mirror of
https://github.com/h-sphere/sql-seal.git
synced 2026-07-22 10:10:28 +00:00
Interactive SQL Visualiser (#177)
* chore: reworking plugin internals into modules * feat: settings module is now working, added debug module, enabled new api module * chore: restoring external API * feat: refactoring project to use proper inversion of control * feat: plugins can now be loaded in any order * chore: final file migration, all typescript is in modules now * chore: fixing dependencies of cellParser * feat: csv and json views are only registered when they are not colliding with other plugins * feat: fixed library behaviour on canvas * feat: added ability to hide columns from csv files * feat: adding global tables support (wip) * feat: global tables full implementation * feat: sqlite databases can now be previewed in explorer * feat: improved explorer view * feat: highlighting code in the copy code modal * feat: advanced interactive schema visualiser
This commit is contained in:
parent
531d486d06
commit
403e9f3d0c
9 changed files with 2085 additions and 16 deletions
5
.changeset/every-years-lick.md
Normal file
5
.changeset/every-years-lick.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"sqlseal": minor
|
||||
---
|
||||
|
||||
added interactive visualiser for SQL schema of external databases
|
||||
|
|
@ -72,6 +72,7 @@
|
|||
"jsonpath": "^1.1.1",
|
||||
"lodash": "^4.17.21",
|
||||
"markdown-table-ts": "^1.0.3",
|
||||
"mermaid": "11.4.0",
|
||||
"ohm-js": "^17.1.0",
|
||||
"papaparse": "^5.5.3",
|
||||
"sql-parser-cst": "^0.33.1",
|
||||
|
|
|
|||
973
pnpm-lock.yaml
973
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
|
@ -47,9 +47,7 @@ export class Editor {
|
|||
this.createCodeblockProcessor(this.codeblockElement, this.query);
|
||||
|
||||
if (this.db) {
|
||||
// rendering structure
|
||||
const schema = this.db.getSchema()
|
||||
const vis = new SchemaVisualiser(schema)
|
||||
const vis = new SchemaVisualiser(this.db)
|
||||
vis.show(structure)
|
||||
} else {
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,73 @@ export class MemoryDatabase {
|
|||
return this.query<{ name: string, type: string }>('select name, type from pragma_table_info(@tableName)', { '@tableName': tableName })
|
||||
}
|
||||
|
||||
getDetailedTableInfo(tableName: string) {
|
||||
const result = this.query<{
|
||||
name: string,
|
||||
type: string,
|
||||
pk: number,
|
||||
dflt_value: any,
|
||||
notnull: number
|
||||
}>(`
|
||||
SELECT name, type, pk, dflt_value, [notnull]
|
||||
FROM pragma_table_info(@tableName)
|
||||
`, { '@tableName': tableName })
|
||||
|
||||
return result.data.map(row => ({
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
isPrimaryKey: row.pk === 1,
|
||||
defaultValue: row.dflt_value,
|
||||
notNull: row.notnull === 1
|
||||
}))
|
||||
}
|
||||
|
||||
getForeignKeys(tableName: string) {
|
||||
const result = this.query<{
|
||||
id: number,
|
||||
seq: number,
|
||||
table: string,
|
||||
from: string,
|
||||
to: string,
|
||||
on_update: string,
|
||||
on_delete: string,
|
||||
match: string
|
||||
}>(`
|
||||
SELECT id, seq, [table], [from], [to], on_update, on_delete, [match]
|
||||
FROM pragma_foreign_key_list(@tableName)
|
||||
`, { '@tableName': tableName })
|
||||
|
||||
return result.data.map(row => ({
|
||||
id: row.id,
|
||||
seq: row.seq,
|
||||
referencedTable: row.table,
|
||||
fromColumn: row.from,
|
||||
toColumn: row.to,
|
||||
onUpdate: row.on_update,
|
||||
onDelete: row.on_delete,
|
||||
match: row.match
|
||||
}))
|
||||
}
|
||||
|
||||
getAllTables() {
|
||||
const result = this.query<{ name: string }>(`
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table' AND name NOT LIKE 'sqlite_%'
|
||||
ORDER BY name
|
||||
`)
|
||||
|
||||
return result.data.map(row => row.name)
|
||||
}
|
||||
|
||||
getDetailedSchema() {
|
||||
const tables = this.getAllTables()
|
||||
return tables.map(tableName => ({
|
||||
name: tableName,
|
||||
columns: this.getDetailedTableInfo(tableName),
|
||||
foreignKeys: this.getForeignKeys(tableName)
|
||||
}))
|
||||
}
|
||||
|
||||
getSchema(): TableInfo[] {
|
||||
const tables = this.allTables().data
|
||||
return tables.map(t => {
|
||||
|
|
|
|||
|
|
@ -1,14 +1,684 @@
|
|||
import { TableInfo, TableVisualiser } from "./TableVisualiser"
|
||||
import mermaid from 'mermaid'
|
||||
import { MemoryDatabase } from '../database/memoryDatabase'
|
||||
|
||||
export interface DetailedColumnInfo {
|
||||
name: string
|
||||
type: string
|
||||
isPrimaryKey: boolean
|
||||
defaultValue: any
|
||||
notNull: boolean
|
||||
}
|
||||
|
||||
export interface ForeignKeyInfo {
|
||||
id: number
|
||||
seq: number
|
||||
referencedTable: string
|
||||
fromColumn: string
|
||||
toColumn: string
|
||||
onUpdate: string
|
||||
onDelete: string
|
||||
match: string
|
||||
}
|
||||
|
||||
export interface DetailedTableInfo {
|
||||
name: string
|
||||
columns: DetailedColumnInfo[]
|
||||
foreignKeys: ForeignKeyInfo[]
|
||||
}
|
||||
|
||||
export class SchemaVisualiser {
|
||||
constructor(private info: TableInfo[]) { }
|
||||
private initialized = false
|
||||
|
||||
constructor(private database: MemoryDatabase) {
|
||||
this.initializeMermaid()
|
||||
}
|
||||
|
||||
show(container: HTMLElement) {
|
||||
private initializeMermaid() {
|
||||
if (this.initialized) return
|
||||
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: 'default',
|
||||
securityLevel: 'loose',
|
||||
er: {
|
||||
diagramPadding: 20,
|
||||
layoutDirection: 'TB',
|
||||
minEntityWidth: 120,
|
||||
minEntityHeight: 80,
|
||||
entityPadding: 15,
|
||||
stroke: '#666',
|
||||
fill: '#f9f9f9',
|
||||
fontSize: 12,
|
||||
// Relationship line styling
|
||||
relationshipLabelColor: '#333',
|
||||
relationshipLabelBackground: '#fff',
|
||||
relationshipLabelBorder: '#666',
|
||||
// Make relationships more visible
|
||||
primaryColor: '#4a90e2',
|
||||
primaryTextColor: '#333',
|
||||
primaryBorderColor: '#666',
|
||||
lineColor: '#666',
|
||||
arrowheadColor: '#666',
|
||||
// Force full width usage
|
||||
useMaxWidth: true
|
||||
},
|
||||
flowchart: {
|
||||
useMaxWidth: true,
|
||||
htmlLabels: true
|
||||
}
|
||||
})
|
||||
|
||||
this.initialized = true
|
||||
}
|
||||
|
||||
async show(container: HTMLElement) {
|
||||
container.empty()
|
||||
|
||||
const c = container.createDiv({ cls: 'sqlseal-tv-container' })
|
||||
this.info
|
||||
.map(i => new TableVisualiser(i))
|
||||
.forEach(v => v.show(c))
|
||||
try {
|
||||
const schema = await this.buildSchema()
|
||||
const mermaidCode = this.generateMermaidERD(schema)
|
||||
|
||||
// Debug: Log the generated Mermaid code
|
||||
console.log('Generated Mermaid ERD Code:', mermaidCode)
|
||||
console.log('Schema tables detected:', schema.map(t => ({
|
||||
original: t.name,
|
||||
escaped: this.escapeMermaidIdentifier(t.name)
|
||||
})))
|
||||
|
||||
const diagramContainer = container.createDiv({
|
||||
cls: 'sqlseal-mermaid-container',
|
||||
attr: {
|
||||
id: `mermaid-${Date.now()}`
|
||||
}
|
||||
})
|
||||
|
||||
const { svg } = await mermaid.render(`diagram-${Date.now()}`, mermaidCode)
|
||||
diagramContainer.innerHTML = svg
|
||||
|
||||
// Ensure SVG takes full width
|
||||
const svgElement = diagramContainer.querySelector('svg')
|
||||
if (svgElement) {
|
||||
svgElement.setAttribute('width', '100%')
|
||||
svgElement.setAttribute('height', '100%')
|
||||
svgElement.style.width = '100%'
|
||||
svgElement.style.height = '100%'
|
||||
svgElement.style.maxWidth = 'none'
|
||||
// Set viewBox to preserve aspect ratio while filling container
|
||||
const viewBox = svgElement.getAttribute('viewBox')
|
||||
if (viewBox) {
|
||||
svgElement.setAttribute('preserveAspectRatio', 'xMidYMid meet')
|
||||
}
|
||||
}
|
||||
|
||||
// Add pan and zoom functionality
|
||||
this.addPanZoom(diagramContainer)
|
||||
|
||||
// Add interactive features
|
||||
this.addInteractivity(diagramContainer, schema)
|
||||
|
||||
// Remove relationship summary - user requested removal
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error rendering Mermaid schema:', error)
|
||||
console.error('Error details:', {
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
stack: error instanceof Error ? error.stack : undefined
|
||||
})
|
||||
|
||||
// Fallback to simple schema display
|
||||
this.showFallbackSchema(container, await this.buildSchema())
|
||||
}
|
||||
}
|
||||
|
||||
private async buildSchema(): Promise<DetailedTableInfo[]> {
|
||||
const schema = this.database.getDetailedSchema()
|
||||
return schema
|
||||
}
|
||||
|
||||
private generateMermaidERD(schema: DetailedTableInfo[]): string {
|
||||
let mermaidCode = 'erDiagram\n'
|
||||
|
||||
// Define entities with their attributes
|
||||
for (const table of schema) {
|
||||
const escapedTableName = this.escapeMermaidIdentifier(table.name)
|
||||
mermaidCode += ` ${escapedTableName} {\n`
|
||||
|
||||
for (const column of table.columns) {
|
||||
const typeAnnotation = this.getColumnTypeAnnotation(column)
|
||||
const keyAnnotation = this.getKeyAnnotation(column, table.foreignKeys)
|
||||
const escapedColumnName = this.escapeMermaidIdentifier(column.name)
|
||||
|
||||
mermaidCode += ` ${column.type} ${escapedColumnName}${keyAnnotation}\n`
|
||||
}
|
||||
|
||||
mermaidCode += ' }\n\n'
|
||||
}
|
||||
|
||||
// Define relationships based on foreign keys
|
||||
const relationships = new Set<string>()
|
||||
|
||||
for (const table of schema) {
|
||||
for (const fk of table.foreignKeys) {
|
||||
// Create a more descriptive relationship with column information
|
||||
// Escape column names in the relationship label as well
|
||||
const escapedFromColumn = fk.fromColumn.includes(' ') ? `"${fk.fromColumn}"` : fk.fromColumn
|
||||
const escapedToColumn = fk.toColumn.includes(' ') ? `"${fk.toColumn}"` : fk.toColumn
|
||||
const relationshipLabel = `"${escapedFromColumn} → ${escapedToColumn}"`
|
||||
|
||||
// Determine cardinality based on foreign key constraints
|
||||
const cardinality = this.determineCardinality(table, fk, schema)
|
||||
|
||||
// Escape table names for Mermaid
|
||||
const escapedSourceTable = this.escapeMermaidIdentifier(fk.referencedTable)
|
||||
const escapedTargetTable = this.escapeMermaidIdentifier(table.name)
|
||||
|
||||
const relationship = ` ${escapedSourceTable} ${cardinality} ${escapedTargetTable} : ${relationshipLabel}`
|
||||
relationships.add(relationship)
|
||||
}
|
||||
}
|
||||
|
||||
// Add relationships section with clear separation
|
||||
if (relationships.size > 0) {
|
||||
mermaidCode += '\n %% Relationships\n'
|
||||
mermaidCode += Array.from(relationships).join('\n')
|
||||
mermaidCode += '\n'
|
||||
}
|
||||
|
||||
return mermaidCode
|
||||
}
|
||||
|
||||
private escapeMermaidIdentifier(identifier: string): string {
|
||||
// Mermaid requires identifiers with spaces or special characters to be quoted
|
||||
// Also handle other problematic characters
|
||||
|
||||
// Check if identifier contains spaces, hyphens, or other special characters
|
||||
const needsQuoting = /[\s\-\.\(\)\[\]{}'"!@#$%^&*+=|\\:;,<>?/~`]/.test(identifier)
|
||||
|
||||
if (needsQuoting) {
|
||||
// Escape any existing quotes and wrap in quotes
|
||||
const escaped = identifier.replace(/"/g, '\\"')
|
||||
return `"${escaped}"`
|
||||
}
|
||||
|
||||
return identifier
|
||||
}
|
||||
|
||||
private determineCardinality(table: DetailedTableInfo, fk: ForeignKeyInfo, schema: DetailedTableInfo[]): string {
|
||||
// Check if the foreign key column is also a primary key (indicates 1:1 relationship)
|
||||
const fkColumn = table.columns.find(col => col.name === fk.fromColumn)
|
||||
const isOneToOne = fkColumn?.isPrimaryKey
|
||||
|
||||
// Check if there are unique constraints (would also indicate 1:1 or 1:0..1)
|
||||
// For now, we'll use simple heuristics:
|
||||
|
||||
if (isOneToOne) {
|
||||
// One-to-one relationship
|
||||
return '||--||'
|
||||
} else {
|
||||
// Default to one-to-many relationship (most common in normalized databases)
|
||||
// Parent (referenced table) to Child (referencing table)
|
||||
return '||--o{'
|
||||
}
|
||||
|
||||
// Future enhancements could include:
|
||||
// - Detecting many-to-many through junction tables: }o--o{
|
||||
// - Optional relationships with: ||--o|
|
||||
// - Zero-or-one relationships with: }o--||
|
||||
}
|
||||
|
||||
private getColumnTypeAnnotation(column: DetailedColumnInfo): string {
|
||||
const annotations = []
|
||||
if (column.notNull) annotations.push('NOT NULL')
|
||||
if (column.defaultValue !== null) annotations.push(`DEFAULT ${column.defaultValue}`)
|
||||
return annotations.length > 0 ? ` (${annotations.join(', ')})` : ''
|
||||
}
|
||||
|
||||
private getKeyAnnotation(column: DetailedColumnInfo, foreignKeys: ForeignKeyInfo[]): string {
|
||||
if (column.isPrimaryKey) return ' PK'
|
||||
|
||||
const fk = foreignKeys.find(fk => fk.fromColumn === column.name)
|
||||
if (fk) return ' FK'
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
private addPanZoom(container: HTMLElement) {
|
||||
const svg = container.querySelector('svg')
|
||||
if (!svg) return
|
||||
|
||||
// Create zoom and pan state with object references for proper binding
|
||||
const zoomRef = { value: 1 }
|
||||
const panRef = { x: 0, y: 0 }
|
||||
let isDragging = false
|
||||
let lastMousePos = { x: 0, y: 0 }
|
||||
|
||||
// Set up SVG for pan/zoom
|
||||
svg.style.cursor = 'grab'
|
||||
svg.style.overflow = 'hidden'
|
||||
|
||||
// Create a group element to contain all SVG content for transformations
|
||||
const existingContent = svg.innerHTML
|
||||
svg.innerHTML = `<g class="pan-zoom-group">${existingContent}</g>`
|
||||
const panZoomGroup = svg.querySelector('.pan-zoom-group') as SVGGElement
|
||||
|
||||
const updateTransform = () => {
|
||||
if (panZoomGroup) {
|
||||
panZoomGroup.setAttribute('transform',
|
||||
`translate(${panRef.x}, ${panRef.y}) scale(${zoomRef.value})`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Add zoom controls UI
|
||||
this.addZoomControls(container, zoomRef, panRef, updateTransform)
|
||||
|
||||
// Wheel zoom
|
||||
svg.addEventListener('wheel', (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
const rect = svg.getBoundingClientRect()
|
||||
const mouseX = e.clientX - rect.left
|
||||
const mouseY = e.clientY - rect.top
|
||||
|
||||
const delta = e.deltaY > 0 ? 0.9 : 1.1
|
||||
const oldZoom = zoomRef.value
|
||||
const newZoom = Math.max(0.1, Math.min(5, oldZoom * delta))
|
||||
|
||||
if (newZoom !== oldZoom) {
|
||||
// Calculate mouse position in the transformed coordinate space
|
||||
const mouseWorldX = (mouseX - panRef.x) / oldZoom
|
||||
const mouseWorldY = (mouseY - panRef.y) / oldZoom
|
||||
|
||||
// Update zoom
|
||||
zoomRef.value = newZoom
|
||||
|
||||
// Adjust pan to keep mouse position fixed in world space
|
||||
panRef.x = mouseX - mouseWorldX * newZoom
|
||||
panRef.y = mouseY - mouseWorldY * newZoom
|
||||
|
||||
updateTransform()
|
||||
this.updateZoomDisplay(container, zoomRef.value)
|
||||
}
|
||||
})
|
||||
|
||||
// Mouse drag panning
|
||||
svg.addEventListener('mousedown', (e) => {
|
||||
if (e.button === 0) { // Left mouse button
|
||||
isDragging = true
|
||||
lastMousePos = { x: e.clientX, y: e.clientY }
|
||||
svg.style.cursor = 'grabbing'
|
||||
e.preventDefault()
|
||||
}
|
||||
})
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!isDragging) return
|
||||
|
||||
const deltaX = e.clientX - lastMousePos.x
|
||||
const deltaY = e.clientY - lastMousePos.y
|
||||
|
||||
panRef.x += deltaX
|
||||
panRef.y += deltaY
|
||||
|
||||
lastMousePos = { x: e.clientX, y: e.clientY }
|
||||
updateTransform()
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (isDragging) {
|
||||
isDragging = false
|
||||
svg.style.cursor = 'grab'
|
||||
}
|
||||
}
|
||||
|
||||
// Attach global mouse events for smooth dragging
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
|
||||
// Touch support for mobile
|
||||
let lastTouchPos: { x: number, y: number } | null = null
|
||||
let initialTouchDistance: number | null = null
|
||||
let initialZoom = zoomRef.value
|
||||
|
||||
svg.addEventListener('touchstart', (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (e.touches.length === 1) {
|
||||
// Single touch - panning
|
||||
const touch = e.touches[0]
|
||||
lastTouchPos = { x: touch.clientX, y: touch.clientY }
|
||||
} else if (e.touches.length === 2) {
|
||||
// Two finger pinch - zooming
|
||||
const touch1 = e.touches[0]
|
||||
const touch2 = e.touches[1]
|
||||
initialTouchDistance = Math.hypot(
|
||||
touch2.clientX - touch1.clientX,
|
||||
touch2.clientY - touch1.clientY
|
||||
)
|
||||
initialZoom = zoomRef.value
|
||||
lastTouchPos = null
|
||||
}
|
||||
})
|
||||
|
||||
svg.addEventListener('touchmove', (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (e.touches.length === 1 && lastTouchPos) {
|
||||
// Single touch panning
|
||||
const touch = e.touches[0]
|
||||
const deltaX = touch.clientX - lastTouchPos.x
|
||||
const deltaY = touch.clientY - lastTouchPos.y
|
||||
|
||||
panRef.x += deltaX
|
||||
panRef.y += deltaY
|
||||
|
||||
lastTouchPos = { x: touch.clientX, y: touch.clientY }
|
||||
updateTransform()
|
||||
} else if (e.touches.length === 2 && initialTouchDistance) {
|
||||
// Two finger pinch zoom
|
||||
const touch1 = e.touches[0]
|
||||
const touch2 = e.touches[1]
|
||||
const currentDistance = Math.hypot(
|
||||
touch2.clientX - touch1.clientX,
|
||||
touch2.clientY - touch1.clientY
|
||||
)
|
||||
|
||||
const scale = currentDistance / initialTouchDistance
|
||||
zoomRef.value = Math.max(0.1, Math.min(5, initialZoom * scale))
|
||||
|
||||
updateTransform()
|
||||
this.updateZoomDisplay(container, zoomRef.value)
|
||||
}
|
||||
})
|
||||
|
||||
svg.addEventListener('touchend', () => {
|
||||
lastTouchPos = null
|
||||
initialTouchDistance = null
|
||||
})
|
||||
|
||||
// Double-click to reset zoom and pan
|
||||
svg.addEventListener('dblclick', (e) => {
|
||||
e.preventDefault()
|
||||
zoomRef.value = 1
|
||||
panRef.x = 0
|
||||
panRef.y = 0
|
||||
updateTransform()
|
||||
this.updateZoomDisplay(container, zoomRef.value)
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
private addZoomControls(container: HTMLElement, localZoom: { value: number }, localPan: { x: number, y: number }, updateTransform: () => void) {
|
||||
const controlsContainer = container.createDiv({ cls: 'sqlseal-zoom-controls' })
|
||||
|
||||
// Animation state
|
||||
let isAnimating = false
|
||||
|
||||
const animateZoom = (targetZoom: number, targetPanX: number, targetPanY: number, duration: number = 200) => {
|
||||
if (isAnimating) return
|
||||
isAnimating = true
|
||||
|
||||
const startZoom = localZoom.value
|
||||
const startPanX = localPan.x
|
||||
const startPanY = localPan.y
|
||||
const startTime = Date.now()
|
||||
|
||||
const animate = () => {
|
||||
const elapsed = Date.now() - startTime
|
||||
const progress = Math.min(elapsed / duration, 1)
|
||||
|
||||
// Easing function - ease out cubic
|
||||
const easeProgress = 1 - Math.pow(1 - progress, 3)
|
||||
|
||||
localZoom.value = startZoom + (targetZoom - startZoom) * easeProgress
|
||||
localPan.x = startPanX + (targetPanX - startPanX) * easeProgress
|
||||
localPan.y = startPanY + (targetPanY - startPanY) * easeProgress
|
||||
|
||||
updateTransform()
|
||||
this.updateZoomDisplay(container, localZoom.value)
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animate)
|
||||
} else {
|
||||
isAnimating = false
|
||||
// Ensure final values are exact
|
||||
localZoom.value = targetZoom
|
||||
localPan.x = targetPanX
|
||||
localPan.y = targetPanY
|
||||
updateTransform()
|
||||
this.updateZoomDisplay(container, localZoom.value)
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
// Reset button (home) - first
|
||||
const resetBtn = controlsContainer.createEl('button', {
|
||||
cls: 'sqlseal-zoom-btn',
|
||||
text: '⌂',
|
||||
attr: { title: 'Reset View' }
|
||||
})
|
||||
resetBtn.addEventListener('click', () => {
|
||||
animateZoom(1, 0, 0, 300)
|
||||
})
|
||||
|
||||
// Zoom in button - second
|
||||
const zoomInBtn = controlsContainer.createEl('button', {
|
||||
cls: 'sqlseal-zoom-btn',
|
||||
text: '+',
|
||||
attr: { title: 'Zoom In' }
|
||||
})
|
||||
zoomInBtn.addEventListener('click', () => {
|
||||
const newZoom = Math.min(5, localZoom.value * 1.2)
|
||||
animateZoom(newZoom, localPan.x, localPan.y, 150)
|
||||
})
|
||||
|
||||
// Zoom out button - third
|
||||
const zoomOutBtn = controlsContainer.createEl('button', {
|
||||
cls: 'sqlseal-zoom-btn',
|
||||
text: '−',
|
||||
attr: { title: 'Zoom Out' }
|
||||
})
|
||||
zoomOutBtn.addEventListener('click', () => {
|
||||
const newZoom = Math.max(0.1, localZoom.value / 1.2)
|
||||
animateZoom(newZoom, localPan.x, localPan.y, 150)
|
||||
})
|
||||
|
||||
// Zoom level display
|
||||
const zoomDisplay = controlsContainer.createDiv({
|
||||
cls: 'sqlseal-zoom-display'
|
||||
})
|
||||
this.updateZoomDisplay(container, localZoom.value)
|
||||
}
|
||||
|
||||
private updateZoomDisplay(container: HTMLElement, zoom: number) {
|
||||
const display = container.querySelector('.sqlseal-zoom-display')
|
||||
if (display) {
|
||||
display.textContent = `${Math.round(zoom * 100)}%`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private addInteractivity(container: HTMLElement, schema: DetailedTableInfo[]) {
|
||||
// Add click handlers for tables to show detailed information
|
||||
const tableElements = container.querySelectorAll('[id^="entity-"]')
|
||||
|
||||
tableElements.forEach((element, index) => {
|
||||
if (index < schema.length) {
|
||||
const table = schema[index]
|
||||
element.addEventListener('click', () => {
|
||||
this.showTableDetails(table)
|
||||
})
|
||||
|
||||
element.addEventListener('mouseenter', () => {
|
||||
element.classList.add('highlighted')
|
||||
})
|
||||
|
||||
element.addEventListener('mouseleave', () => {
|
||||
element.classList.remove('highlighted')
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private showTableDetails(table: DetailedTableInfo) {
|
||||
// Create a modal or popup with detailed table information
|
||||
const modal = document.createElement('div')
|
||||
modal.className = 'sqlseal-table-details-modal'
|
||||
|
||||
const content = document.createElement('div')
|
||||
content.className = 'sqlseal-modal-content'
|
||||
|
||||
const title = document.createElement('h3')
|
||||
title.textContent = `Table: ${table.name}`
|
||||
content.appendChild(title)
|
||||
|
||||
// Columns section
|
||||
const columnsTitle = document.createElement('h4')
|
||||
columnsTitle.textContent = 'Columns'
|
||||
content.appendChild(columnsTitle)
|
||||
|
||||
const columnsTable = document.createElement('table')
|
||||
columnsTable.className = 'sqlseal-columns-table'
|
||||
|
||||
const headerRow = columnsTable.createTHead().insertRow()
|
||||
headerRow.insertCell().textContent = 'Name'
|
||||
headerRow.insertCell().textContent = 'Type'
|
||||
headerRow.insertCell().textContent = 'Key'
|
||||
headerRow.insertCell().textContent = 'Nullable'
|
||||
headerRow.insertCell().textContent = 'Default'
|
||||
|
||||
const tbody = columnsTable.createTBody()
|
||||
table.columns.forEach(column => {
|
||||
const row = tbody.insertRow()
|
||||
row.insertCell().textContent = column.name
|
||||
row.insertCell().textContent = column.type
|
||||
row.insertCell().textContent = column.isPrimaryKey ? 'PK' : ''
|
||||
row.insertCell().textContent = column.notNull ? 'NO' : 'YES'
|
||||
row.insertCell().textContent = column.defaultValue || ''
|
||||
})
|
||||
|
||||
content.appendChild(columnsTable)
|
||||
|
||||
// Foreign keys section
|
||||
if (table.foreignKeys.length > 0) {
|
||||
const fkTitle = document.createElement('h4')
|
||||
fkTitle.textContent = 'Foreign Keys'
|
||||
content.appendChild(fkTitle)
|
||||
|
||||
const fkTable = document.createElement('table')
|
||||
fkTable.className = 'sqlseal-fk-table'
|
||||
|
||||
const fkHeaderRow = fkTable.createTHead().insertRow()
|
||||
fkHeaderRow.insertCell().textContent = 'Column'
|
||||
fkHeaderRow.insertCell().textContent = 'References'
|
||||
fkHeaderRow.insertCell().textContent = 'On Update'
|
||||
fkHeaderRow.insertCell().textContent = 'On Delete'
|
||||
|
||||
const fkTbody = fkTable.createTBody()
|
||||
table.foreignKeys.forEach(fk => {
|
||||
const row = fkTbody.insertRow()
|
||||
row.insertCell().textContent = fk.fromColumn
|
||||
row.insertCell().textContent = `${fk.referencedTable}.${fk.toColumn}`
|
||||
row.insertCell().textContent = fk.onUpdate
|
||||
row.insertCell().textContent = fk.onDelete
|
||||
})
|
||||
|
||||
content.appendChild(fkTable)
|
||||
}
|
||||
|
||||
// Close button
|
||||
const closeBtn = document.createElement('button')
|
||||
closeBtn.textContent = '×'
|
||||
closeBtn.className = 'sqlseal-modal-close'
|
||||
closeBtn.onclick = () => modal.remove()
|
||||
|
||||
modal.appendChild(content)
|
||||
modal.appendChild(closeBtn)
|
||||
document.body.appendChild(modal)
|
||||
|
||||
// Close on backdrop click
|
||||
modal.onclick = (e) => {
|
||||
if (e.target === modal) modal.remove()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private showFallbackSchema(container: HTMLElement, schema: DetailedTableInfo[]) {
|
||||
container.empty()
|
||||
|
||||
const fallbackContainer = container.createDiv({
|
||||
cls: 'sqlseal-fallback-schema'
|
||||
})
|
||||
|
||||
const title = fallbackContainer.createEl('h3', {
|
||||
text: 'Database Schema (Fallback View)'
|
||||
})
|
||||
|
||||
if (schema.length === 0) {
|
||||
fallbackContainer.createEl('p', {
|
||||
text: 'No tables found in the database.',
|
||||
cls: 'sqlseal-empty-schema'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
schema.forEach(table => {
|
||||
const tableContainer = fallbackContainer.createDiv({
|
||||
cls: 'sqlseal-fallback-table'
|
||||
})
|
||||
|
||||
const tableTitle = tableContainer.createEl('h4', {
|
||||
text: table.name,
|
||||
cls: 'sqlseal-fallback-table-title'
|
||||
})
|
||||
|
||||
if (table.columns.length > 0) {
|
||||
const columnsTable = tableContainer.createEl('table', {
|
||||
cls: 'sqlseal-fallback-columns-table'
|
||||
})
|
||||
|
||||
const headerRow = columnsTable.createTHead().insertRow()
|
||||
headerRow.insertCell().textContent = 'Column'
|
||||
headerRow.insertCell().textContent = 'Type'
|
||||
headerRow.insertCell().textContent = 'Key'
|
||||
headerRow.insertCell().textContent = 'Constraints'
|
||||
|
||||
const tbody = columnsTable.createTBody()
|
||||
table.columns.forEach(column => {
|
||||
const row = tbody.insertRow()
|
||||
row.insertCell().textContent = column.name
|
||||
row.insertCell().textContent = column.type
|
||||
|
||||
let keyType = ''
|
||||
if (column.isPrimaryKey) keyType = 'PK'
|
||||
else if (table.foreignKeys.some(fk => fk.fromColumn === column.name)) keyType = 'FK'
|
||||
row.insertCell().textContent = keyType
|
||||
|
||||
const constraints = []
|
||||
if (column.notNull) constraints.push('NOT NULL')
|
||||
if (column.defaultValue !== null) constraints.push(`DEFAULT ${column.defaultValue}`)
|
||||
row.insertCell().textContent = constraints.join(', ')
|
||||
})
|
||||
}
|
||||
|
||||
if (table.foreignKeys.length > 0) {
|
||||
const fkTitle = tableContainer.createEl('h5', {
|
||||
text: 'Foreign Keys',
|
||||
cls: 'sqlseal-fallback-fk-title'
|
||||
})
|
||||
|
||||
const fkList = tableContainer.createEl('ul', {
|
||||
cls: 'sqlseal-fallback-fk-list'
|
||||
})
|
||||
|
||||
table.foreignKeys.forEach(fk => {
|
||||
const fkItem = fkList.createEl('li')
|
||||
fkItem.textContent = `${fk.fromColumn} → ${fk.referencedTable}.${fk.toColumn}`
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
// Legacy styles - kept for backward compatibility
|
||||
.sqlseal-tv-container {
|
||||
gap: 1em;
|
||||
}
|
||||
|
|
@ -26,4 +27,362 @@
|
|||
flex-direction: row;
|
||||
gap: 1em;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
// New Mermaid-based schema visualizer styles
|
||||
.sqlseal-mermaid-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--background-primary);
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
// Interactive elements
|
||||
[id^="entity-"] {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&.highlighted {
|
||||
filter: drop-shadow(2px 2px 4px rgba(0,0,0,0.3));
|
||||
}
|
||||
}
|
||||
|
||||
// Relationship lines and arrows - make them more visible
|
||||
.er.relationshipLine {
|
||||
stroke: var(--text-accent) !important;
|
||||
stroke-width: 2px !important;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
// Relationship labels
|
||||
.er.relationshipLabel {
|
||||
fill: var(--text-accent) !important;
|
||||
font-family: var(--font-interface);
|
||||
font-size: 11px !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
// Arrow markers for relationships
|
||||
.er marker {
|
||||
fill: var(--text-accent) !important;
|
||||
stroke: var(--text-accent) !important;
|
||||
}
|
||||
|
||||
// Entity boxes
|
||||
.er.entityBox {
|
||||
fill: var(--background-secondary);
|
||||
stroke: var(--background-modifier-border);
|
||||
stroke-width: 1px;
|
||||
}
|
||||
|
||||
// Entity labels
|
||||
.er.entityLabel {
|
||||
fill: var(--text-normal);
|
||||
font-family: var(--font-interface);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
// Attribute text
|
||||
.er.attributeBoxEven,
|
||||
.er.attributeBoxOdd {
|
||||
fill: var(--background-primary-alt);
|
||||
stroke: var(--background-modifier-border);
|
||||
}
|
||||
|
||||
.er.attributeBoxEven {
|
||||
fill: var(--background-primary);
|
||||
}
|
||||
|
||||
// Enhance cardinality symbols (crow's foot notation)
|
||||
.er .cardinality text {
|
||||
fill: var(--text-accent) !important;
|
||||
font-family: var(--font-interface);
|
||||
font-size: 14px !important;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
// Ensure all relationship elements are visible
|
||||
path[id*="relationship"] {
|
||||
stroke: var(--text-accent) !important;
|
||||
stroke-width: 2px !important;
|
||||
marker-end: url(#arrowhead) !important;
|
||||
}
|
||||
|
||||
// Style the actual relationship paths
|
||||
g[id*="relationship"] path {
|
||||
stroke: var(--text-accent) !important;
|
||||
stroke-width: 2px !important;
|
||||
fill: none !important;
|
||||
}
|
||||
|
||||
// Make sure arrowheads are visible
|
||||
defs marker path {
|
||||
fill: var(--text-accent) !important;
|
||||
stroke: var(--text-accent) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Pan and zoom controls - minimalist bottom right
|
||||
.sqlseal-zoom-controls {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
right: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
z-index: 10;
|
||||
|
||||
.sqlseal-zoom-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: var(--background-primary);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 4px;
|
||||
color: var(--text-normal);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
|
||||
&:hover {
|
||||
background: var(--background-modifier-hover);
|
||||
border-color: var(--text-accent);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0) scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
.sqlseal-zoom-display {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-monospace);
|
||||
padding: 2px 6px;
|
||||
background: var(--background-primary);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 4px;
|
||||
margin-left: 4px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
min-width: 42px;
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
// Make the mermaid container relative for absolute positioning of controls
|
||||
.sqlseal-mermaid-container {
|
||||
position: relative;
|
||||
|
||||
svg {
|
||||
// Enable smooth transitions for pan/zoom
|
||||
transition: none; // Disable transitions during interaction
|
||||
|
||||
// Cursor styles for interaction states
|
||||
&.grabbing {
|
||||
cursor: grabbing !important;
|
||||
}
|
||||
}
|
||||
|
||||
.pan-zoom-group {
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Modal styles for table details
|
||||
.sqlseal-table-details-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
|
||||
.sqlseal-modal-content {
|
||||
background: var(--background-primary);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
max-width: 80vw;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
|
||||
h3, h4 {
|
||||
color: var(--text-normal);
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin-top: 24px;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
}
|
||||
|
||||
.sqlseal-modal-close {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 16px;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: var(--background-modifier-hover);
|
||||
color: var(--text-normal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Table styles for modal content
|
||||
.sqlseal-columns-table,
|
||||
.sqlseal-fk-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 12px 0;
|
||||
font-family: var(--font-interface);
|
||||
|
||||
th, td {
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
background: var(--background-secondary);
|
||||
font-weight: 600;
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
td {
|
||||
background: var(--background-primary);
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
tr:nth-child(even) td {
|
||||
background: var(--background-primary-alt);
|
||||
}
|
||||
}
|
||||
|
||||
// Error message styling
|
||||
.sqlseal-error {
|
||||
color: var(--text-error);
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
// Fallback schema styles
|
||||
.sqlseal-fallback-schema {
|
||||
padding: 16px;
|
||||
font-family: var(--font-interface);
|
||||
|
||||
h3 {
|
||||
color: var(--text-normal);
|
||||
margin: 0 0 20px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sqlseal-empty-schema {
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
padding: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.sqlseal-fallback-table {
|
||||
margin-bottom: 24px;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
|
||||
.sqlseal-fallback-table-title {
|
||||
background: var(--background-secondary);
|
||||
color: var(--text-normal);
|
||||
margin: 0;
|
||||
padding: 12px 16px;
|
||||
font-size: 1.1em;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
|
||||
.sqlseal-fallback-fk-title {
|
||||
color: var(--text-normal);
|
||||
margin: 16px 16px 8px 16px;
|
||||
font-size: 0.95em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sqlseal-fallback-fk-list {
|
||||
margin: 0 16px 16px 16px;
|
||||
padding-left: 20px;
|
||||
|
||||
li {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9em;
|
||||
margin-bottom: 4px;
|
||||
font-family: var(--font-monospace);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sqlseal-fallback-columns-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 0;
|
||||
|
||||
th, td {
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
padding: 8px 16px;
|
||||
text-align: left;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
th {
|
||||
background: var(--background-primary-alt);
|
||||
font-weight: 600;
|
||||
color: var(--text-normal);
|
||||
font-size: 0.85em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
td {
|
||||
background: var(--background-primary);
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
tr:nth-child(even) td {
|
||||
background: var(--background-primary-alt);
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
|
@ -16,7 +16,7 @@ export class StatsRenderer implements ICellRendererComp {
|
|||
this.sync()
|
||||
}
|
||||
|
||||
public init(params: ICellRendererParams<TableConfiguration, string, GlobalTablesView>): void {
|
||||
public init(params: ICellRendererParams<TableConfiguration, string, GlobalTablesView>) {
|
||||
const { value, data, context } = params;
|
||||
this.context = context
|
||||
this.data = data!
|
||||
|
|
@ -30,11 +30,11 @@ export class StatsRenderer implements ICellRendererComp {
|
|||
requestAnimationFrame(async () => { this.sync() })
|
||||
|
||||
// Watching for the changes
|
||||
this.setupWatchersAsync(context, data!)
|
||||
this.setupWatchers(context, data!)
|
||||
|
||||
}
|
||||
|
||||
async setupWatchersAsync(context: GlobalTablesView, data: TableConfiguration) {
|
||||
async setupWatchers(context: GlobalTablesView, data: TableConfiguration) {
|
||||
this.reg = context.sync.getRegistrator()
|
||||
this.eventName = await context.sync.getEventNameForAlias('/', data!.name)
|
||||
this.reg.on(this.eventName, this.syncFn)
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue