fix(editor): measure shortcut capture input width to fit CJK

This commit is contained in:
Abeautifulsnow
2026-09-15 17:52:59 +08:00
committed by GitHub
parent b06e952ba0
commit 0437f952e0
4 changed files with 288 additions and 1 deletions
@@ -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>
@@ -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;
}