@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,2996 @@
1
+ "use client";
2
+
3
+ /**
4
+ * エディタプロパティパネル
5
+ *
6
+ * Phase 2: パフォーマンス最適化
7
+ * - React.memoでラップして不要な再レンダリングを防止
8
+ */
9
+
10
+ import {
11
+ useState,
12
+ useMemo,
13
+ useCallback,
14
+ useEffect,
15
+ useRef,
16
+ useContext,
17
+ createContext,
18
+ memo,
19
+ type ReactNode,
20
+ type MutableRefObject,
21
+ } from "react";
22
+ import { useEditorComponents } from "../EditorContext";
23
+ import { InstanceOverrideSection } from "./property-panel/InstanceOverrideSection";
24
+ import { Button } from "../../components/ui/button";
25
+ import { Input } from "../../components/ui/input";
26
+ import { Label } from "../../components/ui/label";
27
+ import { Slider } from "../../components/ui/slider";
28
+ import { ScrollArea } from "../../components/ui/scroll-area";
29
+ import {
30
+ Select,
31
+ SelectContent,
32
+ SelectItem,
33
+ SelectTrigger,
34
+ SelectValue,
35
+ } from "../../components/ui/select";
36
+ import {
37
+ Collapsible,
38
+ CollapsibleContent,
39
+ CollapsibleTrigger,
40
+ } from "../../components/ui/collapsible";
41
+ import {
42
+ MousePointer2,
43
+ ChevronRight,
44
+ Square,
45
+ Type,
46
+ Palette,
47
+ Circle,
48
+ Trash2,
49
+ Copy,
50
+ AlignLeft,
51
+ AlignCenter,
52
+ AlignRight,
53
+ Bold,
54
+ Italic,
55
+ Underline,
56
+ Sparkles,
57
+ RotateCw,
58
+ FlipHorizontal,
59
+ FlipVertical,
60
+ Link2,
61
+ Link2Off,
62
+ Move,
63
+ LayoutGrid,
64
+ } from "lucide-react";
65
+ import { Checkbox } from "../../components/ui/checkbox";
66
+ import { buildFilterString, buildTransformString } from "../utils/style-utils";
67
+ import { useEditorContext } from "../EditorContext";
68
+ import { useElementActions, useEditorColors } from "../hooks";
69
+ import { FONT_WEIGHTS } from "../constants";
70
+ import type { PanelSections, SelectedElementInfo } from "../types";
71
+ import {
72
+ FigmaColorPicker,
73
+ FillConfig,
74
+ parseCssToFillConfig,
75
+ fillConfigToCss,
76
+ extractOpacity,
77
+ } from "./FigmaColorPicker";
78
+ import { ScalePanel } from "./property-panel/ScalePanel";
79
+ import { AlignmentPanel } from "./property-panel/AlignmentPanel";
80
+ import { AutoLayoutPanel } from "./property-panel/AutoLayoutPanel";
81
+ import { CompactNumberInput } from "./property-panel/CompactNumberInput";
82
+ import { CompactSizeInput, SizeMode } from "./property-panel/CompactSizeInput";
83
+ import { VariableAwareSizeInput } from "./property-panel/VariableAwareSizeInput";
84
+ import { LinkSection } from "./property-panel/LinkSection";
85
+ import { ImgSrcSection } from "./property-panel/ImgSrcSection";
86
+ import { GoogleFontPicker } from "./property-panel/GoogleFontPicker";
87
+ import { VariableAwareInput } from "./property-panel/VariableAwareInput";
88
+ import { VariableAwareUnitInput, FONT_SIZE_UNITS, SPACING_UNITS, BORDER_RADIUS_UNITS, LINE_HEIGHT_UNITS, LETTER_SPACING_UNITS, BORDER_WIDTH_UNITS, POSITION_UNITS, type UnitConversionContext } from "./property-panel";
89
+ import { VariableAwareColorInput } from "./property-panel/VariableAwareColorInput";
90
+ import { isVariableReference } from "../../types/css-variables";
91
+ import { cn } from "../../lib/utils";
92
+ import { ScrubbableLabel } from "../../components/ui/scrubbable-label";
93
+ import { useResizablePanel } from "../hooks/useResizablePanel";
94
+ import {
95
+ getIframeElement,
96
+ getOverlayRect,
97
+ refreshSelectionOverlay,
98
+ } from "../utils/dom-utils";
99
+ import {
100
+ isOutOfFlowPosition,
101
+ roundPx,
102
+ pxValue,
103
+ MIN_ELEMENT_SIZE,
104
+ } from "../utils/geometry";
105
+ import { convertInlineStylesToTailwind } from "../utils/tailwind-utils";
106
+ import {
107
+ isAspectRatioLocked,
108
+ setAspectRatioLocked,
109
+ useAspectRatioLock,
110
+ } from "../utils/aspect-lock";
111
+
112
+ // RGB を HEX に変換
113
+ const rgbToHex = (rgb: string): string => {
114
+ if (!rgb || rgb === "transparent" || rgb === "none") return "#ffffff";
115
+ if (rgb.startsWith("#")) return rgb.slice(0, 7);
116
+ const match = rgb.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
117
+ if (!match) return "#ffffff";
118
+ const r = parseInt(match[1]).toString(16).padStart(2, "0");
119
+ const g = parseInt(match[2]).toString(16).padStart(2, "0");
120
+ const b = parseInt(match[3]).toString(16).padStart(2, "0");
121
+ return `#${r}${g}${b}`;
122
+ };
123
+
124
+ // デフォルトのプリセットカラー
125
+ const DEFAULT_PRESETS = [
126
+ "#000000",
127
+ "#333333",
128
+ "#666666",
129
+ "#999999",
130
+ "#cccccc",
131
+ "#ffffff",
132
+ "#ff0000",
133
+ "#ff6600",
134
+ "#ffcc00",
135
+ "#00ff00",
136
+ "#00ccff",
137
+ "#0066ff",
138
+ "#6600ff",
139
+ "#ff00ff",
140
+ "#ff0066",
141
+ ];
142
+
143
+ // テキスト関連のタグ名(テキストセクションを表示すべきタグ)
144
+ const TEXT_RELATED_TAGS = new Set([
145
+ "SPAN",
146
+ "P",
147
+ "H1",
148
+ "H2",
149
+ "H3",
150
+ "H4",
151
+ "H5",
152
+ "H6",
153
+ "LABEL",
154
+ "A",
155
+ "BUTTON",
156
+ "SMALL",
157
+ "STRONG",
158
+ "EM",
159
+ "I",
160
+ "B",
161
+ "U",
162
+ "LI",
163
+ "BLOCKQUOTE",
164
+ "CITE",
165
+ "CODE",
166
+ "PRE",
167
+ "TIME",
168
+ "MARK",
169
+ "ABBR",
170
+ "TD",
171
+ "TH",
172
+ "CAPTION",
173
+ "FIGCAPTION",
174
+ "LEGEND",
175
+ "DT",
176
+ "DD",
177
+ ]);
178
+
179
+ /**
180
+ * テキストセクションを表示すべきかどうかを判定
181
+ * - テキスト関連のタグである
182
+ * - または直接テキストコンテンツを持っている
183
+ */
184
+ const shouldShowTypographySection = (element: SelectedElementInfo): boolean => {
185
+ // テキスト関連のタグなら表示
186
+ if (TEXT_RELATED_TAGS.has(element.tagName.toUpperCase())) {
187
+ return true;
188
+ }
189
+ // 直接テキストコンテンツを持っている場合も表示
190
+ if (element.text && element.text.trim().length > 0) {
191
+ return true;
192
+ }
193
+ return false;
194
+ };
195
+
196
+ // ============================================================
197
+ // ライブ計測(群対応 + ドラッグ/リサイズ中のリアルタイム追従)
198
+ // ============================================================
199
+
200
+ /**
201
+ * [なぜパネル側で実測するのか]
202
+ * ドラッグ・リサイズ中、useDragResize は iframe 内の要素の style を直接書き換えるだけで、
203
+ * React 側の selectedElement を更新する ELEMENT_SELECTED は mouseup まで飛んでこない。
204
+ * そのため selectedElement の値をそのまま出すと、操作中は数値が固まって見える。
205
+ * ここでは iframe の DOM を MutationObserver で監視し、変化のあったフレームだけ実測して
206
+ * パネルへ流す。「表示値 = 画面上の実物」を常に一致させるのが目的。
207
+ *
208
+ * [座標系]
209
+ * 群のバウンディングボックスは getOverlayRect(選択枠の描画に使うのと同じ関数)で求める。
210
+ * 選択枠と同じ式で出しているので、パネルの X/Y/W/H と画面の群枠は定義上ズレない。
211
+ * 単一要素の X/Y は extractElementInfo と同じく computed の left/top を使う
212
+ * (回転している要素では描画矩形と style.left が食い違うため、書き戻せる値のほうを出す)。
213
+ */
214
+ interface LiveMemberGeometry {
215
+ id: string;
216
+ /** 群バウンディングボックス算出用: アートボード座標の描画矩形(スケール前 CSS px) */
217
+ x: number;
218
+ y: number;
219
+ w: number;
220
+ h: number;
221
+ /** 単一表示用: computed の left/top(auto 等で数値にならない場合は null) */
222
+ styleLeft: number | null;
223
+ styleTop: number | null;
224
+ /** left/top を書いて動かせるか(absolute / fixed のみ true) */
225
+ canMove: boolean;
226
+ }
227
+
228
+ interface LiveGeometry {
229
+ members: LiveMemberGeometry[];
230
+ /** 選択全体のバウンディングボックス(アートボード座標) */
231
+ bbox: { x: number; y: number; w: number; h: number } | null;
232
+ /** メンバー間で値が異なる項目(Mixed 表示用) */
233
+ mixed: { x: boolean; y: boolean; w: boolean; h: boolean };
234
+ /** left/top を書き換えられるメンバーが 1 つ以上あるか */
235
+ canMove: boolean;
236
+ }
237
+
238
+ /** 小数の揺れで無駄な再レンダリングが起きないよう、計測値は小数2桁で丸める */
239
+ const round2 = (v: number): number => Math.round(v * 100) / 100;
240
+
241
+ /** 選択中の要素をまとめて実測する */
242
+ function measureSelection(doc: Document, ids: string[]): LiveGeometry | null {
243
+ const members: LiveMemberGeometry[] = [];
244
+
245
+ for (const id of ids) {
246
+ const el = getIframeElement(doc, id);
247
+ if (!el || !el.isConnected) continue;
248
+
249
+ const rect = getOverlayRect(doc, el);
250
+ const cs = doc.defaultView?.getComputedStyle(el);
251
+ const rawLeft = cs ? parseFloat(cs.left) : NaN;
252
+ const rawTop = cs ? parseFloat(cs.top) : NaN;
253
+
254
+ members.push({
255
+ id,
256
+ x: round2(rect.left),
257
+ y: round2(rect.top),
258
+ w: round2(rect.width),
259
+ h: round2(rect.height),
260
+ styleLeft: Number.isFinite(rawLeft) ? round2(rawLeft) : null,
261
+ styleTop: Number.isFinite(rawTop) ? round2(rawTop) : null,
262
+ canMove: isOutOfFlowPosition(cs?.position),
263
+ });
264
+ }
265
+
266
+ if (members.length === 0) return null;
267
+
268
+ const left = Math.min(...members.map((m) => m.x));
269
+ const top = Math.min(...members.map((m) => m.y));
270
+ const right = Math.max(...members.map((m) => m.x + m.w));
271
+ const bottom = Math.max(...members.map((m) => m.y + m.h));
272
+
273
+ // 1px 未満の差は「同じ」とみなす。丸め比較だと 0.5px の差で Mixed が点灯し、
274
+ // 見た目が同じなのに警告が出てノイズになるため。
275
+ const allSame = (pick: (m: LiveMemberGeometry) => number): boolean =>
276
+ members.every((m) => Math.abs(pick(m) - pick(members[0])) < 1);
277
+
278
+ return {
279
+ members,
280
+ bbox: {
281
+ x: round2(left),
282
+ y: round2(top),
283
+ w: round2(right - left),
284
+ h: round2(bottom - top),
285
+ },
286
+ mixed: {
287
+ x: !allSame((m) => m.styleLeft ?? m.x),
288
+ y: !allSame((m) => m.styleTop ?? m.y),
289
+ w: !allSame((m) => m.w),
290
+ h: !allSame((m) => m.h),
291
+ },
292
+ canMove: members.some((m) => m.canMove),
293
+ };
294
+ }
295
+
296
+ /** 実測結果が実質同じか(同じなら state を更新せず再レンダリングを避ける) */
297
+ function sameGeometry(a: LiveGeometry | null, b: LiveGeometry | null): boolean {
298
+ if (a === b) return true;
299
+ if (!a || !b) return false;
300
+ if (a.members.length !== b.members.length) return false;
301
+ for (let i = 0; i < a.members.length; i++) {
302
+ const m = a.members[i];
303
+ const n = b.members[i];
304
+ if (
305
+ m.id !== n.id ||
306
+ m.x !== n.x ||
307
+ m.y !== n.y ||
308
+ m.w !== n.w ||
309
+ m.h !== n.h ||
310
+ m.styleLeft !== n.styleLeft ||
311
+ m.styleTop !== n.styleTop ||
312
+ m.canMove !== n.canMove
313
+ ) {
314
+ return false;
315
+ }
316
+ }
317
+ return true;
318
+ }
319
+
320
+ const LiveGeometryContext = createContext<LiveGeometry | null>(null);
321
+
322
+ /**
323
+ * ライブ計測をパネル配下に配信する Provider
324
+ *
325
+ * [なぜ Provider にするか]
326
+ * ドラッグ中は毎フレーム値が変わる。パネル本体(2000行超・カラーピッカー込み)を
327
+ * 毎フレーム再レンダリングするとドラッグ自体が重くなり、直そうとしている操作感を逆に壊す。
328
+ * children を props としてそのまま流すことで、state 更新時に React が子ツリーを
329
+ * バイパスし、実際に値を使うコンシューマ(数値入力と群セクション)だけが更新される。
330
+ */
331
+ function LiveGeometryProvider({
332
+ ids,
333
+ iframeRef,
334
+ iframeReady,
335
+ children,
336
+ }: {
337
+ ids: string[];
338
+ iframeRef: MutableRefObject<HTMLIFrameElement | null>;
339
+ iframeReady: boolean;
340
+ children: ReactNode;
341
+ }) {
342
+ const [geometry, setGeometry] = useState<LiveGeometry | null>(null);
343
+ // 依存配列を安定させるため id 配列は文字列化して比較する
344
+ const idsKey = ids.join(",");
345
+
346
+ useEffect(() => {
347
+ const doc = iframeRef.current?.contentDocument ?? null;
348
+ const targetIds = idsKey ? idsKey.split(",") : [];
349
+ if (!doc || targetIds.length === 0) {
350
+ setGeometry(null);
351
+ return;
352
+ }
353
+
354
+ let rafId = 0;
355
+ const measure = () => {
356
+ rafId = 0;
357
+ const next = measureSelection(doc, targetIds);
358
+ setGeometry((prev) => (sameGeometry(prev, next) ? prev : next));
359
+ };
360
+ // 1フレームに1回へ間引く(ドラッグ中は複数の style 変更が同一フレームに届くため)
361
+ const schedule = () => {
362
+ if (rafId) return;
363
+ rafId = requestAnimationFrame(measure);
364
+ };
365
+
366
+ measure();
367
+
368
+ // ドラッグ/リサイズは style を、確定時の Tailwind 変換は class を書き換える。
369
+ // 親のリフローで矩形が変わる場合もあるので artboard 全体を subtree で監視する。
370
+ const target = doc.getElementById("artboard") || doc.body;
371
+ const observer = new MutationObserver(schedule);
372
+ observer.observe(target, {
373
+ attributes: true,
374
+ attributeFilter: ["style", "class"],
375
+ subtree: true,
376
+ childList: true,
377
+ });
378
+
379
+ // 画像の遅延読み込みなどでレイアウトが動くこともあるので、リサイズも拾う
380
+ const win = doc.defaultView;
381
+ win?.addEventListener("resize", schedule);
382
+
383
+ return () => {
384
+ observer.disconnect();
385
+ win?.removeEventListener("resize", schedule);
386
+ if (rafId) cancelAnimationFrame(rafId);
387
+ };
388
+ }, [idsKey, iframeRef, iframeReady]);
389
+
390
+ return (
391
+ <LiveGeometryContext.Provider value={geometry}>
392
+ {children}
393
+ </LiveGeometryContext.Provider>
394
+ );
395
+ }
396
+
397
+ /**
398
+ * ライブ計測値を「使う場所だけ」で購読するための描画プロップコンポーネント
399
+ * これで囲んだ範囲だけが毎フレーム再レンダリングされる
400
+ */
401
+ function LiveGeom({
402
+ children,
403
+ }: {
404
+ children: (geometry: LiveGeometry | null) => ReactNode;
405
+ }) {
406
+ const geometry = useContext(LiveGeometryContext);
407
+ return <>{children(geometry)}</>;
408
+ }
409
+
410
+ /** 単一の px 数値("120px" / "-8px")か判定する */
411
+ const isPlainPxValue = (raw: string | number | undefined | null): boolean => {
412
+ if (raw === undefined || raw === null || raw === "") return true;
413
+ if (typeof raw === "number") return true;
414
+ return /^-?\d+(\.\d+)?px$/.test(raw.trim());
415
+ };
416
+
417
+ /**
418
+ * 表示値の決定
419
+ * raw が var(--x) / % / vw など「px 以外の指定」なら、実測 px で上書きせず raw をそのまま返す。
420
+ * これをしないと変数参照や % 指定が数値に潰れてバインドが静かに壊れる。
421
+ */
422
+ function liveOrRaw(
423
+ raw: string | undefined,
424
+ live: number | null | undefined,
425
+ fallback: number,
426
+ ): string {
427
+ if (!isPlainPxValue(raw)) return raw as string;
428
+ if (live !== null && live !== undefined) return `${Math.round(live)}px`;
429
+ return raw && raw !== "" ? raw : `${Math.round(fallback)}px`;
430
+ }
431
+
432
+ /** 群操作の対象要素(入れ子の子は親に含まれるので除外する) */
433
+ function resolveGroupTargets(doc: Document, ids: string[]): HTMLElement[] {
434
+ const els = ids
435
+ .map((id) => getIframeElement(doc, id))
436
+ .filter((el): el is HTMLElement => !!el && el.isConnected);
437
+ return els.filter((el) => !els.some((other) => other !== el && other.contains(el)));
438
+ }
439
+
440
+ /**
441
+ * 群をまとめて平行移動する
442
+ *
443
+ * [なぜ updateElementStyle を使わないか]
444
+ * updateElementStyle は「同じスタイルを全選択要素へ配る」ので、
445
+ * left: 100px を配ると全要素が同じ x に重なってしまう。
446
+ * 群の X を動かす=各要素を同じ量だけずらす、なので差分で書く。
447
+ * 書き込み後の Tailwind 変換 → 通知 → 枠再構築の順序は alignElements と揃える。
448
+ */
449
+ function translateGroup(doc: Document, ids: string[], dx: number, dy: number): boolean {
450
+ const targets = resolveGroupTargets(doc, ids);
451
+ let changed = false;
452
+
453
+ targets.forEach((el) => {
454
+ const cs = doc.defaultView?.getComputedStyle(el);
455
+ if (!isOutOfFlowPosition(cs?.position)) return; // static/relative は left を書いても意味が違う
456
+ if (dx !== 0) {
457
+ el.style.left = pxValue((parseFloat(cs?.left ?? "") || 0) + dx);
458
+ }
459
+ if (dy !== 0) {
460
+ el.style.top = pxValue((parseFloat(cs?.top ?? "") || 0) + dy);
461
+ }
462
+ convertInlineStylesToTailwind(el, ["left", "top"]);
463
+ changed = true;
464
+ });
465
+
466
+ return changed;
467
+ }
468
+
469
+ /**
470
+ * 群バウンディングボックスを左上原点で相似拡大縮小する
471
+ *
472
+ * sx / sy は各軸の倍率。1 の軸は「触らない」(W だけ入力したときに H を巻き込まない)。
473
+ * 縦横比ロック時は呼び出し側が sx = sy を渡すので、ここでは軸ごとの一般形だけを持つ。
474
+ *
475
+ * [丸め方] 位置と幅を別々に丸めると対辺が 1px ずれるので、左右(上下)の端をそれぞれ
476
+ * 丸めてから引き算で幅を出す。computeResizeGeometry(item2 側の丸め規則)と同じ考え方。
477
+ */
478
+ function scaleGroup(
479
+ doc: Document,
480
+ ids: string[],
481
+ bbox: { x: number; y: number; w: number; h: number },
482
+ sx: number,
483
+ sy: number,
484
+ ): boolean {
485
+ if (!Number.isFinite(sx) || !Number.isFinite(sy) || sx <= 0 || sy <= 0) return false;
486
+ if (sx === 1 && sy === 1) return false;
487
+
488
+ const targets = resolveGroupTargets(doc, ids);
489
+ let changed = false;
490
+
491
+ targets.forEach((el) => {
492
+ const cs = doc.defaultView?.getComputedStyle(el);
493
+ const rect = getOverlayRect(doc, el);
494
+ const outOfFlow = isOutOfFlowPosition(cs?.position);
495
+
496
+ if (sx !== 1) {
497
+ const newLeft = roundPx(bbox.x + (rect.left - bbox.x) * sx);
498
+ const newRight = roundPx(bbox.x + (rect.left + rect.width - bbox.x) * sx);
499
+ const newWidth = Math.max(MIN_ELEMENT_SIZE, newRight - newLeft);
500
+ if (outOfFlow) {
501
+ el.style.left = pxValue((parseFloat(cs?.left ?? "") || 0) + (newLeft - rect.left));
502
+ }
503
+ el.style.width = `${newWidth}px`;
504
+ }
505
+
506
+ if (sy !== 1) {
507
+ const newTop = roundPx(bbox.y + (rect.top - bbox.y) * sy);
508
+ const newBottom = roundPx(bbox.y + (rect.top + rect.height - bbox.y) * sy);
509
+ const newHeight = Math.max(MIN_ELEMENT_SIZE, newBottom - newTop);
510
+ if (outOfFlow) {
511
+ el.style.top = pxValue((parseFloat(cs?.top ?? "") || 0) + (newTop - rect.top));
512
+ }
513
+ el.style.height = `${newHeight}px`;
514
+ }
515
+
516
+ convertInlineStylesToTailwind(el, ["left", "top", "width", "height"]);
517
+ changed = true;
518
+ });
519
+
520
+ return changed;
521
+ }
522
+
523
+ /**
524
+ * 縦横比ロックのトグル
525
+ *
526
+ * [なぜ「Shift 相当」と説明するか]
527
+ * ロック中のリサイズは useDragResize 側で Shift 押下と同じ経路(computeResizeGeometry の
528
+ * shiftKey)を通る。UI 上も別物の機能に見せず、「Shift を押し続けている状態」と伝える。
529
+ * 状態はモジュールスコープのストア(utils/aspect-lock.ts)に持つので、
530
+ * 選択を切り替えてもロックは保たれる(Figma と同じ)。
531
+ */
532
+ function AspectRatioLockToggle({ compact = false }: { compact?: boolean }) {
533
+ const locked = useAspectRatioLock();
534
+ return (
535
+ <button
536
+ type="button"
537
+ role="switch"
538
+ aria-checked={locked}
539
+ aria-label="縦横比を固定"
540
+ title={
541
+ locked
542
+ ? "縦横比を固定中:ハンドルのドラッグも W / H の入力も比率を保ちます(クリックで解除)"
543
+ : "縦横比を固定する:ハンドルのドラッグと W / H の入力が Shift を押したときと同じ比率維持になります"
544
+ }
545
+ onClick={() => setAspectRatioLocked(!locked)}
546
+ className={cn(
547
+ "shrink-0 flex items-center justify-center rounded border transition-colors",
548
+ compact ? "h-6 w-6" : "h-7 w-6",
549
+ locked
550
+ ? "text-[#4fb8ff] border-[#0d99ff]/60 bg-[#0d99ff]/15"
551
+ : "text-gray-500 border-[#444444] hover:text-white hover:bg-[#383838]",
552
+ )}
553
+ >
554
+ {locked ? (
555
+ <Link2 className="w-3 h-3" />
556
+ ) : (
557
+ <Link2Off className="w-3 h-3" />
558
+ )}
559
+ </button>
560
+ );
561
+ }
562
+
563
+ /**
564
+ * 群セクション専用の数値入力
565
+ *
566
+ * [なぜ CompactNumberInput を使わないか]
567
+ * CompactNumberInput は1文字打つたびに onChange を発火する。
568
+ * 群の W に「864」と打つと 8 → 86 → 864 の順に適用され、
569
+ * 途中の「8」で群が最小サイズまで潰れてから戻るため、要素同士の比率が壊れる。
570
+ * ここでは Enter / フォーカスアウト / 矢印キーでのみ確定する(Figma と同じ挙動)。
571
+ * 表示値はドラッグ中も追従するが、編集中(フォーカス中)だけは打った文字を優先する。
572
+ */
573
+ function GroupNumberInput({
574
+ value,
575
+ onCommit,
576
+ min,
577
+ disabled,
578
+ mixed,
579
+ title,
580
+ }: {
581
+ value: number;
582
+ onCommit: (value: number) => void;
583
+ min?: number;
584
+ disabled?: boolean;
585
+ /** メンバー間で値が異なる項目(枠色で示す) */
586
+ mixed?: boolean;
587
+ title?: string;
588
+ }) {
589
+ const [text, setText] = useState(String(value));
590
+ const [focused, setFocused] = useState(false);
591
+ // Enter で確定した直後に blur が走るため、二重に適用しないための番人
592
+ const dirtyRef = useRef(false);
593
+
594
+ useEffect(() => {
595
+ if (!focused) setText(String(value));
596
+ }, [value, focused]);
597
+
598
+ const commit = (raw: string) => {
599
+ if (!dirtyRef.current) return;
600
+ dirtyRef.current = false;
601
+ const parsed = parseFloat(raw);
602
+ if (Number.isNaN(parsed)) {
603
+ setText(String(value));
604
+ return;
605
+ }
606
+ const clamped = min !== undefined ? Math.max(min, parsed) : parsed;
607
+ setText(String(clamped));
608
+ if (Math.round(clamped) !== Math.round(value)) onCommit(clamped);
609
+ };
610
+
611
+ return (
612
+ <input
613
+ type="text"
614
+ inputMode="numeric"
615
+ // size=1 にしないと input の固有幅(約20文字分)がパネルの内容幅を押し広げる
616
+ size={1}
617
+ title={title}
618
+ value={text}
619
+ disabled={disabled}
620
+ onChange={(e) => {
621
+ dirtyRef.current = true;
622
+ setText(e.target.value);
623
+ }}
624
+ onFocus={(e) => {
625
+ setFocused(true);
626
+ e.currentTarget.select();
627
+ }}
628
+ onBlur={(e) => {
629
+ setFocused(false);
630
+ commit(e.currentTarget.value);
631
+ }}
632
+ onKeyDown={(e) => {
633
+ if (e.key === "Enter") {
634
+ commit(e.currentTarget.value);
635
+ e.currentTarget.blur();
636
+ } else if (e.key === "Escape") {
637
+ dirtyRef.current = false;
638
+ setText(String(value));
639
+ e.currentTarget.blur();
640
+ } else if (e.key === "ArrowUp" || e.key === "ArrowDown") {
641
+ // 矢印は1回の操作で確定値が決まるので即時反映してよい
642
+ e.preventDefault();
643
+ const step = (e.shiftKey ? 10 : 1) * (e.key === "ArrowUp" ? 1 : -1);
644
+ dirtyRef.current = true;
645
+ commit(String((parseFloat(e.currentTarget.value) || 0) + step));
646
+ }
647
+ // Delete / Backspace などがキャンバス側の削除ショートカットに拾われないようにする。
648
+ // ただし Cmd/Ctrl 併用(Undo・Redo・保存など)は入力欄にいても効かせたいので通す。
649
+ if (!e.metaKey && !e.ctrlKey) e.stopPropagation();
650
+ }}
651
+ className={cn(
652
+ "w-[68px] shrink-0 h-6 bg-[#383838] border rounded",
653
+ "text-white text-[10px] text-center px-1 outline-none focus:ring-1 focus:ring-[#0d99ff]",
654
+ mixed ? "border-amber-500/60" : "border-[#444444]",
655
+ disabled && "opacity-50 cursor-not-allowed",
656
+ )}
657
+ />
658
+ );
659
+ }
660
+
661
+ /**
662
+ * 複数選択時の「群」セクション
663
+ *
664
+ * X/Y/W/H は選択範囲全体のバウンディングボックス(選択枠と同じ式で算出)。
665
+ * 値はドラッグ・リサイズ中もリアルタイムに追従する。
666
+ * メンバー間で値が異なる項目は枠を琥珀色にし、下に「Mixed: X / W」と明示する。
667
+ *
668
+ * [幅について] パネル本体はコンテンツ幅(max-content)でレイアウトされるため、
669
+ * ここで input の幅を固定しておかないと行が右へはみ出して読めなくなる。
670
+ */
671
+ function GroupGeometrySection({
672
+ count,
673
+ tagSummary,
674
+ onTranslate,
675
+ onScale,
676
+ }: {
677
+ count: number;
678
+ tagSummary: string;
679
+ onTranslate: (dx: number, dy: number) => void;
680
+ onScale: (axis: "w" | "h", target: number) => void;
681
+ }) {
682
+ return (
683
+ <div className="mb-3 w-fit rounded border border-[#444444] bg-[#2c2c2c] p-2">
684
+ <div className="flex items-center gap-2 mb-1">
685
+ <span className="text-[11px] font-medium text-gray-200 whitespace-nowrap">
686
+ {count}個を選択中
687
+ </span>
688
+ {tagSummary && (
689
+ <span className="text-[10px] text-gray-500 font-mono truncate max-w-[90px]">
690
+ {tagSummary}
691
+ </span>
692
+ )}
693
+ </div>
694
+ <p
695
+ className="text-[10px] text-gray-500 mb-2 whitespace-nowrap"
696
+ title={`このパネルのプロパティを変更すると、選択中の${count}個すべてに同じ値が適用されます`}
697
+ >
698
+ 編集は{count}個すべてに適用
699
+ </p>
700
+
701
+ <LiveGeom>
702
+ {(geometry) => {
703
+ const bbox = geometry?.bbox;
704
+ if (!bbox) {
705
+ return (
706
+ <p className="text-[10px] text-gray-500 whitespace-nowrap">
707
+ 選択範囲を計測中…
708
+ </p>
709
+ );
710
+ }
711
+ const mixed = geometry?.mixed;
712
+ const canMove = !!geometry?.canMove;
713
+ const moveHint = canMove
714
+ ? "選択範囲全体の位置。変更すると全要素が同じ量だけ移動します"
715
+ : "選択要素が絶対配置ではないため、X / Y を直接指定できません";
716
+ const sizeHint =
717
+ "選択範囲全体のサイズ。変更すると範囲の左上を基準に相似で拡大縮小します(縦横比ロック中は W / H が連動します)";
718
+ const mixedLabels = [
719
+ mixed?.x ? "X" : null,
720
+ mixed?.y ? "Y" : null,
721
+ mixed?.w ? "W" : null,
722
+ mixed?.h ? "H" : null,
723
+ ].filter(Boolean) as string[];
724
+
725
+ return (
726
+ <div className="space-y-1.5">
727
+ <div className="flex items-center gap-2">
728
+ <div className="flex items-center gap-1">
729
+ <span className="text-[10px] text-gray-500 w-3 text-center shrink-0">
730
+ X
731
+ </span>
732
+ <GroupNumberInput
733
+ value={Math.round(bbox.x)}
734
+ onCommit={(val) => onTranslate(val - bbox.x, 0)}
735
+ disabled={!canMove}
736
+ mixed={!!mixed?.x}
737
+ title={moveHint}
738
+ />
739
+ </div>
740
+ <div className="flex items-center gap-1">
741
+ <span className="text-[10px] text-gray-500 w-3 text-center shrink-0">
742
+ Y
743
+ </span>
744
+ <GroupNumberInput
745
+ value={Math.round(bbox.y)}
746
+ onCommit={(val) => onTranslate(0, val - bbox.y)}
747
+ disabled={!canMove}
748
+ mixed={!!mixed?.y}
749
+ title={moveHint}
750
+ />
751
+ </div>
752
+ </div>
753
+ <div className="flex items-center gap-2">
754
+ <div className="flex items-center gap-1">
755
+ <span className="text-[10px] text-gray-500 w-3 text-center shrink-0">
756
+ W
757
+ </span>
758
+ <GroupNumberInput
759
+ value={Math.round(bbox.w)}
760
+ onCommit={(val) => onScale("w", val)}
761
+ min={MIN_ELEMENT_SIZE}
762
+ mixed={!!mixed?.w}
763
+ title={sizeHint}
764
+ />
765
+ </div>
766
+ <div className="flex items-center gap-1">
767
+ <span className="text-[10px] text-gray-500 w-3 text-center shrink-0">
768
+ H
769
+ </span>
770
+ <GroupNumberInput
771
+ value={Math.round(bbox.h)}
772
+ onCommit={(val) => onScale("h", val)}
773
+ min={MIN_ELEMENT_SIZE}
774
+ mixed={!!mixed?.h}
775
+ title={sizeHint}
776
+ />
777
+ </div>
778
+ {/* 縦横比ロック(群のサイズ行にも置く。ロック中は W / H が連動する) */}
779
+ <AspectRatioLockToggle compact />
780
+ </div>
781
+ {mixedLabels.length > 0 && (
782
+ <p
783
+ className="text-[10px] text-amber-400/90 whitespace-nowrap"
784
+ title="選択中の要素で値が異なる項目です。入力欄の数値は選択範囲全体(バウンディングボックス)のものです"
785
+ >
786
+ Mixed: {mixedLabels.join(" / ")}
787
+ </p>
788
+ )}
789
+ </div>
790
+ );
791
+ }}
792
+ </LiveGeom>
793
+ </div>
794
+ );
795
+ }
796
+
797
+ /**
798
+ * 右パネル: プロパティパネル(メモ化)
799
+ *
800
+ * Phase 2 最適化:
801
+ * - React.memoでラップして不要な再レンダリングを防止
802
+ * - selectedElementが変更されない限り再レンダリングしない
803
+ */
804
+ export const EditorPropertyPanel = memo(function EditorPropertyPanel() {
805
+ const {
806
+ selectedElement,
807
+ selectedElementIds,
808
+ activeTool,
809
+ restoreFocus,
810
+ iframeRef,
811
+ editorMode,
812
+ viewportWidth: contextViewportWidth,
813
+ getIframeDoc,
814
+ notifyIframeChange,
815
+ iframeReady,
816
+ } = useEditorContext();
817
+ const {
818
+ updateElementStyle,
819
+ updateLinkAttribute,
820
+ updateElementAttribute,
821
+ deleteElement,
822
+ duplicateElement,
823
+ alignElements,
824
+ } = useElementActions();
825
+ const { colors: editorColors } = useEditorColors();
826
+
827
+ // Component instance management
828
+ const {
829
+ getInstanceByDomId,
830
+ getMasterComponent,
831
+ changeVariant,
832
+ removeOverride,
833
+ resetAllOverrides,
834
+ detachInstance,
835
+ navigateToMasterComponent,
836
+ } = useEditorComponents();
837
+
838
+ // Check if selected element is a component instance
839
+ const selectedInstance = useMemo(() => {
840
+ if (!selectedElement?.id) return null;
841
+ return getInstanceByDomId(selectedElement.id);
842
+ }, [selectedElement?.id, getInstanceByDomId]);
843
+
844
+ const selectedMaster = useMemo(() => {
845
+ if (!selectedInstance) return null;
846
+ return getMasterComponent(selectedInstance.masterComponentId);
847
+ }, [selectedInstance, getMasterComponent]);
848
+
849
+ // Instance override section state
850
+ const [isInstanceSectionOpen, setIsInstanceSectionOpen] = useState(true);
851
+
852
+ // Handlers for instance override section
853
+ const handleVariantChange = useCallback((variantId: string) => {
854
+ if (selectedInstance) {
855
+ changeVariant(selectedInstance.id, variantId);
856
+ }
857
+ }, [selectedInstance, changeVariant]);
858
+
859
+ const handleResetOverride = useCallback((overrideId: string) => {
860
+ if (selectedInstance) {
861
+ removeOverride(selectedInstance.id, overrideId);
862
+ }
863
+ }, [selectedInstance, removeOverride]);
864
+
865
+ const handleResetAllOverrides = useCallback(() => {
866
+ if (selectedInstance) {
867
+ resetAllOverrides(selectedInstance.id);
868
+ }
869
+ }, [selectedInstance, resetAllOverrides]);
870
+
871
+ const handleGoToMainComponent = useCallback(() => {
872
+ if (selectedInstance?.masterComponentId) {
873
+ navigateToMasterComponent(selectedInstance.masterComponentId);
874
+ }
875
+ }, [selectedInstance, navigateToMasterComponent]);
876
+
877
+ const handleDetachInstance = useCallback(() => {
878
+ if (selectedInstance) {
879
+ detachInstance(selectedInstance.id);
880
+ }
881
+ }, [selectedInstance, detachInstance]);
882
+
883
+ // リサイズ可能なパネル
884
+ const { width, isDragging, resizeHandleProps } = useResizablePanel({
885
+ initialWidth: 288, // w-72 = 18rem = 288px
886
+ minWidth: 240,
887
+ maxWidth: 480,
888
+ direction: 'left', // 左端をドラッグしてリサイズ
889
+ storageKey: 'editor-property-panel-width',
890
+ });
891
+
892
+ const [openSections, setOpenSections] = useState<PanelSections>({
893
+ position: true, // 位置セクション
894
+ layout: true, // レイアウトセクション
895
+ appearance: true, // 外見セクション
896
+ image: false, // 塗りに統合されたため非表示
897
+ typography: true,
898
+ link: true, // リンクセクション
899
+ fill: true,
900
+ stroke: false,
901
+ effects: false,
902
+ });
903
+
904
+ // パディングのリンクモード(上下・左右をリンク or 個別)
905
+ const [paddingLinked, setPaddingLinked] = useState(true);
906
+ const [radiusLinked, setRadiusLinked] = useState(true);
907
+
908
+ // 選択要素が変わったときにリンク状態を初期化
909
+ useEffect(() => {
910
+ if (selectedElement) {
911
+ // 角丸
912
+ const {
913
+ borderRadiusTopLeft,
914
+ borderRadiusTopRight,
915
+ borderRadiusBottomRight,
916
+ borderRadiusBottomLeft,
917
+ } = selectedElement;
918
+ if (
919
+ borderRadiusTopLeft !== undefined &&
920
+ borderRadiusTopRight !== undefined &&
921
+ borderRadiusBottomRight !== undefined &&
922
+ borderRadiusBottomLeft !== undefined
923
+ ) {
924
+ setRadiusLinked(
925
+ borderRadiusTopLeft === borderRadiusTopRight &&
926
+ borderRadiusTopRight === borderRadiusBottomRight &&
927
+ borderRadiusBottomRight === borderRadiusBottomLeft,
928
+ );
929
+ }
930
+ }
931
+ }, [
932
+ selectedElement?.id,
933
+ selectedElement?.borderRadiusTopLeft,
934
+ selectedElement?.borderRadiusTopRight,
935
+ selectedElement?.borderRadiusBottomRight,
936
+ selectedElement?.borderRadiusBottomLeft,
937
+ ]);
938
+
939
+ // スライド内の色とデフォルトを組み合わせたプリセット
940
+ const colorPresets = useMemo(() => {
941
+ const combined = [...new Set([...editorColors, ...DEFAULT_PRESETS])];
942
+ return combined.slice(0, 24); // 最大24色
943
+ }, [editorColors]);
944
+
945
+ // 背景の塗り設定を取得
946
+ const backgroundFillConfig = useMemo((): FillConfig => {
947
+ if (!selectedElement) return { type: "none" };
948
+ return parseCssToFillConfig(
949
+ selectedElement.backgroundColor,
950
+ selectedElement.backgroundImage,
951
+ selectedElement.backgroundSize,
952
+ selectedElement.rawBackgroundColor,
953
+ );
954
+ }, [selectedElement]);
955
+
956
+ // 背景の塗り設定を更新
957
+ const handleBackgroundFillChange = useCallback(
958
+ (config: FillConfig) => {
959
+ const css = fillConfigToCss(config);
960
+ updateElementStyle(css);
961
+ },
962
+ [updateElementStyle],
963
+ );
964
+
965
+ // 親要素がFlexコンテナかどうか(flex または inline-flex)
966
+ const isParentFlexContainer = selectedElement
967
+ ? selectedElement.parentDisplay === "flex" ||
968
+ selectedElement.parentDisplay === "inline-flex"
969
+ : false;
970
+
971
+ // 単位変換コンテキスト(px ↔ vw/vh/em/rem/% 変換用)
972
+ const conversionContext = useMemo((): UnitConversionContext => {
973
+ const iframeDoc = iframeRef.current?.contentDocument;
974
+
975
+ // キャンバス/アートボードの設計寸法をビューポートサイズとして使用
976
+ // slideモード: 1920x1080
977
+ // webpageモード: viewportWidth(選択されたブレークポイント)x アートボードの高さ
978
+ const SLIDE_WIDTH = 1920;
979
+ const SLIDE_HEIGHT = 1080;
980
+
981
+ const artboard = iframeDoc?.getElementById('artboard');
982
+ const artboardHeight = artboard?.scrollHeight || SLIDE_HEIGHT;
983
+
984
+ // viewport単位の変換に使用するサイズ(ブラウザのビューポートではなくキャンバスサイズ)
985
+ const viewportWidth = editorMode === 'webpage'
986
+ ? (contextViewportWidth || SLIDE_WIDTH)
987
+ : SLIDE_WIDTH;
988
+ const viewportHeight = editorMode === 'webpage'
989
+ ? artboardHeight
990
+ : SLIDE_HEIGHT;
991
+
992
+ // 親要素のサイズ(%変換用)- artboardを基準に
993
+ const parentWidth = artboard?.clientWidth || viewportWidth;
994
+ const parentHeight = artboard?.clientHeight || viewportHeight;
995
+
996
+ // フォントサイズ
997
+ const fontSize = selectedElement?.fontSize || 16;
998
+
999
+ // ルートフォントサイズ(通常は16px)
1000
+ const rootFontSize = iframeDoc?.documentElement
1001
+ ? parseFloat(getComputedStyle(iframeDoc.documentElement).fontSize) || 16
1002
+ : 16;
1003
+
1004
+ return {
1005
+ viewportWidth,
1006
+ viewportHeight,
1007
+ parentWidth,
1008
+ parentHeight,
1009
+ fontSize,
1010
+ rootFontSize,
1011
+ };
1012
+ }, [iframeRef, selectedElement?.fontSize, editorMode, contextViewportWidth]);
1013
+
1014
+ // ライブ計測の対象 ID
1015
+ // 単一選択でも selectedElementIds が空になる経路があるため、selectedElement で補う
1016
+ const liveIds = useMemo(() => {
1017
+ if (selectedElementIds.length > 0) return selectedElementIds;
1018
+ return selectedElement ? [selectedElement.id] : [];
1019
+ }, [selectedElementIds, selectedElement]);
1020
+
1021
+ const isMultiSelection = selectedElementIds.length >= 2;
1022
+
1023
+ // 群の選択内容サマリ(何が選ばれているか分かるようにする)
1024
+ const groupTagSummary = useMemo(() => {
1025
+ if (!isMultiSelection) return "";
1026
+ const doc = iframeRef.current?.contentDocument;
1027
+ if (!doc) return "";
1028
+ const tags = selectedElementIds
1029
+ .map((id) => getIframeElement(doc, id)?.tagName.toLowerCase())
1030
+ .filter((t): t is string => !!t);
1031
+ const uniq = [...new Set(tags)];
1032
+ return uniq.length <= 2 ? uniq.join(" / ") : `${uniq.slice(0, 2).join(" / ")} ほか`;
1033
+ }, [isMultiSelection, selectedElementIds, iframeRef]);
1034
+
1035
+ /** 群の平行移動(X / Y 入力) */
1036
+ const handleGroupTranslate = useCallback(
1037
+ (dx: number, dy: number) => {
1038
+ const doc = getIframeDoc();
1039
+ if (!doc) return;
1040
+ const rdx = roundPx(dx);
1041
+ const rdy = roundPx(dy);
1042
+ if (rdx === 0 && rdy === 0) return;
1043
+ if (!translateGroup(doc, selectedElementIds, rdx, rdy)) return;
1044
+ notifyIframeChange();
1045
+ // Tailwind クラスへの丸め込みで実描画位置が微妙に変わるため、枠は作り直す
1046
+ requestAnimationFrame(() => refreshSelectionOverlay(doc));
1047
+ },
1048
+ [getIframeDoc, selectedElementIds, notifyIframeChange],
1049
+ );
1050
+
1051
+ /**
1052
+ * 群の相似拡大縮小(W / H 入力)
1053
+ *
1054
+ * 縦横比ロック中は入力した軸の倍率をもう一方の軸にも掛ける。
1055
+ * ロック状態は関数で読む(このコールバックの依存に入れて作り直すより、
1056
+ * 押した瞬間の値をそのまま使うほうが確実)。
1057
+ */
1058
+ const handleGroupScale = useCallback(
1059
+ (axis: "w" | "h", target: number) => {
1060
+ const doc = getIframeDoc();
1061
+ if (!doc) return;
1062
+ const current = measureSelection(doc, selectedElementIds);
1063
+ if (!current?.bbox) return;
1064
+ const base = axis === "w" ? current.bbox.w : current.bbox.h;
1065
+ if (base <= 0) return;
1066
+ if (Math.round(base) === Math.round(target)) return;
1067
+ const factor = Math.max(MIN_ELEMENT_SIZE, target) / base;
1068
+ const locked = isAspectRatioLocked();
1069
+ const sx = locked || axis === "w" ? factor : 1;
1070
+ const sy = locked || axis === "h" ? factor : 1;
1071
+ if (!scaleGroup(doc, selectedElementIds, current.bbox, sx, sy)) return;
1072
+ notifyIframeChange();
1073
+ requestAnimationFrame(() => refreshSelectionOverlay(doc));
1074
+ },
1075
+ [getIframeDoc, selectedElementIds, notifyIframeChange],
1076
+ );
1077
+
1078
+ /**
1079
+ * 単一要素のサイズ入力を適用する(縦横比ロック対応)
1080
+ *
1081
+ * [なぜ px 同士に限るか]
1082
+ * ロック中に W を px で打つと H を計算して書き戻すが、H が % / var() / auto(Hug・Fill)の
1083
+ * ときにそれを px へ潰すと、変数バインドやレイアウト追従が黙って壊れる。
1084
+ * 相手側が「素の px の固定値」のときだけ連動させ、それ以外は打った軸だけを変える。
1085
+ *
1086
+ * @param liveW / @param liveH ドラッグ直後でも実物と一致させるための実測値
1087
+ */
1088
+ const applySizeValue = useCallback(
1089
+ (
1090
+ axis: "width" | "height",
1091
+ val: string,
1092
+ live?: { w?: number; h?: number },
1093
+ ) => {
1094
+ const styles: Record<string, string> = {
1095
+ [axis]: val,
1096
+ flexGrow: "0",
1097
+ alignSelf: "auto",
1098
+ };
1099
+
1100
+ const el = selectedElement;
1101
+ const typed = parseFloat(val);
1102
+ const counterpartRaw = axis === "width" ? el?.rawHeight : el?.rawWidth;
1103
+ const counterpartAuto = axis === "width" ? el?.heightAuto : el?.widthAuto;
1104
+ const w = live?.w ?? el?.width ?? 0;
1105
+ const h = live?.h ?? el?.height ?? 0;
1106
+
1107
+ const canLink =
1108
+ !!el &&
1109
+ isAspectRatioLocked() &&
1110
+ isPlainPxValue(val) &&
1111
+ Number.isFinite(typed) &&
1112
+ w > 0 &&
1113
+ h > 0 &&
1114
+ !counterpartAuto &&
1115
+ isPlainPxValue(counterpartRaw);
1116
+
1117
+ if (canLink) {
1118
+ if (axis === "width") {
1119
+ styles.height = `${Math.max(MIN_ELEMENT_SIZE, roundPx((typed * h) / w))}px`;
1120
+ } else {
1121
+ styles.width = `${Math.max(MIN_ELEMENT_SIZE, roundPx((typed * w) / h))}px`;
1122
+ }
1123
+ }
1124
+
1125
+ updateElementStyle(styles);
1126
+ },
1127
+ [selectedElement, updateElementStyle],
1128
+ );
1129
+
1130
+ if (activeTool === "scale") {
1131
+ return <ScalePanel />;
1132
+ }
1133
+
1134
+ // selectedElement が無くても選択IDが残っている経路がある(マーキー直後など)。
1135
+ // ここで空パネルに落とすと「複数選択したのにパネルが空」になるので、群セクションだけは出す。
1136
+ if (!selectedElement) {
1137
+ return (
1138
+ <LiveGeometryProvider
1139
+ ids={liveIds}
1140
+ iframeRef={iframeRef}
1141
+ iframeReady={iframeReady}
1142
+ >
1143
+ <div
1144
+ className="flex-shrink-0 bg-[#2c2c2c] border-l border-[#444444] flex flex-col overflow-hidden relative"
1145
+ style={{ width: `${width}px` }}
1146
+ >
1147
+ {/* リサイズハンドル */}
1148
+ <div {...resizeHandleProps} />
1149
+
1150
+ {/* ドラッグ中のオーバーレイ */}
1151
+ {isDragging && (
1152
+ <div className="fixed inset-0 z-50 cursor-col-resize" />
1153
+ )}
1154
+
1155
+ {selectedElementIds.length > 0 ? (
1156
+ <ScrollArea className="flex-1">
1157
+ <div className="p-3">
1158
+ <GroupGeometrySection
1159
+ count={selectedElementIds.length}
1160
+ tagSummary={groupTagSummary}
1161
+ onTranslate={handleGroupTranslate}
1162
+ onScale={handleGroupScale}
1163
+ />
1164
+ </div>
1165
+ </ScrollArea>
1166
+ ) : (
1167
+ <div className="flex-1 flex items-center justify-center p-4">
1168
+ <div className="text-center text-gray-500 text-xs">
1169
+ <MousePointer2 className="w-8 h-8 mx-auto mb-2 opacity-50" />
1170
+ <p>要素を選択してください</p>
1171
+ </div>
1172
+ </div>
1173
+ )}
1174
+ </div>
1175
+ </LiveGeometryProvider>
1176
+ );
1177
+ }
1178
+
1179
+ return (
1180
+ <LiveGeometryProvider
1181
+ ids={liveIds}
1182
+ iframeRef={iframeRef}
1183
+ iframeReady={iframeReady}
1184
+ >
1185
+ <div
1186
+ data-property-panel
1187
+ className="flex-shrink-0 bg-[#2c2c2c] border-l border-[#444444] flex flex-col overflow-hidden relative"
1188
+ style={{ width: `${width}px` }}
1189
+ >
1190
+ {/* リサイズハンドル */}
1191
+ <div {...resizeHandleProps} />
1192
+
1193
+ {/* ドラッグ中のオーバーレイ */}
1194
+ {isDragging && (
1195
+ <div className="fixed inset-0 z-50 cursor-col-resize" />
1196
+ )}
1197
+ <ScrollArea className="flex-1">
1198
+ <div className="p-3">
1199
+ {/* ヘッダー */}
1200
+ <div className="flex items-center justify-between mb-3">
1201
+ <div className="flex items-center gap-2">
1202
+ <span className="text-xs text-gray-400 bg-[#383838] px-1.5 py-0.5 rounded font-mono">
1203
+ {selectedElement.tagName.toLowerCase()}
1204
+ </span>
1205
+ {isMultiSelection && (
1206
+ <span className="text-[10px] text-[#7cc4ff] bg-[#0d99ff]/15 px-1.5 py-0.5 rounded">
1207
+ {selectedElementIds.length}個選択
1208
+ </span>
1209
+ )}
1210
+ </div>
1211
+ <div className="flex items-center gap-1">
1212
+ <Button
1213
+ variant="ghost"
1214
+ size="icon"
1215
+ onClick={duplicateElement}
1216
+ className="h-6 w-6 text-gray-400 hover:text-white hover:bg-[#4a4a4a]"
1217
+ title="複製"
1218
+ >
1219
+ <Copy className="w-3.5 h-3.5" />
1220
+ </Button>
1221
+ <Button
1222
+ variant="ghost"
1223
+ size="icon"
1224
+ onClick={deleteElement}
1225
+ className="h-6 w-6 text-gray-400 hover:text-red-400 hover:bg-[#4a4a4a]"
1226
+ title="削除"
1227
+ >
1228
+ <Trash2 className="w-3.5 h-3.5" />
1229
+ </Button>
1230
+ </div>
1231
+ </div>
1232
+
1233
+ {/* 群セクション(複数選択時のみ) */}
1234
+ {isMultiSelection && (
1235
+ <GroupGeometrySection
1236
+ count={selectedElementIds.length}
1237
+ tagSummary={groupTagSummary}
1238
+ onTranslate={handleGroupTranslate}
1239
+ onScale={handleGroupScale}
1240
+ />
1241
+ )}
1242
+
1243
+ {/* コンポーネントインスタンスセクション */}
1244
+ {selectedInstance && selectedMaster && (
1245
+ <InstanceOverrideSection
1246
+ instance={selectedInstance}
1247
+ master={selectedMaster}
1248
+ open={isInstanceSectionOpen}
1249
+ onOpenChange={setIsInstanceSectionOpen}
1250
+ onResetOverride={handleResetOverride}
1251
+ onResetAll={handleResetAllOverrides}
1252
+ onVariantChange={handleVariantChange}
1253
+ onGoToMainComponent={handleGoToMainComponent}
1254
+ onDetachInstance={handleDetachInstance}
1255
+ />
1256
+ )}
1257
+
1258
+ {/* 位置セクション */}
1259
+ <Collapsible
1260
+ open={openSections.position}
1261
+ onOpenChange={(open) =>
1262
+ setOpenSections((prev) => ({ ...prev, position: open }))
1263
+ }
1264
+ >
1265
+ <CollapsibleTrigger className="flex items-center justify-between w-full py-2 text-xs font-medium text-gray-300 hover:text-white">
1266
+ <span className="flex items-center gap-2">
1267
+ <Move className="w-3.5 h-3.5" />
1268
+ 位置
1269
+ </span>
1270
+ <ChevronRight
1271
+ className={`w-3.5 h-3.5 transition-transform ${openSections.position ? "rotate-90" : ""}`}
1272
+ />
1273
+ </CollapsibleTrigger>
1274
+ <CollapsibleContent className="space-y-3 pb-3">
1275
+ {/* 配置 */}
1276
+ <AlignmentPanel
1277
+ onAlign={alignElements}
1278
+ canDistribute={selectedElementIds.length >= 3}
1279
+ />
1280
+
1281
+ {/* 位置 X/Y
1282
+ LiveGeom で包むことで、ここだけがドラッグ中のフレーム更新を受ける。
1283
+ raw が var()/% 等の場合は実測 px で上書きしない(liveOrRaw 参照)。 */}
1284
+ <div>
1285
+ <Label className="text-[10px] text-gray-500 mb-1 block">
1286
+ 位置
1287
+ </Label>
1288
+ <LiveGeom>
1289
+ {(geometry) => {
1290
+ const live = geometry?.members.find(
1291
+ (m) => m.id === selectedElement.id,
1292
+ );
1293
+ return (
1294
+ <div className="grid grid-cols-2 gap-2">
1295
+ <VariableAwareUnitInput
1296
+ value={liveOrRaw(
1297
+ selectedElement.rawLeft,
1298
+ live?.styleLeft,
1299
+ selectedElement.x,
1300
+ )}
1301
+ onChange={(val) => updateElementStyle({ left: val })}
1302
+ units={POSITION_UNITS}
1303
+ defaultUnit="px"
1304
+ category="spacing"
1305
+ label="X"
1306
+ compact
1307
+ hideVariableLink
1308
+ conversionContext={conversionContext}
1309
+ />
1310
+ <VariableAwareUnitInput
1311
+ value={liveOrRaw(
1312
+ selectedElement.rawTop,
1313
+ live?.styleTop,
1314
+ selectedElement.y,
1315
+ )}
1316
+ onChange={(val) => updateElementStyle({ top: val })}
1317
+ units={POSITION_UNITS}
1318
+ defaultUnit="px"
1319
+ category="spacing"
1320
+ label="Y"
1321
+ compact
1322
+ hideVariableLink
1323
+ conversionContext={conversionContext}
1324
+ />
1325
+ </div>
1326
+ );
1327
+ }}
1328
+ </LiveGeom>
1329
+ </div>
1330
+
1331
+ {/* 回転 + 反転 */}
1332
+ <div>
1333
+ <Label className="text-[10px] text-gray-500 mb-1 block">
1334
+ 回転
1335
+ </Label>
1336
+ <div className="flex items-center gap-2">
1337
+ <div className="flex items-center gap-1 flex-1 bg-[#383838] rounded px-2 h-7">
1338
+ <RotateCw className="w-3 h-3 text-gray-500" />
1339
+ <CompactNumberInput
1340
+ value={Math.round(selectedElement.rotation)}
1341
+ onChange={(val) => {
1342
+ updateElementStyle({
1343
+ transform: buildTransformString({
1344
+ rotation: val || 0,
1345
+ scaleX: selectedElement.scaleX,
1346
+ scaleY: selectedElement.scaleY,
1347
+ }),
1348
+ });
1349
+ }}
1350
+ className="h-5 w-12 border-0"
1351
+ />
1352
+ <span className="text-xs text-gray-500">°</span>
1353
+ </div>
1354
+ <div className="w-px h-5 bg-[#4a4a4a]" />
1355
+ <Button
1356
+ variant="ghost"
1357
+ size="icon"
1358
+ onClick={() => {
1359
+ const isFlippedX =
1360
+ Math.round(selectedElement.scaleX) === -1;
1361
+ updateElementStyle({
1362
+ transform: buildTransformString({
1363
+ rotation: selectedElement.rotation,
1364
+ scaleX: isFlippedX ? 1 : -1,
1365
+ scaleY: selectedElement.scaleY,
1366
+ }),
1367
+ });
1368
+ }}
1369
+ className={`h-7 w-7 ${Math.round(selectedElement.scaleX) === -1 ? "bg-[#0d99ff] text-white" : "text-gray-400 hover:bg-[#4a4a4a] hover:text-white"}`}
1370
+ title="水平方向に反転"
1371
+ >
1372
+ <FlipHorizontal className="w-3.5 h-3.5" />
1373
+ </Button>
1374
+ <Button
1375
+ variant="ghost"
1376
+ size="icon"
1377
+ onClick={() => {
1378
+ const isFlippedY =
1379
+ Math.round(selectedElement.scaleY) === -1;
1380
+ updateElementStyle({
1381
+ transform: buildTransformString({
1382
+ rotation: selectedElement.rotation,
1383
+ scaleX: selectedElement.scaleX,
1384
+ scaleY: isFlippedY ? 1 : -1,
1385
+ }),
1386
+ });
1387
+ }}
1388
+ className={`h-7 w-7 ${Math.round(selectedElement.scaleY) === -1 ? "bg-[#0d99ff] text-white" : "text-gray-400 hover:bg-[#4a4a4a] hover:text-white"}`}
1389
+ title="垂直方向に反転"
1390
+ >
1391
+ <FlipVertical className="w-3.5 h-3.5" />
1392
+ </Button>
1393
+ </div>
1394
+ </div>
1395
+ </CollapsibleContent>
1396
+ </Collapsible>
1397
+
1398
+ {/* レイアウトセクション */}
1399
+ <Collapsible
1400
+ open={openSections.layout}
1401
+ onOpenChange={(open) =>
1402
+ setOpenSections((prev) => ({ ...prev, layout: open }))
1403
+ }
1404
+ >
1405
+ <CollapsibleTrigger className="flex items-center justify-between w-full py-2 text-xs font-medium text-gray-300 hover:text-white border-t border-[#444444]">
1406
+ <span className="flex items-center gap-2">
1407
+ <LayoutGrid className="w-3.5 h-3.5" />
1408
+ レイアウト
1409
+ </span>
1410
+ <ChevronRight
1411
+ className={`w-3.5 h-3.5 transition-transform ${openSections.layout ? "rotate-90" : ""}`}
1412
+ />
1413
+ </CollapsibleTrigger>
1414
+ <CollapsibleContent className="space-y-3 pb-3">
1415
+ {/* オートレイアウトパネル */}
1416
+ <AutoLayoutPanel
1417
+ element={selectedElement}
1418
+ onStyleChange={updateElementStyle}
1419
+ />
1420
+
1421
+ {/* サイズ W/H */}
1422
+ <div>
1423
+ <Label className="text-[10px] text-gray-500 mb-1 block">
1424
+ サイズ
1425
+ </Label>
1426
+ {/* W / H + 縦横比ロック。ロックはハンドル操作(Shift 相当)と
1427
+ この入力欄の両方に効く */}
1428
+ <div className="flex items-center gap-2">
1429
+ <div className="grid grid-cols-2 gap-2 flex-1 min-w-0">
1430
+ {/* Width Input(リサイズ中の実測値に追従させるため LiveGeom で包む) */}
1431
+ <LiveGeom>
1432
+ {(geometry) => {
1433
+ const member = geometry?.members.find(
1434
+ (m) => m.id === selectedElement.id,
1435
+ );
1436
+ const live = selectedElement.widthAuto ? undefined : member?.w;
1437
+ return (
1438
+ <VariableAwareSizeInput
1439
+ value={liveOrRaw(
1440
+ selectedElement.rawWidth,
1441
+ live,
1442
+ selectedElement.width,
1443
+ )}
1444
+ mode={(() => {
1445
+ if (!selectedElement.widthAuto) return "fixed";
1446
+ const isParentRow =
1447
+ isParentFlexContainer &&
1448
+ (selectedElement.parentFlexDirection === "row" ||
1449
+ selectedElement.parentFlexDirection ===
1450
+ "row-reverse");
1451
+ const isParentColumn =
1452
+ isParentFlexContainer &&
1453
+ (selectedElement.parentFlexDirection === "column" ||
1454
+ selectedElement.parentFlexDirection ===
1455
+ "column-reverse");
1456
+
1457
+ if (isParentRow && selectedElement.flexGrow > 0)
1458
+ return "fill";
1459
+ if (
1460
+ isParentColumn &&
1461
+ selectedElement.alignSelf === "stretch"
1462
+ )
1463
+ return "fill";
1464
+ return "hug";
1465
+ })()}
1466
+ onChangeValue={(val) => {
1467
+ // 値はすでに単位付き (例: "100px", "50%", "var(--xxx)")
1468
+ // 縦横比ロック中は H も連動させる(条件は applySizeValue 参照)
1469
+ applySizeValue("width", val, { w: member?.w, h: member?.h });
1470
+ }}
1471
+ onChangeMode={(mode) => {
1472
+ const isParentRow =
1473
+ isParentFlexContainer &&
1474
+ (selectedElement.parentFlexDirection === "row" ||
1475
+ selectedElement.parentFlexDirection ===
1476
+ "row-reverse");
1477
+
1478
+ if (mode === "fixed") {
1479
+ updateElementStyle({
1480
+ width: `${Math.round(selectedElement.width)}px`,
1481
+ flexGrow: "0",
1482
+ alignSelf: "auto",
1483
+ flexBasis: "auto",
1484
+ });
1485
+ } else if (mode === "fill") {
1486
+ if (isParentRow) {
1487
+ updateElementStyle({
1488
+ width: "auto",
1489
+ flexGrow: "1",
1490
+ flexBasis: "0",
1491
+ alignSelf: "auto",
1492
+ });
1493
+ } else {
1494
+ updateElementStyle({
1495
+ width: "auto",
1496
+ alignSelf: "stretch",
1497
+ flexGrow: "0",
1498
+ });
1499
+ }
1500
+ } else if (mode === "hug") {
1501
+ updateElementStyle({
1502
+ width: "max-content",
1503
+ flexGrow: "0",
1504
+ alignSelf: "auto",
1505
+ });
1506
+ }
1507
+ }}
1508
+ label={
1509
+ <span className="text-[10px] text-gray-500 w-3 text-center block">
1510
+ W
1511
+ </span>
1512
+ }
1513
+ dimension="width"
1514
+ canFill={isParentFlexContainer}
1515
+ canHug={true}
1516
+ min={0}
1517
+ conversionContext={conversionContext}
1518
+ />
1519
+ );
1520
+ }}
1521
+ </LiveGeom>
1522
+
1523
+ {/* Height Input(同上) */}
1524
+ <LiveGeom>
1525
+ {(geometry) => {
1526
+ const member = geometry?.members.find(
1527
+ (m) => m.id === selectedElement.id,
1528
+ );
1529
+ const live = selectedElement.heightAuto ? undefined : member?.h;
1530
+ return (
1531
+ <VariableAwareSizeInput
1532
+ value={liveOrRaw(
1533
+ selectedElement.rawHeight,
1534
+ live,
1535
+ selectedElement.height,
1536
+ )}
1537
+ mode={(() => {
1538
+ if (!selectedElement.heightAuto) return "fixed";
1539
+ const isParentRow =
1540
+ isParentFlexContainer &&
1541
+ (selectedElement.parentFlexDirection === "row" ||
1542
+ selectedElement.parentFlexDirection ===
1543
+ "row-reverse");
1544
+ const isParentColumn =
1545
+ isParentFlexContainer &&
1546
+ (selectedElement.parentFlexDirection === "column" ||
1547
+ selectedElement.parentFlexDirection ===
1548
+ "column-reverse");
1549
+
1550
+ if (isParentColumn && selectedElement.flexGrow > 0)
1551
+ return "fill";
1552
+ if (
1553
+ isParentRow &&
1554
+ selectedElement.alignSelf === "stretch"
1555
+ )
1556
+ return "fill";
1557
+ return "hug";
1558
+ })()}
1559
+ onChangeValue={(val) => {
1560
+ // 値はすでに単位付き (例: "100px", "50vh", "var(--xxx)")
1561
+ // 縦横比ロック中は W も連動させる(条件は applySizeValue 参照)
1562
+ applySizeValue("height", val, { w: member?.w, h: member?.h });
1563
+ }}
1564
+ onChangeMode={(mode) => {
1565
+ const isParentRow =
1566
+ isParentFlexContainer &&
1567
+ (selectedElement.parentFlexDirection === "row" ||
1568
+ selectedElement.parentFlexDirection ===
1569
+ "row-reverse");
1570
+
1571
+ if (mode === "fixed") {
1572
+ updateElementStyle({
1573
+ height: `${Math.round(selectedElement.height)}px`,
1574
+ flexGrow: "0",
1575
+ alignSelf: "auto",
1576
+ flexBasis: "auto",
1577
+ });
1578
+ } else if (mode === "fill") {
1579
+ if (isParentRow) {
1580
+ updateElementStyle({
1581
+ height: "auto",
1582
+ alignSelf: "stretch",
1583
+ flexGrow: "0",
1584
+ });
1585
+ } else {
1586
+ // Parent Column
1587
+ updateElementStyle({
1588
+ height: "auto",
1589
+ flexGrow: "1",
1590
+ flexBasis: "0",
1591
+ alignSelf: "auto",
1592
+ });
1593
+ }
1594
+ } else if (mode === "hug") {
1595
+ updateElementStyle({
1596
+ height: "max-content",
1597
+ flexGrow: "0",
1598
+ alignSelf: "auto",
1599
+ });
1600
+ }
1601
+ }}
1602
+ label={
1603
+ <span className="text-[10px] text-gray-500 w-3 text-center block">
1604
+ H
1605
+ </span>
1606
+ }
1607
+ dimension="height"
1608
+ canFill={isParentFlexContainer}
1609
+ canHug={true}
1610
+ min={0}
1611
+ conversionContext={conversionContext}
1612
+ />
1613
+ );
1614
+ }}
1615
+ </LiveGeom>
1616
+ </div>
1617
+ </div>
1618
+ {/* [修復] 「サイズ」ブロックの閉じタグ。
1619
+ 縦横比ロックの追加作業が中断され、この1つが欠けていた */}
1620
+ </div>
1621
+
1622
+ {/* コンテンツのクリッピング */}
1623
+ <div className="flex items-center gap-2">
1624
+ <Checkbox
1625
+ id="content-clip"
1626
+ checked={selectedElement.overflow === "hidden"}
1627
+ onCheckedChange={(checked) =>
1628
+ updateElementStyle({
1629
+ overflow: checked ? "hidden" : "visible",
1630
+ })
1631
+ }
1632
+ className="h-4 w-4"
1633
+ />
1634
+ <Label
1635
+ htmlFor="content-clip"
1636
+ className="text-[10px] text-gray-400 cursor-pointer"
1637
+ >
1638
+ コンテンツを隠す
1639
+ </Label>
1640
+ </div>
1641
+
1642
+ {/* パディング + マージン(十字レイアウト) */}
1643
+ <div className="flex gap-4">
1644
+ {/* パディング */}
1645
+ <div className="flex-1">
1646
+ <div className="flex items-center justify-between mb-1">
1647
+ <Label className="text-[9px] text-gray-600">
1648
+ パディング
1649
+ </Label>
1650
+ <Button
1651
+ variant="ghost"
1652
+ size="icon"
1653
+ onClick={() => setPaddingLinked(!paddingLinked)}
1654
+ className="h-3 w-3 hover:bg-[#4a4a4a]"
1655
+ title={paddingLinked ? "個別設定" : "リンク"}
1656
+ >
1657
+ {paddingLinked ? (
1658
+ <Link2 className="w-2 h-2 text-[#4fb8ff]" />
1659
+ ) : (
1660
+ <Link2Off className="w-2 h-2 text-gray-500" />
1661
+ )}
1662
+ </Button>
1663
+ </div>
1664
+ {/* 十字レイアウト */}
1665
+ <div className="flex flex-col items-center gap-0.5">
1666
+ {/* 上 */}
1667
+ <VariableAwareUnitInput
1668
+ value={selectedElement.rawPaddingTop || `${Math.round(selectedElement.paddingTop)}px`}
1669
+ onChange={(val) => {
1670
+ if (paddingLinked) {
1671
+ updateElementStyle({
1672
+ paddingTop: val,
1673
+ paddingBottom: val,
1674
+ });
1675
+ } else {
1676
+ updateElementStyle({ paddingTop: val });
1677
+ }
1678
+ }}
1679
+ category="spacing"
1680
+ min={0}
1681
+ compact
1682
+ hideVariableLink
1683
+ conversionContext={conversionContext}
1684
+ />
1685
+ {/* 左・中央・右 */}
1686
+ <div className="flex items-center gap-0.5">
1687
+ <VariableAwareUnitInput
1688
+ value={selectedElement.rawPaddingLeft || `${Math.round(selectedElement.paddingLeft)}px`}
1689
+ onChange={(val) => {
1690
+ if (paddingLinked) {
1691
+ updateElementStyle({
1692
+ paddingLeft: val,
1693
+ paddingRight: val,
1694
+ });
1695
+ } else {
1696
+ updateElementStyle({ paddingLeft: val });
1697
+ }
1698
+ }}
1699
+ category="spacing"
1700
+ min={0}
1701
+ compact
1702
+ hideVariableLink
1703
+ conversionContext={conversionContext}
1704
+ />
1705
+ <div className="w-5 h-5 bg-[#2a2a2a] rounded border border-[#444444]" />
1706
+ <VariableAwareUnitInput
1707
+ value={selectedElement.rawPaddingRight || `${Math.round(selectedElement.paddingRight)}px`}
1708
+ onChange={(val) => {
1709
+ if (paddingLinked) {
1710
+ updateElementStyle({
1711
+ paddingLeft: val,
1712
+ paddingRight: val,
1713
+ });
1714
+ } else {
1715
+ updateElementStyle({ paddingRight: val });
1716
+ }
1717
+ }}
1718
+ category="spacing"
1719
+ min={0}
1720
+ compact
1721
+ hideVariableLink
1722
+ conversionContext={conversionContext}
1723
+ />
1724
+ </div>
1725
+ {/* 下 */}
1726
+ <VariableAwareUnitInput
1727
+ value={selectedElement.rawPaddingBottom || `${Math.round(selectedElement.paddingBottom)}px`}
1728
+ onChange={(val) => {
1729
+ if (paddingLinked) {
1730
+ updateElementStyle({
1731
+ paddingTop: val,
1732
+ paddingBottom: val,
1733
+ });
1734
+ } else {
1735
+ updateElementStyle({ paddingBottom: val });
1736
+ }
1737
+ }}
1738
+ category="spacing"
1739
+ min={0}
1740
+ compact
1741
+ hideVariableLink
1742
+ conversionContext={conversionContext}
1743
+ />
1744
+ </div>
1745
+ </div>
1746
+
1747
+ {/* マージン */}
1748
+ <div className="flex-1">
1749
+ <Label className="text-[9px] text-gray-600 mb-1 block">
1750
+ マージン
1751
+ </Label>
1752
+ {/* 十字レイアウト */}
1753
+ <div className="flex flex-col items-center gap-0.5">
1754
+ {/* 上 */}
1755
+ <VariableAwareUnitInput
1756
+ value={selectedElement.rawMarginTop || `${Math.round(selectedElement.marginTop)}px`}
1757
+ onChange={(val) => updateElementStyle({ marginTop: val })}
1758
+ category="spacing"
1759
+ compact
1760
+ hideVariableLink
1761
+ conversionContext={conversionContext}
1762
+ />
1763
+ {/* 左・中央・右 */}
1764
+ <div className="flex items-center gap-0.5">
1765
+ <VariableAwareUnitInput
1766
+ value={selectedElement.rawMarginLeft || `${Math.round(selectedElement.marginLeft)}px`}
1767
+ onChange={(val) => updateElementStyle({ marginLeft: val })}
1768
+ category="spacing"
1769
+ compact
1770
+ hideVariableLink
1771
+ conversionContext={conversionContext}
1772
+ />
1773
+ <div className="w-5 h-5 bg-[#2a2a2a] rounded border border-[#444444]" />
1774
+ <VariableAwareUnitInput
1775
+ value={selectedElement.rawMarginRight || `${Math.round(selectedElement.marginRight)}px`}
1776
+ onChange={(val) => updateElementStyle({ marginRight: val })}
1777
+ category="spacing"
1778
+ compact
1779
+ hideVariableLink
1780
+ conversionContext={conversionContext}
1781
+ />
1782
+ </div>
1783
+ {/* 下 */}
1784
+ <VariableAwareUnitInput
1785
+ value={selectedElement.rawMarginBottom || `${Math.round(selectedElement.marginBottom)}px`}
1786
+ onChange={(val) => updateElementStyle({ marginBottom: val })}
1787
+ category="spacing"
1788
+ compact
1789
+ hideVariableLink
1790
+ conversionContext={conversionContext}
1791
+ />
1792
+ </div>
1793
+ </div>
1794
+ </div>
1795
+ </CollapsibleContent>
1796
+ </Collapsible>
1797
+
1798
+ {/* 外見セクション */}
1799
+ <Collapsible
1800
+ open={openSections.appearance}
1801
+ onOpenChange={(open) =>
1802
+ setOpenSections((prev) => ({ ...prev, appearance: open }))
1803
+ }
1804
+ >
1805
+ <CollapsibleTrigger className="flex items-center justify-between w-full py-2 text-xs font-medium text-gray-300 hover:text-white border-t border-[#444444]">
1806
+ <span className="flex items-center gap-2">
1807
+ <Circle className="w-3.5 h-3.5" />
1808
+ 外見
1809
+ </span>
1810
+ <ChevronRight
1811
+ className={`w-3.5 h-3.5 transition-transform ${openSections.appearance ? "rotate-90" : ""}`}
1812
+ />
1813
+ </CollapsibleTrigger>
1814
+ <CollapsibleContent className="space-y-3 pb-3">
1815
+ {/* 不透明度 + 角丸 */}
1816
+ {/* ブレンドモード */}
1817
+ <div>
1818
+ <Label className="text-[10px] text-gray-500 mb-1 block">
1819
+ ブレンドモード
1820
+ </Label>
1821
+ <select
1822
+ value={selectedElement.mixBlendMode || "normal"}
1823
+ onChange={(e) =>
1824
+ updateElementStyle({ mixBlendMode: e.target.value })
1825
+ }
1826
+ className="h-7 w-full bg-[#383838] border-transparent rounded text-xs px-2 text-white focus:outline-none focus:ring-1 focus:ring-[#0d99ff] appearance-none cursor-pointer"
1827
+ >
1828
+ <option value="normal">通常</option>
1829
+ <option value="multiply">乗算</option>
1830
+ <option value="screen">スクリーン</option>
1831
+ <option value="overlay">オーバーレイ</option>
1832
+ <option value="darken">暗く</option>
1833
+ <option value="lighten">明るく</option>
1834
+ <option value="color-dodge">覆い焼きカラー</option>
1835
+ <option value="color-burn">焼き込みカラー</option>
1836
+ <option value="hard-light">ハードライト</option>
1837
+ <option value="soft-light">ソフトライト</option>
1838
+ <option value="difference">差の絶対値</option>
1839
+ <option value="exclusion">除外</option>
1840
+ <option value="hue">色相</option>
1841
+ <option value="saturation">彩度</option>
1842
+ <option value="color">カラー</option>
1843
+ <option value="luminosity">輝度</option>
1844
+ </select>
1845
+ </div>
1846
+
1847
+ {/* 不透明度 + 角丸 */}
1848
+ <div className="grid grid-cols-2 gap-3 overflow-hidden">
1849
+ {/* 不透明度 */}
1850
+ <div className="min-w-0">
1851
+ <span className="text-[10px] text-gray-500 mb-1 block">
1852
+ 不透明度
1853
+ </span>
1854
+ <div className="flex items-center gap-1 min-w-0">
1855
+ <Square className="w-3 h-3 text-gray-500 flex-shrink-0" />
1856
+ <VariableAwareInput
1857
+ value={selectedElement.rawOpacity || Math.round(selectedElement.opacity * 100)}
1858
+ onChange={(val) => {
1859
+ if (val.startsWith('var(')) {
1860
+ updateElementStyle({ opacity: val });
1861
+ } else {
1862
+ const num = parseFloat(val);
1863
+ if (!isNaN(num)) {
1864
+ updateElementStyle({
1865
+ opacity: String(Math.max(0, Math.min(100, num)) / 100),
1866
+ });
1867
+ }
1868
+ }
1869
+ }}
1870
+ category="other"
1871
+ min={0}
1872
+ max={100}
1873
+ suffix="%"
1874
+ compact
1875
+ className="flex-1 min-w-0"
1876
+ />
1877
+ </div>
1878
+ </div>
1879
+
1880
+ {/* 角丸 */}
1881
+ <div className="min-w-0">
1882
+ <div className="flex items-center justify-between mb-1">
1883
+ <span className="text-[10px] text-gray-500 block">
1884
+ 角の半径
1885
+ </span>
1886
+ <Button
1887
+ variant="ghost"
1888
+ size="icon"
1889
+ onClick={() => setRadiusLinked(!radiusLinked)}
1890
+ className="h-3 w-3 hover:bg-[#4a4a4a] p-0"
1891
+ title={radiusLinked ? "個別設定" : "リンク"}
1892
+ >
1893
+ {radiusLinked ? (
1894
+ <Link2 className="w-2 h-2 text-[#4fb8ff]" />
1895
+ ) : (
1896
+ <Link2Off className="w-2 h-2 text-gray-500" />
1897
+ )}
1898
+ </Button>
1899
+ </div>
1900
+
1901
+ {/* Linked Mode */}
1902
+ {radiusLinked ? (
1903
+ <div className="flex items-center gap-1 min-w-0">
1904
+ <Circle className="w-3 h-3 text-gray-500 flex-shrink-0" />
1905
+ <VariableAwareUnitInput
1906
+ value={selectedElement.rawBorderRadius || `${Math.round(selectedElement.borderRadius)}px`}
1907
+ onChange={(val) => updateElementStyle({ borderRadius: val })}
1908
+ units={BORDER_RADIUS_UNITS}
1909
+ defaultUnit="px"
1910
+ category="spacing"
1911
+ min={0}
1912
+ compact
1913
+ conversionContext={conversionContext}
1914
+ />
1915
+ </div>
1916
+ ) : (
1917
+ <div className="grid grid-cols-2 gap-1 overflow-hidden">
1918
+ {/* Top Left */}
1919
+ <div className="flex items-center gap-0.5 min-w-0" title="左上">
1920
+ <div className="w-2 h-2 border-t border-l border-gray-500 rounded-tl flex-shrink-0" />
1921
+ <VariableAwareUnitInput
1922
+ value={selectedElement.rawBorderTopLeftRadius || `${Math.round(selectedElement.borderRadiusTopLeft || 0)}px`}
1923
+ onChange={(val) => updateElementStyle({ borderTopLeftRadius: val })}
1924
+ units={BORDER_RADIUS_UNITS}
1925
+ defaultUnit="px"
1926
+ category="spacing"
1927
+ min={0}
1928
+ compact
1929
+ hideVariableLink
1930
+ conversionContext={conversionContext}
1931
+ />
1932
+ </div>
1933
+ {/* Top Right */}
1934
+ <div className="flex items-center gap-0.5 min-w-0" title="右上">
1935
+ <VariableAwareUnitInput
1936
+ value={selectedElement.rawBorderTopRightRadius || `${Math.round(selectedElement.borderRadiusTopRight || 0)}px`}
1937
+ onChange={(val) => updateElementStyle({ borderTopRightRadius: val })}
1938
+ units={BORDER_RADIUS_UNITS}
1939
+ defaultUnit="px"
1940
+ category="spacing"
1941
+ min={0}
1942
+ compact
1943
+ hideVariableLink
1944
+ conversionContext={conversionContext}
1945
+ />
1946
+ <div className="w-2 h-2 border-t border-r border-gray-500 rounded-tr flex-shrink-0" />
1947
+ </div>
1948
+ {/* Bottom Left */}
1949
+ <div className="flex items-center gap-0.5 min-w-0" title="左下">
1950
+ <div className="w-2 h-2 border-b border-l border-gray-500 rounded-bl flex-shrink-0" />
1951
+ <VariableAwareUnitInput
1952
+ value={selectedElement.rawBorderBottomLeftRadius || `${Math.round(selectedElement.borderRadiusBottomLeft || 0)}px`}
1953
+ onChange={(val) => updateElementStyle({ borderBottomLeftRadius: val })}
1954
+ units={BORDER_RADIUS_UNITS}
1955
+ defaultUnit="px"
1956
+ category="spacing"
1957
+ min={0}
1958
+ compact
1959
+ hideVariableLink
1960
+ conversionContext={conversionContext}
1961
+ />
1962
+ </div>
1963
+ {/* Bottom Right */}
1964
+ <div className="flex items-center gap-0.5 min-w-0" title="右下">
1965
+ <VariableAwareUnitInput
1966
+ value={selectedElement.rawBorderBottomRightRadius || `${Math.round(selectedElement.borderRadiusBottomRight || 0)}px`}
1967
+ onChange={(val) => updateElementStyle({ borderBottomRightRadius: val })}
1968
+ units={BORDER_RADIUS_UNITS}
1969
+ defaultUnit="px"
1970
+ category="spacing"
1971
+ min={0}
1972
+ compact
1973
+ hideVariableLink
1974
+ conversionContext={conversionContext}
1975
+ />
1976
+ <div className="w-2 h-2 border-b border-r border-gray-500 rounded-br flex-shrink-0" />
1977
+ </div>
1978
+ </div>
1979
+ )}
1980
+ </div>
1981
+ </div>
1982
+ </CollapsibleContent>
1983
+ </Collapsible>
1984
+
1985
+ {/* テキストセクション(テキスト関連タグまたは直接テキストを持つ要素のみ表示) */}
1986
+ {shouldShowTypographySection(selectedElement) && (
1987
+ <Collapsible
1988
+ open={openSections.typography}
1989
+ onOpenChange={(open) =>
1990
+ setOpenSections((prev) => ({ ...prev, typography: open }))
1991
+ }
1992
+ >
1993
+ <CollapsibleTrigger className="flex items-center justify-between w-full py-2 text-xs font-medium text-gray-300 hover:text-white border-t border-[#444444]">
1994
+ <span className="flex items-center gap-2">
1995
+ <Type className="w-3.5 h-3.5" />
1996
+ テキスト
1997
+ </span>
1998
+ <ChevronRight
1999
+ className={`w-3.5 h-3.5 transition-transform ${openSections.typography ? "rotate-90" : ""}`}
2000
+ />
2001
+ </CollapsibleTrigger>
2002
+ <CollapsibleContent className="space-y-2 pb-3">
2003
+ <div>
2004
+ <Label className="text-[10px] text-gray-500">フォント</Label>
2005
+ <GoogleFontPicker
2006
+ value={selectedElement.fontFamily}
2007
+ onChange={(value) =>
2008
+ updateElementStyle({ fontFamily: value })
2009
+ }
2010
+ iframeDoc={iframeRef.current?.contentDocument || null}
2011
+ className="w-full"
2012
+ />
2013
+ </div>
2014
+ <div className="grid grid-cols-2 gap-2">
2015
+ <div>
2016
+ <Label className="text-[10px] text-gray-500">
2017
+ ウェイト
2018
+ </Label>
2019
+ <Select
2020
+ value={selectedElement.fontWeight}
2021
+ onValueChange={(value) =>
2022
+ updateElementStyle({ fontWeight: value })
2023
+ }
2024
+ >
2025
+ <SelectTrigger className="h-7 text-xs bg-[#383838] border-[#444444] text-white">
2026
+ <SelectValue />
2027
+ </SelectTrigger>
2028
+ <SelectContent>
2029
+ {FONT_WEIGHTS.map((w) => (
2030
+ <SelectItem key={w.value} value={w.value}>
2031
+ {w.label}
2032
+ </SelectItem>
2033
+ ))}
2034
+ </SelectContent>
2035
+ </Select>
2036
+ </div>
2037
+ <div>
2038
+ <span className="text-[10px] text-gray-500 mb-1 block">
2039
+ サイズ
2040
+ </span>
2041
+ <VariableAwareUnitInput
2042
+ value={selectedElement.rawFontSize || `${Math.round(selectedElement.fontSize)}px`}
2043
+ onChange={(val) => updateElementStyle({ fontSize: val })}
2044
+ units={FONT_SIZE_UNITS}
2045
+ defaultUnit="px"
2046
+ category="typography"
2047
+ min={1}
2048
+ compact
2049
+ conversionContext={conversionContext}
2050
+ />
2051
+ </div>
2052
+ </div>
2053
+ <div>
2054
+ <Label className="text-[10px] text-gray-500">配置</Label>
2055
+ <div className="flex gap-1 mt-1">
2056
+ <Button
2057
+ variant="ghost"
2058
+ size="icon"
2059
+ onClick={() => updateElementStyle({ textAlign: "left" })}
2060
+ className={`h-7 w-7 text-gray-400 ${selectedElement.textAlign === "left" ? "bg-[#0d99ff] text-white" : "hover:bg-[#4a4a4a] hover:text-white"}`}
2061
+ >
2062
+ <AlignLeft className="w-3.5 h-3.5" />
2063
+ </Button>
2064
+ <Button
2065
+ variant="ghost"
2066
+ size="icon"
2067
+ onClick={() =>
2068
+ updateElementStyle({ textAlign: "center" })
2069
+ }
2070
+ className={`h-7 w-7 text-gray-400 ${selectedElement.textAlign === "center" ? "bg-[#0d99ff] text-white" : "hover:bg-[#4a4a4a] hover:text-white"}`}
2071
+ >
2072
+ <AlignCenter className="w-3.5 h-3.5" />
2073
+ </Button>
2074
+ <Button
2075
+ variant="ghost"
2076
+ size="icon"
2077
+ onClick={() => updateElementStyle({ textAlign: "right" })}
2078
+ className={`h-7 w-7 text-gray-400 ${selectedElement.textAlign === "right" ? "bg-[#0d99ff] text-white" : "hover:bg-[#4a4a4a] hover:text-white"}`}
2079
+ >
2080
+ <AlignRight className="w-3.5 h-3.5" />
2081
+ </Button>
2082
+ <div className="w-px h-7 bg-[#444444] mx-1" />
2083
+ <Button
2084
+ variant="ghost"
2085
+ size="icon"
2086
+ onClick={() =>
2087
+ updateElementStyle({
2088
+ fontWeight:
2089
+ selectedElement.fontWeight === "700"
2090
+ ? "400"
2091
+ : "700",
2092
+ })
2093
+ }
2094
+ className={`h-7 w-7 text-gray-400 ${selectedElement.fontWeight === "700" ? "bg-[#0d99ff] text-white" : "hover:bg-[#4a4a4a] hover:text-white"}`}
2095
+ >
2096
+ <Bold className="w-3.5 h-3.5" />
2097
+ </Button>
2098
+ <Button
2099
+ variant="ghost"
2100
+ size="icon"
2101
+ onClick={() =>
2102
+ updateElementStyle({
2103
+ fontStyle:
2104
+ selectedElement.fontStyle === "italic"
2105
+ ? "normal"
2106
+ : "italic",
2107
+ })
2108
+ }
2109
+ className={`h-7 w-7 text-gray-400 ${selectedElement.fontStyle === "italic" ? "bg-[#0d99ff] text-white" : "hover:bg-[#4a4a4a] hover:text-white"}`}
2110
+ >
2111
+ <Italic className="w-3.5 h-3.5" />
2112
+ </Button>
2113
+ <Button
2114
+ variant="ghost"
2115
+ size="icon"
2116
+ onClick={() =>
2117
+ updateElementStyle({
2118
+ textDecoration:
2119
+ selectedElement.textDecoration?.includes(
2120
+ "underline",
2121
+ )
2122
+ ? "none"
2123
+ : "underline",
2124
+ })
2125
+ }
2126
+ className={`h-7 w-7 text-gray-400 ${selectedElement.textDecoration?.includes("underline") ? "bg-[#0d99ff] text-white" : "hover:bg-[#4a4a4a] hover:text-white"}`}
2127
+ >
2128
+ <Underline className="w-3.5 h-3.5" />
2129
+ </Button>
2130
+ </div>
2131
+ <div className="grid grid-cols-2 gap-2 mt-2">
2132
+ <div>
2133
+ <span className="text-[10px] text-gray-500 mb-1 block">
2134
+ 行間
2135
+ </span>
2136
+ <VariableAwareUnitInput
2137
+ value={selectedElement.rawLineHeight || (() => {
2138
+ const lh = selectedElement.lineHeight;
2139
+ if (!lh || lh === "normal") return "1.5";
2140
+ // Already has unit or is unitless number
2141
+ return lh;
2142
+ })()}
2143
+ onChange={(val) => updateElementStyle({ lineHeight: val })}
2144
+ units={LINE_HEIGHT_UNITS}
2145
+ defaultUnit=""
2146
+ category="typography"
2147
+ min={0}
2148
+ step={0.1}
2149
+ compact
2150
+ hideVariableLink
2151
+ conversionContext={conversionContext}
2152
+ />
2153
+ </div>
2154
+ <div>
2155
+ <span className="text-[10px] text-gray-500 mb-1 block">
2156
+ 文字間
2157
+ </span>
2158
+ <VariableAwareUnitInput
2159
+ value={selectedElement.rawLetterSpacing || (() => {
2160
+ const ls = selectedElement.letterSpacing;
2161
+ if (!ls || ls === "normal") return "0em";
2162
+ return ls;
2163
+ })()}
2164
+ onChange={(val) => updateElementStyle({ letterSpacing: val })}
2165
+ units={LETTER_SPACING_UNITS}
2166
+ defaultUnit="em"
2167
+ category="typography"
2168
+ step={0.01}
2169
+ compact
2170
+ hideVariableLink
2171
+ conversionContext={conversionContext}
2172
+ />
2173
+ </div>
2174
+ </div>
2175
+ </div>
2176
+ <FigmaColorPicker
2177
+ label="文字色"
2178
+ value={parseCssToFillConfig(
2179
+ selectedElement.color,
2180
+ undefined,
2181
+ undefined,
2182
+ selectedElement.rawColor,
2183
+ )}
2184
+ onChange={(config) => {
2185
+ if (config.type === "solid" && config.color) {
2186
+ const opacity = config.opacity ?? 100;
2187
+ if (opacity < 100) {
2188
+ const r = parseInt(config.color.slice(1, 3), 16);
2189
+ const g = parseInt(config.color.slice(3, 5), 16);
2190
+ const b = parseInt(config.color.slice(5, 7), 16);
2191
+ updateElementStyle({
2192
+ color: `rgba(${r}, ${g}, ${b}, ${opacity / 100})`,
2193
+ });
2194
+ } else {
2195
+ updateElementStyle({ color: config.color });
2196
+ }
2197
+ }
2198
+ }}
2199
+ onVariableSelect={(varRef) => {
2200
+ updateElementStyle({ color: varRef });
2201
+ }}
2202
+ presetColors={colorPresets}
2203
+ showImageTab={false}
2204
+ showVariablesTab={true}
2205
+ onClose={restoreFocus}
2206
+ />
2207
+ </CollapsibleContent>
2208
+ </Collapsible>
2209
+ )}
2210
+
2211
+ {/* [移植時の追加] 画像差し替え(<img>の場合のみ表示) */}
2212
+ {selectedElement.tagName?.toUpperCase() === 'IMG' && (
2213
+ <ImgSrcSection
2214
+ selectedElement={selectedElement}
2215
+ open={openSections.link}
2216
+ onOpenChange={(open) =>
2217
+ setOpenSections((prev) => ({ ...prev, link: open }))
2218
+ }
2219
+ onAttributeChange={updateElementAttribute}
2220
+ currentSrc={selectedElement.imageSrc || ''}
2221
+ />
2222
+ )}
2223
+
2224
+ {/* リンクセクション(<a>タグの場合のみ表示) */}
2225
+ {selectedElement.isLink && (
2226
+ <LinkSection
2227
+ selectedElement={selectedElement}
2228
+ open={openSections.link}
2229
+ onOpenChange={(open) =>
2230
+ setOpenSections((prev) => ({ ...prev, link: open }))
2231
+ }
2232
+ onAttributeChange={updateLinkAttribute}
2233
+ />
2234
+ )}
2235
+
2236
+ {/* 塗りセクション */}
2237
+ <Collapsible
2238
+ open={openSections.fill}
2239
+ onOpenChange={(open) =>
2240
+ setOpenSections((prev) => ({ ...prev, fill: open }))
2241
+ }
2242
+ >
2243
+ <CollapsibleTrigger className="flex items-center justify-between w-full py-2 text-xs font-medium text-gray-300 hover:text-white border-t border-[#444444]">
2244
+ <span className="flex items-center gap-2">
2245
+ <Palette className="w-3.5 h-3.5" />
2246
+ 塗り
2247
+ </span>
2248
+ <ChevronRight
2249
+ className={`w-3.5 h-3.5 transition-transform ${openSections.fill ? "rotate-90" : ""}`}
2250
+ />
2251
+ </CollapsibleTrigger>
2252
+ <CollapsibleContent className="space-y-3 pb-3">
2253
+ <FigmaColorPicker
2254
+ label="背景"
2255
+ value={backgroundFillConfig}
2256
+ onChange={handleBackgroundFillChange}
2257
+ onVariableSelect={(varRef) => {
2258
+ updateElementStyle({
2259
+ backgroundColor: varRef,
2260
+ backgroundImage: "none",
2261
+ });
2262
+ }}
2263
+ presetColors={colorPresets}
2264
+ showImageTab={true}
2265
+ showVariablesTab={true}
2266
+ onClose={restoreFocus}
2267
+ />
2268
+ </CollapsibleContent>
2269
+ </Collapsible>
2270
+
2271
+ {/* 線セクション */}
2272
+ <Collapsible
2273
+ open={openSections.stroke}
2274
+ onOpenChange={(open) =>
2275
+ setOpenSections((prev) => ({ ...prev, stroke: open }))
2276
+ }
2277
+ >
2278
+ <CollapsibleTrigger className="flex items-center justify-between w-full py-2 text-xs font-medium text-gray-300 hover:text-white border-t border-[#444444]">
2279
+ <span className="flex items-center gap-2">
2280
+ <Circle className="w-3.5 h-3.5" />線
2281
+ </span>
2282
+ <ChevronRight
2283
+ className={`w-3.5 h-3.5 transition-transform ${openSections.stroke ? "rotate-90" : ""}`}
2284
+ />
2285
+ </CollapsibleTrigger>
2286
+ <CollapsibleContent className="space-y-2 pb-3">
2287
+ <div className="grid grid-cols-2 gap-2">
2288
+ <div>
2289
+ <span className="text-[10px] text-gray-500 mb-1 block">
2290
+ 線幅
2291
+ </span>
2292
+ <VariableAwareUnitInput
2293
+ value={selectedElement.rawBorderWidth || `${Math.round(selectedElement.borderWidth)}px`}
2294
+ onChange={(val) => {
2295
+ // border-style が none の場合は solid に変更
2296
+ const needsStyleChange = selectedElement.borderStyle === "none" &&
2297
+ !val.startsWith('var(') && parseFloat(val) > 0;
2298
+ updateElementStyle({
2299
+ borderWidth: val,
2300
+ ...(needsStyleChange ? { borderStyle: "solid" } : {}),
2301
+ });
2302
+ }}
2303
+ units={BORDER_WIDTH_UNITS}
2304
+ defaultUnit="px"
2305
+ category="spacing"
2306
+ min={0}
2307
+ compact
2308
+ conversionContext={conversionContext}
2309
+ />
2310
+ </div>
2311
+ <div>
2312
+ <Label className="text-[10px] text-gray-500">スタイル</Label>
2313
+ <Select
2314
+ value={selectedElement.borderStyle}
2315
+ onValueChange={(value) =>
2316
+ updateElementStyle({ borderStyle: value })
2317
+ }
2318
+ >
2319
+ <SelectTrigger className="h-7 text-xs bg-[#383838] border-[#444444] text-white">
2320
+ <SelectValue />
2321
+ </SelectTrigger>
2322
+ <SelectContent>
2323
+ <SelectItem value="none">なし</SelectItem>
2324
+ <SelectItem value="solid">実線</SelectItem>
2325
+ <SelectItem value="dashed">破線</SelectItem>
2326
+ <SelectItem value="dotted">点線</SelectItem>
2327
+ </SelectContent>
2328
+ </Select>
2329
+ </div>
2330
+ </div>
2331
+ <FigmaColorPicker
2332
+ label="線色"
2333
+ value={parseCssToFillConfig(
2334
+ selectedElement.borderColor,
2335
+ undefined,
2336
+ undefined,
2337
+ selectedElement.rawBorderColor,
2338
+ )}
2339
+ onChange={(config) => {
2340
+ if (config.type === "solid" && config.color) {
2341
+ updateElementStyle({
2342
+ borderColor: config.color,
2343
+ borderStyle:
2344
+ selectedElement.borderStyle === "none"
2345
+ ? "solid"
2346
+ : selectedElement.borderStyle,
2347
+ });
2348
+ } else if (config.type === "none") {
2349
+ updateElementStyle({ borderStyle: "none" });
2350
+ }
2351
+ }}
2352
+ onVariableSelect={(varRef) => {
2353
+ updateElementStyle({
2354
+ borderColor: varRef,
2355
+ borderStyle:
2356
+ selectedElement.borderStyle === "none"
2357
+ ? "solid"
2358
+ : selectedElement.borderStyle,
2359
+ });
2360
+ }}
2361
+ presetColors={colorPresets}
2362
+ showImageTab={false}
2363
+ showVariablesTab={true}
2364
+ onClose={restoreFocus}
2365
+ />
2366
+ </CollapsibleContent>
2367
+ </Collapsible>
2368
+
2369
+ {/* エフェクトセクション */}
2370
+ <Collapsible
2371
+ open={openSections.effects}
2372
+ onOpenChange={(open) =>
2373
+ setOpenSections((prev) => ({ ...prev, effects: open }))
2374
+ }
2375
+ >
2376
+ <CollapsibleTrigger className="flex items-center justify-between w-full py-2 text-xs font-medium text-gray-300 hover:text-white border-t border-[#444444]">
2377
+ <span className="flex items-center gap-2">
2378
+ <Sparkles className="w-3.5 h-3.5" />
2379
+ エフェクト
2380
+ </span>
2381
+ <ChevronRight
2382
+ className={`w-3.5 h-3.5 transition-transform ${openSections.effects ? "rotate-90" : ""}`}
2383
+ />
2384
+ </CollapsibleTrigger>
2385
+ <CollapsibleContent className="space-y-3 pb-3">
2386
+ {/* ドロップシャドウ */}
2387
+ <div>
2388
+ <div className="flex items-center justify-between">
2389
+ <Label className="text-[10px] text-gray-500">
2390
+ ドロップシャドウ
2391
+ </Label>
2392
+ <Button
2393
+ variant="ghost"
2394
+ size="sm"
2395
+ onClick={() => {
2396
+ if (selectedElement.hasShadow) {
2397
+ updateElementStyle({ boxShadow: "none" });
2398
+ } else {
2399
+ updateElementStyle({
2400
+ boxShadow: "rgba(0, 0, 0, 0.25) 4px 4px 10px 0px",
2401
+ });
2402
+ }
2403
+ }}
2404
+ className="h-5 text-[10px] text-gray-400 hover:text-white"
2405
+ >
2406
+ {selectedElement.hasShadow ? "削除" : "追加"}
2407
+ </Button>
2408
+ </div>
2409
+ {selectedElement.hasShadow && (
2410
+ <div className="space-y-2 mt-2">
2411
+ <div className="grid grid-cols-2 gap-2">
2412
+ <div>
2413
+ <ScrubbableLabel
2414
+ value={selectedElement.shadowX}
2415
+ onChange={(val) => {
2416
+ updateElementStyle({
2417
+ boxShadow: `${selectedElement.shadowColor} ${Math.round(val)}px ${selectedElement.shadowY}px ${selectedElement.shadowBlur}px ${selectedElement.shadowSpread}px`,
2418
+ });
2419
+ }}
2420
+ className="text-[10px] text-gray-500 mb-1 block"
2421
+ >
2422
+ X
2423
+ </ScrubbableLabel>
2424
+ <CompactNumberInput
2425
+ value={selectedElement.shadowX}
2426
+ onChange={(val) => {
2427
+ updateElementStyle({
2428
+ boxShadow: `${selectedElement.shadowColor} ${val}px ${selectedElement.shadowY}px ${selectedElement.shadowBlur}px ${selectedElement.shadowSpread}px`,
2429
+ });
2430
+ }}
2431
+ className="h-7 bg-[#383838] border-[#444444]"
2432
+ />
2433
+ </div>
2434
+ <div>
2435
+ <ScrubbableLabel
2436
+ value={selectedElement.shadowY}
2437
+ onChange={(val) => {
2438
+ updateElementStyle({
2439
+ boxShadow: `${selectedElement.shadowColor} ${selectedElement.shadowX}px ${Math.round(val)}px ${selectedElement.shadowBlur}px ${selectedElement.shadowSpread}px`,
2440
+ });
2441
+ }}
2442
+ className="text-[10px] text-gray-500 mb-1 block"
2443
+ >
2444
+ Y
2445
+ </ScrubbableLabel>
2446
+ <CompactNumberInput
2447
+ value={selectedElement.shadowY}
2448
+ onChange={(val) => {
2449
+ updateElementStyle({
2450
+ boxShadow: `${selectedElement.shadowColor} ${selectedElement.shadowX}px ${val}px ${selectedElement.shadowBlur}px ${selectedElement.shadowSpread}px`,
2451
+ });
2452
+ }}
2453
+ className="h-7 bg-[#383838] border-[#444444]"
2454
+ />
2455
+ </div>
2456
+ </div>
2457
+ <div className="grid grid-cols-2 gap-2">
2458
+ <div>
2459
+ <ScrubbableLabel
2460
+ value={selectedElement.shadowBlur}
2461
+ onChange={(val) => {
2462
+ updateElementStyle({
2463
+ boxShadow: `${selectedElement.shadowColor} ${selectedElement.shadowX}px ${selectedElement.shadowY}px ${Math.max(0, Math.round(val))}px ${selectedElement.shadowSpread}px`,
2464
+ });
2465
+ }}
2466
+ className="text-[10px] text-gray-500 mb-1 block"
2467
+ >
2468
+ ぼかし
2469
+ </ScrubbableLabel>
2470
+ <CompactNumberInput
2471
+ value={selectedElement.shadowBlur}
2472
+ onChange={(val) => {
2473
+ updateElementStyle({
2474
+ boxShadow: `${selectedElement.shadowColor} ${selectedElement.shadowX}px ${selectedElement.shadowY}px ${val}px ${selectedElement.shadowSpread}px`,
2475
+ });
2476
+ }}
2477
+ min={0}
2478
+ className="h-7 bg-[#383838] border-[#444444]"
2479
+ />
2480
+ </div>
2481
+ <div>
2482
+ <ScrubbableLabel
2483
+ value={selectedElement.shadowSpread}
2484
+ onChange={(val) => {
2485
+ updateElementStyle({
2486
+ boxShadow: `${selectedElement.shadowColor} ${selectedElement.shadowX}px ${selectedElement.shadowY}px ${selectedElement.shadowBlur}px ${Math.round(val)}px`,
2487
+ });
2488
+ }}
2489
+ className="text-[10px] text-gray-500 mb-1 block"
2490
+ >
2491
+ 広がり
2492
+ </ScrubbableLabel>
2493
+ <CompactNumberInput
2494
+ value={selectedElement.shadowSpread}
2495
+ onChange={(val) => {
2496
+ updateElementStyle({
2497
+ boxShadow: `${selectedElement.shadowColor} ${selectedElement.shadowX}px ${selectedElement.shadowY}px ${selectedElement.shadowBlur}px ${val}px`,
2498
+ });
2499
+ }}
2500
+ min={0}
2501
+ className="h-7 bg-[#383838] border-[#444444]"
2502
+ />
2503
+ </div>
2504
+ </div>
2505
+ </div>
2506
+ )}
2507
+ </div>
2508
+
2509
+ {/* ブラー */}
2510
+ <div className="border-t border-[#444444] pt-2">
2511
+ <ScrubbableLabel
2512
+ value={selectedElement.filterBlur}
2513
+ onChange={(val) => {
2514
+ updateElementStyle({
2515
+ filter: buildFilterString({
2516
+ blur: Math.max(0, Math.round(val)),
2517
+ brightness: selectedElement.filterBrightness,
2518
+ contrast: selectedElement.filterContrast,
2519
+ grayscale: selectedElement.filterGrayscale,
2520
+ saturate: selectedElement.filterSaturate,
2521
+ sepia: selectedElement.filterSepia,
2522
+ hueRotate: selectedElement.filterHueRotate,
2523
+ invert: selectedElement.filterInvert,
2524
+ }),
2525
+ });
2526
+ }}
2527
+ className="text-[10px] text-gray-500 mb-1 block"
2528
+ >
2529
+ ブラー
2530
+ </ScrubbableLabel>
2531
+ <div className="flex items-center gap-2">
2532
+ <Slider
2533
+ value={[selectedElement.filterBlur]}
2534
+ onValueChange={([value]) => {
2535
+ updateElementStyle({
2536
+ filter: buildFilterString({
2537
+ blur: value,
2538
+ brightness: selectedElement.filterBrightness,
2539
+ contrast: selectedElement.filterContrast,
2540
+ grayscale: selectedElement.filterGrayscale,
2541
+ saturate: selectedElement.filterSaturate,
2542
+ sepia: selectedElement.filterSepia,
2543
+ hueRotate: selectedElement.filterHueRotate,
2544
+ invert: selectedElement.filterInvert,
2545
+ }),
2546
+ });
2547
+ }}
2548
+ min={0}
2549
+ max={50}
2550
+ step={1}
2551
+ className="flex-1"
2552
+ />
2553
+ <CompactNumberInput
2554
+ value={selectedElement.filterBlur}
2555
+ onChange={(val) => {
2556
+ updateElementStyle({
2557
+ filter: buildFilterString({
2558
+ blur: val || 0,
2559
+ brightness: selectedElement.filterBrightness,
2560
+ contrast: selectedElement.filterContrast,
2561
+ grayscale: selectedElement.filterGrayscale,
2562
+ saturate: selectedElement.filterSaturate,
2563
+ sepia: selectedElement.filterSepia,
2564
+ hueRotate: selectedElement.filterHueRotate,
2565
+ invert: selectedElement.filterInvert,
2566
+ }),
2567
+ });
2568
+ }}
2569
+ min={0}
2570
+ max={50}
2571
+ className="w-14 h-7 border-[#444444]"
2572
+ />
2573
+ </div>
2574
+ </div>
2575
+
2576
+ {/* バックドロップブラー */}
2577
+ <div>
2578
+ <ScrubbableLabel
2579
+ value={selectedElement.backdropBlur}
2580
+ onChange={(val) => {
2581
+ const value = Math.max(0, Math.round(val));
2582
+ updateElementStyle({
2583
+ backdropFilter: value > 0 ? `blur(${value}px)` : "none",
2584
+ WebkitBackdropFilter:
2585
+ value > 0 ? `blur(${value}px)` : "none",
2586
+ });
2587
+ }}
2588
+ className="text-[10px] text-gray-500 mb-1 block"
2589
+ >
2590
+ バックドロップブラー
2591
+ </ScrubbableLabel>
2592
+ <div className="flex items-center gap-2">
2593
+ <Slider
2594
+ value={[selectedElement.backdropBlur]}
2595
+ onValueChange={([value]) => {
2596
+ updateElementStyle({
2597
+ backdropFilter: value > 0 ? `blur(${value}px)` : "none",
2598
+ WebkitBackdropFilter:
2599
+ value > 0 ? `blur(${value}px)` : "none",
2600
+ });
2601
+ }}
2602
+ min={0}
2603
+ max={50}
2604
+ step={1}
2605
+ className="flex-1"
2606
+ />
2607
+ <CompactNumberInput
2608
+ value={selectedElement.backdropBlur}
2609
+ onChange={(val) => {
2610
+ const value = val || 0;
2611
+ updateElementStyle({
2612
+ backdropFilter: value > 0 ? `blur(${value}px)` : "none",
2613
+ WebkitBackdropFilter:
2614
+ value > 0 ? `blur(${value}px)` : "none",
2615
+ });
2616
+ }}
2617
+ min={0}
2618
+ max={50}
2619
+ className="w-14 h-7 border-[#444444]"
2620
+ />
2621
+ </div>
2622
+ </div>
2623
+
2624
+ {/* 明度 */}
2625
+ <div>
2626
+ <ScrubbableLabel
2627
+ value={selectedElement.filterBrightness}
2628
+ onChange={(val) => {
2629
+ updateElementStyle({
2630
+ filter: buildFilterString({
2631
+ blur: selectedElement.filterBlur,
2632
+ brightness: Math.max(0, Math.min(200, Math.round(val))),
2633
+ contrast: selectedElement.filterContrast,
2634
+ grayscale: selectedElement.filterGrayscale,
2635
+ saturate: selectedElement.filterSaturate,
2636
+ sepia: selectedElement.filterSepia,
2637
+ hueRotate: selectedElement.filterHueRotate,
2638
+ invert: selectedElement.filterInvert,
2639
+ }),
2640
+ });
2641
+ }}
2642
+ className="text-[10px] text-gray-500 mb-1 block"
2643
+ >
2644
+ 明度
2645
+ </ScrubbableLabel>
2646
+ <div className="flex items-center gap-2">
2647
+ <Slider
2648
+ value={[selectedElement.filterBrightness]}
2649
+ onValueChange={([value]) => {
2650
+ updateElementStyle({
2651
+ filter: buildFilterString({
2652
+ blur: selectedElement.filterBlur,
2653
+ brightness: value,
2654
+ contrast: selectedElement.filterContrast,
2655
+ grayscale: selectedElement.filterGrayscale,
2656
+ saturate: selectedElement.filterSaturate,
2657
+ sepia: selectedElement.filterSepia,
2658
+ hueRotate: selectedElement.filterHueRotate,
2659
+ invert: selectedElement.filterInvert,
2660
+ }),
2661
+ });
2662
+ }}
2663
+ min={0}
2664
+ max={200}
2665
+ step={1}
2666
+ className="flex-1"
2667
+ />
2668
+ <span className="text-xs text-gray-400 w-10">
2669
+ {selectedElement.filterBrightness}%
2670
+ </span>
2671
+ </div>
2672
+ </div>
2673
+
2674
+ {/* コントラスト */}
2675
+ <div>
2676
+ <ScrubbableLabel
2677
+ value={selectedElement.filterContrast}
2678
+ onChange={(val) => {
2679
+ updateElementStyle({
2680
+ filter: buildFilterString({
2681
+ blur: selectedElement.filterBlur,
2682
+ brightness: selectedElement.filterBrightness,
2683
+ contrast: Math.max(0, Math.min(200, Math.round(val))),
2684
+ grayscale: selectedElement.filterGrayscale,
2685
+ saturate: selectedElement.filterSaturate,
2686
+ sepia: selectedElement.filterSepia,
2687
+ hueRotate: selectedElement.filterHueRotate,
2688
+ invert: selectedElement.filterInvert,
2689
+ }),
2690
+ });
2691
+ }}
2692
+ className="text-[10px] text-gray-500 mb-1 block"
2693
+ >
2694
+ コントラスト
2695
+ </ScrubbableLabel>
2696
+ <div className="flex items-center gap-2">
2697
+ <Slider
2698
+ value={[selectedElement.filterContrast]}
2699
+ onValueChange={([value]) => {
2700
+ updateElementStyle({
2701
+ filter: buildFilterString({
2702
+ blur: selectedElement.filterBlur,
2703
+ brightness: selectedElement.filterBrightness,
2704
+ contrast: value,
2705
+ grayscale: selectedElement.filterGrayscale,
2706
+ saturate: selectedElement.filterSaturate,
2707
+ sepia: selectedElement.filterSepia,
2708
+ hueRotate: selectedElement.filterHueRotate,
2709
+ invert: selectedElement.filterInvert,
2710
+ }),
2711
+ });
2712
+ }}
2713
+ min={0}
2714
+ max={200}
2715
+ step={1}
2716
+ className="flex-1"
2717
+ />
2718
+ <span className="text-xs text-gray-400 w-10">
2719
+ {selectedElement.filterContrast}%
2720
+ </span>
2721
+ </div>
2722
+ </div>
2723
+
2724
+ {/* 彩度 */}
2725
+ <div>
2726
+ <ScrubbableLabel
2727
+ value={selectedElement.filterSaturate}
2728
+ onChange={(val) => {
2729
+ updateElementStyle({
2730
+ filter: buildFilterString({
2731
+ blur: selectedElement.filterBlur,
2732
+ brightness: selectedElement.filterBrightness,
2733
+ contrast: selectedElement.filterContrast,
2734
+ grayscale: selectedElement.filterGrayscale,
2735
+ saturate: Math.max(0, Math.min(200, Math.round(val))),
2736
+ sepia: selectedElement.filterSepia,
2737
+ hueRotate: selectedElement.filterHueRotate,
2738
+ invert: selectedElement.filterInvert,
2739
+ }),
2740
+ });
2741
+ }}
2742
+ className="text-[10px] text-gray-500 mb-1 block"
2743
+ >
2744
+ 彩度
2745
+ </ScrubbableLabel>
2746
+ <div className="flex items-center gap-2">
2747
+ <Slider
2748
+ value={[selectedElement.filterSaturate]}
2749
+ onValueChange={([value]) => {
2750
+ updateElementStyle({
2751
+ filter: buildFilterString({
2752
+ blur: selectedElement.filterBlur,
2753
+ brightness: selectedElement.filterBrightness,
2754
+ contrast: selectedElement.filterContrast,
2755
+ grayscale: selectedElement.filterGrayscale,
2756
+ saturate: value,
2757
+ sepia: selectedElement.filterSepia,
2758
+ hueRotate: selectedElement.filterHueRotate,
2759
+ invert: selectedElement.filterInvert,
2760
+ }),
2761
+ });
2762
+ }}
2763
+ min={0}
2764
+ max={200}
2765
+ step={1}
2766
+ className="flex-1"
2767
+ />
2768
+ <span className="text-xs text-gray-400 w-10">
2769
+ {selectedElement.filterSaturate}%
2770
+ </span>
2771
+ </div>
2772
+ </div>
2773
+
2774
+ {/* グレースケール */}
2775
+ <div>
2776
+ <ScrubbableLabel
2777
+ value={selectedElement.filterGrayscale}
2778
+ onChange={(val) => {
2779
+ updateElementStyle({
2780
+ filter: buildFilterString({
2781
+ blur: selectedElement.filterBlur,
2782
+ brightness: selectedElement.filterBrightness,
2783
+ contrast: selectedElement.filterContrast,
2784
+ grayscale: Math.max(0, Math.min(100, Math.round(val))),
2785
+ saturate: selectedElement.filterSaturate,
2786
+ sepia: selectedElement.filterSepia,
2787
+ hueRotate: selectedElement.filterHueRotate,
2788
+ invert: selectedElement.filterInvert,
2789
+ }),
2790
+ });
2791
+ }}
2792
+ className="text-[10px] text-gray-500 mb-1 block"
2793
+ >
2794
+ グレースケール
2795
+ </ScrubbableLabel>
2796
+ <div className="flex items-center gap-2">
2797
+ <Slider
2798
+ value={[selectedElement.filterGrayscale]}
2799
+ onValueChange={([value]) => {
2800
+ updateElementStyle({
2801
+ filter: buildFilterString({
2802
+ blur: selectedElement.filterBlur,
2803
+ brightness: selectedElement.filterBrightness,
2804
+ contrast: selectedElement.filterContrast,
2805
+ grayscale: value,
2806
+ saturate: selectedElement.filterSaturate,
2807
+ sepia: selectedElement.filterSepia,
2808
+ hueRotate: selectedElement.filterHueRotate,
2809
+ invert: selectedElement.filterInvert,
2810
+ }),
2811
+ });
2812
+ }}
2813
+ min={0}
2814
+ max={100}
2815
+ step={1}
2816
+ className="flex-1"
2817
+ />
2818
+ <span className="text-xs text-gray-400 w-10">
2819
+ {selectedElement.filterGrayscale}%
2820
+ </span>
2821
+ </div>
2822
+ </div>
2823
+
2824
+ {/* セピア */}
2825
+ <div>
2826
+ <ScrubbableLabel
2827
+ value={selectedElement.filterSepia}
2828
+ onChange={(val) => {
2829
+ updateElementStyle({
2830
+ filter: buildFilterString({
2831
+ blur: selectedElement.filterBlur,
2832
+ brightness: selectedElement.filterBrightness,
2833
+ contrast: selectedElement.filterContrast,
2834
+ grayscale: selectedElement.filterGrayscale,
2835
+ saturate: selectedElement.filterSaturate,
2836
+ sepia: Math.max(0, Math.min(100, Math.round(val))),
2837
+ hueRotate: selectedElement.filterHueRotate,
2838
+ invert: selectedElement.filterInvert,
2839
+ }),
2840
+ });
2841
+ }}
2842
+ className="text-[10px] text-gray-500 mb-1 block"
2843
+ >
2844
+ セピア
2845
+ </ScrubbableLabel>
2846
+ <div className="flex items-center gap-2">
2847
+ <Slider
2848
+ value={[selectedElement.filterSepia]}
2849
+ onValueChange={([value]) => {
2850
+ updateElementStyle({
2851
+ filter: buildFilterString({
2852
+ blur: selectedElement.filterBlur,
2853
+ brightness: selectedElement.filterBrightness,
2854
+ contrast: selectedElement.filterContrast,
2855
+ grayscale: selectedElement.filterGrayscale,
2856
+ saturate: selectedElement.filterSaturate,
2857
+ sepia: value,
2858
+ hueRotate: selectedElement.filterHueRotate,
2859
+ invert: selectedElement.filterInvert,
2860
+ }),
2861
+ });
2862
+ }}
2863
+ min={0}
2864
+ max={100}
2865
+ step={1}
2866
+ className="flex-1"
2867
+ />
2868
+ <span className="text-xs text-gray-400 w-10">
2869
+ {selectedElement.filterSepia}%
2870
+ </span>
2871
+ </div>
2872
+ </div>
2873
+
2874
+ {/* 色相回転 */}
2875
+ <div>
2876
+ <ScrubbableLabel
2877
+ value={selectedElement.filterHueRotate}
2878
+ onChange={(val) => {
2879
+ updateElementStyle({
2880
+ filter: buildFilterString({
2881
+ blur: selectedElement.filterBlur,
2882
+ brightness: selectedElement.filterBrightness,
2883
+ contrast: selectedElement.filterContrast,
2884
+ grayscale: selectedElement.filterGrayscale,
2885
+ saturate: selectedElement.filterSaturate,
2886
+ sepia: selectedElement.filterSepia,
2887
+ hueRotate: Math.round(val) % 360,
2888
+ invert: selectedElement.filterInvert,
2889
+ }),
2890
+ });
2891
+ }}
2892
+ className="text-[10px] text-gray-500 mb-1 block"
2893
+ >
2894
+ 色相回転
2895
+ </ScrubbableLabel>
2896
+ <div className="flex items-center gap-2">
2897
+ <Slider
2898
+ value={[selectedElement.filterHueRotate]}
2899
+ onValueChange={([value]) => {
2900
+ updateElementStyle({
2901
+ filter: buildFilterString({
2902
+ blur: selectedElement.filterBlur,
2903
+ brightness: selectedElement.filterBrightness,
2904
+ contrast: selectedElement.filterContrast,
2905
+ grayscale: selectedElement.filterGrayscale,
2906
+ saturate: selectedElement.filterSaturate,
2907
+ sepia: selectedElement.filterSepia,
2908
+ hueRotate: value,
2909
+ invert: selectedElement.filterInvert,
2910
+ }),
2911
+ });
2912
+ }}
2913
+ min={0}
2914
+ max={360}
2915
+ step={1}
2916
+ className="flex-1"
2917
+ />
2918
+ <span className="text-xs text-gray-400 w-10">
2919
+ {selectedElement.filterHueRotate}°
2920
+ </span>
2921
+ </div>
2922
+ </div>
2923
+
2924
+ {/* 反転 */}
2925
+ <div>
2926
+ <ScrubbableLabel
2927
+ value={selectedElement.filterInvert}
2928
+ onChange={(val) => {
2929
+ updateElementStyle({
2930
+ filter: buildFilterString({
2931
+ blur: selectedElement.filterBlur,
2932
+ brightness: selectedElement.filterBrightness,
2933
+ contrast: selectedElement.filterContrast,
2934
+ grayscale: selectedElement.filterGrayscale,
2935
+ saturate: selectedElement.filterSaturate,
2936
+ sepia: selectedElement.filterSepia,
2937
+ hueRotate: selectedElement.filterHueRotate,
2938
+ invert: Math.max(0, Math.min(100, Math.round(val))),
2939
+ }),
2940
+ });
2941
+ }}
2942
+ className="text-[10px] text-gray-500 mb-1 block"
2943
+ >
2944
+ 反転
2945
+ </ScrubbableLabel>
2946
+ <div className="flex items-center gap-2">
2947
+ <Slider
2948
+ value={[selectedElement.filterInvert]}
2949
+ onValueChange={([value]) => {
2950
+ updateElementStyle({
2951
+ filter: buildFilterString({
2952
+ blur: selectedElement.filterBlur,
2953
+ brightness: selectedElement.filterBrightness,
2954
+ contrast: selectedElement.filterContrast,
2955
+ grayscale: selectedElement.filterGrayscale,
2956
+ saturate: selectedElement.filterSaturate,
2957
+ sepia: selectedElement.filterSepia,
2958
+ hueRotate: selectedElement.filterHueRotate,
2959
+ invert: value,
2960
+ }),
2961
+ });
2962
+ }}
2963
+ min={0}
2964
+ max={100}
2965
+ step={1}
2966
+ className="flex-1"
2967
+ />
2968
+ <span className="text-xs text-gray-400 w-10">
2969
+ {selectedElement.filterInvert}%
2970
+ </span>
2971
+ </div>
2972
+ </div>
2973
+
2974
+ {/* エフェクトリセットボタン */}
2975
+ <Button
2976
+ variant="ghost"
2977
+ size="sm"
2978
+ onClick={() => {
2979
+ updateElementStyle({
2980
+ filter: "none",
2981
+ backdropFilter: "none",
2982
+ WebkitBackdropFilter: "none",
2983
+ });
2984
+ }}
2985
+ className="w-full h-6 text-[10px] text-gray-400 hover:text-white hover:bg-[#4a4a4a]"
2986
+ >
2987
+ エフェクトをリセット
2988
+ </Button>
2989
+ </CollapsibleContent>
2990
+ </Collapsible>
2991
+ </div>
2992
+ </ScrollArea>
2993
+ </div>
2994
+ </LiveGeometryProvider>
2995
+ );
2996
+ });