diff --git a/README.md b/README.md index 28a9ec3..3d76324 100644 --- a/README.md +++ b/README.md @@ -22,12 +22,12 @@ app you can serve from any static host. - **Keyframes everywhere** — animate position, rotation, scale, fill, stroke, shadow, and shape-specific properties. Optional separated position / rotation tracks. End keyframes terminate a layer cleanly. -- **Layer modifiers** +- **Effects** - **Kaleidoscope** — radial repeats of a layer or a clip instance, with per-arm time offset for time-pinwheel effects. - **Wiggle** — deterministic procedural shake (position + rotation). - - **Masks** — destination-in / destination-out cascade, scoped to - track groups, clips, and SVG assets. +- **Masks** — destination-in / destination-out cascade, scoped to + track groups, clips, and SVG assets. - **Asset kinds** — rect, circle, polygon (with vertex / bezier editing), freeform path, text (14 bundled Google fonts + custom .woff2 / .ttf upload), images, audio, button-with-states, nested SVG assets, and diff --git a/package.json b/package.json index c1899c4..2aa7a1b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ruwio", "private": true, - "version": "1.2.12", + "version": "1.2.13", "type": "module", "scripts": { "dev": "vite", diff --git a/src/components/CanvasStage.vue b/src/components/CanvasStage.vue index 74cf5d7..a5ebc60 100644 --- a/src/components/CanvasStage.vue +++ b/src/components/CanvasStage.vue @@ -560,18 +560,37 @@ function kaleidoIndicesForLayer(layer: Layer): number[] { return Array.from({ length: n }, (_, i) => i) } -/** Augments a layer's Konva config with the kaleido per-copy rotation. - * Konva rotates around the node's `(x, y)` world position (offset is - * the local-space anchor), so adding `kAngle` to `rotation` gives the - * desired rotation around the layer's pivot for free. Non-source - * copies are listening:false to keep events on the source. */ -function withK>(cfg: T, ki: number, n: number): T { +/** 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 + * listening:false to keep events on the source. */ +function withK>( + cfg: T, + layer: Layer, + ki: number, + n: number, +): T { if (n <= 1 || ki === 0) return cfg - const kAngle = kaleidoAngleDegrees(ki, n) - const baseRot = typeof cfg.rotation === 'number' ? (cfg.rotation as number) : 0 + 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 angle = kaleidoAngleDegrees(ki, n) + const a = (angle * Math.PI) / 180 + const dx = sx - pivotX + const dy = sy - pivotY return { ...cfg, - rotation: baseRot + kAngle, + x: pivotX + dx * Math.cos(a) - dy * Math.sin(a), + y: pivotY + dx * Math.sin(a) + dy * Math.cos(a), + rotation: srot + angle, listening: false, draggable: false, } as T @@ -1577,14 +1596,15 @@ function clipInstanceKaleidoGroupConfig(inst: ClipInstance, ki: number) { const base = clipInstanceGroupConfig(inst) const n = kaleidoCountFor(inst) if (n <= 1 || ki === 0) return base - const trans = clipArmTranslate(inst, ki, n) + // Read the pivot through `effectiveKaleidoPivot` so a live drag + // overrides the stored value — keeps every arm orbiting around + // the cursor in real time during the drag. + const { x: px, y: py } = effectiveKaleidoPivot(inst) if (shouldRotateClipCopies(inst)) { // Rotate the source's local space around the pivot. Konva doesn't // expose a "rotate around an arbitrary point" knob in a single // node, but we can replicate it by translating to the pivot, // rotating, and translating back — folded into x / y / rotation. - const px = inst.kaleidoPivotX ?? (inst.x ?? 0) - const py = inst.kaleidoPivotY ?? (inst.y ?? 0) const a = (kaleidoAngleDegrees(ki, n) * Math.PI) / 180 const dx = (inst.x ?? 0) - px const dy = (inst.y ?? 0) - py @@ -1599,15 +1619,150 @@ function clipInstanceKaleidoGroupConfig(inst: ClipInstance, ki: number) { draggable: false, } } + // rotateCopies = false: position around the pivot but keep the + // source orientation. Compute the rotated translate inline so we + // pick up the live pivot too. + const a = (kaleidoAngleDegrees(ki, n) * Math.PI) / 180 + const ix = inst.x ?? 0 + const iy = inst.y ?? 0 + const dx = ix - px + const dy = iy - py return { ...base, - x: trans.x, - y: trans.y, + x: px + dx * Math.cos(a) - dy * Math.sin(a), + y: py + dx * Math.sin(a) + dy * Math.cos(a), listening: false, draggable: false, } } +/** Live (mid-drag) override for the kaleido pivot. Pinia mutations + * alone don't propagate fast enough through vue-konva while Konva's + * drag loop is active — the kaleido arms only reposition at mouse- + * up. Mirroring the `liveLayerPoses` pattern: a local Vue ref is + * written on every dragmove and consumed by the per-arm config + * helper so the orbit follows the pointer in real time, then + * committed to the store on dragend. */ +const liveKaleidoPivot = ref<{ instanceId: string; x: number; y: number } | null>(null) + +function effectiveKaleidoPivot(inst: ClipInstance): { x: number; y: number } { + const live = liveKaleidoPivot.value + if (live && live.instanceId === inst.id) return { x: live.x, y: live.y } + return { + x: inst.kaleidoPivotX ?? (inst.x ?? 0), + y: inst.kaleidoPivotY ?? (inst.y ?? 0), + } +} + +/** Visible pivot handle for a kaleido'd clip instance OR layer. + * Appears at the pivot world position whenever the entity is + * selected and has kaleido on. Drag is wired manually via window- + * level pointer events — Konva's `draggable: true` doesn't fire + * dragmove reliably for shapes nested inside the clipFunc'd canvas + * group here, so we drive position-from-pointer ourselves. */ +type KaleidoPivotEntity = + | { kind: 'clip'; instanceId: string; x: number; y: number } + | { kind: 'layer'; layerId: string; x: number; y: number } + +const kaleidoPivotHandle = computed(() => { + // Clip instance takes precedence (more specific selection). + const ciId = store.selectedClipInstanceId + if (ciId) { + const inst = store.scene.clipInstances?.find((c) => c.id === ciId) + if (!inst || !inst.kaleido) return null + const { x, y } = effectiveKaleidoPivot(inst) + return { kind: 'clip', instanceId: inst.id, x, y } + } + const lyId = store.selectedLayerId + if (lyId) { + const layer = store.activeLayers.find((l) => l.id === lyId) + if (!layer || !layer.kaleido) return null + // Skip button-asset layers — kaleido is disabled for them and + // a stray pivot handle would be confusing. + const asset = assetMap.value.get(layer.assetId) + 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) + return { kind: 'layer', layerId: layer.id, x, y } + } + return null +}) + +const kaleidoPivotHandleConfig = computed(() => { + const h = kaleidoPivotHandle.value + if (!h) return null + return { + x: h.x, + y: h.y, + radius: 9 / zoom.value, + stroke: '#ff7a59', + strokeWidth: 2 / zoom.value, + strokeScaleEnabled: false, + fill: 'rgba(255, 122, 89, 0.18)', + name: 'kaleido-pivot-handle', + } +}) + +let pivotDragEntity: KaleidoPivotEntity | null = null +let pivotDragKonvaLayer: Konva.Layer | null = null + +function clientToCanvas(clientX: number, clientY: number) { + const el = container.value + if (!el) return null + const rect = el.getBoundingClientRect() + const sx = clientX - rect.left + const sy = clientY - rect.top + return { + x: (sx - viewX.value) / zoom.value, + y: (sy - viewY.value) / zoom.value, + } +} + +function onKaleidoPivotMouseDown(e: Konva.KonvaEventObject) { + e.cancelBubble = true + const h = kaleidoPivotHandle.value + if (!h) return + pivotDragEntity = h + pivotDragKonvaLayer = e.target.getLayer() + window.addEventListener('pointermove', onKaleidoPivotPointerMove) + window.addEventListener('pointerup', onKaleidoPivotPointerUp, { once: true }) +} + +function onKaleidoPivotPointerMove(e: PointerEvent) { + if (!pivotDragEntity) return + const c = clientToCanvas(e.clientX, e.clientY) + if (!c) return + // The live ref is keyed by entityId — both clip-instance ids and + // layer ids are universally-unique uids, so a single map is fine. + const id = pivotDragEntity.kind === 'clip' + ? pivotDragEntity.instanceId + : pivotDragEntity.layerId + liveKaleidoPivot.value = { instanceId: id, x: c.x, y: c.y } + pivotDragKonvaLayer?.batchDraw() +} + +function onKaleidoPivotPointerUp() { + window.removeEventListener('pointermove', onKaleidoPivotPointerMove) + if (pivotDragEntity && liveKaleidoPivot.value) { + if (pivotDragEntity.kind === 'clip') { + store.setClipInstanceKaleido(pivotDragEntity.instanceId, { + kaleidoPivotX: liveKaleidoPivot.value.x, + kaleidoPivotY: liveKaleidoPivot.value.y, + }) + } else { + store.setLayerKaleidoPivot(pivotDragEntity.layerId, { + x: liveKaleidoPivot.value.x, + y: liveKaleidoPivot.value.y, + }) + } + } + liveKaleidoPivot.value = null + pivotDragEntity = null + pivotDragKonvaLayer = null +} + /** Invisible frame rect sized to the clip's union bbox. Sits as the * first child of the ci-group so: * - the group's client rect is dominated by this stable rectangle, @@ -2976,6 +3131,7 @@ function handleStageMouseDown(e: Konva.KonvaEventObject) { if (target.name?.() === 'tangent-handle') return if (target.name?.() === 'path-frame-dot') return if (target.name?.() === 'motion-path-dot') return + if (target.name?.() === 'kaleido-pivot-handle') return if (vertexMenu.value) vertexMenu.value = null if (target === target.getStage()) { deselectAll() @@ -4340,7 +4496,7 @@ function onResizeHandlePointerUp(e: PointerEvent) { > + = { transform: true, @@ -54,8 +54,7 @@ const defaultOpen: Record = { shadow: false, mask: false, button: true, - kaleido: false, - wiggle: false, + effects: false, } function readStoredOpen(): Record { @@ -226,11 +225,29 @@ const kaleidoOn = computed(() => !!selected.value?.kaleido) const kaleidoCountValue = computed(() => selected.value?.kaleidoCount ?? 6) +const kaleidoPivotX = computed(() => { + const l = selected.value + if (!l) return 0 + if (l.kaleidoPivotX !== undefined) return Math.round(l.kaleidoPivotX) + return Math.round(sampled.value?.x ?? 0) +}) +const kaleidoPivotY = computed(() => { + const l = selected.value + if (!l) return 0 + if (l.kaleidoPivotY !== undefined) return Math.round(l.kaleidoPivotY) + return Math.round(sampled.value?.y ?? 0) +}) + function setKaleidoOn(on: boolean) { if (!selected.value) return store.setLayerKaleido(selected.value.id, on) } +function setKaleidoPivot(patch: { x?: number; y?: number }) { + if (!selected.value) return + store.setLayerKaleidoPivot(selected.value.id, patch) +} + const wiggleOn = computed(() => !!selected.value?.wiggle) const wiggleAmplitude = computed({ get: () => selected.value?.wiggle?.amplitude ?? 8, @@ -857,87 +874,103 @@ function deleteSelection() {
- -
-
- {{ t('properties.wiggle.toggle') }} - +
+ +
+
+ {{ t('properties.wiggle.toggle') }} + +
+ + + +

{{ t('properties.wiggle.hint') }}

- - - -

{{ t('properties.wiggle.hint') }}

-
-
-
- -
-
- {{ t('properties.kaleido.toggle') }} - + +
+
+ {{ t('properties.kaleido.toggle') }} + +
+ +
+ + +
+

{{ t('properties.kaleido.hint') }}

- -

{{ t('properties.kaleido.hint') }}

@@ -966,11 +999,11 @@ function deleteSelection() {