Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 59 additions & 10 deletions src/components/video-editor/AnnotationOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
DEFAULT_BLUR_BLOCK_SIZE,
DEFAULT_BLUR_DATA,
DEFAULT_BLUR_INTENSITY,
type FigureData,
} from "./types";

const FREEHAND_POINT_THRESHOLD = 1;
Expand Down Expand Up @@ -184,13 +185,63 @@ export function AnnotationOverlay({
y,
]);

const renderArrow = () => {
const direction = annotation.figureData?.arrowDirection || "right";
const color = annotation.figureData?.color || "#34B27B";
const strokeWidth = annotation.figureData?.strokeWidth || 4;

const ArrowComponent = getArrowComponent(direction);
return <ArrowComponent color={color} strokeWidth={strokeWidth} />;
const renderFigure = (figureData: FigureData) => {
const kind = figureData.kind ?? "arrow";
switch (kind) {
case "arrow": {
const ArrowComponent = getArrowComponent(figureData.arrowDirection);
return (
<div className="w-full h-full flex items-center justify-center p-2">
<ArrowComponent color={figureData.color} strokeWidth={figureData.strokeWidth} />
</div>
);
}
case "rectangle":
return (
<svg
viewBox="0 0 100 100"
preserveAspectRatio="none"
className="w-full h-full"
style={{ overflow: "visible" }}
>
<rect
x={0}
y={0}
width={100}
height={100}
stroke={figureData.color}
strokeWidth={figureData.strokeWidth}
fill={figureData.fill ?? "none"}
vectorEffect="non-scaling-stroke"
/>
</svg>
);
case "ellipse":
return (
<svg
viewBox="0 0 100 100"
preserveAspectRatio="none"
className="w-full h-full"
style={{ overflow: "visible" }}
>
<ellipse
cx={50}
cy={50}
rx={50}
ry={50}
stroke={figureData.color}
strokeWidth={figureData.strokeWidth}
fill={figureData.fill ?? "none"}
vectorEffect="non-scaling-stroke"
/>
</svg>
);
default: {
const _exhaustiveCheck: never = kind;
console.warn(`AnnotationOverlay: unsupported figure kind "${String(_exhaustiveCheck)}"`);
return null;
}
}
};

const normalizePoint = (event: PointerEvent<HTMLDivElement>) => {
Expand Down Expand Up @@ -348,9 +399,7 @@ export function AnnotationOverlay({
);
}

return (
<div className="w-full h-full flex items-center justify-center p-2">{renderArrow()}</div>
);
return renderFigure(annotation.figureData);

case "blur": {
const shape = annotation.blurData?.shape ?? "rectangle";
Expand Down
116 changes: 115 additions & 1 deletion src/components/video-editor/AnnotationSettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,15 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Slider } from "@/components/ui/slider";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
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 { AddCustomFontDialog } from "./AddCustomFontDialog";
import { getArrowComponent } from "./ArrowSvgs";
import { alphaToPercent, getAlpha, percentToAlpha, withAlpha } from "./figureFill";
import {
type AnnotationRegion,
type AnnotationType,
Expand Down Expand Up @@ -589,9 +591,16 @@ export function AnnotationSettingsPanel({
color={annotation.figureData?.color || "#34B27B"}
colors={colorPalette}
onChange={(color) => {
const previous = annotation.figureData;
if (!previous) return;
const nextFill =
typeof previous.fill === "string"
? withAlpha(color.hex, getAlpha(previous.fill))
: previous.fill;
const newFigureData: FigureData = {
...annotation.figureData!,
...previous,
color: color.hex,
fill: nextFill,
};
onFigureDataChange?.(newFigureData);
}}
Expand All @@ -602,6 +611,111 @@ export function AnnotationSettingsPanel({
</PopoverContent>
</Popover>
</div>

{(() => {
const figureData = annotation.figureData;
if (!figureData) return null;
const isClosedShape = (figureData.kind ?? "arrow") !== "arrow";
if (!isClosedShape) return null;
Comment on lines +615 to +619
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Closed shapes still inherit arrow-only controls.

This branch already knows when kind !== "arrow", but rectangles/ellipses still show the Arrow Direction grid and Arrow Color label above it. that’s lowkey confusing: direction becomes a no-op, and the color label is wrong. Reuse the same kind check to hide arrow-only controls and rename the shared color control to something generic like Stroke/Border.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/video-editor/AnnotationSettingsPanel.tsx` around lines 615 -
619, The UI shows arrow-specific controls for closed shapes; use the existing
figureData.kind check (the isClosedShape boolean computed from
annotation.figureData in AnnotationSettingsPanel) to conditionally hide the
"Arrow Direction" grid and the "Arrow Color" label when kind !== "arrow", and
change the shared color control label to a generic term like "Stroke" or
"Border" so it is correct for both arrows and closed shapes; update the
conditional rendering around the Arrow Direction/Arrow Color UI blocks to rely
on isClosedShape (or its negation) and rename the color label text used by the
shared color control.

const fillEnabled = typeof figureData.fill === "string";
const defaultFillFromColor = withAlpha(figureData.color, percentToAlpha(20));
const fillValue = figureData.fill ?? defaultFillFromColor;
const currentAlpha = getAlpha(fillValue);
const opacityPercent = alphaToPercent(currentAlpha);
return (
<div>
<div className="flex items-center justify-between mb-2">
<label
id="annotation-fill-label"
className="text-xs font-medium text-slate-200"
>
{t("annotation.fill.label")}
</label>
<div className="flex items-center gap-2">
<span className="text-[10px] text-slate-400">
{t("annotation.fill.toggle")}
</span>
<Switch
checked={fillEnabled}
onCheckedChange={(checked) => {
const next: FigureData = {
...figureData,
fill: checked ? defaultFillFromColor : undefined,
};
onFigureDataChange?.(next);
}}
aria-labelledby="annotation-fill-label"
className="data-[state=checked]:bg-[#34B27B] scale-90"
/>
</div>
</div>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
{fillEnabled && (
<>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-full h-10 justify-start gap-2 bg-white/5 border-white/10 hover:bg-white/10"
>
<div className="w-5 h-5 rounded-full border border-white/20 relative overflow-hidden">
<div className="absolute inset-0 checkerboard-bg opacity-50" />
<div
className="absolute inset-0"
style={{ backgroundColor: fillValue }}
/>
</div>
<span className="text-xs text-slate-300 truncate flex-1 text-left">
{fillValue}
</span>
<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={fillValue}
colors={colorPalette}
onChange={(color) => {
const next: FigureData = {
...figureData,
fill: withAlpha(color.hex, currentAlpha),
};
onFigureDataChange?.(next);
}}
style={{
borderRadius: "8px",
}}
/>
</PopoverContent>
</Popover>
<div className="mt-3">
<div className="flex items-center justify-between mb-2">
<label className="text-xs font-medium text-slate-200">
{t("annotation.fill.opacity")}
</label>
<span className="text-[10px] text-slate-400 tabular-nums">
{`${opacityPercent}%`}
</span>
</div>
<Slider
value={[opacityPercent]}
onValueChange={([value]) => {
const next: FigureData = {
...figureData,
fill: withAlpha(fillValue, percentToAlpha(value)),
};
onFigureDataChange?.(next);
}}
min={0}
max={100}
step={1}
className="w-full"
/>
</div>
</>
)}
</div>
);
})()}
</TabsContent>
</Tabs>

Expand Down
21 changes: 17 additions & 4 deletions src/components/video-editor/VideoEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -904,20 +904,33 @@ export default function VideoEditor() {
);

const handleAnnotationAdded = useCallback(
(span: Span) => {
(span: Span, figureData?: FigureData) => {
const id = `annotation-${nextAnnotationIdRef.current++}`;
const zIndex = nextAnnotationZIndexRef.current++;
const newRegion: AnnotationRegion = {
const baseRegion = {
id,
startMs: Math.round(span.start),
endMs: Math.round(span.end),
type: "text",
content: "Enter text...",
position: { ...DEFAULT_ANNOTATION_POSITION },
size: { ...DEFAULT_ANNOTATION_SIZE },
style: { ...DEFAULT_ANNOTATION_STYLE },
zIndex,
};
// When figureData is provided by the caller (toolbar shape buttons), it is
// the source of truth — the new region is a figure with that exact data.
// No fallback to "arrow" or DEFAULT_FIGURE_DATA on this branch.
const newRegion: AnnotationRegion = figureData
? {
...baseRegion,
type: "figure",
content: "",
figureData: { ...figureData },
}
: {
...baseRegion,
type: "text",
content: "Enter text...",
};
pushState((prev) => ({
annotationRegions: [...prev.annotationRegions, newRegion],
}));
Expand Down
109 changes: 109 additions & 0 deletions src/components/video-editor/figureFill.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/**
* Hex-color utilities for the annotation Fill section.
*
* Fill is stored on `FigureData.fill` as a single canonical string in
* `#RRGGBBAA` form (8-digit, alpha required when fill is set). The opacity
* slider in the inspector is a *view* of the alpha byte; toggling fill off
* sets `figureData.fill = undefined`. There is no separate alpha field.
*
* All functions throw on malformed input — this module is internal code, not
* a user-input parser. Callers must not rely on silent fallbacks.
*/

/**
* Structured representation of a parsed hex color.
*
* `rgb` is always `#RRGGBB` (lowercase, with leading `#`).
* `alpha` is an integer in the closed range `[0, 255]`. When the input was a
* 6-digit hex (no alpha component), `alpha` defaults to `255` (fully opaque).
*/
export interface ParsedHexColor {
readonly rgb: string;
readonly alpha: number;
}

const HEX_COLOR_REGEX = /^#([0-9a-f]{6})([0-9a-f]{2})?$/i;

/**
* Parse a `#RRGGBB` or `#RRGGBBAA` hex color (case-insensitive) into its RGB
* and alpha components. Throws on malformed input.
*/
export function parseHexColor(hex: string): ParsedHexColor {
const match = HEX_COLOR_REGEX.exec(hex);
if (match === null) {
throw new Error(`Invalid hex color: ${hex}`);
}
const rgbHex = match[1].toLowerCase();
const alphaHex = match[2];
const alpha = alphaHex === undefined ? 255 : Number.parseInt(alphaHex, 16);
return { rgb: `#${rgbHex}`, alpha };
}

/**
* Format an RGB hex (`#RRGGBB`) plus an integer alpha byte (0-255) into a
* canonical `#RRGGBBAA` string. Lowercase, alpha is zero-padded to 2 digits.
*
* Throws if `rgb` is not `#RRGGBB` or `alpha` is not an integer in [0, 255].
*/
export function formatHexColor(rgb: string, alpha: number): string {
const match = HEX_COLOR_REGEX.exec(rgb);
if (match === null || match[2] !== undefined) {
throw new Error(`Invalid RGB hex (expected #RRGGBB): ${rgb}`);
}
if (!Number.isInteger(alpha) || alpha < 0 || alpha > 255) {
throw new Error(`Invalid alpha byte (expected integer 0-255): ${alpha}`);
}
const rgbLower = match[1].toLowerCase();
const alphaHex = alpha.toString(16).padStart(2, "0");
return `#${rgbLower}${alphaHex}`;
}

/**
* Convenience: take any `#RRGGBB` or `#RRGGBBAA` color, replace its alpha
* byte with the supplied one, and return the canonical `#RRGGBBAA` form.
*
* Throws if `color` is malformed or `alpha` is out of range.
*/
export function withAlpha(color: string, alpha: number): string {
const parsed = parseHexColor(color);
return formatHexColor(parsed.rgb, alpha);
}

/**
* Extract the alpha byte (0-255) from a `#RRGGBB` or `#RRGGBBAA` hex string.
* Throws on malformed input. A 6-digit hex resolves to `255`.
*/
export function getAlpha(fill: string): number {
return parseHexColor(fill).alpha;
}

/**
* Convert an alpha byte (0-255) to an integer percent (0-100). The result is
* rounded to the nearest int. Throws on non-finite or out-of-range input.
*/
export function alphaToPercent(alpha: number): number {
if (!Number.isFinite(alpha) || alpha < 0 || alpha > 255) {
throw new Error(`alphaToPercent: invalid alpha (expected finite 0-255): ${alpha}`);
}
return Math.round((alpha / 255) * 100);
}

/**
* Convert an integer percent (0-100) to an alpha byte (0-255). The result is
* rounded to the nearest int. Throws on non-finite or out-of-range input.
*/
export function percentToAlpha(percent: number): number {
if (!Number.isFinite(percent) || percent < 0 || percent > 100) {
throw new Error(`percentToAlpha: invalid percent (expected finite 0-100): ${percent}`);
}
return Math.round((percent / 100) * 255);
}

/**
* Cheap structural check for a `#RRGGBB` or `#RRGGBBAA` hex color string.
* Use this to gate untrusted input before passing to `parseHexColor` /
* `withAlpha` (which throw on malformed input).
*/
export function isValidHexColor(value: unknown): value is string {
return typeof value === "string" && HEX_COLOR_REGEX.test(value);
}
Loading
Loading