@ptengine/inline-editor 0.1.7 → 0.1.8

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,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.8
4
+
5
+ 依赖不变(`@ptengine/inline-core` ~0.2.1),可以直接发。
6
+
7
+ **新增**
8
+
9
+ - 交互模式(对应 engage-editor 画布工具条上的「交互模式」):画布条右侧新增开关,进入后关掉拾取,点击、悬停、输入都交给客户页,
10
+ 用来打开弹窗、切换页签、展开折叠,再改那些交互之后才看得见的内容。
11
+ - 进入时清掉选中与悬停,收起选中框、工具条、样式面板、移动条与区块框;顶栏整条被「您当前正在交互模式 / 返回编辑模式」提示条盖住,
12
+ 图层面板盖一层不可点的遮罩,设备切换禁用,画布发来的与宿主页的 Cmd/Ctrl+Z 都不再触发编辑器撤销;挂 beforeunload 防止页面把产品页带走。
13
+ - 退出时开回拾取、重拉图层树,并把当前改动按原 changeId 撤了重放一遍(页面脚本可能把改过的元素重渲染掉了;id 不变,AI 卡片的撤销仍对得上)。
14
+ - 交互中画布整页跳走过(点了链接 / 提交了表单,SW 派生的 `navAway`),退出时按实验地址重建会话,改动走与切设备相同的快照回放,不往跳走后的页面上放。
15
+ - 切版本、文字编辑、移动拾取、截图进行中时按钮禁用;关拾取失败则不进入。
16
+ - `InlineEditor` 新增 `interactiveMode`(默认 `true`,只读画布可传 `false`)与 `onInteractiveChange`;headless 层 `useInlineEditor` 新增
17
+ `interactive` / `setInteractive(on)`(退出返回 `{ navigated }`)与入参 `reloadKey`;`useChangeList` 新增 `reapplyAll()`。
18
+
3
19
  ## 0.1.7
4
20
 
5
21
  依赖 `@ptengine/inline-core` ~0.2.1(新增 `historyShortcut` 流事件):**先发 inline-core 0.2.1,再发本版**。
@@ -184,6 +184,15 @@ export interface InlineEditorProps {
184
184
  activeId?: string | null;
185
185
  onHover?: (id: string | null) => void;
186
186
  };
187
+ /**
188
+ * 「交互模式」按钮。默认 `true`。分析画布这类只看不改的宿主传 `false` 关掉。
189
+ *
190
+ * 交互模式 = 拾取关掉、页面原样可点(打开弹窗 / 切页签 / 展开折叠后再改那些交互后才
191
+ * 看得见的内容),对应老编辑器(engage-editor)画布工具条上的同名开关。
192
+ */
193
+ interactiveMode?: boolean;
194
+ /** 进出交互模式时通知宿主(宿主自己的浮层要不要让路由它决定)。 */
195
+ onInteractiveChange?: (on: boolean) => void;
187
196
  /**
188
197
  * 选中变化 → 新的 AI 上下文。`null` = 选中被清空。
189
198
  *
@@ -354,4 +363,4 @@ export interface AiApplyApi {
354
363
  /** 当前版本 —— 宿主把 agent 给的版本**名**映射成 id 时要知道现在在哪一版。 */
355
364
  readonly activeVersionId?: string;
356
365
  }
357
- export declare function InlineEditor({ url, device, onDeviceChange, title, versionLabel, chrome, onExit, onSave, libraryComponents, libraryLoading, onAddVersion, onRenameVersion, removableVersionIds, onRemoveVersion, onPreview, onToggleAi, aiOpen, uploadImage, initialChanges, onHydrated, onAskAi, onPageContextReady, anchors, pageBlocks, onEditorReady, onSessionReady, onAiContextChange, onAiApplyReady, onAiInsertedRemoved, onAiInsertedRestored, trackAiSelection, renderNodeIcon, versions, activeVersionId, versionChanges, onVersionChange, onVersionLeave, onVersionEnter, resolveChangeId, allowedLinkUrls, viewportWidthOverride, blockOverlay }: InlineEditorProps): import("react").JSX.Element;
366
+ export declare function InlineEditor({ url, device, onDeviceChange, title, versionLabel, chrome, onExit, onSave, libraryComponents, libraryLoading, onAddVersion, onRenameVersion, removableVersionIds, onRemoveVersion, onPreview, onToggleAi, aiOpen, uploadImage, initialChanges, onHydrated, onAskAi, onPageContextReady, anchors, pageBlocks, onEditorReady, onSessionReady, onAiContextChange, onAiApplyReady, onAiInsertedRemoved, onAiInsertedRestored, trackAiSelection, renderNodeIcon, versions, activeVersionId, versionChanges, onVersionChange, onVersionLeave, onVersionEnter, resolveChangeId, allowedLinkUrls, viewportWidthOverride, blockOverlay, interactiveMode, onInteractiveChange }: InlineEditorProps): import("react").JSX.Element;
@@ -56,14 +56,16 @@ function imageTargetOf(caps) {
56
56
  return branch === 'background' ? 'background' : undefined;
57
57
  }
58
58
  const AI_ELEMENT_IMAGE_LIMIT = 4;
59
- export function InlineEditor({ url, device, onDeviceChange, title, versionLabel = 'V1', chrome = 'full', onExit, onSave, libraryComponents, libraryLoading, onAddVersion, onRenameVersion, removableVersionIds, onRemoveVersion, onPreview, onToggleAi, aiOpen, uploadImage, initialChanges, onHydrated, onAskAi, onPageContextReady, anchors, pageBlocks, onEditorReady, onSessionReady, onAiContextChange, onAiApplyReady, onAiInsertedRemoved, onAiInsertedRestored, trackAiSelection, renderNodeIcon, versions, activeVersionId, versionChanges, onVersionChange, onVersionLeave, onVersionEnter, resolveChangeId, allowedLinkUrls, viewportWidthOverride, blockOverlay }) {
59
+ export function InlineEditor({ url, device, onDeviceChange, title, versionLabel = 'V1', chrome = 'full', onExit, onSave, libraryComponents, libraryLoading, onAddVersion, onRenameVersion, removableVersionIds, onRemoveVersion, onPreview, onToggleAi, aiOpen, uploadImage, initialChanges, onHydrated, onAskAi, onPageContextReady, anchors, pageBlocks, onEditorReady, onSessionReady, onAiContextChange, onAiApplyReady, onAiInsertedRemoved, onAiInsertedRestored, trackAiSelection, renderNodeIcon, versions, activeVersionId, versionChanges, onVersionChange, onVersionLeave, onVersionEnter, resolveChangeId, allowedLinkUrls, viewportWidthOverride, blockOverlay, interactiveMode = true, onInteractiveChange }) {
60
60
  const { t } = useTranslation();
61
61
  const canvasAreaRef = useRef(null);
62
62
  const canvasIframeRef = useRef(null);
63
63
  const insertedRemovedRef = useRef(null);
64
64
  const insertedRestoredRef = useRef(null);
65
65
  const historyShortcutRef = useRef(() => { });
66
+ const [reloadKey, setReloadKey] = useState(0);
66
67
  const editor = useInlineEditor({
68
+ reloadKey,
67
69
  onHistoryShortcut: direction => historyShortcutRef.current(direction),
68
70
  onInsertedRemoved: selectors => insertedRemovedRef.current?.(selectors),
69
71
  onInsertedRestored: selectors => insertedRestoredRef.current?.(selectors),
@@ -72,7 +74,7 @@ export function InlineEditor({ url, device, onDeviceChange, title, versionLabel
72
74
  iframeRef: canvasIframeRef,
73
75
  renderNodeIcon
74
76
  });
75
- const { selection, tree, clipboard, move, capturing, textEditing, geometry, changeList, commands, frameProps, domReady } = editor;
77
+ const { selection, tree, clipboard, move, capturing, textEditing, geometry, changeList, commands, frameProps, domReady, interactive } = editor;
76
78
  const selectAndReveal = useCallback(async (selector) => {
77
79
  await commands.select(selector);
78
80
  await commands.reveal(selector);
@@ -197,7 +199,7 @@ export function InlineEditor({ url, device, onDeviceChange, title, versionLabel
197
199
  void run().catch(e => setCommandError(describeCanvasError(e)));
198
200
  }, [t]);
199
201
  historyShortcutRef.current = direction => {
200
- if (!domReady || textEditing || switchingRef.current)
202
+ if (!domReady || textEditing || switchingRef.current || interactive)
201
203
  return;
202
204
  uiWrite(() => enqueue(() => direction === 'undo' ? changeListRef.current.undo() : changeListRef.current.redo()));
203
205
  };
@@ -248,17 +250,20 @@ export function InlineEditor({ url, device, onDeviceChange, title, versionLabel
248
250
  const hydratedSessionRef = useRef(null);
249
251
  const sessionIdForHydrate = frameProps?.sessionId;
250
252
  const deviceSnapshot = useRef(null);
253
+ const snapshotForRebuild = async () => {
254
+ await changeListRef.current.whenIdle();
255
+ const live = changeListRef.current.peekList();
256
+ deviceSnapshot.current = { version: activeVersionId, changes: live.map(r => r.change) };
257
+ if (activeVersionId)
258
+ versionCallbacksRef.current.onVersionLeave?.(activeVersionId, live);
259
+ };
251
260
  const changeDevice = (next) => {
252
- if (next === device || switchingRef.current)
261
+ if (next === device || switchingRef.current || interactive)
253
262
  return;
254
263
  switchingRef.current = true;
255
264
  setSwitching(true);
256
265
  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);
266
+ await snapshotForRebuild();
262
267
  onDeviceChange(next);
263
268
  })
264
269
  .catch(error => setCommandError(describeCanvasError(error)))
@@ -267,6 +272,41 @@ export function InlineEditor({ url, device, onDeviceChange, title, versionLabel
267
272
  setSwitching(false);
268
273
  });
269
274
  };
275
+ const interactiveChangeRef = useRef(onInteractiveChange);
276
+ interactiveChangeRef.current = onInteractiveChange;
277
+ const canEnterInteractive = interactiveMode && domReady && !switching && !capturing && textEditing === null && !move;
278
+ const enterInteractive = () => {
279
+ if (!canEnterInteractive || switchingRef.current)
280
+ return;
281
+ setStylePanelOpen(false);
282
+ setShowChangeList(false);
283
+ setPasteDialog(false);
284
+ void enqueue(() => editor.setInteractive(true))
285
+ .then(() => interactiveChangeRef.current?.(true))
286
+ .catch(error => setCommandError(describeCanvasError(error)));
287
+ };
288
+ const exitInteractive = () => {
289
+ void enqueue(async () => {
290
+ const { navigated } = await editor.setInteractive(false);
291
+ interactiveChangeRef.current?.(false);
292
+ if (navigated) {
293
+ await snapshotForRebuild();
294
+ setReloadKey(n => n + 1);
295
+ return;
296
+ }
297
+ await changeListRef.current.reapplyAll();
298
+ }).catch(error => setCommandError(describeCanvasError(error)));
299
+ };
300
+ useEffect(() => {
301
+ if (!interactive)
302
+ return;
303
+ const onBeforeUnload = (event) => {
304
+ event.preventDefault();
305
+ event.returnValue = '';
306
+ };
307
+ window.addEventListener('beforeunload', onBeforeUnload);
308
+ return () => window.removeEventListener('beforeunload', onBeforeUnload);
309
+ }, [interactive]);
270
310
  const bootChanges = deviceSnapshot.current?.version === activeVersionId && deviceSnapshot.current
271
311
  ? deviceSnapshot.current.changes
272
312
  : hasVersions && activeVersionId && versionChanges?.[activeVersionId]
@@ -990,13 +1030,13 @@ export function InlineEditor({ url, device, onDeviceChange, title, versionLabel
990
1030
  }
991
1031
  }, [clipboard, commands, depsOf, frameProps?.sessionId, selection, t, uploadImage]);
992
1032
  const self = selection?.chain[0];
993
- return (_jsxs("div", { className: `${CANVAS_UI_SCOPE} flex h-full min-h-0 flex-col bg-[#F5F7FC]`, children: [_jsx(EditorChrome, { slotRight: capturing ? (_jsx("span", { role: "status", className: "text-xs text-muted-foreground", children: t('inlineCanvas.review.generating', '正在生成截图…') })) : undefined, variant: chrome, title: title, versionLabel: versionLabel, versions: versions, activeVersionId: activeVersionId, versionSwitching: switching, onVersionChange: onVersionChange, canUndo: !switching && textEditing === null && changeList.canUndo, canRedo: !switching && textEditing === null && changeList.canRedo, onUndo: () => uiWrite(() => enqueue(() => changeListRef.current.undo())), onRedo: () => uiWrite(() => enqueue(() => changeListRef.current.redo())), changeCount: changeList.list.length, onExit: onExit, onSave: () => {
994
- if (switchingRef.current) {
995
- setCommandError(t('inlineCanvas.version.switching', '正在切换版本,请稍候'));
996
- return;
997
- }
998
- onSave?.(editor);
999
- }, onAddVersion: onAddVersion, onRenameVersion: onRenameVersion, removableVersionIds: removableVersionIds, onRemoveVersion: onRemoveVersion, onPreview: onPreview, onToggleChangeList: () => setShowChangeList(v => !v), changeListOpen: showChangeList, onToggleAi: onToggleAi, aiOpen: aiOpen }), _jsxs("div", { className: "relative flex min-h-0 flex-1", children: [_jsxs(ResizablePanelGroup, { direction: "horizontal", autoSaveId: "inline-editor-panes-v2", className: "flex min-h-0 w-full flex-1", children: [showLeftPanel && (_jsxs(_Fragment, { children: [_jsx(ResizablePanel, { id: "left", order: 1, defaultSize: 18, minSize: 12, maxSize: 40, className: "flex min-h-0 flex-col", children: _jsxs("aside", { "data-testid": "inline-editor-left-panel", className: "inline-editor-left flex h-full min-h-0 flex-col bg-white", children: [!!libraryComponents && (_jsx("div", { className: "flex h-9 shrink-0 items-center gap-1 border-b\n border-[#EBECF0] px-2", children: [
1033
+ return (_jsxs("div", { className: `${CANVAS_UI_SCOPE} flex h-full min-h-0 flex-col bg-[#F5F7FC]`, children: [_jsxs("div", { className: "relative shrink-0", children: [interactive && (_jsxs("div", { role: "status", "data-testid": "inline-editor-interactive-bar", className: "absolute inset-0 z-20 flex items-center justify-center gap-3\n border-b border-[#ececef] bg-white text-[13px] text-[#1a1a1a]\n shadow-[0_4px_12px_0_rgba(9,30,66,0.12)]", children: [_jsx("span", { children: t('inlineCanvas.interactive.active', '您当前正在交互模式') }), _jsx(Button, { size: "sm", variant: "secondary", "data-testid": "inline-editor-interactive-exit", onClick: exitInteractive, children: t('inlineCanvas.interactive.exit', '返回编辑模式') })] })), _jsx(EditorChrome, { slotRight: capturing ? (_jsx("span", { role: "status", className: "text-xs text-muted-foreground", children: t('inlineCanvas.review.generating', '正在生成截图…') })) : undefined, variant: chrome, title: title, versionLabel: versionLabel, versions: versions, activeVersionId: activeVersionId, versionSwitching: switching, onVersionChange: onVersionChange, canUndo: !switching && textEditing === null && changeList.canUndo, canRedo: !switching && textEditing === null && changeList.canRedo, onUndo: () => uiWrite(() => enqueue(() => changeListRef.current.undo())), onRedo: () => uiWrite(() => enqueue(() => changeListRef.current.redo())), changeCount: changeList.list.length, onExit: onExit, onSave: () => {
1034
+ if (switchingRef.current) {
1035
+ setCommandError(t('inlineCanvas.version.switching', '正在切换版本,请稍候'));
1036
+ return;
1037
+ }
1038
+ onSave?.(editor);
1039
+ }, onAddVersion: onAddVersion, onRenameVersion: onRenameVersion, removableVersionIds: removableVersionIds, onRemoveVersion: onRemoveVersion, onPreview: onPreview, onToggleChangeList: () => setShowChangeList(v => !v), changeListOpen: showChangeList, onToggleAi: onToggleAi, aiOpen: aiOpen })] }), _jsxs("div", { className: "relative flex min-h-0 flex-1", children: [_jsxs(ResizablePanelGroup, { direction: "horizontal", autoSaveId: "inline-editor-panes-v2", className: "flex min-h-0 w-full flex-1", children: [showLeftPanel && (_jsxs(_Fragment, { children: [_jsx(ResizablePanel, { id: "left", order: 1, defaultSize: 18, minSize: 12, maxSize: 40, className: "flex min-h-0 flex-col", children: _jsxs("aside", { "data-testid": "inline-editor-left-panel", className: "inline-editor-left relative flex h-full min-h-0 flex-col bg-white", children: [interactive && (_jsx("div", { "data-testid": "inline-editor-left-veil", className: "absolute inset-0 z-10 cursor-not-allowed bg-white/60" })), !!libraryComponents && (_jsx("div", { className: "flex h-9 shrink-0 items-center gap-1 border-b\n border-[#EBECF0] px-2", children: [
1000
1040
  ['layers', '图层'],
1001
1041
  ['assets', '组件']
1002
1042
  ].map(([tab, label]) => (_jsx("button", { type: "button", "data-testid": `left-tab-${tab}`, "aria-pressed": leftTab === tab, onClick: () => setLeftTab(tab), className: `rounded px-2 py-1 text-[12px] ${leftTab === tab
@@ -1007,7 +1047,15 @@ export function InlineEditor({ url, device, onDeviceChange, title, versionLabel
1007
1047
  targetIdentifier: targetSelector,
1008
1048
  targetSelector,
1009
1049
  mode: moveModeFromDropPos(pos)
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: [
1050
+ })) })) })] }) }), _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, deviceDisabled: interactive, interactive: interactiveMode
1051
+ ? {
1052
+ active: interactive,
1053
+ disabled: !interactive && !canEnterInteractive,
1054
+ onToggle: () => interactive
1055
+ ? exitInteractive()
1056
+ : enterInteractive()
1057
+ }
1058
+ : undefined }), _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
1059
  'NO_EXTENSION',
1012
1060
  'EXTENSION_ID_MISSING',
1013
1061
  'CONTRACT_MISMATCH'
@@ -1015,7 +1063,7 @@ export function InlineEditor({ url, device, onDeviceChange, title, versionLabel
1015
1063
  ? describeCanvasError(editor.session.reason)
1016
1064
  : 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: () => {
1017
1065
  setShot(null);
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: {
1066
+ } }, 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 && !interactive && (_jsx(SelectionOverlay, { geometry: geometry.latest, context: overlayContext, selectedLabel: self ? `<${self.tag}>` : undefined })), !capturing && !interactive && 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 && !interactive && 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
1067
  width: 'min(600px, calc(100vw - 32px))',
1020
1068
  left: `clamp(min(316px, 50vw), ${area.x + area.width / 2}px, max(calc(100vw - 316px), 50vw))`,
1021
1069
  top: `${area.y + 8}px`
@@ -1024,7 +1072,8 @@ export function InlineEditor({ url, device, onDeviceChange, title, versionLabel
1024
1072
  : 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: {
1025
1073
  left: `${area.x + area.width / 2}px`,
1026
1074
  top: `${area.y + 8}px`
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 &&
1075
+ }, children: "\u6B63\u5728\u7F16\u8F91\u6587\u5B57 \u00B7 \u56DE\u8F66\u63D0\u4EA4 \u00B7 Esc \u53D6\u6D88" })), !capturing && !interactive && !switching && !move && textEditing === null && (_jsx(SelectionToolbar, { selectedRect: selectedRect, area: area, capabilities: selection?.capabilities ?? null, isScrolling: geometry.isScrolling, onAction: onToolbarAction, host: { askAi: !!onAskAi } })), stylePanelOpen &&
1076
+ !interactive &&
1028
1077
  !switching &&
1029
1078
  selectedRect &&
1030
1079
  self &&
@@ -19,5 +19,18 @@ export interface CanvasContextBarProps {
19
19
  url: string;
20
20
  device: CanvasDevice;
21
21
  onDeviceChange: (device: CanvasDevice) => void;
22
+ /** 交互模式下切设备会重建画布、把用户点出来的页面状态整个丢掉,所以一并禁用。 */
23
+ deviceDisabled?: boolean;
24
+ /**
25
+ * 「交互模式」开关,靠右。不传 = 没有这个按钮。
26
+ *
27
+ * 放在画布条而不是顶栏:它改变的是**画布**的行为(点击交给页面),与设备切换同属
28
+ * 「我在怎么看这一页」;老编辑器也是挨着设备切换放的。
29
+ */
30
+ interactive?: {
31
+ active: boolean;
32
+ disabled?: boolean;
33
+ onToggle: () => void;
34
+ };
22
35
  }
23
- export declare function CanvasContextBar({ url, device, onDeviceChange }: CanvasContextBarProps): import("react").JSX.Element;
36
+ export declare function CanvasContextBar({ url, device, onDeviceChange, deviceDisabled, interactive }: CanvasContextBarProps): import("react").JSX.Element;
@@ -1,12 +1,14 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useTranslation } from 'react-i18next';
3
- import { Link2, Monitor, Smartphone } from 'lucide-react';
4
- import { ChromeSegment, ChromeSegmented } from '../chrome/topbarPrimitives.js';
5
- export function CanvasContextBar({ url, device, onDeviceChange }) {
3
+ import { Link2, Monitor, MousePointerClick, Smartphone } from 'lucide-react';
4
+ import { ChromeSegment, ChromeSegmented, ChromeTextButton } from '../chrome/topbarPrimitives.js';
5
+ export function CanvasContextBar({ url, device, onDeviceChange, deviceDisabled, interactive }) {
6
6
  const { t } = useTranslation();
7
7
  const items = [
8
8
  ['pc', Monitor, t('inlineCanvas.chrome.pc', 'PC')],
9
9
  ['mobile', Smartphone, t('inlineCanvas.chrome.mobile', '移动')]
10
10
  ];
11
- return (_jsxs("div", { "data-testid": "canvas-context-bar", className: "relative flex h-9 shrink-0 items-center border-b border-[#EBECF0]\n bg-white px-3", children: [url && (_jsxs("a", { "data-testid": "canvas-context-url", href: url, target: "_blank", rel: "noopener noreferrer", title: url, className: "flex max-w-[calc(50%-60px)] items-center gap-1.5 truncate text-xs\n text-[#8a8f98] hover:text-[#52525b] hover:underline", children: [_jsx(Link2, { className: "size-3.5 shrink-0" }), _jsx("span", { className: "truncate", children: url })] })), _jsx(ChromeSegmented, { "data-testid": "device-switch", className: "absolute left-1/2 -translate-x-1/2", children: items.map(([value, Icon, label]) => (_jsxs(ChromeSegment, { "data-testid": `device-${value}`, active: device === value, onClick: () => onDeviceChange(value), children: [_jsx(Icon, { className: "size-3.5" }), label] }, value))) })] }));
11
+ return (_jsxs("div", { "data-testid": "canvas-context-bar", className: "relative flex h-9 shrink-0 items-center border-b border-[#EBECF0]\n bg-white px-3", children: [url && (_jsxs("a", { "data-testid": "canvas-context-url", href: url, target: "_blank", rel: "noopener noreferrer", title: url, className: "flex max-w-[calc(50%-60px)] items-center gap-1.5 truncate text-xs\n text-[#8a8f98] hover:text-[#52525b] hover:underline", children: [_jsx(Link2, { className: "size-3.5 shrink-0" }), _jsx("span", { className: "truncate", children: url })] })), _jsx(ChromeSegmented, { "data-testid": "device-switch", className: "absolute left-1/2 -translate-x-1/2", children: items.map(([value, Icon, label]) => (_jsxs(ChromeSegment, { "data-testid": `device-${value}`, active: device === value, disabled: deviceDisabled, onClick: () => onDeviceChange(value), children: [_jsx(Icon, { className: "size-3.5" }), label] }, value))) }), interactive && (_jsxs(ChromeTextButton, { "data-testid": "canvas-interactive-toggle", className: "ml-auto h-7 gap-1.5 px-2 text-xs", active: interactive.active, disabled: interactive.disabled, title: t('inlineCanvas.interactive.tips', '切换到交互模式可以对页面进行交互,以修改需要交互后才能看到的内容。'), onClick: interactive.onToggle, children: [_jsx(MousePointerClick, { className: "size-3.5" }), interactive.active
12
+ ? t('inlineCanvas.interactive.exit', '返回编辑模式')
13
+ : t('inlineCanvas.interactive.enter', '交互模式')] }))] }));
12
14
  }
@@ -82,6 +82,8 @@ export interface UseChangeListResult {
82
82
  outcome: HydrateOutcome;
83
83
  idMap: Map<string, string>;
84
84
  }>;
85
+ /** 当前清单原样重放(changeId 不变、不进撤销历史)。交互模式退出时用。 */
86
+ reapplyAll: () => Promise<void>;
85
87
  whenIdle: () => Promise<unknown>;
86
88
  canUndo: boolean;
87
89
  canRedo: boolean;
@@ -174,6 +174,7 @@ export function useChangeList(sessionId, options = {}) {
174
174
  target.push(entry);
175
175
  refreshHistory(n => n + 1);
176
176
  }, false), [guard, restoreSnapshot]);
177
+ const reapplyAll = useCallback(() => guard(() => restoreSnapshot(listRef.current), false), [guard, restoreSnapshot]);
177
178
  const undo = useCallback(() => travelHistory('undo'), [travelHistory]);
178
179
  const redo = useCallback(() => travelHistory('redo'), [travelHistory]);
179
180
  const addStyle = useCallback((input) => guard(() => push(buildStyleChange(input), input.ancestors)), [guard, push]);
@@ -249,6 +250,7 @@ export function useChangeList(sessionId, options = {}) {
249
250
  clear,
250
251
  hydrate,
251
252
  replaceAll,
253
+ reapplyAll,
252
254
  whenIdle: () => mutationQueue.current,
253
255
  peekList,
254
256
  canUndo: past.current.length > 0,
@@ -32,6 +32,13 @@ export declare function useCanvasSession(input: {
32
32
  device: CanvasDevice;
33
33
  /** agent 主动发来的流(lifecycle / geometry / elementPicked / …)转出去给 useCanvasStream 消费。 */
34
34
  onStreamEvent?: (msg: CanvasStreamMessage) => void;
35
+ /**
36
+ * 递增即按同一 url/device **重建会话**(新 sessionId、新 iframe)。
37
+ *
38
+ * 交互模式里用户点链接让画布整页跳走后,退出时要回到实验页:同会话里跳走的文档
39
+ * 不是编辑对象(拾取没开、改动没放),只能整个重开 —— 与切设备同一条路。
40
+ */
41
+ reloadKey?: number;
35
42
  }): {
36
43
  state: State;
37
44
  reason: LoadFailedReason | undefined;
@@ -177,6 +177,6 @@ export function useCanvasSession(input) {
177
177
  void sendCanvasCommand({ command: 'destroy', sessionId }).catch(() => { });
178
178
  port?.disconnect();
179
179
  };
180
- }, [input.url, input.device]);
180
+ }, [input.url, input.device, input.reloadKey]);
181
181
  return { state, reason, portError, frameProps, handshake, readyPayload, capabilities };
182
182
  }
package/dist/locales.d.ts CHANGED
@@ -90,6 +90,12 @@ export declare const zhCN: {
90
90
  pc: string;
91
91
  mobile: string;
92
92
  };
93
+ interactive: {
94
+ enter: string;
95
+ tips: string;
96
+ active: string;
97
+ exit: string;
98
+ };
93
99
  version: {
94
100
  switching: string;
95
101
  };
@@ -271,6 +277,12 @@ export declare const enUS: {
271
277
  pc: string;
272
278
  mobile: string;
273
279
  };
280
+ interactive: {
281
+ enter: string;
282
+ tips: string;
283
+ active: string;
284
+ exit: string;
285
+ };
274
286
  version: {
275
287
  switching: string;
276
288
  };
@@ -452,6 +464,12 @@ export declare const jaJP: {
452
464
  pc: string;
453
465
  mobile: string;
454
466
  };
467
+ interactive: {
468
+ enter: string;
469
+ tips: string;
470
+ active: string;
471
+ exit: string;
472
+ };
455
473
  version: {
456
474
  switching: string;
457
475
  };
@@ -683,6 +701,12 @@ export declare const inlineEditorLocales: {
683
701
  pc: string;
684
702
  mobile: string;
685
703
  };
704
+ readonly interactive: {
705
+ enter: string;
706
+ tips: string;
707
+ active: string;
708
+ exit: string;
709
+ };
686
710
  readonly version: {
687
711
  switching: string;
688
712
  };
@@ -913,6 +937,12 @@ export declare const inlineEditorLocales: {
913
937
  pc: string;
914
938
  mobile: string;
915
939
  };
940
+ readonly interactive: {
941
+ enter: string;
942
+ tips: string;
943
+ active: string;
944
+ exit: string;
945
+ };
916
946
  readonly version: {
917
947
  switching: string;
918
948
  };
@@ -1143,6 +1173,12 @@ export declare const inlineEditorLocales: {
1143
1173
  pc: string;
1144
1174
  mobile: string;
1145
1175
  };
1176
+ readonly interactive: {
1177
+ enter: string;
1178
+ tips: string;
1179
+ active: string;
1180
+ exit: string;
1181
+ };
1146
1182
  readonly version: {
1147
1183
  switching: string;
1148
1184
  };
package/dist/locales.js CHANGED
@@ -80,6 +80,12 @@ export const zhCN = {
80
80
  pc: 'PC',
81
81
  mobile: '移动'
82
82
  },
83
+ interactive: {
84
+ enter: '交互模式',
85
+ tips: '切换到交互模式可以对页面进行交互,以修改需要交互后才能看到的内容。',
86
+ active: '您当前正在交互模式',
87
+ exit: '返回编辑模式'
88
+ },
83
89
  version: {
84
90
  switching: '正在切换版本,请稍候'
85
91
  },
@@ -261,6 +267,12 @@ export const enUS = {
261
267
  pc: 'Desktop',
262
268
  mobile: 'Mobile'
263
269
  },
270
+ interactive: {
271
+ enter: 'Interactive mode',
272
+ tips: 'Switch to interactive mode to interact with the page like in a live website.',
273
+ active: 'You are now in interactive mode',
274
+ exit: 'Back to edit mode'
275
+ },
264
276
  version: {
265
277
  switching: 'Switching version, please wait'
266
278
  },
@@ -442,6 +454,12 @@ export const jaJP = {
442
454
  pc: 'PC',
443
455
  mobile: 'モバイル'
444
456
  },
457
+ interactive: {
458
+ enter: '画面操作モード',
459
+ tips: '画面操作できるようになります。表示されていない要素を開き、その後編集モードに切り替えることで隠れた要素を変更することが可能です。',
460
+ active: '現在、画面操作モードです',
461
+ exit: '編集モードに戻る'
462
+ },
445
463
  version: {
446
464
  switching: 'バージョンを切り替えています。しばらくお待ちください'
447
465
  },
@@ -45,6 +45,8 @@ export interface UseInlineEditorInput {
45
45
  onHistoryShortcut?: (direction: 'undo' | 'redo') => void;
46
46
  url: string;
47
47
  device: CanvasDevice;
48
+ /** 递增即按同一 url/device 重建画布会话(见 `useCanvasSession` 的同名参数)。 */
49
+ reloadKey?: number;
48
50
  /** change list 变化时回调 —— 宿主自己决定存哪(本 hook 不做持久化)。 */
49
51
  onChangesChange?: (changes: InlineChange[]) => void;
50
52
  /**
@@ -110,6 +112,18 @@ export interface UseInlineEditorResult {
110
112
  */
111
113
  mobileFit: MobileFitState;
112
114
  changeList: ReturnType<typeof useChangeList>;
115
+ /**
116
+ * 交互模式:画布上的拾取关掉,点击/悬停/输入原样交给客户页(打开弹窗、切换页签、展开折叠
117
+ * 之后再改那些「交互后才看得见」的内容)。对应老编辑器(engage-editor)顶栏的「交互模式」。
118
+ */
119
+ interactive: boolean;
120
+ /**
121
+ * 进出交互模式。退出时返回 `navigated`:交互期间画布整页跳走过(点了链接 / 提交了表单)。
122
+ * 跳走的文档不是编辑对象,调用方应重建会话(`reloadKey`)回到实验页,而不是在新页上继续编辑。
123
+ */
124
+ setInteractive: (on: boolean) => Promise<{
125
+ navigated: boolean;
126
+ }>;
113
127
  commands: {
114
128
  /** 选中某个元素(点路径选择器的某一级、或程序化选中)。空串 = 清空。 */
115
129
  select: (selector: string) => Promise<void>;
@@ -38,7 +38,12 @@ export function useInlineEditor(input) {
38
38
  });
39
39
  }, []);
40
40
  const [readySessionId, setReadySessionId] = useState(null);
41
+ const [interactive, setInteractiveState] = useState(false);
42
+ const interactiveRef = useRef(false);
43
+ const navigatedRef = useRef(false);
41
44
  const onLifecycle = useCallback((payload, eventSessionId) => {
45
+ if (payload.phase === 'navAway' && interactiveRef.current)
46
+ navigatedRef.current = true;
42
47
  if (payload.phase !== 'domReady' && payload.phase !== 'loaded')
43
48
  return;
44
49
  if (!eventSessionId)
@@ -54,6 +59,8 @@ export function useInlineEditor(input) {
54
59
  historyShortcutRef.current = input.onHistoryShortcut;
55
60
  const onStreamEvent = useCallback((msg) => {
56
61
  if (msg.namespace === CANVAS_MSG_NAMESPACE && msg.event === 'historyShortcut') {
62
+ if (interactiveRef.current)
63
+ return;
57
64
  const direction = msg.payload?.direction;
58
65
  if (direction === 'undo' || direction === 'redo')
59
66
  historyShortcutRef.current?.(direction);
@@ -64,7 +71,8 @@ export function useInlineEditor(input) {
64
71
  const session = useCanvasSession({
65
72
  url: input.url,
66
73
  device: input.device,
67
- onStreamEvent
74
+ onStreamEvent,
75
+ reloadKey: input.reloadKey
68
76
  });
69
77
  const sessionId = session.frameProps?.sessionId;
70
78
  const changeList = useChangeList(sessionId, {
@@ -121,6 +129,9 @@ export function useInlineEditor(input) {
121
129
  const geometryReset = geometry.reset;
122
130
  const streamReset = stream.reset;
123
131
  useEffect(() => {
132
+ interactiveRef.current = false;
133
+ navigatedRef.current = false;
134
+ setInteractiveState(false);
124
135
  setSelection_(null);
125
136
  setTree(null);
126
137
  treeForRef.current = null;
@@ -160,11 +171,13 @@ export function useInlineEditor(input) {
160
171
  : prev);
161
172
  }, [sessionId]);
162
173
  const treeLoaded = tree !== null;
174
+ const [treeEpoch, setTreeEpoch] = useState(0);
163
175
  const contentRevision = changeList.list
164
176
  .filter(record => ['elementContent', 'elementText', 'elementMove'].includes(record.change.type))
165
177
  .map(record => record.changeId)
166
- .join('|');
167
- const refreshedContent = useRef('');
178
+ .join('|')
179
+ .concat(`#${treeEpoch}`);
180
+ const refreshedContent = useRef('#0');
168
181
  useEffect(() => {
169
182
  if (!wantTree ||
170
183
  !domReady ||
@@ -371,6 +384,41 @@ export function useInlineEditor(input) {
371
384
  }));
372
385
  }
373
386
  }), [graftAt, input.iframeRef, preserveScroll, refresh, sessionId]);
387
+ const setInteractive = useCallback(async (on) => {
388
+ if (!sessionId)
389
+ return { navigated: false };
390
+ if (on) {
391
+ if (interactiveRef.current)
392
+ return { navigated: false };
393
+ interactiveRef.current = true;
394
+ navigatedRef.current = false;
395
+ setInteractiveState(true);
396
+ setMove(null);
397
+ setSelection_(null);
398
+ await setHoverTarget(sessionId, '').catch(() => { });
399
+ await setSelection(sessionId, '').catch(() => { });
400
+ try {
401
+ await setPickMode(sessionId, false);
402
+ }
403
+ catch (e) {
404
+ interactiveRef.current = false;
405
+ setInteractiveState(false);
406
+ throw e;
407
+ }
408
+ return { navigated: false };
409
+ }
410
+ if (!interactiveRef.current)
411
+ return { navigated: false };
412
+ const navigated = navigatedRef.current;
413
+ interactiveRef.current = false;
414
+ navigatedRef.current = false;
415
+ setInteractiveState(false);
416
+ if (!navigated) {
417
+ await setPickMode(sessionId, true);
418
+ setTreeEpoch(n => n + 1);
419
+ }
420
+ return { navigated };
421
+ }, [sessionId]);
374
422
  return useMemo(() => ({
375
423
  session,
376
424
  frameProps: session.frameProps,
@@ -384,6 +432,8 @@ export function useInlineEditor(input) {
384
432
  geometry,
385
433
  mobileFit: mobileFit.state,
386
434
  changeList,
435
+ interactive,
436
+ setInteractive,
387
437
  commands
388
438
  }), [
389
439
  session,
@@ -397,6 +447,8 @@ export function useInlineEditor(input) {
397
447
  geometry,
398
448
  mobileFit.state,
399
449
  changeList,
450
+ interactive,
451
+ setInteractive,
400
452
  commands
401
453
  ]);
402
454
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ptengine/inline-editor",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "Ptengine Inline 编辑器:画布会话编排、图层树、属性面板与组件库(含内部 canvas-ui)",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,