@growgroup/visual-editor 0.2.1 → 0.3.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 (38) hide show
  1. package/README.md +37 -1
  2. package/dist/editor.css +1 -1
  3. package/package.json +1 -1
  4. package/src/editor/EditorContext.tsx +7 -3
  5. package/src/editor/FrontendVisualEditor.tsx +154 -54
  6. package/src/editor/autosave.ts +32 -0
  7. package/src/editor/components/EditorCanvas.tsx +93 -8
  8. package/src/editor/components/EditorHeader.tsx +47 -12
  9. package/src/editor/components/EditorPropertyPanel.tsx +2 -0
  10. package/src/editor/components/multi-page/CanvasRulers.tsx +166 -0
  11. package/src/editor/components/multi-page/MultiPageCanvasView.tsx +295 -1345
  12. package/src/editor/components/multi-page/PageFramePreview.tsx +164 -0
  13. package/src/editor/components/multi-page/index.ts +2 -6
  14. package/src/editor/components/ppt/PptComments.tsx +8 -4
  15. package/src/editor/components/shell/CanvasAppearance.tsx +3 -1
  16. package/src/editor/components/shell/PagesPanel.tsx +8 -0
  17. package/src/editor/contexts/EditorArtboardContext.tsx +8 -2
  18. package/src/editor/contexts/MultiPageCanvasContext.tsx +710 -358
  19. package/src/editor/hooks/useCanvasControls.ts +1 -1
  20. package/src/editor/hooks/useCanvasMarquee.ts +196 -0
  21. package/src/editor/hooks/useContextMenuHandler.ts +5 -14
  22. package/src/editor/hooks/useCoordinateTransform.ts +11 -8
  23. package/src/editor/hooks/useDrawingMode.ts +1 -0
  24. package/src/editor/hooks/useGoogleFonts.ts +6 -2
  25. package/src/editor/hooks/useInfiniteCanvas.ts +228 -220
  26. package/src/editor/hooks/useMarqueeSelection.ts +2 -2
  27. package/src/editor/parts.ts +38 -12
  28. package/src/editor/utils/dom-utils.ts +7 -1
  29. package/src/editor/utils/embedded-canvas-bridge.ts +155 -0
  30. package/src/editor/utils/html-utils.ts +86 -11
  31. package/src/io.ts +20 -0
  32. package/src/styles/skin.css +66 -34
  33. package/src/editor/components/multi-page/CanvasZoomControls.tsx +0 -71
  34. package/src/editor/components/multi-page/InfiniteCanvas.tsx +0 -95
  35. package/src/editor/components/multi-page/PageFrameOverlay.tsx +0 -76
  36. package/src/editor/components/multi-page/PageLabel.tsx +0 -34
  37. package/src/editor/components/multi-page/PageLivePreview.tsx +0 -242
  38. package/src/editor/components/multi-page/PageThumbnail.tsx +0 -59
@@ -3,38 +3,77 @@
3
3
  /**
4
4
  * MultiPageCanvasContext
5
5
  *
6
- * マルチページ無限キャンバスの状態管理Context
7
- * 全ページを常にキャンバス上に表示し、クリックで即座にページ切替
8
- * Figmaライクな統合ビュー(モード切替なし)
6
+ * Figma 風のマルチフレームキャンバスの状態。
7
+ * 全ページをフレームとして 1 枚のキャンバスに並べ、1 つのフレームだけが「生きている」
8
+ * エディタ(EditorCanvas)で、残りは見るだけの紙面(srcdoc の iframe)。
9
+ * クリックしたフレームへ編集が移る。倍率・位置はこの Context が持ち、
10
+ * 外側の CSS transform で全体を拡大縮小する(iframe の中は常に等倍)。
11
+ *
12
+ * [設計]
13
+ * - 編集エンジンは 1 つ(EditorCanvas)。ページごとの編集ロジックの二重実装はしない
14
+ * - フレームの寸法: 幅は版面の幅(webpage は artboardWidth、slide は 1920)、
15
+ * 高さは実測(生きているページはエディタ、他は見るだけの紙面が測る)
16
+ * - 位置は寸法から毎回計算する(状態に持たない)。高さが変わっても流れ直すだけ
17
+ * - 見えている範囲は localStorage に保存し、開き直しても同じ場所に戻る
18
+ *
19
+ * [ビュー状態は Context の外に置く]
20
+ * 倍率・位置(viewState)はホイールの 1 目盛りごとに変わる。これを Context の値に
21
+ * 入れると、購読しているもの(エディタ本体・150 枚の紙面・左パネル…)が毎目盛り
22
+ * 描き直しになる。そこで viewState は小さな外部ストア(viewStore)に置き、
23
+ * 必要な部品だけが useCanvasViewState() で購読する。Context の値そのものは
24
+ * ズーム・パンでは変わらない
9
25
  */
10
26
 
11
27
  import React, {
12
28
  createContext,
13
- useContext,
14
- useState,
15
29
  useCallback,
30
+ useContext,
31
+ useEffect,
16
32
  useMemo,
17
33
  useRef,
18
- useEffect,
34
+ useState,
35
+ useSyncExternalStore,
19
36
  } from 'react';
20
37
  import { useEditorArtboard } from './EditorArtboardContext';
21
- import { useEditorHistory } from './EditorHistoryContext';
22
- import { useEditorRefs } from './EditorRefsContext';
23
- import { generateEditableHtml } from '../utils/html-utils';
38
+ import { useEditorTool } from './EditorToolContext';
39
+ import { useEditorView } from './EditorViewContext';
40
+ import { SLIDE_HEIGHT, SLIDE_WIDTH, WEBPAGE_WIDTH } from '../constants';
41
+ import type { EditorMode } from './EditorToolContext';
42
+ import { io, type PreviewStyle } from '../../io';
24
43
 
25
44
  // ============================================================
26
45
  // 定数
27
46
  // ============================================================
28
47
 
29
- /** ウェブページの標準幅 (px) */
30
- export const WEBPAGE_WIDTH = 1440;
31
- /** ウェブページの最小高さ (px) */
32
- export const WEBPAGE_MIN_HEIGHT = 900;
33
- /** ページ間の水平間隔 (px) */
34
- const LAYOUT_GAP_X = 100;
35
- /** ズーム範囲 */
36
- const MIN_ZOOM = 0.02;
37
- const MAX_ZOOM = 2.0;
48
+ /** 互換のために残す(旧実装が公開していた)。フレームの幅は viewportWidth を使う */
49
+ export { WEBPAGE_WIDTH };
50
+ /** 高さが未測定の webpage フレームに仮に与える高さ */
51
+ export const WEBPAGE_MIN_HEIGHT = 1200;
52
+ /** ズーム範囲(Figma は 2%〜25600%。紙面の編集で要るのはこの範囲) */
53
+ export const MIN_ZOOM = 0.02;
54
+ export const MAX_ZOOM = 4;
55
+ /** ホイール 1 段のズーム倍率(ボタン・キー操作) */
56
+ const ZOOM_STEP = 1.25;
57
+ /** 全体表示・ページ表示のときの余白(px、画面上) */
58
+ const FIT_PADDING = 72;
59
+ /** 上と左の定規の太さ(px)。全体表示の余白に足す */
60
+ export const RULER_SIZE = 20;
61
+ /** ビューのアニメーション(ms) */
62
+ const VIEW_ANIMATION_MS = 240;
63
+ /** 見るだけの紙面を同時に読む枚数 */
64
+ const PREVIEW_PARALLEL = 4;
65
+
66
+ /** フレームどうしの間隔(紙面の座標系、等倍のとき)。フレーム名を出すかの判定にも使う */
67
+ export const FRAME_GAP: Record<EditorMode, { x: number; y: number }> = {
68
+ webpage: { x: 200, y: 260 },
69
+ slide: { x: 160, y: 220 },
70
+ };
71
+ const GAP = FRAME_GAP;
72
+ /** 1 行に並べる数。webpage は横一列が基本だが、多いと横に長すぎるので折り返す */
73
+ const WRAP_AT: Record<EditorMode, (count: number) => number> = {
74
+ webpage: () => 8,
75
+ slide: (count) => Math.min(6, Math.max(3, Math.ceil(Math.sqrt(count)))),
76
+ };
38
77
 
39
78
  // ============================================================
40
79
  // 型定義
@@ -43,57 +82,119 @@ const MAX_ZOOM = 2.0;
43
82
  export interface PageFrame {
44
83
  id: string;
45
84
  title: string;
46
- position: { x: number; y: number };
85
+ /** 本文。null = まだ読んでいない */
86
+ html: string | null;
87
+ /** 本文を読んでいる途中 */
88
+ loading: boolean;
89
+ /** 読めなかったときの理由 */
90
+ error: string | null;
47
91
  size: { width: number; height: number };
48
- thumbnailHtml: string;
49
- thumbnailDataUrl: string | null;
92
+ /** 高さを実測済みか(未測定はレイアウト用の仮の高さ) */
93
+ measured: boolean;
94
+ /** 未保存の変更がある(生きているページだけ立つ) */
50
95
  isDirty: boolean;
51
- html: string;
96
+ }
97
+
98
+ /** レイアウト済み(位置付き)のフレーム */
99
+ export interface PageFrameLayout extends PageFrame {
100
+ position: { x: number; y: number };
101
+ index: number;
52
102
  }
53
103
 
54
104
  export interface CanvasViewState {
55
105
  canvasOffset: { x: number; y: number };
56
106
  canvasZoom: number;
107
+ /** 生きているエディタが載っているページ(利用側の currentContentId と同じ) */
57
108
  activePageId: string | null;
58
109
  }
59
110
 
60
- export interface MultiPageCanvasContextValue {
61
- // ページフレーム管理
62
- pages: PageFrame[];
63
- getPage: (id: string) => PageFrame | undefined;
64
- updatePageFrame: (id: string, updates: Partial<PageFrame>) => void;
111
+ export interface CanvasBounds {
112
+ minX: number;
113
+ minY: number;
114
+ maxX: number;
115
+ maxY: number;
116
+ }
65
117
 
66
- // ビュー状態
67
- viewState: CanvasViewState;
118
+ /** ビュー状態の外部ストア(購読した部品だけが描き直る) */
119
+ export interface CanvasViewStore {
120
+ get: () => CanvasViewState;
121
+ set: (updater: (prev: CanvasViewState) => CanvasViewState) => void;
122
+ subscribe: (listener: () => void) => () => void;
123
+ }
124
+
125
+ export interface MultiPageCanvasContextValue {
126
+ isEnabled: boolean;
127
+ editorMode: EditorMode;
128
+
129
+ // ---- ページフレーム
130
+ pages: PageFrameLayout[];
131
+ bounds: CanvasBounds;
132
+ getPage: (id: string) => PageFrameLayout | undefined;
133
+ updatePageFrame: (
134
+ id: string,
135
+ updates: Partial<Pick<PageFrame, 'title' | 'html' | 'size' | 'isDirty' | 'measured' | 'loading' | 'error'>>,
136
+ ) => void;
137
+ /** 本文を差し替える(保存後・読み込み後) */
138
+ setPageHtml: (id: string, html: string) => void;
139
+ /**
140
+ * 高さの実測を反映する。source が 'preview' のときは、生きているページには効かない
141
+ * (エディタの実測が正。見るだけの紙面はフォントや画像の読み込み前に測ることがある)
142
+ */
143
+ setPageHeight: (id: string, height: number, source: 'editor' | 'preview') => void;
144
+ /** 本文がまだ無ければ読む(contentList の thumbnailHtml → io.loadContent) */
145
+ ensurePageHtml: (id: string) => Promise<void>;
146
+ /** 見るだけの紙面に足すスタイル(io.previewStyles。無ければ空) */
147
+ previewStyles: PreviewStyle[];
148
+
149
+ // ---- ビュー
150
+ /** 倍率・位置。描画に使うなら useCanvasViewState() で購読する。ハンドラから読むだけなら get() */
151
+ viewStore: CanvasViewStore;
68
152
  setCanvasOffset: (offset: { x: number; y: number }) => void;
69
153
  setCanvasZoom: (zoom: number) => void;
70
-
71
- // ページ切替(Figmaスタイル: モード切替なし)
72
- switchPage: (id: string) => void;
73
-
74
- // 軽量ページフォーカス(iframe再読込なし、activePageId + contentId更新のみ)
75
- focusPage: (id: string) => void;
76
-
77
- // ページ配置
78
- autoLayoutPages: () => void;
79
-
80
- // ズーム操作
81
- zoomToFit: (containerWidth: number, containerHeight: number) => void;
154
+ /** 倍率と位置を同時に決める(アニメーションも可) */
155
+ setView: (view: { zoom: number; offset: { x: number; y: number } }, options?: { animate?: boolean }) => void;
156
+ /** 画面上の点(容器の座標)を固定したまま倍率を変える */
157
+ zoomAt: (zoom: number, point: { x: number; y: number }) => void;
158
+ /** 容器の中心を固定して倍率を変える */
159
+ zoomTo: (zoom: number, options?: { animate?: boolean }) => void;
82
160
  zoomIn: () => void;
83
161
  zoomOut: () => void;
162
+ zoomToFit: (options?: { animate?: boolean }) => void;
163
+ zoomToPage: (id: string, options?: { animate?: boolean }) => void;
164
+ zoomToActual: () => void;
165
+ /** 指定のフレームが画面に入っていなければ、見える位置まで寄せる(倍率は変えない) */
166
+ revealPage: (id: string) => void;
84
167
 
85
- // サムネイル更新
86
- updateThumbnail: (id: string, dataUrl: string) => void;
87
-
88
- // 有効化フラグ
89
- isEnabled: boolean;
90
-
91
- // グローバルUndo/Redoハンドラー (MultiPageCanvasViewから登録される)
92
- undo: () => void;
93
- redo: () => void;
94
- canUndo: boolean;
95
- canRedo: boolean;
96
- registerUndoHandlers: (handlers: { undo: () => void; redo: () => void; canUndo: () => boolean; canRedo: () => boolean }) => void;
168
+ // ---- ページ切替
169
+ /**
170
+ * 編集するページを移す。中身の読み込みと未保存の変更の保存は利用側
171
+ * (onContentChange → FrontendVisualEditor.handleContentChange)が行う。
172
+ * 生きているエディタは本文が読めてから移る(activePageId はそのとき変わる)。
173
+ * 戻り値は移れたか
174
+ */
175
+ activatePage: (id: string, options?: { reveal?: boolean }) => Promise<boolean>;
176
+ /** 切替の途中(クリックしてから本文が読めるまで)のページ。リングはこちらへ先に移る */
177
+ activatingPageId: string | null;
178
+
179
+ // ---- 容器・操作状態
180
+ /** キャンバスの容器(ズームの基準になる矩形)。MultiPageCanvasView が登録する */
181
+ registerContainer: (el: HTMLDivElement | null) => void;
182
+ containerRef: React.MutableRefObject<HTMLDivElement | null>;
183
+ /** ホイール・ドラッグの最中(見るだけの紙面の当たり判定を切る) */
184
+ isInteracting: boolean;
185
+ markInteracting: () => void;
186
+ /** 見えている範囲を localStorage から復元できた(初回表示で全体表示を省く) */
187
+ initialViewRestored: boolean;
188
+ /** 定規 */
189
+ rulersVisible: boolean;
190
+ toggleRulers: () => void;
191
+ /**
192
+ * 見るだけの紙面の読み込み枠。同時に読むのは数枚まで(順番待ち。編集中のページに近い順)。
193
+ * 150 枚を一度に読むと、接続枠と主スレッドをそれらが占め、編集中のページの切替が
194
+ * 1 秒近く待たされる(実測 82ms → 912ms)
195
+ */
196
+ requestPreviewSlot: (id: string, grant: () => void) => void;
197
+ releasePreviewSlot: (id: string) => void;
97
198
  }
98
199
 
99
200
  // ============================================================
@@ -110,57 +211,114 @@ export function useMultiPageCanvas(): MultiPageCanvasContextValue {
110
211
  return context;
111
212
  }
112
213
 
113
- /**
114
- * マルチページキャンバスが有効な場合のみ値を返す(オプショナル使用用)
115
- */
214
+ /** マルチページキャンバスが有効な場合のみ値を返す(オプショナル使用用) */
116
215
  export function useMultiPageCanvasOptional(): MultiPageCanvasContextValue | null {
117
216
  return useContext(MultiPageCanvasContext);
118
217
  }
119
218
 
219
+ /** 倍率・位置を購読する(ズーム・パンのたびに描き直る部品だけが使う) */
220
+ export function useCanvasViewState(): CanvasViewState {
221
+ const { viewStore } = useMultiPageCanvas();
222
+ return useSyncExternalStore(viewStore.subscribe, viewStore.get, viewStore.get);
223
+ }
224
+
225
+ const NULL_VIEW_STORE: CanvasViewStore = {
226
+ get: () => NULL_VIEW,
227
+ set: () => undefined,
228
+ subscribe: () => () => undefined,
229
+ };
230
+ const NULL_VIEW: CanvasViewState = { canvasOffset: { x: 0, y: 0 }, canvasZoom: 1, activePageId: null };
231
+
232
+ /** キャンバスが無ければ null(単独表示でも同じ部品を使うとき用) */
233
+ export function useCanvasViewStateOptional(): CanvasViewState | null {
234
+ const context = useContext(MultiPageCanvasContext);
235
+ const store = context?.viewStore ?? NULL_VIEW_STORE;
236
+ const state = useSyncExternalStore(store.subscribe, store.get, store.get);
237
+ return context ? state : null;
238
+ }
239
+
120
240
  // ============================================================
121
- // レイアウト計算ユーティリティ
241
+ // レイアウト
122
242
  // ============================================================
123
243
 
124
- function calculateAutoLayout(pages: PageFrame[]): PageFrame[] {
125
- return pages.map((page, index) => ({
126
- ...page,
127
- position: {
128
- x: index * (WEBPAGE_WIDTH + LAYOUT_GAP_X),
129
- y: 0,
130
- },
131
- size: { width: WEBPAGE_WIDTH, height: page.size.height || WEBPAGE_MIN_HEIGHT },
132
- }));
244
+ /**
245
+ * 寸法から位置を決める。行ごとに折り返し、行の高さはその行で一番高いフレームに合わせる。
246
+ * 位置を状態に持たないので、高さが実測で変わっても流れ直すだけで済む
247
+ */
248
+ export function layoutFrames(
249
+ frames: PageFrame[],
250
+ editorMode: EditorMode,
251
+ ): { pages: PageFrameLayout[]; bounds: CanvasBounds } {
252
+ const gap = GAP[editorMode];
253
+ const perRow = WRAP_AT[editorMode](frames.length);
254
+ const pages: PageFrameLayout[] = [];
255
+ let x = 0;
256
+ let y = 0;
257
+ let rowHeight = 0;
258
+ frames.forEach((frame, index) => {
259
+ if (index > 0 && index % perRow === 0) {
260
+ x = 0;
261
+ y += rowHeight + gap.y;
262
+ rowHeight = 0;
263
+ }
264
+ pages.push({ ...frame, index, position: { x, y } });
265
+ x += frame.size.width + gap.x;
266
+ rowHeight = Math.max(rowHeight, frame.size.height);
267
+ });
268
+ const bounds: CanvasBounds = pages.length
269
+ ? {
270
+ minX: Math.min(...pages.map((p) => p.position.x)),
271
+ minY: Math.min(...pages.map((p) => p.position.y)),
272
+ maxX: Math.max(...pages.map((p) => p.position.x + p.size.width)),
273
+ maxY: Math.max(...pages.map((p) => p.position.y + p.size.height)),
274
+ }
275
+ : { minX: 0, minY: 0, maxX: 0, maxY: 0 };
276
+ return { pages, bounds };
133
277
  }
134
278
 
135
- function calculateFitZoom(
136
- pages: PageFrame[],
137
- containerWidth: number,
138
- containerHeight: number
279
+ function fitView(
280
+ rect: CanvasBounds,
281
+ container: { width: number; height: number },
282
+ options: { padding: number; maxZoom: number; rulers: boolean },
139
283
  ): { zoom: number; offset: { x: number; y: number } } {
140
- if (pages.length === 0) return { zoom: 0.5, offset: { x: 0, y: 0 } };
141
-
142
- let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
143
- pages.forEach(p => {
144
- minX = Math.min(minX, p.position.x);
145
- minY = Math.min(minY, p.position.y);
146
- maxX = Math.max(maxX, p.position.x + p.size.width);
147
- maxY = Math.max(maxY, p.position.y + p.size.height);
148
- });
149
-
150
- const totalWidth = maxX - minX;
151
- const totalHeight = maxY - minY;
152
- const padding = 80;
153
-
154
- const scaleX = (containerWidth - padding * 2) / totalWidth;
155
- const scaleY = (containerHeight - padding * 2) / totalHeight;
156
- const zoom = Math.min(scaleX, scaleY, 1);
157
-
158
- const offset = {
159
- x: (containerWidth - totalWidth * zoom) / 2 - minX * zoom,
160
- y: (containerHeight - totalHeight * zoom) / 2 - minY * zoom,
284
+ const inset = options.rulers ? RULER_SIZE : 0;
285
+ const availW = Math.max(1, container.width - inset - options.padding * 2);
286
+ const availH = Math.max(1, container.height - inset - options.padding * 2);
287
+ const w = Math.max(1, rect.maxX - rect.minX);
288
+ const h = Math.max(1, rect.maxY - rect.minY);
289
+ const zoom = Math.max(MIN_ZOOM, Math.min(options.maxZoom, availW / w, availH / h));
290
+ return {
291
+ zoom,
292
+ offset: {
293
+ x: inset + (container.width - inset - w * zoom) / 2 - rect.minX * zoom,
294
+ y: inset + (container.height - inset - h * zoom) / 2 - rect.minY * zoom,
295
+ },
161
296
  };
297
+ }
162
298
 
163
- return { zoom: Math.max(zoom, MIN_ZOOM), offset };
299
+ const clampZoom = (z: number) => Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, z));
300
+ const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3);
301
+ const prefersReducedMotion = () =>
302
+ typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches;
303
+
304
+ function createViewStore(initial: CanvasViewState): CanvasViewStore {
305
+ let state = initial;
306
+ const listeners = new Set<() => void>();
307
+ return {
308
+ get: () => state,
309
+ set: (updater) => {
310
+ const next = updater(state);
311
+ if (next === state) return;
312
+ state = next;
313
+ listeners.forEach((l) => l());
314
+ },
315
+ subscribe: (listener) => {
316
+ listeners.add(listener);
317
+ return () => {
318
+ listeners.delete(listener);
319
+ };
320
+ },
321
+ };
164
322
  }
165
323
 
166
324
  // ============================================================
@@ -170,311 +328,503 @@ function calculateFitZoom(
170
328
  interface MultiPageCanvasProviderProps {
171
329
  children: React.ReactNode;
172
330
  enabled: boolean;
331
+ /** 見えている範囲を保存するキー(利用側の parentId 等)。無ければ保存しない */
332
+ storageKey?: string | null;
173
333
  }
174
334
 
175
- export function MultiPageCanvasProvider({
176
- children,
177
- enabled,
178
- }: MultiPageCanvasProviderProps) {
335
+ type PersistedView = { zoom: number; offset: { x: number; y: number }; rulers?: boolean };
336
+
337
+ function readPersistedView(key: string | null | undefined): PersistedView | null {
338
+ if (!key || typeof localStorage === 'undefined') return null;
339
+ try {
340
+ const raw = localStorage.getItem(`gg-editor:canvas-view:${key}`);
341
+ if (!raw) return null;
342
+ const v = JSON.parse(raw) as PersistedView;
343
+ if (!Number.isFinite(v.zoom) || !v.offset || !Number.isFinite(v.offset.x) || !Number.isFinite(v.offset.y)) return null;
344
+ return v;
345
+ } catch {
346
+ return null;
347
+ }
348
+ }
349
+
350
+ export function MultiPageCanvasProvider({ children, enabled, storageKey }: MultiPageCanvasProviderProps) {
179
351
  const artboard = useEditorArtboard();
180
- const history = useEditorHistory();
181
- const refs = useEditorRefs();
182
-
183
- // ページフレーム状態
184
- const [pagesMap, setPagesMap] = useState<Map<string, PageFrame>>(new Map());
185
- const [isLayoutInitialized, setIsLayoutInitialized] = useState(false);
186
-
187
- // ビュー状態(モードなし — 常に統合ビュー)
188
- const [viewState, setViewState] = useState<CanvasViewState>({
189
- canvasOffset: { x: 0, y: 0 },
190
- canvasZoom: 0.15,
191
- activePageId: null,
192
- });
352
+ const { editorMode } = useEditorTool();
353
+ const { viewportWidth } = useEditorView();
193
354
 
194
- // contentListからページフレームを同期
195
- const prevContentListRef = useRef<string>('');
196
- useEffect(() => {
197
- if (!enabled) return;
355
+ const frameWidth = editorMode === 'webpage' ? viewportWidth : SLIDE_WIDTH;
356
+ const defaultHeight = editorMode === 'webpage' ? WEBPAGE_MIN_HEIGHT : SLIDE_HEIGHT;
198
357
 
199
- const contentListKey = artboard.contentList.map(c => c.id).join(',');
200
- if (contentListKey === prevContentListRef.current) return;
201
- prevContentListRef.current = contentListKey;
358
+ // ---- フレーム(寸法・本文)
359
+ const [framesMap, setFramesMap] = useState<Map<string, PageFrame>>(() => new Map());
360
+ const framesMapRef = useRef(framesMap);
361
+ framesMapRef.current = framesMap;
202
362
 
203
- setPagesMap(prev => {
204
- const newMap = new Map<string, PageFrame>();
205
- artboard.contentList.forEach(content => {
363
+ // contentList からフレームを同期(既存の実測・本文は保つ)
364
+ useEffect(() => {
365
+ if (!enabled) return;
366
+ setFramesMap((prev) => {
367
+ const next = new Map<string, PageFrame>();
368
+ let changed = prev.size !== artboard.contentList.length;
369
+ artboard.contentList.forEach((content) => {
206
370
  const existing = prev.get(content.id);
207
371
  if (existing) {
208
- newMap.set(content.id, {
209
- ...existing,
210
- title: content.title,
211
- html: content.thumbnailHtml || existing.html,
212
- thumbnailHtml: content.thumbnailHtml || existing.thumbnailHtml,
213
- });
372
+ const title = content.title || existing.title;
373
+ const html = existing.html ?? content.thumbnailHtml ?? null;
374
+ const width = frameWidth;
375
+ if (title !== existing.title || html !== existing.html || width !== existing.size.width) changed = true;
376
+ next.set(content.id, { ...existing, title, html, size: { width, height: existing.size.height } });
214
377
  } else {
215
- newMap.set(content.id, {
378
+ changed = true;
379
+ next.set(content.id, {
216
380
  id: content.id,
217
- title: content.title,
218
- position: { x: 0, y: 0 },
219
- size: { width: WEBPAGE_WIDTH, height: WEBPAGE_MIN_HEIGHT },
220
- thumbnailHtml: content.thumbnailHtml || '',
221
- thumbnailDataUrl: null,
381
+ title: content.title || '',
382
+ html: content.thumbnailHtml ?? null,
383
+ loading: false,
384
+ error: null,
385
+ size: { width: frameWidth, height: defaultHeight },
386
+ measured: editorMode === 'slide',
222
387
  isDirty: false,
223
- html: content.thumbnailHtml || '',
224
388
  });
225
389
  }
226
390
  });
227
- return newMap;
228
- });
229
- setIsLayoutInitialized(false);
230
- }, [enabled, artboard.contentList]);
231
-
232
- // 初期レイアウト
233
- useEffect(() => {
234
- if (!enabled || isLayoutInitialized || pagesMap.size === 0) return;
235
- setPagesMap(prev => {
236
- const pages = Array.from(prev.values());
237
- const laid = calculateAutoLayout(pages);
238
- const newMap = new Map<string, PageFrame>();
239
- laid.forEach(p => newMap.set(p.id, p));
240
- return newMap;
391
+ return changed ? next : prev;
241
392
  });
242
- setIsLayoutInitialized(true);
243
- }, [enabled, isLayoutInitialized, pagesMap.size]);
244
-
245
- // 初回: 最初のページを自動アクティブ化
246
- useEffect(() => {
247
- if (!enabled || viewState.activePageId) return;
248
- if (artboard.contentList.length > 0) {
249
- const firstId = artboard.contentList[0].id;
250
- setViewState(prev => ({ ...prev, activePageId: firstId }));
251
- }
252
- }, [enabled, artboard.contentList, viewState.activePageId]);
253
-
254
- // ページ配列(順序付き)
255
- const pages = useMemo(() => {
256
- const contentOrder = artboard.contentList.map(c => c.id);
257
- return Array.from(pagesMap.values()).sort(
258
- (a, b) => contentOrder.indexOf(a.id) - contentOrder.indexOf(b.id)
259
- );
260
- }, [pagesMap, artboard.contentList]);
261
-
262
- // --- コールバック ---
263
-
264
- const getPage = useCallback((id: string) => pagesMap.get(id), [pagesMap]);
265
-
266
- const updatePageFrame = useCallback((id: string, updates: Partial<PageFrame>) => {
267
- setPagesMap(prev => {
393
+ }, [enabled, artboard.contentList, frameWidth, defaultHeight, editorMode]);
394
+
395
+ const { pages, bounds } = useMemo(() => {
396
+ const order = artboard.contentList.map((c) => c.id);
397
+ const frames = order.map((id) => framesMap.get(id)).filter((f): f is PageFrame => !!f);
398
+ return layoutFrames(frames, editorMode);
399
+ }, [framesMap, artboard.contentList, editorMode]);
400
+ const pagesRef = useRef(pages);
401
+ pagesRef.current = pages;
402
+ const boundsRef = useRef(bounds);
403
+ boundsRef.current = bounds;
404
+
405
+ const getPage = useCallback((id: string) => pagesRef.current.find((p) => p.id === id), []);
406
+
407
+ const updatePageFrame = useCallback<MultiPageCanvasContextValue['updatePageFrame']>((id, updates) => {
408
+ setFramesMap((prev) => {
268
409
  const existing = prev.get(id);
269
410
  if (!existing) return prev;
270
- const newMap = new Map(prev);
271
- newMap.set(id, { ...existing, ...updates });
272
- return newMap;
411
+ const merged = { ...existing, ...updates };
412
+ // 変わっていなければ参照を保つ(レイアウト再計算・再描画を起こさない)
413
+ const same = (Object.keys(updates) as (keyof typeof updates)[]).every((k) => {
414
+ const a = existing[k];
415
+ const b = merged[k];
416
+ if (k === 'size') {
417
+ const sa = a as PageFrame['size'];
418
+ const sb = b as PageFrame['size'];
419
+ return sa.width === sb.width && sa.height === sb.height;
420
+ }
421
+ return a === b;
422
+ });
423
+ if (same) return prev;
424
+ const next = new Map(prev);
425
+ next.set(id, merged);
426
+ return next;
273
427
  });
274
428
  }, []);
275
429
 
276
- const setCanvasOffset = useCallback((offset: { x: number; y: number }) => {
277
- setViewState(prev => ({ ...prev, canvasOffset: offset }));
278
- }, []);
279
-
280
- const setCanvasZoom = useCallback((zoom: number) => {
281
- setViewState(prev => ({
282
- ...prev,
283
- canvasZoom: Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom)),
284
- }));
285
- }, []);
286
-
287
- // Stale closure回避用ref
288
- const activePageIdRef = useRef<string | null>(null);
289
- activePageIdRef.current = viewState.activePageId;
290
- const pagesMapRef = useRef(pagesMap);
291
- pagesMapRef.current = pagesMap;
292
-
293
- /**
294
- * ページ切替(Figmaスタイル)
295
- * 1. 現在のページのHTMLをサムネイルに保存
296
- * 2. 新しいページのキャッシュHTMLをiframeにロード
297
- * 3. 履歴クリア
298
- * 4. activePageId更新
299
- * 5. API経由で最新HTML非同期取得
300
- */
301
- const switchPage = useCallback((id: string) => {
302
- const currentPagesMap = pagesMapRef.current;
303
- const page = currentPagesMap.get(id);
304
- if (!page) {
305
- console.warn('[MultiPageCanvas] switchPage: page not found:', id);
306
- return;
307
- }
308
-
309
- const currentActiveId = activePageIdRef.current;
310
- if (currentActiveId === id) return; // 同じページなら何もしない
430
+ const setPageHtml = useCallback(
431
+ (id: string, html: string) => updatePageFrame(id, { html, loading: false, error: null }),
432
+ [updatePageFrame],
433
+ );
311
434
 
312
- // 1. 現在のページのHTMLをサムネイルに保存
313
- if (currentActiveId) {
314
- const currentEditHtml = history.html;
315
- if (currentEditHtml) {
316
- updatePageFrame(currentActiveId, { thumbnailHtml: currentEditHtml });
435
+ // ---- ビュー状態(外部ストア。ズーム・パンで Context の値は変えない)
436
+ const persisted = useMemo(() => readPersistedView(storageKey), [storageKey]);
437
+ const [viewStore] = useState<CanvasViewStore>(() =>
438
+ createViewStore({
439
+ canvasOffset: persisted?.offset ?? { x: 0, y: 0 },
440
+ canvasZoom: persisted ? clampZoom(persisted.zoom) : 0.25,
441
+ activePageId: artboard.currentContentId ?? null,
442
+ }),
443
+ );
444
+ const [rulersVisible, setRulersVisible] = useState<boolean>(persisted?.rulers ?? true);
445
+ const rulersRef = useRef(rulersVisible);
446
+ rulersRef.current = rulersVisible;
447
+
448
+ const setPageHeight = useCallback<MultiPageCanvasContextValue['setPageHeight']>(
449
+ (id, height, source) => {
450
+ if (!Number.isFinite(height) || height <= 0) return;
451
+ if (source === 'preview' && viewStore.get().activePageId === id) {
452
+ // 生きているページはエディタの実測が正。ただし一度も測っていなければ受ける
453
+ const frame = framesMapRef.current.get(id);
454
+ if (frame?.measured) return;
317
455
  }
318
- }
456
+ const frame = framesMapRef.current.get(id);
457
+ if (!frame) return;
458
+ const h = Math.max(200, Math.round(height));
459
+ if (frame.measured && Math.abs(frame.size.height - h) < 1) return;
460
+ updatePageFrame(id, { size: { width: frame.size.width, height: h }, measured: true });
461
+ },
462
+ [updatePageFrame, viewStore],
463
+ );
319
464
 
320
- // 2. 新しいページのHTMLをセット
321
- const newHtml = page.thumbnailHtml || '';
322
- history.setHtml(newHtml);
323
- history.clearHistory();
324
-
325
- // 3. iframeを新しいHTMLでリロード
326
- const iframe = refs.iframeRef.current;
327
- if (iframe) {
328
- refs.setIframeReady(false);
329
- const blob = new Blob([generateEditableHtml(newHtml, 'webpage')], { type: 'text/html' });
330
- iframe.src = URL.createObjectURL(blob);
331
- }
465
+ // 生きているページは利用側(currentContentId)と揃える。
466
+ // 本文が読めて currentContentId が変わったときに初めてエディタが移る
467
+ // (クリックの時点では移さない。移すと前のページの本文が新しい枠に見えてしまう)
468
+ useEffect(() => {
469
+ if (!enabled) return;
470
+ const id = artboard.currentContentId ?? artboard.contentList[0]?.id ?? null;
471
+ viewStore.set((prev) => (prev.activePageId === id ? prev : { ...prev, activePageId: id }));
472
+ }, [enabled, artboard.currentContentId, artboard.contentList, viewStore]);
332
473
 
333
- // 4. activePageId更新
334
- setViewState(prev => ({ ...prev, activePageId: id }));
474
+ // 見えている範囲の保存(変化が止まってから)
475
+ useEffect(() => {
476
+ if (!storageKey || typeof localStorage === 'undefined') return;
477
+ let timer: ReturnType<typeof setTimeout> | null = null;
478
+ const persist = () => {
479
+ if (timer) clearTimeout(timer);
480
+ timer = setTimeout(() => {
481
+ timer = null;
482
+ try {
483
+ const { canvasZoom, canvasOffset } = viewStore.get();
484
+ const v: PersistedView = { zoom: canvasZoom, offset: canvasOffset, rulers: rulersRef.current };
485
+ localStorage.setItem(`gg-editor:canvas-view:${storageKey}`, JSON.stringify(v));
486
+ } catch {
487
+ /* 保存できなくても表示には影響しない */
488
+ }
489
+ }, 250);
490
+ };
491
+ const unsubscribe = viewStore.subscribe(persist);
492
+ persist();
493
+ return () => {
494
+ unsubscribe();
495
+ if (timer) clearTimeout(timer);
496
+ };
497
+ }, [storageKey, viewStore, rulersVisible]);
498
+
499
+ // ---- 容器
500
+ const containerRef = useRef<HTMLDivElement | null>(null);
501
+ const registerContainer = useCallback((el: HTMLDivElement | null) => {
502
+ containerRef.current = el;
503
+ }, []);
504
+ const containerSize = () => {
505
+ const el = containerRef.current;
506
+ if (!el) return { width: 1200, height: 800 };
507
+ return { width: el.clientWidth || 1200, height: el.clientHeight || 800 };
508
+ };
335
509
 
336
- // 5. API経由で最新HTMLを非同期取得
337
- if (artboard.onContentChange && id !== artboard.currentContentId) {
338
- artboard.onContentChange(id);
510
+ // ---- アニメーション
511
+ const animationRef = useRef<number | null>(null);
512
+ const cancelAnimation = () => {
513
+ if (animationRef.current != null) {
514
+ cancelAnimationFrame(animationRef.current);
515
+ animationRef.current = null;
339
516
  }
517
+ };
340
518
 
341
- console.log('[MultiPageCanvas] switchPage:', currentActiveId, '→', id);
342
- }, [history, refs, artboard, updatePageFrame]);
343
-
344
- /**
345
- * 軽量ページフォーカス(キャンバスモード専用)
346
- * iframeを再読込せず、activePageIdとcontentIdのみ更新
347
- * → レイヤーパネル・CSS/JS・ページ設定が正しいページに切り替わる
348
- */
349
- const focusPage = useCallback((id: string) => {
350
- const currentActiveId = activePageIdRef.current;
351
- if (currentActiveId === id) return;
352
-
353
- // activePageId更新 → レイヤーパネルが正しいページを表示
354
- setViewState(prev => ({ ...prev, activePageId: id }));
355
-
356
- // contentId更新 → CSS/JS/ページ設定が正しいページから読み込まれる
357
- if (artboard.onContentChange && id !== artboard.currentContentId) {
358
- artboard.onContentChange(id);
519
+ const setView = useCallback<MultiPageCanvasContextValue['setView']>((view, options) => {
520
+ cancelAnimation();
521
+ const target = { zoom: clampZoom(view.zoom), offset: view.offset };
522
+ if (!options?.animate || prefersReducedMotion()) {
523
+ viewStore.set((prev) => ({ ...prev, canvasZoom: target.zoom, canvasOffset: target.offset }));
524
+ return;
359
525
  }
526
+ const from = { zoom: viewStore.get().canvasZoom, offset: viewStore.get().canvasOffset };
527
+ const start = performance.now();
528
+ const tick = (now: number) => {
529
+ const t = Math.min(1, (now - start) / VIEW_ANIMATION_MS);
530
+ const k = easeOutCubic(t);
531
+ // 倍率は対数で補間する(拡大と縮小で速さが揃う)
532
+ const zoom = Math.exp(Math.log(from.zoom) + (Math.log(target.zoom) - Math.log(from.zoom)) * k);
533
+ const offset = {
534
+ x: from.offset.x + (target.offset.x - from.offset.x) * k,
535
+ y: from.offset.y + (target.offset.y - from.offset.y) * k,
536
+ };
537
+ viewStore.set((prev) => ({ ...prev, canvasZoom: zoom, canvasOffset: offset }));
538
+ if (t < 1) animationRef.current = requestAnimationFrame(tick);
539
+ else animationRef.current = null;
540
+ };
541
+ animationRef.current = requestAnimationFrame(tick);
542
+ }, [viewStore]);
543
+ useEffect(() => () => cancelAnimation(), []);
360
544
 
361
- console.log('[MultiPageCanvas] focusPage:', currentActiveId, '→', id);
362
- }, [artboard]);
545
+ const setCanvasOffset = useCallback((offset: { x: number; y: number }) => {
546
+ cancelAnimation();
547
+ viewStore.set((prev) => ({ ...prev, canvasOffset: offset }));
548
+ }, [viewStore]);
363
549
 
364
- const autoLayoutPages = useCallback(() => {
365
- setPagesMap(prev => {
366
- const pageArray = Array.from(prev.values());
367
- const laid = calculateAutoLayout(pageArray);
368
- const newMap = new Map<string, PageFrame>();
369
- laid.forEach(p => newMap.set(p.id, p));
370
- return newMap;
550
+ const setCanvasZoom = useCallback((zoom: number) => {
551
+ cancelAnimation();
552
+ viewStore.set((prev) => ({ ...prev, canvasZoom: clampZoom(zoom) }));
553
+ }, [viewStore]);
554
+
555
+ const zoomAt = useCallback<MultiPageCanvasContextValue['zoomAt']>((zoom, point) => {
556
+ cancelAnimation();
557
+ viewStore.set((prev) => {
558
+ const next = clampZoom(zoom);
559
+ if (next === prev.canvasZoom) return prev;
560
+ const ratio = next / prev.canvasZoom;
561
+ return {
562
+ ...prev,
563
+ canvasZoom: next,
564
+ canvasOffset: {
565
+ x: point.x - (point.x - prev.canvasOffset.x) * ratio,
566
+ y: point.y - (point.y - prev.canvasOffset.y) * ratio,
567
+ },
568
+ };
371
569
  });
372
- }, []);
570
+ }, [viewStore]);
571
+
572
+ const zoomTo = useCallback<MultiPageCanvasContextValue['zoomTo']>(
573
+ (zoom, options) => {
574
+ const { width, height } = containerSize();
575
+ const inset = rulersRef.current ? RULER_SIZE : 0;
576
+ const center = { x: inset + (width - inset) / 2, y: inset + (height - inset) / 2 };
577
+ const prev = viewStore.get();
578
+ const next = clampZoom(zoom);
579
+ const ratio = next / prev.canvasZoom;
580
+ setView(
581
+ {
582
+ zoom: next,
583
+ offset: {
584
+ x: center.x - (center.x - prev.canvasOffset.x) * ratio,
585
+ y: center.y - (center.y - prev.canvasOffset.y) * ratio,
586
+ },
587
+ },
588
+ options,
589
+ );
590
+ },
591
+ [setView, viewStore],
592
+ );
373
593
 
374
- const zoomToFit = useCallback((containerWidth: number, containerHeight: number) => {
375
- const pageArray = Array.from(pagesMap.values());
376
- const { zoom, offset } = calculateFitZoom(pageArray, containerWidth, containerHeight);
377
- setViewState(prev => ({
378
- ...prev,
379
- canvasZoom: zoom,
380
- canvasOffset: offset,
381
- }));
382
- }, [pagesMap]);
383
-
384
- const zoomIn = useCallback(() => {
385
- setViewState(prev => ({
386
- ...prev,
387
- canvasZoom: Math.min(MAX_ZOOM, prev.canvasZoom * 1.25),
388
- }));
389
- }, []);
594
+ const zoomIn = useCallback(() => zoomTo(viewStore.get().canvasZoom * ZOOM_STEP, { animate: true }), [zoomTo, viewStore]);
595
+ const zoomOut = useCallback(() => zoomTo(viewStore.get().canvasZoom / ZOOM_STEP, { animate: true }), [zoomTo, viewStore]);
596
+ const zoomToActual = useCallback(() => zoomTo(1, { animate: true }), [zoomTo]);
390
597
 
391
- const zoomOut = useCallback(() => {
392
- setViewState(prev => ({
393
- ...prev,
394
- canvasZoom: Math.max(MIN_ZOOM, prev.canvasZoom * 0.8),
395
- }));
396
- }, []);
598
+ const zoomToFit = useCallback<MultiPageCanvasContextValue['zoomToFit']>(
599
+ (options) => {
600
+ if (!pagesRef.current.length) return;
601
+ setView(fitView(boundsRef.current, containerSize(), { padding: FIT_PADDING, maxZoom: 1, rulers: rulersRef.current }), options);
602
+ },
603
+ [setView],
604
+ );
397
605
 
398
- const updateThumbnail = useCallback((id: string, dataUrl: string) => {
399
- setPagesMap(prev => {
400
- const existing = prev.get(id);
401
- if (!existing) return prev;
402
- const newMap = new Map(prev);
403
- newMap.set(id, { ...existing, thumbnailDataUrl: dataUrl });
404
- return newMap;
405
- });
406
- }, []);
606
+ const zoomToPage = useCallback<MultiPageCanvasContextValue['zoomToPage']>(
607
+ (id, options) => {
608
+ const page = pagesRef.current.find((p) => p.id === id);
609
+ if (!page) return;
610
+ const rect = {
611
+ minX: page.position.x,
612
+ minY: page.position.y,
613
+ maxX: page.position.x + page.size.width,
614
+ maxY: page.position.y + page.size.height,
615
+ };
616
+ const { width, height } = containerSize();
617
+ // 長いページは全体を収めると小さすぎる。幅を合わせて先頭を見せる(Figma のフレーム選択と同じ感覚)
618
+ const fitted = fitView(rect, { width, height }, { padding: FIT_PADDING, maxZoom: 1, rulers: rulersRef.current });
619
+ const inset = rulersRef.current ? RULER_SIZE : 0;
620
+ const widthZoom = Math.min(1, (width - inset - FIT_PADDING * 2) / page.size.width);
621
+ if (fitted.zoom < widthZoom * 0.6) {
622
+ const zoom = clampZoom(widthZoom);
623
+ setView(
624
+ {
625
+ zoom,
626
+ offset: {
627
+ x: inset + (width - inset - page.size.width * zoom) / 2 - page.position.x * zoom,
628
+ y: inset + FIT_PADDING - page.position.y * zoom,
629
+ },
630
+ },
631
+ options,
632
+ );
633
+ return;
634
+ }
635
+ setView(fitted, options);
636
+ },
637
+ [setView],
638
+ );
407
639
 
408
- // --- グローバルUndo/Redoハンドラー用のState ---
409
- const [undoHandlers, setUndoHandlers] = useState({
410
- undo: () => {},
411
- redo: () => {},
412
- canUndo: false,
413
- canRedo: false,
414
- });
640
+ const revealPage = useCallback<MultiPageCanvasContextValue['revealPage']>(
641
+ (id) => {
642
+ const page = pagesRef.current.find((p) => p.id === id);
643
+ if (!page) return;
644
+ const { width, height } = containerSize();
645
+ const { canvasZoom: z, canvasOffset: o } = viewStore.get();
646
+ const inset = rulersRef.current ? RULER_SIZE : 0;
647
+ const left = o.x + page.position.x * z;
648
+ const top = o.y + page.position.y * z;
649
+ const right = left + page.size.width * z;
650
+ const bottom = top + page.size.height * z;
651
+ const margin = 48;
652
+ // 一部でも見えていればそのまま。見えていなければ、そのフレームの左上を画面へ寄せる
653
+ const visible = right > inset + margin && left < width - margin && bottom > inset + margin && top < height - margin;
654
+ if (visible) return;
655
+ const w = page.size.width * z;
656
+ const h = page.size.height * z;
657
+ const offset = {
658
+ x: w <= width - inset ? inset + (width - inset - w) / 2 - page.position.x * z : inset + margin - page.position.x * z,
659
+ y: h <= height - inset ? inset + (height - inset - h) / 2 - page.position.y * z : inset + margin - page.position.y * z,
660
+ };
661
+ setView({ zoom: z, offset }, { animate: true });
662
+ },
663
+ [setView, viewStore],
664
+ );
415
665
 
416
- const registerUndoHandlers = useCallback((handlers: { undo: () => void; redo: () => void; canUndo: () => boolean; canRedo: () => boolean }) => {
417
- // 参照を保持し、再レンダリングをトリガーするためにはcanUndo()の状態を追跡する必要があるが、
418
- // ここでは簡易的に関数のみ登録し、判定は都度させる(必要に応じてフラグ化)
419
- setUndoHandlers({
420
- undo: handlers.undo,
421
- redo: handlers.redo,
422
- // React state is not ideal for this real-time check inside Toolbar, but we pass getters for now
423
- canUndo: handlers.canUndo(),
424
- canRedo: handlers.canRedo(),
425
- });
666
+ // ---- 本文の読み込み
667
+ const loadingRef = useRef(new Set<string>());
668
+ const ensurePageHtml = useCallback<MultiPageCanvasContextValue['ensurePageHtml']>(
669
+ async (id) => {
670
+ const frame = framesMapRef.current.get(id);
671
+ if (!frame || frame.html != null || loadingRef.current.has(id)) return;
672
+ const fromList = artboard.contentList.find((c) => c.id === id)?.thumbnailHtml;
673
+ if (fromList != null) {
674
+ setPageHtml(id, fromList);
675
+ return;
676
+ }
677
+ const loader = io().loadContent;
678
+ if (!loader) return;
679
+ loadingRef.current.add(id);
680
+ updatePageFrame(id, { loading: true, error: null });
681
+ try {
682
+ const html = await loader(id);
683
+ setPageHtml(id, html ?? '');
684
+ } catch (e) {
685
+ updatePageFrame(id, { loading: false, error: e instanceof Error ? e.message : String(e) });
686
+ } finally {
687
+ loadingRef.current.delete(id);
688
+ }
689
+ },
690
+ [artboard.contentList, setPageHtml, updatePageFrame],
691
+ );
426
692
 
427
- // To dynamically update Toolbar when stacks change, we'd need MultiPageCanvasView to call this repeatedly,
428
- // or expose them as refs. For now, Toolbar evaluates button state on render or we expose static getters.
429
- // Let's modify the interface to use getters/setters properly if needed, but for now just pass the object.
430
- }, []);
693
+ // ---- 見るだけの紙面のスタイル(利用側から 1 回だけ取る)
694
+ const [previewStyles, setPreviewStyles] = useState<PreviewStyle[]>([]);
695
+ useEffect(() => {
696
+ if (!enabled) return;
697
+ const loader = io().previewStyles;
698
+ if (!loader) return;
699
+ let alive = true;
700
+ Promise.resolve()
701
+ .then(() => loader())
702
+ .then((styles) => { if (alive && Array.isArray(styles)) setPreviewStyles(styles); })
703
+ .catch((e) => console.warn('[MultiPageCanvas] previewStyles を取れませんでした:', e));
704
+ return () => { alive = false; };
705
+ }, [enabled]);
706
+
707
+ // ---- ページ切替
708
+ // 保存と読み込みは利用側(FrontendVisualEditor.handleContentChange)が 1 か所で行う。
709
+ // ここでは「どこへ移ろうとしているか」を持ち、リングを先に動かして押した手応えを返す
710
+ const [activatingPageId, setActivatingPageId] = useState<string | null>(null);
711
+ const onContentChangeRef = useRef(artboard.onContentChange);
712
+ onContentChangeRef.current = artboard.onContentChange;
713
+ const activatePage = useCallback<MultiPageCanvasContextValue['activatePage']>(
714
+ async (id, options) => {
715
+ if (!framesMapRef.current.has(id)) return false;
716
+ if (viewStore.get().activePageId === id) {
717
+ if (options?.reveal) revealPage(id);
718
+ return true;
719
+ }
720
+ const notify = onContentChangeRef.current;
721
+ if (!notify) return false;
722
+ setActivatingPageId(id);
723
+ if (options?.reveal) revealPage(id);
724
+ try {
725
+ const result = await (notify as (contentId: string) => unknown)(id);
726
+ return result !== false;
727
+ } finally {
728
+ setActivatingPageId((v) => (v === id ? null : v));
729
+ }
730
+ },
731
+ [revealPage, viewStore],
732
+ );
431
733
 
432
- // --- Context値 ---
433
-
434
- const value = useMemo<MultiPageCanvasContextValue>(() => ({
435
- pages,
436
- getPage,
437
- updatePageFrame,
438
- viewState,
439
- setCanvasOffset,
440
- setCanvasZoom,
441
- switchPage,
442
- focusPage,
443
- autoLayoutPages,
444
- zoomToFit,
445
- zoomIn,
446
- zoomOut,
447
- updateThumbnail,
448
- isEnabled: enabled,
449
- undo: undoHandlers.undo,
450
- redo: undoHandlers.redo,
451
- canUndo: undoHandlers.canUndo,
452
- canRedo: undoHandlers.canRedo,
453
- registerUndoHandlers,
454
- }), [
455
- pages,
456
- getPage,
457
- updatePageFrame,
458
- viewState,
459
- setCanvasOffset,
460
- setCanvasZoom,
461
- switchPage,
462
- focusPage,
463
- autoLayoutPages,
464
- zoomToFit,
465
- zoomIn,
466
- zoomOut,
467
- updateThumbnail,
468
- enabled,
469
- undoHandlers,
470
- registerUndoHandlers,
471
- ]);
734
+ // ---- 操作状態
735
+ const [isInteracting, setIsInteracting] = useState(false);
736
+ const interactingTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
737
+ const markInteracting = useCallback(() => {
738
+ setIsInteracting(true);
739
+ if (interactingTimer.current) clearTimeout(interactingTimer.current);
740
+ interactingTimer.current = setTimeout(() => setIsInteracting(false), 160);
741
+ }, []);
742
+ useEffect(() => () => { if (interactingTimer.current) clearTimeout(interactingTimer.current); }, []);
743
+
744
+ const toggleRulers = useCallback(() => setRulersVisible((v) => !v), []);
745
+
746
+ // ---- 見るだけの紙面の読み込み枠(同時に PREVIEW_PARALLEL 枚まで。編集中のページに近い順)
747
+ const slotQueueRef = useRef<{ id: string; grant: () => void }[]>([]);
748
+ const slotHoldersRef = useRef(new Set<string>());
749
+ const pumpPreviewSlots = useCallback(() => {
750
+ const holders = slotHoldersRef.current;
751
+ const queue = slotQueueRef.current;
752
+ if (holders.size >= PREVIEW_PARALLEL || queue.length === 0) return;
753
+ const indexById = new Map(pagesRef.current.map((p) => [p.id, p.index] as const));
754
+ const indexOf = (id: string) => indexById.get(id) ?? 0;
755
+ const activeId = viewStore.get().activePageId;
756
+ const activeIndex = activeId ? indexOf(activeId) : 0;
757
+ queue.sort((a, b) => Math.abs(indexOf(a.id) - activeIndex) - Math.abs(indexOf(b.id) - activeIndex));
758
+ while (holders.size < PREVIEW_PARALLEL && queue.length > 0) {
759
+ const next = queue.shift()!;
760
+ holders.add(next.id);
761
+ next.grant();
762
+ }
763
+ }, [viewStore]);
764
+ const requestPreviewSlot = useCallback<MultiPageCanvasContextValue['requestPreviewSlot']>(
765
+ (id, grant) => {
766
+ if (slotHoldersRef.current.has(id)) {
767
+ grant();
768
+ return;
769
+ }
770
+ if (!slotQueueRef.current.some((q) => q.id === id)) slotQueueRef.current.push({ id, grant });
771
+ pumpPreviewSlots();
772
+ },
773
+ [pumpPreviewSlots],
774
+ );
775
+ const releasePreviewSlot = useCallback<MultiPageCanvasContextValue['releasePreviewSlot']>(
776
+ (id) => {
777
+ slotQueueRef.current = slotQueueRef.current.filter((q) => q.id !== id);
778
+ if (slotHoldersRef.current.delete(id)) pumpPreviewSlots();
779
+ },
780
+ [pumpPreviewSlots],
781
+ );
472
782
 
473
- return (
474
- <MultiPageCanvasContext.Provider value={value}>
475
- {children}
476
- </MultiPageCanvasContext.Provider>
783
+ const value = useMemo<MultiPageCanvasContextValue>(
784
+ () => ({
785
+ isEnabled: enabled,
786
+ editorMode,
787
+ pages,
788
+ bounds,
789
+ getPage,
790
+ updatePageFrame,
791
+ setPageHtml,
792
+ setPageHeight,
793
+ ensurePageHtml,
794
+ previewStyles,
795
+ viewStore,
796
+ setCanvasOffset,
797
+ setCanvasZoom,
798
+ setView,
799
+ zoomAt,
800
+ zoomTo,
801
+ zoomIn,
802
+ zoomOut,
803
+ zoomToFit,
804
+ zoomToPage,
805
+ zoomToActual,
806
+ revealPage,
807
+ activatePage,
808
+ activatingPageId,
809
+ registerContainer,
810
+ containerRef,
811
+ isInteracting,
812
+ markInteracting,
813
+ initialViewRestored: !!persisted,
814
+ rulersVisible,
815
+ toggleRulers,
816
+ requestPreviewSlot,
817
+ releasePreviewSlot,
818
+ }),
819
+ [
820
+ enabled, editorMode, pages, bounds, getPage, updatePageFrame, setPageHtml, setPageHeight, ensurePageHtml,
821
+ previewStyles, viewStore, setCanvasOffset, setCanvasZoom, setView, zoomAt, zoomTo, zoomIn, zoomOut, zoomToFit, zoomToPage,
822
+ zoomToActual, revealPage, activatePage, activatingPageId, registerContainer, isInteracting, markInteracting,
823
+ persisted, rulersVisible, toggleRulers, requestPreviewSlot, releasePreviewSlot,
824
+ ],
477
825
  );
826
+
827
+ return <MultiPageCanvasContext.Provider value={value}>{children}</MultiPageCanvasContext.Provider>;
478
828
  }
479
829
 
480
830
  /**
@@ -482,16 +832,18 @@ export function MultiPageCanvasProvider({
482
832
  */
483
833
  export function ConditionalMultiPageProvider({
484
834
  enabled,
835
+ storageKey,
485
836
  children,
486
837
  }: {
487
838
  enabled: boolean;
839
+ storageKey?: string | null;
488
840
  children: React.ReactNode;
489
841
  }) {
490
842
  if (!enabled) {
491
843
  return <>{children}</>;
492
844
  }
493
845
  return (
494
- <MultiPageCanvasProvider enabled={enabled}>
846
+ <MultiPageCanvasProvider enabled={enabled} storageKey={storageKey}>
495
847
  {children}
496
848
  </MultiPageCanvasProvider>
497
849
  );