/*global EasyMDE*/ /*eslint no-undef: "error"*/ import { markdown2HTML, generateMaps } from './dumbymap'; import { defaultAliases, parseConfigsFromYaml } from 'mapclay'; import * as menuItem from './MenuItem'; // Set up Containers {{{ const HtmlContainer = document.querySelector('.DumbyMap'); const textArea = document.querySelector('.editor textarea'); let dumbymap; const toggleEditing = () => { if (document.body.getAttribute('data-mode') === 'editing') { document.body.removeAttribute('data-mode'); } else { document.body.setAttribute('data-mode', 'editing'); } HtmlContainer.setAttribute('data-layout', 'normal'); }; // }}} // Set up EasyMDE {{{ // Content values for 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 editor = new EasyMDE({ element: textArea, initialValue: defaultContent, autosave: { enabled: true, uniqueId: 'dumbymap', }, indentWithTabs: false, lineNumbers: true, promptURLs: true, uploadImage: true, spellChecker: false, toolbarButtonClassPrefix: 'mde', status: false, shortcuts: { map: 'Ctrl-Alt-M', debug: 'Ctrl-Alt-D', toggleUnorderedList: null, toggleOrderedList: null, }, toolbar: [ { name: 'map', title: 'Toggle Map Generation', text: '🌏', action: () => toggleEditing(), }, { name: 'debug', title: 'Save content as URL', text: '🤔', action: () => { const state = { content: editor.value() }; window.location.hash = encodeURIComponent(JSON.stringify(state)); 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", ], }); const cm = editor.codemirror; const getStateFromHash = hash => { const hashValue = hash.substring(1); const stateString = decodeURIComponent(hashValue); try { return JSON.parse(stateString) ?? {}; } catch (_) { return {}; } }; const getContentFromHash = hash => { const state = getStateFromHash(hash); return state.content; }; const initialState = getStateFromHash(window.location.hash); window.location.hash = ''; const contentFromHash = initialState.content; // Seems like autosave would overwrite initialValue, set content from hash here if (contentFromHash) { editor.cleanup(); editor.value(contentFromHash); } // }}} // Set up logic about editor content {{{ const afterMapRendered = _ => { // mapHolder.oncontextmenu = (event) => { // event.preventDefault() // const lonLat = mapHolder.renderer.unproject([event.x, event.y]) // // TODO... // } }; markdown2HTML(HtmlContainer, editor.value()); dumbymap = generateMaps(HtmlContainer, afterMapRendered); // Quick hack to style lines inside code block const addClassToCodeLines = () => { const lines = cm.getLineHandle(0).parent.lines; let insideCodeBlock = false; lines.forEach((line, index) => { if (line.text.match(/^[\u0060]{3}/)) { insideCodeBlock = !insideCodeBlock; } else if (insideCodeBlock) { cm.addLineClass(index, 'text', 'inside-code-block'); } else { cm.removeLineClass(index, 'text', 'inside-code-block'); } }); }; addClassToCodeLines(); const completeForCodeBlock = change => { const line = change.to.line; if (change.origin === '+input') { const text = change.text[0]; // Completion for YAML doc separator if ( text === '-' && change.to.ch === 0 && insideCodeblockForMap(cm.getCursor()) ) { cm.setSelection({ line: line, ch: 0 }, { line: line, ch: 1 }); cm.replaceSelection(text.repeat(3) + '\n'); } // Completion for Code fence if (text === '`' && change.to.ch === 0) { cm.setSelection({ line: line, ch: 0 }, { line: line, ch: 1 }); cm.replaceSelection(text.repeat(3)); const numberOfFences = cm .getValue() .split('\n') .filter(line => line.match(/[\u0060]{3}/)).length; if (numberOfFences % 2 === 1) { cm.replaceSelection('map\n\n```'); cm.setCursor({ line: line + 1 }); } } } // For YAML doc separator,
and code fence // Auto delete to start of line if (change.origin === '+delete') { const match = change.removed[0].match(/^[-\u0060]$/)?.at(0); if (match && cm.getLine(line) === match.repeat(2) && match) { cm.setSelection({ line: line, ch: 0 }, { line: line, ch: 2 }); cm.replaceSelection(''); } } }; const debounceForMap = (() => { let timer = null; return function (...args) { dumbymap = generateMaps.apply(this, args); // clearTimeout(timer); // timer = setTimeout(() => { // dumbymap = generateMaps.apply(this, args) // }, 10); }; })(); const updateDumbyMap = () => { markdown2HTML(HtmlContainer, editor.value()); // TODO Test if generate maps intantly is OK with map cache // debounceForMap(HtmlContainer, afterMapRendered) dumbymap = generateMaps(HtmlContainer, afterMapRendered); }; updateDumbyMap(); // Re-render HTML by editor content cm.on('change', (_, change) => { updateDumbyMap(); addClassToCodeLines(); completeForCodeBlock(change); }); // Set class for focus cm.on('focus', () => { cm.getWrapperElement().classList.add('focus'); HtmlContainer.classList.remove('focus'); }); cm.on('beforeChange', (_, change) => { const line = change.to.line; // Don't allow more content after YAML doc separator if (change.origin.match(/^(\+input|paste)$/)) { if (cm.getLine(line) === '---' && change.text[0] !== '') { change.cancel(); } } }); // Reload editor content by hash value window.onhashchange = () => { const content = getContentFromHash(window.location.hash); if (content) editor.value(content); }; // FIXME DEBUGONLY // generateMaps(HtmlContainer) // setTimeout(() => { // HtmlContainer.setAttribute("data-layout", 'side') // }, 500) // }}} // Completion in Code Blok {{{ // Elements about suggestions {{{ const menu = document.createElement('div'); menu.id = 'menu'; menu.onclick = () => (menu.style.display = 'none'); document.body.append(menu); const rendererOptions = {}; // }}} // Aliases for map options {{{ const aliasesForMapOptions = {}; const defaultApply = './dist/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 if current token is inside code block {{{ const insideCodeblockForMap = anchor => { const token = cm.getTokenAt(anchor); const insideCodeBlock = token.state.overlay.codeBlock && !cm.getLine(anchor.line).match(/^[\u0060]{3}/); if (!insideCodeBlock) return false; let line = anchor.line - 1; while (line >= 0) { const content = cm.getLine(line); if (content === '```map') { return true; } else if (content === '```') { return false; } line = line - 1; } return false; }; // }}} // FUNCTION: Get Renderer by cursor position in code block {{{ const getLineWithRenderer = anchor => { const currentLine = anchor.line; if (!cm.getLine) return null; const match = line => cm.getLine(line).match(/^use: /); if (match(currentLine)) return currentLine; // Look backward/forward for pattern of used renderer: /use: .+/ let pl = currentLine - 1; while (pl > 0 && insideCodeblockForMap(anchor)) { const text = cm.getLine(pl); if (match(pl)) { return pl; } else if (text.match(/^---|^[\u0060]{3}/)) { break; } pl = pl - 1; } let nl = currentLine + 1; while (insideCodeblockForMap(anchor)) { const text = cm.getLine(nl); if (match(nl)) { return nl; } else if (text.match(/^---|^[\u0060]{3}/)) { return null; } nl = nl + 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 menuItem.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 menuItem.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 menuItem.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); // Clear marks on text cm.findMarks({ ...anchor, ch: 0 }, { ...anchor, ch: text.length }).forEach( m => m.clear(), ); // Mark user input invalid by case const markInputIsInvalid = () => cm .getDoc() .markText( { ...anchor, ch: 0 }, { ...anchor, ch: text.length }, { className: 'invalid-input' }, ); // Check if "use: " is set const lineWithRenderer = getLineWithRenderer(anchor); const renderer = lineWithRenderer ? cm.getLine(lineWithRenderer).split(' ')[1] : null; if (renderer && anchor.line !== lineWithRenderer) { // 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 = defaultAliases.use[renderer]?.value; import(rendererUrl) .then(rendererModule => { rendererOptions[renderer] = rendererModule.default.validOptions; const currentAnchor = cm.getCursor(); if (insideCodeblockForMap(currentAnchor)) { handleTypingInCodeBlock(currentAnchor); } }) .catch(_ => { markInputIsInvalid(lineWithRenderer); console.warn( `Fail to get valid options from Renderer typed: ${renderer}`, ); }); 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) { markInputIsInvalid(); return getSuggestionsForOptions(keyTyped, validOptions); } // 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) { return [ getSuggestionFromMapOption(matchedOption), ...getSuggestionsFromAliases(matchedOption), ].filter(s => s instanceof menuItem.Suggestion); } if (valueTyped && !isValidValue) { markInputIsInvalid(); return []; } } } else { // Suggestion for "use" const rendererSuggestions = Object.entries(defaultAliases.use) .filter(([renderer]) => { const suggestion = `use: ${renderer}`; const suggestionPattern = suggestion.replace(' ', '').toLowerCase(); const textPattern = text.replace(' ', '').toLowerCase(); return suggestion !== text && suggestionPattern.includes(textPattern); }) .map( ([renderer, info]) => new menuItem.Suggestion({ text: `use: ${renderer}`, replace: `use: ${renderer}`, }), ); return rendererSuggestions.length > 0 ? rendererSuggestions : []; } return []; }; // }}} // {{{ FUNCTION: Show element about suggestions const addSuggestions = (anchor, suggestions) => { if (suggestions.length === 0) { menu.style.display = 'none'; return; } else { menu.style.display = 'block'; } menu.innerHTML = ''; suggestions .map(s => s.createElement(cm)) .forEach(option => menu.appendChild(option)); const widgetAnchor = document.createElement('div'); cm.addWidget(anchor, widgetAnchor, true); const rect = widgetAnchor.getBoundingClientRect(); menu.style.left = `calc(${rect.left}px + 2rem)`; menu.style.top = `calc(${rect.bottom}px + 1rem)`; menu.style.maxWidth = `calc(${window.innerWidth}px - ${rect.x}px - 3rem)`; menu.style.display = 'block'; }; // }}} // EVENT: Suggests for current selection {{{ // FIXME Dont show suggestion when selecting multiple chars cm.on('cursorActivity', _ => { menu.style.display = 'none'; const anchor = cm.getCursor(); if (insideCodeblockForMap(anchor)) { handleTypingInCodeBlock(anchor); } }); cm.on('blur', () => { menu.style.display = 'none'; cm.getWrapperElement().classList.remove('focus'); HtmlContainer.classList.add('focus'); }); // }}} // EVENT: keydown for suggestions {{{ const keyForSuggestions = ['Tab', 'Enter', 'Escape']; cm.on('keydown', (_, e) => { if ( !cm.hasFocus || !keyForSuggestions.includes(e.key) || menu.style.display === 'none' ) return; // Directly add a newline when no suggestion is selected const currentSuggestion = menu.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 ?? menu.querySelector('.container__suggestion:first-child'); const previousSuggestion = currentSuggestion?.previousSibling ?? menu.querySelector('.container__suggestion:last-child'); const focusSuggestion = e.shiftKey ? previousSuggestion : nextSuggestion; // Current editor selection state const anchor = cm.getCursor(); switch (e.key) { case 'Tab': Array.from(menu.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': menu.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') { toggleEditing(); e.preventDefault(); return null; } if (!cm.hasFocus()) { if (e.key === 'F1') { e.preventDefault(); cm.focus(); } if (e.key === 'Tab') { e.preventDefault(); dumbymap.utils.focusNextMap(e.shiftKey); } if (e.key === 'x' || e.key === 'X') { e.preventDefault(); dumbymap.utils.switchToNextLayout(e.shiftKey); } if (e.key === 'n') { e.preventDefault(); dumbymap.utils.focusNextBlock(); } if (e.key === 'p') { e.preventDefault(); dumbymap.utils.focusNextBlock(true); } if (e.key === 'Escape') { e.preventDefault(); dumbymap.utils.removeBlockFocus(); } } }; // }}} // }}} // Layout Switch {{{ const layoutObserver = new MutationObserver(() => { const layout = HtmlContainer.getAttribute('data-layout'); if (layout !== 'normal') { document.body.removeAttribute('data-mode'); } }); layoutObserver.observe(HtmlContainer, { attributes: true, attributeFilter: ['data-layout'], attributeOldValue: true, }); // }}} // ContextMenu {{{ document.oncontextmenu = e => { if (cm.hasFocus()) return; const selection = document.getSelection(); const range = selection.getRangeAt(0); if (selection) { e.preventDefault(); menu.innerHTML = ''; menu.style.cssText = `display: block; left: ${e.clientX + 10}px; top: ${e.clientY + 5}px;`; const addGeoLink = new menuItem.GeoLink({ range }); menu.appendChild(addGeoLink.createElement()); } menu.appendChild(menuItem.nextMap.bind(dumbymap)()); menu.appendChild(menuItem.nextBlock.bind(dumbymap)()); menu.appendChild(menuItem.nextLayout.bind(dumbymap)()); }; const actionOutsideMenu = e => { if (menu.style.display === 'none' || cm.hasFocus()) return; const rect = menu.getBoundingClientRect(); if ( e.clientX < rect.left || e.clientX > rect.left + rect.width || e.clientY < rect.top || e.clientY > rect.top + rect.height ) { menu.style.display = 'none'; } }; document.addEventListener('click', actionOutsideMenu); // }}} // vim: sw=2 ts=2 foldmethod=marker foldmarker={{{,}}}