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/editor.mjs |
Initial Commit
Diffstat (limited to 'src/editor.mjs')
-rw-r--r-- | src/editor.mjs | 400 |
1 files changed, 400 insertions, 0 deletions
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={{{,}}} | ||