diff --git a/src/components/CanvasStage.vue b/src/components/CanvasStage.vue index a5ebc60..06522c9 100644 --- a/src/components/CanvasStage.vue +++ b/src/components/CanvasStage.vue @@ -561,36 +561,35 @@ function kaleidoIndicesForLayer(layer: Layer): number[] { } /** Augments a layer's Konva config with the kaleido per-copy - * position + rotation. Pivot defaults to the layer's own (x, y), - * but a per-layer override moves the orbit centre elsewhere on the - * canvas — same arithmetic as render.ts/exportPlayer use, so all - * three render paths land identical pixels. Non-source copies are + * position + rotation. Pivot lookup matches render.ts: live drag + * override → keyframed `props.kaleidoPivotX/Y` → static layer + * override → layer's own (x, y). Non-source copies are * listening:false to keep events on the source. */ function withK>( cfg: T, layer: Layer, + props: AnimatableProps, ki: number, n: number, ): T { if (n <= 1 || ki === 0) return cfg - const sx = typeof cfg.x === 'number' ? (cfg.x as number) : 0 - const sy = typeof cfg.y === 'number' ? (cfg.y as number) : 0 - const srot = typeof cfg.rotation === 'number' ? (cfg.rotation as number) : 0 - // Live-pivot drag: when the user drags the on-stage handle for - // this layer, route through liveKaleidoPivot so the orbit follows - // the pointer in real time. const live = liveKaleidoPivot.value - const pivotX = live && live.instanceId === layer.id ? live.x : (layer.kaleidoPivotX ?? sx) - const pivotY = live && live.instanceId === layer.id ? live.y : (layer.kaleidoPivotY ?? sy) + const liveOverride = live && live.instanceId === layer.id + const pivotX = liveOverride + ? live.x + : props.kaleidoPivotX ?? layer.kaleidoPivotX ?? props.x + const pivotY = liveOverride + ? live.y + : props.kaleidoPivotY ?? layer.kaleidoPivotY ?? props.y const angle = kaleidoAngleDegrees(ki, n) const a = (angle * Math.PI) / 180 - const dx = sx - pivotX - const dy = sy - pivotY + const dx = props.x - pivotX + const dy = props.y - pivotY return { ...cfg, x: pivotX + dx * Math.cos(a) - dy * Math.sin(a), y: pivotY + dx * Math.sin(a) + dy * Math.cos(a), - rotation: srot + angle, + rotation: props.rotation + angle, listening: false, draggable: false, } as T @@ -1683,8 +1682,14 @@ const kaleidoPivotHandle = computed(() => { if (asset?.kind === 'button') return null const sampled = sampleLayer(layer, store.playhead) const live = liveKaleidoPivot.value - const x = live && live.instanceId === lyId ? live.x : (layer.kaleidoPivotX ?? sampled.x) - const y = live && live.instanceId === lyId ? live.y : (layer.kaleidoPivotY ?? sampled.y) + // Pivot lookup matches render.ts: live drag → keyframed prop → + // legacy static layer field → sampled layer position. + const x = live && live.instanceId === lyId + ? live.x + : sampled.kaleidoPivotX ?? layer.kaleidoPivotX ?? sampled.x + const y = live && live.instanceId === lyId + ? live.y + : sampled.kaleidoPivotY ?? layer.kaleidoPivotY ?? sampled.y return { kind: 'layer', layerId: layer.id, x, y } } return null @@ -1752,9 +1757,11 @@ function onKaleidoPivotPointerUp() { kaleidoPivotY: liveKaleidoPivot.value.y, }) } else { - store.setLayerKaleidoPivot(pivotDragEntity.layerId, { - x: liveKaleidoPivot.value.x, - y: liveKaleidoPivot.value.y, + // Layer pivot is keyframable. Upsert at the current playhead + // so dropping multiple kfs animates the orbit centre. + store.upsertKeyframe(pivotDragEntity.layerId, store.playhead, { + kaleidoPivotX: liveKaleidoPivot.value.x, + kaleidoPivotY: liveKaleidoPivot.value.y, }) } } @@ -4496,7 +4503,7 @@ function onResizeHandlePointerUp(e: PointerEvent) { >
Your fonts
- - +
@@ -327,6 +331,38 @@ function isActive(id: string): boolean { color: var(--accent); } +.font-popover .custom-font-row { + display: flex; + align-items: center; + width: 100%; + background: transparent; + border: 1px solid transparent; + border-radius: var(--radius-sm); + color: var(--text); +} + +.font-popover .custom-font-row:hover { + background: var(--panel-hover, var(--panel-alt)); +} + +.font-popover .custom-font-row.active { + background: var(--accent-soft); + color: var(--accent); +} + +.font-popover .custom-font-select { + flex: 1 1 auto; + min-width: 0; + border: 0; + border-radius: 0; + color: inherit; + padding: 6px 0 6px 8px; +} + +.font-popover .custom-font-select:hover { + background: transparent; +} + .font-popover .check { flex-shrink: 0; } @@ -352,10 +388,12 @@ function isActive(id: string): boolean { border-radius: var(--radius-sm); color: var(--text-dim); cursor: pointer; + margin-right: 4px; opacity: 0; } -.font-popover .font-option:hover .delete-custom { +.font-popover .custom-font-row:hover .delete-custom, +.font-popover .delete-custom:focus-visible { opacity: 1; } diff --git a/src/components/PropertiesPanel.vue b/src/components/PropertiesPanel.vue index d359d38..1ffd60a 100644 --- a/src/components/PropertiesPanel.vue +++ b/src/components/PropertiesPanel.vue @@ -225,17 +225,25 @@ const kaleidoOn = computed(() => !!selected.value?.kaleido) const kaleidoCountValue = computed(() => selected.value?.kaleidoCount ?? 6) +/** Display value for the layer's kaleido pivot inputs. Reads the + * sampled keyframe value first (so a moving pivot animates the + * number too), then the layer's static legacy override, then the + * layer's own (x, y). Rounded to int for compact display. */ const kaleidoPivotX = computed(() => { const l = selected.value if (!l) return 0 + const s = sampled.value + if (s?.kaleidoPivotX !== undefined) return Math.round(s.kaleidoPivotX) if (l.kaleidoPivotX !== undefined) return Math.round(l.kaleidoPivotX) - return Math.round(sampled.value?.x ?? 0) + return Math.round(s?.x ?? 0) }) const kaleidoPivotY = computed(() => { const l = selected.value if (!l) return 0 + const s = sampled.value + if (s?.kaleidoPivotY !== undefined) return Math.round(s.kaleidoPivotY) if (l.kaleidoPivotY !== undefined) return Math.round(l.kaleidoPivotY) - return Math.round(sampled.value?.y ?? 0) + return Math.round(s?.y ?? 0) }) function setKaleidoOn(on: boolean) { @@ -243,9 +251,15 @@ function setKaleidoOn(on: boolean) { store.setLayerKaleido(selected.value.id, on) } +/** Pivot writes go through the keyframe pipeline — same flow as + * Transform X/Y. Animators can drop multiple kfs and the orbit + * centre interpolates between them. */ function setKaleidoPivot(patch: { x?: number; y?: number }) { if (!selected.value) return - store.setLayerKaleidoPivot(selected.value.id, patch) + const props: Partial = {} + if (patch.x !== undefined) props.kaleidoPivotX = patch.x + if (patch.y !== undefined) props.kaleidoPivotY = patch.y + store.upsertKeyframe(selected.value.id, store.playhead, props) } const wiggleOn = computed(() => !!selected.value?.wiggle) diff --git a/src/devtools/tests.ts b/src/devtools/tests.ts index 9a48ab0..3e6f6b3 100644 --- a/src/devtools/tests.ts +++ b/src/devtools/tests.ts @@ -15554,7 +15554,7 @@ export function registerAllTests(): void { expect(src).toContain('clipKaleidoPivot') expect(src).toContain('clipArmLocalT') // Runtime version got bumped during the kaleido + wiggle work. - expect(src).toContain("'33'") + expect(src).toContain("'34'") }) test('exported runtime layerActiveAt is unaffected by kaleido fields', async () => { @@ -15932,7 +15932,7 @@ export function registerAllTests(): void { expect(src).toContain('function wiggleSeeds(') // Runtime version got bumped through the wiggle work and the // later layer-kaleido-pivot landing. - expect(src).toContain("'33'") + expect(src).toContain("'34'") }) }) @@ -16207,24 +16207,39 @@ export function registerAllTests(): void { suite('layer kaleido pivot — separate orbit centre', () => { test('layerArmTransform: defaults to sampled position, custom pivot rotates around override', async () => { const { layerArmTransform } = await import('@/lib/kaleido') + const baseProps = (x: number, y: number, rot = 0) => ({ + x, y, rotation: rot, scaleX: 1, scaleY: 1, opacity: 1, + }) as unknown as import('@/types/scene').AnimatableProps // Layer at (100, 100), no custom pivot → arm 1 of 4 rotates // 90° around (100, 100): same result as no-pivot baseline. const layer = { id: 'L', kaleido: true, kaleidoCount: 4 } as unknown as import('@/types/scene').Layer - const a = layerArmTransform(layer, 100, 100, 0, 1, 4) + const a = layerArmTransform(layer, baseProps(100, 100), 1, 4) // dx=dy=0 → arm 1 stays at pivot. expect(a.x).toBeCloseTo(100, 5) expect(a.y).toBeCloseTo(100, 5) expect(a.rotation).toBeCloseTo(90, 5) - // Custom pivot at (50, 50) shifts the orbit centre. Layer at - // (100, 100), arm 1 of 4 (90°) around (50, 50): - // dx=50, dy=50 → rotated by 90° → dx'=-50, dy'=50 → world (0, 100) + // Static legacy override at (50, 50): same rotation, layer at + // (100, 100). Arm 1 (90°): dx=50, dy=50 → after R(90°): (-50, 50) + // → world (0, 100). const layerWithPivot = { id: 'L', kaleido: true, kaleidoCount: 4, kaleidoPivotX: 50, kaleidoPivotY: 50, } as unknown as import('@/types/scene').Layer - const b = layerArmTransform(layerWithPivot, 100, 100, 0, 1, 4) + const b = layerArmTransform(layerWithPivot, baseProps(100, 100), 1, 4) expect(b.x).toBeCloseTo(0, 5) expect(b.y).toBeCloseTo(100, 5) expect(b.rotation).toBeCloseTo(90, 5) + // Keyframed pivot via props takes precedence over the legacy + // layer-level override. Pivot via props (200, 200), layer + // override (50, 50) — props wins. dx=−100, dy=−100 → R(90°): (100, −100) + // → world (300, 100). + const c = layerArmTransform( + layerWithPivot, + { ...baseProps(100, 100), kaleidoPivotX: 200, kaleidoPivotY: 200 } as import('@/types/scene').AnimatableProps, + 1, + 4, + ) + expect(c.x).toBeCloseTo(300, 5) + expect(c.y).toBeCloseTo(100, 5) }) test('store.setLayerKaleidoPivot: patch + null clear', () => { @@ -16322,14 +16337,20 @@ export function registerAllTests(): void { expect(pivotInputs.length).toBe(2) expect(Number(pivotInputs[0].value)).toBe(50) expect(Number(pivotInputs[1].value)).toBe(50) - // Type a new pivot X via @input. + // Type a new pivot X via @input — pivot writes go through the + // keyframe pipeline now (same path as Transform X/Y). pivotInputs[0].value = '120' pivotInputs[0].dispatchEvent(new Event('input')) await nextTick() const stored = s.scene.layers.find((l) => l.id === layer.id)! - expect(stored.kaleidoPivotX).toBe(120) - // Y stays at the sampled default until the user touches it. - expect(stored.kaleidoPivotY === undefined).toBe(true) + // Find the upserted keyframe at the playhead. + const kf = stored.keyframes.find((k) => Math.abs(k.time - s.playhead) < 1e-3) + expect(!!kf).toBe(true) + expect(kf!.props.kaleidoPivotX).toBe(120) + // Y stays unset on the kf since the user only touched X. + expect(kf!.props.kaleidoPivotY === undefined).toBe(true) + // The legacy top-level field is untouched (still undefined). + expect(stored.kaleidoPivotX === undefined).toBe(true) } finally { handle.unmount() } @@ -16361,11 +16382,89 @@ export function registerAllTests(): void { } }) + test('layer kaleido pivot interpolates between keyframes', async () => { + // Keyframe pivot at (0, 0) at t=0 and (200, 200) at t=1. + // At t=0.5 the sampled pivot should land at (100, 100). Verify + // by checking arm 1 (90° of 4) of a layer at world (50, 50): + // dx=50-100=-50, dy=50-100=-50 → R(90°): dx'=50, dy'=-50 + // → world (150, 50). + const { sampleLayer } = await import('@/lib/interpolate') + const { layerArmTransform } = await import('@/lib/kaleido') + const layer = { + id: 'L', + name: 'L', + assetId: 'a', + visible: true, + base: { x: 50, y: 50, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 }, + keyframes: [ + { + id: 'k1', time: 0, easing: 'linear', + props: { x: 50, y: 50, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1, kaleidoPivotX: 0, kaleidoPivotY: 0 }, + }, + { + id: 'k2', time: 1, easing: 'linear', + props: { x: 50, y: 50, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1, kaleidoPivotX: 200, kaleidoPivotY: 200 }, + }, + ], + kaleido: true, + kaleidoCount: 4, + } as unknown as import('@/types/scene').Layer + const props = sampleLayer(layer, 0.5) + expect(props.kaleidoPivotX).toBeCloseTo(100, 5) + expect(props.kaleidoPivotY).toBeCloseTo(100, 5) + const tf = layerArmTransform(layer, props, 1, 4) + expect(tf.x).toBeCloseTo(150, 5) + expect(tf.y).toBeCloseTo(50, 5) + }) + + test('layer kaleido pivot: one-sided override lerps from layer position, not snap', async () => { + // Regression: when only ONE of two adjacent keyframes sets + // kaleidoPivotX/Y, the older lerp logic snapped the entire + // interval to the defined value (because optional props + // default to "carry through"). Pivot should instead lerp from + // the layer's own (x, y) on the unset side to the explicit + // value on the other — so adding a single late kf produces a + // smooth orbit-centre transition, not an instant jump. + const { sampleLayer } = await import('@/lib/interpolate') + const layer = { + id: 'L', + name: 'L', + assetId: 'a', + visible: true, + base: { x: 50, y: 50, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 }, + keyframes: [ + { + id: 'k1', time: 0, easing: 'linear', + // No kaleidoPivotX/Y — layer's own (50, 50) is the implicit start. + props: { x: 50, y: 50, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 }, + }, + { + id: 'k2', time: 1, easing: 'linear', + props: { x: 50, y: 50, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1, kaleidoPivotX: 250, kaleidoPivotY: 250 }, + }, + ], + kaleido: true, + kaleidoCount: 4, + } as unknown as import('@/types/scene').Layer + // At t=0 → pivot starts at the layer's own position (50, 50). + const start = sampleLayer(layer, 0) + expect(start.kaleidoPivotX).toBeCloseTo(50, 5) + expect(start.kaleidoPivotY).toBeCloseTo(50, 5) + // At t=0.5 → halfway between (50, 50) and (250, 250) = (150, 150). + const mid = sampleLayer(layer, 0.5) + expect(mid.kaleidoPivotX).toBeCloseTo(150, 5) + expect(mid.kaleidoPivotY).toBeCloseTo(150, 5) + // At t=1 → fully at the explicit kf2 value. + const end = sampleLayer(layer, 1) + expect(end.kaleidoPivotX).toBeCloseTo(250, 5) + expect(end.kaleidoPivotY).toBeCloseTo(250, 5) + }) + test('exported runtime engine carries layerArmTf helper', () => { const src = generateRuntimeScript() expect(src).toContain('function layerArmTf(') // Bumped on this work. - expect(src).toContain("'33'") + expect(src).toContain("'34'") }) }) } diff --git a/src/lib/exportPlayer.ts b/src/lib/exportPlayer.ts index 08777ec..15d1244 100644 --- a/src/lib/exportPlayer.ts +++ b/src/lib/exportPlayer.ts @@ -11,7 +11,7 @@ function isProject(p: ExportPayload): p is Project { return Array.isArray((p as Partial).scenes) } -const RUNTIME_VERSION = '33' +const RUNTIME_VERSION = '34' /** Materialize the engine source. The font map is rebuilt on every * call so user-uploaded custom fonts (which live in localStorage @@ -107,6 +107,17 @@ const ENGINE_TEMPLATE = `(function(){ if(a.shadowColor && b.shadowColor) out.shadowColor=lerpColor(a.shadowColor,b.shadowColor,t); else if(b.shadowColor) out.shadowColor=b.shadowColor; else if(a.shadowColor) out.shadowColor=a.shadowColor; + // Kaleido pivot: mirror src/lib/interpolate.ts. A one-sided + // override lerps from the layer's own (x, y) on the unset side + // to the explicit value on the other. + var apx=a.kaleidoPivotX, bpx=b.kaleidoPivotX; + if(apx!=null || bpx!=null){ + out.kaleidoPivotX = lerp(apx!=null?apx:a.x, bpx!=null?bpx:b.x, t); + } + var apy=a.kaleidoPivotY, bpy=b.kaleidoPivotY; + if(apy!=null || bpy!=null){ + out.kaleidoPivotY = lerp(apy!=null?apy:a.y, bpy!=null?bpy:b.y, t); + } return out; } // Interpolate a single keyframe track against 'base' at 'time'. @@ -736,20 +747,24 @@ const ENGINE_TEMPLATE = `(function(){ return n<2?1:n; } function kalAngle(i,n){ return n<=1?0:(i*360)/n; } - // Mirror of layerArmTransform in src/lib/kaleido.ts. Defaults the - // pivot to the layer's sampled (x, y); a per-layer override can - // shift it elsewhere on the canvas. - function layerArmTf(layer, sx, sy, srot, i, n){ - if(i===0||n<=1) return {x:sx,y:sy,rotation:srot}; - var px=(layer.kaleidoPivotX==null?sx:layer.kaleidoPivotX); - var py=(layer.kaleidoPivotY==null?sy:layer.kaleidoPivotY); + // Mirror of layerArmTransform in src/lib/kaleido.ts. Pivot lookup: + // sampled props (keyframed) → layer's static legacy override → + // layer's own sampled (x, y). + function layerArmTf(layer, props, i, n){ + if(i===0||n<=1) return {x:props.x,y:props.y,rotation:props.rotation}; + var ppx=(props.kaleidoPivotX==null?null:props.kaleidoPivotX); + var ppy=(props.kaleidoPivotY==null?null:props.kaleidoPivotY); + var lpx=(layer.kaleidoPivotX==null?null:layer.kaleidoPivotX); + var lpy=(layer.kaleidoPivotY==null?null:layer.kaleidoPivotY); + var px=(ppx!=null?ppx:(lpx!=null?lpx:props.x)); + var py=(ppy!=null?ppy:(lpy!=null?lpy:props.y)); var ang=kalAngle(i,n); var a=ang*Math.PI/180; - var dx=sx-px, dy=sy-py; + var dx=props.x-px, dy=props.y-py; return { x: px+dx*Math.cos(a)-dy*Math.sin(a), y: py+dx*Math.sin(a)+dy*Math.cos(a), - rotation: srot+ang + rotation: props.rotation+ang }; } function clipKaleidoPivot(inst){ @@ -833,7 +848,7 @@ const ENGINE_TEMPLATE = `(function(){ var p=sample(layer,time); var kn=kalCount(layer); for(var ki=0;ki { const pb = b.points![i] diff --git a/src/lib/kaleido.ts b/src/lib/kaleido.ts index 842001e..8331abb 100644 --- a/src/lib/kaleido.ts +++ b/src/lib/kaleido.ts @@ -4,7 +4,7 @@ // (lib/exportPlayer.ts engine source — by mirroring the formulas in // JS) all derive identical geometry from the same definitions. -import type { Layer, ClipInstance } from '@/types/scene' +import type { AnimatableProps, Layer, ClipInstance } from '@/types/scene' /** Number of radial copies to render. Returns 1 when kaleido is off * or the count is below the floor — the caller should treat 1 as @@ -107,42 +107,45 @@ export function layerKaleidoCount(layer: Layer): number { return kaleidoCountFor(layer) } -/** Effective pivot for a layer. Defaults to the layer's sampled - * position; an explicit `kaleidoPivotX/Y` overrides per-axis. */ +/** Effective pivot for a layer. Lookup chain: + * 1. `props.kaleidoPivotX/Y` — keyframed value (lets the pivot + * animate) + * 2. `Layer.kaleidoPivotX/Y` — legacy static override (kept for + * back-compat with projects saved before the feature went + * keyframable) + * 3. layer's sampled (x, y) — default + */ export function layerKaleidoPivot( layer: Layer, - sampledX: number, - sampledY: number, + props: AnimatableProps, ): { x: number; y: number } { return { - x: layer.kaleidoPivotX ?? sampledX, - y: layer.kaleidoPivotY ?? sampledY, + x: props.kaleidoPivotX ?? layer.kaleidoPivotX ?? props.x, + y: props.kaleidoPivotY ?? layer.kaleidoPivotY ?? props.y, } } /** World-space (x, y, rotation) for arm `index` of a kaleido'd - * layer. The source (index 0) is unchanged. Higher indices - * rotate around the pivot — both their position AND their - * orientation, since layer kaleido always rotates copies. */ + * layer. The source (index 0) is unchanged. Higher indices rotate + * around the pivot — both their position AND their orientation, + * since layer kaleido always rotates copies. */ export function layerArmTransform( layer: Layer, - sampledX: number, - sampledY: number, - sampledRotation: number, + props: AnimatableProps, index: number, count: number, ): { x: number; y: number; rotation: number } { if (index === 0 || count <= 1) { - return { x: sampledX, y: sampledY, rotation: sampledRotation } + return { x: props.x, y: props.y, rotation: props.rotation } } - const { x: px, y: py } = layerKaleidoPivot(layer, sampledX, sampledY) + const { x: px, y: py } = layerKaleidoPivot(layer, props) const angle = kaleidoAngleDegrees(index, count) const a = (angle * Math.PI) / 180 - const dx = sampledX - px - const dy = sampledY - py + const dx = props.x - px + const dy = props.y - py return { x: px + dx * Math.cos(a) - dy * Math.sin(a), y: py + dx * Math.sin(a) + dy * Math.cos(a), - rotation: sampledRotation + angle, + rotation: props.rotation + angle, } } diff --git a/src/lib/render.ts b/src/lib/render.ts index 5b13840..0d04e9b 100644 --- a/src/lib/render.ts +++ b/src/lib/render.ts @@ -164,7 +164,7 @@ function drawLayers( // their own internal layers and click routing must stay 1:1). const kCount = asset.kind === 'button' ? 1 : layerKaleidoCount(layer) for (let kf = 0; kf < kCount; kf++) { - const tf = layerArmTransform(layer, p.x, p.y, p.rotation, kf, kCount) + const tf = layerArmTransform(layer, p, kf, kCount) ctx.save() ctx.globalAlpha *= p.opacity ctx.translate(tf.x, tf.y) @@ -361,7 +361,7 @@ function drawMaskShape( // N rotated shapes. const kCount = layerKaleidoCount(layer) for (let kf = 0; kf < kCount; kf++) { - const tf = layerArmTransform(layer, p.x, p.y, p.rotation, kf, kCount) + const tf = layerArmTransform(layer, p, kf, kCount) ctx.save() ctx.translate(tf.x, tf.y) ctx.rotate((tf.rotation * Math.PI) / 180) diff --git a/src/types/scene.ts b/src/types/scene.ts index 053482f..9fa3d9c 100644 --- a/src/types/scene.ts +++ b/src/types/scene.ts @@ -29,6 +29,12 @@ export interface AnimatableProps { points?: Point[] stroke?: string strokeWidth?: number + /** Kaleidoscope pivot — keyframable like x/y. When unset on every + * reachable kf, falls back to the layer's static + * `Layer.kaleidoPivotX/Y` (legacy field), then to (x, y). Lets the + * pivot itself dance through the scene. */ + kaleidoPivotX?: number + kaleidoPivotY?: number } export interface Keyframe {