diff options
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 | 806 |
1 files changed, 209 insertions, 597 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 index 22e50e4e..cfca52dc 100644 --- a/apps/web/src/components/editor/panels/properties/text-properties.tsx +++ b/apps/web/src/components/editor/panels/properties/text-properties.tsx @@ -1,24 +1,31 @@ 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 { useReducer, useRef } from "react"; -import { PanelBaseView } from "@/components/editor/panels/panel-base-view"; -import { - PropertyGroup, - PropertyItem, - PropertyItemLabel, - PropertyItemValue, -} from "./property-item"; +import { NumberField } from "@/components/ui/number-field"; +import { useRef } from "react"; +import { Section, SectionContent, SectionHeader } from "./section"; import { ColorPicker } from "@/components/ui/color-picker"; import { uppercase } from "@/utils/string"; import { clamp } from "@/utils/math"; import { useEditor } from "@/hooks/use-editor"; import { DEFAULT_COLOR } from "@/constants/project-constants"; -import { MIN_FONT_SIZE, MAX_FONT_SIZE } from "@/constants/text-constants"; +import { + DEFAULT_TEXT_ELEMENT, + MAX_FONT_SIZE, + MIN_FONT_SIZE, +} from "@/constants/text-constants"; +import { usePropertyDraft } from "./hooks/use-property-draft"; +import { TransformSection, BlendingSection } from "./sections"; +import { + Select, + SelectTrigger, + SelectValue, + SelectContent, + SelectItem, +} from "@/components/ui/select"; +import { OcFontWeightIcon } from "@opencut/ui/icons"; +import { Label } from "@/components/ui/label"; export function TextProperties({ element, @@ -27,624 +34,229 @@ export function TextProperties({ element: TextElement; trackId: string; }) { - const editor = useEditor(); - const containerRef = useRef<HTMLDivElement>(null); - const [, forceRender] = useReducer((x: number) => x + 1, 0); - const isEditingFontSize = useRef(false); - const isEditingOpacity = useRef(false); - const isEditingContent = useRef(false); - const fontSizeDraft = useRef(""); - const opacityDraft = useRef(""); - const contentDraft = useRef(""); - - const fontSizeDisplay = isEditingFontSize.current - ? fontSizeDraft.current - : element.fontSize.toString(); - const opacityDisplay = isEditingOpacity.current - ? opacityDraft.current - : Math.round(element.opacity * 100).toString(); - const contentDisplay = isEditingContent.current - ? contentDraft.current - : element.content; - - const lastSelectedColor = useRef(DEFAULT_COLOR); - const initialFontSizeRef = useRef<number | null>(null); - const initialOpacityRef = useRef<number | null>(null); - const initialContentRef = useRef<string | null>(null); - const initialColorRef = useRef<string | null>(null); - const initialBgColorRef = useRef<string | null>(null); - - const handleFontSizeChange = ({ value }: { value: string }) => { - fontSizeDraft.current = value; - forceRender(); + return ( + <div className="flex h-full flex-col"> + <ContentSection element={element} trackId={trackId} /> + <TransformSection element={element} trackId={trackId} /> + <BlendingSection element={element} trackId={trackId} /> + <FontSection element={element} trackId={trackId} /> + <ColorSection element={element} trackId={trackId} /> + </div> + ); +} - if (value.trim() !== "") { - if (initialFontSizeRef.current === null) { - initialFontSizeRef.current = element.fontSize; - } - const parsed = parseInt(value, 10); - const fontSize = Number.isNaN(parsed) - ? element.fontSize - : clamp({ value: parsed, min: MIN_FONT_SIZE, max: MAX_FONT_SIZE }); - editor.timeline.updateElements({ - updates: [{ trackId, elementId: element.id, updates: { fontSize } }], - pushHistory: false, - }); - } - }; +function ContentSection({ + element, + trackId, +}: { + element: TextElement; + trackId: string; +}) { + const editor = useEditor(); - const handleFontSizeBlur = () => { - if (initialFontSizeRef.current !== null) { - const parsed = parseInt(fontSizeDraft.current, 10); - const fontSize = Number.isNaN(parsed) - ? element.fontSize - : clamp({ value: parsed, min: MIN_FONT_SIZE, max: MAX_FONT_SIZE }); - editor.timeline.updateElements({ + const content = usePropertyDraft({ + displayValue: element.content, + parse: (input) => input, + onPreview: (value) => + editor.timeline.previewElements({ updates: [ - { - trackId, - elementId: element.id, - updates: { fontSize: initialFontSizeRef.current }, - }, + { trackId, elementId: element.id, updates: { content: value } }, ], - pushHistory: false, - }); - editor.timeline.updateElements({ - updates: [{ trackId, elementId: element.id, updates: { fontSize } }], - pushHistory: true, - }); - initialFontSizeRef.current = null; - } - isEditingFontSize.current = false; - fontSizeDraft.current = ""; - forceRender(); - }; + }), + onCommit: () => editor.timeline.commitPreview(), + }); - const handleOpacityChange = ({ value }: { value: string }) => { - opacityDraft.current = value; - forceRender(); - - if (value.trim() !== "") { - if (initialOpacityRef.current === null) { - initialOpacityRef.current = element.opacity; - } - const parsed = parseInt(value, 10); - const opacityPercent = Number.isNaN(parsed) - ? Math.round(element.opacity * 100) - : clamp({ value: parsed, min: 0, max: 100 }); - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { opacity: opacityPercent / 100 }, - }, - ], - pushHistory: false, - }); - } - }; + return ( + <Section collapsible sectionKey="text:content" hasBorderTop={false}> + <SectionHeader title="Content" /> + <SectionContent> + <Textarea + placeholder="Name" + value={content.displayValue} + className="min-h-20" + onFocus={content.onFocus} + onChange={content.onChange} + onBlur={content.onBlur} + /> + </SectionContent> + </Section> + ); +} - const handleOpacityBlur = () => { - if (initialOpacityRef.current !== null) { - const parsed = parseInt(opacityDraft.current, 10); - const opacityPercent = Number.isNaN(parsed) - ? Math.round(element.opacity * 100) - : clamp({ value: parsed, min: 0, max: 100 }); - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { opacity: initialOpacityRef.current }, - }, - ], - pushHistory: false, - }); - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { opacity: opacityPercent / 100 }, - }, - ], - pushHistory: true, - }); - initialOpacityRef.current = null; - } - isEditingOpacity.current = false; - opacityDraft.current = ""; - forceRender(); - }; +function FontSection({ + element, + trackId, +}: { + element: TextElement; + trackId: string; +}) { + const editor = useEditor(); - const handleColorChange = ({ color }: { color: string }) => { - if (color !== "transparent") { - lastSelectedColor.current = color; - } - if (initialBgColorRef.current === null) { - initialBgColorRef.current = element.backgroundColor; - } - if (initialBgColorRef.current !== null) { - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { backgroundColor: color }, - }, - ], - pushHistory: false, - }); - } else { - editor.timeline.updateElements({ + const fontSize = usePropertyDraft({ + displayValue: element.fontSize.toString(), + parse: (input) => { + const parsed = parseFloat(input); + if (Number.isNaN(parsed)) return null; + return clamp({ value: parsed, min: MIN_FONT_SIZE, max: MAX_FONT_SIZE }); + }, + onPreview: (value) => + editor.timeline.previewElements({ updates: [ - { - trackId, - elementId: element.id, - updates: { backgroundColor: color }, - }, + { trackId, elementId: element.id, updates: { fontSize: value } }, ], - }); - } - }; - - const handleColorChangeEnd = ({ color }: { color: string }) => { - if (initialBgColorRef.current !== null) { - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { backgroundColor: initialBgColorRef.current }, - }, - ], - pushHistory: false, - }); - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { backgroundColor: `#${color}` }, - }, - ], - pushHistory: true, - }); - initialBgColorRef.current = null; - } - }; + }), + onCommit: () => editor.timeline.commitPreview(), + }); return ( - <div className="flex h-full flex-col" ref={containerRef}> - <PanelBaseView className="p-0"> - <PropertyGroup title="Content" hasBorderTop={false} collapsible={false}> - <Textarea - placeholder="Name" - value={contentDisplay} - className="bg-accent min-h-20" - onFocus={() => { - isEditingContent.current = true; - contentDraft.current = element.content; - initialContentRef.current = element.content; - forceRender(); - }} - onChange={(event) => { - contentDraft.current = event.target.value; - forceRender(); - if (initialContentRef.current === null) { - initialContentRef.current = element.content; - } + <Section collapsible sectionKey="text:font"> + <SectionHeader title="Font" /> + <SectionContent> + <div className="flex flex-col gap-2"> + <FontPicker + defaultValue={element.fontFamily} + onValueChange={(value) => editor.timeline.updateElements({ updates: [ { trackId, elementId: element.id, - updates: { content: event.target.value }, + updates: { fontFamily: value }, }, ], - pushHistory: false, - }); - }} - onBlur={() => { - if (initialContentRef.current !== null) { - const finalContent = contentDraft.current; - editor.timeline.updateElements({ + }) + } + /> + <Select value={element.fontWeight}> + <SelectTrigger className="w-full" icon={<OcFontWeightIcon />}> + <SelectValue placeholder="Select weight" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="100">Thin</SelectItem> + <SelectItem value="200">Extra Light</SelectItem> + <SelectItem value="300">Light</SelectItem> + <SelectItem value="400">Normal</SelectItem> + <SelectItem value="500">Medium</SelectItem> + <SelectItem value="600">Semi Bold</SelectItem> + <SelectItem value="700">Bold</SelectItem> + <SelectItem value="800">Extra Bold</SelectItem> + <SelectItem value="900">Black</SelectItem> + </SelectContent> + </Select> + + <Label>Font size</Label> + <div className="flex items-center gap-2"> + <Slider + value={[element.fontSize]} + min={MIN_FONT_SIZE} + max={MAX_FONT_SIZE} + step={1} + onValueChange={([value]) => + editor.timeline.previewElements({ updates: [ { trackId, elementId: element.id, - updates: { content: initialContentRef.current }, + updates: { fontSize: value }, }, ], - pushHistory: false, - }); + }) + } + onValueCommit={() => editor.timeline.commitPreview()} + className="w-full" + /> + <NumberField + className="w-18 shrink-0" + value={fontSize.displayValue} + min={MIN_FONT_SIZE} + max={MAX_FONT_SIZE} + onFocus={fontSize.onFocus} + onChange={fontSize.onChange} + onBlur={fontSize.onBlur} + onReset={() => editor.timeline.updateElements({ updates: [ { trackId, elementId: element.id, - updates: { content: finalContent }, + updates: { + fontSize: DEFAULT_TEXT_ELEMENT.fontSize, + }, }, ], - pushHistory: true, - }); - initialContentRef.current = null; + }) } - isEditingContent.current = false; - contentDraft.current = ""; - forceRender(); + isDefault={element.fontSize === DEFAULT_TEXT_ELEMENT.fontSize} + /> + </div> + </div> + </SectionContent> + </Section> + ); +} + +function ColorSection({ + element, + trackId, +}: { + element: TextElement; + trackId: string; +}) { + const editor = useEditor(); + const lastSelectedColor = useRef(DEFAULT_COLOR); + + return ( + <Section collapsible sectionKey="text:color" hasBorderBottom={false}> + <SectionHeader title="Color" /> + <SectionContent> + <div className="flex flex-col gap-6"> + <ColorPicker + value={uppercase({ + string: (element.color || "FFFFFF").replace("#", ""), + })} + onChange={(color) => + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { color: `#${color}` }, + }, + ], + }) + } + onChangeEnd={() => editor.timeline.commitPreview()} + /> + <ColorPicker + value={ + element.backgroundColor === "transparent" + ? lastSelectedColor.current.replace("#", "") + : element.backgroundColor.replace("#", "") + } + onChange={(color) => { + const hexColor = `#${color}`; + if (color !== "transparent") { + lastSelectedColor.current = hexColor; + } + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { backgroundColor: hexColor }, + }, + ], + }); }} + onChangeEnd={() => editor.timeline.commitPreview()} + className={ + element.backgroundColor === "transparent" + ? "pointer-events-none opacity-50" + : "" + } /> - </PropertyGroup> - <PropertyGroup title="Typography" collapsible={false}> - <div className="space-y-6"> - <PropertyItem direction="column"> - <PropertyItemLabel>Font</PropertyItemLabel> - <PropertyItemValue> - <FontPicker - defaultValue={element.fontFamily} - onValueChange={(value: FontFamily) => - editor.timeline.updateElements({ - updates: [ - { - 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.updateElements({ - updates: [ - { - 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.updateElements({ - updates: [ - { - 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.updateElements({ - updates: [ - { - 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.updateElements({ - updates: [ - { - 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={MIN_FONT_SIZE} - max={MAX_FONT_SIZE} - step={1} - onValueChange={([value]) => { - if (initialFontSizeRef.current === null) { - initialFontSizeRef.current = element.fontSize; - } - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { fontSize: value }, - }, - ], - pushHistory: false, - }); - }} - onValueCommit={([value]) => { - if (initialFontSizeRef.current !== null) { - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { - fontSize: initialFontSizeRef.current, - }, - }, - ], - pushHistory: false, - }); - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { fontSize: value }, - }, - ], - pushHistory: true, - }); - initialFontSizeRef.current = null; - } - }} - className="w-full" - /> - <Input - type="number" - value={fontSizeDisplay} - min={MIN_FONT_SIZE} - max={MAX_FONT_SIZE} - onFocus={() => { - isEditingFontSize.current = true; - fontSizeDraft.current = element.fontSize.toString(); - forceRender(); - }} - onChange={(e) => - handleFontSizeChange({ value: e.target.value }) - } - onBlur={handleFontSizeBlur} - className="bg-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> - </div> - </PropertyGroup> - <PropertyGroup title="Appearance" collapsible={false}> - <div className="space-y-6"> - <PropertyItem direction="column"> - <PropertyItemLabel>Color</PropertyItemLabel> - <PropertyItemValue> - <ColorPicker - value={uppercase({ - string: (element.color || "FFFFFF").replace("#", ""), - })} - onChange={(color) => { - if (initialColorRef.current === null) { - initialColorRef.current = element.color || "#FFFFFF"; - } - if (initialColorRef.current !== null) { - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { color: `#${color}` }, - }, - ], - pushHistory: false, - }); - } else { - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { color: `#${color}` }, - }, - ], - }); - } - }} - onChangeEnd={(color) => { - if (initialColorRef.current !== null) { - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { color: initialColorRef.current }, - }, - ], - pushHistory: false, - }); - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { color: `#${color}` }, - }, - ], - pushHistory: true, - }); - initialColorRef.current = null; - } - }} - 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]) => { - if (initialOpacityRef.current === null) { - initialOpacityRef.current = element.opacity; - } - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { opacity: value / 100 }, - }, - ], - pushHistory: false, - }); - }} - onValueCommit={([value]) => { - if (initialOpacityRef.current !== null) { - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { opacity: initialOpacityRef.current }, - }, - ], - pushHistory: false, - }); - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { opacity: value / 100 }, - }, - ], - pushHistory: true, - }); - initialOpacityRef.current = null; - } - }} - className="w-full" - /> - <Input - type="number" - value={opacityDisplay} - min={0} - max={100} - onFocus={() => { - isEditingOpacity.current = true; - opacityDraft.current = Math.round( - element.opacity * 100, - ).toString(); - forceRender(); - }} - onChange={(e) => - handleOpacityChange({ value: e.target.value }) - } - onBlur={handleOpacityBlur} - className="bg-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> - <ColorPicker - value={ - element.backgroundColor === "transparent" - ? lastSelectedColor.current.replace("#", "") - : element.backgroundColor.replace("#", "") - } - onChange={(color) => - handleColorChange({ color: `#${color}` }) - } - onChangeEnd={(color) => handleColorChangeEnd({ color })} - containerRef={containerRef} - className={ - element.backgroundColor === "transparent" - ? "pointer-events-none opacity-50" - : "" - } - /> - </PropertyItemValue> - </PropertyItem> - </div> - </PropertyGroup> - </PanelBaseView> - </div> + </div> + </SectionContent> + </Section> ); } |
