compleet ontvlochten
This commit is contained in:
@@ -0,0 +1,236 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
/** Manage concept dialogs, selection actions and CMap concept page metadata. */
|
||||||
|
export class CmapConceptController {
|
||||||
|
constructor({ state, api, translate, splitPageReference, pageReference,
|
||||||
|
getEditor, getCurrentStorageMap, save, loadPages, getDialog, status,
|
||||||
|
conceptIdForLabel, getNamespace, getPlacementContext }) {
|
||||||
|
this.state = state;
|
||||||
|
this.api = api;
|
||||||
|
this.translate = translate;
|
||||||
|
this.splitPageReference = splitPageReference;
|
||||||
|
this.pageReference = pageReference;
|
||||||
|
this.getEditor = getEditor;
|
||||||
|
this.getCurrentStorageMap = getCurrentStorageMap;
|
||||||
|
this.save = save;
|
||||||
|
this.loadPages = loadPages;
|
||||||
|
this.getDialog = getDialog;
|
||||||
|
this.status = status;
|
||||||
|
this.conceptIdForLabel = conceptIdForLabel;
|
||||||
|
this.getNamespace = getNamespace;
|
||||||
|
this.getPlacementContext = getPlacementContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
addNode(options = {}) {
|
||||||
|
const editor = this.getEditor();
|
||||||
|
if (!editor) return null;
|
||||||
|
const conceptId = options.conceptId || this.conceptIdForLabel(options.label);
|
||||||
|
return editor.addItem(conceptId ? { ...options, conceptId } : options);
|
||||||
|
}
|
||||||
|
|
||||||
|
dialogResources() {
|
||||||
|
const editor = this.getEditor();
|
||||||
|
const namespaceModel = this.state.currentConceptMapSource?.model ||
|
||||||
|
this.state.currentConceptMap?.model;
|
||||||
|
return {
|
||||||
|
cmapNamespace: namespaceModel?.metadata()?.namespace || "",
|
||||||
|
pages: this.state.pages,
|
||||||
|
conceptMaps: this.state.conceptMaps,
|
||||||
|
parentMapAvailable: Boolean(editor && editor.activeMapRoot)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
openDialog(record) {
|
||||||
|
this.getDialog()?.open(record, this.dialogResources());
|
||||||
|
}
|
||||||
|
|
||||||
|
openNewDialog(context = {}) {
|
||||||
|
this.getDialog()?.openNew(context, this.dialogResources());
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveConcept(record, createContext, values) {
|
||||||
|
const editor = this.getEditor();
|
||||||
|
if ((!record && !createContext) || !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 = this.conceptIdForLabel(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;
|
||||||
|
editor.refreshConceptUsageIndicators([previousConceptId, existingConceptId].filter(Boolean));
|
||||||
|
}
|
||||||
|
editor.updateItem(record, changes);
|
||||||
|
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 = this.addNode(changes);
|
||||||
|
if (createContext.source && newRecord) editor.finishRelation(createContext.source, newRecord);
|
||||||
|
else if (newRecord) editor.selectItem(newRecord);
|
||||||
|
}
|
||||||
|
editor.commitHistory();
|
||||||
|
if (this.getCurrentStorageMap()) await this.save({ automatic: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSelectionToolbar(editor, selectedRecords = [], connector = null) {
|
||||||
|
const toolbar = document.getElementById("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();
|
||||||
|
document.getElementById("cmap-move-selected-to-namespace").disabled = !editor ||
|
||||||
|
!this.getNamespace() || !selected.some((item) => item.kind !== "phrase");
|
||||||
|
}
|
||||||
|
|
||||||
|
pageMatchesReference(page, reference) {
|
||||||
|
if (page.slug === reference) return true;
|
||||||
|
const parts = this.splitPageReference(reference);
|
||||||
|
return String(page.namespace || "").toLocaleLowerCase() === parts.namespace.toLocaleLowerCase() &&
|
||||||
|
String(page.pageSlug || this.splitPageReference(page.slug).slug).toLocaleLowerCase() ===
|
||||||
|
parts.slug.toLocaleLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
async moveSelectedDescriptionsToNamespace() {
|
||||||
|
const editor = this.getEditor();
|
||||||
|
const namespace = this.getNamespace();
|
||||||
|
if (!editor || !namespace) return false;
|
||||||
|
const records = editor.selectedAll().filter((record) => record.kind !== "phrase");
|
||||||
|
if (!records.length) return false;
|
||||||
|
const changes = [];
|
||||||
|
for (const record of records) {
|
||||||
|
const current = record.descriptionPageSlug;
|
||||||
|
if (!current) continue;
|
||||||
|
const parts = this.splitPageReference(current);
|
||||||
|
const target = this.pageReference(namespace, parts.slug);
|
||||||
|
if (target === current) continue;
|
||||||
|
const existing = this.state.pages.find((page) => this.pageMatchesReference(page, current));
|
||||||
|
const conflict = this.state.pages.find((page) => this.pageMatchesReference(page, target));
|
||||||
|
if (existing && conflict && existing !== conflict) {
|
||||||
|
this.status(this.translate("namespace-move-conflict", "Cannot move {page}: the target page already exists.")
|
||||||
|
.replace("{page}", target));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
changes.push({ record, current, target, existing });
|
||||||
|
}
|
||||||
|
if (!changes.length) return false;
|
||||||
|
for (const change of changes) {
|
||||||
|
if (change.existing) {
|
||||||
|
const page = await this.api(`/api/pages/${encodeURIComponent(change.current)}/rename`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: change.existing.title,
|
||||||
|
namespace,
|
||||||
|
slug: this.splitPageReference(change.current).slug,
|
||||||
|
summary: this.translate("move-description-page-summary", "Moved description page to CMap namespace")
|
||||||
|
})
|
||||||
|
});
|
||||||
|
change.target = page.slug;
|
||||||
|
}
|
||||||
|
editor.updateItem(change.record, { descriptionPageSlug: change.target });
|
||||||
|
}
|
||||||
|
await this.loadPages();
|
||||||
|
editor.commitHistory();
|
||||||
|
if (this.getCurrentStorageMap()) await this.save({ automatic: true });
|
||||||
|
this.status(this.translate("descriptions-moved-to-namespace", "Selected descriptions moved to {namespace}.")
|
||||||
|
.replace("{namespace}", namespace), true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
placementOptions() {
|
||||||
|
const context = this.getPlacementContext() || {};
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
editSelected(record = null) {
|
||||||
|
const editor = this.getEditor();
|
||||||
|
const selectedRecord = record || editor?.selected();
|
||||||
|
if (!selectedRecord) {
|
||||||
|
window.alert(this.translate("select-one-concept", "Select one concept first."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selectedRecord.kind === "phrase") {
|
||||||
|
editor.editPhraseInline(selectedRecord);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.openDialog(selectedRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
groupSelection() {
|
||||||
|
const editor = this.getEditor();
|
||||||
|
if (!editor || !editor.canGroupSelection()) return false;
|
||||||
|
const label = window.prompt(
|
||||||
|
this.translate("group-submap-name", "Name of the main concept for the new sub-CMap"),
|
||||||
|
this.translate("sub-concept-map", "Sub concept map"));
|
||||||
|
if (!label || !label.trim()) return false;
|
||||||
|
return editor.groupSelection({
|
||||||
|
label: label.trim(),
|
||||||
|
conceptId: this.conceptIdForLabel(label),
|
||||||
|
childMap: label.trim(),
|
||||||
|
synopsis: this.translate("grouped-submap-synopsis", "Grouped sub-concept map.")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
populateSubmap(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,7 +30,6 @@ export class CmapEditorUiController {
|
|||||||
this.bind("#cmap-group-selected", "click", () => this.actions.group?.());
|
this.bind("#cmap-group-selected", "click", () => this.actions.group?.());
|
||||||
this.bind("#cmap-ungroup-selected", "click", (_event, editor) => editor?.ungroupSelection());
|
this.bind("#cmap-ungroup-selected", "click", (_event, editor) => editor?.ungroupSelection());
|
||||||
this.bind("#cmap-hide-selected", "click", (_event, editor) => editor?.hideSelectionInCurrentContext());
|
this.bind("#cmap-hide-selected", "click", (_event, editor) => editor?.hideSelectionInCurrentContext());
|
||||||
this.bind("#cmap-select-all", "click", (_event, editor) => editor?.selectAll());
|
|
||||||
this.bind("#cmap-zoom-out", "click", () => this.actions.zoom?.(-10));
|
this.bind("#cmap-zoom-out", "click", () => this.actions.zoom?.(-10));
|
||||||
this.bind("#cmap-zoom-in", "click", () => this.actions.zoom?.(10));
|
this.bind("#cmap-zoom-in", "click", () => this.actions.zoom?.(10));
|
||||||
this.bind("#cmap-zoom-reset", "click", () => this.actions.zoom?.(100, true));
|
this.bind("#cmap-zoom-reset", "click", () => this.actions.zoom?.(100, true));
|
||||||
|
|||||||
@@ -0,0 +1,337 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
import { CmapModel } from "../../cmap/model/concept-map.js";
|
||||||
|
|
||||||
|
/** Manage loading, persistence workflows and metadata of stored concept maps. */
|
||||||
|
export class CmapMapController {
|
||||||
|
constructor({ state, repository, referenceIndex, canonicalPageReference,
|
||||||
|
normalizeConceptName, renderSelector, getEditor, resetEditor,
|
||||||
|
getCurrentStorageMap, save, markSaved, cancelAutosave, hasUnsavedChanges,
|
||||||
|
status, translate, cmapRoute, navigateToHash, navigateToMap, refreshPageConnections }) {
|
||||||
|
this.state = state;
|
||||||
|
this.repository = repository;
|
||||||
|
this.referenceIndex = referenceIndex;
|
||||||
|
this.canonicalPageReference = canonicalPageReference;
|
||||||
|
this.normalizeConceptName = normalizeConceptName;
|
||||||
|
this.renderSelector = renderSelector;
|
||||||
|
this.getEditor = getEditor;
|
||||||
|
this.resetEditor = resetEditor;
|
||||||
|
this.getCurrentStorageMap = getCurrentStorageMap;
|
||||||
|
this.save = save;
|
||||||
|
this.markSaved = markSaved;
|
||||||
|
this.cancelAutosave = cancelAutosave;
|
||||||
|
this.hasUnsavedChanges = hasUnsavedChanges;
|
||||||
|
this.status = status;
|
||||||
|
this.translate = translate;
|
||||||
|
this.cmapRoute = cmapRoute;
|
||||||
|
this.navigateToHash = navigateToHash;
|
||||||
|
this.navigateToMap = navigateToMap;
|
||||||
|
this.refreshPageConnections = refreshPageConnections;
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadMaps() {
|
||||||
|
const [conceptMaps, placements] = await Promise.all([
|
||||||
|
this.repository.list(),
|
||||||
|
this.repository.conceptUsage().catch((error) => {
|
||||||
|
console.warn("[racket-wiki:cmap-host 0.2.122] concept usage is unavailable; CMaps continue without global counts", error);
|
||||||
|
return [];
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
this.state.conceptMaps = conceptMaps;
|
||||||
|
this.referenceIndex.load(
|
||||||
|
placements,
|
||||||
|
conceptMaps,
|
||||||
|
this.canonicalPageReference,
|
||||||
|
this.normalizeConceptName);
|
||||||
|
this.state.cmapConceptUsage = this.referenceIndex.byConceptId;
|
||||||
|
this.state.cmapConceptIdsByName = this.referenceIndex.byName;
|
||||||
|
this.state.cmapPageConcepts = this.referenceIndex.byPage;
|
||||||
|
this.renderSelector();
|
||||||
|
const editor = this.getEditor();
|
||||||
|
if (editor) editor.refreshConceptUsageIndicators();
|
||||||
|
if (this.state.currentPage) this.refreshPageConnections(this.state.currentPage);
|
||||||
|
return this.state.conceptMaps;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSummary(conceptMap) {
|
||||||
|
const summary = typeof conceptMap.toSummary === "function" ?
|
||||||
|
conceptMap.toSummary() : conceptMap;
|
||||||
|
const index = this.state.conceptMaps.findIndex((item) => item.slug === conceptMap.slug);
|
||||||
|
if (index >= 0) {
|
||||||
|
this.state.conceptMaps[index] = { ...this.state.conceptMaps[index], ...summary };
|
||||||
|
} else {
|
||||||
|
this.state.conceptMaps.push(summary);
|
||||||
|
}
|
||||||
|
this.renderSelector();
|
||||||
|
}
|
||||||
|
|
||||||
|
async open(slug) {
|
||||||
|
if (!slug) return;
|
||||||
|
const loadSequence = ++this.state.cmapLoadSequence;
|
||||||
|
this.status(this.translate("loading", "Loading…"));
|
||||||
|
const conceptMap = await this.repository.load(slug);
|
||||||
|
if (loadSequence !== this.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 this.repository.load(derivedView.sourceCmapSlug);
|
||||||
|
if (loadSequence !== this.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
|
||||||
|
});
|
||||||
|
this.state.currentConceptMap = conceptMap;
|
||||||
|
this.state.currentConceptMapSource = sourceMap;
|
||||||
|
this.renderSelector();
|
||||||
|
this.resetEditor(editorModel, false);
|
||||||
|
if (sourceMap) this.openDerivedView(conceptMap, derivedView);
|
||||||
|
this.markSaved();
|
||||||
|
const editor = this.getEditor();
|
||||||
|
console.info("[racket-wiki:cmap-host 0.2.122] stored CMap loaded", {
|
||||||
|
slug: conceptMap.slug,
|
||||||
|
editorAvailable: Boolean(editor),
|
||||||
|
itemCount: editor ? editor.itemCount() : 0,
|
||||||
|
connectorCount: editor ? editor.connectorCount() : 0
|
||||||
|
});
|
||||||
|
this.status(this.translate("concept-map-loaded", "CMap loaded"), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
openDerivedView(conceptMap, derivedView) {
|
||||||
|
const editor = this.getEditor();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadHistoricalVersion(version) {
|
||||||
|
const conceptMap = this.getCurrentStorageMap();
|
||||||
|
if (!conceptMap) return;
|
||||||
|
const historical = await this.repository.loadVersion(conceptMap, version);
|
||||||
|
const currentSnapshot = JSON.stringify(conceptMap.toDocument());
|
||||||
|
this.resetEditor(historical.model, false);
|
||||||
|
this.state.cmapSavedSnapshot = currentSnapshot;
|
||||||
|
this.status(
|
||||||
|
this.translate("concept-map-version-loaded", "Version {version} loaded; save to make it current.")
|
||||||
|
.replace("{version}", String(historical.version)),
|
||||||
|
true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create() {
|
||||||
|
const title = window.prompt(this.translate("concept-map-name", "Concept map name"), "");
|
||||||
|
if (!title || !title.trim()) return;
|
||||||
|
const conceptMap = await this.repository.create(title.trim(), new CmapModel());
|
||||||
|
await this.loadMaps();
|
||||||
|
location.hash = this.cmapRoute(conceptMap.slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
async promoteSelectedSubmap() {
|
||||||
|
const editor = this.getEditor();
|
||||||
|
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) {
|
||||||
|
this.status(this.translate("empty-submap", "This sub-CMap has no concepts to move."));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceMap = this.getCurrentStorageMap();
|
||||||
|
let linkedMap = null;
|
||||||
|
let title = null;
|
||||||
|
if (record.cmapSlug) {
|
||||||
|
linkedMap = this.state.currentConceptMap?.slug === record.cmapSlug ?
|
||||||
|
this.state.currentConceptMap : await this.repository.load(record.cmapSlug);
|
||||||
|
const derivedView = linkedMap.model.derivedView();
|
||||||
|
const matchesSource = sourceMap && derivedView &&
|
||||||
|
derivedView.sourceCmapSlug === sourceMap.slug &&
|
||||||
|
Number(derivedView.rootItemId) === Number(record.id);
|
||||||
|
if (!matchesSource) {
|
||||||
|
this.status(this.translate("submap-already-independent",
|
||||||
|
"This linked CMap is already independent."), true);
|
||||||
|
await this.navigateToMap(record.cmapSlug);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
title = window.prompt(
|
||||||
|
this.translate("submap-name", "Name of the new concept map"),
|
||||||
|
record.childMap || record.label);
|
||||||
|
if (!title || !title.trim()) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buttons = [document.getElementById("cmap-promote-submap"),
|
||||||
|
document.getElementById("cmap-extract-selected")];
|
||||||
|
for (const button of buttons) button.disabled = true;
|
||||||
|
try {
|
||||||
|
const sourceSaved = await this.save({
|
||||||
|
force: true,
|
||||||
|
snapshotVersion: true,
|
||||||
|
summary: this.translate("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 this.repository.save(storedMap, extraction.childModel, {
|
||||||
|
summary: this.translate("submap-extracted", "Sub-CMap moved to a separate CMap"),
|
||||||
|
saveKind: "manual"
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const prepared = editor.prepareStoredSubmapExtraction(record, null);
|
||||||
|
if (!prepared) return false;
|
||||||
|
storedMap = await this.repository.create(title.trim(), prepared.childModel);
|
||||||
|
extraction = editor.prepareStoredSubmapExtraction(record, storedMap.slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.replaceModel(extraction.parentModel);
|
||||||
|
const parentSaved = await this.save({
|
||||||
|
force: true,
|
||||||
|
historyMode: "autosave",
|
||||||
|
summary: this.translate("submap-extracted", "Sub-CMap moved to a separate CMap")
|
||||||
|
});
|
||||||
|
if (!parentSaved) {
|
||||||
|
this.status(this.translate("submap-created-parent-unsaved",
|
||||||
|
"The new CMap was created, but the parent CMap still needs to be saved."));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await this.navigateToMap(storedMap.slug);
|
||||||
|
return true;
|
||||||
|
} finally {
|
||||||
|
const selected = editor.selected();
|
||||||
|
const canExtractSubmap = selected && selected.kind === "submap";
|
||||||
|
for (const button of buttons) button.disabled = !canExtractSubmap;
|
||||||
|
document.getElementById("cmap-extract-selected")
|
||||||
|
.classList.toggle("hidden", !canExtractSubmap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async rename() {
|
||||||
|
this.cancelAutosave();
|
||||||
|
if (this.hasUnsavedChanges() && !await this.save({ automatic: true })) return false;
|
||||||
|
const conceptMap = this.state.currentConceptMap;
|
||||||
|
if (!conceptMap) return false;
|
||||||
|
const title = window.prompt(
|
||||||
|
this.translate("rename-concept-map", "Rename CMap"), conceptMap.title);
|
||||||
|
if (!title || !title.trim() || title.trim() === conceptMap.title) return false;
|
||||||
|
try {
|
||||||
|
this.state.currentConceptMap = await this.repository.rename(conceptMap, title.trim());
|
||||||
|
await this.loadMaps();
|
||||||
|
this.status(this.translate("concept-map-renamed", "CMap renamed"), true);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
this.status(error.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
activeMetadata() {
|
||||||
|
const currentModel = this.state.currentConceptMap?.model;
|
||||||
|
if (currentModel?.derivedView()) {
|
||||||
|
const metadata = currentModel.metadata();
|
||||||
|
return {
|
||||||
|
namespace: metadata.namespace || "",
|
||||||
|
tags: Array.isArray(metadata.tags) ? metadata.tags : [],
|
||||||
|
summary: metadata.summary || "",
|
||||||
|
explanationPageSlug: metadata.explanationPageSlug || ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const editor = this.getEditor();
|
||||||
|
return editor?.getDocumentMetadata ? editor.getDocumentMetadata() :
|
||||||
|
{ tags: [], summary: "", explanationPageSlug: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveMetadata(metadata) {
|
||||||
|
const conceptMap = this.state.currentConceptMap;
|
||||||
|
const editor = this.getEditor();
|
||||||
|
if (!conceptMap || !editor) return false;
|
||||||
|
const legacyExplanation = `cmap:${conceptMap.slug}`;
|
||||||
|
if (metadata.namespace && metadata.explanationPageSlug === legacyExplanation) {
|
||||||
|
metadata = { ...metadata, explanationPageSlug: `${metadata.namespace}:${conceptMap.slug}` };
|
||||||
|
}
|
||||||
|
if (conceptMap.model.derivedView()) {
|
||||||
|
const updatedModel = conceptMap.model.withMetadata(metadata);
|
||||||
|
const updated = await this.repository.save(conceptMap, updatedModel, {
|
||||||
|
summary: this.translate("updated-concept-map-details", "Updated CMap details"),
|
||||||
|
saveKind: "manual"
|
||||||
|
});
|
||||||
|
this.state.currentConceptMap = updated;
|
||||||
|
this.updateSummary(updated);
|
||||||
|
} else {
|
||||||
|
editor.setDocumentMetadata(metadata);
|
||||||
|
if (!await this.save({
|
||||||
|
force: true,
|
||||||
|
summary: this.translate("updated-concept-map-details", "Updated CMap details")
|
||||||
|
})) return false;
|
||||||
|
}
|
||||||
|
this.status(this.translate("concept-map-details-saved", "CMap details saved"), true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async archive() {
|
||||||
|
this.cancelAutosave();
|
||||||
|
const conceptMap = this.state.currentConceptMap;
|
||||||
|
if (!conceptMap) return false;
|
||||||
|
const question = this.translate(
|
||||||
|
"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(
|
||||||
|
this.translate("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) {
|
||||||
|
this.status(this.translate("archive-concept-map-title-mismatch", "The CMap name did not match; nothing was archived."));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.repository.archive(conceptMap, typedTitle);
|
||||||
|
this.markSaved();
|
||||||
|
this.state.currentConceptMap = null;
|
||||||
|
this.state.currentConceptMapSource = null;
|
||||||
|
await this.loadMaps();
|
||||||
|
const target = this.state.conceptMaps.length ?
|
||||||
|
this.cmapRoute(this.state.conceptMaps[0].slug) : "#cmaps";
|
||||||
|
await this.navigateToHash(target);
|
||||||
|
this.status(this.translate("concept-map-archived", "CMap archived"), true);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
this.status(error.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSnapshot() {
|
||||||
|
if (!this.getCurrentStorageMap()) return false;
|
||||||
|
const description = window.prompt(this.translate("snapshot-description", "Snapshot description"), "");
|
||||||
|
if (description === null) return false;
|
||||||
|
return this.save({
|
||||||
|
force: true,
|
||||||
|
summary: description.trim() || this.translate("snapshot", "Snapshot"),
|
||||||
|
snapshotVersion: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
} from "../reference.js";
|
} from "../reference.js";
|
||||||
import { cmapRoute, pageRoute } from "../routes.js";
|
import { cmapRoute, pageRoute } from "../routes.js";
|
||||||
import { cmapMentionTarget, escapeHtml } from "../markdown.js";
|
import { cmapMentionTarget, escapeHtml } from "../markdown.js";
|
||||||
import { CmapModel } from "../../cmap/model/concept-map.js";
|
|
||||||
import { CmapAppearanceRepository } from "../../cmap/model/appearance-repository.js";
|
import { CmapAppearanceRepository } from "../../cmap/model/appearance-repository.js";
|
||||||
import { CmapRepository } from "../../cmap/model/cmap-repository.js";
|
import { CmapRepository } from "../../cmap/model/cmap-repository.js";
|
||||||
import { CmapJsonExporter } from "../../cmap/model/json-exporter.js";
|
import { CmapJsonExporter } from "../../cmap/model/json-exporter.js";
|
||||||
@@ -30,6 +29,8 @@ import { CmapNavigationController } from "./cmap-navigation-controller.js";
|
|||||||
import { CmapTransferController } from "./cmap-transfer-controller.js";
|
import { CmapTransferController } from "./cmap-transfer-controller.js";
|
||||||
import { CmapEditorUiController } from "./cmap-editor-ui-controller.js";
|
import { CmapEditorUiController } from "./cmap-editor-ui-controller.js";
|
||||||
import { CmapEditorHost } from "./cmap-editor-host.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 { CmapEmbedView } from "./cmap-embed-view.js";
|
||||||
import { CmapEditorPresentation } from "./cmap-editor-presentation.js";
|
import { CmapEditorPresentation } from "./cmap-editor-presentation.js";
|
||||||
|
|
||||||
@@ -199,6 +200,43 @@ export class CmapWorkspaceController {
|
|||||||
factory: (canvas, options) => window.RacketWikiCmap.createEditor(canvas, options),
|
factory: (canvas, options) => window.RacketWikiCmap.createEditor(canvas, options),
|
||||||
createOptions: () => ({})
|
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;
|
let cmapEmbedHydrationTimer = null;
|
||||||
const CMAP_AUTOSAVE_DELAY = 1500;
|
const CMAP_AUTOSAVE_DELAY = 1500;
|
||||||
@@ -255,10 +293,7 @@ export class CmapWorkspaceController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function addCmapPrototypeNode(options = {}) {
|
function addCmapPrototypeNode(options = {}) {
|
||||||
const prototype = cmapPrototypeState();
|
return conceptController.addNode(options);
|
||||||
if (!prototype.editor) return null;
|
|
||||||
const conceptId = options.conceptId || sharedCmapConceptId(options.label);
|
|
||||||
return prototype.editor.addItem(conceptId ? { ...options, conceptId } : options);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeCmapConceptName(label) {
|
function normalizeCmapConceptName(label) {
|
||||||
@@ -315,15 +350,7 @@ export class CmapWorkspaceController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function currentConceptDialogResources() {
|
function currentConceptDialogResources() {
|
||||||
const prototype = cmapPrototypeState();
|
return conceptController.dialogResources();
|
||||||
const namespaceModel = state.currentConceptMapSource?.model ||
|
|
||||||
state.currentConceptMap?.model;
|
|
||||||
return {
|
|
||||||
cmapNamespace: namespaceModel?.metadata()?.namespace || "",
|
|
||||||
pages: state.pages,
|
|
||||||
conceptMaps: state.conceptMaps,
|
|
||||||
parentMapAvailable: Boolean(prototype.editor && prototype.editor.activeMapRoot)
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -332,74 +359,16 @@ export class CmapWorkspaceController {
|
|||||||
* post : A modal form shows concept text, presentation and image fields.
|
* post : A modal form shows concept text, presentation and image fields.
|
||||||
*/
|
*/
|
||||||
function openCmapConceptDialog(record) {
|
function openCmapConceptDialog(record) {
|
||||||
if (conceptDialog) conceptDialog.open(record, currentConceptDialogResources());
|
return conceptController.openDialog(record);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openNewCmapConceptDialog(context = {}) {
|
function openNewCmapConceptDialog(context = {}) {
|
||||||
if (conceptDialog) {
|
return conceptController.openNewDialog(context);
|
||||||
conceptDialog.openNew(context, currentConceptDialogResources());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Apply validated concept-dialog values as one editor history transaction. */
|
/** Apply validated concept-dialog values as one editor history transaction. */
|
||||||
async function saveCmapConcept(record, createContext, values) {
|
async function saveCmapConcept(record, createContext, values) {
|
||||||
const prototype = cmapPrototypeState();
|
return conceptController.saveConcept(record, createContext, values);
|
||||||
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) {
|
function applyCmapZoom(value) {
|
||||||
@@ -480,23 +449,7 @@ export class CmapWorkspaceController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateCmapSelectionToolbar(editor, selectedRecords = [], connector = null) {
|
function updateCmapSelectionToolbar(editor, selectedRecords = [], connector = null) {
|
||||||
const toolbar = $("cmap-selection-toolbar");
|
return conceptController.updateSelectionToolbar(editor, selectedRecords, connector);
|
||||||
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();
|
|
||||||
const namespaceButton = $("cmap-move-selected-to-namespace");
|
|
||||||
namespaceButton.disabled = !editor || !diagramNamespace() ||
|
|
||||||
!selected.some((item) => item.kind !== "phrase");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function diagramNamespace() {
|
function diagramNamespace() {
|
||||||
@@ -504,58 +457,8 @@ export class CmapWorkspaceController {
|
|||||||
return model?.metadata()?.namespace || "";
|
return model?.metadata()?.namespace || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function pageMatchesReference(page, reference) {
|
|
||||||
if (page.slug === reference) return true;
|
|
||||||
const parts = splitPageReference(reference);
|
|
||||||
return String(page.namespace || "").toLocaleLowerCase() === parts.namespace.toLocaleLowerCase() &&
|
|
||||||
String(page.pageSlug || splitPageReference(page.slug).slug).toLocaleLowerCase() ===
|
|
||||||
parts.slug.toLocaleLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function moveSelectedDescriptionsToNamespace() {
|
async function moveSelectedDescriptionsToNamespace() {
|
||||||
const editor = cmapPrototypeState().editor;
|
return conceptController.moveSelectedDescriptionsToNamespace();
|
||||||
const namespace = diagramNamespace();
|
|
||||||
if (!editor || !namespace) return false;
|
|
||||||
const records = editor.selectedAll().filter((record) => record.kind !== "phrase");
|
|
||||||
if (!records.length) return false;
|
|
||||||
const changes = [];
|
|
||||||
for (const record of records) {
|
|
||||||
const current = record.descriptionPageSlug;
|
|
||||||
if (!current) continue;
|
|
||||||
const parts = splitPageReference(current);
|
|
||||||
const target = pageReference(namespace, parts.slug);
|
|
||||||
if (target === current) continue;
|
|
||||||
const existing = state.pages.find((page) => pageMatchesReference(page, current));
|
|
||||||
const conflict = state.pages.find((page) => pageMatchesReference(page, target));
|
|
||||||
if (existing && conflict && existing !== conflict) {
|
|
||||||
showCmapStatus(tr("namespace-move-conflict", "Cannot move {page}: the target page already exists.")
|
|
||||||
.replace("{page}", target));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
changes.push({ record, current, target, existing });
|
|
||||||
}
|
|
||||||
if (!changes.length) return false;
|
|
||||||
for (const change of changes) {
|
|
||||||
if (change.existing) {
|
|
||||||
const page = await api(`/api/pages/${encodeURIComponent(change.current)}/rename`, {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({
|
|
||||||
title: change.existing.title,
|
|
||||||
namespace,
|
|
||||||
slug: splitPageReference(change.current).slug,
|
|
||||||
summary: tr("move-description-page-summary", "Moved description page to CMap namespace")
|
|
||||||
})
|
|
||||||
});
|
|
||||||
change.target = page.slug;
|
|
||||||
}
|
|
||||||
editor.updateItem(change.record, { descriptionPageSlug: change.target });
|
|
||||||
}
|
|
||||||
await loadPages();
|
|
||||||
editor.commitHistory();
|
|
||||||
if (currentCmapStorageMap()) await saveStoredConceptMap({ automatic: true });
|
|
||||||
showCmapStatus(tr("descriptions-moved-to-namespace", "Selected descriptions moved to {namespace}.")
|
|
||||||
.replace("{namespace}", namespace), true);
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCmapContextMenu(clientX, clientY, createContext = {}) {
|
function openCmapContextMenu(clientX, clientY, createContext = {}) {
|
||||||
@@ -564,17 +467,7 @@ export class CmapWorkspaceController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function cmapPlacementOptions() {
|
function cmapPlacementOptions() {
|
||||||
const context = cmapContextCreateContext || {};
|
return conceptController.placementOptions();
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -583,17 +476,7 @@ export class CmapWorkspaceController {
|
|||||||
* post : A concept dialog or the inline phrase editor is opened.
|
* post : A concept dialog or the inline phrase editor is opened.
|
||||||
*/
|
*/
|
||||||
function editSelectedCmapNode(record = null) {
|
function editSelectedCmapNode(record = null) {
|
||||||
const prototype = cmapPrototypeState();
|
return conceptController.editSelected(record);
|
||||||
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) {
|
function cmapKeyboardEditingTarget(target) {
|
||||||
@@ -684,46 +567,11 @@ export class CmapWorkspaceController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function groupSelectedCmapItems() {
|
function groupSelectedCmapItems() {
|
||||||
const prototype = cmapPrototypeState();
|
return conceptController.groupSelection();
|
||||||
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) {
|
function populateCmapPrototypeSubmap(record, editor) {
|
||||||
const baseX = Number(record.node.attr("x"));
|
return conceptController.populateSubmap(record, editor);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -945,27 +793,7 @@ export class CmapWorkspaceController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadConceptMaps() {
|
async function loadConceptMaps() {
|
||||||
const [conceptMaps, placements] = await Promise.all([
|
return mapController.loadMaps();
|
||||||
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;
|
|
||||||
cmapReferenceIndex.load(
|
|
||||||
placements,
|
|
||||||
conceptMaps,
|
|
||||||
canonicalPageReference,
|
|
||||||
normalizeCmapConceptName);
|
|
||||||
state.cmapConceptUsage = cmapReferenceIndex.byConceptId;
|
|
||||||
state.cmapConceptIdsByName = cmapReferenceIndex.byName;
|
|
||||||
state.cmapPageConcepts = cmapReferenceIndex.byPage;
|
|
||||||
renderConceptMapSelector();
|
|
||||||
const prototype = cmapPrototypeState();
|
|
||||||
if (prototype.editor) prototype.editor.refreshConceptUsageIndicators();
|
|
||||||
if (state.currentPage) renderPageCmapConnections(state.currentPage);
|
|
||||||
return state.conceptMaps;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentCmapSnapshot() {
|
function currentCmapSnapshot() {
|
||||||
@@ -1005,15 +833,7 @@ export class CmapWorkspaceController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateStoredConceptMapSummary(conceptMap) {
|
function updateStoredConceptMapSummary(conceptMap) {
|
||||||
const summary = typeof conceptMap.toSummary === "function" ?
|
return mapController.updateSummary(conceptMap);
|
||||||
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) {
|
async function requestCmapTransition(action) {
|
||||||
@@ -1021,204 +841,27 @@ export class CmapWorkspaceController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openStoredConceptMap(slug) {
|
async function openStoredConceptMap(slug) {
|
||||||
if (!slug) return;
|
return mapController.open(slug);
|
||||||
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) {
|
async function loadHistoricalConceptMapVersion(version) {
|
||||||
const conceptMap = currentCmapStorageMap();
|
return mapController.loadHistoricalVersion(version);
|
||||||
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() {
|
async function createStoredConceptMap() {
|
||||||
const title = window.prompt(tr("concept-map-name", "Concept map name"), "");
|
return mapController.create();
|
||||||
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() {
|
async function promoteSelectedSubmapToStoredMap() {
|
||||||
const prototype = cmapPrototypeState();
|
return mapController.promoteSelectedSubmap();
|
||||||
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() {
|
async function renameStoredConceptMap() {
|
||||||
cancelCmapAutosave();
|
return mapController.rename();
|
||||||
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() {
|
function activeCmapMetadata() {
|
||||||
const currentModel = state.currentConceptMap?.model;
|
return mapController.activeMetadata();
|
||||||
if (currentModel?.derivedView()) {
|
|
||||||
const metadata = currentModel.metadata();
|
|
||||||
return {
|
|
||||||
namespace: metadata.namespace || "",
|
|
||||||
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() {
|
function openCmapMetadataDialog() {
|
||||||
@@ -1228,34 +871,7 @@ export class CmapWorkspaceController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveCmapMetadata(metadata) {
|
async function saveCmapMetadata(metadata) {
|
||||||
const conceptMap = state.currentConceptMap;
|
return mapController.saveMetadata(metadata);
|
||||||
const editor = cmapPrototypeState().editor;
|
|
||||||
if (!conceptMap || !editor) return false;
|
|
||||||
const legacyExplanation = `cmap:${conceptMap.slug}`;
|
|
||||||
if (metadata.namespace && metadata.explanationPageSlug === legacyExplanation) {
|
|
||||||
metadata = {
|
|
||||||
...metadata,
|
|
||||||
explanationPageSlug: `${metadata.namespace}:${conceptMap.slug}`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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() {
|
function openCmapExportDialog() {
|
||||||
@@ -1301,38 +917,7 @@ export class CmapWorkspaceController {
|
|||||||
async function deleteStoredConceptMap() {
|
async function deleteStoredConceptMap() {
|
||||||
cancelCmapAutosave();
|
cancelCmapAutosave();
|
||||||
await storage.waitForSave();
|
await storage.waitForSave();
|
||||||
const conceptMap = state.currentConceptMap;
|
return mapController.archive();
|
||||||
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,
|
async function saveStoredConceptMap({ automatic = false, force = false,
|
||||||
@@ -1342,14 +927,7 @@ export class CmapWorkspaceController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function createConceptMapSnapshot() {
|
async function createConceptMapSnapshot() {
|
||||||
if (!currentCmapStorageMap()) return false;
|
return mapController.createSnapshot();
|
||||||
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) {
|
async function showCmapPrototype(requestedSlug = null) {
|
||||||
|
|||||||
Reference in New Issue
Block a user