diff options
Diffstat (limited to 'apps/web/src/components')
5 files changed, 255 insertions, 94 deletions
diff --git a/apps/web/src/components/editor/panels/preview/index.tsx b/apps/web/src/components/editor/panels/preview/index.tsx index 5ed9db00..c579b15b 100644 --- a/apps/web/src/components/editor/panels/preview/index.tsx +++ b/apps/web/src/components/editor/panels/preview/index.tsx @@ -8,6 +8,7 @@ import { CanvasRenderer } from "@/services/renderer/canvas-renderer"; import type { RootNode } from "@/services/renderer/nodes/root-node"; import { buildScene } from "@/services/renderer/scene-builder"; import { getLastFrameTime } from "@/lib/time"; +import { PreviewInteractionOverlay } from "./preview-interaction-overlay"; function usePreviewSize() { const editor = useEditor(); @@ -57,7 +58,7 @@ export function PreviewPanel() { } function PreviewCanvas() { - const ref = useRef<HTMLCanvasElement>(null); + const canvasRef = useRef<HTMLCanvasElement>(null); const lastFrameRef = useRef(-1); const lastSceneRef = useRef<RootNode | null>(null); const renderingRef = useRef(false); @@ -76,7 +77,7 @@ function PreviewCanvas() { const renderTree = editor.renderer.getRenderTree(); const render = useCallback(() => { - if (ref.current && renderTree && !renderingRef.current) { + if (canvasRef.current && renderTree && !renderingRef.current) { const time = editor.playback.getCurrentTime(); const lastFrameTime = getLastFrameTime({ duration: renderTree.duration, @@ -96,7 +97,7 @@ function PreviewCanvas() { .renderToCanvas({ node: renderTree, time: renderTime, - targetCanvas: ref.current, + targetCanvas: canvasRef.current, }) .then(() => { renderingRef.current = false; @@ -108,17 +109,20 @@ function PreviewCanvas() { useRafLoop(render); return ( - <canvas - ref={ref} - width={width} - height={height} - className="block max-h-full max-w-full border" - style={{ - background: - activeProject.settings.background.type === "blur" - ? "transparent" - : activeProject?.settings.background.color, - }} - /> + <div className="relative"> + <canvas + ref={canvasRef} + width={width} + height={height} + className="block max-h-full max-w-full border" + style={{ + background: + activeProject.settings.background.type === "blur" + ? "transparent" + : activeProject?.settings.background.color, + }} + /> + <PreviewInteractionOverlay canvasRef={canvasRef} /> + </div> ); } diff --git a/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx b/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx new file mode 100644 index 00000000..c13379f0 --- /dev/null +++ b/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx @@ -0,0 +1,20 @@ +import { usePreviewInteraction } from "@/hooks/use-preview-interaction"; +import { cn } from "@/utils/ui"; + +export function PreviewInteractionOverlay({ + canvasRef, +}: { + canvasRef: React.RefObject<HTMLCanvasElement | null>; +}) { + const { onPointerDown, onPointerMove, onPointerUp } = + usePreviewInteraction({ canvasRef }); + + return ( + <div + className={cn("absolute inset-0 pointer-events-auto")} + onPointerDown={onPointerDown} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + /> + ); +} 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 43c1eb26..35230c75 100644 --- a/apps/web/src/components/editor/panels/properties/text-properties.tsx +++ b/apps/web/src/components/editor/panels/properties/text-properties.tsx @@ -50,6 +50,8 @@ export function TextProperties({ ); const lastSelectedColor = useRef(DEFAULT_COLOR); + const initialFontSizeRef = useRef<number | null>(null); + const initialOpacityRef = useRef<number | null>(null); const handleFontSizeChange = ({ value }: { value: string }) => { setFontSizeInput(value); @@ -59,10 +61,8 @@ export function TextProperties({ const fontSize = Number.isNaN(parsed) ? element.fontSize : clamp({ value: parsed, min: MIN_FONT_SIZE, max: MAX_FONT_SIZE }); - editor.timeline.updateElement({ - trackId, - elementId: element.id, - updates: { fontSize }, + editor.timeline.updateElements({ + updates: [{ trackId, elementId: element.id, updates: { fontSize } }], }); } }; @@ -73,10 +73,8 @@ export function TextProperties({ ? element.fontSize : clamp({ value: parsed, min: MIN_FONT_SIZE, max: MAX_FONT_SIZE }); setFontSizeInput(fontSize.toString()); - editor.timeline.updateElement({ - trackId, - elementId: element.id, - updates: { fontSize }, + editor.timeline.updateElements({ + updates: [{ trackId, elementId: element.id, updates: { fontSize } }], }); }; @@ -88,10 +86,14 @@ export function TextProperties({ const opacityPercent = Number.isNaN(parsed) ? Math.round(element.opacity * 100) : clamp({ value: parsed, min: 0, max: 100 }); - editor.timeline.updateElement({ - trackId, - elementId: element.id, - updates: { opacity: opacityPercent / 100 }, + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { opacity: opacityPercent / 100 }, + }, + ], }); } }; @@ -102,10 +104,14 @@ export function TextProperties({ ? Math.round(element.opacity * 100) : clamp({ value: parsed, min: 0, max: 100 }); setOpacityInput(opacityPercent.toString()); - editor.timeline.updateElement({ - trackId, - elementId: element.id, - updates: { opacity: opacityPercent / 100 }, + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { opacity: opacityPercent / 100 }, + }, + ], }); }; @@ -113,10 +119,14 @@ export function TextProperties({ if (color !== "transparent") { lastSelectedColor.current = color; } - editor.timeline.updateElement({ - trackId, - elementId: element.id, - updates: { backgroundColor: color }, + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { backgroundColor: color }, + }, + ], }); }; @@ -126,10 +136,14 @@ export function TextProperties({ isTransparent: boolean; }) => { const newColor = isTransparent ? "transparent" : lastSelectedColor.current; - editor.timeline.updateElement({ - trackId, - elementId: element.id, - updates: { backgroundColor: newColor }, + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { backgroundColor: newColor }, + }, + ], }); }; @@ -154,10 +168,14 @@ export function TextProperties({ defaultValue={element.content} className="bg-panel-accent min-h-20" onChange={(e) => - editor.timeline.updateElement({ - trackId, - elementId: element.id, - updates: { content: e.target.value }, + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { content: e.target.value }, + }, + ], }) } /> @@ -167,10 +185,14 @@ export function TextProperties({ <FontPicker defaultValue={element.fontFamily} onValueChange={(value: FontFamily) => - editor.timeline.updateElement({ - trackId, - elementId: element.id, - updates: { fontFamily: value }, + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { fontFamily: value }, + }, + ], }) } /> @@ -186,13 +208,19 @@ export function TextProperties({ } size="sm" onClick={() => - editor.timeline.updateElement({ - trackId, - elementId: element.id, - updates: { - fontWeight: - element.fontWeight === "bold" ? "normal" : "bold", - }, + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { + fontWeight: + element.fontWeight === "bold" + ? "normal" + : "bold", + }, + }, + ], }) } className="h-8 px-3 font-bold" @@ -205,15 +233,19 @@ export function TextProperties({ } size="sm" onClick={() => - editor.timeline.updateElement({ - trackId, - elementId: element.id, - updates: { - fontStyle: - element.fontStyle === "italic" - ? "normal" - : "italic", - }, + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { + fontStyle: + element.fontStyle === "italic" + ? "normal" + : "italic", + }, + }, + ], }) } className="h-8 px-3 italic" @@ -228,15 +260,19 @@ export function TextProperties({ } size="sm" onClick={() => - editor.timeline.updateElement({ - trackId, - elementId: element.id, - updates: { - textDecoration: - element.textDecoration === "underline" - ? "none" - : "underline", - }, + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { + textDecoration: + element.textDecoration === "underline" + ? "none" + : "underline", + }, + }, + ], }) } className="h-8 px-3 underline" @@ -251,15 +287,19 @@ export function TextProperties({ } size="sm" onClick={() => - editor.timeline.updateElement({ - trackId, - elementId: element.id, - updates: { - textDecoration: - element.textDecoration === "line-through" - ? "none" - : "line-through", - }, + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { + textDecoration: + element.textDecoration === "line-through" + ? "none" + : "line-through", + }, + }, + ], }) } className="h-8 px-3 line-through" @@ -279,13 +319,48 @@ export function TextProperties({ max={MAX_FONT_SIZE} step={1} onValueChange={([value]) => { - editor.timeline.updateElement({ - trackId, - elementId: element.id, - updates: { fontSize: value }, + if (initialFontSizeRef.current === null) { + initialFontSizeRef.current = element.fontSize; + } + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { fontSize: value }, + }, + ], + pushHistory: false, }); setFontSizeInput(value.toString()); }} + 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 @@ -310,10 +385,14 @@ export function TextProperties({ string: (element.color || "FFFFFF").replace("#", ""), })} onChange={(color) => { - editor.timeline.updateElement({ - trackId, - elementId: element.id, - updates: { color: `#${color}` }, + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { color: `#${color}` }, + }, + ], }); }} containerRef={containerRef} @@ -330,13 +409,46 @@ export function TextProperties({ max={100} step={1} onValueChange={([value]) => { - editor.timeline.updateElement({ - trackId, - elementId: element.id, - updates: { opacity: value / 100 }, + if (initialOpacityRef.current === null) { + initialOpacityRef.current = element.opacity; + } + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { opacity: value / 100 }, + }, + ], + pushHistory: false, }); setOpacityInput(value.toString()); }} + 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 diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/timeline/index.tsx index 271bad9b..2ff17345 100644 --- a/apps/web/src/components/editor/timeline/index.tsx +++ b/apps/web/src/components/editor/timeline/index.tsx @@ -357,6 +357,7 @@ export function Timeline() { zoomLevel={zoomLevel} dynamicTimelineWidth={dynamicTimelineWidth} rulerRef={rulerRef} + tracksScrollRef={tracksScrollRef} handleWheel={handleWheel} handleTimelineContentClick={handleRulerClick} handleRulerTrackingMouseDown={handleRulerMouseDown} diff --git a/apps/web/src/components/editor/timeline/timeline-ruler.tsx b/apps/web/src/components/editor/timeline/timeline-ruler.tsx index f5334f5a..dfa0534b 100644 --- a/apps/web/src/components/editor/timeline/timeline-ruler.tsx +++ b/apps/web/src/components/editor/timeline/timeline-ruler.tsx @@ -3,12 +3,14 @@ import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { DEFAULT_FPS } from "@/constants/project-constants"; import { useEditor } from "@/hooks/use-editor"; import { getRulerConfig, shouldShowLabel } from "@/lib/timeline/ruler-utils"; +import { useScrollPosition } from "@/hooks/timeline/use-scroll-position"; import { TimelineTick } from "./timeline-tick"; interface TimelineRulerProps { zoomLevel: number; dynamicTimelineWidth: number; rulerRef: React.Ref<HTMLDivElement>; + tracksScrollRef: React.RefObject<HTMLElement | null>; handleWheel: (e: React.WheelEvent) => void; handleTimelineContentClick: (e: React.MouseEvent) => void; handleRulerTrackingMouseDown: (e: React.MouseEvent) => void; @@ -19,6 +21,7 @@ export function TimelineRuler({ zoomLevel, dynamicTimelineWidth, rulerRef, + tracksScrollRef, handleWheel, handleTimelineContentClick, handleRulerTrackingMouseDown, @@ -37,8 +40,29 @@ export function TimelineRuler({ }); const tickCount = Math.ceil(effectiveDuration / tickIntervalSeconds) + 1; + const { scrollLeft, viewportWidth } = useScrollPosition({ + scrollRef: tracksScrollRef, + }); + + const bufferPx = 200; + const visibleStartTime = Math.max( + 0, + (scrollLeft - bufferPx) / pixelsPerSecond, + ); + const visibleEndTime = + (scrollLeft + viewportWidth + bufferPx) / pixelsPerSecond; + + const startTickIndex = Math.max( + 0, + Math.floor(visibleStartTime / tickIntervalSeconds), + ); + const endTickIndex = Math.min( + tickCount - 1, + Math.ceil(visibleEndTime / tickIntervalSeconds), + ); + const timelineTicks: Array<JSX.Element> = []; - for (let tickIndex = 0; tickIndex < tickCount; tickIndex += 1) { + for (let tickIndex = startTickIndex; tickIndex <= endTickIndex; tickIndex += 1) { const time = tickIndex * tickIntervalSeconds; if (time > effectiveDuration) break; |
