summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/panels/properties/text-properties.tsx
diff options
context:
space:
mode:
authorMaze <167211895+mazeincoding@users.noreply.github.com>2026-01-31 00:20:04 +0100
committerGitHub <noreply@github.com>2026-01-31 00:20:04 +0100
commit7bf0984698e43b9aa2dd879f65951318ed410390 (patch)
treeccf2cd590a47d550152afc07129cb55c71a9ec35 /apps/web/src/components/editor/panels/properties/text-properties.tsx
parent0173db99448d1c4c6415dcf15272e17551b48b04 (diff)
downloadopencut-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.tsx410
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>
+ ),
+ }))}
+ />
+ );
+}