From bdb941b606a304dbc8f9e0f5dae40c7f73a887d5 Mon Sep 17 00:00:00 2001 From: zipg Date: Fri, 31 Jul 2026 16:25:37 +0800 Subject: [PATCH] fix(grid): preserve caret when condition editor collapses --- .../grid/DataGridConditionEditor.vue | 102 ++++++++++-- .../__tests__/DataGridConditionEditor.spec.ts | 145 +++++++++++++++++- 2 files changed, 226 insertions(+), 21 deletions(-) diff --git a/apps/desktop/src/components/grid/DataGridConditionEditor.vue b/apps/desktop/src/components/grid/DataGridConditionEditor.vue index dd7a2255d..89139cc6c 100644 --- a/apps/desktop/src/components/grid/DataGridConditionEditor.vue +++ b/apps/desktop/src/components/grid/DataGridConditionEditor.vue @@ -94,11 +94,13 @@ const previewStyle = computed(() => { }); const previewArrowStyle = computed(() => ({ 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(".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; diff --git a/apps/desktop/src/components/grid/__tests__/DataGridConditionEditor.spec.ts b/apps/desktop/src/components/grid/__tests__/DataGridConditionEditor.spec.ts index dca6b1cc3..01d0b7a42 100644 --- a/apps/desktop/src/components/grid/__tests__/DataGridConditionEditor.spec.ts +++ b/apps/desktop/src/components/grid/__tests__/DataGridConditionEditor.spec.ts @@ -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*\{(?[\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*\{(?[\s\S]*?)\n\}/)?.groups?.body; + const compactFloatingLabelCss = source.match(/\.data-grid-topbar-condition-label--floating\.data-grid-topbar-condition-label--compact\s*\{(?[\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\) \{(?[\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\) \{(?[\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\) \{(?[\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*\{(?[\s\S]*?)\n\}/)?.groups?.body;