@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,360 @@
1
+ /**
2
+ * iframe初期化処理フック
3
+ * - スタイル注入
4
+ * - data-editable属性の付与
5
+ * - インライン要素の検出
6
+ * - MutationObserverのセットアップ
7
+ */
8
+
9
+ import { useCallback, useRef, useEffect } from 'react';
10
+ import { useEditorContext } from '../EditorContext';
11
+ import { buildDomTree, isInlineElement } from '../utils/dom-utils';
12
+ import { EDITOR_IFRAME_STYLES } from '../constants';
13
+
14
+ interface UseIframeSetupReturn {
15
+ /**
16
+ * iframe ドキュメントの初期化処理
17
+ * - スタイル注入
18
+ * - data-editable属性の付与
19
+ * - DOMツリーの構築
20
+ */
21
+ initializeIframeDocument: (iframeDoc: Document) => void;
22
+
23
+ /**
24
+ * MutationObserverをセットアップ
25
+ * @returns クリーンアップ関数
26
+ */
27
+ setupMutationObserver: (iframeDoc: Document) => () => void;
28
+
29
+ /**
30
+ * テキスト要素ホバーリスナーをセットアップ
31
+ *
32
+ * @param resolveHoverTarget
33
+ * 「いまクリックしたら選択されるであろう要素」を返す関数。
34
+ * 選択側(useElementSelection)と同じ判別器を渡すことで、
35
+ * ホバー輪郭が必ずクリック結果の予告になる。
36
+ * 省略可(未指定なら輪郭は出さず、従来のテキスト下線だけ動く)。
37
+ * @returns クリーンアップ関数
38
+ */
39
+ setupTextHoverListener: (
40
+ iframeDoc: Document,
41
+ resolveHoverTarget?: HoverTargetResolver,
42
+ ) => () => void;
43
+ }
44
+
45
+ /** ホバー予告の対象を決める関数の型(選択側の判別器と同じもの) */
46
+ export type HoverTargetResolver = (
47
+ target: EventTarget | null,
48
+ iframeDoc: Document,
49
+ options: { meta: boolean },
50
+ ) => HTMLElement | null;
51
+
52
+ /**
53
+ * iframe初期化処理フック
54
+ */
55
+ export function useIframeSetup(): UseIframeSetupReturn {
56
+ const { setDomTree } = useEditorContext();
57
+
58
+ // クロージャ問題回避のためのref
59
+ const setDomTreeRef = useRef(setDomTree);
60
+
61
+ // refを最新値に同期
62
+ useEffect(() => {
63
+ setDomTreeRef.current = setDomTree;
64
+ }, [setDomTree]);
65
+
66
+ /**
67
+ * 全要素にdata-editable属性とdata-element-idを付与
68
+ */
69
+ const initializeEditableElements = useCallback((iframeDoc: Document) => {
70
+ const artboard = iframeDoc.getElementById('artboard');
71
+ if (!artboard) {
72
+ console.warn('[IframeSetup] #artboard not found');
73
+ return 0;
74
+ }
75
+
76
+ let elementIndex = 0;
77
+ const elements = artboard.querySelectorAll('*');
78
+
79
+ elements.forEach((el) => {
80
+ const element = el as HTMLElement;
81
+
82
+ // スキップする要素
83
+ if (element === artboard) return;
84
+ if (element === iframeDoc.documentElement) return;
85
+ // [移植時の修正] スライドのキャンバス本体(#artboard 直下で紙面と同じ大きさの器)は
86
+ // 編集対象にしない。ここを掴めるとスライドごと動いてしまい、中身が版面の外
87
+ // (overflow:hidden)へ出るため、要素が消えたように見える
88
+ if (
89
+ element.parentElement === artboard &&
90
+ element.offsetWidth >= artboard.clientWidth - 2 &&
91
+ element.offsetHeight >= artboard.clientHeight - 2
92
+ ) {
93
+ return;
94
+ }
95
+ if (
96
+ element.tagName === 'SCRIPT' ||
97
+ element.tagName === 'STYLE' ||
98
+ element.tagName === 'HEAD'
99
+ )
100
+ return;
101
+ if (element.closest('script, style, svg path, svg g')) return;
102
+ if (element.classList.contains('material-icons')) return;
103
+ if (element.classList.contains('material-icons-outlined')) return;
104
+ // selection-box およびその子要素はスキップ
105
+ if (element.classList.contains('selection-box')) return;
106
+ if (element.classList.contains('resize-handle')) return;
107
+ if (element.classList.contains('selection-outline')) return;
108
+ if (element.classList.contains('size-label')) return;
109
+ if (element.classList.contains('border-radius-handle')) return;
110
+ if (element.classList.contains('rotation-handle')) return;
111
+ // selection-boxの子孫要素をすべてスキップ(念のため)
112
+ if (element.closest('.selection-box')) return;
113
+
114
+ // 既にIDがある場合はスキップ
115
+ if (!element.getAttribute('data-element-id')) {
116
+ const elementId = `el-${Date.now()}-${elementIndex++}`;
117
+ element.setAttribute('data-editable', 'true');
118
+ element.setAttribute('data-element-id', elementId);
119
+
120
+ // インライン要素にはフラグを付与(絶対配置に変換しない)
121
+ const computedStyle = iframeDoc.defaultView?.getComputedStyle(element);
122
+ if (computedStyle && isInlineElement(element, computedStyle)) {
123
+ element.setAttribute('data-inline', 'true');
124
+ }
125
+ } else if (!element.getAttribute('data-editable')) {
126
+ element.setAttribute('data-editable', 'true');
127
+ }
128
+ });
129
+
130
+ return elementIndex;
131
+ }, []);
132
+
133
+ /**
134
+ * iframe ドキュメントの初期化
135
+ */
136
+ const initializeIframeDocument = useCallback(
137
+ (iframeDoc: Document) => {
138
+ console.log('[IframeSetup] Initializing iframe document...');
139
+
140
+ // スタイルを注入
141
+ const style = iframeDoc.createElement('style');
142
+ style.textContent = EDITOR_IFRAME_STYLES;
143
+ iframeDoc.head.appendChild(style);
144
+
145
+ // タッチアクションを無効化
146
+ iframeDoc.documentElement.style.touchAction = 'none';
147
+ iframeDoc.body.style.touchAction = 'none';
148
+
149
+ // 全要素にdata-editableとdata-element-idを付与
150
+ const elementCount = initializeEditableElements(iframeDoc);
151
+ console.log('[IframeSetup] Initialized', elementCount, 'editable elements');
152
+
153
+ // DOMツリーを構築
154
+ const newTree = buildDomTree(iframeDoc);
155
+ setDomTreeRef.current(newTree);
156
+ },
157
+ [initializeEditableElements]
158
+ );
159
+
160
+ /**
161
+ * MutationObserverをセットアップ
162
+ */
163
+ const setupMutationObserver = useCallback(
164
+ (iframeDoc: Document) => {
165
+ const artboard = iframeDoc.getElementById('artboard');
166
+ if (!artboard) {
167
+ console.warn('[IframeSetup] #artboard not found for MutationObserver');
168
+ return () => {};
169
+ }
170
+
171
+ let mutationTimeout: ReturnType<typeof setTimeout> | null = null;
172
+
173
+ const observer = new MutationObserver(() => {
174
+ // デバウンス: 連続した変更を一度にまとめる
175
+ if (mutationTimeout) {
176
+ clearTimeout(mutationTimeout);
177
+ }
178
+ mutationTimeout = setTimeout(() => {
179
+ // 新しい要素にdata-editableを付与
180
+ initializeEditableElements(iframeDoc);
181
+ // DOMツリーを再構築
182
+ const newTree = buildDomTree(iframeDoc);
183
+ setDomTreeRef.current(newTree);
184
+ }, 100);
185
+ });
186
+
187
+ observer.observe(artboard, {
188
+ childList: true,
189
+ subtree: true,
190
+ attributes: false,
191
+ characterData: false,
192
+ });
193
+
194
+ // クリーンアップ関数
195
+ return () => {
196
+ if (mutationTimeout) {
197
+ clearTimeout(mutationTimeout);
198
+ }
199
+ observer.disconnect();
200
+ };
201
+ },
202
+ [initializeEditableElements]
203
+ );
204
+
205
+ /**
206
+ * 要素が直下にテキストを含むかチェック
207
+ */
208
+ const hasDirectTextContent = useCallback((element: HTMLElement): boolean => {
209
+ // 画像やSVGは除外
210
+ const tagName = element.tagName.toUpperCase();
211
+ if (['IMG', 'SVG', 'VIDEO', 'IFRAME', 'CANVAS'].includes(tagName)) return false;
212
+
213
+ // 直接テキストノードを含むかチェック
214
+ for (const child of element.childNodes) {
215
+ if (child.nodeType === Node.TEXT_NODE && child.textContent?.trim()) {
216
+ return true;
217
+ }
218
+ }
219
+
220
+ // テキスト系タグで、テキストコンテンツがある場合
221
+ const textTags = ['P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'SPAN', 'A', 'LABEL', 'LI', 'TD', 'TH', 'BUTTON'];
222
+ if (textTags.includes(tagName) && element.textContent?.trim()) {
223
+ return true;
224
+ }
225
+
226
+ return false;
227
+ }, []);
228
+
229
+ /**
230
+ * テキスト要素ホバーリスナーをセットアップ
231
+ */
232
+ const setupTextHoverListener = useCallback(
233
+ (iframeDoc: Document, resolveHoverTarget?: HoverTargetResolver) => {
234
+ let currentHoveredTextElement: HTMLElement | null = null;
235
+ // [選択セマンティクスの一本化] クリック結果を予告する輪郭は1要素だけに付ける。
236
+ // CSS の :hover は祖先チェーン全体に当たってしまうため、ここで付け替える。
237
+ let currentPreviewElement: HTMLElement | null = null;
238
+
239
+ const clearPreview = () => {
240
+ if (currentPreviewElement) {
241
+ currentPreviewElement.classList.remove('hover-preview');
242
+ currentPreviewElement = null;
243
+ }
244
+ };
245
+
246
+ /** 予告輪郭を1要素だけに付け替える */
247
+ const updatePreview = (e: MouseEvent) => {
248
+ if (!resolveHoverTarget) return;
249
+ // ドラッグ/マーキー中は予告を出さない(掴んでいる最中に輪郭が踊るのを防ぐ)
250
+ if (
251
+ iframeDoc.body.classList.contains('marquee-active') ||
252
+ iframeDoc.querySelector('.dragging')
253
+ ) {
254
+ clearPreview();
255
+ return;
256
+ }
257
+ const next = resolveHoverTarget(e.target, iframeDoc, {
258
+ meta: e.metaKey || e.ctrlKey,
259
+ });
260
+ if (next === currentPreviewElement) return;
261
+ clearPreview();
262
+ if (next) {
263
+ next.classList.add('hover-preview');
264
+ currentPreviewElement = next;
265
+ }
266
+ };
267
+
268
+ const handleMouseMove = (e: MouseEvent) => {
269
+ // 描画モードやテキストモードでは無効
270
+ const bodyClasses = iframeDoc.body.classList;
271
+ if (bodyClasses.contains('draw-mode') || bodyClasses.contains('text-mode') || bodyClasses.contains('move-mode')) {
272
+ if (currentHoveredTextElement) {
273
+ currentHoveredTextElement.classList.remove('text-editable-hover');
274
+ currentHoveredTextElement = null;
275
+ }
276
+ clearPreview();
277
+ return;
278
+ }
279
+
280
+ updatePreview(e);
281
+
282
+ // マウス下の要素を取得
283
+ const target = e.target as HTMLElement;
284
+
285
+ // 編集可能要素を探す
286
+ let editableElement: HTMLElement | null = target;
287
+ while (editableElement && editableElement !== iframeDoc.body) {
288
+ if (editableElement.getAttribute('data-editable') === 'true') {
289
+ break;
290
+ }
291
+ editableElement = editableElement.parentElement;
292
+ }
293
+
294
+ if (!editableElement || editableElement === iframeDoc.body) {
295
+ // 編集可能要素がない場合、既存のホバーをクリア
296
+ if (currentHoveredTextElement) {
297
+ currentHoveredTextElement.classList.remove('text-editable-hover');
298
+ currentHoveredTextElement = null;
299
+ }
300
+ return;
301
+ }
302
+
303
+ // テキスト編集可能かチェック
304
+ const isTextElement = hasDirectTextContent(editableElement);
305
+
306
+ if (isTextElement && editableElement !== currentHoveredTextElement) {
307
+ // 前のホバーをクリア
308
+ if (currentHoveredTextElement) {
309
+ currentHoveredTextElement.classList.remove('text-editable-hover');
310
+ }
311
+ // 新しいホバーを設定
312
+ editableElement.classList.add('text-editable-hover');
313
+ currentHoveredTextElement = editableElement;
314
+ } else if (!isTextElement && currentHoveredTextElement) {
315
+ // テキスト要素でない場合、ホバーをクリア
316
+ currentHoveredTextElement.classList.remove('text-editable-hover');
317
+ currentHoveredTextElement = null;
318
+ }
319
+ };
320
+
321
+ const handleMouseLeave = () => {
322
+ if (currentHoveredTextElement) {
323
+ currentHoveredTextElement.classList.remove('text-editable-hover');
324
+ currentHoveredTextElement = null;
325
+ }
326
+ clearPreview();
327
+ };
328
+
329
+ // Cmd/Ctrl の押し下げ・解放でも予告先が変わる(Cmdは最深要素を選ぶため)
330
+ const handleModifierChange = (e: KeyboardEvent) => {
331
+ if (e.key !== 'Meta' && e.key !== 'Control') return;
332
+ clearPreview();
333
+ };
334
+
335
+ iframeDoc.addEventListener('mousemove', handleMouseMove);
336
+ iframeDoc.addEventListener('mouseleave', handleMouseLeave);
337
+ iframeDoc.addEventListener('keydown', handleModifierChange);
338
+ iframeDoc.addEventListener('keyup', handleModifierChange);
339
+
340
+ // クリーンアップ関数
341
+ return () => {
342
+ iframeDoc.removeEventListener('mousemove', handleMouseMove);
343
+ iframeDoc.removeEventListener('mouseleave', handleMouseLeave);
344
+ iframeDoc.removeEventListener('keydown', handleModifierChange);
345
+ iframeDoc.removeEventListener('keyup', handleModifierChange);
346
+ if (currentHoveredTextElement) {
347
+ currentHoveredTextElement.classList.remove('text-editable-hover');
348
+ }
349
+ clearPreview();
350
+ };
351
+ },
352
+ [hasDirectTextContent]
353
+ );
354
+
355
+ return {
356
+ initializeIframeDocument,
357
+ setupMutationObserver,
358
+ setupTextHoverListener,
359
+ };
360
+ }
@@ -0,0 +1,270 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * エディタの画像アップロード機能を管理するHook
5
+ * - ファイル選択からのアップロード
6
+ * - ドラッグ&ドロップ
7
+ * - クリップボードからのペースト
8
+ */
9
+
10
+ import { useState, useCallback } from 'react';
11
+ import { useEditorContext } from '../EditorContext';
12
+ import {
13
+ uploadEditorImage,
14
+ uploadEditorImageFromDataUrl,
15
+ SUPPORTED_IMAGE_TYPES,
16
+ MAX_IMAGE_SIZE,
17
+ } from '../../lib/firebase/storage';
18
+ import { buildDomTree } from '../utils/dom-utils';
19
+ import { findInsertionParent, findSlideRoot } from '../utils/slide-root';
20
+
21
+ interface UploadResult {
22
+ storageUrl: string;
23
+ storagePath: string;
24
+ width?: number;
25
+ height?: number;
26
+ }
27
+
28
+ interface UseImageUploadOptions {
29
+ presentationId?: string;
30
+ slideId?: string;
31
+ }
32
+
33
+ export function useImageUpload(options: UseImageUploadOptions = {}) {
34
+ const { getIframeDoc, setDomTree, notifyIframeChange, layoutMode } = useEditorContext();
35
+ const [isUploading, setIsUploading] = useState(false);
36
+ const [uploadError, setUploadError] = useState<string | null>(null);
37
+
38
+ /**
39
+ * 画像要素をiframeに挿入
40
+ */
41
+ const insertImageElement = useCallback((
42
+ url: string,
43
+ options: { width?: number; height?: number; x?: number; y?: number } = {}
44
+ ) => {
45
+ const iframeDoc = getIframeDoc();
46
+ if (!iframeDoc) return;
47
+
48
+ // 画像要素を作成
49
+ const img = iframeDoc.createElement('img');
50
+ img.src = url;
51
+ img.alt = 'Uploaded image';
52
+
53
+ // サイズを設定(デフォルトは元のサイズ、最大幅500px)
54
+ const maxWidth = 500;
55
+ let width = options.width || 300;
56
+ let height = options.height || 200;
57
+
58
+ if (width > maxWidth) {
59
+ const ratio = maxWidth / width;
60
+ width = maxWidth;
61
+ height = Math.round(height * ratio);
62
+ }
63
+
64
+ img.style.width = `${width}px`;
65
+ img.style.height = `${height}px`;
66
+
67
+ // 位置を設定
68
+ // [移植時の修正] スライド編集中はオートレイアウトでも絶対配置にする。
69
+ // スライドは1920×1080の固定枠(overflow:hidden)で、中身も絶対配置で組まれているため、
70
+ // フロー配置にすると画像がスライドの下(1080pxの外)に積まれて見えなくなり、
71
+ // その状態でドラッグすると画面外へ飛んでいったように見えてしまう。
72
+ const insideSlide = !!findSlideRoot(iframeDoc);
73
+ if (layoutMode === 'absolute' || insideSlide) {
74
+ const x = options.x ?? 100;
75
+ const y = options.y ?? 100;
76
+ img.style.position = 'absolute';
77
+ img.style.left = `${x}px`;
78
+ img.style.top = `${y}px`;
79
+ } else {
80
+ // オートレイアウトモード: 通常のレイアウトフローに従う
81
+ img.style.display = 'block';
82
+ img.style.margin = '10px auto';
83
+ }
84
+
85
+ // 編集可能属性を追加
86
+ const elementId = `el-${Date.now()}-img`;
87
+ img.setAttribute('data-editable', 'true');
88
+ img.setAttribute('data-element-id', elementId);
89
+
90
+ // [移植時の修正] スライドがある場合はスライドの中に追加する。
91
+ // 外側(#artboard/body)に入れると、ズーム倍率の分だけ座標系がずれて
92
+ // スライド外に表示されたり、ドラッグで飛んでいったりする。
93
+ findInsertionParent(iframeDoc).appendChild(img);
94
+
95
+ // DOMツリーを更新
96
+ const newTree = buildDomTree(iframeDoc);
97
+ setDomTree(newTree);
98
+
99
+ // 変更を通知
100
+ notifyIframeChange();
101
+
102
+ return elementId;
103
+ }, [getIframeDoc, setDomTree, notifyIframeChange]);
104
+
105
+ /**
106
+ * ファイルからアップロード
107
+ */
108
+ const uploadFromFile = useCallback(async (
109
+ file: File,
110
+ insertOptions?: { x?: number; y?: number }
111
+ ): Promise<UploadResult | null> => {
112
+ setIsUploading(true);
113
+ setUploadError(null);
114
+
115
+ try {
116
+ // ファイルタイプチェック
117
+ if (!SUPPORTED_IMAGE_TYPES.includes(file.type)) {
118
+ throw new Error('サポートされていない画像形式です。JPEG、PNG、GIF、WebP、SVGがサポートされています。');
119
+ }
120
+
121
+ // ファイルサイズチェック
122
+ if (file.size > MAX_IMAGE_SIZE) {
123
+ throw new Error(`画像サイズが大きすぎます。${MAX_IMAGE_SIZE / (1024 * 1024)}MB以下にしてください。`);
124
+ }
125
+
126
+ const result = await uploadEditorImage(file, {
127
+ presentationId: options.presentationId,
128
+ slideId: options.slideId,
129
+ fileName: file.name,
130
+ });
131
+
132
+ // 画像をiframeに挿入
133
+ insertImageElement(result.storageUrl, {
134
+ width: result.width,
135
+ height: result.height,
136
+ x: insertOptions?.x,
137
+ y: insertOptions?.y,
138
+ });
139
+
140
+ return result;
141
+ } catch (error) {
142
+ const message = error instanceof Error ? error.message : '画像のアップロードに失敗しました。';
143
+ setUploadError(message);
144
+ console.error('Image upload error:', error);
145
+ return null;
146
+ } finally {
147
+ setIsUploading(false);
148
+ }
149
+ }, [options.presentationId, options.slideId, insertImageElement]);
150
+
151
+ /**
152
+ * 複数ファイルからアップロード
153
+ */
154
+ const uploadFromFiles = useCallback(async (
155
+ files: FileList | File[],
156
+ insertOptions?: { x?: number; y?: number }
157
+ ): Promise<UploadResult[]> => {
158
+ const results: UploadResult[] = [];
159
+ let currentX = insertOptions?.x ?? 100;
160
+ const startY = insertOptions?.y ?? 100;
161
+
162
+ for (const file of Array.from(files)) {
163
+ const result = await uploadFromFile(file, { x: currentX, y: startY });
164
+ if (result) {
165
+ results.push(result);
166
+ currentX += (result.width || 300) + 20; // 次の画像の位置
167
+ }
168
+ }
169
+
170
+ return results;
171
+ }, [uploadFromFile]);
172
+
173
+ /**
174
+ * クリップボードからアップロード
175
+ */
176
+ const uploadFromClipboard = useCallback(async (
177
+ clipboardData: DataTransfer,
178
+ insertOptions?: { x?: number; y?: number }
179
+ ): Promise<UploadResult | null> => {
180
+ console.log('[useImageUpload] uploadFromClipboard called');
181
+ // クリップボードから画像を探す
182
+ const items = Array.from(clipboardData.items);
183
+ console.log('[useImageUpload] Clipboard items:', items.map(i => ({ type: i.type, kind: i.kind })));
184
+
185
+ for (const item of items) {
186
+ if (item.type.startsWith('image/')) {
187
+ const file = item.getAsFile();
188
+ console.log('[useImageUpload] Got file from clipboard item:', file);
189
+ if (file) {
190
+ return uploadFromFile(file, insertOptions);
191
+ }
192
+ }
193
+ }
194
+
195
+ // DataURLとして取得を試みる
196
+ const htmlData = clipboardData.getData('text/html');
197
+ if (htmlData) {
198
+ const imgMatch = htmlData.match(/<img[^>]+src="(data:image\/[^"]+)"/);
199
+ if (imgMatch) {
200
+ setIsUploading(true);
201
+ setUploadError(null);
202
+
203
+ try {
204
+ const result = await uploadEditorImageFromDataUrl(imgMatch[1], {
205
+ presentationId: options.presentationId,
206
+ slideId: options.slideId,
207
+ });
208
+
209
+ insertImageElement(result.storageUrl, {
210
+ width: result.width,
211
+ height: result.height,
212
+ x: insertOptions?.x,
213
+ y: insertOptions?.y,
214
+ });
215
+
216
+ return result;
217
+ } catch (error) {
218
+ const message = error instanceof Error ? error.message : '画像のアップロードに失敗しました。';
219
+ setUploadError(message);
220
+ console.error('Image upload error:', error);
221
+ return null;
222
+ } finally {
223
+ setIsUploading(false);
224
+ }
225
+ }
226
+ }
227
+
228
+ return null;
229
+ }, [options.presentationId, options.slideId, uploadFromFile, insertImageElement]);
230
+
231
+ /**
232
+ * URLから画像を挿入(アップロードなし、外部URL)
233
+ */
234
+ const insertImageFromUrl = useCallback((
235
+ url: string,
236
+ insertOptions?: { width?: number; height?: number; x?: number; y?: number }
237
+ ) => {
238
+ return insertImageElement(url, insertOptions);
239
+ }, [insertImageElement]);
240
+
241
+ /**
242
+ * ファイル選択ダイアログを開く
243
+ */
244
+ const openFilePicker = useCallback((insertOptions?: { x?: number; y?: number }) => {
245
+ const input = document.createElement('input');
246
+ input.type = 'file';
247
+ input.accept = SUPPORTED_IMAGE_TYPES.join(',');
248
+ input.multiple = true;
249
+
250
+ input.onchange = async (e) => {
251
+ const files = (e.target as HTMLInputElement).files;
252
+ if (files && files.length > 0) {
253
+ await uploadFromFiles(files, insertOptions);
254
+ }
255
+ };
256
+
257
+ input.click();
258
+ }, [uploadFromFiles]);
259
+
260
+ return {
261
+ isUploading,
262
+ uploadError,
263
+ uploadFromFile,
264
+ uploadFromFiles,
265
+ uploadFromClipboard,
266
+ insertImageFromUrl,
267
+ openFilePicker,
268
+ insertImageElement,
269
+ };
270
+ }