@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.
Files changed (45) hide show
  1. package/README.md +90 -1
  2. package/dist/editor.css +1 -1
  3. package/package.json +1 -1
  4. package/src/editor/EditorContext.tsx +5 -1
  5. package/src/editor/FrontendVisualEditor.tsx +26 -12
  6. package/src/editor/components/EditorCanvas.tsx +11 -0
  7. package/src/editor/components/EditorContextMenu.tsx +52 -2
  8. package/src/editor/components/EditorFooter.tsx +29 -6
  9. package/src/editor/components/EditorHeader.tsx +15 -2
  10. package/src/editor/components/EditorLayerPanel.tsx +86 -0
  11. package/src/editor/components/LayerTreeItem.tsx +107 -3
  12. package/src/editor/components/canvas-guides/CanvasGuides.tsx +247 -0
  13. package/src/editor/components/multi-page/CanvasRulers.tsx +3 -0
  14. package/src/editor/components/multi-page/MultiPageCanvasView.tsx +12 -4
  15. package/src/editor/components/ppt/PptChrome.tsx +8 -4
  16. package/src/editor/components/ppt/PptComments.tsx +98 -1
  17. package/src/editor/components/shell/LeftPanel.tsx +8 -1
  18. package/src/editor/components/shell/PagesPanel.tsx +7 -1
  19. package/src/editor/components/shell/useRowsNearViewport.ts +64 -0
  20. package/src/editor/constants.ts +25 -0
  21. package/src/editor/contexts/EditorHistoryContext.tsx +6 -1
  22. package/src/editor/contexts/MultiPageCanvasContext.tsx +106 -19
  23. package/src/editor/hooks/useBrowserZoomPrevention.ts +3 -3
  24. package/src/editor/hooks/useCanvasControls.ts +43 -9
  25. package/src/editor/hooks/useDragResize.ts +205 -17
  26. package/src/editor/hooks/useElementActions.ts +119 -34
  27. package/src/editor/hooks/useInfiniteCanvas.ts +29 -5
  28. package/src/editor/hooks/useKeyboardShortcuts.ts +487 -0
  29. package/src/editor/hooks/useMarqueeSelection.ts +5 -1
  30. package/src/editor/utils/align-elements.ts +49 -0
  31. package/src/editor/utils/canvas-gestures.ts +210 -0
  32. package/src/editor/utils/canvas-guides.ts +46 -0
  33. package/src/editor/utils/dom-utils.ts +12 -2
  34. package/src/editor/utils/embedded-canvas-bridge.ts +6 -11
  35. package/src/editor/utils/history-timing.ts +9 -0
  36. package/src/editor/utils/html-utils.ts +4 -0
  37. package/src/editor/utils/lock.ts +24 -0
  38. package/src/editor/utils/smart-duplicate.ts +51 -0
  39. package/src/editor/utils/smart-guides.ts +232 -2
  40. package/src/hooks/useEditorShortcuts.ts +46 -2
  41. package/src/index.ts +5 -1
  42. package/src/io.ts +54 -0
  43. package/src/styles/skin.css +5 -0
  44. package/src/types/editor.ts +120 -1
  45. 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
- /** ズーム範囲(Figma は 2%〜25600%。紙面の編集で要るのはこの範囲) */
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
- /** ホイール 1 段のズーム倍率(ボタン・キー操作) */
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
- const clampZoom = (z: number) => Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, z));
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
- const zoomIn = useCallback(() => zoomTo(viewStore.get().canvasZoom * ZOOM_STEP, { animate: true }), [zoomTo, viewStore]);
868
- const zoomOut = useCallback(() => zoomTo(viewStore.get().canvasZoom / ZOOM_STEP, { animate: true }), [zoomTo, viewStore]);
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(() => setIsInteracting(false), 160);
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
- const queue = slotQueueRef.current;
1138
- if (holders.size >= PREVIEW_PARALLEL || queue.length === 0) return;
1139
- const indexById = new Map(pagesRef.current.map((p) => [p.id, p.index] as const));
1140
- const indexOf = (id: string) => indexById.get(id) ?? 0;
1141
- const activeId = viewStore.get().activePageId;
1142
- const activeIndex = activeId ? indexOf(activeId) : 0;
1143
- queue.sort((a, b) => Math.abs(indexOf(a.id) - activeIndex) - Math.abs(indexOf(b.id) - activeIndex));
1144
- while (holders.size < PREVIEW_PARALLEL && queue.length > 0) {
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 (!initialZoomSetRef.current) {
584
+ if (followsFit && Math.abs(zoomRef.current - newFitZoom) >= 0.5) {
585
+ selfAppliedZoomRef.current = newFitZoom;
586
+ zoomRef.current = newFitZoom;
556
587
  setZoom(newFitZoom);
557
- initialZoomSetRef.current = true;
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 });