@ptengine/inline-editor 0.1.6 → 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 +26 -0
- package/dist/InlineEditor.js +160 -16
- package/dist/dialogs/InsertElementDialog.js +6 -2
- package/dist/kernel/ai/aiInlineApply.d.ts +4 -3
- package/dist/kernel/changelist/useChangeList.d.ts +1 -0
- package/dist/kernel/changelist/useChangeList.js +1 -0
- package/dist/kernel/selection/buildRootShelf.d.ts +7 -0
- package/dist/kernel/selection/buildRootShelf.js +42 -9
- package/dist/locales.d.ts +15 -0
- package/dist/locales.js +3 -0
- package/dist/panels/MovePositionControls.d.ts +6 -0
- package/dist/panels/MovePositionControls.js +35 -0
- package/dist/panels/StylePanel.d.ts +3 -1
- package/dist/panels/StylePanel.js +5 -2
- package/dist/reviewLocales.d.ts +9 -0
- package/dist/reviewLocales.js +9 -0
- package/dist/useInlineEditor.d.ts +1 -0
- package/dist/useInlineEditor.js +40 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.7
|
|
4
|
+
|
|
5
|
+
依赖 `@ptengine/inline-core` ~0.2.1(新增 `historyShortcut` 流事件):**先发 inline-core 0.2.1,再发本版**。
|
|
6
|
+
|
|
7
|
+
**变更**
|
|
8
|
+
|
|
9
|
+
- 整页模式下(宿主接了 `onPageContextReady`)选中元素时,AI 作用域 = 选中元素 ∪ 它的子树货架 ∪ **整页货架**,
|
|
10
|
+
并按整页货架那些行的真实祖先链收敛落盘;MOVE 也认货架里任意一行作 target。用户点中的常不是他说的那一层 ——
|
|
11
|
+
vivaia 首屏点哪都选中幻灯片的空覆盖链接,「首屏下面加评价」要 after 整个 section、「换这张图」要改它的兄弟 img,
|
|
12
|
+
此前都不在子树里,只能请用户取消选择(真机 2026-10-21)。并进来的仍是宿主发出去的那份货架,货架外拼出来的
|
|
13
|
+
selector 照拒;宿主选中时也要发 `<page-shelf>`。没有整页模式的老宿主行为不变。
|
|
14
|
+
- 整页货架补上矮区块:页面模型的成员容器只要够宽就是候选、不看高度;块根父级(上下叠放的那一层)里不够高、但够宽、
|
|
15
|
+
有内容、不在 header / nav / footer 里的孩子补成块根,参与排序与叶子配额,也能写 `blockId`。此前低于 120px 的区块
|
|
16
|
+
(vivaia 手机首页 86px 的新闻区)等不到广度优先回填就被行数上限挤掉,标题进不了货架,改它的请求全部被拒。
|
|
17
|
+
装着 ≥2 个候选的外壳不再因「只认领了这一块」成块;分析路径的成员(`liftToBlockRoots`)同口径上提到矮块根。
|
|
18
|
+
- 插入位置与移动位置统一使用组件库的 outline 单选 ToggleGroup;移动工具栏的位置选择单独一行,取消与确认固定在下方一行靠右,统一控件高度。移动先选位置,再确认执行,支持取消,提交期间阻止重复操作。
|
|
19
|
+
|
|
20
|
+
**修复**
|
|
21
|
+
|
|
22
|
+
- I01:设备切换先等待已开始的写入完成,再保存当前 live 清单并交还版本宿主;新会话从这份快照回放,回放期间阻止编辑与保存。版本和设备会话切换仍重置操作历史。
|
|
23
|
+
- I02:宿主和画布的 Cmd/Ctrl+Z、Shift+Z / Ctrl+Y 接入已有操作历史;输入框和文字编辑期间保留原生撤销。
|
|
24
|
+
- I04:样式读取当前生效值并随编辑/撤销刷新,仅已有覆盖提供清除入口;图片/背景图、视频、alt 读取 live DOM,点击动作回填当前版本的配置。读取失败显示错误。
|
|
25
|
+
- I05:画布会话失败显示目标页加载提示、原因与返回入口,避免无反馈的空画布。
|
|
26
|
+
- I09:文字、插入、移动及撤销/重做后刷新图层标签和结构,复用图层组件以保留搜索和折叠状态;纯样式编辑不重拉图层。
|
|
27
|
+
- 复验既有 I03 浮层布局:全屏 z-index 1000 宿主、1024×480、拖拽及关闭重开,稳定后完整位于视口内。
|
|
28
|
+
|
|
3
29
|
## 0.1.6
|
|
4
30
|
|
|
5
31
|
**变更**
|
package/dist/InlineEditor.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { EditorStatus } from './panels/EditorStatus.js';
|
|
3
|
+
import { MovePositionControls } from './panels/MovePositionControls.js';
|
|
3
4
|
import { ScreenshotCard } from './panels/ScreenshotCard.js';
|
|
4
5
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
5
6
|
import { useTranslation } from 'react-i18next';
|
|
@@ -16,7 +17,7 @@ import { ChangeListPanel } from './kernel/changelist/ChangeListPanel.js';
|
|
|
16
17
|
import { ChangeListPopover } from './kernel/changelist/ChangeListPopover.js';
|
|
17
18
|
import { frameRectToProductRect, measureFrameContext } from './kernel/selection/geometry.js';
|
|
18
19
|
import { buildElementAiContext, extractPageLinks, linksFragment, ROOT_SHELF_LIMIT, SHELF_TREE_MAX_DEPTH, SHELF_TREE_MAX_NODES } from './kernel/selection/elementAiContext.js';
|
|
19
|
-
import { getAncestorChain, getDomTree, getElementCapabilities, getElementImages, getOuterHtml, resolveCanvasSelector } from './kernel/canvasCommands.js';
|
|
20
|
+
import { getAncestorChain, getDomTree, getElementCapabilities, getElementImages, getOuterHtml, resolveCanvasSelector, queryElements } from './kernel/canvasCommands.js';
|
|
20
21
|
import { useAiSelectionContext } from './kernel/selection/useAiSelectionContext.js';
|
|
21
22
|
import { buildDescendantShelf } from './kernel/selection/buildDescendantShelf.js';
|
|
22
23
|
import { ROOT_SHELF_TREE_PARAMS, buildRootShelf } from './kernel/selection/buildRootShelf.js';
|
|
@@ -35,6 +36,7 @@ import { ImageSettingDialog } from './dialogs/ImageSettingDialog.js';
|
|
|
35
36
|
import { InsertElementDialog } from './dialogs/InsertElementDialog.js';
|
|
36
37
|
import { ReplaceVideoDialog } from './dialogs/ReplaceVideoDialog.js';
|
|
37
38
|
import { StylePanel } from './panels/StylePanel.js';
|
|
39
|
+
import { STYLE_SECTIONS } from './panels/styleFields.js';
|
|
38
40
|
import { HtmlEditDialog } from './dialogs/HtmlEditDialog.js';
|
|
39
41
|
import { setCode } from './kernel/canvasCommands.js';
|
|
40
42
|
import { describeCanvasError } from './kernel/canvasErrors.js';
|
|
@@ -60,7 +62,9 @@ export function InlineEditor({ url, device, onDeviceChange, title, versionLabel
|
|
|
60
62
|
const canvasIframeRef = useRef(null);
|
|
61
63
|
const insertedRemovedRef = useRef(null);
|
|
62
64
|
const insertedRestoredRef = useRef(null);
|
|
65
|
+
const historyShortcutRef = useRef(() => { });
|
|
63
66
|
const editor = useInlineEditor({
|
|
67
|
+
onHistoryShortcut: direction => historyShortcutRef.current(direction),
|
|
64
68
|
onInsertedRemoved: selectors => insertedRemovedRef.current?.(selectors),
|
|
65
69
|
onInsertedRestored: selectors => insertedRestoredRef.current?.(selectors),
|
|
66
70
|
url,
|
|
@@ -80,6 +84,7 @@ export function InlineEditor({ url, device, onDeviceChange, title, versionLabel
|
|
|
80
84
|
const [showChangeList, setShowChangeList] = useState(false);
|
|
81
85
|
const [stylePanelOpen, setStylePanelOpen] = useState(false);
|
|
82
86
|
const [htmlDialog, setHtmlDialog] = useState({ open: false, seed: '' });
|
|
87
|
+
const [currentStyles, setCurrentStyles] = useState({});
|
|
83
88
|
const [imageDialog, setImageDialog] = useState({ open: false, seed: '' });
|
|
84
89
|
const [imageSettingDialog, setImageSettingDialog] = useState({ open: false, seed: '' });
|
|
85
90
|
const [videoDialog, setVideoDialog] = useState({ open: false, seed: '' });
|
|
@@ -96,6 +101,25 @@ export function InlineEditor({ url, device, onDeviceChange, title, versionLabel
|
|
|
96
101
|
return '';
|
|
97
102
|
}
|
|
98
103
|
}, [url]);
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
if (!stylePanelOpen || !selection || !frameProps?.sessionId)
|
|
106
|
+
return;
|
|
107
|
+
let cancelled = false;
|
|
108
|
+
void queryElements(frameProps.sessionId, [selection.selector], {
|
|
109
|
+
fields: STYLE_SECTIONS.flatMap(section => section.fields.map(field => `computed:${field.property}`))
|
|
110
|
+
})
|
|
111
|
+
.then(result => {
|
|
112
|
+
if (!cancelled)
|
|
113
|
+
setCurrentStyles(result.results[0]?.matches[0]?.computed ?? {});
|
|
114
|
+
})
|
|
115
|
+
.catch(error => {
|
|
116
|
+
if (!cancelled)
|
|
117
|
+
setCommandError(describeCanvasError(error));
|
|
118
|
+
});
|
|
119
|
+
return () => {
|
|
120
|
+
cancelled = true;
|
|
121
|
+
};
|
|
122
|
+
}, [stylePanelOpen, selection?.selector, frameProps?.sessionId, changeList.list]);
|
|
99
123
|
const changeListRef = useRef(changeList);
|
|
100
124
|
changeListRef.current = changeList;
|
|
101
125
|
const versionChangesRef = useRef(versionChanges);
|
|
@@ -172,6 +196,27 @@ export function InlineEditor({ url, device, onDeviceChange, title, versionLabel
|
|
|
172
196
|
}
|
|
173
197
|
void run().catch(e => setCommandError(describeCanvasError(e)));
|
|
174
198
|
}, [t]);
|
|
199
|
+
historyShortcutRef.current = direction => {
|
|
200
|
+
if (!domReady || textEditing || switchingRef.current)
|
|
201
|
+
return;
|
|
202
|
+
uiWrite(() => enqueue(() => direction === 'undo' ? changeListRef.current.undo() : changeListRef.current.redo()));
|
|
203
|
+
};
|
|
204
|
+
useEffect(() => {
|
|
205
|
+
const onKey = (event) => {
|
|
206
|
+
if (!(event.metaKey || event.ctrlKey) || event.altKey)
|
|
207
|
+
return;
|
|
208
|
+
const target = event.target;
|
|
209
|
+
if (target?.closest('input, textarea, select, [contenteditable="true"], [role="textbox"]'))
|
|
210
|
+
return;
|
|
211
|
+
const key = event.key.toLowerCase();
|
|
212
|
+
if (key !== 'z' && !(key === 'y' && event.ctrlKey))
|
|
213
|
+
return;
|
|
214
|
+
event.preventDefault();
|
|
215
|
+
historyShortcutRef.current(event.shiftKey || key === 'y' ? 'redo' : 'undo');
|
|
216
|
+
};
|
|
217
|
+
document.addEventListener('keydown', onKey);
|
|
218
|
+
return () => document.removeEventListener('keydown', onKey);
|
|
219
|
+
}, []);
|
|
175
220
|
useEffect(() => {
|
|
176
221
|
if (!hasVersions || !activeVersionId)
|
|
177
222
|
return;
|
|
@@ -202,18 +247,43 @@ export function InlineEditor({ url, device, onDeviceChange, title, versionLabel
|
|
|
202
247
|
}, [sessionIdForAssets]);
|
|
203
248
|
const hydratedSessionRef = useRef(null);
|
|
204
249
|
const sessionIdForHydrate = frameProps?.sessionId;
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
250
|
+
const deviceSnapshot = useRef(null);
|
|
251
|
+
const changeDevice = (next) => {
|
|
252
|
+
if (next === device || switchingRef.current)
|
|
253
|
+
return;
|
|
254
|
+
switchingRef.current = true;
|
|
255
|
+
setSwitching(true);
|
|
256
|
+
void enqueue(async () => {
|
|
257
|
+
await changeListRef.current.whenIdle();
|
|
258
|
+
const live = changeListRef.current.peekList();
|
|
259
|
+
deviceSnapshot.current = { version: activeVersionId, changes: live.map(r => r.change) };
|
|
260
|
+
if (activeVersionId)
|
|
261
|
+
versionCallbacksRef.current.onVersionLeave?.(activeVersionId, live);
|
|
262
|
+
onDeviceChange(next);
|
|
263
|
+
})
|
|
264
|
+
.catch(error => setCommandError(describeCanvasError(error)))
|
|
265
|
+
.finally(() => {
|
|
266
|
+
switchingRef.current = false;
|
|
267
|
+
setSwitching(false);
|
|
268
|
+
});
|
|
269
|
+
};
|
|
270
|
+
const bootChanges = deviceSnapshot.current?.version === activeVersionId && deviceSnapshot.current
|
|
271
|
+
? deviceSnapshot.current.changes
|
|
272
|
+
: hasVersions && activeVersionId && versionChanges?.[activeVersionId]
|
|
273
|
+
? versionChanges[activeVersionId]
|
|
274
|
+
: initialChanges;
|
|
208
275
|
useEffect(() => {
|
|
209
276
|
if (!domReady || !sessionIdForHydrate)
|
|
210
277
|
return;
|
|
211
278
|
if (hydratedSessionRef.current === sessionIdForHydrate)
|
|
212
279
|
return;
|
|
213
280
|
hydratedSessionRef.current = sessionIdForHydrate;
|
|
281
|
+
deviceSnapshot.current = null;
|
|
214
282
|
if (!bootChanges?.length)
|
|
215
283
|
return;
|
|
216
284
|
const versionId = hasVersions ? activeVersionId : undefined;
|
|
285
|
+
setSwitching(true);
|
|
286
|
+
switchingRef.current = true;
|
|
217
287
|
void enqueue(async () => {
|
|
218
288
|
const { outcome, idMap } = await changeListRef.current.replaceAll(bootChanges, prevIdsFor(versionId, bootChanges));
|
|
219
289
|
if (versionId) {
|
|
@@ -222,7 +292,12 @@ export function InlineEditor({ url, device, onDeviceChange, title, versionLabel
|
|
|
222
292
|
versionCallbacksRef.current.onVersionEnter?.(versionId, idMap);
|
|
223
293
|
}
|
|
224
294
|
onHydrated?.(outcome);
|
|
225
|
-
})
|
|
295
|
+
})
|
|
296
|
+
.catch(e => setCommandError(describeCanvasError(e)))
|
|
297
|
+
.finally(() => {
|
|
298
|
+
switchingRef.current = false;
|
|
299
|
+
setSwitching(false);
|
|
300
|
+
});
|
|
226
301
|
}, [domReady, sessionIdForHydrate, bootChanges]);
|
|
227
302
|
const commandsRef = useRef(commands);
|
|
228
303
|
commandsRef.current = commands;
|
|
@@ -658,9 +733,15 @@ export function InlineEditor({ url, device, onDeviceChange, title, versionLabel
|
|
|
658
733
|
identifier: picked.selector,
|
|
659
734
|
descendants: () => [
|
|
660
735
|
...descendantShelfRef.current.map(d => d.selector),
|
|
736
|
+
...(pageMode ? rootShelfRef.current.map(d => d.selector) : []),
|
|
661
737
|
...aiInsertedRef.current
|
|
662
738
|
],
|
|
663
|
-
ancestors: (selection?.chain ?? []).slice(1).map(n => n.selector)
|
|
739
|
+
ancestors: (selection?.chain ?? []).slice(1).map(n => n.selector),
|
|
740
|
+
...(pageMode
|
|
741
|
+
? {
|
|
742
|
+
ancestorsOf: (selector) => rootShelfAncestorsRef.current.get(selector)
|
|
743
|
+
}
|
|
744
|
+
: {})
|
|
664
745
|
}
|
|
665
746
|
: {
|
|
666
747
|
selector: 'body',
|
|
@@ -806,11 +887,40 @@ export function InlineEditor({ url, device, onDeviceChange, title, versionLabel
|
|
|
806
887
|
return;
|
|
807
888
|
}
|
|
808
889
|
if (buttonId === 'replace-image') {
|
|
809
|
-
|
|
890
|
+
void Promise.all([
|
|
891
|
+
getOuterHtml(sessionId, self.selector),
|
|
892
|
+
selection?.capabilities?.hasBackgroundImage && !selection?.capabilities?.isImg
|
|
893
|
+
? queryElements(sessionId, [self.selector], {
|
|
894
|
+
fields: ['computed:background-image']
|
|
895
|
+
})
|
|
896
|
+
: Promise.resolve(null)
|
|
897
|
+
])
|
|
898
|
+
.then(([{ html }, facts]) => {
|
|
899
|
+
const node = new DOMParser().parseFromString(html, 'text/html').body
|
|
900
|
+
.firstElementChild;
|
|
901
|
+
const background = facts?.results[0]?.matches[0]?.computed?.['background-image'] ?? '';
|
|
902
|
+
const url = /^url\(["']?(.*?)["']?\)$/.exec(background)?.[1] ?? '';
|
|
903
|
+
setImageDialog({
|
|
904
|
+
open: true,
|
|
905
|
+
seed: facts ? url : (node?.getAttribute('src') ?? '')
|
|
906
|
+
});
|
|
907
|
+
})
|
|
908
|
+
.catch(error => setCommandError(describeCanvasError(error)));
|
|
810
909
|
return;
|
|
811
910
|
}
|
|
812
911
|
if (buttonId === 'replace-video') {
|
|
813
|
-
|
|
912
|
+
void getOuterHtml(sessionId, self.selector)
|
|
913
|
+
.then(({ html }) => {
|
|
914
|
+
const node = new DOMParser().parseFromString(html, 'text/html').body
|
|
915
|
+
.firstElementChild;
|
|
916
|
+
setVideoDialog({
|
|
917
|
+
open: true,
|
|
918
|
+
seed: node?.getAttribute('src') ??
|
|
919
|
+
node?.querySelector('source')?.getAttribute('src') ??
|
|
920
|
+
''
|
|
921
|
+
});
|
|
922
|
+
})
|
|
923
|
+
.catch(error => setCommandError(describeCanvasError(error)));
|
|
814
924
|
return;
|
|
815
925
|
}
|
|
816
926
|
if (buttonId === 'insert') {
|
|
@@ -818,11 +928,29 @@ export function InlineEditor({ url, device, onDeviceChange, title, versionLabel
|
|
|
818
928
|
return;
|
|
819
929
|
}
|
|
820
930
|
if (buttonId === 'image-setting') {
|
|
821
|
-
|
|
931
|
+
void getOuterHtml(sessionId, self.selector)
|
|
932
|
+
.then(({ html }) => {
|
|
933
|
+
const node = new DOMParser().parseFromString(html, 'text/html').body
|
|
934
|
+
.firstElementChild;
|
|
935
|
+
setImageSettingDialog({
|
|
936
|
+
open: true,
|
|
937
|
+
seed: node?.getAttribute('alt') ?? ''
|
|
938
|
+
});
|
|
939
|
+
})
|
|
940
|
+
.catch(error => setCommandError(describeCanvasError(error)));
|
|
822
941
|
return;
|
|
823
942
|
}
|
|
824
943
|
if (buttonId === 'click-action') {
|
|
825
|
-
|
|
944
|
+
const existing = [...changeListRef.current.peekList()]
|
|
945
|
+
.reverse()
|
|
946
|
+
.find(r => r.change.type === 'changeElementAction' &&
|
|
947
|
+
r.change.selector === self.selector);
|
|
948
|
+
setActionDialog({
|
|
949
|
+
open: true,
|
|
950
|
+
seed: existing?.change.type === 'changeElementAction'
|
|
951
|
+
? existing.change.action
|
|
952
|
+
: null
|
|
953
|
+
});
|
|
826
954
|
return;
|
|
827
955
|
}
|
|
828
956
|
if (buttonId === 'edit-style') {
|
|
@@ -879,13 +1007,21 @@ export function InlineEditor({ url, device, onDeviceChange, title, versionLabel
|
|
|
879
1007
|
targetIdentifier: targetSelector,
|
|
880
1008
|
targetSelector,
|
|
881
1009
|
mode: moveModeFromDropPos(pos)
|
|
882
|
-
})) })) })] }) }), _jsx(ResizableHandle, { withHandle: true })] })), _jsx(ResizablePanel, { id: "canvas", order: 2, minSize: 30, children: _jsxs("div", { className: "flex h-full min-h-0 min-w-0 flex-col", children: [_jsx(CanvasContextBar, { url: url, device: device, onDeviceChange:
|
|
1010
|
+
})) })) })] }) }), _jsx(ResizableHandle, { withHandle: true })] })), _jsx(ResizablePanel, { id: "canvas", order: 2, minSize: 30, children: _jsxs("div", { className: "flex h-full min-h-0 min-w-0 flex-col", children: [_jsx(CanvasContextBar, { url: url, device: device, onDeviceChange: changeDevice }), _jsxs("div", { ref: canvasAreaRef, className: "relative min-h-0 flex-1 overflow-hidden p-3", children: [editor.session.state === 'failed' && (_jsxs("div", { role: "alert", "data-testid": "inline-editor-session-failed", className: "absolute inset-3 z-20 flex flex-col items-center justify-center gap-3 rounded-lg bg-white p-6 text-center text-sm", children: [_jsx("p", { className: "font-semibold", children: t('inlineCanvas.review.pageLoadFailed', '目标页面无法加载') }), _jsx("p", { children: [
|
|
1011
|
+
'NO_EXTENSION',
|
|
1012
|
+
'EXTENSION_ID_MISSING',
|
|
1013
|
+
'CONTRACT_MISMATCH'
|
|
1014
|
+
].includes(editor.session.reason ?? '')
|
|
1015
|
+
? describeCanvasError(editor.session.reason)
|
|
1016
|
+
: t('inlineCanvas.review.pageLoadHint', '请返回检查页面地址、网站访问权限和网络连接后重试。') }), _jsx("code", { className: "text-xs text-muted-foreground", children: editor.session.reason }), _jsx(Button, { variant: "secondary", onClick: onExit, children: t('inlineCanvas.review.backFromFailed', '返回修改') })] })), frameProps && (_jsx(CanvasFrame, { url: frameProps.url, sessionId: frameProps.sessionId, device: frameProps.device, panelWidth: panelWidth, targetViewportWidth: targetViewportWidth[frameProps.device], onFrameLoad: () => { }, domReady: domReady, iframeRef: canvasIframeRef }, frameProps.sessionId))] })] }) })] }), !capturing && shot && (_jsx(ScreenshotCard, { dataUrl: shot.dataUrl, truncated: shot.truncated, scale: shot.scale, uploadImage: uploadImage, onClose: () => {
|
|
883
1017
|
setShot(null);
|
|
884
|
-
} }, shot.dataUrl)), _jsx(ChangeListPopover, { open: showChangeList && !capturing, onClose: () => setShowChangeList(false), children: _jsx(ChangeListPanel, { list: changeList.list, onRevert: id => uiWrite(() => writes.revert(id)), onRevertAll: () => uiWrite(() => writes.revertAll()), lastError: changeList.lastError, onHover: s => void commands.hover(s), onSelect: s => void selectAndReveal(s) }) })] }), !capturing && (_jsx(SelectionOverlay, { geometry: geometry.latest, context: overlayContext, selectedLabel: self ? `<${self.tag}>` : undefined })), !capturing && blockOverlay && (_jsx(BlockOverlay, { blocks: blockOverlay.blocks, activeId: blockOverlay.activeId, onHover: blockOverlay.onHover, geometry: geometry.latest, context: overlayContext, scale: editor.mobileFit.scale })), !capturing && commandError && (_jsxs(EditorStatus, { tone: "error", "data-testid": "inline-editor-command-error", style: { left: `${area.x + area.width / 2}px`, top: `${area.y + 8}px` }, children: [_jsx("span", { children: commandError }), _jsx(Button, { size: "sm", variant: "secondary", onClick: () => setCommandError(null), children: t('inlineCanvas.chrome.close', '关闭') })] })), !capturing && aiApplyNote && (_jsxs(EditorStatus, { "data-testid": "inline-editor-ai-apply-note", style: { left: `${area.x + area.width / 2}px`, top: `${area.y + 8}px` }, children: [_jsx("span", { role: "status", children: aiApplyNote }), _jsx(Button, { size: "sm", variant: "secondary", onClick: () => setAiApplyNote(null), children: t('inlineCanvas.chrome.close', '关闭') })] })), !capturing && move && (
|
|
1018
|
+
} }, shot.dataUrl)), _jsx(ChangeListPopover, { open: showChangeList && !capturing, onClose: () => setShowChangeList(false), children: _jsx(ChangeListPanel, { list: changeList.list, onRevert: id => uiWrite(() => writes.revert(id)), onRevertAll: () => uiWrite(() => writes.revertAll()), lastError: changeList.lastError, onHover: s => void commands.hover(s), onSelect: s => void selectAndReveal(s) }) })] }), !capturing && (_jsx(SelectionOverlay, { geometry: geometry.latest, context: overlayContext, selectedLabel: self ? `<${self.tag}>` : undefined })), !capturing && blockOverlay && (_jsx(BlockOverlay, { blocks: blockOverlay.blocks, activeId: blockOverlay.activeId, onHover: blockOverlay.onHover, geometry: geometry.latest, context: overlayContext, scale: editor.mobileFit.scale })), !capturing && commandError && (_jsxs(EditorStatus, { tone: "error", "data-testid": "inline-editor-command-error", style: { left: `${area.x + area.width / 2}px`, top: `${area.y + 8}px` }, children: [_jsx("span", { children: commandError }), _jsx(Button, { size: "sm", variant: "secondary", onClick: () => setCommandError(null), children: t('inlineCanvas.chrome.close', '关闭') })] })), !capturing && aiApplyNote && (_jsxs(EditorStatus, { "data-testid": "inline-editor-ai-apply-note", style: { left: `${area.x + area.width / 2}px`, top: `${area.y + 8}px` }, children: [_jsx("span", { role: "status", children: aiApplyNote }), _jsx(Button, { size: "sm", variant: "secondary", onClick: () => setAiApplyNote(null), children: t('inlineCanvas.chrome.close', '关闭') })] })), !capturing && move && (_jsx("div", { role: "toolbar", "aria-label": t('inlineCanvas.move.title'), "data-testid": "inline-editor-move-bar", className: "fixed z-[903] -translate-x-1/2 rounded-lg border bg-popover p-3 text-xs text-popover-foreground shadow-md", style: {
|
|
1019
|
+
width: 'min(600px, calc(100vw - 32px))',
|
|
1020
|
+
left: `clamp(min(316px, 50vw), ${area.x + area.width / 2}px, max(calc(100vw - 316px), 50vw))`,
|
|
1021
|
+
top: `${area.y + 8}px`
|
|
1022
|
+
}, children: move.target === null ? (_jsxs("div", { className: "flex items-center justify-between gap-3", children: [_jsx("span", { children: move.rejected === 'same-element'
|
|
885
1023
|
? '不能移到它自己里面,请另选一个目标'
|
|
886
|
-
: t('inlineCanvas.move.pickTarget') })
|
|
887
|
-
.confirmMove(pos)
|
|
888
|
-
.catch(e => setCommandError(describeCanvasError(e))), children: t(`inlineCanvas.move.${pos}`) }, pos)))] })), _jsx(Button, { size: "sm", variant: "secondary", type: "button", "data-testid": "move-cancel", onClick: () => commands.cancelMove(), children: t('inlineCanvas.move.cancel') })] })), !capturing && textEditing !== null && (_jsx(EditorStatus, { "data-testid": "inline-editor-text-editing-hint", style: {
|
|
1024
|
+
: t('inlineCanvas.move.pickTarget') }), _jsx(Button, { size: "default", variant: "secondary", type: "button", "data-testid": "move-cancel", onClick: () => commands.cancelMove(), children: t('inlineCanvas.move.cancel') })] })) : (_jsx(MovePositionControls, { onConfirm: commands.confirmMove, onCancel: commands.cancelMove, onError: e => setCommandError(describeCanvasError(e)) }, move.target)) })), !capturing && textEditing !== null && (_jsx(EditorStatus, { "data-testid": "inline-editor-text-editing-hint", style: {
|
|
889
1025
|
left: `${area.x + area.width / 2}px`,
|
|
890
1026
|
top: `${area.y + 8}px`
|
|
891
1027
|
}, children: "\u6B63\u5728\u7F16\u8F91\u6587\u5B57 \u00B7 \u56DE\u8F66\u63D0\u4EA4 \u00B7 Esc \u53D6\u6D88" })), !capturing && !switching && !move && textEditing === null && (_jsx(SelectionToolbar, { selectedRect: selectedRect, area: area, capabilities: selection?.capabilities ?? null, isScrolling: geometry.isScrolling, onAction: onToolbarAction, host: { askAi: !!onAskAi } })), stylePanelOpen &&
|
|
@@ -895,7 +1031,15 @@ export function InlineEditor({ url, device, onDeviceChange, title, versionLabel
|
|
|
895
1031
|
createPortal(_jsx("div", { className: `${CANVAS_UI_SCOPE} fixed z-[1000]`, children: _jsx(StylePanel, { anchor: {
|
|
896
1032
|
x: Math.min(selectedRect.x + selectedRect.width + 12, area.x + area.width - 332),
|
|
897
1033
|
y: Math.max(area.y + 8, selectedRect.y)
|
|
898
|
-
}, tag: self.tag, capabilities: selection?.capabilities ?? null,
|
|
1034
|
+
}, tag: self.tag, capabilities: selection?.capabilities ?? null, resettableProperties: changeList.list.flatMap(r => r.change.type === 'elementStyle' &&
|
|
1035
|
+
r.change.selector === self.selector
|
|
1036
|
+
? [r.change.property]
|
|
1037
|
+
: []), declaredStyles: Object.assign({}, currentStyles, ...changeList.list
|
|
1038
|
+
.filter(r => r.change.type === 'elementStyle' &&
|
|
1039
|
+
r.change.selector === self.selector)
|
|
1040
|
+
.map(r => r.change.type === 'elementStyle'
|
|
1041
|
+
? { [r.change.property]: r.change.value }
|
|
1042
|
+
: {})), onReset: property => {
|
|
899
1043
|
void uiWrite(async () => {
|
|
900
1044
|
for (const record of changeList
|
|
901
1045
|
.peekList()
|
|
@@ -3,7 +3,7 @@ import { CodeField } from './CodeField.js';
|
|
|
3
3
|
import { useEffect, useMemo, useState } from 'react';
|
|
4
4
|
import { useTranslation } from 'react-i18next';
|
|
5
5
|
import { AlertTriangle } from 'lucide-react';
|
|
6
|
-
import { Button, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@ptengine/design-components';
|
|
6
|
+
import { Button, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, ToggleGroup, ToggleGroupItem } from '@ptengine/design-components';
|
|
7
7
|
import { pasteModes } from '../kernel/selection/toolbarButtons.js';
|
|
8
8
|
import { validateHtml } from './htmlValidation.js';
|
|
9
9
|
export function InsertElementDialog({ open, capabilities, onOpenChange, onApply }) {
|
|
@@ -21,7 +21,11 @@ export function InsertElementDialog({ open, capabilities, onOpenChange, onApply
|
|
|
21
21
|
}, [open, modeKey]);
|
|
22
22
|
const result = useMemo(() => validateHtml(html), [html]);
|
|
23
23
|
const canApply = html.trim().length > 0 && result.ok && modes.length > 0;
|
|
24
|
-
return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { closeLabel: t('inlineCanvas.chrome.close', '关闭'), size: "md", className: "max-h-[calc(100dvh-32px)] overflow-y-auto", "data-testid": "insert-element-dialog", children: [_jsx(DialogHeader, { children: _jsx(DialogTitle, { children: t('inlineCanvas.insert.title') }) }), _jsxs("div", { className: "flex flex-col gap-3", children: [_jsxs("div", { children: [_jsx("p", { className: "mb-1.5 text-[13px] text-[#344563]", children: t('inlineCanvas.insert.html') }), _jsx(CodeField, { "aria-label": t('inlineCanvas.insert.html'), "data-testid": "insert-html", rows: 8, className: "font-mono text-[12px]", value: html, onChange: e => setHtml(e.target.value) })] }), modes.length > 0 && (_jsxs("div", { children: [_jsx("p", { className: "mb-1.5 text-[13px] text-[#344563]", children: t('inlineCanvas.insert.position') }), _jsx(
|
|
24
|
+
return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { closeLabel: t('inlineCanvas.chrome.close', '关闭'), size: "md", className: "max-h-[calc(100dvh-32px)] overflow-y-auto", "data-testid": "insert-element-dialog", children: [_jsx(DialogHeader, { children: _jsx(DialogTitle, { children: t('inlineCanvas.insert.title') }) }), _jsxs("div", { className: "flex flex-col gap-3", children: [_jsxs("div", { children: [_jsx("p", { className: "mb-1.5 text-[13px] text-[#344563]", children: t('inlineCanvas.insert.html') }), _jsx(CodeField, { "aria-label": t('inlineCanvas.insert.html'), "data-testid": "insert-html", rows: 8, className: "font-mono text-[12px]", value: html, onChange: e => setHtml(e.target.value) })] }), modes.length > 0 && (_jsxs("div", { children: [_jsx("p", { className: "mb-1.5 text-[13px] text-[#344563]", children: t('inlineCanvas.insert.position') }), _jsx(ToggleGroup, { type: "single", variant: "outline", value: mode, onValueChange: value => {
|
|
25
|
+
if (modes.includes(value)) {
|
|
26
|
+
setMode(value);
|
|
27
|
+
}
|
|
28
|
+
}, "aria-label": t('inlineCanvas.insert.position'), style: { justifyContent: 'flex-start', flexWrap: 'wrap' }, children: modes.map(m => (_jsx(ToggleGroupItem, { value: m, "data-testid": `insert-mode-${m}`, children: t(`inlineCanvas.clipboard.${m}`) }, m))) })] })), html.trim().length > 0 && result.error && (_jsxs("p", { "data-testid": "insert-error", className: "flex items-start gap-1.5 text-xs leading-4 text-[#DE350B]", children: [_jsx(AlertTriangle, { className: "mt-0.5 size-3 shrink-0" }), t(result.error)] }))] }), _jsxs(DialogFooter, { children: [_jsx(Button, { variant: "secondary", onClick: () => onOpenChange(false), children: t('common.cancel', '取消') }), _jsx(Button, { "data-testid": "insert-apply", disabled: !canApply, onClick: () => {
|
|
25
29
|
void onApply({ html: html.trim(), mode });
|
|
26
30
|
onOpenChange(false);
|
|
27
31
|
}, children: t('common.apply', '应用') })] })] }) }));
|
|
@@ -248,10 +248,11 @@ export interface AiApplyDeps {
|
|
|
248
248
|
*/
|
|
249
249
|
ancestors: string[];
|
|
250
250
|
/**
|
|
251
|
-
*
|
|
251
|
+
* 货架里某一项的祖先链(由近及远,含作用域根自己)。**整页货架那些行用**。
|
|
252
252
|
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
253
|
+
* 纯元素作用域不传:那时 `ancestors` 就是选中元素的祖先链,而作用域里的后代都在它
|
|
254
|
+
* 底下,同一条链够用。元素作用域并上了整页货架时(宿主有整页模式)传,查的是并进来的那些行。
|
|
255
|
+
* 整页作用域不一样 —— 根是 `body`,它的 `ancestors` 是空数组,
|
|
255
256
|
* 拿它去判 `MOVE_ELEMENT` 的 target 会把**所有**移动判成非法(body 没有祖先)。
|
|
256
257
|
* 这个函数让那道闸改为对着「被移动的那个元素」的祖先链判,规则本身没变。
|
|
257
258
|
*
|
|
@@ -16,6 +16,13 @@
|
|
|
16
16
|
* · 装着 ≥2 个候选区块 = 外壳,拆开;只装着 1 个 = 它自己就是这一块(外层赢,小圆点、
|
|
17
17
|
* 进度条这些附属部件都在最外层里),但占页高 ≥60% 的单链节点是整页的框,让给内层;
|
|
18
18
|
* · 宿主给了页面模型(`blocks`)时,成员归属校正拆合,并在块根行写 `blockId`。
|
|
19
|
+
*
|
|
20
|
+
* **矮区块**(2026-10-21):几何门槛(高 ≥120)挡掉了真正的矮区块 —— vivaia 手机首页的新闻区只有 86px,
|
|
21
|
+
* 父级是外壳,它既不是块根也不在块根里,只能等回填,100 行用完时它的标题还没轮到,「把新闻区标题改成…」
|
|
22
|
+
* 两次被拒、0 处落地。门槛本身不降(降了,section 里的标题条也成候选,section 被拆成外壳)。补两条:
|
|
23
|
+
* · 页面模型的成员容器只要够宽就是候选,不看高度(热图说它是一块,它就是一块);
|
|
24
|
+
* · 块根的父级就是区块上下叠放的那一层:那里不够高、但够宽、有内容、不在 header/nav/footer 里的孩子,
|
|
25
|
+
* 也算一块(没有页面模型时的兜底)。
|
|
19
26
|
* 旧规则「内层有 heading 就赢」不可用:kit 的 `heading` 是**子树里**第一个标题(没有就取第一段
|
|
20
27
|
* 文字),section / 轮播轨道 / 每张幻灯片拿到的是同一句,规则又偏向内层 —— 真机 vivaia 首页的
|
|
21
28
|
* 首屏只剩三张幻灯片,模型照「首屏下面」插进了轮播(2026-10-20)。
|
|
@@ -19,6 +19,8 @@ const SAME_TOP_PX = 2;
|
|
|
19
19
|
const CHROME_ROWS_CAP = 5;
|
|
20
20
|
const CHROME_TAGS = new Set(['header', 'nav', 'footer']);
|
|
21
21
|
const TEXT_LEAF_TAGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'a', 'button', 'span']);
|
|
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));
|
|
22
24
|
function analyzeBlockRoots(root, opts) {
|
|
23
25
|
const viewportWidth = typeof opts.viewportWidth === 'number' && opts.viewportWidth > 0
|
|
24
26
|
? opts.viewportWidth
|
|
@@ -76,13 +78,23 @@ function analyzeBlockRoots(root, opts) {
|
|
|
76
78
|
for (const e of entries)
|
|
77
79
|
e.demoted = false;
|
|
78
80
|
}
|
|
79
|
-
const isCandidate = (e) => e.landmark && !e.demoted;
|
|
80
81
|
const entryBySelector = new Map();
|
|
81
82
|
for (const e of entries)
|
|
82
83
|
if (!entryBySelector.has(e.node.selector))
|
|
83
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;
|
|
84
95
|
const claimed = new Map();
|
|
85
96
|
const blocksIn = new Map();
|
|
97
|
+
const memberEntries = new Set();
|
|
86
98
|
const addTo = (m, e, id) => {
|
|
87
99
|
let set = m.get(e);
|
|
88
100
|
if (!set)
|
|
@@ -91,14 +103,17 @@ function analyzeBlockRoots(root, opts) {
|
|
|
91
103
|
};
|
|
92
104
|
for (const b of opts.blocks ?? []) {
|
|
93
105
|
for (const member of b.members) {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
e = e.parent;
|
|
97
|
-
if (!e)
|
|
106
|
+
const own = entryBySelector.get(member.selector);
|
|
107
|
+
if (!own)
|
|
98
108
|
continue;
|
|
99
|
-
|
|
100
|
-
for (let a =
|
|
109
|
+
memberEntries.add(own);
|
|
110
|
+
for (let a = own; a; a = a.parent)
|
|
101
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);
|
|
102
117
|
}
|
|
103
118
|
}
|
|
104
119
|
const modelBlockCount = (e) => blocksIn.get(e)?.size ?? 0;
|
|
@@ -126,7 +141,7 @@ function analyzeBlockRoots(root, opts) {
|
|
|
126
141
|
for (const e of [...candidates].reverse()) {
|
|
127
142
|
const ks = kids.get(e) ?? [];
|
|
128
143
|
let unit;
|
|
129
|
-
if (isAtomic(e))
|
|
144
|
+
if (isAtomic(e) && (memberEntries.has(e) || ks.length < 2))
|
|
130
145
|
unit = true;
|
|
131
146
|
else if (modelBlockCount(e) >= 2)
|
|
132
147
|
unit = ks.length === 0;
|
|
@@ -148,6 +163,25 @@ function analyzeBlockRoots(root, opts) {
|
|
|
148
163
|
}
|
|
149
164
|
};
|
|
150
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))
|
|
174
|
+
continue;
|
|
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);
|
|
151
185
|
const blockIdOf = (e) => {
|
|
152
186
|
const ids = blocksIn.get(e);
|
|
153
187
|
return ids && ids.size === 1 ? [...ids][0] : undefined;
|
|
@@ -226,7 +260,6 @@ export function buildRootShelf(root, opts) {
|
|
|
226
260
|
chromeRows.set(e.chromeKey, (chromeRows.get(e.chromeKey) ?? 0) + 1);
|
|
227
261
|
return true;
|
|
228
262
|
};
|
|
229
|
-
const isTextLeaf = (n) => n.tag === 'img' || (TEXT_LEAF_TAGS.has(n.tag) && n.textSummary.trim().length > 0);
|
|
230
263
|
const leavesPerBlock = landmarks.length
|
|
231
264
|
? Math.min(LEAVES_PER_LANDMARK, Math.max(1, Math.floor((limit - landmarks.length) / landmarks.length)))
|
|
232
265
|
: LEAVES_PER_LANDMARK;
|
package/dist/locales.d.ts
CHANGED
|
@@ -54,6 +54,7 @@ export declare const zhCN: {
|
|
|
54
54
|
title: string;
|
|
55
55
|
pickTarget: string;
|
|
56
56
|
cancel: string;
|
|
57
|
+
confirm: string;
|
|
57
58
|
position: string;
|
|
58
59
|
top: string;
|
|
59
60
|
bottom: string;
|
|
@@ -234,6 +235,7 @@ export declare const enUS: {
|
|
|
234
235
|
title: string;
|
|
235
236
|
pickTarget: string;
|
|
236
237
|
cancel: string;
|
|
238
|
+
confirm: string;
|
|
237
239
|
position: string;
|
|
238
240
|
top: string;
|
|
239
241
|
bottom: string;
|
|
@@ -414,6 +416,7 @@ export declare const jaJP: {
|
|
|
414
416
|
title: string;
|
|
415
417
|
pickTarget: string;
|
|
416
418
|
cancel: string;
|
|
419
|
+
confirm: string;
|
|
417
420
|
position: string;
|
|
418
421
|
top: string;
|
|
419
422
|
bottom: string;
|
|
@@ -553,6 +556,9 @@ export declare const inlineEditorLocales: {
|
|
|
553
556
|
readonly 'zh-CN': {
|
|
554
557
|
readonly inlineCanvas: {
|
|
555
558
|
readonly review: {
|
|
559
|
+
readonly pageLoadFailed: "目标页面无法加载";
|
|
560
|
+
readonly pageLoadHint: "请返回检查页面地址、网站访问权限和网络连接后重试。";
|
|
561
|
+
readonly backFromFailed: "返回修改";
|
|
556
562
|
readonly generating: "正在生成截图…";
|
|
557
563
|
readonly noLayers: "没有匹配的图层";
|
|
558
564
|
readonly clearSearch: "清除搜索";
|
|
@@ -641,6 +647,7 @@ export declare const inlineEditorLocales: {
|
|
|
641
647
|
title: string;
|
|
642
648
|
pickTarget: string;
|
|
643
649
|
cancel: string;
|
|
650
|
+
confirm: string;
|
|
644
651
|
position: string;
|
|
645
652
|
top: string;
|
|
646
653
|
bottom: string;
|
|
@@ -779,6 +786,9 @@ export declare const inlineEditorLocales: {
|
|
|
779
786
|
readonly 'en-US': {
|
|
780
787
|
readonly inlineCanvas: {
|
|
781
788
|
readonly review: {
|
|
789
|
+
readonly pageLoadFailed: "Unable to load the target page";
|
|
790
|
+
readonly pageLoadHint: "Go back to check the page URL, site permissions and network connection, then retry.";
|
|
791
|
+
readonly backFromFailed: "Go back";
|
|
782
792
|
readonly generating: "Generating screenshot…";
|
|
783
793
|
readonly noLayers: "No matching layers";
|
|
784
794
|
readonly clearSearch: "Clear search";
|
|
@@ -867,6 +877,7 @@ export declare const inlineEditorLocales: {
|
|
|
867
877
|
title: string;
|
|
868
878
|
pickTarget: string;
|
|
869
879
|
cancel: string;
|
|
880
|
+
confirm: string;
|
|
870
881
|
position: string;
|
|
871
882
|
top: string;
|
|
872
883
|
bottom: string;
|
|
@@ -1005,6 +1016,9 @@ export declare const inlineEditorLocales: {
|
|
|
1005
1016
|
readonly 'ja-JP': {
|
|
1006
1017
|
readonly inlineCanvas: {
|
|
1007
1018
|
readonly review: {
|
|
1019
|
+
readonly pageLoadFailed: "対象ページを読み込めません";
|
|
1020
|
+
readonly pageLoadHint: "戻ってページ URL、サイトへのアクセス権限、ネットワーク接続を確認して再試行してください。";
|
|
1021
|
+
readonly backFromFailed: "戻って修正";
|
|
1008
1022
|
readonly generating: "スクリーンショットを生成中…";
|
|
1009
1023
|
readonly noLayers: "一致するレイヤーがありません";
|
|
1010
1024
|
readonly clearSearch: "検索をクリア";
|
|
@@ -1093,6 +1107,7 @@ export declare const inlineEditorLocales: {
|
|
|
1093
1107
|
title: string;
|
|
1094
1108
|
pickTarget: string;
|
|
1095
1109
|
cancel: string;
|
|
1110
|
+
confirm: string;
|
|
1096
1111
|
position: string;
|
|
1097
1112
|
top: string;
|
|
1098
1113
|
bottom: string;
|
package/dist/locales.js
CHANGED
|
@@ -44,6 +44,7 @@ export const zhCN = {
|
|
|
44
44
|
title: '移动元素',
|
|
45
45
|
pickTarget: '在画布里点选目标元素',
|
|
46
46
|
cancel: '取消移动',
|
|
47
|
+
confirm: '确认移动',
|
|
47
48
|
position: '相对目标的位置',
|
|
48
49
|
top: '移入开头',
|
|
49
50
|
bottom: '移入末尾',
|
|
@@ -224,6 +225,7 @@ export const enUS = {
|
|
|
224
225
|
title: 'Move element',
|
|
225
226
|
pickTarget: 'Pick a target element on the canvas',
|
|
226
227
|
cancel: 'Cancel move',
|
|
228
|
+
confirm: 'Confirm move',
|
|
227
229
|
position: 'Position relative to target',
|
|
228
230
|
top: 'Inside, first',
|
|
229
231
|
bottom: 'Inside, last',
|
|
@@ -404,6 +406,7 @@ export const jaJP = {
|
|
|
404
406
|
title: '要素を移動',
|
|
405
407
|
pickTarget: 'キャンバスで対象要素を選択',
|
|
406
408
|
cancel: '移動をキャンセル',
|
|
409
|
+
confirm: '移動を確定',
|
|
407
410
|
position: '対象に対する位置',
|
|
408
411
|
top: '内側の先頭',
|
|
409
412
|
bottom: '内側の末尾',
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { MovePosition } from '../kernel/moveState.js';
|
|
2
|
+
export declare function MovePositionControls({ onConfirm, onCancel, onError }: {
|
|
3
|
+
onConfirm: (position: MovePosition) => Promise<void>;
|
|
4
|
+
onCancel: () => void;
|
|
5
|
+
onError: (error: unknown) => void;
|
|
6
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useRef, useState } from 'react';
|
|
3
|
+
import { useTranslation } from 'react-i18next';
|
|
4
|
+
import { Button, ToggleGroup, ToggleGroupItem } from '@ptengine/design-components';
|
|
5
|
+
const positions = ['top', 'bottom', 'before', 'after'];
|
|
6
|
+
export function MovePositionControls({ onConfirm, onCancel, onError }) {
|
|
7
|
+
const { t } = useTranslation();
|
|
8
|
+
const [position, setPosition] = useState(null);
|
|
9
|
+
const [pending, setPending] = useState(false);
|
|
10
|
+
const submitting = useRef(false);
|
|
11
|
+
const confirm = async () => {
|
|
12
|
+
if (!position || submitting.current)
|
|
13
|
+
return;
|
|
14
|
+
submitting.current = true;
|
|
15
|
+
setPending(true);
|
|
16
|
+
try {
|
|
17
|
+
await onConfirm(position);
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
onError(error);
|
|
21
|
+
}
|
|
22
|
+
finally {
|
|
23
|
+
submitting.current = false;
|
|
24
|
+
setPending(false);
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
return (_jsxs("div", { className: "flex w-full min-w-0 flex-col gap-2.5", children: [_jsx("span", { className: "text-xs text-muted-foreground", children: t('inlineCanvas.move.position') }), _jsxs("div", { className: "flex flex-col items-stretch gap-3", children: [_jsx(ToggleGroup, { type: "single", variant: "outline", value: position ?? '', disabled: pending, "aria-label": t('inlineCanvas.move.position'), onValueChange: value => {
|
|
28
|
+
if (positions.includes(value)) {
|
|
29
|
+
setPosition(value);
|
|
30
|
+
}
|
|
31
|
+
}, style: {
|
|
32
|
+
display: 'grid',
|
|
33
|
+
gridTemplateColumns: 'repeat(4, minmax(0, 1fr))'
|
|
34
|
+
}, children: positions.map(pos => (_jsx(ToggleGroupItem, { value: pos, "data-testid": `move-position-${pos}`, children: t(`inlineCanvas.move.${pos}`) }, pos))) }), _jsxs("div", { className: "flex items-center justify-end gap-2", children: [_jsx(Button, { size: "default", variant: "secondary", type: "button", "data-testid": "move-cancel", disabled: pending, onClick: onCancel, children: t('inlineCanvas.move.cancel') }), _jsx(Button, { size: "default", type: "button", "data-testid": "move-confirm", disabled: !position || pending, onClick: () => void confirm(), children: t('inlineCanvas.move.confirm') })] })] })] }));
|
|
35
|
+
}
|
|
@@ -22,9 +22,11 @@ export interface StylePanelProps {
|
|
|
22
22
|
capabilities: ElementCapabilities | null;
|
|
23
23
|
/** 元素当前的 author 层声明,用作输入框初值。 */
|
|
24
24
|
declaredStyles?: Record<string, string>;
|
|
25
|
+
/** Only these properties have an editor override that can be cleared. */
|
|
26
|
+
resettableProperties?: string[];
|
|
25
27
|
/** 提交一条样式改动。由宿主接到 `changeList.addStyle`。 */
|
|
26
28
|
onApply: (property: string, value: string) => void | Promise<unknown>;
|
|
27
29
|
onClose: () => void;
|
|
28
30
|
onReset?: (property: string) => void;
|
|
29
31
|
}
|
|
30
|
-
export declare function StylePanel({ anchor, tag, capabilities, declaredStyles, onApply, onReset, onClose }: StylePanelProps): import("react").JSX.Element | null;
|
|
32
|
+
export declare function StylePanel({ anchor, tag, capabilities, declaredStyles, resettableProperties, onApply, onReset, onClose }: StylePanelProps): import("react").JSX.Element | null;
|
|
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
|
|
|
4
4
|
import { X } from 'lucide-react';
|
|
5
5
|
import { Button, InputGroup, InputGroupInput, InputGroupAddon, InputGroupText, Label, NativeSelect, Separator } from '@ptengine/design-components';
|
|
6
6
|
import { composeStyleValue, visibleSections } from './styleFields.js';
|
|
7
|
-
export function StylePanel({ anchor, tag, capabilities, declaredStyles = {}, onApply, onReset, onClose }) {
|
|
7
|
+
export function StylePanel({ anchor, tag, capabilities, declaredStyles = {}, resettableProperties, onApply, onReset, onClose }) {
|
|
8
8
|
const { t } = useTranslation();
|
|
9
9
|
const sections = visibleSections(capabilities);
|
|
10
10
|
const panelRef = useRef(null);
|
|
@@ -88,7 +88,10 @@ export function StylePanel({ anchor, tag, capabilities, declaredStyles = {}, onA
|
|
|
88
88
|
drag.current = null;
|
|
89
89
|
}, onLostPointerCapture: () => {
|
|
90
90
|
drag.current = null;
|
|
91
|
-
}, className: "flex cursor-grab touch-none select-none h-11 shrink-0 items-center justify-between border-b\n border-[#EBECF0] bg-[#F4F5F7] px-3.5", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsxs("code", { className: "text-xs text-[#6B778C]", children: ["<", tag, ">"] }), _jsx("span", { className: "text-[13px] font-semibold text-[#091E42]", children: t(`taginfo.${tag.toUpperCase()}`, tag) })] }), _jsx(Button, { variant: "ghost", size: "icon", "aria-label": t('inlineCanvas.chrome.close', '关闭'), "data-testid": "inline-style-panel-close", onClick: onClose, children: _jsx(X, { className: "size-4" }) })] }), _jsx("div", { className: "min-h-0 flex-1 flex-col overflow-y-auto", children: sections.map((section, index) => (_jsxs("section", { "data-testid": `style-section-${section.id}`, className: "flex flex-col gap-3 px-3.5 py-3.5", children: [index > 0 && _jsx(Separator, { className: "-mt-3.5 mb-0.5" }), _jsx("p", { className: "text-xs font-semibold text-[#091E42]", children: t(section.labelKey) }), _jsx("div", { className: "grid grid-cols-2 gap-2.5", children: section.fields.map(field => (_jsx(StyleFieldInput, { field: field, initial: declaredStyles[field.property] ?? '', onApply: onApply, onReset:
|
|
91
|
+
}, className: "flex cursor-grab touch-none select-none h-11 shrink-0 items-center justify-between border-b\n border-[#EBECF0] bg-[#F4F5F7] px-3.5", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsxs("code", { className: "text-xs text-[#6B778C]", children: ["<", tag, ">"] }), _jsx("span", { className: "text-[13px] font-semibold text-[#091E42]", children: t(`taginfo.${tag.toUpperCase()}`, tag) })] }), _jsx(Button, { variant: "ghost", size: "icon", "aria-label": t('inlineCanvas.chrome.close', '关闭'), "data-testid": "inline-style-panel-close", onClick: onClose, children: _jsx(X, { className: "size-4" }) })] }), _jsx("div", { className: "min-h-0 flex-1 flex-col overflow-y-auto", children: sections.map((section, index) => (_jsxs("section", { "data-testid": `style-section-${section.id}`, className: "flex flex-col gap-3 px-3.5 py-3.5", children: [index > 0 && _jsx(Separator, { className: "-mt-3.5 mb-0.5" }), _jsx("p", { className: "text-xs font-semibold text-[#091E42]", children: t(section.labelKey) }), _jsx("div", { className: "grid grid-cols-2 gap-2.5", children: section.fields.map(field => (_jsx(StyleFieldInput, { field: field, initial: declaredStyles[field.property] ?? '', onApply: onApply, onReset: resettableProperties === undefined ||
|
|
92
|
+
resettableProperties.includes(field.property)
|
|
93
|
+
? onReset
|
|
94
|
+
: undefined }, field.property))) })] }, section.id))) }), _jsx("footer", { className: "shrink-0 bg-[#F7F8F9] px-3.5 py-3 text-xs leading-5 text-[#6B778C]", children: t('inlineCanvas.style.footnote', '每次改动都会记入改动清单,可单条撤销') })] }));
|
|
92
95
|
}
|
|
93
96
|
function StyleFieldInput({ field, initial, onApply, onReset }) {
|
|
94
97
|
const { t } = useTranslation();
|
package/dist/reviewLocales.d.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
export declare const reviewLocales: {
|
|
2
2
|
readonly 'zh-CN': {
|
|
3
|
+
readonly pageLoadFailed: "目标页面无法加载";
|
|
4
|
+
readonly pageLoadHint: "请返回检查页面地址、网站访问权限和网络连接后重试。";
|
|
5
|
+
readonly backFromFailed: "返回修改";
|
|
3
6
|
readonly generating: "正在生成截图…";
|
|
4
7
|
readonly noLayers: "没有匹配的图层";
|
|
5
8
|
readonly clearSearch: "清除搜索";
|
|
@@ -46,6 +49,9 @@ export declare const reviewLocales: {
|
|
|
46
49
|
readonly align_justify: "两端对齐";
|
|
47
50
|
};
|
|
48
51
|
readonly 'en-US': {
|
|
52
|
+
readonly pageLoadFailed: "Unable to load the target page";
|
|
53
|
+
readonly pageLoadHint: "Go back to check the page URL, site permissions and network connection, then retry.";
|
|
54
|
+
readonly backFromFailed: "Go back";
|
|
49
55
|
readonly generating: "Generating screenshot…";
|
|
50
56
|
readonly noLayers: "No matching layers";
|
|
51
57
|
readonly clearSearch: "Clear search";
|
|
@@ -92,6 +98,9 @@ export declare const reviewLocales: {
|
|
|
92
98
|
readonly align_justify: "Justify";
|
|
93
99
|
};
|
|
94
100
|
readonly 'ja-JP': {
|
|
101
|
+
readonly pageLoadFailed: "対象ページを読み込めません";
|
|
102
|
+
readonly pageLoadHint: "戻ってページ URL、サイトへのアクセス権限、ネットワーク接続を確認して再試行してください。";
|
|
103
|
+
readonly backFromFailed: "戻って修正";
|
|
95
104
|
readonly generating: "スクリーンショットを生成中…";
|
|
96
105
|
readonly noLayers: "一致するレイヤーがありません";
|
|
97
106
|
readonly clearSearch: "検索をクリア";
|
package/dist/reviewLocales.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
export const reviewLocales = {
|
|
2
2
|
'zh-CN': {
|
|
3
|
+
pageLoadFailed: '目标页面无法加载',
|
|
4
|
+
pageLoadHint: '请返回检查页面地址、网站访问权限和网络连接后重试。',
|
|
5
|
+
backFromFailed: '返回修改',
|
|
3
6
|
generating: '正在生成截图…',
|
|
4
7
|
noLayers: '没有匹配的图层', clearSearch: '清除搜索', searchLayers: '搜索图层',
|
|
5
8
|
linkCopied: '链接已复制',
|
|
@@ -44,6 +47,9 @@ export const reviewLocales = {
|
|
|
44
47
|
align_justify: '两端对齐'
|
|
45
48
|
},
|
|
46
49
|
'en-US': {
|
|
50
|
+
pageLoadFailed: 'Unable to load the target page',
|
|
51
|
+
pageLoadHint: 'Go back to check the page URL, site permissions and network connection, then retry.',
|
|
52
|
+
backFromFailed: 'Go back',
|
|
47
53
|
generating: 'Generating screenshot…',
|
|
48
54
|
noLayers: 'No matching layers', clearSearch: 'Clear search', searchLayers: 'Search layers',
|
|
49
55
|
linkCopied: 'Link copied',
|
|
@@ -88,6 +94,9 @@ export const reviewLocales = {
|
|
|
88
94
|
align_justify: 'Justify'
|
|
89
95
|
},
|
|
90
96
|
'ja-JP': {
|
|
97
|
+
pageLoadFailed: '対象ページを読み込めません',
|
|
98
|
+
pageLoadHint: '戻ってページ URL、サイトへのアクセス権限、ネットワーク接続を確認して再試行してください。',
|
|
99
|
+
backFromFailed: '戻って修正',
|
|
91
100
|
generating: 'スクリーンショットを生成中…',
|
|
92
101
|
noLayers: '一致するレイヤーがありません', clearSearch: '検索をクリア', searchLayers: 'レイヤーを検索',
|
|
93
102
|
linkCopied: 'リンクをコピーしました',
|
|
@@ -42,6 +42,7 @@ export interface InlineEditorSelection {
|
|
|
42
42
|
declaredStyles: Record<string, string>;
|
|
43
43
|
}
|
|
44
44
|
export interface UseInlineEditorInput {
|
|
45
|
+
onHistoryShortcut?: (direction: 'undo' | 'redo') => void;
|
|
45
46
|
url: string;
|
|
46
47
|
device: CanvasDevice;
|
|
47
48
|
/** change list 变化时回调 —— 宿主自己决定存哪(本 hook 不做持久化)。 */
|
package/dist/useInlineEditor.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { CANVAS_MSG_NAMESPACE } from '@ptengine/inline-core/protocol';
|
|
2
3
|
import { useCanvasSession } from './kernel/useCanvasSession.js';
|
|
3
4
|
import { useCanvasStream } from './kernel/useCanvasStream.js';
|
|
4
5
|
import { useMobileFit } from './kernel/useMobileFit.js';
|
|
@@ -49,10 +50,21 @@ export function useInlineEditor(input) {
|
|
|
49
50
|
documentSizeRef.current?.(payload);
|
|
50
51
|
}, []);
|
|
51
52
|
const stream = useCanvasStream(undefined, geometry.onGeometry, onTextEditCommit, onLifecycle, onDocumentSize);
|
|
53
|
+
const historyShortcutRef = useRef(input.onHistoryShortcut);
|
|
54
|
+
historyShortcutRef.current = input.onHistoryShortcut;
|
|
55
|
+
const onStreamEvent = useCallback((msg) => {
|
|
56
|
+
if (msg.namespace === CANVAS_MSG_NAMESPACE && msg.event === 'historyShortcut') {
|
|
57
|
+
const direction = msg.payload?.direction;
|
|
58
|
+
if (direction === 'undo' || direction === 'redo')
|
|
59
|
+
historyShortcutRef.current?.(direction);
|
|
60
|
+
}
|
|
61
|
+
else
|
|
62
|
+
stream.onStreamEvent(msg);
|
|
63
|
+
}, [stream.onStreamEvent]);
|
|
52
64
|
const session = useCanvasSession({
|
|
53
65
|
url: input.url,
|
|
54
66
|
device: input.device,
|
|
55
|
-
onStreamEvent
|
|
67
|
+
onStreamEvent
|
|
56
68
|
});
|
|
57
69
|
const sessionId = session.frameProps?.sessionId;
|
|
58
70
|
const changeList = useChangeList(sessionId, {
|
|
@@ -147,6 +159,33 @@ export function useInlineEditor(input) {
|
|
|
147
159
|
? graftSubtree(prev, anchorId, root, { renderIcon: renderIconRef.current })
|
|
148
160
|
: prev);
|
|
149
161
|
}, [sessionId]);
|
|
162
|
+
const treeLoaded = tree !== null;
|
|
163
|
+
const contentRevision = changeList.list
|
|
164
|
+
.filter(record => ['elementContent', 'elementText', 'elementMove'].includes(record.change.type))
|
|
165
|
+
.map(record => record.changeId)
|
|
166
|
+
.join('|');
|
|
167
|
+
const refreshedContent = useRef('');
|
|
168
|
+
useEffect(() => {
|
|
169
|
+
if (!wantTree ||
|
|
170
|
+
!domReady ||
|
|
171
|
+
!sessionId ||
|
|
172
|
+
!treeRef.current ||
|
|
173
|
+
refreshedContent.current === contentRevision)
|
|
174
|
+
return;
|
|
175
|
+
let cancelled = false;
|
|
176
|
+
const loaded = Object.values(treeRef.current.nodes);
|
|
177
|
+
void getDomTree(sessionId, { maxDepth: 16, maxNodes: Math.max(1500, loaded.length) })
|
|
178
|
+
.then(root => {
|
|
179
|
+
if (!cancelled) {
|
|
180
|
+
refreshedContent.current = contentRevision;
|
|
181
|
+
setTree(adaptDomTree(root, { renderIcon: renderIconRef.current }));
|
|
182
|
+
}
|
|
183
|
+
})
|
|
184
|
+
.catch(() => { });
|
|
185
|
+
return () => {
|
|
186
|
+
cancelled = true;
|
|
187
|
+
};
|
|
188
|
+
}, [contentRevision, domReady, sessionId, wantTree, treeLoaded]);
|
|
150
189
|
const revealAttemptedRef = useRef(new Set());
|
|
151
190
|
const selectedSelector = selection?.selector ?? null;
|
|
152
191
|
const selectionChain = selection?.chain;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ptengine/inline-editor",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "Ptengine Inline 编辑器:画布会话编排、图层树、属性面板与组件库(含内部 canvas-ui)",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"private": false,
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"lucide-react": "^0.577.0",
|
|
37
|
-
"@ptengine/inline-core": "~0.2.
|
|
37
|
+
"@ptengine/inline-core": "~0.2.1"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@ptengine/design-components": "^0.10.0",
|