Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
298ca15b97 | ||
|
|
1996647dfe | ||
|
|
45c37a5e73 | ||
|
|
fe57429603 | ||
|
|
094bfbdc60 | ||
|
|
414e0ee30c | ||
|
|
cc439c1152 | ||
|
|
e775ea478f | ||
|
|
dcb804e48e | ||
|
|
f380249c1c | ||
|
|
a87716dc5b | ||
|
|
109f737c2a | ||
|
|
d808c715fd |
@@ -0,0 +1,72 @@
|
|||||||
|
name: Sync Gitea releases to GitHub
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
env:
|
||||||
|
GITEA_URL: https://git.azuze.fr
|
||||||
|
GITEA_OWNER: kawa
|
||||||
|
GITEA_REPO: Motionity
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sync-releases:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install jq
|
||||||
|
run: sudo apt-get update && sudo apt-get install -y jq
|
||||||
|
|
||||||
|
- name: Fetch Gitea releases and sync to GitHub
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
AUTH_HEADER=()
|
||||||
|
if [ -n "${GITEA_TOKEN:-}" ]; then
|
||||||
|
AUTH_HEADER=(-H "Authorization: token ${GITEA_TOKEN}")
|
||||||
|
fi
|
||||||
|
# Récupère toutes les releases Gitea (pagination simple, 50 max, ajuster si besoin)
|
||||||
|
releases=$(curl -sf "${AUTH_HEADER[@]}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/releases?limit=50")
|
||||||
|
echo "$releases" | jq -c '.[]' | while read -r release; do
|
||||||
|
tag=$(echo "$release" | jq -r '.tag_name')
|
||||||
|
name=$(echo "$release" | jq -r '.name // .tag_name')
|
||||||
|
body=$(echo "$release" | jq -r '.body // ""')
|
||||||
|
prerelease=$(echo "$release" | jq -r '.prerelease')
|
||||||
|
draft=$(echo "$release" | jq -r '.draft')
|
||||||
|
# Skip si la release existe déjà sur GitHub
|
||||||
|
if gh release view "$tag" >/dev/null 2>&1; then
|
||||||
|
echo "Release $tag existe déjà sur GitHub, on passe."
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
echo "Création de la release $tag sur GitHub..."
|
||||||
|
flags=()
|
||||||
|
[ "$prerelease" = "true" ] && flags+=(--prerelease)
|
||||||
|
[ "$draft" = "true" ] && flags+=(--draft)
|
||||||
|
# Le tag doit exister sur le repo GitHub ; s'il n'existe pas encore,
|
||||||
|
# on utilise --notes-file avec target par défaut (branche par défaut)
|
||||||
|
gh release create "$tag" \
|
||||||
|
--title "$name" \
|
||||||
|
--notes "$body" \
|
||||||
|
"${flags[@]}"
|
||||||
|
# Télécharge et attache les assets de la release Gitea
|
||||||
|
asset_count=$(echo "$release" | jq '.assets | length')
|
||||||
|
if [ "$asset_count" -gt 0 ]; then
|
||||||
|
tmpdir=$(mktemp -d)
|
||||||
|
echo "$release" | jq -c '.assets[]' | while read -r asset; do
|
||||||
|
asset_name=$(echo "$asset" | jq -r '.name')
|
||||||
|
asset_url=$(echo "$asset" | jq -r '.browser_download_url')
|
||||||
|
echo "Téléchargement de $asset_name..."
|
||||||
|
curl -sfL "${AUTH_HEADER[@]}" -o "${tmpdir}/${asset_name}" "$asset_url"
|
||||||
|
gh release upload "$tag" "${tmpdir}/${asset_name}" --clobber
|
||||||
|
done
|
||||||
|
rm -rf "$tmpdir"
|
||||||
|
fi
|
||||||
|
done
|
||||||
+84
-18
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -1,35 +1,162 @@
|
|||||||
|
<div align="center">
|
||||||
|
|
||||||
|
<img src="src/assets/logo.svg" alt="Motionity" width="72">
|
||||||
|
|
||||||
# Motionity
|
# Motionity
|
||||||
|
|
||||||
Web-based motion graphics editor with keyframing, masking, filters and text animations.
|
**Web-based motion graphics editor** — keyframing, masking, filters, text animations.
|
||||||
|
A free alternative to After Effects and Canva, running entirely in the browser.
|
||||||
|
|
||||||
This is a fork of the original [Motionity](https://github.com/alyssaxuu/motionity) by [@alyssaxuu](https://github.com/alyssaxuu), with bug fixes and enhancements.
|
### ▶ [Try the live demo — motionity.kawa.zip](https://motionity.kawa.zip/)
|
||||||
|
|
||||||
## Quick Start
|
[](https://motionity.kawa.zip/)
|
||||||
|
[](LICENSE)
|
||||||
|
[](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):
|
||||||
|
|
||||||
**Web (localhost only):**
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm run vendor
|
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
|
npm start # http://127.0.0.1:8080
|
||||||
```
|
```
|
||||||
|
|
||||||
**Desktop:**
|
```bash
|
||||||
|
HOST=0.0.0.0 PORT=3000 npm start # bind elsewhere
|
||||||
|
```
|
||||||
|
|
||||||
|
### From source — desktop
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm run vendor
|
npm run vendor
|
||||||
npm run dev # Electron app
|
npm run dev # Electron
|
||||||
```
|
```
|
||||||
|
|
||||||
**Docker:**
|
> 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 run docker:build
|
|
||||||
npm run docker:run # http://localhost:8080
|
|
||||||
```
|
|
||||||
|
|
||||||
Full build instructions (Windows installers, Linux AppImage/Flatpak) in [PACKAGING.md](PACKAGING.md).
|
### Two things to know
|
||||||
|
|
||||||
## Notes
|
- **`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.
|
||||||
|
|
||||||
- `ffmpeg.wasm` is vendored; `npm install` + `npm run vendor` are required
|
Full packaging details, WSL notes and troubleshooting: **[PACKAGING.md](PACKAGING.md)**.
|
||||||
- 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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
+1
-7
@@ -1,12 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
motionity:
|
motionity:
|
||||||
build:
|
image: git.azuze.fr/kawa/motionity:latest
|
||||||
context: .
|
|
||||||
# WITH_FFMPEG: "0" trims 18.5 MB by fetching the MP4/GIF encoder from
|
|
||||||
# archive.org at runtime instead of shipping it.
|
|
||||||
args:
|
|
||||||
WITH_FFMPEG: "1"
|
|
||||||
image: motionity:latest
|
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
+4
-1
@@ -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",
|
||||||
|
|||||||
+223
-5
@@ -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 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
Invoke-Checked $builder $builderArgs
|
||||||
|
}
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+23
-3
@@ -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)
|
||||||
|
|||||||
+27
-7
@@ -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>
|
||||||
@@ -103,6 +102,22 @@
|
|||||||
<p class="header-2">Export this project</p>
|
<p class="header-2">Export this project</p>
|
||||||
<div id="export-project"><img src="assets/download-icon.svg"> <span>Export</span></div>
|
<div id="export-project"><img src="assets/download-icon.svg"> <span>Export</span></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="credits-modal">
|
||||||
|
<p class="header">Credits</p>
|
||||||
|
<p class="subtitle">Created by <span><a href="https://github.com/alyssaxuu">Alyssa X</a></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;">
|
||||||
|
<p class="subheader">Libraries & Tools</p>
|
||||||
|
<div id="credits-list">
|
||||||
|
<div class="credit-item">Fabric.js</div>
|
||||||
|
<div class="credit-item">Anime.js</div>
|
||||||
|
<div class="credit-item">Lottie</div>
|
||||||
|
<div class="credit-item">Pickr</div>
|
||||||
|
<div class="credit-item">Sortable.js</div>
|
||||||
|
<div class="credit-item">FFmpeg.wasm</div>
|
||||||
|
<div class="credit-item">jQuery</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div id="background-overlay"></div>
|
<div id="background-overlay"></div>
|
||||||
<div id="color-picker"></div>
|
<div id="color-picker"></div>
|
||||||
<div id="color-picker-fill"></div>
|
<div id="color-picker-fill"></div>
|
||||||
@@ -290,14 +305,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="bottom-canvas">
|
<div id="bottom-canvas">
|
||||||
<a id="sponsor" href="https://github.com/sponsors/alyssaxuu" target="_blank"><img src="assets/sponsor.svg"> Sponsor</a>
|
|
||||||
<a id="alyssa-credit" href="https://twitter.com/alyssaxuu" target="_blank">Made by <span>Alyssa X</span> <img src="assets/alyssaimg.jpeg"></a>
|
|
||||||
</div>
|
</div>
|
||||||
<img src="assets/replace-image.svg" id="replace-image">
|
<img src="assets/replace-image.svg" id="replace-image">
|
||||||
<img src="assets/loading-image.svg" id="load-image" class="load-media">
|
<img src="assets/loading-image.svg" id="load-image" class="load-media">
|
||||||
<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">
|
||||||
@@ -375,6 +394,7 @@
|
|||||||
<div id="share"><img src="assets/importexport.svg"> Import & export</div>
|
<div id="share"><img src="assets/importexport.svg"> Import & export</div>
|
||||||
<div id="download"><img src="assets/download-icon.svg"> Download</div>
|
<div id="download"><img src="assets/download-icon.svg"> Download</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="credits-button">Credits</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<video id="test-video"></video>
|
<video id="test-video"></video>
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
+353
-68
@@ -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,
|
||||||
|
top: centerY + center.y,
|
||||||
})
|
})
|
||||||
.setCoords();
|
.setCoords();
|
||||||
canvas.renderAll();
|
canvas.renderAll();
|
||||||
obj.set({
|
|
||||||
left: cleft + (obj.get('width') * obj.get('scaleX')) / 2,
|
|
||||||
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);
|
||||||
|
|||||||
+23
-6
@@ -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);
|
||||||
@@ -1785,6 +1794,14 @@ function importExportModal() {
|
|||||||
}
|
}
|
||||||
$('#share').on('click', importExportModal);
|
$('#share').on('click', importExportModal);
|
||||||
|
|
||||||
|
// Open credits modal
|
||||||
|
function creditsModal() {
|
||||||
|
hideModals();
|
||||||
|
$('#credits-modal').toggleClass('modal-open');
|
||||||
|
$('#background-overlay').toggleClass('modal-open');
|
||||||
|
}
|
||||||
|
$('#credits-button').on('click', creditsModal);
|
||||||
|
|
||||||
function searchInput() {
|
function searchInput() {
|
||||||
var value = $(this).val().toLowerCase();
|
var value = $(this).val().toLowerCase();
|
||||||
if (value == '') {
|
if (value == '') {
|
||||||
|
|||||||
+205
-76
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -204,6 +212,27 @@ body {
|
|||||||
.hand-active:hover {
|
.hand-active:hover {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
#credits-button {
|
||||||
|
width: 100px;
|
||||||
|
position: absolute;
|
||||||
|
right: 330px;
|
||||||
|
bottom: 12px;
|
||||||
|
height: 38px;
|
||||||
|
line-height: 38px;
|
||||||
|
text-align: center;
|
||||||
|
background-color: #30314e;
|
||||||
|
color: var(--main-text-color);
|
||||||
|
border-radius: 5px;
|
||||||
|
box-shadow: inset 0px 2px 2px rgba(255, 255, 255, 0.05),
|
||||||
|
inset 0px -2px 1px rgba(0, 0, 0, 0.05);
|
||||||
|
font-family: Inter;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
#credits-button:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
/* Bottom of canvas */
|
/* Bottom of canvas */
|
||||||
#bottom-canvas {
|
#bottom-canvas {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -212,58 +241,6 @@ body {
|
|||||||
z-index: 9999999;
|
z-index: 9999999;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
#sponsor {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 10px;
|
|
||||||
left: -10px;
|
|
||||||
height: 32px;
|
|
||||||
line-height: 32px;
|
|
||||||
font-family: Inter;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--accent-color);
|
|
||||||
background: rgba(22, 95, 205, 0.1);
|
|
||||||
text-align: center;
|
|
||||||
text-decoration: none;
|
|
||||||
padding-left: 10px;
|
|
||||||
padding-right: 10px;
|
|
||||||
border-radius: 5px;
|
|
||||||
}
|
|
||||||
#sponsor img {
|
|
||||||
margin-right: 3px;
|
|
||||||
margin-bottom: -2px;
|
|
||||||
}
|
|
||||||
#sponsor:hover {
|
|
||||||
background: rgba(22, 95, 205, 0.3) !important;
|
|
||||||
}
|
|
||||||
#alyssa-credit {
|
|
||||||
color: var(--secondary-text-color);
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
position: absolute;
|
|
||||||
right: -20px;
|
|
||||||
bottom: 15px;
|
|
||||||
font-family: Inter;
|
|
||||||
width: 200px;
|
|
||||||
filter: drop-shadow(0px 1px 15px #141629);
|
|
||||||
text-decoration: none !important;
|
|
||||||
}
|
|
||||||
#alyssa-credit:hover {
|
|
||||||
filter: drop-shadow(0px 1px 10px #141629) !important;
|
|
||||||
}
|
|
||||||
#alyssa-credit span {
|
|
||||||
color: var(--main-text-color) !important;
|
|
||||||
text-decoration: none !important;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
#alyssa-credit img {
|
|
||||||
display: inline-block;
|
|
||||||
margin-left: 5px;
|
|
||||||
border-radius: 50%;
|
|
||||||
vertical-align: middle;
|
|
||||||
width: 18px;
|
|
||||||
margin-top: -2px;
|
|
||||||
}
|
|
||||||
.hide-folder {
|
.hide-folder {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
@@ -275,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;
|
||||||
@@ -339,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;
|
||||||
@@ -349,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;
|
||||||
@@ -364,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%;
|
||||||
@@ -390,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;
|
||||||
}
|
}
|
||||||
@@ -419,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;
|
||||||
@@ -467,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 {
|
||||||
@@ -492,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;
|
||||||
@@ -807,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%;
|
||||||
@@ -861,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;
|
||||||
}
|
}
|
||||||
@@ -900,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,
|
||||||
@@ -1375,6 +1367,49 @@ input[type="number"] {
|
|||||||
display: block;
|
display: block;
|
||||||
z-index: 99999999999;
|
z-index: 99999999999;
|
||||||
}
|
}
|
||||||
|
/* Credits modal */
|
||||||
|
#credits-modal {
|
||||||
|
width: 300px;
|
||||||
|
max-height: 400px;
|
||||||
|
background-color: var(--panel-back);
|
||||||
|
border: 1px solid var(--panel-stroke);
|
||||||
|
border-radius: 5px;
|
||||||
|
box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.3);
|
||||||
|
position: absolute;
|
||||||
|
right: 280px;
|
||||||
|
bottom: 60px;
|
||||||
|
visibility: hidden;
|
||||||
|
display: block;
|
||||||
|
z-index: 99999999999;
|
||||||
|
padding-bottom: 15px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
#credits-modal .header {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
#credits-modal .subtitle {
|
||||||
|
margin-left: 20px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
#credits-modal .subtitle span {
|
||||||
|
color: var(--main-text-color) !important;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
#credits-modal .subheader {
|
||||||
|
margin-top: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
#credits-list {
|
||||||
|
margin-left: 20px;
|
||||||
|
margin-right: 20px;
|
||||||
|
}
|
||||||
|
.credit-item {
|
||||||
|
color: var(--secondary-text-color);
|
||||||
|
font-family: Inter;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
.subtitle {
|
.subtitle {
|
||||||
color: var(--secondary-text-color);
|
color: var(--secondary-text-color);
|
||||||
font-family: Inter;
|
font-family: Inter;
|
||||||
@@ -1538,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;
|
||||||
@@ -1602,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);
|
||||||
@@ -1804,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);
|
||||||
@@ -1828,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;
|
||||||
}
|
}
|
||||||
@@ -1846,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;
|
||||||
|
|||||||
Reference in New Issue
Block a user