@ptengine/inline-editor 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +69 -0
- package/dist/InlineEditor.d.ts +15 -1
- package/dist/InlineEditor.js +226 -52
- package/dist/canvas-ui/tree/TreePanel.d.ts +2 -1
- package/dist/canvas-ui/tree/TreePanel.js +79 -39
- package/dist/chrome/EditorChrome.d.ts +2 -0
- package/dist/chrome/EditorChrome.js +1 -1
- package/dist/chrome/VersionMenu.d.ts +3 -1
- package/dist/chrome/VersionMenu.js +24 -12
- package/dist/chrome/topbarPrimitives.d.ts +5 -52
- package/dist/chrome/topbarPrimitives.js +13 -31
- package/dist/dialogs/ClickActionDialog.js +4 -5
- package/dist/dialogs/CodeField.d.ts +4 -0
- package/dist/dialogs/CodeField.js +10 -0
- package/dist/dialogs/HtmlEditDialog.js +3 -2
- package/dist/dialogs/ImageEditDialog.js +53 -15
- package/dist/dialogs/ImageSettingDialog.js +3 -3
- package/dist/dialogs/InsertElementDialog.js +10 -6
- package/dist/dialogs/MediaPreview.d.ts +5 -0
- package/dist/dialogs/MediaPreview.js +34 -0
- package/dist/dialogs/ReplaceVideoDialog.js +8 -4
- package/dist/index.d.ts +1 -0
- package/dist/kernel/ai/aiInlineApply.d.ts +4 -3
- package/dist/kernel/blocks/BlockOverlay.js +1 -1
- package/dist/kernel/blocks/resolveBlocks.d.ts +16 -0
- package/dist/kernel/blocks/resolveBlocks.js +39 -6
- package/dist/kernel/changelist/ChangeListPanel.js +11 -9
- package/dist/kernel/changelist/useChangeList.d.ts +5 -0
- package/dist/kernel/changelist/useChangeList.js +85 -10
- package/dist/kernel/selection/SelectionOverlay.js +1 -1
- package/dist/kernel/selection/SelectionToolbar.js +26 -8
- package/dist/kernel/selection/buildRootShelf.d.ts +56 -2
- package/dist/kernel/selection/buildRootShelf.js +189 -41
- package/dist/kernel/selection/elementAiContext.d.ts +11 -3
- package/dist/kernel/selection/resolveAnchors.d.ts +17 -0
- package/dist/kernel/selection/resolveAnchors.js +59 -11
- package/dist/locales.d.ts +201 -48
- package/dist/locales.js +13 -9
- package/dist/panels/EditorStatus.d.ts +5 -0
- package/dist/panels/EditorStatus.js +4 -0
- package/dist/panels/MovePositionControls.d.ts +6 -0
- package/dist/panels/MovePositionControls.js +35 -0
- package/dist/panels/ScreenshotCard.d.ts +7 -0
- package/dist/panels/ScreenshotCard.js +57 -0
- package/dist/panels/StylePanel.d.ts +9 -1
- package/dist/panels/StylePanel.js +108 -12
- package/dist/reviewLocales.d.ts +149 -0
- package/dist/reviewLocales.js +143 -0
- package/dist/tree/LayersPanel.js +4 -2
- package/dist/useInlineEditor.d.ts +1 -0
- package/dist/useInlineEditor.js +40 -1
- package/package.json +2 -2
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useRef, useState } from 'react';
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
2
|
import { applyChange, revertChange } from '../canvasCommands.js';
|
|
3
3
|
import { overrideOutcome } from './overrides.js';
|
|
4
4
|
import { backgroundImageValue, buildInsertImageHtml, randomPtElementId } from '../../dialogs/imageEdit.js';
|
|
@@ -16,6 +16,16 @@ function withResolvedStyleMode(change, applied) {
|
|
|
16
16
|
}
|
|
17
17
|
export function useChangeList(sessionId, options = {}) {
|
|
18
18
|
const [list, setList] = useState([]);
|
|
19
|
+
const past = useRef([]);
|
|
20
|
+
const future = useRef([]);
|
|
21
|
+
const [, refreshHistory] = useState(0);
|
|
22
|
+
const resetHistory = useCallback(() => {
|
|
23
|
+
past.current = [];
|
|
24
|
+
future.current = [];
|
|
25
|
+
refreshHistory(n => n + 1);
|
|
26
|
+
}, []);
|
|
27
|
+
useEffect(resetHistory, [sessionId, resetHistory]);
|
|
28
|
+
const mutationQueue = useRef(Promise.resolve());
|
|
19
29
|
const [lastError, setLastError] = useState(null);
|
|
20
30
|
const listRef = useRef([]);
|
|
21
31
|
listRef.current = list;
|
|
@@ -50,7 +60,9 @@ export function useChangeList(sessionId, options = {}) {
|
|
|
50
60
|
const stored = withResolvedStyleMode(change, applied);
|
|
51
61
|
const ancestorsByChange = new Map(prev.map(r => [r.change, r.ancestors]));
|
|
52
62
|
const insertedByChange = new Map(prev.map(r => [r.change, r.insertedSelectors]));
|
|
53
|
-
const insertedNow = applied.insertedSelectors?.length
|
|
63
|
+
const insertedNow = applied.insertedSelectors?.length
|
|
64
|
+
? applied.insertedSelectors
|
|
65
|
+
: undefined;
|
|
54
66
|
const nextRecords = nextChanges.map(c => {
|
|
55
67
|
const inserted = c === change ? insertedNow : insertedByChange.get(c);
|
|
56
68
|
return {
|
|
@@ -99,15 +111,71 @@ export function useChangeList(sessionId, options = {}) {
|
|
|
99
111
|
reportRestored(records);
|
|
100
112
|
return { applied: records.length - failed.length, failed };
|
|
101
113
|
}, [sessionId, reportRestored]);
|
|
102
|
-
const guard = useCallback(
|
|
114
|
+
const guard = useCallback((run, record = true) => {
|
|
115
|
+
const next = mutationQueue.current.then(async () => {
|
|
116
|
+
const before = listRef.current;
|
|
117
|
+
try {
|
|
118
|
+
const result = await run();
|
|
119
|
+
if (record && before !== listRef.current) {
|
|
120
|
+
past.current.push({ before, after: listRef.current });
|
|
121
|
+
future.current = [];
|
|
122
|
+
refreshHistory(n => n + 1);
|
|
123
|
+
}
|
|
124
|
+
setLastError(null);
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
127
|
+
catch (e) {
|
|
128
|
+
setLastError(String(e?.message ?? e));
|
|
129
|
+
throw e;
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
mutationQueue.current = next.catch(() => { });
|
|
133
|
+
return next;
|
|
134
|
+
}, []);
|
|
135
|
+
const restoreSnapshot = useCallback(async (snapshot) => {
|
|
136
|
+
if (!sessionId)
|
|
137
|
+
throw new Error('no canvas session');
|
|
138
|
+
const current = listRef.current;
|
|
139
|
+
for (const record of [...current].reverse()) {
|
|
140
|
+
await revertChange(sessionId, record.changeId).catch(() => { });
|
|
141
|
+
}
|
|
142
|
+
reportRemoved(current);
|
|
143
|
+
listRef.current = [];
|
|
144
|
+
setList([]);
|
|
145
|
+
for (const record of snapshot) {
|
|
146
|
+
const applied = await applyChange(sessionId, record.changeId, record.change);
|
|
147
|
+
const restored = {
|
|
148
|
+
...record,
|
|
149
|
+
change: withResolvedStyleMode(record.change, applied),
|
|
150
|
+
...(applied?.insertedSelectors?.length
|
|
151
|
+
? { insertedSelectors: applied.insertedSelectors }
|
|
152
|
+
: {})
|
|
153
|
+
};
|
|
154
|
+
listRef.current = [...listRef.current, restored];
|
|
155
|
+
setList(listRef.current);
|
|
156
|
+
reportRestored([restored]);
|
|
157
|
+
}
|
|
158
|
+
}, [sessionId, reportRemoved, reportRestored]);
|
|
159
|
+
const travelHistory = useCallback((direction) => guard(async () => {
|
|
160
|
+
const source = direction === 'undo' ? past.current : future.current;
|
|
161
|
+
const target = direction === 'undo' ? future.current : past.current;
|
|
162
|
+
const entry = source.at(-1);
|
|
163
|
+
if (!entry)
|
|
164
|
+
return;
|
|
165
|
+
const current = listRef.current;
|
|
103
166
|
try {
|
|
104
|
-
|
|
167
|
+
await restoreSnapshot(direction === 'undo' ? entry.before : entry.after);
|
|
105
168
|
}
|
|
106
|
-
catch (
|
|
107
|
-
|
|
108
|
-
throw
|
|
169
|
+
catch (error) {
|
|
170
|
+
await restoreSnapshot(current);
|
|
171
|
+
throw error;
|
|
109
172
|
}
|
|
110
|
-
|
|
173
|
+
source.pop();
|
|
174
|
+
target.push(entry);
|
|
175
|
+
refreshHistory(n => n + 1);
|
|
176
|
+
}, false), [guard, restoreSnapshot]);
|
|
177
|
+
const undo = useCallback(() => travelHistory('undo'), [travelHistory]);
|
|
178
|
+
const redo = useCallback(() => travelHistory('redo'), [travelHistory]);
|
|
111
179
|
const addStyle = useCallback((input) => guard(() => push(buildStyleChange(input), input.ancestors)), [guard, push]);
|
|
112
180
|
const addContent = useCallback((input) => guard(() => push(buildContentChange(input), input.ancestors)), [guard, push]);
|
|
113
181
|
const addAttribute = useCallback((input) => guard(() => push(buildAttributeChange(input), input.ancestors)), [guard, push]);
|
|
@@ -158,14 +226,16 @@ export function useChangeList(sessionId, options = {}) {
|
|
|
158
226
|
idMap.set(old, nextRecords[i].changeId);
|
|
159
227
|
}
|
|
160
228
|
}
|
|
229
|
+
resetHistory();
|
|
161
230
|
return { previous, outcome, idMap };
|
|
162
|
-
}, [hydrate, revertAll, sessionId]);
|
|
231
|
+
}, [hydrate, revertAll, sessionId, resetHistory]);
|
|
163
232
|
const peekList = useCallback(() => listRef.current, []);
|
|
164
233
|
const clear = useCallback(() => {
|
|
234
|
+
resetHistory();
|
|
165
235
|
listRef.current = [];
|
|
166
236
|
setList([]);
|
|
167
237
|
setLastError(null);
|
|
168
|
-
}, []);
|
|
238
|
+
}, [resetHistory]);
|
|
169
239
|
return {
|
|
170
240
|
list,
|
|
171
241
|
changes: list.map(r => r.change),
|
|
@@ -179,7 +249,12 @@ export function useChangeList(sessionId, options = {}) {
|
|
|
179
249
|
clear,
|
|
180
250
|
hydrate,
|
|
181
251
|
replaceAll,
|
|
252
|
+
whenIdle: () => mutationQueue.current,
|
|
182
253
|
peekList,
|
|
254
|
+
canUndo: past.current.length > 0,
|
|
255
|
+
canRedo: future.current.length > 0,
|
|
256
|
+
undo,
|
|
257
|
+
redo,
|
|
183
258
|
lastError
|
|
184
259
|
};
|
|
185
260
|
}
|
|
@@ -2,7 +2,7 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
|
2
2
|
import { useLayoutEffect, useRef, useState } from 'react';
|
|
3
3
|
import { useTranslation } from 'react-i18next';
|
|
4
4
|
import { Camera, ClipboardPaste, Code, Copy, ImageIcon, MousePointerClick, Move, Paintbrush, Pencil, Plus, Settings2, Sparkles, Trash2, Video } from 'lucide-react';
|
|
5
|
-
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@ptengine/design-components';
|
|
5
|
+
import { Button, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@ptengine/design-components';
|
|
6
6
|
import { SELECTION_CLASS } from '../../canvas-ui/index.js';
|
|
7
7
|
import { visibleButtons } from './toolbarButtons.js';
|
|
8
8
|
const TOOLBAR_HEIGHT = 34;
|
|
@@ -23,7 +23,7 @@ const TOOLBAR_ICONS = {
|
|
|
23
23
|
delete: Trash2,
|
|
24
24
|
sparkles: Sparkles
|
|
25
25
|
};
|
|
26
|
-
const ICON_SIZE =
|
|
26
|
+
const ICON_SIZE = 16;
|
|
27
27
|
const ICON_STROKE_WIDTH = 1.75;
|
|
28
28
|
const TOOLTIP_DELAY = 200;
|
|
29
29
|
export function computeToolbarPosition(rect, area, width) {
|
|
@@ -51,6 +51,7 @@ export function computeToolbarPosition(rect, area, width) {
|
|
|
51
51
|
export function SelectionToolbar({ host = {}, selectedRect, area, capabilities, isScrolling, onAction, width = 240 }) {
|
|
52
52
|
const { t } = useTranslation();
|
|
53
53
|
const barRef = useRef(null);
|
|
54
|
+
const [focusIndex, setFocusIndex] = useState(0);
|
|
54
55
|
const [measuredWidth, setMeasuredWidth] = useState(null);
|
|
55
56
|
useLayoutEffect(() => {
|
|
56
57
|
const measured = barRef.current?.offsetWidth ?? 0;
|
|
@@ -63,20 +64,37 @@ export function SelectionToolbar({ host = {}, selectedRect, area, capabilities,
|
|
|
63
64
|
if (buttons.length === 0)
|
|
64
65
|
return null;
|
|
65
66
|
const { left, top } = computeToolbarPosition(selectedRect, area, measuredWidth ?? width);
|
|
66
|
-
return (_jsx("div", { ref: barRef,
|
|
67
|
+
return (_jsx("div", { ref: barRef, role: "toolbar", "aria-label": t('inlineCanvas.toolbar.label', '元素操作'), onKeyDown: e => {
|
|
68
|
+
const items = Array.from(barRef.current?.querySelectorAll('button:not(:disabled)') ??
|
|
69
|
+
[]);
|
|
70
|
+
const current = items.indexOf(document.activeElement);
|
|
71
|
+
let next = current;
|
|
72
|
+
if (e.key === 'ArrowRight')
|
|
73
|
+
next = (current + 1) % items.length;
|
|
74
|
+
else if (e.key === 'ArrowLeft')
|
|
75
|
+
next = (current - 1 + items.length) % items.length;
|
|
76
|
+
else if (e.key === 'Home')
|
|
77
|
+
next = 0;
|
|
78
|
+
else if (e.key === 'End')
|
|
79
|
+
next = items.length - 1;
|
|
80
|
+
else
|
|
81
|
+
return;
|
|
82
|
+
e.preventDefault();
|
|
83
|
+
setFocusIndex(next);
|
|
84
|
+
items[next]?.focus();
|
|
85
|
+
}, "data-testid": "canvas-selection-toolbar", className: SELECTION_CLASS.toolbar, style: {
|
|
67
86
|
position: 'fixed',
|
|
68
87
|
left: `${left}px`,
|
|
69
88
|
top: `${top}px`,
|
|
70
89
|
transform: 'none',
|
|
71
|
-
zIndex:
|
|
72
|
-
}, children: _jsx(TooltipProvider, { delayDuration: TOOLTIP_DELAY, children: buttons.map(button => {
|
|
90
|
+
zIndex: 901
|
|
91
|
+
}, children: _jsx(TooltipProvider, { delayDuration: TOOLTIP_DELAY, children: buttons.map((button, index) => {
|
|
73
92
|
const label = t(button.labelKey);
|
|
74
93
|
const Icon = TOOLBAR_ICONS[button.icon];
|
|
75
94
|
const isAi = button.id === 'ask-ai';
|
|
76
|
-
const className = `${SELECTION_CLASS.tool}${button.id === 'delete' ? ` ${SELECTION_CLASS.toolDanger}` : ''}${isAi ? ` ${SELECTION_CLASS.toolAi}` : ''}`;
|
|
77
95
|
if (isAi) {
|
|
78
|
-
return (_jsxs(
|
|
96
|
+
return (_jsxs(Button, { type: "button", "data-testid": `canvas-toolbar-${button.id}`, size: isAi ? 'sm' : 'icon-sm', variant: button.id === 'delete' ? 'destructive' : 'ghost', tabIndex: index === Math.min(focusIndex, buttons.length - 1) ? 0 : -1, onClick: () => onAction(button.id), children: ["\u2726 ", label] }, button.id));
|
|
79
97
|
}
|
|
80
|
-
return (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: _jsx(
|
|
98
|
+
return (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: _jsx(Button, { type: "button", "data-testid": `canvas-toolbar-${button.id}`, "aria-label": label, size: isAi ? 'sm' : 'icon-sm', variant: button.id === 'delete' ? 'destructive' : 'ghost', tabIndex: index === Math.min(focusIndex, buttons.length - 1) ? 0 : -1, onClick: () => onAction(button.id), children: _jsx(Icon, { size: ICON_SIZE, strokeWidth: ICON_STROKE_WIDTH, "aria-hidden": true }) }) }), _jsx(TooltipContent, { children: label })] }, button.id));
|
|
81
99
|
}) }) }));
|
|
82
100
|
}
|
|
@@ -7,7 +7,25 @@
|
|
|
7
7
|
*
|
|
8
8
|
* 这里改成按**几何**挑区块:新 agent 的树带 `rect`(文档坐标)/ `hidden` / `heading`
|
|
9
9
|
* (`getDomTree` 的 `includeRect` / `visibleOnly`),于是能先找出「够高、够宽」的可见区块,
|
|
10
|
-
* 每块给 1 行容器 +
|
|
10
|
+
* 每块给 1 行容器 + 几行可改点,header/nav/footer 各封顶,隐藏节点一律不给。
|
|
11
|
+
*
|
|
12
|
+
* **哪一层算「一个区块」**(2026-10-21 重写):`after` X 落在 X 的父级里,按父级怎么排孩子
|
|
13
|
+
* 摆放。「X 下面插入」要落在 X 正下方,X 的父级就得是**上下堆叠区块**的那一层 —— 所以块根 =
|
|
14
|
+
* 「只装着这一个区块的最外层容器」:
|
|
15
|
+
* · 父级横排(轮播的每一张、并列的列)里的大项不单独成块,连同子树归外层;
|
|
16
|
+
* · 装着 ≥2 个候选区块 = 外壳,拆开;只装着 1 个 = 它自己就是这一块(外层赢,小圆点、
|
|
17
|
+
* 进度条这些附属部件都在最外层里),但占页高 ≥60% 的单链节点是整页的框,让给内层;
|
|
18
|
+
* · 宿主给了页面模型(`blocks`)时,成员归属校正拆合,并在块根行写 `blockId`。
|
|
19
|
+
*
|
|
20
|
+
* **矮区块**(2026-10-21):几何门槛(高 ≥120)挡掉了真正的矮区块 —— vivaia 手机首页的新闻区只有 86px,
|
|
21
|
+
* 父级是外壳,它既不是块根也不在块根里,只能等回填,100 行用完时它的标题还没轮到,「把新闻区标题改成…」
|
|
22
|
+
* 两次被拒、0 处落地。门槛本身不降(降了,section 里的标题条也成候选,section 被拆成外壳)。补两条:
|
|
23
|
+
* · 页面模型的成员容器只要够宽就是候选,不看高度(热图说它是一块,它就是一块);
|
|
24
|
+
* · 块根的父级就是区块上下叠放的那一层:那里不够高、但够宽、有内容、不在 header/nav/footer 里的孩子,
|
|
25
|
+
* 也算一块(没有页面模型时的兜底)。
|
|
26
|
+
* 旧规则「内层有 heading 就赢」不可用:kit 的 `heading` 是**子树里**第一个标题(没有就取第一段
|
|
27
|
+
* 文字),section / 轮播轨道 / 每张幻灯片拿到的是同一句,规则又偏向内层 —— 真机 vivaia 首页的
|
|
28
|
+
* 首屏只剩三张幻灯片,模型照「首屏下面」插进了轮播(2026-10-20)。
|
|
11
29
|
*
|
|
12
30
|
* **兼容**:树上一个 `rect` 都没有 = 老 agent,整个函数退化成老算法,逐项一致。
|
|
13
31
|
*
|
|
@@ -15,11 +33,47 @@
|
|
|
15
33
|
* 里的项。MOVE 闸靠这张表判 target 是不是被移元素的祖先,两边一旦漂移就是「同一个
|
|
16
34
|
* selector 有时能移有时不能,且不报错」(见 buildDescendantShelf 头部说明)。
|
|
17
35
|
*/
|
|
18
|
-
import type { DomTreeNode } from '@ptengine/inline-core/protocol';
|
|
36
|
+
import type { DomTreeNode, GetDomTreeParams } from '@ptengine/inline-core/protocol';
|
|
19
37
|
import { type AiDescendant } from './elementAiContext.js';
|
|
38
|
+
/**
|
|
39
|
+
* 整页货架取树的口径(`getDomTree`,以 body 为根)。编辑器的整页货架与分析路径的成员上提(`resolveBlocks`)
|
|
40
|
+
* 用**同一份**:块根判据吃的是几何与排法,两边取的树不一样,认出来的块根就可能不一样。
|
|
41
|
+
*
|
|
42
|
+
* 树要**深、宽、带几何**:真实站点的内容区块常在 `body > #root > .page > main > div > div > section`
|
|
43
|
+
* 第 6-7 层,`maxDepth: 4` 根本到不了;`visibleOnly` 把抽屉 / 隐藏菜单整棵剔掉(它们此前把货架吃光),
|
|
44
|
+
* `includeRect` 给块根判据按高度 / 宽度挑区块用。`maxNodes` 是总量安全阀(1500 个节点的 JSON 仍在一次
|
|
45
|
+
* sendMessage 的舒适范围内)。老 agent 不认识这几个字段会静默忽略 —— 树上就没有 `rect`,退回老算法。
|
|
46
|
+
*/
|
|
47
|
+
export declare const ROOT_SHELF_TREE_PARAMS: Readonly<Omit<GetDomTreeParams, 'command' | 'sessionId'>>;
|
|
48
|
+
/**
|
|
49
|
+
* 页面模型的一个区块(热图区块配置解析出来的),只要 id 与成员容器的 selector。
|
|
50
|
+
* 与宿主的 `PageModelBlock` / `InlinePageBlock` 结构兼容,宿主原样传进来即可。
|
|
51
|
+
*/
|
|
52
|
+
export interface ShelfBlockHint {
|
|
53
|
+
id: string;
|
|
54
|
+
members: ReadonlyArray<{
|
|
55
|
+
selector: string;
|
|
56
|
+
}>;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* 页面模型的成员 → 它所在的块根:整页货架上写着这个区块 `blockId` 的那一行(同一份判定)。
|
|
60
|
+
* 返回 区块 id → 成员 selector → 块根节点(输入树上的那个节点)。
|
|
61
|
+
*
|
|
62
|
+
* 分析路径(`resolveBlocks` → `<page-block>` → 方案锚点 → 首版 `anchors:`)此前用热图成员原样:真机
|
|
63
|
+
* vivaia 首页 B1 的成员是轮播第 1 张,方案锚点照抄它,首版 after 它就把「首屏下方」的新模块插进了轮播 ——
|
|
64
|
+
* 而编辑器货架上 `[B1]` 已经在整个首屏 section 上(2026-10-21)。两条路认块必须是同一个结果。
|
|
65
|
+
*
|
|
66
|
+
* 只提到「是成员自己或成员祖先、且只装着这一个区块」的块根。提不了的不进表,调用方保持原成员:
|
|
67
|
+
* 成员不在树上(截断 / 另一端版式)、所在容器还装着别的区块(两张幻灯片各是一块)、老 agent 没有几何。
|
|
68
|
+
*/
|
|
69
|
+
export declare function liftToBlockRoots(root: DomTreeNode, opts: {
|
|
70
|
+
viewportWidth?: number;
|
|
71
|
+
blocks: ReadonlyArray<ShelfBlockHint>;
|
|
72
|
+
}): Map<string, Map<string, DomTreeNode>>;
|
|
20
73
|
export declare function buildRootShelf(root: DomTreeNode, opts: {
|
|
21
74
|
limit: number;
|
|
22
75
|
viewportWidth?: number;
|
|
76
|
+
blocks?: ReadonlyArray<ShelfBlockHint>;
|
|
23
77
|
}): {
|
|
24
78
|
shelf: AiDescendant[];
|
|
25
79
|
ancestorsOf: Map<string, string[]>;
|
|
@@ -1,16 +1,27 @@
|
|
|
1
1
|
import { buildDescendantShelfWithPaths } from './buildDescendantShelf.js';
|
|
2
2
|
import { AI_DESCENDANT_TEXT_CHARS } from './elementAiContext.js';
|
|
3
|
-
import { rowPositions } from './rowLayout.js';
|
|
3
|
+
import { SHELF_LAYOUT_FIELDS, laysOutInRow, rowPositions } from './rowLayout.js';
|
|
4
|
+
export const ROOT_SHELF_TREE_PARAMS = {
|
|
5
|
+
rootSelector: 'body',
|
|
6
|
+
maxDepth: 16,
|
|
7
|
+
maxChildren: 40,
|
|
8
|
+
maxNodes: 1500,
|
|
9
|
+
visibleOnly: true,
|
|
10
|
+
includeRect: true,
|
|
11
|
+
fields: [...SHELF_LAYOUT_FIELDS]
|
|
12
|
+
};
|
|
4
13
|
const LANDMARK_MIN_HEIGHT = 120;
|
|
5
14
|
const LANDMARK_MIN_WIDTH_RATIO = 0.5;
|
|
6
15
|
const DEFAULT_VIEWPORT_WIDTH = 375;
|
|
7
16
|
const LEAVES_PER_LANDMARK = 6;
|
|
17
|
+
const PAGE_SCALE_RATIO = 0.6;
|
|
18
|
+
const SAME_TOP_PX = 2;
|
|
8
19
|
const CHROME_ROWS_CAP = 5;
|
|
9
|
-
const PREFERRED_LANDMARK_TAGS = new Set(['main', 'section', 'article']);
|
|
10
20
|
const CHROME_TAGS = new Set(['header', 'nav', 'footer']);
|
|
11
21
|
const TEXT_LEAF_TAGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'a', 'button', 'span']);
|
|
12
|
-
|
|
13
|
-
|
|
22
|
+
const isTextLeaf = (n) => n.tag === 'img' || (TEXT_LEAF_TAGS.has(n.tag) && n.textSummary.trim().length > 0);
|
|
23
|
+
const hasContent = (n) => isTextLeaf(n) || !!n.image || n.textSummary.trim().length > 0 || n.children.some(c => !c.hidden && hasContent(c));
|
|
24
|
+
function analyzeBlockRoots(root, opts) {
|
|
14
25
|
const viewportWidth = typeof opts.viewportWidth === 'number' && opts.viewportWidth > 0
|
|
15
26
|
? opts.viewportWidth
|
|
16
27
|
: DEFAULT_VIEWPORT_WIDTH;
|
|
@@ -19,7 +30,7 @@ export function buildRootShelf(root, opts) {
|
|
|
19
30
|
const entryOf = new Map();
|
|
20
31
|
let sawRect = root.rect !== undefined;
|
|
21
32
|
const isLandmarkNode = (n) => n.rect !== undefined && n.rect.height >= LANDMARK_MIN_HEIGHT && n.rect.width >= minWidth;
|
|
22
|
-
const visit = (n, ancestors, chromeKey
|
|
33
|
+
const visit = (n, parent, ancestors, chromeKey) => {
|
|
23
34
|
for (const child of n.children) {
|
|
24
35
|
if (child.hidden)
|
|
25
36
|
continue;
|
|
@@ -28,53 +39,186 @@ export function buildRootShelf(root, opts) {
|
|
|
28
39
|
const ownChrome = CHROME_TAGS.has(child.tag) ? child.selector : chromeKey;
|
|
29
40
|
const entry = {
|
|
30
41
|
node: child,
|
|
42
|
+
parent,
|
|
31
43
|
ancestors,
|
|
32
44
|
order: entries.length,
|
|
33
45
|
chromeKey: ownChrome,
|
|
34
|
-
|
|
35
|
-
|
|
46
|
+
landmark: isLandmarkNode(child),
|
|
47
|
+
demoted: false
|
|
36
48
|
};
|
|
37
49
|
entries.push(entry);
|
|
38
50
|
entryOf.set(child, entry);
|
|
39
|
-
visit(child, [child.selector, ...ancestors], ownChrome
|
|
51
|
+
visit(child, entry, [child.selector, ...ancestors], ownChrome);
|
|
40
52
|
}
|
|
41
53
|
};
|
|
42
|
-
visit(root, [root.selector], null
|
|
54
|
+
visit(root, null, [root.selector], null);
|
|
43
55
|
if (!sawRect)
|
|
44
|
-
return
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
56
|
+
return null;
|
|
57
|
+
const pageHeight = entries.reduce((h, e) => (e.node.rect ? Math.max(h, e.node.rect.top + e.node.rect.height) : h), root.rect?.height ?? 0);
|
|
58
|
+
const isPageScale = (n) => n.rect !== undefined && pageHeight > 0 && n.rect.height >= PAGE_SCALE_RATIO * pageHeight;
|
|
59
|
+
const isLandmarkRow = (parent) => {
|
|
60
|
+
const items = parent.children.filter(c => !c.hidden && isLandmarkNode(c));
|
|
61
|
+
if (items.length < 2 || items.some(isPageScale))
|
|
62
|
+
return false;
|
|
63
|
+
if (laysOutInRow(parent))
|
|
64
|
+
return true;
|
|
65
|
+
const top0 = items[0].rect.top;
|
|
66
|
+
return items.every(c => Math.abs(c.rect.top - top0) <= SAME_TOP_PX);
|
|
67
|
+
};
|
|
68
|
+
const rowParents = new Map();
|
|
69
|
+
const inLandmarkRow = (parent) => {
|
|
70
|
+
let v = rowParents.get(parent);
|
|
71
|
+
if (v === undefined)
|
|
72
|
+
rowParents.set(parent, (v = isLandmarkRow(parent)));
|
|
73
|
+
return v;
|
|
74
|
+
};
|
|
75
|
+
for (const e of entries)
|
|
76
|
+
e.demoted = (e.parent?.demoted ?? false) || inLandmarkRow(e.parent?.node ?? root);
|
|
77
|
+
if (entries.some(e => e.landmark) && !entries.some(e => e.landmark && !e.demoted)) {
|
|
78
|
+
for (const e of entries)
|
|
79
|
+
e.demoted = false;
|
|
80
|
+
}
|
|
81
|
+
const entryBySelector = new Map();
|
|
82
|
+
for (const e of entries)
|
|
83
|
+
if (!entryBySelector.has(e.node.selector))
|
|
84
|
+
entryBySelector.set(e.node.selector, e);
|
|
85
|
+
const memberBoxes = new Set();
|
|
86
|
+
for (const b of opts.blocks ?? []) {
|
|
87
|
+
for (const member of b.members) {
|
|
88
|
+
const e = entryBySelector.get(member.selector);
|
|
89
|
+
const r = e?.node.rect;
|
|
90
|
+
if (e && r && r.height > 0 && r.width >= minWidth)
|
|
91
|
+
memberBoxes.add(e);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const isCandidate = (e) => (e.landmark || memberBoxes.has(e)) && !e.demoted;
|
|
95
|
+
const claimed = new Map();
|
|
96
|
+
const blocksIn = new Map();
|
|
97
|
+
const memberEntries = new Set();
|
|
98
|
+
const addTo = (m, e, id) => {
|
|
99
|
+
let set = m.get(e);
|
|
100
|
+
if (!set)
|
|
101
|
+
m.set(e, (set = new Set()));
|
|
102
|
+
set.add(id);
|
|
103
|
+
};
|
|
104
|
+
for (const b of opts.blocks ?? []) {
|
|
105
|
+
for (const member of b.members) {
|
|
106
|
+
const own = entryBySelector.get(member.selector);
|
|
107
|
+
if (!own)
|
|
108
|
+
continue;
|
|
109
|
+
memberEntries.add(own);
|
|
110
|
+
for (let a = own; a; a = a.parent)
|
|
111
|
+
addTo(blocksIn, a, b.id);
|
|
112
|
+
let e = own;
|
|
113
|
+
while (e && !isCandidate(e))
|
|
114
|
+
e = e.parent;
|
|
115
|
+
if (e)
|
|
116
|
+
addTo(claimed, e, b.id);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const modelBlockCount = (e) => blocksIn.get(e)?.size ?? 0;
|
|
120
|
+
const isAtomic = (e) => claimed.get(e)?.size === 1 && modelBlockCount(e) === 1;
|
|
121
|
+
const nearestCandidateAncestor = (e) => {
|
|
122
|
+
for (let a = e.parent; a; a = a.parent)
|
|
123
|
+
if (isCandidate(a))
|
|
124
|
+
return a;
|
|
125
|
+
return null;
|
|
126
|
+
};
|
|
127
|
+
const kids = new Map();
|
|
128
|
+
const tops = [];
|
|
129
|
+
const candidates = entries.filter(isCandidate);
|
|
130
|
+
for (const e of candidates) {
|
|
131
|
+
const p = nearestCandidateAncestor(e);
|
|
132
|
+
if (p) {
|
|
133
|
+
const list = kids.get(p) ?? [];
|
|
134
|
+
list.push(e);
|
|
135
|
+
kids.set(p, list);
|
|
136
|
+
}
|
|
137
|
+
else
|
|
138
|
+
tops.push(e);
|
|
139
|
+
}
|
|
140
|
+
const isUnit = new Map();
|
|
141
|
+
for (const e of [...candidates].reverse()) {
|
|
142
|
+
const ks = kids.get(e) ?? [];
|
|
143
|
+
let unit;
|
|
144
|
+
if (isAtomic(e) && (memberEntries.has(e) || ks.length < 2))
|
|
145
|
+
unit = true;
|
|
146
|
+
else if (modelBlockCount(e) >= 2)
|
|
147
|
+
unit = ks.length === 0;
|
|
148
|
+
else if (ks.length === 0)
|
|
149
|
+
unit = true;
|
|
150
|
+
else if (ks.length >= 2)
|
|
151
|
+
unit = false;
|
|
55
152
|
else
|
|
56
|
-
|
|
153
|
+
unit = (isUnit.get(ks[0]) ?? false) && !isPageScale(e.node);
|
|
154
|
+
isUnit.set(e, unit);
|
|
57
155
|
}
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
156
|
+
const blockRoots = [];
|
|
157
|
+
const takeRoots = (list) => {
|
|
158
|
+
for (const e of list) {
|
|
159
|
+
if (isUnit.get(e))
|
|
160
|
+
blockRoots.push(e);
|
|
161
|
+
else
|
|
162
|
+
takeRoots(kids.get(e) ?? []);
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
takeRoots(tops);
|
|
166
|
+
const rootSet = new Set(blockRoots);
|
|
167
|
+
const holdsRoot = new Set();
|
|
168
|
+
for (const r of blockRoots)
|
|
169
|
+
for (let a = r.parent; a; a = a.parent)
|
|
170
|
+
holdsRoot.add(a);
|
|
171
|
+
const layers = new Set(blockRoots.map(r => r.parent?.node ?? root));
|
|
172
|
+
for (const layer of layers) {
|
|
173
|
+
if (laysOutInRow(layer))
|
|
61
174
|
continue;
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
175
|
+
for (const child of layer.children) {
|
|
176
|
+
const e = entryOf.get(child);
|
|
177
|
+
if (!e || rootSet.has(e) || holdsRoot.has(e) || e.landmark || e.demoted || e.chromeKey !== null)
|
|
178
|
+
continue;
|
|
179
|
+
const r = child.rect;
|
|
180
|
+
if (r && r.height > 0 && r.width >= minWidth && hasContent(child))
|
|
181
|
+
blockRoots.push(e);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
blockRoots.sort((a, b) => a.order - b.order);
|
|
185
|
+
const blockIdOf = (e) => {
|
|
186
|
+
const ids = blocksIn.get(e);
|
|
187
|
+
return ids && ids.size === 1 ? [...ids][0] : undefined;
|
|
188
|
+
};
|
|
189
|
+
return { entryOf, entryBySelector, blockRoots, blockIdOf };
|
|
190
|
+
}
|
|
191
|
+
export function liftToBlockRoots(root, opts) {
|
|
192
|
+
const out = new Map();
|
|
193
|
+
const analysis = analyzeBlockRoots(root, opts);
|
|
194
|
+
if (!analysis)
|
|
195
|
+
return out;
|
|
196
|
+
const rootSet = new Set(analysis.blockRoots);
|
|
197
|
+
for (const b of opts.blocks) {
|
|
198
|
+
for (const member of b.members) {
|
|
199
|
+
for (let e = analysis.entryBySelector.get(member.selector) ?? null; e; e = e.parent) {
|
|
200
|
+
if (!rootSet.has(e))
|
|
201
|
+
continue;
|
|
202
|
+
if (analysis.blockIdOf(e) === b.id) {
|
|
203
|
+
let byMember = out.get(b.id);
|
|
204
|
+
if (!byMember)
|
|
205
|
+
out.set(b.id, (byMember = new Map()));
|
|
206
|
+
byMember.set(member.selector, e.node);
|
|
69
207
|
}
|
|
70
|
-
|
|
208
|
+
break;
|
|
71
209
|
}
|
|
72
|
-
if (hasSurvivingAncestor)
|
|
73
|
-
continue;
|
|
74
210
|
}
|
|
75
|
-
surviving.add(e);
|
|
76
211
|
}
|
|
77
|
-
|
|
212
|
+
return out;
|
|
213
|
+
}
|
|
214
|
+
export function buildRootShelf(root, opts) {
|
|
215
|
+
const { limit } = opts;
|
|
216
|
+
const analysis = analyzeBlockRoots(root, opts);
|
|
217
|
+
if (!analysis)
|
|
218
|
+
return buildDescendantShelfWithPaths(root, limit);
|
|
219
|
+
const { entryOf, blockRoots, blockIdOf } = analysis;
|
|
220
|
+
const rootSet = new Set(blockRoots);
|
|
221
|
+
const landmarks = [...blockRoots].sort((a, b) => {
|
|
78
222
|
const dt = a.node.rect.top - b.node.rect.top;
|
|
79
223
|
return dt !== 0 ? dt : a.order - b.order;
|
|
80
224
|
});
|
|
@@ -83,7 +227,7 @@ export function buildRootShelf(root, opts) {
|
|
|
83
227
|
const ancestorsOf = new Map();
|
|
84
228
|
const emittedSelectors = new Set();
|
|
85
229
|
const chromeRows = new Map();
|
|
86
|
-
const emit = (e) => {
|
|
230
|
+
const emit = (e, blockId) => {
|
|
87
231
|
if (shelf.length >= limit)
|
|
88
232
|
return false;
|
|
89
233
|
if (emittedSelectors.has(e.node.selector))
|
|
@@ -107,6 +251,8 @@ export function buildRootShelf(root, opts) {
|
|
|
107
251
|
const inRow = inRowOf.get(n.selector);
|
|
108
252
|
if (inRow)
|
|
109
253
|
row.inRow = inRow;
|
|
254
|
+
if (blockId)
|
|
255
|
+
row.blockId = blockId;
|
|
110
256
|
shelf.push(row);
|
|
111
257
|
ancestorsOf.set(n.selector, e.ancestors);
|
|
112
258
|
emittedSelectors.add(n.selector);
|
|
@@ -114,22 +260,24 @@ export function buildRootShelf(root, opts) {
|
|
|
114
260
|
chromeRows.set(e.chromeKey, (chromeRows.get(e.chromeKey) ?? 0) + 1);
|
|
115
261
|
return true;
|
|
116
262
|
};
|
|
117
|
-
const
|
|
263
|
+
const leavesPerBlock = landmarks.length
|
|
264
|
+
? Math.min(LEAVES_PER_LANDMARK, Math.max(1, Math.floor((limit - landmarks.length) / landmarks.length)))
|
|
265
|
+
: LEAVES_PER_LANDMARK;
|
|
118
266
|
for (const lm of landmarks) {
|
|
119
267
|
if (shelf.length >= limit)
|
|
120
268
|
break;
|
|
121
|
-
emit(lm);
|
|
269
|
+
emit(lm, blockIdOf(lm));
|
|
122
270
|
let taken = 0;
|
|
123
271
|
const collect = (n) => {
|
|
124
272
|
for (const child of n.children) {
|
|
125
|
-
if (taken >=
|
|
273
|
+
if (taken >= leavesPerBlock || shelf.length >= limit)
|
|
126
274
|
return;
|
|
127
275
|
if (child.hidden)
|
|
128
276
|
continue;
|
|
129
277
|
const ce = entryOf.get(child);
|
|
130
278
|
if (!ce)
|
|
131
279
|
continue;
|
|
132
|
-
if (
|
|
280
|
+
if (rootSet.has(ce) && ce !== lm)
|
|
133
281
|
continue;
|
|
134
282
|
if (isTextLeaf(child) && emit(ce))
|
|
135
283
|
taken += 1;
|
|
@@ -77,11 +77,19 @@ export interface AiDescendant {
|
|
|
77
77
|
*/
|
|
78
78
|
inRow?: RowPosition;
|
|
79
79
|
/**
|
|
80
|
-
*
|
|
80
|
+
* 区块身份(宿主打的,如 `B2 首屏 h:740`),写成行尾的 `[…]`。
|
|
81
81
|
* 没选中元素时「首屏下面」要有确定的锚:同一 `top`、同一段文字的包装行常有十几个,没有它
|
|
82
|
-
* 「最外层容器」会被选成整页包装层(真机 2026-10-16
|
|
82
|
+
* 「最外层容器」会被选成整页包装层(真机 2026-10-16)。宿主按 `blockId`(新内核)打;
|
|
83
|
+
* 没有 `blockId` 的老内核货架,退回「selector 与页面模型 member 逐字相等」。
|
|
83
84
|
*/
|
|
84
85
|
block?: string;
|
|
86
|
+
/**
|
|
87
|
+
* 页面模型区块 id(内核打的):这一行是**只装着这一个区块的最外层容器**(块根),由
|
|
88
|
+
* `buildRootShelf` 的 `blocks` 认领得出 —— 热图成员是轮播第 1 张时,id 落在整个首屏 section
|
|
89
|
+
* 上,而不是那一张上(真机 2026-10-20)。宿主据此格式化成 `block`。没传 `blocks`、或这一行
|
|
90
|
+
* 不是某个区块的块根时没有这个键。
|
|
91
|
+
*/
|
|
92
|
+
blockId?: string;
|
|
85
93
|
}
|
|
86
94
|
/**
|
|
87
95
|
* 区块级货架取树的口径(`<page-block>`、方案锚点子树、选中元素货架三处共用)。
|
|
@@ -292,7 +300,7 @@ export declare const AI_DESCENDANT_LIMIT = 30;
|
|
|
292
300
|
* 60 → 100:货架改成「区块优先」(`buildRootShelf`)之后,每个区块是 1 行容器 + 最多 6 行
|
|
293
301
|
* 可改点,一个 10 个区块的落地页就是 70 行;60 会把最后几个区块整段切掉,AI 又回到
|
|
294
302
|
* 「找不到要改的那一块」。100 = 约 13 个区块,长落地页也够;header/nav/footer 各封 5 行,
|
|
295
|
-
*
|
|
303
|
+
* 不会被它们吃掉。区块再多时 `buildRootShelf` 按块数压低每块的叶子数,块根行一个不落。
|
|
296
304
|
*
|
|
297
305
|
* 也不能顺手把 `AI_DESCENDANT_LIMIT` 一起调大:那份是**每轮对话**的固定开销,
|
|
298
306
|
* 整页这份一次会话只算一次。
|