@antelopejs/dms-database 0.0.13 → 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.
Files changed (173) hide show
  1. package/README.md +30 -5
  2. package/dist/db/models/query_history.model.d.ts +1 -0
  3. package/dist/db/models/query_history.model.js +6 -0
  4. package/dist/db/models/query_history.model.js.map +1 -1
  5. package/dist/db/tables/query_history.table.d.ts +3 -0
  6. package/dist/db/tables/query_history.table.js +12 -0
  7. package/dist/db/tables/query_history.table.js.map +1 -1
  8. package/dist/db/tables/saved_queries.table.d.ts +1 -0
  9. package/dist/db/tables/saved_queries.table.js +4 -0
  10. package/dist/db/tables/saved_queries.table.js.map +1 -1
  11. package/dist/i18n/messages.d.ts +39 -0
  12. package/dist/i18n/messages.js +64 -0
  13. package/dist/i18n/messages.js.map +1 -0
  14. package/dist/index.d.ts +1 -0
  15. package/dist/index.js +1 -7
  16. package/dist/index.js.map +1 -1
  17. package/dist/module.d.ts +11 -0
  18. package/dist/module.js +55 -0
  19. package/dist/module.js.map +1 -0
  20. package/dist/pages/data.d.ts +1 -1
  21. package/dist/pages/data.js +9 -6
  22. package/dist/pages/data.js.map +1 -1
  23. package/dist/pages/diagram.d.ts +9 -0
  24. package/dist/pages/diagram.js +45 -0
  25. package/dist/pages/diagram.js.map +1 -0
  26. package/dist/pages/index.d.ts +1 -0
  27. package/dist/pages/index.js +1 -0
  28. package/dist/pages/index.js.map +1 -1
  29. package/dist/pages/overview.d.ts +4 -1
  30. package/dist/pages/overview.js +113 -6
  31. package/dist/pages/overview.js.map +1 -1
  32. package/dist/pages/query.d.ts +1 -1
  33. package/dist/pages/query.js +22 -6
  34. package/dist/pages/query.js.map +1 -1
  35. package/dist/pages/schemas.d.ts +2 -1
  36. package/dist/pages/schemas.js +143 -6
  37. package/dist/pages/schemas.js.map +1 -1
  38. package/dist/routes/data.d.ts +8 -0
  39. package/dist/routes/data.js +84 -21
  40. package/dist/routes/data.js.map +1 -1
  41. package/dist/routes/index.d.ts +1 -0
  42. package/dist/routes/index.js +1 -0
  43. package/dist/routes/index.js.map +1 -1
  44. package/dist/routes/overview.d.ts +12 -0
  45. package/dist/routes/overview.js +55 -0
  46. package/dist/routes/overview.js.map +1 -0
  47. package/dist/routes/query.d.ts +5 -1
  48. package/dist/routes/query.js +162 -27
  49. package/dist/routes/query.js.map +1 -1
  50. package/dist/routes/tables.d.ts +15 -12
  51. package/dist/routes/tables.js +95 -55
  52. package/dist/routes/tables.js.map +1 -1
  53. package/dist/service/catalog.d.ts +3 -0
  54. package/dist/service/catalog.js +26 -0
  55. package/dist/service/catalog.js.map +1 -0
  56. package/dist/service/health.d.ts +3 -3
  57. package/dist/service/health.js +17 -14
  58. package/dist/service/health.js.map +1 -1
  59. package/dist/service/introspect.d.ts +3 -1
  60. package/dist/service/introspect.js +131 -43
  61. package/dist/service/introspect.js.map +1 -1
  62. package/dist/service/overview.d.ts +9 -0
  63. package/dist/service/overview.js +113 -0
  64. package/dist/service/overview.js.map +1 -0
  65. package/dist/service/queryInspection.d.ts +15 -0
  66. package/dist/service/queryInspection.js +107 -0
  67. package/dist/service/queryInspection.js.map +1 -0
  68. package/dist/service/references.d.ts +9 -0
  69. package/dist/service/references.js +52 -0
  70. package/dist/service/references.js.map +1 -0
  71. package/dist/service/schemaFilters.d.ts +26 -0
  72. package/dist/service/schemaFilters.js +42 -0
  73. package/dist/service/schemaFilters.js.map +1 -0
  74. package/dist/service/stagedSerialization.d.ts +2 -0
  75. package/dist/service/stagedSerialization.js +38 -10
  76. package/dist/service/stagedSerialization.js.map +1 -1
  77. package/dist/service/types.d.ts +11 -1
  78. package/dist/types/constants.d.ts +2 -0
  79. package/dist/types/constants.js +3 -1
  80. package/dist/types/constants.js.map +1 -1
  81. package/dist/utils/query.d.ts +0 -2
  82. package/dist/utils/query.js +0 -20
  83. package/dist/utils/query.js.map +1 -1
  84. package/frontend-vue/app/{composables → build/composables}/useDataBrowserGrid.ts +65 -50
  85. package/frontend-vue/app/build/composables/useDataBrowserTabs.ts +366 -0
  86. package/frontend-vue/app/build/composables/useDatabaseHealth.ts +50 -0
  87. package/frontend-vue/app/build/composables/useDatabaseSchemas.ts +140 -0
  88. package/frontend-vue/app/{composables → build/composables}/useDiagramPersistence.ts +47 -39
  89. package/frontend-vue/app/build/composables/useQueryRuntime.ts +217 -0
  90. package/frontend-vue/app/build/composables/useQueryStore.ts +162 -0
  91. package/frontend-vue/app/build/data/cellValues.ts +132 -0
  92. package/frontend-vue/app/build/data/instanceOptions.ts +36 -0
  93. package/frontend-vue/app/build/data/pickerMenus.ts +175 -0
  94. package/frontend-vue/app/build/data/recentTables.ts +48 -0
  95. package/frontend-vue/app/build/data/stagedEdits.ts +117 -0
  96. package/frontend-vue/app/build/data/stagedWrites.ts +64 -0
  97. package/frontend-vue/app/build/data/useStagedSave.ts +298 -0
  98. package/frontend-vue/app/build/diagram/autosave.ts +72 -0
  99. package/frontend-vue/app/build/diagram/graph.ts +585 -0
  100. package/frontend-vue/app/build/diagram/history.ts +178 -0
  101. package/frontend-vue/app/build/diagram/shortcuts.ts +62 -0
  102. package/frontend-vue/app/build/diagram/sync.ts +137 -0
  103. package/frontend-vue/app/build/query/completion.ts +100 -0
  104. package/frontend-vue/app/build/query/csv.ts +32 -0
  105. package/frontend-vue/app/build/query/examples.ts +38 -0
  106. package/frontend-vue/app/build/query/runs.ts +168 -0
  107. package/frontend-vue/app/build/query/tableColumns.ts +35 -0
  108. package/frontend-vue/app/build/query/target.ts +143 -0
  109. package/frontend-vue/app/build/schemas/filterState.ts +128 -0
  110. package/frontend-vue/app/build/schemas/useSchemasListing.ts +62 -0
  111. package/frontend-vue/app/build/utils/databaseLinks.ts +98 -0
  112. package/frontend-vue/app/build/utils/fieldTypes.ts +198 -0
  113. package/frontend-vue/app/build/utils/relativeTime.ts +19 -0
  114. package/frontend-vue/app/{utils → build/utils}/stagedSerialization.ts +7 -1
  115. package/frontend-vue/app/components/ConnectionStatus.vue +122 -0
  116. package/frontend-vue/app/components/DataBrowser.vue +126 -0
  117. package/frontend-vue/app/components/DataBrowserSidebar.vue +293 -210
  118. package/frontend-vue/app/components/DataBrowserTabBar.vue +185 -93
  119. package/frontend-vue/app/components/DataFilterBar.vue +163 -0
  120. package/frontend-vue/app/components/DataGrid.vue +737 -499
  121. package/frontend-vue/app/components/DataGridCell.vue +125 -77
  122. package/frontend-vue/app/components/DataGridHeaderCell.vue +119 -81
  123. package/frontend-vue/app/components/DiagramNoteNode.vue +81 -63
  124. package/frontend-vue/app/components/DiagramRelationEdge.vue +57 -27
  125. package/frontend-vue/app/components/DiagramStubNode.vue +16 -17
  126. package/frontend-vue/app/components/DiagramTableNode.vue +106 -68
  127. package/frontend-vue/app/components/InstancePicker.vue +195 -0
  128. package/frontend-vue/app/components/JsonCellPanel.vue +30 -29
  129. package/frontend-vue/app/components/QueryBuilderPanel.vue +290 -238
  130. package/frontend-vue/app/components/QueryConsole.vue +726 -0
  131. package/frontend-vue/app/components/QueryLibrary.vue +314 -0
  132. package/frontend-vue/app/components/QueryResultPanel.vue +556 -126
  133. package/frontend-vue/app/components/RowDrawer.vue +520 -0
  134. package/frontend-vue/app/components/SchemaDiagram.vue +1016 -1051
  135. package/frontend-vue/app/components/SchemaPicker.vue +86 -0
  136. package/frontend-vue/app/components/SchemasFilterBar.vue +201 -0
  137. package/frontend-vue/app/components/TableInspector.vue +741 -0
  138. package/frontend-vue/app/config/shortcuts-registry.ts +65 -0
  139. package/frontend-vue/dms.frontend.ts +5 -14
  140. package/frontend-vue/i18n/locales/database-en-GB.json +526 -193
  141. package/frontend-vue/i18n/locales/database-fr-FR.json +528 -195
  142. package/frontend-vue/package.json +2 -1
  143. package/frontend-vue/pnpm-lock.yaml +2601 -858
  144. package/frontend-vue/pnpm-workspace.yaml +1 -1
  145. package/frontend-vue/tests/dataBrowser.test.ts +206 -0
  146. package/frontend-vue/tests/diagram.test.ts +516 -0
  147. package/frontend-vue/tests/helpers.test.ts +205 -0
  148. package/frontend-vue/tests/instanceOptions.test.ts +53 -0
  149. package/frontend-vue/tests/locales.test.ts +56 -0
  150. package/frontend-vue/tests/persistence.test.ts +106 -5
  151. package/frontend-vue/tests/pickerMenus.test.ts +254 -0
  152. package/frontend-vue/tests/queryConsole.test.ts +282 -0
  153. package/frontend-vue/tests/queryRuntime.test.ts +2 -2
  154. package/frontend-vue/tests/registration.test.ts +36 -25
  155. package/frontend-vue/tests/schemasFilters.test.ts +115 -0
  156. package/package.json +9 -6
  157. package/frontend-vue/app/components/QueryFavoritesPanel.vue +0 -127
  158. package/frontend-vue/app/components/QueryHistoryPanel.vue +0 -134
  159. package/frontend-vue/app/components/SchemaTableInspector.vue +0 -278
  160. package/frontend-vue/app/composables/useDataBrowserTabs.ts +0 -255
  161. package/frontend-vue/app/composables/useDatabaseHealth.ts +0 -50
  162. package/frontend-vue/app/composables/useDatabaseSchemas.ts +0 -92
  163. package/frontend-vue/app/composables/useQueryRuntime.ts +0 -118
  164. package/frontend-vue/app/composables/useQueryStore.ts +0 -117
  165. package/frontend-vue/app/composables/useTableViewRowClick.ts +0 -22
  166. package/frontend-vue/app/custom-pages/database/data.vue +0 -41
  167. package/frontend-vue/app/custom-pages/database/overview.vue +0 -367
  168. package/frontend-vue/app/custom-pages/database/query.vue +0 -436
  169. package/frontend-vue/app/custom-pages/database/schemas.vue +0 -218
  170. package/frontend-vue/app/utils/databaseFieldModifiers.ts +0 -33
  171. package/frontend-vue/app/utils/databaseFieldTypeIcons.ts +0 -224
  172. package/frontend-vue/app/utils/dmsTableView.ts +0 -83
  173. package/frontend-vue/app/utils/relativeTime.ts +0 -18
@@ -1,1161 +1,1126 @@
1
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 { ShallowUnwrapRef } from "vue";
10
- import DiagramNoteNode from "./DiagramNoteNode.vue";
11
- import DiagramRelationEdge from "./DiagramRelationEdge.vue";
12
- import DiagramStubNode from "./DiagramStubNode.vue";
13
- import DiagramTableNode from "./DiagramTableNode.vue";
14
- import type { SchemaSummary } from "../composables/useDatabaseSchemas";
2
+ import { useDatabaseSchemas } from '../build/composables/useDatabaseSchemas'
3
+ import { useDiagramPersistence } from '../build/composables/useDiagramPersistence'
15
4
  import type {
16
- DiagramNote,
17
- DiagramPositions,
18
- } from "../composables/useDiagramPersistence";
19
-
20
- // Schema diagram canvas — rendered inline as the "Diagram" view of the schema
21
- // page (not a dedicated page). The active schema is driven by the `schemaId`
22
- // prop (the page pill); `active` lets the parent trigger a refit when the view
23
- // becomes visible (vue-flow needs a measured container).
24
- interface Props {
25
- schemaId: string | null;
26
- active?: boolean;
27
- }
28
-
29
- const props = withDefaults(defineProps<Props>(), { active: true });
30
-
31
- const emit = defineEmits<{
32
- // A table node was double-clicked — the parent opens the shared inspector
33
- // drawer for that table (same drawer as the schema list view).
34
- "inspect-table": [tableName: string];
35
- }>();
36
-
37
- const NODE_WIDTH = 240;
38
- const NODE_HEIGHT_PER_FIELD = 28;
39
- const NODE_HEIGHT_HEADER = 40;
40
- const STUB_NODE_WIDTH = 180;
41
- const STUB_NODE_HEIGHT = 28;
42
- const LAYOUT_DIRECTION = "TB";
43
- const LAYOUT_NODE_SEP = 40;
44
- const LAYOUT_RANK_SEP = 80;
45
- const TABLE_NODE_TYPE = "tableNode";
46
- const STUB_NODE_TYPE = "stubNode";
47
- const NOTE_NODE_TYPE = "noteNode";
48
- const RELATION_EDGE_TYPE = "relationEdge";
49
- const RESET_ZOOM = 1;
50
- const NOTE_DEFAULT_WIDTH = 220;
51
- const NOTE_DEFAULT_HEIGHT = 140;
52
- const SQUASH_WINDOW_MS = 600;
53
- const HYSTERESIS_PX = 15;
54
-
55
- type HandleSide = "left" | "right";
56
- interface Sides {
57
- source: HandleSide;
58
- target: HandleSide;
5
+ NodeDragEvent,
6
+ NodeMouseEvent,
7
+ VueFlowStore,
8
+ } from '@vue-flow/core'
9
+ import { useEventListener, useNow } from '@vueuse/core'
10
+ import type { ShallowUnwrapRef } from 'vue'
11
+ import { DiagramAutosave, type SaveStatus } from '../build/diagram/autosave'
12
+ import {
13
+ buildGraph,
14
+ type DiagramNode,
15
+ freeSpot,
16
+ layOutSchema,
17
+ NODE_WIDTH,
18
+ nodeHeight,
19
+ NOTE_NODE_TYPE,
20
+ type NoteDraft,
21
+ neighbourIds,
22
+ noteNodeId,
23
+ RELATION_EDGE_TYPE,
24
+ type Rect,
25
+ type Sides,
26
+ STUB_NODE_TYPE,
27
+ searchTables,
28
+ TABLE_NODE_TYPE,
29
+ type TableNodeData,
30
+ tableNodeId,
31
+ } from '../build/diagram/graph'
32
+ import {
33
+ clonePositions,
34
+ DiagramHistory,
35
+ type DiagramOperation,
36
+ type DiagramState,
37
+ } from '../build/diagram/history'
38
+ import {
39
+ canvasOwnsKey,
40
+ type DiagramCommand,
41
+ diagramCommand,
42
+ } from '../build/diagram/shortcuts'
43
+ import { DiagramSync, noteFromStored } from '../build/diagram/sync'
44
+ import DiagramNoteNode from './DiagramNoteNode.vue'
45
+ import DiagramRelationEdge from './DiagramRelationEdge.vue'
46
+ import DiagramStubNode from './DiagramStubNode.vue'
47
+ import DiagramTableNode from './DiagramTableNode.vue'
48
+ import TableInspector from './TableInspector.vue'
49
+
50
+ // The Diagram page: the tables of one schema and their relations. Moving a
51
+ // table saves the layout on its own (D-10), with undo and redo; auto layout
52
+ // is previewed before it is kept, because it moves every table at once.
53
+
54
+ const SAVE_DELAY_MS = 800
55
+ const RESET_ZOOM = 1
56
+ // Far enough out for a large schema to fit in the view (D-16).
57
+ const MIN_ZOOM = 0.1
58
+ const NOTE_WIDTH = 220
59
+ const NOTE_HEIGHT = 140
60
+ const MINUTE_MS = 60_000
61
+ // Room around the tables for the floating toolbars.
62
+ const FIT_PADDING = 0.25
63
+ // Above this many tables the canvas suggests focusing on one of them.
64
+ const LARGE_SCHEMA_TABLES = 40
65
+ const SEARCH_HINT_ID = 'diagram-search-hint'
66
+
67
+ const { t } = useI18n()
68
+ const route = useDmsRoute()
69
+ const router = useDmsRouter()
70
+ const toast = useToast()
71
+ const { schemas, isLoading: schemasLoading } = useDatabaseSchemas()
72
+ const persistence = useDiagramPersistence()
73
+ const { open: openDrawer } = useDrawer()
74
+ const now = useNow({ interval: MINUTE_MS / 2 })
75
+
76
+ const canvas = shallowRef<ShallowUnwrapRef<VueFlowStore> | null>(null)
77
+ const canvasWrapper = useTemplateRef<HTMLElement>('canvasWrapper')
78
+ const searchInput = useTemplateRef<{ inputRef?: HTMLInputElement }>(
79
+ 'searchInput',
80
+ )
81
+
82
+ const state = reactive<DiagramState>({ positions: {}, notes: [] })
83
+ const history = new DiagramHistory()
84
+ const historyRevision = ref(0)
85
+ const routing = new Map<string, Sides>()
86
+
87
+ const selectedId = ref<string | null>(null)
88
+ const focusId = ref<string | null>(null)
89
+ const panMode = ref(false)
90
+ const search = ref('')
91
+ const searchIndex = ref(-1)
92
+ const viewport = ref({ x: 0, y: 0, zoom: RESET_ZOOM })
93
+ const preview = ref<{ from: DiagramState['positions'] } | null>(null)
94
+ const saveState = ref<SaveStatus>('idle')
95
+ const savedAt = ref<Date | null>(null)
96
+ const loaded = ref(false)
97
+ // The note just created, opened ready to write until its edit ends.
98
+ const editNoteId = ref<string | null>(null)
99
+ let sync: DiagramSync | null = null
100
+ let autosave: DiagramAutosave | null = null
101
+ let fitPending = true
102
+
103
+ // --- schema ---
104
+ const schemaId = computed<string | null>(() => {
105
+ const asked = route.query.schema
106
+ if (typeof asked === 'string' && schemas.value.some((s) => s.id === asked))
107
+ return asked
108
+ return schemas.value[0]?.id ?? null
109
+ })
110
+ const schema = computed(
111
+ () => schemas.value.find((s) => s.id === schemaId.value) ?? null,
112
+ )
113
+ const schemaItems = computed(() =>
114
+ schemas.value.map((s) => ({ label: s.id, value: s.id, icon: 'i-ph-stack' })),
115
+ )
116
+
117
+ // Each schema is a step of the browser history (D-11).
118
+ function openSchema(id: string) {
119
+ if (id === schemaId.value) return
120
+ router.push({ query: { ...route.query, schema: id, table: undefined } })
59
121
  }
60
122
 
61
- function pickSides(dx: number, W: number, prev?: Sides): Sides {
62
- const pure: Sides =
63
- dx >= W
64
- ? { source: "right", target: "left" }
65
- : dx >= 0
66
- ? { source: "right", target: "right" }
67
- : dx >= -W
68
- ? { source: "left", target: "left" }
69
- : { source: "left", target: "right" };
70
- if (!prev || (prev.source === pure.source && prev.target === pure.target)) {
71
- return pure;
123
+ /** Opens the table a stub stands for (D-10). */
124
+ function openTable(schemaName: string, tableName: string) {
125
+ if (schemaName !== schemaId.value) {
126
+ router.push({
127
+ query: { ...route.query, schema: schemaName, table: tableName },
128
+ })
129
+ return
72
130
  }
73
- const H = HYSTERESIS_PX;
74
- if (prev.source === "right" && prev.target === "left" && dx > W - H)
75
- return prev;
76
- if (
77
- prev.source === "right" &&
78
- prev.target === "right" &&
79
- dx > -H &&
80
- dx < W + H
81
- )
82
- return prev;
83
- if (
84
- prev.source === "left" &&
85
- prev.target === "left" &&
86
- dx > -W - H &&
87
- dx < H
88
- )
89
- return prev;
90
- if (prev.source === "left" && prev.target === "right" && dx < -W + H)
91
- return prev;
92
- return pure;
93
- }
94
-
95
- const edgeRouting = new Map<string, Sides>();
96
-
97
- interface NoteDraft {
98
- id: string;
99
- schemaName: string;
100
- text: string;
101
- x: number;
102
- y: number;
103
- width: number;
104
- height: number;
105
- color: string | null;
106
- isTemp: boolean;
107
- }
108
-
109
- type Op =
110
- | {
111
- kind: "tableMove";
112
- tableName: string;
113
- from: { x: number; y: number };
114
- to: { x: number; y: number };
115
- }
116
- | {
117
- kind: "noteMove";
118
- noteId: string;
119
- from: { x: number; y: number };
120
- to: { x: number; y: number };
121
- }
122
- | { kind: "noteText"; noteId: string; from: string; to: string }
123
- | { kind: "noteCreate"; tempId: string; snapshot: NoteDraft }
124
- | { kind: "noteDelete"; snapshot: NoteDraft }
125
- | { kind: "autoLayout"; from: DiagramPositions };
126
-
127
- interface TableDiagramNode {
128
- id: string;
129
- type: typeof TABLE_NODE_TYPE;
130
- position: { x: number; y: number };
131
- data: {
132
- tableName: string;
133
- fields: Record<string, unknown>;
134
- indexes: Record<string, { fields?: string[] }>;
135
- modifiers: Record<string, string[]>;
136
- };
131
+ const id = tableNodeId(schemaName, tableName)
132
+ if (focusIds.value && !focusIds.value.has(id)) focusId.value = null
133
+ nextTick(() => centerOn(id))
137
134
  }
138
135
 
139
- interface StubDiagramNode {
140
- id: string;
141
- type: typeof STUB_NODE_TYPE;
142
- position: { x: number; y: number };
143
- selectable: false;
144
- draggable: false;
145
- data: {
146
- targetSchema: string;
147
- targetTable: string;
148
- };
136
+ function warn(title: string) {
137
+ toast.add({ title, color: 'warning', icon: 'i-ph-warning' })
149
138
  }
150
139
 
151
- interface NoteDiagramNode {
152
- id: string;
153
- type: typeof NOTE_NODE_TYPE;
154
- position: { x: number; y: number };
155
- data: {
156
- noteId: string;
157
- text: string;
158
- color: string | null;
159
- width: number;
160
- height: number;
161
- onTextChange: (noteId: string, text: string) => void;
162
- onDelete: (noteId: string) => void;
163
- };
140
+ // A schema the URL names that does not exist: say so, and drop it from the
141
+ // URL (D-12).
142
+ onMounted(() => {
143
+ watch(
144
+ [() => route.query.schema, schemas, schemasLoading],
145
+ () => {
146
+ const asked = route.query.schema
147
+ if (typeof asked !== 'string' || schemasLoading.value) return
148
+ if (!schemas.value.length || schemas.value.some((s) => s.id === asked))
149
+ return
150
+ warn(t('dms_database.diagram.not_found.schema', { schema: asked }))
151
+ router.replace({
152
+ query: { ...route.query, schema: undefined, table: undefined },
153
+ })
154
+ },
155
+ { immediate: true },
156
+ )
157
+ })
158
+
159
+ // --- graph ---
160
+ const focusIds = computed(() =>
161
+ focusId.value && schema.value
162
+ ? neighbourIds(schema.value, focusId.value.split('::')[1] ?? '')
163
+ : null,
164
+ )
165
+
166
+ // Where dagre puts every table of the schema: the place of a table without a
167
+ // saved one, focused or not (D-4).
168
+ const fullLayout = computed(() =>
169
+ schema.value ? layOutSchema(schema.value) : undefined,
170
+ )
171
+
172
+ function render() {
173
+ if (!canvas.value || !schema.value || !loaded.value) return
174
+ const built = buildGraph({
175
+ schema: schema.value,
176
+ positions: state.positions,
177
+ notes: state.notes,
178
+ selectedId: selectedId.value,
179
+ focusIds: focusIds.value,
180
+ noteHandlers: {
181
+ onTextChange: changeNoteText,
182
+ onDelete: deleteNote,
183
+ onEditEnd: endNoteEdit,
184
+ },
185
+ editNoteId: editNoteId.value,
186
+ onOpenSchema: openTable,
187
+ routing,
188
+ layout: fullLayout.value,
189
+ })
190
+ canvas.value.setNodes(built.nodes)
191
+ canvas.value.setEdges(built.edges)
164
192
  }
165
193
 
166
- type DiagramNodeT = TableDiagramNode | StubDiagramNode | NoteDiagramNode;
167
-
168
- interface DiagramEdge {
169
- id: string;
170
- type: typeof RELATION_EDGE_TYPE;
171
- source: string;
172
- target: string;
173
- sourceHandle: string;
174
- targetHandle: string;
175
- label: string;
176
- animated: boolean;
177
- markerEnd: { type: MarkerType };
178
- data: {
179
- sourceBase: string;
180
- targetBase: string;
181
- isStubTarget: boolean;
182
- };
194
+ // The nodes have been measured: the first fit, or the one a schema change or
195
+ // a focus asks for, can frame them.
196
+ function onNodesInitialized() {
197
+ if (!fitPending) return
198
+ fitPending = false
199
+ if (!centerOnQueryTable()) fitAll()
183
200
  }
184
201
 
185
- const { t } = useI18n();
186
- const { schemas } = useDatabaseSchemas();
187
- const {
188
- fetchLayout,
189
- saveLayout,
190
- fetchNotes,
191
- createNote,
192
- updateNote,
193
- deleteNote,
194
- } = useDiagramPersistence();
195
- // DmsFlowCanvas (shared dms-ui layer) owns the <VueFlow> instance and the
196
- // canvas chrome (dotted DMS background, themed handles/edges, pan/zoom). It
197
- // exposes the full imperative VueFlow controller (setNodes/setEdges/fitView/
198
- // viewport/findNode/onNodeDrag…) via a template ref; all the ERD business
199
- // logic below (dagre auto-layout, edge routing, undo/redo, notes) stays here.
200
- // Vue's expose proxy unwraps the controller's refs: `edges` is the array
201
- // itself, not a Ref (`edges.value` is undefined and threw on every drag).
202
- const canvas = shallowRef<ShallowUnwrapRef<VueFlowStore> | null>(null);
203
- // Our own wrapper element — used to measure the visible canvas for note placement.
204
- const canvasWrapper = ref<HTMLElement | null>(null);
205
-
206
- function fitView() {
207
- canvas.value?.fitView();
208
- }
209
- function zoomIn() {
210
- canvas.value?.zoomIn();
202
+ function fitAll() {
203
+ canvas.value?.fitView({ padding: FIT_PADDING, maxZoom: RESET_ZOOM })
211
204
  }
212
- function zoomOut() {
213
- canvas.value?.zoomOut();
214
- }
215
-
216
- const panMode = ref(false);
217
- const rebuildKey = ref(0);
218
205
 
219
- // Live viewport mirror. VueFlow's controller `viewport` ref read through the
220
- // exposed DmsFlowCanvas template ref doesn't stay reactive here, so the zoom
221
- // readout froze at 100% and notes were placed against a stale {0,0,1} origin.
222
- // We keep our own copy fed by VueFlow's @viewport-change event (forwarded by
223
- // DmsFlowCanvas via $attrs) — it fires on wheel/pinch zoom, pan and the +/-/fit
224
- // controls — and derive both the zoom badge and note placement from it.
225
- const liveViewport = ref({ x: 0, y: 0, zoom: RESET_ZOOM });
206
+ watch([canvas, schema, selectedId, focusIds, loaded], render)
226
207
 
227
- function onViewportChange(viewport: { x: number; y: number; zoom: number }) {
228
- liveViewport.value = { x: viewport.x, y: viewport.y, zoom: viewport.zoom };
208
+ // --- loading and saving ---
209
+ // An auto layout being previewed is not kept yet: a save writes the layout
210
+ // from before it.
211
+ function stateToSave(): DiagramState {
212
+ return preview.value
213
+ ? { positions: preview.value.from, notes: state.notes }
214
+ : state
229
215
  }
230
216
 
231
- const tablePositions = ref<DiagramPositions>({});
232
- const notes = ref<NoteDraft[]>([]);
233
- const baseline = ref<{ tablePositions: DiagramPositions; notes: NoteDraft[] }>({
234
- tablePositions: {},
235
- notes: [],
236
- });
237
-
238
- const history = ref<Op[]>([]);
239
- const cursor = ref(0);
240
- let lastOpAt = 0;
241
-
242
- const applying = ref(false);
243
- let tempCounter = 0;
244
- let initialFitDone = false;
245
- // Gate the very first paint until both the canvas controller is mounted and the
246
- // initial schema layout has loaded (the controller is async now, unlike the old
247
- // synchronous useVueFlow), so saved table positions land on the first render.
248
- let initialLayoutSettled = false;
249
- let initialRenderDone = false;
250
- let loadToken = 0;
251
-
252
- const isDirty = computed(() => cursor.value > 0);
253
- const canUndo = computed(() => cursor.value > 0);
254
- const canRedo = computed(() => cursor.value < history.value.length);
255
-
256
- const effectiveSchemaId = computed<string | null>(
257
- () => props.schemaId ?? schemas.value[0]?.id ?? null,
258
- );
259
-
260
- const visibleSchemas = computed<SchemaSummary[]>(() => {
261
- const id = effectiveSchemaId.value;
262
- if (!id) return [];
263
- const match = schemas.value.find((s) => s.id === id);
264
- return match ? [match] : [];
265
- });
266
-
267
- function nodeIdFor(schemaId: string, tableName: string): string {
268
- return `${schemaId}::${tableName}`;
217
+ /** Shows the save status of the schema shown; a schema left reports a failure. */
218
+ function statusReporter(owner: DiagramSync) {
219
+ return (status: SaveStatus) => {
220
+ if (owner === sync) {
221
+ saveState.value = status
222
+ if (status === 'saved') savedAt.value = new Date()
223
+ return
224
+ }
225
+ // The save of a schema left before it ended.
226
+ if (status === 'error') {
227
+ toast.add({
228
+ title: t('dms_database.diagram.save.error_other', {
229
+ schema: owner.schemaId,
230
+ }),
231
+ color: 'error',
232
+ icon: 'i-ph-warning-circle',
233
+ })
234
+ }
235
+ }
269
236
  }
270
237
 
271
- function stubIdFor(schemaId: string, tableName: string): string {
272
- return `stub::${schemaId}::${tableName}`;
238
+ function scheduleSave() {
239
+ autosave?.schedule()
273
240
  }
274
241
 
275
- function noteNodeIdFor(noteId: string): string {
276
- return `note::${noteId}`;
242
+ function saveNow() {
243
+ autosave?.schedule()
244
+ void autosave?.flush()
277
245
  }
278
246
 
279
- function clonePositions(p: DiagramPositions): DiagramPositions {
280
- const out: DiagramPositions = {};
281
- for (const [k, v] of Object.entries(p)) out[k] = { x: v.x, y: v.y };
282
- return out;
247
+ async function load(id: string) {
248
+ // What is left of the shown schema's changes is copied now and written
249
+ // under its own id before the next schema loads (D-2).
250
+ const leaving = autosave?.flush()
251
+ autosave = null
252
+ loaded.value = false
253
+ routing.clear()
254
+ history.clear()
255
+ historyRevision.value++
256
+ preview.value = null
257
+ selectedId.value = null
258
+ focusId.value = null
259
+ editNoteId.value = null
260
+ searchIndex.value = -1
261
+ const owner = new DiagramSync(persistence, id)
262
+ sync = owner
263
+ await leaving
264
+ if (sync !== owner) return
265
+ try {
266
+ const [positions, notes] = await Promise.all([
267
+ persistence.fetchLayout(id),
268
+ persistence.fetchNotes(id),
269
+ ])
270
+ if (sync !== owner) return
271
+ state.positions = positions
272
+ state.notes = notes.map(noteFromStored)
273
+ } catch {
274
+ if (sync !== owner) return
275
+ state.positions = {}
276
+ state.notes = []
277
+ }
278
+ owner.reset(state)
279
+ autosave = new DiagramAutosave(
280
+ owner,
281
+ stateToSave,
282
+ SAVE_DELAY_MS,
283
+ statusReporter(owner),
284
+ )
285
+ saveState.value = 'idle'
286
+ savedAt.value = null
287
+ fitPending = true
288
+ loaded.value = true
283
289
  }
284
290
 
285
- function cloneNote(n: NoteDraft): NoteDraft {
286
- return { ...n };
287
- }
291
+ watch(schemaId, (id) => id && load(id), { immediate: true })
288
292
 
289
- interface BuiltGraph {
290
- nodes: DiagramNodeT[];
291
- edges: DiagramEdge[];
293
+ function record(op: DiagramOperation) {
294
+ history.push(op)
295
+ historyRevision.value++
296
+ scheduleSave()
292
297
  }
293
298
 
294
- function buildGraph(schemaList: SchemaSummary[]): BuiltGraph {
295
- const visibleIds = new Set<string>();
296
- for (const schema of schemaList) {
297
- for (const table of schema.tables) {
298
- visibleIds.add(nodeIdFor(schema.id, table.name));
299
- }
300
- }
301
-
302
- const graph = new dagre.graphlib.Graph();
303
- graph.setDefaultEdgeLabel(() => ({}));
304
- graph.setGraph({
305
- rankdir: LAYOUT_DIRECTION,
306
- nodesep: LAYOUT_NODE_SEP,
307
- ranksep: LAYOUT_RANK_SEP,
308
- });
309
-
310
- for (const schema of schemaList) {
311
- for (const table of schema.tables) {
312
- const fieldCount = Object.keys(table.fields).length;
313
- const height = NODE_HEIGHT_HEADER + fieldCount * NODE_HEIGHT_PER_FIELD;
314
- graph.setNode(nodeIdFor(schema.id, table.name), {
315
- width: NODE_WIDTH,
316
- height,
317
- });
318
- }
319
- }
320
-
321
- const stubs = new Map<string, { schema: string; table: string }>();
322
- const tableFieldsById = new Map<string, Record<string, unknown>>();
323
- for (const schema of schemaList) {
324
- for (const table of schema.tables) {
325
- tableFieldsById.set(nodeIdFor(schema.id, table.name), table.fields);
326
- }
327
- }
299
+ onBeforeUnmount(() => void autosave?.flush())
328
300
 
329
- interface PendingEdge {
330
- id: string;
331
- sourceId: string;
332
- targetId: string;
333
- sourceBase: string;
334
- targetBase: string;
335
- isStubTarget: boolean;
336
- label: string;
337
- many: boolean;
338
- }
339
- const pendingEdges: PendingEdge[] = [];
340
-
341
- for (const schema of schemaList) {
342
- for (const table of schema.tables) {
343
- const sourceId = nodeIdFor(schema.id, table.name);
344
- const sourceFields = tableFieldsById.get(sourceId);
345
- for (const rel of table.relations ?? []) {
346
- const realTargetId = nodeIdFor(rel.toSchema, rel.toTable);
347
- const isStubTarget = !visibleIds.has(realTargetId);
348
- const targetId = isStubTarget
349
- ? stubIdFor(rel.toSchema, rel.toTable)
350
- : realTargetId;
351
- if (isStubTarget && !stubs.has(targetId)) {
352
- stubs.set(targetId, {
353
- schema: rel.toSchema,
354
- table: rel.toTable,
355
- });
356
- graph.setNode(targetId, {
357
- width: STUB_NODE_WIDTH,
358
- height: STUB_NODE_HEIGHT,
359
- });
360
- }
361
- graph.setEdge(sourceId, targetId);
362
- const sourceBase =
363
- sourceFields && rel.fromField in sourceFields
364
- ? rel.fromField
365
- : "__node__";
366
- let targetBase: string;
367
- if (isStubTarget) {
368
- targetBase = "stub";
369
- } else {
370
- const targetFields = tableFieldsById.get(realTargetId);
371
- targetBase =
372
- targetFields && rel.toField in targetFields
373
- ? rel.toField
374
- : "__node__";
375
- }
376
- pendingEdges.push({
377
- id: `${sourceId}::${rel.fromField}->${realTargetId}`,
378
- sourceId,
379
- targetId,
380
- sourceBase,
381
- targetBase,
382
- isStubTarget,
383
- label: rel.fromField,
384
- many: rel.many,
385
- });
386
- }
387
- }
388
- }
389
-
390
- dagre.layout(graph);
391
-
392
- const nodePositions = new Map<
393
- string,
394
- { x: number; y: number; width: number }
395
- >();
396
- const nodes: DiagramNodeT[] = [];
397
- for (const schema of schemaList) {
398
- for (const table of schema.tables) {
399
- const id = nodeIdFor(schema.id, table.name);
400
- const laid = graph.node(id);
401
- const saved = tablePositions.value[table.name];
402
- const position = saved ?? { x: laid.x - NODE_WIDTH / 2, y: laid.y };
403
- nodePositions.set(id, { x: position.x, y: position.y, width: NODE_WIDTH });
404
- nodes.push({
405
- id,
406
- type: TABLE_NODE_TYPE,
407
- position,
408
- data: {
409
- tableName: table.name,
410
- fields: table.fields,
411
- indexes: table.indexes,
412
- modifiers: table.modifiers,
413
- },
414
- });
415
- }
416
- }
417
- for (const [id, info] of stubs) {
418
- const laid = graph.node(id);
419
- const position = { x: laid.x - STUB_NODE_WIDTH / 2, y: laid.y };
420
- nodePositions.set(id, {
421
- x: position.x,
422
- y: position.y,
423
- width: STUB_NODE_WIDTH,
424
- });
425
- nodes.push({
426
- id,
427
- type: STUB_NODE_TYPE,
428
- position,
429
- selectable: false,
430
- draggable: false,
431
- data: { targetSchema: info.schema, targetTable: info.table },
432
- });
433
- }
434
-
435
- const edges: DiagramEdge[] = [];
436
- for (const pe of pendingEdges) {
437
- const sPos = nodePositions.get(pe.sourceId);
438
- const tPos = nodePositions.get(pe.targetId);
439
- if (!sPos || !tPos) continue;
440
- const dx = tPos.x + tPos.width / 2 - (sPos.x + sPos.width / 2);
441
- const sides = pickSides(dx, NODE_WIDTH, edgeRouting.get(pe.id));
442
- edgeRouting.set(pe.id, sides);
443
- const sourceHandle = `${pe.sourceBase}::${sides.source}::source`;
444
- const targetHandle = `${pe.targetBase}::${sides.target}::target`;
445
- edges.push({
446
- id: pe.id,
447
- type: RELATION_EDGE_TYPE,
448
- source: pe.sourceId,
449
- target: pe.targetId,
450
- sourceHandle,
451
- targetHandle,
452
- label: pe.label,
453
- animated: pe.many,
454
- markerEnd: { type: MarkerType.ArrowClosed },
455
- data: {
456
- sourceBase: pe.sourceBase,
457
- targetBase: pe.targetBase,
458
- isStubTarget: pe.isStubTarget,
459
- },
460
- });
461
- }
301
+ const savedLabel = computed(() => {
302
+ if (saveState.value === 'saving') return t('dms_database.diagram.save.saving')
303
+ if (saveState.value === 'error') return t('dms_database.diagram.save.error')
304
+ if (!savedAt.value) return t('dms_database.diagram.save.idle')
305
+ const minutes = Math.round(
306
+ (now.value.getTime() - savedAt.value.getTime()) / MINUTE_MS,
307
+ )
308
+ return minutes < 1
309
+ ? t('dms_database.diagram.save.just_now')
310
+ : t('dms_database.diagram.save.minutes', { count: minutes })
311
+ })
312
+
313
+ // --- undo / redo ---
314
+ const canUndo = computed(
315
+ () => historyRevision.value >= 0 && history.canUndo && !preview.value,
316
+ )
317
+ const canRedo = computed(
318
+ () => historyRevision.value >= 0 && history.canRedo && !preview.value,
319
+ )
462
320
 
463
- for (const note of notes.value) {
464
- nodes.push({
465
- id: noteNodeIdFor(note.id),
466
- type: NOTE_NODE_TYPE,
467
- position: { x: note.x, y: note.y },
468
- data: {
469
- noteId: note.id,
470
- text: note.text,
471
- color: note.color,
472
- width: note.width,
473
- height: note.height,
474
- onTextChange: onNoteTextChange,
475
- onDelete: onNoteDelete,
476
- },
477
- });
478
- }
321
+ function undo() {
322
+ if (!canUndo.value || !history.undo(state)) return
323
+ historyRevision.value++
324
+ render()
325
+ scheduleSave()
326
+ }
479
327
 
480
- return { nodes, edges };
328
+ function redo() {
329
+ if (!canRedo.value || !history.redo(state)) return
330
+ historyRevision.value++
331
+ render()
332
+ scheduleSave()
481
333
  }
482
334
 
483
- function rebuildAndApply() {
484
- if (!canvas.value) return;
485
- const built = buildGraph(visibleSchemas.value);
486
- canvas.value.setNodes(built.nodes);
487
- canvas.value.setEdges(built.edges);
335
+ // --- auto layout, previewed before it is kept ---
336
+ const movedCount = computed(() => {
337
+ if (!preview.value || !schema.value) return 0
338
+ return schema.value.tables.filter((table) => {
339
+ // A table without a saved position already sat where dagre puts it.
340
+ const before = preview.value?.from[table.name]
341
+ const after = state.positions[table.name]
342
+ return Boolean(
343
+ before && after && (before.x !== after.x || before.y !== after.y),
344
+ )
345
+ }).length
346
+ })
347
+
348
+ function startAutoLayout() {
349
+ if (!fullLayout.value || preview.value) return
350
+ preview.value = { from: clonePositions(state.positions) }
351
+ state.positions = clonePositions(fullLayout.value.tables)
352
+ routing.clear()
353
+ render()
354
+ nextTick(fitAll)
488
355
  }
489
356
 
490
- // Data-driven rebuilds after the first paint: schema edits, drag-induced
491
- // rebuildKey bumps, undo/redo, notes. The initial render is owned by
492
- // renderInitialGraph so it isn't a premature dagre-only pass before the saved
493
- // positions have loaded.
494
- watch(
495
- [visibleSchemas, rebuildKey],
496
- () => {
497
- if (initialRenderDone) rebuildAndApply();
498
- },
499
- { deep: true },
500
- );
501
-
502
- // First paint: defer until BOTH the canvas controller is mounted AND the initial
503
- // schema layout has settled (whichever happens last drives it), so the very
504
- // first render already carries saved table positions instead of flashing the
505
- // dagre fallback. Triggered from watch(canvas) and from loadSchema.
506
- function renderInitialGraph() {
507
- if (initialRenderDone || !canvas.value || !initialLayoutSettled) return;
508
- initialRenderDone = true;
509
- rebuildAndApply();
357
+ function keepLayout() {
358
+ if (!preview.value) return
359
+ const from = preview.value.from
360
+ preview.value = null
361
+ record({ kind: 'layout', from, to: clonePositions(state.positions) })
510
362
  }
511
- watch(canvas, renderInitialGraph);
512
363
 
513
- function onNodesInitialized() {
514
- if (initialFitDone) return;
515
- initialFitDone = true;
516
- fitView();
364
+ function discardLayout() {
365
+ if (!preview.value) return
366
+ state.positions = preview.value.from
367
+ preview.value = null
368
+ render()
517
369
  }
518
370
 
519
- // Re-fit when the diagram view becomes visible again (vue-flow can't measure a
520
- // hidden container, so a toggle from List -> Diagram needs a nudge).
521
- watch(
522
- () => props.active,
523
- (active) => {
524
- if (active) nextTick(() => fitView());
525
- },
526
- );
371
+ // --- dragging ---
372
+ const dragStarts = new Map<string, { x: number; y: number }>()
527
373
 
528
- async function loadSchema(id: string) {
529
- const myToken = ++loadToken;
530
- edgeRouting.clear();
531
- try {
532
- const [pos, items] = await Promise.all([fetchLayout(id), fetchNotes(id)]);
533
- if (myToken !== loadToken) return;
534
- const loadedNotes: NoteDraft[] = items.map((n: DiagramNote) => ({
535
- id: n.id,
536
- schemaName: n.schemaName,
537
- text: n.text,
538
- x: n.x,
539
- y: n.y,
540
- width: n.width,
541
- height: n.height,
542
- color: n.color,
543
- isTemp: false,
544
- }));
545
- tablePositions.value = clonePositions(pos);
546
- notes.value = loadedNotes.map(cloneNote);
547
- baseline.value = {
548
- tablePositions: clonePositions(pos),
549
- notes: loadedNotes.map(cloneNote),
550
- };
551
- history.value = [];
552
- cursor.value = 0;
553
- rebuildKey.value++;
554
- initialLayoutSettled = true;
555
- renderInitialGraph();
556
- nextTick(() => fitView());
557
- } catch {
558
- if (myToken !== loadToken) return;
559
- tablePositions.value = {};
560
- notes.value = [];
561
- baseline.value = { tablePositions: {}, notes: [] };
562
- history.value = [];
563
- cursor.value = 0;
564
- rebuildKey.value++;
565
- initialLayoutSettled = true;
566
- renderInitialGraph();
567
- }
374
+ // With `select-nodes-on-drag` off, Vue Flow lets go of the selection when a
375
+ // node not selected is grabbed, so a note left selected does not follow a
376
+ // table, which Vue Flow cannot select.
377
+ function onNodeDragStart({ node, nodes }: NodeDragEvent) {
378
+ for (const dragged of nodes.length ? nodes : [node])
379
+ dragStarts.set(dragged.id, { ...dragged.position })
568
380
  }
569
381
 
570
- watch(
571
- effectiveSchemaId,
572
- (newId, oldId) => {
573
- if (newId === oldId) return;
574
- if (!newId) {
575
- tablePositions.value = {};
576
- notes.value = [];
577
- baseline.value = { tablePositions: {}, notes: [] };
578
- history.value = [];
579
- cursor.value = 0;
580
- rebuildKey.value++;
581
- initialLayoutSettled = true;
582
- renderInitialGraph();
583
- return;
584
- }
585
- loadSchema(newId);
586
- },
587
- { immediate: true },
588
- );
382
+ function tableMoved(node: DiagramNode, from: { x: number; y: number }) {
383
+ const tableName = (node.data as { tableName: string }).tableName
384
+ const to = { x: node.position.x, y: node.position.y }
385
+ state.positions[tableName] = to
386
+ if (preview.value) return
387
+ record({ kind: 'tableMove', tableName, from, to })
388
+ }
589
389
 
590
- function pushOp(op: Op) {
591
- if (cursor.value < history.value.length) {
592
- history.value.splice(cursor.value);
593
- }
594
- const now = Date.now();
595
- const head = history.value[cursor.value - 1];
596
- const inWindow = now - lastOpAt < SQUASH_WINDOW_MS;
597
- let squashed = false;
598
- if (head && inWindow) {
599
- if (
600
- op.kind === "tableMove" &&
601
- head.kind === "tableMove" &&
602
- head.tableName === op.tableName
603
- ) {
604
- head.to = op.to;
605
- squashed = true;
606
- } else if (
607
- op.kind === "noteMove" &&
608
- head.kind === "noteMove" &&
609
- head.noteId === op.noteId
610
- ) {
611
- head.to = op.to;
612
- squashed = true;
613
- } else if (
614
- op.kind === "noteText" &&
615
- head.kind === "noteText" &&
616
- head.noteId === op.noteId
617
- ) {
618
- head.to = op.to;
619
- squashed = true;
620
- }
621
- }
622
- if (!squashed) {
623
- history.value.push(op);
624
- cursor.value++;
625
- }
626
- lastOpAt = now;
390
+ function noteMoved(node: DiagramNode, from: { x: number; y: number }) {
391
+ const noteId = (node.data as { noteId: string }).noteId
392
+ const to = { x: node.position.x, y: node.position.y }
393
+ const note = state.notes.find((n) => n.id === noteId)
394
+ if (!note) return
395
+ Object.assign(note, to)
396
+ record({ kind: 'noteMove', noteId, from, to })
627
397
  }
628
398
 
629
- function applyForward(op: Op) {
630
- switch (op.kind) {
631
- case "tableMove":
632
- tablePositions.value[op.tableName] = { ...op.to };
633
- break;
634
- case "noteMove": {
635
- const n = notes.value.find((x) => x.id === op.noteId);
636
- if (n) {
637
- n.x = op.to.x;
638
- n.y = op.to.y;
639
- }
640
- break;
641
- }
642
- case "noteText": {
643
- const n = notes.value.find((x) => x.id === op.noteId);
644
- if (n) n.text = op.to;
645
- break;
646
- }
647
- case "noteCreate":
648
- if (!notes.value.find((n) => n.id === op.tempId)) {
649
- notes.value.push(cloneNote(op.snapshot));
650
- }
651
- break;
652
- case "noteDelete": {
653
- const idx = notes.value.findIndex((n) => n.id === op.snapshot.id);
654
- if (idx >= 0) notes.value.splice(idx, 1);
655
- break;
656
- }
657
- case "autoLayout":
658
- tablePositions.value = {};
659
- break;
660
- }
399
+ const DRAG_HANDLERS: Record<
400
+ string,
401
+ (node: DiagramNode, from: { x: number; y: number }) => void
402
+ > = {
403
+ [TABLE_NODE_TYPE]: tableMoved,
404
+ [NOTE_NODE_TYPE]: noteMoved,
661
405
  }
662
406
 
663
- function applyReverse(op: Op) {
664
- switch (op.kind) {
665
- case "tableMove":
666
- tablePositions.value[op.tableName] = { ...op.from };
667
- break;
668
- case "noteMove": {
669
- const n = notes.value.find((x) => x.id === op.noteId);
670
- if (n) {
671
- n.x = op.from.x;
672
- n.y = op.from.y;
673
- }
674
- break;
675
- }
676
- case "noteText": {
677
- const n = notes.value.find((x) => x.id === op.noteId);
678
- if (n) n.text = op.from;
679
- break;
680
- }
681
- case "noteCreate": {
682
- const idx = notes.value.findIndex((n) => n.id === op.tempId);
683
- if (idx >= 0) notes.value.splice(idx, 1);
684
- break;
685
- }
686
- case "noteDelete":
687
- notes.value.push(cloneNote(op.snapshot));
688
- break;
689
- case "autoLayout":
690
- tablePositions.value = clonePositions(op.from);
691
- break;
407
+ function onNodeDragStop({ node, nodes }: NodeDragEvent) {
408
+ for (const dragged of nodes.length ? nodes : [node]) {
409
+ const from = dragStarts.get(dragged.id)
410
+ dragStarts.delete(dragged.id)
411
+ const { x, y } = dragged.position
412
+ if (!from || (from.x === x && from.y === y)) continue
413
+ DRAG_HANDLERS[dragged.type ?? '']?.(dragged as unknown as DiagramNode, from)
692
414
  }
415
+ // The node put down becomes the selection: a table through
416
+ // `data.selected` (D-9), a note through Vue Flow. Set once the drag is
417
+ // over, as setting the nodes during it would put the dragged one back.
418
+ selectedId.value = node.type === TABLE_NODE_TYPE ? node.id : null
419
+ if (node.type === NOTE_NODE_TYPE && !node.selected) selectNode(node.id)
420
+ render()
693
421
  }
694
422
 
695
- function undo() {
696
- if (cursor.value === 0) return;
697
- cursor.value--;
698
- const op = history.value[cursor.value];
699
- if (!op) return;
700
- applyReverse(op);
701
- rebuildKey.value++;
423
+ // --- selection and inspection ---
424
+ function onNodeClick({ node }: NodeMouseEvent) {
425
+ if (node.type === STUB_NODE_TYPE) return
426
+ selectedId.value = node.type === TABLE_NODE_TYPE ? node.id : null
702
427
  }
703
428
 
704
- function redo() {
705
- if (cursor.value >= history.value.length) return;
706
- const op = history.value[cursor.value];
707
- if (!op) return;
708
- applyForward(op);
709
- cursor.value++;
710
- rebuildKey.value++;
429
+ function onPaneClick() {
430
+ selectedId.value = null
711
431
  }
712
432
 
713
- function cancelChanges() {
714
- tablePositions.value = clonePositions(baseline.value.tablePositions);
715
- notes.value = baseline.value.notes.map(cloneNote);
716
- history.value = [];
717
- cursor.value = 0;
718
- rebuildKey.value++;
433
+ function inspect(tableName: string) {
434
+ if (!schemaId.value) return
435
+ openDrawer({
436
+ title: t('dms_database.schemas.inspector.title'),
437
+ direction: 'right',
438
+ component: TableInspector,
439
+ componentOptions: {
440
+ rowData: { schema: schemaId.value, name: tableName },
441
+ },
442
+ })
719
443
  }
720
444
 
721
- async function applyChanges() {
722
- const id = effectiveSchemaId.value;
723
- if (!id || applying.value || !isDirty.value) return;
724
- applying.value = true;
725
- try {
726
- await saveLayout(id, clonePositions(tablePositions.value));
727
-
728
- const baselineNotes = baseline.value.notes;
729
- const baselineById = new Map(baselineNotes.map((n) => [n.id, n]));
730
- const currentReal = notes.value.filter((n) => !n.isTemp);
731
- const currentTemp = notes.value.filter((n) => n.isTemp);
732
- const currentIds = new Set(currentReal.map((n) => n.id));
445
+ function onNodeDoubleClick({ node }: NodeMouseEvent) {
446
+ if (node.type !== TABLE_NODE_TYPE) return
447
+ inspect((node.data as { tableName: string }).tableName)
448
+ }
733
449
 
734
- for (const b of baselineNotes) {
735
- if (!currentIds.has(b.id)) await deleteNote(b.id);
736
- }
737
- for (const c of currentReal) {
738
- const b = baselineById.get(c.id);
739
- if (!b) continue;
740
- if (
741
- b.x !== c.x ||
742
- b.y !== c.y ||
743
- b.text !== c.text ||
744
- b.width !== c.width ||
745
- b.height !== c.height ||
746
- b.color !== c.color
747
- ) {
748
- await updateNote(c.id, {
749
- x: c.x,
750
- y: c.y,
751
- text: c.text,
752
- width: c.width,
753
- height: c.height,
754
- color: c.color ?? undefined,
755
- });
756
- }
757
- }
758
- for (const tempNote of currentTemp) {
759
- const created = await createNote({
760
- schemaName: id,
761
- text: tempNote.text,
762
- x: tempNote.x,
763
- y: tempNote.y,
764
- width: tempNote.width,
765
- height: tempNote.height,
766
- color: tempNote.color ?? undefined,
767
- });
768
- const idx = notes.value.findIndex((n) => n.id === tempNote.id);
769
- if (idx >= 0) {
770
- notes.value[idx] = {
771
- id: created.id,
772
- schemaName: created.schemaName,
773
- text: created.text,
774
- x: created.x,
775
- y: created.y,
776
- width: created.width,
777
- height: created.height,
778
- color: created.color,
779
- isTemp: false,
780
- };
781
- }
782
- }
450
+ function centerOn(nodeId: string): boolean {
451
+ const node = canvas.value?.findNode(nodeId)
452
+ if (!node) return false
453
+ selectedId.value = nodeId
454
+ canvas.value?.setCenter(
455
+ node.position.x + NODE_WIDTH / 2,
456
+ node.position.y + 120,
457
+ {
458
+ zoom: Math.max(viewport.value.zoom, RESET_ZOOM),
459
+ duration: 300,
460
+ },
461
+ )
462
+ return true
463
+ }
783
464
 
784
- baseline.value = {
785
- tablePositions: clonePositions(tablePositions.value),
786
- notes: notes.value.map(cloneNote),
787
- };
788
- history.value = [];
789
- cursor.value = 0;
790
- rebuildKey.value++;
791
- } catch (err) {
792
- console.error("Failed to apply diagram changes", err);
793
- } finally {
794
- applying.value = false;
465
+ // The table the URL names; one that does not exist is reported and dropped
466
+ // from the URL (D-12).
467
+ function centerOnQueryTable(): boolean {
468
+ const table = route.query.table
469
+ const asked = route.query.schema
470
+ if (typeof table !== 'string' || !schema.value) return false
471
+ if (typeof asked === 'string' && asked !== schema.value.id) return false
472
+ if (!schema.value.tables.some((entry) => entry.name === table)) {
473
+ warn(
474
+ t('dms_database.diagram.not_found.table', {
475
+ schema: schema.value.id,
476
+ table,
477
+ }),
478
+ )
479
+ router.replace({ query: { ...route.query, table: undefined } })
480
+ return false
795
481
  }
482
+ return centerOn(tableNodeId(schema.value.id, table))
796
483
  }
797
484
 
798
- // Imported directly, not resolved by name: the DMS registers its global
799
- // components as async components, and the async relation edge was mounted
800
- // with the HTML namespace — its <path> was created as an HTML element inside
801
- // the edges <svg>, so no relation line was drawn.
802
- const nodeTypes = markRaw({
803
- [TABLE_NODE_TYPE]: DiagramTableNode,
804
- [STUB_NODE_TYPE]: DiagramStubNode,
805
- [NOTE_NODE_TYPE]: DiagramNoteNode,
806
- });
807
-
808
- const edgeTypes = markRaw({
809
- [RELATION_EDGE_TYPE]: DiagramRelationEdge,
810
- });
485
+ const selectedTable = computed(() => selectedId.value?.split('::')[1] ?? null)
811
486
 
812
- const zoomPercent = computed(
813
- () => `${Math.round(liveViewport.value.zoom * 100)}%`,
814
- );
815
-
816
- function widthForNodeType(type: string | undefined): number {
817
- if (type === STUB_NODE_TYPE) return STUB_NODE_WIDTH;
818
- return NODE_WIDTH;
487
+ function toggleFocus() {
488
+ focusId.value = focusId.value ? null : selectedId.value
489
+ render()
490
+ nextTick(fitAll)
819
491
  }
820
492
 
821
- function recomputeEdgesForNode(nodeId: string) {
822
- const instance = canvas.value;
823
- if (!instance) return;
824
- for (const edge of instance.edges) {
825
- if (edge.source !== nodeId && edge.target !== nodeId) continue;
826
- const sNode = instance.findNode(edge.source);
827
- const tNode = instance.findNode(edge.target);
828
- if (!sNode || !tNode) continue;
829
- const sW = widthForNodeType(sNode.type);
830
- const tW = widthForNodeType(tNode.type);
831
- const dx = tNode.position.x + tW / 2 - (sNode.position.x + sW / 2);
832
- const prev = edgeRouting.get(edge.id);
833
- const next = pickSides(dx, NODE_WIDTH, prev);
834
- if (prev && prev.source === next.source && prev.target === next.target)
835
- continue;
836
- edgeRouting.set(edge.id, next);
837
- const data = edge.data as
838
- | { sourceBase: string; targetBase: string; isStubTarget: boolean }
839
- | undefined;
840
- if (!data) continue;
841
- edge.sourceHandle = `${data.sourceBase}::${next.source}::source`;
842
- edge.targetHandle = `${data.targetBase}::${next.target}::target`;
843
- }
493
+ // --- search: a table or a column; Enter goes from match to match (D-13) ---
494
+ const searchMatches = computed(() =>
495
+ schema.value ? searchTables(schema.value, search.value) : [],
496
+ )
497
+ const searchMissed = computed(
498
+ () => search.value.trim() !== '' && searchMatches.value.length === 0,
499
+ )
500
+ const searchPosition = computed(() =>
501
+ searchIndex.value >= 0 && searchMatches.value.length > 1
502
+ ? `${searchIndex.value + 1}/${searchMatches.value.length}`
503
+ : null,
504
+ )
505
+
506
+ // Synchronous: an Enter right after the text changed starts from the first
507
+ // match of the new text.
508
+ watch(
509
+ search,
510
+ () => {
511
+ searchIndex.value = -1
512
+ },
513
+ { flush: 'sync' },
514
+ )
515
+
516
+ function runSearch() {
517
+ const matches = searchMatches.value
518
+ if (!matches.length || !schemaId.value) return
519
+ searchIndex.value = (searchIndex.value + 1) % matches.length
520
+ const id = tableNodeId(schemaId.value, matches[searchIndex.value] ?? '')
521
+ if (focusIds.value && !focusIds.value.has(id)) focusId.value = null
522
+ nextTick(() => centerOn(id))
844
523
  }
845
524
 
846
- const dragStartPositions = new Map<string, { x: number; y: number }>();
525
+ function leaveSearch() {
526
+ searchInput.value?.inputRef?.blur()
527
+ }
847
528
 
848
- function onNodeDragStart({ node }: NodeDragEvent) {
849
- if (node.type !== TABLE_NODE_TYPE && node.type !== NOTE_NODE_TYPE) return;
850
- dragStartPositions.set(node.id, {
851
- x: node.position.x,
852
- y: node.position.y,
853
- });
529
+ // --- notes ---
530
+ // What a new note should not cover: the tables drawn, and every note. Sizes
531
+ // come from the schema and the notes, as a node just added is not measured.
532
+ function takenRects(): Rect[] {
533
+ const tables = (canvas.value?.getNodes ?? []).flatMap((node) => {
534
+ if (node.type !== TABLE_NODE_TYPE) return []
535
+ const { table } = node.data as TableNodeData
536
+ return [
537
+ {
538
+ x: node.position.x,
539
+ y: node.position.y,
540
+ width: NODE_WIDTH,
541
+ height: nodeHeight(table),
542
+ },
543
+ ]
544
+ })
545
+ return [...tables, ...state.notes]
854
546
  }
855
547
 
856
- function onNodeDrag({ node }: NodeDragEvent) {
857
- if (node.type !== TABLE_NODE_TYPE && node.type !== NOTE_NODE_TYPE) return;
858
- recomputeEdgesForNode(node.id);
548
+ function selectNode(nodeId: string) {
549
+ const flow = canvas.value
550
+ const node = flow?.findNode(nodeId)
551
+ if (!flow || !node) return
552
+ flow.removeSelectedNodes(flow.getSelectedNodes)
553
+ flow.addSelectedNodes([node])
859
554
  }
860
555
 
861
- function onNodeDragStop({ node }: NodeDragEvent) {
862
- const start = dragStartPositions.get(node.id);
863
- dragStartPositions.delete(node.id);
864
- if (!start) return;
865
- const newPos = { x: node.position.x, y: node.position.y };
866
- if (start.x === newPos.x && start.y === newPos.y) return;
867
- if (node.type === TABLE_NODE_TYPE) {
868
- const tableName = (node.data as { tableName?: string })?.tableName;
869
- if (!tableName) return;
870
- tablePositions.value[tableName] = newPos;
871
- pushOp({ kind: "tableMove", tableName, from: start, to: newPos });
872
- } else if (node.type === NOTE_NODE_TYPE) {
873
- const noteId = (node.data as { noteId?: string })?.noteId;
874
- if (!noteId) return;
875
- const local = notes.value.find((n) => n.id === noteId);
876
- if (!local) return;
877
- local.x = newPos.x;
878
- local.y = newPos.y;
879
- pushOp({ kind: "noteMove", noteId, from: start, to: newPos });
556
+ // A new note goes in the free space nearest the centre of the view, above
557
+ // the tables, selected and ready to write (D-8).
558
+ function addNote() {
559
+ if (!schemaId.value) return
560
+ const rect = canvasWrapper.value?.getBoundingClientRect()
561
+ const { x, y, zoom } = viewport.value
562
+ const centre = {
563
+ x: ((rect?.width ?? 800) / 2 - x) / zoom - NOTE_WIDTH / 2,
564
+ y: ((rect?.height ?? 600) / 2 - y) / zoom - NOTE_HEIGHT / 2,
565
+ }
566
+ const spot = freeSpot(
567
+ centre,
568
+ { width: NOTE_WIDTH, height: NOTE_HEIGHT },
569
+ takenRects(),
570
+ )
571
+ const note: NoteDraft = {
572
+ id: `temp-${Date.now()}`,
573
+ schemaName: schemaId.value,
574
+ text: '',
575
+ x: Math.round(spot.x),
576
+ y: Math.round(spot.y),
577
+ width: NOTE_WIDTH,
578
+ height: NOTE_HEIGHT,
579
+ color: null,
580
+ isTemp: true,
880
581
  }
582
+ state.notes.push(note)
583
+ record({ kind: 'noteCreate', snapshot: { ...note } })
584
+ editNoteId.value = note.id
585
+ selectedId.value = null
586
+ render()
587
+ nextTick(() => selectNode(noteNodeId(note.id)))
881
588
  }
882
589
 
883
- function onNodeDoubleClick({ node }: NodeMouseEvent) {
884
- // Only real table nodes open the inspector; stub/note nodes are ignored.
885
- if (node.type !== TABLE_NODE_TYPE) return;
886
- const tableName = (node.data as { tableName?: string })?.tableName;
887
- if (tableName) emit("inspect-table", tableName);
590
+ function changeNoteText(noteId: string, text: string) {
591
+ const note = state.notes.find((n) => n.id === noteId)
592
+ if (!note || note.text === text) return
593
+ record({ kind: 'noteText', noteId, from: note.text, to: text })
594
+ note.text = text
595
+ render()
888
596
  }
889
597
 
890
- function onNoteTextChange(noteId: string, newText: string) {
891
- const n = notes.value.find((x) => x.id === noteId);
892
- if (!n || n.text === newText) return;
893
- pushOp({ kind: "noteText", noteId, from: n.text, to: newText });
894
- n.text = newText;
895
- rebuildKey.value++;
598
+ function endNoteEdit(noteId: string) {
599
+ if (editNoteId.value !== noteId) return
600
+ editNoteId.value = null
601
+ render()
896
602
  }
897
603
 
898
- function onNoteDelete(noteId: string) {
899
- const idx = notes.value.findIndex((n) => n.id === noteId);
900
- if (idx < 0) return;
901
- const snapshot = cloneNote(notes.value[idx] as NoteDraft);
902
- notes.value.splice(idx, 1);
903
- pushOp({ kind: "noteDelete", snapshot });
904
- rebuildKey.value++;
604
+ function deleteNote(noteId: string) {
605
+ const note = state.notes.find((n) => n.id === noteId)
606
+ if (!note) return
607
+ state.notes = state.notes.filter((n) => n.id !== noteId)
608
+ if (editNoteId.value === noteId) editNoteId.value = null
609
+ record({ kind: 'noteDelete', snapshot: { ...note } })
610
+ render()
905
611
  }
906
612
 
907
- function addNote() {
908
- const id = effectiveSchemaId.value;
909
- if (!id) return;
910
- // Drop the note at the centre of the *visible* canvas. The pane centre is a
911
- // screen-space point; convert it to flow coordinates with the live viewport
912
- // (offset + zoom) so the note lands on-screen no matter how the user has
913
- // panned or zoomed — the old code read a stale {0,0,1} viewport and could
914
- // place notes far off-screen. Pane size comes from our own wrapper element
915
- // (the controller's `dimensions` ref isn't reliably populated here).
916
- const rect = canvasWrapper.value?.getBoundingClientRect();
917
- const vp = liveViewport.value;
918
- const paneW = rect?.width || NODE_WIDTH * 3;
919
- const paneH = rect?.height || NODE_HEIGHT_HEADER * 8;
920
- const x = (paneW / 2 - vp.x) / vp.zoom - NOTE_DEFAULT_WIDTH / 2;
921
- const y = (paneH / 2 - vp.y) / vp.zoom - NOTE_DEFAULT_HEIGHT / 2;
922
- tempCounter += 1;
923
- const tempId = `temp-${Date.now()}-${tempCounter}`;
924
- const draft: NoteDraft = {
925
- id: tempId,
926
- schemaName: id,
927
- text: "",
928
- x,
929
- y,
930
- width: NOTE_DEFAULT_WIDTH,
931
- height: NOTE_DEFAULT_HEIGHT,
932
- color: null,
933
- isTemp: true,
934
- };
935
- notes.value.push(draft);
936
- pushOp({ kind: "noteCreate", tempId, snapshot: cloneNote(draft) });
937
- rebuildKey.value++;
613
+ // Delete or Backspace on the canvas: only notes go, with undo (D-3).
614
+ function onDeleteNodes(ids: string[]) {
615
+ const doomed = new Set(ids)
616
+ for (const note of [...state.notes]) {
617
+ if (doomed.has(noteNodeId(note.id))) deleteNote(note.id)
618
+ }
938
619
  }
939
620
 
940
- function runAutoLayout() {
941
- if (Object.keys(tablePositions.value).length === 0) {
942
- rebuildKey.value++;
943
- nextTick(() => fitView());
944
- return;
945
- }
946
- const from = clonePositions(tablePositions.value);
947
- tablePositions.value = {};
948
- pushOp({ kind: "autoLayout", from });
949
- rebuildKey.value++;
950
- nextTick(() => fitView());
621
+ // --- viewport ---
622
+ const zoomPercent = computed(() => `${Math.round(viewport.value.zoom * 100)}%`)
623
+
624
+ function onViewportChange(next: { x: number; y: number; zoom: number }) {
625
+ viewport.value = { ...next }
951
626
  }
952
627
 
953
- function resetZoom() {
954
- canvas.value?.zoomTo(RESET_ZOOM);
628
+ // --- keyboard: the shortcuts the toolbar's tooltips name ---
629
+ // Escape steps back one level: the auto layout preview, then the focus,
630
+ // then the selection (D-14).
631
+ function escape(): boolean {
632
+ if (preview.value) {
633
+ discardLayout()
634
+ return true
635
+ }
636
+ if (focusId.value) {
637
+ toggleFocus()
638
+ return true
639
+ }
640
+ const selectedNotes = canvas.value?.getSelectedNodes ?? []
641
+ if (!selectedId.value && !selectedNotes.length) return false
642
+ selectedId.value = null
643
+ canvas.value?.removeSelectedNodes(selectedNotes)
644
+ return true
955
645
  }
956
646
 
957
- function confirmLeave(): boolean {
958
- return window.confirm(t("dms_database.diagram.unsavedConfirm"));
647
+ function always(run: () => unknown): () => boolean {
648
+ return () => {
649
+ run()
650
+ return true
651
+ }
959
652
  }
960
653
 
961
- function onBeforeUnload(e: BeforeUnloadEvent) {
962
- if (!isDirty.value) return;
963
- e.preventDefault();
964
- e.returnValue = "";
654
+ const COMMANDS: Record<DiagramCommand, () => boolean> = {
655
+ search: always(() => searchInput.value?.inputRef?.focus()),
656
+ note: always(() => !isEmpty.value && addNote()),
657
+ select: always(() => (panMode.value = false)),
658
+ pan: always(() => (panMode.value = true)),
659
+ fit: always(fitAll),
660
+ resetZoom: always(() => canvas.value?.zoomTo(RESET_ZOOM)),
661
+ undo: always(undo),
662
+ redo: always(redo),
663
+ escape,
965
664
  }
966
665
 
967
- onMounted(() => {
968
- window.addEventListener("beforeunload", onBeforeUnload);
969
- });
970
-
971
- onBeforeUnmount(() => {
972
- window.removeEventListener("beforeunload", onBeforeUnload);
973
- });
974
-
975
- const { registerGuard } = usePageLeaveGuard();
976
- registerGuard(() => {
977
- if (!isDirty.value) return true;
978
- return confirmLeave();
979
- });
666
+ // Keys reach the canvas only from the canvas or the page itself: not while
667
+ // typing, and not while a dialog such as the inspector is open (D-6).
668
+ useEventListener('keydown', (event: KeyboardEvent) => {
669
+ const command = diagramCommand(event)
670
+ if (!command || event.defaultPrevented) return
671
+ if (!canvasOwnsKey(event.target, canvasWrapper.value)) return
672
+ if (COMMANDS[command]()) event.preventDefault()
673
+ })
674
+
675
+ // Imported, not resolved by name: the edge must mount in the SVG namespace,
676
+ // which an async global component does not.
677
+ const nodeTypes = markRaw({
678
+ [TABLE_NODE_TYPE]: DiagramTableNode,
679
+ [STUB_NODE_TYPE]: DiagramStubNode,
680
+ [NOTE_NODE_TYPE]: DiagramNoteNode,
681
+ })
682
+ const edgeTypes = markRaw({ [RELATION_EDGE_TYPE]: DiagramRelationEdge })
683
+
684
+ const isEmpty = computed(
685
+ () => loaded.value && (schema.value?.tables.length ?? 0) === 0,
686
+ )
687
+ const isLarge = computed(
688
+ () =>
689
+ !focusId.value && (schema.value?.tables.length ?? 0) > LARGE_SCHEMA_TABLES,
690
+ )
980
691
  </script>
981
692
 
982
693
  <template>
983
- <div
984
- ref="canvasWrapper"
985
- class="min-h-0 flex-1 overflow-hidden rounded-xl border border-default"
986
- :class="panMode ? 'diagram-pan-mode' : 'diagram-select-mode'"
987
- >
988
- <DmsFlowCanvas
989
- ref="canvas"
990
- :node-types="nodeTypes"
991
- :edge-types="edgeTypes"
992
- :controls="false"
993
- :fit-view-on-init="false"
994
- :pan-on-drag="panMode"
995
- :nodes-draggable="!panMode"
996
- @nodes-initialized="onNodesInitialized"
997
- @viewport-change="onViewportChange"
998
- @node-drag-start="onNodeDragStart"
999
- @node-drag="onNodeDrag"
1000
- @node-drag-stop="onNodeDragStop"
1001
- @node-double-click="onNodeDoubleClick"
1002
- >
1003
- <template #top-right>
1004
- <div
1005
- class="flex items-center gap-1 rounded-lg border border-default bg-elevated p-1 shadow-md"
694
+ <div class="flex min-h-0 flex-1 flex-col">
695
+ <DmsEmptyState
696
+ v-if="!schemasLoading && schemas.length === 0"
697
+ class="flex-1"
698
+ icon="i-ph-graph"
699
+ hatched
700
+ :title="t('dms_database.diagram.empty.no_schema')"
701
+ />
702
+ <DmsClientOnly v-else>
703
+ <div
704
+ ref="canvasWrapper"
705
+ class="border-default relative min-h-0 flex-1 overflow-hidden rounded-xl border"
706
+ :class="panMode ? 'diagram-pan-mode' : ''"
707
+ >
708
+ <!-- Notes carry their own delete button, named in the page's
709
+ language: the canvas's generic badge would only repeat it. -->
710
+ <DmsFlowCanvas
711
+ ref="canvas"
712
+ :node-types="nodeTypes"
713
+ :edge-types="edgeTypes"
714
+ :controls="false"
715
+ :fit-view-on-init="false"
716
+ :min-zoom="MIN_ZOOM"
717
+ minimap
718
+ deletable-nodes
719
+ :delete-badge="false"
720
+ :pan-on-drag="panMode"
721
+ :nodes-draggable="!panMode"
722
+ :select-nodes-on-drag="false"
723
+ @delete-nodes="onDeleteNodes"
724
+ @nodes-initialized="onNodesInitialized"
725
+ @viewport-change="onViewportChange"
726
+ @node-drag-start="onNodeDragStart"
727
+ @node-drag-stop="onNodeDragStop"
728
+ @node-click="onNodeClick"
729
+ @node-double-click="onNodeDoubleClick"
730
+ @pane-click="onPaneClick"
1006
731
  >
1007
- <UButton
1008
- size="sm"
1009
- variant="ghost"
1010
- color="neutral"
1011
- icon="i-ph-squares-four"
1012
- :label="$t('dms_database.diagram.toolbar.autoLayout')"
1013
- @click="runAutoLayout"
1014
- />
1015
- <UTooltip :text="$t('dms_database.diagram.toolbar.helpText')">
1016
- <UButton
1017
- size="sm"
1018
- variant="ghost"
1019
- color="neutral"
1020
- icon="i-ph-question"
1021
- :aria-label="$t('dms_database.diagram.toolbar.help')"
1022
- />
1023
- </UTooltip>
1024
- <div class="w-px h-5 bg-default mx-1" />
1025
- <UTooltip :text="$t('dms_database.diagram.toolbar.undo')">
1026
- <UButton
1027
- size="sm"
1028
- variant="ghost"
1029
- color="neutral"
1030
- icon="i-ph-arrow-counter-clockwise"
1031
- :aria-label="$t('dms_database.diagram.toolbar.undo')"
1032
- :disabled="!canUndo || applying"
1033
- @click="undo"
1034
- />
1035
- </UTooltip>
1036
- <UTooltip :text="$t('dms_database.diagram.toolbar.redo')">
1037
- <UButton
1038
- size="sm"
1039
- variant="ghost"
1040
- color="neutral"
1041
- icon="i-ph-arrow-clockwise"
1042
- :aria-label="$t('dms_database.diagram.toolbar.redo')"
1043
- :disabled="!canRedo || applying"
1044
- @click="redo"
1045
- />
1046
- </UTooltip>
1047
- <div class="w-px h-5 bg-default mx-1" />
1048
- <UButton
1049
- size="sm"
1050
- variant="ghost"
1051
- color="neutral"
1052
- :label="$t('dms_database.diagram.toolbar.cancel')"
1053
- :disabled="!isDirty || applying"
1054
- @click="cancelChanges"
1055
- />
1056
- <UButton
1057
- size="sm"
1058
- color="primary"
1059
- :label="$t('dms_database.diagram.toolbar.apply')"
1060
- :disabled="!isDirty"
1061
- :loading="applying"
1062
- @click="applyChanges"
1063
- />
1064
- </div>
1065
- </template>
732
+ <template #top-left>
733
+ <div class="flex flex-col items-start gap-1.5">
734
+ <div class="diagram-float">
735
+ <USelect
736
+ :model-value="schemaId ?? undefined"
737
+ :items="schemaItems"
738
+ icon="i-ph-stack"
739
+ variant="ghost"
740
+ size="sm"
741
+ class="min-w-36 font-mono"
742
+ :aria-label="t('dms_database.diagram.schema')"
743
+ @update:model-value="openSchema(String($event))"
744
+ />
745
+ <span class="diagram-float__sep" />
746
+ <UInput
747
+ ref="searchInput"
748
+ v-model="search"
749
+ icon="i-ph-magnifying-glass"
750
+ variant="ghost"
751
+ size="sm"
752
+ class="w-40 md:w-64"
753
+ :color="searchMissed ? 'error' : 'primary'"
754
+ :highlight="searchMissed"
755
+ :placeholder="t('dms_database.diagram.search')"
756
+ :aria-label="t('dms_database.diagram.search')"
757
+ :aria-invalid="searchMissed || undefined"
758
+ :aria-describedby="searchMissed ? SEARCH_HINT_ID : undefined"
759
+ @keydown.enter.prevent="runSearch"
760
+ @keydown.escape.prevent="leaveSearch"
761
+ >
762
+ <template #trailing>
763
+ <span
764
+ v-if="searchPosition"
765
+ class="text-dimmed font-mono text-[11px] tabular-nums"
766
+ >
767
+ {{ searchPosition }}
768
+ </span>
769
+ <UKbd value="F" size="sm" />
770
+ </template>
771
+ </UInput>
772
+ </div>
773
+ <p
774
+ v-if="searchMissed"
775
+ :id="SEARCH_HINT_ID"
776
+ role="status"
777
+ class="diagram-float text-error px-3 py-1 text-xs"
778
+ >
779
+ {{ t('dms_database.diagram.search_empty') }}
780
+ </p>
781
+ </div>
782
+ </template>
783
+
784
+ <template #top-right>
785
+ <div class="diagram-float">
786
+ <span
787
+ class="flex items-center gap-1.5 px-2 font-mono text-[11.5px]"
788
+ :class="saveState === 'error' ? 'text-error' : 'text-dimmed'"
789
+ :title="savedLabel"
790
+ role="status"
791
+ >
792
+ <UIcon
793
+ :name="
794
+ saveState === 'error'
795
+ ? 'i-ph-warning-circle'
796
+ : saveState === 'saving'
797
+ ? 'i-ph-circle-notch'
798
+ : 'i-ph-check-circle'
799
+ "
800
+ class="size-3.5"
801
+ :class="[
802
+ saveState === 'saving' ? 'animate-spin' : '',
803
+ saveState === 'error' ? '' : 'text-success',
804
+ ]"
805
+ />
806
+ <span class="sr-only md:not-sr-only">{{ savedLabel }}</span>
807
+ </span>
808
+ <UButton
809
+ v-if="saveState === 'error'"
810
+ size="xs"
811
+ color="error"
812
+ variant="soft"
813
+ :label="t('dms_database.common.retry')"
814
+ @click="saveNow"
815
+ />
816
+ <span class="diagram-float__sep" />
817
+ <UTooltip
818
+ :text="t('dms_database.diagram.toolbar.undo')"
819
+ :kbds="['meta', 'z']"
820
+ >
821
+ <UButton
822
+ size="sm"
823
+ color="neutral"
824
+ variant="ghost"
825
+ icon="i-ph-arrow-counter-clockwise"
826
+ :aria-label="t('dms_database.diagram.toolbar.undo')"
827
+ :disabled="!canUndo"
828
+ @click="undo"
829
+ />
830
+ </UTooltip>
831
+ <UTooltip
832
+ :text="t('dms_database.diagram.toolbar.redo')"
833
+ :kbds="['meta', 'shift', 'z']"
834
+ >
835
+ <UButton
836
+ size="sm"
837
+ color="neutral"
838
+ variant="ghost"
839
+ icon="i-ph-arrow-clockwise"
840
+ :aria-label="t('dms_database.diagram.toolbar.redo')"
841
+ :disabled="!canRedo"
842
+ @click="redo"
843
+ />
844
+ </UTooltip>
845
+ <span class="diagram-float__sep" />
846
+ <UTooltip
847
+ :text="t('dms_database.diagram.toolbar.auto_layout_hint')"
848
+ >
849
+ <UButton
850
+ size="sm"
851
+ color="neutral"
852
+ variant="ghost"
853
+ icon="i-ph-squares-four"
854
+ :aria-label="t('dms_database.diagram.toolbar.auto_layout')"
855
+ :disabled="Boolean(preview) || isEmpty"
856
+ @click="startAutoLayout"
857
+ >
858
+ <span class="hidden md:inline">
859
+ {{ t('dms_database.diagram.toolbar.auto_layout') }}
860
+ </span>
861
+ </UButton>
862
+ </UTooltip>
863
+ </div>
864
+ </template>
865
+
866
+ <template #top-center>
867
+ <div
868
+ v-if="preview"
869
+ class="diagram-float border-primary/40 mt-12 gap-2 py-1.5 pl-3 pr-1.5 text-sm"
870
+ >
871
+ <UIcon name="i-ph-squares-four" class="text-primary size-4" />
872
+ <span>{{ t('dms_database.diagram.preview.title') }}</span>
873
+ <span class="text-muted mr-1">
874
+ {{ t('dms_database.diagram.preview.moved', movedCount) }}
875
+ </span>
876
+ <UButton
877
+ size="sm"
878
+ color="neutral"
879
+ variant="ghost"
880
+ :label="t('dms_database.diagram.preview.discard')"
881
+ @click="discardLayout"
882
+ />
883
+ <UButton
884
+ size="sm"
885
+ icon="i-ph-check"
886
+ :label="t('dms_database.diagram.preview.keep')"
887
+ @click="keepLayout"
888
+ />
889
+ </div>
890
+ <div
891
+ v-else-if="selectedTable || focusId"
892
+ class="diagram-float mt-12 gap-1 py-1 pl-3 pr-1 text-sm"
893
+ >
894
+ <UIcon name="i-ph-table" class="text-primary size-4" />
895
+ <span class="font-mono text-[12.5px]">
896
+ {{ selectedTable ?? focusId?.split('::')[1] }}
897
+ </span>
898
+ <span class="diagram-float__sep" />
899
+ <UButton
900
+ v-if="selectedTable"
901
+ size="xs"
902
+ color="neutral"
903
+ variant="ghost"
904
+ icon="i-ph-sidebar-simple"
905
+ :label="t('dms_database.diagram.selection.inspect')"
906
+ @click="inspect(selectedTable)"
907
+ />
908
+ <UButton
909
+ size="xs"
910
+ color="neutral"
911
+ :variant="focusId ? 'soft' : 'ghost'"
912
+ icon="i-ph-crosshair"
913
+ :label="
914
+ focusId
915
+ ? t('dms_database.diagram.selection.show_all')
916
+ : t('dms_database.diagram.selection.focus')
917
+ "
918
+ @click="toggleFocus"
919
+ />
920
+ </div>
921
+ <div
922
+ v-else-if="isLarge"
923
+ class="diagram-float text-warning mt-12 gap-2 px-3 py-2 text-sm"
924
+ >
925
+ <UIcon name="i-ph-warning" class="size-4" />
926
+ {{
927
+ t('dms_database.diagram.large', {
928
+ count: schema?.tables.length ?? 0,
929
+ })
930
+ }}
931
+ </div>
932
+ </template>
933
+
934
+ <template #bottom-left>
935
+ <div class="diagram-legend">
936
+ <span>
937
+ <UIcon name="i-ph-key" class="text-warning" />
938
+ {{ t('dms_database.diagram.legend.key') }}
939
+ </span>
940
+ <span>
941
+ <UIcon name="i-ph-arrow-right" class="text-primary" />
942
+ {{ t('dms_database.diagram.legend.relation') }}
943
+ </span>
944
+ <span>
945
+ <UIcon name="i-ph-brackets-curly" />
946
+ {{ t('dms_database.diagram.legend.object') }}
947
+ </span>
948
+ <span>
949
+ <UIcon name="i-ph-lock-key" />
950
+ {{ t('dms_database.modifiers.encrypted') }}
951
+ </span>
952
+ </div>
953
+ </template>
954
+
955
+ <template #bottom-center>
956
+ <div class="diagram-float">
957
+ <UTooltip
958
+ :text="t('dms_database.diagram.toolbar.select')"
959
+ :kbds="['v']"
960
+ >
961
+ <UButton
962
+ size="sm"
963
+ icon="i-ph-cursor"
964
+ :color="panMode ? 'neutral' : 'primary'"
965
+ :variant="panMode ? 'ghost' : 'soft'"
966
+ :aria-pressed="!panMode"
967
+ :aria-label="t('dms_database.diagram.toolbar.select')"
968
+ @click="panMode = false"
969
+ />
970
+ </UTooltip>
971
+ <UTooltip
972
+ :text="t('dms_database.diagram.toolbar.pan')"
973
+ :kbds="['h']"
974
+ >
975
+ <UButton
976
+ size="sm"
977
+ icon="i-ph-hand"
978
+ :color="panMode ? 'primary' : 'neutral'"
979
+ :variant="panMode ? 'soft' : 'ghost'"
980
+ :aria-pressed="panMode"
981
+ :aria-label="t('dms_database.diagram.toolbar.pan')"
982
+ @click="panMode = true"
983
+ />
984
+ </UTooltip>
985
+ <span class="diagram-float__sep" />
986
+ <UTooltip :text="t('dms_database.diagram.toolbar.zoom_out')">
987
+ <UButton
988
+ size="sm"
989
+ color="neutral"
990
+ variant="ghost"
991
+ icon="i-ph-magnifying-glass-minus"
992
+ :aria-label="t('dms_database.diagram.toolbar.zoom_out')"
993
+ @click="canvas?.zoomOut()"
994
+ />
995
+ </UTooltip>
996
+ <UTooltip
997
+ :text="t('dms_database.diagram.toolbar.reset_zoom')"
998
+ :kbds="['0']"
999
+ >
1000
+ <button
1001
+ type="button"
1002
+ class="text-muted hover:text-highlighted hover:bg-elevated min-w-12 rounded-md px-2 font-mono text-[11.5px] tabular-nums"
1003
+ :aria-label="t('dms_database.diagram.toolbar.reset_zoom')"
1004
+ @click="canvas?.zoomTo(RESET_ZOOM)"
1005
+ >
1006
+ {{ zoomPercent }}
1007
+ </button>
1008
+ </UTooltip>
1009
+ <UTooltip :text="t('dms_database.diagram.toolbar.zoom_in')">
1010
+ <UButton
1011
+ size="sm"
1012
+ color="neutral"
1013
+ variant="ghost"
1014
+ icon="i-ph-magnifying-glass-plus"
1015
+ :aria-label="t('dms_database.diagram.toolbar.zoom_in')"
1016
+ @click="canvas?.zoomIn()"
1017
+ />
1018
+ </UTooltip>
1019
+ <UTooltip
1020
+ :text="t('dms_database.diagram.toolbar.fit')"
1021
+ :kbds="['1']"
1022
+ >
1023
+ <UButton
1024
+ size="sm"
1025
+ color="neutral"
1026
+ variant="ghost"
1027
+ icon="i-ph-arrows-out"
1028
+ :aria-label="t('dms_database.diagram.toolbar.fit')"
1029
+ @click="fitAll"
1030
+ />
1031
+ </UTooltip>
1032
+ <span class="diagram-float__sep" />
1033
+ <UTooltip
1034
+ :text="t('dms_database.diagram.toolbar.note_hint')"
1035
+ :kbds="['n']"
1036
+ >
1037
+ <UButton
1038
+ size="sm"
1039
+ color="neutral"
1040
+ variant="ghost"
1041
+ icon="i-ph-note-pencil"
1042
+ :label="t('dms_database.diagram.toolbar.note')"
1043
+ :disabled="isEmpty"
1044
+ @click="addNote"
1045
+ />
1046
+ </UTooltip>
1047
+ </div>
1048
+ </template>
1049
+ </DmsFlowCanvas>
1066
1050
 
1067
- <template #bottom-center>
1068
1051
  <div
1069
- class="flex items-center gap-1 rounded-lg border border-default bg-elevated p-1 shadow-md"
1052
+ v-if="isEmpty"
1053
+ class="pointer-events-none absolute inset-0 grid place-items-center"
1070
1054
  >
1071
- <UTooltip :text="$t('dms_database.diagram.toolbar.pointer')">
1072
- <UButton
1073
- size="sm"
1074
- :color="panMode ? 'neutral' : 'primary'"
1075
- :variant="panMode ? 'ghost' : 'solid'"
1076
- icon="i-ph-cursor"
1077
- :aria-pressed="!panMode"
1078
- :aria-label="$t('dms_database.diagram.toolbar.pointer')"
1079
- @click="panMode = false"
1080
- />
1081
- </UTooltip>
1082
- <UTooltip :text="$t('dms_database.diagram.toolbar.hand')">
1083
- <UButton
1084
- size="sm"
1085
- :color="panMode ? 'primary' : 'neutral'"
1086
- :variant="panMode ? 'solid' : 'ghost'"
1087
- icon="i-ph-hand"
1088
- :aria-pressed="panMode"
1089
- :aria-label="$t('dms_database.diagram.toolbar.hand')"
1090
- @click="panMode = true"
1091
- />
1092
- </UTooltip>
1093
- <div class="w-px h-5 bg-default mx-1" />
1094
- <UTooltip :text="$t('dms_database.diagram.toolbar.zoomOut')">
1095
- <UButton
1096
- size="sm"
1097
- variant="ghost"
1098
- color="neutral"
1099
- icon="i-ph-magnifying-glass-minus"
1100
- :aria-label="$t('dms_database.diagram.toolbar.zoomOut')"
1101
- @click="zoomOut()"
1102
- />
1103
- </UTooltip>
1104
- <UTooltip :text="$t('dms_database.diagram.toolbar.resetZoom')">
1105
- <button
1106
- type="button"
1107
- class="px-2 text-sm text-muted tabular-nums hover:text-highlighted transition-colors"
1108
- :aria-label="$t('dms_database.diagram.toolbar.resetZoom')"
1109
- @click="resetZoom"
1110
- >
1111
- {{ zoomPercent }}
1112
- </button>
1113
- </UTooltip>
1114
- <UTooltip :text="$t('dms_database.diagram.toolbar.zoomIn')">
1115
- <UButton
1116
- size="sm"
1117
- variant="ghost"
1118
- color="neutral"
1119
- icon="i-ph-magnifying-glass-plus"
1120
- :aria-label="$t('dms_database.diagram.toolbar.zoomIn')"
1121
- @click="zoomIn()"
1122
- />
1123
- </UTooltip>
1124
- <UTooltip :text="$t('dms_database.diagram.toolbar.fitView')">
1125
- <UButton
1126
- size="sm"
1127
- variant="ghost"
1128
- color="neutral"
1129
- icon="i-ph-arrows-out"
1130
- :aria-label="$t('dms_database.diagram.toolbar.fitView')"
1131
- @click="fitView()"
1132
- />
1133
- </UTooltip>
1134
- <div class="w-px h-5 bg-default mx-1" />
1135
- <UTooltip :text="$t('dms_database.diagram.toolbar.notes')">
1136
- <UButton
1137
- size="sm"
1138
- variant="ghost"
1139
- color="neutral"
1140
- icon="i-ph-text-t"
1141
- :aria-label="$t('dms_database.diagram.toolbar.notes')"
1142
- @click="addNote"
1143
- />
1144
- </UTooltip>
1055
+ <DmsEmptyState
1056
+ class="pointer-events-auto"
1057
+ icon="i-ph-graph"
1058
+ :title="t('dms_database.diagram.empty.title', { schema: schemaId })"
1059
+ :description="t('dms_database.diagram.empty.description')"
1060
+ />
1145
1061
  </div>
1146
- </template>
1147
- </DmsFlowCanvas>
1062
+ </div>
1063
+ </DmsClientOnly>
1148
1064
  </div>
1149
1065
  </template>
1150
1066
 
1151
1067
  <style scoped>
1152
- /* Pan tool (hand): grab the empty canvas to move the view. Cursor tool keeps the
1153
- default arrow so node drag / selection reads as the active interaction. The
1154
- pane sits inside DmsFlowCanvas, hence :deep. */
1155
1068
  .diagram-pan-mode :deep(.vue-flow__pane) {
1156
1069
  cursor: grab;
1157
1070
  }
1158
1071
  .diagram-pan-mode :deep(.vue-flow__pane.dragging) {
1159
1072
  cursor: grabbing;
1160
1073
  }
1074
+ :deep(.vue-flow__minimap) {
1075
+ border: 1px solid var(--ui-border-accented);
1076
+ border-radius: calc(var(--ui-radius) * 0.75);
1077
+ background: var(--ui-bg-elevated);
1078
+ overflow: hidden;
1079
+ }
1080
+ :deep(.vue-flow__minimap-node) {
1081
+ fill: var(--ui-border-accented);
1082
+ }
1083
+ /* As wide as its content: a centred panel only gets half the canvas, which
1084
+ would wrap a bar that fits. It wraps only when the screen is narrower. */
1085
+ .diagram-float {
1086
+ display: flex;
1087
+ flex-wrap: wrap;
1088
+ width: max-content;
1089
+ max-width: calc(100vw - 4rem);
1090
+ align-items: center;
1091
+ gap: 2px;
1092
+ padding: 4px;
1093
+ border: 1px solid var(--ui-border-accented);
1094
+ border-radius: var(--ui-radius);
1095
+ background: var(--ui-bg-elevated);
1096
+ box-shadow: 0 8px 24px rgb(0 0 0 / 0.18);
1097
+ }
1098
+ .diagram-float__sep {
1099
+ width: 1px;
1100
+ height: 18px;
1101
+ margin: 0 4px;
1102
+ background: var(--ui-border);
1103
+ }
1104
+ .diagram-legend {
1105
+ display: flex;
1106
+ gap: 12px;
1107
+ padding: 6px 10px;
1108
+ border: 1px solid var(--ui-border);
1109
+ border-radius: calc(var(--ui-radius) * 0.75);
1110
+ background: color-mix(in srgb, var(--ui-bg-elevated) 88%, transparent);
1111
+ font: 500 11px var(--font-mono, ui-monospace, monospace);
1112
+ color: var(--ui-text-muted);
1113
+ }
1114
+ .diagram-legend span {
1115
+ display: inline-flex;
1116
+ align-items: center;
1117
+ gap: 5px;
1118
+ }
1119
+ /* Below md the legend and the overview give their room to the canvas. */
1120
+ @media (max-width: 767.98px) {
1121
+ .diagram-legend,
1122
+ :deep(.vue-flow__minimap) {
1123
+ display: none;
1124
+ }
1125
+ }
1161
1126
  </style>