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