mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-27 00:05:39 +00:00
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:
@@ -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}
|
||||
|
||||
@@ -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%);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user