From a25bd5441f1e445f10d667ab1ed70dc2d61734ea Mon Sep 17 00:00:00 2001 From: zipg Date: Sun, 28 Jun 2026 02:09:31 +0800 Subject: [PATCH] feat(grid): add expanded overflow cell editor --- apps/desktop/src/components/grid/DataGrid.vue | 354 +++++++++++++++++- .../src/composables/useDataGridEditor.ts | 14 +- 2 files changed, 355 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index b9ca3a094c..3554ea7f74 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -323,6 +323,7 @@ const dataGridRenderMode = computed(() => settingsStore.editorSettings.dataGridR const compactDataGridToolbar = computed(() => dataGridTopbarWidth.value > 0 && dataGridTopbarWidth.value < DATA_GRID_COMPACT_TOPBAR_WIDTH); const infiniteScrollEnabled = computed(() => settingsStore.editorSettings.infiniteScroll); const infiniteScrollMaxRows = computed(() => settingsStore.editorSettings.infiniteScrollMaxRows); +const expandedCellEditor = ref<{ rowId: number; col: number } | null>(null); function headerColumnComment(column: string): string { if (!showColumnCommentsInHeader.value) return ""; @@ -1631,6 +1632,8 @@ const gridVerticalScrollbarDragging = ref(false); let gridHorizontalScrollbarFrame = 0; let gridHorizontalScrollbarResizeObserver: ResizeObserver | null = null; let dataGridTopbarResizeObserver: ResizeObserver | null = null; +let cellEditResizeObserver: ResizeObserver | null = null; +let resetCellEditTextareaScrollOnResize = false; let gridHorizontalScrollbarDragState: { trackRect: DOMRect; thumbOffsetPx: number; @@ -2877,6 +2880,56 @@ function canEditCellItem(item: RowItem | undefined, columnIndex: number): boolea return true; } +function cellUsesExpandedEditor(rowId: number | undefined, columnIndex: number): boolean { + return !!expandedCellEditor.value && expandedCellEditor.value.rowId === rowId && expandedCellEditor.value.col === columnIndex; +} + +function startCellEdit(rowId: number, columnIndex: number, expanded: boolean) { + expandedCellEditor.value = expanded ? { rowId, col: columnIndex } : null; + startEdit(rowId, columnIndex); +} + +function startDomCellEdit(rowId: number, columnIndex: number, displayText: string, event: MouseEvent) { + const item = getRowItem(rowId); + const editText = item ? cellEditorTextForValue(item.data[columnIndex], columnIndex) : displayText; + startCellEdit(rowId, columnIndex, cellEditContentNeedsExpandedEditor({ displayText, editText, target: event.currentTarget })); +} + +function cellEditContentNeedsExpandedEditor(options: { displayText: string; editText: string; target: EventTarget | null }): boolean { + const text = options.editText || options.displayText; + if (text.includes("\n") || text.includes("\r")) return true; + if (text.length > options.displayText.length) return true; + return cellTextOverflowsElement(options.displayText, options.target); +} + +function cellEditorTextForValue(value: CellValue | undefined, columnIndex: number): string { + return dataGridCellEditorText({ + value: value ?? null, + databaseType: props.databaseType, + columnInfo: tableColumnForGridColumn(columnIndex), + }); +} + +function cellTextOverflowsElement(text: string, target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false; + const style = window.getComputedStyle(target); + const paddingLeft = Number.parseFloat(style.paddingLeft) || 0; + const paddingRight = Number.parseFloat(style.paddingRight) || 0; + const availableWidth = Math.max(0, target.clientWidth - paddingLeft - paddingRight - 2); + return measureCellTextWidth(text, style.font) > availableWidth; +} + +function measureCellTextWidth(text: string, font: string): number { + const canvas = canvasRef.value ?? document.createElement("canvas"); + const context = canvas.getContext("2d"); + if (!context) return 0; + context.save(); + context.font = font; + const width = context.measureText(text).width; + context.restore(); + return width; +} + function tableColumnForGridColumn(columnIndex: number): ColumnInfo | undefined { const columnName = props.sourceColumns?.[columnIndex] ?? props.result.columns[columnIndex]; if (!columnName) return undefined; @@ -2899,6 +2952,25 @@ function isEnumGridColumnNullable(columnIndex: number): boolean { return tableColumnForGridColumn(columnIndex)?.is_nullable ?? false; } +function cellEditInputModeForColumn(columnIndex: number): "decimal" | "numeric" | undefined { + const dataType = normalizedColumnDataType(tableColumnForGridColumn(columnIndex)); + if (isIntegerColumnType(dataType)) return "numeric"; + if (isDecimalColumnType(dataType)) return "decimal"; + return undefined; +} + +function normalizedColumnDataType(column: ColumnInfo | undefined): string { + return (column?.data_type ?? "").trim().toLowerCase(); +} + +function isIntegerColumnType(dataType: string): boolean { + return /^(tinyint|smallint|mediumint|int|integer|bigint|serial|smallserial|bigserial|int2|int4|int8|uint|uint8|uint16|uint32|uint64)\b/.test(dataType); +} + +function isDecimalColumnType(dataType: string): boolean { + return /^(decimal|numeric|number|float|double|real|money|smallmoney|dec|fixed)\b/.test(dataType); +} + function canDeleteRowItem(item: RowItem | undefined): boolean { return !!props.editable && !!item && !item.isDeleted && (item.isNew || canEditExistingRows.value); } @@ -4351,7 +4423,17 @@ function onCanvasDblClick(event: MouseEvent) { const item = displayItemAt(hit.rowIndex); const actualColIdx = visibleColumnIndexes.value[hit.visibleColIdx]; if (!item || actualColIdx === undefined || !canEditCellItem(item, actualColIdx)) return; - startEdit(item.id, actualColIdx); + startCellEdit(item.id, actualColIdx, canvasCellContentOverflows(item, actualColIdx, hit.visibleColIdx)); +} + +function canvasCellContentOverflows(item: RowItem, actualColIdx: number, visibleColIdx: number): boolean { + const cellWidth = renderedColumnWidths.value[visibleColIdx] ?? 0; + if (cellWidth <= 0) return false; + const displayText = formatCellCached(item.data[actualColIdx], actualColIdx); + const editText = cellEditorTextForValue(item.data[actualColIdx], actualColIdx); + if (editText.includes("\n") || editText.includes("\r") || editText.length > displayText.length) return true; + const textWidth = measureCellTextWidth(displayText, `400 13px ${settingsStore.editorSettings.fontFamily}`); + return textWidth > Math.max(0, cellWidth - 24); } function canvasCellViewportRect(rowIndex: number, visibleColIdx: number) { @@ -4400,7 +4482,7 @@ const canvasEditingCell = computed(() => { if (rowIndex < 0 || visibleColIdx < 0) return null; const rect = canvasCellViewportRect(rowIndex, visibleColIdx); if (!rect) return null; - return { rowIndex, visibleColIdx, actualColIdx: editing.col, rect }; + return { rowId: editing.rowId, rowIndex, visibleColIdx, actualColIdx: editing.col, rect }; }); function canvasEffectiveViewportWidth(): number { @@ -4532,6 +4614,7 @@ function pauseCanvasGridWork() { dataGridIsActive = false; canvasResizeObserver?.disconnect(); canvasResizeObserver = null; + disconnectCellEditResizeObserver(); dataGridTopbarResizeObserver?.disconnect(); dataGridTopbarResizeObserver = null; canvasPixelRatioMediaQueryCleanup?.(); @@ -4570,6 +4653,7 @@ onUnmounted(() => { pauseCanvasGridWork(); gridHorizontalScrollbarResizeObserver?.disconnect(); dataGridTopbarResizeObserver?.disconnect(); + disconnectCellEditResizeObserver(); stopColumnHeaderDrag(false); stopGridHorizontalScrollbarDrag(); stopGridVerticalScrollbarDrag(); @@ -5879,6 +5963,130 @@ function onCellContext(rowId: number, rowIndex: number, colIdx: number, visibleC void prefetchCopyStatements(); } +function onCellEditTextareaInput(event: Event) { + resetCellEditTextareaScrollOnResize = false; + const input = event.currentTarget as HTMLInputElement | HTMLTextAreaElement | null; + if (input instanceof HTMLTextAreaElement) { + resizeCellEditTextareaElement(input); + scheduleCellEditTextareaResize(input); + } +} + +function onCellEditTextareaPaste(event: ClipboardEvent) { + const input = event.currentTarget as HTMLInputElement | HTMLTextAreaElement | null; + if (input instanceof HTMLTextAreaElement) scheduleCellEditTextareaResize(input); +} + +function resizeCellEditTextareaElement(textarea: HTMLTextAreaElement | null) { + if (!textarea) return; + const textareaRect = textarea.getBoundingClientRect(); + const visibleBottom = cellEditVisibleBottom(textarea); + const availableHeight = Math.max(36, Math.floor(visibleBottom - textareaRect.top - 10)); + const metrics = cellEditTextMetrics(textarea); + const maxVisibleHeight = Math.ceil(metrics.lineHeight * 9.5 + metrics.verticalChrome); + const maxHeight = Math.min(maxVisibleHeight, availableHeight); + const naturalHeight = cellEditNaturalHeight(textarea, metrics); + const targetMinHeight = Math.max(64, Math.min(naturalHeight, 120)); + const minHeight = Math.min(targetMinHeight, maxHeight); + textarea.style.setProperty("--cell-edit-min-height", `${minHeight}px`); + textarea.style.setProperty("--cell-edit-max-height", `${maxHeight}px`); + textarea.style.height = "auto"; + textarea.style.height = `${Math.max(minHeight, Math.min(textarea.scrollHeight, maxHeight))}px`; + if (resetCellEditTextareaScrollOnResize) { + textarea.scrollTop = 0; + textarea.setSelectionRange?.(0, 0); + } +} + +function cellEditTextMetrics(textarea: HTMLTextAreaElement): { lineHeight: number; verticalChrome: number } { + const computedStyle = window.getComputedStyle(textarea); + const lineHeight = Number.parseFloat(computedStyle.lineHeight) || 18; + const paddingTop = Number.parseFloat(computedStyle.paddingTop) || 0; + const paddingBottom = Number.parseFloat(computedStyle.paddingBottom) || 0; + const borderTop = Number.parseFloat(computedStyle.borderTopWidth) || 0; + const borderBottom = Number.parseFloat(computedStyle.borderBottomWidth) || 0; + return { lineHeight, verticalChrome: paddingTop + paddingBottom + borderTop + borderBottom }; +} + +function cellEditNaturalHeight(textarea: HTMLTextAreaElement, metrics = cellEditTextMetrics(textarea)): number { + const lines = Math.max(1, textarea.value.split(/\r\n|\r|\n/).length); + return Math.ceil(lines * metrics.lineHeight + metrics.verticalChrome); +} + +function cellEditScrollerElement(textarea: HTMLTextAreaElement): HTMLElement | null { + return textarea.closest(".data-grid-scroller, .transpose-grid-scroller") as HTMLElement | null; +} + +function cellEditVisibleBottom(textarea: HTMLTextAreaElement): number { + const bottoms: number[] = []; + const scroller = cellEditScrollerElement(textarea); + const root = gridRef.value; + if (scroller) bottoms.push(scroller.getBoundingClientRect().bottom); + if (root) bottoms.push(root.getBoundingClientRect().bottom); + if (typeof window !== "undefined") bottoms.push(window.innerHeight); + + if (cellDetailPanelIsBottom.value && showCellDetail.value) { + const detailPanel = root?.querySelector("[data-cell-detail-panel]"); + if (detailPanel) bottoms.push(detailPanel.getBoundingClientRect().top); + } + + return Math.min(...bottoms.filter((bottom) => Number.isFinite(bottom))); +} + +function scheduleCellEditTextareaResize(textarea: HTMLTextAreaElement | null) { + if (!textarea || typeof requestAnimationFrame !== "function") return; + requestAnimationFrame(() => { + resizeCellEditTextareaElement(textarea); + requestAnimationFrame(() => resizeCellEditTextareaElement(textarea)); + }); +} + +function resizeActiveCellEditTextarea() { + const textarea = gridRef.value?.querySelector(".cell-edit-input--expanded"); + resizeCellEditTextareaElement(textarea ?? null); +} + +function disconnectCellEditResizeObserver() { + cellEditResizeObserver?.disconnect(); + cellEditResizeObserver = null; +} + +function observeCellEditResizeBounds() { + disconnectCellEditResizeObserver(); + if (!editingCell.value || typeof ResizeObserver === "undefined") return; + const textarea = gridRef.value?.querySelector(".cell-edit-input--expanded"); + const scroller = textarea ? cellEditScrollerElement(textarea) : null; + if (!textarea || !scroller) return; + cellEditResizeObserver = new ResizeObserver(scheduleActiveCellEditTextareaResize); + cellEditResizeObserver.observe(scroller); + cellEditResizeObserver.observe(textarea); + if (gridRef.value) cellEditResizeObserver.observe(gridRef.value); +} + +function scheduleActiveCellEditTextareaResize() { + nextTick(() => { + resizeActiveCellEditTextarea(); + if (typeof requestAnimationFrame === "function") { + requestAnimationFrame(() => { + resizeActiveCellEditTextarea(); + requestAnimationFrame(resizeActiveCellEditTextarea); + }); + } + }); +} + +watch(editingCell, (cell) => { + resetCellEditTextareaScrollOnResize = !!cell; + scheduleActiveCellEditTextareaResize(); + if (cell) nextTick(observeCellEditResizeBounds); + else { + resetCellEditTextareaScrollOnResize = false; + expandedCellEditor.value = null; + disconnectCellEditResizeObserver(); + } +}); +watch(editValue, scheduleActiveCellEditTextareaResize); + function onRowContext(rowId: number, rowIndex: number) { contextHeaderColumn.value = null; contextHeaderColumnIndex.value = null; @@ -5969,13 +6177,17 @@ watch( () => settingsStore.editorSettings.cellDetailDrawerWidth, (height) => { if (!isResizingDetail.value) detailPanelHeight.value = clampCellDetailPanelSize(height); + scheduleActiveCellEditTextareaResize(); }, ); watch(cellDetailPanelLayout, (layout) => { if (!isResizingDetail.value) detailPanelHeight.value = clampCellDetailPanelSize(detailPanelHeight.value, layout); + scheduleActiveCellEditTextareaResize(); }); +watch([showCellDetail, activeCellDetail, cellDetailPanelIsBottom, detailPanelHeight], scheduleActiveCellEditTextareaResize); + const ddlDrawerStyle = computed(() => ({ width: `${ddlWidth.value}px`, })); @@ -6608,7 +6820,7 @@ const gridContextMenuItems = computed(() => {