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 { renderWith, defaultAliases, parseConfigsFromYaml } from "mapclay"; import { onRemove, animateRectTransition, throttle } from "./utils"; import { Layout, SideBySide, Overlay } from "./Layout"; import * as utils from "./dumbyUtils"; const docLinkSelector = 'a[href^="#"][title^="=>"]'; const geoLinkSelector = 'a[href^="geo:"]'; const layouts = [ new Layout({ name: "normal" }), new SideBySide({ name: "side-by-side" }), new Overlay({ name: "overlay" }), ]; const mapCache = {}; // FUNCTION: Get DocLinks from special anchor element {{{ /** * CreateDocLink. * * @param {HTMLElement} Elements contains anchor elements for doclinks */ export const createDocLink = link => { link.classList.add("with-leader-line", "doclink"); link.lines = []; link.onmouseover = () => { const label = decodeURIComponent(link.href.split("#")[1]); const selector = link.title.split("=>")[1] ?? "#" + label; const target = document.querySelector(selector); if (!target?.checkVisibility()) return; const line = new LeaderLine({ start: link, end: target, middleLabel: LeaderLine.pathLabel({ text: label, fontWeight: "bold", }), 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; }; }; // }}} // FUNCTION: Get GeoLinks from special anchor element {{{ /** * Create geolinks, which points to map by geo schema and id * * @param {HTMLElement} Elements contains anchor elements for doclinks * @returns {Boolean} ture is link is created, false if coordinates are invalid */ export const createGeoLink = (link, callback = null) => { const url = new URL(link.href); const xyInParams = url.searchParams.get("xy"); const xy = xyInParams ? xyInParams.split(",")?.map(Number) : 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 = []; callback?.call(this, link); return true; }; // }}} export const markdown2HTML = (container, mdContent) => { // Render: Markdown -> HTML {{{ Array.from(container.children).map(e => e.remove()); container.innerHTML = '
'; const htmlHolder = container.querySelector(".SemanticHtml"); const md = MarkdownIt({ html: true, breaks: true, }) .use(MarkdownItAnchor, { permalink: MarkdownItAnchor.permalink.linkInsideHeader({ placement: "before", }), }) .use(MarkdownItFootnote) .use(MarkdownItFrontMatter) .use(MarkdownItTocDoneRight); // FIXME A better way to generate blocks md.renderer.rules.dumby_block_open = () => "
"; md.renderer.rules.dumby_block_close = () => "
"; md.core.ruler.before("block", "dumby_block", state => { state.tokens.push(new state.Token("dumby_block_open", "", 1)); }); // Add close tag for block with more than 2 empty lines md.block.ruler.before("table", "dumby_block", (state, startLine) => { if ( state.src[state.bMarks[startLine - 1]] === "\n" && state.src[state.bMarks[startLine - 2]] === "\n" && state.tokens.at(-1).type !== "list_item_open" // Quick hack for not adding tag after "::marker" for
  • ) { state.push("dumby_block_close", "", -1); state.push("dumby_block_open", "", 1); } }); md.core.ruler.after("block", "dumby_block", state => { state.tokens.push(new state.Token("dumby_block_close", "", -1)); }); const contentWithToc = "${toc}\n\n\n" + mdContent; htmlHolder.innerHTML = md.render(contentWithToc); // TODO Do this in markdown-it const blocks = htmlHolder.querySelectorAll(":scope > div:not(:has(nav))"); blocks.forEach(b => { b.classList.add("dumby-block"); b.setAttribute("data-total", blocks.length); }); return container; //}}} }; export const generateMaps = (container, { delay, mapCallback }) => { container.classList.add("Dumby"); const htmlHolder = container.querySelector(".SemanticHtml") ?? container; const blocks = Array.from(htmlHolder.querySelectorAll(".dumby-block")); const showcase = document.createElement("div"); container.appendChild(showcase); showcase.classList.add("Showcase"); const renderMaps = []; const dumbymap = { layouts, container, htmlHolder, showcase, blocks, renderMaps: renderMaps, utils: { focusNextMap: throttle(utils.focusNextMap, utils.focusDelay), switchToNextLayout: throttle(utils.switchToNextLayout, 300), focusNextBlock: utils.focusNextBlock, removeBlockFocus: utils.removeBlockFocus, }, }; Object.entries(dumbymap.utils).forEach(([util, func]) => { dumbymap.utils[util] = func.bind(dumbymap); }); // LeaderLine {{{ Array.from(container.querySelectorAll(docLinkSelector)).filter(createDocLink); // Get anchors with "geo:" scheme htmlHolder.anchors = []; const geoLinkCallback = link => { link.onmouseover = () => addLeaderLines(link); link.onmouseout = () => removeLeaderLines(link); link.onclick = event => { event.preventDefault(); htmlHolder.anchors .filter(isAnchorPointedBy(link)) .forEach(updateMapByMarker(link.xy)); // TODO Just hide leader line and show it again removeLeaderLines(link); }; }; const geoLinks = Array.from( container.querySelectorAll(geoLinkSelector), ).filter(l => createGeoLink(l, geoLinkCallback)); const isAnchorPointedBy = link => anchor => { const mapContainer = anchor.closest(".mapclay"); const isTarget = !link.targets || link.targets.includes(mapContainer.id); return anchor.title === link.url.pathname && isTarget; }; const isAnchorVisible = anchor => { const mapContainer = anchor.closest(".mapclay"); 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 = htmlHolder.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(".mapclay")?.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 ); }; //}}} // Draggable Blocks {{{ // Add draggable part for blocks // }}} // CSS observer {{{ // Focus Map {{{ // Set focusArea const mapFocusObserver = () => new MutationObserver(mutations => { const mutation = mutations.at(-1); const target = mutation.target; const focus = target .getAttribute(mutation.attributeName) .includes("focus"); const shouldBeInShowcase = focus && showcase.checkVisibility({ contentVisibilityAuto: true, opacityProperty: true, visibilityProperty: true, }); if (shouldBeInShowcase) { if (showcase.contains(target)) return; // Placeholder for map in Showcase, it should has the same DOMRect const placeholder = target.cloneNode(true); placeholder.removeAttribute("id"); placeholder.classList.remove("mapclay", "focus"); target.parentElement.replaceChild(placeholder, target); // FIXME Maybe use @start-style for CSS // Trigger CSS transition, if placeholde is the olny chil element in block, // reduce its height to zero. // To make sure the original height of placeholder is applied, DOM changes seems needed // then set data-attribute for CSS selector to change height to 0 placeholder.getBoundingClientRect(); placeholder.setAttribute("data-placeholder", target.id); // To fit showcase, remove all inline style target.removeAttribute("style"); showcase.appendChild(target); // Resume rect from Semantic HTML to Showcase, with animation animateRectTransition(target, placeholder.getBoundingClientRect(), { duration: 300, resume: true, }); } else if (showcase.contains(target)) { // Check placeholder is inside Semantic HTML const placeholder = htmlHolder.querySelector( `[data-placeholder="${target.id}"]`, ); if (!placeholder) throw Error(`Cannot fine placeholder for map "${target.id}"`); // Consider animation may fail, write callback const afterAnimation = () => { placeholder.parentElement.replaceChild(target, placeholder); target.style = placeholder.style.cssText; placeholder.remove(); }; // animation from Showcase to placeholder animateRectTransition(target, placeholder.getBoundingClientRect(), { duration: 300, }).finished.finally(afterAnimation); } }); // }}} // Layout {{{ // press key to switch layout const defaultLayout = layouts[0]; container.setAttribute("data-layout", defaultLayout.name); // observe layout change const layoutObserver = new MutationObserver(mutations => { const mutation = mutations.at(-1); const oldLayout = mutation.oldValue; const newLayout = container.getAttribute(mutation.attributeName); // Apply handler for leaving/entering layouts if (oldLayout) { layouts .find(l => l.name === oldLayout) ?.leaveHandler?.call(this, dumbymap); } Object.values(dumbymap) .flat() .filter(ele => ele instanceof HTMLElement) .forEach(ele => ele.removeAttribute("style")); if (newLayout) { layouts .find(l => l.name === newLayout) ?.enterHandler?.call(this, dumbymap); } // Since layout change may show/hide showcase, the current focused map should do something // Reset attribute triggers MutationObserver which is observing it const focusMap = container.querySelector(".mapclay.focus") ?? container.querySelector(".mapclay"); focusMap?.classList?.add("focus"); }); layoutObserver.observe(container, { attributes: true, attributeFilter: ["data-layout"], attributeOldValue: true, characterDataOldValue: true, }); onRemove(htmlHolder, () => layoutObserver.disconnect()); //}}} //}}} // Render Maps {{{ const afterMapRendered = renderer => { const mapElement = renderer.target; mapElement.setAttribute("tabindex", "-1"); if (mapElement.getAttribute("data-render") === "fulfilled") { mapCache[mapElement.id] = renderer; } // Execute callback from caller mapCallback?.call(this, mapElement); const markers = geoLinks .filter(link => !link.targets || link.targets.includes(mapElement.id)) .map(link => ({ xy: link.xy, title: link.url.pathname })); // Add markers with Geolinks renderer.addMarkers(markers); mapElement .querySelectorAll(".marker") .forEach(marker => htmlHolder.anchors.push(marker)); // Work with Mutation Observer const observer = mapFocusObserver(); mapFocusObserver().observe(mapElement, { attributes: true, attributeFilter: ["class"], attributeOldValue: true, }); onRemove(mapElement, () => observer.disconnect()); }; // 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 = 1; while (!mapId || mapIdList.includes(mapId)) { mapId = `${config.use ?? "unnamed"}-${counter}`; counter++; } config.id = mapId; } mapIdList.push(mapId); return config; }; // Render each code block with "language-map" class const elementsWithMapConfig = Array.from( container.querySelectorAll("pre:has(.language-map)") ?? [], ); // Add default aliases into each config const configConverter = config => ({ use: config.use ?? "Leaflet", width: "100%", ...config, aliases: { ...defaultAliases, ...(config.aliases ?? {}), }, }); const render = renderWith(configConverter); elementsWithMapConfig.forEach(target => { // Get text in code block starts with markdown text '```map' const configText = target .querySelector(".language-map") .textContent // BE CAREFUL!!! 0xa0 char is "non-breaking spaces" in HTML text content // replace it by normal space .replace(/\u00A0/g, "\u0020"); let configList = []; try { configList = parseConfigsFromYaml(configText).map(assignMapId); } catch (_) { console.warn("Fail to parse yaml config for element", target); return; } // If map in cache has the same ID, just put it into target // So user won't feel anything changes when editing markdown configList.forEach(config => { const cache = mapCache[config.id]; if (!cache) return; target.appendChild(cache.target); config.target = cache.target; }); // trivial: if map cache is applied, do not show yaml text if (target.querySelector(".mapclay")) { target .querySelectorAll(":scope > :not([data-render=fulfilled])") .forEach(e => e.remove()); } // Render maps with delay const timer = setTimeout( () => render(target, configList).forEach(renderMap => { renderMaps.push(renderMap); renderMap.then(afterMapRendered); }), delay ?? 1000, ); onRemove(htmlHolder, () => { clearTimeout(timer); }); }); //}}} return Object.seal(dumbymap); };