9 Commits
Author SHA1 Message Date
kawa 298ca15b97 Bump version
Sync Gitea releases to GitHub / sync-releases (push) Waiting to run
2026-08-14 10:26:02 +02:00
kawa 1996647dfe Merge pull request 'Fix/upstream open bugs' (#2) from fix/upstream-open-bugs into main
Sync Gitea releases to GitHub / sync-releases (push) Waiting to run
Reviewed-on: #2
2026-08-14 10:22:45 +02:00
kawaandClaude Haiku 4.5 45c37a5e73 feat: resizable side panels, audio layer fixes
- Add horizontal resize handles for browser and properties panels with localStorage persistence
- Support hide/show toggles for each side panel
- Fix audio layers not being interactive on canvas (hasControls, hasBorders, evented flags)
- Convert hardcoded panel widths to CSS variables for responsive layout
- Support narrower windows by shrinking panels while preserving minimum canvas width
- Ensure old projects with audio layers don't show phantom control boxes at 0,0

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-14 09:19:36 +02:00
kawaandClaude Opus 5 fe57429603 fix: crop rotated images, radius in px, modals block timeline
Replays the 13 open bug reports on alyssaxuu/motionity against this fork.
Nine were already fixed by the earlier audit and one (#8, video from the
search tab) needs a Pixabay API key to reproduce; these are the three that
were still broken.

#23 - border radius behaved like a percentage. fabric applies rx/ry before
the object's scale and shapes are resized by scaling, so a radius typed as
20 drew at 60px on a rect scaled 3x while the panel still read 20. A new
cornerRadius property keeps the pixel value the user asked for, rx/ry are
derived from it and re-derived whenever the scale changes, and old projects
fall back to rx * scaleX.

#27 - cropping a rotated image cropped the wrong region. crop() compared
canvas-space edges and covered only three of the four quadrants, so it did
nothing at all when the crop window was centred on the image. It now works
in the image's own frame, clamps the region to the bitmap and re-centres the
object on what it kept; the crop window carries the image's angle and the
expand-to-full-bitmap shift in cropImage is rotated the same way.

#1 - modals are painted over the timeline but never took its pointer events,
so the resize handle, seekbar, keyframes and layer bars all reacted to a drag
behind the dialog. All four now bail while a modal is open.

Verified in Chromium: 20px draws a 20px corner at scale 1 and scale 3 and
survives save, JSON round-trip and reload; at 0, 30 and 45 degrees the pixels
under the crop window are unchanged by the crop and everything outside it is
dropped; a real drag on the timeline handle with the export modal open leaves
the timeline height untouched. WEBM export and a non-rotated crop still work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 17:28:50 +02:00
kawa 094bfbdc60 Updated links pointing to motionity.app > motionity.kawa.zip
Sync Gitea releases to GitHub / sync-releases (push) Canceled after 0s
2026-08-13 16:00:46 +02:00
kawa 414e0ee30c Updated the credits with links to the contributor's git pages
Sync Gitea releases to GitHub / sync-releases (push) Canceled after 0s
2026-08-13 15:33:51 +02:00
kawa cc439c1152 Modified publish pipeline for WSL (flatpak, appimage builds)
Sync Gitea releases to GitHub / sync-releases (push) Canceled after 0s
2026-08-13 15:13:42 +02:00
kawa e775ea478f Made the README.md nicer
Sync Gitea releases to GitHub / sync-releases (push) Canceled after 0s
2026-08-13 14:05:41 +02:00
kawa dcb804e48e Bumped package.json version
Sync Gitea releases to GitHub / sync-releases (push) Canceled after 0s
2026-08-13 13:02:26 +02:00
12 changed files with 1083 additions and 160 deletions
+84 -18
View File
@@ -69,6 +69,28 @@ Each `dist:*` script re-runs `vendor` and regenerates `build/icon.png`
(`scripts/make-icon.cjs` rasterises the logo geometry with zlib only — no (`scripts/make-icon.cjs` rasterises the logo geometry with zlib only — no
ImageMagick, no sharp). ImageMagick, no sharp).
### Why `desktopName` is `app.motionity.desktop.desktop`
The doubled suffix is correct, do not trim it. Electron reads the **root-level**
`desktopName` from `package.json` and derives the Wayland `app_id` / X11
`WM_CLASS` from it with the `.desktop` suffix stripped, so the value has to be
the desktop *file name*, not the app id. Flatpak in turn installs the entry as
`<appId>.desktop` and nothing can rename it — with `appId`
`app.motionity.desktop`, the file is `app.motionity.desktop.desktop`.
`linux.syncDesktopName: true` makes electron-builder use the same base name for
the AppImage's embedded entry and write a matching `StartupWMClass`. Without the
pair, the build warns
```
electron uses desktopName as app_id / WM_CLASS for window association.
reason=desktopName is not set in package.json
```
and the running window is not linked to its launcher entry: GNOME shows a
generic icon and a second, unpinnable dock item instead of the installed app.
Changing `appId` means changing `desktopName` in step with it.
### If the NSIS step fails with "Access denied" on `Motionity.exe` ### If the NSIS step fails with "Access denied" on `Motionity.exe`
On a locked-down Windows machine the security agent can take an exclusive lock On a locked-down Windows machine the security agent can take an exclusive lock
@@ -102,22 +124,58 @@ $env:ELECTRON_RUN_AS_NODE=$null; npm run dev # PowerShell
### Is WSL enough for AppImage and Flatpak? ### Is WSL enough for AppImage and Flatpak?
**AppImage: yes.** electron-builder produces the squashfs itself, so no FUSE is Yes, and `build-release.ps1 -UseWsl` does it for you from Windows:
needed at build time. To *run* the result inside WSL you need `libfuse2`
(or `./Motionity-1.0.0-x64.AppImage --appimage-extract-and-run`), and WSLg on
Windows 11 gives you the GUI.
**Flatpak: technically yes, practically annoying.** `flatpak-builder` runs under ```powershell
WSL2 (the kernel has the user namespaces and `/dev/fuse` that bubblewrap needs), ./scripts/build-release.ps1 -UseWsl # .exe on Windows, Linux bundles in WSL
but you must install the runtimes by hand first and there is no ./scripts/build-release.ps1 -Targets linux -UseWsl # Linux bundles only
`xdg-desktop-portal` to fall back on. If it fights you, build it in a Linux ./scripts/publish.ps1 -Tag v2.0.1 -PublishRelease -UseWsl # same, then attach to the Gitea release
container instead — it is the same command with fewer moving parts. ```
Both bundles have been built this way on this machine (`Ubuntu`, WSL2 kernel
6.18): a 172 MB AppImage and a 133 MB Flatpak, checksums verified with
`sha256sum -c`. Under the hood:
- the `npm ci`, `vendor` and icon steps run **once on the Windows side**, and the
WSL build reads them back through `/mnt/c` — one worktree, no second checkout;
- only `linux-appimage` and `linux-flatpak` are delegated. `-UseWsl` on a Linux
host, or with no Linux target, warns and changes nothing;
- the distro is the first installed one that is not `docker-desktop`, override
with `-WslDistro`. `docker-desktop` is skipped deliberately: it is Docker's own
LinuxKit VM, with no apt and no home to install the flatpak runtimes into, and
it is usually the *default* distro, so a blind `wsl --` lands there;
- missing tooling fails **before** the build with the apt or `flatpak install`
line to run, because electron-builder's own error for an absent flatpak ref is
a bare exit code naming neither the ref nor the remote.
**Why the build stages in `~/.cache/motionity-build` and not in `dist/`.**
electron-builder chmods every file it unpacks from the Electron zip, and `/mnt/c`
is mounted without the `metadata` option, so chmod is refused:
```
EPERM: operation not permitted, chmod '.../dist/linux-unpacked.tmp/locales/de.pak'
```
The alternative fix is `options = "metadata"` under `[automount]` in
`/etc/wsl.conf` plus a `wsl --shutdown` — a global, sudo-and-reboot change to the
distro. Building into ext4 and copying the two finished bundles back into `dist/`
needs neither, and is faster anyway. Reading `src/` over the mount is fine;
nothing chmods the input. The copy back is `cp -f`, never `cp -p` — preserving
modes means chmod, which is the EPERM being avoided.
**AppImage** needs no extra tooling: electron-builder downloads its own appimage
bundle and writes the squashfs itself, no FUSE at build time. To *run* the result
inside WSL you need `libfuse2` (or
`./motionity-*.AppImage --appimage-extract-and-run`); WSLg gives you the GUI.
**Flatpak** needs `flatpak-builder` and the runtimes installed by hand — the
WSL2 kernel has the user namespaces and `/dev/fuse` that bubblewrap wants, but
there is no `xdg-desktop-portal` to fall back on.
**`.exe`: no.** Build it on the Windows side. Cross-building NSIS from Linux **`.exe`: no.** Build it on the Windows side. Cross-building NSIS from Linux
needs Wine and rules out signing. needs Wine and rules out signing.
This machine currently has no WSL distro other than `docker-desktop`, so the One-time distro setup, from PowerShell:
Linux targets have not been run here. Setup, from PowerShell:
```powershell ```powershell
wsl --install -d Ubuntu wsl --install -d Ubuntu
@@ -127,7 +185,7 @@ Then inside Ubuntu:
```bash ```bash
sudo apt update sudo apt update
sudo apt install -y nodejs npm libfuse2 # AppImage sudo apt install -y nodejs npm libfuse2 # AppImage (libfuse2 only to run it)
sudo apt install -y flatpak flatpak-builder elfutils # Flatpak sudo apt install -y flatpak flatpak-builder elfutils # Flatpak
flatpak remote-add --if-not-exists --user flathub \ flatpak remote-add --if-not-exists --user flathub \
https://dl.flathub.org/repo/flathub.flatpakrepo https://dl.flathub.org/repo/flathub.flatpakrepo
@@ -135,14 +193,22 @@ flatpak install --user -y flathub \
org.freedesktop.Platform//23.08 \ org.freedesktop.Platform//23.08 \
org.freedesktop.Sdk//23.08 \ org.freedesktop.Sdk//23.08 \
org.electronjs.Electron2.BaseApp//23.08 org.electronjs.Electron2.BaseApp//23.08
cd /mnt/c/Users/<you>/git\ azuze/motionity-2
npm install
npm run dist:linux
``` ```
Note that building on `/mnt/c` is slow. Copying the tree into the WSL Those three refs must match `build.flatpak.runtimeVersion` / `baseVersion` in
filesystem (`~/motionity`) is several times faster. `package.json`; `build-release.ps1` reads them from there when it checks.
To build inside the distro directly instead — no `-UseWsl`, and faster still,
since `src/` is read locally too:
```bash
git clone <repo> ~/motionity && cd ~/motionity
npm ci && npm run dist:linux
```
`wsl.exe` writes its own listings as UTF-16LE, which PowerShell 5.1 renders as
NUL-interleaved text — `wsl -l -v` can look like it has one distro when it has
two. `$env:WSL_UTF8 = "1"` fixes it.
## 2. Docker ## 2. Docker
+149 -21
View File
@@ -1,34 +1,162 @@
<div align="center">
<img src="src/assets/logo.svg" alt="Motionity" width="72">
# Motionity # Motionity
### Demo available here : [motionity.kawa.zip](https://motionity.kawa.zip/)
Web-based motion graphics editor with keyframing, masking, filters and text animations.
This is a fork of the original [Motionity](https://github.com/alyssaxuu/motionity) by [@alyssaxuu](https://github.com/alyssaxuu), with bug fixes and enhancements. **Web-based motion graphics editor** — keyframing, masking, filters, text animations.
A free alternative to After Effects and Canva, running entirely in the browser.
## Quick Start ### ▶ [Try the live demo — motionity.kawa.zip](https://motionity.kawa.zip/)
[![Demo](https://img.shields.io/badge/demo-motionity.kawa.zip-6c5ce7?style=flat-square)](https://motionity.kawa.zip/)
[![License](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](LICENSE)
[![Downloads](https://img.shields.io/badge/downloads-releases-2ea44f?style=flat-square)](https://git.azuze.fr/kawa/Motionity/releases)
---
Created by **[Alyssa X](https://github.com/alyssaxuu)** · maintained by **Kawa**
Fork of the original [Motionity](https://github.com/alyssaxuu/motionity), with bug fixes,
vendored (offline-capable) assets, desktop builds and a hardened container image.
</div>
---
## How to run
### Docker (recommended for self-hosting)
```bash
docker compose up -d # http://localhost:8080
```
Or without compose:
```bash
docker run --rm -p 8080:8080 git.azuze.fr/kawa/motionity:latest
```
Build the image yourself:
```bash
docker build -t motionity:latest . # ~86 MB on disk
docker build --build-arg WITH_FFMPEG=0 . # -23 MB, no MP4/GIF export
```
Runtime is [static-web-server](https://github.com/static-web-server/static-web-server) on Alpine — no Node, no shell, runs as UID 65534, `read_only` filesystem.
### Windows app
Grab the installer or the portable build from **[Releases](https://git.azuze.fr/kawa/Motionity/releases)**:
| File | What it is |
| --- | --- |
| `Motionity Setup <ver>.exe` | NSIS installer, per-user, choosable install dir |
| `Motionity-<ver>-x64.exe` | Portable, no install |
Build from source (on Windows):
```bash
npm install
npm run dist:win # -> dist/
```
### Linux app
From **[Releases](https://git.azuze.fr/kawa/Motionity/releases)**: `Motionity-<ver>-x86_64.AppImage` or the `.flatpak`.
```bash
chmod +x Motionity-*.AppImage && ./Motionity-*.AppImage # needs libfuse2
flatpak install ./Motionity-*.flatpak # or this
```
Build from source (on Linux or WSL2):
```bash
npm install
npm run dist:appimage # AppImage only
npm run dist:flatpak # Flatpak only
npm run dist:linux # both
```
### From source — web
```bash
npm install
npm run vendor # downloads third-party assets into src/vendor/ (~24 MB)
npm start # http://127.0.0.1:8080
```
```bash
HOST=0.0.0.0 PORT=3000 npm start # bind elsewhere
```
### From source — desktop
**Web (localhost only):**
```bash ```bash
npm install npm install
npm run vendor npm run vendor
npm start # http://127.0.0.1:8080 npm run dev # Electron
``` ```
**Desktop:** > In a VS Code terminal, `ELECTRON_RUN_AS_NODE=1` breaks this. Unset it:
```bash > `$env:ELECTRON_RUN_AS_NODE=$null; npm run dev`
npm install
npm run vendor
npm run dev # Electron app
```
**Docker:** ### Two things to know
```bash
docker-compose up # http://localhost:8080
```
Full build instructions (Windows installers, Linux AppImage/Flatpak) in [PACKAGING.md](PACKAGING.md). - **`npm run vendor` is mandatory** for every non-Docker target. Without it the page loads but every script 404s. The Docker build runs it inside the image.
- **Secure context required.** WebCodecs (fast export) and IndexedDB (project saving) only exist on `http://localhost` or HTTPS. Over plain HTTP on a LAN address they silently disappear: export falls back to slow real-time capture, projects stop saving. Anything beyond localhost needs TLS — `deploy/Caddyfile` is the shortest path.
## Notes Full packaging details, WSL notes and troubleshooting: **[PACKAGING.md](PACKAGING.md)**.
- `ffmpeg.wasm` is vendored; `npm install` + `npm run vendor` are required ---
- WebCodecs and IndexedDB only work in secure context (`http://localhost` OK, plain HTTP over LAN is not)
- Serve over TLS for remote access ## Dependencies
Everything is vendored at build time — no CDN is contacted at runtime.
### Editor core
| Library | Role |
| --- | --- |
| [Fabric.js](https://github.com/fabricjs/fabric.js) | canvas object model, selection, transforms |
| [anime.js](https://github.com/juliangarnier/anime) | keyframe animation engine |
| [lottie-web](https://github.com/airbnb/lottie-web) | Lottie / Bodymovin playback |
| [ffmpeg.wasm](https://github.com/ffmpegwasm/ffmpeg.wasm) | MP4 / GIF export (single-threaded [core](https://github.com/ffmpegwasm/ffmpeg.wasm-core)) |
| [webm-writer-js](https://github.com/thenickdude/webm-writer-js) | WEBM muxing for the WebCodecs exporter |
### UI
| Library | Role |
| --- | --- |
| [jQuery](https://github.com/jquery/jquery) | DOM plumbing |
| [Pickr](https://github.com/simonwep/pickr) | color picker |
| [Selection.js](https://github.com/simonwep/selection) | timeline box-selection |
| [Sortable](https://github.com/SortableJS/Sortable) | layer reordering |
| [jquery-nice-select](https://github.com/hernansartorio/jquery-nice-select) | styled `<select>` |
| range-slider | timeline / property sliders (vendored, no upstream banner) |
| [Localbase](https://github.com/dannyconnell/localbase) | IndexedDB project storage |
| [webfontloader](https://github.com/typekit/webfontloader) | Google Fonts loading |
| [Inter](https://github.com/rsms/inter) | UI typeface |
### Build & runtime
| Package | Role |
| --- | --- |
| [Electron](https://github.com/electron/electron) | desktop shell |
| [electron-builder](https://github.com/electron-userland/electron-builder) | NSIS / AppImage / Flatpak packaging |
| [static-web-server](https://github.com/static-web-server/static-web-server) | container HTTP server |
`scripts/server.cjs` (the `npm start` server) has zero dependencies — Node 18+ builtins only.
### Still online by design
[Google Fonts](https://fonts.google.com/) (font picker), [Pixabay](https://pixabay.com/) (image/video/audio search), [Unsplash](https://unsplash.com/) (empty-state samples). All three degrade quietly when offline; the editor itself starts and exports without a network.
---
## License
[MIT](LICENSE) — same as upstream.
+53
View File
@@ -315,3 +315,56 @@ held the Playwright profile lock), so the following still needs a manual pass:
console shows `Rendering n%` (frame-accurate path) and not console shows `Rendering n%` (frame-accurate path) and not
`Falling back to the real-time encoder` `Falling back to the real-time encoder`
4. Play the result: check audio is in sync and the video layer is not smeared 4. Play the result: check audio is in sync and the video layer is not smeared
---
## Upstream open issues (alyssaxuu/motionity)
The 13 bug reports still open upstream were replayed in Chromium against this
fork. Nine were already fixed by the audit above — mp4 export (#16), blank
render (#25), image download (#10), audio on download (#30), filters not
retained (#24), `EyeDropper is not defined` (#15, #18), the endless
*Loading video…* (#28) and text selection while resizing the timeline (#5).
GIF export (#21) works through the same ffmpeg path. #8 (video from the search
tab) needs a Pixabay API key, so it stays untested; #29 and #4 carry no
reproducer. The remaining three are fixed here:
- [x] **#23 — Border radius behaved like a percentage** — `src/js/functions.js`,
`src/js/ui.js`, `src/js/events.js`, `src/js/database.js`
fabric applies `rx`/`ry` before the object's scale, and shapes are resized by
scaling, so a radius typed as 20 drew at 60 px on a rect scaled 3x while the
panel — which read `rx` back raw — still said 20. A new `cornerRadius` property
stores the pixel value the user asked for; `rx`/`ry` are derived from it
(`setCornerRadius`) and re-derived on `object:scaling` / `object:modified`
(`syncCornerRadius`), so the same number means the same pixels at any size.
`cornerRadius` was added to the four serialised property lists, and
`getCornerRadius` falls back to `rx * scaleX` for projects saved before it
existed. Known limit, unchanged from before: if `scaleX` is *keyframed*, the
drawn radius still varies over the animation.
Verified: typed 20 px draws a 20 px corner at scale 1 and at scale 3, the
panel keeps reading 20, and the value survives save, JSON round-trip and a
page reload.
- [x] **#27 — Cropping a rotated image** — `src/js/functions.js`
`crop()` compared canvas-space edges and covered only three of the four
quadrants, so any rotated image cropped the wrong region — and nothing at all
when the crop window was centred on it, since all three branches need a
strictly positive offset. The guards added in the audit stopped the crash but
left the geometry wrong. `crop()` now works in the image's own frame
(`rotateVector`), clamps the region to the bitmap, and re-centres the object on
the region it kept; the crop window is created with the image's `angle`, and
the "expand back to the full bitmap" shift in `cropImage` is rotated the same
way. The four-branch soup is gone.
Verified: at 0°, 30° and 45°, the pixels under the crop window are byte
identical before and after the crop, everything outside it is dropped, and
crop mode exits cleanly.
- [x] **#1 — A modal did not block the timeline** — `src/js/functions.js`
Modals are painted over the timeline but never took its pointer events, so the
resize handle, the seekbar, keyframes and layer bars all still reacted to a
drag behind the dialog. The onboarding modal from the report does not exist in
the open-source build; the export, import/export and credits modals all had
the bug. `dragTimeline`, `dragSeekBar`, `dragKeyframe` and `dragObjectProps`
now bail while `.modal-open` is present.
Verified: with the export modal open a real mouse drag on the handle leaves
the timeline height untouched, and dragging works again once it closes.
+4 -1
View File
@@ -1,7 +1,8 @@
{ {
"name": "motionity", "name": "motionity",
"productName": "Motionity", "productName": "Motionity",
"version": "2.0.0", "version": "2.0.2",
"desktopName": "app.motionity.desktop.desktop",
"description": "Web-based motion graphics editor with keyframing, masking, filters and text animations", "description": "Web-based motion graphics editor with keyframing, masking, filters and text animations",
"license": "MIT", "license": "MIT",
"author": "Kawa", "author": "Kawa",
@@ -16,6 +17,7 @@
"dist:appimage": "npm run vendor && npm run icons && electron-builder --linux AppImage", "dist:appimage": "npm run vendor && npm run icons && electron-builder --linux AppImage",
"dist:flatpak": "npm run vendor && npm run icons && electron-builder --linux flatpak", "dist:flatpak": "npm run vendor && npm run icons && electron-builder --linux flatpak",
"dist:linux": "npm run vendor && npm run icons && electron-builder --linux AppImage flatpak", "dist:linux": "npm run vendor && npm run icons && electron-builder --linux AppImage flatpak",
"dist:linux:wsl": "pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/build-release.ps1 -Targets linux -UseWsl",
"docker:build": "docker build -t motionity:latest .", "docker:build": "docker build -t motionity:latest .",
"docker:run": "docker run --rm -p 8080:8080 motionity:latest", "docker:run": "docker run --rm -p 8080:8080 motionity:latest",
"release:build": "pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/build-release.ps1", "release:build": "pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/build-release.ps1",
@@ -78,6 +80,7 @@
"icon": "build/icon.png", "icon": "build/icon.png",
"category": "Graphics", "category": "Graphics",
"synopsis": "Motion graphics editor", "synopsis": "Motion graphics editor",
"syncDesktopName": true,
"desktop": { "desktop": {
"entry": { "entry": {
"Name": "Motionity", "Name": "Motionity",
+224 -6
View File
@@ -28,10 +28,20 @@
Windows builds the .exe targets; AppImage and Flatpak need a Linux host or Windows builds the .exe targets; AppImage and Flatpak need a Linux host or
WSL (see PACKAGING.md). Nothing here cross-builds. WSL (see PACKAGING.md). Nothing here cross-builds.
-UseWsl makes that split automatic: the .exe targets run on Windows, and the
Linux ones are handed to a WSL distro over /mnt/c, against this same worktree.
The npm install, the vendor step and the icon all happen once on the Windows
side and the WSL build reads them through the mount, so the artifacts still
land in dist/ and the checksum step below sees every one of them.
.EXAMPLE .EXAMPLE
./scripts/build-release.ps1 ./scripts/build-release.ps1
Build every target at v<package.json version>. Build every target at v<package.json version>.
.EXAMPLE
./scripts/build-release.ps1 -UseWsl
Same, with AppImage and Flatpak built in the first non-docker WSL distro.
.EXAMPLE .EXAMPLE
./scripts/build-release.ps1 -Targets win -Tag v1.1.0 ./scripts/build-release.ps1 -Targets win -Tag v1.1.0
Windows installers only, named v1.1.0. Windows installers only, named v1.1.0.
@@ -40,6 +50,10 @@
./scripts/build-release.ps1 -Targets win,linux-appimage ./scripts/build-release.ps1 -Targets win,linux-appimage
Windows installers plus the Linux AppImage — no Flatpak. Windows installers plus the Linux AppImage — no Flatpak.
.EXAMPLE
./scripts/build-release.ps1 -Targets linux -UseWsl -WslDistro Ubuntu-24.04
Both Linux bundles, in a named distro.
.EXAMPLE .EXAMPLE
./scripts/build-release.ps1 -SkipVendor -SkipDeps ./scripts/build-release.ps1 -SkipVendor -SkipDeps
Reuse src/vendor/ and node_modules as they are — the fast rebuild. Reuse src/vendor/ and node_modules as they are — the fast rebuild.
@@ -62,6 +76,14 @@ param(
# Skip the npm install even when node_modules is missing. # Skip the npm install even when node_modules is missing.
[switch]$SkipDeps, [switch]$SkipDeps,
# Build the Linux targets inside WSL instead of warning that they cannot be
# built on Windows. Ignored on a Linux host, where they build natively.
[switch]$UseWsl,
# WSL distro to build in. Defaults to the first installed one that is not
# docker-desktop.
[string]$WslDistro,
# Remove dist/ before building. # Remove dist/ before building.
[switch]$Clean [switch]$Clean
) )
@@ -91,6 +113,153 @@ function Get-ArtifactName {
return $Stem + '.${ext}' return $Stem + '.${ext}'
} }
# --- WSL plumbing -------------------------------------------------------------
function Invoke-Wsl {
param([Parameter(Mandatory)][string]$Distro, [Parameter(Mandatory)][string]$Command)
Write-Host " > wsl -d $Distro -- $Command" -ForegroundColor DarkGray
# bash -lc, so PATH matches an interactive shell: node installed through nvm
# or fnm is not on the default non-login PATH.
& wsl.exe -d $Distro -e bash -lc $Command
if ($LASTEXITCODE -ne 0) {
throw "the WSL build in '$Distro' failed with exit code $LASTEXITCODE."
}
}
function Test-WslCommand {
param([Parameter(Mandatory)][string]$Distro, [Parameter(Mandatory)][string]$Command)
& wsl.exe -d $Distro -e bash -lc $Command *> $null
return ($LASTEXITCODE -eq 0)
}
function ConvertTo-BashArg {
<#
Single quotes, always. The artifactName arguments carry a literal ${ext}
for electron-builder to expand, and bash would expand it to nothing first
inside double quotes; the repo path has a space in it.
#>
param([Parameter(Mandatory)][string]$Value)
return "'" + $Value.Replace("'", "'\''") + "'"
}
function Get-WslDistro {
<#
docker-desktop is excluded on purpose. It is Docker Desktop's own LinuxKit
VM: no apt, no user home to install the flatpak runtimes into — and it is
usually the *default* distro, so picking blind would land there.
#>
param([string]$Requested)
if (-not (Get-Command wsl.exe -ErrorAction SilentlyContinue)) {
throw "-UseWsl needs wsl.exe on PATH. Install a distro with 'wsl --install -d Ubuntu' (PACKAGING.md has the rest of the setup)."
}
# wsl.exe writes its own listings as UTF-16LE, which PowerShell 5.1 reads back
# as NUL-interleaved text. WSL_UTF8 fixes it at the source; the -replace is the
# fallback for WSL older than 0.64.
$previousUtf8 = $env:WSL_UTF8
$env:WSL_UTF8 = "1"
try { $listed = (& wsl.exe --list --quiet) -join "`n" }
finally { $env:WSL_UTF8 = $previousUtf8 }
$distros = @(($listed -replace "`0", "") -split "`r?`n" |
ForEach-Object { $_.Trim() } | Where-Object { $_ })
if ($Requested) {
if ($distros -notcontains $Requested) {
throw "WSL distro '$Requested' is not installed. Installed: $($distros -join ', ')."
}
return $Requested
}
$usable = @($distros | Where-Object { $_ -notlike "docker-desktop*" })
if (-not $usable.Count) {
throw "no WSL distro that can build here (installed: $($distros -join ', ')). Run 'wsl --install -d Ubuntu', then the package setup in PACKAGING.md."
}
return $usable[0]
}
function Get-WslPath {
param([Parameter(Mandatory)][string]$Distro, [Parameter(Mandatory)][string]$WindowsPath)
# -e wslpath rather than a shell: the backslashes and the space in the repo
# path then reach wslpath as one literal argv entry, unquoted and unmangled.
$out = (& wsl.exe -d $Distro -e wslpath -a -u $WindowsPath)
$path = (@($out) -join "").Replace("`0", "").Trim()
if ($LASTEXITCODE -ne 0 -or -not $path) {
throw "wslpath failed in '$Distro' for '$WindowsPath' — is the Windows drive mounted in that distro?"
}
return $path
}
function Get-WslStageDir {
<#
Where the Linux build actually happens. It cannot be dist/ on /mnt/c:
electron-builder chmods every file it unpacks out of the Electron zip, and
drvfs answers chmod with EPERM unless /mnt/c was mounted with the metadata
option — a global change to the distro needing sudo and a wsl --shutdown.
EPERM: operation not permitted, chmod '.../linux-unpacked.tmp/locales/de.pak'
Staging in the distro's own filesystem and copying the finished bundles back
needs none of that, and is faster besides. Reading src/ over the mount is
still fine — nothing chmods the input.
#>
param([Parameter(Mandatory)][string]$Distro)
# printf, not echo: no trailing newline to trim off the path.
$out = (& wsl.exe -d $Distro -e bash -lc 'printf %s "${XDG_CACHE_HOME:-$HOME/.cache}/motionity-build"')
$path = (@($out) -join "").Replace("`0", "").Trim()
if ($LASTEXITCODE -ne 0 -or $path -notlike "/*") {
throw "could not resolve a staging directory in '$Distro' (got '$path')."
}
return $path
}
function Assert-WslBuildEnv {
<#
Fail before the build rather than during it. electron-builder's own error
for a missing flatpak ref is a bare flatpak-builder exit code that names
neither the ref nor the remote.
#>
param(
[Parameter(Mandatory)][string]$Distro,
[Parameter(Mandatory)][string[]]$WslTargets,
[Parameter(Mandatory)]$Pkg
)
if (-not (Test-WslCommand $Distro 'command -v node')) {
throw "no node in WSL distro '$Distro'. Inside it: sudo apt update && sudo apt install -y nodejs npm"
}
# AppImage needs nothing else: electron-builder downloads its own appimage
# tooling and writes the squashfs itself. libfuse2 is only needed to *run* the
# result, which is not this script's job.
if ($WslTargets -notcontains "linux-flatpak") { return }
if (-not (Test-WslCommand $Distro 'command -v flatpak-builder')) {
throw "no flatpak-builder in WSL distro '$Distro'. Inside it: sudo apt install -y flatpak flatpak-builder elfutils"
}
$runtimeVersion = $Pkg.build.flatpak.runtimeVersion
$baseVersion = $Pkg.build.flatpak.baseVersion
if (-not $runtimeVersion) { $runtimeVersion = "23.08" }
if (-not $baseVersion) { $baseVersion = $runtimeVersion }
$refs = @(
"org.freedesktop.Platform//$runtimeVersion",
"org.freedesktop.Sdk//$runtimeVersion",
"org.electronjs.Electron2.BaseApp//$baseVersion"
)
$missing = @($refs | Where-Object { -not (Test-WslCommand $Distro "flatpak info $_") })
if ($missing.Count) {
throw @"
flatpak refs missing in '$Distro': $($missing -join ', ')
Inside the distro:
flatpak remote-add --if-not-exists --user flathub https://dl.flathub.org/repo/flathub.flatpakrepo
flatpak install --user -y flathub $($refs -join ' ')
"@
}
}
$repoRoot = Split-Path -Parent $PSScriptRoot $repoRoot = Split-Path -Parent $PSScriptRoot
Push-Location $repoRoot Push-Location $repoRoot
try { try {
@@ -119,11 +288,37 @@ try {
} }
$resolvedTargets = @($resolvedTargets | Select-Object -Unique) $resolvedTargets = @($resolvedTargets | Select-Object -Unique)
# electron-builder produces AppImage and Flatpak with Linux-only tooling # electron-builder produces AppImage and Flatpak with Linux-only tooling (its
# (appimagetool, flatpak-builder). Warned rather than blocked: the same script # downloaded appimage bundle, and flatpak-builder). -UseWsl hands those two
# runs under pwsh on a Linux box or in WSL, which is where that target belongs. # targets to a WSL distro; without it they stay a warning rather than an error,
if (($resolvedTargets -like "linux-*") -and $env:OS -eq "Windows_NT") { # because the other right answer is running this whole script under pwsh on a
Write-Warning "the Linux targets need a Linux host or WSL — electron-builder cannot produce AppImage or Flatpak on Windows (PACKAGING.md has the WSL setup)." # Linux box, where they build natively.
$onWindows = ($env:OS -eq "Windows_NT")
$wslTargets = @($resolvedTargets | Where-Object { $_ -like "linux-*" })
$useWslHere = $onWindows -and $UseWsl -and [bool]$wslTargets.Count
if ($onWindows -and $wslTargets.Count -and -not $UseWsl) {
Write-Warning "the Linux targets need a Linux host or WSL — electron-builder cannot produce AppImage or Flatpak on Windows. Add -UseWsl to build them in a WSL distro (PACKAGING.md has the setup)."
}
if ($UseWsl -and -not $onWindows) {
Write-Warning "-UseWsl ignored: this is already a Linux host, so the Linux targets build natively."
}
if ($UseWsl -and $onWindows -and -not $wslTargets.Count) {
Write-Warning "-UseWsl ignored: no Linux target was requested (-Targets $($Targets -join ', '))."
}
$wslRepo = $null
$wslStage = $null
if ($useWslHere) {
$WslDistro = Get-WslDistro -Requested $WslDistro
$wslRepo = Get-WslPath -Distro $WslDistro -WindowsPath $repoRoot
$wslStage = Get-WslStageDir -Distro $WslDistro
Write-Host "Linux targets go to WSL" -ForegroundColor Cyan
Write-Host " distro : $WslDistro"
Write-Host " worktree: $wslRepo"
Write-Host " staging : $wslStage (copied back into dist/)"
Assert-WslBuildEnv -Distro $WslDistro -WslTargets $wslTargets -Pkg $pkg
Write-Host ""
} }
if ($Clean) { if ($Clean) {
@@ -207,7 +402,30 @@ try {
} }
} }
Invoke-Checked $builder $builderArgs if ($useWslHere -and $target -like "linux-*") {
# `node cli.js` rather than node_modules/.bin/electron-builder: the
# extensionless shim npm writes on Windows is a sh script, and whether
# it is executable across the mount depends on the drvfs options.
#
# The output goes to the distro's filesystem (see Get-WslStageDir) and
# the bundles are copied back afterwards. `cp -f`, never `cp -p`:
# preserving modes means chmod, which is the EPERM this avoids.
#
# The rm clears this tag's previous bundles from the staging directory,
# so the copy back cannot pick up an artifact from an earlier build that
# electron-builder did not overwrite this time round.
$quotedArgs = @($builderArgs | ForEach-Object { ConvertTo-BashArg $_ }) -join " "
$stage = ConvertTo-BashArg $wslStage
$wslCommand = "cd $(ConvertTo-BashArg $wslRepo)" +
" && mkdir -p $stage" +
" && rm -f $stage/$prefix-*" +
" && USE_HARD_LINKS=false node node_modules/electron-builder/cli.js $quotedArgs $(ConvertTo-BashArg "-c.directories.output=$wslStage")" +
" && cp -f $stage/$prefix-* $(ConvertTo-BashArg "$wslRepo/dist")/"
Invoke-Wsl -Distro $WslDistro -Command $wslCommand
}
else {
Invoke-Checked $builder $builderArgs
}
Write-Host "" Write-Host ""
} }
+23 -3
View File
@@ -16,8 +16,8 @@
-BinariesOnly ships just the installers: no docker build, no docker login, no -BinariesOnly ships just the installers: no docker build, no docker login, no
image push, and the release upload is implied. It takes the target list to image push, and the release upload is implied. It takes the target list to
build (win, linux-appimage, linux-flatpak — comma-separated) and that list build (win, linux-appimage, linux-flatpak — comma-separated) and that list
overrides -Targets. The Linux targets need a Linux host or WSL, which is where overrides -Targets. The Linux targets need a Linux host or WSL: on Windows,
-BinariesOnly linux-appimage,linux-flatpak belongs. add -UseWsl and build-release.ps1 hands them to a WSL distro.
Credentials are read, in order of precedence: Credentials are read, in order of precedence:
1. -Username / -Password parameters 1. -Username / -Password parameters
@@ -45,6 +45,11 @@
./scripts/publish.ps1 -BinariesOnly win,linux-appimage -Tag v1.1.0 ./scripts/publish.ps1 -BinariesOnly win,linux-appimage -Tag v1.1.0
Windows installers plus the Linux AppImage (no Flatpak), attached to v1.1.0. Windows installers plus the Linux AppImage (no Flatpak), attached to v1.1.0.
.EXAMPLE
./scripts/publish.ps1 -Tag v1.1.0 -PublishRelease -UseWsl
Full release from Windows: image push, .exe installers built natively, AppImage
and Flatpak built in WSL, everything attached to release v1.1.0.
.EXAMPLE .EXAMPLE
./scripts/publish.ps1 -BinariesOnly win -NoBinaryBuild -Tag v1.1.0 ./scripts/publish.ps1 -BinariesOnly win -NoBinaryBuild -Tag v1.1.0
Retry a failed upload: attach the installers already in dist/ without rebuilding Retry a failed upload: attach the installers already in dist/ without rebuilding
@@ -105,6 +110,11 @@ param(
[string[]]$Targets = @("win", "linux"), [string[]]$Targets = @("win", "linux"),
[switch]$SkipVendor, [switch]$SkipVendor,
# Forwarded to build-release.ps1: build the Linux targets in a WSL distro
# instead of warning that Windows cannot produce them.
[switch]$UseWsl,
[string]$WslDistro,
# Reuse the installers already in dist/ instead of re-running the build. For # Reuse the installers already in dist/ instead of re-running the build. For
# retrying a failed upload without paying for the build again. # retrying a failed upload without paying for the build again.
[switch]$NoBinaryBuild, [switch]$NoBinaryBuild,
@@ -413,7 +423,17 @@ try {
# `& script.ps1` leaves $LASTEXITCODE untouched, and with -NoBuild no # `& script.ps1` leaves $LASTEXITCODE untouched, and with -NoBuild no
# docker command has reset it, so checking it would rethrow whatever the # docker command has reset it, so checking it would rethrow whatever the
# caller's shell last failed at. # caller's shell last failed at.
& (Join-Path $PSScriptRoot "build-release.ps1") -Tag $Tag -Targets $Targets -SkipVendor:$SkipVendor # -WslDistro is only passed when set: build-release.ps1 treats an empty
# string as "not requested" either way, but splatting nothing keeps the
# -WhatIf/-Verbose trace readable.
$buildParams = @{
Tag = $Tag
Targets = $Targets
SkipVendor = $SkipVendor
UseWsl = $UseWsl
}
if ($WslDistro) { $buildParams["WslDistro"] = $WslDistro }
& (Join-Path $PSScriptRoot "build-release.ps1") @buildParams
} }
$artifacts = @(Get-ChildItem $distDir -Filter "motionity-$Tag-*" -File | ForEach-Object FullName) $artifacts = @(Get-ChildItem $distDir -Filter "motionity-$Tag-*" -File | ForEach-Object FullName)
+12 -7
View File
@@ -19,17 +19,17 @@
<!-- Open Graph / Facebook --> <!-- Open Graph / Facebook -->
<meta property="og:type" content="website"> <meta property="og:type" content="website">
<meta property="og:url" content="https://www.motionity.app/"> <meta property="og:url" content="https://motionity.kawa.zip/">
<meta property="og:title" content="Motionity - The web-based motion graphics editor for everyone"> <meta property="og:title" content="Motionity - The web-based motion graphics editor for everyone">
<meta property="og:description" content="Create animated videos for free with Motionity, an open source motion graphics editor with keyframing, masking, filters, text animations, and more. "> <meta property="og:description" content="Create animated videos for free with Motionity, an open source motion graphics editor with keyframing, masking, filters, text animations, and more. ">
<meta property="og:image" content="https://motionity.app/meta.png"> <meta property="og:image" content="https://motionity.kawa.zip/meta.png">
<!-- Twitter --> <!-- Twitter -->
<meta property="twitter:card" content="summary_large_image"> <meta property="twitter:card" content="summary_large_image">
<meta property="twitter:url" content="https://www.motionity.app/"> <meta property="twitter:url" content="https://motionity.kawa.zip/">
<meta property="twitter:title" content="Motionity - The web-based motion graphics editor for everyone"> <meta property="twitter:title" content="Motionity - The web-based motion graphics editor for everyone">
<meta property="twitter:description" content="Create animated videos for free with Motionity, an open source motion graphics editor with keyframing, masking, filters, text animations, and more. "> <meta property="twitter:description" content="Create animated videos for free with Motionity, an open source motion graphics editor with keyframing, masking, filters, text animations, and more. ">
<meta property="twitter:image" content="https://motionity.app/meta.png"> <meta property="twitter:image" content="https://motionity.kawa.zip/meta.png">
</head> </head>
<body draggable="false"> <body draggable="false">
<div id="disclaimer"> <div id="disclaimer">
@@ -37,7 +37,6 @@
<div id="emoji">🤔</div> <div id="emoji">🤔</div>
<div id="opt-title">Motionity isn't optimized for mobile</div> <div id="opt-title">Motionity isn't optimized for mobile</div>
<div id="opt-desc">You need to use a computer to be able to create animations with Motionity.</div> <div id="opt-desc">You need to use a computer to be able to create animations with Motionity.</div>
<a href="https://twitter.com/alyssaxuu" target="_blank" id="opt-button">Other products by the maker</a>
</div> </div>
<div id="disc-overlay"></div> <div id="disc-overlay"></div>
</div> </div>
@@ -105,8 +104,8 @@
</div> </div>
<div id="credits-modal"> <div id="credits-modal">
<p class="header">Credits</p> <p class="header">Credits</p>
<p class="subtitle">Created by <span>Alyssa X</span></p> <p class="subtitle">Created by <span><a href="https://github.com/alyssaxuu">Alyssa X</a></span></p>
<p class="subtitle">Maintained by <span>Kawa</span></p> <p class="subtitle">Current version maintained by <span><a href="https://github.com/KawaKode/Motionity">Kawa</a></span></p>
<hr style="margin-top: 15px; margin-bottom: 15px;"> <hr style="margin-top: 15px; margin-bottom: 15px;">
<p class="subheader">Libraries & Tools</p> <p class="subheader">Libraries & Tools</p>
<div id="credits-list"> <div id="credits-list">
@@ -312,6 +311,12 @@
<img src="assets/loading-video.svg" id="load-video" class="load-media"> <img src="assets/loading-video.svg" id="load-video" class="load-media">
<canvas id="canvas"></canvas> <canvas id="canvas"></canvas>
</div> </div>
<div id="browser-handle" class="panel-handle noselect" title="Drag to resize">
<div class="panel-toggle" id="browser-toggle"></div>
</div>
<div id="properties-handle" class="panel-handle noselect" title="Drag to resize">
<div class="panel-toggle" id="properties-toggle"></div>
</div>
<div id="timeline-handle"></div> <div id="timeline-handle"></div>
<div id="bottom-area" class="noselect"> <div id="bottom-area" class="noselect">
<div id="keyframe-properties"> <div id="keyframe-properties">
+13
View File
@@ -50,8 +50,11 @@ function checkDB() {
'strokeUniform', 'strokeUniform',
'rx', 'rx',
'ry', 'ry',
'cornerRadius',
'selectable', 'selectable',
'hasControls', 'hasControls',
'hasBorders',
'evented',
'subTargetCheck', 'subTargetCheck',
'id', 'id',
'hoverCursor', 'hoverCursor',
@@ -226,8 +229,11 @@ async function autoSave() {
'strokeUniform', 'strokeUniform',
'rx', 'rx',
'ry', 'ry',
'cornerRadius',
'selectable', 'selectable',
'hasControls', 'hasControls',
'hasBorders',
'evented',
'subTargetCheck', 'subTargetCheck',
'id', 'id',
'hoverCursor', 'hoverCursor',
@@ -346,6 +352,13 @@ function loadProject() {
}); });
replaceSource(canvas.getItemById(object.id), canvas); replaceSource(canvas.getItemById(object.id), canvas);
} else { } else {
// Projects saved before audio layers were flagged still carry
// controls / borders, which show up as a phantom box at 0,0.
canvas.getItemById(object.id).set({
hasControls: false,
hasBorders: false,
evented: false,
});
renderProp('volume', canvas.getItemById(object.id)); renderProp('volume', canvas.getItemById(object.id));
} }
}); });
+6
View File
@@ -100,6 +100,8 @@ $(document).ready(function () {
canvas.on('object:scaling', function (e) { canvas.on('object:scaling', function (e) {
e.target.hasControls = false; e.target.hasControls = false;
centerLines(e); centerLines(e);
// Keep the corner radius at its pixel value while the handle is dragged
syncCornerRadius(e.target);
if (cropping) { if (cropping) {
updateCropBounds(); updateCropBounds();
crop(canvas.getItemById('cropped')); crop(canvas.getItemById('cropped'));
@@ -135,12 +137,16 @@ $(document).ready(function () {
canvas.renderAll(); canvas.renderAll();
if (e.target.type == 'activeSelection') { if (e.target.type == 'activeSelection') {
const tempselection = canvas.getActiveObject(); const tempselection = canvas.getActiveObject();
// Discarding first bakes the group transform into the children, so
// their scale is final by the time the radius is re-derived
canvas.discardActiveObject(); canvas.discardActiveObject();
e.target._objects.forEach(function (object) { e.target._objects.forEach(function (object) {
syncCornerRadius(object);
autoKeyframe(object, e, true); autoKeyframe(object, e, true);
}); });
reselect(tempselection); reselect(tempselection);
} else { } else {
syncCornerRadius(e.target);
autoKeyframe(e.target, e, false); autoKeyframe(e.target, e, false);
} }
updatePanelValues(); updatePanelValues();
+359 -74
View File
@@ -495,6 +495,7 @@ async function updateRecordCanvas() {
'strokeUniform', 'strokeUniform',
'rx', 'rx',
'ry', 'ry',
'cornerRadius',
'selectable', 'selectable',
'hasControls', 'hasControls',
'subTargetCheck', 'subTargetCheck',
@@ -684,6 +685,7 @@ function save() {
'strokeUniform', 'strokeUniform',
'rx', 'rx',
'ry', 'ry',
'cornerRadius',
'selectable', 'selectable',
'hasControls', 'hasControls',
'subTargetCheck', 'subTargetCheck',
@@ -1050,13 +1052,57 @@ $(document).on('click', '#redo', function () {
} }
}); });
// Corner radius, in the canvas pixels the panel advertises.
//
// fabric applies rx/ry in the object's own coordinate space, before its scale,
// and shapes are resized by scaling rather than by changing width/height. The
// raw input was written straight to rx, so a radius typed as 20 drew at 60 on a
// rect scaled 3x: the value behaved like a proportion of the shape instead of
// pixels. `cornerRadius` stores what the user asked for and rx/ry are derived
// from it, so the same number means the same number of pixels at any size.
function getCornerRadius(object) {
if (!object) {
return 0;
}
if (typeof object.cornerRadius == 'number') {
return object.cornerRadius;
}
// Projects saved before cornerRadius existed only carry the raw rx
return (
(object.get('rx') || 0) * Math.abs(object.get('scaleX') || 1)
);
}
function setCornerRadius(object, radius) {
if (!object) {
return;
}
var px = radius > 0 ? radius : 0;
object.cornerRadius = px;
object.set({
rx: px / Math.abs(object.get('scaleX') || 1),
ry: px / Math.abs(object.get('scaleY') || 1),
});
}
// Re-derive rx/ry after a resize so the drawn radius keeps the typed value
function syncCornerRadius(object) {
if (!object || object.get('type') != 'rect') {
return;
}
if (typeof object.cornerRadius != 'number') {
return;
}
setCornerRadius(object, object.cornerRadius);
}
// Generate keyframes // Generate keyframes
function keyframeChanges(object, type, id, selection) { function keyframeChanges(object, type, id, selection) {
if (object.get('type') == 'rect') { if (object.get('type') == 'rect') {
object.set({ setCornerRadius(
rx: parseFloat($('#object-corners input').val()), object,
ry: parseFloat($('#object-corners input').val()), parseFloat($('#object-corners input').val())
}); );
} else if (object.get('type') == 'textbox') { } else if (object.get('type') == 'textbox') {
object.set({ object.set({
charSpacing: parseFloat($('#text-h input').val()) * 10, charSpacing: parseFloat($('#text-h input').val()) * 10,
@@ -3512,7 +3558,23 @@ function checkCrop(obj) {
crop(canvas.getItemById('cropped')); crop(canvas.getItemById('cropped'));
} }
// Rotate a vector by `radians` around the origin
function rotateVector(x, y, radians) {
if (!radians) {
return { x: x, y: y };
}
var cos = Math.cos(radians);
var sin = Math.sin(radians);
return { x: x * cos - y * sin, y: x * sin + y * cos };
}
// Perform a crop // Perform a crop
//
// Everything is computed in the image's own frame rather than along the canvas
// axes, so a rotated image crops the region the crop window actually covers.
// The previous version compared canvas-space edges and only handled three of
// the four quadrants, which cropped the wrong area of any rotated image (and
// nothing at all when the crop window was centred on it).
function crop(obj) { function crop(obj) {
var cropUI = canvas.getItemById('crop'); var cropUI = canvas.getItemById('crop');
if (!obj || !cropUI || !cropobj) { if (!obj || !cropUI || !cropobj) {
@@ -3520,58 +3582,54 @@ function crop(obj) {
} }
cropobj.setCoords(); cropobj.setCoords();
cropUI.setCoords(); cropUI.setCoords();
var cleft = // Read everything before writing: the final call passes cropobj as `obj`
cropUI.get('left') - (cropUI.get('width') * cropUI.get('scaleX')) / 2; var scaleX = cropobj.get('scaleX') || 1;
var ctop = var scaleY = cropobj.get('scaleY') || 1;
cropUI.get('top') - (cropUI.get('height') * cropUI.get('scaleY')) / 2; var ogWidth = cropobj.get('width');
var height = var ogHeight = cropobj.get('height');
(cropUI.get('height') / cropobj.get('scaleY')) * cropUI.get('scaleY'); var centerX = cropobj.get('left');
var width = var centerY = cropobj.get('top');
(cropUI.get('width') / cropobj.get('scaleX')) * cropUI.get('scaleX'); var radians = fabric.util.degreesToRadians(
var img_height = cropobj.get('height') * cropobj.get('scaleY'); cropobj.get('angle') || 0
var img_width = cropobj.get('width') * cropobj.get('scaleX'); );
var left =
cleft - // The crop window carries the image's angle, so its own width and height are
(cropobj.get('left') - // already axis-aligned with the image
(cropobj.get('width') * cropobj.get('scaleX')) / 2); var width = (cropUI.get('width') * cropUI.get('scaleX')) / scaleX;
var top = var height = (cropUI.get('height') * cropUI.get('scaleY')) / scaleY;
ctop -
(cropobj.get('top') - // Where the crop window sits relative to the image centre, un-rotated
(cropobj.get('height') * cropobj.get('scaleY')) / 2); var offset = rotateVector(
if (left < 0 && top > 0) { cropUI.get('left') - centerX,
obj cropUI.get('top') - centerY,
.set({ cropY: top / cropobj.get('scaleY'), height: height }) -radians
.setCoords(); );
canvas.renderAll(); var cropX = offset.x / scaleX + ogWidth / 2 - width / 2;
obj.set({ var cropY = offset.y / scaleY + ogHeight / 2 - height / 2;
top: ctop + (obj.get('height') * obj.get('scaleY')) / 2,
}); // Never leave the source bitmap: fabric draws nothing for a negative crop
canvas.renderAll(); width = Math.max(1, Math.min(width, ogWidth));
} else if (top < 0 && left > 0) { height = Math.max(1, Math.min(height, ogHeight));
obj cropX = Math.max(0, Math.min(cropX, ogWidth - width));
.set({ cropX: left / cropobj.get('scaleX'), width: width }) cropY = Math.max(0, Math.min(cropY, ogHeight - height));
.setCoords();
canvas.renderAll(); // Put the cropped object back under the (clamped) region it represents
obj.set({ var center = rotateVector(
left: cleft + (obj.get('width') * obj.get('scaleX')) / 2, (cropX + width / 2 - ogWidth / 2) * scaleX,
}); (cropY + height / 2 - ogHeight / 2) * scaleY,
canvas.renderAll(); radians
} else if (top > 0 && left > 0) { );
obj obj
.set({ .set({
cropX: left / cropobj.get('scaleX'), cropX: cropX,
cropY: top / cropobj.get('scaleY'), cropY: cropY,
height: height, width: width,
width: width, height: height,
}) left: centerX + center.x,
.setCoords(); top: centerY + center.y,
canvas.renderAll(); })
obj.set({ .setCoords();
left: cleft + (obj.get('width') * obj.get('scaleX')) / 2, canvas.renderAll();
top: ctop + (obj.get('height') * obj.get('scaleY')) / 2,
});
canvas.renderAll();
}
if (obj.get('id') != 'cropped') { if (obj.get('id') != 'cropped') {
canvas.remove(cropUI); canvas.remove(cropUI);
canvas.remove(canvas.getItemById('crop-overlay')); canvas.remove(canvas.getItemById('crop-overlay'));
@@ -3626,20 +3684,23 @@ function cropImage(object) {
cropobj = object; cropobj = object;
canvas.uniformScaling = false; canvas.uniformScaling = false;
cropobj.setCoords(); cropobj.setCoords();
var left = // The visible region, in the source bitmap's own pixels, before the image
cropobj.get('left') - // is expanded back to its full size below
(cropobj.get('width') * cropobj.get('scaleX')) / 2;
var top =
cropobj.get('top') -
(cropobj.get('height') * cropobj.get('scaleY')) / 2;
var cropx = cropobj.get('cropX'); var cropx = cropobj.get('cropX');
var cropy = cropobj.get('cropY'); var cropy = cropobj.get('cropY');
var shownWidth = cropobj.get('width');
var shownHeight = cropobj.get('height');
var shownLeft = cropobj.get('left');
var shownTop = cropobj.get('top');
overlay(); overlay();
var cropUI = new fabric.Rect({ var cropUI = new fabric.Rect({
left: object.get('left'), left: object.get('left'),
top: object.get('top'), top: object.get('top'),
width: object.get('width') * object.get('scaleX') - 5, width: object.get('width') * object.get('scaleX') - 5,
height: object.get('height') * object.get('scaleY') - 5, height: object.get('height') * object.get('scaleY') - 5,
// The crop window has to turn with the image, otherwise the region it
// covers cannot be expressed as a crop at all
angle: object.get('angle') || 0,
originX: 'center', originX: 'center',
originY: 'center', originY: 'center',
id: 'crop', id: 'crop',
@@ -3674,15 +3735,19 @@ function cropImage(object) {
}) })
.setCoords(); .setCoords();
canvas.renderAll(); canvas.renderAll();
// Growing back to the full bitmap moves the centre, so shift the image so
// the region that was on screen stays exactly where it was. The offset is
// in the image's own frame, hence the rotation.
var shift = rotateVector(
(cropx + shownWidth / 2 - cropobj.get('width') / 2) *
cropobj.get('scaleX'),
(cropy + shownHeight / 2 - cropobj.get('height') / 2) *
cropobj.get('scaleY'),
fabric.util.degreesToRadians(cropobj.get('angle') || 0)
);
cropobj.set({ cropobj.set({
left: left: shownLeft - shift.x,
left + top: shownTop - shift.y,
(cropobj.get('width') * cropobj.get('scaleX')) / 2 -
cropx * cropobj.get('scaleX'),
top:
top +
(cropobj.get('height') * cropobj.get('scaleY')) / 2 -
cropy * cropobj.get('scaleY'),
}); });
cropUI.setControlsVisibility({ cropUI.setControlsVisibility({
mt: false, mt: false,
@@ -4058,6 +4123,7 @@ function newRectangle(color) {
backgroundColor: 'rgba(255,255,255,0)', backgroundColor: 'rgba(255,255,255,0)',
rx: 0, rx: 0,
ry: 0, ry: 0,
cornerRadius: 0,
fill: color, fill: color,
cursorWidth: 1, cursorWidth: 1,
cursorDuration: 1, cursorDuration: 1,
@@ -4315,6 +4381,11 @@ function newAudioLayer(src) {
duration: audio.duration * 1000, duration: audio.duration * 1000,
opacity: 0, opacity: 0,
selectable: false, selectable: false,
// Audio layers are invisible placeholders. Selecting them (from the
// layer list) must not paint controls / a bounding box on the canvas.
hasControls: false,
hasBorders: false,
evented: false,
volume: 0.5, volume: 0.5,
assetType: 'audio', assetType: 'audio',
shadow: { shadow: {
@@ -4862,9 +4933,17 @@ function bindPointerDrag(e, el, onMove, onEnd) {
window.addEventListener('blur', end); window.addEventListener('blur', end);
} }
// A modal covers the timeline but does not cover pointer events, so every
// timeline drag has to opt out while one is open - otherwise the seekbar,
// keyframes, layer bars and the resize handle all still react to a drag
// happening "behind" the dialog.
function modalOpen() {
return $('.modal-open').length > 0;
}
// Dragging a keyframe // Dragging a keyframe
function dragKeyframe(e) { function dragKeyframe(e) {
if (e.which == 3) { if (e.which == 3 || modalOpen()) {
return false; return false;
} }
e.stopPropagation(); e.stopPropagation();
@@ -5014,7 +5093,7 @@ function updateTime(drag, check) {
// Dragging the seekbar // Dragging the seekbar
function dragSeekBar(e) { function dragSeekBar(e) {
if (e.which == 3) { if (e.which == 3 || modalOpen()) {
return false; return false;
} }
// Stop the browser from turning the press into a native drag-and-drop. // Stop the browser from turning the press into a native drag-and-drop.
@@ -5085,7 +5164,7 @@ $(document).on(
// Dragging layer horizontally // Dragging layer horizontally
function dragObjectProps(e) { function dragObjectProps(e) {
if (e.which == 3) { if (e.which == 3 || modalOpen()) {
return false; return false;
} }
var drag = $(this).parent(); var drag = $(this).parent();
@@ -5293,13 +5372,17 @@ function resetHeight() {
'calc(100% - ' + (top + 97) + 'px)' 'calc(100% - ' + (top + 97) + 'px)'
); );
$('#properties').css('height', 'calc(100% - ' + (top + 97) + 'px)'); $('#properties').css('height', 'calc(100% - ' + (top + 97) + 'px)');
$('.panel-handle').css(
'height',
'calc(100% - ' + (top + 97) + 'px)'
);
$('#timeline-handle').css('bottom', top + 95); $('#timeline-handle').css('bottom', top + 95);
resizeCanvas(); resizeCanvas();
} }
// Dragging timeline vertically // Dragging timeline vertically
function dragTimeline(e) { function dragTimeline(e) {
if (e.which == 3) { if (e.which == 3 || modalOpen()) {
return false; return false;
} }
// Suppress text selection for the duration of the drag only - leaving these // Suppress text selection for the duration of the drag only - leaving these
@@ -5325,6 +5408,208 @@ $(document).on('pointerdown', '#timeline-handle', dragTimeline);
oldtimelinepos = $(window).height() - 92 - $('#timearea').height(); oldtimelinepos = $(window).height() - 92 - $('#timearea').height();
// Side panels: horizontal resize and hide/show
// The widths live as CSS variables on :root - every panel rule is a calc() of
// them, so moving a handle only has to write one number.
const RAIL_WIDTH = 76;
const BROWSER_MIN_WIDTH = 210;
const BROWSER_MAX_WIDTH = 640;
const PROPS_MIN_WIDTH = 300;
const PROPS_MAX_WIDTH = 640;
// Canvas width kept free while dragging, whatever the window size
const CANVAS_MIN_WIDTH = 240;
const PANEL_LAYOUT_KEY = 'motionity-panel-layout';
var browserwidth = 299;
var propswidth = 300;
var propshidden = false;
// Tool to reopen the library with, set when it gets collapsed
var lasttool = $('.tool-active').attr('id') || 'shape-tool';
function clampWidth(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function browserHidden() {
return $('#browser').hasClass('collapsed');
}
function applyPanelWidths() {
const root = document.documentElement.style;
const hidden = browserHidden();
var browser = hidden ? 0 : browserwidth;
var properties = propshidden ? 0 : propswidth;
// Shrink to fit rather than squeeze the canvas out of a narrow window. The
// stored widths are left alone, so the panels grow back on a wider one.
var over =
RAIL_WIDTH +
browser +
properties +
CANVAS_MIN_WIDTH -
$(window).width();
if (over > 0 && properties > PROPS_MIN_WIDTH) {
const cut = Math.min(over, properties - PROPS_MIN_WIDTH);
properties -= cut;
over -= cut;
}
if (over > 0 && browser > BROWSER_MIN_WIDTH) {
browser -= Math.min(over, browser - BROWSER_MIN_WIDTH);
}
root.setProperty('--browser-w', browser + 'px');
root.setProperty('--props-w', properties + 'px');
// The layer list and the timeline keep their column width while the library
// is hidden, otherwise layer names would get squashed into the tool rail.
if (!hidden) {
root.setProperty('--layers-w', RAIL_WIDTH + browser + 'px');
}
$('#browser-handle').toggleClass('panel-hidden', hidden);
$('#properties-handle').toggleClass('panel-hidden', propshidden);
$('#properties').toggleClass('collapsed', propshidden);
$('#browser-toggle').attr(
'title',
hidden ? 'Show the library' : 'Hide the library'
);
$('#properties-toggle').attr(
'title',
propshidden ? 'Show the properties' : 'Hide the properties'
);
}
function savePanelLayout() {
try {
localStorage.setItem(
PANEL_LAYOUT_KEY,
JSON.stringify({
browser: browserwidth,
properties: propswidth,
propertieshidden: propshidden,
})
);
} catch (err) {
// Private browsing or a full quota - the layout just won't persist
}
}
function restorePanelLayout() {
var stored;
try {
stored = JSON.parse(localStorage.getItem(PANEL_LAYOUT_KEY));
} catch (err) {
stored = null;
}
if (stored) {
if (typeof stored.browser == 'number') {
browserwidth = clampWidth(
stored.browser,
BROWSER_MIN_WIDTH,
BROWSER_MAX_WIDTH
);
}
if (typeof stored.properties == 'number') {
propswidth = clampWidth(
stored.properties,
PROPS_MIN_WIDTH,
PROPS_MAX_WIDTH
);
}
propshidden = stored.propertieshidden === true;
}
applyPanelWidths();
}
// Dragging a side panel border
function dragPanel(e) {
if (e.which == 3 || modalOpen() || $(this).hasClass('panel-hidden')) {
return false;
}
e.preventDefault();
const handle = $(this);
const isbrowser = handle.attr('id') == 'browser-handle';
const startx = e.pageX;
const startwidth = isbrowser ? browserwidth : propswidth;
// Suppress text selection for the duration of the drag only
const previousSelectStart = document.onselectstart;
document.onselectstart = function () {
return false;
};
handle.addClass('handle-dragging');
function draggingPanelBorder(ev) {
// The properties panel is anchored to the right, so its width grows the
// other way round
const delta = isbrowser ? ev.pageX - startx : startx - ev.pageX;
const otherwidth = isbrowser
? propshidden
? 0
: propswidth
: browserHidden()
? 0
: browserwidth;
const room =
$(window).width() - RAIL_WIDTH - CANVAS_MIN_WIDTH - otherwidth;
if (isbrowser) {
browserwidth = clampWidth(
startwidth + delta,
BROWSER_MIN_WIDTH,
Math.min(BROWSER_MAX_WIDTH, room)
);
} else {
propswidth = clampWidth(
startwidth + delta,
PROPS_MIN_WIDTH,
Math.min(PROPS_MAX_WIDTH, room)
);
}
applyPanelWidths();
resizeCanvas();
}
function releasedPanelBorder() {
document.onselectstart = previousSelectStart || null;
handle.removeClass('handle-dragging');
savePanelLayout();
}
bindPointerDrag(e, this, draggingPanelBorder, releasedPanelBorder);
}
$(document).on('pointerdown', '.panel-handle', dragPanel);
// Keep a click on a hide/show button from starting a resize drag
$(document).on('pointerdown', '.panel-toggle', function (e) {
e.stopPropagation();
});
$(document).on('click', '#browser-toggle', function (e) {
e.stopPropagation();
if (browserHidden()) {
// Reopening goes through the tool click so the rail icons stay in sync
$('#' + lasttool).trigger('click');
} else {
collapsePanel();
}
});
$(document).on('click', '#properties-toggle', function (e) {
e.stopPropagation();
propshidden = !propshidden;
applyPanelWidths();
savePanelLayout();
resizeCanvas();
});
// Re-fits the panels after the window itself changed size. Runs after the
// resizeCanvas listener registered at the top of this file, so the canvas is
// measured again once the new widths are in.
window.addEventListener(
'resize',
function () {
applyPanelWidths();
resizeCanvas();
},
false
);
restorePanelLayout();
resizeCanvas();
// Sync scrolling (vertical) // Sync scrolling (vertical)
function syncScroll(el1, el2) { function syncScroll(el1, el2) {
var $el1 = $(el1); var $el1 = $(el1);
+15 -6
View File
@@ -570,7 +570,7 @@ function updatePanelValues() {
o_slider.setValue(object.get('opacity') * 100); o_slider.setValue(object.get('opacity') * 100);
if (object.get('type') == 'rect') { if (object.get('type') == 'rect') {
$('#object-corners input').val( $('#object-corners input').val(
parseFloat(object.get('rx').toFixed(2)) parseFloat(getCornerRadius(object).toFixed(2))
); );
colormode = 'fill'; colormode = 'fill';
o_fill.setColor(object.get('fill')); o_fill.setColor(object.get('fill'));
@@ -1219,12 +1219,13 @@ $(document).on(
// Switch tool // Switch tool
function switchTool(e) { function switchTool(e) {
$('#browser').removeClass('collapsed');
$('#canvas-area').removeClass('canvas-full');
if ($(this).attr('id') == 'more-tool') { if ($(this).attr('id') == 'more-tool') {
showMore(); showMore();
return false; return false;
} }
$('#browser').removeClass('collapsed');
$('#behind-browser').removeClass('collapsed');
applyPanelWidths();
resizeCanvas(); resizeCanvas();
var act = $('.tool-active'); var act = $('.tool-active');
if (act.attr('id') == 'image-tool') { if (act.attr('id') == 'image-tool') {
@@ -1292,6 +1293,10 @@ function dragObject(e) {
if (e.which == 3) { if (e.which == 3) {
return false; return false;
} }
// Measure before the clone leaves the panel - panel items are sized in
// percentages of a resizable panel, so a clone parented to <body> would
// report the body width instead.
var sourcewidth = $(this).width();
var drag = $(this).clone(); var drag = $(this).clone();
drag.css({ drag.css({
background: 'transparent', background: 'transparent',
@@ -1304,7 +1309,7 @@ function dragObject(e) {
zIndex: 9999999, zIndex: 9999999,
left: $(this).offset().left, left: $(this).offset().left,
top: $(this).offset().top, top: $(this).offset().top,
width: canvas.getZoom() * drag.width(), width: canvas.getZoom() * sourcewidth,
pointerEvents: 'none', pointerEvents: 'none',
opacity: 0, opacity: 0,
}); });
@@ -1619,10 +1624,13 @@ $(document).on(
// Collapse library // Collapse library
function collapsePanel() { function collapsePanel() {
var act = $('.tool-active');
if (act.length > 0) {
// Remembered so the handle button can reopen the same tab
lasttool = act.attr('id');
}
$('#browser').addClass('collapsed'); $('#browser').addClass('collapsed');
$('#behind-browser').addClass('collapsed'); $('#behind-browser').addClass('collapsed');
$('#canvas-area').addClass('canvas-full');
var act = $('.tool-active');
if (act.attr('id') == 'image-tool') { if (act.attr('id') == 'image-tool') {
act.find('img').attr('src', 'assets/image.svg'); act.find('img').attr('src', 'assets/image.svg');
} else if (act.attr('id') == 'text-tool') { } else if (act.attr('id') == 'text-tool') {
@@ -1637,6 +1645,7 @@ function collapsePanel() {
act.find('img').attr('src', 'assets/uploads.svg'); act.find('img').attr('src', 'assets/uploads.svg');
} }
$('.tool-active').removeClass('tool-active'); $('.tool-active').removeClass('tool-active');
applyPanelWidths();
resizeCanvas(); resizeCanvas();
} }
$(document).on('click', '#collapse', collapsePanel); $(document).on('click', '#collapse', collapsePanel);
+141 -24
View File
@@ -7,6 +7,14 @@
--input-color: #22233e; --input-color: #22233e;
--accent-color: #166ef1; --accent-color: #166ef1;
--button-hover: #262746; --button-hover: #262746;
/* Panel geometry. Every rule that used to hardcode 76/299/300/375 derives
from these so the resize handles only have to move one number each.
--layers-w tracks --rail-w + --browser-w while the library panel is
visible, but stays put when it is hidden so the layer names keep room. */
--rail-w: 76px;
--browser-w: 299px;
--props-w: 300px;
--layers-w: 375px;
} }
/* /*
:root { :root {
@@ -41,7 +49,7 @@ body {
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
background: var(--panel-back); background: var(--panel-back);
width: 76px; width: var(--rail-w);
border-right: 1px solid var(--panel-stroke); border-right: 1px solid var(--panel-stroke);
box-sizing: border-box; box-sizing: border-box;
} }
@@ -244,7 +252,7 @@ body {
#toolbar { #toolbar {
position: absolute; position: absolute;
height: calc(100% - 340px); height: calc(100% - 340px);
width: 76px; width: var(--rail-w);
background-color: var(--panel-back); background-color: var(--panel-back);
border-right: 1px solid var(--panel-stroke); border-right: 1px solid var(--panel-stroke);
left: 0px; left: 0px;
@@ -308,8 +316,8 @@ body {
#behind-browser { #behind-browser {
position: absolute; position: absolute;
height: 100%; height: 100%;
width: 299px; width: var(--browser-w);
left: 76px; left: var(--rail-w);
background: var(--panel-back); background: var(--panel-back);
border-right: 1px solid var(--panel-stroke); border-right: 1px solid var(--panel-stroke);
z-index: 1; z-index: 1;
@@ -318,10 +326,10 @@ body {
#browser { #browser {
position: absolute; position: absolute;
height: calc(100% - 450px); height: calc(100% - 450px);
width: 299px; width: var(--browser-w);
background-color: var(--panel-back); background-color: var(--panel-back);
border-right: 1px solid var(--panel-stroke); border-right: 1px solid var(--panel-stroke);
left: 76px; left: var(--rail-w);
top: 110px; top: 110px;
box-sizing: border-box; box-sizing: border-box;
z-index: 999999; z-index: 999999;
@@ -333,7 +341,7 @@ body {
display: none !important; display: none !important;
} }
#browser-container { #browser-container {
width: 260px; width: calc(var(--browser-w) - 39px);
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
height: 100%; height: 100%;
@@ -359,7 +367,7 @@ body {
} }
.image-grid-item, .image-grid-item,
.video-grid-item { .video-grid-item {
width: 120px; width: 100%;
margin-bottom: 15px; margin-bottom: 15px;
position: relative; position: relative;
} }
@@ -388,7 +396,7 @@ body {
text-align: right; text-align: right;
text-decoration: none; text-decoration: none;
text-shadow: 0px 1px 5px #000000; text-shadow: 0px 1px 5px #000000;
width: 110px; width: calc(100% - 10px);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -436,7 +444,9 @@ body {
grid-gap: 15px; grid-gap: 15px;
width: 100%; width: 100%;
grid-auto-columns: auto; grid-auto-columns: auto;
grid-template-columns: 52px 52px 52px 52px; /* Column count follows the panel width so the grid reflows when the library
is resized instead of overflowing a fixed four-column track. */
grid-template-columns: repeat(auto-fill, minmax(52px, 1fr));
overflow: hidden; overflow: hidden;
} }
.scroll-row:before { .scroll-row:before {
@@ -461,7 +471,7 @@ body {
#search-fixed { #search-fixed {
position: fixed; position: fixed;
top: 0px; top: 0px;
width: 279px; width: calc(var(--browser-w) - 20px);
padding-left: 19px; padding-left: 19px;
margin-left: -19px; margin-left: -19px;
z-index: 999999; z-index: 999999;
@@ -776,12 +786,13 @@ body {
position: absolute; position: absolute;
right: 0px; right: 0px;
top: 0px; top: 0px;
width: 300px; width: var(--props-w);
height: calc(100% - 340px); height: calc(100% - 340px);
background-color: var(--panel-back); background-color: var(--panel-back);
border-left: 1px solid var(--panel-stroke); border-left: 1px solid var(--panel-stroke);
z-index: 999; z-index: 999;
overflow-y: overlay; overflow-y: overlay;
overflow-x: hidden;
} }
#properties-overlay { #properties-overlay {
width: 100%; width: 100%;
@@ -830,9 +841,12 @@ hr {
background-color: var(--panel-stroke); background-color: var(--panel-stroke);
margin-bottom: 20px; margin-bottom: 20px;
} }
#properties hr {
width: calc(var(--props-w) - 40px);
}
/* Property sections */ /* Property sections */
.panel-section { .panel-section {
width: 260px; width: calc(var(--props-w) - 40px);
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
} }
@@ -869,8 +883,17 @@ th {
width: 189px; width: 189px;
display: flex; display: flex;
align-items: center; align-items: center;
/* Keeps the inputs against the right edge once the column can be wider
than its contents */
justify-content: flex-end;
margin-left: auto; margin-left: auto;
} }
/* Inside the properties panel the value column follows the panel width, so
dropdowns and sliders grow when the panel is widened. 111px is the label
column plus the panel gutters. The filters popup keeps the fixed width. */
#properties .value-col {
width: calc(var(--props-w) - 111px);
}
/* Dropdows */ /* Dropdows */
.nice-select, .nice-select,
.list, .list,
@@ -1550,16 +1573,12 @@ label span {
#canvas-area { #canvas-area {
position: absolute; position: absolute;
top: 0px; top: 0px;
left: 375px; left: calc(var(--rail-w) + var(--browser-w));
height: calc(100% - 342px); height: calc(100% - 342px);
width: calc(100% - 675px); width: calc(100% - var(--rail-w) - var(--browser-w) - var(--props-w));
box-sizing: border-box; box-sizing: border-box;
overflow: hidden; overflow: hidden;
} }
.canvas-full {
left: 76px !important;
width: calc(100% - 376px) !important;
}
.canvas-container { .canvas-container {
left: 0px; left: 0px;
top: 0px; top: 0px;
@@ -1614,7 +1633,7 @@ canvas {
#layer-list { #layer-list {
position: absolute; position: absolute;
height: 245px; height: 245px;
width: 375px; width: var(--layers-w);
left: 0px; left: 0px;
bottom: 60px; bottom: 60px;
background-color: var(--panel-back); background-color: var(--panel-back);
@@ -1816,7 +1835,7 @@ layer:nth-child(even) .properties {
color: var(--secondary-text-color); color: var(--secondary-text-color);
line-height: 40px; line-height: 40px;
height: 40px; height: 40px;
width: 375px; width: var(--layers-w);
position: fixed; position: fixed;
margin-top: -35px; margin-top: -35px;
background-color: var(--panel-back); background-color: var(--panel-back);
@@ -1840,10 +1859,10 @@ layer:nth-child(even) .properties {
#timearea { #timearea {
position: absolute; position: absolute;
bottom: 60px; bottom: 60px;
left: 375px; left: var(--layers-w);
height: 245px; height: 245px;
background-color: var(--main-back); background-color: var(--main-back);
width: calc(100% - 375px); width: calc(100% - var(--layers-w));
border-top: 1px solid var(--panel-stroke); border-top: 1px solid var(--panel-stroke);
z-index: 999; z-index: 999;
} }
@@ -1858,10 +1877,108 @@ layer:nth-child(even) .properties {
cursor: ns-resize; cursor: ns-resize;
background-color: var(--panel-stroke); background-color: var(--panel-stroke);
} }
/* Side panel resize handles. Each one straddles the border between a side
panel and the canvas, and carries the hide/show button for that panel.
Their height is kept in sync with the panels by resetHeight(). */
.panel-handle {
position: absolute;
top: 0px;
height: calc(100% - 340px);
width: 7px;
z-index: 9999999;
box-sizing: border-box;
touch-action: none;
-webkit-user-drag: none;
-webkit-user-select: none;
user-select: none;
}
#browser-handle {
left: calc(var(--rail-w) + var(--browser-w) - 3px);
}
#properties-handle {
right: calc(var(--props-w) - 3px);
}
#properties-handle.panel-hidden {
right: 0px;
}
.panel-handle:not(.panel-hidden):hover,
.panel-handle.handle-dragging {
cursor: ew-resize;
}
.panel-handle:before {
content: "";
position: absolute;
left: 2px;
top: 0px;
width: 2px;
height: 100%;
background-color: var(--accent-color);
opacity: 0;
}
.panel-handle:not(.panel-hidden):hover:before,
.panel-handle.handle-dragging:before {
opacity: 1;
}
.panel-toggle {
position: absolute;
top: 50%;
margin-top: -18px;
width: 18px;
height: 36px;
background-color: var(--input-color);
border: 1px solid var(--panel-stroke);
box-sizing: border-box;
/* Always on screen, dimmed. Revealing it on hover meant having to find the
3px resize line first, then travel onto the button. */
opacity: 0.4;
}
.panel-toggle:hover {
cursor: pointer;
background-color: var(--button-hover);
}
.panel-toggle:hover,
.panel-handle:hover .panel-toggle,
.panel-handle.handle-dragging .panel-toggle,
.panel-handle.panel-hidden .panel-toggle {
opacity: 1;
}
/* Chevron, pointing at the edge the panel would collapse towards */
.panel-toggle:after {
content: "";
position: absolute;
width: 5px;
height: 5px;
top: 50%;
left: 50%;
border-left: 1.5px solid var(--secondary-text-color);
border-bottom: 1.5px solid var(--secondary-text-color);
}
#browser-handle .panel-toggle {
left: 5px;
border-radius: 0px 4px 4px 0px;
}
#properties-handle .panel-toggle {
right: 5px;
border-radius: 4px 0px 0px 4px;
}
#properties-handle.panel-hidden .panel-toggle {
right: 4px;
}
#browser-handle .panel-toggle:after,
#properties-handle.panel-hidden .panel-toggle:after {
margin: -4px 0px 0px -2px;
transform: rotate(45deg);
}
#properties-handle .panel-toggle:after,
#browser-handle.panel-hidden .panel-toggle:after {
margin: -4px 0px 0px -5px;
transform: rotate(-135deg);
}
#seekarea { #seekarea {
height: 100%; height: 100%;
z-index: 99999; z-index: 99999;
width: calc(100% - 375px); /* Fixed positioning - the percentage is the viewport, not #timearea. */
width: calc(100% - var(--layers-w));
overflow-x: auto; overflow-x: auto;
overflow-y: hidden; overflow-y: hidden;
position: fixed; position: fixed;