/* global EasyMDE */ import { markdown2HTML, generateMaps } from './dumbymap' import { defaultAliases, parseConfigsFromYaml } from 'mapclay' import * as menuItem from './MenuItem' /* eslint-disable-next-line no-unused-vars */ import { addMarkerByPoint } from './dumbyUtils.mjs' import { shiftByWindow } from './utils.mjs' import * as tutorial from './tutorial' /** * @typedef {Object} RefLink * @property {string} ref - name of link * @property {string} link - content of link * @property {string|null} title - title of link */ // Set up Containers {{{ /** Variables: page */ const url = new URL(window.location) const pageParams = url.searchParams const crs = pageParams.get('crs') ?? 'EPSG:4326' const initialLayout = pageParams.get('layout') /** Variables: dumbymap and editor **/ const context = document.querySelector('[data-mode]') const textArea = document.querySelector('.editor textarea') const dumbyContainer = document.querySelector('.DumbyMap') dumbyContainer.dataset.scrollLine = '' /** Watch: DumbyMap */ new window.MutationObserver(mutations => { const mutation = mutations.at(-1) // Handle layout change const layout = dumbyContainer.dataset.layout if (layout && (layout !== 'normal' || mutation.oldValue === 'normal')) { context.dataset.mode = '' } }).observe(dumbyContainer, { attributes: true, attributeFilter: ['data-layout'], attributeOldValue: true, }) const dumbymap = generateMaps(dumbyContainer, { crs, initialLayout }) // Set oncontextmenu callback dumbymap.utils.setContextMenu(menuForEditor) /** Variables: Reference Style Links in Markdown */ const refLinkPattern = /\[([^\x5B\x5D]+)\]:\s+(\S+)(\s["'](\S+)["'])?/ let refLinks = [] /** * Appends a reference link to the CodeMirror instance * * @param {CodeMirror} cm - The CodeMirror instance * @param {RefLink} refLink - The reference link to append */ /* eslint-disable-next-line no-unused-vars */ const appendRefLink = (cm, refLink) => { editor.dataset.update = 'false' const { ref, link, title } = refLink let refLinkString = `\n[${ref}]: ${link} "${title ?? ''}"` const lastLineIsRefLink = cm.getLine(cm.lastLine()).match(refLinkPattern) if (!lastLineIsRefLink) refLinkString = '\n' + refLinkString cm.replaceRange(refLinkString, { line: Infinity }) refLinks.push(refLink) } /** * Watch for changes of editing mode * * For 'data-mode' attribute of the context element, if the mode is 'editing' * and the layout is not 'normal', it sets the layout to 'normal' and switch to editing mode */ new window.MutationObserver(() => { const mode = context.dataset.mode const layout = dumbyContainer.dataset.layout if (mode === 'editing' && layout !== 'normal') { dumbyContainer.dataset.layout = 'normal' } }).observe(context, { attributes: true, attributeFilter: ['data-mode'], attributeOldValue: true, }) /** * Toggles the editing mode */ const toggleEditing = () => { const mode = context.dataset.mode if (mode === 'editing') { context.dataset.mode = '' } else { context.dataset.mode = 'editing' } } // }}} // Set up EasyMDE {{{ /** Editor from EasyMDE **/ const editor = new EasyMDE({ element: textArea, initialValue: tutorial.md, 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: 'roll', title: 'Roll a Dice', text: '\u{2684}', action: () => addMapRandomlyByPreset(), }, { name: 'export', title: 'Export current page', text: '\u{1F4BE}', action: () => { }, }, { name: 'hash', title: 'Save content as URL', // text: '\u{1F4BE}', text: '#', action: () => { const state = { content: editor.value() } window.location.hash = encodeURIComponent(JSON.stringify(state)) window.location.search = '' navigator.clipboard.writeText(window.location.href) window.alert('URL updated in address bar, you can save current page as bookmark') }, }, '|', { name: 'undo', title: 'Undo last editing', text: '\u27F2', action: EasyMDE.undo, }, { name: 'redo', text: '\u27F3', title: 'Redo editing', action: EasyMDE.redo, }, '|', { name: 'heading-1', text: 'H1', title: 'Big Heading', action: EasyMDE['heading-1'], }, { name: 'heading-2', text: 'H2', title: 'Medium Heading', action: EasyMDE['heading-2'], }, '|', { name: 'link', text: '\u{1F517}', title: 'Create Link', action: EasyMDE.drawLink, }, { name: 'image', text: '\u{1F5BC}', title: 'Create Image', action: EasyMDE.drawImage, }, '|', { name: 'Bold', text: '\u{1D401}', title: 'Bold', action: EasyMDE.toggleBold, }, { name: 'Italic', text: '\u{1D43C}', title: 'Italic', action: EasyMDE.toggleItalic, }, '|', { name: 'tutorial', text: '\u{2753}', title: 'Reset contents by tutorial', action: () => { editor.value(tutorial.md) refLinks = getRefLinks() cm.focus() cm.setCursor({ line: 0, ch: 0 }) }, }, ], }) /** CodeMirror Instance **/ const cm = editor.codemirror /** * getRefLinks from contents of editor * @return {RefLink[]} refLinks */ const getRefLinks = () => editor.value() .split('\n') .map(line => { const [, ref, link,, title] = line.match(refLinkPattern) ?? [] return { ref, link, title } }) .filter(({ ref, link }) => ref && link) refLinks = getRefLinks() /** * get state of website from hash string * * @param {String} hash */ const getStateFromHash = hash => { const hashValue = hash.substring(1) const stateString = decodeURIComponent(hashValue) try { return JSON.parse(stateString) ?? {} } catch (_) { return {} } } /** * get editor content from hash string * * @param {String} hash */ const getContentFromHash = hash => { const state = getStateFromHash(hash) return state.content } /** Hash and Query Parameters in URL **/ const contentFromHash = getContentFromHash(window.location.hash) window.location.hash = '' if (url.searchParams.get('content') === 'tutorial') { editor.value(tutorial.md) } else if (contentFromHash) { // Seems like autosave would overwrite initialValue, set content from hash here editor.cleanup() editor.value(contentFromHash) } // }}} // Set up logic about editor content {{{ /** * updateScrollLine. Update data attribute by scroll on given element * * @param {HTMLElement} ele */ const updateScrollLine = (ele) => () => { if (textArea.dataset.scrollLine) return const threshold = ele.scrollTop + window.innerHeight / 2 + 30 const block = Array.from(ele.children) .findLast(e => e.offsetTop < threshold) ?? ele.firstChild const line = Array.from(block.querySelectorAll('p')) .findLast(e => e.offsetTop + block.offsetTop < threshold) const linenumber = line?.dataset?.sourceLine if (!linenumber) return const offset = (line.offsetTop + block.offsetTop - ele.scrollTop) if (linenumber) { ele.closest('[data-scroll-line]').dataset.scrollLine = linenumber + '/' + offset } } /** sync scroll from HTML to CodeMirror */ new window.MutationObserver(() => { clearTimeout(dumbyContainer.timer) dumbyContainer.timer = setTimeout( () => { dumbyContainer.dataset.scrollLine = '' }, 50, ) const line = dumbyContainer.dataset.scrollLine if (line) { const [lineNumber, offset] = line.split('/') if (!lineNumber || isNaN(lineNumber)) return cm.scrollIntoView({ line: lineNumber, ch: 0 }, offset) } }).observe(dumbyContainer, { attributes: true, attributeFilter: ['data-scroll-line'], }) /** * updateScrollLineByCodeMirror. * @param {CodeMirror} cm */ const updateCMScrollLine = (cm) => { if (dumbyContainer.dataset.scrollLine) return const lineNumber = cm.getCursor()?.line ?? cm.lineAtHeight(cm.getScrollInfo().top, 'local') textArea.dataset.scrollLine = lineNumber } cm.on('scroll', () => { if (cm.hasFocus()) updateCMScrollLine(cm) }) /** Sync scroll from CodeMirror to HTML **/ new window.MutationObserver(() => { clearTimeout(textArea.timer) textArea.timer = setTimeout( () => { textArea.dataset.scrollLine = '' }, 1000, ) const line = textArea.dataset.scrollLine let lineNumber = Number(line) let p if (!line || isNaN(lineNumber)) return const paragraphs = Array.from(dumbymap.htmlHolder.querySelectorAll('p')) do { p = paragraphs.find(p => Number(p.dataset.sourceLine) === lineNumber) lineNumber++ } while (!p && lineNumber < cm.doc.size) p = p ?? paragraphs.at(-1) if (!p) return const coords = cm.charCoords({ line: lineNumber, ch: 0 }) p.scrollIntoView({ inline: 'start' }) const top = p.getBoundingClientRect().top dumbymap.htmlHolder.scrollBy(0, top - coords.top + 30) }).observe(textArea, { attributes: true, attributeFilter: ['data-scroll-line'], }) /** * addClassToCodeLines. 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() /** * completeForCodeBlock. * * @param {Object} change - codemirror change object */ 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, ch: 0 }, { line, ch: 1 }) cm.replaceSelection(text.repeat(3) + '\n') } // Completion for Code fence if (text === '`' && change.to.ch === 0) { cm.setSelection({ line, ch: 0 }, { 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, ch: 0 }, { line, ch: 2 }) cm.replaceSelection('') } } } /** * menuForEditor. * * @param {Event} event - Event for context menu * @param {HTMLElement} menu - menu of dumbymap */ function menuForEditor (event, menu) { event.preventDefault() if (document.getSelection().type === 'Range' && cm.getSelection() && refLinks.length > 0) { menu.replaceChildren() menu.appendChild(menuItem.addRefLink(cm, refLinks)) } if (context.dataset.mode !== 'editing') { const switchToEditingMode = menuItem.Item({ innerHTML: 'EDIT', onclick: () => (context.dataset.mode = 'editing'), }) menu.appendChild(switchToEditingMode) } // const map = event.target.closest('.mapclay') // if (map) { // const item = menuItem.Item({ // text: 'Add Anchor', // onclick: () => { // let anchorName // do { // anchorName = window.prompt(anchorName ? 'Name exists' : 'Name of Anchor') // } while (refLinks.find(ref => ref === anchorName)) // if (anchorName === null) return // // const marker = addMarkerByPoint({ point: [event.clientX, event.clientY], map }) // const refLink = { // ref: anchorName, // link: `geo:${marker.dataset.xy.split(',').reverse()}`, // } // appendRefLink(cm, refLink) // }, // }) // menu.insertBefore(item, menu.firstChild) // } } /** * update content of HTML about Dumbymap */ const updateDumbyMap = (callback = null) => { markdown2HTML(dumbyContainer, editor.value()) // Set onscroll callback const htmlHolder = dumbymap.htmlHolder dumbymap.htmlHolder.onscroll = updateScrollLine(htmlHolder) callback?.(dumbymap) } updateDumbyMap() // Re-render HTML by editor content cm.on('change', (_, change) => { if (editor.dataset?.update !== 'false') { textArea.dataset.scrollLine = cm.getCursor().line updateDumbyMap(() => { updateCMScrollLine(cm) }) } else { delete editor.dataset.update } addClassToCodeLines() completeForCodeBlock(change) }) // Set class for focus cm.on('focus', () => { cm.getWrapperElement().classList.add('focus') dumbyContainer.classList.remove('focus') }) cm.on('beforeChange', (_, change) => { // Don't allow more content after YAML doc separator if (change.origin && change.origin.match(/^(\+input|paste)$/)) { const line = change.to.line 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) } // }}} // Completion in Code Blok {{{ // Elements about suggestions {{{ const menu = document.createElement('div') menu.className = 'menu editor-menu' menu.style.display = 'none' menu.onclick = () => (menu.style.display = 'none') new window.MutationObserver(() => { if (menu.style.display === 'none') { menu.replaceChildren() } }).observe(menu, { attributes: true, attributeFilter: ['style'], }) document.body.append(menu) const rendererOptions = {} // }}} // Aliases for map options {{{ const aliasesForMapOptions = {} const defaultApply = './assets/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)) // }}} /** * insideCodeblockForMap. Check if current token is inside code block {{{ * * @param {Anchor} anchor */ 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 } // }}} /** * getLineWithRenderer. Get Renderer by cursor position in code block {{{ * * @param {Object} anchor - Codemirror Anchor Object */ 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 } // }}} /** * getSuggestionsForOptions. Return suggestions for valid options {{{ * * @param {Boolean} optionTyped * @param {Object[]} validOptions */ 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 => menuItem.Suggestion({ text: `${o.valueOf()}`, replace: `${o.valueOf()}: `, cm, }), ) } // }}} /** * getSuggestionFromMapOption. Return suggestion for example of option value {{{ * * @param {Object} option */ const getSuggestionFromMapOption = option => { if (!option.example) return null const text = option.example_desc ? `${option.example_desc}${option.example}` : `${option.example}` return menuItem.Suggestion({ text, replace: `${option.valueOf()}: ${option.example ?? ''}`, cm, }) } // }}} /** * getSuggestionsFromAliases. Return suggestions from aliases {{{ * * @param {Object} option */ const getSuggestionsFromAliases = option => Object.entries(aliasesForMapOptions[option.valueOf()] ?? {})?.map(record => { const [alias, value] = record const valueString = JSON.stringify(value).replaceAll('"', '') return menuItem.Suggestion({ text: `${alias}${valueString}`, replace: `${option.valueOf()}: ${valueString}`, cm, }) }) ?? [] // }}} /** * handleTypingInCodeBlock. Handler for map codeblock {{{ * * @param {Object} anchor - Codemirror Anchor Object */ 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) } } // }}} /** * getSuggestions. Get suggestions by current input {{{ * * @param {Object} anchor - Codemirror Anchor Object */ 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]) => menuItem.Suggestion({ text: `use: ${renderer}`, replace: `use: ${renderer}`, cm, }), ) return rendererSuggestions.length === 0 ? [] : [ ...rendererSuggestions, menuItem.Item({ innerHTML: 'More...', className: ['suggestion'], onclick: () => window.open('https://github.com/outdoorsafetylab/mapclay#renderer', '_blank'), }), ] } return [] } // }}} /** * addSuggestions. Show element about suggestions {{{ * * @param {Object} anchor - Codemirror Anchor Object * @param {Suggestion[]} suggestions */ const addSuggestions = (anchor, suggestions) => { if (suggestions.length === 0) { menu.style.display = 'none' return } else { menu.style.display = 'block' } menu.innerHTML = '' suggestions .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.display = 'block' shiftByWindow(menu) } // }}} // 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', () => { refLinks = getRefLinks() if (menu.checkVisibility()) { cm.focus() } else { cm.getWrapperElement().classList.remove('focus') dumbyContainer.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('.menu-item.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('.menu-item:first-child') const previousSuggestion = currentSuggestion?.previousSibling ?? menu.querySelector('.menu-item:last-child') const focusSuggestion = e.shiftKey ? previousSuggestion : nextSuggestion // Current editor selection state 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': if (!menu.checkVisibility()) break // HACK delay menu display change for blur event, mark cm focus should keep setTimeout(() => (menu.style.display = 'none'), 50) break } }) document.onkeydown = e => { if (document.activeElement.matches('textarea')) return null 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() } } } // }}} // }}} /** * addMapRandomlyByPreset. insert random text of valid mapclay yaml into editor */ const addMapRandomlyByPreset = () => { const yamlText = [ 'apply: ./assets/default.yml', 'width: 85%', 'height: 200px', ] const order = [ 'id', 'apply', 'use', 'width', 'height', 'center', 'XYZ', 'zoom', ] const aliasesEntries = Object.entries(aliasesForMapOptions) .filter(([key, _]) => order.includes(key) && !yamlText.find(text => text.startsWith(key)), ) if (aliasesEntries.length === 0) return aliasesEntries.forEach(([option, aliases]) => { const entries = Object.entries(aliases) const validEntries = entries .filter(([alias, value]) => { // FIXME logic about picking XYZ data if (option === 'XYZ') { const inTaiwan = yamlText.find(text => text.match(/center: TAIWAN/)) if (!inTaiwan) return !alias.includes('TAIWAN') } if (option === 'zoom') { return value > 6 && value < 15 } return true }) const randomValue = validEntries .at((Math.random() * validEntries.length) | 0) .at(0) yamlText.push(`${option}: ${typeof randomValue === 'object' ? randomValue.value : randomValue}`) if (option === 'center') yamlText.push(`id: ${randomValue}`) }) yamlText.sort((a, b) => order.indexOf(a.split(':')[0]) > order.indexOf(b.split(':')[0]), ) const anchor = cm.getCursor() cm.replaceRange( '\n```map\n' + yamlText.join('\n') + '\n```\n', anchor, ) } cm.getWrapperElement().oncontextmenu = e => { if (insideCodeblockForMap(cm.getCursor())) return e.preventDefault() if (cm.getSelection() && refLinks.length > 0) { menu.appendChild(menuItem.addRefLink(cm, refLinks)) } if (menu.children.length > 0) { menu.style.cssText = `display: block; transform: translate(${e.x}px, ${e.y}px); overflow: visible;` } } /** HACK Sync selection from HTML to CodeMirror */ document.addEventListener('selectionchange', () => { if (cm.hasFocus() || dumbyContainer.onmousemove) { return } const selection = document.getSelection() if (selection.type === 'Range') { const content = selection.getRangeAt(0).toString() const parentWithSourceLine = selection.anchorNode.parentElement.closest('.source-line') const lineStart = Number(parentWithSourceLine?.dataset?.sourceLine ?? NaN) const nextSourceLine = parentWithSourceLine?.nextSibling?.dataset?.sourceLine const lineEnd = Number(nextSourceLine) ?? cm.doc.size // TODO Also return when range contains anchor element if (content.includes('\n') || isNaN(lineStart)) { cm.setSelection(cm.getCursor()) return } const texts = [content] let sibling = selection.anchorNode.previousSibling while (sibling) { texts.push(sibling.textContent) sibling = sibling.previousSibling } const anchor = { line: lineStart, ch: 0 } texts .filter(t => t && t !== '\n') .map(t => t.replace('\n', '')) .reverse() .forEach(text => { let index = cm.getLine(anchor.line)?.indexOf(text, anchor.ch) while (index === -1) { anchor.line += 1 anchor.ch = 0 if (anchor.line >= lineEnd) return index = cm.getLine(anchor.line).indexOf(text) } anchor.ch = index + text.length }) const focus = { line: anchor.line, ch: anchor.ch - content.length } cm.setSelection(focus, anchor) cm.scrollIntoView(focus) } })