mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 06:00:39 +00:00
fix(editor): measure shortcut capture input width to fit CJK
This commit is contained in:
@@ -239,6 +239,7 @@ import { DEFAULT_DATA_GRID_FONT_FAMILY, DEFAULT_UI_FONT_FAMILY, normalizeCustomF
|
||||
import { buildFontFamilyOptions, displayFontFamily, isPresetFontFamily, loadSystemFontNames } from "@/lib/app/fontFamilyOptions";
|
||||
import { buildAppSupportInfoRows, formatAppSupportInfoForClipboard, type AppSupportInfoLabels } from "@/lib/app/supportInfo";
|
||||
import { useUiFontFamilyPreview } from "@/composables/useUiFontFamilyPreview";
|
||||
import { useMeasuredWidth } from "@/composables/useMeasuredWidth";
|
||||
import { DateTimePatterns, normalizeSupportedDateTimePattern } from "@/lib/dataGrid/columnFormatter";
|
||||
import { MAX_RESULT_PAGE_SIZE, MIN_RESULT_PAGE_SIZE } from "@/lib/dataGrid/paginationPageSize";
|
||||
import { MAX_QUERY_RESULT_MAX_ROWS } from "@/lib/dataGrid/queryResultRowLimit";
|
||||
@@ -2187,7 +2188,21 @@ function formatShortcutPill(shortcut: string): string {
|
||||
}
|
||||
|
||||
const shortcutPressShortcutLabel = computed(() => t("settings.shortcutPressShortcut"));
|
||||
const shortcutPressShortcutInputWidth = computed(() => `${shortcutPressShortcutLabel.value.length + 2}em`);
|
||||
// #9144: `label.length + 2em` under-sizes CJK placeholders wherever the
|
||||
// environment's fallback font advances wider than 1em (user report: 15px/char
|
||||
// at a 13px font → the last glyph clipped mid-stroke). Measure a hidden mirror
|
||||
// span carrying the same box + typography classes as the capture inputs
|
||||
// instead; the char-count formula only survives as the pre-measurement
|
||||
// fallback. The +2px cushion absorbs sub-pixel rounding differences between
|
||||
// the mirror span and the real input.
|
||||
const shortcutPressShortcutInputWidth = ref(`${shortcutPressShortcutLabel.value.length + 2}em`);
|
||||
const shortcutPlaceholderMirrorRef = ref<HTMLElement | null>(null);
|
||||
const shortcutPlaceholderMirrorWidth = useMeasuredWidth(shortcutPlaceholderMirrorRef, 0, [shortcutPressShortcutLabel]);
|
||||
watch(shortcutPlaceholderMirrorWidth, (width) => {
|
||||
if (width > 0) {
|
||||
shortcutPressShortcutInputWidth.value = `${Math.ceil(width) + 2}px`;
|
||||
}
|
||||
});
|
||||
|
||||
function focusShortcutInput(actionId: ShortcutActionId) {
|
||||
editingShortcutId.value = actionId;
|
||||
@@ -9724,6 +9739,13 @@ LIMIT 100;</pre
|
||||
:confirm-label="t('common.delete')"
|
||||
@confirm="templateDeleteConfirm && confirmDeleteTemplate(templateDeleteConfirm)"
|
||||
/>
|
||||
|
||||
<!-- Hidden mirror of the shortcut-capture placeholder: measured to size
|
||||
the capture inputs (#9144). Carries the same box + typography classes
|
||||
as those inputs (font-mono text-[13px] font-semibold px-2.5 border) so
|
||||
its border-box width is exactly what they need, including font-fallback
|
||||
advances and letter-spacing that char-count arithmetic cannot predict. -->
|
||||
<span ref="shortcutPlaceholderMirrorRef" aria-hidden="true" class="invisible pointer-events-none absolute top-0 left-0 h-0 max-w-64 overflow-hidden whitespace-pre rounded-[6px] border px-2.5 font-mono text-[13px] font-semibold">{{ shortcutPressShortcutLabel }}</span>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
|
||||
Vendored
+74
@@ -0,0 +1,74 @@
|
||||
// @vitest-environment happy-dom
|
||||
// Source-assertion regression for #9144: the shortcut-capture input width must
|
||||
// come from measuring a hidden mirror span (same box + typography as the real
|
||||
// inputs), not from `label.length + 2em` char-count arithmetic — CJK fallback
|
||||
// fonts can advance wider than 1em and clip the last placeholder glyph. The
|
||||
// dialog cannot be mounted (a 10k-line SFC whose imports pull the whole
|
||||
// editor + stores), so this follows the same `?raw` pattern as
|
||||
// EditorSettingsDialog.mcpQueryTimeout.integration.spec.ts.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import dialogSource from "../EditorSettingsDialog.vue?raw";
|
||||
|
||||
function templateOnly(): string {
|
||||
// The root <template> block, not the first "</template>" — this SFC is full
|
||||
// of nested slot <template> elements, so the first closing tag (and the
|
||||
// naive slice up to it) lands inside the template and misses most of it.
|
||||
return dialogSource.slice(dialogSource.search(/^<template>$/m), dialogSource.lastIndexOf("</template>"));
|
||||
}
|
||||
|
||||
describe("EditorSettingsDialog shortcut capture input width (#9144)", () => {
|
||||
it("mounts exactly one hidden mirror span carrying the shared typography/box classes", () => {
|
||||
const template = templateOnly();
|
||||
|
||||
// The ref token appears exactly once in the template: the mirror span.
|
||||
// (The two script occurrences are the ref declaration and the
|
||||
// useMeasuredWidth call.)
|
||||
expect(template.match(/shortcutPlaceholderMirrorRef/g)).toHaveLength(1);
|
||||
expect(dialogSource.match(/shortcutPlaceholderMirrorRef/g)).toHaveLength(3);
|
||||
|
||||
// Same box + typography classes as the capture inputs so the measured
|
||||
// border-box width includes padding, border, letter-spacing and
|
||||
// fallback-font advances. Asserted against the span's own tag (not the
|
||||
// whole template) — the capture inputs share these classes, so a
|
||||
// template-wide match would pass even with the span stripped.
|
||||
const spanTag = template.match(/<span ref="shortcutPlaceholderMirrorRef"[^>]*>/)?.[0];
|
||||
expect(spanTag).toBeTruthy();
|
||||
for (const cls of ["font-mono", "text-[13px]", "font-semibold", "px-2.5", "border", "whitespace-pre", "invisible"]) {
|
||||
expect(spanTag).toContain(cls);
|
||||
}
|
||||
|
||||
// The label must be interpolated in-band with the span tags, not as
|
||||
// template text between newlines — whitespace-pre would otherwise widen
|
||||
// the measured box.
|
||||
expect(template).toMatch(/<span ref="shortcutPlaceholderMirrorRef"[^>]*>\{\{ shortcutPressShortcutLabel \}\}<\/span>/);
|
||||
});
|
||||
|
||||
it("binds all three capture inputs to the measured width ref", () => {
|
||||
// Two width bindings gate on the row-level editing state (formatter table
|
||||
// + shortcuts table), one on the SQL-shortcut dialog editing state. Each
|
||||
// conditional token appears in exactly one kind of binding.
|
||||
expect(dialogSource.match(/editingShortcutId === definition\.id \? shortcutPressShortcutInputWidth/g)).toHaveLength(2);
|
||||
expect(dialogSource.match(/editingSqlShortcutInputId === 'dialog' \? shortcutPressShortcutInputWidth/g)).toHaveLength(1);
|
||||
|
||||
// Account for every usage of the width ref: 1 fallback initializer,
|
||||
// 1 watcher application, 3 capture-input bindings.
|
||||
expect(dialogSource.match(/shortcutPressShortcutInputWidth/g)).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("wires the measured width to the shared mirror ref with the label as revalidate source", () => {
|
||||
expect(dialogSource).toContain("useMeasuredWidth(shortcutPlaceholderMirrorRef, 0, [shortcutPressShortcutLabel])");
|
||||
|
||||
// The applied width adds a ~2px cushion and rounds up so sub-pixel
|
||||
// rounding never under-sizes.
|
||||
expect(dialogSource).toContain("${Math.ceil(width) + 2}px");
|
||||
});
|
||||
|
||||
it("keeps the char-count formula only as the ref fallback initializer", () => {
|
||||
// The legacy `length + 2em` estimate survives solely as the pre-measurement
|
||||
// initializer of the ref.
|
||||
expect(dialogSource).toContain("shortcutPressShortcutInputWidth = ref(`${shortcutPressShortcutLabel.value.length + 2}em`)");
|
||||
|
||||
// No computed re-derives the width from char count anymore.
|
||||
expect(dialogSource).not.toMatch(/computed\(\(\) => `\$\{shortcutPressShortcutLabel\.value\.length \+ 2\}em`\)/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
||||
import type { Ref, WatchSource } from "vue";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { useMeasuredWidth } from "../useMeasuredWidth";
|
||||
|
||||
function createHarness(revalidateSources: WatchSource[] = []) {
|
||||
const targetRef = ref<HTMLElement | null>(null);
|
||||
let widthRef: Ref<number> | undefined;
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup() {
|
||||
widthRef = useMeasuredWidth(targetRef, 42, revalidateSources);
|
||||
return () => h("div");
|
||||
},
|
||||
}),
|
||||
);
|
||||
app.mount(document.createElement("div"));
|
||||
return { targetRef, widthRef: widthRef!, unmount: () => app.unmount() };
|
||||
}
|
||||
|
||||
/** A bare element whose getBoundingClientRect reports the given width. */
|
||||
function elementOfWidth(width: number): HTMLElement {
|
||||
const element = document.createElement("span");
|
||||
Object.defineProperty(element, "getBoundingClientRect", {
|
||||
configurable: true,
|
||||
value: () => ({ width, height: 0, top: 0, left: 0, bottom: 0, right: 0, x: 0, y: 0, toJSON: () => ({}) }),
|
||||
});
|
||||
return element;
|
||||
}
|
||||
|
||||
async function flush(ticks = 5) {
|
||||
for (let i = 0; i < ticks; i += 1) {
|
||||
await nextTick();
|
||||
}
|
||||
}
|
||||
|
||||
describe("useMeasuredWidth", () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("keeps the fallback while no element is attached", async () => {
|
||||
const { targetRef, widthRef } = createHarness();
|
||||
expect(targetRef.value).toBeNull();
|
||||
await flush();
|
||||
expect(widthRef.value).toBe(42);
|
||||
});
|
||||
|
||||
it("measures the element once attached", async () => {
|
||||
const { targetRef, widthRef } = createHarness();
|
||||
|
||||
targetRef.value = elementOfWidth(90);
|
||||
await flush();
|
||||
expect(widthRef.value).toBe(90);
|
||||
});
|
||||
|
||||
it("ignores a zero-width measurement instead of clobbering the previous width", async () => {
|
||||
const flag = ref(0);
|
||||
const { targetRef, widthRef } = createHarness([() => flag.value]);
|
||||
|
||||
targetRef.value = elementOfWidth(90);
|
||||
await flush();
|
||||
expect(widthRef.value).toBe(90);
|
||||
|
||||
// Zero width means "not laid out yet" (or display:none ancestry), never
|
||||
// "the text is zero pixels wide" — must not replace a usable width. Bump
|
||||
// the revalidate source so a re-measure actually runs against the
|
||||
// zero-width stub.
|
||||
Object.defineProperty(targetRef.value!, "getBoundingClientRect", {
|
||||
configurable: true,
|
||||
value: () => ({ width: 0, height: 0, top: 0, left: 0, bottom: 0, right: 0, x: 0, y: 0, toJSON: () => ({}) }),
|
||||
});
|
||||
flag.value += 1;
|
||||
await flush();
|
||||
expect(widthRef.value).toBe(90);
|
||||
});
|
||||
|
||||
it("re-measures when a revalidate source changes (ResizeObserver-unavailable path)", async () => {
|
||||
const flag = ref(0);
|
||||
const { targetRef, widthRef } = createHarness([() => flag.value]);
|
||||
|
||||
targetRef.value = elementOfWidth(70);
|
||||
await flush();
|
||||
expect(widthRef.value).toBe(70);
|
||||
|
||||
// A locale swap changed the text: the element resized without moving, so
|
||||
// only the source watch picks it up.
|
||||
Object.defineProperty(targetRef.value!, "getBoundingClientRect", {
|
||||
configurable: true,
|
||||
value: () => ({ width: 91, height: 0, top: 0, left: 0, bottom: 0, right: 0, x: 0, y: 0, toJSON: () => ({}) }),
|
||||
});
|
||||
flag.value += 1;
|
||||
await flush();
|
||||
expect(widthRef.value).toBe(91);
|
||||
});
|
||||
|
||||
it("re-binds and re-measures when the target element is swapped", async () => {
|
||||
const { targetRef, widthRef } = createHarness();
|
||||
|
||||
targetRef.value = elementOfWidth(90);
|
||||
await flush();
|
||||
expect(widthRef.value).toBe(90);
|
||||
|
||||
targetRef.value = elementOfWidth(120);
|
||||
await flush();
|
||||
expect(widthRef.value).toBe(120);
|
||||
});
|
||||
|
||||
it("stays inert after unmount (observer disconnected, no stray throws)", async () => {
|
||||
const { targetRef, unmount } = createHarness();
|
||||
|
||||
targetRef.value = elementOfWidth(90);
|
||||
await flush();
|
||||
expect(targetRef.value).not.toBeNull();
|
||||
|
||||
unmount();
|
||||
// Mutating the stub and flushing must not throw once the app is gone.
|
||||
Object.defineProperty(targetRef.value!, "getBoundingClientRect", {
|
||||
configurable: true,
|
||||
value: () => ({ width: 200, height: 0, top: 0, left: 0, bottom: 0, right: 0, x: 0, y: 0, toJSON: () => ({}) }),
|
||||
});
|
||||
await flush();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { nextTick, onUnmounted, ref, watch, type Ref, type WatchSource } from "vue";
|
||||
|
||||
/**
|
||||
* Measured border-box width (CSS pixels) of a mirror element.
|
||||
*
|
||||
* Measuring primitive for widths that char-count arithmetic cannot predict:
|
||||
* bind the returned ref to a template ref and it tracks the element's real
|
||||
* laid-out width — including font-fallback advances, letter-spacing, and
|
||||
* sub-pixel rounding. Motivated by #9144, where the zh-CN shortcut-capture
|
||||
* placeholder advanced 15px/char on a machine whose CJK fallback font renders
|
||||
* wider than 1em, so a `label.length + 2em` width clipped the last glyph.
|
||||
*
|
||||
* The width starts at `fallback` so callers render a sane size before the
|
||||
* first measurement lands (and in environments without real layout, e.g.
|
||||
* happy-dom). Zero-width measurements are ignored: they mean "not laid out
|
||||
* yet" (or `display: none` ancestry), never "the text is zero pixels wide",
|
||||
* and must not clobber a usable width. `revalidateSources` covers content
|
||||
* changes that resize the element without moving it (e.g. a locale swap)
|
||||
* for environments where ResizeObserver is unavailable.
|
||||
*/
|
||||
export function useMeasuredWidth(targetRef: Ref<HTMLElement | null>, fallback = 0, revalidateSources: WatchSource[] = []): Ref<number> {
|
||||
const width = ref(fallback);
|
||||
let resizeObserver: ResizeObserver | undefined;
|
||||
|
||||
function measure() {
|
||||
const element = targetRef.value;
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
const measured = element.getBoundingClientRect().width;
|
||||
if (measured > 0) {
|
||||
width.value = measured;
|
||||
}
|
||||
}
|
||||
|
||||
if (revalidateSources.length > 0) {
|
||||
watch(revalidateSources, () => {
|
||||
void nextTick(measure);
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
targetRef,
|
||||
(element) => {
|
||||
resizeObserver?.disconnect();
|
||||
resizeObserver = undefined;
|
||||
if (element && typeof ResizeObserver !== "undefined") {
|
||||
resizeObserver = new ResizeObserver(measure);
|
||||
resizeObserver.observe(element);
|
||||
}
|
||||
void nextTick(measure);
|
||||
},
|
||||
{ flush: "post" },
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
resizeObserver?.disconnect();
|
||||
resizeObserver = undefined;
|
||||
});
|
||||
|
||||
return width;
|
||||
}
|
||||
Reference in New Issue
Block a user