aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/editor.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'src/editor.mjs')
-rw-r--r--src/editor.mjs548
1 files changed, 274 insertions, 274 deletions
diff --git a/src/editor.mjs b/src/editor.mjs
index 6f529ed..4c50e79 100644
--- a/src/editor.mjs
+++ b/src/editor.mjs
@@ -1,44 +1,44 @@
1/*global EasyMDE*/ 1/* global EasyMDE */
2/*eslint no-undef: "error"*/ 2/* eslint no-undef: "error" */
3import { markdown2HTML, generateMaps } from './dumbymap'; 3import { markdown2HTML, generateMaps } from './dumbymap'
4import { defaultAliases, parseConfigsFromYaml } from 'mapclay'; 4import { defaultAliases, parseConfigsFromYaml } from 'mapclay'
5import * as menuItem from './MenuItem'; 5import * as menuItem from './MenuItem'
6 6
7// Set up Containers {{{ 7// Set up Containers {{{
8 8
9const HtmlContainer = document.querySelector('.DumbyMap'); 9const HtmlContainer = document.querySelector('.DumbyMap')
10const textArea = document.querySelector('.editor textarea'); 10const textArea = document.querySelector('.editor textarea')
11let dumbymap; 11let dumbymap
12 12
13new MutationObserver(mutations => { 13new window.MutationObserver(mutations => {
14 const mutation = mutations.at(-1); 14 const mutation = mutations.at(-1)
15 const mode = mutation.target.getAttribute('data-mode'); 15 const mode = mutation.target.getAttribute('data-mode')
16 const layout = HtmlContainer.getAttribute('data-layout'); 16 const layout = HtmlContainer.getAttribute('data-layout')
17 if (mode === 'editing' && layout !== 'normal') { 17 if (mode === 'editing' && layout !== 'normal') {
18 HtmlContainer.setAttribute('data-layout', 'normal'); 18 HtmlContainer.setAttribute('data-layout', 'normal')
19 } 19 }
20}).observe(document.querySelector('[data-mode]'), { 20}).observe(document.querySelector('[data-mode]'), {
21 attributes: true, 21 attributes: true,
22 attributeFilter: ['data-mode'], 22 attributeFilter: ['data-mode'],
23 attributeOldValue: true, 23 attributeOldValue: true
24}); 24})
25const toggleEditing = () => { 25const toggleEditing = () => {
26 const mode = document.body.getAttribute('data-mode'); 26 const mode = document.body.getAttribute('data-mode')
27 document.body.setAttribute('data-mode', mode === 'editing' ? '' : 'editing'); 27 document.body.setAttribute('data-mode', mode === 'editing' ? '' : 'editing')
28}; 28}
29// }}} 29// }}}
30// Set up EasyMDE {{{ 30// Set up EasyMDE {{{
31 31
32// Content values for editor 32// Content values for editor
33 33
34const defaultContent = 34const defaultContent =
35 '## 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'; 35 '## 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'
36const editor = new EasyMDE({ 36const editor = new EasyMDE({
37 element: textArea, 37 element: textArea,
38 initialValue: defaultContent, 38 initialValue: defaultContent,
39 autosave: { 39 autosave: {
40 enabled: true, 40 enabled: true,
41 uniqueId: 'dumbymap', 41 uniqueId: 'dumbymap'
42 }, 42 },
43 indentWithTabs: false, 43 indentWithTabs: false,
44 lineNumbers: true, 44 lineNumbers: true,
@@ -51,105 +51,105 @@ const editor = new EasyMDE({
51 map: 'Ctrl-Alt-M', 51 map: 'Ctrl-Alt-M',
52 debug: 'Ctrl-Alt-D', 52 debug: 'Ctrl-Alt-D',
53 toggleUnorderedList: null, 53 toggleUnorderedList: null,
54 toggleOrderedList: null, 54 toggleOrderedList: null
55 }, 55 },
56 toolbar: [ 56 toolbar: [
57 { 57 {
58 name: 'map', 58 name: 'map',
59 title: 'Toggle Map Generation', 59 title: 'Toggle Map Generation',
60 text: '🌏', 60 text: '🌏',
61 action: () => toggleEditing(), 61 action: () => toggleEditing()
62 }, 62 },
63 { 63 {
64 name: 'debug', 64 name: 'debug',
65 title: 'Save content as URL', 65 title: 'Save content as URL',
66 text: '🤔', 66 text: '🤔',
67 action: () => { 67 action: () => {
68 const state = { content: editor.value() }; 68 const state = { content: editor.value() }
69 window.location.hash = encodeURIComponent(JSON.stringify(state)); 69 window.location.hash = encodeURIComponent(JSON.stringify(state))
70 navigator.clipboard.writeText(window.location.href); 70 navigator.clipboard.writeText(window.location.href)
71 alert('URL copied to clipboard'); 71 window.alert('URL copied to clipboard')
72 }, 72 }
73 }, 73 },
74 { 74 {
75 name: 'undo', 75 name: 'undo',
76 title: 'Undo last editing', 76 title: 'Undo last editing',
77 text: '\u27F2', 77 text: '\u27F2',
78 action: EasyMDE.undo, 78 action: EasyMDE.undo
79 }, 79 },
80 { 80 {
81 name: 'redo', 81 name: 'redo',
82 text: '\u27F3', 82 text: '\u27F3',
83 title: 'Redo editing', 83 title: 'Redo editing',
84 action: EasyMDE.redo, 84 action: EasyMDE.redo
85 }, 85 },
86 '|', 86 '|',
87 { 87 {
88 name: 'heading-1', 88 name: 'heading-1',
89 text: 'H1', 89 text: 'H1',
90 title: 'Big Heading', 90 title: 'Big Heading',
91 action: EasyMDE['heading-1'], 91 action: EasyMDE['heading-1']
92 }, 92 },
93 { 93 {
94 name: 'heading-2', 94 name: 'heading-2',
95 text: 'H2', 95 text: 'H2',
96 title: 'Medium Heading', 96 title: 'Medium Heading',
97 action: EasyMDE['heading-2'], 97 action: EasyMDE['heading-2']
98 }, 98 },
99 '|', 99 '|',
100 { 100 {
101 name: 'link', 101 name: 'link',
102 text: '\u{1F517}', 102 text: '\u{1F517}',
103 title: 'Create Link', 103 title: 'Create Link',
104 action: EasyMDE.drawLink, 104 action: EasyMDE.drawLink
105 }, 105 },
106 { 106 {
107 name: 'image', 107 name: 'image',
108 text: '\u{1F5BC}', 108 text: '\u{1F5BC}',
109 title: 'Create Image', 109 title: 'Create Image',
110 action: EasyMDE.drawImage, 110 action: EasyMDE.drawImage
111 }, 111 },
112 '|', 112 '|',
113 { 113 {
114 name: 'Bold', 114 name: 'Bold',
115 text: '\u{1D401}', 115 text: '\u{1D401}',
116 title: 'Bold', 116 title: 'Bold',
117 action: EasyMDE.toggleBold, 117 action: EasyMDE.toggleBold
118 }, 118 },
119 { 119 {
120 name: 'Italic', 120 name: 'Italic',
121 text: '\u{1D43C}', 121 text: '\u{1D43C}',
122 title: 'Italic', 122 title: 'Italic',
123 action: EasyMDE.toggleItalic, 123 action: EasyMDE.toggleItalic
124 }, 124 }
125 ], 125 ]
126}); 126})
127 127
128const cm = editor.codemirror; 128const cm = editor.codemirror
129 129
130const getStateFromHash = hash => { 130const getStateFromHash = hash => {
131 const hashValue = hash.substring(1); 131 const hashValue = hash.substring(1)
132 const stateString = decodeURIComponent(hashValue); 132 const stateString = decodeURIComponent(hashValue)
133 try { 133 try {
134 return JSON.parse(stateString) ?? {}; 134 return JSON.parse(stateString) ?? {}
135 } catch (_) { 135 } catch (_) {
136 return {}; 136 return {}
137 } 137 }
138}; 138}
139 139
140const getContentFromHash = hash => { 140const getContentFromHash = hash => {
141 const state = getStateFromHash(hash); 141 const state = getStateFromHash(hash)
142 return state.content; 142 return state.content
143}; 143}
144 144
145const initialState = getStateFromHash(window.location.hash); 145const initialState = getStateFromHash(window.location.hash)
146window.location.hash = ''; 146window.location.hash = ''
147const contentFromHash = initialState.content; 147const contentFromHash = initialState.content
148 148
149// Seems like autosave would overwrite initialValue, set content from hash here 149// Seems like autosave would overwrite initialValue, set content from hash here
150if (contentFromHash) { 150if (contentFromHash) {
151 editor.cleanup(); 151 editor.cleanup()
152 editor.value(contentFromHash); 152 editor.value(contentFromHash)
153} 153}
154// }}} 154// }}}
155// Set up logic about editor content {{{ 155// Set up logic about editor content {{{
@@ -159,30 +159,30 @@ const afterMapRendered = _ => {
159 // const lonLat = mapHolder.renderer.unproject([event.x, event.y]) 159 // const lonLat = mapHolder.renderer.unproject([event.x, event.y])
160 // // TODO... 160 // // TODO...
161 // } 161 // }
162}; 162}
163markdown2HTML(HtmlContainer, editor.value()); 163markdown2HTML(HtmlContainer, editor.value())
164dumbymap = generateMaps(HtmlContainer, afterMapRendered); 164dumbymap = generateMaps(HtmlContainer, afterMapRendered)
165 165
166// Quick hack to style lines inside code block 166// Quick hack to style lines inside code block
167const addClassToCodeLines = () => { 167const addClassToCodeLines = () => {
168 const lines = cm.getLineHandle(0).parent.lines; 168 const lines = cm.getLineHandle(0).parent.lines
169 let insideCodeBlock = false; 169 let insideCodeBlock = false
170 lines.forEach((line, index) => { 170 lines.forEach((line, index) => {
171 if (line.text.match(/^[\u0060]{3}/)) { 171 if (line.text.match(/^[\u0060]{3}/)) {
172 insideCodeBlock = !insideCodeBlock; 172 insideCodeBlock = !insideCodeBlock
173 } else if (insideCodeBlock) { 173 } else if (insideCodeBlock) {
174 cm.addLineClass(index, 'text', 'inside-code-block'); 174 cm.addLineClass(index, 'text', 'inside-code-block')
175 } else { 175 } else {
176 cm.removeLineClass(index, 'text', 'inside-code-block'); 176 cm.removeLineClass(index, 'text', 'inside-code-block')
177 } 177 }
178 }); 178 })
179}; 179}
180addClassToCodeLines(); 180addClassToCodeLines()
181 181
182const completeForCodeBlock = change => { 182const completeForCodeBlock = change => {
183 const line = change.to.line; 183 const line = change.to.line
184 if (change.origin === '+input') { 184 if (change.origin === '+input') {
185 const text = change.text[0]; 185 const text = change.text[0]
186 186
187 // Completion for YAML doc separator 187 // Completion for YAML doc separator
188 if ( 188 if (
@@ -190,21 +190,21 @@ const completeForCodeBlock = change => {
190 change.to.ch === 0 && 190 change.to.ch === 0 &&
191 insideCodeblockForMap(cm.getCursor()) 191 insideCodeblockForMap(cm.getCursor())
192 ) { 192 ) {
193 cm.setSelection({ line: line, ch: 0 }, { line: line, ch: 1 }); 193 cm.setSelection({ line, ch: 0 }, { line, ch: 1 })
194 cm.replaceSelection(text.repeat(3) + '\n'); 194 cm.replaceSelection(text.repeat(3) + '\n')
195 } 195 }
196 196
197 // Completion for Code fence 197 // Completion for Code fence
198 if (text === '`' && change.to.ch === 0) { 198 if (text === '`' && change.to.ch === 0) {
199 cm.setSelection({ line: line, ch: 0 }, { line: line, ch: 1 }); 199 cm.setSelection({ line, ch: 0 }, { line, ch: 1 })
200 cm.replaceSelection(text.repeat(3)); 200 cm.replaceSelection(text.repeat(3))
201 const numberOfFences = cm 201 const numberOfFences = cm
202 .getValue() 202 .getValue()
203 .split('\n') 203 .split('\n')
204 .filter(line => line.match(/[\u0060]{3}/)).length; 204 .filter(line => line.match(/[\u0060]{3}/)).length
205 if (numberOfFences % 2 === 1) { 205 if (numberOfFences % 2 === 1) {
206 cm.replaceSelection('map\n\n```'); 206 cm.replaceSelection('map\n\n```')
207 cm.setCursor({ line: line + 1 }); 207 cm.setCursor({ line: line + 1 })
208 } 208 }
209 } 209 }
210 } 210 }
@@ -212,63 +212,64 @@ const completeForCodeBlock = change => {
212 // For YAML doc separator, <hr> and code fence 212 // For YAML doc separator, <hr> and code fence
213 // Auto delete to start of line 213 // Auto delete to start of line
214 if (change.origin === '+delete') { 214 if (change.origin === '+delete') {
215 const match = change.removed[0].match(/^[-\u0060]$/)?.at(0); 215 const match = change.removed[0].match(/^[-\u0060]$/)?.at(0)
216 if (match && cm.getLine(line) === match.repeat(2) && match) { 216 if (match && cm.getLine(line) === match.repeat(2) && match) {
217 cm.setSelection({ line: line, ch: 0 }, { line: line, ch: 2 }); 217 cm.setSelection({ line, ch: 0 }, { line, ch: 2 })
218 cm.replaceSelection(''); 218 cm.replaceSelection('')
219 } 219 }
220 } 220 }
221}; 221}
222
223const debounceForMap = (() => {
224 let timer = null;
225 222
226 return function (...args) { 223/* Disable debounce temporarily */
227 dumbymap = generateMaps.apply(this, args); 224// const debounceForMap = (() => {
228 // clearTimeout(timer); 225// const timer = null
229 // timer = setTimeout(() => { 226//
230 // dumbymap = generateMaps.apply(this, args) 227// return function (...args) {
231 // }, 10); 228// dumbymap = generateMaps.apply(this, args)
232 }; 229// clearTimeout(timer);
233})(); 230// timer = setTimeout(() => {
231// dumbymap = generateMaps.apply(this, args)
232// }, 10);
233// }
234// })()
234 235
235const updateDumbyMap = () => { 236const updateDumbyMap = () => {
236 markdown2HTML(HtmlContainer, editor.value()); 237 markdown2HTML(HtmlContainer, editor.value())
237 // TODO Test if generate maps intantly is OK with map cache 238 // TODO Test if generate maps intantly is OK with map cache
238 // debounceForMap(HtmlContainer, afterMapRendered) 239 // debounceForMap(HtmlContainer, afterMapRendered)
239 dumbymap = generateMaps(HtmlContainer, afterMapRendered); 240 dumbymap = generateMaps(HtmlContainer, afterMapRendered)
240}; 241}
241 242
242updateDumbyMap(); 243updateDumbyMap()
243 244
244// Re-render HTML by editor content 245// Re-render HTML by editor content
245cm.on('change', (_, change) => { 246cm.on('change', (_, change) => {
246 updateDumbyMap(); 247 updateDumbyMap()
247 addClassToCodeLines(); 248 addClassToCodeLines()
248 completeForCodeBlock(change); 249 completeForCodeBlock(change)
249}); 250})
250 251
251// Set class for focus 252// Set class for focus
252cm.on('focus', () => { 253cm.on('focus', () => {
253 cm.getWrapperElement().classList.add('focus'); 254 cm.getWrapperElement().classList.add('focus')
254 HtmlContainer.classList.remove('focus'); 255 HtmlContainer.classList.remove('focus')
255}); 256})
256 257
257cm.on('beforeChange', (_, change) => { 258cm.on('beforeChange', (_, change) => {
258 const line = change.to.line; 259 const line = change.to.line
259 // Don't allow more content after YAML doc separator 260 // Don't allow more content after YAML doc separator
260 if (change.origin.match(/^(\+input|paste)$/)) { 261 if (change.origin.match(/^(\+input|paste)$/)) {
261 if (cm.getLine(line) === '---' && change.text[0] !== '') { 262 if (cm.getLine(line) === '---' && change.text[0] !== '') {
262 change.cancel(); 263 change.cancel()
263 } 264 }
264 } 265 }
265}); 266})
266 267
267// Reload editor content by hash value 268// Reload editor content by hash value
268window.onhashchange = () => { 269window.onhashchange = () => {
269 const content = getContentFromHash(window.location.hash); 270 const content = getContentFromHash(window.location.hash)
270 if (content) editor.value(content); 271 if (content) editor.value(content)
271}; 272}
272 273
273// FIXME DEBUGONLY 274// FIXME DEBUGONLY
274// generateMaps(HtmlContainer) 275// generateMaps(HtmlContainer)
@@ -279,159 +280,159 @@ window.onhashchange = () => {
279// }}} 280// }}}
280// Completion in Code Blok {{{ 281// Completion in Code Blok {{{
281// Elements about suggestions {{{ 282// Elements about suggestions {{{
282const menu = document.createElement('div'); 283const menu = document.createElement('div')
283menu.className = 'menu editor-menu'; 284menu.className = 'menu editor-menu'
284menu.style.display = 'none'; 285menu.style.display = 'none'
285menu.onclick = () => (menu.style.display = 'none'); 286menu.onclick = () => (menu.style.display = 'none')
286new MutationObserver(() => { 287new window.MutationObserver(() => {
287 if (menu.style.display === 'none') { 288 if (menu.style.display === 'none') {
288 menu.replaceChildren(); 289 menu.replaceChildren()
289 } 290 }
290}).observe(menu, { 291}).observe(menu, {
291 attributes: true, 292 attributes: true,
292 attributeFilter: ['style'], 293 attributeFilter: ['style']
293}); 294})
294document.body.append(menu); 295document.body.append(menu)
295 296
296const rendererOptions = {}; 297const rendererOptions = {}
297 298
298// }}} 299// }}}
299// Aliases for map options {{{ 300// Aliases for map options {{{
300const aliasesForMapOptions = {}; 301const aliasesForMapOptions = {}
301const defaultApply = './dist/default.yml'; 302const defaultApply = './dist/default.yml'
302fetch(defaultApply) 303fetch(defaultApply)
303 .then(res => res.text()) 304 .then(res => res.text())
304 .then(rawText => { 305 .then(rawText => {
305 const config = parseConfigsFromYaml(rawText)?.at(0); 306 const config = parseConfigsFromYaml(rawText)?.at(0)
306 Object.assign(aliasesForMapOptions, config.aliases ?? {}); 307 Object.assign(aliasesForMapOptions, config.aliases ?? {})
307 }) 308 })
308 .catch(err => console.warn(`Fail to get aliases from ${defaultApply}`, err)); 309 .catch(err => console.warn(`Fail to get aliases from ${defaultApply}`, err))
309// }}} 310// }}}
310// FUNCTION: Check if current token is inside code block {{{ 311// FUNCTION: Check if current token is inside code block {{{
311const insideCodeblockForMap = anchor => { 312const insideCodeblockForMap = anchor => {
312 const token = cm.getTokenAt(anchor); 313 const token = cm.getTokenAt(anchor)
313 const insideCodeBlock = 314 const insideCodeBlock =
314 token.state.overlay.codeBlock && 315 token.state.overlay.codeBlock &&
315 !cm.getLine(anchor.line).match(/^[\u0060]{3}/); 316 !cm.getLine(anchor.line).match(/^[\u0060]{3}/)
316 if (!insideCodeBlock) return false; 317 if (!insideCodeBlock) return false
317 318
318 let line = anchor.line - 1; 319 let line = anchor.line - 1
319 while (line >= 0) { 320 while (line >= 0) {
320 const content = cm.getLine(line); 321 const content = cm.getLine(line)
321 if (content === '```map') { 322 if (content === '```map') {
322 return true; 323 return true
323 } else if (content === '```') { 324 } else if (content === '```') {
324 return false; 325 return false
325 } 326 }
326 line = line - 1; 327 line = line - 1
327 } 328 }
328 return false; 329 return false
329}; 330}
330// }}} 331// }}}
331// FUNCTION: Get Renderer by cursor position in code block {{{ 332// FUNCTION: Get Renderer by cursor position in code block {{{
332const getLineWithRenderer = anchor => { 333const getLineWithRenderer = anchor => {
333 const currentLine = anchor.line; 334 const currentLine = anchor.line
334 if (!cm.getLine) return null; 335 if (!cm.getLine) return null
335 336
336 const match = line => cm.getLine(line).match(/^use: /); 337 const match = line => cm.getLine(line).match(/^use: /)
337 338
338 if (match(currentLine)) return currentLine; 339 if (match(currentLine)) return currentLine
339 340
340 // Look backward/forward for pattern of used renderer: /use: .+/ 341 // Look backward/forward for pattern of used renderer: /use: .+/
341 let pl = currentLine - 1; 342 let pl = currentLine - 1
342 while (pl > 0 && insideCodeblockForMap(anchor)) { 343 while (pl > 0 && insideCodeblockForMap(anchor)) {
343 const text = cm.getLine(pl); 344 const text = cm.getLine(pl)
344 if (match(pl)) { 345 if (match(pl)) {
345 return pl; 346 return pl
346 } else if (text.match(/^---|^[\u0060]{3}/)) { 347 } else if (text.match(/^---|^[\u0060]{3}/)) {
347 break; 348 break
348 } 349 }
349 pl = pl - 1; 350 pl = pl - 1
350 } 351 }
351 352
352 let nl = currentLine + 1; 353 let nl = currentLine + 1
353 while (insideCodeblockForMap(anchor)) { 354 while (insideCodeblockForMap(anchor)) {
354 const text = cm.getLine(nl); 355 const text = cm.getLine(nl)
355 if (match(nl)) { 356 if (match(nl)) {
356 return nl; 357 return nl
357 } else if (text.match(/^---|^[\u0060]{3}/)) { 358 } else if (text.match(/^---|^[\u0060]{3}/)) {
358 return null; 359 return null
359 } 360 }
360 nl = nl + 1; 361 nl = nl + 1
361 } 362 }
362 363
363 return null; 364 return null
364}; 365}
365// }}} 366// }}}
366// FUNCTION: Return suggestions for valid options {{{ 367// FUNCTION: Return suggestions for valid options {{{
367const getSuggestionsForOptions = (optionTyped, validOptions) => { 368const getSuggestionsForOptions = (optionTyped, validOptions) => {
368 let suggestOptions = []; 369 let suggestOptions = []
369 370
370 const matchedOptions = validOptions.filter(o => 371 const matchedOptions = validOptions.filter(o =>
371 o.valueOf().toLowerCase().includes(optionTyped.toLowerCase()), 372 o.valueOf().toLowerCase().includes(optionTyped.toLowerCase())
372 ); 373 )
373 374
374 if (matchedOptions.length > 0) { 375 if (matchedOptions.length > 0) {
375 suggestOptions = matchedOptions; 376 suggestOptions = matchedOptions
376 } else { 377 } else {
377 suggestOptions = validOptions; 378 suggestOptions = validOptions
378 } 379 }
379 380
380 return suggestOptions.map( 381 return suggestOptions.map(
381 o => 382 o =>
382 new menuItem.Suggestion({ 383 new menuItem.Suggestion({
383 text: `<span>${o.valueOf()}</span><span class='info' title="${o.desc ?? ''}">ⓘ</span>`, 384 text: `<span>${o.valueOf()}</span><span class='info' title="${o.desc ?? ''}">ⓘ</span>`,
384 replace: `${o.valueOf()}: `, 385 replace: `${o.valueOf()}: `
385 }), 386 })
386 ); 387 )
387}; 388}
388// }}} 389// }}}
389// FUNCTION: Return suggestion for example of option value {{{ 390// FUNCTION: Return suggestion for example of option value {{{
390const getSuggestionFromMapOption = option => { 391const getSuggestionFromMapOption = option => {
391 if (!option.example) return null; 392 if (!option.example) return null
392 393
393 const text = option.example_desc 394 const text = option.example_desc
394 ? `<span>${option.example_desc}</span><span class="truncate"style="color: gray">${option.example}</span>` 395 ? `<span>${option.example_desc}</span><span class="truncate"style="color: gray">${option.example}</span>`
395 : `<span>${option.example}</span>`; 396 : `<span>${option.example}</span>`
396 397
397 return new menuItem.Suggestion({ 398 return new menuItem.Suggestion({
398 text: text, 399 text,
399 replace: `${option.valueOf()}: ${option.example ?? ''}`, 400 replace: `${option.valueOf()}: ${option.example ?? ''}`
400 }); 401 })
401}; 402}
402// }}} 403// }}}
403// FUNCTION: Return suggestions from aliases {{{ 404// FUNCTION: Return suggestions from aliases {{{
404const getSuggestionsFromAliases = option => 405const getSuggestionsFromAliases = option =>
405 Object.entries(aliasesForMapOptions[option.valueOf()] ?? {})?.map(record => { 406 Object.entries(aliasesForMapOptions[option.valueOf()] ?? {})?.map(record => {
406 const [alias, value] = record; 407 const [alias, value] = record
407 const valueString = JSON.stringify(value).replaceAll('"', ''); 408 const valueString = JSON.stringify(value).replaceAll('"', '')
408 return new menuItem.Suggestion({ 409 return new menuItem.Suggestion({
409 text: `<span>${alias}</span><span class="truncate" style="color: gray">${valueString}</span>`, 410 text: `<span>${alias}</span><span class="truncate" style="color: gray">${valueString}</span>`,
410 replace: `${option.valueOf()}: ${valueString}`, 411 replace: `${option.valueOf()}: ${valueString}`
411 }); 412 })
412 }) ?? []; 413 }) ?? []
413// }}} 414// }}}
414// FUCNTION: Handler for map codeblock {{{ 415// FUCNTION: Handler for map codeblock {{{
415const handleTypingInCodeBlock = anchor => { 416const handleTypingInCodeBlock = anchor => {
416 const text = cm.getLine(anchor.line); 417 const text = cm.getLine(anchor.line)
417 if (text.match(/^\s\+$/) && text.length % 2 !== 0) { 418 if (text.match(/^\s\+$/) && text.length % 2 !== 0) {
418 // TODO Completion for even number of spaces 419 // TODO Completion for even number of spaces
419 } else if (text.match(/^-/)) { 420 } else if (text.match(/^-/)) {
420 // TODO Completion for YAML doc separator 421 // TODO Completion for YAML doc separator
421 } else { 422 } else {
422 const suggestions = getSuggestions(anchor); 423 const suggestions = getSuggestions(anchor)
423 addSuggestions(anchor, suggestions); 424 addSuggestions(anchor, suggestions)
424 } 425 }
425}; 426}
426// }}} 427// }}}
427// FUNCTION: get suggestions by current input {{{ 428// FUNCTION: get suggestions by current input {{{
428const getSuggestions = anchor => { 429const getSuggestions = anchor => {
429 const text = cm.getLine(anchor.line); 430 const text = cm.getLine(anchor.line)
430 431
431 // Clear marks on text 432 // Clear marks on text
432 cm.findMarks({ ...anchor, ch: 0 }, { ...anchor, ch: text.length }).forEach( 433 cm.findMarks({ ...anchor, ch: 0 }, { ...anchor, ch: text.length }).forEach(
433 m => m.clear(), 434 m => m.clear()
434 ); 435 )
435 436
436 // Mark user input invalid by case 437 // Mark user input invalid by case
437 const markInputIsInvalid = () => 438 const markInputIsInvalid = () =>
@@ -440,231 +441,230 @@ const getSuggestions = anchor => {
440 .markText( 441 .markText(
441 { ...anchor, ch: 0 }, 442 { ...anchor, ch: 0 },
442 { ...anchor, ch: text.length }, 443 { ...anchor, ch: text.length },
443 { className: 'invalid-input' }, 444 { className: 'invalid-input' }
444 ); 445 )
445 446
446 // Check if "use: <renderer>" is set 447 // Check if "use: <renderer>" is set
447 const lineWithRenderer = getLineWithRenderer(anchor); 448 const lineWithRenderer = getLineWithRenderer(anchor)
448 const renderer = lineWithRenderer 449 const renderer = lineWithRenderer
449 ? cm.getLine(lineWithRenderer).split(' ')[1] 450 ? cm.getLine(lineWithRenderer).split(' ')[1]
450 : null; 451 : null
451 if (renderer && anchor.line !== lineWithRenderer) { 452 if (renderer && anchor.line !== lineWithRenderer) {
452 // Do not check properties 453 // Do not check properties
453 if (text.startsWith(' ')) return []; 454 if (text.startsWith(' ')) return []
454 455
455 // If no valid options for current used renderer, go get it! 456 // If no valid options for current used renderer, go get it!
456 const validOptions = rendererOptions[renderer]; 457 const validOptions = rendererOptions[renderer]
457 if (!validOptions) { 458 if (!validOptions) {
458 // Get list of valid options for current renderer 459 // Get list of valid options for current renderer
459 const rendererUrl = defaultAliases.use[renderer]?.value; 460 const rendererUrl = defaultAliases.use[renderer]?.value
460 import(rendererUrl) 461 import(rendererUrl)
461 .then(rendererModule => { 462 .then(rendererModule => {
462 rendererOptions[renderer] = rendererModule.default.validOptions; 463 rendererOptions[renderer] = rendererModule.default.validOptions
463 const currentAnchor = cm.getCursor(); 464 const currentAnchor = cm.getCursor()
464 if (insideCodeblockForMap(currentAnchor)) { 465 if (insideCodeblockForMap(currentAnchor)) {
465 handleTypingInCodeBlock(currentAnchor); 466 handleTypingInCodeBlock(currentAnchor)
466 } 467 }
467 }) 468 })
468 .catch(_ => { 469 .catch(_ => {
469 markInputIsInvalid(lineWithRenderer); 470 markInputIsInvalid(lineWithRenderer)
470 console.warn( 471 console.warn(
471 `Fail to get valid options from Renderer typed: ${renderer}`, 472 `Fail to get valid options from Renderer typed: ${renderer}`
472 ); 473 )
473 }); 474 })
474 return []; 475 return []
475 } 476 }
476 477
477 // If input is "key:value" (no space left after colon), then it is invalid 478 // If input is "key:value" (no space left after colon), then it is invalid
478 const isKeyFinished = text.includes(':'); 479 const isKeyFinished = text.includes(':')
479 const isValidKeyValue = text.match(/^[^:]+:\s+/); 480 const isValidKeyValue = text.match(/^[^:]+:\s+/)
480 if (isKeyFinished && !isValidKeyValue) { 481 if (isKeyFinished && !isValidKeyValue) {
481 markInputIsInvalid(); 482 markInputIsInvalid()
482 return []; 483 return []
483 } 484 }
484 485
485 // If user is typing option 486 // If user is typing option
486 const keyTyped = text.split(':')[0].trim(); 487 const keyTyped = text.split(':')[0].trim()
487 if (!isKeyFinished) { 488 if (!isKeyFinished) {
488 markInputIsInvalid(); 489 markInputIsInvalid()
489 return getSuggestionsForOptions(keyTyped, validOptions); 490 return getSuggestionsForOptions(keyTyped, validOptions)
490 } 491 }
491 492
492 // If user is typing value 493 // If user is typing value
493 const matchedOption = validOptions.find(o => o.name === keyTyped); 494 const matchedOption = validOptions.find(o => o.name === keyTyped)
494 if (isKeyFinished && !matchedOption) { 495 if (isKeyFinished && !matchedOption) {
495 markInputIsInvalid(); 496 markInputIsInvalid()
496 } 497 }
497 498
498 if (isKeyFinished && matchedOption) { 499 if (isKeyFinished && matchedOption) {
499 const valueTyped = text.substring(text.indexOf(':') + 1).trim(); 500 const valueTyped = text.substring(text.indexOf(':') + 1).trim()
500 const isValidValue = matchedOption.isValid(valueTyped); 501 const isValidValue = matchedOption.isValid(valueTyped)
501 if (!valueTyped) { 502 if (!valueTyped) {
502 return [ 503 return [
503 getSuggestionFromMapOption(matchedOption), 504 getSuggestionFromMapOption(matchedOption),
504 ...getSuggestionsFromAliases(matchedOption), 505 ...getSuggestionsFromAliases(matchedOption)
505 ].filter(s => s instanceof menuItem.Suggestion); 506 ].filter(s => s instanceof menuItem.Suggestion)
506 } 507 }
507 if (valueTyped && !isValidValue) { 508 if (valueTyped && !isValidValue) {
508 markInputIsInvalid(); 509 markInputIsInvalid()
509 return []; 510 return []
510 } 511 }
511 } 512 }
512 } else { 513 } else {
513 // Suggestion for "use" 514 // Suggestion for "use"
514 const rendererSuggestions = Object.entries(defaultAliases.use) 515 const rendererSuggestions = Object.entries(defaultAliases.use)
515 .filter(([renderer]) => { 516 .filter(([renderer]) => {
516 const suggestion = `use: ${renderer}`; 517 const suggestion = `use: ${renderer}`
517 const suggestionPattern = suggestion.replace(' ', '').toLowerCase(); 518 const suggestionPattern = suggestion.replace(' ', '').toLowerCase()
518 const textPattern = text.replace(' ', '').toLowerCase(); 519 const textPattern = text.replace(' ', '').toLowerCase()
519 return suggestion !== text && suggestionPattern.includes(textPattern); 520 return suggestion !== text && suggestionPattern.includes(textPattern)
520 }) 521 })
521 .map( 522 .map(
522 ([renderer, info]) => 523 ([renderer, info]) =>
523 new menuItem.Suggestion({ 524 new menuItem.Suggestion({
524 text: `<span>use: ${renderer}</span><span class='info' title="${info.desc}">ⓘ</span>`, 525 text: `<span>use: ${renderer}</span><span class='info' title="${info.desc}">ⓘ</span>`,
525 replace: `use: ${renderer}`, 526 replace: `use: ${renderer}`
526 }), 527 })
527 ); 528 )
528 return rendererSuggestions.length > 0 ? rendererSuggestions : []; 529 return rendererSuggestions.length > 0 ? rendererSuggestions : []
529 } 530 }
530 return []; 531 return []
531}; 532}
532// }}} 533// }}}
533// {{{ FUNCTION: Show element about suggestions 534// {{{ FUNCTION: Show element about suggestions
534const addSuggestions = (anchor, suggestions) => { 535const addSuggestions = (anchor, suggestions) => {
535 if (suggestions.length === 0) { 536 if (suggestions.length === 0) {
536 menu.style.display = 'none'; 537 menu.style.display = 'none'
537 return; 538 return
538 } else { 539 } else {
539 menu.style.display = 'block'; 540 menu.style.display = 'block'
540 } 541 }
541 542
542 menu.innerHTML = ''; 543 menu.innerHTML = ''
543 suggestions 544 suggestions
544 .map(s => s.createElement(cm)) 545 .map(s => s.createElement(cm))
545 .forEach(option => menu.appendChild(option)); 546 .forEach(option => menu.appendChild(option))
546 547
547 const widgetAnchor = document.createElement('div'); 548 const widgetAnchor = document.createElement('div')
548 cm.addWidget(anchor, widgetAnchor, true); 549 cm.addWidget(anchor, widgetAnchor, true)
549 const rect = widgetAnchor.getBoundingClientRect(); 550 const rect = widgetAnchor.getBoundingClientRect()
550 menu.style.left = `calc(${rect.left}px + 2rem)`; 551 menu.style.left = `calc(${rect.left}px + 2rem)`
551 menu.style.top = `calc(${rect.bottom}px + 1rem)`; 552 menu.style.top = `calc(${rect.bottom}px + 1rem)`
552 menu.style.maxWidth = `calc(${window.innerWidth}px - ${rect.x}px - 3rem)`; 553 menu.style.maxWidth = `calc(${window.innerWidth}px - ${rect.x}px - 3rem)`
553 menu.style.display = 'block'; 554 menu.style.display = 'block'
554}; 555}
555// }}} 556// }}}
556// EVENT: Suggests for current selection {{{ 557// EVENT: Suggests for current selection {{{
557// FIXME Dont show suggestion when selecting multiple chars 558// FIXME Dont show suggestion when selecting multiple chars
558cm.on('cursorActivity', _ => { 559cm.on('cursorActivity', _ => {
559 menu.style.display = 'none'; 560 menu.style.display = 'none'
560 const anchor = cm.getCursor(); 561 const anchor = cm.getCursor()
561 562
562 if (insideCodeblockForMap(anchor)) { 563 if (insideCodeblockForMap(anchor)) {
563 handleTypingInCodeBlock(anchor); 564 handleTypingInCodeBlock(anchor)
564 } 565 }
565}); 566})
566cm.on('blur', () => { 567cm.on('blur', () => {
567 if (menu.checkVisibility()) { 568 if (menu.checkVisibility()) {
568 cm.focus() 569 cm.focus()
569 } else { 570 } else {
570 cm.getWrapperElement().classList.remove('focus'); 571 cm.getWrapperElement().classList.remove('focus')
571 HtmlContainer.classList.add('focus'); 572 HtmlContainer.classList.add('focus')
572 } 573 }
573}); 574})
574// }}} 575// }}}
575// EVENT: keydown for suggestions {{{ 576// EVENT: keydown for suggestions {{{
576const keyForSuggestions = ['Tab', 'Enter', 'Escape']; 577const keyForSuggestions = ['Tab', 'Enter', 'Escape']
577cm.on('keydown', (_, e) => { 578cm.on('keydown', (_, e) => {
578 if ( 579 if (
579 !cm.hasFocus || 580 !cm.hasFocus ||
580 !keyForSuggestions.includes(e.key) || 581 !keyForSuggestions.includes(e.key) ||
581 menu.style.display === 'none' 582 menu.style.display === 'none'
582 ) 583 ) { return }
583 return;
584 584
585 // Directly add a newline when no suggestion is selected 585 // Directly add a newline when no suggestion is selected
586 const currentSuggestion = menu.querySelector('.container__suggestion.focus'); 586 const currentSuggestion = menu.querySelector('.container__suggestion.focus')
587 if (!currentSuggestion && e.key === 'Enter') return; 587 if (!currentSuggestion && e.key === 'Enter') return
588 588
589 // Override default behavior 589 // Override default behavior
590 e.preventDefault(); 590 e.preventDefault()
591 591
592 // Suggestion when pressing Tab or Shift + Tab 592 // Suggestion when pressing Tab or Shift + Tab
593 const nextSuggestion = 593 const nextSuggestion =
594 currentSuggestion?.nextSibling ?? 594 currentSuggestion?.nextSibling ??
595 menu.querySelector('.container__suggestion:first-child'); 595 menu.querySelector('.container__suggestion:first-child')
596 const previousSuggestion = 596 const previousSuggestion =
597 currentSuggestion?.previousSibling ?? 597 currentSuggestion?.previousSibling ??
598 menu.querySelector('.container__suggestion:last-child'); 598 menu.querySelector('.container__suggestion:last-child')
599 const focusSuggestion = e.shiftKey ? previousSuggestion : nextSuggestion; 599 const focusSuggestion = e.shiftKey ? previousSuggestion : nextSuggestion
600 600
601 // Current editor selection state 601 // Current editor selection state
602 switch (e.key) { 602 switch (e.key) {
603 case 'Tab': 603 case 'Tab':
604 Array.from(menu.children).forEach(s => s.classList.remove('focus')); 604 Array.from(menu.children).forEach(s => s.classList.remove('focus'))
605 focusSuggestion.classList.add('focus'); 605 focusSuggestion.classList.add('focus')
606 focusSuggestion.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); 606 focusSuggestion.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
607 break; 607 break
608 case 'Enter': 608 case 'Enter':
609 currentSuggestion.onclick(); 609 currentSuggestion.onclick()
610 break; 610 break
611 case 'Escape': 611 case 'Escape':
612 if (!menu.checkVisibility()) break; 612 if (!menu.checkVisibility()) break
613 // HACK delay menu display change for blur event, mark cm focus should keep 613 // HACK delay menu display change for blur event, mark cm focus should keep
614 setTimeout(() => (menu.style.display = 'none'), 50); 614 setTimeout(() => (menu.style.display = 'none'), 50)
615 break; 615 break
616 } 616 }
617}); 617})
618 618
619document.onkeydown = e => { 619document.onkeydown = e => {
620 if (e.altKey && e.ctrlKey && e.key === 'm') { 620 if (e.altKey && e.ctrlKey && e.key === 'm') {
621 toggleEditing(); 621 toggleEditing()
622 e.preventDefault(); 622 e.preventDefault()
623 return null; 623 return null
624 } 624 }
625 625
626 if (!cm.hasFocus()) { 626 if (!cm.hasFocus()) {
627 if (e.key === 'F1') { 627 if (e.key === 'F1') {
628 e.preventDefault(); 628 e.preventDefault()
629 cm.focus(); 629 cm.focus()
630 } 630 }
631 if (e.key === 'Tab') { 631 if (e.key === 'Tab') {
632 e.preventDefault(); 632 e.preventDefault()
633 dumbymap.utils.focusNextMap(e.shiftKey); 633 dumbymap.utils.focusNextMap(e.shiftKey)
634 } 634 }
635 if (e.key === 'x' || e.key === 'X') { 635 if (e.key === 'x' || e.key === 'X') {
636 e.preventDefault(); 636 e.preventDefault()
637 dumbymap.utils.switchToNextLayout(e.shiftKey); 637 dumbymap.utils.switchToNextLayout(e.shiftKey)
638 } 638 }
639 if (e.key === 'n') { 639 if (e.key === 'n') {
640 e.preventDefault(); 640 e.preventDefault()
641 dumbymap.utils.focusNextBlock(); 641 dumbymap.utils.focusNextBlock()
642 } 642 }
643 if (e.key === 'p') { 643 if (e.key === 'p') {
644 e.preventDefault(); 644 e.preventDefault()
645 dumbymap.utils.focusNextBlock(true); 645 dumbymap.utils.focusNextBlock(true)
646 } 646 }
647 if (e.key === 'Escape') { 647 if (e.key === 'Escape') {
648 e.preventDefault(); 648 e.preventDefault()
649 dumbymap.utils.removeBlockFocus(); 649 dumbymap.utils.removeBlockFocus()
650 } 650 }
651 } 651 }
652}; 652}
653 653
654// }}} 654// }}}
655// }}} 655// }}}
656// Layout Switch {{{ 656// Layout Switch {{{
657new MutationObserver(mutaions => { 657new window.MutationObserver(mutaions => {
658 const mutation = mutaions.at(-1); 658 const mutation = mutaions.at(-1)
659 const layout = HtmlContainer.getAttribute('data-layout'); 659 const layout = HtmlContainer.getAttribute('data-layout')
660 if (layout !== 'normal' || mutation.oldValue === 'normal') { 660 if (layout !== 'normal' || mutation.oldValue === 'normal') {
661 document.body.setAttribute('data-mode', ''); 661 document.body.setAttribute('data-mode', '')
662 } 662 }
663}).observe(HtmlContainer, { 663}).observe(HtmlContainer, {
664 attributes: true, 664 attributes: true,
665 attributeFilter: ['data-layout'], 665 attributeFilter: ['data-layout'],
666 attributeOldValue: true, 666 attributeOldValue: true
667}); 667})
668// }}} 668// }}}
669 669
670// vim: sw=2 ts=2 foldmethod=marker foldmarker={{{,}}} 670// vim: sw=2 ts=2 foldmethod=marker foldmarker={{{,}}}