From 15a939d234910016d36d4297ec14de51c96168ce Mon Sep 17 00:00:00 2001 From: Hsieh Chin Fan Date: Tue, 13 Aug 2024 23:58:38 +0800 Subject: Initial Commit --- src/dumbymap.mjs | 357 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 357 insertions(+) create mode 100644 src/dumbymap.mjs (limited to 'src/dumbymap.mjs') diff --git a/src/dumbymap.mjs b/src/dumbymap.mjs new file mode 100644 index 0000000..1cc0f07 --- /dev/null +++ b/src/dumbymap.mjs @@ -0,0 +1,357 @@ +// vim:foldmethod +import MarkdownIt from 'markdown-it' +import MarkdownItAnchor from 'markdown-it-anchor' +import MarkdownItFootnote from 'markdown-it-footnote' +import MarkdownItFrontMatter from 'markdown-it-front-matter' +import MarkdownItTocDoneRight from 'markdown-it-toc-done-right' +import LeaderLine from 'leader-line' +import PlainDraggable from 'plain-draggable' +import { render, parseConfigsFromText } from 'mapclay' + +const observers = new Map() + +export const markdown2HTML = async (container, mdContent) => { + // Render: Markdown -> HTML {{{ + + container.innerHTML = ` +
+
+ ` + + const md = MarkdownIt({ html: true }) + .use(MarkdownItAnchor, { + permalink: MarkdownItAnchor.permalink.linkInsideHeader({ placement: 'before' }) + }) + .use(MarkdownItFootnote) + .use(MarkdownItFrontMatter) + .use(MarkdownItTocDoneRight) + + // FIXME A better way to generate draggable code block + md.renderer.rules.draggable_block_open = () => '
' + md.renderer.rules.draggable_block_close = () => '
' + + md.core.ruler.before('block', 'draggable_block', (state) => { + state.tokens.push(new state.Token('draggable_block_open', '', 1)) + }) + + // Add close tag for block with more than 2 empty lines + md.block.ruler.before('table', 'draggable_block', (state, startLine) => { + if (state.src[state.bMarks[startLine - 1]] === '\n' && state.src[state.bMarks[startLine - 2]] === '\n') { + state.push('draggable_block_close', '', -1); + state.push('draggable_block_open', '', 1); + } + }) + + md.core.ruler.after('block', 'draggable_block', (state) => { + state.tokens.push(new state.Token('draggable_block_close', '', -1)) + }) + + const markdown = container.querySelector('#markdown') + const contentWithToc = '${toc}\n\n\n' + mdContent + markdown.innerHTML = md.render(contentWithToc); + markdown.querySelectorAll('*> div:not(:has(nav))') + .forEach(b => b.classList.add('draggable-block')) + + + // TODO Improve it! + const docLinks = Array.from(container.querySelectorAll('#markdown a[href^="#"][title^="doc"]')) + docLinks.forEach(link => { + link.classList.add('with-leader-line', 'doclink') + link.lines = [] + + link.onmouseover = () => { + const target = document.querySelector(link.getAttribute('href')) + if (!target?.checkVisibility()) return + + const line = new LeaderLine({ + start: link, + end: target, + hide: true, + path: "magnet" + }) + link.lines.push(line) + line.show('draw', { duration: 300, }) + } + link.onmouseout = () => { + link.lines.forEach(line => line.remove()) + link.lines.length = 0 + } + }) + //}}} +} + +export const generateMaps = async (container) => { + // LeaderLine {{{ + + // Get anchors with "geo:" scheme + const markdown = container.querySelector('#markdown') + markdown.anchors = [] + + // Set focusArea + const focusArea = container.querySelector('#map') + const mapPlaceholder = document.createElement('div') + mapPlaceholder.id = 'mapPlaceholder' + focusArea.appendChild(mapPlaceholder) + + // Links points to map by geo schema and id + const geoLinks = Array.from(container.querySelectorAll('#markdown a[href^="geo:"]')) + .filter(link => { + const url = new URL(link.href) + const xy = url?.href?.match(/^geo:([0-9.,]+)/)?.at(1)?.split(',')?.reverse()?.map(Number) + + if (!xy || isNaN(xy[0]) || isNaN(xy[1])) return false + + // Geo information in link + link.url = url + link.xy = xy + link.classList.add('with-leader-line', 'geolink') + link.targets = link.url.searchParams.get('id')?.split(',') ?? null + + // LeaderLine + link.lines = [] + link.onmouseover = () => addLeaderLines(link) + link.onmouseout = () => removeLeaderLines(link) + link.onclick = (event) => { + event.preventDefault() + markdown.anchors + .filter(isAnchorPointedBy(link)) + .forEach(updateMapByMarker(xy)) + // TODO Just hide leader line and show it again + removeLeaderLines(link) + } + + return true + }) + + const isAnchorPointedBy = (link) => (anchor) => { + const mapContainer = anchor.closest('.map-container') + const isTarget = !link.targets || link.targets.includes(mapContainer.id) + return anchor.title === link.url.pathname && isTarget + } + + const isAnchorVisible = (anchor) => { + const mapContainer = anchor.closest('.map-container') + return insideWindow(anchor) && insideParent(anchor, mapContainer) + } + + const drawLeaderLine = (link) => (anchor) => { + const line = new LeaderLine({ + start: link, + end: anchor, + hide: true, + middleLabel: link.url.searchParams.get('text'), + path: "magnet", + }) + line.show('draw', { duration: 300, }) + return line + } + + const addLeaderLines = (link) => { + link.lines = markdown.anchors + .filter(isAnchorPointedBy(link)) + .filter(isAnchorVisible) + .map(drawLeaderLine(link)) + } + + const removeLeaderLines = (link) => { + if (!link.lines) return + link.lines.forEach(line => line.remove()) + link.lines = [] + } + + const updateMapByMarker = (xy) => (marker) => { + const renderer = marker.closest('.map-container')?.renderer + renderer.updateCamera({ center: xy }, true) + } + + const insideWindow = (element) => { + const rect = element.getBoundingClientRect() + return rect.left > 0 && + rect.right < window.innerWidth + rect.width && + rect.top > 0 && + rect.bottom < window.innerHeight + rect.height + } + + const insideParent = (childElement, parentElement) => { + const childRect = childElement.getBoundingClientRect(); + const parentRect = parentElement.getBoundingClientRect(); + const offset = 20 + + return childRect.left > parentRect.left + offset && + childRect.right < parentRect.right - offset && + childRect.top > parentRect.top + offset && + childRect.bottom < parentRect.bottom - offset + } + //}}} + // Render Maps {{{ + + const afterEachMapLoaded = (mapContainer) => { + mapContainer.querySelectorAll('.marker') + .forEach(marker => markdown.anchors.push(marker)) + + const focusClickedMap = () => { + if (container.getAttribute('data-layout') !== 'none') return + + container.querySelectorAll('.map-container') + .forEach(c => c.classList.remove('focus')) + mapContainer.classList.add('focus') + } + mapContainer.onclick = focusClickedMap + } + + // Set unique ID for map container + const mapIdList = [] + const assignMapId = (config) => { + let mapId = config.id + if (!mapId) { + mapId = config.use?.split('/')?.at(-1) + let counter = 2 + while (mapIdList.includes(mapId)) { + mapId = `${config.use}.${counter}` + counter++ + } + config.id = mapId + } + mapIdList.push(mapId) + } + + const markerOptions = geoLinks.map(link => { + return { + targets: link.targets, + xy: link.xy, + title: link.url.pathname + } + }) + + + // Render each code block with "language-map" class + const renderTargets = Array.from(container.querySelectorAll('pre:has(.language-map)')) + const renderAllTargets = renderTargets.map(async (target) => { + // Get text in code block starts with '```map' + // BE CAREFUL!!! 0xa0 char is "non-breaking spaces" in HTML text content + // replace it by normal space + const configText = target.querySelector('.language-map').textContent.replace(/\u00A0/g, '\u0020') + + let configList = [] + try { + configList = parseConfigsFromText(configText).map(result => { + assignMapId(result) + const markersFromLinks = markerOptions.filter(marker => + !marker.targets || marker.targets.includes(result.id) + ) + Object.assign(result, { markers: markersFromLinks }) + return result + }) + } catch (err) { + console.error('Fail to parse yaml config for element', target, err) + } + + // Render maps + return render(target, configList) + .then(results => { + results.forEach((mapByConfig) => { + if (mapByConfig.status === 'fulfilled') { + afterEachMapLoaded(mapByConfig.value) + return mapByConfig.value + } else { + console.error('Fail to render target element', mapByConfig.value) + } + }) + }) + }) + const renderInfo = await Promise.all(renderAllTargets).then(() => 'Finish Rendering') + console.info(renderInfo) + + //}}} + // CSS observer {{{ + if (!observers.get(container)) { + observers.set(container, []) + } + const obs = observers.get(container) + if (obs.length) { + obs.forEach(o => o.disconnect()) + obs.length = 0 + } + // Layout{{{ + + // press key to switch layout + const layouts = ['none', 'side', 'overlay'] + container.setAttribute("data-layout", layouts[0]) + document.onkeydown = (event) => { + if (event.key === 'x' && container.querySelector('.map-container')) { + let currentLayout = container.getAttribute('data-layout') + currentLayout = currentLayout ? currentLayout : 'none' + const nextLayout = layouts[(layouts.indexOf(currentLayout) + 1) % layouts.length] + + container.setAttribute("data-layout", nextLayout) + } + } + + // Add draggable part for blocks + markdown.blocks = Array.from(markdown.querySelectorAll('.draggable-block')) + markdown.blocks.forEach(block => { + const draggablePart = document.createElement('div'); + draggablePart.classList.add('draggable') + draggablePart.textContent = '☰' + + // TODO Better way to close block + draggablePart.onmouseup = (e) => { + if (e.button === 1) block.style.display = "none"; + } + block.insertBefore(draggablePart, block.firstChild) + }) + + // observe layout change + const layoutObserver = new MutationObserver(() => { + const layout = container.getAttribute('data-layout') + markdown.blocks.forEach(b => b.style.display = "block") + + if (layout === 'none') { + mapPlaceholder.innerHTML = "" + const map = focusArea.querySelector('.map-container') + // Swap focused map and palceholder in markdown + if (map) { + mapPlaceholder.parentElement?.replaceChild(map, mapPlaceholder) + focusArea.append(mapPlaceholder) + } + } else { + // If paceholder is not set, create one and put map into focusArea + if (focusArea.contains(mapPlaceholder)) { + const mapContainer = container.querySelector('.map-container.focus') ?? container.querySelector('.map-container') + mapPlaceholder.innerHTML = `
Placeholder
` + // TODO + // mapPlaceholder.src = map.map.getCanvas().toDataURL() + mapContainer.parentElement?.replaceChild(mapPlaceholder, mapContainer) + focusArea.appendChild(mapContainer) + } + } + + if (layout === 'overlay') { + markdown.blocks.forEach(block => { + block.draggableInstance = new PlainDraggable(block, { handle: block.querySelector('.draggable') }) + block.draggableInstance.snap = { x: { step: 20 }, y: { step: 20 } } + // block.draggableInstance.onDragEnd = () => { + // links(block).forEach(link => link.line.position()) + // } + }) + } else { + markdown.blocks.forEach(block => { + try { + block.style.transform = 'none' + block.draggableInstance.remove() + } catch (err) { + console.warn('Fail to remove draggable instance', err) + } + }) + } + }); + layoutObserver.observe(container, { + attributes: true, + attributeFilter: ["data-layout"], + attributeOldValue: true + }); + obs.push(layoutObserver) + //}}} + //}}} + return container +} -- cgit v1.2.3-70-g09d2