@istar-ts/actions 0.0.0-stage → 0.15.0-beta.1
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 +44 -0
- package/README.md +103 -2
- package/dist/index.cjs +1170 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +780 -0
- package/dist/index.d.ts +780 -0
- package/dist/index.js +1155 -0
- package/dist/index.js.map +1 -0
- package/package.json +49 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,1155 @@
|
|
|
1
|
+
import { DEFAULT_PALETTE, DIAGNOSTIC_SEVERITIES, FILE_METAMODEL_KEY, LINK_SELECTOR, MetamodelError, ModelOperationError, canLink, connectResultToDiagnostic, createEmptyModel, dependencyEnds, effectiveLinkKind, elementBehavesLike, inheritMetamodel, inheritSourceLayout, linkBehavesLike, metamodelOf, parsePistar, toPistarObject, validateModel, validateModelProperties, withFileMetamodel } from "@istar-ts/core";
|
|
2
|
+
//#region src/actions.ts
|
|
3
|
+
const OPERATION_KIND_LIST = [
|
|
4
|
+
"createNode",
|
|
5
|
+
"createEdge",
|
|
6
|
+
"deleteElement",
|
|
7
|
+
"changeBounds",
|
|
8
|
+
"changeContainer",
|
|
9
|
+
"reconnectEdge",
|
|
10
|
+
"applyLabelEdit",
|
|
11
|
+
"compound",
|
|
12
|
+
"istar.addLeg",
|
|
13
|
+
"istar.removeLeg",
|
|
14
|
+
"istar.setCollapsed",
|
|
15
|
+
"istar.updateDiagram",
|
|
16
|
+
"istar.updateProperties",
|
|
17
|
+
"istar.updateDisplay",
|
|
18
|
+
"istar.setLinkValue",
|
|
19
|
+
"istar.updateLinkDisplay",
|
|
20
|
+
"istar.changeLinkKind",
|
|
21
|
+
"istar.setFileMetamodel"
|
|
22
|
+
];
|
|
23
|
+
const REQUEST_KIND_LIST = [
|
|
24
|
+
"requestModel",
|
|
25
|
+
"requestTypeHints",
|
|
26
|
+
"requestCheckEdge",
|
|
27
|
+
"requestContextActions",
|
|
28
|
+
"requestMarkers"
|
|
29
|
+
];
|
|
30
|
+
/**
|
|
31
|
+
* The other actions the dispatcher accepts: `glspUndo`/`glspRedo`/`saveModel` (acted on), then
|
|
32
|
+
* the notifications it only re-emits to subscribers (`IstarNotificationAction`'s kinds).
|
|
33
|
+
*/
|
|
34
|
+
const NOTIFICATION_KIND_LIST = [
|
|
35
|
+
"glspUndo",
|
|
36
|
+
"glspRedo",
|
|
37
|
+
"saveModel",
|
|
38
|
+
"sourceModelChanged",
|
|
39
|
+
"setDirtyState",
|
|
40
|
+
"triggerNodeCreation",
|
|
41
|
+
"triggerEdgeCreation",
|
|
42
|
+
"deleteMarkers"
|
|
43
|
+
];
|
|
44
|
+
const OPERATION_KINDS = new Set(OPERATION_KIND_LIST);
|
|
45
|
+
const REQUEST_KINDS = new Set(REQUEST_KIND_LIST);
|
|
46
|
+
/**
|
|
47
|
+
* The action kinds `createActionDispatcher` accepts, grouped the way it treats them: `operations`
|
|
48
|
+
* edit the model as one undo step, `requests` get a response action, `notifications` (plus
|
|
49
|
+
* `glspUndo`/`glspRedo`/`saveModel`) are acted on or re-emitted as is. Derived from the same
|
|
50
|
+
* lists `isOperation`/`isRequestAction` use, so it can't drift from the dispatcher.
|
|
51
|
+
*/
|
|
52
|
+
const HANDLED_ACTION_KINDS = {
|
|
53
|
+
operations: OPERATION_KIND_LIST,
|
|
54
|
+
requests: REQUEST_KIND_LIST,
|
|
55
|
+
notifications: NOTIFICATION_KIND_LIST
|
|
56
|
+
};
|
|
57
|
+
/** True for the operations (actions that edit the model). */
|
|
58
|
+
function isOperation(action) {
|
|
59
|
+
return OPERATION_KINDS.has(action.kind);
|
|
60
|
+
}
|
|
61
|
+
/** True for the requests (actions answered with a response action). */
|
|
62
|
+
function isRequestAction(action) {
|
|
63
|
+
return REQUEST_KINDS.has(action.kind);
|
|
64
|
+
}
|
|
65
|
+
//#endregion
|
|
66
|
+
//#region src/hints.ts
|
|
67
|
+
/**
|
|
68
|
+
* What a client can know before it asks: type hints from the metamodel, and the palette as
|
|
69
|
+
* GLSP context actions. Both are derived from data (`Metamodel`, `PaletteDescription`), so they
|
|
70
|
+
* work for any root or extension.
|
|
71
|
+
*/
|
|
72
|
+
function isHub(definition) {
|
|
73
|
+
return definition.role === "hub";
|
|
74
|
+
}
|
|
75
|
+
/** Element kinds a rule selector matches, as `canLink` matches elements against it. */
|
|
76
|
+
function matching(metamodel, selector) {
|
|
77
|
+
const kinds = [];
|
|
78
|
+
for (const definition of metamodel.elements.values()) if (isHub(definition) ? definition.kind === selector : selector === "*" || selector === definition.category || elementBehavesLike(metamodel, definition.kind, selector)) kinds.push(definition.kind);
|
|
79
|
+
if (selector === LINK_SELECTOR) kinds.push(...metamodel.links.keys());
|
|
80
|
+
else if (metamodel.links.has(selector)) {
|
|
81
|
+
for (const kind of metamodel.links.keys()) if (linkBehavesLike(metamodel, kind, selector)) kinds.push(kind);
|
|
82
|
+
}
|
|
83
|
+
return kinds;
|
|
84
|
+
}
|
|
85
|
+
function union(metamodel, selectors) {
|
|
86
|
+
return [...new Set(selectors.flatMap((selector) => matching(metamodel, selector)))];
|
|
87
|
+
}
|
|
88
|
+
/** Kinds of a category, without hubs. */
|
|
89
|
+
function ofCategory(metamodel, category) {
|
|
90
|
+
return [...metamodel.elements.values()].filter((d) => !isHub(d) && (category === void 0 || d.category === category)).map((d) => d.kind);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* GLSP type hints of a metamodel. Shapes: every element kind (hubs can't be resized or
|
|
94
|
+
* reparented; actors contain the node kinds that can be inner elements). Edges: every link kind,
|
|
95
|
+
* its sources and targets from its rules, or by category for kinds with built-in rules (iStar
|
|
96
|
+
* 2.0's), plus an n-ary kind's hub (`createEdge` onto a hub adds a leg to that link, #108). Edge
|
|
97
|
+
* hints are `repositionable` (their ends can be reconnected) and `dynamic`: `canLink` checks more
|
|
98
|
+
* than kinds.
|
|
99
|
+
*/
|
|
100
|
+
function typeHintsFor(metamodel) {
|
|
101
|
+
const meta = metamodel;
|
|
102
|
+
const inner = [...meta.elements.values()].filter((d) => d.category === "node" && !isHub(d) && d.info?.canBeInnerElement !== false).map((d) => d.kind);
|
|
103
|
+
return {
|
|
104
|
+
shapeHints: [...meta.elements.values()].map((d) => {
|
|
105
|
+
const hub = isHub(d);
|
|
106
|
+
return {
|
|
107
|
+
elementTypeId: d.kind,
|
|
108
|
+
repositionable: true,
|
|
109
|
+
deletable: true,
|
|
110
|
+
resizable: !hub,
|
|
111
|
+
reparentable: !hub && d.category === "node" && d.info?.canBeInnerElement !== false,
|
|
112
|
+
...d.category === "actor" ? { containableElementTypeIds: inner } : {}
|
|
113
|
+
};
|
|
114
|
+
}),
|
|
115
|
+
edgeHints: [...meta.links.values()].map((d) => {
|
|
116
|
+
const rules = meta.links.get(effectiveLinkKind(meta, d.kind))?.rules;
|
|
117
|
+
const builtIn = d.category === "dependency" ? ofCategory(meta) : ofCategory(meta, d.category);
|
|
118
|
+
const targets = rules ? union(meta, rules.targets) : builtIn;
|
|
119
|
+
const hub = d.parts?.hub;
|
|
120
|
+
return {
|
|
121
|
+
elementTypeId: d.kind,
|
|
122
|
+
repositionable: true,
|
|
123
|
+
deletable: true,
|
|
124
|
+
routable: true,
|
|
125
|
+
sourceElementTypeIds: rules ? union(meta, rules.sources) : builtIn,
|
|
126
|
+
targetElementTypeIds: hub === void 0 ? targets : [...targets, hub],
|
|
127
|
+
dynamic: true
|
|
128
|
+
};
|
|
129
|
+
})
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
function elementEntries(kind, value) {
|
|
133
|
+
return (Array.isArray(value) ? value : [value]).map((entry, i) => labeled(Array.isArray(value) ? `${kind}#${i}` : kind, entry, {
|
|
134
|
+
kind: "triggerNodeCreation",
|
|
135
|
+
elementTypeId: kind,
|
|
136
|
+
...entry.properties ? { args: { customProperties: entry.properties } } : {}
|
|
137
|
+
}));
|
|
138
|
+
}
|
|
139
|
+
function linkEntries(kind, list) {
|
|
140
|
+
return list.map((entry, i) => {
|
|
141
|
+
const args = {
|
|
142
|
+
...entry.value === void 0 ? {} : { value: entry.value },
|
|
143
|
+
...entry.dependum === void 0 ? {} : { dependum: { kind: entry.dependum } }
|
|
144
|
+
};
|
|
145
|
+
return labeled(`${kind}#${i}`, entry, {
|
|
146
|
+
kind: "triggerEdgeCreation",
|
|
147
|
+
elementTypeId: kind,
|
|
148
|
+
...Object.keys(args).length > 0 ? { args } : {}
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
function order(action) {
|
|
153
|
+
return action.order ?? Number.POSITIVE_INFINITY;
|
|
154
|
+
}
|
|
155
|
+
function labeled(id, entry, action) {
|
|
156
|
+
return {
|
|
157
|
+
id,
|
|
158
|
+
label: entry.label,
|
|
159
|
+
actions: [action],
|
|
160
|
+
...entry.group === void 0 ? {} : { group: entry.group },
|
|
161
|
+
...entry.section === void 0 ? {} : { section: entry.section },
|
|
162
|
+
...entry.order === void 0 ? {} : { order: entry.order }
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* A palette as GLSP context actions: one `LabeledAction` per entry, triggering the client's
|
|
167
|
+
* creation tool with the entry's presets, sorted by `order`. Only the metamodel's kinds are
|
|
168
|
+
* offered, in the metamodel's order where orders tie; kinds the palette hides (`false`) or
|
|
169
|
+
* lacks are left out.
|
|
170
|
+
*/
|
|
171
|
+
function contextActionsFor(palette, metamodel) {
|
|
172
|
+
const meta = metamodel;
|
|
173
|
+
const actions = [];
|
|
174
|
+
for (const kind of meta.elements.keys()) {
|
|
175
|
+
const value = palette.elements[kind];
|
|
176
|
+
if (value) actions.push(...elementEntries(kind, value));
|
|
177
|
+
}
|
|
178
|
+
for (const kind of meta.links.keys()) {
|
|
179
|
+
const list = palette.links[kind];
|
|
180
|
+
if (list) actions.push(...linkEntries(kind, list));
|
|
181
|
+
}
|
|
182
|
+
actions.sort((a, b) => order(a) === order(b) ? 0 : order(a) < order(b) ? -1 : 1);
|
|
183
|
+
return {
|
|
184
|
+
actions,
|
|
185
|
+
groups: palette.groups
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
//#endregion
|
|
189
|
+
//#region src/markers.ts
|
|
190
|
+
/** A `GoalDiagnostic` as a GLSP marker: `kind` is its severity, `label` its message. */
|
|
191
|
+
function diagnosticToMarker(diagnostic) {
|
|
192
|
+
const { elementId, key, severity, message, source, code } = diagnostic;
|
|
193
|
+
return {
|
|
194
|
+
label: message,
|
|
195
|
+
description: source === void 0 ? message : `${message} (${source})`,
|
|
196
|
+
elementId,
|
|
197
|
+
kind: severity,
|
|
198
|
+
...key === void 0 ? {} : { key },
|
|
199
|
+
...source === void 0 ? {} : { source },
|
|
200
|
+
...code === void 0 ? {} : { code }
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* A GLSP marker as a `GoalDiagnostic`. A `kind` that is no `DiagnosticSeverity` reads as
|
|
205
|
+
* `info`; `range` and `data` are not carried by markers.
|
|
206
|
+
*/
|
|
207
|
+
function markerToDiagnostic(marker) {
|
|
208
|
+
const { elementId, kind, label, key, source, code } = marker;
|
|
209
|
+
return {
|
|
210
|
+
elementId,
|
|
211
|
+
severity: DIAGNOSTIC_SEVERITIES.includes(kind) ? kind : "info",
|
|
212
|
+
message: label,
|
|
213
|
+
...key === void 0 ? {} : { key },
|
|
214
|
+
...source === void 0 ? {} : { source },
|
|
215
|
+
...code === void 0 ? {} : { code }
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
//#endregion
|
|
219
|
+
//#region src/dispatcher.ts
|
|
220
|
+
/**
|
|
221
|
+
* The dispatcher: the server side of the protocol over a `ModelStore`. Operations run as one
|
|
222
|
+
* store transaction each and are rejected, model untouched, with `GoalDiagnostic`s; requests
|
|
223
|
+
* are answered from the model, its metamodel and the palette; store changes reach subscribers
|
|
224
|
+
* as `ModelSourceChangedAction`s, whoever made them.
|
|
225
|
+
*/
|
|
226
|
+
/** Thrown inside a transaction to roll it back and reject the action. */
|
|
227
|
+
var Rejection = class extends Error {
|
|
228
|
+
diagnostics;
|
|
229
|
+
constructor(diagnostics) {
|
|
230
|
+
super(diagnostics.map((d) => d.message).join("; "));
|
|
231
|
+
this.diagnostics = diagnostics;
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
function reject(message, elementId = "", code) {
|
|
235
|
+
throw new Rejection([{
|
|
236
|
+
elementId,
|
|
237
|
+
severity: "error",
|
|
238
|
+
message,
|
|
239
|
+
...code === void 0 ? {} : { code }
|
|
240
|
+
}]);
|
|
241
|
+
}
|
|
242
|
+
function rejectCheck(check, endpoints) {
|
|
243
|
+
throw new Rejection([connectResultToDiagnostic(check, endpoints)]);
|
|
244
|
+
}
|
|
245
|
+
function metaOf(model) {
|
|
246
|
+
return metamodelOf(model);
|
|
247
|
+
}
|
|
248
|
+
/** A model with `links` changed, as the core operations derive one. */
|
|
249
|
+
function withLinks(model, links) {
|
|
250
|
+
return inheritMetamodel(model, inheritSourceLayout(model, {
|
|
251
|
+
...model,
|
|
252
|
+
links
|
|
253
|
+
}));
|
|
254
|
+
}
|
|
255
|
+
function createdIds(before, after) {
|
|
256
|
+
const ids = [];
|
|
257
|
+
for (const id of after.elements.keys()) if (!before.elements.has(id)) ids.push(id);
|
|
258
|
+
for (const id of after.links.keys()) if (!before.links.has(id)) ids.push(id);
|
|
259
|
+
return ids;
|
|
260
|
+
}
|
|
261
|
+
function issueKey(d) {
|
|
262
|
+
return `${d.id}\u0000${d.key}\u0000${d.message}`;
|
|
263
|
+
}
|
|
264
|
+
function linkIssueDiagnostics(model) {
|
|
265
|
+
return validateModel(model).map((issue) => ({
|
|
266
|
+
elementId: issue.linkId,
|
|
267
|
+
severity: "error",
|
|
268
|
+
message: issue.message,
|
|
269
|
+
code: issue.code
|
|
270
|
+
}));
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* A dispatcher over `store`, in-process. Hand it to a transport to serve remote clients.
|
|
274
|
+
*/
|
|
275
|
+
function createActionDispatcher(store, options = {}) {
|
|
276
|
+
const s = store;
|
|
277
|
+
const guards = options.guards ?? [];
|
|
278
|
+
const postGuards = options.postGuards ?? [];
|
|
279
|
+
const palette = options.palette ?? DEFAULT_PALETTE;
|
|
280
|
+
const sourceModelName = options.sourceModelName ?? "";
|
|
281
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
282
|
+
let saved = s.getModel();
|
|
283
|
+
let dirty = false;
|
|
284
|
+
const emit = (action) => {
|
|
285
|
+
for (const listener of listeners) listener(action);
|
|
286
|
+
};
|
|
287
|
+
const updateDirty = (reason) => {
|
|
288
|
+
const next = s.getModel() !== saved;
|
|
289
|
+
if (next === dirty) return;
|
|
290
|
+
dirty = next;
|
|
291
|
+
emit({
|
|
292
|
+
kind: "setDirtyState",
|
|
293
|
+
isDirty: next,
|
|
294
|
+
reason
|
|
295
|
+
});
|
|
296
|
+
};
|
|
297
|
+
const REASONS = {
|
|
298
|
+
edit: "operation",
|
|
299
|
+
undo: "undo",
|
|
300
|
+
redo: "redo",
|
|
301
|
+
load: "external"
|
|
302
|
+
};
|
|
303
|
+
const unsubscribe = s.subscribe((event) => {
|
|
304
|
+
if (event.source === "load") saved = event.model;
|
|
305
|
+
emit({
|
|
306
|
+
kind: "sourceModelChanged",
|
|
307
|
+
sourceModelName,
|
|
308
|
+
source: event.source,
|
|
309
|
+
changes: event.changes,
|
|
310
|
+
...options.sendModel ? { newRoot: toPistarObject(event.model) } : {},
|
|
311
|
+
...options.sendModel ? {
|
|
312
|
+
canUndo: s.canUndo(),
|
|
313
|
+
canRedo: s.canRedo()
|
|
314
|
+
} : {}
|
|
315
|
+
});
|
|
316
|
+
updateDirty(REASONS[event.source]);
|
|
317
|
+
});
|
|
318
|
+
const requireElement = (id) => s.getModel().elements.get(id) ?? reject(`unknown element "${id}"`, id, "unknown-element");
|
|
319
|
+
const runGuards = (operation, warnings) => {
|
|
320
|
+
const errors = [];
|
|
321
|
+
for (const guard of guards) for (const d of guard(operation, s.getModel()) ?? []) (d.severity === "error" ? errors : warnings).push(d);
|
|
322
|
+
if (errors.length > 0) throw new Rejection(errors);
|
|
323
|
+
};
|
|
324
|
+
const runPostGuards = (operation, before, warnings) => {
|
|
325
|
+
const errors = [];
|
|
326
|
+
for (const guard of postGuards) for (const d of guard(operation, s.getModel(), before) ?? []) (d.severity === "error" ? errors : warnings).push(d);
|
|
327
|
+
if (errors.length > 0) throw new Rejection(errors);
|
|
328
|
+
};
|
|
329
|
+
const apply = (operation, warnings) => {
|
|
330
|
+
runGuards(operation, warnings);
|
|
331
|
+
const before = s.getModel();
|
|
332
|
+
doApply(operation, warnings);
|
|
333
|
+
runPostGuards(operation, before, warnings);
|
|
334
|
+
};
|
|
335
|
+
const doApply = (operation, warnings) => {
|
|
336
|
+
const model = s.getModel();
|
|
337
|
+
const meta = metaOf(model);
|
|
338
|
+
switch (operation.kind) {
|
|
339
|
+
case "createNode": {
|
|
340
|
+
const { elementTypeId: kind, containerId, args = {} } = operation;
|
|
341
|
+
const definition = meta.elements.get(kind) ?? reject(`unknown element kind "${kind}" in ${meta.name}`, "", "unknown-kind");
|
|
342
|
+
if (definition.role === "hub") reject(`a ${definition.label} is created with its link (createEdge)`, "", "hub-kind");
|
|
343
|
+
const container = containerId === void 0 ? void 0 : requireElement(containerId);
|
|
344
|
+
if (definition.category === "node") {
|
|
345
|
+
if (container && definition.info?.canBeInnerElement === false) reject(`a ${definition.label} cannot be inside an actor`, containerId, "container");
|
|
346
|
+
if (!container && definition.info?.canBeOnPaper === false) reject(`a ${definition.label} must be created inside an actor`, "", "container");
|
|
347
|
+
}
|
|
348
|
+
const location = operation.location ?? container ?? {
|
|
349
|
+
x: 0,
|
|
350
|
+
y: 0
|
|
351
|
+
};
|
|
352
|
+
s.addElement({
|
|
353
|
+
kind,
|
|
354
|
+
x: location.x,
|
|
355
|
+
y: location.y,
|
|
356
|
+
...containerId === void 0 ? {} : { parent: containerId },
|
|
357
|
+
...args.id === void 0 ? {} : { id: args.id },
|
|
358
|
+
...args.name === void 0 ? {} : { name: args.name },
|
|
359
|
+
...args.customProperties ? { customProperties: args.customProperties } : {}
|
|
360
|
+
});
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
case "createEdge": {
|
|
364
|
+
const { elementTypeId: kind, sourceElementId: source, targetElementId: target } = operation;
|
|
365
|
+
const args = operation.args ?? {};
|
|
366
|
+
const definition = meta.links.get(kind) ?? reject(`unknown link kind "${kind}" in ${meta.name}`, "", "unknown-kind");
|
|
367
|
+
if (definition.category === "dependency") {
|
|
368
|
+
const dependum = args.dependum ?? {};
|
|
369
|
+
const dependumKind = dependum.kind ?? [...meta.elements.values()].find((d) => d.category === "node" && d.role !== "hub" && d.info?.canBeDependum !== false)?.kind ?? reject(`${meta.name} has no kind that can be a dependum`, "", "unknown-kind");
|
|
370
|
+
const result = s.addDependency({
|
|
371
|
+
depender: source,
|
|
372
|
+
dependee: target,
|
|
373
|
+
linkKind: kind,
|
|
374
|
+
dependum: {
|
|
375
|
+
kind: dependumKind,
|
|
376
|
+
...dependum.id === void 0 ? {} : { id: dependum.id },
|
|
377
|
+
...dependum.name === void 0 ? {} : { name: dependum.name },
|
|
378
|
+
...dependum.location ? {
|
|
379
|
+
x: dependum.location.x,
|
|
380
|
+
y: dependum.location.y
|
|
381
|
+
} : {},
|
|
382
|
+
...dependum.customProperties ? { customProperties: dependum.customProperties } : {}
|
|
383
|
+
},
|
|
384
|
+
...args.linkIds ? { linkIds: args.linkIds } : {}
|
|
385
|
+
});
|
|
386
|
+
if (!result.ok) rejectCheck(result, {
|
|
387
|
+
source,
|
|
388
|
+
target
|
|
389
|
+
});
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
const values = definition.info.possibleLabels;
|
|
393
|
+
if (args.value !== void 0 && values && !values.includes(args.value)) reject(`"${args.value}" is not a value of a ${definition.label} link (${values.join(", ")})`, source, "invalid-value");
|
|
394
|
+
const result = s.connect({
|
|
395
|
+
kind,
|
|
396
|
+
source,
|
|
397
|
+
target,
|
|
398
|
+
...args.id === void 0 ? {} : { id: args.id },
|
|
399
|
+
...args.value === void 0 ? {} : { label: args.value },
|
|
400
|
+
...args.name === void 0 ? {} : { name: args.name },
|
|
401
|
+
...args.customProperties ? { customProperties: args.customProperties } : {},
|
|
402
|
+
...args.legId === void 0 ? {} : { legId: args.legId },
|
|
403
|
+
...args.endId === void 0 ? {} : { endId: args.endId }
|
|
404
|
+
});
|
|
405
|
+
if (!result.ok) rejectCheck(result, {
|
|
406
|
+
source,
|
|
407
|
+
target
|
|
408
|
+
});
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
case "deleteElement": {
|
|
412
|
+
const elements = [];
|
|
413
|
+
const links = [];
|
|
414
|
+
for (const id of operation.elementIds) if (model.elements.has(id)) elements.push(id);
|
|
415
|
+
else if (model.links.has(id)) links.push(id);
|
|
416
|
+
else reject(`unknown element or link "${id}"`, id, "unknown-element");
|
|
417
|
+
if (elements.length > 0) s.removeElements(elements);
|
|
418
|
+
for (const id of links) if (s.getModel().links.has(id)) s.disconnect(id);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
case "changeBounds":
|
|
422
|
+
for (const { elementId, newPosition, newSize } of operation.newBounds) {
|
|
423
|
+
requireElement(elementId);
|
|
424
|
+
if (newPosition) s.moveElement(elementId, newPosition.x, newPosition.y);
|
|
425
|
+
if (newSize) s.updateElement(elementId, { display: {
|
|
426
|
+
width: newSize.width,
|
|
427
|
+
height: newSize.height
|
|
428
|
+
} });
|
|
429
|
+
}
|
|
430
|
+
return;
|
|
431
|
+
case "changeContainer": {
|
|
432
|
+
const { elementId, targetContainerId, location } = operation;
|
|
433
|
+
s.nestElement(elementId, targetContainerId);
|
|
434
|
+
if (location) s.moveElement(elementId, location.x, location.y);
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
case "reconnectEdge":
|
|
438
|
+
reconnect(operation.edgeElementId, operation.sourceElementId, operation.targetElementId);
|
|
439
|
+
return;
|
|
440
|
+
case "applyLabelEdit": {
|
|
441
|
+
const hash = operation.labelId.indexOf("#");
|
|
442
|
+
const id = hash < 0 ? operation.labelId : operation.labelId.slice(0, hash);
|
|
443
|
+
const key = hash < 0 ? void 0 : operation.labelId.slice(hash + 1);
|
|
444
|
+
if (key === void 0) {
|
|
445
|
+
if (model.elements.has(id)) s.updateElement(id, { name: operation.text });
|
|
446
|
+
else if (model.links.has(id)) s.updateLink(id, { name: operation.text === "" ? null : operation.text });
|
|
447
|
+
else reject(`unknown element or link "${id}"`, id, "unknown-element");
|
|
448
|
+
} else setProperties(id, { [key]: operation.text });
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
case "compound":
|
|
452
|
+
for (const child of operation.operationList) apply(child, warnings);
|
|
453
|
+
return;
|
|
454
|
+
case "istar.addLeg": {
|
|
455
|
+
const { hubId, sourceElementId: source } = operation;
|
|
456
|
+
const result = s.addLeg(hubId, source, operation.args?.id ? { id: operation.args.id } : {});
|
|
457
|
+
if (!result.ok) rejectCheck(result, {
|
|
458
|
+
source,
|
|
459
|
+
target: hubId
|
|
460
|
+
});
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
case "istar.removeLeg":
|
|
464
|
+
s.removeLeg(operation.legId);
|
|
465
|
+
return;
|
|
466
|
+
case "istar.setCollapsed": {
|
|
467
|
+
const element = requireElement(operation.elementId);
|
|
468
|
+
if (meta.elements.get(element.kind)?.category !== "actor") reject(`"${element.id}" is not an actor`, element.id, "not-an-actor");
|
|
469
|
+
s.setCollapsed(element.id, operation.collapsed);
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
case "istar.updateDiagram": {
|
|
473
|
+
const { name, width, height, customProperties } = operation.diagram;
|
|
474
|
+
s.updateDiagram({
|
|
475
|
+
...name === void 0 ? {} : { name },
|
|
476
|
+
...width === void 0 ? {} : { width },
|
|
477
|
+
...height === void 0 ? {} : { height },
|
|
478
|
+
...customProperties === void 0 ? {} : { customProperties }
|
|
479
|
+
});
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
case "istar.updateProperties":
|
|
483
|
+
setProperties(operation.elementId, operation.properties);
|
|
484
|
+
return;
|
|
485
|
+
case "istar.updateDisplay":
|
|
486
|
+
requireElement(operation.elementId);
|
|
487
|
+
s.updateElement(operation.elementId, { display: operation.patch });
|
|
488
|
+
return;
|
|
489
|
+
case "istar.updateLinkDisplay":
|
|
490
|
+
if (!model.links.has(operation.linkId)) reject(`unknown link "${operation.linkId}"`, operation.linkId, "unknown-element");
|
|
491
|
+
s.updateLink(operation.linkId, { display: operation.patch });
|
|
492
|
+
return;
|
|
493
|
+
case "istar.setLinkValue": {
|
|
494
|
+
const { linkId, value } = operation;
|
|
495
|
+
const link = model.links.get(linkId) ?? reject(`unknown link "${linkId}"`, linkId, "unknown-element");
|
|
496
|
+
const definition = meta.links.get(link.kind);
|
|
497
|
+
const values = definition?.info.possibleLabels;
|
|
498
|
+
if (value !== null && values && !values.includes(value)) reject(`"${value}" is not a value of a ${definition.label} link (${values.join(", ")})`, linkId, "invalid-value");
|
|
499
|
+
s.updateLink(linkId, { label: value });
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
case "istar.changeLinkKind": {
|
|
503
|
+
const { linkIds, elementTypeId: kind } = operation;
|
|
504
|
+
if (linkIds.length === 0) return;
|
|
505
|
+
const ids = new Set(linkIds);
|
|
506
|
+
const links = [...ids].map((id) => model.links.get(id) ?? reject(`unknown link "${id}"`, id, "unknown-element"));
|
|
507
|
+
const definition = meta.links.get(kind) ?? reject(`unknown link kind "${kind}" in ${meta.name}`, "", "unknown-kind");
|
|
508
|
+
const toChange = links.filter((link) => link.kind !== kind);
|
|
509
|
+
if (toChange.length === 0) return;
|
|
510
|
+
const remaining = new Map(model.links);
|
|
511
|
+
for (const id of ids) remaining.delete(id);
|
|
512
|
+
const without = withLinks(model, remaining);
|
|
513
|
+
const values = definition.info.possibleLabels;
|
|
514
|
+
for (const link of toChange) {
|
|
515
|
+
const check = canLink(without, link.source, link.target, kind);
|
|
516
|
+
if (!check.ok) rejectCheck(check, {
|
|
517
|
+
source: link.source,
|
|
518
|
+
target: link.target
|
|
519
|
+
});
|
|
520
|
+
if (link.label != null && values && !values.includes(link.label)) reject(`"${link.label}" is not a value of a ${definition.label} link (${values.join(", ")})`, link.id, "invalid-value");
|
|
521
|
+
}
|
|
522
|
+
const next = new Map(model.links);
|
|
523
|
+
for (const link of toChange) next.set(link.id, {
|
|
524
|
+
...link,
|
|
525
|
+
kind
|
|
526
|
+
});
|
|
527
|
+
s.replace(withLinks(model, next));
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
case "istar.setFileMetamodel": {
|
|
531
|
+
let next;
|
|
532
|
+
try {
|
|
533
|
+
next = withFileMetamodel(model, operation.block);
|
|
534
|
+
} catch (error) {
|
|
535
|
+
if (error instanceof MetamodelError) reject(error.message, "", "invalid-metamodel");
|
|
536
|
+
throw error;
|
|
537
|
+
}
|
|
538
|
+
s.replace(next);
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
default: reject(`unknown operation "${operation.kind}"`, "", "unknown-action");
|
|
542
|
+
}
|
|
543
|
+
};
|
|
544
|
+
/** Sets (or, with `null`, removes) custom properties of an element or link. */
|
|
545
|
+
const setProperties = (id, properties) => {
|
|
546
|
+
const model = s.getModel();
|
|
547
|
+
const owner = model.elements.get(id) ?? model.links.get(id);
|
|
548
|
+
if (!owner) reject(`unknown element or link "${id}"`, id, "unknown-element");
|
|
549
|
+
const next = { ...owner.customProperties };
|
|
550
|
+
for (const [key, value] of Object.entries(properties)) if (value === null) delete next[key];
|
|
551
|
+
else if (typeof value === "string") next[key] = value;
|
|
552
|
+
else reject(`the value of "${key}" must be a string or null`, id, "invalid-value");
|
|
553
|
+
const customProperties = Object.keys(next).length > 0 ? next : null;
|
|
554
|
+
if (model.elements.has(id)) s.updateElement(id, { customProperties });
|
|
555
|
+
else s.updateLink(id, { customProperties });
|
|
556
|
+
};
|
|
557
|
+
/**
|
|
558
|
+
* Gives a link new ends, checked with `canLink` against the model without it. The link keeps
|
|
559
|
+
* its place in the model (and the file), its id and everything else it carries.
|
|
560
|
+
*/
|
|
561
|
+
const reconnect = (id, source, target) => {
|
|
562
|
+
const model = s.getModel();
|
|
563
|
+
const meta = metaOf(model);
|
|
564
|
+
const link = model.links.get(id) ?? reject(`unknown link "${id}"`, id, "unknown-element");
|
|
565
|
+
if (link.source === source && link.target === target) return;
|
|
566
|
+
const definition = meta.links.get(link.kind);
|
|
567
|
+
const others = new Map(model.links);
|
|
568
|
+
others.delete(id);
|
|
569
|
+
const without = withLinks(model, others);
|
|
570
|
+
let check;
|
|
571
|
+
if (definition?.category === "dependency") {
|
|
572
|
+
const dependumAtTarget = model.elements.get(link.target)?.isDependum === true;
|
|
573
|
+
const dependumId = dependumAtTarget ? link.target : link.source;
|
|
574
|
+
if ((dependumAtTarget ? target : source) !== dependumId) reject("a dependency link keeps its dependum", id, "dependum-endpoint");
|
|
575
|
+
const ends = dependencyEnds(model, dependumId);
|
|
576
|
+
const depender = dependumAtTarget ? source : ends.depender;
|
|
577
|
+
const dependee = dependumAtTarget ? ends.dependee : target;
|
|
578
|
+
if (depender === void 0 || dependee === void 0) reject("the dependency is incomplete", id, "dependum-endpoint");
|
|
579
|
+
check = canLink(without, depender, dependee, link.kind);
|
|
580
|
+
if (!check.ok) rejectCheck(check, {
|
|
581
|
+
source: depender,
|
|
582
|
+
target: dependee
|
|
583
|
+
});
|
|
584
|
+
} else {
|
|
585
|
+
if (definition?.role === "end" && source !== link.source) reject("the end of an n-ary link stays on its hub", id, "hub-target");
|
|
586
|
+
if (definition?.role === "leg" && target !== link.target) {
|
|
587
|
+
if ([...model.links.values()].filter((l) => l.target === link.target && l.kind === link.kind).length === 1) reject("the last leg of an n-ary link cannot leave its hub", id, "hub-target");
|
|
588
|
+
}
|
|
589
|
+
check = canLink(without, source, target, link.kind);
|
|
590
|
+
if (!check.ok) rejectCheck(check, {
|
|
591
|
+
source,
|
|
592
|
+
target
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
s.replace(withLinks(model, new Map(model.links).set(id, {
|
|
596
|
+
...link,
|
|
597
|
+
source,
|
|
598
|
+
target
|
|
599
|
+
})));
|
|
600
|
+
};
|
|
601
|
+
const runOperation = (operation) => {
|
|
602
|
+
const before = s.getModel();
|
|
603
|
+
const warnings = [];
|
|
604
|
+
s.transaction(() => {
|
|
605
|
+
apply(operation, warnings);
|
|
606
|
+
const schemas = options.schemas;
|
|
607
|
+
if (!schemas || s.getModel() === before) return;
|
|
608
|
+
const known = new Set(validateModelProperties(before, schemas).map(issueKey));
|
|
609
|
+
const added = validateModelProperties(s.getModel(), schemas).filter((issue) => !known.has(issueKey(issue)));
|
|
610
|
+
if (added.length > 0) throw new Rejection(added.map((issue) => ({
|
|
611
|
+
elementId: issue.id,
|
|
612
|
+
key: issue.key,
|
|
613
|
+
severity: "error",
|
|
614
|
+
message: issue.message,
|
|
615
|
+
code: "invalid-property"
|
|
616
|
+
})));
|
|
617
|
+
});
|
|
618
|
+
return {
|
|
619
|
+
ok: true,
|
|
620
|
+
createdIds: createdIds(before, s.getModel()),
|
|
621
|
+
...warnings.length > 0 ? { diagnostics: warnings } : {}
|
|
622
|
+
};
|
|
623
|
+
};
|
|
624
|
+
const checkEdge = (action) => {
|
|
625
|
+
const { edgeType, sourceElementId: source, targetElementId: target } = action;
|
|
626
|
+
const model = s.getModel();
|
|
627
|
+
const meta = metaOf(model);
|
|
628
|
+
const answer = (check) => ({
|
|
629
|
+
kind: "checkEdgeTargetResult",
|
|
630
|
+
responseId: action.requestId,
|
|
631
|
+
isValid: check.ok,
|
|
632
|
+
edgeType,
|
|
633
|
+
sourceElementId: source,
|
|
634
|
+
...target === void 0 ? {} : { targetElementId: target },
|
|
635
|
+
...check.ok ? {} : {
|
|
636
|
+
reason: check.reason,
|
|
637
|
+
code: check.code
|
|
638
|
+
}
|
|
639
|
+
});
|
|
640
|
+
if (target !== void 0) {
|
|
641
|
+
const check = canLink(model, source, target, edgeType);
|
|
642
|
+
if (!check.ok && meta.links.get(edgeType)?.info.tryReversedWhenAdding) {
|
|
643
|
+
const reversed = canLink(model, target, source, edgeType);
|
|
644
|
+
if (reversed.ok) return answer(reversed);
|
|
645
|
+
}
|
|
646
|
+
return answer(check);
|
|
647
|
+
}
|
|
648
|
+
const element = model.elements.get(source);
|
|
649
|
+
if (!element) return answer({
|
|
650
|
+
ok: false,
|
|
651
|
+
code: "unknown-element",
|
|
652
|
+
reason: `unknown source element "${source}"`
|
|
653
|
+
});
|
|
654
|
+
const hint = typeHintsFor(meta).edgeHints.find((h) => h.elementTypeId === edgeType);
|
|
655
|
+
if (!hint) return answer({
|
|
656
|
+
ok: false,
|
|
657
|
+
code: "unknown-link-kind",
|
|
658
|
+
reason: `unknown link kind "${edgeType}"`
|
|
659
|
+
});
|
|
660
|
+
if (hint.sourceElementTypeIds && !hint.sourceElementTypeIds.includes(element.kind)) return answer({
|
|
661
|
+
ok: false,
|
|
662
|
+
code: "invalid-source",
|
|
663
|
+
reason: `a ${meta.elements.get(element.kind)?.label ?? element.kind} cannot be the source of a ${meta.links.get(edgeType)?.label ?? edgeType} link`
|
|
664
|
+
});
|
|
665
|
+
return answer({ ok: true });
|
|
666
|
+
};
|
|
667
|
+
const markers = (action) => {
|
|
668
|
+
const model = s.getModel();
|
|
669
|
+
const diagnostics = linkIssueDiagnostics(model);
|
|
670
|
+
if (options.schemas) for (const issue of validateModelProperties(model, options.schemas)) diagnostics.push({
|
|
671
|
+
elementId: issue.id,
|
|
672
|
+
key: issue.key,
|
|
673
|
+
severity: "error",
|
|
674
|
+
message: issue.message,
|
|
675
|
+
code: "invalid-property"
|
|
676
|
+
});
|
|
677
|
+
diagnostics.push(...options.diagnostics?.() ?? []);
|
|
678
|
+
const ids = action.elementsIDs.filter((id) => model.elements.has(id) || model.links.has(id));
|
|
679
|
+
const wanted = new Set(ids);
|
|
680
|
+
return {
|
|
681
|
+
kind: "setMarkers",
|
|
682
|
+
responseId: action.requestId,
|
|
683
|
+
markers: diagnostics.filter((d) => wanted.size === 0 || wanted.has(d.elementId)).map(diagnosticToMarker),
|
|
684
|
+
...action.reason === void 0 ? {} : { reason: action.reason }
|
|
685
|
+
};
|
|
686
|
+
};
|
|
687
|
+
const respond = (action) => {
|
|
688
|
+
const model = s.getModel();
|
|
689
|
+
switch (action.kind) {
|
|
690
|
+
case "requestModel": return {
|
|
691
|
+
kind: "setModel",
|
|
692
|
+
responseId: action.requestId,
|
|
693
|
+
newRoot: toPistarObject(model)
|
|
694
|
+
};
|
|
695
|
+
case "requestTypeHints": return {
|
|
696
|
+
kind: "setTypeHints",
|
|
697
|
+
responseId: action.requestId,
|
|
698
|
+
...typeHintsFor(metaOf(model))
|
|
699
|
+
};
|
|
700
|
+
case "requestCheckEdge": return checkEdge(action);
|
|
701
|
+
case "requestContextActions": return {
|
|
702
|
+
kind: "setContextActions",
|
|
703
|
+
responseId: action.requestId,
|
|
704
|
+
...contextActionsFor(palette, metaOf(model))
|
|
705
|
+
};
|
|
706
|
+
case "requestMarkers": return markers(action);
|
|
707
|
+
default: return;
|
|
708
|
+
}
|
|
709
|
+
};
|
|
710
|
+
const run = async (action) => {
|
|
711
|
+
if (isOperation(action)) return runOperation(action);
|
|
712
|
+
if (isRequestAction(action)) return {
|
|
713
|
+
ok: true,
|
|
714
|
+
createdIds: [],
|
|
715
|
+
response: respond(action)
|
|
716
|
+
};
|
|
717
|
+
switch (action.kind) {
|
|
718
|
+
case "glspUndo":
|
|
719
|
+
s.undo();
|
|
720
|
+
return {
|
|
721
|
+
ok: true,
|
|
722
|
+
createdIds: []
|
|
723
|
+
};
|
|
724
|
+
case "glspRedo":
|
|
725
|
+
s.redo();
|
|
726
|
+
return {
|
|
727
|
+
ok: true,
|
|
728
|
+
createdIds: []
|
|
729
|
+
};
|
|
730
|
+
case "saveModel": {
|
|
731
|
+
if (!options.save) reject("this host does not save models", "", "no-save-handler");
|
|
732
|
+
const model = s.getModel();
|
|
733
|
+
await options.save(toPistarObject(model, { saveDate: /* @__PURE__ */ new Date() }), action);
|
|
734
|
+
saved = model;
|
|
735
|
+
updateDirty("save");
|
|
736
|
+
return {
|
|
737
|
+
ok: true,
|
|
738
|
+
createdIds: []
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
case "sourceModelChanged":
|
|
742
|
+
case "setDirtyState":
|
|
743
|
+
case "triggerNodeCreation":
|
|
744
|
+
case "triggerEdgeCreation":
|
|
745
|
+
case "deleteMarkers":
|
|
746
|
+
emit(action);
|
|
747
|
+
return {
|
|
748
|
+
ok: true,
|
|
749
|
+
createdIds: []
|
|
750
|
+
};
|
|
751
|
+
default: return reject(`"${action.kind}" is not an action this dispatcher handles`, "", "unknown-action");
|
|
752
|
+
}
|
|
753
|
+
};
|
|
754
|
+
return {
|
|
755
|
+
async dispatch(action) {
|
|
756
|
+
try {
|
|
757
|
+
return await run(action);
|
|
758
|
+
} catch (error) {
|
|
759
|
+
if (error instanceof Rejection) return {
|
|
760
|
+
ok: false,
|
|
761
|
+
diagnostics: error.diagnostics
|
|
762
|
+
};
|
|
763
|
+
if (error instanceof ModelOperationError) return {
|
|
764
|
+
ok: false,
|
|
765
|
+
diagnostics: [error.toDiagnostic()]
|
|
766
|
+
};
|
|
767
|
+
throw error;
|
|
768
|
+
}
|
|
769
|
+
},
|
|
770
|
+
subscribe(listener) {
|
|
771
|
+
const l = listener;
|
|
772
|
+
listeners.add(l);
|
|
773
|
+
return () => listeners.delete(l);
|
|
774
|
+
},
|
|
775
|
+
isDirty: () => s.getModel() !== saved,
|
|
776
|
+
dispose: unsubscribe
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
//#endregion
|
|
780
|
+
//#region src/host.ts
|
|
781
|
+
/** `dispatcher`, refusing anything but requests: what a read-only host tool is handed. */
|
|
782
|
+
function readOnlyDispatcher(dispatcher, tool) {
|
|
783
|
+
return {
|
|
784
|
+
dispatch: (action) => isRequestAction(action) ? dispatcher.dispatch(action) : Promise.resolve({
|
|
785
|
+
ok: false,
|
|
786
|
+
diagnostics: [{
|
|
787
|
+
elementId: "",
|
|
788
|
+
severity: "error",
|
|
789
|
+
code: "read-only-tool",
|
|
790
|
+
message: `"${tool}" is read-only: it cannot dispatch ${action.kind}`
|
|
791
|
+
}]
|
|
792
|
+
}),
|
|
793
|
+
subscribe: (listener) => dispatcher.subscribe(listener)
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
/** The context a call of `tool` gets: `dispatcher` as is, or read-only for a read-only tool. */
|
|
797
|
+
function hostToolContext(tool, dispatcher, getModel, signal) {
|
|
798
|
+
return {
|
|
799
|
+
getModel,
|
|
800
|
+
dispatcher: tool.readOnly ? readOnlyDispatcher(dispatcher, tool.name) : dispatcher,
|
|
801
|
+
...signal ? { signal } : {}
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
/** A provider's text for `model`. */
|
|
805
|
+
function contextText(provider, model) {
|
|
806
|
+
const { text } = provider;
|
|
807
|
+
return Promise.resolve(typeof text === "string" ? text : text(model));
|
|
808
|
+
}
|
|
809
|
+
//#endregion
|
|
810
|
+
//#region src/mirror.ts
|
|
811
|
+
/**
|
|
812
|
+
* A mirror store: a read-only `ModelStore` in this process whose model follows a dispatcher that
|
|
813
|
+
* lives elsewhere (a worker, a server, the other end of a `createRemoteDispatcher` port), so a
|
|
814
|
+
* canvas and an inspector written against `ModelStore` can render it. Edits never touch the
|
|
815
|
+
* mirror: they are dispatched to the remote, and come back as the remote's `sourceModelChanged`.
|
|
816
|
+
*
|
|
817
|
+
* How the model travels: an initial `requestModel`, then one update per `sourceModelChanged`.
|
|
818
|
+
* A notification that carries the new model (`newRoot`, a dispatcher created with `sendModel`)
|
|
819
|
+
* is applied as it arrives; one without it is answered with a `requestModel`, in order. Either
|
|
820
|
+
* way the update is a whole piStar file, re-parsed (2 ms for the largest fixture, 237 ids), and
|
|
821
|
+
* then shared with the previous model: an element, link or diagram equal to the one it replaces
|
|
822
|
+
* keeps the old object, so whatever is memoised on identity only redoes what changed.
|
|
823
|
+
*/
|
|
824
|
+
/** Deep equality of JSON values (what parsed models are made of). */
|
|
825
|
+
function equal(a, b) {
|
|
826
|
+
if (a === b) return true;
|
|
827
|
+
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
|
|
828
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
829
|
+
const ka = Object.keys(a);
|
|
830
|
+
const kb = Object.keys(b);
|
|
831
|
+
if (ka.length !== kb.length) return false;
|
|
832
|
+
for (const key of ka) if (!equal(a[key], b[key])) return false;
|
|
833
|
+
return true;
|
|
834
|
+
}
|
|
835
|
+
/**
|
|
836
|
+
* `next`, with every element, link and the diagram that equals `previous`'s kept as
|
|
837
|
+
* `previous`'s object; `previous` itself when nothing differs (in content, order or metamodel).
|
|
838
|
+
*/
|
|
839
|
+
function share(previous, next) {
|
|
840
|
+
let same = previous.elements.size === next.elements.size && previous.links.size === next.links.size && metamodelOf(previous) === metamodelOf(next);
|
|
841
|
+
const shareMap = (from, to) => {
|
|
842
|
+
const order = from.keys();
|
|
843
|
+
const out = /* @__PURE__ */ new Map();
|
|
844
|
+
for (const [id, value] of to) {
|
|
845
|
+
const old = from.get(id);
|
|
846
|
+
const keep = old !== void 0 && equal(old, value);
|
|
847
|
+
if (!keep || order.next().value !== id) same = false;
|
|
848
|
+
out.set(id, keep ? old : value);
|
|
849
|
+
}
|
|
850
|
+
return out;
|
|
851
|
+
};
|
|
852
|
+
const elements = shareMap(previous.elements, next.elements);
|
|
853
|
+
const links = shareMap(previous.links, next.links);
|
|
854
|
+
const diagram = equal(previous.diagram, next.diagram) ? previous.diagram : next.diagram;
|
|
855
|
+
const { elements: _e, links: _l, diagram: _d, ...rest } = next;
|
|
856
|
+
const { elements: _pe, links: _pl, diagram: _pd, ...previousRest } = previous;
|
|
857
|
+
if (same && equal(rest, previousRest)) return previous;
|
|
858
|
+
const shared = {
|
|
859
|
+
...rest,
|
|
860
|
+
elements,
|
|
861
|
+
links,
|
|
862
|
+
...diagram === void 0 ? {} : { diagram }
|
|
863
|
+
};
|
|
864
|
+
return inheritMetamodel(next, inheritSourceLayout(next, shared));
|
|
865
|
+
}
|
|
866
|
+
function readOnly() {
|
|
867
|
+
throw new ModelOperationError("a mirror store is read-only: dispatch the edit through its remote dispatcher");
|
|
868
|
+
}
|
|
869
|
+
const parseWith = parsePistar;
|
|
870
|
+
/**
|
|
871
|
+
* A mirror of `remote`'s model (see `MirrorStore`). Shows an empty model until `ready`.
|
|
872
|
+
*
|
|
873
|
+
* Order: changes are shown in the order the remote sends them, whoever made them, and a
|
|
874
|
+
* `dispatch` through `mirror.dispatcher` resolves after its change is shown, so code that
|
|
875
|
+
* selects a created id or rolls back a rejected one sees the model it expects. A rejected
|
|
876
|
+
* action sends no notification: the mirror stays as it was.
|
|
877
|
+
*/
|
|
878
|
+
function createMirrorStore(remote, options = {}) {
|
|
879
|
+
const r = remote;
|
|
880
|
+
const metamodel = options.metamodel;
|
|
881
|
+
const limit = options.historyLimit ?? 100;
|
|
882
|
+
const onError = options.onError ?? (() => {});
|
|
883
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
884
|
+
let model = metamodel ? createEmptyModel(void 0, { metamodel }) : createEmptyModel();
|
|
885
|
+
/** The last file's `"metamodel"` block, as JSON; `undefined` before the first file. */
|
|
886
|
+
let block;
|
|
887
|
+
let undoDepth = 0;
|
|
888
|
+
let redoDepth = 0;
|
|
889
|
+
/** How many pushed models (`newRoot`) were applied: a response asked for before one is stale. */
|
|
890
|
+
let pushed = 0;
|
|
891
|
+
let requests = 0;
|
|
892
|
+
let tail = Promise.resolve();
|
|
893
|
+
let disposed = false;
|
|
894
|
+
const parse = (file) => {
|
|
895
|
+
const nextBlock = JSON.stringify(file[FILE_METAMODEL_KEY] ?? null);
|
|
896
|
+
if (nextBlock === block) return inheritMetamodel(model, parseWith(file, { metamodel: metamodelOf(model) }));
|
|
897
|
+
const parsed = parseWith(file, {
|
|
898
|
+
...metamodel ? { metamodel } : {},
|
|
899
|
+
...options.fileMetamodel ? { fileMetamodel: true } : {}
|
|
900
|
+
});
|
|
901
|
+
block = nextBlock;
|
|
902
|
+
return parsed;
|
|
903
|
+
};
|
|
904
|
+
const apply = (file, source, changes) => {
|
|
905
|
+
const previous = model;
|
|
906
|
+
const next = share(previous, parse(file));
|
|
907
|
+
if (next === previous) return;
|
|
908
|
+
model = next;
|
|
909
|
+
const event = {
|
|
910
|
+
model,
|
|
911
|
+
previous,
|
|
912
|
+
changes,
|
|
913
|
+
source
|
|
914
|
+
};
|
|
915
|
+
for (const listener of listeners) listener(event);
|
|
916
|
+
};
|
|
917
|
+
/** Counts the remote's undo stack from what it reports (see `MirrorStore`). */
|
|
918
|
+
const track = (source, exact) => {
|
|
919
|
+
if (exact) {
|
|
920
|
+
undoDepth = exact.canUndo ? 1 : 0;
|
|
921
|
+
redoDepth = exact.canRedo ? 1 : 0;
|
|
922
|
+
} else if (source === "edit") {
|
|
923
|
+
undoDepth = Math.min(undoDepth + 1, limit);
|
|
924
|
+
redoDepth = 0;
|
|
925
|
+
} else if (source === "undo") {
|
|
926
|
+
undoDepth = Math.max(undoDepth - 1, 0);
|
|
927
|
+
redoDepth++;
|
|
928
|
+
} else if (source === "redo") {
|
|
929
|
+
undoDepth = Math.min(undoDepth + 1, limit);
|
|
930
|
+
redoDepth = Math.max(redoDepth - 1, 0);
|
|
931
|
+
} else {
|
|
932
|
+
undoDepth = 0;
|
|
933
|
+
redoDepth = 0;
|
|
934
|
+
}
|
|
935
|
+
};
|
|
936
|
+
/**
|
|
937
|
+
* Asks for the model now (so the remote answers in order) and shows it once every earlier
|
|
938
|
+
* update is shown. Dropped if a pushed model arrived meanwhile: that one is at least as new.
|
|
939
|
+
*/
|
|
940
|
+
const refresh = (source, changes) => {
|
|
941
|
+
const seen = pushed;
|
|
942
|
+
const sent = r.dispatch({
|
|
943
|
+
kind: "requestModel",
|
|
944
|
+
requestId: `mirror-${++requests}`
|
|
945
|
+
}).then((result) => ({ result })).catch((error) => ({ error }));
|
|
946
|
+
const step = tail.then(async () => {
|
|
947
|
+
const answer = await sent;
|
|
948
|
+
if (disposed || pushed !== seen) return;
|
|
949
|
+
if ("error" in answer) throw answer.error;
|
|
950
|
+
const { result } = answer;
|
|
951
|
+
if (!result.ok) throw new Error(result.diagnostics[0]?.message ?? "requestModel was rejected");
|
|
952
|
+
if (!result.response) throw new Error("requestModel was answered without a model");
|
|
953
|
+
apply(result.response.newRoot, source, changes);
|
|
954
|
+
});
|
|
955
|
+
tail = step.catch(onError);
|
|
956
|
+
return step;
|
|
957
|
+
};
|
|
958
|
+
const unsubscribe = r.subscribe((action) => {
|
|
959
|
+
if (disposed || action.kind !== "sourceModelChanged") return;
|
|
960
|
+
track(action.source, action.canUndo !== void 0 && action.canRedo !== void 0 ? {
|
|
961
|
+
canUndo: action.canUndo,
|
|
962
|
+
canRedo: action.canRedo
|
|
963
|
+
} : void 0);
|
|
964
|
+
if (action.newRoot) {
|
|
965
|
+
pushed++;
|
|
966
|
+
try {
|
|
967
|
+
apply(action.newRoot, action.source, action.changes);
|
|
968
|
+
} catch (error) {
|
|
969
|
+
onError(error);
|
|
970
|
+
}
|
|
971
|
+
} else refresh(action.source, action.changes).catch(() => {});
|
|
972
|
+
});
|
|
973
|
+
const ready = refresh("load", []);
|
|
974
|
+
ready.catch(() => {});
|
|
975
|
+
const send = (kind) => {
|
|
976
|
+
r.dispatch({ kind }).catch(onError);
|
|
977
|
+
};
|
|
978
|
+
return {
|
|
979
|
+
ready,
|
|
980
|
+
dispatcher: {
|
|
981
|
+
async dispatch(action) {
|
|
982
|
+
const result = await r.dispatch(action);
|
|
983
|
+
await tail;
|
|
984
|
+
return result;
|
|
985
|
+
},
|
|
986
|
+
subscribe: (listener) => r.subscribe(listener)
|
|
987
|
+
},
|
|
988
|
+
synced: () => tail,
|
|
989
|
+
dispose() {
|
|
990
|
+
disposed = true;
|
|
991
|
+
unsubscribe();
|
|
992
|
+
},
|
|
993
|
+
getModel: () => model,
|
|
994
|
+
subscribe(listener) {
|
|
995
|
+
listeners.add(listener);
|
|
996
|
+
return () => listeners.delete(listener);
|
|
997
|
+
},
|
|
998
|
+
canLink: (source, target, kind) => canLink(model, source, target, kind),
|
|
999
|
+
addElement: readOnly,
|
|
1000
|
+
updateElement: readOnly,
|
|
1001
|
+
moveElement: readOnly,
|
|
1002
|
+
nestElement: readOnly,
|
|
1003
|
+
removeElement: readOnly,
|
|
1004
|
+
removeElements: readOnly,
|
|
1005
|
+
setCollapsed: readOnly,
|
|
1006
|
+
connect: readOnly,
|
|
1007
|
+
addDependency: readOnly,
|
|
1008
|
+
addLeg: readOnly,
|
|
1009
|
+
removeLeg: readOnly,
|
|
1010
|
+
updateLink: readOnly,
|
|
1011
|
+
disconnect: readOnly,
|
|
1012
|
+
updateDiagram: readOnly,
|
|
1013
|
+
replace: readOnly,
|
|
1014
|
+
load: readOnly,
|
|
1015
|
+
transaction: readOnly,
|
|
1016
|
+
clearHistory: readOnly,
|
|
1017
|
+
undo() {
|
|
1018
|
+
if (undoDepth === 0) return false;
|
|
1019
|
+
send("glspUndo");
|
|
1020
|
+
return true;
|
|
1021
|
+
},
|
|
1022
|
+
redo() {
|
|
1023
|
+
if (redoDepth === 0) return false;
|
|
1024
|
+
send("glspRedo");
|
|
1025
|
+
return true;
|
|
1026
|
+
},
|
|
1027
|
+
canUndo: () => undoDepth > 0,
|
|
1028
|
+
canRedo: () => redoDepth > 0
|
|
1029
|
+
};
|
|
1030
|
+
}
|
|
1031
|
+
//#endregion
|
|
1032
|
+
//#region src/transport.ts
|
|
1033
|
+
const JSONRPC_VERSION = "2.0";
|
|
1034
|
+
function isRecord(value) {
|
|
1035
|
+
return typeof value === "object" && value !== null;
|
|
1036
|
+
}
|
|
1037
|
+
function isJsonRpcId(value) {
|
|
1038
|
+
return typeof value === "string" || typeof value === "number";
|
|
1039
|
+
}
|
|
1040
|
+
function errorMessage(id, code, message) {
|
|
1041
|
+
return {
|
|
1042
|
+
jsonrpc: JSONRPC_VERSION,
|
|
1043
|
+
id,
|
|
1044
|
+
error: {
|
|
1045
|
+
code,
|
|
1046
|
+
message
|
|
1047
|
+
}
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
/**
|
|
1051
|
+
* Serves `dispatcher` on `port`: every well-formed request is answered with the
|
|
1052
|
+
* `ActionResult` as `result`; `dispatcher.subscribe` events are pushed as notifications.
|
|
1053
|
+
* A malformed request answers `-32600` (invalid request) or `-32601` (no usable `method`); an
|
|
1054
|
+
* exception `dispatch` itself doesn't catch answers `-32603` (internal error). Returns a
|
|
1055
|
+
* disposer that stops forwarding subscriber events and detaches the port's `onmessage`.
|
|
1056
|
+
*/
|
|
1057
|
+
function connectDispatcher(port, dispatcher) {
|
|
1058
|
+
const unsubscribe = dispatcher.subscribe((action) => {
|
|
1059
|
+
port.postMessage({
|
|
1060
|
+
jsonrpc: JSONRPC_VERSION,
|
|
1061
|
+
method: action.kind,
|
|
1062
|
+
params: action
|
|
1063
|
+
});
|
|
1064
|
+
});
|
|
1065
|
+
port.onmessage = (ev) => {
|
|
1066
|
+
const message = ev.data;
|
|
1067
|
+
if (!isRecord(message) || message.jsonrpc !== JSONRPC_VERSION) return;
|
|
1068
|
+
const { id, method, params } = message;
|
|
1069
|
+
const hasId = isJsonRpcId(id);
|
|
1070
|
+
if (typeof method !== "string" || method.length === 0) {
|
|
1071
|
+
if (hasId) port.postMessage(errorMessage(id, -32601, "method not found"));
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
if (!isRecord(params) || params.kind !== method) {
|
|
1075
|
+
if (hasId) port.postMessage(errorMessage(id, -32600, "invalid request"));
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
const action = params;
|
|
1079
|
+
if (!hasId) {
|
|
1080
|
+
dispatcher.dispatch(action);
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
dispatcher.dispatch(action).then((result) => {
|
|
1084
|
+
port.postMessage({
|
|
1085
|
+
jsonrpc: JSONRPC_VERSION,
|
|
1086
|
+
id,
|
|
1087
|
+
result
|
|
1088
|
+
});
|
|
1089
|
+
}, (error) => {
|
|
1090
|
+
const text = error instanceof Error ? error.message : "internal error";
|
|
1091
|
+
port.postMessage(errorMessage(id, -32603, text));
|
|
1092
|
+
});
|
|
1093
|
+
};
|
|
1094
|
+
return () => {
|
|
1095
|
+
unsubscribe();
|
|
1096
|
+
port.onmessage = null;
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
/**
|
|
1100
|
+
* The `ActionDispatcher` for the other end of a `connectDispatcher` port: `dispatch` sends a
|
|
1101
|
+
* request and resolves with the `ActionResult` it is answered with (a JSON-RPC `error` rejects
|
|
1102
|
+
* the promise instead — a transport fault, not an `ActionResult`); `subscribe` hears the
|
|
1103
|
+
* notifications the server pushes.
|
|
1104
|
+
*/
|
|
1105
|
+
function createRemoteDispatcher(port) {
|
|
1106
|
+
let nextId = 0;
|
|
1107
|
+
const pending = /* @__PURE__ */ new Map();
|
|
1108
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
1109
|
+
port.onmessage = (ev) => {
|
|
1110
|
+
const message = ev.data;
|
|
1111
|
+
if (!isRecord(message) || message.jsonrpc !== JSONRPC_VERSION) return;
|
|
1112
|
+
const { id, method, params, result, error } = message;
|
|
1113
|
+
if (isJsonRpcId(id)) {
|
|
1114
|
+
const waiting = pending.get(id);
|
|
1115
|
+
if (!waiting) return;
|
|
1116
|
+
pending.delete(id);
|
|
1117
|
+
if (isRecord(error)) waiting.reject(/* @__PURE__ */ new Error(`JSON-RPC error ${String(error.code)}: ${String(error.message)}`));
|
|
1118
|
+
else waiting.resolve(result);
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
if (typeof method === "string" && isRecord(params)) {
|
|
1122
|
+
const action = params;
|
|
1123
|
+
for (const listener of listeners) listener(action);
|
|
1124
|
+
}
|
|
1125
|
+
};
|
|
1126
|
+
return {
|
|
1127
|
+
dispatch(action) {
|
|
1128
|
+
const id = nextId++;
|
|
1129
|
+
return new Promise((resolve, reject) => {
|
|
1130
|
+
pending.set(id, {
|
|
1131
|
+
resolve,
|
|
1132
|
+
reject
|
|
1133
|
+
});
|
|
1134
|
+
port.postMessage({
|
|
1135
|
+
jsonrpc: JSONRPC_VERSION,
|
|
1136
|
+
id,
|
|
1137
|
+
method: action.kind,
|
|
1138
|
+
params: action
|
|
1139
|
+
});
|
|
1140
|
+
});
|
|
1141
|
+
},
|
|
1142
|
+
subscribe(listener) {
|
|
1143
|
+
listeners.add(listener);
|
|
1144
|
+
return () => listeners.delete(listener);
|
|
1145
|
+
}
|
|
1146
|
+
};
|
|
1147
|
+
}
|
|
1148
|
+
//#endregion
|
|
1149
|
+
//#region src/index.ts
|
|
1150
|
+
/** Version of the action protocol this package speaks. */
|
|
1151
|
+
const ACTIONS_PROTOCOL_VERSION = "0.0.0";
|
|
1152
|
+
//#endregion
|
|
1153
|
+
export { ACTIONS_PROTOCOL_VERSION, HANDLED_ACTION_KINDS, connectDispatcher, contextActionsFor, contextText, createActionDispatcher, createMirrorStore, createRemoteDispatcher, diagnosticToMarker, hostToolContext, isOperation, isRequestAction, markerToDiagnostic, readOnlyDispatcher, typeHintsFor };
|
|
1154
|
+
|
|
1155
|
+
//# sourceMappingURL=index.js.map
|