1188 lines
45 KiB
JavaScript
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;
|
|
}
|
|
}
|