import { markdown2HTML, generateMaps } from './dumbymap' import { defaultAliasesForRenderer, parseConfigsFromYaml } from 'mapclay' // Set up Editor {{{ const HtmlContainer = document.querySelector(".result-html") const textArea = document.querySelector(".editor textarea") const toggleMaps = (container) => { if (!container.querySelector('.Showcase')) { generateMaps(container) document.activeElement.blur(); } else { markdown2HTML(HtmlContainer, editor.value()) container.setAttribute('data-layout', 'none') } } // Content values for editor const getContentFromHash = (cleanHash = false) => { const hashValue = location.hash.substring(1); if (cleanHash) window.location.hash = '' return hashValue.startsWith('text=') ? decodeURIComponent(hashValue.substring(5)) : null } const contentFromHash = getContentFromHash(true) const lastContent = localStorage.getItem('editorContent') 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' // Set up EasyMDE {{{ const editor = new EasyMDE({ element: textArea, indentWithTabs: false, initialValue: contentFromHash ?? lastContent ?? defaultContent, lineNumbers: true, promptURLs: true, uploadImage: true, autosave: { enabled: true, uniqueId: 'dumbymap', }, spellChecker: false, toolbarButtonClassPrefix: 'mde', status: false, shortcuts: { "map": "Ctrl-Alt-M", "debug": "Ctrl-Alt-D", "toggleUnorderedList": "Ctrl-Shift-L", }, toolbar: [ { name: 'map', title: 'Toggle Map Generation', text: "🌏", action: toggleMaps(HtmlContainer), }, { name: 'debug', title: 'Save content as URL', text: "🤔", action: () => { window.location.hash = '#text=' + encodeURIComponent(editor.value()) navigator.clipboard.writeText(window.location.href) alert('URL copied to clipboard') }, }, 'undo', 'redo', '|', 'heading-1', 'heading-2', '|', 'link', 'image', '|', 'bold', 'italic', 'strikethrough', 'code', 'clean-block', '|', 'unordered-list', 'ordered-list', 'quote', 'table', '|', 'fullscreen' ], }); const cm = editor.codemirror markdown2HTML(HtmlContainer, editor.value()) cm.on("change", () => { markdown2HTML(HtmlContainer, editor.value()) }) // }}} // Reload editor content by hash value onhashchange = () => { const contentFromHash = getContentFromHash() if (contentFromHash) editor.value(contentFromHash) } // FIXME DEBUGONLY // generateMaps(HtmlContainer) // setTimeout(() => { // HtmlContainer.setAttribute("data-layout", 'side') // }, 500) // }}} // Completion in Code Blok {{{ // Elements about suggestions {{{ const suggestionsEle = document.createElement('div') suggestionsEle.classList.add('container__suggestions'); 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 = (currentLine) => { // let tokens = cm.getLineTokens(currentLine) // // if (!tokens.includes("comment") || tokens.includes('formatting-code-block')) return false // // do { // line = line - 1 // if (line < 0) return false // tokens = cm.getLineTokens(line) // } while (!tokens.includes('formatting-code-block')) // // return true // } // }}} // Check if current token is inside code block {{{ const insideCodeblockForMap = (token) => token.state.overlay.codeBlock && !token.string.match(/^````*/) // }}} // FUNCTION: Get renderer by cursor position in code block {{{ const getLineWithRenderer = (anchor) => { const currentLine = anchor.line const match = (line) => cm.getLine(line).match(/^use: /) if (match(currentLine)) return currentLine const getToken = (line) => cm.getTokenAt({ line: line, ch: 1 }) // Look backward/forward for pattern of used renderer: /use: .+/ let ps = currentLine - 1 while (ps > 0 && insideCodeblockForMap(getToken(ps))) { if (match(ps)) { return ps } else if (cm.getLine(ps).match(/^---/)) { // If yaml doc separator is found break } ps = ps - 1 } let ns = currentLine + 1 while (insideCodeblockForMap(getToken(ns))) { if (match(ns)) { return ns } else if (cm.getLine(ns).match(/^---/)) { // If yaml doc separator is found return null } ns = ns + 1 } 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}`, }) }) ?? [] // }}} // FUCNTION: Handler for map codeblock {{{ const handleTypingInCodeBlock = (anchor) => { const text = cm.getLine(anchor.line) if (text.match(/^\s\+$/) && text.length % 2 !== 0) { // TODO Completion for even number of spaces } else if (text.match(/^-/)) { // TODO Completion for YAML doc separator } else { const suggestions = getSuggestions(anchor) addSuggestions(anchor, suggestions) } } // }}} // FUNCTION: get suggestions by current input {{{ const getSuggestions = (anchor) => { const text = cm.getLine(anchor.line) const markInputIsInvalid = () => cm.getDoc().markText( { ...anchor, ch: 0 }, { ...anchor, ch: -1 }, { className: 'invalid-input' }, ) let suggestions = [] // Check if "use: " is set const lineWithRenderer = getLineWithRenderer(anchor) const renderer = cm.getLine(lineWithRenderer).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.warn(`Fail to get valid options from renderer with URL ${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 : [] } return suggestions } // }}} // {{{ FUNCTION: Show element about suggestions const addSuggestions = (anchor, suggestions) => { 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 = () => { cm.setSelection(anchor, { ...anchor, ch: 0 }) cm.replaceSelection(suggestion.replace) cm.focus(); const newAnchor = { ...anchor, ch: suggestion.replace.length } cm.setCursor(newAnchor); }; suggestionsEle.appendChild(option); }); cm.addWidget(anchor, suggestionsEle, true) const rect = suggestionsEle.getBoundingClientRect() suggestionsEle.style.maxWidth = `calc(${window.innerWidth}px - ${rect.x}px - 2rem)`; suggestionsEle.style.display = 'block' } // }}} // EVENT: suggests for current selection {{{ // FIXME Dont show suggestion when selecting multiple chars cm.on("beforeSelectionChange", (_, obj) => { const anchor = (obj.ranges[0].anchor) const token = cm.getTokenAt(anchor) if (insideCodeblockForMap(token)) { handleTypingInCodeBlock(anchor) } }); // }}} // EVENT: keydown for suggestions {{{ cm.on('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 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() break; case 'Escape': const anchor = cm.getCursor() suggestionsEle.style.display = 'none'; // Focus editor again setTimeout(() => cm.focus() && cm.setCursor(anchor), 100) break; } }); document.onkeydown = (e) => { if (e.altKey && e.ctrlKey && e.key === 'm') { toggleMaps(HtmlContainer) } } // }}} // }}} // vim: sw=2 ts=2 foldmethod=marker foldmarker={{{,}}}