add: drawIcons for extensions, extensions can now draw Icon from Phosphor/react

fix: SliderControl is now the default for Settings Panel of the extensions
fix: resetting of values on shadows/corner radius
This commit is contained in:
Alan Trebugeais
2026-05-08 23:38:33 +02:00
parent eef863198c
commit d2740786fc
4 changed files with 155 additions and 51 deletions
+33 -44
View File
@@ -263,41 +263,21 @@ function ExtensionSettingsSection({
if (field.type === "slider") {
return (
<div
key={field.id}
className="flex items-center justify-between gap-2 rounded-lg bg-foreground/[0.03] px-2.5 py-1.5"
>
<span className="text-[11px] text-muted-foreground flex-shrink-0">
{field.label}
</span>
<div className="flex items-center gap-1.5">
<input
type="range"
min={field.min ?? 0}
max={field.max ?? 1}
step={field.step ?? 0.01}
value={
typeof value === "number"
? value
: (field.defaultValue as number)
}
onChange={(e) => {
extensionHost.setExtensionSetting(
extensionId,
field.id,
parseFloat(e.target.value),
);
forceUpdate((n) => n + 1);
}}
className="w-20 h-1 accent-[#2563EB]"
/>
<span className="text-[10px] text-muted-foreground/70 w-8 text-right font-mono">
{(typeof value === "number"
? value
: (field.defaultValue as number)
).toFixed(1)}
</span>
</div>
<div key={field.id} className="mt-1">
<SliderControl
label={field.label}
value={typeof value === "number" ? value : (field.defaultValue as number)}
defaultValue={field.defaultValue as number}
min={field.min ?? 0}
max={field.max ?? 100}
step={field.step ?? 1}
onChange={(v) => {
extensionHost.setExtensionSetting(extensionId, field.id, v);
forceUpdate((n) => n + 1);
}}
formatValue={(v) => v.toFixed(1)}
parseInput={(text) => parseFloat(text)}
/>
</div>
);
}
@@ -1215,9 +1195,12 @@ export function SettingsPanel({
if (GRADIENTS.includes(selected)) {
setGradient(selected);
}
}, [selected]);
if (selected.startsWith("data:image") && !customImages.includes(selected)) {
setCustomImages((prev) => [selected, ...prev]);
useEffect(() => {
if (selected.startsWith("data:image")) {
setCustomImages((prev) => (prev.includes(selected) ? prev : [selected, ...prev]));
return;
}
const isKnownWallpaper =
@@ -1225,16 +1208,11 @@ export function SettingsPanel({
wallpaperPreviewPaths.includes(selected) ||
extensionWallpaperPaths.includes(selected);
if (
!isKnownWallpaper &&
isVideoWallpaperSource(selected) &&
!customImages.includes(selected)
) {
setCustomImages((prev) => [selected, ...prev]);
if (!isKnownWallpaper && isVideoWallpaperSource(selected)) {
setCustomImages((prev) => (prev.includes(selected) ? prev : [selected, ...prev]));
}
}, [
builtInWallpaperPaths,
customImages,
extensionWallpaperPaths,
selected,
wallpaperPreviewPaths,
@@ -1480,6 +1458,13 @@ export function SettingsPanel({
const resetBackgroundSection = () => {
onBackgroundBlurChange?.(initialEditorPreferences.backgroundBlur);
onWallpaperChange(
initialEditorPreferences.wallpaper ||
builtInWallpaperPaths[0] ||
extensionWallpaperPaths[0] ||
BUILT_IN_WALLPAPERS[0]?.publicPath ||
"",
);
};
const resetZoomSection = () => {
@@ -1558,7 +1543,11 @@ export function SettingsPanel({
};
const resetFrameSection = () => {
onShadowChange?.(initialEditorPreferences.shadowIntensity);
onBorderRadiusChange?.(initialEditorPreferences.borderRadius);
onAspectRatioChange?.(initialEditorPreferences.aspectRatio);
onPaddingChange?.({ ...initialEditorPreferences.padding });
onFrameChange?.(initialEditorPreferences.frame);
removeBackgroundStateRef.current = null;
};
+16 -4
View File
@@ -430,6 +430,14 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const [pixiRendererBackend, setPixiRendererBackend] = useState<PixiPreviewBackend | null>(
null,
);
const [frameUpdateCounter, setFrameUpdateCounter] = useState(0);
useEffect(() => {
return extensionHost.onChange(() => {
setFrameUpdateCounter((c) => c + 1);
});
}, []);
const overlayRef = useRef<HTMLDivElement | null>(null);
const focusIndicatorRef = useRef<HTMLDivElement | null>(null);
const webcamVideoRef = useRef<HTMLVideoElement | null>(null);
@@ -998,10 +1006,14 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const frameContainer = frameContainerRef.current;
if (!frameContainer) return;
// Clear existing frame sprite
// Clear existing frame sprite and its texture to free memory
if (frameSpriteRef.current) {
frameContainer.removeChild(frameSpriteRef.current);
frameSpriteRef.current.destroy();
const sprite = frameSpriteRef.current;
frameContainer.removeChild(sprite);
if (sprite.texture) {
sprite.texture.destroy(true); // destroy texture and its baseTexture
}
sprite.destroy();
frameSpriteRef.current = null;
}
@@ -1065,7 +1077,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
return () => {
cancelled = true;
};
}, [frame]);
}, [frame, frameUpdateCounter]);
const selectedZoom = useMemo(() => {
if (!selectedZoomId) return null;
+86 -3
View File
@@ -6,6 +6,7 @@
*/
import { createExtensionModuleUrl, resolveExtensionRelativeFileUrl } from "./fileUrls";
import * as PhosphorIcons from "@phosphor-icons/react";
import type {
ContributedCursorStyle,
ContributedFrame,
@@ -143,6 +144,9 @@ export class ExtensionHost {
Set<(settingId: string, value: unknown) => void>
>();
private listeners = new Set<() => void>();
private fullSettingsStore: Record<string, Record<string, unknown>> | null = null;
private persistTimeout: any = null;
private iconPathCache = new Map<string, Path2D>();
// Shared playback/project state — set by the app, queried by extensions
private _videoInfo: { width: number; height: number; durationMs: number; fps: number } | null =
@@ -468,6 +472,7 @@ export class ExtensionHost {
borderRadius: layout.borderRadius,
padding: normalizedPadding,
};
this.notifyListeners();
}
setZoomState(
@@ -478,6 +483,7 @@ export class ExtensionHost {
setShadowConfig(config: { enabled: boolean; intensity: number }): void {
this._shadowConfig = config;
this.notifyListeners();
}
setCursorTelemetry(
@@ -572,12 +578,20 @@ export class ExtensionHost {
}
}
private getFullSettingsStore(): Record<string, Record<string, unknown>> {
if (this.fullSettingsStore) {
return this.fullSettingsStore;
}
this.fullSettingsStore = this.readPersistedSettingsStore();
return this.fullSettingsStore;
}
private ensureExtensionSettingsLoaded(extensionId: string): void {
if (this.extensionSettings.has(extensionId)) {
return;
}
const store = this.readPersistedSettingsStore();
const store = this.getFullSettingsStore();
const persisted = store[extensionId];
const normalized =
persisted && typeof persisted === "object" && !Array.isArray(persisted)
@@ -588,7 +602,7 @@ export class ExtensionHost {
}
private persistExtensionSettings(extensionId: string): void {
const store = this.readPersistedSettingsStore();
const store = this.getFullSettingsStore();
const settings = this.extensionSettings.get(extensionId) ?? {};
if (Object.keys(settings).length === 0) {
@@ -597,7 +611,14 @@ export class ExtensionHost {
store[extensionId] = { ...settings };
}
this.writePersistedSettingsStore(store);
// Debounce the actual write to localStorage to avoid blocking the UI thread during rapid changes
if (this.persistTimeout) {
clearTimeout(this.persistTimeout);
}
this.persistTimeout = setTimeout(() => {
this.writePersistedSettingsStore(store);
this.persistTimeout = null;
}, 500);
}
/**
@@ -957,6 +978,68 @@ export class ExtensionHost {
};
},
drawIcon(
ctx: CanvasRenderingContext2D,
name: string,
x: number,
y: number,
size: number,
color: string,
weight: "thin" | "light" | "regular" | "bold" | "fill" = "regular",
): void {
const cacheKey = `${name}:${weight}`;
let path = host.iconPathCache.get(cacheKey);
if (!path) {
// 1. Get the Icon component from the project's library
const Icon = (PhosphorIcons as any)[name];
if (!Icon) return;
try {
// 2. Extract path data by dry-running the component
const Icon = (PhosphorIcons as any)[name];
if (!Icon) {
console.warn(`[extensions] Icon ${name} not found in Phosphor library`);
return;
}
const element = (Icon as any).render?.({ weight }, null);
const weights = element?.props?.weights;
const definition = weights?.get(weight);
const children = definition?.props?.children;
// Handle both single path and array of paths
let pathElement = children;
if (Array.isArray(children)) {
pathElement = children.find((c: any) => c?.type === 'path' || c?.props?.d);
}
const pathData = pathElement?.props?.d;
if (pathData) {
path = new Path2D(pathData);
host.iconPathCache.set(cacheKey, path);
} else {
console.warn(`[extensions] No path data found for ${name}:${weight}`, { element, children });
}
} catch (err) {
console.error(`[extensions] Failed to extract path for icon ${name}:`, err);
return;
}
}
if (path) {
ctx.save();
ctx.translate(x, y);
const scale = size / 256; // Phosphor icons use a 256x256 grid
ctx.scale(scale, scale);
ctx.translate(-128, -128); // Center the icon
ctx.fillStyle = color;
ctx.fill(path);
ctx.restore();
}
},
onSettingChange(callback: (settingId: string, value: unknown) => void): () => void {
if (!host.settingChangeCallbacks.has(extensionId)) {
host.settingChangeCallbacks.set(extensionId, new Set());
+20
View File
@@ -565,6 +565,26 @@ export interface RecordlyExtensionAPI {
* Useful for bulk-reading initial state on activation.
*/
getAllSettings(): Record<string, unknown>;
/**
* Draw a Phosphor icon from the project's library onto the canvas.
* @param ctx The 2D rendering context
* @param name The name of the icon (e.g. "CaretLeft", "ArrowClockwise")
* @param x X coordinate (center)
* @param y Y coordinate (center)
* @param size Icon size in pixels
* @param color Icon color (CSS color string)
* @param weight Icon weight (optional: 'thin' | 'light' | 'regular' | 'bold' | 'fill', default 'regular')
*/
drawIcon(
ctx: CanvasRenderingContext2D,
name: string,
x: number,
y: number,
size: number,
color: string,
weight?: "thin" | "light" | "regular" | "bold" | "fill",
): void;
}
// ---------------------------------------------------------------------------