big cmap refactoring

This commit is contained in:
2026-09-03 08:40:15 +02:00
parent f0562a06cc
commit 2c141b6d3f
32 changed files with 5616 additions and 4216 deletions
+47 -2
View File
@@ -75,6 +75,7 @@ function fakeLink(attributes) {
}
async function loadEditorFactory() {
const previousFactory = global.window && global.window.RacketWikiCmap;
global.Element = class Element {};
global.document = {
currentScript: null,
@@ -83,12 +84,12 @@ async function loadEditorFactory() {
createElement: () => fakeElement()
};
global.window = {
Cmap: null,
setTimeout,
clearTimeout,
requestAnimationFrame: (callback) => callback()
};
await import("../static/cmap/cmap-racket-wiki.js");
global.window.RacketWikiCmap = global.window.RacketWikiCmap || previousFactory;
return global.window.RacketWikiCmap;
}
@@ -114,7 +115,7 @@ test("a dragged connector endpoint survives collapsing and expanding its submap"
scrollTop: 0,
isConnected: true
};
const editor = factory.createEditor(canvas, { Cmap: () => map });
const editor = factory.createEditor(canvas, { createDiagramEngine: () => map });
const submap = editor.addItem({
id: 1,
kind: "submap",
@@ -173,3 +174,47 @@ test("a dragged connector endpoint survives collapsing and expanding its submap"
lineWidth: 2
}]);
});
test("moving a concept also moves connected concepts that link to another CMap", async () => {
const factory = await loadEditorFactory();
const map = {
onSelection() {},
onActivation() {},
node: (attributes) => fakeNode(attributes),
link: (attributes) => fakeLink(attributes)
};
const canvas = {
addEventListener() {},
querySelector: () => null,
clientWidth: 1200,
clientHeight: 800,
scrollLeft: 0,
scrollTop: 0,
isConnected: true
};
const editor = factory.createEditor(canvas, { createDiagramEngine: () => map });
const owner = editor.addItem({ id: 1, label: "Owner", x: 100, y: 100 });
const phrase = editor.addItem({ id: 2, kind: "phrase", label: "relates to", x: 260, y: 100 });
const linked = editor.addItem({
id: 3,
label: "Linked CMap concept",
cmapSlug: "other-map",
x: 420,
y: 100
});
editor.addConnector(owner, phrase, true, { id: 1 });
editor.addConnector(phrase, linked, true, { id: 2 });
editor.selectItem(owner);
editor.handleItemMove(owner, 180, 140);
assert.deepEqual({
owner: [owner.node.attr("x"), owner.node.attr("y")],
phrase: [phrase.node.attr("x"), phrase.node.attr("y")],
linked: [linked.node.attr("x"), linked.node.attr("y")]
}, {
owner: [180, 140],
phrase: [340, 140],
linked: [500, 140]
});
});
+2 -2
View File
@@ -29,7 +29,7 @@ async function loadEditorFactory() {
body: { append() {} },
createElement: () => fakeElement()
};
global.window = { Cmap: null };
global.window = {};
await import("../static/cmap/cmap-racket-wiki.js");
return global.window.RacketWikiCmap;
}
@@ -48,7 +48,7 @@ test("automatic render sizing advances only an unchanged saved baseline", async
let savedBaseline = null;
let notification = null;
const editor = factory.createEditor(canvas, {
Cmap: () => map,
createDiagramEngine: () => map,
onAutomaticLayoutChange: (change) => {
notification = change;
if (savedBaseline === change.beforeSnapshot) {
+98
View File
@@ -0,0 +1,98 @@
import test from "node:test";
import assert from "node:assert/strict";
import { DiagramGroup } from "../static/cmap/engine/diagram-group.js";
import { DiagramComponent } from "../static/cmap/engine/diagram-component.js";
import { DiagramEngine } from "../static/cmap/engine/diagram-engine.js";
function component(engine, attributes) {
const state = { ...attributes };
const drawing = {
visible: true,
redraw() {},
x: () => state.x,
y: () => state.y,
width: () => state.width,
height: () => state.height
};
return new DiagramComponent(engine, drawing, ["x", "y", "width", "height"]);
}
test("DiagramGroup calculates bounds for nodes and nested groups", () => {
const engine = { surfaceElement: () => null, applyFilter: (handle) => {
handle.component.visible = handle.baseVisible;
handle.component.redraw();
}};
const first = component(engine, { x: 10, y: 20, width: 100, height: 40 });
const second = component(engine, { x: 150, y: 80, width: 50, height: 30 });
const group = new DiagramGroup(engine, { padding: 10 });
const nested = new DiagramGroup(engine, { padding: 5 });
nested.add(second);
group.add(first).add(nested);
assert.deepEqual(group.bounds(), { left: 0, top: 10, right: 215, bottom: 125 });
});
test("DiagramGroup collapse controls member visibility", () => {
const engine = { surfaceElement: () => null, applyFilter: (handle) => {
handle.component.visible = handle.baseVisible;
handle.component.redraw();
}};
const item = component(engine, { x: 0, y: 0, width: 20, height: 20 });
const group = new DiagramGroup(engine);
group.add(item);
assert.equal(group.setExpanded(false), false);
assert.equal(item.visible(), false);
assert.equal(group.setExpanded(true), true);
assert.equal(item.visible(), true);
});
test("DiagramGroup updates frame appearance through its public API", () => {
const engine = { surfaceElement: () => null };
const group = new DiagramGroup(engine, { backgroundColor: "white", borderColor: "black" });
group.setAppearance({ label: "Nested", backgroundColor: "green", borderColor: "blue" });
assert.equal(group.label, "Nested");
assert.equal(group.backgroundColor, "green");
assert.equal(group.borderColor, "blue");
});
test("nested DiagramGroups keep child frames above parent frames", () => {
const engine = {
surfaceElement: () => null,
applyFilter: (handle) => {
handle.component.visible = handle.baseVisible;
handle.component.redraw();
}
};
const item = component(engine, { x: 20, y: 20, width: 40, height: 30 });
const child = new DiagramGroup(engine, { depth: 1 });
const parent = new DiagramGroup(engine, { depth: 0 });
child.add(item);
parent.add(child);
assert.equal(child.depth > parent.depth, true);
assert.equal(parent.bounds().left, -28);
assert.equal(child.bounds().left, -4);
});
test("DiagramEngine combines base visibility with an external filter", () => {
const engine = Object.create(DiagramEngine.prototype);
engine.filter = null;
engine.handles = new Map();
engine.groups = new Set();
const item = component(engine, { x: 0, y: 0, width: 20, height: 20 });
engine.handles.set(item.component, item);
engine.setFilter((handle) => handle === item);
assert.equal(item.visible(), true);
engine.setFilter(() => false);
assert.equal(item.visible(), false);
item.visible(true);
assert.equal(item.visible(), false);
engine.setFilter(null);
assert.equal(item.visible(), true);
});
+48
View File
@@ -0,0 +1,48 @@
import test from "node:test";
import assert from "node:assert/strict";
import { CmapHistory } from "../static/cmap/controller/cmap-history.js";
function history(initial = "one") {
let value = initial;
const changes = [];
const instance = new CmapHistory({
snapshot: () => value,
restore: (snapshot) => { value = snapshot; },
onChange: (change) => changes.push(change),
limit: 2
});
return {
instance,
changes,
get value() { return value; },
set value(next) { value = next; }
};
}
test("CmapHistory commits, undoes and redoes serialized state", () => {
const state = history();
state.instance.reset();
state.value = "two";
assert.equal(state.instance.commit(), true);
state.value = "three";
assert.equal(state.instance.commit(), true);
assert.equal(state.instance.undo(), true);
assert.equal(state.value, "two");
assert.equal(state.instance.redo(), true);
assert.equal(state.value, "three");
assert.equal(state.instance.canUndo(), true);
assert.equal(state.instance.canRedo(), false);
});
test("CmapHistory replaces a document as one undo operation", () => {
const state = history();
state.instance.reset();
state.instance.replace(() => { state.value = "replacement"; });
assert.equal(state.value, "replacement");
assert.equal(state.instance.undo(), true);
assert.equal(state.value, "one");
assert.ok(state.changes.length > 0);
});
+7 -3
View File
@@ -88,9 +88,10 @@ test("relations render and hit-test behind concept nodes", async () => {
requestAnimationFrame: (callback) => setImmediate(callback)
};
await import("../static/cmap/cmap.js");
const Cmap = global.window.Cmap;
const map = Cmap(root);
const { DiagramEngine } = await import("../static/cmap/cmap.js");
assert.equal(global.window.Cmap, undefined,
"the ES-module engine does not install a legacy global");
const map = new DiagramEngine(root);
const concept = map.node({ x: 40, y: 0, width: 120, height: 50 });
const otherConcept = map.node({ x: 220, y: 0, width: 120, height: 50 });
const relation = map.link({
@@ -98,6 +99,9 @@ test("relations render and hit-test behind concept nodes", async () => {
cx: 150, cy: 25,
targetX: 360, targetY: 25
});
relation.sourceNode(concept).targetNode(otherConcept).straighten();
assert.equal(relation.sourceNode(), concept);
assert.equal(relation.targetNode(), otherConcept);
await settleRendering();
relation.toFront();
+2 -3
View File
@@ -25,7 +25,6 @@ async function loadEditorFactory() {
createElement: () => ({ style: {}, remove() {} })
};
global.window = {
Cmap: null,
setTimeout,
clearTimeout,
requestAnimationFrame: (callback) => callback()
@@ -46,7 +45,7 @@ test("the last selected concept determines target size and alignment", async ()
clientWidth: 1200, clientHeight: 800, scrollLeft: 0, scrollTop: 0,
isConnected: true
};
const editor = factory.createEditor(canvas, { Cmap: () => map });
const editor = factory.createEditor(canvas, { createDiagramEngine: () => map });
const first = editor.addItem({
id: 1, label: "First", externalUrl: "https://example.com/first",
x: 10, y: 10, width: 100, height: 40
@@ -65,7 +64,7 @@ test("the last selected concept determines target size and alignment", async ()
"https://example.com/first",
"external links are serialized as shared concept content"
);
const reloaded = factory.createEditor(canvas, { Cmap: () => map });
const reloaded = factory.createEditor(canvas, { createDiagramEngine: () => map });
reloaded.loadModel(editor.currentModel());
assert.equal(
reloaded.toDocument().concepts.find((concept) => concept.id === first.conceptId).externalUrl,
+188 -6
View File
@@ -3,21 +3,38 @@
const test = require("node:test");
const assert = require("node:assert/strict");
let editorFactory;
function fakeNode(attributes) {
const state = { ...attributes };
const state = { ...attributes, visible: true };
return {
attr(value) {
if (typeof value === "string") return state[value];
Object.assign(state, value);
return this;
},
onRendered() {}, onMove() {}, onMoveEnd() {}, visible() {}, redraw() {},
onRendered() {}, onMove() {}, onMoveEnd() {},
visible(value) {
if (value === undefined) return state.visible;
state.visible = Boolean(value);
return state.visible;
},
redraw() {},
toFront() {}, remove() {}, element: () => null
};
}
async function loadEditorFactory() {
global.Element = class Element {};
if (editorFactory) return editorFactory;
global.Element = class Element {
constructor() {
this.tagName = "DIV";
this.id = "";
this.classList = [];
this.dataset = {};
}
closest() { return null; }
};
global.document = {
currentScript: null,
styleSheets: [],
@@ -25,13 +42,13 @@ async function loadEditorFactory() {
createElement: () => ({ style: {}, remove() {} })
};
global.window = {
Cmap: null,
setTimeout,
clearTimeout,
requestAnimationFrame: (callback) => callback()
};
await import("../static/cmap/cmap-racket-wiki.js");
return global.window.RacketWikiCmap;
editorFactory = global.window.RacketWikiCmap;
return editorFactory;
}
test("back from a second nested submap returns exactly one map level", async () => {
@@ -46,7 +63,7 @@ test("back from a second nested submap returns exactly one map level", async ()
clientWidth: 1200, clientHeight: 800, scrollLeft: 0, scrollTop: 0,
isConnected: true
};
const editor = factory.createEditor(canvas, { Cmap: () => map });
const editor = factory.createEditor(canvas, { createDiagramEngine: () => map });
const first = editor.addItem({
id: 1, kind: "submap", label: "First level", separateMap: true,
mapReference: { id: "first", title: "First level" },
@@ -72,3 +89,168 @@ test("back from a second nested submap returns exactly one map level", async ()
assert.equal(editor.activeMapRoot, first);
assert.equal(editor.canStepBackWithinMap(), false);
});
test("single-click selects and double-click edits every kind of concept", async () => {
const factory = await loadEditorFactory();
const map = {
onSelection() {},
onActivation() {},
node: (attributes) => fakeNode(attributes)
};
const canvas = {
addEventListener() {},
querySelector: () => null,
clientWidth: 1200, clientHeight: 800, scrollLeft: 0, scrollTop: 0,
isConnected: true
};
const edited = [];
const openedPages = [];
const openedMaps = [];
const editor = factory.createEditor(canvas, {
createDiagramEngine: () => map,
onEditItem: (record) => edited.push(record),
onOpenPage: (record) => openedPages.push(record),
onOpenCmap: (record) => openedMaps.push(record)
});
const ordinary = editor.addItem({ id: 1, label: "Ordinary" });
const page = editor.addItem({ id: 2, label: "Page", pageSlug: "page" });
const linkedMap = editor.addItem({ id: 3, label: "Linked map", cmapSlug: "other" });
const submap = editor.addItem({ id: 4, kind: "submap", label: "Group" });
editor.handleMapSelection(linkedMap.node, {
target: new global.Element(),
ctrlKey: false,
metaKey: false,
shiftKey: false
});
assert.equal(editor.selected(), linkedMap);
assert.deepEqual(openedMaps, []);
editor.handleMapActivation(ordinary.node, { preventDefault() {} });
editor.handleMapActivation(page.node, { preventDefault() {} });
editor.handleMapActivation(linkedMap.node, { preventDefault() {} });
editor.handleMapActivation(submap.node, { preventDefault() {} });
assert.deepEqual(edited, [ordinary, page, linkedMap, submap]);
assert.deepEqual(openedPages, []);
assert.deepEqual(openedMaps, []);
});
test("a linked CMap opens only through its arrow decorator", async () => {
await loadEditorFactory();
const { CmapItemDecorator } = await import(
"../static/cmap/view/cmap-item-decorator.js");
const listeners = new Map();
const linkedButton = {
dataset: {},
addEventListener(type, listener) { listeners.set(type, listener); }
};
const elementListeners = new Map();
const element = {
dataset: {},
style: {},
classList: { add() {}, remove() {}, toggle() {} },
setAttribute() {},
addEventListener(type, listener) { elementListeners.set(type, listener); },
querySelector(selector) {
return selector === ".rw-cmap-open-linked" ? linkedButton : null;
}
};
const openedMaps = [];
const editor = {
labels: { relation: "Relation" },
selectedItems: new Set(),
selectedItem: null,
activeMapRoot: null,
onOpenCmap: (record) => openedMaps.push(record),
ensureCanvasExtent() {}
};
const record = {
id: 1,
kind: "concept",
label: "Linked map",
cmapSlug: "other",
node: {
attr(name) {
return { x: 10, y: 20, width: 120, height: 50 }[name];
}
}
};
const decorator = new CmapItemDecorator(editor);
decorator.fitItemToContent = () => {};
decorator.decorateItem(record, element);
assert.equal(elementListeners.has("click"), false,
"the complete concept must not become a navigation target");
listeners.get("click")({ preventDefault() {}, stopPropagation() {} });
assert.deepEqual(openedMaps, [record]);
});
test("collapsed submap descendants stay hidden after the first render frame", async () => {
const factory = await loadEditorFactory();
const renderFrames = [];
global.window.requestAnimationFrame = (callback) => renderFrames.push(callback);
const map = {
onSelection() {},
onActivation() {},
node: (attributes) => fakeNode(attributes)
};
const canvas = {
addEventListener() {}, querySelector: () => null,
clientWidth: 1200, clientHeight: 800, scrollLeft: 0, scrollTop: 0,
isConnected: true
};
const editor = factory.createEditor(canvas, { createDiagramEngine: () => map });
const submap = editor.addItem({ id: 1, kind: "submap", label: "Collapsed" });
submap.expanded = false;
const child = editor.addItem({ id: 2, label: "Hidden child", parentSubmap: submap });
editor.applyCurrentContextLayout();
assert.equal(child.node.visible(), false);
child.node.visible(true);
for (const renderFrame of renderFrames.splice(0)) renderFrame();
assert.equal(child.node.visible(), false,
"the post-render visibility pass hides a descendant exposed by initial rendering");
});
test("loading an unresolved connector retains it without a runtime error", async () => {
const factory = await loadEditorFactory();
global.window.requestAnimationFrame = (callback) => callback();
const map = {
onSelection() {},
onActivation() {},
node: (attributes) => fakeNode(attributes)
};
const canvas = {
addEventListener() {}, querySelector: () => null,
clientWidth: 1200, clientHeight: 800, scrollLeft: 0, scrollTop: 0,
isConnected: true
};
const editor = factory.createEditor(canvas, { createDiagramEngine: () => map });
const warnings = [];
const originalWarn = console.warn;
console.warn = (...values) => warnings.push(values);
try {
editor.loadDocument({
schemaVersion: 2,
concepts: [{ id: "concept-one", label: "One" }],
items: [{
id: 1, conceptId: "concept-one", kind: "concept",
x: 20, y: 20, width: 120, height: 50
}],
connectors: [{
id: 1, sourceId: 1, targetId: 999,
hasArrow: true, lineColor: "#333", lineWidth: 2
}]
});
} finally {
console.warn = originalWarn;
}
assert.equal(editor.unresolvedConnectors.length, 1);
assert.match(warnings[0][0], /relation retained without rendering/);
});