canvas_erd 0.2.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.
@@ -56,6 +56,23 @@ const frameColorControl = {
56
56
  swatches: document.querySelector("#frame-color-swatches"),
57
57
  custom: document.querySelector("#frame-custom-color")
58
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");
59
76
  const noteColorControls = [
60
77
  {
61
78
  property: "fill",
@@ -90,6 +107,8 @@ let lastCanvasPointer = null;
90
107
  const tableObjects = new Map();
91
108
  let relationshipObjects = [];
92
109
  const noteObjects = new Map();
110
+ const codeObjects = new Map();
111
+ const codeHighlightTimers = new Map();
93
112
  const arrowObjects = new Map();
94
113
  const frameObjects = new Map();
95
114
  const frameLabelObjects = new Map();
@@ -107,6 +126,10 @@ let frameMode = false;
107
126
  let drawingFrame = null;
108
127
  let framePreview = null;
109
128
  let lastNoteFormatting = CanvasERDDocument.inheritedNoteFormatting();
129
+ let lastCodeLanguage = "ruby";
130
+ let highlighting = { foreground: "#24292f", background: "#f6f8fa", languages: [] };
131
+ let modelDetails = {};
132
+ let sourceDialogPosition = null;
110
133
  const arrowEndpointHandles = new Map();
111
134
  const changeTracker = CanvasERDDocument.createChangeTracker(updateSaveButton);
112
135
  const layoutEngine = new ELK({ workerUrl: "/assets/elk-worker.min.js" });
@@ -208,7 +231,7 @@ function updateTableCaching() {
208
231
 
209
232
  function updateNoteSelectionPadding() {
210
233
  const padding = CanvasERDDocument.noteSelectionPadding(canvas.getZoom());
211
- noteObjects.forEach((note) => {
234
+ [...noteObjects.values(), ...codeObjects.values()].forEach((note) => {
212
235
  if (note.padding === padding) return;
213
236
  note.padding = padding;
214
237
  note.setCoords();
@@ -569,29 +592,372 @@ function createNoteObject(note) {
569
592
  transparentCorners: false,
570
593
  lockScalingY: true
571
594
  }));
572
- object._renderBackground = function (context) {
573
- if (!this.backgroundColor) return;
574
-
575
- const dimensions = this._getNonTransformedDimensions();
576
- const bounds = CanvasERDDocument.paddedBackgroundBounds(
577
- dimensions.x,
578
- dimensions.y,
579
- CanvasERDDocument.NOTE_PADDING
580
- );
581
- context.fillStyle = this.backgroundColor;
582
- context.fillRect(bounds.left, bounds.top, bounds.width, bounds.height);
583
- this._removeShadow(context);
584
- };
595
+ object._renderBackground = renderPaddedBackground;
585
596
  object.canvasErdType = "note";
586
597
  object.noteId = note.id;
587
598
  return object;
588
599
  }
589
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
+
590
615
  function activeNoteObject() {
591
616
  const active = canvas?.getActiveObject();
592
617
  return active?.canvasErdType === "note" ? active : null;
593
618
  }
594
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
+
595
961
  function activeTableEntityIds() {
596
962
  return CanvasERDDocument.selectedEntityIds(canvas?.getActiveObject());
597
963
  }
@@ -980,6 +1346,7 @@ function setFrameMode(active) {
980
1346
  ...frameLabelObjects.values(),
981
1347
  ...tableObjects.values(),
982
1348
  ...noteObjects.values(),
1349
+ ...codeObjects.values(),
983
1350
  ...arrowObjects.values()
984
1351
  ].forEach((object) => {
985
1352
  object.selectable = !active;
@@ -1030,6 +1397,7 @@ function attachmentTarget(attachment) {
1030
1397
  if (!attachment) return null;
1031
1398
  if (attachment.type === "entity") return tableObjects.get(attachment.id);
1032
1399
  if (attachment.type === "note") return noteObjects.get(attachment.id);
1400
+ if (attachment.type === "code") return codeObjects.get(attachment.id);
1033
1401
  return null;
1034
1402
  }
1035
1403
 
@@ -1044,7 +1412,7 @@ function resolveArrowEndpoint(endpoint) {
1044
1412
  }
1045
1413
 
1046
1414
  function attachableTargetAt(point) {
1047
- const objects = [...noteObjects.values(), ...tableObjects.values()].reverse();
1415
+ const objects = [...noteObjects.values(), ...codeObjects.values(), ...tableObjects.values()].reverse();
1048
1416
  return objects.find((object) => {
1049
1417
  const bounds = object.getBoundingRect();
1050
1418
  return point.x >= bounds.left && point.x <= bounds.left + bounds.width &&
@@ -1220,6 +1588,7 @@ function setArrowMode(active) {
1220
1588
  ...frameLabelObjects.values(),
1221
1589
  ...tableObjects.values(),
1222
1590
  ...noteObjects.values(),
1591
+ ...codeObjects.values(),
1223
1592
  ...arrowObjects.values()
1224
1593
  ].forEach((object) => {
1225
1594
  object.selectable = !active;
@@ -1395,6 +1764,117 @@ function addNote(position = canvas.getVpCenter()) {
1395
1764
  markDirty();
1396
1765
  }
1397
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
+
1398
1878
  function updateObjectState(object) {
1399
1879
  if (object.canvasErdType === "entity") {
1400
1880
  state.positions[object.entityId] = { x: object.left, y: object.top };
@@ -1404,6 +1884,11 @@ function updateObjectState(object) {
1404
1884
  if (!note) return;
1405
1885
  Object.assign(note, CanvasERDDocument.noteStateGeometry(object));
1406
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();
1407
1892
  } else if (object.canvasErdType === "frame") {
1408
1893
  const frame = state.frames.find((candidate) => candidate.id === object.frameId);
1409
1894
  if (!frame) return;
@@ -1439,19 +1924,45 @@ function removeActiveFrame() {
1439
1924
  return true;
1440
1925
  }
1441
1926
 
1442
- function removeActiveNote() {
1443
- const active = canvas.getActiveObject();
1444
- if (!active || active.canvasErdType !== "note" || active.isEditing) return false;
1445
-
1927
+ function detachArrows(type, id) {
1446
1928
  state.arrows.forEach((arrow) => {
1447
1929
  [arrow.start, arrow.end].forEach((endpoint) => {
1448
- if (endpoint.attachment?.type !== "note" || endpoint.attachment.id !== active.noteId) return;
1930
+ if (endpoint.attachment?.type !== type || endpoint.attachment.id !== id) return;
1449
1931
  const point = resolveArrowEndpoint(endpoint);
1450
1932
  endpoint.x = point.x;
1451
1933
  endpoint.y = point.y;
1452
1934
  endpoint.attachment = null;
1453
1935
  });
1454
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);
1455
1966
  canvas.discardActiveObject();
1456
1967
  canvas.remove(active);
1457
1968
  noteObjects.delete(active.noteId);
@@ -1512,9 +2023,24 @@ function renderTableList() {
1512
2023
  const text = document.createElement("span");
1513
2024
  text.textContent = entity.label;
1514
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
+ });
1515
2040
  fragment.append(row);
1516
2041
  });
1517
2042
  tableListElement.replaceChildren(fragment);
2043
+ updateModelDetailToggles();
1518
2044
  }
1519
2045
 
1520
2046
  function filterTableList() {
@@ -1551,6 +2077,7 @@ async function refreshSchema() {
1551
2077
  applicationSchema = schema;
1552
2078
  markDirty();
1553
2079
  showNotice(CanvasERDDocument.changeSummary(result.changes));
2080
+ await refreshSourceCodeBlocks();
1554
2081
  } catch (error) {
1555
2082
  showNotice(`Could not refresh schema: ${error.message}`, true);
1556
2083
  } finally {
@@ -1672,6 +2199,7 @@ function displayDocument(documentData) {
1672
2199
  ...documentData.state,
1673
2200
  arrows: documentData.state.arrows || [],
1674
2201
  frames: documentData.state.frames || [],
2202
+ codeBlocks: documentData.state.codeBlocks || [],
1675
2203
  tableHeaderColors: documentData.state.tableHeaderColors || {}
1676
2204
  };
1677
2205
  if (frameMode) cancelFrameDrawing();
@@ -1682,6 +2210,7 @@ function displayDocument(documentData) {
1682
2210
  tableObjects.clear();
1683
2211
  relationshipObjects = [];
1684
2212
  noteObjects.clear();
2213
+ codeObjects.clear();
1685
2214
  arrowObjects.clear();
1686
2215
  frameObjects.clear();
1687
2216
  frameLabelObjects.clear();
@@ -1689,6 +2218,7 @@ function displayDocument(documentData) {
1689
2218
  renderFrames();
1690
2219
  renderTables();
1691
2220
  renderNotes();
2221
+ renderCodeBlocks();
1692
2222
  renderArrows();
1693
2223
 
1694
2224
  if (Array.isArray(state.viewportTransform) && state.viewportTransform.length === 6) {
@@ -1756,6 +2286,7 @@ function diagramBounds() {
1756
2286
  ...frameLabelObjects.values(),
1757
2287
  ...tableObjects.values(),
1758
2288
  ...noteObjects.values(),
2289
+ ...codeObjects.values(),
1759
2290
  ...arrowObjects.values(),
1760
2291
  ...relationshipObjects
1761
2292
  ];
@@ -1803,6 +2334,7 @@ function configureCanvasEvents() {
1803
2334
  updateNoteFormattingControls();
1804
2335
  updateTableFormattingControls();
1805
2336
  updateFrameFormattingControls();
2337
+ updateCodeFormattingControls();
1806
2338
  };
1807
2339
  canvas.on("selection:created", selectionChanged);
1808
2340
  canvas.on("selection:updated", selectionChanged);
@@ -1830,6 +2362,16 @@ function configureCanvasEvents() {
1830
2362
  canvas.on("mouse:up", (eventData) => {
1831
2363
  if (arrowMode && drawingArrow) finishArrowDrawing(eventData);
1832
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
+ });
1833
2375
  canvas.on("mouse:down", ({ e, scenePoint }) => {
1834
2376
  if (!frameMode || spacePressed) return;
1835
2377
  const point = scenePoint || canvas.getScenePoint(e);
@@ -1860,7 +2402,14 @@ function configureCanvasEvents() {
1860
2402
  else if (eventData.target.canvasErdType === "arrow-endpoint") updateArrowEndpointHandle(eventData);
1861
2403
  else updateObjectState(eventData.target);
1862
2404
  });
1863
- 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
+ });
1864
2413
 
1865
2414
  canvas.on("mouse:wheel", ({ e }) => {
1866
2415
  if (e.ctrlKey) {
@@ -1915,7 +2464,7 @@ function configureCanvasEvents() {
1915
2464
  const acceptsText = ["INPUT", "TEXTAREA", "SELECT"].includes(event.target.tagName) || event.target.isContentEditable;
1916
2465
  const noteFormatting = CanvasERDDocument.noteFormattingShortcut(event);
1917
2466
  const action = CanvasERDDocument.shortcutAction(event);
1918
- 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);
1919
2468
 
1920
2469
  if (noteFormatting && activeNoteObject() && (!acceptsText || editing)) {
1921
2470
  event.preventDefault();
@@ -1944,6 +2493,8 @@ function configureCanvasEvents() {
1944
2493
  newDiagram();
1945
2494
  } else if (action === "addNote") {
1946
2495
  addNote(lastCanvasPointer || canvas.getVpCenter());
2496
+ } else if (action === "addCode") {
2497
+ addCodeBlock(lastCanvasPointer || canvas.getVpCenter());
1947
2498
  } else if (action === "arrow") {
1948
2499
  if (arrowMode) cancelArrowDrawing();
1949
2500
  else setArrowMode(true);
@@ -1982,8 +2533,8 @@ function configureCanvasEvents() {
1982
2533
  spacePressed = true;
1983
2534
  canvas.skipTargetFind = true;
1984
2535
  canvas.setCursor("grab");
1985
- } else if (["Delete", "Backspace"].includes(event.key) && (
1986
- removeActiveTables() || removeActiveArrows() || removeActiveFrame() || removeActiveNote()
2536
+ } else if (["Delete", "Backspace"].includes(event.key) && !acceptsText && (
2537
+ removeActiveTables() || removeActiveArrows() || removeActiveFrame() || removeActiveNote() || removeActiveCodeBlock()
1987
2538
  )) {
1988
2539
  event.preventDefault();
1989
2540
  }
@@ -2044,6 +2595,17 @@ function configureControls() {
2044
2595
  await loadDiagram(filename);
2045
2596
  });
2046
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));
2047
2609
  layoutTablesButton.addEventListener("click", layoutTables);
2048
2610
  document.querySelector("#zoom-in").addEventListener("click", () => zoom(1.2));
2049
2611
  document.querySelector("#zoom-out").addEventListener("click", () => zoom(1 / 1.2));
@@ -2121,6 +2683,7 @@ function initializeEditor(loadedDocument) {
2121
2683
  ...state,
2122
2684
  arrows: state.arrows || [],
2123
2685
  frames: state.frames || [],
2686
+ codeBlocks: state.codeBlocks || [],
2124
2687
  tableHeaderColors: state.tableHeaderColors || {}
2125
2688
  };
2126
2689
  setDiagramName(loadedDocument.name);
@@ -2139,6 +2702,7 @@ function initializeEditor(loadedDocument) {
2139
2702
  renderFrames();
2140
2703
  renderTables();
2141
2704
  renderNotes();
2705
+ renderCodeBlocks();
2142
2706
  renderArrows();
2143
2707
  if (Array.isArray(state.viewportTransform) && state.viewportTransform.length === 6) {
2144
2708
  canvas.setViewportTransform(state.viewportTransform);
@@ -2159,6 +2723,13 @@ fetch("/api/document", { cache: "no-store" })
2159
2723
  })
2160
2724
  .then(async (loadedDocument) => {
2161
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();
2162
2733
  return loadedDocument;
2163
2734
  })
2164
2735
  .then(initializeEditor)