added mermaid and a lot of cmap changes
This commit is contained in:
+368
-45
@@ -7,7 +7,10 @@
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
const debugPrefix = "[racket-wiki:cmap 0.2.99]";
|
||||
const debugPrefix = "[racket-wiki:cmap 0.2.122]";
|
||||
// The editor is rebuilt when navigating between stored CMaps. Clipboard
|
||||
// entries therefore belong to the shared module, not to one editor instance.
|
||||
let copiedConceptReferences = [];
|
||||
|
||||
function debug(message, details) {
|
||||
if (details === undefined) {
|
||||
@@ -80,12 +83,35 @@
|
||||
function newConceptId() {
|
||||
if (window.crypto && typeof window.crypto.randomUUID === "function") {
|
||||
try {
|
||||
return `concept-${window.crypto.randomUUID()}`;
|
||||
return window.crypto.randomUUID().toLowerCase();
|
||||
} catch (_error) {
|
||||
// randomUUID can be exposed but forbidden in an insecure/file context.
|
||||
}
|
||||
}
|
||||
return `concept-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
||||
const bytes = new Uint8Array(16);
|
||||
if (window.crypto && typeof window.crypto.getRandomValues === "function") {
|
||||
window.crypto.getRandomValues(bytes);
|
||||
} else {
|
||||
for (let index = 0; index < bytes.length; index += 1) {
|
||||
bytes[index] = Math.floor(Math.random() * 256);
|
||||
}
|
||||
}
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0"));
|
||||
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10).join("")}`;
|
||||
}
|
||||
|
||||
function normalizeConceptTags(tags) {
|
||||
if (!Array.isArray(tags)) return [];
|
||||
return tags.map((tag) => {
|
||||
if (typeof tag === "string") return { type: "label", value: tag.trim() };
|
||||
if (!tag || typeof tag !== "object") return null;
|
||||
return {
|
||||
type: String(tag.type || "label").trim() || "label",
|
||||
value: String(tag.value || tag.name || "").trim()
|
||||
};
|
||||
}).filter((tag) => tag && tag.value);
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////
|
||||
@@ -110,6 +136,7 @@
|
||||
this.onSubMapPromoted = options.onSubMapPromoted || null;
|
||||
this.onMapChange = options.onMapChange || null;
|
||||
this.onOpenCmap = options.onOpenCmap || null;
|
||||
this.onOpenExternalUrl = options.onOpenExternalUrl || null;
|
||||
this.onOpenStoredSubMap = options.onOpenStoredSubMap || null;
|
||||
this.onOpenBoundaryReference = options.onOpenBoundaryReference || null;
|
||||
this.onVisibilityChange = options.onVisibilityChange || null;
|
||||
@@ -118,6 +145,7 @@
|
||||
this.onCreateConnectedItem = options.onCreateConnectedItem || null;
|
||||
this.onSelectionChange = options.onSelectionChange || null;
|
||||
this.onHistoryChange = options.onHistoryChange || null;
|
||||
this.onAutomaticLayoutChange = options.onAutomaticLayoutChange || null;
|
||||
this.labels = {
|
||||
createRelation: options.createRelationLabel || "Create relation",
|
||||
editConcept: options.editConceptLabel || "Edit concept",
|
||||
@@ -131,12 +159,12 @@
|
||||
this.connectors = [];
|
||||
this.unresolvedConnectors = [];
|
||||
this.conceptMaps = new Map();
|
||||
this.documentMetadata = { tags: [], summary: "", explanationPageSlug: "" };
|
||||
this.activeMapRoot = null;
|
||||
this.mapHistory = [];
|
||||
this.selectedItem = null;
|
||||
this.selectedItems = new Set();
|
||||
this.selectedConnector = null;
|
||||
this.copiedConceptIds = [];
|
||||
this.destroyed = false;
|
||||
this.nextId = 1;
|
||||
this.nextConnectorId = 1;
|
||||
@@ -239,6 +267,7 @@
|
||||
this.connectors = [];
|
||||
this.unresolvedConnectors = [];
|
||||
this.conceptMaps = new Map();
|
||||
this.documentMetadata = { tags: [], summary: "", explanationPageSlug: "" };
|
||||
this.activeMapRoot = null;
|
||||
this.mapHistory = [];
|
||||
this.nextId = 1;
|
||||
@@ -322,10 +351,12 @@
|
||||
label: options.label || "Concept",
|
||||
synopsis: options.synopsis || "",
|
||||
aspects: Array.isArray(options.aspects) ? options.aspects.map(String) : [],
|
||||
tags: normalizeConceptTags(options.tags),
|
||||
descriptionPageSlug: kind === "phrase" ? null :
|
||||
(options.descriptionPageSlug || conceptDescriptionReference(options.label || "Concept", id)),
|
||||
pageSlug: options.pageSlug || null,
|
||||
cmapSlug: options.cmapSlug || null,
|
||||
externalUrl: options.externalUrl || null,
|
||||
parentCmapLink: Boolean(options.parentCmapLink),
|
||||
groupId: options.groupId || null,
|
||||
childMap: options.childMap || null,
|
||||
@@ -388,9 +419,9 @@
|
||||
fitContentPending: autoWidth || autoHeight,
|
||||
node: null
|
||||
};
|
||||
record.usageCount = record.conceptId && kind !== "phrase" && kind !== "submap" ?
|
||||
record.usageCount = record.conceptId && kind !== "phrase" ?
|
||||
this.items.filter((item) => item.conceptId === record.conceptId &&
|
||||
item.kind !== "phrase" && item.kind !== "submap").length + 1 : null;
|
||||
item.kind !== "phrase").length + 1 : null;
|
||||
|
||||
const node = this.map.node({
|
||||
content: this.itemHtml(record),
|
||||
@@ -424,7 +455,7 @@
|
||||
.map((item) => item.conceptId).filter(Boolean));
|
||||
for (const conceptId of ids) {
|
||||
const peers = this.items.filter((item) => item.conceptId === conceptId &&
|
||||
item.kind !== "phrase" && item.kind !== "submap");
|
||||
item.kind !== "phrase");
|
||||
for (const peer of peers) {
|
||||
peer.usageCount = peers.length;
|
||||
if (!peer.node) continue;
|
||||
@@ -811,6 +842,10 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
canStepBackWithinMap() {
|
||||
return this.mapHistory.length > 0;
|
||||
}
|
||||
|
||||
openParentMap() {
|
||||
if (!this.activeMapRoot) return false;
|
||||
this.clearSelection();
|
||||
@@ -983,6 +1018,7 @@
|
||||
descriptionPageSlug: headDocument.descriptionPageSlug,
|
||||
pageSlug: null,
|
||||
cmapSlug: targetSlug,
|
||||
externalUrl: headDocument.externalUrl || null,
|
||||
parentCmapLink: false,
|
||||
imageSource: headDocument.imageSource || ""
|
||||
});
|
||||
@@ -995,6 +1031,7 @@
|
||||
descriptionPageSlug: childHead.descriptionPageSlug,
|
||||
pageSlug: null,
|
||||
cmapSlug: null,
|
||||
externalUrl: childHead.externalUrl || null,
|
||||
parentCmapLink: false,
|
||||
imageSource: childHead.imageSource || ""
|
||||
});
|
||||
@@ -1084,16 +1121,18 @@
|
||||
// first, so that automatic sizing cannot turn the edited state into the
|
||||
// history baseline before it has been committed as its own Undo step.
|
||||
this.scheduleHistoryCommit();
|
||||
if (changes.tags !== undefined) changes.tags = normalizeConceptTags(changes.tags);
|
||||
for (const [key, value] of Object.entries(changes)) {
|
||||
if (value !== undefined) record[key] = value;
|
||||
}
|
||||
|
||||
const identityKeys = ["kind", "label", "synopsis", "aspects", "descriptionPageSlug",
|
||||
"pageSlug", "cmapSlug",
|
||||
"parentCmapLink", "imageSource"];
|
||||
if (record.conceptId && record.kind !== "submap") {
|
||||
const identityKeys = ["label", "synopsis", "aspects", "tags",
|
||||
"descriptionPageSlug",
|
||||
"pageSlug", "cmapSlug", "externalUrl",
|
||||
"imageSource"];
|
||||
if (record.conceptId && record.kind !== "phrase") {
|
||||
for (const peer of this.items) {
|
||||
if (peer === record || peer.conceptId !== record.conceptId || peer.kind === "submap") continue;
|
||||
if (peer === record || peer.conceptId !== record.conceptId || peer.kind === "phrase") continue;
|
||||
for (const key of identityKeys) {
|
||||
if (changes[key] !== undefined) peer[key] = changes[key];
|
||||
}
|
||||
@@ -1225,11 +1264,55 @@
|
||||
this.nextConnectorId = Math.max(this.nextConnectorId, record.id + 1);
|
||||
this.connectors.push(record);
|
||||
link.onRendered((_renderedLink, element) => this.decorateConnector(record, element));
|
||||
link.onConnectionChange((_changedLink, type, node) =>
|
||||
this.handleConnectorConnectionChange(record, type, node));
|
||||
link.visible(this.isItemVisible(source) && this.isItemVisible(target));
|
||||
this.scheduleHistoryCommit();
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep a dragged ionstage/cmap link endpoint and the persisted wiki
|
||||
* connector model in lockstep. The drawing library owns the endpoint
|
||||
* handles; the wiki model owns sourceId/targetId and submap projection.
|
||||
*/
|
||||
handleConnectorConnectionChange(connector, type, node) {
|
||||
if (!connector || (type !== "source" && type !== "target")) return false;
|
||||
const endpoint = this.items.find((item) => item.node === node) || null;
|
||||
const otherType = type === "source" ? "target" : "source";
|
||||
|
||||
// A connector is only a storable relation while both ends are attached
|
||||
// to different items. Restore the previous logical projection when an
|
||||
// endpoint is dropped on empty space or on its opposite endpoint.
|
||||
if (!endpoint || endpoint === connector[otherType]) {
|
||||
connector[`visual${type === "source" ? "Source" : "Target"}`] = null;
|
||||
this.applyConnectorVisualEndpoints(connector,
|
||||
this.connectorEndpoint(connector.source),
|
||||
this.connectorEndpoint(connector.target));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (connector[type] === endpoint) {
|
||||
connector[`visual${type === "source" ? "Source" : "Target"}`] = endpoint;
|
||||
return false;
|
||||
}
|
||||
|
||||
const previous = connector[type];
|
||||
connector[type] = endpoint;
|
||||
connector[`visual${type === "source" ? "Source" : "Target"}`] = endpoint;
|
||||
this.reconcilePhraseMembership();
|
||||
this.refreshSubmapVisibility();
|
||||
this.refreshConnectorGeometry();
|
||||
debug("connector endpoint changed", {
|
||||
connectorId: connector.id,
|
||||
type,
|
||||
previousItemId: previous ? previous.id : null,
|
||||
itemId: endpoint.id
|
||||
});
|
||||
this.scheduleHistoryCommit();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* goal : Select a concept/linking phrase, optionally beside the current selection.
|
||||
* pre : record belongs to this editor.
|
||||
@@ -1280,6 +1363,8 @@
|
||||
const selected = this.selectedItems.has(item);
|
||||
if (element) {
|
||||
element.classList.toggle("rw-cmap-selected", selected);
|
||||
element.classList.toggle(
|
||||
"rw-cmap-selected-primary", selected && item === this.selectedItem);
|
||||
if (selected) {
|
||||
element.setAttribute("aria-selected", "true");
|
||||
item.node.toFront();
|
||||
@@ -1316,10 +1401,12 @@
|
||||
for (const item of this.selectedItems) {
|
||||
if (item.submapFrameElement) {
|
||||
item.submapFrameElement.classList.remove("rw-cmap-submap-frame-selected");
|
||||
item.submapFrameElement.classList.remove("rw-cmap-submap-frame-selected-primary");
|
||||
}
|
||||
const element = item.node.element();
|
||||
if (element) {
|
||||
element.classList.remove("rw-cmap-selected");
|
||||
element.classList.remove("rw-cmap-selected-primary");
|
||||
element.removeAttribute("aria-selected");
|
||||
this.removeHandles(element);
|
||||
}
|
||||
@@ -1346,42 +1433,95 @@
|
||||
return Array.from(this.selectedItems);
|
||||
}
|
||||
|
||||
storeConceptReferences(records) {
|
||||
if (!records.length) return 0;
|
||||
const referencesById = new Map(records.map((source) =>
|
||||
[source.conceptId, {
|
||||
conceptId: source.conceptId,
|
||||
kind: source.kind === "submap" ? "concept" : source.kind,
|
||||
label: source.label,
|
||||
synopsis: source.synopsis,
|
||||
aspects: Array.isArray(source.aspects) ? [...source.aspects] : [],
|
||||
tags: normalizeConceptTags(source.tags).map((tag) => ({ ...tag })),
|
||||
descriptionPageSlug: source.descriptionPageSlug,
|
||||
pageSlug: source.pageSlug,
|
||||
cmapSlug: source.cmapSlug,
|
||||
externalUrl: source.externalUrl,
|
||||
imageSource: source.imageSource,
|
||||
width: Number(source.node.attr("width")),
|
||||
height: Number(source.node.attr("height")),
|
||||
backgroundColor: source.backgroundColor,
|
||||
borderColor: source.borderColor,
|
||||
textColor: source.textColor,
|
||||
fontFamily: source.fontFamily,
|
||||
fontSize: source.fontSize,
|
||||
fontWeight: source.fontWeight,
|
||||
fontStyle: source.fontStyle,
|
||||
synopsisTextColor: source.synopsisTextColor,
|
||||
synopsisFontFamily: source.synopsisFontFamily,
|
||||
synopsisFontSize: source.synopsisFontSize,
|
||||
synopsisFontWeight: source.synopsisFontWeight,
|
||||
synopsisFontStyle: source.synopsisFontStyle
|
||||
}]));
|
||||
copiedConceptReferences = Array.from(referencesById.values());
|
||||
return copiedConceptReferences.length;
|
||||
}
|
||||
|
||||
copySelectionReferences() {
|
||||
this.copiedConceptIds = Array.from(new Set(this.selectedAll()
|
||||
.filter((item) => item.conceptId && item.kind !== "phrase" && item.kind !== "submap")
|
||||
.map((item) => item.conceptId)));
|
||||
const selected = this.selectedAll()
|
||||
.filter((item) => item.conceptId && item.kind !== "phrase");
|
||||
const copied = this.storeConceptReferences(selected);
|
||||
if (!copied) return 0;
|
||||
this.notifySelection();
|
||||
return this.copiedConceptIds.length;
|
||||
return copied;
|
||||
}
|
||||
|
||||
canCutSelectionReferences() {
|
||||
return this.selectedAll().some((item) =>
|
||||
item !== this.activeMapRoot && item.conceptId && item.kind !== "phrase");
|
||||
}
|
||||
|
||||
cutSelectionReferences() {
|
||||
const cuttable = this.selectedAll().filter((item) =>
|
||||
item !== this.activeMapRoot && item.conceptId && item.kind !== "phrase");
|
||||
if (!cuttable.length) return 0;
|
||||
const copied = this.storeConceptReferences(cuttable);
|
||||
if (!copied) return 0;
|
||||
this.clearSelection(false);
|
||||
for (const record of cuttable) this.selectedItems.add(record);
|
||||
this.selectedItem = cuttable.at(-1) || null;
|
||||
this.deleteSelection();
|
||||
return copied;
|
||||
}
|
||||
|
||||
canPasteConceptReferences() {
|
||||
return this.copiedConceptIds.length > 0;
|
||||
return copiedConceptReferences.length > 0;
|
||||
}
|
||||
|
||||
pasteConceptReferences() {
|
||||
const sources = this.copiedConceptIds
|
||||
.map((conceptId) => this.items.find((item) => item.conceptId === conceptId))
|
||||
.filter(Boolean);
|
||||
const sources = copiedConceptReferences;
|
||||
if (!sources.length) return [];
|
||||
this.clearSelection(false);
|
||||
const parentSubmap = this.activeMapRoot || null;
|
||||
const pasted = sources.map((source, index) => this.addItem({
|
||||
conceptId: source.conceptId,
|
||||
kind: source.kind,
|
||||
kind: source.kind === "submap" ? "concept" : source.kind,
|
||||
label: source.label,
|
||||
synopsis: source.synopsis,
|
||||
aspects: source.aspects,
|
||||
tags: source.tags,
|
||||
descriptionPageSlug: source.descriptionPageSlug,
|
||||
pageSlug: source.pageSlug,
|
||||
cmapSlug: source.cmapSlug,
|
||||
parentCmapLink: source.parentCmapLink,
|
||||
externalUrl: source.externalUrl,
|
||||
parentCmapLink: false,
|
||||
imageSource: source.imageSource,
|
||||
parentSubmap,
|
||||
submapDepth: parentSubmap ? parentSubmap.submapDepth + 1 : 0,
|
||||
x: 120 + (index * 36),
|
||||
y: 120 + (index * 36),
|
||||
width: Number(source.node.attr("width")),
|
||||
height: Number(source.node.attr("height")),
|
||||
width: source.width,
|
||||
height: source.height,
|
||||
backgroundColor: source.backgroundColor,
|
||||
borderColor: source.borderColor,
|
||||
textColor: source.textColor,
|
||||
@@ -1413,6 +1553,119 @@
|
||||
return this.selectedAll();
|
||||
}
|
||||
|
||||
layoutSelectionRecords() {
|
||||
return this.selectedAll().filter((item) => this.isEffectiveItemVisible(item));
|
||||
}
|
||||
|
||||
canLayoutSelection(command) {
|
||||
const minimum = ["distribute-horizontal", "distribute-vertical"].includes(command) ? 3 : 2;
|
||||
return this.layoutSelectionRecords().length >= minimum;
|
||||
}
|
||||
|
||||
applySelectionLayout(command) {
|
||||
const records = this.layoutSelectionRecords();
|
||||
if (!this.canLayoutSelection(command)) return false;
|
||||
const boxes = records.map((record) => ({
|
||||
record,
|
||||
x: Number(record.node.attr("x")),
|
||||
y: Number(record.node.attr("y")),
|
||||
width: Number(record.node.attr("width")),
|
||||
height: Number(record.node.attr("height"))
|
||||
}));
|
||||
const reference = boxes.find((box) => box.record === this.selectedItem) || boxes.at(-1);
|
||||
const updates = new Map(boxes.map(({ record }) => [record, {}]));
|
||||
const referenceRight = reference.x + reference.width;
|
||||
const referenceCenter = reference.x + (reference.width / 2);
|
||||
const referenceBottom = reference.y + reference.height;
|
||||
const referenceMiddle = reference.y + (reference.height / 2);
|
||||
|
||||
if (["same-width", "same-size"].includes(command)) {
|
||||
for (const box of boxes) updates.get(box.record).width = reference.width;
|
||||
}
|
||||
if (["same-height", "same-size"].includes(command)) {
|
||||
for (const box of boxes) updates.get(box.record).height = reference.height;
|
||||
}
|
||||
if (command === "align-left") {
|
||||
for (const box of boxes) updates.get(box.record).x = reference.x;
|
||||
}
|
||||
if (command === "align-right") {
|
||||
for (const box of boxes) updates.get(box.record).x = referenceRight - box.width;
|
||||
}
|
||||
if (command === "align-center") {
|
||||
for (const box of boxes) updates.get(box.record).x = referenceCenter - (box.width / 2);
|
||||
}
|
||||
if (command === "align-top") {
|
||||
for (const box of boxes) updates.get(box.record).y = reference.y;
|
||||
}
|
||||
if (command === "align-bottom") {
|
||||
for (const box of boxes) updates.get(box.record).y = referenceBottom - box.height;
|
||||
}
|
||||
if (command === "align-middle") {
|
||||
for (const box of boxes) updates.get(box.record).y = referenceMiddle - (box.height / 2);
|
||||
}
|
||||
if (command === "distribute-horizontal") {
|
||||
const ordered = [...boxes].sort((a, b) =>
|
||||
(a.x + (a.width / 2)) - (b.x + (b.width / 2)) ||
|
||||
String(a.record.id).localeCompare(String(b.record.id)));
|
||||
const distributionLeft = ordered[0].x;
|
||||
const distributionRight = ordered.at(-1).x + ordered.at(-1).width;
|
||||
const occupiedWidth = ordered.reduce((sum, box) => sum + box.width, 0);
|
||||
const gap = (distributionRight - distributionLeft - occupiedWidth) / (ordered.length - 1);
|
||||
let cursor = distributionLeft;
|
||||
for (const box of ordered) {
|
||||
updates.get(box.record).x = cursor;
|
||||
cursor += box.width + gap;
|
||||
}
|
||||
}
|
||||
if (command === "distribute-vertical") {
|
||||
const ordered = [...boxes].sort((a, b) =>
|
||||
(a.y + (a.height / 2)) - (b.y + (b.height / 2)) ||
|
||||
String(a.record.id).localeCompare(String(b.record.id)));
|
||||
const distributionTop = ordered[0].y;
|
||||
const distributionBottom = ordered.at(-1).y + ordered.at(-1).height;
|
||||
const occupiedHeight = ordered.reduce((sum, box) => sum + box.height, 0);
|
||||
const gap = (distributionBottom - distributionTop - occupiedHeight) / (ordered.length - 1);
|
||||
let cursor = distributionTop;
|
||||
for (const box of ordered) {
|
||||
updates.get(box.record).y = cursor;
|
||||
cursor += box.height + gap;
|
||||
}
|
||||
}
|
||||
|
||||
if (!["same-width", "same-height", "same-size", "align-left", "align-right",
|
||||
"align-center", "align-top", "align-bottom", "align-middle",
|
||||
"distribute-horizontal", "distribute-vertical"].includes(command)) return false;
|
||||
|
||||
this.scheduleHistoryCommit();
|
||||
for (const record of records) {
|
||||
const attributes = updates.get(record);
|
||||
if (attributes.width !== undefined) {
|
||||
record.width = attributes.width;
|
||||
record.autoWidth = false;
|
||||
}
|
||||
if (attributes.height !== undefined) {
|
||||
record.height = attributes.height;
|
||||
record.autoHeight = false;
|
||||
}
|
||||
record.node.attr(attributes);
|
||||
record.node.redraw();
|
||||
}
|
||||
this.saveCurrentContextLayout();
|
||||
this.refreshConnectorGeometry();
|
||||
for (const submap of this.items
|
||||
.filter((item) => item.kind === "submap")
|
||||
.sort((a, b) => b.submapDepth - a.submapDepth)) {
|
||||
this.updateSubmapFrame(submap);
|
||||
}
|
||||
this.refreshSelectionDecoration();
|
||||
debug("selection layout applied", {
|
||||
command,
|
||||
referenceItemId: reference.record.id,
|
||||
itemIds: records.map((record) => record.id)
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
canGroupSelection() {
|
||||
const selected = this.selectedAll();
|
||||
return selected.length >= 2 &&
|
||||
@@ -1513,36 +1766,54 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
getDocumentMetadata() {
|
||||
return {
|
||||
tags: [...this.documentMetadata.tags],
|
||||
summary: this.documentMetadata.summary,
|
||||
explanationPageSlug: this.documentMetadata.explanationPageSlug
|
||||
};
|
||||
}
|
||||
|
||||
setDocumentMetadata(metadata = {}) {
|
||||
this.scheduleHistoryCommit();
|
||||
this.documentMetadata = {
|
||||
tags: Array.isArray(metadata.tags) ? metadata.tags.map(String)
|
||||
.map((tag) => tag.trim()).filter(Boolean) : [],
|
||||
summary: String(metadata.summary || "").trim(),
|
||||
explanationPageSlug: String(metadata.explanationPageSlug || "").trim()
|
||||
};
|
||||
return this.getDocumentMetadata();
|
||||
}
|
||||
|
||||
toDocument() {
|
||||
this.saveCurrentContextLayout();
|
||||
this.refreshConceptMapReferences();
|
||||
const concepts = Array.from(new Map(this.items
|
||||
.filter((record) => record.conceptId && record.kind !== "phrase" && record.kind !== "submap")
|
||||
.filter((record) => record.conceptId && record.kind !== "phrase")
|
||||
.map((record) => [record.conceptId, {
|
||||
id: record.conceptId,
|
||||
kind: record.kind,
|
||||
label: record.label,
|
||||
synopsis: record.synopsis,
|
||||
aspects: record.aspects,
|
||||
tags: normalizeConceptTags(record.tags).filter((tag) => tag.type === "person"),
|
||||
descriptionPageSlug: record.descriptionPageSlug,
|
||||
pageSlug: record.pageSlug,
|
||||
cmapSlug: record.cmapSlug,
|
||||
parentCmapLink: record.parentCmapLink,
|
||||
externalUrl: record.externalUrl,
|
||||
imageSource: record.imageSource
|
||||
}])).values());
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
metadata: this.getDocumentMetadata(),
|
||||
concepts,
|
||||
items: this.items.map((record) => ({
|
||||
id: record.id,
|
||||
conceptId: record.conceptId,
|
||||
kind: record.kind,
|
||||
label: record.label,
|
||||
synopsis: record.synopsis,
|
||||
aspects: record.aspects,
|
||||
descriptionPageSlug: record.descriptionPageSlug,
|
||||
pageSlug: record.pageSlug,
|
||||
cmapSlug: record.cmapSlug,
|
||||
...(record.kind === "phrase" ? {
|
||||
label: record.label,
|
||||
synopsis: record.synopsis
|
||||
} : {}),
|
||||
parentCmapLink: record.parentCmapLink,
|
||||
groupId: record.groupId,
|
||||
childMap: record.childMap,
|
||||
@@ -1554,7 +1825,6 @@
|
||||
mapReference: record.mapReference,
|
||||
hiddenContexts: Array.from(record.hiddenContexts),
|
||||
layouts: record.layouts,
|
||||
imageSource: record.imageSource,
|
||||
backgroundColor: record.backgroundColor,
|
||||
borderColor: record.borderColor,
|
||||
submapBackgroundColor: record.submapBackgroundColor,
|
||||
@@ -1598,10 +1868,25 @@
|
||||
.filter((concept) => concept && concept.id)
|
||||
.map((concept) => [concept.id, concept]));
|
||||
const records = new Map();
|
||||
const metadata = document.metadata && typeof document.metadata === "object" ?
|
||||
document.metadata : {};
|
||||
this.documentMetadata = {
|
||||
tags: Array.isArray(metadata.tags) ? metadata.tags.map(String)
|
||||
.map((tag) => tag.trim()).filter(Boolean) : [],
|
||||
summary: String(metadata.summary || "").trim(),
|
||||
explanationPageSlug: String(metadata.explanationPageSlug || "").trim()
|
||||
};
|
||||
|
||||
for (const itemDocument of itemDocuments) {
|
||||
const concept = concepts.get(itemDocument.conceptId) || {};
|
||||
const record = this.addItem({ ...itemDocument, ...concept, parentSubmap: null });
|
||||
const record = this.addItem({
|
||||
...itemDocument,
|
||||
...concept,
|
||||
id: itemDocument.id,
|
||||
conceptId: itemDocument.conceptId,
|
||||
kind: itemDocument.kind || concept.kind || "concept",
|
||||
parentSubmap: null
|
||||
});
|
||||
record.autoWidth = Boolean(itemDocument.autoWidth);
|
||||
record.autoHeight = Boolean(itemDocument.autoHeight);
|
||||
record.fitContentPending = false;
|
||||
@@ -1756,7 +2041,10 @@
|
||||
element.style.overflow = "visible";
|
||||
this.applyItemTypography(record, element);
|
||||
|
||||
if (record.fitContentPending) {
|
||||
// Concept content is never presentation-optional. A context may retain a
|
||||
// different width or a larger manual height, but it may not keep a
|
||||
// height that clips part of the shared content.
|
||||
if (record.fitContentPending || record.kind !== "phrase") {
|
||||
this.fitItemToContent(record, element);
|
||||
}
|
||||
|
||||
@@ -1764,7 +2052,7 @@
|
||||
if (image && image.dataset.rwCmapFitBound !== "1") {
|
||||
image.dataset.rwCmapFitBound = "1";
|
||||
image.addEventListener("load", () => {
|
||||
if (!record.autoWidth && !record.autoHeight) return;
|
||||
if (record.kind === "phrase" && !record.autoWidth && !record.autoHeight) return;
|
||||
record.fitContentPending = true;
|
||||
this.fitItemToContent(record, element);
|
||||
}, { once: true });
|
||||
@@ -1806,6 +2094,20 @@
|
||||
});
|
||||
}
|
||||
|
||||
const externalButton = element.querySelector(".rw-cmap-open-external");
|
||||
if (externalButton && externalButton.dataset.rwCmapBound !== "1") {
|
||||
externalButton.dataset.rwCmapBound = "1";
|
||||
externalButton.addEventListener("pointerdown", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
});
|
||||
externalButton.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (record.externalUrl && this.onOpenExternalUrl) this.onOpenExternalUrl(record);
|
||||
});
|
||||
}
|
||||
|
||||
if (element.dataset.rwCmapBound !== "1") {
|
||||
element.dataset.rwCmapBound = "1";
|
||||
debug("item pointer handlers attached", {
|
||||
@@ -1818,6 +2120,7 @@
|
||||
|
||||
if (this.selectedItems.has(record)) {
|
||||
element.classList.add("rw-cmap-selected");
|
||||
element.classList.toggle("rw-cmap-selected-primary", this.selectedItem === record);
|
||||
element.setAttribute("aria-selected", "true");
|
||||
if (this.selectedItem === record) this.ensureHandles(record, element);
|
||||
}
|
||||
@@ -1847,14 +2150,16 @@
|
||||
}
|
||||
|
||||
/**
|
||||
* goal : Give a new, not manually sized item a compact content-based size.
|
||||
* goal : Size automatic items and prevent fixed concept cards clipping content.
|
||||
* pre : element has been rendered and contains the current item HTML.
|
||||
* post : Automatic dimensions closely surround the text, with long text
|
||||
* wrapping at a practical maximum width.
|
||||
*/
|
||||
fitItemToContent(record, element) {
|
||||
record.fitContentPending = false;
|
||||
if (!record.autoWidth && !record.autoHeight) return;
|
||||
if (record.kind === "phrase" && !record.autoWidth && !record.autoHeight) return;
|
||||
|
||||
const fixedWidth = !record.autoWidth && record.kind !== "phrase";
|
||||
|
||||
const probe = document.createElement("div");
|
||||
probe.className = element.className;
|
||||
@@ -1863,9 +2168,9 @@
|
||||
position: "fixed",
|
||||
left: "-10000px",
|
||||
top: "0",
|
||||
width: "max-content",
|
||||
width: fixedWidth ? `${record.width}px` : "max-content",
|
||||
height: "auto",
|
||||
maxWidth: record.kind === "phrase" ? "280px" : "380px",
|
||||
maxWidth: fixedWidth ? "none" : (record.kind === "phrase" ? "280px" : "380px"),
|
||||
boxSizing: "border-box",
|
||||
fontFamily: record.fontFamily,
|
||||
fontSize: record.fontSize,
|
||||
@@ -1883,9 +2188,9 @@
|
||||
const content = probe.firstElementChild;
|
||||
if (content) {
|
||||
Object.assign(content.style, {
|
||||
width: "max-content",
|
||||
width: fixedWidth ? "100%" : "max-content",
|
||||
height: "auto",
|
||||
maxWidth: record.kind === "phrase" ? "276px" : "376px",
|
||||
maxWidth: fixedWidth ? "none" : (record.kind === "phrase" ? "276px" : "376px"),
|
||||
overflow: "visible",
|
||||
whiteSpace: "normal"
|
||||
});
|
||||
@@ -1900,9 +2205,14 @@
|
||||
const measuredWidth = Math.ceil(bounds.width) + 4;
|
||||
const measuredHeight = Math.ceil(bounds.height) + 4;
|
||||
const nextWidth = record.autoWidth ? Math.max(minimumWidth, measuredWidth) : record.width;
|
||||
const nextHeight = record.autoHeight ? Math.max(minimumHeight, measuredHeight) : record.height;
|
||||
const nextHeight = record.autoHeight ? Math.max(minimumHeight, measuredHeight) :
|
||||
(record.kind === "phrase" ? record.height : Math.max(record.height, measuredHeight));
|
||||
|
||||
if (nextWidth === record.width && nextHeight === record.height) return;
|
||||
// Rendering may finish after the host has recorded its saved baseline.
|
||||
// Give the host both exact states so it can advance that baseline only
|
||||
// when no user edit occurred in between.
|
||||
const beforeAutomaticLayout = this.onAutomaticLayoutChange ? this.historySnapshot() : null;
|
||||
const previousWidth = record.width;
|
||||
const previousHeight = record.height;
|
||||
const attributes = { width: nextWidth, height: nextHeight };
|
||||
@@ -1922,6 +2232,16 @@
|
||||
height: nextHeight
|
||||
});
|
||||
this.refreshHistorySnapshot();
|
||||
if (this.onAutomaticLayoutChange) {
|
||||
const afterAutomaticLayout = this.historySnapshot();
|
||||
if (beforeAutomaticLayout !== afterAutomaticLayout) {
|
||||
this.onAutomaticLayoutChange({
|
||||
beforeSnapshot: beforeAutomaticLayout,
|
||||
afterSnapshot: afterAutomaticLayout,
|
||||
itemId: record.id
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
decorateConnector(record, renderedElement = null) {
|
||||
@@ -2293,6 +2613,9 @@
|
||||
"--rw-cmap-submap-border", record.submapBorderColor || "#57834a");
|
||||
record.submapFrameElement.classList.toggle(
|
||||
"rw-cmap-submap-frame-selected", this.selectedItems.has(record));
|
||||
record.submapFrameElement.classList.toggle(
|
||||
"rw-cmap-submap-frame-selected-primary",
|
||||
this.selectedItems.has(record) && this.selectedItem === record);
|
||||
record.submapFrameElement.setAttribute("aria-label", record.label);
|
||||
this.updateSubmapAnchorLine(record, bounds, surface);
|
||||
this.ensureCanvasExtent(right, bottom);
|
||||
@@ -2942,7 +3265,7 @@
|
||||
let lastEditor = null;
|
||||
|
||||
window.RacketWikiCmap = {
|
||||
version: "0.2.99",
|
||||
version: "0.2.122",
|
||||
createEditor(canvas, options) {
|
||||
lastEditor = new CmapEditor(canvas, options);
|
||||
return lastEditor;
|
||||
|
||||
Reference in New Issue
Block a user