@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
@@ -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
- import { useEditorArtboard } from './EditorArtboardContext';
21
- import { useEditorHistory } from './EditorHistoryContext';
22
- import { useEditorRefs } from './EditorRefsContext';
23
- import { generateEditableHtml } from '../utils/html-utils';
37
+ import { useEditorArtboard, computeContentDepths, type DocumentAttributes } from './EditorArtboardContext';
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,141 @@ 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
+ /** 親ページ(contentList[].parentId。一覧に無い親は null 扱い) */
97
+ parentId: string | null;
98
+ /** 階層の深さ(ルート = 0) */
99
+ depth: number;
100
+ /** 編集しない表示の URL(contentList[].href) */
101
+ href?: string;
102
+ /** 利用側が進める本文の版(contentList[].revision)。変わったら読み直す */
103
+ revision: number;
104
+ /**
105
+ * 本文が古い(部品の反映などで外から変わった)。読み直すときは contentList[].thumbnailHtml
106
+ * ではなく io.loadContent から取る(thumbnailHtml は利用側が最初に渡した姿のままなので)
107
+ */
108
+ stale: boolean;
52
109
  }
53
110
 
111
+ /** レイアウト済み(位置付き)のフレーム */
112
+ export interface PageFrameLayout extends PageFrame {
113
+ position: { x: number; y: number };
114
+ index: number;
115
+ }
116
+
117
+ /** フレームの並べ方。tree = 階層(parentId)のツリー、grid = 行で折り返す */
118
+ export type CanvasLayoutKind = 'grid' | 'tree';
119
+
54
120
  export interface CanvasViewState {
55
121
  canvasOffset: { x: number; y: number };
56
122
  canvasZoom: number;
123
+ /** 生きているエディタが載っているページ(利用側の currentContentId と同じ) */
57
124
  activePageId: string | null;
58
125
  }
59
126
 
60
- export interface MultiPageCanvasContextValue {
61
- // ページフレーム管理
62
- pages: PageFrame[];
63
- getPage: (id: string) => PageFrame | undefined;
64
- updatePageFrame: (id: string, updates: Partial<PageFrame>) => void;
127
+ export interface CanvasBounds {
128
+ minX: number;
129
+ minY: number;
130
+ maxX: number;
131
+ maxY: number;
132
+ }
133
+
134
+ /** ビュー状態の外部ストア(購読した部品だけが描き直る) */
135
+ export interface CanvasViewStore {
136
+ get: () => CanvasViewState;
137
+ set: (updater: (prev: CanvasViewState) => CanvasViewState) => void;
138
+ subscribe: (listener: () => void) => () => void;
139
+ }
65
140
 
66
- // ビュー状態
67
- viewState: CanvasViewState;
141
+ export interface MultiPageCanvasContextValue {
142
+ isEnabled: boolean;
143
+ editorMode: EditorMode;
144
+
145
+ // ---- ページフレーム
146
+ pages: PageFrameLayout[];
147
+ bounds: CanvasBounds;
148
+ /** 並べ方(contentList に階層があれば tree) */
149
+ layout: CanvasLayoutKind;
150
+ getPage: (id: string) => PageFrameLayout | undefined;
151
+ updatePageFrame: (id: string, updates: Partial<Omit<PageFrame, 'id'>>) => void;
152
+ /** 本文を差し替える(保存後・読み込み後) */
153
+ setPageHtml: (id: string, html: string) => void;
154
+ /**
155
+ * 見るだけの紙面の本文を捨てて読み直させる(部品の定義を更新して他ページが変わったとき等)。
156
+ * except のページ(編集中のページ)は触らない。読み直す手段(io.loadContent)が無ければ何もしない
157
+ */
158
+ invalidatePages: (options?: { except?: readonly (string | null | undefined)[] }) => void;
159
+ /** 文書(`<html>`)に付ける属性(利用側の documentAttributes)。紙面にも読み直さずに反映する */
160
+ documentAttributes: DocumentAttributes;
161
+ /**
162
+ * 高さの実測を反映する。source が 'preview' のときは、生きているページには効かない
163
+ * (エディタの実測が正。見るだけの紙面はフォントや画像の読み込み前に測ることがある)
164
+ */
165
+ setPageHeight: (id: string, height: number, source: 'editor' | 'preview') => void;
166
+ /** 本文がまだ無ければ読む(contentList の thumbnailHtml → io.loadContent) */
167
+ ensurePageHtml: (id: string) => Promise<void>;
168
+ /** 見るだけの紙面に足すスタイル(io.previewStyles。無ければ空) */
169
+ previewStyles: PreviewStyle[];
170
+
171
+ // ---- ビュー
172
+ /** 倍率・位置。描画に使うなら useCanvasViewState() で購読する。ハンドラから読むだけなら get() */
173
+ viewStore: CanvasViewStore;
68
174
  setCanvasOffset: (offset: { x: number; y: number }) => void;
69
175
  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;
176
+ /** 倍率と位置を同時に決める(アニメーションも可) */
177
+ setView: (view: { zoom: number; offset: { x: number; y: number } }, options?: { animate?: boolean }) => void;
178
+ /** 画面上の点(容器の座標)を固定したまま倍率を変える */
179
+ zoomAt: (zoom: number, point: { x: number; y: number }) => void;
180
+ /** 容器の中心を固定して倍率を変える */
181
+ zoomTo: (zoom: number, options?: { animate?: boolean }) => void;
82
182
  zoomIn: () => void;
83
183
  zoomOut: () => void;
184
+ zoomToFit: (options?: { animate?: boolean }) => void;
185
+ zoomToPage: (id: string, options?: { animate?: boolean }) => void;
186
+ zoomToActual: () => void;
187
+ /** 指定のフレームが画面に入っていなければ、見える位置まで寄せる(倍率は変えない) */
188
+ revealPage: (id: string) => void;
84
189
 
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;
190
+ // ---- ページ切替
191
+ /**
192
+ * 編集するページを移す。中身の読み込みと未保存の変更の保存は利用側
193
+ * (onContentChange → FrontendVisualEditor.handleContentChange)が行う。
194
+ * 生きているエディタは本文が読めてから移る(activePageId はそのとき変わる)。
195
+ * 戻り値は移れたか
196
+ */
197
+ activatePage: (id: string, options?: { reveal?: boolean }) => Promise<boolean>;
198
+ /** 切替の途中(クリックしてから本文が読めるまで)のページ。リングはこちらへ先に移る */
199
+ activatingPageId: string | null;
200
+
201
+ // ---- 容器・操作状態
202
+ /** キャンバスの容器(ズームの基準になる矩形)。MultiPageCanvasView が登録する */
203
+ registerContainer: (el: HTMLDivElement | null) => void;
204
+ containerRef: React.MutableRefObject<HTMLDivElement | null>;
205
+ /** ホイール・ドラッグの最中(見るだけの紙面の当たり判定を切る) */
206
+ isInteracting: boolean;
207
+ markInteracting: () => void;
208
+ /** 見えている範囲を localStorage から復元できた(初回表示で全体表示を省く) */
209
+ initialViewRestored: boolean;
210
+ /** 定規 */
211
+ rulersVisible: boolean;
212
+ toggleRulers: () => void;
213
+ /**
214
+ * 見るだけの紙面の読み込み枠。同時に読むのは数枚まで(順番待ち。編集中のページに近い順)。
215
+ * 150 枚を一度に読むと、接続枠と主スレッドをそれらが占め、編集中のページの切替が
216
+ * 1 秒近く待たされる(実測 82ms → 912ms)
217
+ */
218
+ requestPreviewSlot: (id: string, grant: () => void) => void;
219
+ releasePreviewSlot: (id: string) => void;
97
220
  }
98
221
 
99
222
  // ============================================================
@@ -110,57 +233,203 @@ export function useMultiPageCanvas(): MultiPageCanvasContextValue {
110
233
  return context;
111
234
  }
112
235
 
113
- /**
114
- * マルチページキャンバスが有効な場合のみ値を返す(オプショナル使用用)
115
- */
236
+ /** マルチページキャンバスが有効な場合のみ値を返す(オプショナル使用用) */
116
237
  export function useMultiPageCanvasOptional(): MultiPageCanvasContextValue | null {
117
238
  return useContext(MultiPageCanvasContext);
118
239
  }
119
240
 
241
+ /** 倍率・位置を購読する(ズーム・パンのたびに描き直る部品だけが使う) */
242
+ export function useCanvasViewState(): CanvasViewState {
243
+ const { viewStore } = useMultiPageCanvas();
244
+ return useSyncExternalStore(viewStore.subscribe, viewStore.get, viewStore.get);
245
+ }
246
+
247
+ const NULL_VIEW_STORE: CanvasViewStore = {
248
+ get: () => NULL_VIEW,
249
+ set: () => undefined,
250
+ subscribe: () => () => undefined,
251
+ };
252
+ const NULL_VIEW: CanvasViewState = { canvasOffset: { x: 0, y: 0 }, canvasZoom: 1, activePageId: null };
253
+
254
+ /** キャンバスが無ければ null(単独表示でも同じ部品を使うとき用) */
255
+ export function useCanvasViewStateOptional(): CanvasViewState | null {
256
+ const context = useContext(MultiPageCanvasContext);
257
+ const store = context?.viewStore ?? NULL_VIEW_STORE;
258
+ const state = useSyncExternalStore(store.subscribe, store.get, store.get);
259
+ return context ? state : null;
260
+ }
261
+
120
262
  // ============================================================
121
- // レイアウト計算ユーティリティ
263
+ // レイアウト
122
264
  // ============================================================
123
265
 
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
- }));
266
+ function boundsOf(pages: PageFrameLayout[]): CanvasBounds {
267
+ return pages.length
268
+ ? {
269
+ minX: Math.min(...pages.map((p) => p.position.x)),
270
+ minY: Math.min(...pages.map((p) => p.position.y)),
271
+ maxX: Math.max(...pages.map((p) => p.position.x + p.size.width)),
272
+ maxY: Math.max(...pages.map((p) => p.position.y + p.size.height)),
273
+ }
274
+ : { minX: 0, minY: 0, maxX: 0, maxY: 0 };
133
275
  }
134
276
 
135
- function calculateFitZoom(
136
- pages: PageFrame[],
137
- containerWidth: number,
138
- containerHeight: number
139
- ): { 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);
277
+ /** 行で折り返す並べ方(階層が無いとき)。行の高さはその行で一番高いフレームに合わせる */
278
+ function layoutGrid(frames: PageFrame[], gap: { x: number; y: number }, perRow: number): PageFrameLayout[] {
279
+ const pages: PageFrameLayout[] = [];
280
+ let x = 0;
281
+ let y = 0;
282
+ let rowHeight = 0;
283
+ frames.forEach((frame, index) => {
284
+ if (index > 0 && index % perRow === 0) {
285
+ x = 0;
286
+ y += rowHeight + gap.y;
287
+ rowHeight = 0;
288
+ }
289
+ pages.push({ ...frame, index, position: { x, y } });
290
+ x += frame.size.width + gap.x;
291
+ rowHeight = Math.max(rowHeight, frame.size.height);
148
292
  });
293
+ return pages;
294
+ }
149
295
 
150
- const totalWidth = maxX - minX;
151
- const totalHeight = maxY - minY;
152
- const padding = 80;
296
+ /**
297
+ * 階層(parentId)のツリーで並べる(サイトマップの図と同じ姿)。
298
+ * - 深さごとに行を作り、行の高さはその深さで一番高いフレームに合わせる
299
+ * - 親は子の並び(部分木)の中央の上に置く。部分木の幅 = max(自分の幅, 子の部分木の幅の和 + 間隔)
300
+ * - 親が一覧に無い・循環しているものはルートとして左から順に置く
301
+ * 一覧の順(index)は変えない(フレーム名の番号・読み込みの順番待ちに使う)
302
+ */
303
+ function layoutTree(frames: PageFrame[], gap: { x: number; y: number }): PageFrameLayout[] {
304
+ const byId = new Map(frames.map((f) => [f.id, f] as const));
305
+ const children = new Map<string, PageFrame[]>();
306
+ const roots: PageFrame[] = [];
307
+ // 循環の検出: 親をたどってルートに着くか(着かないものはルート扱い)
308
+ const reachesRoot = (f: PageFrame): boolean => {
309
+ const seen = new Set<string>([f.id]);
310
+ let cur: PageFrame | undefined = f;
311
+ while (cur?.parentId && byId.has(cur.parentId)) {
312
+ if (seen.has(cur.parentId)) return false;
313
+ seen.add(cur.parentId);
314
+ cur = byId.get(cur.parentId);
315
+ }
316
+ return true;
317
+ };
318
+ frames.forEach((f) => {
319
+ const parent = f.parentId && f.parentId !== f.id && byId.has(f.parentId) && reachesRoot(f) ? f.parentId : null;
320
+ if (parent) {
321
+ const list = children.get(parent) ?? [];
322
+ list.push(f);
323
+ children.set(parent, list);
324
+ } else {
325
+ roots.push(f);
326
+ }
327
+ });
328
+ const subtreeWidth = new Map<string, number>();
329
+ const widthOf = (f: PageFrame): number => {
330
+ const cached = subtreeWidth.get(f.id);
331
+ if (cached != null) return cached;
332
+ const kids = children.get(f.id) ?? [];
333
+ const kidsWidth = kids.reduce((sum, k, i) => sum + widthOf(k) + (i > 0 ? gap.x : 0), 0);
334
+ const w = Math.max(f.size.width, kidsWidth);
335
+ subtreeWidth.set(f.id, w);
336
+ return w;
337
+ };
338
+ // 深さと行の高さ
339
+ const depthOf = new Map<string, number>();
340
+ const rowHeight: number[] = [];
341
+ const walk = (f: PageFrame, d: number) => {
342
+ depthOf.set(f.id, d);
343
+ rowHeight[d] = Math.max(rowHeight[d] ?? 0, f.size.height);
344
+ (children.get(f.id) ?? []).forEach((k) => walk(k, d + 1));
345
+ };
346
+ roots.forEach((r) => walk(r, 0));
347
+ const rowY: number[] = [];
348
+ rowHeight.forEach((h, d) => {
349
+ rowY[d] = d === 0 ? 0 : rowY[d - 1] + rowHeight[d - 1] + gap.y;
350
+ });
351
+ const position = new Map<string, { x: number; y: number }>();
352
+ const place = (f: PageFrame, x0: number) => {
353
+ const d = depthOf.get(f.id) ?? 0;
354
+ const w = widthOf(f);
355
+ position.set(f.id, { x: x0 + (w - f.size.width) / 2, y: rowY[d] ?? 0 });
356
+ const kids = children.get(f.id) ?? [];
357
+ const kidsWidth = kids.reduce((sum, k, i) => sum + widthOf(k) + (i > 0 ? gap.x : 0), 0);
358
+ let cx = x0 + (w - kidsWidth) / 2;
359
+ kids.forEach((k) => {
360
+ place(k, cx);
361
+ cx += widthOf(k) + gap.x;
362
+ });
363
+ };
364
+ let x = 0;
365
+ roots.forEach((r) => {
366
+ place(r, x);
367
+ x += widthOf(r) + gap.x;
368
+ });
369
+ return frames.map((f, index) => {
370
+ const p = position.get(f.id) ?? { x: 0, y: 0 };
371
+ return { ...f, index, depth: depthOf.get(f.id) ?? 0, position: { x: Math.round(p.x), y: Math.round(p.y) } };
372
+ });
373
+ }
153
374
 
154
- const scaleX = (containerWidth - padding * 2) / totalWidth;
155
- const scaleY = (containerHeight - padding * 2) / totalHeight;
156
- const zoom = Math.min(scaleX, scaleY, 1);
375
+ /**
376
+ * 寸法から位置を決める。contentList に階層(parentId)があればツリー、無ければ行で折り返す。
377
+ * 位置を状態に持たないので、高さが実測で変わっても流れ直すだけで済む
378
+ */
379
+ export function layoutFrames(
380
+ frames: PageFrame[],
381
+ editorMode: EditorMode,
382
+ ): { pages: PageFrameLayout[]; bounds: CanvasBounds; layout: CanvasLayoutKind } {
383
+ const gap = GAP[editorMode];
384
+ const ids = new Set(frames.map((f) => f.id));
385
+ const hasHierarchy = frames.some((f) => f.parentId && f.parentId !== f.id && ids.has(f.parentId));
386
+ const pages = hasHierarchy ? layoutTree(frames, gap) : layoutGrid(frames, gap, WRAP_AT[editorMode](frames.length));
387
+ return { pages, bounds: boundsOf(pages), layout: hasHierarchy ? 'tree' : 'grid' };
388
+ }
157
389
 
158
- const offset = {
159
- x: (containerWidth - totalWidth * zoom) / 2 - minX * zoom,
160
- y: (containerHeight - totalHeight * zoom) / 2 - minY * zoom,
390
+ function fitView(
391
+ rect: CanvasBounds,
392
+ container: { width: number; height: number },
393
+ options: { padding: number; maxZoom: number; rulers: boolean },
394
+ ): { zoom: number; offset: { x: number; y: number } } {
395
+ const inset = options.rulers ? RULER_SIZE : 0;
396
+ const availW = Math.max(1, container.width - inset - options.padding * 2);
397
+ const availH = Math.max(1, container.height - inset - options.padding * 2);
398
+ const w = Math.max(1, rect.maxX - rect.minX);
399
+ const h = Math.max(1, rect.maxY - rect.minY);
400
+ const zoom = Math.max(MIN_ZOOM, Math.min(options.maxZoom, availW / w, availH / h));
401
+ return {
402
+ zoom,
403
+ offset: {
404
+ x: inset + (container.width - inset - w * zoom) / 2 - rect.minX * zoom,
405
+ y: inset + (container.height - inset - h * zoom) / 2 - rect.minY * zoom,
406
+ },
161
407
  };
408
+ }
162
409
 
163
- return { zoom: Math.max(zoom, MIN_ZOOM), offset };
410
+ const clampZoom = (z: number) => Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, z));
411
+ const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3);
412
+ const prefersReducedMotion = () =>
413
+ typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches;
414
+
415
+ function createViewStore(initial: CanvasViewState): CanvasViewStore {
416
+ let state = initial;
417
+ const listeners = new Set<() => void>();
418
+ return {
419
+ get: () => state,
420
+ set: (updater) => {
421
+ const next = updater(state);
422
+ if (next === state) return;
423
+ state = next;
424
+ listeners.forEach((l) => l());
425
+ },
426
+ subscribe: (listener) => {
427
+ listeners.add(listener);
428
+ return () => {
429
+ listeners.delete(listener);
430
+ };
431
+ },
432
+ };
164
433
  }
165
434
 
166
435
  // ============================================================
@@ -170,311 +439,545 @@ function calculateFitZoom(
170
439
  interface MultiPageCanvasProviderProps {
171
440
  children: React.ReactNode;
172
441
  enabled: boolean;
442
+ /** 見えている範囲を保存するキー(利用側の parentId 等)。無ければ保存しない */
443
+ storageKey?: string | null;
173
444
  }
174
445
 
175
- export function MultiPageCanvasProvider({
176
- children,
177
- enabled,
178
- }: MultiPageCanvasProviderProps) {
446
+ type PersistedView = { zoom: number; offset: { x: number; y: number }; rulers?: boolean };
447
+
448
+ function readPersistedView(key: string | null | undefined): PersistedView | null {
449
+ if (!key || typeof localStorage === 'undefined') return null;
450
+ try {
451
+ const raw = localStorage.getItem(`gg-editor:canvas-view:${key}`);
452
+ if (!raw) return null;
453
+ const v = JSON.parse(raw) as PersistedView;
454
+ if (!Number.isFinite(v.zoom) || !v.offset || !Number.isFinite(v.offset.x) || !Number.isFinite(v.offset.y)) return null;
455
+ return v;
456
+ } catch {
457
+ return null;
458
+ }
459
+ }
460
+
461
+ export function MultiPageCanvasProvider({ children, enabled, storageKey }: MultiPageCanvasProviderProps) {
179
462
  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
- });
463
+ const { editorMode } = useEditorTool();
464
+ const { viewportWidth } = useEditorView();
193
465
 
194
- // contentListからページフレームを同期
195
- const prevContentListRef = useRef<string>('');
196
- useEffect(() => {
197
- if (!enabled) return;
466
+ const frameWidth = editorMode === 'webpage' ? viewportWidth : SLIDE_WIDTH;
467
+ const defaultHeight = editorMode === 'webpage' ? WEBPAGE_MIN_HEIGHT : SLIDE_HEIGHT;
198
468
 
199
- const contentListKey = artboard.contentList.map(c => c.id).join(',');
200
- if (contentListKey === prevContentListRef.current) return;
201
- prevContentListRef.current = contentListKey;
469
+ // ---- フレーム(寸法・本文)
470
+ const [framesMap, setFramesMap] = useState<Map<string, PageFrame>>(() => new Map());
471
+ const framesMapRef = useRef(framesMap);
472
+ framesMapRef.current = framesMap;
202
473
 
203
- setPagesMap(prev => {
204
- const newMap = new Map<string, PageFrame>();
205
- artboard.contentList.forEach(content => {
474
+ // contentList からフレームを同期(既存の実測・本文は保つ)
475
+ const currentContentIdRef = useRef(artboard.currentContentId);
476
+ currentContentIdRef.current = artboard.currentContentId;
477
+ useEffect(() => {
478
+ if (!enabled) return;
479
+ const ids = new Set(artboard.contentList.map((c) => c.id));
480
+ const depths = computeContentDepths(artboard.contentList);
481
+ setFramesMap((prev) => {
482
+ const next = new Map<string, PageFrame>();
483
+ let changed = prev.size !== artboard.contentList.length;
484
+ artboard.contentList.forEach((content) => {
206
485
  const existing = prev.get(content.id);
486
+ const parentId = content.parentId && ids.has(content.parentId) && content.parentId !== content.id ? content.parentId : null;
487
+ const depth = depths.get(content.id) ?? 0;
488
+ const revision = content.revision ?? 0;
489
+ const href = content.href;
207
490
  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
- });
491
+ const title = content.title || existing.title;
492
+ const width = frameWidth;
493
+ // 利用側が版を進めた = 本文が外から変わった。編集中のページは触らず(編集中の本文が正)、
494
+ // 他のページは本文を捨てて読み直させる(紙面は次の本文が来るまで前の姿のまま)
495
+ const bumped = revision !== existing.revision;
496
+ const isLive = content.id === currentContentIdRef.current;
497
+ const html = bumped && !isLive ? null : existing.html ?? content.thumbnailHtml ?? null;
498
+ const stale = bumped && !isLive ? true : existing.stale;
499
+ if (
500
+ title !== existing.title || html !== existing.html || width !== existing.size.width ||
501
+ parentId !== existing.parentId || depth !== existing.depth || href !== existing.href ||
502
+ revision !== existing.revision || stale !== existing.stale
503
+ ) changed = true;
504
+ next.set(content.id, { ...existing, title, html, stale, parentId, depth, href, revision, size: { width, height: existing.size.height } });
214
505
  } else {
215
- newMap.set(content.id, {
506
+ changed = true;
507
+ next.set(content.id, {
216
508
  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,
509
+ title: content.title || '',
510
+ html: content.thumbnailHtml ?? null,
511
+ loading: false,
512
+ error: null,
513
+ size: { width: frameWidth, height: defaultHeight },
514
+ measured: editorMode === 'slide',
222
515
  isDirty: false,
223
- html: content.thumbnailHtml || '',
516
+ parentId,
517
+ depth,
518
+ href,
519
+ revision,
520
+ stale: false,
224
521
  });
225
522
  }
226
523
  });
227
- return newMap;
524
+ return changed ? next : prev;
228
525
  });
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;
241
- });
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 => {
526
+ }, [enabled, artboard.contentList, frameWidth, defaultHeight, editorMode]);
527
+
528
+ const { pages, bounds, layout } = useMemo(() => {
529
+ const order = artboard.contentList.map((c) => c.id);
530
+ const frames = order.map((id) => framesMap.get(id)).filter((f): f is PageFrame => !!f);
531
+ return layoutFrames(frames, editorMode);
532
+ }, [framesMap, artboard.contentList, editorMode]);
533
+ const pagesRef = useRef(pages);
534
+ pagesRef.current = pages;
535
+ const boundsRef = useRef(bounds);
536
+ boundsRef.current = bounds;
537
+
538
+ const getPage = useCallback((id: string) => pagesRef.current.find((p) => p.id === id), []);
539
+
540
+ const updatePageFrame = useCallback<MultiPageCanvasContextValue['updatePageFrame']>((id, updates) => {
541
+ setFramesMap((prev) => {
268
542
  const existing = prev.get(id);
269
543
  if (!existing) return prev;
270
- const newMap = new Map(prev);
271
- newMap.set(id, { ...existing, ...updates });
272
- return newMap;
544
+ const merged = { ...existing, ...updates };
545
+ // 変わっていなければ参照を保つ(レイアウト再計算・再描画を起こさない)
546
+ const same = (Object.keys(updates) as (keyof typeof updates)[]).every((k) => {
547
+ const a = existing[k];
548
+ const b = merged[k];
549
+ if (k === 'size') {
550
+ const sa = a as PageFrame['size'];
551
+ const sb = b as PageFrame['size'];
552
+ return sa.width === sb.width && sa.height === sb.height;
553
+ }
554
+ return a === b;
555
+ });
556
+ if (same) return prev;
557
+ const next = new Map(prev);
558
+ next.set(id, merged);
559
+ return next;
273
560
  });
274
561
  }, []);
275
562
 
276
- const setCanvasOffset = useCallback((offset: { x: number; y: number }) => {
277
- setViewState(prev => ({ ...prev, canvasOffset: offset }));
278
- }, []);
563
+ const setPageHtml = useCallback(
564
+ (id: string, html: string) => updatePageFrame(id, { html, loading: false, error: null, stale: false }),
565
+ [updatePageFrame],
566
+ );
279
567
 
280
- const setCanvasZoom = useCallback((zoom: number) => {
281
- setViewState(prev => ({
282
- ...prev,
283
- canvasZoom: Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom)),
284
- }));
568
+ // 見るだけの紙面の本文を捨てて読み直させる(部品の反映など、外からファイルが変わったとき)
569
+ const invalidatePages = useCallback<MultiPageCanvasContextValue['invalidatePages']>((options) => {
570
+ if (!io().loadContent) return; // 読み直す手段が無い(thumbnailHtml は最初の姿のままなので読み直しにならない)
571
+ const except = new Set((options?.except ?? []).filter((id): id is string => !!id));
572
+ setFramesMap((prev) => {
573
+ let changed = false;
574
+ const next = new Map(prev);
575
+ prev.forEach((frame, id) => {
576
+ if (except.has(id) || frame.html == null) return;
577
+ next.set(id, { ...frame, html: null, loading: false, error: null, stale: true });
578
+ changed = true;
579
+ });
580
+ return changed ? next : prev;
581
+ });
285
582
  }, []);
286
583
 
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; // 同じページなら何もしない
311
-
312
- // 1. 現在のページのHTMLをサムネイルに保存
313
- if (currentActiveId) {
314
- const currentEditHtml = history.html;
315
- if (currentEditHtml) {
316
- updatePageFrame(currentActiveId, { thumbnailHtml: currentEditHtml });
584
+ // ---- ビュー状態(外部ストア。ズーム・パンで Context の値は変えない)
585
+ const persisted = useMemo(() => readPersistedView(storageKey), [storageKey]);
586
+ const [viewStore] = useState<CanvasViewStore>(() =>
587
+ createViewStore({
588
+ canvasOffset: persisted?.offset ?? { x: 0, y: 0 },
589
+ canvasZoom: persisted ? clampZoom(persisted.zoom) : 0.25,
590
+ activePageId: artboard.currentContentId ?? null,
591
+ }),
592
+ );
593
+ const [rulersVisible, setRulersVisible] = useState<boolean>(persisted?.rulers ?? true);
594
+ const rulersRef = useRef(rulersVisible);
595
+ rulersRef.current = rulersVisible;
596
+
597
+ const setPageHeight = useCallback<MultiPageCanvasContextValue['setPageHeight']>(
598
+ (id, height, source) => {
599
+ if (!Number.isFinite(height) || height <= 0) return;
600
+ if (source === 'preview' && viewStore.get().activePageId === id) {
601
+ // 生きているページはエディタの実測が正。ただし一度も測っていなければ受ける
602
+ const frame = framesMapRef.current.get(id);
603
+ if (frame?.measured) return;
317
604
  }
318
- }
605
+ const frame = framesMapRef.current.get(id);
606
+ if (!frame) return;
607
+ const h = Math.max(200, Math.round(height));
608
+ if (frame.measured && Math.abs(frame.size.height - h) < 1) return;
609
+ updatePageFrame(id, { size: { width: frame.size.width, height: h }, measured: true });
610
+ },
611
+ [updatePageFrame, viewStore],
612
+ );
319
613
 
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
- }
614
+ // 生きているページは利用側(currentContentId)と揃える。
615
+ // 本文が読めて currentContentId が変わったときに初めてエディタが移る
616
+ // (クリックの時点では移さない。移すと前のページの本文が新しい枠に見えてしまう)
617
+ useEffect(() => {
618
+ if (!enabled) return;
619
+ const id = artboard.currentContentId ?? artboard.contentList[0]?.id ?? null;
620
+ viewStore.set((prev) => (prev.activePageId === id ? prev : { ...prev, activePageId: id }));
621
+ }, [enabled, artboard.currentContentId, artboard.contentList, viewStore]);
332
622
 
333
- // 4. activePageId更新
334
- setViewState(prev => ({ ...prev, activePageId: id }));
623
+ // 見えている範囲の保存(変化が止まってから)
624
+ useEffect(() => {
625
+ if (!storageKey || typeof localStorage === 'undefined') return;
626
+ let timer: ReturnType<typeof setTimeout> | null = null;
627
+ const persist = () => {
628
+ if (timer) clearTimeout(timer);
629
+ timer = setTimeout(() => {
630
+ timer = null;
631
+ try {
632
+ const { canvasZoom, canvasOffset } = viewStore.get();
633
+ const v: PersistedView = { zoom: canvasZoom, offset: canvasOffset, rulers: rulersRef.current };
634
+ localStorage.setItem(`gg-editor:canvas-view:${storageKey}`, JSON.stringify(v));
635
+ } catch {
636
+ /* 保存できなくても表示には影響しない */
637
+ }
638
+ }, 250);
639
+ };
640
+ const unsubscribe = viewStore.subscribe(persist);
641
+ persist();
642
+ return () => {
643
+ unsubscribe();
644
+ if (timer) clearTimeout(timer);
645
+ };
646
+ }, [storageKey, viewStore, rulersVisible]);
647
+
648
+ // ---- 容器
649
+ const containerRef = useRef<HTMLDivElement | null>(null);
650
+ const registerContainer = useCallback((el: HTMLDivElement | null) => {
651
+ containerRef.current = el;
652
+ }, []);
653
+ const containerSize = () => {
654
+ const el = containerRef.current;
655
+ if (!el) return { width: 1200, height: 800 };
656
+ return { width: el.clientWidth || 1200, height: el.clientHeight || 800 };
657
+ };
335
658
 
336
- // 5. API経由で最新HTMLを非同期取得
337
- if (artboard.onContentChange && id !== artboard.currentContentId) {
338
- artboard.onContentChange(id);
659
+ // ---- アニメーション
660
+ const animationRef = useRef<number | null>(null);
661
+ const cancelAnimation = () => {
662
+ if (animationRef.current != null) {
663
+ cancelAnimationFrame(animationRef.current);
664
+ animationRef.current = null;
339
665
  }
666
+ };
340
667
 
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);
668
+ const setView = useCallback<MultiPageCanvasContextValue['setView']>((view, options) => {
669
+ cancelAnimation();
670
+ const target = { zoom: clampZoom(view.zoom), offset: view.offset };
671
+ if (!options?.animate || prefersReducedMotion()) {
672
+ viewStore.set((prev) => ({ ...prev, canvasZoom: target.zoom, canvasOffset: target.offset }));
673
+ return;
359
674
  }
675
+ const from = { zoom: viewStore.get().canvasZoom, offset: viewStore.get().canvasOffset };
676
+ const start = performance.now();
677
+ const tick = (now: number) => {
678
+ const t = Math.min(1, (now - start) / VIEW_ANIMATION_MS);
679
+ const k = easeOutCubic(t);
680
+ // 倍率は対数で補間する(拡大と縮小で速さが揃う)
681
+ const zoom = Math.exp(Math.log(from.zoom) + (Math.log(target.zoom) - Math.log(from.zoom)) * k);
682
+ const offset = {
683
+ x: from.offset.x + (target.offset.x - from.offset.x) * k,
684
+ y: from.offset.y + (target.offset.y - from.offset.y) * k,
685
+ };
686
+ viewStore.set((prev) => ({ ...prev, canvasZoom: zoom, canvasOffset: offset }));
687
+ if (t < 1) animationRef.current = requestAnimationFrame(tick);
688
+ else animationRef.current = null;
689
+ };
690
+ animationRef.current = requestAnimationFrame(tick);
691
+ }, [viewStore]);
692
+ useEffect(() => () => cancelAnimation(), []);
360
693
 
361
- console.log('[MultiPageCanvas] focusPage:', currentActiveId, '→', id);
362
- }, [artboard]);
694
+ const setCanvasOffset = useCallback((offset: { x: number; y: number }) => {
695
+ cancelAnimation();
696
+ viewStore.set((prev) => ({ ...prev, canvasOffset: offset }));
697
+ }, [viewStore]);
363
698
 
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;
699
+ const setCanvasZoom = useCallback((zoom: number) => {
700
+ cancelAnimation();
701
+ viewStore.set((prev) => ({ ...prev, canvasZoom: clampZoom(zoom) }));
702
+ }, [viewStore]);
703
+
704
+ const zoomAt = useCallback<MultiPageCanvasContextValue['zoomAt']>((zoom, point) => {
705
+ cancelAnimation();
706
+ viewStore.set((prev) => {
707
+ const next = clampZoom(zoom);
708
+ if (next === prev.canvasZoom) return prev;
709
+ const ratio = next / prev.canvasZoom;
710
+ return {
711
+ ...prev,
712
+ canvasZoom: next,
713
+ canvasOffset: {
714
+ x: point.x - (point.x - prev.canvasOffset.x) * ratio,
715
+ y: point.y - (point.y - prev.canvasOffset.y) * ratio,
716
+ },
717
+ };
371
718
  });
372
- }, []);
719
+ }, [viewStore]);
720
+
721
+ const zoomTo = useCallback<MultiPageCanvasContextValue['zoomTo']>(
722
+ (zoom, options) => {
723
+ const { width, height } = containerSize();
724
+ const inset = rulersRef.current ? RULER_SIZE : 0;
725
+ const center = { x: inset + (width - inset) / 2, y: inset + (height - inset) / 2 };
726
+ const prev = viewStore.get();
727
+ const next = clampZoom(zoom);
728
+ const ratio = next / prev.canvasZoom;
729
+ setView(
730
+ {
731
+ zoom: next,
732
+ offset: {
733
+ x: center.x - (center.x - prev.canvasOffset.x) * ratio,
734
+ y: center.y - (center.y - prev.canvasOffset.y) * ratio,
735
+ },
736
+ },
737
+ options,
738
+ );
739
+ },
740
+ [setView, viewStore],
741
+ );
373
742
 
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
- }, []);
743
+ const zoomIn = useCallback(() => zoomTo(viewStore.get().canvasZoom * ZOOM_STEP, { animate: true }), [zoomTo, viewStore]);
744
+ const zoomOut = useCallback(() => zoomTo(viewStore.get().canvasZoom / ZOOM_STEP, { animate: true }), [zoomTo, viewStore]);
745
+ const zoomToActual = useCallback(() => zoomTo(1, { animate: true }), [zoomTo]);
390
746
 
391
- const zoomOut = useCallback(() => {
392
- setViewState(prev => ({
393
- ...prev,
394
- canvasZoom: Math.max(MIN_ZOOM, prev.canvasZoom * 0.8),
395
- }));
396
- }, []);
747
+ const zoomToFit = useCallback<MultiPageCanvasContextValue['zoomToFit']>(
748
+ (options) => {
749
+ if (!pagesRef.current.length) return;
750
+ setView(fitView(boundsRef.current, containerSize(), { padding: FIT_PADDING, maxZoom: 1, rulers: rulersRef.current }), options);
751
+ },
752
+ [setView],
753
+ );
397
754
 
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
- }, []);
755
+ const zoomToPage = useCallback<MultiPageCanvasContextValue['zoomToPage']>(
756
+ (id, options) => {
757
+ const page = pagesRef.current.find((p) => p.id === id);
758
+ if (!page) return;
759
+ const rect = {
760
+ minX: page.position.x,
761
+ minY: page.position.y,
762
+ maxX: page.position.x + page.size.width,
763
+ maxY: page.position.y + page.size.height,
764
+ };
765
+ const { width, height } = containerSize();
766
+ // 長いページは全体を収めると小さすぎる。幅を合わせて先頭を見せる(Figma のフレーム選択と同じ感覚)
767
+ const fitted = fitView(rect, { width, height }, { padding: FIT_PADDING, maxZoom: 1, rulers: rulersRef.current });
768
+ const inset = rulersRef.current ? RULER_SIZE : 0;
769
+ const widthZoom = Math.min(1, (width - inset - FIT_PADDING * 2) / page.size.width);
770
+ if (fitted.zoom < widthZoom * 0.6) {
771
+ const zoom = clampZoom(widthZoom);
772
+ setView(
773
+ {
774
+ zoom,
775
+ offset: {
776
+ x: inset + (width - inset - page.size.width * zoom) / 2 - page.position.x * zoom,
777
+ y: inset + FIT_PADDING - page.position.y * zoom,
778
+ },
779
+ },
780
+ options,
781
+ );
782
+ return;
783
+ }
784
+ setView(fitted, options);
785
+ },
786
+ [setView],
787
+ );
407
788
 
408
- // --- グローバルUndo/Redoハンドラー用のState ---
409
- const [undoHandlers, setUndoHandlers] = useState({
410
- undo: () => {},
411
- redo: () => {},
412
- canUndo: false,
413
- canRedo: false,
414
- });
789
+ const revealPage = useCallback<MultiPageCanvasContextValue['revealPage']>(
790
+ (id) => {
791
+ const page = pagesRef.current.find((p) => p.id === id);
792
+ if (!page) return;
793
+ const { width, height } = containerSize();
794
+ const { canvasZoom: z, canvasOffset: o } = viewStore.get();
795
+ const inset = rulersRef.current ? RULER_SIZE : 0;
796
+ const left = o.x + page.position.x * z;
797
+ const top = o.y + page.position.y * z;
798
+ const right = left + page.size.width * z;
799
+ const bottom = top + page.size.height * z;
800
+ const margin = 48;
801
+ // 一部でも見えていればそのまま。見えていなければ、そのフレームの左上を画面へ寄せる
802
+ const visible = right > inset + margin && left < width - margin && bottom > inset + margin && top < height - margin;
803
+ if (visible) return;
804
+ const w = page.size.width * z;
805
+ const h = page.size.height * z;
806
+ const offset = {
807
+ x: w <= width - inset ? inset + (width - inset - w) / 2 - page.position.x * z : inset + margin - page.position.x * z,
808
+ y: h <= height - inset ? inset + (height - inset - h) / 2 - page.position.y * z : inset + margin - page.position.y * z,
809
+ };
810
+ setView({ zoom: z, offset }, { animate: true });
811
+ },
812
+ [setView, viewStore],
813
+ );
415
814
 
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
- });
815
+ // ---- 本文の読み込み
816
+ const loadingRef = useRef(new Set<string>());
817
+ const ensurePageHtml = useCallback<MultiPageCanvasContextValue['ensurePageHtml']>(
818
+ async (id) => {
819
+ const frame = framesMapRef.current.get(id);
820
+ if (!frame || frame.html != null || loadingRef.current.has(id)) return;
821
+ const loader = io().loadContent;
822
+ // 最初は利用側が渡した本文(thumbnailHtml)で足りる。外から変わった(stale)あとは loadContent で読み直す
823
+ const fromList = artboard.contentList.find((c) => c.id === id)?.thumbnailHtml;
824
+ if (fromList != null && !(frame.stale && loader)) {
825
+ setPageHtml(id, fromList);
826
+ return;
827
+ }
828
+ if (!loader) return;
829
+ loadingRef.current.add(id);
830
+ updatePageFrame(id, { loading: true, error: null });
831
+ try {
832
+ const html = await loader(id);
833
+ setPageHtml(id, html ?? '');
834
+ } catch (e) {
835
+ updatePageFrame(id, { loading: false, error: e instanceof Error ? e.message : String(e) });
836
+ } finally {
837
+ loadingRef.current.delete(id);
838
+ }
839
+ },
840
+ [artboard.contentList, setPageHtml, updatePageFrame],
841
+ );
426
842
 
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
- }, []);
843
+ // ---- 見るだけの紙面のスタイル(利用側から 1 回だけ取る)
844
+ const [previewStyles, setPreviewStyles] = useState<PreviewStyle[]>([]);
845
+ useEffect(() => {
846
+ if (!enabled) return;
847
+ const loader = io().previewStyles;
848
+ if (!loader) return;
849
+ let alive = true;
850
+ Promise.resolve()
851
+ .then(() => loader())
852
+ .then((styles) => { if (alive && Array.isArray(styles)) setPreviewStyles(styles); })
853
+ .catch((e) => console.warn('[MultiPageCanvas] previewStyles を取れませんでした:', e));
854
+ return () => { alive = false; };
855
+ }, [enabled]);
856
+
857
+ // ---- ページ切替
858
+ // 保存と読み込みは利用側(FrontendVisualEditor.handleContentChange)が 1 か所で行う。
859
+ // ここでは「どこへ移ろうとしているか」を持ち、リングを先に動かして押した手応えを返す
860
+ const [activatingPageId, setActivatingPageId] = useState<string | null>(null);
861
+ const onContentChangeRef = useRef(artboard.onContentChange);
862
+ onContentChangeRef.current = artboard.onContentChange;
863
+ const activatePage = useCallback<MultiPageCanvasContextValue['activatePage']>(
864
+ async (id, options) => {
865
+ if (!framesMapRef.current.has(id)) return false;
866
+ if (viewStore.get().activePageId === id) {
867
+ if (options?.reveal) revealPage(id);
868
+ return true;
869
+ }
870
+ const notify = onContentChangeRef.current;
871
+ if (!notify) return false;
872
+ setActivatingPageId(id);
873
+ if (options?.reveal) revealPage(id);
874
+ try {
875
+ const result = await (notify as (contentId: string) => unknown)(id);
876
+ return result !== false;
877
+ } finally {
878
+ setActivatingPageId((v) => (v === id ? null : v));
879
+ }
880
+ },
881
+ [revealPage, viewStore],
882
+ );
431
883
 
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
- ]);
884
+ // ---- 操作状態
885
+ const [isInteracting, setIsInteracting] = useState(false);
886
+ const interactingTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
887
+ const markInteracting = useCallback(() => {
888
+ setIsInteracting(true);
889
+ if (interactingTimer.current) clearTimeout(interactingTimer.current);
890
+ interactingTimer.current = setTimeout(() => setIsInteracting(false), 160);
891
+ }, []);
892
+ useEffect(() => () => { if (interactingTimer.current) clearTimeout(interactingTimer.current); }, []);
893
+
894
+ const toggleRulers = useCallback(() => setRulersVisible((v) => !v), []);
895
+
896
+ // ---- 見るだけの紙面の読み込み枠(同時に PREVIEW_PARALLEL 枚まで。編集中のページに近い順)
897
+ const slotQueueRef = useRef<{ id: string; grant: () => void }[]>([]);
898
+ const slotHoldersRef = useRef(new Set<string>());
899
+ const pumpPreviewSlots = useCallback(() => {
900
+ const holders = slotHoldersRef.current;
901
+ const queue = slotQueueRef.current;
902
+ if (holders.size >= PREVIEW_PARALLEL || queue.length === 0) return;
903
+ const indexById = new Map(pagesRef.current.map((p) => [p.id, p.index] as const));
904
+ const indexOf = (id: string) => indexById.get(id) ?? 0;
905
+ const activeId = viewStore.get().activePageId;
906
+ const activeIndex = activeId ? indexOf(activeId) : 0;
907
+ queue.sort((a, b) => Math.abs(indexOf(a.id) - activeIndex) - Math.abs(indexOf(b.id) - activeIndex));
908
+ while (holders.size < PREVIEW_PARALLEL && queue.length > 0) {
909
+ const next = queue.shift()!;
910
+ holders.add(next.id);
911
+ next.grant();
912
+ }
913
+ }, [viewStore]);
914
+ const requestPreviewSlot = useCallback<MultiPageCanvasContextValue['requestPreviewSlot']>(
915
+ (id, grant) => {
916
+ if (slotHoldersRef.current.has(id)) {
917
+ grant();
918
+ return;
919
+ }
920
+ if (!slotQueueRef.current.some((q) => q.id === id)) slotQueueRef.current.push({ id, grant });
921
+ pumpPreviewSlots();
922
+ },
923
+ [pumpPreviewSlots],
924
+ );
925
+ const releasePreviewSlot = useCallback<MultiPageCanvasContextValue['releasePreviewSlot']>(
926
+ (id) => {
927
+ slotQueueRef.current = slotQueueRef.current.filter((q) => q.id !== id);
928
+ if (slotHoldersRef.current.delete(id)) pumpPreviewSlots();
929
+ },
930
+ [pumpPreviewSlots],
931
+ );
472
932
 
473
- return (
474
- <MultiPageCanvasContext.Provider value={value}>
475
- {children}
476
- </MultiPageCanvasContext.Provider>
933
+ const value = useMemo<MultiPageCanvasContextValue>(
934
+ () => ({
935
+ isEnabled: enabled,
936
+ editorMode,
937
+ pages,
938
+ bounds,
939
+ layout,
940
+ getPage,
941
+ updatePageFrame,
942
+ setPageHtml,
943
+ invalidatePages,
944
+ documentAttributes: artboard.documentAttributes,
945
+ setPageHeight,
946
+ ensurePageHtml,
947
+ previewStyles,
948
+ viewStore,
949
+ setCanvasOffset,
950
+ setCanvasZoom,
951
+ setView,
952
+ zoomAt,
953
+ zoomTo,
954
+ zoomIn,
955
+ zoomOut,
956
+ zoomToFit,
957
+ zoomToPage,
958
+ zoomToActual,
959
+ revealPage,
960
+ activatePage,
961
+ activatingPageId,
962
+ registerContainer,
963
+ containerRef,
964
+ isInteracting,
965
+ markInteracting,
966
+ initialViewRestored: !!persisted,
967
+ rulersVisible,
968
+ toggleRulers,
969
+ requestPreviewSlot,
970
+ releasePreviewSlot,
971
+ }),
972
+ [
973
+ enabled, editorMode, pages, bounds, layout, getPage, updatePageFrame, setPageHtml, invalidatePages, artboard.documentAttributes,
974
+ setPageHeight, ensurePageHtml, previewStyles, viewStore, setCanvasOffset, setCanvasZoom, setView, zoomAt, zoomTo, zoomIn, zoomOut, zoomToFit, zoomToPage,
975
+ zoomToActual, revealPage, activatePage, activatingPageId, registerContainer, isInteracting, markInteracting,
976
+ persisted, rulersVisible, toggleRulers, requestPreviewSlot, releasePreviewSlot,
977
+ ],
477
978
  );
979
+
980
+ return <MultiPageCanvasContext.Provider value={value}>{children}</MultiPageCanvasContext.Provider>;
478
981
  }
479
982
 
480
983
  /**
@@ -482,16 +985,18 @@ export function MultiPageCanvasProvider({
482
985
  */
483
986
  export function ConditionalMultiPageProvider({
484
987
  enabled,
988
+ storageKey,
485
989
  children,
486
990
  }: {
487
991
  enabled: boolean;
992
+ storageKey?: string | null;
488
993
  children: React.ReactNode;
489
994
  }) {
490
995
  if (!enabled) {
491
996
  return <>{children}</>;
492
997
  }
493
998
  return (
494
- <MultiPageCanvasProvider enabled={enabled}>
999
+ <MultiPageCanvasProvider enabled={enabled} storageKey={storageKey}>
495
1000
  {children}
496
1001
  </MultiPageCanvasProvider>
497
1002
  );