@ptengine/inline-editor 0.0.0-stage → 0.1.0
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 +28 -0
- package/README.md +286 -2
- package/dist/InlineEditor.d.ts +342 -0
- package/dist/InlineEditor.js +867 -0
- package/dist/PagePreview.d.ts +83 -0
- package/dist/PagePreview.js +111 -0
- package/dist/assets/AssetsPanel.d.ts +19 -0
- package/dist/assets/AssetsPanel.js +23 -0
- package/dist/assets/componentLibrary.d.ts +23 -0
- package/dist/assets/componentLibrary.js +12 -0
- package/dist/canvas-ui/index.d.ts +17 -0
- package/dist/canvas-ui/index.js +7 -0
- package/dist/canvas-ui/scope.d.ts +13 -0
- package/dist/canvas-ui/scope.js +1 -0
- package/dist/canvas-ui/selection/classNames.d.ts +30 -0
- package/dist/canvas-ui/selection/classNames.js +15 -0
- package/dist/canvas-ui/selection/tokens.css +204 -0
- package/dist/canvas-ui/tree/TreePanel.d.ts +47 -0
- package/dist/canvas-ui/tree/TreePanel.js +132 -0
- package/dist/canvas-ui/tree/classNames.d.ts +21 -0
- package/dist/canvas-ui/tree/classNames.js +8 -0
- package/dist/canvas-ui/tree/order.d.ts +38 -0
- package/dist/canvas-ui/tree/order.js +38 -0
- package/dist/canvas-ui/tree/tree.css +131 -0
- package/dist/chrome/EditorChrome.d.ts +83 -0
- package/dist/chrome/EditorChrome.js +10 -0
- package/dist/chrome/VersionMenu.d.ts +26 -0
- package/dist/chrome/VersionMenu.js +86 -0
- package/dist/chrome/topbarPrimitives.d.ts +69 -0
- package/dist/chrome/topbarPrimitives.js +39 -0
- package/dist/chrome/useDismissOnOutside.d.ts +23 -0
- package/dist/chrome/useDismissOnOutside.js +27 -0
- package/dist/dialogs/ClickActionDialog.d.ts +24 -0
- package/dist/dialogs/ClickActionDialog.js +35 -0
- package/dist/dialogs/HtmlEditDialog.d.ts +23 -0
- package/dist/dialogs/HtmlEditDialog.js +23 -0
- package/dist/dialogs/ImageEditDialog.d.ts +30 -0
- package/dist/dialogs/ImageEditDialog.js +57 -0
- package/dist/dialogs/ImageSettingDialog.d.ts +16 -0
- package/dist/dialogs/ImageSettingDialog.js +17 -0
- package/dist/dialogs/InsertElementDialog.d.ts +23 -0
- package/dist/dialogs/InsertElementDialog.js +28 -0
- package/dist/dialogs/ReplaceVideoDialog.d.ts +18 -0
- package/dist/dialogs/ReplaceVideoDialog.js +16 -0
- package/dist/dialogs/actionTypes.d.ts +53 -0
- package/dist/dialogs/actionTypes.js +74 -0
- package/dist/dialogs/htmlValidation.d.ts +24 -0
- package/dist/dialogs/htmlValidation.js +37 -0
- package/dist/dialogs/imageEdit.d.ts +49 -0
- package/dist/dialogs/imageEdit.js +30 -0
- package/dist/index.d.ts +101 -0
- package/dist/index.js +53 -0
- package/dist/kernel/CanvasContextBar.d.ts +23 -0
- package/dist/kernel/CanvasContextBar.js +12 -0
- package/dist/kernel/CanvasFrame.d.ts +56 -0
- package/dist/kernel/CanvasFrame.js +53 -0
- package/dist/kernel/ai/aiInlineApply.d.ts +326 -0
- package/dist/kernel/ai/aiInlineApply.js +461 -0
- package/dist/kernel/ai/imageSource.d.ts +24 -0
- package/dist/kernel/ai/imageSource.js +19 -0
- package/dist/kernel/ai/insertHtmlGate.d.ts +71 -0
- package/dist/kernel/ai/insertHtmlGate.js +123 -0
- package/dist/kernel/ai/postApplyCheck.d.ts +56 -0
- package/dist/kernel/ai/postApplyCheck.js +74 -0
- package/dist/kernel/ai/replaceHtmlGate.d.ts +71 -0
- package/dist/kernel/ai/replaceHtmlGate.js +147 -0
- package/dist/kernel/blocks/BlockOverlay.d.ts +16 -0
- package/dist/kernel/blocks/BlockOverlay.js +66 -0
- package/dist/kernel/blocks/annotateAndTile.d.ts +106 -0
- package/dist/kernel/blocks/annotateAndTile.js +122 -0
- package/dist/kernel/blocks/resolveBlocks.d.ts +153 -0
- package/dist/kernel/blocks/resolveBlocks.js +233 -0
- package/dist/kernel/canvasCapabilities.d.ts +31 -0
- package/dist/kernel/canvasCapabilities.js +39 -0
- package/dist/kernel/canvasCommands.d.ts +126 -0
- package/dist/kernel/canvasCommands.js +146 -0
- package/dist/kernel/canvasErrors.d.ts +1 -0
- package/dist/kernel/canvasErrors.js +14 -0
- package/dist/kernel/captureGeometry.d.ts +35 -0
- package/dist/kernel/captureGeometry.js +25 -0
- package/dist/kernel/captureOrchestration.d.ts +48 -0
- package/dist/kernel/captureOrchestration.js +43 -0
- package/dist/kernel/captureSlices.d.ts +36 -0
- package/dist/kernel/captureSlices.js +20 -0
- package/dist/kernel/changelist/ChangeListPanel.d.ts +13 -0
- package/dist/kernel/changelist/ChangeListPanel.js +47 -0
- package/dist/kernel/changelist/ChangeListPopover.d.ts +20 -0
- package/dist/kernel/changelist/ChangeListPopover.js +10 -0
- package/dist/kernel/changelist/builders.d.ts +89 -0
- package/dist/kernel/changelist/builders.js +81 -0
- package/dist/kernel/changelist/changeSummary.d.ts +19 -0
- package/dist/kernel/changelist/changeSummary.js +85 -0
- package/dist/kernel/changelist/clipboard.d.ts +16 -0
- package/dist/kernel/changelist/clipboard.js +4 -0
- package/dist/kernel/changelist/exportForSave.d.ts +30 -0
- package/dist/kernel/changelist/exportForSave.js +11 -0
- package/dist/kernel/changelist/mergeForSave.d.ts +79 -0
- package/dist/kernel/changelist/mergeForSave.js +40 -0
- package/dist/kernel/changelist/moveObserverSafety.d.ts +70 -0
- package/dist/kernel/changelist/moveObserverSafety.js +39 -0
- package/dist/kernel/changelist/overrides.d.ts +22 -0
- package/dist/kernel/changelist/overrides.js +63 -0
- package/dist/kernel/changelist/useChangeList.d.ts +185 -0
- package/dist/kernel/changelist/useChangeList.js +259 -0
- package/dist/kernel/config.d.ts +29 -0
- package/dist/kernel/config.js +10 -0
- package/dist/kernel/dataUrlToBlob.d.ts +8 -0
- package/dist/kernel/dataUrlToBlob.js +17 -0
- package/dist/kernel/linkHref.d.ts +25 -0
- package/dist/kernel/linkHref.js +16 -0
- package/dist/kernel/mobileFit.d.ts +46 -0
- package/dist/kernel/mobileFit.js +44 -0
- package/dist/kernel/moveState.d.ts +35 -0
- package/dist/kernel/moveState.js +14 -0
- package/dist/kernel/selection/SelectionOverlay.d.ts +10 -0
- package/dist/kernel/selection/SelectionOverlay.js +25 -0
- package/dist/kernel/selection/SelectionToolbar.d.ts +30 -0
- package/dist/kernel/selection/SelectionToolbar.js +82 -0
- package/dist/kernel/selection/SelectorPathBar.d.ts +27 -0
- package/dist/kernel/selection/SelectorPathBar.js +55 -0
- package/dist/kernel/selection/buildDescendantShelf.d.ts +40 -0
- package/dist/kernel/selection/buildDescendantShelf.js +50 -0
- package/dist/kernel/selection/buildRootShelf.d.ts +26 -0
- package/dist/kernel/selection/buildRootShelf.js +149 -0
- package/dist/kernel/selection/elementAiContext.d.ts +313 -0
- package/dist/kernel/selection/elementAiContext.js +122 -0
- package/dist/kernel/selection/elementGeometryForAi.d.ts +9 -0
- package/dist/kernel/selection/elementGeometryForAi.js +15 -0
- package/dist/kernel/selection/geometry.d.ts +55 -0
- package/dist/kernel/selection/geometry.js +18 -0
- package/dist/kernel/selection/resolveAnchors.d.ts +116 -0
- package/dist/kernel/selection/resolveAnchors.js +173 -0
- package/dist/kernel/selection/shelfRow.d.ts +33 -0
- package/dist/kernel/selection/shelfRow.js +19 -0
- package/dist/kernel/selection/tagLabels.d.ts +9 -0
- package/dist/kernel/selection/tagLabels.js +46 -0
- package/dist/kernel/selection/toolbarButtons.d.ts +60 -0
- package/dist/kernel/selection/toolbarButtons.js +105 -0
- package/dist/kernel/selection/useAiSelectionContext.d.ts +26 -0
- package/dist/kernel/selection/useAiSelectionContext.js +26 -0
- package/dist/kernel/selection/useCanvasGeometry.d.ts +32 -0
- package/dist/kernel/selection/useCanvasGeometry.js +47 -0
- package/dist/kernel/selection/useSelectionRestore.d.ts +18 -0
- package/dist/kernel/selection/useSelectionRestore.js +25 -0
- package/dist/kernel/shrinkImageForUpload.d.ts +45 -0
- package/dist/kernel/shrinkImageForUpload.js +61 -0
- package/dist/kernel/useCanvasBridge.d.ts +36 -0
- package/dist/kernel/useCanvasBridge.js +41 -0
- package/dist/kernel/useCanvasGate.d.ts +22 -0
- package/dist/kernel/useCanvasGate.js +35 -0
- package/dist/kernel/useCanvasSession.d.ts +44 -0
- package/dist/kernel/useCanvasSession.js +182 -0
- package/dist/kernel/useCanvasStream.d.ts +39 -0
- package/dist/kernel/useCanvasStream.js +45 -0
- package/dist/kernel/useMobileFit.d.ts +40 -0
- package/dist/kernel/useMobileFit.js +139 -0
- package/dist/locales.d.ts +1094 -0
- package/dist/locales.js +545 -0
- package/dist/panels/StylePanel.d.ts +24 -0
- package/dist/panels/StylePanel.js +40 -0
- package/dist/panels/styleFields.d.ts +39 -0
- package/dist/panels/styleFields.js +91 -0
- package/dist/tree/LayersPanel.d.ts +30 -0
- package/dist/tree/LayersPanel.js +15 -0
- package/dist/tree/domTreeAdapter.d.ts +63 -0
- package/dist/tree/domTreeAdapter.js +90 -0
- package/dist/useInlineEditor.d.ts +158 -0
- package/dist/useInlineEditor.js +343 -0
- package/package.json +58 -3
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { buildDescendantShelfWithPaths } from './buildDescendantShelf.js';
|
|
2
|
+
import { AI_DESCENDANT_LIMIT, AI_DESCENDANT_TEXT_CHARS } from './elementAiContext.js';
|
|
3
|
+
export function blockContainerFor(anchor, shelf, ancestorsOf) {
|
|
4
|
+
const isBlockRow = (selector) => {
|
|
5
|
+
if (selector === 'body')
|
|
6
|
+
return false;
|
|
7
|
+
const row = shelf.find(r => r.selector === selector);
|
|
8
|
+
return !!row && row.hasChildren;
|
|
9
|
+
};
|
|
10
|
+
if (isBlockRow(anchor.selector))
|
|
11
|
+
return anchor.selector;
|
|
12
|
+
for (const up of ancestorsOf.get(anchor.selector) ?? []) {
|
|
13
|
+
if (isBlockRow(up))
|
|
14
|
+
return up;
|
|
15
|
+
}
|
|
16
|
+
return anchor.selector;
|
|
17
|
+
}
|
|
18
|
+
export function backgroundImageAncestorFor(selector, shelf, ancestorsOf) {
|
|
19
|
+
const withBackground = new Set(shelf.filter(r => r.image === 'background').map(r => r.selector));
|
|
20
|
+
if (withBackground.has(selector))
|
|
21
|
+
return null;
|
|
22
|
+
for (const up of ancestorsOf.get(selector) ?? []) {
|
|
23
|
+
if (up === 'body')
|
|
24
|
+
return null;
|
|
25
|
+
if (withBackground.has(up))
|
|
26
|
+
return up;
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
export function withImageRows(shelf, anchorRowCount, images) {
|
|
31
|
+
const kindOf = new Map();
|
|
32
|
+
for (const im of images)
|
|
33
|
+
if (!kindOf.has(im.selector))
|
|
34
|
+
kindOf.set(im.selector, im.kind);
|
|
35
|
+
if (!kindOf.size)
|
|
36
|
+
return { shelf, added: 0 };
|
|
37
|
+
const next = shelf.map(row => {
|
|
38
|
+
const kind = kindOf.get(row.selector);
|
|
39
|
+
return kind && !row.image ? { ...row, image: kind } : row;
|
|
40
|
+
});
|
|
41
|
+
const present = new Set(shelf.map(r => r.selector));
|
|
42
|
+
const extra = [];
|
|
43
|
+
for (const [selector, kind] of kindOf) {
|
|
44
|
+
if (present.has(selector))
|
|
45
|
+
continue;
|
|
46
|
+
extra.push({
|
|
47
|
+
selector,
|
|
48
|
+
tag: kind === 'img'
|
|
49
|
+
? 'img'
|
|
50
|
+
: (/^([a-z][a-z0-9-]*)/i.exec(selector)?.[1] ?? 'div').toLowerCase(),
|
|
51
|
+
text: '',
|
|
52
|
+
hasChildren: kind === 'background',
|
|
53
|
+
image: kind
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
if (!extra.length)
|
|
57
|
+
return { shelf: next, added: 0 };
|
|
58
|
+
return {
|
|
59
|
+
shelf: [...next.slice(0, anchorRowCount), ...extra, ...next.slice(anchorRowCount)],
|
|
60
|
+
added: extra.length
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function normalizeLabel(s) {
|
|
64
|
+
return s.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
65
|
+
}
|
|
66
|
+
function indexAncestors(root) {
|
|
67
|
+
const map = new Map();
|
|
68
|
+
const walk = (n, ancestors) => {
|
|
69
|
+
for (const child of n.children) {
|
|
70
|
+
if (!map.has(child.selector))
|
|
71
|
+
map.set(child.selector, ancestors);
|
|
72
|
+
walk(child, [child.selector, ...ancestors]);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
map.set(root.selector, []);
|
|
76
|
+
walk(root, [root.selector]);
|
|
77
|
+
return map;
|
|
78
|
+
}
|
|
79
|
+
function findUniqueByLabel(root, label) {
|
|
80
|
+
const want = normalizeLabel(label);
|
|
81
|
+
if (!want)
|
|
82
|
+
return null;
|
|
83
|
+
const found = [];
|
|
84
|
+
const walk = (n) => {
|
|
85
|
+
const heading = n.heading ? normalizeLabel(n.heading) : '';
|
|
86
|
+
const text = normalizeLabel(n.textSummary);
|
|
87
|
+
if (heading === want || text === want)
|
|
88
|
+
found.push(n);
|
|
89
|
+
for (const child of n.children)
|
|
90
|
+
walk(child);
|
|
91
|
+
};
|
|
92
|
+
walk(root);
|
|
93
|
+
return found.length === 1 ? found[0] : null;
|
|
94
|
+
}
|
|
95
|
+
function rowOf(n) {
|
|
96
|
+
const hasChildren = n.childCount > 0 || n.children.length > 0;
|
|
97
|
+
const row = {
|
|
98
|
+
selector: n.selector,
|
|
99
|
+
tag: n.tag,
|
|
100
|
+
text: n.textSummary.trim().slice(0, AI_DESCENDANT_TEXT_CHARS),
|
|
101
|
+
hasChildren
|
|
102
|
+
};
|
|
103
|
+
if (hasChildren && n.heading)
|
|
104
|
+
row.heading = n.heading;
|
|
105
|
+
if (n.rect)
|
|
106
|
+
row.top = n.rect.top;
|
|
107
|
+
if (n.image)
|
|
108
|
+
row.image = n.image;
|
|
109
|
+
return row;
|
|
110
|
+
}
|
|
111
|
+
export async function resolveInlinePlanAnchors(input) {
|
|
112
|
+
const { anchors, rootTree, resolveSelector, getSubtree } = input;
|
|
113
|
+
const limitPerAnchor = input.limitPerAnchor ?? AI_DESCENDANT_LIMIT;
|
|
114
|
+
const shelf = [];
|
|
115
|
+
const ancestorsOf = new Map();
|
|
116
|
+
const outcomes = [];
|
|
117
|
+
const seen = new Set();
|
|
118
|
+
const rootAncestors = indexAncestors(rootTree);
|
|
119
|
+
const push = (row, chain) => {
|
|
120
|
+
if (seen.has(row.selector))
|
|
121
|
+
return;
|
|
122
|
+
seen.add(row.selector);
|
|
123
|
+
shelf.push(row);
|
|
124
|
+
ancestorsOf.set(row.selector, chain);
|
|
125
|
+
};
|
|
126
|
+
for (const anchor of anchors) {
|
|
127
|
+
let resolvedSelector = null;
|
|
128
|
+
let via;
|
|
129
|
+
try {
|
|
130
|
+
const probe = await resolveSelector(anchor.selector);
|
|
131
|
+
if (probe.matched && probe.matchCount === 1) {
|
|
132
|
+
resolvedSelector = anchor.selector;
|
|
133
|
+
via = 'selector';
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
}
|
|
138
|
+
if (!resolvedSelector) {
|
|
139
|
+
const byLabel = findUniqueByLabel(rootTree, anchor.label);
|
|
140
|
+
if (byLabel) {
|
|
141
|
+
resolvedSelector = byLabel.selector;
|
|
142
|
+
via = 'label';
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (!resolvedSelector) {
|
|
146
|
+
outcomes.push({ ...anchor, resolved: false });
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
outcomes.push({ ...anchor, resolved: true, via });
|
|
150
|
+
const ownAncestors = rootAncestors.get(resolvedSelector) ?? [];
|
|
151
|
+
let subtree = null;
|
|
152
|
+
try {
|
|
153
|
+
subtree = await getSubtree(resolvedSelector);
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
}
|
|
157
|
+
if (!subtree) {
|
|
158
|
+
push({ selector: resolvedSelector, tag: '', text: '', hasChildren: true }, ownAncestors);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const anchorSelector = subtree.selector || resolvedSelector;
|
|
162
|
+
push(rowOf(subtree), ownAncestors);
|
|
163
|
+
const { shelf: rows, ancestorsOf: within } = buildDescendantShelfWithPaths(subtree, limitPerAnchor);
|
|
164
|
+
for (const row of rows) {
|
|
165
|
+
const chainWithin = within.get(row.selector) ?? [];
|
|
166
|
+
const chain = chainWithin.includes(anchorSelector)
|
|
167
|
+
? [...chainWithin, ...ownAncestors]
|
|
168
|
+
: [...chainWithin, anchorSelector, ...ownAncestors];
|
|
169
|
+
push({ ...row, depth: (row.depth ?? 0) + 1 }, chain);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return { shelf, ancestorsOf, anchors: outcomes };
|
|
173
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 货架行 → prompt 里的一行文本。**唯一**的格式化出口。
|
|
3
|
+
*
|
|
4
|
+
* 此前 `apps/x` 里有两份逐字相同的实现(`pageModel.shelfLine` 与 `inlineElementAi` 的
|
|
5
|
+
* `editable-descendants` 循环)。agent 学的是一套读法(skill 里就写着这一种行形),两份实现
|
|
6
|
+
* 一漂移就是「同一个元素在 `<page-block>` 与 `<page-element>` 里长得不一样」,而它会按其中
|
|
7
|
+
* 一种去抄 selector。
|
|
8
|
+
*
|
|
9
|
+
* 行形:` - selector <tag> [container] [image: …] [link→…] top:N "text"`,`depth` 每加一层在 `- ` 前
|
|
10
|
+
* 多两个空格。缩进是 2026-09-30 加的:平铺的货架看不出谁是谁的父级,agent 分不清
|
|
11
|
+
* 「after 容器」与「after 容器里的标题」落在哪 —— 真机里它 after 了一个 flex 横排里的 Column,
|
|
12
|
+
* 新加的一行跑到了标题右边。
|
|
13
|
+
*/
|
|
14
|
+
import type { AiDescendant } from './elementAiContext.js';
|
|
15
|
+
/** 引用文本的长度上限。正文在 outerHtml 里,这里只要够认出是哪个元素。 */
|
|
16
|
+
export declare const SHELF_TEXT_MAX = 60;
|
|
17
|
+
/**
|
|
18
|
+
* 货架行上的图片位标记:` [image: background]` / ` [image: img]`,不是图片位时空串。
|
|
19
|
+
*
|
|
20
|
+
* 写成与 `<page-element>` 头部那行 `image: background` 同一个词:agent 在 skill 里学的是一套读法,
|
|
21
|
+
* 「这一行换图落在 background-image 上」。没有它时 CSS 背景图容器与普通 div 长得一模一样,
|
|
22
|
+
* agent 换首屏图就去找最近的 `<img>`(真机 2026-09-23:落到了下一个区块的商品图上)。
|
|
23
|
+
*/
|
|
24
|
+
export declare function imageTag(d: Pick<AiDescendant, 'image'>): string;
|
|
25
|
+
/**
|
|
26
|
+
* 货架行上的链接位:` [link→/products/…]`(去向写法见 `linkPath`),不是链接时空串。
|
|
27
|
+
*
|
|
28
|
+
* 没有它时 PageFly 的图片链接(`<img data-href>`)与普通图片长得一模一样 —— 真机 2026-10-15,
|
|
29
|
+
* 方案卡据此断言「全页没有购买导线」,而那页几乎每张图都链到商品页。
|
|
30
|
+
*/
|
|
31
|
+
export declare function linkTag(d: Pick<AiDescendant, 'href'>): string;
|
|
32
|
+
/** 叶子行优先写自己的直接文本;容器行(自己没有文本)才写 heading。 */
|
|
33
|
+
export declare function formatShelfRow(d: AiDescendant): string;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { linkPath } from '../linkHref.js';
|
|
2
|
+
export const SHELF_TEXT_MAX = 60;
|
|
3
|
+
export function imageTag(d) {
|
|
4
|
+
return d.image ? ` [image: ${d.image}]` : '';
|
|
5
|
+
}
|
|
6
|
+
export function linkTag(d) {
|
|
7
|
+
return d.href ? ` [link→${linkPath(d.href)}]` : '';
|
|
8
|
+
}
|
|
9
|
+
function quote(text, max) {
|
|
10
|
+
const clean = text.replace(/\s+/g, ' ').replace(/"/g, "'").trim();
|
|
11
|
+
return `"${clean.length > max ? clean.slice(0, max) : clean}"`;
|
|
12
|
+
}
|
|
13
|
+
export function formatShelfRow(d) {
|
|
14
|
+
const indent = ' '.repeat(Math.max(0, d.depth ?? 0));
|
|
15
|
+
const tag = d.tag ? ` <${d.tag}>` : '';
|
|
16
|
+
const pos = typeof d.top === 'number' ? ` top:${Math.round(d.top)}` : '';
|
|
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)}` : ''}`;
|
|
19
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** 该 tag 是否有友好名。大小写不敏感。 */
|
|
2
|
+
export declare function hasTagLabel(tag: string): boolean;
|
|
3
|
+
/**
|
|
4
|
+
* 该 tag 对应的 i18n key;无友好名时返回 `null`(**不是空串、也不是 tag 名**)。
|
|
5
|
+
* 返回空串会让调用方渲染出一个空标签;返回 tag 名会让「有没有友好名」这个信息丢失。
|
|
6
|
+
*/
|
|
7
|
+
export declare function tagLabelKey(tag: string): string | null;
|
|
8
|
+
/** 供测试与非 i18n 场景使用:全部有友好名的 tag(升序)。 */
|
|
9
|
+
export declare function tagsWithLabel(): string[];
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const TAGS_WITH_LABEL = new Set([
|
|
2
|
+
'A',
|
|
3
|
+
'B',
|
|
4
|
+
'BODY',
|
|
5
|
+
'BUTTON',
|
|
6
|
+
'CAPTION',
|
|
7
|
+
'DIV',
|
|
8
|
+
'FORM',
|
|
9
|
+
'H1',
|
|
10
|
+
'H2',
|
|
11
|
+
'H3',
|
|
12
|
+
'I',
|
|
13
|
+
'IFRAME',
|
|
14
|
+
'IMG',
|
|
15
|
+
'INPUT',
|
|
16
|
+
'LABEL',
|
|
17
|
+
'LI',
|
|
18
|
+
'NAV',
|
|
19
|
+
'OL',
|
|
20
|
+
'OPTION',
|
|
21
|
+
'P',
|
|
22
|
+
'PRE',
|
|
23
|
+
'SELECT',
|
|
24
|
+
'SPAN',
|
|
25
|
+
'TABLE',
|
|
26
|
+
'TBODY',
|
|
27
|
+
'TD',
|
|
28
|
+
'TEXTAREA',
|
|
29
|
+
'TFOOT',
|
|
30
|
+
'TH',
|
|
31
|
+
'THEAD',
|
|
32
|
+
'TR',
|
|
33
|
+
'U',
|
|
34
|
+
'UL',
|
|
35
|
+
'VIDEO'
|
|
36
|
+
]);
|
|
37
|
+
export function hasTagLabel(tag) {
|
|
38
|
+
return TAGS_WITH_LABEL.has(tag.toUpperCase());
|
|
39
|
+
}
|
|
40
|
+
export function tagLabelKey(tag) {
|
|
41
|
+
const upper = tag.toUpperCase();
|
|
42
|
+
return TAGS_WITH_LABEL.has(upper) ? `taginfo.${upper}` : null;
|
|
43
|
+
}
|
|
44
|
+
export function tagsWithLabel() {
|
|
45
|
+
return [...TAGS_WITH_LABEL].sort();
|
|
46
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { ElementCapabilities } from '@ptengine/inline-core/protocol';
|
|
2
|
+
/**
|
|
3
|
+
* 工具条按钮**配置表** —— DEC-12 的核心收益在这里兑现。
|
|
4
|
+
*
|
|
5
|
+
* 现役 engage-editor 的 13 个按钮全部硬编码在模板里(`SelectMask.vue:14-286`,无配置表),
|
|
6
|
+
* 加一个按钮要改模板。而工具条 UI 移到产品页之后,加按钮**不需要过商店审核**
|
|
7
|
+
* —— 前提是它是**数据**而不是代码分支。
|
|
8
|
+
*
|
|
9
|
+
* 设计稿 §5 首期只实装 4 个(删除 / 编辑样式 / 编辑 HTML / 编辑文字),其余按需追加 ——
|
|
10
|
+
* 到 2026-08-10 已补齐现役 13 个里除「AI 助手」外的全部:替换图片、替换视频、点击动作、
|
|
11
|
+
* 移动、复制、粘贴、图片设置、插入,另加本项目特有的「截图」。
|
|
12
|
+
*
|
|
13
|
+
* 加一个按钮 = 这张表里加一行数据 + `InlineEditor` 里加一个分支 + 三语文案各一条,
|
|
14
|
+
* **不需要过商店审核**(工具条 UI 在产品页)—— 这正是 DEC-12 的收益。
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* 图标标识;渲染层(SelectionToolbar 的 TOOLBAR_ICONS)映射到 lucide 图标。
|
|
18
|
+
* 写成联合类型而不是 string:新增一个标识却忘了在渲染层映射,类型检查就过不去。
|
|
19
|
+
*/
|
|
20
|
+
export type ToolbarIconName = 'image' | 'video' | 'plus' | 'setting' | 'click' | 'move' | 'copy' | 'paste' | 'text' | 'style' | 'code' | 'camera' | 'delete' | 'sparkles';
|
|
21
|
+
export interface ToolbarButtonSpec {
|
|
22
|
+
id: string;
|
|
23
|
+
/** apps/x i18n 的 key。 */
|
|
24
|
+
labelKey: string;
|
|
25
|
+
icon: ToolbarIconName;
|
|
26
|
+
/** 返回 false 则不渲染这个按钮。 */
|
|
27
|
+
gate: (caps: ElementCapabilities) => boolean;
|
|
28
|
+
/**
|
|
29
|
+
* 这个按钮依赖宿主注入的哪项能力。宿主没提供就**不渲染** ——
|
|
30
|
+
* 渲染出来点了没反应比没有更让人困惑。
|
|
31
|
+
*
|
|
32
|
+
* 与 `gate` 分开:`gate` 判「这个元素能不能做」,这里判「这个产品入口有没有接」。
|
|
33
|
+
* 两者混在一起的话,`visibleButtons` 的单测就得为每个按钮都造一遍宿主状态。
|
|
34
|
+
*/
|
|
35
|
+
requiresHost?: keyof ToolbarHostCapabilities;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* 宿主注入的能力。缺省全 false —— 包不假设任何一项存在。
|
|
39
|
+
*
|
|
40
|
+
* `askAi` 只有产品里**真有右侧 AI 面板可召唤**的入口才该给 true:详情页给,
|
|
41
|
+
* 分步创建流程不给(那条路由上 dock 压根不渲染,见 apps/x 的 isAiDockRoute)。
|
|
42
|
+
*/
|
|
43
|
+
export interface ToolbarHostCapabilities {
|
|
44
|
+
askAi?: boolean;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* 数组顺序就是工具条上的顺序:先编辑(内容 → 样式 → 点击动作 → HTML),再对元素的操作,
|
|
48
|
+
* 「问 AI」恒最右。删除排在元素操作末尾,和最右的 AI 胶囊挨着但不混在编辑按钮中间。
|
|
49
|
+
*/
|
|
50
|
+
export declare const TOOLBAR_BUTTONS: ToolbarButtonSpec[];
|
|
51
|
+
export declare function visibleButtons(caps: ElementCapabilities, host?: ToolbarHostCapabilities): ToolbarButtonSpec[];
|
|
52
|
+
/**
|
|
53
|
+
* 「插入 / 粘贴」可选的位置,按 tagName 白名单裁剪 —— 对齐现役
|
|
54
|
+
* `SelectMask.vue:426-449`:不能有子节点就去掉 insert/append,不能插同级就去掉
|
|
55
|
+
* before/after(`TD`/`TH`)。
|
|
56
|
+
*
|
|
57
|
+
* ⚠️ 返回空数组是**合法结果**(比如 `<td>` 里的 `<img>`)。调用方必须处理,
|
|
58
|
+
* 不能假设至少有一个位置可选 —— 否则会渲染出一个点了没反应的下拉。
|
|
59
|
+
*/
|
|
60
|
+
export declare function pasteModes(caps: ElementCapabilities): Array<'insert' | 'append' | 'before' | 'after'>;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
function notRoot(caps) {
|
|
2
|
+
return !caps.isRootLevel;
|
|
3
|
+
}
|
|
4
|
+
export const TOOLBAR_BUTTONS = [
|
|
5
|
+
{
|
|
6
|
+
id: 'edit-text',
|
|
7
|
+
labelKey: 'inlineCanvas.toolbar.editText',
|
|
8
|
+
icon: 'text',
|
|
9
|
+
gate: caps => notRoot(caps) && caps.hasText
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
id: 'replace-image',
|
|
13
|
+
labelKey: 'inlineCanvas.toolbar.replaceImage',
|
|
14
|
+
icon: 'image',
|
|
15
|
+
gate: caps => notRoot(caps) && (caps.isImg || caps.hasBackgroundImage)
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
id: 'replace-video',
|
|
19
|
+
labelKey: 'inlineCanvas.toolbar.replaceVideo',
|
|
20
|
+
icon: 'video',
|
|
21
|
+
gate: caps => notRoot(caps) && caps.isVideo
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: 'image-setting',
|
|
25
|
+
labelKey: 'inlineCanvas.toolbar.imageSetting',
|
|
26
|
+
icon: 'setting',
|
|
27
|
+
gate: caps => notRoot(caps) && caps.isImg
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: 'edit-style',
|
|
31
|
+
labelKey: 'inlineCanvas.toolbar.editStyle',
|
|
32
|
+
icon: 'style',
|
|
33
|
+
gate: notRoot
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: 'click-action',
|
|
37
|
+
labelKey: 'inlineCanvas.toolbar.clickAction',
|
|
38
|
+
icon: 'click',
|
|
39
|
+
gate: caps => notRoot(caps) && caps.canEditLink
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: 'edit-html',
|
|
43
|
+
labelKey: 'inlineCanvas.toolbar.editHtml',
|
|
44
|
+
icon: 'code',
|
|
45
|
+
gate: notRoot
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: 'insert',
|
|
49
|
+
labelKey: 'inlineCanvas.toolbar.insert',
|
|
50
|
+
icon: 'plus',
|
|
51
|
+
gate: caps => notRoot(caps) && (caps.canHaveChildren || caps.canInsertSibling)
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: 'move',
|
|
55
|
+
labelKey: 'inlineCanvas.toolbar.move',
|
|
56
|
+
icon: 'move',
|
|
57
|
+
gate: notRoot
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
id: 'copy',
|
|
61
|
+
labelKey: 'inlineCanvas.toolbar.copy',
|
|
62
|
+
icon: 'copy',
|
|
63
|
+
gate: caps => notRoot(caps) && caps.canHaveChildren
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: 'paste',
|
|
67
|
+
labelKey: 'inlineCanvas.toolbar.paste',
|
|
68
|
+
icon: 'paste',
|
|
69
|
+
gate: caps => notRoot(caps) && (caps.canHaveChildren || caps.canInsertSibling)
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: 'screenshot',
|
|
73
|
+
labelKey: 'inlineCanvas.toolbar.screenshot',
|
|
74
|
+
icon: 'camera',
|
|
75
|
+
gate: notRoot
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: 'delete',
|
|
79
|
+
labelKey: 'inlineCanvas.toolbar.delete',
|
|
80
|
+
icon: 'delete',
|
|
81
|
+
gate: notRoot
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
id: 'ask-ai',
|
|
85
|
+
labelKey: 'inlineCanvas.toolbar.askAi',
|
|
86
|
+
icon: 'sparkles',
|
|
87
|
+
gate: notRoot,
|
|
88
|
+
requiresHost: 'askAi'
|
|
89
|
+
}
|
|
90
|
+
];
|
|
91
|
+
export function visibleButtons(caps, host = {}) {
|
|
92
|
+
return TOOLBAR_BUTTONS.filter(b => {
|
|
93
|
+
if (b.requiresHost && !host[b.requiresHost])
|
|
94
|
+
return false;
|
|
95
|
+
return b.gate(caps);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
export function pasteModes(caps) {
|
|
99
|
+
const modes = [];
|
|
100
|
+
if (caps.canHaveChildren)
|
|
101
|
+
modes.push('insert', 'append');
|
|
102
|
+
if (caps.canInsertSibling)
|
|
103
|
+
modes.push('before', 'after');
|
|
104
|
+
return modes;
|
|
105
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ElementAiContext } from './elementAiContext.js';
|
|
2
|
+
export interface AiSelectionContextInput<T> {
|
|
3
|
+
/**
|
|
4
|
+
* 是否跟随。宿主在**右侧 AI 面板已打开**时给 true。
|
|
5
|
+
*
|
|
6
|
+
* 关着还跟的话,每点一个元素都白花一次跨域往返(取 outerHTML),而那份上下文没人看;
|
|
7
|
+
* 开着不跟的话,用户换了选中而 AI 还在聊上一个元素 —— 这种错位没有任何提示。
|
|
8
|
+
*/
|
|
9
|
+
enabled?: boolean;
|
|
10
|
+
/** 当前选中的元素;`null` / `undefined` = 没有选中。 */
|
|
11
|
+
picked: T | null | undefined;
|
|
12
|
+
/** 画布会话;没有会话时什么都不做(命令发不出去)。 */
|
|
13
|
+
sessionId: string | undefined;
|
|
14
|
+
/** 取 outerHTML —— 由调用方绑好 sessionId。 */
|
|
15
|
+
readOuterHtml: (selector: string) => Promise<{
|
|
16
|
+
html: string;
|
|
17
|
+
}>;
|
|
18
|
+
selectorOf: (picked: T) => string;
|
|
19
|
+
/** 由选中态 + HTML 组上下文。与点 ✨ 那条路**共用同一份**,避免两处口径漂移。 */
|
|
20
|
+
buildContext: (picked: T, outerHtml: string) => ElementAiContext;
|
|
21
|
+
/** 延迟截图函数的工厂(发送时才真的拍)。 */
|
|
22
|
+
captureFor: (selector: string) => () => Promise<string | null>;
|
|
23
|
+
/** 上下文变化。`null` = 选中被清空。 */
|
|
24
|
+
onChange?: (ctx: ElementAiContext | null, capture: () => Promise<string | null>) => void;
|
|
25
|
+
}
|
|
26
|
+
export declare function useAiSelectionContext<T>(input: AiSelectionContextInput<T>): void;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
const NO_CAPTURE = () => Promise.resolve(null);
|
|
3
|
+
export function useAiSelectionContext(input) {
|
|
4
|
+
const { enabled, picked, sessionId, readOuterHtml, selectorOf, buildContext, captureFor, onChange } = input;
|
|
5
|
+
useEffect(() => {
|
|
6
|
+
if (!enabled || !onChange)
|
|
7
|
+
return;
|
|
8
|
+
if (!picked || !sessionId) {
|
|
9
|
+
onChange(null, NO_CAPTURE);
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
const selector = selectorOf(picked);
|
|
13
|
+
let stale = false;
|
|
14
|
+
void readOuterHtml(selector)
|
|
15
|
+
.then(r => {
|
|
16
|
+
if (stale)
|
|
17
|
+
return;
|
|
18
|
+
onChange(buildContext(picked, r.html), captureFor(selector));
|
|
19
|
+
})
|
|
20
|
+
.catch(() => {
|
|
21
|
+
});
|
|
22
|
+
return () => {
|
|
23
|
+
stale = true;
|
|
24
|
+
};
|
|
25
|
+
}, [enabled, picked, sessionId, onChange, buildContext]);
|
|
26
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { CanvasGeometryPayload } from '@ptengine/inline-core/protocol';
|
|
2
|
+
/**
|
|
3
|
+
* 消费 agent 推来的 `geometry` 事件(DEC-15)。
|
|
4
|
+
*
|
|
5
|
+
* 两件事:
|
|
6
|
+
* 1. **按 `frameSeq` 丢弃乱序到达的旧帧** —— agent → SW → Port → React 两跳不保证
|
|
7
|
+
* 顺序,旧帧会让高亮框来回跳。
|
|
8
|
+
* 2. **维护滚动态** —— DEC-15 采纳的缓解措施:滚动期间隐藏工具条(高亮框不隐藏,
|
|
9
|
+
* 它必须跟随),停止一小段时间后按最新几何重新显示。
|
|
10
|
+
*/
|
|
11
|
+
/** 滚动停止后多久认为「滚完了」。取值凭手感:太短会在惯性滚动里闪,太长显得迟钝。 */
|
|
12
|
+
export declare const SCROLL_IDLE_MS = 150;
|
|
13
|
+
/** ⚠️ TEMP-DEBUG(区块框不跟随滚动的排查用,定位完删掉)。 */
|
|
14
|
+
export interface CanvasGeometryDebug {
|
|
15
|
+
/** agent 推来的原始帧数(过滤之前)。滚动时它不涨 = 通道断了,不是消费侧的问题。 */
|
|
16
|
+
received: number;
|
|
17
|
+
/** 被 frameSeq 乱序保护丢掉的帧数。滚动时它涨 = agent 的 seq 回退了(frame 重载)。 */
|
|
18
|
+
dropped: number;
|
|
19
|
+
}
|
|
20
|
+
export interface CanvasGeometry {
|
|
21
|
+
/** 最新一帧(已过乱序过滤)。`null` = 还没收到任何一帧。 */
|
|
22
|
+
latest: CanvasGeometryPayload | null;
|
|
23
|
+
/** ⚠️ TEMP-DEBUG,见 `CanvasGeometryDebug`。 */
|
|
24
|
+
debug: CanvasGeometryDebug;
|
|
25
|
+
/** 滚动中。工具条据此隐藏;高亮框**不要**用它。 */
|
|
26
|
+
isScrolling: boolean;
|
|
27
|
+
/** 供 stream 层把收到的 geometry 事件喂进来。 */
|
|
28
|
+
onGeometry: (payload: CanvasGeometryPayload) => void;
|
|
29
|
+
/** 切换会话 / iframe reload 时重置(frameSeq 会从 0 重新开始)。 */
|
|
30
|
+
reset: () => void;
|
|
31
|
+
}
|
|
32
|
+
export declare function useCanvasGeometry(): CanvasGeometry;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
export const SCROLL_IDLE_MS = 150;
|
|
3
|
+
export function useCanvasGeometry() {
|
|
4
|
+
const [latest, setLatest] = useState(null);
|
|
5
|
+
const [isScrolling, setIsScrolling] = useState(false);
|
|
6
|
+
const [debug, setDebug] = useState({ received: 0, dropped: 0 });
|
|
7
|
+
const maxSeqRef = useRef(-1);
|
|
8
|
+
const lastScrollRef = useRef(null);
|
|
9
|
+
const idleTimerRef = useRef(null);
|
|
10
|
+
const clearIdleTimer = useCallback(() => {
|
|
11
|
+
if (idleTimerRef.current !== null) {
|
|
12
|
+
clearTimeout(idleTimerRef.current);
|
|
13
|
+
idleTimerRef.current = null;
|
|
14
|
+
}
|
|
15
|
+
}, []);
|
|
16
|
+
const reset = useCallback(() => {
|
|
17
|
+
maxSeqRef.current = -1;
|
|
18
|
+
lastScrollRef.current = null;
|
|
19
|
+
clearIdleTimer();
|
|
20
|
+
setLatest(null);
|
|
21
|
+
setIsScrolling(false);
|
|
22
|
+
setDebug({ received: 0, dropped: 0 });
|
|
23
|
+
}, [clearIdleTimer]);
|
|
24
|
+
const onGeometry = useCallback((payload) => {
|
|
25
|
+
setDebug(d => ({ ...d, received: d.received + 1 }));
|
|
26
|
+
if (payload.frameSeq <= maxSeqRef.current) {
|
|
27
|
+
setDebug(d => ({ ...d, dropped: d.dropped + 1 }));
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
maxSeqRef.current = payload.frameSeq;
|
|
31
|
+
const previous = lastScrollRef.current;
|
|
32
|
+
const scrolled = previous !== null &&
|
|
33
|
+
(previous.x !== payload.scrollX || previous.y !== payload.scrollY);
|
|
34
|
+
lastScrollRef.current = { x: payload.scrollX, y: payload.scrollY };
|
|
35
|
+
setLatest(payload);
|
|
36
|
+
if (scrolled) {
|
|
37
|
+
setIsScrolling(true);
|
|
38
|
+
clearIdleTimer();
|
|
39
|
+
idleTimerRef.current = setTimeout(() => {
|
|
40
|
+
idleTimerRef.current = null;
|
|
41
|
+
setIsScrolling(false);
|
|
42
|
+
}, SCROLL_IDLE_MS);
|
|
43
|
+
}
|
|
44
|
+
}, [clearIdleTimer]);
|
|
45
|
+
useEffect(() => clearIdleTimer, [clearIdleTimer]);
|
|
46
|
+
return useMemo(() => ({ latest, isScrolling, debug, onGeometry, reset }), [latest, isScrolling, debug, onGeometry, reset]);
|
|
47
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DEC-16:iframe reload 后按 identifier 重新解析选中态。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要它:**我们切设备会 reload iframe**,而现役 engage-editor
|
|
5
|
+
* **没有任何选中态恢复机制**(`SelectionModule` 构造即重置为 `body.firstElementChild`,
|
|
6
|
+
* 恢复的只有 change list)。切设备是我们新引入的高频操作,每次丢选中态会让
|
|
7
|
+
* 「PC 改完看 Mobile」这个核心动作变得难用。
|
|
8
|
+
*
|
|
9
|
+
* ⚠️ **未命中是正常分支,不是错误**:客户改版后老 selector 失效很常见。
|
|
10
|
+
* 未命中就清空选中态,**不弹错、不打断用户**。
|
|
11
|
+
*/
|
|
12
|
+
export interface UseSelectionRestoreResult {
|
|
13
|
+
/** 每次选中变化时记下来,供下一次 domReady 恢复。空串 = 清空。 */
|
|
14
|
+
remember: (selector: string) => void;
|
|
15
|
+
/** `domReady` 之后调用。返回是否恢复成功。 */
|
|
16
|
+
restore: (sessionId: string) => Promise<boolean>;
|
|
17
|
+
}
|
|
18
|
+
export declare function useSelectionRestore(): UseSelectionRestoreResult;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { useCallback, useMemo, useRef } from 'react';
|
|
2
|
+
import { resolveCanvasSelector, setSelection } from '../canvasCommands.js';
|
|
3
|
+
export function useSelectionRestore() {
|
|
4
|
+
const lastSelectorRef = useRef('');
|
|
5
|
+
const remember = useCallback((selector) => {
|
|
6
|
+
lastSelectorRef.current = selector;
|
|
7
|
+
}, []);
|
|
8
|
+
const restore = useCallback(async (sessionId) => {
|
|
9
|
+
const selector = lastSelectorRef.current;
|
|
10
|
+
if (!selector)
|
|
11
|
+
return false;
|
|
12
|
+
const result = await resolveCanvasSelector(sessionId, selector);
|
|
13
|
+
if (!result.matched) {
|
|
14
|
+
lastSelectorRef.current = '';
|
|
15
|
+
await setSelection(sessionId, '');
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
if (result.matchCount > 1) {
|
|
19
|
+
console.debug(`[canvas] 恢复选中态时 selector 命中 ${result.matchCount} 个,按契约只作用第一个:${selector}`);
|
|
20
|
+
}
|
|
21
|
+
await setSelection(sessionId, selector);
|
|
22
|
+
return true;
|
|
23
|
+
}, []);
|
|
24
|
+
return useMemo(() => ({ remember, restore }), [remember, restore]);
|
|
25
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「问 AI」截图上传前的瘦身。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要:整页截图是扩展把多片拼成的**一张 PNG**(`cropImage.ts` 固定 `image/png`),
|
|
5
|
+
* 390×8000+ 的 PNG 轻松超过后端 `/imageUpload` 的 6 MiB 上限(`UPLOAD_IMAGE_MAX_SIZE`),
|
|
6
|
+
* 实测 413。截图给模型看的是布局不是像素,所以在宿主侧重编码成 JPEG、按宽高上限等比缩、
|
|
7
|
+
* 质量梯度往下走直到体积达标。
|
|
8
|
+
*
|
|
9
|
+
* 宽高上限也要管:模型侧任何一边超过 8000px 会让整次请求 400(image-inline 的注释),
|
|
10
|
+
* 与其让服务端切片再缩,不如上传前就落在安全范围内。
|
|
11
|
+
*
|
|
12
|
+
* canvas / createImageBitmap 在 jsdom 里没有实现 → 解码/编码通过 `deps` 注入,默认实现
|
|
13
|
+
* 用浏览器 API;任何一步失败都**原样返回**原图,让上传自己去成败 —— 为一张截图让用户
|
|
14
|
+
* 发不出消息是过度反应(与 captureAiScreenshot 的容错口径一致)。
|
|
15
|
+
*/
|
|
16
|
+
export interface ShrinkLimits {
|
|
17
|
+
/** 上传体积上限(留出 multipart 头的余量,比后端 6 MiB 小一截) */
|
|
18
|
+
maxBytes: number;
|
|
19
|
+
maxWidth: number;
|
|
20
|
+
maxHeight: number;
|
|
21
|
+
}
|
|
22
|
+
export declare const AI_UPLOAD_LIMITS: ShrinkLimits;
|
|
23
|
+
/** JPEG 质量梯度:布局可辨即可,最后一档已经很糙,再低就看不清文字了。 */
|
|
24
|
+
export declare const JPEG_QUALITY_LADDER: readonly number[];
|
|
25
|
+
export interface ShrinkPlan {
|
|
26
|
+
scale: number;
|
|
27
|
+
width: number;
|
|
28
|
+
height: number;
|
|
29
|
+
}
|
|
30
|
+
/** 等比缩到宽高都不超限;永不放大。纯函数,单独测。 */
|
|
31
|
+
export declare function planShrink(size: {
|
|
32
|
+
width: number;
|
|
33
|
+
height: number;
|
|
34
|
+
}, limits: ShrinkLimits): ShrinkPlan;
|
|
35
|
+
export interface DecodedImage {
|
|
36
|
+
width: number;
|
|
37
|
+
height: number;
|
|
38
|
+
/** 交给 encode 画到 canvas 上的源(浏览器里是 ImageBitmap)。 */
|
|
39
|
+
source: unknown;
|
|
40
|
+
}
|
|
41
|
+
export interface ShrinkDeps {
|
|
42
|
+
decode: (blob: Blob) => Promise<DecodedImage>;
|
|
43
|
+
encode: (image: DecodedImage, plan: ShrinkPlan, quality: number) => Promise<Blob>;
|
|
44
|
+
}
|
|
45
|
+
export declare function shrinkImageForUpload(blob: Blob, limits?: ShrinkLimits, deps?: ShrinkDeps): Promise<Blob>;
|