@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.
Files changed (169) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +286 -2
  3. package/dist/InlineEditor.d.ts +342 -0
  4. package/dist/InlineEditor.js +867 -0
  5. package/dist/PagePreview.d.ts +83 -0
  6. package/dist/PagePreview.js +111 -0
  7. package/dist/assets/AssetsPanel.d.ts +19 -0
  8. package/dist/assets/AssetsPanel.js +23 -0
  9. package/dist/assets/componentLibrary.d.ts +23 -0
  10. package/dist/assets/componentLibrary.js +12 -0
  11. package/dist/canvas-ui/index.d.ts +17 -0
  12. package/dist/canvas-ui/index.js +7 -0
  13. package/dist/canvas-ui/scope.d.ts +13 -0
  14. package/dist/canvas-ui/scope.js +1 -0
  15. package/dist/canvas-ui/selection/classNames.d.ts +30 -0
  16. package/dist/canvas-ui/selection/classNames.js +15 -0
  17. package/dist/canvas-ui/selection/tokens.css +204 -0
  18. package/dist/canvas-ui/tree/TreePanel.d.ts +47 -0
  19. package/dist/canvas-ui/tree/TreePanel.js +132 -0
  20. package/dist/canvas-ui/tree/classNames.d.ts +21 -0
  21. package/dist/canvas-ui/tree/classNames.js +8 -0
  22. package/dist/canvas-ui/tree/order.d.ts +38 -0
  23. package/dist/canvas-ui/tree/order.js +38 -0
  24. package/dist/canvas-ui/tree/tree.css +131 -0
  25. package/dist/chrome/EditorChrome.d.ts +83 -0
  26. package/dist/chrome/EditorChrome.js +10 -0
  27. package/dist/chrome/VersionMenu.d.ts +26 -0
  28. package/dist/chrome/VersionMenu.js +86 -0
  29. package/dist/chrome/topbarPrimitives.d.ts +69 -0
  30. package/dist/chrome/topbarPrimitives.js +39 -0
  31. package/dist/chrome/useDismissOnOutside.d.ts +23 -0
  32. package/dist/chrome/useDismissOnOutside.js +27 -0
  33. package/dist/dialogs/ClickActionDialog.d.ts +24 -0
  34. package/dist/dialogs/ClickActionDialog.js +35 -0
  35. package/dist/dialogs/HtmlEditDialog.d.ts +23 -0
  36. package/dist/dialogs/HtmlEditDialog.js +23 -0
  37. package/dist/dialogs/ImageEditDialog.d.ts +30 -0
  38. package/dist/dialogs/ImageEditDialog.js +57 -0
  39. package/dist/dialogs/ImageSettingDialog.d.ts +16 -0
  40. package/dist/dialogs/ImageSettingDialog.js +17 -0
  41. package/dist/dialogs/InsertElementDialog.d.ts +23 -0
  42. package/dist/dialogs/InsertElementDialog.js +28 -0
  43. package/dist/dialogs/ReplaceVideoDialog.d.ts +18 -0
  44. package/dist/dialogs/ReplaceVideoDialog.js +16 -0
  45. package/dist/dialogs/actionTypes.d.ts +53 -0
  46. package/dist/dialogs/actionTypes.js +74 -0
  47. package/dist/dialogs/htmlValidation.d.ts +24 -0
  48. package/dist/dialogs/htmlValidation.js +37 -0
  49. package/dist/dialogs/imageEdit.d.ts +49 -0
  50. package/dist/dialogs/imageEdit.js +30 -0
  51. package/dist/index.d.ts +101 -0
  52. package/dist/index.js +53 -0
  53. package/dist/kernel/CanvasContextBar.d.ts +23 -0
  54. package/dist/kernel/CanvasContextBar.js +12 -0
  55. package/dist/kernel/CanvasFrame.d.ts +56 -0
  56. package/dist/kernel/CanvasFrame.js +53 -0
  57. package/dist/kernel/ai/aiInlineApply.d.ts +326 -0
  58. package/dist/kernel/ai/aiInlineApply.js +461 -0
  59. package/dist/kernel/ai/imageSource.d.ts +24 -0
  60. package/dist/kernel/ai/imageSource.js +19 -0
  61. package/dist/kernel/ai/insertHtmlGate.d.ts +71 -0
  62. package/dist/kernel/ai/insertHtmlGate.js +123 -0
  63. package/dist/kernel/ai/postApplyCheck.d.ts +56 -0
  64. package/dist/kernel/ai/postApplyCheck.js +74 -0
  65. package/dist/kernel/ai/replaceHtmlGate.d.ts +71 -0
  66. package/dist/kernel/ai/replaceHtmlGate.js +147 -0
  67. package/dist/kernel/blocks/BlockOverlay.d.ts +16 -0
  68. package/dist/kernel/blocks/BlockOverlay.js +66 -0
  69. package/dist/kernel/blocks/annotateAndTile.d.ts +106 -0
  70. package/dist/kernel/blocks/annotateAndTile.js +122 -0
  71. package/dist/kernel/blocks/resolveBlocks.d.ts +153 -0
  72. package/dist/kernel/blocks/resolveBlocks.js +233 -0
  73. package/dist/kernel/canvasCapabilities.d.ts +31 -0
  74. package/dist/kernel/canvasCapabilities.js +39 -0
  75. package/dist/kernel/canvasCommands.d.ts +126 -0
  76. package/dist/kernel/canvasCommands.js +146 -0
  77. package/dist/kernel/canvasErrors.d.ts +1 -0
  78. package/dist/kernel/canvasErrors.js +14 -0
  79. package/dist/kernel/captureGeometry.d.ts +35 -0
  80. package/dist/kernel/captureGeometry.js +25 -0
  81. package/dist/kernel/captureOrchestration.d.ts +48 -0
  82. package/dist/kernel/captureOrchestration.js +43 -0
  83. package/dist/kernel/captureSlices.d.ts +36 -0
  84. package/dist/kernel/captureSlices.js +20 -0
  85. package/dist/kernel/changelist/ChangeListPanel.d.ts +13 -0
  86. package/dist/kernel/changelist/ChangeListPanel.js +47 -0
  87. package/dist/kernel/changelist/ChangeListPopover.d.ts +20 -0
  88. package/dist/kernel/changelist/ChangeListPopover.js +10 -0
  89. package/dist/kernel/changelist/builders.d.ts +89 -0
  90. package/dist/kernel/changelist/builders.js +81 -0
  91. package/dist/kernel/changelist/changeSummary.d.ts +19 -0
  92. package/dist/kernel/changelist/changeSummary.js +85 -0
  93. package/dist/kernel/changelist/clipboard.d.ts +16 -0
  94. package/dist/kernel/changelist/clipboard.js +4 -0
  95. package/dist/kernel/changelist/exportForSave.d.ts +30 -0
  96. package/dist/kernel/changelist/exportForSave.js +11 -0
  97. package/dist/kernel/changelist/mergeForSave.d.ts +79 -0
  98. package/dist/kernel/changelist/mergeForSave.js +40 -0
  99. package/dist/kernel/changelist/moveObserverSafety.d.ts +70 -0
  100. package/dist/kernel/changelist/moveObserverSafety.js +39 -0
  101. package/dist/kernel/changelist/overrides.d.ts +22 -0
  102. package/dist/kernel/changelist/overrides.js +63 -0
  103. package/dist/kernel/changelist/useChangeList.d.ts +185 -0
  104. package/dist/kernel/changelist/useChangeList.js +259 -0
  105. package/dist/kernel/config.d.ts +29 -0
  106. package/dist/kernel/config.js +10 -0
  107. package/dist/kernel/dataUrlToBlob.d.ts +8 -0
  108. package/dist/kernel/dataUrlToBlob.js +17 -0
  109. package/dist/kernel/linkHref.d.ts +25 -0
  110. package/dist/kernel/linkHref.js +16 -0
  111. package/dist/kernel/mobileFit.d.ts +46 -0
  112. package/dist/kernel/mobileFit.js +44 -0
  113. package/dist/kernel/moveState.d.ts +35 -0
  114. package/dist/kernel/moveState.js +14 -0
  115. package/dist/kernel/selection/SelectionOverlay.d.ts +10 -0
  116. package/dist/kernel/selection/SelectionOverlay.js +25 -0
  117. package/dist/kernel/selection/SelectionToolbar.d.ts +30 -0
  118. package/dist/kernel/selection/SelectionToolbar.js +82 -0
  119. package/dist/kernel/selection/SelectorPathBar.d.ts +27 -0
  120. package/dist/kernel/selection/SelectorPathBar.js +55 -0
  121. package/dist/kernel/selection/buildDescendantShelf.d.ts +40 -0
  122. package/dist/kernel/selection/buildDescendantShelf.js +50 -0
  123. package/dist/kernel/selection/buildRootShelf.d.ts +26 -0
  124. package/dist/kernel/selection/buildRootShelf.js +149 -0
  125. package/dist/kernel/selection/elementAiContext.d.ts +313 -0
  126. package/dist/kernel/selection/elementAiContext.js +122 -0
  127. package/dist/kernel/selection/elementGeometryForAi.d.ts +9 -0
  128. package/dist/kernel/selection/elementGeometryForAi.js +15 -0
  129. package/dist/kernel/selection/geometry.d.ts +55 -0
  130. package/dist/kernel/selection/geometry.js +18 -0
  131. package/dist/kernel/selection/resolveAnchors.d.ts +116 -0
  132. package/dist/kernel/selection/resolveAnchors.js +173 -0
  133. package/dist/kernel/selection/shelfRow.d.ts +33 -0
  134. package/dist/kernel/selection/shelfRow.js +19 -0
  135. package/dist/kernel/selection/tagLabels.d.ts +9 -0
  136. package/dist/kernel/selection/tagLabels.js +46 -0
  137. package/dist/kernel/selection/toolbarButtons.d.ts +60 -0
  138. package/dist/kernel/selection/toolbarButtons.js +105 -0
  139. package/dist/kernel/selection/useAiSelectionContext.d.ts +26 -0
  140. package/dist/kernel/selection/useAiSelectionContext.js +26 -0
  141. package/dist/kernel/selection/useCanvasGeometry.d.ts +32 -0
  142. package/dist/kernel/selection/useCanvasGeometry.js +47 -0
  143. package/dist/kernel/selection/useSelectionRestore.d.ts +18 -0
  144. package/dist/kernel/selection/useSelectionRestore.js +25 -0
  145. package/dist/kernel/shrinkImageForUpload.d.ts +45 -0
  146. package/dist/kernel/shrinkImageForUpload.js +61 -0
  147. package/dist/kernel/useCanvasBridge.d.ts +36 -0
  148. package/dist/kernel/useCanvasBridge.js +41 -0
  149. package/dist/kernel/useCanvasGate.d.ts +22 -0
  150. package/dist/kernel/useCanvasGate.js +35 -0
  151. package/dist/kernel/useCanvasSession.d.ts +44 -0
  152. package/dist/kernel/useCanvasSession.js +182 -0
  153. package/dist/kernel/useCanvasStream.d.ts +39 -0
  154. package/dist/kernel/useCanvasStream.js +45 -0
  155. package/dist/kernel/useMobileFit.d.ts +40 -0
  156. package/dist/kernel/useMobileFit.js +139 -0
  157. package/dist/locales.d.ts +1094 -0
  158. package/dist/locales.js +545 -0
  159. package/dist/panels/StylePanel.d.ts +24 -0
  160. package/dist/panels/StylePanel.js +40 -0
  161. package/dist/panels/styleFields.d.ts +39 -0
  162. package/dist/panels/styleFields.js +91 -0
  163. package/dist/tree/LayersPanel.d.ts +30 -0
  164. package/dist/tree/LayersPanel.js +15 -0
  165. package/dist/tree/domTreeAdapter.d.ts +63 -0
  166. package/dist/tree/domTreeAdapter.js +90 -0
  167. package/dist/useInlineEditor.d.ts +158 -0
  168. package/dist/useInlineEditor.js +343 -0
  169. package/package.json +58 -3
@@ -0,0 +1,123 @@
1
+ import { imageSourceAllowed } from './imageSource.js';
2
+ export const INSERT_HTML_BUDGET_BYTES = 8192;
3
+ const ALLOWED_TAGS = new Set([
4
+ 'div',
5
+ 'span',
6
+ 'p',
7
+ 'a',
8
+ 'button',
9
+ 'strong',
10
+ 'em',
11
+ 'b',
12
+ 'i',
13
+ 'small',
14
+ 'br',
15
+ 'h1',
16
+ 'h2',
17
+ 'h3',
18
+ 'h4',
19
+ 'h5',
20
+ 'h6',
21
+ 'ul',
22
+ 'ol',
23
+ 'li',
24
+ 'u',
25
+ 'img',
26
+ 'table',
27
+ 'thead',
28
+ 'tbody',
29
+ 'tfoot',
30
+ 'tr',
31
+ 'td',
32
+ 'th'
33
+ ]);
34
+ const ALLOWED_ATTRS = new Set([
35
+ 'class',
36
+ 'style',
37
+ 'href',
38
+ 'target',
39
+ 'rel',
40
+ 'role',
41
+ 'title',
42
+ 'aria-label',
43
+ 'aria-hidden',
44
+ 'src',
45
+ 'alt',
46
+ 'width',
47
+ 'height',
48
+ 'loading'
49
+ ]);
50
+ const STRIPPED_ATTRS = ['srcset', 'imagesrcset'];
51
+ function isSafeHref(value) {
52
+ const v = value.trim().toLowerCase();
53
+ if (v.startsWith('#') || v.startsWith('/') || v.startsWith('./') || v.startsWith('../')) {
54
+ return true;
55
+ }
56
+ return v.startsWith('http://') || v.startsWith('https://') || v.startsWith('mailto:') || v.startsWith('tel:');
57
+ }
58
+ function isSafeStyleAttr(value) {
59
+ const v = value.toLowerCase();
60
+ return (!v.includes('javascript:') &&
61
+ !v.includes('expression(') &&
62
+ !v.includes('url('));
63
+ }
64
+ export function sanitizeInsertHtml(raw, images = { allowedImageOrigins: [] }) {
65
+ const html = (raw ?? '').trim();
66
+ if (!html)
67
+ return { ok: false, problem: { reason: 'EMPTY' } };
68
+ const bytes = new TextEncoder().encode(html).length;
69
+ if (bytes > INSERT_HTML_BUDGET_BYTES) {
70
+ return { ok: false, problem: { reason: 'TOO_LARGE', bytes } };
71
+ }
72
+ if (typeof DOMParser === 'undefined') {
73
+ return {
74
+ ok: false,
75
+ problem: { reason: 'UNSAFE_ATTR_VALUE', detail: 'no DOMParser to inspect markup' }
76
+ };
77
+ }
78
+ const doc = new DOMParser().parseFromString(`<div id="__ai_root">${html}</div>`, 'text/html');
79
+ const root = doc.getElementById('__ai_root');
80
+ if (!root) {
81
+ return { ok: false, problem: { reason: 'EMPTY' } };
82
+ }
83
+ for (const el of Array.from(root.querySelectorAll('*'))) {
84
+ const tag = el.tagName.toLowerCase();
85
+ if (!ALLOWED_TAGS.has(tag)) {
86
+ return { ok: false, problem: { reason: 'TAG_NOT_ALLOWED', detail: tag } };
87
+ }
88
+ for (const name of STRIPPED_ATTRS)
89
+ el.removeAttribute(name);
90
+ for (const attr of Array.from(el.attributes)) {
91
+ const name = attr.name.toLowerCase();
92
+ if (name.startsWith('on')) {
93
+ return { ok: false, problem: { reason: 'ATTR_NOT_ALLOWED', detail: `${tag}[${name}]` } };
94
+ }
95
+ if (!ALLOWED_ATTRS.has(name)) {
96
+ return { ok: false, problem: { reason: 'ATTR_NOT_ALLOWED', detail: `${tag}[${name}]` } };
97
+ }
98
+ if (name === 'href' && !isSafeHref(attr.value)) {
99
+ return {
100
+ ok: false,
101
+ problem: { reason: 'UNSAFE_ATTR_VALUE', detail: `href=${attr.value.slice(0, 40)}` }
102
+ };
103
+ }
104
+ if (name === 'style' && !isSafeStyleAttr(attr.value)) {
105
+ return {
106
+ ok: false,
107
+ problem: { reason: 'UNSAFE_ATTR_VALUE', detail: `style=${attr.value.slice(0, 40)}` }
108
+ };
109
+ }
110
+ }
111
+ if (tag === 'img' && !imageSourceAllowed(el.getAttribute('src'), images)) {
112
+ const src = el.getAttribute('src');
113
+ return {
114
+ ok: false,
115
+ problem: {
116
+ reason: 'UNSAFE_IMAGE_SRC',
117
+ detail: src ? `source not allowed: ${src.slice(0, 60)}` : 'img without src'
118
+ }
119
+ };
120
+ }
121
+ }
122
+ return { ok: true, html: root.innerHTML };
123
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * AI 改动落地后的**几何自检**(P2a,2026-09-30)。
3
+ *
4
+ * 为什么要有它:`propose_inline_changes` 的返回只是一个 ack,编辑器应用完既不量也不报 ——
5
+ * 两次真机都是同一种走样:把东西 `after` 到了一个横排容器的孩子上(首屏信任线跑到标题右边、
6
+ * 购买盒变成评价轮播的第六张 slide),页面上一眼就能看出来,链路里却没有任何一层会说话。
7
+ *
8
+ * 这里只做**确定性**判定,不看图、不问模型:输入是改前 / 改后的视口矩形,输出是几个标记 + 数字。
9
+ * 回执卡据此标 ⚠,后面(P2b)再把它送回模型让它自己修。
10
+ *
11
+ * 判定都带容差:客户页常有 transform 动画(`animate-bob` / `pulse`),矩形会抖一两个像素。
12
+ */
13
+ import type { ViewportRect } from '@ptengine/inline-core/protocol';
14
+ export type ApplyCheckFlag =
15
+ /** before/after 插入的新元素与锚点**并排**了(竖向重叠过半、水平不相交):父级是横排。 */
16
+ 'SIDE_BY_SIDE'
17
+ /** 锚点被挤窄(宽度缩了一成以上):新元素挤进了同一行。 */
18
+ | 'ANCHOR_SQUEEZED'
19
+ /** 新元素(或改后的目标)超出视口宽度:被推到屏幕外 / 横向滚动容器里。 */
20
+ | 'OVERFLOW_X'
21
+ /** 新元素(或改后的目标)宽或高为 0:插进了隐藏区域,或被样式折叠。 */
22
+ | 'ZERO_SIZE'
23
+ /** 改后的目标高度翻了倍以上:文案溢出、样式把布局撑开(只供核对,不一定是错)。 */
24
+ | 'HEIGHT_JUMP';
25
+ export interface ApplyCheckInserted {
26
+ selector: string;
27
+ rect: ViewportRect | null;
28
+ }
29
+ export interface ApplyCheck {
30
+ flags: ApplyCheckFlag[];
31
+ /** 目标(锚点)改前 / 改后的视口矩形;量不到为 null。 */
32
+ targetBefore: ViewportRect | null;
33
+ targetAfter: ViewportRect | null;
34
+ /** 这条改动插入的新元素(INSERT 的根、EDIT_HTML 的新根)。 */
35
+ inserted: ApplyCheckInserted[];
36
+ /** 给回执卡 / 日志用的数字:`widthBefore` / `widthAfter` / `heightBefore` / `heightAfter` / `side`。 */
37
+ detail: Partial<{
38
+ widthBefore: number;
39
+ widthAfter: number;
40
+ heightBefore: number;
41
+ heightAfter: number;
42
+ side: 'left' | 'right';
43
+ }>;
44
+ }
45
+ export type ApplyCheckOp = 'INSERT_ELEMENT' | 'EDIT_TEXT' | 'CHANGE_STYLE' | 'EDIT_HTML' | 'CHANGE_IMAGE';
46
+ export type ApplyCheckMode = 'insert' | 'append' | 'before' | 'after';
47
+ /** 落地后、量之前等多久:让 transition(PageFly 按钮常见 `transition: all .3s`)与图片加载跑完。 */
48
+ export declare const APPLY_CHECK_SETTLE_MS = 400;
49
+ export declare function evaluateApplyCheck(input: {
50
+ op: ApplyCheckOp;
51
+ mode?: ApplyCheckMode;
52
+ targetBefore: ViewportRect | null;
53
+ targetAfter: ViewportRect | null;
54
+ inserted: ApplyCheckInserted[];
55
+ viewportWidth?: number;
56
+ }): ApplyCheck;
@@ -0,0 +1,74 @@
1
+ export const APPLY_CHECK_SETTLE_MS = 400;
2
+ const PX_TOLERANCE = 2;
3
+ const SQUEEZE_RATIO = 0.9;
4
+ const OVERLAP_RATIO = 0.5;
5
+ const HEIGHT_JUMP_RATIO = 2;
6
+ const HEIGHT_JUMP_MIN_PX = 20;
7
+ function zeroSize(r) {
8
+ return r.width <= 0 || r.height <= 0;
9
+ }
10
+ function overflowsX(r, viewportWidth) {
11
+ if (!viewportWidth || viewportWidth <= 0)
12
+ return false;
13
+ return r.x >= viewportWidth - PX_TOLERANCE || r.x + r.width <= PX_TOLERANCE;
14
+ }
15
+ function sideBySide(anchor, other) {
16
+ const overlap = Math.min(anchor.y + anchor.height, other.y + other.height) - Math.max(anchor.y, other.y);
17
+ const shorter = Math.min(anchor.height, other.height);
18
+ if (shorter <= 0 || overlap / shorter < OVERLAP_RATIO)
19
+ return null;
20
+ if (other.x >= anchor.x + anchor.width - PX_TOLERANCE)
21
+ return 'right';
22
+ if (other.x + other.width <= anchor.x + PX_TOLERANCE)
23
+ return 'left';
24
+ return null;
25
+ }
26
+ export function evaluateApplyCheck(input) {
27
+ const flags = new Set();
28
+ const detail = {};
29
+ const { op, mode, targetBefore, targetAfter, inserted, viewportWidth } = input;
30
+ const sibling = op === 'INSERT_ELEMENT' && (mode === 'before' || mode === 'after');
31
+ for (const item of inserted) {
32
+ const r = item.rect;
33
+ if (!r)
34
+ continue;
35
+ if (zeroSize(r)) {
36
+ flags.add('ZERO_SIZE');
37
+ continue;
38
+ }
39
+ if (overflowsX(r, viewportWidth))
40
+ flags.add('OVERFLOW_X');
41
+ if (sibling && targetAfter && !zeroSize(targetAfter)) {
42
+ const side = sideBySide(targetAfter, r);
43
+ if (side) {
44
+ flags.add('SIDE_BY_SIDE');
45
+ detail.side = side;
46
+ }
47
+ }
48
+ }
49
+ if (sibling && targetBefore && targetAfter && targetBefore.width > 0) {
50
+ if (targetAfter.width < targetBefore.width * SQUEEZE_RATIO) {
51
+ flags.add('ANCHOR_SQUEEZED');
52
+ detail.widthBefore = Math.round(targetBefore.width);
53
+ detail.widthAfter = Math.round(targetAfter.width);
54
+ }
55
+ }
56
+ if (op !== 'INSERT_ELEMENT') {
57
+ const after = targetAfter ?? inserted.find(i => i.rect)?.rect ?? null;
58
+ if (after) {
59
+ if (zeroSize(after))
60
+ flags.add('ZERO_SIZE');
61
+ else if (overflowsX(after, viewportWidth))
62
+ flags.add('OVERFLOW_X');
63
+ if (targetBefore &&
64
+ targetBefore.height > 0 &&
65
+ after.height > targetBefore.height * HEIGHT_JUMP_RATIO &&
66
+ after.height - targetBefore.height >= HEIGHT_JUMP_MIN_PX) {
67
+ flags.add('HEIGHT_JUMP');
68
+ detail.heightBefore = Math.round(targetBefore.height);
69
+ detail.heightAfter = Math.round(after.height);
70
+ }
71
+ }
72
+ }
73
+ return { flags: [...flags], targetBefore, targetAfter, inserted, detail };
74
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * AI **整块重写**(`EDIT_HTML`)那段 HTML 的闸。
3
+ *
4
+ * ## 与 `insertHtmlGate` 的关系
5
+ *
6
+ * 同一套审查思路(真解析器、拒绝而非清洗、重新序列化),但**不能共用一份实现** ——
7
+ * 插入是「往页面上加一个小东西」,替换是「把一个既有元素整个换掉」,由此来三条硬差异:
8
+ *
9
+ * 1. **必须单根。** 被替换的一个元素变成 N 个,后续所有基于 `:nth-of-type` 的 selector
10
+ * 全部错位,而且是**上线之后**才暴露。人写 `</>` 那条路早就显式挡住了
11
+ * (`dialogs/htmlValidation.ts`),AI 这条只会更需要。
12
+ * 2. **必须能带 `img`。** 整块重写要把块里原有的图再吐一遍;不允许 `img` 等于说
13
+ * 「含图的区块永远重写不了」——而首屏、商品展示这些最想改的块恰恰都含图。
14
+ * 放开的代价用 **origin 白名单**兜住(与 `CHANGE_IMAGE` 同一张表、同一套 origin 比较),
15
+ * 并且把 `srcset` 整条剥掉:那是第二条 URL 通道,白名单管不到它。
16
+ * 3. **预算更大。** 2048B 是按「一个小新元素」定的,真实区块的 outerHTML 不是那个量级。
17
+ *
18
+ * ⚠️ 用 `<template>` 解析而不是 `<div>`:`<div>` 会把 `<td>` / `<tr>` / `<option>` 这类
19
+ * **需要正确宿主**的标签当成错误内容丢掉 —— 于是「审查通过」了,应用后结构却塌掉。
20
+ * 这一条与 `dialogs/htmlValidation.ts` 同源,理由也一样。
21
+ *
22
+ * ⚠️ `url()` 仍然禁止。允许 `img` 不等于允许任意对外请求:`img[src]` 过 origin 白名单,
23
+ * 而 `style="background:url(...)"` 绕开那张表。
24
+ */
25
+ import { type ImageSourcePolicy } from './imageSource.js';
26
+ /** 一段替换 HTML 的字节上限。比插入那份宽,但模型偶尔会吐一整屏 markup,仍要封顶。 */
27
+ export declare const REPLACE_HTML_BUDGET_BYTES = 8192;
28
+ export type ReplaceHtmlReject = {
29
+ reason: 'EMPTY';
30
+ } | {
31
+ reason: 'TOO_LARGE';
32
+ bytes: number;
33
+ } | {
34
+ reason: 'NO_ROOT_ELEMENT';
35
+ } | {
36
+ reason: 'MULTI_ROOT_HTML';
37
+ detail: string;
38
+ } | {
39
+ reason: 'TAG_NOT_ALLOWED';
40
+ detail: string;
41
+ } | {
42
+ reason: 'ATTR_NOT_ALLOWED';
43
+ detail: string;
44
+ } | {
45
+ reason: 'UNSAFE_ATTR_VALUE';
46
+ detail: string;
47
+ } | {
48
+ reason: 'UNSAFE_IMAGE_SRC';
49
+ detail: string;
50
+ };
51
+ export type ReplaceHtmlOutcome =
52
+ /** `html` 是**重新序列化后**的那份 —— 写进页面用它,不要用原串。 */
53
+ {
54
+ ok: true;
55
+ html: string;
56
+ } | {
57
+ ok: false;
58
+ problem: ReplaceHtmlReject;
59
+ };
60
+ /**
61
+ * `img[src]` 的来源规则:与 `CHANGE_IMAGE`、`INSERT_ELEMENT` 同一份(见 `imageSource.ts`)——
62
+ * origin 白名单(客户页自身 + 上传 / 生图域),或本页已出现过的图片地址(整串相等)。
63
+ */
64
+ export type ReplaceHtmlOptions = ImageSourcePolicy;
65
+ /**
66
+ * 审查并规整一段将**替换掉某个既有元素**的 HTML。
67
+ *
68
+ * 依赖 `DOMParser`(产品页与 jsdom 都有)。拿不到解析器时**一律拒绝** ——
69
+ * 「审不了就放过」是这类闸最糟的降级。
70
+ */
71
+ export declare function sanitizeReplaceHtml(raw: string, opts: ReplaceHtmlOptions): ReplaceHtmlOutcome;
@@ -0,0 +1,147 @@
1
+ import { imageSourceAllowed } from './imageSource.js';
2
+ export const REPLACE_HTML_BUDGET_BYTES = 8192;
3
+ const ALLOWED_TAGS = new Set([
4
+ 'div',
5
+ 'span',
6
+ 'p',
7
+ 'a',
8
+ 'button',
9
+ 'strong',
10
+ 'em',
11
+ 'b',
12
+ 'i',
13
+ 'u',
14
+ 'small',
15
+ 'br',
16
+ 'h1',
17
+ 'h2',
18
+ 'h3',
19
+ 'h4',
20
+ 'h5',
21
+ 'h6',
22
+ 'ul',
23
+ 'ol',
24
+ 'li',
25
+ 'img',
26
+ 'table',
27
+ 'thead',
28
+ 'tbody',
29
+ 'tfoot',
30
+ 'tr',
31
+ 'td',
32
+ 'th'
33
+ ]);
34
+ const ALLOWED_ATTRS = new Set([
35
+ 'class',
36
+ 'style',
37
+ 'href',
38
+ 'target',
39
+ 'rel',
40
+ 'role',
41
+ 'title',
42
+ 'aria-label',
43
+ 'aria-hidden',
44
+ 'src',
45
+ 'alt',
46
+ 'width',
47
+ 'height',
48
+ 'loading'
49
+ ]);
50
+ const STRIPPED_ATTRS = ['srcset', 'imagesrcset'];
51
+ function isSafeHref(value) {
52
+ const v = value.trim().toLowerCase();
53
+ if (v.startsWith('#') || v.startsWith('/') || v.startsWith('./') || v.startsWith('../')) {
54
+ return true;
55
+ }
56
+ return (v.startsWith('http://') ||
57
+ v.startsWith('https://') ||
58
+ v.startsWith('mailto:') ||
59
+ v.startsWith('tel:'));
60
+ }
61
+ function isSafeStyleAttr(value) {
62
+ const v = value.toLowerCase();
63
+ return !v.includes('javascript:') && !v.includes('expression(') && !v.includes('url(');
64
+ }
65
+ export function sanitizeReplaceHtml(raw, opts) {
66
+ const html = (raw ?? '').trim();
67
+ if (!html)
68
+ return { ok: false, problem: { reason: 'EMPTY' } };
69
+ const bytes = new TextEncoder().encode(html).length;
70
+ if (bytes > REPLACE_HTML_BUDGET_BYTES) {
71
+ return { ok: false, problem: { reason: 'TOO_LARGE', bytes } };
72
+ }
73
+ if (typeof DOMParser === 'undefined') {
74
+ return {
75
+ ok: false,
76
+ problem: { reason: 'UNSAFE_ATTR_VALUE', detail: 'no DOMParser to inspect markup' }
77
+ };
78
+ }
79
+ const doc = new DOMParser().parseFromString('<!doctype html><body></body>', 'text/html');
80
+ const template = doc.createElement('template');
81
+ template.innerHTML = html;
82
+ const roots = template.content.children;
83
+ if (roots.length === 0)
84
+ return { ok: false, problem: { reason: 'NO_ROOT_ELEMENT' } };
85
+ if (roots.length > 1) {
86
+ return {
87
+ ok: false,
88
+ problem: {
89
+ reason: 'MULTI_ROOT_HTML',
90
+ detail: `${roots.length} root elements, expected 1`
91
+ }
92
+ };
93
+ }
94
+ const root = roots[0];
95
+ for (const el of [root, ...Array.from(root.querySelectorAll('*'))]) {
96
+ const tag = el.tagName.toLowerCase();
97
+ if (!ALLOWED_TAGS.has(tag)) {
98
+ return { ok: false, problem: { reason: 'TAG_NOT_ALLOWED', detail: tag } };
99
+ }
100
+ for (const name of STRIPPED_ATTRS)
101
+ el.removeAttribute(name);
102
+ for (const attr of Array.from(el.attributes)) {
103
+ const name = attr.name.toLowerCase();
104
+ if (name.startsWith('on')) {
105
+ return {
106
+ ok: false,
107
+ problem: { reason: 'ATTR_NOT_ALLOWED', detail: `${tag}[${name}]` }
108
+ };
109
+ }
110
+ if (!ALLOWED_ATTRS.has(name)) {
111
+ return {
112
+ ok: false,
113
+ problem: { reason: 'ATTR_NOT_ALLOWED', detail: `${tag}[${name}]` }
114
+ };
115
+ }
116
+ if (name === 'href' && !isSafeHref(attr.value)) {
117
+ return {
118
+ ok: false,
119
+ problem: {
120
+ reason: 'UNSAFE_ATTR_VALUE',
121
+ detail: `href=${attr.value.slice(0, 40)}`
122
+ }
123
+ };
124
+ }
125
+ if (name === 'style' && !isSafeStyleAttr(attr.value)) {
126
+ return {
127
+ ok: false,
128
+ problem: {
129
+ reason: 'UNSAFE_ATTR_VALUE',
130
+ detail: `style=${attr.value.slice(0, 40)}`
131
+ }
132
+ };
133
+ }
134
+ }
135
+ if (tag === 'img' && !imageSourceAllowed(el.getAttribute('src'), opts)) {
136
+ const src = el.getAttribute('src');
137
+ return {
138
+ ok: false,
139
+ problem: {
140
+ reason: 'UNSAFE_IMAGE_SRC',
141
+ detail: src ? `origin not allowed: ${src.slice(0, 60)}` : 'img without src'
142
+ }
143
+ };
144
+ }
145
+ }
146
+ return { ok: true, html: root.outerHTML };
147
+ }
@@ -0,0 +1,16 @@
1
+ import type { CanvasGeometryPayload } from '@ptengine/inline-core/protocol';
2
+ import { type FrameToProductContext } from '../selection/geometry.js';
3
+ import type { PageModelBlock } from './resolveBlocks.js';
4
+ export interface BlockOverlayProps {
5
+ blocks: PageModelBlock[];
6
+ activeId?: string | null;
7
+ onHover?: (id: string | null) => void;
8
+ geometry: CanvasGeometryPayload | null;
9
+ context: FrameToProductContext;
10
+ /**
11
+ * 画布当前施加在 `<html>` 上的缩放比例(移动端超宽缩小),默认 1。区块 `top/height` 是
12
+ * 自然文档坐标,几何帧的 `scrollY` 是缩放后页面的 —— 乘上比例两者才在同一空间里。
13
+ */
14
+ scale?: number;
15
+ }
16
+ export declare function BlockOverlay({ blocks, activeId, onHover, geometry, context, scale }: BlockOverlayProps): import("react").JSX.Element | null;
@@ -0,0 +1,66 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { frameRectToProductRect } from '../selection/geometry.js';
3
+ const Z_INDEX = 2147482990;
4
+ const CHIP_HEIGHT = 18;
5
+ const COLORS = { base: '#1D4ED8', active: '#DB2777', none: '#94A3B8' };
6
+ function boxStyle(rect, color, active, none) {
7
+ return {
8
+ position: 'absolute',
9
+ left: `${rect.x}px`,
10
+ top: `${rect.y}px`,
11
+ width: `${rect.width}px`,
12
+ height: `${rect.height}px`,
13
+ boxSizing: 'border-box',
14
+ border: `${active ? 3 : 2}px ${none ? 'dashed' : 'solid'} ${color}`,
15
+ background: active ? 'rgba(219, 39, 119, 0.06)' : 'transparent',
16
+ pointerEvents: 'none'
17
+ };
18
+ }
19
+ export function BlockOverlay({ blocks, activeId, onHover, geometry, context, scale = 1 }) {
20
+ if (!geometry)
21
+ return null;
22
+ const clip = frameRectToProductRect({ x: 0, y: 0, width: geometry.viewportWidth, height: geometry.viewportHeight }, context);
23
+ return (_jsx("div", { "data-testid": "block-overlay-root", style: {
24
+ position: 'fixed',
25
+ left: `${clip.x}px`,
26
+ top: `${clip.y}px`,
27
+ width: `${clip.width}px`,
28
+ height: `${clip.height}px`,
29
+ overflow: 'hidden',
30
+ pointerEvents: 'none',
31
+ zIndex: Z_INDEX
32
+ }, children: blocks.map(block => {
33
+ const active = block.id === activeId;
34
+ const none = block.resolved === 'none';
35
+ const color = active ? COLORS.active : none ? COLORS.none : COLORS.base;
36
+ const abs = frameRectToProductRect({
37
+ x: 0,
38
+ y: block.top * scale - geometry.scrollY,
39
+ width: geometry.viewportWidth,
40
+ height: block.height * scale
41
+ }, context);
42
+ const rect = { ...abs, x: abs.x - clip.x, y: abs.y - clip.y };
43
+ const chipTop = Math.min(Math.max(0, -rect.y), Math.max(0, rect.height - CHIP_HEIGHT));
44
+ return (_jsx("div", { "data-testid": `block-box-${block.id}`, "data-resolved": block.resolved, "data-active": active ? 'true' : undefined, style: boxStyle(rect, color, active, none), children: _jsxs("div", { "data-testid": `block-chip-${block.id}`, onMouseEnter: () => onHover?.(block.id), onMouseLeave: () => onHover?.(null), style: {
45
+ position: 'absolute',
46
+ left: 0,
47
+ top: `${chipTop}px`,
48
+ display: 'inline-flex',
49
+ alignItems: 'center',
50
+ gap: '4px',
51
+ maxWidth: '100%',
52
+ padding: '1px 6px',
53
+ background: color,
54
+ color: '#fff',
55
+ fontSize: '11px',
56
+ lineHeight: '16px',
57
+ fontWeight: 600,
58
+ whiteSpace: 'nowrap',
59
+ overflow: 'hidden',
60
+ textOverflow: 'ellipsis',
61
+ borderRadius: '0 0 2px 0',
62
+ pointerEvents: 'auto',
63
+ cursor: 'default'
64
+ }, children: [_jsx("span", { children: block.id }), _jsx("span", { style: { fontWeight: 400, opacity: 0.9 }, children: block.label ?? block.name })] }) }, block.id));
65
+ }) }));
66
+ }
@@ -0,0 +1,106 @@
1
+ import type { DecodedImage } from '../shrinkImageForUpload.js';
2
+ /**
3
+ * 整页截图 → 画上区块编号框 → 切成若干瓦片给模型看。
4
+ *
5
+ * 为什么切:整页图 375×8000+ 一张塞给模型,纵向被压到看不清文字;切成 2000 高的瓦片,
6
+ * 每片都是可读的。为什么标注:模型要把「B3 那块」与 `resolveBlocks` 给的 `B3` 对上号,
7
+ * 编号框就是两边共用的坐标系。
8
+ *
9
+ * 页太高时(> tileHeight × maxTiles)整体缩小到正好放下,下限 0.5 —— 再小文字就糊了;
10
+ * 到下限仍放不下就只留前 maxTiles 片并标 `truncated`(模型图片数有上限,与其发不出去
11
+ * 不如少看底部)。
12
+ *
13
+ * canvas / createImageBitmap 在 jsdom 里没有实现 → 解码 / 渲染经 `deps` 注入,规划本身
14
+ * (缩放决定、瓦片边界、框与标签位置)是纯函数单独测。与 `shrinkImageForUpload.ts` 同一套写法。
15
+ */
16
+ export declare const ANNOTATE_DEFAULTS: {
17
+ readonly tileHeight: 2000;
18
+ readonly maxTiles: 6;
19
+ };
20
+ /** 整体缩小的下限:再小文字就不可读了。 */
21
+ export declare const MIN_DOWNSCALE = 0.5;
22
+ /** 两色交替:相邻区块颜色不同,边界一眼可辨。 */
23
+ export declare const BLOCK_COLORS: readonly [string, string];
24
+ export declare const TILE_JPEG_QUALITY = 0.82;
25
+ export interface AnnotateBlock {
26
+ id: string;
27
+ /** 页面 CSS px(文档坐标)。 */
28
+ top: number;
29
+ height: number;
30
+ }
31
+ export interface AnnotateOptions {
32
+ /** 每片高度(输出 px),默认 2000。 */
33
+ tileHeight?: number;
34
+ /** 最多几片,默认 6。 */
35
+ maxTiles?: number;
36
+ /**
37
+ * 截图时页面的 CSS 宽(mobile 375 / pc 1440)。DPR > 1 时图宽是它的整数倍,框要按
38
+ * `imageWidth / pageWidthPx` 放大才对得上。默认 = 图宽(不做 DPR 换算)。
39
+ */
40
+ pageWidthPx?: number;
41
+ }
42
+ export interface AnnotateBox {
43
+ id: string;
44
+ /** 输出 px。 */
45
+ x: number;
46
+ y: number;
47
+ width: number;
48
+ height: number;
49
+ color: string;
50
+ label: {
51
+ x: number;
52
+ y: number;
53
+ width: number;
54
+ height: number;
55
+ text: string;
56
+ };
57
+ }
58
+ export interface AnnotateTile {
59
+ index: number;
60
+ /** 输出 px,相对整张(已缩放的)标注图。 */
61
+ top: number;
62
+ height: number;
63
+ }
64
+ export interface AnnotatePlan {
65
+ /** 输出 px / **源图** px(≤ 1;1 = 没缩)。 */
66
+ downscale: number;
67
+ /** 输出 px / **页面 CSS** px = (imageWidth / pageWidthPx) × downscale。页 px → 瓦片 px 用它。 */
68
+ pxPerPagePx: number;
69
+ /** 输出图尺寸。 */
70
+ width: number;
71
+ height: number;
72
+ /** 页高,**页面 CSS px**。 */
73
+ pageHeight: number;
74
+ tileHeight: number;
75
+ tiles: AnnotateTile[];
76
+ boxes: AnnotateBox[];
77
+ /** 到缩放下限仍放不下,底部几片被丢了。 */
78
+ truncated: boolean;
79
+ }
80
+ export declare function planAnnotateAndTile(input: {
81
+ imageWidth: number;
82
+ imageHeight: number;
83
+ pageWidthPx?: number;
84
+ blocks: AnnotateBlock[];
85
+ tileHeight?: number;
86
+ maxTiles?: number;
87
+ }): AnnotatePlan;
88
+ export interface AnnotateDeps {
89
+ decode: (blob: Blob) => Promise<DecodedImage>;
90
+ /** 把源图按 plan 缩放、画框、切片,每片一个 JPEG Blob(顺序与 `plan.tiles` 一致)。 */
91
+ render: (image: DecodedImage, plan: AnnotatePlan) => Promise<Blob[]>;
92
+ }
93
+ export interface AnnotateResult {
94
+ tiles: Blob[];
95
+ /** 输出 px / 页面 CSS px(= `plan.pxPerPagePx`):页 px 的 y 落在第 `floor(y×scale / tileHeight)` 片。 */
96
+ scale: number;
97
+ /** 页高,页面 CSS px。 */
98
+ pageHeight: number;
99
+ tileHeight: number;
100
+ truncated: boolean;
101
+ }
102
+ /**
103
+ * 解码 → 规划 → 渲染。解码 / 渲染失败**原样抛**:调用方(apps/x)决定退回不标注的整图
104
+ * 还是不发图 —— 这里不知道哪种对当前对话更好。
105
+ */
106
+ export declare function annotateAndTile(fullPage: Blob, blocks: AnnotateBlock[], opts?: AnnotateOptions, deps?: AnnotateDeps): Promise<AnnotateResult>;