@ptengine/inline-editor 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/dist/InlineEditor.d.ts +15 -1
  3. package/dist/InlineEditor.js +226 -52
  4. package/dist/canvas-ui/tree/TreePanel.d.ts +2 -1
  5. package/dist/canvas-ui/tree/TreePanel.js +79 -39
  6. package/dist/chrome/EditorChrome.d.ts +2 -0
  7. package/dist/chrome/EditorChrome.js +1 -1
  8. package/dist/chrome/VersionMenu.d.ts +3 -1
  9. package/dist/chrome/VersionMenu.js +24 -12
  10. package/dist/chrome/topbarPrimitives.d.ts +5 -52
  11. package/dist/chrome/topbarPrimitives.js +13 -31
  12. package/dist/dialogs/ClickActionDialog.js +4 -5
  13. package/dist/dialogs/CodeField.d.ts +4 -0
  14. package/dist/dialogs/CodeField.js +10 -0
  15. package/dist/dialogs/HtmlEditDialog.js +3 -2
  16. package/dist/dialogs/ImageEditDialog.js +53 -15
  17. package/dist/dialogs/ImageSettingDialog.js +3 -3
  18. package/dist/dialogs/InsertElementDialog.js +10 -6
  19. package/dist/dialogs/MediaPreview.d.ts +5 -0
  20. package/dist/dialogs/MediaPreview.js +34 -0
  21. package/dist/dialogs/ReplaceVideoDialog.js +8 -4
  22. package/dist/index.d.ts +1 -0
  23. package/dist/kernel/ai/aiInlineApply.d.ts +4 -3
  24. package/dist/kernel/blocks/BlockOverlay.js +1 -1
  25. package/dist/kernel/blocks/resolveBlocks.d.ts +16 -0
  26. package/dist/kernel/blocks/resolveBlocks.js +39 -6
  27. package/dist/kernel/changelist/ChangeListPanel.js +11 -9
  28. package/dist/kernel/changelist/useChangeList.d.ts +5 -0
  29. package/dist/kernel/changelist/useChangeList.js +85 -10
  30. package/dist/kernel/selection/SelectionOverlay.js +1 -1
  31. package/dist/kernel/selection/SelectionToolbar.js +26 -8
  32. package/dist/kernel/selection/buildRootShelf.d.ts +56 -2
  33. package/dist/kernel/selection/buildRootShelf.js +189 -41
  34. package/dist/kernel/selection/elementAiContext.d.ts +11 -3
  35. package/dist/kernel/selection/resolveAnchors.d.ts +17 -0
  36. package/dist/kernel/selection/resolveAnchors.js +59 -11
  37. package/dist/locales.d.ts +201 -48
  38. package/dist/locales.js +13 -9
  39. package/dist/panels/EditorStatus.d.ts +5 -0
  40. package/dist/panels/EditorStatus.js +4 -0
  41. package/dist/panels/MovePositionControls.d.ts +6 -0
  42. package/dist/panels/MovePositionControls.js +35 -0
  43. package/dist/panels/ScreenshotCard.d.ts +7 -0
  44. package/dist/panels/ScreenshotCard.js +57 -0
  45. package/dist/panels/StylePanel.d.ts +9 -1
  46. package/dist/panels/StylePanel.js +108 -12
  47. package/dist/reviewLocales.d.ts +149 -0
  48. package/dist/reviewLocales.js +143 -0
  49. package/dist/tree/LayersPanel.js +4 -2
  50. package/dist/useInlineEditor.d.ts +1 -0
  51. package/dist/useInlineEditor.js +40 -1
  52. package/package.json +2 -2
@@ -1,57 +1,95 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { MediaPreview } from './MediaPreview.js';
2
3
  import { useEffect, useMemo, useRef, useState } from 'react';
3
4
  import { useTranslation } from 'react-i18next';
4
- import { Button, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, Input } from '@ptengine/design-components';
5
+ import { Button, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, Input, Tabs, TabsList, TabsTrigger, Label } from '@ptengine/design-components';
5
6
  import { pasteModes } from '../kernel/selection/toolbarButtons.js';
6
7
  import { resolveImageBranch } from './imageEdit.js';
7
8
  export function ImageEditDialog({ open, capabilities, seed = '', onOpenChange, onApply, uploadImage }) {
8
9
  const { t } = useTranslation();
9
10
  const [url, setUrl] = useState(seed);
11
+ const [source, setSource] = useState('link');
12
+ const [uploaded, setUploaded] = useState('');
13
+ const [ready, setReady] = useState(false);
14
+ const epoch = useRef(0);
10
15
  const [uploading, setUploading] = useState(false);
11
16
  const [error, setError] = useState(null);
17
+ const lastFileRef = useRef(undefined);
12
18
  const fileRef = useRef(null);
13
19
  const branch = capabilities ? resolveImageBranch(capabilities) : 'insert';
14
20
  const modes = useMemo(() => (capabilities ? pasteModes(capabilities) : []), [capabilities]);
21
+ const modeKey = modes.join(',');
15
22
  const [mode, setMode] = useState('append');
16
23
  useEffect(() => {
17
24
  if (!open)
18
25
  return;
19
26
  setUrl(seed);
27
+ setUploaded('');
28
+ setSource('link');
29
+ setReady(false);
30
+ epoch.current++;
20
31
  setError(null);
32
+ setUploading(false);
21
33
  if (modes.length > 0)
22
34
  setMode(modes[0]);
23
- }, [open, seed, modes]);
35
+ return () => {
36
+ epoch.current++;
37
+ };
38
+ }, [open, seed, modeKey]);
24
39
  const onPick = async (file) => {
25
40
  if (!file || !uploadImage)
26
41
  return;
42
+ if (!file.type.startsWith('image/') || file.size > 10 * 1024 * 1024) {
43
+ setError(t('inlineCanvas.review.imageLimit', '请选择不超过 10 MB 的图片'));
44
+ return;
45
+ }
46
+ lastFileRef.current = file;
47
+ const request = ++epoch.current;
27
48
  setUploading(true);
28
49
  setError(null);
29
50
  try {
30
- setUrl(await uploadImage(file));
51
+ const next = await uploadImage(file);
52
+ if (request === epoch.current) {
53
+ setUploaded(next);
54
+ setReady(false);
55
+ }
31
56
  }
32
57
  catch (e) {
33
- setError(String(e?.message ?? e));
58
+ if (request === epoch.current)
59
+ setError(String(e?.message ?? e));
34
60
  }
35
61
  finally {
36
- setUploading(false);
62
+ if (request === epoch.current)
63
+ setUploading(false);
37
64
  }
38
65
  };
39
66
  const noPlace = branch === 'insert' && modes.length === 0;
40
- const canApply = url.trim().length > 0 && !uploading && !noPlace;
41
- return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { className: "max-w-[480px]", "data-testid": "image-edit-dialog", children: [_jsx(DialogHeader, { children: _jsx(DialogTitle, { children: t('inlineCanvas.image.replaceTitle') }) }), _jsxs("div", { className: "flex flex-col gap-3", children: [_jsx("p", { "data-testid": "image-edit-branch", className: "rounded bg-[#F4F5F7] px-2 py-1 text-[11px] leading-4 text-[#6B778C]", children: branch === 'img-src'
42
- ? '改 <img> 的 src'
67
+ const selectedUrl = source === 'upload' ? uploaded : url.trim();
68
+ const canApply = !!selectedUrl && ready && !uploading && !noPlace;
69
+ return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { closeLabel: t('inlineCanvas.chrome.close', '关闭'), className: "max-h-[calc(100dvh-32px)] overflow-y-auto", "data-testid": "image-edit-dialog", children: [_jsx(DialogHeader, { children: _jsx(DialogTitle, { children: t('inlineCanvas.image.replaceTitle') }) }), _jsxs("div", { className: "flex flex-col gap-3", children: [seed && (_jsxs("details", { children: [_jsx("summary", { className: "text-xs", children: t('inlineCanvas.review.currentMedia', '当前媒体') }), _jsx(MediaPreview, { url: seed })] })), uploadImage && (_jsx(Tabs, { value: source, onValueChange: v => {
70
+ setSource(v);
71
+ setReady(false);
72
+ }, children: _jsxs(TabsList, { children: [_jsx(TabsTrigger, { value: "upload", children: t('inlineCanvas.review.uploadFile', '上传文件') }), _jsx(TabsTrigger, { value: "link", children: t('inlineCanvas.review.useLink', '使用链接') })] }) })), _jsx("p", { "data-testid": "image-edit-branch", className: "rounded bg-[#F4F5F7] px-2 py-1 text-xs leading-4 text-[#6B778C]", children: branch === 'img-src'
73
+ ? t('inlineCanvas.review.replaceImage', '替换当前图片')
43
74
  : branch === 'background'
44
- ? '改 background-image'
45
- : '插入一个新的 <img>' }), _jsxs("div", { children: [_jsx("p", { className: "mb-1.5 text-[13px] text-[#344563]", children: t('inlineCanvas.image.url') }), _jsx(Input, { "data-testid": "image-edit-url", value: url, onChange: e => setUrl(e.target.value) })] }), uploadImage && (_jsxs("div", { children: [_jsx("input", { ref: fileRef, type: "file", accept: "image/*", "data-testid": "image-edit-file", className: "hidden", onChange: e => void onPick(e.target.files?.[0]) }), _jsx(Button, { variant: "secondary", size: "sm", "data-testid": "image-edit-upload", disabled: uploading, onClick: () => fileRef.current?.click(), children: uploading
75
+ ? t('inlineCanvas.review.replaceBackground', '替换背景图片')
76
+ : t('inlineCanvas.review.insertImage', '插入新图片') }), _jsxs("div", { hidden: source !== 'link', children: [_jsx(Label, { htmlFor: "image-edit-url", children: t('inlineCanvas.image.url') }), _jsx(Input, { id: "image-edit-url", "aria-label": t('inlineCanvas.image.url'), "data-testid": "image-edit-url", value: url, onChange: e => {
77
+ setReady(false);
78
+ setUrl(e.target.value);
79
+ } })] }), uploadImage && (_jsxs("div", { hidden: source !== 'upload', onDragOver: e => e.preventDefault(), onDrop: e => {
80
+ e.preventDefault();
81
+ void onPick(e.dataTransfer.files[0]);
82
+ }, className: "rounded-lg border border-dashed border-border p-4", children: [_jsx("p", { className: "mb-2 text-xs text-muted-foreground", children: t('inlineCanvas.review.dropImage', '拖入图片或选择文件,支持图片格式,最大 10 MB。上传只准备素材,应用后才修改画布。') }), _jsx("input", { ref: fileRef, type: "file", accept: "image/*", "data-testid": "image-edit-file", className: "hidden", onChange: e => {
83
+ void onPick(e.target.files?.[0]);
84
+ e.target.value = '';
85
+ } }), _jsx(Button, { variant: "secondary", size: "sm", "data-testid": "image-edit-upload", disabled: uploading, onClick: () => fileRef.current?.click(), children: uploading
46
86
  ? t('inlineCanvas.image.uploading')
47
- : t('inlineCanvas.image.upload') })] })), branch === 'insert' && modes.length > 0 && (_jsxs("div", { children: [_jsx("p", { className: "mb-1.5 text-[13px] text-[#344563]", children: t('inlineCanvas.image.insertPosition') }), _jsx("div", { className: "flex gap-1.5", children: modes.map(m => (_jsx("button", { type: "button", "data-testid": `image-edit-mode-${m}`, "aria-pressed": mode === m, onClick: () => setMode(m), className: `rounded border px-2 py-1 text-[12px] ${mode === m
48
- ? 'border-[#00A92E] bg-[#E9F9EE] text-[#091E42]'
49
- : 'border-[#DFE1E6] text-[#6B778C]'}`, children: t(`inlineCanvas.clipboard.${m}`) }, m))) })] })), error && (_jsx("p", { "data-testid": "image-edit-error", className: "text-[11px] text-[#DE350B]", children: error }))] }), _jsxs(DialogFooter, { children: [_jsx(Button, { variant: "ghost", onClick: () => onOpenChange(false), children: t('inlineCanvas.chrome.close') }), _jsx(Button, { "data-testid": "image-edit-apply", disabled: !canApply, onClick: () => {
87
+ : t('inlineCanvas.image.upload') })] })), selectedUrl && (_jsxs("div", { children: [_jsx("p", { className: "mb-2 text-xs", children: t('inlineCanvas.review.pendingMedia', '待应用媒体') }), _jsx(MediaPreview, { url: selectedUrl, onReady: setReady }, source + selectedUrl)] })), branch === 'insert' && modes.length > 0 && (_jsxs("div", { children: [_jsx("p", { className: "mb-1.5 text-[13px] text-[#344563]", children: t('inlineCanvas.image.insertPosition') }), _jsx("div", { className: "flex gap-1.5", children: modes.map(m => (_jsx(Button, { type: "button", "data-testid": `image-edit-mode-${m}`, "aria-pressed": mode === m, onClick: () => setMode(m), size: "sm", variant: mode === m ? 'secondary' : 'ghost', children: t(`inlineCanvas.clipboard.${m}`) }, m))) })] })), error && (_jsxs("p", { "data-testid": "image-edit-error", className: "text-xs text-[#DE350B]", children: [error, source === 'upload' && lastFileRef.current && (_jsx(Button, { variant: "secondary", size: "sm", disabled: uploading, onClick: () => void onPick(lastFileRef.current), children: t('inlineCanvas.review.retry', '重试') }))] }))] }), _jsxs(DialogFooter, { children: [_jsx(Button, { variant: "secondary", onClick: () => onOpenChange(false), children: t('common.cancel', '取消') }), _jsx(Button, { "data-testid": "image-edit-apply", disabled: !canApply, onClick: () => {
50
88
  void onApply({
51
89
  branch,
52
- url: url.trim(),
90
+ url: selectedUrl,
53
91
  mode: branch === 'insert' ? mode : undefined
54
92
  });
55
93
  onOpenChange(false);
56
- }, children: t('inlineCanvas.chrome.save') })] })] }) }));
94
+ }, children: t('common.apply', '应用') })] })] }) }));
57
95
  }
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useState } from 'react';
3
3
  import { useTranslation } from 'react-i18next';
4
- import { Button, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, Input } from '@ptengine/design-components';
4
+ import { Button, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, Input, Label } from '@ptengine/design-components';
5
5
  export function ImageSettingDialog({ open, seed = '', onOpenChange, onApply }) {
6
6
  const { t } = useTranslation();
7
7
  const [alt, setAlt] = useState(seed);
@@ -10,8 +10,8 @@ export function ImageSettingDialog({ open, seed = '', onOpenChange, onApply }) {
10
10
  setAlt(seed);
11
11
  }, [open, seed]);
12
12
  const changed = alt !== seed;
13
- return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { className: "max-w-[480px]", "data-testid": "image-setting-dialog", children: [_jsx(DialogHeader, { children: _jsx(DialogTitle, { children: t('inlineCanvas.image.settingTitle') }) }), _jsxs("div", { children: [_jsx("p", { className: "mb-1.5 text-[13px] text-[#344563]", children: t('inlineCanvas.image.alt') }), _jsx(Input, { "data-testid": "image-setting-alt", value: alt, onChange: e => setAlt(e.target.value) })] }), _jsxs(DialogFooter, { children: [_jsx(Button, { variant: "ghost", onClick: () => onOpenChange(false), children: t('inlineCanvas.chrome.close') }), _jsx(Button, { "data-testid": "image-setting-apply", disabled: !changed, onClick: () => {
13
+ return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { closeLabel: t('inlineCanvas.chrome.close', '关闭'), className: "max-h-[calc(100dvh-32px)] overflow-y-auto", "data-testid": "image-setting-dialog", children: [_jsx(DialogHeader, { children: _jsx(DialogTitle, { children: t('inlineCanvas.image.settingTitle') }) }), _jsxs("div", { children: [_jsx(Label, { htmlFor: "image-setting-alt", children: t('inlineCanvas.image.alt') }), _jsx(Input, { id: "image-setting-alt", "aria-label": t('inlineCanvas.image.alt'), "data-testid": "image-setting-alt", value: alt, onChange: e => setAlt(e.target.value) }), _jsx("p", { className: "mt-2 text-xs text-muted-foreground", children: t('inlineCanvas.review.altHelp', '替代文本用于描述图片内容。装饰图片可留空。') })] }), _jsxs(DialogFooter, { children: [_jsx(Button, { variant: "secondary", onClick: () => onOpenChange(false), children: t('common.cancel', '取消') }), _jsx(Button, { "data-testid": "image-setting-apply", disabled: !changed, onClick: () => {
14
14
  void onApply(alt);
15
15
  onOpenChange(false);
16
- }, children: t('inlineCanvas.chrome.save') })] })] }) }));
16
+ }, children: t('common.apply', '应用') })] })] }) }));
17
17
  }
@@ -1,14 +1,16 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { CodeField } from './CodeField.js';
2
3
  import { useEffect, useMemo, useState } from 'react';
3
4
  import { useTranslation } from 'react-i18next';
4
5
  import { AlertTriangle } from 'lucide-react';
5
- import { Button, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, Textarea } from '@ptengine/design-components';
6
+ import { Button, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, ToggleGroup, ToggleGroupItem } from '@ptengine/design-components';
6
7
  import { pasteModes } from '../kernel/selection/toolbarButtons.js';
7
8
  import { validateHtml } from './htmlValidation.js';
8
9
  export function InsertElementDialog({ open, capabilities, onOpenChange, onApply }) {
9
10
  const { t } = useTranslation();
10
11
  const [html, setHtml] = useState('');
11
12
  const modes = useMemo(() => (capabilities ? pasteModes(capabilities) : []), [capabilities]);
13
+ const modeKey = modes.join(',');
12
14
  const [mode, setMode] = useState('append');
13
15
  useEffect(() => {
14
16
  if (!open)
@@ -16,13 +18,15 @@ export function InsertElementDialog({ open, capabilities, onOpenChange, onApply
16
18
  setHtml('');
17
19
  if (modes.length > 0)
18
20
  setMode(modes[0]);
19
- }, [open, modes]);
21
+ }, [open, modeKey]);
20
22
  const result = useMemo(() => validateHtml(html), [html]);
21
23
  const canApply = html.trim().length > 0 && result.ok && modes.length > 0;
22
- return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { className: "max-w-[640px]", "data-testid": "insert-element-dialog", children: [_jsx(DialogHeader, { children: _jsx(DialogTitle, { children: t('inlineCanvas.insert.title') }) }), _jsxs("div", { className: "flex flex-col gap-3", children: [_jsxs("div", { children: [_jsx("p", { className: "mb-1.5 text-[13px] text-[#344563]", children: t('inlineCanvas.insert.html') }), _jsx(Textarea, { "data-testid": "insert-html", rows: 8, className: "font-mono text-[12px]", value: html, onChange: e => setHtml(e.target.value) })] }), modes.length > 0 && (_jsxs("div", { children: [_jsx("p", { className: "mb-1.5 text-[13px] text-[#344563]", children: t('inlineCanvas.insert.position') }), _jsx("div", { className: "flex gap-1.5", children: modes.map(m => (_jsx("button", { type: "button", "data-testid": `insert-mode-${m}`, "aria-pressed": mode === m, onClick: () => setMode(m), className: `rounded border px-2 py-1 text-[12px] ${mode === m
23
- ? 'border-[#00A92E] bg-[#E9F9EE] text-[#091E42]'
24
- : 'border-[#DFE1E6] text-[#6B778C]'}`, children: t(`inlineCanvas.clipboard.${m}`) }, m))) })] })), html.trim().length > 0 && result.error && (_jsxs("p", { "data-testid": "insert-error", className: "flex items-start gap-1.5 text-[11px] leading-4 text-[#DE350B]", children: [_jsx(AlertTriangle, { className: "mt-0.5 size-3 shrink-0" }), t(result.error)] }))] }), _jsxs(DialogFooter, { children: [_jsx(Button, { variant: "ghost", onClick: () => onOpenChange(false), children: t('inlineCanvas.chrome.close') }), _jsx(Button, { "data-testid": "insert-apply", disabled: !canApply, onClick: () => {
24
+ return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { closeLabel: t('inlineCanvas.chrome.close', '关闭'), size: "md", className: "max-h-[calc(100dvh-32px)] overflow-y-auto", "data-testid": "insert-element-dialog", children: [_jsx(DialogHeader, { children: _jsx(DialogTitle, { children: t('inlineCanvas.insert.title') }) }), _jsxs("div", { className: "flex flex-col gap-3", children: [_jsxs("div", { children: [_jsx("p", { className: "mb-1.5 text-[13px] text-[#344563]", children: t('inlineCanvas.insert.html') }), _jsx(CodeField, { "aria-label": t('inlineCanvas.insert.html'), "data-testid": "insert-html", rows: 8, className: "font-mono text-[12px]", value: html, onChange: e => setHtml(e.target.value) })] }), modes.length > 0 && (_jsxs("div", { children: [_jsx("p", { className: "mb-1.5 text-[13px] text-[#344563]", children: t('inlineCanvas.insert.position') }), _jsx(ToggleGroup, { type: "single", variant: "outline", value: mode, onValueChange: value => {
25
+ if (modes.includes(value)) {
26
+ setMode(value);
27
+ }
28
+ }, "aria-label": t('inlineCanvas.insert.position'), style: { justifyContent: 'flex-start', flexWrap: 'wrap' }, children: modes.map(m => (_jsx(ToggleGroupItem, { value: m, "data-testid": `insert-mode-${m}`, children: t(`inlineCanvas.clipboard.${m}`) }, m))) })] })), html.trim().length > 0 && result.error && (_jsxs("p", { "data-testid": "insert-error", className: "flex items-start gap-1.5 text-xs leading-4 text-[#DE350B]", children: [_jsx(AlertTriangle, { className: "mt-0.5 size-3 shrink-0" }), t(result.error)] }))] }), _jsxs(DialogFooter, { children: [_jsx(Button, { variant: "secondary", onClick: () => onOpenChange(false), children: t('common.cancel', '取消') }), _jsx(Button, { "data-testid": "insert-apply", disabled: !canApply, onClick: () => {
25
29
  void onApply({ html: html.trim(), mode });
26
30
  onOpenChange(false);
27
- }, children: t('inlineCanvas.chrome.save') })] })] }) }));
31
+ }, children: t('common.apply', '应用') })] })] }) }));
28
32
  }
@@ -0,0 +1,5 @@
1
+ export declare function MediaPreview({ url, kind, onReady }: {
2
+ url: string;
3
+ kind?: 'image' | 'video';
4
+ onReady?: (ready: boolean) => void;
5
+ }): import("react").JSX.Element | null;
@@ -0,0 +1,34 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import { Button } from '@ptengine/design-components';
4
+ import { useTranslation } from 'react-i18next';
5
+ export function MediaPreview({ url, kind = 'image', onReady }) {
6
+ const mediaRef = useRef(null);
7
+ const { t } = useTranslation();
8
+ const [state, setState] = useState('loading');
9
+ const [retry, setRetry] = useState(0);
10
+ useEffect(() => {
11
+ const media = mediaRef.current;
12
+ const loaded = media instanceof HTMLImageElement
13
+ ? media.complete && media.naturalWidth > 0
14
+ : media instanceof HTMLVideoElement && media.readyState >= 1;
15
+ setState(loaded ? 'ready' : 'loading');
16
+ onReady?.(!!loaded);
17
+ }, [url, retry]);
18
+ const ready = () => {
19
+ setState('ready');
20
+ onReady?.(true);
21
+ };
22
+ const fail = () => {
23
+ setState('error');
24
+ onReady?.(false);
25
+ };
26
+ if (!url)
27
+ return null;
28
+ const youtube = /^https:\/\/(www\.)?youtube(-nocookie)?\.com\/embed\//i.test(url);
29
+ return (_jsxs("div", { className: "min-w-0 space-y-2", children: [kind === 'image' ? (_jsx("img", { ref: el => {
30
+ mediaRef.current = el;
31
+ }, src: url, alt: t('inlineCanvas.review.mediaPreview', '媒体预览'), onLoad: ready, onError: fail, className: "max-h-48 w-full rounded-lg object-contain" })) : youtube ? (_jsx("iframe", { title: t('inlineCanvas.review.mediaPreview', '媒体预览'), src: url, onLoad: ready, onError: fail, className: "aspect-video w-full", allowFullScreen: true })) : (_jsx("video", { ref: el => {
32
+ mediaRef.current = el;
33
+ }, src: url, controls: true, preload: "metadata", onLoadedMetadata: ready, onError: fail, className: "max-h-48 w-full" })), _jsx("div", { role: "status", className: "text-xs text-muted-foreground", children: state === 'loading' ? (t('inlineCanvas.review.loading', '正在加载预览…')) : state === 'error' ? (_jsxs(_Fragment, { children: [t('inlineCanvas.review.loadError', '预览加载失败'), ' ', _jsx(Button, { size: "sm", variant: "secondary", onClick: () => setRetry(x => x + 1), children: t('inlineCanvas.review.retry', '重试') })] })) : (t('inlineCanvas.review.ready', '媒体已就绪,应用后修改画布')) })] }, `${url}-${retry}`));
34
+ }
@@ -1,16 +1,20 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { MediaPreview } from './MediaPreview.js';
2
3
  import { useEffect, useState } from 'react';
3
4
  import { useTranslation } from 'react-i18next';
4
- import { Button, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, Input } from '@ptengine/design-components';
5
+ import { Button, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, Input, Label } from '@ptengine/design-components';
5
6
  export function ReplaceVideoDialog({ open, seed = '', onOpenChange, onApply }) {
6
7
  const { t } = useTranslation();
8
+ const [ready, setReady] = useState(false);
7
9
  const [url, setUrl] = useState(seed);
8
10
  useEffect(() => {
9
- if (open)
11
+ if (open) {
10
12
  setUrl(seed);
13
+ setReady(false);
14
+ }
11
15
  }, [open, seed]);
12
- return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { className: "max-w-[480px]", "data-testid": "replace-video-dialog", children: [_jsx(DialogHeader, { children: _jsx(DialogTitle, { children: t('inlineCanvas.video.title') }) }), _jsxs("div", { children: [_jsx("p", { className: "mb-1.5 text-[13px] text-[#344563]", children: t('inlineCanvas.video.url') }), _jsx(Input, { "data-testid": "replace-video-url", value: url, onChange: e => setUrl(e.target.value) }), _jsx("p", { className: "mt-1.5 text-[11px] leading-4 text-[#6B778C]", children: t('inlineCanvas.video.hint') })] }), _jsxs(DialogFooter, { children: [_jsx(Button, { variant: "ghost", onClick: () => onOpenChange(false), children: t('inlineCanvas.chrome.close') }), _jsx(Button, { "data-testid": "replace-video-apply", disabled: url.trim().length === 0, onClick: () => {
16
+ return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { closeLabel: t('inlineCanvas.chrome.close', '关闭'), className: "max-h-[calc(100dvh-32px)] overflow-y-auto", "data-testid": "replace-video-dialog", children: [_jsx(DialogHeader, { children: _jsx(DialogTitle, { children: t('inlineCanvas.video.title') }) }), _jsxs("div", { children: [seed && (_jsxs("details", { children: [_jsx("summary", { children: t('inlineCanvas.review.currentMedia', '当前媒体') }), _jsx(MediaPreview, { kind: "video", url: seed })] })), _jsx(Label, { htmlFor: "replace-video-url", children: t('inlineCanvas.video.url') }), _jsx(Input, { id: "replace-video-url", "aria-label": t('inlineCanvas.video.url'), "data-testid": "replace-video-url", value: url, onChange: e => setUrl(e.target.value) }), _jsx("p", { className: "mt-1.5 text-xs leading-4 text-[#6B778C]", children: t('inlineCanvas.review.videoLinkHint', '支持视频文件链接和 YouTube 嵌入链接。加载就绪后应用到画布。') })] }), _jsx(MediaPreview, { kind: "video", url: url.trim(), onReady: setReady }, url), _jsxs(DialogFooter, { children: [_jsx(Button, { variant: "secondary", onClick: () => onOpenChange(false), children: t('common.cancel', '取消') }), _jsx(Button, { "data-testid": "replace-video-apply", disabled: !url.trim() || !ready, onClick: () => {
13
17
  void onApply(url.trim());
14
18
  onOpenChange(false);
15
- }, children: t('inlineCanvas.chrome.save') })] })] }) }));
19
+ }, children: t('common.apply', '应用') })] })] }) }));
16
20
  }
package/dist/index.d.ts CHANGED
@@ -79,6 +79,7 @@ export { SHELF_LAYOUT_FIELDS, laysOutInRow, rowPositions } from './kernel/select
79
79
  export type { RowPosition } from './kernel/selection/rowLayout.js';
80
80
  export { linkPath } from './kernel/linkHref.js';
81
81
  export { buildRootShelf } from './kernel/selection/buildRootShelf.js';
82
+ export type { ShelfBlockHint } from './kernel/selection/buildRootShelf.js';
82
83
  export { LayersPanel } from './tree/LayersPanel.js';
83
84
  export { adaptDomTree, moveModeFromDropPos } from './tree/domTreeAdapter.js';
84
85
  export type { AdaptDomTreeOptions, TreeNodeIconInput } from './tree/domTreeAdapter.js';
@@ -248,10 +248,11 @@ export interface AiApplyDeps {
248
248
  */
249
249
  ancestors: string[];
250
250
  /**
251
- * 货架里某一项的祖先链(由近及远,含作用域根自己)。**整页作用域专用**。
251
+ * 货架里某一项的祖先链(由近及远,含作用域根自己)。**整页货架那些行用**。
252
252
  *
253
- * 元素作用域不传:那时 `ancestors` 就是选中元素的祖先链,而作用域里的后代都在它
254
- * 底下,同一条链够用。整页作用域不一样 —— 根是 `body`,它的 `ancestors` 是空数组,
253
+ * 纯元素作用域不传:那时 `ancestors` 就是选中元素的祖先链,而作用域里的后代都在它
254
+ * 底下,同一条链够用。元素作用域并上了整页货架时(宿主有整页模式)传,查的是并进来的那些行。
255
+ * 整页作用域不一样 —— 根是 `body`,它的 `ancestors` 是空数组,
255
256
  * 拿它去判 `MOVE_ELEMENT` 的 target 会把**所有**移动判成非法(body 没有祖先)。
256
257
  * 这个函数让那道闸改为对着「被移动的那个元素」的祖先链判,规则本身没变。
257
258
  *
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { frameRectToProductRect } from '../selection/geometry.js';
3
- const Z_INDEX = 2147482990;
3
+ const Z_INDEX = 890;
4
4
  const CHIP_HEIGHT = 18;
5
5
  const COLORS = { base: '#1D4ED8', active: '#DB2777', none: '#94A3B8' };
6
6
  function boxStyle(rect, color, active, none) {
@@ -1,4 +1,5 @@
1
1
  import type { DomTreeNode, ElementsInBandResult, GetDomTreeParams, ResolveHeatmapSelectorResult } from '@ptengine/inline-core/protocol';
2
+ import { type RowPosition } from '../selection/rowLayout.js';
2
3
  /**
3
4
  * 分析画布:把热图的**区块配置**(MySQL 里那份,apps/x 读来)画到客户活页面上,
4
5
  * 并给每块找一个锚点元素 + 一份 AI 能读的摘要(标题 / 文案 / CTA)。
@@ -24,6 +25,11 @@ import type { DomTreeNode, ElementsInBandResult, GetDomTreeParams, ResolveHeatma
24
25
  * 3. **none** —— 都没有:members 空。区块框照画(位置本来就来自配置,一样准),
25
26
  * 只是点不进任何元素、也没有 selector 可以交给 agent。
26
27
  *
28
+ * 三条路定下成员之后,**成员上提到块根**(`liftToBlockRoots`):轮播的一张、并列的一列、区块里的一个小节点,
29
+ * 提到「只装着这一块的最外层容器」—— 与编辑器整页货架打 `[Bn]` 的那一行是同一个元素(同一份判定、同一份
30
+ * 取树口径)。真机 vivaia.jp 首页(2026-10-21):B1 的成员是轮播第 1 张,`<page-block>` 的成员行与方案锚点
31
+ * 照抄它,首版 after 它把「首屏下方」的新模块插进了轮播。摘要(heading / text / cta / links)按上提后的成员取。
32
+ *
27
33
  * 编号 `B1..Bn` 按输入的 `yStart` 升序,与模型看到的瓦片标注一致(`annotateAndTile`)。
28
34
  *
29
35
  * 三条跨域命令经 `deps` 注入:编排逻辑不需要插件也不需要浏览器就能测。任何一条失败都
@@ -46,6 +52,11 @@ export interface PageModelBlockMember {
46
52
  /** 该 member 子树的文案摘要(折叠空白、≤80)。拉树失败为空串。 */
47
53
  text: string;
48
54
  hasChildren: boolean;
55
+ /**
56
+ * 它在父级横排里的位次(整页树上判,与整页货架行的 `inRow` 同一份)。`<page-block>` 的成员行据此打
57
+ * `[横排 i/n]`:成员行的父级不在它自己的子树里,只能从整页树带过来。不在横排里 / 判不了时不写这个键。
58
+ */
59
+ inRow?: RowPosition;
49
60
  }
50
61
  export interface PageModelBlock {
51
62
  /** `'B1'`,`'B2'`… 按 `yStart` 升序。 */
@@ -142,6 +153,11 @@ export interface ResolveBlocksOptions {
142
153
  * 三处共用这套坐标,由各自的消费者在画的时候乘比例。
143
154
  */
144
155
  scale?: number;
156
+ /**
157
+ * 画布视口宽(mobile 375 / pc 1440)。成员上提的块根判据按它算「够宽」门槛,与编辑器整页货架传的是
158
+ * 同一个数;缺省按 375(与 `buildRootShelf` 的缺省一致)。
159
+ */
160
+ viewportWidth?: number;
145
161
  }
146
162
  export declare function resolveBlocks(sessionId: string, blocks: HeatmapBlockConfig[], deps?: ResolveBlocksDeps, opts?: ResolveBlocksOptions): Promise<PageModelBlock[]>;
147
163
  /**
@@ -1,6 +1,8 @@
1
1
  import { elementsInBand as elementsInBandCommand, getDomTree as getDomTreeCommand, resolveHeatmapSelector as resolveHeatmapSelectorCommand } from '../canvasCommands.js';
2
2
  import { linkPath } from '../linkHref.js';
3
+ import { ROOT_SHELF_TREE_PARAMS, liftToBlockRoots } from '../selection/buildRootShelf.js';
3
4
  import { SHELF_TREE_MAX_DEPTH } from '../selection/elementAiContext.js';
5
+ import { rowPositions } from '../selection/rowLayout.js';
4
6
  const defaultDeps = {
5
7
  resolveHeatmapSelector: resolveHeatmapSelectorCommand,
6
8
  elementsInBand: elementsInBandCommand,
@@ -104,7 +106,7 @@ export async function resolveBlocks(sessionId, blocks, deps = defaultDeps, opts
104
106
  catch {
105
107
  }
106
108
  }
107
- const out = [];
109
+ const located = [];
108
110
  for (let i = 0; i < ordered.length; i++) {
109
111
  const cfg = ordered[i];
110
112
  const base = { id: idOf(i), name: cfg.name };
@@ -120,7 +122,7 @@ export async function resolveBlocks(sessionId, blocks, deps = defaultDeps, opts
120
122
  hits.push({ selector: m.kernelSelector, tag: m.tag ?? '', rect: m.rect });
121
123
  }
122
124
  if (hits.length > 0) {
123
- out.push(await enrich(sessionId, deps, {
125
+ located.push({
124
126
  ...base,
125
127
  ...geometryOf(cfg),
126
128
  members: hits.map(h => ({
@@ -132,7 +134,7 @@ export async function resolveBlocks(sessionId, blocks, deps = defaultDeps, opts
132
134
  ctas: [],
133
135
  text: '',
134
136
  resolved: 'selector'
135
- }));
137
+ });
136
138
  continue;
137
139
  }
138
140
  let candidate = null;
@@ -147,7 +149,7 @@ export async function resolveBlocks(sessionId, blocks, deps = defaultDeps, opts
147
149
  candidate = null;
148
150
  }
149
151
  if (candidate) {
150
- out.push(await enrich(sessionId, deps, {
152
+ located.push({
151
153
  ...base,
152
154
  ...geometryOf(cfg),
153
155
  members: [
@@ -162,10 +164,10 @@ export async function resolveBlocks(sessionId, blocks, deps = defaultDeps, opts
162
164
  text: '',
163
165
  resolved: 'geometry',
164
166
  iou: candidate.iou
165
- }));
167
+ });
166
168
  continue;
167
169
  }
168
- out.push({
170
+ located.push({
169
171
  ...base,
170
172
  ...geometryOf(cfg),
171
173
  members: [],
@@ -174,8 +176,39 @@ export async function resolveBlocks(sessionId, blocks, deps = defaultDeps, opts
174
176
  resolved: 'none'
175
177
  });
176
178
  }
179
+ const lifted = await liftMembers(sessionId, deps, located, opts.viewportWidth);
180
+ const out = [];
181
+ for (const b of lifted)
182
+ out.push(b.members.length ? await enrich(sessionId, deps, b) : b);
177
183
  return out;
178
184
  }
185
+ async function liftMembers(sessionId, deps, blocks, viewportWidth) {
186
+ if (!blocks.some(b => b.members.length))
187
+ return blocks;
188
+ try {
189
+ const tree = await deps.getDomTree(sessionId, ROOT_SHELF_TREE_PARAMS);
190
+ const liftOf = liftToBlockRoots(tree, { viewportWidth, blocks });
191
+ const inRowOf = rowPositions(tree);
192
+ return blocks.map(b => {
193
+ if (!b.members.length)
194
+ return b;
195
+ const byMember = liftOf.get(b.id);
196
+ const members = [];
197
+ for (const m of b.members) {
198
+ const root = byMember?.get(m.selector);
199
+ const next = root ? { ...m, selector: root.selector, tag: root.tag } : m;
200
+ if (members.some(x => x.selector === next.selector))
201
+ continue;
202
+ const inRow = inRowOf.get(next.selector);
203
+ members.push(inRow ? { ...next, inRow } : next);
204
+ }
205
+ return { ...b, members };
206
+ });
207
+ }
208
+ catch {
209
+ return blocks;
210
+ }
211
+ }
179
212
  export function isAutoGeneratedBlockName(name) {
180
213
  const n = name.trim();
181
214
  if (!n)
@@ -1,12 +1,12 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useMemo, useState } from 'react';
3
3
  import { toWireFormat } from '@ptengine/inline-core/change-format';
4
- import { Code2, Crosshair, EyeOff, Image, Link2, Move, Plus, Trash2, Type, Undo2, Wand } from 'lucide-react';
4
+ import { Code2, Crosshair, EyeOff, Image, Link2, Move, Plus, Trash2, Type, Undo2, Paintbrush } from 'lucide-react';
5
5
  import { Button } from '@ptengine/design-components';
6
6
  import { summarizeChange } from './changeSummary.js';
7
7
  import { classifySuppression } from './mergeForSave.js';
8
8
  const ICONS = {
9
- style: Wand,
9
+ style: Paintbrush,
10
10
  delete: Trash2,
11
11
  text: Type,
12
12
  code: Code2,
@@ -16,19 +16,21 @@ const ICONS = {
16
16
  insert: Plus
17
17
  };
18
18
  export function ChangeListPanel({ list, onRevert, lastError, onHover, onSelect, onRevertAll }) {
19
+ const [confirmRevert, setConfirmRevert] = useState(false);
19
20
  const [view, setView] = useState('summary');
20
21
  const entries = useMemo(() => list.map(r => ({ change: r.change, ancestors: r.ancestors })), [list]);
21
22
  const json = useMemo(() => JSON.stringify(list.map(r => toWireFormat(r.change)), null, 2), [list]);
22
- return (_jsxs("section", { "data-testid": "change-list-panel", className: "flex min-h-0 flex-1 flex-col", onMouseLeave: () => onHover?.(''), children: [_jsxs("header", { className: "flex h-10 shrink-0 items-center justify-between gap-2 border-b\n border-[#f1f2f3] pl-3 pr-2", children: [_jsxs("span", { className: "truncate text-[13px] font-medium text-[#1a1a1a]", children: ["\u6539\u52A8\u6E05\u5355", _jsx("span", { "data-testid": "change-list-count", className: "ml-1.5 text-xs font-normal tabular-nums text-[#8a8f98]", children: list.length })] }), _jsxs("div", { className: "flex shrink-0 items-center gap-1", children: [_jsx("div", { className: "flex items-center gap-px rounded-md bg-[#f3f4f4] p-0.5", children: [
23
+ return (_jsxs("section", { "data-testid": "change-list-panel", className: "flex min-h-0 flex-1 flex-col", onMouseLeave: () => onHover?.(''), children: [confirmRevert && (_jsxs("div", { role: "alert", className: "border-b p-3 text-xs", children: ["\u786E\u5B9A\u64A4\u9500\u6B64\u7248\u672C\u7684\u5168\u90E8\u6539\u52A8\uFF1F", _jsxs("div", { className: "mt-2 flex justify-end gap-2", children: [_jsx(Button, { size: "sm", variant: "secondary", onClick: () => setConfirmRevert(false), children: "\u53D6\u6D88" }), _jsx(Button, { size: "sm", variant: "destructive", onClick: () => {
24
+ setConfirmRevert(false);
25
+ onRevertAll?.();
26
+ }, children: "\u64A4\u9500\u5168\u90E8\u6539\u52A8" })] })] })), _jsxs("header", { className: "flex h-10 shrink-0 items-center justify-between gap-2 border-b\n border-[#f1f2f3] pl-3 pr-2", children: [_jsxs("span", { className: "truncate text-[13px] font-medium text-[#1a1a1a]", children: ["\u6539\u52A8\u6E05\u5355", _jsx("span", { "data-testid": "change-list-count", className: "ml-1.5 text-xs font-normal tabular-nums text-[#8a8f98]", children: list.length })] }), _jsxs("div", { className: "flex shrink-0 items-center gap-1", children: [_jsx("div", { className: "flex items-center gap-px rounded-md bg-[#f3f4f4] p-0.5", children: [
23
27
  ['summary', '摘要', undefined],
24
28
  [
25
29
  'wire',
26
- '数值',
27
- '落盘形态,可与线上存量数据逐字段比对'
30
+ '高级数据',
31
+ '查看此版本的高级修改数据'
28
32
  ]
29
- ].map(([v, label, hint]) => (_jsx("button", { type: "button", "data-testid": `change-list-view-${v}`, "aria-pressed": view === v, title: hint, onClick: () => setView(v), className: `rounded px-2 py-[3px] text-[11px] transition-colors ${view === v
30
- ? 'bg-white font-medium text-[rgba(16,28,31,0.9)] shadow-[0_1px_2px_rgba(16,28,31,0.08)]'
31
- : 'text-[#707779] hover:text-[rgba(16,28,31,0.7)]'}`, children: label }, v))) }), onRevertAll && list.length > 0 && (_jsx("button", { type: "button", "data-testid": "change-list-revert-all", onClick: onRevertAll, className: "rounded px-2 py-1 text-xs text-[#52525b] transition-colors\n hover:bg-[rgba(16,28,31,0.06)] hover:text-[rgba(16,28,31,0.9)]", children: "\u5168\u90E8\u64A4\u9500" }))] })] }), lastError && (_jsx("p", { "data-testid": "change-list-error", className: "px-3 py-2 text-xs text-[#DE350B]", children: lastError })), list.length === 0 ? (_jsx("p", { "data-testid": "change-list-empty", className: "px-3 py-8 text-center text-xs text-[#8a8f98]", children: "\u8FD8\u6CA1\u6709\u6539\u52A8\u3002\u5728\u753B\u5E03\u91CC\u9009\u4E2D\u5143\u7D20\u5F00\u59CB\u7F16\u8F91\u3002" })) : view === 'summary' ? (_jsx("ul", { className: "min-h-0 flex-1 overflow-y-auto", children: list.map((record, index) => (_jsx(ChangeRow, { record: record, summary: summarizeChange(record.change), suppression: classifySuppression(entries, index), onRevert: onRevert, onHover: onHover, onSelect: onSelect }, record.changeId))) })) : (_jsx("pre", { "data-testid": "change-list-json", className: "min-h-0 flex-1 overflow-auto bg-[#FAFAFA] px-3 py-2 text-[11px]\n leading-4 text-[#344563]", children: json }))] }));
33
+ ].map(([v, label, hint]) => (_jsx(Button, { type: "button", "data-testid": `change-list-view-${v}`, "aria-pressed": view === v, title: hint, onClick: () => setView(v), size: "sm", variant: view === v ? 'secondary' : 'ghost', children: label }, v))) }), onRevertAll && list.length > 0 && (_jsx(Button, { type: "button", "data-testid": "change-list-revert-all", onClick: () => setConfirmRevert(true), size: "sm", variant: "ghost", children: "\u5168\u90E8\u64A4\u9500" }))] })] }), lastError && (_jsx("p", { "data-testid": "change-list-error", className: "px-3 py-2 text-xs text-[#DE350B]", children: lastError })), list.length === 0 ? (_jsx("p", { "data-testid": "change-list-empty", className: "px-3 py-8 text-center text-xs text-[#8a8f98]", children: "\u8FD8\u6CA1\u6709\u6539\u52A8\u3002\u5728\u753B\u5E03\u91CC\u9009\u4E2D\u5143\u7D20\u5F00\u59CB\u7F16\u8F91\u3002" })) : view === 'summary' ? (_jsx("ul", { className: "min-h-0 flex-1 overflow-y-auto", children: list.map((record, index) => (_jsx(ChangeRow, { record: record, summary: summarizeChange(record.change), suppression: classifySuppression(entries, index), onRevert: onRevert, onHover: onHover, onSelect: onSelect }, record.changeId))) })) : (_jsx("pre", { "data-testid": "change-list-json", className: "min-h-0 flex-1 overflow-auto bg-[#FAFAFA] px-3 py-2 text-[11px]\n leading-4 text-[#344563]", children: json }))] }));
32
34
  }
33
35
  const SUPPRESSION_TEXT = {
34
36
  'ancestor-hidden': {
@@ -43,5 +45,5 @@ const SUPPRESSION_TEXT = {
43
45
  function ChangeRow({ record, summary, suppression, onRevert, onHover, onSelect }) {
44
46
  const Icon = ICONS[summary.icon];
45
47
  const text = suppression ? SUPPRESSION_TEXT[suppression.reason] : null;
46
- return (_jsxs("li", { "data-testid": "change-list-item", "data-suppressed": suppression ? suppression.reason : undefined, className: "group flex items-center gap-2.5 border-b border-[#f1f2f3] px-3 py-2.5\n last:border-b-0 hover:bg-[rgba(16,28,31,0.04)]", onMouseEnter: () => onHover?.(summary.selector), children: [_jsx(Icon, { className: `size-4 shrink-0 ${suppression ? 'text-[#B3BAC5]' : 'text-[#6B778C]'}` }), _jsxs("button", { type: "button", "data-testid": "change-list-locate", onClick: () => onSelect?.(summary.selector), className: "flex min-w-0 flex-1 flex-col items-start gap-0.5 text-left", title: summary.selector, children: [_jsxs("span", { className: `flex items-center gap-1.5 text-[13px] font-medium ${suppression ? 'text-[#8993A4]' : 'text-[#1a1a1a]'}`, children: [summary.action, onSelect && (_jsx(Crosshair, { className: "size-3 text-[#8993A4] opacity-0 group-hover:opacity-100" }))] }), text && (_jsxs("span", { "data-testid": "change-list-suppressed-badge", className: "flex items-center gap-1 rounded bg-[#FFF7E6] px-1.5 py-0.5\n text-[11px] leading-4 text-[#974F0C]", title: `${text.hint}(${suppression?.by})`, children: [_jsx(EyeOff, { className: "size-3 shrink-0" }), text.badge] })), summary.detail && (_jsx("span", { className: `w-full truncate font-mono text-[11px] ${suppression ? 'text-[#8993A4] line-through' : 'text-[#344563]'}`, children: summary.detail })), _jsx("span", { className: "w-full truncate font-mono text-[11px] text-[#8993A4]", children: summary.selector })] }), _jsx(Button, { variant: "ghost", size: "icon", "data-testid": `change-list-revert-${record.changeId}`, "aria-label": "\u64A4\u9500", onClick: () => onRevert(record.changeId), children: _jsx(Undo2, { className: "size-4" }) })] }));
48
+ return (_jsxs("li", { "data-testid": "change-list-item", "data-suppressed": suppression ? suppression.reason : undefined, className: "group flex items-center gap-2.5 border-b border-[#f1f2f3] px-3 py-2.5\n last:border-b-0 hover:bg-[rgba(16,28,31,0.04)]", onMouseEnter: () => onHover?.(summary.selector), children: [_jsx(Icon, { className: `size-4 shrink-0 ${suppression ? 'text-[#B3BAC5]' : 'text-[#6B778C]'}` }), _jsxs(Button, { type: "button", variant: "ghost", "data-testid": "change-list-locate", onClick: () => onSelect?.(summary.selector), className: "flex h-auto min-h-8 min-w-0 flex-1 flex-col items-start justify-start gap-0.5 whitespace-normal py-2 text-left", title: summary.selector, children: [_jsxs("span", { className: `flex items-center gap-1.5 text-[13px] font-medium ${suppression ? 'text-[#8993A4]' : 'text-[#1a1a1a]'}`, children: [summary.action, onSelect && (_jsx(Crosshair, { className: "size-3 text-[#8993A4] opacity-0 group-hover:opacity-100" }))] }), text && (_jsxs("span", { "data-testid": "change-list-suppressed-badge", className: "flex items-center gap-1 rounded bg-[#FFF7E6] px-1.5 py-0.5\n text-[11px] leading-4 text-[#974F0C]", title: `${text.hint}(${suppression?.by})`, children: [_jsx(EyeOff, { className: "size-3 shrink-0" }), text.badge] })), summary.detail && (_jsx("span", { className: `w-full truncate font-mono text-[11px] ${suppression ? 'text-[#8993A4] line-through' : 'text-[#344563]'}`, children: summary.detail })), _jsx("span", { className: "w-full truncate font-mono text-[11px] text-[#8993A4]", children: summary.selector })] }), _jsx(Button, { variant: "ghost", size: "icon", "data-testid": `change-list-revert-${record.changeId}`, "aria-label": "\u64A4\u9500", onClick: () => onRevert(record.changeId), children: _jsx(Undo2, { className: "size-4" }) })] }));
47
49
  }
@@ -82,6 +82,11 @@ export interface UseChangeListResult {
82
82
  outcome: HydrateOutcome;
83
83
  idMap: Map<string, string>;
84
84
  }>;
85
+ whenIdle: () => Promise<unknown>;
86
+ canUndo: boolean;
87
+ canRedo: boolean;
88
+ undo: () => Promise<void>;
89
+ redo: () => Promise<void>;
85
90
  lastError: string | null;
86
91
  }
87
92
  export interface HydrateOutcome {