Files
racket-wiki/static/js/wiki/cmap/cmap-workspace-controller.js
T
2026-09-03 17:24:56 +02:00

1188 lines
45 KiB
JavaScript

import {
newPageReference,
pageReference,
splitPageReference
} from "../reference.js";
import { cmapRoute, pageRoute } from "../routes.js";
import { cmapMentionTarget, escapeHtml } from "../markdown.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 { CmapReferenceIndex } from "../../cmap/model/cmap-reference-index.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";
import { CmapStorageController } from "./cmap-storage-controller.js";
import { CmapNavigationController } from "./cmap-navigation-controller.js";
import { CmapTransferController } from "./cmap-transfer-controller.js";
import { CmapEditorUiController } from "./cmap-editor-ui-controller.js";
import { CmapEditorHost } from "./cmap-editor-host.js";
import { CmapMapController } from "./cmap-map-controller.js";
import { CmapConceptController } from "./cmap-concept-controller.js";
import { CmapEmbedView } from "./cmap-embed-view.js";
import { CmapEditorPresentation } from "./cmap-editor-presentation.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 CmapWorkspaceController {
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 cmapReferenceIndex = new CmapReferenceIndex();
const presentation = new CmapEditorPresentation({
state,
translate: tr,
canonicalPageReference,
referenceIndex: cmapReferenceIndex
});
const embedView = new CmapEmbedView({
repository: cmapRepository,
conceptMaps: () => state.conceptMaps,
resolveReference: cmapMentionTarget,
route: cmapRoute,
translate: tr
});
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"));
const storage = new CmapStorageController({
repository: cmapRepository,
getEditor: () => cmapPrototypeState().editor,
getCurrentMap: currentCmapStorageMap,
getViewVisible: () => !$("cmap-view").classList.contains("hidden"),
renderSvg: currentCmapRenderedSvg,
reloadMaps: loadConceptMaps,
status: showCmapStatus,
translate: tr,
unsavedDialog,
canEdit: () => can("editor"),
onMapSaved: (saved) => {
if (!state.currentConceptMap && !state.currentConceptMapSource) {
state.currentConceptMap = saved;
const savedRoute = cmapRoute(saved.slug);
history.replaceState(history.state, "", `${location.pathname}${location.search}${savedRoute}`);
state.cmapGuardHash = savedRoute;
} else if (state.currentConceptMapSource?.slug === saved.slug) {
state.currentConceptMapSource = saved;
} else if (state.currentConceptMap?.slug === saved.slug) {
state.currentConceptMap = saved;
}
updateStoredConceptMapSummary(saved);
}
});
const navigation = new CmapNavigationController({
navigateToHash,
cmapRoute,
getEditor: () => cmapPrototypeState().editor,
getCurrentMap: currentCmapStorageMap,
getParentMap: () => state.currentConceptMapSource,
storage
});
const transfer = new CmapTransferController({
repository: cmapRepository,
jsonImporter,
jsonExporter,
markdownExporter,
getCurrentMap: () => state.currentConceptMap,
getPages: () => state.pages,
getConceptMaps: () => state.conceptMaps,
loadPages,
loadConceptMaps,
navigateToMap: (slug) => navigation.open(slug),
saveBeforeTransfer: () => cmapHasUnsavedChanges() ?
saveStoredConceptMap({ automatic: true, force: true, historyMode: "autosave" }) : true,
confirm: (message) => window.confirm(message),
status: showCmapStatus,
translate: tr
});
const editorUi = new CmapEditorUiController({
root: document,
getEditor: () => cmapPrototypeState().editor,
actions: {
save: () => saveStoredConceptMap(),
edit: () => editSelectedCmapNode(),
group: () => groupSelectedCmapItems(),
zoom: (value, absolute = false) => setCmapZoom(
absolute ? Number(value) : Number($("cmap-zoom-percent").value) + value),
togglePageGuides: () => {
const visible = $("cmap-toggle-page-guides").getAttribute("aria-checked") !== "true";
setCmapPageGuides(visible);
settingsRepository.setPageGuidesVisible(visible).catch((error) => showCmapStatus(error.message));
},
selection: (action, editor) => {
if (action === "edit") editSelectedCmapNode();
if (action === "group") groupSelectedCmapItems();
if (action === "ungroup") editor.ungroupSelection();
if (action === "hide") editor.hideSelectionInCurrentContext();
}
}
});
const editorHost = new CmapEditorHost({
canvas: $("cmap-canvas"),
factory: (canvas, options) => window.RacketWikiCmap.createEditor(canvas, options),
createOptions: () => ({})
});
const mapController = new CmapMapController({
state,
repository: cmapRepository,
referenceIndex: cmapReferenceIndex,
canonicalPageReference,
normalizeConceptName: normalizeCmapConceptName,
renderSelector: renderConceptMapSelector,
getEditor: () => cmapPrototypeState().editor,
resetEditor: resetCmapPrototype,
getCurrentStorageMap: currentCmapStorageMap,
save: saveStoredConceptMap,
markSaved: markCurrentCmapSaved,
cancelAutosave: cancelCmapAutosave,
hasUnsavedChanges: cmapHasUnsavedChanges,
status: showCmapStatus,
translate: tr,
cmapRoute,
navigateToHash,
navigateToMap: (slug) => navigateToHash(cmapRoute(slug)),
refreshPageConnections: renderPageCmapConnections
});
const conceptController = new CmapConceptController({
state,
api,
translate: tr,
splitPageReference,
pageReference,
getEditor: () => cmapPrototypeState().editor,
getCurrentStorageMap: currentCmapStorageMap,
save: saveStoredConceptMap,
loadPages,
getDialog: () => conceptDialog,
status: showCmapStatus,
conceptIdForLabel: sharedCmapConceptId,
getNamespace: diagramNamespace,
getPlacementContext: () => cmapContextCreateContext
});
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;
embedView.render(document).catch((error) => console.error(error));
}, 0);
}
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;
}
function cmapNodeHtml(record) {
return presentation.renderItem(record);
}
function effectiveCmapConceptUsage(record) {
return presentation.conceptUsage(record);
}
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;
}
function showCmapDescriptionTooltip(button) {
const item = button.closest("[data-rw-cmap-item-id]");
const editor = cmapPrototypeState().editor;
const record = item && editor ? editor.itemRecord(Number(item.dataset.rwCmapItemId)) : null;
if (record?.descriptionPageSlug) {
cmapDescriptionPreview.show(button, record.descriptionPageSlug, tr("loading", "Loading…"));
}
}
function hideCmapDescriptionTooltip() {
cmapDescriptionPreview.hide();
}
function addCmapPrototypeNode(options = {}) {
return conceptController.addNode(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) {
return navigation.openLinked(record);
}
function rememberActiveCmapContext() {
return navigation.rememberContext();
}
async function restoreActiveCmapContext(slug) {
return navigation.restoreContext(slug);
}
function openParentCmap() {
return navigation.openParent();
}
function titledCmapComboboxEntry(record) {
const label = record.title || record.slug;
return {
value: record.slug,
label,
description: record.slug
};
}
function currentConceptDialogResources() {
return conceptController.dialogResources();
}
/**
* 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) {
return conceptController.openDialog(record);
}
function openNewCmapConceptDialog(context = {}) {
return conceptController.openNewDialog(context);
}
/** Apply validated concept-dialog values as one editor history transaction. */
async function saveCmapConcept(record, createContext, values) {
return conceptController.saveConcept(record, createContext, values);
}
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) {
return conceptController.updateSelectionToolbar(editor, selectedRecords, connector);
}
function diagramNamespace() {
const model = state.currentConceptMapSource?.model || state.currentConceptMap?.model;
return model?.metadata()?.namespace || "";
}
async function moveSelectedDescriptionsToNamespace() {
return conceptController.moveSelectedDescriptionsToNamespace();
}
function openCmapContextMenu(clientX, clientY, createContext = {}) {
cmapContextCreateContext = createContext;
cmapContextMenu.openAt(clientX, clientY);
}
function cmapPlacementOptions() {
return conceptController.placementOptions();
}
/**
* 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) {
return conceptController.editSelected(record);
}
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() {
return conceptController.groupSelection();
}
function populateCmapPrototypeSubmap(record, editor) {
return conceptController.populateSubmap(record, editor);
}
/**
* 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 = "";
editorHost.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("/js/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 = editorHost.create({
renderItem: (record) => cmapNodeHtml(record),
boundaryReferenceMapTitle: state.currentConceptMapSource?.title ||
state.currentConceptMap?.title || "",
onOpenPage: (record) => {
if (record.pageSlug) {
const namespace = state.currentConceptMapSource?.model?.metadata()?.namespace ||
state.currentConceptMap?.model?.metadata()?.namespace || "";
const isDescriptionReference = record.descriptionPageSlug === record.pageSlug;
const page = isDescriptionReference ? splitPageReference(record.pageSlug) : null;
const existingPage = state.pages.some((pageRecord) =>
pageRecord.slug === record.pageSlug);
const target = !existingPage && namespace && page?.namespace === "cmap" ?
pageReference(namespace, page.slug) : record.pageSlug;
if (isDescriptionReference && target !== record.pageSlug) {
record.pageSlug = target;
}
if (!state.pages.some((pageRecord) => pageRecord.slug === target)) {
state.newPageSuggestedTitle = record.label || "";
}
navigateToHash(pageRoute(target)).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: () => openParentCmap(),
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() {
return mapController.loadMaps();
}
function currentCmapSnapshot() {
return storage.currentSnapshot();
}
function currentCmapRenderedSvg() {
const editor = cmapPrototypeState().editor;
if (!editor || typeof editor.snapshotSvg !== "function") return "";
try {
return editor.snapshotSvg();
} catch (error) {
console.warn("The CMap SVG snapshot could not be created.", error);
return "";
}
}
function currentCmapStorageMap() {
return state.currentConceptMapSource || state.currentConceptMap;
}
function markCurrentCmapSaved(snapshot = currentCmapSnapshot()) {
storage.markSaved(snapshot);
state.cmapSavedSnapshot = snapshot;
}
function cmapHasUnsavedChanges() {
return storage.hasUnsavedChanges();
}
function cancelCmapAutosave() {
storage.cancelAutosave();
}
function scheduleCmapAutosave() {
storage.scheduleAutosave();
}
function updateStoredConceptMapSummary(conceptMap) {
return mapController.updateSummary(conceptMap);
}
async function requestCmapTransition(action) {
return navigation.requestTransition(action);
}
async function openStoredConceptMap(slug) {
return mapController.open(slug);
}
async function loadHistoricalConceptMapVersion(version) {
return mapController.loadHistoricalVersion(version);
}
async function createStoredConceptMap() {
return mapController.create();
}
async function promoteSelectedSubmapToStoredMap() {
return mapController.promoteSelectedSubmap();
}
async function renameStoredConceptMap() {
return mapController.rename();
}
function activeCmapMetadata() {
return mapController.activeMetadata();
}
function openCmapMetadataDialog() {
if (!state.currentConceptMap) return;
metadataDialog.open(activeCmapMetadata());
closeCmapContextMenu();
}
async function saveCmapMetadata(metadata) {
return mapController.saveMetadata(metadata);
}
function openCmapExportDialog() {
if (!state.currentConceptMap) return;
exportDialog.open(state.currentConceptMap.slug);
closeCmapContextMenu();
}
async function buildCurrentCmapMarkdownExport({ depth, includeWikiPages }) {
return transfer.exportMarkdown({
maxDepth: depth,
includeWikiPages,
language: state.language
});
}
async function buildCurrentCmapJsonExport(depth) {
return transfer.exportJson(depth);
}
async function importCmapBundleFile(file) {
return transfer.importFile(file);
}
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();
await storage.waitForSave();
return mapController.archive();
}
async function saveStoredConceptMap({ automatic = false, force = false,
summary = null, snapshotVersion = false,
historyMode = null } = {}) {
return storage.save({ automatic, force, summary, snapshotVersion, historyMode });
}
async function createConceptMapSnapshot() {
return mapController.createSnapshot();
}
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);
if (await restoreActiveCmapContext(requestedSlug)) markCurrentCmapSaved();
} 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-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-move-selected-to-namespace").addEventListener("click", () => {
moveSelectedDescriptionsToNamespace()
.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-cut-selected").addEventListener("click", () => {
const prototype = cmapPrototypeState();
if (prototype.editor) prototype.editor.cutSelectionReferences();
});
$("cmap-reset").addEventListener("click", () => {
if (state.currentConceptMap) {
requestCmapTransition(() => openStoredConceptMap(state.currentConceptMap.slug))
.catch((error) => console.error(error));
} else {
resetCmapPrototype();
markCurrentCmapSaved();
}
});
$("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-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 (storage.hasPendingTransition()) {
unsavedDialog.chooseSave();
return;
}
saveStoredConceptMap()
.catch((error) => console.error(error));
}
return;
}
handleCmapKeyboardShortcut(event);
});
editorUi.initialize();
/**
* 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.navigation = navigation;
this.transfer = transfer;
this.storage = storage;
this.show = showCmapPrototype;
this.hasUnsavedChanges = cmapHasUnsavedChanges;
this.startSlug = startCmapSlug;
}
}