Files
racket-wiki/static/js/cmap/view/cmap-item-decorator.js
T

547 lines
22 KiB
JavaScript

"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}`);
}
}