1811 lines
75 KiB
JavaScript
1811 lines
75 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">👤</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: () => 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() {
|
|
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();
|
|
}
|
|
});
|
|
$("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;
|
|
}
|
|
}
|