@antelopejs/dms-database 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +53 -0
  3. package/dist/automation/index.d.ts +2 -0
  4. package/dist/automation/index.js +13 -0
  5. package/dist/automation/index.js.map +1 -0
  6. package/dist/automation/tableWatch.d.ts +17 -0
  7. package/dist/automation/tableWatch.js +189 -0
  8. package/dist/automation/tableWatch.js.map +1 -0
  9. package/dist/config.d.ts +7 -0
  10. package/dist/config.js +12 -0
  11. package/dist/config.js.map +1 -0
  12. package/dist/db/index.d.ts +2 -0
  13. package/dist/db/index.js +19 -0
  14. package/dist/db/index.js.map +1 -0
  15. package/dist/db/models/diagram_layouts.model.d.ts +25 -0
  16. package/dist/db/models/diagram_layouts.model.js +29 -0
  17. package/dist/db/models/diagram_layouts.model.js.map +1 -0
  18. package/dist/db/models/diagram_notes.model.d.ts +23 -0
  19. package/dist/db/models/diagram_notes.model.js +15 -0
  20. package/dist/db/models/diagram_notes.model.js.map +1 -0
  21. package/dist/db/models/index.d.ts +4 -0
  22. package/dist/db/models/index.js +21 -0
  23. package/dist/db/models/index.js.map +1 -0
  24. package/dist/db/models/query_history.model.d.ts +25 -0
  25. package/dist/db/models/query_history.model.js +33 -0
  26. package/dist/db/models/query_history.model.js.map +1 -0
  27. package/dist/db/models/saved_queries.model.d.ts +24 -0
  28. package/dist/db/models/saved_queries.model.js +21 -0
  29. package/dist/db/models/saved_queries.model.js.map +1 -0
  30. package/dist/db/tables/diagram_layouts.table.d.ts +7 -0
  31. package/dist/db/tables/diagram_layouts.table.js +35 -0
  32. package/dist/db/tables/diagram_layouts.table.js.map +1 -0
  33. package/dist/db/tables/diagram_notes.table.d.ts +13 -0
  34. package/dist/db/tables/diagram_notes.table.js +59 -0
  35. package/dist/db/tables/diagram_notes.table.js.map +1 -0
  36. package/dist/db/tables/index.d.ts +4 -0
  37. package/dist/db/tables/index.js +21 -0
  38. package/dist/db/tables/index.js.map +1 -0
  39. package/dist/db/tables/query_history.table.d.ts +11 -0
  40. package/dist/db/tables/query_history.table.js +54 -0
  41. package/dist/db/tables/query_history.table.js.map +1 -0
  42. package/dist/db/tables/saved_queries.table.d.ts +12 -0
  43. package/dist/db/tables/saved_queries.table.js +58 -0
  44. package/dist/db/tables/saved_queries.table.js.map +1 -0
  45. package/dist/index.d.ts +8 -0
  46. package/dist/index.js +56 -0
  47. package/dist/index.js.map +1 -0
  48. package/dist/pages/data.d.ts +9 -0
  49. package/dist/pages/data.js +29 -0
  50. package/dist/pages/data.js.map +1 -0
  51. package/dist/pages/index.d.ts +4 -0
  52. package/dist/pages/index.js +21 -0
  53. package/dist/pages/index.js.map +1 -0
  54. package/dist/pages/overview.d.ts +9 -0
  55. package/dist/pages/overview.js +29 -0
  56. package/dist/pages/overview.js.map +1 -0
  57. package/dist/pages/query.d.ts +9 -0
  58. package/dist/pages/query.js +29 -0
  59. package/dist/pages/query.js.map +1 -0
  60. package/dist/pages/schemas.d.ts +9 -0
  61. package/dist/pages/schemas.js +29 -0
  62. package/dist/pages/schemas.js.map +1 -0
  63. package/dist/routes/data.d.ts +25 -0
  64. package/dist/routes/data.js +324 -0
  65. package/dist/routes/data.js.map +1 -0
  66. package/dist/routes/diagram.d.ts +47 -0
  67. package/dist/routes/diagram.js +200 -0
  68. package/dist/routes/diagram.js.map +1 -0
  69. package/dist/routes/health.d.ts +6 -0
  70. package/dist/routes/health.js +35 -0
  71. package/dist/routes/health.js.map +1 -0
  72. package/dist/routes/index.d.ts +6 -0
  73. package/dist/routes/index.js +23 -0
  74. package/dist/routes/index.js.map +1 -0
  75. package/dist/routes/query.d.ts +26 -0
  76. package/dist/routes/query.js +245 -0
  77. package/dist/routes/query.js.map +1 -0
  78. package/dist/routes/schemas.d.ts +8 -0
  79. package/dist/routes/schemas.js +35 -0
  80. package/dist/routes/schemas.js.map +1 -0
  81. package/dist/routes/tables.d.ts +23 -0
  82. package/dist/routes/tables.js +94 -0
  83. package/dist/routes/tables.js.map +1 -0
  84. package/dist/service/fieldDescriptor.d.ts +66 -0
  85. package/dist/service/fieldDescriptor.js +156 -0
  86. package/dist/service/fieldDescriptor.js.map +1 -0
  87. package/dist/service/health.d.ts +20 -0
  88. package/dist/service/health.js +58 -0
  89. package/dist/service/health.js.map +1 -0
  90. package/dist/service/introspect.d.ts +6 -0
  91. package/dist/service/introspect.js +312 -0
  92. package/dist/service/introspect.js.map +1 -0
  93. package/dist/service/schemaLabels.d.ts +7 -0
  94. package/dist/service/schemaLabels.js +39 -0
  95. package/dist/service/schemaLabels.js.map +1 -0
  96. package/dist/service/schemaRegistry.d.ts +2 -0
  97. package/dist/service/schemaRegistry.js +8 -0
  98. package/dist/service/schemaRegistry.js.map +1 -0
  99. package/dist/service/stagedSerialization.d.ts +6 -0
  100. package/dist/service/stagedSerialization.js +54 -0
  101. package/dist/service/stagedSerialization.js.map +1 -0
  102. package/dist/service/types.d.ts +67 -0
  103. package/dist/service/types.js +3 -0
  104. package/dist/service/types.js.map +1 -0
  105. package/dist/types/constants.d.ts +3 -0
  106. package/dist/types/constants.js +7 -0
  107. package/dist/types/constants.js.map +1 -0
  108. package/dist/types/responses.d.ts +14 -0
  109. package/dist/types/responses.js +3 -0
  110. package/dist/types/responses.js.map +1 -0
  111. package/dist/utils/query.d.ts +17 -0
  112. package/dist/utils/query.js +116 -0
  113. package/dist/utils/query.js.map +1 -0
  114. package/dist/utils/requestValidation.d.ts +2 -0
  115. package/dist/utils/requestValidation.js +14 -0
  116. package/dist/utils/requestValidation.js.map +1 -0
  117. package/frontend-vue/.editorconfig +12 -0
  118. package/frontend-vue/.prettierrc +11 -0
  119. package/frontend-vue/app/components/DataBrowserSidebar.vue +254 -0
  120. package/frontend-vue/app/components/DataBrowserTabBar.vue +166 -0
  121. package/frontend-vue/app/components/DataGrid.vue +735 -0
  122. package/frontend-vue/app/components/DataGridCell.vue +147 -0
  123. package/frontend-vue/app/components/DataGridHeaderCell.vue +175 -0
  124. package/frontend-vue/app/components/DiagramNoteNode.vue +100 -0
  125. package/frontend-vue/app/components/DiagramRelationEdge.vue +62 -0
  126. package/frontend-vue/app/components/DiagramStubNode.vue +37 -0
  127. package/frontend-vue/app/components/DiagramTableNode.vue +94 -0
  128. package/frontend-vue/app/components/JsonCellPanel.vue +93 -0
  129. package/frontend-vue/app/components/QueryBuilderPanel.vue +501 -0
  130. package/frontend-vue/app/components/QueryFavoritesPanel.vue +127 -0
  131. package/frontend-vue/app/components/QueryHistoryPanel.vue +134 -0
  132. package/frontend-vue/app/components/QueryResultPanel.vue +204 -0
  133. package/frontend-vue/app/components/SchemaDiagram.vue +1150 -0
  134. package/frontend-vue/app/components/SchemaTableInspector.vue +278 -0
  135. package/frontend-vue/app/composables/useDataBrowserGrid.ts +162 -0
  136. package/frontend-vue/app/composables/useDataBrowserTabs.ts +255 -0
  137. package/frontend-vue/app/composables/useDatabaseHealth.ts +50 -0
  138. package/frontend-vue/app/composables/useDatabaseSchemas.ts +92 -0
  139. package/frontend-vue/app/composables/useDiagramPersistence.ts +99 -0
  140. package/frontend-vue/app/composables/useQueryRuntime.ts +118 -0
  141. package/frontend-vue/app/composables/useQueryStore.ts +117 -0
  142. package/frontend-vue/app/composables/useTableViewRowClick.ts +22 -0
  143. package/frontend-vue/app/custom-pages/database/data.vue +42 -0
  144. package/frontend-vue/app/custom-pages/database/overview.vue +367 -0
  145. package/frontend-vue/app/custom-pages/database/query.vue +432 -0
  146. package/frontend-vue/app/custom-pages/database/schemas.vue +216 -0
  147. package/frontend-vue/app/utils/databaseFieldModifiers.ts +33 -0
  148. package/frontend-vue/app/utils/databaseFieldTypeIcons.ts +224 -0
  149. package/frontend-vue/app/utils/dmsTableView.ts +83 -0
  150. package/frontend-vue/app/utils/relativeTime.ts +15 -0
  151. package/frontend-vue/app/utils/stagedSerialization.ts +91 -0
  152. package/frontend-vue/dms.frontend.ts +38 -0
  153. package/frontend-vue/eslint.config.mjs +16 -0
  154. package/frontend-vue/i18n/locales/database-en-GB.json +239 -0
  155. package/frontend-vue/i18n/locales/database-fr-FR.json +239 -0
  156. package/frontend-vue/package.json +42 -0
  157. package/frontend-vue/pnpm-lock.yaml +5819 -0
  158. package/frontend-vue/pnpm-workspace.yaml +2 -0
  159. package/frontend-vue/tests/persistence.test.ts +70 -0
  160. package/frontend-vue/tests/queryRuntime.test.ts +53 -0
  161. package/frontend-vue/tests/registration.test.ts +33 -0
  162. package/frontend-vue/tsconfig.json +13 -0
  163. package/frontend-vue/vitest.config.ts +5 -0
  164. package/package.json +78 -0
@@ -0,0 +1,1150 @@
1
+ <script setup lang="ts">
2
+ import {
3
+ MarkerType,
4
+ type NodeDragEvent,
5
+ type NodeMouseEvent,
6
+ type VueFlowStore,
7
+ } from "@vue-flow/core";
8
+ import dagre from "@dagrejs/dagre";
9
+ import type { SchemaSummary } from "../composables/useDatabaseSchemas";
10
+ import type {
11
+ DiagramNote,
12
+ DiagramPositions,
13
+ } from "../composables/useDiagramPersistence";
14
+
15
+ // Schema diagram canvas — rendered inline as the "Diagram" view of the schema
16
+ // page (not a dedicated page). The active schema is driven by the `schemaId`
17
+ // prop (the page pill); `active` lets the parent trigger a refit when the view
18
+ // becomes visible (vue-flow needs a measured container).
19
+ interface Props {
20
+ schemaId: string | null;
21
+ active?: boolean;
22
+ }
23
+
24
+ const props = withDefaults(defineProps<Props>(), { active: true });
25
+
26
+ const emit = defineEmits<{
27
+ // A table node was double-clicked — the parent opens the shared inspector
28
+ // drawer for that table (same drawer as the schema list view).
29
+ "inspect-table": [tableName: string];
30
+ }>();
31
+
32
+ const NODE_WIDTH = 240;
33
+ const NODE_HEIGHT_PER_FIELD = 28;
34
+ const NODE_HEIGHT_HEADER = 40;
35
+ const STUB_NODE_WIDTH = 180;
36
+ const STUB_NODE_HEIGHT = 28;
37
+ const LAYOUT_DIRECTION = "TB";
38
+ const LAYOUT_NODE_SEP = 40;
39
+ const LAYOUT_RANK_SEP = 80;
40
+ const TABLE_NODE_TYPE = "tableNode";
41
+ const STUB_NODE_TYPE = "stubNode";
42
+ const NOTE_NODE_TYPE = "noteNode";
43
+ const RELATION_EDGE_TYPE = "relationEdge";
44
+ const RESET_ZOOM = 1;
45
+ const NOTE_DEFAULT_WIDTH = 220;
46
+ const NOTE_DEFAULT_HEIGHT = 140;
47
+ const SQUASH_WINDOW_MS = 600;
48
+ const HYSTERESIS_PX = 15;
49
+
50
+ type HandleSide = "left" | "right";
51
+ interface Sides {
52
+ source: HandleSide;
53
+ target: HandleSide;
54
+ }
55
+
56
+ function pickSides(dx: number, W: number, prev?: Sides): Sides {
57
+ const pure: Sides =
58
+ dx >= W
59
+ ? { source: "right", target: "left" }
60
+ : dx >= 0
61
+ ? { source: "right", target: "right" }
62
+ : dx >= -W
63
+ ? { source: "left", target: "left" }
64
+ : { source: "left", target: "right" };
65
+ if (!prev || (prev.source === pure.source && prev.target === pure.target)) {
66
+ return pure;
67
+ }
68
+ const H = HYSTERESIS_PX;
69
+ if (prev.source === "right" && prev.target === "left" && dx > W - H)
70
+ return prev;
71
+ if (
72
+ prev.source === "right" &&
73
+ prev.target === "right" &&
74
+ dx > -H &&
75
+ dx < W + H
76
+ )
77
+ return prev;
78
+ if (
79
+ prev.source === "left" &&
80
+ prev.target === "left" &&
81
+ dx > -W - H &&
82
+ dx < H
83
+ )
84
+ return prev;
85
+ if (prev.source === "left" && prev.target === "right" && dx < -W + H)
86
+ return prev;
87
+ return pure;
88
+ }
89
+
90
+ const edgeRouting = new Map<string, Sides>();
91
+
92
+ interface NoteDraft {
93
+ id: string;
94
+ schemaName: string;
95
+ text: string;
96
+ x: number;
97
+ y: number;
98
+ width: number;
99
+ height: number;
100
+ color: string | null;
101
+ isTemp: boolean;
102
+ }
103
+
104
+ type Op =
105
+ | {
106
+ kind: "tableMove";
107
+ tableName: string;
108
+ from: { x: number; y: number };
109
+ to: { x: number; y: number };
110
+ }
111
+ | {
112
+ kind: "noteMove";
113
+ noteId: string;
114
+ from: { x: number; y: number };
115
+ to: { x: number; y: number };
116
+ }
117
+ | { kind: "noteText"; noteId: string; from: string; to: string }
118
+ | { kind: "noteCreate"; tempId: string; snapshot: NoteDraft }
119
+ | { kind: "noteDelete"; snapshot: NoteDraft }
120
+ | { kind: "autoLayout"; from: DiagramPositions };
121
+
122
+ interface TableDiagramNode {
123
+ id: string;
124
+ type: typeof TABLE_NODE_TYPE;
125
+ position: { x: number; y: number };
126
+ data: {
127
+ tableName: string;
128
+ fields: Record<string, unknown>;
129
+ indexes: Record<string, { fields?: string[] }>;
130
+ modifiers: Record<string, string[]>;
131
+ };
132
+ }
133
+
134
+ interface StubDiagramNode {
135
+ id: string;
136
+ type: typeof STUB_NODE_TYPE;
137
+ position: { x: number; y: number };
138
+ selectable: false;
139
+ draggable: false;
140
+ data: {
141
+ targetSchema: string;
142
+ targetTable: string;
143
+ };
144
+ }
145
+
146
+ interface NoteDiagramNode {
147
+ id: string;
148
+ type: typeof NOTE_NODE_TYPE;
149
+ position: { x: number; y: number };
150
+ data: {
151
+ noteId: string;
152
+ text: string;
153
+ color: string | null;
154
+ width: number;
155
+ height: number;
156
+ onTextChange: (noteId: string, text: string) => void;
157
+ onDelete: (noteId: string) => void;
158
+ };
159
+ }
160
+
161
+ type DiagramNodeT = TableDiagramNode | StubDiagramNode | NoteDiagramNode;
162
+
163
+ interface DiagramEdge {
164
+ id: string;
165
+ type: typeof RELATION_EDGE_TYPE;
166
+ source: string;
167
+ target: string;
168
+ sourceHandle: string;
169
+ targetHandle: string;
170
+ label: string;
171
+ animated: boolean;
172
+ markerEnd: { type: MarkerType };
173
+ data: {
174
+ sourceBase: string;
175
+ targetBase: string;
176
+ isStubTarget: boolean;
177
+ };
178
+ }
179
+
180
+ const { t } = useI18n();
181
+ const { schemas } = useDatabaseSchemas();
182
+ const {
183
+ fetchLayout,
184
+ saveLayout,
185
+ fetchNotes,
186
+ createNote,
187
+ updateNote,
188
+ deleteNote,
189
+ } = useDiagramPersistence();
190
+ // DmsFlowCanvas (shared dms-ui layer) owns the <VueFlow> instance and the
191
+ // canvas chrome (dotted DMS background, themed handles/edges, pan/zoom). It
192
+ // exposes the full imperative VueFlow controller (setNodes/setEdges/fitView/
193
+ // viewport/findNode/onNodeDrag…) via a template ref; all the ERD business
194
+ // logic below (dagre auto-layout, edge routing, undo/redo, notes) stays here.
195
+ const canvas = ref<VueFlowStore | null>(null);
196
+ // Our own wrapper element — used to measure the visible canvas for note placement.
197
+ const canvasWrapper = ref<HTMLElement | null>(null);
198
+
199
+ function fitView() {
200
+ canvas.value?.fitView();
201
+ }
202
+ function zoomIn() {
203
+ canvas.value?.zoomIn();
204
+ }
205
+ function zoomOut() {
206
+ canvas.value?.zoomOut();
207
+ }
208
+
209
+ const panMode = ref(false);
210
+ const rebuildKey = ref(0);
211
+
212
+ // Live viewport mirror. VueFlow's controller `viewport` ref read through the
213
+ // exposed DmsFlowCanvas template ref doesn't stay reactive here, so the zoom
214
+ // readout froze at 100% and notes were placed against a stale {0,0,1} origin.
215
+ // We keep our own copy fed by VueFlow's @viewport-change event (forwarded by
216
+ // DmsFlowCanvas via $attrs) — it fires on wheel/pinch zoom, pan and the +/-/fit
217
+ // controls — and derive both the zoom badge and note placement from it.
218
+ const liveViewport = ref({ x: 0, y: 0, zoom: RESET_ZOOM });
219
+
220
+ function onViewportChange(viewport: { x: number; y: number; zoom: number }) {
221
+ liveViewport.value = { x: viewport.x, y: viewport.y, zoom: viewport.zoom };
222
+ }
223
+
224
+ const tablePositions = ref<DiagramPositions>({});
225
+ const notes = ref<NoteDraft[]>([]);
226
+ const baseline = ref<{ tablePositions: DiagramPositions; notes: NoteDraft[] }>({
227
+ tablePositions: {},
228
+ notes: [],
229
+ });
230
+
231
+ const history = ref<Op[]>([]);
232
+ const cursor = ref(0);
233
+ let lastOpAt = 0;
234
+
235
+ const applying = ref(false);
236
+ let tempCounter = 0;
237
+ let initialFitDone = false;
238
+ // Gate the very first paint until both the canvas controller is mounted and the
239
+ // initial schema layout has loaded (the controller is async now, unlike the old
240
+ // synchronous useVueFlow), so saved table positions land on the first render.
241
+ let initialLayoutSettled = false;
242
+ let initialRenderDone = false;
243
+ let loadToken = 0;
244
+
245
+ const isDirty = computed(() => cursor.value > 0);
246
+ const canUndo = computed(() => cursor.value > 0);
247
+ const canRedo = computed(() => cursor.value < history.value.length);
248
+
249
+ const effectiveSchemaId = computed<string | null>(
250
+ () => props.schemaId ?? schemas.value[0]?.id ?? null,
251
+ );
252
+
253
+ const visibleSchemas = computed<SchemaSummary[]>(() => {
254
+ const id = effectiveSchemaId.value;
255
+ if (!id) return [];
256
+ const match = schemas.value.find((s) => s.id === id);
257
+ return match ? [match] : [];
258
+ });
259
+
260
+ function nodeIdFor(schemaId: string, tableName: string): string {
261
+ return `${schemaId}::${tableName}`;
262
+ }
263
+
264
+ function stubIdFor(schemaId: string, tableName: string): string {
265
+ return `stub::${schemaId}::${tableName}`;
266
+ }
267
+
268
+ function noteNodeIdFor(noteId: string): string {
269
+ return `note::${noteId}`;
270
+ }
271
+
272
+ function clonePositions(p: DiagramPositions): DiagramPositions {
273
+ const out: DiagramPositions = {};
274
+ for (const [k, v] of Object.entries(p)) out[k] = { x: v.x, y: v.y };
275
+ return out;
276
+ }
277
+
278
+ function cloneNote(n: NoteDraft): NoteDraft {
279
+ return { ...n };
280
+ }
281
+
282
+ interface BuiltGraph {
283
+ nodes: DiagramNodeT[];
284
+ edges: DiagramEdge[];
285
+ }
286
+
287
+ function buildGraph(schemaList: SchemaSummary[]): BuiltGraph {
288
+ const visibleIds = new Set<string>();
289
+ for (const schema of schemaList) {
290
+ for (const table of schema.tables) {
291
+ visibleIds.add(nodeIdFor(schema.id, table.name));
292
+ }
293
+ }
294
+
295
+ const graph = new dagre.graphlib.Graph();
296
+ graph.setDefaultEdgeLabel(() => ({}));
297
+ graph.setGraph({
298
+ rankdir: LAYOUT_DIRECTION,
299
+ nodesep: LAYOUT_NODE_SEP,
300
+ ranksep: LAYOUT_RANK_SEP,
301
+ });
302
+
303
+ for (const schema of schemaList) {
304
+ for (const table of schema.tables) {
305
+ const fieldCount = Object.keys(table.fields).length;
306
+ const height = NODE_HEIGHT_HEADER + fieldCount * NODE_HEIGHT_PER_FIELD;
307
+ graph.setNode(nodeIdFor(schema.id, table.name), {
308
+ width: NODE_WIDTH,
309
+ height,
310
+ });
311
+ }
312
+ }
313
+
314
+ const stubs = new Map<string, { schema: string; table: string }>();
315
+ const tableFieldsById = new Map<string, Record<string, unknown>>();
316
+ for (const schema of schemaList) {
317
+ for (const table of schema.tables) {
318
+ tableFieldsById.set(nodeIdFor(schema.id, table.name), table.fields);
319
+ }
320
+ }
321
+
322
+ interface PendingEdge {
323
+ id: string;
324
+ sourceId: string;
325
+ targetId: string;
326
+ sourceBase: string;
327
+ targetBase: string;
328
+ isStubTarget: boolean;
329
+ label: string;
330
+ many: boolean;
331
+ }
332
+ const pendingEdges: PendingEdge[] = [];
333
+
334
+ for (const schema of schemaList) {
335
+ for (const table of schema.tables) {
336
+ const sourceId = nodeIdFor(schema.id, table.name);
337
+ const sourceFields = tableFieldsById.get(sourceId);
338
+ for (const rel of table.relations ?? []) {
339
+ const realTargetId = nodeIdFor(rel.toSchema, rel.toTable);
340
+ const isStubTarget = !visibleIds.has(realTargetId);
341
+ const targetId = isStubTarget
342
+ ? stubIdFor(rel.toSchema, rel.toTable)
343
+ : realTargetId;
344
+ if (isStubTarget && !stubs.has(targetId)) {
345
+ stubs.set(targetId, {
346
+ schema: rel.toSchema,
347
+ table: rel.toTable,
348
+ });
349
+ graph.setNode(targetId, {
350
+ width: STUB_NODE_WIDTH,
351
+ height: STUB_NODE_HEIGHT,
352
+ });
353
+ }
354
+ graph.setEdge(sourceId, targetId);
355
+ const sourceBase =
356
+ sourceFields && rel.fromField in sourceFields
357
+ ? rel.fromField
358
+ : "__node__";
359
+ let targetBase: string;
360
+ if (isStubTarget) {
361
+ targetBase = "stub";
362
+ } else {
363
+ const targetFields = tableFieldsById.get(realTargetId);
364
+ targetBase =
365
+ targetFields && rel.toField in targetFields
366
+ ? rel.toField
367
+ : "__node__";
368
+ }
369
+ pendingEdges.push({
370
+ id: `${sourceId}::${rel.fromField}->${realTargetId}`,
371
+ sourceId,
372
+ targetId,
373
+ sourceBase,
374
+ targetBase,
375
+ isStubTarget,
376
+ label: rel.fromField,
377
+ many: rel.many,
378
+ });
379
+ }
380
+ }
381
+ }
382
+
383
+ dagre.layout(graph);
384
+
385
+ const nodePositions = new Map<
386
+ string,
387
+ { x: number; y: number; width: number }
388
+ >();
389
+ const nodes: DiagramNodeT[] = [];
390
+ for (const schema of schemaList) {
391
+ for (const table of schema.tables) {
392
+ const id = nodeIdFor(schema.id, table.name);
393
+ const laid = graph.node(id);
394
+ const saved = tablePositions.value[table.name];
395
+ const position = saved ?? { x: laid.x - NODE_WIDTH / 2, y: laid.y };
396
+ nodePositions.set(id, { x: position.x, y: position.y, width: NODE_WIDTH });
397
+ nodes.push({
398
+ id,
399
+ type: TABLE_NODE_TYPE,
400
+ position,
401
+ data: {
402
+ tableName: table.name,
403
+ fields: table.fields,
404
+ indexes: table.indexes,
405
+ modifiers: table.modifiers,
406
+ },
407
+ });
408
+ }
409
+ }
410
+ for (const [id, info] of stubs) {
411
+ const laid = graph.node(id);
412
+ const position = { x: laid.x - STUB_NODE_WIDTH / 2, y: laid.y };
413
+ nodePositions.set(id, {
414
+ x: position.x,
415
+ y: position.y,
416
+ width: STUB_NODE_WIDTH,
417
+ });
418
+ nodes.push({
419
+ id,
420
+ type: STUB_NODE_TYPE,
421
+ position,
422
+ selectable: false,
423
+ draggable: false,
424
+ data: { targetSchema: info.schema, targetTable: info.table },
425
+ });
426
+ }
427
+
428
+ const edges: DiagramEdge[] = [];
429
+ for (const pe of pendingEdges) {
430
+ const sPos = nodePositions.get(pe.sourceId);
431
+ const tPos = nodePositions.get(pe.targetId);
432
+ if (!sPos || !tPos) continue;
433
+ const dx = tPos.x + tPos.width / 2 - (sPos.x + sPos.width / 2);
434
+ const sides = pickSides(dx, NODE_WIDTH, edgeRouting.get(pe.id));
435
+ edgeRouting.set(pe.id, sides);
436
+ const sourceHandle = `${pe.sourceBase}::${sides.source}::source`;
437
+ const targetHandle = `${pe.targetBase}::${sides.target}::target`;
438
+ edges.push({
439
+ id: pe.id,
440
+ type: RELATION_EDGE_TYPE,
441
+ source: pe.sourceId,
442
+ target: pe.targetId,
443
+ sourceHandle,
444
+ targetHandle,
445
+ label: pe.label,
446
+ animated: pe.many,
447
+ markerEnd: { type: MarkerType.ArrowClosed },
448
+ data: {
449
+ sourceBase: pe.sourceBase,
450
+ targetBase: pe.targetBase,
451
+ isStubTarget: pe.isStubTarget,
452
+ },
453
+ });
454
+ }
455
+
456
+ for (const note of notes.value) {
457
+ nodes.push({
458
+ id: noteNodeIdFor(note.id),
459
+ type: NOTE_NODE_TYPE,
460
+ position: { x: note.x, y: note.y },
461
+ data: {
462
+ noteId: note.id,
463
+ text: note.text,
464
+ color: note.color,
465
+ width: note.width,
466
+ height: note.height,
467
+ onTextChange: onNoteTextChange,
468
+ onDelete: onNoteDelete,
469
+ },
470
+ });
471
+ }
472
+
473
+ return { nodes, edges };
474
+ }
475
+
476
+ function rebuildAndApply() {
477
+ if (!canvas.value) return;
478
+ const built = buildGraph(visibleSchemas.value);
479
+ canvas.value.setNodes(built.nodes);
480
+ canvas.value.setEdges(built.edges);
481
+ }
482
+
483
+ // Data-driven rebuilds after the first paint: schema edits, drag-induced
484
+ // rebuildKey bumps, undo/redo, notes. The initial render is owned by
485
+ // renderInitialGraph so it isn't a premature dagre-only pass before the saved
486
+ // positions have loaded.
487
+ watch(
488
+ [visibleSchemas, rebuildKey],
489
+ () => {
490
+ if (initialRenderDone) rebuildAndApply();
491
+ },
492
+ { deep: true },
493
+ );
494
+
495
+ // First paint: defer until BOTH the canvas controller is mounted AND the initial
496
+ // schema layout has settled (whichever happens last drives it), so the very
497
+ // first render already carries saved table positions instead of flashing the
498
+ // dagre fallback. Triggered from watch(canvas) and from loadSchema.
499
+ function renderInitialGraph() {
500
+ if (initialRenderDone || !canvas.value || !initialLayoutSettled) return;
501
+ initialRenderDone = true;
502
+ rebuildAndApply();
503
+ }
504
+ watch(canvas, renderInitialGraph);
505
+
506
+ function onNodesInitialized() {
507
+ if (initialFitDone) return;
508
+ initialFitDone = true;
509
+ fitView();
510
+ }
511
+
512
+ // Re-fit when the diagram view becomes visible again (vue-flow can't measure a
513
+ // hidden container, so a toggle from List -> Diagram needs a nudge).
514
+ watch(
515
+ () => props.active,
516
+ (active) => {
517
+ if (active) nextTick(() => fitView());
518
+ },
519
+ );
520
+
521
+ async function loadSchema(id: string) {
522
+ const myToken = ++loadToken;
523
+ edgeRouting.clear();
524
+ try {
525
+ const [pos, items] = await Promise.all([fetchLayout(id), fetchNotes(id)]);
526
+ if (myToken !== loadToken) return;
527
+ const loadedNotes: NoteDraft[] = items.map((n: DiagramNote) => ({
528
+ id: n.id,
529
+ schemaName: n.schemaName,
530
+ text: n.text,
531
+ x: n.x,
532
+ y: n.y,
533
+ width: n.width,
534
+ height: n.height,
535
+ color: n.color,
536
+ isTemp: false,
537
+ }));
538
+ tablePositions.value = clonePositions(pos);
539
+ notes.value = loadedNotes.map(cloneNote);
540
+ baseline.value = {
541
+ tablePositions: clonePositions(pos),
542
+ notes: loadedNotes.map(cloneNote),
543
+ };
544
+ history.value = [];
545
+ cursor.value = 0;
546
+ rebuildKey.value++;
547
+ initialLayoutSettled = true;
548
+ renderInitialGraph();
549
+ nextTick(() => fitView());
550
+ } catch {
551
+ if (myToken !== loadToken) return;
552
+ tablePositions.value = {};
553
+ notes.value = [];
554
+ baseline.value = { tablePositions: {}, notes: [] };
555
+ history.value = [];
556
+ cursor.value = 0;
557
+ rebuildKey.value++;
558
+ initialLayoutSettled = true;
559
+ renderInitialGraph();
560
+ }
561
+ }
562
+
563
+ watch(
564
+ effectiveSchemaId,
565
+ (newId, oldId) => {
566
+ if (newId === oldId) return;
567
+ if (!newId) {
568
+ tablePositions.value = {};
569
+ notes.value = [];
570
+ baseline.value = { tablePositions: {}, notes: [] };
571
+ history.value = [];
572
+ cursor.value = 0;
573
+ rebuildKey.value++;
574
+ initialLayoutSettled = true;
575
+ renderInitialGraph();
576
+ return;
577
+ }
578
+ loadSchema(newId);
579
+ },
580
+ { immediate: true },
581
+ );
582
+
583
+ function pushOp(op: Op) {
584
+ if (cursor.value < history.value.length) {
585
+ history.value.splice(cursor.value);
586
+ }
587
+ const now = Date.now();
588
+ const head = history.value[cursor.value - 1];
589
+ const inWindow = now - lastOpAt < SQUASH_WINDOW_MS;
590
+ let squashed = false;
591
+ if (head && inWindow) {
592
+ if (
593
+ op.kind === "tableMove" &&
594
+ head.kind === "tableMove" &&
595
+ head.tableName === op.tableName
596
+ ) {
597
+ head.to = op.to;
598
+ squashed = true;
599
+ } else if (
600
+ op.kind === "noteMove" &&
601
+ head.kind === "noteMove" &&
602
+ head.noteId === op.noteId
603
+ ) {
604
+ head.to = op.to;
605
+ squashed = true;
606
+ } else if (
607
+ op.kind === "noteText" &&
608
+ head.kind === "noteText" &&
609
+ head.noteId === op.noteId
610
+ ) {
611
+ head.to = op.to;
612
+ squashed = true;
613
+ }
614
+ }
615
+ if (!squashed) {
616
+ history.value.push(op);
617
+ cursor.value++;
618
+ }
619
+ lastOpAt = now;
620
+ }
621
+
622
+ function applyForward(op: Op) {
623
+ switch (op.kind) {
624
+ case "tableMove":
625
+ tablePositions.value[op.tableName] = { ...op.to };
626
+ break;
627
+ case "noteMove": {
628
+ const n = notes.value.find((x) => x.id === op.noteId);
629
+ if (n) {
630
+ n.x = op.to.x;
631
+ n.y = op.to.y;
632
+ }
633
+ break;
634
+ }
635
+ case "noteText": {
636
+ const n = notes.value.find((x) => x.id === op.noteId);
637
+ if (n) n.text = op.to;
638
+ break;
639
+ }
640
+ case "noteCreate":
641
+ if (!notes.value.find((n) => n.id === op.tempId)) {
642
+ notes.value.push(cloneNote(op.snapshot));
643
+ }
644
+ break;
645
+ case "noteDelete": {
646
+ const idx = notes.value.findIndex((n) => n.id === op.snapshot.id);
647
+ if (idx >= 0) notes.value.splice(idx, 1);
648
+ break;
649
+ }
650
+ case "autoLayout":
651
+ tablePositions.value = {};
652
+ break;
653
+ }
654
+ }
655
+
656
+ function applyReverse(op: Op) {
657
+ switch (op.kind) {
658
+ case "tableMove":
659
+ tablePositions.value[op.tableName] = { ...op.from };
660
+ break;
661
+ case "noteMove": {
662
+ const n = notes.value.find((x) => x.id === op.noteId);
663
+ if (n) {
664
+ n.x = op.from.x;
665
+ n.y = op.from.y;
666
+ }
667
+ break;
668
+ }
669
+ case "noteText": {
670
+ const n = notes.value.find((x) => x.id === op.noteId);
671
+ if (n) n.text = op.from;
672
+ break;
673
+ }
674
+ case "noteCreate": {
675
+ const idx = notes.value.findIndex((n) => n.id === op.tempId);
676
+ if (idx >= 0) notes.value.splice(idx, 1);
677
+ break;
678
+ }
679
+ case "noteDelete":
680
+ notes.value.push(cloneNote(op.snapshot));
681
+ break;
682
+ case "autoLayout":
683
+ tablePositions.value = clonePositions(op.from);
684
+ break;
685
+ }
686
+ }
687
+
688
+ function undo() {
689
+ if (cursor.value === 0) return;
690
+ cursor.value--;
691
+ const op = history.value[cursor.value];
692
+ if (!op) return;
693
+ applyReverse(op);
694
+ rebuildKey.value++;
695
+ }
696
+
697
+ function redo() {
698
+ if (cursor.value >= history.value.length) return;
699
+ const op = history.value[cursor.value];
700
+ if (!op) return;
701
+ applyForward(op);
702
+ cursor.value++;
703
+ rebuildKey.value++;
704
+ }
705
+
706
+ function cancelChanges() {
707
+ tablePositions.value = clonePositions(baseline.value.tablePositions);
708
+ notes.value = baseline.value.notes.map(cloneNote);
709
+ history.value = [];
710
+ cursor.value = 0;
711
+ rebuildKey.value++;
712
+ }
713
+
714
+ async function applyChanges() {
715
+ const id = effectiveSchemaId.value;
716
+ if (!id || applying.value || !isDirty.value) return;
717
+ applying.value = true;
718
+ try {
719
+ await saveLayout(id, clonePositions(tablePositions.value));
720
+
721
+ const baselineNotes = baseline.value.notes;
722
+ const baselineById = new Map(baselineNotes.map((n) => [n.id, n]));
723
+ const currentReal = notes.value.filter((n) => !n.isTemp);
724
+ const currentTemp = notes.value.filter((n) => n.isTemp);
725
+ const currentIds = new Set(currentReal.map((n) => n.id));
726
+
727
+ for (const b of baselineNotes) {
728
+ if (!currentIds.has(b.id)) await deleteNote(b.id);
729
+ }
730
+ for (const c of currentReal) {
731
+ const b = baselineById.get(c.id);
732
+ if (!b) continue;
733
+ if (
734
+ b.x !== c.x ||
735
+ b.y !== c.y ||
736
+ b.text !== c.text ||
737
+ b.width !== c.width ||
738
+ b.height !== c.height ||
739
+ b.color !== c.color
740
+ ) {
741
+ await updateNote(c.id, {
742
+ x: c.x,
743
+ y: c.y,
744
+ text: c.text,
745
+ width: c.width,
746
+ height: c.height,
747
+ color: c.color ?? undefined,
748
+ });
749
+ }
750
+ }
751
+ for (const tempNote of currentTemp) {
752
+ const created = await createNote({
753
+ schemaName: id,
754
+ text: tempNote.text,
755
+ x: tempNote.x,
756
+ y: tempNote.y,
757
+ width: tempNote.width,
758
+ height: tempNote.height,
759
+ color: tempNote.color ?? undefined,
760
+ });
761
+ const idx = notes.value.findIndex((n) => n.id === tempNote.id);
762
+ if (idx >= 0) {
763
+ notes.value[idx] = {
764
+ id: created.id,
765
+ schemaName: created.schemaName,
766
+ text: created.text,
767
+ x: created.x,
768
+ y: created.y,
769
+ width: created.width,
770
+ height: created.height,
771
+ color: created.color,
772
+ isTemp: false,
773
+ };
774
+ }
775
+ }
776
+
777
+ baseline.value = {
778
+ tablePositions: clonePositions(tablePositions.value),
779
+ notes: notes.value.map(cloneNote),
780
+ };
781
+ history.value = [];
782
+ cursor.value = 0;
783
+ rebuildKey.value++;
784
+ } catch (err) {
785
+ console.error("Failed to apply diagram changes", err);
786
+ } finally {
787
+ applying.value = false;
788
+ }
789
+ }
790
+
791
+ const nodeTypes = {
792
+ [TABLE_NODE_TYPE]: resolveComponent("DmsDatabaseDiagramTableNode"),
793
+ [STUB_NODE_TYPE]: resolveComponent("DmsDatabaseDiagramStubNode"),
794
+ [NOTE_NODE_TYPE]: resolveComponent("DmsDatabaseDiagramNoteNode"),
795
+ };
796
+
797
+ const edgeTypes = {
798
+ [RELATION_EDGE_TYPE]: resolveComponent("DmsDatabaseDiagramRelationEdge"),
799
+ };
800
+
801
+ const zoomPercent = computed(
802
+ () => `${Math.round(liveViewport.value.zoom * 100)}%`,
803
+ );
804
+
805
+ function widthForNodeType(type: string | undefined): number {
806
+ if (type === STUB_NODE_TYPE) return STUB_NODE_WIDTH;
807
+ return NODE_WIDTH;
808
+ }
809
+
810
+ function recomputeEdgesForNode(nodeId: string) {
811
+ const instance = canvas.value;
812
+ if (!instance) return;
813
+ for (const edge of instance.edges.value) {
814
+ if (edge.source !== nodeId && edge.target !== nodeId) continue;
815
+ const sNode = instance.findNode(edge.source);
816
+ const tNode = instance.findNode(edge.target);
817
+ if (!sNode || !tNode) continue;
818
+ const sW = widthForNodeType(sNode.type);
819
+ const tW = widthForNodeType(tNode.type);
820
+ const dx = tNode.position.x + tW / 2 - (sNode.position.x + sW / 2);
821
+ const prev = edgeRouting.get(edge.id);
822
+ const next = pickSides(dx, NODE_WIDTH, prev);
823
+ if (prev && prev.source === next.source && prev.target === next.target)
824
+ continue;
825
+ edgeRouting.set(edge.id, next);
826
+ const data = edge.data as
827
+ | { sourceBase: string; targetBase: string; isStubTarget: boolean }
828
+ | undefined;
829
+ if (!data) continue;
830
+ edge.sourceHandle = `${data.sourceBase}::${next.source}::source`;
831
+ edge.targetHandle = `${data.targetBase}::${next.target}::target`;
832
+ }
833
+ }
834
+
835
+ const dragStartPositions = new Map<string, { x: number; y: number }>();
836
+
837
+ function onNodeDragStart({ node }: NodeDragEvent) {
838
+ if (node.type !== TABLE_NODE_TYPE && node.type !== NOTE_NODE_TYPE) return;
839
+ dragStartPositions.set(node.id, {
840
+ x: node.position.x,
841
+ y: node.position.y,
842
+ });
843
+ }
844
+
845
+ function onNodeDrag({ node }: NodeDragEvent) {
846
+ if (node.type !== TABLE_NODE_TYPE && node.type !== NOTE_NODE_TYPE) return;
847
+ recomputeEdgesForNode(node.id);
848
+ }
849
+
850
+ function onNodeDragStop({ node }: NodeDragEvent) {
851
+ const start = dragStartPositions.get(node.id);
852
+ dragStartPositions.delete(node.id);
853
+ if (!start) return;
854
+ const newPos = { x: node.position.x, y: node.position.y };
855
+ if (start.x === newPos.x && start.y === newPos.y) return;
856
+ if (node.type === TABLE_NODE_TYPE) {
857
+ const tableName = (node.data as { tableName?: string })?.tableName;
858
+ if (!tableName) return;
859
+ tablePositions.value[tableName] = newPos;
860
+ pushOp({ kind: "tableMove", tableName, from: start, to: newPos });
861
+ } else if (node.type === NOTE_NODE_TYPE) {
862
+ const noteId = (node.data as { noteId?: string })?.noteId;
863
+ if (!noteId) return;
864
+ const local = notes.value.find((n) => n.id === noteId);
865
+ if (!local) return;
866
+ local.x = newPos.x;
867
+ local.y = newPos.y;
868
+ pushOp({ kind: "noteMove", noteId, from: start, to: newPos });
869
+ }
870
+ }
871
+
872
+ function onNodeDoubleClick({ node }: NodeMouseEvent) {
873
+ // Only real table nodes open the inspector; stub/note nodes are ignored.
874
+ if (node.type !== TABLE_NODE_TYPE) return;
875
+ const tableName = (node.data as { tableName?: string })?.tableName;
876
+ if (tableName) emit("inspect-table", tableName);
877
+ }
878
+
879
+ function onNoteTextChange(noteId: string, newText: string) {
880
+ const n = notes.value.find((x) => x.id === noteId);
881
+ if (!n || n.text === newText) return;
882
+ pushOp({ kind: "noteText", noteId, from: n.text, to: newText });
883
+ n.text = newText;
884
+ rebuildKey.value++;
885
+ }
886
+
887
+ function onNoteDelete(noteId: string) {
888
+ const idx = notes.value.findIndex((n) => n.id === noteId);
889
+ if (idx < 0) return;
890
+ const snapshot = cloneNote(notes.value[idx] as NoteDraft);
891
+ notes.value.splice(idx, 1);
892
+ pushOp({ kind: "noteDelete", snapshot });
893
+ rebuildKey.value++;
894
+ }
895
+
896
+ function addNote() {
897
+ const id = effectiveSchemaId.value;
898
+ if (!id) return;
899
+ // Drop the note at the centre of the *visible* canvas. The pane centre is a
900
+ // screen-space point; convert it to flow coordinates with the live viewport
901
+ // (offset + zoom) so the note lands on-screen no matter how the user has
902
+ // panned or zoomed — the old code read a stale {0,0,1} viewport and could
903
+ // place notes far off-screen. Pane size comes from our own wrapper element
904
+ // (the controller's `dimensions` ref isn't reliably populated here).
905
+ const rect = canvasWrapper.value?.getBoundingClientRect();
906
+ const vp = liveViewport.value;
907
+ const paneW = rect?.width || NODE_WIDTH * 3;
908
+ const paneH = rect?.height || NODE_HEIGHT_HEADER * 8;
909
+ const x = (paneW / 2 - vp.x) / vp.zoom - NOTE_DEFAULT_WIDTH / 2;
910
+ const y = (paneH / 2 - vp.y) / vp.zoom - NOTE_DEFAULT_HEIGHT / 2;
911
+ tempCounter += 1;
912
+ const tempId = `temp-${Date.now()}-${tempCounter}`;
913
+ const draft: NoteDraft = {
914
+ id: tempId,
915
+ schemaName: id,
916
+ text: "",
917
+ x,
918
+ y,
919
+ width: NOTE_DEFAULT_WIDTH,
920
+ height: NOTE_DEFAULT_HEIGHT,
921
+ color: null,
922
+ isTemp: true,
923
+ };
924
+ notes.value.push(draft);
925
+ pushOp({ kind: "noteCreate", tempId, snapshot: cloneNote(draft) });
926
+ rebuildKey.value++;
927
+ }
928
+
929
+ function runAutoLayout() {
930
+ if (Object.keys(tablePositions.value).length === 0) {
931
+ rebuildKey.value++;
932
+ nextTick(() => fitView());
933
+ return;
934
+ }
935
+ const from = clonePositions(tablePositions.value);
936
+ tablePositions.value = {};
937
+ pushOp({ kind: "autoLayout", from });
938
+ rebuildKey.value++;
939
+ nextTick(() => fitView());
940
+ }
941
+
942
+ function resetZoom() {
943
+ canvas.value?.zoomTo(RESET_ZOOM);
944
+ }
945
+
946
+ function confirmLeave(): boolean {
947
+ return window.confirm(t("dms_database.diagram.unsavedConfirm"));
948
+ }
949
+
950
+ function onBeforeUnload(e: BeforeUnloadEvent) {
951
+ if (!isDirty.value) return;
952
+ e.preventDefault();
953
+ e.returnValue = "";
954
+ }
955
+
956
+ onMounted(() => {
957
+ window.addEventListener("beforeunload", onBeforeUnload);
958
+ });
959
+
960
+ onBeforeUnmount(() => {
961
+ window.removeEventListener("beforeunload", onBeforeUnload);
962
+ });
963
+
964
+ const { registerGuard } = usePageLeaveGuard();
965
+ registerGuard(() => {
966
+ if (!isDirty.value) return true;
967
+ return confirmLeave();
968
+ });
969
+ </script>
970
+
971
+ <template>
972
+ <div
973
+ ref="canvasWrapper"
974
+ class="h-[calc(100vh-15rem)] min-h-[28rem] overflow-hidden rounded-xl border border-default"
975
+ :class="panMode ? 'diagram-pan-mode' : 'diagram-select-mode'"
976
+ >
977
+ <DmsFlowCanvas
978
+ ref="canvas"
979
+ :node-types="nodeTypes"
980
+ :edge-types="edgeTypes"
981
+ :controls="false"
982
+ :fit-view-on-init="false"
983
+ :pan-on-drag="panMode"
984
+ :nodes-draggable="!panMode"
985
+ @nodes-initialized="onNodesInitialized"
986
+ @viewport-change="onViewportChange"
987
+ @node-drag-start="onNodeDragStart"
988
+ @node-drag="onNodeDrag"
989
+ @node-drag-stop="onNodeDragStop"
990
+ @node-double-click="onNodeDoubleClick"
991
+ >
992
+ <template #top-right>
993
+ <div
994
+ class="flex items-center gap-1 rounded-lg border border-default bg-elevated p-1 shadow-md"
995
+ >
996
+ <UButton
997
+ size="sm"
998
+ variant="ghost"
999
+ color="neutral"
1000
+ icon="i-ph-squares-four"
1001
+ :label="$t('dms_database.diagram.toolbar.autoLayout')"
1002
+ @click="runAutoLayout"
1003
+ />
1004
+ <UTooltip :text="$t('dms_database.diagram.toolbar.helpText')">
1005
+ <UButton
1006
+ size="sm"
1007
+ variant="ghost"
1008
+ color="neutral"
1009
+ icon="i-ph-question"
1010
+ :aria-label="$t('dms_database.diagram.toolbar.help')"
1011
+ />
1012
+ </UTooltip>
1013
+ <div class="w-px h-5 bg-default mx-1" />
1014
+ <UTooltip :text="$t('dms_database.diagram.toolbar.undo')">
1015
+ <UButton
1016
+ size="sm"
1017
+ variant="ghost"
1018
+ color="neutral"
1019
+ icon="i-ph-arrow-counter-clockwise"
1020
+ :aria-label="$t('dms_database.diagram.toolbar.undo')"
1021
+ :disabled="!canUndo || applying"
1022
+ @click="undo"
1023
+ />
1024
+ </UTooltip>
1025
+ <UTooltip :text="$t('dms_database.diagram.toolbar.redo')">
1026
+ <UButton
1027
+ size="sm"
1028
+ variant="ghost"
1029
+ color="neutral"
1030
+ icon="i-ph-arrow-clockwise"
1031
+ :aria-label="$t('dms_database.diagram.toolbar.redo')"
1032
+ :disabled="!canRedo || applying"
1033
+ @click="redo"
1034
+ />
1035
+ </UTooltip>
1036
+ <div class="w-px h-5 bg-default mx-1" />
1037
+ <UButton
1038
+ size="sm"
1039
+ variant="ghost"
1040
+ color="neutral"
1041
+ :label="$t('dms_database.diagram.toolbar.cancel')"
1042
+ :disabled="!isDirty || applying"
1043
+ @click="cancelChanges"
1044
+ />
1045
+ <UButton
1046
+ size="sm"
1047
+ color="primary"
1048
+ :label="$t('dms_database.diagram.toolbar.apply')"
1049
+ :disabled="!isDirty"
1050
+ :loading="applying"
1051
+ @click="applyChanges"
1052
+ />
1053
+ </div>
1054
+ </template>
1055
+
1056
+ <template #bottom-center>
1057
+ <div
1058
+ class="flex items-center gap-1 rounded-lg border border-default bg-elevated p-1 shadow-md"
1059
+ >
1060
+ <UTooltip :text="$t('dms_database.diagram.toolbar.pointer')">
1061
+ <UButton
1062
+ size="sm"
1063
+ :color="panMode ? 'neutral' : 'primary'"
1064
+ :variant="panMode ? 'ghost' : 'solid'"
1065
+ icon="i-ph-cursor"
1066
+ :aria-pressed="!panMode"
1067
+ :aria-label="$t('dms_database.diagram.toolbar.pointer')"
1068
+ @click="panMode = false"
1069
+ />
1070
+ </UTooltip>
1071
+ <UTooltip :text="$t('dms_database.diagram.toolbar.hand')">
1072
+ <UButton
1073
+ size="sm"
1074
+ :color="panMode ? 'primary' : 'neutral'"
1075
+ :variant="panMode ? 'solid' : 'ghost'"
1076
+ icon="i-ph-hand"
1077
+ :aria-pressed="panMode"
1078
+ :aria-label="$t('dms_database.diagram.toolbar.hand')"
1079
+ @click="panMode = true"
1080
+ />
1081
+ </UTooltip>
1082
+ <div class="w-px h-5 bg-default mx-1" />
1083
+ <UTooltip :text="$t('dms_database.diagram.toolbar.zoomOut')">
1084
+ <UButton
1085
+ size="sm"
1086
+ variant="ghost"
1087
+ color="neutral"
1088
+ icon="i-ph-magnifying-glass-minus"
1089
+ :aria-label="$t('dms_database.diagram.toolbar.zoomOut')"
1090
+ @click="zoomOut()"
1091
+ />
1092
+ </UTooltip>
1093
+ <UTooltip :text="$t('dms_database.diagram.toolbar.resetZoom')">
1094
+ <button
1095
+ type="button"
1096
+ class="px-2 text-sm text-muted tabular-nums hover:text-highlighted transition-colors"
1097
+ :aria-label="$t('dms_database.diagram.toolbar.resetZoom')"
1098
+ @click="resetZoom"
1099
+ >
1100
+ {{ zoomPercent }}
1101
+ </button>
1102
+ </UTooltip>
1103
+ <UTooltip :text="$t('dms_database.diagram.toolbar.zoomIn')">
1104
+ <UButton
1105
+ size="sm"
1106
+ variant="ghost"
1107
+ color="neutral"
1108
+ icon="i-ph-magnifying-glass-plus"
1109
+ :aria-label="$t('dms_database.diagram.toolbar.zoomIn')"
1110
+ @click="zoomIn()"
1111
+ />
1112
+ </UTooltip>
1113
+ <UTooltip :text="$t('dms_database.diagram.toolbar.fitView')">
1114
+ <UButton
1115
+ size="sm"
1116
+ variant="ghost"
1117
+ color="neutral"
1118
+ icon="i-ph-arrows-out"
1119
+ :aria-label="$t('dms_database.diagram.toolbar.fitView')"
1120
+ @click="fitView()"
1121
+ />
1122
+ </UTooltip>
1123
+ <div class="w-px h-5 bg-default mx-1" />
1124
+ <UTooltip :text="$t('dms_database.diagram.toolbar.notes')">
1125
+ <UButton
1126
+ size="sm"
1127
+ variant="ghost"
1128
+ color="neutral"
1129
+ icon="i-ph-text-t"
1130
+ :aria-label="$t('dms_database.diagram.toolbar.notes')"
1131
+ @click="addNote"
1132
+ />
1133
+ </UTooltip>
1134
+ </div>
1135
+ </template>
1136
+ </DmsFlowCanvas>
1137
+ </div>
1138
+ </template>
1139
+
1140
+ <style scoped>
1141
+ /* Pan tool (hand): grab the empty canvas to move the view. Cursor tool keeps the
1142
+ default arrow so node drag / selection reads as the active interaction. The
1143
+ pane sits inside DmsFlowCanvas, hence :deep. */
1144
+ .diagram-pan-mode :deep(.vue-flow__pane) {
1145
+ cursor: grab;
1146
+ }
1147
+ .diagram-pan-mode :deep(.vue-flow__pane.dragging) {
1148
+ cursor: grabbing;
1149
+ }
1150
+ </style>