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

This commit is contained in:
Abeautifulsnow authored and GitHub committed 2026-09-15 17:52:59 +08:00
1 parent b06e952ba0
commit 0437f952e0
4 files changed
+288 -1

No files matched your search

@@ -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`\)/);
});
});