From aded35c97a57eeb5eabff8d9a6853b01bbfa197e Mon Sep 17 00:00:00 2001 From: Hsieh Chin Fan Date: Tue, 10 Sep 2024 19:46:20 +0800 Subject: refactor: Switch to EasyMDE EasyMDE is based on codemirror, so completion and more features maybe applied later. * Now suggestions also works in EasyMDE * For preview, change return value of markdown2HTML() --- src/css/dumbymap.css | 23 ++-- src/css/easymde.min.css | 7 ++ src/css/index.css | 81 +++++++++---- src/dumbymap.mjs | 101 +++++++++------- src/editor.mjs | 301 +++++++++++++++++++++++------------------------- 5 files changed, 279 insertions(+), 234 deletions(-) create mode 100644 src/css/easymde.min.css (limited to 'src') diff --git a/src/css/dumbymap.css b/src/css/dumbymap.css index d31c97e..383c4ec 100644 --- a/src/css/dumbymap.css +++ b/src/css/dumbymap.css @@ -45,7 +45,7 @@ } } - #markdown { + .SemanticHtml { flex: 1; padding: 20px; overflow-y: scroll; @@ -59,20 +59,19 @@ background-color: white; margin-bottom: 3rem; - * { - width: fit-content; - } - .draggable { display: none; } } - pre:has(.map-container) { - display: flex; - justify-content: flex-start; + pre { width: 100%; - background-color: inherit; + + &:has(.map-container) { + display: flex; + justify-content: flex-start; + background-color: inherit; + } } .map-container { @@ -122,7 +121,7 @@ .result-html[data-layout=side] { #map, - #markdown { + .SemanticHtml { flex: 50%; overflow-y: scroll; height: 100vh; @@ -131,13 +130,13 @@ .result-html[data-layout=overlay] { #map, - #markdown { + .SemanticHtml { position: fixed; height: 100%; width: 100%; } - #markdown { + .SemanticHtml { font-size: 12px; pointer-events: none; diff --git a/src/css/easymde.min.css b/src/css/easymde.min.css new file mode 100644 index 0000000..cb01784 --- /dev/null +++ b/src/css/easymde.min.css @@ -0,0 +1,7 @@ +/** + * easymde v2.18.0 + * Copyright Jeroen Akkerman + * @link https://github.com/ionaru/easy-markdown-editor + * @license MIT + */ +.CodeMirror{font-family:monospace;height:300px;color:#000;direction:ltr}.CodeMirror-lines{padding:4px 0}.CodeMirror pre.CodeMirror-line,.CodeMirror pre.CodeMirror-line-like{padding:0 4px}.CodeMirror-gutter-filler,.CodeMirror-scrollbar-filler{background-color:#fff}.CodeMirror-gutters{border-right:1px solid #ddd;background-color:#f7f7f7;white-space:nowrap}.CodeMirror-linenumber{padding:0 3px 0 5px;min-width:20px;text-align:right;color:#999;white-space:nowrap}.CodeMirror-guttermarker{color:#000}.CodeMirror-guttermarker-subtle{color:#999}.CodeMirror-cursor{border-left:1px solid #000;border-right:none;width:0}.CodeMirror div.CodeMirror-secondarycursor{border-left:1px solid silver}.cm-fat-cursor .CodeMirror-cursor{width:auto;border:0!important;background:#7e7}.cm-fat-cursor div.CodeMirror-cursors{z-index:1}.cm-fat-cursor .CodeMirror-line::selection,.cm-fat-cursor .CodeMirror-line>span::selection,.cm-fat-cursor .CodeMirror-line>span>span::selection{background:0 0}.cm-fat-cursor .CodeMirror-line::-moz-selection,.cm-fat-cursor .CodeMirror-line>span::-moz-selection,.cm-fat-cursor .CodeMirror-line>span>span::-moz-selection{background:0 0}.cm-fat-cursor{caret-color:transparent}@-moz-keyframes blink{50%{background-color:transparent}}@-webkit-keyframes blink{50%{background-color:transparent}}@keyframes blink{50%{background-color:transparent}}.cm-tab{display:inline-block;text-decoration:inherit}.CodeMirror-rulers{position:absolute;left:0;right:0;top:-50px;bottom:0;overflow:hidden}.CodeMirror-ruler{border-left:1px solid #ccc;top:0;bottom:0;position:absolute}.cm-s-default .cm-header{color:#00f}.cm-s-default .cm-quote{color:#090}.cm-negative{color:#d44}.cm-positive{color:#292}.cm-header,.cm-strong{font-weight:700}.cm-em{font-style:italic}.cm-link{text-decoration:underline}.cm-strikethrough{text-decoration:line-through}.cm-s-default .cm-keyword{color:#708}.cm-s-default .cm-atom{color:#219}.cm-s-default .cm-number{color:#164}.cm-s-default .cm-def{color:#00f}.cm-s-default .cm-variable-2{color:#05a}.cm-s-default .cm-type,.cm-s-default .cm-variable-3{color:#085}.cm-s-default .cm-comment{color:#a50}.cm-s-default .cm-string{color:#a11}.cm-s-default .cm-string-2{color:#f50}.cm-s-default .cm-meta{color:#555}.cm-s-default .cm-qualifier{color:#555}.cm-s-default .cm-builtin{color:#30a}.cm-s-default .cm-bracket{color:#997}.cm-s-default .cm-tag{color:#170}.cm-s-default .cm-attribute{color:#00c}.cm-s-default .cm-hr{color:#999}.cm-s-default .cm-link{color:#00c}.cm-s-default .cm-error{color:red}.cm-invalidchar{color:red}.CodeMirror-composing{border-bottom:2px solid}div.CodeMirror span.CodeMirror-matchingbracket{color:#0b0}div.CodeMirror span.CodeMirror-nonmatchingbracket{color:#a22}.CodeMirror-matchingtag{background:rgba(255,150,0,.3)}.CodeMirror-activeline-background{background:#e8f2ff}.CodeMirror{position:relative;overflow:hidden;background:#fff}.CodeMirror-scroll{overflow:scroll!important;margin-bottom:-50px;margin-right:-50px;padding-bottom:50px;height:100%;outline:0;position:relative;z-index:0}.CodeMirror-sizer{position:relative;border-right:50px solid transparent}.CodeMirror-gutter-filler,.CodeMirror-hscrollbar,.CodeMirror-scrollbar-filler,.CodeMirror-vscrollbar{position:absolute;z-index:6;display:none;outline:0}.CodeMirror-vscrollbar{right:0;top:0;overflow-x:hidden;overflow-y:scroll}.CodeMirror-hscrollbar{bottom:0;left:0;overflow-y:hidden;overflow-x:scroll}.CodeMirror-scrollbar-filler{right:0;bottom:0}.CodeMirror-gutter-filler{left:0;bottom:0}.CodeMirror-gutters{position:absolute;left:0;top:0;min-height:100%;z-index:3}.CodeMirror-gutter{white-space:normal;height:100%;display:inline-block;vertical-align:top;margin-bottom:-50px}.CodeMirror-gutter-wrapper{position:absolute;z-index:4;background:0 0!important;border:none!important}.CodeMirror-gutter-background{position:absolute;top:0;bottom:0;z-index:4}.CodeMirror-gutter-elt{position:absolute;cursor:default;z-index:4}.CodeMirror-gutter-wrapper ::selection{background-color:transparent}.CodeMirror-gutter-wrapper ::-moz-selection{background-color:transparent}.CodeMirror-lines{cursor:text;min-height:1px}.CodeMirror pre.CodeMirror-line,.CodeMirror pre.CodeMirror-line-like{-moz-border-radius:0;-webkit-border-radius:0;border-radius:0;border-width:0;background:0 0;font-family:inherit;font-size:inherit;margin:0;white-space:pre;word-wrap:normal;line-height:inherit;color:inherit;z-index:2;position:relative;overflow:visible;-webkit-tap-highlight-color:transparent;-webkit-font-variant-ligatures:contextual;font-variant-ligatures:contextual}.CodeMirror-wrap pre.CodeMirror-line,.CodeMirror-wrap pre.CodeMirror-line-like{word-wrap:break-word;white-space:pre-wrap;word-break:normal}.CodeMirror-linebackground{position:absolute;left:0;right:0;top:0;bottom:0;z-index:0}.CodeMirror-linewidget{position:relative;z-index:2;padding:.1px}.CodeMirror-rtl pre{direction:rtl}.CodeMirror-code{outline:0}.CodeMirror-gutter,.CodeMirror-gutters,.CodeMirror-linenumber,.CodeMirror-scroll,.CodeMirror-sizer{-moz-box-sizing:content-box;box-sizing:content-box}.CodeMirror-measure{position:absolute;width:100%;height:0;overflow:hidden;visibility:hidden}.CodeMirror-cursor{position:absolute;pointer-events:none}.CodeMirror-measure pre{position:static}div.CodeMirror-cursors{visibility:hidden;position:relative;z-index:3}div.CodeMirror-dragcursors{visibility:visible}.CodeMirror-focused div.CodeMirror-cursors{visibility:visible}.CodeMirror-selected{background:#d9d9d9}.CodeMirror-focused .CodeMirror-selected{background:#d7d4f0}.CodeMirror-crosshair{cursor:crosshair}.CodeMirror-line::selection,.CodeMirror-line>span::selection,.CodeMirror-line>span>span::selection{background:#d7d4f0}.CodeMirror-line::-moz-selection,.CodeMirror-line>span::-moz-selection,.CodeMirror-line>span>span::-moz-selection{background:#d7d4f0}.cm-searching{background-color:#ffa;background-color:rgba(255,255,0,.4)}.cm-force-border{padding-right:.1px}@media print{.CodeMirror div.CodeMirror-cursors{visibility:hidden}}.cm-tab-wrap-hack:after{content:''}span.CodeMirror-selectedtext{background:0 0}.EasyMDEContainer{display:block}.CodeMirror-rtl pre{direction:rtl}.EasyMDEContainer.sided--no-fullscreen{display:flex;flex-direction:row;flex-wrap:wrap}.EasyMDEContainer .CodeMirror{box-sizing:border-box;height:auto;border:1px solid #ced4da;border-bottom-left-radius:4px;border-bottom-right-radius:4px;padding:10px;font:inherit;z-index:0;word-wrap:break-word}.EasyMDEContainer .CodeMirror-scroll{cursor:text}.EasyMDEContainer .CodeMirror-fullscreen{background:#fff;position:fixed!important;top:50px;left:0;right:0;bottom:0;height:auto;z-index:8;border-right:none!important;border-bottom-right-radius:0!important}.EasyMDEContainer .CodeMirror-sided{width:50%!important}.EasyMDEContainer.sided--no-fullscreen .CodeMirror-sided{border-right:none!important;border-bottom-right-radius:0;position:relative;flex:1 1 auto}.EasyMDEContainer .CodeMirror-placeholder{opacity:.5}.EasyMDEContainer .CodeMirror-focused .CodeMirror-selected{background:#d9d9d9}.editor-toolbar{position:relative;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;-o-user-select:none;user-select:none;padding:9px 10px;border-top:1px solid #ced4da;border-left:1px solid #ced4da;border-right:1px solid #ced4da;border-top-left-radius:4px;border-top-right-radius:4px}.editor-toolbar.fullscreen{width:100%;height:50px;padding-top:10px;padding-bottom:10px;box-sizing:border-box;background:#fff;border:0;position:fixed;top:0;left:0;opacity:1;z-index:9}.editor-toolbar.fullscreen::before{width:20px;height:50px;background:-moz-linear-gradient(left,#fff 0,rgba(255,255,255,0) 100%);background:-webkit-gradient(linear,left top,right top,color-stop(0,#fff),color-stop(100%,rgba(255,255,255,0)));background:-webkit-linear-gradient(left,#fff 0,rgba(255,255,255,0) 100%);background:-o-linear-gradient(left,#fff 0,rgba(255,255,255,0) 100%);background:-ms-linear-gradient(left,#fff 0,rgba(255,255,255,0) 100%);background:linear-gradient(to right,#fff 0,rgba(255,255,255,0) 100%);position:fixed;top:0;left:0;margin:0;padding:0}.editor-toolbar.fullscreen::after{width:20px;height:50px;background:-moz-linear-gradient(left,rgba(255,255,255,0) 0,#fff 100%);background:-webkit-gradient(linear,left top,right top,color-stop(0,rgba(255,255,255,0)),color-stop(100%,#fff));background:-webkit-linear-gradient(left,rgba(255,255,255,0) 0,#fff 100%);background:-o-linear-gradient(left,rgba(255,255,255,0) 0,#fff 100%);background:-ms-linear-gradient(left,rgba(255,255,255,0) 0,#fff 100%);background:linear-gradient(to right,rgba(255,255,255,0) 0,#fff 100%);position:fixed;top:0;right:0;margin:0;padding:0}.EasyMDEContainer.sided--no-fullscreen .editor-toolbar{width:100%}.editor-toolbar .easymde-dropdown,.editor-toolbar button{background:0 0;display:inline-block;text-align:center;text-decoration:none!important;height:30px;margin:0;padding:0;border:1px solid transparent;border-radius:3px;cursor:pointer}.editor-toolbar button{font-weight:700;min-width:30px;padding:0 6px;white-space:nowrap}.editor-toolbar button.active,.editor-toolbar button:hover{background:#fcfcfc;border-color:#95a5a6}.editor-toolbar i.separator{display:inline-block;width:0;border-left:1px solid #d9d9d9;border-right:1px solid #fff;color:transparent;text-indent:-10px;margin:0 6px}.editor-toolbar button:after{font-family:Arial,"Helvetica Neue",Helvetica,sans-serif;font-size:65%;vertical-align:text-bottom;position:relative;top:2px}.editor-toolbar button.heading-1:after{content:"1"}.editor-toolbar button.heading-2:after{content:"2"}.editor-toolbar button.heading-3:after{content:"3"}.editor-toolbar button.heading-bigger:after{content:"▲"}.editor-toolbar button.heading-smaller:after{content:"▼"}.editor-toolbar.disabled-for-preview button:not(.no-disable){opacity:.6;pointer-events:none}@media only screen and (max-width:700px){.editor-toolbar i.no-mobile{display:none}}.editor-statusbar{padding:8px 10px;font-size:12px;color:#959694;text-align:right}.EasyMDEContainer.sided--no-fullscreen .editor-statusbar{width:100%}.editor-statusbar span{display:inline-block;min-width:4em;margin-left:1em}.editor-statusbar .lines:before{content:'lines: '}.editor-statusbar .words:before{content:'words: '}.editor-statusbar .characters:before{content:'characters: '}.editor-preview-full{position:absolute;width:100%;height:100%;top:0;left:0;z-index:7;overflow:auto;display:none;box-sizing:border-box}.editor-preview-side{position:fixed;bottom:0;width:50%;top:50px;right:0;z-index:9;overflow:auto;display:none;box-sizing:border-box;border:1px solid #ddd;word-wrap:break-word}.editor-preview-active-side{display:block}.EasyMDEContainer.sided--no-fullscreen .editor-preview-active-side{flex:1 1 auto;height:auto;position:static}.editor-preview-active{display:block}.editor-preview{padding:10px;background:#fafafa}.editor-preview>p{margin-top:0}.editor-preview pre{background:#eee;margin-bottom:10px}.editor-preview table td,.editor-preview table th{border:1px solid #ddd;padding:5px}.cm-s-easymde .cm-tag{color:#63a35c}.cm-s-easymde .cm-attribute{color:#795da3}.cm-s-easymde .cm-string{color:#183691}.cm-s-easymde .cm-header-1{font-size:calc(1.375rem + 1.5vw)}.cm-s-easymde .cm-header-2{font-size:calc(1.325rem + .9vw)}.cm-s-easymde .cm-header-3{font-size:calc(1.3rem + .6vw)}.cm-s-easymde .cm-header-4{font-size:calc(1.275rem + .3vw)}.cm-s-easymde .cm-header-5{font-size:1.25rem}.cm-s-easymde .cm-header-6{font-size:1rem}.cm-s-easymde .cm-header-1,.cm-s-easymde .cm-header-2,.cm-s-easymde .cm-header-3,.cm-s-easymde .cm-header-4,.cm-s-easymde .cm-header-5,.cm-s-easymde .cm-header-6{margin-bottom:.5rem;line-height:1.2}.cm-s-easymde .cm-comment{background:rgba(0,0,0,.05);border-radius:2px}.cm-s-easymde .cm-link{color:#7f8c8d}.cm-s-easymde .cm-url{color:#aab2b3}.cm-s-easymde .cm-quote{color:#7f8c8d;font-style:italic}.editor-toolbar .easymde-dropdown{position:relative;background:linear-gradient(to bottom right,#fff 0,#fff 84%,#333 50%,#333 100%);border-radius:0;border:1px solid #fff}.editor-toolbar .easymde-dropdown:hover{background:linear-gradient(to bottom right,#fff 0,#fff 84%,#333 50%,#333 100%)}.easymde-dropdown-content{display:block;visibility:hidden;position:absolute;background-color:#f9f9f9;box-shadow:0 8px 16px 0 rgba(0,0,0,.2);padding:8px;z-index:2;top:30px}.easymde-dropdown:active .easymde-dropdown-content,.easymde-dropdown:focus .easymde-dropdown-content,.easymde-dropdown:focus-within .easymde-dropdown-content{visibility:visible}.easymde-dropdown-content button{display:block}span[data-img-src]::after{content:'';background-image:var(--bg-image);display:block;max-height:100%;max-width:100%;background-size:contain;height:0;padding-top:var(--height);width:var(--width);background-repeat:no-repeat}.CodeMirror .cm-spell-error:not(.cm-url):not(.cm-comment):not(.cm-tag):not(.cm-word){background:rgba(255,0,0,.15)} diff --git a/src/css/index.css b/src/css/index.css index adc1cfe..3764dac 100644 --- a/src/css/index.css +++ b/src/css/index.css @@ -1,34 +1,70 @@ +:root { + --content-border: solid lightgray 2px; + --content-border-radius: 5px; +} + body { display: flex; justify-items: stretch; + width: 100%; } .result-html { - flex: 1; - height: calc(100vh - 20px); - border: solid lightgray 2px; - border-radius: 5px; + flex: 0 0 50%; + border: var(--content-border); + border-radius: var(--content-border-radius); margin: 10px; } .editor { - flex: 1; + flex: 0 0 50%; max-width: 50vw; + height: calc(100vh - 15px); margin: 10px; } -.TinyMDE { - height: calc(100vh - 55px); - padding: 16px; - overflow-y: scroll; - border: 2px solid lightgray; - border-radius: 5px; +.EasyMDEContainer { + display: flex; + flex-direction: column; + height: calc(100vh - 20px); + box-sizing: border-box; + + .CodeMirror { + order: 1; + flex-grow: 1; + border: var(--content-border); + border-radius: var(--content-border-radius); + padding-inline: 0; + + span { + white-space: pre; + } + .invalid-input { + text-decoration: red wavy underline 1px; + } + } + + .editor-toolbar { + order: 2; + margin-top: 0.5rem; + border-left: 1px solid #ced4da; + border-right: 1px solid #ced4da; + border-bottom: 1px solid #ced4da; + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + } - span { - white-space: pre; + .editor-statusbar { + order: 3; } - .invalid-input { - text-decoration: red wavy underline 1px; +} + +/* FIXME For those empty line (no child with cm-comment) */ +.CodeMirror-line:has(.cm-comment) { + background: rgba(0,0,0,.05) !important; + + .cm-comment { + background: none !important; } } @@ -41,6 +77,7 @@ body { overflow-y: scroll; border: 2px solid lightgray; border-radius: 0.5rem; + z-index: 100; background: #fff; } @@ -55,6 +92,13 @@ body { white-space: nowrap; align-items: center; + &:not(:first-child) { + border-top: 1px solid rgb(203 213 225); + } + &.focus { + background: rgb(226 232 240); + } + * { flex-shrink: 0; display: inline-block; @@ -66,15 +110,10 @@ body { font-weight: bold; } .truncate { + flex-shrink: 1; 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/dumbymap.mjs b/src/dumbymap.mjs index b95e7a9..cd3b8ff 100644 --- a/src/dumbymap.mjs +++ b/src/dumbymap.mjs @@ -1,4 +1,3 @@ -// vim:foldmethod import MarkdownIt from 'markdown-it' import MarkdownItAnchor from 'markdown-it-anchor' import MarkdownItFootnote from 'markdown-it-footnote' @@ -8,15 +7,30 @@ import LeaderLine from 'leader-line' import PlainDraggable from 'plain-draggable' import { render, parseConfigsFromYaml } from 'mapclay' -const observers = new Map() +function onRemove(element, callback) { + const parent = element.parentNode; + if (!parent) throw new Error("The node must already be attached"); -export const markdown2HTML = async (container, mdContent) => { - // Render: Markdown -> HTML {{{ + const obs = new MutationObserver(mutations => { + for (const mutation of mutations) { + for (const el of mutation.removedNodes) { + if (el === element) { + obs.disconnect(); + callback(); + } + } + } + }); + obs.observe(parent, { childList: true, }); +} + +// Render: Markdown -> HTML {{{ +export const markdown2HTML = (container, mdContent) => { + + Array.from(container.children).map(e => e.remove()) - container.innerHTML = ` -
-
- ` + container.innerHTML = '
' + const htmlHolder = container.querySelector('.SemanticHtml') const md = MarkdownIt({ html: true }) .use(MarkdownItAnchor, { @@ -46,15 +60,15 @@ export const markdown2HTML = async (container, mdContent) => { 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))') + htmlHolder.innerHTML = md.render(contentWithToc); + // TODO Do this in markdown-it + htmlHolder.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"]')) + const docLinks = Array.from(container.querySelectorAll('a[href^="#"][title^="doc"]')) docLinks.forEach(link => { link.classList.add('with-leader-line', 'doclink') link.lines = [] @@ -77,24 +91,29 @@ export const markdown2HTML = async (container, mdContent) => { link.lines.length = 0 } }) + + return container //}}} } +// FIXME Don't use hard-coded CSS selector export const generateMaps = async (container) => { // LeaderLine {{{ // Get anchors with "geo:" scheme - const markdown = container.querySelector('#markdown') - markdown.anchors = [] + const htmlHolder = container.querySelector('.SemanticHtml') ?? container + htmlHolder.anchors = [] // Set focusArea - const focusArea = container.querySelector('#map') + const showcase = document.createElement('div') + container.appendChild(showcase) + showcase.classList.add('Showcase') const mapPlaceholder = document.createElement('div') mapPlaceholder.id = 'mapPlaceholder' - focusArea.appendChild(mapPlaceholder) + showcase.appendChild(mapPlaceholder) // Links points to map by geo schema and id - const geoLinks = Array.from(container.querySelectorAll('#markdown a[href^="geo:"]')) + const geoLinks = Array.from(htmlHolder.querySelectorAll('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) @@ -113,7 +132,7 @@ export const generateMaps = async (container) => { link.onmouseout = () => removeLeaderLines(link) link.onclick = (event) => { event.preventDefault() - markdown.anchors + htmlHolder.anchors .filter(isAnchorPointedBy(link)) .forEach(updateMapByMarker(xy)) // TODO Just hide leader line and show it again @@ -147,7 +166,7 @@ export const generateMaps = async (container) => { } const addLeaderLines = (link) => { - link.lines = markdown.anchors + link.lines = htmlHolder.anchors .filter(isAnchorPointedBy(link)) .filter(isAnchorVisible) .map(drawLeaderLine(link)) @@ -187,7 +206,7 @@ export const generateMaps = async (container) => { const afterEachMapLoaded = (mapContainer) => { mapContainer.querySelectorAll('.marker') - .forEach(marker => markdown.anchors.push(marker)) + .forEach(marker => htmlHolder.anchors.push(marker)) const focusClickedMap = () => { if (container.getAttribute('data-layout') !== 'none') return @@ -215,6 +234,7 @@ export const generateMaps = async (container) => { mapIdList.push(mapId) } + // FIXME Create markers after maps are created const markerOptions = geoLinks.map(link => ({ targets: link.targets, xy: link.xy, @@ -263,20 +283,16 @@ export const generateMaps = async (container) => { //}}} // 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]) + + // FIXME Use UI to switch layouts + const originalKeyDown = document.onkeydown document.onkeydown = (event) => { + originalKeyDown(event) if (event.key === 'x' && container.querySelector('.map-container')) { let currentLayout = container.getAttribute('data-layout') currentLayout = currentLayout ? currentLayout : 'none' @@ -287,8 +303,8 @@ export const generateMaps = async (container) => { } // Add draggable part for blocks - markdown.blocks = Array.from(markdown.querySelectorAll('.draggable-block')) - markdown.blocks.forEach(block => { + htmlHolder.blocks = Array.from(htmlHolder.querySelectorAll('.draggable-block')) + htmlHolder.blocks.forEach(block => { const draggablePart = document.createElement('div'); draggablePart.classList.add('draggable') draggablePart.textContent = '☰' @@ -303,30 +319,30 @@ export const generateMaps = async (container) => { // observe layout change const layoutObserver = new MutationObserver(() => { const layout = container.getAttribute('data-layout') - markdown.blocks.forEach(b => b.style.display = "block") + htmlHolder.blocks.forEach(b => b.style.display = "block") if (layout === 'none') { mapPlaceholder.innerHTML = "" - const map = focusArea.querySelector('.map-container') + const map = showcase.querySelector('.map-container') // Swap focused map and palceholder in markdown if (map) { mapPlaceholder.parentElement?.replaceChild(map, mapPlaceholder) - focusArea.append(mapPlaceholder) + showcase.append(mapPlaceholder) } } else { // If paceholder is not set, create one and put map into focusArea - if (focusArea.contains(mapPlaceholder)) { + if (showcase.contains(mapPlaceholder)) { const mapContainer = container.querySelector('.map-container.focus') ?? container.querySelector('.map-container') mapPlaceholder.innerHTML = `
Placeholder
` // TODO Get snapshot image // mapPlaceholder.src = map.map.getCanvas().toDataURL() mapContainer.parentElement?.replaceChild(mapPlaceholder, mapContainer) - focusArea.appendChild(mapContainer) + showcase.appendChild(mapContainer) } } if (layout === 'overlay') { - markdown.blocks.forEach(block => { + htmlHolder.blocks.forEach(block => { block.draggableInstance = new PlainDraggable(block, { handle: block.querySelector('.draggable') }) block.draggableInstance.snap = { x: { step: 20 }, y: { step: 20 } } // block.draggableInstance.onDragEnd = () => { @@ -334,13 +350,9 @@ export const generateMaps = async (container) => { // } }) } else { - markdown.blocks.forEach(block => { - try { - block.style.transform = 'none' - block.draggableInstance.remove() - } catch (err) { - console.warn('Fail to remove draggable instance', err) - } + htmlHolder.blocks.forEach(block => { + block.style.transform = 'none' + block.draggableInstance?.remove() }) } }); @@ -349,7 +361,8 @@ export const generateMaps = async (container) => { attributeFilter: ["data-layout"], attributeOldValue: true }); - obs.push(layoutObserver) + + onRemove(htmlHolder, () => layoutObserver.disconnect()) //}}} //}}} return container diff --git a/src/editor.mjs b/src/editor.mjs index dd18a31..b283a90 100644 --- a/src/editor.mjs +++ b/src/editor.mjs @@ -1,12 +1,22 @@ -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") +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 = '' @@ -14,93 +24,71 @@ const getContentFromHash = (cleanHash = false) => { ? decodeURIComponent(hashValue.substring(5)) : null } - -// Add Editor 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' -const tinyEditor = new TinyMDE.Editor({ - element: 'tinymde', - content: contentFromHash ?? lastContent ?? defaultContent +// Set up EasyMDE {{{ +const editor = new EasyMDE({ + element: textArea, + indentWithTabs: false, + initialValue: contentFromHash ?? lastContent ?? defaultContent, + lineNumbers: true, + promptURLs: true, + uploadImage: true, + 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' + ], }); -mdeElement.querySelectorAll('span').forEach(e => e.setAttribute('spellcheck', 'false')) +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) tinyEditor.setContent(contentFromHash) + if (contentFromHash) editor.value(contentFromHash) } -// 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: () => { - window.location.hash = '#text=' + encodeURIComponent(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(() => 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 = {} @@ -110,7 +98,6 @@ class Suggestion { this.replace = replace } } - // }}} // {{{ Aliases for map options const aliasesForMapOptions = {} @@ -124,50 +111,54 @@ fetch(defaultApply) .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' -} +// 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 = (element) => { - const currentLine = element.closest('.TMFencedCodeBacktick') - if (!currentLine) return null +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 - do { - ps = ps.previousSibling - if (ps.textContent.match(/^use: /)) { + let ps = currentLine - 1 + while (ps > 0 && insideCodeblockForMap(getToken(ps))) { + if (match(ps)) { return ps - } else if (ps.textContent.match(/^---/)) { + } else if (cm.getLine(ps).match(/^---/)) { // If yaml doc separator is found break } - } while (ps && ps.classList.contains('TMFencedCodeBacktick')) + ps = ps - 1 + } - let ns = currentLine - do { - ns = ns.nextSibling - if (ns.textContent.match(/^use: /)) { + let ns = currentLine + 1 + while (insideCodeblockForMap(getToken(ns))) { + if (match(ns)) { return ns - } else if (ns.textContent.match(/^---/)) { + } else if (cm.getLine(ns).match(/^---/)) { // If yaml doc separator is found return null } - } while (ns && ns.classList.contains('TMFencedCodeBacktick')) + ns = ns + 1 + } return null } @@ -218,29 +209,35 @@ const getSuggestionsFromAliases = (option) => Object.entries(aliasesForMapOption }) ?? [] // }}} -const handleTypingInCodeBlock = (currentLine, selection) => { - const text = currentLine.textContent +// 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(/^-/)){ + } else if (text.match(/^-/)) { // TODO Completion for YAML doc separator } else { - addSuggestions(currentLine, selection) + const suggestions = getSuggestions(anchor) + addSuggestions(anchor, suggestions) } } -// 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') +// }}} +// 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(currentLine) - const renderer = lineWithRenderer?.textContent.split(' ')[1] + const lineWithRenderer = getLineWithRenderer(anchor) + const renderer = cm.getLine(lineWithRenderer).split(' ')[1] if (renderer) { - // Do not check properties - if (text.startsWith(' ')) return + if (text.startsWith(' ')) return [] // If no valid options for current used renderer, go get it! const validOptions = rendererOptions[renderer] @@ -253,9 +250,9 @@ const addSuggestions = (currentLine, selection) => { }) .catch(() => { markInputIsInvalid(lineWithRenderer) - console.error('Fail to get valid options from renderer, URL is', rendererUrl) + console.warn(`Fail to get valid options from renderer with URL ${rendererUrl}` ) }) - return + return [] } // If input is "key:value" (no space left after colon), then it is invalid @@ -263,7 +260,7 @@ const addSuggestions = (currentLine, selection) => { const isValidKeyValue = text.match(/^[^:]+:\s+/) if (isKeyFinished && !isValidKeyValue) { markInputIsInvalid() - return + return [] } // If user is typing option @@ -311,6 +308,11 @@ const addSuggestions = (currentLine, selection) => { ) suggestions = rendererSuggestions.length > 0 ? rendererSuggestions : [] } + return suggestions +} +// }}} +// {{{ FUNCTION: Show element about suggestions +const addSuggestions = (anchor, suggestions) => { if (suggestions.length === 0) { suggestionsEle.style.display = 'none'; @@ -336,55 +338,35 @@ const addSuggestions = (currentLine, selection) => { 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') + 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); }); - 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)`; + 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 {{{ -tinyEditor.addEventListener('selection', selection => { - // Check selection is inside editor contents - const node = selection?.anchor?.node - if (!node) return - - // FIXME Better way to prevent spellcheck across editor - // Get HTML element for current selection - const element = node instanceof HTMLElement - ? node - : node.parentNode - element.setAttribute('spellcheck', 'false') - - // 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'; - } - - // Do not show suggestion by attribute - if (suggestionsEle.getAttribute('data-keep-close') === 'true') { - suggestionsEle.setAttribute('data-keep-close', 'false') - return - } +// FIXME Dont show suggestion when selecting multiple chars +cm.on("beforeSelectionChange", (_, obj) => { + const anchor = (obj.ranges[0].anchor) + const token = cm.getTokenAt(anchor) - // Show suggestions for map code block - if (insideCodeblockForMap(element)) { - handleTypingInCodeBlock(element, selection) + if (insideCodeblockForMap(token)) { + handleTypingInCodeBlock(anchor) } }); // }}} // EVENT: keydown for suggestions {{{ -mdeElement.addEventListener('keydown', (e) => { +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; @@ -402,7 +384,6 @@ mdeElement.addEventListener('keydown', (e) => { 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')) @@ -411,16 +392,22 @@ mdeElement.addEventListener('keydown', (e) => { break; case 'Enter': currentSuggestion.onclick() - suggestionsEle.style.display = 'none'; break; case 'Escape': + const anchor = cm.getCursor() suggestionsEle.style.display = 'none'; - // Prevent trigger selection event again - suggestionsEle.setAttribute('data-keep-close', 'true') - setTimeout(() => tinyEditor.setSelection(selection), 100) + // Focus editor again + setTimeout(() => cm.focus() && cm.setCursor(anchor), 100) break; } }); + +document.onkeydown = (e) => { + if (e.altKey && e.ctrlKey && e.key === 'm') { + toggleMaps(HtmlContainer) + } +} + // }}} // }}} -- cgit v1.2.3-70-g09d2