@growgroup/visual-editor 0.2.2 → 0.3.1

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 (43) hide show
  1. package/README.md +41 -1
  2. package/dist/editor.css +1 -1
  3. package/package.json +3 -2
  4. package/src/editor/EditorContext.tsx +15 -3
  5. package/src/editor/FrontendVisualEditor.tsx +168 -55
  6. package/src/editor/autosave.ts +32 -0
  7. package/src/editor/components/EditorCanvas.tsx +107 -8
  8. package/src/editor/components/EditorContextMenu.tsx +12 -3
  9. package/src/editor/components/EditorHeader.tsx +47 -12
  10. package/src/editor/components/EditorLayerPanel.tsx +28 -2
  11. package/src/editor/components/EditorPropertyPanel.tsx +56 -25
  12. package/src/editor/components/multi-page/CanvasRulers.tsx +166 -0
  13. package/src/editor/components/multi-page/MultiPageCanvasView.tsx +345 -1345
  14. package/src/editor/components/multi-page/PageFramePreview.tsx +197 -0
  15. package/src/editor/components/multi-page/index.ts +2 -6
  16. package/src/editor/components/ppt/PptComments.tsx +8 -4
  17. package/src/editor/components/shell/CanvasAppearance.tsx +3 -1
  18. package/src/editor/components/shell/PagesPanel.tsx +8 -0
  19. package/src/editor/constants.ts +28 -0
  20. package/src/editor/contexts/EditorArtboardContext.tsx +60 -3
  21. package/src/editor/contexts/EditorComponentsContext.tsx +17 -4
  22. package/src/editor/contexts/MultiPageCanvasContext.tsx +859 -354
  23. package/src/editor/hooks/useCanvasControls.ts +1 -1
  24. package/src/editor/hooks/useCanvasMarquee.ts +196 -0
  25. package/src/editor/hooks/useContextMenuHandler.ts +5 -14
  26. package/src/editor/hooks/useCoordinateTransform.ts +11 -8
  27. package/src/editor/hooks/useDrawingMode.ts +1 -0
  28. package/src/editor/hooks/useGoogleFonts.ts +6 -2
  29. package/src/editor/hooks/useInfiniteCanvas.ts +228 -220
  30. package/src/editor/hooks/useMarqueeSelection.ts +2 -2
  31. package/src/editor/utils/dom-utils.ts +23 -1
  32. package/src/editor/utils/embedded-canvas-bridge.ts +155 -0
  33. package/src/editor/utils/html-utils.ts +102 -14
  34. package/src/index.ts +5 -0
  35. package/src/io.ts +20 -0
  36. package/src/styles/skin.css +122 -36
  37. package/src/version.ts +9 -0
  38. package/src/editor/components/multi-page/CanvasZoomControls.tsx +0 -71
  39. package/src/editor/components/multi-page/InfiniteCanvas.tsx +0 -95
  40. package/src/editor/components/multi-page/PageFrameOverlay.tsx +0 -76
  41. package/src/editor/components/multi-page/PageLabel.tsx +0 -34
  42. package/src/editor/components/multi-page/PageLivePreview.tsx +0 -242
  43. package/src/editor/components/multi-page/PageThumbnail.tsx +0 -59
@@ -0,0 +1,197 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * 見るだけの紙面(編集していないページ)。
5
+ *
6
+ * 生きているエディタは 1 つだけなので、他のページは srcdoc の iframe で描く。
7
+ * - 編集用の属性は付けない(触れない。クリックはフレーム側が受けて、そのページへ編集を移す)
8
+ * - 同一オリジンなので #artboard の高さを親から直接測る(ResizeObserver)。
9
+ * フォントや画像の読み込みで高さが変わっても追従する
10
+ * - 画面から遠いものは描かない(IntersectionObserver)。webpage は高さの実測が
11
+ * レイアウトに要るので、順番に少しずつ先読みする(eagerMountCount)
12
+ * - iframe に sandbox は付けない。script は本文から外してある(generatePreviewHtml)。
13
+ * sandbox で script を禁じると、ページへ script を差し込むブラウザ拡張(React DevTools 等)が
14
+ * 1 枚ごとに「Blocked script execution in 'about:srcdoc'」をコンソールへ出す(実測 26 枚 = 26 件)
15
+ * - 本文が外から変わった(revision / invalidatePages)ときは、次の本文が来るまで前の姿を出したまま読み直す
16
+ * - 文書の属性(documentAttributes)は読み直さずに <html> へ付け替える
17
+ */
18
+
19
+ import { memo, useEffect, useRef, useState } from 'react';
20
+ import { useMultiPageCanvas, type PageFrameLayout } from '../../contexts/MultiPageCanvasContext';
21
+ import type { DocumentAttributes } from '../../contexts/EditorArtboardContext';
22
+ import { generatePreviewHtml } from '../../utils/html-utils';
23
+
24
+ /** 文書の <html> に属性を付ける(null / undefined は外す)。エディタ本体(EditorCanvas)も同じ関数を使う */
25
+ export function applyDocumentAttributes(doc: Document | null | undefined, attrs: DocumentAttributes): void {
26
+ const root = doc?.documentElement;
27
+ if (!root) return;
28
+ for (const [name, value] of Object.entries(attrs)) {
29
+ if (!/^[A-Za-z_:][\w:.-]*$/.test(name)) continue;
30
+ if (value == null) root.removeAttribute(name);
31
+ else if (root.getAttribute(name) !== value) root.setAttribute(name, value);
32
+ }
33
+ }
34
+
35
+ interface PageFramePreviewProps {
36
+ page: PageFrameLayout;
37
+ /** 画面上の判定に使う容器(IntersectionObserver の root) */
38
+ rootRef: React.RefObject<HTMLDivElement | null>;
39
+ }
40
+
41
+ export const PageFramePreview = memo(function PageFramePreview({ page, rootRef }: PageFramePreviewProps) {
42
+ const { editorMode, ensurePageHtml, setPageHeight, previewStyles, requestPreviewSlot, releasePreviewSlot, documentAttributes } = useMultiPageCanvas();
43
+ const documentAttributesRef = useRef(documentAttributes);
44
+ documentAttributesRef.current = documentAttributes;
45
+ const hostRef = useRef<HTMLDivElement>(null);
46
+ const iframeRef = useRef<HTMLIFrameElement>(null);
47
+ const [nearViewport, setNearViewport] = useState(false);
48
+ const [loaded, setLoaded] = useState(false);
49
+
50
+ // 画面に近づいたら描く(遠ざかっても消さない: 戻ったときに白く抜けないため)
51
+ useEffect(() => {
52
+ const host = hostRef.current;
53
+ const root = rootRef.current;
54
+ if (!host || !root) return;
55
+ const observer = new IntersectionObserver(
56
+ (entries) => {
57
+ if (entries.some((e) => e.isIntersecting)) setNearViewport(true);
58
+ },
59
+ { root, rootMargin: '150% 150%' },
60
+ );
61
+ observer.observe(host);
62
+ return () => observer.disconnect();
63
+ }, [rootRef]);
64
+
65
+ // webpage は高さの実測がレイアウトに要る(未測定だと仮の高さで並ぶ)ので、見えていなくても読む。
66
+ // slide は寸法が決まっているので見えたときだけ。どちらも同時に読む枚数は絞る(順番待ち)
67
+ const wantsMount = editorMode === 'webpage' || nearViewport;
68
+ const [granted, setGranted] = useState(false);
69
+ const grantedRef = useRef(false);
70
+ useEffect(() => {
71
+ if (!wantsMount || grantedRef.current) return;
72
+ requestPreviewSlot(page.id, () => {
73
+ grantedRef.current = true;
74
+ setGranted(true);
75
+ });
76
+ // 見えなくなった: 順番待ちから外す(読み始めていれば載せたまま)
77
+ return () => {
78
+ if (!grantedRef.current) releasePreviewSlot(page.id);
79
+ };
80
+ }, [wantsMount, page.id, requestPreviewSlot, releasePreviewSlot]);
81
+ useEffect(() => () => releasePreviewSlot(page.id), [page.id, releasePreviewSlot]);
82
+ const shouldMount = granted;
83
+
84
+ useEffect(() => {
85
+ if (shouldMount && page.html == null && !page.loading && !page.error) void ensurePageHtml(page.id);
86
+ }, [shouldMount, page.html, page.loading, page.error, page.id, ensurePageHtml]);
87
+
88
+ // 本文が変わったら描き直す
89
+ const [doc, setDoc] = useState<string | null>(null);
90
+ useEffect(() => {
91
+ if (!shouldMount || page.html == null) return;
92
+ setDoc(generatePreviewHtml(page.html, editorMode, page.size.width, previewStyles));
93
+ }, [shouldMount, page.html, editorMode, page.size.width, previewStyles]);
94
+
95
+ // 高さの実測(同一オリジンなので親から読む)
96
+ useEffect(() => {
97
+ const iframe = iframeRef.current;
98
+ if (!iframe || doc == null) return;
99
+ let observer: ResizeObserver | null = null;
100
+ let disposed = false;
101
+ const measure = () => {
102
+ try {
103
+ const body = iframe.contentDocument?.body;
104
+ const artboard = iframe.contentDocument?.getElementById('artboard');
105
+ const h = artboard?.getBoundingClientRect().height || body?.scrollHeight || 0;
106
+ if (h > 0) setPageHeight(page.id, h, 'preview');
107
+ } catch {
108
+ /* 読めなければ仮の高さのまま */
109
+ }
110
+ };
111
+ const onLoad = () => {
112
+ if (disposed) return;
113
+ setLoaded(true);
114
+ applyDocumentAttributes(iframe.contentDocument, documentAttributesRef.current);
115
+ measure();
116
+ try {
117
+ const artboard = iframe.contentDocument?.getElementById('artboard');
118
+ if (artboard && typeof ResizeObserver !== 'undefined') {
119
+ observer = new ResizeObserver(() => measure());
120
+ observer.observe(artboard);
121
+ }
122
+ // フォントの読み込みが終わってから測り直す(折り返しが変わる)
123
+ void iframe.contentDocument?.fonts?.ready.then(() => { if (!disposed) measure(); });
124
+ } catch {
125
+ /* cross-origin ではない想定 */
126
+ }
127
+ };
128
+ iframe.addEventListener('load', onLoad);
129
+ return () => {
130
+ disposed = true;
131
+ iframe.removeEventListener('load', onLoad);
132
+ observer?.disconnect();
133
+ };
134
+ }, [doc, page.id, setPageHeight]);
135
+
136
+ // 文書の属性が変わったら、読み直さずに <html> へ付け替える(読み込み前なら onLoad が付ける)
137
+ useEffect(() => {
138
+ if (!loaded) return;
139
+ try {
140
+ applyDocumentAttributes(iframeRef.current?.contentDocument, documentAttributes);
141
+ } catch {
142
+ /* 読めなければ次の読み込みで付く */
143
+ }
144
+ }, [documentAttributes, loaded]);
145
+
146
+ // 読み終わった(または読めない)ら枠を返して次へ。本文が来ない場合の安全弁つき
147
+ useEffect(() => {
148
+ if (!granted) return;
149
+ if (loaded || page.error) {
150
+ releasePreviewSlot(page.id);
151
+ return;
152
+ }
153
+ const timer = setTimeout(() => releasePreviewSlot(page.id), 8000);
154
+ return () => clearTimeout(timer);
155
+ }, [granted, loaded, page.error, page.id, releasePreviewSlot]);
156
+
157
+ return (
158
+ <div
159
+ ref={hostRef}
160
+ className="absolute inset-0 overflow-hidden"
161
+ data-page-preview={page.id}
162
+ style={{ background: '#fff' }}
163
+ >
164
+ {doc != null ? (
165
+ <iframe
166
+ ref={iframeRef}
167
+ title={page.title || page.id}
168
+ srcDoc={doc}
169
+ tabIndex={-1}
170
+ aria-hidden="true"
171
+ className="block border-0"
172
+ style={{
173
+ width: page.size.width,
174
+ height: page.size.height,
175
+ pointerEvents: 'none',
176
+ // ズームで重くならないように。iframe の中身は外側の transform で拡大縮小されるが、
177
+ // will-change が無いと倍率が変わるたびに 26 枚の紙面がラスタライズし直され、
178
+ // 縮小中に 100〜340ms のフレームが混じる(実測)。付けるとラスタが保たれ p95 117ms → 24ms
179
+ willChange: 'transform',
180
+ }}
181
+ />
182
+ ) : (
183
+ <div className="ed-frame-skeleton" aria-hidden="true">
184
+ {page.error ? (
185
+ <span className="ed-frame-skeleton-note">読み込めませんでした</span>
186
+ ) : (
187
+ <>
188
+ <span className="ed-frame-skeleton-bar" style={{ width: '38%' }} />
189
+ <span className="ed-frame-skeleton-bar" style={{ width: '72%' }} />
190
+ <span className="ed-frame-skeleton-bar" style={{ width: '56%' }} />
191
+ </>
192
+ )}
193
+ </div>
194
+ )}
195
+ </div>
196
+ );
197
+ });
@@ -1,7 +1,3 @@
1
- export { InfiniteCanvas } from './InfiniteCanvas';
2
1
  export { MultiPageCanvasView } from './MultiPageCanvasView';
3
- export { PageFrameOverlay } from './PageFrameOverlay';
4
- export { PageThumbnail } from './PageThumbnail';
5
- export { PageLivePreview } from './PageLivePreview';
6
- export { PageLabel } from './PageLabel';
7
- export { CanvasZoomControls } from './CanvasZoomControls';
2
+ export { PageFramePreview } from './PageFramePreview';
3
+ export { CanvasRulers } from './CanvasRulers';
@@ -15,6 +15,7 @@
15
15
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
16
16
  import { Check, CornerUpLeft, Loader2, MapPin, RotateCcw, Send, MessageSquare, Sparkles, Trash2, X } from 'lucide-react';
17
17
  import { useEditorContext } from '../../EditorContext';
18
+ import { useCanvasViewStateOptional } from '../../contexts/MultiPageCanvasContext';
18
19
  import { useDeck, applyDeck } from '../../../components/viewer/useDeck';
19
20
  import { commentAction, type SlideComment } from '../../../lib/deck';
20
21
  import { can, io, type EditorDeck } from '../../../io';
@@ -159,6 +160,8 @@ export function useCommentMarkers({
159
160
  const comments = deck.slides[page - 1]?.comments ?? [];
160
161
  const onOpenRef = useRef(onOpenThread);
161
162
  onOpenRef.current = onOpenThread;
163
+ // マルチフレームのキャンバスでは iframe の外側にも倍率が掛かる。ピンは画面上で同じ大きさに保つ
164
+ const outerZoom = useCanvasViewStateOptional()?.canvasZoom ?? 1;
162
165
 
163
166
  useEffect(() => {
164
167
  if (!active) return;
@@ -183,7 +186,8 @@ export function useCommentMarkers({
183
186
  if (!wanted.has(button.dataset.commentId!)) button.remove();
184
187
  });
185
188
  const rootRect = artboard.getBoundingClientRect();
186
- const scale = rootRect.width / artboard.offsetWidth || 1;
189
+ const innerScale = rootRect.width / artboard.offsetWidth || 1;
190
+ const scale = innerScale * outerZoom;
187
191
  const size = 32 / scale;
188
192
  const stacks = new Map<string, number>();
189
193
  for (const c of anchored) {
@@ -214,8 +218,8 @@ export function useCommentMarkers({
214
218
  const rect = target.getBoundingClientRect();
215
219
  const stack = stacks.get(c.anchorSrc!) ?? 0;
216
220
  stacks.set(c.anchorSrc!, stack + 1);
217
- const x = Math.max(0, Math.min(artboard.offsetWidth - size, (rect.right - rootRect.left) / scale - size / 2));
218
- const y = Math.max(0, (rect.top - rootRect.top) / scale - size / 2) + stack * (size + 4 / scale);
221
+ const x = Math.max(0, Math.min(artboard.offsetWidth - size, (rect.right - rootRect.left) / innerScale - size / 2));
222
+ const y = Math.max(0, (rect.top - rootRect.top) / innerScale - size / 2) + stack * (size + 4 / scale);
219
223
  const styles: Partial<CSSStyleDeclaration> = {
220
224
  position: 'absolute', left: `${x}px`, top: `${y}px`, width: `${size}px`, height: `${size}px`,
221
225
  borderRadius: '50% 50% 50% 4px', background: '#2459c4', color: '#fff', fontSize: `${13 / scale}px`,
@@ -240,7 +244,7 @@ export function useCommentMarkers({
240
244
  const layer = getIframeDoc()?.querySelector('.gg-comment-layer');
241
245
  layer?.remove();
242
246
  };
243
- }, [active, page, getIframeDoc, JSON.stringify(comments.map((c) => [c.id, c.anchorSrc, c.resolved, c.author, c.text]))]);
247
+ }, [active, page, getIframeDoc, outerZoom, JSON.stringify(comments.map((c) => [c.id, c.anchorSrc, c.resolved, c.author, c.text]))]);
244
248
  }
245
249
 
246
250
  /* ============================ 右パネル ============================ */
@@ -8,8 +8,10 @@ import type { PptTheme } from '../ppt/PptChrome';
8
8
  export function CanvasAppearance({ theme }: { theme: PptTheme }) {
9
9
  const { iframeReady, getIframeDoc } = useEditorContext();
10
10
  useEffect(() => {
11
+ // 読み込み中(iframeReady=false)は body がまだ無いことがある。準備できてから塗る
12
+ if (!iframeReady) return;
11
13
  const doc = getIframeDoc();
12
- if (!doc) return;
14
+ if (!doc?.body) return;
13
15
  const color = theme === 'dark' ? '#191d24' : '#eef1f5';
14
16
  doc.body.style.backgroundColor = color;
15
17
  const container = doc.getElementById('canvas-container');
@@ -16,6 +16,7 @@ import { memo, useEffect, useLayoutEffect, useRef, useState } from 'react';
16
16
  import { useDeck } from '../../../components/viewer/useDeck';
17
17
  import { DeckSlideRender } from '../../../components/DeckSlideRender';
18
18
  import { flushAutoSave } from '../../autosave';
19
+ import { useMultiPageCanvasOptional } from '../../contexts/MultiPageCanvasContext';
19
20
 
20
21
  /**
21
22
  * スライド描画はメモ化する。パネル幅の変更や他ページの選択で親が再レンダーしても、
@@ -28,6 +29,7 @@ const ROW_CHROME_PX = 8 + 8 + 16 + 6;
28
29
 
29
30
  export function PagesPanel({ page, height = '33vh' }: { page: number; height?: string }) {
30
31
  const deck = useDeck();
32
+ const canvas = useMultiPageCanvasOptional();
31
33
  const rootRef = useRef<HTMLDivElement>(null);
32
34
  const currentRef = useRef<HTMLButtonElement>(null);
33
35
  /**
@@ -55,6 +57,12 @@ export function PagesPanel({ page, height = '33vh' }: { page: number; height?: s
55
57
  /** ページ切替。未保存があれば黙って保存してから移る(失敗したときだけ確認する) */
56
58
  const goto = async (n: number) => {
57
59
  if (n === page) return;
60
+ // マルチフレームのキャンバスでは画面を移らず、そのフレームへ編集を移して画面に寄せる
61
+ if (canvas) {
62
+ const id = canvas.pages[n - 1]?.id ?? String(n);
63
+ if (await canvas.activatePage(id, { reveal: true })) canvas.zoomToPage(id, { animate: true });
64
+ return;
65
+ }
58
66
  if (!(await flushAutoSave())) {
59
67
  if (!window.confirm('保存に失敗しました。変更を破棄して移動しますか?')) return;
60
68
  }
@@ -375,6 +375,34 @@ export const EDITOR_IFRAME_STYLES = `
375
375
  .selection-box.selection-member .selection-outline {
376
376
  box-shadow: 0 0 0 calc(1px * var(--ov)) rgba(13, 153, 255, 0.75);
377
377
  }
378
+ /*
379
+ * 部品(data-part)のインスタンスは紫(Figma のコンポーネントと同じ色)。
380
+ * ルートを選んだら枠・ハンドル・サイズラベルが紫、中のスロットを選んだら枠は青のまま
381
+ * 「部品の中」の札だけ出す。札はパンくず(上 -22px)の左上に重ならないよう、その上に置く
382
+ */
383
+ .selection-box.selection-part .selection-outline { box-shadow: 0 0 0 var(--outline-w) #7b61ff; }
384
+ .selection-box.selection-part .resize-handle::after { border-color: #7b61ff; }
385
+ .selection-box.selection-part .size-label { background: #7b61ff; }
386
+ .selection-box.selection-member.selection-part .selection-outline { box-shadow: 0 0 0 calc(1px * var(--ov)) rgba(123, 97, 255, 0.8); }
387
+ .selection-box .part-badge {
388
+ position: absolute;
389
+ top: calc(-44px * var(--ov));
390
+ left: 0;
391
+ padding: calc(2px * var(--ov)) calc(6px * var(--ov));
392
+ border-radius: calc(3px * var(--ov));
393
+ background: #7b61ff;
394
+ color: #fff;
395
+ font-size: calc(10px * var(--ov));
396
+ line-height: 1.4;
397
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Hiragino Sans', 'Noto Sans JP', sans-serif;
398
+ white-space: nowrap;
399
+ pointer-events: none;
400
+ z-index: 40;
401
+ }
402
+ .selection-box.selection-in-part .part-badge { background: rgba(123, 97, 255, 0.85); }
403
+ /* 選んでいる部品のスロット(編集できる範囲)は点線。中を選んでいるときは部品の外周を薄い点線で示す */
404
+ [data-part].selected [data-slot] { outline: calc(1px * var(--overlay-scale, 1)) dashed rgba(123, 97, 255, 0.75); outline-offset: calc(-1px * var(--overlay-scale, 1)); }
405
+ [data-part]:not(.selected):has(.selected) { outline: calc(1px * var(--overlay-scale, 1)) dashed rgba(123, 97, 255, 0.55); outline-offset: calc(2px * var(--overlay-scale, 1)); }
378
406
  .size-label {
379
407
  position: absolute;
380
408
  bottom: calc(-22px * var(--ov));
@@ -14,9 +14,15 @@
14
14
  * - contentList: コンテンツリスト(スライド、ページ等)
15
15
  */
16
16
 
17
- import React, { createContext, useContext, useState, useCallback, useEffect } from 'react';
17
+ import React, { createContext, useContext, useState, useCallback, useEffect, useMemo } from 'react';
18
18
  import type { DOMTreeNode } from '../types';
19
19
 
20
+ /**
21
+ * ページ切替の通知。戻り値に false(または false に解決する Promise)を返すと
22
+ * 「移れなかった」として扱う(マルチフレームのキャンバスが activatePage の結果に使う)
23
+ */
24
+ export type ContentChangeHandler = (contentId: string) => void | boolean | Promise<boolean | void>;
25
+
20
26
  // コンテンツリスト用の軽量型(スライド、ページ、コンポーネント等で共用)
21
27
  export interface ContentListItem {
22
28
  id: string;
@@ -24,6 +30,48 @@ export interface ContentListItem {
24
30
  /** 表示順序(スライド番号、ページ番号等) */
25
31
  order: number;
26
32
  thumbnailHtml?: string;
33
+ /**
34
+ * 親ページの id(サイトマップの階層)。渡すと、マルチフレームのキャンバスが
35
+ * フレームを階層のツリー(親の下に子を並べる)で配置し、左パネルの一覧も段を付ける。
36
+ * 無ければ(全ページ null / undefined)従来どおり行で折り返す
37
+ */
38
+ parentId?: string | null;
39
+ /** 編集しない表示(ページそのもの・ビューア)の URL。渡すとフレーム名と一覧に「別タブで開く」が付く */
40
+ href?: string;
41
+ /**
42
+ * 本文の版。利用側でファイルが外から変わったとき(部品の一括反映・CLI・他の人の編集)に
43
+ * 数を進めると、キャンバスの見るだけの紙面がそのページを読み直す。編集中のページには効かない
44
+ * (編集中の本文は上書きしない)
45
+ */
46
+ revision?: number;
47
+ }
48
+
49
+ /**
50
+ * 文書に付ける属性(`<html>` の属性)。生きているエディタと見るだけの紙面の両方の文書に、
51
+ * 読み直さずに反映する。利用側の CSS が `html[data-…]` で切り替える表示(構成ラフの
52
+ * 注釈カラムの表示・非表示など)に使う。値が null / undefined の属性は外す
53
+ */
54
+ export type DocumentAttributes = Record<string, string | null | undefined>;
55
+
56
+ /** contentList の parentId から各ページの深さ(ルート = 0)を求める。親が一覧に無い・循環しているものはルート扱い */
57
+ export function computeContentDepths(list: readonly ContentListItem[]): Map<string, number> {
58
+ const byId = new Map(list.map((c) => [c.id, c] as const));
59
+ const depths = new Map<string, number>();
60
+ const depthOf = (id: string, seen: Set<string>): number => {
61
+ const cached = depths.get(id);
62
+ if (cached != null) return cached;
63
+ const item = byId.get(id);
64
+ const parent = item?.parentId;
65
+ let d = 0;
66
+ if (parent && parent !== id && byId.has(parent) && !seen.has(parent)) {
67
+ seen.add(id);
68
+ d = depthOf(parent, seen) + 1;
69
+ }
70
+ depths.set(id, d);
71
+ return d;
72
+ };
73
+ list.forEach((c) => depthOf(c.id, new Set()));
74
+ return depths;
27
75
  }
28
76
 
29
77
  // アートボード(スライド/ページ)ごとの状態
@@ -48,7 +96,9 @@ export interface EditorArtboardContextValue {
48
96
  // コンテンツリスト
49
97
  contentList: ContentListItem[];
50
98
  currentContentId: string | null;
51
- onContentChange: ((contentId: string) => void) | null;
99
+ onContentChange: ContentChangeHandler | null;
100
+ /** 文書(`<html>`)に付ける属性。エディタと見るだけの紙面の両方に、読み直さずに反映する */
101
+ documentAttributes: DocumentAttributes;
52
102
 
53
103
  // Deprecated aliases
54
104
  /** @deprecated Use contentList instead */
@@ -72,7 +122,8 @@ export function useEditorArtboard(): EditorArtboardContextValue {
72
122
  interface EditorArtboardProviderProps {
73
123
  contentList?: ContentListItem[];
74
124
  currentContentId?: string;
75
- onContentChange?: (contentId: string) => void;
125
+ onContentChange?: ContentChangeHandler;
126
+ documentAttributes?: DocumentAttributes;
76
127
  // 状態同期用のコールバック(ファサードで設定)
77
128
  onArtboardSwitch?: (
78
129
  newId: string,
@@ -104,6 +155,7 @@ export function EditorArtboardProvider({
104
155
  contentList: contentListProp,
105
156
  currentContentId: currentContentIdProp,
106
157
  onContentChange: onContentChangeProp,
158
+ documentAttributes: documentAttributesProp,
107
159
  slides: slidesProp,
108
160
  currentSlideId: currentSlideIdProp,
109
161
  onSlideChange: onSlideChangeProp,
@@ -113,6 +165,10 @@ export function EditorArtboardProvider({
113
165
  const contentList = contentListProp ?? slidesProp ?? [];
114
166
  const currentContentId = currentContentIdProp ?? currentSlideIdProp ?? null;
115
167
  const onContentChange = onContentChangeProp ?? onSlideChangeProp ?? null;
168
+ // 属性は中身で比べる(利用側が毎描画で新しいオブジェクトを渡しても、購読側を描き直さない)
169
+ const documentAttributesKey = JSON.stringify(documentAttributesProp ?? {});
170
+ // eslint-disable-next-line react-hooks/exhaustive-deps
171
+ const documentAttributes = useMemo<DocumentAttributes>(() => ({ ...(documentAttributesProp ?? {}) }), [documentAttributesKey]);
116
172
 
117
173
  // Deprecated aliases
118
174
  const slides = contentList;
@@ -215,6 +271,7 @@ export function EditorArtboardProvider({
215
271
  contentList,
216
272
  currentContentId,
217
273
  onContentChange,
274
+ documentAttributes,
218
275
  // Deprecated
219
276
  slides,
220
277
  currentSlideId,
@@ -45,6 +45,7 @@ import {
45
45
  } from '../../lib/firebase/editor-components';
46
46
  import { io } from '../../io';
47
47
  import type { EditorPartDef, EditorPartsLibrary } from '../../io';
48
+ import { useMultiPageCanvasOptional } from './MultiPageCanvasContext';
48
49
  import {
49
50
  materializePart,
50
51
  partDefFromElement,
@@ -474,6 +475,13 @@ export function EditorComponentsProvider({
474
475
  websiteId,
475
476
  pageId,
476
477
  }: EditorComponentsProviderProps) {
478
+ // マルチフレームのキャンバス(あれば)。部品の定義を更新したあと、他ページの紙面を読み直させる。
479
+ // ref で持つ: キャンバスの値は高さの実測のたびに変わるので、コールバックの依存に入れない
480
+ const canvas = useMultiPageCanvasOptional();
481
+ const canvasRef = useRef(canvas);
482
+ canvasRef.current = canvas;
483
+ const pageIdRef = useRef(pageId);
484
+ pageIdRef.current = pageId;
477
485
  // State
478
486
  const [masterComponents, setMasterComponents] = useState<
479
487
  Map<string, MasterComponent>
@@ -870,6 +878,9 @@ export function EditorComponentsProvider({
870
878
  const saved = (save ? await save(next) : undefined) ?? next;
871
879
  upsertPartDef(saved);
872
880
  el.setAttribute('data-part-v', String(saved.version));
881
+ // 他ページのインスタンスは利用側(savePart)が書き換えている。キャンバスの見るだけの紙面にもその場で反映する
882
+ // (以前は読み直すまで前の姿のままだった)。編集中のページは今の DOM が正なので触らない
883
+ canvasRef.current?.invalidatePages({ except: [pageIdRef.current] });
873
884
  return saved;
874
885
  },
875
886
  [partDefs, upsertPartDef]
@@ -930,10 +941,12 @@ export function EditorComponentsProvider({
930
941
  m.set(id, next);
931
942
  return m;
932
943
  });
933
- io().savePart?.(next).catch((err) => {
934
- console.error('[EditorComponentsContext] 部品の保存に失敗:', err);
935
- setError('部品の保存に失敗しました');
936
- });
944
+ io().savePart?.(next)
945
+ .then(() => canvasRef.current?.invalidatePages({ except: [pageIdRef.current] }))
946
+ .catch((err) => {
947
+ console.error('[EditorComponentsContext] 部品の保存に失敗:', err);
948
+ setError('部品の保存に失敗しました');
949
+ });
937
950
  }
938
951
  }
939
952
  },