@growgroup/visual-editor 0.1.5 → 0.2.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 (52) hide show
  1. package/README.md +59 -2
  2. package/dist/editor.css +1 -1
  3. package/package.json +7 -3
  4. package/src/components/ui/alert-dialog.tsx +18 -4
  5. package/src/components/ui/context-menu.tsx +18 -4
  6. package/src/components/ui/dialog.tsx +19 -5
  7. package/src/components/ui/dropdown-menu.tsx +18 -4
  8. package/src/components/ui/popover.tsx +10 -2
  9. package/src/components/ui/select.tsx +10 -2
  10. package/src/components/ui/tooltip.tsx +10 -2
  11. package/src/editor/EditorToolbar.tsx +10 -20
  12. package/src/editor/FrontendVisualEditor.tsx +189 -43
  13. package/src/editor/components/EditorCanvas.tsx +8 -3
  14. package/src/editor/components/EditorContextMenu.tsx +53 -71
  15. package/src/editor/components/EditorFooter.tsx +36 -70
  16. package/src/editor/components/EditorHeader.tsx +37 -20
  17. package/src/editor/components/EditorLayerPanel.tsx +7 -9
  18. package/src/editor/components/EditorPropertyPanel.tsx +84 -8
  19. package/src/editor/components/LayerTreeItem.tsx +28 -5
  20. package/src/editor/components/component-panel/ComponentPanel.tsx +4 -3
  21. package/src/editor/components/ppt/PptChrome.tsx +156 -344
  22. package/src/editor/components/ppt/PptComments.tsx +135 -87
  23. package/src/editor/components/ppt/PptDesignProposals.tsx +21 -26
  24. package/src/editor/components/ppt/PptNotes.tsx +15 -15
  25. package/src/editor/components/shell/CanvasAppearance.tsx +19 -0
  26. package/src/editor/components/shell/ConfirmDialog.tsx +15 -0
  27. package/src/editor/components/shell/EditorTopBar.tsx +29 -68
  28. package/src/editor/components/shell/LeftPanel.tsx +20 -94
  29. package/src/editor/components/shell/PagesPanel.tsx +4 -2
  30. package/src/editor/contexts/EditorAppearanceContext.tsx +44 -0
  31. package/src/editor/contexts/EditorComponentsContext.tsx +278 -16
  32. package/src/editor/editor-skin.css +2 -122
  33. package/src/editor/hooks/useCanvasControls.ts +3 -2
  34. package/src/editor/hooks/useComponentEditMode.ts +95 -1
  35. package/src/editor/hooks/useDragResize.ts +86 -6
  36. package/src/editor/hooks/useDrawingMode.ts +4 -2
  37. package/src/editor/hooks/useElementActions.ts +60 -1
  38. package/src/editor/hooks/useElementSelection.ts +35 -8
  39. package/src/editor/hooks/useIframeSetup.ts +10 -0
  40. package/src/editor/hooks/useKeyboardShortcuts.ts +12 -1
  41. package/src/editor/hooks/useMediaLibrary.ts +4 -5
  42. package/src/editor/hooks/useResizablePanel.ts +40 -11
  43. package/src/editor/parts.ts +321 -0
  44. package/src/editor/utils/dom-utils.ts +135 -15
  45. package/src/editor/utils/flex-reorder.ts +268 -43
  46. package/src/editor/utils/geometry.ts +4 -3
  47. package/src/editor/utils/html-utils.ts +10 -5
  48. package/src/hooks/useEditorShortcuts.ts +39 -2
  49. package/src/index.ts +25 -0
  50. package/src/io.ts +54 -0
  51. package/src/styles/skin.css +159 -0
  52. package/src/types/editor-components.ts +23 -1
@@ -24,6 +24,15 @@ export interface UseResizablePanelReturn {
24
24
  isDragging: boolean;
25
25
  /** リサイズハンドルのprops */
26
26
  resizeHandleProps: {
27
+ role: 'separator';
28
+ tabIndex: number;
29
+ 'aria-label': string;
30
+ 'aria-orientation': 'vertical';
31
+ 'aria-valuemin': number;
32
+ 'aria-valuemax': number;
33
+ 'aria-valuenow': number;
34
+ onKeyDown: (e: React.KeyboardEvent) => void;
35
+ onDoubleClick: () => void;
27
36
  onMouseDown: (e: React.MouseEvent) => void;
28
37
  onTouchStart: (e: React.TouchEvent) => void;
29
38
  style: React.CSSProperties;
@@ -44,7 +53,8 @@ export function useResizablePanel({
44
53
  // ローカルストレージから初期値を読み込み
45
54
  const getInitialWidth = () => {
46
55
  if (storageKey && typeof window !== 'undefined') {
47
- const stored = localStorage.getItem(storageKey);
56
+ let stored: string | null = null;
57
+ try { stored = localStorage.getItem(storageKey); } catch { /* 既定幅を使う */ }
48
58
  if (stored) {
49
59
  const parsed = parseInt(stored, 10);
50
60
  if (!isNaN(parsed) && parsed >= minWidth && parsed <= maxWidth) {
@@ -65,11 +75,13 @@ export function useResizablePanel({
65
75
  const clampedWidth = Math.min(maxWidth, Math.max(minWidth, newWidth));
66
76
  setWidthState(clampedWidth);
67
77
 
68
- // ローカルストレージに保存
69
- if (storageKey && typeof window !== 'undefined') {
70
- localStorage.setItem(storageKey, String(clampedWidth));
71
- }
72
- }, [minWidth, maxWidth, storageKey]);
78
+ }, [minWidth, maxWidth]);
79
+
80
+ // ドラッグ中の同期ストレージ書込みを避け、確定時だけ幅を記憶する。
81
+ useEffect(() => {
82
+ if (isDragging || !storageKey) return;
83
+ try { localStorage.setItem(storageKey, String(width)); } catch { /* 表示は継続 */ }
84
+ }, [width, isDragging, storageKey]);
73
85
 
74
86
  // マウスダウン
75
87
  const handleMouseDown = useCallback((e: React.MouseEvent) => {
@@ -123,8 +135,11 @@ export function useResizablePanel({
123
135
  document.addEventListener('mouseup', handleMouseUp);
124
136
  document.addEventListener('touchmove', handleTouchMove);
125
137
  document.addEventListener('touchend', handleTouchEnd);
138
+ document.addEventListener('touchcancel', handleTouchEnd);
126
139
 
127
140
  // カーソルスタイルを変更
141
+ const previousCursor = document.body.style.cursor;
142
+ const previousSelect = document.body.style.userSelect;
128
143
  document.body.style.cursor = 'col-resize';
129
144
  document.body.style.userSelect = 'none';
130
145
 
@@ -133,21 +148,35 @@ export function useResizablePanel({
133
148
  document.removeEventListener('mouseup', handleMouseUp);
134
149
  document.removeEventListener('touchmove', handleTouchMove);
135
150
  document.removeEventListener('touchend', handleTouchEnd);
136
- document.body.style.cursor = '';
137
- document.body.style.userSelect = '';
151
+ document.removeEventListener('touchcancel', handleTouchEnd);
152
+ document.body.style.cursor = previousCursor;
153
+ document.body.style.userSelect = previousSelect;
138
154
  };
139
155
  }, [isDragging, direction, setWidth]);
140
156
 
141
157
  // リサイズハンドルのprops
142
- const resizeHandleProps = {
158
+ const resizeHandleProps: UseResizablePanelReturn['resizeHandleProps'] = {
159
+ role: 'separator', tabIndex: 0,
160
+ 'aria-label': 'パネルの幅(矢印キーで調整)',
161
+ 'aria-orientation': 'vertical',
162
+ 'aria-valuemin': minWidth, 'aria-valuemax': maxWidth, 'aria-valuenow': width,
163
+ onDoubleClick: () => setWidth(initialWidth),
164
+ onKeyDown: (e) => {
165
+ if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) return;
166
+ e.preventDefault(); e.stopPropagation();
167
+ const sign = direction === 'right' ? 1 : -1;
168
+ if (e.key === 'Home') setWidth(minWidth);
169
+ else if (e.key === 'End') setWidth(maxWidth);
170
+ else setWidth(width + (e.key === 'ArrowRight' ? sign : -sign) * (e.shiftKey ? 32 : 8));
171
+ },
143
172
  onMouseDown: handleMouseDown,
144
173
  onTouchStart: handleTouchStart,
145
174
  style: {
146
- cursor: 'col-resize',
175
+ cursor: 'col-resize', touchAction: 'none',
147
176
  } as React.CSSProperties,
148
177
  className: `
149
178
  absolute top-0 ${direction === 'right' ? 'right-0' : 'left-0'}
150
- w-1 h-full z-10
179
+ ed-resize-handle w-1 h-full z-10
151
180
  hover:bg-blue-500/50
152
181
  ${isDragging ? 'bg-blue-500' : 'bg-transparent'}
153
182
  transition-colors duration-150
@@ -0,0 +1,321 @@
1
+ /**
2
+ * 部品(パーツ)= HTML の `<template data-part-def>` と、ページに実体化されたインスタンス。
3
+ *
4
+ * ここは DOM だけを扱う純粋なユーティリティ(React に依存しない)。
5
+ *
6
+ * 【用語】
7
+ * - 定義 … `<template data-part-def="X" data-part-v="N">` の中のルート要素 1 つ
8
+ * - 実体化 … 定義を複製してページに置くこと。ルートに `data-part="X" data-part-v="N"` が付く。
9
+ * ページには**展開後の完全な HTML** が残る(読むときに何も解決しない)
10
+ * - スロット … `data-slot="名前"`。インスタンスの中で編集してよい範囲。
11
+ * 定義を更新したあとの一括反映(parts:sync)でも中身が保たれる
12
+ *
13
+ * 【ロック】インスタンスの中でスロットの外にある要素は編集対象にしない
14
+ * (useIframeSetup が `data-editable` を付けない)。ルート自身は選択できる
15
+ * (移動・削除・並び替え)。スロットの外を直したいときは「部品から切り離す」。
16
+ */
17
+ import type { EditorPartDef } from '../io';
18
+
19
+ export const PART_ATTR = 'data-part';
20
+ export const PART_VERSION_ATTR = 'data-part-v';
21
+ export const SLOT_ATTR = 'data-slot';
22
+ export const PART_DEF_ATTR = 'data-part-def';
23
+ const PART_NAME_ATTR = 'data-part-name';
24
+ const PART_CATEGORY_ATTR = 'data-part-category';
25
+ const PART_DESC_ATTR = 'data-part-desc';
26
+
27
+ /** 部品の id に使える形へ丸める(ファイル名・属性値になる) */
28
+ export function toPartId(raw: string): string {
29
+ const s = raw.trim().replace(/[^A-Za-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '');
30
+ return s || `part-${Date.now().toString(36)}`;
31
+ }
32
+
33
+ function parseIntSafe(v: string | null | undefined, fallback: number): number {
34
+ const n = Number.parseInt(v ?? '', 10);
35
+ return Number.isFinite(n) && n >= 0 ? n : fallback;
36
+ }
37
+
38
+ /**
39
+ * `<template data-part-def>` の文字列(ファイル 1 つぶん)を定義に読む。
40
+ * template が無い・中身が空なら null。
41
+ */
42
+ export function parsePartTemplate(source: string, doc: Document = document): EditorPartDef | null {
43
+ const parser = new (doc.defaultView?.DOMParser ?? DOMParser)();
44
+ const parsed = parser.parseFromString(source, 'text/html');
45
+ const tpl = parsed.querySelector<HTMLTemplateElement>(`template[${PART_DEF_ATTR}]`);
46
+ if (!tpl) return null;
47
+ const root = tpl.content.firstElementChild;
48
+ if (!root) return null;
49
+ const id = tpl.getAttribute(PART_DEF_ATTR)?.trim();
50
+ if (!id) return null;
51
+ return {
52
+ id,
53
+ name: tpl.getAttribute(PART_NAME_ATTR) ?? undefined,
54
+ category: tpl.getAttribute(PART_CATEGORY_ATTR) ?? undefined,
55
+ description: tpl.getAttribute(PART_DESC_ATTR) ?? undefined,
56
+ version: parseIntSafe(tpl.getAttribute(PART_VERSION_ATTR), 1),
57
+ html: root.outerHTML,
58
+ };
59
+ }
60
+
61
+ function escapeAttr(v: string): string {
62
+ return v.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
63
+ }
64
+
65
+ /** 定義をファイルに書く形(`<template data-part-def>…</template>`)へ */
66
+ export function serializePartTemplate(def: EditorPartDef): string {
67
+ const attrs = [
68
+ `${PART_DEF_ATTR}="${escapeAttr(def.id)}"`,
69
+ `${PART_VERSION_ATTR}="${def.version}"`,
70
+ def.name ? `${PART_NAME_ATTR}="${escapeAttr(def.name)}"` : '',
71
+ def.category ? `${PART_CATEGORY_ATTR}="${escapeAttr(def.category)}"` : '',
72
+ def.description ? `${PART_DESC_ATTR}="${escapeAttr(def.description)}"` : '',
73
+ ].filter(Boolean);
74
+ const body = def.html
75
+ .split('\n')
76
+ .map((l) => (l.trim() ? ` ${l}` : l))
77
+ .join('\n');
78
+ return `<template ${attrs.join(' ')}>\n${body}\n</template>\n`;
79
+ }
80
+
81
+ /** 定義の HTML(ルート要素 1 つ)を `doc` の要素にする */
82
+ export function partRootElement(def: EditorPartDef, doc: Document): HTMLElement | null {
83
+ const tpl = doc.createElement('template');
84
+ tpl.innerHTML = def.html.trim();
85
+ const root = tpl.content.firstElementChild;
86
+ return root ? (doc.importNode(root, true) as HTMLElement) : null;
87
+ }
88
+
89
+ /**
90
+ * 実体化: 定義を複製し、ルートに `data-part` `data-part-v` を付けた要素を返す。
91
+ * ページにはこの要素がそのまま残る。
92
+ */
93
+ export function materializePart(def: EditorPartDef, doc: Document): HTMLElement | null {
94
+ const el = partRootElement(def, doc);
95
+ if (!el) return null;
96
+ el.setAttribute(PART_ATTR, def.id);
97
+ el.setAttribute(PART_VERSION_ATTR, String(def.version));
98
+ return el;
99
+ }
100
+
101
+ /** インスタンスのルートか */
102
+ export function isPartRoot(el: Element | null | undefined): el is HTMLElement {
103
+ return !!el && el.nodeType === 1 && el.hasAttribute(PART_ATTR);
104
+ }
105
+
106
+ /** 要素を含む最も近いインスタンスのルート(自分自身でもよい) */
107
+ export function closestPartRoot(el: Element | null | undefined): HTMLElement | null {
108
+ if (!el || el.nodeType !== 1) return null;
109
+ const root = el.closest(`[${PART_ATTR}]`);
110
+ return root instanceof Element ? (root as HTMLElement) : null;
111
+ }
112
+
113
+ /** ルートの部品情報(無ければ null) */
114
+ export function partInfoOf(el: Element | null | undefined): { id: string; version: number } | null {
115
+ if (!isPartRoot(el)) return null;
116
+ return { id: el.getAttribute(PART_ATTR) ?? '', version: parseIntSafe(el.getAttribute(PART_VERSION_ATTR), 1) };
117
+ }
118
+
119
+ /**
120
+ * インスタンスの中でスロットの外にいる(= ロックされている)か。
121
+ * ルート自身はロックしない(選択して動かせる)。スロット要素とその子孫もロックしない。
122
+ */
123
+ export function isLockedInsidePart(el: Element): boolean {
124
+ if (el.hasAttribute(PART_ATTR)) return false;
125
+ const root = el.parentElement?.closest(`[${PART_ATTR}]`);
126
+ if (!root) return false;
127
+ const slot = el.closest(`[${SLOT_ATTR}]`);
128
+ return !(slot && root.contains(slot) && slot !== root);
129
+ }
130
+
131
+ /** エディタが作業用に付ける属性・class を落とす(部品の定義や保存 HTML に持ち込まない) */
132
+ export function stripEditorAttrs(root: Element): void {
133
+ const attrs = ['contenteditable', 'data-editable', 'data-element-id', 'data-original-content', 'data-shape-type', 'data-inline', 'data-display-name', 'data-locked', 'data-hidden'];
134
+ const classes = ['selected', 'dragging', 'editing', 'rotating', 'panning', 'hover-preview', 'marquee-hover', 'marquee-active', 'text-editable-hover', 'drag-ghost'];
135
+ const all: Element[] = [root, ...Array.from(root.querySelectorAll('*'))];
136
+ for (const el of all) {
137
+ for (const a of attrs) el.removeAttribute(a);
138
+ for (const attr of Array.from(el.attributes)) {
139
+ if (attr.name.startsWith('data-original-') || attr.name.startsWith('data-gg-')) el.removeAttribute(attr.name);
140
+ }
141
+ if (el.classList.length) {
142
+ el.classList.remove(...classes);
143
+ if (!el.getAttribute('class')) el.removeAttribute('class');
144
+ }
145
+ }
146
+ }
147
+
148
+ /** スロットを推定して付ける対象(文字や画像を持ちうる要素) */
149
+ const SLOT_TAGS: Record<string, string> = {
150
+ h1: 'heading', h2: 'heading', h3: 'heading', h4: 'heading', h5: 'heading', h6: 'heading',
151
+ p: 'body', li: 'item', a: 'link', button: 'button', img: 'image',
152
+ figcaption: 'caption', blockquote: 'quote', dt: 'term', dd: 'desc',
153
+ td: 'cell', th: 'cell', label: 'label', span: 'text', time: 'time', small: 'note',
154
+ };
155
+
156
+ /**
157
+ * スロットの推定。既に `data-slot` があるものはそのまま。
158
+ * 文書順に見て、祖先が既にスロットなら付けない(入れ子のスロットを作らない)。
159
+ * span は文字だけを直接持つときだけ。svg の中は見ない。
160
+ * 戻り値は付けた数。
161
+ */
162
+ export function inferSlots(root: Element): number {
163
+ let count = 0;
164
+ const used = new Map<string, number>();
165
+ const nameFor = (base: string) => {
166
+ const n = (used.get(base) ?? 0) + 1;
167
+ used.set(base, n);
168
+ return n === 1 ? base : `${base}-${n}`;
169
+ };
170
+ for (const el of Array.from(root.querySelectorAll('[' + SLOT_ATTR + ']'))) {
171
+ const v = el.getAttribute(SLOT_ATTR) ?? '';
172
+ const base = v.replace(/-\d+$/, '');
173
+ if (base) used.set(base, Math.max(used.get(base) ?? 0, 1));
174
+ }
175
+ const candidates = Array.from(root.querySelectorAll('*'));
176
+ for (const el of candidates) {
177
+ if (el.closest('svg')) continue;
178
+ if (el.hasAttribute(SLOT_ATTR)) continue;
179
+ const ancestorSlot = el.parentElement?.closest(`[${SLOT_ATTR}]`);
180
+ if (ancestorSlot && root.contains(ancestorSlot)) continue;
181
+ const tag = el.tagName.toLowerCase();
182
+ const base = SLOT_TAGS[tag];
183
+ if (!base) continue;
184
+ if (tag === 'span') {
185
+ const hasText = Array.from(el.childNodes).some((n) => n.nodeType === 3 && n.textContent?.trim());
186
+ if (!hasText || el.children.length > 0) continue;
187
+ }
188
+ if (tag !== 'img' && !el.textContent?.trim() && el.children.length === 0) continue;
189
+ el.setAttribute(SLOT_ATTR, nameFor(base));
190
+ count++;
191
+ }
192
+ return count;
193
+ }
194
+
195
+ /**
196
+ * 選択要素から部品の定義を作る(「部品として保存」)。
197
+ * 元の要素は変えない(複製に対して整える)。スロットは推定して付ける。
198
+ */
199
+ export function partDefFromElement(
200
+ el: HTMLElement,
201
+ meta: { id: string; name?: string; category?: string; description?: string; version?: number },
202
+ options: {
203
+ /**
204
+ * スロットを推定して付けるか。新規作成のときだけ true。
205
+ * 更新(版 +1)で推定すると、定義が意図して固定にしていた要素(ボタンの文言など)が
206
+ * 黙ってスロットになる。更新では既に付いている data-slot だけを尊重する
207
+ */
208
+ inferSlots?: boolean;
209
+ } = {},
210
+ ): EditorPartDef {
211
+ const clone = el.cloneNode(true) as HTMLElement;
212
+ stripEditorAttrs(clone);
213
+ clone.removeAttribute(PART_ATTR);
214
+ clone.removeAttribute(PART_VERSION_ATTR);
215
+ // 絶対配置の座標は「その場所」の情報なので定義には持ち込まない
216
+ for (const k of ['position', 'left', 'top', 'right', 'bottom']) clone.style.removeProperty(k);
217
+ if (!clone.getAttribute('style')) clone.removeAttribute('style');
218
+ if (options.inferSlots) inferSlots(clone);
219
+ return {
220
+ id: meta.id,
221
+ name: meta.name,
222
+ category: meta.category,
223
+ description: meta.description,
224
+ version: meta.version ?? 1,
225
+ html: clone.outerHTML,
226
+ };
227
+ }
228
+
229
+ /**
230
+ * ページ上の要素を、その場でインスタンスにする(「部品として保存」の直後)。
231
+ * 定義に付けたスロットと同じ位置に `data-slot` を付け、ルートに `data-part` を付ける。
232
+ * DOM の形は定義と同じ(複製元だから)なので、文書順で対応づける。
233
+ */
234
+ export function stampAsInstance(el: HTMLElement, def: EditorPartDef, doc: Document): void {
235
+ const ref = partRootElement(def, doc);
236
+ if (!ref) return;
237
+ const refAll = [ref, ...Array.from(ref.querySelectorAll('*'))];
238
+ const liveAll = [el, ...Array.from(el.querySelectorAll('*'))];
239
+ // エディタの作業属性は複製時に落としているので、要素数が一致するはず。ずれたらスロットは付けない
240
+ if (refAll.length === liveAll.length) {
241
+ for (let i = 0; i < refAll.length; i++) {
242
+ const s = refAll[i].getAttribute(SLOT_ATTR);
243
+ if (s) liveAll[i].setAttribute(SLOT_ATTR, s);
244
+ }
245
+ }
246
+ el.setAttribute(PART_ATTR, def.id);
247
+ el.setAttribute(PART_VERSION_ATTR, String(def.version));
248
+ }
249
+
250
+ /** 「部品から切り離す」: 出自の記録だけ外す。中身と data-slot はそのまま(普通の HTML になる) */
251
+ export function detachPart(el: HTMLElement): void {
252
+ el.removeAttribute(PART_ATTR);
253
+ el.removeAttribute(PART_VERSION_ATTR);
254
+ }
255
+
256
+ /**
257
+ * フロー(流し込み)への挿入位置を決める。
258
+ * ドロップ位置の要素から、「セクションの器」の直下にある祖先まで上がり、その後ろに入れる。
259
+ * 器 = `[data-wf-body]` / `main` / `#artboard` / body。何も当たらなければ器の末尾。
260
+ */
261
+ export function insertIntoFlow(doc: Document, el: HTMLElement, x: number, y: number): void {
262
+ const isContainer = (node: Element | null): boolean =>
263
+ !!node &&
264
+ (node === doc.body ||
265
+ node.id === 'artboard' ||
266
+ node.hasAttribute('data-wf-body') ||
267
+ node.tagName === 'MAIN');
268
+ const fallback = (): Element =>
269
+ doc.querySelector('[data-wf-body]') ?? doc.querySelector('main') ?? doc.getElementById('artboard') ?? doc.body;
270
+
271
+ let hit: Element | null = doc.elementFromPoint(x, y);
272
+ if (!hit || hit === doc.body || hit.id === 'artboard' || hit.closest('.selection-box')) {
273
+ fallback().appendChild(el);
274
+ return;
275
+ }
276
+ // 器の直下にあたる祖先まで上がる
277
+ let anchor: Element = hit;
278
+ while (anchor.parentElement && !isContainer(anchor.parentElement)) {
279
+ anchor = anchor.parentElement;
280
+ }
281
+ if (!anchor.parentElement) {
282
+ fallback().appendChild(el);
283
+ return;
284
+ }
285
+ anchor.after(el);
286
+ }
287
+
288
+ /**
289
+ * インスタンスの中のロックを今すぐ効かせる(「部品として保存」の直後)。
290
+ * 通常は useIframeSetup が付け直すが、それは子要素の増減でしか走らないので、ここで落とす。
291
+ */
292
+ export function applyLockInside(root: HTMLElement): void {
293
+ for (const el of Array.from(root.querySelectorAll('*'))) {
294
+ if (!isLockedInsidePart(el)) continue;
295
+ el.removeAttribute('data-editable');
296
+ el.removeAttribute('data-element-id');
297
+ el.removeAttribute('contenteditable');
298
+ el.classList.remove('selected', 'hover-preview', 'text-editable-hover');
299
+ if (!el.getAttribute('class')) el.removeAttribute('class');
300
+ }
301
+ }
302
+
303
+ /**
304
+ * 「部品から切り離す」の直後に、ロックされていた子孫を編集できるようにする。
305
+ * useIframeSetup と同じ形の id を付ける。
306
+ */
307
+ export function unlockPartDescendants(root: HTMLElement): number {
308
+ let i = 0;
309
+ const stamp = Date.now();
310
+ for (const el of Array.from(root.querySelectorAll('*'))) {
311
+ if (el.closest('script, style, svg path, svg g')) continue;
312
+ if (el.tagName === 'SCRIPT' || el.tagName === 'STYLE') continue;
313
+ if (!el.getAttribute('data-element-id')) {
314
+ el.setAttribute('data-editable', 'true');
315
+ el.setAttribute('data-element-id', `el-${stamp}-u${i++}`);
316
+ } else if (!el.getAttribute('data-editable')) {
317
+ el.setAttribute('data-editable', 'true');
318
+ }
319
+ }
320
+ return i;
321
+ }
@@ -249,9 +249,15 @@ export function applyCanvasZoomDom(iframeDoc: Document, zoomPct: number): void {
249
249
  scrollArea.style.minWidth = `${cw}px`;
250
250
  scrollArea.style.minHeight = `${ch}px`;
251
251
 
252
+ // webpage は上端固定(#canvas-scroll-area が flex-start、変形の基準も top)。
253
+ // 上の余白は中央寄せでは自然に生まれたが、上端固定では自分で確保する。
254
+ // 縦方向の中央寄せもしない(ページの高さが変わるたびに紙面が動く原因だった)
255
+ const topAnchored = iframeDoc.body.dataset.editorMode === 'webpage';
256
+ scrollArea.style.paddingTop = topAnchored ? `32px` : '';
257
+
252
258
  // 紙面が容器に収まる軸はスクロールを中央へ(拡大時のパンには干渉しない)
253
259
  if (w * scale <= cw) container.scrollLeft = Math.max(0, (scrollArea.offsetWidth - cw) / 2);
254
- if (h * scale <= ch) container.scrollTop = Math.max(0, (scrollArea.offsetHeight - ch) / 2);
260
+ if (!topAnchored && h * scale <= ch) container.scrollTop = Math.max(0, (scrollArea.offsetHeight - ch) / 2);
255
261
  }
256
262
 
257
263
  /* ============================ 原本への書き戻し支援 ============================
@@ -913,9 +919,10 @@ function buildDomTreeInternal(iframeDoc: Document, rootElement?: Element): DOMTr
913
919
  const className = classNameStr.split(' ')
914
920
  .filter(c => c && !c.startsWith('selected') && !c.startsWith('dragging') && !c.startsWith('editing'))[0] || '';
915
921
 
916
- // コンポーネントインスタンス情報を取得
917
- const componentInstanceId = htmlChild.getAttribute('data-component-instance') || undefined;
918
- const masterComponentId = htmlChild.getAttribute('data-component-master') || undefined;
922
+ // コンポーネントインスタンス情報を取得(HTML 部品の data-part も同じ印として扱う)
923
+ const partId = htmlChild.getAttribute('data-part') || undefined;
924
+ const componentInstanceId = htmlChild.getAttribute('data-component-instance') || partId;
925
+ const masterComponentId = htmlChild.getAttribute('data-component-master') || partId;
919
926
 
920
927
  result.push({
921
928
  id: elementId,
@@ -1784,11 +1791,12 @@ export function convertDragTargetsToAbsolute(
1784
1791
  .map(({ element, origin }) => {
1785
1792
  const cs = win.getComputedStyle(element);
1786
1793
  if (cs.position === 'absolute' || cs.position === 'fixed') return null;
1794
+ const box = readOffsetBox(element, iframeDoc);
1787
1795
  return {
1788
1796
  element,
1789
1797
  origin,
1790
- width: element.offsetWidth,
1791
- height: element.offsetHeight,
1798
+ width: box.width,
1799
+ height: box.height,
1792
1800
  parent: element.parentElement,
1793
1801
  };
1794
1802
  })
@@ -1838,6 +1846,19 @@ export function fitElementToContent(
1838
1846
  const fitH = handle.includes('n') || handle.includes('s');
1839
1847
  if (!fitW && !fitH) return { changed: false };
1840
1848
 
1849
+ // 流し込みの子(カードやリスト項目)を並べている器は対象外。
1850
+ // 「最長行に幅を合わせる」はテキスト箱のための採寸で、器に掛けると
1851
+ // 中の文の長さまで幅が縮み、カードが折れてページ高さまで変わる
1852
+ // (Webページの商品一覧で、ハンドルを2回押しただけで 490→337px に潰れた)。
1853
+ // 絶対配置の子だけを持つ器は下の childExtent で正しく扱えるので通す
1854
+ const INLINE_TEXT_TAGS = new Set(['SPAN', 'A', 'B', 'I', 'EM', 'STRONG', 'BR', 'SMALL', 'SUP', 'SUB', 'MARK', 'CODE']);
1855
+ const hasFlowChildren = [...element.children].some((c) => {
1856
+ if (INLINE_TEXT_TAGS.has(c.tagName)) return false;
1857
+ const pos = win.getComputedStyle(c).position;
1858
+ return pos !== 'absolute' && pos !== 'fixed';
1859
+ });
1860
+ if (hasFlowChildren) return { changed: false };
1861
+
1841
1862
  const before = { w: element.offsetWidth, h: element.offsetHeight,
1842
1863
  left: element.offsetLeft, top: element.offsetTop };
1843
1864
 
@@ -1915,6 +1936,105 @@ export function fitElementToContent(
1915
1936
  return { changed: true };
1916
1937
  }
1917
1938
 
1939
+ /**
1940
+ * テキスト編集中、ブラウザのキャレット追従で紙面が飛ぶのを防ぐ。
1941
+ *
1942
+ * 編集に入る・文字を打つ・End/Home でキャレットが動くたびに、ブラウザは
1943
+ * キャレットを「見える位置」へスクロールさせる。紙面は #artboard-wrapper の
1944
+ * transform で縮小されているため、その計算が狂い、見えているのに大きく
1945
+ * スクロールしてしまう(実測: スライドで 53px、Webページで 1900px 飛んだ)。
1946
+ * ダブルクリックした場所は既に見えているので、キャレット起因のスクロールは要らない。
1947
+ *
1948
+ * 追従は打鍵の後、次の描画更新の中で走る(rAF より後)ので、scroll イベントで
1949
+ * 捕まえて描画される前に戻す。overflow:hidden の html/body/#artboard も
1950
+ * プログラム的にはスクロールされ得るので、要素からルートまでの祖先を全部見る。
1951
+ * ユーザー自身のスクロール(ホイール・パン・スクロールバー)は直前の入力で見分け、
1952
+ * その直後の scroll は「意図した移動」として覚え直す。
1953
+ * 編集を抜けたら(contenteditable が外れたら)自分で外れる。
1954
+ */
1955
+ export function lockCaretScroll(element: HTMLElement, iframeDoc: Document): void {
1956
+ const scrollers: HTMLElement[] = [];
1957
+ for (let a = element.parentElement; a; a = a.parentElement) scrollers.push(a);
1958
+ const remembered = scrollers.map((s) => ({ left: s.scrollLeft, top: s.scrollTop }));
1959
+ const remember = () => {
1960
+ scrollers.forEach((s, i) => {
1961
+ remembered[i].left = s.scrollLeft;
1962
+ remembered[i].top = s.scrollTop;
1963
+ });
1964
+ };
1965
+ const restore = () => {
1966
+ scrollers.forEach((s, i) => {
1967
+ if (s.scrollLeft !== remembered[i].left) s.scrollLeft = remembered[i].left;
1968
+ if (s.scrollTop !== remembered[i].top) s.scrollTop = remembered[i].top;
1969
+ });
1970
+ };
1971
+
1972
+ let userScrollUntil = 0;
1973
+ const markUserScroll = () => {
1974
+ userScrollUntil = performance.now() + 400;
1975
+ };
1976
+ const onScroll = () => {
1977
+ if (element.getAttribute('contenteditable') !== 'true') return detach();
1978
+ if (performance.now() < userScrollUntil) remember();
1979
+ else restore();
1980
+ };
1981
+ const detach = () => {
1982
+ iframeDoc.removeEventListener('wheel', markUserScroll, true);
1983
+ iframeDoc.removeEventListener('mousedown', markUserScroll, true);
1984
+ iframeDoc.removeEventListener('touchstart', markUserScroll, true);
1985
+ iframeDoc.removeEventListener('scroll', onScroll, true);
1986
+ };
1987
+ iframeDoc.addEventListener('wheel', markUserScroll, true);
1988
+ iframeDoc.addEventListener('mousedown', markUserScroll, true);
1989
+ iframeDoc.addEventListener('touchstart', markUserScroll, true);
1990
+ iframeDoc.addEventListener('scroll', onScroll, true);
1991
+ // フォーカスや全選択で既に動いていたら、その場で戻す
1992
+ restore();
1993
+ }
1994
+
1995
+ /**
1996
+ * offsetLeft/offsetTop/offsetWidth/offsetHeight 相当を、SVG でも取れる形で読む。
1997
+ *
1998
+ * [なぜ必要か] `<svg>` と その中身は SVGElement で、offsetLeft 等を**持たない**
1999
+ * (undefined)。そのまま origin にすると `left: NaNpx` が書かれて無視され、
2000
+ * 掴んでも1pxも動かない(スライドのロゴ・図形で実測)。
2001
+ * HTMLElement は従来どおり offset 系(スケール前の CSS px、transform の影響なし)を
2002
+ * 使い、持たない要素だけ矩形から割り戻す。基準は offsetParent と同じ
2003
+ * 「最も近い position が static でない祖先」(無ければ body)。
2004
+ */
2005
+ export function readOffsetBox(
2006
+ element: HTMLElement,
2007
+ iframeDoc: Document,
2008
+ ): { left: number; top: number; width: number; height: number } {
2009
+ if (typeof element.offsetLeft === 'number') {
2010
+ return {
2011
+ left: element.offsetLeft,
2012
+ top: element.offsetTop,
2013
+ width: element.offsetWidth,
2014
+ height: element.offsetHeight,
2015
+ };
2016
+ }
2017
+ const win = iframeDoc.defaultView;
2018
+ const artboard = iframeDoc.getElementById('artboard');
2019
+ const scale = artboard ? artboard.getBoundingClientRect().width / (artboard.offsetWidth || 1) || 1 : 1;
2020
+ let parent: HTMLElement | null = element.parentElement;
2021
+ while (parent && parent !== iframeDoc.body && win?.getComputedStyle(parent).position === 'static') {
2022
+ parent = parent.parentElement;
2023
+ }
2024
+ const base = parent ?? iframeDoc.body;
2025
+ const baseRect = base.getBoundingClientRect();
2026
+ const baseStyle = win?.getComputedStyle(base);
2027
+ const borderLeft = parseFloat(baseStyle?.borderLeftWidth || '0') || 0;
2028
+ const borderTop = parseFloat(baseStyle?.borderTopWidth || '0') || 0;
2029
+ const rect = element.getBoundingClientRect();
2030
+ return {
2031
+ left: (rect.left - baseRect.left) / scale - borderLeft,
2032
+ top: (rect.top - baseRect.top) / scale - borderTop,
2033
+ width: rect.width / scale,
2034
+ height: rect.height / scale,
2035
+ };
2036
+ }
2037
+
1918
2038
  export function prepareElementDragOrigin(
1919
2039
  element: HTMLElement,
1920
2040
  iframeDoc: Document,
@@ -1929,7 +2049,8 @@ export function prepareElementDragOrigin(
1929
2049
  if (!options.convertToAbsolute) {
1930
2050
  // 変換が許されていない(オートレイアウト等)。
1931
2051
  // 値だけ返して要素は一切触らない。
1932
- return { left: element.offsetLeft, top: element.offsetTop };
2052
+ const box = readOffsetBox(element, iframeDoc);
2053
+ return { left: box.left, top: box.top };
1933
2054
  }
1934
2055
 
1935
2056
  // 親を offsetParent に確定させてから採寸する。
@@ -1944,10 +2065,7 @@ export function prepareElementDragOrigin(
1944
2065
  }
1945
2066
 
1946
2067
  // 採寸はスタイルを書き換える前に行う(margin:0 を入れると値が変わるため)
1947
- const left = element.offsetLeft;
1948
- const top = element.offsetTop;
1949
- const width = element.offsetWidth;
1950
- const height = element.offsetHeight;
2068
+ const { left, top, width, height } = readOffsetBox(element, iframeDoc);
1951
2069
 
1952
2070
  capturePrestyle(element);
1953
2071
  element.style.width = `${width}px`;
@@ -1960,8 +2078,10 @@ export function prepareElementDragOrigin(
1960
2078
  }
1961
2079
 
1962
2080
  // すでに絶対配置。offsetLeft/offsetTop がそのまま left/top の基準になる
1963
- const left = element.offsetLeft;
1964
- const top = element.offsetTop;
2081
+ // (SVG は offset 系を持たないので矩形から割り戻す)
2082
+ const box = readOffsetBox(element, iframeDoc);
2083
+ const left = box.left;
2084
+ const top = box.top;
1965
2085
 
1966
2086
  // right / bottom で位置決めされている要素(例: `absolute bottom-[24px] left-[96px]`)に
1967
2087
  // そのまま top を書くと、top と bottom が同時に効いて「移動」ではなく「伸長」になる。
@@ -1978,8 +2098,8 @@ export function prepareElementDragOrigin(
1978
2098
  (element.style.bottom !== '' && element.style.bottom !== 'auto') ||
1979
2099
  /(^|\s)-?bottom-/.test(element.className);
1980
2100
  if (hasRight || hasBottom) {
1981
- element.style.width = `${element.offsetWidth}px`;
1982
- element.style.height = `${element.offsetHeight}px`;
2101
+ element.style.width = `${box.width}px`;
2102
+ element.style.height = `${box.height}px`;
1983
2103
  if (hasRight) {
1984
2104
  element.style.right = 'auto';
1985
2105
  // インラインの auto だけでは `right-[24px]` クラスが保存HTMLに残り、