add: better performance through RAF of the Camera preview overlay dragging (avoiding re-renders)

add: SourceSelector marquee (scrolling text when too big)
This commit is contained in:
Alan Trebugeais
2026-05-07 00:17:05 +02:00
parent 50a58f80a9
commit 5a2cfee6b4
4 changed files with 161 additions and 29 deletions
+2 -1
View File
@@ -33,6 +33,7 @@ import { MicPopover } from "./popovers/MicPopover";
import { MorePopover } from "./popovers/MorePopover";
import { SourcePopover } from "./popovers/SourcePopover";
import { WebcamPopover } from "./popovers/WebcamPopover";
import { MarqueeText } from "./SourceSelector";
import styles from "./LaunchWindow.module.css";
import { Separator } from "@/components/ui/separator";
@@ -235,7 +236,7 @@ function LaunchWindowContent() {
>
<MonitorIcon size={16} className="shrink-0" />
<div className="flex-1 min-w-0 overflow-hidden">
<div className="truncate">{selectedSource}</div>
<MarqueeText text={selectedSource} />
</div>
<CaretUpIcon
size={10}
+55 -5
View File
@@ -84,12 +84,62 @@
scrollbar-color: rgba(107, 107, 120, 0.2) transparent;
}
/* Marquee Animation */
@keyframes marquee {
0% {
.source-selector-marquee {
position: relative;
overflow: hidden;
white-space: nowrap;
}
.source-selector-marquee-static {
display: block;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: bottom;
}
.source-selector-marquee-animated {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
}
.source-selector-marquee-track {
display: inline-flex;
max-width: none;
white-space: nowrap;
will-change: transform;
animation: source-selector-marquee 10s linear infinite;
animation-play-state: paused;
}
.source-selector-marquee-segment {
display: inline-block;
}
.source-selector-marquee-duplicate {
padding-left: 20px;
}
.group:hover .source-selector-marquee[data-overflowing="true"] .source-selector-marquee-static {
opacity: 0;
}
.group:hover .source-selector-marquee[data-overflowing="true"] .source-selector-marquee-animated {
opacity: 1;
}
.group:hover .source-selector-marquee[data-overflowing="true"] .source-selector-marquee-track {
animation-play-state: running;
}
@keyframes source-selector-marquee {
from {
transform: translateX(0);
}
100% {
transform: translateX(var(--marquee-translate));
to {
transform: translateX(-50%);
}
}
+43 -5
View File
@@ -1,6 +1,6 @@
import * as React from "react";
import { Monitor, AppWindow, CaretUp as ChevronUp } from "@phosphor-icons/react";
import { useEffect, useRef } from "react";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { useScopedT } from "@/contexts/I18nContext";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
@@ -40,6 +40,42 @@ interface SourceSelectorProps {
children?: React.ReactNode;
}
export function MarqueeText({ text }: { text: string }) {
const staticRef = useRef<HTMLSpanElement>(null);
const [overflowing, setOverflowing] = useState(false);
useLayoutEffect(() => {
const node = staticRef.current;
if (!node) return;
const checkOverflow = () => {
setOverflowing(node.scrollWidth > node.clientWidth + 1);
};
checkOverflow();
const observer = new ResizeObserver(checkOverflow);
observer.observe(node);
return () => observer.disconnect();
}, [text]);
return (
<div
className="w-full source-selector-marquee"
data-overflowing={overflowing ? "true" : "false"}
>
<span ref={staticRef} className="source-selector-marquee-static">
{text}
</span>
<span className="source-selector-marquee-animated">
<span className="source-selector-marquee-track">
<span className="source-selector-marquee-segment">{text}</span>
<span className="source-selector-marquee-segment source-selector-marquee-duplicate">
{text}
</span>
</span>
</span>
</div>
);
}
/**
* SourceSelectorContent - The actual list of sources
*/
@@ -84,9 +120,9 @@ export const SourceSelectorContent = ({
)}
</div>
<div className="flex-1 min-w-0 flex flex-col items-start text-left">
<div className="text-sm font-medium source-selector-text truncate w-full">
{source.windowTitle || source.name}
<div className="flex-1 min-w-0 flex flex-col items-start text-left">
<div className="text-sm font-medium source-selector-text w-full">
<MarqueeText text={source.windowTitle || source.name} />
</div>
<div className="text-xs source-selector-subtle truncate w-full text-left">
{source.sourceType === "screen" ? t("recording.screen") : t("recording.window")}
@@ -196,7 +232,9 @@ export const SourceSelector = React.memo(function SourceSelector({
title={selectedSource}
>
<Monitor size={16} className="shrink-0" />
<div className="flex-1 min-w-0 truncate">{selectedSource}</div>
<div className="flex-1 min-w-0">
<MarqueeText text={selectedSource} />
</div>
<ChevronUp
size={10}
className={cn(
@@ -19,10 +19,13 @@ export function useWebcamPreviewOverlay({
}) {
const [showFloatingWebcamPreview, setShowFloatingWebcamPreview] = useState(true);
const [webcamPreviewOffset, setWebcamPreviewOffset] = useState(DEFAULT_WEBCAM_PREVIEW_OFFSET);
const webcamPreviewOffsetRef = useRef(DEFAULT_WEBCAM_PREVIEW_OFFSET);
const webcamPreviewRef = useRef<HTMLVideoElement | null>(null);
const recordingWebcamPreviewRef = useRef<HTMLVideoElement | null>(null);
const recordingWebcamPreviewContainerRef = useRef<HTMLDivElement | null>(null);
const previewStreamRef = useRef<MediaStream | null>(null);
const previewDragMoveRafRef = useRef<number | null>(null);
const previewDragPendingPointerRef = useRef<{ clientX: number; clientY: number } | null>(null);
const webcamPreviewDragStartRef = useRef<{
pointerId: number;
startX: number;
@@ -47,7 +50,11 @@ export function useWebcamPreviewOverlay({
useEffect(() => {
if (!webcamEnabled) {
webcamPreviewOffsetRef.current = DEFAULT_WEBCAM_PREVIEW_OFFSET;
setWebcamPreviewOffset(DEFAULT_WEBCAM_PREVIEW_OFFSET);
if (recordingWebcamPreviewContainerRef.current) {
recordingWebcamPreviewContainerRef.current.style.transform = "translate(0px, 0px)";
}
webcamPreviewDragStartRef.current = null;
isWebcamPreviewDraggingRef.current = false;
setShowFloatingWebcamPreview(true);
@@ -68,8 +75,8 @@ export function useWebcamPreviewOverlay({
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
originX: webcamPreviewOffset.x,
originY: webcamPreviewOffset.y,
originX: webcamPreviewOffsetRef.current.x,
originY: webcamPreviewOffsetRef.current.y,
initialLeft: previewRect.left,
initialTop: previewRect.top,
previewWidth: previewRect.width,
@@ -78,7 +85,7 @@ export function useWebcamPreviewOverlay({
};
event.currentTarget.setPointerCapture(event.pointerId);
},
[webcamPreviewOffset.x, webcamPreviewOffset.y],
[],
);
const handleWebcamPreviewPointerMove = useCallback((event: PointerEvent<HTMLDivElement>) => {
@@ -99,22 +106,42 @@ export function useWebcamPreviewOverlay({
isWebcamPreviewDraggingRef.current = true;
}
const viewportWidth = Math.max(window.innerWidth, window.screen?.width ?? 0);
const viewportHeight = Math.max(window.innerHeight, window.screen?.height ?? 0);
const unclampedLeft = dragState.initialLeft + deltaX;
const unclampedTop = dragState.initialTop + deltaY;
const clampedLeft = Math.min(
Math.max(0, unclampedLeft),
Math.max(0, viewportWidth - dragState.previewWidth),
);
const clampedTop = Math.min(
Math.max(0, unclampedTop),
Math.max(0, viewportHeight - dragState.previewHeight),
);
previewDragPendingPointerRef.current = { clientX: event.clientX, clientY: event.clientY };
if (previewDragMoveRafRef.current !== null) {
return;
}
setWebcamPreviewOffset({
x: dragState.originX + (clampedLeft - dragState.initialLeft),
y: dragState.originY + (clampedTop - dragState.initialTop),
previewDragMoveRafRef.current = requestAnimationFrame(() => {
previewDragMoveRafRef.current = null;
const latestDragState = webcamPreviewDragStartRef.current;
const pointer = previewDragPendingPointerRef.current;
if (!latestDragState || !pointer) {
return;
}
const latestDeltaX = pointer.clientX - latestDragState.startX;
const latestDeltaY = pointer.clientY - latestDragState.startY;
const viewportWidth = Math.max(window.innerWidth, window.screen?.width ?? 0);
const viewportHeight = Math.max(window.innerHeight, window.screen?.height ?? 0);
const unclampedLeft = latestDragState.initialLeft + latestDeltaX;
const unclampedTop = latestDragState.initialTop + latestDeltaY;
const clampedLeft = Math.min(
Math.max(0, unclampedLeft),
Math.max(0, viewportWidth - latestDragState.previewWidth),
);
const clampedTop = Math.min(
Math.max(0, unclampedTop),
Math.max(0, viewportHeight - latestDragState.previewHeight),
);
const nextOffset = {
x: latestDragState.originX + (clampedLeft - latestDragState.initialLeft),
y: latestDragState.originY + (clampedTop - latestDragState.initialTop),
};
webcamPreviewOffsetRef.current = nextOffset;
if (recordingWebcamPreviewContainerRef.current) {
recordingWebcamPreviewContainerRef.current.style.transform = `translate(${nextOffset.x}px, ${nextOffset.y}px)`;
}
});
}, []);
@@ -123,10 +150,16 @@ export function useWebcamPreviewOverlay({
if (!dragState || dragState.pointerId !== event.pointerId) {
return;
}
if (previewDragMoveRafRef.current !== null) {
cancelAnimationFrame(previewDragMoveRafRef.current);
previewDragMoveRafRef.current = null;
}
previewDragPendingPointerRef.current = null;
const wasDragging = dragState.dragging;
webcamPreviewDragStartRef.current = null;
isWebcamPreviewDraggingRef.current = false;
setWebcamPreviewOffset({ ...webcamPreviewOffsetRef.current });
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
@@ -166,6 +199,16 @@ export function useWebcamPreviewOverlay({
[attachPreviewStreamToNode],
);
useEffect(() => {
return () => {
if (previewDragMoveRafRef.current !== null) {
cancelAnimationFrame(previewDragMoveRafRef.current);
}
previewDragMoveRafRef.current = null;
previewDragPendingPointerRef.current = null;
};
}, []);
useEffect(() => {
let mounted = true;