diff options
| author | Hsieh Chin Fan <pham@topo.tw> | 2024-08-13 23:58:38 +0800 |
|---|---|---|
| committer | Hsieh Chin Fan <pham@topo.tw> | 2024-09-07 17:02:01 +0800 |
| commit | 15a939d234910016d36d4297ec14de51c96168ce (patch) | |
| tree | 4b3fe24f842485242949c19e3b2d0c078cae7797 /src | |
Initial Commit
Diffstat (limited to 'src')
| l--------- | src/css/css | 1 | ||||
| -rw-r--r-- | src/css/dumbymap.css | 206 | ||||
| -rw-r--r-- | src/css/index.css | 80 | ||||
| -rw-r--r-- | src/css/style.css | 114 | ||||
| -rw-r--r-- | src/css/tiny-mde.min.css | 1 | ||||
| -rw-r--r-- | src/dumbymap.mjs | 357 | ||||
| -rw-r--r-- | src/editor.mjs | 400 |
7 files changed, 1159 insertions, 0 deletions
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 @@ | |||
| 1 | [class^="leader-line"] { | ||
| 2 | z-index: 9999; | ||
| 3 | } | ||
| 4 | |||
| 5 | .with-leader-line:not(:has(> *)) { | ||
| 6 | display: inline-block; | ||
| 7 | left: 0px; | ||
| 8 | top: 0px; | ||
| 9 | |||
| 10 | padding-right: 15px; | ||
| 11 | padding-left: 6px; | ||
| 12 | |||
| 13 | background-image: url("data:image/svg+xml;charset=utf-8;base64,PHN2ZyB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0Ij48cG9seWdvbiBwb2ludHM9IjI0LDAgMCw4IDgsMTEgMCwxOSA1LDI0IDEzLDE2IDE2LDI0IiBmaWxsPSJjb3JhbCIvPjwvc3ZnPg=="); | ||
| 14 | background-size: 12px 12px; | ||
| 15 | background-repeat: no-repeat; | ||
| 16 | background-position: right 2px top 2px; | ||
| 17 | color: #555; | ||
| 18 | |||
| 19 | &.geolink { | ||
| 20 | background-color: rgb(248, 248, 129); | ||
| 21 | } | ||
| 22 | &.doclink { | ||
| 23 | background-color: #9ee7ea; | ||
| 24 | } | ||
| 25 | |||
| 26 | &:hover { | ||
| 27 | padding-right: 6px; | ||
| 28 | background-image: none; | ||
| 29 | |||
| 30 | font-weight: bolder; | ||
| 31 | text-decoration: none; | ||
| 32 | } | ||
| 33 | } | ||
| 34 | |||
| 35 | .result-html { | ||
| 36 | position: relative; | ||
| 37 | max-width: 60em; | ||
| 38 | margin: 0 auto; | ||
| 39 | padding: 0; | ||
| 40 | display: flex; | ||
| 41 | overflow-x: scroll; | ||
| 42 | /* height: 100vh; */ | ||
| 43 | |||
| 44 | #map { | ||
| 45 | flex: 0; | ||
| 46 | #mapPlaceholder { | ||
| 47 | display: none; | ||
| 48 | } | ||
| 49 | } | ||
| 50 | |||
| 51 | #markdown { | ||
| 52 | flex: 1; | ||
| 53 | padding: 20px; | ||
| 54 | overflow-y: scroll; | ||
| 55 | |||
| 56 | > .draggable-block { | ||
| 57 | width: 100%; | ||
| 58 | position: relative; | ||
| 59 | pointer-events: auto; | ||
| 60 | border-radius: 0.5rem; | ||
| 61 | padding: 0.5rem; | ||
| 62 | background-color: white; | ||
| 63 | margin-bottom: 3rem; | ||
| 64 | |||
| 65 | * { | ||
| 66 | width: fit-content; | ||
| 67 | } | ||
| 68 | |||
| 69 | .draggable { | ||
| 70 | display: none; | ||
| 71 | } | ||
| 72 | } | ||
| 73 | |||
| 74 | pre:has(.map-container) { | ||
| 75 | display: flex; | ||
| 76 | justify-content: flex-start; | ||
| 77 | width: 100%; | ||
| 78 | background-color: inherit; | ||
| 79 | } | ||
| 80 | |||
| 81 | .map-container { | ||
| 82 | margin: 2px; | ||
| 83 | &.focus { | ||
| 84 | border: solid gray; | ||
| 85 | } | ||
| 86 | } | ||
| 87 | |||
| 88 | #mapPlaceholder { | ||
| 89 | flex: 1; | ||
| 90 | display: flex; | ||
| 91 | justify-content: center; | ||
| 92 | align-items: center; | ||
| 93 | background-color: lightgray; | ||
| 94 | > * { | ||
| 95 | width: fit-content; | ||
| 96 | flex: 0 0; | ||
| 97 | } | ||
| 98 | } | ||
| 99 | } | ||
| 100 | |||
| 101 | :has(> .map-container) { | ||
| 102 | display: flex; | ||
| 103 | width: fit-content; | ||
| 104 | } | ||
| 105 | |||
| 106 | #map .map-container { | ||
| 107 | width: 100% !important; | ||
| 108 | height: 100% !important; | ||
| 109 | } | ||
| 110 | } | ||
| 111 | |||
| 112 | .result-html[data-layout]:not([data-layout=none]) { | ||
| 113 | margin: 0; | ||
| 114 | |||
| 115 | height: 100vh; | ||
| 116 | width: 100%; | ||
| 117 | max-width: none; | ||
| 118 | border: none; | ||
| 119 | |||
| 120 | & ~ :not(.leader-line) { | ||
| 121 | display: none; | ||
| 122 | pointer-events: none; | ||
| 123 | } | ||
| 124 | } | ||
| 125 | |||
| 126 | .result-html[data-layout=side] { | ||
| 127 | #map, | ||
| 128 | #markdown { | ||
| 129 | flex: 50%; | ||
| 130 | overflow-y: scroll; | ||
| 131 | height: 100vh; | ||
| 132 | } | ||
| 133 | } | ||
| 134 | |||
| 135 | .result-html[data-layout=overlay] { | ||
| 136 | #map, | ||
| 137 | #markdown { | ||
| 138 | position: fixed; | ||
| 139 | height: 100%; | ||
| 140 | width: 100%; | ||
| 141 | } | ||
| 142 | |||
| 143 | #markdown { | ||
| 144 | font-size: 12px; | ||
| 145 | pointer-events: none; | ||
| 146 | |||
| 147 | > .draggable-block { | ||
| 148 | box-sizing: content-box; | ||
| 149 | width: fit-content; | ||
| 150 | max-height: 50vh; | ||
| 151 | overflow: scroll; | ||
| 152 | border: solid gray; | ||
| 153 | |||
| 154 | * { | ||
| 155 | max-width: calc(100vw/4); | ||
| 156 | } | ||
| 157 | |||
| 158 | .draggable { | ||
| 159 | width: 100%; | ||
| 160 | display: block; | ||
| 161 | top: 0; | ||
| 162 | left: 0; | ||
| 163 | position: sticky; | ||
| 164 | margin-bottom: -18px; | ||
| 165 | opacity: 0.3; | ||
| 166 | transform: translate(0, -16px); | ||
| 167 | text-align: center; | ||
| 168 | z-index: 100; | ||
| 169 | transition: all 0.3s ease-out; | ||
| 170 | &:hover { | ||
| 171 | background-color: lightgray; | ||
| 172 | } | ||
| 173 | } | ||
| 174 | } | ||
| 175 | > :not(.draggable-block) { | ||
| 176 | display: none; | ||
| 177 | } | ||
| 178 | } | ||
| 179 | } | ||
| 180 | |||
| 181 | *:has(> nav) { | ||
| 182 | position: absolute; | ||
| 183 | right: 10px; | ||
| 184 | bottom: 10px; | ||
| 185 | /* FIXME */ | ||
| 186 | display: none; | ||
| 187 | |||
| 188 | padding: 0.5rem; | ||
| 189 | min-width: 120px; | ||
| 190 | border: 2px solid gray; | ||
| 191 | border-radius: 0.5rem; | ||
| 192 | background-color: white; | ||
| 193 | z-index: 500; | ||
| 194 | |||
| 195 | &:has(> nav:empty) { | ||
| 196 | display: none; | ||
| 197 | } | ||
| 198 | ol { | ||
| 199 | margin-top: 0; | ||
| 200 | margin-bottom: 0.5rem; | ||
| 201 | } | ||
| 202 | } | ||
| 203 | |||
| 204 | .bold-options { | ||
| 205 | font-weight: bold; | ||
| 206 | } | ||
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 @@ | |||
| 1 | body { | ||
| 2 | display: flex; | ||
| 3 | justify-items: stretch; | ||
| 4 | } | ||
| 5 | |||
| 6 | .result-html { | ||
| 7 | flex: 1; | ||
| 8 | height: calc(100vh - 20px); | ||
| 9 | border: solid lightgray 2px; | ||
| 10 | border-radius: 5px; | ||
| 11 | margin: 10px; | ||
| 12 | } | ||
| 13 | |||
| 14 | .editor { | ||
| 15 | flex: 1; | ||
| 16 | max-width: 50vw; | ||
| 17 | margin: 10px; | ||
| 18 | } | ||
| 19 | |||
| 20 | .TinyMDE { | ||
| 21 | height: calc(100vh - 55px); | ||
| 22 | padding: 16px; | ||
| 23 | overflow-y: scroll; | ||
| 24 | border: 2px solid lightgray; | ||
| 25 | border-radius: 5px; | ||
| 26 | |||
| 27 | span { | ||
| 28 | white-space: pre; | ||
| 29 | } | ||
| 30 | .invalid-input { | ||
| 31 | text-decoration: red wavy underline 1px; | ||
| 32 | } | ||
| 33 | } | ||
| 34 | |||
| 35 | .container__suggestions { | ||
| 36 | display: none; | ||
| 37 | position: absolute; | ||
| 38 | width: fit-content; | ||
| 39 | min-width: 10rem; | ||
| 40 | max-height: 40vh; | ||
| 41 | overflow-y: scroll; | ||
| 42 | border: 2px solid lightgray; | ||
| 43 | border-radius: 0.5rem; | ||
| 44 | |||
| 45 | background: #fff; | ||
| 46 | } | ||
| 47 | .container__suggestion { | ||
| 48 | cursor: pointer; | ||
| 49 | display: flex; | ||
| 50 | justify-content: space-between; | ||
| 51 | overflow: hidden; | ||
| 52 | |||
| 53 | height: fit-content; | ||
| 54 | min-height: 2rem; | ||
| 55 | white-space: nowrap; | ||
| 56 | align-items: center; | ||
| 57 | |||
| 58 | * { | ||
| 59 | flex-shrink: 0; | ||
| 60 | display: inline-block; | ||
| 61 | overflow: hidden; | ||
| 62 | padding-inline: 1em; | ||
| 63 | } | ||
| 64 | .info { | ||
| 65 | color: #4682B4; | ||
| 66 | font-weight: bold; | ||
| 67 | } | ||
| 68 | .truncate { | ||
| 69 | text-overflow: ellipsis; | ||
| 70 | ::before { | ||
| 71 | width: 2rem | ||
| 72 | } | ||
| 73 | } | ||
| 74 | } | ||
| 75 | .container__suggestion:not(:first-child) { | ||
| 76 | border-top: 1px solid rgb(203 213 225); | ||
| 77 | } | ||
| 78 | .container__suggestion.focus { | ||
| 79 | background: rgb(226 232 240); | ||
| 80 | } | ||
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 @@ | |||
| 1 | /* CSS Reset */ | ||
| 2 | *, *::before, *::after { | ||
| 3 | box-sizing: border-box; | ||
| 4 | } | ||
| 5 | |||
| 6 | * { | ||
| 7 | margin: 0; | ||
| 8 | line-height: calc(1em + 0.5rem); | ||
| 9 | } | ||
| 10 | |||
| 11 | img, picture, video, canvas, svg { | ||
| 12 | display: block; | ||
| 13 | max-width: 100%; | ||
| 14 | } | ||
| 15 | |||
| 16 | input, button, textarea, select { | ||
| 17 | font: inherit; | ||
| 18 | } | ||
| 19 | |||
| 20 | p, h1, h2, h3, h4, h5, h6 { | ||
| 21 | overflow-wrap: break-word; | ||
| 22 | hyphens: auto; | ||
| 23 | } | ||
| 24 | /* End of Reset */ | ||
| 25 | |||
| 26 | body { | ||
| 27 | font-family: sans-serif; | ||
| 28 | line-height: 1.6; | ||
| 29 | } | ||
| 30 | |||
| 31 | h1, h2, h3, h4, h5, h6 { | ||
| 32 | font-weight: bold; | ||
| 33 | margin-bottom: 0.5em; | ||
| 34 | } | ||
| 35 | |||
| 36 | h1 { | ||
| 37 | font-size: 2em; | ||
| 38 | } | ||
| 39 | |||
| 40 | h2 { | ||
| 41 | font-size: 1.5em; | ||
| 42 | } | ||
| 43 | |||
| 44 | h3 { | ||
| 45 | font-size: 1.25em; | ||
| 46 | } | ||
| 47 | |||
| 48 | p { | ||
| 49 | margin-top: 1em; | ||
| 50 | margin-bottom: 1em; | ||
| 51 | } | ||
| 52 | |||
| 53 | a { | ||
| 54 | color: #007bff; | ||
| 55 | text-decoration: none; | ||
| 56 | } | ||
| 57 | |||
| 58 | a:hover { | ||
| 59 | text-decoration: underline; | ||
| 60 | } | ||
| 61 | |||
| 62 | pre:has([class^="language-"]) { | ||
| 63 | min-width: 400px; | ||
| 64 | display: block; | ||
| 65 | padding: 9.5px; | ||
| 66 | margin: 0 0 10px; | ||
| 67 | font-size: 13px; | ||
| 68 | line-height: 1.42857143; | ||
| 69 | color: #333; | ||
| 70 | word-break: break-all; | ||
| 71 | word-wrap: break-word; | ||
| 72 | background-color: #f5f5f5; | ||
| 73 | border: 1px solid #ccc; | ||
| 74 | border-radius: 4px; | ||
| 75 | |||
| 76 | code { | ||
| 77 | padding: 0; | ||
| 78 | font-size: inherit; | ||
| 79 | color: inherit; | ||
| 80 | white-space: pre-wrap; | ||
| 81 | background-color: transparent; | ||
| 82 | border-radius: 0; | ||
| 83 | } | ||
| 84 | } | ||
| 85 | |||
| 86 | ul, ol { | ||
| 87 | margin-top: 1em; | ||
| 88 | margin-bottom: 1em; | ||
| 89 | padding-left: 20px; | ||
| 90 | } | ||
| 91 | |||
| 92 | li { | ||
| 93 | margin-bottom: 0.5em; | ||
| 94 | } | ||
| 95 | |||
| 96 | table { | ||
| 97 | border-collapse: collapse; | ||
| 98 | width: 100%; | ||
| 99 | } | ||
| 100 | |||
| 101 | th, td { | ||
| 102 | border: 1px solid #ddd; | ||
| 103 | padding: 8px; | ||
| 104 | text-align: left; | ||
| 105 | } | ||
| 106 | |||
| 107 | th { | ||
| 108 | background-color: #f0f0f0; | ||
| 109 | } | ||
| 110 | |||
| 111 | img { | ||
| 112 | max-width: 100%; | ||
| 113 | height: auto; | ||
| 114 | } | ||
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 @@ | |||
| 1 | // vim:foldmethod | ||
| 2 | import MarkdownIt from 'markdown-it' | ||
| 3 | import MarkdownItAnchor from 'markdown-it-anchor' | ||
| 4 | import MarkdownItFootnote from 'markdown-it-footnote' | ||
| 5 | import MarkdownItFrontMatter from 'markdown-it-front-matter' | ||
| 6 | import MarkdownItTocDoneRight from 'markdown-it-toc-done-right' | ||
| 7 | import LeaderLine from 'leader-line' | ||
| 8 | import PlainDraggable from 'plain-draggable' | ||
| 9 | import { render, parseConfigsFromText } from 'mapclay' | ||
| 10 | |||
| 11 | const observers = new Map() | ||
| 12 | |||
| 13 | export const markdown2HTML = async (container, mdContent) => { | ||
| 14 | // Render: Markdown -> HTML {{{ | ||
| 15 | |||
| 16 | container.innerHTML = ` | ||
| 17 | <div id="map"></div> | ||
| 18 | <div id="markdown"></div> | ||
| 19 | ` | ||
| 20 | |||
| 21 | const md = MarkdownIt({ html: true }) | ||
| 22 | .use(MarkdownItAnchor, { | ||
| 23 | permalink: MarkdownItAnchor.permalink.linkInsideHeader({ placement: 'before' }) | ||
| 24 | }) | ||
| 25 | .use(MarkdownItFootnote) | ||
| 26 | .use(MarkdownItFrontMatter) | ||
| 27 | .use(MarkdownItTocDoneRight) | ||
| 28 | |||
| 29 | // FIXME A better way to generate draggable code block | ||
| 30 | md.renderer.rules.draggable_block_open = () => '<div>' | ||
| 31 | md.renderer.rules.draggable_block_close = () => '</div>' | ||
| 32 | |||
| 33 | md.core.ruler.before('block', 'draggable_block', (state) => { | ||
| 34 | state.tokens.push(new state.Token('draggable_block_open', '', 1)) | ||
| 35 | }) | ||
| 36 | |||
| 37 | // Add close tag for block with more than 2 empty lines | ||
| 38 | md.block.ruler.before('table', 'draggable_block', (state, startLine) => { | ||
| 39 | if (state.src[state.bMarks[startLine - 1]] === '\n' && state.src[state.bMarks[startLine - 2]] === '\n') { | ||
| 40 | state.push('draggable_block_close', '', -1); | ||
| 41 | state.push('draggable_block_open', '', 1); | ||
| 42 | } | ||
| 43 | }) | ||
| 44 | |||
| 45 | md.core.ruler.after('block', 'draggable_block', (state) => { | ||
| 46 | state.tokens.push(new state.Token('draggable_block_close', '', -1)) | ||
| 47 | }) | ||
| 48 | |||
| 49 | const markdown = container.querySelector('#markdown') | ||
| 50 | const contentWithToc = '${toc}\n\n\n' + mdContent | ||
| 51 | markdown.innerHTML = md.render(contentWithToc); | ||
| 52 | markdown.querySelectorAll('*> div:not(:has(nav))') | ||
| 53 | .forEach(b => b.classList.add('draggable-block')) | ||
| 54 | |||
| 55 | |||
| 56 | // TODO Improve it! | ||
| 57 | const docLinks = Array.from(container.querySelectorAll('#markdown a[href^="#"][title^="doc"]')) | ||
| 58 | docLinks.forEach(link => { | ||
| 59 | link.classList.add('with-leader-line', 'doclink') | ||
| 60 | link.lines = [] | ||
| 61 | |||
| 62 | link.onmouseover = () => { | ||
| 63 | const target = document.querySelector(link.getAttribute('href')) | ||
| 64 | if (!target?.checkVisibility()) return | ||
| 65 | |||
| 66 | const line = new LeaderLine({ | ||
| 67 | start: link, | ||
| 68 | end: target, | ||
| 69 | hide: true, | ||
| 70 | path: "magnet" | ||
| 71 | }) | ||
| 72 | link.lines.push(line) | ||
| 73 | line.show('draw', { duration: 300, }) | ||
| 74 | } | ||
| 75 | link.onmouseout = () => { | ||
| 76 | link.lines.forEach(line => line.remove()) | ||
| 77 | link.lines.length = 0 | ||
| 78 | } | ||
| 79 | }) | ||
| 80 | //}}} | ||
| 81 | } | ||
| 82 | |||
| 83 | export const generateMaps = async (container) => { | ||
| 84 | // LeaderLine {{{ | ||
| 85 | |||
| 86 | // Get anchors with "geo:" scheme | ||
| 87 | const markdown = container.querySelector('#markdown') | ||
| 88 | markdown.anchors = [] | ||
| 89 | |||
| 90 | // Set focusArea | ||
| 91 | const focusArea = container.querySelector('#map') | ||
| 92 | const mapPlaceholder = document.createElement('div') | ||
| 93 | mapPlaceholder.id = 'mapPlaceholder' | ||
| 94 | focusArea.appendChild(mapPlaceholder) | ||
| 95 | |||
| 96 | // Links points to map by geo schema and id | ||
| 97 | const geoLinks = Array.from(container.querySelectorAll('#markdown a[href^="geo:"]')) | ||
| 98 | .filter(link => { | ||
| 99 | const url = new URL(link.href) | ||
| 100 | const xy = url?.href?.match(/^geo:([0-9.,]+)/)?.at(1)?.split(',')?.reverse()?.map(Number) | ||
| 101 | |||
| 102 | if (!xy || isNaN(xy[0]) || isNaN(xy[1])) return false | ||
| 103 | |||
| 104 | // Geo information in link | ||
| 105 | link.url = url | ||
| 106 | link.xy = xy | ||
| 107 | link.classList.add('with-leader-line', 'geolink') | ||
| 108 | link.targets = link.url.searchParams.get('id')?.split(',') ?? null | ||
| 109 | |||
| 110 | // LeaderLine | ||
| 111 | link.lines = [] | ||
| 112 | link.onmouseover = () => addLeaderLines(link) | ||
| 113 | link.onmouseout = () => removeLeaderLines(link) | ||
| 114 | link.onclick = (event) => { | ||
| 115 | event.preventDefault() | ||
| 116 | markdown.anchors | ||
| 117 | .filter(isAnchorPointedBy(link)) | ||
| 118 | .forEach(updateMapByMarker(xy)) | ||
| 119 | // TODO Just hide leader line and show it again | ||
| 120 | removeLeaderLines(link) | ||
| 121 | } | ||
| 122 | |||
| 123 | return true | ||
| 124 | }) | ||
| 125 | |||
| 126 | const isAnchorPointedBy = (link) => (anchor) => { | ||
| 127 | const mapContainer = anchor.closest('.map-container') | ||
| 128 | const isTarget = !link.targets || link.targets.includes(mapContainer.id) | ||
| 129 | return anchor.title === link.url.pathname && isTarget | ||
| 130 | } | ||
| 131 | |||
| 132 | const isAnchorVisible = (anchor) => { | ||
| 133 | const mapContainer = anchor.closest('.map-container') | ||
| 134 | return insideWindow(anchor) && insideParent(anchor, mapContainer) | ||
| 135 | } | ||
| 136 | |||
| 137 | const drawLeaderLine = (link) => (anchor) => { | ||
| 138 | const line = new LeaderLine({ | ||
| 139 | start: link, | ||
| 140 | end: anchor, | ||
| 141 | hide: true, | ||
| 142 | middleLabel: link.url.searchParams.get('text'), | ||
| 143 | path: "magnet", | ||
| 144 | }) | ||
| 145 | line.show('draw', { duration: 300, }) | ||
| 146 | return line | ||
| 147 | } | ||
| 148 | |||
| 149 | const addLeaderLines = (link) => { | ||
| 150 | link.lines = markdown.anchors | ||
| 151 | .filter(isAnchorPointedBy(link)) | ||
| 152 | .filter(isAnchorVisible) | ||
| 153 | .map(drawLeaderLine(link)) | ||
| 154 | } | ||
| 155 | |||
| 156 | const removeLeaderLines = (link) => { | ||
| 157 | if (!link.lines) return | ||
| 158 | link.lines.forEach(line => line.remove()) | ||
| 159 | link.lines = [] | ||
| 160 | } | ||
| 161 | |||
| 162 | const updateMapByMarker = (xy) => (marker) => { | ||
| 163 | const renderer = marker.closest('.map-container')?.renderer | ||
| 164 | renderer.updateCamera({ center: xy }, true) | ||
| 165 | } | ||
| 166 | |||
| 167 | const insideWindow = (element) => { | ||
| 168 | const rect = element.getBoundingClientRect() | ||
| 169 | return rect.left > 0 && | ||
| 170 | rect.right < window.innerWidth + rect.width && | ||
| 171 | rect.top > 0 && | ||
| 172 | rect.bottom < window.innerHeight + rect.height | ||
| 173 | } | ||
| 174 | |||
| 175 | const insideParent = (childElement, parentElement) => { | ||
| 176 | const childRect = childElement.getBoundingClientRect(); | ||
| 177 | const parentRect = parentElement.getBoundingClientRect(); | ||
| 178 | const offset = 20 | ||
| 179 | |||
| 180 | return childRect.left > parentRect.left + offset && | ||
| 181 | childRect.right < parentRect.right - offset && | ||
| 182 | childRect.top > parentRect.top + offset && | ||
| 183 | childRect.bottom < parentRect.bottom - offset | ||
| 184 | } | ||
| 185 | //}}} | ||
| 186 | // Render Maps {{{ | ||
| 187 | |||
| 188 | const afterEachMapLoaded = (mapContainer) => { | ||
| 189 | mapContainer.querySelectorAll('.marker') | ||
| 190 | .forEach(marker => markdown.anchors.push(marker)) | ||
| 191 | |||
| 192 | const focusClickedMap = () => { | ||
| 193 | if (container.getAttribute('data-layout') !== 'none') return | ||
| 194 | |||
| 195 | container.querySelectorAll('.map-container') | ||
| 196 | .forEach(c => c.classList.remove('focus')) | ||
| 197 | mapContainer.classList.add('focus') | ||
| 198 | } | ||
| 199 | mapContainer.onclick = focusClickedMap | ||
| 200 | } | ||
| 201 | |||
| 202 | // Set unique ID for map container | ||
| 203 | const mapIdList = [] | ||
| 204 | const assignMapId = (config) => { | ||
| 205 | let mapId = config.id | ||
| 206 | if (!mapId) { | ||
| 207 | mapId = config.use?.split('/')?.at(-1) | ||
| 208 | let counter = 2 | ||
| 209 | while (mapIdList.includes(mapId)) { | ||
| 210 | mapId = `${config.use}.${counter}` | ||
| 211 | counter++ | ||
| 212 | } | ||
| 213 | config.id = mapId | ||
| 214 | } | ||
| 215 | mapIdList.push(mapId) | ||
| 216 | } | ||
| 217 | |||
| 218 | const markerOptions = geoLinks.map(link => { | ||
| 219 | return { | ||
| 220 | targets: link.targets, | ||
| 221 | xy: link.xy, | ||
| 222 | title: link.url.pathname | ||
| 223 | } | ||
| 224 | }) | ||
| 225 | |||
| 226 | |||
| 227 | // Render each code block with "language-map" class | ||
| 228 | const renderTargets = Array.from(container.querySelectorAll('pre:has(.language-map)')) | ||
| 229 | const renderAllTargets = renderTargets.map(async (target) => { | ||
| 230 | // Get text in code block starts with '```map' | ||
| 231 | // BE CAREFUL!!! 0xa0 char is "non-breaking spaces" in HTML text content | ||
| 232 | // replace it by normal space | ||
| 233 | const configText = target.querySelector('.language-map').textContent.replace(/\u00A0/g, '\u0020') | ||
| 234 | |||
| 235 | let configList = [] | ||
| 236 | try { | ||
| 237 | configList = parseConfigsFromText(configText).map(result => { | ||
| 238 | assignMapId(result) | ||
| 239 | const markersFromLinks = markerOptions.filter(marker => | ||
| 240 | !marker.targets || marker.targets.includes(result.id) | ||
| 241 | ) | ||
| 242 | Object.assign(result, { markers: markersFromLinks }) | ||
| 243 | return result | ||
| 244 | }) | ||
| 245 | } catch (err) { | ||
| 246 | console.error('Fail to parse yaml config for element', target, err) | ||
| 247 | } | ||
| 248 | |||
| 249 | // Render maps | ||
| 250 | return render(target, configList) | ||
| 251 | .then(results => { | ||
| 252 | results.forEach((mapByConfig) => { | ||
| 253 | if (mapByConfig.status === 'fulfilled') { | ||
| 254 | afterEachMapLoaded(mapByConfig.value) | ||
| 255 | return mapByConfig.value | ||
| 256 | } else { | ||
| 257 | console.error('Fail to render target element', mapByConfig.value) | ||
| 258 | } | ||
| 259 | }) | ||
| 260 | }) | ||
| 261 | }) | ||
| 262 | const renderInfo = await Promise.all(renderAllTargets).then(() => 'Finish Rendering') | ||
| 263 | console.info(renderInfo) | ||
| 264 | |||
| 265 | //}}} | ||
| 266 | // CSS observer {{{ | ||
| 267 | if (!observers.get(container)) { | ||
| 268 | observers.set(container, []) | ||
| 269 | } | ||
| 270 | const obs = observers.get(container) | ||
| 271 | if (obs.length) { | ||
| 272 | obs.forEach(o => o.disconnect()) | ||
| 273 | obs.length = 0 | ||
| 274 | } | ||
| 275 | // Layout{{{ | ||
| 276 | |||
| 277 | // press key to switch layout | ||
| 278 | const layouts = ['none', 'side', 'overlay'] | ||
| 279 | container.setAttribute("data-layout", layouts[0]) | ||
| 280 | document.onkeydown = (event) => { | ||
| 281 | if (event.key === 'x' && container.querySelector('.map-container')) { | ||
| 282 | let currentLayout = container.getAttribute('data-layout') | ||
| 283 | currentLayout = currentLayout ? currentLayout : 'none' | ||
| 284 | const nextLayout = layouts[(layouts.indexOf(currentLayout) + 1) % layouts.length] | ||
| 285 | |||
| 286 | container.setAttribute("data-layout", nextLayout) | ||
| 287 | } | ||
| 288 | } | ||
| 289 | |||
| 290 | // Add draggable part for blocks | ||
| 291 | markdown.blocks = Array.from(markdown.querySelectorAll('.draggable-block')) | ||
| 292 | markdown.blocks.forEach(block => { | ||
| 293 | const draggablePart = document.createElement('div'); | ||
| 294 | draggablePart.classList.add('draggable') | ||
| 295 | draggablePart.textContent = '☰' | ||
| 296 | |||
| 297 | // TODO Better way to close block | ||
| 298 | draggablePart.onmouseup = (e) => { | ||
| 299 | if (e.button === 1) block.style.display = "none"; | ||
| 300 | } | ||
| 301 | block.insertBefore(draggablePart, block.firstChild) | ||
| 302 | }) | ||
| 303 | |||
| 304 | // observe layout change | ||
| 305 | const layoutObserver = new MutationObserver(() => { | ||
| 306 | const layout = container.getAttribute('data-layout') | ||
| 307 | markdown.blocks.forEach(b => b.style.display = "block") | ||
| 308 | |||
| 309 | if (layout === 'none') { | ||
| 310 | mapPlaceholder.innerHTML = "" | ||
| 311 | const map = focusArea.querySelector('.map-container') | ||
| 312 | // Swap focused map and palceholder in markdown | ||
| 313 | if (map) { | ||
| 314 | mapPlaceholder.parentElement?.replaceChild(map, mapPlaceholder) | ||
| 315 | focusArea.append(mapPlaceholder) | ||
| 316 | } | ||
| 317 | } else { | ||
| 318 | // If paceholder is not set, create one and put map into focusArea | ||
| 319 | if (focusArea.contains(mapPlaceholder)) { | ||
| 320 | const mapContainer = container.querySelector('.map-container.focus') ?? container.querySelector('.map-container') | ||
| 321 | mapPlaceholder.innerHTML = `<div>Placeholder</div>` | ||
| 322 | // TODO | ||
| 323 | // mapPlaceholder.src = map.map.getCanvas().toDataURL() | ||
| 324 | mapContainer.parentElement?.replaceChild(mapPlaceholder, mapContainer) | ||
| 325 | focusArea.appendChild(mapContainer) | ||
| 326 | } | ||
| 327 | } | ||
| 328 | |||
| 329 | if (layout === 'overlay') { | ||
| 330 | markdown.blocks.forEach(block => { | ||
| 331 | block.draggableInstance = new PlainDraggable(block, { handle: block.querySelector('.draggable') }) | ||
| 332 | block.draggableInstance.snap = { x: { step: 20 }, y: { step: 20 } } | ||
| 333 | // block.draggableInstance.onDragEnd = () => { | ||
| 334 | // links(block).forEach(link => link.line.position()) | ||
| 335 | // } | ||
| 336 | }) | ||
| 337 | } else { | ||
| 338 | markdown.blocks.forEach(block => { | ||
| 339 | try { | ||
| 340 | block.style.transform = 'none' | ||
| 341 | block.draggableInstance.remove() | ||
| 342 | } catch (err) { | ||
| 343 | console.warn('Fail to remove draggable instance', err) | ||
| 344 | } | ||
| 345 | }) | ||
| 346 | } | ||
| 347 | }); | ||
| 348 | layoutObserver.observe(container, { | ||
| 349 | attributes: true, | ||
| 350 | attributeFilter: ["data-layout"], | ||
| 351 | attributeOldValue: true | ||
| 352 | }); | ||
| 353 | obs.push(layoutObserver) | ||
| 354 | //}}} | ||
| 355 | //}}} | ||
| 356 | return container | ||
| 357 | } | ||
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 @@ | |||
| 1 | import TinyMDE from 'tiny-markdown-editor' | ||
| 2 | import { markdown2HTML, generateMaps } from './dumbymap' | ||
| 3 | import { defaultAliasesForRenderer, parseConfigsFromText } from 'mapclay' | ||
| 4 | |||
| 5 | // Set up Editor {{{ | ||
| 6 | |||
| 7 | const HtmlContainer = document.querySelector(".result-html") | ||
| 8 | const mdeElement = document.querySelector("#tinymde") | ||
| 9 | |||
| 10 | // Add Editor | ||
| 11 | 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' | ||
| 12 | const lastContent = localStorage.getItem('editorContent') | ||
| 13 | const tinyEditor = new TinyMDE.Editor({ | ||
| 14 | element: 'tinymde', | ||
| 15 | content: lastContent ? lastContent : defaultContent | ||
| 16 | }); | ||
| 17 | mdeElement.querySelectorAll('span').forEach(e => e.setAttribute('spellcheck', 'false')) | ||
| 18 | |||
| 19 | // Add command bar for editor | ||
| 20 | // Use this command to render maps and geoLinks | ||
| 21 | const mapCommand = { | ||
| 22 | name: 'map', | ||
| 23 | title: 'Switch Map Generation', | ||
| 24 | innerHTML: `<div style="font-size: 16px; line-height: 1.1;">🌏</div>`, | ||
| 25 | action: () => { | ||
| 26 | if (!HtmlContainer.querySelector('.map-container')) { | ||
| 27 | generateMaps(HtmlContainer) | ||
| 28 | document.activeElement.blur(); | ||
| 29 | } else { | ||
| 30 | markdown2HTML(HtmlContainer, tinyEditor.getContent()) | ||
| 31 | HtmlContainer.setAttribute('data-layout', 'none') | ||
| 32 | } | ||
| 33 | }, | ||
| 34 | hotkey: 'Ctrl-m' | ||
| 35 | } | ||
| 36 | const debugCommand = { | ||
| 37 | name: 'debug', | ||
| 38 | title: 'show debug message', | ||
| 39 | innerHTML: `<div style="font-size: 16px; line-height: 1.1;">🤔</div>`, | ||
| 40 | action: () => { | ||
| 41 | // FIXME | ||
| 42 | alert(tinyEditor.getContent()) | ||
| 43 | }, | ||
| 44 | hotkey: 'Ctrl-i' | ||
| 45 | } | ||
| 46 | // Set up command bar | ||
| 47 | new TinyMDE.CommandBar({ | ||
| 48 | element: 'tinymde_commandbar', editor: tinyEditor, | ||
| 49 | commands: [mapCommand, debugCommand, '|', 'h1', 'h2', '|', 'insertLink', 'insertImage', '|', 'bold', 'italic', 'strikethrough', 'code', '|', 'ul', 'ol', '|', 'blockquote'] | ||
| 50 | }); | ||
| 51 | |||
| 52 | // Render HTML to result container | ||
| 53 | markdown2HTML(HtmlContainer, tinyEditor.getContent()) | ||
| 54 | |||
| 55 | // FIXME DEBUGONLY | ||
| 56 | // generateMaps(HtmlContainer) | ||
| 57 | // setTimeout(() => { | ||
| 58 | // HtmlContainer.setAttribute("data-layout", 'side') | ||
| 59 | // }, 500) | ||
| 60 | |||
| 61 | // }}} | ||
| 62 | // Event Listener: change {{{ | ||
| 63 | |||
| 64 | // Save editor content to local storage, set timeout for 3 seconds | ||
| 65 | let rejectLastSaving | ||
| 66 | const saveContent = (content) => { | ||
| 67 | new Promise((resolve, reject) => { | ||
| 68 | // If user is typing, the last change cancel previous ones | ||
| 69 | if (rejectLastSaving) rejectLastSaving(content.length) | ||
| 70 | rejectLastSaving = reject | ||
| 71 | |||
| 72 | setTimeout(() => { | ||
| 73 | localStorage.setItem('editorContent', content) | ||
| 74 | resolve('Content Saved') | ||
| 75 | }, 3000) | ||
| 76 | }).catch(() => null) | ||
| 77 | } | ||
| 78 | |||
| 79 | // Render HTML to result container and save current content | ||
| 80 | tinyEditor.addEventListener('change', e => { | ||
| 81 | markdown2HTML(HtmlContainer, e.content) | ||
| 82 | saveContent(e.content) | ||
| 83 | }); | ||
| 84 | // }}} | ||
| 85 | // Completion in Code Blok {{{ | ||
| 86 | // Elements about suggestions {{{ | ||
| 87 | const suggestionsEle = document.createElement('div') | ||
| 88 | suggestionsEle.classList.add('container__suggestions'); | ||
| 89 | mdeElement.appendChild(suggestionsEle) | ||
| 90 | |||
| 91 | const rendererOptions = {} | ||
| 92 | |||
| 93 | class Suggestion { | ||
| 94 | constructor({ text, replace }) { | ||
| 95 | this.text = text | ||
| 96 | this.replace = replace | ||
| 97 | } | ||
| 98 | } | ||
| 99 | |||
| 100 | // }}} | ||
| 101 | // {{{ Aliases for map options | ||
| 102 | const aliasesForMapOptions = {} | ||
| 103 | const defaultApply = '/default.yml' | ||
| 104 | fetch(defaultApply) | ||
| 105 | .then(res => res.text()) | ||
| 106 | .then(rawText => { | ||
| 107 | const config = parseConfigsFromText(rawText)?.at(0) | ||
| 108 | Object.assign(aliasesForMapOptions, config.aliases ?? {}) | ||
| 109 | }) | ||
| 110 | .catch(err => console.warn(`Fail to get aliases from ${defaultApply}`, err)) | ||
| 111 | // }}} | ||
| 112 | // FUNCTION: Check cursor is inside map code block {{{ | ||
| 113 | const insideCodeblockForMap = (element) => { | ||
| 114 | const code = element.closest('.TMFencedCodeBacktick') | ||
| 115 | if (!code) return false | ||
| 116 | |||
| 117 | let ps = code.previousSibling | ||
| 118 | if (!ps) return false | ||
| 119 | |||
| 120 | // Look backward to find pattern of code block: /```map/ | ||
| 121 | while (!ps.classList.contains('TMCodeFenceBacktickOpen')) { | ||
| 122 | ps = ps.previousSibling | ||
| 123 | if (!ps) return false | ||
| 124 | if (ps.classList.contains('TMCodeFenceBacktickClose')) return false | ||
| 125 | } | ||
| 126 | |||
| 127 | return ps.querySelector('.TMInfoString')?.textContent === 'map' | ||
| 128 | } | ||
| 129 | // }}} | ||
| 130 | // FUNCTION: Get renderer by cursor position in code block {{{ | ||
| 131 | const getLineWithRenderer = (element) => { | ||
| 132 | const currentLine = element.closest('.TMFencedCodeBacktick') | ||
| 133 | if (!currentLine) return null | ||
| 134 | |||
| 135 | // Look backward/forward for pattern of used renderer: /use: .+/ | ||
| 136 | let ps = currentLine | ||
| 137 | do { | ||
| 138 | ps = ps.previousSibling | ||
| 139 | if (ps.textContent.match(/^use: /)) { | ||
| 140 | return ps | ||
| 141 | } else if (ps.textContent.match(/^---/)) { | ||
| 142 | // If yaml doc separator is found | ||
| 143 | break | ||
| 144 | } | ||
| 145 | } while (ps && ps.classList.contains('TMFencedCodeBacktick')) | ||
| 146 | |||
| 147 | let ns = currentLine | ||
| 148 | do { | ||
| 149 | ns = ns.nextSibling | ||
| 150 | if (ns.textContent.match(/^use: /)) { | ||
| 151 | return ns | ||
| 152 | } else if (ns.textContent.match(/^---/)) { | ||
| 153 | // If yaml doc separator is found | ||
| 154 | return null | ||
| 155 | } | ||
| 156 | } while (ns && ns.classList.contains('TMFencedCodeBacktick')) | ||
| 157 | |||
| 158 | return null | ||
| 159 | } | ||
| 160 | // }}} | ||
| 161 | // FUNCTION: Add suggestions for current selection {{{ | ||
| 162 | const getSuggestionsForOptions = (optionTyped, validOptions) => { | ||
| 163 | let suggestOptions = [] | ||
| 164 | |||
| 165 | const matchedOptions = validOptions | ||
| 166 | .filter(o => o.valueOf().toLowerCase().includes(optionTyped.toLowerCase())) | ||
| 167 | |||
| 168 | if (matchedOptions.length > 0) { | ||
| 169 | suggestOptions = matchedOptions | ||
| 170 | } else { | ||
| 171 | suggestOptions = validOptions | ||
| 172 | } | ||
| 173 | |||
| 174 | return suggestOptions | ||
| 175 | .map(o => new Suggestion({ | ||
| 176 | text: `<span>${o.valueOf()}</span><span class='info' title="${o.desc ?? ''}">ⓘ</span>`, | ||
| 177 | replace: `${o.valueOf()}: `, | ||
| 178 | })) | ||
| 179 | } | ||
| 180 | |||
| 181 | const getSuggestionFromMapOption = (option) => { | ||
| 182 | if (!option.example) return null | ||
| 183 | |||
| 184 | const text = option.example_desc | ||
| 185 | ? `<span>${option.example_desc}</span><span class="truncate"style="color: gray">${option.example}</span>` | ||
| 186 | : `<span>${option.example}</span>` | ||
| 187 | |||
| 188 | return new Suggestion({ | ||
| 189 | text: text, | ||
| 190 | replace: `${option.valueOf()}: ${option.example ?? ""}`, | ||
| 191 | }) | ||
| 192 | } | ||
| 193 | |||
| 194 | const getSuggestionsFromAliases = (option) => Object.entries(aliasesForMapOptions[option.valueOf()] ?? {}) | ||
| 195 | ?.map(record => { | ||
| 196 | const [alias, value] = record | ||
| 197 | const valueString = JSON.stringify(value).replaceAll('"', '') | ||
| 198 | return new Suggestion({ | ||
| 199 | text: `<span>${alias}</span><span class="truncate" style="color: gray">${valueString}</span>`, | ||
| 200 | replace: `${option.valueOf()}: ${valueString}`, | ||
| 201 | }) | ||
| 202 | }) | ||
| 203 | ?? [] | ||
| 204 | |||
| 205 | // Add HTML element for List of suggestions | ||
| 206 | const addSuggestions = (currentLine, selection) => { | ||
| 207 | const text = currentLine.textContent | ||
| 208 | const markInputIsInvalid = (ele) => (ele ?? currentLine).classList.add('invalid-input') | ||
| 209 | let suggestions = [] | ||
| 210 | |||
| 211 | // Check if "use: <renderer>" is set | ||
| 212 | const lineWithRenderer = getLineWithRenderer(currentLine) | ||
| 213 | const renderer = lineWithRenderer?.textContent.split(' ')[1] | ||
| 214 | if (renderer) { | ||
| 215 | |||
| 216 | // Do not check properties | ||
| 217 | if (text.startsWith(' ')) { | ||
| 218 | return | ||
| 219 | } | ||
| 220 | |||
| 221 | // If no valid options for current used renderer, go get it! | ||
| 222 | const validOptions = rendererOptions[renderer] | ||
| 223 | if (!validOptions) { | ||
| 224 | // Get list of valid options for current renderer | ||
| 225 | const rendererUrl = defaultAliasesForRenderer.use[renderer]?.value | ||
| 226 | import(rendererUrl) | ||
| 227 | .then(rendererModule => { | ||
| 228 | rendererOptions[renderer] = rendererModule.default.validOptions | ||
| 229 | }) | ||
| 230 | .catch(() => { | ||
| 231 | markInputIsInvalid(lineWithRenderer) | ||
| 232 | console.error('Fail to get valid options from renderer, URL is', rendererUrl) | ||
| 233 | }) | ||
| 234 | return | ||
| 235 | } | ||
| 236 | |||
| 237 | // If input is "key:value" (no space left after colon), then it is invalid | ||
| 238 | const isKeyFinished = text.includes(':'); | ||
| 239 | const isValidKeyValue = text.match(/^[^:]+:\s+/) | ||
| 240 | if (isKeyFinished && !isValidKeyValue) { | ||
| 241 | markInputIsInvalid() | ||
| 242 | return | ||
| 243 | } | ||
| 244 | |||
| 245 | // If user is typing option | ||
| 246 | const keyTyped = text.split(':')[0].trim() | ||
| 247 | if (!isKeyFinished) { | ||
| 248 | suggestions = getSuggestionsForOptions(keyTyped, validOptions) | ||
| 249 | markInputIsInvalid() | ||
| 250 | } | ||
| 251 | |||
| 252 | // If user is typing value | ||
| 253 | const matchedOption = validOptions.find(o => o.name === keyTyped) | ||
| 254 | if (isKeyFinished && !matchedOption) { | ||
| 255 | markInputIsInvalid() | ||
| 256 | } | ||
| 257 | |||
| 258 | if (isKeyFinished && matchedOption) { | ||
| 259 | const valueTyped = text.substring(text.indexOf(':') + 1).trim() | ||
| 260 | const isValidValue = matchedOption.isValid(valueTyped) | ||
| 261 | if (!valueTyped) { | ||
| 262 | suggestions = [ | ||
| 263 | getSuggestionFromMapOption(matchedOption), | ||
| 264 | ...getSuggestionsFromAliases(matchedOption) | ||
| 265 | ].filter(s => s instanceof Suggestion) | ||
| 266 | } | ||
| 267 | if (valueTyped && !isValidValue) { | ||
| 268 | markInputIsInvalid() | ||
| 269 | } | ||
| 270 | } | ||
| 271 | |||
| 272 | } else { | ||
| 273 | // Suggestion for "use" | ||
| 274 | const rendererSuggestions = Object.entries(defaultAliasesForRenderer.use) | ||
| 275 | .filter(([renderer,]) => { | ||
| 276 | const suggestion = `use: ${renderer}` | ||
| 277 | const suggetionNoSpace = suggestion.replace(' ', '') | ||
| 278 | const textNoSpace = text.replace(' ', '') | ||
| 279 | return suggestion !== text && | ||
| 280 | (suggetionNoSpace.includes(textNoSpace)) | ||
| 281 | }) | ||
| 282 | .map(([renderer, info]) => | ||
| 283 | new Suggestion({ | ||
| 284 | text: `<span>use: ${renderer}</span><span class='info' title="${info.desc}">ⓘ</span>`, | ||
| 285 | replace: `use: ${renderer}`, | ||
| 286 | }) | ||
| 287 | ) | ||
| 288 | suggestions = rendererSuggestions.length > 0 ? rendererSuggestions : [] | ||
| 289 | } | ||
| 290 | |||
| 291 | if (suggestions.length === 0) { | ||
| 292 | suggestionsEle.style.display = 'none'; | ||
| 293 | return | ||
| 294 | } else { | ||
| 295 | suggestionsEle.style.display = 'block'; | ||
| 296 | } | ||
| 297 | |||
| 298 | suggestionsEle.innerHTML = '' | ||
| 299 | suggestions.forEach((suggestion) => { | ||
| 300 | const option = document.createElement('div'); | ||
| 301 | if (suggestion.text.startsWith('<')) { | ||
| 302 | option.innerHTML = suggestion.text; | ||
| 303 | } else { | ||
| 304 | option.innerText = suggestion.text; | ||
| 305 | } | ||
| 306 | option.classList.add('container__suggestion'); | ||
| 307 | option.onmouseover = () => { | ||
| 308 | Array.from(suggestionsEle.children).forEach(s => s.classList.remove('focus')) | ||
| 309 | option.classList.add('focus') | ||
| 310 | } | ||
| 311 | option.onmouseout = () => { | ||
| 312 | option.classList.remove('focus') | ||
| 313 | } | ||
| 314 | option.onclick = () => { | ||
| 315 | const newFocus = { ...selection.focus, col: 0 } | ||
| 316 | const newAnchor = { ...selection.anchor, col: text.length } | ||
| 317 | tinyEditor.paste(suggestion.replace, newFocus, newAnchor) | ||
| 318 | suggestionsEle.style.display = 'none'; | ||
| 319 | option.classList.remove('focus') | ||
| 320 | }; | ||
| 321 | suggestionsEle.appendChild(option); | ||
| 322 | }); | ||
| 323 | |||
| 324 | const rect = currentLine.getBoundingClientRect(); | ||
| 325 | suggestionsEle.style.top = `${rect.top + rect.height + 12}px`; | ||
| 326 | suggestionsEle.style.left = `${rect.right}px`; | ||
| 327 | suggestionsEle.style.maxWidth = `calc(${window.innerWidth}px - ${rect.right}px - 2rem)`; | ||
| 328 | } | ||
| 329 | // }}} | ||
| 330 | // Event: suggests for current selection {{{ | ||
| 331 | tinyEditor.addEventListener('selection', selection => { | ||
| 332 | // To trigger click event on suggestions list, don't set suggestion list invisible | ||
| 333 | if (suggestionsEle.querySelector('.container__suggestion.focus:hover') !== null) { | ||
| 334 | return | ||
| 335 | } else { | ||
| 336 | suggestionsEle.style.display = 'none'; | ||
| 337 | } | ||
| 338 | |||
| 339 | // Check selection is inside editor contents | ||
| 340 | const node = selection?.anchor?.node | ||
| 341 | if (!node) return | ||
| 342 | |||
| 343 | // Get HTML element for current selection | ||
| 344 | const element = node instanceof HTMLElement | ||
| 345 | ? node | ||
| 346 | : node.parentNode | ||
| 347 | element.setAttribute('spellcheck', 'false') | ||
| 348 | |||
| 349 | // Do not show suggestion by attribute | ||
| 350 | if (suggestionsEle.getAttribute('data-keep-close') === 'true') { | ||
| 351 | suggestionsEle.setAttribute('data-keep-close', 'false') | ||
| 352 | return | ||
| 353 | } | ||
| 354 | |||
| 355 | // Show suggestions for map code block | ||
| 356 | if (insideCodeblockForMap(element)) addSuggestions(element, selection) | ||
| 357 | }); | ||
| 358 | // }}} | ||
| 359 | // Key events for suggestions {{{ | ||
| 360 | mdeElement.addEventListener('keydown', (e) => { | ||
| 361 | // Only the following keys are used | ||
| 362 | const keyForSuggestions = ['Tab', 'Enter', 'Escape'].includes(e.key) | ||
| 363 | if (!keyForSuggestions || suggestionsEle.style.display === 'none') return; | ||
| 364 | |||
| 365 | // Directly add a newline when no suggestion is selected | ||
| 366 | const currentSuggestion = suggestionsEle.querySelector('.container__suggestion.focus') | ||
| 367 | if (!currentSuggestion && e.key === 'Enter') return | ||
| 368 | |||
| 369 | // Override default behavior | ||
| 370 | e.preventDefault(); | ||
| 371 | |||
| 372 | // Suggestion when pressing Tab or Shift + Tab | ||
| 373 | const nextSuggestion = currentSuggestion?.nextSibling ?? suggestionsEle.querySelector('.container__suggestion:first-child') | ||
| 374 | const previousSuggestion = currentSuggestion?.previousSibling ?? suggestionsEle.querySelector('.container__suggestion:last-child') | ||
| 375 | const focusSuggestion = e.shiftKey ? previousSuggestion : nextSuggestion | ||
| 376 | |||
| 377 | // Current editor selection state | ||
| 378 | const selection = tinyEditor.getSelection(true) | ||
| 379 | switch (e.key) { | ||
| 380 | case 'Tab': | ||
| 381 | Array.from(suggestionsEle.children).forEach(s => s.classList.remove('focus')) | ||
| 382 | focusSuggestion.classList.add('focus') | ||
| 383 | focusSuggestion.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) | ||
| 384 | break; | ||
| 385 | case 'Enter': | ||
| 386 | currentSuggestion.onclick() | ||
| 387 | suggestionsEle.style.display = 'none'; | ||
| 388 | break; | ||
| 389 | case 'Escape': | ||
| 390 | suggestionsEle.style.display = 'none'; | ||
| 391 | // Prevent trigger selection event again | ||
| 392 | suggestionsEle.setAttribute('data-keep-close', 'true') | ||
| 393 | setTimeout(() => tinyEditor.setSelection(selection), 100) | ||
| 394 | break; | ||
| 395 | } | ||
| 396 | }); | ||
| 397 | // }}} | ||
| 398 | // }}} | ||
| 399 | |||
| 400 | // vim: sw=2 ts=2 foldmethod=marker foldmarker={{{,}}} | ||