@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,1573 @@
1
+ /**
2
+ * 要素選択処理フック
3
+ * - シングル選択 / マルチ選択
4
+ * - Ctrl/Cmd クリックによるリーフ選択
5
+ * - Shift クリックによる追加選択
6
+ * - ダブルクリックによるドリルダウン
7
+ * - 兄弟要素(Peer)の階層認識
8
+ */
9
+
10
+ import { useCallback, useRef, useEffect, MutableRefObject } from "react";
11
+ import { useEditorContext } from "../EditorContext";
12
+ import {
13
+ updateSelectionBox,
14
+ isInlineElement,
15
+ fitElementToContent,
16
+ getArtboardContent,
17
+ prepareElementDragOrigin,
18
+ prepareElementsForGroupDrag,
19
+ getArtboardScale,
20
+ refreshSelectionOverlay,
21
+ syncSelectionOverlayRects,
22
+ } from "../utils/dom-utils";
23
+ import { extractElementInfo } from "../utils/style-utils";
24
+ import { MARQUEE_DRAG_THRESHOLD } from "../constants";
25
+ import type { DragState, ResizeState, MarqueeState } from "../types";
26
+
27
+ /**
28
+ * mousedown で保留し、mouseup で意味を確定するクリック情報
29
+ *
30
+ * [なぜ保留するか]
31
+ * Shift の意味は「トグル」と「軸拘束ドラッグ」の2つがあり、mousedown の時点では
32
+ * どちらか決められない。mousedown で即トグルすると、Shift+ドラッグ(軸拘束)の
33
+ * たびに群の構成が変わってしまう。同様に「群のメンバーを素クリック → 単独選択に
34
+ * 落ちる」も、掴んだ瞬間に落とすと群ドラッグができなくなる。
35
+ * そのため、掴んだ時点では選択を変えず、移動量が閾値未満だったときだけ
36
+ * mouseup で確定させる。
37
+ */
38
+ interface PendingClick {
39
+ element: HTMLElement;
40
+ elementId: string;
41
+ /** Shift が押されていたか(トグル候補) */
42
+ shift: boolean;
43
+ /** mousedown 時点で選択済みだったか */
44
+ wasSelected: boolean;
45
+ /** mousedown 時点で複数選択だったか(素クリックで単独へ畳む判定用) */
46
+ wasMulti: boolean;
47
+ startX: number;
48
+ startY: number;
49
+ }
50
+
51
+ interface UseElementSelectionOptions {
52
+ /** ドラッグ状態を更新するコールバック(useRefオブジェクトのcurrentを更新) */
53
+ dragStateRef: MutableRefObject<DragState>;
54
+ /** リサイズ状態を更新するコールバック(useRefオブジェクトのcurrentを更新) */
55
+ resizeStateRef: MutableRefObject<ResizeState>;
56
+ /** マーキー選択の開始保留フラグ */
57
+ marqueeStartPendingRef: MutableRefObject<boolean>;
58
+ /** マーキークリック対象の要素 */
59
+ marqueeClickTargetRef: MutableRefObject<HTMLElement | null>;
60
+ /** レイアウトモード */
61
+ layoutModeRef: MutableRefObject<"absolute" | "auto">;
62
+ /** レイアウトヒント表示関数のref(群移動できないときの案内用) */
63
+ setShowLayoutHintRef?: MutableRefObject<(show: boolean) => void>;
64
+ /** マーキーを既存選択への加算として実行するか(Shift+マーキー) */
65
+ marqueeAdditiveRef?: MutableRefObject<boolean>;
66
+ /**
67
+ * マーキー矩形の同期的な保持先。
68
+ * React state だと mousedown → 最初の mousemove の間に反映が間に合わず、
69
+ * 始点が (0,0) のまま読まれて矩形がずれる。判定はこの ref を正とする。
70
+ */
71
+ marqueeGeomRef?: MutableRefObject<MarqueeState>;
72
+ }
73
+
74
+ interface UseElementSelectionReturn {
75
+ /**
76
+ * 編集可能な要素を取得(インライン要素の場合は親を返す)
77
+ * Cmd/Ctrl クリック用:最下層(Leaf)を取得
78
+ */
79
+ getEditableElement: (
80
+ target: EventTarget | null,
81
+ iframeDoc: Document,
82
+ ) => HTMLElement | null;
83
+
84
+ /**
85
+ * 最上位(Top-Level)の編集可能要素を取得
86
+ * body直前またはdata-editableを持たない親の手前まで遡る
87
+ */
88
+ getTopLevelEditable: (
89
+ element: HTMLElement,
90
+ iframeDoc: Document,
91
+ ) => HTMLElement;
92
+
93
+ /**
94
+ * 要素のドラッグを開始
95
+ */
96
+ startElementDrag: (
97
+ element: HTMLElement,
98
+ e: MouseEvent,
99
+ iframeDoc: Document,
100
+ ) => void;
101
+
102
+ /**
103
+ * 要素情報を親ウィンドウに送信
104
+ */
105
+ sendElementInfo: (element: HTMLElement, iframeDoc: Document) => void;
106
+
107
+ /**
108
+ * iframe ドキュメントに選択リスナーをセットアップ
109
+ * @returns クリーンアップ関数
110
+ */
111
+ setupSelectionListeners: (iframeDoc: Document) => () => void;
112
+
113
+ /**
114
+ * 「いまクリックしたら選択されるであろう要素」を返す(ホバー予告用)
115
+ * mousedown の判別器と同じ経路を通るので、輪郭とクリック結果が必ず一致する
116
+ */
117
+ resolveHoverTarget: (
118
+ target: EventTarget | null,
119
+ iframeDoc: Document,
120
+ options: { meta: boolean },
121
+ ) => HTMLElement | null;
122
+
123
+ /**
124
+ * mouseup で Shift の意味(トグル or 軸拘束ドラッグ)と
125
+ * 「群のメンバーの素クリック → 単独選択」を確定させる。
126
+ * ドラッグ確定処理(useDragResize)の *後* に呼ぶこと。
127
+ * iframe の外で離した場合は、iframe ビューポート座標へ変換したイベントを渡す。
128
+ */
129
+ handleSelectionMouseUp: (e: MouseEvent, iframeDoc: Document) => void;
130
+ }
131
+
132
+ /**
133
+ * 要素選択処理フック
134
+ */
135
+ export function useElementSelection(
136
+ options: UseElementSelectionOptions,
137
+ ): UseElementSelectionReturn {
138
+ const {
139
+ selectedElementIds,
140
+ setSelectedElement,
141
+ setSelectedElementIds,
142
+ setMarqueeState,
143
+ zoom,
144
+ } = useEditorContext();
145
+
146
+ const {
147
+ dragStateRef,
148
+ resizeStateRef,
149
+ marqueeStartPendingRef,
150
+ marqueeClickTargetRef,
151
+ layoutModeRef,
152
+ setShowLayoutHintRef,
153
+ marqueeAdditiveRef,
154
+ marqueeGeomRef,
155
+ } = options;
156
+
157
+ // mousedown で保留し mouseup で意味を確定するクリック情報
158
+ const pendingClickRef = useRef<PendingClick | null>(null);
159
+
160
+ /**
161
+ * ハンドルの2連打検出用。
162
+ * リサイズが終わるたびに選択ボックス(とハンドル)は作り直されるため、
163
+ * ブラウザは「同一要素への2連クリック」と見なさず dblclick を合成しない。
164
+ * dblclick イベントには頼れないので、mousedown の時刻と種類で自前判定する。
165
+ */
166
+ const lastHandleDownRef = useRef<{ handle: string; time: number; x: number; y: number } | null>(null);
167
+
168
+ // クロージャ問題回避のためのref
169
+ const selectedElementIdsRef = useRef(selectedElementIds);
170
+ const setSelectedElementRef = useRef(setSelectedElement);
171
+ const setSelectedElementIdsRef = useRef(setSelectedElementIds);
172
+ const setMarqueeStateRef = useRef(setMarqueeState);
173
+ const zoomRef = useRef(zoom);
174
+
175
+ // refを最新値に同期
176
+ useEffect(() => {
177
+ selectedElementIdsRef.current = selectedElementIds;
178
+ }, [selectedElementIds]);
179
+
180
+ useEffect(() => {
181
+ setSelectedElementRef.current = setSelectedElement;
182
+ setSelectedElementIdsRef.current = setSelectedElementIds;
183
+ setMarqueeStateRef.current = setMarqueeState;
184
+ }, [setSelectedElement, setSelectedElementIds, setMarqueeState]);
185
+
186
+ useEffect(() => {
187
+ zoomRef.current = zoom;
188
+ }, [zoom]);
189
+
190
+ /**
191
+ * 要素情報を親ウィンドウに送信
192
+ */
193
+ const sendElementInfo = useCallback(
194
+ (element: HTMLElement, iframeDoc: Document) => {
195
+ const info = extractElementInfo(element, iframeDoc);
196
+ if (info) {
197
+ window.postMessage({ type: "ELEMENT_SELECTED", element: info }, "*");
198
+ }
199
+ },
200
+ [],
201
+ );
202
+
203
+ /**
204
+ * 要素が視覚的にブロックレベルとして表示されているかを判定
205
+ * data-inline="true" でも、Tailwindのclass="block"などでblock表示の場合はtrue
206
+ */
207
+ const isVisuallyBlockLevel = useCallback(
208
+ (element: HTMLElement, iframeDoc: Document): boolean => {
209
+ const computedStyle = iframeDoc.defaultView?.getComputedStyle(element);
210
+ if (!computedStyle) return false;
211
+
212
+ const display = computedStyle.display;
213
+ // ブロックレベル表示(block, flex, grid, etc.)はtrue
214
+ const blockDisplays = [
215
+ "block",
216
+ "flex",
217
+ "grid",
218
+ "inline-block",
219
+ "inline-flex",
220
+ "inline-grid",
221
+ "table",
222
+ "list-item",
223
+ ];
224
+ return blockDisplays.includes(display);
225
+ },
226
+ [],
227
+ );
228
+
229
+ /**
230
+ * 要素が真のインライン要素(テキストフロー内に存在し、絶対配置すると壊れる)かを判定
231
+ * 例: <p>テスト<span>テスト</span>テスト</p> のspan
232
+ */
233
+ const isTrueInlineInTextFlow = useCallback(
234
+ (element: HTMLElement, iframeDoc: Document): boolean => {
235
+ // data-inline属性がなければfalse
236
+ if (element.getAttribute("data-inline") !== "true") {
237
+ return false;
238
+ }
239
+
240
+ // <a>タグは常に選択可能
241
+ if (element.tagName === "A") {
242
+ return false;
243
+ }
244
+
245
+ // 視覚的にブロックレベルならfalse(選択可能)
246
+ if (isVisuallyBlockLevel(element, iframeDoc)) {
247
+ return false;
248
+ }
249
+
250
+ // 親がテキスト要素で、兄弟にテキストノードがある場合は真のインライン
251
+ const parent = element.parentElement;
252
+ if (!parent) return false;
253
+
254
+ const textContainerTags = [
255
+ "P",
256
+ "H1",
257
+ "H2",
258
+ "H3",
259
+ "H4",
260
+ "H5",
261
+ "H6",
262
+ "LI",
263
+ "TD",
264
+ "TH",
265
+ "LABEL",
266
+ "SPAN",
267
+ ];
268
+ if (textContainerTags.includes(parent.tagName)) {
269
+ // 兄弟にテキストノードがあるかチェック
270
+ for (const sibling of parent.childNodes) {
271
+ if (
272
+ sibling.nodeType === Node.TEXT_NODE &&
273
+ sibling.textContent?.trim()
274
+ ) {
275
+ return true; // テキストフロー内の真のインライン要素
276
+ }
277
+ }
278
+ }
279
+
280
+ return false;
281
+ },
282
+ [isVisuallyBlockLevel],
283
+ );
284
+
285
+ /**
286
+ * 編集可能な要素を取得(インライン要素の場合は親を返す)
287
+ */
288
+ const getEditableElement = useCallback(
289
+ (target: EventTarget | null, iframeDoc: Document): HTMLElement | null => {
290
+ if (!target) return null;
291
+ const el = target as HTMLElement;
292
+ if (
293
+ !el.nodeType ||
294
+ el.nodeType !== 1 ||
295
+ typeof el.getAttribute !== "function"
296
+ )
297
+ return null;
298
+
299
+ // selection-box およびその子要素は除外
300
+ if (el.closest(".selection-box")) {
301
+ return null;
302
+ }
303
+
304
+ // 直接data-editableを持っているか
305
+ if (el.getAttribute("data-editable") === "true") {
306
+ // 真のインライン要素(テキストフロー内)の場合は親のブロック要素を探す
307
+ if (isTrueInlineInTextFlow(el, iframeDoc)) {
308
+ let parent = el.parentElement;
309
+ while (parent && parent !== iframeDoc.body) {
310
+ if (
311
+ parent.getAttribute("data-editable") === "true" &&
312
+ !isTrueInlineInTextFlow(parent, iframeDoc)
313
+ ) {
314
+ return parent;
315
+ }
316
+ parent = parent.parentElement;
317
+ }
318
+ return null; // 親のブロック要素が見つからない
319
+ }
320
+ return el;
321
+ }
322
+
323
+ // 親要素を探索(真のインライン要素をスキップ)
324
+ if (typeof el.closest === "function") {
325
+ let current: HTMLElement | null = el;
326
+ while (current && current !== iframeDoc.body) {
327
+ if (
328
+ current.getAttribute("data-editable") === "true" &&
329
+ !isTrueInlineInTextFlow(current, iframeDoc)
330
+ ) {
331
+ return current;
332
+ }
333
+ current = current.parentElement;
334
+ }
335
+ }
336
+ return null;
337
+ },
338
+ [isTrueInlineInTextFlow],
339
+ );
340
+
341
+ /**
342
+ * [移植時の追加] Figmaと同じ「選択コンテキスト」。
343
+ *
344
+ * Figmaの選択は次の規則で動く:
345
+ * - クリック … いま入っているコンテナ(既定はページ)の**直下の子**を選ぶ。ページ自体は選ばない
346
+ * - ダブルクリック … そのコンテナの中へ**1段だけ**入る(繰り返すと深くなる)
347
+ * - Cmd/Ctrl+click … 階層を無視して**最下層**を直接選ぶ
348
+ * - 空白をクリック … 選択解除してコンテキストをページへ戻す
349
+ *
350
+ * ここでは「スライドの面」をページとみなす。面自体を選ばせないことで、
351
+ * 1クリック目にスライド全体のdivが選ばれてしまう問題を解消する。
352
+ */
353
+ const selectionContextRef = useRef<HTMLElement | null>(null);
354
+
355
+ /** スライドの面(=Figmaのページ相当)。これ自身は選択対象にしない */
356
+ const getCanvasRoot = useCallback((iframeDoc: Document): HTMLElement => {
357
+ let root: HTMLElement = iframeDoc.getElementById("artboard") ?? iframeDoc.body;
358
+ // #artboard > スライド本体(1920x1080) のようなラッパーは、面として読み飛ばす
359
+ for (let depth = 0; depth < 4; depth++) {
360
+ // 選択枠などエディタが注入するUIは「中身」に数えない
361
+ // (数えるとラッパー判定が崩れ、選択の基準がスライドの面から#artboardへずれる)
362
+ const kids = Array.from(root.children).filter(
363
+ (c): c is HTMLElement =>
364
+ c.nodeType === 1 &&
365
+ !c.classList.contains("selection-box") &&
366
+ !c.classList.contains("marquee-selection-box") &&
367
+ !c.hasAttribute("data-editor-overlay") &&
368
+ c.tagName !== "STYLE" &&
369
+ c.tagName !== "SCRIPT",
370
+ );
371
+ const fillsParent = (c: HTMLElement) =>
372
+ c.offsetWidth >= root.clientWidth * 0.95 &&
373
+ c.offsetHeight >= root.clientHeight * 0.95;
374
+
375
+ // [修正] 以前は「子がちょうど1つ」のときしか面を見つけられなかった。
376
+ // エディタで挿入した図形・画像は面の**兄弟**(#artboard直下)に入るため、
377
+ // 1つでも挿入した時点で面の判定が崩れ、基準が #artboard へずれていた。
378
+ // その状態では1クリックでスライドの面そのものが選ばれ、
379
+ // 面は data-element-id を持たないので「選択枠は出るが何も操作できない」
380
+ // (React側の選択は空のままでプロパティパネルも空)状態になっていた。
381
+ // 面は「エディタが編集対象にしない器」= data-editable が付かない要素なので、
382
+ // 挿入物を除いてから探す。
383
+ let face = kids.find(
384
+ (c) => c.getAttribute("data-editable") !== "true" && fillsParent(c),
385
+ );
386
+ // 古い保存HTMLでは面にも data-editable が残っていることがある。
387
+ // その場合だけ、従来どおり「親を埋める唯一の子」を面とみなす
388
+ if (!face && kids.length === 1 && fillsParent(kids[0])) face = kids[0];
389
+ if (!face) break;
390
+ root = face;
391
+ }
392
+ return root;
393
+ }, []);
394
+
395
+ /**
396
+ * クリック位置の要素を、いまのコンテキストの直下の階層へ引き上げる
397
+ *
398
+ * @param options.readonly true のとき selectionContextRef を書き換えない。
399
+ * ホバー予告はマウスを動かすたびに呼ばれるので、ここでコンテキストを
400
+ * リセットしてしまうと「ダブルクリックで潜った階層」がマウス移動だけで
401
+ * 失われる。予告は必ず readonly で呼ぶこと。
402
+ */
403
+ const resolveByContext = useCallback(
404
+ (
405
+ hit: HTMLElement,
406
+ iframeDoc: Document,
407
+ options?: { readonly?: boolean },
408
+ ): HTMLElement => {
409
+ const canvasRoot = getCanvasRoot(iframeDoc);
410
+ let context = selectionContextRef.current;
411
+ // コンテキストが外れている(消えた/別の枝をクリックした)ならページへ戻す
412
+ if (!context || !context.isConnected || context === hit || !context.contains(hit)) {
413
+ context = canvasRoot;
414
+ if (!options?.readonly) selectionContextRef.current = null;
415
+ }
416
+ if (!context.contains(hit)) return hit;
417
+
418
+ let current: HTMLElement = hit;
419
+ while (current.parentElement && current.parentElement !== context) {
420
+ if (current.parentElement === iframeDoc.body) break;
421
+ current = current.parentElement;
422
+ }
423
+ return current;
424
+ },
425
+ [getCanvasRoot],
426
+ );
427
+
428
+ /**
429
+ * 最上位(Top-Level)の編集可能要素を取得
430
+ */
431
+ const getTopLevelEditable = useCallback(
432
+ (element: HTMLElement, iframeDoc: Document): HTMLElement => {
433
+ let current = element;
434
+ while (
435
+ current.parentElement &&
436
+ current.parentElement !== iframeDoc.body &&
437
+ current.parentElement.getAttribute("data-editable") === "true"
438
+ ) {
439
+ current = current.parentElement;
440
+ }
441
+ return current;
442
+ },
443
+ [],
444
+ );
445
+
446
+ /**
447
+ * 要素のドラッグを開始
448
+ */
449
+ const startElementDrag = useCallback(
450
+ (element: HTMLElement, e: MouseEvent, iframeDoc: Document) => {
451
+ const computedStyle = iframeDoc.defaultView?.getComputedStyle(element);
452
+
453
+ // 真のインライン要素(テキストフロー内)はドラッグしない
454
+ if (isTrueInlineInTextFlow(element, iframeDoc)) {
455
+ console.log(
456
+ "[startElementDrag] Skipping true inline element:",
457
+ element.tagName,
458
+ element.getAttribute("data-element-id"),
459
+ );
460
+ return;
461
+ }
462
+
463
+ // インライン表示の要素は絶対配置に変換しない(ただしブロック/positioned要素は許可)
464
+ if (computedStyle && isInlineElement(element, computedStyle)) {
465
+ const position = computedStyle.position;
466
+ const display = computedStyle.display;
467
+ if (
468
+ position === "absolute" ||
469
+ position === "fixed" ||
470
+ display === "block" ||
471
+ display === "inline-block" ||
472
+ display === "flex" ||
473
+ display === "inline-flex" ||
474
+ display === "grid" ||
475
+ display === "inline-grid"
476
+ ) {
477
+ // ドラッグ許可、処理を続行
478
+ } else {
479
+ return; // 純粋なインライン要素はドラッグしない
480
+ }
481
+ }
482
+
483
+ // [移植時の修正] スライドのキャンバス自体は動かさない。
484
+ // 既存の保存済みHTMLに data-editable が残っているケースへの保険で、
485
+ // ここを掴むとスライドごと移動して中身が版面外へ消える
486
+ const artboard = iframeDoc.getElementById('artboard');
487
+ if (
488
+ artboard &&
489
+ element.parentElement === artboard &&
490
+ element.offsetWidth >= artboard.clientWidth - 2 &&
491
+ element.offsetHeight >= artboard.clientHeight - 2
492
+ ) {
493
+ return;
494
+ }
495
+
496
+ // [座標基準の一本化]
497
+ // 従来はここで `parseFloat(element.style.left) || 0` を使っていたため、
498
+ // Tailwindの任意値クラス(`absolute left-[96px]`)だけで配置された要素は
499
+ // origin が 0 と誤読され、移動量に「その要素自身の座標 × ズーム倍率」の誤差が乗っていた。
500
+ // 単一ドラッグと複数ドラッグで挙動が分岐しないよう、
501
+ // dom-utils.prepareElementDragOrigin(offsetLeft/offsetTop ベース)に集約する。
502
+ // Skip absolute positioning conversion in component edit mode
503
+ // to preserve the component's natural layout
504
+ const isComponentEditMode = iframeDoc.body.classList.contains(
505
+ "component-edit-mode",
506
+ );
507
+ const { left: computedLeft, top: computedTop } = prepareElementDragOrigin(
508
+ element,
509
+ iframeDoc,
510
+ {
511
+ // [選択で動かさない] mousedown では**採寸だけ**する。
512
+ // ここで絶対配置へ変換すると要素が流れから外れ、後続の兄弟が一斉に詰め上がるため、
513
+ // クリックして選んだだけで版面が動いて見える。
514
+ // 変換は「しきい値を超えて実際に動き始めた時点」(useDragResize)で行う。
515
+ convertToAbsolute: false,
516
+ },
517
+ );
518
+
519
+ dragStateRef.current = {
520
+ element,
521
+ elements: [element],
522
+ startX: e.clientX,
523
+ startY: e.clientY,
524
+ origLeft: computedLeft,
525
+ origTop: computedTop,
526
+ origPositions: [{ left: computedLeft, top: computedTop }],
527
+ isDragging: true,
528
+ hasMoved: false,
529
+ // Flex reorder mode (auto-layout)
530
+ flexReorderMode: false,
531
+ flexParent: null,
532
+ originalIndex: -1,
533
+ targetIndex: -1,
534
+ // Enhanced auto-layout drag (hierarchy change support)
535
+ autoLayoutDragMode: false,
536
+ dragGhost: null,
537
+ originalParent: null,
538
+ currentDropTarget: null,
539
+ dropPosition: null,
540
+ dropIndex: -1,
541
+ };
542
+ },
543
+ [dragStateRef, layoutModeRef, isTrueInlineInTextFlow],
544
+ );
545
+
546
+ /**
547
+ * 複数選択の「群ドラッグ」を開始する
548
+ *
549
+ * [なぜ共通化したか]
550
+ * 従来は selection-box 経由 / 選択済み要素の内部クリック経由 / 通常クリック経由 の
551
+ * 3箇所に同じコードが重複しており、いずれも
552
+ * `origPositions = selectedElements.map(el => ({ left: parseFloat(el.style.left) || 0, ... }))`
553
+ * という壊れた座標基準を持っていた。
554
+ * 座標基準の決定・親子の重複除外・static→absolute 変換はすべて
555
+ * dom-utils.prepareElementsForGroupDrag に集約する。
556
+ *
557
+ * @returns 群ドラッグを開始できた場合 true
558
+ */
559
+ const startGroupDrag = useCallback(
560
+ (
561
+ selectedElements: HTMLElement[],
562
+ e: MouseEvent,
563
+ iframeDoc: Document,
564
+ ): boolean => {
565
+ const prepared = prepareElementsForGroupDrag(
566
+ selectedElements,
567
+ iframeDoc,
568
+ layoutModeRef.current,
569
+ );
570
+
571
+ if (!prepared) {
572
+ // オートレイアウト中のフロー要素は、絶対配置へ変換すると未選択の兄弟まで
573
+ // リフローしてスライド全体が崩れる。黙って1要素だけ動かすより、
574
+ // 「絶対配置モードに切り替えてください」というヒントを出すほうが正直。
575
+ dragStateRef.current.isDragging = false; // 前回の状態が残っていても動かさない
576
+ setShowLayoutHintRef?.current?.(true);
577
+ return false;
578
+ }
579
+
580
+ dragStateRef.current = {
581
+ element: prepared.elements[0],
582
+ elements: prepared.elements,
583
+ startX: e.clientX,
584
+ startY: e.clientY,
585
+ origLeft: prepared.origPositions[0].left,
586
+ origTop: prepared.origPositions[0].top,
587
+ origPositions: prepared.origPositions,
588
+ isDragging: true,
589
+ hasMoved: false,
590
+ // Flex reorder mode (auto-layout)
591
+ flexReorderMode: false,
592
+ flexParent: null,
593
+ originalIndex: -1,
594
+ targetIndex: -1,
595
+ // Enhanced auto-layout drag (hierarchy change support)
596
+ autoLayoutDragMode: false,
597
+ dragGhost: null,
598
+ originalParent: null,
599
+ currentDropTarget: null,
600
+ dropPosition: null,
601
+ dropIndex: -1,
602
+ };
603
+
604
+ // 群移動で right/bottom を無効化したり absolute へ変換したりすると
605
+ // 表示位置が微調整されるため、枠の位置を同期させる。
606
+ // ここで refreshSelectionOverlay(作り直し)を使うと、パンくずをmousedownした
607
+ // 直後にそのノードが消えて click が発火しなくなるため、位置同期にとどめる。
608
+ syncSelectionOverlayRects(iframeDoc);
609
+ return true;
610
+ },
611
+ [dragStateRef, layoutModeRef, setShowLayoutHintRef],
612
+ );
613
+
614
+ // ========================================
615
+ // 選択状態の書き換え(.selected を唯一の正とする)
616
+ // ========================================
617
+
618
+ /** いま .selected が付いている要素(DOMを正とする) */
619
+ const getSelectedEls = useCallback(
620
+ (iframeDoc: Document): HTMLElement[] =>
621
+ Array.from(
622
+ iframeDoc.querySelectorAll<HTMLElement>("[data-element-id].selected"),
623
+ ),
624
+ [],
625
+ );
626
+
627
+ /** .selected から選択IDリストを作り直して React 側へ反映する */
628
+ const commitSelection = useCallback(
629
+ (iframeDoc: Document, focusEl?: HTMLElement | null) => {
630
+ const els = getSelectedEls(iframeDoc);
631
+ const ids = els
632
+ .map((el) => el.getAttribute("data-element-id") || "")
633
+ .filter(Boolean);
634
+ setSelectedElementIdsRef.current(ids);
635
+ refreshSelectionOverlay(iframeDoc);
636
+ if (ids.length === 0) {
637
+ setSelectedElementRef.current(null);
638
+ window.postMessage({ type: "ELEMENT_DESELECTED" }, "*");
639
+ return;
640
+ }
641
+ const target = focusEl && focusEl.isConnected ? focusEl : els[els.length - 1];
642
+ if (target) sendElementInfo(target, iframeDoc);
643
+ },
644
+ [getSelectedEls, sendElementInfo],
645
+ );
646
+
647
+ /** 単独選択にする */
648
+ const selectSingle = useCallback(
649
+ (iframeDoc: Document, element: HTMLElement) => {
650
+ iframeDoc
651
+ .querySelectorAll(".selected")
652
+ .forEach((el) => el.classList.remove("selected"));
653
+ element.classList.add("selected");
654
+ commitSelection(iframeDoc, element);
655
+ },
656
+ [commitSelection],
657
+ );
658
+
659
+ /** 選択を全解除する */
660
+ const clearSelection = useCallback(
661
+ (iframeDoc: Document) => {
662
+ iframeDoc
663
+ .querySelectorAll(".selected")
664
+ .forEach((el) => el.classList.remove("selected"));
665
+ selectionContextRef.current = null;
666
+ commitSelection(iframeDoc);
667
+ },
668
+ [commitSelection],
669
+ );
670
+
671
+ /**
672
+ * Shift+クリックのトグルを適用する
673
+ *
674
+ * [階層ルール] 祖先と子孫が同時に選択されると、群バウンディングボックスも
675
+ * 群ドラッグ(親が動けば子も動く)も破綻する。そのため
676
+ * - 祖先が選択済みなら子孫は入れない(何も起きない)
677
+ * - 子孫が選択済みなら、祖先を足すときに子孫を外す
678
+ * とする。
679
+ */
680
+ const applyShiftToggle = useCallback(
681
+ (iframeDoc: Document, element: HTMLElement) => {
682
+ const selected = getSelectedEls(iframeDoc);
683
+
684
+ if (element.classList.contains("selected")) {
685
+ element.classList.remove("selected");
686
+ commitSelection(iframeDoc);
687
+ return;
688
+ }
689
+
690
+ const ancestors = selected.filter((s) => s !== element && s.contains(element));
691
+ if (ancestors.length > 0) {
692
+ // 祖先が選択済みのまま子孫を足すと群の境界が壊れるので、両立はさせない。
693
+ // 従来はここで何もせず黙っていたため「なぜか選択できない」に見えた。
694
+ // Figmaと同じく、祖先を外して子孫へ入れ替える(他の選択は保つ)
695
+ ancestors.forEach((s) => s.classList.remove("selected"));
696
+ }
697
+ selected.forEach((s) => {
698
+ if (s !== element && element.contains(s)) s.classList.remove("selected");
699
+ });
700
+ element.classList.add("selected");
701
+ commitSelection(iframeDoc, element);
702
+ },
703
+ [getSelectedEls, commitSelection],
704
+ );
705
+
706
+ /**
707
+ * 押した点を内側に含む「選択済み要素」を返す
708
+ *
709
+ * 群のメンバーを掴んだときにコンテキスト解決で祖先へ引き上げてしまうと、
710
+ * 「掴んだ要素」と「選択済み要素」が食い違って群が壊れる。掴み判定は
711
+ * 生の座標ターゲットから選択済み要素を直接引く。
712
+ */
713
+ const findSelectedHit = useCallback(
714
+ (rawTarget: HTMLElement, iframeDoc: Document): HTMLElement | null => {
715
+ for (const el of getSelectedEls(iframeDoc)) {
716
+ if (el === rawTarget || el.contains(rawTarget)) return el;
717
+ }
718
+ return null;
719
+ },
720
+ [getSelectedEls],
721
+ );
722
+
723
+ /**
724
+ * 「いまクリックしたら選択されるであろう要素」(ホバー予告用)
725
+ * mousedown の役割決定とまったく同じ順序で解決する
726
+ */
727
+ const resolveHoverTarget = useCallback(
728
+ (
729
+ target: EventTarget | null,
730
+ iframeDoc: Document,
731
+ opts: { meta: boolean },
732
+ ): HTMLElement | null => {
733
+ const hit = getEditableElement(target, iframeDoc);
734
+ if (!hit) return null;
735
+ if (opts.meta) return hit; // Cmd/Ctrl は最深要素
736
+ const rawTarget = target as HTMLElement;
737
+ const selectedHit =
738
+ rawTarget && typeof rawTarget.closest === "function"
739
+ ? findSelectedHit(rawTarget, iframeDoc)
740
+ : null;
741
+ if (selectedHit) return selectedHit;
742
+ return resolveByContext(hit, iframeDoc, { readonly: true });
743
+ },
744
+ [getEditableElement, findSelectedHit, resolveByContext],
745
+ );
746
+
747
+ /**
748
+ * mouseup で Shift の意味と「群 → 単独」を確定させる
749
+ * 閾値を超えて動いていたらドラッグだったとみなし、選択は一切変えない
750
+ */
751
+ const handleSelectionMouseUp = useCallback(
752
+ (e: MouseEvent, iframeDoc: Document) => {
753
+ const pending = pendingClickRef.current;
754
+ pendingClickRef.current = null;
755
+ if (!pending) return;
756
+ if (!pending.element.isConnected) return;
757
+
758
+ const moved =
759
+ Math.abs(e.clientX - pending.startX) > MARQUEE_DRAG_THRESHOLD ||
760
+ Math.abs(e.clientY - pending.startY) > MARQUEE_DRAG_THRESHOLD;
761
+ if (moved) return; // ドラッグ(Shiftなら軸拘束)だったのでトグルは取り消す
762
+
763
+ if (pending.shift) {
764
+ applyShiftToggle(iframeDoc, pending.element);
765
+ return;
766
+ }
767
+ // 群のメンバーを素クリック(動かさず離した)→ その1つだけの選択に落ちる
768
+ if (pending.wasMulti && pending.wasSelected) {
769
+ selectSingle(iframeDoc, pending.element);
770
+ }
771
+ },
772
+ [applyShiftToggle, selectSingle],
773
+ );
774
+
775
+ const setupSelectionListeners = useCallback(
776
+ (iframeDoc: Document) => {
777
+ /**
778
+ * マウスダウンイベントハンドラ
779
+ */
780
+ /**
781
+ * 群リサイズ(複数選択のバウンディングボックスのハンドルをドラッグ)。
782
+ *
783
+ * 群バウンディングボックスを基準に倍率を出し、各メンバーの位置と寸法を
784
+ * 同じ倍率でスケールする(Figmaと同じ。文字サイズは変えない)。
785
+ * 掴んだハンドルの反対側の辺・角が固定点。
786
+ * 単一リサイズの機構(resizeState)には相乗りせず、ここで完結させる。
787
+ */
788
+ const startGroupResize = (handleName: string, downEvent: MouseEvent) => {
789
+ const scale = getArtboardScale(iframeDoc) || 1;
790
+ const members = (
791
+ [...iframeDoc.querySelectorAll('.selected:not(.selection-box)')] as HTMLElement[]
792
+ ).map((el) => {
793
+ const r = el.getBoundingClientRect();
794
+ return {
795
+ el,
796
+ rect: r,
797
+ left: el.offsetLeft,
798
+ top: el.offsetTop,
799
+ w: el.offsetWidth,
800
+ h: el.offsetHeight,
801
+ };
802
+ });
803
+ if (members.length < 2) return;
804
+
805
+ const minL = Math.min(...members.map((m) => m.rect.left));
806
+ const minT = Math.min(...members.map((m) => m.rect.top));
807
+ const maxR = Math.max(...members.map((m) => m.rect.right));
808
+ const maxB = Math.max(...members.map((m) => m.rect.bottom));
809
+ const bboxW = maxR - minL;
810
+ const bboxH = maxB - minT;
811
+ if (bboxW < 1 || bboxH < 1) return;
812
+
813
+ const fitX = handleName.includes('e') || handleName.includes('w');
814
+ const fitY = handleName.includes('n') || handleName.includes('s');
815
+ const anchorX = handleName.includes('w') ? maxR : minL;
816
+ const anchorY = handleName.includes('n') ? maxB : minT;
817
+ const startX = downEvent.clientX;
818
+ const startY = downEvent.clientY;
819
+
820
+ const frameRect = () =>
821
+ (iframeDoc.defaultView?.frameElement as HTMLElement | null)?.getBoundingClientRect();
822
+
823
+ const applyAt = (clientX: number, clientY: number, shift: boolean) => {
824
+ const dx = clientX - startX;
825
+ const dy = clientY - startY;
826
+ let sx = fitX
827
+ ? (bboxW + (handleName.includes('w') ? -dx : dx)) / bboxW
828
+ : 1;
829
+ let sy = fitY
830
+ ? (bboxH + (handleName.includes('n') ? -dy : dy)) / bboxH
831
+ : 1;
832
+ // Shift: 角なら等比(変化の大きい軸に合わせる)
833
+ if (shift && fitX && fitY) {
834
+ const u = Math.abs(sx - 1) >= Math.abs(sy - 1) ? sx : sy;
835
+ sx = u;
836
+ sy = u;
837
+ }
838
+ sx = Math.max(0.05, sx);
839
+ sy = Math.max(0.05, sy);
840
+ members.forEach((m) => {
841
+ if (fitX || (shift && fitY)) {
842
+ const newClientLeft = anchorX + (m.rect.left - anchorX) * sx;
843
+ m.el.style.left = `${m.left + (newClientLeft - m.rect.left) / scale}px`;
844
+ m.el.style.width = `${Math.max(4, (m.w * sx))}px`;
845
+ }
846
+ if (fitY || (shift && fitX)) {
847
+ const newClientTop = anchorY + (m.rect.top - anchorY) * sy;
848
+ m.el.style.top = `${m.top + (newClientTop - m.rect.top) / scale}px`;
849
+ m.el.style.height = `${Math.max(4, (m.h * sy))}px`;
850
+ }
851
+ });
852
+ syncSelectionOverlayRects(iframeDoc);
853
+ };
854
+
855
+ const onIframeMove = (ev: MouseEvent) => applyAt(ev.clientX, ev.clientY, ev.shiftKey);
856
+ const onWinMove = (ev: MouseEvent) => {
857
+ // 親ウィンドウの座標を iframe 基準へ(カーソルが iframe の外へ出ても追従する)
858
+ const fr = frameRect();
859
+ if (!fr) return;
860
+ applyAt(ev.clientX - fr.left, ev.clientY - fr.top, ev.shiftKey);
861
+ };
862
+ const finish = () => {
863
+ iframeDoc.removeEventListener('mousemove', onIframeMove);
864
+ iframeDoc.removeEventListener('mouseup', finish);
865
+ window.removeEventListener('mousemove', onWinMove);
866
+ window.removeEventListener('mouseup', finish);
867
+ refreshSelectionOverlay(iframeDoc);
868
+ const first = members[0]?.el;
869
+ if (first) sendElementInfo(first, iframeDoc);
870
+ window.postMessage(
871
+ { type: 'SLIDE_CONTENT_CHANGED', html: getArtboardContent(iframeDoc) },
872
+ '*',
873
+ );
874
+ };
875
+ iframeDoc.addEventListener('mousemove', onIframeMove);
876
+ iframeDoc.addEventListener('mouseup', finish);
877
+ window.addEventListener('mousemove', onWinMove);
878
+ window.addEventListener('mouseup', finish);
879
+ };
880
+
881
+ const handleMouseDown = (e: MouseEvent) => {
882
+ // トリミング中は選択・ドラッグを一切動かさない(crop-mode.ts が全操作を持つ)
883
+ if (iframeDoc.body.classList.contains("gg-cropping")) return;
884
+
885
+ // コンテキストメニューを閉じる(右クリック以外)
886
+ if (e.button !== 2) {
887
+ window.postMessage({ type: "IFRAME_CLICK" }, "*");
888
+ }
889
+
890
+ // 右クリックはここで打ち切る。
891
+ // 以前は左クリックと同じ経路を通っていたため、複数選択したまま右クリック
892
+ // すると「掴んだ1個だけの選択」に畳まれ、メニューが単独要素にしか
893
+ // 効かなくなっていた。選択の切り替えが必要なとき(選択外を右クリック)
894
+ // だけ contextmenu ハンドラ側で単独選択にする。
895
+ if (e.button === 2) return;
896
+
897
+ // テキスト編集中の要素があれば、フォーカスを解除して編集を終了
898
+ const editingElement = iframeDoc.querySelector(
899
+ '[contenteditable="true"]',
900
+ ) as HTMLElement | null;
901
+ if (
902
+ editingElement &&
903
+ editingElement !== e.target &&
904
+ !editingElement.contains(e.target as Node)
905
+ ) {
906
+ editingElement.blur();
907
+ editingElement.classList.remove("editing");
908
+ editingElement.removeAttribute("contenteditable");
909
+
910
+ // テキスト選択をクリア
911
+ const selection = iframeDoc.getSelection();
912
+ if (selection) {
913
+ selection.removeAllRanges();
914
+ }
915
+
916
+ // 選択ボックスを再表示(まだ選択されている場合)
917
+ if (editingElement.classList.contains("selected")) {
918
+ updateSelectionBox(iframeDoc, editingElement);
919
+ }
920
+ console.log("[Canvas] Text editing exited via click elsewhere");
921
+ }
922
+
923
+ // 描画モード・テキストモードでは選択しない
924
+ const bodyClasses = iframeDoc.body.classList;
925
+ if (
926
+ bodyClasses.contains("draw-mode") ||
927
+ bodyClasses.contains("text-mode")
928
+ )
929
+ return;
930
+
931
+ // リサイズハンドル・回転ハンドルのクリック処理
932
+ const clickedElement = e.target as HTMLElement;
933
+ const handleType = clickedElement.getAttribute?.("data-handle");
934
+ if (handleType) {
935
+ e.preventDefault();
936
+ e.stopPropagation();
937
+
938
+ // ダブルクリック = 中身にフィット(Figmaと同じ)。
939
+ // 右辺なら幅を最長行へ、下辺なら高さへ、角なら両方。反対側の辺は固定
940
+ const prevDown = lastHandleDownRef.current;
941
+ lastHandleDownRef.current = {
942
+ handle: handleType, time: Date.now(), x: e.clientX, y: e.clientY,
943
+ };
944
+ if (
945
+ prevDown &&
946
+ prevDown.handle === handleType &&
947
+ Date.now() - prevDown.time < 400 &&
948
+ Math.abs(e.clientX - prevDown.x) < 4 &&
949
+ Math.abs(e.clientY - prevDown.y) < 4 &&
950
+ !handleType.startsWith("rotate") &&
951
+ handleType !== "radius"
952
+ ) {
953
+ lastHandleDownRef.current = null;
954
+ const fitTarget = iframeDoc.querySelector(
955
+ ".selected:not(.selection-box)",
956
+ ) as HTMLElement | null;
957
+ if (fitTarget) {
958
+ const { changed } = fitElementToContent(fitTarget, iframeDoc, handleType);
959
+ if (changed) {
960
+ updateSelectionBox(iframeDoc, fitTarget);
961
+ sendElementInfo(fitTarget, iframeDoc);
962
+ window.postMessage(
963
+ { type: "SLIDE_CONTENT_CHANGED", html: getArtboardContent(iframeDoc) },
964
+ "*",
965
+ );
966
+ }
967
+ }
968
+ return;
969
+ }
970
+
971
+ // 群バウンディングボックスのハンドル(elementIdを持たない)
972
+ if (clickedElement.getAttribute("data-group-handle") === "true") {
973
+ startGroupResize(handleType, e);
974
+ return;
975
+ }
976
+
977
+ const elementId = clickedElement.getAttribute("data-element-id");
978
+ // data-editable="true"を追加してリサイズハンドル自体を選択しないようにする
979
+ const targetEl = elementId
980
+ ? (iframeDoc.querySelector(
981
+ `[data-editable="true"][data-element-id="${elementId}"]`,
982
+ ) as HTMLElement)
983
+ : null;
984
+
985
+ if (targetEl) {
986
+ // リサイズ/回転の開始(選択は維持)
987
+ const rect = targetEl.getBoundingClientRect();
988
+ const computedStyle =
989
+ iframeDoc.defaultView?.getComputedStyle(targetEl);
990
+ const currentTransform = computedStyle?.transform || "";
991
+ const rotateMatch = currentTransform.match(/rotate\(([^)]+)deg\)/);
992
+ const currentRotation = rotateMatch
993
+ ? parseFloat(rotateMatch[1])
994
+ : 0;
995
+
996
+ // ズームスケールを考慮(getBoundingClientRectはスケール後の値を返す)
997
+ const scale = zoomRef.current / 100;
998
+ const actualWidth = rect.width / scale;
999
+ const actualHeight = rect.height / scale;
1000
+
1001
+ // 回転ハンドルの場合
1002
+ if (handleType.startsWith("rotate-")) {
1003
+ const centerX = rect.left + rect.width / 2;
1004
+ const centerY = rect.top + rect.height / 2;
1005
+ const startAngle =
1006
+ Math.atan2(e.clientY - centerY, e.clientX - centerX) *
1007
+ (180 / Math.PI);
1008
+
1009
+ resizeStateRef.current = {
1010
+ isResizing: false,
1011
+ isRotating: true,
1012
+ element: targetEl,
1013
+ elements: [],
1014
+ handle: handleType,
1015
+ startX: e.clientX,
1016
+ startY: e.clientY,
1017
+ origLeft: parseFloat(targetEl.style.left) || 0,
1018
+ origTop: parseFloat(targetEl.style.top) || 0,
1019
+ origWidth: actualWidth,
1020
+ origHeight: actualHeight,
1021
+ origRadius: 0,
1022
+ selectionBounds: null,
1023
+ origElementStates: [],
1024
+ origScaleX: 1,
1025
+ origScaleY: 1,
1026
+ rotation: currentRotation,
1027
+ rotationStartAngle: startAngle,
1028
+ centerX,
1029
+ centerY,
1030
+ };
1031
+ iframeDoc.body.classList.add("rotating");
1032
+ console.log("[Canvas] Rotation started:", handleType);
1033
+ } else if (handleType === "radius") {
1034
+ // 角丸ハンドル
1035
+ const borderRadius =
1036
+ parseFloat(computedStyle?.borderRadius || "0") || 0;
1037
+ resizeStateRef.current = {
1038
+ isResizing: true,
1039
+ isRotating: false,
1040
+ element: targetEl,
1041
+ elements: [],
1042
+ handle: "radius",
1043
+ startX: e.clientX,
1044
+ startY: e.clientY,
1045
+ origLeft: parseFloat(targetEl.style.left) || 0,
1046
+ origTop: parseFloat(targetEl.style.top) || 0,
1047
+ origWidth: actualWidth,
1048
+ origHeight: actualHeight,
1049
+ origRadius: borderRadius,
1050
+ selectionBounds: null,
1051
+ origElementStates: [],
1052
+ origScaleX: 1,
1053
+ origScaleY: 1,
1054
+ rotation: currentRotation,
1055
+ rotationStartAngle: 0,
1056
+ centerX: 0,
1057
+ centerY: 0,
1058
+ };
1059
+ console.log("[Canvas] Border radius resize started");
1060
+ } else {
1061
+ // 通常のリサイズハンドル
1062
+ resizeStateRef.current = {
1063
+ isResizing: true,
1064
+ isRotating: false,
1065
+ element: targetEl,
1066
+ elements: [],
1067
+ handle: handleType,
1068
+ startX: e.clientX,
1069
+ startY: e.clientY,
1070
+ origLeft: parseFloat(targetEl.style.left) || 0,
1071
+ origTop: parseFloat(targetEl.style.top) || 0,
1072
+ origWidth: actualWidth,
1073
+ origHeight: actualHeight,
1074
+ origRadius: 0,
1075
+ selectionBounds: null,
1076
+ origElementStates: [],
1077
+ origScaleX: 1,
1078
+ origScaleY: 1,
1079
+ rotation: currentRotation,
1080
+ rotationStartAngle: 0,
1081
+ centerX: 0,
1082
+ centerY: 0,
1083
+ };
1084
+ console.log("[Canvas] Resize started:", handleType);
1085
+ }
1086
+ }
1087
+ return;
1088
+ }
1089
+
1090
+ // 【最優先チェック】生のe.targetで選択済み要素内のクリックを検出
1091
+ // getEditableElementを呼ぶ前にチェックすることで、
1092
+ // DOM変換による検出漏れを防ぐ
1093
+ const rawTarget = e.target as HTMLElement;
1094
+ const existingIds = selectedElementIdsRef.current;
1095
+
1096
+ // selection-boxをクリックした場合、対象要素のドラッグを開始
1097
+ const selectionBox = rawTarget.closest(
1098
+ ".selection-box",
1099
+ ) as HTMLElement | null;
1100
+ if (selectionBox && !e.shiftKey) {
1101
+ const forElementId = selectionBox.getAttribute("data-for-element");
1102
+ if (forElementId) {
1103
+ const targetEl = iframeDoc.querySelector(
1104
+ `[data-element-id="${forElementId}"]`,
1105
+ ) as HTMLElement | null;
1106
+
1107
+ if (targetEl) {
1108
+ e.preventDefault();
1109
+ e.stopPropagation();
1110
+
1111
+ // 複数選択の場合は複数ドラッグ
1112
+ if (existingIds.length > 1) {
1113
+ const selectedElements = existingIds
1114
+ .map(
1115
+ (id) =>
1116
+ iframeDoc.querySelector(
1117
+ `[data-element-id="${id}"]`,
1118
+ ) as HTMLElement,
1119
+ )
1120
+ .filter((el) => el !== null);
1121
+
1122
+ if (selectedElements.length > 0) {
1123
+ const started = startGroupDrag(
1124
+ selectedElements,
1125
+ e,
1126
+ iframeDoc,
1127
+ );
1128
+ console.log(
1129
+ "[Canvas] Multi-element drag via selection-box:",
1130
+ existingIds,
1131
+ started ? "started" : "blocked",
1132
+ );
1133
+ }
1134
+ } else {
1135
+ // 単一選択時:対象要素をドラッグ
1136
+ startElementDrag(targetEl, e, iframeDoc);
1137
+ console.log(
1138
+ "[Canvas] Drag via selection-box for:",
1139
+ forElementId,
1140
+ );
1141
+ }
1142
+ return;
1143
+ }
1144
+ }
1145
+ }
1146
+
1147
+ // ============================================================
1148
+ // 【1本の判別器】①ヒットテスト → ②役割決定 → ③保留
1149
+ //
1150
+ // [なぜ1か所に集約したか]
1151
+ // 従来は「押した点が空白か編集可能要素か」を判別する前に修飾キーで
1152
+ // 分岐しており、Cmd が最初に return するせいで
1153
+ // - マーキーと Cmd リーフ選択が1本の経路を共有
1154
+ // - 素ドラッグにはマーキーが割り当てられていない
1155
+ // - Shift 分岐が resolveByContext を通らず Cmd と同じ結果になる
1156
+ // - 空白クリックが選択を消さない
1157
+ // という食い違いが生まれていた。役割は必ず「押した点」から決める。
1158
+ // ============================================================
1159
+
1160
+ // ---- ① ヒットテスト ----
1161
+ const hit = getEditableElement(e.target, iframeDoc);
1162
+ // 空白=押した点の祖先に編集可能要素がまったく無い状態。
1163
+ // #artboard / 紙面と同じ大きさの器 / #artboard-wrapper / #canvas-container は
1164
+ // initializeEditableElements が意図的に data-editable を付けないので
1165
+ // ここで自然に「空白」と判定される(IDの白リストは不要)。
1166
+ const isBlank =
1167
+ !hit &&
1168
+ !(typeof rawTarget?.closest === "function"
1169
+ ? rawTarget.closest('[data-editable="true"]')
1170
+ : null);
1171
+
1172
+ // ---- ② 役割決定 ----
1173
+
1174
+ // 空白: ドラッグ=マーキー / クリック=選択解除
1175
+ if (isBlank) {
1176
+ e.preventDefault();
1177
+ pendingClickRef.current = null;
1178
+ if (marqueeAdditiveRef) marqueeAdditiveRef.current = e.shiftKey;
1179
+ if (!e.shiftKey) {
1180
+ // Figma と同じく、空白を押した瞬間に選択は消える。
1181
+ // (従来は selectionContextRef を null にするだけで選択が残っていた)
1182
+ clearSelection(iframeDoc);
1183
+ }
1184
+ marqueeClickTargetRef.current = null;
1185
+ const geom: MarqueeState = {
1186
+ isActive: false,
1187
+ startX: e.clientX,
1188
+ startY: e.clientY,
1189
+ currentX: e.clientX,
1190
+ currentY: e.clientY,
1191
+ };
1192
+ // 同期の ref を先に確定させる(React state の反映待ちで始点がずれるのを防ぐ)
1193
+ if (marqueeGeomRef) marqueeGeomRef.current = { ...geom };
1194
+ setMarqueeStateRef.current(geom);
1195
+ marqueeStartPendingRef.current = true;
1196
+ console.log(
1197
+ "[Canvas] Marquee pending (blank) at",
1198
+ e.clientX,
1199
+ e.clientY,
1200
+ e.shiftKey ? "(additive)" : "",
1201
+ );
1202
+ return;
1203
+ }
1204
+
1205
+ // 編集可能な祖先はあるが解決できない(テキストフロー内の純インライン等)
1206
+ if (!hit) return;
1207
+ if (hit.getAttribute("contenteditable") === "true") return;
1208
+
1209
+ const meta = e.metaKey || e.ctrlKey;
1210
+
1211
+ // 押した点を含む「選択済み要素」。あればそれを掴んだものとして扱う。
1212
+ // (コンテキスト解決で祖先へ引き上げると、掴んだ要素と選択済み要素が
1213
+ // 食い違って群が壊れる)
1214
+ const selectedHit = findSelectedHit(rawTarget, iframeDoc);
1215
+
1216
+ let target: HTMLElement;
1217
+ if (meta) {
1218
+ // Cmd/Ctrl: 階層を無視して最深要素。
1219
+ // Shift併用なら「最深要素をトグル追加」(mouseupで確定)= Figmaの複数選択。
1220
+ // 従来 meta+shift をここから外していたため、選択済みの祖先を掴んだ扱いになり
1221
+ // 「追加」のつもりが「解除」になる・そもそも深い要素を足せない、が起きていた
1222
+ target = hit;
1223
+ } else if (selectedHit) {
1224
+ target = selectedHit; // 選択済みを掴んだ
1225
+ } else {
1226
+ // 素クリックも Shift+クリックも同じ resolveByContext を通す。
1227
+ // (従来 Shift は生の最深要素を使っていたため Cmd と同一結果だった)
1228
+ target = resolveByContext(hit, iframeDoc);
1229
+ }
1230
+
1231
+ if (target.getAttribute("contenteditable") === "true") {
1232
+ console.log("[Canvas] Skip - contenteditable active");
1233
+ return;
1234
+ }
1235
+
1236
+ e.preventDefault();
1237
+ e.stopPropagation();
1238
+
1239
+ const elementId = target.getAttribute("data-element-id") || "";
1240
+ const isSelected = target.classList.contains("selected");
1241
+
1242
+ // ---- ③ 保留 ----
1243
+ // Shift の意味(トグル / 軸拘束ドラッグ)と「群→単独の畳み込み」は
1244
+ // mouseup で移動量を見て確定させる
1245
+ pendingClickRef.current = {
1246
+ element: target,
1247
+ elementId,
1248
+ shift: e.shiftKey,
1249
+ wasSelected: isSelected,
1250
+ wasMulti: existingIds.length > 1,
1251
+ startX: e.clientX,
1252
+ startY: e.clientY,
1253
+ };
1254
+
1255
+ if (isSelected) {
1256
+ // 選択済みを掴んだ → 群/単独ドラッグを開始(Shift でも同じ)
1257
+ if (existingIds.length > 1) {
1258
+ const selectedElements = existingIds
1259
+ .map(
1260
+ (id) =>
1261
+ iframeDoc.querySelector(
1262
+ `[data-element-id="${id}"]`,
1263
+ ) as HTMLElement,
1264
+ )
1265
+ .filter((el) => el !== null);
1266
+ if (selectedElements.length > 0) {
1267
+ const started = startGroupDrag(selectedElements, e, iframeDoc);
1268
+ console.log(
1269
+ "[Canvas] Group drag:",
1270
+ existingIds.length,
1271
+ started ? "started" : "blocked",
1272
+ );
1273
+ }
1274
+ } else {
1275
+ startElementDrag(target, e, iframeDoc);
1276
+ console.log("[Canvas] Single drag start:", elementId);
1277
+ }
1278
+ return;
1279
+ }
1280
+
1281
+ if (e.shiftKey) {
1282
+ // 未選択 + Shift → 追加するかは mouseup で確定(ここでは動かさない)
1283
+ console.log("[Canvas] Shift add pending:", elementId);
1284
+ return;
1285
+ }
1286
+
1287
+ // 未選択 + 素クリック → その場で単独選択してドラッグ開始(モード非依存)
1288
+ selectSingle(iframeDoc, target);
1289
+ startElementDrag(target, e, iframeDoc);
1290
+ console.log("[Canvas] Select + drag start:", elementId);
1291
+ };
1292
+
1293
+ /**
1294
+ * 要素がテキスト編集可能かどうかを判定
1295
+ * - 直接テキストノードを含む場合
1296
+ * - または特定のテキスト系タグの場合
1297
+ */
1298
+ /** 中に文字を置ける図形か(線・ペンは除く) */
1299
+ const isTextBearingShape = (element: HTMLElement): boolean => {
1300
+ const t = element.getAttribute("data-shape-type");
1301
+ return !!t && !["line", "arrow", "pen", "pencil", "icon"].includes(t);
1302
+ };
1303
+
1304
+ const isTextEditable = (element: HTMLElement): boolean => {
1305
+ // 既に編集中なら無視
1306
+ if (element.getAttribute("contenteditable") === "true") return false;
1307
+
1308
+ // 図形は空でも文字を入れられる(PowerPointと同じ)
1309
+ if (isTextBearingShape(element)) return true;
1310
+
1311
+ // 画像やSVGは編集不可
1312
+ const tagName = element.tagName.toUpperCase();
1313
+ if (["IMG", "SVG", "VIDEO", "IFRAME", "CANVAS"].includes(tagName))
1314
+ return false;
1315
+
1316
+ // 直接テキストノードを含むかチェック
1317
+ for (const child of element.childNodes) {
1318
+ if (child.nodeType === Node.TEXT_NODE && child.textContent?.trim()) {
1319
+ return true;
1320
+ }
1321
+ }
1322
+
1323
+ // テキスト系タグで、子要素がテキストのみの場合も編集可能
1324
+ const textTags = [
1325
+ "P",
1326
+ "H1",
1327
+ "H2",
1328
+ "H3",
1329
+ "H4",
1330
+ "H5",
1331
+ "H6",
1332
+ "SPAN",
1333
+ "A",
1334
+ "LABEL",
1335
+ "LI",
1336
+ "TD",
1337
+ "TH",
1338
+ "BUTTON",
1339
+ ];
1340
+ if (textTags.includes(tagName) && element.textContent?.trim()) {
1341
+ return true;
1342
+ }
1343
+
1344
+ return false;
1345
+ };
1346
+
1347
+ /**
1348
+ * テキスト編集モードを開始
1349
+ */
1350
+ const enableTextEditing = (element: HTMLElement) => {
1351
+ // 図形に初めて文字を入れるときは、中央寄せと余白を用意する
1352
+ // (PowerPointの図形内テキストと同じ見え方にするため)
1353
+ if (isTextBearingShape(element) && !element.textContent?.trim()) {
1354
+ const s = element.style;
1355
+ s.display = "flex";
1356
+ s.alignItems = "center";
1357
+ s.justifyContent = "center";
1358
+ s.textAlign = "center";
1359
+ if (!s.padding) s.padding = "16px 24px";
1360
+ if (!s.color) s.color = "#ffffff";
1361
+ if (!s.fontSize) s.fontSize = "28px";
1362
+ if (!s.lineHeight) s.lineHeight = "1.4";
1363
+ s.overflowWrap = "anywhere";
1364
+ }
1365
+ element.setAttribute("contenteditable", "true");
1366
+ element.classList.add("editing");
1367
+
1368
+ // 選択ボックスを非表示にして編集に集中
1369
+ iframeDoc
1370
+ .querySelectorAll(".selection-box")
1371
+ .forEach((box) => box.remove());
1372
+
1373
+ // フォーカスを設定
1374
+ element.focus();
1375
+
1376
+ // テキスト全体を選択(オプション)
1377
+ const selection = iframeDoc.getSelection();
1378
+ if (selection) {
1379
+ const range = iframeDoc.createRange();
1380
+ range.selectNodeContents(element);
1381
+ selection.removeAllRanges();
1382
+ selection.addRange(range);
1383
+ }
1384
+ };
1385
+
1386
+ /**
1387
+ * ダブルクリックイベントハンドラ(Figmaスタイル)
1388
+ * - Ctrl/Cmd + ダブルクリック → テキスト編集開始
1389
+ * - 既に選択済みの要素をダブルクリック → テキスト編集開始
1390
+ * - それ以外 → ドリルダウン選択のみ
1391
+ */
1392
+ const handleDoubleClick = (e: MouseEvent) => {
1393
+ const bodyClasses = iframeDoc.body.classList;
1394
+ if (bodyClasses.contains("gg-cropping")) return;
1395
+ if (
1396
+ bodyClasses.contains("draw-mode") ||
1397
+ bodyClasses.contains("text-mode")
1398
+ )
1399
+ return;
1400
+
1401
+
1402
+ const isCtrlCmd = e.ctrlKey || e.metaKey;
1403
+ const deepElement = getEditableElement(e.target, iframeDoc);
1404
+
1405
+ if (!deepElement) return;
1406
+
1407
+ e.preventDefault();
1408
+ e.stopPropagation();
1409
+
1410
+ const elementId = deepElement.getAttribute("data-element-id") || "";
1411
+ const currentSelectedIds = selectedElementIdsRef.current;
1412
+ const isAlreadySelected = currentSelectedIds.includes(elementId);
1413
+
1414
+ // Ctrl/Cmd + ダブルクリック、または既に選択済みの要素をダブルクリック
1415
+ // → テキスト編集可能ならテキスト編集開始
1416
+ if ((isCtrlCmd || isAlreadySelected) && isTextEditable(deepElement)) {
1417
+ // 既存の選択をクリア
1418
+ iframeDoc
1419
+ .querySelectorAll(".selected")
1420
+ .forEach((el) => el.classList.remove("selected"));
1421
+ iframeDoc
1422
+ .querySelectorAll(".selection-box")
1423
+ .forEach((box) => box.remove());
1424
+
1425
+ deepElement.classList.add("selected");
1426
+ setSelectedElementIdsRef.current([elementId]);
1427
+ enableTextEditing(deepElement);
1428
+ console.log(
1429
+ "[Canvas] Text editing started via double-click:",
1430
+ elementId,
1431
+ );
1432
+ sendElementInfo(deepElement, iframeDoc);
1433
+ return;
1434
+ }
1435
+
1436
+ // それ以外 → **必ず枠が変わる段まで**潜る(Figmaのダブルクリック相当)
1437
+ //
1438
+ // [なぜ「1段ずつ」をやめたか]
1439
+ // DOM を1段ずつ潜ると、親と同じ矩形のラッパー div(レイアウト用の
1440
+ // 中間ノード)を踏んだ段では選択枠が1pxも動かず、
1441
+ // 「ダブルクリックしても何も起きない」ように見える。
1442
+ // 矩形が起点と一致する段は読み飛ばし、枠が変わる段で止める。
1443
+ const selectedNow = currentSelectedIds[0]
1444
+ ? iframeDoc.querySelector<HTMLElement>(
1445
+ `[data-element-id="${currentSelectedIds[0]}"]`,
1446
+ )
1447
+ : null;
1448
+
1449
+ // 潜る起点:いまの選択がクリック位置を含むならそれ、
1450
+ // そうでなければ「素クリックで選ばれる要素」
1451
+ const base =
1452
+ selectedNow &&
1453
+ selectedNow !== deepElement &&
1454
+ selectedNow.contains(deepElement)
1455
+ ? selectedNow
1456
+ : resolveByContext(deepElement, iframeDoc);
1457
+
1458
+ let drilled: HTMLElement = deepElement;
1459
+ if (base !== deepElement && base.contains(deepElement)) {
1460
+ const baseRect = base.getBoundingClientRect();
1461
+ // 画面上2px未満の差は「枠が動いていない」と同じに見えるので同一扱いにする。
1462
+ // (getBoundingClientRect はズーム後の実寸なので、この2pxは見た目の2px)
1463
+ const SAME_RECT_TOLERANCE = 2;
1464
+ const sameRect = (el: HTMLElement) => {
1465
+ const r = el.getBoundingClientRect();
1466
+ return (
1467
+ Math.abs(r.left - baseRect.left) < SAME_RECT_TOLERANCE &&
1468
+ Math.abs(r.top - baseRect.top) < SAME_RECT_TOLERANCE &&
1469
+ Math.abs(r.width - baseRect.width) < SAME_RECT_TOLERANCE &&
1470
+ Math.abs(r.height - baseRect.height) < SAME_RECT_TOLERANCE
1471
+ );
1472
+ };
1473
+
1474
+ selectionContextRef.current = base;
1475
+ drilled = resolveByContext(deepElement, iframeDoc);
1476
+
1477
+ let guard = 0;
1478
+ while (drilled !== deepElement && sameRect(drilled) && guard++ < 20) {
1479
+ selectionContextRef.current = drilled;
1480
+ const next = resolveByContext(deepElement, iframeDoc);
1481
+ if (next === drilled) break;
1482
+ drilled = next;
1483
+ }
1484
+ // 次のクリック/ダブルクリックのために、コンテキストは
1485
+ // 「選ばれた要素の親」=いま入っているコンテナに合わせる
1486
+ selectionContextRef.current = drilled.parentElement;
1487
+ }
1488
+
1489
+ iframeDoc
1490
+ .querySelectorAll(".selected")
1491
+ .forEach((el) => el.classList.remove("selected"));
1492
+ drilled.classList.add("selected");
1493
+ setSelectedElementIdsRef.current([
1494
+ drilled.getAttribute("data-element-id") || "",
1495
+ ]);
1496
+ refreshSelectionOverlay(iframeDoc);
1497
+ sendElementInfo(drilled, iframeDoc);
1498
+ };
1499
+
1500
+ /**
1501
+ * クリックイベントハンドラ
1502
+ * <a>タグのデフォルト動作(リンク遷移)を防ぐ
1503
+ */
1504
+ const handleClick = (e: MouseEvent) => {
1505
+ if (iframeDoc.body.classList.contains("gg-cropping")) return;
1506
+ const target = e.target as HTMLElement;
1507
+
1508
+ // <a>タグまたはその子要素がクリックされた場合
1509
+ const anchorElement = target.closest("a");
1510
+ if (anchorElement) {
1511
+ // エディタ内ではリンクのデフォルト動作を常に防ぐ
1512
+ e.preventDefault();
1513
+ }
1514
+ };
1515
+
1516
+ // イベントリスナーを登録
1517
+ /**
1518
+ * パンくず(選択枠の上に出る祖先チップ)からの選択。
1519
+ *
1520
+ * 従来は useEditorMessages が独自にDOMを触って選択していたため、
1521
+ * このフックが持つ選択状態(selectionContextRef)が古いまま残り、
1522
+ * 次の操作で元の要素へ戻ってしまっていた。正規の selectSingle を通す。
1523
+ */
1524
+ const handleBreadcrumbSelect = (e: MessageEvent) => {
1525
+ if (e.data?.type !== "breadcrumb-select" || !e.data.elementId) return;
1526
+ const el = iframeDoc.querySelector(
1527
+ `[data-element-id="${e.data.elementId}"]`,
1528
+ ) as HTMLElement | null;
1529
+ if (el) selectSingle(iframeDoc, el);
1530
+ };
1531
+
1532
+ iframeDoc.addEventListener("mousedown", handleMouseDown);
1533
+ iframeDoc.addEventListener("click", handleClick);
1534
+ iframeDoc.addEventListener("dblclick", handleDoubleClick);
1535
+ window.addEventListener("message", handleBreadcrumbSelect);
1536
+
1537
+ // クリーンアップ関数を返す
1538
+ return () => {
1539
+ iframeDoc.removeEventListener("mousedown", handleMouseDown);
1540
+ iframeDoc.removeEventListener("click", handleClick);
1541
+ iframeDoc.removeEventListener("dblclick", handleDoubleClick);
1542
+ window.removeEventListener("message", handleBreadcrumbSelect);
1543
+ };
1544
+ },
1545
+ [
1546
+ getEditableElement,
1547
+ getTopLevelEditable,
1548
+ resolveByContext,
1549
+ startElementDrag,
1550
+ startGroupDrag,
1551
+ sendElementInfo,
1552
+ findSelectedHit,
1553
+ selectSingle,
1554
+ clearSelection,
1555
+ dragStateRef,
1556
+ marqueeStartPendingRef,
1557
+ marqueeClickTargetRef,
1558
+ marqueeAdditiveRef,
1559
+ marqueeGeomRef,
1560
+ resizeStateRef,
1561
+ ],
1562
+ );
1563
+
1564
+ return {
1565
+ getEditableElement,
1566
+ getTopLevelEditable,
1567
+ startElementDrag,
1568
+ sendElementInfo,
1569
+ setupSelectionListeners,
1570
+ resolveHoverTarget,
1571
+ handleSelectionMouseUp,
1572
+ };
1573
+ }