Merge main into fix/305-hud-horizontal-scrollbar

Resolved conflicts in src/App.tsx and src/components/launch/LaunchWindow.tsx:
- App.tsx: kept main's split useEffect for loadAllCustomFonts; placed PR's
  HUD-overlay style block inside the original [windowType] effect.
- LaunchWindow.tsx: kept main's systemLocaleSuggestion modal in place of the
  earlier inline language switcher; preserved PR's root-div className change
  that fixes the Windows horizontal-scrollbar bug.
This commit is contained in:
Siddharth
2026-05-02 23:21:12 -07:00
153 changed files with 13396 additions and 7822 deletions
@@ -0,0 +1,30 @@
import { useEffect, useState } from "react";
export function CountdownOverlay() {
const [value, setValue] = useState<number | null>(null);
useEffect(() => {
const unsubscribe = window.electronAPI.onCountdownOverlayValue((nextValue) => {
setValue(nextValue);
});
return () => unsubscribe();
}, []);
if (value === null) {
return null;
}
return (
<div className="w-screen h-screen bg-transparent flex items-center justify-center pointer-events-none select-none">
<div className="flex items-center justify-center w-40 h-40 rounded-full bg-black/50">
<div
className="text-white/90 text-[80px] font-bold leading-none tabular-nums"
style={{ textShadow: "0 4px 24px rgba(0, 0, 0, 0.65)" }}
>
{value}
</div>
</div>
</div>
);
}
@@ -6,3 +6,78 @@
.electronNoDrag {
-webkit-app-region: no-drag;
}
.languageMenuScroll {
max-height: 16rem;
overflow-y: auto;
overflow-x: hidden;
overscroll-behavior: contain;
touch-action: pan-y;
-webkit-overflow-scrolling: touch;
}
.languageMenuScroll::-webkit-scrollbar {
width: 8px;
}
.languageMenuScroll::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.04);
border-radius: 999px;
}
.languageMenuScroll::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.35), rgba(255, 255, 255, 0.2));
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.15);
}
.languageMenuScroll::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.3));
}
.languageMenuContainer {
position: relative;
z-index: 20;
}
.languageMenuPanel {
position: fixed;
right: 0;
top: 0;
width: 12rem;
padding: 0.375rem;
border-radius: 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.14);
background: linear-gradient(160deg, rgba(28, 29, 42, 0.98), rgba(18, 19, 28, 0.98));
box-shadow: 0 20px 45px rgba(0, 0, 0, 0.55);
backdrop-filter: blur(14px);
pointer-events: auto;
box-sizing: border-box;
}
.languageMenuItem {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.625rem;
border-radius: 0.5rem;
font-size: 11px;
color: rgba(255, 255, 255, 0.88);
background: transparent;
border: 0;
cursor: pointer;
transition: background-color 120ms ease, color 120ms ease;
}
.languageMenuItem:hover,
.languageMenuItem:focus-visible {
background: rgba(255, 255, 255, 0.1);
color: #ffffff;
outline: none;
}
.languageMenuItemActive {
background: rgba(255, 255, 255, 0.12);
color: #ffffff;
}
+276 -108
View File
@@ -1,10 +1,12 @@
import { ChevronDown, Languages } from "lucide-react";
import { useEffect, useState } from "react";
import { BsRecordCircle } from "react-icons/bs";
import { Check, ChevronDown, Languages } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { BsPauseCircle, BsPlayCircle, BsRecordCircle } from "react-icons/bs";
import { FaRegStopCircle } from "react-icons/fa";
import { FaFolderOpen } from "react-icons/fa6";
import { FiMinus, FiX } from "react-icons/fi";
import {
MdCancel,
MdMic,
MdMicOff,
MdMonitor,
@@ -17,9 +19,7 @@ import {
} from "react-icons/md";
import { RxDragHandleDots2 } from "react-icons/rx";
import { useI18n, useScopedT } from "@/contexts/I18nContext";
import { type Locale, SUPPORTED_LOCALES } from "@/i18n/config";
import { getLocaleName } from "@/i18n/loader";
import { isMac as getIsMac } from "@/utils/platformUtils";
import { getAvailableLocales, getLocaleName } from "@/i18n/loader";
import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter";
import { useCameraDevices } from "../../hooks/useCameraDevices";
import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices";
@@ -27,6 +27,7 @@ import { useScreenRecorder } from "../../hooks/useScreenRecorder";
import { requestCameraAccess } from "../../lib/requestCameraAccess";
import { formatTimePadded } from "../../utils/timeUtils";
import { AudioLevelMeter } from "../ui/audio-level-meter";
import { Button } from "../ui/button";
import { Tooltip } from "../ui/tooltip";
import styles from "./LaunchWindow.module.css";
@@ -41,8 +42,11 @@ const ICON_CONFIG = {
micOff: { icon: MdMicOff, size: ICON_SIZE },
webcamOn: { icon: MdVideocam, size: ICON_SIZE },
webcamOff: { icon: MdVideocamOff, size: ICON_SIZE },
pause: { icon: BsPauseCircle, size: ICON_SIZE },
resume: { icon: BsPlayCircle, size: ICON_SIZE },
stop: { icon: FaRegStopCircle, size: ICON_SIZE },
restart: { icon: MdRestartAlt, size: ICON_SIZE },
cancel: { icon: MdCancel, size: ICON_SIZE },
record: { icon: BsRecordCircle, size: ICON_SIZE },
videoFile: { icon: MdVideoFile, size: ICON_SIZE },
folder: { icon: FaFolderOpen, size: ICON_SIZE },
@@ -63,22 +67,35 @@ const hudGroupClasses =
const hudIconBtnClasses =
"flex items-center justify-center p-2 rounded-full transition-all duration-150 cursor-pointer text-white hover:bg-white/10 hover:scale-[1.08] active:scale-95";
const hudAuxIconBtnClasses =
"flex items-center justify-center p-1.5 rounded-full transition-colors duration-150 text-white/55 hover:bg-white/10 disabled:opacity-30 disabled:cursor-not-allowed";
const windowBtnClasses =
"flex items-center justify-center p-2 rounded-full transition-all duration-150 cursor-pointer opacity-50 hover:opacity-90 hover:bg-white/[0.08]";
const hudSidebarClasses = "ml-0.5 pl-1.5 border-l border-white/10 flex items-center gap-0.5";
export function LaunchWindow() {
const t = useScopedT("launch");
const { locale, setLocale } = useI18n();
const [isMac, setIsMac] = useState(false);
useEffect(() => {
getIsMac().then(setIsMac);
}, []);
const availableLocales = getAvailableLocales();
const {
locale,
setLocale,
systemLocaleSuggestion,
acceptSystemLocaleSuggestion,
dismissSystemLocaleSuggestion,
resolveSystemLocaleSuggestion,
} = useI18n();
const suggestedLanguageName = systemLocaleSuggestion ? getLocaleName(systemLocaleSuggestion) : "";
const {
recording,
paused,
elapsedSeconds,
toggleRecording,
togglePaused,
restartRecording,
cancelRecording,
microphoneEnabled,
setMicrophoneEnabled,
microphoneDeviceId,
@@ -90,8 +107,6 @@ export function LaunchWindow() {
webcamDeviceId,
setWebcamDeviceId,
} = useScreenRecorder();
const [recordingStart, setRecordingStart] = useState<number | null>(null);
const [elapsed, setElapsed] = useState(0);
const showMicControls = microphoneEnabled && !recording;
const showWebcamControls = webcamEnabled && !recording;
@@ -103,6 +118,18 @@ export function LaunchWindow() {
const [isWebcamHovered, setIsWebcamHovered] = useState(false);
const [isWebcamFocused, setIsWebcamFocused] = useState(false);
const webcamExpanded = isWebcamHovered || isWebcamFocused;
const [isLanguageMenuOpen, setIsLanguageMenuOpen] = useState(false);
const languageTriggerRef = useRef<HTMLButtonElement | null>(null);
const languageMenuPanelRef = useRef<HTMLDivElement | null>(null);
const [languageMenuStyle, setLanguageMenuStyle] = useState<{
right: number;
top: number;
maxHeight: number;
}>({
right: 12,
top: 12,
maxHeight: 240,
});
const {
devices: micDevices,
@@ -146,25 +173,6 @@ export function LaunchWindow() {
}
}, [selectedCameraId, setWebcamDeviceId]);
useEffect(() => {
let timer: NodeJS.Timeout | null = null;
if (recording) {
if (!recordingStart) setRecordingStart(Date.now());
timer = setInterval(() => {
if (recordingStart) {
setElapsed(Math.floor((Date.now() - recordingStart) / 1000));
}
}, 1000);
} else {
setRecordingStart(null);
setElapsed(0);
if (timer) clearInterval(timer);
}
return () => {
if (timer) clearInterval(timer);
};
}, [recording, recordingStart]);
useEffect(() => {
if (!import.meta.env.DEV) {
return;
@@ -175,6 +183,71 @@ export function LaunchWindow() {
});
}, []);
useEffect(() => {
if (!isLanguageMenuOpen) return;
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as Node;
const clickedTrigger = languageTriggerRef.current?.contains(target);
const clickedMenu = languageMenuPanelRef.current?.contains(target);
if (!clickedTrigger && !clickedMenu) {
setIsLanguageMenuOpen(false);
}
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setIsLanguageMenuOpen(false);
}
};
window.addEventListener("pointerdown", handlePointerDown);
window.addEventListener("keydown", handleEscape);
return () => {
window.removeEventListener("pointerdown", handlePointerDown);
window.removeEventListener("keydown", handleEscape);
};
}, [isLanguageMenuOpen]);
useEffect(() => {
if (!isLanguageMenuOpen || !languageTriggerRef.current) return;
const updatePosition = () => {
if (!languageTriggerRef.current) return;
const rect = languageTriggerRef.current.getBoundingClientRect();
const gap = 8;
const viewportPadding = 8;
const availableHeight = Math.max(80, rect.top - viewportPadding - gap);
const top = Math.max(viewportPadding, rect.top - gap - availableHeight);
setLanguageMenuStyle({
right: Math.max(viewportPadding, window.innerWidth - rect.right),
top,
maxHeight: availableHeight,
});
};
updatePosition();
window.addEventListener("resize", updatePosition);
window.addEventListener("scroll", updatePosition, true);
return () => {
window.removeEventListener("resize", updatePosition);
window.removeEventListener("scroll", updatePosition, true);
};
}, [isLanguageMenuOpen]);
useEffect(() => {
if (!isLanguageMenuOpen || !languageMenuPanelRef.current) return;
const id = requestAnimationFrame(() => {
if (languageMenuPanelRef.current) {
languageMenuPanelRef.current.scrollTop = 0;
}
});
return () => cancelAnimationFrame(id);
}, [isLanguageMenuOpen]);
const [selectedSource, setSelectedSource] = useState("Screen");
const [hasSelectedSource, setHasSelectedSource] = useState(false);
@@ -241,30 +314,48 @@ export function LaunchWindow() {
};
return (
// Root fills the HUD window only. Avoid `w-screen`/`h-screen` (`100vw`/`100vh`): `100vw` can
// exceed the inner layout width when scrollbars affect the viewport (notably on Windows), which
// showed up as a horizontal scrollbar once recording widened the toolbar (issue #305).
// Root fills the HUD window only. Avoid w-screen/h-screen (100vw/100vh):
// 100vw can exceed the inner layout width when scrollbars affect the
// viewport (notably on Windows), causing a horizontal scrollbar once the
// recording toolbar widened (issue #305).
<div
className={`h-full w-full min-w-0 max-w-full overflow-x-hidden overflow-y-hidden bg-transparent ${styles.electronDrag}`}
>
{/* Language switcher — top-left, beside traffic lights */}
<div
className={`fixed top-2 flex items-center gap-1 px-2 py-1 rounded-md text-white/50 hover:text-white/90 hover:bg-white/10 transition-all duration-150 ${isMac ? "left-[72px]" : "left-2"} ${styles.electronNoDrag}`}
>
<Languages size={14} />
<select
value={locale}
onChange={(e) => setLocale(e.target.value as Locale)}
className="bg-transparent text-[11px] font-medium outline-none cursor-pointer appearance-none pr-1"
style={{ color: "inherit" }}
{systemLocaleSuggestion && (
<div
className={`fixed top-8 left-1/2 z-30 w-[calc(100vw-1rem)] max-w-[520px] -translate-x-1/2 rounded-xl border border-white/15 bg-[rgba(20,20,28,0.95)] p-3 shadow-2xl backdrop-blur-xl text-white animate-in fade-in-0 zoom-in-95 duration-200 ${styles.electronNoDrag}`}
>
{SUPPORTED_LOCALES.map((loc) => (
<option key={loc} value={loc} className="bg-[#1c1c24] text-white">
{getLocaleName(loc)}
</option>
))}
</select>
</div>
<div className="text-[13px] font-semibold text-white">
{t("systemLanguagePrompt.title")}
</div>
<div className="mt-1 text-[11px] leading-relaxed text-white/75">
{t("systemLanguagePrompt.description", {
language: suggestedLanguageName,
})}
</div>
<div className="mt-3 flex items-center justify-end gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={dismissSystemLocaleSuggestion}
className="h-7 text-xs text-white/80 hover:bg-white/10 hover:text-white"
>
{t("systemLanguagePrompt.keepDefault")}
</Button>
<Button
type="button"
size="sm"
onClick={acceptSystemLocaleSuggestion}
className="h-7 text-xs bg-white text-[#10121b] hover:bg-white/90"
>
{t("systemLanguagePrompt.switch", {
language: suggestedLanguageName,
})}
</Button>
</div>
</div>
)}
{/* Device selectors — fixed above HUD bar, viewport-relative, never clipped */}
{(showMicControls || showWebcamControls) && (
@@ -441,6 +532,7 @@ export function LaunchWindow() {
onClick={async () => {
await setWebcamEnabled(!webcamEnabled);
}}
disabled={recording}
title={webcamEnabled ? t("webcam.disableWebcam") : t("webcam.enableWebcam")}
>
{webcamEnabled
@@ -451,75 +543,151 @@ export function LaunchWindow() {
{/* Record/Stop group */}
<button
className={`flex items-center gap-0.5 rounded-full p-2 transition-colors duration-150 ${styles.electronNoDrag} ${
recording ? "animate-record-pulse bg-red-500/10" : "bg-white/5 hover:bg-white/[0.08]"
className={`flex items-center justify-center rounded-full p-2 transition-[min-width,background-color] duration-150 ${recording ? "min-w-[78px]" : "min-w-[36px]"} ${styles.electronNoDrag} ${
recording
? paused
? "bg-amber-500/10 hover:bg-amber-500/15"
: "bg-red-500/12 hover:bg-red-500/16"
: "bg-white/5 hover:bg-white/[0.08]"
}`}
onClick={toggleRecording}
disabled={!hasSelectedSource && !recording}
style={{ flex: "0 0 auto" }}
>
{recording ? (
<>
{getIcon("stop", "text-red-400")}
<span className="text-red-400 text-xs font-semibold tabular-nums">
{formatTimePadded(elapsed)}
<div className={`flex items-center justify-center ${recording ? "gap-1.5" : ""}`}>
{recording
? getIcon("stop", paused ? "text-amber-400" : "text-red-400")
: getIcon("record", hasSelectedSource ? "text-white/80" : "text-white/30")}
{recording && (
<span
className={`${paused ? "text-amber-400" : "text-red-400"} inline-block w-[34px] text-left text-xs font-semibold tabular-nums`}
>
{formatTimePadded(elapsedSeconds)}
</span>
</>
) : (
getIcon("record", hasSelectedSource ? "text-white/80" : "text-white/30")
)}
)}
</div>
</button>
{/* Restart recording */}
{recording && (
<Tooltip content={t("tooltips.restartRecording")}>
<button
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
onClick={restartRecording}
<div className={`flex items-center gap-0.5 ${styles.electronNoDrag}`}>
<Tooltip
content={paused ? t("tooltips.resumeRecording") : t("tooltips.pauseRecording")}
>
{getIcon("restart", "text-white/60")}
</button>
</Tooltip>
<button className={hudAuxIconBtnClasses} onClick={togglePaused}>
{getIcon(paused ? "resume" : "pause", paused ? "text-amber-400" : "text-white/60")}
</button>
</Tooltip>
<Tooltip content={t("tooltips.restartRecording")}>
<button className={hudAuxIconBtnClasses} onClick={restartRecording}>
{getIcon("restart", "text-white/60")}
</button>
</Tooltip>
<Tooltip content={t("tooltips.cancelRecording")}>
<button className={hudAuxIconBtnClasses} onClick={cancelRecording}>
{getIcon("cancel", "text-white/60")}
</button>
</Tooltip>
</div>
)}
{/* Open video file */}
<Tooltip content={t("tooltips.openVideoFile")}>
<button
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
onClick={openVideoFile}
disabled={recording}
>
{getIcon("videoFile", "text-white/60")}
</button>
</Tooltip>
{!recording && (
<>
{/* Open video file */}
<Tooltip content={t("tooltips.openVideoFile")}>
<button
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
onClick={openVideoFile}
>
{getIcon("videoFile", "text-white/60")}
</button>
</Tooltip>
{/* Open project */}
<Tooltip content={t("tooltips.openProject")}>
<button
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
onClick={openProjectFile}
disabled={recording}
>
{getIcon("folder", "text-white/60")}
</button>
</Tooltip>
{/* Open project */}
<Tooltip content={t("tooltips.openProject")}>
<button
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
onClick={openProjectFile}
>
{getIcon("folder", "text-white/60")}
</button>
</Tooltip>
</>
)}
{/* Window controls */}
<div className={`flex items-center gap-0.5 ${styles.electronNoDrag}`}>
<button
className={windowBtnClasses}
title={t("tooltips.hideHUD")}
onClick={sendHudOverlayHide}
>
{getIcon("minimize", "text-white")}
</button>
<button
className={windowBtnClasses}
title={t("tooltips.closeApp")}
onClick={sendHudOverlayClose}
>
{getIcon("close", "text-white")}
</button>
{/* Right sidebar controls */}
<div className={`${hudSidebarClasses} ${styles.electronNoDrag}`}>
<div className={`${styles.languageMenuContainer} ${styles.electronNoDrag}`}>
<button
ref={languageTriggerRef}
type="button"
aria-label={t("language")}
aria-expanded={isLanguageMenuOpen}
aria-haspopup="menu"
onClick={() => setIsLanguageMenuOpen((open) => !open)}
className={`h-8 w-8 rounded-lg border border-white/10 bg-white/5 text-white/85 shadow-none transition-colors hover:bg-white/10 ${styles.electronNoDrag}`}
>
<div className="flex w-full items-center justify-center">
<Languages size={13} className="text-white/75" />
</div>
</button>
</div>
{isLanguageMenuOpen
? createPortal(
<div
ref={languageMenuPanelRef}
role="menu"
className={`${styles.languageMenuPanel} ${styles.languageMenuScroll} ${styles.electronNoDrag}`}
style={
{
WebkitAppRegion: "no-drag",
pointerEvents: "auto",
right: `${languageMenuStyle.right}px`,
top: `${languageMenuStyle.top}px`,
maxHeight: `${languageMenuStyle.maxHeight}px`,
} as React.CSSProperties
}
onPointerDown={(event) => event.stopPropagation()}
>
{availableLocales.map((loc) => (
<button
key={loc}
type="button"
role="menuitemradio"
aria-checked={loc === locale}
onClick={() => {
setLocale(loc);
resolveSystemLocaleSuggestion();
setIsLanguageMenuOpen(false);
}}
className={`${styles.languageMenuItem} ${loc === locale ? styles.languageMenuItemActive : ""}`}
>
<span className="truncate">{getLocaleName(loc)}</span>
{loc === locale ? <Check size={11} className="text-white/85" /> : null}
</button>
))}
</div>,
document.body,
)
: null}
{/* Window controls */}
<div className="flex items-center gap-0.5">
<button
className={windowBtnClasses}
title={t("tooltips.hideHUD")}
onClick={sendHudOverlayHide}
>
{getIcon("minimize", "text-white")}
</button>
<button
className={windowBtnClasses}
title={t("tooltips.closeApp")}
onClick={sendHudOverlayClose}
>
{getIcon("close", "text-white")}
</button>
</div>
</div>
</div>
</div>
+19 -16
View File
@@ -2,15 +2,21 @@
background: linear-gradient(135deg, rgba(28, 28, 34, 0.92) 0%, rgba(18, 18, 22, 0.88) 100%);
backdrop-filter: blur(20px) saturate(160%);
-webkit-backdrop-filter: blur(20px) saturate(160%);
border-radius: 14px;
box-shadow:
0 4px 16px 0 rgba(0, 0, 0, 0.32),
0 1px 3px 0 rgba(0, 0, 0, 0.18) inset;
border: 1px solid rgba(60, 60, 80, 0.18);
border-radius: 30px;
corner-shape: squircle;
/*
Removed box-shadow here because electron doesn't round corners of the shadow, thereby leaving a square border shadow conflicting with the rounded corners of the SourceSelector.
The result is easily visible when you place a white window just behind the SourceSelector
*/
/* box-shadow:
0 0px 16px 0 rgba(0, 0, 0, 0.32),
0 1px 3px 0 rgba(0, 0, 0, 0.18) inset; */
border: 1.5px solid rgba(60, 60, 80, 0.3);
}
.sourceCard {
border-radius: 12px;
corner-shape: squircle;
border-radius: 20px;
background: linear-gradient(120deg, rgba(38, 38, 48, 0.98) 0%, rgba(24, 24, 32, 0.96) 100%);
border: 1px solid rgba(60, 60, 80, 0.22);
box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.18);
@@ -28,7 +34,7 @@
}
.selected {
border: 2px solid #34b27b;
border: 1.5px solid #34b27b;
background: linear-gradient(120deg, rgba(52, 178, 123, 0.08) 0%, rgba(38, 38, 48, 0.98) 100%);
box-shadow:
0 0 12px rgba(52, 178, 123, 0.15),
@@ -70,30 +76,27 @@
}
/* scrollbar */
.sourceGridScroll {
scrollbar-width: thin;
scrollbar-color: rgba(52, 178, 123, 0.5) rgba(40, 40, 50, 0.6);
}
.sourceGridScroll::-webkit-scrollbar {
width: 8px;
width: 3px;
}
.sourceGridScroll::-webkit-scrollbar-track {
background: rgba(30, 30, 38, 0.5);
background: rgba(30, 30, 38, 0.3);
border-radius: 4px;
margin: 4px 0;
}
.sourceGridScroll::-webkit-scrollbar-thumb {
background: rgba(80, 80, 100, 0.6);
border-radius: 4px;
background: rgba(52, 178, 123, 0.5);
border-radius: 10px;
}
.sourceGridScroll::-webkit-scrollbar-thumb:hover {
background: rgba(52, 178, 123, 0.6);
cursor: grab;
}
.sourceGridScroll::-webkit-scrollbar-thumb:active {
background: rgba(52, 178, 123, 0.8);
cursor: grabbing;
}
+11 -11
View File
@@ -65,7 +65,7 @@ export function SourceSelector() {
style={{ minHeight: "100vh" }}
>
<div className="text-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-[#34B27B] mx-auto mb-2" />
<div className="animate-spin duration-500 rounded-[50%] h-6 w-6 border-2 border-b-transparent border-[#34B27B] mx-auto mb-2" />
<p className="text-xs text-zinc-400">{t("sourceSelector.loading")}</p>
</div>
</div>
@@ -84,10 +84,10 @@ export function SourceSelector() {
<img
src={source.thumbnail || ""}
alt={source.name}
className="w-full aspect-video object-cover rounded-lg"
className="w-full aspect-video object-cover rounded-xl [corner-shape:squircle] "
/>
{isSelected && (
<div className="absolute -top-1.5 -right-1.5">
<div className="absolute -top-1 -right-1">
<div className={styles.checkBadge}>
<MdCheck size={12} className="text-white" />
</div>
@@ -111,16 +111,16 @@ export function SourceSelector() {
defaultValue={screenSources.length === 0 ? "windows" : "screens"}
className="flex-1 flex flex-col"
>
<TabsList className="grid grid-cols-2 mb-3 bg-white/5 rounded-full">
<TabsList className="grid grid-cols-2 mb-3 bg-white/5 rounded-[14px] squircle ">
<TabsTrigger
value="screens"
className="data-[state=active]:bg-white/15 data-[state=active]:text-white text-zinc-400 rounded-full text-xs py-1 transition-all"
className="data-[state=active]:bg-white/15 data-[state=active]:text-white text-zinc-400 rounded-[12px] squircle text-xs py-1.5 transition-all"
>
{t("sourceSelector.screens", { count: String(screenSources.length) })}
</TabsTrigger>
<TabsTrigger
value="windows"
className="data-[state=active]:bg-white/15 data-[state=active]:text-white text-zinc-400 rounded-full text-xs py-1 transition-all"
className="data-[state=active]:bg-white/15 data-[state=active]:text-white text-zinc-400 rounded-[12px] squircle text-xs py-1.5 transition-all"
>
{t("sourceSelector.windows", { count: String(windowSources.length) })}
</TabsTrigger>
@@ -128,14 +128,14 @@ export function SourceSelector() {
<div className="flex-1 min-h-0">
<TabsContent value="screens" className="h-full mt-0">
<div
className={`grid grid-cols-2 gap-3 h-[280px] overflow-y-auto pr-1 auto-rows-min ${styles.sourceGridScroll}`}
className={`grid grid-cols-2 gap-3 h-[280px] overflow-y-auto pt-1 pr-1.5 auto-rows-min ${styles.sourceGridScroll}`}
>
{screenSources.map(renderSourceCard)}
</div>
</TabsContent>
<TabsContent value="windows" className="h-full mt-0">
<div
className={`grid grid-cols-2 gap-3 h-[280px] overflow-y-auto pr-1 auto-rows-min ${styles.sourceGridScroll}`}
className={`grid grid-cols-2 gap-3 h-[280px] overflow-y-auto pt-1 pr-1.5 auto-rows-min ${styles.sourceGridScroll}`}
>
{windowSources.map(renderSourceCard)}
</div>
@@ -143,18 +143,18 @@ export function SourceSelector() {
</div>
</Tabs>
</div>
<div className="p-3 flex justify-center gap-2">
<div className="p-3 justify-center flex gap-2">
<Button
variant="ghost"
onClick={() => window.close()}
className="px-5 py-1 text-xs text-zinc-400 hover:text-white hover:bg-white/5 rounded-full"
className="px-5 py-1 text-xs text-zinc-400 hover:text-white active:scale-95 transition-transform duration-150 hover:bg-white/5 rounded-full"
>
{tc("actions.cancel")}
</Button>
<Button
onClick={handleShare}
disabled={!selectedSource}
className="px-5 py-1 text-xs bg-[#34B27B] text-white hover:bg-[#34B27B]/80 disabled:opacity-30 disabled:bg-zinc-700 rounded-full"
className="px-5 py-1 text-xs bg-[#34B27B] text-white active:scale-95 transition-transform duration-150 hover:bg-[#34B27B]/80 disabled:opacity-30 disabled:bg-zinc-700 rounded-full"
>
{tc("actions.share")}
</Button>
+1 -1
View File
@@ -52,4 +52,4 @@ const AccordionContent = React.forwardRef<
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };
+1 -1
View File
@@ -52,4 +52,4 @@ const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDiv
);
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };
+161
View File
@@ -0,0 +1,161 @@
import { HsvaColor, hexToHsva } from "@uiw/color-convert";
import Block from "@uiw/react-color-block";
import Colorful from "@uiw/react-color-colorful";
import { useEffect, useState } from "react";
import { Button } from "./button";
import { Input } from "./input";
type BaseProps = {
selectedColor: string;
colorPalette: string[];
onUpdateColor: (color: string) => void;
};
type ColorPickerProps =
| (BaseProps & {
clearBackgroundOption?: false;
translations: Record<"colorWheel" | "colorPalette", string>;
})
| (BaseProps & {
clearBackgroundOption: true;
translations: Record<"colorWheel" | "colorPalette" | "clearBackground", string>;
});
export default function ColorPicker(props: ColorPickerProps) {
const { selectedColor, colorPalette, translations, onUpdateColor } = props;
const [colorMode, setColorMode] = useState<"wheel" | "palette">("wheel");
const [hexInput, setHexInput] = useState(selectedColor);
const [transparentColorHSVA, setTransparentColorHSVA] = useState<HsvaColor>({
h: 0,
s: 0,
v: 0,
a: 0,
});
useEffect(() => {
setHexInput(selectedColor);
}, [selectedColor]);
const getTextColor = (color: string) => {
if (color === "transparent") return "#ffffff";
const r = parseInt(color.slice(1, 3), 16);
const g = parseInt(color.slice(3, 5), 16);
const b = parseInt(color.slice(5, 7), 16);
const luminance = 0.299 * r + 0.587 * g + 0.114 * b;
if (luminance > 186) return "#000000";
return "#ffffff";
};
// Normalize the hex input.
// Adds a # at the beginning of the input if it's not there.
const normalizeHexDraft = (raw: string) => {
const trimmed = raw.trim();
if (trimmed === "") return "";
if (/^[0-9A-Fa-f]/.test(trimmed[0])) return `#${trimmed}`;
return trimmed;
};
const handleColorInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const normalized = normalizeHexDraft(e.target.value);
setHexInput(normalized);
// Check if the normalized hex is a valid hex color.
// It should follow the format #RRGGBB or #RGB.
const isValidHexColor =
/^#[0-9A-Fa-f]{3}$/.test(normalized) || /^#[0-9A-Fa-f]{6}$/.test(normalized);
if (isValidHexColor) {
onUpdateColor(normalized);
}
};
const toTransparent = (color: string) => {
if (color === "transparent") return;
const hsva = hexToHsva(color);
hsva.a = 0;
return hsva;
};
return (
<div className="p-1 flex flex-col gap-4 items-center">
<div className="flex items-center gap-2 w-full">
<Button
variant="outline"
size="sm"
className="w-full h-9 justify-start gap-2 bg-white/5 border-white/10 hover:bg-white/10 px-2"
onClick={() => setColorMode("wheel")}
style={{
backgroundColor: colorMode === "wheel" ? "#34B27B" : "transparent",
}}
>
<span className="text-xs text-slate-300 truncate flex-1 text-left">
{translations.colorWheel}
</span>
</Button>
<Button
variant="outline"
size="sm"
className="w-full h-9 justify-start gap-2 bg-white/5 border-white/10 hover:bg-white/10 px-2"
onClick={() => setColorMode("palette")}
style={{
backgroundColor: colorMode === "palette" ? "#34B27B" : "transparent",
}}
>
<span className="text-xs text-slate-300 truncate flex-1 text-left">
{translations.colorPalette}
</span>
</Button>
</div>
{colorMode === "wheel" && (
<>
<div
className={`w-full h-20 flex items-center justify-center border border-white/10 rounded-lg`}
style={{ backgroundColor: selectedColor }}
>
<span style={{ color: getTextColor(selectedColor) }}>{selectedColor}</span>
</div>
<Colorful
color={selectedColor !== "transparent" ? selectedColor : transparentColorHSVA}
onChange={(color) => {
onUpdateColor(color.hex);
}}
style={{
borderRadius: "8px",
}}
disableAlpha={true}
/>
<Input
type="text"
value={hexInput}
className="w-full h-9 rounded-md border border-white/10 bg-white/5 px-2 text-xs text-slate-200 outline-none focus:border-[#34B27B]/50 focus:ring-1 focus:ring-[#34B27B]/30 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
onChange={handleColorInputChange}
/>
</>
)}
{colorMode === "palette" && (
<Block
color={selectedColor !== "transparent" ? selectedColor : transparentColorHSVA}
colors={colorPalette}
onChange={(color) => {
onUpdateColor(color.hex);
}}
style={{
width: "100%",
borderRadius: "8px",
}}
/>
)}
{props.clearBackgroundOption === true && (
<Button
variant="ghost"
size="sm"
className="w-full mt-2 text-xs h-7 hover:bg-white/5 text-slate-400"
onClick={() => {
const hsva = toTransparent(selectedColor);
if (hsva) setTransparentColorHSVA(hsva);
onUpdateColor("transparent");
}}
>
{props.translations.clearBackground}
</Button>
)}
</div>
);
}
+6 -6
View File
@@ -90,13 +90,13 @@ DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
+20 -12
View File
@@ -54,9 +54,11 @@ DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayNam
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> & {
portalled?: boolean;
}
>(({ className, sideOffset = 4, portalled = true, ...props }, ref) => {
const content = (
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
@@ -67,8 +69,14 @@ const DropdownMenuContent = React.forwardRef<
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
);
if (!portalled) {
return content;
}
return <DropdownMenuPrimitive.Portal>{content}</DropdownMenuPrimitive.Portal>;
});
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
@@ -169,18 +177,18 @@ DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
DropdownMenuTrigger,
};
+1 -1
View File
@@ -57,4 +57,4 @@ function PopoverArrow({
);
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor, PopoverArrow };
export { Popover, PopoverAnchor, PopoverArrow, PopoverContent, PopoverTrigger };
+46 -30
View File
@@ -62,34 +62,50 @@ SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayNam
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> & {
showScrollButtons?: boolean;
viewportClassName?: string;
}
>(
(
{
className,
children,
position = "popper",
showScrollButtons = true,
viewportClassName,
...props
},
ref,
) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"p-1",
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1",
className,
)}
position={position}
{...props}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
{showScrollButtons ? <SelectScrollUpButton /> : null}
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"max-h-[var(--radix-select-content-available-height)] w-full min-w-[var(--radix-select-trigger-width)]",
viewportClassName,
)}
>
{children}
</SelectPrimitive.Viewport>
{showScrollButtons ? <SelectScrollDownButton /> : null}
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
),
);
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
@@ -141,13 +157,13 @@ SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectGroup,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};
+1 -1
View File
@@ -50,4 +50,4 @@ const TabsContent = React.forwardRef<
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };
export { Tabs, TabsContent, TabsList, TabsTrigger };
+1 -1
View File
@@ -67,4 +67,4 @@ function Tooltip({
);
}
export { TooltipProvider, TooltipRoot, TooltipTrigger, TooltipContent, Tooltip };
export { Tooltip, TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger };
+431 -16
View File
@@ -1,8 +1,40 @@
import { useRef } from "react";
import { type CSSProperties, type PointerEvent, useEffect, useRef, useState } from "react";
import { Rnd } from "react-rnd";
import {
getBlurOverlayColor,
getMosaicGridOverlayColor,
getNormalizedMosaicBlockSize,
} from "@/lib/blurEffects";
import { cn } from "@/lib/utils";
import { getArrowComponent } from "./ArrowSvgs";
import type { AnnotationRegion } from "./types";
import {
type AnnotationRegion,
type BlurData,
DEFAULT_BLUR_BLOCK_SIZE,
DEFAULT_BLUR_DATA,
DEFAULT_BLUR_INTENSITY,
} from "./types";
const FREEHAND_POINT_THRESHOLD = 1;
type PreviewCanvasSource = {
width: number;
height: number;
clientWidth?: number;
clientHeight?: number;
};
function buildBlurPolygonClipPath(points: Array<{ x: number; y: number }>) {
if (points.length < 3) return undefined;
const polygon = points.map((point) => `${point.x}% ${point.y}%`).join(", ");
return `polygon(${polygon})`;
}
function buildBlurFreehandPath(points: Array<{ x: number; y: number }>, closed = true) {
if (closed ? points.length < 3 : points.length < 2) return null;
const [firstPoint, ...rest] = points;
const path = `M ${firstPoint.x} ${firstPoint.y} ${rest.map((point) => `L ${point.x} ${point.y}`).join(" ")}`;
return closed ? `${path} Z` : path;
}
interface AnnotationOverlayProps {
annotation: AnnotationRegion;
@@ -11,9 +43,13 @@ interface AnnotationOverlayProps {
containerHeight: number;
onPositionChange: (id: string, position: { x: number; y: number }) => void;
onSizeChange: (id: string, size: { width: number; height: number }) => void;
onBlurDataChange?: (id: string, blurData: BlurData) => void;
onBlurDataCommit?: () => void;
onClick: (id: string) => void;
zIndex: number;
isSelectedBoost: boolean; // Boost z-index when selected for easy editing
previewSourceCanvas?: PreviewCanvasSource | null;
previewFrameVersion?: number;
}
export function AnnotationOverlay({
@@ -23,16 +59,130 @@ export function AnnotationOverlay({
containerHeight,
onPositionChange,
onSizeChange,
onBlurDataChange,
onBlurDataCommit,
onClick,
zIndex,
isSelectedBoost,
previewSourceCanvas,
previewFrameVersion,
}: AnnotationOverlayProps) {
const x = (annotation.position.x / 100) * containerWidth;
const y = (annotation.position.y / 100) * containerHeight;
const width = (annotation.size.width / 100) * containerWidth;
const height = (annotation.size.height / 100) * containerHeight;
const committedX = (annotation.position.x / 100) * containerWidth;
const committedY = (annotation.position.y / 100) * containerHeight;
const committedWidth = (annotation.size.width / 100) * containerWidth;
const committedHeight = (annotation.size.height / 100) * containerHeight;
const blurShape = annotation.type === "blur" ? (annotation.blurData?.shape ?? "rectangle") : null;
const isSelectedFreehandBlur = isSelected && blurShape === "freehand";
const isDraggingRef = useRef(false);
const isDrawingFreehandRef = useRef(false);
const freehandPointsRef = useRef<Array<{ x: number; y: number }>>([]);
const [isFreehandDrawing, setIsFreehandDrawing] = useState(false);
const [draftFreehandPoints, setDraftFreehandPoints] = useState<Array<{ x: number; y: number }>>(
[],
);
const [livePointerPoint, setLivePointerPoint] = useState<{ x: number; y: number } | null>(null);
const mosaicCanvasRef = useRef<HTMLCanvasElement | null>(null);
const blurType = annotation.type === "blur" ? (annotation.blurData?.type ?? "blur") : "blur";
const blurOverlayColor =
annotation.type === "blur" ? getBlurOverlayColor(annotation.blurData) : "";
const mosaicGridOverlayColor =
annotation.type === "blur" ? getMosaicGridOverlayColor(annotation.blurData) : "";
const [liveRect, setLiveRect] = useState({
x: committedX,
y: committedY,
width: committedWidth,
height: committedHeight,
});
useEffect(() => {
setLiveRect({
x: committedX,
y: committedY,
width: committedWidth,
height: committedHeight,
});
}, [committedHeight, committedWidth, committedX, committedY]);
const { x, y, width, height } = liveRect;
useEffect(() => {
if (annotation.type !== "blur" || blurType !== "mosaic") {
return;
}
void previewFrameVersion;
const canvas = mosaicCanvasRef.current;
const sourceCanvas = previewSourceCanvas;
if (!canvas || !sourceCanvas) {
return;
}
const sourceWidth = sourceCanvas.width;
const sourceHeight = sourceCanvas.height;
const sourceClientWidth = sourceCanvas.clientWidth || containerWidth || sourceWidth;
const sourceClientHeight = sourceCanvas.clientHeight || containerHeight || sourceHeight;
if (
sourceWidth <= 0 ||
sourceHeight <= 0 ||
sourceClientWidth <= 0 ||
sourceClientHeight <= 0
) {
return;
}
const drawWidth = Math.max(1, Math.round(width));
const drawHeight = Math.max(1, Math.round(height));
if (drawWidth <= 0 || drawHeight <= 0) {
return;
}
canvas.width = drawWidth;
canvas.height = drawHeight;
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) {
return;
}
const scaleX = sourceWidth / sourceClientWidth;
const scaleY = sourceHeight / sourceClientHeight;
const sourceX = Math.max(0, Math.floor(x * scaleX));
const sourceY = Math.max(0, Math.floor(y * scaleY));
const sourceSampleWidth = Math.max(1, Math.ceil(drawWidth * scaleX));
const sourceSampleHeight = Math.max(1, Math.ceil(drawHeight * scaleY));
const clampedSampleWidth = Math.max(1, Math.min(sourceSampleWidth, sourceWidth - sourceX));
const clampedSampleHeight = Math.max(1, Math.min(sourceSampleHeight, sourceHeight - sourceY));
const blockSize = getNormalizedMosaicBlockSize(annotation.blurData);
const downscaledWidth = Math.max(1, Math.round(drawWidth / blockSize));
const downscaledHeight = Math.max(1, Math.round(drawHeight / blockSize));
canvas.width = downscaledWidth;
canvas.height = downscaledHeight;
context.clearRect(0, 0, downscaledWidth, downscaledHeight);
context.imageSmoothingEnabled = true;
context.drawImage(
sourceCanvas as CanvasImageSource,
sourceX,
sourceY,
clampedSampleWidth,
clampedSampleHeight,
0,
0,
downscaledWidth,
downscaledHeight,
);
}, [
annotation,
blurType,
containerHeight,
containerWidth,
height,
previewFrameVersion,
previewSourceCanvas,
width,
x,
y,
]);
const renderArrow = () => {
const direction = annotation.figureData?.arrowDirection || "right";
@@ -43,6 +193,95 @@ export function AnnotationOverlay({
return <ArrowComponent color={color} strokeWidth={strokeWidth} />;
};
const normalizePoint = (event: PointerEvent<HTMLDivElement>) => {
const rect = event.currentTarget.getBoundingClientRect();
const x = ((event.clientX - rect.left) / rect.width) * 100;
const y = ((event.clientY - rect.top) / rect.height) * 100;
return {
x: Math.max(0, Math.min(100, x)),
y: Math.max(0, Math.min(100, y)),
};
};
const appendFreehandPoint = (point: { x: number; y: number }) => {
const points = freehandPointsRef.current;
const lastPoint = points[points.length - 1];
if (!lastPoint) {
points.push(point);
return;
}
const dx = point.x - lastPoint.x;
const dy = point.y - lastPoint.y;
// Sample freehand points in annotation-space percent units to avoid overly dense paths.
if (Math.hypot(dx, dy) >= FREEHAND_POINT_THRESHOLD) {
points.push(point);
}
};
const handleFreehandPointerDown = (event: PointerEvent<HTMLDivElement>) => {
if (
!isSelected ||
annotation.type !== "blur" ||
annotation.blurData?.shape !== "freehand" ||
!onBlurDataChange
) {
return;
}
event.preventDefault();
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
isDrawingFreehandRef.current = true;
setIsFreehandDrawing(true);
const point = normalizePoint(event);
freehandPointsRef.current = [point];
setDraftFreehandPoints([point]);
setLivePointerPoint(point);
};
const handleFreehandPointerMove = (event: PointerEvent<HTMLDivElement>) => {
if (!isDrawingFreehandRef.current) return;
event.preventDefault();
event.stopPropagation();
const point = normalizePoint(event);
setLivePointerPoint(point);
appendFreehandPoint(point);
setDraftFreehandPoints([...freehandPointsRef.current]);
};
const finishFreehandPointer = (event: PointerEvent<HTMLDivElement>) => {
if (!isDrawingFreehandRef.current || !onBlurDataChange) return;
isDrawingFreehandRef.current = false;
setIsFreehandDrawing(false);
try {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {
// no-op if already released
}
const points = [...freehandPointsRef.current];
if (livePointerPoint) {
const last = points[points.length - 1];
if (!last || Math.hypot(last.x - livePointerPoint.x, last.y - livePointerPoint.y) > 0.001) {
points.push(livePointerPoint);
}
}
if (points.length >= 3) {
const closedPoints = [...points];
const first = closedPoints[0];
const last = closedPoints[closedPoints.length - 1];
if (Math.hypot(last.x - first.x, last.y - first.y) > 0.001) {
closedPoints.push({ ...first });
}
onBlurDataChange(annotation.id, {
...(annotation.blurData || { ...DEFAULT_BLUR_DATA, shape: "freehand" }),
shape: "freehand",
freehandPoints: closedPoints,
});
setDraftFreehandPoints(closedPoints);
onBlurDataCommit?.();
}
setLivePointerPoint(null);
};
const renderContent = () => {
switch (annotation.type) {
case "text":
@@ -113,6 +352,149 @@ export function AnnotationOverlay({
<div className="w-full h-full flex items-center justify-center p-2">{renderArrow()}</div>
);
case "blur": {
const shape = annotation.blurData?.shape ?? "rectangle";
const blurIntensity = Math.max(
1,
Math.round(annotation.blurData?.intensity ?? DEFAULT_BLUR_INTENSITY),
);
const blockSize = Math.max(
1,
Math.round(annotation.blurData?.blockSize ?? DEFAULT_BLUR_BLOCK_SIZE),
);
const activeFreehandPoints =
shape === "freehand"
? isFreehandDrawing
? draftFreehandPoints
: (annotation.blurData?.freehandPoints ?? [])
: [];
const drawingPoints =
isFreehandDrawing && livePointerPoint
? (() => {
const last = activeFreehandPoints[activeFreehandPoints.length - 1];
if (!last) return [livePointerPoint];
const dx = livePointerPoint.x - last.x;
const dy = livePointerPoint.y - last.y;
return Math.hypot(dx, dy) > 0.01
? [...activeFreehandPoints, livePointerPoint]
: activeFreehandPoints;
})()
: activeFreehandPoints;
const clipPath =
shape === "freehand" ? buildBlurPolygonClipPath(activeFreehandPoints) : undefined;
const freehandPath =
shape === "freehand"
? buildBlurFreehandPath(
isFreehandDrawing ? drawingPoints : activeFreehandPoints,
!isFreehandDrawing,
)
: null;
const currentPointerPoint = isFreehandDrawing
? livePointerPoint || drawingPoints[drawingPoints.length - 1] || null
: null;
const shapeBorderRadius = shape === "oval" ? "50%" : shape === "rectangle" ? "8px" : "0";
const shouldShowFreehandBlurFill =
shape !== "freehand" || (!!clipPath && !isFreehandDrawing);
const shapeMaskStyle: CSSProperties = {
borderRadius: shapeBorderRadius,
clipPath: isFreehandDrawing ? undefined : clipPath,
WebkitClipPath: isFreehandDrawing ? undefined : clipPath,
};
const isFreehandSelected = isSelectedFreehandBlur;
return (
<div className="w-full h-full relative">
<div
className="absolute inset-0 overflow-hidden"
style={{
...shapeMaskStyle,
isolation: "isolate",
}}
>
<div
className="absolute inset-0"
style={{
...shapeMaskStyle,
backdropFilter: blurType === "mosaic" ? "none" : `blur(${blurIntensity}px)`,
WebkitBackdropFilter: blurType === "mosaic" ? "none" : `blur(${blurIntensity}px)`,
backgroundColor: blurOverlayColor,
opacity: shouldShowFreehandBlurFill ? 1 : 0,
}}
/>
{blurType === "mosaic" && shouldShowFreehandBlurFill && (
<canvas
ref={mosaicCanvasRef}
className="absolute inset-0 w-full h-full"
style={{
...shapeMaskStyle,
imageRendering: "pixelated",
}}
/>
)}
{blurType === "mosaic" && shouldShowFreehandBlurFill && (
<div
className="absolute inset-0 pointer-events-none"
style={{
...shapeMaskStyle,
backgroundColor: blurOverlayColor,
}}
/>
)}
{blurType === "mosaic" && (
<div
className="absolute inset-0 pointer-events-none"
style={{
...shapeMaskStyle,
backgroundImage: `linear-gradient(${mosaicGridOverlayColor} 1px, transparent 1px), linear-gradient(90deg, ${mosaicGridOverlayColor} 1px, transparent 1px)`,
backgroundSize: `${blockSize}px ${blockSize}px`,
mixBlendMode: "screen",
opacity: 0.35,
}}
/>
)}
{isSelected && shape !== "freehand" && (
<div
className="absolute inset-0 pointer-events-none border-2 border-[#34B27B]/80"
style={{ borderRadius: shapeBorderRadius }}
/>
)}
</div>
{isSelected && shape === "freehand" && freehandPath && (
<svg
viewBox="0 0 100 100"
preserveAspectRatio="none"
className="absolute inset-0 pointer-events-none"
>
<path
d={freehandPath}
fill="none"
stroke="#34B27B"
strokeWidth="0.55"
strokeLinecap="round"
strokeLinejoin="round"
/>
{currentPointerPoint && (
<circle
cx={currentPointerPoint.x}
cy={currentPointerPoint.y}
r="0.6"
fill="#34B27B"
/>
)}
</svg>
)}
{isFreehandSelected && (
<div
className="absolute inset-0 cursor-crosshair"
onPointerDown={handleFreehandPointerDown}
onPointerMove={handleFreehandPointerMove}
onPointerUp={finishFreehandPointer}
onPointerCancel={finishFreehandPointer}
/>
)}
</div>
);
}
default:
return null;
}
@@ -125,7 +507,19 @@ export function AnnotationOverlay({
onDragStart={() => {
isDraggingRef.current = true;
}}
onDrag={(_e, d) => {
setLiveRect((prev) => ({
...prev,
x: d.x,
y: d.y,
}));
}}
onDragStop={(_e, d) => {
setLiveRect((prev) => ({
...prev,
x: d.x,
y: d.y,
}));
const xPercent = (d.x / containerWidth) * 100;
const yPercent = (d.y / containerHeight) * 100;
onPositionChange(annotation.id, { x: xPercent, y: yPercent });
@@ -135,7 +529,21 @@ export function AnnotationOverlay({
isDraggingRef.current = false;
}, 100);
}}
onResize={(_e, _direction, ref, _delta, position) => {
setLiveRect({
x: position.x,
y: position.y,
width: ref.offsetWidth,
height: ref.offsetHeight,
});
}}
onResizeStop={(_e, _direction, ref, _delta, position) => {
setLiveRect({
x: position.x,
y: position.y,
width: ref.offsetWidth,
height: ref.offsetHeight,
});
const xPercent = (position.x / containerWidth) * 100;
const yPercent = (position.y / containerHeight) * 100;
const widthPercent = (ref.offsetWidth / containerWidth) * 100;
@@ -149,18 +557,23 @@ export function AnnotationOverlay({
}}
bounds="parent"
className={cn(
"cursor-move transition-all",
isSelected && "ring-2 ring-[#34B27B] ring-offset-2 ring-offset-transparent",
"cursor-move",
isSelected &&
annotation.type !== "blur" &&
"ring-2 ring-[#34B27B] ring-offset-2 ring-offset-transparent",
)}
style={{
zIndex: isSelectedBoost ? zIndex + 1000 : zIndex, // Boost selected annotation to ensure it's on top
pointerEvents: isSelected ? "auto" : "none",
border: isSelected ? "2px solid rgba(52, 178, 123, 0.8)" : "none",
backgroundColor: isSelected ? "rgba(52, 178, 123, 0.1)" : "transparent",
boxShadow: isSelected ? "0 0 0 1px rgba(52, 178, 123, 0.35)" : "none",
border:
isSelected && annotation.type !== "blur" ? "2px solid rgba(52, 178, 123, 0.8)" : "none",
backgroundColor:
isSelected && annotation.type !== "blur" ? "rgba(52, 178, 123, 0.1)" : "transparent",
boxShadow:
isSelected && annotation.type !== "blur" ? "0 0 0 1px rgba(52, 178, 123, 0.35)" : "none",
}}
enableResizing={isSelected}
disableDragging={!isSelected}
enableResizing={isSelected && !isSelectedFreehandBlur}
disableDragging={!isSelected || isSelectedFreehandBlur}
resizeHandleStyles={{
topLeft: {
width: "12px",
@@ -206,11 +619,13 @@ export function AnnotationOverlay({
>
<div
className={cn(
"w-full h-full rounded-lg",
"w-full h-full",
annotation.type !== "blur" && "rounded-lg",
annotation.type === "text" && "bg-transparent",
annotation.type === "image" && "bg-transparent",
annotation.type === "figure" && "bg-transparent",
isSelected && "shadow-lg",
annotation.type === "blur" && "bg-transparent",
isSelected && annotation.type !== "blur" && "shadow-lg",
)}
>
{renderContent()}
@@ -5,6 +5,7 @@ import {
AlignRight,
Bold,
ChevronDown,
Copy,
Image as ImageIcon,
Info,
Italic,
@@ -30,9 +31,15 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useScopedT } from "@/contexts/I18nContext";
import { type CustomFont, getCustomFonts } from "@/lib/customFonts";
import { cn } from "@/lib/utils";
import ColorPicker from "../ui/color-picker";
import { AddCustomFontDialog } from "./AddCustomFontDialog";
import { getArrowComponent } from "./ArrowSvgs";
import type { AnnotationRegion, AnnotationType, ArrowDirection, FigureData } from "./types";
import {
type AnnotationRegion,
type AnnotationType,
type ArrowDirection,
type FigureData,
} from "./types";
interface AnnotationSettingsPanelProps {
annotation: AnnotationRegion;
@@ -40,6 +47,7 @@ interface AnnotationSettingsPanelProps {
onTypeChange: (type: AnnotationType) => void;
onStyleChange: (style: Partial<AnnotationRegion["style"]>) => void;
onFigureDataChange?: (figureData: FigureData) => void;
onDuplicate?: () => void;
onDelete: () => void;
}
@@ -62,12 +70,12 @@ export function AnnotationSettingsPanel({
onTypeChange,
onStyleChange,
onFigureDataChange,
onDuplicate,
onDelete,
}: AnnotationSettingsPanelProps) {
const t = useScopedT("settings");
const fileInputRef = useRef<HTMLInputElement>(null);
const [customFonts, setCustomFonts] = useState<CustomFont[]>([]);
const fontStyleLabels: Record<string, string> = {
classic: t("fontStyles.classic"),
editor: t("fontStyles.editor"),
@@ -380,15 +388,19 @@ export function AnnotationSettingsPanel({
<ChevronDown className="h-3 w-3 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[260px] p-3 bg-[#1a1a1c] border border-white/10 rounded-xl shadow-xl">
<Block
color={annotation.style.color}
colors={colorPalette}
onChange={(color) => {
onStyleChange({ color: color.hex });
<PopoverContent
side="top"
className="w-[260px] p-3 bg-[#1a1a1c] border border-white/10 rounded-xl shadow-xl"
>
<ColorPicker
selectedColor={annotation.style.color}
colorPalette={colorPalette}
translations={{
colorWheel: t("annotation.colorWheel"),
colorPalette: t("annotation.colorPalette"),
}}
style={{
borderRadius: "8px",
onUpdateColor={(color) => {
onStyleChange({ color: color });
}}
/>
</PopoverContent>
@@ -419,31 +431,23 @@ export function AnnotationSettingsPanel({
<ChevronDown className="h-3 w-3 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[260px] p-3 bg-[#1a1a1c] border border-white/10 rounded-xl shadow-xl">
<Block
color={
annotation.style.backgroundColor === "transparent"
? "#000000"
: annotation.style.backgroundColor
}
colors={colorPalette}
onChange={(color) => {
onStyleChange({ backgroundColor: color.hex });
<PopoverContent
side="top"
className="w-[260px] p-3 bg-[#1a1a1c] border border-white/10 rounded-xl shadow-xl"
>
<ColorPicker
selectedColor={annotation.style.backgroundColor}
colorPalette={colorPalette}
translations={{
colorWheel: t("annotation.colorWheel"),
colorPalette: t("annotation.colorPalette"),
clearBackground: t("annotation.clearBackground"),
}}
style={{
borderRadius: "8px",
clearBackgroundOption={true}
onUpdateColor={(color) => {
onStyleChange({ backgroundColor: color });
}}
/>
<Button
variant="ghost"
size="sm"
className="w-full mt-2 text-xs h-7 hover:bg-white/5 text-slate-400"
onClick={() => {
onStyleChange({ backgroundColor: "transparent" });
}}
>
{t("annotation.clearBackground")}
</Button>
</PopoverContent>
</Popover>
</div>
@@ -597,15 +601,28 @@ export function AnnotationSettingsPanel({
</TabsContent>
</Tabs>
<Button
onClick={onDelete}
variant="destructive"
size="sm"
className="w-full gap-2 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all mt-4"
>
<Trash2 className="w-4 h-4" />
{t("annotation.deleteAnnotation")}
</Button>
<div className="mt-4 grid grid-cols-2 gap-2">
<Button
onClick={() => onDuplicate?.()}
variant="outline"
size="sm"
disabled={!onDuplicate}
className="w-full gap-2 bg-white/5 text-slate-200 border border-white/10 hover:bg-white/10 hover:border-white/20 transition-all"
>
<Copy className="w-4 h-4" />
Duplicate
</Button>
<Button
onClick={onDelete}
variant="destructive"
size="sm"
className="w-full gap-2 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all"
>
<Trash2 className="w-4 h-4" />
{t("annotation.deleteAnnotation")}
</Button>
</div>
<div className="mt-6 p-3 bg-white/5 rounded-lg border border-white/5">
<div className="flex items-center gap-2 mb-2 text-slate-300">
@@ -0,0 +1,247 @@
import { Info, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Slider } from "@/components/ui/slider";
import { useScopedT } from "@/contexts/I18nContext";
import { getBlurOverlayColor } from "@/lib/blurEffects";
import { cn } from "@/lib/utils";
import {
type AnnotationRegion,
type BlurColor,
type BlurData,
type BlurShape,
DEFAULT_BLUR_BLOCK_SIZE,
DEFAULT_BLUR_DATA,
MAX_BLUR_BLOCK_SIZE,
MAX_BLUR_INTENSITY,
MIN_BLUR_BLOCK_SIZE,
MIN_BLUR_INTENSITY,
} from "./types";
interface BlurSettingsPanelProps {
blurRegion: AnnotationRegion;
onBlurDataChange: (blurData: BlurData) => void;
onBlurDataCommit?: () => void;
onDelete: () => void;
}
export function BlurSettingsPanel({
blurRegion,
onBlurDataChange,
onBlurDataCommit,
onDelete,
}: BlurSettingsPanelProps) {
const t = useScopedT("settings");
const blurShapeOptions: Array<{ value: BlurShape; labelKey: string }> = [
{ value: "rectangle", labelKey: "blurShapeRectangle" },
{ value: "oval", labelKey: "blurShapeOval" },
];
const blurColorOptions: Array<{ value: BlurColor; labelKey: string }> = [
{ value: "white", labelKey: "blurColorWhite" },
{ value: "black", labelKey: "blurColorBlack" },
];
return (
<div className="flex-[2] min-w-0 bg-[#09090b] border border-white/5 rounded-2xl p-4 flex flex-col shadow-xl h-full overflow-y-auto custom-scrollbar">
<div className="mb-6">
<div className="flex items-center justify-between mb-4">
<span className="text-sm font-medium text-slate-200">{t("annotation.blurShape")}</span>
<span className="text-[10px] uppercase tracking-wider font-medium text-[#34B27B] bg-[#34B27B]/10 px-2 py-1 rounded-full">
{t("annotation.active")}
</span>
</div>
<div className="grid grid-cols-2 gap-2">
{blurShapeOptions.map((shape) => {
const activeShape = blurRegion.blurData?.shape || DEFAULT_BLUR_DATA.shape;
const isActive = activeShape === shape.value;
return (
<button
key={shape.value}
onClick={() => {
const nextBlurData: BlurData = {
...DEFAULT_BLUR_DATA,
...blurRegion.blurData,
shape: shape.value,
};
onBlurDataChange(nextBlurData);
requestAnimationFrame(() => {
onBlurDataCommit?.();
});
}}
className={cn(
"h-16 rounded-lg border flex flex-col items-center justify-center transition-all p-2 gap-1",
isActive
? "bg-[#34B27B] border-[#34B27B]"
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20",
)}
>
{shape.value === "rectangle" && (
<div
className={cn(
"w-8 h-5 border-2 rounded-sm",
isActive ? "border-white" : "border-slate-400",
)}
/>
)}
{shape.value === "oval" && (
<div
className={cn(
"w-8 h-5 border-2 rounded-full",
isActive ? "border-white" : "border-slate-400",
)}
/>
)}
<span className="text-[10px] leading-none">
{t(`annotation.${shape.labelKey}`)}
</span>
</button>
);
})}
</div>
<div className="mt-4">
<label className="text-xs font-medium text-slate-300 mb-2 block">
{t("annotation.blurType")}
</label>
<Select
value={blurRegion.blurData?.type ?? DEFAULT_BLUR_DATA.type}
onValueChange={(value) => {
const nextBlurData: BlurData = {
...DEFAULT_BLUR_DATA,
...blurRegion.blurData,
type: value === "mosaic" ? "mosaic" : "blur",
};
onBlurDataChange(nextBlurData);
requestAnimationFrame(() => {
onBlurDataCommit?.();
});
}}
>
<SelectTrigger className="w-full bg-white/5 border-white/10 text-slate-200 h-9 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#1a1a1c] border-white/10 text-slate-200">
<SelectItem value="blur">{t("annotation.blurTypeBlur")}</SelectItem>
<SelectItem value="mosaic">{t("annotation.blurTypeMosaic")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="mt-4">
<label className="text-xs font-medium text-slate-300 mb-2 block">
{t("annotation.blurColor")}
</label>
<div className="grid grid-cols-2 gap-2">
{blurColorOptions.map((option) => {
const activeColor = blurRegion.blurData?.color ?? DEFAULT_BLUR_DATA.color;
const isActive = activeColor === option.value;
return (
<button
key={option.value}
onClick={() => {
const nextBlurData: BlurData = {
...DEFAULT_BLUR_DATA,
...blurRegion.blurData,
color: option.value,
};
onBlurDataChange(nextBlurData);
requestAnimationFrame(() => {
onBlurDataCommit?.();
});
}}
className={cn(
"h-10 rounded-lg border flex items-center gap-2 px-3 transition-all",
isActive
? "bg-[#34B27B] border-[#34B27B]"
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20",
)}
>
<div
className="w-4 h-4 rounded-full border border-white/20"
style={{
backgroundColor: getBlurOverlayColor({
...DEFAULT_BLUR_DATA,
...blurRegion.blurData,
color: option.value,
}),
}}
/>
<span className="text-xs text-slate-200">
{t(`annotation.${option.labelKey}`)}
</span>
</button>
);
})}
</div>
</div>
<div className="mt-4 p-3 rounded-lg bg-white/5 border border-white/10">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-slate-300">
{blurRegion.blurData?.type === "mosaic"
? t("annotation.mosaicBlockSize")
: t("annotation.blurIntensity")}
</span>
<span className="text-[10px] text-slate-400 font-mono">
{Math.round(
blurRegion.blurData?.type === "mosaic"
? (blurRegion.blurData?.blockSize ?? DEFAULT_BLUR_BLOCK_SIZE)
: (blurRegion.blurData?.intensity ?? DEFAULT_BLUR_DATA.intensity),
)}
px
</span>
</div>
<Slider
value={[
blurRegion.blurData?.type === "mosaic"
? (blurRegion.blurData?.blockSize ?? DEFAULT_BLUR_BLOCK_SIZE)
: (blurRegion.blurData?.intensity ?? DEFAULT_BLUR_DATA.intensity),
]}
onValueChange={(values) => {
onBlurDataChange({
...DEFAULT_BLUR_DATA,
...blurRegion.blurData,
...(blurRegion.blurData?.type === "mosaic"
? { blockSize: values[0] }
: { intensity: values[0] }),
});
}}
onValueCommit={() => onBlurDataCommit?.()}
min={blurRegion.blurData?.type === "mosaic" ? MIN_BLUR_BLOCK_SIZE : MIN_BLUR_INTENSITY}
max={blurRegion.blurData?.type === "mosaic" ? MAX_BLUR_BLOCK_SIZE : MAX_BLUR_INTENSITY}
step={1}
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
/>
</div>
<Button
onClick={onDelete}
variant="destructive"
size="sm"
className="w-full gap-2 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all mt-4"
>
<Trash2 className="w-4 h-4" />
{t("annotation.deleteAnnotation")}
</Button>
<div className="mt-6 p-3 bg-white/5 rounded-lg border border-white/5">
<div className="flex items-center gap-2 mb-2 text-slate-300">
<Info className="w-3.5 h-3.5" />
<span className="text-xs font-medium">{t("annotation.shortcutsAndTips")}</span>
</div>
<ul className="text-[10px] text-slate-400 space-y-1.5 list-disc pl-3 leading-relaxed">
<li>{t("annotation.tipMovePlayhead")}</li>
</ul>
</div>
</div>
</div>
);
}
@@ -37,8 +37,10 @@ export function KeyboardShortcutsHelp() {
<div className="pt-1 border-t border-white/5 mt-1 space-y-1.5">
{FIXED_SHORTCUTS.map((fixed) => (
<div key={fixed.label} className="flex items-center justify-between">
<span className="text-slate-400">{fixed.label}</span>
<div key={fixed.i18nKey} className="flex items-center justify-between">
<span className="text-slate-400">
{t(`fixedActions.${fixed.i18nKey}`, { defaultValue: fixed.label })}
</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">
{isMac
? fixed.display
+377 -70
View File
@@ -1,6 +1,6 @@
import Block from "@uiw/react-color-block";
import {
Bug,
ChevronDown,
Crop,
Download,
Film,
@@ -14,7 +14,7 @@ import {
Upload,
X,
} from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import {
Accordion,
@@ -23,6 +23,7 @@ import {
AccordionTrigger,
} from "@/components/ui/accordion";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Select,
SelectContent,
@@ -34,34 +35,96 @@ import { Slider } from "@/components/ui/slider";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useScopedT } from "@/contexts/I18nContext";
import { getAssetPath } from "@/lib/assetPath";
import { WEBCAM_LAYOUT_PRESETS } from "@/lib/compositeLayout";
import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter";
import { GIF_FRAME_RATES, GIF_SIZE_PRESETS } from "@/lib/exporter";
import { cn } from "@/lib/utils";
import { resolveImageWallpaperUrl, WALLPAPER_PATHS } from "@/lib/wallpaper";
import { type AspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils";
import { getTestId } from "@/utils/getTestId";
import ColorPicker from "../ui/color-picker";
import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel";
import { BlurSettingsPanel } from "./BlurSettingsPanel";
import { CropControl } from "./CropControl";
import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp";
import type {
AnnotationRegion,
AnnotationType,
BlurData,
CropRegion,
FigureData,
PlaybackSpeed,
WebcamLayoutPreset,
WebcamMaskShape,
WebcamSizePreset,
ZoomDepth,
ZoomFocusMode,
} from "./types";
import { SPEED_OPTIONS } from "./types";
import { DEFAULT_WEBCAM_SIZE_PRESET, MAX_PLAYBACK_SPEED, SPEED_OPTIONS } from "./types";
function CustomSpeedInput({
value,
onChange,
onError,
}: {
value: number;
onChange: (val: number) => void;
onError: () => void;
}) {
const isPreset = SPEED_OPTIONS.some((o) => o.speed === value);
const [draft, setDraft] = useState(isPreset ? "" : String(Math.round(value)));
const [isFocused, setIsFocused] = useState(false);
const prevValue = useRef(value);
if (!isFocused && prevValue.current !== value) {
prevValue.current = value;
setDraft(isPreset ? "" : String(Math.round(value)));
}
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const digits = e.target.value.replace(/\D/g, "");
if (digits === "") {
setDraft("");
return;
}
const num = Number(digits);
if (num > MAX_PLAYBACK_SPEED) {
onError();
return;
}
setDraft(digits);
if (num >= 1) onChange(num);
},
[onChange, onError],
);
const handleBlur = useCallback(() => {
setIsFocused(false);
if (!draft || Number(draft) < 1) {
setDraft(isPreset ? "" : String(Math.round(value)));
}
}, [draft, isPreset, value]);
return (
<div className="flex items-center gap-1">
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
placeholder="--"
value={draft}
onFocus={() => setIsFocused(true)}
onChange={handleChange}
onBlur={handleBlur}
onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()}
className="w-12 bg-white/5 border border-white/10 rounded-md px-1 py-0.5 text-[11px] font-semibold text-[#d97706] text-center focus:outline-none focus:border-[#d97706]/40"
/>
<span className="text-[11px] font-semibold text-slate-500">×</span>
</div>
);
}
const WALLPAPER_COUNT = 18;
const WALLPAPER_RELATIVE = Array.from(
{ length: WALLPAPER_COUNT },
(_, i) => `wallpapers/wallpaper${i + 1}.jpg`,
);
const GRADIENTS = [
"linear-gradient( 111.6deg, rgba(114,167,232,1) 9.4%, rgba(253,129,82,1) 43.9%, rgba(253,129,82,1) 54.8%, rgba(249,202,86,1) 86.3% )",
"linear-gradient(120deg, #d4fc79 0%, #96e6a1 100%)",
@@ -90,6 +153,12 @@ const GRADIENTS = [
];
interface SettingsPanelProps {
cursorHighlight?: import("./videoPlayback/cursorHighlight").CursorHighlightConfig;
onCursorHighlightChange?: (
next: import("./videoPlayback/cursorHighlight").CursorHighlightConfig,
) => void;
// macOS only — gates the "Only on clicks" toggle (needs uiohook).
cursorHighlightSupportsClicks?: boolean;
selected: string;
onWallpaperChange: (path: string) => void;
selectedZoomDepth?: ZoomDepth | null;
@@ -132,7 +201,11 @@ interface SettingsPanelProps {
onGifSizePresetChange?: (preset: GifSizePreset) => void;
gifOutputDimensions?: { width: number; height: number };
onExport?: () => void;
unsavedExport?: { arrayBuffer: ArrayBuffer; fileName: string; format: string } | null;
unsavedExport?: {
arrayBuffer: ArrayBuffer;
fileName: string;
format: string;
} | null;
onSaveUnsavedExport?: () => void;
selectedAnnotationId?: string | null;
annotationRegions?: AnnotationRegion[];
@@ -140,7 +213,13 @@ interface SettingsPanelProps {
onAnnotationTypeChange?: (id: string, type: AnnotationType) => void;
onAnnotationStyleChange?: (id: string, style: Partial<AnnotationRegion["style"]>) => void;
onAnnotationFigureDataChange?: (id: string, figureData: FigureData) => void;
onAnnotationDuplicate?: (id: string) => void;
onAnnotationDelete?: (id: string) => void;
selectedBlurId?: string | null;
blurRegions?: AnnotationRegion[];
onBlurDataChange?: (id: string, blurData: BlurData) => void;
onBlurDataCommit?: () => void;
onBlurDelete?: (id: string) => void;
selectedSpeedId?: string | null;
selectedSpeedValue?: PlaybackSpeed | null;
onSpeedChange?: (speed: PlaybackSpeed) => void;
@@ -150,6 +229,9 @@ interface SettingsPanelProps {
onWebcamLayoutPresetChange?: (preset: WebcamLayoutPreset) => void;
webcamMaskShape?: import("./types").WebcamMaskShape;
onWebcamMaskShapeChange?: (shape: import("./types").WebcamMaskShape) => void;
webcamSizePreset?: WebcamSizePreset;
onWebcamSizePresetChange?: (size: WebcamSizePreset) => void;
onWebcamSizePresetCommit?: () => void;
}
export default SettingsPanel;
@@ -164,6 +246,9 @@ const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [
];
export function SettingsPanel({
cursorHighlight,
onCursorHighlightChange,
cursorHighlightSupportsClicks = false,
selected,
onWallpaperChange,
selectedZoomDepth,
@@ -213,7 +298,13 @@ export function SettingsPanel({
onAnnotationTypeChange,
onAnnotationStyleChange,
onAnnotationFigureDataChange,
onAnnotationDuplicate,
onAnnotationDelete,
selectedBlurId,
blurRegions = [],
onBlurDataChange,
onBlurDataCommit,
onBlurDelete,
selectedSpeedId,
selectedSpeedValue,
onSpeedChange,
@@ -223,26 +314,17 @@ export function SettingsPanel({
onWebcamLayoutPresetChange,
webcamMaskShape = "rectangle",
onWebcamMaskShapeChange,
webcamSizePreset = DEFAULT_WEBCAM_SIZE_PRESET,
onWebcamSizePresetChange,
onWebcamSizePresetCommit,
}: SettingsPanelProps) {
const t = useScopedT("settings");
const [wallpaperPaths, setWallpaperPaths] = useState<string[]>([]);
// Resolved URLs are for DOM rendering only (backgroundImage). The canonical
// `/wallpapers/wallpaperN.jpg` form in WALLPAPER_PATHS is what gets persisted
// on click — never the machine-specific file:// URL.
const wallpaperPreviewUrls = useMemo(() => WALLPAPER_PATHS.map(resolveImageWallpaperUrl), []);
const [customImages, setCustomImages] = useState<string[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
let mounted = true;
(async () => {
try {
const resolved = await Promise.all(WALLPAPER_RELATIVE.map((p) => getAssetPath(p)));
if (mounted) setWallpaperPaths(resolved);
} catch (_err) {
if (mounted) setWallpaperPaths(WALLPAPER_RELATIVE.map((p) => `/${p}`));
}
})();
return () => {
mounted = false;
};
}, []);
const colorPalette = [
"#FF0000",
"#FFD700",
@@ -268,6 +350,7 @@ export function SettingsPanel({
const cropSnapshotRef = useRef<CropRegion | null>(null);
const [cropAspectLocked, setCropAspectLocked] = useState(false);
const [cropAspectRatio, setCropAspectRatio] = useState("");
const isPortraitCanvas = isPortraitAspectRatio(aspectRatio);
const videoWidth = videoElement?.videoWidth || 1920;
const videoHeight = videoElement?.videoHeight || 1080;
@@ -424,7 +507,7 @@ export function SettingsPanel({
setCustomImages((prev) => prev.filter((img) => img !== imageUrl));
// If the removed image was selected, clear selection
if (selected === imageUrl) {
onWallpaperChange(wallpaperPaths[0] || WALLPAPER_RELATIVE[0]);
onWallpaperChange(WALLPAPER_PATHS[0]);
}
};
@@ -446,6 +529,9 @@ export function SettingsPanel({
const selectedAnnotation = selectedAnnotationId
? annotationRegions.find((a) => a.id === selectedAnnotationId)
: null;
const selectedBlur = selectedBlurId
? blurRegions.find((region) => region.id === selectedBlurId)
: null;
// If an annotation is selected, show annotation settings instead
if (
@@ -466,11 +552,25 @@ export function SettingsPanel({
? (figureData) => onAnnotationFigureDataChange(selectedAnnotation.id, figureData)
: undefined
}
onDuplicate={
onAnnotationDuplicate ? () => onAnnotationDuplicate(selectedAnnotation.id) : undefined
}
onDelete={() => onAnnotationDelete(selectedAnnotation.id)}
/>
);
}
if (selectedBlur && onBlurDataChange && onBlurDelete) {
return (
<BlurSettingsPanel
blurRegion={selectedBlur}
onBlurDataChange={(blurData) => onBlurDataChange(selectedBlur.id, blurData)}
onBlurDataCommit={onBlurDataCommit}
onDelete={() => onBlurDelete(selectedBlur.id)}
/>
);
}
return (
<div className="flex-[2] min-w-0 bg-[#09090b] border border-white/5 rounded-2xl flex flex-col shadow-xl h-full overflow-hidden">
<div className="flex-1 overflow-y-auto custom-scrollbar p-4 pb-0">
@@ -584,7 +684,7 @@ export function SettingsPanel({
</span>
)}
</div>
<div className="grid grid-cols-7 gap-1.5">
<div className="grid grid-cols-5 gap-1.5">
{SPEED_OPTIONS.map((option) => {
const isActive = selectedSpeedValue === option.speed;
return (
@@ -609,6 +709,29 @@ export function SettingsPanel({
);
})}
</div>
<div className="mt-3">
<div className="flex items-center justify-between">
<span
className={cn("text-[11px]", selectedSpeedId ? "text-slate-500" : "text-slate-600")}
>
{t("speed.customPlaybackSpeed")}
</span>
{selectedSpeedId ? (
<CustomSpeedInput
value={selectedSpeedValue ?? 1}
onChange={(val) => onSpeedChange?.(val)}
onError={() => toast.error(t("speed.maxSpeedError"))}
/>
) : (
<div className="flex items-center gap-1 opacity-40">
<div className="w-12 bg-white/5 border border-white/10 rounded-md px-1 py-0.5 text-[11px] font-semibold text-slate-600 text-center">
--
</div>
<span className="text-[11px] font-semibold text-slate-600">×</span>
</div>
)}
</div>
</div>
{!selectedSpeedId && (
<p className="text-[10px] text-slate-500 mt-2 text-center">{t("speed.selectRegion")}</p>
)}
@@ -656,15 +779,17 @@ export function SettingsPanel({
<SelectValue placeholder={t("layout.selectPreset")} />
</SelectTrigger>
<SelectContent>
{WEBCAM_LAYOUT_PRESETS.filter(
(preset) =>
preset.value === "picture-in-picture" ||
isPortraitAspectRatio(aspectRatio),
).map((preset) => (
{WEBCAM_LAYOUT_PRESETS.filter((preset) => {
if (preset.value === "picture-in-picture") return true;
if (preset.value === "vertical-stack") return isPortraitCanvas;
return !isPortraitCanvas;
}).map((preset) => (
<SelectItem key={preset.value} value={preset.value} className="text-xs">
{preset.value === "picture-in-picture"
? t("layout.pictureInPicture")
: t("layout.verticalStack")}
: preset.value === "vertical-stack"
? t("layout.verticalStack")
: t("layout.dualFrame")}
</SelectItem>
))}
</SelectContent>
@@ -751,6 +876,27 @@ export function SettingsPanel({
</div>
</div>
)}
{webcamLayoutPreset === "picture-in-picture" && (
<div className="p-2 rounded-lg bg-white/5 border border-white/5 mt-2">
<div className="flex items-center justify-between mb-1.5">
<div className="text-[10px] font-medium text-slate-300">
{t("layout.webcamSize")}
</div>
<div className="text-[10px] font-medium text-slate-400">
{webcamSizePreset}%
</div>
</div>
<Slider
value={[webcamSizePreset]}
onValueChange={(values) => onWebcamSizePresetChange?.(values[0])}
onValueCommit={() => onWebcamSizePresetCommit?.()}
min={10}
max={50}
step={1}
className="w-full"
/>
</div>
)}
</AccordionContent>
</AccordionItem>
)}
@@ -856,6 +1002,181 @@ export function SettingsPanel({
</div>
</div>
{cursorHighlight && onCursorHighlightChange && (
<div className="p-2 rounded-lg bg-white/5 border border-white/5 mt-2 space-y-2">
<div className="flex items-center justify-between">
<div className="text-[10px] font-medium text-slate-300">Cursor highlight</div>
<button
type="button"
onClick={() =>
onCursorHighlightChange({
...cursorHighlight,
enabled: !cursorHighlight.enabled,
})
}
className={`text-[10px] px-2 py-0.5 rounded border transition-colors ${
cursorHighlight.enabled
? "bg-[#34B27B]/20 border-[#34B27B]/50 text-[#34B27B]"
: "bg-white/5 border-white/10 text-slate-400"
}`}
>
{cursorHighlight.enabled ? "On" : "Off"}
</button>
</div>
<div
className={`grid grid-cols-2 gap-1 ${cursorHighlight.enabled ? "" : "opacity-40 pointer-events-none"}`}
>
{(["dot", "ring"] as const).map((style) => (
<button
key={style}
type="button"
onClick={() => onCursorHighlightChange({ ...cursorHighlight, style })}
className={`text-[10px] px-2 py-1 rounded border capitalize transition-colors ${
cursorHighlight.style === style
? "bg-[#34B27B]/20 border-[#34B27B]/50 text-[#34B27B]"
: "bg-white/5 border-white/10 text-slate-300 hover:border-white/20"
}`}
>
{style}
</button>
))}
</div>
<div className={cursorHighlight.enabled ? "" : "opacity-40 pointer-events-none"}>
<div className="flex items-center justify-between mb-1">
<div className="text-[10px] text-slate-400">Size</div>
<span className="text-[10px] text-slate-500 font-mono">
{cursorHighlight.sizePx}px
</span>
</div>
<Slider
value={[cursorHighlight.sizePx]}
onValueChange={(values) =>
onCursorHighlightChange({ ...cursorHighlight, sizePx: values[0] })
}
min={10}
max={36}
step={1}
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
/>
</div>
{cursorHighlightSupportsClicks && (
<div
className={`flex items-center justify-between ${cursorHighlight.enabled ? "" : "opacity-40 pointer-events-none"}`}
>
<div className="text-[10px] text-slate-400">Only on clicks</div>
<button
type="button"
onClick={async () => {
const turningOn = !cursorHighlight.onlyOnClicks;
if (turningOn) {
try {
const result = await window.electronAPI.requestAccessibilityAccess();
if (!result.granted) {
toast.message("Accessibility permission needed", {
description:
"Open System Settings → Privacy & Security → Accessibility, enable Openscreen, then restart the app.",
});
}
} catch (err) {
console.warn("Accessibility request failed:", err);
}
}
onCursorHighlightChange({
...cursorHighlight,
onlyOnClicks: turningOn,
});
}}
className={`text-[10px] px-2 py-0.5 rounded border transition-colors ${
cursorHighlight.onlyOnClicks
? "bg-[#34B27B]/20 border-[#34B27B]/50 text-[#34B27B]"
: "bg-white/5 border-white/10 text-slate-400"
}`}
>
{cursorHighlight.onlyOnClicks ? "On" : "Off"}
</button>
</div>
)}
<div className={cursorHighlight.enabled ? "" : "opacity-40 pointer-events-none"}>
<div className="text-[10px] text-slate-400 mb-1">Color</div>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-full h-8 justify-start gap-2 bg-white/5 border-white/10 hover:bg-white/10 px-2"
>
<div
className="w-4 h-4 rounded-full border border-white/20"
style={{ backgroundColor: cursorHighlight.color }}
/>
<span className="text-[10px] text-slate-300 truncate flex-1 text-left font-mono">
{cursorHighlight.color}
</span>
<ChevronDown className="h-3 w-3 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
side="top"
className="w-[260px] p-3 bg-[#1a1a1c] border border-white/10 rounded-xl shadow-xl"
>
<ColorPicker
selectedColor={cursorHighlight.color}
colorPalette={colorPalette}
translations={{
colorWheel: t("background.colorWheel"),
colorPalette: t("background.colorPalette"),
}}
onUpdateColor={(color) =>
onCursorHighlightChange({ ...cursorHighlight, color })
}
/>
</PopoverContent>
</Popover>
</div>
<div className={cursorHighlight.enabled ? "" : "opacity-40 pointer-events-none"}>
<div className="flex items-center justify-between mb-1">
<div className="text-[10px] text-slate-400">Offset X (window recordings)</div>
<span className="text-[10px] text-slate-500 font-mono">
{(cursorHighlight.offsetXNorm * 100).toFixed(1)}%
</span>
</div>
<Slider
value={[cursorHighlight.offsetXNorm]}
onValueChange={(values) =>
onCursorHighlightChange({
...cursorHighlight,
offsetXNorm: values[0],
})
}
min={-0.25}
max={0.25}
step={0.005}
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
/>
</div>
<div className={cursorHighlight.enabled ? "" : "opacity-40 pointer-events-none"}>
<div className="flex items-center justify-between mb-1">
<div className="text-[10px] text-slate-400">Offset Y</div>
<span className="text-[10px] text-slate-500 font-mono">
{(cursorHighlight.offsetYNorm * 100).toFixed(1)}%
</span>
</div>
<Slider
value={[cursorHighlight.offsetYNorm]}
onValueChange={(values) =>
onCursorHighlightChange({
...cursorHighlight,
offsetYNorm: values[0],
})
}
min={-0.25}
max={0.25}
step={0.005}
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
/>
</div>
</div>
)}
<Button
onClick={handleCropToggle}
variant="outline"
@@ -900,7 +1221,7 @@ export function SettingsPanel({
</TabsTrigger>
</TabsList>
<div className="max-h-[min(200px,25vh)] overflow-y-auto custom-scrollbar">
<div className="overflow-y-auto custom-scrollbar">
<TabsContent value="image" className="mt-0 space-y-2">
<input
type="file"
@@ -948,26 +1269,12 @@ export function SettingsPanel({
);
})}
{(wallpaperPaths.length > 0
? wallpaperPaths
: WALLPAPER_RELATIVE.map((p) => `/${p}`)
).map((path) => {
const isSelected = (() => {
if (!selected) return false;
if (selected === path) return true;
try {
const clean = (s: string) =>
s.replace(/^file:\/\//, "").replace(/^\//, "");
if (clean(selected).endsWith(clean(path))) return true;
if (clean(path).endsWith(clean(selected))) return true;
} catch {
// Best-effort comparison; fallback to strict match.
}
return false;
})();
{WALLPAPER_PATHS.map((canonicalPath, i) => {
const previewUrl = wallpaperPreviewUrls[i] ?? canonicalPath;
const isSelected = selected === canonicalPath;
return (
<div
key={path}
key={canonicalPath}
className={cn(
"aspect-square w-9 h-9 rounded-md border-2 overflow-hidden cursor-pointer transition-all duration-200 shadow-sm",
isSelected
@@ -975,11 +1282,11 @@ export function SettingsPanel({
: "border-white/10 hover:border-[#34B27B]/40 opacity-80 hover:opacity-100 bg-white/5",
)}
style={{
backgroundImage: `url(${path})`,
backgroundImage: `url(${previewUrl})`,
backgroundSize: "cover",
backgroundPosition: "center",
}}
onClick={() => onWallpaperChange(path)}
onClick={() => onWallpaperChange(canonicalPath)}
role="button"
/>
);
@@ -988,20 +1295,18 @@ export function SettingsPanel({
</TabsContent>
<TabsContent value="color" className="mt-0">
<div className="p-1">
<Block
color={selectedColor}
colors={colorPalette}
onChange={(color) => {
setSelectedColor(color.hex);
onWallpaperChange(color.hex);
}}
style={{
width: "100%",
borderRadius: "8px",
}}
/>
</div>
<ColorPicker
selectedColor={selectedColor}
colorPalette={colorPalette}
translations={{
colorWheel: t("background.colorWheel"),
colorPalette: t("background.colorPalette"),
}}
onUpdateColor={(color) => {
setSelectedColor(color);
onWallpaperChange(color);
}}
/>
</TabsContent>
<TabsContent value="gradient" className="mt-0">
@@ -1016,7 +1321,9 @@ export function SettingsPanel({
: "border-white/10 hover:border-[#34B27B]/40 opacity-80 hover:opacity-100 bg-white/5",
)}
style={{ background: g }}
aria-label={t("background.gradientLabel", { index: idx + 1 })}
aria-label={t("background.gradientLabel", {
index: idx + 1,
})}
onClick={() => {
setGradient(g);
onWallpaperChange(g);
@@ -126,93 +126,99 @@ export function ShortcutsConfigDialog() {
if (!open) handleClose();
}}
>
<DialogContent className="bg-[#09090b] border-white/10 text-white max-w-[420px]">
<DialogHeader>
<DialogContent className="bg-[#09090b] border-white/10 text-white max-w-[420px] max-h-[85vh] flex flex-col">
<DialogHeader className="shrink-0">
<DialogTitle className="flex items-center gap-2 text-sm">
<Keyboard className="w-4 h-4 text-[#34B27B]" />
{t("title")}
</DialogTitle>
</DialogHeader>
<div className="space-y-0.5">
<p className="text-[10px] text-slate-500 mb-2 uppercase tracking-wide font-semibold">
{t("configurable")}
</p>
{SHORTCUT_ACTIONS.map((action) => {
const isCapturing = captureFor === action;
const hasConflict = conflict?.forAction === action;
return (
<div key={action}>
<div className="flex items-center justify-between py-1.5 px-1 border-b border-white/5">
<span className="text-sm text-slate-300">{t(`actions.${action}`)}</span>
<button
type="button"
onClick={() => {
setConflict(null);
setCaptureFor(isCapturing ? null : action);
}}
title={isCapturing ? t("pressEscToCancel") : t("clickToChange")}
className={[
"px-2 py-1 rounded text-xs font-mono border transition-all min-w-[90px] text-center select-none",
isCapturing
? "bg-[#34B27B]/20 border-[#34B27B] text-[#34B27B] animate-pulse"
: hasConflict
? "bg-amber-500/10 border-amber-500/50 text-amber-400"
: "bg-white/5 border-white/10 text-slate-200 hover:border-[#34B27B]/50 hover:text-[#34B27B] cursor-pointer",
].join(" ")}
>
{isCapturing ? t("pressKey") : formatBinding(draft[action], isMac)}
</button>
</div>
{hasConflict && conflict?.conflictWith.type === "configurable" && (
<div className="flex items-center justify-between px-1 py-1.5 mb-0.5 bg-amber-500/10 border border-amber-500/20 rounded text-xs">
<span className="text-amber-400">
{" "}
{t("alreadyUsedBy", { action: t(`actions.${conflict.conflictWith.action}`) })}
</span>
<div className="flex gap-1.5">
<button
type="button"
onClick={handleSwap}
className="px-2 py-0.5 bg-amber-500/20 hover:bg-amber-500/30 border border-amber-500/40 rounded text-amber-300 font-medium transition-colors"
>
{t("swap")}
</button>
<button
type="button"
onClick={handleCancelConflict}
className="px-2 py-0.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded text-slate-400 transition-colors"
>
{tc("actions.cancel")}
</button>
</div>
<div className="flex-1 min-h-0 overflow-y-auto pr-1 -mr-1">
<div className="space-y-0.5">
<p className="text-[10px] text-slate-500 mb-2 uppercase tracking-wide font-semibold">
{t("configurable")}
</p>
{SHORTCUT_ACTIONS.map((action) => {
const isCapturing = captureFor === action;
const hasConflict = conflict?.forAction === action;
return (
<div key={action}>
<div className="flex items-center justify-between py-1.5 px-1 border-b border-white/5">
<span className="text-sm text-slate-300">{t(`actions.${action}`)}</span>
<button
type="button"
onClick={() => {
setConflict(null);
setCaptureFor(isCapturing ? null : action);
}}
title={isCapturing ? t("pressEscToCancel") : t("clickToChange")}
className={[
"px-2 py-1 rounded text-xs font-mono border transition-all min-w-[90px] text-center select-none",
isCapturing
? "bg-[#34B27B]/20 border-[#34B27B] text-[#34B27B] animate-pulse"
: hasConflict
? "bg-amber-500/10 border-amber-500/50 text-amber-400"
: "bg-white/5 border-white/10 text-slate-200 hover:border-[#34B27B]/50 hover:text-[#34B27B] cursor-pointer",
].join(" ")}
>
{isCapturing ? t("pressKey") : formatBinding(draft[action], isMac)}
</button>
</div>
)}
{hasConflict && conflict?.conflictWith.type === "configurable" && (
<div className="flex items-center justify-between px-1 py-1.5 mb-0.5 bg-amber-500/10 border border-amber-500/20 rounded text-xs">
<span className="text-amber-400">
{" "}
{t("alreadyUsedBy", {
action: t(`actions.${conflict.conflictWith.action}`),
})}
</span>
<div className="flex gap-1.5">
<button
type="button"
onClick={handleSwap}
className="px-2 py-0.5 bg-amber-500/20 hover:bg-amber-500/30 border border-amber-500/40 rounded text-amber-300 font-medium transition-colors"
>
{t("swap")}
</button>
<button
type="button"
onClick={handleCancelConflict}
className="px-2 py-0.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded text-slate-400 transition-colors"
>
{tc("actions.cancel")}
</button>
</div>
</div>
)}
</div>
);
})}
</div>
<div className="space-y-0.5 mt-2">
<p className="text-[10px] text-slate-500 mb-2 uppercase tracking-wide font-semibold">
{t("fixed")}
</p>
{FIXED_SHORTCUTS.map(({ i18nKey, label, display }) => (
<div
key={i18nKey}
className="flex items-center justify-between py-1.5 px-1 border-b border-white/5 last:border-0"
>
<span className="text-sm text-slate-400">
{t(`fixedActions.${i18nKey}`, { defaultValue: label })}
</span>
<kbd className="px-2 py-1 bg-white/5 border border-white/10 rounded text-xs font-mono text-slate-400 min-w-[90px] text-center">
{display}
</kbd>
</div>
);
})}
))}
</div>
<p className="text-[10px] text-slate-500 mt-1">{t("helpText")}</p>
</div>
<div className="space-y-0.5 mt-2">
<p className="text-[10px] text-slate-500 mb-2 uppercase tracking-wide font-semibold">
{t("fixed")}
</p>
{FIXED_SHORTCUTS.map(({ label, display }) => (
<div
key={label}
className="flex items-center justify-between py-1.5 px-1 border-b border-white/5 last:border-0"
>
<span className="text-sm text-slate-400">{label}</span>
<kbd className="px-2 py-1 bg-white/5 border border-white/10 rounded text-xs font-mono text-slate-400 min-w-[90px] text-center">
{display}
</kbd>
</div>
))}
</div>
<p className="text-[10px] text-slate-500 mt-1">{t("helpText")}</p>
<DialogFooter className="flex gap-2 sm:justify-between mt-2">
<DialogFooter className="shrink-0 flex gap-2 sm:justify-between mt-2">
<Button
variant="ghost"
size="sm"
File diff suppressed because it is too large Load Diff
+289 -165
View File
@@ -18,13 +18,14 @@ import {
useRef,
useState,
} from "react";
import { getAssetPath } from "@/lib/assetPath";
import {
getWebcamLayoutCssBoxShadow,
type Size,
type StyledRenderRect,
type WebcamLayoutPreset,
type WebcamSizePreset,
} from "@/lib/compositeLayout";
import { classifyWallpaper, DEFAULT_WALLPAPER, resolveImageWallpaperUrl } from "@/lib/wallpaper";
import { getCssClipPath } from "@/lib/webcamMaskShapes";
import {
type AspectRatio,
@@ -34,6 +35,7 @@ import {
import { AnnotationOverlay } from "./AnnotationOverlay";
import {
type AnnotationRegion,
type BlurData,
type SpeedRegion,
type TrimRegion,
ZOOM_DEPTH_SCALES,
@@ -49,7 +51,17 @@ import {
ZOOM_SCALE_DEADZONE,
ZOOM_TRANSLATION_DEADZONE_PX,
} from "./videoPlayback/constants";
import { adaptiveSmoothFactor, smoothCursorFocus } from "./videoPlayback/cursorFollowUtils";
import {
adaptiveSmoothFactor,
interpolateCursorAt,
smoothCursorFocus,
} from "./videoPlayback/cursorFollowUtils";
import {
type CursorHighlightConfig,
clickEmphasisAlpha,
DEFAULT_CURSOR_HIGHLIGHT,
drawCursorHighlightGraphics,
} from "./videoPlayback/cursorHighlight";
import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focusUtils";
import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils";
import { clamp01 } from "./videoPlayback/mathUtils";
@@ -69,6 +81,7 @@ interface VideoPlaybackProps {
webcamVideoPath?: string;
webcamLayoutPreset: WebcamLayoutPreset;
webcamMaskShape?: import("./types").WebcamMaskShape;
webcamSizePreset?: WebcamSizePreset;
webcamPosition?: { cx: number; cy: number } | null;
onWebcamPositionChange?: (position: { cx: number; cy: number }) => void;
onWebcamPositionDragEnd?: () => void;
@@ -99,7 +112,16 @@ interface VideoPlaybackProps {
onSelectAnnotation?: (id: string | null) => void;
onAnnotationPositionChange?: (id: string, position: { x: number; y: number }) => void;
onAnnotationSizeChange?: (id: string, size: { width: number; height: number }) => void;
blurRegions?: AnnotationRegion[];
selectedBlurId?: string | null;
onSelectBlur?: (id: string | null) => void;
onBlurPositionChange?: (id: string, position: { x: number; y: number }) => void;
onBlurSizeChange?: (id: string, size: { width: number; height: number }) => void;
onBlurDataChange?: (id: string, blurData: BlurData) => void;
onBlurDataCommit?: () => void;
cursorTelemetry?: import("./types").CursorTelemetryPoint[];
cursorHighlight?: CursorHighlightConfig;
cursorClickTimestamps?: number[];
}
export interface VideoPlaybackRef {
@@ -119,6 +141,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
webcamVideoPath,
webcamLayoutPreset,
webcamMaskShape,
webcamSizePreset,
webcamPosition,
onWebcamPositionChange,
onWebcamPositionDragEnd,
@@ -149,7 +172,16 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
onSelectAnnotation,
onAnnotationPositionChange,
onAnnotationSizeChange,
blurRegions = [],
selectedBlurId,
onSelectBlur,
onBlurPositionChange,
onBlurSizeChange,
onBlurDataChange,
onBlurDataCommit,
cursorTelemetry = [],
cursorHighlight = DEFAULT_CURSOR_HIGHLIGHT,
cursorClickTimestamps = [],
},
ref,
) => {
@@ -163,13 +195,19 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const timeUpdateAnimationRef = useRef<number | null>(null);
const [pixiReady, setPixiReady] = useState(false);
const [videoReady, setVideoReady] = useState(false);
const [overlaySize, setOverlaySize] = useState({ width: 800, height: 600 });
const [overlayElement, setOverlayElement] = useState<HTMLDivElement | null>(null);
const overlayRef = useRef<HTMLDivElement | null>(null);
const focusIndicatorRef = useRef<HTMLDivElement | null>(null);
const [webcamLayout, setWebcamLayout] = useState<StyledRenderRect | null>(null);
const [webcamDimensions, setWebcamDimensions] = useState<Size | null>(null);
const currentTimeRef = useRef(0);
const zoomRegionsRef = useRef<ZoomRegion[]>([]);
const cursorTelemetryRef = useRef<import("./types").CursorTelemetryPoint[]>([]);
const cursorHighlightRef = useRef<CursorHighlightConfig>(DEFAULT_CURSOR_HIGHLIGHT);
const cursorClickTimestampsRef = useRef<number[]>([]);
const cursorHighlightGraphicsRef = useRef<Graphics | null>(null);
const selectedZoomIdRef = useRef<string | null>(null);
const animationStateRef = useRef({
scale: 1,
@@ -195,7 +233,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const isPlayingRef = useRef(isPlaying);
const isSeekingRef = useRef(false);
const allowPlaybackRef = useRef(false);
const lockedVideoDimensionsRef = useRef<{ width: number; height: number } | null>(null);
const lockedVideoDimensionsRef = useRef<{
width: number;
height: number;
} | null>(null);
const layoutVideoContentRef = useRef<(() => void) | null>(null);
const trimRegionsRef = useRef<TrimRegion[]>([]);
const speedRegionsRef = useRef<SpeedRegion[]>([]);
@@ -283,6 +324,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
padding,
webcamDimensions,
webcamLayoutPreset,
webcamSizePreset,
webcamPosition,
webcamMaskShape,
});
@@ -314,6 +356,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
padding,
webcamDimensions,
webcamLayoutPreset,
webcamSizePreset,
webcamPosition,
webcamMaskShape,
]);
@@ -322,6 +365,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
layoutVideoContentRef.current = layoutVideoContent;
}, [layoutVideoContent]);
const setOverlayRefs = useCallback((node: HTMLDivElement | null) => {
overlayRef.current = node;
setOverlayElement(node);
}, []);
const selectedZoom = useMemo(() => {
if (!selectedZoomId) return null;
return zoomRegions.find((region) => region.id === selectedZoomId) ?? null;
@@ -338,7 +386,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
if (!vid) return;
try {
allowPlaybackRef.current = true;
await vid.play();
await vid.play().catch((err) => {
console.log("PLAY ERROR:", err);
throw err;
});
} catch (error) {
allowPlaybackRef.current = false;
throw error;
@@ -481,6 +532,17 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
cursorTelemetryRef.current = cursorTelemetry;
}, [cursorTelemetry]);
useEffect(() => {
cursorHighlightRef.current = cursorHighlight;
if (cursorHighlightGraphicsRef.current) {
drawCursorHighlightGraphics(cursorHighlightGraphicsRef.current, cursorHighlight);
}
}, [cursorHighlight]);
useEffect(() => {
cursorClickTimestampsRef.current = cursorClickTimestamps;
}, [cursorClickTimestamps]);
useEffect(() => {
selectedZoomIdRef.current = selectedZoomId;
}, [selectedZoomId]);
@@ -511,84 +573,24 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
useEffect(() => {
if (!pixiReady || !videoReady) return;
const el = overlayRef.current;
if (!el) return;
const app = appRef.current;
const cameraContainer = cameraContainerRef.current;
const video = videoRef.current;
// Seed immediately so overlays never start at 800×600
setOverlaySize({ width: el.clientWidth, height: el.clientHeight });
if (!app || !cameraContainer || !video) return;
const tickerWasStarted = app.ticker?.started || false;
if (tickerWasStarted && app.ticker) {
app.ticker.stop();
}
const wasPlaying = !video.paused;
if (wasPlaying) {
video.pause();
}
animationStateRef.current = {
scale: 1,
focusX: DEFAULT_FOCUS.cx,
focusY: DEFAULT_FOCUS.cy,
progress: 0,
x: 0,
y: 0,
appliedScale: 1,
};
// Reset motion blur state for clean transitions
motionBlurStateRef.current = createMotionBlurState();
if (blurFilterRef.current) {
blurFilterRef.current.blur = 0;
}
requestAnimationFrame(() => {
const container = cameraContainerRef.current;
const videoStage = videoContainerRef.current;
const sprite = videoSpriteRef.current;
const currentApp = appRef.current;
if (!container || !videoStage || !sprite || !currentApp) {
return;
}
container.scale.set(1);
container.position.set(0, 0);
videoStage.scale.set(1);
videoStage.position.set(0, 0);
sprite.scale.set(1);
sprite.position.set(0, 0);
layoutVideoContent();
applyZoomTransform({
cameraContainer: container,
blurFilter: blurFilterRef.current,
stageSize: stageSizeRef.current,
baseMask: baseMaskRef.current,
zoomScale: 1,
focusX: DEFAULT_FOCUS.cx,
focusY: DEFAULT_FOCUS.cy,
motionIntensity: 0,
isPlaying: false,
motionBlurAmount: motionBlurAmountRef.current,
});
requestAnimationFrame(() => {
const finalApp = appRef.current;
if (wasPlaying && video) {
video.play().catch(() => {
// Ignore autoplay restoration failures.
});
}
if (tickerWasStarted && finalApp?.ticker) {
finalApp.ticker.start();
}
const observer = new ResizeObserver((entries) => {
if (!entries[0]) return;
const { width, height } = entries[0].contentRect;
setOverlaySize((prev) => {
if (prev.width === width && prev.height === height) return prev;
return { width, height };
});
});
}, [pixiReady, videoReady, layoutVideoContent]);
observer.observe(el);
return () => observer.disconnect();
}, [pixiReady, videoReady]);
useEffect(() => {
if (!pixiReady || !videoReady) return;
@@ -615,7 +617,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
}, [selectedZoom, pixiReady, videoReady, updateOverlayForRegion]);
useEffect(() => {
const overlayEl = overlayRef.current;
if (!pixiReady || !videoReady) return;
const overlayEl = overlayElement;
if (!overlayEl) return;
if (!selectedZoom) {
overlayEl.style.cursor = "default";
@@ -624,7 +627,34 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
}
overlayEl.style.cursor = isPlaying ? "not-allowed" : "grab";
overlayEl.style.pointerEvents = isPlaying ? "none" : "auto";
}, [selectedZoom, isPlaying]);
}, [selectedZoom, isPlaying, pixiReady, videoReady, overlayElement]);
useEffect(() => {
const overlayEl = overlayElement;
if (!overlayEl) return;
const updateOverlaySize = () => {
const width = overlayEl.clientWidth || 800;
const height = overlayEl.clientHeight || 600;
setOverlaySize((prev) => {
if (prev.width === width && prev.height === height) return prev;
return { width, height };
});
};
updateOverlaySize();
if (typeof ResizeObserver !== "undefined") {
const observer = new ResizeObserver(() => {
updateOverlaySize();
});
observer.observe(overlayEl);
return () => observer.disconnect();
}
window.addEventListener("resize", updateOverlaySize);
return () => window.removeEventListener("resize", updateOverlaySize);
}, [overlayElement]);
useEffect(() => {
const container = containerRef.current;
@@ -648,7 +678,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
app.ticker.maxFPS = 60;
if (!mounted) {
app.destroy(true, { children: true, texture: true, textureSource: true });
app.destroy(true, {
children: true,
texture: true,
textureSource: true,
});
return;
}
@@ -672,7 +706,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
mounted = false;
setPixiReady(false);
if (app && app.renderer) {
app.destroy(true, { children: true, texture: true, textureSource: true });
app.destroy(true, {
children: true,
texture: true,
textureSource: true,
});
}
appRef.current = null;
cameraContainerRef.current = null;
@@ -728,6 +766,12 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
videoContainer.mask = maskGraphics;
maskGraphicsRef.current = maskGraphics;
const cursorHighlightGraphics = new Graphics();
cursorHighlightGraphics.visible = false;
videoContainer.addChild(cursorHighlightGraphics);
cursorHighlightGraphicsRef.current = cursorHighlightGraphics;
drawCursorHighlightGraphics(cursorHighlightGraphics, cursorHighlightRef.current);
animationStateRef.current = {
scale: 1,
focusX: DEFAULT_FOCUS.cx,
@@ -743,7 +787,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
blurFilter.resolution = app.renderer.resolution;
blurFilter.blur = 0;
const motionBlurFilter = new MotionBlurFilter([0, 0], 5, 0);
videoContainer.filters = [blurFilter, motionBlurFilter];
blurFilterRef.current = blurFilter;
motionBlurFilterRef.current = motionBlurFilter;
@@ -788,10 +831,15 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
videoContainer.removeChild(maskGraphics);
maskGraphics.destroy();
}
if (cursorHighlightGraphicsRef.current) {
videoContainer.removeChild(cursorHighlightGraphicsRef.current);
cursorHighlightGraphicsRef.current.destroy();
cursorHighlightGraphicsRef.current = null;
}
videoContainer.mask = null;
maskGraphicsRef.current = null;
if (blurFilterRef.current) {
videoContainer.filters = [];
videoContainer.filters = null;
blurFilterRef.current.destroy();
blurFilterRef.current = null;
}
@@ -848,17 +896,15 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
state.appliedScale = appliedTransform.scale;
};
let lastMotionBlurActive: boolean | null = null;
const ticker = () => {
const bm = baseMaskRef.current;
const ss = stageSizeRef.current;
const viewportRatio =
bm.width > 0 && bm.height > 0
? { widthRatio: ss.width / bm.width, heightRatio: ss.height / bm.height }
: undefined;
const { region, strength, blendedScale, transition } = findDominantRegion(
zoomRegionsRef.current,
currentTimeRef.current,
{ connectZooms: true, cursorTelemetry: cursorTelemetryRef.current, viewportRatio },
{
connectZooms: true,
cursorTelemetry: cursorTelemetryRef.current,
},
);
const defaultFocus = DEFAULT_FOCUS;
@@ -1008,6 +1054,56 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
motionIntensity,
motionVector,
);
const cursorGraphics = cursorHighlightGraphicsRef.current;
const cursorConfig = cursorHighlightRef.current;
const lockedDims = lockedVideoDimensionsRef.current;
if (cursorGraphics) {
if (cursorConfig.enabled && lockedDims && cursorTelemetryRef.current.length > 0) {
const emphasisAlpha = clickEmphasisAlpha(
currentTimeRef.current,
cursorClickTimestampsRef.current,
cursorConfig,
);
const cursorPoint =
emphasisAlpha > 0
? interpolateCursorAt(cursorTelemetryRef.current, currentTimeRef.current)
: null;
if (cursorPoint) {
const baseScale = baseScaleRef.current;
const baseOffset = baseOffsetRef.current;
const cx = cursorPoint.cx + cursorConfig.offsetXNorm;
const cy = cursorPoint.cy + cursorConfig.offsetYNorm;
cursorGraphics.position.set(
baseOffset.x + cx * lockedDims.width * baseScale,
baseOffset.y + cy * lockedDims.height * baseScale,
);
cursorGraphics.alpha = emphasisAlpha;
cursorGraphics.visible = true;
} else {
cursorGraphics.visible = false;
}
} else {
cursorGraphics.visible = false;
}
}
const isMotionBlurActive = (motionBlurAmountRef.current || 0) > 0 && isPlayingRef.current;
if (isMotionBlurActive !== lastMotionBlurActive && videoContainerRef.current) {
if (isMotionBlurActive) {
if (blurFilterRef.current && motionBlurFilterRef.current) {
videoContainerRef.current.filters = [
blurFilterRef.current,
motionBlurFilterRef.current,
];
lastMotionBlurActive = true;
}
} else {
videoContainerRef.current.filters = null;
lastMotionBlurActive = false;
}
}
};
app.ticker.add(ticker);
@@ -1045,7 +1141,17 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
videoReadyRafRef.current = requestAnimationFrame(waitForRenderableFrame);
};
const [resolvedWallpaper, setResolvedWallpaper] = useState<string | null>(null);
const resolvedWallpaper = useMemo<string | null>(() => {
const source = wallpaper || DEFAULT_WALLPAPER;
const classified = classifyWallpaper(source);
if (classified.kind !== "image") return classified.value;
try {
return resolveImageWallpaperUrl(classified.path);
} catch (err) {
console.warn("[VideoPlayback] wallpaper resolve failed:", err);
return null;
}
}, [wallpaper]);
const webcamCssBoxShadow = useMemo(
() => getWebcamLayoutCssBoxShadow(webcamLayoutPreset),
[webcamLayoutPreset],
@@ -1113,58 +1219,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
webcamVideo.currentTime = 0;
}, [webcamVideoPath]);
useEffect(() => {
let mounted = true;
(async () => {
try {
if (!wallpaper) {
const def = await getAssetPath("wallpapers/wallpaper1.jpg");
if (mounted) setResolvedWallpaper(def);
return;
}
if (
wallpaper.startsWith("#") ||
wallpaper.startsWith("linear-gradient") ||
wallpaper.startsWith("radial-gradient")
) {
if (mounted) setResolvedWallpaper(wallpaper);
return;
}
// If it's a data URL (custom uploaded image), use as-is
if (wallpaper.startsWith("data:")) {
if (mounted) setResolvedWallpaper(wallpaper);
return;
}
// If it's an absolute web/http or file path, use as-is
if (
wallpaper.startsWith("http") ||
wallpaper.startsWith("file://") ||
wallpaper.startsWith("/")
) {
// If it's an absolute server path (starts with '/'), resolve via getAssetPath as well
if (wallpaper.startsWith("/")) {
const rel = wallpaper.replace(/^\//, "");
const p = await getAssetPath(rel);
if (mounted) setResolvedWallpaper(p);
return;
}
if (mounted) setResolvedWallpaper(wallpaper);
return;
}
const p = await getAssetPath(wallpaper.replace(/^\//, ""));
if (mounted) setResolvedWallpaper(p);
} catch (_err) {
if (mounted) setResolvedWallpaper(wallpaper || "/wallpapers/wallpaper1.jpg");
}
})();
return () => {
mounted = false;
};
}, [wallpaper]);
useEffect(() => {
return () => {
if (videoReadyRafRef.current) {
@@ -1264,9 +1318,9 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
{/* Only render overlay after PIXI and video are fully initialized */}
{pixiReady && videoReady && (
<div
ref={overlayRef}
ref={setOverlayRefs}
className="absolute inset-0 select-none"
style={{ pointerEvents: "none", zIndex: 30 }}
style={{ pointerEvents: "auto", zIndex: 30 }}
onPointerDown={handleOverlayPointerDown}
onPointerMove={handleOverlayPointerMove}
onPointerUp={handleOverlayPointerUp}
@@ -1278,47 +1332,117 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
style={{ display: "none", pointerEvents: "none" }}
/>
{(() => {
const filtered = (annotationRegions || []).filter((annotation) => {
const filteredAnnotations = (annotationRegions || []).filter((annotation) => {
if (typeof annotation.startMs !== "number" || typeof annotation.endMs !== "number")
return false;
if (annotation.id === selectedAnnotationId) return true;
const timeMs = Math.round(currentTime * 1000);
return timeMs >= annotation.startMs && timeMs <= annotation.endMs;
return timeMs >= annotation.startMs && timeMs < annotation.endMs;
});
// Sort by z-index (lowest to highest) so higher z-index renders on top
const sorted = [...filtered].sort((a, b) => a.zIndex - b.zIndex);
const filteredBlurRegions = (blurRegions || []).filter((blurRegion) => {
if (typeof blurRegion.startMs !== "number" || typeof blurRegion.endMs !== "number")
return false;
if (blurRegion.id === selectedBlurId) return true;
const timeMs = Math.round(currentTime * 1000);
return timeMs >= blurRegion.startMs && timeMs < blurRegion.endMs;
});
const sorted = [
...filteredAnnotations.map((annotation) => ({
kind: "annotation" as const,
region: annotation,
})),
...filteredBlurRegions.map((blurRegion) => ({
kind: "blur" as const,
region: blurRegion,
})),
].sort((a, b) => a.region.zIndex - b.region.zIndex);
const previewSnapshotCanvas =
filteredBlurRegions.length > 0
? (() => {
const app = appRef.current;
if (!app?.renderer?.extract) return null;
try {
return app.renderer.extract.canvas(app.stage);
} catch {
return null;
}
})()
: null;
// Handle click-through cycling: when clicking same annotation, cycle to next
const handleAnnotationClick = (clickedId: string) => {
if (!onSelectAnnotation) return;
// If clicking on already selected annotation and there are multiple overlapping
if (clickedId === selectedAnnotationId && sorted.length > 1) {
if (clickedId === selectedAnnotationId && filteredAnnotations.length > 1) {
// Find current index and cycle to next
const currentIndex = sorted.findIndex((a) => a.id === clickedId);
const nextIndex = (currentIndex + 1) % sorted.length;
onSelectAnnotation(sorted[nextIndex].id);
const currentIndex = filteredAnnotations.findIndex((a) => a.id === clickedId);
const nextIndex = (currentIndex + 1) % filteredAnnotations.length;
onSelectAnnotation(filteredAnnotations[nextIndex].id);
} else {
// First click or clicking different annotation
onSelectAnnotation(clickedId);
}
};
return sorted.map((annotation) => (
const handleBlurClick = (clickedId: string) => {
if (!onSelectBlur) return;
if (clickedId === selectedBlurId && filteredBlurRegions.length > 1) {
const currentIndex = filteredBlurRegions.findIndex((a) => a.id === clickedId);
const nextIndex = (currentIndex + 1) % filteredBlurRegions.length;
onSelectBlur(filteredBlurRegions[nextIndex].id);
} else {
onSelectBlur(clickedId);
}
};
return sorted.map((item) => (
<AnnotationOverlay
key={annotation.id}
annotation={annotation}
isSelected={annotation.id === selectedAnnotationId}
containerWidth={overlayRef.current?.clientWidth || 800}
containerHeight={overlayRef.current?.clientHeight || 600}
onPositionChange={(id, position) => onAnnotationPositionChange?.(id, position)}
onSizeChange={(id, size) => onAnnotationSizeChange?.(id, size)}
onClick={handleAnnotationClick}
zIndex={annotation.zIndex}
isSelectedBoost={annotation.id === selectedAnnotationId}
key={
item.kind === "blur"
? `${item.region.id}-${overlaySize.width}-${overlaySize.height}-${item.region.blurData?.type ?? "blur"}-${item.region.blurData?.shape ?? "rectangle"}-${item.region.blurData?.color ?? "white"}-${Math.round(item.region.blurData?.blockSize ?? 0)}-${Math.round(item.region.blurData?.intensity ?? 0)}-${(item.region.blurData?.freehandPoints ?? []).map((p) => `${Math.round(p.x)}_${Math.round(p.y)}`).join("-")}`
: `${item.region.id}-${overlaySize.width}-${overlaySize.height}`
}
annotation={item.region}
isSelected={
item.kind === "blur"
? item.region.id === selectedBlurId
: item.region.id === selectedAnnotationId
}
containerWidth={overlaySize.width}
containerHeight={overlaySize.height}
onPositionChange={(id, position) =>
item.kind === "blur"
? onBlurPositionChange?.(id, position)
: onAnnotationPositionChange?.(id, position)
}
onSizeChange={(id, size) =>
item.kind === "blur"
? onBlurSizeChange?.(id, size)
: onAnnotationSizeChange?.(id, size)
}
onBlurDataChange={
item.kind === "blur"
? (id, blurData) => onBlurDataChange?.(id, blurData)
: undefined
}
onBlurDataCommit={item.kind === "blur" ? onBlurDataCommit : undefined}
onClick={item.kind === "blur" ? handleBlurClick : handleAnnotationClick}
zIndex={item.region.zIndex}
isSelectedBoost={
item.kind === "blur"
? item.region.id === selectedBlurId
: item.region.id === selectedAnnotationId
}
previewSourceCanvas={previewSnapshotCanvas}
previewFrameVersion={Math.round(currentTime * 1000)}
/>
));
})()}
@@ -1,6 +1,8 @@
import { describe, expect, it } from "vitest";
import {
createProjectData,
createProjectSnapshot,
hasProjectUnsavedChanges,
normalizeProjectEditor,
PROJECT_VERSION,
resolveProjectMedia,
@@ -42,6 +44,7 @@ describe("projectPersistence media compatibility", () => {
aspectRatio: "16:9",
webcamLayoutPreset: "picture-in-picture",
webcamMaskShape: "circle",
webcamPosition: null,
exportQuality: "good",
exportFormat: "mp4",
gifFrameRate: 15,
@@ -64,4 +67,192 @@ describe("projectPersistence media compatibility", () => {
normalizeProjectEditor({ webcamMaskShape: "not-a-real-shape" as never }).webcamMaskShape,
).toBe("rectangle");
});
it("normalizes blur region type and mosaic block size safely", () => {
const editor = normalizeProjectEditor({
annotationRegions: [
{
id: "annotation-1",
startMs: 0,
endMs: 500,
type: "blur",
content: "",
position: { x: 10, y: 10 },
size: { width: 20, height: 20 },
style: {
color: "#fff",
backgroundColor: "transparent",
fontSize: 32,
fontFamily: "Inter",
fontWeight: "bold",
fontStyle: "normal",
textDecoration: "none",
textAlign: "center",
},
zIndex: 1,
blurData: {
type: "mosaic",
shape: "rectangle",
color: "black",
intensity: 999,
blockSize: 999,
},
},
{
id: "annotation-2",
startMs: 0,
endMs: 500,
type: "blur",
content: "",
position: { x: 10, y: 10 },
size: { width: 20, height: 20 },
style: {
color: "#fff",
backgroundColor: "transparent",
fontSize: 32,
fontFamily: "Inter",
fontWeight: "bold",
fontStyle: "normal",
textDecoration: "none",
textAlign: "center",
},
zIndex: 2,
blurData: {
type: "invalid" as never,
shape: "rectangle",
color: "invalid" as never,
intensity: 10,
blockSize: 0,
},
},
],
});
expect(editor.annotationRegions[0].blurData?.type).toBe("mosaic");
expect(editor.annotationRegions[0].blurData?.color).toBe("black");
expect(editor.annotationRegions[0].blurData?.intensity).toBe(40);
expect(editor.annotationRegions[0].blurData?.blockSize).toBe(48);
expect(editor.annotationRegions[1].blurData?.type).toBe("blur");
expect(editor.annotationRegions[1].blurData?.color).toBe("white");
expect(editor.annotationRegions[1].blurData?.blockSize).toBe(4);
});
it("accepts the dual frame webcam layout preset", () => {
expect(normalizeProjectEditor({ webcamLayoutPreset: "dual-frame" }).webcamLayoutPreset).toBe(
"dual-frame",
);
});
it("falls back from dual frame to picture in picture for portrait aspect ratios", () => {
expect(
normalizeProjectEditor({
aspectRatio: "9:16",
webcamLayoutPreset: "dual-frame",
}).webcamLayoutPreset,
).toBe("picture-in-picture");
});
it("clears webcamPosition when the normalized preset is not picture in picture", () => {
expect(
normalizeProjectEditor({
webcamLayoutPreset: "dual-frame",
webcamPosition: { cx: 0.2, cy: 0.8 },
}).webcamPosition,
).toBeNull();
});
});
it("creates stable snapshots for identical project state", () => {
const media = {
screenVideoPath: "/tmp/screen.webm",
webcamVideoPath: "/tmp/webcam.webm",
};
const editor = normalizeProjectEditor({
wallpaper: "/wallpapers/wallpaper1.jpg",
shadowIntensity: 0,
showBlur: false,
motionBlurAmount: 0,
borderRadius: 0,
padding: 50,
cropRegion: { x: 0, y: 0, width: 1, height: 1 },
zoomRegions: [],
trimRegions: [],
speedRegions: [],
annotationRegions: [],
aspectRatio: "16:9",
webcamLayoutPreset: "picture-in-picture",
webcamMaskShape: "circle",
exportQuality: "good",
exportFormat: "mp4",
gifFrameRate: 15,
gifLoop: true,
gifSizePreset: "medium",
});
expect(createProjectSnapshot(media, editor)).toBe(createProjectSnapshot(media, editor));
});
it("detects unsaved changes from differing snapshots", () => {
expect(hasProjectUnsavedChanges(null, null)).toBe(false);
expect(hasProjectUnsavedChanges("same", "same")).toBe(false);
expect(hasProjectUnsavedChanges("current", "baseline")).toBe(true);
});
describe("wallpaper legacy normalization", () => {
it("rewrites pre-fix packaged paths (resources/assets/wallpapers/…)", () => {
const normalized = normalizeProjectEditor({
wallpaper: "file:///opt/Openscreen/resources/assets/wallpapers/wallpaper5.jpg",
});
expect(normalized.wallpaper).toBe("/wallpapers/wallpaper5.jpg");
});
it("rewrites new packaged layout (resources/wallpapers/…)", () => {
const normalized = normalizeProjectEditor({
wallpaper: "file:///opt/Openscreen/resources/wallpapers/wallpaper3.jpg",
});
expect(normalized.wallpaper).toBe("/wallpapers/wallpaper3.jpg");
});
it("rewrites unpackaged dev layout (public/wallpapers/…)", () => {
const normalized = normalizeProjectEditor({
wallpaper: "file:///home/user/project/public/wallpapers/wallpaper1.jpg",
});
expect(normalized.wallpaper).toBe("/wallpapers/wallpaper1.jpg");
});
it("rewrites Windows-style file URLs with drive letter", () => {
const normalized = normalizeProjectEditor({
wallpaper: "file:///C:/Users/me/openscreen/resources/wallpapers/wallpaper2.jpg",
});
expect(normalized.wallpaper).toBe("/wallpapers/wallpaper2.jpg");
});
it("leaves canonical relative paths untouched", () => {
const normalized = normalizeProjectEditor({ wallpaper: "/wallpapers/wallpaper2.jpg" });
expect(normalized.wallpaper).toBe("/wallpapers/wallpaper2.jpg");
});
it("leaves data URIs untouched", () => {
const dataUri = "data:image/png;base64,AAA";
expect(normalizeProjectEditor({ wallpaper: dataUri }).wallpaper).toBe(dataUri);
});
it("leaves colors and gradients untouched", () => {
expect(normalizeProjectEditor({ wallpaper: "#1a1a2e" }).wallpaper).toBe("#1a1a2e");
expect(
normalizeProjectEditor({ wallpaper: "linear-gradient(90deg, red, blue)" }).wallpaper,
).toBe("linear-gradient(90deg, red, blue)");
});
it("does NOT rewrite user files outside the known install layout", () => {
const userPath = "file:///home/user/Pictures/wallpapers/wallpaper1.jpg";
expect(normalizeProjectEditor({ wallpaper: userPath }).wallpaper).toBe(userPath);
});
it("falls back to default for bundled paths outside WALLPAPER_PATHS", () => {
const normalized = normalizeProjectEditor({
wallpaper: "file:///opt/Openscreen/resources/wallpapers/wallpaper99.jpg",
});
expect(normalized.wallpaper).toBe("/wallpapers/wallpaper1.jpg");
});
});
+198 -33
View File
@@ -1,34 +1,60 @@
import { normalizeBlurColor, normalizeBlurType } from "@/lib/blurEffects";
import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter";
import type { ProjectMedia } from "@/lib/recordingSession";
import { normalizeProjectMedia } from "@/lib/recordingSession";
import { ASPECT_RATIOS, type AspectRatio } from "@/utils/aspectRatioUtils";
import { DEFAULT_WALLPAPER, WALLPAPER_PATHS } from "@/lib/wallpaper";
import { ASPECT_RATIOS, type AspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils";
import {
type AnnotationRegion,
type CropRegion,
clampPlaybackSpeed,
DEFAULT_ANNOTATION_POSITION,
DEFAULT_ANNOTATION_SIZE,
DEFAULT_ANNOTATION_STYLE,
DEFAULT_BLUR_BLOCK_SIZE,
DEFAULT_BLUR_DATA,
DEFAULT_BLUR_FREEHAND_POINTS,
DEFAULT_BLUR_INTENSITY,
DEFAULT_CROP_REGION,
DEFAULT_FIGURE_DATA,
DEFAULT_PLAYBACK_SPEED,
DEFAULT_WEBCAM_LAYOUT_PRESET,
DEFAULT_WEBCAM_MASK_SHAPE,
DEFAULT_WEBCAM_POSITION,
DEFAULT_WEBCAM_SIZE_PRESET,
DEFAULT_ZOOM_DEPTH,
MAX_BLUR_BLOCK_SIZE,
MAX_BLUR_INTENSITY,
MAX_PLAYBACK_SPEED,
MIN_BLUR_BLOCK_SIZE,
MIN_BLUR_INTENSITY,
MIN_PLAYBACK_SPEED,
type SpeedRegion,
type TrimRegion,
type WebcamLayoutPreset,
type WebcamMaskShape,
type WebcamPosition,
type WebcamSizePreset,
type ZoomRegion,
} from "./types";
const WALLPAPER_COUNT = 18;
const VALID_BLUR_SHAPES = new Set(["rectangle", "oval", "freehand"] as const);
export const WALLPAPER_PATHS = Array.from(
{ length: WALLPAPER_COUNT },
(_, i) => `/wallpapers/wallpaper${i + 1}.jpg`,
);
// Pre-fix projects could persist resolved file:// URLs (machine-specific) for
// bundled wallpapers. Rewrite only paths that match a known install layout
// (resources/[assets/]wallpapers for packaged, public/wallpapers for dev) so
// a legitimate user file that happens to live in a folder named "wallpapers"
// elsewhere is never silently replaced.
const LEGACY_FILE_WALLPAPER_RE =
/^file:\/\/.*?\/(?:resources\/(?:assets\/)?|public\/)wallpapers\/(wallpaper\d+\.jpg)$/i;
const CANONICAL_WALLPAPERS = new Set(WALLPAPER_PATHS);
function normalizeWallpaperValue(value: string): string {
const match = LEGACY_FILE_WALLPAPER_RE.exec(value);
if (!match) return value;
const canonical = `/wallpapers/${match[1]}`;
return CANONICAL_WALLPAPERS.has(canonical) ? canonical : DEFAULT_WALLPAPER;
}
export const PROJECT_VERSION = 2;
@@ -47,12 +73,14 @@ export interface ProjectEditorState {
aspectRatio: AspectRatio;
webcamLayoutPreset: WebcamLayoutPreset;
webcamMaskShape: WebcamMaskShape;
webcamSizePreset: WebcamSizePreset;
webcamPosition: WebcamPosition | null;
exportQuality: ExportQuality;
exportFormat: ExportFormat;
gifFrameRate: GifFrameRate;
gifLoop: boolean;
gifSizePreset: GifSizePreset;
cursorHighlight: import("./videoPlayback/cursorHighlight").CursorHighlightConfig;
}
export interface EditorProjectData {
@@ -66,6 +94,26 @@ function isFiniteNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}
function computeNormalizedWebcamLayoutPreset(
webcamLayoutPreset: Partial<ProjectEditorState>["webcamLayoutPreset"],
normalizedAspectRatio: AspectRatio,
): WebcamLayoutPreset {
switch (webcamLayoutPreset) {
case "picture-in-picture":
return webcamLayoutPreset;
case "vertical-stack":
return isPortraitAspectRatio(normalizedAspectRatio)
? webcamLayoutPreset
: DEFAULT_WEBCAM_LAYOUT_PRESET;
case "dual-frame":
return isPortraitAspectRatio(normalizedAspectRatio)
? DEFAULT_WEBCAM_LAYOUT_PRESET
: webcamLayoutPreset;
default:
return DEFAULT_WEBCAM_LAYOUT_PRESET;
}
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
@@ -173,6 +221,26 @@ export function resolveProjectMedia(
export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): ProjectEditorState {
const validAspectRatios = new Set<AspectRatio>(ASPECT_RATIOS);
const normalizedAspectRatio: AspectRatio = validAspectRatios.has(
editor.aspectRatio as AspectRatio,
)
? (editor.aspectRatio as AspectRatio)
: "16:9";
const normalizedWebcamLayoutPreset = computeNormalizedWebcamLayoutPreset(
editor.webcamLayoutPreset,
normalizedAspectRatio,
);
const normalizedWebcamPosition: WebcamPosition | null =
normalizedWebcamLayoutPreset === "picture-in-picture" &&
editor.webcamPosition &&
typeof editor.webcamPosition === "object" &&
isFiniteNumber((editor.webcamPosition as WebcamPosition).cx) &&
isFiniteNumber((editor.webcamPosition as WebcamPosition).cy)
? {
cx: clamp((editor.webcamPosition as WebcamPosition).cx, 0, 1),
cy: clamp((editor.webcamPosition as WebcamPosition).cy, 0, 1),
}
: DEFAULT_WEBCAM_POSITION;
const normalizedZoomRegions: ZoomRegion[] = Array.isArray(editor.zoomRegions)
? editor.zoomRegions
@@ -223,14 +291,10 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
const endMs = Math.max(startMs + 1, rawEnd);
const speed =
region.speed === 0.25 ||
region.speed === 0.5 ||
region.speed === 0.75 ||
region.speed === 1.25 ||
region.speed === 1.5 ||
region.speed === 1.75 ||
region.speed === 2
? region.speed
isFiniteNumber(region.speed) &&
region.speed >= MIN_PLAYBACK_SPEED &&
region.speed <= MAX_PLAYBACK_SPEED
? clampPlaybackSpeed(region.speed)
: DEFAULT_PLAYBACK_SPEED;
return {
@@ -252,12 +316,22 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000;
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
const endMs = Math.max(startMs + 1, rawEnd);
const blurShape =
typeof region.blurData?.shape === "string" &&
VALID_BLUR_SHAPES.has(region.blurData.shape)
? region.blurData.shape
: DEFAULT_BLUR_DATA.shape;
const blurType = normalizeBlurType(region.blurData?.type);
const blurColor = normalizeBlurColor(region.blurData?.color);
return {
id: region.id,
startMs,
endMs,
type: region.type === "image" || region.type === "figure" ? region.type : "text",
type:
region.type === "image" || region.type === "figure" || region.type === "blur"
? region.type
: "text",
content: typeof region.content === "string" ? region.content : "",
textContent: typeof region.textContent === "string" ? region.textContent : undefined,
imageContent: typeof region.imageContent === "string" ? region.imageContent : undefined,
@@ -304,6 +378,42 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
...region.figureData,
}
: undefined,
blurData:
region.blurData && typeof region.blurData === "object"
? {
...DEFAULT_BLUR_DATA,
...region.blurData,
type: blurType,
shape: blurShape,
color: blurColor,
intensity: isFiniteNumber(region.blurData.intensity)
? clamp(region.blurData.intensity, MIN_BLUR_INTENSITY, MAX_BLUR_INTENSITY)
: DEFAULT_BLUR_INTENSITY,
blockSize: isFiniteNumber(region.blurData.blockSize)
? clamp(region.blurData.blockSize, MIN_BLUR_BLOCK_SIZE, MAX_BLUR_BLOCK_SIZE)
: DEFAULT_BLUR_BLOCK_SIZE,
freehandPoints: Array.isArray(region.blurData.freehandPoints)
? region.blurData.freehandPoints
.filter(
(
point,
): point is {
x: number;
y: number;
} =>
Boolean(
point &&
isFiniteNumber((point as { x?: unknown }).x) &&
isFiniteNumber((point as { y?: unknown }).y),
),
)
.map((point) => ({
x: clamp(point.x, 0, 100),
y: clamp(point.y, 0, 100),
}))
: DEFAULT_BLUR_FREEHAND_POINTS,
}
: undefined,
};
})
: [];
@@ -327,7 +437,10 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
const cropHeight = clamp(rawCropHeight, 0.01, 1 - cropY);
return {
wallpaper: typeof editor.wallpaper === "string" ? editor.wallpaper : WALLPAPER_PATHS[0],
wallpaper:
typeof editor.wallpaper === "string"
? normalizeWallpaperValue(editor.wallpaper)
: DEFAULT_WALLPAPER,
shadowIntensity: typeof editor.shadowIntensity === "number" ? editor.shadowIntensity : 0,
showBlur: typeof editor.showBlur === "boolean" ? editor.showBlur : false,
motionBlurAmount: isFiniteNumber(editor.motionBlurAmount)
@@ -349,13 +462,8 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
trimRegions: normalizedTrimRegions,
speedRegions: normalizedSpeedRegions,
annotationRegions: normalizedAnnotationRegions,
aspectRatio:
editor.aspectRatio && validAspectRatios.has(editor.aspectRatio) ? editor.aspectRatio : "16:9",
webcamLayoutPreset:
editor.webcamLayoutPreset === "vertical-stack" ||
editor.webcamLayoutPreset === "picture-in-picture"
? editor.webcamLayoutPreset
: DEFAULT_WEBCAM_LAYOUT_PRESET,
aspectRatio: normalizedAspectRatio,
webcamLayoutPreset: normalizedWebcamLayoutPreset,
webcamMaskShape:
editor.webcamMaskShape === "rectangle" ||
editor.webcamMaskShape === "circle" ||
@@ -363,16 +471,11 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
editor.webcamMaskShape === "rounded"
? editor.webcamMaskShape
: DEFAULT_WEBCAM_MASK_SHAPE,
webcamPosition:
editor.webcamPosition &&
typeof editor.webcamPosition === "object" &&
isFiniteNumber((editor.webcamPosition as WebcamPosition).cx) &&
isFiniteNumber((editor.webcamPosition as WebcamPosition).cy)
? {
cx: clamp((editor.webcamPosition as WebcamPosition).cx, 0, 1),
cy: clamp((editor.webcamPosition as WebcamPosition).cy, 0, 1),
}
: DEFAULT_WEBCAM_POSITION,
webcamSizePreset:
typeof editor.webcamSizePreset === "number" && isFiniteNumber(editor.webcamSizePreset)
? Math.max(10, Math.min(50, editor.webcamSizePreset))
: DEFAULT_WEBCAM_SIZE_PRESET,
webcamPosition: normalizedWebcamPosition,
exportQuality:
editor.exportQuality === "medium" || editor.exportQuality === "source"
? editor.exportQuality
@@ -392,6 +495,52 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
editor.gifSizePreset === "original"
? editor.gifSizePreset
: "medium",
cursorHighlight: normalizeCursorHighlight(editor.cursorHighlight),
};
}
function normalizeCursorHighlight(
value: unknown,
): import("./videoPlayback/cursorHighlight").CursorHighlightConfig {
const fallback: import("./videoPlayback/cursorHighlight").CursorHighlightConfig = {
enabled: false,
style: "ring",
sizePx: 24,
color: "#FFD700",
opacity: 0.9,
onlyOnClicks: false,
clickEmphasisDurationMs: 350,
offsetXNorm: 0,
offsetYNorm: 0,
};
if (!value || typeof value !== "object") return fallback;
const v = value as Partial<import("./videoPlayback/cursorHighlight").CursorHighlightConfig>;
return {
enabled: typeof v.enabled === "boolean" ? v.enabled : fallback.enabled,
style: v.style === "dot" || v.style === "ring" ? v.style : fallback.style,
sizePx:
typeof v.sizePx === "number" && v.sizePx >= 10 && v.sizePx <= 36 ? v.sizePx : fallback.sizePx,
color:
typeof v.color === "string" && /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(v.color)
? v.color
: fallback.color,
opacity:
typeof v.opacity === "number" && v.opacity >= 0 && v.opacity <= 1
? v.opacity
: fallback.opacity,
onlyOnClicks: typeof v.onlyOnClicks === "boolean" ? v.onlyOnClicks : fallback.onlyOnClicks,
clickEmphasisDurationMs:
typeof v.clickEmphasisDurationMs === "number" && v.clickEmphasisDurationMs > 0
? v.clickEmphasisDurationMs
: fallback.clickEmphasisDurationMs,
offsetXNorm:
typeof v.offsetXNorm === "number" && Number.isFinite(v.offsetXNorm)
? Math.max(-1, Math.min(1, v.offsetXNorm))
: fallback.offsetXNorm,
offsetYNorm:
typeof v.offsetYNorm === "number" && Number.isFinite(v.offsetYNorm)
? Math.max(-1, Math.min(1, v.offsetYNorm))
: fallback.offsetYNorm,
};
}
@@ -405,3 +554,19 @@ export function createProjectData(
editor,
};
}
export function createProjectSnapshot(
media: ProjectMedia,
editor: Partial<ProjectEditorState>,
): string {
return JSON.stringify(createProjectData(media, normalizeProjectEditor(editor)));
}
export function hasProjectUnsavedChanges(
currentSnapshot: string | null,
baselineSnapshot: string | null,
): boolean {
return Boolean(
currentSnapshot !== null && baselineSnapshot !== null && currentSnapshot !== baselineSnapshot,
);
}
+14 -4
View File
@@ -1,7 +1,8 @@
import type { Span } from "dnd-timeline";
import { useItem } from "dnd-timeline";
import { Gauge, MessageSquare, Scissors, ZoomIn } from "lucide-react";
import { Gauge, MessageSquare, MousePointer2, Scissors, ZoomIn } from "lucide-react";
import { useMemo } from "react";
import { useScopedT } from "@/contexts/I18nContext";
import { cn } from "@/lib/utils";
import glassStyles from "./ItemGlass.module.css";
@@ -14,7 +15,8 @@ interface ItemProps {
onSelect?: () => void;
zoomDepth?: number;
speedValue?: number;
variant?: "zoom" | "trim" | "annotation" | "speed";
isAutoFocus?: boolean;
variant?: "zoom" | "trim" | "annotation" | "speed" | "blur";
}
// Map zoom depth to multiplier labels
@@ -45,9 +47,11 @@ export default function Item({
onSelect,
zoomDepth = 1,
speedValue,
isAutoFocus = false,
variant = "zoom",
children,
}: ItemProps) {
const t = useScopedT("timeline");
const { setNodeRef, attributes, listeners, itemStyle, itemContentStyle } = useItem({
id,
span,
@@ -132,19 +136,25 @@ export default function Item({
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
{ZOOM_LABELS[zoomDepth] || `${zoomDepth}×`}
</span>
{isAutoFocus && (
<MousePointer2
className="w-3 h-3 shrink-0 opacity-90"
aria-label="Cursor-follow"
/>
)}
</>
) : isTrim ? (
<>
<Scissors className="w-3.5 h-3.5 shrink-0" />
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
Trim
{t("labels.trim")}
</span>
</>
) : isSpeed ? (
<>
<Gauge className="w-3.5 h-3.5 shrink-0" />
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
{speedValue !== undefined ? `${speedValue}×` : "Speed"}
{speedValue !== undefined ? `${speedValue}×` : t("labels.speed")}
</span>
</>
) : (
@@ -44,6 +44,7 @@ import { detectZoomDwellCandidates, normalizeCursorTelemetry } from "./zoomSugge
const ZOOM_ROW_ID = "row-zoom";
const TRIM_ROW_ID = "row-trim";
const ANNOTATION_ROW_ID = "row-annotation";
const BLUR_ROW_ID = "row-blur";
const SPEED_ROW_ID = "row-speed";
const FALLBACK_RANGE_MS = 1000;
const TARGET_MARKER_COUNT = 12;
@@ -73,6 +74,12 @@ interface TimelineEditorProps {
onAnnotationDelete?: (id: string) => void;
selectedAnnotationId?: string | null;
onSelectAnnotation?: (id: string | null) => void;
blurRegions?: AnnotationRegion[];
onBlurAdded?: (span: Span) => void;
onBlurSpanChange?: (id: string, span: Span) => void;
onBlurDelete?: (id: string) => void;
selectedBlurId?: string | null;
onSelectBlur?: (id: string | null) => void;
speedRegions?: SpeedRegion[];
onSpeedAdded?: (span: Span) => void;
onSpeedSpanChange?: (id: string, span: Span) => void;
@@ -96,7 +103,8 @@ interface TimelineRenderItem {
label: string;
zoomDepth?: number;
speedValue?: number;
variant: "zoom" | "trim" | "annotation" | "speed";
isAutoFocus?: boolean;
variant: "zoom" | "trim" | "annotation" | "speed" | "blur";
}
const SCALE_CANDIDATES = [
@@ -525,10 +533,12 @@ function Timeline({
onSelectZoom,
onSelectTrim,
onSelectAnnotation,
onSelectBlur,
onSelectSpeed,
selectedZoomId,
selectedTrimId,
selectedAnnotationId,
selectedBlurId,
selectedSpeedId,
keyframes = [],
}: {
@@ -540,10 +550,12 @@ function Timeline({
onSelectZoom?: (id: string | null) => void;
onSelectTrim?: (id: string | null) => void;
onSelectAnnotation?: (id: string | null) => void;
onSelectBlur?: (id: string | null) => void;
onSelectSpeed?: (id: string | null) => void;
selectedZoomId: string | null;
selectedTrimId?: string | null;
selectedAnnotationId?: string | null;
selectedBlurId?: string | null;
selectedSpeedId?: string | null;
keyframes?: { id: string; time: number }[];
}) {
@@ -568,6 +580,7 @@ function Timeline({
onSelectZoom?.(null);
onSelectTrim?.(null);
onSelectAnnotation?.(null);
onSelectBlur?.(null);
onSelectSpeed?.(null);
const rect = e.currentTarget.getBoundingClientRect();
@@ -586,6 +599,7 @@ function Timeline({
onSelectZoom,
onSelectTrim,
onSelectAnnotation,
onSelectBlur,
onSelectSpeed,
videoDurationMs,
sidebarWidth,
@@ -637,6 +651,7 @@ function Timeline({
const zoomItems = items.filter((item) => item.rowId === ZOOM_ROW_ID);
const trimItems = items.filter((item) => item.rowId === TRIM_ROW_ID);
const annotationItems = items.filter((item) => item.rowId === ANNOTATION_ROW_ID);
const blurItems = items.filter((item) => item.rowId === BLUR_ROW_ID);
const speedItems = items.filter((item) => item.rowId === SPEED_ROW_ID);
return (
@@ -668,6 +683,7 @@ function Timeline({
isSelected={item.id === selectedZoomId}
onSelect={() => onSelectZoom?.(item.id)}
zoomDepth={item.zoomDepth}
isAutoFocus={item.isAutoFocus}
variant="zoom"
>
{item.label}
@@ -711,6 +727,22 @@ function Timeline({
))}
</Row>
<Row id={BLUR_ROW_ID} isEmpty={blurItems.length === 0} hint={t("hints.pressBlur")}>
{blurItems.map((item) => (
<Item
id={item.id}
key={item.id}
rowId={item.rowId}
span={item.span}
isSelected={item.id === selectedBlurId}
onSelect={() => onSelectBlur?.(item.id)}
variant={item.variant}
>
{item.label}
</Item>
))}
</Row>
<Row id={SPEED_ROW_ID} isEmpty={speedItems.length === 0} hint={t("hints.pressSpeed")}>
{speedItems.map((item) => (
<Item
@@ -755,6 +787,12 @@ export default function TimelineEditor({
onAnnotationDelete,
selectedAnnotationId,
onSelectAnnotation,
blurRegions = [],
onBlurAdded,
onBlurSpanChange,
onBlurDelete,
selectedBlurId,
onSelectBlur,
speedRegions = [],
onSpeedAdded,
onSpeedSpanChange,
@@ -839,6 +877,12 @@ export default function TimelineEditor({
onSelectAnnotation(null);
}, [selectedAnnotationId, onAnnotationDelete, onSelectAnnotation]);
const deleteSelectedBlur = useCallback(() => {
if (!selectedBlurId || !onBlurDelete || !onSelectBlur) return;
onBlurDelete(selectedBlurId);
onSelectBlur(null);
}, [selectedBlurId, onBlurDelete, onSelectBlur]);
const deleteSelectedSpeed = useCallback(() => {
if (!selectedSpeedId || !onSpeedDelete || !onSelectSpeed) return;
onSpeedDelete(selectedSpeedId);
@@ -908,9 +952,10 @@ export default function TimelineEditor({
const isZoomItem = zoomRegions.some((r) => r.id === excludeId);
const isTrimItem = trimRegions.some((r) => r.id === excludeId);
const isAnnotationItem = annotationRegions.some((r) => r.id === excludeId);
const isBlurItem = blurRegions.some((r) => r.id === excludeId);
const isSpeedItem = speedRegions.some((r) => r.id === excludeId);
if (isAnnotationItem) {
if (isAnnotationItem || isBlurItem) {
return false;
}
@@ -937,7 +982,7 @@ export default function TimelineEditor({
return false;
},
[zoomRegions, trimRegions, annotationRegions, speedRegions],
[zoomRegions, trimRegions, annotationRegions, blurRegions, speedRegions],
);
// At least 5% of the timeline or 1000ms, whichever is larger, so the region
@@ -1165,6 +1210,21 @@ export default function TimelineEditor({
onAnnotationAdded({ start: startPos, end: endPos });
}, [videoDuration, totalMs, currentTimeMs, onAnnotationAdded, defaultRegionDurationMs]);
const handleAddBlur = useCallback(() => {
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onBlurAdded) {
return;
}
const defaultDuration = Math.min(defaultRegionDurationMs, totalMs);
if (defaultDuration <= 0) {
return;
}
const startPos = Math.max(0, Math.min(currentTimeMs, totalMs));
const endPos = Math.min(startPos + defaultDuration, totalMs);
onBlurAdded({ start: startPos, end: endPos });
}, [videoDuration, totalMs, currentTimeMs, onBlurAdded, defaultRegionDurationMs]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
@@ -1183,6 +1243,9 @@ export default function TimelineEditor({
if (matchesShortcut(e, keyShortcuts.addAnnotation, isMac)) {
handleAddAnnotation();
}
if (matchesShortcut(e, keyShortcuts.addBlur, isMac)) {
handleAddBlur();
}
if (matchesShortcut(e, keyShortcuts.addSpeed, isMac)) {
handleAddSpeed();
}
@@ -1223,6 +1286,8 @@ export default function TimelineEditor({
deleteSelectedTrim();
} else if (selectedAnnotationId) {
deleteSelectedAnnotation();
} else if (selectedBlurId) {
deleteSelectedBlur();
} else if (selectedSpeedId) {
deleteSelectedSpeed();
}
@@ -1235,16 +1300,19 @@ export default function TimelineEditor({
handleAddZoom,
handleAddTrim,
handleAddAnnotation,
handleAddBlur,
handleAddSpeed,
deleteSelectedKeyframe,
deleteSelectedZoom,
deleteSelectedTrim,
deleteSelectedAnnotation,
deleteSelectedBlur,
deleteSelectedSpeed,
selectedKeyframeId,
selectedZoomId,
selectedTrimId,
selectedAnnotationId,
selectedBlurId,
selectedSpeedId,
annotationRegions,
currentTime,
@@ -1271,6 +1339,7 @@ export default function TimelineEditor({
span: { start: region.startMs, end: region.endMs },
label: t("labels.zoomItem", { index: String(index + 1) }),
zoomDepth: region.depth,
isAutoFocus: region.focusMode === "auto",
variant: "zoom",
}));
@@ -1304,6 +1373,14 @@ export default function TimelineEditor({
};
});
const blurs: TimelineRenderItem[] = blurRegions.map((region, index) => ({
id: region.id,
rowId: BLUR_ROW_ID,
span: { start: region.startMs, end: region.endMs },
label: t("labels.blurItem", { index: String(index + 1) }),
variant: "blur",
}));
const speeds: TimelineRenderItem[] = speedRegions.map((region, index) => ({
id: region.id,
rowId: SPEED_ROW_ID,
@@ -1313,8 +1390,8 @@ export default function TimelineEditor({
variant: "speed",
}));
return [...zooms, ...trims, ...annotations, ...speeds];
}, [zoomRegions, trimRegions, annotationRegions, speedRegions, t]);
return [...zooms, ...trims, ...annotations, ...blurs, ...speeds];
}, [zoomRegions, trimRegions, annotationRegions, blurRegions, speedRegions, t]);
// Flat list of all non-annotation region spans for neighbour-clamping during drag/resize
const allRegionSpans = useMemo(() => {
@@ -1335,6 +1412,8 @@ export default function TimelineEditor({
onSpeedSpanChange?.(id, span);
} else if (annotationRegions.some((r) => r.id === id)) {
onAnnotationSpanChange?.(id, span);
} else if (blurRegions.some((r) => r.id === id)) {
onBlurSpanChange?.(id, span);
}
},
[
@@ -1342,10 +1421,12 @@ export default function TimelineEditor({
trimRegions,
speedRegions,
annotationRegions,
blurRegions,
onZoomSpanChange,
onTrimSpanChange,
onSpeedSpanChange,
onAnnotationSpanChange,
onBlurSpanChange,
],
);
@@ -1403,6 +1484,25 @@ export default function TimelineEditor({
>
<MessageSquare className="w-4 h-4" />
</Button>
<Button
onClick={handleAddBlur}
variant="ghost"
size="icon"
className="h-7 w-7 text-slate-400 hover:text-[#7dd3fc] hover:bg-[#7dd3fc]/10 transition-all"
title={t("buttons.addBlur")}
>
<svg
className="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="8" cy="12" r="3" />
<circle cx="16" cy="12" r="3" />
<path d="M6 6h12M6 18h12" />
</svg>
</Button>
<Button
onClick={handleAddSpeed}
variant="ghost"
@@ -1489,10 +1589,12 @@ export default function TimelineEditor({
onSelectZoom={onSelectZoom}
onSelectTrim={onSelectTrim}
onSelectAnnotation={onSelectAnnotation}
onSelectBlur={onSelectBlur}
onSelectSpeed={onSelectSpeed}
selectedZoomId={selectedZoomId}
selectedTrimId={selectedTrimId}
selectedAnnotationId={selectedAnnotationId}
selectedBlurId={selectedBlurId}
selectedSpeedId={selectedSpeedId}
keyframes={keyframes}
/>
+61 -2
View File
@@ -3,6 +3,10 @@ import type { WebcamLayoutPreset } from "@/lib/compositeLayout";
export type ZoomDepth = 1 | 2 | 3 | 4 | 5 | 6;
export type ZoomFocusMode = "manual" | "auto";
export type { WebcamLayoutPreset };
/** Webcam size as a percentage of the canvas reference dimension (1050). */
export type WebcamSizePreset = number;
export const DEFAULT_WEBCAM_SIZE_PRESET: WebcamSizePreset = 25;
export const DEFAULT_WEBCAM_LAYOUT_PRESET: WebcamLayoutPreset = "picture-in-picture";
@@ -43,7 +47,7 @@ export interface TrimRegion {
endMs: number;
}
export type AnnotationType = "text" | "image" | "figure";
export type AnnotationType = "text" | "image" | "figure" | "blur";
export type ArrowDirection =
| "up"
@@ -61,6 +65,27 @@ export interface FigureData {
strokeWidth: number;
}
export type BlurShape = "rectangle" | "oval" | "freehand";
export type BlurType = "blur" | "mosaic";
export type BlurColor = "white" | "black";
export const MIN_BLUR_INTENSITY = 2;
export const MAX_BLUR_INTENSITY = 40;
export const DEFAULT_BLUR_INTENSITY = 12;
export const MIN_BLUR_BLOCK_SIZE = 4;
export const MAX_BLUR_BLOCK_SIZE = 48;
export const DEFAULT_BLUR_BLOCK_SIZE = 12;
export interface BlurData {
type: BlurType;
shape: BlurShape;
color: BlurColor;
intensity: number;
blockSize: number;
// Points are normalized (0-100) within the annotation bounds.
freehandPoints?: Array<{ x: number; y: number }>;
}
export interface AnnotationPosition {
x: number;
y: number;
@@ -95,6 +120,7 @@ export interface AnnotationRegion {
style: AnnotationTextStyle;
zIndex: number;
figureData?: FigureData;
blurData?: BlurData;
}
export const DEFAULT_ANNOTATION_POSITION: AnnotationPosition = {
@@ -124,6 +150,27 @@ export const DEFAULT_FIGURE_DATA: FigureData = {
strokeWidth: 4,
};
export const DEFAULT_BLUR_FREEHAND_POINTS: Array<{ x: number; y: number }> = [
{ x: 10, y: 30 },
{ x: 25, y: 10 },
{ x: 55, y: 8 },
{ x: 82, y: 20 },
{ x: 90, y: 45 },
{ x: 78, y: 72 },
{ x: 52, y: 90 },
{ x: 22, y: 84 },
{ x: 8, y: 58 },
];
export const DEFAULT_BLUR_DATA: BlurData = {
type: "blur",
shape: "rectangle",
color: "white",
intensity: DEFAULT_BLUR_INTENSITY,
blockSize: DEFAULT_BLUR_BLOCK_SIZE,
freehandPoints: DEFAULT_BLUR_FREEHAND_POINTS,
};
export interface CropRegion {
x: number;
y: number;
@@ -138,7 +185,16 @@ export const DEFAULT_CROP_REGION: CropRegion = {
height: 1,
};
export type PlaybackSpeed = 0.25 | 0.5 | 0.75 | 1.25 | 1.5 | 1.75 | 2;
export type PlaybackSpeed = number;
export const MIN_PLAYBACK_SPEED = 0.1;
// Anything above 16x causes the playhead to stall during preview
// due to the video decoder not being able to keep up.
export const MAX_PLAYBACK_SPEED = 16;
export function clampPlaybackSpeed(speed: number): PlaybackSpeed {
return Math.round(Math.min(MAX_PLAYBACK_SPEED, Math.max(MIN_PLAYBACK_SPEED, speed)) * 100) / 100;
}
export interface SpeedRegion {
id: string;
@@ -155,6 +211,9 @@ export const SPEED_OPTIONS: Array<{ speed: PlaybackSpeed; label: string }> = [
{ speed: 1.5, label: "1.5×" },
{ speed: 1.75, label: "1.75×" },
{ speed: 2, label: "2×" },
{ speed: 3, label: "3×" },
{ speed: 4, label: "4×" },
{ speed: 5, label: "5×" },
];
export const DEFAULT_PLAYBACK_SPEED: PlaybackSpeed = 1.5;
@@ -0,0 +1,125 @@
import type { Graphics } from "pixi.js";
export type CursorHighlightStyle = "dot" | "ring";
export interface CursorHighlightConfig {
enabled: boolean;
style: CursorHighlightStyle;
sizePx: number;
color: string;
opacity: number;
// Show only on clicks (macOS — depends on click telemetry from uiohook).
onlyOnClicks: boolean;
clickEmphasisDurationMs: number;
// Per-recording manual nudge. Cursor telemetry is normalized to the display,
// but window recordings frame a subset of the display so the highlight
// lands offset. Users dial these in once to align with the actual cursor.
offsetXNorm: number;
offsetYNorm: number;
}
export const CURSOR_HIGHLIGHT_MIN_SIZE_PX = 10;
export const CURSOR_HIGHLIGHT_MAX_SIZE_PX = 36;
export const DEFAULT_CURSOR_HIGHLIGHT: CursorHighlightConfig = {
enabled: false,
style: "ring",
sizePx: 24,
color: "#FFD700",
opacity: 0.9,
onlyOnClicks: false,
clickEmphasisDurationMs: 350,
offsetXNorm: 0,
offsetYNorm: 0,
};
export const CURSOR_HIGHLIGHT_OFFSET_RANGE = 0.25; // ±25% of recorded surface
// Alpha multiplier for the highlight at `timeMs`. Returns 1 when not in
// click-only mode; in click-only mode fades 1→0 across each click's window.
export function clickEmphasisAlpha(
timeMs: number,
clickTimestampsMs: number[] | undefined,
config: CursorHighlightConfig,
): number {
if (!config.onlyOnClicks) return 1;
if (!clickTimestampsMs || clickTimestampsMs.length === 0) return 0;
const window = Math.max(1, config.clickEmphasisDurationMs);
for (let i = 0; i < clickTimestampsMs.length; i++) {
const dt = timeMs - clickTimestampsMs[i];
if (dt >= 0 && dt <= window) {
return 1 - dt / window;
}
}
return 0;
}
function parseHexColor(hex: string): number {
const cleaned = hex.replace("#", "");
if (cleaned.length === 3) {
const r = cleaned[0];
const g = cleaned[1];
const b = cleaned[2];
return Number.parseInt(`${r}${r}${g}${g}${b}${b}`, 16);
}
return Number.parseInt(cleaned.slice(0, 6), 16);
}
export function drawCursorHighlightGraphics(g: Graphics, config: CursorHighlightConfig): void {
g.clear();
if (!config.enabled) return;
const color = parseHexColor(config.color);
const radius = Math.max(1, config.sizePx / 2);
const alpha = Math.max(0, Math.min(1, config.opacity));
switch (config.style) {
case "dot": {
g.circle(0, 0, radius);
g.fill({ color, alpha });
break;
}
case "ring": {
g.circle(0, 0, radius);
g.stroke({ color, alpha, width: Math.max(2, radius * 0.18) });
break;
}
}
}
export function drawCursorHighlightCanvas(
ctx: CanvasRenderingContext2D,
cx: number,
cy: number,
config: CursorHighlightConfig,
pixelScale = 1,
): void {
if (!config.enabled) return;
const radius = Math.max(1, (config.sizePx / 2) * pixelScale);
const alpha = Math.max(0, Math.min(1, config.opacity));
const color = config.color;
ctx.save();
ctx.globalAlpha = alpha;
switch (config.style) {
case "dot": {
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.fill();
break;
}
case "ring": {
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.strokeStyle = color;
ctx.lineWidth = Math.max(2, radius * 0.18);
ctx.stroke();
break;
}
}
ctx.restore();
}
@@ -5,6 +5,7 @@ import {
type Size,
type StyledRenderRect,
type WebcamLayoutPreset,
type WebcamSizePreset,
} from "@/lib/compositeLayout";
import type { CropRegion, WebcamMaskShape } from "../types";
@@ -20,6 +21,7 @@ interface LayoutParams {
padding?: number;
webcamDimensions?: Size | null;
webcamLayoutPreset?: WebcamLayoutPreset;
webcamSizePreset?: WebcamSizePreset;
webcamPosition?: { cx: number; cy: number } | null;
webcamMaskShape?: WebcamMaskShape;
}
@@ -47,6 +49,7 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
padding = 0,
webcamDimensions,
webcamLayoutPreset,
webcamSizePreset,
webcamPosition,
webcamMaskShape,
} = params;
@@ -95,6 +98,7 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
screenSize: { width: croppedVideoWidth, height: croppedVideoHeight },
webcamSize: webcamDimensions,
layoutPreset: webcamLayoutPreset,
webcamSizePreset,
webcamPosition,
webcamMaskShape,
});
@@ -136,7 +140,7 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
screenRect.y,
screenRect.width,
screenRect.height,
compositeLayout.screenCover ? 0 : borderRadius,
compositeLayout.screenBorderRadius ?? (compositeLayout.screenCover ? 0 : borderRadius),
);
maskGraphics.fill({ color: 0xffffff });
@@ -90,8 +90,10 @@ export function computeZoomTransform({
}
const progress = Math.min(1, Math.max(0, zoomProgress));
const focusStagePxX = baseMask.x + focusX * baseMask.width;
const focusStagePxY = baseMask.y + focusY * baseMask.height;
// Focus coordinates are stage-normalized (0-1 of full canvas),
// so map directly to stage pixels, not through baseMask.
const focusStagePxX = focusX * stageSize.width;
const focusStagePxY = focusY * stageSize.height;
const stageCenterX = stageSize.width / 2;
const stageCenterY = stageSize.height / 2;
const scale = 1 + (zoomScale - 1) * progress;
@@ -128,8 +130,8 @@ export function computeFocusFromTransform({
const focusStagePxY = (stageCenterY - y) / zoomScale;
return {
cx: (focusStagePxX - baseMask.x) / baseMask.width,
cy: (focusStagePxY - baseMask.y) / baseMask.height,
cx: focusStagePxX / stageSize.width,
cy: focusStagePxY / stageSize.height,
};
}