@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,1577 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Figmaライクなカラーピッカーコンポーネント
5
+ *
6
+ * 機能:
7
+ * - ソリッドカラー
8
+ * - リニアグラデーション
9
+ * - ラジアルグラデーション
10
+ * - 画像
11
+ * - スライド内で使用されているカラーのプリセット
12
+ * - 透明度調整
13
+ */
14
+
15
+ import React, { useState, useCallback, useMemo, useEffect, useRef } from "react";
16
+ import { HexColorPicker, HexColorInput } from "react-colorful";
17
+ import {
18
+ Popover,
19
+ PopoverContent,
20
+ PopoverTrigger,
21
+ } from "../../components/ui/popover";
22
+ import { Button } from "../../components/ui/button";
23
+ import { Input } from "../../components/ui/input";
24
+ import { Label } from "../../components/ui/label";
25
+ import { Slider } from "../../components/ui/slider";
26
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../components/ui/tabs";
27
+ import {
28
+ Select,
29
+ SelectContent,
30
+ SelectItem,
31
+ SelectTrigger,
32
+ SelectValue,
33
+ } from "../../components/ui/select";
34
+ import {
35
+ Paintbrush,
36
+ Plus,
37
+ Trash2,
38
+ Image as ImageIcon,
39
+ Upload,
40
+ X,
41
+ GripVertical,
42
+ RotateCcw,
43
+ Pipette,
44
+ Unlink,
45
+ } from "lucide-react";
46
+ import { cn } from "../../lib/utils";
47
+ import { useImageUpload } from "../hooks/useImageUpload";
48
+ import { ScrubbableLabel } from "../../components/ui/scrubbable-label";
49
+ import { CompactNumberInput } from "./property-panel/CompactNumberInput";
50
+ import { ScrollArea } from "../../components/ui/scroll-area";
51
+ import { Search } from "lucide-react";
52
+ import { useEditorVariables } from "../EditorContext";
53
+ import { isEyeDropperSupported, pickScreenColor } from "../utils/eyedropper";
54
+ import type { CSSVariableDefinition } from "../../types/css-variables";
55
+ import { isVariableReference, extractVariableName, generateVarReference } from "../../types/css-variables";
56
+
57
+ // 塗りのタイプ
58
+ export type FillType = "solid" | "linear" | "radial" | "image" | "variable" | "none";
59
+
60
+ // グラデーションストップ
61
+ export interface GradientStop {
62
+ color: string;
63
+ position: number; // 0-100
64
+ opacity: number; // 0-100
65
+ }
66
+
67
+ // 塗りの設定
68
+ export interface FillConfig {
69
+ type: FillType;
70
+ // ソリッドカラー
71
+ color?: string;
72
+ opacity?: number;
73
+ // グラデーション
74
+ gradient?: {
75
+ stops: GradientStop[];
76
+ angle?: number; // リニア用(度)
77
+ };
78
+ // 画像
79
+ image?: {
80
+ url: string;
81
+ size: "cover" | "contain" | "fill" | "tile" | "custom";
82
+ position: string;
83
+ positionX?: number; // パーセント(0-100)
84
+ positionY?: number; // パーセント(0-100)
85
+ customWidth?: number; // パーセント
86
+ customHeight?: number; // パーセント
87
+ };
88
+ // 変数
89
+ variable?: {
90
+ id: string;
91
+ name: string;
92
+ cssName: string;
93
+ value: string;
94
+ };
95
+ }
96
+
97
+ interface FigmaColorPickerProps {
98
+ value: FillConfig;
99
+ onChange: (config: FillConfig) => void;
100
+ presetColors?: string[];
101
+ showImageTab?: boolean;
102
+ showVariablesTab?: boolean;
103
+ label?: string;
104
+ className?: string;
105
+ presentationId?: string;
106
+ slideId?: string;
107
+ /** ポップオーバーが閉じた時のコールバック(フォーカス復元用) */
108
+ onClose?: () => void;
109
+ /** 変数選択時に var(--xxx) 形式で直接返す(onChange の代わり) */
110
+ onVariableSelect?: (varReference: string) => void;
111
+ }
112
+
113
+ // RGB を HEX に変換
114
+ const rgbToHex = (rgb: string): string => {
115
+ if (!rgb || rgb === "transparent" || rgb === "none") return "#ffffff";
116
+ if (rgb.startsWith("#")) return rgb.slice(0, 7);
117
+ const match = rgb.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
118
+ if (!match) return "#ffffff";
119
+ const r = parseInt(match[1]).toString(16).padStart(2, "0");
120
+ const g = parseInt(match[2]).toString(16).padStart(2, "0");
121
+ const b = parseInt(match[3]).toString(16).padStart(2, "0");
122
+ return `#${r}${g}${b}`;
123
+ };
124
+
125
+ // RGBA から opacity を抽出
126
+ // RGBA から opacity を抽出
127
+ export const extractOpacity = (color: string): number => {
128
+ if (!color) return 100;
129
+ // rgba(r, g, b, a) の形式だけをマッチさせる(カンマが3つあることを確認)
130
+ const match = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\s*\)/);
131
+ if (match && match[4]) {
132
+ const value = parseFloat(match[4]);
133
+ // 1より大きい場合はパーセント値として扱う(誤って保存された値の復旧)
134
+ if (value > 1) {
135
+ return Math.min(100, Math.round(value));
136
+ }
137
+ return Math.min(100, Math.max(0, Math.round(value * 100)));
138
+ }
139
+ return 100;
140
+ };
141
+
142
+ // HEX を RGBA に変換
143
+ const hexToRgba = (hex: string, opacity: number): string => {
144
+ const r = parseInt(hex.slice(1, 3), 16);
145
+ const g = parseInt(hex.slice(3, 5), 16);
146
+ const b = parseInt(hex.slice(5, 7), 16);
147
+ return `rgba(${r}, ${g}, ${b}, ${opacity / 100})`;
148
+ };
149
+
150
+ // グラデーションストップをCSS文字列に変換
151
+ // toSorted()を使用して入力配列を変更しない(イミュータブル)
152
+ const stopsToGradientString = (stops: GradientStop[]): string => {
153
+ return stops
154
+ .toSorted((a, b) => a.position - b.position)
155
+ .map((stop) => `${hexToRgba(stop.color, stop.opacity)} ${stop.position}%`)
156
+ .join(", ");
157
+ };
158
+
159
+ // 塗り設定からCSS値を生成
160
+ export const fillConfigToCss = (
161
+ config: FillConfig,
162
+ ): {
163
+ backgroundColor?: string;
164
+ backgroundImage?: string;
165
+ backgroundSize?: string;
166
+ backgroundPosition?: string;
167
+ backgroundRepeat?: string;
168
+ } => {
169
+ if (config.type === "none") {
170
+ return { backgroundColor: "transparent", backgroundImage: "none" };
171
+ }
172
+
173
+ if (config.type === "solid") {
174
+ const color = config.color || "#ffffff";
175
+ const opacity = config.opacity ?? 100;
176
+ return {
177
+ backgroundColor: opacity < 100 ? hexToRgba(color, opacity) : color,
178
+ backgroundImage: "none",
179
+ };
180
+ }
181
+
182
+ if (config.type === "variable" && config.variable) {
183
+ // 変数の場合は解決された値をプレビュー用に使用
184
+ return {
185
+ backgroundColor: config.variable.value,
186
+ backgroundImage: "none",
187
+ };
188
+ }
189
+
190
+ if (config.type === "linear" && config.gradient) {
191
+ const angle = config.gradient.angle ?? 180;
192
+ const gradientStr = stopsToGradientString(config.gradient.stops);
193
+ return {
194
+ backgroundColor: "transparent",
195
+ backgroundImage: `linear-gradient(${angle}deg, ${gradientStr})`,
196
+ };
197
+ }
198
+
199
+ if (config.type === "radial" && config.gradient) {
200
+ const gradientStr = stopsToGradientString(config.gradient.stops);
201
+ return {
202
+ backgroundColor: "transparent",
203
+ backgroundImage: `radial-gradient(circle, ${gradientStr})`,
204
+ };
205
+ }
206
+
207
+ if (config.type === "image" && config.image) {
208
+ const sizeMap: Record<string, string> = {
209
+ cover: "cover",
210
+ contain: "contain",
211
+ fill: "100% 100%",
212
+ tile: "auto",
213
+ custom: `${config.image.customWidth ?? 100}% ${config.image.customHeight ?? 100}%`,
214
+ };
215
+
216
+ // positionX/Yが設定されている場合はそれを使用、なければposition文字列を使用
217
+ const posX = config.image.positionX ?? 50;
218
+ const posY = config.image.positionY ?? 50;
219
+ const position =
220
+ config.image.positionX !== undefined ||
221
+ config.image.positionY !== undefined
222
+ ? `${posX}% ${posY}%`
223
+ : config.image.position || "center";
224
+
225
+ return {
226
+ backgroundColor: "transparent",
227
+ backgroundImage: `url('${config.image.url}')`,
228
+ backgroundSize: sizeMap[config.image.size],
229
+ backgroundPosition: position,
230
+ backgroundRepeat: config.image.size === "tile" ? "repeat" : "no-repeat",
231
+ };
232
+ }
233
+
234
+ return { backgroundColor: "transparent" };
235
+ };
236
+
237
+ // CSSから塗り設定を解析
238
+ // rawBackgroundColor: インラインスタイルの生の値(CSS変数検出用)
239
+ export const parseCssToFillConfig = (
240
+ backgroundColor?: string,
241
+ backgroundImage?: string,
242
+ backgroundSize?: string,
243
+ rawBackgroundColor?: string,
244
+ ): FillConfig => {
245
+ // CSS変数参照の場合(rawBackgroundColorにvar()が含まれている)
246
+ if (rawBackgroundColor && isVariableReference(rawBackgroundColor)) {
247
+ const varName = extractVariableName(rawBackgroundColor);
248
+ if (varName) {
249
+ return {
250
+ type: "variable",
251
+ variable: {
252
+ id: varName,
253
+ name: varName.replace('--', '').replace(/-/g, ' '),
254
+ cssName: varName,
255
+ value: backgroundColor || '', // 解決済みの値
256
+ },
257
+ };
258
+ }
259
+ }
260
+
261
+ // 画像の場合
262
+ if (
263
+ backgroundImage &&
264
+ backgroundImage !== "none" &&
265
+ backgroundImage.startsWith("url(")
266
+ ) {
267
+ const urlMatch = backgroundImage.match(/url\(['"]?([^'"()]+)['"]?\)/);
268
+ const url = urlMatch ? urlMatch[1] : "";
269
+ const size =
270
+ backgroundSize === "cover"
271
+ ? "cover"
272
+ : backgroundSize === "contain"
273
+ ? "contain"
274
+ : backgroundSize === "100% 100%"
275
+ ? "fill"
276
+ : backgroundSize === "auto"
277
+ ? "tile"
278
+ : "cover";
279
+ return {
280
+ type: "image",
281
+ image: { url, size, position: "center" },
282
+ };
283
+ }
284
+
285
+ // リニアグラデーションの場合
286
+ if (backgroundImage && backgroundImage.includes("linear-gradient")) {
287
+ const angleMatch = backgroundImage.match(/linear-gradient\((\d+)deg/);
288
+ const angle = angleMatch ? parseInt(angleMatch[1]) : 180;
289
+
290
+ // ストップを解析
291
+ const stopsMatch = backgroundImage.match(/rgba?\([^)]+\)\s*\d+%/g);
292
+ const stops: GradientStop[] = stopsMatch?.map((stopStr) => {
293
+ const colorMatch = stopStr.match(/(rgba?\([^)]+\))/);
294
+ const posMatch = stopStr.match(/(\d+)%/);
295
+ const color = colorMatch ? rgbToHex(colorMatch[1]) : "#ffffff";
296
+ const opacity = extractOpacity(colorMatch?.[1] || "");
297
+ return {
298
+ color,
299
+ position: posMatch ? parseInt(posMatch[1]) : 0,
300
+ opacity,
301
+ };
302
+ }) || [
303
+ { color: "#ffffff", position: 0, opacity: 100 },
304
+ { color: "#000000", position: 100, opacity: 100 },
305
+ ];
306
+
307
+ return {
308
+ type: "linear",
309
+ gradient: { stops, angle },
310
+ };
311
+ }
312
+
313
+ // ラジアルグラデーションの場合
314
+ if (backgroundImage && backgroundImage.includes("radial-gradient")) {
315
+ const stopsMatch = backgroundImage.match(/rgba?\([^)]+\)\s*\d+%/g);
316
+ const stops: GradientStop[] = stopsMatch?.map((stopStr) => {
317
+ const colorMatch = stopStr.match(/(rgba?\([^)]+\))/);
318
+ const posMatch = stopStr.match(/(\d+)%/);
319
+ const color = colorMatch ? rgbToHex(colorMatch[1]) : "#ffffff";
320
+ const opacity = extractOpacity(colorMatch?.[1] || "");
321
+ return {
322
+ color,
323
+ position: posMatch ? parseInt(posMatch[1]) : 0,
324
+ opacity,
325
+ };
326
+ }) || [
327
+ { color: "#ffffff", position: 0, opacity: 100 },
328
+ { color: "#000000", position: 100, opacity: 100 },
329
+ ];
330
+
331
+ return {
332
+ type: "radial",
333
+ gradient: { stops },
334
+ };
335
+ }
336
+
337
+ // ソリッドカラーの場合
338
+ if (backgroundColor && backgroundColor !== "transparent") {
339
+ return {
340
+ type: "solid",
341
+ color: rgbToHex(backgroundColor),
342
+ opacity: extractOpacity(backgroundColor),
343
+ };
344
+ }
345
+
346
+ return { type: "none" };
347
+ };
348
+
349
+ // デフォルトのプリセットカラー
350
+ const DEFAULT_PRESETS = [
351
+ "#000000",
352
+ "#333333",
353
+ "#666666",
354
+ "#999999",
355
+ "#cccccc",
356
+ "#ffffff",
357
+ "#ff0000",
358
+ "#ff6600",
359
+ "#ffcc00",
360
+ "#00ff00",
361
+ "#00ccff",
362
+ "#0066ff",
363
+ "#6600ff",
364
+ "#ff00ff",
365
+ "#ff0066",
366
+ ];
367
+
368
+ /**
369
+ * プレビューサムネイル
370
+ */
371
+ function FillPreview({
372
+ config,
373
+ className,
374
+ }: {
375
+ config: FillConfig;
376
+ className?: string;
377
+ }) {
378
+ const style = useMemo(() => fillConfigToCss(config), [config]);
379
+
380
+ return (
381
+ <div
382
+ className={cn(
383
+ "w-full h-full rounded border border-[#444444]",
384
+ config.type === "none" &&
385
+ "bg-[url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Crect width='4' height='4' fill='%23ccc'/%3E%3Crect x='4' y='4' width='4' height='4' fill='%23ccc'/%3E%3C/svg%3E\")]",
386
+ className,
387
+ )}
388
+ style={config.type !== "none" ? style : undefined}
389
+ />
390
+ );
391
+ }
392
+
393
+ /**
394
+ * カラースウォッチ
395
+ */
396
+ function ColorSwatch({
397
+ color,
398
+ isSelected,
399
+ onClick,
400
+ }: {
401
+ color: string;
402
+ isSelected?: boolean;
403
+ onClick: () => void;
404
+ }) {
405
+ return (
406
+ <button
407
+ className={cn(
408
+ "w-6 h-6 rounded border-2 transition-all hover:scale-110",
409
+ isSelected
410
+ ? "border-[#0d99ff] ring-2 ring-[#0d99ff]/50"
411
+ : "border-[#444444]",
412
+ )}
413
+ style={{ backgroundColor: color }}
414
+ onClick={onClick}
415
+ />
416
+ );
417
+ }
418
+
419
+ /**
420
+ * グラデーションストップエディター
421
+ */
422
+ function GradientStopEditor({
423
+ stops,
424
+ selectedIndex,
425
+ onStopsChange,
426
+ onSelectStop,
427
+ }: {
428
+ stops: GradientStop[];
429
+ selectedIndex: number;
430
+ onStopsChange: (stops: GradientStop[]) => void;
431
+ onSelectStop: (index: number) => void;
432
+ }) {
433
+ const gradientPreview = useMemo(() => {
434
+ return stopsToGradientString(stops);
435
+ }, [stops]);
436
+
437
+ const handleStopPositionChange = (index: number, position: number) => {
438
+ const newStops = [...stops];
439
+ newStops[index] = { ...newStops[index], position };
440
+ onStopsChange(newStops);
441
+ };
442
+
443
+ const handleAddStop = (e: React.MouseEvent<HTMLDivElement>) => {
444
+ const rect = e.currentTarget.getBoundingClientRect();
445
+ const position = Math.round(((e.clientX - rect.left) / rect.width) * 100);
446
+
447
+ // 近い2つのストップの間の色を補間
448
+ const sortedStops = [...stops].sort((a, b) => a.position - b.position);
449
+ let newColor = "#888888";
450
+
451
+ for (let i = 0; i < sortedStops.length - 1; i++) {
452
+ if (
453
+ position >= sortedStops[i].position &&
454
+ position <= sortedStops[i + 1].position
455
+ ) {
456
+ newColor = sortedStops[i].color;
457
+ break;
458
+ }
459
+ }
460
+
461
+ const newStops = [...stops, { color: newColor, position, opacity: 100 }];
462
+ onStopsChange(newStops);
463
+ onSelectStop(stops.length);
464
+ };
465
+
466
+ const handleRemoveStop = (index: number) => {
467
+ if (stops.length <= 2) return; // 最低2つ必要
468
+ const newStops = stops.filter((_, i) => i !== index);
469
+ onStopsChange(newStops);
470
+ if (selectedIndex >= newStops.length) {
471
+ onSelectStop(newStops.length - 1);
472
+ }
473
+ };
474
+
475
+ return (
476
+ <div className="space-y-2">
477
+ {/* グラデーションバー */}
478
+ <div
479
+ className="relative h-6 rounded cursor-crosshair"
480
+ style={{ background: `linear-gradient(to right, ${gradientPreview})` }}
481
+ onClick={handleAddStop}
482
+ >
483
+ {/* ストップハンドル */}
484
+ {stops.map((stop, index) => (
485
+ <div
486
+ key={index}
487
+ className={cn(
488
+ "absolute top-0 w-3 h-full cursor-pointer transform -translate-x-1/2",
489
+ "flex items-end justify-center",
490
+ )}
491
+ style={{ left: `${stop.position}%` }}
492
+ onClick={(e) => {
493
+ e.stopPropagation();
494
+ onSelectStop(index);
495
+ }}
496
+ >
497
+ <div
498
+ className={cn(
499
+ "w-3 h-3 rounded-full border-2 shadow",
500
+ selectedIndex === index ? "border-[#0d99ff]" : "border-white",
501
+ )}
502
+ style={{ backgroundColor: stop.color }}
503
+ />
504
+ </div>
505
+ ))}
506
+ </div>
507
+
508
+ {/* 選択中のストップのコントロール */}
509
+ {stops[selectedIndex] && (
510
+ <div className="flex items-center gap-2">
511
+ <div
512
+ className="w-6 h-6 rounded border border-[#444444]"
513
+ style={{ backgroundColor: stops[selectedIndex].color }}
514
+ />
515
+ <Input
516
+ type="text"
517
+ value={stops[selectedIndex].color}
518
+ onChange={(e) => {
519
+ const newStops = [...stops];
520
+ newStops[selectedIndex] = {
521
+ ...newStops[selectedIndex],
522
+ color: e.target.value,
523
+ };
524
+ onStopsChange(newStops);
525
+ }}
526
+ className="flex-1 h-7 text-xs bg-[#383838] border-[#444444] text-white font-mono"
527
+ />
528
+ <CompactNumberInput
529
+ value={stops[selectedIndex].position}
530
+ onChange={(position) =>
531
+ handleStopPositionChange(selectedIndex, position)
532
+ }
533
+ min={0}
534
+ max={100}
535
+ step={1}
536
+ suffix="%"
537
+ className="w-14"
538
+ />
539
+ {stops.length > 2 && (
540
+ <Button
541
+ variant="ghost"
542
+ size="icon"
543
+ onClick={() => handleRemoveStop(selectedIndex)}
544
+ className="h-7 w-7 text-gray-400 hover:text-red-400"
545
+ >
546
+ <Trash2 className="w-3 h-3" />
547
+ </Button>
548
+ )}
549
+ </div>
550
+ )}
551
+ </div>
552
+ );
553
+ }
554
+
555
+ /**
556
+ * メインコンポーネント
557
+ */
558
+ // デフォルトのグラデーション設定
559
+ const DEFAULT_GRADIENT = {
560
+ stops: [
561
+ { color: "#ffffff", position: 0, opacity: 100 },
562
+ { color: "#000000", position: 100, opacity: 100 },
563
+ ],
564
+ angle: 180,
565
+ };
566
+
567
+ export function FigmaColorPicker({
568
+ value,
569
+ onChange,
570
+ presetColors = DEFAULT_PRESETS,
571
+ showImageTab = true,
572
+ showVariablesTab = true,
573
+ label,
574
+ className,
575
+ presentationId,
576
+ slideId,
577
+ onClose,
578
+ onVariableSelect,
579
+ }: FigmaColorPickerProps) {
580
+ const [isOpen, setIsOpen] = useState(false);
581
+ const [activeTab, setActiveTab] = useState<FillType>(value.type || "solid");
582
+ const [selectedGradientStop, setSelectedGradientStop] = useState(0);
583
+ const [variableSearchQuery, setVariableSearchQuery] = useState("");
584
+ const [isCreatingVariable, setIsCreatingVariable] = useState(false);
585
+ const [newVariableName, setNewVariableName] = useState("");
586
+ const [newVariableValue, setNewVariableValue] = useState("");
587
+ const createVariableInputRef = useRef<HTMLInputElement>(null);
588
+
589
+ // CSS変数を取得
590
+ const { variables, addVariable } = useEditorVariables();
591
+
592
+ // 画像アップロード
593
+ const { isUploading, openFilePicker } = useImageUpload({
594
+ presentationId,
595
+ slideId,
596
+ });
597
+
598
+ // グラデーション設定(デフォルト値を含む)
599
+ const gradientConfig = useMemo(() => {
600
+ return value.gradient || DEFAULT_GRADIENT;
601
+ }, [value.gradient]);
602
+
603
+ // 現在の色(ソリッドまたはグラデーションの選択中ストップ)
604
+ const currentColor = useMemo(() => {
605
+ if (activeTab === "solid") {
606
+ return value.color || "#ffffff";
607
+ }
608
+ if (activeTab === "linear" || activeTab === "radial") {
609
+ return gradientConfig.stops[selectedGradientStop]?.color || "#ffffff";
610
+ }
611
+ return "#ffffff";
612
+ }, [value.color, activeTab, gradientConfig, selectedGradientStop]);
613
+
614
+ // 透明度
615
+ const currentOpacity = useMemo(() => {
616
+ if (activeTab === "solid") {
617
+ return value.opacity ?? 100;
618
+ }
619
+ if (activeTab === "linear" || activeTab === "radial") {
620
+ return gradientConfig.stops[selectedGradientStop]?.opacity ?? 100;
621
+ }
622
+ return 100;
623
+ }, [value.opacity, activeTab, gradientConfig, selectedGradientStop]);
624
+
625
+ // 色の変更
626
+ const handleColorChange = useCallback(
627
+ (newColor: string) => {
628
+ if (activeTab === "solid") {
629
+ // opacityを維持して更新
630
+ onChange({ ...value, type: "solid", color: newColor, opacity: currentOpacity });
631
+ } else if (activeTab === "linear" || activeTab === "radial") {
632
+ const currentGradient = value.gradient || DEFAULT_GRADIENT;
633
+ const newStops = [...currentGradient.stops];
634
+ newStops[selectedGradientStop] = {
635
+ ...newStops[selectedGradientStop],
636
+ color: newColor,
637
+ };
638
+ onChange({
639
+ ...value,
640
+ type: activeTab,
641
+ gradient: { ...currentGradient, stops: newStops },
642
+ });
643
+ }
644
+ },
645
+ [value, onChange, selectedGradientStop, activeTab, currentOpacity],
646
+ );
647
+
648
+ // 透明度の変更
649
+ const handleOpacityChange = useCallback(
650
+ (newOpacity: number) => {
651
+ if (activeTab === "solid") {
652
+ onChange({ ...value, type: "solid", opacity: newOpacity });
653
+ } else if (activeTab === "linear" || activeTab === "radial") {
654
+ const currentGradient = value.gradient || DEFAULT_GRADIENT;
655
+ const newStops = [...currentGradient.stops];
656
+ newStops[selectedGradientStop] = {
657
+ ...newStops[selectedGradientStop],
658
+ opacity: newOpacity,
659
+ };
660
+ onChange({
661
+ ...value,
662
+ type: activeTab,
663
+ gradient: { ...currentGradient, stops: newStops },
664
+ });
665
+ }
666
+ },
667
+ [value, onChange, selectedGradientStop, activeTab],
668
+ );
669
+
670
+ // タブ変更時の初期化
671
+ const handleTabChange = useCallback(
672
+ (tab: string) => {
673
+ const fillType = tab as FillType;
674
+ setActiveTab(fillType);
675
+
676
+ if (fillType === "solid") {
677
+ onChange({
678
+ type: "solid",
679
+ color: value.color || "#ffffff",
680
+ opacity: value.opacity ?? 100,
681
+ });
682
+ } else if (fillType === "linear") {
683
+ onChange({
684
+ type: "linear",
685
+ gradient: value.gradient || {
686
+ stops: [
687
+ { color: "#ffffff", position: 0, opacity: 100 },
688
+ { color: "#000000", position: 100, opacity: 100 },
689
+ ],
690
+ angle: 180,
691
+ },
692
+ });
693
+ } else if (fillType === "radial") {
694
+ onChange({
695
+ type: "radial",
696
+ gradient: value.gradient || {
697
+ stops: [
698
+ { color: "#ffffff", position: 0, opacity: 100 },
699
+ { color: "#000000", position: 100, opacity: 100 },
700
+ ],
701
+ },
702
+ });
703
+ } else if (fillType === "image") {
704
+ onChange({
705
+ type: "image",
706
+ image: value.image || { url: "", size: "cover", position: "center" },
707
+ });
708
+ } else if (fillType === "variable") {
709
+ // 変数タブに切り替え(選択するまでは値は変更しない)
710
+ // 既存の変数設定がある場合はそのまま維持
711
+ if (!value.variable) {
712
+ // 変数が選択されていない場合はタブ表示のみ
713
+ }
714
+ } else {
715
+ onChange({ type: "none" });
716
+ }
717
+ },
718
+ [onChange, value],
719
+ );
720
+
721
+ // 画像アップロード用のカスタムハンドラ
722
+ const handleImageUpload = useCallback(() => {
723
+ const input = document.createElement("input");
724
+ input.type = "file";
725
+ input.accept = "image/*";
726
+ input.onchange = async (e) => {
727
+ const file = (e.target as HTMLInputElement).files?.[0];
728
+ if (file) {
729
+ // FileReaderでDataURLに変換(ローカルプレビュー用)
730
+ const reader = new FileReader();
731
+ reader.onload = (e) => {
732
+ const dataUrl = e.target?.result as string;
733
+ onChange({
734
+ type: "image",
735
+ image: { url: dataUrl, size: "cover", position: "center" },
736
+ });
737
+ };
738
+ reader.readAsDataURL(file);
739
+ }
740
+ };
741
+ input.click();
742
+ }, [onChange]);
743
+
744
+ // スポイトツール(PPT風UIのリボンと同じ utils/eyedropper.ts を通す)
745
+ const handleEyeDropper = useCallback(async () => {
746
+ const hex = await pickScreenColor();
747
+ if (hex) handleColorChange(hex);
748
+ }, [handleColorChange]);
749
+
750
+ const eyeDropperSupported = isEyeDropperSupported();
751
+
752
+ // Popover開閉ハンドラ(閉じた時にonClose呼び出し)
753
+ const handleOpenChange = useCallback((open: boolean) => {
754
+ setIsOpen(open);
755
+ if (!open && onClose) {
756
+ requestAnimationFrame(() => {
757
+ onClose();
758
+ });
759
+ }
760
+ }, [onClose]);
761
+
762
+ // 色変数のフィルタリング
763
+ const filteredColorVariables = useMemo(() => {
764
+ let filtered = variables.filter(v => v.category === "color");
765
+ if (variableSearchQuery) {
766
+ const query = variableSearchQuery.toLowerCase();
767
+ filtered = filtered.filter(v =>
768
+ v.name.toLowerCase().includes(query) ||
769
+ v.cssName.toLowerCase().includes(query)
770
+ );
771
+ }
772
+ return filtered;
773
+ }, [variables, variableSearchQuery]);
774
+
775
+ // 現在の値が変数参照かどうかチェック
776
+ const isVariableLinked = value.type === "variable" && value.variable;
777
+
778
+ /**
779
+ * バインドを外し、いまの見た目の実値(ソリッド)に戻す。Figmaのデタッチ。
780
+ * 実値は登録簿の現在値を優先する(value.variable.value は選択時点の解決値で、
781
+ * トークンを編集した後だと古いことがある)。
782
+ */
783
+ const handleDetachVariable = useCallback(
784
+ (e: React.MouseEvent) => {
785
+ e.stopPropagation();
786
+ if (!(value.type === "variable" && value.variable)) return;
787
+ const registered = variables.find((v) => v.cssName === value.variable!.cssName);
788
+ const resolved = registered?.value || value.variable.value || "#ffffff";
789
+ onChange({ type: "solid", color: rgbToHex(resolved), opacity: 100 });
790
+ },
791
+ [value, variables, onChange],
792
+ );
793
+
794
+ // 変数選択ハンドラ
795
+ const handleVariableSelect = useCallback((variable: CSSVariableDefinition) => {
796
+ const varRef = generateVarReference(variable);
797
+ console.log('[DEBUG FigmaColorPicker] handleVariableSelect:', {
798
+ variable: { id: variable.id, name: variable.name, cssName: variable.cssName },
799
+ generatedVarReference: varRef,
800
+ hasOnVariableSelect: !!onVariableSelect,
801
+ });
802
+ if (onVariableSelect) {
803
+ // var(--xxx) 形式で返す
804
+ onVariableSelect(varRef);
805
+ } else {
806
+ // FillConfig形式で返す
807
+ onChange({
808
+ type: "variable",
809
+ variable: {
810
+ id: variable.id,
811
+ name: variable.name,
812
+ cssName: variable.cssName,
813
+ value: variable.value,
814
+ },
815
+ });
816
+ }
817
+ setIsOpen(false);
818
+ }, [onChange, onVariableSelect]);
819
+
820
+ // 現在の色から変数を作成
821
+ const handleCreateVariable = useCallback(() => {
822
+ if (!newVariableName.trim() || !addVariable) return;
823
+
824
+ // 既に変数がリンクされている場合は作成しない
825
+ if (isVariableLinked) return;
826
+
827
+ // 編集された値を使用(空の場合は現在の色を使う)
828
+ const colorValue = newVariableValue.trim() || currentColor;
829
+
830
+ // 変数を作成(カテゴリはcolor)
831
+ const newVar = addVariable(newVariableName.trim(), colorValue, 'color');
832
+
833
+ // 作成した変数を選択
834
+ const varRef = generateVarReference(newVar);
835
+ if (onVariableSelect) {
836
+ onVariableSelect(varRef);
837
+ } else {
838
+ onChange({
839
+ type: "variable",
840
+ variable: {
841
+ id: newVar.id,
842
+ name: newVar.name,
843
+ cssName: newVar.cssName,
844
+ value: newVar.value,
845
+ },
846
+ });
847
+ }
848
+
849
+ // UIをリセット
850
+ setNewVariableName('');
851
+ setNewVariableValue('');
852
+ setIsCreatingVariable(false);
853
+ setIsOpen(false);
854
+ }, [newVariableName, newVariableValue, addVariable, onChange, onVariableSelect, isVariableLinked, currentColor]);
855
+
856
+ // 変数作成UIを開く
857
+ const handleStartCreatingVariable = useCallback(() => {
858
+ setIsCreatingVariable(true);
859
+ setVariableSearchQuery('');
860
+ // 現在の色を初期値として設定
861
+ setNewVariableValue(currentColor);
862
+ setTimeout(() => createVariableInputRef.current?.focus(), 0);
863
+ }, [currentColor]);
864
+
865
+ // 変数作成をキャンセル
866
+ const handleCancelCreatingVariable = useCallback(() => {
867
+ setIsCreatingVariable(false);
868
+ setNewVariableName('');
869
+ setNewVariableValue('');
870
+ }, []);
871
+
872
+ return (
873
+ <div className={cn("space-y-1", className)}>
874
+ {label && <Label className="text-[10px] text-gray-500">{label}</Label>}
875
+
876
+ <Popover open={isOpen} onOpenChange={handleOpenChange}>
877
+ <PopoverTrigger asChild>
878
+ <Button
879
+ variant="ghost"
880
+ className="group/fill relative w-full h-8 p-1 justify-start gap-2 bg-[#383838] border border-[#444444] hover:bg-[#4a4a4a]"
881
+ >
882
+ <div className="w-6 h-6 flex-shrink-0">
883
+ <FillPreview config={value} />
884
+ </div>
885
+ <span className={cn(
886
+ "text-xs truncate",
887
+ value.type === "variable" ? "text-purple-300" : "text-gray-300"
888
+ )}>
889
+ {value.type === "none"
890
+ ? "なし"
891
+ : value.type === "solid"
892
+ ? value.color
893
+ : value.type === "linear"
894
+ ? "リニアグラデーション"
895
+ : value.type === "radial"
896
+ ? "ラジアルグラデーション"
897
+ : value.type === "image"
898
+ ? "画像"
899
+ : value.type === "variable"
900
+ ? value.variable?.name || "変数"
901
+ : ""}
902
+ </span>
903
+ {isVariableLinked && (
904
+ /* Figmaのデタッチ。ボタン内ボタンを避けるため span で受ける */
905
+ <span
906
+ role="button"
907
+ tabIndex={0}
908
+ title="変数の紐づけを解除(実値に戻す)"
909
+ onClick={handleDetachVariable}
910
+ onKeyDown={(e) => {
911
+ if (e.key === 'Enter' || e.key === ' ') handleDetachVariable(e as never);
912
+ }}
913
+ className="ml-auto mr-0.5 hidden rounded p-1 text-purple-300 hover:bg-white/10 hover:text-white group-hover/fill:block"
914
+ >
915
+ <Unlink className="h-3 w-3" />
916
+ </span>
917
+ )}
918
+ </Button>
919
+ </PopoverTrigger>
920
+
921
+ <PopoverContent
922
+ className="w-72 p-0 bg-[#2c2c2c] border-[#444444]"
923
+ align="start"
924
+ side="left"
925
+ onOpenAutoFocus={(e) => e.preventDefault()}
926
+ onInteractOutside={(e) => {
927
+ // スライダーのドラッグ中にポップオーバーが閉じないようにする
928
+ const target = e.target as HTMLElement;
929
+ if (target.closest('[role="slider"]')) {
930
+ e.preventDefault();
931
+ }
932
+ }}
933
+ >
934
+ <Tabs value={activeTab} onValueChange={handleTabChange}>
935
+ <TabsList className={cn(
936
+ "w-full grid h-9 bg-[#1e1e1e] rounded-none border-b border-[#444444]",
937
+ showVariablesTab && showImageTab ? "grid-cols-6" :
938
+ showVariablesTab || showImageTab ? "grid-cols-5" : "grid-cols-4"
939
+ )}>
940
+ <TabsTrigger
941
+ value="solid"
942
+ className="text-[10px] data-[state=active]:bg-[#383838]"
943
+ >
944
+ ソリッド
945
+ </TabsTrigger>
946
+ <TabsTrigger
947
+ value="linear"
948
+ className="text-[10px] data-[state=active]:bg-[#383838]"
949
+ >
950
+ リニア
951
+ </TabsTrigger>
952
+ <TabsTrigger
953
+ value="radial"
954
+ className="text-[10px] data-[state=active]:bg-[#383838]"
955
+ >
956
+ ラジアル
957
+ </TabsTrigger>
958
+ {showImageTab && (
959
+ <TabsTrigger
960
+ value="image"
961
+ className="text-[10px] data-[state=active]:bg-[#383838]"
962
+ >
963
+ 画像
964
+ </TabsTrigger>
965
+ )}
966
+ {showVariablesTab && (
967
+ <TabsTrigger
968
+ value="variable"
969
+ className="text-[10px] data-[state=active]:bg-[#383838]"
970
+ >
971
+ 変数
972
+ </TabsTrigger>
973
+ )}
974
+ <TabsTrigger
975
+ value="none"
976
+ className="text-[10px] data-[state=active]:bg-[#383838]"
977
+ >
978
+ なし
979
+ </TabsTrigger>
980
+ </TabsList>
981
+
982
+ {/* ソリッドカラー */}
983
+ <TabsContent value="solid" className="p-3 space-y-3 mt-0">
984
+ <HexColorPicker
985
+ color={currentColor}
986
+ onChange={handleColorChange}
987
+ style={{ width: "100%", height: 150 }}
988
+ />
989
+
990
+ {/* 色入力と透明度 */}
991
+ <div className="flex items-center gap-2">
992
+ <Button
993
+ variant="outline"
994
+ size="icon"
995
+ className="w-8 h-8 p-0 border-[#444444] bg-[#383838] hover:bg-[#4a4a4a]"
996
+ onClick={handleEyeDropper}
997
+ disabled={!eyeDropperSupported}
998
+ title={
999
+ eyeDropperSupported
1000
+ ? "スポイト (画面から色を取得)"
1001
+ : "このブラウザはスポイト機能をサポートしていません"
1002
+ }
1003
+ >
1004
+ <Pipette className="w-4 h-4 text-gray-300" />
1005
+ </Button>
1006
+ <div
1007
+ className="w-8 h-8 rounded border border-[#444444]"
1008
+ style={{
1009
+ backgroundColor: hexToRgba(currentColor, currentOpacity),
1010
+ }}
1011
+ />
1012
+ <HexColorInput
1013
+ color={currentColor}
1014
+ onChange={handleColorChange}
1015
+ prefixed
1016
+ className="flex-1 h-7 text-xs bg-[#383838] border border-[#444444] text-white font-mono px-2 rounded"
1017
+ />
1018
+ </div>
1019
+
1020
+ {/* 透明度 */}
1021
+ <div>
1022
+ <Label className="text-[10px] text-gray-500 mb-1 block">
1023
+ 透明度
1024
+ </Label>
1025
+ <div className="flex items-center gap-2">
1026
+ <Slider
1027
+ value={[currentOpacity]}
1028
+ onValueChange={([v]) => handleOpacityChange(v)}
1029
+ min={0}
1030
+ max={100}
1031
+ step={1}
1032
+ className="flex-1"
1033
+ />
1034
+ <CompactNumberInput
1035
+ value={currentOpacity}
1036
+ onChange={handleOpacityChange}
1037
+ min={0}
1038
+ max={100}
1039
+ step={1}
1040
+ suffix="%"
1041
+ className="w-14"
1042
+ />
1043
+ </div>
1044
+ </div>
1045
+
1046
+ {/* プリセットカラー */}
1047
+ <div>
1048
+ <Label className="text-[10px] text-gray-500 mb-2 block">
1049
+ プリセット
1050
+ </Label>
1051
+ <div className="flex flex-wrap gap-1">
1052
+ {presetColors.map((color, i) => (
1053
+ <ColorSwatch
1054
+ key={i}
1055
+ color={color}
1056
+ isSelected={
1057
+ currentColor.toLowerCase() === color.toLowerCase()
1058
+ }
1059
+ onClick={() => handleColorChange(color)}
1060
+ />
1061
+ ))}
1062
+ </div>
1063
+ </div>
1064
+ </TabsContent>
1065
+
1066
+ {/* リニアグラデーション */}
1067
+ <TabsContent value="linear" className="p-3 space-y-3 mt-0">
1068
+ <GradientStopEditor
1069
+ stops={gradientConfig.stops}
1070
+ selectedIndex={selectedGradientStop}
1071
+ onStopsChange={(stops) => {
1072
+ onChange({
1073
+ ...value,
1074
+ type: "linear",
1075
+ gradient: { ...gradientConfig, stops },
1076
+ });
1077
+ }}
1078
+ onSelectStop={setSelectedGradientStop}
1079
+ />
1080
+
1081
+ <HexColorPicker
1082
+ color={currentColor}
1083
+ onChange={handleColorChange}
1084
+ style={{ width: "100%", height: 120 }}
1085
+ />
1086
+
1087
+ {/* 角度 */}
1088
+ <div>
1089
+ <Label className="text-[10px] text-gray-500 mb-1 block">
1090
+ 角度
1091
+ </Label>
1092
+ <div className="flex items-center gap-2">
1093
+ <Slider
1094
+ value={[gradientConfig.angle ?? 180]}
1095
+ onValueChange={([v]) => {
1096
+ onChange({
1097
+ ...value,
1098
+ type: "linear",
1099
+ gradient: { ...gradientConfig, angle: v },
1100
+ });
1101
+ }}
1102
+ min={0}
1103
+ max={360}
1104
+ step={1}
1105
+ className="flex-1"
1106
+ />
1107
+ <CompactNumberInput
1108
+ value={gradientConfig.angle ?? 180}
1109
+ onChange={(angle) => {
1110
+ onChange({
1111
+ ...value,
1112
+ type: "linear",
1113
+ gradient: { ...gradientConfig, angle },
1114
+ });
1115
+ }}
1116
+ min={0}
1117
+ max={360}
1118
+ step={1}
1119
+ suffix="°"
1120
+ className="w-14"
1121
+ />
1122
+ </div>
1123
+ </div>
1124
+
1125
+ {/* 透明度 */}
1126
+ <div>
1127
+ <Label className="text-[10px] text-gray-500 mb-1 block">
1128
+ ストップ透明度
1129
+ </Label>
1130
+ <div className="flex items-center gap-2">
1131
+ <Slider
1132
+ value={[currentOpacity]}
1133
+ onValueChange={([v]) => handleOpacityChange(v)}
1134
+ min={0}
1135
+ max={100}
1136
+ step={1}
1137
+ className="flex-1"
1138
+ />
1139
+ <CompactNumberInput
1140
+ value={currentOpacity}
1141
+ onChange={handleOpacityChange}
1142
+ min={0}
1143
+ max={100}
1144
+ step={1}
1145
+ suffix="%"
1146
+ className="w-14"
1147
+ />
1148
+ </div>
1149
+ </div>
1150
+ </TabsContent>
1151
+
1152
+ {/* ラジアルグラデーション */}
1153
+ <TabsContent value="radial" className="p-3 space-y-3 mt-0">
1154
+ <GradientStopEditor
1155
+ stops={gradientConfig.stops}
1156
+ selectedIndex={selectedGradientStop}
1157
+ onStopsChange={(stops) => {
1158
+ onChange({
1159
+ ...value,
1160
+ type: "radial",
1161
+ gradient: { ...gradientConfig, stops },
1162
+ });
1163
+ }}
1164
+ onSelectStop={setSelectedGradientStop}
1165
+ />
1166
+
1167
+ <HexColorPicker
1168
+ color={currentColor}
1169
+ onChange={handleColorChange}
1170
+ style={{ width: "100%", height: 120 }}
1171
+ />
1172
+
1173
+ {/* 透明度 */}
1174
+ <div>
1175
+ <Label className="text-[10px] text-gray-500 mb-1 block">
1176
+ ストップ透明度
1177
+ </Label>
1178
+ <div className="flex items-center gap-2">
1179
+ <Slider
1180
+ value={[currentOpacity]}
1181
+ onValueChange={([v]) => handleOpacityChange(v)}
1182
+ min={0}
1183
+ max={100}
1184
+ step={1}
1185
+ className="flex-1"
1186
+ />
1187
+ <CompactNumberInput
1188
+ value={currentOpacity}
1189
+ onChange={handleOpacityChange}
1190
+ min={0}
1191
+ max={100}
1192
+ step={1}
1193
+ suffix="%"
1194
+ className="w-14"
1195
+ />
1196
+ </div>
1197
+ </div>
1198
+ </TabsContent>
1199
+
1200
+ {/* 画像 */}
1201
+ {showImageTab && (
1202
+ <TabsContent value="image" className="p-3 space-y-3 mt-0">
1203
+ {value.image?.url ? (
1204
+ <div className="space-y-3">
1205
+ {/* プレビュー */}
1206
+ <div
1207
+ className="w-full h-24 rounded border border-[#444444] bg-cover bg-center"
1208
+ style={{ backgroundImage: `url('${value.image.url}')` }}
1209
+ />
1210
+
1211
+ {/* URL入力 */}
1212
+ <div className="flex gap-1">
1213
+ <Input
1214
+ type="text"
1215
+ value={
1216
+ value.image.url.startsWith("data:")
1217
+ ? "(アップロード画像)"
1218
+ : value.image.url
1219
+ }
1220
+ onChange={(e) => {
1221
+ onChange({
1222
+ ...value,
1223
+ image: { ...value.image!, url: e.target.value },
1224
+ });
1225
+ }}
1226
+ placeholder="画像URL"
1227
+ className="flex-1 h-7 text-xs bg-[#383838] border-[#444444] text-white"
1228
+ readOnly={value.image.url.startsWith("data:")}
1229
+ />
1230
+ <Button
1231
+ variant="ghost"
1232
+ size="icon"
1233
+ onClick={() => onChange({ type: "none" })}
1234
+ className="h-7 w-7 text-gray-400 hover:text-red-400"
1235
+ >
1236
+ <Trash2 className="w-3 h-3" />
1237
+ </Button>
1238
+ </div>
1239
+
1240
+ {/* フィルモード */}
1241
+ <div>
1242
+ <Label className="text-[10px] text-gray-500 mb-1 block">
1243
+ フィルモード
1244
+ </Label>
1245
+ <Select
1246
+ value={value.image.size}
1247
+ onValueChange={(
1248
+ size:
1249
+ | "cover"
1250
+ | "contain"
1251
+ | "fill"
1252
+ | "tile"
1253
+ | "custom",
1254
+ ) => {
1255
+ onChange({
1256
+ ...value,
1257
+ image: {
1258
+ ...value.image!,
1259
+ size,
1260
+ // customの場合はデフォルト値を設定
1261
+ ...(size === "custom" && {
1262
+ customWidth: value.image!.customWidth ?? 100,
1263
+ customHeight: value.image!.customHeight ?? 100,
1264
+ }),
1265
+ },
1266
+ });
1267
+ }}
1268
+ >
1269
+ <SelectTrigger className="h-7 text-xs bg-[#383838] border-[#444444] text-white">
1270
+ <SelectValue />
1271
+ </SelectTrigger>
1272
+ <SelectContent>
1273
+ <SelectItem value="cover">
1274
+ トリミング(埋める)
1275
+ </SelectItem>
1276
+ <SelectItem value="contain">
1277
+ 自動(収める)
1278
+ </SelectItem>
1279
+ <SelectItem value="fill">
1280
+ 塗り(アスペクト比無視)
1281
+ </SelectItem>
1282
+ <SelectItem value="tile">
1283
+ タイル(繰り返し)
1284
+ </SelectItem>
1285
+ <SelectItem value="custom">カスタムサイズ</SelectItem>
1286
+ </SelectContent>
1287
+ </Select>
1288
+ </div>
1289
+
1290
+ {/* カスタムサイズ(customモード時のみ表示) */}
1291
+ {value.image.size === "custom" && (
1292
+ <div>
1293
+ <Label className="text-[10px] text-gray-500 mb-1 block">
1294
+ サイズ
1295
+ </Label>
1296
+ <div className="flex items-center gap-2">
1297
+ <span className="text-[10px] text-gray-500 w-4">
1298
+ W
1299
+ </span>
1300
+ <CompactNumberInput
1301
+ value={value.image.customWidth ?? 100}
1302
+ onChange={(customWidth) => {
1303
+ onChange({
1304
+ ...value,
1305
+ image: { ...value.image!, customWidth },
1306
+ });
1307
+ }}
1308
+ min={1}
1309
+ max={500}
1310
+ step={1}
1311
+ suffix="%"
1312
+ className="flex-1"
1313
+ />
1314
+ <span className="text-[10px] text-gray-500 w-4">
1315
+ H
1316
+ </span>
1317
+ <CompactNumberInput
1318
+ value={value.image.customHeight ?? 100}
1319
+ onChange={(customHeight) => {
1320
+ onChange({
1321
+ ...value,
1322
+ image: { ...value.image!, customHeight },
1323
+ });
1324
+ }}
1325
+ min={1}
1326
+ max={500}
1327
+ step={1}
1328
+ suffix="%"
1329
+ className="flex-1"
1330
+ />
1331
+ </div>
1332
+ </div>
1333
+ )}
1334
+
1335
+ {/* 位置 */}
1336
+ <div>
1337
+ <Label className="text-[10px] text-gray-500 mb-1 block">
1338
+ 位置
1339
+ </Label>
1340
+ <div className="flex items-center gap-2">
1341
+ <span className="text-[10px] text-gray-500 w-4">X</span>
1342
+ <CompactNumberInput
1343
+ value={value.image.positionX ?? 50}
1344
+ onChange={(positionX) => {
1345
+ onChange({
1346
+ ...value,
1347
+ image: { ...value.image!, positionX },
1348
+ });
1349
+ }}
1350
+ min={0}
1351
+ max={100}
1352
+ step={1}
1353
+ suffix="%"
1354
+ className="flex-1"
1355
+ />
1356
+ <span className="text-[10px] text-gray-500 w-4">Y</span>
1357
+ <CompactNumberInput
1358
+ value={value.image.positionY ?? 50}
1359
+ onChange={(positionY) => {
1360
+ onChange({
1361
+ ...value,
1362
+ image: { ...value.image!, positionY },
1363
+ });
1364
+ }}
1365
+ min={0}
1366
+ max={100}
1367
+ step={1}
1368
+ suffix="%"
1369
+ className="flex-1"
1370
+ />
1371
+ </div>
1372
+ </div>
1373
+ </div>
1374
+ ) : (
1375
+ <div className="space-y-3">
1376
+ <Button
1377
+ variant="outline"
1378
+ className="w-full h-20 border-dashed border-[#444444] hover:bg-[#383838]"
1379
+ onClick={handleImageUpload}
1380
+ >
1381
+ <div className="flex flex-col items-center gap-2 text-gray-400">
1382
+ <Upload className="w-6 h-6" />
1383
+ <span className="text-xs">画像をアップロード</span>
1384
+ </div>
1385
+ </Button>
1386
+
1387
+ <div className="text-center text-[10px] text-gray-500">
1388
+ または
1389
+ </div>
1390
+
1391
+ <Input
1392
+ type="text"
1393
+ placeholder="画像URLを入力..."
1394
+ onChange={(e) => {
1395
+ if (e.target.value) {
1396
+ onChange({
1397
+ type: "image",
1398
+ image: {
1399
+ url: e.target.value,
1400
+ size: "cover",
1401
+ position: "center",
1402
+ },
1403
+ });
1404
+ }
1405
+ }}
1406
+ className="h-7 text-xs bg-[#383838] border-[#444444] text-white"
1407
+ />
1408
+ </div>
1409
+ )}
1410
+ </TabsContent>
1411
+ )}
1412
+
1413
+ {/* 変数 */}
1414
+ {showVariablesTab && (
1415
+ <TabsContent value="variable" className="p-0 mt-0">
1416
+ {/* 検索 */}
1417
+ <div className="p-2 border-b border-[#444444]">
1418
+ <div className="relative">
1419
+ <Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-500" />
1420
+ <Input
1421
+ type="text"
1422
+ placeholder="変数を検索..."
1423
+ value={variableSearchQuery}
1424
+ onChange={(e) => setVariableSearchQuery(e.target.value)}
1425
+ className="h-7 pl-7 text-xs bg-[#383838] border-[#444444] text-white placeholder:text-gray-500"
1426
+ />
1427
+ </div>
1428
+ </div>
1429
+
1430
+ {/* カテゴリヘッダーと作成ボタン */}
1431
+ <div className="px-2 py-1.5 border-b border-[#444444] flex items-center justify-between">
1432
+ <span className="text-[10px] text-gray-500 uppercase">カラー変数</span>
1433
+ {!isCreatingVariable && !isVariableLinked && (
1434
+ <button
1435
+ type="button"
1436
+ onClick={handleStartCreatingVariable}
1437
+ className="flex items-center gap-1 px-1.5 py-0.5 text-[10px] text-gray-400 hover:text-white hover:bg-[#444444] rounded transition-colors"
1438
+ title="現在の色から変数を作成"
1439
+ >
1440
+ <Plus className="w-3 h-3" />
1441
+ <span>作成</span>
1442
+ </button>
1443
+ )}
1444
+ </div>
1445
+
1446
+ {/* 変数作成UI */}
1447
+ {isCreatingVariable && (
1448
+ <div className="p-2 border-b border-[#444444] bg-[#2a2a2a]">
1449
+ <div className="text-[10px] text-gray-400 mb-1.5">新しい変数を作成</div>
1450
+ <div className="space-y-2">
1451
+ {/* 変数名入力 */}
1452
+ <div className="flex items-center gap-1.5">
1453
+ <span className="text-[10px] text-gray-500 w-8 flex-shrink-0">名前</span>
1454
+ <Input
1455
+ ref={createVariableInputRef}
1456
+ type="text"
1457
+ placeholder="variable-name"
1458
+ value={newVariableName}
1459
+ onChange={(e) => setNewVariableName(e.target.value)}
1460
+ onKeyDown={(e) => {
1461
+ if (e.key === 'Enter') {
1462
+ handleCreateVariable();
1463
+ } else if (e.key === 'Escape') {
1464
+ handleCancelCreatingVariable();
1465
+ }
1466
+ }}
1467
+ className="h-6 text-[10px] bg-[#383838] border-[#444444] text-white placeholder:text-gray-600 flex-1"
1468
+ />
1469
+ </div>
1470
+ {/* 値を編集 */}
1471
+ <div className="flex items-center gap-1.5">
1472
+ <span className="text-[10px] text-gray-500 w-8 flex-shrink-0">値</span>
1473
+ <div className="flex-1 flex items-center gap-1">
1474
+ <div
1475
+ className="w-6 h-6 rounded border border-[#5d5d5d] flex-shrink-0"
1476
+ style={{ backgroundColor: newVariableValue || currentColor }}
1477
+ />
1478
+ <Input
1479
+ type="text"
1480
+ placeholder="#ffffff"
1481
+ value={newVariableValue}
1482
+ onChange={(e) => setNewVariableValue(e.target.value)}
1483
+ onKeyDown={(e) => {
1484
+ if (e.key === 'Enter') {
1485
+ handleCreateVariable();
1486
+ } else if (e.key === 'Escape') {
1487
+ handleCancelCreatingVariable();
1488
+ }
1489
+ }}
1490
+ className="h-6 text-[10px] bg-[#383838] border-[#444444] text-white placeholder:text-gray-600 flex-1 font-mono"
1491
+ />
1492
+ </div>
1493
+ </div>
1494
+ {/* 生成されるCSS変数名のプレビュー */}
1495
+ {newVariableName.trim() && (
1496
+ <div className="text-[9px] text-gray-500 font-mono">
1497
+ --color-{newVariableName.trim().toLowerCase().replace(/\s+/g, '-')}
1498
+ </div>
1499
+ )}
1500
+ {/* ボタン */}
1501
+ <div className="flex items-center gap-2 pt-1">
1502
+ <Button
1503
+ type="button"
1504
+ size="sm"
1505
+ onClick={handleCreateVariable}
1506
+ disabled={!newVariableName.trim()}
1507
+ className="h-6 px-3 text-[10px] bg-purple-600 hover:bg-purple-700 text-white"
1508
+ >
1509
+ 作成
1510
+ </Button>
1511
+ <Button
1512
+ type="button"
1513
+ size="sm"
1514
+ variant="ghost"
1515
+ onClick={handleCancelCreatingVariable}
1516
+ className="h-6 px-3 text-[10px] text-gray-400 hover:text-white hover:bg-[#444444]"
1517
+ >
1518
+ キャンセル
1519
+ </Button>
1520
+ </div>
1521
+ </div>
1522
+ </div>
1523
+ )}
1524
+
1525
+ {/* 変数リスト */}
1526
+ <ScrollArea className="h-48">
1527
+ {filteredColorVariables.length === 0 ? (
1528
+ <div className="p-4 text-center text-xs text-gray-500">
1529
+ {variableSearchQuery ? "検索結果がありません" : "カラー変数がありません"}
1530
+ </div>
1531
+ ) : (
1532
+ <div className="p-1">
1533
+ {filteredColorVariables.map((variable) => {
1534
+ const isSelected = value.variable?.id === variable.id;
1535
+ return (
1536
+ <button
1537
+ key={variable.id}
1538
+ onClick={() => handleVariableSelect(variable)}
1539
+ className={cn(
1540
+ "w-full flex items-center gap-2 px-2 py-1.5 rounded text-left",
1541
+ "hover:bg-[#444444] transition-colors",
1542
+ isSelected && "bg-purple-500/20"
1543
+ )}
1544
+ >
1545
+ <div
1546
+ className="w-4 h-4 rounded border border-[#5d5d5d] flex-shrink-0"
1547
+ style={{ backgroundColor: variable.value }}
1548
+ />
1549
+ <span className="text-xs text-white truncate flex-1">
1550
+ {variable.name}
1551
+ </span>
1552
+ <span className="text-[10px] text-gray-500 font-mono">
1553
+ {variable.value}
1554
+ </span>
1555
+ </button>
1556
+ );
1557
+ })}
1558
+ </div>
1559
+ )}
1560
+ </ScrollArea>
1561
+ </TabsContent>
1562
+ )}
1563
+
1564
+ {/* なし */}
1565
+ <TabsContent value="none" className="p-3 mt-0">
1566
+ <div className="text-center py-4 text-gray-500 text-xs">
1567
+ 塗りなし(透明)
1568
+ </div>
1569
+ </TabsContent>
1570
+ </Tabs>
1571
+ </PopoverContent>
1572
+ </Popover>
1573
+ </div>
1574
+ );
1575
+ }
1576
+
1577
+ export default FigmaColorPicker;