import TinyMDE from 'tiny-markdown-editor' import { markdown2HTML, generateMaps } from './dumbymap' import { defaultAliasesForRenderer, parseConfigsFromYaml } 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 cancelLastSave const saveContent = (content) => { new Promise((resolve, reject) => { // If user is typing, the last change cancel previous ones if (cancelLastSave) cancelLastSave(content.length) cancelLastSave = reject setTimeout(() => { localStorage.setItem('editorContent', content) resolve('Content Saved') }, 3000) }).catch((err) => console.warn('Fail to save content', err)) } // 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 = parseConfigsFromYaml(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: Return suggestions for valid options {{{ 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()}: `, })) } // }}} // FUNCTION: Return suggestion for example of option value {{{ 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 ?? ""}`, }) } // }}} // FUNCTION: Return suggestions from aliases {{{ 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}`, }) }) ?? [] // }}} // FUNCTION: 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) }); // }}} // EVENT: keydown 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={{{,}}}