@diagc/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +709 -0
- package/README.md +27 -0
- package/dist/builder.d.ts +381 -0
- package/dist/builder.js +590 -0
- package/dist/children.d.ts +21 -0
- package/dist/children.js +45 -0
- package/dist/commands.d.ts +219 -0
- package/dist/commands.js +474 -0
- package/dist/compose.d.ts +19 -0
- package/dist/compose.js +246 -0
- package/dist/drawings.d.ts +13 -0
- package/dist/drawings.js +36 -0
- package/dist/eject.d.ts +20 -0
- package/dist/eject.js +260 -0
- package/dist/fishbone.d.ts +66 -0
- package/dist/fishbone.js +95 -0
- package/dist/git.d.ts +65 -0
- package/dist/git.js +159 -0
- package/dist/guards.d.ts +10 -0
- package/dist/guards.js +98 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +45 -0
- package/dist/labels.d.ts +5 -0
- package/dist/labels.js +10 -0
- package/dist/layout-defaults.d.ts +20 -0
- package/dist/layout-defaults.js +20 -0
- package/dist/mutate.d.ts +106 -0
- package/dist/mutate.js +547 -0
- package/dist/second-order.d.ts +39 -0
- package/dist/second-order.js +86 -0
- package/dist/text.d.ts +5 -0
- package/dist/text.js +25 -0
- package/dist/threat-model.d.ts +88 -0
- package/dist/threat-model.js +188 -0
- package/dist/types.d.ts +395 -0
- package/dist/types.js +27 -0
- package/dist/util.d.ts +11 -0
- package/dist/util.js +13 -0
- package/dist/validate.d.ts +19 -0
- package/dist/validate.js +736 -0
- package/dist/view/compile.d.ts +29 -0
- package/dist/view/compile.js +78 -0
- package/dist/view/edges.d.ts +4 -0
- package/dist/view/edges.js +118 -0
- package/dist/view/hierarchy.d.ts +41 -0
- package/dist/view/hierarchy.js +103 -0
- package/dist/view/layers.d.ts +7 -0
- package/dist/view/layers.js +17 -0
- package/dist/view/lod.d.ts +15 -0
- package/dist/view/lod.js +17 -0
- package/dist/view/scope.d.ts +23 -0
- package/dist/view/scope.js +106 -0
- package/dist/view/size.d.ts +8 -0
- package/dist/view/size.js +34 -0
- package/dist/view/tree.d.ts +12 -0
- package/dist/view/tree.js +147 -0
- package/dist/view/types.d.ts +68 -0
- package/dist/view/types.js +1 -0
- package/package.json +38 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import type { Column, DiagramLayer, DiagramLegend, DiagramModel, DiagramNode, DiagramPlane, Drawings, EdgeLabelPlacement, LayoutOverlay, LayoutSettings, Stroke, TextRun, Threat } from './types.js';
|
|
2
|
+
import { type ThreatTarget } from './threat-model.js';
|
|
3
|
+
import { type NodeDetails, type RelationOptsInput, type RelationPatch, type ThreatPatch } from './mutate.js';
|
|
4
|
+
export interface EditorState {
|
|
5
|
+
model: DiagramModel;
|
|
6
|
+
layout: LayoutOverlay;
|
|
7
|
+
/** the freehand-drawings sidecar; emptyDrawings() when the diagram has none */
|
|
8
|
+
drawings: Drawings;
|
|
9
|
+
}
|
|
10
|
+
export declare const emptyLayout: () => LayoutOverlay;
|
|
11
|
+
export declare function layoutPlaneKey(m: DiagramModel, plane?: string): string;
|
|
12
|
+
/**
|
|
13
|
+
* The pins a plane opens with: its saved `unfolded` containers, in the shape
|
|
14
|
+
* `compileView`'s viewport reads. One reader for every host (studio, published
|
|
15
|
+
* page, embed), so a saved arrangement reopens the same everywhere.
|
|
16
|
+
*/
|
|
17
|
+
export declare function openingPins(layout: LayoutOverlay | undefined, m: DiagramModel, plane?: string): Record<string, 'expanded' | 'collapsed'>;
|
|
18
|
+
export type EditorCommand = {
|
|
19
|
+
type: 'add-node';
|
|
20
|
+
node: DiagramNode;
|
|
21
|
+
parent?: {
|
|
22
|
+
id: string;
|
|
23
|
+
plane?: string;
|
|
24
|
+
};
|
|
25
|
+
} | {
|
|
26
|
+
type: 'rename-node';
|
|
27
|
+
id: string;
|
|
28
|
+
name: string;
|
|
29
|
+
} | {
|
|
30
|
+
type: 'set-node-details';
|
|
31
|
+
id: string;
|
|
32
|
+
details: NodeDetails;
|
|
33
|
+
} | {
|
|
34
|
+
type: 'set-node-rich';
|
|
35
|
+
id: string;
|
|
36
|
+
runs: TextRun[];
|
|
37
|
+
} | {
|
|
38
|
+
type: 'set-table-columns';
|
|
39
|
+
id: string;
|
|
40
|
+
columns: Column[];
|
|
41
|
+
}
|
|
42
|
+
/** STRIDE findings ride on the node/relation they are about, so the three
|
|
43
|
+
* threat commands take a {@link ThreatTarget} instead of a bare id. */
|
|
44
|
+
| {
|
|
45
|
+
type: 'add-threat';
|
|
46
|
+
target: ThreatTarget;
|
|
47
|
+
threat: Threat;
|
|
48
|
+
} | {
|
|
49
|
+
type: 'update-threat';
|
|
50
|
+
target: ThreatTarget;
|
|
51
|
+
id: string;
|
|
52
|
+
patch: ThreatPatch;
|
|
53
|
+
} | {
|
|
54
|
+
type: 'remove-threat';
|
|
55
|
+
target: ThreatTarget;
|
|
56
|
+
id: string;
|
|
57
|
+
} | {
|
|
58
|
+
type: 'set-node-plane-hidden';
|
|
59
|
+
nodeId: string;
|
|
60
|
+
plane: string;
|
|
61
|
+
hidden: boolean;
|
|
62
|
+
} | {
|
|
63
|
+
type: 'set-diagram-style';
|
|
64
|
+
style: string | null;
|
|
65
|
+
} | {
|
|
66
|
+
type: 'set-diagram-notation';
|
|
67
|
+
notation: string | null;
|
|
68
|
+
} | {
|
|
69
|
+
type: 'set-diagram-legend';
|
|
70
|
+
legend: DiagramLegend | null;
|
|
71
|
+
} | {
|
|
72
|
+
type: 'delete-node';
|
|
73
|
+
id: string;
|
|
74
|
+
cascade?: boolean;
|
|
75
|
+
} | {
|
|
76
|
+
type: 'add-containment';
|
|
77
|
+
parent: string;
|
|
78
|
+
child: string;
|
|
79
|
+
plane?: string;
|
|
80
|
+
} | {
|
|
81
|
+
type: 'remove-containment';
|
|
82
|
+
parent: string;
|
|
83
|
+
child: string;
|
|
84
|
+
plane?: string;
|
|
85
|
+
} | {
|
|
86
|
+
type: 'group-nodes';
|
|
87
|
+
node: DiagramNode;
|
|
88
|
+
memberIds: string[];
|
|
89
|
+
plane?: string;
|
|
90
|
+
} | {
|
|
91
|
+
type: 'add-relation';
|
|
92
|
+
from: string;
|
|
93
|
+
to: string;
|
|
94
|
+
opts: RelationOptsInput;
|
|
95
|
+
} | {
|
|
96
|
+
type: 'update-relation';
|
|
97
|
+
id: string;
|
|
98
|
+
patch: RelationPatch;
|
|
99
|
+
} | {
|
|
100
|
+
type: 'delete-relation';
|
|
101
|
+
id: string;
|
|
102
|
+
} | {
|
|
103
|
+
type: 'upsert-layer';
|
|
104
|
+
layer: DiagramLayer;
|
|
105
|
+
} | {
|
|
106
|
+
type: 'delete-layer';
|
|
107
|
+
id: string;
|
|
108
|
+
} | {
|
|
109
|
+
type: 'merge-layers';
|
|
110
|
+
sources: string[];
|
|
111
|
+
target?: string;
|
|
112
|
+
} | {
|
|
113
|
+
type: 'upsert-plane';
|
|
114
|
+
plane: DiagramPlane;
|
|
115
|
+
} | {
|
|
116
|
+
type: 'delete-plane';
|
|
117
|
+
id: string;
|
|
118
|
+
} | {
|
|
119
|
+
type: 'set-position';
|
|
120
|
+
plane?: string;
|
|
121
|
+
nodeId: string;
|
|
122
|
+
x: number;
|
|
123
|
+
y: number;
|
|
124
|
+
} | {
|
|
125
|
+
type: 'set-size';
|
|
126
|
+
nodeId: string;
|
|
127
|
+
w: number;
|
|
128
|
+
h: number;
|
|
129
|
+
} | {
|
|
130
|
+
type: 'clear-position';
|
|
131
|
+
plane?: string;
|
|
132
|
+
nodeId: string;
|
|
133
|
+
} | {
|
|
134
|
+
type: 'clear-positions';
|
|
135
|
+
plane?: string;
|
|
136
|
+
} | {
|
|
137
|
+
type: 'set-positions';
|
|
138
|
+
plane?: string;
|
|
139
|
+
positions: Record<string, {
|
|
140
|
+
x: number;
|
|
141
|
+
y: number;
|
|
142
|
+
}>;
|
|
143
|
+
} | {
|
|
144
|
+
type: 'set-plane-layout';
|
|
145
|
+
plane?: string;
|
|
146
|
+
manual: boolean;
|
|
147
|
+
}
|
|
148
|
+
/** replace the list of containers the plane opens with unfolded
|
|
149
|
+
* (LayoutOverlay.unfolded); `[]` clears it */
|
|
150
|
+
| {
|
|
151
|
+
type: 'set-unfolded';
|
|
152
|
+
plane?: string;
|
|
153
|
+
ids: string[];
|
|
154
|
+
} | {
|
|
155
|
+
type: 'set-layout-settings';
|
|
156
|
+
plane?: string;
|
|
157
|
+
patch: Partial<LayoutSettings>;
|
|
158
|
+
}
|
|
159
|
+
/** a threat note was dragged: its offset from the automatic anchor, or null to
|
|
160
|
+
* let it sit beside its element again. Layout-only — the threats stay put. */
|
|
161
|
+
| {
|
|
162
|
+
type: 'set-note-offset';
|
|
163
|
+
target: ThreatTarget;
|
|
164
|
+
plane?: string;
|
|
165
|
+
offset: {
|
|
166
|
+
dx: number;
|
|
167
|
+
dy: number;
|
|
168
|
+
} | null;
|
|
169
|
+
}
|
|
170
|
+
/** open or close one element's threat bubble in this picture (saved, so the export shows it) */
|
|
171
|
+
| {
|
|
172
|
+
type: 'set-note-open';
|
|
173
|
+
target: ThreatTarget;
|
|
174
|
+
plane?: string;
|
|
175
|
+
open: boolean;
|
|
176
|
+
}
|
|
177
|
+
/** every element in the model that carries a threat, at once — the `Notes` chip */
|
|
178
|
+
| {
|
|
179
|
+
type: 'set-notes-open';
|
|
180
|
+
plane?: string;
|
|
181
|
+
open: boolean;
|
|
182
|
+
} | {
|
|
183
|
+
type: 'add-stroke';
|
|
184
|
+
plane?: string;
|
|
185
|
+
stroke: Stroke;
|
|
186
|
+
} | {
|
|
187
|
+
type: 'delete-stroke';
|
|
188
|
+
plane?: string;
|
|
189
|
+
id: string;
|
|
190
|
+
}
|
|
191
|
+
/** several commands as one step: applied in order, all or nothing, one undo entry */
|
|
192
|
+
| {
|
|
193
|
+
type: 'batch';
|
|
194
|
+
commands: EditorCommand[];
|
|
195
|
+
};
|
|
196
|
+
/**
|
|
197
|
+
* `layout` with the plane's unfolded list replaced. Sorted and de-duplicated so
|
|
198
|
+
* the file does not churn with the order boxes were clicked in; an emptied list
|
|
199
|
+
* is dropped and an emptied map omitted entirely (the set-plane-layout hygiene).
|
|
200
|
+
* Exported because the studio's view-mode save builds the same overlay without
|
|
201
|
+
* a command.
|
|
202
|
+
*/
|
|
203
|
+
export declare function withUnfolded(layout: LayoutOverlay, key: string, ids: readonly string[]): LayoutOverlay;
|
|
204
|
+
/**
|
|
205
|
+
* `layout` with viewer label placements merged into the plane's bucket (see
|
|
206
|
+
* LayoutOverlay.edgeLabels). Exported because the studio's view-mode save
|
|
207
|
+
* builds the overlay without a command.
|
|
208
|
+
*/
|
|
209
|
+
export declare function withEdgeLabelPlacements(layout: LayoutOverlay, key: string, placements: Readonly<Record<string, Readonly<Record<string, EdgeLabelPlacement>>>>): LayoutOverlay;
|
|
210
|
+
export declare function applyCommand(state: EditorState, command: EditorCommand): EditorState;
|
|
211
|
+
/**
|
|
212
|
+
* Like {@link applyCommand}, but surfaces the generated id for `add-relation` so
|
|
213
|
+
* callers (e.g. a connect gesture) can select the new relation. All other
|
|
214
|
+
* commands delegate unchanged and carry no id.
|
|
215
|
+
*/
|
|
216
|
+
export declare function applyCommandWithResult(state: EditorState, command: EditorCommand): {
|
|
217
|
+
state: EditorState;
|
|
218
|
+
relationId?: string;
|
|
219
|
+
};
|
package/dist/commands.js
ADDED
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
import { threatTargetKey } from './threat-model.js';
|
|
2
|
+
import { resolveContainmentPlane } from './view/compile.js';
|
|
3
|
+
import { addStroke, deleteStroke, pruneDrawingsPlane } from './drawings.js';
|
|
4
|
+
import { relationLabels } from './labels.js';
|
|
5
|
+
import { addContainment, addNode, addRelation, addThreat, CommandError, deleteLayer, deleteNode, deletePlane, deleteRelation, groupNodes, mergeLayers, removeContainment, removeThreat, renameNode, setDiagramLegend, setDiagramNotation, setDiagramStyle, setNodeDetails, setNodePlaneHidden, setNodeRich, setTableColumns, subtreeOf, updateRelation, updateThreat, upsertLayer, upsertPlane, } from './mutate.js';
|
|
6
|
+
export const emptyLayout = () => ({ version: 1, planes: {} });
|
|
7
|
+
export function layoutPlaneKey(m, plane) {
|
|
8
|
+
return resolveContainmentPlane(m, plane) ?? 'default';
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The pins a plane opens with: its saved `unfolded` containers, in the shape
|
|
12
|
+
* `compileView`'s viewport reads. One reader for every host (studio, published
|
|
13
|
+
* page, embed), so a saved arrangement reopens the same everywhere.
|
|
14
|
+
*/
|
|
15
|
+
export function openingPins(layout, m, plane) {
|
|
16
|
+
const ids = layout?.unfolded?.[layoutPlaneKey(m, plane)] ?? [];
|
|
17
|
+
return Object.fromEntries(ids.map((id) => [id, 'expanded']));
|
|
18
|
+
}
|
|
19
|
+
function setPos(layout, key, nodeId, pos) {
|
|
20
|
+
const plane = { ...(layout.planes[key] ?? {}) };
|
|
21
|
+
if (pos === undefined)
|
|
22
|
+
delete plane[nodeId];
|
|
23
|
+
else
|
|
24
|
+
plane[nodeId] = pos;
|
|
25
|
+
return { ...layout, planes: { ...layout.planes, [key]: plane } };
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Drop every node position and unfolded entry (across all planes) and every
|
|
29
|
+
* size entry whose id satisfies `drop` — the shared "layout hygiene" contract for commands that
|
|
30
|
+
* destroy nodes (delete-node, and delete-layer's cascade). Identity is preserved
|
|
31
|
+
* as aggressively as possible: an untouched plane bucket keeps its reference,
|
|
32
|
+
* and if nothing at all is dropped the input `layout` is returned unchanged, so
|
|
33
|
+
* layout state unrelated to this command stays referentially stable.
|
|
34
|
+
*/
|
|
35
|
+
function prunePositions(layout, drop) {
|
|
36
|
+
let changed = false;
|
|
37
|
+
let planes = layout.planes;
|
|
38
|
+
for (const [key, positions] of Object.entries(planes)) {
|
|
39
|
+
const kept = Object.fromEntries(Object.entries(positions).filter(([nid]) => !drop(nid)));
|
|
40
|
+
if (Object.keys(kept).length === Object.keys(positions).length)
|
|
41
|
+
continue; // bucket untouched → keep ref
|
|
42
|
+
if (!changed)
|
|
43
|
+
planes = { ...planes }; // first real change: start the structural copy
|
|
44
|
+
planes[key] = kept;
|
|
45
|
+
changed = true;
|
|
46
|
+
}
|
|
47
|
+
let sizes = layout.sizes;
|
|
48
|
+
if (sizes !== undefined) {
|
|
49
|
+
const kept = Object.fromEntries(Object.entries(sizes).filter(([nid]) => !drop(nid)));
|
|
50
|
+
if (Object.keys(kept).length !== Object.keys(sizes).length) {
|
|
51
|
+
sizes = kept;
|
|
52
|
+
changed = true;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
let next = layout;
|
|
56
|
+
for (const [key, ids] of Object.entries(layout.unfolded ?? {})) {
|
|
57
|
+
const kept = ids.filter((nid) => !drop(nid));
|
|
58
|
+
if (kept.length !== ids.length)
|
|
59
|
+
next = withUnfolded(next, key, kept);
|
|
60
|
+
}
|
|
61
|
+
if (!changed && next === layout)
|
|
62
|
+
return layout;
|
|
63
|
+
return { ...next, planes, ...(sizes !== undefined ? { sizes } : {}) };
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* `layout` with the plane's unfolded list replaced. Sorted and de-duplicated so
|
|
67
|
+
* the file does not churn with the order boxes were clicked in; an emptied list
|
|
68
|
+
* is dropped and an emptied map omitted entirely (the set-plane-layout hygiene).
|
|
69
|
+
* Exported because the studio's view-mode save builds the same overlay without
|
|
70
|
+
* a command.
|
|
71
|
+
*/
|
|
72
|
+
export function withUnfolded(layout, key, ids) {
|
|
73
|
+
const { unfolded: current = {}, ...rest } = layout;
|
|
74
|
+
const { [key]: _drop, ...others } = current;
|
|
75
|
+
const list = [...new Set(ids)].sort();
|
|
76
|
+
const next = list.length > 0 ? { ...others, [key]: list } : others;
|
|
77
|
+
return Object.keys(next).length > 0 ? { ...rest, unfolded: next } : rest;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* `layout` with viewer label placements merged into the plane's bucket (see
|
|
81
|
+
* LayoutOverlay.edgeLabels). Exported because the studio's view-mode save
|
|
82
|
+
* builds the overlay without a command.
|
|
83
|
+
*/
|
|
84
|
+
export function withEdgeLabelPlacements(layout, key, placements) {
|
|
85
|
+
if (Object.keys(placements).length === 0)
|
|
86
|
+
return layout;
|
|
87
|
+
const plane = { ...(layout.edgeLabels?.[key] ?? {}) };
|
|
88
|
+
for (const [relationId, labels] of Object.entries(placements))
|
|
89
|
+
plane[relationId] = { ...plane[relationId], ...labels };
|
|
90
|
+
return { ...layout, edgeLabels: { ...(layout.edgeLabels ?? {}), [key]: plane } };
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* `layout` with one plane's note bucket replaced, normalised: an entry at the
|
|
94
|
+
* automatic spot that is not open (`{ dx: 0, dy: 0 }`) says nothing and is
|
|
95
|
+
* dropped, then an emptied bucket and an emptied map are omitted — the
|
|
96
|
+
* set-plane-layout hygiene, so a bubble dragged back and closed leaves no
|
|
97
|
+
* trace in the file.
|
|
98
|
+
*/
|
|
99
|
+
function withNoteBucket(layout, key, bucket) {
|
|
100
|
+
const kept = {};
|
|
101
|
+
for (const [tk, p] of Object.entries(bucket)) {
|
|
102
|
+
if (p.dx === 0 && p.dy === 0 && p.open !== true)
|
|
103
|
+
continue;
|
|
104
|
+
kept[tk] = p;
|
|
105
|
+
}
|
|
106
|
+
const { notes: current = {}, ...rest } = layout;
|
|
107
|
+
const { [key]: _drop, ...others } = current;
|
|
108
|
+
const next = Object.keys(kept).length > 0 ? { ...others, [key]: kept } : others;
|
|
109
|
+
return Object.keys(next).length > 0 ? { ...rest, notes: next } : rest;
|
|
110
|
+
}
|
|
111
|
+
/** one entry rewritten through `f` (absent = automatic, closed) */
|
|
112
|
+
function withNote(layout, key, target, f) {
|
|
113
|
+
const tk = threatTargetKey(target);
|
|
114
|
+
const bucket = { ...(layout.notes?.[key] ?? {}) };
|
|
115
|
+
bucket[tk] = f(bucket[tk] ?? { dx: 0, dy: 0 });
|
|
116
|
+
return withNoteBucket(layout, key, bucket);
|
|
117
|
+
}
|
|
118
|
+
/** `open` set or removed on `p` — never `open: false`, see NotePlacement */
|
|
119
|
+
function withOpen(p, open) {
|
|
120
|
+
const { open: _drop, ...rest } = p;
|
|
121
|
+
return open ? { ...rest, open: true } : rest;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Mirror hygiene for `notes` after a command changed the model: a note exists
|
|
125
|
+
* only while its element has a threat, so an offset for an element that lost
|
|
126
|
+
* its last threat — or was deleted — is dead data. Identity is kept when
|
|
127
|
+
* nothing is dropped, like pruneEdgeLabels.
|
|
128
|
+
*/
|
|
129
|
+
function pruneNotes(layout, before, after) {
|
|
130
|
+
if (layout.notes === undefined || (before.nodes === after.nodes && before.relations === after.relations))
|
|
131
|
+
return layout;
|
|
132
|
+
const alive = new Set();
|
|
133
|
+
for (const n of after.nodes)
|
|
134
|
+
if ((n.threats?.length ?? 0) > 0)
|
|
135
|
+
alive.add(threatTargetKey({ node: n.id }));
|
|
136
|
+
for (const r of after.relations)
|
|
137
|
+
if ((r.threats?.length ?? 0) > 0)
|
|
138
|
+
alive.add(threatTargetKey({ relation: r.id }));
|
|
139
|
+
let changed = false;
|
|
140
|
+
const planes = {};
|
|
141
|
+
for (const [key, bucket] of Object.entries(layout.notes)) {
|
|
142
|
+
const kept = Object.fromEntries(Object.entries(bucket).filter(([tk]) => alive.has(tk)));
|
|
143
|
+
if (Object.keys(kept).length !== Object.keys(bucket).length)
|
|
144
|
+
changed = true;
|
|
145
|
+
if (Object.keys(kept).length > 0)
|
|
146
|
+
planes[key] = kept;
|
|
147
|
+
}
|
|
148
|
+
if (!changed)
|
|
149
|
+
return layout;
|
|
150
|
+
const { notes: _drop, ...rest } = layout;
|
|
151
|
+
return Object.keys(planes).length > 0 ? { ...rest, notes: planes } : rest;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Mirror hygiene for `edgeLabels` after a command changed the relations: drop
|
|
155
|
+
* the placement of a label that no longer exists (its relation or the label
|
|
156
|
+
* itself is gone), and of one whose position the command just set in the MODEL
|
|
157
|
+
* — a viewer's override must never shadow the document the author is editing,
|
|
158
|
+
* or dragging the label in edit mode would appear to do nothing.
|
|
159
|
+
*/
|
|
160
|
+
function pruneEdgeLabels(layout, before, after) {
|
|
161
|
+
if (layout.edgeLabels === undefined || before.relations === after.relations)
|
|
162
|
+
return layout;
|
|
163
|
+
const labelsOf = (m) => new Map(m.relations.map((r) => [r.id, new Map(relationLabels(r).map((l) => [l.id, l]))]));
|
|
164
|
+
const was = labelsOf(before);
|
|
165
|
+
const now = labelsOf(after);
|
|
166
|
+
let changed = false;
|
|
167
|
+
const planes = {};
|
|
168
|
+
for (const [key, plane] of Object.entries(layout.edgeLabels)) {
|
|
169
|
+
const keptPlane = {};
|
|
170
|
+
for (const [relationId, placements] of Object.entries(plane)) {
|
|
171
|
+
const kept = Object.fromEntries(Object.entries(placements).filter(([labelId]) => {
|
|
172
|
+
const label = now.get(relationId)?.get(labelId);
|
|
173
|
+
const old = was.get(relationId)?.get(labelId);
|
|
174
|
+
return label !== undefined && (old === undefined || (old.t === label.t && old.side === label.side));
|
|
175
|
+
}));
|
|
176
|
+
if (Object.keys(kept).length !== Object.keys(placements).length)
|
|
177
|
+
changed = true;
|
|
178
|
+
if (Object.keys(kept).length > 0)
|
|
179
|
+
keptPlane[relationId] = kept;
|
|
180
|
+
}
|
|
181
|
+
if (Object.keys(keptPlane).length > 0)
|
|
182
|
+
planes[key] = keptPlane;
|
|
183
|
+
}
|
|
184
|
+
if (!changed)
|
|
185
|
+
return layout;
|
|
186
|
+
const { edgeLabels: _drop, ...rest } = layout;
|
|
187
|
+
return Object.keys(planes).length > 0 ? { ...rest, edgeLabels: planes } : rest;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Drop every layout structure keyed by `plane` — its positions bucket, manual
|
|
191
|
+
* flag, layout settings, unfolded list, label placements and threat-bubble
|
|
192
|
+
* entries (the "mirror hygiene" for deleting a plane). An
|
|
193
|
+
* emptied `manual`/`settings` map is omitted entirely, mirroring
|
|
194
|
+
* set-plane-layout / set-layout-settings. Returns the input `layout` unchanged
|
|
195
|
+
* when `plane` had no layout state at all.
|
|
196
|
+
*/
|
|
197
|
+
function prunePlaneLayout(layout, plane) {
|
|
198
|
+
const hasState = plane in layout.planes ||
|
|
199
|
+
(layout.manual !== undefined && plane in layout.manual) ||
|
|
200
|
+
(layout.settings !== undefined && plane in layout.settings) ||
|
|
201
|
+
(layout.unfolded !== undefined && plane in layout.unfolded) ||
|
|
202
|
+
(layout.edgeLabels !== undefined && plane in layout.edgeLabels) ||
|
|
203
|
+
(layout.notes !== undefined && plane in layout.notes);
|
|
204
|
+
if (!hasState)
|
|
205
|
+
return layout;
|
|
206
|
+
const next = { ...withUnfolded(layout, plane, []), planes: { ...layout.planes } };
|
|
207
|
+
delete next.planes[plane];
|
|
208
|
+
if (layout.manual !== undefined) {
|
|
209
|
+
const { [plane]: _dropManual, ...manualRest } = layout.manual;
|
|
210
|
+
if (Object.keys(manualRest).length > 0)
|
|
211
|
+
next.manual = manualRest;
|
|
212
|
+
else
|
|
213
|
+
delete next.manual;
|
|
214
|
+
}
|
|
215
|
+
if (layout.settings !== undefined) {
|
|
216
|
+
const { [plane]: _dropSettings, ...settingsRest } = layout.settings;
|
|
217
|
+
if (Object.keys(settingsRest).length > 0)
|
|
218
|
+
next.settings = settingsRest;
|
|
219
|
+
else
|
|
220
|
+
delete next.settings;
|
|
221
|
+
}
|
|
222
|
+
if (layout.edgeLabels !== undefined) {
|
|
223
|
+
const { [plane]: _dropLabels, ...labelsRest } = layout.edgeLabels;
|
|
224
|
+
if (Object.keys(labelsRest).length > 0)
|
|
225
|
+
next.edgeLabels = labelsRest;
|
|
226
|
+
else
|
|
227
|
+
delete next.edgeLabels;
|
|
228
|
+
}
|
|
229
|
+
if (layout.notes !== undefined) {
|
|
230
|
+
const { [plane]: _dropNotes, ...notesRest } = layout.notes;
|
|
231
|
+
if (Object.keys(notesRest).length > 0)
|
|
232
|
+
next.notes = notesRest;
|
|
233
|
+
else
|
|
234
|
+
delete next.notes;
|
|
235
|
+
}
|
|
236
|
+
return next;
|
|
237
|
+
}
|
|
238
|
+
function applyModelLayout(state, command) {
|
|
239
|
+
const { model, layout } = state;
|
|
240
|
+
switch (command.type) {
|
|
241
|
+
case 'add-node': {
|
|
242
|
+
let next = addNode(model, command.node);
|
|
243
|
+
if (command.parent !== undefined) {
|
|
244
|
+
next = addContainment(next, command.parent.id, command.node.id, command.parent.plane);
|
|
245
|
+
}
|
|
246
|
+
return { model: next, layout };
|
|
247
|
+
}
|
|
248
|
+
case 'rename-node':
|
|
249
|
+
return { model: renameNode(model, command.id, command.name), layout };
|
|
250
|
+
case 'set-node-details':
|
|
251
|
+
return { model: setNodeDetails(model, command.id, command.details), layout };
|
|
252
|
+
case 'set-node-rich':
|
|
253
|
+
return { model: setNodeRich(model, command.id, command.runs), layout };
|
|
254
|
+
case 'set-table-columns':
|
|
255
|
+
return { model: setTableColumns(model, command.id, command.columns), layout };
|
|
256
|
+
case 'add-threat':
|
|
257
|
+
return { model: addThreat(model, command.target, command.threat), layout };
|
|
258
|
+
case 'update-threat':
|
|
259
|
+
return { model: updateThreat(model, command.target, command.id, command.patch), layout };
|
|
260
|
+
case 'remove-threat':
|
|
261
|
+
return { model: removeThreat(model, command.target, command.id), layout };
|
|
262
|
+
case 'set-node-plane-hidden':
|
|
263
|
+
return { model: setNodePlaneHidden(model, command.nodeId, command.plane, command.hidden), layout };
|
|
264
|
+
case 'set-diagram-style':
|
|
265
|
+
return { model: setDiagramStyle(model, command.style), layout };
|
|
266
|
+
case 'set-diagram-notation':
|
|
267
|
+
return { model: setDiagramNotation(model, command.notation), layout };
|
|
268
|
+
case 'set-diagram-legend':
|
|
269
|
+
return { model: setDiagramLegend(model, command.legend), layout };
|
|
270
|
+
case 'delete-node': {
|
|
271
|
+
// Cascade destroys the whole containment subtree (notation containers
|
|
272
|
+
// whose children cannot be re-homed), so the layout hygiene must cover
|
|
273
|
+
// every doomed id, not just the root.
|
|
274
|
+
const doomed = command.cascade === true ? subtreeOf(model, command.id) : new Set([command.id]);
|
|
275
|
+
return {
|
|
276
|
+
model: deleteNode(model, command.id, command.cascade === true),
|
|
277
|
+
layout: prunePositions(layout, (nid) => doomed.has(nid)),
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
case 'add-containment':
|
|
281
|
+
return { model: addContainment(model, command.parent, command.child, command.plane), layout };
|
|
282
|
+
case 'remove-containment':
|
|
283
|
+
return { model: removeContainment(model, command.parent, command.child, command.plane), layout };
|
|
284
|
+
case 'group-nodes': {
|
|
285
|
+
const grouped = groupNodes(model, command.node, command.memberIds, command.plane);
|
|
286
|
+
// The members were positioned as top-level nodes; once nested, those
|
|
287
|
+
// coordinates are reinterpreted parent-relative and collide. Drop them
|
|
288
|
+
// (and the new group's) so the view re-lays them out fresh under elk.
|
|
289
|
+
const key = layoutPlaneKey(grouped, command.plane);
|
|
290
|
+
const bucket = layout.planes[key];
|
|
291
|
+
if (bucket === undefined)
|
|
292
|
+
return { model: grouped, layout };
|
|
293
|
+
const positions = { ...bucket };
|
|
294
|
+
for (const id of [command.node.id, ...command.memberIds])
|
|
295
|
+
delete positions[id];
|
|
296
|
+
return { model: grouped, layout: { ...layout, planes: { ...layout.planes, [key]: positions } } };
|
|
297
|
+
}
|
|
298
|
+
case 'add-relation':
|
|
299
|
+
return { model: addRelation(model, command.from, command.to, command.opts).model, layout };
|
|
300
|
+
case 'update-relation':
|
|
301
|
+
return { model: updateRelation(model, command.id, command.patch), layout };
|
|
302
|
+
case 'delete-relation':
|
|
303
|
+
return { model: deleteRelation(model, command.id), layout };
|
|
304
|
+
case 'upsert-layer':
|
|
305
|
+
return { model: upsertLayer(model, command.layer), layout };
|
|
306
|
+
case 'delete-layer': {
|
|
307
|
+
// Destructive delete removes the layer's tagged nodes, so drop their
|
|
308
|
+
// layout too — the same node-pruning hygiene as delete-node, now shared.
|
|
309
|
+
const doomed = new Set(model.nodes.filter((n) => n.layer === command.id).map((n) => n.id));
|
|
310
|
+
return {
|
|
311
|
+
model: deleteLayer(model, command.id),
|
|
312
|
+
layout: prunePositions(layout, (nid) => doomed.has(nid)),
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
case 'merge-layers':
|
|
316
|
+
return { model: mergeLayers(model, command.sources, command.target), layout };
|
|
317
|
+
case 'upsert-plane':
|
|
318
|
+
return { model: upsertPlane(model, command.plane), layout };
|
|
319
|
+
case 'delete-plane':
|
|
320
|
+
// Drop the deleted plane's own positions bucket, manual flag, and layout
|
|
321
|
+
// settings — the same hygiene as deleting a node, now shared in one helper.
|
|
322
|
+
return {
|
|
323
|
+
model: deletePlane(model, command.id),
|
|
324
|
+
layout: prunePlaneLayout(layout, command.id),
|
|
325
|
+
};
|
|
326
|
+
case 'set-position':
|
|
327
|
+
return {
|
|
328
|
+
model,
|
|
329
|
+
layout: setPos(layout, layoutPlaneKey(model, command.plane), command.nodeId, {
|
|
330
|
+
x: command.x,
|
|
331
|
+
y: command.y,
|
|
332
|
+
}),
|
|
333
|
+
};
|
|
334
|
+
case 'set-size':
|
|
335
|
+
return {
|
|
336
|
+
model,
|
|
337
|
+
layout: { ...layout, sizes: { ...(layout.sizes ?? {}), [command.nodeId]: { w: command.w, h: command.h } } },
|
|
338
|
+
};
|
|
339
|
+
case 'clear-position':
|
|
340
|
+
return { model, layout: setPos(layout, layoutPlaneKey(model, command.plane), command.nodeId) };
|
|
341
|
+
case 'clear-positions': {
|
|
342
|
+
const key = layoutPlaneKey(model, command.plane);
|
|
343
|
+
return { model, layout: { ...layout, planes: { ...layout.planes, [key]: {} } } };
|
|
344
|
+
}
|
|
345
|
+
case 'set-positions': {
|
|
346
|
+
const key = layoutPlaneKey(model, command.plane);
|
|
347
|
+
return {
|
|
348
|
+
model,
|
|
349
|
+
layout: {
|
|
350
|
+
...layout,
|
|
351
|
+
planes: { ...layout.planes, [key]: { ...(layout.planes[key] ?? {}), ...command.positions } },
|
|
352
|
+
},
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
case 'set-plane-layout': {
|
|
356
|
+
const key = layoutPlaneKey(model, command.plane);
|
|
357
|
+
const manual = { ...(layout.manual ?? {}) };
|
|
358
|
+
if (command.manual)
|
|
359
|
+
manual[key] = true;
|
|
360
|
+
else
|
|
361
|
+
delete manual[key];
|
|
362
|
+
const { manual: _drop, ...rest } = layout;
|
|
363
|
+
return { model, layout: Object.keys(manual).length > 0 ? { ...rest, manual } : rest };
|
|
364
|
+
}
|
|
365
|
+
case 'set-unfolded':
|
|
366
|
+
return { model, layout: withUnfolded(layout, layoutPlaneKey(model, command.plane), command.ids) };
|
|
367
|
+
case 'set-layout-settings': {
|
|
368
|
+
// Merge the patch into this plane's settings; a field explicitly set to
|
|
369
|
+
// undefined clears it. An emptied bucket is dropped, and an emptied
|
|
370
|
+
// settings map is omitted entirely (mirrors set-plane-layout hygiene).
|
|
371
|
+
const key = layoutPlaneKey(model, command.plane);
|
|
372
|
+
const merged = { ...(layout.settings?.[key] ?? {}) };
|
|
373
|
+
for (const [k, v] of Object.entries(command.patch)) {
|
|
374
|
+
if (v === undefined)
|
|
375
|
+
delete merged[k];
|
|
376
|
+
else
|
|
377
|
+
merged[k] = v;
|
|
378
|
+
}
|
|
379
|
+
const settings = { ...(layout.settings ?? {}) };
|
|
380
|
+
if (Object.keys(merged).length === 0)
|
|
381
|
+
delete settings[key];
|
|
382
|
+
else
|
|
383
|
+
settings[key] = merged;
|
|
384
|
+
const { settings: _drop, ...rest } = layout;
|
|
385
|
+
return { model, layout: Object.keys(settings).length > 0 ? { ...rest, settings } : rest };
|
|
386
|
+
}
|
|
387
|
+
case 'set-note-offset':
|
|
388
|
+
// `null` = back to the automatic spot; whether the bubble is open is a
|
|
389
|
+
// separate fact and survives the move
|
|
390
|
+
return {
|
|
391
|
+
model,
|
|
392
|
+
layout: withNote(layout, layoutPlaneKey(model, command.plane), command.target, (p) => ({
|
|
393
|
+
...p,
|
|
394
|
+
...(command.offset ?? { dx: 0, dy: 0 }),
|
|
395
|
+
})),
|
|
396
|
+
};
|
|
397
|
+
case 'set-note-open':
|
|
398
|
+
return {
|
|
399
|
+
model,
|
|
400
|
+
layout: withNote(layout, layoutPlaneKey(model, command.plane), command.target, (p) => withOpen(p, command.open)),
|
|
401
|
+
};
|
|
402
|
+
case 'set-notes-open': {
|
|
403
|
+
// Model-wide: every element that carries a threat, whether or not this
|
|
404
|
+
// plane draws it. An entry for an undrawn element is harmless (nothing
|
|
405
|
+
// renders it) and far simpler than threading the compiled view into
|
|
406
|
+
// core; allNotesOpen counts the same set, so the chip cannot disagree.
|
|
407
|
+
const key = layoutPlaneKey(model, command.plane);
|
|
408
|
+
const bucket = { ...(layout.notes?.[key] ?? {}) };
|
|
409
|
+
const targets = [
|
|
410
|
+
...model.nodes.filter((n) => (n.threats?.length ?? 0) > 0).map((n) => ({ node: n.id })),
|
|
411
|
+
...model.relations.filter((r) => (r.threats?.length ?? 0) > 0).map((r) => ({ relation: r.id })),
|
|
412
|
+
];
|
|
413
|
+
for (const t of targets) {
|
|
414
|
+
const tk = threatTargetKey(t);
|
|
415
|
+
bucket[tk] = withOpen(bucket[tk] ?? { dx: 0, dy: 0 }, command.open);
|
|
416
|
+
}
|
|
417
|
+
return { model, layout: withNoteBucket(layout, key, bucket) };
|
|
418
|
+
}
|
|
419
|
+
default:
|
|
420
|
+
throw new CommandError(`Unknown command type '${command.type}'`);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
export function applyCommand(state, command) {
|
|
424
|
+
const { model, layout, drawings } = state;
|
|
425
|
+
switch (command.type) {
|
|
426
|
+
case 'add-stroke':
|
|
427
|
+
return { model, layout, drawings: addStroke(drawings, layoutPlaneKey(model, command.plane), command.stroke) };
|
|
428
|
+
case 'delete-stroke':
|
|
429
|
+
return { model, layout, drawings: deleteStroke(drawings, layoutPlaneKey(model, command.plane), command.id) };
|
|
430
|
+
case 'batch':
|
|
431
|
+
return applyCommandWithResult(state, command).state;
|
|
432
|
+
case 'delete-plane': {
|
|
433
|
+
// The drawings bucket is keyed by the plane id, like the layout bucket —
|
|
434
|
+
// same mirror hygiene, third file.
|
|
435
|
+
const next = applyModelLayout(state, command);
|
|
436
|
+
return { model: next.model, layout: next.layout, drawings: pruneDrawingsPlane(drawings, command.id) };
|
|
437
|
+
}
|
|
438
|
+
default: {
|
|
439
|
+
const next = applyModelLayout(state, command);
|
|
440
|
+
return {
|
|
441
|
+
model: next.model,
|
|
442
|
+
layout: pruneNotes(pruneEdgeLabels(next.layout, model, next.model), model, next.model),
|
|
443
|
+
drawings,
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Like {@link applyCommand}, but surfaces the generated id for `add-relation` so
|
|
450
|
+
* callers (e.g. a connect gesture) can select the new relation. All other
|
|
451
|
+
* commands delegate unchanged and carry no id.
|
|
452
|
+
*/
|
|
453
|
+
export function applyCommandWithResult(state, command) {
|
|
454
|
+
if (command.type === 'batch') {
|
|
455
|
+
// Atomic by construction: members apply to a running copy and a throw
|
|
456
|
+
// unwinds before the caller sees anything. The last add-relation's id is
|
|
457
|
+
// surfaced exactly as a lone add-relation's would be, so a panel that ends
|
|
458
|
+
// a batch with a connect can still select the edge.
|
|
459
|
+
let next = state;
|
|
460
|
+
let relationId;
|
|
461
|
+
for (const c of command.commands) {
|
|
462
|
+
const r = applyCommandWithResult(next, c);
|
|
463
|
+
next = r.state;
|
|
464
|
+
if (r.relationId !== undefined)
|
|
465
|
+
relationId = r.relationId;
|
|
466
|
+
}
|
|
467
|
+
return relationId !== undefined ? { state: next, relationId } : { state: next };
|
|
468
|
+
}
|
|
469
|
+
if (command.type === 'add-relation') {
|
|
470
|
+
const { model, id } = addRelation(state.model, command.from, command.to, command.opts);
|
|
471
|
+
return { state: { model, layout: state.layout, drawings: state.drawings }, relationId: id };
|
|
472
|
+
}
|
|
473
|
+
return { state: applyCommand(state, command) };
|
|
474
|
+
}
|