@growgroup/visual-editor 0.1.6 → 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 (42) 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 +157 -43
  13. package/src/editor/components/EditorCanvas.tsx +1 -1
  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/useComponentEditMode.ts +95 -1
  34. package/src/editor/hooks/useIframeSetup.ts +10 -0
  35. package/src/editor/hooks/useMediaLibrary.ts +4 -5
  36. package/src/editor/hooks/useResizablePanel.ts +40 -11
  37. package/src/editor/parts.ts +321 -0
  38. package/src/editor/utils/dom-utils.ts +5 -4
  39. package/src/index.ts +25 -0
  40. package/src/io.ts +54 -0
  41. package/src/styles/skin.css +159 -0
  42. package/src/types/editor-components.ts +23 -1
@@ -10,6 +10,7 @@ import { useCallback, useRef, useEffect } from 'react';
10
10
  import { useEditorContext } from '../EditorContext';
11
11
  import { buildDomTree, isInlineElement } from '../utils/dom-utils';
12
12
  import { EDITOR_IFRAME_STYLES } from '../constants';
13
+ import { isLockedInsidePart } from '../parts';
13
14
 
14
15
  interface UseIframeSetupReturn {
15
16
  /**
@@ -111,6 +112,15 @@ export function useIframeSetup(): UseIframeSetupReturn {
111
112
  // selection-boxの子孫要素をすべてスキップ(念のため)
112
113
  if (element.closest('.selection-box')) return;
113
114
 
115
+ // 部品のインスタンス(data-part)の中で、スロット(data-slot)の外にある要素は編集対象にしない。
116
+ // ルート自身とスロットの中は通常どおり(src/editor/parts.ts)。
117
+ // 保存 HTML には data-editable が残らないので、古い保存物には効かない = 既存の挙動は変わらない
118
+ if (isLockedInsidePart(element)) {
119
+ element.removeAttribute('data-editable');
120
+ element.removeAttribute('data-element-id');
121
+ return;
122
+ }
123
+
114
124
  // 既にIDがある場合はスキップ
115
125
  if (!element.getAttribute('data-element-id')) {
116
126
  const elementId = `el-${Date.now()}-${elementIndex++}`;
@@ -9,6 +9,7 @@
9
9
  * 同時オープン時のリクエスト重複も in-flight promise で防ぐ。
10
10
  */
11
11
 
12
+ import { io, notProvided } from '../../io';
12
13
  import { useCallback, useEffect, useState } from 'react';
13
14
 
14
15
  export interface MediaItem {
@@ -48,11 +49,9 @@ function normalizeItem(raw: unknown): MediaItem | null {
48
49
  }
49
50
 
50
51
  async function fetchMediaItems(): Promise<MediaItem[]> {
51
- const res = await fetch('/__media');
52
- if (!res.ok) {
53
- throw new Error(`メディア一覧の取得に失敗しました (HTTP ${res.status})`);
54
- }
55
- const data = (await res.json()) as MediaResponse;
52
+ const fetchCatalog = io().apiFetch;
53
+ if (!fetchCatalog) throw notProvided('apiFetch');
54
+ const data = await fetchCatalog('/__media') as MediaResponse;
56
55
  const items = Array.isArray(data.items) ? data.items : [];
57
56
  return items.map(normalizeItem).filter((i): i is MediaItem => i !== null);
58
57
  }
@@ -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
+ }
@@ -253,7 +253,7 @@ export function applyCanvasZoomDom(iframeDoc: Document, zoomPct: number): void {
253
253
  // 上の余白は中央寄せでは自然に生まれたが、上端固定では自分で確保する。
254
254
  // 縦方向の中央寄せもしない(ページの高さが変わるたびに紙面が動く原因だった)
255
255
  const topAnchored = iframeDoc.body.dataset.editorMode === 'webpage';
256
- scrollArea.style.paddingTop = topAnchored ? `${padding}px` : '';
256
+ scrollArea.style.paddingTop = topAnchored ? `32px` : '';
257
257
 
258
258
  // 紙面が容器に収まる軸はスクロールを中央へ(拡大時のパンには干渉しない)
259
259
  if (w * scale <= cw) container.scrollLeft = Math.max(0, (scrollArea.offsetWidth - cw) / 2);
@@ -919,9 +919,10 @@ function buildDomTreeInternal(iframeDoc: Document, rootElement?: Element): DOMTr
919
919
  const className = classNameStr.split(' ')
920
920
  .filter(c => c && !c.startsWith('selected') && !c.startsWith('dragging') && !c.startsWith('editing'))[0] || '';
921
921
 
922
- // コンポーネントインスタンス情報を取得
923
- const componentInstanceId = htmlChild.getAttribute('data-component-instance') || undefined;
924
- 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;
925
926
 
926
927
  result.push({
927
928
  id: elementId,
package/src/index.ts CHANGED
@@ -52,4 +52,29 @@ export type {
52
52
  CommentAction,
53
53
  ExportFormat,
54
54
  UploadResult,
55
+ EditorPartDef,
56
+ EditorPartCategory,
57
+ EditorPartsLibrary,
55
58
  } from "./io";
59
+ export type { CSSVariableDefinition, CSSVariableCategory } from "./types/css-variables";
60
+
61
+ /**
62
+ * 部品(HTML の `<template data-part-def>`)のユーティリティ。
63
+ * 利用側が parts/*.html を読み書きする・スクリプトで同期する(parts:sync)ときに使う。
64
+ * DOM だけに依存する(Node では linkedom 等の Document を渡す)。
65
+ */
66
+ export {
67
+ PART_ATTR,
68
+ PART_VERSION_ATTR,
69
+ SLOT_ATTR,
70
+ PART_DEF_ATTR,
71
+ parsePartTemplate,
72
+ serializePartTemplate,
73
+ materializePart,
74
+ inferSlots,
75
+ partDefFromElement,
76
+ partInfoOf,
77
+ isPartRoot,
78
+ isLockedInsidePart,
79
+ toPartId,
80
+ } from "./editor/parts";
package/src/io.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ReactNode } from "react";
2
+ import type { CSSVariableDefinition } from "./types/css-variables";
2
3
 
3
4
  /**
4
5
  * エディタと「置き場」のあいだの唯一の境界。
@@ -63,6 +64,43 @@ export type ExportFormat = "pdf" | "pdf-lite" | "pptx" | "pptx-edit";
63
64
 
64
65
  export type UploadResult = { url: string; width?: number; height?: number };
65
66
 
67
+ /**
68
+ * 部品(パーツ)の定義。利用側では 1 部品 1 ファイル(`parts/<id>.html`)の
69
+ *
70
+ * <template data-part-def="ContactBand" data-part-v="1"
71
+ * data-part-name="お問合せ帯" data-part-category="block" data-part-desc="…">
72
+ * <section …>…<h2 data-slot="heading">…</h2>…</section>
73
+ * </template>
74
+ *
75
+ * を想定している。`html` は template の中身(ルート要素 1 つ)。
76
+ * 文字列と `<template>` の相互変換は `parsePartTemplate` / `serializePartTemplate`(src/editor/parts.ts)。
77
+ *
78
+ * ページに挿すときは**実体化**する: 定義を複製してルートに `data-part` `data-part-v` を付けた
79
+ * 完全な HTML がページに残る。エディタは `data-slot` の中だけを編集対象にし、
80
+ * 定義を変えたあとの一括反映は利用側のスクリプト(parts:sync)が行う。
81
+ */
82
+ export type EditorPartDef = {
83
+ /** 識別子。`data-part` の値になる(ファイル名にも使うので英数字と - _ だけ) */
84
+ id: string;
85
+ /** 表示名。無ければ id */
86
+ name?: string;
87
+ /** パネルの分類。無ければ "parts" */
88
+ category?: string;
89
+ description?: string;
90
+ /** 定義の版。実体化したインスタンスに `data-part-v` として写る */
91
+ version: number;
92
+ /** ルート要素 1 つの HTML */
93
+ html: string;
94
+ };
95
+
96
+ export type EditorPartCategory = { id: string; name: string; description?: string };
97
+
98
+ export type EditorPartsLibrary = {
99
+ parts: EditorPartDef[];
100
+ /** 無ければ parts の category から作る */
101
+ categories?: EditorPartCategory[];
102
+ };
103
+
66
104
  export type EditorIO = {
67
105
  /** 一覧とコメントを取る。無ければ空の一覧として振る舞う */
68
106
  loadDeck?: () => Promise<EditorDeck>;
@@ -104,6 +142,22 @@ export type EditorIO = {
104
142
 
105
143
  /** 保存結果の通知。無ければ何もしない */
106
144
  notifySave?: (info: { page: number }) => void;
145
+
146
+ /**
147
+ * 部品(HTML の template)の一覧。渡すと部品パネルはこれを使い、
148
+ * ブラウザ内(localStorage)の JSON コンポーネントは読まない。
149
+ * 挿入は実体化(完全な HTML をページに残す)になる
150
+ */
151
+ loadParts?: () => Promise<EditorPartsLibrary>;
152
+ /** 部品を作る・更新する(「部品として保存」「部品を更新」)。無ければそれらの操作を出さない */
153
+ savePart?: (part: EditorPartDef) => Promise<EditorPartDef | void>;
154
+ /** 部品を消す。無ければ削除を出さない */
155
+ deletePart?: (id: string) => Promise<void>;
156
+
157
+ /** CSS 変数(デザイントークン)の読み込み。無ければブラウザ内(localStorage)に持つ */
158
+ loadVariables?: () => Promise<CSSVariableDefinition[]>;
159
+ /** CSS 変数の保存。無ければブラウザ内(localStorage)に持つ */
160
+ saveVariables?: (variables: CSSVariableDefinition[]) => Promise<void>;
107
161
  };
108
162
 
109
163
  let current: EditorIO = {};