From f52935a3525a9e97b2055ffb4305d1e9ee0140e0 Mon Sep 17 00:00:00 2001 From: xo-o Date: Mon, 30 Mar 2026 14:15:19 -0500 Subject: [PATCH 1/2] update clip text --- package.json | 2 +- pnpm-lock.yaml | 10 +- src/components/editor/canvas-panel.tsx | 14 +- .../editor/media-panel/panel/text.tsx | 4 +- .../editor/media-panel/panel/transition.tsx | 55 ++++- src/components/editor/text-editor-overlay.tsx | 198 ++++++++++++++++++ .../editor/timeline/timeline/canvas.ts | 4 +- 7 files changed, 275 insertions(+), 12 deletions(-) create mode 100644 src/components/editor/text-editor-overlay.tsx diff --git a/package.json b/package.json index f2a8b99..9456eda 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "motion": "^12.23.26", "next": "16.0.7", "next-themes": "^0.4.6", - "openvideo": "^0.2.11", + "openvideo": "^0.2.13", "opfs-tools": "^0.7.2", "pg": "^8.20.0", "pixi.js": "^8.14.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f3dc544..8a23f01 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -123,8 +123,8 @@ importers: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0) openvideo: - specifier: ^0.2.11 - version: 0.2.11(@types/react@19.2.8)(react@19.2.0)(yoga-layout@3.2.1) + specifier: ^0.2.13 + version: 0.2.13(@types/react@19.2.8)(react@19.2.0)(yoga-layout@3.2.1) opfs-tools: specifier: ^0.7.2 version: 0.7.4 @@ -4828,8 +4828,8 @@ packages: resolution: {integrity: sha512-vz9iS7MJ5+Bp1URw8Khvdyw1H/hGvzHWlKQ7eRrQojSCDL1/SrWfrY9QebLw97n2deyRtzHRC3MkQfVNUCo91Q==} engines: {node: '>=0.10'} - openvideo@0.2.11: - resolution: {integrity: sha512-ix0CWCDI44KY6exPaq2XcDmgPa+s+HhK/AKptNFEeNCiPA+hWLR39TrvOnThCA/M8pSHYyoAz1/gLnxy+0ddtQ==} + openvideo@0.2.13: + resolution: {integrity: sha512-ynbnU80GFeKHSaq/1fRKNUa7B30DOW67Yrz01l0VhJLmWyJ7D1o4SvPJO9Bp7DjaVHcs+p6K5G7qcp3CN6avhw==} opfs-tools@0.7.4: resolution: {integrity: sha512-DJgQnXyPcCj3q8pKa8SjP4HfNBPffj8kO6W1nc2zQydTqu5G/AxOuIjmWnRxZ84nE1EOpzZzhj8MYCZP1jRR9A==} @@ -11513,7 +11513,7 @@ snapshots: opentracing@0.14.7: {} - openvideo@0.2.11(@types/react@19.2.8)(react@19.2.0)(yoga-layout@3.2.1): + openvideo@0.2.13(@types/react@19.2.8)(react@19.2.0)(yoga-layout@3.2.1): dependencies: '@pixi/layout': 3.2.0(@types/react@19.2.8)(pixi.js@8.16.0)(react@19.2.0)(yoga-layout@3.2.1) gl-transitions: 1.43.0 diff --git a/src/components/editor/canvas-panel.tsx b/src/components/editor/canvas-panel.tsx index 7767737..3463359 100644 --- a/src/components/editor/canvas-panel.tsx +++ b/src/components/editor/canvas-panel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useMemo } from "react"; +import { useEffect, useRef, useMemo, useState } from "react"; import { Studio, fontManager, registerCustomTransition, registerCustomEffect } from "openvideo"; import { useTheme } from "next-themes"; import { useStudioStore } from "@/stores/studio-store"; @@ -7,6 +7,7 @@ import { editorFont } from "./constants"; import { CUSTOM_TRANSITIONS } from "./transition-custom"; import { CUSTOM_EFFECTS } from "./effect-custom"; import { SelectionFloatingMenu } from "./selection-floating-menu"; +import { TextEditorOverlay } from "./text-editor-overlay"; import { useClipActions } from "./options-floating-menu"; import { ContextMenu, @@ -53,6 +54,7 @@ export function CanvasPanel({ onReady }: CanvasPanelProps) { handleToggleLock, handleDelete, } = useClipActions(); + const [editingClip, setEditingClip] = useState(null); const bgColor = useMemo(() => { const currentTheme = theme === "system" ? resolvedTheme : theme; @@ -179,16 +181,23 @@ export function CanvasPanel({ onReady }: CanvasPanelProps) { const handleClear = () => { setSelectedClips([]); + setEditingClip(null); + }; + + const handleDblClick = ({ clip }: { clip: any }) => { + setEditingClip(clip); }; studio.on("selection:created", handleSelection); studio.on("selection:updated", handleSelection); studio.on("selection:cleared", handleClear); + studio.on("clip:dblclick", handleDblClick); return () => { studio.off("selection:created", handleSelection); studio.off("selection:updated", handleSelection); studio.off("selection:cleared", handleClear); + studio.off("clip:dblclick", handleDblClick); }; }, [setSelectedClips]); @@ -235,6 +244,9 @@ export function CanvasPanel({ onReady }: CanvasPanelProps) { tabIndex={0} /> + {editingClip && ( + setEditingClip(null)} /> + )} diff --git a/src/components/editor/media-panel/panel/text.tsx b/src/components/editor/media-panel/panel/text.tsx index b483647..23c5331 100644 --- a/src/components/editor/media-panel/panel/text.tsx +++ b/src/components/editor/media-panel/panel/text.tsx @@ -103,7 +103,7 @@ export default function PanelText() { if (!studio) return; try { - const textClip = new Text(preset ? preset.description : "Add Text pro", { + const textClip = new Text(preset ? preset.description : "Add Text", { fontSize: preset?.style.fontSize || 124, fontFamily: preset?.style.fontFamily || "Arial", align: "center", @@ -113,7 +113,7 @@ export default function PanelText() { stroke: (preset?.style as any)?.stroke || undefined, dropShadow: (preset?.style as any)?.dropShadow || undefined, wordWrap: true, - wordWrapWidth: 800, + wordWrapWidth: 600, fontUrl: (preset?.style as any)?.fontUrl, }); textClip.name = preset ? preset.name : "Text"; diff --git a/src/components/editor/media-panel/panel/transition.tsx b/src/components/editor/media-panel/panel/transition.tsx index 90ad549..d27ab8a 100644 --- a/src/components/editor/media-panel/panel/transition.tsx +++ b/src/components/editor/media-panel/panel/transition.tsx @@ -27,6 +27,46 @@ type CustomPreset = { userId: string; }; +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/** + * Finds the first junction between two adjacent "media" clips (Video or Image) + * across all Video tracks, sorted by start time. + */ +const findFirstMediaClipUnion = (studio: any) => { + const tracks = studio.getTracks().filter((t: any) => t.type === "Video" || t.type === "Image"); + const unions: { fromId: string; toId: string; start: number; duration: number }[] = []; + + for (const track of tracks) { + const clips = track.clipIds + .map((id: string) => studio.getClipById(id)) + .filter((c: any) => c && (c.type === "Video" || c.type === "Image")) + .sort((a: any, b: any) => a.display.from - b.display.from); + + for (let i = 0; i < clips.length - 1; i++) { + const current = clips[i]; + const next = clips[i + 1]; + + // Check if they are adjacent (allowing for a small 100ms tolerance for sub-frame gaps) + const gap = Math.abs(next.display.from - current.display.to); + if (gap < 100_000) { + const duration = Math.min(current.duration, next.duration) * 0.25; + unions.push({ + fromId: current.id, + toId: next.id, + start: current.display.to, + duration, + }); + } + } + } + + if (unions.length === 0) return null; + + // Return the one that starts earliest + return unions.sort((a, b) => a.start - b.start)[0]; +}; + // ─── Shared card for built-in transitions ───────────────────────────────────── type TransitionCardProps = { @@ -210,7 +250,12 @@ const TransitionDefault = () => { previewDynamic={effect.previewDynamic} onClick={() => { if (!studio) return; - studio.addTransition(effect.key, TRANSITION_DURATION_DEFAULT); + const union = findFirstMediaClipUnion(studio); + if (union) { + studio.addTransition(effect.key, union.duration, union.fromId, union.toId); + } else { + studio.addTransition(effect.key, TRANSITION_DURATION_DEFAULT); + } }} /> ))} @@ -256,7 +301,13 @@ const TransitionCustom = () => { label: preset.data.label || preset.name, fragment: preset.data.fragment, } as any); - studio.addTransition(key, TRANSITION_DURATION_DEFAULT); + + const union = findFirstMediaClipUnion(studio); + if (union) { + studio.addTransition(key, union.duration, union.fromId, union.toId); + } else { + studio.addTransition(key, TRANSITION_DURATION_DEFAULT); + } }; if (isLoading) { diff --git a/src/components/editor/text-editor-overlay.tsx b/src/components/editor/text-editor-overlay.tsx new file mode 100644 index 0000000..ccd8659 --- /dev/null +++ b/src/components/editor/text-editor-overlay.tsx @@ -0,0 +1,198 @@ +"use client"; + +import React, { useState, useEffect, useRef } from "react"; +import { useStudioStore } from "@/stores/studio-store"; +import { Textarea } from "@/components/ui/textarea"; + +interface TextEditorOverlayProps { + clip: any; + onClose: () => void; +} + +/** + * TextEditorOverlay - An inline text editor that overlays the canvas. + * Appears when a text clip is double-clicked. + */ +export function TextEditorOverlay({ clip, onClose }: TextEditorOverlayProps) { + const { studio } = useStudioStore(); + const [text, setText] = useState(clip.text || ""); + const [bounds, setBounds] = useState({ x: 0, y: 0, width: 0, height: 0, rotation: 0 }); + const textareaRef = useRef(null); + const textRef = useRef(text); // Track text for saving on unmount + + // Sync textRef with state + useEffect(() => { + textRef.current = text; + }, [text]); + + useEffect(() => { + if (!studio || !studio.pixiApp) return; + + const updatePosition = () => { + const transformer = studio.activeTransformer; + if (!transformer) { + onClose(); + return; + } + + // getBounds() returns coordinates relative to the canvas stage + const pixiBounds = transformer.getBounds(); + + setBounds({ + x: pixiBounds.x, + y: pixiBounds.y, + width: pixiBounds.width, + height: pixiBounds.height, + rotation: transformer.rotation, + }); + }; + + updatePosition(); + + // Sync position if the clip is moved/resized while editing + studio.on("transforming", updatePosition); + studio.on("selection:cleared", onClose); + + return () => { + studio.off("transforming", updatePosition); + studio.off("selection:cleared", onClose); + }; + }, [studio, onClose]); + + // Auto-resize height as you type + useEffect(() => { + const textarea = textareaRef.current; + if (textarea) { + textarea.style.height = "0px"; + textarea.style.height = `${textarea.scrollHeight}px`; + } + }, [text, bounds.width]); + + // Focus and select text on mount + useEffect(() => { + setTimeout(() => { + if (textareaRef.current) { + textareaRef.current.focus(); + textareaRef.current.select(); + } + }, 50); + }, []); + + const handleSave = () => { + const currentText = textRef.current; + if (studio && currentText !== clip.text) { + studio.updateClip(clip.id, { text: currentText } as any); + studio.updateFrame(studio.currentTime); + } + onClose(); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + onClose(); + } else if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { + handleSave(); + } + }; + + // Visibility toggle: hide the original clip and transformer while editing + useEffect(() => { + const originalOpacity = clip.opacity ?? 1; + clip.opacity = 0; + + // Hide the selection transformer (blue box/handles) to avoid clutter + const transformer = studio?.activeTransformer; + const originalTransformerVisible = transformer?.visible ?? true; + if (transformer) { + transformer.visible = false; + } + + if (studio) { + studio.updateFrame(studio.currentTime); + } + + return () => { + // Auto-save on unmount if dirty + if (studio && textRef.current !== clip.text) { + studio.updateClip(clip.id, { text: textRef.current } as any); + } + clip.opacity = originalOpacity; + + // Restore transformer visibility + if (transformer) { + transformer.visible = originalTransformerVisible; + } + + if (studio) { + studio.updateFrame(studio.currentTime); + } + }; + }, [clip, studio]); + + // Calculate font size scaling + // Use width ratio for better stability than height + const style = clip.style || {}; + const logicalFontSize = style.fontSize || 40; + + // Safe scale calculation to avoid "exploding" text + const logicalWidth = clip.width || 1; + const currentScale = + bounds.width > 0 && logicalWidth > 1 + ? bounds.width / logicalWidth + : studio?.artboard?.scale?.x || 1; + + const scaledFontSize = logicalFontSize * currentScale; + + return ( +
e.stopPropagation()} + > +