@growgroup/visual-editor 0.9.1 → 0.11.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 +90 -1
- package/dist/editor.css +1 -1
- package/package.json +1 -1
- package/src/editor/EditorContext.tsx +5 -1
- package/src/editor/FrontendVisualEditor.tsx +26 -12
- package/src/editor/components/EditorCanvas.tsx +11 -0
- package/src/editor/components/EditorContextMenu.tsx +52 -2
- package/src/editor/components/EditorFooter.tsx +29 -6
- package/src/editor/components/EditorHeader.tsx +15 -2
- package/src/editor/components/EditorLayerPanel.tsx +86 -0
- package/src/editor/components/LayerTreeItem.tsx +107 -3
- package/src/editor/components/canvas-guides/CanvasGuides.tsx +247 -0
- package/src/editor/components/multi-page/CanvasRulers.tsx +3 -0
- package/src/editor/components/multi-page/MultiPageCanvasView.tsx +12 -4
- package/src/editor/components/ppt/PptChrome.tsx +8 -4
- package/src/editor/components/ppt/PptComments.tsx +98 -1
- package/src/editor/components/shell/LeftPanel.tsx +8 -1
- package/src/editor/components/shell/PagesPanel.tsx +7 -1
- package/src/editor/components/shell/useRowsNearViewport.ts +64 -0
- package/src/editor/constants.ts +25 -0
- package/src/editor/contexts/EditorHistoryContext.tsx +6 -1
- package/src/editor/contexts/MultiPageCanvasContext.tsx +106 -19
- package/src/editor/hooks/useBrowserZoomPrevention.ts +3 -3
- package/src/editor/hooks/useCanvasControls.ts +43 -9
- package/src/editor/hooks/useDragResize.ts +205 -17
- package/src/editor/hooks/useElementActions.ts +119 -34
- package/src/editor/hooks/useInfiniteCanvas.ts +29 -5
- package/src/editor/hooks/useKeyboardShortcuts.ts +487 -0
- package/src/editor/hooks/useMarqueeSelection.ts +5 -1
- package/src/editor/utils/align-elements.ts +49 -0
- package/src/editor/utils/canvas-gestures.ts +210 -0
- package/src/editor/utils/canvas-guides.ts +46 -0
- package/src/editor/utils/dom-utils.ts +12 -2
- package/src/editor/utils/embedded-canvas-bridge.ts +6 -11
- package/src/editor/utils/history-timing.ts +9 -0
- package/src/editor/utils/html-utils.ts +4 -0
- package/src/editor/utils/lock.ts +24 -0
- package/src/editor/utils/smart-duplicate.ts +51 -0
- package/src/editor/utils/smart-guides.ts +232 -2
- package/src/hooks/useEditorShortcuts.ts +46 -2
- package/src/index.ts +5 -1
- package/src/io.ts +54 -0
- package/src/styles/skin.css +5 -0
- package/src/types/editor.ts +120 -1
- package/src/version.ts +1 -1
|
@@ -38,6 +38,7 @@ import React, {
|
|
|
38
38
|
useState,
|
|
39
39
|
} from 'react';
|
|
40
40
|
import { MAX_HISTORY_SIZE } from '../../types/editor';
|
|
41
|
+
import { COALESCE_WINDOW_MS } from '../utils/history-timing';
|
|
41
42
|
import { useEditorRefs } from './EditorRefsContext';
|
|
42
43
|
|
|
43
44
|
export interface EditorHistoryContextValue {
|
|
@@ -77,8 +78,8 @@ export function useEditorHistory(): EditorHistoryContextValue {
|
|
|
77
78
|
* - 矢印キーのキーリピート(約30ms間隔)・テキスト入力・プロパティ入力が1エントリにまとまる
|
|
78
79
|
* - 意図的に間を空けた操作(400ms超)は別エントリのまま残る
|
|
79
80
|
* ドラッグ/リサイズはmouseupで1回しかpushされないため、この窓に依存せず元から1エントリ。
|
|
81
|
+
* 値は utils/history-timing.ts の COALESCE_WINDOW_MS(数字キーの2桁入力と共有)。
|
|
80
82
|
*/
|
|
81
|
-
const COALESCE_WINDOW_MS = 400;
|
|
82
83
|
|
|
83
84
|
/**
|
|
84
85
|
* 履歴に残してはいけないエディタUIのノード。
|
|
@@ -156,6 +157,10 @@ function normalizeSnapshot(rawHtml: string): { html: string; signature: string }
|
|
|
156
157
|
node.classList.remove(cls);
|
|
157
158
|
});
|
|
158
159
|
});
|
|
160
|
+
// レイヤーパネルの行のホバー強調(属性)も一時状態。焼き付けると ⌘Z で強調が戻ってくる
|
|
161
|
+
template.content.querySelectorAll('[data-layer-hover]').forEach((node) => {
|
|
162
|
+
node.removeAttribute('data-layer-hover');
|
|
163
|
+
});
|
|
159
164
|
// 空になったclass属性は落とす。
|
|
160
165
|
// 「元から空」と「剥がして空になった」が同じ表記になるようにして、
|
|
161
166
|
// 見た目が同じなのに別状態と判定される(=何も起きないUndoが増える)のを防ぐ。
|
|
@@ -42,6 +42,7 @@ import { SLIDE_HEIGHT, SLIDE_WIDTH, WEBPAGE_WIDTH } from '../constants';
|
|
|
42
42
|
import type { EditorMode } from './EditorToolContext';
|
|
43
43
|
import { io, type PreviewStyle } from '../../io';
|
|
44
44
|
import { readStorage, writeStorage } from '../utils/storage';
|
|
45
|
+
import { nextZoomLevel, type CanvasZoomOptions, type ResolvedCanvasZoomOptions } from '../utils/canvas-gestures';
|
|
45
46
|
|
|
46
47
|
// ============================================================
|
|
47
48
|
// 定数
|
|
@@ -51,10 +52,13 @@ import { readStorage, writeStorage } from '../utils/storage';
|
|
|
51
52
|
export { WEBPAGE_WIDTH };
|
|
52
53
|
/** 高さが未測定の webpage フレームに仮に与える高さ */
|
|
53
54
|
export const WEBPAGE_MIN_HEIGHT = 1200;
|
|
54
|
-
/**
|
|
55
|
+
/**
|
|
56
|
+
* ズーム範囲の既定(Figma は 2%〜25600%。紙面の編集で要るのはこの範囲)。
|
|
57
|
+
* 利用側は `canvasZoomOptions` で範囲・刻み・⌘2 の合わせ先を変えられる(0.10.0)
|
|
58
|
+
*/
|
|
55
59
|
export const MIN_ZOOM = 0.02;
|
|
56
60
|
export const MAX_ZOOM = 4;
|
|
57
|
-
/**
|
|
61
|
+
/** ボタン・キー操作の 1 段の倍率(`steps: 'ratio'`、既定) */
|
|
58
62
|
const ZOOM_STEP = 1.25;
|
|
59
63
|
/** 全体表示・ページ表示のときの余白(px、画面上) */
|
|
60
64
|
const FIT_PADDING = 72;
|
|
@@ -72,6 +76,14 @@ export const RULER_SIZE = 20;
|
|
|
72
76
|
const VIEW_ANIMATION_MS = 240;
|
|
73
77
|
/** 見るだけの紙面を同時に読む枚数 */
|
|
74
78
|
const PREVIEW_PARALLEL = 4;
|
|
79
|
+
|
|
80
|
+
/** 主スレッドが空いたときに 1 回呼ぶ(requestIdleCallback が無い環境は次のタスク)。待つのは最大 200ms */
|
|
81
|
+
function scheduleIdle(cb: () => void): void {
|
|
82
|
+
const w = typeof window !== 'undefined' ? (window as Window & { requestIdleCallback?: (cb: () => void, o?: { timeout: number }) => number }) : null;
|
|
83
|
+
if (w?.requestIdleCallback) w.requestIdleCallback(cb, { timeout: 200 });
|
|
84
|
+
else setTimeout(cb, 16);
|
|
85
|
+
}
|
|
86
|
+
|
|
75
87
|
/**
|
|
76
88
|
* 見るだけの紙面を画像で描く倍率の **上限の最大値**。これ以上に拡大されていれば、
|
|
77
89
|
* どんなに解像度の高い画像でも iframe で描く(画像は文字を選べない・検索できないため)。
|
|
@@ -225,6 +237,10 @@ export interface MultiPageCanvasContextValue {
|
|
|
225
237
|
zoomToFit: (options?: { animate?: boolean }) => void;
|
|
226
238
|
zoomToPage: (id: string, options?: { animate?: boolean }) => void;
|
|
227
239
|
zoomToActual: () => void;
|
|
240
|
+
/** キャンバスの座標の矩形を、余白を取って画面に収める(100% を超えてもよい。選択範囲に合わせる等) — 0.10.0 */
|
|
241
|
+
zoomToRect: (rect: CanvasBounds, options?: { animate?: boolean }) => void;
|
|
242
|
+
/** 拡大縮小の設定(`canvasZoomOptions` を省略時の値で埋めたもの) — 0.10.0 */
|
|
243
|
+
zoomOptions: ResolvedCanvasZoomOptions;
|
|
228
244
|
/** 指定のフレームが画面に入っていなければ、見える位置まで寄せる(倍率は変えない) */
|
|
229
245
|
revealPage: (id: string) => void;
|
|
230
246
|
/**
|
|
@@ -484,7 +500,17 @@ function fitView(
|
|
|
484
500
|
};
|
|
485
501
|
}
|
|
486
502
|
|
|
487
|
-
|
|
503
|
+
/** 利用側が渡した設定を省略時の値で埋める(範囲は片方だけ・逆転・0 以下を既定へ) */
|
|
504
|
+
export function resolveCanvasZoomOptions(options?: CanvasZoomOptions | null): ResolvedCanvasZoomOptions {
|
|
505
|
+
const min = Number.isFinite(options?.min) && (options?.min as number) > 0 ? (options?.min as number) : MIN_ZOOM;
|
|
506
|
+
const max = Number.isFinite(options?.max) && (options?.max as number) > 0 ? (options?.max as number) : MAX_ZOOM;
|
|
507
|
+
const range = min <= max ? { min, max } : { min: MIN_ZOOM, max: MAX_ZOOM };
|
|
508
|
+
return {
|
|
509
|
+
...range,
|
|
510
|
+
steps: options?.steps === 'powers-of-two' ? 'powers-of-two' : 'ratio',
|
|
511
|
+
fitShortcut: options?.fitShortcut === 'selection' ? 'selection' : 'page',
|
|
512
|
+
};
|
|
513
|
+
}
|
|
488
514
|
const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3);
|
|
489
515
|
const prefersReducedMotion = () =>
|
|
490
516
|
typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
@@ -518,6 +544,8 @@ interface MultiPageCanvasProviderProps {
|
|
|
518
544
|
enabled: boolean;
|
|
519
545
|
/** 見えている範囲を保存するキー(利用側の parentId 等)。無ければ保存しない */
|
|
520
546
|
storageKey?: string | null;
|
|
547
|
+
/** 拡大縮小の設定(範囲・刻み・⌘2 の合わせ先)。省略時は従来の挙動(0.10.0) */
|
|
548
|
+
zoomOptions?: CanvasZoomOptions;
|
|
521
549
|
}
|
|
522
550
|
|
|
523
551
|
type PersistedView = {
|
|
@@ -546,7 +574,15 @@ function readPersistedView(key: string | null | undefined): PersistedView | null
|
|
|
546
574
|
}
|
|
547
575
|
}
|
|
548
576
|
|
|
549
|
-
export function MultiPageCanvasProvider({ children, enabled, storageKey }: MultiPageCanvasProviderProps) {
|
|
577
|
+
export function MultiPageCanvasProvider({ children, enabled, storageKey, zoomOptions: zoomOptionsProp }: MultiPageCanvasProviderProps) {
|
|
578
|
+
// 拡大縮小の設定。倍率はどの経路(ホイール・ピンチ・キー・ボタン・全体表示)も clampZoom で範囲に収める
|
|
579
|
+
const zoomOptions = useMemo<ResolvedCanvasZoomOptions>(
|
|
580
|
+
() => resolveCanvasZoomOptions(zoomOptionsProp),
|
|
581
|
+
[zoomOptionsProp?.min, zoomOptionsProp?.max, zoomOptionsProp?.steps, zoomOptionsProp?.fitShortcut],
|
|
582
|
+
);
|
|
583
|
+
const zoomOptionsRef = useRef(zoomOptions);
|
|
584
|
+
zoomOptionsRef.current = zoomOptions;
|
|
585
|
+
const clampZoom = useCallback((z: number) => Math.max(zoomOptionsRef.current.min, Math.min(zoomOptionsRef.current.max, z)), []);
|
|
550
586
|
const artboard = useEditorArtboard();
|
|
551
587
|
const { editorMode } = useEditorTool();
|
|
552
588
|
const { viewportWidth } = useEditorView();
|
|
@@ -864,8 +900,17 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
|
|
|
864
900
|
[setView, viewStore],
|
|
865
901
|
);
|
|
866
902
|
|
|
867
|
-
|
|
868
|
-
const
|
|
903
|
+
// 刻みは既定で 1.25 倍ずつ。`steps: 'powers-of-two'` なら 2 の累乗の段(Figma。…50% → 100% → 200%…。段の間からは向きの側の段へ)
|
|
904
|
+
const stepZoom = useCallback((direction: 1 | -1) => {
|
|
905
|
+
const current = viewStore.get().canvasZoom;
|
|
906
|
+
const options = zoomOptionsRef.current;
|
|
907
|
+
const next = options.steps === 'powers-of-two'
|
|
908
|
+
? nextZoomLevel(current, direction, options)
|
|
909
|
+
: current * (direction > 0 ? ZOOM_STEP : 1 / ZOOM_STEP);
|
|
910
|
+
zoomTo(next, { animate: true });
|
|
911
|
+
}, [zoomTo, viewStore]);
|
|
912
|
+
const zoomIn = useCallback(() => stepZoom(1), [stepZoom]);
|
|
913
|
+
const zoomOut = useCallback(() => stepZoom(-1), [stepZoom]);
|
|
869
914
|
const zoomToActual = useCallback(() => zoomTo(1, { animate: true }), [zoomTo]);
|
|
870
915
|
|
|
871
916
|
const zoomToFit = useCallback<MultiPageCanvasContextValue['zoomToFit']>(
|
|
@@ -876,6 +921,14 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
|
|
|
876
921
|
[setView],
|
|
877
922
|
);
|
|
878
923
|
|
|
924
|
+
// 選択範囲に合わせる等。全体表示と違って 100% で止めない(小さい要素は大きく見せる。Figma と同じ)
|
|
925
|
+
const zoomToRect = useCallback<MultiPageCanvasContextValue['zoomToRect']>(
|
|
926
|
+
(rect, options) => {
|
|
927
|
+
setView(fitView(rect, containerSize(), { padding: FIT_PADDING, maxZoom: zoomOptionsRef.current.max, rulers: rulersRef.current }), options);
|
|
928
|
+
},
|
|
929
|
+
[setView],
|
|
930
|
+
);
|
|
931
|
+
|
|
879
932
|
const zoomToPage = useCallback<MultiPageCanvasContextValue['zoomToPage']>(
|
|
880
933
|
(id, options) => {
|
|
881
934
|
const page = pagesRef.current.find((p) => p.id === id);
|
|
@@ -1086,6 +1139,8 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
|
|
|
1086
1139
|
// 保存と読み込みは利用側(FrontendVisualEditor.handleContentChange)が 1 か所で行う。
|
|
1087
1140
|
// ここでは「どこへ移ろうとしているか」を持ち、リングを先に動かして押した手応えを返す
|
|
1088
1141
|
const [activatingPageId, setActivatingPageId] = useState<string | null>(null);
|
|
1142
|
+
// 移っている最中の数。activatePage が重なっても、全部が終わるまで 0 に戻らない(boolean だと先に終わった側が戻してしまう)
|
|
1143
|
+
const activatingCountRef = useRef(0);
|
|
1089
1144
|
const onContentChangeRef = useRef(artboard.onContentChange);
|
|
1090
1145
|
onContentChangeRef.current = artboard.onContentChange;
|
|
1091
1146
|
const activatePage = useCallback<MultiPageCanvasContextValue['activatePage']>(
|
|
@@ -1101,14 +1156,19 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
|
|
|
1101
1156
|
// この切替は終わっているので、state ではなく ref で残す
|
|
1102
1157
|
editorInitiatedPageIdRef.current = id;
|
|
1103
1158
|
setActivatingPageId(id);
|
|
1104
|
-
if (options?.reveal) revealPage(id);
|
|
1105
1159
|
let moved = false;
|
|
1160
|
+
// 移る先の本文を読むあいだは、見るだけの紙面を新しく載せない(編集するページを先に出す)。
|
|
1161
|
+
// 数を上げるのは try の直前。ここから先で例外が出ても finally で必ず下がる(下がらないと紙面が二度と載らない)
|
|
1162
|
+
activatingCountRef.current += 1;
|
|
1106
1163
|
try {
|
|
1164
|
+
if (options?.reveal) revealPage(id);
|
|
1107
1165
|
const result = await (notify as (contentId: string) => unknown)(id);
|
|
1108
1166
|
moved = result !== false;
|
|
1109
1167
|
return moved;
|
|
1110
1168
|
} finally {
|
|
1111
1169
|
setActivatingPageId((v) => (v === id ? null : v));
|
|
1170
|
+
activatingCountRef.current -= 1;
|
|
1171
|
+
pumpAfterInteractionRef.current();
|
|
1112
1172
|
// 移れなかったら印を消す(残すと、あとで同じページへホスト発で移るときに視点が動かない)。
|
|
1113
1173
|
// 別のページへ移ったあとなら消さない ── その印はそちらの切替のもの
|
|
1114
1174
|
if (!moved && editorInitiatedPageIdRef.current === id) editorInitiatedPageIdRef.current = null;
|
|
@@ -1120,10 +1180,22 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
|
|
|
1120
1180
|
// ---- 操作状態
|
|
1121
1181
|
const [isInteracting, setIsInteracting] = useState(false);
|
|
1122
1182
|
const interactingTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
1183
|
+
// パン・ズームの最中は見るだけの紙面を新しく載せない(下の pumpPreviewSlots)。
|
|
1184
|
+
// iframe を載せると本文の解析・スタイル・レイアウトがその段のフレームに乗り、
|
|
1185
|
+
// ズームアウトの途中で 50〜200ms のフレームが混じる(ヘッドレス 150 枚で実測)。
|
|
1186
|
+
// 止まったら(160ms 後)編集中のページに近い順に、空いた時間に 1 枚ずつ載せる。
|
|
1187
|
+
// それまでは骨組み(または画像)が見える。止まらずにパンし続けると、その間は 1 枚も増えない
|
|
1188
|
+
const interactingRef = useRef(false);
|
|
1189
|
+
const pumpAfterInteractionRef = useRef<() => void>(() => undefined);
|
|
1123
1190
|
const markInteracting = useCallback(() => {
|
|
1191
|
+
interactingRef.current = true;
|
|
1124
1192
|
setIsInteracting(true);
|
|
1125
1193
|
if (interactingTimer.current) clearTimeout(interactingTimer.current);
|
|
1126
|
-
interactingTimer.current = setTimeout(() =>
|
|
1194
|
+
interactingTimer.current = setTimeout(() => {
|
|
1195
|
+
interactingRef.current = false;
|
|
1196
|
+
setIsInteracting(false);
|
|
1197
|
+
pumpAfterInteractionRef.current();
|
|
1198
|
+
}, 160);
|
|
1127
1199
|
}, []);
|
|
1128
1200
|
useEffect(() => () => { if (interactingTimer.current) clearTimeout(interactingTimer.current); }, []);
|
|
1129
1201
|
|
|
@@ -1132,21 +1204,32 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
|
|
|
1132
1204
|
// ---- 見るだけの紙面の読み込み枠(同時に PREVIEW_PARALLEL 枚まで。編集中のページに近い順)
|
|
1133
1205
|
const slotQueueRef = useRef<{ id: string; grant: () => void }[]>([]);
|
|
1134
1206
|
const slotHoldersRef = useRef(new Set<string>());
|
|
1207
|
+
// 枠は 1 回に 1 枚だけ、主スレッドが空いたとき(requestIdleCallback)に渡す。
|
|
1208
|
+
// iframe を載せると本文の解析・スタイル・レイアウト・初回のラスタが同じ主スレッドに乗る。
|
|
1209
|
+
// 4 枚を同じフレームで渡すと 1 フレームに 4 枚分が積まれ、編集中のページの読み込みや
|
|
1210
|
+
// 直後のクリック・ページ移動の描画を押し出す(ヘッドレス 150 枚のページ移動で 30〜60ms のフレームが続いた)
|
|
1211
|
+
const pumpScheduledRef = useRef(false);
|
|
1135
1212
|
const pumpPreviewSlots = useCallback(() => {
|
|
1136
1213
|
const holders = slotHoldersRef.current;
|
|
1137
|
-
|
|
1138
|
-
if (holders.size >= PREVIEW_PARALLEL ||
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1214
|
+
if (pumpScheduledRef.current) return;
|
|
1215
|
+
if (holders.size >= PREVIEW_PARALLEL || slotQueueRef.current.length === 0 || interactingRef.current || activatingCountRef.current > 0) return;
|
|
1216
|
+
pumpScheduledRef.current = true;
|
|
1217
|
+
scheduleIdle(() => {
|
|
1218
|
+
pumpScheduledRef.current = false;
|
|
1219
|
+
const queue = slotQueueRef.current;
|
|
1220
|
+
if (holders.size >= PREVIEW_PARALLEL || queue.length === 0 || interactingRef.current || activatingCountRef.current > 0) return;
|
|
1221
|
+
const indexById = new Map(pagesRef.current.map((p) => [p.id, p.index] as const));
|
|
1222
|
+
const indexOf = (id: string) => indexById.get(id) ?? 0;
|
|
1223
|
+
const activeId = viewStore.get().activePageId;
|
|
1224
|
+
const activeIndex = activeId ? indexOf(activeId) : 0;
|
|
1225
|
+
queue.sort((a, b) => Math.abs(indexOf(a.id) - activeIndex) - Math.abs(indexOf(b.id) - activeIndex));
|
|
1145
1226
|
const next = queue.shift()!;
|
|
1146
1227
|
holders.add(next.id);
|
|
1147
1228
|
next.grant();
|
|
1148
|
-
|
|
1229
|
+
pumpAfterInteractionRef.current();
|
|
1230
|
+
});
|
|
1149
1231
|
}, [viewStore]);
|
|
1232
|
+
pumpAfterInteractionRef.current = pumpPreviewSlots;
|
|
1150
1233
|
const requestPreviewSlot = useCallback<MultiPageCanvasContextValue['requestPreviewSlot']>(
|
|
1151
1234
|
(id, grant) => {
|
|
1152
1235
|
if (slotHoldersRef.current.has(id)) {
|
|
@@ -1223,6 +1306,8 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
|
|
|
1223
1306
|
zoomToFit,
|
|
1224
1307
|
zoomToPage,
|
|
1225
1308
|
zoomToActual,
|
|
1309
|
+
zoomToRect,
|
|
1310
|
+
zoomOptions,
|
|
1226
1311
|
revealPage,
|
|
1227
1312
|
focusPage,
|
|
1228
1313
|
activatePage,
|
|
@@ -1244,7 +1329,7 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
|
|
|
1244
1329
|
[
|
|
1245
1330
|
enabled, editorMode, pages, bounds, layout, getPage, updatePageFrame, setPageHtml, invalidatePages, artboard.documentAttributes,
|
|
1246
1331
|
setPageHeight, ensurePageHtml, previewStyles, viewStore, setCanvasOffset, shiftView, setCanvasZoom, setView, zoomAt, zoomTo, zoomIn, zoomOut, zoomToFit, zoomToPage,
|
|
1247
|
-
zoomToActual, revealPage, focusPage, activatePage, activatingPageId, registerContainer, isInteracting, markInteracting,
|
|
1332
|
+
zoomToActual, zoomToRect, zoomOptions, revealPage, focusPage, activatePage, activatingPageId, registerContainer, isInteracting, markInteracting,
|
|
1248
1333
|
persisted, applyInitialView, rulersVisible, toggleRulers, requestPreviewSlot, releasePreviewSlot,
|
|
1249
1334
|
getPreviewImageZoomCap, reportThumbnailWidth, previewImageCapVersion,
|
|
1250
1335
|
],
|
|
@@ -1259,17 +1344,19 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
|
|
|
1259
1344
|
export function ConditionalMultiPageProvider({
|
|
1260
1345
|
enabled,
|
|
1261
1346
|
storageKey,
|
|
1347
|
+
zoomOptions,
|
|
1262
1348
|
children,
|
|
1263
1349
|
}: {
|
|
1264
1350
|
enabled: boolean;
|
|
1265
1351
|
storageKey?: string | null;
|
|
1352
|
+
zoomOptions?: CanvasZoomOptions;
|
|
1266
1353
|
children: React.ReactNode;
|
|
1267
1354
|
}) {
|
|
1268
1355
|
if (!enabled) {
|
|
1269
1356
|
return <>{children}</>;
|
|
1270
1357
|
}
|
|
1271
1358
|
return (
|
|
1272
|
-
<MultiPageCanvasProvider enabled={enabled} storageKey={storageKey}>
|
|
1359
|
+
<MultiPageCanvasProvider enabled={enabled} storageKey={storageKey} zoomOptions={zoomOptions}>
|
|
1273
1360
|
{children}
|
|
1274
1361
|
</MultiPageCanvasProvider>
|
|
1275
1362
|
);
|
|
@@ -31,11 +31,11 @@ export function useBrowserZoomPrevention(): void {
|
|
|
31
31
|
}
|
|
32
32
|
};
|
|
33
33
|
|
|
34
|
-
// Safari/WebKit
|
|
34
|
+
// Safari/WebKitのジェスチャーイベント(ピンチ)でのブラウザ拡大を防止
|
|
35
|
+
// 注: 伝播は止めない(0.10.0)。キャンバス(useInfiniteCanvas)がピンチを拡大縮小に使う。
|
|
36
|
+
// 以前は stopImmediatePropagation で握りつぶしていて、Safari・WKWebView ではピンチしても何も起きなかった
|
|
35
37
|
const preventGesture = (e: Event) => {
|
|
36
38
|
e.preventDefault();
|
|
37
|
-
e.stopPropagation();
|
|
38
|
-
e.stopImmediatePropagation();
|
|
39
39
|
return false;
|
|
40
40
|
};
|
|
41
41
|
|
|
@@ -45,6 +45,8 @@ import { SLIDE_WIDTH, SLIDE_HEIGHT, WEBPAGE_WIDTH, WEBPAGE_MIN_HEIGHT } from '..
|
|
|
45
45
|
|
|
46
46
|
// ズーム設定
|
|
47
47
|
export const MIN_ZOOM = 10; // 10%
|
|
48
|
+
/** この大きさ(px)に満たない容器はまだ組み上がっていないとみなし、フィットの計算に使わない */
|
|
49
|
+
const FIT_MIN_CONTAINER_PX = 120;
|
|
48
50
|
export const MAX_ZOOM = 400; // 400%
|
|
49
51
|
|
|
50
52
|
/**
|
|
@@ -169,6 +171,13 @@ export function useCanvasControls() {
|
|
|
169
171
|
// 初期ズーム設定済みフラグ
|
|
170
172
|
const initialZoomSetRef = useRef(false);
|
|
171
173
|
|
|
174
|
+
// 人が倍率を動かしたか。true の間は、容器の大きさが変わってもフィットに合わせ直さない(下の updateFitZoomValue)。
|
|
175
|
+
// ホイール・ピンチ・100%・選択範囲へのズーム・倍率の直接入力で true、全体表示(⌘0 など zoomToFit)で false。
|
|
176
|
+
// 「いまの倍率がフィット値に近いか」だけで決めると、手でズームしてたまたまフィット値の近くで止めたとき、次のリサイズで追従に戻ってしまう
|
|
177
|
+
const userZoomedRef = useRef(false);
|
|
178
|
+
// このフックが自分で入れた倍率(フィット・全体表示)。これと違う倍率が入ってきたら、人(ヘッダーの倍率など外の setZoom)が動かした
|
|
179
|
+
const selfAppliedZoomRef = useRef<number | null>(null);
|
|
180
|
+
|
|
172
181
|
// パンモードが有効かどうか(スペースキーまたはハンドツール)
|
|
173
182
|
const isPanModeActive = isSpacePressed || activeTool === 'move';
|
|
174
183
|
|
|
@@ -307,6 +316,7 @@ export function useCanvasControls() {
|
|
|
307
316
|
const zoomRef = useRef(zoom);
|
|
308
317
|
useEffect(() => {
|
|
309
318
|
zoomRef.current = zoom;
|
|
319
|
+
if (selfAppliedZoomRef.current !== null && Math.abs(zoom - selfAppliedZoomRef.current) >= 0.5) userZoomedRef.current = true;
|
|
310
320
|
}, [zoom]);
|
|
311
321
|
|
|
312
322
|
/**
|
|
@@ -359,6 +369,7 @@ export function useCanvasControls() {
|
|
|
359
369
|
heldAnchorRef.current = { anchor, time: now };
|
|
360
370
|
}
|
|
361
371
|
|
|
372
|
+
userZoomedRef.current = true;
|
|
362
373
|
zoomRef.current = clamped;
|
|
363
374
|
|
|
364
375
|
if (options?.continuous) {
|
|
@@ -422,6 +433,7 @@ export function useCanvasControls() {
|
|
|
422
433
|
ly: rect.y + rect.h / 2,
|
|
423
434
|
};
|
|
424
435
|
heldAnchorRef.current = null;
|
|
436
|
+
userZoomedRef.current = true;
|
|
425
437
|
|
|
426
438
|
// すでに同じ倍率なら setZoom しても再レンダーが起きない=補正の useEffect も走らない。
|
|
427
439
|
// 「押しても何も起きない」を作らないため、その場合はここで直接寄せる。
|
|
@@ -444,6 +456,8 @@ export function useCanvasControls() {
|
|
|
444
456
|
const zoomToFit = useCallback(() => {
|
|
445
457
|
heldAnchorRef.current = null;
|
|
446
458
|
zoomAnchorRef.current = null;
|
|
459
|
+
userZoomedRef.current = false;
|
|
460
|
+
selfAppliedZoomRef.current = fitZoom;
|
|
447
461
|
zoomRef.current = fitZoom;
|
|
448
462
|
setZoom(fitZoom);
|
|
449
463
|
}, [fitZoom, setZoom]);
|
|
@@ -452,6 +466,7 @@ export function useCanvasControls() {
|
|
|
452
466
|
const zoomToActual = useCallback(() => {
|
|
453
467
|
heldAnchorRef.current = null;
|
|
454
468
|
zoomAnchorRef.current = null;
|
|
469
|
+
userZoomedRef.current = true;
|
|
455
470
|
zoomRef.current = 100;
|
|
456
471
|
setZoom(100);
|
|
457
472
|
}, [setZoom]);
|
|
@@ -545,26 +560,48 @@ export function useCanvasControls() {
|
|
|
545
560
|
return Math.floor(Math.min(scaleX, scaleY) * 100);
|
|
546
561
|
}, [containerRef, canvasWidth, canvasHeight]);
|
|
547
562
|
|
|
548
|
-
//
|
|
563
|
+
// 初期化時と容器の大きさが変わったときにフィットズームを計算する。
|
|
564
|
+
// 「全体表示のまま」(人がまだ倍率を動かしていない = userZoomedRef が false)なら
|
|
565
|
+
// 倍率もフィットに合わせ直す(PowerPoint の「スライドを画面に合わせる」と同じ)。
|
|
566
|
+
// 合わせ直すのはスライド(高さが決まっている)だけ。webpage は本文の高さ(canvasHeight)が変わるたびに
|
|
567
|
+
// フィット値が変わるので、追従させると編集中に倍率が勝手に動く。webpage は最初の 1 回だけ合わせる(以前と同じ)。
|
|
568
|
+
// 以前は 100ms 後に 1 回だけ合わせていたので、その時点で容器が組み上がっていない
|
|
569
|
+
// (キャンバス →「1 枚」の切替直後・パネルが後から開く)と 3〜19% の豆粒のまま残った。
|
|
570
|
+
// 容器の大きさの変化は window の resize では拾えない(パネルの開閉)ので ResizeObserver で見る
|
|
571
|
+
const fitZoomRef = useRef(fitZoom);
|
|
572
|
+
fitZoomRef.current = fitZoom;
|
|
549
573
|
useEffect(() => {
|
|
550
574
|
if (isInMultiPageMode) return;
|
|
551
575
|
|
|
552
576
|
const updateFitZoomValue = () => {
|
|
577
|
+
const container = containerRef.current;
|
|
578
|
+
// 組み上がる前(幅・高さがほぼ 0)の値で倍率を決めない
|
|
579
|
+
if (!container || container.clientWidth < FIT_MIN_CONTAINER_PX || container.clientHeight < FIT_MIN_CONTAINER_PX) return;
|
|
553
580
|
const newFitZoom = calculateFitZoom();
|
|
581
|
+
const followsFit = !initialZoomSetRef.current || (editorMode !== 'webpage' && !userZoomedRef.current);
|
|
582
|
+
fitZoomRef.current = newFitZoom;
|
|
554
583
|
setFitZoom(newFitZoom);
|
|
555
|
-
if (
|
|
584
|
+
if (followsFit && Math.abs(zoomRef.current - newFitZoom) >= 0.5) {
|
|
585
|
+
selfAppliedZoomRef.current = newFitZoom;
|
|
586
|
+
zoomRef.current = newFitZoom;
|
|
556
587
|
setZoom(newFitZoom);
|
|
557
|
-
|
|
588
|
+
} else if (followsFit) {
|
|
589
|
+
selfAppliedZoomRef.current = zoomRef.current;
|
|
558
590
|
}
|
|
591
|
+
initialZoomSetRef.current = true;
|
|
559
592
|
};
|
|
560
593
|
|
|
561
594
|
const timer = setTimeout(updateFitZoomValue, 100);
|
|
562
595
|
window.addEventListener('resize', updateFitZoomValue);
|
|
596
|
+
const container = containerRef.current;
|
|
597
|
+
const observer = container && typeof ResizeObserver !== 'undefined' ? new ResizeObserver(() => updateFitZoomValue()) : null;
|
|
598
|
+
if (container) observer?.observe(container);
|
|
563
599
|
return () => {
|
|
564
600
|
clearTimeout(timer);
|
|
565
601
|
window.removeEventListener('resize', updateFitZoomValue);
|
|
602
|
+
observer?.disconnect();
|
|
566
603
|
};
|
|
567
|
-
}, [calculateFitZoom, setFitZoom, setZoom, isInMultiPageMode]);
|
|
604
|
+
}, [calculateFitZoom, setFitZoom, setZoom, isInMultiPageMode, containerRef, editorMode]);
|
|
568
605
|
|
|
569
606
|
// 初回マウント時に中央配置
|
|
570
607
|
useEffect(() => {
|
|
@@ -610,12 +647,9 @@ export function useCanvasControls() {
|
|
|
610
647
|
window.addEventListener('wheel', handleWheel, { passive: false, capture: true });
|
|
611
648
|
document.addEventListener('wheel', handleWheel, { passive: false, capture: true });
|
|
612
649
|
|
|
650
|
+
// ブラウザの拡大だけを止める。伝播は止めない(0.10.0。キャンバスがピンチを拡大縮小に使う)
|
|
613
651
|
const handleGesture = (e: Event) => {
|
|
614
|
-
if (isEditorOpen())
|
|
615
|
-
e.preventDefault();
|
|
616
|
-
e.stopPropagation();
|
|
617
|
-
e.stopImmediatePropagation();
|
|
618
|
-
}
|
|
652
|
+
if (isEditorOpen()) e.preventDefault();
|
|
619
653
|
};
|
|
620
654
|
|
|
621
655
|
window.addEventListener('gesturestart', handleGesture, { passive: false, capture: true });
|