@ptengine/inline-editor 0.1.0 → 0.1.2

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,32 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.2
4
+
5
+ 全部是新增:扩展不支持契约 v11 `feature:node-fields`(取树不回 `facts`)时,货架行与原来逐字一致。
6
+
7
+ **新增**
8
+ - AI 货架行标出父级横排:`AiDescendant.inRow`(`{ index, count }`),`formatShelfRow` 在引用文本之后、区块身份之前写
9
+ ` [横排 i/n]`(与 frontend-base `<page-block>` 的行尾逐字一致)。父级是 flex 行(不换行;换行的要孩子同一行)或多列 grid
10
+ 时才写 —— `after` 这一行会排到它旁边而不是下方。对整棵树判:父级常是货架跳过的包装层(PageFly `.pf-c`)。
11
+ `buildDescendantShelf(WithPaths)` / `buildRootShelf` / `resolveInlinePlanAnchors` 都会写。
12
+ - 编辑器给 AI 取树的三处(选中元素子树、整页、锚点子树)请求 `SHELF_LAYOUT_FIELDS`(display / flexDirection / flexWrap /
13
+ gridTemplateColumns / gridAutoFlow 的计算样式);图层面板取树不变。
14
+ - 导出 `SHELF_LAYOUT_FIELDS` / `laysOutInRow` / `rowPositions` / `rowTag` 与类型 `RowPosition`,宿主自己拼货架时可同口径调用。
15
+
16
+ ## 0.1.1
17
+
18
+ 自 frontend-base `feat/dp/inline-v2` 搬来编辑器包的改动(源仓 develop 已删 `packages/inline-editor`,此后只在本仓改)。
19
+ 全部是新增,不传新 props 的宿主行为不变。
20
+
21
+ **新增**
22
+ - `InlineEditor` 可选 props `onAiInsertedRemoved` / `onAiInsertedRestored`(`useInlineEditor` 的同名输入、
23
+ `useChangeList(sessionId, { onInsertedRemoved, onInsertedRestored })` 同步透传):撤销(单条 / 整批 / 换版本)
24
+ 拿掉 AI 插入的元素时回报其 selector,并从包内的 AI 作用域剔除;回放(切版本 / 切设备)把它们放回画布时回报,
25
+ 并并回作用域。宿主据此同步 AI 的货架与授权票。
26
+ - `AiDescendant.aiInserted` / `AiDescendant.block`:`formatShelfRow` 在能力位之后写 ` [ai-inserted]`,行尾写区块身份 ` [B2 首屏 h:740]`。
27
+ - `AiRejectReason` 新增 `GARBLED_TEXT`(content / summary 含 U+FFFD,该条不落地)与 `RESOLVE_FAILED`
28
+ (selector 探测本身抛错,`detail` 带原文,应重试);真找不到仍是 `ELEMENTS_NOT_FOUND`,并带 `detail`。
29
+
3
30
  ## 0.1.0
4
31
 
5
32
  首个发布版。自 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;
@@ -17,6 +17,7 @@ import { getAncestorChain, getDomTree, getElementCapabilities, getElementImages,
17
17
  import { useAiSelectionContext } from './kernel/selection/useAiSelectionContext.js';
18
18
  import { buildDescendantShelf } from './kernel/selection/buildDescendantShelf.js';
19
19
  import { buildRootShelf } from './kernel/selection/buildRootShelf.js';
20
+ import { SHELF_LAYOUT_FIELDS } from './kernel/selection/rowLayout.js';
20
21
  import { backgroundImageAncestorFor, blockContainerFor, resolveInlinePlanAnchors, withImageRows } from './kernel/selection/resolveAnchors.js';
21
22
  import { elementGeometryForAi } from './kernel/selection/elementGeometryForAi.js';
22
23
  import { applyAiChanges } from './kernel/ai/aiInlineApply.js';
@@ -50,11 +51,15 @@ function imageTargetOf(caps) {
50
51
  return branch === 'background' ? 'background' : undefined;
51
52
  }
52
53
  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 }) {
54
+ 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
55
  const { t } = useTranslation();
55
56
  const canvasAreaRef = useRef(null);
56
57
  const canvasIframeRef = useRef(null);
58
+ const insertedRemovedRef = useRef(null);
59
+ const insertedRestoredRef = useRef(null);
57
60
  const editor = useInlineEditor({
61
+ onInsertedRemoved: selectors => insertedRemovedRef.current?.(selectors),
62
+ onInsertedRestored: selectors => insertedRestoredRef.current?.(selectors),
58
63
  url,
59
64
  device,
60
65
  iframeRef: canvasIframeRef,
@@ -367,7 +372,8 @@ export function InlineEditor({ url, device, onDeviceChange, title, versionLabel
367
372
  maxChildren: 20,
368
373
  maxNodes: SHELF_TREE_MAX_NODES,
369
374
  visibleOnly: true,
370
- includeRect: true
375
+ includeRect: true,
376
+ fields: [...SHELF_LAYOUT_FIELDS]
371
377
  })
372
378
  .then(root => {
373
379
  if (!stale)
@@ -400,6 +406,21 @@ export function InlineEditor({ url, device, onDeviceChange, title, versionLabel
400
406
  const pageContextSessionRef = useRef(null);
401
407
  const rootShelfRef = useRef([]);
402
408
  const aiInsertedRef = useRef([]);
409
+ const onAiInsertedRemovedRef = useRef(onAiInsertedRemoved);
410
+ onAiInsertedRemovedRef.current = onAiInsertedRemoved;
411
+ insertedRemovedRef.current = selectors => {
412
+ const gone = new Set(selectors);
413
+ aiInsertedRef.current = aiInsertedRef.current.filter(s => !gone.has(s));
414
+ onAiInsertedRemovedRef.current?.(selectors);
415
+ };
416
+ const onAiInsertedRestoredRef = useRef(onAiInsertedRestored);
417
+ onAiInsertedRestoredRef.current = onAiInsertedRestored;
418
+ insertedRestoredRef.current = selectors => {
419
+ for (const sel of selectors)
420
+ if (!aiInsertedRef.current.includes(sel))
421
+ aiInsertedRef.current.push(sel);
422
+ onAiInsertedRestoredRef.current?.(selectors);
423
+ };
403
424
  const rootShelfAncestorsRef = useRef(new Map());
404
425
  const anchorsResultRef = useRef(null);
405
426
  const anchorsRef = useRef(anchors);
@@ -428,7 +449,8 @@ export function InlineEditor({ url, device, onDeviceChange, title, versionLabel
428
449
  maxChildren: 40,
429
450
  maxNodes: 1500,
430
451
  visibleOnly: true,
431
- includeRect: true
452
+ includeRect: true,
453
+ fields: [...SHELF_LAYOUT_FIELDS]
432
454
  });
433
455
  const { html } = await getOuterHtml(sessionIdForAiContext, 'body');
434
456
  if (stale)
@@ -452,7 +474,8 @@ export function InlineEditor({ url, device, onDeviceChange, title, versionLabel
452
474
  maxChildren: 20,
453
475
  maxNodes: SHELF_TREE_MAX_NODES,
454
476
  visibleOnly: true,
455
- includeRect: true
477
+ includeRect: true,
478
+ fields: [...SHELF_LAYOUT_FIELDS]
456
479
  })
457
480
  })
458
481
  : null;
package/dist/index.d.ts CHANGED
@@ -74,7 +74,9 @@ export type { ElementAiContext, AiDescendant, AiHtmlFragment, AiPageImage, Ancho
74
74
  export { resolveInlinePlanAnchors } from './kernel/selection/resolveAnchors.js';
75
75
  export type { InlinePlanAnchor, ResolvedAnchor, AnchorShelfResult } from './kernel/selection/resolveAnchors.js';
76
76
  export { buildDescendantShelf } from './kernel/selection/buildDescendantShelf.js';
77
- export { formatShelfRow, imageTag, linkTag, SHELF_TEXT_MAX } from './kernel/selection/shelfRow.js';
77
+ export { formatShelfRow, imageTag, linkTag, rowTag, SHELF_TEXT_MAX } from './kernel/selection/shelfRow.js';
78
+ export { SHELF_LAYOUT_FIELDS, laysOutInRow, rowPositions } from './kernel/selection/rowLayout.js';
79
+ export type { RowPosition } from './kernel/selection/rowLayout.js';
78
80
  export { linkPath } from './kernel/linkHref.js';
79
81
  export { buildRootShelf } from './kernel/selection/buildRootShelf.js';
80
82
  export { LayersPanel } from './tree/LayersPanel.js';
package/dist/index.js CHANGED
@@ -36,7 +36,8 @@ export { exportChangesForSave } from './kernel/changelist/exportForSave.js';
36
36
  export { buildElementAiContext, truncateHtml, AI_HTML_BUDGET_BYTES, AI_DESCENDANT_LIMIT, SHELF_TREE_MAX_DEPTH, SHELF_TREE_MAX_NODES, ROOT_SHELF_LIMIT, composeAnchorHtml, extractPageLinks, linksFragment } from './kernel/selection/elementAiContext.js';
37
37
  export { resolveInlinePlanAnchors } from './kernel/selection/resolveAnchors.js';
38
38
  export { buildDescendantShelf } from './kernel/selection/buildDescendantShelf.js';
39
- export { formatShelfRow, imageTag, linkTag, SHELF_TEXT_MAX } from './kernel/selection/shelfRow.js';
39
+ export { formatShelfRow, imageTag, linkTag, rowTag, SHELF_TEXT_MAX } from './kernel/selection/shelfRow.js';
40
+ export { SHELF_LAYOUT_FIELDS, laysOutInRow, rowPositions } from './kernel/selection/rowLayout.js';
40
41
  export { linkPath } from './kernel/linkHref.js';
41
42
  export { buildRootShelf } from './kernel/selection/buildRootShelf.js';
42
43
  export { LayersPanel } from './tree/LayersPanel.js';
@@ -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;
@@ -1,5 +1,6 @@
1
1
  import '../linkHref.js';
2
2
  import { AI_DESCENDANT_LIMIT, AI_DESCENDANT_TEXT_CHARS } from './elementAiContext.js';
3
+ import { rowPositions } from './rowLayout.js';
3
4
  const NOISE_TAGS = new Set(['br', 'wbr', 'hr', 'script', 'style', 'noscript', 'template', 'link', 'meta']);
4
5
  const INTERACTIVE_TAGS = new Set(['a', 'button', 'input', 'select', 'textarea', 'label', 'summary', 'details', 'form']);
5
6
  function hasChildrenOf(node) {
@@ -23,6 +24,7 @@ export function buildDescendantShelf(root, limit = AI_DESCENDANT_LIMIT) {
23
24
  export function buildDescendantShelfWithPaths(root, limit = AI_DESCENDANT_LIMIT) {
24
25
  const shelf = [];
25
26
  const ancestorsOf = new Map();
27
+ const inRowOf = rowPositions(root);
26
28
  const queue = root.children.map(node => ({ node, ancestors: [root.selector], depth: 0 }));
27
29
  while (queue.length && shelf.length < limit) {
28
30
  const { node, ancestors, depth } = queue.shift();
@@ -34,6 +36,7 @@ export function buildDescendantShelfWithPaths(root, limit = AI_DESCENDANT_LIMIT)
34
36
  continue;
35
37
  }
36
38
  const text = node.textSummary.trim().slice(0, AI_DESCENDANT_TEXT_CHARS);
39
+ const inRow = inRowOf.get(node.selector);
37
40
  shelf.push({
38
41
  selector: node.selector,
39
42
  tag: node.tag,
@@ -41,6 +44,7 @@ export function buildDescendantShelfWithPaths(root, limit = AI_DESCENDANT_LIMIT)
41
44
  hasChildren: hasChildrenOf(node),
42
45
  ...(node.image ? { image: node.image } : {}),
43
46
  ...(node.href ? { href: node.href } : {}),
47
+ ...(inRow ? { inRow } : {}),
44
48
  ...(depth > 0 ? { depth } : {})
45
49
  });
46
50
  ancestorsOf.set(node.selector, ancestors);
@@ -1,5 +1,6 @@
1
1
  import { buildDescendantShelfWithPaths } from './buildDescendantShelf.js';
2
2
  import { AI_DESCENDANT_TEXT_CHARS } from './elementAiContext.js';
3
+ import { rowPositions } from './rowLayout.js';
3
4
  const LANDMARK_MIN_HEIGHT = 120;
4
5
  const LANDMARK_MIN_WIDTH_RATIO = 0.5;
5
6
  const DEFAULT_VIEWPORT_WIDTH = 375;
@@ -77,6 +78,7 @@ export function buildRootShelf(root, opts) {
77
78
  const dt = a.node.rect.top - b.node.rect.top;
78
79
  return dt !== 0 ? dt : a.order - b.order;
79
80
  });
81
+ const inRowOf = rowPositions(root);
80
82
  const shelf = [];
81
83
  const ancestorsOf = new Map();
82
84
  const emittedSelectors = new Set();
@@ -102,6 +104,9 @@ export function buildRootShelf(root, opts) {
102
104
  row.top = n.rect.top;
103
105
  if (n.image)
104
106
  row.image = n.image;
107
+ const inRow = inRowOf.get(n.selector);
108
+ if (inRow)
109
+ row.inRow = inRow;
105
110
  shelf.push(row);
106
111
  ancestorsOf.set(n.selector, e.ancestors);
107
112
  emittedSelectors.add(n.selector);
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import type { CanvasDevice } from '@ptengine/inline-core/protocol';
8
8
  import type { ResolvedAnchor } from './resolveAnchors.js';
9
+ import type { RowPosition } from './rowLayout.js';
9
10
  /** outerHTML 进 prompt 的预算。 */
10
11
  export declare const AI_HTML_BUDGET_BYTES = 4096;
11
12
  /**
@@ -64,6 +65,23 @@ export interface AiDescendant {
64
65
  * (`formatShelfRow`),不进授权判断。顶层行不写这个键(条件写入,与 `image` 同一条纪律)。
65
66
  */
66
67
  depth?: number;
68
+ /**
69
+ * 这一行是 AI 本会话插入的元素(宿主按回执的 `insertedSelectors` 追加进货架)。货架上它没有别的
70
+ * 信息,至少让模型知道它是新的;用户撤销后宿主会把它从清单里拿掉。写成行尾的 `[ai-inserted]`。
71
+ */
72
+ aiInserted?: boolean;
73
+ /**
74
+ * 这一行在**父级横排**里的位次(父级是 flex 行或多列 grid,见 `rowPositions`):`after` 它会排到
75
+ * 它旁边,不是下方。来自树节点的排法事实(取树时请求 `SHELF_LAYOUT_FIELDS`);父级竖排、或老扩展
76
+ * 没给事实时没有这个键。写成引用文本之后的 `[横排 i/n]`。
77
+ */
78
+ inRow?: RowPosition;
79
+ /**
80
+ * 区块身份(宿主按页面模型的 member 容器打的,如 `B2 首屏 h:740`),写成行尾的 `[…]`。
81
+ * 没选中元素时「首屏下面」要有确定的锚:同一 `top`、同一段文字的包装行常有十几个,没有它
82
+ * 「最外层容器」会被选成整页包装层(真机 2026-10-16)。只有恰好是区块容器的那一行才有。
83
+ */
84
+ block?: string;
67
85
  }
68
86
  /**
69
87
  * 区块级货架取树的口径(`<page-block>`、方案锚点子树、选中元素货架三处共用)。
@@ -1,4 +1,5 @@
1
1
  import { buildDescendantShelfWithPaths } from './buildDescendantShelf.js';
2
+ import { rowPositions } from './rowLayout.js';
2
3
  import { AI_DESCENDANT_LIMIT, AI_DESCENDANT_TEXT_CHARS } from './elementAiContext.js';
3
4
  export function blockContainerFor(anchor, shelf, ancestorsOf) {
4
5
  const isBlockRow = (selector) => {
@@ -116,6 +117,7 @@ export async function resolveInlinePlanAnchors(input) {
116
117
  const outcomes = [];
117
118
  const seen = new Set();
118
119
  const rootAncestors = indexAncestors(rootTree);
120
+ const rootInRow = rowPositions(rootTree);
119
121
  const push = (row, chain) => {
120
122
  if (seen.has(row.selector))
121
123
  return;
@@ -148,6 +150,8 @@ export async function resolveInlinePlanAnchors(input) {
148
150
  }
149
151
  outcomes.push({ ...anchor, resolved: true, via });
150
152
  const ownAncestors = rootAncestors.get(resolvedSelector) ?? [];
153
+ const ownInRow = rootInRow.get(resolvedSelector);
154
+ const withOwnInRow = (row) => ownInRow ? { ...row, inRow: ownInRow } : row;
151
155
  let subtree = null;
152
156
  try {
153
157
  subtree = await getSubtree(resolvedSelector);
@@ -155,11 +159,11 @@ export async function resolveInlinePlanAnchors(input) {
155
159
  catch {
156
160
  }
157
161
  if (!subtree) {
158
- push({ selector: resolvedSelector, tag: '', text: '', hasChildren: true }, ownAncestors);
162
+ push(withOwnInRow({ selector: resolvedSelector, tag: '', text: '', hasChildren: true }), ownAncestors);
159
163
  continue;
160
164
  }
161
165
  const anchorSelector = subtree.selector || resolvedSelector;
162
- push(rowOf(subtree), ownAncestors);
166
+ push(withOwnInRow(rowOf(subtree)), ownAncestors);
163
167
  const { shelf: rows, ancestorsOf: within } = buildDescendantShelfWithPaths(subtree, limitPerAnchor);
164
168
  for (const row of rows) {
165
169
  const chainWithin = within.get(row.selector) ?? [];
@@ -0,0 +1,31 @@
1
+ /**
2
+ * 「父级横着摆孩子吗」—— 货架行上的 ` [横排 i/n]`(`rowTag`)就从这里来。
3
+ *
4
+ * `after` X 落在 X 的**父级**怎么摆孩子的地方:父级横排就排到 X 右边。货架此前只有缩进,看不出
5
+ * 父级怎么排 —— 真机 2026-09-30 / 10-16 / 10-19 三次,模型锚了 PageFly `.pf-c`(flex 不换行)里
6
+ * 唯一的 Column,「下方加一行」落到文案右边、把文案挤窄;同一页另一次碰巧锚了 Column 里的 h2 就对了。
7
+ *
8
+ * 口径与 frontend-base `apps/x/src/stores/pageModel.ts` 的 `laysOutInRow` / `rowMarkIndex`
9
+ * (63112d497a,`<page-block>` 那份)逐字一致:同一个元素在 `<page-block>` 与 `<page-shelf>` /
10
+ * `<page-element>` 里必须打出同一个标记。那份以后可改成调用这里。
11
+ */
12
+ import type { DomTreeNode, NodeField } from '@ptengine/inline-core/protocol';
13
+ /**
14
+ * 判排法要的计算样式(契约 v11 `feature:node-fields`,挂在节点的 `facts.computed` 上)。货架取树时
15
+ * 随 `fields` 一起请求;老扩展不认 `fields`、静默忽略 —— 树上没有 facts,行不打标记,与原来逐字一致。
16
+ */
17
+ export declare const SHELF_LAYOUT_FIELDS: readonly NodeField[];
18
+ /** 在父级横排里的位次:第 `index` 项(从 1 起)/ 共 `count` 项。 */
19
+ export interface RowPosition {
20
+ index: number;
21
+ count: number;
22
+ }
23
+ /**
24
+ * 树上每个节点 → 它在父级横排里的位次;父级竖排、或树上没有排法事实的不进表。
25
+ *
26
+ * 父级可能是货架上跳过的包装层(`.pf-c` 正是),所以对着**整棵树**判,不对着货架行判。
27
+ * 根自己的父级不在树里,判不了横竖,不进表。
28
+ */
29
+ export declare function rowPositions(root: DomTreeNode): Map<string, RowPosition>;
30
+ /** 这个节点把孩子横着摆吗:flex 行(不换行;换行的要孩子真排在同一行)或多列 grid。 */
31
+ export declare function laysOutInRow(n: DomTreeNode): boolean;
@@ -0,0 +1,41 @@
1
+ export const SHELF_LAYOUT_FIELDS = [
2
+ 'computed:display',
3
+ 'computed:flexDirection',
4
+ 'computed:flexWrap',
5
+ 'computed:gridTemplateColumns',
6
+ 'computed:gridAutoFlow'
7
+ ];
8
+ export function rowPositions(root) {
9
+ const positions = new Map();
10
+ const walk = (n) => {
11
+ if (laysOutInRow(n)) {
12
+ n.children.forEach((c, i) => positions.set(c.selector, { index: i + 1, count: n.children.length }));
13
+ }
14
+ n.children.forEach(walk);
15
+ };
16
+ walk(root);
17
+ return positions;
18
+ }
19
+ export function laysOutInRow(n) {
20
+ const c = n.facts?.computed;
21
+ if (!c)
22
+ return false;
23
+ if (c.display === 'flex' || c.display === 'inline-flex') {
24
+ if (c.flexDirection && !c.flexDirection.startsWith('row'))
25
+ return false;
26
+ if (c.flexWrap !== 'wrap' && c.flexWrap !== 'wrap-reverse')
27
+ return true;
28
+ const tops = n.children.flatMap(ch => (ch.rect ? [ch.rect.top] : []));
29
+ return tops.some((t, i) => tops.some((u, j) => j !== i && Math.abs(t - u) <= 1));
30
+ }
31
+ if (c.display === 'grid' || c.display === 'inline-grid') {
32
+ if (c.gridAutoFlow?.startsWith('column'))
33
+ return true;
34
+ const tracks = (c.gridTemplateColumns ?? '')
35
+ .replace(/\[[^\]]*\]/g, ' ')
36
+ .split(/\s+/)
37
+ .filter(t => t && t !== 'none');
38
+ return tracks.length >= 2;
39
+ }
40
+ return false;
41
+ }
@@ -6,7 +6,7 @@
6
6
  * 一漂移就是「同一个元素在 `<page-block>` 与 `<page-element>` 里长得不一样」,而它会按其中
7
7
  * 一种去抄 selector。
8
8
  *
9
- * 行形:` - selector <tag> [container] [image: …] [link→…] top:N "text"`,`depth` 每加一层在 `- ` 前
9
+ * 行形:` - selector <tag> [container] [image: …] [link→…] top:N "text" [横排 i/n]`,`depth` 每加一层在 `- ` 前
10
10
  * 多两个空格。缩进是 2026-09-30 加的:平铺的货架看不出谁是谁的父级,agent 分不清
11
11
  * 「after 容器」与「after 容器里的标题」落在哪 —— 真机里它 after 了一个 flex 横排里的 Column,
12
12
  * 新加的一行跑到了标题右边。
@@ -29,5 +29,12 @@ export declare function imageTag(d: Pick<AiDescendant, 'image'>): string;
29
29
  * 方案卡据此断言「全页没有购买导线」,而那页几乎每张图都链到商品页。
30
30
  */
31
31
  export declare function linkTag(d: Pick<AiDescendant, 'href'>): string;
32
+ /**
33
+ * 货架行上的横排位:` [横排 1/2]`,父级不是横排(或树上没有排法事实)时空串。
34
+ *
35
+ * `after` 这一行落在它的父级怎么摆孩子的地方:父级横排就排到它旁边、把它挤窄(真机 2026-09-30 /
36
+ * 10-16 / 10-19 三次)。写法与 `<page-block>`(frontend-base 63112d497a)逐字一致。
37
+ */
38
+ export declare function rowTag(d: Pick<AiDescendant, 'inRow'>): string;
32
39
  /** 叶子行优先写自己的直接文本;容器行(自己没有文本)才写 heading。 */
33
40
  export declare function formatShelfRow(d: AiDescendant): string;
@@ -6,6 +6,9 @@ export function imageTag(d) {
6
6
  export function linkTag(d) {
7
7
  return d.href ? ` [link→${linkPath(d.href)}]` : '';
8
8
  }
9
+ export function rowTag(d) {
10
+ return d.inRow ? ` [横排 ${d.inRow.index}/${d.inRow.count}]` : '';
11
+ }
9
12
  function quote(text, max) {
10
13
  const clean = text.replace(/\s+/g, ' ').replace(/"/g, "'").trim();
11
14
  return `"${clean.length > max ? clean.slice(0, max) : clean}"`;
@@ -15,5 +18,7 @@ export function formatShelfRow(d) {
15
18
  const tag = d.tag ? ` <${d.tag}>` : '';
16
19
  const pos = typeof d.top === 'number' ? ` top:${Math.round(d.top)}` : '';
17
20
  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)}` : ''}`;
21
+ const inserted = d.aiInserted ? ' [ai-inserted]' : '';
22
+ const block = d.block ? ` [${d.block}]` : '';
23
+ return ` ${indent}- ${d.selector}${tag}${d.hasChildren ? ' [container]' : ''}${imageTag(d)}${linkTag(d)}${inserted}${pos}${label ? ` ${quote(label, SHELF_TEXT_MAX)}` : ''}${rowTag(d)}${block}`;
19
24
  }
@@ -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.2",
4
4
  "description": "Ptengine Inline 编辑器:画布会话编排、图层树、属性面板与组件库(含内部 canvas-ui)",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,