0.4.0: clipart drawer, animation paths, bezier keyframes

- Clipart library: new left-edge drawer (5px idle, expands on hover,
  400px when open). User picks a local folder via the File System Access
  API in settings; directory handle is persisted in IndexedDB so the
  library survives reloads. Dragging a thumbnail onto the stage creates
  an image asset + layer at the drop point.
- Animation paths on the stage: selecting a keyframe draws the tween
  path from the previous keyframe to the selected one as a solid
  polyline with per-frame draggable dots. All other segments on the
  same position-track render as dashed ghost lines so the full curve
  progression is visible.
- Path dots grow on hover (2.5 → 6 px), show a grab cursor, and have a
  wider hit area so they're easier to grab.
- Bezier-curved position interpolation: keyframes gain optional
  ctrlX/ctrlY fields. When set, x/y tween along a quadratic bezier
  through the control point instead of a straight line. Dragging a
  path dot solves for the control so the curve passes through the
  drag point at its frame progress. Mirrored across all four render
  pipelines.
- Tests: new suites for polygon actions, freeform drawing lifecycle,
  polygon + stroke rendering (15 new tests). Fixed the mask-render
  tests that relied on a default-transparent canvas background.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-20 10:35:43 +02:00
1 parent 276d621d20
commit 869b67458a
16 files changed
+1379 -15

No files matched your search

+129
View File
@@ -0,0 +1,129 @@
declare global {
interface Window {
showDirectoryPicker?: (options?: {
id?: string
mode?: 'read' | 'readwrite'
}) => Promise<FileSystemDirectoryHandle>
}
}
const DB_NAME = 'animatr-handles'
const DB_VERSION = 1
const STORE = 'handles'
const HANDLE_KEY = 'clipartDir'
const IMAGE_EXT = /\.(svg|png|jpe?g|webp|gif|avif)$/i
export interface ClipartItem {
name: string
file: File
url: string
}
function openDb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION)
req.onupgradeneeded = () => {
const db = req.result
if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE)
}
req.onsuccess = () => resolve(req.result)
req.onerror = () => reject(req.error)
})
}
async function putHandle(handle: FileSystemDirectoryHandle | null): Promise<void> {
const db = await openDb()
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE, 'readwrite')
const store = tx.objectStore(STORE)
if (handle) store.put(handle, HANDLE_KEY)
else store.delete(HANDLE_KEY)
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error)
})
db.close()
}
async function getHandle(): Promise<FileSystemDirectoryHandle | null> {
const db = await openDb()
const result = await new Promise<FileSystemDirectoryHandle | null>((resolve, reject) => {
const tx = db.transaction(STORE, 'readonly')
const req = tx.objectStore(STORE).get(HANDLE_KEY)
req.onsuccess = () => resolve((req.result as FileSystemDirectoryHandle | undefined) ?? null)
req.onerror = () => reject(req.error)
})
db.close()
return result
}
export function isFileSystemAccessSupported(): boolean {
return typeof window !== 'undefined' && typeof window.showDirectoryPicker === 'function'
}
export async function pickClipartFolder(): Promise<FileSystemDirectoryHandle | null> {
if (!isFileSystemAccessSupported()) return null
try {
const handle = await window.showDirectoryPicker!({ id: 'animatr-clipart', mode: 'read' })
await putHandle(handle)
return handle
} catch (err) {
if ((err as Error).name === 'AbortError') return null
throw err
}
}
export async function loadStoredClipartFolder(): Promise<FileSystemDirectoryHandle | null> {
try {
return await getHandle()
} catch {
return null
}
}
export async function clearStoredClipartFolder(): Promise<void> {
await putHandle(null)
}
async function ensurePermission(
handle: FileSystemDirectoryHandle,
mode: 'read' | 'readwrite' = 'read',
): Promise<boolean> {
// @ts-expect-error queryPermission is not in DOM lib yet
const current: PermissionState = await handle.queryPermission({ mode })
if (current === 'granted') return true
// @ts-expect-error requestPermission is not in DOM lib yet
const next: PermissionState = await handle.requestPermission({ mode })
return next === 'granted'
}
export async function listClipart(
handle: FileSystemDirectoryHandle,
): Promise<ClipartItem[]> {
const granted = await ensurePermission(handle)
if (!granted) return []
const out: ClipartItem[] = []
// @ts-expect-error async iterator not in DOM lib
for await (const [name, entry] of handle.entries()) {
if (entry.kind !== 'file') continue
if (!IMAGE_EXT.test(name)) continue
try {
const file = await (entry as FileSystemFileHandle).getFile()
out.push({ name, file, url: URL.createObjectURL(file) })
} catch {
/* ignore unreadable entries */
}
}
out.sort((a, b) => a.name.localeCompare(b.name))
return out
}
export function disposeClipartItems(items: ClipartItem[]): void {
for (const it of items) {
try {
URL.revokeObjectURL(it.url)
} catch {
/* noop */
}
}
}
+9 -1
View File
@@ -78,7 +78,15 @@ const ENGINE_SOURCE = `(function(){
}
var raw=(time-a.time)/(b.time-a.time);
var t=easing(b.easing,raw);
return lerpProps(merge(layer.base,a.props),merge(layer.base,b.props),t);
var pa=merge(layer.base,a.props);
var pb=merge(layer.base,b.props);
var outSample=lerpProps(pa,pb,t);
if(b.ctrlX!=null && b.ctrlY!=null){
var om=1-t;
outSample.x=om*om*pa.x+2*om*t*b.ctrlX+t*t*pb.x;
outSample.y=om*om*pa.y+2*om*t*b.ctrlY+t*t*pb.y;
}
return outSample;
}
}
return merge(layer.base);
+10 -1
View File
@@ -105,7 +105,16 @@ function sampleKeyframeSet(
}
const raw = (time - a.time) / (b.time - a.time)
const t = easings[b.easing](raw)
return lerpProps(merge(base, a.props), merge(base, b.props), t)
const pa = merge(base, a.props)
const pb = merge(base, b.props)
const out = lerpProps(pa, pb, t)
// Bezier-curved position: if b defines a quadratic control, override x/y
if (b.ctrlX !== undefined && b.ctrlY !== undefined) {
const oneMinus = 1 - t
out.x = oneMinus * oneMinus * pa.x + 2 * oneMinus * t * b.ctrlX + t * t * pb.x
out.y = oneMinus * oneMinus * pa.y + 2 * oneMinus * t * b.ctrlY + t * t * pb.y
}
return out
}
}
return { ...base }