diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 43cb8263e1..cc7d37b8a5 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -245,7 +245,7 @@ import { type DataGridScrollPosition, } from "@/lib/dataGrid/dataGridInfiniteScroll"; import { resolveDataGridWheelScroll } from "@/lib/dataGrid/dataGridWheel"; -import { CANVAS_DATA_GRID_ROW_HEIGHT, MAX_CANVAS_DATA_GRID_PIXEL_RATIO, canvasDataGridActionOverlayWidth, canvasDataGridActionReservedWidth, dataGridSearchMatchKey, drawCanvasDataGrid, type CanvasDevicePixelSize } from "@/lib/dataGrid/canvasDataGridRenderer"; +import { CANVAS_DATA_GRID_ROW_HEIGHT, MAX_CANVAS_DATA_GRID_PIXEL_RATIO, canvasDataGridActionOverlayWidth, canvasDataGridActionReservedWidth, dataGridSearchMatchKey, drawCanvasDataGrid, resolveCanvasCellTextLayout, type CanvasDevicePixelSize } from "@/lib/dataGrid/canvasDataGridRenderer"; import { resolveCrosshairTarget, type CrosshairTarget } from "@/lib/dataGrid/crosshairHighlight"; import { DATA_GRID_DARK_STRIPED_ROW_BG, DATA_GRID_LIGHT_STRIPED_ROW_BG, dataGridActiveRowBackground } from "@/lib/dataGrid/dataGridPaintTheme"; import { createRowLowerTextCache } from "@/lib/dataGrid/dataGridRowLowerText"; @@ -3451,6 +3451,7 @@ function onScrollerScroll(e: Event) { } else { syncHeaderScroll(e); } + if (editingCell.value) scheduleActiveCellEditTextareaResize(); markGridScrolling(); } @@ -4420,6 +4421,7 @@ async function startCellEdit(rowId: number, columnIndex: number, expanded: boole } async function startDomCellEdit(rowId: number, columnIndex: number, displayText: string, event: MouseEvent) { + const target = event.currentTarget; if (!(await hydrateLargeValueCell(rowId, columnIndex))) return; const item = getRowItem(rowId); const editText = item ? cellEditorTextForValue(item.data[columnIndex], columnIndex) : displayText; @@ -4429,7 +4431,7 @@ async function startDomCellEdit(rowId: number, columnIndex: number, displayText: cellEditContentNeedsExpandedEditor({ displayText, editText, - target: event.currentTarget, + target, }), ); } @@ -7409,6 +7411,7 @@ function onCanvasScroll(event: Event) { } if (headerRef.value && headerRef.value.scrollLeft !== scrollLeft) headerRef.value.scrollLeft = scrollLeft; recordScrollPosition({ top: scrollTop, left: scrollLeft }); + if (editingCell.value) scheduleActiveCellEditTextareaResize(); markGridScrolling(); scheduleCanvasDraw(); } @@ -7643,8 +7646,29 @@ function canvasCellContentOverflows(item: RowItem, actualColIdx: number, visible 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 ${tableFontSize.value}px ${tableFontFamily.value}`); - return textWidth > Math.max(0, cellWidth - 24); + const canvas = activeCanvasSurface(); + const context = canvas?.getContext("2d"); + if (!context) { + const textWidth = measureCellTextWidth(displayText, `400 ${tableFontSize.value}px ${tableFontFamily.value}`); + return textWidth > Math.max(0, cellWidth - 24); + } + + const isRightAlign = columnAligns.value[visibleColIdx] === "right"; + const actionCell = canvasRightAlignedActionCell.value; + const reservedWidth = actionCell?.rowIndex === item.displayIndex && actionCell.visibleColIdx === visibleColIdx ? actionCell.reservedWidth : 0; + const { maxWidth } = resolveCanvasCellTextLayout({ + drawX: 0, + colWidth: cellWidth, + dpr: 1, + isRightAlign, + reservedWidth, + }); + context.save(); + context.font = `400 ${tableFontSize.value}px ${tableFontFamily.value}`; + if ("fontVariantNumeric" in context) (context as CanvasRenderingContext2D & { fontVariantNumeric?: string }).fontVariantNumeric = "tabular-nums"; + const textWidth = context.measureText(displayText).width; + context.restore(); + return textWidth > maxWidth; } function canvasCellViewportRect(rowIndex: number, visibleColIdx: number) { @@ -8511,10 +8535,11 @@ function showTransposeCellDetails(rowIndex: number, actualColIdx: number) { } async function onTransposeCellDblClick(rowIndex: number, actualColIdx: number, displayText: string, event: MouseEvent) { + const target = event.currentTarget; const item = displayItemAt(rowIndex); if (!item) return; if (!canEditCellItem(item, actualColIdx)) { - await startReadonlyCellTextSelection(item.id, actualColIdx, displayText, cellEditContentNeedsExpandedEditor({ displayText, editText: cellEditorTextForValue(item.data[actualColIdx], actualColIdx), target: event.currentTarget })); + await startReadonlyCellTextSelection(item.id, actualColIdx, displayText, cellEditContentNeedsExpandedEditor({ displayText, editText: cellEditorTextForValue(item.data[actualColIdx], actualColIdx), target })); return; } await startDomCellEdit(item.id, actualColIdx, displayText, event); @@ -10490,19 +10515,45 @@ function onCellEditTextareaPaste(event: ClipboardEvent) { function resizeCellEditTextareaElement(textarea: HTMLTextAreaElement | null) { if (!textarea) return; - const textareaRect = textarea.getBoundingClientRect(); + // The result grid is a scroll container, so an absolutely positioned editor + // cannot escape its bottom clipping edge. Use the cell's viewport rect as the + // anchor and position the editor itself in the viewport instead. + const anchorRect = textarea.parentElement?.getBoundingClientRect(); + if (!anchorRect) return; const visibleBottom = cellEditVisibleBottom(textarea); - const availableHeight = Math.max(36, Math.floor(visibleBottom - textareaRect.top - 10)); + const visibleTop = cellEditVisibleTop(textarea); 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 preferredHeight = Math.min(maxVisibleHeight, Math.max(targetMinHeight, naturalHeight)); + const spaceBelow = Math.max(0, Math.floor(visibleBottom - anchorRect.top - 10)); + const spaceAbove = Math.max(0, Math.floor(anchorRect.bottom - visibleTop - 10)); + const placeAbove = spaceBelow < preferredHeight && spaceAbove > spaceBelow; + const availableHeight = placeAbove ? spaceAbove : spaceBelow; + const maxHeight = Math.min(maxVisibleHeight, Math.max(36, availableHeight)); const minHeight = Math.min(targetMinHeight, maxHeight); + const height = Math.max(minHeight, Math.min(textarea.scrollHeight, maxHeight)); + const top = placeAbove ? Math.max(visibleTop, Math.min(anchorRect.top, visibleBottom - height)) : anchorRect.top; + const horizontalInset = 7; + const desiredWidth = Math.max(0, anchorRect.width - horizontalInset * 2); + const maxViewportWidth = Math.max(0, window.innerWidth - horizontalInset * 2); + const width = Math.min(desiredWidth, maxViewportWidth); + const left = Math.min(Math.max(horizontalInset, anchorRect.left + horizontalInset), Math.max(horizontalInset, window.innerWidth - width - horizontalInset)); textarea.style.setProperty("--cell-edit-min-height", `${minHeight}px`); textarea.style.setProperty("--cell-edit-max-height", `${maxHeight}px`); + textarea.style.left = `${left}px`; + textarea.style.width = `${width}px`; textarea.style.height = "auto"; - textarea.style.height = `${Math.max(minHeight, Math.min(textarea.scrollHeight, maxHeight))}px`; + textarea.style.height = `${height}px`; + textarea.style.top = `${top}px`; + // Virtualized and contained scrollers can establish a fixed-position + // containing block in either viewport or scrolled-content coordinates. Use + // the rendered rect to correct that implementation detail back to viewport + // coordinates instead of guessing from the ancestor tree. + const positionedRect = textarea.getBoundingClientRect(); + textarea.style.left = `${left + (left - positionedRect.left)}px`; + textarea.style.top = `${top + (top - positionedRect.top)}px`; if (resetCellEditTextareaScrollOnResize) { textarea.scrollTop = 0; textarea.setSelectionRange?.(0, 0); @@ -10550,6 +10601,16 @@ function cellEditVisibleBottom(textarea: HTMLTextAreaElement): number { return Math.min(...bottoms.filter((bottom) => Number.isFinite(bottom))); } +function cellEditVisibleTop(textarea: HTMLTextAreaElement): number { + const tops: number[] = []; + const scroller = cellEditScrollerElement(textarea); + const root = gridRef.value; + if (scroller) tops.push(scroller.getBoundingClientRect().top); + if (root) tops.push(root.getBoundingClientRect().top); + if (typeof window !== "undefined") tops.push(0); + return Math.max(...tops.filter((top) => Number.isFinite(top))); +} + function scheduleCellEditTextareaResize(textarea: HTMLTextAreaElement | null) { if (!textarea || typeof requestAnimationFrame !== "function") return; requestAnimationFrame(() => { @@ -12731,6 +12792,7 @@ function openGridSnapshot() { @input="onCellEditTextareaInput" @keydown.stop="onCellEditKeydown" @paste.stop="onCellEditTextareaPaste" + @wheel.stop />