@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.
- package/LICENSE +21 -0
- package/NOTICE +52 -0
- package/README.md +143 -0
- package/dist/editor.css +2 -0
- package/package.json +94 -0
- package/src/components/DeckSlideRender.tsx +21 -0
- package/src/components/SaveNote.tsx +21 -0
- package/src/components/auth/AuthProvider.tsx +44 -0
- package/src/components/ui/accordion.tsx +57 -0
- package/src/components/ui/alert-dialog.tsx +141 -0
- package/src/components/ui/badge.tsx +36 -0
- package/src/components/ui/button.tsx +56 -0
- package/src/components/ui/card.tsx +76 -0
- package/src/components/ui/checkbox.tsx +30 -0
- package/src/components/ui/collapsible.tsx +11 -0
- package/src/components/ui/context-menu.tsx +200 -0
- package/src/components/ui/dialog.tsx +122 -0
- package/src/components/ui/dropdown-menu.tsx +201 -0
- package/src/components/ui/input.tsx +22 -0
- package/src/components/ui/label.tsx +26 -0
- package/src/components/ui/popover.tsx +33 -0
- package/src/components/ui/scroll-area.tsx +48 -0
- package/src/components/ui/scrubbable-label.tsx +95 -0
- package/src/components/ui/select.tsx +159 -0
- package/src/components/ui/separator.tsx +31 -0
- package/src/components/ui/slider.tsx +28 -0
- package/src/components/ui/switch.tsx +29 -0
- package/src/components/ui/tabs.tsx +55 -0
- package/src/components/ui/textarea.tsx +22 -0
- package/src/components/ui/tooltip.tsx +32 -0
- package/src/components/viewer/useDeck.ts +58 -0
- package/src/editor/EditorContext.tsx +632 -0
- package/src/editor/EditorToolbar.tsx +446 -0
- package/src/editor/FrontendVisualEditor.tsx +2595 -0
- package/src/editor/autosave.ts +34 -0
- package/src/editor/components/AiPromptPopover.tsx +378 -0
- package/src/editor/components/BreakpointGuides.tsx +323 -0
- package/src/editor/components/BreakpointSelector.tsx +180 -0
- package/src/editor/components/ComponentPropertyEditor.tsx +426 -0
- package/src/editor/components/CssEditorDialog.tsx +168 -0
- package/src/editor/components/EditorCanvas.tsx +987 -0
- package/src/editor/components/EditorContextMenu.tsx +515 -0
- package/src/editor/components/EditorFooter.tsx +81 -0
- package/src/editor/components/EditorHeader.tsx +524 -0
- package/src/editor/components/EditorLayerPanel.tsx +1197 -0
- package/src/editor/components/EditorPropertyPanel.tsx +2996 -0
- package/src/editor/components/EditorSidebar.tsx +226 -0
- package/src/editor/components/FigmaColorPicker.tsx +1577 -0
- package/src/editor/components/HtmlEditorDialog.tsx +71 -0
- package/src/editor/components/HtmlImportDialog.tsx +346 -0
- package/src/editor/components/JsEditorDialog.tsx +175 -0
- package/src/editor/components/LayerTreeItem.tsx +494 -0
- package/src/editor/components/MasterComponentEditor.tsx +846 -0
- package/src/editor/components/MediaLibraryDialog.tsx +447 -0
- package/src/editor/components/PageSettingsDialog.tsx +776 -0
- package/src/editor/components/VariablePicker.tsx +355 -0
- package/src/editor/components/VariablesDialog.tsx +564 -0
- package/src/editor/components/VariantEditor.tsx +687 -0
- package/src/editor/components/VariantMatrix.tsx +507 -0
- package/src/editor/components/component-panel/CategoryAccordion.tsx +139 -0
- package/src/editor/components/component-panel/ComponentGrid.tsx +51 -0
- package/src/editor/components/component-panel/ComponentItem.tsx +368 -0
- package/src/editor/components/component-panel/ComponentPanel.tsx +275 -0
- package/src/editor/components/component-panel/ComponentSearch.tsx +82 -0
- package/src/editor/components/component-panel/VariantList.tsx +260 -0
- package/src/editor/components/component-panel/index.ts +12 -0
- package/src/editor/components/index.ts +48 -0
- package/src/editor/components/multi-page/CanvasZoomControls.tsx +71 -0
- package/src/editor/components/multi-page/InfiniteCanvas.tsx +95 -0
- package/src/editor/components/multi-page/MultiPageCanvasView.tsx +1404 -0
- package/src/editor/components/multi-page/PageFrameOverlay.tsx +76 -0
- package/src/editor/components/multi-page/PageLabel.tsx +34 -0
- package/src/editor/components/multi-page/PageLivePreview.tsx +242 -0
- package/src/editor/components/multi-page/PageThumbnail.tsx +59 -0
- package/src/editor/components/multi-page/index.ts +7 -0
- package/src/editor/components/ppt/PptChrome.tsx +2508 -0
- package/src/editor/components/ppt/PptComments.tsx +589 -0
- package/src/editor/components/ppt/PptDesignProposals.tsx +275 -0
- package/src/editor/components/ppt/PptFormatPane.tsx +320 -0
- package/src/editor/components/ppt/PptNotes.tsx +246 -0
- package/src/editor/components/property-panel/AlignmentPanel.tsx +110 -0
- package/src/editor/components/property-panel/AutoLayoutPanel.tsx +615 -0
- package/src/editor/components/property-panel/BorderSection.tsx +104 -0
- package/src/editor/components/property-panel/CompactNumberInput.tsx +237 -0
- package/src/editor/components/property-panel/CompactSizeInput.tsx +148 -0
- package/src/editor/components/property-panel/EffectSection.tsx +157 -0
- package/src/editor/components/property-panel/FillSection.tsx +69 -0
- package/src/editor/components/property-panel/GoogleFontPicker.tsx +192 -0
- package/src/editor/components/property-panel/ImageSection.tsx +206 -0
- package/src/editor/components/property-panel/ImgSrcSection.tsx +125 -0
- package/src/editor/components/property-panel/InstanceOverrideSection.tsx +413 -0
- package/src/editor/components/property-panel/LayoutSection.tsx +105 -0
- package/src/editor/components/property-panel/LinkSection.tsx +103 -0
- package/src/editor/components/property-panel/PropertyVariablePicker.tsx +311 -0
- package/src/editor/components/property-panel/ScalePanel.tsx +212 -0
- package/src/editor/components/property-panel/TypographySection.tsx +230 -0
- package/src/editor/components/property-panel/UnitAwareNumberInput.tsx +238 -0
- package/src/editor/components/property-panel/VariableAwareColorInput.tsx +477 -0
- package/src/editor/components/property-panel/VariableAwareInput.tsx +606 -0
- package/src/editor/components/property-panel/VariableAwareSizeInput.tsx +588 -0
- package/src/editor/components/property-panel/VariableAwareUnitInput.tsx +546 -0
- package/src/editor/components/property-panel/index.ts +24 -0
- package/src/editor/components/property-panel/unit-utils.ts +261 -0
- package/src/editor/components/shell/EditorTopBar.tsx +392 -0
- package/src/editor/components/shell/LeftPanel.tsx +47 -0
- package/src/editor/components/shell/PagesPanel.tsx +118 -0
- package/src/editor/components/variables-panel/ColorValueCell.tsx +165 -0
- package/src/editor/components/variables-panel/TextValueCell.tsx +116 -0
- package/src/editor/components/variables-panel/VariablesPanel.tsx +518 -0
- package/src/editor/components/variables-panel/index.ts +7 -0
- package/src/editor/constants.ts +468 -0
- package/src/editor/contexts/EditorArtboardContext.tsx +229 -0
- package/src/editor/contexts/EditorComponentsContext.tsx +1320 -0
- package/src/editor/contexts/EditorDocumentContext.tsx +113 -0
- package/src/editor/contexts/EditorHistoryContext.tsx +516 -0
- package/src/editor/contexts/EditorRefsContext.tsx +204 -0
- package/src/editor/contexts/EditorSelectionContext.tsx +54 -0
- package/src/editor/contexts/EditorToolContext.tsx +58 -0
- package/src/editor/contexts/EditorUIStateContext.tsx +122 -0
- package/src/editor/contexts/EditorVariablesContext.tsx +397 -0
- package/src/editor/contexts/EditorViewContext.tsx +101 -0
- package/src/editor/contexts/MultiPageCanvasContext.tsx +498 -0
- package/src/editor/contexts/index.ts +17 -0
- package/src/editor/editor-skin.css +122 -0
- package/src/editor/hooks/index.ts +38 -0
- package/src/editor/hooks/useAiReplace.ts +397 -0
- package/src/editor/hooks/useAltMeasure.ts +67 -0
- package/src/editor/hooks/useBrowserZoomPrevention.ts +121 -0
- package/src/editor/hooks/useCanvasControls.ts +1222 -0
- package/src/editor/hooks/useComponentEditMode.ts +658 -0
- package/src/editor/hooks/useComponentInstances.ts +515 -0
- package/src/editor/hooks/useContextMenuHandler.ts +157 -0
- package/src/editor/hooks/useCoordinateTransform.ts +207 -0
- package/src/editor/hooks/useDragResize.ts +1317 -0
- package/src/editor/hooks/useDrawingMode.ts +472 -0
- package/src/editor/hooks/useEditorColors.ts +144 -0
- package/src/editor/hooks/useEditorMessages.ts +108 -0
- package/src/editor/hooks/useElementActions.ts +1754 -0
- package/src/editor/hooks/useElementSelection.ts +1573 -0
- package/src/editor/hooks/useFocusManagement.ts +249 -0
- package/src/editor/hooks/useGoogleFonts.ts +305 -0
- package/src/editor/hooks/useIframeInitializer.ts +584 -0
- package/src/editor/hooks/useIframeSetup.ts +360 -0
- package/src/editor/hooks/useImageUpload.ts +270 -0
- package/src/editor/hooks/useInfiniteCanvas.ts +301 -0
- package/src/editor/hooks/useKeyboardShortcuts.ts +205 -0
- package/src/editor/hooks/useMarqueeSelection.ts +388 -0
- package/src/editor/hooks/useMediaLibrary.ts +129 -0
- package/src/editor/hooks/usePageSettingsManager.ts +739 -0
- package/src/editor/hooks/useResizablePanel.ts +163 -0
- package/src/editor/hooks/useRichPaste.ts +255 -0
- package/src/editor/hooks/useTouchGestures.ts +197 -0
- package/src/editor/index.ts +27 -0
- package/src/editor/types/page-settings.ts +513 -0
- package/src/editor/types.ts +382 -0
- package/src/editor/utils/align-elements.ts +169 -0
- package/src/editor/utils/api-json.ts +21 -0
- package/src/editor/utils/aspect-lock.ts +50 -0
- package/src/editor/utils/component-renderer.ts +702 -0
- package/src/editor/utils/component-sync.ts +845 -0
- package/src/editor/utils/crop-mode.ts +265 -0
- package/src/editor/utils/dom-utils.ts +2238 -0
- package/src/editor/utils/element-effects.ts +218 -0
- package/src/editor/utils/eyedropper.ts +35 -0
- package/src/editor/utils/figma-export.ts +560 -0
- package/src/editor/utils/figma-kiwi-decoder.ts +722 -0
- package/src/editor/utils/figma-kiwi-encoder.ts +583 -0
- package/src/editor/utils/figma-paste.ts +2262 -0
- package/src/editor/utils/flex-reorder.ts +231 -0
- package/src/editor/utils/flex-utils.ts +1230 -0
- package/src/editor/utils/geometry.ts +232 -0
- package/src/editor/utils/grid-layout.ts +210 -0
- package/src/editor/utils/html-utils.ts +470 -0
- package/src/editor/utils/index.ts +11 -0
- package/src/editor/utils/ink-style.ts +57 -0
- package/src/editor/utils/inline-format.ts +236 -0
- package/src/editor/utils/measure-distance.ts +120 -0
- package/src/editor/utils/override-apply.ts +526 -0
- package/src/editor/utils/override-detection.ts +613 -0
- package/src/editor/utils/paste-processors.ts +609 -0
- package/src/editor/utils/paste-sanitizer.ts +244 -0
- package/src/editor/utils/restore-flow.ts +188 -0
- package/src/editor/utils/shape-library.ts +171 -0
- package/src/editor/utils/slide-root.ts +29 -0
- package/src/editor/utils/smart-guides.ts +218 -0
- package/src/editor/utils/style-utils.ts +685 -0
- package/src/editor/utils/table-edit.ts +151 -0
- package/src/editor/utils/tailwind-mappings.ts +778 -0
- package/src/editor/utils/tailwind-utils.ts +804 -0
- package/src/editor/utils/text-highlight.ts +216 -0
- package/src/editor/utils/theme-to-variables.ts +341 -0
- package/src/editor/utils/variant-resolver.ts +456 -0
- package/src/editor/utils/viewport-utils.ts +295 -0
- package/src/hooks/useEditorHistory.ts +195 -0
- package/src/hooks/useEditorShortcuts.ts +260 -0
- package/src/index.ts +40 -0
- package/src/io.ts +134 -0
- package/src/lib/agent/slide-agent/types.ts +547 -0
- package/src/lib/agent/website-agent/types.ts +805 -0
- package/src/lib/api/auth-fetch.ts +58 -0
- package/src/lib/deck.ts +69 -0
- package/src/lib/export.ts +47 -0
- package/src/lib/firebase/config.ts +29 -0
- package/src/lib/firebase/css-variables.ts +378 -0
- package/src/lib/firebase/editor-components.ts +677 -0
- package/src/lib/firebase/storage.ts +469 -0
- package/src/lib/utils.ts +6 -0
- package/src/styles/editor.css +52 -0
- package/src/styles/skin.css +122 -0
- package/src/types/css-variables.ts +645 -0
- package/src/types/editor-components.ts +944 -0
- package/src/types/editor.ts +420 -0
- package/src/types/page-master.ts +197 -0
- package/src/types/page.ts +419 -0
- package/src/types/slide.ts +402 -0
- package/src/types/website-theme.ts +788 -0
- package/src/types/website.ts +374 -0
- package/src/vendor/firebase-firestore.ts +13 -0
- 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
|
+
});
|