Skip to content

Commit cf085a0

Browse files
committed
feat(input): 统一 PathChipsInput 历史记录并修复图片预览回退
此前输入框只能稳定撤回文本输入,普通 Chip、图片 Chip 与部分 引用操作的增删改没有接入统一历史;同时图片 Chip 在撤回后,其 blob 预览可能已被上层释放并 revoke,导致缩略图与悬浮预览失效。 这次调整让输入框整体行为更符合编辑器的一致交互预期。 - 将 draft、普通 Chip、图片 Chip、规则 Chip 的增删改统一接入历史栈 - 记录并恢复光标/选区,接管 historyUndo/historyRedo,支持 Ctrl/Cmd+Z 与 Ctrl+Y/Cmd+Shift+Z - 让 Backspace 删除末尾 Chip、鼠标删除 Chip、@ 插入与粘贴图片都走统一撤回链路 - 在 blob 预览失效时自动回退到 Windows file URL,恢复缩略图与悬浮大图 - 合并 master 上已有的复制文件名按钮能力,并补齐组件测试覆盖复制按钮、撤回/重做与图片回退场景 验证: - npm run test - npm run i18n:check Signed-off-by: Lulu <58587930+lulu-sk@users.noreply.github.com>
1 parent 4759402 commit cf085a0

File tree

2 files changed

+821
-75
lines changed

2 files changed

+821
-75
lines changed

web/src/components/ui/path-chips-input.test.tsx

Lines changed: 264 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// @vitest-environment jsdom
22

3-
import React, { act } from "react";
3+
import React, { act, useState } from "react";
44
import { afterEach, describe, expect, it, vi } from "vitest";
55
import { createRoot, type Root } from "react-dom/client";
66
import PathChipsInput, { type PathChip } from "./path-chips-input";
@@ -30,7 +30,26 @@ vi.mock("@/components/ui/dialog", () => ({
3030
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
3131

3232
/**
33-
* 中文说明:卸载并清理 React Root,避免测试间 DOM 相互污染。
33+
* 中文说明:在单测中将 requestAnimationFrame 改为同步执行,确保撤回后的选区恢复及时生效。
34+
*/
35+
function installSyncRequestAnimationFrame(): () => void {
36+
const originalRaf = (window as any).requestAnimationFrame as ((cb: FrameRequestCallback) => number) | undefined;
37+
const originalCancel = (window as any).cancelAnimationFrame as ((id: number) => void) | undefined;
38+
let seq = 0;
39+
(window as any).requestAnimationFrame = (cb: FrameRequestCallback) => {
40+
seq += 1;
41+
try { cb(0); } catch {}
42+
return seq;
43+
};
44+
(window as any).cancelAnimationFrame = () => {};
45+
return () => {
46+
(window as any).requestAnimationFrame = originalRaf;
47+
(window as any).cancelAnimationFrame = originalCancel;
48+
};
49+
}
50+
51+
/**
52+
* 中文说明:卸载并清理 React Root,避免不同用例之间相互污染。
3453
*/
3554
function safeUnmountRoot(root: Root, host: HTMLElement): void {
3655
try {
@@ -44,7 +63,7 @@ function safeUnmountRoot(root: Root, host: HTMLElement): void {
4463
}
4564

4665
/**
47-
* 中文说明:创建并挂载一个 React Root,便于在 jsdom 中验证组件渲染结果
66+
* 中文说明:创建并挂载一个独立的 React Root。
4867
*/
4968
function createMountedRoot(): { host: HTMLDivElement; root: Root; unmount: () => void } {
5069
const host = document.createElement("div");
@@ -60,7 +79,7 @@ function createMountedRoot(): { host: HTMLDivElement; root: Root; unmount: () =>
6079
}
6180

6281
/**
63-
* 中文说明:渲染最小化的 `PathChipsInput` 场景,只保留本次验证所需的受控属性
82+
* 中文说明:渲染最小化的 `PathChipsInput` 场景,只保留复制文件名验证所需的受控属性
6483
*/
6584
async function renderPathChipsInput(chips: PathChip[]): Promise<() => void> {
6685
const mounted = createMountedRoot();
@@ -91,6 +110,108 @@ function createPathChip(overrides: Partial<PathChip> & { isDir?: boolean }): Pat
91110
} as PathChip;
92111
}
93112

113+
/**
114+
* 中文说明:测试用受控包装器,模拟真实页面里 `draft/chips` 由父组件托管的场景。
115+
*/
116+
function Harness(props: { initialDraft?: string; initialChips?: PathChip[] }): React.ReactElement {
117+
const [draft, setDraft] = useState(props.initialDraft ?? "");
118+
const [chips, setChips] = useState<PathChip[]>(props.initialChips ?? []);
119+
return (
120+
<div>
121+
<PathChipsInput
122+
draft={draft}
123+
onDraftChange={setDraft}
124+
chips={chips}
125+
onChipsChange={setChips}
126+
multiline
127+
/>
128+
<div data-testid="chips-count">{chips.length}</div>
129+
</div>
130+
);
131+
}
132+
133+
/**
134+
* 中文说明:从容器中获取实际编辑器(当前组件在测试里使用 textarea)。
135+
*/
136+
function getEditor(host: HTMLElement): HTMLTextAreaElement | HTMLInputElement {
137+
const editor = host.querySelector("textarea, input");
138+
if (!editor) throw new Error("missing editor");
139+
return editor as HTMLTextAreaElement | HTMLInputElement;
140+
}
141+
142+
/**
143+
* 中文说明:读取当前 Chip 数量,便于断言删除/撤回结果。
144+
*/
145+
function getChipCount(host: HTMLElement): number {
146+
const el = host.querySelector("[data-testid=\"chips-count\"]");
147+
if (!el) throw new Error("missing chips count");
148+
return Number(el.textContent || "0");
149+
}
150+
151+
/**
152+
* 中文说明:查找当前可见的 Chip 删除按钮。
153+
*/
154+
function getChipRemoveButton(host: HTMLElement): HTMLButtonElement {
155+
const buttons = Array.from(host.querySelectorAll("button")) as HTMLButtonElement[];
156+
const button = buttons.find((candidate) => (candidate.textContent || "").includes("×"));
157+
if (!button) throw new Error("missing chip remove button");
158+
return button;
159+
}
160+
161+
/**
162+
* 中文说明:获取 Chip 上显示的缩略图元素。
163+
*/
164+
function getChipPreviewImage(host: HTMLElement): HTMLImageElement {
165+
const image = host.querySelector("img");
166+
if (!image) throw new Error("missing chip preview image");
167+
return image as HTMLImageElement;
168+
}
169+
170+
/**
171+
* 中文说明:派发一次键盘事件,用于模拟 Backspace / Ctrl+Z / Ctrl+Y。
172+
*/
173+
async function dispatchKeyDown(
174+
target: HTMLElement,
175+
init: KeyboardEventInit,
176+
): Promise<void> {
177+
await act(async () => {
178+
target.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ...init }));
179+
});
180+
}
181+
182+
/**
183+
* 中文说明:派发一次输入事件,并附带 inputType 供历史合并逻辑识别。
184+
*/
185+
async function dispatchInput(
186+
editor: HTMLTextAreaElement | HTMLInputElement,
187+
nextValue: string,
188+
inputType: string,
189+
): Promise<void> {
190+
await act(async () => {
191+
const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(editor), "value")?.set;
192+
if (!setter) throw new Error("missing native value setter");
193+
setter.call(editor, nextValue);
194+
try { editor.setSelectionRange(nextValue.length, nextValue.length); } catch {}
195+
const event = typeof InputEvent === "function"
196+
? new InputEvent("input", { bubbles: true, cancelable: true, inputType })
197+
: new Event("input", { bubbles: true, cancelable: true });
198+
if (!("inputType" in event)) {
199+
Object.defineProperty(event, "inputType", { value: inputType });
200+
}
201+
editor.dispatchEvent(event);
202+
});
203+
}
204+
205+
/**
206+
* 中文说明:依次派发 mousedown + click,模拟用户点击删除按钮。
207+
*/
208+
async function clickElement(target: HTMLElement): Promise<void> {
209+
await act(async () => {
210+
target.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true }));
211+
target.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
212+
});
213+
}
214+
94215
describe("PathChipsInput(复制文件名按钮)", () => {
95216
let cleanup: (() => void) | null = null;
96217

@@ -130,3 +251,142 @@ describe("PathChipsInput(复制文件名按钮)", () => {
130251
expect(copyButton).toBeNull();
131252
});
132253
});
254+
255+
describe("PathChipsInput 撤回历史", () => {
256+
let cleanup: (() => void) | null = null;
257+
let restoreRaf: (() => void) | null = null;
258+
259+
afterEach(() => {
260+
try { restoreRaf?.(); } catch {}
261+
restoreRaf = null;
262+
try { cleanup?.(); } catch {}
263+
cleanup = null;
264+
});
265+
266+
it("Backspace 删除的 chip 可以通过 Ctrl+Z 撤回", async () => {
267+
restoreRaf = installSyncRequestAnimationFrame();
268+
const mounted = createMountedRoot();
269+
cleanup = mounted.unmount;
270+
271+
const initialChip: PathChip = {
272+
id: "file-1",
273+
blob: new Blob(),
274+
previewUrl: "",
275+
type: "text/path",
276+
size: 0,
277+
saved: true,
278+
fromPaste: false,
279+
wslPath: "/repo/README.md",
280+
fileName: "README.md",
281+
chipKind: "file",
282+
} as PathChip;
283+
284+
await act(async () => {
285+
mounted.root.render(<Harness initialChips={[initialChip]} />);
286+
});
287+
288+
const editor = getEditor(mounted.host);
289+
editor.focus();
290+
291+
await dispatchKeyDown(editor, { key: "Backspace" });
292+
expect(getChipCount(mounted.host)).toBe(0);
293+
expect(mounted.host.textContent || "").not.toContain("README.md");
294+
295+
await dispatchKeyDown(editor, { key: "z", ctrlKey: true });
296+
expect(getChipCount(mounted.host)).toBe(1);
297+
expect(mounted.host.textContent || "").toContain("README.md");
298+
});
299+
300+
it("鼠标删除的图片 chip 可以撤回,且焦点保持在输入框", async () => {
301+
restoreRaf = installSyncRequestAnimationFrame();
302+
const mounted = createMountedRoot();
303+
cleanup = mounted.unmount;
304+
305+
const imageChip: PathChip = {
306+
id: "image-1",
307+
blob: new Blob(),
308+
previewUrl: "blob:test-image",
309+
type: "image/png",
310+
size: 12,
311+
saved: true,
312+
fromPaste: true,
313+
wslPath: "/repo/image.png",
314+
winPath: "C:\\repo\\image.png",
315+
fileName: "image.png",
316+
chipKind: "image",
317+
} as PathChip;
318+
319+
await act(async () => {
320+
mounted.root.render(<Harness initialChips={[imageChip]} />);
321+
});
322+
323+
const editor = getEditor(mounted.host);
324+
editor.focus();
325+
const removeButton = getChipRemoveButton(mounted.host);
326+
327+
await clickElement(removeButton);
328+
expect(getChipCount(mounted.host)).toBe(0);
329+
expect(document.activeElement).toBe(editor);
330+
331+
await dispatchKeyDown(editor, { key: "z", ctrlKey: true });
332+
expect(getChipCount(mounted.host)).toBe(1);
333+
expect(mounted.host.textContent || "").toContain("image.png");
334+
});
335+
336+
it("文字输入支持连续撤回与 Ctrl+Y 重做", async () => {
337+
restoreRaf = installSyncRequestAnimationFrame();
338+
const mounted = createMountedRoot();
339+
cleanup = mounted.unmount;
340+
341+
await act(async () => {
342+
mounted.root.render(<Harness />);
343+
});
344+
345+
const editor = getEditor(mounted.host);
346+
editor.focus();
347+
348+
await dispatchInput(editor, "a", "insertText");
349+
await dispatchInput(editor, "ab", "insertText");
350+
await dispatchInput(editor, "abc", "insertText");
351+
expect(editor.value).toBe("abc");
352+
353+
await dispatchKeyDown(editor, { key: "z", ctrlKey: true });
354+
expect(editor.value).toBe("");
355+
356+
await dispatchKeyDown(editor, { key: "y", ctrlKey: true });
357+
expect(editor.value).toBe("abc");
358+
});
359+
360+
it("图片 blob 预览失效后应回退到 file 预览", async () => {
361+
restoreRaf = installSyncRequestAnimationFrame();
362+
const mounted = createMountedRoot();
363+
cleanup = mounted.unmount;
364+
365+
const imageChip: PathChip = {
366+
id: "image-fallback-1",
367+
blob: new Blob(),
368+
previewUrl: "blob:revoked-image",
369+
type: "image/png",
370+
size: 12,
371+
saved: true,
372+
fromPaste: true,
373+
wslPath: "/repo/image.png",
374+
winPath: "C:\\repo\\image.png",
375+
fileName: "image.png",
376+
chipKind: "image",
377+
} as PathChip;
378+
379+
await act(async () => {
380+
mounted.root.render(<Harness initialChips={[imageChip]} />);
381+
});
382+
383+
const image = getChipPreviewImage(mounted.host);
384+
expect(image.getAttribute("src")).toBe("blob:revoked-image");
385+
386+
await act(async () => {
387+
image.dispatchEvent(new Event("error", { bubbles: false, cancelable: false }));
388+
});
389+
390+
expect(image.getAttribute("src")).toBe("file:///C:/repo/image.png");
391+
});
392+
});

0 commit comments

Comments
 (0)