@ptengine/inline-editor 0.1.0 → 0.1.1

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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1
4
+
5
+ 自 frontend-base `feat/dp/inline-v2` 搬来编辑器包的改动(源仓 develop 已删 `packages/inline-editor`,此后只在本仓改)。
6
+ 全部是新增,不传新 props 的宿主行为不变。
7
+
8
+ **新增**
9
+ - `InlineEditor` 可选 props `onAiInsertedRemoved` / `onAiInsertedRestored`(`useInlineEditor` 的同名输入、
10
+ `useChangeList(sessionId, { onInsertedRemoved, onInsertedRestored })` 同步透传):撤销(单条 / 整批 / 换版本)
11
+ 拿掉 AI 插入的元素时回报其 selector,并从包内的 AI 作用域剔除;回放(切版本 / 切设备)把它们放回画布时回报,
12
+ 并并回作用域。宿主据此同步 AI 的货架与授权票。
13
+ - `AiDescendant.aiInserted` / `AiDescendant.block`:`formatShelfRow` 在能力位之后写 ` [ai-inserted]`,行尾写区块身份 ` [B2 首屏 h:740]`。
14
+ - `AiRejectReason` 新增 `GARBLED_TEXT`(content / summary 含 U+FFFD,该条不落地)与 `RESOLVE_FAILED`
15
+ (selector 探测本身抛错,`detail` 带原文,应重试);真找不到仍是 `ELEMENTS_NOT_FOUND`,并带 `detail`。
16
+
3
17
  ## 0.1.0
4
18
 
5
19
  首个发布版。自 frontend-base(`cecb6de77`)迁入 `inline-editor`,其内部依赖的 `canvas-ui` 并入本包的内部目录(不单独发布)。
@@ -198,6 +198,13 @@ export interface InlineEditorProps {
198
198
  * 也不应该自己传 selector 进来 —— agent 编的 selector 正是这道闸要拦的东西。
199
199
  */
200
200
  onAiApplyReady?: (api: AiApplyApi | null) => void;
201
+ /**
202
+ * 用户撤销(单条 / 整批 / 换版本)拿掉了 AI 插入的元素 → 回报它们的 selector。宿主据此把这些行
203
+ * 从 AI 的货架 / 授权票里删掉;包自己同时把它们从作用域里剔除。没有插入类时不调用。
204
+ */
205
+ onAiInsertedRemoved?: (selectors: string[]) => void;
206
+ /** 回放把 AI 插入的元素放回画布(切版本 / 切设备)→ 回报它们的 selector;宿主把它们并回货架。 */
207
+ onAiInsertedRestored?: (selectors: string[]) => void;
201
208
  /**
202
209
  * 是否跟着选中实时刷新 AI 上下文。宿主在**右侧 AI 面板已打开**时给 true。
203
210
  *
@@ -339,4 +346,4 @@ export interface AiApplyApi {
339
346
  /** 当前版本 —— 宿主把 agent 给的版本**名**映射成 id 时要知道现在在哪一版。 */
340
347
  readonly activeVersionId?: string;
341
348
  }
342
- export declare function InlineEditor({ url, device, onDeviceChange, title, versionLabel, chrome, onExit, onSave, libraryComponents, libraryLoading, onAddVersion, onRenameVersion, onPreview, onToggleAi, aiOpen, uploadImage, initialChanges, onHydrated, onAskAi, onPageContextReady, anchors, onEditorReady, onSessionReady, onAiContextChange, onAiApplyReady, trackAiSelection, renderNodeIcon, versions, activeVersionId, versionChanges, onVersionChange, onVersionLeave, onVersionEnter, resolveChangeId, allowedImageOrigins, allowedImageUrls, allowedLinkUrls, viewportWidthOverride, blockOverlay }: InlineEditorProps): import("react").JSX.Element;
349
+ export declare function InlineEditor({ url, device, onDeviceChange, title, versionLabel, chrome, onExit, onSave, libraryComponents, libraryLoading, onAddVersion, onRenameVersion, onPreview, onToggleAi, aiOpen, uploadImage, initialChanges, onHydrated, onAskAi, onPageContextReady, anchors, onEditorReady, onSessionReady, onAiContextChange, onAiApplyReady, onAiInsertedRemoved, onAiInsertedRestored, trackAiSelection, renderNodeIcon, versions, activeVersionId, versionChanges, onVersionChange, onVersionLeave, onVersionEnter, resolveChangeId, allowedImageOrigins, allowedImageUrls, allowedLinkUrls, viewportWidthOverride, blockOverlay }: InlineEditorProps): import("react").JSX.Element;
@@ -50,11 +50,15 @@ function imageTargetOf(caps) {
50
50
  return branch === 'background' ? 'background' : undefined;
51
51
  }
52
52
  const AI_ELEMENT_IMAGE_LIMIT = 4;
53
- export function InlineEditor({ url, device, onDeviceChange, title, versionLabel = 'V1', chrome = 'full', onExit, onSave, libraryComponents, libraryLoading, onAddVersion, onRenameVersion, onPreview, onToggleAi, aiOpen, uploadImage, initialChanges, onHydrated, onAskAi, onPageContextReady, anchors, onEditorReady, onSessionReady, onAiContextChange, onAiApplyReady, trackAiSelection, renderNodeIcon, versions, activeVersionId, versionChanges, onVersionChange, onVersionLeave, onVersionEnter, resolveChangeId, allowedImageOrigins, allowedImageUrls, allowedLinkUrls, viewportWidthOverride, blockOverlay }) {
53
+ export function InlineEditor({ url, device, onDeviceChange, title, versionLabel = 'V1', chrome = 'full', onExit, onSave, libraryComponents, libraryLoading, onAddVersion, onRenameVersion, onPreview, onToggleAi, aiOpen, uploadImage, initialChanges, onHydrated, onAskAi, onPageContextReady, anchors, onEditorReady, onSessionReady, onAiContextChange, onAiApplyReady, onAiInsertedRemoved, onAiInsertedRestored, trackAiSelection, renderNodeIcon, versions, activeVersionId, versionChanges, onVersionChange, onVersionLeave, onVersionEnter, resolveChangeId, allowedImageOrigins, allowedImageUrls, allowedLinkUrls, viewportWidthOverride, blockOverlay }) {
54
54
  const { t } = useTranslation();
55
55
  const canvasAreaRef = useRef(null);
56
56
  const canvasIframeRef = useRef(null);
57
+ const insertedRemovedRef = useRef(null);
58
+ const insertedRestoredRef = useRef(null);
57
59
  const editor = useInlineEditor({
60
+ onInsertedRemoved: selectors => insertedRemovedRef.current?.(selectors),
61
+ onInsertedRestored: selectors => insertedRestoredRef.current?.(selectors),
58
62
  url,
59
63
  device,
60
64
  iframeRef: canvasIframeRef,
@@ -400,6 +404,21 @@ export function InlineEditor({ url, device, onDeviceChange, title, versionLabel
400
404
  const pageContextSessionRef = useRef(null);
401
405
  const rootShelfRef = useRef([]);
402
406
  const aiInsertedRef = useRef([]);
407
+ const onAiInsertedRemovedRef = useRef(onAiInsertedRemoved);
408
+ onAiInsertedRemovedRef.current = onAiInsertedRemoved;
409
+ insertedRemovedRef.current = selectors => {
410
+ const gone = new Set(selectors);
411
+ aiInsertedRef.current = aiInsertedRef.current.filter(s => !gone.has(s));
412
+ onAiInsertedRemovedRef.current?.(selectors);
413
+ };
414
+ const onAiInsertedRestoredRef = useRef(onAiInsertedRestored);
415
+ onAiInsertedRestoredRef.current = onAiInsertedRestored;
416
+ insertedRestoredRef.current = selectors => {
417
+ for (const sel of selectors)
418
+ if (!aiInsertedRef.current.includes(sel))
419
+ aiInsertedRef.current.push(sel);
420
+ onAiInsertedRestoredRef.current?.(selectors);
421
+ };
403
422
  const rootShelfAncestorsRef = useRef(new Map());
404
423
  const anchorsResultRef = useRef(null);
405
424
  const anchorsRef = useRef(anchors);
@@ -99,7 +99,11 @@ export interface AiWireChange {
99
99
  summary?: string;
100
100
  };
101
101
  }
102
- export type AiRejectReason = 'NOT_IN_WHITELIST' | 'INVALID_SHAPE' | 'OUT_OF_SCOPE' | 'ELEMENTS_NOT_FOUND' | 'AMBIGUOUS_SELECTOR' | 'INVALID_TEXT_CONTENT' | 'INVALID_STYLE_CONTENT'
102
+ export type AiRejectReason = 'NOT_IN_WHITELIST' | 'INVALID_SHAPE' | 'OUT_OF_SCOPE' | 'ELEMENTS_NOT_FOUND'
103
+ /** content / summary 里有替换字符 U+FFFD(模型吐坏的字):这一条不落地,让它只重发这一条。 */
104
+ | 'GARBLED_TEXT'
105
+ /** 探测 selector 时通道 / 会话出错(不是「找不到」):该重试,`detail` 带原文。 */
106
+ | 'RESOLVE_FAILED' | 'AMBIGUOUS_SELECTOR' | 'INVALID_TEXT_CONTENT' | 'INVALID_STYLE_CONTENT'
103
107
  /** 新增元素那段 HTML 没过闸(标签/属性/取值/体量),detail 里是具体原因。 */
104
108
  | 'INVALID_HTML_CONTENT'
105
109
  /** mode 不在该 op 的词表里(两套词表容易串)。 */
@@ -146,16 +146,32 @@ export async function applyAiChanges(changes, deps) {
146
146
  continue;
147
147
  }
148
148
  const grantHit = !inLiveScope && grant ? grant : null;
149
+ const garbledText = (typeof change.content === 'string' && change.content.includes('\uFFFD')) ||
150
+ (typeof change.rationale?.summary === 'string' && change.rationale.summary.includes('\uFFFD'));
151
+ if (garbledText) {
152
+ rejected.push({ index, selector, reason: 'GARBLED_TEXT', detail: 'replacement character U+FFFD in text' });
153
+ continue;
154
+ }
149
155
  let hit;
150
156
  try {
151
157
  hit = await deps.resolve(selector);
152
158
  }
153
- catch {
154
- rejected.push({ index, selector, reason: 'ELEMENTS_NOT_FOUND' });
159
+ catch (e) {
160
+ rejected.push({
161
+ index,
162
+ selector,
163
+ reason: 'RESOLVE_FAILED',
164
+ detail: String(e?.message ?? e)
165
+ });
155
166
  continue;
156
167
  }
157
168
  if (!hit.matched || hit.matchCount === 0) {
158
- rejected.push({ index, selector, reason: 'ELEMENTS_NOT_FOUND' });
169
+ rejected.push({
170
+ index,
171
+ selector,
172
+ reason: 'ELEMENTS_NOT_FOUND',
173
+ detail: 'no match on the canvas (reverted or page changed)'
174
+ });
159
175
  continue;
160
176
  }
161
177
  if (hit.matchCount > 1) {
@@ -101,7 +101,22 @@ export interface HydrateOutcome {
101
101
  reason: string;
102
102
  }>;
103
103
  }
104
- export declare function useChangeList(sessionId: string | undefined): UseChangeListResult;
104
+ export interface UseChangeListOptions {
105
+ /**
106
+ * 撤销(单条 / 整批 / 换版本)拿掉了插入类 change 时,回报它插入过的元素 selector。
107
+ *
108
+ * 宿主据此把这些行从 AI 的货架 / 授权票里删掉:此前只追加不删,用户撤销了模块、模型下一轮照着
109
+ * 货架去改一个已经不在的元素(真机 2026-10-16,线程 a303483f)。没有插入类时不调用。
110
+ */
111
+ onInsertedRemoved?: (selectors: string[]) => void;
112
+ /**
113
+ * 回放(切版本 / 切设备 / 开编辑器)把带 `insertedSelectors` 的记录放回画布时,回报那些 selector。
114
+ * 与 `onInsertedRemoved` 成对:切 A→B→A 时 A 的模块先被报「拿掉」、回放时再报「回来了」,
115
+ * 否则它掉出 AI 的作用域(复审 2026-10-19)。
116
+ */
117
+ onInsertedRestored?: (selectors: string[]) => void;
118
+ }
119
+ export declare function useChangeList(sessionId: string | undefined, options?: UseChangeListOptions): UseChangeListResult;
105
120
  export interface ButtonActionDeps {
106
121
  identifier: string;
107
122
  selector: string;
@@ -14,11 +14,25 @@ function withResolvedStyleMode(change, applied) {
14
14
  ? { ...change, ...applied.resolvedStyleMode }
15
15
  : change;
16
16
  }
17
- export function useChangeList(sessionId) {
17
+ export function useChangeList(sessionId, options = {}) {
18
18
  const [list, setList] = useState([]);
19
19
  const [lastError, setLastError] = useState(null);
20
20
  const listRef = useRef([]);
21
21
  listRef.current = list;
22
+ const onInsertedRemovedRef = useRef(options.onInsertedRemoved);
23
+ onInsertedRemovedRef.current = options.onInsertedRemoved;
24
+ const onInsertedRestoredRef = useRef(options.onInsertedRestored);
25
+ onInsertedRestoredRef.current = options.onInsertedRestored;
26
+ const reportRemoved = useCallback((records) => {
27
+ const gone = records.flatMap(r => r.insertedSelectors ?? []);
28
+ if (gone.length)
29
+ onInsertedRemovedRef.current?.(gone);
30
+ }, []);
31
+ const reportRestored = useCallback((records) => {
32
+ const back = records.flatMap(r => r.insertedSelectors ?? []);
33
+ if (back.length)
34
+ onInsertedRestoredRef.current?.(back);
35
+ }, []);
22
36
  const push = useCallback(async (change, ancestors = []) => {
23
37
  if (!sessionId)
24
38
  throw new Error('no canvas session');
@@ -73,14 +87,18 @@ export function useChangeList(sessionId) {
73
87
  records.push({
74
88
  changeId,
75
89
  change: withResolvedStyleMode(change, applied),
76
- ancestors: []
90
+ ancestors: [],
91
+ ...(applied?.insertedSelectors?.length
92
+ ? { insertedSelectors: applied.insertedSelectors }
93
+ : {})
77
94
  });
78
95
  }
79
96
  listRef.current = records;
80
97
  setList(records);
81
98
  setLastError(null);
99
+ reportRestored(records);
82
100
  return { applied: records.length - failed.length, failed };
83
- }, [sessionId]);
101
+ }, [sessionId, reportRestored]);
84
102
  const guard = useCallback(async (run) => {
85
103
  try {
86
104
  return await run();
@@ -99,19 +117,23 @@ export function useChangeList(sessionId) {
99
117
  if (!sessionId)
100
118
  throw new Error('no canvas session');
101
119
  await revertChange(sessionId, changeId);
120
+ const removed = listRef.current.filter(r => r.changeId === changeId);
102
121
  const next = listRef.current.filter(r => r.changeId !== changeId);
103
122
  listRef.current = next;
104
123
  setList(next);
105
- }), [guard, sessionId]);
124
+ reportRemoved(removed);
125
+ }), [guard, sessionId, reportRemoved]);
106
126
  const revertAll = useCallback(() => guard(async () => {
107
127
  if (!sessionId)
108
128
  throw new Error('no canvas session');
109
- for (const record of [...listRef.current].reverse()) {
129
+ const reverted = listRef.current;
130
+ for (const record of [...reverted].reverse()) {
110
131
  await revertChange(sessionId, record.changeId).catch(() => { });
111
132
  }
112
133
  listRef.current = [];
113
134
  setList([]);
114
- }), [guard, sessionId]);
135
+ reportRemoved(reverted);
136
+ }), [guard, sessionId, reportRemoved]);
115
137
  const replaceAll = useCallback(async (next, prevIds) => {
116
138
  if (!sessionId)
117
139
  throw new Error('no canvas session');
@@ -124,6 +146,7 @@ export function useChangeList(sessionId) {
124
146
  catch (e) {
125
147
  listRef.current = previous;
126
148
  setList(previous);
149
+ reportRestored(previous);
127
150
  throw e;
128
151
  }
129
152
  const nextRecords = listRef.current;
@@ -64,6 +64,17 @@ export interface AiDescendant {
64
64
  * (`formatShelfRow`),不进授权判断。顶层行不写这个键(条件写入,与 `image` 同一条纪律)。
65
65
  */
66
66
  depth?: number;
67
+ /**
68
+ * 这一行是 AI 本会话插入的元素(宿主按回执的 `insertedSelectors` 追加进货架)。货架上它没有别的
69
+ * 信息,至少让模型知道它是新的;用户撤销后宿主会把它从清单里拿掉。写成行尾的 `[ai-inserted]`。
70
+ */
71
+ aiInserted?: boolean;
72
+ /**
73
+ * 区块身份(宿主按页面模型的 member 容器打的,如 `B2 首屏 h:740`),写成行尾的 `[…]`。
74
+ * 没选中元素时「首屏下面」要有确定的锚:同一 `top`、同一段文字的包装行常有十几个,没有它
75
+ * 「最外层容器」会被选成整页包装层(真机 2026-10-16)。只有恰好是区块容器的那一行才有。
76
+ */
77
+ block?: string;
67
78
  }
68
79
  /**
69
80
  * 区块级货架取树的口径(`<page-block>`、方案锚点子树、选中元素货架三处共用)。
@@ -15,5 +15,7 @@ export function formatShelfRow(d) {
15
15
  const tag = d.tag ? ` <${d.tag}>` : '';
16
16
  const pos = typeof d.top === 'number' ? ` top:${Math.round(d.top)}` : '';
17
17
  const label = d.text || d.heading;
18
- return ` ${indent}- ${d.selector}${tag}${d.hasChildren ? ' [container]' : ''}${imageTag(d)}${linkTag(d)}${pos}${label ? ` ${quote(label, SHELF_TEXT_MAX)}` : ''}`;
18
+ const inserted = d.aiInserted ? ' [ai-inserted]' : '';
19
+ const block = d.block ? ` [${d.block}]` : '';
20
+ return ` ${indent}- ${d.selector}${tag}${d.hasChildren ? ' [container]' : ''}${imageTag(d)}${linkTag(d)}${inserted}${pos}${label ? ` ${quote(label, SHELF_TEXT_MAX)}` : ''}${block}`;
19
21
  }
@@ -53,6 +53,9 @@ export interface UseInlineEditorInput {
53
53
  iframeRef?: React.MutableRefObject<HTMLIFrameElement | null>;
54
54
  /** 图层树行首图标渲染器(宿主注入,见 `adaptDomTree`)。 */
55
55
  renderNodeIcon?: AdaptDomTreeOptions['renderIcon'];
56
+ /** 撤销拿掉了插入类 change → 回报它插入过的元素(见 `UseChangeListOptions.onInsertedRemoved`)。 */
57
+ onInsertedRemoved?: (selectors: string[]) => void;
58
+ onInsertedRestored?: (selectors: string[]) => void;
56
59
  /**
57
60
  * 是否在 `domReady` 后拉取页面结构树(图层面板的数据源)。默认 `true`。
58
61
  *
@@ -55,7 +55,10 @@ export function useInlineEditor(input) {
55
55
  onStreamEvent: stream.onStreamEvent
56
56
  });
57
57
  const sessionId = session.frameProps?.sessionId;
58
- const changeList = useChangeList(sessionId);
58
+ const changeList = useChangeList(sessionId, {
59
+ onInsertedRemoved: input.onInsertedRemoved,
60
+ onInsertedRestored: input.onInsertedRestored
61
+ });
59
62
  changeListRef.current = changeList;
60
63
  const restore = useSelectionRestore();
61
64
  const domReady = readySessionId !== null && readySessionId === sessionId;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ptengine/inline-editor",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Ptengine Inline 编辑器:画布会话编排、图层树、属性面板与组件库(含内部 canvas-ui)",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,