From 55739479bd203480a12a463e86497182888904af Mon Sep 17 00:00:00 2001 From: juliangojani Date: Wed, 25 Mar 2026 18:04:12 +0100 Subject: [PATCH 1/2] feat: enhance Windows build process with MSIX and Squirrel packaging support --- .github/workflows/release-electron.yml | 35 ++++++++++++++++++ .../scripts/vendor-runtime-dependencies.cjs | 36 +++++++++++++++++-- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-electron.yml b/.github/workflows/release-electron.yml index e3eb652..23ec128 100644 --- a/.github/workflows/release-electron.yml +++ b/.github/workflows/release-electron.yml @@ -137,6 +137,7 @@ jobs: Write-Host "Using Windows SDK version $selectedVersion for MSIX packaging" - name: Build Electron distributables + if: runner.os != 'Windows' env: STARQUERY_MAC_SIGN: ${{ runner.os == 'macOS' && env.APPLE_SIGN_CERTIFICATE_P12_BASE64 != '' && 'true' || 'false' }} STARQUERY_MAC_NOTARIZE: ${{ runner.os == 'macOS' && ((env.APPLE_NOTARY_API_KEY_P8_BASE64 != '' && env.APPLE_API_KEY_ID != '' && env.APPLE_API_ISSUER != '') || (env.APPLE_ID != '' && env.APPLE_APP_SPECIFIC_PASSWORD != '' && env.APPLE_TEAM_ID != '')) && 'true' || 'false' }} @@ -160,6 +161,40 @@ jobs: WINDOWS_CERTIFICATE_PASSWORD: ${{ env.STARQUERY_WINDOWS_CERTIFICATE_PASSWORD }} run: pnpm --dir packages/electron make + - name: Package Electron app + if: runner.os == 'Windows' + run: pnpm --dir packages/electron exec electron-forge package + + - name: Build Windows Squirrel distributable + if: runner.os == 'Windows' + env: + STARQUERY_MSIX_PUBLISHER: ${{ vars.STARQUERY_MSIX_PUBLISHER }} + STARQUERY_MSIX_PUBLISHER_DISPLAY_NAME: ${{ vars.STARQUERY_MSIX_PUBLISHER_DISPLAY_NAME }} + STARQUERY_MSIX_IDENTITY_NAME: ${{ vars.STARQUERY_MSIX_IDENTITY_NAME }} + STARQUERY_MSIX_PACKAGE_DISPLAY_NAME: ${{ vars.STARQUERY_MSIX_PACKAGE_DISPLAY_NAME }} + STARQUERY_MSIX_APP_DISPLAY_NAME: ${{ vars.STARQUERY_MSIX_APP_DISPLAY_NAME }} + STARQUERY_MSIX_BACKGROUND_COLOR: ${{ vars.STARQUERY_MSIX_BACKGROUND_COLOR }} + STARQUERY_MSIX_MIN_OS_VERSION: ${{ vars.STARQUERY_MSIX_MIN_OS_VERSION }} + STARQUERY_MSIX_MAX_OS_VERSION_TESTED: ${{ vars.STARQUERY_MSIX_MAX_OS_VERSION_TESTED }} + STARQUERY_MSIX_SIGN: ${{ vars.STARQUERY_MSIX_SIGN }} + WINDOWS_CERTIFICATE_PASSWORD: ${{ env.STARQUERY_WINDOWS_CERTIFICATE_PASSWORD }} + run: pnpm --dir packages/electron exec electron-forge make --skip-package --targets=@electron-forge/maker-squirrel + + - name: Build Windows MSIX distributable + if: runner.os == 'Windows' + env: + STARQUERY_MSIX_PUBLISHER: ${{ vars.STARQUERY_MSIX_PUBLISHER }} + STARQUERY_MSIX_PUBLISHER_DISPLAY_NAME: ${{ vars.STARQUERY_MSIX_PUBLISHER_DISPLAY_NAME }} + STARQUERY_MSIX_IDENTITY_NAME: ${{ vars.STARQUERY_MSIX_IDENTITY_NAME }} + STARQUERY_MSIX_PACKAGE_DISPLAY_NAME: ${{ vars.STARQUERY_MSIX_PACKAGE_DISPLAY_NAME }} + STARQUERY_MSIX_APP_DISPLAY_NAME: ${{ vars.STARQUERY_MSIX_APP_DISPLAY_NAME }} + STARQUERY_MSIX_BACKGROUND_COLOR: ${{ vars.STARQUERY_MSIX_BACKGROUND_COLOR }} + STARQUERY_MSIX_MIN_OS_VERSION: ${{ vars.STARQUERY_MSIX_MIN_OS_VERSION }} + STARQUERY_MSIX_MAX_OS_VERSION_TESTED: ${{ vars.STARQUERY_MSIX_MAX_OS_VERSION_TESTED }} + STARQUERY_MSIX_SIGN: ${{ vars.STARQUERY_MSIX_SIGN }} + WINDOWS_CERTIFICATE_PASSWORD: ${{ env.STARQUERY_WINDOWS_CERTIFICATE_PASSWORD }} + run: pnpm --dir packages/electron exec electron-forge make --skip-package --targets=@electron-forge/maker-msix + - name: Upload workflow artifacts uses: actions/upload-artifact@v4 with: diff --git a/packages/electron/scripts/vendor-runtime-dependencies.cjs b/packages/electron/scripts/vendor-runtime-dependencies.cjs index c5bc31d..5d82beb 100644 --- a/packages/electron/scripts/vendor-runtime-dependencies.cjs +++ b/packages/electron/scripts/vendor-runtime-dependencies.cjs @@ -96,7 +96,38 @@ function copyPackageTree(sourceDir, targetDir) { }); } -async function vendorRuntimeDependencies(buildPath) { +function pruneVendoredPackage(packageName, targetPackageDir, targetPlatform, targetArch) { + if (packageName !== 'oracledb') { + return; + } + + const releaseDir = path.join(targetPackageDir, 'build', 'Release'); + if (!fs.existsSync(releaseDir)) { + return; + } + + const expectedBinarySuffix = `-${targetPlatform}-${targetArch}.node`; + const releaseEntries = fs.readdirSync(releaseDir); + + for (const entryName of releaseEntries) { + if (!entryName.startsWith('oracledb-')) { + continue; + } + + const isNativeBinary = entryName.endsWith('.node'); + const isBuildInfo = entryName.endsWith('.node-buildinfo.txt'); + if (!isNativeBinary && !isBuildInfo) { + continue; + } + + const matchesTarget = entryName.includes(expectedBinarySuffix); + if (!matchesTarget) { + fs.rmSync(path.join(releaseDir, entryName), { force: true }); + } + } +} + +async function vendorRuntimeDependencies(buildPath, targetPlatform, targetArch) { const electronPackageJson = readJson(electronPackageJsonPath); const pending = [...collectDependencyNames(electronPackageJson)]; const visited = new Set(); @@ -121,6 +152,7 @@ async function vendorRuntimeDependencies(buildPath) { ensureDirectory(path.dirname(targetPackageDir)); copyPackageTree(installedPackageDir, targetPackageDir); + pruneVendoredPackage(packageName, targetPackageDir, targetPlatform, targetArch); const installedPackageJson = readJson(installedPackageJsonPath); for (const dependencyName of collectDependencyNames(installedPackageJson)) { @@ -133,7 +165,7 @@ async function vendorRuntimeDependencies(buildPath) { module.exports = async function afterCopy(buildPath, electronVersion, platform, arch, callback) { try { - await vendorRuntimeDependencies(buildPath); + await vendorRuntimeDependencies(buildPath, platform, arch); callback(); } catch (error) { callback(error); From e700fbcdf2b230194558ded3028d6a3d2240051d Mon Sep 17 00:00:00 2001 From: juliangojani Date: Wed, 25 Mar 2026 19:42:12 +0100 Subject: [PATCH 2/2] feat: implement virtual scrolling and optimize data table rendering --- .../components/table/ExtendedDataTable.vue | 331 +++++------------- .../table/ExtendedDataTableCellDisplay.vue | 30 ++ .../table/extended-data-table-utils.ts | 147 ++++++++ .../table/extended-data-table.types.ts | 9 + .../table/useExtendedDataTableViewport.ts | 252 +++++++++++++ 5 files changed, 535 insertions(+), 234 deletions(-) create mode 100644 packages/frontend/src/components/table/ExtendedDataTableCellDisplay.vue create mode 100644 packages/frontend/src/components/table/extended-data-table-utils.ts create mode 100644 packages/frontend/src/components/table/extended-data-table.types.ts create mode 100644 packages/frontend/src/components/table/useExtendedDataTableViewport.ts diff --git a/packages/frontend/src/components/table/ExtendedDataTable.vue b/packages/frontend/src/components/table/ExtendedDataTable.vue index e0c9277..931866a 100644 --- a/packages/frontend/src/components/table/ExtendedDataTable.vue +++ b/packages/frontend/src/components/table/ExtendedDataTable.vue @@ -2,19 +2,23 @@ import { computed, nextTick, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue' import ContextMenu, { type ContextMenuMethods } from 'primevue/contextmenu' import ResizeKnob from '@/components/ResizeKnob.vue' +import ExtendedDataTableCellDisplay from '@/components/table/ExtendedDataTableCellDisplay.vue' import SQLTableCellEditor from '@/components/table/SQLTableCellEditor.vue' +import type { CellPosition } from '@/components/table/extended-data-table.types' +import { + DRAG_SCROLL_STEP, + DRAG_SCROLL_THRESHOLD, + buildColumnWidths, + cloneCellValue, + createRowId, + isAutoGeneratedColumn, + normalizeCellInputValue, + valuesEqual, +} from '@/components/table/extended-data-table-utils' +import { useExtendedDataTableViewport } from '@/components/table/useExtendedDataTableViewport' import { useToast } from 'primevue/usetoast' import type { SQLTableColumn, SQLTableRowDraft } from '@/types/sql' -type CellPosition = { - row: number - column: number -} - -const CELL_PREVIEW_TEXT_LIMIT = 150 -const DRAG_SCROLL_THRESHOLD = 48 -const DRAG_SCROLL_STEP = 20 - const props = withDefaults( defineProps<{ canEdit?: boolean @@ -33,6 +37,8 @@ const rows = defineModel('rows', { required: true }) const toast = useToast() const gridContainer = useTemplateRef('gridContainer') +const tableHead = useTemplateRef('tableHead') +const cornerHeaderCell = useTemplateRef('cornerHeaderCell') const editingEditor = useTemplateRef>('editingEditor') const contextMenu = useTemplateRef('contextMenu') @@ -48,43 +54,6 @@ const dragPointer = ref<{ x: number; y: number } | null>(null) let dragScrollFrame = 0 -const createRowId = () => - typeof crypto !== 'undefined' && 'randomUUID' in crypto - ? crypto.randomUUID() - : `row-${Date.now()}-${Math.random().toString(36).slice(2)}` - -const cloneCellValue = (value: unknown) => { - if (value instanceof Date) { - return new Date(value) - } - - return value -} - -const isGeneratedDefaultValue = (value: unknown) => { - if (typeof value !== 'string') { - return false - } - - const normalized = value.trim().toLowerCase() - return [ - 'current_timestamp', - 'current_timestamp()', - 'current_date', - 'current_date()', - 'current_time', - 'current_time()', - 'localtimestamp', - 'localtimestamp()', - 'now()', - 'uuid()', - 'gen_random_uuid()', - ].includes(normalized) -} - -const isAutoGeneratedColumn = (column: SQLTableColumn) => - column.autoIncrement === true || isGeneratedDefaultValue(column.defaultValue) - const clampCell = (cell: CellPosition): CellPosition => ({ row: Math.min(Math.max(cell.row, 0), Math.max(rows.value.length - 1, 0)), column: Math.min(Math.max(cell.column, 0), Math.max(columns.value.length - 1, 0)), @@ -101,13 +70,7 @@ const findFirstEditableColumnIndex = () => watch( columns, (nextColumns) => { - widths.value = nextColumns.reduce( - (previous, column) => ({ - ...previous, - [column.field]: widths.value[column.field] ?? 220, - }), - {} as Record, - ) + widths.value = buildColumnWidths(nextColumns, widths.value) }, { immediate: true }, ) @@ -132,22 +95,6 @@ const selectionRange = computed(() => { } }) -const selectedRowIndexes = computed(() => { - if (!selectionRange.value) return [] - return Array.from( - { length: selectionRange.value.endRow - selectionRange.value.startRow + 1 }, - (_, index) => selectionRange.value!.startRow + index, - ) -}) - -const selectedColumnIndexes = computed(() => { - if (!selectionRange.value) return [] - return Array.from( - { length: selectionRange.value.endColumn - selectionRange.value.startColumn + 1 }, - (_, index) => selectionRange.value!.startColumn + index, - ) -}) - const selectionIsSingleCell = computed( () => !!selectionRange.value && @@ -155,23 +102,33 @@ const selectionIsSingleCell = computed( selectionRange.value.startColumn === selectionRange.value.endColumn, ) -const focusGrid = () => { - gridContainer.value?.focus() +const { ensureCellVisible, getCellPositionFromPointer, measuredRowHeight, renderedRows, scheduleViewportSync, shouldVirtualizeRows, visibleRowRange } = + useExtendedDataTableViewport({ + gridContainer, + tableHead, + cornerHeaderCell, + rows, + columns, + widths, + editingCell, + clampCell, + hasRows, + }) + +const getSelectedRowIndexes = () => { + const range = selectionRange.value + if (!range) return [] + + return Array.from( + { length: range.endRow - range.startRow + 1 }, + (_, index) => range.startRow + index, + ) } -const getCellElement = (cell: CellPosition) => - gridContainer.value?.querySelector( - `td[data-cell-row="${cell.row}"][data-cell-column="${cell.column}"]`, - ) ?? null +const isEditingRow = (rowIndex: number) => editingCell.value?.row === rowIndex -const ensureCellVisible = (cell: CellPosition) => { - nextTick(() => { - const targetCell = getCellElement(cell) - targetCell?.scrollIntoView({ - block: 'nearest', - inline: 'nearest', - }) - }) +const focusGrid = () => { + gridContainer.value?.focus() } const restoreGridFocusAfterEdit = () => { @@ -260,17 +217,10 @@ const isCellSelected = (rowIndex: number, columnIndex: number) => { ) } -const isColumnSelected = (columnIndex: number) => selectedColumnIndexes.value.includes(columnIndex) - -const valuesEqual = (left: unknown, right: unknown) => { - if (left === right) return true - - if (left instanceof Date && right instanceof Date) { - return left.getTime() === right.getTime() - } - - return false -} +const isColumnSelected = (columnIndex: number) => + !!selectionRange.value && + columnIndex >= selectionRange.value.startColumn && + columnIndex <= selectionRange.value.endColumn const recomputeRowState = (rowIndex: number) => { const row = rows.value[rowIndex] @@ -294,80 +244,6 @@ const getValue = (rowIndex: number, columnIndex: number) => { return row?.values?.[column.field] } -const formatDisplayValue = (value: unknown) => { - const text = - typeof value === 'string' ? value : value === null || value === undefined ? '' : String(value) - - if (text.length <= CELL_PREVIEW_TEXT_LIMIT) { - return text - } - - return `${text.slice(0, CELL_PREVIEW_TEXT_LIMIT)}…` -} - -const getDisplaySegments = (value: unknown) => { - const text = formatDisplayValue(value) - - return text.split(/(\r?\n)/).filter((segment) => segment.length > 0) -} - -const pad = (value: number) => String(value).padStart(2, '0') - -const getEditorKind = (column: SQLTableColumn) => { - const type = (column.type || '').toLowerCase() - - if (column.enumValues?.length) return 'enum' - if (/\b(timestamp|datetime)\b/.test(type)) return 'datetime' - if (/\btime\b/.test(type) && !/\b(timestamp|datetime)\b/.test(type)) return 'time' - if (/\bdate\b/.test(type)) return 'date' - - return 'text' -} - -const formatDateForColumn = (value: Date, kind: ReturnType) => { - if (kind === 'date') { - return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}` - } - - if (kind === 'time') { - return `${pad(value.getHours())}:${pad(value.getMinutes())}:${pad(value.getSeconds())}` - } - - return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())} ${pad(value.getHours())}:${pad(value.getMinutes())}:${pad(value.getSeconds())}` -} - -const normalizeInputValue = (rawValue: unknown, rowIndex: number, columnIndex: number) => { - const column = columns.value[columnIndex] - const currentValue = getValue(rowIndex, columnIndex) - const type = (column.type || '').toLowerCase() - const editorKind = getEditorKind(column) - - if (rawValue instanceof Date) { - return formatDateForColumn(rawValue, editorKind) - } - - if (rawValue === null || rawValue === undefined) { - return column.nullable !== false ? null : currentValue - } - - const normalized = String(rawValue).trim() - - if (normalized === '' && column.nullable !== false) { - return null - } - - if (typeof currentValue === 'number' || /(int|decimal|float|double)/.test(type)) { - const numericValue = Number(rawValue) - return Number.isNaN(numericValue) ? rawValue : numericValue - } - - if (typeof currentValue === 'boolean' || /(bool|tinyint\(1\))/.test(type)) { - return ['1', 'true', 'yes'].includes(normalized.toLowerCase()) - } - - return rawValue -} - const setValue = (rowIndex: number, columnIndex: number, value: unknown) => { const row = rows.value[rowIndex] if (!row || row.state === 'deleted') return @@ -398,7 +274,15 @@ const commitEditing = () => { if (!editingCell.value) return const { row, column } = editingCell.value - setValue(row, column, normalizeInputValue(editingValue.value, row, column)) + setValue( + row, + column, + normalizeCellInputValue({ + rawValue: editingValue.value, + currentValue: getValue(row, column), + column: columns.value[column], + }), + ) editingCell.value = null } @@ -433,11 +317,12 @@ const addRow = () => { } const duplicateSelectedRows = () => { - if (!selectedRowIndexes.value.length) return + const selectedRowIndexes = getSelectedRowIndexes() + if (!selectedRowIndexes.length) return finishPendingEdit() - const insertionIndex = Math.max(...selectedRowIndexes.value) + 1 - const duplicates = selectedRowIndexes.value + const insertionIndex = Math.max(...selectedRowIndexes) + 1 + const duplicates = selectedRowIndexes .map((rowIndex) => rows.value[rowIndex]) .filter((row): row is SQLTableRowDraft => !!row && row.state !== 'deleted') .map((row) => ({ @@ -465,10 +350,11 @@ const duplicateSelectedRows = () => { } const deleteSelectedRows = () => { - if (!selectedRowIndexes.value.length) return + const selectedRowIndexes = getSelectedRowIndexes() + if (!selectedRowIndexes.length) return finishPendingEdit() - const indexes = [...selectedRowIndexes.value].sort((left, right) => right - left) + const indexes = [...selectedRowIndexes].sort((left, right) => right - left) for (const index of indexes) { const row = rows.value[index] @@ -513,7 +399,7 @@ const setSelectionToNull = () => { } const discardSelectedChanges = () => { - const indexes = [...selectedRowIndexes.value].sort((left, right) => right - left) + const indexes = [...getSelectedRowIndexes()].sort((left, right) => right - left) for (const rowIndex of indexes) { const row = rows.value[rowIndex] @@ -589,26 +475,12 @@ const updateDragSelectionFromPointer = (clientX: number, clientY: number) => { return } - const containerRect = gridContainer.value.getBoundingClientRect() - const probeX = Math.min(Math.max(clientX, containerRect.left + 2), containerRect.right - 2) - const probeY = Math.min(Math.max(clientY, containerRect.top + 2), containerRect.bottom - 2) - const element = document.elementFromPoint(probeX, probeY) - const cell = element?.closest?.( - 'td[data-cell-row][data-cell-column]', - ) as HTMLTableCellElement | null - - if (!cell) { - return - } - - const row = Number(cell.dataset.cellRow) - const column = Number(cell.dataset.cellColumn) - - if (Number.isNaN(row) || Number.isNaN(column)) { + const nextCell = getCellPositionFromPointer(clientX, clientY) + if (!nextCell) { return } - focusCell.value = clampCell({ row, column }) + focusCell.value = nextCell } const stopDragAutoScroll = () => { @@ -883,7 +755,7 @@ defineExpose({ setSelection({ row: rowIndex, column: columnIndex }) }, getFocusedRowIndex: () => focusedRowIndex.value, - getSelectedRowIndexes: () => [...selectedRowIndexes.value], + getSelectedRowIndexes: () => [...getSelectedRowIndexes()], }) @@ -892,13 +764,15 @@ defineExpose({ ref="gridContainer" tabindex="0" class="h-full overflow-auto outline-none" + @scroll.passive="scheduleViewportSync" @keydown="onGridKeyDown" @contextmenu.prevent="showContextMenu($event)" > - + + + + + + +
-
- - NULL - - - - - -
+ :value="getValue(rowIndex, columnIndex)" + :row-state="row.state" + :read-only="column.readOnly" + />
diff --git a/packages/frontend/src/components/table/ExtendedDataTableCellDisplay.vue b/packages/frontend/src/components/table/ExtendedDataTableCellDisplay.vue new file mode 100644 index 0000000..b3eae33 --- /dev/null +++ b/packages/frontend/src/components/table/ExtendedDataTableCellDisplay.vue @@ -0,0 +1,30 @@ + + + diff --git a/packages/frontend/src/components/table/extended-data-table-utils.ts b/packages/frontend/src/components/table/extended-data-table-utils.ts new file mode 100644 index 0000000..f996b73 --- /dev/null +++ b/packages/frontend/src/components/table/extended-data-table-utils.ts @@ -0,0 +1,147 @@ +import type { SQLTableColumn } from '@/types/sql' + +export type EditorKind = 'text' | 'textarea' | 'enum' | 'date' | 'datetime' | 'time' + +export const CELL_PREVIEW_TEXT_LIMIT = 150 +export const DRAG_SCROLL_THRESHOLD = 48 +export const DRAG_SCROLL_STEP = 20 +export const VIRTUALIZATION_ROW_THRESHOLD = 200 +export const VIRTUAL_ROW_HEIGHT = 30 +export const VIRTUAL_OVERSCAN_ROWS = 12 + +const GENERATED_DEFAULT_VALUES = new Set([ + 'current_timestamp', + 'current_timestamp()', + 'current_date', + 'current_date()', + 'current_time', + 'current_time()', + 'localtimestamp', + 'localtimestamp()', + 'now()', + 'uuid()', + 'gen_random_uuid()', +]) + +const pad = (value: number) => String(value).padStart(2, '0') + +export const createRowId = () => + typeof crypto !== 'undefined' && 'randomUUID' in crypto + ? crypto.randomUUID() + : `row-${Date.now()}-${Math.random().toString(36).slice(2)}` + +export const cloneCellValue = (value: unknown) => { + if (value instanceof Date) { + return new Date(value) + } + + return value +} + +export const isGeneratedDefaultValue = (value: unknown) => { + if (typeof value !== 'string') { + return false + } + + return GENERATED_DEFAULT_VALUES.has(value.trim().toLowerCase()) +} + +export const isAutoGeneratedColumn = (column: SQLTableColumn) => + column.autoIncrement === true || isGeneratedDefaultValue(column.defaultValue) + +export const buildColumnWidths = ( + nextColumns: SQLTableColumn[], + previousWidths: Record, +) => + nextColumns.reduce( + (nextWidths, column) => ({ + ...nextWidths, + [column.field]: previousWidths[column.field] ?? 220, + }), + {} as Record, + ) + +export const valuesEqual = (left: unknown, right: unknown) => { + if (left === right) return true + + if (left instanceof Date && right instanceof Date) { + return left.getTime() === right.getTime() + } + + return false +} + +export const formatDisplayValue = (value: unknown) => { + const text = + typeof value === 'string' ? value : value === null || value === undefined ? '' : String(value) + + if (text.length <= CELL_PREVIEW_TEXT_LIMIT) { + return text + } + + return `${text.slice(0, CELL_PREVIEW_TEXT_LIMIT)}...` +} + +export const getDisplayText = (value: unknown) => + formatDisplayValue(typeof value === 'string' ? value.replace(/\r?\n/g, ' \\n ') : value) + +export const getEditorKind = (column: SQLTableColumn): EditorKind => { + const type = (column.type || '').toLowerCase() + + if (column.enumValues?.length) return 'enum' + if (/\b(timestamp|datetime)\b/.test(type)) return 'datetime' + if (/\btime\b/.test(type) && !/\b(timestamp|datetime)\b/.test(type)) return 'time' + if (/\bdate\b/.test(type)) return 'date' + + return 'text' +} + +const formatDateForColumn = (value: Date, kind: EditorKind) => { + if (kind === 'date') { + return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}` + } + + if (kind === 'time') { + return `${pad(value.getHours())}:${pad(value.getMinutes())}:${pad(value.getSeconds())}` + } + + return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())} ${pad(value.getHours())}:${pad(value.getMinutes())}:${pad(value.getSeconds())}` +} + +export const normalizeCellInputValue = ({ + rawValue, + currentValue, + column, +}: { + rawValue: unknown + currentValue: unknown + column: SQLTableColumn +}) => { + const type = (column.type || '').toLowerCase() + const editorKind = getEditorKind(column) + + if (rawValue instanceof Date) { + return formatDateForColumn(rawValue, editorKind) + } + + if (rawValue === null || rawValue === undefined) { + return column.nullable !== false ? null : currentValue + } + + const normalized = String(rawValue).trim() + + if (normalized === '' && column.nullable !== false) { + return null + } + + if (typeof currentValue === 'number' || /(int|decimal|float|double)/.test(type)) { + const numericValue = Number(rawValue) + return Number.isNaN(numericValue) ? rawValue : numericValue + } + + if (typeof currentValue === 'boolean' || /(bool|tinyint\(1\))/.test(type)) { + return ['1', 'true', 'yes'].includes(normalized.toLowerCase()) + } + + return rawValue +} diff --git a/packages/frontend/src/components/table/extended-data-table.types.ts b/packages/frontend/src/components/table/extended-data-table.types.ts new file mode 100644 index 0000000..37c7aee --- /dev/null +++ b/packages/frontend/src/components/table/extended-data-table.types.ts @@ -0,0 +1,9 @@ +export type CellPosition = { + row: number + column: number +} + +export type RenderedTableRow = { + row: T + rowIndex: number +} diff --git a/packages/frontend/src/components/table/useExtendedDataTableViewport.ts b/packages/frontend/src/components/table/useExtendedDataTableViewport.ts new file mode 100644 index 0000000..2d5462c --- /dev/null +++ b/packages/frontend/src/components/table/useExtendedDataTableViewport.ts @@ -0,0 +1,252 @@ +import { computed, nextTick, onMounted, onUnmounted, ref, watch, type Ref } from 'vue' +import type { SQLTableColumn, SQLTableRowDraft } from '@/types/sql' +import type { CellPosition, RenderedTableRow } from '@/components/table/extended-data-table.types' +import { + VIRTUALIZATION_ROW_THRESHOLD, + VIRTUAL_OVERSCAN_ROWS, + VIRTUAL_ROW_HEIGHT, +} from '@/components/table/extended-data-table-utils' + +type ViewportElementRef = Ref + +export function useExtendedDataTableViewport(options: { + gridContainer: ViewportElementRef + tableHead: ViewportElementRef + cornerHeaderCell: ViewportElementRef + rows: Ref + columns: Ref + widths: Ref> + editingCell: Ref + clampCell: (cell: CellPosition) => CellPosition + hasRows: Ref +}) { + const containerScrollTop = ref(0) + const containerHeight = ref(0) + const tableHeadHeight = ref(0) + const stickyColumnWidth = ref(48) + const measuredRowHeight = ref(VIRTUAL_ROW_HEIGHT) + + let viewportSyncFrame = 0 + let resizeObserver: ResizeObserver | null = null + + const shouldVirtualizeRows = computed( + () => options.rows.value.length >= VIRTUALIZATION_ROW_THRESHOLD, + ) + + const bodyViewportHeight = computed(() => + Math.max(containerHeight.value - tableHeadHeight.value, measuredRowHeight.value), + ) + + const visibleRowRange = computed(() => { + if (!options.rows.value.length) { + return { + start: 0, + end: -1, + topSpacerHeight: 0, + bottomSpacerHeight: 0, + } + } + + if (!shouldVirtualizeRows.value) { + return { + start: 0, + end: options.rows.value.length - 1, + topSpacerHeight: 0, + bottomSpacerHeight: 0, + } + } + + const bodyScrollTop = Math.max(containerScrollTop.value - tableHeadHeight.value, 0) + const firstVisibleRow = Math.floor(bodyScrollTop / measuredRowHeight.value) + const visibleRowCount = Math.max( + 1, + Math.ceil(bodyViewportHeight.value / measuredRowHeight.value), + ) + const start = Math.max(0, firstVisibleRow - VIRTUAL_OVERSCAN_ROWS) + const end = Math.min( + options.rows.value.length - 1, + firstVisibleRow + visibleRowCount + VIRTUAL_OVERSCAN_ROWS, + ) + + return { + start, + end, + topSpacerHeight: start * measuredRowHeight.value, + bottomSpacerHeight: Math.max( + 0, + (options.rows.value.length - end - 1) * measuredRowHeight.value, + ), + } + }) + + const renderedRows = computed[]>(() => { + const { start, end } = visibleRowRange.value + if (end < start) { + return [] + } + + return options.rows.value.slice(start, end + 1).map((row, index) => ({ + row, + rowIndex: start + index, + })) + }) + + const syncViewportMetrics = () => { + viewportSyncFrame = 0 + containerScrollTop.value = options.gridContainer.value?.scrollTop ?? 0 + containerHeight.value = options.gridContainer.value?.clientHeight ?? 0 + tableHeadHeight.value = options.tableHead.value?.offsetHeight ?? 0 + stickyColumnWidth.value = options.cornerHeaderCell.value?.offsetWidth ?? 48 + + const measuredDataRow = options.gridContainer.value?.querySelector( + 'tbody tr[data-virtual-row="true"]:not([data-is-editing-row="true"])', + ) + if (measuredDataRow?.offsetHeight) { + measuredRowHeight.value = measuredDataRow.offsetHeight + } + } + + const scheduleViewportSync = () => { + if (viewportSyncFrame) { + return + } + + viewportSyncFrame = requestAnimationFrame(syncViewportMetrics) + } + + const getCellElement = (cell: CellPosition) => + options.gridContainer.value?.querySelector( + `td[data-cell-row="${cell.row}"][data-cell-column="${cell.column}"]`, + ) ?? null + + const ensureCellVisible = (cell: CellPosition) => { + const container = options.gridContainer.value + if (!container) { + return + } + + if (shouldVirtualizeRows.value) { + const rowTop = tableHeadHeight.value + cell.row * measuredRowHeight.value + const rowBottom = rowTop + measuredRowHeight.value + const viewportTop = container.scrollTop + tableHeadHeight.value + const viewportBottom = container.scrollTop + container.clientHeight + + if (rowTop < viewportTop) { + container.scrollTop = Math.max(0, rowTop - tableHeadHeight.value) + syncViewportMetrics() + } else if (rowBottom > viewportBottom) { + container.scrollTop = Math.max(0, rowBottom - container.clientHeight) + syncViewportMetrics() + } + } + + nextTick(() => { + getCellElement(cell)?.scrollIntoView({ + block: 'nearest', + inline: 'nearest', + }) + }) + } + + const getColumnIndexFromPointer = (clientX: number) => { + const container = options.gridContainer.value + if (!container || !options.columns.value.length) { + return 0 + } + + const containerRect = container.getBoundingClientRect() + const contentX = Math.max( + 0, + clientX - containerRect.left + container.scrollLeft - stickyColumnWidth.value, + ) + + let consumedWidth = 0 + for (let columnIndex = 0; columnIndex < options.columns.value.length; columnIndex += 1) { + consumedWidth += options.widths.value[options.columns.value[columnIndex].field] ?? 220 + if (contentX < consumedWidth) { + return columnIndex + } + } + + return options.columns.value.length - 1 + } + + const getCellPositionFromPointer = (clientX: number, clientY: number) => { + const container = options.gridContainer.value + if (!container || !options.hasRows.value) { + return null + } + + const containerRect = container.getBoundingClientRect() + const probeX = Math.min(Math.max(clientX, containerRect.left + 2), containerRect.right - 2) + const probeY = Math.min(Math.max(clientY, containerRect.top + 2), containerRect.bottom - 2) + const element = document.elementFromPoint(probeX, probeY) + const cell = element?.closest?.( + 'td[data-cell-row][data-cell-column]', + ) as HTMLTableCellElement | null + + if (cell) { + const row = Number(cell.dataset.cellRow) + const column = Number(cell.dataset.cellColumn) + + if (!Number.isNaN(row) && !Number.isNaN(column)) { + return options.clampCell({ row, column }) + } + } + + if (!shouldVirtualizeRows.value) { + return null + } + + const relativeY = clientY - containerRect.top + container.scrollTop - tableHeadHeight.value + const row = Math.floor(relativeY / measuredRowHeight.value) + const column = getColumnIndexFromPointer(clientX) + + return options.clampCell({ row, column }) + } + + onMounted(() => { + scheduleViewportSync() + + if (typeof ResizeObserver === 'undefined') { + return + } + + resizeObserver = new ResizeObserver(() => { + scheduleViewportSync() + }) + + if (options.gridContainer.value) { + resizeObserver.observe(options.gridContainer.value) + } + + if (options.tableHead.value) { + resizeObserver.observe(options.tableHead.value) + } + }) + + onUnmounted(() => { + if (viewportSyncFrame) { + cancelAnimationFrame(viewportSyncFrame) + } + + resizeObserver?.disconnect() + }) + + watch( + [() => options.rows.value.length, () => options.columns.value.length, () => options.editingCell.value], + () => { + scheduleViewportSync() + }, + ) + + return { + shouldVirtualizeRows, + visibleRowRange, + renderedRows, + measuredRowHeight, + scheduleViewportSync, + ensureCellVisible, + getCellPositionFromPointer, + } +}