fix(grid): preserve caret when condition editor collapses

This commit is contained in:
zipg 2026-07-31 16:25:37 +08:00 committed by GitHub
parent 7b95f0ca17
commit bdb941b606
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 226 additions and 21 deletions

View File

@ -94,11 +94,13 @@ const previewStyle = computed<CSSProperties>(() => {
});
const previewArrowStyle = computed<CSSProperties>(() => ({ top: `${historyPreview.value?.arrowTop ?? 0}px` }));
function createTextProbe(input: HTMLTextAreaElement, wrap: boolean) {
function createTextProbe(input: HTMLTextAreaElement, wrap: boolean, options: { width?: number; textIndent?: number } = {}) {
const probe = document.createElement(wrap ? "div" : "span");
const style = window.getComputedStyle(input);
const width = options.width ?? input.clientWidth;
const textIndent = options.textIndent !== undefined ? `${options.textIndent}px` : style.textIndent;
probe.textContent = input.value || input.placeholder || "";
probe.style.cssText = `position:fixed;left:-9999px;top:-9999px;visibility:hidden;box-sizing:border-box;${wrap ? `width:${input.clientWidth}px;white-space:pre-wrap;overflow-wrap:normal;padding:${style.paddingTop} ${style.paddingRight} ${style.paddingBottom} ${style.paddingLeft};` : "white-space:pre;"}font:${style.font};font-size:${style.fontSize};font-family:${style.fontFamily};font-weight:${style.fontWeight};line-height:${style.lineHeight};letter-spacing:${style.letterSpacing};`;
probe.style.cssText = `position:fixed;left:-9999px;top:-9999px;visibility:hidden;box-sizing:border-box;${wrap ? `width:${width}px;white-space:pre-wrap;overflow-wrap:anywhere;padding:${style.paddingTop} ${style.paddingRight} ${style.paddingBottom} ${style.paddingLeft};text-indent:${textIndent};` : "white-space:pre;"}font:${style.font};font-size:${style.fontSize};font-family:${style.fontFamily};font-weight:${style.fontWeight};line-height:${style.lineHeight};letter-spacing:${style.letterSpacing};`;
document.body.appendChild(probe);
return probe;
}
@ -111,8 +113,8 @@ function shouldExpand(input: HTMLTextAreaElement) {
return should;
}
function measureExpandedHeight(input: HTMLTextAreaElement) {
const probe = createTextProbe(input, true);
function measureExpandedHeight(input: HTMLTextAreaElement, rect: typeof expandedRect.value) {
const probe = createTextProbe(input, true, { width: Math.max(1, rect.width - 8), textIndent: rect.prefix });
const style = window.getComputedStyle(input);
const lineHeight = Number.parseFloat(style.lineHeight) || 24;
const contentHeight = probe.scrollHeight;
@ -121,11 +123,30 @@ function measureExpandedHeight(input: HTMLTextAreaElement) {
return Math.min(Math.max(56, lineHeight * 2.5, contentHeight), Math.min(260, availableHeight));
}
function maxExpandedHeight() {
const input = inputRef.value;
const top = input?.getBoundingClientRect().top ?? expandedRect.value.top;
return Math.min(260, Math.max(56, window.innerHeight - top - 12));
}
function fitExpandedHeightToOverlay() {
const overlay = overlayRef.value;
if (!overlay) return;
const overflow = overlay.scrollHeight - overlay.clientHeight;
if (overflow > 4) {
expandedHeight.value = Math.min(maxExpandedHeight(), expandedHeight.value + overflow);
} else if (overflow <= 0) {
overlay.scrollTop = 0;
}
}
function measureExpandedRect(input: HTMLTextAreaElement) {
const inputRect = input.getBoundingClientRect();
const control = controlRef.value;
const controlRect = control?.getBoundingClientRect() ?? inputRect;
const controlsRect = control?.firstElementChild?.getBoundingClientRect() ?? controlRect;
const label = control?.querySelector<HTMLElement>(".data-grid-topbar-condition-label");
const labelPrefix = label ? label.scrollWidth + 4 : 0;
const horizontalInset = 8;
return {
left: controlRect.left - horizontalInset,
@ -133,7 +154,7 @@ function measureExpandedRect(input: HTMLTextAreaElement) {
width: controlRect.width + horizontalInset * 2,
controlsTop: Math.max(0, controlsRect.top - controlRect.top),
inputTop: Math.max(0, inputRect.top - controlRect.top),
prefix: Math.max(0, inputRect.left - controlRect.left),
prefix: Math.max(0, inputRect.left - controlRect.left, labelPrefix),
suffix: Math.max(28, controlRect.right - inputRect.right),
};
}
@ -172,8 +193,10 @@ function resizeEditor(forceExpand = false) {
const focused = document.activeElement === input || overlayFocused;
const nextExpanded = focused && shouldExpand(input) && (forceExpand || expanded.value);
if (nextExpanded) {
expandedRect.value = measureExpandedRect(input);
expandedHeight.value = measureExpandedHeight(input);
const nextRect = measureExpandedRect(input);
expandedRect.value = nextRect;
expandedHeight.value = measureExpandedHeight(input, nextRect);
void nextTick(fitExpandedHeightToOverlay);
}
expanded.value = nextExpanded;
updateSuggestionPosition();
@ -181,15 +204,25 @@ function resizeEditor(forceExpand = false) {
void nextTick(() => {
const overlay = overlayRef.value;
if (!overlay || composing.value) return;
syncSelection(input);
overlay.focus();
overlay.setSelectionRange(selectionStart.value, selectionEnd.value);
const start = selectionStart.value;
const end = selectionEnd.value;
overlay.setSelectionRange(start, end);
overlay.focus({ preventScroll: true });
overlay.setSelectionRange(start, end);
selectionStart.value = start;
selectionEnd.value = end;
scheduleCaretIntoView();
});
}
if (!nextExpanded && overlayFocused && !composing.value) {
void nextTick(() => {
input.focus();
input.setSelectionRange(selectionStart.value, selectionEnd.value);
const start = selectionStart.value;
const end = selectionEnd.value;
input.focus({ preventScroll: true });
input.setSelectionRange(start, end);
selectionStart.value = start;
selectionEnd.value = end;
scheduleCaretIntoView();
});
}
});
@ -220,7 +253,14 @@ function focus(select = false) {
function scrollCaretIntoView() {
const target = activeEditor.value;
if (!target || target.scrollHeight <= target.clientHeight) return;
if (!target) return;
const hasVerticalOverflow = target.scrollHeight > target.clientHeight + 4;
const hasHorizontalOverflow = target.scrollWidth > target.clientWidth + 1;
if (!hasVerticalOverflow && !hasHorizontalOverflow) {
target.scrollTop = 0;
target.scrollLeft = 0;
return;
}
const style = window.getComputedStyle(target);
const probe = document.createElement("div");
const caretMarker = document.createElement("span");
@ -232,19 +272,40 @@ function scrollCaretIntoView() {
document.body.appendChild(probe);
const lineHeight = Number.parseFloat(style.lineHeight) || 24;
const caretTop = caretMarker.offsetTop;
const caretLeft = caretMarker.offsetLeft;
const topPadding = Number.parseFloat(style.paddingTop) || 0;
const bottomPadding = Number.parseFloat(style.paddingBottom) || 0;
const leftPadding = Number.parseFloat(style.paddingLeft) || 0;
const rightPadding = Number.parseFloat(style.paddingRight) || 0;
const visibleTop = target.scrollTop + topPadding;
const visibleBottom = target.scrollTop + target.clientHeight - bottomPadding;
if (caretTop < visibleTop) target.scrollTop = Math.max(0, caretTop - topPadding);
else if (caretTop + lineHeight > visibleBottom) target.scrollTop = caretTop + lineHeight + bottomPadding - target.clientHeight;
const visibleLeft = target.scrollLeft + leftPadding;
const visibleRight = target.scrollLeft + target.clientWidth - rightPadding;
if (hasVerticalOverflow) {
if (caretTop < visibleTop) target.scrollTop = Math.max(0, caretTop - topPadding);
else if (caretTop + lineHeight > visibleBottom) target.scrollTop = caretTop + lineHeight + bottomPadding - target.clientHeight;
} else {
target.scrollTop = 0;
}
if (hasHorizontalOverflow) {
if (caretLeft < visibleLeft) target.scrollLeft = Math.max(0, caretLeft - leftPadding);
else if (caretLeft > visibleRight) target.scrollLeft = caretLeft + rightPadding - target.clientWidth;
} else {
target.scrollLeft = 0;
}
probe.remove();
}
function scheduleCaretIntoView() {
void nextTick(() => {
requestAnimationFrame(() => scrollCaretIntoView());
});
}
function focusAfterAccept() {
void nextTick(() => {
focus();
void nextTick(scrollCaretIntoView);
scheduleCaretIntoView();
});
}
@ -280,6 +341,7 @@ function onInput(event: Event) {
syncSelection(event.currentTarget as HTMLTextAreaElement);
resizeEditor(true);
updateSuggestionPosition();
scheduleCaretIntoView();
}
async function applyCondition() {
@ -592,6 +654,12 @@ defineExpose({ focus, dismiss: editor.dismiss, rememberHistory: editor.rememberH
transform: translateX(-4px);
}
.data-grid-topbar-condition-label--floating.data-grid-topbar-condition-label--compact {
max-width: 5rem;
opacity: 1;
transform: translateX(0);
}
.data-grid-topbar-condition-input,
.data-grid-condition-suggestion-field {
font-family: var(--data-grid-condition-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);
@ -681,7 +749,7 @@ defineExpose({ focus, dismiss: editor.dismiss, rememberHistory: editor.rememberH
overflow-x: hidden;
overflow-y: auto;
white-space: pre-wrap;
overflow-wrap: normal;
overflow-wrap: anywhere;
border: 0;
border-radius: 0;
background: transparent;

View File

@ -33,6 +33,27 @@ function mountEditor(kind: DataGridConditionHistoryKind, initialValue: string, o
return { value, input: host.querySelector("textarea") as HTMLTextAreaElement };
}
function mockTextareaMetrics(input: HTMLTextAreaElement, options: { clientWidth: number; scrollWidth?: number; clientHeight?: number; scrollHeight?: number }) {
Object.defineProperties(input, {
clientWidth: { configurable: true, value: options.clientWidth },
scrollWidth: { configurable: true, value: options.scrollWidth ?? options.clientWidth },
clientHeight: { configurable: true, value: options.clientHeight ?? 24 },
scrollHeight: { configurable: true, value: options.scrollHeight ?? 24 },
});
input.getBoundingClientRect = () =>
({
x: 0,
y: 0,
left: 0,
top: 0,
right: options.clientWidth,
bottom: options.clientHeight ?? 24,
width: options.clientWidth,
height: options.clientHeight ?? 24,
toJSON: () => ({}),
}) as DOMRect;
}
afterEach(() => {
for (const { app, host } of mountedApps.splice(0)) {
app.unmount();
@ -102,26 +123,36 @@ describe("DataGridConditionEditor quote completion", () => {
expect(input.selectionEnd).toBe(20);
});
it("keeps expanded input first-line indent without forced word breaks", () => {
it("keeps expanded input first-line indent and wraps long tokens", () => {
const source = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/grid/DataGridConditionEditor.vue"), "utf8");
const expandedInputCss = source.match(/\.data-grid-topbar-condition-input--expanded\s*\{(?<body>[\s\S]*?)\n\}/)?.groups?.body;
expect(expandedInputCss).toContain("padding:");
expect(expandedInputCss).toContain("0.0625rem 0.125rem");
expect(expandedInputCss).toContain("text-indent: var(--data-grid-condition-prefix-indent)");
expect(expandedInputCss).toContain("overflow-wrap: normal");
expect(source).toContain("white-space:pre-wrap;overflow-wrap:normal;");
expect(expandedInputCss).toContain("overflow-wrap: anywhere");
expect(source).toContain("white-space:pre-wrap;overflow-wrap:anywhere;");
expect(source).toContain("text-indent:${style.textIndent};");
expect(source).toContain("textIndent: rect.prefix");
expect(source).toContain("width: Math.max(1, rect.width - 8)");
expect(source).toContain("function fitExpandedHeightToOverlay()");
expect(source).toContain("expandedHeight.value + overflow");
});
it("keeps the expanded condition label readable over wrapped content", () => {
const source = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/grid/DataGridConditionEditor.vue"), "utf8");
const floatingControls = source.match(/data-grid-topbar-condition-floating-controls[^"]*/)?.[0];
const floatingLabelCss = source.match(/\.data-grid-topbar-condition-label--floating\s*\{(?<body>[\s\S]*?)\n\}/)?.groups?.body;
const compactFloatingLabelCss = source.match(/\.data-grid-topbar-condition-label--floating\.data-grid-topbar-condition-label--compact\s*\{(?<body>[\s\S]*?)\n\}/)?.groups?.body;
expect(floatingControls).toContain("z-[2]");
expect(source).toContain("data-grid-topbar-condition-label--floating");
expect(source).toContain("label.scrollWidth + 4");
expect(floatingLabelCss).toContain("text-shadow:");
expect(floatingLabelCss).not.toContain("padding-right:");
expect(floatingLabelCss).not.toContain("box-shadow:");
expect(compactFloatingLabelCss).toContain("max-width: 5rem");
expect(compactFloatingLabelCss).toContain("opacity: 1");
});
it("keeps dark condition styles scoped to their target elements", () => {
@ -136,11 +167,117 @@ describe("DataGridConditionEditor quote completion", () => {
const source = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/grid/DataGridConditionEditor.vue"), "utf8");
expect(source).toContain("function scrollCaretIntoView()");
expect(source).toContain("const hasVerticalOverflow = target.scrollHeight > target.clientHeight + 4");
expect(source).toContain("const hasHorizontalOverflow = target.scrollWidth > target.clientWidth + 1");
expect(source).toContain("target.scrollTop = 0");
expect(source).toContain("const caretLeft = caretMarker.offsetLeft");
expect(source).toContain("target.scrollLeft");
expect(source).toContain("function scheduleCaretIntoView()");
expect(source).toContain("function focusAfterAccept()");
expect(source).toContain("void nextTick(scrollCaretIntoView)");
expect(source).toContain("requestAnimationFrame(() => scrollCaretIntoView())");
expect(source).toContain("scheduleCaretIntoView()");
expect(source).toContain('if (action === "accept") focusAfterAccept()');
});
it("also keeps the caret visible after regular input wraps", () => {
const source = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/grid/DataGridConditionEditor.vue"), "utf8");
const onInputBody = source.match(/function onInput\(event: Event\) \{(?<body>[\s\S]*?)\n\}/)?.groups?.body;
expect(onInputBody).toContain("resizeEditor(true)");
expect(onInputBody).toContain("updateSuggestionPosition()");
expect(onInputBody).toContain("scheduleCaretIntoView()");
});
it("keeps the caret visible after switching focus into the expanded editor", () => {
const source = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/grid/DataGridConditionEditor.vue"), "utf8");
const focusTransferBody = source.match(/if \(nextExpanded && document\.activeElement === input && !composing\.value\) \{(?<body>[\s\S]*?)\n \}/)?.groups?.body;
expect(focusTransferBody).toContain("const start = selectionStart.value");
expect(focusTransferBody).toContain("overlay.setSelectionRange(start, end)");
expect(focusTransferBody).toContain("overlay.focus({ preventScroll: true })");
expect(focusTransferBody).toContain("scheduleCaretIntoView()");
});
it("keeps the caret visible after collapsing the expanded editor back to one line", () => {
const source = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/grid/DataGridConditionEditor.vue"), "utf8");
const collapseTransferBody = source.match(/if \(!nextExpanded && overlayFocused && !composing\.value\) \{(?<body>[\s\S]*?)\n \}/)?.groups?.body;
expect(collapseTransferBody).toContain("const start = selectionStart.value");
expect(collapseTransferBody).toContain("input.setSelectionRange(start, end)");
expect(collapseTransferBody).toContain("input.focus({ preventScroll: true })");
expect(collapseTransferBody!.indexOf("input.focus({ preventScroll: true })")).toBeLessThan(collapseTransferBody!.indexOf("input.setSelectionRange(start, end)"));
expect(collapseTransferBody).toContain("scheduleCaretIntoView()");
});
it("preserves continuous input when the expanded editor collapses", async () => {
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function (this: HTMLElement) {
const textWidth = (this.textContent?.length ?? 0) * 8;
const width = this.classList.contains("data-grid-topbar-condition-pane--expanded") ? 160 : textWidth;
return { x: 0, y: 0, left: 0, top: 0, right: width, bottom: 24, width, height: 24, toJSON: () => ({}) } as DOMRect;
});
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
callback(0);
return 1;
});
const { value, input } = mountEditor("where", "abcdefghijklmnopqrstuvwxyz0123456789");
mockTextareaMetrics(input, { clientWidth: 80, scrollWidth: 320 });
input.focus();
input.setSelectionRange(input.value.length, input.value.length);
input.dispatchEvent(new Event("select", { bubbles: true }));
input.dispatchEvent(new Event("focus", { bubbles: true }));
await nextTick();
await nextTick();
const overlay = document.body.querySelector(".data-grid-topbar-condition-input--expanded") as HTMLTextAreaElement | null;
expect(overlay).toBeTruthy();
overlay!.value = "i";
overlay!.setSelectionRange(1, 1);
overlay!.dispatchEvent(new Event("input", { bubbles: true }));
await nextTick();
await nextTick();
expect(document.activeElement).toBe(input);
expect(input.selectionStart).toBe(1);
input.value = "id";
input.setSelectionRange(2, 2);
input.dispatchEvent(new Event("input", { bubbles: true }));
await nextTick();
expect(value.value).toBe("id");
expect(input.selectionStart).toBe(2);
vi.unstubAllGlobals();
});
it("preserves the caret offset when focus moves into the expanded textarea", async () => {
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function (this: HTMLElement) {
const width = this.classList.contains("data-grid-topbar-condition-pane--expanded") ? 160 : 140;
return { x: 0, y: 0, left: 0, top: 0, right: width, bottom: 24, width, height: 24, toJSON: () => ({}) } as DOMRect;
});
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
callback(0);
return 1;
});
const { input } = mountEditor("where", "abcdefghijklmnopqrstuvwxyz0123456789");
mockTextareaMetrics(input, { clientWidth: 80, scrollWidth: 320 });
input.focus();
input.setSelectionRange(30, 30);
input.dispatchEvent(new Event("select", { bubbles: true }));
input.dispatchEvent(new Event("focus", { bubbles: true }));
await nextTick();
await nextTick();
const overlay = document.body.querySelector(".data-grid-topbar-condition-input--expanded") as HTMLTextAreaElement | null;
expect(overlay).toBeTruthy();
expect(document.activeElement).toBe(overlay);
expect(overlay?.selectionStart).toBe(30);
expect(overlay?.selectionEnd).toBe(30);
vi.unstubAllGlobals();
});
it("positions suggestions below the measured expanded editor height", () => {
const source = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/grid/DataGridConditionEditor.vue"), "utf8");
const expandedPaneCss = source.match(/\.data-grid-topbar-condition-pane--expanded\s*\{(?<body>[\s\S]*?)\n\}/)?.groups?.body;