mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-27 00:05:39 +00:00
Merge pull request #459 from ExtraBinoss/feat/settings-panel-auto-props-extension-ui
add: drawIcons for extensions, SettingsPanel for extension fixed.
This commit is contained in:
@@ -230,8 +230,27 @@ api.getActiveFrame();
|
||||
api.isExtensionActive(extensionId);
|
||||
api.getPlaybackState();
|
||||
api.getCanvasDimensions();
|
||||
api.drawIcon(ctx, "Sparkle", 100, 100, 20, "#2563EB", "regular");
|
||||
```
|
||||
|
||||
### Drawing Icons
|
||||
|
||||
Extensions can draw icons from Recordly's bundled Phosphor icon set directly on a canvas context:
|
||||
|
||||
```js
|
||||
api.drawIcon(
|
||||
ctx,
|
||||
"ArrowClockwise", // icon name from @phosphor-icons/react
|
||||
120, // x (center)
|
||||
80, // y (center)
|
||||
18, // size in px
|
||||
"#ffffff", // color
|
||||
"bold", // optional weight: thin | light | regular | bold | fill
|
||||
);
|
||||
```
|
||||
|
||||
This is useful for lightweight overlays and avoids bundling your own icon assets.
|
||||
|
||||
## Settings Panels
|
||||
|
||||
```js
|
||||
|
||||
@@ -110,6 +110,15 @@ const tahoeCursorUrl = cursorSetAssets.tahoe.arrow.url;
|
||||
const BUILTIN_CURSOR_PREVIEW_SIZE = 28;
|
||||
const BUILTIN_CURSOR_PREVIEW_FRAME_SIZE = 48;
|
||||
|
||||
function getStepPrecision(step: number): number {
|
||||
if (!Number.isFinite(step) || step <= 0) return 0;
|
||||
const [mantissa = "0", exponentPart = "0"] = step.toExponential().split("e");
|
||||
const exponent = Number.parseInt(exponentPart, 10);
|
||||
const mantissaDecimals = (mantissa.split(".")[1] ?? "").replace(/0+$/, "").length;
|
||||
const precision = exponent < 0 ? Math.max(0, -exponent + mantissaDecimals) : mantissaDecimals;
|
||||
return Math.min(12, precision);
|
||||
}
|
||||
|
||||
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%)",
|
||||
@@ -262,42 +271,24 @@ function ExtensionSettingsSection({
|
||||
}
|
||||
|
||||
if (field.type === "slider") {
|
||||
const step = field.step ?? 0.01;
|
||||
const precision = getStepPrecision(step);
|
||||
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 ?? 1}
|
||||
step={step}
|
||||
onChange={(v) => {
|
||||
extensionHost.setExtensionSetting(extensionId, field.id, v);
|
||||
forceUpdate((n) => n + 1);
|
||||
}}
|
||||
formatValue={(v) => v.toFixed(precision)}
|
||||
parseInput={(text) => parseFloat(text)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1233,9 +1224,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 =
|
||||
@@ -1243,16 +1237,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,
|
||||
@@ -1498,6 +1487,22 @@ export function SettingsPanel({
|
||||
|
||||
const resetBackgroundSection = () => {
|
||||
onBackgroundBlurChange?.(initialEditorPreferences.backgroundBlur);
|
||||
|
||||
const preferredWallpaper = initialEditorPreferences.wallpaper;
|
||||
const hasPreferredWallpaper =
|
||||
(preferredWallpaper && builtInWallpaperPaths.includes(preferredWallpaper)) ||
|
||||
(preferredWallpaper && extensionWallpaperPaths.includes(preferredWallpaper)) ||
|
||||
(preferredWallpaper && customImages.includes(preferredWallpaper)) ||
|
||||
(preferredWallpaper && isHexWallpaper(preferredWallpaper)) ||
|
||||
(preferredWallpaper && GRADIENTS.includes(preferredWallpaper));
|
||||
|
||||
onWallpaperChange(
|
||||
(hasPreferredWallpaper ? preferredWallpaper : "") ||
|
||||
builtInWallpaperPaths[0] ||
|
||||
extensionWallpaperPaths[0] ||
|
||||
BUILT_IN_WALLPAPERS[0]?.publicPath ||
|
||||
"",
|
||||
);
|
||||
};
|
||||
|
||||
const resetZoomSection = () => {
|
||||
@@ -1576,7 +1581,17 @@ export function SettingsPanel({
|
||||
};
|
||||
|
||||
const resetFrameSection = () => {
|
||||
const preferredFrame = initialEditorPreferences.frame;
|
||||
const resolvedFrame = preferredFrame
|
||||
? availableFrames.some((candidate) => candidate.id === preferredFrame)
|
||||
? preferredFrame
|
||||
: null
|
||||
: null;
|
||||
onShadowChange?.(initialEditorPreferences.shadowIntensity);
|
||||
onBorderRadiusChange?.(initialEditorPreferences.borderRadius);
|
||||
onAspectRatioChange?.(initialEditorPreferences.aspectRatio);
|
||||
onPaddingChange?.({ ...initialEditorPreferences.padding });
|
||||
onFrameChange?.(resolvedFrame);
|
||||
removeBackgroundStateRef.current = null;
|
||||
};
|
||||
|
||||
|
||||
@@ -1642,17 +1642,28 @@ export default function VideoEditor() {
|
||||
|
||||
// Extension-contributed standalone section pages (no parentSection)
|
||||
const [extensionSectionButtons, setExtensionSectionButtons] = useState<
|
||||
{ id: EditorEffectSection; label: string; icon: typeof PhPuzzle | string }[]
|
||||
{
|
||||
id: EditorEffectSection;
|
||||
label: string;
|
||||
icon: typeof PhPuzzle | string;
|
||||
extensionPath?: string | null;
|
||||
}[]
|
||||
>([]);
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
const panels = extensionHost.getSettingsPanels();
|
||||
const extensionPathById = new Map(
|
||||
extensionHost
|
||||
.getActiveExtensions()
|
||||
.map((extension) => [extension.manifest.id, extension.path]),
|
||||
);
|
||||
const standalone = panels
|
||||
.filter((p) => !p.panel.parentSection)
|
||||
.map((p) => ({
|
||||
id: `ext:${p.extensionId}/${p.panel.id}` as EditorEffectSection,
|
||||
label: p.panel.label,
|
||||
icon: p.panel.icon || (PhPuzzle as typeof PhPuzzle | string),
|
||||
extensionPath: extensionPathById.get(p.extensionId),
|
||||
}));
|
||||
setExtensionSectionButtons(standalone);
|
||||
};
|
||||
@@ -5681,6 +5692,7 @@ export default function VideoEditor() {
|
||||
{typeof section.icon === "string" ? (
|
||||
<ExtensionIcon
|
||||
icon={section.icon}
|
||||
extensionPath={section.extensionPath}
|
||||
className="h-[27px] w-[27px]"
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -74,6 +74,44 @@ function getContributedCursorStylesSignature() {
|
||||
.join("|");
|
||||
}
|
||||
|
||||
function getRegisteredFramesSignature() {
|
||||
return extensionHost
|
||||
.getFrames()
|
||||
.map(
|
||||
(frame) =>
|
||||
`${frame.id}:${frame.filePath}:${frame.thumbnailPath}:${frame.appearance ?? ""}`,
|
||||
)
|
||||
.sort()
|
||||
.join("|");
|
||||
}
|
||||
|
||||
function serializeExtensionSettingValue(value: unknown): string {
|
||||
try {
|
||||
const serialized = JSON.stringify(value);
|
||||
return serialized ?? "undefined";
|
||||
} catch {
|
||||
try {
|
||||
return String(value);
|
||||
} catch {
|
||||
return "[unserializable]";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getExtensionSettingsSignature() {
|
||||
return extensionHost
|
||||
.getSettingsPanels()
|
||||
.flatMap((registeredPanel) => {
|
||||
const { extensionId, panel } = registeredPanel;
|
||||
return panel.fields.map((field) => {
|
||||
const value = extensionHost.getExtensionSetting(extensionId, field.id);
|
||||
return `${extensionId}:${panel.id}:${field.id}:${serializeExtensionSettingValue(value)}`;
|
||||
});
|
||||
})
|
||||
.sort()
|
||||
.join("|");
|
||||
}
|
||||
|
||||
import { extensionHost } from "@/lib/extensions";
|
||||
import {
|
||||
mapCursorToCanvasNormalized,
|
||||
@@ -430,6 +468,26 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
const [pixiRendererBackend, setPixiRendererBackend] = useState<PixiPreviewBackend | null>(
|
||||
null,
|
||||
);
|
||||
const [frameUpdateCounter, setFrameUpdateCounter] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let framesSignature = getRegisteredFramesSignature();
|
||||
let settingsSignature = getExtensionSettingsSignature();
|
||||
return extensionHost.onChange(() => {
|
||||
const nextFramesSignature = getRegisteredFramesSignature();
|
||||
const nextSettingsSignature = getExtensionSettingsSignature();
|
||||
if (
|
||||
nextFramesSignature === framesSignature &&
|
||||
nextSettingsSignature === settingsSignature
|
||||
) {
|
||||
return;
|
||||
}
|
||||
framesSignature = nextFramesSignature;
|
||||
settingsSignature = nextSettingsSignature;
|
||||
setFrameUpdateCounter((c) => c + 1);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const overlayRef = useRef<HTMLDivElement | null>(null);
|
||||
const focusIndicatorRef = useRef<HTMLDivElement | null>(null);
|
||||
const webcamVideoRef = useRef<HTMLVideoElement | null>(null);
|
||||
@@ -466,6 +524,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
const frameSpriteRef = useRef<Sprite | null>(null);
|
||||
const frameContainerRef = useRef<Container | null>(null);
|
||||
const frameIdRef = useRef<string | null>(frame);
|
||||
const frameReloadKeyRef = useRef<string | null>(null);
|
||||
const isPlayingRef = useRef(isPlaying);
|
||||
const suspendRenderingRef = useRef(suspendRendering);
|
||||
const isSeekingRef = useRef(false);
|
||||
@@ -997,11 +1056,27 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
useEffect(() => {
|
||||
const frameContainer = frameContainerRef.current;
|
||||
if (!frameContainer) return;
|
||||
const nextFrameReloadKey = `${frame ?? ""}:${frameUpdateCounter}`;
|
||||
const activeFrameData = frame
|
||||
? extensionHost.getFrames().find((registeredFrame) => registeredFrame.id === frame)
|
||||
: null;
|
||||
const shouldRedrawDynamicFrame = Boolean(activeFrameData?.draw && frameSpriteRef.current);
|
||||
|
||||
// Clear existing frame sprite
|
||||
// Layout-only changes should not force texture/sprite recreation.
|
||||
if (frameReloadKeyRef.current === nextFrameReloadKey && !shouldRedrawDynamicFrame) {
|
||||
layoutVideoContentRef.current?.();
|
||||
return;
|
||||
}
|
||||
frameReloadKeyRef.current = nextFrameReloadKey;
|
||||
|
||||
// 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 +1140,12 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [frame]);
|
||||
}, [aspectRatio, borderRadius, cropRegion, frame, frameUpdateCounter, padding]);
|
||||
|
||||
// Always re-run geometric layout when layout props change, even if frame sprite isn't reloaded.
|
||||
useEffect(() => {
|
||||
layoutVideoContentRef.current?.();
|
||||
}, [aspectRatio, borderRadius, cropRegion, padding]);
|
||||
|
||||
const selectedZoom = useMemo(() => {
|
||||
if (!selectedZoomId) return null;
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import { createExtensionModuleUrl, resolveExtensionRelativeFileUrl } from "./fileUrls";
|
||||
import { resolveIconPath } from "./iconDraw";
|
||||
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: ReturnType<typeof setTimeout> | null = 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 =
|
||||
@@ -181,6 +185,14 @@ export class ExtensionHost {
|
||||
isPlaying: boolean;
|
||||
} | null = null;
|
||||
|
||||
constructor() {
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("beforeunload", () => {
|
||||
this.flushPersistedSettings();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate an extension given its info and resolved module URL.
|
||||
*/
|
||||
@@ -270,6 +282,7 @@ export class ExtensionHost {
|
||||
}
|
||||
|
||||
this.activeExtensions.delete(extensionId);
|
||||
this.flushPersistedSettings();
|
||||
this.notifyListeners();
|
||||
console.log(`[extensions] Deactivated: ${extensionId}`);
|
||||
}
|
||||
@@ -282,6 +295,7 @@ export class ExtensionHost {
|
||||
for (const id of ids) {
|
||||
await this.deactivateExtension(id);
|
||||
}
|
||||
this.flushPersistedSettings();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -572,12 +586,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 +610,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 +619,22 @@ 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);
|
||||
}
|
||||
|
||||
private flushPersistedSettings(): void {
|
||||
if (this.persistTimeout) {
|
||||
clearTimeout(this.persistTimeout);
|
||||
this.persistTimeout = null;
|
||||
}
|
||||
this.writePersistedSettingsStore(this.getFullSettingsStore());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -957,6 +994,29 @@ 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 path = resolveIconPath(name, weight, host.iconPathCache);
|
||||
|
||||
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());
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import * as PhosphorIcons from "@phosphor-icons/react";
|
||||
|
||||
type IconWeight = "thin" | "light" | "regular" | "bold" | "fill";
|
||||
const missingIconPathCache = new Set<string>();
|
||||
|
||||
function resolveIconComponent(name: string): {
|
||||
iconName: string;
|
||||
icon: { render?: (props: { weight: string }, ref: unknown) => unknown };
|
||||
} | null {
|
||||
const iconLibrary = PhosphorIcons as Record<string, unknown>;
|
||||
const iconName =
|
||||
typeof iconLibrary[name] !== "undefined"
|
||||
? name
|
||||
: typeof iconLibrary[`${name}Icon`] !== "undefined"
|
||||
? `${name}Icon`
|
||||
: null;
|
||||
if (!iconName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
iconName,
|
||||
icon: iconLibrary[iconName] as {
|
||||
render?: (props: { weight: string }, ref: unknown) => unknown;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function toNumber(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string") {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parsePointList(value: unknown): Array<{ x: number; y: number }> {
|
||||
if (typeof value !== "string") return [];
|
||||
const nums = value
|
||||
.trim()
|
||||
.split(/[\s,]+/)
|
||||
.map((part) => Number(part))
|
||||
.filter((n) => Number.isFinite(n));
|
||||
const points: Array<{ x: number; y: number }> = [];
|
||||
for (let i = 0; i + 1 < nums.length; i += 2) {
|
||||
points.push({ x: nums[i], y: nums[i + 1] });
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
function collectPathData(node: unknown, output: Path2D): number {
|
||||
if (!node) return 0;
|
||||
if (Array.isArray(node)) {
|
||||
let added = 0;
|
||||
for (const child of node) added += collectPathData(child, output);
|
||||
return added;
|
||||
}
|
||||
if (typeof node !== "object") return 0;
|
||||
|
||||
const maybeNode = node as {
|
||||
type?: unknown;
|
||||
props?: {
|
||||
d?: unknown;
|
||||
children?: unknown;
|
||||
x1?: unknown;
|
||||
y1?: unknown;
|
||||
x2?: unknown;
|
||||
y2?: unknown;
|
||||
points?: unknown;
|
||||
cx?: unknown;
|
||||
cy?: unknown;
|
||||
r?: unknown;
|
||||
rx?: unknown;
|
||||
ry?: unknown;
|
||||
x?: unknown;
|
||||
y?: unknown;
|
||||
width?: unknown;
|
||||
height?: unknown;
|
||||
};
|
||||
};
|
||||
const props = maybeNode.props;
|
||||
|
||||
let added = 0;
|
||||
if (maybeNode.type === "path" && typeof props?.d === "string") {
|
||||
output.addPath(new Path2D(props.d));
|
||||
added += 1;
|
||||
} else if (maybeNode.type === "line") {
|
||||
const x1 = toNumber(props?.x1);
|
||||
const y1 = toNumber(props?.y1);
|
||||
const x2 = toNumber(props?.x2);
|
||||
const y2 = toNumber(props?.y2);
|
||||
if (x1 !== null && y1 !== null && x2 !== null && y2 !== null) {
|
||||
output.moveTo(x1, y1);
|
||||
output.lineTo(x2, y2);
|
||||
added += 1;
|
||||
}
|
||||
} else if (maybeNode.type === "polyline" || maybeNode.type === "polygon") {
|
||||
const points = parsePointList(props?.points);
|
||||
if (points.length > 0) {
|
||||
output.moveTo(points[0].x, points[0].y);
|
||||
for (let i = 1; i < points.length; i += 1) {
|
||||
output.lineTo(points[i].x, points[i].y);
|
||||
}
|
||||
if (maybeNode.type === "polygon") {
|
||||
output.closePath();
|
||||
}
|
||||
added += 1;
|
||||
}
|
||||
} else if (maybeNode.type === "circle") {
|
||||
const cx = toNumber(props?.cx);
|
||||
const cy = toNumber(props?.cy);
|
||||
const r = toNumber(props?.r);
|
||||
if (cx !== null && cy !== null && r !== null) {
|
||||
output.arc(cx, cy, r, 0, Math.PI * 2);
|
||||
added += 1;
|
||||
}
|
||||
} else if (maybeNode.type === "ellipse") {
|
||||
const cx = toNumber(props?.cx);
|
||||
const cy = toNumber(props?.cy);
|
||||
const rx = toNumber(props?.rx);
|
||||
const ry = toNumber(props?.ry);
|
||||
if (cx !== null && cy !== null && rx !== null && ry !== null) {
|
||||
output.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
|
||||
added += 1;
|
||||
}
|
||||
} else if (maybeNode.type === "rect") {
|
||||
const x = toNumber(props?.x) ?? 0;
|
||||
const y = toNumber(props?.y) ?? 0;
|
||||
const width = toNumber(props?.width);
|
||||
const height = toNumber(props?.height);
|
||||
const rxRaw = toNumber(props?.rx);
|
||||
const ryRaw = toNumber(props?.ry);
|
||||
if (width !== null && height !== null) {
|
||||
const resolvedRxRaw = rxRaw ?? ryRaw ?? 0;
|
||||
const resolvedRyRaw = ryRaw ?? rxRaw ?? 0;
|
||||
const rx = Math.max(0, Math.min(resolvedRxRaw, width / 2));
|
||||
const ry = Math.max(0, Math.min(resolvedRyRaw, height / 2));
|
||||
if (rx === 0 && ry === 0) {
|
||||
output.rect(x, y, width, height);
|
||||
} else {
|
||||
const right = x + width;
|
||||
const bottom = y + height;
|
||||
output.moveTo(x + rx, y);
|
||||
output.lineTo(right - rx, y);
|
||||
output.ellipse(right - rx, y + ry, rx, ry, 0, -Math.PI / 2, 0);
|
||||
output.lineTo(right, bottom - ry);
|
||||
output.ellipse(right - rx, bottom - ry, rx, ry, 0, 0, Math.PI / 2);
|
||||
output.lineTo(x + rx, bottom);
|
||||
output.ellipse(x + rx, bottom - ry, rx, ry, 0, Math.PI / 2, Math.PI);
|
||||
output.lineTo(x, y + ry);
|
||||
output.ellipse(x + rx, y + ry, rx, ry, 0, Math.PI, (3 * Math.PI) / 2);
|
||||
output.closePath();
|
||||
}
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
added += collectPathData(props?.children, output);
|
||||
return added;
|
||||
}
|
||||
|
||||
export function resolveIconPath(
|
||||
name: string,
|
||||
weight: IconWeight,
|
||||
cache: Map<string, Path2D>,
|
||||
): Path2D | null {
|
||||
const cacheKey = `${name}:${weight}`;
|
||||
if (missingIconPathCache.has(cacheKey)) {
|
||||
return null;
|
||||
}
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const resolved = resolveIconComponent(name);
|
||||
if (!resolved) {
|
||||
console.warn(`[extensions] Icon ${name} not found in Phosphor library`);
|
||||
missingIconPathCache.add(cacheKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const element = resolved.icon.render?.({ weight }, null) as
|
||||
| { props?: { weights?: Map<string, { props?: { children?: unknown } }> } }
|
||||
| undefined;
|
||||
const weights = element?.props?.weights;
|
||||
const definition = weights?.get(weight);
|
||||
const children = definition?.props?.children;
|
||||
const combinedPath = new Path2D();
|
||||
const shapeCount = collectPathData(children, combinedPath);
|
||||
|
||||
if (shapeCount === 0) {
|
||||
console.warn(`[extensions] No path data found for ${name}:${weight}`, {
|
||||
iconName: resolved.iconName,
|
||||
element,
|
||||
children,
|
||||
});
|
||||
missingIconPathCache.add(cacheKey);
|
||||
return null;
|
||||
}
|
||||
cache.set(cacheKey, combinedPath);
|
||||
return combinedPath;
|
||||
} catch (err) {
|
||||
console.error(`[extensions] Failed to extract path for icon ${name}:`, err);
|
||||
missingIconPathCache.add(cacheKey);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user