summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/panels/properties
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/components/editor/panels/properties')
-rw-r--r--apps/web/src/components/editor/panels/properties/audio-properties.tsx5
-rw-r--r--apps/web/src/components/editor/panels/properties/index.tsx68
-rw-r--r--apps/web/src/components/editor/panels/properties/property-item.tsx91
-rw-r--r--apps/web/src/components/editor/panels/properties/text-properties.tsx410
-rw-r--r--apps/web/src/components/editor/panels/properties/video-properties.tsx9
5 files changed, 583 insertions, 0 deletions
diff --git a/apps/web/src/components/editor/panels/properties/audio-properties.tsx b/apps/web/src/components/editor/panels/properties/audio-properties.tsx
new file mode 100644
index 00000000..0b83a4b6
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/audio-properties.tsx
@@ -0,0 +1,5 @@
+import type { AudioElement } from "@/types/timeline";
+
+export function AudioProperties({ _element }: { _element: AudioElement }) {
+ return <div className="space-y-4 p-5">Audio properties</div>;
+}
diff --git a/apps/web/src/components/editor/panels/properties/index.tsx b/apps/web/src/components/editor/panels/properties/index.tsx
new file mode 100644
index 00000000..3a552b93
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/index.tsx
@@ -0,0 +1,68 @@
+"use client";
+
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { AudioProperties } from "./audio-properties";
+import { VideoProperties } from "./video-properties";
+import { TextProperties } from "./text-properties";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { Settings05Icon } from "@hugeicons/core-free-icons";
+import { useEditor } from "@/hooks/use-editor";
+import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
+
+export function PropertiesPanel() {
+ const editor = useEditor();
+ const { selectedElements } = useElementSelection();
+
+ const elementsWithTracks = editor.timeline.getElementsWithTracks({
+ elements: selectedElements,
+ });
+
+ return (
+ <>
+ {selectedElements.length > 0 ? (
+ <ScrollArea className="bg-panel h-full rounded-sm">
+ {elementsWithTracks.map(({ track, element }) => {
+ if (element.type === "text") {
+ return (
+ <div key={element.id}>
+ <TextProperties element={element} trackId={track.id} />
+ </div>
+ );
+ }
+ if (element.type === "audio") {
+ return <AudioProperties key={element.id} _element={element} />;
+ }
+ if (element.type === "video" || element.type === "image") {
+ return (
+ <div key={element.id}>
+ <VideoProperties _element={element} />
+ </div>
+ );
+ }
+ return null;
+ })}
+ </ScrollArea>
+ ) : (
+ <EmptyView />
+ )}
+ </>
+ );
+}
+
+function EmptyView() {
+ return (
+ <div className="bg-panel flex h-full flex-col items-center justify-center gap-3 p-4">
+ <HugeiconsIcon
+ icon={Settings05Icon}
+ className="text-muted-foreground/75 size-10"
+ strokeWidth={1}
+ />
+ <div className="flex flex-col gap-2 text-center">
+ <p className="text-lg font-medium">It's empty here</p>
+ <p className="text-muted-foreground text-sm text-balance">
+ Click an element on the timeline to edit its properties
+ </p>
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/property-item.tsx b/apps/web/src/components/editor/panels/properties/property-item.tsx
new file mode 100644
index 00000000..6d2fe444
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/property-item.tsx
@@ -0,0 +1,91 @@
+import { useState } from "react";
+import { cn } from "@/utils/ui";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { ArrowDownIcon } from "@hugeicons/core-free-icons";
+
+interface PropertyItemProps {
+ direction?: "row" | "column";
+ children: React.ReactNode;
+ className?: string;
+}
+
+export function PropertyItem({
+ direction = "row",
+ children,
+ className,
+}: PropertyItemProps) {
+ return (
+ <div
+ className={cn(
+ "flex gap-2",
+ direction === "row"
+ ? "items-center justify-between gap-6"
+ : "flex-col gap-1.5",
+ className,
+ )}
+ >
+ {children}
+ </div>
+ );
+}
+
+export function PropertyItemLabel({
+ children,
+ className,
+}: {
+ children: React.ReactNode;
+ className?: string;
+}) {
+ return (
+ <span className={cn("text-muted-foreground text-xs", className)}>
+ {children}
+ </span>
+ );
+}
+
+export function PropertyItemValue({
+ children,
+ className,
+}: {
+ children: React.ReactNode;
+ className?: string;
+}) {
+ return <div className={cn("flex-1 text-sm", className)}>{children}</div>;
+}
+
+interface PropertyGroupProps {
+ title: string;
+ children: React.ReactNode;
+ defaultExpanded?: boolean;
+ className?: string;
+ titleClassName?: string;
+}
+
+export function PropertyGroup({
+ title,
+ children,
+ defaultExpanded = true,
+ className,
+ titleClassName,
+}: PropertyGroupProps) {
+ const [isExpanded, setIsExpanded] = useState(defaultExpanded);
+
+ return (
+ <PropertyItem direction="column" className={cn("gap-3", className)}>
+ <button
+ type="button"
+ className="flex items-center gap-1.5 cursor-pointer"
+ onClick={() => setIsExpanded(!isExpanded)}
+ >
+ <PropertyItemLabel className={cn(titleClassName)}>
+ {title}
+ </PropertyItemLabel>
+ <HugeiconsIcon
+ icon={ArrowDownIcon}
+ className={cn("size-3", !isExpanded && "-rotate-90")}
+ />
+ </button>
+ {isExpanded && <PropertyItemValue>{children}</PropertyItemValue>}
+ </PropertyItem>
+ );
+}
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>
+ ),
+ }))}
+ />
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/video-properties.tsx b/apps/web/src/components/editor/panels/properties/video-properties.tsx
new file mode 100644
index 00000000..bf6d92d8
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/video-properties.tsx
@@ -0,0 +1,9 @@
+import type { ImageElement, VideoElement } from "@/types/timeline";
+
+export function VideoProperties({
+ _element,
+}: {
+ _element: VideoElement | ImageElement;
+}) {
+ return <div className="space-y-4 p-5">Video properties</div>;
+}