From 15a939d234910016d36d4297ec14de51c96168ce Mon Sep 17 00:00:00 2001 From: Hsieh Chin Fan Date: Tue, 13 Aug 2024 23:58:38 +0800 Subject: Initial Commit --- src/editor.mjs | 400 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 400 insertions(+) create mode 100644 src/editor.mjs (limited to 'src/editor.mjs') diff --git a/src/editor.mjs b/src/editor.mjs new file mode 100644 index 0000000..8b0a6ac --- /dev/null +++ b/src/editor.mjs @@ -0,0 +1,400 @@ +import TinyMDE from 'tiny-markdown-editor' +import { markdown2HTML, generateMaps } from './dumbymap' +import { defaultAliasesForRenderer, parseConfigsFromText } from 'mapclay' + +// Set up Editor {{{ + +const HtmlContainer = document.querySelector(".result-html") +const mdeElement = document.querySelector("#tinymde") + +// Add Editor +const defaultContent = '## Links\n\n- [Go to marker](geo:24,121?id=foo,leaflet&text=normal "Link Test")\n\n```map\nid: foo\nuse: maplibre\n```\n' +const lastContent = localStorage.getItem('editorContent') +const tinyEditor = new TinyMDE.Editor({ + element: 'tinymde', + content: lastContent ? lastContent : defaultContent +}); +mdeElement.querySelectorAll('span').forEach(e => e.setAttribute('spellcheck', 'false')) + +// Add command bar for editor +// Use this command to render maps and geoLinks +const mapCommand = { + name: 'map', + title: 'Switch Map Generation', + innerHTML: `
🌏
`, + action: () => { + if (!HtmlContainer.querySelector('.map-container')) { + generateMaps(HtmlContainer) + document.activeElement.blur(); + } else { + markdown2HTML(HtmlContainer, tinyEditor.getContent()) + HtmlContainer.setAttribute('data-layout', 'none') + } + }, + hotkey: 'Ctrl-m' +} +const debugCommand = { + name: 'debug', + title: 'show debug message', + innerHTML: `
🤔
`, + action: () => { + // FIXME + alert(tinyEditor.getContent()) + }, + hotkey: 'Ctrl-i' +} +// Set up command bar +new TinyMDE.CommandBar({ + element: 'tinymde_commandbar', editor: tinyEditor, + commands: [mapCommand, debugCommand, '|', 'h1', 'h2', '|', 'insertLink', 'insertImage', '|', 'bold', 'italic', 'strikethrough', 'code', '|', 'ul', 'ol', '|', 'blockquote'] +}); + +// Render HTML to result container +markdown2HTML(HtmlContainer, tinyEditor.getContent()) + +// FIXME DEBUGONLY +// generateMaps(HtmlContainer) +// setTimeout(() => { +// HtmlContainer.setAttribute("data-layout", 'side') +// }, 500) + +// }}} +// Event Listener: change {{{ + +// Save editor content to local storage, set timeout for 3 seconds +let rejectLastSaving +const saveContent = (content) => { + new Promise((resolve, reject) => { + // If user is typing, the last change cancel previous ones + if (rejectLastSaving) rejectLastSaving(content.length) + rejectLastSaving = reject + + setTimeout(() => { + localStorage.setItem('editorContent', content) + resolve('Content Saved') + }, 3000) + }).catch(() => null) +} + +// Render HTML to result container and save current content +tinyEditor.addEventListener('change', e => { + markdown2HTML(HtmlContainer, e.content) + saveContent(e.content) +}); +// }}} +// Completion in Code Blok {{{ +// Elements about suggestions {{{ +const suggestionsEle = document.createElement('div') +suggestionsEle.classList.add('container__suggestions'); +mdeElement.appendChild(suggestionsEle) + +const rendererOptions = {} + +class Suggestion { + constructor({ text, replace }) { + this.text = text + this.replace = replace + } +} + +// }}} +// {{{ Aliases for map options +const aliasesForMapOptions = {} +const defaultApply = '/default.yml' +fetch(defaultApply) + .then(res => res.text()) + .then(rawText => { + const config = parseConfigsFromText(rawText)?.at(0) + Object.assign(aliasesForMapOptions, config.aliases ?? {}) + }) + .catch(err => console.warn(`Fail to get aliases from ${defaultApply}`, err)) +// }}} +// FUNCTION: Check cursor is inside map code block {{{ +const insideCodeblockForMap = (element) => { + const code = element.closest('.TMFencedCodeBacktick') + if (!code) return false + + let ps = code.previousSibling + if (!ps) return false + + // Look backward to find pattern of code block: /```map/ + while (!ps.classList.contains('TMCodeFenceBacktickOpen')) { + ps = ps.previousSibling + if (!ps) return false + if (ps.classList.contains('TMCodeFenceBacktickClose')) return false + } + + return ps.querySelector('.TMInfoString')?.textContent === 'map' +} +// }}} +// FUNCTION: Get renderer by cursor position in code block {{{ +const getLineWithRenderer = (element) => { + const currentLine = element.closest('.TMFencedCodeBacktick') + if (!currentLine) return null + + // Look backward/forward for pattern of used renderer: /use: .+/ + let ps = currentLine + do { + ps = ps.previousSibling + if (ps.textContent.match(/^use: /)) { + return ps + } else if (ps.textContent.match(/^---/)) { + // If yaml doc separator is found + break + } + } while (ps && ps.classList.contains('TMFencedCodeBacktick')) + + let ns = currentLine + do { + ns = ns.nextSibling + if (ns.textContent.match(/^use: /)) { + return ns + } else if (ns.textContent.match(/^---/)) { + // If yaml doc separator is found + return null + } + } while (ns && ns.classList.contains('TMFencedCodeBacktick')) + + return null +} +// }}} +// FUNCTION: Add suggestions for current selection {{{ +const getSuggestionsForOptions = (optionTyped, validOptions) => { + let suggestOptions = [] + + const matchedOptions = validOptions + .filter(o => o.valueOf().toLowerCase().includes(optionTyped.toLowerCase())) + + if (matchedOptions.length > 0) { + suggestOptions = matchedOptions + } else { + suggestOptions = validOptions + } + + return suggestOptions + .map(o => new Suggestion({ + text: `${o.valueOf()}`, + replace: `${o.valueOf()}: `, + })) +} + +const getSuggestionFromMapOption = (option) => { + if (!option.example) return null + + const text = option.example_desc + ? `${option.example_desc}${option.example}` + : `${option.example}` + + return new Suggestion({ + text: text, + replace: `${option.valueOf()}: ${option.example ?? ""}`, + }) +} + +const getSuggestionsFromAliases = (option) => Object.entries(aliasesForMapOptions[option.valueOf()] ?? {}) + ?.map(record => { + const [alias, value] = record + const valueString = JSON.stringify(value).replaceAll('"', '') + return new Suggestion({ + text: `${alias}${valueString}`, + replace: `${option.valueOf()}: ${valueString}`, + }) + }) + ?? [] + +// Add HTML element for List of suggestions +const addSuggestions = (currentLine, selection) => { + const text = currentLine.textContent + const markInputIsInvalid = (ele) => (ele ?? currentLine).classList.add('invalid-input') + let suggestions = [] + + // Check if "use: " is set + const lineWithRenderer = getLineWithRenderer(currentLine) + const renderer = lineWithRenderer?.textContent.split(' ')[1] + if (renderer) { + + // Do not check properties + if (text.startsWith(' ')) { + return + } + + // If no valid options for current used renderer, go get it! + const validOptions = rendererOptions[renderer] + if (!validOptions) { + // Get list of valid options for current renderer + const rendererUrl = defaultAliasesForRenderer.use[renderer]?.value + import(rendererUrl) + .then(rendererModule => { + rendererOptions[renderer] = rendererModule.default.validOptions + }) + .catch(() => { + markInputIsInvalid(lineWithRenderer) + console.error('Fail to get valid options from renderer, URL is', rendererUrl) + }) + return + } + + // If input is "key:value" (no space left after colon), then it is invalid + const isKeyFinished = text.includes(':'); + const isValidKeyValue = text.match(/^[^:]+:\s+/) + if (isKeyFinished && !isValidKeyValue) { + markInputIsInvalid() + return + } + + // If user is typing option + const keyTyped = text.split(':')[0].trim() + if (!isKeyFinished) { + suggestions = getSuggestionsForOptions(keyTyped, validOptions) + markInputIsInvalid() + } + + // If user is typing value + const matchedOption = validOptions.find(o => o.name === keyTyped) + if (isKeyFinished && !matchedOption) { + markInputIsInvalid() + } + + if (isKeyFinished && matchedOption) { + const valueTyped = text.substring(text.indexOf(':') + 1).trim() + const isValidValue = matchedOption.isValid(valueTyped) + if (!valueTyped) { + suggestions = [ + getSuggestionFromMapOption(matchedOption), + ...getSuggestionsFromAliases(matchedOption) + ].filter(s => s instanceof Suggestion) + } + if (valueTyped && !isValidValue) { + markInputIsInvalid() + } + } + + } else { + // Suggestion for "use" + const rendererSuggestions = Object.entries(defaultAliasesForRenderer.use) + .filter(([renderer,]) => { + const suggestion = `use: ${renderer}` + const suggetionNoSpace = suggestion.replace(' ', '') + const textNoSpace = text.replace(' ', '') + return suggestion !== text && + (suggetionNoSpace.includes(textNoSpace)) + }) + .map(([renderer, info]) => + new Suggestion({ + text: `use: ${renderer}`, + replace: `use: ${renderer}`, + }) + ) + suggestions = rendererSuggestions.length > 0 ? rendererSuggestions : [] + } + + if (suggestions.length === 0) { + suggestionsEle.style.display = 'none'; + return + } else { + suggestionsEle.style.display = 'block'; + } + + suggestionsEle.innerHTML = '' + suggestions.forEach((suggestion) => { + const option = document.createElement('div'); + if (suggestion.text.startsWith('<')) { + option.innerHTML = suggestion.text; + } else { + option.innerText = suggestion.text; + } + option.classList.add('container__suggestion'); + option.onmouseover = () => { + Array.from(suggestionsEle.children).forEach(s => s.classList.remove('focus')) + option.classList.add('focus') + } + option.onmouseout = () => { + option.classList.remove('focus') + } + option.onclick = () => { + const newFocus = { ...selection.focus, col: 0 } + const newAnchor = { ...selection.anchor, col: text.length } + tinyEditor.paste(suggestion.replace, newFocus, newAnchor) + suggestionsEle.style.display = 'none'; + option.classList.remove('focus') + }; + suggestionsEle.appendChild(option); + }); + + const rect = currentLine.getBoundingClientRect(); + suggestionsEle.style.top = `${rect.top + rect.height + 12}px`; + suggestionsEle.style.left = `${rect.right}px`; + suggestionsEle.style.maxWidth = `calc(${window.innerWidth}px - ${rect.right}px - 2rem)`; +} +// }}} +// Event: suggests for current selection {{{ +tinyEditor.addEventListener('selection', selection => { + // To trigger click event on suggestions list, don't set suggestion list invisible + if (suggestionsEle.querySelector('.container__suggestion.focus:hover') !== null) { + return + } else { + suggestionsEle.style.display = 'none'; + } + + // Check selection is inside editor contents + const node = selection?.anchor?.node + if (!node) return + + // Get HTML element for current selection + const element = node instanceof HTMLElement + ? node + : node.parentNode + element.setAttribute('spellcheck', 'false') + + // Do not show suggestion by attribute + if (suggestionsEle.getAttribute('data-keep-close') === 'true') { + suggestionsEle.setAttribute('data-keep-close', 'false') + return + } + + // Show suggestions for map code block + if (insideCodeblockForMap(element)) addSuggestions(element, selection) +}); +// }}} +// Key events for suggestions {{{ +mdeElement.addEventListener('keydown', (e) => { + // Only the following keys are used + const keyForSuggestions = ['Tab', 'Enter', 'Escape'].includes(e.key) + if (!keyForSuggestions || suggestionsEle.style.display === 'none') return; + + // Directly add a newline when no suggestion is selected + const currentSuggestion = suggestionsEle.querySelector('.container__suggestion.focus') + if (!currentSuggestion && e.key === 'Enter') return + + // Override default behavior + e.preventDefault(); + + // Suggestion when pressing Tab or Shift + Tab + const nextSuggestion = currentSuggestion?.nextSibling ?? suggestionsEle.querySelector('.container__suggestion:first-child') + const previousSuggestion = currentSuggestion?.previousSibling ?? suggestionsEle.querySelector('.container__suggestion:last-child') + const focusSuggestion = e.shiftKey ? previousSuggestion : nextSuggestion + + // Current editor selection state + const selection = tinyEditor.getSelection(true) + switch (e.key) { + case 'Tab': + Array.from(suggestionsEle.children).forEach(s => s.classList.remove('focus')) + focusSuggestion.classList.add('focus') + focusSuggestion.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) + break; + case 'Enter': + currentSuggestion.onclick() + suggestionsEle.style.display = 'none'; + break; + case 'Escape': + suggestionsEle.style.display = 'none'; + // Prevent trigger selection event again + suggestionsEle.setAttribute('data-keep-close', 'true') + setTimeout(() => tinyEditor.setSelection(selection), 100) + break; + } +}); +// }}} +// }}} + +// vim: sw=2 ts=2 foldmethod=marker foldmarker={{{,}}} -- cgit v1.2.3-70-g09d2