diff options
| author | Maze <167211895+mazeincoding@users.noreply.github.com> | 2026-01-31 00:20:04 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-01-31 00:20:04 +0100 |
| commit | 7bf0984698e43b9aa2dd879f65951318ed410390 (patch) | |
| tree | ccf2cd590a47d550152afc07129cb55c71a9ec35 /apps/web/src/components/editor/panels/properties/text-properties.tsx | |
| parent | 0173db99448d1c4c6415dcf15272e17551b48b04 (diff) | |
| download | opencut-7bf0984698e43b9aa2dd879f65951318ed410390.tar.gz | |
codebase overhaul (#697)
Diffstat (limited to 'apps/web/src/components/editor/panels/properties/text-properties.tsx')
| -rw-r--r-- | apps/web/src/components/editor/panels/properties/text-properties.tsx | 410 |
1 files changed, 410 insertions, 0 deletions
diff --git a/apps/web/src/components/editor/panels/properties/text-properties.tsx b/apps/web/src/components/editor/panels/properties/text-properties.tsx new file mode 100644 index 00000000..f8c9bf87 --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/text-properties.tsx @@ -0,0 +1,410 @@ +import { Textarea } from "@/components/ui/textarea"; +import { FontPicker } from "@/components/ui/font-picker"; +import type { FontFamily } from "@/constants/font-constants"; +import type { TextElement } from "@/types/timeline"; +import { Slider } from "@/components/ui/slider"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { useState, useRef } from "react"; +import { PanelBaseView } from "@/components/editor/panels/panel-base-view"; +import { + TEXT_PROPERTIES_TABS, + isTextPropertiesTab, + useTextPropertiesStore, +} from "@/stores/text-properties-store"; +import { + PropertyItem, + PropertyItemLabel, + PropertyItemValue, +} from "./property-item"; +import { ColorPicker } from "@/components/ui/color-picker"; +import { cn } from "@/utils/ui"; +import { capitalizeFirstLetter, uppercase } from "@/utils/string"; +import { clamp } from "@/utils/math"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { LayoutGridIcon } from "@hugeicons/core-free-icons"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { useEditor } from "@/hooks/use-editor"; +import { DEFAULT_COLOR } from "@/constants/project-constants"; + +export function TextProperties({ + element, + trackId, +}: { + element: TextElement; + trackId: string; +}) { + const editor = useEditor(); + const { activeTab, setActiveTab } = useTextPropertiesStore(); + const containerRef = useRef<HTMLDivElement>(null); + const [fontSizeInput, setFontSizeInput] = useState( + element.fontSize.toString(), + ); + const [opacityInput, setOpacityInput] = useState( + Math.round(element.opacity * 100).toString(), + ); + + const lastSelectedColor = useRef(DEFAULT_COLOR); + + const handleFontSizeChange = ({ value }: { value: string }) => { + setFontSizeInput(value); + + if (value.trim() !== "") { + const parsed = parseInt(value, 10); + const fontSize = Number.isNaN(parsed) + ? element.fontSize + : clamp({ value: parsed, min: 8, max: 300 }); + editor.timeline.updateTextElement({ + trackId, + elementId: element.id, + updates: { fontSize }, + }); + } + }; + + const handleFontSizeBlur = () => { + const parsed = parseInt(fontSizeInput, 10); + const fontSize = Number.isNaN(parsed) + ? element.fontSize + : clamp({ value: parsed, min: 8, max: 300 }); + setFontSizeInput(fontSize.toString()); + editor.timeline.updateTextElement({ + trackId, + elementId: element.id, + updates: { fontSize }, + }); + }; + + const handleOpacityChange = ({ value }: { value: string }) => { + setOpacityInput(value); + + if (value.trim() !== "") { + const parsed = parseInt(value, 10); + const opacityPercent = Number.isNaN(parsed) + ? Math.round(element.opacity * 100) + : clamp({ value: parsed, min: 0, max: 100 }); + editor.timeline.updateTextElement({ + trackId, + elementId: element.id, + updates: { opacity: opacityPercent / 100 }, + }); + } + }; + + const handleOpacityBlur = () => { + const parsed = parseInt(opacityInput, 10); + const opacityPercent = Number.isNaN(parsed) + ? Math.round(element.opacity * 100) + : clamp({ value: parsed, min: 0, max: 100 }); + setOpacityInput(opacityPercent.toString()); + editor.timeline.updateTextElement({ + trackId, + elementId: element.id, + updates: { opacity: opacityPercent / 100 }, + }); + }; + + const handleColorChange = ({ color }: { color: string }) => { + if (color !== "transparent") { + lastSelectedColor.current = color; + } + editor.timeline.updateTextElement({ + trackId, + elementId: element.id, + updates: { backgroundColor: color }, + }); + }; + + const handleTransparentToggle = ({ + isTransparent, + }: { + isTransparent: boolean; + }) => { + const newColor = isTransparent ? "transparent" : lastSelectedColor.current; + editor.timeline.updateTextElement({ + trackId, + elementId: element.id, + updates: { backgroundColor: newColor }, + }); + }; + + return ( + <PanelBaseView + defaultTab="transform" + value={activeTab} + onValueChange={(v) => { + if (isTextPropertiesTab(v)) setActiveTab(v); + }} + ref={containerRef} + tabs={TEXT_PROPERTIES_TABS.map((t) => ({ + value: t.value, + label: t.label, + content: + t.value === "transform" ? ( + <div className="space-y-6"></div> + ) : ( + <div className="space-y-6"> + <Textarea + placeholder="Name" + defaultValue={element.content} + className="bg-panel-accent min-h-20" + onChange={(e) => + editor.timeline.updateTextElement({ + trackId, + elementId: element.id, + updates: { content: e.target.value }, + }) + } + /> + <PropertyItem direction="column"> + <PropertyItemLabel>Font</PropertyItemLabel> + <PropertyItemValue> + <FontPicker + defaultValue={element.fontFamily} + onValueChange={(value: FontFamily) => + editor.timeline.updateTextElement({ + trackId, + elementId: element.id, + updates: { fontFamily: value }, + }) + } + /> + </PropertyItemValue> + </PropertyItem> + <PropertyItem direction="column"> + <PropertyItemLabel>Style</PropertyItemLabel> + <PropertyItemValue> + <div className="flex items-center gap-2"> + <Button + variant={ + element.fontWeight === "bold" ? "default" : "outline" + } + size="sm" + onClick={() => + editor.timeline.updateTextElement({ + trackId, + elementId: element.id, + updates: { + fontWeight: + element.fontWeight === "bold" ? "normal" : "bold", + }, + }) + } + className="h-8 px-3 font-bold" + > + B + </Button> + <Button + variant={ + element.fontStyle === "italic" ? "default" : "outline" + } + size="sm" + onClick={() => + editor.timeline.updateTextElement({ + trackId, + elementId: element.id, + updates: { + fontStyle: + element.fontStyle === "italic" + ? "normal" + : "italic", + }, + }) + } + className="h-8 px-3 italic" + > + I + </Button> + <Button + variant={ + element.textDecoration === "underline" + ? "default" + : "outline" + } + size="sm" + onClick={() => + editor.timeline.updateTextElement({ + trackId, + elementId: element.id, + updates: { + textDecoration: + element.textDecoration === "underline" + ? "none" + : "underline", + }, + }) + } + className="h-8 px-3 underline" + > + U + </Button> + <Button + variant={ + element.textDecoration === "line-through" + ? "default" + : "outline" + } + size="sm" + onClick={() => + editor.timeline.updateTextElement({ + trackId, + elementId: element.id, + updates: { + textDecoration: + element.textDecoration === "line-through" + ? "none" + : "line-through", + }, + }) + } + className="h-8 px-3 line-through" + > + S + </Button> + </div> + </PropertyItemValue> + </PropertyItem> + <PropertyItem direction="column"> + <PropertyItemLabel>Font size</PropertyItemLabel> + <PropertyItemValue> + <div className="flex items-center gap-2"> + <Slider + value={[element.fontSize]} + min={8} + max={300} + step={1} + onValueChange={([value]) => { + editor.timeline.updateTextElement({ + trackId, + elementId: element.id, + updates: { fontSize: value }, + }); + setFontSizeInput(value.toString()); + }} + className="w-full" + /> + <Input + type="number" + value={fontSizeInput} + min={8} + max={300} + onChange={(e) => + handleFontSizeChange({ value: e.target.value }) + } + onBlur={handleFontSizeBlur} + className="bg-panel-accent h-7 w-12 [appearance:textfield] rounded-sm px-2 text-center !text-xs [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" + /> + </div> + </PropertyItemValue> + </PropertyItem> + <PropertyItem direction="column"> + <PropertyItemLabel>Color</PropertyItemLabel> + <PropertyItemValue> + <ColorPicker + value={uppercase({ + string: (element.color || "FFFFFF").replace("#", ""), + })} + onChange={(color) => { + editor.timeline.updateTextElement({ + trackId, + elementId: element.id, + updates: { color: `#${color}` }, + }); + }} + containerRef={containerRef} + /> + </PropertyItemValue> + </PropertyItem> + <PropertyItem direction="column"> + <PropertyItemLabel>Opacity</PropertyItemLabel> + <PropertyItemValue> + <div className="flex items-center gap-2"> + <Slider + value={[element.opacity * 100]} + min={0} + max={100} + step={1} + onValueChange={([value]) => { + editor.timeline.updateTextElement({ + trackId, + elementId: element.id, + updates: { opacity: value / 100 }, + }); + setOpacityInput(value.toString()); + }} + className="w-full" + /> + <Input + type="number" + value={opacityInput} + min={0} + max={100} + onChange={(e) => + handleOpacityChange({ value: e.target.value }) + } + onBlur={handleOpacityBlur} + className="bg-panel-accent h-7 w-12 [appearance:textfield] rounded-sm text-center !text-xs [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" + /> + </div> + </PropertyItemValue> + </PropertyItem> + <PropertyItem direction="column"> + <PropertyItemLabel>Background</PropertyItemLabel> + <PropertyItemValue> + <div className="flex items-center gap-2"> + <ColorPicker + value={capitalizeFirstLetter({ + string: + element.backgroundColor === "transparent" + ? lastSelectedColor.current.replace("#", "") + : element.backgroundColor.replace("#", ""), + })} + onChange={(color) => + handleColorChange({ color: `#${color}` }) + } + containerRef={containerRef} + className={ + element.backgroundColor === "transparent" + ? "pointer-events-none opacity-50" + : "" + } + /> + + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="outline" + size="icon" + onClick={() => + handleTransparentToggle({ + isTransparent: + element.backgroundColor !== "transparent", + }) + } + className="bg-panel-accent size-9 overflow-hidden rounded-full p-0" + > + <HugeiconsIcon + icon={LayoutGridIcon} + className={cn( + "text-foreground", + element.backgroundColor === "transparent" && + "text-primary", + )} + /> + </Button> + </TooltipTrigger> + <TooltipContent>Transparent background</TooltipContent> + </Tooltip> + </div> + </PropertyItemValue> + </PropertyItem> + </div> + ), + }))} + /> + ); +} |
