@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.
- package/README.md +41 -1
- package/dist/editor.css +1 -1
- package/package.json +3 -2
- package/src/editor/EditorContext.tsx +15 -3
- package/src/editor/FrontendVisualEditor.tsx +168 -55
- package/src/editor/autosave.ts +32 -0
- package/src/editor/components/EditorCanvas.tsx +107 -8
- package/src/editor/components/EditorContextMenu.tsx +12 -3
- package/src/editor/components/EditorHeader.tsx +47 -12
- package/src/editor/components/EditorLayerPanel.tsx +28 -2
- package/src/editor/components/EditorPropertyPanel.tsx +56 -25
- package/src/editor/components/multi-page/CanvasRulers.tsx +166 -0
- package/src/editor/components/multi-page/MultiPageCanvasView.tsx +345 -1345
- package/src/editor/components/multi-page/PageFramePreview.tsx +197 -0
- package/src/editor/components/multi-page/index.ts +2 -6
- package/src/editor/components/ppt/PptComments.tsx +8 -4
- package/src/editor/components/shell/CanvasAppearance.tsx +3 -1
- package/src/editor/components/shell/PagesPanel.tsx +8 -0
- package/src/editor/constants.ts +28 -0
- package/src/editor/contexts/EditorArtboardContext.tsx +60 -3
- package/src/editor/contexts/EditorComponentsContext.tsx +17 -4
- package/src/editor/contexts/MultiPageCanvasContext.tsx +859 -354
- package/src/editor/hooks/useCanvasControls.ts +1 -1
- package/src/editor/hooks/useCanvasMarquee.ts +196 -0
- package/src/editor/hooks/useContextMenuHandler.ts +5 -14
- package/src/editor/hooks/useCoordinateTransform.ts +11 -8
- package/src/editor/hooks/useDrawingMode.ts +1 -0
- package/src/editor/hooks/useGoogleFonts.ts +6 -2
- package/src/editor/hooks/useInfiniteCanvas.ts +228 -220
- package/src/editor/hooks/useMarqueeSelection.ts +2 -2
- package/src/editor/utils/dom-utils.ts +23 -1
- package/src/editor/utils/embedded-canvas-bridge.ts +155 -0
- package/src/editor/utils/html-utils.ts +102 -14
- package/src/index.ts +5 -0
- package/src/io.ts +20 -0
- package/src/styles/skin.css +122 -36
- package/src/version.ts +9 -0
- package/src/editor/components/multi-page/CanvasZoomControls.tsx +0 -71
- package/src/editor/components/multi-page/InfiniteCanvas.tsx +0 -95
- package/src/editor/components/multi-page/PageFrameOverlay.tsx +0 -76
- package/src/editor/components/multi-page/PageLabel.tsx +0 -34
- package/src/editor/components/multi-page/PageLivePreview.tsx +0 -242
- package/src/editor/components/multi-page/PageThumbnail.tsx +0 -59
|
@@ -3,38 +3,77 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* MultiPageCanvasContext
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
|
|
34
|
+
useState,
|
|
35
|
+
useSyncExternalStore,
|
|
19
36
|
} from 'react';
|
|
20
|
-
import { useEditorArtboard } from './EditorArtboardContext';
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
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
|
-
/**
|
|
30
|
-
export
|
|
31
|
-
/**
|
|
32
|
-
export const WEBPAGE_MIN_HEIGHT =
|
|
33
|
-
/**
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
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
|
-
|
|
85
|
+
/** 本文。null = まだ読んでいない(または読み直す) */
|
|
86
|
+
html: string | null;
|
|
87
|
+
/** 本文を読んでいる途中 */
|
|
88
|
+
loading: boolean;
|
|
89
|
+
/** 読めなかったときの理由 */
|
|
90
|
+
error: string | null;
|
|
47
91
|
size: { width: number; height: number };
|
|
48
|
-
|
|
49
|
-
|
|
92
|
+
/** 高さを実測済みか(未測定はレイアウト用の仮の高さ) */
|
|
93
|
+
measured: boolean;
|
|
94
|
+
/** 未保存の変更がある(生きているページだけ立つ) */
|
|
50
95
|
isDirty: boolean;
|
|
51
|
-
|
|
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
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
125
|
-
return pages.
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
|
181
|
-
const
|
|
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
|
-
|
|
195
|
-
const
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
469
|
+
// ---- フレーム(寸法・本文)
|
|
470
|
+
const [framesMap, setFramesMap] = useState<Map<string, PageFrame>>(() => new Map());
|
|
471
|
+
const framesMapRef = useRef(framesMap);
|
|
472
|
+
framesMapRef.current = framesMap;
|
|
202
473
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
506
|
+
changed = true;
|
|
507
|
+
next.set(content.id, {
|
|
216
508
|
id: content.id,
|
|
217
|
-
title: content.title,
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
516
|
+
parentId,
|
|
517
|
+
depth,
|
|
518
|
+
href,
|
|
519
|
+
revision,
|
|
520
|
+
stale: false,
|
|
224
521
|
});
|
|
225
522
|
}
|
|
226
523
|
});
|
|
227
|
-
return
|
|
524
|
+
return changed ? next : prev;
|
|
228
525
|
});
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
|
271
|
-
|
|
272
|
-
|
|
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
|
|
277
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
//
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
334
|
-
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
362
|
-
|
|
694
|
+
const setCanvasOffset = useCallback((offset: { x: number; y: number }) => {
|
|
695
|
+
cancelAnimation();
|
|
696
|
+
viewStore.set((prev) => ({ ...prev, canvasOffset: offset }));
|
|
697
|
+
}, [viewStore]);
|
|
363
698
|
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
|
375
|
-
|
|
376
|
-
|
|
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
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
|
399
|
-
|
|
400
|
-
const
|
|
401
|
-
if (!
|
|
402
|
-
const
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
-
//
|
|
433
|
-
|
|
434
|
-
const
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
);
|