@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.
Files changed (112) hide show
  1. package/dist/add-icons.d.ts +44 -0
  2. package/dist/add-icons.d.ts.map +1 -0
  3. package/dist/add-icons.js +178 -0
  4. package/dist/add-icons.js.map +1 -0
  5. package/dist/arrange.d.ts.map +1 -1
  6. package/dist/arrange.js +0 -1
  7. package/dist/arrange.js.map +1 -1
  8. package/dist/asset-files.d.ts +46 -0
  9. package/dist/asset-files.d.ts.map +1 -0
  10. package/dist/asset-files.js +79 -0
  11. package/dist/asset-files.js.map +1 -0
  12. package/dist/c4.d.ts +91 -1
  13. package/dist/c4.d.ts.map +1 -1
  14. package/dist/c4.js +358 -9
  15. package/dist/c4.js.map +1 -1
  16. package/dist/canvas/keyboard.d.ts +3 -1
  17. package/dist/canvas/keyboard.d.ts.map +1 -1
  18. package/dist/canvas/keyboard.js +4 -0
  19. package/dist/canvas/keyboard.js.map +1 -1
  20. package/dist/canvas/palette.d.ts +7 -5
  21. package/dist/canvas/palette.d.ts.map +1 -1
  22. package/dist/canvas/palette.js +13 -9
  23. package/dist/canvas/palette.js.map +1 -1
  24. package/dist/canvas/protocol.d.ts +223 -7
  25. package/dist/canvas/protocol.d.ts.map +1 -1
  26. package/dist/canvas/protocol.js +58 -1
  27. package/dist/canvas/protocol.js.map +1 -1
  28. package/dist/canvas/routes.d.ts +17 -0
  29. package/dist/canvas/routes.d.ts.map +1 -1
  30. package/dist/canvas/routes.js +37 -0
  31. package/dist/canvas/routes.js.map +1 -1
  32. package/dist/canvas/turning.d.ts +7 -0
  33. package/dist/canvas/turning.d.ts.map +1 -1
  34. package/dist/canvas/turning.js +17 -3
  35. package/dist/canvas/turning.js.map +1 -1
  36. package/dist/clipboard.d.ts.map +1 -1
  37. package/dist/clipboard.js +61 -9
  38. package/dist/clipboard.js.map +1 -1
  39. package/dist/effects.d.ts +5 -0
  40. package/dist/effects.d.ts.map +1 -1
  41. package/dist/effects.js +28 -22
  42. package/dist/effects.js.map +1 -1
  43. package/dist/exports.d.ts +19 -11
  44. package/dist/exports.d.ts.map +1 -1
  45. package/dist/exports.js +54 -19
  46. package/dist/exports.js.map +1 -1
  47. package/dist/icons.d.ts +2 -0
  48. package/dist/icons.d.ts.map +1 -1
  49. package/dist/icons.js +39 -6
  50. package/dist/icons.js.map +1 -1
  51. package/dist/index.d.ts +6 -3
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +4 -1
  54. package/dist/index.js.map +1 -1
  55. package/dist/inspector.d.ts.map +1 -1
  56. package/dist/inspector.js +229 -97
  57. package/dist/inspector.js.map +1 -1
  58. package/dist/messages/en.d.ts +55 -20
  59. package/dist/messages/en.d.ts.map +1 -1
  60. package/dist/messages/en.js +59 -26
  61. package/dist/messages/en.js.map +1 -1
  62. package/dist/messages/es.d.ts.map +1 -1
  63. package/dist/messages/es.js +47 -43
  64. package/dist/messages/es.js.map +1 -1
  65. package/dist/messages/index.d.ts +43 -17
  66. package/dist/messages/index.d.ts.map +1 -1
  67. package/dist/messages/keys.d.ts.map +1 -1
  68. package/dist/messages/keys.js +2 -0
  69. package/dist/messages/keys.js.map +1 -1
  70. package/dist/messages/services.d.ts +6 -0
  71. package/dist/messages/services.d.ts.map +1 -1
  72. package/dist/messages/services.js +8 -2
  73. package/dist/messages/services.js.map +1 -1
  74. package/dist/move-file.d.ts +32 -0
  75. package/dist/move-file.d.ts.map +1 -0
  76. package/dist/move-file.js +58 -0
  77. package/dist/move-file.js.map +1 -0
  78. package/dist/node.d.ts +1 -1
  79. package/dist/node.d.ts.map +1 -1
  80. package/dist/node.js.map +1 -1
  81. package/dist/projects.d.ts +65 -1
  82. package/dist/projects.d.ts.map +1 -1
  83. package/dist/projects.js +349 -1
  84. package/dist/projects.js.map +1 -1
  85. package/dist/services/actions.d.ts.map +1 -1
  86. package/dist/services/actions.js +11 -1
  87. package/dist/services/actions.js.map +1 -1
  88. package/dist/services/completion.d.ts.map +1 -1
  89. package/dist/services/completion.js +46 -6
  90. package/dist/services/completion.js.map +1 -1
  91. package/dist/services/hover.d.ts.map +1 -1
  92. package/dist/services/hover.js +3 -2
  93. package/dist/services/hover.js.map +1 -1
  94. package/dist/services/navigation.d.ts +1 -1
  95. package/dist/services/navigation.d.ts.map +1 -1
  96. package/dist/services/navigation.js +2 -1
  97. package/dist/services/navigation.js.map +1 -1
  98. package/dist/session.d.ts +56 -10
  99. package/dist/session.d.ts.map +1 -1
  100. package/dist/session.js +650 -38
  101. package/dist/session.js.map +1 -1
  102. package/dist/targets.d.ts +25 -4
  103. package/dist/targets.d.ts.map +1 -1
  104. package/dist/targets.js +103 -7
  105. package/dist/targets.js.map +1 -1
  106. package/dist/text-places.d.ts.map +1 -1
  107. package/dist/text-places.js +55 -1
  108. package/dist/text-places.js.map +1 -1
  109. package/dist/themes.d.ts.map +1 -1
  110. package/dist/themes.js +11 -1
  111. package/dist/themes.js.map +1 -1
  112. 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
- /** True while the canvas shows the diagram's effects running. */
59
- playing = false;
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 { messages: [], project: { kind: "themeFile", file: location.file, operation } };
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
- /** The plan of `plan` for the host to apply; a refusal drops what waited for the edit. */
742
- planned(view, kind, operations) {
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; ids rename the model's entities. Returns
761
- * undefined for the actions the flow code handles.
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
- if (diagram.elementsById.get(id).kind === "group")
1206
+ const element = diagram.elementsById.get(id);
1207
+ if (element.kind === "group")
786
1208
  continue;
787
- ops.push({ kind: "excludeEntity", diagram: d, entity: id, cascade: action.cascade });
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
- return {
794
- messages: [
795
- {
796
- type: "rejected",
797
- code: "edit.invalid-value",
798
- reason: wording().session.modelRelation(key),
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
- const ops = diagram.c4?.combined.includes(action.id)
835
- ? [{ kind: "setRelationLabel", diagram: d, relation: action.id, label }]
836
- : [{ kind: "setLocalLabel", diagram: d, target: action.id, label }];
837
- return this.planned(view, "setLabel", ops);
838
- }
839
- case "renameId": {
840
- if (!model || !modelIsLocal(diagram)) {
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.unsafe-target",
846
- reason: wording().session.modelElsewhere,
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
- const snapped = this.snap(action.at);
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.twoWay ? { twoWay: true } : {}),
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
- if (element && element.kind !== "group" && box)
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
- const geometric = ["x", "y", "w", "h"].includes(action.field) ||
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
- const result = this.plan(view, action.kind, operations);
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
- return { messages: [{ type: "preview", token, svg: drawn.svg, width, height, offset }] };
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
- if (action.kind === "connect" && action.twoWay)
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];