@ptengine/inline-editor 0.1.6 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.7
4
+
5
+ 依赖 `@ptengine/inline-core` ~0.2.1(新增 `historyShortcut` 流事件):**先发 inline-core 0.2.1,再发本版**。
6
+
7
+ **变更**
8
+
9
+ - 整页模式下(宿主接了 `onPageContextReady`)选中元素时,AI 作用域 = 选中元素 ∪ 它的子树货架 ∪ **整页货架**,
10
+ 并按整页货架那些行的真实祖先链收敛落盘;MOVE 也认货架里任意一行作 target。用户点中的常不是他说的那一层 ——
11
+ vivaia 首屏点哪都选中幻灯片的空覆盖链接,「首屏下面加评价」要 after 整个 section、「换这张图」要改它的兄弟 img,
12
+ 此前都不在子树里,只能请用户取消选择(真机 2026-10-21)。并进来的仍是宿主发出去的那份货架,货架外拼出来的
13
+ selector 照拒;宿主选中时也要发 `<page-shelf>`。没有整页模式的老宿主行为不变。
14
+ - 整页货架补上矮区块:页面模型的成员容器只要够宽就是候选、不看高度;块根父级(上下叠放的那一层)里不够高、但够宽、
15
+ 有内容、不在 header / nav / footer 里的孩子补成块根,参与排序与叶子配额,也能写 `blockId`。此前低于 120px 的区块
16
+ (vivaia 手机首页 86px 的新闻区)等不到广度优先回填就被行数上限挤掉,标题进不了货架,改它的请求全部被拒。
17
+ 装着 ≥2 个候选的外壳不再因「只认领了这一块」成块;分析路径的成员(`liftToBlockRoots`)同口径上提到矮块根。
18
+ - 插入位置与移动位置统一使用组件库的 outline 单选 ToggleGroup;移动工具栏的位置选择单独一行,取消与确认固定在下方一行靠右,统一控件高度。移动先选位置,再确认执行,支持取消,提交期间阻止重复操作。
19
+
20
+ **修复**
21
+
22
+ - I01:设备切换先等待已开始的写入完成,再保存当前 live 清单并交还版本宿主;新会话从这份快照回放,回放期间阻止编辑与保存。版本和设备会话切换仍重置操作历史。
23
+ - I02:宿主和画布的 Cmd/Ctrl+Z、Shift+Z / Ctrl+Y 接入已有操作历史;输入框和文字编辑期间保留原生撤销。
24
+ - I04:样式读取当前生效值并随编辑/撤销刷新,仅已有覆盖提供清除入口;图片/背景图、视频、alt 读取 live DOM,点击动作回填当前版本的配置。读取失败显示错误。
25
+ - I05:画布会话失败显示目标页加载提示、原因与返回入口,避免无反馈的空画布。
26
+ - I09:文字、插入、移动及撤销/重做后刷新图层标签和结构,复用图层组件以保留搜索和折叠状态;纯样式编辑不重拉图层。
27
+ - 复验既有 I03 浮层布局:全屏 z-index 1000 宿主、1024×480、拖拽及关闭重开,稳定后完整位于视口内。
28
+
3
29
  ## 0.1.6
4
30
 
5
31
  **变更**
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { EditorStatus } from './panels/EditorStatus.js';
3
+ import { MovePositionControls } from './panels/MovePositionControls.js';
3
4
  import { ScreenshotCard } from './panels/ScreenshotCard.js';
4
5
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
5
6
  import { useTranslation } from 'react-i18next';
@@ -16,7 +17,7 @@ import { ChangeListPanel } from './kernel/changelist/ChangeListPanel.js';
16
17
  import { ChangeListPopover } from './kernel/changelist/ChangeListPopover.js';
17
18
  import { frameRectToProductRect, measureFrameContext } from './kernel/selection/geometry.js';
18
19
  import { buildElementAiContext, extractPageLinks, linksFragment, ROOT_SHELF_LIMIT, SHELF_TREE_MAX_DEPTH, SHELF_TREE_MAX_NODES } from './kernel/selection/elementAiContext.js';
19
- import { getAncestorChain, getDomTree, getElementCapabilities, getElementImages, getOuterHtml, resolveCanvasSelector } from './kernel/canvasCommands.js';
20
+ import { getAncestorChain, getDomTree, getElementCapabilities, getElementImages, getOuterHtml, resolveCanvasSelector, queryElements } from './kernel/canvasCommands.js';
20
21
  import { useAiSelectionContext } from './kernel/selection/useAiSelectionContext.js';
21
22
  import { buildDescendantShelf } from './kernel/selection/buildDescendantShelf.js';
22
23
  import { ROOT_SHELF_TREE_PARAMS, buildRootShelf } from './kernel/selection/buildRootShelf.js';
@@ -35,6 +36,7 @@ import { ImageSettingDialog } from './dialogs/ImageSettingDialog.js';
35
36
  import { InsertElementDialog } from './dialogs/InsertElementDialog.js';
36
37
  import { ReplaceVideoDialog } from './dialogs/ReplaceVideoDialog.js';
37
38
  import { StylePanel } from './panels/StylePanel.js';
39
+ import { STYLE_SECTIONS } from './panels/styleFields.js';
38
40
  import { HtmlEditDialog } from './dialogs/HtmlEditDialog.js';
39
41
  import { setCode } from './kernel/canvasCommands.js';
40
42
  import { describeCanvasError } from './kernel/canvasErrors.js';
@@ -60,7 +62,9 @@ export function InlineEditor({ url, device, onDeviceChange, title, versionLabel
60
62
  const canvasIframeRef = useRef(null);
61
63
  const insertedRemovedRef = useRef(null);
62
64
  const insertedRestoredRef = useRef(null);
65
+ const historyShortcutRef = useRef(() => { });
63
66
  const editor = useInlineEditor({
67
+ onHistoryShortcut: direction => historyShortcutRef.current(direction),
64
68
  onInsertedRemoved: selectors => insertedRemovedRef.current?.(selectors),
65
69
  onInsertedRestored: selectors => insertedRestoredRef.current?.(selectors),
66
70
  url,
@@ -80,6 +84,7 @@ export function InlineEditor({ url, device, onDeviceChange, title, versionLabel
80
84
  const [showChangeList, setShowChangeList] = useState(false);
81
85
  const [stylePanelOpen, setStylePanelOpen] = useState(false);
82
86
  const [htmlDialog, setHtmlDialog] = useState({ open: false, seed: '' });
87
+ const [currentStyles, setCurrentStyles] = useState({});
83
88
  const [imageDialog, setImageDialog] = useState({ open: false, seed: '' });
84
89
  const [imageSettingDialog, setImageSettingDialog] = useState({ open: false, seed: '' });
85
90
  const [videoDialog, setVideoDialog] = useState({ open: false, seed: '' });
@@ -96,6 +101,25 @@ export function InlineEditor({ url, device, onDeviceChange, title, versionLabel
96
101
  return '';
97
102
  }
98
103
  }, [url]);
104
+ useEffect(() => {
105
+ if (!stylePanelOpen || !selection || !frameProps?.sessionId)
106
+ return;
107
+ let cancelled = false;
108
+ void queryElements(frameProps.sessionId, [selection.selector], {
109
+ fields: STYLE_SECTIONS.flatMap(section => section.fields.map(field => `computed:${field.property}`))
110
+ })
111
+ .then(result => {
112
+ if (!cancelled)
113
+ setCurrentStyles(result.results[0]?.matches[0]?.computed ?? {});
114
+ })
115
+ .catch(error => {
116
+ if (!cancelled)
117
+ setCommandError(describeCanvasError(error));
118
+ });
119
+ return () => {
120
+ cancelled = true;
121
+ };
122
+ }, [stylePanelOpen, selection?.selector, frameProps?.sessionId, changeList.list]);
99
123
  const changeListRef = useRef(changeList);
100
124
  changeListRef.current = changeList;
101
125
  const versionChangesRef = useRef(versionChanges);
@@ -172,6 +196,27 @@ export function InlineEditor({ url, device, onDeviceChange, title, versionLabel
172
196
  }
173
197
  void run().catch(e => setCommandError(describeCanvasError(e)));
174
198
  }, [t]);
199
+ historyShortcutRef.current = direction => {
200
+ if (!domReady || textEditing || switchingRef.current)
201
+ return;
202
+ uiWrite(() => enqueue(() => direction === 'undo' ? changeListRef.current.undo() : changeListRef.current.redo()));
203
+ };
204
+ useEffect(() => {
205
+ const onKey = (event) => {
206
+ if (!(event.metaKey || event.ctrlKey) || event.altKey)
207
+ return;
208
+ const target = event.target;
209
+ if (target?.closest('input, textarea, select, [contenteditable="true"], [role="textbox"]'))
210
+ return;
211
+ const key = event.key.toLowerCase();
212
+ if (key !== 'z' && !(key === 'y' && event.ctrlKey))
213
+ return;
214
+ event.preventDefault();
215
+ historyShortcutRef.current(event.shiftKey || key === 'y' ? 'redo' : 'undo');
216
+ };
217
+ document.addEventListener('keydown', onKey);
218
+ return () => document.removeEventListener('keydown', onKey);
219
+ }, []);
175
220
  useEffect(() => {
176
221
  if (!hasVersions || !activeVersionId)
177
222
  return;
@@ -202,18 +247,43 @@ export function InlineEditor({ url, device, onDeviceChange, title, versionLabel
202
247
  }, [sessionIdForAssets]);
203
248
  const hydratedSessionRef = useRef(null);
204
249
  const sessionIdForHydrate = frameProps?.sessionId;
205
- const bootChanges = hasVersions && activeVersionId && versionChanges?.[activeVersionId]
206
- ? versionChanges[activeVersionId]
207
- : initialChanges;
250
+ const deviceSnapshot = useRef(null);
251
+ const changeDevice = (next) => {
252
+ if (next === device || switchingRef.current)
253
+ return;
254
+ switchingRef.current = true;
255
+ setSwitching(true);
256
+ void enqueue(async () => {
257
+ await changeListRef.current.whenIdle();
258
+ const live = changeListRef.current.peekList();
259
+ deviceSnapshot.current = { version: activeVersionId, changes: live.map(r => r.change) };
260
+ if (activeVersionId)
261
+ versionCallbacksRef.current.onVersionLeave?.(activeVersionId, live);
262
+ onDeviceChange(next);
263
+ })
264
+ .catch(error => setCommandError(describeCanvasError(error)))
265
+ .finally(() => {
266
+ switchingRef.current = false;
267
+ setSwitching(false);
268
+ });
269
+ };
270
+ const bootChanges = deviceSnapshot.current?.version === activeVersionId && deviceSnapshot.current
271
+ ? deviceSnapshot.current.changes
272
+ : hasVersions && activeVersionId && versionChanges?.[activeVersionId]
273
+ ? versionChanges[activeVersionId]
274
+ : initialChanges;
208
275
  useEffect(() => {
209
276
  if (!domReady || !sessionIdForHydrate)
210
277
  return;
211
278
  if (hydratedSessionRef.current === sessionIdForHydrate)
212
279
  return;
213
280
  hydratedSessionRef.current = sessionIdForHydrate;
281
+ deviceSnapshot.current = null;
214
282
  if (!bootChanges?.length)
215
283
  return;
216
284
  const versionId = hasVersions ? activeVersionId : undefined;
285
+ setSwitching(true);
286
+ switchingRef.current = true;
217
287
  void enqueue(async () => {
218
288
  const { outcome, idMap } = await changeListRef.current.replaceAll(bootChanges, prevIdsFor(versionId, bootChanges));
219
289
  if (versionId) {
@@ -222,7 +292,12 @@ export function InlineEditor({ url, device, onDeviceChange, title, versionLabel
222
292
  versionCallbacksRef.current.onVersionEnter?.(versionId, idMap);
223
293
  }
224
294
  onHydrated?.(outcome);
225
- }).catch(e => setCommandError(describeCanvasError(e)));
295
+ })
296
+ .catch(e => setCommandError(describeCanvasError(e)))
297
+ .finally(() => {
298
+ switchingRef.current = false;
299
+ setSwitching(false);
300
+ });
226
301
  }, [domReady, sessionIdForHydrate, bootChanges]);
227
302
  const commandsRef = useRef(commands);
228
303
  commandsRef.current = commands;
@@ -658,9 +733,15 @@ export function InlineEditor({ url, device, onDeviceChange, title, versionLabel
658
733
  identifier: picked.selector,
659
734
  descendants: () => [
660
735
  ...descendantShelfRef.current.map(d => d.selector),
736
+ ...(pageMode ? rootShelfRef.current.map(d => d.selector) : []),
661
737
  ...aiInsertedRef.current
662
738
  ],
663
- ancestors: (selection?.chain ?? []).slice(1).map(n => n.selector)
739
+ ancestors: (selection?.chain ?? []).slice(1).map(n => n.selector),
740
+ ...(pageMode
741
+ ? {
742
+ ancestorsOf: (selector) => rootShelfAncestorsRef.current.get(selector)
743
+ }
744
+ : {})
664
745
  }
665
746
  : {
666
747
  selector: 'body',
@@ -806,11 +887,40 @@ export function InlineEditor({ url, device, onDeviceChange, title, versionLabel
806
887
  return;
807
888
  }
808
889
  if (buttonId === 'replace-image') {
809
- setImageDialog({ open: true, seed: '' });
890
+ void Promise.all([
891
+ getOuterHtml(sessionId, self.selector),
892
+ selection?.capabilities?.hasBackgroundImage && !selection?.capabilities?.isImg
893
+ ? queryElements(sessionId, [self.selector], {
894
+ fields: ['computed:background-image']
895
+ })
896
+ : Promise.resolve(null)
897
+ ])
898
+ .then(([{ html }, facts]) => {
899
+ const node = new DOMParser().parseFromString(html, 'text/html').body
900
+ .firstElementChild;
901
+ const background = facts?.results[0]?.matches[0]?.computed?.['background-image'] ?? '';
902
+ const url = /^url\(["']?(.*?)["']?\)$/.exec(background)?.[1] ?? '';
903
+ setImageDialog({
904
+ open: true,
905
+ seed: facts ? url : (node?.getAttribute('src') ?? '')
906
+ });
907
+ })
908
+ .catch(error => setCommandError(describeCanvasError(error)));
810
909
  return;
811
910
  }
812
911
  if (buttonId === 'replace-video') {
813
- setVideoDialog({ open: true, seed: '' });
912
+ void getOuterHtml(sessionId, self.selector)
913
+ .then(({ html }) => {
914
+ const node = new DOMParser().parseFromString(html, 'text/html').body
915
+ .firstElementChild;
916
+ setVideoDialog({
917
+ open: true,
918
+ seed: node?.getAttribute('src') ??
919
+ node?.querySelector('source')?.getAttribute('src') ??
920
+ ''
921
+ });
922
+ })
923
+ .catch(error => setCommandError(describeCanvasError(error)));
814
924
  return;
815
925
  }
816
926
  if (buttonId === 'insert') {
@@ -818,11 +928,29 @@ export function InlineEditor({ url, device, onDeviceChange, title, versionLabel
818
928
  return;
819
929
  }
820
930
  if (buttonId === 'image-setting') {
821
- setImageSettingDialog({ open: true, seed: '' });
931
+ void getOuterHtml(sessionId, self.selector)
932
+ .then(({ html }) => {
933
+ const node = new DOMParser().parseFromString(html, 'text/html').body
934
+ .firstElementChild;
935
+ setImageSettingDialog({
936
+ open: true,
937
+ seed: node?.getAttribute('alt') ?? ''
938
+ });
939
+ })
940
+ .catch(error => setCommandError(describeCanvasError(error)));
822
941
  return;
823
942
  }
824
943
  if (buttonId === 'click-action') {
825
- setActionDialog({ open: true, seed: null });
944
+ const existing = [...changeListRef.current.peekList()]
945
+ .reverse()
946
+ .find(r => r.change.type === 'changeElementAction' &&
947
+ r.change.selector === self.selector);
948
+ setActionDialog({
949
+ open: true,
950
+ seed: existing?.change.type === 'changeElementAction'
951
+ ? existing.change.action
952
+ : null
953
+ });
826
954
  return;
827
955
  }
828
956
  if (buttonId === 'edit-style') {
@@ -879,13 +1007,21 @@ export function InlineEditor({ url, device, onDeviceChange, title, versionLabel
879
1007
  targetIdentifier: targetSelector,
880
1008
  targetSelector,
881
1009
  mode: moveModeFromDropPos(pos)
882
- })) })) })] }) }), _jsx(ResizableHandle, { withHandle: true })] })), _jsx(ResizablePanel, { id: "canvas", order: 2, minSize: 30, children: _jsxs("div", { className: "flex h-full min-h-0 min-w-0 flex-col", children: [_jsx(CanvasContextBar, { url: url, device: device, onDeviceChange: onDeviceChange }), _jsx("div", { ref: canvasAreaRef, className: "relative min-h-0 flex-1 overflow-hidden p-3", children: frameProps && (_jsx(CanvasFrame, { url: frameProps.url, sessionId: frameProps.sessionId, device: frameProps.device, panelWidth: panelWidth, targetViewportWidth: targetViewportWidth[frameProps.device], onFrameLoad: () => { }, domReady: domReady, iframeRef: canvasIframeRef }, frameProps.sessionId)) })] }) })] }), !capturing && shot && (_jsx(ScreenshotCard, { dataUrl: shot.dataUrl, truncated: shot.truncated, scale: shot.scale, uploadImage: uploadImage, onClose: () => {
1010
+ })) })) })] }) }), _jsx(ResizableHandle, { withHandle: true })] })), _jsx(ResizablePanel, { id: "canvas", order: 2, minSize: 30, children: _jsxs("div", { className: "flex h-full min-h-0 min-w-0 flex-col", children: [_jsx(CanvasContextBar, { url: url, device: device, onDeviceChange: changeDevice }), _jsxs("div", { ref: canvasAreaRef, className: "relative min-h-0 flex-1 overflow-hidden p-3", children: [editor.session.state === 'failed' && (_jsxs("div", { role: "alert", "data-testid": "inline-editor-session-failed", className: "absolute inset-3 z-20 flex flex-col items-center justify-center gap-3 rounded-lg bg-white p-6 text-center text-sm", children: [_jsx("p", { className: "font-semibold", children: t('inlineCanvas.review.pageLoadFailed', '目标页面无法加载') }), _jsx("p", { children: [
1011
+ 'NO_EXTENSION',
1012
+ 'EXTENSION_ID_MISSING',
1013
+ 'CONTRACT_MISMATCH'
1014
+ ].includes(editor.session.reason ?? '')
1015
+ ? describeCanvasError(editor.session.reason)
1016
+ : t('inlineCanvas.review.pageLoadHint', '请返回检查页面地址、网站访问权限和网络连接后重试。') }), _jsx("code", { className: "text-xs text-muted-foreground", children: editor.session.reason }), _jsx(Button, { variant: "secondary", onClick: onExit, children: t('inlineCanvas.review.backFromFailed', '返回修改') })] })), frameProps && (_jsx(CanvasFrame, { url: frameProps.url, sessionId: frameProps.sessionId, device: frameProps.device, panelWidth: panelWidth, targetViewportWidth: targetViewportWidth[frameProps.device], onFrameLoad: () => { }, domReady: domReady, iframeRef: canvasIframeRef }, frameProps.sessionId))] })] }) })] }), !capturing && shot && (_jsx(ScreenshotCard, { dataUrl: shot.dataUrl, truncated: shot.truncated, scale: shot.scale, uploadImage: uploadImage, onClose: () => {
883
1017
  setShot(null);
884
- } }, shot.dataUrl)), _jsx(ChangeListPopover, { open: showChangeList && !capturing, onClose: () => setShowChangeList(false), children: _jsx(ChangeListPanel, { list: changeList.list, onRevert: id => uiWrite(() => writes.revert(id)), onRevertAll: () => uiWrite(() => writes.revertAll()), lastError: changeList.lastError, onHover: s => void commands.hover(s), onSelect: s => void selectAndReveal(s) }) })] }), !capturing && (_jsx(SelectionOverlay, { geometry: geometry.latest, context: overlayContext, selectedLabel: self ? `<${self.tag}>` : undefined })), !capturing && blockOverlay && (_jsx(BlockOverlay, { blocks: blockOverlay.blocks, activeId: blockOverlay.activeId, onHover: blockOverlay.onHover, geometry: geometry.latest, context: overlayContext, scale: editor.mobileFit.scale })), !capturing && commandError && (_jsxs(EditorStatus, { tone: "error", "data-testid": "inline-editor-command-error", style: { left: `${area.x + area.width / 2}px`, top: `${area.y + 8}px` }, children: [_jsx("span", { children: commandError }), _jsx(Button, { size: "sm", variant: "secondary", onClick: () => setCommandError(null), children: t('inlineCanvas.chrome.close', '关闭') })] })), !capturing && aiApplyNote && (_jsxs(EditorStatus, { "data-testid": "inline-editor-ai-apply-note", style: { left: `${area.x + area.width / 2}px`, top: `${area.y + 8}px` }, children: [_jsx("span", { role: "status", children: aiApplyNote }), _jsx(Button, { size: "sm", variant: "secondary", onClick: () => setAiApplyNote(null), children: t('inlineCanvas.chrome.close', '关闭') })] })), !capturing && move && (_jsxs(EditorStatus, { "data-testid": "inline-editor-move-bar", style: { left: `${area.x + area.width / 2}px`, top: `${area.y + 8}px` }, children: [move.target === null ? (_jsx(_Fragment, { children: _jsx("span", { children: move.rejected === 'same-element'
1018
+ } }, shot.dataUrl)), _jsx(ChangeListPopover, { open: showChangeList && !capturing, onClose: () => setShowChangeList(false), children: _jsx(ChangeListPanel, { list: changeList.list, onRevert: id => uiWrite(() => writes.revert(id)), onRevertAll: () => uiWrite(() => writes.revertAll()), lastError: changeList.lastError, onHover: s => void commands.hover(s), onSelect: s => void selectAndReveal(s) }) })] }), !capturing && (_jsx(SelectionOverlay, { geometry: geometry.latest, context: overlayContext, selectedLabel: self ? `<${self.tag}>` : undefined })), !capturing && blockOverlay && (_jsx(BlockOverlay, { blocks: blockOverlay.blocks, activeId: blockOverlay.activeId, onHover: blockOverlay.onHover, geometry: geometry.latest, context: overlayContext, scale: editor.mobileFit.scale })), !capturing && commandError && (_jsxs(EditorStatus, { tone: "error", "data-testid": "inline-editor-command-error", style: { left: `${area.x + area.width / 2}px`, top: `${area.y + 8}px` }, children: [_jsx("span", { children: commandError }), _jsx(Button, { size: "sm", variant: "secondary", onClick: () => setCommandError(null), children: t('inlineCanvas.chrome.close', '关闭') })] })), !capturing && aiApplyNote && (_jsxs(EditorStatus, { "data-testid": "inline-editor-ai-apply-note", style: { left: `${area.x + area.width / 2}px`, top: `${area.y + 8}px` }, children: [_jsx("span", { role: "status", children: aiApplyNote }), _jsx(Button, { size: "sm", variant: "secondary", onClick: () => setAiApplyNote(null), children: t('inlineCanvas.chrome.close', '关闭') })] })), !capturing && move && (_jsx("div", { role: "toolbar", "aria-label": t('inlineCanvas.move.title'), "data-testid": "inline-editor-move-bar", className: "fixed z-[903] -translate-x-1/2 rounded-lg border bg-popover p-3 text-xs text-popover-foreground shadow-md", style: {
1019
+ width: 'min(600px, calc(100vw - 32px))',
1020
+ left: `clamp(min(316px, 50vw), ${area.x + area.width / 2}px, max(calc(100vw - 316px), 50vw))`,
1021
+ top: `${area.y + 8}px`
1022
+ }, children: move.target === null ? (_jsxs("div", { className: "flex items-center justify-between gap-3", children: [_jsx("span", { children: move.rejected === 'same-element'
885
1023
  ? '不能移到它自己里面,请另选一个目标'
886
- : t('inlineCanvas.move.pickTarget') }) })) : (_jsxs(_Fragment, { children: [_jsx("span", { children: t('inlineCanvas.move.position') }), ['top', 'bottom', 'before', 'after'].map(pos => (_jsx(Button, { size: "sm", variant: "secondary", type: "button", "data-testid": `move-position-${pos}`, onClick: () => void commands
887
- .confirmMove(pos)
888
- .catch(e => setCommandError(describeCanvasError(e))), children: t(`inlineCanvas.move.${pos}`) }, pos)))] })), _jsx(Button, { size: "sm", variant: "secondary", type: "button", "data-testid": "move-cancel", onClick: () => commands.cancelMove(), children: t('inlineCanvas.move.cancel') })] })), !capturing && textEditing !== null && (_jsx(EditorStatus, { "data-testid": "inline-editor-text-editing-hint", style: {
1024
+ : t('inlineCanvas.move.pickTarget') }), _jsx(Button, { size: "default", variant: "secondary", type: "button", "data-testid": "move-cancel", onClick: () => commands.cancelMove(), children: t('inlineCanvas.move.cancel') })] })) : (_jsx(MovePositionControls, { onConfirm: commands.confirmMove, onCancel: commands.cancelMove, onError: e => setCommandError(describeCanvasError(e)) }, move.target)) })), !capturing && textEditing !== null && (_jsx(EditorStatus, { "data-testid": "inline-editor-text-editing-hint", style: {
889
1025
  left: `${area.x + area.width / 2}px`,
890
1026
  top: `${area.y + 8}px`
891
1027
  }, children: "\u6B63\u5728\u7F16\u8F91\u6587\u5B57 \u00B7 \u56DE\u8F66\u63D0\u4EA4 \u00B7 Esc \u53D6\u6D88" })), !capturing && !switching && !move && textEditing === null && (_jsx(SelectionToolbar, { selectedRect: selectedRect, area: area, capabilities: selection?.capabilities ?? null, isScrolling: geometry.isScrolling, onAction: onToolbarAction, host: { askAi: !!onAskAi } })), stylePanelOpen &&
@@ -895,7 +1031,15 @@ export function InlineEditor({ url, device, onDeviceChange, title, versionLabel
895
1031
  createPortal(_jsx("div", { className: `${CANVAS_UI_SCOPE} fixed z-[1000]`, children: _jsx(StylePanel, { anchor: {
896
1032
  x: Math.min(selectedRect.x + selectedRect.width + 12, area.x + area.width - 332),
897
1033
  y: Math.max(area.y + 8, selectedRect.y)
898
- }, tag: self.tag, capabilities: selection?.capabilities ?? null, declaredStyles: selection?.declaredStyles, onReset: property => {
1034
+ }, tag: self.tag, capabilities: selection?.capabilities ?? null, resettableProperties: changeList.list.flatMap(r => r.change.type === 'elementStyle' &&
1035
+ r.change.selector === self.selector
1036
+ ? [r.change.property]
1037
+ : []), declaredStyles: Object.assign({}, currentStyles, ...changeList.list
1038
+ .filter(r => r.change.type === 'elementStyle' &&
1039
+ r.change.selector === self.selector)
1040
+ .map(r => r.change.type === 'elementStyle'
1041
+ ? { [r.change.property]: r.change.value }
1042
+ : {})), onReset: property => {
899
1043
  void uiWrite(async () => {
900
1044
  for (const record of changeList
901
1045
  .peekList()
@@ -3,7 +3,7 @@ import { CodeField } from './CodeField.js';
3
3
  import { useEffect, useMemo, useState } from 'react';
4
4
  import { useTranslation } from 'react-i18next';
5
5
  import { AlertTriangle } from 'lucide-react';
6
- import { Button, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@ptengine/design-components';
6
+ import { Button, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, ToggleGroup, ToggleGroupItem } from '@ptengine/design-components';
7
7
  import { pasteModes } from '../kernel/selection/toolbarButtons.js';
8
8
  import { validateHtml } from './htmlValidation.js';
9
9
  export function InsertElementDialog({ open, capabilities, onOpenChange, onApply }) {
@@ -21,7 +21,11 @@ export function InsertElementDialog({ open, capabilities, onOpenChange, onApply
21
21
  }, [open, modeKey]);
22
22
  const result = useMemo(() => validateHtml(html), [html]);
23
23
  const canApply = html.trim().length > 0 && result.ok && modes.length > 0;
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("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), size: "sm", variant: mode === m ? 'secondary' : 'ghost', 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: () => {
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
31
  }, children: t('common.apply', '应用') })] })] }) }));
@@ -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
  *
@@ -82,6 +82,7 @@ export interface UseChangeListResult {
82
82
  outcome: HydrateOutcome;
83
83
  idMap: Map<string, string>;
84
84
  }>;
85
+ whenIdle: () => Promise<unknown>;
85
86
  canUndo: boolean;
86
87
  canRedo: boolean;
87
88
  undo: () => Promise<void>;
@@ -249,6 +249,7 @@ export function useChangeList(sessionId, options = {}) {
249
249
  clear,
250
250
  hydrate,
251
251
  replaceAll,
252
+ whenIdle: () => mutationQueue.current,
252
253
  peekList,
253
254
  canUndo: past.current.length > 0,
254
255
  canRedo: future.current.length > 0,
@@ -16,6 +16,13 @@
16
16
  * · 装着 ≥2 个候选区块 = 外壳,拆开;只装着 1 个 = 它自己就是这一块(外层赢,小圆点、
17
17
  * 进度条这些附属部件都在最外层里),但占页高 ≥60% 的单链节点是整页的框,让给内层;
18
18
  * · 宿主给了页面模型(`blocks`)时,成员归属校正拆合,并在块根行写 `blockId`。
19
+ *
20
+ * **矮区块**(2026-10-21):几何门槛(高 ≥120)挡掉了真正的矮区块 —— vivaia 手机首页的新闻区只有 86px,
21
+ * 父级是外壳,它既不是块根也不在块根里,只能等回填,100 行用完时它的标题还没轮到,「把新闻区标题改成…」
22
+ * 两次被拒、0 处落地。门槛本身不降(降了,section 里的标题条也成候选,section 被拆成外壳)。补两条:
23
+ * · 页面模型的成员容器只要够宽就是候选,不看高度(热图说它是一块,它就是一块);
24
+ * · 块根的父级就是区块上下叠放的那一层:那里不够高、但够宽、有内容、不在 header/nav/footer 里的孩子,
25
+ * 也算一块(没有页面模型时的兜底)。
19
26
  * 旧规则「内层有 heading 就赢」不可用:kit 的 `heading` 是**子树里**第一个标题(没有就取第一段
20
27
  * 文字),section / 轮播轨道 / 每张幻灯片拿到的是同一句,规则又偏向内层 —— 真机 vivaia 首页的
21
28
  * 首屏只剩三张幻灯片,模型照「首屏下面」插进了轮播(2026-10-20)。
@@ -19,6 +19,8 @@ const SAME_TOP_PX = 2;
19
19
  const CHROME_ROWS_CAP = 5;
20
20
  const CHROME_TAGS = new Set(['header', 'nav', 'footer']);
21
21
  const TEXT_LEAF_TAGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'a', 'button', 'span']);
22
+ const isTextLeaf = (n) => n.tag === 'img' || (TEXT_LEAF_TAGS.has(n.tag) && n.textSummary.trim().length > 0);
23
+ const hasContent = (n) => isTextLeaf(n) || !!n.image || n.textSummary.trim().length > 0 || n.children.some(c => !c.hidden && hasContent(c));
22
24
  function analyzeBlockRoots(root, opts) {
23
25
  const viewportWidth = typeof opts.viewportWidth === 'number' && opts.viewportWidth > 0
24
26
  ? opts.viewportWidth
@@ -76,13 +78,23 @@ function analyzeBlockRoots(root, opts) {
76
78
  for (const e of entries)
77
79
  e.demoted = false;
78
80
  }
79
- const isCandidate = (e) => e.landmark && !e.demoted;
80
81
  const entryBySelector = new Map();
81
82
  for (const e of entries)
82
83
  if (!entryBySelector.has(e.node.selector))
83
84
  entryBySelector.set(e.node.selector, e);
85
+ const memberBoxes = new Set();
86
+ for (const b of opts.blocks ?? []) {
87
+ for (const member of b.members) {
88
+ const e = entryBySelector.get(member.selector);
89
+ const r = e?.node.rect;
90
+ if (e && r && r.height > 0 && r.width >= minWidth)
91
+ memberBoxes.add(e);
92
+ }
93
+ }
94
+ const isCandidate = (e) => (e.landmark || memberBoxes.has(e)) && !e.demoted;
84
95
  const claimed = new Map();
85
96
  const blocksIn = new Map();
97
+ const memberEntries = new Set();
86
98
  const addTo = (m, e, id) => {
87
99
  let set = m.get(e);
88
100
  if (!set)
@@ -91,14 +103,17 @@ function analyzeBlockRoots(root, opts) {
91
103
  };
92
104
  for (const b of opts.blocks ?? []) {
93
105
  for (const member of b.members) {
94
- let e = entryBySelector.get(member.selector) ?? null;
95
- while (e && !isCandidate(e))
96
- e = e.parent;
97
- if (!e)
106
+ const own = entryBySelector.get(member.selector);
107
+ if (!own)
98
108
  continue;
99
- addTo(claimed, e, b.id);
100
- for (let a = e; a; a = a.parent)
109
+ memberEntries.add(own);
110
+ for (let a = own; a; a = a.parent)
101
111
  addTo(blocksIn, a, b.id);
112
+ let e = own;
113
+ while (e && !isCandidate(e))
114
+ e = e.parent;
115
+ if (e)
116
+ addTo(claimed, e, b.id);
102
117
  }
103
118
  }
104
119
  const modelBlockCount = (e) => blocksIn.get(e)?.size ?? 0;
@@ -126,7 +141,7 @@ function analyzeBlockRoots(root, opts) {
126
141
  for (const e of [...candidates].reverse()) {
127
142
  const ks = kids.get(e) ?? [];
128
143
  let unit;
129
- if (isAtomic(e))
144
+ if (isAtomic(e) && (memberEntries.has(e) || ks.length < 2))
130
145
  unit = true;
131
146
  else if (modelBlockCount(e) >= 2)
132
147
  unit = ks.length === 0;
@@ -148,6 +163,25 @@ function analyzeBlockRoots(root, opts) {
148
163
  }
149
164
  };
150
165
  takeRoots(tops);
166
+ const rootSet = new Set(blockRoots);
167
+ const holdsRoot = new Set();
168
+ for (const r of blockRoots)
169
+ for (let a = r.parent; a; a = a.parent)
170
+ holdsRoot.add(a);
171
+ const layers = new Set(blockRoots.map(r => r.parent?.node ?? root));
172
+ for (const layer of layers) {
173
+ if (laysOutInRow(layer))
174
+ continue;
175
+ for (const child of layer.children) {
176
+ const e = entryOf.get(child);
177
+ if (!e || rootSet.has(e) || holdsRoot.has(e) || e.landmark || e.demoted || e.chromeKey !== null)
178
+ continue;
179
+ const r = child.rect;
180
+ if (r && r.height > 0 && r.width >= minWidth && hasContent(child))
181
+ blockRoots.push(e);
182
+ }
183
+ }
184
+ blockRoots.sort((a, b) => a.order - b.order);
151
185
  const blockIdOf = (e) => {
152
186
  const ids = blocksIn.get(e);
153
187
  return ids && ids.size === 1 ? [...ids][0] : undefined;
@@ -226,7 +260,6 @@ export function buildRootShelf(root, opts) {
226
260
  chromeRows.set(e.chromeKey, (chromeRows.get(e.chromeKey) ?? 0) + 1);
227
261
  return true;
228
262
  };
229
- const isTextLeaf = (n) => n.tag === 'img' || (TEXT_LEAF_TAGS.has(n.tag) && n.textSummary.trim().length > 0);
230
263
  const leavesPerBlock = landmarks.length
231
264
  ? Math.min(LEAVES_PER_LANDMARK, Math.max(1, Math.floor((limit - landmarks.length) / landmarks.length)))
232
265
  : LEAVES_PER_LANDMARK;
package/dist/locales.d.ts CHANGED
@@ -54,6 +54,7 @@ export declare const zhCN: {
54
54
  title: string;
55
55
  pickTarget: string;
56
56
  cancel: string;
57
+ confirm: string;
57
58
  position: string;
58
59
  top: string;
59
60
  bottom: string;
@@ -234,6 +235,7 @@ export declare const enUS: {
234
235
  title: string;
235
236
  pickTarget: string;
236
237
  cancel: string;
238
+ confirm: string;
237
239
  position: string;
238
240
  top: string;
239
241
  bottom: string;
@@ -414,6 +416,7 @@ export declare const jaJP: {
414
416
  title: string;
415
417
  pickTarget: string;
416
418
  cancel: string;
419
+ confirm: string;
417
420
  position: string;
418
421
  top: string;
419
422
  bottom: string;
@@ -553,6 +556,9 @@ export declare const inlineEditorLocales: {
553
556
  readonly 'zh-CN': {
554
557
  readonly inlineCanvas: {
555
558
  readonly review: {
559
+ readonly pageLoadFailed: "目标页面无法加载";
560
+ readonly pageLoadHint: "请返回检查页面地址、网站访问权限和网络连接后重试。";
561
+ readonly backFromFailed: "返回修改";
556
562
  readonly generating: "正在生成截图…";
557
563
  readonly noLayers: "没有匹配的图层";
558
564
  readonly clearSearch: "清除搜索";
@@ -641,6 +647,7 @@ export declare const inlineEditorLocales: {
641
647
  title: string;
642
648
  pickTarget: string;
643
649
  cancel: string;
650
+ confirm: string;
644
651
  position: string;
645
652
  top: string;
646
653
  bottom: string;
@@ -779,6 +786,9 @@ export declare const inlineEditorLocales: {
779
786
  readonly 'en-US': {
780
787
  readonly inlineCanvas: {
781
788
  readonly review: {
789
+ readonly pageLoadFailed: "Unable to load the target page";
790
+ readonly pageLoadHint: "Go back to check the page URL, site permissions and network connection, then retry.";
791
+ readonly backFromFailed: "Go back";
782
792
  readonly generating: "Generating screenshot…";
783
793
  readonly noLayers: "No matching layers";
784
794
  readonly clearSearch: "Clear search";
@@ -867,6 +877,7 @@ export declare const inlineEditorLocales: {
867
877
  title: string;
868
878
  pickTarget: string;
869
879
  cancel: string;
880
+ confirm: string;
870
881
  position: string;
871
882
  top: string;
872
883
  bottom: string;
@@ -1005,6 +1016,9 @@ export declare const inlineEditorLocales: {
1005
1016
  readonly 'ja-JP': {
1006
1017
  readonly inlineCanvas: {
1007
1018
  readonly review: {
1019
+ readonly pageLoadFailed: "対象ページを読み込めません";
1020
+ readonly pageLoadHint: "戻ってページ URL、サイトへのアクセス権限、ネットワーク接続を確認して再試行してください。";
1021
+ readonly backFromFailed: "戻って修正";
1008
1022
  readonly generating: "スクリーンショットを生成中…";
1009
1023
  readonly noLayers: "一致するレイヤーがありません";
1010
1024
  readonly clearSearch: "検索をクリア";
@@ -1093,6 +1107,7 @@ export declare const inlineEditorLocales: {
1093
1107
  title: string;
1094
1108
  pickTarget: string;
1095
1109
  cancel: string;
1110
+ confirm: string;
1096
1111
  position: string;
1097
1112
  top: string;
1098
1113
  bottom: string;
package/dist/locales.js CHANGED
@@ -44,6 +44,7 @@ export const zhCN = {
44
44
  title: '移动元素',
45
45
  pickTarget: '在画布里点选目标元素',
46
46
  cancel: '取消移动',
47
+ confirm: '确认移动',
47
48
  position: '相对目标的位置',
48
49
  top: '移入开头',
49
50
  bottom: '移入末尾',
@@ -224,6 +225,7 @@ export const enUS = {
224
225
  title: 'Move element',
225
226
  pickTarget: 'Pick a target element on the canvas',
226
227
  cancel: 'Cancel move',
228
+ confirm: 'Confirm move',
227
229
  position: 'Position relative to target',
228
230
  top: 'Inside, first',
229
231
  bottom: 'Inside, last',
@@ -404,6 +406,7 @@ export const jaJP = {
404
406
  title: '要素を移動',
405
407
  pickTarget: 'キャンバスで対象要素を選択',
406
408
  cancel: '移動をキャンセル',
409
+ confirm: '移動を確定',
407
410
  position: '対象に対する位置',
408
411
  top: '内側の先頭',
409
412
  bottom: '内側の末尾',
@@ -0,0 +1,6 @@
1
+ import type { MovePosition } from '../kernel/moveState.js';
2
+ export declare function MovePositionControls({ onConfirm, onCancel, onError }: {
3
+ onConfirm: (position: MovePosition) => Promise<void>;
4
+ onCancel: () => void;
5
+ onError: (error: unknown) => void;
6
+ }): import("react").JSX.Element;
@@ -0,0 +1,35 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useRef, useState } from 'react';
3
+ import { useTranslation } from 'react-i18next';
4
+ import { Button, ToggleGroup, ToggleGroupItem } from '@ptengine/design-components';
5
+ const positions = ['top', 'bottom', 'before', 'after'];
6
+ export function MovePositionControls({ onConfirm, onCancel, onError }) {
7
+ const { t } = useTranslation();
8
+ const [position, setPosition] = useState(null);
9
+ const [pending, setPending] = useState(false);
10
+ const submitting = useRef(false);
11
+ const confirm = async () => {
12
+ if (!position || submitting.current)
13
+ return;
14
+ submitting.current = true;
15
+ setPending(true);
16
+ try {
17
+ await onConfirm(position);
18
+ }
19
+ catch (error) {
20
+ onError(error);
21
+ }
22
+ finally {
23
+ submitting.current = false;
24
+ setPending(false);
25
+ }
26
+ };
27
+ return (_jsxs("div", { className: "flex w-full min-w-0 flex-col gap-2.5", children: [_jsx("span", { className: "text-xs text-muted-foreground", children: t('inlineCanvas.move.position') }), _jsxs("div", { className: "flex flex-col items-stretch gap-3", children: [_jsx(ToggleGroup, { type: "single", variant: "outline", value: position ?? '', disabled: pending, "aria-label": t('inlineCanvas.move.position'), onValueChange: value => {
28
+ if (positions.includes(value)) {
29
+ setPosition(value);
30
+ }
31
+ }, style: {
32
+ display: 'grid',
33
+ gridTemplateColumns: 'repeat(4, minmax(0, 1fr))'
34
+ }, children: positions.map(pos => (_jsx(ToggleGroupItem, { value: pos, "data-testid": `move-position-${pos}`, children: t(`inlineCanvas.move.${pos}`) }, pos))) }), _jsxs("div", { className: "flex items-center justify-end gap-2", children: [_jsx(Button, { size: "default", variant: "secondary", type: "button", "data-testid": "move-cancel", disabled: pending, onClick: onCancel, children: t('inlineCanvas.move.cancel') }), _jsx(Button, { size: "default", type: "button", "data-testid": "move-confirm", disabled: !position || pending, onClick: () => void confirm(), children: t('inlineCanvas.move.confirm') })] })] })] }));
35
+ }
@@ -22,9 +22,11 @@ export interface StylePanelProps {
22
22
  capabilities: ElementCapabilities | null;
23
23
  /** 元素当前的 author 层声明,用作输入框初值。 */
24
24
  declaredStyles?: Record<string, string>;
25
+ /** Only these properties have an editor override that can be cleared. */
26
+ resettableProperties?: string[];
25
27
  /** 提交一条样式改动。由宿主接到 `changeList.addStyle`。 */
26
28
  onApply: (property: string, value: string) => void | Promise<unknown>;
27
29
  onClose: () => void;
28
30
  onReset?: (property: string) => void;
29
31
  }
30
- export declare function StylePanel({ anchor, tag, capabilities, declaredStyles, onApply, onReset, onClose }: StylePanelProps): import("react").JSX.Element | null;
32
+ export declare function StylePanel({ anchor, tag, capabilities, declaredStyles, resettableProperties, onApply, onReset, onClose }: StylePanelProps): import("react").JSX.Element | null;
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
4
4
  import { X } from 'lucide-react';
5
5
  import { Button, InputGroup, InputGroupInput, InputGroupAddon, InputGroupText, Label, NativeSelect, Separator } from '@ptengine/design-components';
6
6
  import { composeStyleValue, visibleSections } from './styleFields.js';
7
- export function StylePanel({ anchor, tag, capabilities, declaredStyles = {}, onApply, onReset, onClose }) {
7
+ export function StylePanel({ anchor, tag, capabilities, declaredStyles = {}, resettableProperties, onApply, onReset, onClose }) {
8
8
  const { t } = useTranslation();
9
9
  const sections = visibleSections(capabilities);
10
10
  const panelRef = useRef(null);
@@ -88,7 +88,10 @@ export function StylePanel({ anchor, tag, capabilities, declaredStyles = {}, onA
88
88
  drag.current = null;
89
89
  }, onLostPointerCapture: () => {
90
90
  drag.current = null;
91
- }, className: "flex cursor-grab touch-none select-none h-11 shrink-0 items-center justify-between border-b\n border-[#EBECF0] bg-[#F4F5F7] px-3.5", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsxs("code", { className: "text-xs text-[#6B778C]", children: ["<", tag, ">"] }), _jsx("span", { className: "text-[13px] font-semibold text-[#091E42]", children: t(`taginfo.${tag.toUpperCase()}`, tag) })] }), _jsx(Button, { variant: "ghost", size: "icon", "aria-label": t('inlineCanvas.chrome.close', '关闭'), "data-testid": "inline-style-panel-close", onClick: onClose, children: _jsx(X, { className: "size-4" }) })] }), _jsx("div", { className: "min-h-0 flex-1 flex-col overflow-y-auto", children: sections.map((section, index) => (_jsxs("section", { "data-testid": `style-section-${section.id}`, className: "flex flex-col gap-3 px-3.5 py-3.5", children: [index > 0 && _jsx(Separator, { className: "-mt-3.5 mb-0.5" }), _jsx("p", { className: "text-xs font-semibold text-[#091E42]", children: t(section.labelKey) }), _jsx("div", { className: "grid grid-cols-2 gap-2.5", children: section.fields.map(field => (_jsx(StyleFieldInput, { field: field, initial: declaredStyles[field.property] ?? '', onApply: onApply, onReset: onReset }, field.property))) })] }, section.id))) }), _jsx("footer", { className: "shrink-0 bg-[#F7F8F9] px-3.5 py-3 text-xs leading-5 text-[#6B778C]", children: t('inlineCanvas.style.footnote', '每次改动都会记入改动清单,可单条撤销') })] }));
91
+ }, className: "flex cursor-grab touch-none select-none h-11 shrink-0 items-center justify-between border-b\n border-[#EBECF0] bg-[#F4F5F7] px-3.5", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsxs("code", { className: "text-xs text-[#6B778C]", children: ["<", tag, ">"] }), _jsx("span", { className: "text-[13px] font-semibold text-[#091E42]", children: t(`taginfo.${tag.toUpperCase()}`, tag) })] }), _jsx(Button, { variant: "ghost", size: "icon", "aria-label": t('inlineCanvas.chrome.close', '关闭'), "data-testid": "inline-style-panel-close", onClick: onClose, children: _jsx(X, { className: "size-4" }) })] }), _jsx("div", { className: "min-h-0 flex-1 flex-col overflow-y-auto", children: sections.map((section, index) => (_jsxs("section", { "data-testid": `style-section-${section.id}`, className: "flex flex-col gap-3 px-3.5 py-3.5", children: [index > 0 && _jsx(Separator, { className: "-mt-3.5 mb-0.5" }), _jsx("p", { className: "text-xs font-semibold text-[#091E42]", children: t(section.labelKey) }), _jsx("div", { className: "grid grid-cols-2 gap-2.5", children: section.fields.map(field => (_jsx(StyleFieldInput, { field: field, initial: declaredStyles[field.property] ?? '', onApply: onApply, onReset: resettableProperties === undefined ||
92
+ resettableProperties.includes(field.property)
93
+ ? onReset
94
+ : undefined }, field.property))) })] }, section.id))) }), _jsx("footer", { className: "shrink-0 bg-[#F7F8F9] px-3.5 py-3 text-xs leading-5 text-[#6B778C]", children: t('inlineCanvas.style.footnote', '每次改动都会记入改动清单,可单条撤销') })] }));
92
95
  }
93
96
  function StyleFieldInput({ field, initial, onApply, onReset }) {
94
97
  const { t } = useTranslation();
@@ -1,5 +1,8 @@
1
1
  export declare const reviewLocales: {
2
2
  readonly 'zh-CN': {
3
+ readonly pageLoadFailed: "目标页面无法加载";
4
+ readonly pageLoadHint: "请返回检查页面地址、网站访问权限和网络连接后重试。";
5
+ readonly backFromFailed: "返回修改";
3
6
  readonly generating: "正在生成截图…";
4
7
  readonly noLayers: "没有匹配的图层";
5
8
  readonly clearSearch: "清除搜索";
@@ -46,6 +49,9 @@ export declare const reviewLocales: {
46
49
  readonly align_justify: "两端对齐";
47
50
  };
48
51
  readonly 'en-US': {
52
+ readonly pageLoadFailed: "Unable to load the target page";
53
+ readonly pageLoadHint: "Go back to check the page URL, site permissions and network connection, then retry.";
54
+ readonly backFromFailed: "Go back";
49
55
  readonly generating: "Generating screenshot…";
50
56
  readonly noLayers: "No matching layers";
51
57
  readonly clearSearch: "Clear search";
@@ -92,6 +98,9 @@ export declare const reviewLocales: {
92
98
  readonly align_justify: "Justify";
93
99
  };
94
100
  readonly 'ja-JP': {
101
+ readonly pageLoadFailed: "対象ページを読み込めません";
102
+ readonly pageLoadHint: "戻ってページ URL、サイトへのアクセス権限、ネットワーク接続を確認して再試行してください。";
103
+ readonly backFromFailed: "戻って修正";
95
104
  readonly generating: "スクリーンショットを生成中…";
96
105
  readonly noLayers: "一致するレイヤーがありません";
97
106
  readonly clearSearch: "検索をクリア";
@@ -1,5 +1,8 @@
1
1
  export const reviewLocales = {
2
2
  'zh-CN': {
3
+ pageLoadFailed: '目标页面无法加载',
4
+ pageLoadHint: '请返回检查页面地址、网站访问权限和网络连接后重试。',
5
+ backFromFailed: '返回修改',
3
6
  generating: '正在生成截图…',
4
7
  noLayers: '没有匹配的图层', clearSearch: '清除搜索', searchLayers: '搜索图层',
5
8
  linkCopied: '链接已复制',
@@ -44,6 +47,9 @@ export const reviewLocales = {
44
47
  align_justify: '两端对齐'
45
48
  },
46
49
  'en-US': {
50
+ pageLoadFailed: 'Unable to load the target page',
51
+ pageLoadHint: 'Go back to check the page URL, site permissions and network connection, then retry.',
52
+ backFromFailed: 'Go back',
47
53
  generating: 'Generating screenshot…',
48
54
  noLayers: 'No matching layers', clearSearch: 'Clear search', searchLayers: 'Search layers',
49
55
  linkCopied: 'Link copied',
@@ -88,6 +94,9 @@ export const reviewLocales = {
88
94
  align_justify: 'Justify'
89
95
  },
90
96
  'ja-JP': {
97
+ pageLoadFailed: '対象ページを読み込めません',
98
+ pageLoadHint: '戻ってページ URL、サイトへのアクセス権限、ネットワーク接続を確認して再試行してください。',
99
+ backFromFailed: '戻って修正',
91
100
  generating: 'スクリーンショットを生成中…',
92
101
  noLayers: '一致するレイヤーがありません', clearSearch: '検索をクリア', searchLayers: 'レイヤーを検索',
93
102
  linkCopied: 'リンクをコピーしました',
@@ -42,6 +42,7 @@ export interface InlineEditorSelection {
42
42
  declaredStyles: Record<string, string>;
43
43
  }
44
44
  export interface UseInlineEditorInput {
45
+ onHistoryShortcut?: (direction: 'undo' | 'redo') => void;
45
46
  url: string;
46
47
  device: CanvasDevice;
47
48
  /** change list 变化时回调 —— 宿主自己决定存哪(本 hook 不做持久化)。 */
@@ -1,4 +1,5 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { CANVAS_MSG_NAMESPACE } from '@ptengine/inline-core/protocol';
2
3
  import { useCanvasSession } from './kernel/useCanvasSession.js';
3
4
  import { useCanvasStream } from './kernel/useCanvasStream.js';
4
5
  import { useMobileFit } from './kernel/useMobileFit.js';
@@ -49,10 +50,21 @@ export function useInlineEditor(input) {
49
50
  documentSizeRef.current?.(payload);
50
51
  }, []);
51
52
  const stream = useCanvasStream(undefined, geometry.onGeometry, onTextEditCommit, onLifecycle, onDocumentSize);
53
+ const historyShortcutRef = useRef(input.onHistoryShortcut);
54
+ historyShortcutRef.current = input.onHistoryShortcut;
55
+ const onStreamEvent = useCallback((msg) => {
56
+ if (msg.namespace === CANVAS_MSG_NAMESPACE && msg.event === 'historyShortcut') {
57
+ const direction = msg.payload?.direction;
58
+ if (direction === 'undo' || direction === 'redo')
59
+ historyShortcutRef.current?.(direction);
60
+ }
61
+ else
62
+ stream.onStreamEvent(msg);
63
+ }, [stream.onStreamEvent]);
52
64
  const session = useCanvasSession({
53
65
  url: input.url,
54
66
  device: input.device,
55
- onStreamEvent: stream.onStreamEvent
67
+ onStreamEvent
56
68
  });
57
69
  const sessionId = session.frameProps?.sessionId;
58
70
  const changeList = useChangeList(sessionId, {
@@ -147,6 +159,33 @@ export function useInlineEditor(input) {
147
159
  ? graftSubtree(prev, anchorId, root, { renderIcon: renderIconRef.current })
148
160
  : prev);
149
161
  }, [sessionId]);
162
+ const treeLoaded = tree !== null;
163
+ const contentRevision = changeList.list
164
+ .filter(record => ['elementContent', 'elementText', 'elementMove'].includes(record.change.type))
165
+ .map(record => record.changeId)
166
+ .join('|');
167
+ const refreshedContent = useRef('');
168
+ useEffect(() => {
169
+ if (!wantTree ||
170
+ !domReady ||
171
+ !sessionId ||
172
+ !treeRef.current ||
173
+ refreshedContent.current === contentRevision)
174
+ return;
175
+ let cancelled = false;
176
+ const loaded = Object.values(treeRef.current.nodes);
177
+ void getDomTree(sessionId, { maxDepth: 16, maxNodes: Math.max(1500, loaded.length) })
178
+ .then(root => {
179
+ if (!cancelled) {
180
+ refreshedContent.current = contentRevision;
181
+ setTree(adaptDomTree(root, { renderIcon: renderIconRef.current }));
182
+ }
183
+ })
184
+ .catch(() => { });
185
+ return () => {
186
+ cancelled = true;
187
+ };
188
+ }, [contentRevision, domReady, sessionId, wantTree, treeLoaded]);
150
189
  const revealAttemptedRef = useRef(new Set());
151
190
  const selectedSelector = selection?.selector ?? null;
152
191
  const selectionChain = selection?.chain;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ptengine/inline-editor",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "Ptengine Inline 编辑器:画布会话编排、图层树、属性面板与组件库(含内部 canvas-ui)",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -34,7 +34,7 @@
34
34
  },
35
35
  "dependencies": {
36
36
  "lucide-react": "^0.577.0",
37
- "@ptengine/inline-core": "~0.2.0"
37
+ "@ptengine/inline-core": "~0.2.1"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@ptengine/design-components": "^0.10.0",