@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,1404 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * MultiPageCanvasView
5
+ *
6
+ * 全ページ同時編集可能な無限キャンバス
7
+ * - 全ページのiframeに親側から編集ハンドラをアタッチ
8
+ * - updateSelectionBox / extractElementInfo を直接使用
9
+ * - キーボードショートカット、リサイズハンドル、ドラッグ完備
10
+ */
11
+
12
+ import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
13
+ import { useMultiPageCanvas } from '../../contexts/MultiPageCanvasContext';
14
+ import { useEditorContext } from '../../EditorContext';
15
+ import { useEditorRefs } from '../../contexts/EditorRefsContext';
16
+ import { useInfiniteCanvas } from '../../hooks/useInfiniteCanvas';
17
+ import { extractElementInfo } from '../../utils/style-utils';
18
+ import {
19
+ buildDomTree,
20
+ updateSelectionBox,
21
+ removeSelectionBox,
22
+ } from '../../utils/dom-utils';
23
+ import { EDITOR_IFRAME_STYLES } from '../../constants';
24
+ import {
25
+ startAutoLayoutDrag,
26
+ updateDragPosition,
27
+ endAutoLayoutDrag,
28
+ cancelAutoLayoutDrag,
29
+ findDropTarget,
30
+ showDropIndicator,
31
+ hideFlexDropIndicator,
32
+ type AutoLayoutDragState,
33
+ type DropTargetInfo,
34
+ } from '../../utils/flex-utils';
35
+ import { PageLivePreview } from './PageLivePreview';
36
+ import { PageLabel } from './PageLabel';
37
+ import { CanvasZoomControls } from './CanvasZoomControls';
38
+
39
+ /** キャンバスモード用の追加CSS(data-element-idベース) */
40
+ const CANVAS_EDITING_STYLES = `
41
+ /* トップレベル要素のホバー */
42
+ #artboard > [data-element-id]:hover:not(.selected):not(.editing) {
43
+ outline: 2px solid rgba(13, 153, 255, 0.3);
44
+ outline-offset: -1px;
45
+ }
46
+ /* 全要素のホバー(Ctrl/Cmd押下で子要素選択可) */
47
+ [data-element-id]:hover:not(.selected):not(.editing) {
48
+ outline: 1px solid rgba(13, 153, 255, 0.15);
49
+ outline-offset: -1px;
50
+ }
51
+ [data-element-id].selected {
52
+ outline: none !important;
53
+ cursor: move;
54
+ }
55
+ [data-element-id].dragging {
56
+ opacity: 0.7;
57
+ cursor: grabbing !important;
58
+ }
59
+ [data-element-id].editing {
60
+ outline: 2px solid #0d99ff !important;
61
+ outline-offset: 2px;
62
+ cursor: text !important;
63
+ min-height: 1em;
64
+ }
65
+ .marquee-hover {
66
+ outline: 2px solid rgba(13, 153, 255, 0.6) !important;
67
+ outline-offset: 1px;
68
+ }
69
+ /* 描画ツール: 既存要素のpointer-events無効化 */
70
+ body.draw-mode [data-element-id] {
71
+ pointer-events: none;
72
+ }
73
+ body.text-mode [data-element-id] {
74
+ pointer-events: none;
75
+ }
76
+ `;
77
+
78
+ export const MultiPageCanvasView = memo(function MultiPageCanvasView() {
79
+ const {
80
+ viewState,
81
+ pages,
82
+ updatePageFrame,
83
+ focusPage,
84
+ zoomToFit,
85
+ setCanvasZoom,
86
+ setCanvasOffset,
87
+ registerUndoHandlers,
88
+ } = useMultiPageCanvas();
89
+ const {
90
+ setZoom,
91
+ setFitZoom,
92
+ setSelectedElement,
93
+ setSelectedElementIds,
94
+ setDomTree,
95
+ setExpandedNodes,
96
+ activeTool,
97
+ layoutMode,
98
+ } = useEditorContext();
99
+ const refs = useEditorRefs();
100
+ const { canvasOffset, canvasZoom } = viewState;
101
+ const containerRef = useRef<HTMLDivElement>(null);
102
+
103
+ // 各ページのiframe参照を保持
104
+ const iframeMapRef = useRef(new Map<string, HTMLIFrameElement>());
105
+
106
+ // 無限キャンバスのズーム/パン操作
107
+ useInfiniteCanvas(containerRef);
108
+
109
+ // エディタズームを100%に固定(キャンバスズームが全体スケーリングを担う)
110
+ useEffect(() => {
111
+ setZoom(100);
112
+ setFitZoom(100);
113
+ }, [setZoom, setFitZoom]);
114
+
115
+ const [isInteracting, setIsInteracting] = useState(false);
116
+ const interactingTimeoutRef = useRef<number | null>(null);
117
+
118
+ useEffect(() => {
119
+ const handleInteractData = (e?: Event) => {
120
+ // For message events, only trigger if it's a relevant one
121
+ if (e && e.type === 'message') {
122
+ const msg = (e as MessageEvent).data;
123
+ if (!['PAGE_PREVIEW_WHEEL', 'IFRAME_WHEEL_EVENT', 'IFRAME_PINCH_EVENT'].includes(msg?.type)) {
124
+ return;
125
+ }
126
+ }
127
+
128
+ setIsInteracting(true);
129
+ if (interactingTimeoutRef.current) {
130
+ window.clearTimeout(interactingTimeoutRef.current);
131
+ }
132
+ interactingTimeoutRef.current = window.setTimeout(() => {
133
+ setIsInteracting(false);
134
+ }, 150);
135
+ };
136
+
137
+ window.addEventListener('wheel', handleInteractData, { passive: true, capture: true });
138
+ window.addEventListener('touchstart', handleInteractData, { passive: true, capture: true });
139
+ window.addEventListener('touchmove', handleInteractData, { passive: true, capture: true });
140
+ window.addEventListener('message', handleInteractData);
141
+
142
+ return () => {
143
+ window.removeEventListener('wheel', handleInteractData, { capture: true });
144
+ window.removeEventListener('touchstart', handleInteractData, { capture: true });
145
+ window.removeEventListener('touchmove', handleInteractData, { capture: true });
146
+ window.removeEventListener('message', handleInteractData);
147
+ if (interactingTimeoutRef.current) window.clearTimeout(interactingTimeoutRef.current);
148
+ };
149
+ }, []);
150
+
151
+ // 初回フィットズーム
152
+ const hasInitialFitRef = useRef(false);
153
+ useEffect(() => {
154
+ if (hasInitialFitRef.current || pages.length === 0) return;
155
+ const container = containerRef.current;
156
+ if (!container) return;
157
+
158
+ const timer = setTimeout(() => {
159
+ const rect = container.getBoundingClientRect();
160
+ if (rect.width > 0 && rect.height > 0) {
161
+ zoomToFit(rect.width, rect.height);
162
+ hasInitialFitRef.current = true;
163
+ }
164
+ }, 100);
165
+ return () => clearTimeout(timer);
166
+ }, [pages.length, zoomToFit]);
167
+
168
+ // Stale closure回避用ref
169
+ const viewStateRef = useRef(viewState);
170
+ viewStateRef.current = viewState;
171
+ const pagesRef = useRef(pages);
172
+ pagesRef.current = pages;
173
+ const setSelectedElementRef = useRef(setSelectedElement);
174
+ setSelectedElementRef.current = setSelectedElement;
175
+ const setSelectedElementIdsRef = useRef(setSelectedElementIds);
176
+ setSelectedElementIdsRef.current = setSelectedElementIds;
177
+ const setDomTreeRef = useRef(setDomTree);
178
+ setDomTreeRef.current = setDomTree;
179
+ const setExpandedNodesRef = useRef(setExpandedNodes);
180
+ setExpandedNodesRef.current = setExpandedNodes;
181
+ const updatePageFrameRef = useRef(updatePageFrame);
182
+ updatePageFrameRef.current = updatePageFrame;
183
+ const activeToolRef = useRef(activeTool);
184
+ activeToolRef.current = activeTool;
185
+ const focusPageRef = useRef(focusPage);
186
+ focusPageRef.current = focusPage;
187
+ const layoutModeRef = useRef(layoutMode);
188
+ layoutModeRef.current = layoutMode;
189
+
190
+ // --- Global Undo/Redo Stack for Multi-Page ---
191
+ // A single sequential timeline across all iframes
192
+ const globalUndoStackRef = useRef<{ pageId: string; html: string }[]>([]);
193
+ const globalRedoStackRef = useRef<{ pageId: string; html: string }[]>([]);
194
+
195
+ const canUndo = useCallback(() => globalUndoStackRef.current.length > 0, []);
196
+ const canRedo = useCallback(() => globalRedoStackRef.current.length > 0, []);
197
+
198
+ const performGlobalUndo = useCallback(() => {
199
+ const stack = globalUndoStackRef.current;
200
+ if (stack.length === 0) return;
201
+
202
+ const lastAction = stack.pop()!;
203
+ const targetIframe = containerRef.current?.querySelector(`iframe[data-page-id="${lastAction.pageId}"]`) as HTMLIFrameElement;
204
+ const targetDoc = targetIframe?.contentDocument || targetIframe?.contentWindow?.document;
205
+ const targetArtboard = targetDoc?.getElementById('artboard');
206
+
207
+ if (!targetArtboard || !targetDoc) {
208
+ stack.push(lastAction);
209
+ return;
210
+ }
211
+
212
+ globalRedoStackRef.current.push({
213
+ pageId: lastAction.pageId,
214
+ html: targetArtboard.innerHTML
215
+ });
216
+
217
+ targetArtboard.innerHTML = lastAction.html;
218
+
219
+ // Clear selections explicitly at context level
220
+ setSelectedElementRef.current(null);
221
+ setSelectedElementIdsRef.current([]);
222
+
223
+ // Trigger update
224
+ updatePageFrameRef.current(lastAction.pageId, {
225
+ thumbnailHtml: lastAction.html,
226
+ isDirty: true,
227
+ });
228
+
229
+ // Fix Tree
230
+ const tree = buildDomTree(targetDoc);
231
+ setDomTreeRef.current(tree);
232
+ }, []);
233
+
234
+ const performGlobalRedo = useCallback(() => {
235
+ const redoStack = globalRedoStackRef.current;
236
+ if (redoStack.length === 0) return;
237
+
238
+ const nextAction = redoStack.pop()!;
239
+ const targetIframe = containerRef.current?.querySelector(`iframe[data-page-id="${nextAction.pageId}"]`) as HTMLIFrameElement;
240
+ const targetDoc = targetIframe?.contentDocument || targetIframe?.contentWindow?.document;
241
+ const targetArtboard = targetDoc?.getElementById('artboard');
242
+
243
+ if (!targetArtboard || !targetDoc) {
244
+ redoStack.push(nextAction);
245
+ return;
246
+ }
247
+
248
+ globalUndoStackRef.current.push({
249
+ pageId: nextAction.pageId,
250
+ html: targetArtboard.innerHTML
251
+ });
252
+
253
+ targetArtboard.innerHTML = nextAction.html;
254
+
255
+ setSelectedElementRef.current(null);
256
+ setSelectedElementIdsRef.current([]);
257
+
258
+ updatePageFrameRef.current(nextAction.pageId, {
259
+ thumbnailHtml: nextAction.html,
260
+ isDirty: true,
261
+ });
262
+
263
+ const tree = buildDomTree(targetDoc);
264
+ setDomTreeRef.current(tree);
265
+ }, []);
266
+
267
+ // Register these globally to the Toolbar Context
268
+ useEffect(() => {
269
+ if (registerUndoHandlers) {
270
+ registerUndoHandlers({
271
+ undo: performGlobalUndo,
272
+ redo: performGlobalRedo,
273
+ canUndo,
274
+ canRedo,
275
+ });
276
+ }
277
+ }, [performGlobalUndo, performGlobalRedo, canUndo, canRedo, registerUndoHandlers]);
278
+
279
+ /**
280
+ * 各ページのiframeに完全な編集機能を初期化
281
+ * 親コンテキストから直接 updateSelectionBox / extractElementInfo を使用
282
+ */
283
+ const handlePageIframeLoad = useCallback((pageId: string, iframe: HTMLIFrameElement) => {
284
+ iframeMapRef.current.set(pageId, iframe);
285
+
286
+ const iframeDoc = iframe.contentDocument;
287
+ if (!iframeDoc) return;
288
+
289
+ // 既に初期化済みなら何もしない
290
+ if (iframeDoc.querySelector('[data-canvas-editor-css]')) return;
291
+
292
+ // ===== 1. CSS注入 =====
293
+ const style = iframeDoc.createElement('style');
294
+ style.setAttribute('data-canvas-editor-css', 'true');
295
+ style.textContent = EDITOR_IFRAME_STYLES + CANVAS_EDITING_STYLES;
296
+ iframeDoc.head.appendChild(style);
297
+
298
+ // ===== 1.1 data-editable属性の補完 =====
299
+ // レイアウトモード変換で必要な data-editable="true" を #artboard 直下の子要素に付与
300
+ const artboard = iframeDoc.getElementById('artboard');
301
+ if (artboard) {
302
+ Array.from(artboard.children).forEach(child => {
303
+ if (child.nodeType !== 1) return;
304
+ if (child.tagName === 'SCRIPT' || child.tagName === 'STYLE') return;
305
+ if (!child.hasAttribute('data-editable')) {
306
+ child.setAttribute('data-editable', 'true');
307
+ }
308
+ });
309
+ }
310
+
311
+ // ===== 1.5 iframe内ブラウザズーム防止 =====
312
+ let meta = iframeDoc.querySelector('meta[name="viewport"]');
313
+ if (!meta) {
314
+ meta = iframeDoc.createElement('meta');
315
+ meta.setAttribute('name', 'viewport');
316
+ iframeDoc.head.appendChild(meta);
317
+ }
318
+ meta.setAttribute('content', 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0');
319
+
320
+ iframeDoc.body.style.touchAction = 'none';
321
+ iframeDoc.documentElement.style.touchAction = 'none';
322
+
323
+ // === ブラウザネイティブズーム防止徹底 ===
324
+ const preventZoom = (e: WheelEvent) => {
325
+ if (e.ctrlKey || e.metaKey) {
326
+ e.preventDefault();
327
+ }
328
+ };
329
+ iframeDoc.addEventListener('wheel', preventZoom, { passive: false, capture: true });
330
+ iframe.contentWindow?.addEventListener('wheel', preventZoom, { passive: false, capture: true });
331
+
332
+ const preventGesture = (e: Event) => { e.preventDefault(); e.stopPropagation(); };
333
+ iframeDoc.addEventListener('gesturestart', preventGesture, { passive: false, capture: true } as EventListenerOptions);
334
+ iframeDoc.addEventListener('gesturechange', preventGesture, { passive: false, capture: true } as EventListenerOptions);
335
+ iframeDoc.addEventListener('gestureend', preventGesture, { passive: false, capture: true } as EventListenerOptions);
336
+ iframe.contentWindow?.addEventListener('gesturestart', preventGesture, { passive: false, capture: true } as EventListenerOptions);
337
+ iframe.contentWindow?.addEventListener('gesturechange', preventGesture, { passive: false, capture: true } as EventListenerOptions);
338
+ iframe.contentWindow?.addEventListener('gestureend', preventGesture, { passive: false, capture: true } as EventListenerOptions);
339
+
340
+ const preventTouch = (e: TouchEvent) => {
341
+ if (e.touches.length > 1) { e.preventDefault(); e.stopPropagation(); }
342
+ };
343
+ iframeDoc.addEventListener('touchstart', preventTouch, { passive: false, capture: true });
344
+ iframeDoc.addEventListener('touchmove', preventTouch, { passive: false, capture: true });
345
+ iframe.contentWindow?.addEventListener('touchstart', preventTouch, { passive: false, capture: true });
346
+ iframe.contentWindow?.addEventListener('touchmove', preventTouch, { passive: false, capture: true });
347
+
348
+ const preventShortcuts = (e: KeyboardEvent) => {
349
+ if ((e.ctrlKey || e.metaKey) && (e.key === '+' || e.key === '=' || e.key === '-' || e.key === '0')) {
350
+ e.preventDefault();
351
+ }
352
+ };
353
+ iframeDoc.addEventListener('keydown', preventShortcuts, { passive: false, capture: true });
354
+ iframe.contentWindow?.addEventListener('keydown', preventShortcuts, { passive: false, capture: true });
355
+
356
+ // ===== 2. 編集状態 =====
357
+ let selectedEl: HTMLElement | null = null;
358
+
359
+ let dragState = {
360
+ element: null as HTMLElement | null,
361
+ startX: 0,
362
+ startY: 0,
363
+ origLeft: 0,
364
+ origTop: 0,
365
+ isDragging: false,
366
+ hasMoved: false,
367
+ // Auto-layout drag fields
368
+ autoLayoutDragMode: false,
369
+ autoLayoutState: null as AutoLayoutDragState | null,
370
+ originalParent: null as HTMLElement | null,
371
+ currentDropTarget: null as HTMLElement | null,
372
+ dropPosition: null as string | null,
373
+ dropIndex: -1,
374
+ };
375
+
376
+ let resizeState = {
377
+ isResizing: false,
378
+ isRotating: false,
379
+ element: null as HTMLElement | null,
380
+ handle: '',
381
+ startX: 0,
382
+ startY: 0,
383
+ origLeft: 0,
384
+ origTop: 0,
385
+ origWidth: 0,
386
+ origHeight: 0,
387
+ origRadius: 0,
388
+ rotation: 0,
389
+ rotationStartAngle: 0,
390
+ centerX: 0,
391
+ centerY: 0,
392
+ };
393
+
394
+ // ===== 3. ヘルパー関数 =====
395
+ const wireIframeToRefs = () => {
396
+ const prevIframe = (refs.iframeRef as React.MutableRefObject<HTMLIFrameElement | null>).current;
397
+ (refs.iframeRef as React.MutableRefObject<HTMLIFrameElement | null>).current = iframe;
398
+ if (prevIframe !== iframe) {
399
+ // iframe切替: useDrawingMode等のiframe依存hookを再アタッチさせる
400
+ refs.setIframeReady(false);
401
+ setTimeout(() => refs.setIframeReady(true), 0);
402
+ } else {
403
+ refs.setIframeReady(true);
404
+ }
405
+ };
406
+
407
+ const selectElement = (el: HTMLElement | null) => {
408
+ // 前の選択を解除
409
+ if (selectedEl) {
410
+ selectedEl.classList.remove('selected');
411
+ }
412
+ removeSelectionBox(iframeDoc);
413
+
414
+ selectedEl = el;
415
+
416
+ if (el) {
417
+ el.classList.add('selected');
418
+ updateSelectionBox(iframeDoc, el);
419
+ wireIframeToRefs();
420
+
421
+ // プロパティパネル更新
422
+ const info = extractElementInfo(el, iframeDoc);
423
+ if (info) {
424
+ setSelectedElementRef.current(info);
425
+ setSelectedElementIdsRef.current([info.id]);
426
+ }
427
+ } else {
428
+ setSelectedElementRef.current(null);
429
+ setSelectedElementIdsRef.current([]);
430
+ }
431
+ };
432
+
433
+ // iframe単位の `saveSnapshot` 関数内でグローバルスタックに積ませる
434
+
435
+ // React更新デバウンス用
436
+ let notifyTimer: ReturnType<typeof setTimeout> | null = null;
437
+ const notifyContentChanged = () => {
438
+ if (notifyTimer) clearTimeout(notifyTimer);
439
+ notifyTimer = setTimeout(() => {
440
+ const artboard = iframeDoc.getElementById('artboard');
441
+ if (artboard) {
442
+ updatePageFrameRef.current(pageId, {
443
+ thumbnailHtml: artboard.innerHTML,
444
+ isDirty: true,
445
+ });
446
+ }
447
+ }, 300);
448
+ };
449
+
450
+ // --- Undo/Redo (Global DOM スナップショット) ---
451
+ const saveSnapshot = () => {
452
+ const artboard = iframeDoc.getElementById('artboard');
453
+ if (!artboard) return;
454
+
455
+ const stack = globalUndoStackRef.current;
456
+ stack.push({ pageId, html: artboard.innerHTML });
457
+
458
+ // Limit global history depth to 100
459
+ if (stack.length > 100) stack.shift();
460
+
461
+ // 操作が行われたらRedoスタックは破棄
462
+ globalRedoStackRef.current = [];
463
+ };
464
+
465
+ const performUndo = () => {
466
+ const stack = globalUndoStackRef.current;
467
+ if (stack.length === 0) return;
468
+
469
+ // Pop the most recent action across ALL pages
470
+ const lastAction = stack.pop()!;
471
+
472
+ // Find the specific iframe that this action belongs to
473
+ const targetIframe = containerRef.current?.querySelector(`iframe[data-page-id="${lastAction.pageId}"]`) as HTMLIFrameElement;
474
+ const targetDoc = targetIframe?.contentDocument || targetIframe?.contentWindow?.document;
475
+ const targetArtboard = targetDoc?.getElementById('artboard');
476
+
477
+ if (!targetArtboard || !targetDoc) {
478
+ // Fallback: IF frame not found, push back and abort
479
+ stack.push(lastAction);
480
+ return;
481
+ }
482
+
483
+ // Save current state to Redo stack before applying Undo
484
+ globalRedoStackRef.current.push({
485
+ pageId: lastAction.pageId,
486
+ html: targetArtboard.innerHTML
487
+ });
488
+
489
+ // Apply the historical HTML
490
+ targetArtboard.innerHTML = lastAction.html;
491
+
492
+ // Clear selection gracefully
493
+ selectElement(null);
494
+ // Trigger update
495
+ if (lastAction.pageId === pageId) {
496
+ notifyContentChanged();
497
+ } else {
498
+ // Directly call the update on the target page if different
499
+ updatePageFrameRef.current(lastAction.pageId, {
500
+ thumbnailHtml: lastAction.html,
501
+ isDirty: true,
502
+ });
503
+ }
504
+
505
+ // Fix Tree
506
+ const tree = buildDomTree(targetDoc);
507
+ setDomTreeRef.current(tree);
508
+ };
509
+
510
+ const performRedo = () => {
511
+ const redoStack = globalRedoStackRef.current;
512
+ if (redoStack.length === 0) return;
513
+
514
+ const nextAction = redoStack.pop()!;
515
+
516
+ // Find the specific iframe
517
+ const targetIframe = containerRef.current?.querySelector(`iframe[data-page-id="${nextAction.pageId}"]`) as HTMLIFrameElement;
518
+ const targetDoc = targetIframe?.contentDocument || targetIframe?.contentWindow?.document;
519
+ const targetArtboard = targetDoc?.getElementById('artboard');
520
+
521
+ if (!targetArtboard || !targetDoc) {
522
+ redoStack.push(nextAction);
523
+ return;
524
+ }
525
+
526
+ // Save current state to Undo stack before applying Redo
527
+ globalUndoStackRef.current.push({
528
+ pageId: nextAction.pageId,
529
+ html: targetArtboard.innerHTML
530
+ });
531
+
532
+ // Apply the redo HTML
533
+ targetArtboard.innerHTML = nextAction.html;
534
+
535
+ selectElement(null);
536
+ if (nextAction.pageId === pageId) {
537
+ notifyContentChanged();
538
+ } else {
539
+ updatePageFrameRef.current(nextAction.pageId, {
540
+ thumbnailHtml: nextAction.html,
541
+ isDirty: true,
542
+ });
543
+ }
544
+
545
+ const tree = buildDomTree(targetDoc);
546
+ setDomTreeRef.current(tree);
547
+ };
548
+
549
+ // --- テキスト編集状態 ---
550
+ let isEditing = false;
551
+
552
+ // ===== 4. マウスダウン(選択 + ドラッグ開始 + リサイズハンドル) =====
553
+ iframeDoc.addEventListener('mousedown', (e) => {
554
+ const target = e.target as HTMLElement;
555
+
556
+ // --- テキスト編集中は通常のマウスイベントを許可 ---
557
+ if (isEditing) {
558
+ // 編集中要素の外側クリックで編集終了
559
+ if (selectedEl && !selectedEl.contains(target)) {
560
+ selectedEl.contentEditable = 'false';
561
+ selectedEl.classList.remove('editing');
562
+ isEditing = false;
563
+ saveSnapshot();
564
+ notifyContentChanged();
565
+ const info = extractElementInfo(selectedEl, iframeDoc);
566
+ if (info) setSelectedElementRef.current(info);
567
+ }
568
+ return;
569
+ }
570
+
571
+ // --- selection-box内(breadcrumb等)のクリックはスキップ ---
572
+ if (target.closest('.selection-box') && !target.getAttribute('data-handle')) {
573
+ return;
574
+ }
575
+
576
+ // --- 描画ツールが有効な場合はselection/dragをスキップ ---
577
+ const drawingTools = ['rectangle', 'ellipse', 'line', 'arrow', 'pen', 'pencil', 'frame', 'text'];
578
+ if (drawingTools.includes(activeToolRef.current)) {
579
+ wireIframeToRefs();
580
+ // useDrawingModeにイベント処理を委譲(preventDefault/stopPropagationしない)
581
+ return;
582
+ }
583
+
584
+ // --- リサイズ/回転ハンドル ---
585
+ const handle = target.getAttribute('data-handle');
586
+ if (handle && selectedEl) {
587
+ e.preventDefault();
588
+ e.stopPropagation();
589
+
590
+ const rect = selectedEl.getBoundingClientRect();
591
+ const computedLeft = parseFloat(selectedEl.style.left) || 0;
592
+ const computedTop = parseFloat(selectedEl.style.top) || 0;
593
+ const computedStyle = iframeDoc.defaultView?.getComputedStyle(selectedEl);
594
+
595
+ if (handle.startsWith('rotate-')) {
596
+ const transform = selectedEl.style.transform || '';
597
+ const rotateMatch = transform.match(/rotate\(([^)]+)deg\)/);
598
+ const currentRotation = rotateMatch ? parseFloat(rotateMatch[1]) : 0;
599
+ const centerX = rect.left + rect.width / 2;
600
+ const centerY = rect.top + rect.height / 2;
601
+ const startAngle = Math.atan2(e.clientY - centerY, e.clientX - centerX) * (180 / Math.PI);
602
+
603
+ resizeState = {
604
+ isResizing: false, isRotating: true, element: selectedEl, handle,
605
+ startX: e.clientX, startY: e.clientY,
606
+ origLeft: computedLeft, origTop: computedTop,
607
+ origWidth: rect.width, origHeight: rect.height,
608
+ origRadius: 0, rotation: currentRotation, rotationStartAngle: startAngle,
609
+ centerX, centerY,
610
+ };
611
+ iframeDoc.body.classList.add('rotating');
612
+ } else {
613
+ resizeState = {
614
+ isResizing: true, isRotating: false, element: selectedEl, handle,
615
+ startX: e.clientX, startY: e.clientY,
616
+ origLeft: computedLeft, origTop: computedTop,
617
+ origWidth: rect.width, origHeight: rect.height,
618
+ origRadius: parseInt(computedStyle?.borderRadius || '0') || 0,
619
+ rotation: 0, rotationStartAngle: 0, centerX: 0, centerY: 0,
620
+ };
621
+ }
622
+ return;
623
+ }
624
+
625
+ // --- 要素選択 + ドラッグ開始 ---
626
+ // ページフォーカス切替(レイヤーパネル・CSS/JS・設定を正しいページに同期)
627
+ if (viewStateRef.current.activePageId !== pageId) {
628
+ focusPageRef.current(pageId);
629
+ }
630
+ // PAGE_FOCUSED: レイヤーパネル用DOMツリー構築
631
+ wireIframeToRefs();
632
+ const tree = buildDomTree(iframeDoc);
633
+ setDomTreeRef.current(tree);
634
+ const firstLevelIds = new Set<string>(tree.map(n => n.id));
635
+ setExpandedNodesRef.current(firstLevelIds);
636
+
637
+ const isMeta = e.ctrlKey || e.metaKey;
638
+
639
+ // 既に選択済みの要素内をクリック(Ctrl/Cmd未押下時)→ 再選択せずドラッグ開始
640
+ // Ctrl/Cmd押下時は子要素選択を優先
641
+ let dragTarget: HTMLElement;
642
+ if (!isMeta && selectedEl && (selectedEl === target || selectedEl.contains(target))) {
643
+ e.preventDefault();
644
+ e.stopPropagation();
645
+ dragTarget = selectedEl;
646
+ } else {
647
+ // Ctrl/Cmd+クリック: 子要素を直接選択
648
+ // 通常クリック: トップレベル要素(#artboard直下)を選択
649
+ let selectable: HTMLElement | null;
650
+ if (isMeta) {
651
+ selectable = target.closest('[data-element-id]') as HTMLElement | null;
652
+ if (selectable && selectable.id === 'artboard') selectable = null;
653
+ } else {
654
+ selectable = target.closest('#artboard > [data-element-id]') as HTMLElement | null;
655
+ }
656
+ if (!selectable) {
657
+ selectElement(null);
658
+ return;
659
+ }
660
+
661
+ e.preventDefault();
662
+ e.stopPropagation();
663
+ selectElement(selectable);
664
+ dragTarget = selectable;
665
+ }
666
+
667
+ // DOM変更前のスナップショット保存(undo用)
668
+ saveSnapshot();
669
+
670
+ if (layoutModeRef.current === 'auto') {
671
+ // Auto-layout: ドラッグ状態のみ設定(ghost は mousemove で作成)
672
+ dragState = {
673
+ element: dragTarget,
674
+ startX: e.clientX,
675
+ startY: e.clientY,
676
+ origLeft: 0,
677
+ origTop: 0,
678
+ isDragging: true,
679
+ hasMoved: false,
680
+ autoLayoutDragMode: true,
681
+ autoLayoutState: null,
682
+ originalParent: dragTarget.parentElement,
683
+ currentDropTarget: null,
684
+ dropPosition: null,
685
+ dropIndex: -1,
686
+ };
687
+ } else {
688
+ // Absolute: ドラッグ準備(絶対配置モード時)
689
+ const cs = iframeDoc.defaultView?.getComputedStyle(dragTarget);
690
+ let computedLeft = parseFloat(dragTarget.style.left) || 0;
691
+ let computedTop = parseFloat(dragTarget.style.top) || 0;
692
+ if (cs?.position === 'static') {
693
+ dragTarget.style.position = 'relative';
694
+ computedLeft = 0;
695
+ computedTop = 0;
696
+ }
697
+
698
+ dragState = {
699
+ element: dragTarget,
700
+ startX: e.clientX,
701
+ startY: e.clientY,
702
+ origLeft: computedLeft,
703
+ origTop: computedTop,
704
+ isDragging: true,
705
+ hasMoved: false,
706
+ autoLayoutDragMode: false,
707
+ autoLayoutState: null,
708
+ originalParent: null,
709
+ currentDropTarget: null,
710
+ dropPosition: null,
711
+ dropIndex: -1,
712
+ };
713
+ }
714
+ });
715
+
716
+ // ===== 5. マウスムーブ(ドラッグ移動 + リサイズ + 回転) =====
717
+ iframeDoc.addEventListener('mousemove', (e) => {
718
+ // --- 回転 ---
719
+ if (resizeState.isRotating && resizeState.element) {
720
+ const currentAngle = Math.atan2(
721
+ e.clientY - resizeState.centerY,
722
+ e.clientX - resizeState.centerX
723
+ ) * (180 / Math.PI);
724
+ const angleDelta = currentAngle - resizeState.rotationStartAngle;
725
+ const newRotation = resizeState.rotation + angleDelta;
726
+ const existingTransform = resizeState.element.style.transform || '';
727
+ const scaleMatch = existingTransform.match(/scale\(([^)]+)\)/);
728
+ const scaleValue = scaleMatch ? scaleMatch[0] : '';
729
+ resizeState.element.style.transform = `rotate(${newRotation}deg) ${scaleValue}`.trim();
730
+ updateSelectionBox(iframeDoc, resizeState.element);
731
+ return;
732
+ }
733
+
734
+ // --- リサイズ ---
735
+ if (resizeState.isResizing && resizeState.element) {
736
+ const deltaX = e.clientX - resizeState.startX;
737
+ const deltaY = e.clientY - resizeState.startY;
738
+ const el = resizeState.element;
739
+ const h = resizeState.handle;
740
+ let newLeft = resizeState.origLeft;
741
+ let newTop = resizeState.origTop;
742
+ let newWidth = resizeState.origWidth;
743
+ let newHeight = resizeState.origHeight;
744
+ const minSize = 10;
745
+
746
+ if (h === 'radius') {
747
+ const maxRadius = Math.min(resizeState.origWidth, resizeState.origHeight) / 2;
748
+ const newRadius = Math.max(0, Math.min(maxRadius, resizeState.origRadius - deltaX - deltaY));
749
+ el.style.borderRadius = `${newRadius}px`;
750
+ updateSelectionBox(iframeDoc, el);
751
+ return;
752
+ }
753
+
754
+ switch (h) {
755
+ case 'nw': newWidth = Math.max(minSize, resizeState.origWidth - deltaX); newHeight = Math.max(minSize, resizeState.origHeight - deltaY); newLeft = resizeState.origLeft + (resizeState.origWidth - newWidth); newTop = resizeState.origTop + (resizeState.origHeight - newHeight); break;
756
+ case 'n': newHeight = Math.max(minSize, resizeState.origHeight - deltaY); newTop = resizeState.origTop + (resizeState.origHeight - newHeight); break;
757
+ case 'ne': newWidth = Math.max(minSize, resizeState.origWidth + deltaX); newHeight = Math.max(minSize, resizeState.origHeight - deltaY); newTop = resizeState.origTop + (resizeState.origHeight - newHeight); break;
758
+ case 'e': newWidth = Math.max(minSize, resizeState.origWidth + deltaX); break;
759
+ case 'se': newWidth = Math.max(minSize, resizeState.origWidth + deltaX); newHeight = Math.max(minSize, resizeState.origHeight + deltaY); break;
760
+ case 's': newHeight = Math.max(minSize, resizeState.origHeight + deltaY); break;
761
+ case 'sw': newWidth = Math.max(minSize, resizeState.origWidth - deltaX); newHeight = Math.max(minSize, resizeState.origHeight + deltaY); newLeft = resizeState.origLeft + (resizeState.origWidth - newWidth); break;
762
+ case 'w': newWidth = Math.max(minSize, resizeState.origWidth - deltaX); newLeft = resizeState.origLeft + (resizeState.origWidth - newWidth); break;
763
+ }
764
+
765
+ const cs = iframeDoc.defaultView?.getComputedStyle(el);
766
+ if (cs?.position !== 'absolute' && cs?.position !== 'fixed') {
767
+ el.style.position = 'absolute';
768
+ }
769
+ el.style.left = `${newLeft}px`;
770
+ el.style.top = `${newTop}px`;
771
+ el.style.width = `${newWidth}px`;
772
+ el.style.height = `${newHeight}px`;
773
+ updateSelectionBox(iframeDoc, el);
774
+ return;
775
+ }
776
+
777
+ // --- ドラッグ移動 ---
778
+ if (dragState.isDragging && dragState.element) {
779
+ const dx = e.clientX - dragState.startX;
780
+ const dy = e.clientY - dragState.startY;
781
+ if (Math.abs(dx) > 2 || Math.abs(dy) > 2) {
782
+ if (dragState.autoLayoutDragMode) {
783
+ // Auto-layout: Ghost clone ドラッグ
784
+ if (!dragState.hasMoved) {
785
+ dragState.hasMoved = true;
786
+ // scale=1: iframe内は1:1、外側CSSトランスフォームがズーム担当
787
+ dragState.autoLayoutState = startAutoLayoutDrag(dragState.element, iframeDoc, 1);
788
+ removeSelectionBox(iframeDoc);
789
+ }
790
+ if (dragState.autoLayoutState) {
791
+ updateDragPosition(dragState.autoLayoutState, e.clientX, e.clientY);
792
+ const dropInfo = findDropTarget(iframeDoc, e.clientX, e.clientY, dragState.element);
793
+ if (dropInfo) {
794
+ showDropIndicator(iframeDoc, dropInfo, dragState.element);
795
+ dragState.currentDropTarget = dropInfo.container;
796
+ dragState.dropPosition = dropInfo.position;
797
+ dragState.dropIndex = dropInfo.index;
798
+ } else {
799
+ hideFlexDropIndicator(iframeDoc);
800
+ dragState.currentDropTarget = null;
801
+ dragState.dropPosition = null;
802
+ dragState.dropIndex = -1;
803
+ }
804
+ }
805
+ } else {
806
+ // Absolute: 既存の left/top ドラッグ
807
+ dragState.hasMoved = true;
808
+ dragState.element.classList.add('dragging');
809
+ dragState.element.style.left = `${dragState.origLeft + dx}px`;
810
+ dragState.element.style.top = `${dragState.origTop + dy}px`;
811
+ updateSelectionBox(iframeDoc, dragState.element);
812
+ }
813
+ }
814
+ }
815
+ });
816
+
817
+ // ===== 6. マウスアップ =====
818
+ iframeDoc.addEventListener('mouseup', () => {
819
+ // 回転終了
820
+ if (resizeState.isRotating && resizeState.element) {
821
+ iframeDoc.body.classList.remove('rotating');
822
+ notifyContentChanged();
823
+ const info = extractElementInfo(resizeState.element, iframeDoc);
824
+ if (info) setSelectedElementRef.current(info);
825
+ }
826
+ // リサイズ終了
827
+ else if (resizeState.isResizing && resizeState.element) {
828
+ notifyContentChanged();
829
+ const info = extractElementInfo(resizeState.element, iframeDoc);
830
+ if (info) setSelectedElementRef.current(info);
831
+ }
832
+
833
+ resizeState = {
834
+ isResizing: false, isRotating: false, element: null, handle: '',
835
+ startX: 0, startY: 0, origLeft: 0, origTop: 0,
836
+ origWidth: 0, origHeight: 0, origRadius: 0,
837
+ rotation: 0, rotationStartAngle: 0, centerX: 0, centerY: 0,
838
+ };
839
+
840
+ // ドラッグ終了
841
+ if (dragState.isDragging && dragState.element) {
842
+ if (dragState.autoLayoutDragMode && dragState.autoLayoutState) {
843
+ // Auto-layout ドラッグ終了
844
+ const autoState = dragState.autoLayoutState;
845
+
846
+ // ドロップ先を構築(artboard内のみ許可)
847
+ let dropInfo: DropTargetInfo | null = null;
848
+ if (dragState.currentDropTarget && dragState.dropPosition !== null) {
849
+ const artboard = iframeDoc.getElementById('artboard');
850
+ const isValidDrop = dragState.currentDropTarget === artboard ||
851
+ (artboard && artboard.contains(dragState.currentDropTarget));
852
+ if (isValidDrop) {
853
+ dropInfo = {
854
+ container: dragState.currentDropTarget,
855
+ position: dragState.dropPosition as 'before' | 'after' | 'inside',
856
+ index: dragState.dropIndex,
857
+ referenceElement: null,
858
+ };
859
+ }
860
+ }
861
+
862
+ const domChanged = endAutoLayoutDrag(autoState, dropInfo, iframeDoc);
863
+ if (domChanged) {
864
+ notifyContentChanged();
865
+ const tree = buildDomTree(iframeDoc);
866
+ setDomTreeRef.current(tree);
867
+ }
868
+
869
+ // 選択ボックス更新(DOM安定後)
870
+ const droppedElement = dragState.element;
871
+ setTimeout(() => {
872
+ if (droppedElement) {
873
+ updateSelectionBox(iframeDoc, droppedElement);
874
+ const info = extractElementInfo(droppedElement, iframeDoc);
875
+ if (info) setSelectedElementRef.current(info);
876
+ }
877
+ }, 0);
878
+ } else {
879
+ // Absolute: 既存のドラッグ終了
880
+ dragState.element.classList.remove('dragging');
881
+ if (dragState.hasMoved) {
882
+ notifyContentChanged();
883
+ // プロパティパネル更新
884
+ const info = extractElementInfo(dragState.element, iframeDoc);
885
+ if (info) setSelectedElementRef.current(info);
886
+ }
887
+ }
888
+ }
889
+ dragState = {
890
+ element: null, startX: 0, startY: 0,
891
+ origLeft: 0, origTop: 0, isDragging: false, hasMoved: false,
892
+ autoLayoutDragMode: false, autoLayoutState: null,
893
+ originalParent: null, currentDropTarget: null,
894
+ dropPosition: null, dropIndex: -1,
895
+ };
896
+ });
897
+
898
+ // ===== 7. キーボードショートカット =====
899
+ iframeDoc.addEventListener('keydown', (e) => {
900
+ // テキスト編集中はEscape以外のショートカットを無効化
901
+ if (isEditing) {
902
+ if (e.key === 'Escape') {
903
+ e.preventDefault();
904
+ if (selectedEl) {
905
+ selectedEl.contentEditable = 'false';
906
+ selectedEl.classList.remove('editing');
907
+ isEditing = false;
908
+ saveSnapshot();
909
+ notifyContentChanged();
910
+ updateSelectionBox(iframeDoc, selectedEl);
911
+ const info = extractElementInfo(selectedEl, iframeDoc);
912
+ if (info) setSelectedElementRef.current(info);
913
+ }
914
+ }
915
+ return;
916
+ }
917
+
918
+ const isMeta = e.ctrlKey || e.metaKey;
919
+
920
+ // Undo (per-iframe DOM snapshot)
921
+ if (isMeta && e.key === 'z' && !e.shiftKey) {
922
+ e.preventDefault();
923
+ performUndo();
924
+ return;
925
+ }
926
+ // Redo (per-iframe DOM snapshot)
927
+ if (isMeta && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) {
928
+ e.preventDefault();
929
+ performRedo();
930
+ return;
931
+ }
932
+
933
+ if (!selectedEl) return;
934
+
935
+ // Delete / Backspace
936
+ if (e.key === 'Delete' || e.key === 'Backspace') {
937
+ e.preventDefault();
938
+ saveSnapshot();
939
+ const el = selectedEl;
940
+ selectElement(null);
941
+ el.remove();
942
+ notifyContentChanged();
943
+ // DOMツリー再構築
944
+ const tree = buildDomTree(iframeDoc);
945
+ setDomTreeRef.current(tree);
946
+ return;
947
+ }
948
+
949
+ // Arrow keys (nudge)
950
+ if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) {
951
+ e.preventDefault();
952
+ const n = e.shiftKey ? 10 : 1;
953
+ const cs = iframeDoc.defaultView?.getComputedStyle(selectedEl);
954
+ if (cs?.position === 'static') selectedEl.style.position = 'relative';
955
+ const cl = parseFloat(selectedEl.style.left) || 0;
956
+ const ct = parseFloat(selectedEl.style.top) || 0;
957
+ if (e.key === 'ArrowLeft') selectedEl.style.left = `${cl - n}px`;
958
+ if (e.key === 'ArrowRight') selectedEl.style.left = `${cl + n}px`;
959
+ if (e.key === 'ArrowUp') selectedEl.style.top = `${ct - n}px`;
960
+ if (e.key === 'ArrowDown') selectedEl.style.top = `${ct + n}px`;
961
+ updateSelectionBox(iframeDoc, selectedEl);
962
+ notifyContentChanged();
963
+ return;
964
+ }
965
+
966
+ // Escape
967
+ if (e.key === 'Escape') {
968
+ // 進行中のオートレイアウトドラッグをキャンセル
969
+ if (dragState.autoLayoutDragMode && dragState.autoLayoutState) {
970
+ cancelAutoLayoutDrag(dragState.autoLayoutState, iframeDoc);
971
+ dragState = {
972
+ element: null, startX: 0, startY: 0,
973
+ origLeft: 0, origTop: 0, isDragging: false, hasMoved: false,
974
+ autoLayoutDragMode: false, autoLayoutState: null,
975
+ originalParent: null, currentDropTarget: null,
976
+ dropPosition: null, dropIndex: -1,
977
+ };
978
+ }
979
+ selectElement(null);
980
+ return;
981
+ }
982
+
983
+ // Duplicate (Ctrl+D)
984
+ if (isMeta && e.key === 'd') {
985
+ e.preventDefault();
986
+ saveSnapshot();
987
+ const clone = selectedEl.cloneNode(true) as HTMLElement;
988
+ // 新しいIDを付与
989
+ let idCounter = iframeDoc.querySelectorAll('[data-element-id]').length;
990
+ clone.setAttribute('data-element-id', 'el-' + (idCounter++));
991
+ const assignNewIds = (parent: Element) => {
992
+ Array.from(parent.children).forEach(child => {
993
+ if (child.nodeType === 1 && child.tagName !== 'SCRIPT' && child.tagName !== 'STYLE') {
994
+ (child as HTMLElement).setAttribute('data-element-id', 'el-' + (idCounter++));
995
+ assignNewIds(child);
996
+ }
997
+ });
998
+ };
999
+ assignNewIds(clone);
1000
+ // オフセット配置
1001
+ const left = parseFloat(clone.style.left) || 0;
1002
+ const top = parseFloat(clone.style.top) || 0;
1003
+ clone.style.left = `${left + 20}px`;
1004
+ clone.style.top = `${top + 20}px`;
1005
+ clone.classList.remove('selected');
1006
+ selectedEl.parentElement?.appendChild(clone);
1007
+ selectElement(clone);
1008
+ notifyContentChanged();
1009
+ const tree = buildDomTree(iframeDoc);
1010
+ setDomTreeRef.current(tree);
1011
+ return;
1012
+ }
1013
+ });
1014
+
1015
+ // ===== 8. リンクナビゲーション防止 =====
1016
+ iframeDoc.addEventListener('click', (e) => {
1017
+ const anchor = (e.target as HTMLElement).closest('a');
1018
+ if (anchor) e.preventDefault();
1019
+ }, true);
1020
+
1021
+ // ===== 8.5 コンテキストメニュー(右クリック) =====
1022
+ iframeDoc.addEventListener('contextmenu', (e) => {
1023
+ e.preventDefault();
1024
+ e.stopPropagation();
1025
+
1026
+ // テキスト編集中はスキップ
1027
+ if (isEditing) return;
1028
+
1029
+ // ページフォーカス切替
1030
+ if (viewStateRef.current.activePageId !== pageId) {
1031
+ focusPageRef.current(pageId);
1032
+ wireIframeToRefs();
1033
+ }
1034
+
1035
+ const target = e.target as HTMLElement;
1036
+
1037
+ // 右クリックされた要素を選択(選択済み要素の内部なら維持)
1038
+ if (!target.closest('.selection-box')) {
1039
+ const isInsideSelected = selectedEl && (selectedEl === target || selectedEl.contains(target) || target.contains(selectedEl));
1040
+ if (!isInsideSelected) {
1041
+ const selectable = target.closest('#artboard > [data-element-id]') as HTMLElement | null;
1042
+ if (selectable) {
1043
+ wireIframeToRefs();
1044
+ selectElement(selectable);
1045
+ }
1046
+ }
1047
+ }
1048
+
1049
+ // スクリーン座標を計算(iframe内座標 → 親ウィンドウ座標)
1050
+ const iframeRect = iframe.getBoundingClientRect();
1051
+ const scaleX = iframeRect.width / (iframe.clientWidth || 1);
1052
+ const scaleY = iframeRect.height / (iframe.clientHeight || 1);
1053
+
1054
+ window.parent.postMessage({
1055
+ type: 'IFRAME_CONTEXT_MENU',
1056
+ clientX: iframeRect.left + e.clientX * scaleX,
1057
+ clientY: iframeRect.top + e.clientY * scaleY,
1058
+ }, '*');
1059
+ });
1060
+
1061
+ // ===== 9. ダブルクリックでテキスト編集(選択済み要素のみ) =====
1062
+ iframeDoc.addEventListener('dblclick', (e) => {
1063
+ const target = e.target as HTMLElement;
1064
+ if (target.closest('.selection-box')) return;
1065
+
1066
+ // ページフォーカス切替
1067
+ if (viewStateRef.current.activePageId !== pageId) {
1068
+ focusPageRef.current(pageId);
1069
+ wireIframeToRefs();
1070
+ }
1071
+
1072
+ // data-element-idを持つ最も近い要素を検索
1073
+ const editable = target.closest('[data-element-id]') as HTMLElement | null;
1074
+ if (!editable || editable.id === 'artboard') return;
1075
+
1076
+ // 選択済み要素、またはその親要素が選択済みの場合のみ編集モードに入る
1077
+ if (!selectedEl || (editable !== selectedEl && !selectedEl.contains(editable) && !editable.contains(selectedEl))) {
1078
+ return;
1079
+ }
1080
+
1081
+ e.preventDefault();
1082
+ e.stopPropagation();
1083
+
1084
+ // スナップショット保存(編集前状態)
1085
+ saveSnapshot();
1086
+
1087
+ // 編集対象を決定(selectedElの子孫をダブルクリックした場合はその要素を編集)
1088
+ const editTarget = editable.contains(selectedEl) ? selectedEl : editable;
1089
+ selectElement(editTarget);
1090
+ selectedEl = editTarget;
1091
+
1092
+ // contentEditable有効化
1093
+ editTarget.contentEditable = 'true';
1094
+ editTarget.classList.add('editing');
1095
+ editTarget.classList.remove('selected');
1096
+ isEditing = true;
1097
+
1098
+ // selection boxを非表示(テキスト選択と干渉するため)
1099
+ removeSelectionBox(iframeDoc);
1100
+
1101
+ // フォーカスしてカーソル配置
1102
+ editTarget.focus();
1103
+
1104
+ // ダブルクリック位置にカーソルを配置
1105
+ const selection = iframeDoc.defaultView?.getSelection();
1106
+ if (selection) {
1107
+ const range = iframeDoc.caretRangeFromPoint?.(e.clientX, e.clientY);
1108
+ if (range) {
1109
+ selection.removeAllRanges();
1110
+ selection.addRange(range);
1111
+ }
1112
+ }
1113
+ });
1114
+
1115
+ // ===== 10. 描画ツール用: マウスが入った時にiframeを事前ワイヤリング =====
1116
+ iframeDoc.addEventListener('pointerenter', () => {
1117
+ const dTools = ['rectangle', 'ellipse', 'line', 'arrow', 'pen', 'pencil', 'frame', 'text'];
1118
+ if (dTools.includes(activeToolRef.current)) {
1119
+ if (viewStateRef.current.activePageId !== pageId) {
1120
+ focusPageRef.current(pageId);
1121
+ }
1122
+ wireIframeToRefs();
1123
+ }
1124
+ });
1125
+
1126
+ // ===== 11. 初期DOMツリー構築 =====
1127
+ setTimeout(() => {
1128
+ wireIframeToRefs();
1129
+ const tree = buildDomTree(iframeDoc);
1130
+ setDomTreeRef.current(tree);
1131
+ const firstLevelIds = new Set<string>(tree.map(n => n.id));
1132
+ setExpandedNodesRef.current(firstLevelIds);
1133
+ }, 600);
1134
+ }, [refs]);
1135
+
1136
+ // ===== 描画ツール: 全iframeのbody classを同期 =====
1137
+ useEffect(() => {
1138
+ const drawingTools = ['rectangle', 'ellipse', 'line', 'arrow', 'pen', 'pencil', 'frame'];
1139
+ const isDrawing = drawingTools.includes(activeTool);
1140
+ const isText = activeTool === 'text';
1141
+
1142
+ for (const [, iframe] of iframeMapRef.current) {
1143
+ const doc = iframe.contentDocument;
1144
+ if (!doc?.body) continue;
1145
+
1146
+ doc.body.classList.remove(
1147
+ 'draw-mode', 'text-mode', 'move-mode',
1148
+ 'tool-rectangle', 'tool-ellipse', 'tool-line', 'tool-arrow',
1149
+ 'tool-pen', 'tool-pencil', 'tool-text', 'tool-frame'
1150
+ );
1151
+
1152
+ if (isDrawing) {
1153
+ doc.body.classList.add('draw-mode', `tool-${activeTool}`);
1154
+ } else if (isText) {
1155
+ doc.body.classList.add('text-mode', 'tool-text');
1156
+ }
1157
+ }
1158
+ }, [activeTool]);
1159
+
1160
+ // ===== ホイール転送メッセージ処理 =====
1161
+ useEffect(() => {
1162
+ const handleMessage = (e: MessageEvent) => {
1163
+ // ホイール転送 → キャンバスズーム/パン
1164
+ if (e.data?.type === 'PAGE_PREVIEW_WHEEL') {
1165
+ const container = containerRef.current;
1166
+ if (!container) return;
1167
+
1168
+ const vs = viewStateRef.current;
1169
+ const co = vs.canvasOffset;
1170
+ const cz = vs.canvasZoom;
1171
+
1172
+ if (e.data.ctrlKey || e.data.metaKey) {
1173
+ const rect = container.getBoundingClientRect();
1174
+ const mouseX = rect.width / 2;
1175
+ const mouseY = rect.height / 2;
1176
+
1177
+ const delta = -(e.data.deltaY || 0);
1178
+ const factor = delta > 0 ? 1.05 : 0.95;
1179
+ const newZoom = Math.max(0.02, Math.min(2.0, cz * factor));
1180
+
1181
+ setCanvasZoom(newZoom);
1182
+ setCanvasOffset({
1183
+ x: mouseX - (mouseX - co.x) * (newZoom / cz),
1184
+ y: mouseY - (mouseY - co.y) * (newZoom / cz),
1185
+ });
1186
+ } else {
1187
+ setCanvasOffset({
1188
+ x: co.x - (e.data.deltaX || 0),
1189
+ y: co.y - (e.data.deltaY || 0),
1190
+ });
1191
+ }
1192
+ }
1193
+
1194
+ // タッチ転送 → ピンチズーム
1195
+ if (e.data?.type === 'PAGE_PREVIEW_TOUCH' && e.data.touchType === 'move') {
1196
+ const container = containerRef.current;
1197
+ if (!container) return;
1198
+ const touches = e.data.touches;
1199
+ const prevTouches = e.data.prevTouches;
1200
+ if (!touches || touches.length < 2 || !prevTouches || prevTouches.length < 2) return;
1201
+
1202
+ const vs = viewStateRef.current;
1203
+ const co = vs.canvasOffset;
1204
+ const cz = vs.canvasZoom;
1205
+
1206
+ // ピンチ距離計算
1207
+ const prevDist = Math.hypot(
1208
+ prevTouches[0].clientX - prevTouches[1].clientX,
1209
+ prevTouches[0].clientY - prevTouches[1].clientY
1210
+ );
1211
+ const currDist = Math.hypot(
1212
+ touches[0].clientX - touches[1].clientX,
1213
+ touches[0].clientY - touches[1].clientY
1214
+ );
1215
+ if (prevDist === 0) return;
1216
+
1217
+ const scale = currDist / prevDist;
1218
+ const newZoom = Math.max(0.02, Math.min(2.0, cz * scale));
1219
+
1220
+ const rect = container.getBoundingClientRect();
1221
+ const centerX = (touches[0].clientX + touches[1].clientX) / 2 - rect.left;
1222
+ const centerY = (touches[0].clientY + touches[1].clientY) / 2 - rect.top;
1223
+
1224
+ setCanvasZoom(newZoom);
1225
+ setCanvasOffset({
1226
+ x: centerX - (centerX - co.x) * (newZoom / cz),
1227
+ y: centerY - (centerY - co.y) * (newZoom / cz),
1228
+ });
1229
+ }
1230
+
1231
+ // breadcrumb-select: パンくずクリックで要素選択
1232
+ if (e.data?.type === 'breadcrumb-select' && e.data.elementId) {
1233
+ for (const [, iframe] of iframeMapRef.current) {
1234
+ if (e.source === iframe.contentWindow) {
1235
+ const iframeDoc = iframe.contentDocument;
1236
+ if (!iframeDoc) break;
1237
+ const targetEl = iframeDoc.querySelector(`[data-element-id="${e.data.elementId}"]`) as HTMLElement;
1238
+ if (targetEl) {
1239
+ // 既存の選択を解除
1240
+ const existingSelected = iframeDoc.querySelector('.selected');
1241
+ if (existingSelected) existingSelected.classList.remove('selected');
1242
+ removeSelectionBox(iframeDoc);
1243
+ // 新しい要素を選択
1244
+ targetEl.classList.add('selected');
1245
+ updateSelectionBox(iframeDoc, targetEl);
1246
+ // refs更新 + パネル更新
1247
+ (refs.iframeRef as React.MutableRefObject<HTMLIFrameElement | null>).current = iframe;
1248
+ refs.setIframeReady(true);
1249
+ const info = extractElementInfo(targetEl, iframeDoc);
1250
+ if (info) {
1251
+ setSelectedElementRef.current(info);
1252
+ setSelectedElementIdsRef.current([info.id]);
1253
+ }
1254
+ }
1255
+ break;
1256
+ }
1257
+ }
1258
+ }
1259
+
1260
+ // 描画ツール/画像アップロード後のページフレーム同期
1261
+ if (e.data?.type === 'DOM_TREE_UPDATED') {
1262
+ const currentIframe = (refs.iframeRef as React.MutableRefObject<HTMLIFrameElement | null>).current;
1263
+ if (currentIframe) {
1264
+ for (const [pid, ifr] of iframeMapRef.current) {
1265
+ if (ifr === currentIframe) {
1266
+ const doc = ifr.contentDocument;
1267
+ if (doc) {
1268
+ const artboard = doc.getElementById('artboard');
1269
+ if (artboard) {
1270
+ updatePageFrameRef.current(pid, {
1271
+ thumbnailHtml: artboard.innerHTML,
1272
+ isDirty: true,
1273
+ });
1274
+ }
1275
+ }
1276
+ break;
1277
+ }
1278
+ }
1279
+ }
1280
+ }
1281
+
1282
+ // コンテンツ高さ通知
1283
+ if (e.data?.type === 'PAGE_CONTENT_HEIGHT') {
1284
+ const { pageId, height } = e.data;
1285
+ if (pageId && typeof height === 'number' && height > 0) {
1286
+ const existingPage = pagesRef.current.find(p => p.id === pageId);
1287
+ if (existingPage && Math.abs(existingPage.size.height - height) > 10) {
1288
+ updatePageFrame(pageId, { size: { width: existingPage.size.width, height } });
1289
+ }
1290
+ }
1291
+ }
1292
+ };
1293
+
1294
+ window.addEventListener('message', handleMessage);
1295
+ return () => window.removeEventListener('message', handleMessage);
1296
+ }, [setCanvasZoom, setCanvasOffset, updatePageFrame, refs]);
1297
+
1298
+ // スペースキーでiframeのpointer-eventsを一時無効化(パンモード)
1299
+ useEffect(() => {
1300
+ const handleKeyDown = (e: KeyboardEvent) => {
1301
+ if (e.code === 'Space' && !e.repeat) {
1302
+ const container = containerRef.current;
1303
+ if (!container) return;
1304
+ container.querySelectorAll('iframe').forEach(iframe => {
1305
+ (iframe as HTMLElement).style.pointerEvents = 'none';
1306
+ });
1307
+ }
1308
+ };
1309
+
1310
+ const handleKeyUp = (e: KeyboardEvent) => {
1311
+ if (e.code === 'Space') {
1312
+ const container = containerRef.current;
1313
+ if (!container) return;
1314
+ container.querySelectorAll('iframe').forEach(iframe => {
1315
+ (iframe as HTMLElement).style.pointerEvents = '';
1316
+ });
1317
+ }
1318
+ };
1319
+
1320
+ window.addEventListener('keydown', handleKeyDown);
1321
+ window.addEventListener('keyup', handleKeyUp);
1322
+ return () => {
1323
+ window.removeEventListener('keydown', handleKeyDown);
1324
+ window.removeEventListener('keyup', handleKeyUp);
1325
+ };
1326
+ }, []);
1327
+
1328
+ return (
1329
+ <div
1330
+ ref={containerRef}
1331
+ data-infinite-canvas="true"
1332
+ className="absolute inset-0 overflow-hidden select-none overscroll-none"
1333
+ style={{ backgroundColor: '#1a1a1a', touchAction: 'none', overscrollBehavior: 'none' }}
1334
+ >
1335
+ {/* ドットグリッド背景 */}
1336
+ <div
1337
+ className="absolute inset-0 pointer-events-none"
1338
+ style={{
1339
+ backgroundImage: `radial-gradient(circle, rgba(255,255,255,0.05) 1px, transparent 1px)`,
1340
+ backgroundSize: `${24 * canvasZoom}px ${24 * canvasZoom}px`,
1341
+ backgroundPosition: `${canvasOffset.x % (24 * canvasZoom)}px ${canvasOffset.y % (24 * canvasZoom)}px`,
1342
+ }}
1343
+ />
1344
+
1345
+ {/* CSS transform 無限キャンバス */}
1346
+ <div
1347
+ className="canvas-transform-layer"
1348
+ style={{
1349
+ transform: `translate(${canvasOffset.x}px, ${canvasOffset.y}px) scale(${canvasZoom})`,
1350
+ transformOrigin: '0 0',
1351
+ willChange: 'transform',
1352
+ }}
1353
+ >
1354
+ {/*
1355
+ * イベントキャプチャ用オーバーレイ
1356
+ * ズームやスクロール中にiframeをまたぐとイベントが途切れる問題を防ぐため、
1357
+ * ホイールやタッチ操作中のみポインターイベントを横取りする
1358
+ */}
1359
+ <div
1360
+ className="absolute inset-x-0 inset-y-0 z-50 transition-opacity duration-150"
1361
+ style={{
1362
+ pointerEvents: isInteracting ? 'auto' : 'none',
1363
+ opacity: 0
1364
+ }}
1365
+ />
1366
+
1367
+ {/* 全ページ: 同時編集可能 */}
1368
+ {pages.map(page => (
1369
+ <div
1370
+ key={page.id}
1371
+ className="absolute"
1372
+ style={{
1373
+ left: page.position.x,
1374
+ top: page.position.y,
1375
+ width: page.size.width,
1376
+ height: page.size.height,
1377
+ }}
1378
+ >
1379
+ <PageLabel
1380
+ title={page.title}
1381
+ isDirty={page.isDirty}
1382
+ />
1383
+
1384
+ <div className="w-full h-full bg-white rounded-sm shadow-lg overflow-hidden">
1385
+ <PageLivePreview
1386
+ html={page.thumbnailHtml}
1387
+ pageId={page.id}
1388
+ onIframeLoad={handlePageIframeLoad}
1389
+ />
1390
+ </div>
1391
+ </div>
1392
+ ))}
1393
+ </div>
1394
+
1395
+ {/* ズームコントロール */}
1396
+ <CanvasZoomControls />
1397
+
1398
+ {/* ページ数表示 */}
1399
+ <div className="absolute top-4 left-4 z-10 text-xs text-gray-500 select-none">
1400
+ {pages.length} pages
1401
+ </div>
1402
+ </div>
1403
+ );
1404
+ });