refactoring van de cmap structuren bijna compleet
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user