@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,1197 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * EditorLayerPanel - レイヤーパネル
5
+ *
6
+ * Phase 2: パフォーマンス最適化
7
+ * - LayerTreeItemコンポーネントを抽出してメモ化
8
+ * - renderTreeNodeをuseCallbackでメモ化
9
+ */
10
+
11
+ import React, { useState, useMemo, useEffect, useRef, useCallback, memo } from 'react';
12
+ import { Input } from '../../components/ui/input';
13
+ import { ScrollArea } from '../../components/ui/scroll-area';
14
+ import {
15
+ Search,
16
+ Layers,
17
+ ChevronRight,
18
+ ChevronDown,
19
+ Presentation,
20
+ } from 'lucide-react';
21
+ import { useEditorContext, type ContentListItem } from '../EditorContext';
22
+ import { useElementActions } from '../hooks/useElementActions';
23
+ import { useResizablePanel } from '../hooks/useResizablePanel';
24
+ import { refreshSelectionOverlay, buildDomTree } from '../utils/dom-utils';
25
+ import { extractElementInfo } from '../utils/style-utils';
26
+ import type { DOMTreeNode } from '../types';
27
+ import { LayerTreeItem, type LayerKind } from './LayerTreeItem';
28
+
29
+ // ========================================
30
+ // レイヤー名の生成
31
+ // ========================================
32
+ //
33
+ // なぜ実 DOM から作るか:
34
+ // buildDomTree が持たせる className は「先頭のクラス1個だけ」で、このプロジェクトの
35
+ // スライドは Tailwind ユーティリティ(absolute / flex / mt-[22px] …)なので、
36
+ // 先頭クラスは中身を一切説明しない。text も 20 文字で切られ「...」が焼き込まれている。
37
+ // そのため名前はレイヤーツリーのノードではなく iframe 内の実要素から作る。
38
+
39
+ interface LayerLabel {
40
+ name: string;
41
+ kind: LayerKind;
42
+ }
43
+
44
+ /** コンテナ系タグの日本語名。中身が空でも「何の入れ物か」は分かるようにする */
45
+ const SEMANTIC_CONTAINER_NAMES: Record<string, { name: string; kind: LayerKind }> = {
46
+ section: { name: 'セクション', kind: 'group' },
47
+ header: { name: 'ヘッダー', kind: 'group' },
48
+ footer: { name: 'フッター', kind: 'group' },
49
+ nav: { name: 'ナビゲーション', kind: 'group' },
50
+ main: { name: 'メイン', kind: 'group' },
51
+ aside: { name: 'サイド', kind: 'group' },
52
+ article: { name: '記事', kind: 'group' },
53
+ figure: { name: '図版', kind: 'group' },
54
+ figcaption: { name: '図版キャプション', kind: 'text' },
55
+ form: { name: 'フォーム', kind: 'group' },
56
+ ul: { name: 'リスト', kind: 'list' },
57
+ ol: { name: '番号リスト', kind: 'list' },
58
+ dl: { name: '定義リスト', kind: 'list' },
59
+ li: { name: 'リスト項目', kind: 'list' },
60
+ table: { name: 'テーブル', kind: 'table' },
61
+ thead: { name: 'テーブル見出し', kind: 'table' },
62
+ tbody: { name: 'テーブル本体', kind: 'table' },
63
+ tr: { name: 'テーブル行', kind: 'table' },
64
+ td: { name: 'セル', kind: 'table' },
65
+ th: { name: '見出しセル', kind: 'table' },
66
+ };
67
+
68
+ /**
69
+ * 表示幅に収まるところで切る。
70
+ * パネル幅は 200〜400px で、階層インデントとアイコンに 60〜100px 取られるため、
71
+ * 24 文字を超えると右端のタグ名が見切れる。全文は行の title 属性で読める。
72
+ */
73
+ function truncateLabel(text: string, max = 24): string {
74
+ const normalized = text.replace(/\s+/g, ' ').trim();
75
+ return normalized.length > max ? `${normalized.slice(0, max)}…` : normalized;
76
+ }
77
+
78
+ /** 直下のテキストノードだけを拾う(子要素のテキストは含めない) */
79
+ function getOwnText(el: HTMLElement): string {
80
+ let text = '';
81
+ el.childNodes.forEach((n) => {
82
+ if (n.nodeType === 3) text += n.textContent ?? '';
83
+ });
84
+ return text.replace(/\s+/g, ' ').trim();
85
+ }
86
+
87
+ /** src からファイル名だけを取り出す */
88
+ function fileNameFromSrc(src: string): string {
89
+ try {
90
+ const path = src.split('?')[0].split('#')[0];
91
+ const name = path.substring(path.lastIndexOf('/') + 1);
92
+ return decodeURIComponent(name);
93
+ } catch {
94
+ return '';
95
+ }
96
+ }
97
+
98
+ /**
99
+ * 実要素からレイヤー行に出す名前と種別を作る。
100
+ *
101
+ * 優先順位:
102
+ * 1. 明示的な名前(data-layer-name / data-name / aria-label)
103
+ * 2. メディア・フォーム要素の固有情報(alt、ファイル名、placeholder)
104
+ * 3. 自分が直接持っているテキスト(見出し・本文・ボタン文言)
105
+ * 4. 入れ物としての意味(セクション/リスト/テーブル…)や中身のテキスト
106
+ * 5. 中身のない装飾要素(罫線・シェイプ)
107
+ */
108
+ function buildLayerLabel(el: HTMLElement): LayerLabel {
109
+ const tag = el.tagName.toLowerCase();
110
+
111
+ // 1. 明示的な名前が付いていればそれが最優先
112
+ const explicit =
113
+ el.getAttribute('data-layer-name') ||
114
+ el.getAttribute('data-name') ||
115
+ el.getAttribute('aria-label');
116
+ if (explicit && explicit.trim()) {
117
+ return { name: truncateLabel(explicit), kind: tag === 'img' ? 'image' : 'group' };
118
+ }
119
+
120
+ // 2. メディア・フォーム
121
+ if (tag === 'img') {
122
+ const alt = el.getAttribute('alt')?.trim();
123
+ const file = fileNameFromSrc(el.getAttribute('src') ?? '');
124
+ return { name: truncateLabel(alt || file || '画像'), kind: 'image' };
125
+ }
126
+ if (tag === 'svg' || tag === 'use' || tag === 'path') {
127
+ return { name: 'アイコン', kind: 'icon' };
128
+ }
129
+ if (tag === 'video' || tag === 'iframe' || tag === 'canvas') {
130
+ return { name: tag === 'video' ? '動画' : tag === 'iframe' ? '埋め込み' : 'キャンバス', kind: 'image' };
131
+ }
132
+ if (tag === 'input' || tag === 'textarea' || tag === 'select') {
133
+ const ph =
134
+ el.getAttribute('placeholder')?.trim() ||
135
+ (el as HTMLInputElement).value?.trim() ||
136
+ el.getAttribute('type')?.trim() ||
137
+ '入力';
138
+ return { name: truncateLabel(ph), kind: 'input' };
139
+ }
140
+ if (tag === 'hr') {
141
+ return { name: '区切り線', kind: 'line' };
142
+ }
143
+
144
+ // 3. 自分が直接持っているテキスト
145
+ const ownText = getOwnText(el);
146
+ if (ownText) {
147
+ if (tag === 'button' || el.getAttribute('role') === 'button') {
148
+ return { name: truncateLabel(ownText), kind: 'button' };
149
+ }
150
+ if (tag === 'a') {
151
+ return { name: truncateLabel(ownText), kind: 'link' };
152
+ }
153
+ if (/^h[1-6]$/.test(tag)) {
154
+ return { name: truncateLabel(ownText), kind: 'heading' };
155
+ }
156
+ return { name: truncateLabel(ownText), kind: 'text' };
157
+ }
158
+
159
+ const childElementCount = el.children.length;
160
+ const allText = (el.textContent ?? '').replace(/\s+/g, ' ').trim();
161
+
162
+ // 4. 入れ物
163
+ if (childElementCount > 0) {
164
+ // 中にテキストがあるなら、その文言で呼ぶのが一番探しやすい
165
+ if (allText) {
166
+ const semantic = SEMANTIC_CONTAINER_NAMES[tag];
167
+ return {
168
+ name: truncateLabel(allText),
169
+ kind: semantic ? semantic.kind : 'group',
170
+ };
171
+ }
172
+ const semantic = SEMANTIC_CONTAINER_NAMES[tag];
173
+ if (semantic) return semantic;
174
+ return { name: `グループ (${childElementCount})`, kind: 'group' };
175
+ }
176
+
177
+ // 5. 中身のない要素 = 罫線か装飾シェイプ
178
+ const semanticEmpty = SEMANTIC_CONTAINER_NAMES[tag];
179
+ if (semanticEmpty) return semanticEmpty;
180
+
181
+ // offsetWidth/Height はズーム(transform: scale)の影響を受けない実寸なので、
182
+ // 倍率を変えても罫線判定がぶれない
183
+ const w = el.offsetWidth;
184
+ const h = el.offsetHeight;
185
+ if ((h > 0 && h <= 4) || (w > 0 && w <= 4)) {
186
+ return { name: '区切り線', kind: 'line' };
187
+ }
188
+
189
+ const bg = el.ownerDocument?.defaultView?.getComputedStyle(el).backgroundImage;
190
+ if (bg && bg !== 'none') {
191
+ return { name: '背景画像', kind: 'image' };
192
+ }
193
+
194
+ return { name: 'シェイプ', kind: 'shape' };
195
+ }
196
+
197
+ /** 実要素が取れなかったときの保険(ツリーの情報だけで作る) */
198
+ function fallbackLabel(node: DOMTreeNode): LayerLabel {
199
+ if (node.text) return { name: truncateLabel(node.text), kind: 'text' };
200
+ return { name: node.tagName, kind: 'group' };
201
+ }
202
+
203
+ // ========================================
204
+ // レイヤー行ホバー → キャンバス側ハイライト
205
+ // ========================================
206
+ //
207
+ // キャンバス側の .hover-preview(クリック予告の輪郭)は使い回さない。
208
+ // あちらは useIframeSetup が「自分が付けた1要素」を内部に記憶して付け替えるため、
209
+ // 外から一括で外すと、あちらの記憶とズレて輪郭が出なくなる(他人の機能を壊す)。
210
+ // こちらは独立した属性を使い、毎回「全部外して1個だけ付ける」ので取り残しが起きない。
211
+ const LAYER_HOVER_ATTR = 'data-layer-hover';
212
+ const LAYER_HOVER_STYLE_ID = 'editor-layer-hover-style';
213
+ const LAYER_HOVER_CSS = `
214
+ [${LAYER_HOVER_ATTR}="true"] {
215
+ box-shadow:
216
+ inset 0 0 0 9999px rgba(13, 153, 255, 0.10),
217
+ 0 0 0 2px rgba(13, 153, 255, 0.55) !important;
218
+ }
219
+ `;
220
+
221
+ /** iframe 内にハイライト用スタイルを1度だけ差し込む */
222
+ function ensureLayerHoverStyle(doc: Document) {
223
+ if (doc.getElementById(LAYER_HOVER_STYLE_ID)) return;
224
+ const style = doc.createElement('style');
225
+ style.id = LAYER_HOVER_STYLE_ID;
226
+ style.textContent = LAYER_HOVER_CSS;
227
+ (doc.head || doc.documentElement).appendChild(style);
228
+ }
229
+
230
+ /** ハイライトを掃除してから1要素だけに付け直す */
231
+ function applyLayerHover(doc: Document, elementId: string | null) {
232
+ doc
233
+ .querySelectorAll(`[${LAYER_HOVER_ATTR}]`)
234
+ .forEach((el) => el.removeAttribute(LAYER_HOVER_ATTR));
235
+ if (!elementId) return;
236
+ ensureLayerHoverStyle(doc);
237
+ const el = doc.querySelector(`[data-element-id="${elementId}"]`);
238
+ el?.setAttribute(LAYER_HOVER_ATTR, 'true');
239
+ }
240
+
241
+ /**
242
+ * メモ化されたスライドサムネイルコンポーネント
243
+ * 大きなHTMLをレンダリングするため、パフォーマンス最適化が重要
244
+ */
245
+ const ContentThumbnail = memo(function ContentThumbnail({
246
+ html,
247
+ slideNumber
248
+ }: {
249
+ html?: string;
250
+ slideNumber: number;
251
+ }) {
252
+ if (!html) {
253
+ return (
254
+ <div className="w-full h-full flex items-center justify-center text-[8px] text-gray-400 bg-[#2c2c2c]">
255
+ {slideNumber}
256
+ </div>
257
+ );
258
+ }
259
+
260
+ return (
261
+ <div className="w-full h-full overflow-hidden relative bg-white">
262
+ <iframe
263
+ srcDoc={html}
264
+ title={`Thumbnail for slide ${slideNumber}`}
265
+ style={{
266
+ width: '4000%', // 100% / 0.025
267
+ height: '4000%',
268
+ transform: 'scale(0.025)',
269
+ transformOrigin: 'top left',
270
+ border: 'none',
271
+ pointerEvents: 'none',
272
+ position: 'absolute',
273
+ top: 0,
274
+ left: 0,
275
+ }}
276
+ tabIndex={-1}
277
+ sandbox="allow-scripts"
278
+ />
279
+ </div>
280
+ );
281
+ });
282
+
283
+ /**
284
+ * メモ化されたスライドリストアイテム
285
+ */
286
+ const ContentListItemComponent = memo(function ContentListItemComponent({
287
+ slide,
288
+ isCurrent,
289
+ onClick,
290
+ }: {
291
+ slide: ContentListItem;
292
+ isCurrent: boolean;
293
+ onClick: () => void;
294
+ }) {
295
+ return (
296
+ <button
297
+ onClick={onClick}
298
+ className={`
299
+ w-full px-2 py-1.5 flex items-center gap-2 text-xs transition-colors
300
+ ${isCurrent
301
+ ? 'bg-[#0d99ff]/20 text-[#4fb8ff] border-l-2 border-[#0d99ff]'
302
+ : 'text-gray-400 hover:bg-[#2c2c2c] border-l-2 border-transparent'
303
+ }
304
+ `}
305
+ disabled={isCurrent}
306
+ title={slide.title || `スライド ${slide.order}`}
307
+ >
308
+
309
+
310
+ {/* スライド情報 */}
311
+ <div className="flex-1 min-w-0 text-left">
312
+ <div className="truncate font-medium">
313
+ {slide.order}. {slide.title || '無題'}
314
+ </div>
315
+ </div>
316
+
317
+ {/* カレント表示 */}
318
+ {isCurrent && (
319
+ <span className="text-[10px] px-1 py-0.5 bg-[#0d99ff]/30 rounded text-[#7cc4ff]">
320
+ 編集中
321
+ </span>
322
+ )}
323
+ </button>
324
+ );
325
+ });
326
+
327
+ /**
328
+ * メモ化されたスライドリスト
329
+ */
330
+ const ContentList = memo(function ContentList({
331
+ slides,
332
+ currentContentId,
333
+ onContentClick,
334
+ }: {
335
+ slides: ContentListItem[];
336
+ currentContentId: string | null;
337
+ onContentClick: (slideId: string) => void;
338
+ }) {
339
+ return (
340
+ <>
341
+ {slides.map((slide) => (
342
+ <ContentListItemComponent
343
+ key={slide.id}
344
+ slide={slide}
345
+ isCurrent={slide.id === currentContentId}
346
+ onClick={() => onContentClick(slide.id)}
347
+ />
348
+ ))}
349
+ </>
350
+ );
351
+ });
352
+
353
+ /**
354
+ * 左パネル: スライドリスト + レイヤー/DOMツリー(Figmaライク)
355
+ *
356
+ * hideSlideList を立てると全スライドの一覧を出さず、編集中ページのレイヤーだけを出す。
357
+ * 上段にプレビュー付きのページ切替(PagesPanel)を置く2段構成では、
358
+ * ページの並びは上段が担うので、ここで同じ一覧を繰り返さない。
359
+ */
360
+ export function EditorLayerPanel({ hideSlideList = false }: { hideSlideList?: boolean } = {}) {
361
+ const {
362
+ selectedElement,
363
+ setSelectedElement,
364
+ selectedElementIds,
365
+ setSelectedElementIds,
366
+ domTree,
367
+ expandedNodes,
368
+ setExpandedNodes,
369
+ setDomTree,
370
+ getIframeDoc,
371
+ notifyIframeChange,
372
+ // スライドリスト
373
+ slides,
374
+ currentContentId,
375
+ onContentChange,
376
+ } = useEditorContext();
377
+
378
+ const [contentListExpanded, setContentListExpanded] = useState(true);
379
+ // 各スライドのツリー展開状態(スライドID → 展開状態)
380
+ const [slideTreeExpanded, setSlideTreeExpanded] = useState<Map<string, boolean>>(new Map());
381
+
382
+ // リサイズ可能なパネル
383
+ const { width, isDragging, resizeHandleProps } = useResizablePanel({
384
+ initialWidth: 256, // w-64 = 16rem = 256px
385
+ minWidth: 200,
386
+ maxWidth: 400,
387
+ direction: 'right', // 右端をドラッグしてリサイズ
388
+ storageKey: 'editor-layer-panel-width',
389
+ });
390
+
391
+ const {
392
+ deleteElement,
393
+ duplicateElement,
394
+ bringForward,
395
+ sendBackward,
396
+ bringToFront,
397
+ sendToBack,
398
+ copyStyle,
399
+ pasteStyle,
400
+ hasStyleInClipboard,
401
+ } = useElementActions();
402
+
403
+ const [searchQuery, setSearchQuery] = useState('');
404
+ // レイヤー行にカーソルがある要素
405
+ const [rowHoverId, setRowHoverId] = useState<string | null>(null);
406
+ // キャンバス側でホバー中の要素(.hover-preview から拾う)
407
+ const [canvasHoverId, setCanvasHoverId] = useState<string | null>(null);
408
+ const [draggedNodeId, setDraggedNodeId] = useState<string | null>(null);
409
+ const [dragOverNodeId, setDragOverNodeId] = useState<string | null>(null);
410
+ const [dragOverPosition, setDragOverPosition] = useState<'before' | 'after' | 'inside' | null>(null);
411
+ const nodeRefs = useRef<Map<string, HTMLDivElement>>(new Map());
412
+
413
+ // ノードからルートまでのパスを取得
414
+ const findPathToNode = useCallback((nodes: DOMTreeNode[], targetId: string, path: string[] = []): string[] | null => {
415
+ for (const node of nodes) {
416
+ if (node.id === targetId) {
417
+ return path;
418
+ }
419
+ if (node.children.length > 0) {
420
+ const foundPath = findPathToNode(node.children, targetId, [...path, node.id]);
421
+ if (foundPath) return foundPath;
422
+ }
423
+ }
424
+ return null;
425
+ }, []);
426
+
427
+ // 選択された要素が変わったときに自動的にレイヤーを展開・スクロール
428
+ useEffect(() => {
429
+ if (!selectedElement?.id) return;
430
+
431
+ // 親ノードを全て展開
432
+ const path = findPathToNode(domTree, selectedElement.id);
433
+ if (path && path.length > 0) {
434
+ setExpandedNodes(prev => {
435
+ const next = new Set(prev);
436
+ path.forEach(id => next.add(id));
437
+ return next;
438
+ });
439
+ }
440
+
441
+ // 少し遅延してからスクロール(展開アニメーション後)
442
+ setTimeout(() => {
443
+ const nodeElement = nodeRefs.current.get(selectedElement.id);
444
+ if (nodeElement) {
445
+ nodeElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
446
+ }
447
+ }, 100);
448
+ }, [selectedElement?.id, domTree, findPathToNode, setExpandedNodes]);
449
+
450
+ /**
451
+ * レイヤー名のテーブル(要素ID → 表示名・種別)。
452
+ * domTree が作り直されたら(=DOM が変わったら)作り直す。
453
+ * labelKey は「名前そのものが変わったか」を子行に伝えるためのキー。
454
+ * テキストを編集しても domTree の構造キーは変わらないので、
455
+ * 名前の中身から作らないと memo に弾かれて古い名前が残る。
456
+ */
457
+ const { labelMap, labelKey } = useMemo(() => {
458
+ const map = new Map<string, LayerLabel>();
459
+ const iframeDoc = getIframeDoc();
460
+
461
+ // 実要素を1回の querySelectorAll でまとめて引く(ノードごとの検索より速い)
462
+ const elementById = new Map<string, HTMLElement>();
463
+ if (iframeDoc) {
464
+ iframeDoc.querySelectorAll('[data-element-id]').forEach((el) => {
465
+ const id = el.getAttribute('data-element-id');
466
+ if (id) elementById.set(id, el as HTMLElement);
467
+ });
468
+ }
469
+
470
+ const walk = (nodes: DOMTreeNode[]) => {
471
+ for (const node of nodes) {
472
+ const el = elementById.get(node.id);
473
+ map.set(node.id, el ? buildLayerLabel(el) : fallbackLabel(node));
474
+ if (node.children.length > 0) walk(node.children);
475
+ }
476
+ };
477
+ walk(domTree);
478
+
479
+ let key = '';
480
+ map.forEach((value, id) => {
481
+ key += `${id}:${value.name}:${value.kind}|`;
482
+ });
483
+
484
+ return { labelMap: map, labelKey: key };
485
+ }, [domTree, getIframeDoc]);
486
+
487
+ // 検索にマッチするノードをフィルタ
488
+ const filteredDomTree = useMemo(() => {
489
+ if (!searchQuery.trim()) return domTree;
490
+
491
+ const query = searchQuery.toLowerCase();
492
+ const filterNodes = (nodes: DOMTreeNode[]): DOMTreeNode[] => {
493
+ return nodes.reduce<DOMTreeNode[]>((acc, node) => {
494
+ // 画面に出ている名前でも引けるようにする(見えている文字で検索できないと
495
+ // 「検索したのに出ない」という無反応になる)
496
+ const label = labelMap.get(node.id)?.name ?? '';
497
+ const matches =
498
+ label.toLowerCase().includes(query) ||
499
+ node.tagName.toLowerCase().includes(query) ||
500
+ node.className.toLowerCase().includes(query) ||
501
+ node.text.toLowerCase().includes(query);
502
+
503
+ const filteredChildren = filterNodes(node.children);
504
+
505
+ if (matches || filteredChildren.length > 0) {
506
+ acc.push({
507
+ ...node,
508
+ children: filteredChildren,
509
+ });
510
+ }
511
+
512
+ return acc;
513
+ }, []);
514
+ };
515
+
516
+ return filterNodes(domTree);
517
+ }, [domTree, searchQuery]);
518
+
519
+ // ノードの展開/折りたたみ(旧API互換用、handleToggleExpandを推奨)
520
+ const toggleExpand = useCallback((nodeId: string) => {
521
+ setExpandedNodes(prev => {
522
+ const next = new Set(prev);
523
+ if (next.has(nodeId)) {
524
+ next.delete(nodeId);
525
+ } else {
526
+ next.add(nodeId);
527
+ }
528
+ return next;
529
+ });
530
+ }, [setExpandedNodes]);
531
+
532
+ // DOMツリーをフラット化して取得(表示順)
533
+ const flattenDomTree = useCallback((nodes: DOMTreeNode[], result: DOMTreeNode[] = []): DOMTreeNode[] => {
534
+ for (const node of nodes) {
535
+ // 親ノードが閉じている場合は子を含めない場合はここを調整するが、
536
+ // 範囲選択は「見えている」ノード間で行うのが一般的。
537
+ // ここでは単純化のため全ノードを対象とするか、expandedNodes を考慮するか。
538
+ // Figmaライクにするなら、ツリー構造上の順序でフラット化する。
539
+ result.push(node);
540
+ if (node.children.length > 0 && expandedNodes.has(node.id)) {
541
+ flattenDomTree(node.children, result);
542
+ }
543
+ }
544
+ return result;
545
+ }, [expandedNodes]);
546
+
547
+ // 選択状態をiframeと同期(複数選択対応)
548
+ const updateSelection = useCallback((newSelectedIds: string[]) => {
549
+ const iframeDoc = getIframeDoc();
550
+ if (!iframeDoc) return;
551
+
552
+ // 既存の選択解除
553
+ iframeDoc.querySelectorAll('.selected').forEach(e => e.classList.remove('selected'));
554
+
555
+ // 新しい選択適用
556
+ const selectedElements: HTMLElement[] = [];
557
+ newSelectedIds.forEach(id => {
558
+ const el = iframeDoc.querySelector(`[data-element-id="${id}"]`) as HTMLElement;
559
+ if (el) {
560
+ el.classList.add('selected');
561
+ selectedElements.push(el);
562
+ }
563
+ });
564
+
565
+ // 選択ボックス更新(最後の要素または複数要素の境界ボックス)
566
+ if (selectedElements.length > 0) {
567
+ // 最後の要素を基準にpostMessage(プロパティパネル表示用)
568
+ // 複数選択時は共通プロパティを表示するか、最後の要素を表示するか。
569
+ // ここでは最後の要素(フォーカス要素)を使用
570
+ const primaryElement = selectedElements[selectedElements.length - 1];
571
+ const primaryId = newSelectedIds[newSelectedIds.length - 1];
572
+
573
+ // 選択セット全体から枠を作り直す。
574
+ // 従来は代表1要素分の枠しか作らなかったため、レイヤーパネルで複数選択しても
575
+ // 枠が1個しか出ず、そのままドラッグすると枠が取り残されていた。
576
+ // refreshSelectionOverlay は複数選択時に群バウンディングボックスも描く。
577
+ refreshSelectionOverlay(iframeDoc);
578
+
579
+ // プロパティパネル更新用のメッセージ送信
580
+ // extractElementInfoを使用して全プロパティ(isLink等含む)を取得
581
+ const elementInfo = extractElementInfo(primaryElement, iframeDoc);
582
+ if (elementInfo) {
583
+ window.postMessage({
584
+ type: 'ELEMENT_SELECTED',
585
+ element: elementInfo,
586
+ }, '*');
587
+ }
588
+ } else {
589
+ // 選択解除(枠は全て削除する。querySelector 単数だと1個しか消えない)
590
+ refreshSelectionOverlay(iframeDoc);
591
+ window.postMessage({ type: 'SELECTION_CLEARED' }, '*');
592
+ }
593
+ }, [getIframeDoc]);
594
+
595
+ // ノードクリックハンドラ
596
+ const handleNodeClick = useCallback((e: React.MouseEvent, nodeId: string) => {
597
+ e.stopPropagation(); // バブリング防止
598
+
599
+ let newSelectedIds: string[] = [];
600
+
601
+ if (e.metaKey || e.ctrlKey) {
602
+ // Cmd/Ctrl: トグル選択
603
+ if (selectedElementIds.includes(nodeId)) {
604
+ newSelectedIds = selectedElementIds.filter(id => id !== nodeId);
605
+ } else {
606
+ newSelectedIds = [...selectedElementIds, nodeId];
607
+ }
608
+ } else if (e.shiftKey && selectedElementIds.length > 0) {
609
+ // Shift: 範囲選択
610
+ const flatNodes = flattenDomTree(domTree);
611
+ const lastSelectedId = selectedElementIds[selectedElementIds.length - 1];
612
+
613
+ const startIdx = flatNodes.findIndex(n => n.id === lastSelectedId);
614
+ const endIdx = flatNodes.findIndex(n => n.id === nodeId);
615
+
616
+ if (startIdx !== -1 && endIdx !== -1) {
617
+ const minIdx = Math.min(startIdx, endIdx);
618
+ const maxIdx = Math.max(startIdx, endIdx);
619
+
620
+ // 既存の選択を維持しつつ、範囲を追加
621
+ // 単純な範囲選択なら既存をクリアして範囲だけにするのが一般的だが、
622
+ // ユーザー体験的には「最後」から「現在」までの範囲を選択状態にする。
623
+ // ここではFigma式に、Shiftクリックは「アンカーからクリック位置までを排他的に選択」ではなく、
624
+ // 「追加範囲選択」とするか、「純粋な範囲選択(他は解除)」とするか。
625
+ // 一般的にはShiftは「範囲選択」で、既存選択は解除されることが多い(エクスプローラー等)。
626
+ // ただし、Ctrl+Clickとの組み合わせもある。
627
+ // ここでは「範囲選択」として実装(既存選択はクリアせず統合する場合はSetを使う)
628
+
629
+ const rangeIds = flatNodes.slice(minIdx, maxIdx + 1).map(n => n.id);
630
+ // 今回はShiftクリックは「既存選択をリセットして範囲選択」ではなく「既存に追加」ではなく...
631
+ // 多くのアプリ: Shift+Clickは単一選択モードからの拡張。
632
+ // ここでは「前回の選択位置からここまで」を選択に追加する形にします。
633
+ // ただし、もしCmdキーが押されてなければリセット?
634
+ // 複雑さを避けるため、「前回の選択要素」と「今回の要素」の間の範囲を、現在の選択に追加する(Setで重複排除)
635
+
636
+ const currentSet = new Set(selectedElementIds);
637
+ rangeIds.forEach(id => currentSet.add(id));
638
+ newSelectedIds = Array.from(currentSet);
639
+ } else {
640
+ newSelectedIds = [nodeId];
641
+ }
642
+ } else {
643
+ // 修飾キーなし: 単一選択
644
+ newSelectedIds = [nodeId];
645
+ }
646
+
647
+ setSelectedElementIds(newSelectedIds);
648
+ // 単一要素の情報も更新(後方互換性のため)
649
+ if (newSelectedIds.length === 1) {
650
+ // updateSelection内で処理されるためここではIDのみセット
651
+ } else if (newSelectedIds.length === 0) {
652
+ setSelectedElement(null);
653
+ }
654
+
655
+ // 実際にiframe側を更新
656
+ updateSelection(newSelectedIds);
657
+
658
+ // primaryElementを設定(プロパティパネル用、最後の選択要素)
659
+ if (newSelectedIds.length > 0) {
660
+ const lastId = newSelectedIds[newSelectedIds.length - 1];
661
+ // setSelectedElement は updateSelection 内の postMessage で受け取った側で処理されるか、
662
+ // ここで明示的に呼ぶ必要があるか確認。
663
+ // useEditorHistoryフックなどが iframe からのメッセージを受け取って setSelectedElement しているなら任せる。
664
+ // しかし、EditorLayerPanel から直接操作しているので、ここで呼ぶのが確実。
665
+ // ただし、SelectedElementInfo を構築するのは大変なので、iframe 側のロジック(updateSelection)に任せるのが良い。
666
+ // updateSelection は postMessage を送るだけで、EditorContext の setSelectedElement を呼んでいない?
667
+ // EditorContext 側で message event listener があるはず。
668
+ }
669
+
670
+ }, [selectedElementIds, flattenDomTree, domTree, setSelectedElementIds, setSelectedElement, updateSelection]);
671
+
672
+
673
+
674
+ // ドラッグ開始
675
+ const handleDragStart = useCallback((e: React.DragEvent, nodeId: string) => {
676
+ e.dataTransfer.setData('text/plain', nodeId);
677
+ e.dataTransfer.effectAllowed = 'move';
678
+ setDraggedNodeId(nodeId);
679
+ }, []);
680
+
681
+ // ドラッグオーバー
682
+ const handleDragOver = useCallback((e: React.DragEvent, nodeId: string, rect: DOMRect) => {
683
+ e.preventDefault();
684
+ e.stopPropagation();
685
+
686
+ if (draggedNodeId === nodeId) return;
687
+
688
+ const y = e.clientY - rect.top;
689
+ const height = rect.height;
690
+
691
+ // 上1/4: before, 下1/4: after, 中央: inside
692
+ if (y < height * 0.25) {
693
+ setDragOverPosition('before');
694
+ } else if (y > height * 0.75) {
695
+ setDragOverPosition('after');
696
+ } else {
697
+ setDragOverPosition('inside');
698
+ }
699
+
700
+ setDragOverNodeId(nodeId);
701
+ }, [draggedNodeId]);
702
+
703
+ // ドラッグリーブ
704
+ const handleDragLeave = useCallback(() => {
705
+ setDragOverNodeId(null);
706
+ setDragOverPosition(null);
707
+ }, []);
708
+
709
+ // ドロップ
710
+ const handleDrop = useCallback((e: React.DragEvent, targetNodeId: string) => {
711
+ e.preventDefault();
712
+ e.stopPropagation();
713
+
714
+ const sourceNodeId = e.dataTransfer.getData('text/plain');
715
+ if (!sourceNodeId || sourceNodeId === targetNodeId) {
716
+ setDraggedNodeId(null);
717
+ setDragOverNodeId(null);
718
+ setDragOverPosition(null);
719
+ return;
720
+ }
721
+
722
+ const iframeDoc = getIframeDoc();
723
+ if (!iframeDoc) return;
724
+
725
+ const sourceEl = iframeDoc.querySelector(`[data-element-id="${sourceNodeId}"]`) as HTMLElement;
726
+ const targetEl = iframeDoc.querySelector(`[data-element-id="${targetNodeId}"]`) as HTMLElement;
727
+
728
+ if (!sourceEl || !targetEl) return;
729
+
730
+ // ドロップ位置に応じて要素を移動
731
+ switch (dragOverPosition) {
732
+ case 'before':
733
+ targetEl.parentElement?.insertBefore(sourceEl, targetEl);
734
+ break;
735
+ case 'after':
736
+ targetEl.parentElement?.insertBefore(sourceEl, targetEl.nextSibling);
737
+ break;
738
+ case 'inside':
739
+ targetEl.appendChild(sourceEl);
740
+ break;
741
+ }
742
+
743
+ // DOMツリーを再構築
744
+ const newTree = buildDomTree(iframeDoc);
745
+ setDomTree(newTree);
746
+
747
+ // 変更を通知(履歴に保存)
748
+ notifyIframeChange();
749
+
750
+ setDraggedNodeId(null);
751
+ setDragOverNodeId(null);
752
+ setDragOverPosition(null);
753
+ }, [dragOverPosition, getIframeDoc, setDomTree, notifyIframeChange]);
754
+
755
+ // ドラッグ終了
756
+ const handleDragEnd = useCallback(() => {
757
+ setDraggedNodeId(null);
758
+ setDragOverNodeId(null);
759
+ setDragOverPosition(null);
760
+ }, []);
761
+
762
+ // 要素の表示/非表示切り替え(旧API互換用)
763
+ const toggleVisibility = useCallback((nodeId: string) => {
764
+ const iframeDoc = getIframeDoc();
765
+ if (!iframeDoc) return;
766
+
767
+ const el = iframeDoc.querySelector(`[data-element-id="${nodeId}"]`) as HTMLElement;
768
+ if (!el) return;
769
+
770
+ if (el.style.visibility === 'hidden') {
771
+ el.style.visibility = '';
772
+ el.style.opacity = '';
773
+ } else {
774
+ el.style.visibility = 'hidden';
775
+ el.style.opacity = '0';
776
+ }
777
+ notifyIframeChange();
778
+ }, [getIframeDoc, notifyIframeChange]);
779
+
780
+ // 要素がコンテキストメニューで操作対象として選択されたときに実際に選択状態にする
781
+ const selectForContextMenu = useCallback((nodeId: string) => {
782
+ // コンテキストメニュー用は単一選択にする
783
+ const newSelectedIds = [nodeId];
784
+ setSelectedElementIds(newSelectedIds);
785
+ updateSelection(newSelectedIds);
786
+ }, [setSelectedElementIds, updateSelection]);
787
+
788
+ // useCallback化されたハンドラー(LayerTreeItem用)
789
+ const handleToggleExpand = useCallback((nodeId: string) => {
790
+ setExpandedNodes(prev => {
791
+ const next = new Set(prev);
792
+ if (next.has(nodeId)) {
793
+ next.delete(nodeId);
794
+ } else {
795
+ next.add(nodeId);
796
+ }
797
+ return next;
798
+ });
799
+ }, [setExpandedNodes]);
800
+
801
+ const handleToggleVisibility = useCallback((nodeId: string) => {
802
+ const iframeDoc = getIframeDoc();
803
+ if (!iframeDoc) return;
804
+
805
+ const el = iframeDoc.querySelector(`[data-element-id="${nodeId}"]`) as HTMLElement;
806
+ if (!el) return;
807
+
808
+ if (el.style.visibility === 'hidden') {
809
+ el.style.visibility = '';
810
+ el.style.opacity = '';
811
+ } else {
812
+ el.style.visibility = 'hidden';
813
+ el.style.opacity = '0';
814
+ }
815
+ notifyIframeChange();
816
+ }, [getIframeDoc, notifyIframeChange]);
817
+
818
+ // hiddenNodesの計算(メモ化)
819
+ const hiddenNodes = useMemo(() => {
820
+ const iframeDoc = getIframeDoc();
821
+ if (!iframeDoc) return new Set<string>();
822
+
823
+ const hidden = new Set<string>();
824
+ const checkHidden = (nodes: DOMTreeNode[]) => {
825
+ for (const node of nodes) {
826
+ const el = iframeDoc.querySelector(`[data-element-id="${node.id}"]`) as HTMLElement | null;
827
+ if (el?.style.visibility === 'hidden') {
828
+ hidden.add(node.id);
829
+ }
830
+ if (node.children.length > 0) {
831
+ checkHidden(node.children);
832
+ }
833
+ }
834
+ };
835
+ checkHidden(domTree);
836
+ return hidden;
837
+ }, [domTree, getIframeDoc]);
838
+
839
+ // ========================================
840
+ // ホバー連動(レイヤー ⇄ キャンバス)
841
+ // ========================================
842
+
843
+ const handleRowMouseEnter = useCallback((nodeId: string) => {
844
+ setRowHoverId(nodeId);
845
+ }, []);
846
+
847
+ const handleRowMouseLeave = useCallback(() => {
848
+ setRowHoverId(null);
849
+ }, []);
850
+
851
+ // レイヤー行 → キャンバス。掃除してから1個だけ付けるので取り残しが起きない
852
+ useEffect(() => {
853
+ const iframeDoc = getIframeDoc();
854
+ if (!iframeDoc) return;
855
+ applyLayerHover(iframeDoc, rowHoverId);
856
+ }, [rowHoverId, getIframeDoc]);
857
+
858
+ // スライドを切り替えたらホバー状態は無効なので捨てる
859
+ useEffect(() => {
860
+ setRowHoverId(null);
861
+ setCanvasHoverId(null);
862
+ }, [currentContentId]);
863
+
864
+ // アンマウント時にキャンバス側のハイライトを残さない
865
+ useEffect(() => {
866
+ return () => {
867
+ const iframeDoc = getIframeDoc();
868
+ if (iframeDoc) applyLayerHover(iframeDoc, null);
869
+ };
870
+ }, [getIframeDoc]);
871
+
872
+ // キャンバス → レイヤー行。
873
+ // キャンバス側は useIframeSetup が「クリックしたら選ばれる要素」に .hover-preview を
874
+ // 1個だけ付けている。イベント購読順に依存しないよう、その *結果* を MutationObserver で拾う。
875
+ // rAF は iframe 側の window で取るため、取り消しも同じ window で行う必要がある
876
+ // (親 window の cancelAnimationFrame に他 window の id を渡すと無関係な処理を止めうる)
877
+ const hoverObserverRef = useRef<{
878
+ doc: Document;
879
+ observer: MutationObserver;
880
+ win: Window;
881
+ } | null>(null);
882
+ const hoverRafRef = useRef<number | null>(null);
883
+
884
+ useEffect(() => {
885
+ const iframeDoc = getIframeDoc();
886
+ if (!iframeDoc) return;
887
+ // 同じドキュメントに対しては貼り直さない(domTree 更新のたびの付け外しを避ける)
888
+ if (hoverObserverRef.current?.doc === iframeDoc) return;
889
+
890
+ hoverObserverRef.current?.observer.disconnect();
891
+
892
+ const win = iframeDoc.defaultView ?? window;
893
+ const read = () => {
894
+ hoverRafRef.current = null;
895
+ const previewed = iframeDoc.querySelector('.hover-preview');
896
+ const id = previewed?.getAttribute('data-element-id') ?? null;
897
+ // 値が変わらないなら state を触らない。
898
+ // class 変化は選択やドラッグでも毎フレーム飛んでくるため、
899
+ // ここで止めないと全行の memo を無意味に破り続ける。
900
+ setCanvasHoverId((prev) => (prev === id ? prev : id));
901
+ };
902
+
903
+ const observer = new MutationObserver(() => {
904
+ if (hoverRafRef.current !== null) return;
905
+ hoverRafRef.current = win.requestAnimationFrame(read);
906
+ });
907
+
908
+ observer.observe(iframeDoc.body, {
909
+ attributes: true,
910
+ attributeFilter: ['class'],
911
+ subtree: true,
912
+ });
913
+ hoverObserverRef.current = { doc: iframeDoc, observer, win };
914
+ read();
915
+ }, [getIframeDoc, currentContentId, domTree]);
916
+
917
+ // アンマウント時のみ購読解除
918
+ useEffect(() => {
919
+ return () => {
920
+ const current = hoverObserverRef.current;
921
+ current?.observer.disconnect();
922
+ if (hoverRafRef.current !== null) {
923
+ current?.win.cancelAnimationFrame(hoverRafRef.current);
924
+ hoverRafRef.current = null;
925
+ }
926
+ hoverObserverRef.current = null;
927
+ };
928
+ }, []);
929
+
930
+ // 行のハイライト対象。レイヤー行のホバーを優先する
931
+ const effectiveHoverId = rowHoverId ?? canvasHoverId;
932
+ const hoverKey = effectiveHoverId ?? '';
933
+
934
+ // hasStyleInClipboardの値(メモ化)
935
+ const styleInClipboard = useMemo(() => hasStyleInClipboard(), [hasStyleInClipboard]);
936
+
937
+ // 選択状態のキー(子要素の再レンダリングをトリガーするため)
938
+ const selectionKey = useMemo(() => selectedElementIds.join(','), [selectedElementIds]);
939
+
940
+ // 展開状態のキー(子要素の再レンダリングをトリガーするため)
941
+ const expandedKey = useMemo(() => Array.from(expandedNodes).sort().join(','), [expandedNodes]);
942
+
943
+ // DOMツリーの変更を検知するキー(新要素の再レンダリングをトリガーするため)
944
+ const domTreeKey = useMemo(() => {
945
+ const collectIds = (nodes: DOMTreeNode[]): string => {
946
+ return nodes.map(n => `${n.id}:${n.children.length}${collectIds(n.children)}`).join('|');
947
+ };
948
+ return collectIds(domTree);
949
+ }, [domTree]);
950
+
951
+ // DOMツリーノードをレンダリング(LayerTreeItemを使用)
952
+ const renderTreeNode = useCallback((node: DOMTreeNode, depth: number = 0) => {
953
+ const isExpanded = expandedNodes.has(node.id);
954
+ // [選択の一致] 元は `selectedElementIds ? A : B` だったが、配列は空でも truthy なので
955
+ // B(selectedElement によるフォールバック)が永久に死んでいた。
956
+ // かといって単純な OR にすると、キャンバスで別要素を選び直した直後に
957
+ // 古い selectedElement が残っていて2行が同時に青くなる(実測)。
958
+ // ids がある間は ids を正とし、ids が空のときだけ selectedElement を見る。
959
+ const isSelected =
960
+ selectedElementIds.length > 0
961
+ ? selectedElementIds.includes(node.id)
962
+ : selectedElement?.id === node.id;
963
+ const isDragging = draggedNodeId === node.id;
964
+ const isDragOver = dragOverNodeId === node.id;
965
+ const isHidden = hiddenNodes.has(node.id);
966
+ const label = labelMap.get(node.id) ?? fallbackLabel(node);
967
+
968
+ return (
969
+ <LayerTreeItem
970
+ key={node.id || `node-${depth}-${node.tagName}`}
971
+ node={node}
972
+ depth={depth}
973
+ isExpanded={isExpanded}
974
+ isSelected={isSelected}
975
+ isDragging={isDragging}
976
+ isDragOver={isDragOver}
977
+ dragOverPosition={isDragOver ? dragOverPosition : null}
978
+ isHidden={isHidden}
979
+ hasStyleInClipboard={styleInClipboard}
980
+ label={label.name}
981
+ kind={label.kind}
982
+ isHovered={effectiveHoverId === node.id}
983
+ selectionKey={selectionKey}
984
+ expandedKey={expandedKey}
985
+ hoverKey={hoverKey}
986
+ labelKey={labelKey}
987
+ onToggleExpand={handleToggleExpand}
988
+ onToggleVisibility={handleToggleVisibility}
989
+ onNodeClick={handleNodeClick}
990
+ onRowMouseEnter={handleRowMouseEnter}
991
+ onRowMouseLeave={handleRowMouseLeave}
992
+ onDragStart={handleDragStart}
993
+ onDragOver={handleDragOver}
994
+ onDragLeave={handleDragLeave}
995
+ onDrop={handleDrop}
996
+ onDragEnd={handleDragEnd}
997
+ onSelectForContextMenu={selectForContextMenu}
998
+ onDuplicate={duplicateElement}
999
+ onCopyStyle={copyStyle}
1000
+ onPasteStyle={pasteStyle}
1001
+ onBringToFront={bringToFront}
1002
+ onBringForward={bringForward}
1003
+ onSendBackward={sendBackward}
1004
+ onSendToBack={sendToBack}
1005
+ onDelete={deleteElement}
1006
+ nodeRef={(ref) => {
1007
+ if (ref) nodeRefs.current.set(node.id, ref);
1008
+ else nodeRefs.current.delete(node.id);
1009
+ }}
1010
+ renderChildren={() => (
1011
+ <>
1012
+ {node.children.map((child) => renderTreeNode(child, depth + 1))}
1013
+ </>
1014
+ )}
1015
+ />
1016
+ );
1017
+ }, [
1018
+ expandedNodes,
1019
+ selectedElementIds,
1020
+ selectedElement?.id,
1021
+ draggedNodeId,
1022
+ dragOverNodeId,
1023
+ dragOverPosition,
1024
+ hiddenNodes,
1025
+ styleInClipboard,
1026
+ labelMap,
1027
+ labelKey,
1028
+ effectiveHoverId,
1029
+ hoverKey,
1030
+ selectionKey,
1031
+ expandedKey,
1032
+ handleToggleExpand,
1033
+ handleToggleVisibility,
1034
+ handleNodeClick,
1035
+ handleRowMouseEnter,
1036
+ handleRowMouseLeave,
1037
+ handleDragStart,
1038
+ handleDragOver,
1039
+ handleDragLeave,
1040
+ handleDrop,
1041
+ handleDragEnd,
1042
+ selectForContextMenu,
1043
+ duplicateElement,
1044
+ copyStyle,
1045
+ pasteStyle,
1046
+ bringToFront,
1047
+ bringForward,
1048
+ sendBackward,
1049
+ sendToBack,
1050
+ deleteElement,
1051
+ ]);
1052
+
1053
+ // スライドクリック時のハンドラ
1054
+ const handleContentClick = useCallback((slideId: string) => {
1055
+ if (onContentChange && slideId !== currentContentId) {
1056
+ onContentChange(slideId);
1057
+ }
1058
+ }, [onContentChange, currentContentId]);
1059
+
1060
+ // スライドツリーの展開/折りたたみ
1061
+ const toggleSlideTreeExpanded = useCallback((slideId: string) => {
1062
+ setSlideTreeExpanded(prev => {
1063
+ const next = new Map(prev);
1064
+ const current = next.get(slideId) ?? true; // デフォルトは展開
1065
+ next.set(slideId, !current);
1066
+ return next;
1067
+ });
1068
+ }, []);
1069
+
1070
+ // スライドのDOMツリーを取得
1071
+ const getSlideTree = useCallback((slideId: string): DOMTreeNode[] => {
1072
+ if (slideId === currentContentId) {
1073
+ // 現在のスライドはdomTreeを使用
1074
+ return domTree;
1075
+ }
1076
+ // 他のスライドはツリーを表示しない(切り替え後に表示される)
1077
+ return [];
1078
+ }, [currentContentId, domTree]);
1079
+
1080
+ return (
1081
+ <div
1082
+ className="bg-[#2c2c2c] border-r border-[#444444] flex flex-col relative flex-shrink-0"
1083
+ // 2段構成(ページ+レイヤー)に埋め込まれているときは幅を親(LeftPanel)が持つ。
1084
+ // ここで固定幅を持つと上下の段で幅が食い違い、リサイズも二重になる
1085
+ style={hideSlideList ? { width: '100%' } : { width: `${width}px` }}
1086
+ // 行の onMouseLeave だけだと、行から一気にパネル外へ抜けたときに
1087
+ // ハイライトが残ることがあるので、パネル自体でも確実に消す
1088
+ onMouseLeave={handleRowMouseLeave}
1089
+ >
1090
+ {/* リサイズハンドル(埋め込み時は親のハンドルに任せる) */}
1091
+ {!hideSlideList && <div {...resizeHandleProps} />}
1092
+
1093
+ {/* ドラッグ中のオーバーレイ(スムーズな操作のため) */}
1094
+ {isDragging && (
1095
+ <div className="fixed inset-0 z-50 cursor-col-resize" />
1096
+ )}
1097
+
1098
+ {/* 検索バー */}
1099
+ <div className="p-2 border-b border-[#444444]">
1100
+ <div className="relative">
1101
+ <Search className="w-3.5 h-3.5 absolute left-2 top-1/2 -translate-y-1/2 text-gray-500" />
1102
+ <Input
1103
+ value={searchQuery}
1104
+ onChange={(e) => setSearchQuery(e.target.value)}
1105
+ placeholder="要素を検索..."
1106
+ className="h-7 text-xs pl-7 bg-[#383838] border-[#444444] text-white placeholder:text-gray-500"
1107
+ />
1108
+ </div>
1109
+ </div>
1110
+
1111
+ <ScrollArea className="flex-1">
1112
+ <div className="p-1">
1113
+ {/* 全スライド/ページをツリー構造で表示 */}
1114
+ {!hideSlideList && slides.length > 0 ? (
1115
+ slides.map((slide, index) => {
1116
+ const isActive = slide.id === currentContentId;
1117
+ const isTreeExpanded = slideTreeExpanded.get(slide.id) ?? isActive;
1118
+ const slideTree = searchQuery ?
1119
+ // 検索時は該当スライドのフィルタ済みツリーを表示
1120
+ (isActive ? filteredDomTree : []) :
1121
+ getSlideTree(slide.id);
1122
+
1123
+ return (
1124
+ <div key={slide.id} className="mb-1">
1125
+ {/* スライドヘッダー */}
1126
+ <div
1127
+ className={`flex items-center gap-1 py-1 px-1 rounded cursor-pointer text-xs transition-colors ${
1128
+ isActive
1129
+ ? 'bg-[#0d99ff]/20 text-[#4fb8ff] border-l-2 border-[#0d99ff]'
1130
+ : 'text-gray-300 hover:bg-[#444444] border-l-2 border-transparent'
1131
+ }`}
1132
+ onClick={() => handleContentClick(slide.id)}
1133
+ >
1134
+ {/* 展開/折りたたみボタン */}
1135
+ <button
1136
+ onClick={(e) => {
1137
+ e.stopPropagation();
1138
+ toggleSlideTreeExpanded(slide.id);
1139
+ }}
1140
+ className={`p-0.5 rounded ${isActive ? 'hover:bg-[#0d99ff]/30' : 'hover:bg-gray-600'}`}
1141
+ >
1142
+ {isTreeExpanded ? (
1143
+ <ChevronDown className="w-3 h-3" />
1144
+ ) : (
1145
+ <ChevronRight className="w-3 h-3" />
1146
+ )}
1147
+ </button>
1148
+
1149
+ <Presentation className={`w-3.5 h-3.5 ${isActive ? 'text-[#4fb8ff]' : 'text-gray-500'}`} />
1150
+
1151
+ <span className="font-medium truncate flex-1">
1152
+ {index + 1}. {slide.title || '無題'}
1153
+ </span>
1154
+
1155
+ {isActive && (
1156
+ <span className="text-[10px] px-1 py-0.5 bg-[#0d99ff]/30 rounded text-[#7cc4ff] flex-shrink-0">
1157
+ 編集中
1158
+ </span>
1159
+ )}
1160
+ </div>
1161
+
1162
+ {/* スライドのDOMツリー */}
1163
+ {isTreeExpanded && (
1164
+ <div className="ml-2 mt-0.5">
1165
+ {slideTree.length > 0 ? (
1166
+ slideTree.map((node) => renderTreeNode(node, 1))
1167
+ ) : (
1168
+ <div className="text-[10px] text-gray-500 py-1 pl-4">
1169
+ {searchQuery ? '検索結果なし' : 'コンテンツなし'}
1170
+ </div>
1171
+ )}
1172
+ </div>
1173
+ )}
1174
+ </div>
1175
+ );
1176
+ })
1177
+ ) : (
1178
+ // スライドがない場合は従来のレイヤー表示
1179
+ <>
1180
+ <div className="flex items-center gap-2 text-xs text-gray-400 font-medium p-2">
1181
+ <Layers className="w-3.5 h-3.5" />
1182
+ レイヤー
1183
+ </div>
1184
+ {filteredDomTree.length > 0 ? (
1185
+ filteredDomTree.map((node) => renderTreeNode(node))
1186
+ ) : (
1187
+ <div className="text-xs text-gray-500 text-center py-4">
1188
+ {searchQuery ? '検索結果がありません' : 'レイヤーがありません'}
1189
+ </div>
1190
+ )}
1191
+ </>
1192
+ )}
1193
+ </div>
1194
+ </ScrollArea>
1195
+ </div>
1196
+ );
1197
+ }