From 2c73982ddee778d6a6792c683f5447b24fb6c947 Mon Sep 17 00:00:00 2001
From: webadderall <131426131+webadderall@users.noreply.github.com>
Date: Sun, 22 Mar 2026 18:33:44 +1100
Subject: [PATCH] feat: add custom cursor style rendering
Add minimal, dot, and inverted cursor styles for preview and export rendering, including the bundled minimal cursor asset and runtime cursor asset generation.
---
Minimal Cursor.svg | 17 +
.../videoPlayback/cursorRenderer.ts | 430 ++++++++++++++++--
2 files changed, 409 insertions(+), 38 deletions(-)
create mode 100644 Minimal Cursor.svg
diff --git a/Minimal Cursor.svg b/Minimal Cursor.svg
new file mode 100644
index 00000000..2a74013f
--- /dev/null
+++ b/Minimal Cursor.svg
@@ -0,0 +1,17 @@
+
diff --git a/src/components/video-editor/videoPlayback/cursorRenderer.ts b/src/components/video-editor/videoPlayback/cursorRenderer.ts
index 6be84150..2cf00f7c 100644
--- a/src/components/video-editor/videoPlayback/cursorRenderer.ts
+++ b/src/components/video-editor/videoPlayback/cursorRenderer.ts
@@ -7,7 +7,13 @@ import {
Texture,
} from "pixi.js";
import { MotionBlurFilter } from "pixi-filters/motion-blur";
-import { DEFAULT_CURSOR_CLICK_BOUNCE_DURATION, type CursorTelemetryPoint } from "../types";
+import minimalCursorUrl from "../../../../Minimal Cursor.svg";
+import {
+ DEFAULT_CURSOR_CLICK_BOUNCE_DURATION,
+ DEFAULT_CURSOR_STYLE,
+ type CursorStyle,
+ type CursorTelemetryPoint,
+} from "../types";
import {
projectCursorPositionToViewport,
type CursorViewportRect,
@@ -56,6 +62,8 @@ export interface CursorRenderConfig {
clickBounceDuration: number;
/** Cursor sway multiplier. */
sway: number;
+ /** Cursor visual style. */
+ style: CursorStyle;
}
export const DEFAULT_CURSOR_CONFIG: CursorRenderConfig = {
@@ -68,6 +76,7 @@ export const DEFAULT_CURSOR_CONFIG: CursorRenderConfig = {
clickBounce: 1,
clickBounceDuration: DEFAULT_CURSOR_CLICK_BOUNCE_DURATION,
sway: 0,
+ style: DEFAULT_CURSOR_STYLE,
};
const REFERENCE_WIDTH = 1920;
@@ -88,6 +97,10 @@ const CURSOR_SHADOW_PADDING = 12;
let cursorAssetsPromise: Promise | null = null;
let loadedCursorAssets: Partial> = {};
+let loadedInvertedCursorAssets: Partial> = {};
+let loadedCursorStyleAssets: Partial<
+ Record, LoadedCursorAsset>
+> = {};
const SUPPORTED_CURSOR_KEYS: CursorAssetKey[] = [
"arrow",
"text",
@@ -100,6 +113,106 @@ const SUPPORTED_CURSOR_KEYS: CursorAssetKey[] = [
"not-allowed",
];
+const CUSTOM_CURSOR_ARROW_WIDTH = 150;
+const CUSTOM_CURSOR_ARROW_HEIGHT = 214;
+const CUSTOM_CURSOR_ARROW_TIP_X = 14;
+const CUSTOM_CURSOR_ARROW_TIP_Y = 12;
+
+function drawArrowCursorPath(
+ ctx: CanvasRenderingContext2D,
+ width: number,
+ height: number,
+) {
+ ctx.beginPath();
+ ctx.moveTo(width * 0.093, height * 0.056);
+ ctx.lineTo(width * 0.136, height * 0.78);
+ ctx.lineTo(width * 0.34, height * 0.618);
+ ctx.lineTo(width * 0.453, height * 0.967);
+ ctx.lineTo(width * 0.62, height * 0.906);
+ ctx.lineTo(width * 0.501, height * 0.57);
+ ctx.lineTo(width * 0.933, height * 0.57);
+ ctx.closePath();
+}
+
+async function createCursorStyleAsset(
+ style: Exclude,
+): Promise {
+ if (style === "figma") {
+ const image = await loadImage(minimalCursorUrl);
+ const sourceCanvas = document.createElement("canvas");
+ sourceCanvas.width = image.naturalWidth;
+ sourceCanvas.height = image.naturalHeight;
+ const sourceCtx = sourceCanvas.getContext("2d")!;
+ sourceCtx.drawImage(image, 0, 0);
+ const trimmed = trimCanvasToAlpha(sourceCanvas, { x: 40, y: 22 });
+ await Assets.load(trimmed.dataUrl);
+ const trimmedImage = await loadImage(trimmed.dataUrl);
+ const texture = Texture.from(trimmed.dataUrl);
+
+ return {
+ texture,
+ image: trimmedImage,
+ aspectRatio:
+ trimmed.height > 0
+ ? trimmed.width / trimmed.height
+ : 1,
+ anchorX:
+ trimmed.hotspot && trimmed.width > 0 ? trimmed.hotspot.x / trimmed.width : 0,
+ anchorY:
+ trimmed.hotspot && trimmed.height > 0 ? trimmed.hotspot.y / trimmed.height : 0,
+ };
+ }
+
+ const canvas = document.createElement("canvas");
+ let anchorX = 0.5;
+ let anchorY = 0.5;
+
+ if (style === "dot") {
+ canvas.width = 112;
+ canvas.height = 112;
+ anchorX = 0.5;
+ anchorY = 0.5;
+ const ctx = canvas.getContext("2d")!;
+ const cx = canvas.width / 2;
+ const cy = canvas.height / 2;
+ const radius = 26;
+ ctx.fillStyle = "#ffffff";
+ ctx.strokeStyle = "rgba(15, 23, 42, 0.88)";
+ ctx.lineWidth = 10;
+ ctx.beginPath();
+ ctx.arc(cx, cy, radius, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.stroke();
+ } else {
+ canvas.width = CUSTOM_CURSOR_ARROW_WIDTH;
+ canvas.height = CUSTOM_CURSOR_ARROW_HEIGHT;
+ anchorX = CUSTOM_CURSOR_ARROW_TIP_X / CUSTOM_CURSOR_ARROW_WIDTH;
+ anchorY = CUSTOM_CURSOR_ARROW_TIP_Y / CUSTOM_CURSOR_ARROW_HEIGHT;
+ const ctx = canvas.getContext("2d")!;
+ ctx.fillStyle = "#ffffff";
+ ctx.strokeStyle = "#111111";
+ ctx.lineWidth = 11;
+ ctx.lineJoin = "round";
+ ctx.lineCap = "round";
+ drawArrowCursorPath(ctx, canvas.width, canvas.height);
+ ctx.fill();
+ ctx.stroke();
+ }
+
+ const dataUrl = canvas.toDataURL("image/png");
+ await Assets.load(dataUrl);
+ const image = await loadImage(dataUrl);
+ const texture = Texture.from(dataUrl);
+
+ return {
+ texture,
+ image,
+ aspectRatio: canvas.height > 0 ? canvas.width / canvas.height : 1,
+ anchorX,
+ anchorY,
+ };
+}
+
function loadImage(dataUrl: string) {
return new Promise((resolve, reject) => {
const image = new Image();
@@ -116,6 +229,107 @@ function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
+function trimCanvasToAlpha(
+ canvas: HTMLCanvasElement,
+ hotspot?: { x: number; y: number },
+) {
+ const ctx = canvas.getContext("2d");
+ if (!ctx) {
+ return {
+ dataUrl: canvas.toDataURL("image/png"),
+ width: canvas.width,
+ height: canvas.height,
+ hotspot,
+ };
+ }
+
+ const { width, height } = canvas;
+ const imageData = ctx.getImageData(0, 0, width, height);
+ const { data } = imageData;
+ let minX = width;
+ let minY = height;
+ let maxX = -1;
+ let maxY = -1;
+
+ for (let y = 0; y < height; y += 1) {
+ for (let x = 0; x < width; x += 1) {
+ const alpha = data[(y * width + x) * 4 + 3];
+ if (alpha === 0) {
+ continue;
+ }
+
+ minX = Math.min(minX, x);
+ minY = Math.min(minY, y);
+ maxX = Math.max(maxX, x);
+ maxY = Math.max(maxY, y);
+ }
+ }
+
+ if (maxX < minX || maxY < minY) {
+ return {
+ dataUrl: canvas.toDataURL("image/png"),
+ width,
+ height,
+ hotspot,
+ };
+ }
+
+ const croppedWidth = maxX - minX + 1;
+ const croppedHeight = maxY - minY + 1;
+ const croppedCanvas = document.createElement("canvas");
+ croppedCanvas.width = croppedWidth;
+ croppedCanvas.height = croppedHeight;
+ const croppedCtx = croppedCanvas.getContext("2d")!;
+ croppedCtx.drawImage(canvas, minX, minY, croppedWidth, croppedHeight, 0, 0, croppedWidth, croppedHeight);
+
+ return {
+ dataUrl: croppedCanvas.toDataURL("image/png"),
+ width: croppedWidth,
+ height: croppedHeight,
+ hotspot: hotspot
+ ? {
+ x: hotspot.x - minX,
+ y: hotspot.y - minY,
+ }
+ : undefined,
+ };
+}
+
+async function createInvertedCursorAsset(
+ asset: LoadedCursorAsset,
+): Promise {
+ const canvas = document.createElement("canvas");
+ canvas.width = asset.image.naturalWidth;
+ canvas.height = asset.image.naturalHeight;
+ const ctx = canvas.getContext("2d")!;
+ ctx.drawImage(asset.image, 0, 0);
+ const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
+ const { data } = imageData;
+ for (let index = 0; index < data.length; index += 4) {
+ if (data[index + 3] === 0) {
+ continue;
+ }
+
+ data[index] = 255 - data[index];
+ data[index + 1] = 255 - data[index + 1];
+ data[index + 2] = 255 - data[index + 2];
+ }
+ ctx.putImageData(imageData, 0, 0);
+
+ const dataUrl = canvas.toDataURL("image/png");
+ await Assets.load(dataUrl);
+ const image = await loadImage(dataUrl);
+ const texture = Texture.from(dataUrl);
+
+ return {
+ texture,
+ image,
+ aspectRatio: asset.aspectRatio,
+ anchorX: asset.anchorX,
+ anchorY: asset.anchorY,
+ };
+}
+
function getNormalizedAnchor(
systemAsset: SystemCursorAsset | undefined,
fallbackAnchor: { x: number; y: number },
@@ -191,6 +405,25 @@ function getAvailableCursorKeys(): CursorAssetKey[] {
return loadedKeys.length > 0 ? loadedKeys : ["arrow"];
}
+function getCursorStyleAsset(style: Exclude) {
+ const asset = loadedCursorStyleAssets[style];
+ if (!asset) {
+ throw new Error(`Missing cursor style asset for ${style}`);
+ }
+
+ return asset;
+}
+
+function getStatefulCursorAsset(style: Extract, key: CursorAssetKey) {
+ const assetMap = style === "mono" ? loadedInvertedCursorAssets : loadedCursorAssets;
+ const asset = assetMap[key] ?? assetMap.arrow;
+ if (!asset) {
+ throw new Error(`Missing ${style} cursor asset for ${key}`);
+ }
+
+ return asset;
+}
+
export async function preloadCursorAssets() {
if (!cursorAssetsPromise) {
cursorAssetsPromise = (async () => {
@@ -284,6 +517,27 @@ export async function preloadCursorAssets() {
entries.filter(Boolean).map((entry) => entry!),
) as Partial>;
+ const invertedEntries = await Promise.all(
+ (Object.entries(loadedCursorAssets) as Array<[CursorAssetKey, LoadedCursorAsset]>).map(
+ async ([key, asset]) => [key, await createInvertedCursorAsset(asset)] as const,
+ ),
+ );
+
+ loadedInvertedCursorAssets = Object.fromEntries(
+ invertedEntries,
+ ) as Partial>;
+
+ const customStyleEntries = await Promise.all(
+ (["dot", "figma"] as const).map(async (style) => [
+ style,
+ await createCursorStyleAsset(style),
+ ] as const),
+ );
+
+ loadedCursorStyleAssets = Object.fromEntries(
+ customStyleEntries,
+ ) as Partial, LoadedCursorAsset>>;
+
if (!loadedCursorAssets.arrow) {
throw new Error("Failed to initialize the fallback arrow cursor asset");
}
@@ -580,6 +834,9 @@ function drawClickRing(
export class PixiCursorOverlay {
public readonly container: Container;
private clickRingGraphics: Graphics;
+ private customCursorShadowSprite: Sprite;
+ private customCursorShadowFilter: BlurFilter;
+ private customCursorSprite: Sprite;
private cursorShadowSprites: Partial>;
private cursorShadowFilters: Partial>;
private cursorSprites: Partial>;
@@ -599,6 +856,27 @@ export class PixiCursorOverlay {
this.container.label = "cursor-overlay";
this.clickRingGraphics = new Graphics();
+ const initialCustomAsset = getCursorStyleAsset("figma");
+ this.customCursorShadowSprite = new Sprite(initialCustomAsset.texture);
+ this.customCursorShadowSprite.anchor.set(
+ initialCustomAsset.anchorX,
+ initialCustomAsset.anchorY,
+ );
+ this.customCursorShadowSprite.visible = false;
+ this.customCursorShadowSprite.tint = CURSOR_SHADOW_COLOR;
+ this.customCursorShadowSprite.alpha = CURSOR_SHADOW_ALPHA;
+ this.customCursorShadowFilter = new BlurFilter();
+ this.customCursorShadowFilter.blur = CURSOR_SHADOW_BLUR;
+ this.customCursorShadowFilter.quality = 4;
+ this.customCursorShadowFilter.padding = CURSOR_SHADOW_PADDING;
+ this.customCursorShadowSprite.filters = [this.customCursorShadowFilter];
+
+ this.customCursorSprite = new Sprite(initialCustomAsset.texture);
+ this.customCursorSprite.anchor.set(
+ initialCustomAsset.anchorX,
+ initialCustomAsset.anchorY,
+ );
+ this.customCursorSprite.visible = false;
this.cursorShadowSprites = {};
this.cursorShadowFilters = {};
this.cursorSprites = {};
@@ -628,10 +906,13 @@ export class PixiCursorOverlay {
this.container.addChild(
this.clickRingGraphics,
+ this.customCursorShadowSprite,
...Object.values(this.cursorShadowSprites),
+ this.customCursorSprite,
...Object.values(this.cursorSprites),
);
this.setMotionBlur(this.config.motionBlur);
+ this.setStyle(this.config.style);
}
setDotRadius(dotRadius: number) {
@@ -666,6 +947,32 @@ export class PixiCursorOverlay {
this.config.sway = clamp(sway, 0, 2);
}
+ setStyle(style: CursorStyle) {
+ this.config.style = style;
+ if (style === "tahoe" || style === "mono") {
+ for (const key of getAvailableCursorKeys()) {
+ const asset = getStatefulCursorAsset(style, key);
+ const shadowSprite = this.cursorShadowSprites[key];
+ const sprite = this.cursorSprites[key];
+ shadowSprite?.anchor.set(asset.anchorX, asset.anchorY);
+ if (shadowSprite) {
+ shadowSprite.texture = asset.texture;
+ }
+ sprite?.anchor.set(asset.anchorX, asset.anchorY);
+ if (sprite) {
+ sprite.texture = asset.texture;
+ }
+ }
+ return;
+ }
+
+ const asset = getCursorStyleAsset(style);
+ this.customCursorShadowSprite.texture = asset.texture;
+ this.customCursorShadowSprite.anchor.set(asset.anchorX, asset.anchorY);
+ this.customCursorSprite.texture = asset.texture;
+ this.customCursorSprite.anchor.set(asset.anchorX, asset.anchorY);
+ }
+
update(
samples: CursorTelemetryPoint[],
timeMs: number,
@@ -730,13 +1037,6 @@ export class PixiCursorOverlay {
const h = this.config.dotRadius * getCursorViewportScale(viewport);
const { cursorType, clickBounceProgress, clickProgress } =
getCursorVisualState(samples, timeMs, this.config.clickBounceDuration);
- const spriteKey = (
- cursorType in this.cursorSprites ? cursorType : "arrow"
- ) as CursorAssetKey;
- const asset = getCursorAsset(spriteKey);
- const shadowSprite =
- this.cursorShadowSprites[spriteKey] ?? this.cursorShadowSprites.arrow!;
- const sprite = this.cursorSprites[spriteKey] ?? this.cursorSprites.arrow!;
const bounceScale = Math.max(
0.72,
1 -
@@ -754,34 +1054,76 @@ export class PixiCursorOverlay {
this.clickRingGraphics.clear();
drawClickRing(this.clickRingGraphics, px, py, h, clickProgress);
- for (const [key, currentShadowSprite] of Object.entries(
- this.cursorShadowSprites,
- ) as Array<[CursorAssetKey, Sprite]>) {
- currentShadowSprite.visible = key === spriteKey;
- }
+ if (this.config.style === "tahoe" || this.config.style === "mono") {
+ this.customCursorShadowSprite.visible = false;
+ this.customCursorSprite.visible = false;
- for (const [key, currentSprite] of Object.entries(
- this.cursorSprites,
- ) as Array<[CursorAssetKey, Sprite]>) {
- currentSprite.visible = key === spriteKey;
- }
+ const spriteKey = (
+ cursorType in this.cursorSprites ? cursorType : "arrow"
+ ) as CursorAssetKey;
+ const asset = getStatefulCursorAsset(this.config.style, spriteKey);
+ const shadowSprite =
+ this.cursorShadowSprites[spriteKey] ?? this.cursorShadowSprites.arrow!;
+ const sprite = this.cursorSprites[spriteKey] ?? this.cursorSprites.arrow!;
- if (shadowSprite) {
- shadowSprite.height = scaledH * bounceScale;
- shadowSprite.width = scaledH * bounceScale * asset.aspectRatio;
- shadowSprite.position.set(
- px + CURSOR_SHADOW_OFFSET_X,
- py + CURSOR_SHADOW_OFFSET_Y,
- );
- shadowSprite.rotation = swayRotation;
- }
+ for (const [key, currentShadowSprite] of Object.entries(
+ this.cursorShadowSprites,
+ ) as Array<[CursorAssetKey, Sprite]>) {
+ currentShadowSprite.visible = key === spriteKey;
+ }
- if (sprite) {
- sprite.alpha = this.config.dotAlpha;
- sprite.height = scaledH * bounceScale;
- sprite.width = scaledH * bounceScale * asset.aspectRatio;
- sprite.position.set(px, py);
- sprite.rotation = swayRotation;
+ for (const [key, currentSprite] of Object.entries(
+ this.cursorSprites,
+ ) as Array<[CursorAssetKey, Sprite]>) {
+ currentSprite.visible = key === spriteKey;
+ }
+
+ if (shadowSprite) {
+ shadowSprite.height = scaledH * bounceScale;
+ shadowSprite.width = scaledH * bounceScale * asset.aspectRatio;
+ shadowSprite.position.set(
+ px + CURSOR_SHADOW_OFFSET_X,
+ py + CURSOR_SHADOW_OFFSET_Y,
+ );
+ shadowSprite.rotation = swayRotation;
+ }
+
+ if (sprite) {
+ sprite.alpha = this.config.dotAlpha;
+ sprite.height = scaledH * bounceScale;
+ sprite.width = scaledH * bounceScale * asset.aspectRatio;
+ sprite.position.set(px, py);
+ sprite.rotation = swayRotation;
+ }
+ } else {
+ for (const currentShadowSprite of Object.values(this.cursorShadowSprites)) {
+ currentShadowSprite.visible = false;
+ }
+
+ for (const currentSprite of Object.values(this.cursorSprites)) {
+ currentSprite.visible = false;
+ }
+
+ const asset = getCursorStyleAsset(this.config.style);
+ const showSeparateShadow = this.config.style !== "figma";
+ this.customCursorShadowSprite.visible = showSeparateShadow;
+ if (showSeparateShadow) {
+ this.customCursorShadowSprite.height = scaledH * bounceScale;
+ this.customCursorShadowSprite.width =
+ scaledH * bounceScale * asset.aspectRatio;
+ this.customCursorShadowSprite.position.set(
+ px + CURSOR_SHADOW_OFFSET_X,
+ py + CURSOR_SHADOW_OFFSET_Y,
+ );
+ this.customCursorShadowSprite.rotation = swayRotation;
+ }
+
+ this.customCursorSprite.visible = true;
+ this.customCursorSprite.alpha = this.config.dotAlpha;
+ this.customCursorSprite.height = scaledH * bounceScale;
+ this.customCursorSprite.width = scaledH * bounceScale * asset.aspectRatio;
+ this.customCursorSprite.position.set(px, py);
+ this.customCursorSprite.rotation = swayRotation;
}
this.applyCursorMotionBlur(px, py, timeMs, shouldFreezeCursorMotion);
@@ -868,10 +1210,14 @@ export class PixiCursorOverlay {
shadowSprite.visible = false;
shadowSprite.scale.set(1);
}
+ this.customCursorShadowSprite.visible = false;
+ this.customCursorShadowSprite.scale.set(1);
for (const sprite of Object.values(this.cursorSprites)) {
sprite.visible = false;
sprite.scale.set(1);
}
+ this.customCursorSprite.visible = false;
+ this.customCursorSprite.scale.set(1);
this.container.visible = false;
this.lastRenderedPoint = null;
this.lastRenderedTimeMs = null;
@@ -884,6 +1230,7 @@ export class PixiCursorOverlay {
destroy(): void {
this.clickRingGraphics.destroy();
+ this.customCursorShadowFilter.destroy();
for (const shadowFilter of Object.values(this.cursorShadowFilters)) {
shadowFilter.destroy();
}
@@ -922,17 +1269,24 @@ export function drawCursorOnCanvas(
timeMs,
config.clickBounceDuration,
);
- const spriteKey = (
- cursorType && loadedCursorAssets[cursorType] ? cursorType : "arrow"
- ) as CursorAssetKey;
- const asset = getCursorAsset(spriteKey);
+ const asset =
+ config.style === "tahoe" || config.style === "mono"
+ ? getStatefulCursorAsset(
+ config.style,
+ (
+ cursorType && loadedCursorAssets[cursorType] ? cursorType : "arrow"
+ ) as CursorAssetKey,
+ )
+ : getCursorStyleAsset(config.style);
const bounceScale = Math.max(
0.72,
1 - Math.sin(clickBounceProgress * Math.PI) * (0.08 * config.clickBounce),
);
ctx.save();
- ctx.filter = CURSOR_SVG_DROP_SHADOW_FILTER;
+ if (config.style !== "figma") {
+ ctx.filter = CURSOR_SVG_DROP_SHADOW_FILTER;
+ }
const drawHeight = h * bounceScale;
const drawWidth = drawHeight * asset.aspectRatio;