@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,2595 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * フロントエンドビジュアルエディタ
5
+ *
6
+ * レイアウト構成:
7
+ * - 左パネル (w-64): レイヤー/DOMツリー
8
+ * - 中央 (flex-1): キャンバス + ツールバー
9
+ * - 右パネル (w-72): プロパティパネル
10
+ *
11
+ * 汎用的なHTML編集エディタとして、スライド、ページ、コンポーネント等で利用可能
12
+ */
13
+
14
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
15
+ import { useEditorShortcuts } from '../hooks/useEditorShortcuts';
16
+ import {
17
+ editorCancelDragRef,
18
+ selectAllSiblingsIn,
19
+ selectSiblingIn,
20
+ exitTextEditingIn,
21
+ } from './hooks/useKeyboardShortcuts';
22
+ import type { MoveElementResult } from './hooks/useElementActions';
23
+ import { EditorProvider, useEditorContext, type ContentListItem, type EditorMode } from './EditorContext';
24
+ import { PptTitleBar, PptRibbon, PptThumbnails, PptStatusBar, initialPptTheme, PPT_PALETTES, type PptTheme } from './components/ppt/PptChrome';
25
+ import { LeftPanel } from './components/shell/LeftPanel';
26
+ import { useAltMeasure } from './hooks/useAltMeasure';
27
+ import { PptCommentsPanel, PptCommentMarkers } from './components/ppt/PptComments';
28
+ import { PptNotes } from './components/ppt/PptNotes';
29
+ import { PptFormatPane } from './components/ppt/PptFormatPane';
30
+ import { createAuthApi } from '../lib/api/auth-fetch';
31
+ import { useAuth } from '../components/auth/AuthProvider';
32
+ import { findSlideRoot, findInsertionParent } from './utils/slide-root';
33
+ import { registerAutoSaveFlush } from './autosave';
34
+ import { getCleanHtml } from './utils/html-utils';
35
+ import './editor-skin.css';
36
+ import type { Slide } from '../types/slide';
37
+ import {
38
+ useEditorMessages,
39
+ useDrawingMode,
40
+ useElementActions,
41
+ useImageUpload,
42
+ useRichPaste,
43
+ useAiReplace,
44
+ useBrowserZoomPrevention,
45
+ usePageSettingsManager,
46
+ useComponentEditMode,
47
+ toEditorMediaUrl,
48
+ editorZoomApiRef,
49
+ } from './hooks';
50
+ import type { MediaItem } from './hooks';
51
+ import {
52
+ EditorHeader,
53
+ EditorLayerPanel,
54
+ EditorPropertyPanel,
55
+ EditorFooter,
56
+ EditorContextMenu,
57
+ AiPromptPopover,
58
+ HtmlEditorDialog,
59
+ HtmlImportDialog,
60
+ CssEditorDialog,
61
+ JsEditorDialog,
62
+ PageSettingsDialog,
63
+ MediaLibraryDialog,
64
+ EditorCanvas,
65
+ VariablesPanel,
66
+ ComponentPanel,
67
+ MasterComponentEditor,
68
+ } from './components';
69
+ import { useEditorVariables, useEditorComponents } from './EditorContext';
70
+ import type { CSSVariableDefinition } from '../types/css-variables';
71
+ import { getCSSVariablesList, saveCSSVariables, type CSSVariableScope } from '../lib/firebase/css-variables';
72
+ import { BreakpointGuides } from './components/BreakpointGuides';
73
+ import type { ContextMenuPosition } from './components';
74
+ import { EditorToolbar } from './EditorToolbar';
75
+ import { MultiPageCanvasView } from './components/multi-page';
76
+ import { convertToAbsolutePositioning, buildDomTree, getArtboardContent, canUngroup, updateSelectionBox } from './utils/dom-utils';
77
+ import { extractElementInfo } from './utils/style-utils';
78
+ import { findTableCell, insertRow, insertColumn, deleteRow, deleteColumn } from './utils/table-edit';
79
+ import { Loader2 } from 'lucide-react';
80
+ import { toast } from 'sonner';
81
+ import {
82
+ Dialog,
83
+ DialogContent,
84
+ DialogHeader,
85
+ DialogTitle,
86
+ DialogFooter,
87
+ } from '../components/ui/dialog';
88
+ import {
89
+ Select,
90
+ SelectContent,
91
+ SelectItem,
92
+ SelectTrigger,
93
+ SelectValue,
94
+ } from '../components/ui/select';
95
+ import { Input } from '../components/ui/input';
96
+ import { Button } from '../components/ui/button';
97
+ import { cn } from '../lib/utils';
98
+
99
+ /**
100
+ * 自動保存のデバウンス(ms)。
101
+ * 変更が止まってからこの時間で保存する。連続編集中はタイマーが延び続けるので、
102
+ * 打鍵やドラッグのたびに保存(=原本TSXへの書き戻しジョブ)が走ることはない。
103
+ */
104
+ const AUTO_SAVE_DELAY_MS = 2000;
105
+
106
+ /**
107
+ * 保存の状態表示。エディタの殻(Figma風ヘッダー / PowerPoint風タイトルバー)で共通に使う。
108
+ * - dirty: 未保存の変更あり(このあと自動保存される)
109
+ * - saving: 保存中
110
+ * - saved: 保存済み
111
+ * - error: 自動保存に失敗(手動保存で再試行できる)
112
+ */
113
+ export type SaveStatus = 'saved' | 'dirty' | 'saving' | 'error';
114
+
115
+ export interface FrontendVisualEditorProps {
116
+ html: string;
117
+ /** エディタモード: 'slide'(16:9固定)または 'webpage'(可変高さ) */
118
+ editorMode?: EditorMode;
119
+ /**
120
+ * アートボードの幅(webpageモードのとき)。省略時は 1400。
121
+ *
122
+ * ここを変えると版面の幅が変わり、**文字の折り返し位置が実際の見え方とずれる**。
123
+ * 「この幅で見る」と決まっているものを渡すこと。
124
+ */
125
+ artboardWidth?: number;
126
+ /** コンテンツID(スライドID、ページID等) */
127
+ contentId?: string;
128
+ /** 親リソースID(プレゼンテーションID、プロジェクトID等) */
129
+ parentId?: string;
130
+ /** @deprecated slideId - contentIdを使用してください */
131
+ slideId?: string;
132
+ /** @deprecated presentationId - parentIdを使用してください */
133
+ presentationId?: string;
134
+ /**
135
+ * 保存。options.auto は自動保存(ユーザーが押したのではない)を示す。
136
+ * 保存結果のトーストは手動保存のときだけ出したいので、呼び出し側で見分けられるようにする
137
+ */
138
+ onSave: (html: string, options?: { auto?: boolean }) => Promise<void>;
139
+ onClose: () => void;
140
+ /** マルチページ無限キャンバスモードを有効にする (default: false) */
141
+ enableMultiPageCanvas?: boolean;
142
+ /** 外部から渡すコンテンツリスト(指定時はAPI取得をスキップ) */
143
+ contentList?: ContentListItem[];
144
+ }
145
+
146
+ /**
147
+ * エディタの内部コンポーネント
148
+ * EditorProviderの内部でHooksを使用
149
+ */
150
+ function FrontendVisualEditorInner({
151
+ onSave,
152
+ onClose,
153
+ parentId,
154
+ contentId,
155
+ isMultiPageCanvas = false,
156
+ }: Pick<FrontendVisualEditorProps, 'onSave' | 'onClose' | 'parentId' | 'contentId'> & {
157
+ isMultiPageCanvas?: boolean;
158
+ }) {
159
+ const {
160
+ iframeRef,
161
+ containerRef,
162
+ activeTool,
163
+ setActiveTool,
164
+ originalHtml,
165
+ // [自動保存] 変更検知と、保存できた分の基準の付け替えに使う
166
+ html,
167
+ hasChanges,
168
+ setOriginalHtml,
169
+ saving,
170
+ setSaving,
171
+ selectedElement,
172
+ setSelectedElement,
173
+ selectedElementIds,
174
+ setSelectedElementIds,
175
+ undo,
176
+ redo,
177
+ canUndo,
178
+ canRedo,
179
+ pushHistory,
180
+ zoom,
181
+ setZoom,
182
+ fitZoom,
183
+ // [移植時の追加] ヘッダーに「何枚目の何というスライドを編集中か」を出すため
184
+ contentList,
185
+ currentContentId,
186
+ domTree,
187
+ setDomTree,
188
+ notifyIframeChange,
189
+ getIframeDoc,
190
+ openSections,
191
+ setOpenSections,
192
+ showLayoutHint,
193
+ setShowLayoutHint,
194
+ setLayoutMode,
195
+ layoutMode,
196
+ setAutoLayoutHtml,
197
+ setExpandedNodes,
198
+ editorMode,
199
+ restoreFocus,
200
+ iframeReady,
201
+ } = useEditorContext();
202
+
203
+ const {
204
+ deleteElement,
205
+ duplicateElement,
206
+ copyElements,
207
+ pasteSerializedElements,
208
+ pasteFromInternalIfFresh,
209
+ resizeElements,
210
+ cutElements,
211
+ pasteElements,
212
+ copyStyle,
213
+ pasteStyle,
214
+ hasStyleInClipboard,
215
+ copyToFigma,
216
+ bringForward,
217
+ sendBackward,
218
+ bringToFront,
219
+ sendToBack,
220
+ groupElements,
221
+ ungroupElements,
222
+ moveUp,
223
+ moveDown,
224
+ moveLeft,
225
+ moveRight,
226
+ updateElementAttribute,
227
+ } = useElementActions();
228
+
229
+ // 画像アップロード
230
+ const {
231
+ isUploading,
232
+ uploadError,
233
+ uploadFromFile,
234
+ uploadFromFiles,
235
+ uploadFromClipboard,
236
+ openFilePicker,
237
+ insertImageFromUrl,
238
+ } = useImageUpload({ presentationId: parentId, slideId: contentId });
239
+
240
+ // リッチペースト(Excel, Word, HTML, SVG)
241
+ const {
242
+ canHandleRichPaste,
243
+ pasteRichContent,
244
+ debugClipboard,
245
+ detectContentType,
246
+ } = useRichPaste();
247
+
248
+ // CSS変数管理
249
+ const {
250
+ variables,
251
+ cssString,
252
+ hasVariables,
253
+ isSaving: isVariablesSaving,
254
+ hasChanges: hasVariableChanges,
255
+ addVariable,
256
+ updateVariable,
257
+ deleteVariable,
258
+ saveVariables,
259
+ } = useEditorVariables();
260
+
261
+ // コンポーネント管理(メインコンポーネントで直接使用する値のみ)
262
+ const {
263
+ masterComponents,
264
+ componentLibrary,
265
+ createInstance,
266
+ getMasterComponent,
267
+ } = useEditorComponents();
268
+
269
+ const { getIdToken } = useAuth();
270
+ const hasComponents = masterComponents.size > 0;
271
+
272
+ // ドラッグ&ドロップ状態
273
+ const [isDraggingOver, setIsDraggingOver] = useState(false);
274
+ const dragCounterRef = useRef(0); // ドラッグイベントのカウンター(子要素の出入りを追跡)
275
+ const canvasAreaRef = useRef<HTMLDivElement>(null);
276
+
277
+ // [移植時の修正] キーボードショートカット用の Ref 群(undoRef / deleteElementRef …)は削除した。
278
+ // Stale Closure 回避のために置かれていたが、その利用者だった window capture ハンドラと
279
+ // postMessage ブリッジを廃止したため不要。
280
+ // 現在はディスパッチャ(useEditorShortcuts)が毎レンダー最新のコールバック表を
281
+ // ref 経由で参照するので、この階層で個別に ref を持つ必要がない。
282
+
283
+ // コンテキストメニュー状態
284
+ const [contextMenuPosition, setContextMenuPosition] = useState<ContextMenuPosition | null>(null);
285
+
286
+ // AI生成ポップオーバー状態
287
+ const [aiPromptPosition, setAiPromptPosition] = useState<{ x: number; y: number } | null>(null);
288
+
289
+ // HTML編集モーダル状態
290
+ const [htmlEditorState, setHtmlEditorState] = useState<{
291
+ isOpen: boolean;
292
+ elementId: string | null;
293
+ initialHtml: string;
294
+ }>({
295
+ isOpen: false,
296
+ elementId: null,
297
+ initialHtml: '',
298
+ });
299
+
300
+ // HTMLインポートダイアログ状態
301
+ const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
302
+ // PowerPoint風UIとFigma風UIの切り替え(殻だけ。エンジンは共通)
303
+ const [uiMode, setUiMode] = useState<'figma' | 'ppt'>(
304
+ () => (typeof localStorage !== 'undefined' && localStorage.getItem('gg-editor:ui-mode') === 'ppt' ? 'ppt' : 'figma'),
305
+ );
306
+ const switchUi = (mode: 'figma' | 'ppt') => {
307
+ // 殻を差し替えるだけで編集内容は残るが、どちらのUIから見ても同じ状態で始まるよう
308
+ // 未保存があればここで保存しておく(切替の見た目は待たせない)
309
+ void saveIfDirtyRef.current();
310
+ setUiMode(mode);
311
+ try { localStorage.setItem('gg-editor:ui-mode', mode); } catch { /* 記憶できなくても動作は継続 */ }
312
+ };
313
+ // PowerPoint風UIのテーマ(OS設定に追従・切替は記憶)とサムネイル検索
314
+ const [pptTheme, setPptTheme] = useState<PptTheme>(() => initialPptTheme());
315
+ const togglePptTheme = () => {
316
+ const next: PptTheme = pptTheme === 'dark' ? 'light' : 'dark';
317
+ setPptTheme(next);
318
+ try { localStorage.setItem('gg-editor:ppt-theme', next); } catch { /* 続行 */ }
319
+ };
320
+ const [pptSearch, setPptSearch] = useState('');
321
+ // コメントパネル(PowerPoint風UIのみ)。フォーカス合図はカウンタで送る
322
+ const [pptCommentsOpen, setPptCommentsOpen] = useState(false);
323
+ // PowerPoint風UIの右ペイン「図の書式設定」(影・反射・光彩・ぼかし)
324
+ const [pptFormatPaneOpen, setPptFormatPaneOpen] = useState(false);
325
+ const [pptCommentFocus, setPptCommentFocus] = useState(0);
326
+ const [pptActiveThread, setPptActiveThread] = useState<string | null>(null);
327
+
328
+ // CSS変数パネル状態
329
+ const [isVariablesPanelOpen, setIsVariablesPanelOpen] = useState(false);
330
+
331
+ // メディアライブラリ状態
332
+ const [isMediaLibraryOpen, setIsMediaLibraryOpen] = useState(false);
333
+
334
+ // ページ設定管理(CSS/JS編集、エクスポート、OGP、HTMLインポート含む)
335
+ const {
336
+ isCssEditorOpen,
337
+ setIsCssEditorOpen,
338
+ importedCss,
339
+ handleOpenCssEditor,
340
+ handleSaveCss,
341
+ handleClearCss,
342
+ isJsEditorOpen,
343
+ setIsJsEditorOpen,
344
+ importedJs,
345
+ handleOpenJsEditor,
346
+ handleSaveJs,
347
+ handleClearJs,
348
+ isPageSettingsOpen,
349
+ setIsPageSettingsOpen,
350
+ pageSettings,
351
+ projectSettings,
352
+ handleOpenPageSettings,
353
+ handleSavePageSettings,
354
+ handleSaveCurrentSettings,
355
+ handleExport,
356
+ handleUploadOgpImage,
357
+ handleHtmlImport,
358
+ } = usePageSettingsManager({ parentId, contentId });
359
+
360
+ // CSS変数をiframeに反映(iframeReadyになってから)
361
+ useEffect(() => {
362
+ // iframeが準備できていない場合は何もしない
363
+ if (!iframeReady) {
364
+ console.log('[applyCssVariablesToIframe] Waiting for iframe to be ready...');
365
+ return;
366
+ }
367
+ if (!cssString) return;
368
+
369
+ const iframeDoc = getIframeDoc();
370
+ if (!iframeDoc) {
371
+ console.warn('[applyCssVariablesToIframe] iframe ready but document not accessible');
372
+ return;
373
+ }
374
+
375
+ // CSS変数スタイル要素を作成または更新
376
+ let styleElement = iframeDoc.getElementById('editor-css-variables');
377
+ if (!styleElement) {
378
+ styleElement = iframeDoc.createElement('style');
379
+ styleElement.id = 'editor-css-variables';
380
+ // CSS変数は他のスタイルより優先されるようheadの先頭に挿入
381
+ iframeDoc.head.insertBefore(styleElement, iframeDoc.head.firstChild);
382
+ }
383
+
384
+ if (styleElement.textContent !== cssString) {
385
+ styleElement.textContent = cssString;
386
+ console.log('[applyCssVariablesToIframe] Applied CSS variables:', variables.length, 'variables');
387
+ console.log('[applyCssVariablesToIframe] CSS String:', cssString);
388
+ console.log('[applyCssVariablesToIframe] Variables:', variables.map(v => ({ name: v.name, cssName: v.cssName, value: v.value })));
389
+ }
390
+ }, [cssString, variables.length, getIframeDoc, iframeReady]);
391
+
392
+ // キャンバスエリアのサイズ(ブレイクポイントガイド用)
393
+ const [canvasAreaSize, setCanvasAreaSize] = useState({ width: 0, height: 0 });
394
+
395
+ const prevLayoutModeRef = useRef(layoutMode);
396
+
397
+ // レイアウトモードの変更を検知して保存
398
+ useEffect(() => {
399
+ // 初期ロード時や、値が変わっていない場合はスキップ
400
+ if (prevLayoutModeRef.current === layoutMode) return;
401
+
402
+ // 値を更新
403
+ prevLayoutModeRef.current = layoutMode;
404
+
405
+ // コンテンツIDと親IDがある場合のみ保存
406
+ if (parentId && contentId) {
407
+ const saveLayoutMode = async () => {
408
+ try {
409
+ const api = await createAuthApi(getIdToken);
410
+ // editorModeに応じてAPIパスを切り替え
411
+ const apiPath = editorMode === 'webpage'
412
+ ? `/api/websites/${parentId}/pages/${contentId}`
413
+ : `/api/presentations/${parentId}/slides/${contentId}`;
414
+ await api.patch(apiPath, { layoutMode });
415
+ console.log('[FrontendVisualEditor] Saved layout mode:', layoutMode);
416
+ } catch (error) {
417
+ console.error('[FrontendVisualEditor] Failed to save layout mode:', error);
418
+ }
419
+ };
420
+
421
+ saveLayoutMode();
422
+ }
423
+ }, [layoutMode, parentId, contentId, getIdToken, editorMode]);
424
+
425
+ // キャンバスエリアのサイズを監視(ブレイクポイントガイド用)
426
+ useEffect(() => {
427
+ if (!canvasAreaRef.current) return;
428
+
429
+ const resizeObserver = new ResizeObserver((entries) => {
430
+ for (const entry of entries) {
431
+ setCanvasAreaSize({
432
+ width: entry.contentRect.width,
433
+ height: entry.contentRect.height,
434
+ });
435
+ }
436
+ });
437
+
438
+ resizeObserver.observe(canvasAreaRef.current);
439
+
440
+ // 初期サイズを設定
441
+ const rect = canvasAreaRef.current.getBoundingClientRect();
442
+ setCanvasAreaSize({ width: rect.width, height: rect.height });
443
+
444
+ return () => resizeObserver.disconnect();
445
+ }, []);
446
+
447
+ // ブラウザのネイティブズームを防止(最優先で登録)
448
+ useBrowserZoomPrevention();
449
+
450
+ // Hooks初期化
451
+ useEditorMessages();
452
+ useDrawingMode();
453
+ // Alt(Option)ホバーで距離を測る(Figmaの計測線)
454
+ useAltMeasure();
455
+
456
+ // AI要素置換(editorModeに基づいてslide-agentまたはwebsite-agentを使用)
457
+ const {
458
+ generateAndReplace,
459
+ isGenerating: isAiGenerating,
460
+ getSelectedElementInfo,
461
+ } = useAiReplace({
462
+ // スライドモードならpresentationIdとして、WebページモードならwebsiteIdとしてparentIdを渡す
463
+ presentationId: editorMode === 'slide' ? parentId : undefined,
464
+ websiteId: editorMode === 'webpage' ? parentId : undefined,
465
+ });
466
+
467
+ // オーバーレイ(コンテキストメニュー/AIポップオーバー)を閉じた時刻。
468
+ // Escape は「オーバーレイを閉じる」→「選択を1段上へ」→「選択解除」の順で1段だけ効かせたい。
469
+ // オーバーレイ自身の Escape ハンドラは document(バブリング)にあり、
470
+ // その中の setState が React のマイクロタスクで即座に反映されるため、
471
+ // 後から window で受け取るディスパッチャからは「もう閉じている」ように見えてしまう。
472
+ // 直前に閉じたかどうかを時刻で見て、同じ Escape が2段進むのを防ぐ。
473
+ const overlayClosedAtRef = useRef(0);
474
+
475
+ /**
476
+ * 右クリックしたセル(表の編集の起点)。
477
+ * iframe から届く IFRAME_CONTEXT_MENU は座標しか持たないので、
478
+ * 同じ contextmenu イベントを自前でも拾って「どのセルか」を覚えておく。
479
+ * 座標から elementFromPoint で引き直さないのは、アートボードがズームで
480
+ * 拡大縮小されており、親ページとiframeで座標系が食い違うため。
481
+ */
482
+ const [contextCell, setContextCell] = useState<HTMLTableCellElement | null>(null);
483
+
484
+ // コンテキストメニューを閉じる
485
+ const closeContextMenu = useCallback(() => {
486
+ overlayClosedAtRef.current = performance.now();
487
+ setContextCell(null);
488
+ setContextMenuPosition(null);
489
+ // ショートカットが引き続き機能するようフォーカスを復元
490
+ requestAnimationFrame(() => {
491
+ restoreFocus();
492
+ });
493
+ }, [restoreFocus]);
494
+
495
+ // コンポーネント編集モード管理
496
+ const {
497
+ isComponentPanelOpen,
498
+ setIsComponentPanelOpen,
499
+ selectedMasterComponentId,
500
+ setSelectedMasterComponentId,
501
+ editingMasterComponent,
502
+ isMasterEditorOpen,
503
+ setIsMasterEditorOpen,
504
+ setEditingMasterComponent,
505
+ isComponentEditMode,
506
+ editingComponentId,
507
+ createComponentDialogOpen,
508
+ setCreateComponentDialogOpen,
509
+ newComponentName,
510
+ setNewComponentName,
511
+ newComponentCategory,
512
+ setNewComponentCategory,
513
+ selectedElementInstance,
514
+ isComponentInstance,
515
+ hasOverrides,
516
+ handleCreateComponent,
517
+ handleConfirmCreateComponent,
518
+ handleEditMasterComponent,
519
+ handleSaveMasterComponent,
520
+ handleDeleteMasterComponent,
521
+ enterComponentEditMode,
522
+ exitComponentEditMode,
523
+ handleGoToMainComponent,
524
+ handleDetachInstance,
525
+ handleResetOverrides,
526
+ handlePushOverridesToMain,
527
+ } = useComponentEditMode({ contentId, closeContextMenu });
528
+
529
+ // HTML編集を開く
530
+ const handleEditHtml = useCallback(() => {
531
+ if (!selectedElement) return;
532
+
533
+ const iframeDoc = getIframeDoc();
534
+ if (!iframeDoc) return;
535
+
536
+ const element = iframeDoc.querySelector(`[data-element-id="${selectedElement.id}"]`);
537
+ if (element) {
538
+ setHtmlEditorState({
539
+ isOpen: true,
540
+ elementId: selectedElement.id,
541
+ initialHtml: element.outerHTML,
542
+ });
543
+ // メニューを閉じる
544
+ setContextMenuPosition(null);
545
+ }
546
+ }, [selectedElement, getIframeDoc]);
547
+
548
+ // ===== メディアライブラリ =====
549
+
550
+ /** <img> を選択中かどうか。選択中なら新規挿入ではなく src 差し替えになる */
551
+ const isImageSelected =
552
+ selectedElement?.tagName?.toUpperCase() === 'IMG' && selectedElementIds.length <= 1;
553
+
554
+ /** 選択中 <img> の現在の src(ライブラリ内で選択状態を示すため) */
555
+ const selectedImageSrc = isImageSelected ? selectedElement?.imageSrc : undefined;
556
+
557
+ /**
558
+ * メディアライブラリで画像が選ばれたとき。
559
+ * - <img> 選択中: updateElementAttribute({ src }) で差し替え
560
+ * - それ以外: insertImageFromUrl() で新規挿入(縦横比を保つため実寸を先に読む)
561
+ */
562
+ /**
563
+ * 画像の変更(コンテキストメニュー)。実機PowerPointの「画像の変更」相当。
564
+ * 位置・サイズ・スタイルは要素に付いているのでそのまま残り、srcだけ差し替わる。
565
+ */
566
+ const replaceImageFromFile = useCallback(() => {
567
+ if (!isImageSelected) return;
568
+ const input = document.createElement('input');
569
+ input.type = 'file';
570
+ input.accept = 'image/*';
571
+ input.onchange = async () => {
572
+ const file = input.files?.[0];
573
+ if (!file) return;
574
+ try {
575
+ const { uploadEditorImage } = await import('../lib/firebase/storage');
576
+ const result = await uploadEditorImage(file, { fileName: file.name });
577
+ updateElementAttribute({ src: result.storageUrl, alt: file.name });
578
+ } catch (e) {
579
+ toast.error(`画像の差し替えに失敗しました: ${String(e).slice(0, 80)}`);
580
+ }
581
+ };
582
+ input.click();
583
+ }, [isImageSelected, updateElementAttribute]);
584
+
585
+ /**
586
+ * 「クリップボードから」のワンショット差し替え。
587
+ * clipboard.read() は許可・OSの形式(Finderのファイルコピー等)で読めないことが
588
+ * 多いため、読めないときは「次の⌘Vで差し替える」モードに切り替える。
589
+ * pasteイベント経由なら権限プロンプトなしで確実に画像が取れる。
590
+ */
591
+ const replaceImageOnPasteRef = useRef<string | null>(null);
592
+
593
+ const replaceImageFromClipboard = useCallback(async () => {
594
+ if (!isImageSelected || !selectedElement?.id) return;
595
+ try {
596
+ const items = await navigator.clipboard.read();
597
+ for (const item of items) {
598
+ const type = item.types.find((t) => t.startsWith('image/'));
599
+ if (!type) continue;
600
+ const blob = await item.getType(type);
601
+ const { uploadEditorImage } = await import('../lib/firebase/storage');
602
+ const result = await uploadEditorImage(blob, { fileName: 'clipboard.png' });
603
+ updateElementAttribute({ src: result.storageUrl });
604
+ toast.success('画像を差し替えました');
605
+ return;
606
+ }
607
+ } catch {
608
+ // 読めない環境(許可なし・ファイル形式)はフォールバックへ
609
+ }
610
+ // フォールバック: 次のペーストを「差し替え」として扱う
611
+ replaceImageOnPasteRef.current = selectedElement.id;
612
+ toast.info('⌘V(Ctrl+V)を押すと、選択中の画像に貼り付けて差し替えます');
613
+ }, [isImageSelected, selectedElement, updateElementAttribute]);
614
+
615
+ const handleMediaSelect = useCallback(
616
+ async (item: MediaItem) => {
617
+ setIsMediaLibraryOpen(false);
618
+
619
+ // blob: iframe では相対パスが解決できないため絶対URLへ(保存時に相対へ戻る)
620
+ const src = toEditorMediaUrl(item.url);
621
+
622
+ if (isImageSelected) {
623
+ updateElementAttribute({ src, alt: item.label });
624
+ return;
625
+ }
626
+
627
+ // 画像の実寸を取得して縦横比を保つ(取得失敗時はフックの既定サイズに任せる)
628
+ const size = await new Promise<{ width?: number; height?: number }>((resolve) => {
629
+ const probe = new window.Image();
630
+ probe.onload = () =>
631
+ resolve({ width: probe.naturalWidth || undefined, height: probe.naturalHeight || undefined });
632
+ probe.onerror = () => resolve({});
633
+ probe.src = item.url;
634
+ });
635
+
636
+ const elementId = insertImageFromUrl(src, {
637
+ ...size,
638
+ x: 100,
639
+ y: 100,
640
+ });
641
+
642
+ if (!elementId) return;
643
+
644
+ const iframeDoc = getIframeDoc();
645
+ const inserted = iframeDoc?.querySelector<HTMLElement>(`[data-element-id="${elementId}"]`);
646
+ if (!inserted) return;
647
+
648
+ // 代替テキストに日本語ラベルを入れる
649
+ inserted.setAttribute('alt', item.label);
650
+
651
+ // スライドは高さ固定(overflow:hidden)なので、オートレイアウトのまま末尾に
652
+ // 追加するとアートボード外に出て見えない。可視範囲に絶対配置する。
653
+ if (editorMode === 'slide' && layoutMode !== 'absolute') {
654
+ inserted.style.position = 'absolute';
655
+ inserted.style.left = '100px';
656
+ inserted.style.top = '100px';
657
+ inserted.style.margin = '0';
658
+ }
659
+
660
+ // 履歴は挿入時の1件にまとめる(後処理で余計なUndoステップを作らない)
661
+ notifyIframeChange(false);
662
+ },
663
+ [
664
+ isImageSelected,
665
+ updateElementAttribute,
666
+ insertImageFromUrl,
667
+ getIframeDoc,
668
+ notifyIframeChange,
669
+ editorMode,
670
+ layoutMode,
671
+ ]
672
+ );
673
+
674
+ // ================= 保存 / 自動保存 =================
675
+ // 実機のPowerPointと同じく自動保存は常時オン。変更が止まって2秒で静かに保存する。
676
+ // 保存の入口はこの effectiveSave 1本に絞ってあり、手動保存(ヘッダー/タイトルバー)も
677
+ // 自動保存もページ切替直前のフラッシュもここを通る。
678
+
679
+ /** 保存の実行中を指すPromise。同時に2本走らせないための番人 */
680
+ const saveInFlightRef = useRef<Promise<void> | null>(null);
681
+ /** 直近で保存できた本文。同じ中身を何度も書き戻さないための番人 */
682
+ const savedPayloadRef = useRef<string | null>(null);
683
+ /** 自動保存が失敗したまま(状態表示に出し、成功で戻す) */
684
+ const [autoSaveFailed, setAutoSaveFailed] = useState(false);
685
+ /**
686
+ * 保存コールバックの中から見る「今の状態」。
687
+ * 特に onSave はページ番号を閉じ込めているため、保存の開始時点の値で
688
+ * 内容とページ番号を揃える必要がある(古い内容を新しいページへ書かないため)
689
+ */
690
+ const saveStateRef = useRef({ html, hasChanges, canUndo, contentId });
691
+ useEffect(() => {
692
+ saveStateRef.current = { html, hasChanges, canUndo, contentId };
693
+ }, [html, hasChanges, canUndo, contentId]);
694
+
695
+ const effectiveSave = useCallback(async (htmlToSave: string, options?: { auto?: boolean }) => {
696
+ // 実行中の保存があれば終わるまで待つ。待っている間に来た保存要求は1本にまとまる
697
+ while (saveInFlightRef.current) {
698
+ await saveInFlightRef.current.catch(() => undefined);
699
+ }
700
+ // 保存し終えた分を「変更なし」の基準にするため、送る直前の姿を控える。
701
+ // 保存中に加えられた編集は基準とずれたまま残るので、次のデバウンスで拾われる
702
+ const baseline = saveStateRef.current.html;
703
+ const savingContentId = saveStateRef.current.contentId;
704
+
705
+ const task = (async () => {
706
+ if (isMultiPageCanvas && parentId && contentId) {
707
+ const api = await createAuthApi(getIdToken);
708
+ await api.patch(`/api/websites/${parentId}/pages/${contentId}`, {
709
+ html: htmlToSave,
710
+ layoutMode,
711
+ });
712
+ toast.success('ページを保存しました');
713
+ return;
714
+ }
715
+ await onSave(htmlToSave, options);
716
+ })();
717
+ saveInFlightRef.current = task.then(
718
+ () => undefined,
719
+ () => undefined,
720
+ );
721
+ setSaving(true);
722
+ try {
723
+ await task;
724
+ // 保存中にページが変わっていたら、基準の付け替えは別ページに効いてしまうので見送る
725
+ if (saveStateRef.current.contentId === savingContentId) {
726
+ savedPayloadRef.current = htmlToSave;
727
+ setOriginalHtml(baseline);
728
+ }
729
+ setAutoSaveFailed(false);
730
+ } catch (e) {
731
+ setAutoSaveFailed(true);
732
+ throw e;
733
+ } finally {
734
+ saveInFlightRef.current = null;
735
+ setSaving(false);
736
+ }
737
+ }, [isMultiPageCanvas, parentId, contentId, getIdToken, layoutMode, onSave, setOriginalHtml, setSaving]);
738
+
739
+ /**
740
+ * 未保存の変更があれば保存する。戻り値は「保存できたか」。
741
+ *
742
+ * hasChanges だけでなく canUndo も見るのは、hasChanges が
743
+ * 「今のHTML ≠ 開いた直後のHTML」の文字列比較で、エディタ側の一時的な差分でも
744
+ * 立ちうるため。canUndo は実際の編集(履歴に積まれた操作)が無いと立たないので、
745
+ * 「開いただけのスライドを勝手に上書きする」事故を防げる。
746
+ */
747
+ const saveIfDirty = useCallback(async (): Promise<boolean> => {
748
+ const state = saveStateRef.current;
749
+ if (!state.hasChanges || !state.canUndo) return true;
750
+ const iframeDoc = getIframeDoc();
751
+ const payload = iframeDoc ? getCleanHtml(iframeDoc) : state.html;
752
+ if (payload === savedPayloadRef.current) {
753
+ // 保存済みと1文字も違わない = エディタ側の見た目の差でしかない。
754
+ // 基準だけ合わせて終える(ここで送ると同じ書き戻しを延々と繰り返してしまう)
755
+ setOriginalHtml(state.html);
756
+ return true;
757
+ }
758
+ try {
759
+ await effectiveSave(payload, { auto: true });
760
+ return true;
761
+ } catch (e) {
762
+ console.error('[FrontendVisualEditor] 自動保存に失敗:', e);
763
+ return false;
764
+ }
765
+ }, [effectiveSave, getIframeDoc, setOriginalHtml]);
766
+
767
+ // saveIfDirty は onSave(毎レンダー作り直される)に依存するため、
768
+ // そのまま効果の依存に入れるとレンダーのたびにデバウンスが延びて永久に発火しない。
769
+ // 呼ぶ側は常に最新を ref 越しに掴む
770
+ const saveIfDirtyRef = useRef(saveIfDirty);
771
+ useEffect(() => {
772
+ saveIfDirtyRef.current = saveIfDirty;
773
+ }, [saveIfDirty]);
774
+
775
+ /**
776
+ * 変更が止まってから AUTO_SAVE_DELAY_MS で保存する。
777
+ * html が変わるたびにこの効果が張り直されてタイマーが延びる = 連続編集中は走らない。
778
+ * テキスト編集中(contenteditable)は保存がDOMを読み直してカーソル/選択を壊しうるので、
779
+ * 編集が終わる(blurで contenteditable が外れる)まで待ってから発火させる。
780
+ */
781
+ useEffect(() => {
782
+ if (!hasChanges || !canUndo) return;
783
+ let timer: ReturnType<typeof setTimeout>;
784
+ const tick = () => {
785
+ const iframeDoc = getIframeDoc();
786
+ if (iframeDoc?.querySelector('[contenteditable="true"]')) {
787
+ timer = setTimeout(tick, AUTO_SAVE_DELAY_MS);
788
+ return;
789
+ }
790
+ void saveIfDirtyRef.current();
791
+ };
792
+ timer = setTimeout(tick, AUTO_SAVE_DELAY_MS);
793
+ return () => clearTimeout(timer);
794
+ }, [hasChanges, canUndo, html, getIframeDoc]);
795
+
796
+ /** ページ切替(サムネイル・新しいスライド)の直前に、殻から保存を呼べるようにする */
797
+ useEffect(() => registerAutoSaveFlush(() => saveIfDirtyRef.current()), []);
798
+
799
+ /** ページが変わったら「保存済みの本文」の記憶も切り替える */
800
+ useEffect(() => {
801
+ savedPayloadRef.current = null;
802
+ }, [contentId]);
803
+
804
+ /** 保存しきれていない状態でタブを閉じられたときだけ、ブラウザ既定の離脱警告を出す */
805
+ useEffect(() => {
806
+ if (!hasChanges) return;
807
+ const onBeforeUnload = (e: BeforeUnloadEvent) => {
808
+ e.preventDefault();
809
+ e.returnValue = '';
810
+ };
811
+ window.addEventListener('beforeunload', onBeforeUnload);
812
+ return () => window.removeEventListener('beforeunload', onBeforeUnload);
813
+ }, [hasChanges]);
814
+
815
+ /** 閉じる/一覧へ戻る。未保存は黙って保存してから離れ、失敗したときだけ確認する */
816
+ const handleClose = useCallback(() => {
817
+ void saveIfDirty().then((ok) => {
818
+ if (ok || window.confirm('保存に失敗しました。変更を破棄して閉じますか?')) onClose();
819
+ });
820
+ }, [saveIfDirty, onClose]);
821
+
822
+ /** ヘッダー(Figma風)とタイトルバー(PowerPoint風)に出す保存状態 */
823
+ const saveStatus: SaveStatus = autoSaveFailed
824
+ ? 'error'
825
+ : saving
826
+ ? 'saving'
827
+ : hasChanges
828
+ ? 'dirty'
829
+ : 'saved';
830
+
831
+ // HTML保存処理
832
+ const handleSaveHtml = useCallback((newHtml: string) => {
833
+ const { elementId } = htmlEditorState;
834
+ if (!elementId) return;
835
+
836
+ const iframeDoc = getIframeDoc();
837
+ if (!iframeDoc) return;
838
+
839
+ const element = iframeDoc.querySelector(`[data-element-id="${elementId}"]`);
840
+ if (element) {
841
+ // outerHTMLを置換
842
+ element.outerHTML = newHtml;
843
+
844
+ // 変更通知
845
+ window.postMessage({
846
+ type: 'SLIDE_CONTENT_CHANGED',
847
+ html: getArtboardContent(iframeDoc),
848
+ }, '*');
849
+
850
+ // 更新された要素を再選択(IDが変わっていなければ)
851
+ // ※IDごと書き換えられると選択が外れるが、基本は外枠のIDを維持することを期待
852
+ const newElement = iframeDoc.querySelector(`[data-element-id="${elementId}"]`) as HTMLElement;
853
+ if (newElement) {
854
+ // useElementActions のロジックを参考に要素情報送信
855
+ // ここでは簡易的に選択解除扱いになるのを避けるため何もしないか、
856
+ // 必要なら再選択ロジックを入れる
857
+ }
858
+ }
859
+ }, [htmlEditorState, getIframeDoc]);
860
+
861
+ // AIプロンプトポップオーバーを開く
862
+ const openAiPrompt = useCallback(() => {
863
+ // コンテキストメニューを閉じる
864
+ setContextMenuPosition(null);
865
+
866
+ // 選択要素の位置を基準にポップオーバーを表示
867
+ if (selectedElement) {
868
+ const iframeDoc = getIframeDoc();
869
+ const iframe = iframeRef.current;
870
+ if (iframeDoc && iframe) {
871
+ const element = iframeDoc.querySelector(
872
+ `[data-element-id="${selectedElement.id}"]`
873
+ ) as HTMLElement | null;
874
+
875
+ if (element) {
876
+ const rect = element.getBoundingClientRect();
877
+ const iframeRect = iframe.getBoundingClientRect();
878
+
879
+ // ポップオーバーを要素の右側に表示
880
+ setAiPromptPosition({
881
+ x: iframeRect.left + rect.right + 10,
882
+ y: iframeRect.top + rect.top,
883
+ });
884
+ return;
885
+ }
886
+ }
887
+ }
888
+
889
+ // フォールバック: 画面中央に表示
890
+ setAiPromptPosition({
891
+ x: window.innerWidth / 2 - 160,
892
+ y: window.innerHeight / 2 - 100,
893
+ });
894
+ }, [selectedElement, getIframeDoc, iframeRef]);
895
+
896
+ // AIプロンプトポップオーバーを閉じる
897
+ const closeAiPrompt = useCallback(() => {
898
+ overlayClosedAtRef.current = performance.now();
899
+ setAiPromptPosition(null);
900
+ // ショートカットが引き続き機能するようフォーカスを復元
901
+ requestAnimationFrame(() => {
902
+ restoreFocus();
903
+ });
904
+ }, [restoreFocus]);
905
+
906
+ // AI生成ハンドラ
907
+ const handleAiGenerate = useCallback(async (
908
+ prompt: string,
909
+ attachedFiles?: Array<{ name: string; type: string; size: number; data: string }>,
910
+ engine?: 'codex' | 'claude'
911
+ ) => {
912
+ await generateAndReplace(prompt, attachedFiles, engine);
913
+ }, [generateAndReplace]);
914
+
915
+ /**
916
+ * iframe 内の contextmenu を拾って、右クリックされたセルを控える。
917
+ * useContextMenuHandler の登録とは別口だが、同じイベントで両方の setState が
918
+ * 走るので、メニューが開くときには対象セルも揃っている。
919
+ */
920
+ useEffect(() => {
921
+ const iframe = iframeRef.current;
922
+ let attachedDoc: Document | null = null;
923
+ const onContextMenu = (e: Event) => {
924
+ setContextCell(findTableCell(e.target));
925
+ };
926
+ const attach = () => {
927
+ const doc = iframe?.contentDocument;
928
+ if (!doc || doc === attachedDoc) return; // 同じ文書に二重登録しない
929
+ attachedDoc?.removeEventListener('contextmenu', onContextMenu);
930
+ attachedDoc = doc;
931
+ doc.addEventListener('contextmenu', onContextMenu);
932
+ };
933
+ attach();
934
+ iframe?.addEventListener('load', attach);
935
+ return () => {
936
+ attachedDoc?.removeEventListener('contextmenu', onContextMenu);
937
+ iframe?.removeEventListener('load', attach);
938
+ };
939
+ }, [iframeRef]);
940
+
941
+ /** 表を編集したあとの後始末(履歴へ積み、行数が変わった分だけ選択枠を描き直す) */
942
+ const afterTableEdit = useCallback(() => {
943
+ notifyIframeChange();
944
+ const doc = getIframeDoc();
945
+ const el = selectedElement?.id ? doc?.querySelector<HTMLElement>(`[data-element-id="${selectedElement.id}"]`) : null;
946
+ if (doc && el) requestAnimationFrame(() => updateSelectionBox(doc, el));
947
+ }, [notifyIframeChange, getIframeDoc, selectedElement?.id]);
948
+
949
+ /** 表の行・列操作。対象は右クリックしたセル */
950
+ const tableActions = useMemo(() => {
951
+ const cell = contextCell;
952
+ if (!cell) return null;
953
+ const run = (fn: () => unknown) => () => {
954
+ if (fn()) afterTableEdit();
955
+ };
956
+ const table = cell.closest('table');
957
+ const rowCount = table?.rows.length ?? 0;
958
+ const colCount = table?.rows[0]?.cells.length ?? 0;
959
+ return {
960
+ insertRowAbove: run(() => insertRow(cell, 'above')),
961
+ insertRowBelow: run(() => insertRow(cell, 'below')),
962
+ insertColumnLeft: run(() => insertColumn(cell, 'left')),
963
+ insertColumnRight: run(() => insertColumn(cell, 'right')),
964
+ // 最後の1行/1列は消させない(空の表を作らない)
965
+ deleteRow: rowCount > 1 ? run(() => deleteRow(cell)) : undefined,
966
+ deleteColumn: colCount > 1 ? run(() => deleteColumn(cell)) : undefined,
967
+ };
968
+ }, [contextCell, afterTableEdit]);
969
+
970
+ /**
971
+ * リンク(href)の編集。
972
+ * 選択が <a>(またはその中)なら href をそのまま書き換え、
973
+ * それ以外の要素には data-href を持たせる(保存時に消えない属性)。
974
+ */
975
+ const handleEditLink = useCallback(() => {
976
+ const doc = getIframeDoc();
977
+ const el = selectedElement?.id ? doc?.querySelector<HTMLElement>(`[data-element-id="${selectedElement.id}"]`) : null;
978
+ if (!el) return;
979
+ const anchor = el.tagName === 'A' ? (el as HTMLAnchorElement) : el.closest('a');
980
+ const current = anchor?.getAttribute('href') ?? el.getAttribute('data-href') ?? '';
981
+ const next = window.prompt('リンク先URL(空にすると解除)', current);
982
+ if (next === null) return; // キャンセル
983
+ const url = next.trim();
984
+ if (anchor) {
985
+ if (url) anchor.setAttribute('href', url);
986
+ else anchor.removeAttribute('href');
987
+ } else if (url) {
988
+ el.setAttribute('data-href', url);
989
+ } else {
990
+ el.removeAttribute('data-href');
991
+ }
992
+ notifyIframeChange();
993
+ }, [getIframeDoc, selectedElement?.id, notifyIframeChange]);
994
+
995
+ // 右クリックハンドラ
996
+ const handleContextMenu = useCallback((e: React.MouseEvent) => {
997
+ e.preventDefault();
998
+ e.stopPropagation();
999
+
1000
+ // コンテキストメニューを表示
1001
+ setContextMenuPosition({
1002
+ x: e.clientX,
1003
+ y: e.clientY,
1004
+ });
1005
+ }, []);
1006
+
1007
+ // iframe内からのコンテキストメニュー/クリック/ドラッグ&ドロップ/ペーストイベントを受信
1008
+ useEffect(() => {
1009
+ const handleMessage = async (event: MessageEvent) => {
1010
+ if (event.data?.type === 'IFRAME_CONTEXT_MENU') {
1011
+ setContextMenuPosition({
1012
+ x: event.data.clientX,
1013
+ y: event.data.clientY,
1014
+ });
1015
+ } else if (event.data?.type === 'IFRAME_CLICK') {
1016
+ // iframe内のクリックでコンテキストメニューを閉じる
1017
+ setContextMenuPosition(null);
1018
+ } else if (event.data?.type === 'IFRAME_DRAG_ENTER') {
1019
+ // iframe内にドラッグが入った
1020
+ setIsDraggingOver(true);
1021
+ } else if (event.data?.type === 'IFRAME_DRAG_LEAVE') {
1022
+ // iframe内からドラッグが出た
1023
+ setIsDraggingOver(false);
1024
+ } else if (event.data?.type === 'IFRAME_DROP_FILE_DATA') {
1025
+ // iframe内にドロップされたファイルデータを処理
1026
+ setIsDraggingOver(false);
1027
+ const { dataUrl, fileName, fileType, x, y } = event.data;
1028
+ if (dataUrl && fileType?.startsWith('image/')) {
1029
+ // DataURLからFileオブジェクトを作成
1030
+ try {
1031
+ const response = await fetch(dataUrl);
1032
+ const blob = await response.blob();
1033
+ const file = new File([blob], fileName || 'dropped-image.png', { type: fileType });
1034
+ await uploadFromFile(file, { x: x || 100, y: y || 100 });
1035
+ } catch (error) {
1036
+ console.error('[FrontendVisualEditor] Failed to process dropped file:', error);
1037
+ }
1038
+ }
1039
+ } else if (event.data?.type === 'IFRAME_PASTE_IMAGE') {
1040
+ // iframe内でペーストされた画像を処理
1041
+ console.log('[FrontendVisualEditor] Received IFRAME_PASTE_IMAGE message', event.data);
1042
+ const { dataUrl, fileName, fileType } = event.data;
1043
+ if (dataUrl && fileType?.startsWith('image/')) {
1044
+ try {
1045
+ const response = await fetch(dataUrl);
1046
+ const blob = await response.blob();
1047
+ const file = new File([blob], fileName || 'pasted-image.png', { type: fileType });
1048
+ await uploadFromFile(file, { x: 100, y: 100 });
1049
+ } catch (error) {
1050
+ console.error('[FrontendVisualEditor] Failed to process pasted image:', error);
1051
+ }
1052
+ }
1053
+ } else if (event.data?.type === 'IFRAME_COMPONENT_DROP') {
1054
+ // iframe内にドロップされたコンポーネントを処理
1055
+ setIsDraggingOver(false);
1056
+ const { componentData, x, y } = event.data;
1057
+ console.log('[FrontendVisualEditor] Received IFRAME_COMPONENT_DROP:', componentData, 'at', x, y);
1058
+ if (componentData && contentId) {
1059
+ try {
1060
+ const { componentId } = JSON.parse(componentData);
1061
+ console.log('[FrontendVisualEditor] Parsed componentId:', componentId);
1062
+ if (componentId) {
1063
+ // インスタンスを作成
1064
+ const instance = createInstance(componentId, undefined, contentId);
1065
+ console.log('[FrontendVisualEditor] Created instance:', instance);
1066
+ if (instance) {
1067
+ // マスターコンポーネントを取得
1068
+ const master = getMasterComponent(componentId);
1069
+ console.log('[FrontendVisualEditor] Master component:', master);
1070
+ if (master && iframeRef.current) {
1071
+ const iframeDoc = iframeRef.current.contentDocument;
1072
+ if (iframeDoc) {
1073
+ // インスタンスをHTMLにレンダリング
1074
+ const { renderInstance } = await import('./utils/component-renderer');
1075
+ const element = renderInstance(master, instance, iframeDoc);
1076
+
1077
+ // 位置を設定
1078
+ element.style.position = 'absolute';
1079
+ element.style.left = `${x || 100}px`;
1080
+ element.style.top = `${y || 100}px`;
1081
+
1082
+ // [移植時の修正] スライドの中に追加する(外に出ると座標系がずれる)
1083
+ findInsertionParent(iframeDoc).appendChild(element);
1084
+
1085
+ // 履歴を更新
1086
+ notifyIframeChange(true);
1087
+
1088
+ // 要素を選択状態にする
1089
+ const { extractElementInfo } = await import('./utils/style-utils');
1090
+ const elementInfo = extractElementInfo(element, iframeDoc);
1091
+ if (elementInfo) {
1092
+ setSelectedElement(elementInfo);
1093
+ setSelectedElementIds([instance.domElementId]);
1094
+ element.classList.add('selected');
1095
+ }
1096
+
1097
+ console.log('[FrontendVisualEditor] Component instance created via iframe message:', instance.id);
1098
+ }
1099
+ } else {
1100
+ console.error('[FrontendVisualEditor] Master component not found for id:', componentId);
1101
+ }
1102
+ } else {
1103
+ console.error('[FrontendVisualEditor] Failed to create instance');
1104
+ }
1105
+ }
1106
+ } catch (error) {
1107
+ console.error('[FrontendVisualEditor] Failed to handle iframe component drop:', error);
1108
+ }
1109
+ }
1110
+ }
1111
+ };
1112
+
1113
+ window.addEventListener('message', handleMessage);
1114
+ return () => window.removeEventListener('message', handleMessage);
1115
+ }, [uploadFromFile, contentId, createInstance, getMasterComponent, iframeRef, notifyIframeChange, setSelectedElement, setSelectedElementIds]);
1116
+
1117
+ // ドラッグ&ドロップハンドラ
1118
+ const handleDragEnter = useCallback((e: React.DragEvent) => {
1119
+ e.preventDefault();
1120
+ e.stopPropagation();
1121
+
1122
+ dragCounterRef.current++;
1123
+
1124
+ // コンポーネントまたはファイルがドラッグされているか確認
1125
+ if (e.dataTransfer.types.includes('application/x-editor-component') ||
1126
+ e.dataTransfer.types.includes('Files')) {
1127
+ setIsDraggingOver(true);
1128
+ e.dataTransfer.dropEffect = 'copy';
1129
+ }
1130
+ }, []);
1131
+
1132
+ const handleDragOver = useCallback((e: React.DragEvent) => {
1133
+ e.preventDefault();
1134
+ e.stopPropagation();
1135
+
1136
+ // コンポーネントまたはファイルがドラッグされているか確認
1137
+ if (e.dataTransfer.types.includes('application/x-editor-component') ||
1138
+ e.dataTransfer.types.includes('Files')) {
1139
+ e.dataTransfer.dropEffect = 'copy';
1140
+ }
1141
+ }, []);
1142
+
1143
+ const handleDragLeave = useCallback((e: React.DragEvent) => {
1144
+ e.preventDefault();
1145
+ e.stopPropagation();
1146
+
1147
+ dragCounterRef.current--;
1148
+
1149
+ // カウンターが0になったら本当に外に出た
1150
+ if (dragCounterRef.current === 0) {
1151
+ setIsDraggingOver(false);
1152
+ }
1153
+ }, []);
1154
+
1155
+ const handleDrop = useCallback(async (e: React.DragEvent) => {
1156
+ e.preventDefault();
1157
+ e.stopPropagation();
1158
+
1159
+ // ドラッグ状態をリセット
1160
+ dragCounterRef.current = 0;
1161
+ setIsDraggingOver(false);
1162
+
1163
+ // コンポーネントのドロップをチェック
1164
+ const componentData = e.dataTransfer.getData('application/x-editor-component');
1165
+ console.log('[FrontendVisualEditor] Drop event - componentData:', componentData);
1166
+ if (componentData) {
1167
+ try {
1168
+ const { componentId, variantId } = JSON.parse(componentData);
1169
+ console.log('[FrontendVisualEditor] Parsed componentId:', componentId, 'variantId:', variantId, 'contentId:', contentId, 'iframeRef:', !!iframeRef.current);
1170
+ if (componentId && iframeRef.current && contentId) {
1171
+ const iframeDoc = iframeRef.current.contentDocument;
1172
+ if (iframeDoc) {
1173
+ // iframe内でのドロップ位置を計算
1174
+ // [移植時の修正] スライドは iframe 内でラッパーに包まれ、さらにズーム倍率で
1175
+ // 縮小表示されている。画面座標をそのまま使うとスライド左上を原点にできないので、
1176
+ // スライドのルート要素の実測矩形を基準に、スライド内座標へ換算する。
1177
+ const iframeRect = iframeRef.current.getBoundingClientRect();
1178
+ const slideRoot = findSlideRoot(iframeDoc);
1179
+ let x: number;
1180
+ let y: number;
1181
+ if (slideRoot) {
1182
+ const rootRect = slideRoot.getBoundingClientRect();
1183
+ const rootScale = rootRect.width / (slideRoot.offsetWidth || rootRect.width) || 1;
1184
+ x = (e.clientX - iframeRect.left - rootRect.left) / rootScale;
1185
+ y = (e.clientY - iframeRect.top - rootRect.top) / rootScale;
1186
+ } else {
1187
+ const dropScale = zoom || 1;
1188
+ x = (e.clientX - iframeRect.left) / dropScale;
1189
+ y = (e.clientY - iframeRect.top) / dropScale;
1190
+ }
1191
+
1192
+ // インスタンスを作成(バリアントIDが指定されていれば使用)
1193
+ // Pass position directly to createInstance to avoid React state batching race condition
1194
+ console.log('[FrontendVisualEditor] Creating instance for component:', componentId, 'variant:', variantId);
1195
+ const instance = createInstance(
1196
+ componentId,
1197
+ variantId,
1198
+ contentId,
1199
+ undefined, // providedMaster
1200
+ undefined, // customDomElementId
1201
+ undefined, // initialOverrides
1202
+ undefined, // initialPropertyValues
1203
+ { x, y } // initialPosition
1204
+ );
1205
+ console.log('[FrontendVisualEditor] Created instance:', instance);
1206
+ if (instance) {
1207
+ // マスターコンポーネントを取得
1208
+ const master = getMasterComponent(componentId);
1209
+ console.log('[FrontendVisualEditor] Master component:', master);
1210
+ if (master) {
1211
+ // インスタンスをHTMLにレンダリング (position will be applied from instance.position)
1212
+ const { renderInstance } = await import('./utils/component-renderer');
1213
+ const element = renderInstance(master, instance, iframeDoc);
1214
+
1215
+ // キャンバスに追加
1216
+ // [移植時の修正] スライドは 1920×1080 のルート要素の中で絶対配置されている。
1217
+ // body に足すとスライドの外に出てしまうので、ルートを追加先にする。
1218
+ (slideRoot ?? iframeDoc.body).appendChild(element);
1219
+
1220
+ // 履歴を更新
1221
+ notifyIframeChange(true);
1222
+
1223
+ // 要素を選択状態にする using proper element info extraction
1224
+ const { extractElementInfo } = await import('./utils/style-utils');
1225
+ const elementInfo = extractElementInfo(element, iframeDoc);
1226
+ if (elementInfo) {
1227
+ setSelectedElement(elementInfo);
1228
+ setSelectedElementIds([instance.domElementId]);
1229
+ element.classList.add('selected');
1230
+ }
1231
+
1232
+ console.log('[FrontendVisualEditor] Component instance created:', instance.id);
1233
+ } else {
1234
+ console.error('[FrontendVisualEditor] Master component not found for id:', componentId);
1235
+ }
1236
+ } else {
1237
+ console.error('[FrontendVisualEditor] Failed to create instance');
1238
+ }
1239
+ }
1240
+ } else {
1241
+ console.error('[FrontendVisualEditor] Missing required: componentId=', componentId, 'iframeRef=', !!iframeRef.current, 'contentId=', contentId);
1242
+ }
1243
+ } catch (error) {
1244
+ console.error('[FrontendVisualEditor] Failed to handle component drop:', error);
1245
+ }
1246
+ return;
1247
+ }
1248
+
1249
+ // ファイルのドロップ
1250
+ const files = e.dataTransfer.files;
1251
+ if (files.length > 0) {
1252
+ // ドロップ位置を計算
1253
+ const container = containerRef.current;
1254
+ if (container) {
1255
+ const rect = container.getBoundingClientRect();
1256
+ const x = e.clientX - rect.left;
1257
+ const y = e.clientY - rect.top;
1258
+
1259
+ // 画像ファイルのみフィルタ
1260
+ const imageFiles = Array.from(files).filter(file =>
1261
+ file.type.startsWith('image/')
1262
+ );
1263
+
1264
+ if (imageFiles.length > 0) {
1265
+ await uploadFromFiles(imageFiles, { x, y });
1266
+ }
1267
+ }
1268
+ }
1269
+ }, [containerRef, uploadFromFiles, createInstance, getMasterComponent, setSelectedElement, contentId, pushHistory, iframeRef, zoom]);
1270
+
1271
+ // クリップボードからのペースト処理(画像、HTML、Excel、Word、SVG)
1272
+ // 同じpasteイベントを二度処理しないための記録。
1273
+ // iframeとメインドキュメントの両方にリスナーが載る構造なので、
1274
+ // 登録が重なると1回の貼り付けで画像が2枚入ってしまう。
1275
+ const handledPasteRef = useRef<WeakSet<Event>>(new WeakSet());
1276
+
1277
+ const handlePaste = useCallback(async (e: Event) => {
1278
+ if (handledPasteRef.current.has(e)) return;
1279
+ handledPasteRef.current.add(e);
1280
+ console.log('[FrontendVisualEditor] handlePaste triggered');
1281
+ const clipboardEvent = e as ClipboardEvent;
1282
+
1283
+ // テキスト編集中は通常のペーストを許可
1284
+ const activeElement = document.activeElement;
1285
+ if (activeElement && (
1286
+ activeElement.tagName === 'INPUT' ||
1287
+ activeElement.tagName === 'TEXTAREA' ||
1288
+ (activeElement as HTMLElement).isContentEditable
1289
+ )) {
1290
+ return;
1291
+ }
1292
+
1293
+ // iframe内のテキスト編集中も通常のペーストを許可
1294
+ const iframeDoc = getIframeDoc();
1295
+ if (iframeDoc) {
1296
+ const iframeActiveElement = iframeDoc.activeElement;
1297
+ if (iframeActiveElement && (
1298
+ iframeActiveElement.tagName === 'INPUT' ||
1299
+ iframeActiveElement.tagName === 'TEXTAREA' ||
1300
+ (iframeActiveElement as HTMLElement).isContentEditable
1301
+ )) {
1302
+ return;
1303
+ }
1304
+ }
1305
+
1306
+ // クリップボード処理
1307
+ if (clipboardEvent.clipboardData) {
1308
+ // このエディタがコピーした要素(スライド跨ぎ含む)。
1309
+ // ここで確定させて return するので、後段のリッチペーストと二重貼りにならない
1310
+ const ownHtml = clipboardEvent.clipboardData.getData('text/html');
1311
+ const ownPayload = ownHtml && ownHtml.match(/data-gg-payload="([^"]+)"/);
1312
+ if (ownPayload) {
1313
+ e.preventDefault();
1314
+ e.stopPropagation();
1315
+ try {
1316
+ const list = JSON.parse(decodeURIComponent(escape(atob(ownPayload[1]))));
1317
+ pasteSerializedElements(list);
1318
+ } catch (err) {
1319
+ console.warn('[FrontendVisualEditor] ggペイロードの復元に失敗:', err);
1320
+ }
1321
+ return;
1322
+ }
1323
+ // OSクリップボードへ書けない環境のフォールバック(内部クリップボードが新しい場合のみ)
1324
+ if (pasteFromInternalIfFresh()) {
1325
+ e.preventDefault();
1326
+ e.stopPropagation();
1327
+ return;
1328
+ }
1329
+
1330
+ const items = Array.from(clipboardEvent.clipboardData.items);
1331
+ console.log('[FrontendVisualEditor] Clipboard items:', items.map(i => ({ type: i.type, kind: i.kind })));
1332
+
1333
+ // デバッグ: クリップボードの内容を詳細ログ
1334
+ debugClipboard(clipboardEvent.clipboardData);
1335
+
1336
+ // コンテンツタイプを最初に判定(Excel/Wordは画像より優先)
1337
+ const contentType = detectContentType(clipboardEvent.clipboardData);
1338
+ console.log('[FrontendVisualEditor] Detected content type:', contentType);
1339
+
1340
+ // 画像の場合(純粋な画像ペーストのみ、Excel/Wordは除外済み)
1341
+ if (contentType === 'image') {
1342
+ e.preventDefault();
1343
+ e.stopPropagation();
1344
+
1345
+ // 「画像の変更 > クリップボードから」の続き: 挿入ではなく選択中の画像を差し替える
1346
+ const replaceTargetId = replaceImageOnPasteRef.current;
1347
+ if (replaceTargetId) {
1348
+ replaceImageOnPasteRef.current = null;
1349
+ const item = Array.from(clipboardEvent.clipboardData.items).find((i) => i.type.startsWith('image/'));
1350
+ const file = item?.getAsFile();
1351
+ if (file) {
1352
+ try {
1353
+ const { uploadEditorImage } = await import('../lib/firebase/storage');
1354
+ const result = await uploadEditorImage(file, { fileName: file.name || 'clipboard.png' });
1355
+ const doc = getIframeDoc();
1356
+ const el = doc?.querySelector<HTMLElement>(`[data-element-id="${replaceTargetId}"]`);
1357
+ if (el && el.tagName === 'IMG') {
1358
+ el.setAttribute('src', result.storageUrl);
1359
+ notifyIframeChange();
1360
+ toast.success('画像を差し替えました');
1361
+ return;
1362
+ }
1363
+ } catch (err) {
1364
+ toast.error(`画像の差し替えに失敗しました: ${String(err).slice(0, 80)}`);
1365
+ return;
1366
+ }
1367
+ }
1368
+ }
1369
+
1370
+ console.log('[FrontendVisualEditor] Calling uploadFromClipboard for image');
1371
+ await uploadFromClipboard(clipboardEvent.clipboardData, { x: 100, y: 100 });
1372
+ return;
1373
+ }
1374
+
1375
+ // リッチコンテンツ(HTML、Excel、Word、SVG、プレーンテキスト)
1376
+ if (canHandleRichPaste(clipboardEvent.clipboardData)) {
1377
+ e.preventDefault();
1378
+ e.stopPropagation();
1379
+ console.log('[FrontendVisualEditor] Calling pasteRichContent for:', contentType);
1380
+ const result = await pasteRichContent(clipboardEvent.clipboardData);
1381
+ console.log('[FrontendVisualEditor] Rich paste result:', result);
1382
+
1383
+ // Figmaなどでデコード失敗した場合は画像としてフォールバック
1384
+ if (result.shouldFallbackToImage) {
1385
+ console.log('[FrontendVisualEditor] Rich paste failed, falling back to image upload');
1386
+ const imageResult = await uploadFromClipboard(clipboardEvent.clipboardData, { x: 100, y: 100 });
1387
+
1388
+ // 画像もない場合はプレーンテキストとしてペースト
1389
+ if (!imageResult) {
1390
+ console.log('[FrontendVisualEditor] No image found, falling back to plain text');
1391
+ const plainText = clipboardEvent.clipboardData.getData('text/plain');
1392
+ if (plainText) {
1393
+ // プレーンテキストとして強制的に処理
1394
+ const textResult = await pasteRichContent(clipboardEvent.clipboardData, 'plain-text');
1395
+ console.log('[FrontendVisualEditor] Plain text paste result:', textResult);
1396
+ }
1397
+ }
1398
+ }
1399
+ return;
1400
+ }
1401
+ } else {
1402
+ console.log('[FrontendVisualEditor] No clipboardData available');
1403
+ }
1404
+ }, [getIframeDoc, uploadFromClipboard, canHandleRichPaste, pasteRichContent, debugClipboard, detectContentType]);
1405
+
1406
+ // メインドキュメントとiframe両方にペーストイベントを登録
1407
+ useEffect(() => {
1408
+ console.log('[FrontendVisualEditor] Registering paste event listeners');
1409
+
1410
+ // デバッグ用:ウィンドウレベルでキャプチャフェーズでリスナー追加
1411
+ const debugPasteHandler = (e: ClipboardEvent) => {
1412
+ console.log('[FrontendVisualEditor] Window paste event captured (capture phase)', e);
1413
+ console.log('[FrontendVisualEditor] Target:', e.target);
1414
+ console.log('[FrontendVisualEditor] ClipboardData:', e.clipboardData);
1415
+ if (e.clipboardData) {
1416
+ console.log('[FrontendVisualEditor] Items:', Array.from(e.clipboardData.items).map(i => ({ type: i.type, kind: i.kind })));
1417
+ }
1418
+ };
1419
+ window.addEventListener('paste', debugPasteHandler, true); // capture phase
1420
+
1421
+ // メインドキュメントにリスナー追加
1422
+ document.addEventListener('paste', handlePaste);
1423
+
1424
+ // iframeにも登録する。
1425
+ // [修正] 以前は「iframeロード時に登録する」effectが別にあり、後片付けで
1426
+ // paste を外していなかったため、再レンダリングのたびにハンドラが積み上がり、
1427
+ // 1回の貼り付けで画像が2枚以上入っていた。登録も解除もこの1か所に集約する。
1428
+ const iframe = iframeRef.current;
1429
+ let attachedDoc: Document | null = null;
1430
+ const attachToIframe = () => {
1431
+ const doc = iframe?.contentDocument;
1432
+ if (!doc || doc === attachedDoc) return; // 同じ文書に二重登録しない
1433
+ attachedDoc = doc;
1434
+ doc.addEventListener('paste', handlePaste);
1435
+ };
1436
+ attachToIframe();
1437
+ iframe?.addEventListener('load', attachToIframe);
1438
+
1439
+ return () => {
1440
+ window.removeEventListener('paste', debugPasteHandler, true);
1441
+ document.removeEventListener('paste', handlePaste);
1442
+ attachedDoc?.removeEventListener('paste', handlePaste);
1443
+ iframe?.contentDocument?.removeEventListener('paste', handlePaste);
1444
+ iframe?.removeEventListener('load', attachToIframe);
1445
+ };
1446
+ }, [handlePaste, getIframeDoc, iframeRef]);
1447
+
1448
+ // カスタムのCtrl+V / Cmd+V ハンドラ
1449
+ // 内部クリップボード(要素のコピー)がある場合はそれを使用
1450
+ // preventDefaultしないので、ネイティブのpasteイベントも発火し、画像ペーストが可能
1451
+ //
1452
+ // [注意] ここだけはディスパッチャに寄せていない。
1453
+ // KEYBOARD_SHORTCUTS には 'meta+v': 'paste' が載っているが、
1454
+ // ペーストは paste イベント(handlePaste)に一本化している。
1455
+ // 以前は Cmd+V の keydown でも内部クリップボードを貼っていたため、
1456
+ // ネイティブの paste イベントと二重発火し「要素と、無関係なOSクリップボードの
1457
+ // 中身が同時に貼られる」バグになっていた。keydown ではペーストしない。
1458
+
1459
+
1460
+ // [移植時の修正] ここにあった window capture のキーボードハンドラと、
1461
+ // iframe から SHORTCUT_* を postMessage で受け取るブリッジは削除した。
1462
+ // 同じキーを useEditorShortcuts / useKeyboardShortcuts とで三重に判定していたため、
1463
+ // Cmd+D で複製が2個できる・フォーカス位置で効くキーが入れ替わる、という状態だった。
1464
+ // キー処理は下部の shortcutCallbacks + useEditorShortcuts の1本に集約している。
1465
+
1466
+ // iframe内の要素を取得するヘルパー
1467
+ const getIframeElement = (elementId: string): HTMLElement | null => {
1468
+ const iframeDoc = getIframeDoc();
1469
+ if (!iframeDoc) return null;
1470
+ return iframeDoc.querySelector(`[data-element-id="${elementId}"]`) as HTMLElement | null;
1471
+ };
1472
+
1473
+ // 選択が1つ以上あるか(ショートカットの有効判定に使う)
1474
+ // selectedElement(情報) と selectedElementIds(ID配列) は片方だけ立つ場面があるため両方見る
1475
+ const hasSelection = !!selectedElement || selectedElementIds.length > 0;
1476
+
1477
+ // 要素がグループ解除可能かどうかをチェック
1478
+ // canUngroup関数で詳細な条件を判定
1479
+ const hasUngroupableChildren = (elementId: string): boolean => {
1480
+ const el = getIframeElement(elementId);
1481
+ if (!el) return false;
1482
+ return canUngroup(el);
1483
+ };
1484
+
1485
+ // 矢印キー移動の結果を受けて、動かせなかった理由を利用者に見せる
1486
+ // (黙って何も起きない、という状態を作らない)
1487
+ // 通知には既存のレイアウトヒント(画面下の帯)を使う。
1488
+ // sonner の Toaster がアプリに設置されていないため toast() は表示されない。
1489
+ // この帯はドラッグで同じ状況になったときにも出るもので、
1490
+ // 「絶対配置モードに切り替える」ボタン付き=解決手段まで提示できる。
1491
+ const reportMoveResult = useCallback((result: MoveElementResult) => {
1492
+ if (result.blocked === 'flow') {
1493
+ setShowLayoutHint(true);
1494
+ }
1495
+ }, [setShowLayoutHint]);
1496
+
1497
+ /**
1498
+ * Escape の統一処理(Figma準拠)。1回のEscapeで1段だけ戻る。
1499
+ * 1. オーバーレイ表示中 → それを閉じるだけ
1500
+ * 2. テキスト編集中 → 編集を抜ける
1501
+ * 3. ドラッグ中 → ドラッグを取り消して元位置へ戻す(useDragResize の cancelDrag)
1502
+ * 4. 単一選択 → 1階層上へ(ダブルクリックで潜った分を戻る)
1503
+ * 5. それ以外 → 選択解除
1504
+ */
1505
+ const handleEscape = useCallback(() => {
1506
+ // オーバーレイ(コンテキストメニュー/AIポップオーバー/各種ダイアログ)が開いているときの
1507
+ // Escape は「それを閉じる」ためのもの。選択状態には手を出さない。
1508
+ // ディスパッチャはバブリングで動くので、先に走る各オーバーレイ自身の
1509
+ // Escape ハンドラが既に閉じ処理を行っている。
1510
+ const justClosedOverlay = performance.now() - overlayClosedAtRef.current < 150;
1511
+ if (
1512
+ contextMenuPosition ||
1513
+ aiPromptPosition ||
1514
+ justClosedOverlay ||
1515
+ document.querySelector('[role="dialog"]')
1516
+ ) {
1517
+ // オーバーレイが開いていた場合は確実に閉じるところまでは面倒を見る
1518
+ // (キャンバスにフォーカスがあると、オーバーレイ自身の document ハンドラには
1519
+ // イベントが届かないため)
1520
+ if (contextMenuPosition) closeContextMenu();
1521
+ if (aiPromptPosition) closeAiPrompt();
1522
+ return;
1523
+ }
1524
+
1525
+ const iframeDoc = getIframeDoc();
1526
+
1527
+ // Scaleツールなど特定のツールを使用中の場合、選択ツールに戻す
1528
+ if (activeTool !== 'select') {
1529
+ setActiveTool('select');
1530
+ }
1531
+
1532
+ if (iframeDoc) {
1533
+ // 1. テキスト編集の終了
1534
+ if (exitTextEditingIn(iframeDoc)) {
1535
+ restoreFocus();
1536
+ return;
1537
+ }
1538
+
1539
+ // 2. ドラッグ中断
1540
+ if (editorCancelDragRef.current?.(iframeDoc)) {
1541
+ restoreFocus();
1542
+ return;
1543
+ }
1544
+
1545
+ // 3. 1階層上へ
1546
+ const selectedEls = iframeDoc.querySelectorAll<HTMLElement>('.selected');
1547
+ const selected = selectedEls.length === 1 ? selectedEls[0] : null;
1548
+ const artboard = iframeDoc.getElementById('artboard');
1549
+ const parent = selected?.parentElement ?? null;
1550
+ const canGoUp =
1551
+ !!selected &&
1552
+ !!parent &&
1553
+ parent !== iframeDoc.body &&
1554
+ parent !== artboard &&
1555
+ parent.parentElement !== artboard; // スライドの面の直下(トップレベル)で止める
1556
+
1557
+ if (canGoUp && parent) {
1558
+ selectedEls.forEach((el) => el.classList.remove('selected'));
1559
+ parent.classList.add('selected');
1560
+ setSelectedElementIds([parent.getAttribute('data-element-id') || '']);
1561
+ updateSelectionBox(iframeDoc, parent);
1562
+ const info = extractElementInfo(parent, iframeDoc);
1563
+ if (info) setSelectedElement(info);
1564
+ restoreFocus();
1565
+ return;
1566
+ }
1567
+
1568
+ // 4. 選択解除
1569
+ iframeDoc.querySelectorAll('.selected').forEach((el) => el.classList.remove('selected'));
1570
+ iframeDoc.querySelectorAll('.selection-box').forEach((box) => box.remove());
1571
+ }
1572
+
1573
+ setSelectedElement(null);
1574
+ setSelectedElementIds([]);
1575
+ restoreFocus();
1576
+ }, [
1577
+ getIframeDoc,
1578
+ activeTool,
1579
+ setActiveTool,
1580
+ setSelectedElement,
1581
+ setSelectedElementIds,
1582
+ restoreFocus,
1583
+ contextMenuPosition,
1584
+ aiPromptPosition,
1585
+ closeContextMenu,
1586
+ closeAiPrompt,
1587
+ ]);
1588
+
1589
+ /**
1590
+ * Cmd/Ctrl + A: 兄弟要素の全選択(無選択ならトップレベルを全選択)
1591
+ * postMessage 経由の迂回をやめ、ここで直接 iframe の DOM を触る
1592
+ */
1593
+ const handleSelectAll = useCallback(() => {
1594
+ const iframeDoc = getIframeDoc();
1595
+ if (!iframeDoc) return;
1596
+ const { ids, elements } = selectAllSiblingsIn(iframeDoc, selectedElementIds);
1597
+ if (ids.length === 0) return;
1598
+ setSelectedElementIds(ids);
1599
+ const info = extractElementInfo(elements[0], iframeDoc);
1600
+ if (info) setSelectedElement(info);
1601
+ }, [getIframeDoc, selectedElementIds, setSelectedElementIds, setSelectedElement]);
1602
+
1603
+ /**
1604
+ * Tab / Shift+Tab: 兄弟要素を順に選び直す(Figma準拠)。
1605
+ * 選択枠はDOM側(selectSiblingIn)で作り直し、React側の選択状態をそれに合わせる。
1606
+ */
1607
+ const handleSelectSibling = useCallback(
1608
+ (direction: 1 | -1) => {
1609
+ const iframeDoc = getIframeDoc();
1610
+ if (!iframeDoc) return;
1611
+ const next = selectSiblingIn(iframeDoc, selectedElementIds, direction);
1612
+ if (!next) return;
1613
+ setSelectedElementIds([next.getAttribute('data-element-id') || '']);
1614
+ const info = extractElementInfo(next, iframeDoc);
1615
+ if (info) setSelectedElement(info);
1616
+ restoreFocus();
1617
+ },
1618
+ [getIframeDoc, selectedElementIds, setSelectedElementIds, setSelectedElement, restoreFocus],
1619
+ );
1620
+
1621
+ /**
1622
+ * Cmd/Ctrl + Shift + G: グループ解除
1623
+ * [移植時の修正] data-is-group を持つ要素だけに限定する。
1624
+ * 以前は通常のコンテナ(div/section等)まで解体してしまい、Cmd+G と非対称だった。
1625
+ */
1626
+ const handleUngroup = useCallback(() => {
1627
+ const targetId = selectedElement?.id ?? selectedElementIds[0];
1628
+ const el = targetId ? getIframeElement(targetId) : null;
1629
+ if (!el) return;
1630
+ if (el.getAttribute('data-is-group') !== 'true') {
1631
+ // グループでないコンテナを黙って解体しない。
1632
+ // 通知は toast を用意しているが Toaster 未設置のため現状は出ない。
1633
+ // 可視の告知は item6 のプロパティパネル側に寄せる想定。
1634
+ console.info('[Shortcut] Cmd+Shift+G: グループ(data-is-group)ではないため解除しません', el);
1635
+ toast.info('グループではありません', {
1636
+ description: 'Cmd+Shift+G で解除できるのは Cmd+G で作ったグループだけです。',
1637
+ id: 'ungroup-not-a-group',
1638
+ });
1639
+ return;
1640
+ }
1641
+ ungroupElements();
1642
+ }, [selectedElement, selectedElementIds, ungroupElements]);
1643
+
1644
+ /**
1645
+ * ズーム操作(Cmd+0 / Cmd+1 / Cmd+2)
1646
+ * ズームの実処理はキャンバス側の責務なので、ここでは EditorContext の
1647
+ * zoom 値の更新と、選択要素へのスクロールだけを行う最小実装にしてある。
1648
+ */
1649
+ const handleZoom = useCallback((kind: 'fit' | 'actual' | 'selection') => {
1650
+ // [移植時の修正] ズームの実処理はキャンバス側(useCanvasControls)に一本化した。
1651
+ // ここで setZoom するだけだと、固定点の扱いがヘッダーのメニューやホイールと
1652
+ // 食い違う(同じ「全体表示」でも押す場所で結果が変わる)ため、
1653
+ // 登録されていればそちらに委譲する。
1654
+ const zoomApi = editorZoomApiRef.current;
1655
+ if (zoomApi) {
1656
+ if (kind === 'actual') zoomApi.actual();
1657
+ else if (kind === 'fit') zoomApi.fit();
1658
+ else zoomApi.selection();
1659
+ return;
1660
+ }
1661
+
1662
+ if (kind === 'actual') {
1663
+ setZoom(100);
1664
+ return;
1665
+ }
1666
+ if (kind === 'fit') {
1667
+ setZoom(fitZoom);
1668
+ return;
1669
+ }
1670
+
1671
+ // selection: 選択要素が画面に収まる倍率にして、その要素を中央へ
1672
+ const iframeDoc = getIframeDoc();
1673
+ const targetId = selectedElement?.id ?? selectedElementIds[0];
1674
+ const el = targetId && iframeDoc ? getIframeElement(targetId) : null;
1675
+ if (!iframeDoc || !el) {
1676
+ setZoom(fitZoom);
1677
+ return;
1678
+ }
1679
+ const container = iframeDoc.getElementById('canvas-container');
1680
+ const artboard = iframeDoc.getElementById('artboard');
1681
+ if (!container || !artboard) return;
1682
+
1683
+ // 現在のスケールで割り戻して、CSSピクセルでの要素サイズを得る
1684
+ const scale = artboard.getBoundingClientRect().width / (artboard.offsetWidth || 1) || 1;
1685
+ const rect = el.getBoundingClientRect();
1686
+ const w = rect.width / scale;
1687
+ const h = rect.height / scale;
1688
+ if (w <= 0 || h <= 0) return;
1689
+
1690
+ const margin = 80;
1691
+ const fit = Math.min(
1692
+ (container.clientWidth - margin) / w,
1693
+ (container.clientHeight - margin) / h
1694
+ );
1695
+ setZoom(Math.max(10, Math.min(400, Math.floor(fit * 100))));
1696
+ // 倍率反映後にスクロール(#canvas-container がスクロール容器)
1697
+ requestAnimationFrame(() => {
1698
+ el.scrollIntoView({ block: 'center', inline: 'center' });
1699
+ });
1700
+ }, [setZoom, fitZoom, getIframeDoc, selectedElement, selectedElementIds]);
1701
+
1702
+ // キーボードショートカット用のコールバックマップ
1703
+ // [重要] キー割り当ては KEYBOARD_SHORTCUTS(src/types/editor.ts) が唯一の定義。
1704
+ // ここには「アクション名 → 実処理」だけを書く。
1705
+ const shortcutCallbacks = {
1706
+ // ツール切り替え
1707
+ select: () => setActiveTool('select'),
1708
+ // 選択解除 (Escape)
1709
+ deselect: handleEscape,
1710
+ scale: () => setActiveTool('scale'),
1711
+ move: () => setActiveTool('move'),
1712
+ rectangle: () => setActiveTool('rectangle'),
1713
+ ellipse: () => setActiveTool('ellipse'),
1714
+ line: () => setActiveTool('line'),
1715
+ arrow: () => setActiveTool('arrow'),
1716
+ pen: () => setActiveTool('pen'),
1717
+ pencil: () => setActiveTool('pencil'),
1718
+ eraser: () => setActiveTool('eraser'),
1719
+ text: () => setActiveTool('text'),
1720
+ frame: () => setActiveTool('frame'),
1721
+ // 編集操作
1722
+ // undo/redo は常に生やす(履歴が無いときは内部で何もしない)。
1723
+ // 条件付きで undefined にするとブラウザ既定の取り消しが走ってしまう。
1724
+ undo: () => {
1725
+ if (canUndo) undo();
1726
+ restoreFocus();
1727
+ },
1728
+ redo: () => {
1729
+ if (canRedo) redo();
1730
+ restoreFocus();
1731
+ },
1732
+ delete: hasSelection ? deleteElement : undefined,
1733
+ duplicate: hasSelection ? duplicateElement : undefined,
1734
+ copy: hasSelection ? copyElements : undefined,
1735
+ cut: hasSelection ? cutElements : undefined,
1736
+ // paste は意図的に未定義のまま(上部のカスタムハンドラで処理し、画像ペーストを許可)
1737
+ // スタイル操作
1738
+ copyStyle: hasSelection ? copyStyle : undefined,
1739
+ pasteStyle: hasSelection ? pasteStyle : undefined,
1740
+ // Figma形式でコピー
1741
+ copyToFigma: hasSelection ? () => { void copyToFigma(); } : undefined,
1742
+ // 選択
1743
+ selectAll: handleSelectAll,
1744
+ // Tab / Shift+Tab で兄弟要素を巡る
1745
+ selectNextSibling: () => handleSelectSibling(1),
1746
+ selectPrevSibling: () => handleSelectSibling(-1),
1747
+ // グループ操作(単一選択でもグループ化できる = Cmd+Shift+G と対称)
1748
+ group: hasSelection ? groupElements : undefined,
1749
+ ungroup: hasSelection ? handleUngroup : undefined,
1750
+ // レイヤー操作
1751
+ bringForward: hasSelection ? bringForward : undefined,
1752
+ sendBackward: hasSelection ? sendBackward : undefined,
1753
+ bringToFront: hasSelection ? bringToFront : undefined,
1754
+ sendToBack: hasSelection ? sendToBack : undefined,
1755
+ // 矢印キーでの移動(1px)
1756
+ moveUp: hasSelection ? () => reportMoveResult(moveUp(1)) : undefined,
1757
+ moveDown: hasSelection ? () => reportMoveResult(moveDown(1)) : undefined,
1758
+ moveLeft: hasSelection ? () => reportMoveResult(moveLeft(1)) : undefined,
1759
+ moveRight: hasSelection ? () => reportMoveResult(moveRight(1)) : undefined,
1760
+ // Shift + 矢印キーでの移動(10px)
1761
+ moveUpLarge: hasSelection ? () => reportMoveResult(moveUp(10)) : undefined,
1762
+ moveDownLarge: hasSelection ? () => reportMoveResult(moveDown(10)) : undefined,
1763
+ moveLeftLarge: hasSelection ? () => reportMoveResult(moveLeft(10)) : undefined,
1764
+ moveRightLarge: hasSelection ? () => reportMoveResult(moveRight(10)) : undefined,
1765
+ // Cmd+矢印でのリサイズ(1px) / Cmd+Shift+矢印(10px)。→↓で広がり←↑で縮む
1766
+ resizeGrowX: hasSelection ? () => resizeElements(1, 0) : undefined,
1767
+ resizeShrinkX: hasSelection ? () => resizeElements(-1, 0) : undefined,
1768
+ resizeGrowY: hasSelection ? () => resizeElements(0, 1) : undefined,
1769
+ resizeShrinkY: hasSelection ? () => resizeElements(0, -1) : undefined,
1770
+ resizeGrowXLarge: hasSelection ? () => resizeElements(10, 0) : undefined,
1771
+ resizeShrinkXLarge: hasSelection ? () => resizeElements(-10, 0) : undefined,
1772
+ resizeGrowYLarge: hasSelection ? () => resizeElements(0, 10) : undefined,
1773
+ resizeShrinkYLarge: hasSelection ? () => resizeElements(0, -10) : undefined,
1774
+ // ズーム
1775
+ zoomFit: () => handleZoom('fit'),
1776
+ zoomActual: () => handleZoom('actual'),
1777
+ zoomSelection: () => handleZoom('selection'),
1778
+ };
1779
+
1780
+ // キーボード処理の唯一の入口。
1781
+ // 親 window と iframe の最新 document の両方へ同じハンドラが張られるので、
1782
+ // フォーカスがキャンバスの内か外かで効くキーが変わらない。
1783
+ useEditorShortcuts(shortcutCallbacks, { iframeRef });
1784
+
1785
+ // ページ切替(htmlプロップ差し替え)で、前のiframeの要素を指したままの
1786
+ // 選択が残ると、リボンや削除が消えた要素を操作してしまう。切替時に空にする
1787
+ useEffect(() => {
1788
+ setSelectedElement(null);
1789
+ setSelectedElementIds([]);
1790
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1791
+ }, [originalHtml]);
1792
+
1793
+ // ================= UIモード(Figma風 / PowerPoint風) =================
1794
+ // ベース(キャンバス・選択・保存・書き戻し)は完全共通で、切り替わるのは殻だけ。
1795
+ // 選択はlocalStorageに記憶する
1796
+ const isPpt = uiMode === 'ppt' && !isMultiPageCanvas;
1797
+
1798
+ return (
1799
+ <div
1800
+ className={cn(
1801
+ // [移植時の修正] gg-editor-skin でビューアと同じ配色に揃える(editor-skin.css)
1802
+ "gg-editor-skin flex flex-col",
1803
+ isMultiPageCanvas ? "absolute inset-0 z-40" : "fixed inset-0 z-50"
1804
+ )}
1805
+ data-frontend-visual-editor="true"
1806
+ style={{ touchAction: 'none', backgroundColor: isPpt ? PPT_PALETTES[pptTheme].chrome : '#1e1e1e' }}
1807
+ >
1808
+ {/* ヘッダー(UIモードで切り替え) */}
1809
+ {isPpt ? (
1810
+ <>
1811
+ {(() => {
1812
+ const pptActions = {
1813
+ undo,
1814
+ redo,
1815
+ canUndo,
1816
+ canRedo,
1817
+ deleteElement: selectedElement ? deleteElement : undefined,
1818
+ duplicateElement: selectedElement ? duplicateElement : undefined,
1819
+ bringToFront: selectedElement ? bringToFront : undefined,
1820
+ bringForward: selectedElement ? bringForward : undefined,
1821
+ sendBackward: selectedElement ? sendBackward : undefined,
1822
+ sendToBack: selectedElement ? sendToBack : undefined,
1823
+ groupElements: selectedElementIds.length > 1 ? groupElements : undefined,
1824
+ ungroupElements:
1825
+ selectedElement?.id && hasUngroupableChildren(selectedElement.id) ? ungroupElements : undefined,
1826
+ openFilePicker: () => openFilePicker({ x: 100, y: 100 }),
1827
+ openMediaLibrary: () => setIsMediaLibraryOpen(true),
1828
+ openComponents: () => setIsComponentPanelOpen(true),
1829
+ openVariables: () => setIsVariablesPanelOpen(true),
1830
+ activeTool,
1831
+ setActiveTool,
1832
+ toggleFormatPane: () => setPptFormatPaneOpen((v) => !v),
1833
+ formatPaneOpen: pptFormatPaneOpen,
1834
+ };
1835
+ const pageNo = Number(currentContentId ?? contentId) || 1;
1836
+ const deckTitle = contentList.find((c) => c.id === (currentContentId ?? contentId))?.title;
1837
+ return (
1838
+ <>
1839
+ <PptTitleBar
1840
+ title={deckTitle}
1841
+ page={pageNo}
1842
+ theme={pptTheme}
1843
+ onToggleTheme={togglePptTheme}
1844
+ search={pptSearch}
1845
+ onSearch={setPptSearch}
1846
+ onSave={effectiveSave}
1847
+ onClose={handleClose}
1848
+ onSwitchUi={() => switchUi('figma')}
1849
+ saveStatus={saveStatus}
1850
+ actions={pptActions}
1851
+ />
1852
+ <PptRibbon
1853
+ actions={pptActions}
1854
+ theme={pptTheme}
1855
+ onToggleTheme={togglePptTheme}
1856
+ onSwitchUi={() => switchUi('figma')}
1857
+ page={pageNo}
1858
+ deckTitle={deckTitle}
1859
+ comments={{
1860
+ open: pptCommentsOpen,
1861
+ toggle: () => setPptCommentsOpen((v) => !v),
1862
+ newComment: () => {
1863
+ setPptCommentsOpen(true);
1864
+ setPptCommentFocus((n) => n + 1);
1865
+ },
1866
+ }}
1867
+ />
1868
+ </>
1869
+ );
1870
+ })()}
1871
+ </>
1872
+ ) : (
1873
+ <EditorHeader
1874
+ comments={{
1875
+ open: pptCommentsOpen,
1876
+ toggle: () => setPptCommentsOpen((v) => !v),
1877
+ page: Number(currentContentId ?? contentId) || 1,
1878
+ }}
1879
+ onSwitchUi={() => switchUi('ppt')}
1880
+ onSave={effectiveSave}
1881
+ onSaveSettings={handleSaveCurrentSettings}
1882
+ onClose={handleClose}
1883
+ saveStatus={saveStatus}
1884
+ isCanvasEditing={isMultiPageCanvas}
1885
+ onImport={() => setIsImportDialogOpen(true)}
1886
+ onCssEdit={handleOpenCssEditor}
1887
+ hasCss={!!importedCss}
1888
+ onJsEdit={handleOpenJsEditor}
1889
+ hasJs={!!importedJs}
1890
+ onPageSettings={handleOpenPageSettings}
1891
+ hasPageSettings={!!(pageSettings.title || pageSettings.description || pageSettings.ogp?.image)}
1892
+ onExport={parentId && contentId ? handleExport : undefined}
1893
+ contextNumber={Number(currentContentId ?? contentId) || undefined}
1894
+ contextTitle={contentList.find((c) => c.id === (currentContentId ?? contentId))?.title}
1895
+ />
1896
+ )}
1897
+
1898
+ {/* メインコンテンツ: 左パネル + キャンバス + 右パネル */}
1899
+ <div className="flex-1 flex overflow-hidden">
1900
+ {/* 左パネル: スライドサムネイル(PowerPoint風) /
1901
+ 上下2段=ページ切替+レイヤー(Figma風。キャンバス編集中はページの並びがキャンバス側にあるので出さない) */}
1902
+ {isPpt ? (
1903
+ <PptThumbnails page={Number(currentContentId ?? contentId) || 1} theme={pptTheme} search={pptSearch} />
1904
+ ) : isMultiPageCanvas ? (
1905
+ <EditorLayerPanel />
1906
+ ) : (
1907
+ <LeftPanel page={Number(currentContentId ?? contentId) || 1} />
1908
+ )}
1909
+
1910
+ {/* コンポーネントパネル(左側、レイヤーパネルの隣) */}
1911
+ {isComponentPanelOpen && (
1912
+ <ComponentPanel
1913
+ onClose={() => {
1914
+ setIsComponentPanelOpen(false);
1915
+ setSelectedMasterComponentId(null);
1916
+ // ショートカットが引き続き機能するようフォーカスを復元
1917
+ requestAnimationFrame(() => {
1918
+ restoreFocus();
1919
+ });
1920
+ }}
1921
+ websiteId={parentId}
1922
+ selectedComponentId={selectedMasterComponentId}
1923
+ onClearSelection={() => setSelectedMasterComponentId(null)}
1924
+ onEditComponent={enterComponentEditMode}
1925
+ />
1926
+ )}
1927
+
1928
+ {/* 中央: キャンバス + ツールバー */}
1929
+ <div
1930
+ ref={canvasAreaRef}
1931
+ className="flex-1 relative overflow-hidden"
1932
+ onDragEnter={handleDragEnter}
1933
+ onDragOver={handleDragOver}
1934
+ onDragLeave={handleDragLeave}
1935
+ onDrop={handleDrop}
1936
+ onContextMenu={handleContextMenu}
1937
+ >
1938
+ {/* キャンバスエリア: マルチページ or シングルページ */}
1939
+ {isMultiPageCanvas ? (
1940
+ <MultiPageCanvasView />
1941
+ ) : (
1942
+ <>
1943
+ <EditorCanvas />
1944
+ {/* ブレイクポイントガイド(webpageモード用) */}
1945
+ <BreakpointGuides
1946
+ containerWidth={canvasAreaSize.width}
1947
+ containerHeight={canvasAreaSize.height}
1948
+ />
1949
+ </>
1950
+ )}
1951
+
1952
+ {/* コンポーネント編集モードバナー */}
1953
+ {isComponentEditMode && editingComponentId && (
1954
+ <div className="absolute top-0 left-0 right-0 z-50 bg-purple-600 text-white px-4 py-2 flex items-center justify-between shadow-lg">
1955
+ <div className="flex items-center gap-2">
1956
+ <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1957
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z" />
1958
+ </svg>
1959
+ <span className="font-medium">
1960
+ コンポーネント編集中: {getMasterComponent(editingComponentId)?.name || 'Unknown'}
1961
+ </span>
1962
+ </div>
1963
+ <div className="flex items-center gap-2">
1964
+ <Button
1965
+ variant="ghost"
1966
+ size="sm"
1967
+ className="text-white hover:bg-purple-700"
1968
+ onClick={() => exitComponentEditMode(false)}
1969
+ >
1970
+ キャンセル
1971
+ </Button>
1972
+ <Button
1973
+ size="sm"
1974
+ className="bg-white text-purple-600 hover:bg-gray-100"
1975
+ onClick={() => exitComponentEditMode(true)}
1976
+ >
1977
+ 保存して終了
1978
+ </Button>
1979
+ </div>
1980
+ </div>
1981
+ )}
1982
+
1983
+ {/* ツールバー(キャンバス上に配置。PowerPoint風ではリボンが担う) */}
1984
+ {!isPpt && (
1985
+ <EditorToolbar
1986
+ activeTool={activeTool}
1987
+ onToolChange={setActiveTool}
1988
+ onUndo={undo}
1989
+ onRedo={redo}
1990
+ canUndo={canUndo}
1991
+ canRedo={canRedo}
1992
+ selectedCount={selectedElementIds.length > 0 ? selectedElementIds.length : (selectedElement ? 1 : 0)}
1993
+ onGroup={selectedElementIds.length > 1 ? groupElements : undefined}
1994
+ onUngroup={selectedElement?.id && hasUngroupableChildren(selectedElement.id) ? ungroupElements : undefined}
1995
+ onDelete={selectedElement ? deleteElement : undefined}
1996
+ onDuplicate={selectedElement ? duplicateElement : undefined}
1997
+ onBringForward={selectedElement ? bringForward : undefined}
1998
+ onSendBackward={selectedElement ? sendBackward : undefined}
1999
+ onBringToFront={selectedElement ? bringToFront : undefined}
2000
+ onSendToBack={selectedElement ? sendToBack : undefined}
2001
+ onImageUpload={() => openFilePicker({ x: 100, y: 100 })}
2002
+ onOpenMediaLibrary={() => setIsMediaLibraryOpen(true)}
2003
+ isMediaReplaceMode={isImageSelected}
2004
+ onAiRegenerate={selectedElement && selectedElementIds.length <= 1 ? openAiPrompt : undefined}
2005
+ onOpenVariables={() => setIsVariablesPanelOpen(true)}
2006
+ hasVariables={hasVariables}
2007
+ onOpenComponents={() => setIsComponentPanelOpen(true)}
2008
+ hasComponents={hasComponents}
2009
+ />
2010
+ )}
2011
+
2012
+ {/* ドラッグオーバーのオーバーレイ */}
2013
+ {isDraggingOver && (
2014
+ <div className="absolute inset-0 bg-[#0d99ff]/20 border-4 border-[#0d99ff] z-50 flex items-center justify-center backdrop-blur-sm pointer-events-none">
2015
+ <div className="text-white font-bold text-xl drop-shadow-md">
2016
+ ここに画像をドロップ
2017
+ </div>
2018
+ </div>
2019
+ )}
2020
+
2021
+ {/* アップロード中オーバーレイ */}
2022
+ {isUploading && (
2023
+ <div className="absolute inset-0 bg-black/50 flex items-center justify-center z-50">
2024
+ <div className="bg-[#1e1e1e] px-6 py-4 rounded-lg shadow-lg flex items-center gap-3">
2025
+ {/* SVGアニメーションはdivラッパーで適用(ハードウェアアクセラレーション対応) */}
2026
+ <div className="animate-spin">
2027
+ <Loader2 className="w-5 h-5 text-blue-500" />
2028
+ </div>
2029
+ <p className="text-white">画像をアップロード中...</p>
2030
+ </div>
2031
+ </div>
2032
+ )}
2033
+
2034
+ {/* アップロードエラー表示 */}
2035
+ {uploadError && (
2036
+ <div className="absolute bottom-20 left-1/2 -translate-x-1/2 bg-red-500/90 text-white px-4 py-2 rounded-lg shadow-lg z-50">
2037
+ {uploadError}
2038
+ </div>
2039
+ )}
2040
+ </div>
2041
+
2042
+ {/* 右パネル: プロパティ(Figma風) / コメント(PowerPoint風) */}
2043
+ {!isPpt && <EditorPropertyPanel />}
2044
+ {!isMultiPageCanvas && (
2045
+ <PptCommentMarkers
2046
+ page={Number(currentContentId ?? contentId) || 1}
2047
+ onOpenThread={(id) => {
2048
+ setPptCommentsOpen(true);
2049
+ setPptActiveThread(id);
2050
+ }}
2051
+ />
2052
+ )}
2053
+ {isPpt && pptFormatPaneOpen && (
2054
+ <PptFormatPane theme={pptTheme} onClose={() => setPptFormatPaneOpen(false)} />
2055
+ )}
2056
+ {!isMultiPageCanvas && pptCommentsOpen && (
2057
+ <PptCommentsPanel
2058
+ page={Number(currentContentId ?? contentId) || 1}
2059
+ theme={isPpt ? pptTheme : 'dark'}
2060
+ onClose={() => setPptCommentsOpen(false)}
2061
+ focusSignal={pptCommentFocus}
2062
+ activeThreadId={pptActiveThread}
2063
+ onActiveThread={setPptActiveThread}
2064
+ />
2065
+ )}
2066
+ </div>
2067
+
2068
+ {/* ノート欄(トークスクリプト)。PowerPoint風・Figma風の両方に出す。
2069
+ Figma風はダーク配色で固定(スキンと馴染む) */}
2070
+ {/* ノート欄は発表原稿。Webページには無い概念なので出さない */}
2071
+ {!isMultiPageCanvas && editorMode !== 'webpage' && (
2072
+ <PptNotes page={Number(currentContentId ?? contentId) || 1} theme={isPpt ? pptTheme : 'dark'} />
2073
+ )}
2074
+
2075
+ {/* フッター(Figma風) / ステータスバー(PowerPoint風) */}
2076
+ {isPpt ? (
2077
+ <PptStatusBar page={Number(currentContentId ?? contentId) || 1} total={contentList.length} theme={pptTheme} />
2078
+ ) : (
2079
+ <EditorFooter />
2080
+ )}
2081
+
2082
+ {/* コンテキストメニュー */}
2083
+ <EditorContextMenu
2084
+ position={contextMenuPosition}
2085
+ onClose={closeContextMenu}
2086
+ hasSelection={!!selectedElement || selectedElementIds.length > 0}
2087
+ selectionCount={selectedElementIds.length > 0 ? selectedElementIds.length : (selectedElement ? 1 : 0)}
2088
+ isGroup={selectedElement?.id ? hasUngroupableChildren(selectedElement.id) : false}
2089
+ hasStyleInClipboard={hasStyleInClipboard()}
2090
+ isComponentInstance={isComponentInstance}
2091
+ hasOverrides={hasOverrides}
2092
+ onCopy={selectedElement ? copyElements : undefined}
2093
+ onCut={selectedElement ? cutElements : undefined}
2094
+ onPaste={pasteElements}
2095
+ onDelete={selectedElement ? deleteElement : undefined}
2096
+ onDuplicate={selectedElement ? duplicateElement : undefined}
2097
+ onReplaceImageFromFile={isImageSelected ? replaceImageFromFile : undefined}
2098
+ onReplaceImageFromLibrary={isImageSelected ? () => setIsMediaLibraryOpen(true) : undefined}
2099
+ onReplaceImageFromClipboard={isImageSelected ? () => void replaceImageFromClipboard() : undefined}
2100
+ onCopyStyle={selectedElement ? copyStyle : undefined}
2101
+ onPasteStyle={selectedElement ? pasteStyle : undefined}
2102
+ onBringForward={selectedElement ? bringForward : undefined}
2103
+ onSendBackward={selectedElement ? sendBackward : undefined}
2104
+ onBringToFront={selectedElement ? bringToFront : undefined}
2105
+ onSendToBack={selectedElement ? sendToBack : undefined}
2106
+ onGroup={selectedElementIds.length > 1 ? groupElements : undefined}
2107
+ onUngroup={selectedElement?.id && hasUngroupableChildren(selectedElement.id) ? ungroupElements : undefined}
2108
+ onInsertRowAbove={tableActions?.insertRowAbove}
2109
+ onInsertRowBelow={tableActions?.insertRowBelow}
2110
+ onInsertColumnLeft={tableActions?.insertColumnLeft}
2111
+ onInsertColumnRight={tableActions?.insertColumnRight}
2112
+ onDeleteRow={tableActions?.deleteRow}
2113
+ onDeleteColumn={tableActions?.deleteColumn}
2114
+ onEditLink={selectedElement ? handleEditLink : undefined}
2115
+ onAiRegenerate={selectedElement && selectedElementIds.length <= 1 ? openAiPrompt : undefined}
2116
+ onEditHtml={selectedElement ? handleEditHtml : undefined}
2117
+ onCreateComponent={handleCreateComponent}
2118
+ onGoToMainComponent={handleGoToMainComponent}
2119
+ onDetachInstance={handleDetachInstance}
2120
+ onResetOverrides={handleResetOverrides}
2121
+ onPushOverridesToMain={handlePushOverridesToMain}
2122
+ />
2123
+
2124
+ {/* AI生成ポップオーバー */}
2125
+ <AiPromptPopover
2126
+ isOpen={!!aiPromptPosition}
2127
+ position={aiPromptPosition || { x: 0, y: 0 }}
2128
+ onClose={closeAiPrompt}
2129
+ onGenerate={handleAiGenerate}
2130
+ isGenerating={isAiGenerating}
2131
+ selectedElementInfo={getSelectedElementInfo()}
2132
+ />
2133
+
2134
+ {/* HTML編集ダイアログ */}
2135
+ <HtmlEditorDialog
2136
+ isOpen={htmlEditorState.isOpen}
2137
+ onClose={() => {
2138
+ setHtmlEditorState({ isOpen: false, elementId: null, initialHtml: '' });
2139
+ // ショートカットが引き続き機能するようフォーカスを復元
2140
+ requestAnimationFrame(() => {
2141
+ restoreFocus();
2142
+ });
2143
+ }}
2144
+ onSave={handleSaveHtml}
2145
+ initialHtml={htmlEditorState.initialHtml}
2146
+ />
2147
+
2148
+ {/* HTMLインポートダイアログ */}
2149
+ <HtmlImportDialog
2150
+ isOpen={isImportDialogOpen}
2151
+ onClose={() => {
2152
+ setIsImportDialogOpen(false);
2153
+ // ショートカットが引き続き機能するようフォーカスを復元
2154
+ requestAnimationFrame(() => {
2155
+ restoreFocus();
2156
+ });
2157
+ }}
2158
+ onImport={handleHtmlImport}
2159
+ presentationId={parentId}
2160
+ slideId={contentId}
2161
+ />
2162
+
2163
+ {/* CSS編集ダイアログ */}
2164
+ <CssEditorDialog
2165
+ isOpen={isCssEditorOpen}
2166
+ onClose={() => {
2167
+ setIsCssEditorOpen(false);
2168
+ // ショートカットが引き続き機能するようフォーカスを復元
2169
+ requestAnimationFrame(() => {
2170
+ restoreFocus();
2171
+ });
2172
+ }}
2173
+ initialCss={importedCss}
2174
+ onSave={handleSaveCss}
2175
+ onClear={handleClearCss}
2176
+ />
2177
+
2178
+ {/* JS編集ダイアログ */}
2179
+ <JsEditorDialog
2180
+ isOpen={isJsEditorOpen}
2181
+ onClose={() => {
2182
+ setIsJsEditorOpen(false);
2183
+ // ショートカットが引き続き機能するようフォーカスを復元
2184
+ requestAnimationFrame(() => {
2185
+ restoreFocus();
2186
+ });
2187
+ }}
2188
+ initialJs={importedJs}
2189
+ onSave={handleSaveJs}
2190
+ onClear={handleClearJs}
2191
+ />
2192
+
2193
+ {/* ページ設定ダイアログ */}
2194
+ <PageSettingsDialog
2195
+ isOpen={isPageSettingsOpen}
2196
+ onClose={() => {
2197
+ setIsPageSettingsOpen(false);
2198
+ // ショートカットが引き続き機能するようフォーカスを復元
2199
+ requestAnimationFrame(() => {
2200
+ restoreFocus();
2201
+ });
2202
+ }}
2203
+ pageSettings={pageSettings}
2204
+ projectSettings={projectSettings}
2205
+ onSave={handleSavePageSettings}
2206
+ onUploadImage={handleUploadOgpImage}
2207
+ />
2208
+
2209
+ {/* メディアライブラリ */}
2210
+ <MediaLibraryDialog
2211
+ isOpen={isMediaLibraryOpen}
2212
+ onClose={() => {
2213
+ setIsMediaLibraryOpen(false);
2214
+ // ショートカットが引き続き機能するようフォーカスを復元
2215
+ requestAnimationFrame(() => {
2216
+ restoreFocus();
2217
+ });
2218
+ }}
2219
+ onSelect={handleMediaSelect}
2220
+ mode={isImageSelected ? 'replace' : 'insert'}
2221
+ currentSrc={selectedImageSrc}
2222
+ />
2223
+
2224
+ {/* CSS変数パネル */}
2225
+ <VariablesPanel
2226
+ isOpen={isVariablesPanelOpen}
2227
+ onClose={() => {
2228
+ setIsVariablesPanelOpen(false);
2229
+ // ショートカットが引き続き機能するようフォーカスを復元
2230
+ requestAnimationFrame(() => {
2231
+ restoreFocus();
2232
+ });
2233
+ }}
2234
+ variables={variables}
2235
+ isSaving={isVariablesSaving}
2236
+ hasChanges={hasVariableChanges}
2237
+ onAddVariable={addVariable}
2238
+ onUpdateVariable={updateVariable}
2239
+ onDeleteVariable={deleteVariable}
2240
+ onSave={saveVariables}
2241
+ />
2242
+
2243
+ {/* コンポーネント作成ダイアログ */}
2244
+ <Dialog open={createComponentDialogOpen} onOpenChange={setCreateComponentDialogOpen}>
2245
+ <DialogContent className="bg-[#2c2c2c] border-[#444444] text-white">
2246
+ <DialogHeader>
2247
+ <DialogTitle>コンポーネントを作成</DialogTitle>
2248
+ </DialogHeader>
2249
+ <div className="space-y-4 py-4">
2250
+ <div className="space-y-2">
2251
+ <label className="text-sm text-gray-400">コンポーネント名</label>
2252
+ <Input
2253
+ value={newComponentName}
2254
+ onChange={(e) => setNewComponentName(e.target.value)}
2255
+ placeholder="例: Primary Button"
2256
+ className="bg-[#383838] border-[#444444] text-white"
2257
+ autoFocus
2258
+ />
2259
+ </div>
2260
+ <div className="space-y-2">
2261
+ <label className="text-sm text-gray-400">カテゴリ</label>
2262
+ <Select value={newComponentCategory} onValueChange={setNewComponentCategory}>
2263
+ <SelectTrigger className="bg-[#383838] border-[#444444] text-white">
2264
+ <SelectValue placeholder="カテゴリを選択" />
2265
+ </SelectTrigger>
2266
+ <SelectContent className="bg-[#2c2c2c] border-[#444444] z-[9999]">
2267
+ {componentLibrary.map((cat) => (
2268
+ <SelectItem key={cat.id} value={cat.id} className="text-white hover:bg-[#444444]">
2269
+ {cat.name}
2270
+ </SelectItem>
2271
+ ))}
2272
+ </SelectContent>
2273
+ </Select>
2274
+ </div>
2275
+ </div>
2276
+ <DialogFooter>
2277
+ <Button variant="ghost" onClick={() => setCreateComponentDialogOpen(false)}>
2278
+ キャンセル
2279
+ </Button>
2280
+ <Button onClick={handleConfirmCreateComponent} disabled={!newComponentName.trim()}>
2281
+ 作成
2282
+ </Button>
2283
+ </DialogFooter>
2284
+ </DialogContent>
2285
+ </Dialog>
2286
+
2287
+ {/* マスターコンポーネントエディタ */}
2288
+ <MasterComponentEditor
2289
+ master={editingMasterComponent}
2290
+ open={isMasterEditorOpen}
2291
+ onOpenChange={(open) => {
2292
+ setIsMasterEditorOpen(open);
2293
+ if (!open) {
2294
+ setEditingMasterComponent(null);
2295
+ }
2296
+ }}
2297
+ onSave={handleSaveMasterComponent}
2298
+ onDelete={handleDeleteMasterComponent}
2299
+ categories={componentLibrary}
2300
+ availableComponents={Array.from(masterComponents.values()).map(c => ({
2301
+ id: c.id,
2302
+ name: c.name,
2303
+ }))}
2304
+ />
2305
+
2306
+ {/* レイアウトモード切替ヒントトースト。
2307
+ Webページでは絶対配置に倒さないので、それを勧めるこの導線も出さない */}
2308
+ {showLayoutHint && editorMode !== 'webpage' && (
2309
+ <div className="fixed bottom-8 left-1/2 -translate-x-1/2 z-[100] animate-in fade-in slide-in-from-bottom-2 duration-300">
2310
+ <div className="bg-black/90 text-white px-4 py-3 rounded-lg shadow-xl flex items-center gap-4">
2311
+ <span className="text-sm">要素の移動は絶対配置モードに切り替えてください</span>
2312
+ <button
2313
+ onClick={() => {
2314
+ // 変換前のHTMLを保存
2315
+ const iframeDoc = getIframeDoc();
2316
+ if (iframeDoc) {
2317
+ setAutoLayoutHtml(getArtboardContent(iframeDoc));
2318
+
2319
+ // 絶対配置に変換(トップレベルでimport済み)
2320
+ const count = convertToAbsolutePositioning(iframeDoc);
2321
+ console.log('[Toast] Converted', count, 'elements to absolute positioning');
2322
+
2323
+ // DOMツリーを再構築
2324
+ const tree = buildDomTree(iframeDoc);
2325
+ setDomTree(tree);
2326
+ setExpandedNodes(new Set(tree.map((n: { id: string }) => n.id)));
2327
+
2328
+ // 変更を通知
2329
+ notifyIframeChange();
2330
+ }
2331
+
2332
+ setLayoutMode('absolute');
2333
+ setShowLayoutHint(false);
2334
+ }}
2335
+ className="bg-[#0d99ff] hover:bg-[#0c8ce9] text-white text-sm font-medium px-3 py-1.5 rounded-md transition-colors"
2336
+ >
2337
+ 絶対配置モードに切り替える
2338
+ </button>
2339
+ <button
2340
+ onClick={() => setShowLayoutHint(false)}
2341
+ className="text-gray-400 hover:text-white text-lg leading-none ml-1"
2342
+ aria-label="閉じる"
2343
+ >
2344
+ ×
2345
+ </button>
2346
+ </div>
2347
+ </div>
2348
+ )}
2349
+ </div>
2350
+ );
2351
+ }
2352
+
2353
+ /**
2354
+ * フロントエンドビジュアルエディタ
2355
+ *
2356
+ * 汎用的なHTML編集エディタとして、スライド、ページ、コンポーネント等で利用可能
2357
+ */
2358
+ export function FrontendVisualEditor({
2359
+ html,
2360
+ editorMode = 'slide', // デフォルトはスライドモード(後方互換性)
2361
+ artboardWidth,
2362
+ contentId,
2363
+ parentId,
2364
+ // deprecated props for backward compatibility
2365
+ slideId,
2366
+ presentationId,
2367
+ onSave,
2368
+ onClose,
2369
+ enableMultiPageCanvas = false,
2370
+ contentList: externalContentList,
2371
+ }: FrontendVisualEditorProps) {
2372
+ const { getIdToken } = useAuth();
2373
+ const [contentList, setContentList] = useState<ContentListItem[]>(externalContentList || []);
2374
+ const [currentHtml, setCurrentHtml] = useState(html);
2375
+
2376
+ // Support deprecated props for backward compatibility
2377
+ const effectiveContentId = contentId ?? slideId;
2378
+ const effectiveParentId = parentId ?? presentationId;
2379
+
2380
+ const [currentContentId, setCurrentContentId] = useState(effectiveContentId);
2381
+ // ページ遷移(サムネイル・URL)で contentId が変わったら追随する。
2382
+ // 殻(EditorProvider)は保ち、iframeの中身だけが html プロップ経由で差し替わる
2383
+ useEffect(() => {
2384
+ setCurrentContentId(effectiveContentId);
2385
+ }, [effectiveContentId]);
2386
+ const [currentLayoutMode, setCurrentLayoutMode] = useState<'absolute' | 'auto'>('auto');
2387
+ const [isLoading, setIsLoading] = useState(false);
2388
+
2389
+ // CSS変数の初期値(プロジェクト単位で保存)
2390
+ const [initialVariables, setInitialVariables] = useState<CSSVariableDefinition[]>([]);
2391
+ const [isVariablesLoaded, setIsVariablesLoaded] = useState(false);
2392
+
2393
+ // CSS変数をロード(webpageモード・slideモード両対応)
2394
+ useEffect(() => {
2395
+ if (!effectiveParentId) {
2396
+ setIsVariablesLoaded(true);
2397
+ return;
2398
+ }
2399
+
2400
+ const loadVariables = async () => {
2401
+ try {
2402
+ // editorModeに応じてスコープを決定
2403
+ const scope: CSSVariableScope = editorMode === 'webpage' ? 'website' : 'presentation';
2404
+ const variables = await getCSSVariablesList(effectiveParentId, scope);
2405
+ setInitialVariables(variables);
2406
+ console.log(`[FrontendVisualEditor] Loaded CSS variables (${scope}):`, variables.length);
2407
+ } catch (error) {
2408
+ console.error('[FrontendVisualEditor] Failed to load CSS variables:', error);
2409
+ } finally {
2410
+ setIsVariablesLoaded(true);
2411
+ }
2412
+ };
2413
+
2414
+ loadVariables();
2415
+ }, [effectiveParentId, editorMode]);
2416
+
2417
+ // CSS変数を保存するコールバック
2418
+ const handleSaveVariables = useCallback(async (variables: CSSVariableDefinition[]) => {
2419
+ if (!effectiveParentId) {
2420
+ throw new Error('Parent ID is required to save CSS variables');
2421
+ }
2422
+ // editorModeに応じてスコープを決定
2423
+ const scope: CSSVariableScope = editorMode === 'webpage' ? 'website' : 'presentation';
2424
+ await saveCSSVariables(effectiveParentId, variables, { scope });
2425
+ console.log(`[FrontendVisualEditor] Saved CSS variables (${scope}):`, variables.length);
2426
+ }, [effectiveParentId, editorMode]);
2427
+
2428
+ // CSS変数をロードするコールバック
2429
+ const handleLoadVariables = useCallback(async (resourceId: string) => {
2430
+ // editorModeに応じてスコープを決定
2431
+ const scope: CSSVariableScope = editorMode === 'webpage' ? 'website' : 'presentation';
2432
+ return getCSSVariablesList(resourceId, scope);
2433
+ }, [editorMode]);
2434
+
2435
+ // 外部contentListの同期
2436
+ useEffect(() => {
2437
+ if (externalContentList && externalContentList.length > 0) {
2438
+ setContentList(externalContentList);
2439
+ }
2440
+ }, [externalContentList]);
2441
+
2442
+ // コンテンツリストを取得(editorModeに応じてAPIを切り替え)
2443
+ // 外部からcontentListが渡されている場合はスキップ
2444
+ useEffect(() => {
2445
+ if (!effectiveParentId) return;
2446
+ if (externalContentList && externalContentList.length > 0) return;
2447
+
2448
+ const fetchContentList = async () => {
2449
+ try {
2450
+ const api = await createAuthApi(getIdToken);
2451
+
2452
+ if (editorMode === 'webpage') {
2453
+ // Webページモード
2454
+ const data = await api.get(`/api/websites/${effectiveParentId}/pages`);
2455
+ const items: ContentListItem[] = (data.pages || [])
2456
+ .toSorted((a: { pageNumber: number }, b: { pageNumber: number }) => a.pageNumber - b.pageNumber)
2457
+ .map((page: { id: string; title?: string; pageNumber: number; content?: { html?: string }; layoutMode?: string }) => ({
2458
+ id: page.id,
2459
+ title: page.title || '',
2460
+ order: page.pageNumber,
2461
+ thumbnailHtml: page.content?.html,
2462
+ }));
2463
+ setContentList(items);
2464
+
2465
+ // 現在のコンテンツのlayoutModeを取得して設定
2466
+ const currentContent = (data.pages || []).find((p: { id: string; layoutMode?: string }) => p.id === (effectiveContentId || items[0]?.id));
2467
+ if (currentContent) {
2468
+ setCurrentLayoutMode(currentContent.layoutMode || 'auto');
2469
+ }
2470
+ } else {
2471
+ // スライドモード(デフォルト)
2472
+ const data = await api.get(`/api/presentations/${effectiveParentId}/slides`);
2473
+ const items: ContentListItem[] = (data.slides || [])
2474
+ .toSorted((a: Slide, b: Slide) => a.slideNumber - b.slideNumber)
2475
+ .map((slide: Slide) => ({
2476
+ id: slide.id,
2477
+ title: slide.title || slide.content?.title || '',
2478
+ order: slide.slideNumber,
2479
+ thumbnailHtml: slide.generatedHtml || slide.content?.html,
2480
+ }));
2481
+ setContentList(items);
2482
+
2483
+ // 現在のコンテンツのlayoutModeを取得して設定
2484
+ const currentContent = (data.slides || []).find((s: Slide) => s.id === (effectiveContentId || items[0]?.id));
2485
+ if (currentContent) {
2486
+ setCurrentLayoutMode(currentContent.layoutMode || 'auto');
2487
+ }
2488
+ }
2489
+ } catch (error) {
2490
+ console.error('Failed to fetch content list:', error);
2491
+ }
2492
+ };
2493
+
2494
+ fetchContentList();
2495
+ }, [effectiveParentId, effectiveContentId, getIdToken, editorMode]);
2496
+
2497
+ // コンテンツ変更ハンドラ(editorModeに応じてAPIを切り替え)
2498
+ const handleContentChange = useCallback(async (newContentId: string) => {
2499
+ if (!effectiveParentId || newContentId === currentContentId) return;
2500
+
2501
+ // キャンバスモード時はisLoadingをスキップ(EditorProviderのアンマウントを防止)
2502
+ if (!enableMultiPageCanvas) {
2503
+ setIsLoading(true);
2504
+ }
2505
+ try {
2506
+ // 新しいコンテンツのHTMLを取得
2507
+ const api = await createAuthApi(getIdToken);
2508
+
2509
+ if (editorMode === 'webpage') {
2510
+ // Webページモード
2511
+ const data = await api.get(`/api/websites/${effectiveParentId}/pages/${newContentId}`);
2512
+ const newHtml = data.page?.content?.html || '';
2513
+ const newLayoutMode = data.page?.layoutMode || 'auto';
2514
+
2515
+ setCurrentHtml(newHtml);
2516
+ setCurrentLayoutMode(newLayoutMode);
2517
+ setCurrentContentId(newContentId);
2518
+ } else {
2519
+ // スライドモード(デフォルト)
2520
+ const data = await api.get(`/api/presentations/${effectiveParentId}/slides/${newContentId}`);
2521
+ const newHtml = data.slide?.generatedHtml || data.slide?.content?.html || '';
2522
+ const newLayoutMode = data.slide?.layoutMode || 'auto';
2523
+
2524
+ setCurrentHtml(newHtml);
2525
+ setCurrentLayoutMode(newLayoutMode);
2526
+ setCurrentContentId(newContentId);
2527
+ }
2528
+ } catch (error) {
2529
+ console.error('Failed to fetch content:', error);
2530
+ } finally {
2531
+ if (!enableMultiPageCanvas) {
2532
+ setIsLoading(false);
2533
+ }
2534
+ }
2535
+ }, [effectiveParentId, currentContentId, getIdToken, editorMode, enableMultiPageCanvas]);
2536
+
2537
+ useEffect(() => {
2538
+ if (html) {
2539
+ setCurrentHtml(html);
2540
+ }
2541
+ }, [html]);
2542
+
2543
+ // EditorProviderのキーはparentIdのみ(コンテンツ変更で状態をリセットしない)
2544
+ // マルチアートボード対応: 状態はEditorContext内のartboardStatesで管理
2545
+ const editorKey = `editor-${effectiveParentId}`;
2546
+
2547
+ // CSS変数のロードも待つ
2548
+ if (isLoading || !isVariablesLoaded) {
2549
+ return (
2550
+ <div className={cn(
2551
+ "gg-editor-skin bg-[#1e1e1e] flex items-center justify-center",
2552
+ enableMultiPageCanvas ? "absolute inset-0" : "fixed inset-0 z-50"
2553
+ )}>
2554
+ <div className="flex items-center gap-3 text-gray-400">
2555
+ {/* SVGアニメーションはdivラッパーで適用(ハードウェアアクセラレーション対応) */}
2556
+ <div className="animate-spin">
2557
+ <Loader2 className="w-6 h-6" />
2558
+ </div>
2559
+ <span>読み込み中...</span>
2560
+ </div>
2561
+ </div>
2562
+ );
2563
+ }
2564
+
2565
+ return (
2566
+ <EditorProvider
2567
+ key={editorKey}
2568
+ initialHtml={currentHtml}
2569
+ editorMode={editorMode}
2570
+ artboardWidth={artboardWidth}
2571
+ contentList={contentList}
2572
+ currentContentId={currentContentId}
2573
+ onContentChange={handleContentChange}
2574
+ initialLayoutMode={currentLayoutMode}
2575
+ initialVariables={initialVariables}
2576
+ onSaveVariables={handleSaveVariables}
2577
+ onLoadVariables={handleLoadVariables}
2578
+ websiteId={effectiveParentId}
2579
+ enableMultiPageCanvas={enableMultiPageCanvas}
2580
+ >
2581
+ <FrontendVisualEditorInner
2582
+ onSave={onSave}
2583
+ onClose={onClose}
2584
+ parentId={effectiveParentId}
2585
+ contentId={currentContentId}
2586
+ isMultiPageCanvas={enableMultiPageCanvas}
2587
+ />
2588
+ </EditorProvider>
2589
+ );
2590
+ }
2591
+
2592
+ /** @deprecated Use FrontendVisualEditor instead */
2593
+ export const SlideVisualEditor = FrontendVisualEditor;
2594
+
2595
+ export default FrontendVisualEditor;