Compare commits

...
2 Commits
Author SHA1 Message Date
th 70cf131c47 Make kaleido pivot keyframable 2026-06-05 14:31:31 +02:00
th c646f66e8f Add Electron desktop shell 2026-06-05 14:11:44 +02:00
18 changed files with 4513 additions and 92 deletions

No files matched your search

+2
View File
@@ -1,7 +1,9 @@
node_modules node_modules
dist dist
dist-electron
dist-ssr dist-ssr
dist-archive dist-archive
release
*.local *.local
.vite .vite
+228
View File
@@ -0,0 +1,228 @@
# Electron Desktop Plan
## Goal
Package RUWIO as a desktop app while keeping the existing Vue/Vite renderer as the primary application. The Electron layer should start small: host the built app, expose native filesystem dialogs, and later absorb the current server-side MP4 render workflow so desktop users do not need a separate render server.
## Current App Shape
- The UI is a Vue 3 + Vite single-page app in `src/`.
- The production web build is produced by `npm run build` into `dist/`.
- Project save/load is currently browser-driven:
- JSON project export/import lives in `src/lib/projectFile.ts`.
- `.ruw` bundle export/import lives in `src/lib/ruwFile.ts`.
- User-triggered file downloads use `Blob`, object URLs, and anchor clicks.
- Still/GIF/APNG style exports are implemented client-side.
- MP4 rendering is currently split out into `server/server.js`, using Express, Puppeteer Core, Chromium, and ffmpeg.
- The app already has PWA assets and static-host assumptions, which are compatible with an Electron renderer if paths are handled carefully.
## Recommended Architecture
Use a standard Electron split:
- `electron/main.ts`
- Creates the main `BrowserWindow`.
- Loads the Vite dev server during development.
- Loads `dist/index.html` in packaged builds.
- Owns native dialogs, app menu, recent files, and privileged filesystem/process work.
- `electron/preload.ts`
- Exposes a small typed API on `window.ruwioDesktop`.
- Uses `contextBridge` and IPC.
- Does not expose Node primitives directly.
- Vue renderer
- Remains browser-compatible.
- Detects the desktop API when available.
- Falls back to current browser file picker/download behavior on the web.
Keep Electron as an optional target rather than replacing the browser build. The same app should continue to run with `npm run dev`, `npm run build`, and static hosting.
## Package Additions
Add these development dependencies:
- `electron`
- `electron-builder` or `electron-forge`
- `concurrently` for local dev convenience
- `wait-on` if the Electron dev command waits for Vite
Recommended scripts:
```json
{
"dev": "vite",
"desktop:dev": "concurrently \"vite\" \"wait-on http://localhost:5200 && electron .\"",
"desktop:build": "npm run build && electron-builder",
"desktop:pack": "npm run build && electron-builder --dir"
}
```
The exact script shape can be adjusted once the Electron entry files exist.
## Phase 1: Minimal Desktop Shell
1. Add Electron source files under `electron/`.
2. Configure TypeScript output for the Electron main/preload files.
3. Create a secure `BrowserWindow`:
- `contextIsolation: true`
- `nodeIntegration: false`
- `sandbox: true` if preload needs stay small enough to support it
4. Load `http://localhost:5200` in development.
5. Load the built `dist/index.html` in production.
6. Set app name, icon, minimum window size, and basic lifecycle handling for macOS/Windows/Linux.
7. Add a basic app menu with New/Open/Save/Save As/Export/Quit placeholders.
Acceptance check:
- `npm run desktop:dev` opens the existing app.
- `npm run desktop:pack` creates an unpacked app.
- The normal browser dev/build flows still work.
## Phase 2: Desktop File API
Add IPC-backed file operations instead of immediately rewriting the app's save/load model.
Expose a preload API like:
```ts
window.ruwioDesktop = {
openProject(): Promise<{ name: string; bytes: Uint8Array } | null>,
saveProject(options: { suggestedName: string; bytes: Uint8Array }): Promise<{ path: string } | null>,
saveJson(options: { suggestedName: string; text: string }): Promise<{ path: string } | null>,
exportBlob(options: { suggestedName: string; bytes: Uint8Array }): Promise<{ path: string } | null>
}
```
Renderer changes:
- Add a small desktop adapter module, for example `src/lib/desktopBridge.ts`.
- In `downloadProject` and `downloadRuwFile`, use the desktop bridge when present and fall back to browser downloads otherwise.
- For loading, keep the current `<input type="file">` path as the web fallback and add desktop open dialog support for Electron.
- Preserve existing `.ruw` bundle logic. The desktop API should move bytes to and from disk, not duplicate parsing/packing rules.
Acceptance check:
- Open `.ruw` and legacy `.json` projects from a native dialog.
- Save `.ruw` projects through a native save dialog.
- Browser import/export behavior remains unchanged.
## Phase 3: Native Menus And App State
Wire menu actions to renderer commands through IPC.
Planned menu commands:
- New Project
- Open
- Save
- Save As
- Export HTML/JS bundle
- Export PNG/GIF/APNG/MP4 as available
- Undo/Redo if the existing history store can expose commands
- Toggle fullscreen/preview/test overlay where appropriate
Implementation notes:
- The main process should not mutate project state directly.
- The renderer should own project state and respond to menu events.
- Track the current file path in the desktop bridge or a small Pinia/composable integration.
- Add a dirty-state signal so the main process can confirm before closing a window with unsaved work.
Acceptance check:
- Menu shortcuts trigger the same code paths as toolbar buttons.
- Closing the app warns when there are unsaved changes.
- Recent files can be added after the open/save path is stable.
## Phase 4: Desktop MP4 Rendering
Replace the external render server requirement for desktop builds.
Preferred approach:
1. Move the core render job orchestration out of `server/server.js` into a reusable Node module.
2. Keep the existing Express server as a thin wrapper around that module for web/server deployments.
3. Call the same render module from Electron main via IPC.
4. Stream progress back to the renderer.
5. Save the resulting MP4 through a native save dialog.
Rendering dependencies:
- Bundle or locate Chromium. Electron already ships Chromium, but Puppeteer against Electron's browser runtime needs careful validation. The safer first version is to keep Puppeteer or Playwright with an explicit executable path.
- Bundle or locate ffmpeg. For a consumer desktop app, prefer an embedded ffmpeg binary per platform or document a clear install requirement until bundling is solved.
- Keep temporary frames/pipes in the OS temp directory, as the current server does.
Acceptance check:
- MP4 export works offline in the packaged desktop app.
- Progress, cancel, and failure states match the current server export UX.
- The existing `server/` deployment still runs.
## Phase 5: Packaging And Release
Configure packaging metadata:
- App id, product name, copyright.
- Icons for macOS, Windows, and Linux.
- File associations for `.ruw`.
- Build targets:
- macOS: `.dmg` or `.zip`
- Windows: `nsis`
- Linux: `AppImage` first, optionally `.deb`
- Include `dist/`, Electron compiled files, static assets, and any render binaries.
- Exclude source maps and development-only files unless needed for debugging.
Later release tasks:
- Code signing and notarization for macOS.
- Windows signing.
- Auto-update channel if RUWIO will ship outside app stores.
- CI builds for all target platforms.
Acceptance check:
- A clean checkout can produce a packaged desktop artifact.
- The artifact launches without a local dev server.
- File associations open `.ruw` projects when double-clicked.
## Security Rules
- Keep Node access out of the Vue renderer.
- Use `contextBridge` with a narrow allowlist.
- Validate IPC payloads in the main process.
- Do not pass arbitrary file paths from renderer to main without checks.
- Restrict navigation to the app origin in development and local files in production.
- Disable or guard `window.open`.
- Keep the render process isolated from arbitrary project content where possible, especially if imported SVG/HTML assets can contain script-like payloads.
## Risk Areas
- Asset paths under `file://` may behave differently than Vite dev paths. Test fonts, icons, manifest assets, imported images, audio, and exported runtime files in packaged builds.
- Browser download code is synchronous from the user's perspective, while native save dialogs are async and cancellable. UI states should handle cancellation cleanly.
- MP4 export is the largest unknown because Chromium and ffmpeg packaging varies by platform.
- Large `.ruw` files may put pressure on IPC if sent as huge arrays. If this becomes a problem, switch to main-process file handles or temporary files.
- Existing in-browser tests need an Electron smoke layer because packaged-app failures may not appear in Vite.
## Suggested Implementation Order
1. Add Electron shell and dev/build scripts.
2. Package the current app without native integrations.
3. Add preload typings and a tiny `desktopBridge`.
4. Move project open/save to native dialogs.
5. Add native export save paths for existing browser-generated blobs.
6. Add app menu and unsaved-change handling.
7. Refactor `server/server.js` render logic into a reusable Node module.
8. Add desktop MP4 export through Electron main.
9. Configure packaging icons, file associations, and platform targets.
10. Add CI/package smoke checks.
## First Pull Request Scope
Keep the first PR intentionally small:
- Electron main/preload files.
- Desktop dev and pack scripts.
- Production loading of `dist/index.html`.
- Basic secure window configuration.
- No changes to project save/load or rendering behavior yet.
That gives RUWIO a launchable desktop shell before any app behavior changes, making the rest of the migration easier to test in small increments.
+137
View File
@@ -0,0 +1,137 @@
import { app, BrowserWindow, Menu, shell } from 'electron'
import type { BrowserWindow as BrowserWindowInstance, Event, MenuItemConstructorOptions } from 'electron'
import path from 'node:path'
const isDev = !app.isPackaged
const devServerUrl = process.env.VITE_DEV_SERVER_URL ?? 'http://localhost:5200'
let mainWindow: BrowserWindowInstance | null = null
function createMainWindow(): void {
mainWindow = new BrowserWindow({
width: 1280,
height: 860,
minWidth: 1024,
minHeight: 700,
title: 'RUWIO',
show: false,
backgroundColor: '#14171f',
webPreferences: {
preload: path.join(__dirname, 'preload.cjs'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
})
mainWindow.once('ready-to-show', () => {
mainWindow?.show()
})
mainWindow.webContents.setWindowOpenHandler(({ url }: { url: string }) => {
if (url.startsWith('https://') || url.startsWith('http://')) {
void shell.openExternal(url)
}
return { action: 'deny' }
})
mainWindow.webContents.on('will-navigate', (event: Event, url: string) => {
const allowedUrl = isDev ? devServerUrl : `file://${path.join(app.getAppPath(), 'dist', 'index.html')}`
if (!url.startsWith(allowedUrl)) {
event.preventDefault()
}
})
if (isDev) {
void mainWindow.loadURL(devServerUrl)
mainWindow.webContents.openDevTools({ mode: 'detach' })
} else {
void mainWindow.loadFile(path.join(app.getAppPath(), 'dist', 'index.html'))
}
}
function createMenu(): void {
const template: MenuItemConstructorOptions[] = [
{
label: 'File',
submenu: [
{ label: 'New Project', accelerator: 'CmdOrCtrl+N', enabled: false },
{ label: 'Open...', accelerator: 'CmdOrCtrl+O', enabled: false },
{ type: 'separator' },
{ label: 'Save', accelerator: 'CmdOrCtrl+S', enabled: false },
{ label: 'Save As...', accelerator: 'CmdOrCtrl+Shift+S', enabled: false },
{ type: 'separator' },
{ label: 'Export...', accelerator: 'CmdOrCtrl+E', enabled: false },
{ type: 'separator' },
process.platform === 'darwin' ? { role: 'close' } : { role: 'quit' },
],
},
{
label: 'Edit',
submenu: [
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
{ role: 'selectAll' },
],
},
{
label: 'View',
submenu: [
{ role: 'reload' },
{ role: 'forceReload' },
{ role: 'toggleDevTools' },
{ type: 'separator' },
{ role: 'resetZoom' },
{ role: 'zoomIn' },
{ role: 'zoomOut' },
{ type: 'separator' },
{ role: 'togglefullscreen' },
],
},
{
label: 'Window',
submenu: [
{ role: 'minimize' },
{ role: 'zoom' },
],
},
]
if (process.platform === 'darwin') {
template.unshift({
label: app.name,
submenu: [
{ role: 'about' },
{ type: 'separator' },
{ role: 'services' },
{ type: 'separator' },
{ role: 'hide' },
{ role: 'hideOthers' },
{ role: 'unhide' },
{ type: 'separator' },
{ role: 'quit' },
],
})
}
Menu.setApplicationMenu(Menu.buildFromTemplate(template))
}
app.setName('RUWIO')
app.whenReady().then(() => {
createMenu()
createMainWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createMainWindow()
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})
+10
View File
@@ -0,0 +1,10 @@
import { contextBridge } from 'electron'
contextBridge.exposeInMainWorld('ruwioDesktop', {
platform: process.platform,
versions: {
electron: process.versions.electron,
chrome: process.versions.chrome,
node: process.versions.node,
},
})
+3782 -3
View File
File diff suppressed because it is too large. Load diff
+39 -1
View File
@@ -3,9 +3,14 @@
"private": true, "private": true,
"version": "1.2.13", "version": "1.2.13",
"type": "module", "type": "module",
"main": "dist-electron/main.cjs",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vue-tsc --noEmit && vite build", "build": "vue-tsc --noEmit && vite build",
"desktop:dev": "concurrently -k \"vite\" \"wait-on http://localhost:5200 && npm run desktop:start\"",
"desktop:start": "tsc -p tsconfig.electron.json && electron .",
"desktop:build": "npm run build && tsc -p tsconfig.electron.json && electron-builder",
"desktop:pack": "npm run build && tsc -p tsconfig.electron.json && electron-builder --dir",
"preview": "vite preview", "preview": "vite preview",
"type-check": "vue-tsc --noEmit", "type-check": "vue-tsc --noEmit",
"pack": "bash scripts/pack.sh" "pack": "bash scripts/pack.sh"
@@ -22,8 +27,41 @@
"devDependencies": { "devDependencies": {
"@types/node": "^22.10.2", "@types/node": "^22.10.2",
"@vitejs/plugin-vue": "^5.2.1", "@vitejs/plugin-vue": "^5.2.1",
"concurrently": "^9.2.1",
"electron": "^42.3.3",
"electron-builder": "^26.8.1",
"typescript": "^5.6.3", "typescript": "^5.6.3",
"vite": "^6.0.3", "vite": "^6.0.3",
"vue-tsc": "^2.1.10" "vue-tsc": "^2.1.10",
"wait-on": "^8.0.5"
},
"build": {
"appId": "app.ruwio.desktop",
"productName": "RUWIO",
"directories": {
"output": "release"
},
"files": [
"dist/**/*",
"dist-electron/**/*",
"package.json"
],
"linux": {
"target": [
"AppImage"
],
"category": "Graphics"
},
"win": {
"target": [
"nsis"
]
},
"mac": {
"target": [
"dmg"
],
"category": "public.app-category.graphics-design"
}
} }
} }
+33 -26
View File
@@ -561,36 +561,35 @@ function kaleidoIndicesForLayer(layer: Layer): number[] {
} }
/** Augments a layer's Konva config with the kaleido per-copy /** Augments a layer's Konva config with the kaleido per-copy
* position + rotation. Pivot defaults to the layer's own (x, y), * position + rotation. Pivot lookup matches render.ts: live drag
* but a per-layer override moves the orbit centre elsewhere on the * override → keyframed `props.kaleidoPivotX/Y` → static layer
* canvas — same arithmetic as render.ts/exportPlayer use, so all * override → layer's own (x, y). Non-source copies are
* three render paths land identical pixels. Non-source copies are
* listening:false to keep events on the source. */ * listening:false to keep events on the source. */
function withK<T extends Record<string, unknown>>( function withK<T extends Record<string, unknown>>(
cfg: T, cfg: T,
layer: Layer, layer: Layer,
props: AnimatableProps,
ki: number, ki: number,
n: number, n: number,
): T { ): T {
if (n <= 1 || ki === 0) return cfg 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 live = liveKaleidoPivot.value
const pivotX = live && live.instanceId === layer.id ? live.x : (layer.kaleidoPivotX ?? sx) const liveOverride = live && live.instanceId === layer.id
const pivotY = live && live.instanceId === layer.id ? live.y : (layer.kaleidoPivotY ?? sy) 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 angle = kaleidoAngleDegrees(ki, n)
const a = (angle * Math.PI) / 180 const a = (angle * Math.PI) / 180
const dx = sx - pivotX const dx = props.x - pivotX
const dy = sy - pivotY const dy = props.y - pivotY
return { return {
...cfg, ...cfg,
x: pivotX + dx * Math.cos(a) - dy * Math.sin(a), x: pivotX + dx * Math.cos(a) - dy * Math.sin(a),
y: pivotY + dx * Math.sin(a) + dy * Math.cos(a), y: pivotY + dx * Math.sin(a) + dy * Math.cos(a),
rotation: srot + angle, rotation: props.rotation + angle,
listening: false, listening: false,
draggable: false, draggable: false,
} as T } as T
@@ -1683,8 +1682,14 @@ const kaleidoPivotHandle = computed<KaleidoPivotEntity | null>(() => {
if (asset?.kind === 'button') return null if (asset?.kind === 'button') return null
const sampled = sampleLayer(layer, store.playhead) const sampled = sampleLayer(layer, store.playhead)
const live = liveKaleidoPivot.value const live = liveKaleidoPivot.value
const x = live && live.instanceId === lyId ? live.x : (layer.kaleidoPivotX ?? sampled.x) // Pivot lookup matches render.ts: live drag → keyframed prop →
const y = live && live.instanceId === lyId ? live.y : (layer.kaleidoPivotY ?? sampled.y) // 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 { kind: 'layer', layerId: layer.id, x, y }
} }
return null return null
@@ -1752,9 +1757,11 @@ function onKaleidoPivotPointerUp() {
kaleidoPivotY: liveKaleidoPivot.value.y, kaleidoPivotY: liveKaleidoPivot.value.y,
}) })
} else { } else {
store.setLayerKaleidoPivot(pivotDragEntity.layerId, { // Layer pivot is keyframable. Upsert at the current playhead
x: liveKaleidoPivot.value.x, // so dropping multiple kfs animates the orbit centre.
y: liveKaleidoPivot.value.y, store.upsertKeyframe(pivotDragEntity.layerId, store.playhead, {
kaleidoPivotX: liveKaleidoPivot.value.x,
kaleidoPivotY: liveKaleidoPivot.value.y,
}) })
} }
} }
@@ -4496,7 +4503,7 @@ function onResizeHandlePointerUp(e: PointerEvent) {
> >
<v-group <v-group
v-if="entry.item.asset.kind === 'svg' && svgAssetFor(entry.item.asset)" v-if="entry.item.asset.kind === 'svg' && svgAssetFor(entry.item.asset)"
:config="withK(buttonLayerGroupConfig(entry.item.layer, entry.item.props), entry.item.layer, ki, kaleidoCountFor(entry.item.layer))" :config="withK(buttonLayerGroupConfig(entry.item.layer, entry.item.props), entry.item.layer, entry.item.props, ki, kaleidoCountFor(entry.item.layer))"
@dragmove="handleDragMove" @dragmove="handleDragMove"
@dragend="handleDragEnd" @dragend="handleDragEnd"
@transform="handleTransform" @transform="handleTransform"
@@ -4581,7 +4588,7 @@ function onResizeHandlePointerUp(e: PointerEvent) {
</v-group> </v-group>
<v-rect <v-rect
v-else-if="entry.item.asset.kind === 'rect'" v-else-if="entry.item.asset.kind === 'rect'"
:config="withK(rectConfig(entry.item.layer, entry.item.asset, entry.item.props), entry.item.layer, ki, kaleidoCountFor(entry.item.layer))" :config="withK(rectConfig(entry.item.layer, entry.item.asset, entry.item.props), entry.item.layer, entry.item.props, ki, kaleidoCountFor(entry.item.layer))"
@dragmove="handleDragMove" @dragmove="handleDragMove"
@dragend="handleDragEnd" @dragend="handleDragEnd"
@transform="handleTransform" @transform="handleTransform"
@@ -4589,7 +4596,7 @@ function onResizeHandlePointerUp(e: PointerEvent) {
/> />
<v-circle <v-circle
v-else-if="entry.item.asset.kind === 'circle'" v-else-if="entry.item.asset.kind === 'circle'"
:config="withK(circleConfig(entry.item.layer, entry.item.asset, entry.item.props), entry.item.layer, ki, kaleidoCountFor(entry.item.layer))" :config="withK(circleConfig(entry.item.layer, entry.item.asset, entry.item.props), entry.item.layer, entry.item.props, ki, kaleidoCountFor(entry.item.layer))"
@dragmove="handleDragMove" @dragmove="handleDragMove"
@dragend="handleDragEnd" @dragend="handleDragEnd"
@transform="handleTransform" @transform="handleTransform"
@@ -4597,7 +4604,7 @@ function onResizeHandlePointerUp(e: PointerEvent) {
/> />
<v-text <v-text
v-else-if="entry.item.asset.kind === 'text'" v-else-if="entry.item.asset.kind === 'text'"
:config="withK(textConfig(entry.item.layer, entry.item.asset, entry.item.props), entry.item.layer, ki, kaleidoCountFor(entry.item.layer))" :config="withK(textConfig(entry.item.layer, entry.item.asset, entry.item.props), entry.item.layer, entry.item.props, ki, kaleidoCountFor(entry.item.layer))"
@dragmove="handleDragMove" @dragmove="handleDragMove"
@dragend="handleDragEnd" @dragend="handleDragEnd"
@transform="handleTransform" @transform="handleTransform"
@@ -4605,7 +4612,7 @@ function onResizeHandlePointerUp(e: PointerEvent) {
/> />
<v-line <v-line
v-else-if="entry.item.asset.kind === 'polygon'" v-else-if="entry.item.asset.kind === 'polygon'"
:config="withK(polygonConfig(entry.item.layer, entry.item.asset, entry.item.props), entry.item.layer, ki, kaleidoCountFor(entry.item.layer))" :config="withK(polygonConfig(entry.item.layer, entry.item.asset, entry.item.props), entry.item.layer, entry.item.props, ki, kaleidoCountFor(entry.item.layer))"
@dragmove="handleDragMove" @dragmove="handleDragMove"
@dragend="handleDragEnd" @dragend="handleDragEnd"
@transform="handleTransform" @transform="handleTransform"
@@ -4613,7 +4620,7 @@ function onResizeHandlePointerUp(e: PointerEvent) {
/> />
<v-image <v-image
v-else-if="entry.item.asset.kind === 'image' && imageConfig(entry.item.layer, entry.item.asset, entry.item.props)" v-else-if="entry.item.asset.kind === 'image' && imageConfig(entry.item.layer, entry.item.asset, entry.item.props)"
:config="withK(imageConfig(entry.item.layer, entry.item.asset, entry.item.props)!, entry.item.layer, ki, kaleidoCountFor(entry.item.layer))" :config="withK(imageConfig(entry.item.layer, entry.item.asset, entry.item.props)!, entry.item.layer, entry.item.props, ki, kaleidoCountFor(entry.item.layer))"
@dragmove="handleDragMove" @dragmove="handleDragMove"
@dragend="handleDragEnd" @dragend="handleDragEnd"
@transform="handleTransform" @transform="handleTransform"
+52 -14
View File
@@ -164,22 +164,26 @@ function isActive(id: string): boolean {
<div v-if="customFonts.length > 0" class="font-popover-section"> <div v-if="customFonts.length > 0" class="font-popover-section">
<div class="section-title">Your fonts</div> <div class="section-title">Your fonts</div>
<button <div
v-for="f in customFonts" v-for="f in customFonts"
:key="f.id" :key="f.id"
type="button" class="custom-font-row"
class="font-option"
:class="{ active: isActive(f.id) }" :class="{ active: isActive(f.id) }"
role="option"
:aria-selected="isActive(f.id)"
@click="pick(f.id)"
> >
<PhCheck <button
:size="12" type="button"
class="check" class="font-option custom-font-select"
:style="{ visibility: isActive(f.id) ? 'visible' : 'hidden' }" role="option"
/> :aria-selected="isActive(f.id)"
<span class="option-label" :style="{ fontFamily: f.family }">{{ f.label }}</span> @click="pick(f.id)"
>
<PhCheck
:size="12"
class="check"
:style="{ visibility: isActive(f.id) ? 'visible' : 'hidden' }"
/>
<span class="option-label" :style="{ fontFamily: f.family }">{{ f.label }}</span>
</button>
<button <button
type="button" type="button"
class="delete-custom" class="delete-custom"
@@ -188,7 +192,7 @@ function isActive(id: string): boolean {
> >
<PhTrash :size="12" /> <PhTrash :size="12" />
</button> </button>
</button> </div>
</div> </div>
<div class="font-popover-divider"></div> <div class="font-popover-divider"></div>
@@ -327,6 +331,38 @@ function isActive(id: string): boolean {
color: var(--accent); 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 { .font-popover .check {
flex-shrink: 0; flex-shrink: 0;
} }
@@ -352,10 +388,12 @@ function isActive(id: string): boolean {
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
color: var(--text-dim); color: var(--text-dim);
cursor: pointer; cursor: pointer;
margin-right: 4px;
opacity: 0; 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; opacity: 1;
} }
+17 -3
View File
@@ -225,17 +225,25 @@ const kaleidoOn = computed(() => !!selected.value?.kaleido)
const kaleidoCountValue = computed(() => selected.value?.kaleidoCount ?? 6) 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 kaleidoPivotX = computed(() => {
const l = selected.value const l = selected.value
if (!l) return 0 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) 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 kaleidoPivotY = computed(() => {
const l = selected.value const l = selected.value
if (!l) return 0 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) 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) { function setKaleidoOn(on: boolean) {
@@ -243,9 +251,15 @@ function setKaleidoOn(on: boolean) {
store.setLayerKaleido(selected.value.id, on) 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 }) { function setKaleidoPivot(patch: { x?: number; y?: number }) {
if (!selected.value) return if (!selected.value) return
store.setLayerKaleidoPivot(selected.value.id, patch) const props: Partial<import('@/types/scene').AnimatableProps> = {}
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) const wiggleOn = computed(() => !!selected.value?.wiggle)
+111 -12
View File
@@ -15554,7 +15554,7 @@ export function registerAllTests(): void {
expect(src).toContain('clipKaleidoPivot') expect(src).toContain('clipKaleidoPivot')
expect(src).toContain('clipArmLocalT') expect(src).toContain('clipArmLocalT')
// Runtime version got bumped during the kaleido + wiggle work. // 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 () => { test('exported runtime layerActiveAt is unaffected by kaleido fields', async () => {
@@ -15932,7 +15932,7 @@ export function registerAllTests(): void {
expect(src).toContain('function wiggleSeeds(') expect(src).toContain('function wiggleSeeds(')
// Runtime version got bumped through the wiggle work and the // Runtime version got bumped through the wiggle work and the
// later layer-kaleido-pivot landing. // 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', () => { suite('layer kaleido pivot — separate orbit centre', () => {
test('layerArmTransform: defaults to sampled position, custom pivot rotates around override', async () => { test('layerArmTransform: defaults to sampled position, custom pivot rotates around override', async () => {
const { layerArmTransform } = await import('@/lib/kaleido') 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 // Layer at (100, 100), no custom pivot → arm 1 of 4 rotates
// 90° around (100, 100): same result as no-pivot baseline. // 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 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. // dx=dy=0 → arm 1 stays at pivot.
expect(a.x).toBeCloseTo(100, 5) expect(a.x).toBeCloseTo(100, 5)
expect(a.y).toBeCloseTo(100, 5) expect(a.y).toBeCloseTo(100, 5)
expect(a.rotation).toBeCloseTo(90, 5) expect(a.rotation).toBeCloseTo(90, 5)
// Custom pivot at (50, 50) shifts the orbit centre. Layer at // Static legacy override at (50, 50): same rotation, layer at
// (100, 100), arm 1 of 4 (90°) around (50, 50): // (100, 100). Arm 1 (90°): dx=50, dy=50 → after R(90°): (-50, 50)
// dx=50, dy=50 → rotated by 90° → dx'=-50, dy'=50 → world (0, 100) // → world (0, 100).
const layerWithPivot = { const layerWithPivot = {
id: 'L', kaleido: true, kaleidoCount: 4, kaleidoPivotX: 50, kaleidoPivotY: 50, id: 'L', kaleido: true, kaleidoCount: 4, kaleidoPivotX: 50, kaleidoPivotY: 50,
} as unknown as import('@/types/scene').Layer } 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.x).toBeCloseTo(0, 5)
expect(b.y).toBeCloseTo(100, 5) expect(b.y).toBeCloseTo(100, 5)
expect(b.rotation).toBeCloseTo(90, 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', () => { test('store.setLayerKaleidoPivot: patch + null clear', () => {
@@ -16322,14 +16337,20 @@ export function registerAllTests(): void {
expect(pivotInputs.length).toBe(2) expect(pivotInputs.length).toBe(2)
expect(Number(pivotInputs[0].value)).toBe(50) expect(Number(pivotInputs[0].value)).toBe(50)
expect(Number(pivotInputs[1].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].value = '120'
pivotInputs[0].dispatchEvent(new Event('input')) pivotInputs[0].dispatchEvent(new Event('input'))
await nextTick() await nextTick()
const stored = s.scene.layers.find((l) => l.id === layer.id)! const stored = s.scene.layers.find((l) => l.id === layer.id)!
expect(stored.kaleidoPivotX).toBe(120) // Find the upserted keyframe at the playhead.
// Y stays at the sampled default until the user touches it. const kf = stored.keyframes.find((k) => Math.abs(k.time - s.playhead) < 1e-3)
expect(stored.kaleidoPivotY === undefined).toBe(true) 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 { } finally {
handle.unmount() 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', () => { test('exported runtime engine carries layerArmTf helper', () => {
const src = generateRuntimeScript() const src = generateRuntimeScript()
expect(src).toContain('function layerArmTf(') expect(src).toContain('function layerArmTf(')
// Bumped on this work. // Bumped on this work.
expect(src).toContain("'33'") expect(src).toContain("'34'")
}) })
}) })
} }
+27 -12
View File
@@ -11,7 +11,7 @@ function isProject(p: ExportPayload): p is Project {
return Array.isArray((p as Partial<Project>).scenes) return Array.isArray((p as Partial<Project>).scenes)
} }
const RUNTIME_VERSION = '33' const RUNTIME_VERSION = '34'
/** Materialize the engine source. The font map is rebuilt on every /** Materialize the engine source. The font map is rebuilt on every
* call so user-uploaded custom fonts (which live in localStorage * 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); if(a.shadowColor && b.shadowColor) out.shadowColor=lerpColor(a.shadowColor,b.shadowColor,t);
else if(b.shadowColor) out.shadowColor=b.shadowColor; else if(b.shadowColor) out.shadowColor=b.shadowColor;
else if(a.shadowColor) out.shadowColor=a.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; return out;
} }
// Interpolate a single keyframe track against 'base' at 'time'. // Interpolate a single keyframe track against 'base' at 'time'.
@@ -736,20 +747,24 @@ const ENGINE_TEMPLATE = `(function(){
return n<2?1:n; return n<2?1:n;
} }
function kalAngle(i,n){ return n<=1?0:(i*360)/n; } function kalAngle(i,n){ return n<=1?0:(i*360)/n; }
// Mirror of layerArmTransform in src/lib/kaleido.ts. Defaults the // Mirror of layerArmTransform in src/lib/kaleido.ts. Pivot lookup:
// pivot to the layer's sampled (x, y); a per-layer override can // sampled props (keyframed) → layer's static legacy override →
// shift it elsewhere on the canvas. // layer's own sampled (x, y).
function layerArmTf(layer, sx, sy, srot, i, n){ function layerArmTf(layer, props, i, n){
if(i===0||n<=1) return {x:sx,y:sy,rotation:srot}; if(i===0||n<=1) return {x:props.x,y:props.y,rotation:props.rotation};
var px=(layer.kaleidoPivotX==null?sx:layer.kaleidoPivotX); var ppx=(props.kaleidoPivotX==null?null:props.kaleidoPivotX);
var py=(layer.kaleidoPivotY==null?sy:layer.kaleidoPivotY); 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 ang=kalAngle(i,n);
var a=ang*Math.PI/180; var a=ang*Math.PI/180;
var dx=sx-px, dy=sy-py; var dx=props.x-px, dy=props.y-py;
return { return {
x: px+dx*Math.cos(a)-dy*Math.sin(a), x: px+dx*Math.cos(a)-dy*Math.sin(a),
y: py+dx*Math.sin(a)+dy*Math.cos(a), y: py+dx*Math.sin(a)+dy*Math.cos(a),
rotation: srot+ang rotation: props.rotation+ang
}; };
} }
function clipKaleidoPivot(inst){ function clipKaleidoPivot(inst){
@@ -833,7 +848,7 @@ const ENGINE_TEMPLATE = `(function(){
var p=sample(layer,time); var p=sample(layer,time);
var kn=kalCount(layer); var kn=kalCount(layer);
for(var ki=0;ki<kn;ki++){ for(var ki=0;ki<kn;ki++){
var tfm=layerArmTf(layer, p.x, p.y, p.rotation, ki, kn); var tfm=layerArmTf(layer, p, ki, kn);
ctxm.save(); ctxm.save();
ctxm.translate(tfm.x,tfm.y); ctxm.translate(tfm.x,tfm.y);
ctxm.rotate(tfm.rotation*Math.PI/180); ctxm.rotate(tfm.rotation*Math.PI/180);
@@ -932,7 +947,7 @@ const ENGINE_TEMPLATE = `(function(){
// areas would break click routing. // areas would break click routing.
var kn=(asset.kind==='button')?1:kalCount(layer); var kn=(asset.kind==='button')?1:kalCount(layer);
for(var ki=0;ki<kn;ki++){ for(var ki=0;ki<kn;ki++){
var tfL=layerArmTf(layer, p.x, p.y, p.rotation, ki, kn); var tfL=layerArmTf(layer, p, ki, kn);
ctx.save(); ctx.save();
ctx.globalAlpha=ctx.globalAlpha*p.opacity; ctx.globalAlpha=ctx.globalAlpha*p.opacity;
ctx.translate(tfL.x,tfL.y); ctx.translate(tfL.x,tfL.y);
+16
View File
@@ -69,6 +69,22 @@ export function lerpProps(a: AnimatableProps, b: AnimatableProps, t: number): An
if (a.strokeWidth !== undefined && b.strokeWidth !== undefined) out.strokeWidth = lerp(a.strokeWidth, b.strokeWidth, t) if (a.strokeWidth !== undefined && b.strokeWidth !== undefined) out.strokeWidth = lerp(a.strokeWidth, b.strokeWidth, t)
else if (b.strokeWidth !== undefined) out.strokeWidth = b.strokeWidth else if (b.strokeWidth !== undefined) out.strokeWidth = b.strokeWidth
else if (a.strokeWidth !== undefined) out.strokeWidth = a.strokeWidth else if (a.strokeWidth !== undefined) out.strokeWidth = a.strokeWidth
// Kaleido pivot defaults to the layer's own (x, y) when unset on a
// keyframe — so a one-sided override (only b has kaleidoPivotX, a
// doesn't) lerps from a.x → b.kaleidoPivotX instead of snapping to
// b's value at t=0+. Same on the reverse direction.
{
const ax = a.kaleidoPivotX
const bx = b.kaleidoPivotX
if (ax !== undefined || bx !== undefined) {
out.kaleidoPivotX = lerp(ax ?? a.x, bx ?? b.x, t)
}
const ay = a.kaleidoPivotY
const by = b.kaleidoPivotY
if (ay !== undefined || by !== undefined) {
out.kaleidoPivotY = lerp(ay ?? a.y, by ?? b.y, t)
}
}
if (a.points && b.points && a.points.length === b.points.length) { if (a.points && b.points && a.points.length === b.points.length) {
out.points = a.points.map((pa, i) => { out.points = a.points.map((pa, i) => {
const pb = b.points![i] const pb = b.points![i]
+21 -18
View File
@@ -4,7 +4,7 @@
// (lib/exportPlayer.ts engine source — by mirroring the formulas in // (lib/exportPlayer.ts engine source — by mirroring the formulas in
// JS) all derive identical geometry from the same definitions. // 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 /** 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 * 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) return kaleidoCountFor(layer)
} }
/** Effective pivot for a layer. Defaults to the layer's sampled /** Effective pivot for a layer. Lookup chain:
* position; an explicit `kaleidoPivotX/Y` overrides per-axis. */ * 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( export function layerKaleidoPivot(
layer: Layer, layer: Layer,
sampledX: number, props: AnimatableProps,
sampledY: number,
): { x: number; y: number } { ): { x: number; y: number } {
return { return {
x: layer.kaleidoPivotX ?? sampledX, x: props.kaleidoPivotX ?? layer.kaleidoPivotX ?? props.x,
y: layer.kaleidoPivotY ?? sampledY, y: props.kaleidoPivotY ?? layer.kaleidoPivotY ?? props.y,
} }
} }
/** World-space (x, y, rotation) for arm `index` of a kaleido'd /** World-space (x, y, rotation) for arm `index` of a kaleido'd
* layer. The source (index 0) is unchanged. Higher indices * layer. The source (index 0) is unchanged. Higher indices rotate
* rotate around the pivot — both their position AND their * around the pivot — both their position AND their orientation,
* orientation, since layer kaleido always rotates copies. */ * since layer kaleido always rotates copies. */
export function layerArmTransform( export function layerArmTransform(
layer: Layer, layer: Layer,
sampledX: number, props: AnimatableProps,
sampledY: number,
sampledRotation: number,
index: number, index: number,
count: number, count: number,
): { x: number; y: number; rotation: number } { ): { x: number; y: number; rotation: number } {
if (index === 0 || count <= 1) { 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 angle = kaleidoAngleDegrees(index, count)
const a = (angle * Math.PI) / 180 const a = (angle * Math.PI) / 180
const dx = sampledX - px const dx = props.x - px
const dy = sampledY - py const dy = props.y - py
return { return {
x: px + dx * Math.cos(a) - dy * Math.sin(a), x: px + dx * Math.cos(a) - dy * Math.sin(a),
y: py + dx * Math.sin(a) + dy * Math.cos(a), y: py + dx * Math.sin(a) + dy * Math.cos(a),
rotation: sampledRotation + angle, rotation: props.rotation + angle,
} }
} }
+2 -2
View File
@@ -164,7 +164,7 @@ function drawLayers(
// their own internal layers and click routing must stay 1:1). // their own internal layers and click routing must stay 1:1).
const kCount = asset.kind === 'button' ? 1 : layerKaleidoCount(layer) const kCount = asset.kind === 'button' ? 1 : layerKaleidoCount(layer)
for (let kf = 0; kf < kCount; kf++) { 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.save()
ctx.globalAlpha *= p.opacity ctx.globalAlpha *= p.opacity
ctx.translate(tf.x, tf.y) ctx.translate(tf.x, tf.y)
@@ -361,7 +361,7 @@ function drawMaskShape(
// N rotated shapes. // N rotated shapes.
const kCount = layerKaleidoCount(layer) const kCount = layerKaleidoCount(layer)
for (let kf = 0; kf < kCount; kf++) { 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.save()
ctx.translate(tf.x, tf.y) ctx.translate(tf.x, tf.y)
ctx.rotate((tf.rotation * Math.PI) / 180) ctx.rotate((tf.rotation * Math.PI) / 180)
+1 -1
View File
@@ -22,7 +22,7 @@ app.mount('#app')
// In Chrome/Edge, an installed PWA runs in standalone display mode, // In Chrome/Edge, an installed PWA runs in standalone display mode,
// which removes the top-edge "exit fullscreen" indicator that tabs // which removes the top-edge "exit fullscreen" indicator that tabs
// show when the Fullscreen API is active. // show when the Fullscreen API is active.
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator && !window.ruwioDesktop) {
window.addEventListener('load', () => { window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => { navigator.serviceWorker.register('/sw.js').catch(() => {
/* registration failed — app still works without PWA install */ /* registration failed — app still works without PWA install */
+14
View File
@@ -0,0 +1,14 @@
export {}
declare global {
interface Window {
ruwioDesktop?: {
platform: 'aix' | 'android' | 'darwin' | 'freebsd' | 'haiku' | 'linux' | 'openbsd' | 'sunos' | 'win32' | 'cygwin' | 'netbsd'
versions: {
electron?: string
chrome?: string
node?: string
}
}
}
}
+6
View File
@@ -29,6 +29,12 @@ export interface AnimatableProps {
points?: Point[] points?: Point[]
stroke?: string stroke?: string
strokeWidth?: number 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 { export interface Keyframe {
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node", "electron"],
"outDir": "dist-electron",
"rootDir": "electron",
"sourceMap": false
},
"include": ["electron/**/*.cts"]
}