@ptengine/inline-editor 0.1.7 → 0.1.9

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,35 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.9
4
+
5
+ 依赖不变(`@ptengine/inline-core` ~0.2.1),可以直接发。
6
+
7
+ **新增**
8
+
9
+ - 自定义代码(老编辑器的「追加代码」/ code mode):画布条新增「追加代码」按钮,打开面板给当前版本追加 CSS 与 JavaScript。
10
+ - `InlineEditor` 新增受控属性 `code`(当前版本的 `ExperienceCode[]`)与 `onCodeChange(code, versionId)`(带上代码所属版本:切版本时面板卸载补交的输入属于旧版本);不传 `onCodeChange` 则没有按钮(对照组、只读画布),已有代码照样注入。
11
+ - 改即生效:输入停顿约 400ms 后整份交给宿主,关面板 / 换版本时立即交出;空白段不写。Tab 键插两个空格。
12
+ - CSS 经 `setCode` 实时注入画布,与组件库资产合并发送(全量替换语义,只发一边会清掉另一边);会话就绪 / 重建后重新注入。
13
+ - JavaScript 只保存不在画布执行(与老编辑器一致),面板里提示通过预览 / 发布查看,并说明 ptKit 工具函数。
14
+ - 有代码时改动清单计数 +1,清单顶上显示「自定义代码」行,点它打开面板;按钮上带圆点提示。代码不进撤销历史。
15
+ - 交互模式中按钮禁用、面板收起。
16
+
17
+ ## 0.1.8
18
+
19
+ 依赖不变(`@ptengine/inline-core` ~0.2.1),可以直接发。
20
+
21
+ **新增**
22
+
23
+ - 交互模式(对应 engage-editor 画布工具条上的「交互模式」):画布条右侧新增开关,进入后关掉拾取,点击、悬停、输入都交给客户页,
24
+ 用来打开弹窗、切换页签、展开折叠,再改那些交互之后才看得见的内容。
25
+ - 进入时清掉选中与悬停,收起选中框、工具条、样式面板、移动条与区块框;顶栏整条被「您当前正在交互模式 / 返回编辑模式」提示条盖住,
26
+ 图层面板盖一层不可点的遮罩,设备切换禁用,画布发来的与宿主页的 Cmd/Ctrl+Z 都不再触发编辑器撤销;挂 beforeunload 防止页面把产品页带走。
27
+ - 退出时开回拾取、重拉图层树,并把当前改动按原 changeId 撤了重放一遍(页面脚本可能把改过的元素重渲染掉了;id 不变,AI 卡片的撤销仍对得上)。
28
+ - 交互中画布整页跳走过(点了链接 / 提交了表单,SW 派生的 `navAway`),退出时按实验地址重建会话,改动走与切设备相同的快照回放,不往跳走后的页面上放。
29
+ - 切版本、文字编辑、移动拾取、截图进行中时按钮禁用;关拾取失败则不进入。
30
+ - `InlineEditor` 新增 `interactiveMode`(默认 `true`,只读画布可传 `false`)与 `onInteractiveChange`;headless 层 `useInlineEditor` 新增
31
+ `interactive` / `setInteractive(on)`(退出返回 `{ navigated }`)与入参 `reloadKey`;`useChangeList` 新增 `reapplyAll()`。
32
+
3
33
  ## 0.1.7
4
34
 
5
35
  依赖 `@ptengine/inline-core` ~0.2.1(新增 `historyShortcut` 流事件):**先发 inline-core 0.2.1,再发本版**。
@@ -6,6 +6,7 @@ import { type AnchorShot, type ElementAiContext } from './kernel/selection/eleme
6
6
  import { type ShelfBlockHint } from './kernel/selection/buildRootShelf.js';
7
7
  import { type InlinePlanAnchor } from './kernel/selection/resolveAnchors.js';
8
8
  import { type AiApplyOutcome, type AiScopeGrant, type AiWireChange } from './kernel/ai/aiInlineApply.js';
9
+ import type { ExperienceCode } from '@ptengine/inline-core/protocol';
9
10
  import { type LibraryComponent } from './assets/componentLibrary.js';
10
11
  import { type UseInlineEditorResult } from './useInlineEditor.js';
11
12
  import type { ChangeRecord, HydrateOutcome } from './kernel/changelist/useChangeList.js';
@@ -184,6 +185,27 @@ export interface InlineEditorProps {
184
185
  activeId?: string | null;
185
186
  onHover?: (id: string | null) => void;
186
187
  };
188
+ /**
189
+ * 「交互模式」按钮。默认 `true`。分析画布这类只看不改的宿主传 `false` 关掉。
190
+ *
191
+ * 交互模式 = 拾取关掉、页面原样可点(打开弹窗 / 切页签 / 展开折叠后再改那些交互后才
192
+ * 看得见的内容),对应老编辑器(engage-editor)画布工具条上的同名开关。
193
+ */
194
+ interactiveMode?: boolean;
195
+ /** 进出交互模式时通知宿主(宿主自己的浮层要不要让路由它决定)。 */
196
+ onInteractiveChange?: (on: boolean) => void;
197
+ /**
198
+ * 当前版本的自定义代码(老编辑器的「追加代码」/ code mode),**受控**:宿主按版本持有,
199
+ * 切版本时换成那一版的。CSS 实时注入画布,JavaScript 只存不执行(见 `CodePanel`)。
200
+ */
201
+ code?: ExperienceCode[];
202
+ /**
203
+ * 自定义代码改了(输入停顿后整份交出),带上**这份代码属于哪一版**。⚠️ 宿主要按这个 id 存,
204
+ * 不能按「当前版本」存:切版本时面板卸载会把停顿窗口里没交出的那一下补交出来,那一刻宿主的
205
+ * 当前版本已经是新的了。**不传 = 这一版不能加代码**:画布条上没有「追加代码」
206
+ * 按钮(对照组、只读画布);已有的 `code` 仍会注入画布。
207
+ */
208
+ onCodeChange?: (code: ExperienceCode[], versionId: string | undefined) => void;
187
209
  /**
188
210
  * 选中变化 → 新的 AI 上下文。`null` = 选中被清空。
189
211
  *
@@ -354,4 +376,4 @@ export interface AiApplyApi {
354
376
  /** 当前版本 —— 宿主把 agent 给的版本**名**映射成 id 时要知道现在在哪一版。 */
355
377
  readonly activeVersionId?: string;
356
378
  }
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;
379
+ 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, code, onCodeChange }: InlineEditorProps): import("react").JSX.Element;
@@ -39,6 +39,7 @@ import { StylePanel } from './panels/StylePanel.js';
39
39
  import { STYLE_SECTIONS } from './panels/styleFields.js';
40
40
  import { HtmlEditDialog } from './dialogs/HtmlEditDialog.js';
41
41
  import { setCode } from './kernel/canvasCommands.js';
42
+ import { CodePanel } from './panels/CodePanel.js';
42
43
  import { describeCanvasError } from './kernel/canvasErrors.js';
43
44
  import { LayersPanel } from './tree/LayersPanel.js';
44
45
  import { AssetsPanel } from './assets/AssetsPanel.js';
@@ -56,14 +57,16 @@ function imageTargetOf(caps) {
56
57
  return branch === 'background' ? 'background' : undefined;
57
58
  }
58
59
  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 }) {
60
+ 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, code, onCodeChange }) {
60
61
  const { t } = useTranslation();
61
62
  const canvasAreaRef = useRef(null);
62
63
  const canvasIframeRef = useRef(null);
63
64
  const insertedRemovedRef = useRef(null);
64
65
  const insertedRestoredRef = useRef(null);
65
66
  const historyShortcutRef = useRef(() => { });
67
+ const [reloadKey, setReloadKey] = useState(0);
66
68
  const editor = useInlineEditor({
69
+ reloadKey,
67
70
  onHistoryShortcut: direction => historyShortcutRef.current(direction),
68
71
  onInsertedRemoved: selectors => insertedRemovedRef.current?.(selectors),
69
72
  onInsertedRestored: selectors => insertedRestoredRef.current?.(selectors),
@@ -72,7 +75,7 @@ export function InlineEditor({ url, device, onDeviceChange, title, versionLabel
72
75
  iframeRef: canvasIframeRef,
73
76
  renderNodeIcon
74
77
  });
75
- const { selection, tree, clipboard, move, capturing, textEditing, geometry, changeList, commands, frameProps, domReady } = editor;
78
+ const { selection, tree, clipboard, move, capturing, textEditing, geometry, changeList, commands, frameProps, domReady, interactive } = editor;
76
79
  const selectAndReveal = useCallback(async (selector) => {
77
80
  await commands.select(selector);
78
81
  await commands.reveal(selector);
@@ -197,7 +200,7 @@ export function InlineEditor({ url, device, onDeviceChange, title, versionLabel
197
200
  void run().catch(e => setCommandError(describeCanvasError(e)));
198
201
  }, [t]);
199
202
  historyShortcutRef.current = direction => {
200
- if (!domReady || textEditing || switchingRef.current)
203
+ if (!domReady || textEditing || switchingRef.current || interactive)
201
204
  return;
202
205
  uiWrite(() => enqueue(() => direction === 'undo' ? changeListRef.current.undo() : changeListRef.current.redo()));
203
206
  };
@@ -248,17 +251,20 @@ export function InlineEditor({ url, device, onDeviceChange, title, versionLabel
248
251
  const hydratedSessionRef = useRef(null);
249
252
  const sessionIdForHydrate = frameProps?.sessionId;
250
253
  const deviceSnapshot = useRef(null);
254
+ const snapshotForRebuild = async () => {
255
+ await changeListRef.current.whenIdle();
256
+ const live = changeListRef.current.peekList();
257
+ deviceSnapshot.current = { version: activeVersionId, changes: live.map(r => r.change) };
258
+ if (activeVersionId)
259
+ versionCallbacksRef.current.onVersionLeave?.(activeVersionId, live);
260
+ };
251
261
  const changeDevice = (next) => {
252
- if (next === device || switchingRef.current)
262
+ if (next === device || switchingRef.current || interactive)
253
263
  return;
254
264
  switchingRef.current = true;
255
265
  setSwitching(true);
256
266
  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);
267
+ await snapshotForRebuild();
262
268
  onDeviceChange(next);
263
269
  })
264
270
  .catch(error => setCommandError(describeCanvasError(error)))
@@ -267,6 +273,42 @@ export function InlineEditor({ url, device, onDeviceChange, title, versionLabel
267
273
  setSwitching(false);
268
274
  });
269
275
  };
276
+ const interactiveChangeRef = useRef(onInteractiveChange);
277
+ interactiveChangeRef.current = onInteractiveChange;
278
+ const canEnterInteractive = interactiveMode && domReady && !switching && !capturing && textEditing === null && !move;
279
+ const enterInteractive = () => {
280
+ if (!canEnterInteractive || switchingRef.current)
281
+ return;
282
+ setStylePanelOpen(false);
283
+ setShowChangeList(false);
284
+ setPasteDialog(false);
285
+ setCodePanelOpen(false);
286
+ void enqueue(() => editor.setInteractive(true))
287
+ .then(() => interactiveChangeRef.current?.(true))
288
+ .catch(error => setCommandError(describeCanvasError(error)));
289
+ };
290
+ const exitInteractive = () => {
291
+ void enqueue(async () => {
292
+ const { navigated } = await editor.setInteractive(false);
293
+ interactiveChangeRef.current?.(false);
294
+ if (navigated) {
295
+ await snapshotForRebuild();
296
+ setReloadKey(n => n + 1);
297
+ return;
298
+ }
299
+ await changeListRef.current.reapplyAll();
300
+ }).catch(error => setCommandError(describeCanvasError(error)));
301
+ };
302
+ useEffect(() => {
303
+ if (!interactive)
304
+ return;
305
+ const onBeforeUnload = (event) => {
306
+ event.preventDefault();
307
+ event.returnValue = '';
308
+ };
309
+ window.addEventListener('beforeunload', onBeforeUnload);
310
+ return () => window.removeEventListener('beforeunload', onBeforeUnload);
311
+ }, [interactive]);
270
312
  const bootChanges = deviceSnapshot.current?.version === activeVersionId && deviceSnapshot.current
271
313
  ? deviceSnapshot.current.changes
272
314
  : hasVersions && activeVersionId && versionChanges?.[activeVersionId]
@@ -368,6 +410,29 @@ export function InlineEditor({ url, device, onDeviceChange, title, versionLabel
368
410
  addAction: writes.addAction
369
411
  }), [writes, selection]);
370
412
  const injectedAssetsRef = useRef([]);
413
+ const userCode = useMemo(() => (Array.isArray(code) ? code : []), [code]);
414
+ const userCodeRef = useRef(userCode);
415
+ userCodeRef.current = userCode;
416
+ const userCodeKey = JSON.stringify(userCode);
417
+ const codeSyncedRef = useRef(null);
418
+ useEffect(() => {
419
+ const sessionId = frameProps?.sessionId;
420
+ if (!domReady || !sessionId)
421
+ return;
422
+ const last = codeSyncedRef.current;
423
+ if (last && last.sessionId === sessionId && last.key === userCodeKey)
424
+ return;
425
+ if ((!last || last.sessionId !== sessionId) &&
426
+ userCode.length === 0 &&
427
+ !injectedAssetsRef.current.length) {
428
+ codeSyncedRef.current = { sessionId, key: userCodeKey };
429
+ return;
430
+ }
431
+ codeSyncedRef.current = { sessionId, key: userCodeKey };
432
+ void setCode(sessionId, [...injectedAssetsRef.current, ...userCode]).catch(error => setCommandError(describeCanvasError(error)));
433
+ }, [domReady, frameProps?.sessionId, userCodeKey]);
434
+ const hasUserCode = userCode.some(c => c.code.trim() !== '');
435
+ const [codePanelOpen, setCodePanelOpen] = useState(false);
371
436
  const insertComponent = useCallback(async (component) => {
372
437
  const target = selection?.chain[0];
373
438
  const sessionId = frameProps?.sessionId;
@@ -382,7 +447,10 @@ export function InlineEditor({ url, device, onDeviceChange, title, versionLabel
382
447
  const { html, codeList } = componentInsertPayload(component);
383
448
  if (codeList.length > 0) {
384
449
  injectedAssetsRef.current = [...injectedAssetsRef.current, ...codeList];
385
- await setCode(sessionId, injectedAssetsRef.current);
450
+ await setCode(sessionId, [
451
+ ...injectedAssetsRef.current,
452
+ ...userCodeRef.current
453
+ ]);
386
454
  }
387
455
  await writes.addContent({
388
456
  identifier: target.selector,
@@ -990,13 +1058,13 @@ export function InlineEditor({ url, device, onDeviceChange, title, versionLabel
990
1058
  }
991
1059
  }, [clipboard, commands, depsOf, frameProps?.sessionId, selection, t, uploadImage]);
992
1060
  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: [
1061
+ 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 + (hasUserCode ? 1 : 0), onExit: onExit, onSave: () => {
1062
+ if (switchingRef.current) {
1063
+ setCommandError(t('inlineCanvas.version.switching', '正在切换版本,请稍候'));
1064
+ return;
1065
+ }
1066
+ onSave?.(editor);
1067
+ }, 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
1068
  ['layers', '图层'],
1001
1069
  ['assets', '组件']
1002
1070
  ].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,15 +1075,42 @@ export function InlineEditor({ url, device, onDeviceChange, title, versionLabel
1007
1075
  targetIdentifier: targetSelector,
1008
1076
  targetSelector,
1009
1077
  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: [
1078
+ })) })) })] }) }), _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
1079
+ ? {
1080
+ active: interactive,
1081
+ disabled: !interactive && !canEnterInteractive,
1082
+ onToggle: () => interactive
1083
+ ? exitInteractive()
1084
+ : enterInteractive()
1085
+ }
1086
+ : undefined, code: onCodeChange
1087
+ ? {
1088
+ active: codePanelOpen,
1089
+ hasCode: hasUserCode,
1090
+ disabled: interactive,
1091
+ onToggle: () => setCodePanelOpen(v => !v)
1092
+ }
1093
+ : 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
1094
  'NO_EXTENSION',
1012
1095
  'EXTENSION_ID_MISSING',
1013
1096
  'CONTRACT_MISMATCH'
1014
1097
  ].includes(editor.session.reason ?? '')
1015
1098
  ? 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: () => {
1099
+ : 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', '返回修改') })] })), codePanelOpen && onCodeChange && !interactive && (_jsx(CodePanel, { code: userCode, onChange: next => onCodeChange(next, activeVersionId), onClose: () => setCodePanelOpen(false) }, activeVersionId ?? 'single')), 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
1100
  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: {
1101
+ } }, 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), customCode: hasUserCode
1102
+ ? {
1103
+ types: userCode
1104
+ .filter(c => c.code.trim() !== '')
1105
+ .map(c => c.type),
1106
+ onOpen: onCodeChange
1107
+ ? () => {
1108
+ setShowChangeList(false);
1109
+ setCodePanelOpen(true);
1110
+ }
1111
+ : undefined
1112
+ }
1113
+ : undefined }) })] }), !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
1114
  width: 'min(600px, calc(100vw - 32px))',
1020
1115
  left: `clamp(min(316px, 50vw), ${area.x + area.width / 2}px, max(calc(100vw - 316px), 50vw))`,
1021
1116
  top: `${area.y + 8}px`
@@ -1024,7 +1119,8 @@ export function InlineEditor({ url, device, onDeviceChange, title, versionLabel
1024
1119
  : 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
1120
  left: `${area.x + area.width / 2}px`,
1026
1121
  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 &&
1122
+ }, 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 &&
1123
+ !interactive &&
1028
1124
  !switching &&
1029
1125
  selectedRect &&
1030
1126
  self &&
@@ -19,5 +19,28 @@ 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
+ };
35
+ /**
36
+ * 「追加代码」开关(自定义 CSS / JS 面板),挨着交互模式。不传 = 没有这个按钮(对照组、只读画布)。
37
+ * `hasCode` 时按钮上带个点,提示这一版已经有代码 —— 面板关着时代码是看不见的。
38
+ */
39
+ code?: {
40
+ active: boolean;
41
+ hasCode: boolean;
42
+ disabled?: boolean;
43
+ onToggle: () => void;
44
+ };
22
45
  }
23
- export declare function CanvasContextBar({ url, device, onDeviceChange }: CanvasContextBarProps): import("react").JSX.Element;
46
+ export declare function CanvasContextBar({ url, device, onDeviceChange, deviceDisabled, interactive, code }: 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 { Code2, 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, code }) {
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))) }), code && (_jsxs(ChromeTextButton, { "data-testid": "canvas-code-toggle", className: "ml-auto h-7 gap-1.5 px-2 text-xs", active: code.active, disabled: code.disabled, title: t('inlineCanvas.code.desc', '可以追加 CSS / JavaScript 代码,更自由地编辑您的网页。'), onClick: code.onToggle, children: [_jsx(Code2, { className: "size-3.5" }), t('inlineCanvas.code.button', '追加代码'), code.hasCode && (_jsx("span", { "data-testid": "canvas-code-dot", "aria-hidden": true, className: "size-1.5 rounded-full bg-[#0052CC]" }))] })), interactive && (_jsxs(ChromeTextButton, { "data-testid": "canvas-interactive-toggle", className: `${code ? 'ml-1.5' : '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
  }
@@ -9,5 +9,13 @@ export interface ChangeListPanelProps {
9
9
  onSelect?: (selector: string) => void;
10
10
  /** 全部撤销。省略则不显示该入口。 */
11
11
  onRevertAll?: () => void;
12
+ /**
13
+ * 这一版有自定义代码时在清单顶上占一行(老编辑器同款,计数也 +1)。它不是一条 change:
14
+ * 不能单独撤销,点它打开代码面板(`onOpen` 不传则不可点)。
15
+ */
16
+ customCode?: {
17
+ types: Array<'css' | 'javascript'>;
18
+ onOpen?: () => void;
19
+ };
12
20
  }
13
- export declare function ChangeListPanel({ list, onRevert, lastError, onHover, onSelect, onRevertAll }: ChangeListPanelProps): import("react").JSX.Element;
21
+ export declare function ChangeListPanel({ list, onRevert, lastError, onHover, onSelect, onRevertAll, customCode }: ChangeListPanelProps): import("react").JSX.Element;
@@ -15,7 +15,7 @@ const ICONS = {
15
15
  move: Move,
16
16
  insert: Plus
17
17
  };
18
- export function ChangeListPanel({ list, onRevert, lastError, onHover, onSelect, onRevertAll }) {
18
+ export function ChangeListPanel({ list, onRevert, lastError, onHover, onSelect, onRevertAll, customCode }) {
19
19
  const [confirmRevert, setConfirmRevert] = useState(false);
20
20
  const [view, setView] = useState('summary');
21
21
  const entries = useMemo(() => list.map(r => ({ change: r.change, ancestors: r.ancestors })), [list]);
@@ -23,14 +23,16 @@ export function ChangeListPanel({ list, onRevert, lastError, onHover, onSelect,
23
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
24
  setConfirmRevert(false);
25
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: [
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 + (customCode ? 1 : 0) })] }), _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: [
27
27
  ['summary', '摘要', undefined],
28
28
  [
29
29
  'wire',
30
30
  '高级数据',
31
31
  '查看此版本的高级修改数据'
32
32
  ]
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 }))] }));
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 })), customCode && (_jsxs("button", { type: "button", "data-testid": "change-list-custom-code", disabled: !customCode.onOpen, onClick: customCode.onOpen, className: "flex shrink-0 items-center gap-2 border-b border-[#f1f2f3] px-3 py-2 text-left\n text-xs text-[#1a1a1a] enabled:hover:bg-[#f7f8f9]", children: [_jsx("span", { className: "font-medium", children: "\u81EA\u5B9A\u4E49\u4EE3\u7801" }), _jsx("span", { className: "text-[#8a8f98]", children: customCode.types
34
+ .map(t => (t === 'css' ? 'CSS' : 'JavaScript'))
35
+ .join(' / ') })] })), list.length === 0 && customCode ? null : 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 }))] }));
34
36
  }
35
37
  const SUPPRESSION_TEXT = {
36
38
  'ancestor-hidden': {
@@ -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,19 @@ 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
+ };
99
+ code: {
100
+ button: string;
101
+ title: string;
102
+ desc: string;
103
+ cssHint: string;
104
+ jsHint: string;
105
+ };
93
106
  version: {
94
107
  switching: string;
95
108
  };
@@ -271,6 +284,19 @@ export declare const enUS: {
271
284
  pc: string;
272
285
  mobile: string;
273
286
  };
287
+ interactive: {
288
+ enter: string;
289
+ tips: string;
290
+ active: string;
291
+ exit: string;
292
+ };
293
+ code: {
294
+ button: string;
295
+ title: string;
296
+ desc: string;
297
+ cssHint: string;
298
+ jsHint: string;
299
+ };
274
300
  version: {
275
301
  switching: string;
276
302
  };
@@ -452,6 +478,19 @@ export declare const jaJP: {
452
478
  pc: string;
453
479
  mobile: string;
454
480
  };
481
+ interactive: {
482
+ enter: string;
483
+ tips: string;
484
+ active: string;
485
+ exit: string;
486
+ };
487
+ code: {
488
+ button: string;
489
+ title: string;
490
+ desc: string;
491
+ cssHint: string;
492
+ jsHint: string;
493
+ };
455
494
  version: {
456
495
  switching: string;
457
496
  };
@@ -683,6 +722,19 @@ export declare const inlineEditorLocales: {
683
722
  pc: string;
684
723
  mobile: string;
685
724
  };
725
+ readonly interactive: {
726
+ enter: string;
727
+ tips: string;
728
+ active: string;
729
+ exit: string;
730
+ };
731
+ readonly code: {
732
+ button: string;
733
+ title: string;
734
+ desc: string;
735
+ cssHint: string;
736
+ jsHint: string;
737
+ };
686
738
  readonly version: {
687
739
  switching: string;
688
740
  };
@@ -913,6 +965,19 @@ export declare const inlineEditorLocales: {
913
965
  pc: string;
914
966
  mobile: string;
915
967
  };
968
+ readonly interactive: {
969
+ enter: string;
970
+ tips: string;
971
+ active: string;
972
+ exit: string;
973
+ };
974
+ readonly code: {
975
+ button: string;
976
+ title: string;
977
+ desc: string;
978
+ cssHint: string;
979
+ jsHint: string;
980
+ };
916
981
  readonly version: {
917
982
  switching: string;
918
983
  };
@@ -1143,6 +1208,19 @@ export declare const inlineEditorLocales: {
1143
1208
  pc: string;
1144
1209
  mobile: string;
1145
1210
  };
1211
+ readonly interactive: {
1212
+ enter: string;
1213
+ tips: string;
1214
+ active: string;
1215
+ exit: string;
1216
+ };
1217
+ readonly code: {
1218
+ button: string;
1219
+ title: string;
1220
+ desc: string;
1221
+ cssHint: string;
1222
+ jsHint: string;
1223
+ };
1146
1224
  readonly version: {
1147
1225
  switching: string;
1148
1226
  };
package/dist/locales.js CHANGED
@@ -80,6 +80,19 @@ export const zhCN = {
80
80
  pc: 'PC',
81
81
  mobile: '移动'
82
82
  },
83
+ interactive: {
84
+ enter: '交互模式',
85
+ tips: '切换到交互模式可以对页面进行交互,以修改需要交互后才能看到的内容。',
86
+ active: '您当前正在交互模式',
87
+ exit: '返回编辑模式'
88
+ },
89
+ code: {
90
+ button: '追加代码',
91
+ title: '自定义代码',
92
+ desc: '可以追加 CSS / JavaScript 代码,更自由地编辑您的网页。',
93
+ cssHint: 'CSS 会实时应用到画布。',
94
+ jsHint: 'JavaScript 不会在编辑器画布里执行,请通过预览或发布后查看效果。可使用 ptKit 工具函数(如 ptKit.query)等待元素出现。'
95
+ },
83
96
  version: {
84
97
  switching: '正在切换版本,请稍候'
85
98
  },
@@ -261,6 +274,19 @@ export const enUS = {
261
274
  pc: 'Desktop',
262
275
  mobile: 'Mobile'
263
276
  },
277
+ interactive: {
278
+ enter: 'Interactive mode',
279
+ tips: 'Switch to interactive mode to interact with the page like in a live website.',
280
+ active: 'You are now in interactive mode',
281
+ exit: 'Back to edit mode'
282
+ },
283
+ code: {
284
+ button: 'Custom code',
285
+ title: 'Custom code',
286
+ desc: 'Add CSS / JavaScript to customize your page more freely.',
287
+ cssHint: 'CSS is applied to the canvas in real time.',
288
+ jsHint: 'JavaScript does not run in the editor canvas. Use preview or publish to see its effect. ptKit helpers (e.g. ptKit.query) can wait for elements to appear.'
289
+ },
264
290
  version: {
265
291
  switching: 'Switching version, please wait'
266
292
  },
@@ -442,6 +468,19 @@ export const jaJP = {
442
468
  pc: 'PC',
443
469
  mobile: 'モバイル'
444
470
  },
471
+ interactive: {
472
+ enter: '画面操作モード',
473
+ tips: '画面操作できるようになります。表示されていない要素を開き、その後編集モードに切り替えることで隠れた要素を変更することが可能です。',
474
+ active: '現在、画面操作モードです',
475
+ exit: '編集モードに戻る'
476
+ },
477
+ code: {
478
+ button: 'コード追加',
479
+ title: 'カスタムコード',
480
+ desc: 'CSS / JavaScript を追加して、ページをより自由に編集できます。',
481
+ cssHint: 'CSS はキャンバスにリアルタイムで反映されます。',
482
+ jsHint: 'JavaScript はエディタのキャンバスでは実行されません。プレビューまたは公開後に効果を確認してください。ptKit のヘルパー(例: ptKit.query)で要素の表示を待つことができます。'
483
+ },
445
484
  version: {
446
485
  switching: 'バージョンを切り替えています。しばらくお待ちください'
447
486
  },
@@ -0,0 +1,20 @@
1
+ import type { ExperienceCode } from '@ptengine/inline-core/protocol';
2
+ /**
3
+ * 自定义代码面板(老编辑器的「追加代码」/ code mode):给当前版本追加 CSS 与 JavaScript。
4
+ *
5
+ * - **改即生效,没有「应用」按钮**(与老编辑器一致):输入停顿 `FLUSH_MS` 后把整份代码交给宿主,
6
+ * 宿主写回 `code` 属性,编辑器据此把 CSS 注入画布。关面板 / 换版本时立即交出未落的那一下。
7
+ * - JavaScript **只保存、画布里不执行**(老编辑器同样关掉了:编辑器里的页面环境不对、
8
+ * 客户页也没有 SDK 的 `ptKit`),预览或发布后才生效 —— 面板里说清楚。
9
+ * - 不进撤销历史(老编辑器也不进)。
10
+ *
11
+ * 编辑控件用包里现成的等宽文本框,不引代码编辑器库(体积);Tab 键插两个空格而不是跳走焦点。
12
+ */
13
+ export interface CodePanelProps {
14
+ code: ExperienceCode[];
15
+ onChange: (code: ExperienceCode[]) => void;
16
+ onClose: () => void;
17
+ }
18
+ /** 两段文本 → 存储形状。空白段不写(与后端 normalize 同口径)。 */
19
+ export declare function toExperienceCode(css: string, js: string): ExperienceCode[];
20
+ export declare function CodePanel({ code, onChange, onClose }: CodePanelProps): import("react").JSX.Element;
@@ -0,0 +1,89 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import { useTranslation } from 'react-i18next';
4
+ import { X } from 'lucide-react';
5
+ import { Button } from '@ptengine/design-components';
6
+ import { ChromeSegment, ChromeSegmented } from '../chrome/topbarPrimitives.js';
7
+ import { CodeField } from '../dialogs/CodeField.js';
8
+ const FLUSH_MS = 400;
9
+ function textOf(code, type) {
10
+ return code
11
+ .filter(c => c.type === type)
12
+ .map(c => c.code)
13
+ .join('\n');
14
+ }
15
+ export function toExperienceCode(css, js) {
16
+ const out = [];
17
+ if (css.trim())
18
+ out.push({ type: 'css', code: css });
19
+ if (js.trim())
20
+ out.push({ type: 'javascript', code: js });
21
+ return out;
22
+ }
23
+ export function CodePanel({ code, onChange, onClose }) {
24
+ const { t } = useTranslation();
25
+ const [tab, setTab] = useState('css');
26
+ const [draft, setDraft] = useState(() => ({
27
+ css: textOf(code, 'css'),
28
+ javascript: textOf(code, 'javascript')
29
+ }));
30
+ const draftRef = useRef(draft);
31
+ draftRef.current = draft;
32
+ const onChangeRef = useRef(onChange);
33
+ onChangeRef.current = onChange;
34
+ const timerRef = useRef(null);
35
+ const dirtyRef = useRef(false);
36
+ const flush = () => {
37
+ if (timerRef.current)
38
+ clearTimeout(timerRef.current);
39
+ timerRef.current = null;
40
+ if (!dirtyRef.current)
41
+ return;
42
+ dirtyRef.current = false;
43
+ onChangeRef.current(toExperienceCode(draftRef.current.css, draftRef.current.javascript));
44
+ };
45
+ useEffect(() => flush, []);
46
+ const cssProp = textOf(code, 'css');
47
+ const jsProp = textOf(code, 'javascript');
48
+ useEffect(() => {
49
+ if (dirtyRef.current)
50
+ return;
51
+ const cur = draftRef.current;
52
+ if (cur.css.trim() === cssProp.trim() && cur.javascript.trim() === jsProp.trim())
53
+ return;
54
+ const next = { css: cssProp, javascript: jsProp };
55
+ draftRef.current = next;
56
+ setDraft(next);
57
+ }, [cssProp, jsProp]);
58
+ const update = (type, value) => {
59
+ setDraft(prev => ({ ...prev, [type]: value }));
60
+ draftRef.current = { ...draftRef.current, [type]: value };
61
+ dirtyRef.current = true;
62
+ if (timerRef.current)
63
+ clearTimeout(timerRef.current);
64
+ timerRef.current = setTimeout(flush, FLUSH_MS);
65
+ };
66
+ const onKeyDown = (event) => {
67
+ if (event.key !== 'Tab' || event.shiftKey)
68
+ return;
69
+ event.preventDefault();
70
+ const el = event.currentTarget;
71
+ const { selectionStart, selectionEnd, value } = el;
72
+ const next = `${value.slice(0, selectionStart)} ${value.slice(selectionEnd)}`;
73
+ update(tab, next);
74
+ requestAnimationFrame(() => {
75
+ el.selectionStart = el.selectionEnd = selectionStart + 2;
76
+ });
77
+ };
78
+ return (_jsxs("aside", { "data-testid": "inline-editor-code-panel", className: "absolute bottom-3 right-3 top-3 z-30 flex w-[420px] max-w-[calc(100%-24px)] flex-col\n rounded-lg border border-[#EBECF0] bg-white shadow-[0_8px_24px_rgba(9,30,66,0.16)]", children: [_jsxs("header", { className: "flex h-11 shrink-0 items-center justify-between border-b border-[#f1f2f3] pl-3 pr-1.5", children: [_jsx("span", { className: "text-[13px] font-medium text-[#1a1a1a]", children: t('inlineCanvas.code.title', '自定义代码') }), _jsx(Button, { size: "sm", variant: "ghost", "data-testid": "inline-editor-code-close", "aria-label": t('inlineCanvas.chrome.close', '关闭'), onClick: () => {
79
+ flush();
80
+ onClose();
81
+ }, children: _jsx(X, { className: "size-4" }) })] }), _jsxs("div", { className: "flex min-h-0 flex-1 flex-col gap-2 p-3", children: [_jsx("p", { className: "text-xs leading-5 text-[#6B778C]", children: t('inlineCanvas.code.desc', '可以追加 CSS / JavaScript 代码,更自由地编辑您的网页。') }), _jsx(ChromeSegmented, { "data-testid": "inline-editor-code-tabs", className: "self-start", children: [
82
+ ['css', 'CSS'],
83
+ ['javascript', 'JavaScript']
84
+ ].map(([value, label]) => (_jsx(ChromeSegment, { "data-testid": `inline-editor-code-tab-${value}`, active: tab === value, onClick: () => setTab(value), children: label }, value))) }), _jsx(CodeField, { "data-testid": `inline-editor-code-input-${tab}`, "aria-label": tab === 'css' ? 'CSS' : 'JavaScript', className: "min-h-0 flex-1 resize-none", value: draft[tab], placeholder: tab === 'css'
85
+ ? '.hero-title { color: #e60012; }'
86
+ : "ptKit.query('.cta').then(function (el) { el.textContent = '…'; });", onChange: event => update(tab, event.target.value), onKeyDown: onKeyDown, onBlur: flush }, tab), _jsx("p", { "data-testid": "inline-editor-code-hint", className: "text-xs leading-5 text-[#6B778C]", children: tab === 'css'
87
+ ? t('inlineCanvas.code.cssHint', 'CSS 会实时应用到画布。')
88
+ : t('inlineCanvas.code.jsHint', 'JavaScript 不会在编辑器画布里执行,请通过预览或发布后查看效果。可使用 ptKit 工具函数(如 ptKit.query)等待元素出现。') })] })] }));
89
+ }
@@ -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.9",
4
4
  "description": "Ptengine Inline 编辑器:画布会话编排、图层树、属性面板与组件库(含内部 canvas-ui)",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,