import {
newPageReference,
pageReference,
splitPageReference
} from "../reference.js";
import { cmapRoute, pageRoute } from "../routes.js";
import { cmapMentionTarget, escapeHtml } from "../markdown.js";
import {
buildBundle,
preparedMapDocument,
replaceAttachmentUrls,
validateBundle
} from "./interchange.js";
/**
* Own the complete browser-side CMap workspace.
*
* The workspace coordinates the CMap editor, its dialogs, appearance,
* persistence, history and browser event handling. The wiki application
* supplies only the concrete services needed to cross the module boundary.
*/
export class CmapWorkspace {
constructor(
state,
api,
tr,
can,
show,
renderBreadcrumbs,
renderToc,
pageDisplayDate,
loadPages,
openPage,
navigateToHash,
canonicalPageReference,
renderMarkdown,
renderPageCmapConnections,
escapeMarkdownLinkLabel
) {
const $ = (id) => document.getElementById(id);
const cmapMapCombobox = new window.RacketWikiComboBox($("cmap-map-combobox"));
const cmapPageCombobox = new window.RacketWikiComboBox(
$("cmap-concept-page-combobox"));
const cmapLinkCombobox = new window.RacketWikiComboBox(
$("cmap-concept-cmap-combobox"));
let pendingCmapTransition = null;
let cmapStatusTimer = null;
let cmapAutosaveTimer = null;
let cmapSavePromise = null;
let cmapEmbedHydrationTimer = null;
const cmapDescriptionPreviewCache = new Map();
let cmapDescriptionTooltip = null;
let cmapDescriptionTooltipToken = 0;
const CMAP_AUTOSAVE_DELAY = 1500;
/** Queue rendering of CMap embeds after their sanitized HTML enters the DOM. */
function queueCmapEmbedHydration() {
if (cmapEmbedHydrationTimer !== null) window.clearTimeout(cmapEmbedHydrationTimer);
cmapEmbedHydrationTimer = window.setTimeout(() => {
cmapEmbedHydrationTimer = null;
hydrateCmapEmbeds(document).catch((error) => console.error(error));
}, 0);
}
/** Render all pending CMap embeds below root as read-only CMap editors. */
async function hydrateCmapEmbeds(root) {
const embeds = Array.from(root.querySelectorAll(
".rw-cmap-embed:not([data-cmap-hydrated])"));
for (const embed of embeds) {
embed.dataset.cmapHydrated = "loading";
const conceptMap = cmapMentionTarget(
embed.dataset.cmapReference || "",
state.conceptMaps);
if (!conceptMap) {
embed.dataset.cmapHydrated = "error";
embed.replaceChildren();
const message = document.createElement("p");
message.className = "error";
message.textContent = tr("concept-map-not-found", "CMap not found");
embed.append(message);
continue;
}
try {
const stored = await api(`/api/cmaps/${encodeURIComponent(conceptMap.slug)}`);
const documentValue = decodeStoredConceptMapDocument(stored);
embed.replaceChildren();
embed.dataset.cmapSlug = conceptMap.slug;
embed.title = tr("embedded-concept-map-help", "Double-click to open this CMap.");
const header = document.createElement("header");
const title = document.createElement("strong");
title.textContent = conceptMap.title;
const hint = document.createElement("span");
hint.textContent = tr(
"embedded-concept-map-help", "Double-click to open this CMap.");
header.append(title, hint);
const viewport = document.createElement("div");
viewport.className = "rw-cmap-embed-viewport";
const canvas = document.createElement("div");
canvas.className =
"cmap-canvas cmap-page-guides-hidden rw-cmap-embed-canvas";
viewport.append(canvas);
embed.append(header, viewport);
const editor = window.RacketWikiCmap.createEditor(canvas, {
Cmap: window.Cmap,
renderItem: (record) => cmapNodeHtml(record),
onOpenExternalUrl: (record) => openCmapExternalUrl(record)
});
editor.loadDocument(documentValue);
const visible = editor.visibleItemRecords();
if (visible.length) {
const left = Math.min(...visible.map((item) =>
Number(item.node.attr("x")))) - 24;
const top = Math.min(...visible.map((item) =>
Number(item.node.attr("y")))) - 24;
const right = Math.max(...visible.map((item) =>
Number(item.node.attr("x")) + Number(item.node.attr("width")))) + 24;
const bottom = Math.max(...visible.map((item) =>
Number(item.node.attr("y")) + Number(item.node.attr("height")))) + 24;
const availableWidth = Math.max(320, embed.clientWidth - 2);
const scale = Math.min(
1,
availableWidth / Math.max(1, right - left),
520 / Math.max(1, bottom - top));
editor.zoomFactor = scale;
editor.map.zoom(scale);
viewport.style.height =
`${Math.max(180, Math.ceil((bottom - top) * scale))}px`;
window.requestAnimationFrame(() => {
viewport.scrollLeft = Math.max(0, left * scale);
viewport.scrollTop = Math.max(0, top * scale);
});
}
embed.dataset.cmapHydrated = "ready";
embed.addEventListener("dblclick", () => {
navigateToHash(cmapRoute(conceptMap.slug))
.catch((error) => console.error(error));
});
} catch (error) {
embed.dataset.cmapHydrated = "error";
embed.replaceChildren();
const message = document.createElement("p");
message.className = "error";
message.textContent = error.message;
embed.append(message);
}
}
}
const CMAP_START_SLUG_KEY = "racket-wiki:start-cmap";
function startCmapSlug() {
try {
return window.localStorage.getItem(CMAP_START_SLUG_KEY) || "";
} catch (_error) {
return "";
}
}
function setStartCmapSlug(slug) {
try {
if (slug) window.localStorage.setItem(CMAP_START_SLUG_KEY, slug);
else window.localStorage.removeItem(CMAP_START_SLUG_KEY);
} catch (error) {
console.warn("The start CMap preference could not be stored.", error);
}
}
////////////////////////////////////////////////////////////////////////////////
// Concept map prototype
////////////////////////////////////////////////////////////////////////////////
/**
* goal : Return the in-memory concept-map prototype state.
* post : A state object exists for the current CMap prototype.
* result : Object containing the RacketWikiCmap editor instance.
*/
function cmapPrototypeState() {
if (!state.cmapPrototype) {
state.cmapPrototype = {
editor: null
};
}
return state.cmapPrototype;
}
/**
* goal : Render one concept card inside an ionstage/cmap node.
* pre : record contains label, synopsis and kind.
* result : Safe HTML used as the node's content.
*/
function cmapNodeHtml(record) {
const safeLabel = escapeHtml(record.label || "");
const safeSynopsis = escapeHtml(record.synopsis || "");
const safeKind = escapeHtml(record.kind || "concept");
const safeImageSource = escapeHtml(record.imageSource || "");
const image = safeImageSource ? `
` : "";
const aspects = (Array.isArray(record.aspects) ? record.aspects : [])
.map((aspect) => `${escapeHtml(aspect)}`)
.join("");
const aspectList = aspects ? `
${aspects}
` : "";
const personNames = (Array.isArray(record.tags) ? record.tags : [])
.filter((tag) => tag && typeof tag === "object" && tag.type === "person" && tag.value)
.map((tag) => String(tag.value));
const peopleLabel = personNames.length ?
tr("person-tags-title", "Responsibility/action: {people}").replace("{people}", personNames.join(", ")) : "";
const personTags = personNames.length ?
`👤 ${personNames.map(escapeHtml).join(" · ")}
` : "";
const usageCount = record.conceptId && record.kind !== "phrase" ?
effectiveCmapConceptUsage(record) : null;
const usageLabel = usageCount === null ? "" :
tr("concept-usage-count", "{count} placements across all concept maps")
.replace("{count}", String(usageCount));
const usage = usageCount === null ? "" :
`(${usageCount})`;
const descriptionReference = canonicalPageReference(record.descriptionPageSlug || "");
const descriptionExists = Boolean(descriptionReference &&
state.pages.some((page) => page.slug === descriptionReference));
const descriptionButton = record.descriptionPageSlug ?
`` : "";
const linkedTarget = record.pageSlug || record.cmapSlug || record.parentCmapLink;
const linkedButton = linkedTarget && record.kind !== "submap" ?
`` : "";
const externalButton = record.externalUrl ?
`` : "";
const linkedCmapClass = record.cmapSlug || record.parentCmapLink ? " cmap-card-linked-cmap" : "";
return `${descriptionButton}${linkedButton}${externalButton}${image}
${safeLabel} ${usage}
${aspectList}${personTags}${safeSynopsis ? `
${safeSynopsis}
` : ""}
`;
}
function effectiveCmapConceptUsage(record) {
const byMap = state.cmapConceptUsage.get(record.conceptId);
const storedTotal = byMap ? Array.from(byMap.values()).reduce((sum, count) => sum + count, 0) : 0;
const prototype = cmapPrototypeState();
const isActiveEditorRecord = Boolean(
prototype.editor && prototype.editor.containsItemRecord(record));
if (!isActiveEditorRecord) return Math.max(1, storedTotal || Number(record.usageCount) || 1);
const currentSlug = state.currentConceptMapSource?.slug ||
state.currentConceptMap?.slug || null;
const storedHere = currentSlug && byMap ? (byMap.get(currentSlug) || 0) : 0;
return Math.max(1, storedTotal - storedHere + (Number(record.usageCount) || 1));
}
function ensureCmapDescriptionTooltip() {
if (cmapDescriptionTooltip) return cmapDescriptionTooltip;
cmapDescriptionTooltip = document.createElement("div");
cmapDescriptionTooltip.id = "cmap-description-tooltip";
cmapDescriptionTooltip.className = "cmap-description-tooltip hidden";
cmapDescriptionTooltip.setAttribute("role", "tooltip");
document.body.append(cmapDescriptionTooltip);
return cmapDescriptionTooltip;
}
function hideCmapDescriptionTooltip() {
cmapDescriptionTooltipToken += 1;
if (cmapDescriptionTooltip) cmapDescriptionTooltip.classList.add("hidden");
}
async function showCmapDescriptionTooltip(button) {
if (!button.classList.contains("is-filled")) return;
const itemElement = button.closest("[data-rw-cmap-item-id]");
const prototype = cmapPrototypeState();
const record = itemElement && prototype.editor ?
prototype.editor.itemRecord(itemElement.dataset.rwCmapItemId) : null;
if (!record || !record.descriptionPageSlug) return;
const reference = canonicalPageReference(record.descriptionPageSlug);
const tooltip = ensureCmapDescriptionTooltip();
const token = ++cmapDescriptionTooltipToken;
button.setAttribute("aria-describedby", tooltip.id);
tooltip.classList.remove("hidden");
tooltip.textContent = tr("loading", "Loading…");
const rect = button.getBoundingClientRect();
tooltip.style.left = `${Math.max(8, Math.min(rect.left, window.innerWidth - 440))}px`;
tooltip.style.top = `${Math.min(window.innerHeight - 180, rect.bottom + 8)}px`;
try {
let preview = cmapDescriptionPreviewCache.get(reference);
if (preview === undefined) {
const page = await api(`/api/pages/${encodeURIComponent(reference)}`);
preview = String(page.markdown || "").trim() ? renderMarkdown(page.markdown, page.slug) : null;
cmapDescriptionPreviewCache.set(reference, preview);
}
if (token !== cmapDescriptionTooltipToken) return;
if (!preview) {
button.classList.remove("is-filled");
button.classList.add("is-empty");
tooltip.classList.add("hidden");
return;
}
tooltip.innerHTML = `${preview}`;
const height = tooltip.offsetHeight;
if (rect.bottom + 8 + height > window.innerHeight) {
tooltip.style.top = `${Math.max(8, rect.top - height - 8)}px`;
}
} catch (error) {
if (token !== cmapDescriptionTooltipToken) return;
button.classList.remove("is-filled");
button.classList.add("is-empty");
tooltip.classList.add("hidden");
if (error.status !== 404) console.error(error);
}
}
/**
* goal : Derive a compact synopsis from the currently opened wiki page.
* pre : state.currentPage may be #f/null when no page is open.
* result : Plain text of at most about 150 characters.
*/
function currentPageSynopsis() {
if (!state.currentPage) return "";
const container = document.createElement("div");
container.innerHTML = renderMarkdown(state.currentPage.content || "");
const text = (container.textContent || "").replace(/\s+/g, " ").trim();
return text.length > 150 ? `${text.slice(0, 147)}…` : text;
}
/**
* goal : Add a concept-like item to the active CMap prototype.
* pre : resetCmapPrototype has created the RacketWikiCmap editor.
* post : The item is draggable, selectable, resizable and linkable.
* result : The item record created by the interaction layer.
*/
function addCmapPrototypeNode(options = {}) {
const prototype = cmapPrototypeState();
if (!prototype.editor) return null;
const conceptId = options.conceptId || sharedCmapConceptId(options.label);
return prototype.editor.addItem(conceptId ? { ...options, conceptId } : options);
}
function normalizeCmapConceptName(label) {
return String(label || "").trim().toLocaleLowerCase();
}
function sharedCmapConceptId(label) {
return state.cmapConceptIdsByName.get(normalizeCmapConceptName(label)) || null;
}
let cmapDialogRecord = null;
let cmapDialogCreateContext = null;
let cmapContextCreateContext = {};
let cmapDialogImageSource = "";
let cmapDialogImageRead = Promise.resolve();
function cmapColorValue(value, fallback = "#f3f6f8") {
return /^#[0-9a-f]{6}$/i.test(value || "") ? value : fallback;
}
function cmapFontSizeInPoints(value, baseSize = 11) {
const size = Number.parseFloat(value);
if (!Number.isFinite(size)) return 11;
if (/px$/i.test(value || "")) return size * 0.75;
if (/em$/i.test(value || "")) return size * baseSize;
if (/%$/i.test(value || "")) return (size / 100) * baseSize;
return size;
}
function displayCmapFontSize(value) {
return String(Math.round(value * 2) / 2);
}
function normalizedCmapFontSize(value, fallback) {
const size = Number(value);
const usableSize = Number.isFinite(size) ? size : fallback;
return Math.max(6, Math.min(54, Math.round(usableSize * 2) / 2));
}
function normalizedCmapExternalUrl(value) {
const text = String(value || "").trim();
if (!text) return "";
try {
const url = new URL(text);
return ["http:", "https:"].includes(url.protocol) ? url.href : null;
} catch (_error) {
return null;
}
}
function openCmapExternalUrl(record) {
const url = normalizedCmapExternalUrl(record && record.externalUrl);
if (!url) return false;
window.open(url, "_blank", "noopener,noreferrer");
return true;
}
function selectCmapConceptTab(name) {
for (const tab of $("cmap-concept-form").querySelectorAll("[data-cmap-concept-tab]")) {
const selected = tab.dataset.cmapConceptTab === name;
tab.setAttribute("aria-selected", selected ? "true" : "false");
tab.tabIndex = selected ? 0 : -1;
const panel = $(tab.getAttribute("aria-controls"));
if (panel) panel.classList.toggle("hidden", !selected);
}
$("cmap-concept-form").querySelector(".cmap-concept-dialog-body").scrollTop = 0;
}
function selectCmapFont(fontFamily, selectId = "cmap-concept-font-family") {
const select = $(selectId);
const value = fontFamily || "Arial, Helvetica, sans-serif";
const existing = Array.from(select.options).find((option) => option.value === value);
if (!existing) {
const option = document.createElement("option");
option.value = value;
option.textContent = value;
select.append(option);
}
select.value = value;
}
function titledCmapComboboxEntry(record) {
const label = record.title || record.slug;
return {
value: record.slug,
label,
description: record.slug
};
}
function populateCmapPageOptions(record) {
const pages = [...state.pages].sort((a, b) => a.title.localeCompare(b.title));
const entries = pages.map((page) => titledCmapComboboxEntry(page));
if (record.pageSlug && !pages.some((page) => page.slug === record.pageSlug)) {
entries.push({ value: record.pageSlug, label: record.pageSlug });
}
cmapPageCombobox.setOptions(entries, record.pageSlug || "");
$("cmap-concept-page-row").classList.toggle("hidden", record.kind === "submap");
}
function populateCmapLinkOptions(record) {
const prototype = cmapPrototypeState();
const entries = [];
if (prototype.editor && prototype.editor.activeMapRoot) {
const parentLabel = `↩ ${tr("parent-concept-map", "Parent concept map")}`;
entries.push({ value: "__parent__", label: parentLabel });
}
for (const conceptMap of [...state.conceptMaps].sort((a, b) => a.title.localeCompare(b.title))) {
entries.push(titledCmapComboboxEntry(conceptMap));
}
if (record.cmapSlug && !state.conceptMaps.some((conceptMap) => conceptMap.slug === record.cmapSlug)) {
entries.push({ value: record.cmapSlug, label: record.cmapSlug });
}
const selectedValue = record.parentCmapLink ? "__parent__" : (record.cmapSlug || "");
cmapLinkCombobox.setOptions(entries, selectedValue);
$("cmap-concept-cmap-row").classList.toggle("hidden", record.kind === "submap");
}
function updateCmapImagePreview() {
const row = $("cmap-concept-image-preview-row");
const preview = $("cmap-concept-image-preview");
if (!cmapDialogImageSource) {
row.classList.add("hidden");
preview.removeAttribute("src");
return;
}
preview.src = cmapDialogImageSource;
row.classList.remove("hidden");
}
async function loadPeople() {
const result = await api("/api/people");
state.people = Array.isArray(result.people) ? result.people : [];
return state.people;
}
function selectedCmapPersonNames() {
return Array.from($("cmap-person-tag-options").querySelectorAll("input[type='checkbox']:checked"))
.map((input) => input.dataset.personName).filter(Boolean);
}
function renderCmapPersonTagOptions(selectedNames = []) {
const selected = new Set(selectedNames.map((name) => name.toLocaleLowerCase()));
const target = $("cmap-person-tag-options");
target.replaceChildren();
const visiblePeople = state.people.filter((person) =>
person.active || selected.has(String(person.name).toLocaleLowerCase()));
for (const person of visiblePeople) {
const label = document.createElement("label");
label.className = "cmap-person-tag-option";
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.dataset.personName = person.name;
checkbox.checked = selected.has(String(person.name).toLocaleLowerCase());
const text = document.createElement("span");
text.textContent = person.active ? person.name :
`${person.name} (${tr("inactive", "inactive")})`;
label.append(checkbox, text);
target.append(label);
}
const knownNames = new Set(state.people.map((person) => String(person.name).toLocaleLowerCase()));
for (const selectedName of selectedNames) {
if (knownNames.has(String(selectedName).toLocaleLowerCase())) continue;
const label = document.createElement("label");
label.className = "cmap-person-tag-option";
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.dataset.personName = selectedName;
checkbox.checked = true;
const text = document.createElement("span");
text.textContent = `${selectedName} (${tr("inactive", "inactive")})`;
label.append(checkbox, text);
target.append(label);
}
if (!target.childElementCount) {
const empty = document.createElement("span");
empty.className = "muted";
empty.textContent = tr("no-active-people", "No active people yet.");
target.append(empty);
}
const summary = $("cmap-person-tags-picker").querySelector("summary");
const selectedCount = selected.size;
summary.textContent = selectedCount ?
tr("people-selected", "{count} people selected").replace("{count}", String(selectedCount)) :
tr("select-people", "Select people");
}
async function createPersonFromInput(inputId, selectInConceptDialog = false) {
const input = $(inputId);
const name = input.value.trim();
if (!name) {
input.focus();
return null;
}
const selected = selectInConceptDialog ? selectedCmapPersonNames() : [];
const person = await api("/api/people", {
method: "POST",
body: JSON.stringify({ name })
});
input.value = "";
await loadPeople();
if (selectInConceptDialog) renderCmapPersonTagOptions([...selected, person.name]);
return person;
}
function renderPeopleManagement() {
const target = $("cmap-people-list");
target.replaceChildren();
for (const person of state.people) {
const row = document.createElement("div");
row.className = "cmap-person-admin-row";
const name = document.createElement("strong");
name.textContent = person.name;
const activeLabel = document.createElement("label");
const active = document.createElement("input");
active.type = "checkbox";
active.checked = Boolean(person.active);
const activeText = document.createElement("span");
activeText.textContent = tr("active", "Active");
activeLabel.append(active, activeText);
const save = document.createElement("button");
save.type = "button";
save.textContent = tr("save", "Save");
save.addEventListener("click", async () => {
save.disabled = true;
try {
await api(`/api/people/${person.id}`, {
method: "PUT",
body: JSON.stringify({ name: person.name, active: active.checked })
});
await loadPeople();
renderPeopleManagement();
} catch (error) {
window.alert(error.message);
save.disabled = false;
}
});
row.append(name, activeLabel, save);
target.append(row);
}
}
async function openPeopleManagement() {
await loadPeople();
renderPeopleManagement();
$("cmap-people-dialog").showModal();
closeCmapContextMenu();
}
function readCmapImage(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener("load", () => resolve(String(reader.result || "")), { once: true });
reader.addEventListener("error", () => reject(reader.error || new Error("Image could not be read.")), { once: true });
reader.readAsDataURL(file);
});
}
/**
* goal : Open the concept editor without changing its wiki-page identity.
* pre : record is a non-phrase item in the current CMap editor.
* post : A modal form shows concept text, presentation and image fields.
*/
function openCmapConceptDialog(record) {
if (!record || record.kind === "phrase") return;
cmapDialogRecord = record;
cmapDialogCreateContext = null;
$("cmap-concept-dialog-title").textContent = tr("edit-concept", "Edit concept");
cmapDialogImageSource = record.imageSource || "";
cmapDialogImageRead = Promise.resolve();
$("cmap-concept-label").value = record.label || "";
$("cmap-concept-synopsis").value = record.synopsis || "";
$("cmap-concept-aspects").value = (record.aspects || []).join(", ");
const selectedPeople = (Array.isArray(record.tags) ? record.tags : [])
.filter((tag) => tag && typeof tag === "object" && tag.type === "person")
.map((tag) => tag.value);
renderCmapPersonTagOptions(selectedPeople);
$("cmap-person-tags-picker").open = false;
$("cmap-concept-description-page").value = record.descriptionPageSlug || "";
$("cmap-concept-description-link").href = record.descriptionPageSlug ?
pageRoute(record.descriptionPageSlug) : "#";
$("cmap-concept-description-link").classList.toggle("hidden", !record.descriptionPageSlug);
$("cmap-concept-external-url").value = record.externalUrl || "";
$("cmap-concept-external-url").setCustomValidity("");
$("cmap-concept-background").value = cmapColorValue(record.backgroundColor);
$("cmap-concept-background-label").textContent = record.kind === "submap" ?
tr("main-concept-background-color", "Main concept background color") :
tr("background-color", "Background color");
$("cmap-submap-style-fields").classList.toggle("hidden", record.kind !== "submap");
$("cmap-submap-background").value = cmapColorValue(
record.submapBackgroundColor, "#edf7e8");
$("cmap-submap-border").value = cmapColorValue(
record.submapBorderColor, "#57834a");
$("cmap-concept-text-color").value = cmapColorValue(record.textColor, "#222222");
selectCmapFont(record.fontFamily);
const titleFontSize = cmapFontSizeInPoints(record.fontSize);
$("cmap-concept-font-size").value = displayCmapFontSize(titleFontSize);
$("cmap-concept-bold").checked = String(record.fontWeight || "700") !== "400";
$("cmap-concept-italic").checked = record.fontStyle === "italic";
$("cmap-concept-synopsis-text-color").value = cmapColorValue(
record.synopsisTextColor || record.textColor, "#222222");
selectCmapFont(
record.synopsisFontFamily || record.fontFamily,
"cmap-concept-synopsis-font-family");
$("cmap-concept-synopsis-font-size").value = displayCmapFontSize(
cmapFontSizeInPoints(record.synopsisFontSize || "0.84em", titleFontSize));
$("cmap-concept-synopsis-bold").checked = String(
record.synopsisFontWeight || record.fontWeight || "700") !== "400";
$("cmap-concept-synopsis-italic").checked =
(record.synopsisFontStyle || record.fontStyle) === "italic";
$("cmap-concept-image").value = "";
populateCmapPageOptions(record);
populateCmapLinkOptions(record);
updateCmapImagePreview();
for (const input of $("cmap-concept-form").querySelectorAll(".cmap-color-input")) {
updateCmapColorControl(input);
}
renderCmapStyleOptions();
selectCmapConceptTab("content");
$("cmap-concept-dialog").showModal();
$("cmap-concept-label").focus();
$("cmap-concept-label").select();
}
function openNewCmapConceptDialog(context = {}) {
cmapDialogRecord = null;
cmapDialogCreateContext = context;
cmapDialogImageSource = "";
cmapDialogImageRead = Promise.resolve();
$("cmap-concept-dialog-title").textContent = tr("add-concept", "Add concept");
$("cmap-concept-label").value = "New concept";
$("cmap-concept-synopsis").value = "";
$("cmap-concept-aspects").value = "";
renderCmapPersonTagOptions([]);
$("cmap-person-tags-picker").open = false;
$("cmap-concept-description-page").value = "";
$("cmap-concept-description-link").classList.add("hidden");
$("cmap-concept-external-url").value = "";
$("cmap-concept-external-url").setCustomValidity("");
$("cmap-concept-background").value = "#fff4cf";
$("cmap-concept-background-label").textContent = tr("background-color", "Background color");
$("cmap-submap-style-fields").classList.add("hidden");
$("cmap-submap-background").value = "#edf7e8";
$("cmap-submap-border").value = "#57834a";
$("cmap-concept-text-color").value = "#222222";
selectCmapFont("Arial, Helvetica, sans-serif");
$("cmap-concept-font-size").value = "11";
$("cmap-concept-bold").checked = true;
$("cmap-concept-italic").checked = false;
$("cmap-concept-synopsis-text-color").value = "#4d4d4d";
selectCmapFont("Arial, Helvetica, sans-serif", "cmap-concept-synopsis-font-family");
$("cmap-concept-synopsis-font-size").value = "9";
$("cmap-concept-synopsis-bold").checked = false;
$("cmap-concept-synopsis-italic").checked = false;
$("cmap-concept-image").value = "";
populateCmapPageOptions({ kind: "concept", pageSlug: null });
populateCmapLinkOptions({ kind: "concept", cmapSlug: null, parentCmapLink: false });
updateCmapImagePreview();
for (const input of $("cmap-concept-form").querySelectorAll(".cmap-color-input")) {
updateCmapColorControl(input);
}
renderCmapStyleOptions();
selectCmapConceptTab("content");
$("cmap-concept-dialog").showModal();
$("cmap-concept-label").focus();
$("cmap-concept-label").select();
}
const cmapStyleValueKeys = [
"backgroundColor", "textColor", "fontFamily", "fontSize", "fontWeight", "fontStyle",
"synopsisTextColor", "synopsisFontFamily", "synopsisFontSize", "synopsisFontWeight",
"synopsisFontStyle", "submapBackgroundColor", "submapBorderColor"
];
function seededCmapStyle(id, nameKey, options) {
const synopsisSize = Math.max(6, options.size - 2);
return {
id,
nameKey,
protected: id === "default",
values: {
backgroundColor: options.background,
textColor: options.text,
fontFamily: options.font,
fontSize: options.size,
fontWeight: options.bold ? "700" : "400",
fontStyle: options.italic ? "italic" : "normal",
synopsisTextColor: options.synopsisText || options.text,
synopsisFontFamily: options.font,
synopsisFontSize: synopsisSize,
synopsisFontWeight: "400",
synopsisFontStyle: options.italic ? "italic" : "normal",
submapBackgroundColor: "#edf7e8",
submapBorderColor: "#57834a"
}
};
}
function defaultCmapStyles() {
return [
seededCmapStyle("default", "style-default", { background: "#fff4cf", text: "#222222", synopsisText: "#4d4d4d", font: "Arial, Helvetica, sans-serif", size: 11, bold: true, italic: false }),
seededCmapStyle("subtle", "style-subtle", { background: "#f1f3f5", text: "#56616b", font: "system-ui, sans-serif", size: 10, bold: false, italic: false }),
seededCmapStyle("emphasis", "style-emphasis", { background: "#e7f2fb", text: "#173b57", font: "Georgia, Times New Roman, serif", size: 12, bold: true, italic: false }),
seededCmapStyle("warning", "style-warning", { background: "#fff0d5", text: "#713b00", font: "Arial, Helvetica, sans-serif", size: 11, bold: true, italic: true }),
seededCmapStyle("success", "style-success", { background: "#e6f4e2", text: "#285b27", font: "Arial, Helvetica, sans-serif", size: 11, bold: false, italic: true })
];
}
function normalizedCmapStyleValues(values, fallbackValues) {
if (!values || typeof values !== "object") return null;
const fallback = fallbackValues || defaultCmapStyles()[0].values;
const fontSize = normalizedCmapFontSize(values.fontSize, fallback.fontSize);
return {
backgroundColor: cmapColorValue(values.backgroundColor, fallback.backgroundColor).toLowerCase(),
textColor: cmapColorValue(values.textColor, fallback.textColor).toLowerCase(),
fontFamily: typeof values.fontFamily === "string" && values.fontFamily.trim() ? values.fontFamily.trim() : fallback.fontFamily,
fontSize,
fontWeight: String(values.fontWeight) === "400" ? "400" : "700",
fontStyle: values.fontStyle === "italic" ? "italic" : "normal",
synopsisTextColor: cmapColorValue(values.synopsisTextColor, fallback.synopsisTextColor).toLowerCase(),
synopsisFontFamily: typeof values.synopsisFontFamily === "string" && values.synopsisFontFamily.trim() ? values.synopsisFontFamily.trim() : fallback.synopsisFontFamily,
synopsisFontSize: normalizedCmapFontSize(values.synopsisFontSize, Math.max(6, fontSize - 2)),
synopsisFontWeight: String(values.synopsisFontWeight) === "700" ? "700" : "400",
synopsisFontStyle: values.synopsisFontStyle === "italic" ? "italic" : "normal",
submapBackgroundColor: cmapColorValue(values.submapBackgroundColor, fallback.submapBackgroundColor).toLowerCase(),
submapBorderColor: cmapColorValue(values.submapBorderColor, fallback.submapBorderColor).toLowerCase()
};
}
function normalizeCmapStyles(styles) {
const seeds = defaultCmapStyles();
if (!Array.isArray(styles)) return seeds;
const seenIds = new Set(["default"]);
const normalized = styles.flatMap((style) => {
const name = typeof style?.name === "string" ? style.name.trim() : "";
const nameKey = typeof style?.nameKey === "string" ? style.nameKey.trim() : "";
if (!style || typeof style.id !== "string" || !style.id || style.id === "default" ||
seenIds.has(style.id) || (!name && !nameKey)) return [];
const values = normalizedCmapStyleValues(style.values, seeds[0].values);
if (!values) return [];
seenIds.add(style.id);
return [{
id: style.id,
...(nameKey ? { nameKey } : { name }),
protected: false,
values
}];
});
const storedDefault = styles.find((style) => style?.id === "default");
const defaultValues = normalizedCmapStyleValues(storedDefault?.values, seeds[0].values);
return [{ ...seeds[0], values: defaultValues || seeds[0].values }, ...normalized];
}
async function loadCmapStyles() {
try {
const result = await api("/api/cmap-styles");
cmapStyles = normalizeCmapStyles(result.styles);
const databaseSnapshot = JSON.stringify(cmapStyles);
const initialSnapshot = JSON.stringify(defaultCmapStyles());
let legacyStyles = null;
try {
const legacy = JSON.parse(window.localStorage.getItem("racket-wiki:cmap-styles:v1") || "null");
if (legacy?.version === 1 && Array.isArray(legacy.styles)) {
legacyStyles = normalizeCmapStyles(legacy.styles);
}
} catch (error) {
console.warn("Legacy browser-local CMap styles could not be read.", error);
}
if (legacyStyles && can("editor") && databaseSnapshot === initialSnapshot &&
JSON.stringify(legacyStyles) !== initialSnapshot) {
cmapStyles = legacyStyles;
if (await storeCmapStyles()) window.localStorage.removeItem("racket-wiki:cmap-styles:v1");
} else if (legacyStyles && (databaseSnapshot !== initialSnapshot ||
JSON.stringify(legacyStyles) === initialSnapshot)) {
window.localStorage.removeItem("racket-wiki:cmap-styles:v1");
}
renderCmapStyleOptions();
} catch (error) {
console.warn("The CMap styles could not be read from the database.", error);
cmapStyles = defaultCmapStyles();
}
return cmapStyles;
}
async function storeCmapStyles() {
try {
const result = await api("/api/cmap-styles", {
method: "PUT",
body: JSON.stringify({ styles: cmapStyles })
});
cmapStyles = normalizeCmapStyles(result.styles);
return true;
} catch (error) {
console.warn("The CMap styles could not be stored in the database.", error);
window.alert(tr("style-storage-failed", "The style could not be stored in the wiki database."));
return false;
}
}
let cmapStyles = defaultCmapStyles();
const defaultCmapColorPalette = ["#ffffff", "#f1f3f5", "#e7f2fb", "#e6f4e2", "#fff4cf", "#fff0d5",
"#f7dede", "#dcd8f7", "#222222", "#4479a1", "#57834a", "#a97c00"];
const cmapColorPaletteStorageKey = "racket-wiki:cmap-color-palette:v1";
function loadCmapColorPalette() {
try {
const stored = JSON.parse(window.localStorage.getItem(cmapColorPaletteStorageKey) || "null");
if (Array.isArray(stored) && stored.length === defaultCmapColorPalette.length) {
return stored.map((color, index) =>
cmapColorValue(color, defaultCmapColorPalette[index]));
}
} catch (error) {
console.warn("The CMap color palette could not be read.", error);
}
return [...defaultCmapColorPalette];
}
function storeCmapColorPalette(colors) {
try {
window.localStorage.setItem(cmapColorPaletteStorageKey, JSON.stringify(colors));
} catch (error) {
console.warn("The CMap color palette could not be stored.", error);
}
}
function openNativeCmapColorPicker(initialColor, onInput) {
const picker = document.createElement("input");
picker.type = "color";
picker.className = "cmap-native-color-picker";
picker.value = cmapColorValue(initialColor, "#ffffff");
document.body.append(picker);
let removed = false;
const cleanup = () => {
if (removed) return;
removed = true;
picker.remove();
};
picker.addEventListener("input", () => onInput(cmapColorValue(picker.value, "#ffffff")));
picker.addEventListener("change", () => window.setTimeout(cleanup, 0), { once: true });
picker.addEventListener("blur", () => window.setTimeout(cleanup, 100), { once: true });
try {
if (typeof picker.showPicker === "function") picker.showPicker();
else picker.click();
} catch (_error) {
picker.click();
}
}
function updateCmapColorControl(input) {
const swatch = input.closest(".cmap-color-control")?.querySelector(".cmap-color-swatch");
if (swatch) swatch.style.backgroundColor = cmapColorValue(input.value, "#ffffff");
}
function installCmapColorPickers() {
const colors = loadCmapColorPalette();
for (const control of document.querySelectorAll(".cmap-color-control")) {
const input = control.querySelector(".cmap-color-input");
const swatch = control.querySelector(".cmap-color-swatch");
swatch.title = tr("color-swatch-help",
"Click for the palette; double-click for a custom color");
const palette = document.createElement("span");
palette.className = "cmap-color-palette hidden";
colors.forEach((color, index) => {
const choice = document.createElement("button");
choice.type = "button";
choice.dataset.cmapColorIndex = String(index);
choice.style.backgroundColor = color;
choice.title = `${color} — ${tr("change-palette-color", "double-click to change")}`;
choice.setAttribute("aria-label", color);
let clickTimer = null;
choice.addEventListener("click", () => {
if (clickTimer !== null) window.clearTimeout(clickTimer);
clickTimer = window.setTimeout(() => {
clickTimer = null;
input.value = colors[index];
input.dispatchEvent(new Event("input", { bubbles: true }));
palette.classList.add("hidden");
}, 240);
});
choice.addEventListener("dblclick", (event) => {
event.preventDefault();
if (clickTimer !== null) window.clearTimeout(clickTimer);
clickTimer = null;
openNativeCmapColorPicker(colors[index], (newColor) => {
colors[index] = newColor;
for (const matchingChoice of document.querySelectorAll(
`.cmap-color-palette button[data-cmap-color-index="${index}"]`)) {
matchingChoice.style.backgroundColor = newColor;
matchingChoice.title = `${newColor} — ${tr("change-palette-color", "double-click to change")}`;
matchingChoice.setAttribute("aria-label", newColor);
}
storeCmapColorPalette(colors);
});
});
palette.append(choice);
});
control.append(palette);
swatch.addEventListener("click", () => {
for (const other of document.querySelectorAll(".cmap-color-palette")) {
if (other !== palette) other.classList.add("hidden");
}
palette.classList.toggle("hidden");
});
swatch.addEventListener("dblclick", (event) => {
event.preventDefault();
palette.classList.add("hidden");
openNativeCmapColorPicker(input.value, (newColor) => {
input.value = newColor;
input.dispatchEvent(new Event("input", { bubbles: true }));
});
});
input.addEventListener("input", () => updateCmapColorControl(input));
updateCmapColorControl(input);
}
}
function captureCmapAppearance() {
return normalizedCmapStyleValues({
backgroundColor: $("cmap-concept-background").value,
textColor: $("cmap-concept-text-color").value,
fontFamily: $("cmap-concept-font-family").value,
fontSize: $("cmap-concept-font-size").value,
fontWeight: $("cmap-concept-bold").checked ? "700" : "400",
fontStyle: $("cmap-concept-italic").checked ? "italic" : "normal",
synopsisTextColor: $("cmap-concept-synopsis-text-color").value,
synopsisFontFamily: $("cmap-concept-synopsis-font-family").value,
synopsisFontSize: $("cmap-concept-synopsis-font-size").value,
synopsisFontWeight: $("cmap-concept-synopsis-bold").checked ? "700" : "400",
synopsisFontStyle: $("cmap-concept-synopsis-italic").checked ? "italic" : "normal",
submapBackgroundColor: $("cmap-submap-background").value,
submapBorderColor: $("cmap-submap-border").value
});
}
function applyCmapAppearance(values) {
const style = normalizedCmapStyleValues(values);
if (!style) return;
$("cmap-concept-background").value = style.backgroundColor;
$("cmap-concept-text-color").value = style.textColor;
selectCmapFont(style.fontFamily);
$("cmap-concept-font-size").value = displayCmapFontSize(style.fontSize);
$("cmap-concept-bold").checked = style.fontWeight === "700";
$("cmap-concept-italic").checked = style.fontStyle === "italic";
$("cmap-concept-synopsis-text-color").value = style.synopsisTextColor;
selectCmapFont(style.synopsisFontFamily, "cmap-concept-synopsis-font-family");
$("cmap-concept-synopsis-font-size").value = displayCmapFontSize(style.synopsisFontSize);
$("cmap-concept-synopsis-bold").checked = style.synopsisFontWeight === "700";
$("cmap-concept-synopsis-italic").checked = style.synopsisFontStyle === "italic";
$("cmap-submap-background").value = style.submapBackgroundColor;
$("cmap-submap-border").value = style.submapBorderColor;
for (const input of $("cmap-concept-panel-appearance").querySelectorAll(".cmap-color-input")) {
updateCmapColorControl(input);
}
}
function sameCmapAppearance(left, right) {
return Boolean(left && right) && cmapStyleValueKeys.every((key) => left[key] === right[key]);
}
function cmapStyleName(style) {
return style.nameKey ? tr(style.nameKey, style.nameKey) : style.name;
}
function matchingCmapStyleId() {
const current = captureCmapAppearance();
return cmapStyles.find((style) => sameCmapAppearance(style.values, current))?.id || "";
}
function updateCmapStyleDeleteButton() {
const selected = cmapStyles.find((style) => style.id === $("cmap-concept-style-preset").value);
$("cmap-delete-style").disabled = !selected || selected.protected;
}
function renderCmapStyleOptions(selectedId = matchingCmapStyleId()) {
const usableId = cmapStyles.some((style) => style.id === selectedId) ? selectedId : "";
for (const select of [$("cmap-concept-quick-style"), $("cmap-concept-style-preset")]) {
select.replaceChildren();
const custom = document.createElement("option");
custom.value = "";
custom.textContent = tr("custom-style", "Custom");
select.append(custom);
for (const style of cmapStyles) {
const option = document.createElement("option");
option.value = style.id;
option.textContent = cmapStyleName(style);
select.append(option);
}
select.value = usableId;
}
updateCmapStyleDeleteButton();
}
function syncCmapStyleSelection() {
const matchingId = matchingCmapStyleId();
$("cmap-concept-quick-style").value = matchingId;
$("cmap-concept-style-preset").value = matchingId;
updateCmapStyleDeleteButton();
}
function applySelectedCmapStyle(event) {
const selectedId = event?.currentTarget?.value ?? $("cmap-concept-style-preset").value;
$("cmap-concept-quick-style").value = selectedId;
$("cmap-concept-style-preset").value = selectedId;
const style = cmapStyles.find((candidate) => candidate.id === selectedId);
if (!style) {
updateCmapStyleDeleteButton();
return;
}
applyCmapAppearance(style.values);
updateCmapStyleDeleteButton();
}
function newCmapStyleId() {
try {
if (window.crypto && typeof window.crypto.randomUUID === "function") {
return `custom-${window.crypto.randomUUID()}`;
}
} catch (_error) {
// Fall back to a timestamp when randomUUID is unavailable in this context.
}
return `custom-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
async function saveCurrentCmapStyle() {
const selected = cmapStyles.find((style) => style.id === $("cmap-concept-style-preset").value);
const proposedName = selected && !selected.nameKey ? selected.name : "";
const name = window.prompt(tr("style-name-prompt", "Name for this style"), proposedName);
if (name === null) return;
const cleanName = name.trim();
if (!cleanName) {
window.alert(tr("style-name-required", "Enter a style name."));
return;
}
const existing = cmapStyles.find((style) =>
cmapStyleName(style).toLocaleLowerCase() === cleanName.toLocaleLowerCase());
if (existing?.protected) {
window.alert(tr("default-style-protected", "The default style cannot be changed or deleted."));
return;
}
if (existing && !window.confirm(tr(
"replace-style-confirm", 'Replace the existing style "{name}"?').replace("{name}", cmapStyleName(existing)))) return;
const replacement = {
id: existing?.id || newCmapStyleId(),
name: cleanName,
protected: false,
values: captureCmapAppearance()
};
const previousStyles = cmapStyles;
cmapStyles = existing ? cmapStyles.map((style) => style.id === existing.id ? replacement : style) :
[...cmapStyles, replacement];
if (!await storeCmapStyles()) {
cmapStyles = previousStyles;
return;
}
renderCmapStyleOptions(replacement.id);
}
async function deleteSelectedCmapStyle() {
const selected = cmapStyles.find((style) => style.id === $("cmap-concept-style-preset").value);
if (!selected) return;
if (selected.protected) {
window.alert(tr("default-style-protected", "The default style cannot be changed or deleted."));
return;
}
if (!window.confirm(tr(
"delete-style-confirm", 'Delete style "{name}"?').replace("{name}", cmapStyleName(selected)))) return;
const previousStyles = cmapStyles;
cmapStyles = cmapStyles.filter((style) => style.id !== selected.id);
if (!await storeCmapStyles()) {
cmapStyles = previousStyles;
return;
}
renderCmapStyleOptions();
}
function setCmapZoom(value) {
const prototype = cmapPrototypeState();
const percent = Math.max(25, Math.min(300, Number(value) || 100));
$("cmap-zoom-percent").value = String(percent);
try {
window.localStorage.setItem(cmapZoomStorageKey(), String(percent));
} catch (error) {
console.warn("The CMap zoom factor could not be stored.", error);
}
const canvas = $("cmap-canvas");
canvas.style.setProperty("--cmap-a4-width", `${1123 * percent / 100}px`);
canvas.style.setProperty("--cmap-a4-height", `${794 * percent / 100}px`);
return prototype.editor ? prototype.editor.setZoom(percent) : percent;
}
function setCmapPageGuides(visible) {
$("cmap-canvas").classList.toggle("cmap-page-guides-hidden", !visible);
$("cmap-toggle-page-guides").setAttribute("aria-checked", String(visible));
try {
window.localStorage.setItem("racket-wiki:cmap-a4-page-guides", visible ? "true" : "false");
} catch (error) {
console.warn("The CMap page-boundary preference could not be stored.", error);
}
}
function restoreCmapPageGuides() {
let visible = true;
try {
visible = window.localStorage.getItem("racket-wiki:cmap-a4-page-guides") !== "false";
} catch (error) {
console.warn("The CMap page-boundary preference could not be read.", error);
}
setCmapPageGuides(visible);
}
function showCmapStatus(message, temporary = false) {
const status = $("cmap-save-status");
window.clearTimeout(cmapStatusTimer);
status.textContent = message || "";
status.classList.toggle("cmap-status-visible", Boolean(message));
if (message && temporary) {
cmapStatusTimer = window.setTimeout(() => {
status.classList.remove("cmap-status-visible");
status.textContent = "";
}, 1800);
}
}
function renderHiddenCmapItems() {
const details = $("cmap-hidden-items");
const options = $("cmap-hidden-item-options");
const prototype = cmapPrototypeState();
const hidden = prototype.editor ? prototype.editor.hiddenItemsInCurrentContext() : [];
details.classList.toggle("hidden", hidden.length === 0);
details.querySelector("summary").textContent = hidden.length ?
`${tr("hidden-concepts", "Hidden concepts")} (${hidden.length})` :
tr("hidden-concepts", "Hidden concepts");
options.replaceChildren();
for (const record of hidden) {
const button = document.createElement("button");
button.type = "button";
button.textContent = record.label;
button.title = tr("show-hidden-concept", "Show this concept again");
button.addEventListener("click", () => {
prototype.editor.showItemInCurrentContext(record);
details.open = false;
});
options.append(button);
}
}
function cmapZoomStorageKey() {
const storedMap = state.currentConceptMap ? state.currentConceptMap.slug : "unsaved";
const prototype = cmapPrototypeState();
const activeMap = prototype.editor && prototype.editor.activeMapRoot &&
prototype.editor.activeMapRoot.mapReference ? prototype.editor.activeMapRoot.mapReference.id : "root";
return `racket-wiki:cmap-zoom:${storedMap}:${activeMap}`;
}
function restoreCmapZoom() {
let stored = 100;
try {
stored = Number(window.localStorage.getItem(cmapZoomStorageKey()));
} catch (error) {
console.warn("The stored CMap zoom factor could not be read.", error);
}
return setCmapZoom(stored >= 25 && stored <= 300 ? stored : 100);
}
function closeCmapContextMenu() {
$("cmap-context-menu").classList.add("hidden");
$("cmap-tools-menu").setAttribute("aria-expanded", "false");
}
function updateCmapSelectionToolbar(editor, selectedRecords = [], connector = null) {
const toolbar = $("cmap-selection-toolbar");
const selected = Array.isArray(selectedRecords) ? selectedRecords : [];
toolbar.dataset.selectionCount = String(selected.length);
for (const button of toolbar.querySelectorAll("[data-cmap-layout]")) {
button.disabled = !editor || !editor.canLayoutSelection(button.dataset.cmapLayout);
}
const actionButtons = new Map(Array.from(
toolbar.querySelectorAll("[data-cmap-selection-action]"),
(button) => [button.dataset.cmapSelectionAction, button]
));
actionButtons.get("edit").disabled = !editor || selected.length !== 1 || Boolean(connector);
actionButtons.get("group").disabled = !editor || !editor.canGroupSelection();
actionButtons.get("ungroup").disabled = !editor || !editor.canUngroupSelection();
actionButtons.get("hide").disabled = !editor || !editor.canHideSelectionInCurrentContext();
}
function openCmapContextMenu(clientX, clientY, createContext = {}) {
const menu = $("cmap-context-menu");
cmapContextCreateContext = createContext;
// The CMap library assigns ever-increasing z-indices to map components.
// Keep this fixed-position menu outside that stacking context.
if (menu.parentElement !== document.body) document.body.append(menu);
menu.classList.remove("hidden");
const left = Math.max(8, Math.min(clientX, window.innerWidth - menu.offsetWidth - 8));
const top = Math.max(8, Math.min(clientY, window.innerHeight - menu.offsetHeight - 8));
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
$("cmap-tools-menu").setAttribute("aria-expanded", "true");
}
function cmapPlacementOptions() {
const context = cmapContextCreateContext || {};
const options = {};
if (context.point) {
options.x = Math.max(0, context.point.x - 70);
options.y = Math.max(0, context.point.y - 30);
}
if (context.parentSubmap) {
options.parentSubmap = context.parentSubmap;
options.submapDepth = context.parentSubmap.submapDepth + 1;
}
return options;
}
/**
* goal : Edit the selected concept presentation and synopsis.
* pre : One concept, page, submap or linking phrase is selected.
* post : A concept dialog or the inline phrase editor is opened.
*/
function editSelectedCmapNode(record = null) {
const prototype = cmapPrototypeState();
const selectedRecord = record || (prototype.editor ? prototype.editor.selected() : null);
if (!selectedRecord) {
window.alert(tr("select-one-concept", "Select one concept first."));
return;
}
if (selectedRecord.kind === "phrase") {
prototype.editor.editPhraseInline(selectedRecord);
return;
}
openCmapConceptDialog(selectedRecord);
}
function cmapKeyboardEditingTarget(target) {
return target instanceof Element && Boolean(
target.closest("input, textarea, select, [contenteditable='true']"));
}
function handleCmapKeyboardShortcut(event) {
if ($("cmap-view").classList.contains("hidden") || $("cmap-concept-dialog").open) return;
if (cmapKeyboardEditingTarget(event.target)) return;
const prototype = cmapPrototypeState();
const editor = prototype.editor;
if (!editor) return;
const commandKey = event.ctrlKey || event.metaKey;
const key = event.key.toLocaleLowerCase();
if (commandKey && !event.altKey && key === "a") {
event.preventDefault();
editor.selectAll();
return;
}
if (commandKey && !event.altKey && key === "c") {
if (editor.copySelectionReferences()) event.preventDefault();
return;
}
if (commandKey && !event.altKey && key === "x") {
if (editor.cutSelectionReferences()) event.preventDefault();
return;
}
if (commandKey && !event.altKey && key === "v") {
if (editor.canPasteConceptReferences()) {
event.preventDefault();
editor.pasteConceptReferences();
}
return;
}
if (commandKey && !event.altKey && key === "z") {
event.preventDefault();
if (event.shiftKey) {
editor.redo();
} else {
editor.undo();
}
return;
}
if (commandKey && !event.altKey && !event.shiftKey && key === "y") {
event.preventDefault();
editor.redo();
return;
}
if (commandKey && !event.altKey && key === "g") {
event.preventDefault();
if (event.shiftKey) {
editor.ungroupSelection();
} else {
groupSelectedCmapItems();
}
return;
}
if (!commandKey && !event.altKey && !event.shiftKey &&
(event.key === "Delete" || event.key === "Backspace")) {
event.preventDefault();
editor.deleteSelection();
return;
}
if (!commandKey && !event.altKey && !event.shiftKey && event.key === "Escape") {
editor.clearSelection();
return;
}
if (event.key === "F2" && !event.repeat && !commandKey && !event.altKey &&
editor.selectedAll().length === 1) {
event.preventDefault();
editor.editSelected();
}
}
/**
* goal : Add a labelled relation between two sample items.
* pre : source and target are records from the active prototype.
* post : source -> linking phrase -> target is visible.
* result : The new linking-phrase record.
*/
function connectCmapPrototypeNodes(source, target, label) {
const prototype = cmapPrototypeState();
if (!prototype.editor) return null;
return prototype.editor.connectWithPhrase(source, target, label || "?????", false);
}
function groupSelectedCmapItems() {
const prototype = cmapPrototypeState();
if (!prototype.editor || !prototype.editor.canGroupSelection()) return false;
const label = window.prompt(
tr("group-submap-name", "Name of the main concept for the new sub-CMap"),
tr("sub-concept-map", "Sub concept map"));
if (!label || !label.trim()) return false;
return prototype.editor.groupSelection({
label: label.trim(),
conceptId: sharedCmapConceptId(label),
childMap: label.trim(),
synopsis: tr("grouped-submap-synopsis", "Grouped sub-concept map.")
});
}
function populateCmapPrototypeSubmap(record, editor) {
const baseX = Number(record.node.attr("x"));
const baseY = Number(record.node.attr("y"));
const detail = editor.addSubmapItem(record, {
label: "Detail concept",
synopsis: "Concept inside the expanded submap.",
x: baseX + 315,
y: baseY + 170,
backgroundColor: "#fff4cf",
borderColor: "#a97c00"
});
editor.connectWithPhrase(record, detail, "contains", false);
if (record.submapDepth < 2) {
const nested = editor.addSubmapItem(record, {
label: "Nested submap",
synopsis: "This submap can also be expanded.",
kind: "submap",
childMap: `${record.childMap || record.label}/nested`,
x: baseX + 60,
y: baseY + 310,
backgroundColor: "#edf7e8",
borderColor: "#57834a"
});
editor.connectWithPhrase(detail, nested, "contains", false);
}
}
/**
* goal : Build a small editable CmapTools-like sample around the current page.
* pre : The bundled racket-wiki cmap component is loaded.
* post : Concepts can be selected, resized and linked by dragging the relation handle.
*/
function resetCmapPrototype(cmapDocument = null, includeSample = true) {
const canvas = $("cmap-canvas");
cancelCmapAutosave();
state.cmapSavedSnapshot = null;
closeCmapContextMenu();
$("cmap-map-navigation").classList.add("hidden");
$("cmap-active-map-title").textContent = "";
if (state.cmapPrototype && state.cmapPrototype.editor &&
typeof state.cmapPrototype.editor.destroy === "function") {
state.cmapPrototype.editor.destroy();
}
canvas.replaceChildren();
state.cmapPrototype = null;
updateCmapSelectionToolbar(null, [], null);
console.info("[racket-wiki:cmap-host 0.2.122] resetCmapPrototype", {
cmapAvailable: typeof window.Cmap === "function",
interactionLayerAvailable: Boolean(window.RacketWikiCmap),
interactionLayerVersion: window.RacketWikiCmap ? window.RacketWikiCmap.version : null,
cmapStylesheet: Array.from(document.styleSheets)
.map((sheet) => sheet.href)
.find((href) => href && href.includes("/cmap/cmap.css")) || null
});
if (typeof window.Cmap !== "function" || !window.RacketWikiCmap) {
const message = document.createElement("p");
message.className = "error";
message.textContent = "The bundled cmap component could not be loaded.";
canvas.append(message);
return;
}
const prototype = cmapPrototypeState();
prototype.editor = window.RacketWikiCmap.createEditor(canvas, {
Cmap: window.Cmap,
renderItem: (record) => cmapNodeHtml(record),
onOpenPage: (record) => {
if (record.pageSlug) {
navigateToHash(pageRoute(record.pageSlug)).catch((error) => console.error(error));
}
},
onOpenCmap: (record) => {
if (record.cmapSlug) {
navigateToHash(cmapRoute(record.cmapSlug)).catch((error) => console.error(error));
}
},
onOpenExternalUrl: (record) => openCmapExternalUrl(record),
onOpenStoredSubMap: (record) => {
if (record.cmapSlug) {
navigateToHash(cmapRoute(record.cmapSlug)).catch((error) => console.error(error));
}
},
onOpenBoundaryReference: () => {
const source = state.currentConceptMapSource;
if (source) {
navigateToHash(cmapRoute(source.slug)).catch((error) => console.error(error));
}
},
onOpenSubMap: (record) => {
console.info("[racket-wiki:cmap-host 0.2.122] submap state changed", {
id: record.id,
expanded: record.expanded,
childMap: record.childMap
});
},
onPopulateSubMap: (record, editor) => populateCmapPrototypeSubmap(record, editor),
onConfirmDetachFromSubmap: (record, parent) => window.confirm(
tr("detach-submap-confirm", "Place \"{concept}\" outside submap \"{submap}\"?")
.replace("{concept}", record.label)
.replace("{submap}", parent.label)),
onSelectionChange: (record, connector, selectedRecords) => {
const selected = Array.isArray(selectedRecords) ? selectedRecords : (record ? [record] : []);
const canExtractSubmap = selected.length === 1 && record &&
record.kind === "submap";
$("cmap-promote-submap").disabled = !canExtractSubmap;
$("cmap-promote-submap").title = "";
$("cmap-extract-selected").disabled = !canExtractSubmap;
$("cmap-extract-selected").classList.toggle("hidden", !canExtractSubmap);
$("cmap-edit-selected").disabled = selected.length !== 1;
$("cmap-group-selected").disabled = !prototype.editor.canGroupSelection();
$("cmap-ungroup-selected").disabled = !prototype.editor.canUngroupSelection();
$("cmap-hide-selected").disabled = !prototype.editor.canHideSelectionInCurrentContext();
$("cmap-delete-selected").disabled = selected.length === 0 && !connector;
$("cmap-cut-selected").disabled = !prototype.editor.canCutSelectionReferences();
$("cmap-copy-selected").disabled = !selected.some((item) =>
item.conceptId && item.kind !== "phrase");
$("cmap-paste-concepts").disabled = !prototype.editor.canPasteConceptReferences();
updateCmapSelectionToolbar(prototype.editor, selected, connector);
},
onAutomaticLayoutChange: ({ beforeSnapshot, afterSnapshot }) => {
// Asynchronous text/image measurement is renderer normalization, not
// an edit. Advance the baseline only when it still equals the exact
// pre-layout state, so a real intervening user change is never hidden.
if (state.cmapSavedSnapshot === beforeSnapshot) {
state.cmapSavedSnapshot = afterSnapshot;
}
},
onHistoryChange: ({ canUndo, canRedo }) => {
$("cmap-undo").disabled = !canUndo;
$("cmap-redo").disabled = !canRedo;
scheduleCmapAutosave();
},
onMapChange: (mapReference) => {
$("cmap-map-navigation").classList.toggle("hidden", !mapReference);
$("cmap-active-map-title").textContent = mapReference ? mapReference.title : "";
restoreCmapZoom();
renderHiddenCmapItems();
},
onVisibilityChange: () => renderHiddenCmapItems(),
onEditItem: (record) => editSelectedCmapNode(record),
onCreateConnectedItem: (context) => openNewCmapConceptDialog(context),
createRelationLabel: tr("create-relation", "Create relation"),
editConceptLabel: tr("edit-concept", "Edit concept"),
resizeConceptLabel: tr("resize-concept", "Resize concept"),
relationLabel: tr("relation", "Relation")
});
restoreCmapZoom();
console.info("[racket-wiki:cmap-host 0.2.122] editor stored", {
editorAvailable: Boolean(prototype.editor),
canvasChildCount: canvas.children.length
});
if (cmapDocument) {
prototype.editor.loadDocument(cmapDocument);
renderHiddenCmapItems();
return;
}
if (!includeSample) {
prototype.editor.resetHistory();
return;
}
const page = state.currentPage || state.pages[0] || null;
const pageNode = addCmapPrototypeNode({
label: page ? page.title : state.siteTitle,
synopsis: page ? currentPageSynopsis() : "Wiki page concept",
kind: "page",
pageSlug: page ? page.slug : null,
x: 365,
y: 220,
width: 270,
height: 125,
backgroundColor: "#e7f2fb",
borderColor: "#4479a1"
});
const conceptNode = addCmapPrototypeNode({
label: tr("context", "Context"),
synopsis: "A free concept without a wiki page.",
x: 70,
y: 120,
backgroundColor: "#fff4cf",
borderColor: "#a97c00"
});
const subMapNode = addCmapPrototypeNode({
label: tr("sub-concept-map", "Sub concept map"),
synopsis: "Placeholder for an expandable child map.",
kind: "submap",
childMap: "prototype-child",
x: 690,
y: 360,
backgroundColor: "#edf7e8",
borderColor: "#57834a"
});
connectCmapPrototypeNodes(conceptNode, pageNode, "describes");
connectCmapPrototypeNodes(pageNode, subMapNode, "contains");
prototype.editor.clearSelection();
prototype.editor.resetHistory();
}
/**
* goal : Open the persistent CMap workspace without changing wiki pages.
* pre : User can read the wiki frontend.
* post : The selected stored CMap or the unsaved starter map is displayed.
*/
function renderConceptMapSelector() {
const input = $("cmap-map-select");
input.placeholder = state.conceptMaps.length ?
tr("select-concept-map", "Select a CMap") :
tr("no-concept-maps", "No saved CMaps");
cmapMapCombobox.setOptions(
state.conceptMaps.map((conceptMap) => titledCmapComboboxEntry(conceptMap)),
state.currentConceptMap ? state.currentConceptMap.slug : "");
const hasStoredMap = Boolean(state.currentConceptMap);
const referenceButton = $("cmap-wiki-reference");
referenceButton.classList.toggle("hidden", !hasStoredMap);
if (hasStoredMap) {
referenceButton.textContent = `${state.currentConceptMap.title} · cmap:${state.currentConceptMap.slug}`;
referenceButton.dataset.markdown =
`[${escapeMarkdownLinkLabel(state.currentConceptMap.title)}](cmap:${state.currentConceptMap.slug})`;
} else {
referenceButton.textContent = "";
delete referenceButton.dataset.markdown;
}
$("cmap-rename-map").disabled = !hasStoredMap;
$("cmap-edit-metadata").disabled = !hasStoredMap;
$("cmap-export-markdown").disabled = !hasStoredMap;
$("cmap-set-start-map").disabled = !hasStoredMap;
$("cmap-set-start-map").textContent = hasStoredMap &&
startCmapSlug() === state.currentConceptMap.slug ?
tr("start-concept-map", "Start CMap") :
tr("set-start-concept-map", "Use as start CMap");
$("cmap-delete-map").disabled = !hasStoredMap;
$("cmap-create-snapshot").disabled = !hasStoredMap;
$("cmap-history").disabled = !hasStoredMap;
}
async function loadConceptMaps() {
const [result, usageResult] = await Promise.all([
api("/api/cmaps"),
api("/api/cmaps/concept-usage").catch((error) => {
console.warn("[racket-wiki:cmap-host 0.2.122] concept usage is unavailable; CMaps continue without global counts", error);
return { placements: [] };
})
]);
state.conceptMaps = Array.isArray(result.conceptMaps) ? result.conceptMaps : [];
state.cmapConceptUsage = new Map();
state.cmapConceptIdsByName = new Map();
state.cmapPageConcepts = new Map();
const placements = Array.isArray(usageResult.placements) ? usageResult.placements : [];
const pageByConcept = new Map();
for (const placement of placements) {
if (!placement?.conceptId || !placement.pageSlug) continue;
pageByConcept.set(
placement.conceptId,
canonicalPageReference(placement.pageSlug).toLocaleLowerCase());
}
for (const placement of placements) {
if (!placement || !placement.conceptId || !placement.cmapSlug) continue;
const nameKey = normalizeCmapConceptName(placement.label);
if (nameKey) state.cmapConceptIdsByName.set(nameKey, placement.conceptId);
if (!state.cmapConceptUsage.has(placement.conceptId)) {
state.cmapConceptUsage.set(placement.conceptId, new Map());
}
state.cmapConceptUsage.get(placement.conceptId)
.set(placement.cmapSlug, Number(placement.count) || 0);
const pageKey = pageByConcept.get(placement.conceptId);
if (!pageKey) continue;
if (!state.cmapPageConcepts.has(pageKey)) state.cmapPageConcepts.set(pageKey, new Map());
const pageConcepts = state.cmapPageConcepts.get(pageKey);
if (!pageConcepts.has(placement.conceptId)) {
pageConcepts.set(placement.conceptId, {
conceptId: placement.conceptId,
label: placement.label || tr("concept", "Concept"),
count: 0,
maps: new Map()
});
}
const concept = pageConcepts.get(placement.conceptId);
const count = Number(placement.count) || 0;
concept.count += count;
const existingMap = concept.maps.get(placement.cmapSlug);
concept.maps.set(placement.cmapSlug, {
slug: placement.cmapSlug,
title: placement.cmapTitle || placement.cmapSlug,
count: count + (existingMap?.count || 0)
});
}
renderConceptMapSelector();
const prototype = cmapPrototypeState();
if (prototype.editor) prototype.editor.refreshConceptUsageIndicators();
if (state.currentPage) renderPageCmapConnections(state.currentPage);
return state.conceptMaps;
}
function normalizeCmapConceptIdentities(documentValue, cmapSlug) {
const legacyIds = new Map();
const normalize = (conceptId) => {
const prefixedUuid = typeof conceptId === "string" && conceptId.match(
/^concept-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i);
if (prefixedUuid) return prefixedUuid[1].toLowerCase();
if (!conceptId || !/^concept-\d+$/.test(conceptId) || !cmapSlug) return conceptId;
if (!legacyIds.has(conceptId)) legacyIds.set(conceptId, `legacy:${cmapSlug}:${conceptId}`);
return legacyIds.get(conceptId);
};
for (const concept of (Array.isArray(documentValue.concepts) ? documentValue.concepts : [])) {
concept.id = normalize(concept.id);
}
for (const item of (Array.isArray(documentValue.items) ? documentValue.items : [])) {
item.conceptId = normalize(item.conceptId);
}
return documentValue;
}
function decodeStoredConceptMapDocument(conceptMap) {
let documentValue = conceptMap.document;
for (let attempt = 0; attempt < 2 && typeof documentValue === "string"; attempt += 1) {
documentValue = JSON.parse(documentValue);
}
if (!documentValue || typeof documentValue !== "object" || Array.isArray(documentValue)) {
console.error("[racket-wiki:cmap-host 0.2.122] invalid stored CMap document", {
slug: conceptMap.slug,
valueType: Array.isArray(documentValue) ? "array" : typeof documentValue,
value: documentValue
});
throw new Error("The stored CMap document is not a JSON object.");
}
return normalizeCmapConceptIdentities(documentValue, conceptMap.slug || "");
}
function currentCmapSnapshot() {
const prototype = cmapPrototypeState();
if (!prototype.editor) return null;
return JSON.stringify(prototype.editor.toDocument());
}
function currentCmapStorageMap() {
return state.currentConceptMapSource || state.currentConceptMap;
}
function markCurrentCmapSaved(snapshot = currentCmapSnapshot()) {
state.cmapSavedSnapshot = snapshot;
}
function cmapHasUnsavedChanges() {
if ($("cmap-view").classList.contains("hidden")) return false;
const currentSnapshot = currentCmapSnapshot();
return currentSnapshot !== null && state.cmapSavedSnapshot !== null &&
currentSnapshot !== state.cmapSavedSnapshot;
}
function cancelCmapAutosave() {
if (cmapAutosaveTimer !== null) {
window.clearTimeout(cmapAutosaveTimer);
cmapAutosaveTimer = null;
}
}
function scheduleCmapAutosave() {
cancelCmapAutosave();
if (!can("editor") || !state.currentConceptMap || !cmapHasUnsavedChanges()) return;
showCmapStatus(tr("autosave-pending", "Changes waiting to be saved"));
cmapAutosaveTimer = window.setTimeout(() => {
cmapAutosaveTimer = null;
saveStoredConceptMap({ automatic: true }).catch((error) => console.error(error));
}, CMAP_AUTOSAVE_DELAY);
}
function updateStoredConceptMapSummary(conceptMap) {
const index = state.conceptMaps.findIndex((item) => item.slug === conceptMap.slug);
if (index >= 0) {
state.conceptMaps[index] = { ...state.conceptMaps[index], ...conceptMap };
} else {
state.conceptMaps.push(conceptMap);
}
renderConceptMapSelector();
}
function continueCmapTransition() {
const transition = pendingCmapTransition;
if (!transition) return;
pendingCmapTransition = null;
$("cmap-unsaved-dialog").close();
Promise.resolve()
.then(transition.action)
.then(() => transition.resolve(true))
.catch((error) => {
transition.reject(error);
});
}
function discardCmapChangesAndContinue() {
markCurrentCmapSaved();
continueCmapTransition();
}
function cancelCmapTransition() {
const transition = pendingCmapTransition;
pendingCmapTransition = null;
$("cmap-unsaved-dialog").close();
if (transition) transition.resolve(false);
renderConceptMapSelector();
}
function requestCmapTransition(action) {
if (!cmapHasUnsavedChanges()) {
return Promise.resolve()
.then(action)
.then(() => true);
}
if (pendingCmapTransition) return Promise.resolve(false);
return new Promise((resolve, reject) => {
pendingCmapTransition = { action, resolve, reject };
$("cmap-unsaved-dialog").showModal();
});
}
async function openStoredConceptMap(slug) {
if (!slug) return;
const loadSequence = ++state.cmapLoadSequence;
showCmapStatus(tr("loading", "Loading…"));
const conceptMap = await api(`/api/cmaps/${encodeURIComponent(slug)}`);
if (loadSequence !== state.cmapLoadSequence) return;
conceptMap.document = decodeStoredConceptMapDocument(conceptMap);
const derivedView = conceptMap.document.derivedView;
let sourceMap = null;
let editorDocument = conceptMap.document;
if (derivedView && typeof derivedView === "object" &&
typeof derivedView.sourceCmapSlug === "string" && derivedView.sourceCmapSlug &&
Number.isInteger(Number(derivedView.rootItemId))) {
sourceMap = await api(`/api/cmaps/${encodeURIComponent(derivedView.sourceCmapSlug)}`);
if (loadSequence !== state.cmapLoadSequence) return;
sourceMap.document = decodeStoredConceptMapDocument(sourceMap);
editorDocument = sourceMap.document;
}
console.info("[racket-wiki:cmap-host 0.2.122] stored CMap received", {
slug: conceptMap.slug,
version: conceptMap.currentVersion,
itemCount: Array.isArray(conceptMap.document.items) ? conceptMap.document.items.length : 0,
connectorCount: Array.isArray(conceptMap.document.connectors) ? conceptMap.document.connectors.length : 0
});
state.currentConceptMap = conceptMap;
state.currentConceptMapSource = sourceMap;
renderConceptMapSelector();
resetCmapPrototype(editorDocument, false);
if (sourceMap) {
const editor = cmapPrototypeState().editor;
const root = editor.itemRecord(derivedView.rootItemId);
if (!root || root.kind !== "submap") {
throw new Error("The source sub-CMap no longer exists.");
}
root.separateMap = true;
root.cmapSlug = conceptMap.slug;
root.childMap = conceptMap.title;
if (!root.mapReference) {
root.mapReference = {
id: `cmap-${root.id}`,
title: conceptMap.title,
rootItemId: root.id,
itemIds: editor.descendantItemRecords(root).map((item) => item.id)
};
editor.setConceptMapReference(root.mapReference);
}
editor.openSubmapMap(root);
}
markCurrentCmapSaved();
const loadedEditor = cmapPrototypeState().editor;
console.info("[racket-wiki:cmap-host 0.2.122] stored CMap loaded", {
slug: conceptMap.slug,
editorAvailable: Boolean(loadedEditor),
itemCount: loadedEditor ? loadedEditor.itemCount() : 0,
connectorCount: loadedEditor ? loadedEditor.connectorCount() : 0
});
showCmapStatus(tr("concept-map-loaded", "CMap loaded"), true);
}
async function loadHistoricalConceptMapVersion(version) {
const conceptMap = currentCmapStorageMap();
if (!conceptMap) return;
const historical = await api(
`/api/cmaps/${encodeURIComponent(conceptMap.slug)}/versions/${encodeURIComponent(version)}`);
historical.document = decodeStoredConceptMapDocument(historical);
const currentSnapshot = JSON.stringify(conceptMap.document);
resetCmapPrototype(historical.document, false);
state.cmapSavedSnapshot = currentSnapshot;
showCmapStatus(
tr("concept-map-version-loaded", "Version {version} loaded; save to make it current.")
.replace("{version}", String(historical.version)),
true);
}
async function showConceptMapHistory() {
const conceptMap = currentCmapStorageMap();
if (!conceptMap) return;
const result = await api(`/api/cmaps/${encodeURIComponent(conceptMap.slug)}/history`);
const list = $("cmap-history-list");
list.replaceChildren();
const versions = result.versions || [];
if (!versions.length) {
const empty = document.createElement("p");
empty.className = "muted cmap-history-empty";
empty.textContent = tr("no-concept-map-history", "No snapshots or manual saves yet.");
list.append(empty);
}
for (const version of versions) {
const row = document.createElement("div");
row.className = "cmap-history-row";
const label = document.createElement("div");
const heading = document.createElement("strong");
heading.textContent = `${tr("version", "Version")} ${version.version} — ${version.title}`;
const meta = document.createElement("div");
meta.className = "muted";
const knownSummaries = {
create: tr("concept-map-created-version", "CMap created"),
rename: tr("concept-map-renamed-version", "CMap renamed")
};
let summary = knownSummaries[version.action] || version.summary;
if (version.action === "snapshot") {
const snapshotLabel = tr("snapshot", "Snapshot");
if (version.summary === "Current state when CMap history was enabled") {
summary = tr("concept-map-initial-version", "Initial available version");
} else {
summary = version.summary === snapshotLabel ? snapshotLabel : `${snapshotLabel} — ${version.summary}`;
}
}
if (version.summary === "Automatic save") summary = tr("automatic-save", "Automatic save");
if (version.summary === "Manual save") summary = tr("manual-save", "Manual save");
meta.textContent = `${pageDisplayDate(version.createdAt)} · ${version.author} · ${summary}`;
label.append(heading, document.createElement("br"), meta);
const load = document.createElement("button");
load.type = "button";
load.textContent = version.version === conceptMap.currentVersion ?
tr("current-version", "Current") : tr("load-version", "Load version");
load.disabled = version.version === conceptMap.currentVersion;
load.addEventListener("click", () => {
$("cmap-history-dialog").close();
requestCmapTransition(() => loadHistoricalConceptMapVersion(version.version))
.catch((error) => showCmapStatus(error.message));
});
const actions = document.createElement("div");
actions.className = "cmap-history-actions";
actions.append(load);
if (can("editor")) {
const remove = document.createElement("button");
remove.type = "button";
remove.textContent = tr("delete-history-item", "Delete");
remove.addEventListener("click", async () => {
const question = tr(
"delete-concept-map-history-confirm",
"Delete CMap history version {version}? The current CMap will not be changed.")
.replace("{version}", String(version.version));
if (!window.confirm(question)) return;
remove.disabled = true;
try {
await api(
`/api/cmaps/${encodeURIComponent(conceptMap.slug)}/versions/${encodeURIComponent(version.version)}`,
{ method: "DELETE" });
row.remove();
if (!list.querySelector(".cmap-history-row")) {
const empty = document.createElement("p");
empty.className = "muted cmap-history-empty";
empty.textContent = tr("no-concept-map-history", "No snapshots or manual saves yet.");
list.append(empty);
}
showCmapStatus(tr("concept-map-history-deleted", "History item deleted"), true);
} catch (error) {
remove.disabled = false;
showCmapStatus(error.message);
}
});
actions.append(remove);
}
row.append(label, actions);
list.append(row);
}
$("cmap-history-dialog").showModal();
}
async function createStoredConceptMap() {
const title = window.prompt(tr("concept-map-name", "Concept map name"), "");
if (!title || !title.trim()) return;
const conceptMap = await api("/api/cmaps", {
method: "POST",
body: JSON.stringify({
title: title.trim(),
document: {
schemaVersion: 2,
metadata: { tags: [], summary: "", explanationPageSlug: "" },
concepts: [], items: [], connectors: [], conceptMaps: []
}
})
});
await loadConceptMaps();
location.hash = cmapRoute(conceptMap.slug);
}
async function promoteSelectedSubmapToStoredMap() {
const prototype = cmapPrototypeState();
const editor = prototype.editor;
const record = editor ? editor.selected() : null;
if (!record || record.kind !== "submap") return false;
const hasConcepts = editor.descendantItemRecords(record)
.some((item) => item.kind !== "phrase");
if (!hasConcepts) {
showCmapStatus(tr("empty-submap", "This sub-CMap has no concepts to move."));
return false;
}
const sourceMap = currentCmapStorageMap();
let linkedMap = null;
let title = null;
if (record.cmapSlug) {
linkedMap = state.currentConceptMap?.slug === record.cmapSlug ?
{ ...state.currentConceptMap } :
await api(`/api/cmaps/${encodeURIComponent(record.cmapSlug)}`);
linkedMap.document = decodeStoredConceptMapDocument(linkedMap);
const derivedView = linkedMap.document.derivedView;
const matchesSource = sourceMap && derivedView &&
derivedView.sourceCmapSlug === sourceMap.slug &&
Number(derivedView.rootItemId) === Number(record.id);
if (!matchesSource) {
showCmapStatus(tr("submap-already-independent",
"This linked CMap is already independent."), true);
await navigateToHash(cmapRoute(record.cmapSlug));
return true;
}
} else {
title = window.prompt(
tr("submap-name", "Name of the new concept map"),
record.childMap || record.label);
if (!title || !title.trim()) return false;
}
const buttons = [$("cmap-promote-submap"), $("cmap-extract-selected")];
for (const button of buttons) button.disabled = true;
try {
const sourceSaved = await saveStoredConceptMap({
force: true,
snapshotVersion: true,
summary: tr("before-submap-extraction", "Before extracting sub-CMap")
});
if (!sourceSaved) return false;
let storedMap = linkedMap;
let extraction;
if (storedMap) {
extraction = editor.prepareStoredSubmapExtraction(
record, storedMap.slug, storedMap.document.metadata);
storedMap = await api(`/api/cmaps/${encodeURIComponent(storedMap.slug)}`, {
method: "PUT",
body: JSON.stringify({
title: storedMap.title,
baseVersion: storedMap.currentVersion,
summary: tr("submap-extracted", "Sub-CMap moved to a separate CMap"),
saveKind: "manual",
document: extraction.childDocument
})
});
} else {
const prepared = editor.prepareStoredSubmapExtraction(record, null);
if (!prepared) return false;
storedMap = await api("/api/cmaps", {
method: "POST",
body: JSON.stringify({
title: title.trim(),
document: prepared.childDocument
})
});
extraction = editor.prepareStoredSubmapExtraction(record, storedMap.slug);
}
editor.replaceDocument(extraction.parentDocument);
const parentSaved = await saveStoredConceptMap({
force: true,
historyMode: "autosave",
summary: tr("submap-extracted", "Sub-CMap moved to a separate CMap")
});
if (!parentSaved) {
showCmapStatus(tr("submap-created-parent-unsaved",
"The new CMap was created, but the parent CMap still needs to be saved."));
return false;
}
await navigateToHash(cmapRoute(storedMap.slug));
return true;
} finally {
const selected = editor.selected();
const canExtractSubmap = selected && selected.kind === "submap";
for (const button of buttons) button.disabled = !canExtractSubmap;
$("cmap-extract-selected").classList.toggle("hidden", !canExtractSubmap);
}
}
async function renameStoredConceptMap() {
cancelCmapAutosave();
if (cmapHasUnsavedChanges() && !await saveStoredConceptMap({ automatic: true })) return false;
const conceptMap = state.currentConceptMap;
if (!conceptMap) return false;
const title = window.prompt(
tr("rename-concept-map", "Rename CMap"),
conceptMap.title);
if (!title || !title.trim() || title.trim() === conceptMap.title) return false;
try {
state.currentConceptMap = await api(
`/api/cmaps/${encodeURIComponent(conceptMap.slug)}/rename`,
{
method: "POST",
body: JSON.stringify({
title: title.trim(),
baseVersion: conceptMap.currentVersion
})
});
await loadConceptMaps();
showCmapStatus(tr("concept-map-renamed", "CMap renamed"), true);
return true;
} catch (error) {
showCmapStatus(error.message);
return false;
}
}
function activeCmapMetadata() {
const currentDocument = state.currentConceptMap?.document;
if (currentDocument?.derivedView) {
const metadata = currentDocument.metadata || {};
return {
tags: Array.isArray(metadata.tags) ? metadata.tags : [],
summary: metadata.summary || "",
explanationPageSlug: metadata.explanationPageSlug || ""
};
}
const editor = cmapPrototypeState().editor;
return editor?.getDocumentMetadata ? editor.getDocumentMetadata() :
{ tags: [], summary: "", explanationPageSlug: "" };
}
function openCmapMetadataDialog() {
if (!state.currentConceptMap) return;
const metadata = activeCmapMetadata();
$("cmap-metadata-summary").value = metadata.summary || "";
$("cmap-metadata-tags").value = (metadata.tags || []).join(", ");
$("cmap-metadata-explanation-page").value = metadata.explanationPageSlug || "";
$("cmap-metadata-explanation-page").setCustomValidity("");
$("cmap-metadata-dialog").showModal();
$("cmap-metadata-summary").focus();
closeCmapContextMenu();
}
async function saveCmapMetadata() {
const conceptMap = state.currentConceptMap;
const editor = cmapPrototypeState().editor;
if (!conceptMap || !editor) return false;
const explanationInput = $("cmap-metadata-explanation-page").value.trim();
const explanationPageSlug = explanationInput ? newPageReference(explanationInput) : "";
if (explanationInput && !explanationPageSlug) {
const input = $("cmap-metadata-explanation-page");
input.setCustomValidity(tr("invalid-description-page", "Enter a valid description page address."));
input.reportValidity();
return false;
}
const metadata = {
tags: $("cmap-metadata-tags").value.split(",")
.map((tag) => tag.trim()).filter(Boolean),
summary: $("cmap-metadata-summary").value.trim(),
explanationPageSlug
};
if (conceptMap.document?.derivedView) {
const updated = await api(`/api/cmaps/${encodeURIComponent(conceptMap.slug)}`, {
method: "PUT",
body: JSON.stringify({
title: conceptMap.title,
baseVersion: conceptMap.currentVersion,
summary: tr("updated-concept-map-details", "Updated CMap details"),
saveKind: "manual",
document: { ...conceptMap.document, metadata }
})
});
updated.document = decodeStoredConceptMapDocument(updated);
state.currentConceptMap = updated;
updateStoredConceptMapSummary(updated);
} else {
editor.setDocumentMetadata(metadata);
if (!await saveStoredConceptMap({
force: true,
summary: tr("updated-concept-map-details", "Updated CMap details")
})) return false;
}
$("cmap-metadata-dialog").close();
showCmapStatus(tr("concept-map-details-saved", "CMap details saved"), true);
return true;
}
function openCmapExportDialog() {
if (!state.currentConceptMap) return;
$("cmap-export-status").textContent = "";
$("cmap-export-dialog").showModal();
$("cmap-export-depth").focus();
closeCmapContextMenu();
}
async function buildCurrentCmapMarkdownExport() {
if (!state.currentConceptMap || !window.RacketWikiCmapExport) {
throw new Error(tr("cmap-export-unavailable", "CMap export is unavailable."));
}
if (cmapHasUnsavedChanges() && can("editor")) {
const saved = await saveStoredConceptMap({ automatic: true, force: true, historyMode: "autosave" });
if (!saved) throw new Error(tr("save-before-export-failed", "The CMap could not be saved before export."));
}
const rootMap = await api(`/api/cmaps/${encodeURIComponent(state.currentConceptMap.slug)}`);
rootMap.document = decodeStoredConceptMapDocument(rootMap);
const depth = Math.max(0, Math.min(10, Number($("cmap-export-depth").value) || 0));
return window.RacketWikiCmapExport.generateMarkdown({
rootMap,
maxDepth: depth,
includeWikiPages: $("cmap-export-pages").checked,
language: state.language,
loadConceptMap: async (slug) => {
const map = await api(`/api/cmaps/${encodeURIComponent(slug)}`);
map.document = decodeStoredConceptMapDocument(map);
return map;
},
loadWikiPage: (reference) => api(`/api/pages/${encodeURIComponent(reference)}`)
});
}
async function buildCurrentCmapJsonExport() {
if (!state.currentConceptMap) {
throw new Error(tr("cmap-json-unavailable", "CMap JSON export is unavailable."));
}
if (cmapHasUnsavedChanges() && can("editor")) {
const saved = await saveStoredConceptMap({ automatic: true, force: true, historyMode: "autosave" });
if (!saved) throw new Error(tr("save-before-export-failed", "The CMap could not be saved before export."));
}
const rootMap = await api(`/api/cmaps/${encodeURIComponent(state.currentConceptMap.slug)}`);
rootMap.document = decodeStoredConceptMapDocument(rootMap);
const depth = Math.max(0, Math.min(10, Number($("cmap-export-depth").value) || 0));
return buildBundle({
rootMap,
maxDepth: depth,
generator: "Racket Wiki 0.2.122",
loadConceptMap: async (slug) => {
const map = await api(`/api/cmaps/${encodeURIComponent(slug)}`);
map.document = decodeStoredConceptMapDocument(map);
return map;
},
loadWikiPage: (reference) => api(`/api/pages/${encodeURIComponent(reference)}`),
loadAttachment: async (url) => {
const response = await fetch(url, { credentials: "same-origin" });
if (!response.ok) throw new Error(`Attachment could not be exported: ${url}`);
const content = await response.arrayBuffer();
return {
mimeType: response.headers.get("content-type") || "application/octet-stream",
contentBase64: arrayBufferToBase64(content)
};
}
});
}
function arrayBufferToBase64(buffer) {
const bytes = new Uint8Array(buffer);
let binary = "";
const chunkSize = 0x8000;
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize));
}
return window.btoa(binary);
}
function base64AttachmentBlob(attachment) {
const binary = window.atob(String(attachment.contentBase64 || ""));
if (binary.length > 50 * 1024 * 1024) {
throw new Error(`Attachment exceeds 50 MiB: ${attachment.name}`);
}
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return new Blob([bytes], {
type: attachment.mimeType || "application/octet-stream"
});
}
async function importPageAttachments(page) {
const replacements = new Map();
for (const attachment of (Array.isArray(page.attachments) ? page.attachments : [])) {
const uploaded = await api(`/api/pages/${encodeURIComponent(page.reference)}/upload`, {
method: "POST",
headers: { "X-File-Name": attachment.name },
body: base64AttachmentBlob(attachment)
});
replacements.set(attachment.url, uploaded.url);
}
return replaceAttachmentUrls(page.markdown, replacements);
}
function downloadTextFile(text, filename, contentType) {
const blob = new Blob([text], { type: contentType });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.append(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 0);
}
async function exportCurrentCmapJson() {
const status = $("cmap-export-status");
status.textContent = tr("preparing-cmap-json", "Preparing complete CMap JSON…");
try {
const bundle = await buildCurrentCmapJsonExport();
downloadTextFile(`${JSON.stringify(bundle, null, 2)}\n`,
`${bundle.rootCmapSlug}-cmap.json`, "application/json;charset=utf-8");
status.textContent = tr("cmap-json-downloaded", "CMap JSON downloaded.");
return true;
} catch (error) {
status.textContent = error.message;
return false;
}
}
async function importCmapBundleFile(file) {
if (file.size > 256 * 1024 * 1024) {
throw new Error(tr("cmap-json-too-large", "The CMap JSON file exceeds 256 MiB."));
}
const bundle = JSON.parse(await file.text());
validateBundle(bundle);
if (cmapHasUnsavedChanges()) {
const saved = await saveStoredConceptMap({ automatic: true, force: true, historyMode: "autosave" });
if (!saved) throw new Error(tr("save-before-import-failed", "The current CMap could not be saved before import."));
}
const existingPages = new Map(state.pages.map((page) => [page.slug, page]));
const existingMaps = new Map(state.conceptMaps.map((cmap) => [cmap.slug, cmap]));
const pageConflicts = bundle.pages.filter((page) => existingPages.has(page.reference));
const mapConflicts = bundle.cmaps.filter((cmap) => existingMaps.has(cmap.slug));
let replaceExisting = false;
if (pageConflicts.length || mapConflicts.length) {
replaceExisting = window.confirm(tr(
"cmap-import-conflicts",
"The import contains {maps} existing CMaps and {pages} existing pages. Choose OK to replace them with the imported content, or Cancel to keep them and import only new records.")
.replace("{maps}", mapConflicts.length)
.replace("{pages}", pageConflicts.length));
}
const result = { mapsCreated: 0, mapsUpdated: 0, mapsSkipped: 0,
pagesCreated: 0, pagesUpdated: 0, pagesSkipped: 0, attachmentsImported: 0 };
for (const page of bundle.pages) {
const existing = existingPages.get(page.reference);
if (existing && !replaceExisting) {
result.pagesSkipped += 1;
continue;
}
const attachments = Array.isArray(page.attachments) ? page.attachments : [];
const body = {
slug: page.reference,
title: page.title,
markdown: page.markdown,
tags: page.tags,
summary: tr("imported-from-cmap-json", "Imported from CMap JSON")
};
if (existing) {
body.markdown = await importPageAttachments(page);
body.baseVersion = existing.currentVersion;
await api(`/api/pages/${encodeURIComponent(page.reference)}`, {
method: "PUT", body: JSON.stringify(body)
});
result.pagesUpdated += 1;
} else {
const created = await api("/api/pages", { method: "POST", body: JSON.stringify(body) });
if (attachments.length) {
body.markdown = await importPageAttachments(page);
body.baseVersion = created.currentVersion;
await api(`/api/pages/${encodeURIComponent(page.reference)}`, {
method: "PUT", body: JSON.stringify(body)
});
}
result.pagesCreated += 1;
}
result.attachmentsImported += attachments.length;
}
for (const cmap of bundle.cmaps) {
const existing = existingMaps.get(cmap.slug);
if (existing && !replaceExisting) {
result.mapsSkipped += 1;
continue;
}
const documentValue = preparedMapDocument(bundle, cmap);
if (existing) {
await api(`/api/cmaps/${encodeURIComponent(cmap.slug)}`, {
method: "PUT",
body: JSON.stringify({
title: cmap.title,
document: documentValue,
baseVersion: existing.currentVersion,
saveKind: "manual",
summary: tr("imported-from-cmap-json", "Imported from CMap JSON")
})
});
result.mapsUpdated += 1;
} else {
await api("/api/cmaps", {
method: "POST",
body: JSON.stringify({ slug: cmap.slug, title: cmap.title, document: documentValue })
});
result.mapsCreated += 1;
}
}
await loadPages();
await loadConceptMaps();
await navigateToHash(cmapRoute(bundle.rootCmapSlug));
return result;
}
async function handleCmapImportFile(file) {
showCmapStatus(tr("importing-cmap-json", "Importing CMap JSON…"));
try {
const result = await importCmapBundleFile(file);
showCmapStatus(tr(
"cmap-json-imported",
"Import complete: {maps} CMaps, {pages} pages and {attachments} attachments imported; {skipped} existing records kept.")
.replace("{maps}", result.mapsCreated + result.mapsUpdated)
.replace("{pages}", result.pagesCreated + result.pagesUpdated)
.replace("{attachments}", result.attachmentsImported)
.replace("{skipped}", result.mapsSkipped + result.pagesSkipped), true);
} catch (error) {
showCmapStatus(error.message);
window.alert(error.message);
}
}
async function copyText(text) {
try {
await navigator.clipboard.writeText(text);
} catch (_error) {
const input = document.createElement("textarea");
input.value = text;
input.style.position = "fixed";
input.style.left = "-10000px";
document.body.append(input);
input.select();
document.execCommand("copy");
input.remove();
}
}
async function exportCurrentCmap(download) {
const status = $("cmap-export-status");
status.textContent = tr("preparing-markdown-export", "Preparing Markdown export…");
try {
const markdown = await buildCurrentCmapMarkdownExport();
if (download) {
const blob = new Blob([markdown], { type: "text/markdown;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${state.currentConceptMap.slug}-report.md`;
document.body.append(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 0);
status.textContent = tr("markdown-export-downloaded", "Markdown export downloaded.");
} else {
await copyText(markdown);
status.textContent = tr("markdown-export-copied", "Markdown export copied.");
}
return true;
} catch (error) {
status.textContent = error.message;
return false;
}
}
async function deleteStoredConceptMap() {
cancelCmapAutosave();
if (cmapSavePromise) await cmapSavePromise;
const conceptMap = state.currentConceptMap;
if (!conceptMap) return false;
const question = tr(
"archive-concept-map-confirm",
"Archive the entire concept map \"{title}\"? It will disappear from normal navigation, but an administrator can restore it.")
.replace("{title}", conceptMap.title);
if (!window.confirm(question)) return false;
const typedTitle = window.prompt(
tr(
"archive-concept-map-type-title",
"Type the complete CMap name to confirm: {title}")
.replace("{title}", conceptMap.title),
"");
if (typedTitle === null) return false;
if (typedTitle !== conceptMap.title) {
showCmapStatus(tr("archive-concept-map-title-mismatch", "The CMap name did not match; nothing was archived."));
return false;
}
try {
await api(`/api/cmaps/${encodeURIComponent(conceptMap.slug)}`, {
method: "DELETE",
body: JSON.stringify({
confirmTitle: typedTitle,
baseVersion: conceptMap.currentVersion
})
});
markCurrentCmapSaved();
state.currentConceptMap = null;
state.currentConceptMapSource = null;
await loadConceptMaps();
const target = state.conceptMaps.length ? cmapRoute(state.conceptMaps[0].slug) : "#cmaps";
await navigateToHash(target);
showCmapStatus(tr("concept-map-archived", "CMap archived"), true);
return true;
} catch (error) {
showCmapStatus(error.message);
return false;
}
}
async function saveStoredConceptMap({ automatic = false, force = false,
summary = null, snapshotVersion = false,
historyMode = null } = {}) {
const prototype = cmapPrototypeState();
if (!prototype.editor) return false;
cancelCmapAutosave();
if (automatic && !currentCmapStorageMap()) return false;
const effectiveHistoryMode = historyMode ||
(snapshotVersion ? "snapshot" : (automatic ? "autosave" : "manual"));
if (cmapSavePromise) {
const firstSaveSucceeded = await cmapSavePromise;
if (!firstSaveSucceeded) return false;
if (force || cmapHasUnsavedChanges() || effectiveHistoryMode !== "autosave") {
return saveStoredConceptMap({ automatic, force, summary, snapshotVersion, historyMode });
}
return true;
}
const snapshot = currentCmapSnapshot();
if (snapshot === null) return false;
if (!force && snapshot === state.cmapSavedSnapshot && effectiveHistoryMode === "autosave") {
return true;
}
const document = JSON.parse(snapshot);
const conceptMapAtStart = currentCmapStorageMap();
let saveSucceeded = false;
showCmapStatus(snapshotVersion ? tr("creating-snapshot", "Creating snapshot…") :
(automatic ? tr("autosaving", "Saving automatically…") : tr("saving", "Saving…")));
cmapSavePromise = (async () => {
try {
if (!conceptMapAtStart) {
const title = window.prompt(tr("concept-map-name", "Concept map name"), "");
if (!title || !title.trim()) {
showCmapStatus("");
return false;
}
state.currentConceptMap = await api("/api/cmaps", {
method: "POST",
body: JSON.stringify({ title: title.trim(), document })
});
const savedRoute = cmapRoute(state.currentConceptMap.slug);
history.replaceState(history.state, "", `${location.pathname}${location.search}${savedRoute}`);
state.cmapGuardHash = savedRoute;
await loadConceptMaps();
} else {
const savedConceptMap = await api(
`/api/cmaps/${encodeURIComponent(conceptMapAtStart.slug)}`,
{
method: "PUT",
body: JSON.stringify({
title: conceptMapAtStart.title,
baseVersion: conceptMapAtStart.currentVersion,
summary: summary || (automatic ? tr("automatic-save", "Automatic save") : tr("manual-save", "Manual save")),
snapshot: snapshotVersion,
saveKind: effectiveHistoryMode,
document
})
});
if (state.currentConceptMapSource &&
state.currentConceptMapSource.slug === conceptMapAtStart.slug) {
state.currentConceptMapSource = savedConceptMap;
updateStoredConceptMapSummary(savedConceptMap);
} else if (state.currentConceptMap &&
state.currentConceptMap.slug === conceptMapAtStart.slug) {
state.currentConceptMap = savedConceptMap;
updateStoredConceptMapSummary(savedConceptMap);
}
}
markCurrentCmapSaved(snapshot);
await loadConceptMaps();
showCmapStatus(
snapshotVersion ? tr("snapshot-created", "Snapshot created") :
(automatic ? tr("concept-map-autosaved", "CMap saved automatically") : tr("concept-map-saved", "CMap saved")),
true);
saveSucceeded = true;
return true;
} catch (error) {
showCmapStatus(error.message);
return false;
}
})().finally(() => {
cmapSavePromise = null;
if (saveSucceeded && cmapHasUnsavedChanges()) scheduleCmapAutosave();
});
return cmapSavePromise;
}
async function createConceptMapSnapshot() {
if (!currentCmapStorageMap()) return false;
const description = window.prompt(tr("snapshot-description", "Snapshot description"), "");
if (description === null) return false;
return saveStoredConceptMap({
force: true,
summary: description.trim() || tr("snapshot", "Snapshot"),
snapshotVersion: true
});
}
async function showCmapPrototype(requestedSlug = null) {
state.previousView = state.currentPage ? "page-view" : "cmap-view";
renderBreadcrumbs([
{ label: state.siteTitle, href: "/" },
{ label: tr("cmaps", "CMaps") }
]);
renderToc([], () => {});
show("cmap-view");
state.cmapGuardHash = location.hash;
await loadConceptMaps();
if (requestedSlug && state.conceptMaps.some((conceptMap) => conceptMap.slug === requestedSlug)) {
await openStoredConceptMap(requestedSlug);
} else if (requestedSlug) {
state.currentConceptMap = null;
state.currentConceptMapSource = null;
resetCmapPrototype(null, false);
markCurrentCmapSaved();
renderConceptMapSelector();
showCmapStatus(tr("concept-map-not-found", "CMap not found"));
} else if (state.conceptMaps.length) {
const preferred = startCmapSlug();
const target = state.conceptMaps.find((conceptMap) => conceptMap.slug === preferred) ||
state.conceptMaps[0];
await openStoredConceptMap(target.slug);
} else {
state.currentConceptMap = null;
state.currentConceptMapSource = null;
resetCmapPrototype();
markCurrentCmapSaved();
renderConceptMapSelector();
}
}
function openSelectedConceptMap() {
const slug = cmapMapCombobox.value();
if (!slug) return;
if (state.currentConceptMap && slug === state.currentConceptMap.slug) return;
navigateToHash(cmapRoute(slug))
.then((changed) => {
if (!changed) renderConceptMapSelector();
})
.catch((error) => {
showCmapStatus(error.message);
console.error(error);
});
}
$("cmap-map-select").addEventListener("change", openSelectedConceptMap);
$("cmap-wiki-reference").addEventListener("click", async (event) => {
const markdown = event.currentTarget.dataset.markdown || "";
if (!markdown) return;
try {
await navigator.clipboard.writeText(markdown);
} catch (_error) {
const input = document.createElement("textarea");
input.value = markdown;
input.style.position = "fixed";
input.style.left = "-10000px";
document.body.append(input);
input.select();
document.execCommand("copy");
input.remove();
}
showCmapStatus(tr("wiki-link-copied", "Wiki link copied"));
});
$("cmap-set-start-map").addEventListener("click", () => {
if (!state.currentConceptMap) return;
setStartCmapSlug(state.currentConceptMap.slug);
renderConceptMapSelector();
showCmapStatus(tr("start-concept-map-set", "Start CMap set"), true);
closeCmapContextMenu();
});
$("cmap-new-map").addEventListener("click", () => {
requestCmapTransition(() => createStoredConceptMap())
.catch((error) => {
showCmapStatus(error.message);
});
});
$("cmap-save-map").addEventListener("click", () => saveStoredConceptMap());
$("cmap-rename-map").addEventListener("click", () => renameStoredConceptMap());
$("cmap-edit-metadata").addEventListener("click", openCmapMetadataDialog);
$("cmap-export-markdown").addEventListener("click", openCmapExportDialog);
$("cmap-import-json").addEventListener("click", () => {
closeCmapContextMenu();
$("cmap-import-file").value = "";
$("cmap-import-file").click();
});
$("cmap-manage-people").addEventListener("click", () => {
openPeopleManagement().catch((error) => showCmapStatus(error.message));
});
$("cmap-delete-map").addEventListener("click", () => deleteStoredConceptMap());
$("cmap-create-snapshot").addEventListener("click", () => {
createConceptMapSnapshot().catch((error) => showCmapStatus(error.message));
});
$("cmap-history").addEventListener("click", () => {
showConceptMapHistory().catch((error) => showCmapStatus(error.message));
});
$("cmap-add-concept").addEventListener("click", () => {
openNewCmapConceptDialog(cmapContextCreateContext || {});
});
$("cmap-add-page").addEventListener("click", () => {
if (!state.currentPage) return;
addCmapPrototypeNode({ ...cmapPlacementOptions(), label: state.currentPage.title, synopsis: currentPageSynopsis(), kind: "page", pageSlug: state.currentPage.slug, backgroundColor: "#e7f2fb", borderColor: "#4479a1" });
});
$("cmap-add-submap").addEventListener("click", () => {
const label = window.prompt(tr("add-submap", "Add sub-CMap"), tr("sub-concept-map", "Sub concept map"));
if (!label) return;
addCmapPrototypeNode({ ...cmapPlacementOptions(), label, synopsis: "Expandable child-map placeholder.", kind: "submap", childMap: label, backgroundColor: "#edf7e8", borderColor: "#57834a" });
});
for (const id of ["cmap-promote-submap", "cmap-extract-selected"]) {
$(id).addEventListener("click", () => {
promoteSelectedSubmapToStoredMap().catch((error) => {
console.error(error);
showCmapStatus(error.message);
});
});
}
$("cmap-edit-selected").addEventListener("click", () => editSelectedCmapNode());
$("cmap-cut-selected").addEventListener("click", () => {
const prototype = cmapPrototypeState();
if (prototype.editor) prototype.editor.cutSelectionReferences();
});
$("cmap-copy-selected").addEventListener("click", () => {
const prototype = cmapPrototypeState();
if (prototype.editor) prototype.editor.copySelectionReferences();
});
$("cmap-paste-concepts").addEventListener("click", () => {
const prototype = cmapPrototypeState();
if (prototype.editor) prototype.editor.pasteConceptReferences();
});
$("cmap-undo").addEventListener("click", () => {
const prototype = cmapPrototypeState();
if (prototype.editor) prototype.editor.undo();
});
$("cmap-redo").addEventListener("click", () => {
const prototype = cmapPrototypeState();
if (prototype.editor) prototype.editor.redo();
});
$("cmap-select-all").addEventListener("click", () => {
const prototype = cmapPrototypeState();
if (prototype.editor) prototype.editor.selectAll();
});
$("cmap-group-selected").addEventListener("click", () => {
groupSelectedCmapItems();
});
$("cmap-ungroup-selected").addEventListener("click", () => {
const prototype = cmapPrototypeState();
if (prototype.editor) prototype.editor.ungroupSelection();
});
$("cmap-hide-selected").addEventListener("click", () => {
const prototype = cmapPrototypeState();
if (prototype.editor) prototype.editor.hideSelectionInCurrentContext();
});
$("cmap-delete-selected").addEventListener("click", () => {
const prototype = cmapPrototypeState();
if (prototype.editor) prototype.editor.deleteSelection();
});
$("cmap-selection-toolbar").addEventListener("click", (event) => {
const button = event.target.closest("button");
if (!button || button.disabled) return;
const prototype = cmapPrototypeState();
const editor = prototype.editor;
if (!editor) return;
const layoutCommand = button.dataset.cmapLayout;
if (layoutCommand) {
editor.applySelectionLayout(layoutCommand);
return;
}
switch (button.dataset.cmapSelectionAction) {
case "edit":
editSelectedCmapNode();
break;
case "group":
groupSelectedCmapItems();
break;
case "ungroup":
editor.ungroupSelection();
break;
case "hide":
editor.hideSelectionInCurrentContext();
break;
default:
break;
}
});
$("cmap-toggle-page-guides").addEventListener("click", () => {
const visible = $("cmap-toggle-page-guides").getAttribute("aria-checked") !== "true";
setCmapPageGuides(visible);
});
$("cmap-reset").addEventListener("click", () => {
if (state.currentConceptMap) {
requestCmapTransition(() => openStoredConceptMap(state.currentConceptMap.slug))
.catch((error) => console.error(error));
} else {
resetCmapPrototype();
markCurrentCmapSaved();
}
});
$("cmap-map-back").addEventListener("click", () => {
const prototype = cmapPrototypeState();
if (prototype.editor && prototype.editor.canStepBackWithinMap()) {
prototype.editor.openParentMap();
return;
}
if (state.currentConceptMapSource) {
navigateToHash(cmapRoute(state.currentConceptMapSource.slug))
.catch((error) => console.error(error));
return;
}
if (prototype.editor) prototype.editor.openParentMap();
});
$("cmap-canvas").addEventListener("contextmenu", (event) => {
event.preventDefault();
const prototype = cmapPrototypeState();
if (!prototype.editor) return;
const point = prototype.editor.canvasPoint(event);
openCmapContextMenu(event.clientX, event.clientY, {
point,
parentSubmap: prototype.editor.submapAtPoint(point)
});
});
$("cmap-canvas").addEventListener("pointerover", (event) => {
const button = event.target.closest(".rw-cmap-view-description");
if (button) showCmapDescriptionTooltip(button);
});
$("cmap-canvas").addEventListener("pointerout", (event) => {
if (event.target.closest(".rw-cmap-view-description")) hideCmapDescriptionTooltip();
});
$("cmap-canvas").addEventListener("focusin", (event) => {
const button = event.target.closest(".rw-cmap-view-description");
if (button) showCmapDescriptionTooltip(button);
});
$("cmap-canvas").addEventListener("focusout", (event) => {
if (event.target.closest(".rw-cmap-view-description")) hideCmapDescriptionTooltip();
});
$("cmap-tools-menu").addEventListener("click", (event) => {
const prototype = cmapPrototypeState();
if (!prototype.editor) return;
if (!$("cmap-context-menu").classList.contains("hidden")) {
closeCmapContextMenu();
return;
}
const buttonRect = event.currentTarget.getBoundingClientRect();
const canvasRect = $("cmap-canvas").getBoundingClientRect();
const point = prototype.editor.canvasPoint({
clientX: canvasRect.left + 160,
clientY: Math.max(canvasRect.top, buttonRect.bottom) + 80
});
openCmapContextMenu(buttonRect.left, buttonRect.bottom + 4, {
point,
parentSubmap: prototype.editor.submapAtPoint(point)
});
});
$("cmap-context-menu").addEventListener("click", () => closeCmapContextMenu());
document.addEventListener("pointerdown", (event) => {
if (!event.target.closest("#cmap-context-menu, #cmap-tools-menu")) closeCmapContextMenu();
if (!event.target.closest(".cmap-color-control")) {
for (const palette of document.querySelectorAll(".cmap-color-palette")) {
palette.classList.add("hidden");
}
}
});
$("cmap-zoom-out").addEventListener("click", () => setCmapZoom(Number($("cmap-zoom-percent").value) - 10));
$("cmap-zoom-in").addEventListener("click", () => setCmapZoom(Number($("cmap-zoom-percent").value) + 10));
$("cmap-zoom-reset").addEventListener("click", () => setCmapZoom(100));
$("cmap-zoom-percent").addEventListener("change", (event) => setCmapZoom(event.target.value));
$("cmap-concept-page").addEventListener("change", (event) => {
if (cmapPageCombobox.value()) cmapLinkCombobox.clear();
});
$("cmap-concept-cmap").addEventListener("change", (event) => {
if (cmapLinkCombobox.value()) cmapPageCombobox.clear();
});
for (const id of ["cmap-concept-page", "cmap-concept-cmap"]) {
$(id).addEventListener("input", (event) => event.target.setCustomValidity(""));
}
$("cmap-concept-description-page").addEventListener("input", (event) =>
event.target.setCustomValidity(""));
$("cmap-concept-description-link").addEventListener("click", () =>
$("cmap-concept-dialog").close());
installCmapColorPickers();
$("cmap-concept-quick-style").addEventListener("change", applySelectedCmapStyle);
$("cmap-concept-style-preset").addEventListener("change", applySelectedCmapStyle);
$("cmap-save-style").addEventListener("click", saveCurrentCmapStyle);
$("cmap-delete-style").addEventListener("click", deleteSelectedCmapStyle);
for (const eventName of ["input", "change"]) {
$("cmap-concept-panel-appearance").addEventListener(eventName, (event) => {
if (!event.target.closest(".cmap-style-manager")) syncCmapStyleSelection();
});
}
$("cmap-concept-image").addEventListener("change", (event) => {
const file = event.target.files && event.target.files[0];
if (!file) return;
const record = cmapDialogRecord;
cmapDialogImageRead = readCmapImage(file)
.then((imageSource) => {
if (cmapDialogRecord !== record) return;
cmapDialogImageSource = imageSource;
updateCmapImagePreview();
})
.catch((error) => {
console.error(error);
window.alert(error.message);
});
});
$("cmap-concept-image-remove").addEventListener("click", () => {
cmapDialogImageSource = "";
cmapDialogImageRead = Promise.resolve();
$("cmap-concept-image").value = "";
updateCmapImagePreview();
});
$("cmap-concept-form").querySelector(".cmap-concept-tabs").addEventListener("click", (event) => {
const tab = event.target.closest("[data-cmap-concept-tab]");
if (tab) selectCmapConceptTab(tab.dataset.cmapConceptTab);
});
$("cmap-concept-form").querySelector(".cmap-concept-tabs").addEventListener("keydown", (event) => {
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
const tabs = Array.from(event.currentTarget.querySelectorAll("[data-cmap-concept-tab]"));
const current = tabs.indexOf(event.target.closest("[data-cmap-concept-tab]"));
if (current < 0) return;
event.preventDefault();
const next = event.key === "Home" ? 0 : event.key === "End" ? tabs.length - 1 :
(current + (event.key === "ArrowRight" ? 1 : -1) + tabs.length) % tabs.length;
selectCmapConceptTab(tabs[next].dataset.cmapConceptTab);
tabs[next].focus();
});
$("cmap-concept-external-url").addEventListener("input", (event) =>
event.currentTarget.setCustomValidity(""));
$("cmap-concept-cancel").addEventListener("click", () => $("cmap-concept-dialog").close());
$("cmap-concept-form").addEventListener("submit", async (event) => {
event.preventDefault();
await cmapDialogImageRead;
const record = cmapDialogRecord;
const createContext = cmapDialogCreateContext;
const prototype = cmapPrototypeState();
if ((!record && !createContext) || !prototype.editor) return;
const label = $("cmap-concept-label").value.trim();
if (!label) {
selectCmapConceptTab("content");
$("cmap-concept-label").focus();
return;
}
const fontSize = normalizedCmapFontSize($("cmap-concept-font-size").value, 11);
const synopsisFontSize = normalizedCmapFontSize(
$("cmap-concept-synopsis-font-size").value, 9);
const pageInput = $("cmap-concept-page");
const cmapInput = $("cmap-concept-cmap");
const selectedPage = record && record.kind === "submap" ? "" : cmapPageCombobox.value();
const selectedCmap = record && record.kind === "submap" ? "" : cmapLinkCombobox.value();
const linkedPage = selectedPage === null ? newPageReference(pageInput.value) : selectedPage;
if (selectedPage === null && !linkedPage) {
selectCmapConceptTab("content");
pageInput.setCustomValidity(tr("invalid-new-page", "Enter a page title or valid wiki address."));
pageInput.reportValidity();
return;
}
if (selectedCmap === null) {
selectCmapConceptTab("content");
cmapInput.setCustomValidity(tr("select-listed-concept-map", "Select a CMap from the list or clear the field."));
cmapInput.reportValidity();
return;
}
const linkedPageValue = linkedPage || null;
const externalUrlInput = $("cmap-concept-external-url");
const externalUrl = normalizedCmapExternalUrl(externalUrlInput.value);
if (externalUrl === null) {
selectCmapConceptTab("content");
externalUrlInput.setCustomValidity(tr(
"invalid-external-web-page",
"Enter a complete http or https web address."));
externalUrlInput.reportValidity();
externalUrlInput.focus();
return;
}
const descriptionInput = $("cmap-concept-description-page").value.trim();
const descriptionPage = descriptionInput ? newPageReference(descriptionInput) :
pageReference("cmap", splitPageReference(newPageReference(label) || "concept").slug);
if (!descriptionPage) {
selectCmapConceptTab("content");
$("cmap-concept-description-page").setCustomValidity(
tr("invalid-description-page", "Enter a valid description page address."));
$("cmap-concept-description-page").reportValidity();
return;
}
const linkedCmapValue = selectedCmap || "";
const parentCmapLink = linkedCmapValue === "__parent__";
const linkedCmap = linkedCmapValue && !parentCmapLink ? linkedCmapValue : null;
const existingNonPersonTags = record && Array.isArray(record.tags) ?
record.tags.filter((tag) => !(tag && typeof tag === "object" && tag.type === "person")) : [];
const personTags = selectedCmapPersonNames()
.map((name) => ({ type: "person", value: name }));
const changes = {
kind: record && record.kind === "submap" ? "submap" : (linkedPageValue ? "page" : "concept"),
label,
synopsis: $("cmap-concept-synopsis").value,
aspects: $("cmap-concept-aspects").value.split(",")
.map((aspect) => aspect.trim()).filter(Boolean),
tags: [...existingNonPersonTags, ...personTags],
descriptionPageSlug: descriptionPage,
pageSlug: linkedPageValue,
cmapSlug: linkedCmap,
externalUrl: externalUrl || null,
parentCmapLink,
imageSource: cmapDialogImageSource,
backgroundColor: cmapColorValue($("cmap-concept-background").value),
textColor: cmapColorValue($("cmap-concept-text-color").value, "#222222"),
fontFamily: $("cmap-concept-font-family").value || "Arial, Helvetica, sans-serif",
fontSize: `${fontSize}pt`,
fontWeight: $("cmap-concept-bold").checked ? "700" : "400",
fontStyle: $("cmap-concept-italic").checked ? "italic" : "normal",
synopsisTextColor: cmapColorValue(
$("cmap-concept-synopsis-text-color").value, "#4d4d4d"),
synopsisFontFamily: $("cmap-concept-synopsis-font-family").value ||
"Arial, Helvetica, sans-serif",
synopsisFontSize: `${synopsisFontSize}pt`,
synopsisFontWeight: $("cmap-concept-synopsis-bold").checked ? "700" : "400",
synopsisFontStyle: $("cmap-concept-synopsis-italic").checked ? "italic" : "normal"
};
const existingConceptId = sharedCmapConceptId(label);
if (!record || record.kind !== "submap") {
changes.borderColor = linkedPageValue ? "#4479a1" :
((linkedCmap || parentCmapLink) ? "#57834a" : "#a97c00");
} else {
changes.submapBackgroundColor = cmapColorValue($("cmap-submap-background").value, "#edf7e8");
changes.submapBorderColor = cmapColorValue($("cmap-submap-border").value, "#57834a");
}
if (record) {
if (existingConceptId && record.conceptId !== existingConceptId) {
const previousConceptId = record.conceptId;
record.conceptId = existingConceptId;
prototype.editor.refreshConceptUsageIndicators(
[previousConceptId, existingConceptId].filter(Boolean));
}
prototype.editor.updateItem(record, changes);
prototype.editor.selectItem(record);
} else {
if (existingConceptId) changes.conceptId = existingConceptId;
if (createContext.point) {
changes.x = Math.max(0, createContext.point.x - 70);
changes.y = Math.max(0, createContext.point.y - 30);
}
if (createContext.parentSubmap) {
changes.parentSubmap = createContext.parentSubmap;
changes.submapDepth = createContext.parentSubmap.submapDepth + 1;
}
const newRecord = addCmapPrototypeNode(changes);
if (createContext.source && newRecord) {
prototype.editor.finishRelation(createContext.source, newRecord);
} else if (newRecord) {
prototype.editor.selectItem(newRecord);
}
}
prototype.editor.commitHistory();
$("cmap-concept-dialog").close();
if (currentCmapStorageMap()) {
await saveStoredConceptMap({ automatic: true });
}
});
$("cmap-concept-dialog").addEventListener("close", () => {
cmapDialogRecord = null;
cmapDialogCreateContext = null;
cmapDialogImageSource = "";
cmapDialogImageRead = Promise.resolve();
});
$("cmap-person-tag-options").addEventListener("change", () =>
renderCmapPersonTagOptions(selectedCmapPersonNames()));
$("cmap-person-add").addEventListener("click", () => {
createPersonFromInput("cmap-person-new-name", true)
.catch((error) => window.alert(error.message));
});
$("cmap-person-new-name").addEventListener("keydown", (event) => {
if (event.key !== "Enter") return;
event.preventDefault();
createPersonFromInput("cmap-person-new-name", true)
.catch((error) => window.alert(error.message));
});
$("cmap-people-add").addEventListener("click", () => {
createPersonFromInput("cmap-people-new-name")
.then(() => renderPeopleManagement())
.catch((error) => window.alert(error.message));
});
$("cmap-people-new-name").addEventListener("keydown", (event) => {
if (event.key !== "Enter") return;
event.preventDefault();
createPersonFromInput("cmap-people-new-name")
.then(() => renderPeopleManagement())
.catch((error) => window.alert(error.message));
});
$("cmap-people-close").addEventListener("click", () => $("cmap-people-dialog").close());
$("cmap-metadata-cancel").addEventListener("click", () => $("cmap-metadata-dialog").close());
$("cmap-metadata-form").addEventListener("submit", (event) => {
event.preventDefault();
saveCmapMetadata().catch((error) => showCmapStatus(error.message));
});
$("cmap-metadata-explanation-page").addEventListener("input", (event) =>
event.currentTarget.setCustomValidity(""));
$("cmap-export-cancel").addEventListener("click", () => $("cmap-export-dialog").close());
$("cmap-export-copy").addEventListener("click", () => exportCurrentCmap(false));
$("cmap-export-json").addEventListener("click", () => exportCurrentCmapJson());
$("cmap-export-form").addEventListener("submit", (event) => {
event.preventDefault();
exportCurrentCmap(true);
});
$("cmap-import-file").addEventListener("change", (event) => {
const file = event.currentTarget.files && event.currentTarget.files[0];
if (file) handleCmapImportFile(file);
});
$("cmap-unsaved-save").addEventListener("click", async () => {
if (await saveStoredConceptMap()) continueCmapTransition();
});
$("cmap-unsaved-discard").addEventListener("click", discardCmapChangesAndContinue);
$("cmap-unsaved-cancel").addEventListener("click", cancelCmapTransition);
$("cmap-unsaved-dialog").addEventListener("cancel", (event) => {
event.preventDefault();
cancelCmapTransition();
});
$("cmap-history-close").addEventListener("click", () => $("cmap-history-dialog").close());
document.addEventListener("keydown", (event) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLocaleLowerCase() === "s" &&
!$("cmap-view").classList.contains("hidden")) {
event.preventDefault();
if (can("editor")) {
saveStoredConceptMap()
.then((saved) => {
if (saved && pendingCmapTransition) continueCmapTransition();
})
.catch((error) => console.error(error));
}
return;
}
handleCmapKeyboardShortcut(event);
});
/**
* Load persistent CMap preferences and the data needed by the workspace.
*/
this.initialize = async () => {
await loadCmapStyles();
restoreCmapPageGuides();
await loadConceptMaps();
await loadPeople();
};
this.renderNodeHtml = cmapNodeHtml;
this.queueEmbedHydration = queueCmapEmbedHydration;
this.loadConceptMaps = loadConceptMaps;
this.conceptMapEntry = titledCmapComboboxEntry;
this.normalizeExternalUrl = normalizedCmapExternalUrl;
this.clearDescriptionPreviews = () => cmapDescriptionPreviewCache.clear();
this.requestTransition = requestCmapTransition;
this.show = showCmapPrototype;
this.hasUnsavedChanges = cmapHasUnsavedChanges;
this.startSlug = startCmapSlug;
}
}