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:
1 parent
276d621d20
commit
869b67458a
16 files changed
+1379
-15
No files matched your search
@@ -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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 }
|
||||
|
||||
Reference in new issue
Block a user