@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,1754 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * 要素操作(削除、複製、コピー、ペースト、レイヤー操作など)を管理するHook
5
+ */
6
+
7
+ import { useCallback } from 'react';
8
+ import { useEditorContext, useEditorComponents } from '../EditorContext';
9
+ import {
10
+ getIframeElement,
11
+ generateElementId,
12
+ refreshSelectionOverlay,
13
+ convertSingleElementToAbsolute,
14
+ canUngroup,
15
+ hasEditableChildren,
16
+ prepareElementDragOrigin,
17
+ } from '../utils/dom-utils';
18
+ import { extractElementInfo } from '../utils/style-utils';
19
+ import { applyTailwindStyles, convertInlineStylesToTailwind } from '../utils/tailwind-utils';
20
+ import { copyElementsToFigma, isFigmaExportAvailable } from '../utils/figma-export';
21
+ import {
22
+ hasViewportUnit,
23
+ convertViewportToPx,
24
+ getOriginalAttrName,
25
+ } from '../utils/viewport-utils';
26
+ import type { SerializedElement, BoundingBox, StyleClipboard, ClipboardData } from '../types';
27
+ import type { globalLastUsedStylesRef } from '../contexts/EditorRefsContext';
28
+
29
+ /**
30
+ * 要素の種類を判定(shape / text / line)
31
+ */
32
+ function getElementCategory(el: HTMLElement): 'shape' | 'text' | 'line' {
33
+ const shapeType = el.getAttribute('data-shape-type');
34
+ if (shapeType === 'text') return 'text';
35
+ if (shapeType === 'line' || shapeType === 'arrow') return 'line';
36
+ if (shapeType === 'pen' || shapeType === 'pencil') return 'line';
37
+
38
+ // data-shape-typeがない場合はタグ名とスタイルで判定
39
+ const tagName = el.tagName.toLowerCase();
40
+ if (tagName === 'p' || tagName === 'h1' || tagName === 'h2' || tagName === 'h3' ||
41
+ tagName === 'h4' || tagName === 'h5' || tagName === 'h6' || tagName === 'span' ||
42
+ el.getAttribute('contenteditable') === 'true') {
43
+ return 'text';
44
+ }
45
+
46
+ // SVG要素は線として扱う
47
+ if (tagName === 'svg' || el.querySelector('svg')) {
48
+ return 'line';
49
+ }
50
+
51
+ return 'shape';
52
+ }
53
+
54
+ /**
55
+ * 要素の「現在位置」を実測値から読む。
56
+ *
57
+ * [移植時の修正] 以前は `parseInt(el.style.left || '0') || 0` で読んでいたため、
58
+ * インラインの left/top を持たない要素(このプロジェクトでは編集対象の大半)が
59
+ * 原点0扱いになり、矢印キー1回で紙面の左上へ吹き飛んでいた。
60
+ *
61
+ * getComputedStyle の left/top は「位置指定された要素なら使用値(px)」を返す。
62
+ * - インライン style が無くても正しい原点が得られる
63
+ * - レイアウト値なので #artboard-wrapper の transform:scale() の影響を受けない
64
+ * (getBoundingClientRect のようにスケールで割り戻す必要がない)
65
+ * position:static の場合は 'auto' が返るので、動かせないことがそのまま分かる。
66
+ */
67
+ function readUsedOffset(view: Window, el: HTMLElement): { left: number; top: number } | null {
68
+ const cs = view.getComputedStyle(el);
69
+ if (cs.position === 'static') return null;
70
+ const left = parseFloat(cs.left);
71
+ const top = parseFloat(cs.top);
72
+ if (!Number.isFinite(left) || !Number.isFinite(top)) return null;
73
+ return { left, top };
74
+ }
75
+
76
+ /**
77
+ * フロー外(絶対配置等)の要素を dx,dy だけ平行移動する。
78
+ *
79
+ * 左右(上下)両方にアンカーされている要素は left だけ書き換えると
80
+ * 幅が変わってしまう(left+width+right が過剰制約になる)ため、
81
+ * 実測して寸法が変わった場合のみ反対側のオフセットも同量ずらして見た目を保つ。
82
+ * 「right/bottom 基準の要素が左上へ飛ぶ」現象への対処もこれで兼ねる。
83
+ */
84
+ function translateOutOfFlowElement(view: Window, el: HTMLElement, dx: number, dy: number): boolean {
85
+ const origin = readUsedOffset(view, el);
86
+ if (!origin) return false;
87
+
88
+ const before = el.getBoundingClientRect();
89
+
90
+ if (dx !== 0) el.style.left = `${origin.left + dx}px`;
91
+ if (dy !== 0) el.style.top = `${origin.top + dy}px`;
92
+
93
+ const after = el.getBoundingClientRect();
94
+ if (dx !== 0 && Math.abs(after.width - before.width) > 0.5) {
95
+ const usedRight = parseFloat(view.getComputedStyle(el).right);
96
+ if (Number.isFinite(usedRight)) el.style.right = `${usedRight - dx}px`;
97
+ }
98
+ if (dy !== 0 && Math.abs(after.height - before.height) > 0.5) {
99
+ const usedBottom = parseFloat(view.getComputedStyle(el).bottom);
100
+ if (Number.isFinite(usedBottom)) el.style.bottom = `${usedBottom - dy}px`;
101
+ }
102
+ return true;
103
+ }
104
+
105
+ /** 複製時にずらす量(px) */
106
+ const DUPLICATE_OFFSET = 20;
107
+
108
+ /**
109
+ * 複製した要素を「元要素の見た目の位置 + offset」に置く。
110
+ *
111
+ * [移植時の修正] 以前は clone のインライン left/top を parseInt して +20 していたため、
112
+ * インライン値を持たない要素(大半)と right/bottom 基準の要素は
113
+ * left:20px / top:20px、つまり紙面の左上へ飛んでいた。群を複製すると
114
+ * メンバーの相対関係も壊れていた。
115
+ * clone を DOM に挿入した *後* に、元要素の使用値を基準に配置し直す。
116
+ * フロー(static)要素は DOM 順で正しい位置に入るので何も書かない。
117
+ */
118
+ function offsetDuplicate(
119
+ iframeDoc: Document,
120
+ original: HTMLElement,
121
+ clone: HTMLElement,
122
+ dx: number,
123
+ dy: number
124
+ ): void {
125
+ const view = iframeDoc.defaultView;
126
+ if (!view) return;
127
+
128
+ const origin = readUsedOffset(view, original);
129
+ if (!origin) return; // フロー要素: 位置指定しない
130
+
131
+ const originalRect = original.getBoundingClientRect();
132
+ clone.style.left = `${origin.left + dx}px`;
133
+ clone.style.top = `${origin.top + dy}px`;
134
+
135
+ // 左右/上下アンカーで幅・高さが変わってしまう場合は反対側も同量ずらす
136
+ const cloneRect = clone.getBoundingClientRect();
137
+ if (Math.abs(cloneRect.width - originalRect.width) > 0.5) {
138
+ const usedRight = parseFloat(view.getComputedStyle(clone).right);
139
+ if (Number.isFinite(usedRight)) clone.style.right = `${usedRight - dx}px`;
140
+ }
141
+ if (Math.abs(cloneRect.height - originalRect.height) > 0.5) {
142
+ const usedBottom = parseFloat(view.getComputedStyle(clone).bottom);
143
+ if (Number.isFinite(usedBottom)) clone.style.bottom = `${usedBottom - dy}px`;
144
+ }
145
+ }
146
+
147
+ /** 矢印キー移動の結果。呼び出し側が「なぜ動かないか」を提示できるようにする */
148
+ export interface MoveElementResult {
149
+ /** 実際に動かした要素数 */
150
+ moved: number;
151
+ /** 動かせなかった理由。null なら成功 */
152
+ blocked: null | 'no-selection' | 'flow';
153
+ }
154
+
155
+ /**
156
+ * 最後に使用したスタイルをキャプチャ
157
+ */
158
+ function captureLastUsedStyles(
159
+ el: HTMLElement,
160
+ styles: Record<string, string>,
161
+ lastUsedStylesRef: typeof globalLastUsedStylesRef
162
+ ) {
163
+ const category = getElementCategory(el);
164
+ console.log('[captureLastUsedStyles] Category:', category, 'Styles:', styles);
165
+
166
+ if (category === 'shape') {
167
+ const shapeStyles: Partial<typeof lastUsedStylesRef.current.shape> = {};
168
+ if (styles.backgroundColor) shapeStyles.backgroundColor = styles.backgroundColor;
169
+ if (styles.borderRadius) shapeStyles.borderRadius = styles.borderRadius;
170
+ if (styles.borderColor) shapeStyles.borderColor = styles.borderColor;
171
+ if (styles.borderWidth) shapeStyles.borderWidth = styles.borderWidth;
172
+ if (styles.borderStyle) shapeStyles.borderStyle = styles.borderStyle;
173
+ if (styles.opacity) shapeStyles.opacity = styles.opacity;
174
+ if (Object.keys(shapeStyles).length > 0) {
175
+ lastUsedStylesRef.updateShape(shapeStyles);
176
+ console.log('[captureLastUsedStyles] Updated shape styles:', shapeStyles);
177
+ }
178
+ } else if (category === 'text') {
179
+ const textStyles: Partial<typeof lastUsedStylesRef.current.text> = {};
180
+ if (styles.color) textStyles.color = styles.color;
181
+ if (styles.fontSize) textStyles.fontSize = styles.fontSize;
182
+ if (styles.fontFamily) textStyles.fontFamily = styles.fontFamily;
183
+ if (styles.fontWeight) textStyles.fontWeight = styles.fontWeight;
184
+ if (styles.lineHeight) textStyles.lineHeight = styles.lineHeight;
185
+ if (styles.letterSpacing) textStyles.letterSpacing = styles.letterSpacing;
186
+ if (styles.textAlign) textStyles.textAlign = styles.textAlign;
187
+ if (Object.keys(textStyles).length > 0) {
188
+ lastUsedStylesRef.updateText(textStyles);
189
+ console.log('[captureLastUsedStyles] Updated text styles:', textStyles);
190
+ }
191
+ } else if (category === 'line') {
192
+ const lineStyles: Partial<typeof lastUsedStylesRef.current.line> = {};
193
+ if (styles.stroke) lineStyles.stroke = styles.stroke;
194
+ if (styles.strokeWidth) lineStyles.strokeWidth = styles.strokeWidth;
195
+ if (Object.keys(lineStyles).length > 0) {
196
+ lastUsedStylesRef.updateLine(lineStyles);
197
+ console.log('[captureLastUsedStyles] Updated line styles:', lineStyles);
198
+ }
199
+ }
200
+ }
201
+
202
+ /**
203
+ * 要素操作のアクションを提供するHook
204
+ */
205
+ export function useElementActions() {
206
+ const {
207
+ selectedElement,
208
+ setSelectedElement,
209
+ selectedElementIds,
210
+ setSelectedElementIds,
211
+ clipboardRef,
212
+ styleClipboardRef,
213
+ lastUsedStylesRef,
214
+ notifyIframeChange,
215
+ getIframeDoc,
216
+ layoutMode,
217
+ editorMode,
218
+ viewportWidth,
219
+ } = useEditorContext();
220
+
221
+ // コンポーネント管理
222
+ const {
223
+ getInstanceByDomId,
224
+ getMasterComponent,
225
+ createInstance,
226
+ deleteInstance,
227
+ } = useEditorComponents();
228
+
229
+ const SLIDE_WIDTH = 1920; // 定数化すべきだが一旦ここに
230
+ const SLIDE_HEIGHT = 1080;
231
+
232
+ /**
233
+ * viewport単位の変換に使用するキャンバス/アートボードの寸法を取得
234
+ * - slideモード: 固定の1920x1080
235
+ * - webpageモード: viewportWidth(選択されたブレークポイント)と計算された高さ
236
+ */
237
+ const getCanvasDimensions = useCallback(() => {
238
+ if (editorMode === 'webpage') {
239
+ // webpageモードでは選択されたviewportWidthを使用
240
+ const iframeDoc = getIframeDoc();
241
+ const artboard = iframeDoc?.getElementById('artboard');
242
+ const artboardHeight = artboard?.scrollHeight || 1080;
243
+ return {
244
+ width: viewportWidth || 1920,
245
+ height: artboardHeight,
246
+ };
247
+ }
248
+ // slideモードでは固定サイズ
249
+ return {
250
+ width: SLIDE_WIDTH,
251
+ height: SLIDE_HEIGHT,
252
+ };
253
+ }, [editorMode, viewportWidth, getIframeDoc]);
254
+
255
+ // 整列機能
256
+ const alignElements = useCallback((type: 'left' | 'center-h' | 'right' | 'top' | 'center-v' | 'bottom' | 'distribute-h' | 'distribute-v') => {
257
+ const iframeDoc = getIframeDoc();
258
+ if (!iframeDoc) return;
259
+
260
+ // 処理対象の要素を取得
261
+ const targetIds = selectedElementIds.length > 0
262
+ ? selectedElementIds
263
+ : selectedElement ? [selectedElement.id] : [];
264
+
265
+ if (targetIds.length === 0) return;
266
+
267
+ const elements = targetIds.map(id => getIframeElement(iframeDoc, id)).filter(el => el) as HTMLElement[];
268
+ if (elements.length === 0) return;
269
+
270
+ // バウンディングボックスと親情報の取得
271
+ const bounds = elements.map(el => {
272
+ const rect = el.getBoundingClientRect();
273
+ const style = iframeDoc.defaultView?.getComputedStyle(el);
274
+ const matrix = new DOMMatrix(style?.transform);
275
+ const left = parseFloat(el.style.left) || 0;
276
+ const top = parseFloat(el.style.top) || 0;
277
+ return { el, rect, left, top, width: rect.width, height: rect.height };
278
+ });
279
+
280
+ // 基準となる領域の計算
281
+ let referenceBounds = { left: Infinity, top: Infinity, right: -Infinity, bottom: -Infinity, width: 0, height: 0, centerX: 0, centerY: 0 };
282
+
283
+ if (elements.length > 1) {
284
+ // 複数選択: 選択範囲全体を基準
285
+ bounds.forEach(b => {
286
+ referenceBounds.left = Math.min(referenceBounds.left, b.left);
287
+ referenceBounds.top = Math.min(referenceBounds.top, b.top);
288
+ referenceBounds.right = Math.max(referenceBounds.right, b.left + b.width);
289
+ referenceBounds.bottom = Math.max(referenceBounds.bottom, b.top + b.height);
290
+ });
291
+ referenceBounds.width = referenceBounds.right - referenceBounds.left;
292
+ referenceBounds.height = referenceBounds.bottom - referenceBounds.top;
293
+ referenceBounds.centerX = referenceBounds.left + referenceBounds.width / 2;
294
+ referenceBounds.centerY = referenceBounds.top + referenceBounds.height / 2;
295
+ } else {
296
+ // 単一選択: 親要素を基準
297
+ const el = elements[0];
298
+ const parent = el.parentElement;
299
+
300
+ if (parent && parent !== iframeDoc.body) {
301
+ // 親が要素の場合(グループなど)
302
+ const parentStyle = iframeDoc.defaultView?.getComputedStyle(parent);
303
+ const parentRect = parent.getBoundingClientRect();
304
+ // 相対座標系での親サイズと見なす
305
+ const pW = parseFloat(parentStyle?.width || '0') || parentRect.width;
306
+ const pH = parseFloat(parentStyle?.height || '0') || parentRect.height;
307
+ referenceBounds = {
308
+ left: 0, top: 0, right: pW, bottom: pH, width: pW, height: pH,
309
+ centerX: pW / 2, centerY: pH / 2
310
+ };
311
+ } else {
312
+ // 親がSlide直下の場合
313
+ referenceBounds = {
314
+ left: 0, top: 0, right: SLIDE_WIDTH, bottom: SLIDE_HEIGHT, width: SLIDE_WIDTH, height: SLIDE_HEIGHT,
315
+ centerX: SLIDE_WIDTH / 2, centerY: SLIDE_HEIGHT / 2
316
+ };
317
+ }
318
+ }
319
+
320
+ // 配置の適用
321
+ const updates: Record<string, string> = {};
322
+
323
+ if (type === 'distribute-h' || type === 'distribute-v') {
324
+ if (elements.length < 3) return; // 3つ以上必要
325
+
326
+ // 位置でソート
327
+ const sorted = [...bounds].sort((a, b) =>
328
+ type === 'distribute-h' ? a.left - b.left : a.top - b.top
329
+ );
330
+
331
+ const first = sorted[0];
332
+ const last = sorted[sorted.length - 1];
333
+ const totalSpan = (type === 'distribute-h' ? last.left : last.top) - (type === 'distribute-h' ? first.left : first.top);
334
+ const gap = totalSpan / (sorted.length - 1); // ここは単純な中心間距離ではなく、スペースの均等化なら計算が違うが、FigmaのTidy UpではなくDistributeは通常「端から端を等分」
335
+
336
+ // Distribute centers? Usually distribute left/top edges or centers.
337
+ // Figma "Distribute horizontal spacing" makes gaps equal. "Distribute left" makes left edges equal distance?
338
+ // Let's implement "Distribute Horizontal Spacing" (equal gaps) if possible, or simpler "Distribute Centers".
339
+ // Figma: "Distribute horizontal spacing" -> Equal gaps between elements.
340
+
341
+ // 等間隔(スペース)の実装
342
+ const totalWidth = sorted.reduce((sum, b) => sum + (type === 'distribute-h' ? b.width : b.height), 0);
343
+ const availableSpace = (type === 'distribute-h' ? (last.left + last.width) - first.left : (last.top + last.height) - first.top) - totalWidth;
344
+ // これは両端固定で間を埋める場合。
345
+
346
+ // 単純化: 両端の要素位置を固定し、その間を等距離(中心基準ではなく、要素間スペース基準が望ましいが複雑)
347
+ // ここでは「等間隔(中心基準)」ではなく「等間隔(スペース)」を目指すが、まずは安全に「範囲内での均等配置」
348
+ // FigmaのDistributeは「両端のオブジェクトを基準に、その間のオブジェクトを均等に配置」
349
+
350
+ // 簡易実装: 中心座標を均等に配置(Distribute Horizontal Centers)ではなく、Spaceを均等にする
351
+ // Space Based:
352
+ // First el fixed. Last el fixed.
353
+ // Span = (Last Right) - (First Left)
354
+ // Total Object Width = sum(widths)
355
+ // Total Gap = Span - Total Object Width
356
+ // Gap per space = Total Gap / (n - 1)
357
+
358
+ // 再計算: 最初と最後の要素の位置はそのまま
359
+ const startPos = type === 'distribute-h' ? first.left : first.top;
360
+ const endPos = type === 'distribute-h' ? (last.left + last.width) : (last.top + last.height);
361
+ const totalObjectSize = sorted.map(b => type === 'distribute-h' ? b.width : b.height).reduce((a, b) => a + b, 0) - (type === 'distribute-h' ? first.width + last.width : first.height + last.height);
362
+ // 間にあるオブジェクトのサイズの合計
363
+
364
+ // 正確には: (Last Left - (First Left + First Width)) / (count - 1)? No.
365
+
366
+ // Distribute spacing logic:
367
+ // sort elements.
368
+ // let currentPos = first.right + gap
369
+ // loop 1 to n-2.
370
+
371
+ // Calculate Gap
372
+ const fullDistance = (type === 'distribute-h' ? last.left : last.top) - (type === 'distribute-h' ? (first.left + first.width) : (first.top + first.height));
373
+ // 中間の要素の幅合計
374
+ const innerWidthSum = sorted.slice(1, -1).reduce((sum, b) => sum + (type === 'distribute-h' ? b.width : b.height), 0);
375
+
376
+ const gapCount = sorted.length - 1;
377
+ // スペース自体の合計 = (Last Left - First Right) - Inner Widths
378
+ // 実は単純に「要素の左端」を等間隔にする "Distribute Left" と、「スペース」を等間隔にする "Distribute Spacing" がある。
379
+ // Figmaのアイコンは "Distribute Horizontal Spacing" (縦棒グラフみたいなの)
380
+ // ここでは Spacing を実装する。
381
+
382
+ const totalGap = fullDistance - innerWidthSum;
383
+ const singleGap = totalGap / gapCount;
384
+
385
+ let currentPos = (type === 'distribute-h' ? (first.left + first.width) : (first.top + first.height));
386
+
387
+ sorted.forEach((b, i) => {
388
+ if (i === 0) return; // 先頭は動かさない
389
+ if (i === sorted.length - 1) return; // 末尾は動かさない(誤差吸収のため最後は何もしない手もあるが)
390
+
391
+ currentPos += singleGap;
392
+ // 位置適用
393
+ b.el.style[type === 'distribute-h' ? 'left' : 'top'] = `${currentPos}px`;
394
+
395
+ currentPos += (type === 'distribute-h' ? b.width : b.height);
396
+ });
397
+
398
+ } else {
399
+ // 通常の整列
400
+ bounds.forEach(b => {
401
+ let newValue = 0;
402
+ switch (type) {
403
+ case 'left':
404
+ newValue = referenceBounds.left;
405
+ b.el.style.left = `${newValue}px`;
406
+ break;
407
+ case 'center-h':
408
+ newValue = referenceBounds.centerX - (b.width / 2);
409
+ b.el.style.left = `${newValue}px`;
410
+ break;
411
+ case 'right':
412
+ newValue = referenceBounds.right - b.width;
413
+ b.el.style.left = `${newValue}px`;
414
+ break;
415
+ case 'top':
416
+ newValue = referenceBounds.top;
417
+ b.el.style.top = `${newValue}px`;
418
+ break;
419
+ case 'center-v':
420
+ newValue = referenceBounds.centerY - (b.height / 2);
421
+ b.el.style.top = `${newValue}px`;
422
+ break;
423
+ case 'bottom':
424
+ newValue = referenceBounds.bottom - b.height;
425
+ b.el.style.top = `${newValue}px`;
426
+ break;
427
+ }
428
+ });
429
+ }
430
+
431
+ // インラインスタイルをTailwindクラスに変換
432
+ const positionProperties = ['left', 'top'];
433
+ elements.forEach(el => {
434
+ convertInlineStylesToTailwind(el, positionProperties);
435
+ });
436
+
437
+ notifyIframeChange();
438
+ // 選択ボックス更新(選択セット全体から作り直す = 群バウンディングボックスも更新される)
439
+ requestAnimationFrame(() => {
440
+ refreshSelectionOverlay(iframeDoc);
441
+ });
442
+
443
+ }, [selectedElementIds, selectedElement, getIframeDoc, notifyIframeChange]);
444
+ const updateElementStyle = useCallback((styles: Record<string, string>) => {
445
+
446
+
447
+ const iframeDoc = getIframeDoc();
448
+ if (!iframeDoc) {
449
+ return;
450
+ }
451
+
452
+ // キャンバス/アートボードの寸法を取得(viewport単位の変換用)
453
+ // iframeWindow.innerWidthではなく、アートボードの設計寸法を使用
454
+ const canvasDimensions = getCanvasDimensions();
455
+ const canvasWidth = canvasDimensions.width;
456
+ const canvasHeight = canvasDimensions.height;
457
+
458
+
459
+ /**
460
+ * 要素にスタイルを適用(viewport単位を考慮)
461
+ * - viewport単位(vw, vh, vmin, vmax)を検出した場合:
462
+ * - 元の値をdata-original-*属性に保存
463
+ * - キャンバス寸法に基づいてpxに変換して適用
464
+ * - それ以外は通常通りTailwindスタイルを適用
465
+ */
466
+ const applyStylesWithViewportHandling = (el: HTMLElement, stylesToApply: Record<string, string>) => {
467
+ const processedStyles: Record<string, string> = {};
468
+
469
+ Object.entries(stylesToApply).forEach(([property, value]) => {
470
+ if (hasViewportUnit(value)) {
471
+ // viewport単位が含まれる場合
472
+ // 元の値をdata属性に保存
473
+ const attrName = getOriginalAttrName(property);
474
+ el.setAttribute(attrName, value);
475
+
476
+ // pxに変換(キャンバス寸法を基準に)
477
+ const pxValue = convertViewportToPx(value, canvasWidth, canvasHeight);
478
+ processedStyles[property] = pxValue;
479
+ } else {
480
+ // viewport単位でない場合は元のdata属性を削除
481
+ const attrName = getOriginalAttrName(property);
482
+ if (el.hasAttribute(attrName)) {
483
+ el.removeAttribute(attrName);
484
+ }
485
+ processedStyles[property] = value;
486
+ }
487
+ });
488
+
489
+ // 処理済みのスタイルを適用
490
+ applyTailwindStyles(el, processedStyles);
491
+ };
492
+
493
+ // 複数選択されている場合は全て更新
494
+ if (selectedElementIds.length > 0) {
495
+ console.log('[DEBUG useElementActions] Applying styles to', selectedElementIds.length, 'elements');
496
+ selectedElementIds.forEach(id => {
497
+ const el = getIframeElement(iframeDoc, id);
498
+ if (el) {
499
+ console.log('[DEBUG useElementActions] Applying to element:', id);
500
+ applyStylesWithViewportHandling(el, styles);
501
+ }
502
+ });
503
+ notifyIframeChange();
504
+
505
+ // 最後に使用したスタイルを保存(最初の要素の種類で判断)
506
+ const firstEl = getIframeElement(iframeDoc, selectedElementIds[0]);
507
+ if (firstEl) {
508
+ captureLastUsedStyles(firstEl, styles, lastUsedStylesRef);
509
+ }
510
+
511
+ // 最後の選択要素の情報を更新
512
+ if (selectedElement) {
513
+ const el = getIframeElement(iframeDoc, selectedElement.id);
514
+ if (el) {
515
+ const info = extractElementInfo(el, iframeDoc);
516
+ if (info) setSelectedElement(info);
517
+
518
+ // 選択ボックスを更新(レイアウト反映待ち)
519
+ requestAnimationFrame(() => {
520
+ refreshSelectionOverlay(iframeDoc);
521
+ });
522
+ }
523
+ }
524
+ return;
525
+ }
526
+
527
+ // 単一選択の場合(後方互換)
528
+ if (!selectedElement) return;
529
+ const el = getIframeElement(iframeDoc, selectedElement.id);
530
+ if (el) {
531
+ applyStylesWithViewportHandling(el, styles);
532
+ notifyIframeChange();
533
+
534
+ // 最後に使用したスタイルを保存
535
+ captureLastUsedStyles(el, styles, lastUsedStylesRef);
536
+
537
+ // 更新後の要素情報を再取得
538
+ const info = extractElementInfo(el, iframeDoc);
539
+ if (info) setSelectedElement(info);
540
+
541
+ // 選択ボックスを更新(レイアウト反映待ち)
542
+ // 1要素だけ作り直すと複数選択中に他の枠が消えるため全体を再構築する
543
+ requestAnimationFrame(() => {
544
+ refreshSelectionOverlay(iframeDoc);
545
+ });
546
+ }
547
+ }, [selectedElement, selectedElementIds, getIframeDoc, notifyIframeChange, setSelectedElement, lastUsedStylesRef, getCanvasDimensions, editorMode]);
548
+
549
+ // 要素削除(複数選択対応)
550
+ const deleteElement = useCallback(() => {
551
+ const iframeDoc = getIframeDoc();
552
+ if (!iframeDoc) return;
553
+
554
+ // 複数選択されている場合は全て削除
555
+ if (selectedElementIds.length > 0) {
556
+ selectedElementIds.forEach(id => {
557
+ const el = getIframeElement(iframeDoc, id);
558
+ if (el) {
559
+ el.remove();
560
+ }
561
+ });
562
+ // 選択ボックスも全て削除
563
+ iframeDoc.querySelectorAll('.selection-box').forEach(box => box.remove());
564
+ setSelectedElementIds([]);
565
+ setSelectedElement(null);
566
+ notifyIframeChange();
567
+ return;
568
+ }
569
+
570
+ // 単一選択の場合
571
+ if (!selectedElement) return;
572
+ const el = getIframeElement(iframeDoc, selectedElement.id);
573
+ if (el) {
574
+ el.remove();
575
+ notifyIframeChange();
576
+ setSelectedElement(null);
577
+ setSelectedElementIds([]);
578
+ }
579
+ }, [selectedElement, selectedElementIds, getIframeDoc, notifyIframeChange, setSelectedElement, setSelectedElementIds]);
580
+
581
+ // 要素複製(複数選択対応)- コンポーネントインスタンス対応
582
+ const duplicateElement = useCallback(() => {
583
+ const iframeDoc = getIframeDoc();
584
+ if (!iframeDoc) return;
585
+
586
+ // 既存の選択ボックスをクリア
587
+ iframeDoc.querySelectorAll('.selection-box').forEach(box => box.remove());
588
+ iframeDoc.querySelectorAll('.selected').forEach(el => el.classList.remove('selected'));
589
+
590
+ /**
591
+ * 複製された要素ツリー内の全ての data-element-id を再生成する
592
+ * また、元のIDを data-master-element-id に保存してオーバーライド検出用に使用する
593
+ * これにより、複製されたインスタンスが元のインスタンスと同じIDを持たなくなる
594
+ */
595
+ const regenerateAllElementIds = (element: HTMLElement, idMap: Map<string, string>): void => {
596
+ // 現在の要素のIDを更新
597
+ const oldId = element.getAttribute('data-element-id');
598
+ if (oldId) {
599
+ const newId = generateElementId('dup');
600
+ // 元のIDをマスター参照用に保存(オーバーライド検出に使用)
601
+ element.setAttribute('data-master-element-id', oldId);
602
+ element.setAttribute('data-element-id', newId);
603
+ idMap.set(oldId, newId);
604
+ }
605
+
606
+ // 全ての子要素を再帰的に処理
607
+ const children = element.querySelectorAll('[data-element-id]');
608
+ children.forEach((child) => {
609
+ const childOldId = child.getAttribute('data-element-id');
610
+ if (childOldId) {
611
+ const childNewId = generateElementId('dup');
612
+ // 元のIDをマスター参照用に保存
613
+ child.setAttribute('data-master-element-id', childOldId);
614
+ child.setAttribute('data-element-id', childNewId);
615
+ idMap.set(childOldId, childNewId);
616
+ }
617
+ });
618
+ };
619
+
620
+ /**
621
+ * 要素を複製するヘルパー関数
622
+ * コンポーネントインスタンスの場合は新しいインスタンスを作成する
623
+ */
624
+ const cloneElementWithInstance = (el: HTMLElement): { clone: HTMLElement; newId: string } => {
625
+ const clone = el.cloneNode(true) as HTMLElement;
626
+
627
+ // ID再生成マップ(オーバーライドのターゲットID更新用)
628
+ const idMap = new Map<string, string>();
629
+
630
+ // 全ての要素IDを再生成
631
+ regenerateAllElementIds(clone, idMap);
632
+
633
+ // 新しいルートIDを取得
634
+ const newId = clone.getAttribute('data-element-id') || generateElementId('dup');
635
+ clone.classList.remove('selected');
636
+
637
+ // コンポーネントインスタンスかどうかをチェック
638
+ const instanceId = el.getAttribute('data-component-instance');
639
+ const masterId = el.getAttribute('data-component-master');
640
+
641
+ if (instanceId && masterId) {
642
+ // 元のインスタンス情報を取得
643
+ const originalInstance = getInstanceByDomId(el.getAttribute('data-element-id') || '');
644
+
645
+ if (originalInstance) {
646
+ // 新しいインスタンスを作成(元のオーバーライド、バリアント、プロパティ値を継承)
647
+ try {
648
+ const newInstance = createInstance(
649
+ masterId,
650
+ originalInstance.variantId,
651
+ undefined, // instancePageId - use default from context
652
+ undefined, // providedMaster - get from state
653
+ newId, // customDomElementId
654
+ originalInstance.overrides,
655
+ originalInstance.propertyValues
656
+ );
657
+
658
+ if (newInstance) {
659
+ // 新しいインスタンスIDで属性を更新
660
+ clone.setAttribute('data-component-instance', newInstance.id);
661
+ console.log('[duplicateElement] Created new component instance:', newInstance.id, 'from:', instanceId);
662
+ }
663
+ } catch (error) {
664
+ // インスタンス作成に失敗した場合、コンポーネント属性を削除(通常要素として複製)
665
+ console.warn('[duplicateElement] Failed to create instance, duplicating as regular element:', error);
666
+ clone.removeAttribute('data-component-instance');
667
+ clone.removeAttribute('data-component-master');
668
+ }
669
+ } else {
670
+ // 元のインスタンスが見つからない場合も通常要素として複製
671
+ console.warn('[duplicateElement] Original instance not found, duplicating as regular element');
672
+ clone.removeAttribute('data-component-instance');
673
+ clone.removeAttribute('data-component-master');
674
+ }
675
+ }
676
+
677
+ return { clone, newId };
678
+ };
679
+
680
+ // 複数選択されている場合は全て複製
681
+ if (selectedElementIds.length > 0) {
682
+ const newIds: string[] = [];
683
+ const newElements: HTMLElement[] = [];
684
+ selectedElementIds.forEach(id => {
685
+ const el = getIframeElement(iframeDoc, id);
686
+ if (el) {
687
+ const { clone, newId } = cloneElementWithInstance(el);
688
+ // 位置指定は「DOMに入れてから」元要素の実測位置を基準に行う(後述の理由)
689
+ el.parentNode?.insertBefore(clone, el.nextSibling);
690
+ offsetDuplicate(iframeDoc, el, clone, DUPLICATE_OFFSET, DUPLICATE_OFFSET);
691
+ newIds.push(newId);
692
+ newElements.push(clone);
693
+ }
694
+ });
695
+
696
+ // 新しい要素を選択
697
+ setSelectedElementIds(newIds);
698
+ newElements.forEach(el => {
699
+ el.classList.add('selected');
700
+ });
701
+ refreshSelectionOverlay(iframeDoc);
702
+ if (newElements.length > 0) {
703
+ const info = extractElementInfo(newElements[0], iframeDoc);
704
+ if (info) setSelectedElement(info);
705
+ }
706
+
707
+ notifyIframeChange();
708
+ return;
709
+ }
710
+
711
+ // 単一選択の場合
712
+ if (!selectedElement) return;
713
+ const el = getIframeElement(iframeDoc, selectedElement.id);
714
+ if (el) {
715
+ const { clone, newId } = cloneElementWithInstance(el);
716
+ el.parentNode?.insertBefore(clone, el.nextSibling);
717
+ offsetDuplicate(iframeDoc, el, clone, DUPLICATE_OFFSET, DUPLICATE_OFFSET);
718
+
719
+ // 新しい要素を選択
720
+ clone.classList.add('selected');
721
+ refreshSelectionOverlay(iframeDoc);
722
+ setSelectedElementIds([newId]);
723
+ const info = extractElementInfo(clone, iframeDoc);
724
+ if (info) setSelectedElement(info);
725
+
726
+ notifyIframeChange();
727
+ }
728
+ }, [selectedElement, selectedElementIds, getIframeDoc, notifyIframeChange, setSelectedElement, setSelectedElementIds, getInstanceByDomId, createInstance]);
729
+
730
+ // 要素シリアライズ
731
+ const serializeElement = useCallback((el: HTMLElement): SerializedElement => {
732
+ const attributes: Record<string, string> = {};
733
+ for (const attr of Array.from(el.attributes)) {
734
+ if (!['contenteditable', 'data-editable'].includes(attr.name)) {
735
+ attributes[attr.name] = attr.value;
736
+ }
737
+ }
738
+ return {
739
+ tagName: el.tagName.toLowerCase(),
740
+ id: el.getAttribute('data-element-id') || '',
741
+ className: el.className,
742
+ style: el.getAttribute('style') || '',
743
+ innerHTML: el.innerHTML,
744
+ attributes,
745
+ };
746
+ }, []);
747
+
748
+ // 要素デシリアライズ
749
+ const deserializeElement = useCallback((serialized: SerializedElement): HTMLElement | null => {
750
+ const iframeDoc = getIframeDoc();
751
+ if (!iframeDoc) return null;
752
+
753
+ const el = iframeDoc.createElement(serialized.tagName);
754
+ el.className = serialized.className;
755
+ el.setAttribute('style', serialized.style);
756
+ el.innerHTML = serialized.innerHTML;
757
+ for (const [key, value] of Object.entries(serialized.attributes)) {
758
+ if (key !== 'class' && key !== 'style') {
759
+ el.setAttribute(key, value);
760
+ }
761
+ }
762
+ el.setAttribute('data-editable', 'true');
763
+ return el;
764
+ }, [getIframeDoc]);
765
+
766
+ // コピー(複数選択対応)
767
+ /**
768
+ * コピー内容をOSクリップボードにも書く。
769
+ * これによりスライドを移っても(エディタを開き直しても)貼り付けられる。
770
+ * ペイロードは text/html の data 属性に埋める(コメントはブラウザの
771
+ * サニタイズで剥がされることがあるため使わない)。text/plain には
772
+ * 文字内容を入れ、メモ帳等への貼り付けはテキストとして自然に振る舞う。
773
+ */
774
+ const writeToOsClipboard = useCallback((clip: ClipboardData) => {
775
+ clip.osWritten = false;
776
+ try {
777
+ const payload = btoa(unescape(encodeURIComponent(JSON.stringify(clip.elements))));
778
+ const html = `<div data-gg-clipboard="v1" data-gg-payload="${payload}">${
779
+ clip.elements.map(e => `<${e.tagName} class="${e.className}" style="${e.style}">${e.innerHTML}</${e.tagName}>`).join('')
780
+ }</div>`;
781
+ const text = clip.elements
782
+ .map(e => {
783
+ const tmp = document.createElement('div');
784
+ tmp.innerHTML = e.innerHTML;
785
+ return tmp.textContent || '';
786
+ })
787
+ .join('\n');
788
+ void navigator.clipboard
789
+ .write([
790
+ new ClipboardItem({
791
+ 'text/html': new Blob([html], { type: 'text/html' }),
792
+ 'text/plain': new Blob([text], { type: 'text/plain' }),
793
+ }),
794
+ ])
795
+ .then(() => {
796
+ if (clipboardRef.current) clipboardRef.current.osWritten = true;
797
+ })
798
+ .catch(() => {
799
+ // 権限が無い環境では内部クリップボードだけで動く(同一スライド内は可)
800
+ });
801
+ } catch {
802
+ // ClipboardItem 非対応環境も同様
803
+ }
804
+ }, [clipboardRef]);
805
+
806
+ const copyElements = useCallback(() => {
807
+ const iframeDoc = getIframeDoc();
808
+ if (!iframeDoc) return;
809
+
810
+ // 複数選択されている場合
811
+ if (selectedElementIds.length > 0) {
812
+ const serialized = selectedElementIds
813
+ .map(id => getIframeElement(iframeDoc, id))
814
+ .filter((el): el is HTMLElement => el !== null)
815
+ .map(el => serializeElement(el));
816
+
817
+ if (serialized.length > 0) {
818
+ clipboardRef.current = {
819
+ elements: serialized,
820
+ offset: { x: 10, y: 10 },
821
+ };
822
+ writeToOsClipboard(clipboardRef.current);
823
+ }
824
+ return;
825
+ }
826
+
827
+ // 単一選択の場合
828
+ if (!selectedElement) return;
829
+ const el = getIframeElement(iframeDoc, selectedElement.id);
830
+ if (!el) return;
831
+
832
+ clipboardRef.current = {
833
+ elements: [serializeElement(el)],
834
+ offset: { x: 10, y: 10 },
835
+ };
836
+ writeToOsClipboard(clipboardRef.current);
837
+ }, [selectedElement, selectedElementIds, getIframeDoc, serializeElement, clipboardRef, writeToOsClipboard]);
838
+
839
+ // カット(複数選択対応)
840
+ const cutElements = useCallback(() => {
841
+ copyElements();
842
+ deleteElement();
843
+ }, [copyElements, deleteElement]);
844
+
845
+ /**
846
+ * serialized の配列を貼り付ける本体。
847
+ * 内部クリップボード(同一エディタ内)と、OSクリップボード経由
848
+ * (スライド跨ぎ。pasteイベントがペイロードを取り出して渡す)の両方から使う。
849
+ */
850
+ const pasteSerializedElements = useCallback((list: SerializedElement[], offset?: { x: number; y: number }) => {
851
+ const iframeDoc = getIframeDoc();
852
+ if (!iframeDoc || list.length === 0) return;
853
+ const off = offset ?? { x: 10, y: 10 };
854
+
855
+ // 既存の選択ボックスをクリア
856
+ iframeDoc.querySelectorAll('.selection-box').forEach(box => box.remove());
857
+ iframeDoc.querySelectorAll('.selected').forEach(el => el.classList.remove('selected'));
858
+
859
+ // 挿入先を決定: 選択要素がある場合はその直後、なければbodyの末尾
860
+ let insertAfterElement: HTMLElement | null = null;
861
+ if (selectedElement) {
862
+ insertAfterElement = getIframeElement(iframeDoc, selectedElement.id);
863
+ }
864
+
865
+ const pastedIds: string[] = [];
866
+ const pastedElements: HTMLElement[] = [];
867
+
868
+ list.forEach(serialized => {
869
+ const el = deserializeElement(serialized);
870
+ if (!el) return;
871
+
872
+ const newId = generateElementId('paste');
873
+ el.setAttribute('data-element-id', newId);
874
+ const left = parseInt(el.style.left || '0') || 0;
875
+ const top = parseInt(el.style.top || '0') || 0;
876
+ el.style.left = `${left + off.x}px`;
877
+ el.style.top = `${top + off.y}px`;
878
+
879
+ // 挿入先に応じて挿入
880
+ if (insertAfterElement && insertAfterElement.parentNode) {
881
+ // 選択要素の直後に挿入
882
+ insertAfterElement.parentNode.insertBefore(el, insertAfterElement.nextSibling);
883
+ // 次のペースト要素のために更新
884
+ insertAfterElement = el;
885
+ } else {
886
+ // 版面(スライドのルート)の末尾へ。body に貼ると版面の外に落ちて見えない
887
+ const root =
888
+ (iframeDoc.querySelector('#artboard > [data-editable]') as HTMLElement | null) ??
889
+ (iframeDoc.getElementById('artboard') as HTMLElement | null) ??
890
+ iframeDoc.body;
891
+ root.appendChild(el);
892
+ }
893
+
894
+ pastedIds.push(newId);
895
+ pastedElements.push(el);
896
+ });
897
+
898
+ notifyIframeChange();
899
+
900
+ // ペーストした要素を選択し、選択ボックスを更新
901
+ if (pastedIds.length > 0) {
902
+ setSelectedElementIds(pastedIds);
903
+ pastedElements.forEach(el => {
904
+ el.classList.add('selected');
905
+ });
906
+ refreshSelectionOverlay(iframeDoc);
907
+ // 単一要素の場合は selectedElement も更新
908
+ if (pastedElements.length > 0) {
909
+ const info = extractElementInfo(pastedElements[0], iframeDoc);
910
+ if (info) setSelectedElement(info);
911
+ }
912
+ }
913
+ }, [getIframeDoc, deserializeElement, notifyIframeChange, selectedElement, setSelectedElementIds, setSelectedElement]);
914
+
915
+ // 内部クリップボードからのペースト(pasteイベント側のフォールバック用)
916
+ const pasteElements = useCallback(() => {
917
+ const clip = clipboardRef.current;
918
+ if (!clip) return;
919
+ pasteSerializedElements(clip.elements, clip.offset);
920
+ clip.offset.x += 10;
921
+ clip.offset.y += 10;
922
+ }, [clipboardRef, pasteSerializedElements]);
923
+
924
+ /**
925
+ * OSクリップボードにggペイロードが無いときのフォールバック判断。
926
+ * OS書込みに失敗した環境でだけ内部クリップボードを貼る。
927
+ * OS書込みが成功しているのにペイロードが無い = ユーザーが後から別のものを
928
+ * コピーしたということなので、内部の古い要素を貼ってはいけない
929
+ */
930
+ const pasteFromInternalIfFresh = useCallback((): boolean => {
931
+ const clip = clipboardRef.current;
932
+ if (!clip || clip.osWritten) return false;
933
+ pasteElements();
934
+ return true;
935
+ }, [clipboardRef, pasteElements]);
936
+
937
+ // 順序変更後の選択状態を更新するヘルパー
938
+ const updateSelectionAfterReorder = useCallback((el: HTMLElement, iframeDoc: Document) => {
939
+ // DOM変更後のレイアウト再計算を待ってから選択ボックスを更新
940
+ requestAnimationFrame(() => {
941
+ refreshSelectionOverlay(iframeDoc);
942
+ const info = extractElementInfo(el, iframeDoc);
943
+ if (info) {
944
+ setSelectedElement(info);
945
+ }
946
+ });
947
+ }, [setSelectedElement]);
948
+
949
+ // 前面へ
950
+ const bringForward = useCallback(() => {
951
+ if (!selectedElement) return;
952
+ const iframeDoc = getIframeDoc();
953
+ if (!iframeDoc) return;
954
+
955
+ const el = getIframeElement(iframeDoc, selectedElement.id);
956
+ if (!el || !el.nextElementSibling) return;
957
+ el.parentElement?.insertBefore(el.nextElementSibling, el);
958
+ notifyIframeChange();
959
+ updateSelectionAfterReorder(el, iframeDoc);
960
+ }, [selectedElement, getIframeDoc, notifyIframeChange, updateSelectionAfterReorder]);
961
+
962
+ // 背面へ
963
+ const sendBackward = useCallback(() => {
964
+ if (!selectedElement) return;
965
+ const iframeDoc = getIframeDoc();
966
+ if (!iframeDoc) return;
967
+
968
+ const el = getIframeElement(iframeDoc, selectedElement.id);
969
+ if (!el || !el.previousElementSibling) return;
970
+ el.parentElement?.insertBefore(el, el.previousElementSibling);
971
+ notifyIframeChange();
972
+ updateSelectionAfterReorder(el, iframeDoc);
973
+ }, [selectedElement, getIframeDoc, notifyIframeChange, updateSelectionAfterReorder]);
974
+
975
+ // 最前面へ
976
+ const bringToFront = useCallback(() => {
977
+ if (!selectedElement) return;
978
+ const iframeDoc = getIframeDoc();
979
+ if (!iframeDoc) return;
980
+
981
+ const el = getIframeElement(iframeDoc, selectedElement.id);
982
+ if (!el) return;
983
+ el.parentElement?.appendChild(el);
984
+ notifyIframeChange();
985
+ updateSelectionAfterReorder(el, iframeDoc);
986
+ }, [selectedElement, getIframeDoc, notifyIframeChange, updateSelectionAfterReorder]);
987
+
988
+ // 最背面へ
989
+ const sendToBack = useCallback(() => {
990
+ if (!selectedElement) return;
991
+ const iframeDoc = getIframeDoc();
992
+ if (!iframeDoc) return;
993
+
994
+ const el = getIframeElement(iframeDoc, selectedElement.id);
995
+ if (!el || !el.parentElement) return;
996
+ el.parentElement.insertBefore(el, el.parentElement.firstChild);
997
+ notifyIframeChange();
998
+ updateSelectionAfterReorder(el, iframeDoc);
999
+ }, [selectedElement, getIframeDoc, notifyIframeChange, updateSelectionAfterReorder]);
1000
+
1001
+ // バウンディングボックス計算
1002
+ /**
1003
+ * 選択要素を囲む矩形(CSSピクセル)を求める
1004
+ *
1005
+ * [移植時の修正] 位置を inline style から読むのをやめ、使用値から読む。
1006
+ * さらにサイズも getBoundingClientRect ではなく使用値(cs.width/height)を使う。
1007
+ * rect はキャンバスのズーム(transform:scale)が掛かった値なので、
1008
+ * CSSピクセルの left/top と足すとズーム倍率のぶんだけ矩形が縮んでいた。
1009
+ */
1010
+ const calculateBoundingBox = useCallback((elements: HTMLElement[]): BoundingBox => {
1011
+ let minLeft = Infinity, minTop = Infinity;
1012
+ let maxRight = -Infinity, maxBottom = -Infinity;
1013
+
1014
+ elements.forEach(el => {
1015
+ const view = el.ownerDocument.defaultView;
1016
+ const rect = el.getBoundingClientRect();
1017
+ const origin = view ? readUsedOffset(view, el) : null;
1018
+ const left = origin ? origin.left : (parseFloat(el.style.left) || 0);
1019
+ const top = origin ? origin.top : (parseFloat(el.style.top) || 0);
1020
+ const cs = view?.getComputedStyle(el);
1021
+ const width = parseFloat(cs?.width ?? '') || rect.width;
1022
+ const height = parseFloat(cs?.height ?? '') || rect.height;
1023
+ minLeft = Math.min(minLeft, left);
1024
+ minTop = Math.min(minTop, top);
1025
+ maxRight = Math.max(maxRight, left + width);
1026
+ maxBottom = Math.max(maxBottom, top + height);
1027
+ });
1028
+
1029
+ return {
1030
+ left: minLeft,
1031
+ top: minTop,
1032
+ width: maxRight - minLeft,
1033
+ height: maxBottom - minTop,
1034
+ right: maxRight,
1035
+ bottom: maxBottom,
1036
+ };
1037
+ }, []);
1038
+
1039
+ // グループ化
1040
+ const groupElements = useCallback(() => {
1041
+ const iframeDoc = getIframeDoc();
1042
+ if (!iframeDoc) return;
1043
+
1044
+ // [移植時の修正] 単一選択でもグループ化できるようにする。
1045
+ // Cmd+Shift+G(解除)は1要素で効くのに Cmd+G は2要素以上必須で非対称だった。
1046
+ const ids = selectedElementIds.length > 0
1047
+ ? selectedElementIds
1048
+ : selectedElement
1049
+ ? [selectedElement.id]
1050
+ : [];
1051
+ if (ids.length < 1) return;
1052
+
1053
+ const elements = ids
1054
+ .map(id => getIframeElement(iframeDoc, id))
1055
+ .filter((el): el is HTMLElement => el !== null);
1056
+
1057
+ if (elements.length < 1) return;
1058
+
1059
+ const bounds = calculateBoundingBox(elements);
1060
+
1061
+ // グループコンテナ作成
1062
+ const group = iframeDoc.createElement('div');
1063
+ const groupId = generateElementId('group');
1064
+ group.setAttribute('data-element-id', groupId);
1065
+ group.setAttribute('data-is-group', 'true');
1066
+ group.setAttribute('data-editable', 'true');
1067
+
1068
+ // オートレイアウトモードかどうかで異なるスタイルを適用
1069
+ if (layoutMode === 'auto') {
1070
+ // オートレイアウトモード: position absoluteなし、サイズのみ設定
1071
+ group.style.cssText = `
1072
+ display: flex;
1073
+ flex-direction: column;
1074
+ gap: 8px;
1075
+ `;
1076
+ } else {
1077
+ // 絶対配置モード: position absoluteで配置
1078
+ group.style.cssText = `
1079
+ position: absolute;
1080
+ left: ${bounds.left}px;
1081
+ top: ${bounds.top}px;
1082
+ width: ${bounds.width}px;
1083
+ height: ${bounds.height}px;
1084
+ `;
1085
+ }
1086
+
1087
+ // 最初の選択要素の親を取得(グループは同じ階層に挿入)
1088
+ const firstElement = elements[0];
1089
+ const parentElement = firstElement.parentElement || iframeDoc.body;
1090
+
1091
+ // 子要素をグループに移動(オートレイアウトモードでは位置変換なし)
1092
+ elements.forEach(el => {
1093
+ if (layoutMode !== 'auto') {
1094
+ // 絶対配置モード: 相対位置に変換。
1095
+ // 位置は inline ではなく使用値から読む(inline を持たない要素が0扱いで飛ぶため)。
1096
+ // フロー要素はここでは触らず、後段の convertSingleElementToAbsolute に任せる。
1097
+ const origin = readUsedOffset(iframeDoc.defaultView!, el);
1098
+ if (origin) {
1099
+ el.style.left = `${origin.left - bounds.left}px`;
1100
+ el.style.top = `${origin.top - bounds.top}px`;
1101
+ }
1102
+ } else {
1103
+ // オートレイアウトモード: position absoluteを解除
1104
+ el.style.position = '';
1105
+ el.style.left = '';
1106
+ el.style.top = '';
1107
+ }
1108
+ group.appendChild(el);
1109
+ });
1110
+
1111
+ // 最初の選択要素があった位置にグループを挿入
1112
+ parentElement.appendChild(group);
1113
+
1114
+ // グループコンテナの選択ボックスを削除
1115
+ iframeDoc.querySelectorAll('.selection-box').forEach(box => box.remove());
1116
+
1117
+ // フォントの読み込みを待ってから子要素を絶対配置に変換(絶対配置モードのみ)
1118
+ const convertChildrenToAbsolute = async () => {
1119
+ // オートレイアウトモードの場合は変換をスキップ
1120
+ if (layoutMode === 'auto') {
1121
+ console.log('[groupElements] Auto layout mode - skipping absolute positioning conversion');
1122
+ return;
1123
+ }
1124
+
1125
+ try {
1126
+ // フォント読み込み待機
1127
+ if (iframeDoc.fonts?.ready) {
1128
+ await iframeDoc.fonts.ready;
1129
+ }
1130
+ await new Promise(resolve => setTimeout(resolve, 50));
1131
+
1132
+ // グループ内の子要素を絶対配置に変換
1133
+ elements.forEach(el => {
1134
+ convertSingleElementToAbsolute(iframeDoc, el);
1135
+ });
1136
+
1137
+ console.log('[groupElements] Converted', elements.length, 'child elements to absolute positioning');
1138
+ notifyIframeChange();
1139
+ } catch (err) {
1140
+ console.error('[groupElements] Error converting children:', err);
1141
+ }
1142
+ };
1143
+
1144
+ convertChildrenToAbsolute();
1145
+
1146
+ // 作ったグループを選択状態にする(枠・プロパティパネルもグループを指す)
1147
+ iframeDoc.querySelectorAll('.selected').forEach(el => el.classList.remove('selected'));
1148
+ group.classList.add('selected');
1149
+ setSelectedElementIds([groupId]);
1150
+ refreshSelectionOverlay(iframeDoc);
1151
+ const groupInfo = extractElementInfo(group, iframeDoc);
1152
+ if (groupInfo) setSelectedElement(groupInfo);
1153
+ notifyIframeChange();
1154
+ }, [selectedElement, selectedElementIds, getIframeDoc, calculateBoundingBox, setSelectedElementIds, setSelectedElement, notifyIframeChange, layoutMode]);
1155
+
1156
+ // グループ解除(親要素を削除し、子要素を展開)
1157
+ // canUngroup関数で詳細な条件を判定
1158
+ const ungroupElements = useCallback(() => {
1159
+ if (!selectedElement) return;
1160
+ const iframeDoc = getIframeDoc();
1161
+ if (!iframeDoc) return;
1162
+
1163
+ const parent = getIframeElement(iframeDoc, selectedElement.id);
1164
+ if (!parent) return;
1165
+
1166
+ // グループ解除可能かどうかを判定(詳細な条件をチェック)
1167
+ if (!canUngroup(parent)) {
1168
+ console.log('[ungroupElements] Element cannot be ungrouped:', parent.tagName);
1169
+ return;
1170
+ }
1171
+
1172
+ // 編集可能な子要素を取得
1173
+ const children = Array.from(parent.children).filter(child => {
1174
+ const el = child as HTMLElement;
1175
+ // UI要素は除外
1176
+ if (el.classList?.contains('selection-box') ||
1177
+ el.classList?.contains('resize-handle') ||
1178
+ el.tagName === 'SCRIPT' ||
1179
+ el.tagName === 'STYLE') {
1180
+ return false;
1181
+ }
1182
+ // 編集可能な子要素のみ対象
1183
+ return el.getAttribute('data-editable') === 'true' ||
1184
+ el.hasAttribute('data-element-id');
1185
+ });
1186
+
1187
+ if (children.length === 0) {
1188
+ console.log('[ungroupElements] No editable children to ungroup');
1189
+ return;
1190
+ }
1191
+
1192
+ // 親要素の位置を取得
1193
+ const parentRect = parent.getBoundingClientRect();
1194
+ const parentLeft = parseInt(parent.style.left || '0') || 0;
1195
+ const parentTop = parseInt(parent.style.top || '0') || 0;
1196
+ const parentComputedStyle = iframeDoc.defaultView!.getComputedStyle(parent);
1197
+ const parentBorderLeft = parseFloat(parentComputedStyle.borderLeftWidth) || 0;
1198
+ const parentBorderTop = parseFloat(parentComputedStyle.borderTopWidth) || 0;
1199
+
1200
+ const childIds: string[] = [];
1201
+ const childElements: HTMLElement[] = [];
1202
+
1203
+ // 子要素を親の親に移動(またはbodyに)
1204
+ const grandParent = parent.parentElement || iframeDoc.body;
1205
+
1206
+ children.forEach(child => {
1207
+ const el = child as HTMLElement;
1208
+
1209
+ // data-element-idがない場合は付与
1210
+ if (!el.getAttribute('data-element-id')) {
1211
+ el.setAttribute('data-element-id', generateElementId('ungrouped'));
1212
+ }
1213
+
1214
+ // data-editableを付与
1215
+ if (!el.getAttribute('data-editable')) {
1216
+ el.setAttribute('data-editable', 'true');
1217
+ }
1218
+
1219
+ // 子要素の現在の位置を取得
1220
+ const childRect = el.getBoundingClientRect();
1221
+ const childLeft = parseInt(el.style.left || '0') || 0;
1222
+ const childTop = parseInt(el.style.top || '0') || 0;
1223
+
1224
+ // 親の位置を考慮して新しい位置を計算
1225
+ let newLeft: number;
1226
+ let newTop: number;
1227
+
1228
+ const childComputedStyle = iframeDoc.defaultView!.getComputedStyle(el);
1229
+ const isChildAbsolute = childComputedStyle.position === 'absolute' || childComputedStyle.position === 'fixed';
1230
+
1231
+ if (isChildAbsolute) {
1232
+ // すでに絶対配置の場合、親の位置を加算
1233
+ newLeft = parentLeft + childLeft + parentBorderLeft;
1234
+ newTop = parentTop + childTop + parentBorderTop;
1235
+ } else {
1236
+ // 相対配置やstaticの場合、getBoundingClientRectから計算
1237
+ const bodyRect = iframeDoc.body.getBoundingClientRect();
1238
+ newLeft = childRect.left - bodyRect.left;
1239
+ newTop = childRect.top - bodyRect.top;
1240
+ }
1241
+
1242
+ // 位置とサイズを設定
1243
+ el.style.position = 'absolute';
1244
+ el.style.left = `${newLeft}px`;
1245
+ el.style.top = `${newTop}px`;
1246
+ el.style.width = `${childRect.width}px`;
1247
+ el.style.height = `${childRect.height}px`;
1248
+ el.style.margin = '0';
1249
+
1250
+ grandParent.appendChild(el);
1251
+
1252
+ const childId = el.getAttribute('data-element-id');
1253
+ if (childId) {
1254
+ childIds.push(childId);
1255
+ childElements.push(el);
1256
+ }
1257
+ });
1258
+
1259
+ // 親要素を削除
1260
+ parent.remove();
1261
+
1262
+ // 選択ボックスをクリア
1263
+ iframeDoc.querySelectorAll('.selection-box').forEach(box => box.remove());
1264
+
1265
+ // フォントの読み込みを待ってから再度絶対配置に変換(レイアウト確定後)
1266
+ const finalizePositioning = async () => {
1267
+ // オートレイアウトモードの場合は変換をスキップ
1268
+ if (layoutMode === 'auto') {
1269
+ console.log('[ungroupElements] Auto layout mode - skipping absolute positioning conversion');
1270
+ return;
1271
+ }
1272
+
1273
+ try {
1274
+ if (iframeDoc.fonts?.ready) {
1275
+ await iframeDoc.fonts.ready;
1276
+ }
1277
+ await new Promise(resolve => setTimeout(resolve, 50));
1278
+
1279
+ // 子要素を再度絶対配置に変換
1280
+ childElements.forEach(el => {
1281
+ convertSingleElementToAbsolute(iframeDoc, el);
1282
+ });
1283
+
1284
+ console.log('[ungroupElements] Converted', childElements.length, 'elements to absolute positioning');
1285
+ notifyIframeChange();
1286
+ } catch (err) {
1287
+ console.error('[ungroupElements] Error finalizing positions:', err);
1288
+ }
1289
+ };
1290
+
1291
+ finalizePositioning();
1292
+
1293
+ setSelectedElementIds(childIds);
1294
+ setSelectedElement(null);
1295
+ notifyIframeChange();
1296
+
1297
+ console.log('[ungroupElements] Ungrouped', childIds.length, 'elements from', selectedElement.id);
1298
+ }, [selectedElement, getIframeDoc, setSelectedElementIds, setSelectedElement, notifyIframeChange]);
1299
+
1300
+ /**
1301
+ * 要素移動(矢印キー用)
1302
+ *
1303
+ * [移植時の修正]
1304
+ * 1. 現在位置は inline style ではなく使用値(getComputedStyle)から読む。
1305
+ * → inline left/top を持たない要素が (0,0) へワープする問題の解消。
1306
+ * 2. position:static(フロー)の要素には left/top を書かない。
1307
+ * 書いても見た目は動かないのに DOM と「未保存」だけが汚れていたため。
1308
+ * 3. 選択の中に1つでも動かせない要素があれば **誰も動かさない**(all-or-nothing)。
1309
+ * 一部だけ動くと群がバラけるので、Figma と同じく群は必ず一体で動く。
1310
+ * 4. 移動後は必ず選択枠とプロパティパネルを更新する。
1311
+ */
1312
+ const moveElement = useCallback((dx: number, dy: number): MoveElementResult => {
1313
+ const iframeDoc = getIframeDoc();
1314
+ const view = iframeDoc?.defaultView;
1315
+ if (!iframeDoc || !view) return { moved: 0, blocked: 'no-selection' };
1316
+
1317
+ const ids = selectedElementIds.length > 0
1318
+ ? selectedElementIds
1319
+ : selectedElement
1320
+ ? [selectedElement.id]
1321
+ : [];
1322
+ if (ids.length === 0) return { moved: 0, blocked: 'no-selection' };
1323
+
1324
+ const elements = ids
1325
+ .map(id => getIframeElement(iframeDoc, id))
1326
+ .filter((el): el is HTMLElement => el !== null);
1327
+ if (elements.length === 0) return { moved: 0, blocked: 'no-selection' };
1328
+
1329
+ // [モード廃止] フロー内の要素は「動かせない」で終わらせず、その要素だけを
1330
+ // 絶対配置へ変換してから動かす(ドラッグと同じ遅延変換)。
1331
+ // 以前はここで弾いていたため、矢印キーが無反応になっていた。
1332
+ elements.forEach(el => {
1333
+ if (readUsedOffset(view, el) === null) {
1334
+ prepareElementDragOrigin(el, iframeDoc, { convertToAbsolute: true });
1335
+ }
1336
+ });
1337
+ const movable = elements.every(el => readUsedOffset(view, el) !== null);
1338
+ if (!movable) return { moved: 0, blocked: 'flow' };
1339
+
1340
+ elements.forEach(el => translateOutOfFlowElement(view, el, dx, dy));
1341
+
1342
+ notifyIframeChange();
1343
+
1344
+ // 枠とプロパティパネルをその場で追従させる
1345
+ refreshSelectionOverlay(iframeDoc);
1346
+ const anchor = selectedElement
1347
+ ? getIframeElement(iframeDoc, selectedElement.id) ?? elements[0]
1348
+ : elements[0];
1349
+ const info = extractElementInfo(anchor, iframeDoc);
1350
+ if (info) setSelectedElement(info);
1351
+
1352
+ return { moved: elements.length, blocked: null };
1353
+ }, [selectedElement, selectedElementIds, getIframeDoc, notifyIframeChange, setSelectedElement]);
1354
+
1355
+ /**
1356
+ * 選択中の全要素の寸法を変える(キーボードリサイズ用)。
1357
+ * Figmaと同じく左上を固定し、複数選択なら各要素がそれぞれ同じ量だけ変わる
1358
+ * (群としての比例スケールではない。それはハンドルの群リサイズが担う)。
1359
+ */
1360
+ const resizeElements = useCallback((dw: number, dh: number): MoveElementResult => {
1361
+ const iframeDoc = getIframeDoc();
1362
+ const view = iframeDoc?.defaultView;
1363
+ if (!iframeDoc || !view) return { moved: 0, blocked: 'no-selection' };
1364
+
1365
+ const ids = selectedElementIds.length > 0
1366
+ ? selectedElementIds
1367
+ : selectedElement
1368
+ ? [selectedElement.id]
1369
+ : [];
1370
+ const elements = ids
1371
+ .map(id => getIframeElement(iframeDoc, id))
1372
+ .filter((el): el is HTMLElement => el !== null);
1373
+ if (elements.length === 0) return { moved: 0, blocked: 'no-selection' };
1374
+
1375
+ // フロー内の要素は移動と同じ遅延変換で絶対配置へ倒してから触る
1376
+ elements.forEach(el => {
1377
+ if (readUsedOffset(view, el) === null) {
1378
+ prepareElementDragOrigin(el, iframeDoc, { convertToAbsolute: true });
1379
+ }
1380
+ });
1381
+
1382
+ elements.forEach(el => {
1383
+ if (dw !== 0) el.style.width = `${Math.max(8, el.offsetWidth + dw)}px`;
1384
+ if (dh !== 0) el.style.height = `${Math.max(8, el.offsetHeight + dh)}px`;
1385
+ });
1386
+
1387
+ notifyIframeChange();
1388
+ refreshSelectionOverlay(iframeDoc);
1389
+ const anchor = selectedElement
1390
+ ? getIframeElement(iframeDoc, selectedElement.id) ?? elements[0]
1391
+ : elements[0];
1392
+ const info = extractElementInfo(anchor, iframeDoc);
1393
+ if (info) setSelectedElement(info);
1394
+ return { moved: elements.length, blocked: null };
1395
+ }, [selectedElement, selectedElementIds, getIframeDoc, notifyIframeChange, setSelectedElement]);
1396
+
1397
+ // 移動用の個別関数(Shift+矢印=10px は呼び出し側が amount で指定する)
1398
+ const moveUp = useCallback((amount: number = 1) => moveElement(0, -amount), [moveElement]);
1399
+ const moveDown = useCallback((amount: number = 1) => moveElement(0, amount), [moveElement]);
1400
+ const moveLeft = useCallback((amount: number = 1) => moveElement(-amount, 0), [moveElement]);
1401
+ const moveRight = useCallback((amount: number = 1) => moveElement(amount, 0), [moveElement]);
1402
+
1403
+ // リンク属性更新(<a>タグのhref, target, title等)
1404
+ const updateLinkAttribute = useCallback((attrs: Record<string, string>) => {
1405
+ const iframeDoc = getIframeDoc();
1406
+ if (!iframeDoc || !selectedElement) return;
1407
+
1408
+ const el = getIframeElement(iframeDoc, selectedElement.id);
1409
+ if (!el) return;
1410
+
1411
+ // 要素自体が<a>タグの場合
1412
+ let anchor: HTMLAnchorElement | null = null;
1413
+ if (el.tagName === 'A') {
1414
+ anchor = el as HTMLAnchorElement;
1415
+ } else {
1416
+ // 親に<a>タグがある場合
1417
+ anchor = el.closest('a');
1418
+ }
1419
+
1420
+ if (!anchor) return;
1421
+
1422
+ // 属性を更新
1423
+ Object.entries(attrs).forEach(([attr, value]) => {
1424
+ if (value === '' || value === null) {
1425
+ anchor!.removeAttribute(attr);
1426
+ } else {
1427
+ anchor!.setAttribute(attr, value);
1428
+ }
1429
+ });
1430
+
1431
+ notifyIframeChange();
1432
+
1433
+ // 更新後の要素情報を再取得
1434
+ const info = extractElementInfo(el, iframeDoc);
1435
+ if (info) setSelectedElement(info);
1436
+ }, [selectedElement, getIframeDoc, notifyIframeChange, setSelectedElement]);
1437
+
1438
+ /**
1439
+ * [移植時の追加] 選択中の要素自身の属性を更新する(汎用)。
1440
+ * updateLinkAttribute は <a> を探して適用する専用実装のため、
1441
+ * <img> の src 差し替えなどには使えなかった。
1442
+ */
1443
+ const updateElementAttribute = useCallback((attrs: Record<string, string>) => {
1444
+ const iframeDoc = getIframeDoc();
1445
+ if (!iframeDoc || !selectedElement) return;
1446
+
1447
+ const el = getIframeElement(iframeDoc, selectedElement.id);
1448
+ if (!el) return;
1449
+
1450
+ Object.entries(attrs).forEach(([attr, value]) => {
1451
+ if (value === '' || value === null || value === undefined) {
1452
+ el.removeAttribute(attr);
1453
+ } else {
1454
+ el.setAttribute(attr, value);
1455
+ }
1456
+ });
1457
+
1458
+ notifyIframeChange();
1459
+
1460
+ const info = extractElementInfo(el, iframeDoc);
1461
+ if (info) setSelectedElement(info);
1462
+ }, [selectedElement, getIframeDoc, notifyIframeChange, setSelectedElement]);
1463
+
1464
+ /**
1465
+ * 選択中の要素からスタイルをコピー(Figmaライク)
1466
+ * Ctrl/Cmd + Alt + C
1467
+ */
1468
+ const copyStyle = useCallback(() => {
1469
+ console.log('[copyStyle] Called, selectedElement:', selectedElement?.id);
1470
+ const iframeDoc = getIframeDoc();
1471
+ if (!iframeDoc || !selectedElement) {
1472
+ console.log('[copyStyle] No element selected or no iframeDoc');
1473
+ return false;
1474
+ }
1475
+
1476
+ const el = getIframeElement(iframeDoc, selectedElement.id);
1477
+ if (!el) return false;
1478
+
1479
+ const computedStyle = iframeDoc.defaultView?.getComputedStyle(el);
1480
+ if (!computedStyle) return false;
1481
+
1482
+ // スタイルを抽出(位置・サイズ以外)
1483
+ const styleData: StyleClipboard = {
1484
+ // タイポグラフィ
1485
+ fontSize: parseFloat(computedStyle.fontSize) || undefined,
1486
+ fontFamily: computedStyle.fontFamily?.split(',')[0]?.replace(/['"]/g, '').trim() || undefined,
1487
+ fontWeight: computedStyle.fontWeight || undefined,
1488
+ lineHeight: computedStyle.lineHeight || undefined,
1489
+ letterSpacing: computedStyle.letterSpacing || undefined,
1490
+ textAlign: computedStyle.textAlign || undefined,
1491
+ textDecoration: computedStyle.textDecoration !== 'none' ? computedStyle.textDecoration : undefined,
1492
+ fontStyle: computedStyle.fontStyle !== 'normal' ? computedStyle.fontStyle : undefined,
1493
+ color: computedStyle.color || undefined,
1494
+
1495
+ // 背景・塗り
1496
+ backgroundColor: computedStyle.backgroundColor !== 'rgba(0, 0, 0, 0)' && computedStyle.backgroundColor !== 'transparent'
1497
+ ? computedStyle.backgroundColor : undefined,
1498
+ backgroundImage: computedStyle.backgroundImage !== 'none' ? computedStyle.backgroundImage : undefined,
1499
+ backgroundSize: computedStyle.backgroundSize || undefined,
1500
+ backgroundPosition: computedStyle.backgroundPosition || undefined,
1501
+ backgroundRepeat: computedStyle.backgroundRepeat || undefined,
1502
+ opacity: parseFloat(computedStyle.opacity) !== 1 ? parseFloat(computedStyle.opacity) : undefined,
1503
+
1504
+ // ボーダー
1505
+ borderWidth: parseFloat(computedStyle.borderWidth) || undefined,
1506
+ borderColor: computedStyle.borderColor !== 'rgba(0, 0, 0, 0)' && computedStyle.borderColor !== 'transparent'
1507
+ ? computedStyle.borderColor : undefined,
1508
+ borderStyle: computedStyle.borderStyle !== 'none' ? computedStyle.borderStyle : undefined,
1509
+ borderRadius: parseFloat(computedStyle.borderRadius) || undefined,
1510
+ borderRadiusTopLeft: parseFloat(computedStyle.borderTopLeftRadius) || undefined,
1511
+ borderRadiusTopRight: parseFloat(computedStyle.borderTopRightRadius) || undefined,
1512
+ borderRadiusBottomRight: parseFloat(computedStyle.borderBottomRightRadius) || undefined,
1513
+ borderRadiusBottomLeft: parseFloat(computedStyle.borderBottomLeftRadius) || undefined,
1514
+
1515
+ // シャドウ
1516
+ boxShadow: computedStyle.boxShadow !== 'none' ? computedStyle.boxShadow : undefined,
1517
+
1518
+ // フィルター
1519
+ filter: computedStyle.filter !== 'none' ? computedStyle.filter : undefined,
1520
+ mixBlendMode: computedStyle.mixBlendMode !== 'normal' ? computedStyle.mixBlendMode : undefined,
1521
+ backdropFilter: computedStyle.backdropFilter !== 'none' ? computedStyle.backdropFilter : undefined,
1522
+
1523
+ // パディング
1524
+ paddingTop: parseFloat(computedStyle.paddingTop) || undefined,
1525
+ paddingRight: parseFloat(computedStyle.paddingRight) || undefined,
1526
+ paddingBottom: parseFloat(computedStyle.paddingBottom) || undefined,
1527
+ paddingLeft: parseFloat(computedStyle.paddingLeft) || undefined,
1528
+
1529
+ // Flexbox(オートレイアウト)
1530
+ display: computedStyle.display === 'flex' || computedStyle.display === 'grid' ? computedStyle.display : undefined,
1531
+ flexDirection: computedStyle.flexDirection || undefined,
1532
+ flexWrap: computedStyle.flexWrap || undefined,
1533
+ justifyContent: computedStyle.justifyContent || undefined,
1534
+ alignItems: computedStyle.alignItems || undefined,
1535
+ gap: parseFloat(computedStyle.gap) || undefined,
1536
+ };
1537
+
1538
+ // undefinedのプロパティを削除
1539
+ const cleanedStyle = Object.fromEntries(
1540
+ Object.entries(styleData).filter(([, v]) => v !== undefined)
1541
+ ) as StyleClipboard;
1542
+
1543
+ styleClipboardRef.current = cleanedStyle;
1544
+ console.log('[copyStyle] Style copied:', cleanedStyle);
1545
+ console.log('[copyStyle] Clipboard after setting:', styleClipboardRef.current);
1546
+ return true;
1547
+ }, [selectedElement, getIframeDoc, styleClipboardRef]);
1548
+
1549
+ /**
1550
+ * コピーしたスタイルを選択中の要素に貼り付け(Figmaライク)
1551
+ * Ctrl/Cmd + Alt + V
1552
+ */
1553
+ const pasteStyle = useCallback(() => {
1554
+ console.log('[pasteStyle] Called');
1555
+ console.log('[pasteStyle] styleClipboardRef.current:', styleClipboardRef.current);
1556
+ const iframeDoc = getIframeDoc();
1557
+ if (!iframeDoc) {
1558
+ console.log('[pasteStyle] No iframeDoc');
1559
+ return false;
1560
+ }
1561
+
1562
+ const copiedStyle = styleClipboardRef.current;
1563
+ if (!copiedStyle || Object.keys(copiedStyle).length === 0) {
1564
+ console.log('[pasteStyle] No style in clipboard');
1565
+ return false;
1566
+ }
1567
+
1568
+ // 対象要素を取得(複数選択対応)
1569
+ const targetIds = selectedElementIds.length > 0
1570
+ ? selectedElementIds
1571
+ : selectedElement ? [selectedElement.id] : [];
1572
+
1573
+ if (targetIds.length === 0) {
1574
+ console.log('[pasteStyle] No element selected');
1575
+ return false;
1576
+ }
1577
+
1578
+ // スタイルをCSS形式に変換
1579
+ const styles: Record<string, string> = {};
1580
+
1581
+ // タイポグラフィ
1582
+ if (copiedStyle.fontSize) styles.fontSize = `${copiedStyle.fontSize}px`;
1583
+ if (copiedStyle.fontFamily) styles.fontFamily = copiedStyle.fontFamily;
1584
+ if (copiedStyle.fontWeight) styles.fontWeight = copiedStyle.fontWeight;
1585
+ if (copiedStyle.lineHeight) styles.lineHeight = copiedStyle.lineHeight;
1586
+ if (copiedStyle.letterSpacing) styles.letterSpacing = copiedStyle.letterSpacing;
1587
+ if (copiedStyle.textAlign) styles.textAlign = copiedStyle.textAlign;
1588
+ if (copiedStyle.textDecoration) styles.textDecoration = copiedStyle.textDecoration;
1589
+ if (copiedStyle.fontStyle) styles.fontStyle = copiedStyle.fontStyle;
1590
+ if (copiedStyle.color) styles.color = copiedStyle.color;
1591
+
1592
+ // 背景・塗り
1593
+ if (copiedStyle.backgroundColor) styles.backgroundColor = copiedStyle.backgroundColor;
1594
+ if (copiedStyle.backgroundImage) styles.backgroundImage = copiedStyle.backgroundImage;
1595
+ if (copiedStyle.backgroundSize) styles.backgroundSize = copiedStyle.backgroundSize;
1596
+ if (copiedStyle.backgroundPosition) styles.backgroundPosition = copiedStyle.backgroundPosition;
1597
+ if (copiedStyle.backgroundRepeat) styles.backgroundRepeat = copiedStyle.backgroundRepeat;
1598
+ if (copiedStyle.opacity !== undefined) styles.opacity = String(copiedStyle.opacity);
1599
+
1600
+ // ボーダー
1601
+ if (copiedStyle.borderWidth) styles.borderWidth = `${copiedStyle.borderWidth}px`;
1602
+ if (copiedStyle.borderColor) styles.borderColor = copiedStyle.borderColor;
1603
+ if (copiedStyle.borderStyle) styles.borderStyle = copiedStyle.borderStyle;
1604
+ if (copiedStyle.borderRadius) styles.borderRadius = `${copiedStyle.borderRadius}px`;
1605
+ if (copiedStyle.borderRadiusTopLeft) styles.borderTopLeftRadius = `${copiedStyle.borderRadiusTopLeft}px`;
1606
+ if (copiedStyle.borderRadiusTopRight) styles.borderTopRightRadius = `${copiedStyle.borderRadiusTopRight}px`;
1607
+ if (copiedStyle.borderRadiusBottomRight) styles.borderBottomRightRadius = `${copiedStyle.borderRadiusBottomRight}px`;
1608
+ if (copiedStyle.borderRadiusBottomLeft) styles.borderBottomLeftRadius = `${copiedStyle.borderRadiusBottomLeft}px`;
1609
+
1610
+ // シャドウ
1611
+ if (copiedStyle.boxShadow) styles.boxShadow = copiedStyle.boxShadow;
1612
+
1613
+ // フィルター
1614
+ if (copiedStyle.filter) styles.filter = copiedStyle.filter;
1615
+ if (copiedStyle.mixBlendMode) styles.mixBlendMode = copiedStyle.mixBlendMode;
1616
+ if (copiedStyle.backdropFilter) styles.backdropFilter = copiedStyle.backdropFilter;
1617
+
1618
+ // パディング
1619
+ if (copiedStyle.paddingTop) styles.paddingTop = `${copiedStyle.paddingTop}px`;
1620
+ if (copiedStyle.paddingRight) styles.paddingRight = `${copiedStyle.paddingRight}px`;
1621
+ if (copiedStyle.paddingBottom) styles.paddingBottom = `${copiedStyle.paddingBottom}px`;
1622
+ if (copiedStyle.paddingLeft) styles.paddingLeft = `${copiedStyle.paddingLeft}px`;
1623
+
1624
+ // Flexbox
1625
+ if (copiedStyle.display) styles.display = copiedStyle.display;
1626
+ if (copiedStyle.flexDirection) styles.flexDirection = copiedStyle.flexDirection;
1627
+ if (copiedStyle.flexWrap) styles.flexWrap = copiedStyle.flexWrap;
1628
+ if (copiedStyle.justifyContent) styles.justifyContent = copiedStyle.justifyContent;
1629
+ if (copiedStyle.alignItems) styles.alignItems = copiedStyle.alignItems;
1630
+ if (copiedStyle.gap) styles.gap = `${copiedStyle.gap}px`;
1631
+
1632
+ // 各要素にスタイルを適用
1633
+ targetIds.forEach(id => {
1634
+ const el = getIframeElement(iframeDoc, id);
1635
+ if (el) {
1636
+ applyTailwindStyles(el, styles);
1637
+ }
1638
+ });
1639
+
1640
+ notifyIframeChange();
1641
+
1642
+ // 選択要素の情報を更新
1643
+ if (selectedElement) {
1644
+ const el = getIframeElement(iframeDoc, selectedElement.id);
1645
+ if (el) {
1646
+ const info = extractElementInfo(el, iframeDoc);
1647
+ if (info) setSelectedElement(info);
1648
+ }
1649
+ }
1650
+
1651
+ console.log('[pasteStyle] Style pasted to', targetIds.length, 'element(s)');
1652
+ return true;
1653
+ }, [selectedElement, selectedElementIds, getIframeDoc, styleClipboardRef, notifyIframeChange, setSelectedElement]);
1654
+
1655
+ /**
1656
+ * スタイルクリップボードにスタイルがあるかどうか
1657
+ */
1658
+ const hasStyleInClipboard = useCallback(() => {
1659
+ return styleClipboardRef.current !== null && Object.keys(styleClipboardRef.current).length > 0;
1660
+ }, [styleClipboardRef]);
1661
+
1662
+ /**
1663
+ * 選択中の要素をFigma形式でクリップボードにコピー
1664
+ * Figmaに貼り付け可能な形式で出力する
1665
+ * Ctrl/Cmd + Shift + C
1666
+ */
1667
+ const copyToFigma = useCallback(async (): Promise<{ success: boolean; error?: string; nodeCount?: number }> => {
1668
+ const iframeDoc = getIframeDoc();
1669
+ if (!iframeDoc) {
1670
+ return { success: false, error: 'No iframe document available' };
1671
+ }
1672
+
1673
+ // Figmaエクスポートが利用可能か確認(スキーマがキャッシュされているか)
1674
+ if (!isFigmaExportAvailable()) {
1675
+ return {
1676
+ success: false,
1677
+ error: 'Figma export not available. Please paste from Figma first to initialize the schema.'
1678
+ };
1679
+ }
1680
+
1681
+ // 対象要素を取得
1682
+ const targetIds = selectedElementIds.length > 0
1683
+ ? selectedElementIds
1684
+ : selectedElement ? [selectedElement.id] : [];
1685
+
1686
+ if (targetIds.length === 0) {
1687
+ return { success: false, error: 'No element selected' };
1688
+ }
1689
+
1690
+ const elements = targetIds
1691
+ .map(id => getIframeElement(iframeDoc, id))
1692
+ .filter((el): el is HTMLElement => el !== null);
1693
+
1694
+ if (elements.length === 0) {
1695
+ return { success: false, error: 'Selected elements not found in document' };
1696
+ }
1697
+
1698
+ console.log('[copyToFigma] Copying', elements.length, 'element(s) to Figma format');
1699
+
1700
+ try {
1701
+ const result = await copyElementsToFigma(elements);
1702
+
1703
+ if (result.success) {
1704
+ console.log('[copyToFigma] Successfully copied', result.nodeCount, 'nodes to clipboard');
1705
+ return { success: true, nodeCount: result.nodeCount };
1706
+ } else {
1707
+ console.error('[copyToFigma] Failed:', result.error);
1708
+ return { success: false, error: result.error };
1709
+ }
1710
+ } catch (err) {
1711
+ const errorMessage = err instanceof Error ? err.message : 'Unknown error';
1712
+ console.error('[copyToFigma] Error:', errorMessage);
1713
+ return { success: false, error: errorMessage };
1714
+ }
1715
+ }, [selectedElement, selectedElementIds, getIframeDoc]);
1716
+
1717
+ /**
1718
+ * Figmaエクスポートが利用可能かどうか
1719
+ */
1720
+ const canCopyToFigma = useCallback(() => {
1721
+ return isFigmaExportAvailable();
1722
+ }, []);
1723
+
1724
+ return {
1725
+ updateElementStyle,
1726
+ updateLinkAttribute,
1727
+ updateElementAttribute,
1728
+ deleteElement,
1729
+ duplicateElement,
1730
+ copyElements,
1731
+ cutElements,
1732
+ pasteElements,
1733
+ pasteSerializedElements,
1734
+ pasteFromInternalIfFresh,
1735
+ copyStyle,
1736
+ pasteStyle,
1737
+ hasStyleInClipboard,
1738
+ copyToFigma,
1739
+ canCopyToFigma,
1740
+ bringForward,
1741
+ sendBackward,
1742
+ bringToFront,
1743
+ sendToBack,
1744
+ groupElements,
1745
+ ungroupElements,
1746
+ moveElement,
1747
+ resizeElements,
1748
+ moveUp,
1749
+ moveDown,
1750
+ moveLeft,
1751
+ moveRight,
1752
+ alignElements,
1753
+ };
1754
+ }