diff options
Diffstat (limited to 'src/dumbymap.mjs')
-rw-r--r-- | src/dumbymap.mjs | 469 |
1 files changed, 234 insertions, 235 deletions
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 @@ | |||
1 | import MarkdownIt from 'markdown-it'; | 1 | import MarkdownIt from 'markdown-it' |
2 | import MarkdownItAnchor from 'markdown-it-anchor'; | 2 | import MarkdownItAnchor from 'markdown-it-anchor' |
3 | import MarkdownItFootnote from 'markdown-it-footnote'; | 3 | import MarkdownItFootnote from 'markdown-it-footnote' |
4 | import MarkdownItFrontMatter from 'markdown-it-front-matter'; | 4 | import MarkdownItFrontMatter from 'markdown-it-front-matter' |
5 | import MarkdownItTocDoneRight from 'markdown-it-toc-done-right'; | 5 | import MarkdownItTocDoneRight from 'markdown-it-toc-done-right' |
6 | import LeaderLine from 'leader-line'; | 6 | import LeaderLine from 'leader-line' |
7 | import { renderWith, defaultAliases, parseConfigsFromYaml } from 'mapclay'; | 7 | import { renderWith, defaultAliases, parseConfigsFromYaml } from 'mapclay' |
8 | import { onRemove, animateRectTransition, throttle } from './utils'; | 8 | import { onRemove, animateRectTransition, throttle } from './utils' |
9 | import { Layout, SideBySide, Overlay } from './Layout'; | 9 | import { Layout, SideBySide, Overlay } from './Layout' |
10 | import * as utils from './dumbyUtils'; | 10 | import * as utils from './dumbyUtils' |
11 | import * as menuItem from './MenuItem'; | 11 | import * as menuItem from './MenuItem' |
12 | import { default as PlainModal } from 'plain-modal'; | 12 | import PlainModal from 'plain-modal' |
13 | 13 | ||
14 | const docLinkSelector = 'a[href^="#"][title^="=>"]'; | 14 | const docLinkSelector = 'a[href^="#"][title^="=>"]' |
15 | const geoLinkSelector = 'a[href^="geo:"]'; | 15 | const geoLinkSelector = 'a[href^="geo:"]' |
16 | 16 | ||
17 | const layouts = [ | 17 | const 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 | ] |
22 | const mapCache = {}; | 22 | const mapCache = {} |
23 | 23 | ||
24 | export const markdown2HTML = (container, mdContent) => { | 24 | export 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 | } |
81 | export const generateMaps = (container, { delay, mapCallback }) => { | 81 | export 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 | } |