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);
};