feat(grid): refine boolean editing and copy actions
This commit is contained in:
16 files changed
+188
-279
No files matched your search
@@ -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<string, number>();
|
||||
|
||||
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<ContextMenuItem[]>(() => {
|
||||
@cancel="cancelEdit"
|
||||
@commit="commitGridEdit"
|
||||
/>
|
||||
<EnumCellEditor
|
||||
v-else-if="isBooleanGridCell(displayItems[cell.recordIndex], cell.valueIndex)"
|
||||
v-model="booleanEditorModelValue"
|
||||
:values="BOOLEAN_CELL_EDITOR_VALUES"
|
||||
:nullable="isBooleanGridColumnNullable(cell.valueIndex)"
|
||||
:initial-null="isGridCellInitialNull(displayItems[cell.recordIndex]?.id, cell.valueIndex)"
|
||||
cell-layout="transpose"
|
||||
@cancel="cancelEdit"
|
||||
@commit="commitBooleanGridEdit"
|
||||
/>
|
||||
<EnumCellEditor
|
||||
v-else-if="isEnumGridColumn(cell.valueIndex)"
|
||||
v-model="editValue"
|
||||
:values="enumValuesForGridColumn(cell.valueIndex)"
|
||||
:nullable="isEnumGridColumnNullable(cell.valueIndex)"
|
||||
:initial-null="isEnumEditorInitialNull(displayItems[cell.recordIndex]?.id, cell.valueIndex)"
|
||||
:initial-null="isGridCellInitialNull(displayItems[cell.recordIndex]?.id, cell.valueIndex)"
|
||||
cell-layout="transpose"
|
||||
@cancel="cancelEdit"
|
||||
@commit="commitGridEdit"
|
||||
@@ -8858,22 +8837,6 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
<template v-if="draftCellPlaceholder(displayItems[cell.recordIndex], cell.valueIndex)">
|
||||
<span class="text-muted-foreground/70 italic">{{ draftCellPlaceholder(displayItems[cell.recordIndex], cell.valueIndex) }}</span>
|
||||
</template>
|
||||
<template v-else-if="isBooleanGridCell(displayItems[cell.recordIndex], cell.valueIndex) && !cell.isNull">
|
||||
<span class="flex w-full justify-center">
|
||||
<span
|
||||
class="flex h-4 w-4 shrink-0 items-center justify-center rounded border"
|
||||
:class="[booleanCellChecked(displayItems[cell.recordIndex]?.data[cell.valueIndex]) ? 'border-primary bg-primary text-primary-foreground' : '', canEditCellItem(displayItems[cell.recordIndex], cell.valueIndex) ? 'cursor-pointer' : '']"
|
||||
@click="cycleBooleanGridCell(displayItems[cell.recordIndex], cell.valueIndex, $event)"
|
||||
>
|
||||
<Check v-if="booleanCellChecked(displayItems[cell.recordIndex]?.data[cell.valueIndex])" class="h-3 w-3" />
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="isBooleanGridCell(displayItems[cell.recordIndex], cell.valueIndex)">
|
||||
<span class="flex w-full justify-center">
|
||||
<span :class="canEditCellItem(displayItems[cell.recordIndex], cell.valueIndex) ? 'cursor-pointer' : ''" @click="cycleBooleanGridCell(displayItems[cell.recordIndex], cell.valueIndex, $event)">{{ firstLineCellDisplayValue(cell.display) }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>{{ firstLineCellDisplayValue(cell.display) }}</template>
|
||||
<div v-if="cellDetailButtonVisible(cell.recordIndex, cell.valueIndex)" class="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-1">
|
||||
<LightDropdownMenu
|
||||
@@ -9330,12 +9293,21 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
@cancel="cancelEdit"
|
||||
@commit="commitGridEdit"
|
||||
/>
|
||||
<EnumCellEditor
|
||||
v-else-if="isBooleanGridCell(getRowItem(canvasEditingCell.rowId), canvasEditingCell.actualColIdx)"
|
||||
v-model="booleanEditorModelValue"
|
||||
:values="BOOLEAN_CELL_EDITOR_VALUES"
|
||||
:nullable="isBooleanGridColumnNullable(canvasEditingCell.actualColIdx)"
|
||||
:initial-null="isGridCellInitialNull(canvasEditingCell.rowId, canvasEditingCell.actualColIdx)"
|
||||
@cancel="cancelEdit"
|
||||
@commit="commitBooleanGridEdit"
|
||||
/>
|
||||
<EnumCellEditor
|
||||
v-else-if="isEnumGridColumn(canvasEditingCell.actualColIdx)"
|
||||
v-model="editValue"
|
||||
:values="enumValuesForGridColumn(canvasEditingCell.actualColIdx)"
|
||||
:nullable="isEnumGridColumnNullable(canvasEditingCell.actualColIdx)"
|
||||
:initial-null="isEnumEditorInitialNull(canvasEditingCell.rowId, canvasEditingCell.actualColIdx)"
|
||||
:initial-null="isGridCellInitialNull(canvasEditingCell.rowId, canvasEditingCell.actualColIdx)"
|
||||
@cancel="cancelEdit"
|
||||
@commit="commitGridEdit"
|
||||
/>
|
||||
@@ -9497,12 +9469,21 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
@cancel="cancelEdit"
|
||||
@commit="commitGridEdit"
|
||||
/>
|
||||
<EnumCellEditor
|
||||
v-else-if="isBooleanGridCell(item, col.actualColIdx)"
|
||||
v-model="booleanEditorModelValue"
|
||||
:values="BOOLEAN_CELL_EDITOR_VALUES"
|
||||
:nullable="isBooleanGridColumnNullable(col.actualColIdx)"
|
||||
:initial-null="isGridCellInitialNull(item.id, col.actualColIdx)"
|
||||
@cancel="cancelEdit"
|
||||
@commit="commitBooleanGridEdit"
|
||||
/>
|
||||
<EnumCellEditor
|
||||
v-else-if="isEnumGridColumn(col.actualColIdx)"
|
||||
v-model="editValue"
|
||||
:values="enumValuesForGridColumn(col.actualColIdx)"
|
||||
:nullable="isEnumGridColumnNullable(col.actualColIdx)"
|
||||
:initial-null="isEnumEditorInitialNull(item.id, col.actualColIdx)"
|
||||
:initial-null="isGridCellInitialNull(item.id, col.actualColIdx)"
|
||||
@cancel="cancelEdit"
|
||||
@commit="commitGridEdit"
|
||||
/>
|
||||
@@ -9542,22 +9523,6 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
<template v-if="draftCellPlaceholder(item, col.actualColIdx)">
|
||||
<span class="text-muted-foreground/70 italic">{{ draftCellPlaceholder(item, col.actualColIdx) }}</span>
|
||||
</template>
|
||||
<template v-else-if="isBooleanGridCell(item, col.actualColIdx) && !isNull(item.data[col.actualColIdx])">
|
||||
<span class="flex w-full justify-center">
|
||||
<span
|
||||
class="flex h-4 w-4 shrink-0 items-center justify-center rounded border"
|
||||
:class="[booleanCellChecked(item.data[col.actualColIdx]) ? 'border-primary bg-primary text-primary-foreground' : '', canEditCellItem(item, col.actualColIdx) ? 'cursor-pointer' : '']"
|
||||
@click="cycleBooleanGridCell(item, col.actualColIdx, $event)"
|
||||
>
|
||||
<Check v-if="booleanCellChecked(item.data[col.actualColIdx])" class="h-3 w-3" />
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="isBooleanGridCell(item, col.actualColIdx)">
|
||||
<span class="flex w-full justify-center">
|
||||
<span :class="canEditCellItem(item, col.actualColIdx) ? 'cursor-pointer' : ''" @click="cycleBooleanGridCell(item, col.actualColIdx, $event)">{{ firstLineCellDisplayValue(formatCellCached(item.data[col.actualColIdx], col.actualColIdx)) }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>{{ firstLineCellDisplayValue(formatCellCached(item.data[col.actualColIdx], col.actualColIdx)) }}</template>
|
||||
<div v-if="cellDetailButtonVisible(item.displayIndex, col.actualColIdx)" class="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-1">
|
||||
<LightDropdownMenu
|
||||
|
||||
@@ -141,7 +141,7 @@ function save() {
|
||||
<div class="grid gap-4 md:grid-cols-[260px_minmax(0,1fr)]">
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-1.5">
|
||||
<Label>{{ t("grid.copyExtractorFormat") }}</Label>
|
||||
<Label>{{ t("grid.copyExtractorDefaultFormat") }}</Label>
|
||||
<Select v-model="draftExtractor">
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
@@ -282,10 +282,10 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
<button v-else :disabled="itemIsDisabled(item)" :class="[...itemButtonClass(item.variant), activeSubIndex === index ? 'bg-accent text-accent-foreground' : '']" @click="handleItemClick(item)" @mouseenter="(e) => onItemMouseEnter(index, e)" @mouseleave="onItemMouseLeave">
|
||||
<span class="flex size-4 shrink-0 items-center justify-center">
|
||||
<component :is="item.icon" v-if="item.icon" :class="['size-4', item.iconClass]" />
|
||||
<Check v-if="item.checked" class="size-4 text-primary" />
|
||||
<component :is="item.icon" v-else-if="item.icon" :class="['size-4', item.iconClass]" />
|
||||
</span>
|
||||
<span class="flex-1 whitespace-nowrap">{{ item.label }}</span>
|
||||
<Check v-if="item.checked" class="ml-4 size-4 shrink-0 text-primary" />
|
||||
<span v-if="item.shortcut" class="ml-8 inline-flex shrink-0 items-center gap-1 text-muted-foreground">
|
||||
<kbd v-for="key in shortcutKeys(item.shortcut)" :key="key" class="min-w-4 rounded border border-border/70 bg-muted/60 px-1 py-0.5 text-center font-mono text-[10px] leading-none text-muted-foreground shadow-xs">{{ key }}</kbd>
|
||||
</span>
|
||||
@@ -313,10 +313,10 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
<button v-else :disabled="itemIsDisabled(child)" :class="itemButtonClass(child.variant)" @click="handleSubItemClick(child)">
|
||||
<span class="flex size-4 shrink-0 items-center justify-center">
|
||||
<component :is="child.icon" v-if="child.icon" :class="['size-4', child.iconClass]" />
|
||||
<Check v-if="child.checked" class="size-4 text-primary" />
|
||||
<component :is="child.icon" v-else-if="child.icon" :class="['size-4', child.iconClass]" />
|
||||
</span>
|
||||
<span class="flex-1 whitespace-nowrap">{{ child.label }}</span>
|
||||
<Check v-if="child.checked" class="ml-4 size-4 shrink-0 text-primary" />
|
||||
<span v-if="child.shortcut" class="ml-8 inline-flex shrink-0 items-center gap-1 text-muted-foreground">
|
||||
<kbd v-for="key in shortcutKeys(child.shortcut)" :key="key" class="min-w-4 rounded border border-border/70 bg-muted/60 px-1 py-0.5 text-center font-mono text-[10px] leading-none text-muted-foreground shadow-xs">{{ key }}</kbd>
|
||||
</span>
|
||||
|
||||
@@ -164,7 +164,7 @@ describe("CustomContextMenu lifecycle", () => {
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
it("renders checked state after the menu label", async () => {
|
||||
it("renders checked state in the leading icon slot", async () => {
|
||||
const root = defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
@@ -188,7 +188,8 @@ describe("CustomContextMenu lifecycle", () => {
|
||||
|
||||
const current = Array.from(document.body.querySelectorAll("button")).find((button) => button.textContent?.includes("Current"));
|
||||
const other = Array.from(document.body.querySelectorAll("button")).find((button) => button.textContent?.includes("Other"));
|
||||
expect(current?.lastElementChild?.tagName.toLowerCase()).toBe("svg");
|
||||
expect(current?.firstElementChild?.querySelector("svg")).not.toBeNull();
|
||||
expect(current?.lastElementChild?.tagName.toLowerCase()).toBe("span");
|
||||
expect(other?.querySelector("svg")).toBeNull();
|
||||
|
||||
app.unmount();
|
||||
|
||||
@@ -2,7 +2,6 @@ import { ref, shallowRef, computed, nextTick, watch, getCurrentInstance, onActiv
|
||||
import * as api from "@/lib/backend/api";
|
||||
import type { CellValue } from "@/lib/dataGrid/cellValue";
|
||||
import { coerceDataGridCellValue, dataGridCellEditorText } from "@/lib/dataGrid/dataGridCellCoercion";
|
||||
import { nextBooleanCellValue } from "@/lib/dataGrid/dataGridBooleanColumn";
|
||||
import { focusDataGridEditorWithoutScrolling, preserveDataGridScrollPosition } from "@/lib/dataGrid/dataGridEditorFocus";
|
||||
import { normalizeDataGridSaveError } from "@/lib/dataGrid/dataGridSql";
|
||||
import { rowStatusFilterAfterAddingRow, type RowStatusFilter } from "@/lib/dataGrid/gridRowStatus";
|
||||
@@ -793,19 +792,6 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
async function cycleBooleanCellValue(rowId: number, col: number, nullable: boolean) {
|
||||
if (!editable.value || !canEditColumn(col)) return;
|
||||
const item = getRowItem(rowId);
|
||||
if (!item || item.isDeleted) return;
|
||||
if (!item.isNew && !item.isDraft && !canEditExistingRows.value) return;
|
||||
if (isSavingNewRow(item)) return;
|
||||
const newVal = nextBooleanCellValue(item.data[col], nullable);
|
||||
isCancelling = false;
|
||||
suppressNextBlurCommit = false;
|
||||
editingCell.value = { rowId, col };
|
||||
await commitEditAndMaybeAutoSave({ explicitValue: newVal });
|
||||
}
|
||||
|
||||
async function commitEditFromBlur(options: CommitEditOptions = {}) {
|
||||
if (suppressNextBlurCommit) {
|
||||
suppressNextBlurCommit = false;
|
||||
@@ -1732,7 +1718,6 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
|
||||
startEdit,
|
||||
commitEdit,
|
||||
commitEditAndMaybeAutoSave,
|
||||
cycleBooleanCellValue,
|
||||
commitEditFromBlur,
|
||||
applyCellValue,
|
||||
restoreCellValue,
|
||||
|
||||
@@ -1124,6 +1124,7 @@ export default {
|
||||
copy: "Copy",
|
||||
copyExtractorWithHeaders: "{format} with Headers",
|
||||
copyExtractorOneRow: "One-row",
|
||||
copyExtractorJsonArray: "JSON Array",
|
||||
copyExtractorSqlInList: "SQL IN List",
|
||||
copyExtractorSqlInserts: "SQL Inserts",
|
||||
copyExtractorSqlInsertsWithPrimaryKeys: "SQL Inserts (with Primary Keys)",
|
||||
@@ -1172,7 +1173,7 @@ export default {
|
||||
copyJson: "Copy JSON",
|
||||
copyDdl: "Copy DDL",
|
||||
copyCell: "Copy Cell",
|
||||
copyRow: "Copy Row (JSON)",
|
||||
copyRow: "Copy Entire Row (JSON Object)",
|
||||
copyColumnJson: "Copy Column (JSON)",
|
||||
copyColumnNames: "Copy Column Names",
|
||||
copyColumnNamesSelected: "Copy Selected Column Names ({count})",
|
||||
@@ -1389,7 +1390,7 @@ export default {
|
||||
deleteRows: "Delete {count} Rows",
|
||||
cloneRows: "Clone {count} as New Rows",
|
||||
restoreRows: "Restore {count} Rows",
|
||||
copyRows: "Copy {count} Rows (JSON)",
|
||||
copyRows: "Copy {count} Rows (JSON Array)",
|
||||
copyColumnsJson: "Copy {count} Columns (JSON)",
|
||||
selectedRows: "{count} rows selected",
|
||||
restoreRow: "Restore Row",
|
||||
|
||||
@@ -1075,7 +1075,7 @@ export default withEnglishFallback({
|
||||
copyJson: "Copiar JSON",
|
||||
copyDdl: "Copiar DDL",
|
||||
copyCell: "Copiar celda",
|
||||
copyRow: "Copiar fila (JSON)",
|
||||
copyRow: "Copiar fila completa (objeto JSON)",
|
||||
copyColumnJson: "Copiar columna (JSON)",
|
||||
copyColumnNames: "Copiar nombres de columnas",
|
||||
copyColumnNamesSelected: "Copiar nombres de columnas seleccionadas ({count})",
|
||||
@@ -1273,7 +1273,7 @@ export default withEnglishFallback({
|
||||
deleteRows: "Eliminar {count} filas",
|
||||
cloneRows: "Clonar {count} como filas nuevas",
|
||||
restoreRows: "Restaurar {count} filas",
|
||||
copyRows: "Copiar {count} filas (JSON)",
|
||||
copyRows: "Copiar {count} filas (array JSON)",
|
||||
copyColumnsJson: "Copiar {count} columnas (JSON)",
|
||||
selectedRows: "{count} filas seleccionadas",
|
||||
restoreRow: "Restaurar fila",
|
||||
@@ -1443,6 +1443,7 @@ export default withEnglishFallback({
|
||||
filterBuilderNoMatchingColumns: "Sin columnas coincidentes",
|
||||
copyExtractorWithHeaders: "{format} (con encabezados)",
|
||||
copyExtractorOneRow: "CSV de una fila",
|
||||
copyExtractorJsonArray: "Array JSON",
|
||||
copyExtractorSqlInList: "Lista SQL IN",
|
||||
copyExtractorSqlInserts: "Sentencias SQL INSERT",
|
||||
copyExtractorSqlInsertsWithPrimaryKeys: "Sentencias SQL INSERT (con claves primarias)",
|
||||
|
||||
@@ -1073,7 +1073,7 @@ export default withEnglishFallback({
|
||||
copyJson: "Copia JSON",
|
||||
copyDdl: "Copia DDL",
|
||||
copyCell: "Copia Cella",
|
||||
copyRow: "Copia Riga (JSON)",
|
||||
copyRow: "Copia intera riga (oggetto JSON)",
|
||||
copyColumnJson: "Copia Colonna (JSON)",
|
||||
copyColumnNames: "Copia Nomi Colonne",
|
||||
copyColumnNamesSelected: "Copia Nomi Colonne Selezionate ({count})",
|
||||
@@ -1271,7 +1271,7 @@ export default withEnglishFallback({
|
||||
deleteRows: "Elimina {count} Righe",
|
||||
cloneRows: "Clona {count} come Nuove Righe",
|
||||
restoreRows: "Ripristina {count} Righe",
|
||||
copyRows: "Copia {count} Righe (JSON)",
|
||||
copyRows: "Copia {count} righe (array JSON)",
|
||||
copyColumnsJson: "Copia {count} Colonne (JSON)",
|
||||
selectedRows: "{count} righe selezionate",
|
||||
restoreRow: "Ripristina Riga",
|
||||
@@ -1441,6 +1441,7 @@ export default withEnglishFallback({
|
||||
filterBuilderNoMatchingColumns: "Nessun campo corrispondente",
|
||||
copyExtractorWithHeaders: "{format} (con intestazione)",
|
||||
copyExtractorOneRow: "CSV a riga singola",
|
||||
copyExtractorJsonArray: "Array JSON",
|
||||
copyExtractorSqlInList: "Lista SQL IN",
|
||||
copyExtractorSqlInserts: "Istruzioni SQL INSERT",
|
||||
copyExtractorSqlInsertsWithPrimaryKeys: "Istruzioni SQL INSERT (con chiavi primarie)",
|
||||
|
||||
@@ -1093,7 +1093,7 @@ export default withEnglishFallback({
|
||||
copyJson: "JSON をコピー",
|
||||
copyDdl: "DDLをコピー",
|
||||
copyCell: "セルをコピー",
|
||||
copyRow: "行をコピー (JSON)",
|
||||
copyRow: "行全体をコピー(JSON オブジェクト)",
|
||||
copyColumnJson: "列をコピー (JSON)",
|
||||
copyColumnNames: "すべての列名をコピー",
|
||||
copyColumnNamesSelected: "選択した列名をコピー({count} 列)",
|
||||
@@ -1289,7 +1289,7 @@ export default withEnglishFallback({
|
||||
deleteRows: "{count}行を削除",
|
||||
cloneRows: "{count}行を新しい行として複製",
|
||||
restoreRows: "{count}行を復元",
|
||||
copyRows: "{count}行をコピー (JSON)",
|
||||
copyRows: "{count}行をコピー(JSON 配列)",
|
||||
copyColumnsJson: "{count}列をコピー (JSON)",
|
||||
selectedRows: "{count}行選択中",
|
||||
restoreRow: "行を復元",
|
||||
@@ -1463,6 +1463,7 @@ export default withEnglishFallback({
|
||||
filterBuilderNoMatchingColumns: "一致するフィールドがありません",
|
||||
copyExtractorWithHeaders: "{format}(ヘッダー付き)",
|
||||
copyExtractorOneRow: "単行CSV",
|
||||
copyExtractorJsonArray: "JSON 配列",
|
||||
copyExtractorSqlInList: "SQL IN リスト",
|
||||
copyExtractorSqlInserts: "SQL INSERT 文",
|
||||
copyExtractorSqlInsertsWithPrimaryKeys: "SQL INSERT 文(主キーを含む)",
|
||||
|
||||
@@ -1039,6 +1039,7 @@ export default withEnglishFallback({
|
||||
copy: "복사",
|
||||
copyExtractorWithHeaders: "머리글 포함 {format}",
|
||||
copyExtractorOneRow: "단일 행",
|
||||
copyExtractorJsonArray: "JSON 배열",
|
||||
copyExtractorSqlInList: "SQL IN 목록",
|
||||
copyExtractorSqlInserts: "SQL INSERT",
|
||||
copyExtractorSqlInsertsWithPrimaryKeys: "SQL INSERT (기본 키 포함)",
|
||||
@@ -1087,7 +1088,7 @@ export default withEnglishFallback({
|
||||
copyJson: "JSON 복사",
|
||||
copyDdl: "DDL 복사",
|
||||
copyCell: "셀 복사",
|
||||
copyRow: "행 복사 (JSON)",
|
||||
copyRow: "전체 행 복사(JSON 객체)",
|
||||
copyColumnJson: "컬럼 복사 (JSON)",
|
||||
copyColumnNames: "컬럼 이름 복사",
|
||||
copyColumnNamesSelected: "선택한 컬럼 이름 복사 ({count})",
|
||||
@@ -1294,7 +1295,7 @@ export default withEnglishFallback({
|
||||
deleteRows: "{count}행 삭제",
|
||||
cloneRows: "{count}개를 새 행으로 복제",
|
||||
restoreRows: "{count}행 복원",
|
||||
copyRows: "{count}행 복사 (JSON)",
|
||||
copyRows: "{count}행 복사(JSON 배열)",
|
||||
copyColumnsJson: "{count}컬럼 복사 (JSON)",
|
||||
selectedRows: "{count}행 선택됨",
|
||||
restoreRow: "행 복원",
|
||||
|
||||
@@ -1075,7 +1075,7 @@ export default withEnglishFallback({
|
||||
copyJson: "Copiar JSON",
|
||||
copyDdl: "Copiar DDL",
|
||||
copyCell: "Copiar Célula",
|
||||
copyRow: "Copiar Linha (JSON)",
|
||||
copyRow: "Copiar linha inteira (objeto JSON)",
|
||||
copyColumnJson: "Copiar Coluna (JSON)",
|
||||
copyColumnNames: "Copiar Nomes das Colunas",
|
||||
copyColumnNamesSelected: "Copiar Nomes das Colunas Selecionadas ({count})",
|
||||
@@ -1273,7 +1273,7 @@ export default withEnglishFallback({
|
||||
deleteRows: "Excluir {count} Linhas",
|
||||
cloneRows: "Clonar {count} como Novas Linhas",
|
||||
restoreRows: "Restaurar {count} Linhas",
|
||||
copyRows: "Copiar {count} Linhas (JSON)",
|
||||
copyRows: "Copiar {count} linhas (array JSON)",
|
||||
copyColumnsJson: "Copiar {count} Colunas (JSON)",
|
||||
selectedRows: "{count} linhas selecionadas",
|
||||
restoreRow: "Restaurar Linha",
|
||||
@@ -1443,6 +1443,7 @@ export default withEnglishFallback({
|
||||
filterBuilderNoMatchingColumns: "Nenhum campo correspondente",
|
||||
copyExtractorWithHeaders: "{format} (com cabeçalhos)",
|
||||
copyExtractorOneRow: "CSV de linha única",
|
||||
copyExtractorJsonArray: "Array JSON",
|
||||
copyExtractorSqlInList: "Lista SQL IN",
|
||||
copyExtractorSqlInserts: "Instruções SQL INSERT",
|
||||
copyExtractorSqlInsertsWithPrimaryKeys: "Instruções SQL INSERT (com chaves primárias)",
|
||||
|
||||
@@ -1125,6 +1125,7 @@ export default withEnglishFallback({
|
||||
copy: "复制",
|
||||
copyExtractorWithHeaders: "{format}(含表头)",
|
||||
copyExtractorOneRow: "单行 CSV",
|
||||
copyExtractorJsonArray: "JSON 数组",
|
||||
copyExtractorSqlInList: "SQL IN 列表",
|
||||
copyExtractorSqlInserts: "SQL INSERT 语句",
|
||||
copyExtractorSqlInsertsWithPrimaryKeys: "SQL INSERT 语句(含主键)",
|
||||
@@ -1133,7 +1134,7 @@ export default withEnglishFallback({
|
||||
copyExtractorWhereClause: "WHERE 条件",
|
||||
copyExtractorPipeSeparated: "竖线分隔",
|
||||
copyExtractorPretty: "表格文本",
|
||||
copyExtractorConfigure: "配置数据提取器…",
|
||||
copyExtractorConfigure: "设置复制格式…",
|
||||
copyExtractorDefaultFormat: "默认复制格式",
|
||||
copyExtractorFormat: "提取器",
|
||||
copyExtractorColumnSeparator: "列分隔符",
|
||||
@@ -1173,7 +1174,7 @@ export default withEnglishFallback({
|
||||
copyJson: "复制 JSON",
|
||||
copyDdl: "复制 DDL",
|
||||
copyCell: "复制单元格",
|
||||
copyRow: "复制行 (JSON)",
|
||||
copyRow: "复制整行(JSON 对象)",
|
||||
copyColumnJson: "复制列 (JSON)",
|
||||
copyColumnNames: "复制全部列名",
|
||||
copyColumnNamesSelected: "复制选中列名({count} 列)",
|
||||
@@ -1390,7 +1391,7 @@ export default withEnglishFallback({
|
||||
deleteRows: "删除 {count} 行",
|
||||
cloneRows: "克隆 {count} 行为新行",
|
||||
restoreRows: "恢复 {count} 行",
|
||||
copyRows: "复制 {count} 行 (JSON)",
|
||||
copyRows: "复制 {count} 行(JSON 数组)",
|
||||
copyColumnsJson: "复制 {count} 列 (JSON)",
|
||||
selectedRows: "已选 {count} 行",
|
||||
restoreRow: "恢复行",
|
||||
|
||||
@@ -1074,7 +1074,7 @@ export default withEnglishFallback({
|
||||
copyJson: "複製 JSON",
|
||||
copyDdl: "複製 DDL",
|
||||
copyCell: "複製儲存格",
|
||||
copyRow: "複製整筆 (JSON)",
|
||||
copyRow: "複製整筆(JSON 物件)",
|
||||
copyColumnJson: "複製欄 (JSON)",
|
||||
copyColumnNames: "複製全部欄名",
|
||||
copyColumnNamesSelected: "複製選取欄名({count} 欄)",
|
||||
@@ -1272,7 +1272,7 @@ export default withEnglishFallback({
|
||||
deleteRows: "刪除 {count} 筆",
|
||||
cloneRows: "複製 {count} 筆為新資料",
|
||||
restoreRows: "復原 {count} 筆",
|
||||
copyRows: "複製 {count} 筆 (JSON)",
|
||||
copyRows: "複製 {count} 筆(JSON 陣列)",
|
||||
copyColumnsJson: "複製 {count} 欄 (JSON)",
|
||||
selectedRows: "已選 {count} 筆",
|
||||
restoreRow: "復原資料",
|
||||
@@ -1442,6 +1442,7 @@ export default withEnglishFallback({
|
||||
filterBuilderNoMatchingColumns: "沒有匹配的欄位",
|
||||
copyExtractorWithHeaders: "{format}(含表頭)",
|
||||
copyExtractorOneRow: "單行 CSV",
|
||||
copyExtractorJsonArray: "JSON 陣列",
|
||||
copyExtractorSqlInList: "SQL IN 列表",
|
||||
copyExtractorSqlInserts: "SQL INSERT 語句",
|
||||
copyExtractorSqlInsertsWithPrimaryKeys: "SQL INSERT 語句(含主鍵)",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { firstLineCellDisplayValue, type CellValue } from "@/lib/dataGrid/cellValue";
|
||||
import type { RowStatus } from "@/lib/dataGrid/gridRowStatus";
|
||||
import { DATA_GRID_DARK_SEARCH_COLORS, resolveDataGridPaintTheme, type DataGridPaintTheme } from "@/lib/dataGrid/dataGridPaintTheme";
|
||||
import { BOOLEAN_CHECKBOX_SIZE, isBooleanCheckboxValue, normalizeBooleanCellValue } from "@/lib/dataGrid/dataGridBooleanColumn";
|
||||
|
||||
export const CANVAS_DATA_GRID_ROW_HEIGHT = 26;
|
||||
|
||||
@@ -73,7 +72,6 @@ export interface DrawCanvasDataGridOptions {
|
||||
searchMatchKeys: ReadonlySet<number>;
|
||||
currentSearchMatch: CanvasSearchMatch | null;
|
||||
formatCell: (value: CellValue, columnIndex: number) => string;
|
||||
columnIsBoolean?: (columnIndex: number) => boolean;
|
||||
draftCellPlaceholder?: string;
|
||||
isRowActive: (rowIndex: number) => boolean;
|
||||
rowCellsUseSelectionVisual: (rowId: number) => boolean;
|
||||
@@ -228,29 +226,6 @@ function alignCanvasPixel(value: number, dpr: number): number {
|
||||
return Math.round(value * dpr) / dpr;
|
||||
}
|
||||
|
||||
function drawBooleanCheckbox(ctx: CanvasRenderingContext2D, options: { drawX: number; y: number; colWidth: number; scaleX: number; scaleY: number; theme: DataGridPaintTheme; checked: boolean }): void {
|
||||
const { drawX, y, colWidth, scaleX, scaleY, theme, checked } = options;
|
||||
const size = BOOLEAN_CHECKBOX_SIZE;
|
||||
const boxX = alignCanvasPixel(drawX + (colWidth - size) / 2, scaleX);
|
||||
const boxY = alignCanvasPixel(y + (CANVAS_DATA_GRID_ROW_HEIGHT - size) / 2, scaleY);
|
||||
ctx.lineWidth = 1;
|
||||
if (checked) {
|
||||
ctx.fillStyle = theme.primary;
|
||||
ctx.fillRect(boxX, boxY, size, size);
|
||||
ctx.strokeStyle = theme.background;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(boxX + 3, boxY + size / 2);
|
||||
ctx.lineTo(boxX + size / 2 - 0.5, boxY + size - 3.5);
|
||||
ctx.lineTo(boxX + size - 2.5, boxY + 3);
|
||||
ctx.stroke();
|
||||
ctx.lineWidth = 1;
|
||||
} else {
|
||||
ctx.strokeStyle = theme.mutedForeground;
|
||||
ctx.strokeRect(boxX + 0.5, boxY + 0.5, size - 1, size - 1);
|
||||
}
|
||||
}
|
||||
|
||||
function crispCanvasLine(value: number, dpr: number): number {
|
||||
return alignCanvasPixel(value, dpr) + 0.5 / dpr;
|
||||
}
|
||||
@@ -335,7 +310,6 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) {
|
||||
frozenColumnCount = 0,
|
||||
columnAligns,
|
||||
rightAlignedActionCell,
|
||||
columnIsBoolean,
|
||||
} = options;
|
||||
const fallbackRatio = Math.max(1, options.pixelRatio ?? window.devicePixelRatio ?? 1);
|
||||
const { pixelWidth, pixelHeight, scaleX, scaleY } = resolveCanvasBackingStoreMetrics({
|
||||
@@ -502,43 +476,26 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) {
|
||||
ctx.rect(clippedX, y, Math.min(cellPaintWidth, width - clippedX), CANVAS_DATA_GRID_ROW_HEIGHT);
|
||||
ctx.clip();
|
||||
const value = item.data[actualColIdx];
|
||||
const isBooleanCell = columnIsBoolean?.(actualColIdx) === true && isBooleanCheckboxValue(value);
|
||||
const isRightAlign = columnAligns?.[visibleColIdx] === "right";
|
||||
const isEditingThisCell = editingCell?.rowId === item.id && editingCell.col === actualColIdx;
|
||||
const isBooleanNullCell = isBooleanCell && value === null && !isEditingThisCell;
|
||||
ctx.textAlign = isBooleanNullCell ? "center" : isRightAlign ? "right" : "left";
|
||||
ctx.textAlign = isRightAlign ? "right" : "left";
|
||||
ctx.fillStyle = value === null ? theme.mutedForeground : theme.foreground;
|
||||
ctx.font = value === null ? italicFont : tabularFont;
|
||||
setCanvasNumericVariant(ctx, value === null ? "normal" : "tabular-nums");
|
||||
const reservedWidth = rightAlignedActionCell?.rowIndex === item.displayIndex && rightAlignedActionCell.visibleColIdx === visibleColIdx ? rightAlignedActionCell.reservedWidth : 0;
|
||||
const { textAnchorX, maxWidth: cellMaxWidth } = resolveCanvasCellTextLayout({ drawX, colWidth, dpr: scaleX, isRightAlign, reservedWidth });
|
||||
if (isBooleanCell && value !== null && !isEditingThisCell) {
|
||||
drawBooleanCheckbox(ctx, { drawX, y, colWidth, scaleX, scaleY, theme, checked: normalizeBooleanCellValue(value) === true });
|
||||
if (item.isDeleted) {
|
||||
const boxX = alignCanvasPixel(drawX + (colWidth - BOOLEAN_CHECKBOX_SIZE) / 2, scaleX);
|
||||
const strikeY = alignCanvasPixel(y + CANVAS_DATA_GRID_ROW_HEIGHT / 2, scaleY);
|
||||
ctx.strokeStyle = theme.foreground;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(boxX - 1, strikeY);
|
||||
ctx.lineTo(alignCanvasPixel(boxX + BOOLEAN_CHECKBOX_SIZE + 1, scaleX), strikeY);
|
||||
ctx.stroke();
|
||||
}
|
||||
} else {
|
||||
const rawDisplayText = item.isDraft && value === null ? (draftCellPlaceholder ?? "") : formatCell(value, actualColIdx);
|
||||
const displayText = isEditingThisCell ? "" : firstLineCellDisplayValue(rawDisplayText);
|
||||
const text = isEditingThisCell ? displayText : fitCanvasText(ctx, displayText, cellMaxWidth, isBooleanNullCell ? "left" : isRightAlign ? "right" : "left");
|
||||
const anchorX = isBooleanNullCell ? alignCanvasPixel(drawX + colWidth / 2, scaleX) : textAnchorX;
|
||||
ctx.fillText(text, anchorX, textY);
|
||||
if (item.isDeleted && text) {
|
||||
const textWidth = ctx.measureText(text).width;
|
||||
const lineStartX = isBooleanNullCell ? anchorX - textWidth / 2 : isRightAlign ? textAnchorX - textWidth : textAnchorX;
|
||||
ctx.strokeStyle = theme.foreground;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(lineStartX, textY);
|
||||
ctx.lineTo(alignCanvasPixel(lineStartX + textWidth, scaleX), textY);
|
||||
ctx.stroke();
|
||||
}
|
||||
const rawDisplayText = item.isDraft && value === null ? (draftCellPlaceholder ?? "") : formatCell(value, actualColIdx);
|
||||
const displayText = isEditingThisCell ? "" : firstLineCellDisplayValue(rawDisplayText);
|
||||
const text = isEditingThisCell ? displayText : fitCanvasText(ctx, displayText, cellMaxWidth, isRightAlign ? "right" : "left");
|
||||
ctx.fillText(text, textAnchorX, textY);
|
||||
if (item.isDeleted && text) {
|
||||
const textWidth = ctx.measureText(text).width;
|
||||
const lineStartX = isRightAlign ? textAnchorX - textWidth : textAnchorX;
|
||||
ctx.strokeStyle = theme.foreground;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(lineStartX, textY);
|
||||
ctx.lineTo(alignCanvasPixel(lineStartX + textWidth, scaleX), textY);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
setCanvasNumericVariant(ctx, "normal");
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import type { DatabaseType } from "@/types/database";
|
||||
|
||||
export const BOOLEAN_CHECKBOX_SIZE = 13;
|
||||
|
||||
const MYSQL_BIT_BOOLEAN_DATABASE_TYPES = new Set<DatabaseType>(["mysql"]);
|
||||
export const BOOLEAN_CELL_EDITOR_VALUES = ["true", "false"];
|
||||
|
||||
export function isBooleanColumnType(dataType: string | undefined, databaseType?: DatabaseType): boolean {
|
||||
if (!dataType) return false;
|
||||
const normalized = dataType.trim().toLowerCase();
|
||||
if (normalized === "boolean" || normalized === "bool") return true;
|
||||
if (databaseType === "sqlserver") return normalized === "bit";
|
||||
if (databaseType && MYSQL_BIT_BOOLEAN_DATABASE_TYPES.has(databaseType)) return normalized === "bit" || normalized === "bit(1)";
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -26,26 +23,20 @@ export function normalizeBooleanCellValue(value: unknown): boolean | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isBooleanCheckboxValue(value: unknown): boolean {
|
||||
export function isBooleanCellValue(value: unknown): boolean {
|
||||
return value === null || normalizeBooleanCellValue(value) !== null;
|
||||
}
|
||||
|
||||
export function nextBooleanCellValue(current: unknown, nullable: boolean): boolean | null {
|
||||
const normalized = normalizeBooleanCellValue(current);
|
||||
if (normalized === true) return false;
|
||||
if (normalized === false) return nullable ? null : true;
|
||||
return true;
|
||||
export function booleanCellEditorValue(value: unknown): string {
|
||||
const normalized = normalizeBooleanCellValue(value);
|
||||
if (normalized === true) return "true";
|
||||
if (normalized === false) return "false";
|
||||
return "";
|
||||
}
|
||||
|
||||
export function booleanCheckboxRect(cell: { left: number; top: number; width: number; height: number }): { left: number; top: number; size: number } {
|
||||
return {
|
||||
left: cell.left + (cell.width - BOOLEAN_CHECKBOX_SIZE) / 2,
|
||||
top: cell.top + (cell.height - BOOLEAN_CHECKBOX_SIZE) / 2,
|
||||
size: BOOLEAN_CHECKBOX_SIZE,
|
||||
};
|
||||
}
|
||||
|
||||
export function isPointInBooleanCheckbox(point: { x: number; y: number }, cell: { left: number; top: number; width: number; height: number }): boolean {
|
||||
const rect = booleanCheckboxRect(cell);
|
||||
return point.x >= rect.left && point.x <= rect.left + rect.size && point.y >= rect.top && point.y <= rect.top + rect.size;
|
||||
export function parseBooleanCellEditorValue(value: string | null): boolean | null | undefined {
|
||||
if (value === null) return null;
|
||||
if (value === "true") return true;
|
||||
if (value === "false") return false;
|
||||
return undefined;
|
||||
}
|
||||
Reference in new issue
Block a user