aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/editor.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'src/editor.mjs')
-rw-r--r--src/editor.mjs301
1 files changed, 144 insertions, 157 deletions
diff --git a/src/editor.mjs b/src/editor.mjs
index dd18a31..b283a90 100644
--- a/src/editor.mjs
+++ b/src/editor.mjs
@@ -1,12 +1,22 @@
1import TinyMDE from 'tiny-markdown-editor'
2import { markdown2HTML, generateMaps } from './dumbymap' 1import { markdown2HTML, generateMaps } from './dumbymap'
3import { defaultAliasesForRenderer, parseConfigsFromYaml } from 'mapclay' 2import { defaultAliasesForRenderer, parseConfigsFromYaml } from 'mapclay'
4 3
5// Set up Editor {{{ 4// Set up Editor {{{
6 5
7const HtmlContainer = document.querySelector(".result-html") 6const HtmlContainer = document.querySelector(".result-html")
8const mdeElement = document.querySelector("#tinymde") 7const textArea = document.querySelector(".editor textarea")
9 8
9const toggleMaps = (container) => {
10 if (!container.querySelector('.Showcase')) {
11 generateMaps(container)
12 document.activeElement.blur();
13 } else {
14 markdown2HTML(HtmlContainer, editor.value())
15 container.setAttribute('data-layout', 'none')
16 }
17}
18
19// Content values for editor
10const getContentFromHash = (cleanHash = false) => { 20const getContentFromHash = (cleanHash = false) => {
11 const hashValue = location.hash.substring(1); 21 const hashValue = location.hash.substring(1);
12 if (cleanHash) window.location.hash = '' 22 if (cleanHash) window.location.hash = ''
@@ -14,58 +24,60 @@ const getContentFromHash = (cleanHash = false) => {
14 ? decodeURIComponent(hashValue.substring(5)) 24 ? decodeURIComponent(hashValue.substring(5))
15 : null 25 : null
16} 26}
17
18// Add Editor
19const contentFromHash = getContentFromHash(true) 27const contentFromHash = getContentFromHash(true)
20const lastContent = localStorage.getItem('editorContent') 28const lastContent = localStorage.getItem('editorContent')
21const 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' 29const 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'
22 30
23const tinyEditor = new TinyMDE.Editor({ 31// Set up EasyMDE {{{
24 element: 'tinymde', 32const editor = new EasyMDE({
25 content: contentFromHash ?? lastContent ?? defaultContent 33 element: textArea,
34 indentWithTabs: false,
35 initialValue: contentFromHash ?? lastContent ?? defaultContent,
36 lineNumbers: true,
37 promptURLs: true,
38 uploadImage: true,
39 spellChecker: false,
40 toolbarButtonClassPrefix: 'mde',
41 status: false,
42 shortcuts: {
43 "map": "Ctrl-Alt-M",
44 "debug": "Ctrl-Alt-D",
45 "toggleUnorderedList": "Ctrl-Shift-L",
46 },
47 toolbar: [
48 {
49 name: 'map',
50 title: 'Toggle Map Generation',
51 text: "🌏",
52 action: toggleMaps(HtmlContainer),
53 },
54 {
55 name: 'debug',
56 title: 'Save content as URL',
57 text: "🤔",
58 action: () => {
59 window.location.hash = '#text=' + encodeURIComponent(editor.value())
60 navigator.clipboard.writeText(window.location.href)
61 alert('URL copied to clipboard')
62 },
63 }, 'undo', 'redo', '|', 'heading-1', 'heading-2', '|', 'link', 'image', '|', 'bold', 'italic', 'strikethrough', 'code', 'clean-block', '|', 'unordered-list', 'ordered-list', 'quote', 'table', '|', 'fullscreen'
64 ],
26}); 65});
27mdeElement.querySelectorAll('span').forEach(e => e.setAttribute('spellcheck', 'false'))
28 66
67const cm = editor.codemirror
68markdown2HTML(HtmlContainer, editor.value())
69
70cm.on("change", () => {
71 markdown2HTML(HtmlContainer, editor.value())
72})
73// }}}
74
75// Reload editor content by hash value
29onhashchange = () => { 76onhashchange = () => {
30 const contentFromHash = getContentFromHash() 77 const contentFromHash = getContentFromHash()
31 if (contentFromHash) tinyEditor.setContent(contentFromHash) 78 if (contentFromHash) editor.value(contentFromHash)
32} 79}
33 80
34// Add command bar for editor
35// Use this command to render maps and geoLinks
36const mapCommand = {
37 name: 'map',
38 title: 'Switch Map Generation',
39 innerHTML: `<div style="font-size: 16px; line-height: 1.1;">🌏</div>`,
40 action: () => {
41 if (!HtmlContainer.querySelector('.map-container')) {
42 generateMaps(HtmlContainer)
43 document.activeElement.blur();
44 } else {
45 markdown2HTML(HtmlContainer, tinyEditor.getContent())
46 HtmlContainer.setAttribute('data-layout', 'none')
47 }
48 },
49 hotkey: 'Ctrl-m'
50}
51const debugCommand = {
52 name: 'debug',
53 title: 'show debug message',
54 innerHTML: `<div style="font-size: 16px; line-height: 1.1;">🤔</div>`,
55 action: () => {
56 window.location.hash = '#text=' + encodeURIComponent(tinyEditor.getContent())
57 },
58 hotkey: 'Ctrl-i'
59}
60// Set up command bar
61new TinyMDE.CommandBar({
62 element: 'tinymde_commandbar', editor: tinyEditor,
63 commands: [mapCommand, debugCommand, '|', 'h1', 'h2', '|', 'insertLink', 'insertImage', '|', 'bold', 'italic', 'strikethrough', 'code', '|', 'ul', 'ol', '|', 'blockquote']
64});
65
66// Render HTML to result container
67markdown2HTML(HtmlContainer, tinyEditor.getContent())
68
69// FIXME DEBUGONLY 81// FIXME DEBUGONLY
70// generateMaps(HtmlContainer) 82// generateMaps(HtmlContainer)
71// setTimeout(() => { 83// setTimeout(() => {
@@ -73,34 +85,10 @@ markdown2HTML(HtmlContainer, tinyEditor.getContent())
73// }, 500) 85// }, 500)
74 86
75// }}} 87// }}}
76// Event Listener: change {{{
77
78// Save editor content to local storage, set timeout for 3 seconds
79let cancelLastSave
80const saveContent = (content) => {
81 new Promise((resolve, reject) => {
82 // If user is typing, the last change cancel previous ones
83 if (cancelLastSave) cancelLastSave(content.length)
84 cancelLastSave = reject
85
86 setTimeout(() => {
87 localStorage.setItem('editorContent', content)
88 resolve('Content Saved')
89 }, 3000)
90 }).catch(() => null)
91}
92
93// Render HTML to result container and save current content
94tinyEditor.addEventListener('change', e => {
95 markdown2HTML(HtmlContainer, e.content)
96 saveContent(e.content)
97});
98// }}}
99// Completion in Code Blok {{{ 88// Completion in Code Blok {{{
100// Elements about suggestions {{{ 89// Elements about suggestions {{{
101const suggestionsEle = document.createElement('div') 90const suggestionsEle = document.createElement('div')
102suggestionsEle.classList.add('container__suggestions'); 91suggestionsEle.classList.add('container__suggestions');
103mdeElement.appendChild(suggestionsEle)
104 92
105const rendererOptions = {} 93const rendererOptions = {}
106 94
@@ -110,7 +98,6 @@ class Suggestion {
110 this.replace = replace 98 this.replace = replace
111 } 99 }
112} 100}
113
114// }}} 101// }}}
115// {{{ Aliases for map options 102// {{{ Aliases for map options
116const aliasesForMapOptions = {} 103const aliasesForMapOptions = {}
@@ -124,50 +111,54 @@ fetch(defaultApply)
124 .catch(err => console.warn(`Fail to get aliases from ${defaultApply}`, err)) 111 .catch(err => console.warn(`Fail to get aliases from ${defaultApply}`, err))
125// }}} 112// }}}
126// FUNCTION: Check cursor is inside map code block {{{ 113// FUNCTION: Check cursor is inside map code block {{{
127const insideCodeblockForMap = (element) => { 114// const insideCodeblockForMap = (currentLine) => {
128 const code = element.closest('.TMFencedCodeBacktick') 115// let tokens = cm.getLineTokens(currentLine)
129 if (!code) return false 116//
130 117// if (!tokens.includes("comment") || tokens.includes('formatting-code-block')) return false
131 let ps = code.previousSibling 118//
132 if (!ps) return false 119// do {
133 120// line = line - 1
134 // Look backward to find pattern of code block: /```map/ 121// if (line < 0) return false
135 while (!ps.classList.contains('TMCodeFenceBacktickOpen')) { 122// tokens = cm.getLineTokens(line)
136 ps = ps.previousSibling 123// } while (!tokens.includes('formatting-code-block'))
137 if (!ps) return false 124//
138 if (ps.classList.contains('TMCodeFenceBacktickClose')) return false 125// return true
139 } 126// }
140 127// }}}
141 return ps.querySelector('.TMInfoString')?.textContent === 'map' 128// Check if current token is inside code block {{{
142} 129const insideCodeblockForMap = (token) =>
130 token.state.overlay.codeBlock && !token.string.match(/^````*/)
143// }}} 131// }}}
144// FUNCTION: Get renderer by cursor position in code block {{{ 132// FUNCTION: Get renderer by cursor position in code block {{{
145const getLineWithRenderer = (element) => { 133const getLineWithRenderer = (anchor) => {
146 const currentLine = element.closest('.TMFencedCodeBacktick') 134 const currentLine = anchor.line
147 if (!currentLine) return null 135 const match = (line) => cm.getLine(line).match(/^use: /)
136 if (match(currentLine)) return currentLine
137
138 const getToken = (line) => cm.getTokenAt({ line: line, ch: 1 })
148 139
149 // Look backward/forward for pattern of used renderer: /use: .+/ 140 // Look backward/forward for pattern of used renderer: /use: .+/
150 let ps = currentLine 141 let ps = currentLine - 1
151 do { 142 while (ps > 0 && insideCodeblockForMap(getToken(ps))) {
152 ps = ps.previousSibling 143 if (match(ps)) {
153 if (ps.textContent.match(/^use: /)) {
154 return ps 144 return ps
155 } else if (ps.textContent.match(/^---/)) { 145 } else if (cm.getLine(ps).match(/^---/)) {
156 // If yaml doc separator is found 146 // If yaml doc separator is found
157 break 147 break
158 } 148 }
159 } while (ps && ps.classList.contains('TMFencedCodeBacktick')) 149 ps = ps - 1
150 }
160 151
161 let ns = currentLine 152 let ns = currentLine + 1
162 do { 153 while (insideCodeblockForMap(getToken(ns))) {
163 ns = ns.nextSibling 154 if (match(ns)) {
164 if (ns.textContent.match(/^use: /)) {
165 return ns 155 return ns
166 } else if (ns.textContent.match(/^---/)) { 156 } else if (cm.getLine(ns).match(/^---/)) {
167 // If yaml doc separator is found 157 // If yaml doc separator is found
168 return null 158 return null
169 } 159 }
170 } while (ns && ns.classList.contains('TMFencedCodeBacktick')) 160 ns = ns + 1
161 }
171 162
172 return null 163 return null
173} 164}
@@ -218,29 +209,35 @@ const getSuggestionsFromAliases = (option) => Object.entries(aliasesForMapOption
218 }) 209 })
219 ?? [] 210 ?? []
220// }}} 211// }}}
221const handleTypingInCodeBlock = (currentLine, selection) => { 212// FUCNTION: Handler for map codeblock {{{
222 const text = currentLine.textContent 213const handleTypingInCodeBlock = (anchor) => {
214 const text = cm.getLine(anchor.line)
223 if (text.match(/^\s\+$/) && text.length % 2 !== 0) { 215 if (text.match(/^\s\+$/) && text.length % 2 !== 0) {
224 // TODO Completion for even number of spaces 216 // TODO Completion for even number of spaces
225 } else if (text.match(/^-/)){ 217 } else if (text.match(/^-/)) {
226 // TODO Completion for YAML doc separator 218 // TODO Completion for YAML doc separator
227 } else { 219 } else {
228 addSuggestions(currentLine, selection) 220 const suggestions = getSuggestions(anchor)
221 addSuggestions(anchor, suggestions)
229 } 222 }
230} 223}
231// FUNCTION: Add HTML element for List of suggestions {{{ 224// }}}
232const addSuggestions = (currentLine, selection) => { 225// FUNCTION: get suggestions by current input {{{
233 const text = currentLine.textContent 226const getSuggestions = (anchor) => {
234 const markInputIsInvalid = (ele) => (ele ?? currentLine).classList.add('invalid-input') 227 const text = cm.getLine(anchor.line)
228 const markInputIsInvalid = () => cm.getDoc().markText(
229 { ...anchor, ch: 0 },
230 { ...anchor, ch: -1 },
231 { className: 'invalid-input' },
232 )
235 let suggestions = [] 233 let suggestions = []
236 234
237 // Check if "use: <renderer>" is set 235 // Check if "use: <renderer>" is set
238 const lineWithRenderer = getLineWithRenderer(currentLine) 236 const lineWithRenderer = getLineWithRenderer(anchor)
239 const renderer = lineWithRenderer?.textContent.split(' ')[1] 237 const renderer = cm.getLine(lineWithRenderer).split(' ')[1]
240 if (renderer) { 238 if (renderer) {
241
242 // Do not check properties 239 // Do not check properties
243 if (text.startsWith(' ')) return 240 if (text.startsWith(' ')) return []
244 241
245 // If no valid options for current used renderer, go get it! 242 // If no valid options for current used renderer, go get it!
246 const validOptions = rendererOptions[renderer] 243 const validOptions = rendererOptions[renderer]
@@ -253,9 +250,9 @@ const addSuggestions = (currentLine, selection) => {
253 }) 250 })
254 .catch(() => { 251 .catch(() => {
255 markInputIsInvalid(lineWithRenderer) 252 markInputIsInvalid(lineWithRenderer)
256 console.error('Fail to get valid options from renderer, URL is', rendererUrl) 253 console.warn(`Fail to get valid options from renderer with URL ${rendererUrl}` )
257 }) 254 })
258 return 255 return []
259 } 256 }
260 257
261 // If input is "key:value" (no space left after colon), then it is invalid 258 // If input is "key:value" (no space left after colon), then it is invalid
@@ -263,7 +260,7 @@ const addSuggestions = (currentLine, selection) => {
263 const isValidKeyValue = text.match(/^[^:]+:\s+/) 260 const isValidKeyValue = text.match(/^[^:]+:\s+/)
264 if (isKeyFinished && !isValidKeyValue) { 261 if (isKeyFinished && !isValidKeyValue) {
265 markInputIsInvalid() 262 markInputIsInvalid()
266 return 263 return []
267 } 264 }
268 265
269 // If user is typing option 266 // If user is typing option
@@ -311,6 +308,11 @@ const addSuggestions = (currentLine, selection) => {
311 ) 308 )
312 suggestions = rendererSuggestions.length > 0 ? rendererSuggestions : [] 309 suggestions = rendererSuggestions.length > 0 ? rendererSuggestions : []
313 } 310 }
311 return suggestions
312}
313// }}}
314// {{{ FUNCTION: Show element about suggestions
315const addSuggestions = (anchor, suggestions) => {
314 316
315 if (suggestions.length === 0) { 317 if (suggestions.length === 0) {
316 suggestionsEle.style.display = 'none'; 318 suggestionsEle.style.display = 'none';
@@ -336,55 +338,35 @@ const addSuggestions = (currentLine, selection) => {
336 option.classList.remove('focus') 338 option.classList.remove('focus')
337 } 339 }
338 option.onclick = () => { 340 option.onclick = () => {
339 const newFocus = { ...selection.focus, col: 0 } 341 cm.setSelection(anchor, { ...anchor, ch: 0 })
340 const newAnchor = { ...selection.anchor, col: text.length } 342 cm.replaceSelection(suggestion.replace)
341 tinyEditor.paste(suggestion.replace, newFocus, newAnchor) 343 cm.focus();
342 suggestionsEle.style.display = 'none'; 344 const newAnchor = { ...anchor, ch: suggestion.replace.length }
343 option.classList.remove('focus') 345 cm.setCursor(newAnchor);
344 }; 346 };
345 suggestionsEle.appendChild(option); 347 suggestionsEle.appendChild(option);
346 }); 348 });
347 349
348 const rect = currentLine.getBoundingClientRect(); 350 cm.addWidget(anchor, suggestionsEle, true)
349 suggestionsEle.style.top = `${rect.top + rect.height + 12}px`; 351 const rect = suggestionsEle.getBoundingClientRect()
350 suggestionsEle.style.left = `${rect.right}px`; 352 suggestionsEle.style.maxWidth = `calc(${window.innerWidth}px - ${rect.x}px - 2rem)`;
351 suggestionsEle.style.maxWidth = `calc(${window.innerWidth}px - ${rect.right}px - 2rem)`; 353 suggestionsEle.style.display = 'block'
352} 354}
353// }}} 355// }}}
354// EVENT: suggests for current selection {{{ 356// EVENT: suggests for current selection {{{
355tinyEditor.addEventListener('selection', selection => { 357// FIXME Dont show suggestion when selecting multiple chars
356 // Check selection is inside editor contents 358cm.on("beforeSelectionChange", (_, obj) => {
357 const node = selection?.anchor?.node 359 const anchor = (obj.ranges[0].anchor)
358 if (!node) return 360 const token = cm.getTokenAt(anchor)
359
360 // FIXME Better way to prevent spellcheck across editor
361 // Get HTML element for current selection
362 const element = node instanceof HTMLElement
363 ? node
364 : node.parentNode
365 element.setAttribute('spellcheck', 'false')
366
367 // To trigger click event on suggestions list, don't set suggestion list invisible
368 if (suggestionsEle.querySelector('.container__suggestion.focus:hover') !== null) {
369 return
370 } else {
371 suggestionsEle.style.display = 'none';
372 }
373
374 // Do not show suggestion by attribute
375 if (suggestionsEle.getAttribute('data-keep-close') === 'true') {
376 suggestionsEle.setAttribute('data-keep-close', 'false')
377 return
378 }
379 361
380 // Show suggestions for map code block 362 if (insideCodeblockForMap(token)) {
381 if (insideCodeblockForMap(element)) { 363 handleTypingInCodeBlock(anchor)
382 handleTypingInCodeBlock(element, selection)
383 } 364 }
384}); 365});
385// }}} 366// }}}
386// EVENT: keydown for suggestions {{{ 367// EVENT: keydown for suggestions {{{
387mdeElement.addEventListener('keydown', (e) => { 368cm.on('keydown', (_, e) => {
369
388 // Only the following keys are used 370 // Only the following keys are used
389 const keyForSuggestions = ['Tab', 'Enter', 'Escape'].includes(e.key) 371 const keyForSuggestions = ['Tab', 'Enter', 'Escape'].includes(e.key)
390 if (!keyForSuggestions || suggestionsEle.style.display === 'none') return; 372 if (!keyForSuggestions || suggestionsEle.style.display === 'none') return;
@@ -402,7 +384,6 @@ mdeElement.addEventListener('keydown', (e) => {
402 const focusSuggestion = e.shiftKey ? previousSuggestion : nextSuggestion 384 const focusSuggestion = e.shiftKey ? previousSuggestion : nextSuggestion
403 385
404 // Current editor selection state 386 // Current editor selection state
405 const selection = tinyEditor.getSelection(true)
406 switch (e.key) { 387 switch (e.key) {
407 case 'Tab': 388 case 'Tab':
408 Array.from(suggestionsEle.children).forEach(s => s.classList.remove('focus')) 389 Array.from(suggestionsEle.children).forEach(s => s.classList.remove('focus'))
@@ -411,16 +392,22 @@ mdeElement.addEventListener('keydown', (e) => {
411 break; 392 break;
412 case 'Enter': 393 case 'Enter':
413 currentSuggestion.onclick() 394 currentSuggestion.onclick()
414 suggestionsEle.style.display = 'none';
415 break; 395 break;
416 case 'Escape': 396 case 'Escape':
397 const anchor = cm.getCursor()
417 suggestionsEle.style.display = 'none'; 398 suggestionsEle.style.display = 'none';
418 // Prevent trigger selection event again 399 // Focus editor again
419 suggestionsEle.setAttribute('data-keep-close', 'true') 400 setTimeout(() => cm.focus() && cm.setCursor(anchor), 100)
420 setTimeout(() => tinyEditor.setSelection(selection), 100)
421 break; 401 break;
422 } 402 }
423}); 403});
404
405document.onkeydown = (e) => {
406 if (e.altKey && e.ctrlKey && e.key === 'm') {
407 toggleMaps(HtmlContainer)
408 }
409}
410
424// }}} 411// }}}
425// }}} 412// }}}
426 413