From 5523a7de4f9b80b9b8ca03933560f010ece62d7f Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Mon, 23 Mar 2026 19:22:40 +1100 Subject: [PATCH] fix(cursor): align Windows bundled cursor anchors --- .../videoPlayback/cursorRenderer.ts | 2052 ++++++++--------- .../videoPlayback/uploadedCursorAssets.ts | 135 +- 2 files changed, 1058 insertions(+), 1129 deletions(-) diff --git a/src/components/video-editor/videoPlayback/cursorRenderer.ts b/src/components/video-editor/videoPlayback/cursorRenderer.ts index 2cf00f7c..afd04a8e 100644 --- a/src/components/video-editor/videoPlayback/cursorRenderer.ts +++ b/src/components/video-editor/videoPlayback/cursorRenderer.ts @@ -1,82 +1,69 @@ -import { - Assets, - BlurFilter, - Container, - Graphics, - Sprite, - Texture, -} from "pixi.js"; +import { Assets, BlurFilter, Container, Graphics, Sprite, Texture } from "pixi.js"; import { MotionBlurFilter } from "pixi-filters/motion-blur"; import minimalCursorUrl from "../../../../Minimal Cursor.svg"; import { - DEFAULT_CURSOR_CLICK_BOUNCE_DURATION, - DEFAULT_CURSOR_STYLE, - type CursorStyle, - type CursorTelemetryPoint, + type CursorStyle, + type CursorTelemetryPoint, + DEFAULT_CURSOR_CLICK_BOUNCE_DURATION, + DEFAULT_CURSOR_STYLE, } from "../types"; -import { - projectCursorPositionToViewport, - type CursorViewportRect, -} from "./cursorViewport"; -import { - createSpringState, - getCursorSpringConfig, - resetSpringState, - stepSpringValue, -} from "./motionSmoothing"; import { computeCursorSwayRotation } from "./cursorSway"; +import { type CursorViewportRect, projectCursorPositionToViewport } from "./cursorViewport"; import { - uploadedCursorAssets, - UPLOADED_CURSOR_SAMPLE_SIZE, -} from "./uploadedCursorAssets"; + createSpringState, + getCursorSpringConfig, + resetSpringState, + stepSpringValue, +} from "./motionSmoothing"; +import { UPLOADED_CURSOR_SAMPLE_SIZE, uploadedCursorAssets } from "./uploadedCursorAssets"; type CursorAssetKey = NonNullable; type LoadedCursorAsset = { - texture: Texture; - image: HTMLImageElement; - aspectRatio: number; - anchorX: number; - anchorY: number; + texture: Texture; + image: HTMLImageElement; + aspectRatio: number; + anchorX: number; + anchorY: number; }; /** * Configuration for cursor rendering. */ export interface CursorRenderConfig { - /** Base cursor height in pixels (at reference width of 1920px) */ - dotRadius: number; - /** Cursor fill color (hex number for PixiJS) */ - dotColor: number; - /** Cursor opacity (0–1) */ - dotAlpha: number; - /** Unused, kept for interface compatibility */ - trailLength: number; - /** Smoothing factor for cursor interpolation (0–1, lower = smoother/slower) */ - smoothingFactor: number; - /** Directional cursor motion blur amount. */ - motionBlur: number; - /** Click bounce multiplier. */ - clickBounce: number; - /** Click bounce duration in milliseconds. */ - clickBounceDuration: number; - /** Cursor sway multiplier. */ - sway: number; - /** Cursor visual style. */ - style: CursorStyle; + /** Base cursor height in pixels (at reference width of 1920px) */ + dotRadius: number; + /** Cursor fill color (hex number for PixiJS) */ + dotColor: number; + /** Cursor opacity (0–1) */ + dotAlpha: number; + /** Unused, kept for interface compatibility */ + trailLength: number; + /** Smoothing factor for cursor interpolation (0–1, lower = smoother/slower) */ + smoothingFactor: number; + /** Directional cursor motion blur amount. */ + motionBlur: number; + /** Click bounce multiplier. */ + clickBounce: number; + /** Click bounce duration in milliseconds. */ + clickBounceDuration: number; + /** Cursor sway multiplier. */ + sway: number; + /** Cursor visual style. */ + style: CursorStyle; } export const DEFAULT_CURSOR_CONFIG: CursorRenderConfig = { - dotRadius: 28, - dotColor: 0xffffff, - dotAlpha: 0.95, - trailLength: 0, - smoothingFactor: 0.18, - motionBlur: 0, - clickBounce: 1, - clickBounceDuration: DEFAULT_CURSOR_CLICK_BOUNCE_DURATION, - sway: 0, - style: DEFAULT_CURSOR_STYLE, + dotRadius: 28, + dotColor: 0xffffff, + dotAlpha: 0.95, + trailLength: 0, + smoothingFactor: 0.18, + motionBlur: 0, + clickBounce: 1, + clickBounceDuration: DEFAULT_CURSOR_CLICK_BOUNCE_DURATION, + sway: 0, + style: DEFAULT_CURSOR_STYLE, }; const REFERENCE_WIDTH = 1920; @@ -86,8 +73,7 @@ const CURSOR_MOTION_BLUR_BASE_MULTIPLIER = 0.08; const CURSOR_TIME_DISCONTINUITY_MS = 100; const CURSOR_SWAY_SMOOTHING_MULTIPLIER = 0.7; const CURSOR_SWAY_SMOOTHING_OFFSET = 0.18; -const CURSOR_SVG_DROP_SHADOW_FILTER = - "drop-shadow(0px 2px 3px rgba(0, 0, 0, 0.35))"; +const CURSOR_SVG_DROP_SHADOW_FILTER = "drop-shadow(0px 2px 3px rgba(0, 0, 0, 0.35))"; const CURSOR_SHADOW_COLOR = 0x000000; const CURSOR_SHADOW_ALPHA = 0.35; const CURSOR_SHADOW_OFFSET_X = 0; @@ -98,19 +84,17 @@ const CURSOR_SHADOW_PADDING = 12; let cursorAssetsPromise: Promise | null = null; let loadedCursorAssets: Partial> = {}; let loadedInvertedCursorAssets: Partial> = {}; -let loadedCursorStyleAssets: Partial< - Record, LoadedCursorAsset> -> = {}; +let loadedCursorStyleAssets: Partial, LoadedCursorAsset>> = {}; const SUPPORTED_CURSOR_KEYS: CursorAssetKey[] = [ - "arrow", - "text", - "pointer", - "crosshair", - "open-hand", - "closed-hand", - "resize-ew", - "resize-ns", - "not-allowed", + "arrow", + "text", + "pointer", + "crosshair", + "open-hand", + "closed-hand", + "resize-ew", + "resize-ns", + "not-allowed", ]; const CUSTOM_CURSOR_ARROW_WIDTH = 150; @@ -118,230 +102,248 @@ 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(); +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, + 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); + 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, - }; - } + 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; + 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(); - } + 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); + 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, - }; + 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(); - image.onload = () => resolve(image); - image.onerror = () => - reject( - new Error(`Failed to load cursor image: ${dataUrl.slice(0, 128)}`), - ); - image.src = dataUrl; - }); + return new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => resolve(image); + image.onerror = () => + reject(new Error(`Failed to load cursor image: ${dataUrl.slice(0, 128)}`)); + image.src = dataUrl; + }); } function clamp(value: number, min: number, max: number) { - return Math.min(max, Math.max(min, value)); + 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, - }; - } +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; + 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; - } + 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); - } - } + 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, - }; - } + 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); + 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, - }; + 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; - } +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); + 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); + 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, - }; + return { + texture, + image, + aspectRatio: asset.aspectRatio, + anchorX: asset.anchorX, + anchorY: asset.anchorY, + }; } function getNormalizedAnchor( - systemAsset: SystemCursorAsset | undefined, - fallbackAnchor: { x: number; y: number }, + systemAsset: SystemCursorAsset | undefined, + fallbackAnchor: { x: number; y: number }, ) { - if (!systemAsset || systemAsset.width <= 0 || systemAsset.height <= 0) { - return fallbackAnchor; - } + if (!systemAsset || systemAsset.width <= 0 || systemAsset.height <= 0) { + return fallbackAnchor; + } - return { - x: clamp(systemAsset.hotspotX / systemAsset.width, 0, 1), - y: clamp(systemAsset.hotspotY / systemAsset.height, 0, 1), - }; + return { + x: clamp(systemAsset.hotspotX / systemAsset.width, 0, 1), + y: clamp(systemAsset.hotspotY / systemAsset.height, 0, 1), + }; +} + +function getPlatformCursorAnchor(uploadedAsset: { + fallbackAnchor: { x: number; y: number }; + platformAnchors?: Partial>; +}) { + if (typeof navigator === "undefined") { + return uploadedAsset.fallbackAnchor; + } + + const platform = navigator.platform.toLowerCase(); + if (platform.includes("win")) { + return uploadedAsset.platformAnchors?.win32 ?? uploadedAsset.fallbackAnchor; + } + + if (platform.includes("mac")) { + return uploadedAsset.platformAnchors?.darwin ?? uploadedAsset.fallbackAnchor; + } + + if (platform.includes("linux")) { + return uploadedAsset.platformAnchors?.linux ?? uploadedAsset.fallbackAnchor; + } + + return uploadedAsset.fallbackAnchor; } /** @@ -351,200 +353,187 @@ function getNormalizedAnchor( * match the 1024-sample coordinate space used by the trim measurements. */ async function rasterizeAndCropSvg( - url: string, - sampleSize: number, - trimX: number, - trimY: number, - trimWidth: number, - trimHeight: number, + url: string, + sampleSize: number, + trimX: number, + trimY: number, + trimWidth: number, + trimHeight: number, ): Promise<{ dataUrl: string; width: number; height: number }> { - const img = await loadImage(url); + const img = await loadImage(url); - // Draw at full sample size - const srcCanvas = document.createElement("canvas"); - srcCanvas.width = sampleSize; - srcCanvas.height = sampleSize; - const srcCtx = srcCanvas.getContext("2d")!; - srcCtx.drawImage(img, 0, 0, sampleSize, sampleSize); + // Draw at full sample size + const srcCanvas = document.createElement("canvas"); + srcCanvas.width = sampleSize; + srcCanvas.height = sampleSize; + const srcCtx = srcCanvas.getContext("2d")!; + srcCtx.drawImage(img, 0, 0, sampleSize, sampleSize); - // Crop to trim bounds - const dstCanvas = document.createElement("canvas"); - dstCanvas.width = trimWidth; - dstCanvas.height = trimHeight; - const dstCtx = dstCanvas.getContext("2d")!; - dstCtx.drawImage( - srcCanvas, - trimX, - trimY, - trimWidth, - trimHeight, - 0, - 0, - trimWidth, - trimHeight, - ); + // Crop to trim bounds + const dstCanvas = document.createElement("canvas"); + dstCanvas.width = trimWidth; + dstCanvas.height = trimHeight; + const dstCtx = dstCanvas.getContext("2d")!; + dstCtx.drawImage(srcCanvas, trimX, trimY, trimWidth, trimHeight, 0, 0, trimWidth, trimHeight); - return { - dataUrl: dstCanvas.toDataURL("image/png"), - width: dstCanvas.width, - height: dstCanvas.height, - }; + return { + dataUrl: dstCanvas.toDataURL("image/png"), + width: dstCanvas.width, + height: dstCanvas.height, + }; } function getCursorAsset(key: CursorAssetKey): LoadedCursorAsset { - const asset = loadedCursorAssets[key]; - if (!asset) { - throw new Error(`Missing cursor asset for ${key}`); - } + const asset = loadedCursorAssets[key]; + if (!asset) { + throw new Error(`Missing cursor asset for ${key}`); + } - return asset; + return asset; } function getAvailableCursorKeys(): CursorAssetKey[] { - const loadedKeys = Object.keys(loadedCursorAssets) as CursorAssetKey[]; - return loadedKeys.length > 0 ? loadedKeys : ["arrow"]; + const loadedKeys = Object.keys(loadedCursorAssets) as 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}`); - } + const asset = loadedCursorStyleAssets[style]; + if (!asset) { + throw new Error(`Missing cursor style asset for ${style}`); + } - return asset; + 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}`); - } +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; + return asset; } export async function preloadCursorAssets() { - if (!cursorAssetsPromise) { - cursorAssetsPromise = (async () => { - const isLinux = typeof navigator !== 'undefined' && /linux/i.test(navigator.platform); - let systemCursors: Record = {}; + if (!cursorAssetsPromise) { + cursorAssetsPromise = (async () => { + const isLinux = typeof navigator !== "undefined" && /linux/i.test(navigator.platform); + let systemCursors: Record = {}; - try { - const result = await window.electronAPI.getSystemCursorAssets(); - if (result.success && result.cursors) { - systemCursors = result.cursors; - } - } catch (error) { - console.warn( - "[CursorRenderer] Failed to fetch system cursor assets:", - error, - ); - } + try { + const result = await window.electronAPI.getSystemCursorAssets(); + if (result.success && result.cursors) { + systemCursors = result.cursors; + } + } catch (error) { + console.warn("[CursorRenderer] Failed to fetch system cursor assets:", error); + } - const entries = await Promise.all( - SUPPORTED_CURSOR_KEYS.map(async (key) => { - const systemAsset = systemCursors[key]; - const uploadedAsset = uploadedCursorAssets[key]; - const assetUrl = isLinux - ? uploadedAsset?.url - : uploadedAsset?.url ?? systemAsset?.dataUrl; + const entries = await Promise.all( + SUPPORTED_CURSOR_KEYS.map(async (key) => { + const systemAsset = systemCursors[key]; + const uploadedAsset = uploadedCursorAssets[key]; + const assetUrl = isLinux + ? uploadedAsset?.url + : (uploadedAsset?.url ?? systemAsset?.dataUrl); - if (!assetUrl) { - console.warn(`[CursorRenderer] No cursor image for: ${key}`); - return null; - } + if (!assetUrl) { + console.warn(`[CursorRenderer] No cursor image for: ${key}`); + return null; + } - try { - let finalUrl: string; - let width: number; - let height: number; - let normalizedAnchor: { x: number; y: number }; + try { + let finalUrl: string; + let width: number; + let height: number; + let normalizedAnchor: { x: number; y: number }; - if (uploadedAsset) { - const { trim, fallbackAnchor } = uploadedAsset; - const rasterized = await rasterizeAndCropSvg( - assetUrl, - UPLOADED_CURSOR_SAMPLE_SIZE, - trim.x, - trim.y, - trim.width, - trim.height, - ); - finalUrl = rasterized.dataUrl; - width = rasterized.width; - height = rasterized.height; - normalizedAnchor = { - x: clamp((fallbackAnchor.x * trim.width) / width, 0, 1), - y: clamp((fallbackAnchor.y * trim.height) / height, 0, 1), - }; - } else { - finalUrl = assetUrl; - const img = await loadImage(finalUrl); - width = img.naturalWidth; - height = img.naturalHeight; - normalizedAnchor = getNormalizedAnchor(systemAsset, { - x: 0, - y: 0, - }); - } + if (uploadedAsset) { + const { trim } = uploadedAsset; + const fallbackAnchor = getPlatformCursorAnchor(uploadedAsset); + const rasterized = await rasterizeAndCropSvg( + assetUrl, + UPLOADED_CURSOR_SAMPLE_SIZE, + trim.x, + trim.y, + trim.width, + trim.height, + ); + finalUrl = rasterized.dataUrl; + width = rasterized.width; + height = rasterized.height; + normalizedAnchor = { + x: clamp((fallbackAnchor.x * trim.width) / width, 0, 1), + y: clamp((fallbackAnchor.y * trim.height) / height, 0, 1), + }; + } else { + finalUrl = assetUrl; + const img = await loadImage(finalUrl); + width = img.naturalWidth; + height = img.naturalHeight; + normalizedAnchor = getNormalizedAnchor(systemAsset, { + x: 0, + y: 0, + }); + } - await Assets.load(finalUrl); - const image = await loadImage(finalUrl); - const texture = Texture.from(finalUrl); + await Assets.load(finalUrl); + const image = await loadImage(finalUrl); + const texture = Texture.from(finalUrl); - return [ - key, - { - texture, - image, - aspectRatio: height > 0 ? width / height : 1, - anchorX: normalizedAnchor.x, - anchorY: normalizedAnchor.y, - } satisfies LoadedCursorAsset, - ] as const; - } catch (error) { - console.warn( - `[CursorRenderer] Failed to load cursor image for: ${key}`, - error, - ); - return null; - } - }), - ); + return [ + key, + { + texture, + image, + aspectRatio: height > 0 ? width / height : 1, + anchorX: normalizedAnchor.x, + anchorY: normalizedAnchor.y, + } satisfies LoadedCursorAsset, + ] as const; + } catch (error) { + console.warn(`[CursorRenderer] Failed to load cursor image for: ${key}`, error); + return null; + } + }), + ); - loadedCursorAssets = Object.fromEntries( - entries.filter(Boolean).map((entry) => entry!), - ) as Partial>; + loadedCursorAssets = Object.fromEntries( + 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, - ), - ); + 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>; + loadedInvertedCursorAssets = Object.fromEntries(invertedEntries) as Partial< + Record + >; - const customStyleEntries = await Promise.all( - (["dot", "figma"] as const).map(async (style) => [ - style, - await createCursorStyleAsset(style), - ] as const), - ); + 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>>; + loadedCursorStyleAssets = Object.fromEntries(customStyleEntries) as Partial< + Record, LoadedCursorAsset> + >; - if (!loadedCursorAssets.arrow) { - throw new Error("Failed to initialize the fallback arrow cursor asset"); - } - })(); - } + if (!loadedCursorAssets.arrow) { + throw new Error("Failed to initialize the fallback arrow cursor asset"); + } + })(); + } - return cursorAssetsPromise; + return cursorAssetsPromise; } /** @@ -552,754 +541,681 @@ export async function preloadCursorAssets() { * Uses linear interpolation between the two nearest samples. */ export function interpolateCursorPosition( - samples: CursorTelemetryPoint[], - timeMs: number, + samples: CursorTelemetryPoint[], + timeMs: number, ): { cx: number; cy: number } | null { - if (!samples || samples.length === 0) return null; + if (!samples || samples.length === 0) return null; - if (timeMs <= samples[0].timeMs) { - return { cx: samples[0].cx, cy: samples[0].cy }; - } + if (timeMs <= samples[0].timeMs) { + return { cx: samples[0].cx, cy: samples[0].cy }; + } - if (timeMs >= samples[samples.length - 1].timeMs) { - return { - cx: samples[samples.length - 1].cx, - cy: samples[samples.length - 1].cy, - }; - } + if (timeMs >= samples[samples.length - 1].timeMs) { + return { + cx: samples[samples.length - 1].cx, + cy: samples[samples.length - 1].cy, + }; + } - let lo = 0; - let hi = samples.length - 1; - while (lo < hi - 1) { - const mid = (lo + hi) >> 1; - if (samples[mid].timeMs <= timeMs) { - lo = mid; - } else { - hi = mid; - } - } + let lo = 0; + let hi = samples.length - 1; + while (lo < hi - 1) { + const mid = (lo + hi) >> 1; + if (samples[mid].timeMs <= timeMs) { + lo = mid; + } else { + hi = mid; + } + } - const a = samples[lo]; - const b = samples[hi]; - const span = b.timeMs - a.timeMs; - if (span <= 0) return { cx: a.cx, cy: a.cy }; + const a = samples[lo]; + const b = samples[hi]; + const span = b.timeMs - a.timeMs; + if (span <= 0) return { cx: a.cx, cy: a.cy }; - const t = (timeMs - a.timeMs) / span; - return { - cx: a.cx + (b.cx - a.cx) * t, - cy: a.cy + (b.cy - a.cy) * t, - }; + const t = (timeMs - a.timeMs) / span; + return { + cx: a.cx + (b.cx - a.cx) * t, + cy: a.cy + (b.cy - a.cy) * t, + }; } function findLatestSample(samples: CursorTelemetryPoint[], timeMs: number) { - if (samples.length === 0) return null; + if (samples.length === 0) return null; - let lo = 0; - let hi = samples.length - 1; - while (lo < hi) { - const mid = Math.ceil((lo + hi) / 2); - if (samples[mid].timeMs <= timeMs) { - lo = mid; - } else { - hi = mid - 1; - } - } + let lo = 0; + let hi = samples.length - 1; + while (lo < hi) { + const mid = Math.ceil((lo + hi) / 2); + if (samples[mid].timeMs <= timeMs) { + lo = mid; + } else { + hi = mid - 1; + } + } - return samples[lo]?.timeMs <= timeMs ? samples[lo] : null; + return samples[lo]?.timeMs <= timeMs ? samples[lo] : null; } -function findLatestInteractionSample( - samples: CursorTelemetryPoint[], - timeMs: number, -) { - for (let index = samples.length - 1; index >= 0; index -= 1) { - const sample = samples[index]; - if (sample.timeMs > timeMs) { - continue; - } +function findLatestInteractionSample(samples: CursorTelemetryPoint[], timeMs: number) { + for (let index = samples.length - 1; index >= 0; index -= 1) { + const sample = samples[index]; + if (sample.timeMs > timeMs) { + continue; + } - if ( - sample.interactionType === "click" || - sample.interactionType === "double-click" || - sample.interactionType === "right-click" || - sample.interactionType === "middle-click" - ) { - return sample; - } - } + if ( + sample.interactionType === "click" || + sample.interactionType === "double-click" || + sample.interactionType === "right-click" || + sample.interactionType === "middle-click" + ) { + return sample; + } + } - return null; + return null; } -function findLatestStableCursorType( - samples: CursorTelemetryPoint[], - timeMs: number, -) { - // Binary search to find position at timeMs, then scan backwards - let lo = 0; - let hi = samples.length - 1; - while (lo < hi) { - const mid = Math.ceil((lo + hi) / 2); - if (samples[mid].timeMs <= timeMs) { - lo = mid; - } else { - hi = mid - 1; - } - } +function findLatestStableCursorType(samples: CursorTelemetryPoint[], timeMs: number) { + // Binary search to find position at timeMs, then scan backwards + let lo = 0; + let hi = samples.length - 1; + while (lo < hi) { + const mid = Math.ceil((lo + hi) / 2); + if (samples[mid].timeMs <= timeMs) { + lo = mid; + } else { + hi = mid - 1; + } + } - // Scan backwards from the position to find a sample with cursorType - // Skip click events only (not mouseup) to avoid transient re-type during clicks - for (let index = lo; index >= 0; index -= 1) { - const sample = samples[index]; - if (sample.timeMs > timeMs) { - continue; - } + // Scan backwards from the position to find a sample with cursorType + // Skip click events only (not mouseup) to avoid transient re-type during clicks + for (let index = lo; index >= 0; index -= 1) { + const sample = samples[index]; + if (sample.timeMs > timeMs) { + continue; + } - if (!sample.cursorType) { - continue; - } + if (!sample.cursorType) { + continue; + } - if ( - sample.interactionType === "click" || - sample.interactionType === "double-click" || - sample.interactionType === "right-click" || - sample.interactionType === "middle-click" - ) { - continue; - } + if ( + sample.interactionType === "click" || + sample.interactionType === "double-click" || + sample.interactionType === "right-click" || + sample.interactionType === "middle-click" + ) { + continue; + } - return sample.cursorType; - } + return sample.cursorType; + } - return findLatestSample(samples, timeMs)?.cursorType ?? "arrow"; + return findLatestSample(samples, timeMs)?.cursorType ?? "arrow"; } function getCursorViewportScale(viewport: CursorViewportRect) { - return Math.max(MIN_CURSOR_VIEWPORT_SCALE, viewport.width / REFERENCE_WIDTH); + return Math.max(MIN_CURSOR_VIEWPORT_SCALE, viewport.width / REFERENCE_WIDTH); } function getCursorSwaySpringConfig(smoothingFactor: number) { - const baseConfig = getCursorSpringConfig( - Math.min( - 2, - Math.max( - 0.15, - smoothingFactor * CURSOR_SWAY_SMOOTHING_MULTIPLIER + - CURSOR_SWAY_SMOOTHING_OFFSET, - ), - ), - ); + const baseConfig = getCursorSpringConfig( + Math.min( + 2, + Math.max( + 0.15, + smoothingFactor * CURSOR_SWAY_SMOOTHING_MULTIPLIER + CURSOR_SWAY_SMOOTHING_OFFSET, + ), + ), + ); - return { - ...baseConfig, - damping: baseConfig.damping * 0.9, - mass: Math.max(0.55, baseConfig.mass * 0.8), - restDelta: 0.0005, - restSpeed: 0.02, - }; + return { + ...baseConfig, + damping: baseConfig.damping * 0.9, + mass: Math.max(0.55, baseConfig.mass * 0.8), + restDelta: 0.0005, + restSpeed: 0.02, + }; } function getCursorVisualState( - samples: CursorTelemetryPoint[], - timeMs: number, - clickBounceDuration: number, + samples: CursorTelemetryPoint[], + timeMs: number, + clickBounceDuration: number, ) { - const latestClick = findLatestInteractionSample(samples, timeMs); - const interactionType = latestClick?.interactionType; - const ageMs = latestClick - ? Math.max(0, timeMs - latestClick.timeMs) - : Number.POSITIVE_INFINITY; - const isClickEvent = - interactionType === "click" || - interactionType === "double-click" || - interactionType === "right-click" || - interactionType === "middle-click"; - const clickBounceProgress = - latestClick && isClickEvent && ageMs <= clickBounceDuration - ? 1 - ageMs / clickBounceDuration - : 0; + const latestClick = findLatestInteractionSample(samples, timeMs); + const interactionType = latestClick?.interactionType; + const ageMs = latestClick ? Math.max(0, timeMs - latestClick.timeMs) : Number.POSITIVE_INFINITY; + const isClickEvent = + interactionType === "click" || + interactionType === "double-click" || + interactionType === "right-click" || + interactionType === "middle-click"; + const clickBounceProgress = + latestClick && isClickEvent && ageMs <= clickBounceDuration + ? 1 - ageMs / clickBounceDuration + : 0; - return { - cursorType: findLatestStableCursorType(samples, timeMs), - clickBounceProgress, - clickProgress: - latestClick && isClickEvent && ageMs <= CLICK_RING_FADE_MS - ? 1 - ageMs / CLICK_RING_FADE_MS - : 0, - }; + return { + cursorType: findLatestStableCursorType(samples, timeMs), + clickBounceProgress, + clickProgress: + latestClick && isClickEvent && ageMs <= CLICK_RING_FADE_MS + ? 1 - ageMs / CLICK_RING_FADE_MS + : 0, + }; } /** * Manages a smoothed cursor state that chases the interpolated target. */ export class SmoothedCursorState { - public x = 0.5; - public y = 0.5; - public trail: Array<{ x: number; y: number }> = []; - private smoothingFactor: number; - private trailLength: number; - private initialized = false; - private lastTimeMs: number | null = null; - private xSpring = createSpringState(0.5); - private ySpring = createSpringState(0.5); + public x = 0.5; + public y = 0.5; + public trail: Array<{ x: number; y: number }> = []; + private smoothingFactor: number; + private trailLength: number; + private initialized = false; + private lastTimeMs: number | null = null; + private xSpring = createSpringState(0.5); + private ySpring = createSpringState(0.5); - constructor( - config: Pick, - ) { - this.smoothingFactor = config.smoothingFactor; - this.trailLength = config.trailLength; - } + constructor(config: Pick) { + this.smoothingFactor = config.smoothingFactor; + this.trailLength = config.trailLength; + } - update(targetX: number, targetY: number, timeMs: number): void { - if (!this.initialized) { - this.x = targetX; - this.y = targetY; - this.initialized = true; - this.lastTimeMs = timeMs; - this.xSpring.value = targetX; - this.ySpring.value = targetY; - this.xSpring.velocity = 0; - this.ySpring.velocity = 0; - this.xSpring.initialized = true; - this.ySpring.initialized = true; - this.trail = []; - return; - } + update(targetX: number, targetY: number, timeMs: number): void { + if (!this.initialized) { + this.x = targetX; + this.y = targetY; + this.initialized = true; + this.lastTimeMs = timeMs; + this.xSpring.value = targetX; + this.ySpring.value = targetY; + this.xSpring.velocity = 0; + this.ySpring.velocity = 0; + this.xSpring.initialized = true; + this.ySpring.initialized = true; + this.trail = []; + return; + } - if ( - this.smoothingFactor <= 0 || - (this.lastTimeMs !== null && timeMs < this.lastTimeMs) - ) { - this.snapTo(targetX, targetY, timeMs); - return; - } + if (this.smoothingFactor <= 0 || (this.lastTimeMs !== null && timeMs < this.lastTimeMs)) { + this.snapTo(targetX, targetY, timeMs); + return; + } - this.trail.unshift({ x: this.x, y: this.y }); - if (this.trail.length > this.trailLength) { - this.trail.length = this.trailLength; - } + this.trail.unshift({ x: this.x, y: this.y }); + if (this.trail.length > this.trailLength) { + this.trail.length = this.trailLength; + } - const deltaMs = - this.lastTimeMs === null - ? 1000 / 60 - : Math.max(1, timeMs - this.lastTimeMs); - this.lastTimeMs = timeMs; + const deltaMs = this.lastTimeMs === null ? 1000 / 60 : Math.max(1, timeMs - this.lastTimeMs); + this.lastTimeMs = timeMs; - const springConfig = getCursorSpringConfig(this.smoothingFactor); - this.x = stepSpringValue(this.xSpring, targetX, deltaMs, springConfig); - this.y = stepSpringValue(this.ySpring, targetY, deltaMs, springConfig); - } + const springConfig = getCursorSpringConfig(this.smoothingFactor); + this.x = stepSpringValue(this.xSpring, targetX, deltaMs, springConfig); + this.y = stepSpringValue(this.ySpring, targetY, deltaMs, springConfig); + } - setSmoothingFactor(smoothingFactor: number): void { - this.smoothingFactor = smoothingFactor; - } + setSmoothingFactor(smoothingFactor: number): void { + this.smoothingFactor = smoothingFactor; + } - snapTo(targetX: number, targetY: number, timeMs: number): void { - this.x = targetX; - this.y = targetY; - this.initialized = true; - this.lastTimeMs = timeMs; - this.xSpring.value = targetX; - this.ySpring.value = targetY; - this.xSpring.velocity = 0; - this.ySpring.velocity = 0; - this.xSpring.initialized = true; - this.ySpring.initialized = true; - this.trail = []; - } + snapTo(targetX: number, targetY: number, timeMs: number): void { + this.x = targetX; + this.y = targetY; + this.initialized = true; + this.lastTimeMs = timeMs; + this.xSpring.value = targetX; + this.ySpring.value = targetY; + this.xSpring.velocity = 0; + this.ySpring.velocity = 0; + this.xSpring.initialized = true; + this.ySpring.initialized = true; + this.trail = []; + } - reset(): void { - this.initialized = false; - this.lastTimeMs = null; - this.trail = []; - resetSpringState(this.xSpring, this.x); - resetSpringState(this.ySpring, this.y); - } + reset(): void { + this.initialized = false; + this.lastTimeMs = null; + this.trail = []; + resetSpringState(this.xSpring, this.x); + resetSpringState(this.ySpring, this.y); + } } -function drawClickRing( - graphics: Graphics, - px: number, - py: number, - h: number, - progress: number, -) { - void graphics; - void px; - void py; - void h; - void progress; +function drawClickRing(graphics: Graphics, px: number, py: number, h: number, progress: number) { + void graphics; + void px; + void py; + void h; + void progress; } 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>; - private cursorMotionBlurFilter: MotionBlurFilter; - private state: SmoothedCursorState; - private config: CursorRenderConfig; - private lastRenderedPoint: { px: number; py: number } | null = null; - private lastRenderedTimeMs: number | null = null; - private swayRotation = 0; - private swaySpring = createSpringState(0); + 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>; + private cursorMotionBlurFilter: MotionBlurFilter; + private state: SmoothedCursorState; + private config: CursorRenderConfig; + private lastRenderedPoint: { px: number; py: number } | null = null; + private lastRenderedTimeMs: number | null = null; + private swayRotation = 0; + private swaySpring = createSpringState(0); - constructor(config: Partial = {}) { - this.config = { ...DEFAULT_CURSOR_CONFIG, ...config }; - this.state = new SmoothedCursorState(this.config); + constructor(config: Partial = {}) { + this.config = { ...DEFAULT_CURSOR_CONFIG, ...config }; + this.state = new SmoothedCursorState(this.config); - this.container = new Container(); - this.container.label = "cursor-overlay"; + this.container = new Container(); + 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.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 = {}; - for (const key of getAvailableCursorKeys()) { - const asset = getCursorAsset(key); - const shadowSprite = new Sprite(asset.texture); - shadowSprite.anchor.set(asset.anchorX, asset.anchorY); - shadowSprite.visible = false; - shadowSprite.tint = CURSOR_SHADOW_COLOR; - shadowSprite.alpha = CURSOR_SHADOW_ALPHA; - const shadowFilter = new BlurFilter(); - shadowFilter.blur = CURSOR_SHADOW_BLUR; - shadowFilter.quality = 4; - shadowFilter.padding = CURSOR_SHADOW_PADDING; - shadowSprite.filters = [shadowFilter]; - this.cursorShadowSprites[key] = shadowSprite; - this.cursorShadowFilters[key] = shadowFilter; + this.customCursorSprite = new Sprite(initialCustomAsset.texture); + this.customCursorSprite.anchor.set(initialCustomAsset.anchorX, initialCustomAsset.anchorY); + this.customCursorSprite.visible = false; + this.cursorShadowSprites = {}; + this.cursorShadowFilters = {}; + this.cursorSprites = {}; + for (const key of getAvailableCursorKeys()) { + const asset = getCursorAsset(key); + const shadowSprite = new Sprite(asset.texture); + shadowSprite.anchor.set(asset.anchorX, asset.anchorY); + shadowSprite.visible = false; + shadowSprite.tint = CURSOR_SHADOW_COLOR; + shadowSprite.alpha = CURSOR_SHADOW_ALPHA; + const shadowFilter = new BlurFilter(); + shadowFilter.blur = CURSOR_SHADOW_BLUR; + shadowFilter.quality = 4; + shadowFilter.padding = CURSOR_SHADOW_PADDING; + shadowSprite.filters = [shadowFilter]; + this.cursorShadowSprites[key] = shadowSprite; + this.cursorShadowFilters[key] = shadowFilter; - const sprite = new Sprite(asset.texture); - sprite.anchor.set(asset.anchorX, asset.anchorY); - sprite.visible = false; - this.cursorSprites[key] = sprite; - } + const sprite = new Sprite(asset.texture); + sprite.anchor.set(asset.anchorX, asset.anchorY); + sprite.visible = false; + this.cursorSprites[key] = sprite; + } - this.cursorMotionBlurFilter = new MotionBlurFilter([0, 0], 5, 0); - this.container.filters = null; + this.cursorMotionBlurFilter = new MotionBlurFilter([0, 0], 5, 0); + this.container.filters = null; - 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); - } + 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) { - this.config.dotRadius = dotRadius; - } + setDotRadius(dotRadius: number) { + this.config.dotRadius = dotRadius; + } - setSmoothingFactor(smoothingFactor: number) { - this.config.smoothingFactor = smoothingFactor; - this.state.setSmoothingFactor(smoothingFactor); - } + setSmoothingFactor(smoothingFactor: number) { + this.config.smoothingFactor = smoothingFactor; + this.state.setSmoothingFactor(smoothingFactor); + } - setMotionBlur(motionBlur: number) { - this.config.motionBlur = Math.max(0, motionBlur); - this.container.filters = - this.config.motionBlur > 0 ? [this.cursorMotionBlurFilter] : null; - if (this.config.motionBlur <= 0) { - this.cursorMotionBlurFilter.velocity = { x: 0, y: 0 }; - this.cursorMotionBlurFilter.kernelSize = 5; - this.cursorMotionBlurFilter.offset = 0; - } - } + setMotionBlur(motionBlur: number) { + this.config.motionBlur = Math.max(0, motionBlur); + this.container.filters = this.config.motionBlur > 0 ? [this.cursorMotionBlurFilter] : null; + if (this.config.motionBlur <= 0) { + this.cursorMotionBlurFilter.velocity = { x: 0, y: 0 }; + this.cursorMotionBlurFilter.kernelSize = 5; + this.cursorMotionBlurFilter.offset = 0; + } + } - setClickBounce(clickBounce: number) { - this.config.clickBounce = Math.max(0, clickBounce); - } + setClickBounce(clickBounce: number) { + this.config.clickBounce = Math.max(0, clickBounce); + } - setClickBounceDuration(clickBounceDuration: number) { - this.config.clickBounceDuration = clamp(clickBounceDuration, 60, 500); - } + setClickBounceDuration(clickBounceDuration: number) { + this.config.clickBounceDuration = clamp(clickBounceDuration, 60, 500); + } - setSway(sway: number) { - this.config.sway = clamp(sway, 0, 2); - } + setSway(sway: number) { + 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; - } + 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); - } + 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, - viewport: CursorViewportRect, - visible: boolean, - freeze = false, - ): void { - if ( - !visible || - samples.length === 0 || - viewport.width <= 0 || - viewport.height <= 0 - ) { - this.container.visible = false; - this.lastRenderedPoint = null; - this.lastRenderedTimeMs = null; - this.swayRotation = 0; - resetSpringState(this.swaySpring, 0); - this.cursorMotionBlurFilter.velocity = { x: 0, y: 0 }; - return; - } + update( + samples: CursorTelemetryPoint[], + timeMs: number, + viewport: CursorViewportRect, + visible: boolean, + freeze = false, + ): void { + if (!visible || samples.length === 0 || viewport.width <= 0 || viewport.height <= 0) { + this.container.visible = false; + this.lastRenderedPoint = null; + this.lastRenderedTimeMs = null; + this.swayRotation = 0; + resetSpringState(this.swaySpring, 0); + this.cursorMotionBlurFilter.velocity = { x: 0, y: 0 }; + return; + } - const target = interpolateCursorPosition(samples, timeMs); - if (!target) { - this.container.visible = false; - return; - } + const target = interpolateCursorPosition(samples, timeMs); + if (!target) { + this.container.visible = false; + return; + } - const projectedTarget = projectCursorPositionToViewport( - target, - viewport.sourceCrop, - ); - if (!projectedTarget.visible) { - this.container.visible = false; - this.lastRenderedPoint = null; - this.lastRenderedTimeMs = null; - this.swayRotation = 0; - resetSpringState(this.swaySpring, 0); - this.cursorMotionBlurFilter.velocity = { x: 0, y: 0 }; - return; - } + const projectedTarget = projectCursorPositionToViewport(target, viewport.sourceCrop); + if (!projectedTarget.visible) { + this.container.visible = false; + this.lastRenderedPoint = null; + this.lastRenderedTimeMs = null; + this.swayRotation = 0; + resetSpringState(this.swaySpring, 0); + this.cursorMotionBlurFilter.velocity = { x: 0, y: 0 }; + return; + } - const sameFrameTime = - this.lastRenderedTimeMs !== null && - Math.abs(this.lastRenderedTimeMs - timeMs) < 0.0001; - const hasTimeDiscontinuity = - this.lastRenderedTimeMs !== null && - Math.abs(timeMs - this.lastRenderedTimeMs) > CURSOR_TIME_DISCONTINUITY_MS; - const shouldFreezeCursorMotion = freeze || hasTimeDiscontinuity; + const sameFrameTime = + this.lastRenderedTimeMs !== null && Math.abs(this.lastRenderedTimeMs - timeMs) < 0.0001; + const hasTimeDiscontinuity = + this.lastRenderedTimeMs !== null && + Math.abs(timeMs - this.lastRenderedTimeMs) > CURSOR_TIME_DISCONTINUITY_MS; + const shouldFreezeCursorMotion = freeze || hasTimeDiscontinuity; - if (shouldFreezeCursorMotion) { - if (!sameFrameTime || !this.lastRenderedPoint) { - this.state.snapTo(projectedTarget.cx, projectedTarget.cy, timeMs); - } - } else { - this.state.update(projectedTarget.cx, projectedTarget.cy, timeMs); - } - this.container.visible = true; + if (shouldFreezeCursorMotion) { + if (!sameFrameTime || !this.lastRenderedPoint) { + this.state.snapTo(projectedTarget.cx, projectedTarget.cy, timeMs); + } + } else { + this.state.update(projectedTarget.cx, projectedTarget.cy, timeMs); + } + this.container.visible = true; - const px = viewport.x + this.state.x * viewport.width; - const py = viewport.y + this.state.y * viewport.height; - const h = this.config.dotRadius * getCursorViewportScale(viewport); - const { cursorType, clickBounceProgress, clickProgress } = - getCursorVisualState(samples, timeMs, this.config.clickBounceDuration); - const bounceScale = Math.max( - 0.72, - 1 - - Math.sin(clickBounceProgress * Math.PI) * - (0.08 * this.config.clickBounce), - ); - const scaledH = h; - const swayRotation = this.updateCursorSway( - px, - py, - timeMs, - shouldFreezeCursorMotion, - ); + const px = viewport.x + this.state.x * viewport.width; + const py = viewport.y + this.state.y * viewport.height; + const h = this.config.dotRadius * getCursorViewportScale(viewport); + const { cursorType, clickBounceProgress, clickProgress } = getCursorVisualState( + samples, + timeMs, + this.config.clickBounceDuration, + ); + const bounceScale = Math.max( + 0.72, + 1 - Math.sin(clickBounceProgress * Math.PI) * (0.08 * this.config.clickBounce), + ); + const scaledH = h; + const swayRotation = this.updateCursorSway(px, py, timeMs, shouldFreezeCursorMotion); - this.clickRingGraphics.clear(); - drawClickRing(this.clickRingGraphics, px, py, h, clickProgress); + this.clickRingGraphics.clear(); + drawClickRing(this.clickRingGraphics, px, py, h, clickProgress); - if (this.config.style === "tahoe" || this.config.style === "mono") { - this.customCursorShadowSprite.visible = false; - this.customCursorSprite.visible = false; + if (this.config.style === "tahoe" || this.config.style === "mono") { + this.customCursorShadowSprite.visible = false; + this.customCursorSprite.visible = false; - 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!; + 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!; - for (const [key, currentShadowSprite] of Object.entries( - this.cursorShadowSprites, - ) as Array<[CursorAssetKey, Sprite]>) { - currentShadowSprite.visible = key === spriteKey; - } + for (const [key, currentShadowSprite] of Object.entries(this.cursorShadowSprites) as Array< + [CursorAssetKey, Sprite] + >) { + currentShadowSprite.visible = key === spriteKey; + } - for (const [key, currentSprite] of Object.entries( - this.cursorSprites, - ) as Array<[CursorAssetKey, Sprite]>) { - currentSprite.visible = key === spriteKey; - } + 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 (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; - } + 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; - } + 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; - } + 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.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); - this.lastRenderedPoint = { px, py }; - this.lastRenderedTimeMs = timeMs; - } + this.applyCursorMotionBlur(px, py, timeMs, shouldFreezeCursorMotion); + this.lastRenderedPoint = { px, py }; + this.lastRenderedTimeMs = timeMs; + } - private updateCursorSway( - px: number, - py: number, - timeMs: number, - freeze: boolean, - ) { - const deltaMs = - this.lastRenderedTimeMs === null || freeze - ? 1000 / 60 - : Math.max(1, timeMs - this.lastRenderedTimeMs); - const targetRotation = - !freeze && this.lastRenderedPoint && this.lastRenderedTimeMs !== null - ? computeCursorSwayRotation( - px - this.lastRenderedPoint.px, - py - this.lastRenderedPoint.py, - timeMs - this.lastRenderedTimeMs, - this.config.sway, - ) - : 0; + private updateCursorSway(px: number, py: number, timeMs: number, freeze: boolean) { + const deltaMs = + this.lastRenderedTimeMs === null || freeze + ? 1000 / 60 + : Math.max(1, timeMs - this.lastRenderedTimeMs); + const targetRotation = + !freeze && this.lastRenderedPoint && this.lastRenderedTimeMs !== null + ? computeCursorSwayRotation( + px - this.lastRenderedPoint.px, + py - this.lastRenderedPoint.py, + timeMs - this.lastRenderedTimeMs, + this.config.sway, + ) + : 0; - this.swayRotation = stepSpringValue( - this.swaySpring, - targetRotation, - deltaMs, - getCursorSwaySpringConfig(this.config.smoothingFactor), - ); + this.swayRotation = stepSpringValue( + this.swaySpring, + targetRotation, + deltaMs, + getCursorSwaySpringConfig(this.config.smoothingFactor), + ); - if (Math.abs(this.swayRotation) < 0.0001 && targetRotation === 0) { - this.swayRotation = 0; - } + if (Math.abs(this.swayRotation) < 0.0001 && targetRotation === 0) { + this.swayRotation = 0; + } - return this.swayRotation; - } + return this.swayRotation; + } - private applyCursorMotionBlur( - px: number, - py: number, - timeMs: number, - freeze: boolean, - ) { - if ( - freeze || - this.config.motionBlur <= 0 || - !this.lastRenderedPoint || - this.lastRenderedTimeMs === null - ) { - this.cursorMotionBlurFilter.velocity = { x: 0, y: 0 }; - this.cursorMotionBlurFilter.kernelSize = 5; - this.cursorMotionBlurFilter.offset = 0; - return; - } + private applyCursorMotionBlur(px: number, py: number, timeMs: number, freeze: boolean) { + if ( + freeze || + this.config.motionBlur <= 0 || + !this.lastRenderedPoint || + this.lastRenderedTimeMs === null + ) { + this.cursorMotionBlurFilter.velocity = { x: 0, y: 0 }; + this.cursorMotionBlurFilter.kernelSize = 5; + this.cursorMotionBlurFilter.offset = 0; + return; + } - const deltaMs = Math.max(1, timeMs - this.lastRenderedTimeMs); - const dx = px - this.lastRenderedPoint.px; - const dy = py - this.lastRenderedPoint.py; - const velocityScale = - (1000 / deltaMs) * - this.config.motionBlur * - CURSOR_MOTION_BLUR_BASE_MULTIPLIER; - const velocity = { - x: dx * velocityScale, - y: dy * velocityScale, - }; - const magnitude = Math.hypot(velocity.x, velocity.y); + const deltaMs = Math.max(1, timeMs - this.lastRenderedTimeMs); + const dx = px - this.lastRenderedPoint.px; + const dy = py - this.lastRenderedPoint.py; + const velocityScale = + (1000 / deltaMs) * this.config.motionBlur * CURSOR_MOTION_BLUR_BASE_MULTIPLIER; + const velocity = { + x: dx * velocityScale, + y: dy * velocityScale, + }; + const magnitude = Math.hypot(velocity.x, velocity.y); - this.cursorMotionBlurFilter.velocity = - magnitude > 0.05 ? velocity : { x: 0, y: 0 }; - this.cursorMotionBlurFilter.kernelSize = - magnitude > 3 ? 9 : magnitude > 1 ? 7 : 5; - this.cursorMotionBlurFilter.offset = magnitude > 0.5 ? -0.25 : 0; - } + this.cursorMotionBlurFilter.velocity = magnitude > 0.05 ? velocity : { x: 0, y: 0 }; + this.cursorMotionBlurFilter.kernelSize = magnitude > 3 ? 9 : magnitude > 1 ? 7 : 5; + this.cursorMotionBlurFilter.offset = magnitude > 0.5 ? -0.25 : 0; + } - reset(): void { - this.state.reset(); - this.clickRingGraphics.clear(); - for (const shadowSprite of Object.values(this.cursorShadowSprites)) { - 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; - this.swayRotation = 0; - resetSpringState(this.swaySpring, 0); - this.cursorMotionBlurFilter.velocity = { x: 0, y: 0 }; - this.cursorMotionBlurFilter.kernelSize = 5; - this.cursorMotionBlurFilter.offset = 0; - } + reset(): void { + this.state.reset(); + this.clickRingGraphics.clear(); + for (const shadowSprite of Object.values(this.cursorShadowSprites)) { + 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; + this.swayRotation = 0; + resetSpringState(this.swaySpring, 0); + this.cursorMotionBlurFilter.velocity = { x: 0, y: 0 }; + this.cursorMotionBlurFilter.kernelSize = 5; + this.cursorMotionBlurFilter.offset = 0; + } - destroy(): void { - this.clickRingGraphics.destroy(); - this.customCursorShadowFilter.destroy(); - for (const shadowFilter of Object.values(this.cursorShadowFilters)) { - shadowFilter.destroy(); - } - this.cursorMotionBlurFilter.destroy(); - this.container.destroy({ children: true }); - } + destroy(): void { + this.clickRingGraphics.destroy(); + this.customCursorShadowFilter.destroy(); + for (const shadowFilter of Object.values(this.cursorShadowFilters)) { + shadowFilter.destroy(); + } + this.cursorMotionBlurFilter.destroy(); + this.container.destroy({ children: true }); + } } export function drawCursorOnCanvas( - ctx: CanvasRenderingContext2D, - samples: CursorTelemetryPoint[], - timeMs: number, - viewport: CursorViewportRect, - smoothedState: SmoothedCursorState, - config: CursorRenderConfig = DEFAULT_CURSOR_CONFIG, + ctx: CanvasRenderingContext2D, + samples: CursorTelemetryPoint[], + timeMs: number, + viewport: CursorViewportRect, + smoothedState: SmoothedCursorState, + config: CursorRenderConfig = DEFAULT_CURSOR_CONFIG, ): void { - if (samples.length === 0 || viewport.width <= 0 || viewport.height <= 0) - return; + if (samples.length === 0 || viewport.width <= 0 || viewport.height <= 0) return; - const target = interpolateCursorPosition(samples, timeMs); - if (!target) return; + const target = interpolateCursorPosition(samples, timeMs); + if (!target) return; - const projectedTarget = projectCursorPositionToViewport( - target, - viewport.sourceCrop, - ); - if (!projectedTarget.visible) return; + const projectedTarget = projectCursorPositionToViewport(target, viewport.sourceCrop); + if (!projectedTarget.visible) return; - smoothedState.update(projectedTarget.cx, projectedTarget.cy, timeMs); + smoothedState.update(projectedTarget.cx, projectedTarget.cy, timeMs); - const px = viewport.x + smoothedState.x * viewport.width; - const py = viewport.y + smoothedState.y * viewport.height; - const h = config.dotRadius * getCursorViewportScale(viewport); - const { cursorType, clickBounceProgress } = getCursorVisualState( - samples, - timeMs, - config.clickBounceDuration, - ); - 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), - ); + const px = viewport.x + smoothedState.x * viewport.width; + const py = viewport.y + smoothedState.y * viewport.height; + const h = config.dotRadius * getCursorViewportScale(viewport); + const { cursorType, clickBounceProgress } = getCursorVisualState( + samples, + timeMs, + config.clickBounceDuration, + ); + 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(); - if (config.style !== "figma") { - ctx.filter = CURSOR_SVG_DROP_SHADOW_FILTER; - } + ctx.save(); + if (config.style !== "figma") { + ctx.filter = CURSOR_SVG_DROP_SHADOW_FILTER; + } - const drawHeight = h * bounceScale; - const drawWidth = drawHeight * asset.aspectRatio; - const hotspotX = asset.anchorX * drawWidth; - const hotspotY = asset.anchorY * drawHeight; - ctx.globalAlpha = config.dotAlpha; - ctx.drawImage( - asset.image, - px - hotspotX, - py - hotspotY, - drawWidth, - drawHeight, - ); + const drawHeight = h * bounceScale; + const drawWidth = drawHeight * asset.aspectRatio; + const hotspotX = asset.anchorX * drawWidth; + const hotspotY = asset.anchorY * drawHeight; + ctx.globalAlpha = config.dotAlpha; + ctx.drawImage(asset.image, px - hotspotX, py - hotspotY, drawWidth, drawHeight); - ctx.restore(); + ctx.restore(); } diff --git a/src/components/video-editor/videoPlayback/uploadedCursorAssets.ts b/src/components/video-editor/videoPlayback/uploadedCursorAssets.ts index f37d4587..4db50368 100644 --- a/src/components/video-editor/videoPlayback/uploadedCursorAssets.ts +++ b/src/components/video-editor/videoPlayback/uploadedCursorAssets.ts @@ -1,70 +1,83 @@ -import arrowUrl from '../../../assets/cursors/Cursor=Default.svg'; -import closedHandUrl from '../../../assets/cursors/Cursor=Hand-(Grabbing).svg'; -import crosshairUrl from '../../../assets/cursors/Cursor=Cross.svg'; -import openHandUrl from '../../../assets/cursors/Cursor=Hand-(Open).svg'; -import pointerUrl from '../../../assets/cursors/Cursor=Hand-(Pointing).svg'; -import resizeEwUrl from '../../../assets/cursors/Cursor=Resize-West-East.svg'; -import resizeNsUrl from '../../../assets/cursors/Cursor=Resize-North-South.svg'; -import textUrl from '../../../assets/cursors/Cursor=Text-Cursor.svg'; -import type { CursorTelemetryPoint } from '../types'; +import crosshairUrl from "../../../assets/cursors/Cursor=Cross.svg"; +import arrowUrl from "../../../assets/cursors/Cursor=Default.svg"; +import closedHandUrl from "../../../assets/cursors/Cursor=Hand-(Grabbing).svg"; +import openHandUrl from "../../../assets/cursors/Cursor=Hand-(Open).svg"; +import pointerUrl from "../../../assets/cursors/Cursor=Hand-(Pointing).svg"; +import resizeNsUrl from "../../../assets/cursors/Cursor=Resize-North-South.svg"; +import resizeEwUrl from "../../../assets/cursors/Cursor=Resize-West-East.svg"; +import textUrl from "../../../assets/cursors/Cursor=Text-Cursor.svg"; +import type { CursorTelemetryPoint } from "../types"; -type CursorAssetKey = NonNullable; +type CursorAssetKey = NonNullable; export type UploadedCursorAsset = { - url: string; - trim: { - x: number; - y: number; - width: number; - height: number; - }; - fallbackAnchor: { - x: number; - y: number; - }; + url: string; + trim: { + x: number; + y: number; + width: number; + height: number; + }; + fallbackAnchor: { + x: number; + y: number; + }; + platformAnchors?: Partial>; }; export const UPLOADED_CURSOR_SAMPLE_SIZE = 1024; export const uploadedCursorAssets: Partial> = { - arrow: { - url: arrowUrl, - trim: { x: 480, y: 435, width: 333, height: 553 }, - fallbackAnchor: { x: 0.18, y: 0.1 }, - }, - text: { - url: textUrl, - trim: { x: 404, y: 192, width: 247, height: 596 }, - fallbackAnchor: { x: 0.5, y: 0.5 }, - }, - pointer: { - url: pointerUrl, - trim: { x: 352, y: 441, width: 466, height: 583 }, - fallbackAnchor: { x: 0.37, y: 0.08 }, - }, - crosshair: { - url: crosshairUrl, - trim: { x: 288, y: 288, width: 480, height: 480 }, - fallbackAnchor: { x: 0.5, y: 0.5 }, - }, - 'open-hand': { - url: openHandUrl, - trim: { x: 288, y: 188, width: 512, height: 580 }, - fallbackAnchor: { x: 0.5, y: 0.28 }, - }, - 'closed-hand': { - url: closedHandUrl, - trim: { x: 344, y: 365, width: 432, height: 403 }, - fallbackAnchor: { x: 0.5, y: 0.28 }, - }, - 'resize-ew': { - url: resizeEwUrl, - trim: { x: 187, y: 384, width: 669, height: 270 }, - fallbackAnchor: { x: 0.5, y: 0.5 }, - }, - 'resize-ns': { - url: resizeNsUrl, - trim: { x: 376, y: 178, width: 271, height: 669 }, - fallbackAnchor: { x: 0.5, y: 0.5 }, - }, + arrow: { + url: arrowUrl, + trim: { x: 480, y: 435, width: 333, height: 553 }, + fallbackAnchor: { x: 0.18, y: 0.1 }, + platformAnchors: { + win32: { x: 0.095, y: 0.056 }, + }, + }, + text: { + url: textUrl, + trim: { x: 404, y: 192, width: 247, height: 596 }, + fallbackAnchor: { x: 0.5, y: 0.5 }, + }, + pointer: { + url: pointerUrl, + trim: { x: 352, y: 441, width: 466, height: 583 }, + fallbackAnchor: { x: 0.37, y: 0.08 }, + platformAnchors: { + win32: { x: 0.29, y: 0.07 }, + }, + }, + crosshair: { + url: crosshairUrl, + trim: { x: 288, y: 288, width: 480, height: 480 }, + fallbackAnchor: { x: 0.5, y: 0.5 }, + }, + "open-hand": { + url: openHandUrl, + trim: { x: 288, y: 188, width: 512, height: 580 }, + fallbackAnchor: { x: 0.5, y: 0.28 }, + platformAnchors: { + win32: { x: 0.46, y: 0.2 }, + }, + }, + "closed-hand": { + url: closedHandUrl, + trim: { x: 344, y: 365, width: 432, height: 403 }, + fallbackAnchor: { x: 0.5, y: 0.28 }, + platformAnchors: { + win32: { x: 0.47, y: 0.22 }, + }, + }, + "resize-ew": { + url: resizeEwUrl, + trim: { x: 187, y: 384, width: 669, height: 270 }, + fallbackAnchor: { x: 0.5, y: 0.5 }, + }, + "resize-ns": { + url: resizeNsUrl, + trim: { x: 376, y: 178, width: 271, height: 669 }, + fallbackAnchor: { x: 0.5, y: 0.5 }, + }, };