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/css/css | 1 + src/css/dumbymap.css | 206 ++++++++++++++++++++++++ src/css/index.css | 80 ++++++++++ src/css/style.css | 114 ++++++++++++++ src/css/tiny-mde.min.css | 1 + src/dumbymap.mjs | 357 ++++++++++++++++++++++++++++++++++++++++++ src/editor.mjs | 400 +++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 1159 insertions(+) create mode 120000 src/css/css create mode 100644 src/css/dumbymap.css create mode 100644 src/css/index.css create mode 100644 src/css/style.css create mode 100644 src/css/tiny-mde.min.css create mode 100644 src/dumbymap.mjs create mode 100644 src/editor.mjs (limited to 'src') diff --git a/src/css/css b/src/css/css new file mode 120000 index 0000000..9913b8c --- /dev/null +++ b/src/css/css @@ -0,0 +1 @@ +/home/pham/git/dumbymap/src/css \ No newline at end of file diff --git a/src/css/dumbymap.css b/src/css/dumbymap.css new file mode 100644 index 0000000..bfdcc2b --- /dev/null +++ b/src/css/dumbymap.css @@ -0,0 +1,206 @@ +[class^="leader-line"] { + z-index: 9999; +} + +.with-leader-line:not(:has(> *)) { + display: inline-block; + left: 0px; + top: 0px; + + padding-right: 15px; + padding-left: 6px; + + background-image: url("data:image/svg+xml;charset=utf-8;base64,PHN2ZyB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0Ij48cG9seWdvbiBwb2ludHM9IjI0LDAgMCw4IDgsMTEgMCwxOSA1LDI0IDEzLDE2IDE2LDI0IiBmaWxsPSJjb3JhbCIvPjwvc3ZnPg=="); + background-size: 12px 12px; + background-repeat: no-repeat; + background-position: right 2px top 2px; + color: #555; + + &.geolink { + background-color: rgb(248, 248, 129); + } + &.doclink { + background-color: #9ee7ea; + } + + &:hover { + padding-right: 6px; + background-image: none; + + font-weight: bolder; + text-decoration: none; + } +} + +.result-html { + position: relative; + max-width: 60em; + margin: 0 auto; + padding: 0; + display: flex; + overflow-x: scroll; + /* height: 100vh; */ + + #map { + flex: 0; + #mapPlaceholder { + display: none; + } + } + + #markdown { + flex: 1; + padding: 20px; + overflow-y: scroll; + + > .draggable-block { + width: 100%; + position: relative; + pointer-events: auto; + border-radius: 0.5rem; + padding: 0.5rem; + background-color: white; + margin-bottom: 3rem; + + * { + width: fit-content; + } + + .draggable { + display: none; + } + } + + pre:has(.map-container) { + display: flex; + justify-content: flex-start; + width: 100%; + background-color: inherit; + } + + .map-container { + margin: 2px; + &.focus { + border: solid gray; + } + } + + #mapPlaceholder { + flex: 1; + display: flex; + justify-content: center; + align-items: center; + background-color: lightgray; + > * { + width: fit-content; + flex: 0 0; + } + } + } + + :has(> .map-container) { + display: flex; + width: fit-content; + } + + #map .map-container { + width: 100% !important; + height: 100% !important; + } +} + +.result-html[data-layout]:not([data-layout=none]) { + margin: 0; + + height: 100vh; + width: 100%; + max-width: none; + border: none; + + & ~ :not(.leader-line) { + display: none; + pointer-events: none; + } +} + +.result-html[data-layout=side] { + #map, + #markdown { + flex: 50%; + overflow-y: scroll; + height: 100vh; + } +} + +.result-html[data-layout=overlay] { + #map, + #markdown { + position: fixed; + height: 100%; + width: 100%; + } + + #markdown { + font-size: 12px; + pointer-events: none; + + > .draggable-block { + box-sizing: content-box; + width: fit-content; + max-height: 50vh; + overflow: scroll; + border: solid gray; + + * { + max-width: calc(100vw/4); + } + + .draggable { + width: 100%; + display: block; + top: 0; + left: 0; + position: sticky; + margin-bottom: -18px; + opacity: 0.3; + transform: translate(0, -16px); + text-align: center; + z-index: 100; + transition: all 0.3s ease-out; + &:hover { + background-color: lightgray; + } + } + } + > :not(.draggable-block) { + display: none; + } + } +} + +*:has(> nav) { + position: absolute; + right: 10px; + bottom: 10px; + /* FIXME */ + display: none; + + padding: 0.5rem; + min-width: 120px; + border: 2px solid gray; + border-radius: 0.5rem; + background-color: white; + z-index: 500; + + &:has(> nav:empty) { + display: none; + } + ol { + margin-top: 0; + margin-bottom: 0.5rem; + } +} + +.bold-options { + font-weight: bold; +} diff --git a/src/css/index.css b/src/css/index.css new file mode 100644 index 0000000..adc1cfe --- /dev/null +++ b/src/css/index.css @@ -0,0 +1,80 @@ +body { + display: flex; + justify-items: stretch; +} + +.result-html { + flex: 1; + height: calc(100vh - 20px); + border: solid lightgray 2px; + border-radius: 5px; + margin: 10px; +} + +.editor { + flex: 1; + max-width: 50vw; + margin: 10px; +} + +.TinyMDE { + height: calc(100vh - 55px); + padding: 16px; + overflow-y: scroll; + border: 2px solid lightgray; + border-radius: 5px; + + span { + white-space: pre; + } + .invalid-input { + text-decoration: red wavy underline 1px; + } +} + +.container__suggestions { + display: none; + position: absolute; + width: fit-content; + min-width: 10rem; + max-height: 40vh; + overflow-y: scroll; + border: 2px solid lightgray; + border-radius: 0.5rem; + + background: #fff; +} +.container__suggestion { + cursor: pointer; + display: flex; + justify-content: space-between; + overflow: hidden; + + height: fit-content; + min-height: 2rem; + white-space: nowrap; + align-items: center; + + * { + flex-shrink: 0; + display: inline-block; + overflow: hidden; + padding-inline: 1em; + } + .info { + color: #4682B4; + font-weight: bold; + } + .truncate { + text-overflow: ellipsis; + ::before { + width: 2rem + } + } +} +.container__suggestion:not(:first-child) { + border-top: 1px solid rgb(203 213 225); +} +.container__suggestion.focus { + background: rgb(226 232 240); +} diff --git a/src/css/style.css b/src/css/style.css new file mode 100644 index 0000000..e650c1e --- /dev/null +++ b/src/css/style.css @@ -0,0 +1,114 @@ +/* CSS Reset */ +*, *::before, *::after { + box-sizing: border-box; +} + +* { + margin: 0; + line-height: calc(1em + 0.5rem); +} + +img, picture, video, canvas, svg { + display: block; + max-width: 100%; +} + +input, button, textarea, select { + font: inherit; +} + +p, h1, h2, h3, h4, h5, h6 { + overflow-wrap: break-word; + hyphens: auto; +} +/* End of Reset */ + +body { + font-family: sans-serif; + line-height: 1.6; +} + +h1, h2, h3, h4, h5, h6 { + font-weight: bold; + margin-bottom: 0.5em; +} + +h1 { + font-size: 2em; +} + +h2 { + font-size: 1.5em; +} + +h3 { + font-size: 1.25em; +} + +p { + margin-top: 1em; + margin-bottom: 1em; +} + +a { + color: #007bff; + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +pre:has([class^="language-"]) { + min-width: 400px; + display: block; + padding: 9.5px; + margin: 0 0 10px; + font-size: 13px; + line-height: 1.42857143; + color: #333; + word-break: break-all; + word-wrap: break-word; + background-color: #f5f5f5; + border: 1px solid #ccc; + border-radius: 4px; + + code { + padding: 0; + font-size: inherit; + color: inherit; + white-space: pre-wrap; + background-color: transparent; + border-radius: 0; + } +} + +ul, ol { + margin-top: 1em; + margin-bottom: 1em; + padding-left: 20px; +} + +li { + margin-bottom: 0.5em; +} + +table { + border-collapse: collapse; + width: 100%; +} + +th, td { + border: 1px solid #ddd; + padding: 8px; + text-align: left; +} + +th { + background-color: #f0f0f0; +} + +img { + max-width: 100%; + height: auto; +} diff --git a/src/css/tiny-mde.min.css b/src/css/tiny-mde.min.css new file mode 100644 index 0000000..f317628 --- /dev/null +++ b/src/css/tiny-mde.min.css @@ -0,0 +1 @@ +.TinyMDE{background-color:#fff;color:#000;font-size:16px;line-height:24px;outline:none;padding:5px}.TMBlankLine{height:24px}.TMH1,.TMSetextH1{font-size:22px;font-weight:700;line-height:32px;margin-bottom:8px}.TMSetextH1{margin-bottom:0}.TMSetextH1Marker{margin-bottom:8px}.TMH2,.TMSetextH2{font-size:20px;font-weight:700;line-height:28px;margin-bottom:4px}.TMMark_TMCode{font-family:monospace;font-size:.9em}.TMCode,.TMFencedCodeBacktick,.TMFencedCodeTilde,.TMIndentedCode{background-color:#e0e0e0;font-family:monospace;font-size:.9em}.TMCodeFenceBacktickOpen,.TMCodeFenceTildeOpen{border-bottom:1px solid silver;font-family:monospace;font-size:.9em}.TMCodeFenceBacktickClose,.TMCodeFenceTildeClose{border-top:1px solid silver;font-family:monospace;font-size:.9em}.TMInfoString{color:#00f}.TMCode{border:1px solid silver;border-radius:2px}.TMBlockquote{border-left:2px solid silver;font-style:italic;margin-left:10px;padding-left:10px}.TMMark{color:#a0a0a0}.TMMark_TMH1,.TMMark_TMH2,.TMMark_TMOL,.TMMark_TMUL{color:#ff8080}.TMImage{text-decoration:underline;text-decoration-color:#0f0}.TMLink{text-decoration:underline;text-decoration-color:#00f}.TMLinkLabel{font-family:monospace;text-decoration:underline}.TMLinkLabel_Definition,.TMLinkLabel_Valid{color:#40c040}.TMLinkLabel_Invalid{color:red}.TMLinkTitle{font-style:italic}.TMAutolink,.TMLinkDestination{color:#00f;text-decoration:underline}.TMHR{position:relative}.TMHR:before{border-bottom:2px solid grey;bottom:50%;content:"";left:40%;position:absolute;width:20%;z-index:0}.TMHTML,.TMHTMLBlock{color:#8000ff;font-family:monospace;font-size:.9em}.TMHTMLBlock{color:#6000c0}.TMCommandBar{-ms-overflow-style:none;background-color:#f8f8f8;border:4px solid #f8f8f8;box-sizing:content-box;display:flex;height:24px;overflow-x:scroll;overflow-y:hidden;scrollbar-width:none;-webkit-user-select:none;user-select:none}.TMCommandBar::-webkit-scrollbar{display:none}.TMCommandButton{fill:#404040;box-sizing:border-box;color:#404040;cursor:pointer;display:inline-block;font-family:sans-serif;font-size:20px;height:24px;line-height:18px;margin-right:4px;padding:3px;text-align:center;vertical-align:middle;width:24px}.TMCommandDivider{border-left:1px solid silver;border-right:1px solid #fff;box-sizing:content-box;height:24px;margin-left:4px;margin-right:8px;width:0}.TMCommandButton_Active{fill:navy;background-color:#c0c0ff;color:navy;font-weight:700}.TMCommandButton_Inactive{background-color:#f8f8f8}.TMCommandButton_Disabled{fill:#a0a0a0;color:#a0a0a0}@media (hover:hover){.TMCommandButton_Active:hover,.TMCommandButton_Disabled:hover,.TMCommandButton_Inactive:hover{fill:#000;background-color:#e0e0ff}} diff --git a/src/dumbymap.mjs b/src/dumbymap.mjs new file mode 100644 index 0000000..1cc0f07 --- /dev/null +++ b/src/dumbymap.mjs @@ -0,0 +1,357 @@ +// vim:foldmethod +import MarkdownIt from 'markdown-it' +import MarkdownItAnchor from 'markdown-it-anchor' +import MarkdownItFootnote from 'markdown-it-footnote' +import MarkdownItFrontMatter from 'markdown-it-front-matter' +import MarkdownItTocDoneRight from 'markdown-it-toc-done-right' +import LeaderLine from 'leader-line' +import PlainDraggable from 'plain-draggable' +import { render, parseConfigsFromText } from 'mapclay' + +const observers = new Map() + +export const markdown2HTML = async (container, mdContent) => { + // Render: Markdown -> HTML {{{ + + container.innerHTML = ` +
+
+ ` + + const md = MarkdownIt({ html: true }) + .use(MarkdownItAnchor, { + permalink: MarkdownItAnchor.permalink.linkInsideHeader({ placement: 'before' }) + }) + .use(MarkdownItFootnote) + .use(MarkdownItFrontMatter) + .use(MarkdownItTocDoneRight) + + // FIXME A better way to generate draggable code block + md.renderer.rules.draggable_block_open = () => '
' + md.renderer.rules.draggable_block_close = () => '
' + + md.core.ruler.before('block', 'draggable_block', (state) => { + state.tokens.push(new state.Token('draggable_block_open', '', 1)) + }) + + // Add close tag for block with more than 2 empty lines + md.block.ruler.before('table', 'draggable_block', (state, startLine) => { + if (state.src[state.bMarks[startLine - 1]] === '\n' && state.src[state.bMarks[startLine - 2]] === '\n') { + state.push('draggable_block_close', '', -1); + state.push('draggable_block_open', '', 1); + } + }) + + md.core.ruler.after('block', 'draggable_block', (state) => { + state.tokens.push(new state.Token('draggable_block_close', '', -1)) + }) + + const markdown = container.querySelector('#markdown') + const contentWithToc = '${toc}\n\n\n' + mdContent + markdown.innerHTML = md.render(contentWithToc); + markdown.querySelectorAll('*> div:not(:has(nav))') + .forEach(b => b.classList.add('draggable-block')) + + + // TODO Improve it! + const docLinks = Array.from(container.querySelectorAll('#markdown a[href^="#"][title^="doc"]')) + docLinks.forEach(link => { + link.classList.add('with-leader-line', 'doclink') + link.lines = [] + + link.onmouseover = () => { + const target = document.querySelector(link.getAttribute('href')) + if (!target?.checkVisibility()) return + + const line = new LeaderLine({ + start: link, + end: target, + hide: true, + path: "magnet" + }) + link.lines.push(line) + line.show('draw', { duration: 300, }) + } + link.onmouseout = () => { + link.lines.forEach(line => line.remove()) + link.lines.length = 0 + } + }) + //}}} +} + +export const generateMaps = async (container) => { + // LeaderLine {{{ + + // Get anchors with "geo:" scheme + const markdown = container.querySelector('#markdown') + markdown.anchors = [] + + // Set focusArea + const focusArea = container.querySelector('#map') + const mapPlaceholder = document.createElement('div') + mapPlaceholder.id = 'mapPlaceholder' + focusArea.appendChild(mapPlaceholder) + + // Links points to map by geo schema and id + const geoLinks = Array.from(container.querySelectorAll('#markdown a[href^="geo:"]')) + .filter(link => { + const url = new URL(link.href) + const xy = url?.href?.match(/^geo:([0-9.,]+)/)?.at(1)?.split(',')?.reverse()?.map(Number) + + if (!xy || isNaN(xy[0]) || isNaN(xy[1])) return false + + // Geo information in link + link.url = url + link.xy = xy + link.classList.add('with-leader-line', 'geolink') + link.targets = link.url.searchParams.get('id')?.split(',') ?? null + + // LeaderLine + link.lines = [] + link.onmouseover = () => addLeaderLines(link) + link.onmouseout = () => removeLeaderLines(link) + link.onclick = (event) => { + event.preventDefault() + markdown.anchors + .filter(isAnchorPointedBy(link)) + .forEach(updateMapByMarker(xy)) + // TODO Just hide leader line and show it again + removeLeaderLines(link) + } + + return true + }) + + const isAnchorPointedBy = (link) => (anchor) => { + const mapContainer = anchor.closest('.map-container') + const isTarget = !link.targets || link.targets.includes(mapContainer.id) + return anchor.title === link.url.pathname && isTarget + } + + const isAnchorVisible = (anchor) => { + const mapContainer = anchor.closest('.map-container') + return insideWindow(anchor) && insideParent(anchor, mapContainer) + } + + const drawLeaderLine = (link) => (anchor) => { + const line = new LeaderLine({ + start: link, + end: anchor, + hide: true, + middleLabel: link.url.searchParams.get('text'), + path: "magnet", + }) + line.show('draw', { duration: 300, }) + return line + } + + const addLeaderLines = (link) => { + link.lines = markdown.anchors + .filter(isAnchorPointedBy(link)) + .filter(isAnchorVisible) + .map(drawLeaderLine(link)) + } + + const removeLeaderLines = (link) => { + if (!link.lines) return + link.lines.forEach(line => line.remove()) + link.lines = [] + } + + const updateMapByMarker = (xy) => (marker) => { + const renderer = marker.closest('.map-container')?.renderer + renderer.updateCamera({ center: xy }, true) + } + + const insideWindow = (element) => { + const rect = element.getBoundingClientRect() + return rect.left > 0 && + rect.right < window.innerWidth + rect.width && + rect.top > 0 && + rect.bottom < window.innerHeight + rect.height + } + + const insideParent = (childElement, parentElement) => { + const childRect = childElement.getBoundingClientRect(); + const parentRect = parentElement.getBoundingClientRect(); + const offset = 20 + + return childRect.left > parentRect.left + offset && + childRect.right < parentRect.right - offset && + childRect.top > parentRect.top + offset && + childRect.bottom < parentRect.bottom - offset + } + //}}} + // Render Maps {{{ + + const afterEachMapLoaded = (mapContainer) => { + mapContainer.querySelectorAll('.marker') + .forEach(marker => markdown.anchors.push(marker)) + + const focusClickedMap = () => { + if (container.getAttribute('data-layout') !== 'none') return + + container.querySelectorAll('.map-container') + .forEach(c => c.classList.remove('focus')) + mapContainer.classList.add('focus') + } + mapContainer.onclick = focusClickedMap + } + + // Set unique ID for map container + const mapIdList = [] + const assignMapId = (config) => { + let mapId = config.id + if (!mapId) { + mapId = config.use?.split('/')?.at(-1) + let counter = 2 + while (mapIdList.includes(mapId)) { + mapId = `${config.use}.${counter}` + counter++ + } + config.id = mapId + } + mapIdList.push(mapId) + } + + const markerOptions = geoLinks.map(link => { + return { + targets: link.targets, + xy: link.xy, + title: link.url.pathname + } + }) + + + // Render each code block with "language-map" class + const renderTargets = Array.from(container.querySelectorAll('pre:has(.language-map)')) + const renderAllTargets = renderTargets.map(async (target) => { + // Get text in code block starts with '```map' + // BE CAREFUL!!! 0xa0 char is "non-breaking spaces" in HTML text content + // replace it by normal space + const configText = target.querySelector('.language-map').textContent.replace(/\u00A0/g, '\u0020') + + let configList = [] + try { + configList = parseConfigsFromText(configText).map(result => { + assignMapId(result) + const markersFromLinks = markerOptions.filter(marker => + !marker.targets || marker.targets.includes(result.id) + ) + Object.assign(result, { markers: markersFromLinks }) + return result + }) + } catch (err) { + console.error('Fail to parse yaml config for element', target, err) + } + + // Render maps + return render(target, configList) + .then(results => { + results.forEach((mapByConfig) => { + if (mapByConfig.status === 'fulfilled') { + afterEachMapLoaded(mapByConfig.value) + return mapByConfig.value + } else { + console.error('Fail to render target element', mapByConfig.value) + } + }) + }) + }) + const renderInfo = await Promise.all(renderAllTargets).then(() => 'Finish Rendering') + console.info(renderInfo) + + //}}} + // CSS observer {{{ + if (!observers.get(container)) { + observers.set(container, []) + } + const obs = observers.get(container) + if (obs.length) { + obs.forEach(o => o.disconnect()) + obs.length = 0 + } + // Layout{{{ + + // press key to switch layout + const layouts = ['none', 'side', 'overlay'] + container.setAttribute("data-layout", layouts[0]) + document.onkeydown = (event) => { + if (event.key === 'x' && container.querySelector('.map-container')) { + let currentLayout = container.getAttribute('data-layout') + currentLayout = currentLayout ? currentLayout : 'none' + const nextLayout = layouts[(layouts.indexOf(currentLayout) + 1) % layouts.length] + + container.setAttribute("data-layout", nextLayout) + } + } + + // Add draggable part for blocks + markdown.blocks = Array.from(markdown.querySelectorAll('.draggable-block')) + markdown.blocks.forEach(block => { + const draggablePart = document.createElement('div'); + draggablePart.classList.add('draggable') + draggablePart.textContent = '☰' + + // TODO Better way to close block + draggablePart.onmouseup = (e) => { + if (e.button === 1) block.style.display = "none"; + } + block.insertBefore(draggablePart, block.firstChild) + }) + + // observe layout change + const layoutObserver = new MutationObserver(() => { + const layout = container.getAttribute('data-layout') + markdown.blocks.forEach(b => b.style.display = "block") + + if (layout === 'none') { + mapPlaceholder.innerHTML = "" + const map = focusArea.querySelector('.map-container') + // Swap focused map and palceholder in markdown + if (map) { + mapPlaceholder.parentElement?.replaceChild(map, mapPlaceholder) + focusArea.append(mapPlaceholder) + } + } else { + // If paceholder is not set, create one and put map into focusArea + if (focusArea.contains(mapPlaceholder)) { + const mapContainer = container.querySelector('.map-container.focus') ?? container.querySelector('.map-container') + mapPlaceholder.innerHTML = `
Placeholder
` + // TODO + // mapPlaceholder.src = map.map.getCanvas().toDataURL() + mapContainer.parentElement?.replaceChild(mapPlaceholder, mapContainer) + focusArea.appendChild(mapContainer) + } + } + + if (layout === 'overlay') { + markdown.blocks.forEach(block => { + block.draggableInstance = new PlainDraggable(block, { handle: block.querySelector('.draggable') }) + block.draggableInstance.snap = { x: { step: 20 }, y: { step: 20 } } + // block.draggableInstance.onDragEnd = () => { + // links(block).forEach(link => link.line.position()) + // } + }) + } else { + markdown.blocks.forEach(block => { + try { + block.style.transform = 'none' + block.draggableInstance.remove() + } catch (err) { + console.warn('Fail to remove draggable instance', err) + } + }) + } + }); + layoutObserver.observe(container, { + attributes: true, + attributeFilter: ["data-layout"], + attributeOldValue: true + }); + obs.push(layoutObserver) + //}}} + //}}} + return container +} 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