@meltemi/diagrams-editor 1.9.0 → 1.10.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/dist/add-icons.d.ts +44 -0
- package/dist/add-icons.d.ts.map +1 -0
- package/dist/add-icons.js +178 -0
- package/dist/add-icons.js.map +1 -0
- package/dist/arrange.d.ts.map +1 -1
- package/dist/arrange.js +0 -1
- package/dist/arrange.js.map +1 -1
- package/dist/asset-files.d.ts +46 -0
- package/dist/asset-files.d.ts.map +1 -0
- package/dist/asset-files.js +79 -0
- package/dist/asset-files.js.map +1 -0
- package/dist/c4.d.ts +91 -1
- package/dist/c4.d.ts.map +1 -1
- package/dist/c4.js +358 -9
- package/dist/c4.js.map +1 -1
- package/dist/canvas/keyboard.d.ts +3 -1
- package/dist/canvas/keyboard.d.ts.map +1 -1
- package/dist/canvas/keyboard.js +4 -0
- package/dist/canvas/keyboard.js.map +1 -1
- package/dist/canvas/palette.d.ts +7 -5
- package/dist/canvas/palette.d.ts.map +1 -1
- package/dist/canvas/palette.js +13 -9
- package/dist/canvas/palette.js.map +1 -1
- package/dist/canvas/protocol.d.ts +223 -7
- package/dist/canvas/protocol.d.ts.map +1 -1
- package/dist/canvas/protocol.js +58 -1
- package/dist/canvas/protocol.js.map +1 -1
- package/dist/canvas/routes.d.ts +17 -0
- package/dist/canvas/routes.d.ts.map +1 -1
- package/dist/canvas/routes.js +37 -0
- package/dist/canvas/routes.js.map +1 -1
- package/dist/canvas/turning.d.ts +7 -0
- package/dist/canvas/turning.d.ts.map +1 -1
- package/dist/canvas/turning.js +17 -3
- package/dist/canvas/turning.js.map +1 -1
- package/dist/clipboard.d.ts.map +1 -1
- package/dist/clipboard.js +61 -9
- package/dist/clipboard.js.map +1 -1
- package/dist/effects.d.ts +5 -0
- package/dist/effects.d.ts.map +1 -1
- package/dist/effects.js +28 -22
- package/dist/effects.js.map +1 -1
- package/dist/exports.d.ts +19 -11
- package/dist/exports.d.ts.map +1 -1
- package/dist/exports.js +54 -19
- package/dist/exports.js.map +1 -1
- package/dist/icons.d.ts +2 -0
- package/dist/icons.d.ts.map +1 -1
- package/dist/icons.js +39 -6
- package/dist/icons.js.map +1 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/inspector.d.ts.map +1 -1
- package/dist/inspector.js +229 -97
- package/dist/inspector.js.map +1 -1
- package/dist/messages/en.d.ts +55 -20
- package/dist/messages/en.d.ts.map +1 -1
- package/dist/messages/en.js +59 -26
- package/dist/messages/en.js.map +1 -1
- package/dist/messages/es.d.ts.map +1 -1
- package/dist/messages/es.js +47 -43
- package/dist/messages/es.js.map +1 -1
- package/dist/messages/index.d.ts +43 -17
- package/dist/messages/index.d.ts.map +1 -1
- package/dist/messages/keys.d.ts.map +1 -1
- package/dist/messages/keys.js +2 -0
- package/dist/messages/keys.js.map +1 -1
- package/dist/messages/services.d.ts +6 -0
- package/dist/messages/services.d.ts.map +1 -1
- package/dist/messages/services.js +8 -2
- package/dist/messages/services.js.map +1 -1
- package/dist/move-file.d.ts +32 -0
- package/dist/move-file.d.ts.map +1 -0
- package/dist/move-file.js +58 -0
- package/dist/move-file.js.map +1 -0
- package/dist/node.d.ts +1 -1
- package/dist/node.d.ts.map +1 -1
- package/dist/node.js.map +1 -1
- package/dist/projects.d.ts +65 -1
- package/dist/projects.d.ts.map +1 -1
- package/dist/projects.js +349 -1
- package/dist/projects.js.map +1 -1
- package/dist/services/actions.d.ts.map +1 -1
- package/dist/services/actions.js +11 -1
- package/dist/services/actions.js.map +1 -1
- package/dist/services/completion.d.ts.map +1 -1
- package/dist/services/completion.js +46 -6
- package/dist/services/completion.js.map +1 -1
- package/dist/services/hover.d.ts.map +1 -1
- package/dist/services/hover.js +3 -2
- package/dist/services/hover.js.map +1 -1
- package/dist/services/navigation.d.ts +1 -1
- package/dist/services/navigation.d.ts.map +1 -1
- package/dist/services/navigation.js +2 -1
- package/dist/services/navigation.js.map +1 -1
- package/dist/session.d.ts +56 -10
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +650 -38
- package/dist/session.js.map +1 -1
- package/dist/targets.d.ts +25 -4
- package/dist/targets.d.ts.map +1 -1
- package/dist/targets.js +103 -7
- package/dist/targets.js.map +1 -1
- package/dist/text-places.d.ts.map +1 -1
- package/dist/text-places.js +55 -1
- package/dist/text-places.js.map +1 -1
- package/dist/themes.d.ts.map +1 -1
- package/dist/themes.js +11 -1
- package/dist/themes.js.map +1 -1
- package/package.json +16 -16
package/dist/session.js
CHANGED
|
@@ -13,17 +13,24 @@ import { DEFAULT_GRID, snapValue, writtenCenter } from "./canvas/snapping.js";
|
|
|
13
13
|
import { copyFragment, pasteOperations } from "./clipboard.js";
|
|
14
14
|
import { iconGroups, takesIcon, writtenIcon } from "./icons.js";
|
|
15
15
|
import { fieldOperations, inspect, namedRelation } from "./inspector.js";
|
|
16
|
-
import { freshRelationId } from "./targets.js";
|
|
16
|
+
import { freshModelRelationId, freshRelationId, nameable } from "./targets.js";
|
|
17
17
|
// The editor's catalog; `messages` names the host messages in this file.
|
|
18
18
|
import { messages as wording } from "./messages/index.js";
|
|
19
19
|
import { baseName, themeLocation, themePanel, themeTokenOperation } from "./themes.js";
|
|
20
20
|
import { renderDiagram, renderLayout } from "./render.js";
|
|
21
21
|
import { selectionAt } from "./services/navigation.js";
|
|
22
22
|
import { eventInfos, sequenceAction, sequenceMove } from "./sequence.js";
|
|
23
|
-
import { entityChoices, modelIsLocal } from "./c4.js";
|
|
23
|
+
import { allowedKinds, copiedEntities, copiedNotes, entityChoices, entityParent, freshEntityPath, modelIsLocal, pasteEntities, } from "./c4.js";
|
|
24
|
+
import { addIcons, checkIconFiles, checkIconPrefix } from "./add-icons.js";
|
|
24
25
|
import { storyChoices, storyPlayback } from "./story.js";
|
|
25
26
|
const NONE = { messages: [] };
|
|
26
27
|
/** Default sizes of groups created from the palette, on the 14 px grid. */
|
|
28
|
+
/** A note of a C4 view: a declaration of the view, not an entity of the model. */
|
|
29
|
+
function isViewNote(element) {
|
|
30
|
+
return element.kind === "note" && element.entity === null;
|
|
31
|
+
}
|
|
32
|
+
/** What a new note measures before its text is typed: "Note" at the note size. */
|
|
33
|
+
const NEW_NOTE_SIZE = { width: 36, height: 22 };
|
|
27
34
|
const NEW_GROUP_SIZE = { width: 280, height: 168 };
|
|
28
35
|
/**
|
|
29
36
|
* Kinds created as empty shapes, `box box1 ""` or `node1[""]`, the text added later in place;
|
|
@@ -53,10 +60,15 @@ export class CanvasSession {
|
|
|
53
60
|
rendered = new Map();
|
|
54
61
|
pendingSelection;
|
|
55
62
|
pendingEdit;
|
|
63
|
+
/** A status line to show after the next render and the selection it brings. */
|
|
64
|
+
pendingStatus;
|
|
56
65
|
/** The diagram to show once it exists, after a conversion. */
|
|
57
66
|
pendingDiagram;
|
|
58
|
-
/**
|
|
59
|
-
|
|
67
|
+
/**
|
|
68
|
+
* True while the canvas shows the diagram's effects running: from the start, so a diagram with
|
|
69
|
+
* effects opens animated; the canvas pauses it from the choice it remembers per diagram.
|
|
70
|
+
*/
|
|
71
|
+
playing = true;
|
|
60
72
|
/** The scenario of a sequence diagram previewed read-only, with its diagram. */
|
|
61
73
|
scenario;
|
|
62
74
|
/** The story the canvas plays, read-only, in story mode. */
|
|
@@ -189,6 +201,12 @@ export class CanvasSession {
|
|
|
189
201
|
messages.push({ type: "select", ids: [this.pendingEdit] });
|
|
190
202
|
this.pendingEdit = undefined;
|
|
191
203
|
}
|
|
204
|
+
// After the selection, whose own announcement it would otherwise lose to.
|
|
205
|
+
if (this.pendingStatus !== undefined) {
|
|
206
|
+
if (render)
|
|
207
|
+
messages.push({ type: "status", message: this.pendingStatus });
|
|
208
|
+
this.pendingStatus = undefined;
|
|
209
|
+
}
|
|
192
210
|
const model = this.inspect(view.id);
|
|
193
211
|
if (model)
|
|
194
212
|
messages.push({ type: "inspector", model });
|
|
@@ -264,7 +282,10 @@ export class CanvasSession {
|
|
|
264
282
|
return NONE;
|
|
265
283
|
const operation = themeTokenOperation(location, token, value);
|
|
266
284
|
if (location.file !== null) {
|
|
267
|
-
return {
|
|
285
|
+
return {
|
|
286
|
+
messages: [],
|
|
287
|
+
project: { kind: "files", steps: [{ file: location.file, operation }] },
|
|
288
|
+
};
|
|
268
289
|
}
|
|
269
290
|
return this.planned(view, "setField", [operation]);
|
|
270
291
|
}
|
|
@@ -303,6 +324,13 @@ export class CanvasSession {
|
|
|
303
324
|
const labelBox = geometry.elementLabels.get(element.id);
|
|
304
325
|
const rotation = geometry.rotations.get(element.id);
|
|
305
326
|
const radius = geometry.radii.get(element.id);
|
|
327
|
+
// A free line: its written points (ends and bends) and the polyline it is drawn with.
|
|
328
|
+
const connector = element.kind === "line" ? geometry.connectors.get(element.id) : undefined;
|
|
329
|
+
const line = connector && {
|
|
330
|
+
points: [connector.points[0], ...connector.via, connector.points.at(-1)],
|
|
331
|
+
drawn: connector.line,
|
|
332
|
+
via: connector.via,
|
|
333
|
+
};
|
|
306
334
|
elements.push({
|
|
307
335
|
id: element.id,
|
|
308
336
|
kind: element.kind,
|
|
@@ -320,6 +348,7 @@ export class CanvasSession {
|
|
|
320
348
|
takesIcon: takesIcon(diagram, element),
|
|
321
349
|
icon: writtenIcon(diagram, element.id, "element"),
|
|
322
350
|
...(labelBox ? { labelBox } : {}),
|
|
351
|
+
...(line ? { line } : {}),
|
|
323
352
|
});
|
|
324
353
|
}
|
|
325
354
|
const relations = [];
|
|
@@ -367,6 +396,14 @@ export class CanvasSession {
|
|
|
367
396
|
diagram: view.id,
|
|
368
397
|
profile: diagram.type,
|
|
369
398
|
...(diagram.definition ? { tools: diagram.definition.tools } : {}),
|
|
399
|
+
...(diagram.c4
|
|
400
|
+
? {
|
|
401
|
+
tools: allowedKinds(diagram.c4.level).map((kind) => ({
|
|
402
|
+
kind,
|
|
403
|
+
label: wording().c4.tools[kind],
|
|
404
|
+
})),
|
|
405
|
+
}
|
|
406
|
+
: {}),
|
|
370
407
|
layers: diagram.properties.layers,
|
|
371
408
|
mode: view.mode,
|
|
372
409
|
svg: rendered.svg,
|
|
@@ -380,6 +417,7 @@ export class CanvasSession {
|
|
|
380
417
|
scenario: scenario ?? null,
|
|
381
418
|
effects,
|
|
382
419
|
playing,
|
|
420
|
+
...(this.inProject() ? { project: true } : {}),
|
|
383
421
|
entities: diagram.type === "c4" ? entityChoices(diagram) : [],
|
|
384
422
|
selection: this.selection,
|
|
385
423
|
...(view.layout?.theme
|
|
@@ -387,6 +425,7 @@ export class CanvasSession {
|
|
|
387
425
|
colors: {
|
|
388
426
|
selection: view.layout.theme.selection ?? view.layout.theme.accent,
|
|
389
427
|
guide: view.layout.theme.guide ?? view.layout.theme.accent,
|
|
428
|
+
accent: view.layout.theme.accent,
|
|
390
429
|
},
|
|
391
430
|
}
|
|
392
431
|
: {}),
|
|
@@ -448,6 +487,44 @@ export class CanvasSession {
|
|
|
448
487
|
return [];
|
|
449
488
|
return iconGroups(analysis, this.libraries(), query);
|
|
450
489
|
}
|
|
490
|
+
/**
|
|
491
|
+
* Adds icons to the project (`addIcons`): the files of the assets and the statements that give
|
|
492
|
+
* the first icon to the targets, as one request of steps over files the host applies in one
|
|
493
|
+
* undo step; outside a project every file is refused.
|
|
494
|
+
*/
|
|
495
|
+
addIcons(message) {
|
|
496
|
+
const words = wording().addIcons;
|
|
497
|
+
if (!this.inProject()) {
|
|
498
|
+
const reason = words.needsProject;
|
|
499
|
+
return {
|
|
500
|
+
messages: [
|
|
501
|
+
{
|
|
502
|
+
type: "iconsAdded",
|
|
503
|
+
token: message.token,
|
|
504
|
+
added: [],
|
|
505
|
+
refused: message.files.map((f) => ({ file: f.file, reason })),
|
|
506
|
+
},
|
|
507
|
+
{ type: "rejected", code: "edit.unsafe-target", reason },
|
|
508
|
+
],
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
const result = addIcons(this.analysis?.icons, this.view()?.diagram, message);
|
|
512
|
+
const answer = {
|
|
513
|
+
type: "iconsAdded",
|
|
514
|
+
token: message.token,
|
|
515
|
+
added: result.added,
|
|
516
|
+
refused: result.refused,
|
|
517
|
+
};
|
|
518
|
+
if (result.added.length === 0)
|
|
519
|
+
return { messages: [answer] };
|
|
520
|
+
return {
|
|
521
|
+
messages: [
|
|
522
|
+
answer,
|
|
523
|
+
{ type: "status", message: words.added(result.added.map((a) => a.reference)) },
|
|
524
|
+
],
|
|
525
|
+
project: { kind: "files", steps: result.steps, assets: result.assets },
|
|
526
|
+
};
|
|
527
|
+
}
|
|
451
528
|
/** Highlights the element or relation of the statement at a text offset. */
|
|
452
529
|
selectAt(offset) {
|
|
453
530
|
if (!this.analysis)
|
|
@@ -503,6 +580,19 @@ export class CanvasSession {
|
|
|
503
580
|
{ type: "icons", token: message.token, groups: this.searchIcons(message.query) },
|
|
504
581
|
],
|
|
505
582
|
};
|
|
583
|
+
case "checkIcons":
|
|
584
|
+
return {
|
|
585
|
+
messages: [
|
|
586
|
+
{
|
|
587
|
+
type: "iconsChecked",
|
|
588
|
+
token: message.token,
|
|
589
|
+
prefix: checkIconPrefix(analysis.icons, message.prefix),
|
|
590
|
+
files: checkIconFiles(analysis.icons, message.prefix, message.files),
|
|
591
|
+
},
|
|
592
|
+
],
|
|
593
|
+
};
|
|
594
|
+
case "addIcons":
|
|
595
|
+
return this.addIcons(message);
|
|
506
596
|
case "editTheme":
|
|
507
597
|
this.themeEditing = message.open;
|
|
508
598
|
this.awaitedTheme = undefined;
|
|
@@ -594,6 +684,10 @@ export class CanvasSession {
|
|
|
594
684
|
};
|
|
595
685
|
}
|
|
596
686
|
return this.act(message.action);
|
|
687
|
+
case "presencePointer":
|
|
688
|
+
case "presenceLaser":
|
|
689
|
+
// The host's to share; the document has nothing to do with it.
|
|
690
|
+
return NONE;
|
|
597
691
|
}
|
|
598
692
|
}
|
|
599
693
|
/** Pastes clipboard text at a point, or two grid steps off when no point is given. */
|
|
@@ -602,12 +696,32 @@ export class CanvasSession {
|
|
|
602
696
|
if (!view)
|
|
603
697
|
return NONE;
|
|
604
698
|
const where = at ? { at: this.snap(at) } : { shift: { x: 2 * this.grid, y: 2 * this.grid } };
|
|
699
|
+
if (view.diagram.type === "c4")
|
|
700
|
+
return this.pasteEntities(view, text, where);
|
|
605
701
|
const pasted = pasteOperations(this.analysis, view.id, text, where);
|
|
606
702
|
if (!pasted.ok)
|
|
607
703
|
return { messages: [{ type: "status", message: pasted.reason }] };
|
|
608
704
|
this.pendingSelection = [...pasted.ids];
|
|
609
705
|
return this.planned(view, "paste", pasted.operations);
|
|
610
706
|
}
|
|
707
|
+
/**
|
|
708
|
+
* Pastes entities copied from a C4 view into this one: copies declared in its model, included
|
|
709
|
+
* and placed; anything else on the clipboard says why it does not paste here.
|
|
710
|
+
*/
|
|
711
|
+
pasteEntities(view, text, where) {
|
|
712
|
+
const compilation = compile(text);
|
|
713
|
+
const copied = copiedEntities(compilation.document.models, compilation.document.diagrams);
|
|
714
|
+
const notes = copiedNotes(compilation.document.diagrams);
|
|
715
|
+
if (!copied || (copied.length === 0 && notes.length === 0)) {
|
|
716
|
+
return { messages: [{ type: "status", message: wording().reasons.notFragment }] };
|
|
717
|
+
}
|
|
718
|
+
const placed = view.mode !== "auto" || view.layout?.boxes !== undefined;
|
|
719
|
+
const pasted = pasteEntities(view.diagram, placed, copied, where, notes);
|
|
720
|
+
if (!pasted.ok)
|
|
721
|
+
return { messages: [{ type: "status", message: pasted.reason }] };
|
|
722
|
+
this.pendingSelection = [...pasted.ids];
|
|
723
|
+
return this.planned(view, "paste", pasted.operations);
|
|
724
|
+
}
|
|
611
725
|
/** The scenario the canvas previews, if any. */
|
|
612
726
|
get previewed() {
|
|
613
727
|
const view = this.view();
|
|
@@ -726,6 +840,17 @@ export class CanvasSession {
|
|
|
726
840
|
* One plan for the operations, after the arrangement of an `auto` diagram when needed; undefined
|
|
727
841
|
* without operations. It changes nothing in the session: previews plan with it too.
|
|
728
842
|
*/
|
|
843
|
+
/**
|
|
844
|
+
* After an edit names the relation `key` as `id` first, the selection holds `id` where it held
|
|
845
|
+
* the key, so the canvas and the inspector stay on the relation; a refusal drops it.
|
|
846
|
+
*/
|
|
847
|
+
followNaming(key, id, operations) {
|
|
848
|
+
if (operations.length === 0)
|
|
849
|
+
return;
|
|
850
|
+
this.pendingSelection = this.selection.includes(key)
|
|
851
|
+
? this.selection.map((s) => (s === key ? id : s))
|
|
852
|
+
: [id];
|
|
853
|
+
}
|
|
729
854
|
plan(view, kind, operations) {
|
|
730
855
|
if (operations.length === 0)
|
|
731
856
|
return undefined;
|
|
@@ -738,14 +863,22 @@ export class CanvasSession {
|
|
|
738
863
|
all.push(...operations);
|
|
739
864
|
return planEdit(this.analysis.compilation, all.length === 1 ? all[0] : { kind: "batch", operations: all }, { revision: this.analysis.revision });
|
|
740
865
|
}
|
|
741
|
-
/**
|
|
742
|
-
|
|
866
|
+
/**
|
|
867
|
+
* The plan of `plan` for the host to apply; a refusal drops what waited for the edit. When the
|
|
868
|
+
* operations write the model of a C4 view that another file holds, they go to the host as
|
|
869
|
+
* steps over files instead, with `retry` for the dependents dialog.
|
|
870
|
+
*/
|
|
871
|
+
planned(view, kind, operations, retry) {
|
|
872
|
+
const steps = this.modelSteps(view, kind, operations, retry);
|
|
873
|
+
if (steps)
|
|
874
|
+
return steps;
|
|
743
875
|
const result = this.plan(view, kind, operations);
|
|
744
876
|
if (!result)
|
|
745
877
|
return NONE;
|
|
746
878
|
if (!result.ok) {
|
|
747
879
|
this.pendingSelection = undefined;
|
|
748
880
|
this.pendingEdit = undefined;
|
|
881
|
+
this.pendingStatus = undefined;
|
|
749
882
|
this.pendingDiagram = undefined;
|
|
750
883
|
return {
|
|
751
884
|
messages: [
|
|
@@ -755,10 +888,61 @@ export class CanvasSession {
|
|
|
755
888
|
}
|
|
756
889
|
return { messages: [], plan: result.plan };
|
|
757
890
|
}
|
|
891
|
+
/**
|
|
892
|
+
* The operations as steps over files when a C4 view's model is in another file of the project
|
|
893
|
+
* and they write it: the model's first, then the view's, which name what the model's wrote;
|
|
894
|
+
* the host plans and applies them together. Undefined when the plan is the document's alone.
|
|
895
|
+
*/
|
|
896
|
+
modelSteps(view, kind, operations, retry) {
|
|
897
|
+
const diagram = view.diagram;
|
|
898
|
+
const file = diagram.type === "c4" ? (diagram.c4?.resolved?.file ?? null) : null;
|
|
899
|
+
if (file === null || !operations.some((op) => "model" in op))
|
|
900
|
+
return undefined;
|
|
901
|
+
const own = [];
|
|
902
|
+
if (GEOMETRY.has(kind)) {
|
|
903
|
+
const materialize = materializeOperation(view);
|
|
904
|
+
if (materialize)
|
|
905
|
+
own.push(materialize);
|
|
906
|
+
}
|
|
907
|
+
own.push(...operations.filter((op) => !("model" in op)));
|
|
908
|
+
const steps = [
|
|
909
|
+
...operations.filter((op) => "model" in op).map((operation) => ({ file, operation })),
|
|
910
|
+
...own.map((operation) => ({ file: null, operation })),
|
|
911
|
+
];
|
|
912
|
+
const asks = operations.some((op) => op.kind === "removeModelRelation" && !op.cascade);
|
|
913
|
+
return {
|
|
914
|
+
messages: [],
|
|
915
|
+
project: { kind: "files", steps, ...(asks && retry ? { retry } : {}) },
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
/**
|
|
919
|
+
* Where a new note's centre goes: where the tool was dropped, or, dropped on an element it is
|
|
920
|
+
* attached to, at the element's east edge plus the theme's `groupPadding`, aligned to its top,
|
|
921
|
+
* as a note of the size the text "Note" gives it, snapped to the grid.
|
|
922
|
+
*/
|
|
923
|
+
notePlace(view, action) {
|
|
924
|
+
const target = action.attachedTo !== undefined ? view.layout?.boxes?.get(action.attachedTo) : undefined;
|
|
925
|
+
if (!target)
|
|
926
|
+
return this.snap(action.at);
|
|
927
|
+
const padding = view.layout?.theme?.groupPadding ?? 16;
|
|
928
|
+
return this.snap({
|
|
929
|
+
x: target.x + target.width + padding + NEW_NOTE_SIZE.width / 2,
|
|
930
|
+
y: target.y + NEW_NOTE_SIZE.height / 2,
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
/** A view whose model is not resolved cannot write it. */
|
|
934
|
+
unresolved() {
|
|
935
|
+
return {
|
|
936
|
+
messages: [
|
|
937
|
+
{ type: "rejected", code: "edit.unsafe-target", reason: wording().session.fixFirst },
|
|
938
|
+
],
|
|
939
|
+
};
|
|
940
|
+
}
|
|
758
941
|
/**
|
|
759
942
|
* The actions of a C4 view that differ from flow diagrams: entities are added from the model
|
|
760
|
-
* and removed by excluding them; labels are local
|
|
761
|
-
* undefined for the actions the flow
|
|
943
|
+
* and removed by excluding them; relations are the model's; labels are local or the model's;
|
|
944
|
+
* ids rename the model's entities and relations. Returns undefined for the actions the flow
|
|
945
|
+
* code handles.
|
|
762
946
|
*/
|
|
763
947
|
c4Action(view, action) {
|
|
764
948
|
const diagram = view.diagram;
|
|
@@ -769,6 +953,101 @@ export class CanvasSession {
|
|
|
769
953
|
}
|
|
770
954
|
const model = diagram.c4?.resolved?.definition;
|
|
771
955
|
switch (action.kind) {
|
|
956
|
+
case "create": {
|
|
957
|
+
// A note, the one declaration a view holds: written in the view, never in the model.
|
|
958
|
+
if (action.elementKind === "note") {
|
|
959
|
+
if (!diagram.c4?.resolved)
|
|
960
|
+
return this.unresolved();
|
|
961
|
+
const id = freeElementId(this.analysis, diagram, "note");
|
|
962
|
+
const placed = view.mode !== "auto" || view.layout?.boxes !== undefined;
|
|
963
|
+
const at = this.notePlace(view, action);
|
|
964
|
+
this.pendingSelection = [id];
|
|
965
|
+
this.pendingEdit = id;
|
|
966
|
+
if (action.attachedTo !== undefined) {
|
|
967
|
+
this.pendingStatus = wording().session.noteAttached(action.attachedTo);
|
|
968
|
+
}
|
|
969
|
+
return this.planned(view, "create", [
|
|
970
|
+
{
|
|
971
|
+
kind: "createElement",
|
|
972
|
+
diagram: d,
|
|
973
|
+
elementKind: "note",
|
|
974
|
+
id,
|
|
975
|
+
label: "Note",
|
|
976
|
+
...(placed ? { at } : {}),
|
|
977
|
+
...(action.attachedTo !== undefined ? { attachedTo: action.attachedTo } : {}),
|
|
978
|
+
},
|
|
979
|
+
]);
|
|
980
|
+
}
|
|
981
|
+
if (action.attachedTo !== undefined) {
|
|
982
|
+
return {
|
|
983
|
+
messages: [
|
|
984
|
+
{
|
|
985
|
+
type: "rejected",
|
|
986
|
+
code: "edit.invalid-value",
|
|
987
|
+
reason: wording().session.attachNotesOnly(action.elementKind),
|
|
988
|
+
},
|
|
989
|
+
],
|
|
990
|
+
};
|
|
991
|
+
}
|
|
992
|
+
// An entity of a kind the level draws, declared in the model where the level puts it,
|
|
993
|
+
// included in the view (nothing when the view draws it by default) and placed.
|
|
994
|
+
if (!model)
|
|
995
|
+
return this.unresolved();
|
|
996
|
+
const kind = action.elementKind;
|
|
997
|
+
const kinds = allowedKinds(diagram.c4.level);
|
|
998
|
+
if (!kinds.includes(kind)) {
|
|
999
|
+
return {
|
|
1000
|
+
messages: [
|
|
1001
|
+
{
|
|
1002
|
+
type: "rejected",
|
|
1003
|
+
code: "edit.invalid-value",
|
|
1004
|
+
reason: wording().c4.kindNotDrawn(action.elementKind, kinds),
|
|
1005
|
+
},
|
|
1006
|
+
],
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
const parent = entityParent(diagram, kind);
|
|
1010
|
+
const path = freshEntityPath(model, parent, kind);
|
|
1011
|
+
const ops = [
|
|
1012
|
+
{
|
|
1013
|
+
kind: "createEntity",
|
|
1014
|
+
model: model.id,
|
|
1015
|
+
entityKind: kind,
|
|
1016
|
+
...(parent !== null ? { parent } : {}),
|
|
1017
|
+
id: path.slice(path.lastIndexOf(".") + 1),
|
|
1018
|
+
label: "",
|
|
1019
|
+
},
|
|
1020
|
+
{ kind: "includeEntity", diagram: d, entity: path },
|
|
1021
|
+
];
|
|
1022
|
+
// Where it was dropped: an `auto` view writes its arrangement first, as for any creation.
|
|
1023
|
+
if (view.mode !== "auto" || view.layout?.boxes) {
|
|
1024
|
+
const at = this.snap(action.at);
|
|
1025
|
+
ops.push({ kind: "setPosition", diagram: d, element: path, x: at.x, y: at.y });
|
|
1026
|
+
}
|
|
1027
|
+
this.pendingSelection = [path];
|
|
1028
|
+
this.pendingEdit = path;
|
|
1029
|
+
return this.planned(view, "create", ops);
|
|
1030
|
+
}
|
|
1031
|
+
case "duplicate": {
|
|
1032
|
+
// Copies of the selected entities, declared beside them in the model and placed off them.
|
|
1033
|
+
const fragment = copyFragment(this.analysis, d, action.ids);
|
|
1034
|
+
const read = fragment ? compile(fragment) : undefined;
|
|
1035
|
+
const copied = read
|
|
1036
|
+
? copiedEntities(read.document.models, read.document.diagrams)
|
|
1037
|
+
: undefined;
|
|
1038
|
+
const notes = read ? copiedNotes(read.document.diagrams) : [];
|
|
1039
|
+
if (!copied || (copied.length === 0 && notes.length === 0))
|
|
1040
|
+
return { messages: [] };
|
|
1041
|
+
const placed = view.mode !== "auto" || view.layout?.boxes !== undefined;
|
|
1042
|
+
const pasted = pasteEntities(diagram, placed, copied, { shift: { x: 2 * this.grid, y: 2 * this.grid } }, notes);
|
|
1043
|
+
if (!pasted.ok) {
|
|
1044
|
+
return {
|
|
1045
|
+
messages: [{ type: "rejected", code: "edit.invalid-value", reason: pasted.reason }],
|
|
1046
|
+
};
|
|
1047
|
+
}
|
|
1048
|
+
this.pendingSelection = [...pasted.ids];
|
|
1049
|
+
return this.planned(view, "duplicate", pasted.operations);
|
|
1050
|
+
}
|
|
772
1051
|
case "includeEntity": {
|
|
773
1052
|
const ops = [{ kind: "includeEntity", diagram: d, entity: action.entity }];
|
|
774
1053
|
if (action.at && view.mode !== "auto") {
|
|
@@ -778,33 +1057,186 @@ export class CanvasSession {
|
|
|
778
1057
|
this.pendingSelection = [action.entity];
|
|
779
1058
|
return this.planned(view, "includeEntity", ops);
|
|
780
1059
|
}
|
|
1060
|
+
case "connect": {
|
|
1061
|
+
if (!model)
|
|
1062
|
+
return this.unresolved();
|
|
1063
|
+
// A relation pinned to points needs a route, and a route needs a named relation.
|
|
1064
|
+
const pinned = action.pinned;
|
|
1065
|
+
const id = pinned ? freshModelRelationId(diagram, action) : undefined;
|
|
1066
|
+
const ops = [
|
|
1067
|
+
{
|
|
1068
|
+
kind: "createModelRelation",
|
|
1069
|
+
model: model.id,
|
|
1070
|
+
from: action.from,
|
|
1071
|
+
to: action.to,
|
|
1072
|
+
...(id ? { id } : {}),
|
|
1073
|
+
},
|
|
1074
|
+
];
|
|
1075
|
+
if (pinned && id) {
|
|
1076
|
+
ops.push({
|
|
1077
|
+
kind: "setRoute",
|
|
1078
|
+
diagram: d,
|
|
1079
|
+
relation: id,
|
|
1080
|
+
via: [],
|
|
1081
|
+
...(pinned.fromPort !== undefined ? { from: pinned.fromPort } : {}),
|
|
1082
|
+
...(pinned.fromPosition !== undefined ? { fromPosition: pinned.fromPosition } : {}),
|
|
1083
|
+
...(pinned.toPort !== undefined ? { to: pinned.toPort } : {}),
|
|
1084
|
+
...(pinned.toPosition !== undefined ? { toPosition: pinned.toPosition } : {}),
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
1087
|
+
// The new relation comes after the others of its pair in the model, so its key is the next.
|
|
1088
|
+
const pair = model.relations.filter((r) => r.from === action.from && r.to === action.to);
|
|
1089
|
+
this.pendingSelection = [id ?? `${action.from}->${action.to}#${pair.length + 1}`];
|
|
1090
|
+
return this.planned(view, "connect", ops);
|
|
1091
|
+
}
|
|
1092
|
+
case "setEnd": {
|
|
1093
|
+
const relation = diagram.relationsByKey.get(action.relation);
|
|
1094
|
+
if (!relation)
|
|
1095
|
+
return { messages: [] };
|
|
1096
|
+
if (!model)
|
|
1097
|
+
return this.unresolved();
|
|
1098
|
+
const combined = diagram.c4?.combined.includes(relation.key) ?? false;
|
|
1099
|
+
const source = combined ? undefined : model.relationsByKey.get(relation.key);
|
|
1100
|
+
const current = action.end === "from" ? relation.from : relation.to;
|
|
1101
|
+
const end = (id, port) => ({
|
|
1102
|
+
kind: "setRoute",
|
|
1103
|
+
diagram: d,
|
|
1104
|
+
relation: id,
|
|
1105
|
+
via: currentVia(diagram, id),
|
|
1106
|
+
...(action.end === "from"
|
|
1107
|
+
? {
|
|
1108
|
+
from: port,
|
|
1109
|
+
...(action.position !== undefined ? { fromPosition: action.position } : {}),
|
|
1110
|
+
}
|
|
1111
|
+
: {
|
|
1112
|
+
to: port,
|
|
1113
|
+
...(action.position !== undefined ? { toPosition: action.position } : {}),
|
|
1114
|
+
}),
|
|
1115
|
+
});
|
|
1116
|
+
if (current !== action.element) {
|
|
1117
|
+
// Another entity: the model relation's end moves there.
|
|
1118
|
+
if (!source) {
|
|
1119
|
+
return {
|
|
1120
|
+
messages: [
|
|
1121
|
+
{
|
|
1122
|
+
type: "rejected",
|
|
1123
|
+
code: "edit.invalid-value",
|
|
1124
|
+
reason: wording().c4.reconnectCombination(relation.key, relation.sources),
|
|
1125
|
+
},
|
|
1126
|
+
],
|
|
1127
|
+
};
|
|
1128
|
+
}
|
|
1129
|
+
const route = relation.id
|
|
1130
|
+
? diagram.placement.find((p) => p.statement === "route" && p.target === relation.id)
|
|
1131
|
+
: undefined;
|
|
1132
|
+
const endpoint = route ? (action.end === "from" ? route.from : route.to) : undefined;
|
|
1133
|
+
// A dropped port, else the compass port of the old end carried over; a named port of
|
|
1134
|
+
// the old entity does not carry over.
|
|
1135
|
+
const port = action.port !== null
|
|
1136
|
+
? action.port
|
|
1137
|
+
: endpoint?.explicitPort
|
|
1138
|
+
? endpoint.named
|
|
1139
|
+
? null
|
|
1140
|
+
: endpoint.port
|
|
1141
|
+
: undefined;
|
|
1142
|
+
const from = action.end === "from" ? action.element : source.from;
|
|
1143
|
+
const to = action.end === "to" ? action.element : source.to;
|
|
1144
|
+
const ops = [];
|
|
1145
|
+
let id = relation.id;
|
|
1146
|
+
if (port !== undefined && id === null) {
|
|
1147
|
+
// The route needs a name: the relation is named after its new ends.
|
|
1148
|
+
id = freshModelRelationId(diagram, { from, to });
|
|
1149
|
+
ops.push({
|
|
1150
|
+
kind: "setModelRelationProperty",
|
|
1151
|
+
model: model.id,
|
|
1152
|
+
relation: source.key,
|
|
1153
|
+
property: "id",
|
|
1154
|
+
value: id,
|
|
1155
|
+
});
|
|
1156
|
+
}
|
|
1157
|
+
// Once named, the relation goes by its id. The move rewrites the relation's routes in
|
|
1158
|
+
// the views of the model's file, with the dropped port in this one's; a route still to
|
|
1159
|
+
// write (none here yet), or one in another file than the model's, is set after it.
|
|
1160
|
+
ops.push({
|
|
1161
|
+
kind: "reconnectModelRelation",
|
|
1162
|
+
model: model.id,
|
|
1163
|
+
relation: id ?? source.key,
|
|
1164
|
+
end: action.end,
|
|
1165
|
+
entity: action.element,
|
|
1166
|
+
...(id !== null && port !== undefined
|
|
1167
|
+
? {
|
|
1168
|
+
view: d,
|
|
1169
|
+
port,
|
|
1170
|
+
...(action.position !== undefined ? { position: action.position } : {}),
|
|
1171
|
+
}
|
|
1172
|
+
: {}),
|
|
1173
|
+
});
|
|
1174
|
+
const elsewhere = (diagram.c4?.resolved?.file ?? null) !== null;
|
|
1175
|
+
if (port !== undefined && id !== null && (!route || elsewhere))
|
|
1176
|
+
ops.push(end(id, port));
|
|
1177
|
+
// The relation keeps its place in the model's text, so its key counts the relations of
|
|
1178
|
+
// its new pair written before it.
|
|
1179
|
+
const before = model.relations.filter((r) => r.from === from && r.to === to && r.node.start < source.node.start).length;
|
|
1180
|
+
this.pendingSelection = [id ?? `${from}->${to}#${before + 1}`];
|
|
1181
|
+
return this.planned(view, "setEnd", ops);
|
|
1182
|
+
}
|
|
1183
|
+
// The same entity: the end pinned to a port or a point of its border, or handed back to
|
|
1184
|
+
// the layout, which changes nothing for a relation without a route.
|
|
1185
|
+
if (action.port === null && relation.id === null)
|
|
1186
|
+
return { messages: [] };
|
|
1187
|
+
if (!nameable(diagram, relation)) {
|
|
1188
|
+
return {
|
|
1189
|
+
messages: [
|
|
1190
|
+
{
|
|
1191
|
+
type: "rejected",
|
|
1192
|
+
code: "edit.invalid-value",
|
|
1193
|
+
reason: wording().session.fixFirst,
|
|
1194
|
+
},
|
|
1195
|
+
],
|
|
1196
|
+
};
|
|
1197
|
+
}
|
|
1198
|
+
const { id, operations } = namedRelation(diagram, relation);
|
|
1199
|
+
this.followNaming(relation.key, id, operations);
|
|
1200
|
+
return this.planned(view, "setEnd", [...operations, end(id, action.port)]);
|
|
1201
|
+
}
|
|
781
1202
|
case "remove": {
|
|
782
1203
|
const ops = [];
|
|
783
1204
|
const elements = action.ids.filter((id) => diagram.elementsById.get(id)?.kind !== undefined);
|
|
784
1205
|
for (const id of elements) {
|
|
785
|
-
|
|
1206
|
+
const element = diagram.elementsById.get(id);
|
|
1207
|
+
if (element.kind === "group")
|
|
786
1208
|
continue;
|
|
787
|
-
|
|
1209
|
+
// A note is the view's own declaration; an entity is left out of the view.
|
|
1210
|
+
ops.push(isViewNote(element)
|
|
1211
|
+
? { kind: "removeElement", diagram: d, element: id, cascade: action.cascade }
|
|
1212
|
+
: { kind: "excludeEntity", diagram: d, entity: id, cascade: action.cascade });
|
|
788
1213
|
}
|
|
789
1214
|
for (const key of action.ids) {
|
|
790
1215
|
if (diagram.c4?.combined.includes(key))
|
|
791
1216
|
ops.push({ kind: "removeRelation", diagram: d, relation: key });
|
|
792
1217
|
else if (diagram.relationsByKey.has(key)) {
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
1218
|
+
// A drawn model relation goes from the model, with what names it in its views.
|
|
1219
|
+
const source = model?.relationsByKey.get(key);
|
|
1220
|
+
if (!model || !source)
|
|
1221
|
+
return this.unresolved();
|
|
1222
|
+
ops.push({
|
|
1223
|
+
kind: "removeModelRelation",
|
|
1224
|
+
model: model.id,
|
|
1225
|
+
relation: source.key,
|
|
1226
|
+
cascade: action.cascade,
|
|
1227
|
+
view: d,
|
|
1228
|
+
});
|
|
802
1229
|
}
|
|
803
1230
|
}
|
|
804
1231
|
if (!action.cascade) {
|
|
805
1232
|
const dependents = [];
|
|
806
1233
|
for (const operation of ops) {
|
|
807
|
-
if (operation.kind !== "excludeEntity"
|
|
1234
|
+
if (operation.kind !== "excludeEntity" &&
|
|
1235
|
+
operation.kind !== "removeElement" &&
|
|
1236
|
+
operation.kind !== "removeModelRelation")
|
|
1237
|
+
continue;
|
|
1238
|
+
// A model in another file is asked through the host's steps.
|
|
1239
|
+
if (operation.kind === "removeModelRelation" && !modelIsLocal(diagram))
|
|
808
1240
|
continue;
|
|
809
1241
|
const probe = planEdit(this.analysis.compilation, operation);
|
|
810
1242
|
if (!probe.ok && probe.rejection.code === "edit.has-dependents") {
|
|
@@ -827,27 +1259,85 @@ export class CanvasSession {
|
|
|
827
1259
|
}
|
|
828
1260
|
}
|
|
829
1261
|
this.pendingSelection = [];
|
|
830
|
-
return this.planned(view, "remove", ops);
|
|
1262
|
+
return this.planned(view, "remove", ops, { ...action, cascade: true });
|
|
831
1263
|
}
|
|
832
1264
|
case "setLabel": {
|
|
833
1265
|
const label = action.label === "" ? null : action.label;
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
1266
|
+
if (diagram.c4?.combined.includes(action.id)) {
|
|
1267
|
+
return this.planned(view, "setLabel", [
|
|
1268
|
+
{ kind: "setRelationLabel", diagram: d, relation: action.id, label },
|
|
1269
|
+
]);
|
|
1270
|
+
}
|
|
1271
|
+
const relation = diagram.relationsByKey.get(action.id);
|
|
1272
|
+
const note = diagram.elementsById.get(action.id);
|
|
1273
|
+
if (note && isViewNote(note)) {
|
|
1274
|
+
return this.planned(view, "setLabel", [
|
|
1275
|
+
{ kind: "setElementLabel", diagram: d, element: action.id, label: action.label },
|
|
1276
|
+
]);
|
|
1277
|
+
}
|
|
1278
|
+
if (!relation) {
|
|
1279
|
+
// A label typed on an entity the model left empty (one just created) is the model's;
|
|
1280
|
+
// otherwise it is this view's local label.
|
|
1281
|
+
const entity = model?.entitiesByPath.get(action.id);
|
|
1282
|
+
if (entity && entity.label === "" && label !== null) {
|
|
1283
|
+
return this.planned(view, "setLabel", [
|
|
1284
|
+
{
|
|
1285
|
+
kind: "setEntityProperty",
|
|
1286
|
+
model: model.id,
|
|
1287
|
+
entity: entity.path,
|
|
1288
|
+
property: "label",
|
|
1289
|
+
value: label,
|
|
1290
|
+
},
|
|
1291
|
+
]);
|
|
1292
|
+
}
|
|
1293
|
+
return this.planned(view, "setLabel", [
|
|
1294
|
+
{ kind: "setLocalLabel", diagram: d, target: action.id, label },
|
|
1295
|
+
]);
|
|
1296
|
+
}
|
|
1297
|
+
// A label typed on an arrow fills the model relation's when it has none; otherwise it is
|
|
1298
|
+
// this view's local label, which names the relation first.
|
|
1299
|
+
const source = model?.relationsByKey.get(relation.key);
|
|
1300
|
+
if (source && source.label === null && label !== null) {
|
|
1301
|
+
return this.planned(view, "setLabel", [
|
|
1302
|
+
{
|
|
1303
|
+
kind: "setModelRelationProperty",
|
|
1304
|
+
model: model.id,
|
|
1305
|
+
relation: source.key,
|
|
1306
|
+
property: "label",
|
|
1307
|
+
value: label,
|
|
1308
|
+
},
|
|
1309
|
+
]);
|
|
1310
|
+
}
|
|
1311
|
+
if (!nameable(diagram, relation)) {
|
|
841
1312
|
return {
|
|
842
1313
|
messages: [
|
|
843
1314
|
{
|
|
844
1315
|
type: "rejected",
|
|
845
|
-
code: "edit.
|
|
846
|
-
reason: wording().session.
|
|
1316
|
+
code: "edit.invalid-value",
|
|
1317
|
+
reason: wording().session.fixFirst,
|
|
847
1318
|
},
|
|
848
1319
|
],
|
|
849
1320
|
};
|
|
850
1321
|
}
|
|
1322
|
+
if (relation.id === null && label === null)
|
|
1323
|
+
return { messages: [] };
|
|
1324
|
+
const { id, operations } = namedRelation(diagram, relation);
|
|
1325
|
+
this.followNaming(relation.key, id, operations);
|
|
1326
|
+
return this.planned(view, "setLabel", [
|
|
1327
|
+
...operations,
|
|
1328
|
+
{ kind: "setLocalLabel", diagram: d, target: id, label },
|
|
1329
|
+
]);
|
|
1330
|
+
}
|
|
1331
|
+
case "renameId": {
|
|
1332
|
+
const note = diagram.elementsById.get(action.id);
|
|
1333
|
+
if (note && isViewNote(note)) {
|
|
1334
|
+
this.pendingSelection = this.selection.map((s) => (s === action.id ? action.newId : s));
|
|
1335
|
+
return this.planned(view, "renameId", [
|
|
1336
|
+
{ kind: "renameElement", diagram: d, element: action.id, newId: action.newId },
|
|
1337
|
+
]);
|
|
1338
|
+
}
|
|
1339
|
+
if (!model)
|
|
1340
|
+
return this.unresolved();
|
|
851
1341
|
const entity = model.entitiesByPath.get(action.id);
|
|
852
1342
|
const relation = model.relationsByKey.get(action.id);
|
|
853
1343
|
const ops = entity
|
|
@@ -970,10 +1460,28 @@ export class CanvasSession {
|
|
|
970
1460
|
ops.push(...this.resizeOperations(view, action));
|
|
971
1461
|
break;
|
|
972
1462
|
case "moveLabel": {
|
|
1463
|
+
// A free line's pill moves as a relation's does, by the line's id.
|
|
1464
|
+
const line = diagram.elementsById.get(action.relation);
|
|
1465
|
+
if (line?.kind === "line") {
|
|
1466
|
+
const entry = diagram.placement.find((p) => p.statement === "label" && p.target === line.id);
|
|
1467
|
+
const offset = entry?.offset ?? { x: 0, y: 0 };
|
|
1468
|
+
ops.push({
|
|
1469
|
+
kind: "setLabelAnchor",
|
|
1470
|
+
diagram: d,
|
|
1471
|
+
relation: line.id,
|
|
1472
|
+
at: entry?.at ?? 50,
|
|
1473
|
+
offset: {
|
|
1474
|
+
x: Math.round(offset.x + action.delta.x),
|
|
1475
|
+
y: Math.round(offset.y + action.delta.y),
|
|
1476
|
+
},
|
|
1477
|
+
});
|
|
1478
|
+
break;
|
|
1479
|
+
}
|
|
973
1480
|
const relation = diagram.relationsByKey.get(action.relation);
|
|
974
1481
|
if (!relation)
|
|
975
1482
|
break;
|
|
976
1483
|
const { id, operations } = namedRelation(diagram, relation);
|
|
1484
|
+
this.followNaming(relation.key, id, operations);
|
|
977
1485
|
const entry = relation.id
|
|
978
1486
|
? diagram.placement.find((p) => p.statement === "label" && p.target === relation.id)
|
|
979
1487
|
: undefined;
|
|
@@ -990,11 +1498,58 @@ export class CanvasSession {
|
|
|
990
1498
|
});
|
|
991
1499
|
break;
|
|
992
1500
|
}
|
|
1501
|
+
case "createLine": {
|
|
1502
|
+
// In the group under the press, its points in that group's interior; the armed kind's
|
|
1503
|
+
// dashes and heads as styles, with the effective theme's head.
|
|
1504
|
+
const id = freeElementId(this.analysis, diagram, "line");
|
|
1505
|
+
const origin = interiorOf(view, action.parent ?? null);
|
|
1506
|
+
const at = (p) => {
|
|
1507
|
+
const snapped = this.snap(p);
|
|
1508
|
+
return { x: snapped.x - origin.x, y: snapped.y - origin.y };
|
|
1509
|
+
};
|
|
1510
|
+
const head = view.layout?.theme?.arrowhead ?? "triangle";
|
|
1511
|
+
const styles = [
|
|
1512
|
+
...(action.line === "dashed" ? [{ property: "line", value: "dashed" }] : []),
|
|
1513
|
+
...(action.heads === "both" ? [{ property: "arrowtail", value: head }] : []),
|
|
1514
|
+
...(action.heads !== "none" ? [{ property: "arrowhead", value: head }] : []),
|
|
1515
|
+
];
|
|
1516
|
+
ops.push({
|
|
1517
|
+
kind: "createLine",
|
|
1518
|
+
diagram: d,
|
|
1519
|
+
id,
|
|
1520
|
+
from: at(action.from),
|
|
1521
|
+
to: at(action.to),
|
|
1522
|
+
...(action.parent ? { parent: action.parent } : {}),
|
|
1523
|
+
...(styles.length > 0 ? { styles } : {}),
|
|
1524
|
+
});
|
|
1525
|
+
this.pendingSelection = [id];
|
|
1526
|
+
break;
|
|
1527
|
+
}
|
|
1528
|
+
case "setLinePoints": {
|
|
1529
|
+
const element = diagram.elementsById.get(action.element);
|
|
1530
|
+
if (element?.kind !== "line")
|
|
1531
|
+
break;
|
|
1532
|
+
const origin = interiorOf(view, element.groupPath[element.groupPath.length - 1] ?? null);
|
|
1533
|
+
const at = (p) => ({
|
|
1534
|
+
x: Math.round(p.x - origin.x),
|
|
1535
|
+
y: Math.round(p.y - origin.y),
|
|
1536
|
+
});
|
|
1537
|
+
ops.push({
|
|
1538
|
+
kind: "setLinePoints",
|
|
1539
|
+
diagram: d,
|
|
1540
|
+
element: element.id,
|
|
1541
|
+
from: at(action.from),
|
|
1542
|
+
to: at(action.to),
|
|
1543
|
+
via: action.via.map(at),
|
|
1544
|
+
});
|
|
1545
|
+
break;
|
|
1546
|
+
}
|
|
993
1547
|
case "setVia": {
|
|
994
1548
|
const relation = diagram.relationsByKey.get(action.relation);
|
|
995
1549
|
if (!relation)
|
|
996
1550
|
break;
|
|
997
1551
|
const { id, operations } = namedRelation(diagram, relation);
|
|
1552
|
+
this.followNaming(relation.key, id, operations);
|
|
998
1553
|
ops.push(...operations, {
|
|
999
1554
|
kind: "setRoute",
|
|
1000
1555
|
diagram: d,
|
|
@@ -1013,6 +1568,7 @@ export class CanvasSession {
|
|
|
1013
1568
|
if (action.port === null && relation.id === null)
|
|
1014
1569
|
break;
|
|
1015
1570
|
const { id, operations } = namedRelation(diagram, relation);
|
|
1571
|
+
this.followNaming(relation.key, id, operations);
|
|
1016
1572
|
ops.push(...operations, {
|
|
1017
1573
|
kind: "setRoute",
|
|
1018
1574
|
diagram: d,
|
|
@@ -1069,7 +1625,8 @@ export class CanvasSession {
|
|
|
1069
1625
|
this.pendingEdit = id;
|
|
1070
1626
|
break;
|
|
1071
1627
|
}
|
|
1072
|
-
|
|
1628
|
+
// A note dropped on an element goes beside it; anything else where it was dropped.
|
|
1629
|
+
const snapped = kind === "note" ? this.notePlace(view, action) : this.snap(action.at);
|
|
1073
1630
|
// Groups too go inside the group under the point, so groups nest.
|
|
1074
1631
|
const origin = interiorOf(view, action.parent ?? null);
|
|
1075
1632
|
// A library element keeps its label; a shape from the palette, the keys or the grid is empty.
|
|
@@ -1086,9 +1643,19 @@ export class CanvasSession {
|
|
|
1086
1643
|
...(label !== undefined ? { label } : {}),
|
|
1087
1644
|
...(action.icon !== undefined ? { icon: action.icon } : {}),
|
|
1088
1645
|
...(action.shape !== undefined ? { shape: action.shape } : {}),
|
|
1646
|
+
...(action.attachedTo !== undefined ? { attachedTo: action.attachedTo } : {}),
|
|
1089
1647
|
});
|
|
1090
1648
|
this.pendingSelection = [id];
|
|
1091
1649
|
this.pendingEdit = id;
|
|
1650
|
+
// The selection is announced too; the status line ends on the attachment.
|
|
1651
|
+
if (action.attachedTo !== undefined) {
|
|
1652
|
+
this.pendingStatus = words.session.noteAttached(action.attachedTo);
|
|
1653
|
+
}
|
|
1654
|
+
break;
|
|
1655
|
+
}
|
|
1656
|
+
case "attachNote": {
|
|
1657
|
+
ops.push({ kind: "attachNote", diagram: d, note: action.note, element: action.element });
|
|
1658
|
+
this.pendingSelection = [action.note];
|
|
1092
1659
|
break;
|
|
1093
1660
|
}
|
|
1094
1661
|
case "connect": {
|
|
@@ -1101,7 +1668,7 @@ export class CanvasSession {
|
|
|
1101
1668
|
from: action.from,
|
|
1102
1669
|
to: action.to,
|
|
1103
1670
|
line: action.line,
|
|
1104
|
-
...(action.
|
|
1671
|
+
...(action.heads ? { heads: action.heads } : {}),
|
|
1105
1672
|
...(pinned
|
|
1106
1673
|
? {
|
|
1107
1674
|
id,
|
|
@@ -1202,6 +1769,7 @@ export class CanvasSession {
|
|
|
1202
1769
|
if (!relation)
|
|
1203
1770
|
break;
|
|
1204
1771
|
const { id, operations } = namedRelation(diagram, relation);
|
|
1772
|
+
this.followNaming(relation.key, id, operations);
|
|
1205
1773
|
ops.push(...operations, {
|
|
1206
1774
|
kind: "setRelationLabel",
|
|
1207
1775
|
diagram: d,
|
|
@@ -1277,8 +1845,10 @@ export class CanvasSession {
|
|
|
1277
1845
|
for (const id of action.ids) {
|
|
1278
1846
|
const element = diagram.elementsById.get(id);
|
|
1279
1847
|
const box = boxes.get(id);
|
|
1280
|
-
|
|
1848
|
+
// Lines have no edges to align: they keep their points.
|
|
1849
|
+
if (element && element.kind !== "group" && element.kind !== "line" && box) {
|
|
1281
1850
|
selected.set(id, box);
|
|
1851
|
+
}
|
|
1282
1852
|
}
|
|
1283
1853
|
const centres = action.kind === "align"
|
|
1284
1854
|
? alignBoxes(selected, action.edge)
|
|
@@ -1338,7 +1908,8 @@ export class CanvasSession {
|
|
|
1338
1908
|
this.pendingSelection = [...result.select];
|
|
1339
1909
|
// Moving into a group of a free diagram writes the place in the group's interior, and a
|
|
1340
1910
|
// group left empty gets its place and size.
|
|
1341
|
-
|
|
1911
|
+
// A rotation turns a box in place: an `auto` diagram keeps its arrangement first.
|
|
1912
|
+
const geometric = ["x", "y", "w", "h", "style.rotation"].includes(action.field) ||
|
|
1342
1913
|
(action.field === "inside" &&
|
|
1343
1914
|
(diagram.type === "free" ||
|
|
1344
1915
|
result.operations.some((op) => op.kind === "setPosition" || op.kind === "setSize")));
|
|
@@ -1351,6 +1922,20 @@ export class CanvasSession {
|
|
|
1351
1922
|
}
|
|
1352
1923
|
: planned;
|
|
1353
1924
|
}
|
|
1925
|
+
case "setFields": {
|
|
1926
|
+
// The fields of one control written together: one plan, one undo step.
|
|
1927
|
+
const operations = [];
|
|
1928
|
+
for (const { field, value } of action.fields) {
|
|
1929
|
+
const result = fieldOperations(this.analysis, d, action.ids, field, value);
|
|
1930
|
+
if (!result.ok) {
|
|
1931
|
+
return {
|
|
1932
|
+
messages: [{ type: "rejected", code: "edit.invalid-value", reason: result.reason }],
|
|
1933
|
+
};
|
|
1934
|
+
}
|
|
1935
|
+
operations.push(...result.operations);
|
|
1936
|
+
}
|
|
1937
|
+
return this.planned(view, "setField", operations);
|
|
1938
|
+
}
|
|
1354
1939
|
case "setIcon": {
|
|
1355
1940
|
const targets = action.ids.filter((id) => {
|
|
1356
1941
|
const element = diagram.elementsById.get(id);
|
|
@@ -1491,6 +2076,9 @@ export class CanvasSession {
|
|
|
1491
2076
|
for (const id of moved) {
|
|
1492
2077
|
const element = diagram.elementsById.get(id);
|
|
1493
2078
|
const parent = element.groupPath[element.groupPath.length - 1] ?? null;
|
|
2079
|
+
// A free line stays in its group: its points are written there.
|
|
2080
|
+
if (element.kind === "line")
|
|
2081
|
+
continue;
|
|
1494
2082
|
if (parent === target || carried.has(id) || element.groupPath.some((g) => moved.has(g)))
|
|
1495
2083
|
continue;
|
|
1496
2084
|
reparented.add(id);
|
|
@@ -1516,6 +2104,22 @@ export class CanvasSession {
|
|
|
1516
2104
|
const element = diagram.elementsById.get(id);
|
|
1517
2105
|
if (reparented.has(id) || element.groupPath.some((g) => moved.has(g)))
|
|
1518
2106
|
continue;
|
|
2107
|
+
// A free line moves every point by the same amount, in its own coordinates.
|
|
2108
|
+
if (element.kind === "line" && element.line) {
|
|
2109
|
+
const by = (p) => ({
|
|
2110
|
+
x: Math.round(p.x + action.delta.x),
|
|
2111
|
+
y: Math.round(p.y + action.delta.y),
|
|
2112
|
+
});
|
|
2113
|
+
ops.push({
|
|
2114
|
+
kind: "setLinePoints",
|
|
2115
|
+
diagram: d,
|
|
2116
|
+
element: id,
|
|
2117
|
+
from: by(element.line.from),
|
|
2118
|
+
to: by(element.line.to),
|
|
2119
|
+
via: element.line.via.map(by),
|
|
2120
|
+
});
|
|
2121
|
+
continue;
|
|
2122
|
+
}
|
|
1519
2123
|
const to = landed(id);
|
|
1520
2124
|
if (!to)
|
|
1521
2125
|
continue;
|
|
@@ -1630,7 +2234,8 @@ export class CanvasSession {
|
|
|
1630
2234
|
return none;
|
|
1631
2235
|
operations = [...field.operations];
|
|
1632
2236
|
}
|
|
1633
|
-
|
|
2237
|
+
// A rotation previews as its release writes it: after the arrangement of an `auto` diagram.
|
|
2238
|
+
const result = this.plan(view, action.kind === "setField" ? "move" : action.kind, operations);
|
|
1634
2239
|
if (!result?.ok)
|
|
1635
2240
|
return none;
|
|
1636
2241
|
const compilation = compile(applyTextEdits(analysis.compilation.text, result.plan.edits), analysis.compilation.options);
|
|
@@ -1645,7 +2250,13 @@ export class CanvasSession {
|
|
|
1645
2250
|
if (!drawn)
|
|
1646
2251
|
return none;
|
|
1647
2252
|
const { width, height, offset } = drawn.geometry;
|
|
1648
|
-
|
|
2253
|
+
// The turned element's box in the drawing, which the canvas turns it and its outline about.
|
|
2254
|
+
const box = action.kind === "setField" ? layout.boxes?.get(action.ids[0]) : undefined;
|
|
2255
|
+
return {
|
|
2256
|
+
messages: [
|
|
2257
|
+
{ type: "preview", token, svg: drawn.svg, width, height, offset, ...(box ? { box } : {}) },
|
|
2258
|
+
],
|
|
2259
|
+
};
|
|
1649
2260
|
}
|
|
1650
2261
|
}
|
|
1651
2262
|
/** How many errors a compilation reports. */
|
|
@@ -1669,7 +2280,8 @@ function refusal(refused, action, diagram) {
|
|
|
1669
2280
|
}
|
|
1670
2281
|
if (action.kind === "create" && action.shape !== undefined)
|
|
1671
2282
|
keys.push("create.shape");
|
|
1672
|
-
|
|
2283
|
+
// A view's relations go one way with a head: no two-way arrow, no line.
|
|
2284
|
+
if (action.kind === "connect" && action.heads)
|
|
1673
2285
|
keys.push("connect.twoWay");
|
|
1674
2286
|
const key = keys.find((k) => Object.hasOwn(refused, k));
|
|
1675
2287
|
return key === undefined ? undefined : refused[key];
|