refactoring van de cmap structuren bijna compleet

This commit is contained in:
2026-09-03 16:21:21 +02:00
parent 1abc84489f
commit bd1ef6bed0
75 changed files with 2547 additions and 1967 deletions
+398
View File
@@ -0,0 +1,398 @@
import {
cmapColorValue,
cmapFontSizeInPoints,
displayCmapFontSize
} from "../model/appearance.js";
/**
* Present and edit CMap appearance in the concept dialog.
* The editor translates DOM changes to CmapAppearance operations and asks the
* repository to persist the complete aggregate after style or palette changes.
*/
export class CmapAppearanceEditor {
constructor(appearance, repository, tr) {
this.appearance = appearance;
this.repository = repository;
this.tr = tr;
this.$ = (id) => document.getElementById(id);
this.installColorPickers();
this.installStyleControls();
}
/** Fill appearance fields from one existing concept placement. */
showRecord(record) {
const fallback = this.appearance.defaultValues;
const titleFontSize = cmapFontSizeInPoints(record.fontSize, fallback.fontSize);
this.$("cmap-concept-background-label").textContent = record.kind === "submap" ?
this.tr("main-concept-background-color", "Main concept background color") :
this.tr("background-color", "Background color");
this.$("cmap-submap-style-fields").classList.toggle("hidden", record.kind !== "submap");
this.apply({
backgroundColor: cmapColorValue(record.backgroundColor, fallback.backgroundColor),
textColor: cmapColorValue(record.textColor, fallback.textColor),
fontFamily: record.fontFamily || fallback.fontFamily,
fontSize: titleFontSize,
fontWeight: String(record.fontWeight || fallback.fontWeight) === "400" ? "400" : "700",
fontStyle: record.fontStyle === "italic" ? "italic" : "normal",
synopsisTextColor: cmapColorValue(
record.synopsisTextColor || record.textColor, fallback.synopsisTextColor),
synopsisFontFamily: record.synopsisFontFamily || record.fontFamily || fallback.synopsisFontFamily,
synopsisFontSize: cmapFontSizeInPoints(
record.synopsisFontSize || "0.84em", titleFontSize),
synopsisFontWeight: String(
record.synopsisFontWeight || record.fontWeight || fallback.synopsisFontWeight) === "700" ?
"700" : "400",
synopsisFontStyle: (record.synopsisFontStyle || record.fontStyle) === "italic" ?
"italic" : "normal",
submapBackgroundColor: cmapColorValue(
record.submapBackgroundColor, fallback.submapBackgroundColor),
submapBorderColor: cmapColorValue(record.submapBorderColor, fallback.submapBorderColor)
});
this.renderStyleOptions();
}
/** Fill appearance fields for a new ordinary concept using the model default. */
showNewConcept() {
this.$("cmap-concept-background-label").textContent =
this.tr("background-color", "Background color");
this.$("cmap-submap-style-fields").classList.add("hidden");
this.apply(this.appearance.defaultValues);
this.renderStyleOptions();
}
/** Return normalized placement values from the appearance form. */
placementChanges(isSubmap) {
const values = this.capture();
const changes = {
backgroundColor: values.backgroundColor,
textColor: values.textColor,
fontFamily: values.fontFamily,
fontSize: `${values.fontSize}pt`,
fontWeight: values.fontWeight,
fontStyle: values.fontStyle,
synopsisTextColor: values.synopsisTextColor,
synopsisFontFamily: values.synopsisFontFamily,
synopsisFontSize: `${values.synopsisFontSize}pt`,
synopsisFontWeight: values.synopsisFontWeight,
synopsisFontStyle: values.synopsisFontStyle
};
if (isSubmap) {
changes.submapBackgroundColor = values.submapBackgroundColor;
changes.submapBorderColor = values.submapBorderColor;
}
return changes;
}
capture() {
return this.appearance.normalizeValues({
backgroundColor: this.$("cmap-concept-background").value,
textColor: this.$("cmap-concept-text-color").value,
fontFamily: this.$("cmap-concept-font-family").value,
fontSize: this.$("cmap-concept-font-size").value,
fontWeight: this.$("cmap-concept-bold").checked ? "700" : "400",
fontStyle: this.$("cmap-concept-italic").checked ? "italic" : "normal",
synopsisTextColor: this.$("cmap-concept-synopsis-text-color").value,
synopsisFontFamily: this.$("cmap-concept-synopsis-font-family").value,
synopsisFontSize: this.$("cmap-concept-synopsis-font-size").value,
synopsisFontWeight: this.$("cmap-concept-synopsis-bold").checked ? "700" : "400",
synopsisFontStyle: this.$("cmap-concept-synopsis-italic").checked ? "italic" : "normal",
submapBackgroundColor: this.$("cmap-submap-background").value,
submapBorderColor: this.$("cmap-submap-border").value
});
}
apply(values) {
const style = this.appearance.normalizeValues(values);
if (!style) return;
this.$("cmap-concept-background").value = style.backgroundColor;
this.$("cmap-concept-text-color").value = style.textColor;
this.selectFont(style.fontFamily);
this.$("cmap-concept-font-size").value = displayCmapFontSize(style.fontSize);
this.$("cmap-concept-bold").checked = style.fontWeight === "700";
this.$("cmap-concept-italic").checked = style.fontStyle === "italic";
this.$("cmap-concept-synopsis-text-color").value = style.synopsisTextColor;
this.selectFont(style.synopsisFontFamily, "cmap-concept-synopsis-font-family");
this.$("cmap-concept-synopsis-font-size").value =
displayCmapFontSize(style.synopsisFontSize);
this.$("cmap-concept-synopsis-bold").checked = style.synopsisFontWeight === "700";
this.$("cmap-concept-synopsis-italic").checked = style.synopsisFontStyle === "italic";
this.$("cmap-submap-background").value = style.submapBackgroundColor;
this.$("cmap-submap-border").value = style.submapBorderColor;
this.updateColorControls();
}
selectFont(fontFamily, selectId = "cmap-concept-font-family") {
const select = this.$(selectId);
const value = fontFamily || this.appearance.defaultValues.fontFamily;
const existing = Array.from(select.options).find((option) => option.value === value);
if (!existing) {
const option = document.createElement("option");
option.value = value;
option.textContent = value;
select.append(option);
}
select.value = value;
}
styleName(style) {
return style.nameKey ? this.tr(style.nameKey, style.nameKey) : style.name;
}
renderStyleOptions(selectedId = this.appearance.matchingStyleId(this.capture())) {
const styles = this.appearance.styles;
const usableId = styles.some((style) => style.id === selectedId) ? selectedId : "";
for (const select of [
this.$("cmap-concept-quick-style"),
this.$("cmap-concept-style-preset")
]) {
select.replaceChildren();
const custom = document.createElement("option");
custom.value = "";
custom.textContent = this.tr("custom-style", "Custom");
select.append(custom);
for (const style of styles) {
const option = document.createElement("option");
option.value = style.id;
option.textContent = this.styleName(style);
select.append(option);
}
select.value = usableId;
}
this.updateDeleteButton();
}
syncStyleSelection() {
const matchingId = this.appearance.matchingStyleId(this.capture());
this.$("cmap-concept-quick-style").value = matchingId;
this.$("cmap-concept-style-preset").value = matchingId;
this.updateDeleteButton();
}
updateDeleteButton() {
const selected = this.appearance.style(this.$("cmap-concept-style-preset").value);
this.$("cmap-delete-style").disabled = !selected || selected.protected;
}
applySelectedStyle(event) {
const selectedId = event?.currentTarget?.value ??
this.$("cmap-concept-style-preset").value;
this.$("cmap-concept-quick-style").value = selectedId;
this.$("cmap-concept-style-preset").value = selectedId;
const style = this.appearance.style(selectedId);
if (style) this.apply(style.values);
this.updateDeleteButton();
}
newStyleId() {
try {
if (window.crypto && typeof window.crypto.randomUUID === "function") {
return `custom-${window.crypto.randomUUID()}`;
}
} catch (_error) {
// A timestamp remains sufficient when randomUUID is unavailable.
}
return `custom-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
async saveCurrentStyle() {
const selected = this.appearance.style(this.$("cmap-concept-style-preset").value);
const proposedName = selected && !selected.nameKey ? selected.name : "";
const name = window.prompt(this.tr("style-name-prompt", "Name for this style"), proposedName);
if (name === null) return;
const cleanName = name.trim();
if (!cleanName) {
window.alert(this.tr("style-name-required", "Enter a style name."));
return;
}
const existing = this.appearance.styles.find((style) =>
this.styleName(style).toLocaleLowerCase() === cleanName.toLocaleLowerCase());
if (existing?.protected) {
window.alert(this.tr(
"default-style-protected", "The default style cannot be changed or deleted."));
return;
}
if (existing && !window.confirm(this.tr(
"replace-style-confirm", 'Replace the existing style "{name}"?')
.replace("{name}", this.styleName(existing)))) return;
const previousAppearance = this.appearance.toData();
const replacement = this.appearance.putStyle({
id: existing?.id || this.newStyleId(),
name: cleanName,
values: this.capture()
});
if (!await this.store()) {
this.appearance.replace(previousAppearance);
return;
}
this.renderStyleOptions(replacement.id);
}
async deleteSelectedStyle() {
const selected = this.appearance.style(this.$("cmap-concept-style-preset").value);
if (!selected) return;
if (selected.protected) {
window.alert(this.tr(
"default-style-protected", "The default style cannot be changed or deleted."));
return;
}
if (!window.confirm(this.tr(
"delete-style-confirm", 'Delete style "{name}"?')
.replace("{name}", this.styleName(selected)))) return;
const previousAppearance = this.appearance.toData();
this.appearance.deleteStyle(selected.id);
if (!await this.store()) {
this.appearance.replace(previousAppearance);
return;
}
this.renderStyleOptions();
}
async store() {
try {
await this.repository.save(this.appearance);
return true;
} catch (error) {
console.warn("The CMap appearance could not be stored in the database.", error);
window.alert(this.tr(
"cmap-appearance-storage-failed",
"The CMap appearance could not be stored in the wiki database."));
return false;
}
}
openNativeColorPicker(initialColor, onInput, onCommit = null) {
const picker = document.createElement("input");
picker.type = "color";
picker.className = "cmap-native-color-picker";
picker.value = cmapColorValue(initialColor, "#ffffff");
document.body.append(picker);
let removed = false;
const cleanup = () => {
if (removed) return;
removed = true;
picker.remove();
};
picker.addEventListener("input", () => onInput(cmapColorValue(picker.value, "#ffffff")));
picker.addEventListener("change", () => {
if (onCommit) onCommit(cmapColorValue(picker.value, "#ffffff"));
window.setTimeout(cleanup, 0);
}, { once: true });
picker.addEventListener("blur", () => window.setTimeout(cleanup, 100), { once: true });
try {
if (typeof picker.showPicker === "function") picker.showPicker();
else picker.click();
} catch (_error) {
picker.click();
}
}
updateColorControl(input) {
const swatch = input.closest(".cmap-color-control")?.querySelector(".cmap-color-swatch");
if (swatch) swatch.style.backgroundColor = cmapColorValue(input.value, "#ffffff");
}
updateColorControls() {
for (const input of this.$("cmap-concept-panel-appearance").querySelectorAll(
".cmap-color-input")) {
this.updateColorControl(input);
}
}
updatePaletteChoices(index, color) {
for (const choice of document.querySelectorAll(
`.cmap-color-palette button[data-cmap-color-index="${index}"]`)) {
choice.style.backgroundColor = color;
choice.title = `${color}${this.tr("change-palette-color", "double-click to change")}`;
choice.setAttribute("aria-label", color);
}
}
installColorPickers() {
for (const control of document.querySelectorAll(".cmap-color-control")) {
const input = control.querySelector(".cmap-color-input");
const swatch = control.querySelector(".cmap-color-swatch");
swatch.title = this.tr(
"color-swatch-help", "Click for the palette; double-click for a custom color");
const palette = document.createElement("span");
palette.className = "cmap-color-palette hidden";
this.appearance.palette.forEach((color, index) => {
const choice = document.createElement("button");
choice.type = "button";
choice.dataset.cmapColorIndex = String(index);
this.updatePaletteChoice(choice, color);
let clickTimer = null;
choice.addEventListener("click", () => {
if (clickTimer !== null) window.clearTimeout(clickTimer);
clickTimer = window.setTimeout(() => {
clickTimer = null;
input.value = this.appearance.palette[index];
input.dispatchEvent(new Event("input", { bubbles: true }));
palette.classList.add("hidden");
}, 240);
});
choice.addEventListener("dblclick", (event) => {
event.preventDefault();
if (clickTimer !== null) window.clearTimeout(clickTimer);
clickTimer = null;
const previousAppearance = this.appearance.toData();
const updateColor = (newColor) => {
this.appearance.setPaletteColor(index, newColor);
this.updatePaletteChoices(index, newColor);
};
this.openNativeColorPicker(this.appearance.palette[index], updateColor, async () => {
if (!await this.store()) {
this.appearance.replace(previousAppearance);
this.updatePaletteChoices(index, previousAppearance.palette[index]);
}
});
});
palette.append(choice);
});
control.append(palette);
swatch.addEventListener("click", () => {
for (const other of document.querySelectorAll(".cmap-color-palette")) {
if (other !== palette) other.classList.add("hidden");
}
palette.classList.toggle("hidden");
});
swatch.addEventListener("dblclick", (event) => {
event.preventDefault();
palette.classList.add("hidden");
this.openNativeColorPicker(input.value, (newColor) => {
input.value = newColor;
input.dispatchEvent(new Event("input", { bubbles: true }));
});
});
input.addEventListener("input", () => this.updateColorControl(input));
this.updateColorControl(input);
}
document.addEventListener("pointerdown", (event) => {
if (event.target.closest(".cmap-color-control")) return;
for (const palette of document.querySelectorAll(".cmap-color-palette")) {
palette.classList.add("hidden");
}
});
}
updatePaletteChoice(choice, color) {
choice.style.backgroundColor = color;
choice.title = `${color}${this.tr("change-palette-color", "double-click to change")}`;
choice.setAttribute("aria-label", color);
}
installStyleControls() {
this.$("cmap-concept-quick-style").addEventListener(
"change", (event) => this.applySelectedStyle(event));
this.$("cmap-concept-style-preset").addEventListener(
"change", (event) => this.applySelectedStyle(event));
this.$("cmap-save-style").addEventListener("click", () => this.saveCurrentStyle());
this.$("cmap-delete-style").addEventListener("click", () => this.deleteSelectedStyle());
for (const eventName of ["input", "change"]) {
this.$("cmap-concept-panel-appearance").addEventListener(eventName, (event) => {
if (!event.target.closest(".cmap-style-manager")) this.syncStyleSelection();
});
}
}
}
@@ -0,0 +1,113 @@
"use strict";
/** Render references from the active diagram to concepts linked from other CMaps. */
export class CmapBoundaryReferenceView {
constructor({ canvas, zoomFactor, mapTitle, surfaceElement, itemCenter, onOpenReference }) {
this.canvas = canvas;
this.zoomFactor = zoomFactor;
this.mapTitle = mapTitle || "";
this.surfaceElement = surfaceElement;
this.itemCenter = itemCenter;
this.onOpenReference = onOpenReference;
this.layer = null;
}
clear() {
if (this.layer) this.layer.remove();
this.layer = null;
}
render(references = []) {
this.clear();
if (!references.length) return;
const surface = this.surfaceElement();
if (!surface) return;
const layer = document.createElement("div");
layer.className = "rw-cmap-boundary-layer";
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.classList.add("rw-cmap-boundary-lines");
const definitions = document.createElementNS("http://www.w3.org/2000/svg", "defs");
const marker = document.createElementNS("http://www.w3.org/2000/svg", "marker");
marker.setAttribute("id", "rw-cmap-boundary-arrow");
marker.setAttribute("viewBox", "0 0 10 10");
marker.setAttribute("refX", "9");
marker.setAttribute("refY", "5");
marker.setAttribute("markerWidth", "7");
marker.setAttribute("markerHeight", "7");
marker.setAttribute("orient", "auto-start-reverse");
const arrow = document.createElementNS("http://www.w3.org/2000/svg", "path");
arrow.setAttribute("d", "M 0 0 L 10 5 L 0 10 z");
arrow.setAttribute("fill", "#808080");
marker.append(arrow);
definitions.append(marker);
svg.append(definitions);
layer.append(svg);
surface.append(layer);
this.layer = layer;
const factor = this.zoomFactor();
const viewLeft = this.canvas.scrollLeft / factor;
const viewTop = this.canvas.scrollTop / factor;
const viewWidth = this.canvas.clientWidth / factor;
const viewHeight = this.canvas.clientHeight / factor;
const buttonWidth = 190;
const occupied = { left: [], right: [] };
const reserveY = (side, desired) => {
let y = Math.max(viewTop + 12, Math.min(desired, viewTop + viewHeight - 40));
while (occupied[side].some((used) => Math.abs(used - y) < 34)) y += 34;
if (y > viewTop + viewHeight - 40) y = viewTop + 12;
occupied[side].push(y);
return y;
};
for (const reference of references) {
const inside = this.itemCenter(reference.insideRecord);
const side = inside.x < viewLeft + (viewWidth / 2) ? "left" : "right";
const x = side === "left" ? viewLeft + 12 : viewLeft + viewWidth - buttonWidth - 12;
const y = reserveY(side, inside.y - 15);
const button = document.createElement("button");
button.type = "button";
button.className = `rw-cmap-boundary-reference rw-cmap-boundary-reference-${side}`;
button.style.transform = `translate(${x}px, ${y}px)`;
button.style.width = `${buttonWidth}px`;
if (reference.linkingPhraseLabel) {
button.append(document.createTextNode(reference.linkingPhraseLabel));
button.append(document.createTextNode(" →"));
button.append(document.createElement("br"));
}
button.append(document.createTextNode(reference.label));
if (this.mapTitle) {
button.append(document.createElement("br"));
const mapLabel = document.createElement("small");
const mapName = document.createElement("em");
mapName.textContent = `(${this.mapTitle})`;
mapLabel.append(mapName);
button.append(mapLabel);
}
button.title = "Open the concept map containing this connection";
button.addEventListener("click", () => {
if (this.onOpenReference) this.onOpenReference(reference);
});
layer.append(button);
const boundaryX = side === "left" ? x + buttonWidth : x;
const boundaryY = y + 15;
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
const startX = reference.sourceInside ? inside.x : boundaryX;
const startY = reference.sourceInside ? inside.y : boundaryY;
const endX = reference.sourceInside ? boundaryX : inside.x;
const endY = reference.sourceInside ? boundaryY : inside.y;
path.setAttribute("d", `M ${startX} ${startY} L ${endX} ${endY}`);
path.setAttribute("fill", "none");
path.setAttribute("stroke", "#808080");
path.setAttribute("stroke-width", String(reference.connector.lineWidth || 2));
if (reference.connector.hasArrow) path.setAttribute("marker-end", "url(#rw-cmap-boundary-arrow)");
svg.append(path);
}
}
destroy() {
this.clear();
}
}
+546
View File
@@ -0,0 +1,546 @@
"use strict";
import { debug, elementDescription, selectionStyle, escapeHtml } from "../cmap-utils.js";
const BOUNDARY_LINE_COLOR = "#77838e";
export class CmapItemDecorator {
constructor(editor) {
this.editor = editor;
}
get items() { return this.editor.items; }
get connectors() { return this.editor.connectors; }
get canvas() { return this.editor.canvas; }
get zoomFactor() { return this.editor.zoomFactor; }
itemHtml(record) {
if (record.kind === "phrase") {
return `<div class="rw-cmap-phrase-label">${escapeHtml(record.label || "?????")}</div>`;
}
if (this.editor.renderItem) return this.editor.renderItem(record);
return `<div>${escapeHtml(record.label)}</div>`;
}
editPhraseInline(record) {
if (!record || record.kind !== "phrase") return;
const value = record.label || "?????";
record.node.attr("content",
`<input class="rw-cmap-phrase-input" type="text" value="${escapeHtml(value)}" aria-label="${escapeHtml(this.editor.labels.relation)}">`);
record.node.redraw();
const element = record.node.element();
const input = element ? element.querySelector(".rw-cmap-phrase-input") : null;
if (!input) {
record.editWhenRendered = true;
return;
}
const commit = () => {
this.editor.scheduleHistoryCommit();
const text = input.value.trim() || "?????";
record.label = text;
record.node.attr("content", this.itemHtml(record));
record.node.redraw();
this.editor.selectItem(record);
};
input.addEventListener("pointerdown", (event) => event.stopPropagation());
input.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
input.blur();
}
if (event.key === "Escape") {
event.preventDefault();
input.value = value;
input.blur();
}
});
input.addEventListener("blur", commit, { once: true });
input.focus();
input.select();
}
applyItemTypography(record, element) {
const title = element.querySelector(".cmap-card-title");
if (title) {
title.style.color = record.textColor;
title.style.fontFamily = record.fontFamily;
title.style.fontSize = record.fontSize;
title.style.fontWeight = record.fontWeight;
title.style.fontStyle = record.fontStyle;
}
const synopsis = element.querySelector(".cmap-card-synopsis");
if (synopsis) {
synopsis.style.color = record.synopsisTextColor;
synopsis.style.fontFamily = record.synopsisFontFamily;
synopsis.style.fontSize = record.synopsisFontSize;
synopsis.style.fontWeight = record.synopsisFontWeight;
synopsis.style.fontStyle = record.synopsisFontStyle;
}
}
decorateItem(record, renderedElement = null) {
const element = renderedElement || record.node.element();
if (!element) return;
element.classList.remove("rw-cmap-item-concept", "rw-cmap-item-page", "rw-cmap-item-submap", "rw-cmap-item-phrase");
element.classList.add("cmap-prototype-node", "rw-cmap-item", `rw-cmap-item-${record.kind}`);
element.dataset.rwCmapItemId = String(record.id);
element.style.fontFamily = record.fontFamily;
element.style.fontSize = record.fontSize;
element.style.fontWeight = record.fontWeight;
element.style.fontStyle = record.fontStyle;
element.style.overflow = "visible";
this.applyItemTypography(record, element);
if (record.fitContentPending || record.kind !== "phrase") {
this.fitItemToContent(record, element);
}
const image = element.querySelector(".cmap-card-image");
if (image && image.dataset.rwCmapFitBound !== "1") {
image.dataset.rwCmapFitBound = "1";
image.addEventListener("load", () => {
if (record.kind === "phrase" && !record.autoWidth && !record.autoHeight) return;
record.fitContentPending = true;
this.fitItemToContent(record, element);
}, { once: true });
}
const descriptionButton = element.querySelector(".rw-cmap-view-description");
if (descriptionButton && descriptionButton.dataset.rwCmapBound !== "1") {
descriptionButton.dataset.rwCmapBound = "1";
descriptionButton.addEventListener("pointerdown", (event) => {
event.preventDefault();
event.stopPropagation();
});
descriptionButton.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
if (record.descriptionPageSlug && this.editor.onOpenPage) {
this.editor.onOpenPage({ ...record, pageSlug: record.descriptionPageSlug });
}
});
}
const linkedButton = element.querySelector(".rw-cmap-open-linked");
if (linkedButton && linkedButton.dataset.rwCmapBound !== "1") {
linkedButton.dataset.rwCmapBound = "1";
linkedButton.addEventListener("pointerdown", (event) => {
event.preventDefault();
event.stopPropagation();
});
linkedButton.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
if (record.parentCmapLink) {
if (this.editor.onOpenParentCmap) this.editor.onOpenParentCmap(record);
else this.editor.openParentMap();
} else if (record.cmapSlug && this.editor.onOpenCmap) {
this.editor.onOpenCmap(record);
} else if (record.pageSlug && this.editor.onOpenPage) {
this.editor.onOpenPage(record);
}
});
}
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.editor.onOpenExternalUrl) this.editor.onOpenExternalUrl(record);
});
}
if (element.dataset.rwCmapBound !== "1") {
element.dataset.rwCmapBound = "1";
debug("item pointer handlers attached", {
id: record.id,
kind: record.kind,
element: elementDescription(element),
style: selectionStyle(element)
});
}
if (this.editor.selectedItems.has(record)) {
element.classList.add("rw-cmap-selected");
element.classList.toggle("rw-cmap-selected-primary", this.editor.selectedItem === record);
element.setAttribute("aria-selected", "true");
if (this.editor.selectedItem === record) this.ensureHandles(record, element);
}
this.ensureSubmapToggle(record, element);
debug("cmap node rendered and decorated", {
id: record.id,
kind: record.kind,
selected: this.editor.selectedItems.has(record),
element: elementDescription(element),
style: selectionStyle(element)
});
if (record.editWhenRendered) {
record.editWhenRendered = false;
queueMicrotask(() => this.editPhraseInline(record));
}
this.editor.ensureCanvasExtent(
Number(record.node.attr("x")) + Number(record.node.attr("width")),
Number(record.node.attr("y")) + Number(record.node.attr("height"))
);
if (!record.moveMembership) {
let parent = record.parentSubmap;
while (parent) {
this.editor.updateSubmapFrame(parent);
parent = parent.parentSubmap;
}
}
}
fitItemToContent(record, element) {
record.fitContentPending = false;
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;
probe.innerHTML = this.itemHtml(record);
Object.assign(probe.style, {
position: "fixed",
left: "-10000px",
top: "0",
width: fixedWidth ? `${record.width}px` : "max-content",
height: "auto",
maxWidth: fixedWidth ? "none" : (record.kind === "phrase" ? "280px" : "380px"),
boxSizing: "border-box",
fontFamily: record.fontFamily,
fontSize: record.fontSize,
fontWeight: record.fontWeight,
fontStyle: record.fontStyle,
lineHeight: "1.25",
overflow: "visible",
pointerEvents: "none",
transform: "none",
visibility: "hidden",
whiteSpace: "normal"
});
this.applyItemTypography(record, probe);
const content = probe.firstElementChild;
if (content) {
Object.assign(content.style, {
width: fixedWidth ? "100%" : "max-content",
height: "auto",
maxWidth: fixedWidth ? "none" : (record.kind === "phrase" ? "276px" : "376px"),
overflow: "visible",
whiteSpace: "normal"
});
}
document.body.append(probe);
const bounds = probe.getBoundingClientRect();
probe.remove();
const minimumWidth = record.kind === "phrase" ? 50 : 100;
const minimumHeight = record.kind === "phrase" ? 24 : 40;
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.kind === "phrase" ? record.height : Math.max(record.height, measuredHeight));
if (nextWidth === record.width && nextHeight === record.height) return;
const beforeAutomaticLayout = this.editor.onAutomaticLayoutChange ? this.editor.historySnapshot() : null;
const previousWidth = record.width;
const previousHeight = record.height;
const attributes = { width: nextWidth, height: nextHeight };
if (record.kind === "phrase") {
attributes.x = Number(record.node.attr("x")) + ((previousWidth - nextWidth) / 2);
attributes.y = Number(record.node.attr("y")) + ((previousHeight - nextHeight) / 2);
}
record.width = nextWidth;
record.height = nextHeight;
record.node.attr(attributes);
record.node.redraw();
this.editor.redrawConnectorsFor(record);
debug("automatic item size applied", {
id: record.id,
kind: record.kind,
width: nextWidth,
height: nextHeight
});
this.editor.refreshHistorySnapshot();
if (this.editor.onAutomaticLayoutChange) {
const afterAutomaticLayout = this.editor.historySnapshot();
if (beforeAutomaticLayout !== afterAutomaticLayout) {
this.editor.onAutomaticLayoutChange({
beforeSnapshot: beforeAutomaticLayout,
afterSnapshot: afterAutomaticLayout,
itemId: record.id
});
}
}
}
decorateConnector(record, renderedElement = null) {
const element = renderedElement || record.link.element();
if (!element) return;
element.classList.add("rw-cmap-connector");
element.dataset.rwCmapConnectorId = String(record.id);
}
ensureSubmapToggle(record, element) {
let toggle = element.querySelector(":scope > .rw-cmap-submap-toggle");
let open = element.querySelector(":scope > .rw-cmap-submap-open");
if (record.kind !== "submap") {
if (toggle) toggle.remove();
if (open) open.remove();
return;
}
if (record === this.editor.activeMapRoot) {
if (toggle) toggle.remove();
if (open) open.remove();
return;
}
const legacySeparateMap = record.separateMap && !record.cmapSlug;
if (record.expanded && !legacySeparateMap) {
if (toggle) toggle.remove();
toggle = null;
}
if (!toggle) {
if (!record.expanded || legacySeparateMap) {
toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "rw-cmap-submap-toggle";
toggle.addEventListener("pointerdown", (event) => {
event.preventDefault();
event.stopPropagation();
});
toggle.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
this.editor.selectItem(record);
this.editor.toggleSubmap(record);
});
element.append(toggle);
}
}
if (toggle) {
toggle.textContent = legacySeparateMap ? "↗" : "+";
toggle.title = legacySeparateMap ? "Open concept map" : "Expand submap";
toggle.setAttribute("aria-label", toggle.title);
toggle.setAttribute("aria-expanded", String(record.expanded));
}
if (record.cmapSlug && this.editor.onOpenStoredSubMap) {
if (!open) {
open = document.createElement("button");
open.type = "button";
open.className = "rw-cmap-submap-open";
open.textContent = "↗";
open.title = "Open as separate concept map";
open.setAttribute("aria-label", open.title);
open.addEventListener("pointerdown", (event) => {
event.preventDefault();
event.stopPropagation();
});
open.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
this.editor.selectItem(record);
this.editor.onOpenStoredSubMap(record);
});
element.append(open);
}
} else if (open) {
open.remove();
}
}
ensureHandles(record, element) {
if (!element.querySelector(":scope > .rw-cmap-relation-handle")) {
const relation = document.createElement("button");
relation.type = "button";
relation.className = "rw-cmap-handle rw-cmap-relation-handle";
relation.title = this.editor.labels.createRelation;
relation.setAttribute("aria-label", this.editor.labels.createRelation);
relation.setAttribute("aria-hidden", "false");
relation.addEventListener("pointerdown", (event) => this.editor.startRelationDrag(event, record));
element.append(relation);
}
if (record.kind !== "phrase" &&
!element.querySelector(":scope > .rw-cmap-edit-handle")) {
const edit = document.createElement("button");
edit.type = "button";
edit.className = "rw-cmap-handle rw-cmap-edit-handle";
edit.title = this.editor.labels.editConcept;
edit.setAttribute("aria-label", this.editor.labels.editConcept);
edit.setAttribute("aria-hidden", "false");
edit.addEventListener("pointerdown", (event) => {
event.preventDefault();
event.stopPropagation();
});
edit.addEventListener("mousedown", (event) => event.stopPropagation());
edit.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
this.editor.selectItem(record);
if (this.editor.onEditItem) this.editor.onEditItem(record);
});
element.append(edit);
}
if (record.kind !== "phrase" &&
!element.querySelector(":scope > .rw-cmap-resize-handle")) {
const resize = document.createElement("button");
resize.type = "button";
resize.className = "rw-cmap-handle rw-cmap-resize-handle";
resize.title = this.editor.labels.resizeConcept;
resize.setAttribute("aria-label", this.editor.labels.resizeConcept);
resize.setAttribute("aria-hidden", "false");
resize.addEventListener("pointerdown", (event) => this.editor.startResize(event, record));
element.append(resize);
}
}
removeHandles(element) {
for (const handle of element.querySelectorAll(":scope > .rw-cmap-handle")) handle.remove();
}
boundaryConceptFor(record, crossedConnector, inside) {
if (!record || record.kind !== "phrase") return record;
for (const connector of this.connectors) {
if (connector === crossedConnector) continue;
if (connector.source !== record && connector.target !== record) continue;
const neighbour = connector.source === record ? connector.target : connector.source;
if (neighbour.kind === "phrase") continue;
if (this.editor.itemInsideActiveMap(neighbour) === inside) return neighbour;
}
return record;
}
refreshBoundaryReferences() {
if (this.editor.boundaryLayer) {
this.editor.boundaryLayer.remove();
this.editor.boundaryLayer = null;
}
if (!this.editor.activeMapRoot) return;
const surface = this.editor.surfaceElement();
if (!surface) return;
const crossings = [];
for (const connector of this.connectors) {
const sourceInside = this.editor.itemInsideActiveMap(connector.source);
const targetInside = this.editor.itemInsideActiveMap(connector.target);
if (sourceInside === targetInside) continue;
const insideRecord = sourceInside ? connector.source : connector.target;
const outsideRecord = sourceInside ? connector.target : connector.source;
const insideConcept = this.boundaryConceptFor(insideRecord, connector, true);
const outsideConcept = this.boundaryConceptFor(outsideRecord, connector, false);
if (!insideConcept || !outsideConcept || !this.editor.isItemVisible(insideConcept)) continue;
crossings.push({ connector, sourceInside, insideConcept, outsideConcept });
}
if (!crossings.length) return;
const layer = document.createElement("div");
layer.className = "rw-cmap-boundary-layer";
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.classList.add("rw-cmap-boundary-lines");
const definitions = document.createElementNS("http://www.w3.org/2000/svg", "defs");
const marker = document.createElementNS("http://www.w3.org/2000/svg", "marker");
marker.setAttribute("id", "rw-cmap-boundary-arrow");
marker.setAttribute("viewBox", "0 0 10 10");
marker.setAttribute("refX", "9");
marker.setAttribute("refY", "5");
marker.setAttribute("markerWidth", "7");
marker.setAttribute("markerHeight", "7");
marker.setAttribute("orient", "auto-start-reverse");
const arrow = document.createElementNS("http://www.w3.org/2000/svg", "path");
arrow.setAttribute("d", "M 0 0 L 10 5 L 0 10 z");
arrow.setAttribute("fill", BOUNDARY_LINE_COLOR);
marker.append(arrow);
definitions.append(marker);
svg.append(definitions);
layer.append(svg);
surface.append(layer);
this.editor.boundaryLayer = layer;
const viewLeft = this.canvas.scrollLeft / this.zoomFactor;
const viewTop = this.canvas.scrollTop / this.zoomFactor;
const viewWidth = this.canvas.clientWidth / this.zoomFactor;
const viewHeight = this.canvas.clientHeight / this.zoomFactor;
const buttonWidth = 190;
const occupied = { left: [], right: [] };
const reserveY = (side, desired) => {
let y = Math.max(viewTop + 12, Math.min(desired, viewTop + viewHeight - 40));
while (occupied[side].some((used) => Math.abs(used - y) < 34)) y += 34;
if (y > viewTop + viewHeight - 40) y = viewTop + 12;
occupied[side].push(y);
return y;
};
for (const crossing of crossings) {
const insideX = Number(crossing.insideConcept.node.attr("x")) +
(Number(crossing.insideConcept.node.attr("width")) / 2);
const insideY = Number(crossing.insideConcept.node.attr("y")) +
(Number(crossing.insideConcept.node.attr("height")) / 2);
const side = insideX <= viewLeft + (viewWidth / 2) ? "left" : "right";
const x = side === "left" ? viewLeft + 12 : viewLeft + viewWidth - buttonWidth - 12;
const y = reserveY(side, insideY - 15);
const button = document.createElement("button");
button.type = "button";
button.className = `rw-cmap-boundary-reference rw-cmap-boundary-reference-${side}`;
button.style.transform = `translate(${x}px, ${y}px)`;
button.style.width = `${buttonWidth}px`;
button.textContent = crossing.outsideConcept.label || "External concept";
button.title = "Open the concept map containing this connection";
button.addEventListener("click", () => {
if (this.editor.onOpenBoundaryReference) {
this.editor.onOpenBoundaryReference(crossing.outsideConcept, crossing.connector);
}
});
layer.append(button);
const boundaryX = side === "left" ? x + buttonWidth : x;
const boundaryY = y + 15;
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
const startX = crossing.sourceInside ? insideX : boundaryX;
const startY = crossing.sourceInside ? insideY : boundaryY;
const endX = crossing.sourceInside ? boundaryX : insideX;
const endY = crossing.sourceInside ? boundaryY : insideY;
path.setAttribute("d", `M ${startX} ${startY} L ${endX} ${endY}`);
path.setAttribute("fill", "none");
path.setAttribute("stroke", BOUNDARY_LINE_COLOR);
path.setAttribute("stroke-width", String(crossing.connector.lineWidth || 2));
if (crossing.connector.hasArrow) path.setAttribute("marker-end", "url(#rw-cmap-boundary-arrow)");
svg.append(path);
}
}
updateSubmapAnchorLine(record, bounds, surface = this.editor.surfaceElement()) {
if (!surface || !bounds || record === this.editor.activeMapRoot || !record.expanded) {
if (record.submapAnchorLineElement) {
record.submapAnchorLineElement.remove();
record.submapAnchorLineElement = null;
}
return;
}
if (!record.submapAnchorLineElement) {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.classList.add("rw-cmap-submap-anchor-line");
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
svg.append(path);
surface.prepend(svg);
record.submapAnchorLineElement = svg;
}
const anchor = this.editor.itemCenter(record);
const target = {
x: Math.max(bounds.left, Math.min(anchor.x, bounds.right)),
y: Math.max(bounds.top, Math.min(anchor.y, bounds.bottom))
};
record.submapAnchorLineElement.querySelector("path")
.setAttribute("d", `M ${anchor.x} ${anchor.y} L ${target.x} ${target.y}`);
}
}
@@ -0,0 +1,102 @@
"use strict";
const XHTML_NAMESPACE = "http://www.w3.org/1999/xhtml";
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
/**
* Convert the rendered CMap surface into a standalone SVG snapshot.
*
* HTML concept cards remain inside an XHTML foreignObject while connector SVG
* elements stay vector based. The renderer reads the current DOM only; it has
* no knowledge of the editor model or persistence layer.
*/
export class CmapSnapshotSvgRenderer {
constructor({ stylesheetFilter = (sheet) => Boolean(sheet.href?.includes("cmap.css")) } = {}) {
this.stylesheetFilter = stylesheetFilter;
}
render(surface, { width = null, height = null, includeStyles = true } = {}) {
if (!surface || typeof surface.cloneNode !== "function") {
throw new TypeError("A rendered CMap surface is required");
}
const dimensions = this.dimensions(surface, width, height);
const clone = surface.cloneNode(true);
this.removeInteractiveElements(clone);
clone.removeAttribute("id");
clone.style.zoom = "1";
clone.style.transform = "none";
clone.style.width = `${dimensions.width}px`;
clone.style.height = `${dimensions.height}px`;
const svg = document.createElementNS(SVG_NAMESPACE, "svg");
svg.setAttribute("xmlns", SVG_NAMESPACE);
svg.setAttribute("xmlns:xhtml", XHTML_NAMESPACE);
svg.setAttribute("version", "1.1");
svg.setAttribute("width", String(dimensions.width));
svg.setAttribute("height", String(dimensions.height));
svg.setAttribute("viewBox", `0 0 ${dimensions.width} ${dimensions.height}`);
svg.setAttribute("preserveAspectRatio", "xMinYMin meet");
if (includeStyles) {
const style = document.createElementNS(SVG_NAMESPACE, "style");
style.textContent = this.stylesheetText();
svg.append(style);
}
const foreignObject = document.createElementNS(SVG_NAMESPACE, "foreignObject");
foreignObject.setAttribute("x", "0");
foreignObject.setAttribute("y", "0");
foreignObject.setAttribute("width", String(dimensions.width));
foreignObject.setAttribute("height", String(dimensions.height));
clone.setAttribute("xmlns", XHTML_NAMESPACE);
foreignObject.append(clone);
svg.append(foreignObject);
return new XMLSerializer().serializeToString(svg);
}
dimensions(surface, requestedWidth, requestedHeight) {
const width = Number(requestedWidth) || Math.ceil(Math.max(
surface.scrollWidth || 0,
surface.getBoundingClientRect?.().width || 0,
...Array.from(surface.children || []).map((child) =>
(Number.parseFloat(child.style.left) || 0) + (child.offsetWidth || 0))
));
const height = Number(requestedHeight) || Math.ceil(Math.max(
surface.scrollHeight || 0,
surface.getBoundingClientRect?.().height || 0,
...Array.from(surface.children || []).map((child) =>
(Number.parseFloat(child.style.top) || 0) + (child.offsetHeight || 0))
));
return {
width: Math.max(1, width || 1),
height: Math.max(1, height || 1)
};
}
removeInteractiveElements(root) {
for (const element of root.querySelectorAll(
".rw-cmap-handle, .rw-cmap-resize-handle, .rw-cmap-relation-handle, " +
".rw-cmap-edit-handle, .rw-cmap-submap-toggle, .rw-cmap-submap-open")) {
element.remove();
}
for (const element of root.querySelectorAll("[aria-selected], [data-rw-cmap-bound]") ) {
element.removeAttribute("aria-selected");
element.removeAttribute("data-rw-cmap-bound");
}
}
stylesheetText() {
if (typeof document === "undefined") return "";
const rules = [];
for (const sheet of Array.from(document.styleSheets || [])) {
if (!this.stylesheetFilter(sheet)) continue;
try {
rules.push(...Array.from(sheet.cssRules || []).map((rule) => rule.cssText));
} catch (_error) {
// Cross-origin stylesheets cannot be inspected and are skipped.
}
}
return rules.join("\n");
}
}