diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 3d7dc43862..727e0c06bd 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -135,7 +135,7 @@ import { canFormatCellDetailJson, cellDetailEditorText, compactJsonText, default import { buildDataGridCellDetail, buildDataGridColumnDetail, buildDataGridRowDetail, CELL_DETAIL_VALUE_PREVIEW_MAX_LENGTH, dataGridColumnDetailJson, dataGridColumnDetailTsv, dataGridRowDetailJson, dataGridRowDetailTsv, type DataGridCellDetail } from "@/lib/dataGrid/dataGridDetail"; import { applyColumnFormatter, buildColumnFormatterKey, getSupportedTimeZoneOptions, normalizeColumnFormatter, resolveColumnFormatter, type ColumnFormatterConfig, type DateTimeFormatterUnit, DateTimePatterns } from "@/lib/dataGrid/columnFormatter"; import { temporalCellEditorConfig, type TemporalCellEditorConfig } from "@/lib/dataGrid/dataGridTemporalEditor"; -import { isBooleanCheckboxValue, isBooleanColumnType, isPointInBooleanCheckbox, normalizeBooleanCellValue } from "@/lib/dataGrid/dataGridBooleanColumn"; +import { BOOLEAN_CELL_EDITOR_VALUES, booleanCellEditorValue, isBooleanCellValue, isBooleanColumnType, parseBooleanCellEditorValue } from "@/lib/dataGrid/dataGridBooleanColumn"; import { resolveDataGridColumnsByResultIndex } from "@/lib/dataGrid/dataGridColumnMetadata"; import { isCancelSearchShortcut, isCopyCurrentRowShortcut, isDeleteCurrentRowShortcut, isFocusSearchShortcut, isModRShortcut, isSaveShortcut, isToggleTransposeShortcut } from "@/lib/editor/keyboardShortcuts"; import { dataGridHeaderContentWidth, scrollbarGutterWidth } from "@/lib/dataGrid/dataGridScrollGutter"; @@ -2968,7 +2968,6 @@ const { startEdit, commitEdit, commitEditAndMaybeAutoSave, - cycleBooleanCellValue, commitEditFromBlur, applyCellValue, restoreCellValue, @@ -3146,7 +3145,6 @@ function showReadonlyCellDetailsOnDblClick(item: RowItem, rowIndex: number, visi } function onDomCellDblClick(item: RowItem, rowIndex: number, visibleColIdx: number, actualColIdx: number, event: MouseEvent) { - if (isBooleanGridCell(item, actualColIdx) && canEditCellItem(item, actualColIdx)) return; if (showReadonlyCellDetailsOnDblClick(item, rowIndex, visibleColIdx, actualColIdx)) return; startDomCellEdit(item.id, actualColIdx, formatCellCached(item.data[actualColIdx], actualColIdx), event); } @@ -3186,19 +3184,6 @@ function measureCellTextWidth(text: string, font: string): number { return width; } -const cellTextWidthCache = new Map(); - -function measureCellTextWidthCached(text: string, font: string): number { - const key = `${font}|${text}`; - let width = cellTextWidthCache.get(key); - if (width === undefined) { - width = measureCellTextWidth(text, font); - if (cellTextWidthCache.size >= 200) cellTextWidthCache.clear(); - cellTextWidthCache.set(key, width); - } - return width; -} - const tableColumnsByResultIndex = computed(() => resolveDataGridColumnsByResultIndex({ resultColumns: props.result.columns, @@ -3232,7 +3217,7 @@ function isEnumGridColumnNullable(columnIndex: number): boolean { return tableColumnForGridColumn(columnIndex)?.is_nullable ?? false; } -function isEnumEditorInitialNull(rowId: number | undefined, columnIndex: number): boolean { +function isGridCellInitialNull(rowId: number | undefined, columnIndex: number): boolean { if (rowId === undefined) return false; return getRowItem(rowId)?.data[columnIndex] === null; } @@ -3249,22 +3234,19 @@ function isBooleanGridColumn(columnIndex: number): boolean { } function isBooleanGridCell(item: RowItem | undefined, columnIndex: number): boolean { - return !!item && isBooleanGridColumn(columnIndex) && isBooleanCheckboxValue(item.data[columnIndex]); + return !!item && isBooleanGridColumn(columnIndex) && isBooleanCellValue(item.data[columnIndex]); } function isBooleanGridColumnNullable(columnIndex: number): boolean { return tableColumnForGridColumn(columnIndex)?.is_nullable ?? true; } -function booleanCellChecked(value: unknown): boolean { - return normalizeBooleanCellValue(value) === true; -} - -function cycleBooleanGridCell(item: RowItem | undefined, actualColIdx: number, event: MouseEvent) { - if (!item || !isBooleanGridCell(item, actualColIdx) || !canEditCellItem(item, actualColIdx)) return; - event.stopPropagation(); - void cycleBooleanCellValue(item.id, actualColIdx, isBooleanGridColumnNullable(actualColIdx)); -} +const booleanEditorModelValue = computed({ + get: () => booleanCellEditorValue(editValue.value), + set: (value: string) => { + editValue.value = value; + }, +}); function cellEditInputModeForColumn(columnIndex: number): "decimal" | "numeric" | undefined { const dataType = normalizedColumnDataType(tableColumnForGridColumn(columnIndex)); @@ -5079,8 +5061,7 @@ function onCanvasMouseMove(event: MouseEvent) { const next = hit && hitItem ? { rowIndex: hitItem.displayIndex, visibleColIdx: hit.rowNumber ? -1 : hit.visibleColIdx } : null; const actualColIdx = next ? visibleColumnIndexes.value[next.visibleColIdx] : undefined; if (canvasRef.value) { - const overBooleanInteractive = hit != null && !hit.rowNumber && hitItem != null && actualColIdx !== undefined && isBooleanGridCell(hitItem, actualColIdx) && canEditCellItem(hitItem, actualColIdx) && booleanInteractiveHitFromCanvasEvent(hitItem, hit, actualColIdx, event); - canvasRef.value.style.cursor = hit?.rowNumber ? "default" : overBooleanInteractive ? "pointer" : hitItem && actualColIdx !== undefined && canEditCellItem(hitItem, actualColIdx) ? "text" : "cell"; + canvasRef.value.style.cursor = hit?.rowNumber ? "default" : hitItem && actualColIdx !== undefined && canEditCellItem(hitItem, actualColIdx) ? "text" : "cell"; } if (next?.rowIndex === canvasHoverCell.value?.rowIndex && next?.visibleColIdx === canvasHoverCell.value?.visibleColIdx) { return; @@ -5123,43 +5104,6 @@ function clearCanvasDetailHover(event?: MouseEvent) { onCanvasMouseLeave(); } -function booleanCheckboxHitFromCanvasEvent(item: RowItem, hit: { rowIndex: number; visibleColIdx: number }, actualColIdx: number, event: MouseEvent): boolean { - if (item.data[actualColIdx] === null) return false; - const canvas = canvasRef.value; - const canvasRect = canvas?.getBoundingClientRect(); - const cellRect = canvasCellViewportRect(hit.rowIndex, hit.visibleColIdx); - if (!canvasRect || !cellRect) return false; - return isPointInBooleanCheckbox({ x: event.clientX - canvasRect.left, y: event.clientY - canvasRect.top }, { left: cellRect.left, top: cellRect.top, width: cellRect.width, height: cellRect.height }); -} - -function booleanNullTextHitFromCanvasEvent(item: RowItem, hit: { rowIndex: number; visibleColIdx: number }, actualColIdx: number, event: MouseEvent): boolean { - if (item.data[actualColIdx] !== null) return false; - const canvas = canvasRef.value; - const canvasRect = canvas?.getBoundingClientRect(); - const cellRect = canvasCellViewportRect(hit.rowIndex, hit.visibleColIdx); - if (!canvasRect || !cellRect) return false; - const text = firstLineCellDisplayValue(formatCellCached(item.data[actualColIdx], actualColIdx)); - if (!text) return false; - const textWidth = measureCellTextWidthCached(text, `italic 400 ${tableFontSize.value}px ${tableFontFamily.value}`); - if (textWidth <= 0) return false; - const left = cellRect.left + (cellRect.width - textWidth) / 2 - 2; - const right = left + textWidth + 4; - const x = event.clientX - canvasRect.left; - const y = event.clientY - canvasRect.top; - return x >= left && x <= right && y >= cellRect.top && y <= cellRect.top + cellRect.height; -} - -function booleanInteractiveHitFromCanvasEvent(item: RowItem, hit: { rowIndex: number; visibleColIdx: number }, actualColIdx: number, event: MouseEvent): boolean { - return item.data[actualColIdx] === null ? booleanNullTextHitFromCanvasEvent(item, hit, actualColIdx, event) : booleanCheckboxHitFromCanvasEvent(item, hit, actualColIdx, event); -} - -function tryCycleBooleanCheckboxOnCanvasMouseDown(item: RowItem, hit: { rowIndex: number; visibleColIdx: number }, actualColIdx: number, event: MouseEvent): boolean { - if (!isBooleanGridCell(item, actualColIdx) || !canEditCellItem(item, actualColIdx)) return false; - if (!booleanInteractiveHitFromCanvasEvent(item, hit, actualColIdx, event)) return false; - void cycleBooleanCellValue(item.id, actualColIdx, isBooleanGridColumnNullable(actualColIdx)); - return true; -} - function onCanvasMouseDown(event: MouseEvent) { if (event.button !== 0) return; const hit = canvasHitTest(event); @@ -5176,7 +5120,6 @@ function onCanvasMouseDown(event: MouseEvent) { onRowNumberMouseDown(item, event); } else { handleDataCellMousedown(item.displayIndex, hit.visibleColIdx, item.id, event); - if (actualColIdx !== undefined) tryCycleBooleanCheckboxOnCanvasMouseDown(item, hit, actualColIdx, event); } gridRef.value?.focus({ preventScroll: true }); scheduleCanvasDraw(); @@ -5209,7 +5152,6 @@ function onCanvasDblClick(event: MouseEvent) { const actualColIdx = visibleColumnIndexes.value[hit.visibleColIdx]; if (!item || actualColIdx === undefined) return; if (showReadonlyCellDetailsOnDblClick(item, item.displayIndex, hit.visibleColIdx, actualColIdx)) return; - if (isBooleanGridCell(item, actualColIdx) && canEditCellItem(item, actualColIdx)) return; startCellEdit(item.id, actualColIdx, canvasCellContentOverflows(item, actualColIdx, hit.visibleColIdx)); } @@ -5375,7 +5317,6 @@ function drawCanvasGrid() { searchMatchKeys: searchMatchSet.value, currentSearchMatch: currentSearchMatch.value, formatCell: formatCellCached, - columnIsBoolean: isBooleanGridColumn, draftCellPlaceholder: t("grid.quickEntryDraftPlaceholder"), isRowActive, rowCellsUseSelectionVisual, @@ -5629,7 +5570,7 @@ function copyExtractorLabel(extractor: DataGridCopyExtractorId): string { "csv-with-headers": t("grid.copyExtractorWithHeaders", { format: "CSV" }), "pipe-separated": t("grid.copyExtractorPipeSeparated"), dsv: "DSV", - json: "JSON", + json: t("grid.copyExtractorJsonArray"), "json-lines": "JSON Lines", "one-row": t("grid.copyExtractorOneRow"), "sql-in-list": t("grid.copyExtractorSqlInList"), @@ -5900,7 +5841,6 @@ function onTransposeCellDblClick(rowIndex: number, actualColIdx: number, display showTransposeCellDetails(rowIndex, actualColIdx); return; } - if (isBooleanGridCell(item, actualColIdx)) return; startDomCellEdit(item.id, actualColIdx, displayText, event); } @@ -6401,10 +6341,6 @@ function editSelectedCell(): boolean { const item = displayItemAt(position.rowIndex); const actualColIndex = actualColumnIndex(position.colIndex); if (!item || !canEditCellItem(item, actualColIndex)) return false; - if (isBooleanGridCell(item, actualColIndex)) { - void cycleBooleanCellValue(item.id, actualColIndex, isBooleanGridColumnNullable(actualColIndex)); - return true; - } startEdit(item.id, actualColIndex); return true; } @@ -6444,10 +6380,23 @@ function deleteCurrentRow(): boolean { return true; } -function commitGridEdit(value?: string | null) { +function commitGridEdit(value?: CellValue) { void commitEditAndMaybeAutoSave(value === undefined ? undefined : { explicitValue: value }).finally(() => nextTick(() => gridRef.value?.focus({ preventScroll: true }))); } +function commitBooleanGridEdit(value?: string | null) { + if (value === undefined) { + commitGridEdit(); + return; + } + const parsedValue = parseBooleanCellEditorValue(value); + if (parsedValue === undefined) { + commitGridEdit(); + return; + } + commitGridEdit(parsedValue); +} + async function commitEditFromCellBlur() { const target = pendingQuickEntryDraftCellFocus.value; pendingQuickEntryDraftCellFocus.value = null; @@ -8275,46 +8224,66 @@ function filterSubmenu(): ContextMenuItem { function buildExtractorContextItems(): ContextMenuItem[] { const items: ContextMenuItem[] = []; + let separatorPending = false; for (const extractor of DATA_GRID_COPY_EXTRACTOR_IDS) { const descriptor = DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS[extractor]; if (descriptor.separatorBefore) { - items.push({ label: "", separator: true }); + separatorPending = items.length > 0; } const selected = extractor === selectedCopyExtractor.value; + const extractorItems: ContextMenuItem[] = []; if (extractor === "sql-inserts") { for (const excludePrimaryKeysFromInsert of [false, true]) { + if (selected && settingsStore.editorSettings.dataGridExtractorOptions.sql.excludePrimaryKeysFromInsert === excludePrimaryKeysFromInsert) continue; const options = sqlInsertExtractorOptions(excludePrimaryKeysFromInsert); - items.push({ + extractorItems.push({ label: t(excludePrimaryKeysFromInsert ? "grid.copyExtractorSqlInsertsWithoutPrimaryKeys" : "grid.copyExtractorSqlInsertsWithPrimaryKeys"), action: () => void copyWithExtractor(extractor, options), disabled: !canCopyWithExtractor(extractor, options), - checked: selected && settingsStore.editorSettings.dataGridExtractorOptions.sql.excludePrimaryKeysFromInsert === excludePrimaryKeysFromInsert, }); } - continue; + } else if (!selected) { + extractorItems.push({ + label: copyExtractorLabel(extractor), + action: () => { + // One-off copy as the chosen format; do NOT persist it as the default — + // the saved default stays controlled by the toolbar/settings dialog. + void copyWithExtractor(extractor); + }, + disabled: !canCopyWithExtractor(extractor), + }); } - items.push({ - label: copyExtractorLabel(extractor), - action: () => { - // One-off copy as the chosen format; do NOT persist it as the default — - // the saved default stays controlled by the toolbar/settings dialog. - void copyWithExtractor(extractor); - }, - disabled: !canCopyWithExtractor(extractor), - checked: selected, - }); + if (extractorItems.length === 0) continue; + if (separatorPending) { + items.push({ label: "", separator: true }); + separatorPending = false; + } + items.push(...extractorItems); } - items.push({ label: "", separator: true }); - items.push({ - label: t("grid.copyExtractorConfigure"), - icon: Settings2, - action: openExtractorConfiguration, - }); return items; } +function defaultExtractorContextItem(): ContextMenuItem { + const extractor = selectedCopyExtractor.value; + const extractorOptions = extractor === "sql-inserts" ? sqlInsertExtractorOptions(settingsStore.editorSettings.dataGridExtractorOptions.sql.excludePrimaryKeysFromInsert) : undefined; + return { + label: defaultCopyExtractorLabel.value, + action: () => void copyWithExtractor(extractor, extractorOptions), + disabled: !canCopyWithExtractor(extractor, extractorOptions), + checked: true, + }; +} + function copySubmenu(): ContextMenuItem { - const items: ContextMenuItem[] = []; + const items: ContextMenuItem[] = [ + { + label: t("grid.copyExtractorConfigure"), + icon: Settings2, + action: openExtractorConfiguration, + }, + defaultExtractorContextItem(), + { label: "", separator: true }, + ]; if (contextColumn.value) { items.push({ label: t("grid.copyCell"), action: copyCell }); } @@ -8812,12 +8781,22 @@ const gridContextMenuItems = computed(() => { @cancel="cancelEdit" @commit="commitGridEdit" /> + (() => { - -
(() => { @cancel="cancelEdit" @commit="commitGridEdit" /> + @@ -9497,12 +9469,21 @@ const gridContextMenuItems = computed(() => { @cancel="cancelEdit" @commit="commitGridEdit" /> + @@ -9542,22 +9523,6 @@ const gridContextMenuItems = computed(() => { - -
- +