aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/editor.mjs
diff options
context:
space:
mode:
authorHsieh Chin Fan <pham@topo.tw>2024-08-13 23:58:38 +0800
committerHsieh Chin Fan <pham@topo.tw>2024-09-07 17:02:01 +0800
commit15a939d234910016d36d4297ec14de51c96168ce (patch)
tree4b3fe24f842485242949c19e3b2d0c078cae7797 /src/editor.mjs
Initial Commit
Diffstat (limited to 'src/editor.mjs')
-rw-r--r--src/editor.mjs400
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 @@
1import TinyMDE from 'tiny-markdown-editor'
2import { markdown2HTML, generateMaps } from './dumbymap'
3import { defaultAliasesForRenderer, parseConfigsFromText } from 'mapclay'
4
5// Set up Editor {{{
6
7const HtmlContainer = document.querySelector(".result-html")
8const mdeElement = document.querySelector("#tinymde")
9
10// Add Editor
11const 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'
12const lastContent = localStorage.getItem('editorContent')
13const tinyEditor = new TinyMDE.Editor({
14 element: 'tinymde',
15 content: lastContent ? lastContent : defaultContent
16});
17mdeElement.querySelectorAll('span').forEach(e => e.setAttribute('spellcheck', 'false'))
18
19// Add command bar for editor
20// Use this command to render maps and geoLinks
21const 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}
36const 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
47new 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
53markdown2HTML(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
65let rejectLastSaving
66const 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
80tinyEditor.addEventListener('change', e => {
81 markdown2HTML(HtmlContainer, e.content)
82 saveContent(e.content)
83});
84// }}}
85// Completion in Code Blok {{{
86// Elements about suggestions {{{
87const suggestionsEle = document.createElement('div')
88suggestionsEle.classList.add('container__suggestions');
89mdeElement.appendChild(suggestionsEle)
90
91const rendererOptions = {}
92
93class Suggestion {
94 constructor({ text, replace }) {
95 this.text = text
96 this.replace = replace
97 }
98}
99
100// }}}
101// {{{ Aliases for map options
102const aliasesForMapOptions = {}
103const defaultApply = '/default.yml'
104fetch(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 {{{
113const 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 {{{
131const 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 {{{
162const 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
181const 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
194const 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
206const 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 {{{
331tinyEditor.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 {{{
360mdeElement.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={{{,}}}