@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,2238 @@
1
+ /**
2
+ * DOM操作関連のユーティリティ関数
3
+ *
4
+ * Phase 2: パフォーマンス最適化
5
+ * - buildDomTree にWeakMapキャッシュを追加
6
+ */
7
+
8
+ import type { ElementCapture, DOMTreeNode, MarqueeState } from '../types';
9
+ import { removeConflictingClasses } from './tailwind-utils';
10
+
11
+ // ========================================
12
+ // buildDomTree キャッシュ(Phase 2 最適化)
13
+ // ========================================
14
+
15
+ /**
16
+ * DOMツリーキャッシュ
17
+ * WeakMapを使用してDocumentごとにキャッシュを保持
18
+ * Documentがガベージコレクションされると自動的にキャッシュも解放される
19
+ */
20
+ const domTreeCache = new WeakMap<Document, {
21
+ html: string;
22
+ tree: DOMTreeNode[];
23
+ }>();
24
+
25
+ /**
26
+ * キャッシュをクリア(テスト用またはDOM構造が大きく変更された場合)
27
+ */
28
+ export function clearDomTreeCache(iframeDoc?: Document): void {
29
+ if (iframeDoc) {
30
+ domTreeCache.delete(iframeDoc);
31
+ }
32
+ }
33
+
34
+ /**
35
+ * artboard-wrapperからズームスケールを取得
36
+ */
37
+ export function getArtboardScale(iframeDoc: Document): number {
38
+ const wrapper = iframeDoc.getElementById('artboard-wrapper');
39
+ if (!wrapper) return 1;
40
+
41
+ // まずインラインスタイルを確認
42
+ let transform = wrapper.style.transform;
43
+
44
+ // インラインスタイルがない場合はcomputedStyleを使用
45
+ if (!transform) {
46
+ const computedStyle = iframeDoc.defaultView?.getComputedStyle(wrapper);
47
+ transform = computedStyle?.transform || '';
48
+ }
49
+
50
+ // scale(x) 形式をパース
51
+ const scaleMatch = transform.match(/scale\(([^)]+)\)/);
52
+ if (scaleMatch) {
53
+ return parseFloat(scaleMatch[1]) || 1;
54
+ }
55
+
56
+ // matrix(a, b, c, d, e, f) 形式からスケールを抽出
57
+ const matrixMatch = transform.match(/matrix\(([^,]+),/);
58
+ if (matrixMatch) {
59
+ return parseFloat(matrixMatch[1]) || 1;
60
+ }
61
+
62
+ return 1;
63
+ }
64
+
65
+ // ========================================
66
+ // グループ解除判定用の定数と関数
67
+ // ========================================
68
+
69
+ /**
70
+ * グループ解除可能なコンテナタグ
71
+ * これらのタグは、編集可能な子要素を持つ場合にグループ解除を許可
72
+ */
73
+ const ALLOWED_CONTAINER_TAGS = new Set([
74
+ 'DIV', 'SECTION', 'ARTICLE', 'HEADER', 'FOOTER',
75
+ 'NAV', 'MAIN', 'ASIDE', 'FIGURE', 'FIGCAPTION',
76
+ 'FORM', 'FIELDSET', 'DETAILS', 'SUMMARY',
77
+ 'UL', 'OL', 'DL', // リストコンテナ
78
+ ]);
79
+
80
+ /**
81
+ * グループ解除を禁止するタグ
82
+ * これらのタグは内部構造が壊れる可能性があるため保護
83
+ */
84
+ const PROTECTED_TAGS = new Set([
85
+ 'BUTTON', 'A', 'LABEL', // インタラクティブ要素
86
+ 'INPUT', 'SELECT', 'TEXTAREA', // フォーム入力
87
+ 'IMG', 'VIDEO', 'AUDIO', 'CANVAS', 'SVG', // メディア
88
+ 'TABLE', 'THEAD', 'TBODY', 'TR', 'TD', 'TH', // テーブル
89
+ 'P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'SPAN', // テキスト要素
90
+ 'CODE', 'PRE', 'BLOCKQUOTE', // コードブロック
91
+ ]);
92
+
93
+ /**
94
+ * 要素が編集可能な子要素を持つかどうかを判定
95
+ * UI要素(selection-box等)は除外
96
+ */
97
+ export function hasEditableChildren(element: HTMLElement): boolean {
98
+ return Array.from(element.children).some(child => {
99
+ const el = child as HTMLElement;
100
+ // UI要素を除外
101
+ if (el.classList?.contains('selection-box') ||
102
+ el.classList?.contains('resize-handle') ||
103
+ el.tagName === 'SCRIPT' ||
104
+ el.tagName === 'STYLE') {
105
+ return false;
106
+ }
107
+ // data-editable または data-element-id があれば編集可能な子
108
+ return el.getAttribute('data-editable') === 'true' ||
109
+ el.hasAttribute('data-element-id');
110
+ });
111
+ }
112
+
113
+ /**
114
+ * 要素がグループ解除可能かどうかを判定
115
+ *
116
+ * 【許可条件】以下のいずれかを満たす:
117
+ * 1. data-is-group="true" 属性がある(明示的なグループ)
118
+ * 2. 以下の全てを満たすコンテナ要素:
119
+ * - 許可されたコンテナタグである
120
+ * - data-editable="true" を持つ子要素が1つ以上ある
121
+ * - 禁止タグリストに含まれない
122
+ */
123
+ export function canUngroup(element: HTMLElement): boolean {
124
+ // 条件1: 明示的なグループは常に解除可能(子要素があれば)
125
+ if (element.getAttribute('data-is-group') === 'true') {
126
+ return hasEditableChildren(element);
127
+ }
128
+
129
+ // 条件2: 禁止タグは絶対不可
130
+ if (PROTECTED_TAGS.has(element.tagName)) {
131
+ return false;
132
+ }
133
+
134
+ // 条件3: 許可されたコンテナタグ + 編集可能な子要素あり
135
+ if (ALLOWED_CONTAINER_TAGS.has(element.tagName)) {
136
+ return hasEditableChildren(element);
137
+ }
138
+
139
+ // その他: 不許可
140
+ return false;
141
+ }
142
+
143
+ /**
144
+ * transformからtranslate部分を除去してrotate/scaleを保持
145
+ */
146
+ function preserveTransformWithoutTranslate(transform: string): string {
147
+ if (!transform || transform === 'none') return '';
148
+
149
+ // translate関連を削除
150
+ const preserved = transform
151
+ .replace(/translate3d\([^)]+\)/g, '')
152
+ .replace(/translateX\([^)]+\)/g, '')
153
+ .replace(/translateY\([^)]+\)/g, '')
154
+ .replace(/translate\([^)]+\)/g, '')
155
+ .trim();
156
+
157
+ return preserved && preserved !== 'none' ? preserved : '';
158
+ }
159
+
160
+ /**
161
+ * iframe内の要素を取得
162
+ */
163
+ export function getIframeElement(
164
+ iframeDoc: Document | null,
165
+ elementId: string
166
+ ): HTMLElement | null {
167
+ if (!iframeDoc) return null;
168
+ return iframeDoc.querySelector(`[data-element-id="${elementId}"]`) as HTMLElement | null;
169
+ }
170
+
171
+ /**
172
+ * artboard内のコンテンツHTMLを取得
173
+ * キャンバス構造(#canvas-container等)を含めず、純粋なコンテンツのみを返す
174
+ * SLIDE_CONTENT_CHANGEDメッセージで送信する際は必ずこの関数を使用すること
175
+ */
176
+ export function getArtboardContent(iframeDoc: Document | null): string {
177
+ if (!iframeDoc) return '';
178
+ const artboard = iframeDoc.getElementById('artboard');
179
+ return artboard ? artboard.innerHTML : iframeDoc.body.innerHTML;
180
+ }
181
+
182
+ // インライン要素のタグ名リスト(絶対配置に変換しない)
183
+ const INLINE_TAGS = new Set([
184
+ 'SPAN', 'A', 'STRONG', 'EM', 'B', 'I', 'U', 'S', 'MARK', 'CODE',
185
+ 'SMALL', 'SUB', 'SUP', 'ABBR', 'CITE', 'DFN', 'KBD', 'SAMP', 'VAR',
186
+ 'TIME', 'Q', 'BR', 'WBR', 'LABEL',
187
+ ]);
188
+
189
+ /**
190
+ * 要素がインライン要素かどうかを判定
191
+ */
192
+ export function isInlineElement(element: HTMLElement, computedStyle?: CSSStyleDeclaration): boolean {
193
+ // タグ名でチェック
194
+ if (INLINE_TAGS.has(element.tagName)) {
195
+ return true;
196
+ }
197
+
198
+ // display プロパティでチェック
199
+ if (computedStyle) {
200
+ const display = computedStyle.display;
201
+ if (display === 'inline' || display === 'inline-block') {
202
+ // ただし、テキストを含まない場合(アイコンなど)は除外
203
+ const hasOnlyTextContent = element.childNodes.length === 0 ||
204
+ Array.from(element.childNodes).every(n => n.nodeType === Node.TEXT_NODE);
205
+ if (hasOnlyTextContent && element.closest('p, h1, h2, h3, h4, h5, h6, li, td, th, span')) {
206
+ return true;
207
+ }
208
+ }
209
+ }
210
+
211
+ return false;
212
+ }
213
+
214
+ /**
215
+ * 単一の要素を絶対配置に変換(改善版)
216
+ * AI再生成後など、単一要素を変換する場合に使用
217
+ * @param iframeDoc iframeのドキュメント
218
+ * @param element 変換対象の要素
219
+ * @returns 変換が成功したかどうか
220
+ */
221
+ /**
222
+ * ズームのDOM反映(トランスフォーム+スクロール領域サイズ+収まる軸の中央寄せ)。
223
+ *
224
+ * 【なぜReactを介さないか】ホイール/ピンチは毎秒数十回来る。1ティックごとに
225
+ * setZoom→全ツリー再レンダー(サムネイル150枚を含む)を回すと確実にガタつく。
226
+ * 入力ハンドラはこの関数で**その場でDOMに反映**し、Reactの状態へは
227
+ * 間引いてコミットする(useCanvasControlsのzoomAtPoint参照)。
228
+ * EditorCanvas側のエフェクト(スライダー等の状態駆動の経路)も同じ関数を使い、
229
+ * 適用ロジックの二重実装を作らない。
230
+ */
231
+ export function applyCanvasZoomDom(iframeDoc: Document, zoomPct: number): void {
232
+ const wrapper = iframeDoc.getElementById('artboard-wrapper');
233
+ const scrollArea = iframeDoc.getElementById('canvas-scroll-area');
234
+ const container = iframeDoc.getElementById('canvas-container');
235
+ const artboard = iframeDoc.getElementById('artboard');
236
+ if (!wrapper || !scrollArea || !container || !artboard) return;
237
+
238
+ const scale = zoomPct / 100;
239
+ wrapper.style.willChange = 'transform';
240
+ wrapper.style.transform = `scale(${scale})`;
241
+
242
+ const w = artboard.offsetWidth;
243
+ const h = artboard.offsetHeight;
244
+ const padding = 200;
245
+ const cw = container.clientWidth || iframeDoc.defaultView?.innerWidth || 0;
246
+ const ch = container.clientHeight || iframeDoc.defaultView?.innerHeight || 0;
247
+ scrollArea.style.width = `${Math.max(w * scale + padding * 2, cw)}px`;
248
+ scrollArea.style.height = `${Math.max(h * scale + padding * 2, ch)}px`;
249
+ scrollArea.style.minWidth = `${cw}px`;
250
+ scrollArea.style.minHeight = `${ch}px`;
251
+
252
+ // 紙面が容器に収まる軸はスクロールを中央へ(拡大時のパンには干渉しない)
253
+ if (w * scale <= cw) container.scrollLeft = Math.max(0, (scrollArea.offsetWidth - cw) / 2);
254
+ if (h * scale <= ch) container.scrollTop = Math.max(0, (scrollArea.offsetHeight - ch) / 2);
255
+ }
256
+
257
+ /* ============================ 原本への書き戻し支援 ============================
258
+ *
259
+ * エディタは開いた時点で全要素を絶対配置へ変換する。この変換はエディタ内の
260
+ * 都合であって「ユーザーの編集」ではないので、保存HTMLにそのまま焼き込むと
261
+ * 原本TSXとの差分が変換ノイズで埋まり、書き戻し(scripts/slide-writeback.mjs)が
262
+ * 成立しない。そこで:
263
+ * 1. 変換で style を書く直前に、元の style 属性を data-gg-prestyle へ退避する
264
+ * 2. 開いた直後(変換後)の姿を data-gg-base(指紋)として全要素に刻む
265
+ * 3. 保存時、指紋が変わっていない要素は style を退避値へ巻き戻す
266
+ * 結果、保存HTMLは「原本のレイアウト + 本当に編集した内容」だけになる。
267
+ */
268
+
269
+ /** 変換前の style 属性を退避する(最初の1回だけ。'__none__' = 属性なし) */
270
+ export function capturePrestyle(element: HTMLElement): void {
271
+ if (element.hasAttribute('data-gg-prestyle')) return;
272
+ element.setAttribute('data-gg-prestyle', element.getAttribute('style') ?? '__none__');
273
+ }
274
+
275
+ /** 指紋の計算から外す属性(エディタの管理用・退避用) */
276
+ const SIG_SKIP_ATTRS = new Set([
277
+ 'style', 'class', 'contenteditable', 'spellcheck', 'draggable',
278
+ 'data-editable', 'data-element-id', 'data-shape-type', 'data-inline',
279
+ 'data-gg-base', 'data-gg-prestyle', 'data-gg-dirty', 'data-gg-pre-overflow',
280
+ ]);
281
+
282
+ /** 指紋の計算から外すクラス(エディタが一時的に付けるもの) */
283
+ const SIG_SKIP_CLASSES = new Set([
284
+ 'selected', 'dragging', 'editing', 'rotating', 'panning',
285
+ 'hover-preview', 'marquee-hover', 'marquee-active', 'text-editable-hover', 'drag-ghost',
286
+ ]);
287
+
288
+ /** 変換が書き込むスタイル(=幾何)。ユーザーの移動・リサイズもここに現れる */
289
+ const SIG_GEO_PROPS = new Set([
290
+ 'position', 'left', 'top', 'right', 'bottom', 'width', 'height',
291
+ // ブラウザは left/top/right/bottom を inset 短縮形へ直列化することがある。
292
+ // これを幾何に数えないと「移動」が style 側の変更として誤分類される
293
+ 'inset', 'inset-block', 'inset-inline',
294
+ 'margin', 'flex', 'flex-grow', 'flex-shrink', 'flex-basis', 'transform',
295
+ ]);
296
+
297
+ const sigHash = (s: string): string => {
298
+ let x = 5381;
299
+ for (let i = 0; i < s.length; i++) x = ((x * 33) ^ s.charCodeAt(i)) >>> 0;
300
+ return x.toString(36);
301
+ };
302
+
303
+ /**
304
+ * 要素の指紋。「幾何.その他スタイルと属性.直下の文字.子タグ列」の4部で、
305
+ * 保存時にどの側面を触ったか(geometry / style / text / children)を切り分ける。
306
+ * 検査は自分自身のみ(子孫の変更は子孫自身と、親の children 部で捕まえる)。
307
+ */
308
+ export function elementSignature(el: HTMLElement): string {
309
+ const style = el.getAttribute('style') ?? '';
310
+ const geo: string[] = [];
311
+ const rest: string[] = [];
312
+ for (const decl of style.split(';')) {
313
+ const c = decl.indexOf(':');
314
+ if (c < 0) continue;
315
+ const k = decl.slice(0, c).trim().toLowerCase();
316
+ const v = decl.slice(c + 1).trim();
317
+ if (!k) continue;
318
+ // ブラウザは right:auto 等が加わると left/top を inset 短縮形へまとめ直す。
319
+ // 表記が変わっただけで「幾何を触った」と誤判定しないよう、
320
+ // inset は縦横へ展開し、auto(=指定なしと同義)は指紋から落とす
321
+ if (k === 'inset') {
322
+ const parts = v.split(/\s+/);
323
+ const [t, r, b, l] =
324
+ parts.length === 1 ? [parts[0], parts[0], parts[0], parts[0]]
325
+ : parts.length === 2 ? [parts[0], parts[1], parts[0], parts[1]]
326
+ : parts.length === 3 ? [parts[0], parts[1], parts[2], parts[1]]
327
+ : parts;
328
+ for (const [kk, vv] of [['top', t], ['right', r], ['bottom', b], ['left', l]] as const) {
329
+ if (vv && vv !== 'auto') geo.push(`${kk}:${vv}`);
330
+ }
331
+ continue;
332
+ }
333
+ if ((k === 'right' || k === 'bottom' || k === 'left' || k === 'top') && v === 'auto') continue;
334
+ (SIG_GEO_PROPS.has(k) ? geo : rest).push(`${k}:${v}`);
335
+ }
336
+ const classes = (el.getAttribute('class') ?? '')
337
+ .split(/\s+/)
338
+ .filter((t) => t && !SIG_SKIP_CLASSES.has(t))
339
+ .sort();
340
+ const attrs: string[] = [];
341
+ for (const a of Array.from(el.attributes)) {
342
+ if (SIG_SKIP_ATTRS.has(a.name) || a.name.startsWith('data-original-')) continue;
343
+ attrs.push(`${a.name}=${a.value}`);
344
+ }
345
+ let text = '';
346
+ el.childNodes.forEach((n) => {
347
+ if (n.nodeType === Node.TEXT_NODE) text += n.textContent ?? '';
348
+ });
349
+ const childTags: string[] = [];
350
+ for (const c of Array.from(el.children)) childTags.push(c.tagName);
351
+ return [
352
+ sigHash(geo.sort().join(';')),
353
+ sigHash(rest.sort().join(';') + '|' + classes.join(' ') + '|' + attrs.sort().join(' ')),
354
+ sigHash(text.replace(/\s+/g, ' ').trim()),
355
+ sigHash(childTags.join(',')),
356
+ ].join('.');
357
+ }
358
+
359
+ /** 開いた直後の姿を全要素に刻む(EditorCanvas の初期化末尾で呼ぶ) */
360
+ export function stampBaselines(iframeDoc: Document): number {
361
+ const artboard = iframeDoc.getElementById('artboard');
362
+ if (!artboard) return 0;
363
+ let n = 0;
364
+ artboard.querySelectorAll<HTMLElement>('*').forEach((el) => {
365
+ if (el.closest('.selection-box, .marquee-selection-box')) return;
366
+ el.setAttribute('data-gg-base', elementSignature(el));
367
+ n++;
368
+ });
369
+ return n;
370
+ }
371
+
372
+ /**
373
+ * 保存用クローンに対して:
374
+ * - 指紋が変わっていない要素 → style を変換前(data-gg-prestyle)へ巻き戻す
375
+ * - 変わった要素 → data-gg-dirty="geometry,text,…" を付ける(書き戻しの手掛かり)
376
+ * - ただし「元は流し込み(in-flow)だった要素の幾何」を触った場合、その親と兄弟は
377
+ * 巻き戻さない。1つだけ絶対配置に変えると兄弟が詰まり、編集画面の見た目と
378
+ * 保存結果が食い違うため(親子まとめて絶対配置のまま原本へ書く)
379
+ * 最後に data-gg-base / data-gg-prestyle を剥がす。
380
+ */
381
+ /**
382
+ * 幾何プロパティは変換前(pres)の値、それ以外は現在(cur)の値でスタイルを組み直す。
383
+ * 「色は変えたが位置は触っていない」要素から変換の焼き込みだけを取り除くために使う。
384
+ */
385
+ function mergeStyleKeepingPresGeometry(pres: string, cur: string): string {
386
+ const parse = (css: string): [string, string][] => {
387
+ const out: [string, string][] = [];
388
+ for (const decl of css.split(';')) {
389
+ const c = decl.indexOf(':');
390
+ if (c < 0) continue;
391
+ const k = decl.slice(0, c).trim().toLowerCase();
392
+ const v = decl.slice(c + 1).trim();
393
+ if (k) out.push([k, v]);
394
+ }
395
+ return out;
396
+ };
397
+ const presProps = parse(pres);
398
+ const curProps = parse(cur);
399
+ const out: string[] = [];
400
+ // 変換前の並びを土台に: 幾何は変換前の値、他は現在の値(消されたものは落とす)
401
+ const curMap = new Map(curProps);
402
+ for (const [k, v] of presProps) {
403
+ if (SIG_GEO_PROPS.has(k)) out.push(`${k}: ${v}`);
404
+ else if (curMap.has(k)) out.push(`${k}: ${curMap.get(k)}`);
405
+ }
406
+ const seen = new Set(presProps.map(([k]) => k));
407
+ for (const [k, v] of curProps) {
408
+ if (seen.has(k) || SIG_GEO_PROPS.has(k)) continue;
409
+ out.push(`${k}: ${v}`);
410
+ }
411
+ return out.join('; ');
412
+ }
413
+
414
+ export function classifySaveAndRevert(container: HTMLElement): void {
415
+ const infos: { el: HTMLElement; dirty: string[] }[] = [];
416
+ container.querySelectorAll<HTMLElement>('[data-gg-base]').forEach((el) => {
417
+ const base = (el.getAttribute('data-gg-base') ?? '').split('.');
418
+ const now = elementSignature(el).split('.');
419
+ const names = ['geometry', 'style', 'text', 'children'];
420
+ infos.push({ el, dirty: names.filter((_, i) => base[i] !== now[i]) });
421
+ });
422
+
423
+ const keep = new Set<HTMLElement>();
424
+ for (const { el, dirty } of infos) {
425
+ if (!dirty.includes('geometry')) continue;
426
+ const pres = el.getAttribute('data-gg-prestyle');
427
+ const presStyle = pres === '__none__' ? '' : (pres ?? '');
428
+ const wasFlow =
429
+ !/position\s*:\s*(absolute|fixed)/.test(presStyle) &&
430
+ !el.classList.contains('absolute') &&
431
+ !el.classList.contains('fixed');
432
+ if (!wasFlow) continue;
433
+ const parent = el.parentElement;
434
+ if (!parent || parent === container) continue;
435
+ keep.add(parent);
436
+ for (const sib of Array.from(parent.children)) keep.add(sib as HTMLElement);
437
+ }
438
+
439
+ for (const { el, dirty } of infos) {
440
+ const geoDirty = dirty.includes('geometry') || keep.has(el);
441
+ const styleDirty = dirty.includes('style');
442
+ const pres = el.getAttribute('data-gg-prestyle');
443
+ // 幾何を触っていないなら、変換が焼き込んだ幾何スタイルは全部ノイズ。
444
+ // 文字だけ直した要素に width:345px が固定されて残ると、原本TSXの
445
+ // レイアウト(auto幅)が壊れるので、ここで必ず戻す
446
+ if (!geoDirty && pres !== null) {
447
+ const presStyle = pres === '__none__' ? '' : pres;
448
+ if (!styleDirty) {
449
+ // 何もスタイルを触っていない → 丸ごと変換前へ
450
+ if (presStyle) el.setAttribute('style', presStyle);
451
+ else el.removeAttribute('style');
452
+ } else {
453
+ // 色などは触ったが幾何は触っていない → 幾何だけ変換前へ戻し、他は今の値
454
+ const merged = mergeStyleKeepingPresGeometry(presStyle, el.getAttribute('style') ?? '');
455
+ if (merged) el.setAttribute('style', merged);
456
+ else el.removeAttribute('style');
457
+ }
458
+ }
459
+ if (dirty.length) el.setAttribute('data-gg-dirty', dirty.join(','));
460
+ else if (keep.has(el)) el.setAttribute('data-gg-dirty', 'keep');
461
+ }
462
+
463
+ container.querySelectorAll('[data-gg-base], [data-gg-prestyle], [data-gg-pre-overflow]').forEach((el) => {
464
+ el.removeAttribute('data-gg-base');
465
+ el.removeAttribute('data-gg-prestyle');
466
+ el.removeAttribute('data-gg-pre-overflow');
467
+ });
468
+ }
469
+
470
+ export function convertSingleElementToAbsolute(iframeDoc: Document, element: HTMLElement): boolean {
471
+ const computedStyle = iframeDoc.defaultView!.getComputedStyle(element);
472
+
473
+ // インライン要素はスキップ
474
+ if (isInlineElement(element, computedStyle)) {
475
+ console.log('[convertSingleElementToAbsolute] Skipping inline element:', element.tagName);
476
+ return false;
477
+ }
478
+
479
+ // ズームスケールを取得
480
+ const scale = getArtboardScale(iframeDoc);
481
+
482
+ const rect = element.getBoundingClientRect();
483
+ const parent = element.parentElement;
484
+ const parentRect = parent ? parent.getBoundingClientRect() : null;
485
+ const parentComputedStyle = parent ? iframeDoc.defaultView!.getComputedStyle(parent) : null;
486
+
487
+ // 親要素の処理
488
+ if (parent && parent !== iframeDoc.body && parentComputedStyle) {
489
+ // position: relativeに設定(絶対配置の基準点として必要)
490
+ if (parentComputedStyle.position === 'static') {
491
+ capturePrestyle(parent);
492
+ parent.style.position = 'relative';
493
+ }
494
+ }
495
+
496
+ const position = computedStyle.position;
497
+ const isAlreadyPositioned = position === 'absolute' || position === 'fixed';
498
+
499
+ // 座標計算
500
+ let left: number;
501
+ let top: number;
502
+
503
+ if (parent && parent !== iframeDoc.body && parentRect) {
504
+ // 親要素基準で座標を計算(スケール考慮)
505
+ const parentStyle = iframeDoc.defaultView!.getComputedStyle(parent);
506
+ const parentBorderLeft = parseFloat(parentStyle.borderLeftWidth) || 0;
507
+ const parentBorderTop = parseFloat(parentStyle.borderTopWidth) || 0;
508
+ const parentScrollLeft = parent.scrollLeft || 0;
509
+ const parentScrollTop = parent.scrollTop || 0;
510
+
511
+ // スケールを考慮した座標変換
512
+ // getBoundingClientRect()はスケール適用後の値を返すため、スケールで割る
513
+ // 注意: position: absolute の座標は padding box を基準とする
514
+ // marginは引かない(rectは視覚位置を返し、margin:0にリセットするため)
515
+ left = (rect.left - parentRect.left) / scale - parentBorderLeft + parentScrollLeft;
516
+ top = (rect.top - parentRect.top) / scale - parentBorderTop + parentScrollTop;
517
+ } else {
518
+ // body または artboard 基準
519
+ const artboard = iframeDoc.getElementById('artboard');
520
+ const containerRect = artboard ? artboard.getBoundingClientRect() : iframeDoc.body.getBoundingClientRect();
521
+ const scrollX = iframeDoc.defaultView?.scrollX || 0;
522
+ const scrollY = iframeDoc.defaultView?.scrollY || 0;
523
+
524
+ // marginは引かない(rectは視覚位置を返し、margin:0にリセットするため)
525
+ left = (rect.left - containerRect.left) / scale + scrollX;
526
+ top = (rect.top - containerRect.top) / scale + scrollY;
527
+ }
528
+
529
+ // 幅と高さ(スケールを考慮)
530
+ const width = rect.width / scale;
531
+ const height = rect.height / scale;
532
+
533
+ // 元のtransformを保持(rotate/scaleのみ)
534
+ const originalTransform = computedStyle.transform;
535
+ const preservedTransform = preserveTransformWithoutTranslate(originalTransform);
536
+
537
+ // スタイルを適用
538
+ capturePrestyle(element);
539
+ if (!isAlreadyPositioned) {
540
+ element.style.position = 'absolute';
541
+ }
542
+ element.style.left = `${Math.round(left * 100) / 100}px`;
543
+ element.style.top = `${Math.round(top * 100) / 100}px`;
544
+ element.style.width = `${Math.round(width * 100) / 100}px`;
545
+ element.style.height = `${Math.round(height * 100) / 100}px`;
546
+
547
+ // marginをリセット(絶対配置では不要)
548
+ element.style.margin = '0';
549
+
550
+ // flex関連のプロパティを無効化
551
+ element.style.flex = 'none';
552
+ element.style.flexGrow = '0';
553
+ element.style.flexShrink = '0';
554
+
555
+ // transformを保持(rotate/scaleがある場合)
556
+ if (preservedTransform) {
557
+ element.style.transform = preservedTransform;
558
+ }
559
+
560
+ console.log('[convertSingleElementToAbsolute] Converted:', {
561
+ tagName: element.tagName,
562
+ left: Math.round(left * 100) / 100,
563
+ top: Math.round(top * 100) / 100,
564
+ width: Math.round(width * 100) / 100,
565
+ height: Math.round(height * 100) / 100,
566
+ scale,
567
+ preservedTransform,
568
+ });
569
+
570
+ return true;
571
+ }
572
+
573
+ /**
574
+ * 全ての編集可能要素を絶対配置に変換(改善版)
575
+ * document.fonts.ready後に呼び出すこと
576
+ * インライン要素(span, strong, emなど)はスキップ
577
+ *
578
+ * 改善点:
579
+ * - 親要素のpadding考慮
580
+ * - flex/grid親のdisplayリセット
581
+ * - ズームスケール考慮
582
+ * - transform(rotate/scale)保持
583
+ * - margin: auto 対応
584
+ */
585
+ export function convertToAbsolutePositioning(iframeDoc: Document): number {
586
+ let editableElements = iframeDoc.querySelectorAll('[data-editable="true"]');
587
+ // フォールバック: data-editableが無い場合(キャンバスモード等)
588
+ if (editableElements.length === 0) {
589
+ editableElements = iframeDoc.querySelectorAll('#artboard > [data-element-id]');
590
+ }
591
+ if (editableElements.length === 0) return 0;
592
+
593
+ // ズームスケールを取得
594
+ const scale = getArtboardScale(iframeDoc);
595
+ console.log('[convertToAbsolutePositioning] Scale:', scale);
596
+
597
+ // artboardの現在の高さをキャプチャして固定(絶対配置後も高さを維持するため)
598
+ const artboard = iframeDoc.getElementById('artboard');
599
+ if (artboard) {
600
+ const artboardRect = artboard.getBoundingClientRect();
601
+ const artboardHeight = artboardRect.height / scale;
602
+ // 元の高さが設定されていない場合のみ保存
603
+ if (!artboard.hasAttribute('data-original-height')) {
604
+ artboard.setAttribute('data-original-height', artboard.style.height || 'auto');
605
+ }
606
+ artboard.style.height = `${Math.round(artboardHeight)}px`;
607
+ console.log('[convertToAbsolutePositioning] Set artboard height:', artboardHeight);
608
+ }
609
+
610
+ // 1. まず全要素の現在の位置・サイズをキャプチャ(インライン要素は除外)
611
+ const captures: ElementCapture[] = [];
612
+
613
+ editableElements.forEach((el) => {
614
+ const element = el as HTMLElement;
615
+ const computedStyle = iframeDoc.defaultView!.getComputedStyle(element);
616
+
617
+ // [移植時の修正] flex/grid コンテナの子は「一部だけ変換」すると
618
+ // 残った子がレイアウトの先頭に詰められ、絶対配置した兄弟と重なる
619
+ // (例: アイコン<img>だけ変換され、隣の<span>が左端へ寄って重なる)。
620
+ // flexアイテム/gridアイテムは blockify されるため、インライン扱いせず
621
+ // すべて同じ基準で変換して見た目を保つ。
622
+ const parentEl = element.parentElement;
623
+ const parentDisplay = parentEl
624
+ ? iframeDoc.defaultView!.getComputedStyle(parentEl).display
625
+ : '';
626
+ const isFlexOrGridItem = /(^|\s)(inline-)?(flex|grid)($|\s)/.test(parentDisplay);
627
+
628
+ // インライン要素はスキップ(ただし flex/grid アイテムは除く)
629
+ if (!isFlexOrGridItem && isInlineElement(element, computedStyle)) {
630
+ return;
631
+ }
632
+
633
+ const rect = element.getBoundingClientRect();
634
+ const parent = element.parentElement;
635
+ const parentRect = parent ? parent.getBoundingClientRect() : null;
636
+ const parentComputedStyle = parent ? iframeDoc.defaultView!.getComputedStyle(parent) : null;
637
+
638
+ // 元のtransformを保存
639
+ const originalTransform = computedStyle.transform;
640
+
641
+ if (
642
+ element.scrollWidth - element.clientWidth > 2 ||
643
+ element.scrollHeight - element.clientHeight > 2
644
+ ) {
645
+ element.setAttribute('data-gg-pre-overflow', 'true');
646
+ }
647
+ captures.push({
648
+ element,
649
+ rect,
650
+ parent,
651
+ parentRect,
652
+ computedStyle,
653
+ parentComputedStyle,
654
+ // 拡張情報
655
+ originalTransform,
656
+ scale,
657
+ prevStyle: element.getAttribute('style'),
658
+ });
659
+ });
660
+
661
+ // 2. 親要素を処理(position: relative設定)
662
+ //
663
+ // [採寸より前にやる] offsetLeft/offsetTop は offsetParent 基準の値なので、
664
+ // 親を relative にしてからでないと「直近の親からの座標」にならない。
665
+ // position:relative を入れるだけでは要素は動かないので、採寸前に実行して安全。
666
+ const processedParents = new Set<HTMLElement>();
667
+ captures.forEach(({ parent, parentComputedStyle }) => {
668
+ if (parent && !processedParents.has(parent) && parent !== iframeDoc.body) {
669
+ const artboard = iframeDoc.getElementById('artboard');
670
+ // artboard自体は変更しない
671
+ if (parent === artboard) {
672
+ processedParents.add(parent);
673
+ return;
674
+ }
675
+
676
+ // position: staticの場合はrelativeに(絶対配置の基準点として必要)
677
+ if (parentComputedStyle?.position === 'static') {
678
+ capturePrestyle(parent);
679
+ parent.style.position = 'relative';
680
+ }
681
+ processedParents.add(parent);
682
+ }
683
+ });
684
+
685
+ // 2.5 offset* をまとめて採寸する
686
+ //
687
+ // **書き込みの前に全部測りきる**のが肝。1要素ずつ「測って書く」を繰り返すと、
688
+ // 先に絶対配置へ倒した要素が流れから抜け、その分だけ後続の兄弟の offsetTop が
689
+ // 変わってしまう(版面が上へ詰まっていく)。
690
+ // offsetParent が直近の親になっている素直なケースだけをここで拾い、
691
+ // それ以外は従来の rect ベースの計算に任せる。
692
+ const offsets = new Map<HTMLElement, { left: number; top: number; width: number; height: number }>();
693
+ captures.forEach(({ element, parent, rect, scale: capScale }) => {
694
+ if (!parent || parent === iframeDoc.body) return;
695
+ if (element.offsetParent !== parent) return;
696
+ // 寸法は offsetWidth(整数へ丸める)ではなく、rect の小数値を**切り上げ**て使う。
697
+ // 例: 必要幅1227.4pxの文字列を1227pxで固定すると最後の1文字だけが折り返す
698
+ // (284pxの「テキスト」で実害)。位置は1px未満の誤差が折り返しを生まないので offset で良い
699
+ const s = capScale || 1;
700
+ offsets.set(element, {
701
+ left: element.offsetLeft,
702
+ top: element.offsetTop,
703
+ width: Math.ceil(rect.width / s),
704
+ height: Math.ceil(rect.height / s),
705
+ });
706
+ });
707
+
708
+ // 3. 各要素を絶対配置に変換
709
+ captures.forEach(({
710
+ element,
711
+ rect,
712
+ parent,
713
+ parentRect,
714
+ computedStyle,
715
+ scale: capturedScale,
716
+ originalTransform,
717
+ }) => {
718
+ const position = computedStyle.position;
719
+ const isAlreadyPositioned = position === 'absolute' || position === 'fixed';
720
+ const currentScale = capturedScale || 1;
721
+
722
+ // 座標計算
723
+ let left: number;
724
+ let top: number;
725
+
726
+ // [まず offset* を使う]
727
+ // getBoundingClientRect をズーム倍率で割り戻す方式は、端数と transform の影響で
728
+ // 1〜2px ずれる。要素数が多いと版面全体が滲むので、offsetParent が直近の親に
729
+ // なっている素直なケースでは offsetLeft/offsetTop をそのまま使う。
730
+ // (offset* は CSSピクセルの値で、ズームや transform の影響を受けない)
731
+ const off = offsets.get(element);
732
+ if (off) {
733
+ capturePrestyle(element);
734
+ element.style.position = 'absolute';
735
+ element.style.left = `${off.left}px`;
736
+ element.style.top = `${off.top}px`;
737
+ element.style.width = `${off.width}px`;
738
+ element.style.height = `${off.height}px`;
739
+ element.style.margin = '0';
740
+ element.style.flex = 'none';
741
+ element.style.flexGrow = '0';
742
+ element.style.flexShrink = '0';
743
+ const keep = preserveTransformWithoutTranslate(originalTransform || '');
744
+ if (keep) element.style.transform = keep;
745
+ return;
746
+ }
747
+
748
+ if (parent && parent !== iframeDoc.body && parentRect) {
749
+ const artboard = iframeDoc.getElementById('artboard');
750
+
751
+ // 親要素基準で座標を計算(スケール考慮)
752
+ // parentRectは変換前にキャプチャした値を使用
753
+ const parentStyle = iframeDoc.defaultView!.getComputedStyle(parent);
754
+ const parentBorderLeft = parseFloat(parentStyle.borderLeftWidth) || 0;
755
+ const parentBorderTop = parseFloat(parentStyle.borderTopWidth) || 0;
756
+ const parentScrollLeft = parent.scrollLeft || 0;
757
+ const parentScrollTop = parent.scrollTop || 0;
758
+
759
+ // artboard直下の場合は特別処理
760
+ if (parent === artboard) {
761
+ // artboard基準:スケールを考慮
762
+ // marginは引かない(rectは視覚位置を返し、margin:0にリセットするため)
763
+ left = (rect.left - parentRect.left) / currentScale;
764
+ top = (rect.top - parentRect.top) / currentScale;
765
+ } else {
766
+ // 通常の親要素基準:スケールを考慮し、border のみ を引く
767
+ // 注意: position: absolute の座標は padding box を基準とする
768
+ // marginは引かない(rectは視覚位置を返し、margin:0にリセットするため)
769
+ left = (rect.left - parentRect.left) / currentScale - parentBorderLeft + parentScrollLeft;
770
+ top = (rect.top - parentRect.top) / currentScale - parentBorderTop + parentScrollTop;
771
+ }
772
+ } else {
773
+ // body または artboard 基準
774
+ const artboard = iframeDoc.getElementById('artboard');
775
+ const containerRect = artboard ? artboard.getBoundingClientRect() : iframeDoc.body.getBoundingClientRect();
776
+ const scrollX = iframeDoc.defaultView?.scrollX || 0;
777
+ const scrollY = iframeDoc.defaultView?.scrollY || 0;
778
+
779
+ // marginは引かない(rectは視覚位置を返し、margin:0にリセットするため)
780
+ left = (rect.left - containerRect.left) / currentScale + scrollX;
781
+ top = (rect.top - containerRect.top) / currentScale + scrollY;
782
+ }
783
+
784
+ // 幅と高さ(スケールを考慮)
785
+ const width = rect.width / currentScale;
786
+ const height = rect.height / currentScale;
787
+
788
+ // 元のtransformを保持(rotate/scaleのみ、translateは削除)
789
+ const preservedTransform = preserveTransformWithoutTranslate(originalTransform || '');
790
+
791
+ // スタイルを適用
792
+ capturePrestyle(element);
793
+ if (!isAlreadyPositioned) {
794
+ element.style.position = 'absolute';
795
+ }
796
+ element.style.left = `${Math.round(left * 100) / 100}px`;
797
+ element.style.top = `${Math.round(top * 100) / 100}px`;
798
+ element.style.width = `${Math.round(width * 100) / 100}px`;
799
+ element.style.height = `${Math.round(height * 100) / 100}px`;
800
+
801
+ // marginをリセット(絶対配置では不要)
802
+ element.style.margin = '0';
803
+
804
+ // flex関連のプロパティを無効化
805
+ element.style.flex = 'none';
806
+ element.style.flexGrow = '0';
807
+ element.style.flexShrink = '0';
808
+
809
+ // transformを保持(rotate/scaleがある場合)
810
+ if (preservedTransform) {
811
+ element.style.transform = preservedTransform;
812
+ }
813
+ });
814
+
815
+ // ── 自己検証: 変換で見た目が動いていないか ──
816
+ // 採寸のタイミング(画像・書体・Tailwindの遅延適用)が悪いと、間違った座標で
817
+ // 固定されて版面が崩れる。1つでも位置がずれた要素があれば**変換ごと巻き戻す**。
818
+ // 崩れた編集画面より、変換されていない編集画面のほうがはるかにまし
819
+ // (in-flow のままでも、動かした要素は遅延変換で扱える)。
820
+ const moved = captures.filter(({ element, rect }) => {
821
+ const now = element.getBoundingClientRect();
822
+ if (
823
+ Math.abs(now.left - rect.left) > 1.5 ||
824
+ Math.abs(now.top - rect.top) > 1.5 ||
825
+ Math.abs(now.width - rect.width) > 2 ||
826
+ Math.abs(now.height - rect.height) > 2
827
+ ) {
828
+ return true;
829
+ }
830
+ // 幅と高さを固定した要素は、文字が折り返しても矩形が動かない。
831
+ // 「変換で新たに中身がはみ出した」= 折り返しや欠けが起きたサインとして検知する
832
+ const overX = element.scrollWidth - element.clientWidth > 2;
833
+ const overY = element.scrollHeight - element.clientHeight > 2;
834
+ return (overX || overY) && element.getAttribute('data-gg-pre-overflow') !== 'true';
835
+ });
836
+ if (moved.length > 0) {
837
+ console.warn(
838
+ '[convertToAbsolutePositioning] 変換で',
839
+ moved.length,
840
+ '要素がずれたため巻き戻します(採寸タイミングの問題の可能性)',
841
+ );
842
+ captures.forEach(({ element, prevStyle }) => {
843
+ if (prevStyle == null) element.removeAttribute('style');
844
+ else element.setAttribute('style', prevStyle);
845
+ element.removeAttribute('data-gg-pre-overflow');
846
+ });
847
+ if (artboard) {
848
+ const orig = artboard.getAttribute('data-original-height');
849
+ if (orig !== null) {
850
+ artboard.style.height = orig === 'auto' ? '' : orig;
851
+ artboard.removeAttribute('data-original-height');
852
+ }
853
+ }
854
+ return 0;
855
+ }
856
+
857
+ captures.forEach(({ element }) => element.removeAttribute('data-gg-pre-overflow'));
858
+ console.log('[convertToAbsolutePositioning] Converted', captures.length, 'elements');
859
+ return captures.length;
860
+ }
861
+
862
+ /**
863
+ * artboardの高さを元に戻す(オートレイアウトに戻す際に使用)
864
+ * convertToAbsolutePositioningで設定された固定高さを削除する
865
+ */
866
+ export function restoreArtboardAutoHeight(iframeDoc: Document): void {
867
+ const artboard = iframeDoc.getElementById('artboard');
868
+ if (!artboard) return;
869
+
870
+ // 元の高さを復元
871
+ const originalHeight = artboard.getAttribute('data-original-height');
872
+ if (originalHeight) {
873
+ if (originalHeight === 'auto' || originalHeight === '') {
874
+ artboard.style.removeProperty('height');
875
+ } else {
876
+ artboard.style.height = originalHeight;
877
+ }
878
+ artboard.removeAttribute('data-original-height');
879
+ console.log('[restoreArtboardAutoHeight] Restored artboard height to:', originalHeight);
880
+ } else {
881
+ // data-original-heightがない場合は単純に高さを削除
882
+ artboard.style.removeProperty('height');
883
+ console.log('[restoreArtboardAutoHeight] Removed artboard height style');
884
+ }
885
+ }
886
+
887
+ /**
888
+ * DOMツリー構築の内部実装
889
+ * キャッシュを使用しない純粋な構築処理
890
+ */
891
+ function buildDomTreeInternal(iframeDoc: Document, rootElement?: Element): DOMTreeNode[] {
892
+ const traverse = (el: Element, depth: number): DOMTreeNode[] => {
893
+ const result: DOMTreeNode[] = [];
894
+
895
+ Array.from(el.children).forEach(child => {
896
+ // iframeの要素は親ウィンドウのHTMLElementとは異なるため、nodeTypeでチェック
897
+ if (!child || child.nodeType !== 1) return;
898
+ const htmlChild = child as HTMLElement;
899
+
900
+ // スキップする要素
901
+ if (htmlChild.tagName === 'SCRIPT' || htmlChild.tagName === 'STYLE') return;
902
+ if (htmlChild.classList?.contains('selection-box')) return;
903
+ if (htmlChild.classList?.contains('resize-handle')) return;
904
+ if (htmlChild.classList?.contains('drawing-preview')) return;
905
+
906
+ const elementId = htmlChild.getAttribute('data-element-id');
907
+
908
+ if (elementId) {
909
+ // IDを持つ要素はノードとして追加
910
+ const childNodes = traverse(htmlChild, depth + 1);
911
+ const textContent = htmlChild.textContent?.trim() || '';
912
+ const classNameStr = htmlChild.className?.toString() || '';
913
+ const className = classNameStr.split(' ')
914
+ .filter(c => c && !c.startsWith('selected') && !c.startsWith('dragging') && !c.startsWith('editing'))[0] || '';
915
+
916
+ // コンポーネントインスタンス情報を取得
917
+ const componentInstanceId = htmlChild.getAttribute('data-component-instance') || undefined;
918
+ const masterComponentId = htmlChild.getAttribute('data-component-master') || undefined;
919
+
920
+ result.push({
921
+ id: elementId,
922
+ tagName: htmlChild.tagName.toLowerCase(),
923
+ className,
924
+ text: textContent.substring(0, 20) + (textContent.length > 20 ? '...' : ''),
925
+ children: childNodes,
926
+ visible: true,
927
+ depth,
928
+ componentInstanceId,
929
+ masterComponentId,
930
+ });
931
+ } else {
932
+ // IDを持たない要素は子要素を再帰的に検索
933
+ const childNodes = traverse(htmlChild, depth);
934
+ result.push(...childNodes);
935
+ }
936
+ });
937
+
938
+ return result;
939
+ };
940
+
941
+ // ルート要素を決定(指定されていれば使用、なければ artboard または body)
942
+ const root = rootElement || iframeDoc.getElementById('artboard') || iframeDoc.body;
943
+ return traverse(root, 0);
944
+ }
945
+
946
+ /**
947
+ * DOMツリーを構築(キャッシュ付き)
948
+ * data-element-idを持つ要素を収集し、階層構造を保持
949
+ *
950
+ * Phase 2 最適化:
951
+ * - HTMLが変更されていなければキャッシュを返す
952
+ * - WeakMapを使用してメモリリークを防止
953
+ *
954
+ * @param iframeDoc - iframe ドキュメント
955
+ * @param rootElement - オプション: ルート要素(指定しない場合は body、キャンバス構造では #artboard を指定)
956
+ * @param skipCache - キャッシュをスキップして強制的に再構築(デフォルト: false)
957
+ */
958
+ export function buildDomTree(
959
+ iframeDoc: Document,
960
+ rootElement?: Element,
961
+ skipCache: boolean = false
962
+ ): DOMTreeNode[] {
963
+ const artboard = iframeDoc.getElementById('artboard');
964
+ const root = rootElement || artboard || iframeDoc.body;
965
+
966
+ // キャッシュチェック(rootElementが指定されている場合や、skipCacheの場合はキャッシュを使用しない)
967
+ if (!skipCache && !rootElement && artboard) {
968
+ const currentHtml = artboard.innerHTML;
969
+ const cached = domTreeCache.get(iframeDoc);
970
+
971
+ // HTMLが変わっていなければキャッシュを返す
972
+ if (cached && cached.html === currentHtml) {
973
+ console.log('[buildDomTree] Cache hit, returning cached tree with', cached.tree.length, 'root nodes');
974
+ return cached.tree;
975
+ }
976
+
977
+ // 新規計算
978
+ const tree = buildDomTreeInternal(iframeDoc, root);
979
+ domTreeCache.set(iframeDoc, { html: currentHtml, tree });
980
+ console.log('[buildDomTree] Cache miss, built tree with', tree.length, 'root nodes from', root.id || root.tagName);
981
+ return tree;
982
+ }
983
+
984
+ // キャッシュを使用しない場合
985
+ const nodes = buildDomTreeInternal(iframeDoc, root);
986
+ console.log('[buildDomTree] Built tree (no cache) with', nodes.length, 'root nodes from', root.id || root.tagName);
987
+ return nodes;
988
+ }
989
+
990
+ /**
991
+ * 要素のパス情報(パンくずリスト用)
992
+ */
993
+ export interface ElementPathItem {
994
+ elementId: string;
995
+ tagName: string;
996
+ className: string;
997
+ displayName: string;
998
+ }
999
+
1000
+ /**
1001
+ * 要素からルートまでのパスを取得
1002
+ * @param element 対象要素
1003
+ * @param iframeDoc iframeのドキュメント
1004
+ * @returns ルートから要素までのパス(配列の最後が対象要素)
1005
+ */
1006
+ export function getElementPath(element: HTMLElement, iframeDoc: Document): ElementPathItem[] {
1007
+ const path: ElementPathItem[] = [];
1008
+ let current: HTMLElement | null = element;
1009
+ const artboard = iframeDoc.getElementById('artboard');
1010
+
1011
+ while (current && current !== artboard && current !== iframeDoc.body) {
1012
+ const elementId = current.getAttribute('data-element-id');
1013
+ if (elementId) {
1014
+ const tagName = current.tagName.toLowerCase();
1015
+
1016
+ path.unshift({
1017
+ elementId,
1018
+ tagName,
1019
+ className: '',
1020
+ displayName: tagName,
1021
+ });
1022
+ }
1023
+ current = current.parentElement;
1024
+ }
1025
+
1026
+ return path;
1027
+ }
1028
+
1029
+ /**
1030
+ * 選択オーバーレイ(選択枠)の配置先を返す
1031
+ * #artboard があればその中、なければ body
1032
+ */
1033
+ function getSelectionContainer(iframeDoc: Document): HTMLElement {
1034
+ return iframeDoc.getElementById('artboard') || iframeDoc.body;
1035
+ }
1036
+
1037
+ /** 選択オーバーレイの矩形(artboard 相対のCSS座標) */
1038
+ export interface OverlayRect {
1039
+ left: number;
1040
+ top: number;
1041
+ width: number;
1042
+ height: number;
1043
+ }
1044
+
1045
+ /**
1046
+ * 要素の「選択枠を置くべき矩形」を artboard 相対のCSS座標で返す
1047
+ *
1048
+ * [なぜ切り出したか]
1049
+ * これまで updateSelectionBox の中にだけ座標変換が埋まっていたため、
1050
+ * ドラッグ中に枠を「作り直さず位置だけ同期する」ことができなかった。
1051
+ * 群バウンディングボックスの計算とも共有するので独立した関数にしている。
1052
+ *
1053
+ * getBoundingClientRect() はビューポート座標(= #artboard-wrapper の
1054
+ * transform:scale() 適用後)を返すため、必ず同じスケールで割り戻して
1055
+ * CSS座標へ変換する。ここでの基準は getArtboardScale() に統一する。
1056
+ */
1057
+ export function getOverlayRect(iframeDoc: Document, element: HTMLElement): OverlayRect {
1058
+ const selectionContainer = getSelectionContainer(iframeDoc);
1059
+ const rect = element.getBoundingClientRect();
1060
+ const containerRect = selectionContainer.getBoundingClientRect();
1061
+ const scale = getArtboardScale(iframeDoc) || 1;
1062
+
1063
+ return {
1064
+ left: (rect.left - containerRect.left) / scale,
1065
+ top: (rect.top - containerRect.top) / scale,
1066
+ width: rect.width / scale,
1067
+ height: rect.height / scale,
1068
+ };
1069
+ }
1070
+
1071
+ // ========================================
1072
+ // オーバーレイの寸法(すべて「画面上のpx」で定義する)
1073
+ // ========================================
1074
+ //
1075
+ // オーバーレイは #artboard の中にあり、#artboard-wrapper の transform:scale() を
1076
+ // 一緒に受ける。CSS側は --overlay-scale (= 1/zoom) を掛けて画面上の実寸を
1077
+ // 一定に保つので、JSから位置を計算するときも同じ倍率を掛ける必要がある。
1078
+
1079
+ /** 角・辺中点ハンドルの掴み代(画面上px) */
1080
+ const HANDLE_HIT_SCREEN = 12;
1081
+ /**
1082
+ * 角丸ハンドルの内側オフセットの下限(画面上px)
1083
+ * 角ハンドルの当たり判定は角を中心に ±6px なので、
1084
+ * 角丸ハンドル(±6px)の中心が 14px 内側にあれば両者は重ならない。
1085
+ */
1086
+ const RADIUS_MIN_INSET_SCREEN = 14;
1087
+
1088
+ /**
1089
+ * オーバーレイの寸法をズーム非依存にするための倍率を <html> に書き込む
1090
+ *
1091
+ * [なぜ <html> か]
1092
+ * #artboard に書くと保存対象(#artboard の innerHTML)ではないので実害は無いが、
1093
+ * 将来 outerHTML ベースの保存が入ったときに漏れる。<html> はどの保存経路でも
1094
+ * 直列化されないので安全で、かつ全要素に継承される(ホバー輪郭の太さにも効く)。
1095
+ *
1096
+ * 選択オーバーレイを触るすべての入口から呼ばれるので、ズーム変更時にも
1097
+ * 必ず更新される(EditorCanvas のズーム適用 useEffect が
1098
+ * refreshSelectionOverlay を呼んでいる)。
1099
+ */
1100
+ export function applyOverlayScale(iframeDoc: Document): number {
1101
+ const scale = getArtboardScale(iframeDoc) || 1;
1102
+ const overlayScale = 1 / scale;
1103
+ const root = iframeDoc.documentElement;
1104
+ if (!root) return overlayScale;
1105
+ // --overlay-scale = 1/zoom。オーバーレイの寸法はすべてこれを掛けて書く。
1106
+ root.style.setProperty('--overlay-scale', String(overlayScale));
1107
+ // --ed-overlay-scale = zoom そのもの。
1108
+ // 「画面上の1px」を calc(1px / var(--ed-overlay-scale)) で表す書き方も
1109
+ // 併存しうるので、書き込み口を1箇所にまとめる意味で同時に更新しておく。
1110
+ // (片方だけ更新されると、線の太さとハンドルの大きさがズーム時にズレる)
1111
+ root.style.setProperty('--ed-overlay-scale', String(scale));
1112
+ return overlayScale;
1113
+ }
1114
+
1115
+ /**
1116
+ * オーバーレイのスケール変数を現在のズームに同期する(別名)
1117
+ * 実体は applyOverlayScale。呼び名が揺れても迷わないよう両方を公開している。
1118
+ */
1119
+ export function syncOverlayScaleVar(iframeDoc: Document): number {
1120
+ return applyOverlayScale(iframeDoc);
1121
+ }
1122
+
1123
+ /** 選択枠の描画モード */
1124
+ type SelectionBoxMode =
1125
+ /** 単一選択: 枠 + ハンドル + サイズラベル + パンくず */
1126
+ | 'full'
1127
+ /** 複数選択のメンバー: 細い輪郭のみ(ハンドル類は群バウンディングボックスに集約) */
1128
+ | 'member';
1129
+
1130
+ /**
1131
+ * リサイズハンドル(角4 + 辺中点4 + 辺の帯4)を選択枠に追加する
1132
+ *
1133
+ * @param elementId 対象要素のID。空文字の場合 data-element-id を付けない
1134
+ * (= 群バウンディングボックス用。mousedown 側は elementId が無ければ
1135
+ * 何もせず return するので、誤って別要素をリサイズすることがない)
1136
+ */
1137
+ function appendResizeHandles(
1138
+ iframeDoc: Document,
1139
+ box: HTMLElement,
1140
+ elementId: string,
1141
+ options?: { includeEdges?: boolean }
1142
+ ): void {
1143
+ const includeEdges = options?.includeEdges !== false;
1144
+ const setIds = (el: HTMLElement, handle: string) => {
1145
+ el.setAttribute('data-handle', handle);
1146
+ if (elementId) {
1147
+ el.setAttribute('data-element-id', elementId);
1148
+ } else {
1149
+ // 群のハンドルであることを示す印(後段で群リサイズを実装する際の目印)
1150
+ el.setAttribute('data-group-handle', 'true');
1151
+ }
1152
+ };
1153
+
1154
+ // 角 + 辺中点(見える四角)
1155
+ ['nw', 'n', 'ne', 'w', 'e', 'sw', 's', 'se'].forEach(pos => {
1156
+ const handle = iframeDoc.createElement('div');
1157
+ handle.className = `resize-handle ${pos}`;
1158
+ setIds(handle, pos);
1159
+ box.appendChild(handle);
1160
+ });
1161
+
1162
+ if (!includeEdges) return;
1163
+
1164
+ // 辺の帯(見た目なし・当たり判定のみ)
1165
+ // data-handle は n/e/s/w のまま渡す。useDragResize が name.includes('e') 等で
1166
+ // 軸を判定しているため、独自の値にすると軸判定が壊れる。
1167
+ const edges: Array<[handle: string, cls: string]> = [
1168
+ ['n', 'edge-top'],
1169
+ ['e', 'edge-right'],
1170
+ ['s', 'edge-bottom'],
1171
+ ['w', 'edge-left'],
1172
+ ];
1173
+ edges.forEach(([handle, cls]) => {
1174
+ const band = iframeDoc.createElement('div');
1175
+ band.className = `resize-handle edge ${cls}`;
1176
+ setIds(band, handle);
1177
+ box.appendChild(band);
1178
+ });
1179
+ }
1180
+
1181
+ /**
1182
+ * 角丸ハンドルの位置を更新(無ければ作る / 置けない大きさなら消す)
1183
+ *
1184
+ * [なぜ位置をJSで計算するか]
1185
+ * 角丸ハンドルは「現在の border-radius の分だけ内側」に置くのが直感的だが、
1186
+ * border-radius:0 の要素では内側オフセットが 0 になり、NEハンドルを完全に
1187
+ * 覆ってしまう(実測: NE中心の elementFromPoint が border-radius-handle を返す)。
1188
+ * そこで内側オフセットに下限(角ハンドルと重ならない値)を設け、
1189
+ * 要素が小さすぎて下限すら確保できない場合はハンドル自体を出さない。
1190
+ */
1191
+ function updateRadiusHandle(
1192
+ iframeDoc: Document,
1193
+ box: HTMLElement,
1194
+ element: HTMLElement,
1195
+ rect: OverlayRect,
1196
+ overlayScale: number
1197
+ ): void {
1198
+ const elementId = element.getAttribute('data-element-id') || '';
1199
+ let handle = box.querySelector<HTMLElement>('.border-radius-handle');
1200
+
1201
+ const minInset = RADIUS_MIN_INSET_SCREEN * overlayScale;
1202
+ const maxInset = Math.min(rect.width, rect.height) / 2;
1203
+
1204
+ // 下限すら確保できない小さな要素では角丸ハンドルを出さない
1205
+ if (!Number.isFinite(maxInset) || maxInset < minInset) {
1206
+ handle?.remove();
1207
+ return;
1208
+ }
1209
+
1210
+ if (!handle) {
1211
+ handle = iframeDoc.createElement('div');
1212
+ handle.className = 'border-radius-handle';
1213
+ handle.setAttribute('data-handle', 'radius');
1214
+ if (elementId) handle.setAttribute('data-element-id', elementId);
1215
+ box.appendChild(handle);
1216
+ }
1217
+
1218
+ const borderRadius =
1219
+ parseFloat(iframeDoc.defaultView?.getComputedStyle(element).borderRadius || '0') || 0;
1220
+ const inset = Math.min(Math.max(borderRadius, minInset), maxInset);
1221
+ // 当たり判定の中心が inset に来るよう、半分だけ戻す
1222
+ const offset = inset - (HANDLE_HIT_SCREEN / 2) * overlayScale;
1223
+ handle.style.top = `${offset}px`;
1224
+ handle.style.right = `${offset}px`;
1225
+ }
1226
+
1227
+ /**
1228
+ * 選択ボックスを更新
1229
+ *
1230
+ * [統一入口]
1231
+ * 外部からはこの関数か refreshSelectionOverlay / syncSelectionOverlayRects だけを
1232
+ * 呼ぶ契約にする。この関数は「対象が選択中なら選択セット全体から作り直す」ため、
1233
+ * 1要素ずつ呼んでも枠の集合と .selected の集合が食い違わない。
1234
+ * 複数選択時にメンバーへハンドルが13個ずつ生えるのも、この一本化で防いでいる。
1235
+ *
1236
+ * @param iframeDoc iframeのドキュメント
1237
+ * @param element 対象要素
1238
+ * @param keepOthers trueの場合、他の選択ボックスを保持(複数選択用・後方互換)
1239
+ */
1240
+ export function updateSelectionBox(
1241
+ iframeDoc: Document,
1242
+ element: HTMLElement,
1243
+ keepOthers: boolean = false
1244
+ ): void {
1245
+ if (element.classList.contains('selected')) {
1246
+ // 選択セット全体を正として作り直す(群バウンディングボックスもここで更新される)
1247
+ refreshSelectionOverlay(iframeDoc);
1248
+ return;
1249
+ }
1250
+ // .selected が付いていない要素への呼び出しは従来どおり単体の枠を描く
1251
+ drawSelectionBox(iframeDoc, element, keepOthers, 'full');
1252
+ }
1253
+
1254
+ /**
1255
+ * 選択枠を1枚描く(内部実装)
1256
+ */
1257
+ function drawSelectionBox(
1258
+ iframeDoc: Document,
1259
+ element: HTMLElement,
1260
+ keepOthers: boolean,
1261
+ mode: SelectionBoxMode
1262
+ ): void {
1263
+ const elementId = element.getAttribute('data-element-id') || '';
1264
+
1265
+ // 選択ボックスの配置先(#artboard があればその中、なければ body)
1266
+ const selectionContainer = getSelectionContainer(iframeDoc);
1267
+ const overlayScale = applyOverlayScale(iframeDoc);
1268
+
1269
+ if (!keepOthers) {
1270
+ // [バグ修正] 以前は querySelector(単数)で「先頭の1個だけ」を消していたため、
1271
+ // 「先頭を1個消して末尾に1個足す」という玉突きが起きていた。
1272
+ // 枠の個数と選択要素の個数がずれると、ずれた分の枠が古い位置に永久に残る。
1273
+ // コメント通り「全て削除」に直す。
1274
+ iframeDoc.querySelectorAll('.selection-box').forEach(box => box.remove());
1275
+ } else {
1276
+ // この要素の既存の選択ボックスのみ削除
1277
+ const existingBox = iframeDoc.querySelector(`.selection-box[data-for-element="${elementId}"]`);
1278
+ if (existingBox) {
1279
+ existingBox.remove();
1280
+ }
1281
+ }
1282
+
1283
+ const overlayRect = getOverlayRect(iframeDoc, element);
1284
+ const { left: relativeLeft, top: relativeTop, width, height } = overlayRect;
1285
+
1286
+ // 選択ボックスを作成
1287
+ const selectionBox = iframeDoc.createElement('div');
1288
+ selectionBox.className =
1289
+ mode === 'member' ? 'selection-box selection-member' : 'selection-box';
1290
+ selectionBox.setAttribute('data-for-element', elementId);
1291
+ selectionBox.style.cssText = `
1292
+ left: ${relativeLeft}px;
1293
+ top: ${relativeTop}px;
1294
+ width: ${width}px;
1295
+ height: ${height}px;
1296
+ `;
1297
+
1298
+ // 外枠
1299
+ const outline = iframeDoc.createElement('div');
1300
+ outline.className = 'selection-outline';
1301
+ selectionBox.appendChild(outline);
1302
+
1303
+ if (mode === 'member') {
1304
+ // 複数選択のメンバーは細い輪郭だけ。
1305
+ // ハンドル・サイズラベル・パンくずは群バウンディングボックスに集約する。
1306
+ selectionContainer.appendChild(selectionBox);
1307
+ return;
1308
+ }
1309
+
1310
+ // リサイズハンドル(角4 + 辺中点4 + 辺の帯4)
1311
+ appendResizeHandles(iframeDoc, selectionBox, elementId);
1312
+
1313
+ // 角丸ハンドル(角ハンドルと重ならない位置に置く)
1314
+ updateRadiusHandle(iframeDoc, selectionBox, element, overlayRect, overlayScale);
1315
+
1316
+ // サイズラベル(スケール前の実際のサイズを表示)
1317
+ const sizeLabel = iframeDoc.createElement('div');
1318
+ sizeLabel.className = 'size-label';
1319
+ sizeLabel.textContent = overlayLabelText(width, height);
1320
+ selectionBox.appendChild(sizeLabel);
1321
+
1322
+ // 回転ハンドル(4つのコーナー外側)- Figmaスタイル(不可視、カーソル変更のみ)
1323
+ //
1324
+ // [追加] 右上の1つだけ ⟳ を出して見えるようにする。
1325
+ // 4隅の回転域は昔から在ったが完全に透明で、知らなければ一生見つからない。
1326
+ // PowerPointは回転ハンドルが1つ見えているので、それに倣って右上を目印にする
1327
+ // (左上はパンくず、下中央はサイズラベルが居るので右上が空いている)。
1328
+ const rotationHandles = ['nw', 'ne', 'sw', 'se'];
1329
+ rotationHandles.forEach(pos => {
1330
+ const rotHandle = iframeDoc.createElement('div');
1331
+ rotHandle.className = `rotation-handle ${pos}`;
1332
+ rotHandle.setAttribute('data-handle', `rotate-${pos}`);
1333
+ rotHandle.setAttribute('data-element-id', elementId);
1334
+ // インラインスタイルで確実に透明に(スタイルシート適用前対策)
1335
+ rotHandle.style.background = 'transparent';
1336
+ rotHandle.style.border = 'none';
1337
+ rotHandle.style.outline = 'none';
1338
+ if (pos === 'ne') {
1339
+ rotHandle.classList.add('rotation-handle-visible');
1340
+ const glyph = iframeDoc.createElement('span');
1341
+ glyph.className = 'rotation-handle-glyph';
1342
+ glyph.textContent = '⟳';
1343
+ // 掴んだ先は必ず .rotation-handle 本体でなければならない
1344
+ // (mousedown 側は e.target の data-handle しか見ないので、
1345
+ // 印が当たり判定を横取りすると回転が始まらない)
1346
+ glyph.style.pointerEvents = 'none';
1347
+ rotHandle.appendChild(glyph);
1348
+ }
1349
+ selectionBox.appendChild(rotHandle);
1350
+ });
1351
+
1352
+ // パンくずリスト(要素パス)を追加
1353
+ const elementPath = getElementPath(element, iframeDoc);
1354
+ if (elementPath.length > 1) {
1355
+ const breadcrumb = iframeDoc.createElement('div');
1356
+ breadcrumb.className = 'element-breadcrumb';
1357
+ // ズームで文字が潰れない/巨大化しないよう、寸法は --ov(1/zoom)を掛ける
1358
+ breadcrumb.style.cssText = `
1359
+ position: absolute;
1360
+ top: calc(-22px * var(--ov, 1));
1361
+ left: 0;
1362
+ display: flex;
1363
+ align-items: center;
1364
+ gap: calc(2px * var(--ov, 1));
1365
+ font-size: calc(10px * var(--ov, 1));
1366
+ line-height: 1.4;
1367
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
1368
+ white-space: nowrap;
1369
+ pointer-events: auto;
1370
+ z-index: 40;
1371
+ `;
1372
+
1373
+ elementPath.forEach((item, index) => {
1374
+ // セパレータ(最初の要素以外)
1375
+ if (index > 0) {
1376
+ const separator = iframeDoc.createElement('span');
1377
+ separator.textContent = '›';
1378
+ separator.style.cssText = `
1379
+ color: rgba(59, 130, 246, 0.5);
1380
+ font-size: 10px;
1381
+ `;
1382
+ breadcrumb.appendChild(separator);
1383
+ }
1384
+
1385
+ // パス要素
1386
+ const pathItem = iframeDoc.createElement('span');
1387
+ pathItem.textContent = item.displayName;
1388
+ pathItem.setAttribute('data-element-id', item.elementId);
1389
+
1390
+ const isCurrentElement = index === elementPath.length - 1;
1391
+ pathItem.style.cssText = `
1392
+ padding: calc(2px * var(--ov, 1)) calc(4px * var(--ov, 1));
1393
+ border-radius: calc(3px * var(--ov, 1));
1394
+ cursor: ${isCurrentElement ? 'default' : 'pointer'};
1395
+ color: ${isCurrentElement ? '#fff' : 'rgba(255, 255, 255, 0.8)'};
1396
+ background: ${isCurrentElement ? 'rgba(59, 130, 246, 0.9)' : 'rgba(59, 130, 246, 0.6)'};
1397
+ transition: background 0.15s ease;
1398
+ `;
1399
+
1400
+ if (!isCurrentElement) {
1401
+ pathItem.addEventListener('mouseenter', () => {
1402
+ pathItem.style.background = 'rgba(59, 130, 246, 0.85)';
1403
+ // 該当要素にホバーアウトラインを表示
1404
+ const targetEl = iframeDoc.querySelector(`[data-element-id="${item.elementId}"]`) as HTMLElement;
1405
+ if (targetEl) {
1406
+ targetEl.setAttribute('data-breadcrumb-hover', 'true');
1407
+ targetEl.style.outline = '2px dashed rgba(59, 130, 246, 0.7)';
1408
+ targetEl.style.outlineOffset = '2px';
1409
+ }
1410
+ });
1411
+ pathItem.addEventListener('mouseleave', () => {
1412
+ pathItem.style.background = 'rgba(59, 130, 246, 0.6)';
1413
+ // ホバーアウトラインを削除
1414
+ const targetEl = iframeDoc.querySelector(`[data-element-id="${item.elementId}"]`) as HTMLElement;
1415
+ if (targetEl) {
1416
+ targetEl.removeAttribute('data-breadcrumb-hover');
1417
+ targetEl.style.outline = '';
1418
+ targetEl.style.outlineOffset = '';
1419
+ }
1420
+ });
1421
+ // 選択は mousedown で走るので、click だけ止めても間に合わない。
1422
+ // チップを押した時点で「今選択中の要素のドラッグ」が始まってしまい、
1423
+ // その後の click による親の選択が上書きされていた。
1424
+ const swallow = (e: Event) => {
1425
+ e.stopPropagation();
1426
+ e.preventDefault();
1427
+ };
1428
+ pathItem.addEventListener('mousedown', swallow);
1429
+ pathItem.addEventListener('pointerdown', swallow);
1430
+ pathItem.addEventListener('click', (e) => {
1431
+ e.stopPropagation();
1432
+ // ホバーアウトラインを削除してから選択
1433
+ const targetEl = iframeDoc.querySelector(`[data-element-id="${item.elementId}"]`) as HTMLElement;
1434
+ if (targetEl) {
1435
+ targetEl.style.outline = '';
1436
+ targetEl.style.outlineOffset = '';
1437
+ }
1438
+ // 親ウィンドウにメッセージを送信して要素選択
1439
+ window.parent.postMessage({
1440
+ type: 'breadcrumb-select',
1441
+ elementId: item.elementId,
1442
+ }, '*');
1443
+ });
1444
+ }
1445
+
1446
+ breadcrumb.appendChild(pathItem);
1447
+ });
1448
+
1449
+ selectionBox.appendChild(breadcrumb);
1450
+ }
1451
+
1452
+ selectionContainer.appendChild(selectionBox);
1453
+ }
1454
+
1455
+ /**
1456
+ * 選択ボックスを削除
1457
+ * @param iframeDoc iframeのドキュメント
1458
+ * @param element 特定の要素の選択ボックスのみ削除する場合に指定
1459
+ */
1460
+ export function removeSelectionBox(iframeDoc: Document, element?: HTMLElement): void {
1461
+ if (element) {
1462
+ const elementId = element.getAttribute('data-element-id');
1463
+ if (elementId) {
1464
+ const existingBox = iframeDoc.querySelector(`.selection-box[data-for-element="${elementId}"]`);
1465
+ if (existingBox) {
1466
+ existingBox.remove();
1467
+ }
1468
+ }
1469
+ } else {
1470
+ // 全ての選択ボックスを削除
1471
+ iframeDoc.querySelectorAll('.selection-box').forEach(box => box.remove());
1472
+ }
1473
+ }
1474
+
1475
+ // ========================================
1476
+ // 選択オーバーレイ(要素ごとの枠 + 群バウンディングボックス)
1477
+ // ========================================
1478
+ //
1479
+ // [設計方針]
1480
+ // 従来は「要素1個ずつ add / remove」で枠を管理していたため、
1481
+ // 枠の集合と .selected の集合が容易に食い違い、
1482
+ // - 古い位置に枠が取り残される
1483
+ // - 同じ要素の枠が二重に生成される
1484
+ // といった破綻が日常的に起きていた。
1485
+ //
1486
+ // そこで、
1487
+ // refreshSelectionOverlay() … 選択セット全体から作り直す(選択が変わったとき)
1488
+ // syncSelectionOverlayRects() … 既存の枠の位置・サイズだけを書き換える(ドラッグ中など)
1489
+ // の2本に集約する。ドラッグ中に毎フレーム作り直さないのは、
1490
+ // パンくずに mouseenter/mouseleave を張っており、カーソルがパンくず上にある状態で
1491
+ // ノードを差し替えると mouseleave が飛ばず、スライド側の要素に
1492
+ // インライン outline が焼き付いて保存HTMLに漏れるため。
1493
+
1494
+ /** 群バウンディングボックスのクラス名 */
1495
+ const SELECTION_BOUNDS_CLASS = 'selection-bounds';
1496
+
1497
+ /**
1498
+ * サイズラベル(W × H)の表示を一時的に差し替える文字列
1499
+ *
1500
+ * ドラッグ中は X, Y、回転中は角度を、Figmaと同じくサイズと同じ場所に出す。
1501
+ * 枠はドラッグ中も毎フレーム syncSelectionOverlayRects で書き直されるので、
1502
+ * 呼び出し側が textContent を直接書いても次のフレームで消される。
1503
+ * 「今どのラベルを出すか」をここに1つ持ち、ラベルを書く場所すべてがこれを見る。
1504
+ * ドラッグ/回転を終えたら必ず null に戻すこと(refreshSelectionOverlay でも戻る)。
1505
+ */
1506
+ let overlayLabelOverride: string | null = null;
1507
+
1508
+ /** ドラッグ中のX,Y・回転中の角度をサイズラベルの位置に出す(null で通常のW × Hに戻る) */
1509
+ export function setOverlayLabel(text: string | null): void {
1510
+ overlayLabelOverride = text;
1511
+ }
1512
+
1513
+ /** サイズラベルに書く文字列(差し替えがあればそちらを優先) */
1514
+ function overlayLabelText(width: number, height: number): string {
1515
+ return overlayLabelOverride ?? `${Math.round(width)} × ${Math.round(height)}`;
1516
+ }
1517
+
1518
+ /**
1519
+ * 個別要素の選択枠だけを取得する
1520
+ * (群バウンディングボックスは .selection-box を兼ねているので除外する。
1521
+ * 兼ねているのは、保存時のサニタイズ(html-utils 等)が .selection-box を
1522
+ * 前提に組まれており、そこに自動的に乗せるため)
1523
+ */
1524
+ function getElementSelectionBoxes(iframeDoc: Document): HTMLElement[] {
1525
+ return Array.from(
1526
+ iframeDoc.querySelectorAll<HTMLElement>(`.selection-box:not(.${SELECTION_BOUNDS_CLASS})`)
1527
+ );
1528
+ }
1529
+
1530
+ /** 現在選択中(.selected)の要素一覧 */
1531
+ function getSelectedElements(iframeDoc: Document): HTMLElement[] {
1532
+ return Array.from(iframeDoc.querySelectorAll<HTMLElement>('.selected'));
1533
+ }
1534
+
1535
+ /**
1536
+ * 群バウンディングボックスを削除
1537
+ */
1538
+ export function removeSelectionBounds(iframeDoc: Document): void {
1539
+ iframeDoc
1540
+ .querySelectorAll(`.${SELECTION_BOUNDS_CLASS}`)
1541
+ .forEach(box => box.remove());
1542
+ }
1543
+
1544
+ /**
1545
+ * 複数選択時の「群バウンディングボックス」を更新(Figma相当)
1546
+ * 選択中の全要素の矩形の和集合を1枚の枠として描画する。
1547
+ * 2要素以上のときだけ表示し、1要素以下なら削除する。
1548
+ */
1549
+ export function updateSelectionBounds(iframeDoc: Document, elements: HTMLElement[]): void {
1550
+ if (elements.length < 2) {
1551
+ removeSelectionBounds(iframeDoc);
1552
+ return;
1553
+ }
1554
+
1555
+ let minLeft = Infinity;
1556
+ let minTop = Infinity;
1557
+ let maxRight = -Infinity;
1558
+ let maxBottom = -Infinity;
1559
+
1560
+ elements.forEach(el => {
1561
+ const r = getOverlayRect(iframeDoc, el);
1562
+ minLeft = Math.min(minLeft, r.left);
1563
+ minTop = Math.min(minTop, r.top);
1564
+ maxRight = Math.max(maxRight, r.left + r.width);
1565
+ maxBottom = Math.max(maxBottom, r.top + r.height);
1566
+ });
1567
+
1568
+ if (!Number.isFinite(minLeft) || !Number.isFinite(minTop)) {
1569
+ removeSelectionBounds(iframeDoc);
1570
+ return;
1571
+ }
1572
+
1573
+ applyOverlayScale(iframeDoc);
1574
+
1575
+ const container = getSelectionContainer(iframeDoc);
1576
+ let bounds = iframeDoc.querySelector<HTMLElement>(`.${SELECTION_BOUNDS_CLASS}`);
1577
+ if (!bounds) {
1578
+ bounds = iframeDoc.createElement('div');
1579
+ // .selection-box も付けることで既存のサニタイズ処理に自動的に拾われる
1580
+ bounds.className = `selection-box ${SELECTION_BOUNDS_CLASS}`;
1581
+
1582
+ // 外枠
1583
+ const outline = iframeDoc.createElement('div');
1584
+ outline.className = 'selection-outline';
1585
+ bounds.appendChild(outline);
1586
+
1587
+ // ハンドルは群バウンディングボックス1枚に集約する。
1588
+ // data-element-id を付けないので、既存の mousedown ハンドラは
1589
+ // 「handle はあるが elementId が無い」ため何もせず return する。
1590
+ // = 誤って別の要素をリサイズしてしまうことがない(群リサイズの実装は後段)。
1591
+ //
1592
+ // 辺の帯は付けない: 群リサイズが未実装のうちは、群の外周に沿った長い帯が
1593
+ // クリックを飲み込むだけになるため。角・辺中点の8個だけを出す。
1594
+ appendResizeHandles(iframeDoc, bounds, '', { includeEdges: false });
1595
+
1596
+ // サイズラベルも1枚だけ(メンバー全員に出すと40件マーキーで紙面が読めない)
1597
+ const label = iframeDoc.createElement('div');
1598
+ label.className = 'size-label';
1599
+ bounds.appendChild(label);
1600
+
1601
+ container.appendChild(bounds);
1602
+ } else if (bounds.parentElement !== container) {
1603
+ container.appendChild(bounds);
1604
+ }
1605
+
1606
+ const width = maxRight - minLeft;
1607
+ const height = maxBottom - minTop;
1608
+ bounds.style.left = `${minLeft}px`;
1609
+ bounds.style.top = `${minTop}px`;
1610
+ bounds.style.width = `${width}px`;
1611
+ bounds.style.height = `${height}px`;
1612
+
1613
+ const label = bounds.querySelector('.size-label');
1614
+ if (label) {
1615
+ label.textContent = overlayLabelText(width, height);
1616
+ }
1617
+ }
1618
+
1619
+ /**
1620
+ * 選択オーバーレイを選択セット全体から作り直す
1621
+ *
1622
+ * 「1要素ずつ足し引きする」設計をやめ、常に .selected を正とする。
1623
+ * 選択が変化した直後・ドラッグ終了直後(Tailwindクラスへの丸め込みで
1624
+ * 位置がわずかに変わるため)に呼ぶこと。
1625
+ */
1626
+ export function refreshSelectionOverlay(iframeDoc: Document): void {
1627
+ // 選択枠を作り直す = 操作が終わった合図。ラベルは通常のW × Hに戻す
1628
+ overlayLabelOverride = null;
1629
+ removeSelectionBox(iframeDoc); // 群バウンディングボックスも .selection-box なのでここで消える
1630
+ applyOverlayScale(iframeDoc);
1631
+ const selected = getSelectedElements(iframeDoc);
1632
+
1633
+ // 複数選択のときはメンバーを「細い輪郭のみ」にし、
1634
+ // ハンドル・サイズラベル・パンくずは群バウンディングボックス1枚に集約する。
1635
+ const mode: SelectionBoxMode = selected.length > 1 ? 'member' : 'full';
1636
+ selected.forEach(el => drawSelectionBox(iframeDoc, el, true, mode));
1637
+ updateSelectionBounds(iframeDoc, selected);
1638
+ }
1639
+
1640
+ /**
1641
+ * 選択オーバーレイの更新入口(唯一の公開API)
1642
+ *
1643
+ * 位置・サイズを変える処理は、処理の最後に必ずこれを呼ぶこと。
1644
+ * 「作り直し」と「位置だけ同期」の使い分けは中で判断するので、
1645
+ * 呼び出し側はどちらを使うか考えなくてよい。
1646
+ *
1647
+ * @param options.rebuild true を渡すと必ず作り直す(選択セットが変わったとき)
1648
+ */
1649
+ export function updateSelectionOverlay(
1650
+ iframeDoc: Document,
1651
+ options?: { rebuild?: boolean }
1652
+ ): void {
1653
+ if (options?.rebuild) {
1654
+ refreshSelectionOverlay(iframeDoc);
1655
+ return;
1656
+ }
1657
+ syncSelectionOverlayRects(iframeDoc);
1658
+ }
1659
+
1660
+ /**
1661
+ * 既存の選択枠の位置・サイズだけを同期する(ドラッグ / リサイズ / 回転中用)
1662
+ *
1663
+ * DOMを作り直さないので、パンくずのイベントリスナーや掴んでいるリサイズハンドルが
1664
+ * 生き残る。枠の集合と .selected の集合が食い違っている場合のみ作り直しにフォールバックする。
1665
+ */
1666
+ export function syncSelectionOverlayRects(iframeDoc: Document): void {
1667
+ const selected = getSelectedElements(iframeDoc);
1668
+ const boxes = getElementSelectionBoxes(iframeDoc);
1669
+
1670
+ // 個数が合わない = どこかで枠がリークしている。作り直して整合させる
1671
+ if (boxes.length !== selected.length) {
1672
+ refreshSelectionOverlay(iframeDoc);
1673
+ return;
1674
+ }
1675
+
1676
+ // 単一 ⇄ 複数 で枠の中身(ハンドルの有無)が変わるので、
1677
+ // 装飾モードが今の選択数と食い違っていたら作り直す
1678
+ const shouldBeMember = selected.length > 1;
1679
+ if (boxes.some(box => box.classList.contains('selection-member') !== shouldBeMember)) {
1680
+ refreshSelectionOverlay(iframeDoc);
1681
+ return;
1682
+ }
1683
+
1684
+ const overlayScale = applyOverlayScale(iframeDoc);
1685
+
1686
+ for (const el of selected) {
1687
+ const elementId = el.getAttribute('data-element-id') || '';
1688
+ const box = iframeDoc.querySelector<HTMLElement>(
1689
+ `.selection-box[data-for-element="${elementId}"]`
1690
+ );
1691
+ if (!box) {
1692
+ // 対応する枠が無い = 対応関係が壊れている
1693
+ refreshSelectionOverlay(iframeDoc);
1694
+ return;
1695
+ }
1696
+
1697
+ const rect = getOverlayRect(iframeDoc, el);
1698
+ box.style.left = `${rect.left}px`;
1699
+ box.style.top = `${rect.top}px`;
1700
+ box.style.width = `${rect.width}px`;
1701
+ box.style.height = `${rect.height}px`;
1702
+
1703
+ const sizeLabel = box.querySelector('.size-label');
1704
+ if (sizeLabel) {
1705
+ sizeLabel.textContent = overlayLabelText(rect.width, rect.height);
1706
+ }
1707
+
1708
+ // 角丸ハンドルの位置は要素サイズと border-radius に依存するので、
1709
+ // リサイズ中も追従させる(小さくなりすぎたら自動的に消える)
1710
+ if (!shouldBeMember) {
1711
+ updateRadiusHandle(iframeDoc, box, el, rect, overlayScale);
1712
+ }
1713
+ }
1714
+
1715
+ updateSelectionBounds(iframeDoc, selected);
1716
+ }
1717
+
1718
+ // ========================================
1719
+ // ドラッグ開始時の座標基準(origin)の決定
1720
+ // ========================================
1721
+
1722
+ /**
1723
+ * この要素を left/top 駆動でドラッグできるか
1724
+ *
1725
+ * テキストフロー内の純粋なインライン要素は left/top を書いても動かないため、
1726
+ * 群ドラッグの対象から外す(対象に入れると「動かないのに枠だけ動く」ことになる)。
1727
+ */
1728
+ export function canDragElementByPosition(element: HTMLElement, iframeDoc: Document): boolean {
1729
+ const computedStyle = iframeDoc.defaultView?.getComputedStyle(element);
1730
+ if (!computedStyle) return true;
1731
+
1732
+ if (!isInlineElement(element, computedStyle)) return true;
1733
+
1734
+ const position = computedStyle.position;
1735
+ const display = computedStyle.display;
1736
+ return (
1737
+ position === 'absolute' ||
1738
+ position === 'fixed' ||
1739
+ display === 'block' ||
1740
+ display === 'inline-block' ||
1741
+ display === 'flex' ||
1742
+ display === 'inline-flex' ||
1743
+ display === 'grid' ||
1744
+ display === 'inline-grid'
1745
+ );
1746
+ }
1747
+
1748
+ /**
1749
+ * ドラッグ開始時の座標基準(origin)を決定する
1750
+ *
1751
+ * [なぜ offsetLeft / offsetTop を使うか]
1752
+ * 従来は `parseFloat(el.style.left) || 0` でインラインstyleだけを見ていたため、
1753
+ * Tailwindの任意値クラス(`absolute left-[96px]`)で配置された要素は origin=0 と
1754
+ * 誤読され、移動量に「その要素自身の座標 × ズーム倍率」の誤差が乗っていた。
1755
+ * これが「同じドラッグ量なのに要素ごとに移動量が違う」の直接の原因。
1756
+ *
1757
+ * offsetLeft/offsetTop は offsetParent のパディングボックス基準の
1758
+ * 「スケール前のCSSピクセル」を返す。絶対配置要素の包含ブロックは offsetParent と
1759
+ * 一致するので、そのまま style.left/top に書き戻せる。
1760
+ * getBoundingClientRect と違ってズーム倍率で割る必要がなく、
1761
+ * 要素自身の transform:rotate() の影響も受けない。
1762
+ *
1763
+ * @param options.convertToAbsolute static/relative の要素を absolute へ変換してよいか
1764
+ * (オートレイアウトモードでは false。周囲の兄弟がリフローしてしまうため)
1765
+ */
1766
+ /**
1767
+ * ドラッグ対象をまとめて絶対配置へ変換する。
1768
+ *
1769
+ * **移動が始まってから**呼ぶこと。mousedown の時点で変換すると、要素が流れから外れて
1770
+ * 後続の兄弟が一斉に詰め上がり、「選択しただけで版面が動く」ように見える。
1771
+ *
1772
+ * 1要素ずつ変換すると、先に外した要素の分だけ後続要素の offsetTop が変わってしまう。
1773
+ * そのため **採寸を全部先に済ませてから** 書き込む。位置は mousedown 時に読んでおいた
1774
+ * origin をそのまま使うので、変換の前後で見た目は動かない。
1775
+ */
1776
+ export function convertDragTargetsToAbsolute(
1777
+ targets: { element: HTMLElement; origin: { left: number; top: number } }[],
1778
+ iframeDoc: Document
1779
+ ): void {
1780
+ const win = iframeDoc.defaultView;
1781
+ if (!win) return;
1782
+
1783
+ const pending = targets
1784
+ .map(({ element, origin }) => {
1785
+ const cs = win.getComputedStyle(element);
1786
+ if (cs.position === 'absolute' || cs.position === 'fixed') return null;
1787
+ return {
1788
+ element,
1789
+ origin,
1790
+ width: element.offsetWidth,
1791
+ height: element.offsetHeight,
1792
+ parent: element.parentElement,
1793
+ };
1794
+ })
1795
+ .filter((x): x is NonNullable<typeof x> => x !== null);
1796
+
1797
+ if (!pending.length) return;
1798
+
1799
+ // 親を offsetParent に確定させる(position:relative を入れるだけでは要素は動かない)
1800
+ for (const p of pending) {
1801
+ if (p.parent && p.parent !== iframeDoc.body) {
1802
+ if (win.getComputedStyle(p.parent).position === 'static') {
1803
+ capturePrestyle(p.parent);
1804
+ p.parent.style.position = 'relative';
1805
+ }
1806
+ }
1807
+ }
1808
+
1809
+ for (const p of pending) {
1810
+ capturePrestyle(p.element);
1811
+ p.element.style.width = `${p.width}px`;
1812
+ p.element.style.height = `${p.height}px`;
1813
+ p.element.style.margin = '0';
1814
+ p.element.style.position = 'absolute';
1815
+ p.element.style.left = `${p.origin.left}px`;
1816
+ p.element.style.top = `${p.origin.top}px`;
1817
+ }
1818
+ }
1819
+
1820
+ /**
1821
+ * 要素の寸法を中身にフィットさせる(Figmaのハンドルのダブルクリック相当)。
1822
+ *
1823
+ * - 横(e/w): 幅を中身の最長行に合わせる。<br>による改行は保ち、自動折り返しだけが解ける
1824
+ * - 縦(n/s): いまの幅のまま、高さを中身に合わせる
1825
+ * - 角(ne等): 両方
1826
+ * 掴んだハンドルの**反対側の辺を固定**する(左辺のハンドルなら右端が動かない)。
1827
+ *
1828
+ * 中身が絶対配置の子だけの場合、auto採寸は0になるので子の外接矩形にフィットさせる。
1829
+ */
1830
+ export function fitElementToContent(
1831
+ element: HTMLElement,
1832
+ iframeDoc: Document,
1833
+ handle: string,
1834
+ ): { changed: boolean } {
1835
+ const win = iframeDoc.defaultView;
1836
+ if (!win) return { changed: false };
1837
+ const fitW = handle.includes('e') || handle.includes('w');
1838
+ const fitH = handle.includes('n') || handle.includes('s');
1839
+ if (!fitW && !fitH) return { changed: false };
1840
+
1841
+ const before = { w: element.offsetWidth, h: element.offsetHeight,
1842
+ left: element.offsetLeft, top: element.offsetTop };
1843
+
1844
+ // 絶対配置の子の外接矩形(フォールバック用)
1845
+ const absChildren = [...element.children].filter((c) => {
1846
+ const pos = win.getComputedStyle(c).position;
1847
+ return pos === 'absolute';
1848
+ }) as HTMLElement[];
1849
+ const childExtent = absChildren.length
1850
+ ? {
1851
+ w: Math.max(...absChildren.map((c) => c.offsetLeft + c.offsetWidth)),
1852
+ h: Math.max(...absChildren.map((c) => c.offsetTop + c.offsetHeight)),
1853
+ }
1854
+ : null;
1855
+
1856
+ // 実際の行数(y位置のクラスタ数)。折り返し検証に使う
1857
+ const countLines = (): number => {
1858
+ const range = iframeDoc.createRange();
1859
+ range.selectNodeContents(element);
1860
+ const ys: number[] = [];
1861
+ for (const r of range.getClientRects()) {
1862
+ if (r.width <= 0 || r.height <= 0) continue;
1863
+ if (!ys.some((y) => Math.abs(y - r.top) < 3)) ys.push(r.top);
1864
+ }
1865
+ return ys.length;
1866
+ };
1867
+
1868
+ // 一時的に auto / max-content で採寸して戻す
1869
+ const prev = { width: element.style.width, height: element.style.height };
1870
+ let newW = before.w;
1871
+ let newH = before.h;
1872
+ if (fitW) {
1873
+ element.style.width = 'max-content';
1874
+ const m = element.offsetWidth;
1875
+ const naturalLines = countLines(); // <br>による本来の行数
1876
+ // テキストが無く子も無いと 0 になる。その場合は子の外接、それも無ければ現状維持
1877
+ newW = m > 4 ? m : childExtent ? childExtent.w : before.w;
1878
+ if (m > 4) {
1879
+ // 測定値ちょうどで固定すると、letter-spacing の末尾分や丸めで1px足りず
1880
+ // 最後の1文字だけが折り返することがある(実害あり)。
1881
+ // 固定してみて行数が増えていたら、増えなくなるまで少しずつ広げる
1882
+ newW = Math.ceil(newW);
1883
+ element.style.width = `${newW}px`;
1884
+ let guard = 12;
1885
+ while (countLines() > naturalLines && guard-- > 0) {
1886
+ newW += 1;
1887
+ element.style.width = `${newW}px`;
1888
+ }
1889
+ }
1890
+ }
1891
+ if (fitH) {
1892
+ // 幅を確定させてから高さを測る(横フィットと同時なら新しい幅で折り返す)
1893
+ element.style.width = fitW ? `${Math.ceil(newW)}px` : prev.width;
1894
+ element.style.height = 'auto';
1895
+ const m = element.offsetHeight;
1896
+ newH = m > 4 ? m : childExtent ? childExtent.h : before.h;
1897
+ }
1898
+ element.style.width = prev.width;
1899
+ element.style.height = prev.height;
1900
+
1901
+ newW = Math.ceil(newW);
1902
+ newH = Math.ceil(newH);
1903
+ const changed = newW !== before.w || newH !== before.h;
1904
+ if (!changed) return { changed: false };
1905
+
1906
+ // 反対側の辺を固定する。w側を掴んだら右端、n側を掴んだら下端を保つ
1907
+ if (fitW) {
1908
+ element.style.width = `${newW}px`;
1909
+ if (handle.includes('w')) element.style.left = `${before.left + (before.w - newW)}px`;
1910
+ }
1911
+ if (fitH) {
1912
+ element.style.height = `${newH}px`;
1913
+ if (handle.includes('n')) element.style.top = `${before.top + (before.h - newH)}px`;
1914
+ }
1915
+ return { changed: true };
1916
+ }
1917
+
1918
+ export function prepareElementDragOrigin(
1919
+ element: HTMLElement,
1920
+ iframeDoc: Document,
1921
+ options: { convertToAbsolute: boolean }
1922
+ ): { left: number; top: number } {
1923
+ const win = iframeDoc.defaultView;
1924
+ const computedStyle = win?.getComputedStyle(element);
1925
+ const position = computedStyle?.position;
1926
+ const isOutOfFlow = position === 'absolute' || position === 'fixed';
1927
+
1928
+ if (!isOutOfFlow) {
1929
+ if (!options.convertToAbsolute) {
1930
+ // 変換が許されていない(オートレイアウト等)。
1931
+ // 値だけ返して要素は一切触らない。
1932
+ return { left: element.offsetLeft, top: element.offsetTop };
1933
+ }
1934
+
1935
+ // 親を offsetParent に確定させてから採寸する。
1936
+ // (position:relative を入れるだけでは要素は移動しない)
1937
+ const parent = element.parentElement;
1938
+ if (parent && parent !== iframeDoc.body) {
1939
+ const parentStyle = win?.getComputedStyle(parent);
1940
+ if (parentStyle?.position === 'static') {
1941
+ capturePrestyle(parent);
1942
+ parent.style.position = 'relative';
1943
+ }
1944
+ }
1945
+
1946
+ // 採寸はスタイルを書き換える前に行う(margin:0 を入れると値が変わるため)
1947
+ const left = element.offsetLeft;
1948
+ const top = element.offsetTop;
1949
+ const width = element.offsetWidth;
1950
+ const height = element.offsetHeight;
1951
+
1952
+ capturePrestyle(element);
1953
+ element.style.width = `${width}px`;
1954
+ element.style.height = `${height}px`;
1955
+ element.style.margin = '0';
1956
+ element.style.position = 'absolute';
1957
+ element.style.left = `${left}px`;
1958
+ element.style.top = `${top}px`;
1959
+ return { left, top };
1960
+ }
1961
+
1962
+ // すでに絶対配置。offsetLeft/offsetTop がそのまま left/top の基準になる
1963
+ const left = element.offsetLeft;
1964
+ const top = element.offsetTop;
1965
+
1966
+ // right / bottom で位置決めされている要素(例: `absolute bottom-[24px] left-[96px]`)に
1967
+ // そのまま top を書くと、top と bottom が同時に効いて「移動」ではなく「伸長」になる。
1968
+ // 対向オフセットを auto に落とし、サイズを固定してから left/top 駆動に切り替える。
1969
+ // 「right/bottom で位置決めされているか」は**指定値**で判定する。
1970
+ // computedStyle.right は left+width が決まっていれば使用値(px)を返すため、
1971
+ // それで判定すると絶対配置の全要素が該当し、クリックしただけの要素へ
1972
+ // right:auto / bottom:auto が書き込まれてしまう(=触っていないのに
1973
+ // 指紋が変わり、原本への書き戻しで幾何が焼き込まれる誤爆の原因になった)
1974
+ const hasRight =
1975
+ (element.style.right !== '' && element.style.right !== 'auto') ||
1976
+ /(^|\s)-?right-/.test(element.className);
1977
+ const hasBottom =
1978
+ (element.style.bottom !== '' && element.style.bottom !== 'auto') ||
1979
+ /(^|\s)-?bottom-/.test(element.className);
1980
+ if (hasRight || hasBottom) {
1981
+ element.style.width = `${element.offsetWidth}px`;
1982
+ element.style.height = `${element.offsetHeight}px`;
1983
+ if (hasRight) {
1984
+ element.style.right = 'auto';
1985
+ // インラインの auto だけでは `right-[24px]` クラスが保存HTMLに残り、
1986
+ // 再読み込み時に再びアンカーが復活してしまうためクラスごと剥がす
1987
+ removeConflictingClasses(element, 'right');
1988
+ }
1989
+ if (hasBottom) {
1990
+ element.style.bottom = 'auto';
1991
+ removeConflictingClasses(element, 'bottom');
1992
+ }
1993
+ }
1994
+
1995
+ element.style.left = `${left}px`;
1996
+ element.style.top = `${top}px`;
1997
+ return { left, top };
1998
+ }
1999
+
2000
+ /** 群ドラッグの初期状態 */
2001
+ export interface GroupDragOrigin {
2002
+ /** 実際に移動させる要素(子孫や移動不可の要素を除外済み) */
2003
+ elements: HTMLElement[];
2004
+ /** 各要素の開始座標 */
2005
+ origPositions: { left: number; top: number }[];
2006
+ }
2007
+
2008
+ /**
2009
+ * 複数選択の群ドラッグを開始できる状態にする
2010
+ *
2011
+ * 1. 「他の選択要素の子孫」を移動対象から除外する
2012
+ * (親と子の両方に同じ delta を足すと、子は親と一緒に動いた分と合わせて2倍動く)
2013
+ * 2. left/top で動かせない要素を除外する
2014
+ * 3. 各要素の origin を offsetLeft/offsetTop で採り、必要なら absolute へ変換する
2015
+ *
2016
+ * @returns 群移動できない場合は null(呼び出し側でヒントを出す)
2017
+ */
2018
+ export function prepareElementsForGroupDrag(
2019
+ rawElements: HTMLElement[],
2020
+ iframeDoc: Document,
2021
+ layoutMode: 'absolute' | 'auto'
2022
+ ): GroupDragOrigin | null {
2023
+ const elementSet = new Set(rawElements);
2024
+
2025
+ // 1. 祖先が同じ選択に含まれる要素を除外(子は親と一緒に動くので delta を足さない)
2026
+ const topLevel = rawElements.filter(el => {
2027
+ let parent = el.parentElement;
2028
+ while (parent) {
2029
+ if (elementSet.has(parent)) return false;
2030
+ parent = parent.parentElement;
2031
+ }
2032
+ return true;
2033
+ });
2034
+
2035
+ // 2. left/top で動かせない要素を除外
2036
+ const draggable = topLevel.filter(el => canDragElementByPosition(el, iframeDoc));
2037
+ if (draggable.length === 0) return null;
2038
+
2039
+ const win = iframeDoc.defaultView;
2040
+ const isComponentEditMode = iframeDoc.body.classList.contains('component-edit-mode');
2041
+ const mayConvert = layoutMode === 'absolute' && !isComponentEditMode;
2042
+
2043
+ // 3. コンポーネント編集中は絶対配置へ変換できない。変換すると未選択の兄弟が
2044
+ // 空いた場所へリフローしてスライド全体が崩れる。
2045
+ // 全要素がすでにフローから外れている(absolute/fixed)なら、動かしても
2046
+ // リフローは起きないので群移動を許可する。
2047
+ if (!mayConvert) {
2048
+ const allOutOfFlow = draggable.every(el => {
2049
+ const position = win?.getComputedStyle(el).position;
2050
+ return position === 'absolute' || position === 'fixed';
2051
+ });
2052
+ if (!allOutOfFlow) return null;
2053
+ }
2054
+
2055
+ // ここでは**採寸だけ**する。mousedown の時点で絶対配置へ変換すると、
2056
+ // 選んだ要素が流れから外れて未選択の兄弟が詰め上がり、
2057
+ // 「選択しただけで位置やサイズが変わる」ように見える。
2058
+ // 変換は実際に動き始めた時点(useDragResize)で行う。
2059
+ const origPositions = draggable.map(el =>
2060
+ prepareElementDragOrigin(el, iframeDoc, { convertToAbsolute: false })
2061
+ );
2062
+
2063
+ return { elements: draggable, origPositions };
2064
+ }
2065
+
2066
+ /**
2067
+ * 要素にユニークIDを付与
2068
+ */
2069
+ export function generateElementId(prefix: string = 'el'): string {
2070
+ return `${prefix}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
2071
+ }
2072
+
2073
+ // ========================================
2074
+ // マーキー選択(範囲選択)関連のユーティリティ
2075
+ // ========================================
2076
+
2077
+ /**
2078
+ * マーキー選択ボックスを作成
2079
+ */
2080
+ export function createMarqueeBox(iframeDoc: Document): HTMLDivElement {
2081
+ const box = iframeDoc.createElement('div');
2082
+ box.className = 'marquee-selection-box';
2083
+ box.style.display = 'none';
2084
+ iframeDoc.body.appendChild(box);
2085
+ return box;
2086
+ }
2087
+
2088
+ /**
2089
+ * マーキー選択ボックスの位置・サイズを更新
2090
+ * 座標はiframe内のビューポート座標(e.clientX/e.clientY)をそのまま使用
2091
+ */
2092
+ export function updateMarqueeBox(
2093
+ box: HTMLDivElement,
2094
+ startX: number,
2095
+ startY: number,
2096
+ currentX: number,
2097
+ currentY: number
2098
+ ): void {
2099
+ const left = Math.min(startX, currentX);
2100
+ const top = Math.min(startY, currentY);
2101
+ const width = Math.abs(currentX - startX);
2102
+ const height = Math.abs(currentY - startY);
2103
+
2104
+ box.style.display = 'block';
2105
+ box.style.left = `${left}px`;
2106
+ box.style.top = `${top}px`;
2107
+ box.style.width = `${width}px`;
2108
+ box.style.height = `${height}px`;
2109
+ }
2110
+
2111
+ /**
2112
+ * マーキー選択ボックスを非表示
2113
+ */
2114
+ export function hideMarqueeBox(box: HTMLDivElement): void {
2115
+ box.style.display = 'none';
2116
+ }
2117
+
2118
+ /**
2119
+ * マーキー状態から境界座標を取得
2120
+ */
2121
+ export function getMarqueeBounds(state: MarqueeState): {
2122
+ left: number;
2123
+ top: number;
2124
+ right: number;
2125
+ bottom: number;
2126
+ } {
2127
+ return {
2128
+ left: Math.min(state.startX, state.currentX),
2129
+ top: Math.min(state.startY, state.currentY),
2130
+ right: Math.max(state.startX, state.currentX),
2131
+ bottom: Math.max(state.startY, state.currentY),
2132
+ };
2133
+ }
2134
+
2135
+ /**
2136
+ * 要素が完全にマーキー範囲内にあるか判定
2137
+ * @param element 判定対象の要素
2138
+ * @param marquee マーキー境界(iframe内ビューポート座標)
2139
+ */
2140
+ export function isElementFullyInMarquee(
2141
+ element: HTMLElement,
2142
+ marquee: { left: number; top: number; right: number; bottom: number }
2143
+ ): boolean {
2144
+ const rect = element.getBoundingClientRect();
2145
+
2146
+ // getBoundingClientRectはビューポート座標を返す
2147
+ // マーキー境界も同じビューポート座標なので直接比較可能
2148
+ return (
2149
+ rect.left >= marquee.left &&
2150
+ rect.top >= marquee.top &&
2151
+ rect.right <= marquee.right &&
2152
+ rect.bottom <= marquee.bottom
2153
+ );
2154
+ }
2155
+
2156
+ /**
2157
+ * マーキー範囲内の全要素のIDを取得
2158
+ */
2159
+ export function findElementsInMarquee(
2160
+ iframeDoc: Document,
2161
+ marquee: { left: number; top: number; right: number; bottom: number }
2162
+ ): string[] {
2163
+ const selectedIds: string[] = [];
2164
+ const elements = iframeDoc.querySelectorAll('[data-element-id]');
2165
+
2166
+ elements.forEach((el) => {
2167
+ const htmlEl = el as HTMLElement;
2168
+ const id = htmlEl.getAttribute('data-element-id');
2169
+
2170
+ // 選択ボックスやプレビュー要素はスキップ
2171
+ if (htmlEl.classList.contains('selection-box') ||
2172
+ htmlEl.classList.contains('marquee-selection-box') ||
2173
+ htmlEl.classList.contains('drawing-preview')) {
2174
+ return;
2175
+ }
2176
+
2177
+ if (id && isElementFullyInMarquee(htmlEl, marquee)) {
2178
+ selectedIds.push(id);
2179
+ }
2180
+ });
2181
+
2182
+ return selectedIds;
2183
+ }
2184
+
2185
+ /**
2186
+ * 全ての子が選択されている場合、親要素に折りたたむ
2187
+ * 選択された要素のうち、全ての直接の子が選択されている親要素を見つけて置き換える
2188
+ */
2189
+ export function collapseToParentIfAllChildrenSelected(
2190
+ iframeDoc: Document,
2191
+ selectedIds: Set<string>
2192
+ ): string[] {
2193
+ const result = new Set(selectedIds);
2194
+
2195
+ // 親子関係をマップ: parentId -> 直接の子IDの配列
2196
+ const parentChildMap = new Map<string, string[]>();
2197
+
2198
+ const allElements = iframeDoc.querySelectorAll('[data-element-id]');
2199
+
2200
+ allElements.forEach((el) => {
2201
+ const id = el.getAttribute('data-element-id');
2202
+ if (!id) return;
2203
+
2204
+ // 直接の親(data-element-idを持つ最も近い先祖)を探す
2205
+ const parent = el.parentElement?.closest('[data-element-id]');
2206
+ if (parent) {
2207
+ const parentId = parent.getAttribute('data-element-id');
2208
+ if (parentId) {
2209
+ if (!parentChildMap.has(parentId)) {
2210
+ parentChildMap.set(parentId, []);
2211
+ }
2212
+ parentChildMap.get(parentId)!.push(id);
2213
+ }
2214
+ }
2215
+ });
2216
+
2217
+ // ボトムアップで処理(葉から根へ)
2218
+ // 全ての子が選択されている親を見つけて置き換え
2219
+ let changed = true;
2220
+ while (changed) {
2221
+ changed = false;
2222
+
2223
+ parentChildMap.forEach((childIds, parentId) => {
2224
+ // 親自体がすでに選択されている場合はスキップ
2225
+ if (result.has(parentId)) return;
2226
+
2227
+ // 全ての子が選択されているか確認
2228
+ if (childIds.length > 0 && childIds.every(id => result.has(id))) {
2229
+ // 全ての子が選択されている → 子を削除して親を追加
2230
+ childIds.forEach(id => result.delete(id));
2231
+ result.add(parentId);
2232
+ changed = true;
2233
+ }
2234
+ });
2235
+ }
2236
+
2237
+ return Array.from(result);
2238
+ }