aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--eslint.config.js36
-rw-r--r--package.json14
-rw-r--r--rollup.config.js54
-rw-r--r--src/Layout.mjs231
-rw-r--r--src/MenuItem.mjs248
-rw-r--r--src/dumbyUtils.mjs130
-rw-r--r--src/dumbymap.mjs469
-rw-r--r--src/editor.mjs548
-rw-r--r--src/utils.mjs67
9 files changed, 895 insertions, 902 deletions
diff --git a/eslint.config.js b/eslint.config.js
index 7ac6782..3bca729 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -1,16 +1,16 @@
1import globals from "globals"; 1import globals from 'globals'
2import js from "@eslint/js"; 2import js from '@eslint/js'
3import importPlugin from "eslint-plugin-import"; 3import importPlugin from 'eslint-plugin-import'
4import promisePlugin from "eslint-plugin-promise"; 4import promisePlugin from 'eslint-plugin-promise'
5import nodePlugin from "eslint-plugin-node"; 5import nodePlugin from 'eslint-plugin-node'
6import jsdoc from 'eslint-plugin-jsdoc'; 6import jsdoc from 'eslint-plugin-jsdoc'
7 7
8export default [ 8export default [
9 js.configs.recommended, 9 js.configs.recommended,
10 { 10 {
11 languageOptions: { 11 languageOptions: {
12 ecmaVersion: 2022, 12 ecmaVersion: 2022,
13 sourceType: "module", 13 sourceType: 'module',
14 globals: { 14 globals: {
15 ...globals.browser, 15 ...globals.browser,
16 ...globals.node 16 ...globals.node
@@ -19,31 +19,31 @@ export default [
19 plugins: { 19 plugins: {
20 import: importPlugin, 20 import: importPlugin,
21 promise: promisePlugin, 21 promise: promisePlugin,
22 node: nodePlugin, 22 node: nodePlugin
23 }, 23 },
24 rules: { 24 rules: {
25 'no-unused-vars': ['warn', { 25 'no-unused-vars': ['warn', {
26 'varsIgnorePattern': '^_', 26 varsIgnorePattern: '^_',
27 "argsIgnorePattern": "^_", 27 argsIgnorePattern: '^_',
28 "caughtErrorsIgnorePattern": "^ignore|_", 28 caughtErrorsIgnorePattern: '^ignore|_'
29 }], 29 }],
30 'import/no-unresolved': 'error', 30 'import/no-unresolved': 'error',
31 'no-console': ["error", { allow: ["info", "warn", "error"] }], 31 'no-console': ['error', { allow: ['info', 'warn', 'error'] }],
32 'eqeqeq': ['error', 'always'], 32 eqeqeq: ['error', 'always'],
33 // 'curly': ['warn', 'multi'], 33 // 'curly': ['warn', 'multi'],
34 'prefer-const': 'error', 34 'prefer-const': 'error',
35 'no-var': 'error', 35 'no-var': 'error',
36 'array-callback-return': 'error', 36 'array-callback-return': 'error',
37 'no-unexpected-multiline': 'warn', 37 'no-unexpected-multiline': 'warn'
38 }, 38 }
39 }, 39 },
40 { 40 {
41 files: ['src/*js'], 41 files: ['src/*js'],
42 plugins: { 42 plugins: {
43 jsdoc, 43 jsdoc
44 }, 44 },
45 rules: { 45 rules: {
46 'jsdoc/require-description': 'warn' 46 'jsdoc/require-description': 'warn'
47 } 47 }
48 }, 48 }
49]; 49]
diff --git a/package.json b/package.json
index 9798d45..918a710 100644
--- a/package.json
+++ b/package.json
@@ -25,26 +25,20 @@
25 "build-resources": "cp node_modules/mapclay/assets/default.yml dist; cp node_modules/easymde/dist/easymde.min.js dist; cp node_modules/easymde/dist/easymde.min.css dist/css", 25 "build-resources": "cp node_modules/mapclay/assets/default.yml dist; cp node_modules/easymde/dist/easymde.min.js dist; cp node_modules/easymde/dist/easymde.min.css dist/css",
26 "server": "live-server --port=8080 --ignore='**/src/**js' --wait=2000 --no-browser --cors", 26 "server": "live-server --port=8080 --ignore='**/src/**js' --wait=2000 --no-browser --cors",
27 "dev": "npm run server", 27 "dev": "npm run server",
28 "lint": "npx eslint src", 28 "lint": "npx standard",
29 "style": "scripts/stylelint.sh", 29 "style": "scripts/stylelint.sh",
30 "prepack": "npm run lint && npm run style && npm run build", 30 "prepack": "npm run lint && npm run style && npm run build",
31 "postpack": "rm -rf dist/css dist/renderers; ln -sf `pwd`/src/css dist; cp node_modules/easymde/dist/easymde.min.css src/css; ln -sf `pwd`/mapclay/dist/renderers dist" 31 "postpack": "rm -rf dist/css dist/renderers; ln -sf `pwd`/src/css dist; cp node_modules/easymde/dist/easymde.min.css src/css; ln -sf `pwd`/mapclay/dist/renderers dist"
32 }, 32 },
33 "devDependencies": { 33 "devDependencies": {
34 "@eslint/compat": "^1.1.1",
35 "@eslint/js": "^9.11.1",
36 "@rollup/plugin-alias": "^5.1.1", 34 "@rollup/plugin-alias": "^5.1.1",
37 "@rollup/plugin-commonjs": "^26.0.3", 35 "@rollup/plugin-commonjs": "^26.0.3",
38 "@rollup/plugin-node-resolve": "^15.3.0", 36 "@rollup/plugin-node-resolve": "^15.3.0",
39 "@rollup/plugin-terser": "^0.4.4", 37 "@rollup/plugin-terser": "^0.4.4",
40 "eslint": "^9.11.1", 38 "globals": "^15.10.0",
41 "eslint-plugin-import": "^2.30.0", 39 "rollup": "^4.23.0",
42 "eslint-plugin-jsdoc": "^50.3.0",
43 "eslint-plugin-node": "^11.1.0",
44 "eslint-plugin-promise": "^7.1.0",
45 "globals": "^15.9.0",
46 "rollup": "^4.22.5",
47 "rollup-plugin-bundle-stats": "^4.15.1", 40 "rollup-plugin-bundle-stats": "^4.15.1",
41 "standard": "^17.1.2",
48 "stylelint": "^16.9.0", 42 "stylelint": "^16.9.0",
49 "stylelint-config-standard": "^36.0.1", 43 "stylelint-config-standard": "^36.0.1",
50 "stylelint-order": "^6.0.4" 44 "stylelint-order": "^6.0.4"
diff --git a/rollup.config.js b/rollup.config.js
index 75db905..cc42bf0 100644
--- a/rollup.config.js
+++ b/rollup.config.js
@@ -1,70 +1,70 @@
1import node from '@rollup/plugin-node-resolve'; 1import node from '@rollup/plugin-node-resolve'
2import commonjs from '@rollup/plugin-commonjs'; 2import commonjs from '@rollup/plugin-commonjs'
3import terser from '@rollup/plugin-terser'; 3import terser from '@rollup/plugin-terser'
4import { existsSync } from 'fs'; 4import { existsSync } from 'fs'
5import { join } from 'path'; 5import { join } from 'path'
6import { bundleStats } from 'rollup-plugin-bundle-stats'; 6import { bundleStats } from 'rollup-plugin-bundle-stats'
7 7
8const production = !process.env.ROLLUP_WATCH; 8const production = !process.env.ROLLUP_WATCH
9 9
10const general = { 10const general = {
11 output: [ 11 output: [
12 { 12 {
13 dir: './dist', 13 dir: './dist',
14 format: 'esm', 14 format: 'esm',
15 entryFileNames: '[name].mjs', 15 entryFileNames: '[name].mjs'
16 } 16 }
17 ], 17 ],
18 watch: { 18 watch: {
19 clearScreen: false, 19 clearScreen: false,
20 include: ["src/**", "mapclay/dist/mapclay.mjs"] 20 include: ['src/**', 'mapclay/dist/mapclay.mjs']
21 }, 21 },
22 context: "window", 22 context: 'window',
23 plugins: [ 23 plugins: [
24 { 24 {
25 name: 'watch-mapclay', 25 name: 'watch-mapclay',
26 buildStart() { 26 buildStart () {
27 const mapclayPath = join(process.cwd(), 'mapclay', 'dist', 'mapclay.mjs'); 27 const mapclayPath = join(process.cwd(), 'mapclay', 'dist', 'mapclay.mjs')
28 console.log('Watching:', mapclayPath); 28 console.log('Watching:', mapclayPath)
29 if (existsSync(mapclayPath)) { 29 if (existsSync(mapclayPath)) {
30 this.addWatchFile(mapclayPath); 30 this.addWatchFile(mapclayPath)
31 } else { 31 } else {
32 console.log('mapclay.mjs not found at:', mapclayPath); 32 console.log('mapclay.mjs not found at:', mapclayPath)
33 } 33 }
34 } 34 }
35 }, 35 },
36 { 36 {
37 name: 'leader-line', 37 name: 'leader-line',
38 transform(code, id) { 38 transform (code, id) {
39 if (id.includes('node_modules/leader-line/')) { 39 if (id.includes('node_modules/leader-line/')) {
40 return `${code}\nexport default LeaderLine;`; 40 return `${code}\nexport default LeaderLine;`
41 } 41 }
42 return null; 42 return null
43 }, 43 }
44 }, 44 },
45 { 45 {
46 name: 'mapclay', 46 name: 'mapclay',
47 resolveId(source) { 47 resolveId (source) {
48 if (source === 'mapclay' && existsSync(join('.', 'mapclay'))) { 48 if (source === 'mapclay' && existsSync(join('.', 'mapclay'))) {
49 return './mapclay/dist/mapclay.mjs'; 49 return './mapclay/dist/mapclay.mjs'
50 } 50 }
51 return null; 51 return null
52 } 52 }
53 }, 53 },
54 node(), 54 node(),
55 commonjs(), 55 commonjs(),
56 production && terser(), 56 production && terser(),
57 // bundleStats(), 57 production && bundleStats()
58 ], 58 ]
59} 59}
60 60
61export default [ 61export default [
62 { 62 {
63 input: "src/editor.mjs", 63 input: 'src/editor.mjs'
64 }, 64 },
65 { 65 {
66 input: "src/dumbymap.mjs", 66 input: 'src/dumbymap.mjs'
67 }, 67 }
68] 68]
69 .map(config => ({ ...general, ...config })) 69 .map(config => ({ ...general, ...config }))
70 .filter((config) => production || config.input.match(/editor/)) 70 .filter((config) => production || config.input.match(/editor/))
diff --git a/src/Layout.mjs b/src/Layout.mjs
index 3a835c9..89a48ae 100644
--- a/src/Layout.mjs
+++ b/src/Layout.mjs
@@ -1,208 +1,209 @@
1import PlainDraggable from 'plain-draggable'; 1import PlainDraggable from 'plain-draggable'
2import { onRemove, animateRectTransition } from './utils'; 2import { onRemove, animateRectTransition } from './utils'
3 3
4export class Layout { 4export class Layout {
5 constructor(options = {}) { 5 constructor (options = {}) {
6 if (!options.name) throw Error('Layout name is not given'); 6 if (!options.name) throw Error('Layout name is not given')
7 this.name = options.name; 7 this.name = options.name
8 this.enterHandler = options.enterHandler; 8 this.enterHandler = options.enterHandler
9 this.leaveHandler = options.leaveHandler; 9 this.leaveHandler = options.leaveHandler
10 } 10 }
11 valueOf = () => this.name; 11
12 valueOf = () => this.name
12} 13}
13 14
14export class SideBySide extends Layout { 15export class SideBySide extends Layout {
15 name = 'side-by-side'; 16 name = 'side-by-side'
16 17
17 enterHandler = ({ container, htmlHolder, showcase }) => { 18 enterHandler = ({ container, htmlHolder, showcase }) => {
18 const bar = document.createElement('div'); 19 const bar = document.createElement('div')
19 bar.className = 'bar'; 20 bar.className = 'bar'
20 bar.innerHTML = '<div class="bar-handle"></div>'; 21 bar.innerHTML = '<div class="bar-handle"></div>'
21 const handle = bar.querySelector('.bar-handle'); 22 const handle = bar.querySelector('.bar-handle')
22 container.appendChild(bar); 23 container.appendChild(bar)
23 24
24 // Resize views by value 25 // Resize views by value
25 const resizeByLeft = left => { 26 const resizeByLeft = left => {
26 htmlHolder.style.width = left + 'px'; 27 htmlHolder.style.width = left + 'px'
27 showcase.style.width = 28 showcase.style.width =
28 parseFloat(getComputedStyle(container).width) - left + 'px'; 29 parseFloat(window.getComputedStyle(container).width) - left + 'px'
29 }; 30 }
30 31
31 const draggable = new PlainDraggable(bar, { 32 const draggable = new PlainDraggable(bar, {
32 handle: handle, 33 handle,
33 containment: { left: '25%', top: 0, right: '75%', height: 0 }, 34 containment: { left: '25%', top: 0, right: '75%', height: 0 }
34 }); 35 })
35 draggable.draggableCursor = 'grab'; 36 draggable.draggableCursor = 'grab'
36 37
37 draggable.onDrag = pos => { 38 draggable.onDrag = pos => {
38 handle.style.transform = 'unset'; 39 handle.style.transform = 'unset'
39 resizeByLeft(pos.left); 40 resizeByLeft(pos.left)
40 }; 41 }
41 draggable.onDragEnd = _ => { 42 draggable.onDragEnd = _ => {
42 handle.removeAttribute('style'); 43 handle.removeAttribute('style')
43 }; 44 }
44 45
45 onRemove(bar, () => draggable.remove()); 46 onRemove(bar, () => draggable.remove())
46 }; 47 }
47 48
48 leaveHandler = ({ container }) => { 49 leaveHandler = ({ container }) => {
49 container.querySelector('.bar')?.remove(); 50 container.querySelector('.bar')?.remove()
50 }; 51 }
51} 52}
52 53
53export class Overlay extends Layout { 54export class Overlay extends Layout {
54 name = 'overlay'; 55 name = 'overlay'
55 56
56 saveLeftTopAsData = element => { 57 saveLeftTopAsData = element => {
57 const { left, top } = element.getBoundingClientRect(); 58 const { left, top } = element.getBoundingClientRect()
58 element.setAttribute('data-left', left); 59 element.setAttribute('data-left', left)
59 element.setAttribute('data-top', top); 60 element.setAttribute('data-top', top)
60 }; 61 }
61 62
62 addDraggable = element => { 63 addDraggable = element => {
63 // Make sure current element always on top 64 // Make sure current element always on top
64 const siblings = Array.from( 65 const siblings = Array.from(
65 element.parentElement?.querySelectorAll(':scope > *') ?? [], 66 element.parentElement?.querySelectorAll(':scope > *') ?? []
66 ); 67 )
67 let popTimer = null; 68 let popTimer = null
68 element.onmouseover = () => { 69 element.onmouseover = () => {
69 popTimer = setTimeout(() => { 70 popTimer = setTimeout(() => {
70 siblings.forEach(e => e.style.removeProperty('z-index')); 71 siblings.forEach(e => e.style.removeProperty('z-index'))
71 element.style.zIndex = '9001'; 72 element.style.zIndex = '9001'
72 }, 200); 73 }, 200)
73 }; 74 }
74 element.onmouseout = () => { 75 element.onmouseout = () => {
75 clearTimeout(popTimer); 76 clearTimeout(popTimer)
76 }; 77 }
77 78
78 // Add draggable part 79 // Add draggable part
79 const draggablePart = document.createElement('div'); 80 const draggablePart = document.createElement('div')
80 element.appendChild(draggablePart); 81 element.appendChild(draggablePart)
81 draggablePart.className = 'draggable-part'; 82 draggablePart.className = 'draggable-part'
82 draggablePart.innerHTML = '<div class="handle">\u2630</div>'; 83 draggablePart.innerHTML = '<div class="handle">\u2630</div>'
83 84
84 // Add draggable instance 85 // Add draggable instance
85 const { left, top } = element.getBoundingClientRect(); 86 const { left, top } = element.getBoundingClientRect()
86 const draggable = new PlainDraggable(element, { 87 const draggable = new PlainDraggable(element, {
87 top: top, 88 top,
88 left: left, 89 left,
89 handle: draggablePart, 90 handle: draggablePart,
90 snap: { x: { step: 20 }, y: { step: 20 } }, 91 snap: { x: { step: 20 }, y: { step: 20 } }
91 }); 92 })
92 93
93 // FIXME use pure CSS to hide utils 94 // FIXME use pure CSS to hide utils
94 const utils = element.querySelector('.utils'); 95 const utils = element.querySelector('.utils')
95 draggable.onDragStart = () => { 96 draggable.onDragStart = () => {
96 utils.style.display = 'none'; 97 utils.style.display = 'none'
97 element.classList.add('drag'); 98 element.classList.add('drag')
98 }; 99 }
99 100
100 draggable.onDragEnd = () => { 101 draggable.onDragEnd = () => {
101 utils.style = ''; 102 utils.style = ''
102 element.classList.remove('drag'); 103 element.classList.remove('drag')
103 element.style.zIndex = '9000'; 104 element.style.zIndex = '9000'
104 }; 105 }
105 106
106 // Reposition draggable instance when resized 107 // Reposition draggable instance when resized
107 new ResizeObserver(() => { 108 new window.ResizeObserver(() => {
108 try { 109 try {
109 draggable.position(); 110 draggable.position()
110 } catch (_) { 111 } catch (err) {
111 null; 112 console.warn(err)
112 } 113 }
113 }).observe(element); 114 }).observe(element)
114 115
115 // Callback for remove 116 // Callback for remove
116 onRemove(element, () => { 117 onRemove(element, () => {
117 draggable.remove(); 118 draggable.remove()
118 }); 119 })
119 }; 120 }
120 121
121 enterHandler = ({ htmlHolder, blocks }) => { 122 enterHandler = ({ htmlHolder, blocks }) => {
122 // FIXME It is weird rect from this method and this scope are different... 123 // FIXME It is weird rect from this method and this scope are different...
123 blocks.forEach(this.saveLeftTopAsData); 124 blocks.forEach(this.saveLeftTopAsData)
124 125
125 // Create draggable blocks and set each position by previous one 126 // Create draggable blocks and set each position by previous one
126 let [left, top] = [20, 20]; 127 let [left, top] = [20, 20]
127 blocks.forEach(block => { 128 blocks.forEach(block => {
128 const originLeft = Number(block.getAttribute('data-left')); 129 const originLeft = Number(block.getAttribute('data-left'))
129 const originTop = Number(block.getAttribute('data-top')); 130 const originTop = Number(block.getAttribute('data-top'))
130 131
131 // Create draggable block 132 // Create draggable block
132 const wrapper = document.createElement('div'); 133 const wrapper = document.createElement('div')
133 wrapper.classList.add('draggable-block'); 134 wrapper.classList.add('draggable-block')
134 wrapper.innerHTML = ` 135 wrapper.innerHTML = `
135 <div class="utils"> 136 <div class="utils">
136 <div id="close">\u274C</div> 137 <div id="close">\u274C</div>
137 <div id="plus-font-size" ">\u2795</div> 138 <div id="plus-font-size" ">\u2795</div>
138 <div id="minus-font-size">\u2796</div> 139 <div id="minus-font-size">\u2796</div>
139 </div> 140 </div>
140 `; 141 `
141 wrapper.title = 'Middle-click to hide block'; 142 wrapper.title = 'Middle-click to hide block'
142 wrapper.onmouseup = e => { 143 wrapper.onmouseup = e => {
143 // Hide block with middle click 144 // Hide block with middle click
144 if (e.button === 1) { 145 if (e.button === 1) {
145 wrapper.classList.add('hide'); 146 wrapper.classList.add('hide')
146 } 147 }
147 }; 148 }
148 149
149 // Set DOMRect for wrapper 150 // Set DOMRect for wrapper
150 wrapper.appendChild(block); 151 wrapper.appendChild(block)
151 wrapper.style.left = left + 'px'; 152 wrapper.style.left = left + 'px'
152 wrapper.style.top = top + 'px'; 153 wrapper.style.top = top + 'px'
153 htmlHolder.appendChild(wrapper); 154 htmlHolder.appendChild(wrapper)
154 const { width } = wrapper.getBoundingClientRect(); 155 const { width } = wrapper.getBoundingClientRect()
155 left += width + 30; 156 left += width + 30
156 if (left > window.innerWidth) { 157 if (left > window.innerWidth) {
157 top += 200; 158 top += 200
158 left = left % window.innerWidth; 159 left = left % window.innerWidth
159 } 160 }
160 161
161 // Animation for DOMRect 162 // Animation for DOMRect
162 animateRectTransition( 163 animateRectTransition(
163 wrapper, 164 wrapper,
164 { left: originLeft, top: originTop }, 165 { left: originLeft, top: originTop },
165 { resume: true, duration: 300 }, 166 { resume: true, duration: 300 }
166 ).finished.finally(() => this.addDraggable(wrapper)); 167 ).finished.finally(() => this.addDraggable(wrapper))
167 168
168 // Trivial case: 169 // Trivial case:
169 // This hack make sure utils remains at the same place even when wrapper resized 170 // This hack make sure utils remains at the same place even when wrapper resized
170 // Prevent DOMRect changes when user clicking plus/minus button many times 171 // Prevent DOMRect changes when user clicking plus/minus button many times
171 const utils = wrapper.querySelector('.utils'); 172 const utils = wrapper.querySelector('.utils')
172 utils.onmouseover = () => { 173 utils.onmouseover = () => {
173 const { left, top } = utils.getBoundingClientRect(); 174 const { left, top } = utils.getBoundingClientRect()
174 utils.style.cssText = `visibility: visible; z-index: 9000; position: fixed; transition: unset; left: ${left}px; top: ${top}px;`; 175 utils.style.cssText = `visibility: visible; z-index: 9000; position: fixed; transition: unset; left: ${left}px; top: ${top}px;`
175 document.body.appendChild(utils); 176 document.body.appendChild(utils)
176 }; 177 }
177 utils.onmouseout = () => { 178 utils.onmouseout = () => {
178 wrapper.appendChild(utils); 179 wrapper.appendChild(utils)
179 utils.removeAttribute('style'); 180 utils.removeAttribute('style')
180 }; 181 }
181 182
182 // Close button 183 // Close button
183 wrapper.querySelector('#close').onclick = () => { 184 wrapper.querySelector('#close').onclick = () => {
184 wrapper.classList.add('hide'); 185 wrapper.classList.add('hide')
185 utils.removeAttribute('style'); 186 utils.removeAttribute('style')
186 }; 187 }
187 // Plus/Minus font-size of content 188 // Plus/Minus font-size of content
188 wrapper.querySelector('#plus-font-size').onclick = () => { 189 wrapper.querySelector('#plus-font-size').onclick = () => {
189 const fontSize = parseFloat(getComputedStyle(block).fontSize) / 16; 190 const fontSize = parseFloat(window.getComputedStyle(block).fontSize) / 16
190 block.style.fontSize = `${fontSize + 0.2}rem`; 191 block.style.fontSize = `${fontSize + 0.2}rem`
191 }; 192 }
192 wrapper.querySelector('#minus-font-size').onclick = () => { 193 wrapper.querySelector('#minus-font-size').onclick = () => {
193 const fontSize = parseFloat(getComputedStyle(block).fontSize) / 16; 194 const fontSize = parseFloat(window.getComputedStyle(block).fontSize) / 16
194 block.style.fontSize = `${fontSize - 0.2}rem`; 195 block.style.fontSize = `${fontSize - 0.2}rem`
195 }; 196 }
196 }); 197 })
197 }; 198 }
198 199
199 leaveHandler = ({ htmlHolder, blocks }) => { 200 leaveHandler = ({ htmlHolder, blocks }) => {
200 const resumeFromDraggable = block => { 201 const resumeFromDraggable = block => {
201 const draggableContainer = block.closest('.draggable-block'); 202 const draggableContainer = block.closest('.draggable-block')
202 if (!draggableContainer) return; 203 if (!draggableContainer) return
203 htmlHolder.appendChild(block); 204 htmlHolder.appendChild(block)
204 draggableContainer.remove(); 205 draggableContainer.remove()
205 }; 206 }
206 blocks.forEach(resumeFromDraggable); 207 blocks.forEach(resumeFromDraggable)
207 }; 208 }
208} 209}
diff --git a/src/MenuItem.mjs b/src/MenuItem.mjs
index 2bf54c2..e1cb582 100644
--- a/src/MenuItem.mjs
+++ b/src/MenuItem.mjs
@@ -1,44 +1,44 @@
1class Item extends HTMLDivElement { 1class Item extends window.HTMLDivElement {
2 constructor({ text, innerHTML, onclick, style }) { 2 constructor ({ text, innerHTML, onclick, style }) {
3 super(); 3 super()
4 this.innerHTML = innerHTML ?? text; 4 this.innerHTML = innerHTML ?? text
5 this.onclick = onclick; 5 this.onclick = onclick
6 this.classList.add('menu-item'); 6 this.classList.add('menu-item')
7 this.style.cssText = style; 7 this.style.cssText = style
8 8
9 this.onmouseover = () => { 9 this.onmouseover = () => {
10 this.parentElement 10 this.parentElement
11 .querySelectorAll('.sub-menu') 11 .querySelectorAll('.sub-menu')
12 .forEach(sub => sub.remove()); 12 .forEach(sub => sub.remove())
13 } 13 }
14 } 14 }
15} 15}
16window.customElements.define('menu-item', Item, { extends: 'div' }); 16window.customElements.define('menu-item', Item, { extends: 'div' })
17 17
18class Folder extends HTMLDivElement { 18class Folder extends window.HTMLDivElement {
19 constructor({ text, innerHTML, items }) { 19 constructor ({ text, innerHTML, items }) {
20 super(); 20 super()
21 this.innerHTML = innerHTML ?? text; 21 this.innerHTML = innerHTML ?? text
22 this.classList.add('folder', 'menu-item'); 22 this.classList.add('folder', 'menu-item')
23 this.items = items; 23 this.items = items
24 this.onmouseover = () => { 24 this.onmouseover = () => {
25 if (this.querySelector('.sub-menu')) return; 25 if (this.querySelector('.sub-menu')) return
26 // Prepare submenu 26 // Prepare submenu
27 const submenu = document.createElement('div'); 27 const submenu = document.createElement('div')
28 submenu.className = 'sub-menu'; 28 submenu.className = 'sub-menu'
29 submenu.style.cssText = `position: absolute; left: 105%; top: 0px;`; 29 submenu.style.cssText = 'position: absolute; left: 105%; top: 0px;'
30 this.items.forEach(item => submenu.appendChild(item)); 30 this.items.forEach(item => submenu.appendChild(item))
31 submenu.onmouseleave = () => submenu.remove() 31 submenu.onmouseleave = () => submenu.remove()
32 32
33 // hover effect 33 // hover effect
34 this.parentElement 34 this.parentElement
35 .querySelectorAll('.sub-menu') 35 .querySelectorAll('.sub-menu')
36 .forEach(sub => sub.remove()); 36 .forEach(sub => sub.remove())
37 this.appendChild(submenu); 37 this.appendChild(submenu)
38 }; 38 }
39 } 39 }
40} 40}
41window.customElements.define('menu-folder', Folder, { extends: 'div' }); 41window.customElements.define('menu-folder', Folder, { extends: 'div' })
42 42
43export const pickMapItem = ({ utils }) => 43export const pickMapItem = ({ utils }) =>
44 new Folder({ 44 new Folder({
@@ -48,12 +48,12 @@ export const pickMapItem = ({ utils }) =>
48 new Item({ 48 new Item({
49 text: map.id, 49 text: map.id,
50 onclick: () => { 50 onclick: () => {
51 map.classList.add('focus'); 51 map.classList.add('focus')
52 map.scrollIntoView({ behavior: 'smooth' }); 52 map.scrollIntoView({ behavior: 'smooth' })
53 }, 53 }
54 }), 54 })
55 ), 55 )
56 }); 56 })
57 57
58export const pickBlockItem = ({ blocks, utils }) => 58export const pickBlockItem = ({ blocks, utils }) =>
59 new Folder({ 59 new Folder({
@@ -68,12 +68,12 @@ export const pickBlockItem = ({ blocks, utils }) =>
68 ?.textContent.substring(0, 15) 68 ?.textContent.substring(0, 15)
69 .concat(' ...'), 69 .concat(' ...'),
70 onclick: () => { 70 onclick: () => {
71 block.classList.add('focus'); 71 block.classList.add('focus')
72 utils.scrollToBlock(block); 72 utils.scrollToBlock(block)
73 }, 73 }
74 }), 74 })
75 ), 75 )
76 }); 76 })
77 77
78export const pickLayoutItem = ({ container, layouts }) => 78export const pickLayoutItem = ({ container, layouts }) =>
79 new Folder({ 79 new Folder({
@@ -82,78 +82,78 @@ export const pickLayoutItem = ({ container, layouts }) =>
82 new Item({ 82 new Item({
83 text: 'EDIT', 83 text: 'EDIT',
84 onclick: () => 84 onclick: () =>
85 container.closest('[data-mode]').setAttribute('data-mode', 'editing'), 85 container.closest('[data-mode]').setAttribute('data-mode', 'editing')
86 }), 86 }),
87 ...layouts.map( 87 ...layouts.map(
88 layout => 88 layout =>
89 new Item({ 89 new Item({
90 text: layout.name, 90 text: layout.name,
91 onclick: () => container.setAttribute('data-layout', layout.name), 91 onclick: () => container.setAttribute('data-layout', layout.name)
92 }), 92 })
93 ), 93 ),
94 new Item({ 94 new Item({
95 innerHTML: '<a href="https://github.com/outdoorsafetylab/dumbymap#layouts" class="external" style="display: block; padding: 0.5rem;">More...</a>', 95 innerHTML: '<a href="https://github.com/outdoorsafetylab/dumbymap#layouts" class="external" style="display: block; padding: 0.5rem;">More...</a>',
96 style: 'padding: 0;' 96 style: 'padding: 0;'
97 }), 97 })
98 ], 98 ]
99 }); 99 })
100 100
101export const addGeoLink = ({ utils }, range) => 101export const addGeoLink = ({ utils }, range) =>
102 new Item({ 102 new Item({
103 text: 'Add GeoLink', 103 text: 'Add GeoLink',
104 onclick: () => { 104 onclick: () => {
105 const content = range.toString(); 105 const content = range.toString()
106 // FIXME Apply geolink only on matching sub-range 106 // FIXME Apply geolink only on matching sub-range
107 const match = content.match(/(^\D*[\d.]+)\D+([\d.]+)\D*$/); 107 const match = content.match(/(^\D*[\d.]+)\D+([\d.]+)\D*$/)
108 if (!match) return false; 108 if (!match) return false
109 109
110 const [x, y] = match.slice(1); 110 const [x, y] = match.slice(1)
111 const anchor = document.createElement('a'); 111 const anchor = document.createElement('a')
112 anchor.textContent = content; 112 anchor.textContent = content
113 // FIXME apply WGS84 113 // FIXME apply WGS84
114 anchor.href = `geo:${y},${x}?xy=${x},${y}`; 114 anchor.href = `geo:${y},${x}?xy=${x},${y}`
115 115
116 // FIXME 116 // FIXME
117 if (utils.createGeoLink(anchor)) { 117 if (utils.createGeoLink(anchor)) {
118 range.deleteContents(); 118 range.deleteContents()
119 range.insertNode(anchor); 119 range.insertNode(anchor)
120 } 120 }
121 }, 121 }
122 }); 122 })
123 123
124export class Suggestion { 124export class Suggestion {
125 constructor({ text, replace }) { 125 constructor ({ text, replace }) {
126 this.text = text; 126 this.text = text
127 this.replace = replace; 127 this.replace = replace
128 } 128 }
129 129
130 createElement(codemirror) { 130 createElement (codemirror) {
131 const option = document.createElement('div'); 131 const option = document.createElement('div')
132 if (this.text.startsWith('<')) { 132 if (this.text.startsWith('<')) {
133 option.innerHTML = this.text; 133 option.innerHTML = this.text
134 } else { 134 } else {
135 option.innerText = this.text; 135 option.innerText = this.text
136 } 136 }
137 option.classList.add('container__suggestion'); 137 option.classList.add('container__suggestion')
138 option.onmouseover = () => { 138 option.onmouseover = () => {
139 Array.from(option.parentElement?.children)?.forEach(s => 139 Array.from(option.parentElement?.children)?.forEach(s =>
140 s.classList.remove('focus'), 140 s.classList.remove('focus')
141 ); 141 )
142 option.classList.add('focus'); 142 option.classList.add('focus')
143 }; 143 }
144 option.onmouseout = () => { 144 option.onmouseout = () => {
145 option.classList.remove('focus'); 145 option.classList.remove('focus')
146 }; 146 }
147 option.onclick = () => { 147 option.onclick = () => {
148 const anchor = codemirror.getCursor(); 148 const anchor = codemirror.getCursor()
149 codemirror.setSelection(anchor, { ...anchor, ch: 0 }); 149 codemirror.setSelection(anchor, { ...anchor, ch: 0 })
150 codemirror.replaceSelection(this.replace); 150 codemirror.replaceSelection(this.replace)
151 codemirror.focus(); 151 codemirror.focus()
152 const newAnchor = { ...anchor, ch: this.replace.length }; 152 const newAnchor = { ...anchor, ch: this.replace.length }
153 codemirror.setCursor(newAnchor); 153 codemirror.setCursor(newAnchor)
154 }; 154 }
155 155
156 return option; 156 return option
157 } 157 }
158} 158}
159 159
@@ -161,100 +161,100 @@ export const renderResults = ({ modal, modalContent }, map) =>
161 new Item({ 161 new Item({
162 text: 'Render Results', 162 text: 'Render Results',
163 onclick: () => { 163 onclick: () => {
164 modal.open(); 164 modal.open()
165 modal.overlayBlur = 3; 165 modal.overlayBlur = 3
166 modal.closeByEscKey = false; 166 modal.closeByEscKey = false
167 // HACK find another way to override inline style 167 // HACK find another way to override inline style
168 document.querySelector('.plainmodal-overlay-force').style.position = 168 document.querySelector('.plainmodal-overlay-force').style.position =
169 'relative'; 169 'relative'
170 170
171 modalContent.innerHTML = ''; 171 modalContent.innerHTML = ''
172 const sourceCode = document.createElement('div') 172 const sourceCode = document.createElement('div')
173 sourceCode.innerHTML = `<a href="${map.renderer.url ?? map.renderer.use}">Source Code</a>` 173 sourceCode.innerHTML = `<a href="${map.renderer.url ?? map.renderer.use}">Source Code</a>`
174 modalContent.appendChild(sourceCode) 174 modalContent.appendChild(sourceCode)
175 const printDetails = result => { 175 const printDetails = result => {
176 const funcBody = result.func.toString(); 176 const funcBody = result.func.toString()
177 const loc = funcBody.split('\n').length; 177 const loc = funcBody.split('\n').length
178 const color = 178 const color =
179 { 179 {
180 success: 'green', 180 success: 'green',
181 fail: 'red', 181 fail: 'red',
182 skip: 'black', 182 skip: 'black',
183 stop: 'yellow', 183 stop: 'yellow'
184 }[result.state] ?? 'black'; 184 }[result.state] ?? 'black'
185 printObject( 185 printObject(
186 result, 186 result,
187 modalContent, 187 modalContent,
188 `${result.func.name} <span style='float: right;'>${loc}LOC\x20\x20\x20<span style='display: inline-block; width: 100px; color: ${color};'>${result.state}</span></span>`, 188 `${result.func.name} <span style='float: right;'>${loc}LOC\x20\x20\x20<span style='display: inline-block; width: 100px; color: ${color};'>${result.state}</span></span>`
189 ); 189 )
190 }; 190 }
191 191
192 // Add contents about prepare steps 192 // Add contents about prepare steps
193 const prepareHeading = document.createElement('h3'); 193 const prepareHeading = document.createElement('h3')
194 prepareHeading.textContent = 'Prepare Steps'; 194 prepareHeading.textContent = 'Prepare Steps'
195 modalContent.appendChild(prepareHeading); 195 modalContent.appendChild(prepareHeading)
196 const prepareSteps = map.renderer.results.filter( 196 const prepareSteps = map.renderer.results.filter(
197 r => r.type === 'prepare', 197 r => r.type === 'prepare'
198 ); 198 )
199 prepareSteps.forEach(printDetails); 199 prepareSteps.forEach(printDetails)
200 200
201 // Add contents about render steps 201 // Add contents about render steps
202 const renderHeading = document.createElement('h3'); 202 const renderHeading = document.createElement('h3')
203 renderHeading.textContent = 'Render Steps'; 203 renderHeading.textContent = 'Render Steps'
204 modalContent.appendChild(renderHeading); 204 modalContent.appendChild(renderHeading)
205 const renderSteps = map.renderer.results.filter(r => r.type === 'render'); 205 const renderSteps = map.renderer.results.filter(r => r.type === 'render')
206 renderSteps.forEach(printDetails); 206 renderSteps.forEach(printDetails)
207 }, 207 }
208 }); 208 })
209 209
210function printObject(obj, parentElement, name = null) { 210function printObject (obj, parentElement, name = null) {
211 // Create <details> and <summary> inside 211 // Create <details> and <summary> inside
212 const detailsEle = document.createElement('details'); 212 const detailsEle = document.createElement('details')
213 const details = name ?? (obj instanceof Error ? obj.name : Object.values(obj)[0]); 213 const details = name ?? (obj instanceof Error ? obj.name : Object.values(obj)[0])
214 detailsEle.innerHTML = `<summary>${details}</summary>`; 214 detailsEle.innerHTML = `<summary>${details}</summary>`
215 parentElement.appendChild(detailsEle); 215 parentElement.appendChild(detailsEle)
216 216
217 detailsEle.onclick = () => { 217 detailsEle.onclick = () => {
218 // Don't add items if it has contents 218 // Don't add items if it has contents
219 if (detailsEle.querySelector(':scope > :not(summary)')) return; 219 if (detailsEle.querySelector(':scope > :not(summary)')) return
220 220
221 if (obj instanceof Error) { 221 if (obj instanceof Error) {
222 // Handle Error objects specially 222 // Handle Error objects specially
223 const errorProps = ['name', 'message', 'stack', ...Object.keys(obj)]; 223 const errorProps = ['name', 'message', 'stack', ...Object.keys(obj)]
224 errorProps.forEach(key => { 224 errorProps.forEach(key => {
225 const value = obj[key]; 225 const value = obj[key]
226 const valueString = key === 'stack' ? `<pre>${value}</pre>` : value; 226 const valueString = key === 'stack' ? `<pre>${value}</pre>` : value
227 const propertyElement = document.createElement('p'); 227 const propertyElement = document.createElement('p')
228 propertyElement.innerHTML = `<strong>${key}</strong>: ${valueString}`; 228 propertyElement.innerHTML = `<strong>${key}</strong>: ${valueString}`
229 detailsEle.appendChild(propertyElement); 229 detailsEle.appendChild(propertyElement)
230 }); 230 })
231 } else { 231 } else {
232 // Handle regular objects 232 // Handle regular objects
233 Object.entries(obj).forEach(([key, value]) => { 233 Object.entries(obj).forEach(([key, value]) => {
234 if (typeof value === 'object' && value !== null) { 234 if (typeof value === 'object' && value !== null) {
235 printObject(value, detailsEle, key); 235 printObject(value, detailsEle, key)
236 } else { 236 } else {
237 const valueString = 237 const valueString =
238 typeof value === 'function' 238 typeof value === 'function'
239 ? `<pre>${value}</pre>` 239 ? `<pre>${value}</pre>`
240 : value ?? typeof value; 240 : value ?? typeof value
241 const propertyElement = document.createElement('p'); 241 const propertyElement = document.createElement('p')
242 propertyElement.innerHTML = `<strong>${key}</strong>: ${valueString}`; 242 propertyElement.innerHTML = `<strong>${key}</strong>: ${valueString}`
243 detailsEle.appendChild(propertyElement); 243 detailsEle.appendChild(propertyElement)
244 } 244 }
245 }); 245 })
246 } 246 }
247 }; 247 }
248} 248}
249 249
250export const toggleBlockFocus = block => 250export const toggleBlockFocus = block =>
251 new Item({ 251 new Item({
252 text: 'Toggle Focus', 252 text: 'Toggle Focus',
253 onclick: () => block.classList.toggle('focus'), 253 onclick: () => block.classList.toggle('focus')
254 }); 254 })
255 255
256export const toggleMapFocus = map => 256export const toggleMapFocus = map =>
257 new Item({ 257 new Item({
258 text: 'Toggle Focus', 258 text: 'Toggle Focus',
259 onclick: () => map.classList.toggle('focus'), 259 onclick: () => map.classList.toggle('focus')
260 }); 260 })
diff --git a/src/dumbyUtils.mjs b/src/dumbyUtils.mjs
index 2a9b787..7bf26c8 100644
--- a/src/dumbyUtils.mjs
+++ b/src/dumbyUtils.mjs
@@ -1,62 +1,62 @@
1import LeaderLine from 'leader-line'; 1import LeaderLine from 'leader-line'
2 2
3export function focusNextMap(reverse = false) { 3export function focusNextMap (reverse = false) {
4 const renderedList = this.utils.renderedMaps(); 4 const renderedList = this.utils.renderedMaps()
5 const index = renderedList.findIndex(e => e.classList.contains('focus')); 5 const index = renderedList.findIndex(e => e.classList.contains('focus'))
6 const nextIndex = 6 const nextIndex =
7 index === -1 ? 0 : (index + (reverse ? -1 : 1)) % renderedList.length; 7 index === -1 ? 0 : (index + (reverse ? -1 : 1)) % renderedList.length
8 8
9 const nextMap = renderedList.at(nextIndex); 9 const nextMap = renderedList.at(nextIndex)
10 nextMap.classList.add('focus'); 10 nextMap.classList.add('focus')
11} 11}
12 12
13export function focusNextBlock(reverse = false) { 13export function focusNextBlock (reverse = false) {
14 const blocks = this.blocks.filter(b => 14 const blocks = this.blocks.filter(b =>
15 b.checkVisibility({ 15 b.checkVisibility({
16 contentVisibilityAuto: true, 16 contentVisibilityAuto: true,
17 opacityProperty: true, 17 opacityProperty: true,
18 visibilityProperty: true, 18 visibilityProperty: true
19 }), 19 })
20 ); 20 )
21 const index = blocks.findIndex(e => e.classList.contains('focus')); 21 const index = blocks.findIndex(e => e.classList.contains('focus'))
22 const nextIndex = 22 const nextIndex =
23 index === -1 ? 0 : (index + (reverse ? -1 : 1)) % blocks.length; 23 index === -1 ? 0 : (index + (reverse ? -1 : 1)) % blocks.length
24 24
25 blocks.forEach(b => b.classList.remove('focus')); 25 blocks.forEach(b => b.classList.remove('focus'))
26 const nextBlock = blocks.at(nextIndex); 26 const nextBlock = blocks.at(nextIndex)
27 nextBlock?.classList?.add('focus'); 27 nextBlock?.classList?.add('focus')
28 scrollToBlock(nextBlock); 28 scrollToBlock(nextBlock)
29} 29}
30 30
31// Consider block is bigger then viewport height 31// Consider block is bigger then viewport height
32export const scrollToBlock = block => { 32export const scrollToBlock = block => {
33 const parentRect = block.parentElement.getBoundingClientRect(); 33 const parentRect = block.parentElement.getBoundingClientRect()
34 const scrollBlock = 34 const scrollBlock =
35 block.getBoundingClientRect().height > parentRect.height * 0.8 35 block.getBoundingClientRect().height > parentRect.height * 0.8
36 ? 'nearest' 36 ? 'nearest'
37 : 'center'; 37 : 'center'
38 block.scrollIntoView({ behavior: 'smooth', block: scrollBlock }); 38 block.scrollIntoView({ behavior: 'smooth', block: scrollBlock })
39}; 39}
40 40
41export function focusDelay() { 41export function focusDelay () {
42 return window.getComputedStyle(this.showcase).display === 'none' ? 50 : 300; 42 return window.window.getComputedStyle(this.showcase).display === 'none' ? 50 : 300
43} 43}
44 44
45export function switchToNextLayout(reverse = false) { 45export function switchToNextLayout (reverse = false) {
46 const layouts = this.layouts; 46 const layouts = this.layouts
47 const currentLayoutName = this.container.getAttribute('data-layout'); 47 const currentLayoutName = this.container.getAttribute('data-layout')
48 const currentIndex = layouts.map(l => l.name).indexOf(currentLayoutName); 48 const currentIndex = layouts.map(l => l.name).indexOf(currentLayoutName)
49 const padding = reverse ? -1 : 1; 49 const padding = reverse ? -1 : 1
50 const nextIndex = 50 const nextIndex =
51 currentIndex === -1 51 currentIndex === -1
52 ? 0 52 ? 0
53 : (currentIndex + padding + layouts.length) % layouts.length; 53 : (currentIndex + padding + layouts.length) % layouts.length
54 const nextLayout = layouts[nextIndex]; 54 const nextLayout = layouts[nextIndex]
55 this.container.setAttribute('data-layout', nextLayout.name); 55 this.container.setAttribute('data-layout', nextLayout.name)
56} 56}
57 57
58export function removeBlockFocus() { 58export function removeBlockFocus () {
59 this.blocks.forEach(b => b.classList.remove('focus')); 59 this.blocks.forEach(b => b.classList.remove('focus'))
60} 60}
61 61
62/** 62/**
@@ -66,31 +66,31 @@ export function removeBlockFocus() {
66 * @returns {Boolean} ture is link is created, false if coordinates are invalid 66 * @returns {Boolean} ture is link is created, false if coordinates are invalid
67 */ 67 */
68export const createGeoLink = (link, callback = null) => { 68export const createGeoLink = (link, callback = null) => {
69 const url = new URL(link.href); 69 const url = new URL(link.href)
70 const xyInParams = url.searchParams.get('xy'); 70 const xyInParams = url.searchParams.get('xy')
71 const xy = xyInParams 71 const xy = xyInParams
72 ? xyInParams.split(',')?.map(Number) 72 ? xyInParams.split(',')?.map(Number)
73 : url?.href 73 : url?.href
74 ?.match(/^geo:([0-9.,]+)/) 74 ?.match(/^geo:([0-9.,]+)/)
75 ?.at(1) 75 ?.at(1)
76 ?.split(',') 76 ?.split(',')
77 ?.reverse() 77 ?.reverse()
78 ?.map(Number); 78 ?.map(Number)
79 79
80 if (!xy || isNaN(xy[0]) || isNaN(xy[1])) return false; 80 if (!xy || isNaN(xy[0]) || isNaN(xy[1])) return false
81 81
82 // Geo information in link 82 // Geo information in link
83 link.url = url; 83 link.url = url
84 link.xy = xy; 84 link.xy = xy
85 link.classList.add('with-leader-line', 'geolink'); 85 link.classList.add('with-leader-line', 'geolink')
86 link.targets = link.url.searchParams.get('id')?.split(',') ?? null; 86 link.targets = link.url.searchParams.get('id')?.split(',') ?? null
87 87
88 // LeaderLine 88 // LeaderLine
89 link.lines = []; 89 link.lines = []
90 callback?.call(this, link); 90 callback?.call(this, link)
91 91
92 return true; 92 return true
93}; 93}
94 94
95/** 95/**
96 * CreateDocLink. 96 * CreateDocLink.
@@ -98,30 +98,30 @@ export const createGeoLink = (link, callback = null) => {
98 * @param {HTMLElement} Elements contains anchor elements for doclinks 98 * @param {HTMLElement} Elements contains anchor elements for doclinks
99 */ 99 */
100export const createDocLink = link => { 100export const createDocLink = link => {
101 link.classList.add('with-leader-line', 'doclink'); 101 link.classList.add('with-leader-line', 'doclink')
102 link.lines = []; 102 link.lines = []
103 103
104 link.onmouseover = () => { 104 link.onmouseover = () => {
105 const label = decodeURIComponent(link.href.split('#')[1]); 105 const label = decodeURIComponent(link.href.split('#')[1])
106 const selector = link.title.split('=>')[1] ?? '#' + label; 106 const selector = link.title.split('=>')[1] ?? '#' + label
107 const target = document.querySelector(selector); 107 const target = document.querySelector(selector)
108 if (!target?.checkVisibility()) return; 108 if (!target?.checkVisibility()) return
109 109
110 const line = new LeaderLine({ 110 const line = new LeaderLine({
111 start: link, 111 start: link,
112 end: target, 112 end: target,
113 middleLabel: LeaderLine.pathLabel({ 113 middleLabel: LeaderLine.pathLabel({
114 text: label, 114 text: label,
115 fontWeight: 'bold', 115 fontWeight: 'bold'
116 }), 116 }),
117 hide: true, 117 hide: true,
118 path: 'magnet', 118 path: 'magnet'
119 }); 119 })
120 link.lines.push(line); 120 link.lines.push(line)
121 line.show('draw', { duration: 300 }); 121 line.show('draw', { duration: 300 })
122 }; 122 }
123 link.onmouseout = () => { 123 link.onmouseout = () => {
124 link.lines.forEach(line => line.remove()); 124 link.lines.forEach(line => line.remove())
125 link.lines.length = 0; 125 link.lines.length = 0
126 }; 126 }
127}; 127}
diff --git a/src/dumbymap.mjs b/src/dumbymap.mjs
index 2efc350..fa88f3a 100644
--- a/src/dumbymap.mjs
+++ b/src/dumbymap.mjs
@@ -1,53 +1,53 @@
1import MarkdownIt from 'markdown-it'; 1import MarkdownIt from 'markdown-it'
2import MarkdownItAnchor from 'markdown-it-anchor'; 2import MarkdownItAnchor from 'markdown-it-anchor'
3import MarkdownItFootnote from 'markdown-it-footnote'; 3import MarkdownItFootnote from 'markdown-it-footnote'
4import MarkdownItFrontMatter from 'markdown-it-front-matter'; 4import MarkdownItFrontMatter from 'markdown-it-front-matter'
5import MarkdownItTocDoneRight from 'markdown-it-toc-done-right'; 5import MarkdownItTocDoneRight from 'markdown-it-toc-done-right'
6import LeaderLine from 'leader-line'; 6import LeaderLine from 'leader-line'
7import { renderWith, defaultAliases, parseConfigsFromYaml } from 'mapclay'; 7import { renderWith, defaultAliases, parseConfigsFromYaml } from 'mapclay'
8import { onRemove, animateRectTransition, throttle } from './utils'; 8import { onRemove, animateRectTransition, throttle } from './utils'
9import { Layout, SideBySide, Overlay } from './Layout'; 9import { Layout, SideBySide, Overlay } from './Layout'
10import * as utils from './dumbyUtils'; 10import * as utils from './dumbyUtils'
11import * as menuItem from './MenuItem'; 11import * as menuItem from './MenuItem'
12import { default as PlainModal } from 'plain-modal'; 12import PlainModal from 'plain-modal'
13 13
14const docLinkSelector = 'a[href^="#"][title^="=>"]'; 14const docLinkSelector = 'a[href^="#"][title^="=>"]'
15const geoLinkSelector = 'a[href^="geo:"]'; 15const geoLinkSelector = 'a[href^="geo:"]'
16 16
17const layouts = [ 17const layouts = [
18 new Layout({ name: 'normal' }), 18 new Layout({ name: 'normal' }),
19 new SideBySide({ name: 'side-by-side' }), 19 new SideBySide({ name: 'side-by-side' }),
20 new Overlay({ name: 'overlay' }), 20 new Overlay({ name: 'overlay' })
21]; 21]
22const mapCache = {}; 22const mapCache = {}
23 23
24export const markdown2HTML = (container, mdContent) => { 24export const markdown2HTML = (container, mdContent) => {
25 // Render: Markdown -> HTML {{{ 25 // Render: Markdown -> HTML {{{
26 container.replaceChildren(); 26 container.replaceChildren()
27 27
28 container.innerHTML = '<div class="SemanticHtml"></div>'; 28 container.innerHTML = '<div class="SemanticHtml"></div>'
29 const htmlHolder = container.querySelector('.SemanticHtml'); 29 const htmlHolder = container.querySelector('.SemanticHtml')
30 30
31 const md = MarkdownIt({ 31 const md = MarkdownIt({
32 html: true, 32 html: true,
33 breaks: true, 33 breaks: true
34 }) 34 })
35 .use(MarkdownItAnchor, { 35 .use(MarkdownItAnchor, {
36 permalink: MarkdownItAnchor.permalink.linkInsideHeader({ 36 permalink: MarkdownItAnchor.permalink.linkInsideHeader({
37 placement: 'before', 37 placement: 'before'
38 }), 38 })
39 }) 39 })
40 .use(MarkdownItFootnote) 40 .use(MarkdownItFootnote)
41 .use(MarkdownItFrontMatter) 41 .use(MarkdownItFrontMatter)
42 .use(MarkdownItTocDoneRight); 42 .use(MarkdownItTocDoneRight)
43 43
44 // FIXME A better way to generate blocks 44 // FIXME A better way to generate blocks
45 md.renderer.rules.dumby_block_open = () => '<div>'; 45 md.renderer.rules.dumby_block_open = () => '<div>'
46 md.renderer.rules.dumby_block_close = () => '</div>'; 46 md.renderer.rules.dumby_block_close = () => '</div>'
47 47
48 md.core.ruler.before('block', 'dumby_block', state => { 48 md.core.ruler.before('block', 'dumby_block', state => {
49 state.tokens.push(new state.Token('dumby_block_open', '', 1)); 49 state.tokens.push(new state.Token('dumby_block_open', '', 1))
50 }); 50 })
51 51
52 // Add close tag for block with more than 2 empty lines 52 // Add close tag for block with more than 2 empty lines
53 md.block.ruler.before('table', 'dumby_block', (state, startLine) => { 53 md.block.ruler.before('table', 'dumby_block', (state, startLine) => {
@@ -56,39 +56,39 @@ export const markdown2HTML = (container, mdContent) => {
56 state.src[state.bMarks[startLine - 2]] === '\n' && 56 state.src[state.bMarks[startLine - 2]] === '\n' &&
57 state.tokens.at(-1).type !== 'list_item_open' // Quick hack for not adding tag after "::marker" for <li> 57 state.tokens.at(-1).type !== 'list_item_open' // Quick hack for not adding tag after "::marker" for <li>
58 ) { 58 ) {
59 state.push('dumby_block_close', '', -1); 59 state.push('dumby_block_close', '', -1)
60 state.push('dumby_block_open', '', 1); 60 state.push('dumby_block_open', '', 1)
61 } 61 }
62 }); 62 })
63 63
64 md.core.ruler.after('block', 'dumby_block', state => { 64 md.core.ruler.after('block', 'dumby_block', state => {
65 state.tokens.push(new state.Token('dumby_block_close', '', -1)); 65 state.tokens.push(new state.Token('dumby_block_close', '', -1))
66 }); 66 })
67 67
68 const contentWithToc = '${toc}\n\n\n' + mdContent; 68 const contentWithToc = '${toc}\n\n\n' + mdContent // eslint-disable-line
69 htmlHolder.innerHTML = md.render(contentWithToc); 69 htmlHolder.innerHTML = md.render(contentWithToc)
70 70
71 // TODO Do this in markdown-it 71 // TODO Do this in markdown-it
72 const blocks = htmlHolder.querySelectorAll(':scope > div:not(:has(nav))'); 72 const blocks = htmlHolder.querySelectorAll(':scope > div:not(:has(nav))')
73 blocks.forEach(b => { 73 blocks.forEach(b => {
74 b.classList.add('dumby-block'); 74 b.classList.add('dumby-block')
75 b.setAttribute('data-total', blocks.length); 75 b.setAttribute('data-total', blocks.length)
76 }); 76 })
77 77
78 return container; 78 return container
79 //}}} 79 // }}}
80}; 80}
81export const generateMaps = (container, { delay, mapCallback }) => { 81export const generateMaps = (container, { delay, mapCallback }) => {
82 container.classList.add('Dumby'); 82 container.classList.add('Dumby')
83 const htmlHolder = container.querySelector('.SemanticHtml') ?? container; 83 const htmlHolder = container.querySelector('.SemanticHtml') ?? container
84 const blocks = Array.from(htmlHolder.querySelectorAll('.dumby-block')); 84 const blocks = Array.from(htmlHolder.querySelectorAll('.dumby-block'))
85 const showcase = document.createElement('div'); 85 const showcase = document.createElement('div')
86 container.appendChild(showcase); 86 container.appendChild(showcase)
87 showcase.classList.add('Showcase'); 87 showcase.classList.add('Showcase')
88 const renderPromises = []; 88 const renderPromises = []
89 const modalContent = document.createElement('div'); 89 const modalContent = document.createElement('div')
90 container.appendChild(modalContent); 90 container.appendChild(modalContent)
91 const modal = new PlainModal(modalContent); 91 const modal = new PlainModal(modalContent)
92 92
93 const dumbymap = { 93 const dumbymap = {
94 layouts, 94 layouts,
@@ -102,50 +102,50 @@ export const generateMaps = (container, { delay, mapCallback }) => {
102 ...utils, 102 ...utils,
103 renderedMaps: () => 103 renderedMaps: () =>
104 Array.from( 104 Array.from(
105 container.querySelectorAll('.mapclay[data-render=fulfilled]'), 105 container.querySelectorAll('.mapclay[data-render=fulfilled]')
106 ), 106 ),
107 focusNextMap: throttle(utils.focusNextMap, utils.focusDelay), 107 focusNextMap: throttle(utils.focusNextMap, utils.focusDelay),
108 switchToNextLayout: throttle(utils.switchToNextLayout, 300), 108 switchToNextLayout: throttle(utils.switchToNextLayout, 300)
109 }, 109 }
110 }; 110 }
111 Object.entries(dumbymap.utils).forEach(([util, func]) => { 111 Object.entries(dumbymap.utils).forEach(([util, func]) => {
112 dumbymap.utils[util] = func.bind(dumbymap); 112 dumbymap.utils[util] = func.bind(dumbymap)
113 }); 113 })
114 114
115 // LeaderLine {{{ 115 // LeaderLine {{{
116 116
117 Array.from(container.querySelectorAll(docLinkSelector)).filter( 117 Array.from(container.querySelectorAll(docLinkSelector)).filter(
118 utils.createDocLink, 118 utils.createDocLink
119 ); 119 )
120 120
121 // Get anchors with "geo:" scheme 121 // Get anchors with "geo:" scheme
122 htmlHolder.anchors = []; 122 htmlHolder.anchors = []
123 const geoLinkCallback = link => { 123 const geoLinkCallback = link => {
124 link.onmouseover = () => addLeaderLines(link); 124 link.onmouseover = () => addLeaderLines(link)
125 link.onmouseout = () => removeLeaderLines(link); 125 link.onmouseout = () => removeLeaderLines(link)
126 link.onclick = event => { 126 link.onclick = event => {
127 event.preventDefault(); 127 event.preventDefault()
128 htmlHolder.anchors 128 htmlHolder.anchors
129 .filter(isAnchorPointedBy(link)) 129 .filter(isAnchorPointedBy(link))
130 .forEach(updateMapByMarker(link.xy)); 130 .forEach(updateMapByMarker(link.xy))
131 // TODO Just hide leader line and show it again 131 // TODO Just hide leader line and show it again
132 removeLeaderLines(link); 132 removeLeaderLines(link)
133 }; 133 }
134 }; 134 }
135 const geoLinks = Array.from( 135 const geoLinks = Array.from(
136 container.querySelectorAll(geoLinkSelector), 136 container.querySelectorAll(geoLinkSelector)
137 ).filter(l => utils.createGeoLink(l, geoLinkCallback)); 137 ).filter(l => utils.createGeoLink(l, geoLinkCallback))
138 138
139 const isAnchorPointedBy = link => anchor => { 139 const isAnchorPointedBy = link => anchor => {
140 const mapContainer = anchor.closest('.mapclay'); 140 const mapContainer = anchor.closest('.mapclay')
141 const isTarget = !link.targets || link.targets.includes(mapContainer.id); 141 const isTarget = !link.targets || link.targets.includes(mapContainer.id)
142 return anchor.title === link.url.pathname && isTarget; 142 return anchor.title === link.url.pathname && isTarget
143 }; 143 }
144 144
145 const isAnchorVisible = anchor => { 145 const isAnchorVisible = anchor => {
146 const mapContainer = anchor.closest('.mapclay'); 146 const mapContainer = anchor.closest('.mapclay')
147 return insideWindow(anchor) && insideParent(anchor, mapContainer); 147 return insideWindow(anchor) && insideParent(anchor, mapContainer)
148 }; 148 }
149 149
150 const drawLeaderLine = link => anchor => { 150 const drawLeaderLine = link => anchor => {
151 const line = new LeaderLine({ 151 const line = new LeaderLine({
@@ -153,223 +153,222 @@ export const generateMaps = (container, { delay, mapCallback }) => {
153 end: anchor, 153 end: anchor,
154 hide: true, 154 hide: true,
155 middleLabel: link.url.searchParams.get('text'), 155 middleLabel: link.url.searchParams.get('text'),
156 path: 'magnet', 156 path: 'magnet'
157 }); 157 })
158 line.show('draw', { duration: 300 }); 158 line.show('draw', { duration: 300 })
159 return line; 159 return line
160 }; 160 }
161 161
162 const addLeaderLines = link => { 162 const addLeaderLines = link => {
163 link.lines = htmlHolder.anchors 163 link.lines = htmlHolder.anchors
164 .filter(isAnchorPointedBy(link)) 164 .filter(isAnchorPointedBy(link))
165 .filter(isAnchorVisible) 165 .filter(isAnchorVisible)
166 .map(drawLeaderLine(link)); 166 .map(drawLeaderLine(link))
167 }; 167 }
168 168
169 const removeLeaderLines = link => { 169 const removeLeaderLines = link => {
170 if (!link.lines) return; 170 if (!link.lines) return
171 link.lines.forEach(line => line.remove()); 171 link.lines.forEach(line => line.remove())
172 link.lines = []; 172 link.lines = []
173 }; 173 }
174 174
175 const updateMapByMarker = xy => marker => { 175 const updateMapByMarker = xy => marker => {
176 const renderer = marker.closest('.mapclay')?.renderer; 176 const renderer = marker.closest('.mapclay')?.renderer
177 renderer.updateCamera({ center: xy }, true); 177 renderer.updateCamera({ center: xy }, true)
178 }; 178 }
179 179
180 const insideWindow = element => { 180 const insideWindow = element => {
181 const rect = element.getBoundingClientRect(); 181 const rect = element.getBoundingClientRect()
182 return ( 182 return (
183 rect.left > 0 && 183 rect.left > 0 &&
184 rect.right < window.innerWidth + rect.width && 184 rect.right < window.innerWidth + rect.width &&
185 rect.top > 0 && 185 rect.top > 0 &&
186 rect.bottom < window.innerHeight + rect.height 186 rect.bottom < window.innerHeight + rect.height
187 ); 187 )
188 }; 188 }
189 189
190 const insideParent = (childElement, parentElement) => { 190 const insideParent = (childElement, parentElement) => {
191 const childRect = childElement.getBoundingClientRect(); 191 const childRect = childElement.getBoundingClientRect()
192 const parentRect = parentElement.getBoundingClientRect(); 192 const parentRect = parentElement.getBoundingClientRect()
193 const offset = 20; 193 const offset = 20
194 194
195 return ( 195 return (
196 childRect.left > parentRect.left + offset && 196 childRect.left > parentRect.left + offset &&
197 childRect.right < parentRect.right - offset && 197 childRect.right < parentRect.right - offset &&
198 childRect.top > parentRect.top + offset && 198 childRect.top > parentRect.top + offset &&
199 childRect.bottom < parentRect.bottom - offset 199 childRect.bottom < parentRect.bottom - offset
200 ); 200 )
201 }; 201 }
202 //}}} 202 // }}}
203 // CSS observer {{{ 203 // CSS observer {{{
204 // Focus Map {{{ 204 // Focus Map {{{
205 // Set focusArea 205 // Set focusArea
206 206
207 const mapFocusObserver = () => 207 const mapFocusObserver = () =>
208 new MutationObserver(mutations => { 208 new window.MutationObserver(mutations => {
209 const mutation = mutations.at(-1); 209 const mutation = mutations.at(-1)
210 const target = mutation.target; 210 const target = mutation.target
211 const focus = target.classList.contains('focus'); 211 const focus = target.classList.contains('focus')
212 const shouldBeInShowcase = 212 const shouldBeInShowcase =
213 focus && 213 focus &&
214 showcase.checkVisibility({ 214 showcase.checkVisibility({
215 contentVisibilityAuto: true, 215 contentVisibilityAuto: true,
216 opacityProperty: true, 216 opacityProperty: true,
217 visibilityProperty: true, 217 visibilityProperty: true
218 }); 218 })
219 219
220 if (focus) { 220 if (focus) {
221 dumbymap.utils 221 dumbymap.utils
222 .renderedMaps() 222 .renderedMaps()
223 .filter(map => map.id !== target.id) 223 .filter(map => map.id !== target.id)
224 .forEach(map => map.classList.remove('focus')); 224 .forEach(map => map.classList.remove('focus'))
225 } 225 }
226 226
227 if (shouldBeInShowcase) { 227 if (shouldBeInShowcase) {
228 if (showcase.contains(target)) return; 228 if (showcase.contains(target)) return
229 229
230 // Placeholder for map in Showcase, it should has the same DOMRect 230 // Placeholder for map in Showcase, it should has the same DOMRect
231 const placeholder = target.cloneNode(true); 231 const placeholder = target.cloneNode(true)
232 placeholder.removeAttribute('id'); 232 placeholder.removeAttribute('id')
233 placeholder.classList.remove('mapclay', 'focus'); 233 placeholder.classList.remove('mapclay', 'focus')
234 target.parentElement.replaceChild(placeholder, target); 234 target.parentElement.replaceChild(placeholder, target)
235 235
236 // FIXME Maybe use @start-style for CSS 236 // FIXME Maybe use @start-style for CSS
237 // Trigger CSS transition, if placeholde is the olny chil element in block, 237 // Trigger CSS transition, if placeholde is the olny chil element in block,
238 // reduce its height to zero. 238 // reduce its height to zero.
239 // To make sure the original height of placeholder is applied, DOM changes seems needed 239 // To make sure the original height of placeholder is applied, DOM changes seems needed
240 // then set data-attribute for CSS selector to change height to 0 240 // then set data-attribute for CSS selector to change height to 0
241 placeholder.getBoundingClientRect(); 241 placeholder.getBoundingClientRect()
242 placeholder.setAttribute('data-placeholder', target.id); 242 placeholder.setAttribute('data-placeholder', target.id)
243 243
244 // To fit showcase, remove all inline style 244 // To fit showcase, remove all inline style
245 target.removeAttribute('style'); 245 target.removeAttribute('style')
246 showcase.appendChild(target); 246 showcase.appendChild(target)
247 247
248 // Resume rect from Semantic HTML to Showcase, with animation 248 // Resume rect from Semantic HTML to Showcase, with animation
249 animateRectTransition(target, placeholder.getBoundingClientRect(), { 249 animateRectTransition(target, placeholder.getBoundingClientRect(), {
250 duration: 300, 250 duration: 300,
251 resume: true, 251 resume: true
252 }); 252 })
253 } else if (showcase.contains(target)) { 253 } else if (showcase.contains(target)) {
254 // Check placeholder is inside Semantic HTML 254 // Check placeholder is inside Semantic HTML
255 const placeholder = htmlHolder.querySelector( 255 const placeholder = htmlHolder.querySelector(
256 `[data-placeholder="${target.id}"]`, 256 `[data-placeholder="${target.id}"]`
257 ); 257 )
258 if (!placeholder) 258 if (!placeholder) { throw Error(`Cannot fine placeholder for map "${target.id}"`) }
259 throw Error(`Cannot fine placeholder for map "${target.id}"`);
260 259
261 // Consider animation may fail, write callback 260 // Consider animation may fail, write callback
262 const afterAnimation = () => { 261 const afterAnimation = () => {
263 placeholder.parentElement.replaceChild(target, placeholder); 262 placeholder.parentElement.replaceChild(target, placeholder)
264 target.style = placeholder.style.cssText; 263 target.style = placeholder.style.cssText
265 placeholder.remove(); 264 placeholder.remove()
266 }; 265 }
267 266
268 // animation from Showcase to placeholder 267 // animation from Showcase to placeholder
269 animateRectTransition(target, placeholder.getBoundingClientRect(), { 268 animateRectTransition(target, placeholder.getBoundingClientRect(), {
270 duration: 300, 269 duration: 300
271 }).finished.finally(afterAnimation); 270 }).finished.finally(afterAnimation)
272 } 271 }
273 }); 272 })
274 // }}} 273 // }}}
275 // Layout {{{ 274 // Layout {{{
276 // press key to switch layout 275 // press key to switch layout
277 276
278 // observe layout change 277 // observe layout change
279 const layoutObserver = new MutationObserver(mutations => { 278 const layoutObserver = new window.MutationObserver(mutations => {
280 const mutation = mutations.at(-1); 279 const mutation = mutations.at(-1)
281 const oldLayout = mutation.oldValue; 280 const oldLayout = mutation.oldValue
282 const newLayout = container.getAttribute(mutation.attributeName); 281 const newLayout = container.getAttribute(mutation.attributeName)
283 282
284 // Apply handler for leaving/entering layouts 283 // Apply handler for leaving/entering layouts
285 if (oldLayout) { 284 if (oldLayout) {
286 layouts 285 layouts
287 .find(l => l.name === oldLayout) 286 .find(l => l.name === oldLayout)
288 ?.leaveHandler?.call(this, dumbymap); 287 ?.leaveHandler?.call(this, dumbymap)
289 } 288 }
290 289
291 Object.values(dumbymap) 290 Object.values(dumbymap)
292 .flat() 291 .flat()
293 .filter(ele => ele instanceof HTMLElement) 292 .filter(ele => ele instanceof window.HTMLElement)
294 .forEach(ele => ele.removeAttribute('style')); 293 .forEach(ele => ele.removeAttribute('style'))
295 294
296 if (newLayout) { 295 if (newLayout) {
297 layouts 296 layouts
298 .find(l => l.name === newLayout) 297 .find(l => l.name === newLayout)
299 ?.enterHandler?.call(this, dumbymap); 298 ?.enterHandler?.call(this, dumbymap)
300 } 299 }
301 300
302 // Since layout change may show/hide showcase, the current focused map may need to go into/outside showcase 301 // Since layout change may show/hide showcase, the current focused map may need to go into/outside showcase
303 // Reset attribute triggers MutationObserver which is observing it 302 // Reset attribute triggers MutationObserver which is observing it
304 const focusMap = 303 const focusMap =
305 container.querySelector('.mapclay.focus') ?? 304 container.querySelector('.mapclay.focus') ??
306 container.querySelector('.mapclay'); 305 container.querySelector('.mapclay')
307 focusMap?.classList?.add('focus'); 306 focusMap?.classList?.add('focus')
308 }); 307 })
309 layoutObserver.observe(container, { 308 layoutObserver.observe(container, {
310 attributes: true, 309 attributes: true,
311 attributeFilter: ['data-layout'], 310 attributeFilter: ['data-layout'],
312 attributeOldValue: true, 311 attributeOldValue: true,
313 characterDataOldValue: true, 312 characterDataOldValue: true
314 }); 313 })
315 314
316 onRemove(htmlHolder, () => layoutObserver.disconnect()); 315 onRemove(htmlHolder, () => layoutObserver.disconnect())
317 //}}} 316 // }}}
318 //}}} 317 // }}}
319 // Render Maps {{{ 318 // Render Maps {{{
320 319
321 const afterMapRendered = renderer => { 320 const afterMapRendered = renderer => {
322 const mapElement = renderer.target; 321 const mapElement = renderer.target
323 //FIXME 322 // FIXME
324 mapElement.renderer = renderer; 323 mapElement.renderer = renderer
325 mapElement.setAttribute('tabindex', '-1'); 324 mapElement.setAttribute('tabindex', '-1')
326 if (mapElement.getAttribute('data-render') === 'fulfilled') { 325 if (mapElement.getAttribute('data-render') === 'fulfilled') {
327 mapCache[mapElement.id] = renderer; 326 mapCache[mapElement.id] = renderer
328 } 327 }
329 328
330 // Execute callback from caller 329 // Execute callback from caller
331 mapCallback?.call(this, mapElement); 330 mapCallback?.call(this, mapElement)
332 const markers = geoLinks 331 const markers = geoLinks
333 .filter(link => !link.targets || link.targets.includes(mapElement.id)) 332 .filter(link => !link.targets || link.targets.includes(mapElement.id))
334 .map(link => ({ xy: link.xy, title: link.url.pathname })); 333 .map(link => ({ xy: link.xy, title: link.url.pathname }))
335 334
336 // Add markers with Geolinks 335 // Add markers with Geolinks
337 renderer.addMarkers(markers); 336 renderer.addMarkers(markers)
338 mapElement 337 mapElement
339 .querySelectorAll('.marker') 338 .querySelectorAll('.marker')
340 .forEach(marker => htmlHolder.anchors.push(marker)); 339 .forEach(marker => htmlHolder.anchors.push(marker))
341 340
342 // Work with Mutation Observer 341 // Work with Mutation Observer
343 const observer = mapFocusObserver(); 342 const observer = mapFocusObserver()
344 mapFocusObserver().observe(mapElement, { 343 mapFocusObserver().observe(mapElement, {
345 attributes: true, 344 attributes: true,
346 attributeFilter: ['class'], 345 attributeFilter: ['class'],
347 attributeOldValue: true, 346 attributeOldValue: true
348 }); 347 })
349 onRemove(mapElement, () => observer.disconnect()); 348 onRemove(mapElement, () => observer.disconnect())
350 }; 349 }
351 350
352 // Set unique ID for map container 351 // Set unique ID for map container
353 const mapIdList = []; 352 const mapIdList = []
354 const assignMapId = config => { 353 const assignMapId = config => {
355 let mapId = config.id; 354 let mapId = config.id
356 if (!mapId) { 355 if (!mapId) {
357 mapId = config.use?.split('/')?.at(-1); 356 mapId = config.use?.split('/')?.at(-1)
358 let counter = 1; 357 let counter = 1
359 while (!mapId || mapIdList.includes(mapId)) { 358 while (!mapId || mapIdList.includes(mapId)) {
360 mapId = `${config.use ?? 'unnamed'}-${counter}`; 359 mapId = `${config.use ?? 'unnamed'}-${counter}`
361 counter++; 360 counter++
362 } 361 }
363 config.id = mapId; 362 config.id = mapId
364 } 363 }
365 mapIdList.push(mapId); 364 mapIdList.push(mapId)
366 return config; 365 return config
367 }; 366 }
368 367
369 // Render each code block with "language-map" class 368 // Render each code block with "language-map" class
370 const elementsWithMapConfig = Array.from( 369 const elementsWithMapConfig = Array.from(
371 container.querySelectorAll('pre:has(.language-map)') ?? [], 370 container.querySelectorAll('pre:has(.language-map)') ?? []
372 ); 371 )
373 /** 372 /**
374 * updateAttributeByStep. 373 * updateAttributeByStep.
375 * 374 *
@@ -377,21 +376,21 @@ export const generateMaps = (container, { delay, mapCallback }) => {
377 */ 376 */
378 const updateAttributeByStep = ({ results, target, steps }) => { 377 const updateAttributeByStep = ({ results, target, steps }) => {
379 let passNum = results.filter( 378 let passNum = results.filter(
380 r => r.type === 'render' && r.state.match(/success|skip/), 379 r => r.type === 'render' && r.state.match(/success|skip/)
381 ).length; 380 ).length
382 const total = steps.length; 381 const total = steps.length
383 passNum += `/${total}`; 382 passNum += `/${total}`
384 if (results.filter(r => r.type === 'render').length === total) { 383 if (results.filter(r => r.type === 'render').length === total) {
385 passNum += '\u0020'; 384 passNum += '\u0020'
386 } 385 }
387 386
388 // FIXME HACK use MutationObserver for animation 387 // FIXME HACK use MutationObserver for animation
389 if (!target.animations) target.animations = Promise.resolve(); 388 if (!target.animations) target.animations = Promise.resolve()
390 target.animations = target.animations.then(async () => { 389 target.animations = target.animations.then(async () => {
391 await new Promise(resolve => setTimeout(resolve, 100)); 390 await new Promise(resolve => setTimeout(resolve, 100))
392 target.setAttribute('data-report', passNum); 391 target.setAttribute('data-report', passNum)
393 }); 392 })
394 }; 393 }
395 /** 394 /**
396 * config converter for mapclay.renderWith() 395 * config converter for mapclay.renderWith()
397 * 396 *
@@ -404,42 +403,42 @@ export const generateMaps = (container, { delay, mapCallback }) => {
404 ...config, 403 ...config,
405 aliases: { 404 aliases: {
406 ...defaultAliases, 405 ...defaultAliases,
407 ...(config.aliases ?? {}), 406 ...(config.aliases ?? {})
408 }, 407 },
409 stepCallback: updateAttributeByStep, 408 stepCallback: updateAttributeByStep
410 }); 409 })
411 const render = renderWith(configConverter); 410 const render = renderWith(configConverter)
412 elementsWithMapConfig.forEach(target => { 411 elementsWithMapConfig.forEach(target => {
413 // Get text in code block starts with markdown text '```map' 412 // Get text in code block starts with markdown text '```map'
414 const configText = target 413 const configText = target
415 .querySelector('.language-map') 414 .querySelector('.language-map')
416 .textContent // BE CAREFUL!!! 0xa0 char is "non-breaking spaces" in HTML text content 415 .textContent // BE CAREFUL!!! 0xa0 char is "non-breaking spaces" in HTML text content
417 // replace it by normal space 416 // replace it by normal space
418 .replace(/\u00A0/g, '\u0020'); 417 .replace(/\u00A0/g, '\u0020')
419 418
420 let configList = []; 419 let configList = []
421 try { 420 try {
422 configList = parseConfigsFromYaml(configText).map(assignMapId); 421 configList = parseConfigsFromYaml(configText).map(assignMapId)
423 } catch (_) { 422 } catch (_) {
424 console.warn('Fail to parse yaml config for element', target); 423 console.warn('Fail to parse yaml config for element', target)
425 return; 424 return
426 } 425 }
427 426
428 // If map in cache has the same ID, just put it into target 427 // If map in cache has the same ID, just put it into target
429 // So user won't feel anything changes when editing markdown 428 // So user won't feel anything changes when editing markdown
430 configList.forEach(config => { 429 configList.forEach(config => {
431 const cache = mapCache[config.id]; 430 const cache = mapCache[config.id]
432 if (!cache) return; 431 if (!cache) return
433 432
434 target.appendChild(cache.target); 433 target.appendChild(cache.target)
435 config.target = cache.target; 434 config.target = cache.target
436 }); 435 })
437 436
438 // trivial: if map cache is applied, do not show yaml text 437 // trivial: if map cache is applied, do not show yaml text
439 if (target.querySelector('.mapclay')) { 438 if (target.querySelector('.mapclay')) {
440 target 439 target
441 .querySelectorAll(':scope > :not([data-render=fulfilled])') 440 .querySelectorAll(':scope > :not([data-render=fulfilled])')
442 .forEach(e => e.remove()); 441 .forEach(e => e.remove())
443 } 442 }
444 443
445 // TODO Use debounce of user input to decide rendering timing 444 // TODO Use debounce of user input to decide rendering timing
@@ -447,76 +446,76 @@ export const generateMaps = (container, { delay, mapCallback }) => {
447 const timer = setTimeout( 446 const timer = setTimeout(
448 () => 447 () =>
449 render(target, configList).forEach(renderPromise => { 448 render(target, configList).forEach(renderPromise => {
450 renderPromises.push(renderPromise); 449 renderPromises.push(renderPromise)
451 renderPromise.then(afterMapRendered); 450 renderPromise.then(afterMapRendered)
452 }), 451 }),
453 delay ?? 1000, 452 delay ?? 1000
454 ); 453 )
455 onRemove(htmlHolder, () => { 454 onRemove(htmlHolder, () => {
456 clearTimeout(timer); 455 clearTimeout(timer)
457 }); 456 })
458 }); 457 })
459 // }}} 458 // }}}
460 // Menu {{{ 459 // Menu {{{
461 const menu = document.createElement('div'); 460 const menu = document.createElement('div')
462 menu.className = 'menu'; 461 menu.className = 'menu'
463 menu.style.display = 'none'; 462 menu.style.display = 'none'
464 menu.onclick = () => (menu.style.display = 'none'); 463 menu.onclick = () => (menu.style.display = 'none')
465 container.appendChild(menu); 464 container.appendChild(menu)
466 465
467 // Menu Items 466 // Menu Items
468 container.oncontextmenu = e => { 467 container.oncontextmenu = e => {
469 menu.replaceChildren(); 468 menu.replaceChildren()
470 menu.style.display = 'block'; 469 menu.style.display = 'block'
471 menu.style.cssText = `left: ${e.x - menu.offsetParent.offsetLeft + 10}px; top: ${e.y - menu.offsetParent.offsetTop + 5}px;`; 470 menu.style.cssText = `left: ${e.x - menu.offsetParent.offsetLeft + 10}px; top: ${e.y - menu.offsetParent.offsetTop + 5}px;`
472 e.preventDefault(); 471 e.preventDefault()
473 472
474 // GeoLinks 473 // GeoLinks
475 const selection = document.getSelection(); 474 const selection = document.getSelection()
476 if (selection.type === 'Range') { 475 if (selection.type === 'Range') {
477 const range = selection.getRangeAt(0); 476 const range = selection.getRangeAt(0)
478 menu.appendChild(menuItem.addGeoLink(dumbymap, range)); 477 menu.appendChild(menuItem.addGeoLink(dumbymap, range))
479 } 478 }
480 479
481 // Menu Items for map 480 // Menu Items for map
482 const map = e.target.closest('.mapclay'); 481 const map = e.target.closest('.mapclay')
483 if (map?.renderer?.results) { 482 if (map?.renderer?.results) {
484 // Focus or Print Map Results 483 // Focus or Print Map Results
485 menu.appendChild(menuItem.toggleMapFocus(map)); 484 menu.appendChild(menuItem.toggleMapFocus(map))
486 menu.appendChild(menuItem.renderResults(dumbymap, map)); 485 menu.appendChild(menuItem.renderResults(dumbymap, map))
487 } else { 486 } else {
488 // Toggle block focus 487 // Toggle block focus
489 const block = e.target.closest('.dumby-block'); 488 const block = e.target.closest('.dumby-block')
490 if (block) { 489 if (block) {
491 menu.appendChild(menuItem.toggleBlockFocus(block)); 490 menu.appendChild(menuItem.toggleBlockFocus(block))
492 } 491 }
493 } 492 }
494 493
495 // Menu Items for map/block/layout 494 // Menu Items for map/block/layout
496 if (!map || map.closest('.Showcase')) { 495 if (!map || map.closest('.Showcase')) {
497 menu.appendChild(menuItem.pickMapItem(dumbymap)); 496 menu.appendChild(menuItem.pickMapItem(dumbymap))
498 menu.appendChild(menuItem.pickBlockItem(dumbymap)); 497 menu.appendChild(menuItem.pickBlockItem(dumbymap))
499 menu.appendChild(menuItem.pickLayoutItem(dumbymap)); 498 menu.appendChild(menuItem.pickLayoutItem(dumbymap))
500 } 499 }
501 }; 500 }
502 501
503 // Remove menu when click outside 502 // Remove menu when click outside
504 const actionOutsideMenu = e => { 503 const actionOutsideMenu = e => {
505 if (menu.style.display === 'none') return; 504 if (menu.style.display === 'none') return
506 const rect = menu.getBoundingClientRect(); 505 const rect = menu.getBoundingClientRect()
507 if ( 506 if (
508 e.clientX < rect.left || 507 e.clientX < rect.left ||
509 e.clientX > rect.left + rect.width || 508 e.clientX > rect.left + rect.width ||
510 e.clientY < rect.top || 509 e.clientY < rect.top ||
511 e.clientY > rect.top + rect.height 510 e.clientY > rect.top + rect.height
512 ) { 511 ) {
513 menu.style.display = 'none'; 512 menu.style.display = 'none'
514 } 513 }
515 }; 514 }
516 document.addEventListener('click', actionOutsideMenu); 515 document.addEventListener('click', actionOutsideMenu)
517 onRemove(htmlHolder, () => 516 onRemove(htmlHolder, () =>
518 document.removeEventListener('click', actionOutsideMenu), 517 document.removeEventListener('click', actionOutsideMenu)
519 ); 518 )
520 //}}} 519 // }}}
521 return Object.seal(dumbymap); 520 return Object.seal(dumbymap)
522}; 521}
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={{{,}}}
diff --git a/src/utils.mjs b/src/utils.mjs
index 3824672..d9ed2d7 100644
--- a/src/utils.mjs
+++ b/src/utils.mjs
@@ -5,21 +5,21 @@
5 * @param {Function} callback 5 * @param {Function} callback
6 */ 6 */
7export const onRemove = (element, callback) => { 7export const onRemove = (element, callback) => {
8 const parent = element.parentNode; 8 const parent = element.parentNode
9 if (!parent) throw new Error('The node must already be attached'); 9 if (!parent) throw new Error('The node must already be attached')
10 10
11 const obs = new MutationObserver(mutations => { 11 const obs = new window.MutationObserver(mutations => {
12 for (const mutation of mutations) { 12 for (const mutation of mutations) {
13 for (const el of mutation.removedNodes) { 13 for (const el of mutation.removedNodes) {
14 if (el === element) { 14 if (el === element) {
15 obs.disconnect(); 15 obs.disconnect()
16 callback(); 16 callback()
17 } 17 }
18 } 18 }
19 } 19 }
20 }); 20 })
21 obs.observe(parent, { childList: true }); 21 obs.observe(parent, { childList: true })
22}; 22}
23 23
24/** 24/**
25 * Animate transition of DOMRect, with Web Animation API 25 * Animate transition of DOMRect, with Web Animation API
@@ -32,39 +32,38 @@ export const onRemove = (element, callback) => {
32 * @returns {Animation} https://developer.mozilla.org/en-US/docs/Web/API/Animation 32 * @returns {Animation} https://developer.mozilla.org/en-US/docs/Web/API/Animation
33 */ 33 */
34export const animateRectTransition = (element, rect, options = {}) => { 34export const animateRectTransition = (element, rect, options = {}) => {
35 if (!element.parentElement) 35 if (!element.parentElement) { throw new Error('The node must already be attached') }
36 throw new Error('The node must already be attached');
37 36
38 const { width: w1, height: h1, left: x1, top: y1 } = rect; 37 const { width: w1, height: h1, left: x1, top: y1 } = rect
39 const { 38 const {
40 width: w2, 39 width: w2,
41 height: h2, 40 height: h2,
42 left: x2, 41 left: x2,
43 top: y2, 42 top: y2
44 } = element.getBoundingClientRect(); 43 } = element.getBoundingClientRect()
45 44
46 const rw = (w1 ?? w2) / w2; 45 const rw = (w1 ?? w2) / w2
47 const rh = (h1 ?? h2) / h2; 46 const rh = (h1 ?? h2) / h2
48 const dx = x1 - x2; 47 const dx = x1 - x2
49 const dy = y1 - y2; 48 const dy = y1 - y2
50 49
51 if ((dx === 0 && dy === 0) || !isFinite(rw) || !isFinite(rh)) { 50 if ((dx === 0 && dy === 0) || !isFinite(rw) || !isFinite(rh)) {
52 return element.animate([], { duration: 0 }); 51 return element.animate([], { duration: 0 })
53 } 52 }
54 53
55 const transform1 = `translate(0, 0) scale(1, 1)`; 54 const transform1 = 'translate(0, 0) scale(1, 1)'
56 const transform2 = `translate(${dx}px, ${dy}px) scale(${rw}, ${rh})`; 55 const transform2 = `translate(${dx}px, ${dy}px) scale(${rw}, ${rh})`
57 const keyframes = [ 56 const keyframes = [
58 { transform: transform1, opacity: 1 }, 57 { transform: transform1, opacity: 1 },
59 { transform: transform2, opacity: 0.3 }, 58 { transform: transform2, opacity: 0.3 }
60 ]; 59 ]
61 if (options.resume === true) keyframes.reverse(); 60 if (options.resume === true) keyframes.reverse()
62 61
63 return element.animate(keyframes, { 62 return element.animate(keyframes, {
64 duration: options.duration ?? 500, 63 duration: options.duration ?? 500,
65 easing: 'ease-in-out', 64 easing: 'ease-in-out'
66 }); 65 })
67}; 66}
68 67
69/** 68/**
70 * Throttle for function call 69 * Throttle for function call
@@ -73,18 +72,18 @@ export const animateRectTransition = (element, rect, options = {}) => {
73 * @param {Number} delay milliseconds 72 * @param {Number} delay milliseconds
74 * @returns {Any} return value of function call, or null if throttled 73 * @returns {Any} return value of function call, or null if throttled
75 */ 74 */
76export function throttle(func, delay) { 75export function throttle (func, delay) {
77 let timerFlag = null; 76 let timerFlag = null
78 77
79 return function (...args) { 78 return function (...args) {
80 const context = this; 79 const context = this
81 if (timerFlag !== null) return null; 80 if (timerFlag !== null) return null
82 81
83 timerFlag = setTimeout( 82 timerFlag = setTimeout(
84 () => (timerFlag = null), 83 () => (timerFlag = null),
85 typeof delay === 'function' ? delay.call(context) : delay, 84 typeof delay === 'function' ? delay.call(context) : delay
86 ); 85 )
87 86
88 return func.call(context, ...args); 87 return func.call(context, ...args)
89 }; 88 }
90} 89}