@growgroup/visual-editor 0.1.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 (219) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +52 -0
  3. package/README.md +143 -0
  4. package/dist/editor.css +2 -0
  5. package/package.json +94 -0
  6. package/src/components/DeckSlideRender.tsx +21 -0
  7. package/src/components/SaveNote.tsx +21 -0
  8. package/src/components/auth/AuthProvider.tsx +44 -0
  9. package/src/components/ui/accordion.tsx +57 -0
  10. package/src/components/ui/alert-dialog.tsx +141 -0
  11. package/src/components/ui/badge.tsx +36 -0
  12. package/src/components/ui/button.tsx +56 -0
  13. package/src/components/ui/card.tsx +76 -0
  14. package/src/components/ui/checkbox.tsx +30 -0
  15. package/src/components/ui/collapsible.tsx +11 -0
  16. package/src/components/ui/context-menu.tsx +200 -0
  17. package/src/components/ui/dialog.tsx +122 -0
  18. package/src/components/ui/dropdown-menu.tsx +201 -0
  19. package/src/components/ui/input.tsx +22 -0
  20. package/src/components/ui/label.tsx +26 -0
  21. package/src/components/ui/popover.tsx +33 -0
  22. package/src/components/ui/scroll-area.tsx +48 -0
  23. package/src/components/ui/scrubbable-label.tsx +95 -0
  24. package/src/components/ui/select.tsx +159 -0
  25. package/src/components/ui/separator.tsx +31 -0
  26. package/src/components/ui/slider.tsx +28 -0
  27. package/src/components/ui/switch.tsx +29 -0
  28. package/src/components/ui/tabs.tsx +55 -0
  29. package/src/components/ui/textarea.tsx +22 -0
  30. package/src/components/ui/tooltip.tsx +32 -0
  31. package/src/components/viewer/useDeck.ts +58 -0
  32. package/src/editor/EditorContext.tsx +632 -0
  33. package/src/editor/EditorToolbar.tsx +446 -0
  34. package/src/editor/FrontendVisualEditor.tsx +2595 -0
  35. package/src/editor/autosave.ts +34 -0
  36. package/src/editor/components/AiPromptPopover.tsx +378 -0
  37. package/src/editor/components/BreakpointGuides.tsx +323 -0
  38. package/src/editor/components/BreakpointSelector.tsx +180 -0
  39. package/src/editor/components/ComponentPropertyEditor.tsx +426 -0
  40. package/src/editor/components/CssEditorDialog.tsx +168 -0
  41. package/src/editor/components/EditorCanvas.tsx +987 -0
  42. package/src/editor/components/EditorContextMenu.tsx +515 -0
  43. package/src/editor/components/EditorFooter.tsx +81 -0
  44. package/src/editor/components/EditorHeader.tsx +524 -0
  45. package/src/editor/components/EditorLayerPanel.tsx +1197 -0
  46. package/src/editor/components/EditorPropertyPanel.tsx +2996 -0
  47. package/src/editor/components/EditorSidebar.tsx +226 -0
  48. package/src/editor/components/FigmaColorPicker.tsx +1577 -0
  49. package/src/editor/components/HtmlEditorDialog.tsx +71 -0
  50. package/src/editor/components/HtmlImportDialog.tsx +346 -0
  51. package/src/editor/components/JsEditorDialog.tsx +175 -0
  52. package/src/editor/components/LayerTreeItem.tsx +494 -0
  53. package/src/editor/components/MasterComponentEditor.tsx +846 -0
  54. package/src/editor/components/MediaLibraryDialog.tsx +447 -0
  55. package/src/editor/components/PageSettingsDialog.tsx +776 -0
  56. package/src/editor/components/VariablePicker.tsx +355 -0
  57. package/src/editor/components/VariablesDialog.tsx +564 -0
  58. package/src/editor/components/VariantEditor.tsx +687 -0
  59. package/src/editor/components/VariantMatrix.tsx +507 -0
  60. package/src/editor/components/component-panel/CategoryAccordion.tsx +139 -0
  61. package/src/editor/components/component-panel/ComponentGrid.tsx +51 -0
  62. package/src/editor/components/component-panel/ComponentItem.tsx +368 -0
  63. package/src/editor/components/component-panel/ComponentPanel.tsx +275 -0
  64. package/src/editor/components/component-panel/ComponentSearch.tsx +82 -0
  65. package/src/editor/components/component-panel/VariantList.tsx +260 -0
  66. package/src/editor/components/component-panel/index.ts +12 -0
  67. package/src/editor/components/index.ts +48 -0
  68. package/src/editor/components/multi-page/CanvasZoomControls.tsx +71 -0
  69. package/src/editor/components/multi-page/InfiniteCanvas.tsx +95 -0
  70. package/src/editor/components/multi-page/MultiPageCanvasView.tsx +1404 -0
  71. package/src/editor/components/multi-page/PageFrameOverlay.tsx +76 -0
  72. package/src/editor/components/multi-page/PageLabel.tsx +34 -0
  73. package/src/editor/components/multi-page/PageLivePreview.tsx +242 -0
  74. package/src/editor/components/multi-page/PageThumbnail.tsx +59 -0
  75. package/src/editor/components/multi-page/index.ts +7 -0
  76. package/src/editor/components/ppt/PptChrome.tsx +2508 -0
  77. package/src/editor/components/ppt/PptComments.tsx +589 -0
  78. package/src/editor/components/ppt/PptDesignProposals.tsx +275 -0
  79. package/src/editor/components/ppt/PptFormatPane.tsx +320 -0
  80. package/src/editor/components/ppt/PptNotes.tsx +246 -0
  81. package/src/editor/components/property-panel/AlignmentPanel.tsx +110 -0
  82. package/src/editor/components/property-panel/AutoLayoutPanel.tsx +615 -0
  83. package/src/editor/components/property-panel/BorderSection.tsx +104 -0
  84. package/src/editor/components/property-panel/CompactNumberInput.tsx +237 -0
  85. package/src/editor/components/property-panel/CompactSizeInput.tsx +148 -0
  86. package/src/editor/components/property-panel/EffectSection.tsx +157 -0
  87. package/src/editor/components/property-panel/FillSection.tsx +69 -0
  88. package/src/editor/components/property-panel/GoogleFontPicker.tsx +192 -0
  89. package/src/editor/components/property-panel/ImageSection.tsx +206 -0
  90. package/src/editor/components/property-panel/ImgSrcSection.tsx +125 -0
  91. package/src/editor/components/property-panel/InstanceOverrideSection.tsx +413 -0
  92. package/src/editor/components/property-panel/LayoutSection.tsx +105 -0
  93. package/src/editor/components/property-panel/LinkSection.tsx +103 -0
  94. package/src/editor/components/property-panel/PropertyVariablePicker.tsx +311 -0
  95. package/src/editor/components/property-panel/ScalePanel.tsx +212 -0
  96. package/src/editor/components/property-panel/TypographySection.tsx +230 -0
  97. package/src/editor/components/property-panel/UnitAwareNumberInput.tsx +238 -0
  98. package/src/editor/components/property-panel/VariableAwareColorInput.tsx +477 -0
  99. package/src/editor/components/property-panel/VariableAwareInput.tsx +606 -0
  100. package/src/editor/components/property-panel/VariableAwareSizeInput.tsx +588 -0
  101. package/src/editor/components/property-panel/VariableAwareUnitInput.tsx +546 -0
  102. package/src/editor/components/property-panel/index.ts +24 -0
  103. package/src/editor/components/property-panel/unit-utils.ts +261 -0
  104. package/src/editor/components/shell/EditorTopBar.tsx +392 -0
  105. package/src/editor/components/shell/LeftPanel.tsx +47 -0
  106. package/src/editor/components/shell/PagesPanel.tsx +118 -0
  107. package/src/editor/components/variables-panel/ColorValueCell.tsx +165 -0
  108. package/src/editor/components/variables-panel/TextValueCell.tsx +116 -0
  109. package/src/editor/components/variables-panel/VariablesPanel.tsx +518 -0
  110. package/src/editor/components/variables-panel/index.ts +7 -0
  111. package/src/editor/constants.ts +468 -0
  112. package/src/editor/contexts/EditorArtboardContext.tsx +229 -0
  113. package/src/editor/contexts/EditorComponentsContext.tsx +1320 -0
  114. package/src/editor/contexts/EditorDocumentContext.tsx +113 -0
  115. package/src/editor/contexts/EditorHistoryContext.tsx +516 -0
  116. package/src/editor/contexts/EditorRefsContext.tsx +204 -0
  117. package/src/editor/contexts/EditorSelectionContext.tsx +54 -0
  118. package/src/editor/contexts/EditorToolContext.tsx +58 -0
  119. package/src/editor/contexts/EditorUIStateContext.tsx +122 -0
  120. package/src/editor/contexts/EditorVariablesContext.tsx +397 -0
  121. package/src/editor/contexts/EditorViewContext.tsx +101 -0
  122. package/src/editor/contexts/MultiPageCanvasContext.tsx +498 -0
  123. package/src/editor/contexts/index.ts +17 -0
  124. package/src/editor/editor-skin.css +122 -0
  125. package/src/editor/hooks/index.ts +38 -0
  126. package/src/editor/hooks/useAiReplace.ts +397 -0
  127. package/src/editor/hooks/useAltMeasure.ts +67 -0
  128. package/src/editor/hooks/useBrowserZoomPrevention.ts +121 -0
  129. package/src/editor/hooks/useCanvasControls.ts +1222 -0
  130. package/src/editor/hooks/useComponentEditMode.ts +658 -0
  131. package/src/editor/hooks/useComponentInstances.ts +515 -0
  132. package/src/editor/hooks/useContextMenuHandler.ts +157 -0
  133. package/src/editor/hooks/useCoordinateTransform.ts +207 -0
  134. package/src/editor/hooks/useDragResize.ts +1317 -0
  135. package/src/editor/hooks/useDrawingMode.ts +472 -0
  136. package/src/editor/hooks/useEditorColors.ts +144 -0
  137. package/src/editor/hooks/useEditorMessages.ts +108 -0
  138. package/src/editor/hooks/useElementActions.ts +1754 -0
  139. package/src/editor/hooks/useElementSelection.ts +1573 -0
  140. package/src/editor/hooks/useFocusManagement.ts +249 -0
  141. package/src/editor/hooks/useGoogleFonts.ts +305 -0
  142. package/src/editor/hooks/useIframeInitializer.ts +584 -0
  143. package/src/editor/hooks/useIframeSetup.ts +360 -0
  144. package/src/editor/hooks/useImageUpload.ts +270 -0
  145. package/src/editor/hooks/useInfiniteCanvas.ts +301 -0
  146. package/src/editor/hooks/useKeyboardShortcuts.ts +205 -0
  147. package/src/editor/hooks/useMarqueeSelection.ts +388 -0
  148. package/src/editor/hooks/useMediaLibrary.ts +129 -0
  149. package/src/editor/hooks/usePageSettingsManager.ts +739 -0
  150. package/src/editor/hooks/useResizablePanel.ts +163 -0
  151. package/src/editor/hooks/useRichPaste.ts +255 -0
  152. package/src/editor/hooks/useTouchGestures.ts +197 -0
  153. package/src/editor/index.ts +27 -0
  154. package/src/editor/types/page-settings.ts +513 -0
  155. package/src/editor/types.ts +382 -0
  156. package/src/editor/utils/align-elements.ts +169 -0
  157. package/src/editor/utils/api-json.ts +21 -0
  158. package/src/editor/utils/aspect-lock.ts +50 -0
  159. package/src/editor/utils/component-renderer.ts +702 -0
  160. package/src/editor/utils/component-sync.ts +845 -0
  161. package/src/editor/utils/crop-mode.ts +265 -0
  162. package/src/editor/utils/dom-utils.ts +2238 -0
  163. package/src/editor/utils/element-effects.ts +218 -0
  164. package/src/editor/utils/eyedropper.ts +35 -0
  165. package/src/editor/utils/figma-export.ts +560 -0
  166. package/src/editor/utils/figma-kiwi-decoder.ts +722 -0
  167. package/src/editor/utils/figma-kiwi-encoder.ts +583 -0
  168. package/src/editor/utils/figma-paste.ts +2262 -0
  169. package/src/editor/utils/flex-reorder.ts +231 -0
  170. package/src/editor/utils/flex-utils.ts +1230 -0
  171. package/src/editor/utils/geometry.ts +232 -0
  172. package/src/editor/utils/grid-layout.ts +210 -0
  173. package/src/editor/utils/html-utils.ts +470 -0
  174. package/src/editor/utils/index.ts +11 -0
  175. package/src/editor/utils/ink-style.ts +57 -0
  176. package/src/editor/utils/inline-format.ts +236 -0
  177. package/src/editor/utils/measure-distance.ts +120 -0
  178. package/src/editor/utils/override-apply.ts +526 -0
  179. package/src/editor/utils/override-detection.ts +613 -0
  180. package/src/editor/utils/paste-processors.ts +609 -0
  181. package/src/editor/utils/paste-sanitizer.ts +244 -0
  182. package/src/editor/utils/restore-flow.ts +188 -0
  183. package/src/editor/utils/shape-library.ts +171 -0
  184. package/src/editor/utils/slide-root.ts +29 -0
  185. package/src/editor/utils/smart-guides.ts +218 -0
  186. package/src/editor/utils/style-utils.ts +685 -0
  187. package/src/editor/utils/table-edit.ts +151 -0
  188. package/src/editor/utils/tailwind-mappings.ts +778 -0
  189. package/src/editor/utils/tailwind-utils.ts +804 -0
  190. package/src/editor/utils/text-highlight.ts +216 -0
  191. package/src/editor/utils/theme-to-variables.ts +341 -0
  192. package/src/editor/utils/variant-resolver.ts +456 -0
  193. package/src/editor/utils/viewport-utils.ts +295 -0
  194. package/src/hooks/useEditorHistory.ts +195 -0
  195. package/src/hooks/useEditorShortcuts.ts +260 -0
  196. package/src/index.ts +40 -0
  197. package/src/io.ts +134 -0
  198. package/src/lib/agent/slide-agent/types.ts +547 -0
  199. package/src/lib/agent/website-agent/types.ts +805 -0
  200. package/src/lib/api/auth-fetch.ts +58 -0
  201. package/src/lib/deck.ts +69 -0
  202. package/src/lib/export.ts +47 -0
  203. package/src/lib/firebase/config.ts +29 -0
  204. package/src/lib/firebase/css-variables.ts +378 -0
  205. package/src/lib/firebase/editor-components.ts +677 -0
  206. package/src/lib/firebase/storage.ts +469 -0
  207. package/src/lib/utils.ts +6 -0
  208. package/src/styles/editor.css +52 -0
  209. package/src/styles/skin.css +122 -0
  210. package/src/types/css-variables.ts +645 -0
  211. package/src/types/editor-components.ts +944 -0
  212. package/src/types/editor.ts +420 -0
  213. package/src/types/page-master.ts +197 -0
  214. package/src/types/page.ts +419 -0
  215. package/src/types/slide.ts +402 -0
  216. package/src/types/website-theme.ts +788 -0
  217. package/src/types/website.ts +374 -0
  218. package/src/vendor/firebase-firestore.ts +13 -0
  219. package/src/vendor/firebase-functions.ts +17 -0
@@ -0,0 +1,1222 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Figmaライクなキャンバス操作(ズーム、パン、ビューポート)を管理するHook
5
+ *
6
+ * 構造:
7
+ * - iframe が編集領域の全面を覆う
8
+ * - iframe 内は #canvas-container(overflow:auto = スクロール容器)
9
+ * > #canvas-scroll-area(flexで中央寄せ・サイズはJSで指定)
10
+ * > #artboard-wrapper(transform: scale() = ズーム)
11
+ * > #artboard(1920x1080 の紙面)
12
+ * - ズームは #artboard-wrapper の scale、パンは #canvas-container の scroll で表現する
13
+ *
14
+ * 操作方法:
15
+ * - Ctrl/Cmd + ホイール: カーソル位置を固定点にしたズーム
16
+ * - ホイール: 縦スクロール / Shift + ホイール: 横スクロール(useTouchGestures 側)
17
+ * - ピンチイン/アウト: 指の中心を固定点にしたズーム
18
+ * - スペース + ドラッグ / ハンドツール(H) + ドラッグ: パン
19
+ * - 拡大縮小ツール(K) + クリック: カーソル位置を中心に拡大
20
+ * / Alt+クリック: 縮小 / ドラッグ: 囲った範囲へズーム
21
+ * - Cmd+0 / Cmd+1 / Cmd+2: 全体表示 / 100% / 選択範囲
22
+ * (キー判定は useEditorShortcuts が唯一の経路。ここは実処理を
23
+ * zoomToFit / zoomToActual / zoomToSelection として公開する側)
24
+ * - キャンバスの余白(紙面の外)をダブルクリック: 全体表示
25
+ *
26
+ * [移植時の修正] 以前の実装が実機で動かなかった理由と、その直し方
27
+ * 1. パンの mousedown を *親ウィンドウの containerRef* に張っていた。
28
+ * 編集領域は iframe が覆っているので、その要素にマウスイベントは一切届かず
29
+ * スペース+ドラッグが完全に無反応だった(実測: scroll の変化 0px)。
30
+ * → iframe document 側にキャプチャフェーズで張り直した。
31
+ * 2. ズーム倍率の計算が `zoom * (1 + -deltaY*0.002*10)` で、ホイール1回
32
+ * (deltaY=-300) が 51% → 357% に飛んでいた。→ 指数関数 + 1イベントの
33
+ * 変化量に上限を設けた。
34
+ * 3. ズーム中心が「選択要素があればその中心」になっていてカーソル位置が無視されていた。
35
+ * さらにスクロール補正の式が #canvas-scroll-area の余白と flex 中央寄せを
36
+ * 考慮しておらず、実測で 600px 以上ずれていた。
37
+ * → #artboard の実測矩形から差分を求める方式に置き換えた(下記 settleAnchor)。
38
+ */
39
+
40
+ import { useEffect, useRef, useCallback, useState } from 'react';
41
+ import { useEditorContext } from '../EditorContext';
42
+ import { useMultiPageCanvasOptional } from '../contexts/MultiPageCanvasContext';
43
+ import { applyCanvasZoomDom, applyOverlayScale, syncSelectionOverlayRects } from '../utils/dom-utils';
44
+ import { SLIDE_WIDTH, SLIDE_HEIGHT, WEBPAGE_WIDTH, WEBPAGE_MIN_HEIGHT } from '../constants';
45
+
46
+ // ズーム設定
47
+ export const MIN_ZOOM = 10; // 10%
48
+ export const MAX_ZOOM = 400; // 400%
49
+
50
+ /**
51
+ * ホイールズームの感度。newZoom = zoom * exp(-deltaY * K)
52
+ * deltaY=100(マウスホイール1ノッチ相当)で約 1.16 倍。
53
+ * 線形式だと deltaY の大きいデバイスで倍率が飛ぶので指数にしている。
54
+ */
55
+ // ノッチ式マウスホイール用。1ノッチ(|deltaY|≈100)で約16%動く
56
+ const WHEEL_ZOOM_K = 0.0015;
57
+ /**
58
+ * トラックパッドのピンチ用。
59
+ * macOSのピンチは ctrlKey=true の wheel として、1イベント±数px の連続値で届く。
60
+ * ノッチ用の係数で受けると1イベント1%未満しか動かず「感度が悪すぎる」になる
61
+ * (ノッチ式と同じ距離を動かすのに20倍近いイベント数が要る)。
62
+ * 1イベントの|deltaY|が小さいものを連続デバイスとみなして強い係数を使う。
63
+ */
64
+ const PINCH_ZOOM_K = 0.008;
65
+ /** これ未満の|deltaY|は連続デバイス(ピンチ/精密トラックパッド)とみなす */
66
+ const CONTINUOUS_DELTA_MAX = 50;
67
+ /** 1イベントで変化してよい倍率の上限(暴れ防止) */
68
+ const MAX_STEP_FACTOR = 2;
69
+
70
+ /** 拡大縮小ツール(K)のクリック1回あたりの倍率 */
71
+ const ZOOM_TOOL_STEP = 1.25;
72
+
73
+ /**
74
+ * ズーム操作の実処理レジストリ。
75
+ *
76
+ * [なぜモジュールレベルの ref か]
77
+ * キーボードのディスパッチャ(useEditorShortcuts)に渡すコールバックは
78
+ * FrontendVisualEditor が組み立てているが、ズームの実処理を持つこのフックは
79
+ * EditorCanvas 側で呼ばれているため、戻り値を直接渡せない。
80
+ * 既存の editorCancelDragRef(useKeyboardShortcuts.ts)と同じ方式で橋渡しし、
81
+ * 「Cmd+0 で起きること」と「ヘッダーの全体表示で起きること」を1つの実装に揃える。
82
+ */
83
+ export const editorZoomApiRef: {
84
+ current: {
85
+ fit: () => void;
86
+ actual: () => void;
87
+ selection: () => void;
88
+ } | null;
89
+ } = { current: null };
90
+
91
+ /**
92
+ * ズーム時に「画面上のこの位置に留めたい紙面上の点」
93
+ * - vx, vy: iframe ビューポート座標
94
+ * - lx, ly: 紙面(#artboard)の論理座標
95
+ */
96
+ interface ZoomAnchor {
97
+ vx: number;
98
+ vy: number;
99
+ lx: number;
100
+ ly: number;
101
+ }
102
+
103
+ /**
104
+ * テキスト入力中かどうか(realm をまたいでも壊れない判定)
105
+ *
106
+ * [移植時の修正] 元は `e.target instanceof HTMLInputElement` だった。
107
+ * iframe 内の要素は別 realm なので親フレームの HTMLInputElement とは
108
+ * instanceof が一致せず、常に false になる。このハンドラは iframe document にも
109
+ * 張るため、そのままだと「テキスト編集中にスペースが打てない」不具合になる。
110
+ */
111
+ function isTypingTarget(target: EventTarget | null): boolean {
112
+ const el = target as (HTMLElement & { closest?: (s: string) => Element | null }) | null;
113
+ if (!el || typeof el !== 'object' || typeof el.tagName !== 'string') return false;
114
+
115
+ const tag = el.tagName.toUpperCase();
116
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
117
+ if (el.isContentEditable === true) return true;
118
+ if (typeof el.getAttribute === 'function' && el.getAttribute('contenteditable') === 'true') {
119
+ return true;
120
+ }
121
+ if (typeof el.closest === 'function') {
122
+ return !!el.closest('[contenteditable="true"], input, textarea, select');
123
+ }
124
+ return false;
125
+ }
126
+
127
+ /**
128
+ * スペースキーで「押す」操作が割り当たっている要素かどうか。
129
+ *
130
+ * ヘッダーのボタンにフォーカスが残った状態でスペースを奪うと、
131
+ * キーボードだけで操作している人がそのボタンを押せなくなる。
132
+ * realm をまたいでも壊れないプロパティだけで判定する。
133
+ */
134
+ function isSpaceActivatedControl(target: EventTarget | null): boolean {
135
+ const el = target as (HTMLElement & { closest?: (s: string) => Element | null }) | null;
136
+ if (!el || typeof el !== 'object' || typeof el.tagName !== 'string') return false;
137
+ const tag = el.tagName.toUpperCase();
138
+ if (tag === 'BUTTON' || tag === 'SUMMARY' || tag === 'A') return true;
139
+ if (typeof el.closest === 'function') {
140
+ return !!el.closest('button, summary, [role="button"], [role="checkbox"], [role="switch"], [role="menuitem"]');
141
+ }
142
+ return false;
143
+ }
144
+
145
+ export function useCanvasControls() {
146
+ const {
147
+ containerRef,
148
+ iframeRef,
149
+ zoom,
150
+ setZoom,
151
+ fitZoom,
152
+ setFitZoom,
153
+ activeTool,
154
+ getIframeDoc,
155
+ editorMode,
156
+ } = useEditorContext();
157
+
158
+ // マルチページモード判定(マルチページモードではuseInfiniteCanvasが操作を管理)
159
+ const multiPageCanvas = useMultiPageCanvasOptional();
160
+ const isInMultiPageMode = !!multiPageCanvas?.isEnabled;
161
+
162
+ // パン状態
163
+ const [isPanning, setIsPanning] = useState(false);
164
+ const [isSpacePressed, setIsSpacePressed] = useState(false);
165
+
166
+ // Webページモード用のコンテンツ高さ追跡
167
+ const [contentHeight, setContentHeight] = useState(WEBPAGE_MIN_HEIGHT);
168
+
169
+ // 初期ズーム設定済みフラグ
170
+ const initialZoomSetRef = useRef(false);
171
+
172
+ // パンモードが有効かどうか(スペースキーまたはハンドツール)
173
+ const isPanModeActive = isSpacePressed || activeTool === 'move';
174
+
175
+ // 拡大縮小ツール(K)が有効かどうか。
176
+ // パン中はパンを優先するので、ここでは activeTool だけを見る。
177
+ const isZoomToolActive = activeTool === 'scale';
178
+
179
+ // iframe 内のキャンバス要素を取得するヘルパー
180
+ const getCanvasContainer = useCallback((): HTMLElement | null => {
181
+ return getIframeDoc()?.getElementById('canvas-container') ?? null;
182
+ }, [getIframeDoc]);
183
+
184
+ const getArtboard = useCallback((): HTMLElement | null => {
185
+ return getIframeDoc()?.getElementById('artboard') ?? null;
186
+ }, [getIframeDoc]);
187
+
188
+ // キャンバスの寸法を計算(エディタモードによって異なる)
189
+ const canvasWidth = editorMode === 'webpage' ? WEBPAGE_WIDTH : SLIDE_WIDTH;
190
+ const canvasHeight = editorMode === 'webpage' ? contentHeight : SLIDE_HEIGHT;
191
+
192
+ // ========================================================================
193
+ // 座標変換:iframe ビューポート座標 ⇄ 紙面の論理座標
194
+ // ========================================================================
195
+
196
+ /**
197
+ * 現在 DOM に載っている倍率で、iframe ビューポート座標を紙面の論理座標に変換する。
198
+ *
199
+ * 倍率は state の zoom ではなく #artboard の実測値から取る。
200
+ * state と DOM の反映タイミングがずれても座標がずれないようにするため。
201
+ */
202
+ const clientToArtboard = useCallback(
203
+ (vx: number, vy: number): ZoomAnchor | null => {
204
+ const artboard = getArtboard();
205
+ if (!artboard) return null;
206
+ const rect = artboard.getBoundingClientRect();
207
+ const scale = rect.width / (artboard.offsetWidth || 1) || 1;
208
+ return { vx, vy, lx: (vx - rect.left) / scale, ly: (vy - rect.top) / scale };
209
+ },
210
+ [getArtboard]
211
+ );
212
+
213
+ /**
214
+ * アンカー点が元の画面位置に戻るようにスクロールを補正する。
215
+ *
216
+ * #artboard-wrapper は transform-origin: center center、
217
+ * #canvas-scroll-area は flex 中央寄せ + 200px の余白付き、と条件が入り組んでいるので、
218
+ * 数式でスクロール量を組み立てず「実際に描画されている #artboard の矩形」から
219
+ * ずれ量を直接測って引く。何度呼んでも同じ結果に収束する(べき等)。
220
+ *
221
+ * @param keepAutoCenter true のとき、紙面がその軸で容器に収まっているなら補正しない。
222
+ * 収まっている軸は EditorCanvas 側が「中央寄せ」に固定するのが正しい挙動なので
223
+ * (全体表示=Cmd+0 が中央に来る)、そこを奪い返さないための逃げ道。
224
+ * カーソル位置を固定したいホイールズームでは false(常にアンカー優先)。
225
+ */
226
+ const settleAnchor = useCallback(
227
+ (anchor: ZoomAnchor, keepAutoCenter: boolean) => {
228
+ const artboard = getArtboard();
229
+ const container = getCanvasContainer();
230
+ if (!artboard || !container) return;
231
+
232
+ const rect = artboard.getBoundingClientRect();
233
+ const scale = rect.width / (artboard.offsetWidth || 1) || 1;
234
+ // いまアンカー点が描画されている画面位置
235
+ const currentX = rect.left + anchor.lx * scale;
236
+ const currentY = rect.top + anchor.ly * scale;
237
+ // 本来あるべき位置との差。紙面を右に dx 動かす = scrollLeft を dx 減らす
238
+ const dx = anchor.vx - currentX;
239
+ const dy = anchor.vy - currentY;
240
+
241
+ const fitsX = keepAutoCenter && rect.width <= container.clientWidth;
242
+ const fitsY = keepAutoCenter && rect.height <= container.clientHeight;
243
+
244
+ if (!fitsX && Math.abs(dx) > 0.01) container.scrollLeft -= dx;
245
+ if (!fitsY && Math.abs(dy) > 0.01) container.scrollTop -= dy;
246
+ },
247
+ [getArtboard, getCanvasContainer]
248
+ );
249
+
250
+ // 次のズームで固定したい点。ホイール/ピンチが setZoom の直前に書き込む
251
+ const zoomAnchorRef = useRef<ZoomAnchor | null>(null);
252
+
253
+ /**
254
+ * ズーム反映後にアンカーを合わせ直す。
255
+ *
256
+ * [なぜここに置くか]
257
+ * このフックは EditorCanvas の先頭で呼ばれるので、この useEffect は
258
+ * EditorCanvas 側の「#artboard-wrapper に transform を適用する useEffect」より
259
+ * *先に* 走る。つまりこの時点の DOM はまだ変更前の倍率で、固定したい点の
260
+ * 論理座標を正確に読める。実際の補正は倍率適用後に走らせたいので rAF に回す。
261
+ *
262
+ * [なぜ rAF を二重にするか]
263
+ * EditorCanvas 側は「紙面が容器に収まる軸はスクロールを中央に寄せる」補正を
264
+ * 別の rAF で行う。これは軸ごとの判定で、例えば 1056x910 の容器では
265
+ * 横は倍率0.55以下、縦は0.843以下で発動する。つまり 0.55〜0.843 の帯では
266
+ * 縦だけ後から上書きされる。settleAnchor はべき等なので、相手の補正の後に
267
+ * もう一度走らせて上書きし返す。
268
+ */
269
+ useEffect(() => {
270
+ if (isInMultiPageMode) return;
271
+ if (!getCanvasContainer()) return;
272
+ if (skipSettleOnceRef.current) {
273
+ // 高速経路が既にDOMへ反映・補正済みの遅延コミット。二重補正しない
274
+ skipSettleOnceRef.current = false;
275
+ return;
276
+ }
277
+
278
+ let anchor = zoomAnchorRef.current;
279
+ zoomAnchorRef.current = null;
280
+ // カーソル(指)の位置を固定したい操作か、そうでない外部からのズームか
281
+ const hasPointerAnchor = anchor !== null;
282
+
283
+ if (!anchor) {
284
+ // ヘッダーの +/- や Cmd+0 / Cmd+1、プロパティパネルなど
285
+ // 「カーソル位置を持たない」経路からのズーム。Figma と同じくビューポート中心を固定する
286
+ // (紙面が丸ごと収まる倍率のときは中央寄せが優先される。settleAnchor 参照)
287
+ const container = getCanvasContainer();
288
+ if (!container) return;
289
+ const rect = container.getBoundingClientRect();
290
+ anchor = clientToArtboard(rect.left + rect.width / 2, rect.top + rect.height / 2);
291
+ }
292
+ if (!anchor) return;
293
+
294
+ const target = anchor;
295
+ const keepAutoCenter = !hasPointerAnchor;
296
+ requestAnimationFrame(() => {
297
+ settleAnchor(target, keepAutoCenter);
298
+ requestAnimationFrame(() => settleAnchor(target, keepAutoCenter));
299
+ });
300
+ }, [zoom, isInMultiPageMode, getCanvasContainer, clientToArtboard, settleAnchor]);
301
+
302
+ // ========================================================================
303
+ // ズーム
304
+ // ========================================================================
305
+
306
+ // ホイールが連続で来たとき、React の再レンダーを待たずに倍率を積み上げるための実値
307
+ const zoomRef = useRef(zoom);
308
+ useEffect(() => {
309
+ zoomRef.current = zoom;
310
+ }, [zoom]);
311
+
312
+ /**
313
+ * 直前にホイール/ピンチで掴んだ固定点。連続操作の間だけ使い回す。
314
+ *
315
+ * [なぜ使い回すか]
316
+ * パンもズーム補正も #canvas-container の scroll で表現しているが、
317
+ * scrollLeft/Top は **整数に丸められる**(実測: 100.37 を代入 → 100)。
318
+ * つまり1ステップにつき最大0.5pxだけ狙いからずれる。毎イベントで
319
+ * 「いまカーソルの下にある論理座標」を読み直すと、そのズレを正として
320
+ * 掴み直してしまうため誤差が積み上がる(実測: 5ステップで1.3px 流れる)。
321
+ * 連続ズーム中は最初に掴んだ紙面上の点を固定し続けることで、
322
+ * ズレを1ステップぶんの丸め誤差(1px未満)に閉じ込める。
323
+ */
324
+ const heldAnchorRef = useRef<{ anchor: ZoomAnchor; time: number } | null>(null);
325
+ /** 連続ズームのReactコミットを間引くためのタイマーと、コミット時の再センタリング抑止 */
326
+ const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
327
+ const skipSettleOnceRef = useRef(false);
328
+ /** 連続操作とみなす間隔(ms)とカーソル移動の許容量(px) */
329
+ const HOLD_MS = 700;
330
+ const HOLD_TOLERANCE = 3;
331
+
332
+ /**
333
+ * 指定した iframe ビューポート座標を固定点にしてズームする
334
+ * @param newZoom 目標倍率(%)
335
+ * @param clientX iframe ビューポート座標
336
+ * @param clientY iframe ビューポート座標
337
+ * @param options.continuous ホイール/ピンチのように連打される操作か
338
+ */
339
+ const zoomAtPoint = useCallback(
340
+ (newZoom: number, clientX: number, clientY: number, options?: { continuous?: boolean }) => {
341
+ const clamped = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, newZoom));
342
+ if (Math.abs(clamped - zoomRef.current) < 0.01) return;
343
+
344
+ const now = Date.now();
345
+ const held = heldAnchorRef.current;
346
+ const canReuse =
347
+ options?.continuous === true &&
348
+ held !== null &&
349
+ now - held.time < HOLD_MS &&
350
+ Math.abs(held.anchor.vx - clientX) <= HOLD_TOLERANCE &&
351
+ Math.abs(held.anchor.vy - clientY) <= HOLD_TOLERANCE;
352
+
353
+ // 連続中は論理座標(lx, ly)を保持したまま、画面上の狙い位置だけ今のカーソルに更新する
354
+ const anchor: ZoomAnchor | null = canReuse
355
+ ? { lx: held.anchor.lx, ly: held.anchor.ly, vx: clientX, vy: clientY }
356
+ : clientToArtboard(clientX, clientY);
357
+
358
+ if (anchor) {
359
+ heldAnchorRef.current = { anchor, time: now };
360
+ }
361
+
362
+ zoomRef.current = clamped;
363
+
364
+ if (options?.continuous) {
365
+ // ── 高速経路 ──
366
+ // ホイール/ピンチは毎秒数十回来る。Reactを1ティックごとに回すと
367
+ // サムネイル等の再レンダーで確実にガタつくため、DOMへ即時反映し、
368
+ // 状態へのコミット(%表示などの追随)は操作が落ち着いてからにする
369
+ const doc = getIframeDoc();
370
+ if (doc) {
371
+ applyCanvasZoomDom(doc, clamped);
372
+ if (anchor) settleAnchor(anchor, false);
373
+ applyOverlayScale(doc);
374
+ syncSelectionOverlayRects(doc);
375
+ }
376
+ if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
377
+ commitTimerRef.current = setTimeout(() => {
378
+ // コミットで走る補正エフェクトに「ビューポート中心へ寄せ直し」を
379
+ // させない(直前まで固定していたカーソル位置から視点が飛ぶため)
380
+ skipSettleOnceRef.current = true;
381
+ setZoom(zoomRef.current);
382
+ }, 160);
383
+ return;
384
+ }
385
+
386
+ if (anchor) zoomAnchorRef.current = anchor;
387
+ setZoom(clamped);
388
+ },
389
+ [clientToArtboard, setZoom, getIframeDoc, settleAnchor]
390
+ );
391
+
392
+ // ========================================================================
393
+ // ズームAPI(Cmd+0 / Cmd+1 / Cmd+2、ヘッダーのメニュー、拡大縮小ツールから使う)
394
+ //
395
+ // [なぜ関数として公開するか]
396
+ // 以前は「Cmd+0 を押す」も「ヘッダーの全体表示を押す」も、呼ぶ側がそれぞれ
397
+ // setZoom(fitZoom) と書いていた。同じ操作の実装が散らばると、片方だけ
398
+ // 固定点の扱いが違う(=押す場所によって結果が変わる)状態になりやすい。
399
+ // ズームの意味づけはこのフックに集約し、外からは名前で呼ぶ。
400
+ // ========================================================================
401
+
402
+ /**
403
+ * 紙面の論理矩形が画面に収まる倍率にし、その中心をビューポート中心に置く
404
+ * @param rect 紙面(#artboard)の論理座標系での矩形
405
+ * @param margin 画面端に残す余白(px)
406
+ */
407
+ const zoomToRect = useCallback(
408
+ (rect: { x: number; y: number; w: number; h: number }, margin = 80) => {
409
+ const container = getCanvasContainer();
410
+ if (!container || rect.w <= 0 || rect.h <= 0) return;
411
+
412
+ const cw = container.clientWidth;
413
+ const ch = container.clientHeight;
414
+ const scale = Math.min((cw - margin) / rect.w, (ch - margin) / rect.h);
415
+ const next = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, Math.floor(scale * 100)));
416
+
417
+ const cRect = container.getBoundingClientRect();
418
+ const anchor: ZoomAnchor = {
419
+ vx: cRect.left + cw / 2,
420
+ vy: cRect.top + ch / 2,
421
+ lx: rect.x + rect.w / 2,
422
+ ly: rect.y + rect.h / 2,
423
+ };
424
+ heldAnchorRef.current = null;
425
+
426
+ // すでに同じ倍率なら setZoom しても再レンダーが起きない=補正の useEffect も走らない。
427
+ // 「押しても何も起きない」を作らないため、その場合はここで直接寄せる。
428
+ if (Math.abs(next - zoomRef.current) < 0.01) {
429
+ requestAnimationFrame(() => {
430
+ settleAnchor(anchor, false);
431
+ requestAnimationFrame(() => settleAnchor(anchor, false));
432
+ });
433
+ return;
434
+ }
435
+
436
+ zoomAnchorRef.current = anchor;
437
+ zoomRef.current = next;
438
+ setZoom(next);
439
+ },
440
+ [getCanvasContainer, settleAnchor, setZoom]
441
+ );
442
+
443
+ /** 全体表示(Cmd+0)。固定点を持たない=ビューポート中心+中央寄せに任せる */
444
+ const zoomToFit = useCallback(() => {
445
+ heldAnchorRef.current = null;
446
+ zoomAnchorRef.current = null;
447
+ zoomRef.current = fitZoom;
448
+ setZoom(fitZoom);
449
+ }, [fitZoom, setZoom]);
450
+
451
+ /** 100%(Cmd+1) */
452
+ const zoomToActual = useCallback(() => {
453
+ heldAnchorRef.current = null;
454
+ zoomAnchorRef.current = null;
455
+ zoomRef.current = 100;
456
+ setZoom(100);
457
+ }, [setZoom]);
458
+
459
+ /** 選択範囲にズーム(Cmd+2)。無選択のときは全体表示にフォールバックする */
460
+ const zoomToSelection = useCallback(() => {
461
+ const doc = getIframeDoc();
462
+ const artboard = getArtboard();
463
+ if (!doc || !artboard) return;
464
+
465
+ const selected = Array.from(doc.querySelectorAll<HTMLElement>('.selected'));
466
+ if (selected.length === 0) {
467
+ zoomToFit();
468
+ return;
469
+ }
470
+
471
+ const ab = artboard.getBoundingClientRect();
472
+ const scale = ab.width / (artboard.offsetWidth || 1) || 1;
473
+ let left = Infinity;
474
+ let top = Infinity;
475
+ let right = -Infinity;
476
+ let bottom = -Infinity;
477
+ selected.forEach((el) => {
478
+ const r = el.getBoundingClientRect();
479
+ if (r.width === 0 && r.height === 0) return;
480
+ left = Math.min(left, r.left);
481
+ top = Math.min(top, r.top);
482
+ right = Math.max(right, r.right);
483
+ bottom = Math.max(bottom, r.bottom);
484
+ });
485
+ if (!Number.isFinite(left) || right <= left || bottom <= top) {
486
+ zoomToFit();
487
+ return;
488
+ }
489
+
490
+ zoomToRect({
491
+ x: (left - ab.left) / scale,
492
+ y: (top - ab.top) / scale,
493
+ w: (right - left) / scale,
494
+ h: (bottom - top) / scale,
495
+ });
496
+ }, [getIframeDoc, getArtboard, zoomToFit, zoomToRect]);
497
+
498
+ // ========================================================================
499
+ // ビューポート管理(既存API維持)
500
+ // ========================================================================
501
+
502
+ // Webページモードでコンテンツ高さを更新する関数
503
+ const updateContentHeight = useCallback(() => {
504
+ if (editorMode !== 'webpage') return;
505
+
506
+ const iframeDoc = getIframeDoc();
507
+ if (!iframeDoc) return;
508
+
509
+ const artboard = iframeDoc.getElementById('artboard');
510
+ const targetElement = artboard || iframeDoc.body;
511
+
512
+ const elements = targetElement.querySelectorAll('*');
513
+ let maxBottom = 0;
514
+ elements.forEach((el) => {
515
+ const bottom = (el as HTMLElement).getBoundingClientRect().bottom;
516
+ if (bottom > maxBottom) maxBottom = bottom;
517
+ });
518
+
519
+ const scrollHeight = targetElement.scrollHeight;
520
+ const computedHeight = Math.max(scrollHeight, maxBottom);
521
+ setContentHeight(Math.max(WEBPAGE_MIN_HEIGHT, computedHeight + 100));
522
+ }, [editorMode, getIframeDoc]);
523
+
524
+ // 初期表示時に中央にスクロール
525
+ const centerSlide = useCallback(() => {
526
+ const container = containerRef.current;
527
+ if (!container) return;
528
+ container.scrollTo({
529
+ left: (container.scrollWidth - container.clientWidth) / 2,
530
+ top: (container.scrollHeight - container.clientHeight) / 2,
531
+ behavior: 'instant',
532
+ });
533
+ }, [containerRef]);
534
+
535
+ // ズームフィット計算
536
+ const calculateFitZoom = useCallback(() => {
537
+ const container = containerRef.current;
538
+ if (!container) return 100;
539
+
540
+ const containerRect = container.getBoundingClientRect();
541
+ const padding = 64;
542
+ const scaleX = (containerRect.width - padding) / canvasWidth;
543
+ const scaleY = (containerRect.height - padding) / canvasHeight;
544
+ return Math.floor(Math.min(scaleX, scaleY) * 100);
545
+ }, [containerRef, canvasWidth, canvasHeight]);
546
+
547
+ // 初期化時とウィンドウリサイズ時にフィットズームを計算
548
+ useEffect(() => {
549
+ if (isInMultiPageMode) return;
550
+
551
+ const updateFitZoomValue = () => {
552
+ const newFitZoom = calculateFitZoom();
553
+ setFitZoom(newFitZoom);
554
+ if (!initialZoomSetRef.current) {
555
+ setZoom(newFitZoom);
556
+ initialZoomSetRef.current = true;
557
+ }
558
+ };
559
+
560
+ const timer = setTimeout(updateFitZoomValue, 100);
561
+ window.addEventListener('resize', updateFitZoomValue);
562
+ return () => {
563
+ clearTimeout(timer);
564
+ window.removeEventListener('resize', updateFitZoomValue);
565
+ };
566
+ }, [calculateFitZoom, setFitZoom, setZoom, isInMultiPageMode]);
567
+
568
+ // 初回マウント時に中央配置
569
+ useEffect(() => {
570
+ if (isInMultiPageMode) return;
571
+ if (containerRef.current) {
572
+ centerSlide();
573
+ requestAnimationFrame(centerSlide);
574
+ setTimeout(centerSlide, 50);
575
+ setTimeout(centerSlide, 150);
576
+ setTimeout(centerSlide, 300);
577
+ }
578
+ }, [centerSlide, isInMultiPageMode, containerRef]);
579
+
580
+ // iframeロード後にも中央配置を実行
581
+ useEffect(() => {
582
+ if (isInMultiPageMode) return;
583
+ const iframe = iframeRef.current;
584
+ if (!iframe) return;
585
+
586
+ const handleLoad = () => {
587
+ requestAnimationFrame(centerSlide);
588
+ setTimeout(centerSlide, 100);
589
+ };
590
+
591
+ iframe.addEventListener('load', handleLoad);
592
+ return () => iframe.removeEventListener('load', handleLoad);
593
+ }, [iframeRef, centerSlide, isInMultiPageMode]);
594
+
595
+ // ========================================================================
596
+ // ブラウザのネイティブズーム防止
597
+ // ========================================================================
598
+ useEffect(() => {
599
+ const isEditorOpen = () =>
600
+ document.querySelector('[data-frontend-visual-editor="true"], [data-slide-visual-editor="true"]');
601
+
602
+ const handleWheel = (e: WheelEvent) => {
603
+ // Ctrl/Cmdキーが押されている場合(トラックパッドのピンチを含む)はブラウザズームを止める
604
+ if ((e.ctrlKey || e.metaKey) && isEditorOpen()) {
605
+ e.preventDefault();
606
+ }
607
+ };
608
+
609
+ window.addEventListener('wheel', handleWheel, { passive: false, capture: true });
610
+ document.addEventListener('wheel', handleWheel, { passive: false, capture: true });
611
+
612
+ const handleGesture = (e: Event) => {
613
+ if (isEditorOpen()) {
614
+ e.preventDefault();
615
+ e.stopPropagation();
616
+ e.stopImmediatePropagation();
617
+ }
618
+ };
619
+
620
+ window.addEventListener('gesturestart', handleGesture, { passive: false, capture: true });
621
+ window.addEventListener('gesturechange', handleGesture, { passive: false, capture: true });
622
+ window.addEventListener('gestureend', handleGesture, { passive: false, capture: true });
623
+ document.addEventListener('gesturestart', handleGesture, { passive: false, capture: true });
624
+ document.addEventListener('gesturechange', handleGesture, { passive: false, capture: true });
625
+ document.addEventListener('gestureend', handleGesture, { passive: false, capture: true });
626
+
627
+ const handleTouchStart = (e: TouchEvent) => {
628
+ if (e.touches.length >= 2 && isEditorOpen()) {
629
+ e.preventDefault();
630
+ e.stopPropagation();
631
+ }
632
+ };
633
+ const handleTouchMove = (e: TouchEvent) => {
634
+ if (e.touches.length >= 2 && isEditorOpen()) {
635
+ e.preventDefault();
636
+ e.stopPropagation();
637
+ }
638
+ };
639
+
640
+ window.addEventListener('touchstart', handleTouchStart, { passive: false, capture: true });
641
+ window.addEventListener('touchmove', handleTouchMove, { passive: false, capture: true });
642
+ document.addEventListener('touchstart', handleTouchStart, { passive: false, capture: true });
643
+ document.addEventListener('touchmove', handleTouchMove, { passive: false, capture: true });
644
+
645
+ return () => {
646
+ window.removeEventListener('wheel', handleWheel, { capture: true });
647
+ document.removeEventListener('wheel', handleWheel, { capture: true });
648
+ window.removeEventListener('gesturestart', handleGesture, { capture: true });
649
+ window.removeEventListener('gesturechange', handleGesture, { capture: true });
650
+ window.removeEventListener('gestureend', handleGesture, { capture: true });
651
+ document.removeEventListener('gesturestart', handleGesture, { capture: true });
652
+ document.removeEventListener('gesturechange', handleGesture, { capture: true });
653
+ document.removeEventListener('gestureend', handleGesture, { capture: true });
654
+ window.removeEventListener('touchstart', handleTouchStart, { capture: true });
655
+ window.removeEventListener('touchmove', handleTouchMove, { capture: true });
656
+ document.removeEventListener('touchstart', handleTouchStart, { capture: true });
657
+ document.removeEventListener('touchmove', handleTouchMove, { capture: true });
658
+ };
659
+ }, []);
660
+
661
+ // ========================================================================
662
+ // パン(スペース + ドラッグ / 移動ツール + ドラッグ)
663
+ // ========================================================================
664
+
665
+ // ドラッグ中の値。setState を挟むと mousemove に間に合わないので ref を正とする
666
+ const panRef = useRef({ active: false, startX: 0, startY: 0, scrollLeft: 0, scrollTop: 0 });
667
+
668
+ /**
669
+ * イベントの座標を親ウィンドウ基準に揃える。
670
+ * iframe 内で起きたイベントは iframe ビューポート基準なので、iframe の位置を足す。
671
+ * パンでは差分しか使わないが、始点と経過点で基準がずれると飛ぶので必ず通す。
672
+ */
673
+ const toParentClient = useCallback(
674
+ (e: MouseEvent): { x: number; y: number } => {
675
+ const doc = (e.target as Node | null)?.ownerDocument;
676
+ if (doc && doc !== document) {
677
+ const rect = iframeRef.current?.getBoundingClientRect();
678
+ if (rect) return { x: e.clientX + rect.left, y: e.clientY + rect.top };
679
+ }
680
+ return { x: e.clientX, y: e.clientY };
681
+ },
682
+ [iframeRef]
683
+ );
684
+
685
+ /**
686
+ * イベントの座標を iframe ビューポート基準に揃える。
687
+ * ズームの固定点(clientToArtboard / zoomAtPoint)は iframe 基準なので、
688
+ * ドラッグが iframe の外に出たときもこちらに合わせる。
689
+ */
690
+ const toIframeClient = useCallback(
691
+ (e: MouseEvent): { x: number; y: number } => {
692
+ const doc = (e.target as Node | null)?.ownerDocument;
693
+ if (doc && doc !== document) return { x: e.clientX, y: e.clientY };
694
+ const rect = iframeRef.current?.getBoundingClientRect();
695
+ if (rect) return { x: e.clientX - rect.left, y: e.clientY - rect.top };
696
+ return { x: e.clientX, y: e.clientY };
697
+ },
698
+ [iframeRef]
699
+ );
700
+
701
+ // ========================================================================
702
+ // 拡大縮小ツール(K)
703
+ //
704
+ // [方針] このツールは2つの役割を持たせている。
705
+ // - 選択中の要素のハンドルをドラッグ → オブジェクトの拡大縮小(useDragResize が担当)
706
+ // - それ以外の場所 → キャンバスのズーム(クリック=拡大 / Alt+クリック=縮小 / ドラッグ=範囲)
707
+ // ハンドルの上ではイベントを一切奪わないので、既存のスケール操作は壊れない。
708
+ // ========================================================================
709
+
710
+ /** ドラッグ中の値。mousemove に間に合わせるため ref を正とする(iframe ビューポート座標) */
711
+ const zoomDragRef = useRef({ active: false, sx: 0, sy: 0, x: 0, y: 0, moved: false, alt: false });
712
+ /** 範囲ズームのラバーバンド要素 */
713
+ const zoomBandRef = useRef<HTMLElement | null>(null);
714
+ /** クリックとドラッグを分ける距離(px) */
715
+ const ZOOM_DRAG_THRESHOLD = 6;
716
+
717
+ /** ラバーバンドを描く(iframe ビューポート座標なので position: fixed で置く) */
718
+ const drawZoomBand = useCallback(() => {
719
+ const doc = getIframeDoc();
720
+ if (!doc?.body) return;
721
+ let band = zoomBandRef.current;
722
+ if (!band || !band.isConnected || band.ownerDocument !== doc) {
723
+ band = doc.createElement('div');
724
+ band.setAttribute('data-editor-ui', 'zoom-band');
725
+ band.style.cssText =
726
+ 'position:fixed;pointer-events:none;z-index:2147483647;' +
727
+ 'border:1px solid #3b82f6;background:rgba(59,130,246,0.12);';
728
+ doc.body.appendChild(band);
729
+ zoomBandRef.current = band;
730
+ }
731
+ const d = zoomDragRef.current;
732
+ band.style.left = `${Math.min(d.sx, d.x)}px`;
733
+ band.style.top = `${Math.min(d.sy, d.y)}px`;
734
+ band.style.width = `${Math.abs(d.x - d.sx)}px`;
735
+ band.style.height = `${Math.abs(d.y - d.sy)}px`;
736
+ }, [getIframeDoc]);
737
+
738
+ const removeZoomBand = useCallback(() => {
739
+ zoomBandRef.current?.remove();
740
+ zoomBandRef.current = null;
741
+ }, []);
742
+
743
+ // 最新の実装をレンダーごとに差し替えるための箱。
744
+ // 実際に addEventListener するのは下の stableHandlers(常に同じ関数オブジェクト)で、
745
+ // 二重登録による二重発火が構造的に起きないようにしている。
746
+ const implRef = useRef({
747
+ onKeyDown: (_e: KeyboardEvent) => {},
748
+ onKeyUp: (_e: KeyboardEvent) => {},
749
+ onBlur: () => {},
750
+ onMouseDown: (_e: MouseEvent) => {},
751
+ onMouseMove: (_e: MouseEvent) => {},
752
+ onMouseUp: (_e: MouseEvent) => {},
753
+ onDblClick: (_e: MouseEvent) => {},
754
+ });
755
+
756
+ implRef.current.onKeyDown = (e: KeyboardEvent) => {
757
+ if (e.code !== 'Space' || e.repeat) return;
758
+ // テキスト入力中のスペースは本来の入力として通す
759
+ if (isTypingTarget(e.target)) return;
760
+ const doc = (e.target as Node | null)?.ownerDocument ?? null;
761
+ const active = doc?.activeElement ?? null;
762
+ if (isTypingTarget(active)) return;
763
+ // ボタンにフォーカスが残っているときは、そのボタンを押す操作を優先する
764
+ if (isSpaceActivatedControl(e.target) || isSpaceActivatedControl(active)) return;
765
+ e.preventDefault();
766
+ setIsSpacePressed(true);
767
+ };
768
+
769
+ implRef.current.onKeyUp = (e: KeyboardEvent) => {
770
+ if (e.code !== 'Space') return;
771
+ setIsSpacePressed(false);
772
+ if (panRef.current.active) {
773
+ panRef.current.active = false;
774
+ setIsPanning(false);
775
+ }
776
+ };
777
+
778
+ // Cmd+Tab などで keyup を取り逃してパンモードに張り付くのを防ぐ
779
+ implRef.current.onBlur = () => {
780
+ setIsSpacePressed(false);
781
+ if (panRef.current.active) {
782
+ panRef.current.active = false;
783
+ setIsPanning(false);
784
+ }
785
+ // 範囲ズームのドラッグ中にウィンドウを離れたとき、
786
+ // ラバーバンドが取り残されないようにする
787
+ if (zoomDragRef.current.active) {
788
+ zoomDragRef.current.active = false;
789
+ removeZoomBand();
790
+ }
791
+ };
792
+
793
+ implRef.current.onMouseDown = (e: MouseEvent) => {
794
+ if (e.button !== 0) return;
795
+
796
+ // パンが最優先(拡大縮小ツール中でもスペースを押せばパンできる)
797
+ if (isPanModeActive) {
798
+ const container = getCanvasContainer();
799
+ if (!container) return;
800
+
801
+ // 選択・マーキー・描画より先にイベントを止める(このハンドラはキャプチャで張っている)
802
+ e.preventDefault();
803
+ e.stopPropagation();
804
+
805
+ const { x, y } = toParentClient(e);
806
+ panRef.current = {
807
+ active: true,
808
+ startX: x,
809
+ startY: y,
810
+ scrollLeft: container.scrollLeft,
811
+ scrollTop: container.scrollTop,
812
+ };
813
+ setIsPanning(true);
814
+ return;
815
+ }
816
+
817
+ if (!isZoomToolActive) return;
818
+
819
+ // ハンドル/選択枠の上は「オブジェクトの拡大縮小」に渡す(イベントを奪わない)
820
+ const target = e.target as (HTMLElement & { closest?: (s: string) => Element | null }) | null;
821
+ if (typeof target?.closest === 'function' && target.closest('.resize-handle, .selection-box')) {
822
+ return;
823
+ }
824
+
825
+ e.preventDefault();
826
+ e.stopPropagation();
827
+ const pt = toIframeClient(e);
828
+ zoomDragRef.current = {
829
+ active: true,
830
+ sx: pt.x,
831
+ sy: pt.y,
832
+ x: pt.x,
833
+ y: pt.y,
834
+ moved: false,
835
+ alt: e.altKey,
836
+ };
837
+ };
838
+
839
+ implRef.current.onMouseMove = (e: MouseEvent) => {
840
+ if (panRef.current.active) {
841
+ const container = getCanvasContainer();
842
+ if (!container) return;
843
+ e.preventDefault();
844
+ const { x, y } = toParentClient(e);
845
+ // 掴んだ紙面がカーソルに追従する = スクロールは逆方向
846
+ container.scrollLeft = panRef.current.scrollLeft - (x - panRef.current.startX);
847
+ container.scrollTop = panRef.current.scrollTop - (y - panRef.current.startY);
848
+ return;
849
+ }
850
+
851
+ const drag = zoomDragRef.current;
852
+ if (!drag.active) return;
853
+ const pt = toIframeClient(e);
854
+ drag.x = pt.x;
855
+ drag.y = pt.y;
856
+ if (!drag.moved && Math.hypot(pt.x - drag.sx, pt.y - drag.sy) > ZOOM_DRAG_THRESHOLD) {
857
+ drag.moved = true;
858
+ }
859
+ if (drag.moved) drawZoomBand();
860
+ };
861
+
862
+ implRef.current.onMouseUp = (e: MouseEvent) => {
863
+ if (panRef.current.active) {
864
+ panRef.current.active = false;
865
+ setIsPanning(false);
866
+ return;
867
+ }
868
+
869
+ const drag = zoomDragRef.current;
870
+ if (!drag.active) return;
871
+ drag.active = false;
872
+ removeZoomBand();
873
+
874
+ if (drag.moved) {
875
+ // 範囲ズーム: 囲った矩形が画面いっぱいに来るようにする
876
+ const a = clientToArtboard(drag.sx, drag.sy);
877
+ const b = clientToArtboard(drag.x, drag.y);
878
+ const w = a && b ? Math.abs(b.lx - a.lx) : 0;
879
+ const h = a && b ? Math.abs(b.ly - a.ly) : 0;
880
+ // 真横/真下だけのドラッグは面積が 0 になり、範囲としては使えない。
881
+ // ここで黙って何もしないと「バンドは出たのにズームしない」になるので、
882
+ // クリックと同じ扱い(カーソル位置で拡大)にフォールバックする。
883
+ if (a && b && w > 2 && h > 2) {
884
+ zoomToRect({
885
+ x: Math.min(a.lx, b.lx),
886
+ y: Math.min(a.ly, b.ly),
887
+ w,
888
+ h,
889
+ });
890
+ return;
891
+ }
892
+ }
893
+
894
+ // クリック = カーソル位置を固定点に拡大 / Alt+クリック = 縮小
895
+ const zoomOut = e.altKey || drag.alt;
896
+ zoomAtPoint(zoomRef.current * (zoomOut ? 1 / ZOOM_TOOL_STEP : ZOOM_TOOL_STEP), drag.x, drag.y);
897
+ };
898
+
899
+ /**
900
+ * キャンバスの余白(紙面の外)をダブルクリックで全体表示。
901
+ *
902
+ * [移植時の修正] 以前は *親ウィンドウの* containerRef に dblclick を張り、
903
+ * `e.target === iframe内の #canvas-container` と比較していた。
904
+ * dblclick はフレーム境界を越えないので、この条件は永久に成立せず
905
+ * 完全な死にコードだった(実測: 60%表示で余白をダブルクリック → 60%のまま)。
906
+ * iframe document 側に張り直したうえで、判定も「e.target が何か」ではなく
907
+ * 「編集可能な要素にヒットしたか」に変えている。ヒットしたときは
908
+ * ダブルクリック=1階層潜る(useElementSelection の担当)なので何もしない。
909
+ */
910
+ implRef.current.onDblClick = (e: MouseEvent) => {
911
+ const target = e.target as (HTMLElement & { closest?: (s: string) => Element | null }) | null;
912
+ if (typeof target?.closest === 'function') {
913
+ if (target.closest('[data-editable="true"], [data-element-id]')) return;
914
+ if (target.closest('.resize-handle, .selection-box')) return;
915
+ }
916
+ zoomToFit();
917
+ };
918
+
919
+ // 実際に登録する不変のハンドラ群
920
+ const stableHandlersRef = useRef<{
921
+ keyDown: (e: Event) => void;
922
+ keyUp: (e: Event) => void;
923
+ blur: () => void;
924
+ mouseDown: (e: Event) => void;
925
+ mouseMove: (e: Event) => void;
926
+ mouseUp: (e: Event) => void;
927
+ dblClick: (e: Event) => void;
928
+ } | null>(null);
929
+ if (!stableHandlersRef.current) {
930
+ stableHandlersRef.current = {
931
+ keyDown: (e) => implRef.current.onKeyDown(e as KeyboardEvent),
932
+ keyUp: (e) => implRef.current.onKeyUp(e as KeyboardEvent),
933
+ blur: () => implRef.current.onBlur(),
934
+ mouseDown: (e) => implRef.current.onMouseDown(e as MouseEvent),
935
+ mouseMove: (e) => implRef.current.onMouseMove(e as MouseEvent),
936
+ mouseUp: (e) => implRef.current.onMouseUp(e as MouseEvent),
937
+ dblClick: (e) => implRef.current.onDblClick(e as MouseEvent),
938
+ };
939
+ }
940
+
941
+ /**
942
+ * 親ウィンドウと iframe document の両方にパン用のリスナーを張る。
943
+ *
944
+ * iframe は差し替えのたびに document が作り直されるので、load とポーリングで
945
+ * 最新の document に張り直す(キーボードディスパッチャと同じ方式)。
946
+ * mousedown だけキャプチャフェーズにしているのは、iframe document に
947
+ * バブリングで張られている選択ハンドラ(useElementSelection)より先に
948
+ * 掴む必要があるため。
949
+ */
950
+ useEffect(() => {
951
+ if (isInMultiPageMode) return;
952
+ if (typeof window === 'undefined') return;
953
+ const h = stableHandlersRef.current;
954
+ if (!h) return;
955
+
956
+ window.addEventListener('keydown', h.keyDown);
957
+ window.addEventListener('keyup', h.keyUp);
958
+ window.addEventListener('blur', h.blur);
959
+ window.addEventListener('mousemove', h.mouseMove);
960
+ window.addEventListener('mouseup', h.mouseUp);
961
+
962
+ const attachedDocs = new Set<Document>();
963
+ let watchedIframe: HTMLIFrameElement | null = null;
964
+
965
+ const attach = (doc: Document) => {
966
+ doc.addEventListener('keydown', h.keyDown);
967
+ doc.addEventListener('keyup', h.keyUp);
968
+ doc.addEventListener('mousedown', h.mouseDown, true);
969
+ doc.addEventListener('mousemove', h.mouseMove);
970
+ doc.addEventListener('mouseup', h.mouseUp);
971
+ // ダブルクリックは「編集可能要素にヒットしなかったとき」だけ拾うので
972
+ // 選択側(バブリング)より先に横取りしないよう、こちらもバブリングで張る
973
+ doc.addEventListener('dblclick', h.dblClick);
974
+ doc.defaultView?.addEventListener('blur', h.blur);
975
+ };
976
+ const detach = (doc: Document) => {
977
+ try {
978
+ doc.removeEventListener('keydown', h.keyDown);
979
+ doc.removeEventListener('keyup', h.keyUp);
980
+ doc.removeEventListener('mousedown', h.mouseDown, true);
981
+ doc.removeEventListener('mousemove', h.mouseMove);
982
+ doc.removeEventListener('mouseup', h.mouseUp);
983
+ doc.removeEventListener('dblclick', h.dblClick);
984
+ doc.defaultView?.removeEventListener('blur', h.blur);
985
+ } catch {
986
+ /* すでに破棄された document は無視 */
987
+ }
988
+ };
989
+
990
+ const sync = () => {
991
+ const el = iframeRef.current ?? null;
992
+ if (el !== watchedIframe) {
993
+ watchedIframe?.removeEventListener('load', sync);
994
+ watchedIframe = el;
995
+ watchedIframe?.addEventListener('load', sync);
996
+ }
997
+ let doc: Document | null = null;
998
+ try {
999
+ doc = el?.contentDocument ?? null;
1000
+ } catch {
1001
+ doc = null;
1002
+ }
1003
+ if (doc && !attachedDocs.has(doc)) {
1004
+ attach(doc);
1005
+ attachedDocs.add(doc);
1006
+ }
1007
+ // 破棄済み document は保持しない
1008
+ attachedDocs.forEach((d) => {
1009
+ if (!d.defaultView) attachedDocs.delete(d);
1010
+ });
1011
+ };
1012
+
1013
+ sync();
1014
+ const timer = window.setInterval(sync, 400);
1015
+
1016
+ return () => {
1017
+ window.clearInterval(timer);
1018
+ window.removeEventListener('keydown', h.keyDown);
1019
+ window.removeEventListener('keyup', h.keyUp);
1020
+ window.removeEventListener('blur', h.blur);
1021
+ window.removeEventListener('mousemove', h.mouseMove);
1022
+ window.removeEventListener('mouseup', h.mouseUp);
1023
+ watchedIframe?.removeEventListener('load', sync);
1024
+ attachedDocs.forEach(detach);
1025
+ attachedDocs.clear();
1026
+ };
1027
+ }, [isInMultiPageMode, iframeRef]);
1028
+
1029
+ /**
1030
+ * パン中のカーソル表示。
1031
+ * iframe 側は body の pan-mode / panning クラス(CSSは html-utils にある)、
1032
+ * 親ウィンドウ側は body の cursor で表現する。
1033
+ *
1034
+ * [移植時の修正] 以前はスペースキーの keydown 内でだけクラスを付けていたので、
1035
+ * 移動ツール(H)で入ったパンモードでは手のカーソルにならなかった。
1036
+ * isPanModeActive を唯一の情報源にして両方の入口をカバーする。
1037
+ */
1038
+ useEffect(() => {
1039
+ if (isInMultiPageMode) return;
1040
+
1041
+ const iframeDoc = getIframeDoc();
1042
+ const body = iframeDoc?.body;
1043
+
1044
+ if (isPanModeActive) {
1045
+ body?.classList.add('pan-mode');
1046
+ document.body.style.cursor = isPanning ? 'grabbing' : 'grab';
1047
+ } else {
1048
+ body?.classList.remove('pan-mode');
1049
+ document.body.style.cursor = '';
1050
+ }
1051
+
1052
+ if (isPanning) body?.classList.add('panning');
1053
+ else body?.classList.remove('panning');
1054
+
1055
+ return () => {
1056
+ // モード終了時にカーソルを残さない
1057
+ if (!isPanModeActive) document.body.style.cursor = '';
1058
+ };
1059
+ }, [isPanModeActive, isPanning, getIframeDoc, isInMultiPageMode]);
1060
+
1061
+ // アンマウント時にカーソルとクラスを必ず戻す
1062
+ useEffect(() => {
1063
+ return () => {
1064
+ document.body.style.cursor = '';
1065
+ const body = getIframeDoc()?.body;
1066
+ body?.classList.remove('pan-mode');
1067
+ body?.classList.remove('panning');
1068
+ };
1069
+ }, [getIframeDoc]);
1070
+
1071
+ // キーボードディスパッチャ(Cmd+0 / Cmd+1 / Cmd+2)から呼べるように登録する
1072
+ useEffect(() => {
1073
+ if (isInMultiPageMode) return;
1074
+ editorZoomApiRef.current = {
1075
+ fit: zoomToFit,
1076
+ actual: zoomToActual,
1077
+ selection: zoomToSelection,
1078
+ };
1079
+ return () => {
1080
+ editorZoomApiRef.current = null;
1081
+ };
1082
+ }, [zoomToFit, zoomToActual, zoomToSelection, isInMultiPageMode]);
1083
+
1084
+ // ========================================================================
1085
+ // 拡大縮小ツール中のカーソルと当たり判定
1086
+ //
1087
+ // ズーム操作であることが見て分かるように zoom-in / zoom-out カーソルにする。
1088
+ // 併せて [data-editable] の pointer-events を切り、クリックが要素の選択に
1089
+ // 化けないようにする(#artboard 自体は残すので .resize-handle は生きたまま=
1090
+ // 選択済み要素のハンドルドラッグによる「オブジェクトの拡大縮小」は使える)。
1091
+ // ========================================================================
1092
+ useEffect(() => {
1093
+ if (isInMultiPageMode) return;
1094
+
1095
+ const STYLE_ID = 'editor-zoom-tool-style';
1096
+ const apply = () => {
1097
+ const doc = getIframeDoc();
1098
+ const body = doc?.body;
1099
+ if (!doc || !body) return;
1100
+
1101
+ if (!doc.getElementById(STYLE_ID)) {
1102
+ const style = doc.createElement('style');
1103
+ style.id = STYLE_ID;
1104
+ style.textContent = [
1105
+ 'body.zoom-tool { cursor: zoom-in !important; }',
1106
+ 'body.zoom-tool.zoom-out { cursor: zoom-out !important; }',
1107
+ 'body.zoom-tool [data-editable="true"] { pointer-events: none !important; }',
1108
+ ].join('\n');
1109
+ doc.head?.appendChild(style);
1110
+ }
1111
+ body.classList.toggle('zoom-tool', isZoomToolActive);
1112
+ if (!isZoomToolActive) body.classList.remove('zoom-out');
1113
+ };
1114
+
1115
+ apply();
1116
+ if (!isZoomToolActive) {
1117
+ // ツールを離れたらドラッグ中のラバーバンドも残さない
1118
+ zoomDragRef.current.active = false;
1119
+ removeZoomBand();
1120
+ }
1121
+ // スライドを切り替えると iframe の document ごと作り直されてクラスが消えるので、
1122
+ // 他のリスナー再登録(sync)と同じ間隔で貼り直す
1123
+ const timer = window.setInterval(apply, 400);
1124
+
1125
+ return () => {
1126
+ window.clearInterval(timer);
1127
+ getIframeDoc()?.body?.classList.remove('zoom-tool', 'zoom-out');
1128
+ };
1129
+ }, [isZoomToolActive, getIframeDoc, isInMultiPageMode, removeZoomBand]);
1130
+
1131
+ // Alt キーで zoom-in / zoom-out カーソルを入れ替える(押した結果が事前に分かる状態にする)
1132
+ useEffect(() => {
1133
+ if (isInMultiPageMode || !isZoomToolActive) return;
1134
+ const sync = (e: KeyboardEvent) => {
1135
+ const body = getIframeDoc()?.body;
1136
+ if (!body) return;
1137
+ body.classList.toggle('zoom-out', e.altKey);
1138
+ };
1139
+ const clear = () => getIframeDoc()?.body?.classList.remove('zoom-out');
1140
+
1141
+ const docs: Array<Document | Window> = [window];
1142
+ const iframeDoc = getIframeDoc();
1143
+ if (iframeDoc) docs.push(iframeDoc);
1144
+ docs.forEach((d) => {
1145
+ d.addEventListener('keydown', sync as EventListener);
1146
+ d.addEventListener('keyup', sync as EventListener);
1147
+ });
1148
+ window.addEventListener('blur', clear);
1149
+ return () => {
1150
+ docs.forEach((d) => {
1151
+ d.removeEventListener('keydown', sync as EventListener);
1152
+ d.removeEventListener('keyup', sync as EventListener);
1153
+ });
1154
+ window.removeEventListener('blur', clear);
1155
+ clear();
1156
+ };
1157
+ }, [isZoomToolActive, getIframeDoc, isInMultiPageMode]);
1158
+
1159
+ // ========================================================================
1160
+ // iframe からのホイール/ピンチイベント(useTouchGestures が postMessage で転送)
1161
+ // ========================================================================
1162
+ useEffect(() => {
1163
+ if (isInMultiPageMode) return;
1164
+
1165
+ const handleMessage = (e: MessageEvent) => {
1166
+ // Ctrl/Cmd + ホイール。座標は iframe ビューポート基準で送られてくる
1167
+ if (e.data?.type === 'IFRAME_WHEEL_EVENT') {
1168
+ const { deltaY, clientX, clientY } = e.data as {
1169
+ deltaY: number;
1170
+ clientX: number;
1171
+ clientY: number;
1172
+ };
1173
+ if (typeof deltaY !== 'number') return;
1174
+ const k = Math.abs(deltaY) < CONTINUOUS_DELTA_MAX ? PINCH_ZOOM_K : WHEEL_ZOOM_K;
1175
+ let factor = Math.exp(-deltaY * k);
1176
+ factor = Math.max(1 / MAX_STEP_FACTOR, Math.min(MAX_STEP_FACTOR, factor));
1177
+ // continuous: 連打される操作。固定点を掴み直さない(丸め誤差の蓄積対策)
1178
+ zoomAtPoint(zoomRef.current * factor, clientX, clientY, { continuous: true });
1179
+ }
1180
+
1181
+ // ピンチ。指の間隔の比率をそのまま倍率比にする
1182
+ if (e.data?.type === 'IFRAME_PINCH_EVENT') {
1183
+ const { previousDistance, currentDistance, centerX, centerY } = e.data as {
1184
+ previousDistance: number;
1185
+ currentDistance: number;
1186
+ centerX: number;
1187
+ centerY: number;
1188
+ };
1189
+ if (!previousDistance || !currentDistance) return;
1190
+ let factor = currentDistance / previousDistance;
1191
+ factor = Math.max(1 / MAX_STEP_FACTOR, Math.min(MAX_STEP_FACTOR, factor));
1192
+ zoomAtPoint(zoomRef.current * factor, centerX, centerY, { continuous: true });
1193
+ }
1194
+ };
1195
+
1196
+ window.addEventListener('message', handleMessage);
1197
+ return () => window.removeEventListener('message', handleMessage);
1198
+ }, [zoomAtPoint, isInMultiPageMode]);
1199
+
1200
+ return {
1201
+ // パン状態
1202
+ isPanning,
1203
+ isSpacePressed,
1204
+ isPanModeActive,
1205
+ // ズーム
1206
+ zoomAtPoint,
1207
+ // ズームAPI(Cmd+0 / Cmd+1 / Cmd+2 やヘッダーのメニューから呼べる形で公開する)
1208
+ zoomToFit,
1209
+ zoomToActual,
1210
+ zoomToSelection,
1211
+ zoomToRect,
1212
+ isZoomToolActive,
1213
+ // ビューポート管理
1214
+ centerSlide,
1215
+ calculateFitZoom,
1216
+ updateContentHeight,
1217
+ // キャンバス寸法
1218
+ canvasWidth,
1219
+ canvasHeight,
1220
+ contentHeight,
1221
+ };
1222
+ }