canvas_erd 0.1.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.
@@ -0,0 +1,1394 @@
1
+ "use strict";
2
+
3
+ const statusElement = document.querySelector("#status");
4
+ const loadingElement = document.querySelector("#loading");
5
+ const panelElement = document.querySelector("#canvas-panel");
6
+ const tableListElement = document.querySelector("#table-list");
7
+ const tablesSidebarElement = document.querySelector("#tables-sidebar");
8
+ const toggleTablesButton = document.querySelector("#toggle-tables");
9
+ const searchElement = document.querySelector("#table-search");
10
+ const refreshButton = document.querySelector("#refresh-schema");
11
+ const saveButton = document.querySelector("#save-diagram");
12
+ const newButton = document.querySelector("#new-diagram");
13
+ const openButton = document.querySelector("#open-diagram");
14
+ const editNameButton = document.querySelector("#edit-diagram-name");
15
+ const diagramNameElement = document.querySelector("#diagram-name");
16
+ const diagramNameDisplayElement = document.querySelector("#diagram-name-display");
17
+ const savedDiagramsElement = document.querySelector("#saved-diagrams");
18
+ const noticeElement = document.querySelector("#notice");
19
+ const shortcutHelpButton = document.querySelector("#shortcut-help");
20
+ const shortcutPanelElement = document.querySelector("#shortcut-panel");
21
+ const arrowButton = document.querySelector("#add-arrow");
22
+ const layoutTablesButton = document.querySelector("#layout-tables");
23
+
24
+ let schema;
25
+ let applicationSchema;
26
+ let state;
27
+ let canvas;
28
+ let relationFrame;
29
+ let isPanning = false;
30
+ let spacePressed = false;
31
+ let lastPointer = null;
32
+ let lastCanvasPointer = null;
33
+ const tableObjects = new Map();
34
+ let relationshipObjects = [];
35
+ const noteObjects = new Map();
36
+ const arrowObjects = new Map();
37
+ let noticeTimer;
38
+ let currentDiagramFilename = null;
39
+ let diagramsDirectory = "docs/erd";
40
+ let isSaving = false;
41
+ let isEditingName = false;
42
+ let nameBeforeEditing = "Untitled ERD";
43
+ let arrowMode = false;
44
+ let drawingArrow = null;
45
+ let arrowPreview = null;
46
+ let editingArrowId = null;
47
+ const arrowEndpointHandles = new Map();
48
+ const changeTracker = CanvasERDDocument.createChangeTracker(updateSaveButton);
49
+ const layoutEngine = new ELK({ workerUrl: "/assets/elk-worker.min.js" });
50
+
51
+ function layoutPositions(nextSchema) {
52
+ return CanvasERDLayout.layoutEntities(nextSchema, layoutEngine, {
53
+ width: () => CanvasERDDocument.CARD_WIDTH,
54
+ height: CanvasERDDocument.tableHeight
55
+ });
56
+ }
57
+
58
+ async function createInitialState(nextSchema) {
59
+ try {
60
+ const positions = await layoutPositions(nextSchema);
61
+ return CanvasERDDocument.createState(nextSchema, positions);
62
+ } catch (error) {
63
+ console.warn("ELK layout failed; using the fallback layout.", error);
64
+ showNotice("ELK layout failed; using the fallback layout.", true);
65
+ return CanvasERDDocument.createState(nextSchema);
66
+ }
67
+ }
68
+
69
+ function updateSaveButton() {
70
+ saveButton.disabled = isSaving || !changeTracker.isDirty();
71
+ diagramNameElement.disabled = isSaving;
72
+ editNameButton.disabled = isSaving;
73
+ }
74
+
75
+ function markDirty() {
76
+ changeTracker.markDirty();
77
+ }
78
+
79
+ function setDiagramName(value) {
80
+ const name = CanvasERDDocument.diagramName(value);
81
+ diagramNameElement.value = name;
82
+ diagramNameDisplayElement.textContent = name;
83
+ }
84
+
85
+ function beginNameEditing() {
86
+ if (isSaving) return;
87
+ isEditingName = true;
88
+ nameBeforeEditing = diagramNameElement.value;
89
+ diagramNameDisplayElement.hidden = true;
90
+ editNameButton.hidden = true;
91
+ diagramNameElement.hidden = false;
92
+ diagramNameElement.focus();
93
+ diagramNameElement.select();
94
+ }
95
+
96
+ function finishNameEditing(cancel = false) {
97
+ if (!isEditingName) return;
98
+
99
+ isEditingName = false;
100
+ const nextName = cancel ? nameBeforeEditing : CanvasERDDocument.diagramName(diagramNameElement.value);
101
+ setDiagramName(nextName);
102
+ diagramNameElement.hidden = true;
103
+ diagramNameDisplayElement.hidden = false;
104
+ editNameButton.hidden = false;
105
+ if (!cancel && nextName !== nameBeforeEditing) markDirty();
106
+ }
107
+
108
+ function showSavedDiagrams(visible) {
109
+ savedDiagramsElement.hidden = !visible;
110
+ openButton.setAttribute("aria-expanded", String(visible));
111
+ if (visible) savedDiagramsElement.focus();
112
+ }
113
+
114
+ function openSavedDiagrams() {
115
+ showSavedDiagrams(true);
116
+ if (typeof savedDiagramsElement.showPicker !== "function") return;
117
+ try {
118
+ savedDiagramsElement.showPicker();
119
+ } catch (_error) {
120
+ // The visible select remains available when the browser cannot open it programmatically.
121
+ }
122
+ }
123
+
124
+ function showShortcutHelp(visible) {
125
+ shortcutPanelElement.hidden = !visible;
126
+ shortcutHelpButton.setAttribute("aria-expanded", String(visible));
127
+ }
128
+
129
+ function setTablesCollapsed(collapsed) {
130
+ tablesSidebarElement.classList.toggle("collapsed", collapsed);
131
+ toggleTablesButton.setAttribute("aria-expanded", String(!collapsed));
132
+ const label = collapsed ? "Show tables" : "Hide tables";
133
+ toggleTablesButton.setAttribute("aria-label", label);
134
+ toggleTablesButton.title = `${label} (T)`;
135
+ }
136
+
137
+ function updateTableCaching() {
138
+ const enabled = CanvasERDDocument.shouldCacheTable(canvas.getZoom(), canvas.getRetinaScaling());
139
+ tableObjects.forEach((table) => {
140
+ if (table.objectCaching === enabled) return;
141
+ table.objectCaching = enabled;
142
+ table.dirty = true;
143
+ });
144
+ }
145
+
146
+ function updateNoteSelectionPadding() {
147
+ const padding = CanvasERDDocument.noteSelectionPadding(canvas.getZoom());
148
+ noteObjects.forEach((note) => {
149
+ if (note.padding === padding) return;
150
+ note.padding = padding;
151
+ note.setCoords();
152
+ });
153
+ }
154
+
155
+ function updateZoomDependentObjects() {
156
+ updateTableCaching();
157
+ updateNoteSelectionPadding();
158
+ }
159
+
160
+ const scheduleViewportPan = CanvasERDDocument.createPanScheduler((deltaX, deltaY) => {
161
+ canvas.setViewportTransform(CanvasERDDocument.panViewport(canvas.viewportTransform, deltaX, deltaY));
162
+ canvas.requestRenderAll();
163
+ markDirty();
164
+ });
165
+ const panQuality = CanvasERDDocument.createPanQualityController((enabled) => {
166
+ if (canvas.enableRetinaScaling === enabled) return;
167
+ canvas.enableRetinaScaling = enabled;
168
+ updateTableCaching();
169
+ canvas.setDimensions({ width: canvas.width, height: canvas.height });
170
+ });
171
+
172
+ function truncate(value, length) {
173
+ if (value.length <= length) return value;
174
+ return `${value.slice(0, length - 1)}…`;
175
+ }
176
+
177
+ function createTableObject(entity) {
178
+ const width = CanvasERDDocument.CARD_WIDTH;
179
+ const height = CanvasERDDocument.tableHeight(entity);
180
+ const objects = [
181
+ new fabric.Rect(CanvasERDDocument.topLeft({
182
+ left: 0,
183
+ top: 0,
184
+ width,
185
+ height,
186
+ fill: "#ffffff",
187
+ stroke: "#b8c2d1",
188
+ strokeWidth: 1,
189
+ rx: 8,
190
+ ry: 8,
191
+ shadow: "rgba(15, 23, 42, 0.14) 0 4px 12px"
192
+ })),
193
+ new fabric.Rect(CanvasERDDocument.topLeft({
194
+ left: 0,
195
+ top: 0,
196
+ width,
197
+ height: CanvasERDDocument.HEADER_HEIGHT,
198
+ fill: "#243b63",
199
+ rx: 8,
200
+ ry: 8
201
+ })),
202
+ new fabric.Rect(CanvasERDDocument.topLeft({
203
+ left: 0,
204
+ top: CanvasERDDocument.HEADER_HEIGHT - 8,
205
+ width,
206
+ height: 8,
207
+ fill: "#243b63"
208
+ })),
209
+ new fabric.FabricText(truncate(entity.label, 34), CanvasERDDocument.uncachedText({
210
+ left: 14,
211
+ top: 9,
212
+ fill: "#ffffff",
213
+ fontFamily: CanvasERDDocument.CANVAS_FONT_FAMILY,
214
+ fontSize: 16,
215
+ fontWeight: "600"
216
+ })),
217
+ new fabric.FabricText(truncate(entity.table_name || entity.name, 42), CanvasERDDocument.uncachedText({
218
+ left: 14,
219
+ top: 33,
220
+ fill: "#c9d7ee",
221
+ fontFamily: CanvasERDDocument.CANVAS_FONT_FAMILY,
222
+ fontSize: 10
223
+ }))
224
+ ];
225
+
226
+ const attributes = entity.attributes.length > 0 ? entity.attributes : [{ name: "Attributes hidden", type: "" }];
227
+ attributes.forEach((attribute, index) => {
228
+ const top = CanvasERDDocument.HEADER_HEIGHT + 5 + index * CanvasERDDocument.ROW_HEIGHT;
229
+ const markers = [
230
+ attribute.primary_key ? "PK" : null,
231
+ attribute.foreign_key ? "FK" : null
232
+ ].filter(Boolean).join("/");
233
+ const name = markers ? `${markers} ${attribute.name}` : attribute.name;
234
+
235
+ if (index > 0) {
236
+ objects.push(new fabric.Rect(CanvasERDDocument.topLeft({
237
+ left: 10,
238
+ top: top - 3,
239
+ width: width - 20,
240
+ height: 1,
241
+ fill: "#edf0f5",
242
+ stroke: "#edf0f5",
243
+ strokeWidth: 0,
244
+ selectable: false,
245
+ evented: false
246
+ })));
247
+ }
248
+
249
+ objects.push(new fabric.FabricText(truncate(name, 28), CanvasERDDocument.uncachedText({
250
+ left: 13,
251
+ top,
252
+ fill: attribute.primary_key ? "#172554" : "#263447",
253
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
254
+ fontSize: 11,
255
+ fontWeight: attribute.primary_key ? "600" : "400"
256
+ })));
257
+ objects.push(new fabric.FabricText(truncate(attribute.type || "", 16), CanvasERDDocument.uncachedText({
258
+ left: width - 13,
259
+ top,
260
+ originX: "right",
261
+ fill: "#6b778c",
262
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
263
+ fontSize: 10
264
+ })));
265
+ });
266
+
267
+ const position = state.positions[entity.id];
268
+ const group = new fabric.Group(objects, CanvasERDDocument.topLeft({
269
+ left: position.x,
270
+ top: position.y,
271
+ hasControls: false,
272
+ lockScalingX: true,
273
+ lockScalingY: true,
274
+ lockRotation: true,
275
+ objectCaching: CanvasERDDocument.shouldCacheTable(canvas.getZoom(), canvas.getRetinaScaling()),
276
+ borderColor: "#2563eb",
277
+ cornerColor: "#2563eb"
278
+ }));
279
+ group.canvasErdType = "entity";
280
+ group.entityId = entity.id;
281
+ return group;
282
+ }
283
+
284
+ function addTable(entity) {
285
+ if (tableObjects.has(entity.id)) return;
286
+
287
+ const table = createTableObject(entity);
288
+ tableObjects.set(entity.id, table);
289
+ canvas.add(table);
290
+ }
291
+
292
+ function removeTable(entityId) {
293
+ const table = tableObjects.get(entityId);
294
+ if (!table) return;
295
+
296
+ canvas.remove(table);
297
+ tableObjects.delete(entityId);
298
+ }
299
+
300
+ function tableBounds(table) {
301
+ return {
302
+ left: table.left,
303
+ top: table.top,
304
+ width: table.getScaledWidth(),
305
+ height: table.getScaledHeight()
306
+ };
307
+ }
308
+
309
+ function relationStyle(relationship) {
310
+ return {
311
+ fill: "",
312
+ stroke: relationship.indirect ? "#94a3b8" : "#62748e",
313
+ strokeWidth: 1.5,
314
+ strokeDashArray: relationship.indirect ? [7, 6] : null,
315
+ selectable: false,
316
+ evented: false,
317
+ objectCaching: false
318
+ };
319
+ }
320
+
321
+ function createRelationshipObjects(relationship, source, destination) {
322
+ const sourceBounds = tableBounds(source);
323
+ const destinationBounds = tableBounds(destination);
324
+ const style = relationStyle(relationship);
325
+ let connector;
326
+ let labelPosition;
327
+
328
+ if (source === destination) {
329
+ const right = sourceBounds.left + sourceBounds.width;
330
+ const top = sourceBounds.top;
331
+ const centerY = top + sourceBounds.height / 2;
332
+ const points = [
333
+ { 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 }
338
+ ];
339
+ connector = new fabric.Polyline(points, style);
340
+ labelPosition = { x: right + 48, y: top - 28 };
341
+ } else {
342
+ const sourceCenter = {
343
+ x: sourceBounds.left + sourceBounds.width / 2,
344
+ y: sourceBounds.top + sourceBounds.height / 2
345
+ };
346
+ const destinationCenter = {
347
+ x: destinationBounds.left + destinationBounds.width / 2,
348
+ y: destinationBounds.top + destinationBounds.height / 2
349
+ };
350
+ connector = new fabric.Line([
351
+ sourceCenter.x,
352
+ sourceCenter.y,
353
+ destinationCenter.x,
354
+ destinationCenter.y
355
+ ], style);
356
+ labelPosition = {
357
+ x: (sourceCenter.x + destinationCenter.x) / 2,
358
+ y: (sourceCenter.y + destinationCenter.y) / 2
359
+ };
360
+ }
361
+
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];
377
+ }
378
+
379
+ function renderRelationships() {
380
+ relationshipObjects.forEach((object) => canvas.remove(object));
381
+ relationshipObjects = [];
382
+
383
+ CanvasERDDocument.visibleRelationships(schema, state.includedEntityIds).forEach((relationship) => {
384
+ const source = tableObjects.get(relationship.source_id);
385
+ const destination = tableObjects.get(relationship.destination_id);
386
+ if (!source || !destination) return;
387
+
388
+ const objects = createRelationshipObjects(relationship, source, destination);
389
+ relationshipObjects.push(...objects);
390
+ canvas.add(...objects);
391
+ });
392
+
393
+ [...relationshipObjects].reverse().forEach((object) => canvas.sendObjectToBack(object));
394
+ canvas.requestRenderAll();
395
+ updateStatus();
396
+ }
397
+
398
+ function scheduleRelationshipRender() {
399
+ if (relationFrame) return;
400
+ relationFrame = requestAnimationFrame(() => {
401
+ relationFrame = null;
402
+ renderRelationships();
403
+ renderArrows();
404
+ });
405
+ }
406
+
407
+ function renderTables() {
408
+ const included = new Set(state.includedEntityIds);
409
+ schema.entities.forEach((entity) => {
410
+ if (included.has(entity.id)) addTable(entity);
411
+ else removeTable(entity.id);
412
+ });
413
+ renderRelationships();
414
+ renderArrows();
415
+ }
416
+
417
+ function rebuildTables() {
418
+ relationshipObjects.forEach((object) => canvas.remove(object));
419
+ relationshipObjects = [];
420
+ tableObjects.forEach((object) => canvas.remove(object));
421
+ tableObjects.clear();
422
+ renderTables();
423
+ }
424
+
425
+ async function layoutTables() {
426
+ if (layoutTablesButton.disabled) return;
427
+ const layoutSchema = CanvasERDLayout.schemaForEntityIds(schema, state.includedEntityIds);
428
+ if (layoutSchema.entities.length === 0) {
429
+ showNotice("There are no tables to lay out.", true);
430
+ return;
431
+ }
432
+
433
+ layoutTablesButton.disabled = true;
434
+ showNotice(`Laying out ${layoutSchema.entities.length} tables…`);
435
+ try {
436
+ const positions = await layoutPositions(layoutSchema);
437
+ state.positions = { ...state.positions, ...positions };
438
+ canvas.discardActiveObject();
439
+ rebuildTables();
440
+ markDirty();
441
+ showNotice(`Laid out ${layoutSchema.entities.length} tables.`);
442
+ } catch (error) {
443
+ console.warn("ELK layout failed.", error);
444
+ showNotice("Could not lay out tables.", true);
445
+ } finally {
446
+ layoutTablesButton.disabled = false;
447
+ }
448
+ }
449
+
450
+ function createNoteObject(note) {
451
+ const object = new fabric.Textbox(note.text, CanvasERDDocument.uncachedText({
452
+ ...CanvasERDDocument.noteCanvasGeometry(note),
453
+ fill: "#4a3f12",
454
+ backgroundColor: "#fff2a8",
455
+ fontFamily: CanvasERDDocument.CANVAS_FONT_FAMILY,
456
+ fontSize: 15,
457
+ lineHeight: 1.25,
458
+ padding: CanvasERDDocument.noteSelectionPadding(canvas.getZoom()),
459
+ borderColor: "#d09b16",
460
+ editingBorderColor: "#d09b16",
461
+ cornerColor: "#d09b16",
462
+ transparentCorners: false,
463
+ lockScalingY: true
464
+ }));
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
+ };
478
+ object.canvasErdType = "note";
479
+ object.noteId = note.id;
480
+ return object;
481
+ }
482
+
483
+ function renderNotes() {
484
+ state.notes.forEach((note) => {
485
+ const object = createNoteObject(note);
486
+ noteObjects.set(note.id, object);
487
+ canvas.add(object);
488
+ });
489
+ }
490
+
491
+ function attachmentTarget(attachment) {
492
+ if (!attachment) return null;
493
+ if (attachment.type === "entity") return tableObjects.get(attachment.id);
494
+ if (attachment.type === "note") return noteObjects.get(attachment.id);
495
+ return null;
496
+ }
497
+
498
+ function resolveArrowEndpoint(endpoint) {
499
+ const target = attachmentTarget(endpoint.attachment);
500
+ if (!target) return { x: endpoint.x, y: endpoint.y };
501
+
502
+ const point = CanvasERDDocument.pointAtAnchor(target.getBoundingRect(), endpoint.attachment);
503
+ endpoint.x = point.x;
504
+ endpoint.y = point.y;
505
+ return point;
506
+ }
507
+
508
+ function attachableTargetAt(point) {
509
+ const objects = [...noteObjects.values(), ...tableObjects.values()].reverse();
510
+ return objects.find((object) => {
511
+ const bounds = object.getBoundingRect();
512
+ return point.x >= bounds.left && point.x <= bounds.left + bounds.width &&
513
+ point.y >= bounds.top && point.y <= bounds.top + bounds.height;
514
+ }) || null;
515
+ }
516
+
517
+ function endpointAt(point, target, free) {
518
+ const attachment = free ? null : CanvasERDDocument.arrowAttachment(target);
519
+ if (!attachment) return { x: point.x, y: point.y, attachment: null };
520
+
521
+ const snapped = CanvasERDDocument.snapPointToBounds(point, target.getBoundingRect());
522
+ return {
523
+ ...snapped.point,
524
+ attachment: { ...attachment, ...snapped.anchor }
525
+ };
526
+ }
527
+
528
+ function arrowEndpoint(eventData) {
529
+ const point = eventData.scenePoint || canvas.getScenePoint(eventData.e);
530
+ return endpointAt(point, eventData.target, eventData.e.ctrlKey);
531
+ }
532
+
533
+ function createArrowObject(arrow, options = {}) {
534
+ const start = resolveArrowEndpoint(arrow.start);
535
+ const end = resolveArrowEndpoint(arrow.end);
536
+ 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",
540
+ objectCaching: false,
541
+ perPixelTargetFind: true,
542
+ padding: 6,
543
+ borderColor: "#2563eb",
544
+ cornerColor: "#2563eb",
545
+ transparentCorners: false,
546
+ selectable: options.selectable !== false,
547
+ evented: options.evented !== false,
548
+ lockSkewingX: true,
549
+ lockSkewingY: true
550
+ });
551
+ object._render = function (context) {
552
+ fabric.Line.prototype._render.call(this, context);
553
+ const points = this.calcLinePoints();
554
+ const angle = Math.atan2(points.y2 - points.y1, points.x2 - points.x1);
555
+ context.save();
556
+ context.translate(points.x2, points.y2);
557
+ context.rotate(angle);
558
+ context.fillStyle = this.stroke;
559
+ 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();
565
+ context.restore();
566
+ };
567
+ object.canvasErdType = options.preview ? "arrow-preview" : "arrow";
568
+ object.arrowId = arrow.id;
569
+ return object;
570
+ }
571
+
572
+ function restackCanvasObjects() {
573
+ canvas.getObjects()
574
+ .map((object, index) => ({ object, index }))
575
+ .sort((left, right) => {
576
+ const layerDifference = CanvasERDDocument.canvasLayer(left.object.canvasErdType) -
577
+ CanvasERDDocument.canvasLayer(right.object.canvasErdType);
578
+ return layerDifference || left.index - right.index;
579
+ })
580
+ .forEach(({ object }, index) => canvas.moveObjectTo(object, index));
581
+ }
582
+
583
+ function renderArrows() {
584
+ arrowObjects.forEach((object) => canvas.remove(object));
585
+ arrowObjects.clear();
586
+ state.arrows.forEach((arrow) => {
587
+ const editable = !arrowMode && arrow.id !== editingArrowId;
588
+ const object = createArrowObject(arrow, { selectable: editable, evented: editable });
589
+ arrowObjects.set(arrow.id, object);
590
+ canvas.add(object);
591
+ });
592
+ restackCanvasObjects();
593
+ canvas.requestRenderAll();
594
+ }
595
+
596
+ function renderArrowPreview() {
597
+ if (arrowPreview) canvas.remove(arrowPreview);
598
+ arrowPreview = createArrowObject(drawingArrow, {
599
+ stroke: "#2563eb",
600
+ selectable: false,
601
+ evented: false,
602
+ preview: true
603
+ });
604
+ canvas.add(arrowPreview);
605
+ canvas.requestRenderAll();
606
+ }
607
+
608
+ function setArrowMode(active) {
609
+ if (active && editingArrowId) finishArrowEndpointEditing();
610
+ arrowMode = active;
611
+ arrowButton.setAttribute("aria-pressed", String(active));
612
+ canvas.selection = !active;
613
+ canvas.setCursor(active ? "crosshair" : "default");
614
+ [...tableObjects.values(), ...noteObjects.values(), ...arrowObjects.values()].forEach((object) => {
615
+ object.selectable = !active;
616
+ });
617
+ arrowObjects.forEach((object) => {
618
+ object.evented = !active;
619
+ });
620
+ if (active) canvas.discardActiveObject();
621
+ canvas.requestRenderAll();
622
+ }
623
+
624
+ function cancelArrowDrawing() {
625
+ if (arrowPreview) canvas.remove(arrowPreview);
626
+ arrowPreview = null;
627
+ drawingArrow = null;
628
+ setArrowMode(false);
629
+ }
630
+
631
+ function finishArrowDrawing(eventData) {
632
+ if (!drawingArrow) return;
633
+ drawingArrow.end = arrowEndpoint(eventData);
634
+ const distance = Math.hypot(
635
+ drawingArrow.end.x - drawingArrow.start.x,
636
+ drawingArrow.end.y - drawingArrow.start.y
637
+ );
638
+ if (arrowPreview) canvas.remove(arrowPreview);
639
+ arrowPreview = null;
640
+
641
+ if (distance >= 4) {
642
+ state.arrows.push(drawingArrow);
643
+ drawingArrow = null;
644
+ renderArrows();
645
+ setArrowMode(false);
646
+ canvas.requestRenderAll();
647
+ markDirty();
648
+ } else {
649
+ drawingArrow = null;
650
+ setArrowMode(false);
651
+ }
652
+ }
653
+
654
+ function updateArrowState(object) {
655
+ const arrow = state.arrows.find((candidate) => candidate.id === object.arrowId);
656
+ if (!arrow) return;
657
+ const { start, end } = CanvasERDDocument.transformedArrowEndpoints(
658
+ object.calcLinePoints(),
659
+ object.calcTransformMatrix()
660
+ );
661
+ arrow.start = { x: start.x, y: start.y, attachment: null };
662
+ arrow.end = { x: end.x, y: end.y, attachment: null };
663
+ markDirty();
664
+ }
665
+
666
+ function createArrowEndpointHandle(arrow, endpointName) {
667
+ const point = resolveArrowEndpoint(arrow[endpointName]);
668
+ const handle = new fabric.Circle({
669
+ left: point.x,
670
+ top: point.y,
671
+ originX: "center",
672
+ originY: "center",
673
+ radius: 7,
674
+ fill: endpointName === "end" ? "#2563eb" : "#ffffff",
675
+ stroke: "#2563eb",
676
+ strokeWidth: 2,
677
+ hasControls: false,
678
+ hasBorders: false,
679
+ objectCaching: false
680
+ });
681
+ handle.canvasErdType = "arrow-endpoint";
682
+ handle.arrowId = arrow.id;
683
+ handle.endpointName = endpointName;
684
+ return handle;
685
+ }
686
+
687
+ function beginArrowEndpointEditing(arrowId) {
688
+ if (arrowMode) cancelArrowDrawing();
689
+ if (editingArrowId) finishArrowEndpointEditing();
690
+ const arrow = state.arrows.find((candidate) => candidate.id === arrowId);
691
+ const object = arrowObjects.get(arrowId);
692
+ if (!arrow || !object) return;
693
+
694
+ editingArrowId = arrowId;
695
+ canvas.discardActiveObject();
696
+ object.selectable = false;
697
+ object.evented = false;
698
+ ["start", "end"].forEach((endpointName) => {
699
+ const handle = createArrowEndpointHandle(arrow, endpointName);
700
+ arrowEndpointHandles.set(endpointName, handle);
701
+ canvas.add(handle);
702
+ });
703
+ canvas.requestRenderAll();
704
+ }
705
+
706
+ function finishArrowEndpointEditing() {
707
+ if (!editingArrowId) return;
708
+ arrowEndpointHandles.forEach((handle) => canvas.remove(handle));
709
+ arrowEndpointHandles.clear();
710
+ const object = arrowObjects.get(editingArrowId);
711
+ if (object) {
712
+ object.selectable = true;
713
+ object.evented = true;
714
+ }
715
+ editingArrowId = null;
716
+ canvas.discardActiveObject();
717
+ canvas.requestRenderAll();
718
+ }
719
+
720
+ function updateArrowEndpointHandle(eventData) {
721
+ const handle = eventData.target;
722
+ const arrow = state.arrows.find((candidate) => candidate.id === handle.arrowId);
723
+ if (!arrow) return;
724
+ const point = eventData.pointer || { x: handle.left, y: handle.top };
725
+ const target = eventData.e.ctrlKey ? null : attachableTargetAt(point);
726
+ const endpoint = endpointAt(point, target, eventData.e.ctrlKey);
727
+ arrow[handle.endpointName] = endpoint;
728
+ handle.set({ left: endpoint.x, top: endpoint.y });
729
+ handle.setCoords();
730
+ renderArrows();
731
+ markDirty();
732
+ }
733
+
734
+ function removeActiveArrows() {
735
+ const active = canvas.getActiveObject();
736
+ if (!active) return false;
737
+ if (active.canvasErdType === "arrow-endpoint" && editingArrowId) {
738
+ const arrowId = editingArrowId;
739
+ finishArrowEndpointEditing();
740
+ state.arrows = state.arrows.filter((arrow) => arrow.id !== arrowId);
741
+ renderArrows();
742
+ markDirty();
743
+ return true;
744
+ }
745
+ const objects = active.canvasErdType === "arrow"
746
+ ? [active]
747
+ : (typeof active.getObjects === "function" ? active.getObjects() : []);
748
+ const ids = objects.filter((object) => object.canvasErdType === "arrow").map((object) => object.arrowId);
749
+ if (ids.length === 0) return false;
750
+
751
+ const removed = new Set(ids);
752
+ canvas.discardActiveObject();
753
+ state.arrows = state.arrows.filter((arrow) => !removed.has(arrow.id));
754
+ renderArrows();
755
+ markDirty();
756
+ return true;
757
+ }
758
+
759
+ function addNote(position = canvas.getVpCenter()) {
760
+ if (arrowMode) cancelArrowDrawing();
761
+ if (editingArrowId) finishArrowEndpointEditing();
762
+ const origin = CanvasERDDocument.notePositionAt(position);
763
+ const note = {
764
+ id: globalThis.crypto && crypto.randomUUID ? crypto.randomUUID() : `note-${Date.now()}`,
765
+ text: "Double-click to edit",
766
+ x: origin.x,
767
+ y: origin.y,
768
+ width: 220,
769
+ angle: 0
770
+ };
771
+ state.notes.push(note);
772
+
773
+ const object = createNoteObject(note);
774
+ noteObjects.set(note.id, object);
775
+ canvas.add(object);
776
+ restackCanvasObjects();
777
+ canvas.setActiveObject(object);
778
+ object.enterEditing();
779
+ object.selectAll();
780
+ canvas.requestRenderAll();
781
+ markDirty();
782
+ }
783
+
784
+ function updateObjectState(object) {
785
+ if (object.canvasErdType === "entity") {
786
+ state.positions[object.entityId] = { x: object.left, y: object.top };
787
+ scheduleRelationshipRender();
788
+ } else if (object.canvasErdType === "note") {
789
+ const note = state.notes.find((candidate) => candidate.id === object.noteId);
790
+ if (!note) return;
791
+ Object.assign(note, CanvasERDDocument.noteStateGeometry(object));
792
+ scheduleRelationshipRender();
793
+ }
794
+ markDirty();
795
+ }
796
+
797
+ function removeActiveNote() {
798
+ const active = canvas.getActiveObject();
799
+ if (!active || active.canvasErdType !== "note" || active.isEditing) return false;
800
+
801
+ state.arrows.forEach((arrow) => {
802
+ [arrow.start, arrow.end].forEach((endpoint) => {
803
+ if (endpoint.attachment?.type !== "note" || endpoint.attachment.id !== active.noteId) return;
804
+ const point = resolveArrowEndpoint(endpoint);
805
+ endpoint.x = point.x;
806
+ endpoint.y = point.y;
807
+ endpoint.attachment = null;
808
+ });
809
+ });
810
+ canvas.remove(active);
811
+ noteObjects.delete(active.noteId);
812
+ state.notes = state.notes.filter((note) => note.id !== active.noteId);
813
+ renderArrows();
814
+ markDirty();
815
+ return true;
816
+ }
817
+
818
+ function removeActiveTables() {
819
+ const entityIds = CanvasERDDocument.selectedEntityIds(canvas.getActiveObject());
820
+ if (entityIds.length === 0) return false;
821
+
822
+ const removed = new Set(entityIds);
823
+ canvas.discardActiveObject();
824
+ state.includedEntityIds = state.includedEntityIds.filter((id) => !removed.has(id));
825
+ document.querySelectorAll("[data-entity-id]").forEach((checkbox) => {
826
+ if (removed.has(checkbox.dataset.entityId)) checkbox.checked = false;
827
+ });
828
+ renderTables();
829
+ markDirty();
830
+ return true;
831
+ }
832
+
833
+ function updateStatus() {
834
+ if (!schema) return;
835
+ const tables = state.includedEntityIds.length;
836
+ const relationships = CanvasERDDocument.visibleRelationships(schema, state.includedEntityIds).length;
837
+ statusElement.textContent = `${schema.name || "Rails application"} · ${tables} tables · ${relationships} relationships`;
838
+ }
839
+
840
+ function setAllTables(included) {
841
+ state.includedEntityIds = included ? schema.entities.map((entity) => entity.id) : [];
842
+ document.querySelectorAll("[data-entity-id]").forEach((checkbox) => {
843
+ checkbox.checked = included;
844
+ });
845
+ renderTables();
846
+ markDirty();
847
+ }
848
+
849
+ function renderTableList() {
850
+ const fragment = document.createDocumentFragment();
851
+ schema.entities.forEach((entity) => {
852
+ const row = document.createElement("label");
853
+ row.className = "table-option";
854
+ row.dataset.search = `${entity.name} ${entity.table_name || ""}`.toLowerCase();
855
+
856
+ const checkbox = document.createElement("input");
857
+ checkbox.type = "checkbox";
858
+ checkbox.checked = state.includedEntityIds.includes(entity.id);
859
+ checkbox.dataset.entityId = entity.id;
860
+ checkbox.addEventListener("change", () => {
861
+ state = CanvasERDDocument.setEntityIncluded(state, entity.id, checkbox.checked);
862
+ renderTables();
863
+ markDirty();
864
+ });
865
+
866
+ const text = document.createElement("span");
867
+ text.textContent = entity.label;
868
+ row.append(checkbox, text);
869
+ fragment.append(row);
870
+ });
871
+ tableListElement.replaceChildren(fragment);
872
+ }
873
+
874
+ function filterTableList() {
875
+ const query = searchElement.value.trim().toLowerCase();
876
+ tableListElement.querySelectorAll(".table-option").forEach((row) => {
877
+ row.hidden = query !== "" && !row.dataset.search.includes(query);
878
+ });
879
+ }
880
+
881
+ function showNotice(message, error = false) {
882
+ clearTimeout(noticeTimer);
883
+ noticeElement.textContent = message;
884
+ noticeElement.classList.toggle("error", error);
885
+ noticeElement.hidden = false;
886
+ noticeTimer = setTimeout(() => { noticeElement.hidden = true; }, 5000);
887
+ }
888
+
889
+ async function refreshSchema() {
890
+ refreshButton.disabled = true;
891
+ refreshButton.setAttribute("aria-label", "Refreshing schema");
892
+ refreshButton.title = "Refreshing schema";
893
+
894
+ try {
895
+ const response = await fetch("/api/schema?refresh=1", { cache: "no-store" });
896
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
897
+
898
+ const nextSchema = await response.json();
899
+ const result = CanvasERDDocument.reconcileState(state, schema, nextSchema);
900
+ schema = nextSchema;
901
+ state = result.state;
902
+ renderTableList();
903
+ filterTableList();
904
+ rebuildTables();
905
+ applicationSchema = schema;
906
+ markDirty();
907
+ showNotice(CanvasERDDocument.changeSummary(result.changes));
908
+ } catch (error) {
909
+ showNotice(`Could not refresh schema: ${error.message}`, true);
910
+ } finally {
911
+ refreshButton.disabled = false;
912
+ refreshButton.setAttribute("aria-label", "Refresh schema");
913
+ refreshButton.title = "Refresh schema";
914
+ }
915
+ }
916
+
917
+ function exportedPng() {
918
+ const bounds = diagramBounds();
919
+ const viewport = [...canvas.viewportTransform];
920
+ const backgroundColor = canvas.backgroundColor;
921
+
922
+ try {
923
+ if (bounds) {
924
+ const padding = 64;
925
+ const width = Math.ceil(bounds.width + padding * 2);
926
+ const height = Math.ceil(bounds.height + padding * 2);
927
+ canvas.setViewportTransform([1, 0, 0, 1, padding - bounds.left, padding - bounds.top]);
928
+ canvas.backgroundColor = "#f7f8fb";
929
+ return canvas.toDataURL({
930
+ format: "png",
931
+ width,
932
+ height,
933
+ multiplier: CanvasERDPng.exportMultiplier(width, height)
934
+ });
935
+ }
936
+ return canvas.toDataURL({ format: "png" });
937
+ } finally {
938
+ canvas.backgroundColor = backgroundColor;
939
+ canvas.setViewportTransform(viewport);
940
+ canvas.requestRenderAll();
941
+ }
942
+ }
943
+
944
+ function diagramDocument() {
945
+ return {
946
+ format: "canvas_erd",
947
+ version: 1,
948
+ name: CanvasERDDocument.diagramName(diagramNameElement.value),
949
+ schema,
950
+ state: {
951
+ ...state,
952
+ viewportTransform: [...canvas.viewportTransform]
953
+ }
954
+ };
955
+ }
956
+
957
+ function embeddedDiagramPng() {
958
+ const imageBytes = CanvasERDPng.bytesFromDataUrl(exportedPng());
959
+ return CanvasERDPng.embedDocument(imageBytes, diagramDocument());
960
+ }
961
+
962
+ async function refreshSavedDiagrams(selectedName = currentDiagramFilename) {
963
+ const response = await fetch("/api/diagrams", { cache: "no-store" });
964
+ if (!response.ok) throw new Error(await response.text());
965
+
966
+ const result = await response.json();
967
+ diagramsDirectory = result.directory;
968
+ const selectedFilename = CanvasERDDocument.selectedDiagramFilename(selectedName, result.diagrams);
969
+ const fragment = document.createDocumentFragment();
970
+ const placeholder = document.createElement("option");
971
+ placeholder.value = "";
972
+ placeholder.textContent = "Untitled ERD";
973
+ placeholder.selected = selectedFilename === "";
974
+ fragment.append(placeholder);
975
+ result.diagrams.forEach((name) => {
976
+ const option = document.createElement("option");
977
+ option.value = name;
978
+ option.textContent = CanvasERDDocument.diagramName(name);
979
+ fragment.append(option);
980
+ });
981
+ savedDiagramsElement.replaceChildren(fragment);
982
+ savedDiagramsElement.value = selectedFilename;
983
+ }
984
+
985
+ async function saveDiagram() {
986
+ if (!changeTracker.isDirty() || isSaving) return;
987
+ if (editingArrowId) finishArrowEndpointEditing();
988
+
989
+ const requestedName = diagramNameElement.value.trim();
990
+ if (!requestedName) {
991
+ showNotice("Enter a diagram name before saving.", true);
992
+ diagramNameElement.focus();
993
+ return;
994
+ }
995
+
996
+ const filename = CanvasERDDocument.erdFilename(requestedName);
997
+ isSaving = true;
998
+ updateSaveButton();
999
+
1000
+ try {
1001
+ const png = embeddedDiagramPng();
1002
+ changeTracker.markClean();
1003
+ const response = await fetch(`/api/diagram?name=${encodeURIComponent(filename)}`, {
1004
+ method: "PUT",
1005
+ headers: { "content-type": "image/png" },
1006
+ body: png
1007
+ });
1008
+ if (!response.ok) throw new Error(await response.text());
1009
+
1010
+ currentDiagramFilename = filename;
1011
+ setDiagramName(filename);
1012
+ await refreshSavedDiagrams(filename);
1013
+ showNotice(`Saved ${diagramsDirectory}/${filename}.`);
1014
+ } catch (error) {
1015
+ markDirty();
1016
+ showNotice(`Could not save diagram: ${error.message}`, true);
1017
+ } finally {
1018
+ isSaving = false;
1019
+ updateSaveButton();
1020
+ }
1021
+ }
1022
+
1023
+ function displayDocument(documentData) {
1024
+ schema = documentData.schema;
1025
+ state = { ...documentData.state, arrows: documentData.state.arrows || [] };
1026
+ if (arrowMode) cancelArrowDrawing();
1027
+ if (editingArrowId) finishArrowEndpointEditing();
1028
+ canvas.discardActiveObject();
1029
+ canvas.clear();
1030
+ tableObjects.clear();
1031
+ relationshipObjects = [];
1032
+ noteObjects.clear();
1033
+ arrowObjects.clear();
1034
+ renderTableList();
1035
+ renderTables();
1036
+ renderNotes();
1037
+ renderArrows();
1038
+
1039
+ if (Array.isArray(state.viewportTransform) && state.viewportTransform.length === 6) {
1040
+ canvas.setViewportTransform(state.viewportTransform);
1041
+ updateZoomDependentObjects();
1042
+ canvas.requestRenderAll();
1043
+ } else {
1044
+ fitDiagram(false);
1045
+ }
1046
+ }
1047
+
1048
+ function confirmDiscardChanges() {
1049
+ return CanvasERDDocument.confirmDiscardChanges(
1050
+ changeTracker.isDirty(),
1051
+ (message) => globalThis.confirm(message)
1052
+ );
1053
+ }
1054
+
1055
+ async function loadDiagram(filename) {
1056
+ if (!filename) return;
1057
+
1058
+ const previousFilename = currentDiagramFilename;
1059
+ savedDiagramsElement.disabled = true;
1060
+ try {
1061
+ const response = await fetch(`/api/diagram?name=${encodeURIComponent(filename)}`, { cache: "no-store" });
1062
+ if (!response.ok) throw new Error(await response.text());
1063
+
1064
+ const documentData = CanvasERDPng.extractDocument(new Uint8Array(await response.arrayBuffer()));
1065
+ displayDocument(documentData);
1066
+ currentDiagramFilename = filename;
1067
+ setDiagramName(documentData.name || filename);
1068
+ changeTracker.markClean();
1069
+ showNotice(`Loaded ${diagramsDirectory}/${filename}.`);
1070
+ } catch (error) {
1071
+ savedDiagramsElement.value = previousFilename || "";
1072
+ showNotice(`Could not load diagram: ${error.message}`, true);
1073
+ } finally {
1074
+ savedDiagramsElement.disabled = false;
1075
+ }
1076
+ }
1077
+
1078
+ async function newDiagram() {
1079
+ if (newButton.disabled || !confirmDiscardChanges()) return;
1080
+
1081
+ newButton.disabled = true;
1082
+ loadingElement.textContent = "Laying out diagram…";
1083
+ loadingElement.hidden = false;
1084
+ try {
1085
+ const initialState = await createInitialState(applicationSchema);
1086
+ currentDiagramFilename = null;
1087
+ savedDiagramsElement.value = "";
1088
+ showSavedDiagrams(false);
1089
+ setDiagramName("Untitled ERD");
1090
+ displayDocument({ schema: applicationSchema, state: initialState });
1091
+ changeTracker.markClean();
1092
+ } finally {
1093
+ newButton.disabled = false;
1094
+ loadingElement.hidden = true;
1095
+ }
1096
+ }
1097
+
1098
+ function diagramBounds() {
1099
+ const objects = [
1100
+ ...tableObjects.values(),
1101
+ ...noteObjects.values(),
1102
+ ...arrowObjects.values(),
1103
+ ...relationshipObjects
1104
+ ];
1105
+ if (objects.length === 0) return null;
1106
+
1107
+ return objects.map((object) => object.getBoundingRect()).reduce((bounds, object) => {
1108
+ const left = Math.min(bounds.left, object.left);
1109
+ const top = Math.min(bounds.top, object.top);
1110
+ const right = Math.max(bounds.left + bounds.width, object.left + object.width);
1111
+ const bottom = Math.max(bounds.top + bounds.height, object.top + object.height);
1112
+ return { left, top, width: right - left, height: bottom - top };
1113
+ });
1114
+ }
1115
+
1116
+ function fitDiagram(trackChange = true) {
1117
+ const transform = CanvasERDDocument.fitViewport(
1118
+ diagramBounds(),
1119
+ { width: canvas.width, height: canvas.height }
1120
+ );
1121
+ canvas.setViewportTransform(transform);
1122
+ updateZoomDependentObjects();
1123
+ canvas.requestRenderAll();
1124
+ if (trackChange) markDirty();
1125
+ }
1126
+
1127
+ function zoom(multiplier) {
1128
+ const nextZoom = Math.min(2.5, Math.max(0.15, canvas.getZoom() * multiplier));
1129
+ canvas.zoomToPoint(canvas.getCenterPoint(), nextZoom);
1130
+ updateZoomDependentObjects();
1131
+ canvas.requestRenderAll();
1132
+ markDirty();
1133
+ }
1134
+
1135
+ function resizeCanvas() {
1136
+ const bounds = panelElement.getBoundingClientRect();
1137
+ canvas.setDimensions({
1138
+ width: Math.max(1, Math.floor(bounds.width)),
1139
+ height: Math.max(1, Math.floor(bounds.height))
1140
+ });
1141
+ }
1142
+
1143
+ function configureCanvasEvents() {
1144
+ canvas.on("mouse:dblclick", ({ target }) => {
1145
+ if (target?.canvasErdType === "arrow") beginArrowEndpointEditing(target.arrowId);
1146
+ });
1147
+ canvas.on("mouse:down", ({ target }) => {
1148
+ if (editingArrowId && target?.canvasErdType !== "arrow-endpoint") finishArrowEndpointEditing();
1149
+ });
1150
+ canvas.on("mouse:down", (eventData) => {
1151
+ if (!arrowMode || spacePressed) return;
1152
+ drawingArrow = {
1153
+ id: globalThis.crypto && crypto.randomUUID ? crypto.randomUUID() : `arrow-${Date.now()}`,
1154
+ start: arrowEndpoint(eventData),
1155
+ end: arrowEndpoint(eventData)
1156
+ };
1157
+ renderArrowPreview();
1158
+ });
1159
+ canvas.on("mouse:move", (eventData) => {
1160
+ if (!arrowMode || !drawingArrow) return;
1161
+ drawingArrow.end = arrowEndpoint(eventData);
1162
+ renderArrowPreview();
1163
+ });
1164
+ canvas.on("mouse:up", (eventData) => {
1165
+ if (arrowMode && drawingArrow) finishArrowDrawing(eventData);
1166
+ });
1167
+
1168
+ canvas.on("object:moving", (eventData) => {
1169
+ if (eventData.target.canvasErdType === "arrow-endpoint") updateArrowEndpointHandle(eventData);
1170
+ else if (eventData.target.canvasErdType !== "arrow") updateObjectState(eventData.target);
1171
+ });
1172
+ canvas.on("object:modified", (eventData) => {
1173
+ if (eventData.target.canvasErdType === "arrow") updateArrowState(eventData.target);
1174
+ else if (eventData.target.canvasErdType === "arrow-endpoint") updateArrowEndpointHandle(eventData);
1175
+ else updateObjectState(eventData.target);
1176
+ });
1177
+ canvas.on("text:changed", ({ target }) => updateObjectState(target));
1178
+
1179
+ canvas.on("mouse:wheel", ({ e }) => {
1180
+ if (e.ctrlKey) {
1181
+ panQuality.finish();
1182
+ const nextZoom = CanvasERDDocument.zoomForWheel(canvas.getZoom(), e.deltaY);
1183
+ canvas.zoomToPoint(new fabric.Point(e.offsetX, e.offsetY), nextZoom);
1184
+ updateZoomDependentObjects();
1185
+ canvas.requestRenderAll();
1186
+ markDirty();
1187
+ } else {
1188
+ panQuality.begin();
1189
+ scheduleViewportPan(e.deltaX, e.deltaY);
1190
+ }
1191
+ e.preventDefault();
1192
+ e.stopPropagation();
1193
+ });
1194
+
1195
+ canvas.on("mouse:down", ({ e, scenePoint }) => {
1196
+ const point = scenePoint || canvas.getScenePoint(e);
1197
+ lastCanvasPointer = { x: point.x, y: point.y };
1198
+ if (!spacePressed) return;
1199
+ isPanning = true;
1200
+ panQuality.begin();
1201
+ lastPointer = { x: e.clientX, y: e.clientY };
1202
+ canvas.selection = false;
1203
+ canvas.setCursor("grabbing");
1204
+ });
1205
+
1206
+ canvas.on("mouse:move", ({ e, scenePoint }) => {
1207
+ const point = scenePoint || canvas.getScenePoint(e);
1208
+ lastCanvasPointer = { x: point.x, y: point.y };
1209
+ if (!isPanning) return;
1210
+ panQuality.begin();
1211
+ const transform = [...canvas.viewportTransform];
1212
+ transform[4] += e.clientX - lastPointer.x;
1213
+ transform[5] += e.clientY - lastPointer.y;
1214
+ canvas.setViewportTransform(transform);
1215
+ lastPointer = { x: e.clientX, y: e.clientY };
1216
+ markDirty();
1217
+ });
1218
+
1219
+ canvas.on("mouse:up", () => {
1220
+ if (isPanning) panQuality.finish();
1221
+ isPanning = false;
1222
+ lastPointer = null;
1223
+ canvas.selection = true;
1224
+ canvas.setCursor(spacePressed ? "grab" : "default");
1225
+ });
1226
+
1227
+ document.addEventListener("keydown", (event) => {
1228
+ const editing = canvas.getActiveObject() && canvas.getActiveObject().isEditing;
1229
+ const acceptsText = ["INPUT", "TEXTAREA", "SELECT"].includes(event.target.tagName) || event.target.isContentEditable;
1230
+ const action = CanvasERDDocument.shortcutAction(event);
1231
+ const bareAction = ["addNote", "arrow", "toggleTables", "layoutTables", "zoomIn", "zoomOut", "fit", "help"].includes(action);
1232
+
1233
+ if (action && (!bareAction || (!editing && !acceptsText))) {
1234
+ event.preventDefault();
1235
+ if (event.repeat) return;
1236
+
1237
+ if (action === "save") {
1238
+ finishNameEditing();
1239
+ saveDiagram();
1240
+ } else if (action === "open") {
1241
+ finishNameEditing();
1242
+ openSavedDiagrams();
1243
+ } else if (action === "new") {
1244
+ finishNameEditing();
1245
+ newDiagram();
1246
+ } else if (action === "addNote") {
1247
+ addNote(lastCanvasPointer || canvas.getVpCenter());
1248
+ } else if (action === "arrow") {
1249
+ if (arrowMode) cancelArrowDrawing();
1250
+ else setArrowMode(true);
1251
+ } else if (action === "toggleTables") {
1252
+ setTablesCollapsed(!tablesSidebarElement.classList.contains("collapsed"));
1253
+ } else if (action === "layoutTables") {
1254
+ layoutTables();
1255
+ } else if (action === "zoomIn") {
1256
+ zoom(1.2);
1257
+ } else if (action === "zoomOut") {
1258
+ zoom(1 / 1.2);
1259
+ } else if (action === "fit") {
1260
+ fitDiagram();
1261
+ } else if (action === "help") {
1262
+ showShortcutHelp(shortcutPanelElement.hidden);
1263
+ }
1264
+ return;
1265
+ }
1266
+
1267
+ if (event.key === "Escape" && editingArrowId) {
1268
+ event.preventDefault();
1269
+ finishArrowEndpointEditing();
1270
+ } else if (event.key === "Escape" && arrowMode) {
1271
+ event.preventDefault();
1272
+ cancelArrowDrawing();
1273
+ } else if (event.key === "Escape" && !shortcutPanelElement.hidden) {
1274
+ event.preventDefault();
1275
+ showShortcutHelp(false);
1276
+ } else if (event.code === "Space" && !editing && !acceptsText) {
1277
+ event.preventDefault();
1278
+ spacePressed = true;
1279
+ canvas.skipTargetFind = true;
1280
+ canvas.setCursor("grab");
1281
+ } else if (["Delete", "Backspace"].includes(event.key) && (
1282
+ removeActiveTables() || removeActiveArrows() || removeActiveNote()
1283
+ )) {
1284
+ event.preventDefault();
1285
+ }
1286
+ });
1287
+
1288
+ document.addEventListener("keyup", (event) => {
1289
+ if (event.code !== "Space") return;
1290
+ spacePressed = false;
1291
+ isPanning = false;
1292
+ canvas.skipTargetFind = false;
1293
+ canvas.selection = true;
1294
+ canvas.setCursor(arrowMode ? "crosshair" : "default");
1295
+ });
1296
+ }
1297
+
1298
+ function configureControls() {
1299
+ refreshButton.addEventListener("click", refreshSchema);
1300
+ toggleTablesButton.addEventListener("click", () => {
1301
+ setTablesCollapsed(!tablesSidebarElement.classList.contains("collapsed"));
1302
+ });
1303
+ arrowButton.addEventListener("click", () => {
1304
+ if (arrowMode) cancelArrowDrawing();
1305
+ else setArrowMode(true);
1306
+ });
1307
+ saveButton.addEventListener("click", saveDiagram);
1308
+ newButton.addEventListener("click", newDiagram);
1309
+ openButton.addEventListener("click", () => {
1310
+ if (savedDiagramsElement.hidden) openSavedDiagrams();
1311
+ else showSavedDiagrams(false);
1312
+ });
1313
+ shortcutHelpButton.addEventListener("click", () => showShortcutHelp(shortcutPanelElement.hidden));
1314
+ editNameButton.addEventListener("click", beginNameEditing);
1315
+ diagramNameElement.addEventListener("blur", () => finishNameEditing());
1316
+ diagramNameElement.addEventListener("keydown", (event) => {
1317
+ if (event.key === "Enter") {
1318
+ event.preventDefault();
1319
+ finishNameEditing();
1320
+ } else if (event.key === "Escape") {
1321
+ event.preventDefault();
1322
+ finishNameEditing(true);
1323
+ }
1324
+ });
1325
+ savedDiagramsElement.addEventListener("change", async () => {
1326
+ const filename = savedDiagramsElement.value;
1327
+ showSavedDiagrams(false);
1328
+ if (!filename) {
1329
+ savedDiagramsElement.value = currentDiagramFilename || "";
1330
+ return;
1331
+ }
1332
+ if (!confirmDiscardChanges()) {
1333
+ savedDiagramsElement.value = currentDiagramFilename || "";
1334
+ return;
1335
+ }
1336
+ await loadDiagram(filename);
1337
+ });
1338
+ document.querySelector("#add-note").addEventListener("click", () => addNote());
1339
+ layoutTablesButton.addEventListener("click", layoutTables);
1340
+ document.querySelector("#zoom-in").addEventListener("click", () => zoom(1.2));
1341
+ document.querySelector("#zoom-out").addEventListener("click", () => zoom(1 / 1.2));
1342
+ document.querySelector("#reset-view").addEventListener("click", fitDiagram);
1343
+ document.querySelector("#select-all").addEventListener("click", () => setAllTables(true));
1344
+ document.querySelector("#select-none").addEventListener("click", () => setAllTables(false));
1345
+ searchElement.addEventListener("input", filterTableList);
1346
+ }
1347
+
1348
+ function initializeEditor(loadedDocument) {
1349
+ schema = loadedDocument.schema;
1350
+ applicationSchema = schema;
1351
+ state = loadedDocument.state || CanvasERDDocument.createState(schema);
1352
+ state = { ...state, arrows: state.arrows || [] };
1353
+ setDiagramName(loadedDocument.name);
1354
+ canvas = new fabric.Canvas("erd-canvas", {
1355
+ backgroundColor: "transparent",
1356
+ preserveObjectStacking: true,
1357
+ targetFindTolerance: 5,
1358
+ selectionColor: "rgba(37, 99, 235, 0.08)",
1359
+ selectionBorderColor: "#2563eb"
1360
+ });
1361
+
1362
+ resizeCanvas();
1363
+ configureCanvasEvents();
1364
+ configureControls();
1365
+ renderTableList();
1366
+ renderTables();
1367
+ renderNotes();
1368
+ renderArrows();
1369
+ if (Array.isArray(state.viewportTransform) && state.viewportTransform.length === 6) {
1370
+ canvas.setViewportTransform(state.viewportTransform);
1371
+ updateZoomDependentObjects();
1372
+ } else {
1373
+ fitDiagram(false);
1374
+ }
1375
+ new ResizeObserver(resizeCanvas).observe(panelElement);
1376
+ refreshSavedDiagrams().catch((error) => showNotice(`Could not list diagrams: ${error.message}`, true));
1377
+ loadingElement.hidden = true;
1378
+ }
1379
+
1380
+ fetch("/api/document", { cache: "no-store" })
1381
+ .then((response) => {
1382
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
1383
+ return response.json();
1384
+ })
1385
+ .then(async (loadedDocument) => {
1386
+ if (!loadedDocument.state) loadedDocument.state = await createInitialState(loadedDocument.schema);
1387
+ return loadedDocument;
1388
+ })
1389
+ .then(initializeEditor)
1390
+ .catch((error) => {
1391
+ statusElement.textContent = "Could not load the Rails ERD schema";
1392
+ loadingElement.textContent = error.message;
1393
+ loadingElement.classList.add("error");
1394
+ });