@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.
- package/README.md +30 -5
- package/dist/db/models/query_history.model.d.ts +1 -0
- package/dist/db/models/query_history.model.js +6 -0
- package/dist/db/models/query_history.model.js.map +1 -1
- package/dist/db/tables/query_history.table.d.ts +3 -0
- package/dist/db/tables/query_history.table.js +12 -0
- package/dist/db/tables/query_history.table.js.map +1 -1
- package/dist/db/tables/saved_queries.table.d.ts +1 -0
- package/dist/db/tables/saved_queries.table.js +4 -0
- package/dist/db/tables/saved_queries.table.js.map +1 -1
- package/dist/i18n/messages.d.ts +39 -0
- package/dist/i18n/messages.js +64 -0
- package/dist/i18n/messages.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -7
- package/dist/index.js.map +1 -1
- package/dist/module.d.ts +11 -0
- package/dist/module.js +55 -0
- package/dist/module.js.map +1 -0
- package/dist/pages/data.d.ts +1 -1
- package/dist/pages/data.js +9 -6
- package/dist/pages/data.js.map +1 -1
- package/dist/pages/diagram.d.ts +9 -0
- package/dist/pages/diagram.js +45 -0
- package/dist/pages/diagram.js.map +1 -0
- package/dist/pages/index.d.ts +1 -0
- package/dist/pages/index.js +1 -0
- package/dist/pages/index.js.map +1 -1
- package/dist/pages/overview.d.ts +4 -1
- package/dist/pages/overview.js +113 -6
- package/dist/pages/overview.js.map +1 -1
- package/dist/pages/query.d.ts +1 -1
- package/dist/pages/query.js +22 -6
- package/dist/pages/query.js.map +1 -1
- package/dist/pages/schemas.d.ts +2 -1
- package/dist/pages/schemas.js +143 -6
- package/dist/pages/schemas.js.map +1 -1
- package/dist/routes/data.d.ts +8 -0
- package/dist/routes/data.js +84 -21
- package/dist/routes/data.js.map +1 -1
- package/dist/routes/index.d.ts +1 -0
- package/dist/routes/index.js +1 -0
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/overview.d.ts +12 -0
- package/dist/routes/overview.js +55 -0
- package/dist/routes/overview.js.map +1 -0
- package/dist/routes/query.d.ts +5 -1
- package/dist/routes/query.js +162 -27
- package/dist/routes/query.js.map +1 -1
- package/dist/routes/tables.d.ts +15 -12
- package/dist/routes/tables.js +95 -55
- package/dist/routes/tables.js.map +1 -1
- package/dist/service/catalog.d.ts +3 -0
- package/dist/service/catalog.js +26 -0
- package/dist/service/catalog.js.map +1 -0
- package/dist/service/health.d.ts +3 -3
- package/dist/service/health.js +17 -14
- package/dist/service/health.js.map +1 -1
- package/dist/service/introspect.d.ts +3 -1
- package/dist/service/introspect.js +131 -43
- package/dist/service/introspect.js.map +1 -1
- package/dist/service/overview.d.ts +9 -0
- package/dist/service/overview.js +113 -0
- package/dist/service/overview.js.map +1 -0
- package/dist/service/queryInspection.d.ts +15 -0
- package/dist/service/queryInspection.js +107 -0
- package/dist/service/queryInspection.js.map +1 -0
- package/dist/service/references.d.ts +9 -0
- package/dist/service/references.js +52 -0
- package/dist/service/references.js.map +1 -0
- package/dist/service/schemaFilters.d.ts +26 -0
- package/dist/service/schemaFilters.js +42 -0
- package/dist/service/schemaFilters.js.map +1 -0
- package/dist/service/stagedSerialization.d.ts +2 -0
- package/dist/service/stagedSerialization.js +38 -10
- package/dist/service/stagedSerialization.js.map +1 -1
- package/dist/service/types.d.ts +11 -1
- package/dist/types/constants.d.ts +2 -0
- package/dist/types/constants.js +3 -1
- package/dist/types/constants.js.map +1 -1
- package/dist/utils/query.d.ts +0 -2
- package/dist/utils/query.js +0 -20
- package/dist/utils/query.js.map +1 -1
- package/frontend-vue/app/{composables → build/composables}/useDataBrowserGrid.ts +65 -50
- package/frontend-vue/app/build/composables/useDataBrowserTabs.ts +366 -0
- package/frontend-vue/app/build/composables/useDatabaseHealth.ts +50 -0
- package/frontend-vue/app/build/composables/useDatabaseSchemas.ts +140 -0
- package/frontend-vue/app/{composables → build/composables}/useDiagramPersistence.ts +47 -39
- package/frontend-vue/app/build/composables/useQueryRuntime.ts +217 -0
- package/frontend-vue/app/build/composables/useQueryStore.ts +162 -0
- package/frontend-vue/app/build/data/cellValues.ts +132 -0
- package/frontend-vue/app/build/data/instanceOptions.ts +36 -0
- package/frontend-vue/app/build/data/pickerMenus.ts +175 -0
- package/frontend-vue/app/build/data/recentTables.ts +48 -0
- package/frontend-vue/app/build/data/stagedEdits.ts +117 -0
- package/frontend-vue/app/build/data/stagedWrites.ts +64 -0
- package/frontend-vue/app/build/data/useStagedSave.ts +298 -0
- package/frontend-vue/app/build/diagram/autosave.ts +72 -0
- package/frontend-vue/app/build/diagram/graph.ts +585 -0
- package/frontend-vue/app/build/diagram/history.ts +178 -0
- package/frontend-vue/app/build/diagram/shortcuts.ts +62 -0
- package/frontend-vue/app/build/diagram/sync.ts +137 -0
- package/frontend-vue/app/build/query/completion.ts +100 -0
- package/frontend-vue/app/build/query/csv.ts +32 -0
- package/frontend-vue/app/build/query/examples.ts +38 -0
- package/frontend-vue/app/build/query/runs.ts +168 -0
- package/frontend-vue/app/build/query/tableColumns.ts +35 -0
- package/frontend-vue/app/build/query/target.ts +143 -0
- package/frontend-vue/app/build/schemas/filterState.ts +128 -0
- package/frontend-vue/app/build/schemas/useSchemasListing.ts +62 -0
- package/frontend-vue/app/build/utils/databaseLinks.ts +98 -0
- package/frontend-vue/app/build/utils/fieldTypes.ts +198 -0
- package/frontend-vue/app/build/utils/relativeTime.ts +19 -0
- package/frontend-vue/app/{utils → build/utils}/stagedSerialization.ts +7 -1
- package/frontend-vue/app/components/ConnectionStatus.vue +122 -0
- package/frontend-vue/app/components/DataBrowser.vue +126 -0
- package/frontend-vue/app/components/DataBrowserSidebar.vue +293 -210
- package/frontend-vue/app/components/DataBrowserTabBar.vue +185 -93
- package/frontend-vue/app/components/DataFilterBar.vue +163 -0
- package/frontend-vue/app/components/DataGrid.vue +737 -499
- package/frontend-vue/app/components/DataGridCell.vue +125 -77
- package/frontend-vue/app/components/DataGridHeaderCell.vue +119 -81
- package/frontend-vue/app/components/DiagramNoteNode.vue +81 -63
- package/frontend-vue/app/components/DiagramRelationEdge.vue +57 -27
- package/frontend-vue/app/components/DiagramStubNode.vue +16 -17
- package/frontend-vue/app/components/DiagramTableNode.vue +106 -68
- package/frontend-vue/app/components/InstancePicker.vue +195 -0
- package/frontend-vue/app/components/JsonCellPanel.vue +30 -29
- package/frontend-vue/app/components/QueryBuilderPanel.vue +290 -238
- package/frontend-vue/app/components/QueryConsole.vue +726 -0
- package/frontend-vue/app/components/QueryLibrary.vue +314 -0
- package/frontend-vue/app/components/QueryResultPanel.vue +556 -126
- package/frontend-vue/app/components/RowDrawer.vue +520 -0
- package/frontend-vue/app/components/SchemaDiagram.vue +1016 -1051
- package/frontend-vue/app/components/SchemaPicker.vue +86 -0
- package/frontend-vue/app/components/SchemasFilterBar.vue +201 -0
- package/frontend-vue/app/components/TableInspector.vue +741 -0
- package/frontend-vue/app/config/shortcuts-registry.ts +65 -0
- package/frontend-vue/dms.frontend.ts +5 -14
- package/frontend-vue/i18n/locales/database-en-GB.json +526 -193
- package/frontend-vue/i18n/locales/database-fr-FR.json +528 -195
- package/frontend-vue/package.json +2 -1
- package/frontend-vue/pnpm-lock.yaml +2601 -858
- package/frontend-vue/pnpm-workspace.yaml +1 -1
- package/frontend-vue/tests/dataBrowser.test.ts +206 -0
- package/frontend-vue/tests/diagram.test.ts +516 -0
- package/frontend-vue/tests/helpers.test.ts +205 -0
- package/frontend-vue/tests/instanceOptions.test.ts +53 -0
- package/frontend-vue/tests/locales.test.ts +56 -0
- package/frontend-vue/tests/persistence.test.ts +106 -5
- package/frontend-vue/tests/pickerMenus.test.ts +254 -0
- package/frontend-vue/tests/queryConsole.test.ts +282 -0
- package/frontend-vue/tests/queryRuntime.test.ts +2 -2
- package/frontend-vue/tests/registration.test.ts +36 -25
- package/frontend-vue/tests/schemasFilters.test.ts +115 -0
- package/package.json +9 -6
- package/frontend-vue/app/components/QueryFavoritesPanel.vue +0 -127
- package/frontend-vue/app/components/QueryHistoryPanel.vue +0 -134
- package/frontend-vue/app/components/SchemaTableInspector.vue +0 -278
- package/frontend-vue/app/composables/useDataBrowserTabs.ts +0 -255
- package/frontend-vue/app/composables/useDatabaseHealth.ts +0 -50
- package/frontend-vue/app/composables/useDatabaseSchemas.ts +0 -92
- package/frontend-vue/app/composables/useQueryRuntime.ts +0 -118
- package/frontend-vue/app/composables/useQueryStore.ts +0 -117
- package/frontend-vue/app/composables/useTableViewRowClick.ts +0 -22
- package/frontend-vue/app/custom-pages/database/data.vue +0 -41
- package/frontend-vue/app/custom-pages/database/overview.vue +0 -367
- package/frontend-vue/app/custom-pages/database/query.vue +0 -436
- package/frontend-vue/app/custom-pages/database/schemas.vue +0 -218
- package/frontend-vue/app/utils/databaseFieldModifiers.ts +0 -33
- package/frontend-vue/app/utils/databaseFieldTypeIcons.ts +0 -224
- package/frontend-vue/app/utils/dmsTableView.ts +0 -83
- package/frontend-vue/app/utils/relativeTime.ts +0 -18
|
@@ -1,1161 +1,1126 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import {
|
|
3
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
:
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
74
|
-
if (
|
|
75
|
-
|
|
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
|
-
|
|
140
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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
|
-
|
|
228
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
|
272
|
-
|
|
238
|
+
function scheduleSave() {
|
|
239
|
+
autosave?.schedule()
|
|
273
240
|
}
|
|
274
241
|
|
|
275
|
-
function
|
|
276
|
-
|
|
242
|
+
function saveNow() {
|
|
243
|
+
autosave?.schedule()
|
|
244
|
+
void autosave?.flush()
|
|
277
245
|
}
|
|
278
246
|
|
|
279
|
-
function
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
286
|
-
return { ...n };
|
|
287
|
-
}
|
|
291
|
+
watch(schemaId, (id) => id && load(id), { immediate: true })
|
|
288
292
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
293
|
+
function record(op: DiagramOperation) {
|
|
294
|
+
history.push(op)
|
|
295
|
+
historyRevision.value++
|
|
296
|
+
scheduleSave()
|
|
292
297
|
}
|
|
293
298
|
|
|
294
|
-
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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
|
-
|
|
328
|
+
function redo() {
|
|
329
|
+
if (!canRedo.value || !history.redo(state)) return
|
|
330
|
+
historyRevision.value++
|
|
331
|
+
render()
|
|
332
|
+
scheduleSave()
|
|
481
333
|
}
|
|
482
334
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
|
514
|
-
if (
|
|
515
|
-
|
|
516
|
-
|
|
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
|
-
//
|
|
520
|
-
|
|
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
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
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
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
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
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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
|
|
705
|
-
|
|
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
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
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
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
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
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
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
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
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
|
-
|
|
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
|
-
|
|
813
|
-
|
|
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
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
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
|
-
|
|
525
|
+
function leaveSearch() {
|
|
526
|
+
searchInput.value?.inputRef?.blur()
|
|
527
|
+
}
|
|
847
528
|
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
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
|
|
857
|
-
|
|
858
|
-
|
|
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
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
if (!
|
|
865
|
-
const
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
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
|
|
884
|
-
|
|
885
|
-
if (
|
|
886
|
-
|
|
887
|
-
|
|
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
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
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
|
|
899
|
-
const
|
|
900
|
-
if (
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
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
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
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
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
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
|
-
|
|
954
|
-
|
|
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
|
|
958
|
-
return
|
|
647
|
+
function always(run: () => unknown): () => boolean {
|
|
648
|
+
return () => {
|
|
649
|
+
run()
|
|
650
|
+
return true
|
|
651
|
+
}
|
|
959
652
|
}
|
|
960
653
|
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
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
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
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
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
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
|
-
<
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
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
|
-
|
|
1052
|
+
v-if="isEmpty"
|
|
1053
|
+
class="pointer-events-none absolute inset-0 grid place-items-center"
|
|
1070
1054
|
>
|
|
1071
|
-
<
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
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
|
-
</
|
|
1147
|
-
</
|
|
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>
|