canvas_erd 0.1.0 → 0.3.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.
@@ -19,7 +19,81 @@ const noticeElement = document.querySelector("#notice");
19
19
  const shortcutHelpButton = document.querySelector("#shortcut-help");
20
20
  const shortcutPanelElement = document.querySelector("#shortcut-panel");
21
21
  const arrowButton = document.querySelector("#add-arrow");
22
+ const frameButton = document.querySelector("#add-frame");
22
23
  const layoutTablesButton = document.querySelector("#layout-tables");
24
+ const noteFormattingElement = document.querySelector("#note-formatting");
25
+ const noteBoldButton = document.querySelector("#note-bold");
26
+ const noteItalicButton = document.querySelector("#note-italic");
27
+ const noteUnderlineButton = document.querySelector("#note-underline");
28
+ const noteParagraphStyleElement = document.querySelector("#note-paragraph-style");
29
+ const noteAlignmentElement = document.querySelector("#note-alignment");
30
+ const tableFormattingElement = document.querySelector("#table-formatting");
31
+ const tableHeaderColorControl = {
32
+ button: document.querySelector("#table-header-color-button"),
33
+ preview: document.querySelector("#table-header-color-preview"),
34
+ menu: document.querySelector("#table-header-color-menu"),
35
+ swatches: document.querySelector("#table-header-color-swatches"),
36
+ custom: document.querySelector("#table-header-custom-color")
37
+ };
38
+ const arrowFormattingElement = document.querySelector("#arrow-formatting");
39
+ const arrowStartHeadElement = document.querySelector("#arrow-start-head");
40
+ const arrowEndHeadElement = document.querySelector("#arrow-end-head");
41
+ const arrowLineStyleElement = document.querySelector("#arrow-line-style");
42
+ const arrowStrokeWidthElement = document.querySelector("#arrow-stroke-width");
43
+ const arrowColorControl = {
44
+ button: document.querySelector("#arrow-color-button"),
45
+ preview: document.querySelector("#arrow-color-preview"),
46
+ menu: document.querySelector("#arrow-color-menu"),
47
+ swatches: document.querySelector("#arrow-color-swatches"),
48
+ custom: document.querySelector("#arrow-custom-color")
49
+ };
50
+ const frameFormattingElement = document.querySelector("#frame-formatting");
51
+ const frameLineStyleElement = document.querySelector("#frame-line-style");
52
+ const frameColorControl = {
53
+ button: document.querySelector("#frame-color-button"),
54
+ preview: document.querySelector("#frame-color-preview"),
55
+ menu: document.querySelector("#frame-color-menu"),
56
+ swatches: document.querySelector("#frame-color-swatches"),
57
+ custom: document.querySelector("#frame-custom-color")
58
+ };
59
+ const codeFormattingElement = document.querySelector("#code-formatting");
60
+ const codeLanguageElement = document.querySelector("#code-language");
61
+ const codeSourceElement = document.querySelector("#code-source");
62
+ const codeLineNumbersButton = document.querySelector("#code-line-numbers");
63
+ const codeSizeElement = document.querySelector("#code-size");
64
+ const codeSourceFieldsElement = document.querySelector("#code-source-fields");
65
+ const codeSourcePathElement = document.querySelector("#code-source-path");
66
+ const codeSourceStartElement = document.querySelector("#code-source-start");
67
+ const codeSourceEndElement = document.querySelector("#code-source-end");
68
+ const sourceFilePathsElement = document.querySelector("#source-file-paths");
69
+ const sourceDialogElement = document.querySelector("#source-dialog");
70
+ const sourceFormElement = document.querySelector("#source-form");
71
+ const sourcePathElement = document.querySelector("#source-path");
72
+ const sourceStartElement = document.querySelector("#source-start");
73
+ const sourceEndElement = document.querySelector("#source-end");
74
+ const sourceErrorElement = document.querySelector("#source-error");
75
+ const sourceSubmitButton = document.querySelector("#source-submit");
76
+ const noteColorControls = [
77
+ {
78
+ property: "fill",
79
+ defaults: CanvasERDDocument.DEFAULT_FOREGROUND_COLORS,
80
+ button: document.querySelector("#note-text-color-button"),
81
+ preview: document.querySelector("#note-text-color-preview"),
82
+ menu: document.querySelector("#note-text-color-menu"),
83
+ swatches: document.querySelector("#note-text-color-swatches"),
84
+ custom: document.querySelector("#note-text-custom-color")
85
+ },
86
+ {
87
+ property: "backgroundColor",
88
+ defaults: CanvasERDDocument.DEFAULT_BACKGROUND_COLORS,
89
+ button: document.querySelector("#note-background-color-button"),
90
+ preview: document.querySelector("#note-background-color-preview"),
91
+ menu: document.querySelector("#note-background-color-menu"),
92
+ swatches: document.querySelector("#note-background-color-swatches"),
93
+ custom: document.querySelector("#note-background-custom-color"),
94
+ transparent: document.querySelector("#note-background-transparent")
95
+ }
96
+ ];
23
97
 
24
98
  let schema;
25
99
  let applicationSchema;
@@ -33,7 +107,11 @@ let lastCanvasPointer = null;
33
107
  const tableObjects = new Map();
34
108
  let relationshipObjects = [];
35
109
  const noteObjects = new Map();
110
+ const codeObjects = new Map();
111
+ const codeHighlightTimers = new Map();
36
112
  const arrowObjects = new Map();
113
+ const frameObjects = new Map();
114
+ const frameLabelObjects = new Map();
37
115
  let noticeTimer;
38
116
  let currentDiagramFilename = null;
39
117
  let diagramsDirectory = "docs/erd";
@@ -44,6 +122,14 @@ let arrowMode = false;
44
122
  let drawingArrow = null;
45
123
  let arrowPreview = null;
46
124
  let editingArrowId = null;
125
+ let frameMode = false;
126
+ let drawingFrame = null;
127
+ let framePreview = null;
128
+ let lastNoteFormatting = CanvasERDDocument.inheritedNoteFormatting();
129
+ let lastCodeLanguage = "ruby";
130
+ let highlighting = { foreground: "#24292f", background: "#f6f8fa", languages: [] };
131
+ let modelDetails = {};
132
+ let sourceDialogPosition = null;
47
133
  const arrowEndpointHandles = new Map();
48
134
  const changeTracker = CanvasERDDocument.createChangeTracker(updateSaveButton);
49
135
  const layoutEngine = new ELK({ workerUrl: "/assets/elk-worker.min.js" });
@@ -145,7 +231,7 @@ function updateTableCaching() {
145
231
 
146
232
  function updateNoteSelectionPadding() {
147
233
  const padding = CanvasERDDocument.noteSelectionPadding(canvas.getZoom());
148
- noteObjects.forEach((note) => {
234
+ [...noteObjects.values(), ...codeObjects.values()].forEach((note) => {
149
235
  if (note.padding === padding) return;
150
236
  note.padding = padding;
151
237
  note.setCoords();
@@ -177,6 +263,7 @@ function truncate(value, length) {
177
263
  function createTableObject(entity) {
178
264
  const width = CanvasERDDocument.CARD_WIDTH;
179
265
  const height = CanvasERDDocument.tableHeight(entity);
266
+ const headerColor = CanvasERDDocument.tableHeaderColor(state, entity.id);
180
267
  const objects = [
181
268
  new fabric.Rect(CanvasERDDocument.topLeft({
182
269
  left: 0,
@@ -195,7 +282,7 @@ function createTableObject(entity) {
195
282
  top: 0,
196
283
  width,
197
284
  height: CanvasERDDocument.HEADER_HEIGHT,
198
- fill: "#243b63",
285
+ fill: headerColor,
199
286
  rx: 8,
200
287
  ry: 8
201
288
  })),
@@ -204,7 +291,7 @@ function createTableObject(entity) {
204
291
  top: CanvasERDDocument.HEADER_HEIGHT - 8,
205
292
  width,
206
293
  height: 8,
207
- fill: "#243b63"
294
+ fill: headerColor
208
295
  })),
209
296
  new fabric.FabricText(truncate(entity.label, 34), CanvasERDDocument.uncachedText({
210
297
  left: 14,
@@ -318,26 +405,73 @@ function relationStyle(relationship) {
318
405
  };
319
406
  }
320
407
 
408
+ function createCardinalityObjects(geometry, style) {
409
+ const markerStyle = { ...style, strokeDashArray: null };
410
+ const objects = geometry.segments.map(({ start, end }) => (
411
+ new fabric.Line([start.x, start.y, end.x, end.y], markerStyle)
412
+ ));
413
+ geometry.circles.forEach(({ center, radius }) => {
414
+ objects.push(new fabric.Circle({
415
+ left: center.x,
416
+ top: center.y,
417
+ originX: "center",
418
+ originY: "center",
419
+ radius,
420
+ fill: "#f7f8fb",
421
+ stroke: style.stroke,
422
+ strokeWidth: style.strokeWidth,
423
+ selectable: false,
424
+ evented: false,
425
+ objectCaching: false
426
+ }));
427
+ });
428
+ if (geometry.label) {
429
+ objects.push(new fabric.FabricText(geometry.label, {
430
+ left: geometry.labelPosition.x,
431
+ top: geometry.labelPosition.y,
432
+ originX: "center",
433
+ originY: "center",
434
+ fontFamily: CanvasERDDocument.CANVAS_FONT_FAMILY,
435
+ fontSize: 10,
436
+ fill: "#526277",
437
+ backgroundColor: "#f7f8fb",
438
+ selectable: false,
439
+ evented: false
440
+ }));
441
+ }
442
+ return objects;
443
+ }
444
+
321
445
  function createRelationshipObjects(relationship, source, destination) {
322
446
  const sourceBounds = tableBounds(source);
323
447
  const destinationBounds = tableBounds(destination);
324
448
  const style = relationStyle(relationship);
325
449
  let connector;
326
- let labelPosition;
450
+ let sourceEndpoint;
451
+ let destinationEndpoint;
327
452
 
328
453
  if (source === destination) {
329
454
  const right = sourceBounds.left + sourceBounds.width;
330
455
  const top = sourceBounds.top;
331
456
  const centerY = top + sourceBounds.height / 2;
332
- const points = [
457
+ sourceEndpoint = CanvasERDDocument.cardinalityEndpointGeometry(
333
458
  { x: right, y: centerY },
334
- { x: right + 44, y: centerY },
335
- { x: right + 44, y: top - 34 },
336
- { x: right - 20, y: top - 34 },
337
- { x: right - 20, y: top }
459
+ { x: 1, y: 0 },
460
+ relationship.cardinality.source
461
+ );
462
+ destinationEndpoint = CanvasERDDocument.cardinalityEndpointGeometry(
463
+ { x: right - 20, y: top },
464
+ { x: 0, y: -1 },
465
+ relationship.cardinality.destination
466
+ );
467
+ const points = [
468
+ sourceEndpoint.shaftPoint,
469
+ { x: right + 48, y: centerY },
470
+ { x: right + 48, y: top - 48 },
471
+ { x: right - 20, y: top - 48 },
472
+ destinationEndpoint.shaftPoint
338
473
  ];
339
474
  connector = new fabric.Polyline(points, style);
340
- labelPosition = { x: right + 48, y: top - 28 };
341
475
  } else {
342
476
  const sourceCenter = {
343
477
  x: sourceBounds.left + sourceBounds.width / 2,
@@ -347,33 +481,31 @@ function createRelationshipObjects(relationship, source, destination) {
347
481
  x: destinationBounds.left + destinationBounds.width / 2,
348
482
  y: destinationBounds.top + destinationBounds.height / 2
349
483
  };
484
+ sourceEndpoint = CanvasERDDocument.relationshipEndpointGeometry(
485
+ sourceBounds,
486
+ destinationCenter,
487
+ relationship.cardinality.source
488
+ );
489
+ destinationEndpoint = CanvasERDDocument.relationshipEndpointGeometry(
490
+ destinationBounds,
491
+ sourceCenter,
492
+ relationship.cardinality.destination
493
+ );
350
494
  connector = new fabric.Line([
351
- sourceCenter.x,
352
- sourceCenter.y,
353
- destinationCenter.x,
354
- destinationCenter.y
495
+ sourceEndpoint.shaftPoint.x,
496
+ sourceEndpoint.shaftPoint.y,
497
+ destinationEndpoint.shaftPoint.x,
498
+ destinationEndpoint.shaftPoint.y
355
499
  ], style);
356
- labelPosition = {
357
- x: (sourceCenter.x + destinationCenter.x) / 2,
358
- y: (sourceCenter.y + destinationCenter.y) / 2
359
- };
360
500
  }
361
501
 
362
- connector.canvasErdType = "relationship";
363
- const label = new fabric.FabricText(CanvasERDDocument.cardinalityLabel(relationship), {
364
- left: labelPosition.x,
365
- top: labelPosition.y,
366
- originX: "center",
367
- originY: "center",
368
- fontFamily: CanvasERDDocument.CANVAS_FONT_FAMILY,
369
- fontSize: 10,
370
- fill: "#526277",
371
- backgroundColor: "#f7f8fb",
372
- selectable: false,
373
- evented: false
374
- });
375
- label.canvasErdType = "relationship";
376
- return [connector, label];
502
+ const objects = [
503
+ connector,
504
+ ...createCardinalityObjects(sourceEndpoint, style),
505
+ ...createCardinalityObjects(destinationEndpoint, style)
506
+ ];
507
+ objects.forEach((object) => { object.canvasErdType = "relationship"; });
508
+ return objects;
377
509
  }
378
510
 
379
511
  function renderRelationships() {
@@ -390,7 +522,7 @@ function renderRelationships() {
390
522
  canvas.add(...objects);
391
523
  });
392
524
 
393
- [...relationshipObjects].reverse().forEach((object) => canvas.sendObjectToBack(object));
525
+ restackCanvasObjects();
394
526
  canvas.requestRenderAll();
395
527
  updateStatus();
396
528
  }
@@ -448,38 +580,666 @@ async function layoutTables() {
448
580
  }
449
581
 
450
582
  function createNoteObject(note) {
583
+ const borderColor = CanvasERDDocument.noteBorderColor(
584
+ CanvasERDDocument.noteFormatting(note).backgroundColor
585
+ );
451
586
  const object = new fabric.Textbox(note.text, CanvasERDDocument.uncachedText({
452
587
  ...CanvasERDDocument.noteCanvasGeometry(note),
453
- fill: "#4a3f12",
454
- backgroundColor: "#fff2a8",
455
- fontFamily: CanvasERDDocument.CANVAS_FONT_FAMILY,
456
- fontSize: 15,
457
- lineHeight: 1.25,
458
588
  padding: CanvasERDDocument.noteSelectionPadding(canvas.getZoom()),
459
- borderColor: "#d09b16",
460
- editingBorderColor: "#d09b16",
461
- cornerColor: "#d09b16",
589
+ borderColor,
590
+ editingBorderColor: borderColor,
591
+ cornerColor: borderColor,
462
592
  transparentCorners: false,
463
593
  lockScalingY: true
464
594
  }));
465
- object._renderBackground = function (context) {
466
- if (!this.backgroundColor) return;
467
-
468
- const dimensions = this._getNonTransformedDimensions();
469
- const bounds = CanvasERDDocument.paddedBackgroundBounds(
470
- dimensions.x,
471
- dimensions.y,
472
- CanvasERDDocument.NOTE_PADDING
473
- );
474
- context.fillStyle = this.backgroundColor;
475
- context.fillRect(bounds.left, bounds.top, bounds.width, bounds.height);
476
- this._removeShadow(context);
477
- };
595
+ object._renderBackground = renderPaddedBackground;
478
596
  object.canvasErdType = "note";
479
597
  object.noteId = note.id;
480
598
  return object;
481
599
  }
482
600
 
601
+ function renderPaddedBackground(context) {
602
+ if (!this.backgroundColor) return;
603
+
604
+ const dimensions = this._getNonTransformedDimensions();
605
+ const bounds = CanvasERDDocument.paddedBackgroundBounds(
606
+ dimensions.x,
607
+ dimensions.y,
608
+ CanvasERDDocument.NOTE_PADDING
609
+ );
610
+ context.fillStyle = this.backgroundColor;
611
+ context.fillRect(bounds.left, bounds.top, bounds.width, bounds.height);
612
+ this._removeShadow(context);
613
+ }
614
+
615
+ function activeNoteObject() {
616
+ const active = canvas?.getActiveObject();
617
+ return active?.canvasErdType === "note" ? active : null;
618
+ }
619
+
620
+ class CodeTextbox extends fabric.Textbox {
621
+ _getLeftOffset() {
622
+ return super._getLeftOffset() + (this.gutterWidth || 0);
623
+ }
624
+
625
+ _wrapText(lines, desiredWidth) {
626
+ const wrapped = super._wrapText(lines, desiredWidth - (this.gutterWidth || 0));
627
+ this.dynamicMinWidth += this.gutterWidth || 0;
628
+ return wrapped;
629
+ }
630
+
631
+ gutterLabelAt(event) {
632
+ const block = findCodeBlock(this.codeBlockId);
633
+ if (!this.gutterWidth || !block || !this.canvas) return undefined;
634
+
635
+ const point = this.canvas.getScenePoint(event).transform(fabric.util.invertTransform(this.calcTransformMatrix()));
636
+ const x = point.x + this.width / 2;
637
+ if (x < -CanvasERDDocument.NOTE_PADDING || x > this.gutterWidth - this.fontSize * 0.3) return undefined;
638
+
639
+ const row = CanvasERDDocument.codeRowAt(
640
+ this._textLines.map((_, index) => this.getHeightOfLine(index)),
641
+ point.y - this._getTopOffset()
642
+ );
643
+ if (row < 0) return null;
644
+ const labels = CanvasERDDocument.codeLineLabels(block, this.text.split("\n").length);
645
+ return labels[this._styleMap[row].line] ?? null;
646
+ }
647
+
648
+ mouseUpHandler(options) {
649
+ if (this.gutterLabelAt(options.e) !== undefined) return;
650
+ super.mouseUpHandler(options);
651
+ }
652
+ }
653
+
654
+ function toggleCodeLineHighlight(object, label, extend) {
655
+ const block = findCodeBlock(object.codeBlockId);
656
+ if (!block) return;
657
+
658
+ const labels = CanvasERDDocument.codeLineLabels(block, object.text.split("\n").length);
659
+ block.highlightedLines = extend && object.highlightAnchor !== undefined
660
+ ? CanvasERDDocument.highlightLineRange(block.highlightedLines, labels, object.highlightAnchor, label)
661
+ : CanvasERDDocument.toggleHighlightedLine(block.highlightedLines, label);
662
+ object.highlightAnchor = label;
663
+ canvas.requestRenderAll();
664
+ markDirty();
665
+ }
666
+
667
+ function codeGutterWidth(block, text) {
668
+ if (!block.showLineNumbers) return 0;
669
+ const labels = CanvasERDDocument.codeLineLabels(block, text.split("\n").length);
670
+ return CanvasERDDocument.codeGutterWidth(labels, CanvasERDDocument.codeFontSize(block));
671
+ }
672
+
673
+ function renderCodeBackground(context) {
674
+ renderPaddedBackground.call(this, context);
675
+ const block = findCodeBlock(this.codeBlockId);
676
+ if (!block) return;
677
+
678
+ const labels = CanvasERDDocument.codeLineLabels(block, this.text.split("\n").length);
679
+ const highlighted = new Set(block.highlightedLines || []);
680
+ const dimensions = this._getNonTransformedDimensions();
681
+ const padding = CanvasERDDocument.NOTE_PADDING;
682
+ context.save();
683
+ if (highlighted.size > 0) {
684
+ let rowTop = this._getTopOffset();
685
+ context.fillStyle = "#fff8c5";
686
+ this._textLines.forEach((_, index) => {
687
+ const height = this.getHeightOfLine(index);
688
+ if (highlighted.has(labels[this._styleMap[index].line])) {
689
+ context.fillRect(-dimensions.x / 2 - padding, rowTop, dimensions.x + padding * 2, height);
690
+ }
691
+ rowTop += height;
692
+ });
693
+ }
694
+ if (!this.gutterWidth) {
695
+ context.restore();
696
+ return;
697
+ }
698
+
699
+ const right = -this.width / 2 + this.gutterWidth - this.fontSize * 0.6;
700
+ const separator = -this.width / 2 + this.gutterWidth - this.fontSize * 0.3;
701
+ let top = this._getTopOffset();
702
+ context.strokeStyle = "#d0d7de";
703
+ context.lineWidth = 1;
704
+ context.beginPath();
705
+ context.moveTo(separator, -dimensions.y / 2 - CanvasERDDocument.NOTE_PADDING);
706
+ context.lineTo(separator, dimensions.y / 2 + CanvasERDDocument.NOTE_PADDING);
707
+ context.stroke();
708
+ context.font = `${this.fontSize}px ${this.fontFamily}`;
709
+ context.fillStyle = "#8c959f";
710
+ context.textAlign = "right";
711
+ this._textLines.forEach((_, index) => {
712
+ const line = this._styleMap[index];
713
+ const label = line.offset === 0 ? labels[line.line] : null;
714
+ if (label !== null && label !== undefined) {
715
+ const baseline = top + this.getHeightOfLineImpl(index) - this.fontSize * this._fontSizeFraction;
716
+ context.fillText(String(label), right, baseline);
717
+ }
718
+ top += this.getHeightOfLine(index);
719
+ });
720
+ context.restore();
721
+ }
722
+
723
+ function updateCodeGutter(object) {
724
+ const block = findCodeBlock(object.codeBlockId);
725
+ const width = block ? codeGutterWidth(block, object.text) : 0;
726
+ if ((object.gutterWidth || 0) === width) return;
727
+ object.gutterWidth = width;
728
+ object.initDimensions();
729
+ object.setCoords();
730
+ }
731
+
732
+ function toggleCodeLineNumbers() {
733
+ const object = activeCodeObject();
734
+ const block = object && findCodeBlock(object.codeBlockId);
735
+ if (!block) return;
736
+
737
+ block.showLineNumbers = !block.showLineNumbers;
738
+ const previousWidth = object.gutterWidth || 0;
739
+ object.gutterWidth = codeGutterWidth(block, object.text);
740
+ object.set({ width: object.width + object.gutterWidth - previousWidth });
741
+ object.initDimensions();
742
+ object.setCoords();
743
+ updateObjectState(object);
744
+ renderArrows();
745
+ updateCodeFormattingControls();
746
+ }
747
+
748
+ function applyCodeSize(size) {
749
+ const object = activeCodeObject();
750
+ const block = object && findCodeBlock(object.codeBlockId);
751
+ if (!block) return;
752
+
753
+ const previousFontSize = CanvasERDDocument.codeFontSize(block);
754
+ block.size = size;
755
+ const fontSize = CanvasERDDocument.codeFontSize(block);
756
+ object.set({ fontSize, width: object.width * fontSize / previousFontSize });
757
+ object.gutterWidth = codeGutterWidth(block, object.text);
758
+ object.initDimensions();
759
+ object.setCoords();
760
+ updateObjectState(object);
761
+ renderArrows();
762
+ }
763
+
764
+ function createCodeObject(block) {
765
+ const borderColor = CanvasERDDocument.noteBorderColor(highlighting.background);
766
+ const object = new CodeTextbox(block.code, CanvasERDDocument.uncachedText({
767
+ ...CanvasERDDocument.codeCanvasGeometry(block),
768
+ gutterWidth: codeGutterWidth(block, block.code),
769
+ fill: highlighting.foreground,
770
+ backgroundColor: highlighting.background,
771
+ editable: !block.source,
772
+ padding: CanvasERDDocument.noteSelectionPadding(canvas.getZoom()),
773
+ borderColor,
774
+ editingBorderColor: borderColor,
775
+ cornerColor: borderColor,
776
+ transparentCorners: false,
777
+ lockScalingY: true
778
+ }));
779
+ object._renderBackground = renderCodeBackground;
780
+ object.canvasErdType = "code";
781
+ object.codeBlockId = block.id;
782
+ highlightCodeObject(object);
783
+ return object;
784
+ }
785
+
786
+ function findCodeBlock(id) {
787
+ return state.codeBlocks.find((candidate) => candidate.id === id);
788
+ }
789
+
790
+ function activeCodeObject() {
791
+ const active = canvas?.getActiveObject();
792
+ return active?.canvasErdType === "code" ? active : null;
793
+ }
794
+
795
+ function scheduleCodeHighlight(object, delay = 300) {
796
+ clearTimeout(codeHighlightTimers.get(object.codeBlockId));
797
+ codeHighlightTimers.set(object.codeBlockId, setTimeout(() => highlightCodeObject(object), delay));
798
+ }
799
+
800
+ async function highlightCodeObject(object) {
801
+ codeHighlightTimers.delete(object.codeBlockId);
802
+ const block = findCodeBlock(object.codeBlockId);
803
+ if (!block) return;
804
+
805
+ const code = object.text;
806
+ try {
807
+ const response = await fetch("/api/highlight", {
808
+ method: "POST",
809
+ headers: { "content-type": "application/json" },
810
+ body: JSON.stringify({ code, language: block.language })
811
+ });
812
+ if (!response.ok) throw new Error(await response.text());
813
+
814
+ const result = await response.json();
815
+ if (codeObjects.get(block.id) !== object || object.text !== code) return;
816
+ if (result.code !== code) {
817
+ if (object.isEditing) return;
818
+ object.set({ text: result.code });
819
+ block.code = result.code;
820
+ markDirty();
821
+ }
822
+ object.styles = CanvasERDDocument.codeTokenStyles(result.runs);
823
+ object.initDimensions();
824
+ object.setCoords();
825
+ canvas.requestRenderAll();
826
+ } catch (error) {
827
+ showNotice(`Could not highlight code: ${error.message}`, true);
828
+ }
829
+ }
830
+
831
+ function updateCodeFormattingControls() {
832
+ const object = activeCodeObject();
833
+ const block = object && findCodeBlock(object.codeBlockId);
834
+ codeFormattingElement.hidden = !block;
835
+ if (!block) return;
836
+
837
+ lastCodeLanguage = block.language;
838
+ codeLanguageElement.value = block.language;
839
+ codeLineNumbersButton.setAttribute("aria-pressed", String(Boolean(block.showLineNumbers)));
840
+ codeSizeElement.value = block.size || "medium";
841
+ const fileSource = block.source?.path ? block.source : null;
842
+ codeSourceElement.hidden = !block.source?.model;
843
+ codeSourceElement.textContent = block.source?.model ? CanvasERDDocument.sourceReferenceLabel(block.source) : "";
844
+ codeSourceFieldsElement.hidden = !fileSource;
845
+ codeSourcePathElement.value = fileSource?.path || "";
846
+ codeSourceStartElement.value = fileSource?.startLine || "";
847
+ codeSourceEndElement.value = fileSource?.endLine || "";
848
+ }
849
+
850
+ async function applyCodeSource() {
851
+ const object = activeCodeObject();
852
+ const block = object && findCodeBlock(object.codeBlockId);
853
+ if (!block?.source?.path) return;
854
+
855
+ const source = CanvasERDDocument.sourceFromFields(
856
+ codeSourcePathElement.value,
857
+ codeSourceStartElement.value,
858
+ codeSourceEndElement.value
859
+ );
860
+ try {
861
+ if (!source) throw new Error("Enter a source file path.");
862
+ const result = await fetchSource(source);
863
+ if (source.path !== block.source.path) block.language = result.language;
864
+ block.source = source;
865
+ replaceCodeText(object, block, result.code);
866
+ markDirty();
867
+ } catch (error) {
868
+ showNotice(`Could not change source file: ${error.message}`, true);
869
+ }
870
+ if (activeCodeObject() === object) updateCodeFormattingControls();
871
+ }
872
+
873
+ function replaceCodeText(object, block, code) {
874
+ block.code = code;
875
+ object.set({ text: code });
876
+ object.styles = {};
877
+ updateCodeGutter(object);
878
+ object.setCoords();
879
+ renderArrows();
880
+ highlightCodeObject(object);
881
+ }
882
+
883
+ async function loadSourceFilePaths() {
884
+ try {
885
+ const response = await fetch("/api/source_files", { cache: "no-store" });
886
+ if (!response.ok) throw new Error(await response.text());
887
+
888
+ const result = await response.json();
889
+ sourceFilePathsElement.replaceChildren(...result.paths.map((path) => {
890
+ const option = document.createElement("option");
891
+ option.value = path;
892
+ return option;
893
+ }));
894
+ } catch (error) {
895
+ showNotice(`Could not list source files: ${error.message}`, true);
896
+ }
897
+ }
898
+
899
+ function renderCodeLanguages() {
900
+ codeLanguageElement.replaceChildren(...highlighting.languages.map((language) => {
901
+ const option = document.createElement("option");
902
+ option.value = language.tag;
903
+ option.textContent = language.title;
904
+ return option;
905
+ }));
906
+ }
907
+
908
+ function applyCodeLanguage(language) {
909
+ const object = activeCodeObject();
910
+ const block = object && findCodeBlock(object.codeBlockId);
911
+ if (!block) return;
912
+
913
+ block.language = language;
914
+ lastCodeLanguage = language;
915
+ highlightCodeObject(object);
916
+ markDirty();
917
+ }
918
+
919
+ function renderCodeBlocks() {
920
+ state.codeBlocks.forEach((block) => {
921
+ const object = createCodeObject(block);
922
+ codeObjects.set(block.id, object);
923
+ canvas.add(object);
924
+ });
925
+ }
926
+
927
+ function sourceUrl(source) {
928
+ if (source.model) {
929
+ return `/api/model_detail?${new URLSearchParams({ model: source.model, detail: source.detail })}`;
930
+ }
931
+
932
+ const query = new URLSearchParams({ path: source.path });
933
+ if (source.startLine) query.set("start", source.startLine);
934
+ if (source.endLine) query.set("end", source.endLine);
935
+ return `/api/source?${query}`;
936
+ }
937
+
938
+ async function fetchSource(source) {
939
+ const response = await fetch(sourceUrl(source), { cache: "no-store" });
940
+ if (!response.ok) throw new Error(await response.text());
941
+ return response.json();
942
+ }
943
+
944
+ async function refreshSourceCodeBlocks() {
945
+ const failures = [];
946
+ await Promise.all(state.codeBlocks.filter((block) => block.source).map(async (block) => {
947
+ try {
948
+ const result = await fetchSource(block.source);
949
+ const object = codeObjects.get(block.id);
950
+ if (result.lineNumbers) block.lineNumbers = result.lineNumbers;
951
+ canvas.requestRenderAll();
952
+ if (result.code === block.code || !object) return;
953
+ replaceCodeText(object, block, result.code);
954
+ } catch (error) {
955
+ failures.push(`${CanvasERDDocument.sourceReferenceLabel(block.source)}: ${error.message}`);
956
+ }
957
+ }));
958
+ if (failures.length > 0) showNotice(`Could not refresh source files: ${failures.join("; ")}`, true);
959
+ }
960
+
961
+ function activeTableEntityIds() {
962
+ return CanvasERDDocument.selectedEntityIds(canvas?.getActiveObject());
963
+ }
964
+
965
+ function updateNoteFormattingControls() {
966
+ const note = activeNoteObject();
967
+ noteFormattingElement.hidden = !note;
968
+ if (!note) return;
969
+
970
+ const formatting = CanvasERDDocument.noteFormatting(note);
971
+ lastNoteFormatting = CanvasERDDocument.inheritedNoteFormatting(formatting);
972
+ noteBoldButton.setAttribute("aria-pressed", String(formatting.fontWeight === "bold"));
973
+ noteItalicButton.setAttribute("aria-pressed", String(formatting.fontStyle === "italic"));
974
+ noteUnderlineButton.setAttribute("aria-pressed", String(formatting.underline));
975
+ noteColorControls.forEach((control) => {
976
+ const color = formatting[control.property];
977
+ control.preview.style.backgroundColor = color;
978
+ control.preview.classList.toggle("transparent-color", color === "transparent");
979
+ if (control.transparent) {
980
+ control.transparent.setAttribute("aria-pressed", String(color === "transparent"));
981
+ }
982
+ if (/^#[0-9a-f]{6}$/i.test(color)) control.custom.value = color;
983
+ });
984
+ noteParagraphStyleElement.value = formatting.paragraphStyle;
985
+ noteAlignmentElement.value = formatting.textAlign;
986
+ }
987
+
988
+ function closeNoteFormattingMenus() {
989
+ let closed = false;
990
+ noteColorControls.forEach((control) => {
991
+ if (!control.menu.hidden) closed = true;
992
+ control.menu.hidden = true;
993
+ control.button.setAttribute("aria-expanded", "false");
994
+ });
995
+ if (!tableHeaderColorControl.menu.hidden) closed = true;
996
+ tableHeaderColorControl.menu.hidden = true;
997
+ tableHeaderColorControl.button.setAttribute("aria-expanded", "false");
998
+ if (!arrowColorControl.menu.hidden) closed = true;
999
+ arrowColorControl.menu.hidden = true;
1000
+ arrowColorControl.button.setAttribute("aria-expanded", "false");
1001
+ if (!frameColorControl.menu.hidden) closed = true;
1002
+ frameColorControl.menu.hidden = true;
1003
+ frameColorControl.button.setAttribute("aria-expanded", "false");
1004
+ return closed;
1005
+ }
1006
+
1007
+ function renderColorSwatches(element, colors, defaults, currentColor, applyColor) {
1008
+ const fragment = document.createDocumentFragment();
1009
+ const sections = CanvasERDDocument.colorPaletteSections(colors, defaults);
1010
+ const appendSwatches = (swatches) => swatches.forEach((color) => {
1011
+ const swatch = document.createElement("button");
1012
+ swatch.className = "color-swatch";
1013
+ swatch.type = "button";
1014
+ swatch.title = color;
1015
+ swatch.setAttribute("aria-label", `Use ${color}`);
1016
+ swatch.setAttribute("aria-pressed", String(color === currentColor));
1017
+ swatch.style.backgroundColor = color;
1018
+ swatch.classList.toggle("transparent-color", color === "transparent");
1019
+ swatch.addEventListener("click", () => {
1020
+ applyColor(color);
1021
+ closeNoteFormattingMenus();
1022
+ });
1023
+ fragment.append(swatch);
1024
+ });
1025
+ appendSwatches(sections.defaults);
1026
+ if (sections.custom.length > 0) {
1027
+ const separator = document.createElement("div");
1028
+ separator.className = "swatch-separator";
1029
+ separator.setAttribute("role", "separator");
1030
+ fragment.append(separator);
1031
+ appendSwatches(sections.custom);
1032
+ }
1033
+ element.replaceChildren(fragment);
1034
+ }
1035
+
1036
+ function renderNoteColorSwatches(control) {
1037
+ const note = activeNoteObject();
1038
+ if (!note) return;
1039
+
1040
+ const currentColor = CanvasERDDocument.noteFormatting(note)[control.property].toLowerCase();
1041
+ renderColorSwatches(
1042
+ control.swatches,
1043
+ CanvasERDDocument.noteColorSwatches(state.notes, control.property, control.defaults),
1044
+ control.defaults,
1045
+ currentColor,
1046
+ (color) => applyNoteFormatting({ [control.property]: color })
1047
+ );
1048
+ }
1049
+
1050
+ function toggleNoteColorMenu(control) {
1051
+ const opening = control.menu.hidden;
1052
+ closeNoteFormattingMenus();
1053
+ if (!opening || !activeNoteObject()) return;
1054
+
1055
+ renderNoteColorSwatches(control);
1056
+ control.menu.hidden = false;
1057
+ control.button.setAttribute("aria-expanded", "true");
1058
+ }
1059
+
1060
+ function updateTableFormattingControls() {
1061
+ const entityIds = activeTableEntityIds();
1062
+ tableFormattingElement.hidden = entityIds.length === 0;
1063
+ if (entityIds.length === 0) return;
1064
+
1065
+ const color = CanvasERDDocument.selectedTableHeaderColor(state, entityIds);
1066
+ tableHeaderColorControl.preview.style.backgroundColor = color || "#ffffff";
1067
+ tableHeaderColorControl.preview.classList.toggle("mixed-color", color === null);
1068
+ if (color) tableHeaderColorControl.custom.value = color;
1069
+ }
1070
+
1071
+ function applyTableHeaderColor(color) {
1072
+ const entityIds = activeTableEntityIds();
1073
+ if (entityIds.length === 0) return;
1074
+
1075
+ state = CanvasERDDocument.setTableHeaderColor(state, entityIds, color);
1076
+ entityIds.forEach((entityId) => {
1077
+ const table = tableObjects.get(entityId);
1078
+ if (!table) return;
1079
+ table.getObjects().slice(1, 3).forEach((header) => header.set({ fill: color }));
1080
+ table.dirty = true;
1081
+ });
1082
+ updateTableFormattingControls();
1083
+ canvas.requestRenderAll();
1084
+ markDirty();
1085
+ }
1086
+
1087
+ function renderTableHeaderColorSwatches() {
1088
+ const entityIds = activeTableEntityIds();
1089
+ if (entityIds.length === 0) return;
1090
+
1091
+ renderColorSwatches(
1092
+ tableHeaderColorControl.swatches,
1093
+ CanvasERDDocument.tableHeaderColorSwatches(state.tableHeaderColors),
1094
+ CanvasERDDocument.DEFAULT_TABLE_HEADER_COLORS,
1095
+ CanvasERDDocument.selectedTableHeaderColor(state, entityIds),
1096
+ applyTableHeaderColor
1097
+ );
1098
+ }
1099
+
1100
+ function toggleTableHeaderColorMenu() {
1101
+ const opening = tableHeaderColorControl.menu.hidden;
1102
+ closeNoteFormattingMenus();
1103
+ if (!opening || activeTableEntityIds().length === 0) return;
1104
+
1105
+ renderTableHeaderColorSwatches();
1106
+ tableHeaderColorControl.menu.hidden = false;
1107
+ tableHeaderColorControl.button.setAttribute("aria-expanded", "true");
1108
+ }
1109
+
1110
+ function editingArrow() {
1111
+ return state.arrows.find((arrow) => arrow.id === editingArrowId) || null;
1112
+ }
1113
+
1114
+ function updateArrowFormattingControls() {
1115
+ const arrow = editingArrow();
1116
+ arrowFormattingElement.hidden = !arrow;
1117
+ if (!arrow) return;
1118
+
1119
+ const formatting = CanvasERDDocument.arrowFormatting(arrow);
1120
+ arrowColorControl.preview.style.backgroundColor = formatting.color;
1121
+ if (/^#[0-9a-f]{6}$/i.test(formatting.color)) arrowColorControl.custom.value = formatting.color;
1122
+ arrowStartHeadElement.value = formatting.startHead;
1123
+ arrowEndHeadElement.value = formatting.endHead;
1124
+ arrowLineStyleElement.value = formatting.lineStyle;
1125
+ arrowStrokeWidthElement.value = formatting.strokeWidth;
1126
+ }
1127
+
1128
+ function applyArrowFormatting(changes) {
1129
+ const arrow = editingArrow();
1130
+ if (!arrow) return;
1131
+
1132
+ Object.assign(arrow, CanvasERDDocument.arrowFormatting({ ...arrow, ...changes }));
1133
+ renderArrows();
1134
+ updateArrowFormattingControls();
1135
+ markDirty();
1136
+ }
1137
+
1138
+ function renderArrowColorSwatches() {
1139
+ const arrow = editingArrow();
1140
+ if (!arrow) return;
1141
+
1142
+ renderColorSwatches(
1143
+ arrowColorControl.swatches,
1144
+ CanvasERDDocument.arrowColorSwatches(state.arrows),
1145
+ CanvasERDDocument.DEFAULT_FOREGROUND_COLORS,
1146
+ CanvasERDDocument.arrowFormatting(arrow).color.toLowerCase(),
1147
+ (color) => applyArrowFormatting({ color })
1148
+ );
1149
+ }
1150
+
1151
+ function toggleArrowColorMenu() {
1152
+ const opening = arrowColorControl.menu.hidden;
1153
+ closeNoteFormattingMenus();
1154
+ if (!opening || !editingArrow()) return;
1155
+
1156
+ renderArrowColorSwatches();
1157
+ arrowColorControl.menu.hidden = false;
1158
+ arrowColorControl.button.setAttribute("aria-expanded", "true");
1159
+ }
1160
+
1161
+ function activeFrame() {
1162
+ const active = canvas?.getActiveObject();
1163
+ const frameId = ["frame", "frame-label"].includes(active?.canvasErdType) ? active.frameId : null;
1164
+ return state.frames.find((frame) => frame.id === frameId) || null;
1165
+ }
1166
+
1167
+ function updateFrameFormattingControls() {
1168
+ const frame = activeFrame();
1169
+ frameFormattingElement.hidden = !frame;
1170
+ if (!frame) return;
1171
+
1172
+ const formatting = CanvasERDDocument.frameFormatting(frame);
1173
+ frameColorControl.preview.style.backgroundColor = formatting.color;
1174
+ if (/^#[0-9a-f]{6}$/i.test(formatting.color)) frameColorControl.custom.value = formatting.color;
1175
+ frameLineStyleElement.value = formatting.lineStyle;
1176
+ }
1177
+
1178
+ function applyFrameFormatting(changes) {
1179
+ const frame = activeFrame();
1180
+ if (!frame) return;
1181
+
1182
+ Object.assign(frame, CanvasERDDocument.frameFormatting({ ...frame, ...changes }));
1183
+ frameObjects.get(frame.id)?.set({
1184
+ stroke: frame.color,
1185
+ strokeDashArray: CanvasERDDocument.arrowStrokeDashArray(frame.lineStyle),
1186
+ strokeLineCap: frame.lineStyle === "dotted" ? "round" : "butt"
1187
+ });
1188
+ frameLabelObjects.get(frame.id)?.set({ fill: frame.color });
1189
+ updateFrameFormattingControls();
1190
+ canvas.requestRenderAll();
1191
+ markDirty();
1192
+ }
1193
+
1194
+ function renderFrameColorSwatches() {
1195
+ const frame = activeFrame();
1196
+ if (!frame) return;
1197
+
1198
+ renderColorSwatches(
1199
+ frameColorControl.swatches,
1200
+ CanvasERDDocument.frameColorSwatches(state.frames),
1201
+ CanvasERDDocument.DEFAULT_FOREGROUND_COLORS,
1202
+ CanvasERDDocument.frameFormatting(frame).color.toLowerCase(),
1203
+ (color) => applyFrameFormatting({ color })
1204
+ );
1205
+ }
1206
+
1207
+ function toggleFrameColorMenu() {
1208
+ const opening = frameColorControl.menu.hidden;
1209
+ closeNoteFormattingMenus();
1210
+ if (!opening || !activeFrame()) return;
1211
+
1212
+ renderFrameColorSwatches();
1213
+ frameColorControl.menu.hidden = false;
1214
+ frameColorControl.button.setAttribute("aria-expanded", "true");
1215
+ }
1216
+
1217
+ function applyNoteFormatting(formatting) {
1218
+ const note = activeNoteObject();
1219
+ if (!note) return;
1220
+
1221
+ note.set(formatting);
1222
+ const borderColor = CanvasERDDocument.noteBorderColor(note.backgroundColor);
1223
+ note.set({ borderColor, editingBorderColor: borderColor, cornerColor: borderColor });
1224
+ note.setCoords();
1225
+ updateObjectState(note);
1226
+ updateNoteFormattingControls();
1227
+ canvas.requestRenderAll();
1228
+ }
1229
+
1230
+ function toggleNoteFormatting(formatting) {
1231
+ const note = activeNoteObject();
1232
+ if (!note) return;
1233
+
1234
+ if (formatting === "bold") {
1235
+ applyNoteFormatting({ fontWeight: note.fontWeight === "bold" ? "normal" : "bold" });
1236
+ } else if (formatting === "italic") {
1237
+ applyNoteFormatting({ fontStyle: note.fontStyle === "italic" ? "normal" : "italic" });
1238
+ } else if (formatting === "underline") {
1239
+ applyNoteFormatting({ underline: !note.underline });
1240
+ }
1241
+ }
1242
+
483
1243
  function renderNotes() {
484
1244
  state.notes.forEach((note) => {
485
1245
  const object = createNoteObject(note);
@@ -488,10 +1248,156 @@ function renderNotes() {
488
1248
  });
489
1249
  }
490
1250
 
1251
+ function createFrameObject(frame) {
1252
+ const formatting = CanvasERDDocument.frameFormatting(frame);
1253
+ const object = new fabric.Rect(CanvasERDDocument.topLeft({
1254
+ left: frame.x,
1255
+ top: frame.y,
1256
+ width: frame.width,
1257
+ height: frame.height,
1258
+ fill: "transparent",
1259
+ stroke: formatting.color,
1260
+ strokeWidth: 2,
1261
+ strokeDashArray: CanvasERDDocument.arrowStrokeDashArray(formatting.lineStyle),
1262
+ strokeLineCap: formatting.lineStyle === "dotted" ? "round" : "butt",
1263
+ strokeUniform: true,
1264
+ objectCaching: false,
1265
+ perPixelTargetFind: true,
1266
+ padding: 4,
1267
+ borderColor: "#2563eb",
1268
+ cornerColor: "#2563eb",
1269
+ transparentCorners: false,
1270
+ lockRotation: true,
1271
+ lockSkewingX: true,
1272
+ lockSkewingY: true
1273
+ }));
1274
+ object.setControlsVisibility({ mtr: false });
1275
+ object.canvasErdType = "frame";
1276
+ object.frameId = frame.id;
1277
+ return object;
1278
+ }
1279
+
1280
+ function createFrameLabelObject(frame) {
1281
+ const label = typeof frame.label === "string" ? frame.label : "Frame";
1282
+ const object = new fabric.IText(label, CanvasERDDocument.uncachedText({
1283
+ ...CanvasERDDocument.frameLabelCanvasStyle(frame),
1284
+ fontFamily: CanvasERDDocument.CANVAS_FONT_FAMILY,
1285
+ fontSize: 15,
1286
+ fontWeight: "bold",
1287
+ padding: 3,
1288
+ hasControls: false,
1289
+ lockMovementX: true,
1290
+ lockMovementY: true,
1291
+ borderColor: "#2563eb",
1292
+ editingBorderColor: "#2563eb"
1293
+ }));
1294
+ object.canvasErdType = "frame-label";
1295
+ object.frameId = frame.id;
1296
+ return object;
1297
+ }
1298
+
1299
+ function renderFrames() {
1300
+ frameObjects.forEach((object) => canvas.remove(object));
1301
+ frameLabelObjects.forEach((object) => canvas.remove(object));
1302
+ frameObjects.clear();
1303
+ frameLabelObjects.clear();
1304
+ state.frames.forEach((frame) => {
1305
+ const object = createFrameObject(frame);
1306
+ const label = createFrameLabelObject(frame);
1307
+ frameObjects.set(frame.id, object);
1308
+ frameLabelObjects.set(frame.id, label);
1309
+ canvas.add(object, label);
1310
+ });
1311
+ restackCanvasObjects();
1312
+ canvas.requestRenderAll();
1313
+ }
1314
+
1315
+ function renderFramePreview() {
1316
+ if (framePreview) canvas.remove(framePreview);
1317
+ const bounds = CanvasERDDocument.frameBounds(drawingFrame.start, drawingFrame.end);
1318
+ framePreview = new fabric.Rect(CanvasERDDocument.topLeft({
1319
+ left: bounds.x,
1320
+ top: bounds.y,
1321
+ width: bounds.width,
1322
+ height: bounds.height,
1323
+ fill: "rgba(37, 99, 235, 0.03)",
1324
+ stroke: "#2563eb",
1325
+ strokeWidth: 2,
1326
+ strokeDashArray: [7, 5],
1327
+ selectable: false,
1328
+ evented: false,
1329
+ objectCaching: false
1330
+ }));
1331
+ framePreview.canvasErdType = "frame-preview";
1332
+ canvas.add(framePreview);
1333
+ restackCanvasObjects();
1334
+ canvas.requestRenderAll();
1335
+ }
1336
+
1337
+ function setFrameMode(active) {
1338
+ if (active && arrowMode) cancelArrowDrawing();
1339
+ if (active && editingArrowId) finishArrowEndpointEditing();
1340
+ frameMode = active;
1341
+ frameButton.setAttribute("aria-pressed", String(active));
1342
+ canvas.selection = !active;
1343
+ canvas.setCursor(active ? "crosshair" : "default");
1344
+ [
1345
+ ...frameObjects.values(),
1346
+ ...frameLabelObjects.values(),
1347
+ ...tableObjects.values(),
1348
+ ...noteObjects.values(),
1349
+ ...codeObjects.values(),
1350
+ ...arrowObjects.values()
1351
+ ].forEach((object) => {
1352
+ object.selectable = !active;
1353
+ object.evented = !active;
1354
+ });
1355
+ if (active) canvas.discardActiveObject();
1356
+ canvas.requestRenderAll();
1357
+ }
1358
+
1359
+ function cancelFrameDrawing() {
1360
+ if (framePreview) canvas.remove(framePreview);
1361
+ framePreview = null;
1362
+ drawingFrame = null;
1363
+ setFrameMode(false);
1364
+ }
1365
+
1366
+ function finishFrameDrawing(point) {
1367
+ if (!drawingFrame) return;
1368
+ drawingFrame.end = point;
1369
+ const bounds = CanvasERDDocument.frameBounds(drawingFrame.start, drawingFrame.end);
1370
+ if (framePreview) canvas.remove(framePreview);
1371
+ framePreview = null;
1372
+
1373
+ if (bounds.width >= 40 && bounds.height >= 30) {
1374
+ const frame = {
1375
+ id: globalThis.crypto && crypto.randomUUID ? crypto.randomUUID() : `frame-${Date.now()}`,
1376
+ label: "Frame",
1377
+ ...bounds,
1378
+ ...CanvasERDDocument.DEFAULT_FRAME_FORMATTING
1379
+ };
1380
+ state.frames.push(frame);
1381
+ drawingFrame = null;
1382
+ renderFrames();
1383
+ setFrameMode(false);
1384
+ const label = frameLabelObjects.get(frame.id);
1385
+ canvas.setActiveObject(label);
1386
+ label.enterEditing();
1387
+ label.selectAll();
1388
+ canvas.requestRenderAll();
1389
+ markDirty();
1390
+ } else {
1391
+ drawingFrame = null;
1392
+ setFrameMode(false);
1393
+ }
1394
+ }
1395
+
491
1396
  function attachmentTarget(attachment) {
492
1397
  if (!attachment) return null;
493
1398
  if (attachment.type === "entity") return tableObjects.get(attachment.id);
494
1399
  if (attachment.type === "note") return noteObjects.get(attachment.id);
1400
+ if (attachment.type === "code") return codeObjects.get(attachment.id);
495
1401
  return null;
496
1402
  }
497
1403
 
@@ -506,7 +1412,7 @@ function resolveArrowEndpoint(endpoint) {
506
1412
  }
507
1413
 
508
1414
  function attachableTargetAt(point) {
509
- const objects = [...noteObjects.values(), ...tableObjects.values()].reverse();
1415
+ const objects = [...noteObjects.values(), ...codeObjects.values(), ...tableObjects.values()].reverse();
510
1416
  return objects.find((object) => {
511
1417
  const bounds = object.getBoundingRect();
512
1418
  return point.x >= bounds.left && point.x <= bounds.left + bounds.width &&
@@ -530,13 +1436,58 @@ function arrowEndpoint(eventData) {
530
1436
  return endpointAt(point, eventData.target, eventData.e.ctrlKey);
531
1437
  }
532
1438
 
1439
+ function drawArrowHead(context, point, angle, style, color, strokeWidth) {
1440
+ if (style === "none") return;
1441
+
1442
+ const { length, halfWidth, radius } = CanvasERDDocument.arrowHeadGeometry(style, strokeWidth);
1443
+ context.save();
1444
+ context.translate(point.x, point.y);
1445
+ context.rotate(angle);
1446
+ context.fillStyle = color;
1447
+ context.strokeStyle = color;
1448
+ context.lineWidth = strokeWidth;
1449
+ context.lineCap = "round";
1450
+ context.lineJoin = "round";
1451
+ context.setLineDash([]);
1452
+ context.beginPath();
1453
+ if (style === "open") {
1454
+ context.moveTo(-length, -halfWidth);
1455
+ context.lineTo(0, 0);
1456
+ context.lineTo(-length, halfWidth);
1457
+ context.stroke();
1458
+ } else if (["circle", "circle-open"].includes(style)) {
1459
+ context.arc(-radius, 0, radius, 0, Math.PI * 2);
1460
+ if (style === "circle-open") context.stroke();
1461
+ else context.fill();
1462
+ } else if (["diamond", "diamond-open"].includes(style)) {
1463
+ context.moveTo(0, 0);
1464
+ context.lineTo(-length / 2, -halfWidth);
1465
+ context.lineTo(-length, 0);
1466
+ context.lineTo(-length / 2, halfWidth);
1467
+ context.closePath();
1468
+ if (style === "diamond-open") context.stroke();
1469
+ else context.fill();
1470
+ } else {
1471
+ context.moveTo(0, 0);
1472
+ context.lineTo(-length, -halfWidth);
1473
+ context.lineTo(-length, halfWidth);
1474
+ context.closePath();
1475
+ context.fill();
1476
+ }
1477
+ context.restore();
1478
+ }
1479
+
533
1480
  function createArrowObject(arrow, options = {}) {
534
1481
  const start = resolveArrowEndpoint(arrow.start);
535
1482
  const end = resolveArrowEndpoint(arrow.end);
1483
+ const formatting = CanvasERDDocument.arrowFormatting(arrow);
1484
+ const color = options.stroke || formatting.color;
536
1485
  const object = new fabric.Line([start.x, start.y, end.x, end.y], {
537
- stroke: options.stroke || "#334e75",
538
- strokeWidth: 2.25,
539
- fill: options.stroke || "#334e75",
1486
+ stroke: color,
1487
+ strokeWidth: formatting.strokeWidth,
1488
+ strokeDashArray: CanvasERDDocument.arrowStrokeDashArray(formatting.lineStyle),
1489
+ strokeLineCap: formatting.lineStyle === "dotted" ? "round" : "butt",
1490
+ fill: color,
540
1491
  objectCaching: false,
541
1492
  perPixelTargetFind: true,
542
1493
  padding: 6,
@@ -549,20 +1500,40 @@ function createArrowObject(arrow, options = {}) {
549
1500
  lockSkewingY: true
550
1501
  });
551
1502
  object._render = function (context) {
552
- fabric.Line.prototype._render.call(this, context);
553
1503
  const points = this.calcLinePoints();
554
- const angle = Math.atan2(points.y2 - points.y1, points.x2 - points.x1);
1504
+ const shaft = CanvasERDDocument.shortenedArrowLinePoints(
1505
+ points,
1506
+ formatting.startHead,
1507
+ formatting.endHead,
1508
+ this.strokeWidth
1509
+ );
555
1510
  context.save();
556
- context.translate(points.x2, points.y2);
557
- context.rotate(angle);
558
- context.fillStyle = this.stroke;
559
1511
  context.beginPath();
560
- context.moveTo(1, 0);
561
- context.lineTo(-10, -5.5);
562
- context.lineTo(-10, 5.5);
563
- context.closePath();
564
- context.fill();
1512
+ context.moveTo(shaft.x1, shaft.y1);
1513
+ context.lineTo(shaft.x2, shaft.y2);
1514
+ context.strokeStyle = this.stroke;
1515
+ context.lineWidth = this.strokeWidth;
1516
+ context.lineCap = this.strokeLineCap;
1517
+ context.setLineDash(this.strokeDashArray || []);
1518
+ context.stroke();
565
1519
  context.restore();
1520
+ const angle = Math.atan2(points.y2 - points.y1, points.x2 - points.x1);
1521
+ drawArrowHead(
1522
+ context,
1523
+ { x: points.x1, y: points.y1 },
1524
+ angle + Math.PI,
1525
+ formatting.startHead,
1526
+ this.stroke,
1527
+ this.strokeWidth
1528
+ );
1529
+ drawArrowHead(
1530
+ context,
1531
+ { x: points.x2, y: points.y2 },
1532
+ angle,
1533
+ formatting.endHead,
1534
+ this.stroke,
1535
+ this.strokeWidth
1536
+ );
566
1537
  };
567
1538
  object.canvasErdType = options.preview ? "arrow-preview" : "arrow";
568
1539
  object.arrowId = arrow.id;
@@ -606,15 +1577,21 @@ function renderArrowPreview() {
606
1577
  }
607
1578
 
608
1579
  function setArrowMode(active) {
1580
+ if (active && frameMode) cancelFrameDrawing();
609
1581
  if (active && editingArrowId) finishArrowEndpointEditing();
610
1582
  arrowMode = active;
611
1583
  arrowButton.setAttribute("aria-pressed", String(active));
612
1584
  canvas.selection = !active;
613
1585
  canvas.setCursor(active ? "crosshair" : "default");
614
- [...tableObjects.values(), ...noteObjects.values(), ...arrowObjects.values()].forEach((object) => {
1586
+ [
1587
+ ...frameObjects.values(),
1588
+ ...frameLabelObjects.values(),
1589
+ ...tableObjects.values(),
1590
+ ...noteObjects.values(),
1591
+ ...codeObjects.values(),
1592
+ ...arrowObjects.values()
1593
+ ].forEach((object) => {
615
1594
  object.selectable = !active;
616
- });
617
- arrowObjects.forEach((object) => {
618
1595
  object.evented = !active;
619
1596
  });
620
1597
  if (active) canvas.discardActiveObject();
@@ -700,6 +1677,7 @@ function beginArrowEndpointEditing(arrowId) {
700
1677
  arrowEndpointHandles.set(endpointName, handle);
701
1678
  canvas.add(handle);
702
1679
  });
1680
+ updateArrowFormattingControls();
703
1681
  canvas.requestRenderAll();
704
1682
  }
705
1683
 
@@ -713,6 +1691,8 @@ function finishArrowEndpointEditing() {
713
1691
  object.evented = true;
714
1692
  }
715
1693
  editingArrowId = null;
1694
+ updateArrowFormattingControls();
1695
+ closeNoteFormattingMenus();
716
1696
  canvas.discardActiveObject();
717
1697
  canvas.requestRenderAll();
718
1698
  }
@@ -757,6 +1737,7 @@ function removeActiveArrows() {
757
1737
  }
758
1738
 
759
1739
  function addNote(position = canvas.getVpCenter()) {
1740
+ if (frameMode) cancelFrameDrawing();
760
1741
  if (arrowMode) cancelArrowDrawing();
761
1742
  if (editingArrowId) finishArrowEndpointEditing();
762
1743
  const origin = CanvasERDDocument.notePositionAt(position);
@@ -766,7 +1747,8 @@ function addNote(position = canvas.getVpCenter()) {
766
1747
  x: origin.x,
767
1748
  y: origin.y,
768
1749
  width: 220,
769
- angle: 0
1750
+ angle: 0,
1751
+ ...lastNoteFormatting
770
1752
  };
771
1753
  state.notes.push(note);
772
1754
 
@@ -777,10 +1759,122 @@ function addNote(position = canvas.getVpCenter()) {
777
1759
  canvas.setActiveObject(object);
778
1760
  object.enterEditing();
779
1761
  object.selectAll();
1762
+ updateNoteFormattingControls();
780
1763
  canvas.requestRenderAll();
781
1764
  markDirty();
782
1765
  }
783
1766
 
1767
+ function insertCodeBlock(origin, block) {
1768
+ if (frameMode) cancelFrameDrawing();
1769
+ if (arrowMode) cancelArrowDrawing();
1770
+ if (editingArrowId) finishArrowEndpointEditing();
1771
+ Object.assign(block, {
1772
+ id: globalThis.crypto && crypto.randomUUID ? crypto.randomUUID() : `code-${Date.now()}`,
1773
+ x: origin.x,
1774
+ y: origin.y,
1775
+ angle: 0
1776
+ });
1777
+ state.codeBlocks.push(block);
1778
+
1779
+ const object = createCodeObject(block);
1780
+ codeObjects.set(block.id, object);
1781
+ updateModelDetailToggles();
1782
+ canvas.add(object);
1783
+ restackCanvasObjects();
1784
+ canvas.setActiveObject(object);
1785
+ updateCodeFormattingControls();
1786
+ canvas.requestRenderAll();
1787
+ markDirty();
1788
+ return object;
1789
+ }
1790
+
1791
+ function addCodeBlock(position = canvas.getVpCenter()) {
1792
+ const object = insertCodeBlock(CanvasERDDocument.notePositionAt(position), {
1793
+ code: "# Double-click to edit",
1794
+ width: 360,
1795
+ language: lastCodeLanguage,
1796
+ source: null
1797
+ });
1798
+ object.enterEditing();
1799
+ object.selectAll();
1800
+ }
1801
+
1802
+ function openSourceDialog(position = canvas.getVpCenter()) {
1803
+ sourceDialogPosition = position;
1804
+ sourceFormElement.reset();
1805
+ sourceErrorElement.hidden = true;
1806
+ sourceSubmitButton.disabled = false;
1807
+ sourceDialogElement.showModal();
1808
+ sourcePathElement.focus();
1809
+ loadSourceFilePaths();
1810
+ }
1811
+
1812
+ async function addSourceCodeBlock(event) {
1813
+ event.preventDefault();
1814
+ const source = CanvasERDDocument.sourceFromFields(
1815
+ sourcePathElement.value,
1816
+ sourceStartElement.value,
1817
+ sourceEndElement.value
1818
+ );
1819
+ if (!source) return;
1820
+
1821
+ sourceSubmitButton.disabled = true;
1822
+ try {
1823
+ const result = await fetchSource(source);
1824
+ sourceDialogElement.close();
1825
+ insertCodeBlock(
1826
+ CanvasERDDocument.notePositionAt(sourceDialogPosition),
1827
+ { code: result.code, width: 520, language: result.language, source }
1828
+ );
1829
+ } catch (error) {
1830
+ sourceErrorElement.textContent = error.message;
1831
+ sourceErrorElement.hidden = false;
1832
+ } finally {
1833
+ sourceSubmitButton.disabled = false;
1834
+ }
1835
+ }
1836
+
1837
+ async function toggleModelDetail(entityId, detail) {
1838
+ const existing = CanvasERDDocument.modelDetailBlock(state.codeBlocks, entityId, detail);
1839
+ if (existing) {
1840
+ state.arrows = state.arrows.filter((arrow) => (
1841
+ ![arrow.start, arrow.end].some((endpoint) => endpoint.attachment?.type === "code" && endpoint.attachment.id === existing.id)
1842
+ ));
1843
+ removeCodeBlock(existing.id);
1844
+ return;
1845
+ }
1846
+
1847
+ const source = { model: entityId, detail };
1848
+ try {
1849
+ const result = await fetchSource(source);
1850
+ const origin = CanvasERDDocument.modelDetailOrigin(state.positions[entityId]);
1851
+ const block = {
1852
+ code: result.code,
1853
+ width: 520,
1854
+ language: result.language,
1855
+ lineNumbers: result.lineNumbers,
1856
+ source
1857
+ };
1858
+ insertCodeBlock(origin, block);
1859
+ state.arrows.push({
1860
+ id: globalThis.crypto && crypto.randomUUID ? crypto.randomUUID() : `arrow-${Date.now()}`,
1861
+ start: { x: 0, y: 0, attachment: { type: "entity", id: entityId, x: 1, y: 0.5 } },
1862
+ end: { x: 0, y: 0, attachment: { type: "code", id: block.id, x: 0, y: 0.5 } },
1863
+ ...CanvasERDDocument.DEFAULT_ARROW_FORMATTING
1864
+ });
1865
+ renderArrows();
1866
+ } catch (error) {
1867
+ showNotice(`Could not show ${detail} for ${entityId}: ${error.message}`, true);
1868
+ }
1869
+ }
1870
+
1871
+ function updateModelDetailToggles() {
1872
+ tableListElement.querySelectorAll("[data-detail]").forEach((button) => {
1873
+ const block = CanvasERDDocument.modelDetailBlock(state.codeBlocks, button.dataset.entityId, button.dataset.detail);
1874
+ button.setAttribute("aria-pressed", String(Boolean(block)));
1875
+ });
1876
+ }
1877
+
784
1878
  function updateObjectState(object) {
785
1879
  if (object.canvasErdType === "entity") {
786
1880
  state.positions[object.entityId] = { x: object.left, y: object.top };
@@ -790,23 +1884,86 @@ function updateObjectState(object) {
790
1884
  if (!note) return;
791
1885
  Object.assign(note, CanvasERDDocument.noteStateGeometry(object));
792
1886
  scheduleRelationshipRender();
1887
+ } else if (object.canvasErdType === "code") {
1888
+ const block = findCodeBlock(object.codeBlockId);
1889
+ if (!block) return;
1890
+ Object.assign(block, CanvasERDDocument.codeStateGeometry(object));
1891
+ scheduleRelationshipRender();
1892
+ } else if (object.canvasErdType === "frame") {
1893
+ const frame = state.frames.find((candidate) => candidate.id === object.frameId);
1894
+ if (!frame) return;
1895
+ Object.assign(frame, {
1896
+ x: object.left,
1897
+ y: object.top,
1898
+ width: object.width * object.scaleX,
1899
+ height: object.height * object.scaleY
1900
+ });
1901
+ const label = frameLabelObjects.get(frame.id);
1902
+ const position = CanvasERDDocument.frameLabelPosition(frame);
1903
+ label?.set({ left: position.x, top: position.y });
1904
+ label?.setCoords();
1905
+ } else if (object.canvasErdType === "frame-label") {
1906
+ const frame = state.frames.find((candidate) => candidate.id === object.frameId);
1907
+ if (!frame) return;
1908
+ frame.label = object.text;
793
1909
  }
794
1910
  markDirty();
795
1911
  }
796
1912
 
797
- function removeActiveNote() {
1913
+ function removeActiveFrame() {
798
1914
  const active = canvas.getActiveObject();
799
- if (!active || active.canvasErdType !== "note" || active.isEditing) return false;
1915
+ if (!["frame", "frame-label"].includes(active?.canvasErdType) || active.isEditing) return false;
800
1916
 
1917
+ const frameId = active.frameId;
1918
+ canvas.discardActiveObject();
1919
+ canvas.remove(frameObjects.get(frameId), frameLabelObjects.get(frameId));
1920
+ frameObjects.delete(frameId);
1921
+ frameLabelObjects.delete(frameId);
1922
+ state.frames = state.frames.filter((frame) => frame.id !== frameId);
1923
+ markDirty();
1924
+ return true;
1925
+ }
1926
+
1927
+ function detachArrows(type, id) {
801
1928
  state.arrows.forEach((arrow) => {
802
1929
  [arrow.start, arrow.end].forEach((endpoint) => {
803
- if (endpoint.attachment?.type !== "note" || endpoint.attachment.id !== active.noteId) return;
1930
+ if (endpoint.attachment?.type !== type || endpoint.attachment.id !== id) return;
804
1931
  const point = resolveArrowEndpoint(endpoint);
805
1932
  endpoint.x = point.x;
806
1933
  endpoint.y = point.y;
807
1934
  endpoint.attachment = null;
808
1935
  });
809
1936
  });
1937
+ }
1938
+
1939
+ function removeActiveCodeBlock() {
1940
+ const active = canvas.getActiveObject();
1941
+ if (!active || active.canvasErdType !== "code" || active.isEditing) return false;
1942
+
1943
+ detachArrows("code", active.codeBlockId);
1944
+ removeCodeBlock(active.codeBlockId);
1945
+ return true;
1946
+ }
1947
+
1948
+ function removeCodeBlock(id) {
1949
+ const object = codeObjects.get(id);
1950
+ clearTimeout(codeHighlightTimers.get(id));
1951
+ codeHighlightTimers.delete(id);
1952
+ if (canvas.getActiveObject() === object) canvas.discardActiveObject();
1953
+ canvas.remove(object);
1954
+ codeObjects.delete(id);
1955
+ state.codeBlocks = state.codeBlocks.filter((block) => block.id !== id);
1956
+ renderArrows();
1957
+ updateModelDetailToggles();
1958
+ markDirty();
1959
+ }
1960
+
1961
+ function removeActiveNote() {
1962
+ const active = canvas.getActiveObject();
1963
+ if (!active || active.canvasErdType !== "note" || active.isEditing) return false;
1964
+
1965
+ detachArrows("note", active.noteId);
1966
+ canvas.discardActiveObject();
810
1967
  canvas.remove(active);
811
1968
  noteObjects.delete(active.noteId);
812
1969
  state.notes = state.notes.filter((note) => note.id !== active.noteId);
@@ -866,9 +2023,24 @@ function renderTableList() {
866
2023
  const text = document.createElement("span");
867
2024
  text.textContent = entity.label;
868
2025
  row.append(checkbox, text);
2026
+ Object.entries(modelDetails[entity.id] || {}).forEach(([detail, count]) => {
2027
+ const button = document.createElement("button");
2028
+ button.type = "button";
2029
+ button.className = "detail-toggle";
2030
+ button.dataset.entityId = entity.id;
2031
+ button.dataset.detail = detail;
2032
+ button.textContent = detail;
2033
+ button.title = `Show ${detail} (${count})`;
2034
+ button.addEventListener("click", (event) => {
2035
+ event.preventDefault();
2036
+ toggleModelDetail(entity.id, detail);
2037
+ });
2038
+ row.append(button);
2039
+ });
869
2040
  fragment.append(row);
870
2041
  });
871
2042
  tableListElement.replaceChildren(fragment);
2043
+ updateModelDetailToggles();
872
2044
  }
873
2045
 
874
2046
  function filterTableList() {
@@ -905,6 +2077,7 @@ async function refreshSchema() {
905
2077
  applicationSchema = schema;
906
2078
  markDirty();
907
2079
  showNotice(CanvasERDDocument.changeSummary(result.changes));
2080
+ await refreshSourceCodeBlocks();
908
2081
  } catch (error) {
909
2082
  showNotice(`Could not refresh schema: ${error.message}`, true);
910
2083
  } finally {
@@ -1022,7 +2195,14 @@ async function saveDiagram() {
1022
2195
 
1023
2196
  function displayDocument(documentData) {
1024
2197
  schema = documentData.schema;
1025
- state = { ...documentData.state, arrows: documentData.state.arrows || [] };
2198
+ state = {
2199
+ ...documentData.state,
2200
+ arrows: documentData.state.arrows || [],
2201
+ frames: documentData.state.frames || [],
2202
+ codeBlocks: documentData.state.codeBlocks || [],
2203
+ tableHeaderColors: documentData.state.tableHeaderColors || {}
2204
+ };
2205
+ if (frameMode) cancelFrameDrawing();
1026
2206
  if (arrowMode) cancelArrowDrawing();
1027
2207
  if (editingArrowId) finishArrowEndpointEditing();
1028
2208
  canvas.discardActiveObject();
@@ -1030,10 +2210,15 @@ function displayDocument(documentData) {
1030
2210
  tableObjects.clear();
1031
2211
  relationshipObjects = [];
1032
2212
  noteObjects.clear();
2213
+ codeObjects.clear();
1033
2214
  arrowObjects.clear();
2215
+ frameObjects.clear();
2216
+ frameLabelObjects.clear();
1034
2217
  renderTableList();
2218
+ renderFrames();
1035
2219
  renderTables();
1036
2220
  renderNotes();
2221
+ renderCodeBlocks();
1037
2222
  renderArrows();
1038
2223
 
1039
2224
  if (Array.isArray(state.viewportTransform) && state.viewportTransform.length === 6) {
@@ -1097,8 +2282,11 @@ async function newDiagram() {
1097
2282
 
1098
2283
  function diagramBounds() {
1099
2284
  const objects = [
2285
+ ...frameObjects.values(),
2286
+ ...frameLabelObjects.values(),
1100
2287
  ...tableObjects.values(),
1101
2288
  ...noteObjects.values(),
2289
+ ...codeObjects.values(),
1102
2290
  ...arrowObjects.values(),
1103
2291
  ...relationshipObjects
1104
2292
  ];
@@ -1141,18 +2329,28 @@ function resizeCanvas() {
1141
2329
  }
1142
2330
 
1143
2331
  function configureCanvasEvents() {
1144
- canvas.on("mouse:dblclick", ({ target }) => {
1145
- if (target?.canvasErdType === "arrow") beginArrowEndpointEditing(target.arrowId);
1146
- });
2332
+ const selectionChanged = () => {
2333
+ closeNoteFormattingMenus();
2334
+ updateNoteFormattingControls();
2335
+ updateTableFormattingControls();
2336
+ updateFrameFormattingControls();
2337
+ updateCodeFormattingControls();
2338
+ };
2339
+ canvas.on("selection:created", selectionChanged);
2340
+ canvas.on("selection:updated", selectionChanged);
2341
+ canvas.on("selection:cleared", selectionChanged);
1147
2342
  canvas.on("mouse:down", ({ target }) => {
1148
2343
  if (editingArrowId && target?.canvasErdType !== "arrow-endpoint") finishArrowEndpointEditing();
2344
+ const arrowId = CanvasERDDocument.arrowIdForEndpointEditing(target);
2345
+ if (arrowId) beginArrowEndpointEditing(arrowId);
1149
2346
  });
1150
2347
  canvas.on("mouse:down", (eventData) => {
1151
2348
  if (!arrowMode || spacePressed) return;
1152
2349
  drawingArrow = {
1153
2350
  id: globalThis.crypto && crypto.randomUUID ? crypto.randomUUID() : `arrow-${Date.now()}`,
1154
2351
  start: arrowEndpoint(eventData),
1155
- end: arrowEndpoint(eventData)
2352
+ end: arrowEndpoint(eventData),
2353
+ ...CanvasERDDocument.DEFAULT_ARROW_FORMATTING
1156
2354
  };
1157
2355
  renderArrowPreview();
1158
2356
  });
@@ -1164,17 +2362,54 @@ function configureCanvasEvents() {
1164
2362
  canvas.on("mouse:up", (eventData) => {
1165
2363
  if (arrowMode && drawingArrow) finishArrowDrawing(eventData);
1166
2364
  });
2365
+ canvas.on("mouse:up", ({ target, e, isClick }) => {
2366
+ if (!isClick || target?.canvasErdType !== "code") return;
2367
+ const label = target.gutterLabelAt(e);
2368
+ if (label !== null && label !== undefined) toggleCodeLineHighlight(target, label, e.shiftKey);
2369
+ });
2370
+ canvas.on("mouse:move", ({ target, e }) => {
2371
+ if (target?.canvasErdType !== "code") return;
2372
+ const label = target.gutterLabelAt(e);
2373
+ target.hoverCursor = label !== null && label !== undefined ? "pointer" : null;
2374
+ });
2375
+ canvas.on("mouse:down", ({ e, scenePoint }) => {
2376
+ if (!frameMode || spacePressed) return;
2377
+ const point = scenePoint || canvas.getScenePoint(e);
2378
+ drawingFrame = { start: { x: point.x, y: point.y }, end: { x: point.x, y: point.y } };
2379
+ renderFramePreview();
2380
+ });
2381
+ canvas.on("mouse:move", ({ e, scenePoint }) => {
2382
+ if (!frameMode || !drawingFrame) return;
2383
+ const point = scenePoint || canvas.getScenePoint(e);
2384
+ drawingFrame.end = { x: point.x, y: point.y };
2385
+ renderFramePreview();
2386
+ });
2387
+ canvas.on("mouse:up", ({ e, scenePoint }) => {
2388
+ if (!frameMode || !drawingFrame) return;
2389
+ const point = scenePoint || canvas.getScenePoint(e);
2390
+ finishFrameDrawing({ x: point.x, y: point.y });
2391
+ });
1167
2392
 
1168
2393
  canvas.on("object:moving", (eventData) => {
1169
2394
  if (eventData.target.canvasErdType === "arrow-endpoint") updateArrowEndpointHandle(eventData);
1170
2395
  else if (eventData.target.canvasErdType !== "arrow") updateObjectState(eventData.target);
1171
2396
  });
2397
+ canvas.on("object:scaling", ({ target }) => {
2398
+ if (target.canvasErdType === "frame") updateObjectState(target);
2399
+ });
1172
2400
  canvas.on("object:modified", (eventData) => {
1173
2401
  if (eventData.target.canvasErdType === "arrow") updateArrowState(eventData.target);
1174
2402
  else if (eventData.target.canvasErdType === "arrow-endpoint") updateArrowEndpointHandle(eventData);
1175
2403
  else updateObjectState(eventData.target);
1176
2404
  });
1177
- canvas.on("text:changed", ({ target }) => updateObjectState(target));
2405
+ canvas.on("text:changed", ({ target }) => {
2406
+ if (target.canvasErdType === "code") updateCodeGutter(target);
2407
+ updateObjectState(target);
2408
+ if (target.canvasErdType === "code") scheduleCodeHighlight(target);
2409
+ });
2410
+ canvas.on("text:editing:exited", ({ target }) => {
2411
+ if (target.canvasErdType === "code") scheduleCodeHighlight(target, 0);
2412
+ });
1178
2413
 
1179
2414
  canvas.on("mouse:wheel", ({ e }) => {
1180
2415
  if (e.ctrlKey) {
@@ -1227,8 +2462,21 @@ function configureCanvasEvents() {
1227
2462
  document.addEventListener("keydown", (event) => {
1228
2463
  const editing = canvas.getActiveObject() && canvas.getActiveObject().isEditing;
1229
2464
  const acceptsText = ["INPUT", "TEXTAREA", "SELECT"].includes(event.target.tagName) || event.target.isContentEditable;
2465
+ const noteFormatting = CanvasERDDocument.noteFormattingShortcut(event);
1230
2466
  const action = CanvasERDDocument.shortcutAction(event);
1231
- const bareAction = ["addNote", "arrow", "toggleTables", "layoutTables", "zoomIn", "zoomOut", "fit", "help"].includes(action);
2467
+ const bareAction = ["addNote", "addCode", "arrow", "toggleTables", "layoutTables", "zoomIn", "zoomOut", "fit", "help"].includes(action);
2468
+
2469
+ if (noteFormatting && activeNoteObject() && (!acceptsText || editing)) {
2470
+ event.preventDefault();
2471
+ if (!event.repeat) {
2472
+ if (CanvasERDDocument.NOTE_PARAGRAPH_STYLES[noteFormatting]) {
2473
+ applyNoteFormatting(CanvasERDDocument.noteParagraphStyleFormatting(noteFormatting));
2474
+ } else {
2475
+ toggleNoteFormatting(noteFormatting);
2476
+ }
2477
+ }
2478
+ return;
2479
+ }
1232
2480
 
1233
2481
  if (action && (!bareAction || (!editing && !acceptsText))) {
1234
2482
  event.preventDefault();
@@ -1245,6 +2493,8 @@ function configureCanvasEvents() {
1245
2493
  newDiagram();
1246
2494
  } else if (action === "addNote") {
1247
2495
  addNote(lastCanvasPointer || canvas.getVpCenter());
2496
+ } else if (action === "addCode") {
2497
+ addCodeBlock(lastCanvasPointer || canvas.getVpCenter());
1248
2498
  } else if (action === "arrow") {
1249
2499
  if (arrowMode) cancelArrowDrawing();
1250
2500
  else setArrowMode(true);
@@ -1264,12 +2514,17 @@ function configureCanvasEvents() {
1264
2514
  return;
1265
2515
  }
1266
2516
 
1267
- if (event.key === "Escape" && editingArrowId) {
2517
+ if (event.key === "Escape" && closeNoteFormattingMenus()) {
2518
+ event.preventDefault();
2519
+ } else if (event.key === "Escape" && editingArrowId) {
1268
2520
  event.preventDefault();
1269
2521
  finishArrowEndpointEditing();
1270
2522
  } else if (event.key === "Escape" && arrowMode) {
1271
2523
  event.preventDefault();
1272
2524
  cancelArrowDrawing();
2525
+ } else if (event.key === "Escape" && frameMode) {
2526
+ event.preventDefault();
2527
+ cancelFrameDrawing();
1273
2528
  } else if (event.key === "Escape" && !shortcutPanelElement.hidden) {
1274
2529
  event.preventDefault();
1275
2530
  showShortcutHelp(false);
@@ -1278,8 +2533,8 @@ function configureCanvasEvents() {
1278
2533
  spacePressed = true;
1279
2534
  canvas.skipTargetFind = true;
1280
2535
  canvas.setCursor("grab");
1281
- } else if (["Delete", "Backspace"].includes(event.key) && (
1282
- removeActiveTables() || removeActiveArrows() || removeActiveNote()
2536
+ } else if (["Delete", "Backspace"].includes(event.key) && !acceptsText && (
2537
+ removeActiveTables() || removeActiveArrows() || removeActiveFrame() || removeActiveNote() || removeActiveCodeBlock()
1283
2538
  )) {
1284
2539
  event.preventDefault();
1285
2540
  }
@@ -1290,8 +2545,8 @@ function configureCanvasEvents() {
1290
2545
  spacePressed = false;
1291
2546
  isPanning = false;
1292
2547
  canvas.skipTargetFind = false;
1293
- canvas.selection = true;
1294
- canvas.setCursor(arrowMode ? "crosshair" : "default");
2548
+ canvas.selection = !arrowMode && !frameMode;
2549
+ canvas.setCursor(arrowMode || frameMode ? "crosshair" : "default");
1295
2550
  });
1296
2551
  }
1297
2552
 
@@ -1304,6 +2559,10 @@ function configureControls() {
1304
2559
  if (arrowMode) cancelArrowDrawing();
1305
2560
  else setArrowMode(true);
1306
2561
  });
2562
+ frameButton.addEventListener("click", () => {
2563
+ if (frameMode) cancelFrameDrawing();
2564
+ else setFrameMode(true);
2565
+ });
1307
2566
  saveButton.addEventListener("click", saveDiagram);
1308
2567
  newButton.addEventListener("click", newDiagram);
1309
2568
  openButton.addEventListener("click", () => {
@@ -1336,6 +2595,17 @@ function configureControls() {
1336
2595
  await loadDiagram(filename);
1337
2596
  });
1338
2597
  document.querySelector("#add-note").addEventListener("click", () => addNote());
2598
+ document.querySelector("#add-code").addEventListener("click", () => addCodeBlock());
2599
+ document.querySelector("#add-source").addEventListener("click", () => openSourceDialog());
2600
+ sourceFormElement.addEventListener("submit", addSourceCodeBlock);
2601
+ document.querySelector("#source-cancel").addEventListener("click", () => sourceDialogElement.close());
2602
+ codeSourcePathElement.addEventListener("focus", loadSourceFilePaths);
2603
+ [codeSourcePathElement, codeSourceStartElement, codeSourceEndElement].forEach((element) => {
2604
+ element.addEventListener("change", applyCodeSource);
2605
+ });
2606
+ codeLanguageElement.addEventListener("change", () => applyCodeLanguage(codeLanguageElement.value));
2607
+ codeLineNumbersButton.addEventListener("click", toggleCodeLineNumbers);
2608
+ codeSizeElement.addEventListener("change", () => applyCodeSize(codeSizeElement.value));
1339
2609
  layoutTablesButton.addEventListener("click", layoutTables);
1340
2610
  document.querySelector("#zoom-in").addEventListener("click", () => zoom(1.2));
1341
2611
  document.querySelector("#zoom-out").addEventListener("click", () => zoom(1 / 1.2));
@@ -1343,13 +2613,79 @@ function configureControls() {
1343
2613
  document.querySelector("#select-all").addEventListener("click", () => setAllTables(true));
1344
2614
  document.querySelector("#select-none").addEventListener("click", () => setAllTables(false));
1345
2615
  searchElement.addEventListener("input", filterTableList);
2616
+ noteBoldButton.addEventListener("click", () => toggleNoteFormatting("bold"));
2617
+ noteItalicButton.addEventListener("click", () => toggleNoteFormatting("italic"));
2618
+ noteUnderlineButton.addEventListener("click", () => toggleNoteFormatting("underline"));
2619
+ noteColorControls.forEach((control) => {
2620
+ control.button.addEventListener("click", () => toggleNoteColorMenu(control));
2621
+ control.custom.addEventListener("change", () => {
2622
+ applyNoteFormatting({ [control.property]: control.custom.value });
2623
+ closeNoteFormattingMenus();
2624
+ });
2625
+ control.transparent?.addEventListener("click", () => {
2626
+ applyNoteFormatting({ [control.property]: "transparent" });
2627
+ closeNoteFormattingMenus();
2628
+ });
2629
+ });
2630
+ tableHeaderColorControl.button.addEventListener("click", toggleTableHeaderColorMenu);
2631
+ tableHeaderColorControl.custom.addEventListener("change", () => {
2632
+ applyTableHeaderColor(tableHeaderColorControl.custom.value);
2633
+ closeNoteFormattingMenus();
2634
+ });
2635
+ arrowColorControl.button.addEventListener("click", toggleArrowColorMenu);
2636
+ arrowColorControl.custom.addEventListener("change", () => {
2637
+ applyArrowFormatting({ color: arrowColorControl.custom.value });
2638
+ closeNoteFormattingMenus();
2639
+ });
2640
+ arrowStartHeadElement.addEventListener("change", () => {
2641
+ applyArrowFormatting({ startHead: arrowStartHeadElement.value });
2642
+ });
2643
+ arrowEndHeadElement.addEventListener("change", () => {
2644
+ applyArrowFormatting({ endHead: arrowEndHeadElement.value });
2645
+ });
2646
+ arrowLineStyleElement.addEventListener("change", () => {
2647
+ applyArrowFormatting({ lineStyle: arrowLineStyleElement.value });
2648
+ });
2649
+ arrowStrokeWidthElement.addEventListener("change", () => {
2650
+ applyArrowFormatting({ strokeWidth: arrowStrokeWidthElement.value });
2651
+ });
2652
+ frameColorControl.button.addEventListener("click", toggleFrameColorMenu);
2653
+ frameColorControl.custom.addEventListener("change", () => {
2654
+ applyFrameFormatting({ color: frameColorControl.custom.value });
2655
+ closeNoteFormattingMenus();
2656
+ });
2657
+ frameLineStyleElement.addEventListener("change", () => {
2658
+ applyFrameFormatting({ lineStyle: frameLineStyleElement.value });
2659
+ });
2660
+ document.addEventListener("pointerdown", (event) => {
2661
+ const insideFormatting = noteFormattingElement.contains(event.target) ||
2662
+ tableFormattingElement.contains(event.target) ||
2663
+ arrowFormattingElement.contains(event.target) ||
2664
+ frameFormattingElement.contains(event.target);
2665
+ if (!insideFormatting) closeNoteFormattingMenus();
2666
+ });
2667
+ noteParagraphStyleElement.addEventListener("change", () => {
2668
+ applyNoteFormatting(
2669
+ CanvasERDDocument.noteParagraphStyleFormatting(noteParagraphStyleElement.value)
2670
+ );
2671
+ });
2672
+ noteAlignmentElement.addEventListener("change", () => {
2673
+ applyNoteFormatting({ textAlign: noteAlignmentElement.value });
2674
+ });
1346
2675
  }
1347
2676
 
1348
2677
  function initializeEditor(loadedDocument) {
1349
2678
  schema = loadedDocument.schema;
1350
- applicationSchema = schema;
2679
+ applicationSchema = loadedDocument.application_schema || schema;
2680
+ currentDiagramFilename = loadedDocument.filename || null;
1351
2681
  state = loadedDocument.state || CanvasERDDocument.createState(schema);
1352
- state = { ...state, arrows: state.arrows || [] };
2682
+ state = {
2683
+ ...state,
2684
+ arrows: state.arrows || [],
2685
+ frames: state.frames || [],
2686
+ codeBlocks: state.codeBlocks || [],
2687
+ tableHeaderColors: state.tableHeaderColors || {}
2688
+ };
1353
2689
  setDiagramName(loadedDocument.name);
1354
2690
  canvas = new fabric.Canvas("erd-canvas", {
1355
2691
  backgroundColor: "transparent",
@@ -1363,8 +2699,10 @@ function initializeEditor(loadedDocument) {
1363
2699
  configureCanvasEvents();
1364
2700
  configureControls();
1365
2701
  renderTableList();
2702
+ renderFrames();
1366
2703
  renderTables();
1367
2704
  renderNotes();
2705
+ renderCodeBlocks();
1368
2706
  renderArrows();
1369
2707
  if (Array.isArray(state.viewportTransform) && state.viewportTransform.length === 6) {
1370
2708
  canvas.setViewportTransform(state.viewportTransform);
@@ -1373,7 +2711,8 @@ function initializeEditor(loadedDocument) {
1373
2711
  fitDiagram(false);
1374
2712
  }
1375
2713
  new ResizeObserver(resizeCanvas).observe(panelElement);
1376
- refreshSavedDiagrams().catch((error) => showNotice(`Could not list diagrams: ${error.message}`, true));
2714
+ refreshSavedDiagrams(currentDiagramFilename)
2715
+ .catch((error) => showNotice(`Could not list diagrams: ${error.message}`, true));
1377
2716
  loadingElement.hidden = true;
1378
2717
  }
1379
2718
 
@@ -1384,6 +2723,13 @@ fetch("/api/document", { cache: "no-store" })
1384
2723
  })
1385
2724
  .then(async (loadedDocument) => {
1386
2725
  if (!loadedDocument.state) loadedDocument.state = await createInitialState(loadedDocument.schema);
2726
+ const response = await fetch("/api/highlighting", { cache: "no-store" });
2727
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
2728
+ highlighting = await response.json();
2729
+ renderCodeLanguages();
2730
+ const detailsResponse = await fetch("/api/model_details", { cache: "no-store" });
2731
+ if (!detailsResponse.ok) throw new Error(`HTTP ${detailsResponse.status}`);
2732
+ modelDetails = await detailsResponse.json();
1387
2733
  return loadedDocument;
1388
2734
  })
1389
2735
  .then(initializeEditor)