diff --git a/apps/desktop/src/components/grid/DataGridConditionEditor.vue b/apps/desktop/src/components/grid/DataGridConditionEditor.vue index 080e67ae81..dacdb5967d 100644 --- a/apps/desktop/src/components/grid/DataGridConditionEditor.vue +++ b/apps/desktop/src/components/grid/DataGridConditionEditor.vue @@ -84,8 +84,18 @@ const highlightTokens = computed(() => tokenizeDataGridCondition(modelValue.valu const collapsedHighlightScrollLeft = ref(0); const expandedHighlightScrollLeft = ref(0); const expandedHighlightScrollTop = ref(0); +// The highlight layer is a plain div and never gives up content width to a +// scrollbar, while the transparent-text textarea above it does. Both wrap with +// `white-space: pre-wrap` + `overflow-wrap: anywhere`, so an uncompensated +// scrollbar makes the two layers break at different characters and the visible +// text drifts away from the caret. Compensate with the measured width instead +// of assuming one. +const expandedHighlightScrollbarWidth = ref(0); const collapsedHighlightStyle = computed(() => ({ transform: `translateX(${-collapsedHighlightScrollLeft.value}px)` })); -const expandedHighlightStyle = computed(() => ({ transform: `translate(${-expandedHighlightScrollLeft.value}px, ${-expandedHighlightScrollTop.value}px)` })); +const expandedHighlightStyle = computed(() => ({ + transform: `translate(${-expandedHighlightScrollLeft.value}px, ${-expandedHighlightScrollTop.value}px)`, + "--data-grid-condition-highlight-scrollbar": `${expandedHighlightScrollbarWidth.value}px`, +})); function highlightTokenClass(type: DataGridConditionTokenType): string | undefined { if (type === "plain") return undefined; @@ -177,6 +187,21 @@ function fitExpandedHeightToOverlay() { } } +function syncExpandedHighlightScrollbar() { + const overlay = overlayRef.value; + if (!overlay) return; + const scrollbar = Math.max(0, overlay.offsetWidth - overlay.clientWidth); + if (scrollbar !== expandedHighlightScrollbarWidth.value) expandedHighlightScrollbarWidth.value = scrollbar; +} + +function syncExpandedLayout() { + syncExpandedHighlightScrollbar(); + // Re-fit once the compensation has been applied: the highlight layer now + // wraps exactly like the textarea, so the pane can size itself to the real + // content height instead of leaving a scrollbar that only the textarea sees. + void nextTick(fitExpandedHeightToOverlay); +} + function measureExpandedRect(input: HTMLTextAreaElement) { const inputRect = input.getBoundingClientRect(); const control = controlRef.value; @@ -238,7 +263,7 @@ function resizeEditor(forceExpand = false) { const nextRect = measureExpandedRect(input); expandedRect.value = nextRect; expandedHeight.value = measureExpandedHeight(input, nextRect); - void nextTick(fitExpandedHeightToOverlay); + void nextTick(syncExpandedLayout); } expanded.value = nextExpanded; updateSuggestionPosition(); @@ -923,7 +948,7 @@ defineExpose({ focus, dismiss: editor.dismiss, rememberHistory: editor.rememberH min-width: 0; height: auto; margin-right: calc(-1 * var(--data-grid-expanded-scrollbar-offset)); - padding: 0 calc(var(--data-grid-condition-suffix-width) + 0.5rem) 0.0625rem calc(var(--data-grid-condition-prefix-indent) + 0.125rem); + padding: 0 calc(var(--data-grid-condition-suffix-width) + 0.5rem + var(--data-grid-condition-highlight-scrollbar, 0px)) 0.0625rem calc(var(--data-grid-condition-prefix-indent) + 0.125rem); white-space: pre-wrap; overflow-wrap: anywhere; } diff --git a/apps/desktop/src/components/grid/__tests__/DataGridConditionEditor.spec.ts b/apps/desktop/src/components/grid/__tests__/DataGridConditionEditor.spec.ts index 8e35053e31..516650bfc5 100644 --- a/apps/desktop/src/components/grid/__tests__/DataGridConditionEditor.spec.ts +++ b/apps/desktop/src/components/grid/__tests__/DataGridConditionEditor.spec.ts @@ -379,6 +379,49 @@ describe("DataGridConditionEditor quote completion", () => { expect(expandedHighlight?.style.transform).toBe("translate(0px, 0px)"); vi.unstubAllGlobals(); }); + + it("compensates the expanded highlight layer for the textarea scrollbar", 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 { input } = mountEditor("where", "test_item_id=12 and test_item_name=''"); + mockTextareaMetrics(input, { clientWidth: 80, scrollWidth: 320 }); + input.focus(); + input.setSelectionRange(input.value.length, input.value.length); + 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; + const expandedHighlight = document.body.querySelector(".data-grid-condition-highlight--expanded") as HTMLElement | null; + expect(overlay).toBeTruthy(); + expect(expandedHighlight).toBeTruthy(); + + // A vertical scrollbar takes content width away from the textarea but not + // from the plain-div highlight layer. Both wrap with `pre-wrap` + + // `overflow-wrap: anywhere`, so unless the layer gives up the same width the + // two break at different characters and the caret drifts off the visible text. + Object.defineProperties(overlay as HTMLTextAreaElement, { + offsetWidth: { configurable: true, value: 178 }, + clientWidth: { configurable: true, value: 168 }, + }); + overlay?.dispatchEvent(new Event("input", { bubbles: true })); + + await nextTick(); + await nextTick(); + await nextTick(); + + expect(expandedHighlight?.style.getPropertyValue("--data-grid-condition-highlight-scrollbar")).toBe("10px"); + vi.unstubAllGlobals(); + }); }); describe("DataGridConditionEditor Chinese column matching", () => {