Files
racket-wiki/static/js/wiki/cmap/workspace.js
T
2026-09-03 08:40:15 +02:00

1830 lines
76 KiB
JavaScript

import {
newPageReference
} from "../reference.js";
import { cmapRoute, pageRoute } from "../routes.js";
import { cmapMentionTarget, escapeHtml } from "../markdown.js";
import { CmapModel } from "../../../cmap/model/concept-map.js";
import { CmapAppearanceRepository } from "../../../cmap/model/appearance-repository.js";
import { CmapRepository } from "../../../cmap/model/cmap-repository.js";
import { CmapJsonExporter } from "../../../cmap/model/json-exporter.js";
import { CmapJsonImporter } from "../../../cmap/model/json-importer.js";
import { CmapMarkdownExporter } from "../../../cmap/model/markdown-exporter.js";
import { CmapSettingsRepository } from "../../../cmap/model/settings-repository.js";
import { PeopleRepository } from "../../../cmap/model/people-repository.js";
import { CmapAppearanceEditor } from "../../../cmap/view/appearance-editor.js";
import { ComboBox } from "../../widgets/combobox.js";
import { DescriptionPreview } from "../../widgets/description-preview.js";
import { PopupMenu } from "../../widgets/popup-menu.js";
import { StatusField } from "../../widgets/status-field.js";
import { CmapConceptDialog } from "./dialogs/concept-dialog.js";
import { CmapExportDialog } from "./dialogs/export-dialog.js";
import { CmapHistoryDialog } from "./dialogs/history-dialog.js";
import { CmapMetadataDialog } from "./dialogs/metadata-dialog.js";
import { CmapPeopleDialog } from "./dialogs/people-dialog.js";
import { CmapUnsavedDialog } from "./dialogs/unsaved-dialog.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 ComboBox($("cmap-map-combobox"));
const cmapContextMenu = new PopupMenu(
$("cmap-context-menu"), $("cmap-tools-menu"));
const cmapStatus = new StatusField($("cmap-save-status"), "cmap-status-visible");
const cmapDescriptionPreview = new DescriptionPreview(
(reference) => api(`/api/pages/${encodeURIComponent(reference)}`),
renderMarkdown);
const cmapRepository = new CmapRepository(api);
const appearanceRepository = new CmapAppearanceRepository(api);
const settingsRepository = new CmapSettingsRepository(api);
const peopleRepository = new PeopleRepository(api);
let appearanceEditor = null;
let conceptDialog = null;
const loadExportWikiPage = (reference) =>
api(`/api/pages/${encodeURIComponent(reference)}`);
const markdownExporter = new CmapMarkdownExporter(
cmapRepository, loadExportWikiPage);
const jsonExporter = new CmapJsonExporter(
cmapRepository,
loadExportWikiPage,
(url) => fetch(url, { credentials: "same-origin" }),
"Racket Wiki 0.2.122");
const jsonImporter = new CmapJsonImporter(cmapRepository, api, tr);
const peopleDialog = new CmapPeopleDialog(
$("cmap-people-dialog"), $("cmap-person-tags-picker"), peopleRepository, tr);
const metadataDialog = new CmapMetadataDialog(
$("cmap-metadata-dialog"), tr, newPageReference)
.onSave(async (metadata) => {
try {
return await saveCmapMetadata(metadata);
} catch (error) {
showCmapStatus(error.message);
return false;
}
});
const historyDialog = new CmapHistoryDialog(
$("cmap-history-dialog"),
cmapRepository,
tr,
pageDisplayDate,
can,
(version) => {
requestCmapTransition(() => loadHistoricalConceptMapVersion(version))
.catch((error) => showCmapStatus(error.message));
},
showCmapStatus);
const exportDialog = new CmapExportDialog(
$("cmap-export-dialog"), tr, buildCurrentCmapMarkdownExport, buildCurrentCmapJsonExport);
const unsavedDialog = new CmapUnsavedDialog($("cmap-unsaved-dialog"));
let pendingCmapTransition = null;
let cmapAutosaveTimer = null;
let cmapSavePromise = null;
let cmapEmbedHydrationTimer = null;
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 cmapRepository.load(conceptMap.slug);
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, {
renderItem: (record) => cmapNodeHtml(record),
onOpenExternalUrl: (record) => openCmapExternalUrl(record)
});
editor.loadModel(stored.model);
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);
}
}
}
function startCmapSlug() {
return settingsRepository.startCmapSlug;
}
async function setStartCmapSlug(slug) {
return settingsRepository.setStartCmap(slug);
}
////////////////////////////////////////////////////////////////////////////////
// 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 ? `<img class="cmap-card-image" src="${safeImageSource}" alt="">` : "";
const aspects = (Array.isArray(record.aspects) ? record.aspects : [])
.map((aspect) => `<span class="cmap-card-aspect">${escapeHtml(aspect)}</span>`)
.join("");
const aspectList = aspects ? `<div class="cmap-card-aspects">${aspects}</div>` : "";
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 ?
`<div class="cmap-card-people" title="${escapeHtml(peopleLabel)}"><span aria-hidden="true">&#128100;</span> ${personNames.map(escapeHtml).join(" · ")}</div>` : "";
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 ? "" :
`<span class="cmap-card-usage" title="${escapeHtml(usageLabel)}">(${usageCount})</span>`;
const descriptionReference = canonicalPageReference(record.descriptionPageSlug || "");
const descriptionExists = Boolean(descriptionReference &&
state.pages.some((page) => page.slug === descriptionReference));
const descriptionButton = record.descriptionPageSlug ?
`<button type="button" class="rw-cmap-view-description ${descriptionExists ? "is-filled" : "is-empty"}" aria-label="${escapeHtml(tr("view-concept-description", "View concept description"))}">I</button>` : "";
const linkedTarget = record.pageSlug || record.cmapSlug || record.parentCmapLink;
const linkedButton = linkedTarget && record.kind !== "submap" ?
`<button type="button" class="rw-cmap-open-linked" title="${escapeHtml(tr("open-linked-item", "Open linked page or CMap"))}" aria-label="${escapeHtml(tr("open-linked-item", "Open linked page or CMap"))}">↗</button>` : "";
const externalButton = record.externalUrl ?
`<button type="button" class="rw-cmap-open-external" title="${escapeHtml(tr("open-external-web-page", "Open external web page"))}" aria-label="${escapeHtml(tr("open-external-web-page", "Open external web page"))}">↗</button>` : "";
const linkedCmapClass = record.cmapSlug || record.parentCmapLink ? " cmap-card-linked-cmap" : "";
return `<div class="cmap-card cmap-card-${safeKind}${linkedCmapClass}">${descriptionButton}${linkedButton}${externalButton}${image}<div class="cmap-card-title">${safeLabel} ${usage}</div>${aspectList}${personTags}${safeSynopsis ? `<div class="cmap-card-synopsis">${safeSynopsis}</div>` : ""}</div>`;
}
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 hideCmapDescriptionTooltip() {
cmapDescriptionPreview.hide();
}
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);
await cmapDescriptionPreview.show(button, reference, tr("loading", "Loading…"));
}
/**
* 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 cmapContextCreateContext = {};
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 openLinkedCmap(record) {
if (!record || !record.cmapSlug) return false;
navigateToHash(cmapRoute(record.cmapSlug)).catch((error) => console.error(error));
return true;
}
function openParentCmap() {
const source = state.currentConceptMapSource;
if (source && source.slug) {
navigateToHash(cmapRoute(source.slug)).catch((error) => console.error(error));
return true;
}
const prototype = cmapPrototypeState();
return Boolean(prototype.editor && prototype.editor.openParentMap());
}
function titledCmapComboboxEntry(record) {
const label = record.title || record.slug;
return {
value: record.slug,
label,
description: record.slug
};
}
function currentConceptDialogResources() {
const prototype = cmapPrototypeState();
return {
pages: state.pages,
conceptMaps: state.conceptMaps,
parentMapAvailable: Boolean(prototype.editor && prototype.editor.activeMapRoot)
};
}
/**
* 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 (conceptDialog) conceptDialog.open(record, currentConceptDialogResources());
}
function openNewCmapConceptDialog(context = {}) {
if (conceptDialog) {
conceptDialog.openNew(context, currentConceptDialogResources());
}
}
/** Apply validated concept-dialog values as one editor history transaction. */
async function saveCmapConcept(record, createContext, values) {
const prototype = cmapPrototypeState();
if ((!record && !createContext) || !prototype.editor) return false;
const existingNonPersonTags = record && Array.isArray(record.tags) ?
record.tags.filter((tag) =>
!(tag && typeof tag === "object" && tag.type === "person")) : [];
const personTags = values.personNames
.map((name) => ({ type: "person", value: name }));
const changes = {
kind: record && record.kind === "submap" ?
"submap" : (values.pageSlug ? "page" : "concept"),
label: values.label,
synopsis: values.synopsis,
aspects: values.aspects,
tags: [...existingNonPersonTags, ...personTags],
descriptionPageSlug: values.descriptionPageSlug,
pageSlug: values.pageSlug,
cmapSlug: values.cmapSlug,
externalUrl: values.externalUrl,
parentCmapLink: values.parentCmapLink,
imageSource: values.imageSource,
...values.appearance
};
const existingConceptId = sharedCmapConceptId(values.label);
if (!record || record.kind !== "submap") {
changes.borderColor = values.pageSlug ? "#4479a1" :
((values.cmapSlug || values.parentCmapLink) ? "#57834a" : "#a97c00");
}
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();
if (currentCmapStorageMap()) await saveStoredConceptMap({ automatic: true });
return true;
}
function applyCmapZoom(value) {
const prototype = cmapPrototypeState();
const percent = Math.max(25, Math.min(300, Number(value) || 100));
$("cmap-zoom-percent").value = String(percent);
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 setCmapZoom(value) {
const percent = applyCmapZoom(value);
const cmapSlug = state.currentConceptMap?.slug;
if (cmapSlug) {
settingsRepository.setZoom(cmapSlug, cmapZoomContextKey(), percent)
.catch((error) => console.warn("The CMap zoom preference could not be stored.", error));
}
return percent;
}
function setCmapPageGuides(visible) {
$("cmap-canvas").classList.toggle("cmap-page-guides-hidden", !visible);
$("cmap-toggle-page-guides").setAttribute("aria-checked", String(visible));
}
function restoreCmapPageGuides() {
setCmapPageGuides(settingsRepository.pageGuidesVisible);
}
function showCmapStatus(message, temporary = false) {
if (temporary) {
cmapStatus.showTemporarily(message);
} else {
cmapStatus.set(message);
}
}
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 cmapZoomContextKey() {
const prototype = cmapPrototypeState();
const activeMap = prototype.editor && prototype.editor.activeMapRoot &&
prototype.editor.activeMapRoot.mapReference ? prototype.editor.activeMapRoot.mapReference.id : "root";
return activeMap;
}
function restoreCmapZoom() {
const cmapSlug = state.currentConceptMap?.slug;
const stored = cmapSlug ? settingsRepository.zoom(cmapSlug, cmapZoomContextKey()) : 100;
return applyCmapZoom(stored);
}
function closeCmapContextMenu() {
cmapContextMenu.close();
}
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 = {}) {
cmapContextCreateContext = createContext;
cmapContextMenu.openAt(clientX, clientY);
}
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") || conceptDialog?.isOpen()) 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(cmapModel = 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", {
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 (!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, {
renderItem: (record) => cmapNodeHtml(record),
onOpenPage: (record) => {
if (record.pageSlug) {
navigateToHash(pageRoute(record.pageSlug)).catch((error) => console.error(error));
}
},
onOpenCmap: (record) => {
openLinkedCmap(record);
},
onOpenParentCmap: () => {
openParentCmap();
},
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 (cmapModel) {
prototype.editor.loadModel(cmapModel);
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 [conceptMaps, placements] = await Promise.all([
cmapRepository.list(),
cmapRepository.conceptUsage().catch((error) => {
console.warn("[racket-wiki:cmap-host 0.2.122] concept usage is unavailable; CMaps continue without global counts", error);
return [];
})
]);
state.conceptMaps = conceptMaps;
state.cmapConceptUsage = new Map();
state.cmapConceptIdsByName = new Map();
state.cmapPageConcepts = new Map();
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 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 summary = typeof conceptMap.toSummary === "function" ?
conceptMap.toSummary() : conceptMap;
const index = state.conceptMaps.findIndex((item) => item.slug === conceptMap.slug);
if (index >= 0) {
state.conceptMaps[index] = { ...state.conceptMaps[index], ...summary };
} else {
state.conceptMaps.push(summary);
}
renderConceptMapSelector();
}
async function requestCmapTransition(action) {
if (!cmapHasUnsavedChanges()) {
await action();
return true;
}
if (pendingCmapTransition) return false;
const transition = (async () => {
const choice = await unsavedDialog.choose();
if (choice === "save" && !await saveStoredConceptMap()) return false;
if (choice === "discard") markCurrentCmapSaved();
if (choice === "cancel") {
renderConceptMapSelector();
return false;
}
await action();
return true;
})();
pendingCmapTransition = transition;
try {
return await transition;
} finally {
if (pendingCmapTransition === transition) pendingCmapTransition = null;
}
}
async function openStoredConceptMap(slug) {
if (!slug) return;
const loadSequence = ++state.cmapLoadSequence;
showCmapStatus(tr("loading", "Loading…"));
const conceptMap = await cmapRepository.load(slug);
if (loadSequence !== state.cmapLoadSequence) return;
const derivedView = conceptMap.model.derivedView();
let sourceMap = null;
let editorModel = conceptMap.model;
if (derivedView && typeof derivedView === "object" &&
typeof derivedView.sourceCmapSlug === "string" && derivedView.sourceCmapSlug &&
Number.isInteger(Number(derivedView.rootItemId))) {
sourceMap = await cmapRepository.load(derivedView.sourceCmapSlug);
if (loadSequence !== state.cmapLoadSequence) return;
editorModel = sourceMap.model;
}
console.info("[racket-wiki:cmap-host 0.2.122] stored CMap received", {
slug: conceptMap.slug,
version: conceptMap.currentVersion,
itemCount: conceptMap.model.conceptMap.items().length,
connectorCount: conceptMap.model.conceptMap.connectors().length
});
state.currentConceptMap = conceptMap;
state.currentConceptMapSource = sourceMap;
renderConceptMapSelector();
resetCmapPrototype(editorModel, 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 cmapRepository.loadVersion(conceptMap, version);
const currentSnapshot = JSON.stringify(conceptMap.toDocument());
resetCmapPrototype(historical.model, 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 createStoredConceptMap() {
const title = window.prompt(tr("concept-map-name", "Concept map name"), "");
if (!title || !title.trim()) return;
const model = new CmapModel();
const conceptMap = await cmapRepository.create(title.trim(), model);
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 cmapRepository.load(record.cmapSlug);
const derivedView = linkedMap.model.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.model.metadata());
storedMap = await cmapRepository.save(
storedMap,
extraction.childModel,
{
summary: tr("submap-extracted", "Sub-CMap moved to a separate CMap"),
saveKind: "manual"
});
} else {
const prepared = editor.prepareStoredSubmapExtraction(record, null);
if (!prepared) return false;
storedMap = await cmapRepository.create(
title.trim(), prepared.childModel);
extraction = editor.prepareStoredSubmapExtraction(record, storedMap.slug);
}
editor.replaceModel(extraction.parentModel);
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 cmapRepository.rename(conceptMap, title.trim());
await loadConceptMaps();
showCmapStatus(tr("concept-map-renamed", "CMap renamed"), true);
return true;
} catch (error) {
showCmapStatus(error.message);
return false;
}
}
function activeCmapMetadata() {
const currentModel = state.currentConceptMap?.model;
if (currentModel?.derivedView()) {
const metadata = currentModel.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;
metadataDialog.open(activeCmapMetadata());
closeCmapContextMenu();
}
async function saveCmapMetadata(metadata) {
const conceptMap = state.currentConceptMap;
const editor = cmapPrototypeState().editor;
if (!conceptMap || !editor) return false;
if (conceptMap.model.derivedView()) {
const updatedModel = conceptMap.model.withMetadata(metadata);
const updated = await cmapRepository.save(conceptMap, updatedModel, {
summary: tr("updated-concept-map-details", "Updated CMap details"),
saveKind: "manual"
});
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;
}
showCmapStatus(tr("concept-map-details-saved", "CMap details saved"), true);
return true;
}
function openCmapExportDialog() {
if (!state.currentConceptMap) return;
exportDialog.open(state.currentConceptMap.slug);
closeCmapContextMenu();
}
async function buildCurrentCmapMarkdownExport({ depth, includeWikiPages }) {
if (!state.currentConceptMap) {
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."));
}
return markdownExporter.export(state.currentConceptMap, {
maxDepth: depth,
includeWikiPages,
language: state.language
});
}
async function buildCurrentCmapJsonExport(depth) {
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."));
}
return jsonExporter.export(state.currentConceptMap, depth);
}
async function importCmapBundleFile(file) {
const bundle = await jsonImporter.read(file);
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 conflicts = jsonImporter.conflicts(bundle, state.pages, state.conceptMaps);
let replaceExisting = false;
if (conflicts.pages.length || conflicts.conceptMaps.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}", conflicts.conceptMaps.length)
.replace("{pages}", conflicts.pages.length));
}
const result = await jsonImporter.import(bundle, {
pages: state.pages,
conceptMaps: state.conceptMaps,
replaceExisting,
summary: tr("imported-from-cmap-json", "Imported from CMap JSON")
});
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 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 cmapRepository.archive(conceptMap, typedTitle);
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 model = prototype.editor.currentModel();
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 cmapRepository.create(title.trim(), model);
const savedRoute = cmapRoute(state.currentConceptMap.slug);
history.replaceState(history.state, "", `${location.pathname}${location.search}${savedRoute}`);
state.cmapGuardHash = savedRoute;
await loadConceptMaps();
} else {
const savedConceptMap = await cmapRepository.save(
conceptMapAtStart,
model,
{
summary: summary || (automatic ?
tr("automatic-save", "Automatic save") :
tr("manual-save", "Manual save")),
snapshot: snapshotVersion,
saveKind: effectiveHistoryMode
});
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", async () => {
if (!state.currentConceptMap) return;
try {
await setStartCmapSlug(state.currentConceptMap.slug);
renderConceptMapSelector();
showCmapStatus(tr("start-concept-map-set", "Start CMap set"), true);
closeCmapContextMenu();
} catch (error) {
showCmapStatus(error.message);
}
});
$("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", () => {
peopleDialog.open().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", () => {
const conceptMap = currentCmapStorageMap();
if (conceptMap) {
historyDialog.open(conceptMap).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);
settingsRepository.setPageGuidesVisible(visible)
.catch((error) => {
setCmapPageGuides(settingsRepository.pageGuidesVisible);
showCmapStatus(error.message);
});
});
$("cmap-reset").addEventListener("click", () => {
if (state.currentConceptMap) {
requestCmapTransition(() => openStoredConceptMap(state.currentConceptMap.slug))
.catch((error) => console.error(error));
} else {
resetCmapPrototype();
markCurrentCmapSaved();
}
});
const navigateToParentCmap = () => {
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-map-navigation").addEventListener("click", navigateToParentCmap);
$("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 (cmapContextMenu.isOpen()) {
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-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-import-file").addEventListener("change", (event) => {
const file = event.currentTarget.files && event.currentTarget.files[0];
if (file) handleCmapImportFile(file);
});
document.addEventListener("keydown", (event) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLocaleLowerCase() === "s" &&
!$("cmap-view").classList.contains("hidden")) {
event.preventDefault();
if (can("editor")) {
if (pendingCmapTransition) {
unsavedDialog.chooseSave();
return;
}
saveStoredConceptMap()
.catch((error) => console.error(error));
}
return;
}
handleCmapKeyboardShortcut(event);
});
/**
* Load persistent CMap preferences and the data needed by the workspace.
*/
this.initialize = async () => {
const [appearance] = await Promise.all([
appearanceRepository.load(),
settingsRepository.load()
]);
appearanceEditor = new CmapAppearanceEditor(appearance, appearanceRepository, tr);
conceptDialog = new CmapConceptDialog(
$("cmap-concept-dialog"),
tr,
appearanceEditor,
peopleDialog,
normalizedCmapExternalUrl)
.onSave(saveCmapConcept);
restoreCmapPageGuides();
await loadConceptMaps();
await peopleDialog.load();
};
this.renderNodeHtml = cmapNodeHtml;
this.queueEmbedHydration = queueCmapEmbedHydration;
this.loadConceptMaps = loadConceptMaps;
this.conceptMapEntry = titledCmapComboboxEntry;
this.normalizeExternalUrl = normalizedCmapExternalUrl;
this.clearDescriptionPreviews = () => cmapDescriptionPreview.clear();
this.requestTransition = requestCmapTransition;
this.show = showCmapPrototype;
this.hasUnsavedChanges = cmapHasUnsavedChanges;
this.startSlug = startCmapSlug;
}
}