feat(grid): decompose data grid interaction surfaces

This commit is contained in:
t8y2 committed 2026-07-16 14:00:02 +08:00
1 parent 71497f0851
commit 8825b6335f
56 files changed
+6446 -4051

No files matched your search

@@ -0,0 +1,31 @@
import { effectScope, nextTick, ref } from "vue";
import { describe, expect, it } from "vitest";
import { useDataGridAsyncSurface } from "@/composables/useDataGridAsyncSurface";
describe("useDataGridAsyncSurface", () => {
it("defers mounting until first open and keeps the surface mounted afterward", async () => {
const open = ref(false);
const scope = effectScope();
const mounted = scope.run(() => useDataGridAsyncSurface(open))!;
expect(mounted.value).toBe(false);
open.value = true;
await nextTick();
expect(mounted.value).toBe(true);
open.value = false;
await nextTick();
expect(mounted.value).toBe(true);
scope.stop();
});
it("mounts immediately when restored with an already-open surface", () => {
const scope = effectScope();
const mounted = scope.run(() => useDataGridAsyncSurface(ref(true)))!;
expect(mounted.value).toBe(true);
scope.stop();
});
});
@@ -0,0 +1,32 @@
import { computed, nextTick } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useDataGridAutoRefresh } from "@/composables/useDataGridAutoRefresh";
describe("useDataGridAutoRefresh", () => {
afterEach(() => vi.useRealTimers());
it("refreshes on the configured interval when enabled", async () => {
vi.useFakeTimers();
const refresh = vi.fn();
const autoRefresh = useDataGridAutoRefresh({ canRefresh: computed(() => true), refresh, initialIntervalSeconds: 5 });
autoRefresh.toggle();
vi.advanceTimersByTime(5000);
await nextTick();
expect(refresh).toHaveBeenCalledOnce();
autoRefresh.stop();
});
it("does not refresh while the operation is unavailable", () => {
vi.useFakeTimers();
const refresh = vi.fn();
const autoRefresh = useDataGridAutoRefresh({ canRefresh: computed(() => false), refresh, initialIntervalSeconds: 1 });
autoRefresh.toggle();
vi.advanceTimersByTime(1000);
expect(refresh).not.toHaveBeenCalled();
autoRefresh.stop();
});
});
@@ -0,0 +1,160 @@
import { effectScope } from "vue";
import { describe, expect, it, vi } from "vitest";
import { useDataGridCanvasRuntime, type DataGridAnimationFrameDriver } from "@/composables/useDataGridCanvasRuntime";
import { useDataGridScrollbars } from "@/composables/useDataGridScrollbars";
function createFrameDriver() {
let nextId = 0;
const callbacks = new Map<number, FrameRequestCallback>();
const driver: DataGridAnimationFrameDriver = {
request(callback) {
const id = nextId++;
callbacks.set(id, callback);
return id;
},
cancel(id) {
callbacks.delete(id);
},
};
return { driver, callbacks, run: (id: number) => callbacks.get(id)?.(0) };
}
function createObserver() {
const observe = vi.fn();
const disconnect = vi.fn();
return { observer: { observe, disconnect } as unknown as ResizeObserver, observe, disconnect };
}
describe("data grid visual runtimes", () => {
it("coalesces canvas frames and cancels all resources on dispose", () => {
const frames = createFrameDriver();
const resize = createObserver();
const draw = vi.fn();
const viewport = { children: [] } as unknown as Element;
const runtime = useDataGridCanvasRuntime({
draw,
syncViewport: vi.fn(),
getViewport: () => viewport,
refreshPixelRatio: vi.fn(),
frameDriver: frames.driver,
createResizeObserver: () => resize.observer,
});
runtime.observeViewport();
runtime.scheduleDraw();
runtime.scheduleDraw();
runtime.schedulePixelRatioRefresh();
expect(frames.callbacks.size).toBe(2);
runtime.dispose();
expect(frames.callbacks.size).toBe(0);
expect(resize.disconnect).toHaveBeenCalledOnce();
expect(draw).not.toHaveBeenCalled();
});
it("keeps a large scroll burst within one scheduled draw frame", () => {
const frames = createFrameDriver();
const draw = vi.fn();
const runtime = useDataGridCanvasRuntime({
draw,
syncViewport: vi.fn(),
getViewport: () => ({ children: [] }) as unknown as Element,
frameDriver: frames.driver,
});
for (let index = 0; index < 10_000; index++) runtime.scheduleDraw();
expect(frames.callbacks.size).toBe(1);
const frameId = [...frames.callbacks.keys()][0];
frames.run(frameId);
expect(draw).toHaveBeenCalledOnce();
runtime.dispose();
});
it("keeps the latest drag coordinate and disconnects observed scroll content", () => {
const frames = createFrameDriver();
const resize = createObserver();
const horizontalDrag = vi.fn();
const child = { children: [] } as unknown as Element;
const scroller = { children: [child] } as unknown as Element;
const runtime = useDataGridScrollbars({
update: vi.fn(),
getScroller: () => scroller,
applyHorizontalDrag: horizontalDrag,
applyVerticalDrag: vi.fn(),
frameDriver: frames.driver,
createResizeObserver: () => resize.observer,
});
runtime.observeScroller();
runtime.scheduleHorizontalDrag(10);
runtime.scheduleHorizontalDrag(25);
runtime.flushHorizontalDrag();
expect(horizontalDrag).toHaveBeenCalledWith(25);
expect(resize.observe).toHaveBeenCalledTimes(2);
runtime.dispose();
expect(resize.disconnect).toHaveBeenCalledOnce();
expect(frames.callbacks.size).toBe(0);
});
it("tears down pending canvas work when its Vue scope stops", () => {
const frames = createFrameDriver();
const resize = createObserver();
const draw = vi.fn();
const syncViewport = vi.fn();
const scope = effectScope();
const runtime = scope.run(() =>
useDataGridCanvasRuntime({
draw,
syncViewport,
getViewport: () => ({ children: [] }) as unknown as Element,
frameDriver: frames.driver,
createResizeObserver: () => resize.observer,
}),
)!;
runtime.observeViewport();
runtime.scheduleDraw();
const lateDraw = [...frames.callbacks.values()][0];
scope.stop();
expect(runtime.disposed).toBe(true);
expect(runtime.active).toBe(false);
expect(frames.callbacks.size).toBe(0);
expect(resize.disconnect).toHaveBeenCalledOnce();
lateDraw(0);
expect(draw).not.toHaveBeenCalled();
expect(syncViewport).toHaveBeenCalledOnce();
});
it("does not revive scroll work after disposal", () => {
const frames = createFrameDriver();
const resize = createObserver();
const update = vi.fn();
const verticalDrag = vi.fn();
const runtime = useDataGridScrollbars({
update,
getScroller: () => ({ children: [] }) as unknown as Element,
applyHorizontalDrag: vi.fn(),
applyVerticalDrag: verticalDrag,
frameDriver: frames.driver,
createResizeObserver: () => resize.observer,
});
runtime.observeScroller();
runtime.scheduleVerticalDrag(42);
const lateCallbacks = [...frames.callbacks.values()];
runtime.dispose();
runtime.resume();
runtime.scheduleUpdate();
runtime.flushVerticalDrag();
lateCallbacks.forEach((callback) => callback(0));
expect(runtime.disposed).toBe(true);
expect(runtime.active).toBe(false);
expect(frames.callbacks.size).toBe(0);
expect(update).not.toHaveBeenCalled();
expect(verticalDrag).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,141 @@
// @vitest-environment happy-dom
import { effectScope, ref } from "vue";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { dataGridColumnOffsets, dataGridHorizontalColumnWindow, useDataGridColumnLayout, useDataGridColumnLayoutState } from "@/composables/useDataGridColumnLayout";
describe("useDataGridColumnLayout", () => {
beforeEach(() => localStorage.clear());
it("builds cumulative offsets", () => {
expect(dataGridColumnOffsets([80, 120, 60])).toEqual([0, 80, 200, 260]);
});
it("windows columns while preserving spacer widths", () => {
const widths = [100, 100, 100, 100, 100];
const offsets = dataGridColumnOffsets(widths);
expect(dataGridHorizontalColumnWindow({ widths, offsets, columnCount: 5, scrollLeft: 250, viewportWidth: 100, rowNumberWidth: 40, bufferPx: 0 })).toEqual({ start: 2, end: 4, beforeWidth: 200, afterWidth: 100 });
});
it("returns an empty window without columns", () => {
expect(dataGridHorizontalColumnWindow({ widths: [], offsets: [0], columnCount: 0, scrollLeft: 0, viewportWidth: 0, rowNumberWidth: 40, bufferPx: 900 })).toEqual({
start: 0,
end: 0,
beforeWidth: 0,
afterWidth: 0,
});
});
it("owns visibility, null-column toggles, and persisted ordering", () => {
const scope = effectScope();
const state = scope.run(() =>
useDataGridColumnLayoutState({
columns: ref(["id", "name", "empty"]),
sourceColumns: ref(undefined),
commentByColumn: ref(new Map()),
displayableColumnIndexes: ref([0, 1, 2]),
allNullColumnIndexes: ref([2]),
columnOrderKeys: ref(["id\0\0", "name\0\0", "empty\0\0"]),
layoutScopeKey: ref("test-layout"),
tableScopeKey: ref(""),
}),
)!;
state.toggleColumnVisibility(1);
expect(state.visibleColumnIndexes.value).toEqual([0, 2]);
state.toggleAllNullColumns();
expect(state.visibleColumnIndexes.value).toEqual([0]);
state.showAllColumns();
expect(state.visibleColumnIndexes.value).toEqual([0, 1, 2]);
state.persistColumnOrder([1, 0, 2]);
expect(state.orderedDisplayableColumnIndexes.value).toEqual([1, 0, 2]);
scope.stop();
});
it("keeps a new resize active when the previous resize completion frame is pending", () => {
const frames = new Map<number, FrameRequestCallback>();
let nextFrame = 1;
vi.stubGlobal(
"requestAnimationFrame",
vi.fn((callback: FrameRequestCallback) => {
const frame = nextFrame++;
frames.set(frame, callback);
return frame;
}),
);
vi.stubGlobal(
"cancelAnimationFrame",
vi.fn((frame: number) => frames.delete(frame)),
);
const scope = effectScope();
const layout = scope.run(() =>
useDataGridColumnLayout({
columnNames: ref(["id"]),
visibleColumnIndexes: ref([0]),
renderedColumnWidths: ref([100]),
scrollLeft: ref(0),
viewportWidth: ref(400),
rowNumberWidth: 40,
}),
)!;
layout.startColumnHeaderResize(0, new MouseEvent("mousedown"));
window.dispatchEvent(new MouseEvent("mouseup"));
expect(frames.size).toBe(1);
layout.startColumnHeaderResize(0, new MouseEvent("mousedown"));
expect(frames.size).toBe(0);
expect(layout.columnHeaderResizeActive.value).toBe(true);
scope.stop();
});
it("commits column drag order and removes global interaction state on disposal", () => {
vi.stubGlobal(
"requestAnimationFrame",
vi.fn((callback: FrameRequestCallback) => {
callback(0);
return 1;
}),
);
vi.stubGlobal("cancelAnimationFrame", vi.fn());
const first = document.createElement("div");
first.dataset.visibleColIndex = "0";
first.getBoundingClientRect = () => ({ left: 0, width: 100, right: 100, top: 0, bottom: 20, height: 20, x: 0, y: 0, toJSON: () => ({}) });
const second = document.createElement("div");
second.dataset.visibleColIndex = "1";
second.getBoundingClientRect = () => ({ left: 100, width: 100, right: 200, top: 0, bottom: 20, height: 20, x: 100, y: 0, toJSON: () => ({}) });
const header = document.createElement("div");
header.append(first, second);
const persist = vi.fn();
const scope = effectScope();
const layout = scope.run(() =>
useDataGridColumnLayout({
columnNames: ref(["id", "name"]),
visibleColumnIndexes: ref([0, 1]),
renderedColumnWidths: ref([100, 100]),
scrollLeft: ref(0),
viewportWidth: ref(400),
rowNumberWidth: 40,
headerRef: ref(header),
onPersistColumnOrder: persist,
}),
)!;
layout.startColumnHeaderDrag(0, new PointerEvent("pointerdown", { button: 0, clientX: 20, clientY: 10 }));
window.dispatchEvent(new PointerEvent("pointermove", { clientX: 180, clientY: 10 }));
expect(document.body.style.userSelect).toBe("none");
window.dispatchEvent(new PointerEvent("pointerup", { clientX: 180, clientY: 10 }));
expect(persist).toHaveBeenCalledWith([1, 0]);
expect(document.body.style.userSelect).toBe("");
layout.startColumnHeaderDrag(0, new PointerEvent("pointerdown", { button: 0, clientX: 20, clientY: 10 }));
window.dispatchEvent(new PointerEvent("pointermove", { clientX: 180, clientY: 10 }));
scope.stop();
expect(document.body.style.userSelect).toBe("");
window.dispatchEvent(new PointerEvent("pointerup", { clientX: 180, clientY: 10 }));
expect(persist).toHaveBeenCalledTimes(1);
});
});
@@ -1,6 +1,6 @@
import { computed, nextTick, ref } from "vue";
import { describe, expect, it, vi } from "vitest";
import { DATA_GRID_COL_AUTO_FIT_MAX_WIDTH, DATA_GRID_COL_MAX_WIDTH, DATA_GRID_COL_MIN_WIDTH } from "@/lib/dataGrid/dataGridColumnWidth";
import { describe, expect, it } from "vitest";
import { DATA_GRID_COL_AUTO_FIT_MAX_WIDTH, DATA_GRID_COL_MIN_WIDTH } from "@/lib/dataGrid/dataGridColumnWidth";
import { DATA_GRID_ROW_NUM_WIDTH, resizeDataGridColumnWidth, useDataGridColumnResize } from "@/composables/useDataGridColumnResize";
function createResizeState(options: { columns: string[]; rows: Array<Array<string | number | boolean | null>>; columnIndexes?: number[]; density?: "compact" | "standard" | "comfortable"; compactColumnHeaderActions?: boolean }) {
@@ -0,0 +1,137 @@
import { effectScope, nextTick, ref } from "vue";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useDataGridConditionEditor } from "@/composables/useDataGridConditionEditor";
import { rememberDataGridConditionHistory } from "@/lib/dataGrid/dataGridConditionHistory";
const storage = new Map<string, string>();
vi.stubGlobal("localStorage", {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
removeItem: (key: string) => storage.delete(key),
clear: () => storage.clear(),
});
function keyboardEvent(key: string) {
return { key, shiftKey: false, preventDefault: vi.fn() } as unknown as KeyboardEvent;
}
describe("useDataGridConditionEditor", () => {
beforeEach(() => {
localStorage.clear();
vi.useRealTimers();
});
it("replaces the active token and supports clamped keyboard navigation", async () => {
const value = ref("status = cus");
const editor = useDataGridConditionEditor({
kind: "where",
value,
columns: ["customer_id", "customer_name"],
historyScope: {},
});
value.value = "status = cust";
await nextTick();
await vi.waitFor(() => expect(editor.suggestions.value.map((item) => item.value)).toEqual(["customer_id", "customer_name"]));
expect(editor.navigate(1)).toBe(true);
expect(editor.highlightedIndex.value).toBe(1);
expect(editor.navigate(1)).toBe(true);
expect(editor.highlightedIndex.value).toBe(1);
expect(editor.accept()).toBe(true);
expect(value.value).toBe("status = customer_name");
});
it("ignores stale asynchronous suggestion responses", async () => {
vi.useFakeTimers();
const value = ref("");
const resolvers = new Map<string, (values: string[]) => void>();
const editor = useDataGridConditionEditor({
kind: "where",
value,
historyScope: {},
suggestionProvider: ({ token }) => new Promise((resolve) => resolvers.set(token, resolve)),
suggestionDebounceMs: 10,
});
value.value = "cus";
await nextTick();
vi.advanceTimersByTime(10);
await nextTick();
value.value = "ord";
await nextTick();
vi.advanceTimersByTime(10);
await nextTick();
resolvers.get("ord")?.(["order_id"]);
await Promise.resolve();
expect(editor.suggestions.value.map((item) => item.value)).toEqual(["order_id"]);
resolvers.get("cus")?.(["customer_id"]);
await Promise.resolve();
expect(editor.suggestions.value.map((item) => item.value)).toEqual(["order_id"]);
});
it("loads, filters, accepts, and deletes scoped history", () => {
const scope = { connectionId: "connection", database: "db", tableName: "users" };
rememberDataGridConditionHistory("where", scope, "status = 'active'");
rememberDataGridConditionHistory("where", scope, "customer_id > 10");
const value = ref("status");
const editor = useDataGridConditionEditor({ kind: "where", value, historyScope: scope });
editor.openHistory();
expect(editor.suggestions.value.map((item) => item.value)).toEqual(["status = 'active'"]);
expect(editor.accept(0)).toBe(true);
expect(value.value).toBe("status = 'active'");
editor.openHistory();
editor.deleteHistory("status = 'active'");
expect(editor.suggestions.value).toEqual([]);
expect(editor.historyOpen.value).toBe(true);
});
it("aborts pending suggestion work when its scope is disposed", async () => {
vi.useFakeTimers();
const value = ref("");
const aborted = vi.fn();
const scope = effectScope();
scope.run(() => {
useDataGridConditionEditor({
kind: "orderBy",
value,
historyScope: {},
suggestionDebounceMs: 10,
suggestionProvider: ({ signal }) => {
signal.addEventListener("abort", aborted);
return new Promise(() => {});
},
});
});
value.value = "created";
await nextTick();
vi.advanceTimersByTime(10);
scope.stop();
expect(aborted).toHaveBeenCalledOnce();
});
it("maps Enter, Tab, arrows, and Escape without applying stale selections", async () => {
const value = ref("");
const editor = useDataGridConditionEditor({ kind: "orderBy", value, columns: ["name"], historyScope: {} });
value.value = "na";
await nextTick();
await vi.waitFor(() => expect(editor.suggestions.value).toHaveLength(1));
const down = keyboardEvent("ArrowDown");
expect(editor.handleKeydown(down)).toBe("navigate");
expect(down.preventDefault).toHaveBeenCalledOnce();
const tab = keyboardEvent("Tab");
expect(editor.handleKeydown(tab)).toBe("accept");
expect(value.value).toBe("name");
const enter = keyboardEvent("Enter");
expect(editor.handleKeydown(enter)).toBe("apply");
editor.openHistory();
const escape = keyboardEvent("Escape");
expect(editor.handleKeydown(escape)).toBe("dismiss");
expect(editor.dropdownOpen.value).toBe(false);
});
});
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { buildDataGridStructuredWhere, useDataGridFilterBuilder, type DataGridStructuredFilterRule } from "@/composables/useDataGridFilterBuilder";
describe("useDataGridFilterBuilder", () => {
it("normalizes values when modes change", () => {
const builder = useDataGridFilterBuilder({ columns: ["id"], createId: () => "rule-1", isComplete: () => true, buildCondition: async () => "id = 1" });
builder.ensureRule();
builder.updateRule("rule-1", { rawValue: "1", rawEndValue: "2", mode: "is-null" });
expect(builder.rules.value[0]).toMatchObject({ rawValue: "", rawEndValue: "" });
});
it("skips disabled rules and applies conjunctions", async () => {
let nextId = 0;
const builder = useDataGridFilterBuilder({
columns: ["id", "name"],
createId: () => `rule-${++nextId}`,
isComplete: (rule) => !!rule.rawValue,
buildCondition: async (rule) => `${rule.columnName} = '${rule.rawValue}'`,
});
builder.ensureRule();
builder.updateRule("rule-1", { rawValue: "1" });
builder.addRule();
builder.updateRule("rule-2", { columnName: "name", rawValue: "Alice", conjunction: "OR" });
expect(await builder.apply()).toBe("(id = '1') OR (name = 'Alice')");
});
it("groups conditions in rule order", () => {
const rule = (id: string, conjunction: "AND" | "OR"): DataGridStructuredFilterRule => ({ id, columnName: id, mode: "equals", rawValue: id, rawEndValue: "", conjunction });
expect(
buildDataGridStructuredWhere([
{ rule: rule("a", "AND"), condition: "a" },
{ rule: rule("b", "AND"), condition: "b" },
{ rule: rule("c", "OR"), condition: "c" },
]),
).toBe("((a) AND (b)) OR (c)");
});
});
@@ -0,0 +1,28 @@
import { nextTick, ref } from "vue";
import { describe, expect, it } from "vitest";
import { useDataGridResultLifecycle } from "@/composables/useDataGridResultLifecycle";
describe("useDataGridResultLifecycle", () => {
it("invalidates operations when the result identity changes", async () => {
const result = ref<object>({ id: 1 });
const lifecycle = useDataGridResultLifecycle({ resultKey: result });
const operation = lifecycle.beginOperation();
result.value = { id: 2 };
await nextTick();
expect(lifecycle.isCurrent(operation)).toBe(false);
lifecycle.cleanup();
});
it("invalidates operations explicitly and cleanup is idempotent", () => {
const lifecycle = useDataGridResultLifecycle({ resultKey: ref({ id: 1 }) });
const operation = lifecycle.beginOperation();
lifecycle.invalidate();
lifecycle.cleanup();
lifecycle.cleanup();
expect(lifecycle.isCurrent(operation)).toBe(false);
});
});
@@ -0,0 +1,27 @@
import { nextTick, ref } from "vue";
import { describe, expect, it, vi } from "vitest";
import { useDataGridSearch } from "@/composables/useDataGridSearch";
describe("useDataGridSearch", () => {
it("debounces matching across columns and cells", async () => {
vi.useFakeTimers();
const search = useDataGridSearch({ columns: ["id", "name"], rows: [[1, "Alice"]], getCellText: (row, column) => String(row[column] ?? "") });
search.searchText.value = "ali";
await nextTick();
expect(search.matches.value).toEqual([]);
vi.advanceTimersByTime(150);
await nextTick();
expect(search.matches.value).toEqual([{ kind: "cell", displayRow: 0, col: 1 }]);
vi.useRealTimers();
});
it("suggests columns and replaces only the active token", async () => {
const columns = ref(["customer_id", "created_at"]);
const search = useDataGridSearch({ columns, rows: [], getCellText: () => "" });
search.searchText.value = "status = cus";
await nextTick();
expect(search.suggestions.value).toEqual(["customer_id"]);
expect(search.acceptSuggestion()).toBe(true);
expect(search.searchText.value).toBe("status = customer_id");
});
});
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { useDataGridSort } from "@/composables/useDataGridSort";
describe("useDataGridSort", () => {
it("stores the selected database or local sort state", () => {
const sort = useDataGridSort();
sort.setSort("name", 2, "desc", "local");
expect(sort.sortColumn.value).toBe("name");
expect(sort.sortColumnIndex.value).toBe(2);
expect(sort.sortDirection.value).toBe("desc");
expect(sort.sortMode.value).toBe("local");
});
it("resets to the default unsorted state", () => {
const sort = useDataGridSort();
sort.setSort("name", 2, "desc", "local");
sort.clearSort();
expect(sort.sortColumn.value).toBeNull();
expect(sort.sortColumnIndex.value).toBeNull();
expect(sort.sortDirection.value).toBe("asc");
expect(sort.sortMode.value).toBe("database");
});
});
@@ -0,0 +1,16 @@
import { ref, toValue, watch, type MaybeRefOrGetter } from "vue";
export function useDataGridAsyncSurface(open: MaybeRefOrGetter<boolean>) {
const mounted = ref(false);
watch(
() => toValue(open),
(value) => {
// Preserve the existing dialog instance after its chunk loads so closing it does not reset local UI state.
if (value) mounted.value = true;
},
{ immediate: true },
);
return mounted;
}
@@ -0,0 +1,51 @@
import { onBeforeUnmount, ref, type ComputedRef } from "vue";
export interface UseDataGridAutoRefreshOptions {
canRefresh: ComputedRef<boolean>;
refresh: () => void | Promise<void>;
initialIntervalSeconds?: number;
}
export function useDataGridAutoRefresh(options: UseDataGridAutoRefreshOptions) {
const intervalSeconds = ref(options.initialIntervalSeconds ?? 10);
const enabled = ref(false);
let timer: ReturnType<typeof setInterval> | undefined;
function stop() {
if (timer !== undefined) clearInterval(timer);
timer = undefined;
}
function tick() {
if (!enabled.value || !options.canRefresh.value) return;
void options.refresh();
}
function start() {
stop();
if (!enabled.value) return;
timer = setInterval(tick, intervalSeconds.value * 1000);
}
function setIntervalSeconds(seconds: number) {
intervalSeconds.value = seconds;
if (enabled.value) start();
}
function toggle() {
enabled.value = !enabled.value;
if (enabled.value) start();
else stop();
}
onBeforeUnmount(stop);
return {
intervalSeconds,
enabled,
start,
stop,
setIntervalSeconds,
toggle,
};
}
@@ -0,0 +1,116 @@
import { getCurrentScope, onScopeDispose } from "vue";
export interface DataGridAnimationFrameDriver {
request(callback: FrameRequestCallback): number;
cancel(frameId: number): void;
}
export interface DataGridCanvasRuntimeOptions {
draw(): void;
syncViewport(): void;
getViewport(): Element | null;
refreshPixelRatio?: () => void;
frameDriver?: DataGridAnimationFrameDriver;
createResizeObserver?: (callback: ResizeObserverCallback) => ResizeObserver | null;
startPaused?: boolean;
}
export interface DataGridCanvasRuntime {
readonly active: boolean;
readonly disposed: boolean;
scheduleDraw(): void;
drawNow(): void;
schedulePixelRatioRefresh(): void;
observeViewport(): void;
pause(): void;
resume(): void;
dispose(): void;
}
function defaultFrameDriver(): DataGridAnimationFrameDriver {
return {
request: (callback) => requestAnimationFrame(callback),
cancel: (frameId) => cancelAnimationFrame(frameId),
};
}
function defaultResizeObserverFactory(callback: ResizeObserverCallback): ResizeObserver | null {
return typeof ResizeObserver === "undefined" ? null : new ResizeObserver(callback);
}
export function useDataGridCanvasRuntime(options: DataGridCanvasRuntimeOptions): DataGridCanvasRuntime {
const frameDriver = options.frameDriver ?? defaultFrameDriver();
const createResizeObserver = options.createResizeObserver ?? defaultResizeObserverFactory;
let active = !options.startPaused;
let disposed = false;
let drawFrame: number | null = null;
let pixelRatioFrame: number | null = null;
let resizeObserver: ResizeObserver | null = null;
const cancelFrames = () => {
if (drawFrame !== null) frameDriver.cancel(drawFrame);
if (pixelRatioFrame !== null) frameDriver.cancel(pixelRatioFrame);
drawFrame = null;
pixelRatioFrame = null;
};
const runtime: DataGridCanvasRuntime = {
get active() {
return active;
},
get disposed() {
return disposed;
},
scheduleDraw() {
if (!active || disposed || drawFrame !== null) return;
drawFrame = frameDriver.request(() => {
drawFrame = null;
if (active && !disposed) options.draw();
});
},
drawNow() {
if (!active || disposed) return;
if (drawFrame !== null) frameDriver.cancel(drawFrame);
drawFrame = null;
options.draw();
},
schedulePixelRatioRefresh() {
if (!active || disposed || pixelRatioFrame !== null || !options.refreshPixelRatio) return;
pixelRatioFrame = frameDriver.request(() => {
pixelRatioFrame = null;
if (active && !disposed) options.refreshPixelRatio?.();
});
},
observeViewport() {
resizeObserver?.disconnect();
resizeObserver = null;
if (!active || disposed) return;
options.syncViewport();
const viewport = options.getViewport();
if (!viewport) return;
resizeObserver = createResizeObserver(() => {
if (active && !disposed) options.syncViewport();
});
resizeObserver?.observe(viewport);
},
pause() {
active = false;
resizeObserver?.disconnect();
resizeObserver = null;
cancelFrames();
},
resume() {
if (disposed) return;
active = true;
runtime.observeViewport();
},
dispose() {
if (disposed) return;
runtime.pause();
disposed = true;
},
};
if (getCurrentScope()) onScopeDispose(runtime.dispose);
return runtime;
}
@@ -0,0 +1,67 @@
import { nextTick, ref, watch, type Ref } from "vue";
import { useCellDetailEditor, type UseCellDetailEditorReturn } from "@/composables/useCellDetailEditor";
import { useTheme } from "@/composables/useTheme";
import { useSettingsStore } from "@/stores/settingsStore";
import { renderWktOnCanvas } from "@/lib/dataGrid/geometryPreview";
import type { DataGridCellDetail } from "@/lib/dataGrid/dataGridDetail";
export function useDataGridCellDetail(options: { detail: Ref<DataGridCellDetail>; editValue: Ref<string>; onCancel: () => void }) {
const settingsStore = useSettingsStore();
const { isDark, themePalette } = useTheme();
const geometryPreviewOpen = ref(false);
const geometryCanvas = ref<HTMLCanvasElement | null>(null);
const detailsEditorContainer = ref<HTMLElement>();
const sideJsonPreviewContainer = ref<HTMLElement>();
let detailsEditor: UseCellDetailEditorReturn | null = null;
let sideJsonEditor: UseCellDetailEditorReturn | null = null;
const editorOptions = () => ({
editorTheme: () => settingsStore.editorSettings.theme,
appAppearance: () => (isDark.value ? "dark" : "light") as import("@/lib/app/appTheme").AppThemeAppearance,
appPalette: () => themePalette.value,
fontSize: () => settingsStore.editorSettings.fontSize,
fontFamily: () => settingsStore.editorSettings.fontFamily,
});
watch(geometryPreviewOpen, async (open) => {
if (!open) return;
await nextTick();
const canvas = geometryCanvas.value;
const detail = options.detail.value;
if (canvas && detail.value !== null && detail.value !== undefined) renderWktOnCanvas(canvas, String(detail.value));
});
watch(detailsEditorContainer, async (element) => {
if (element && !detailsEditor) {
detailsEditor = useCellDetailEditor({ onChange: (value) => (options.editValue.value = value), onEscape: options.onCancel, ...editorOptions() });
await detailsEditor.create(element, options.editValue.value, options.detail.value.type);
} else if (!element && detailsEditor) {
detailsEditor.destroy();
detailsEditor = null;
}
});
watch(sideJsonPreviewContainer, async (element) => {
if (element && !sideJsonEditor) {
sideJsonEditor = useCellDetailEditor({ language: "json", readOnly: true, ...editorOptions() });
await sideJsonEditor.create(element, options.detail.value.formattedJson ?? "", "json");
} else if (!element && sideJsonEditor) {
sideJsonEditor.destroy();
sideJsonEditor = null;
}
});
watch(
() => options.detail.value.formattedJson ?? "",
(value) => sideJsonEditor?.setValue(value, "json"),
);
watch(options.editValue, (value) => detailsEditor?.setValue(value, options.detail.value.type));
return {
geometryPreviewOpen,
geometryCanvas,
detailsEditorContainer,
sideJsonPreviewContainer,
openSearch: () => detailsEditor?.openSearch() ?? sideJsonEditor?.openSearch() ?? false,
};
}
@@ -0,0 +1,512 @@
import { computed, nextTick, onScopeDispose, ref, toValue, watch, type MaybeRefOrGetter } from "vue";
import { columnOrderKeysForIndexes, isDefaultColumnOrder, moveVisibleColumnIndex, orderedColumnIndexes } from "@/lib/dataGrid/dataGridColumnOrder";
import { columnHeaderCanvasPointerDisabled, columnHeaderClickShouldBeSuppressed, columnHeaderPreviewOffsetForColumn, columnHeaderTooltipDisabled } from "@/lib/dataGrid/dataGridColumnHeaderInteraction";
import {
loadDataGridColumnOrder,
loadTableDataGridColumnOrder,
notifyTableDataGridColumnOrderChanged,
removeDataGridColumnOrder,
removeTableDataGridColumnOrder,
saveDataGridColumnOrder,
saveTableDataGridColumnOrder,
type TableDataGridColumnOrderChangedDetail,
} from "@/lib/dataGrid/dataGridColumnLayoutStorage";
import { filterColumnVisibilityOptions, hiddenColumnIndexesWithAllNullColumns, invertedHiddenColumnIndexes, nextHiddenColumnIndexes, removeAutoHiddenColumnIndexes, visibleColumnIndexesForFilter } from "@/lib/dataGrid/dataGridColumnVisibility";
export type RenderedDataGridColumn = {
visibleColIdx: number;
actualColIdx: number;
name: string;
};
export type DataGridHorizontalColumnWindow = {
start: number;
end: number;
beforeWidth: number;
afterWidth: number;
};
type ColumnHeaderDragState = {
sourceVisibleIndex: number;
targetVisibleIndex: number;
startX: number;
startY: number;
currentX: number;
columnRects: { visibleIndex: number; left: number; width: number }[];
dragging: boolean;
};
export function dataGridColumnOffsets(widths: readonly number[]): number[] {
const offsets = Array.from({ length: widths.length + 1 }, () => 0);
for (let index = 0; index < widths.length; index++) offsets[index + 1] = offsets[index] + (widths[index] ?? 0);
return offsets;
}
export function dataGridHorizontalColumnWindow(options: { widths: readonly number[]; offsets: readonly number[]; columnCount: number; scrollLeft: number; viewportWidth: number; rowNumberWidth: number; bufferPx: number }): DataGridHorizontalColumnWindow {
const { widths, offsets, columnCount } = options;
if (columnCount === 0 || widths.length === 0) return { start: 0, end: 0, beforeWidth: 0, afterWidth: 0 };
const viewportStart = Math.max(0, options.scrollLeft - options.rowNumberWidth - options.bufferPx);
const viewportEnd = Math.max(options.viewportWidth, 1) + Math.max(0, options.scrollLeft - options.rowNumberWidth) + options.bufferPx;
let low = 0;
let high = columnCount - 1;
while (low < high) {
const mid = Math.floor((low + high) / 2);
if ((offsets[mid + 1] ?? 0) < viewportStart) low = mid + 1;
else high = mid;
}
const start = low;
let end = start;
while (end < columnCount && (offsets[end] ?? 0) < viewportEnd) end++;
const columnsWidth = offsets[columnCount] ?? 0;
const visibleWidth = offsets[end] ?? offsets[start] ?? 0;
return { start, end, beforeWidth: offsets[start] ?? 0, afterWidth: Math.max(0, columnsWidth - visibleWidth) };
}
export function useDataGridColumnLayoutState(options: {
columns: MaybeRefOrGetter<readonly string[]>;
sourceColumns?: MaybeRefOrGetter<readonly (string | undefined)[] | undefined>;
commentByColumn?: MaybeRefOrGetter<ReadonlyMap<string, string>>;
displayableColumnIndexes: MaybeRefOrGetter<readonly number[]>;
allNullColumnIndexes: MaybeRefOrGetter<readonly number[]>;
columnOrderKeys: MaybeRefOrGetter<readonly string[]>;
layoutScopeKey: MaybeRefOrGetter<string>;
tableScopeKey: MaybeRefOrGetter<string>;
onRefreshMetrics?: () => void;
}) {
const hiddenColumnIndexes = ref<Set<number>>(new Set());
const nullColumnsHidden = ref(false);
const autoHiddenNullColumnIndexes = ref<Set<number>>(new Set());
const persistedColumnOrderKeys = ref<string[]>([]);
const orderedDisplayableColumnIndexes = computed(() => orderedColumnIndexes({ availableIndexes: toValue(options.displayableColumnIndexes), columnKeys: toValue(options.columnOrderKeys), orderedKeys: persistedColumnOrderKeys.value }));
const visibleColumnIndexes = computed(() => visibleColumnIndexesForFilter(orderedDisplayableColumnIndexes.value, hiddenColumnIndexes.value));
const displayableColumnCount = computed(() => toValue(options.displayableColumnIndexes).length);
const hiddenColumnCount = computed(() => displayableColumnCount.value - visibleColumnIndexes.value.length);
const allNullColumnCount = computed(() => toValue(options.allNullColumnIndexes).length);
const hasCustomColumnOrder = computed(() => !isDefaultColumnOrder(toValue(options.displayableColumnIndexes), orderedDisplayableColumnIndexes.value));
const canToggleAllNullColumns = computed(() => nullColumnsHidden.value || (toValue(options.allNullColumnIndexes).length > 0 && displayableColumnCount.value > 1));
function filteredColumnVisibilityOptions(query: string) {
const displayable = new Set(toValue(options.displayableColumnIndexes));
return filterColumnVisibilityOptions(toValue(options.columns), query, { sourceColumns: toValue(options.sourceColumns), commentByColumn: toValue(options.commentByColumn) }).filter((option) => displayable.has(option.index));
}
function isColumnVisible(columnIndex: number) {
return !hiddenColumnIndexes.value.has(columnIndex);
}
function toggleColumnVisibility(columnIndex: number) {
hiddenColumnIndexes.value = nextHiddenColumnIndexes({ columnIndex, hiddenIndexes: hiddenColumnIndexes.value, totalColumns: displayableColumnCount.value });
}
function showAllColumns() {
hiddenColumnIndexes.value = new Set();
}
function invertColumnVisibility() {
hiddenColumnIndexes.value = invertedHiddenColumnIndexes([...toValue(options.displayableColumnIndexes)], hiddenColumnIndexes.value);
}
function loadColumnOrder() {
const tableScopeKey = toValue(options.tableScopeKey);
const tableOrder = tableScopeKey ? loadTableDataGridColumnOrder(tableScopeKey) : [];
persistedColumnOrderKeys.value = tableOrder.length ? tableOrder : loadDataGridColumnOrder(toValue(options.layoutScopeKey), toValue(options.columnOrderKeys));
}
function persistColumnOrder(indexes: number[]) {
const tableScopeKey = toValue(options.tableScopeKey);
if (isDefaultColumnOrder(toValue(options.displayableColumnIndexes), indexes)) {
removeDataGridColumnOrder(toValue(options.layoutScopeKey));
if (tableScopeKey) {
removeTableDataGridColumnOrder(tableScopeKey);
notifyTableDataGridColumnOrderChanged(tableScopeKey);
}
persistedColumnOrderKeys.value = [];
return;
}
const keys = columnOrderKeysForIndexes(indexes, toValue(options.columnOrderKeys));
persistedColumnOrderKeys.value = keys;
saveDataGridColumnOrder(toValue(options.layoutScopeKey), toValue(options.columnOrderKeys), keys);
if (tableScopeKey) {
saveTableDataGridColumnOrder(tableScopeKey, keys);
notifyTableDataGridColumnOrderChanged(tableScopeKey);
}
}
function resetColumnOrder() {
removeDataGridColumnOrder(toValue(options.layoutScopeKey));
const tableScopeKey = toValue(options.tableScopeKey);
if (tableScopeKey) {
removeTableDataGridColumnOrder(tableScopeKey);
notifyTableDataGridColumnOrderChanged(tableScopeKey);
}
persistedColumnOrderKeys.value = [];
if (options.onRefreshMetrics) nextTick(options.onRefreshMetrics);
}
function showAllNullColumns() {
hiddenColumnIndexes.value = removeAutoHiddenColumnIndexes(hiddenColumnIndexes.value, autoHiddenNullColumnIndexes.value);
autoHiddenNullColumnIndexes.value = new Set();
nullColumnsHidden.value = false;
}
function hideAllNullColumns() {
const next = hiddenColumnIndexesWithAllNullColumns({ availableIndexes: [...toValue(options.displayableColumnIndexes)], hiddenIndexes: hiddenColumnIndexes.value, allNullIndexes: new Set(toValue(options.allNullColumnIndexes)) });
hiddenColumnIndexes.value = next.hiddenIndexes;
autoHiddenNullColumnIndexes.value = next.autoHiddenIndexes;
nullColumnsHidden.value = next.autoHiddenIndexes.size > 0;
}
function toggleAllNullColumns() {
if (nullColumnsHidden.value) showAllNullColumns();
else hideAllNullColumns();
}
function onTableDataGridColumnOrderChanged(event: Event) {
if (!(event instanceof CustomEvent)) return;
const detail = event.detail as TableDataGridColumnOrderChangedDetail | undefined;
if (!detail || detail.scopeKey !== toValue(options.tableScopeKey)) return;
loadColumnOrder();
if (options.onRefreshMetrics) nextTick(options.onRefreshMetrics);
}
function resetColumnVisibility() {
hiddenColumnIndexes.value = new Set();
autoHiddenNullColumnIndexes.value = new Set();
nullColumnsHidden.value = false;
}
watch(
() => [...toValue(options.allNullColumnIndexes)],
() => {
if (!nullColumnsHidden.value) return;
hiddenColumnIndexes.value = removeAutoHiddenColumnIndexes(hiddenColumnIndexes.value, autoHiddenNullColumnIndexes.value);
autoHiddenNullColumnIndexes.value = new Set();
hideAllNullColumns();
},
);
watch([() => toValue(options.layoutScopeKey), () => toValue(options.tableScopeKey)], loadColumnOrder, { immediate: true });
return {
hiddenColumnIndexes,
nullColumnsHidden,
orderedDisplayableColumnIndexes,
visibleColumnIndexes,
displayableColumnCount,
hiddenColumnCount,
allNullColumnCount,
hasCustomColumnOrder,
canToggleAllNullColumns,
filteredColumnVisibilityOptions,
isColumnVisible,
toggleColumnVisibility,
showAllColumns,
invertColumnVisibility,
persistColumnOrder,
resetColumnOrder,
toggleAllNullColumns,
resetColumnVisibility,
onTableDataGridColumnOrderChanged,
};
}
export function useDataGridColumnLayout(options: {
columnNames: MaybeRefOrGetter<readonly string[]>;
visibleColumnIndexes: MaybeRefOrGetter<readonly number[]>;
renderedColumnWidths: MaybeRefOrGetter<readonly number[]>;
scrollLeft: MaybeRefOrGetter<number>;
viewportWidth: MaybeRefOrGetter<number>;
rowNumberWidth: number;
bufferPx?: number;
headerRef?: MaybeRefOrGetter<HTMLElement | null | undefined>;
orderedColumnIndexes?: MaybeRefOrGetter<readonly number[]>;
hiddenColumnIndexes?: MaybeRefOrGetter<ReadonlySet<number>>;
getIsResizing?: () => boolean;
onResizeStart?: (visibleColIdx: number, event: MouseEvent) => void;
onCanvasMouseLeave?: () => void;
onCanvasDrawSchedule?: () => void;
onRefreshMetrics?: () => void;
onPersistColumnOrder?: (indexes: number[]) => void;
}) {
const renderedColumnOffsets = computed(() => dataGridColumnOffsets(toValue(options.renderedColumnWidths)));
const horizontalColumnWindow = computed(() =>
dataGridHorizontalColumnWindow({
widths: toValue(options.renderedColumnWidths),
offsets: renderedColumnOffsets.value,
columnCount: toValue(options.visibleColumnIndexes).length,
scrollLeft: toValue(options.scrollLeft),
viewportWidth: toValue(options.viewportWidth),
rowNumberWidth: options.rowNumberWidth,
bufferPx: options.bufferPx ?? 900,
}),
);
const renderedGridColumns = computed<RenderedDataGridColumn[]>(() => {
const columnNames = toValue(options.columnNames);
const visibleIndexes = toValue(options.visibleColumnIndexes);
const window = horizontalColumnWindow.value;
return visibleIndexes.slice(window.start, window.end).map((actualColIdx, offset) => ({
visibleColIdx: window.start + offset,
actualColIdx,
name: columnNames[actualColIdx] ?? "",
}));
});
function renderedColumnStyle(visibleColIdx: number) {
return { width: `var(--col-w-${visibleColIdx})` };
}
function columnContentOffsetLeft(visibleColIdx: number): number {
return options.rowNumberWidth + (renderedColumnOffsets.value[visibleColIdx] ?? 0);
}
const columnHeaderDragState = ref<ColumnHeaderDragState | null>(null);
const columnHeaderResizeActive = ref(false);
let columnHeaderDragClickGuardUntil = 0;
let columnHeaderSuppressNextClick = false;
let columnHeaderSuppressClickTimer = 0;
let columnHeaderDragFrame = 0;
let columnHeaderResizeFrame = 0;
let columnHeaderPendingClientX = 0;
let columnHeaderResizeListenersCleanup: (() => void) | null = null;
const columnHeaderTooltipsDisabled = computed(() =>
columnHeaderTooltipDisabled({
columnDragActive: columnHeaderDragState.value !== null,
columnResizeActive: columnHeaderResizeActive.value,
}),
);
function columnHeaderPointerInteractionActive(): boolean {
return columnHeaderCanvasPointerDisabled({
columnDragActive: columnHeaderDragState.value !== null,
columnResizeActive: columnHeaderResizeActive.value,
});
}
function clearColumnHeaderResizeListeners() {
columnHeaderResizeListenersCleanup?.();
columnHeaderResizeListenersCleanup = null;
}
function clearColumnHeaderClickGuard() {
columnHeaderSuppressNextClick = false;
columnHeaderDragClickGuardUntil = 0;
if (columnHeaderSuppressClickTimer) {
window.clearTimeout(columnHeaderSuppressClickTimer);
columnHeaderSuppressClickTimer = 0;
}
}
function armColumnHeaderClickGuard() {
clearColumnHeaderClickGuard();
columnHeaderSuppressNextClick = true;
columnHeaderDragClickGuardUntil = Date.now() + 800;
columnHeaderSuppressClickTimer = window.setTimeout(clearColumnHeaderClickGuard, 800);
}
function finishColumnHeaderResizeInteraction() {
clearColumnHeaderResizeListeners();
if (columnHeaderResizeFrame) cancelAnimationFrame(columnHeaderResizeFrame);
columnHeaderResizeFrame = requestAnimationFrame(() => {
columnHeaderResizeFrame = 0;
columnHeaderResizeActive.value = false;
});
}
function startColumnHeaderResize(visibleColIdx: number, event: MouseEvent) {
clearColumnHeaderResizeListeners();
if (columnHeaderResizeFrame) {
cancelAnimationFrame(columnHeaderResizeFrame);
columnHeaderResizeFrame = 0;
}
columnHeaderResizeActive.value = true;
armColumnHeaderClickGuard();
options.onCanvasMouseLeave?.();
const finishResize = () => {
armColumnHeaderClickGuard();
finishColumnHeaderResizeInteraction();
};
columnHeaderResizeListenersCleanup = () => {
window.removeEventListener("mouseup", finishResize, true);
window.removeEventListener("blur", finishResize, true);
};
window.addEventListener("mouseup", finishResize, true);
window.addEventListener("blur", finishResize, true);
options.onResizeStart?.(visibleColIdx, event);
}
function columnHeaderInteractiveTarget(target: EventTarget | null): boolean {
return target instanceof HTMLElement && !!target.closest("button, input, textarea, select, [contenteditable='true'], [role='button'], [data-column-resize-handle]");
}
function columnHeaderDropTargetVisibleIndex(clientX: number): number {
const state = columnHeaderDragState.value;
if (!state || state.columnRects.length === 0) return state?.sourceVisibleIndex ?? 0;
for (const rect of state.columnRects) {
if (clientX < rect.left + rect.width / 2) return rect.visibleIndex;
}
return toValue(options.visibleColumnIndexes).length;
}
function applyColumnHeaderDragPreview() {
columnHeaderDragFrame = 0;
const state = columnHeaderDragState.value;
if (!state?.dragging) return;
state.currentX = columnHeaderPendingClientX;
state.targetVisibleIndex = columnHeaderDropTargetVisibleIndex(columnHeaderPendingClientX);
options.onCanvasDrawSchedule?.();
}
function scheduleColumnHeaderDragPreview(clientX: number) {
columnHeaderPendingClientX = clientX;
if (columnHeaderDragFrame) return;
columnHeaderDragFrame = requestAnimationFrame(applyColumnHeaderDragPreview);
}
function flushColumnHeaderDragPreview() {
if (columnHeaderDragFrame) cancelAnimationFrame(columnHeaderDragFrame);
applyColumnHeaderDragPreview();
}
function cancelColumnHeaderDragPreview() {
if (!columnHeaderDragFrame) return;
cancelAnimationFrame(columnHeaderDragFrame);
columnHeaderDragFrame = 0;
}
function columnHeaderLayoutRects() {
const header = toValue(options.headerRef);
return Array.from(header?.querySelectorAll<HTMLElement>("[data-visible-col-index]") ?? [])
.map((element) => {
const rect = element.getBoundingClientRect();
return { visibleIndex: Number(element.dataset.visibleColIndex), left: rect.left, width: rect.width };
})
.filter((rect) => Number.isFinite(rect.visibleIndex));
}
function stopColumnHeaderDrag(commit: boolean) {
const state = columnHeaderDragState.value;
if (!state) return;
const hadCanvasPreview = state.dragging;
window.removeEventListener("pointermove", onColumnHeaderPointerMove, true);
window.removeEventListener("pointerup", onColumnHeaderPointerUp, true);
window.removeEventListener("pointercancel", onColumnHeaderPointerCancel, true);
cancelColumnHeaderDragPreview();
document.body.style.userSelect = "";
columnHeaderDragState.value = null;
if (hadCanvasPreview) options.onCanvasDrawSchedule?.();
if (state.dragging) armColumnHeaderClickGuard();
if (!commit || !state.dragging || state.sourceVisibleIndex === state.targetVisibleIndex) return;
const next = moveVisibleColumnIndex({
orderedIndexes: toValue(options.orderedColumnIndexes ?? options.visibleColumnIndexes),
hiddenIndexes: toValue(options.hiddenColumnIndexes ?? (() => new Set<number>())),
fromVisibleIndex: state.sourceVisibleIndex,
toVisibleIndex: state.targetVisibleIndex,
});
options.onPersistColumnOrder?.(next);
options.onRefreshMetrics?.();
}
function onColumnHeaderPointerMove(event: PointerEvent) {
const state = columnHeaderDragState.value;
if (!state) return;
const moved = Math.abs(event.clientX - state.startX) > 5 || Math.abs(event.clientY - state.startY) > 5;
if (!state.dragging && moved) {
state.dragging = true;
document.body.style.userSelect = "none";
options.onCanvasMouseLeave?.();
}
if (!state.dragging) return;
event.preventDefault();
scheduleColumnHeaderDragPreview(event.clientX);
}
function onColumnHeaderPointerUp(event: PointerEvent) {
columnHeaderPendingClientX = event.clientX;
flushColumnHeaderDragPreview();
stopColumnHeaderDrag(true);
}
function onColumnHeaderPointerCancel() {
stopColumnHeaderDrag(false);
}
function startColumnHeaderDrag(visibleColIdx: number, event: PointerEvent) {
if (event.button !== 0 || options.getIsResizing?.() || columnHeaderInteractiveTarget(event.target)) return;
columnHeaderDragState.value = {
sourceVisibleIndex: visibleColIdx,
targetVisibleIndex: visibleColIdx,
startX: event.clientX,
startY: event.clientY,
currentX: event.clientX,
columnRects: columnHeaderLayoutRects(),
dragging: false,
};
columnHeaderPendingClientX = event.clientX;
window.addEventListener("pointermove", onColumnHeaderPointerMove, true);
window.addEventListener("pointerup", onColumnHeaderPointerUp, true);
window.addEventListener("pointercancel", onColumnHeaderPointerCancel, true);
}
function suppressHeaderClickIfNeeded(event: MouseEvent): boolean {
if (!columnHeaderClickShouldBeSuppressed({ now: Date.now(), guardUntil: columnHeaderDragClickGuardUntil, suppressNextClick: columnHeaderSuppressNextClick })) return false;
clearColumnHeaderClickGuard();
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
return true;
}
function columnHeaderDragClass(visibleColIdx: number) {
const state = columnHeaderDragState.value;
return { "z-30 shadow-lg ring-1 ring-primary/40 bg-background dark:bg-muted pointer-events-none": state?.dragging && state.sourceVisibleIndex === visibleColIdx };
}
function columnHeaderPreviewOffset(visibleColIdx: number): number {
const state = columnHeaderDragState.value;
if (!state) return 0;
return columnHeaderPreviewOffsetForColumn({
columnDragActive: state.dragging,
visibleColIdx,
sourceVisibleIndex: state.sourceVisibleIndex,
targetVisibleIndex: state.targetVisibleIndex,
startX: state.startX,
currentX: state.currentX,
sourceWidth: toValue(options.renderedColumnWidths)[state.sourceVisibleIndex] ?? 0,
});
}
function columnHeaderStyle(visibleColIdx: number) {
const style = renderedColumnStyle(visibleColIdx);
const offset = columnHeaderPreviewOffset(visibleColIdx);
if (!offset) return style;
return { ...style, transform: `translateX(${offset}px)`, transition: columnHeaderDragState.value?.sourceVisibleIndex === visibleColIdx ? undefined : "transform 120ms ease-out" };
}
const columnHeaderPreviewOffsets = computed(() => toValue(options.renderedColumnWidths).map((_, visibleColIdx) => columnHeaderPreviewOffset(visibleColIdx)));
const columnHeaderPreviewSourceVisibleIndex = computed(() => {
const state = columnHeaderDragState.value;
return state?.dragging ? state.sourceVisibleIndex : null;
});
function disposeColumnHeaderInteractions() {
stopColumnHeaderDrag(false);
clearColumnHeaderResizeListeners();
clearColumnHeaderClickGuard();
if (columnHeaderDragFrame) cancelAnimationFrame(columnHeaderDragFrame);
if (columnHeaderResizeFrame) cancelAnimationFrame(columnHeaderResizeFrame);
columnHeaderResizeFrame = 0;
document.body.style.userSelect = "";
}
onScopeDispose(disposeColumnHeaderInteractions);
return {
renderedColumnOffsets,
horizontalColumnWindow,
renderedGridColumns,
renderedColumnStyle,
columnContentOffsetLeft,
columnHeaderDragState,
columnHeaderResizeActive,
columnHeaderTooltipsDisabled,
columnHeaderPreviewOffsets,
columnHeaderPreviewSourceVisibleIndex,
columnHeaderPointerInteractionActive,
startColumnHeaderResize,
startColumnHeaderDrag,
suppressHeaderClickIfNeeded,
columnHeaderDragClass,
columnHeaderStyle,
disposeColumnHeaderInteractions,
};
}
@@ -0,0 +1,213 @@
import { computed, getCurrentScope, onScopeDispose, ref, toValue, watch, type MaybeRefOrGetter, type Ref } from "vue";
import { forgetDataGridConditionHistory, loadDataGridConditionHistory, rememberDataGridConditionHistory, type DataGridConditionHistoryKind, type DataGridConditionHistoryScope } from "@/lib/dataGrid/dataGridConditionHistory";
export type DataGridConditionSuggestionKind = "column" | "history";
export interface DataGridConditionSuggestion {
value: string;
kind: DataGridConditionSuggestionKind;
}
export interface DataGridConditionSuggestionContext {
kind: DataGridConditionHistoryKind;
value: string;
token: string;
signal: AbortSignal;
}
export type DataGridConditionSuggestionProvider = (context: DataGridConditionSuggestionContext) => readonly string[] | Promise<readonly string[]>;
export interface UseDataGridConditionEditorOptions {
kind: DataGridConditionHistoryKind;
value: Ref<string>;
columns?: MaybeRefOrGetter<readonly string[] | undefined>;
historyScope: MaybeRefOrGetter<DataGridConditionHistoryScope>;
suggestionProvider?: DataGridConditionSuggestionProvider;
suggestionDebounceMs?: number;
suggestionLimit?: number;
}
const WHERE_TOKEN_PATTERN = /([^\s,()><=!&|]+)$/;
const ORDER_BY_TOKEN_PATTERN = /([^\s,()]+)$/;
function activeToken(kind: DataGridConditionHistoryKind, value: string): string {
return (
value
.trim()
.split(kind === "where" ? /[\s,()><=!&|]+/ : /[\s,()]+/)
.pop() ?? ""
);
}
function replaceActiveToken(kind: DataGridConditionHistoryKind, value: string, replacement: string): string {
const match = value.match(kind === "where" ? WHERE_TOKEN_PATTERN : ORDER_BY_TOKEN_PATTERN);
if (!match) return value;
return `${value.slice(0, -match[1].length)}${replacement}`;
}
export function useDataGridConditionEditor(options: UseDataGridConditionEditorOptions) {
const suggestions = ref<DataGridConditionSuggestion[]>([]);
const highlightedIndex = ref(-1);
const historyOpen = ref(false);
const suggestionsLoading = ref(false);
let suggestionTimer: ReturnType<typeof setTimeout> | undefined;
let suggestionRequestId = 0;
let suggestionAbortController: AbortController | undefined;
let suppressNextValueSuggestion = false;
const dropdownOpen = computed(() => suggestions.value.length > 0 || historyOpen.value);
function cancelSuggestionRequest() {
if (suggestionTimer) clearTimeout(suggestionTimer);
suggestionTimer = undefined;
suggestionRequestId += 1;
suggestionAbortController?.abort();
suggestionAbortController = undefined;
suggestionsLoading.value = false;
}
function dismiss() {
cancelSuggestionRequest();
suggestions.value = [];
highlightedIndex.value = -1;
historyOpen.value = false;
}
function defaultSuggestions(token: string): readonly string[] {
const normalizedToken = token.toLowerCase();
if (!normalizedToken) return [];
return (toValue(options.columns) ?? []).filter((column) => column.toLowerCase().startsWith(normalizedToken) && column.toLowerCase() !== normalizedToken);
}
async function loadSuggestions(value: string, requestId: number, controller: AbortController) {
const token = activeToken(options.kind, value);
if (!token) return;
suggestionsLoading.value = true;
try {
const values = options.suggestionProvider ? await options.suggestionProvider({ kind: options.kind, value, token, signal: controller.signal }) : defaultSuggestions(token);
// A slower request must never replace suggestions for a newer editor value.
if (controller.signal.aborted || requestId !== suggestionRequestId || options.value.value !== value || historyOpen.value) return;
const limit = options.suggestionLimit ?? 8;
suggestions.value = [...new Set(values)].slice(0, limit).map((suggestion) => ({ value: suggestion, kind: "column" }));
highlightedIndex.value = suggestions.value.length > 0 ? 0 : -1;
} catch (error) {
if (!controller.signal.aborted && requestId === suggestionRequestId) {
suggestions.value = [];
highlightedIndex.value = -1;
console.warn("[DBX][condition-editor] Failed to load suggestions", error);
}
} finally {
if (requestId === suggestionRequestId) suggestionsLoading.value = false;
}
}
function scheduleSuggestions(value: string) {
cancelSuggestionRequest();
suggestions.value = [];
highlightedIndex.value = -1;
historyOpen.value = false;
if (!value.trim()) return;
const requestId = suggestionRequestId;
const controller = new AbortController();
suggestionAbortController = controller;
suggestionTimer = setTimeout(() => {
suggestionTimer = undefined;
void loadSuggestions(value, requestId, controller);
}, options.suggestionDebounceMs ?? 0);
}
function openHistory() {
cancelSuggestionRequest();
if (dropdownOpen.value) {
dismiss();
return;
}
historyOpen.value = true;
suggestions.value = loadDataGridConditionHistory(options.kind, toValue(options.historyScope), options.value.value).map((value) => ({ value, kind: "history" }));
highlightedIndex.value = -1;
}
function deleteHistory(value: string) {
const history = forgetDataGridConditionHistory(options.kind, toValue(options.historyScope), value);
const query = options.value.value.trim().toLowerCase();
suggestions.value = history.filter((item) => !query || item.toLowerCase().includes(query)).map((item) => ({ value: item, kind: "history" }));
highlightedIndex.value = suggestions.value.length > 0 ? Math.min(Math.max(highlightedIndex.value, 0), suggestions.value.length - 1) : -1;
historyOpen.value = true;
}
function rememberHistory(value = options.value.value) {
return rememberDataGridConditionHistory(options.kind, toValue(options.historyScope), value);
}
function navigate(delta: number) {
if (suggestions.value.length === 0) return false;
if (highlightedIndex.value < 0) {
highlightedIndex.value = delta > 0 ? 0 : suggestions.value.length - 1;
} else {
highlightedIndex.value = Math.min(Math.max(highlightedIndex.value + delta, 0), suggestions.value.length - 1);
}
return true;
}
function accept(index = highlightedIndex.value) {
const suggestion = suggestions.value[index];
if (!suggestion) return false;
suppressNextValueSuggestion = true;
options.value.value = suggestion.kind === "history" ? suggestion.value : replaceActiveToken(options.kind, options.value.value, suggestion.value);
dismiss();
return true;
}
function handleKeydown(event: KeyboardEvent): "accept" | "apply" | "dismiss" | "navigate" | undefined {
if (dropdownOpen.value && event.key === "Escape") {
event.preventDefault();
dismiss();
return "dismiss";
}
if (suggestions.value.length > 0 && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
event.preventDefault();
navigate(event.key === "ArrowDown" ? 1 : -1);
return "navigate";
}
if (suggestions.value.length > 0 && event.key === "Tab") {
event.preventDefault();
accept();
return "accept";
}
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
if (suggestions.value.length > 0 && highlightedIndex.value >= 0) {
accept();
return "accept";
}
return "apply";
}
return undefined;
}
watch(options.value, (value) => {
if (suppressNextValueSuggestion) {
suppressNextValueSuggestion = false;
return;
}
scheduleSuggestions(value);
});
if (getCurrentScope()) onScopeDispose(cancelSuggestionRequest);
return {
suggestions,
highlightedIndex,
historyOpen,
suggestionsLoading,
dropdownOpen,
scheduleSuggestions,
openHistory,
deleteHistory,
rememberHistory,
dismiss,
navigate,
accept,
handleKeydown,
};
}
@@ -0,0 +1,93 @@
import { computed, ref, toValue, watch, type MaybeRefOrGetter } from "vue";
import { filterModeNeedsValue, filterModeUsesRange } from "@/lib/dataGrid/dataGridColumnFilter";
import type { DataGridContextFilterMode } from "@/lib/dataGrid/dataGridSql";
export type DataGridStructuredFilterRule = {
id: string;
columnName: string;
mode: DataGridContextFilterMode;
rawValue: string;
rawEndValue: string;
conjunction: "AND" | "OR";
disabled?: boolean;
};
export type UseDataGridFilterBuilderOptions = {
columns: MaybeRefOrGetter<readonly string[]>;
createId?: () => string;
isComplete: (rule: DataGridStructuredFilterRule) => boolean;
buildCondition: (rule: DataGridStructuredFilterRule) => Promise<string | undefined>;
};
export function buildDataGridStructuredWhere(items: Array<{ rule: DataGridStructuredFilterRule; condition: string }>): string {
if (!items.length) return "";
let result = items[0].condition;
for (let index = 1; index < items.length; index++) {
result = `(${result}) ${items[index].rule.conjunction} (${items[index].condition})`;
}
return result;
}
export function useDataGridFilterBuilder(options: UseDataGridFilterBuilderOptions) {
const rules = ref<DataGridStructuredFilterRule[]>([]);
const open = ref(false);
const columnSearch = ref("");
const appliedWhereInput = ref("");
const filteredColumns = computed(() => {
const query = columnSearch.value.trim().toLowerCase();
return query ? toValue(options.columns).filter((column) => column.toLowerCase().includes(query)) : [...toValue(options.columns)];
});
const activeCount = computed(() => rules.value.filter((rule) => !rule.disabled && rule.columnName && options.isComplete(rule)).length);
function defaultRule(): DataGridStructuredFilterRule {
return { id: options.createId?.() ?? crypto.randomUUID(), columnName: toValue(options.columns)[0] ?? "", mode: "equals", rawValue: "", rawEndValue: "", conjunction: "AND" };
}
function ensureRule() {
if (!rules.value.length && toValue(options.columns).length) rules.value = [defaultRule()];
}
function addRule() {
ensureRule();
rules.value = [...rules.value, defaultRule()];
}
function removeRule(id: string) {
rules.value = rules.value.filter((rule) => rule.id !== id);
if (!rules.value.length) appliedWhereInput.value = "";
}
function updateRule(id: string, patch: Partial<DataGridStructuredFilterRule>) {
rules.value = rules.value.map((rule) => {
if (rule.id !== id) return rule;
const next = { ...rule, ...patch };
if (!filterModeNeedsValue(next.mode)) next.rawValue = next.rawEndValue = "";
else if (!filterModeUsesRange(next.mode)) next.rawEndValue = "";
return next;
});
}
function reset() {
appliedWhereInput.value = "";
rules.value = toValue(options.columns).length ? [defaultRule()] : [];
}
async function buildWhere() {
const items = (await Promise.all(rules.value.map(async (rule) => ({ rule, condition: !rule.disabled && rule.columnName && options.isComplete(rule) ? await options.buildCondition(rule) : undefined })))).filter(
(item): item is { rule: DataGridStructuredFilterRule; condition: string } => !!item.condition,
);
return buildDataGridStructuredWhere(items);
}
async function apply() {
appliedWhereInput.value = await buildWhere();
open.value = false;
return appliedWhereInput.value;
}
watch(open, (value) => {
if (!value) columnSearch.value = "";
});
watch(
() => [...toValue(options.columns)],
(columns) => {
if (!columns.length) rules.value = [];
else rules.value = rules.value.map((rule) => (columns.includes(rule.columnName) ? rule : { ...rule, columnName: columns[0] }));
},
);
return { rules, open, columnSearch, appliedWhereInput, filteredColumns, activeCount, defaultRule, ensureRule, addRule, removeRule, updateRule, reset, buildWhere, apply };
}
@@ -0,0 +1,38 @@
import { onUnmounted, watch, type Ref } from "vue";
import type { DataGridRuntimeScope } from "@/lib/dataGrid/dataGridRuntime";
export interface UseDataGridResultLifecycleOptions {
resultKey: Ref<unknown>;
runtimeScope?: DataGridRuntimeScope;
}
export function useDataGridResultLifecycle(options: UseDataGridResultLifecycleOptions) {
let version = 0;
let disposed = false;
const invalidate = () => {
version += 1;
};
const stopWatchingResult = watch(options.resultKey, invalidate);
const cleanup = () => {
if (disposed) return;
disposed = true;
invalidate();
stopWatchingResult();
};
if (options.runtimeScope) options.runtimeScope.addCleanup(cleanup);
else onUnmounted(cleanup);
return {
beginOperation() {
return version;
},
isCurrent(operationVersion: number) {
return !disposed && operationVersion === version;
},
invalidate,
cleanup,
};
}
@@ -0,0 +1,126 @@
import { getCurrentScope, onScopeDispose } from "vue";
import type { DataGridAnimationFrameDriver } from "./useDataGridCanvasRuntime";
export interface DataGridScrollbarsOptions {
update(): void;
getScroller(): Element | null;
applyHorizontalDrag(clientX: number): void;
applyVerticalDrag(clientY: number): void;
frameDriver: DataGridAnimationFrameDriver;
createResizeObserver?: (callback: ResizeObserverCallback) => ResizeObserver | null;
startPaused?: boolean;
}
export interface DataGridScrollbarsRuntime {
readonly active: boolean;
readonly disposed: boolean;
scheduleUpdate(): void;
scheduleHorizontalDrag(clientX: number): void;
scheduleVerticalDrag(clientY: number): void;
flushHorizontalDrag(): void;
flushVerticalDrag(): void;
observeScroller(): void;
pause(): void;
resume(): void;
dispose(): void;
}
function defaultResizeObserverFactory(callback: ResizeObserverCallback): ResizeObserver | null {
return typeof ResizeObserver === "undefined" ? null : new ResizeObserver(callback);
}
export function useDataGridScrollbars(options: DataGridScrollbarsOptions): DataGridScrollbarsRuntime {
const createResizeObserver = options.createResizeObserver ?? defaultResizeObserverFactory;
let active = !options.startPaused;
let disposed = false;
let updateFrame: number | null = null;
let horizontalFrame: number | null = null;
let verticalFrame: number | null = null;
let pendingClientX = 0;
let pendingClientY = 0;
let resizeObserver: ResizeObserver | null = null;
const cancelFrame = (frame: number | null) => {
if (frame !== null) options.frameDriver.cancel(frame);
};
const runtime: DataGridScrollbarsRuntime = {
get active() {
return active;
},
get disposed() {
return disposed;
},
scheduleUpdate() {
if (!active || disposed || updateFrame !== null) return;
updateFrame = options.frameDriver.request(() => {
updateFrame = null;
if (active && !disposed) options.update();
});
},
scheduleHorizontalDrag(clientX) {
pendingClientX = clientX;
if (!active || disposed || horizontalFrame !== null) return;
horizontalFrame = options.frameDriver.request(() => {
horizontalFrame = null;
if (active && !disposed) options.applyHorizontalDrag(pendingClientX);
});
},
scheduleVerticalDrag(clientY) {
pendingClientY = clientY;
if (!active || disposed || verticalFrame !== null) return;
verticalFrame = options.frameDriver.request(() => {
verticalFrame = null;
if (active && !disposed) options.applyVerticalDrag(pendingClientY);
});
},
flushHorizontalDrag() {
if (!active || disposed || horizontalFrame === null) return;
options.frameDriver.cancel(horizontalFrame);
horizontalFrame = null;
options.applyHorizontalDrag(pendingClientX);
},
flushVerticalDrag() {
if (!active || disposed || verticalFrame === null) return;
options.frameDriver.cancel(verticalFrame);
verticalFrame = null;
options.applyVerticalDrag(pendingClientY);
},
observeScroller() {
resizeObserver?.disconnect();
resizeObserver = null;
if (!active || disposed) return;
const scroller = options.getScroller();
if (scroller) {
resizeObserver = createResizeObserver(runtime.scheduleUpdate);
resizeObserver?.observe(scroller);
// Child size changes can alter scrollWidth without resizing the scroller itself.
for (const child of Array.from(scroller.children)) resizeObserver?.observe(child);
}
runtime.scheduleUpdate();
},
pause() {
active = false;
resizeObserver?.disconnect();
resizeObserver = null;
cancelFrame(updateFrame);
cancelFrame(horizontalFrame);
cancelFrame(verticalFrame);
updateFrame = null;
horizontalFrame = null;
verticalFrame = null;
},
resume() {
if (disposed) return;
active = true;
runtime.observeScroller();
},
dispose() {
if (disposed) return;
runtime.pause();
disposed = true;
},
};
if (getCurrentScope()) onScopeDispose(runtime.dispose);
return runtime;
}
@@ -0,0 +1,131 @@
import { computed, getCurrentScope, nextTick, onScopeDispose, ref, toValue, watch, type MaybeRefOrGetter } from "vue";
export type DataGridSearchMatch = {
kind: "cell" | "column";
displayRow: number;
col: number;
};
export type UseDataGridSearchOptions<Row> = {
columns: MaybeRefOrGetter<readonly string[]>;
suggestionColumns?: MaybeRefOrGetter<readonly string[]>;
rows: MaybeRefOrGetter<readonly Row[]>;
getCellText: (row: Row, columnIndex: number) => string;
debounceMs?: number;
onNavigate?: (match: DataGridSearchMatch) => void;
};
const SEARCH_TOKEN_SEPARATOR = /[\s,()><=!&|]+/;
const SEARCH_TOKEN_SUFFIX = /([^\s,()><=!&|]+)$/;
const SEARCH_PAIRS: Record<string, string> = { "'": "'", '"': '"', "(": ")" };
export function useDataGridSearch<Row>(options: UseDataGridSearchOptions<Row>) {
const searchText = ref("");
const deferredSearchText = ref("");
const overlayVisible = ref(false);
const currentMatchIndex = ref(-1);
const suggestions = ref<string[]>([]);
const suggestionIndex = ref(-1);
let searchTimer: ReturnType<typeof setTimeout> | undefined;
const matches = computed<DataGridSearchMatch[]>(() => {
const query = deferredSearchText.value;
if (!query) return [];
const result: DataGridSearchMatch[] = [];
const columns = toValue(options.columns);
columns.forEach((column, col) => {
if (column.toLowerCase().includes(query)) result.push({ kind: "column", displayRow: -1, col });
});
toValue(options.rows).forEach((row, displayRow) => {
columns.forEach((_, col) => {
if (options.getCellText(row, col).toLowerCase().includes(query)) result.push({ kind: "cell", displayRow, col });
});
});
return result;
});
const matchSet = computed(() => new Set(matches.value.map((match) => `${match.kind}:${match.displayRow}:${match.col}`)));
const currentMatch = computed(() => matches.value[currentMatchIndex.value] ?? null);
function clearTimer() {
if (searchTimer !== undefined) clearTimeout(searchTimer);
searchTimer = undefined;
}
watch(searchText, (value) => {
clearTimer();
const query = value.trim().toLowerCase();
if (!query) deferredSearchText.value = "";
else searchTimer = setTimeout(() => (deferredSearchText.value = query), options.debounceMs ?? 150);
const lastToken = value.trim().split(SEARCH_TOKEN_SEPARATOR).pop()?.toLowerCase() ?? "";
suggestions.value = lastToken
? toValue(options.suggestionColumns ?? options.columns)
.filter((column) => column.toLowerCase().startsWith(lastToken) && column.toLowerCase() !== lastToken)
.slice(0, 8)
: [];
suggestionIndex.value = suggestions.value.length ? 0 : -1;
});
watch(matches, (value) => {
currentMatchIndex.value = value.length ? 0 : -1;
if (value[0]) nextTick(() => options.onNavigate?.(value[0]));
});
function acceptSuggestion(index = suggestionIndex.value) {
const suggestion = suggestions.value[index];
if (!suggestion) return false;
const token = searchText.value.match(SEARCH_TOKEN_SUFFIX)?.[1];
if (token) searchText.value = searchText.value.slice(0, -token.length) + suggestion;
suggestions.value = [];
suggestionIndex.value = -1;
return true;
}
function navigateSuggestion(delta: number) {
if (!suggestions.value.length) return;
suggestionIndex.value = Math.min(Math.max(suggestionIndex.value + delta, 0), suggestions.value.length - 1);
}
function navigateMatch(delta: number) {
if (!matches.value.length) return;
currentMatchIndex.value = (currentMatchIndex.value + delta + matches.value.length) % matches.value.length;
const match = currentMatch.value;
if (match) options.onNavigate?.(match);
}
function close() {
clearTimer();
overlayVisible.value = false;
searchText.value = "";
deferredSearchText.value = "";
suggestions.value = [];
}
function onKeydown(event: KeyboardEvent) {
const pair = SEARCH_PAIRS[event.key];
const input = event.target as HTMLInputElement;
if (pair && !event.ctrlKey && !event.metaKey && input?.setSelectionRange) {
const start = input.selectionStart ?? 0;
const end = input.selectionEnd ?? start;
event.preventDefault();
const selected = searchText.value.slice(start, end);
searchText.value = `${searchText.value.slice(0, start)}${event.key}${selected}${pair}${searchText.value.slice(end)}`;
nextTick(() => input.setSelectionRange(start + 1 + selected.length, start + 1 + selected.length));
return;
}
if (suggestions.value.length && event.key === "Tab") {
event.preventDefault();
acceptSuggestion();
} else if (suggestions.value.length && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
event.preventDefault();
navigateSuggestion(event.key === "ArrowDown" ? 1 : -1);
} else if (event.key === "Enter") {
event.preventDefault();
navigateMatch(event.shiftKey ? -1 : 1);
}
}
if (getCurrentScope()) onScopeDispose(clearTimer);
return { searchText, deferredSearchText, overlayVisible, currentMatchIndex, suggestions, suggestionIndex, matches, matchSet, currentMatch, acceptSuggestion, navigateSuggestion, navigateMatch, close, onKeydown };
}
@@ -1,5 +1,6 @@
import { ref, computed, getCurrentScope, onScopeDispose, type ComputedRef, type Ref } from "vue";
import { allCellsSelectionRange, extractColumnsSelection, extractSelection, isCellInSelection, normalizeSelectionRange, normalizeSelectedColumnIndexes, rowSelectionRange, type CellPosition, type CellSelectionRange, type SelectionData } from "@/lib/dataGrid/gridSelection";
import type { DataGridRuntimeScope } from "@/lib/dataGrid/dataGridRuntime";
type CellValue = string | number | boolean | null;
@@ -24,6 +25,7 @@ export interface UseDataGridSelectionOptions {
gridRef: Ref<HTMLDivElement | undefined>;
getScrollElement?: () => HTMLElement | null;
cellFromClientPoint?: (clientX: number, clientY: number) => CellPosition | null;
runtimeScope?: DataGridRuntimeScope;
}
const AUTO_SCROLL_EDGE_SIZE = 40;
@@ -341,7 +343,8 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) {
document.addEventListener("mousemove", handleSelectionPointerMove);
}
if (getCurrentScope()) onScopeDispose(finishCellSelection);
if (options.runtimeScope) options.runtimeScope.addCleanup(finishCellSelection);
else if (getCurrentScope()) onScopeDispose(finishCellSelection);
function extendCellSelection(rowIndex: number, colIndex: number) {
if (!isSelectingCells.value || !selectionAnchor.value) return;
@@ -0,0 +1,29 @@
import { ref } from "vue";
import type { DataGridSortDirection, DataGridSortMode } from "@/lib/dataGrid/dataGridSort";
export function useDataGridSort() {
const sortColumn = ref<string | null>(null);
const sortColumnIndex = ref<number | null>(null);
const sortDirection = ref<DataGridSortDirection>("asc");
const sortMode = ref<DataGridSortMode>("database");
function setSort(column: string | null, columnIndex: number | null, direction: DataGridSortDirection, mode: DataGridSortMode) {
sortColumn.value = column;
sortColumnIndex.value = columnIndex;
sortDirection.value = direction;
sortMode.value = mode;
}
function clearSort() {
setSort(null, null, "asc", "database");
}
return {
sortColumn,
sortColumnIndex,
sortDirection,
sortMode,
setSort,
clearSort,
};
}