Compare commits

..
Author SHA1 Message Date
dependabot[bot] 3af4e25dd1 chore(deps): bump python from 3.14.9-slim to 3.15.0-slim
Bumps python from 3.14.9-slim to 3.15.0-slim.

---
updated-dependencies:
- dependency-name: python
  dependency-version: 3.15.0-slim
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-29 17:34:04 +00:00
RaresKeY 8b8fc62449 fix(docker): require the patched Debian Brotli decoder (#6956)
* fix(docker): require the patched Debian Brotli decoder

Refresh the signed Debian native Brotli package in a shared build-stage base and fail builds below the security backport floor. Verify the actual selected library, native fix marker, valid decoding, and distro Node linkage before accepting the runtime.

* fix(docker): guard HTTPS trust and accept Bookworm Brotli

Check the inherited system CA store before the shared base makes its first HTTPS package request. Accept the exact fixed Debian 12 marker alongside Debian 13 while retaining package floors, decoder ownership, and valid decoding controls.

* fix(docker): preserve Brotli gate diagnostics
2026-10-29 15:05:58 +01:00
Alexandre Teixeira 0f996f58da Merge pull request #6972 from odysseus-dev/test/env-reference-cli-fixture-performance
test: isolate environment-reference CLI state transitions
2026-10-29 10:31:08 +00:00
Alexandre Teixeira 18329268b0 Merge pull request #6974 from odysseus-dev/test/import-stub-guard-snapshot-performance
test: avoid tuple-list allocation in the import-stub guard
2026-10-29 10:30:50 +00:00
Alexandre Teixeira 6cc98f34b8 Merge pull request #6740 from odysseus-dev/codex/review-7011-preview-ui-20261015
fix(ui): integrate streaming, model picker and Notes updates
2026-10-29 10:29:51 +00:00
Alexandre Teixeira c493a66101 Merge pull request #6960 from odysseus-dev/test/remove-duplicate-editor-cache-check
test: remove duplicate editor cache-name check
2026-10-29 10:28:06 +00:00
Alexandre Teixeira d5949c0286 Merge pull request #6961 from odysseus-dev/test/restore-regression-test-sensitivity
test: restore active-document and owner-isolation sensitivity
2026-10-29 10:26:18 +00:00
Alexandre Teixeira 9ec897d947 Merge pull request #6964 from odysseus-dev/test/imap-concurrent-delete-regression
test: simulate concurrent IMAP deletion at the protocol boundary
2026-10-29 10:25:48 +00:00
Alexandre Teixeira 9257f0e6d0 Merge pull request #6965 from odysseus-dev/test/admin-route-authorization-regressions
test: enforce behavioral admin authorization on listings and diagnostics
2026-10-29 10:25:36 +00:00
Alexandre Teixeira a334a21d57 Merge pull request #6967 from odysseus-dev/test/chat-document-owner-context
test: protect chat document context owner boundaries
2026-10-29 10:24:53 +00:00
Alexandre Teixeira 87ef52f7cc test: avoid tuple-list allocation in the import-stub guard 2026-10-29 08:54:46 +00:00
Alexandre Teixeira 25617a8c10 test: isolate environment-reference CLI state transitions 2026-10-29 08:44:19 +00:00
Alexandre Teixeira 4992cc620f test: protect chat document context owner boundaries 2026-10-29 02:09:37 +00:00
Alexandre Teixeira 59cca6b915 fix(upload): canonicalize confinement on symlinked upload roots 2026-10-29 02:02:47 +00:00
Alexandre Teixeira 1ddda10716 test: exercise real admin gates before listing data 2026-10-29 01:58:21 +00:00
Alexandre Teixeira 958848f9f8 test: interleave concurrent IMAP deletion before expunge 2026-10-29 01:51:41 +00:00
Alexandre Teixeira 571ac09cea fix(security): sanitize file_id and enforce strict prefix confinement on upload paths 2026-10-29 01:39:41 +00:00
Alexandre Teixeira f979e5602f fix(upload,sw): sanitize and confine upload paths and align service worker precache token 2026-10-29 01:35:07 +00:00
Alexandre Teixeira 0f95032177 test: restore active-document and owner-isolation sensitivity 2026-10-29 01:29:04 +00:00
Alexandre Teixeira b3e2e628ab fix(upload,ui): confine thumbnail paths and align stylesheet cache-bust token 2026-10-29 01:25:07 +00:00
Alexandre Teixeira f1a45e85c5 test: remove duplicate editor cache-name assertion 2026-10-29 01:19:32 +00:00
Alexandre Teixeira f843a3dc01 merge(dev): integrate latest upstream dev (PR #6498 omit temperature from subscription requests) 2026-10-29 01:12:31 +00:00
Alexandre Teixeira 72ba37af6b merge(dev): integrate reviewed preview UI changes 2026-10-29 01:12:08 +00:00
Raul JuarezandRaul 5bf4524618 fix(chatgpt): omit temperature from subscription requests (#6498)
Co-authored-by: Raul <9117159+raultcj@users.noreply.github.com>
2026-10-29 01:34:52 +01:00
Alexandre Teixeira 1a7c79946b Merge pull request #6741 from odysseus-dev/codex/review-7011-photo-editor-20261015
feat(editor): integrate layer tools, document tabs and editor UI
2026-10-28 21:27:53 +00:00
Alexandre Teixeira 6d102a213f fix(security): confine teardown paths and avoid interpolating exception data in logs
- routes/gallery/gallery_routes.py: use stable diagnostic log message without
  interpolating raw exception, resolver details, tokens, or destination strings.
- tests/e2e/teardown.js: remove environment-variable path overrides from production
  code, strictly confine test options and temp directories, and prevent arbitrary
  directory cleanup.
- Add regression tests covering non-leakage of synthetic tokens/queries/exceptions
  into logs and responses, and path confinement during teardown.
2026-10-28 21:14:43 +00:00
Alexandre Teixeira de8f151632 fix(security): harden trace teardown and gallery error responses
- CodeQL #1202 (tests/e2e/teardown.js): replace execSync shell execution with
  execFileSync, passing python executable and sanitizer arguments separately with
  shell: false. Preserve ODYSSEUS_TEST_PYTHON support, timeout handling, and
  fail-closed deletion.
- CodeQL #1203 (routes/gallery/gallery_routes.py): prevent exception information
  exposure in /api/image/edit-progress by returning a generic rejection response
  without leaking internal hostnames, IPs, resolver errors, or credentials.
- Add regression tests covering paths with spaces, metacharacters, synthetic command
  substitutions, process failure modes, fail-closed artifact purge, and SSRF
  rejection confidentiality.
2026-10-28 21:10:12 +00:00
Alexandre Teixeira cce5892eeb test(css): update approved photo editor visual baseline
Record the Product Owner-approved 13 element changes across 48 page/variant
combinations for PR #6741. The regenerated baseline exactly matches the
visually reviewed candidate; retain the upstream PR #6840 sidebar styles.

Validation: 49 CSS snapshot/layout tests and 26 frontend/mobile browser tests.
Visual acceptance evidence remains outside the repository.
2026-10-28 20:56:09 +00:00
Alexandre Teixeira f4e3ae6e68 test(security): allowlist synthetic dummy api key in trace sanitizer test fixture
- Add gitleaks allowlist rule for synthetic test string in tests/test_trace_artifact_sanitizer.py
- Resolves Gitleaks generic-api-key false positive on GitHub Actions CI
2026-10-28 19:52:10 +00:00
Alexandre Teixeira 671cbd79ac merge(dev): integrate latest upstream dev (PR #6840 sidebar animations)
- Merge upstream/dev at 0116f0a62b
- Incorporate upstream sidebar toggle animation and auto-collapse measurement fixes
- Preserve upstream CSS snapshot baseline in tests/css_snapshot/baseline.json
- Retain all PR #6741 photo-editor features, security controls, and mobile fixes
2026-10-28 19:30:57 +00:00
Alexandre Teixeira 2e1c2a343a merge(dev): reconcile photo editor security and upstream changes
- Reconcile conflict resolution in routes/editor_agent_routes.py and src/editor_agent_model.py
- Enforce strict server-authorized endpoint resolution and reject client URL overrides
- Enforce bearer-token privilege separation: delegated API tokens are capped at non-admin scope per src/tool_security.py and cannot inspect private base URLs or probe metadata
- Preserve chat-scope authorization, missing-session 401 rejection, and owner-scoped browser bridge/SSE queues
- Preserve administrator-disabled-tool denylist enforcement introduced by PR #6357
- Fix mobile toolbar flyout hit-testing and stacking context trapping on viewports <= 700px via scoped elevation and mask suppression
- Stabilize project recovery test fixture by awaiting document draft quiescence prior to baseline capture
2026-10-28 19:30:08 +00:00
Alexandre Teixeira 0116f0a62b Merge pull request #6840 from eesoymilk/fix/sidebar-toggle-animation
fix(sidebar): animate the sidebar toggle instead of snapping
2026-10-28 18:15:15 +00:00
Alexandre Teixeira c237a1cb9c feat(editor): harden photo editor security and interactions 2026-10-28 17:37:10 +00:00
57a1859f08 fix(security): stop the agent from re-enabling tools an admin disabled (#6357)
* fix(security): stop the agent from re-enabling tools an admin disabled

settings.json:disabled_tools is the only global tool-permission boundary
the Settings > Agent Tools panel offers, yet manage_settings exposed an
`enable_tool` action that removed entries from it. Any agent turn could
undo the admin's restriction, and because the agent reads untrusted web
pages, emails and documents, a planted instruction could re-enable bash,
email or any other tool that had been switched off.

The denylist can now only be narrowed from a turn: `disable_tool` and
`list_tools` keep working, `enable_tool` is refused with a message that
points the user to Settings > Agent Tools, and the action is dropped from
the function schema, the prompt rule and the tool index so models are not
steered into calling it. The free-form set/delete/reset actions were
already unable to reach `disabled_tools`; a test now pins that.

Fixes #5523

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UAB5sz5srMKR7rbeDFtdMH

* fix(settings): close internal API tool-policy bypass

Refuse global tool-policy replacement from internal-token requests even when an admin owner is impersonated, and block the same write in app_api. Route explicit image re-enable requests to list_tools with a Settings pointer. Keep disabled_tools outside general settings actions even if a default is added later. Remove the retired action from capability metadata. Validation: 573 focused tests passed; the running app rejected handler, generic API and encoded-path internal HTTP attempts while preserving human administrator controls.

* fix(mcp): block agent writes to per-server MCP tool policy

PATCH /api/mcp/servers/{id}/tools only checked require_admin, which accepts
the internal tool token, and app_api still forwarded it, so an agent turn
could re-enable MCP tools an admin had switched off. Refuse the internal
token on that route (same check as POST /api/tools) and add PATCH
/api/mcp/servers to the app_api blocklist, with regression tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BCbhLNC7mJsunxqfwwshqn

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Afonso Junior <chatgpt@yack.com.br>
2026-10-27 22:50:35 -06:00
Alexandre Teixeira a646b59110 Merge pull request #6738 from odysseus-dev/codex/review-7011-harness-20261015
feat(harness): integrate Ajax routing, email context and memory updates
2026-10-28 01:09:22 +00:00
pewdiepie-archdaemon da8ba69c74 Keep streamed-chat module identity consistently cache-busted 2026-10-26 16:57:01 +00:00
pewdiepie-archdaemon 0b8176b11e Keep streamed-chat module identity consistently cache-busted 2026-10-26 16:57:01 +00:00
pewdiepie-archdaemon 375e263419 Merge backend ownership checks and test ownership cleanup 2026-10-26 16:53:52 +00:00
pewdiepie-archdaemon 106b1e7c86 Verify saved action names and steps cannot inject HTML 2026-10-26 16:50:09 +00:00
pewdiepie-archdaemon 49d57a43fe Merge recolor cleanup regression coverage 2026-10-26 16:50:09 +00:00
pewdiepie-archdaemon 2c48e7fdab Verify the Gallery model picker in the UI review that ships it 2026-10-26 16:49:01 +00:00
pewdiepie-archdaemon 9ab440c4cb Keep Gallery image-model picker tests with the editor client 2026-10-26 16:49:01 +00:00
pewdiepie-archdaemon fa29a86696 Merge backend prompt hardening and scoped tests 2026-10-26 16:49:01 +00:00
pewdiepie-archdaemon 8cfdb8e2e5 Render saved action labels as text instead of HTML 2026-10-26 16:49:01 +00:00
pewdiepie-archdaemon 76ab72412d Preserve completion gating and handle explicit idle stream status safely 2026-10-26 16:34:56 +00:00
pewdiepie-archdaemon 56b85c359c Normalize final newlines in integrated review files 2026-10-26 16:31:23 +00:00
pewdiepie-archdaemon 04ca6c26c3 Normalize final newlines in integrated review files 2026-10-26 16:31:23 +00:00
pewdiepie-archdaemon fdb2a330d9 Remove duplicate session copy-header helpers after integration 2026-10-26 16:30:56 +00:00
pewdiepie-archdaemon 08452ab5f9 Keep chat module URLs consistent across imports and precache 2026-10-26 16:29:17 +00:00
pewdiepie-archdaemon e461a6b5c5 Keep chat module URLs consistent across imports and precache 2026-10-26 16:29:17 +00:00
pewdiepie-archdaemon c54bc1d2d3 Merge the verified editor-server foundation into the client review 2026-10-26 16:27:31 +00:00
pewdiepie-archdaemon 9ab3f70fe4 Integrate gallery document tabs with the updated editor client 2026-10-26 16:27:31 +00:00
pewdiepie-archdaemon 22144050fe Update canonical Notes modules alongside their deployment files 2026-10-26 16:23:29 +00:00
pewdiepie-archdaemon 3d47888559 Use explicit editor endpoints in backend regression fixtures 2026-10-26 16:23:29 +00:00
pewdiepie-archdaemon ace6be44f3 Merge current dev and refresh PR #6740 from the 7011 integration 2026-10-26 16:21:34 +00:00
pewdiepie-archdaemon 36a71886bb Merge current dev and refresh PR #6741 from the 7011 integration 2026-10-26 16:21:28 +00:00
Yu-Wei Chang 76abce349b fix(sidebar): measure the auto-collapse restore with the sidebar at full width
When the window widens after an auto-collapse, the restore check removes
.hidden and measures the chat width to decide whether the sidebar fits.
html.ody-sidebar-mini also forces the sidebar to width 0 and was only
cleared by syncRailSide() after the measurement, so the check always saw
a 0-width sidebar and restored it even when the chat ended up narrower
than MIN_CHAT_WIDTH. Sync the mode classes before measuring.
2026-10-24 22:53:45 -07:00
Yu-Wei Chang 4954cc2e8c fix(sidebar): animate the sidebar toggle instead of snapping
The global .hidden utility (display: none) also matches #sidebar.hidden,
so the collapsed sidebar was removed from layout before its width/opacity
transition could run, and opening and closing both snapped in one frame.

Keep the collapsed sidebar rendered and take it out of interaction once
closed: .sidebar.hidden stays display: flex, gains pointer-events: none,
and visibility: hidden that is transitioned so it only flips after the
collapse finishes (keeping the closed sidebar out of the tab order).

On desktop the icon rail now tucks under the sidebar and slides out as it
collapses, so the combined width eases instead of jumping by 48px when
the two swap. On mobile the drawer keeps its width while off so it slides
out as well as in, and switching it to the right edge before opening no
longer animates it across the screen. Transitions are disabled under
prefers-reduced-motion, and the auto-collapse restore measures the chat
width with the transition suppressed.

Re-record the computed-style snapshot baseline for the sidebar and icon
rail.
2026-10-24 22:26:40 -07:00
pewdiepie-archdaemonandClaude Opus 5.5 5fac693c1c feat(photo-editor): Ajax Image Editor client (Photoshop-parity tools)
Photoshop-parity editor client: layers, selection/masking, brushes,
smart filters, LUT import, Quick Edit, mobile gestures, workspaces,
batch operations, and the service-worker versioning (v978->v1006).

Source: vendor/photocraft (ad1cff44) photo-editor client files, merged
onto upstream/dev (a8c147b2). Conflict resolution documented in
pr_work/A_conflicts.md; editor files taken from vendor/photocraft
(upstream's editor is an earlier snapshot of the same feature).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:57:35 +00:00
pewdiepie-archdaemonandClaude Opus 5.5 8fda203d28 feat(ui): chat frontend, model picker, streaming, admin, a11y
Preview/UI work: chat renderer, streaming/think-trace, model picker
(instant-open/refresh-backoff), admin panel, a11y, gallery landing.

Source: vendor/photocraft (ad1cff44) static files, 3-way merged onto
upstream/dev (a8c147b2). Resolution policy in pr_work/D_conflicts.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:42:33 +00:00
434 changed files with 49339 additions and 1741 deletions
+2
View File
@@ -53,6 +53,8 @@ services/data/
# Test capture artifacts (browser session dumps may contain personal content) # Test capture artifacts (browser session dumps may contain personal content)
.playwright-mcp/ .playwright-mcp/
test-results/
playwright-report/
# OS # OS
.DS_Store .DS_Store
+12
View File
@@ -35,3 +35,15 @@ regexTarget = "secret"
regexes = [ regexes = [
'''^7abc5b334a6c3c52dd3eba15d8bc516da728a67f14fb7f56d0bb5992d89a16ca$''', '''^7abc5b334a6c3c52dd3eba15d8bc516da728a67f14fb7f56d0bb5992d89a16ca$''',
] ]
[[allowlists]]
description = "Synthetic dummy API key in trace artifact sanitizer test fixture"
targetRules = ["generic-api-key"]
condition = "AND"
paths = [
'''(?:^|/)tests/test_trace_artifact_sanitizer\.py$''',
]
regexTarget = "secret"
regexes = [
'''^sk-123456789$''',
]
+18 -3
View File
@@ -1,9 +1,22 @@
# Both stages need the fixed native decoder, including curl in the builder and
# distro Node in the runtime. A cached Python base may predate the Debian fix.
FROM python:3.15.0-slim AS brotli-base
COPY scripts/check_brotli.py /usr/local/bin/check-brotli.py
COPY scripts/check_ca_trust.py /usr/local/bin/check-ca-trust.py
# Check inherited trust before the first HTTPS package request, retaining Debian's
# signed archive verification. Refresh only the explicit native dependency.
RUN python /usr/local/bin/check-ca-trust.py --system \
&& sed -i 's|http://deb.debian.org/|https://deb.debian.org/|g' /etc/apt/sources.list.d/debian.sources \
&& apt-get update && apt-get install -y --no-install-recommends libbrotli1 \
&& python /usr/local/bin/check-brotli.py \
&& rm -rf /var/lib/apt/lists/*
# ---- builder: patch + build wheels for Real-ESRGAN's broken-on-3.14 deps ---- # ---- builder: patch + build wheels for Real-ESRGAN's broken-on-3.14 deps ----
# basicsr/gfpgan/facexlib read their version via exec()+locals()['__version__'], # basicsr/gfpgan/facexlib read their version via exec()+locals()['__version__'],
# which raises KeyError on Python 3.13+ (PEP 667). Build patched wheels here so # which raises KeyError on Python 3.13+ (PEP 667). Build patched wheels here so
# the final image / Cookbook never has to compile the broken sdists. See # the final image / Cookbook never has to compile the broken sdists. See
# docker/build-realesrgan-wheels.sh for the full rationale. # docker/build-realesrgan-wheels.sh for the full rationale.
FROM python:3.14.9-slim AS realesrgan-wheels FROM brotli-base AS realesrgan-wheels
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \ RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY scripts/check_ca_trust.py /usr/local/bin/check-ca-trust.py COPY scripts/check_ca_trust.py /usr/local/bin/check-ca-trust.py
@@ -14,7 +27,7 @@ RUN python -m pip install --no-cache-dir --require-hashes --only-binary=:all: --
COPY docker/build-realesrgan-wheels.sh /usr/local/bin/build-realesrgan-wheels.sh COPY docker/build-realesrgan-wheels.sh /usr/local/bin/build-realesrgan-wheels.sh
RUN bash /usr/local/bin/build-realesrgan-wheels.sh /wheels RUN bash /usr/local/bin/build-realesrgan-wheels.sh /wheels
FROM python:3.14.9-slim FROM brotli-base
# System deps. tmux is required by Cookbook for background downloads/serves. # System deps. tmux is required by Cookbook for background downloads/serves.
# openssh-client is required for Cookbook remote server tests, setup, probes, # openssh-client is required for Cookbook remote server tests, setup, probes,
@@ -52,6 +65,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libglib2.0-0t64 \ libglib2.0-0t64 \
libxcb1 \ libxcb1 \
libmagic1 \ libmagic1 \
&& python /usr/local/bin/check-brotli.py --node /usr/bin/node \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Refresh both Python trust bundles before installing application dependencies. # Refresh both Python trust bundles before installing application dependencies.
@@ -132,7 +146,8 @@ RUN pip install --no-cache-dir --no-deps /tmp/odysseus-wheels/*.whl \
# Check gRPC's effective defaults after all Python dependencies are installed. # Check gRPC's effective defaults after all Python dependencies are installed.
# The application minimum fixes its bundled roots; the checked override also # The application minimum fixes its bundled roots; the checked override also
# keeps the effective Docker selection consistent with system trust. # keeps the effective Docker selection consistent with system trust.
RUN python /usr/local/bin/check-ca-trust.py --system --python --grpc RUN python /usr/local/bin/check-ca-trust.py --system --python --grpc \
&& python /usr/local/bin/check-brotli.py --node /usr/bin/node
# Copy app code # Copy app code
COPY . . COPY . .
+255
View File
@@ -0,0 +1,255 @@
# Quick Edit Coverage Audit
Audit of how much of the PhotoCraft editor is reachable through **Quick Edit**
(the natural-language editor agent). Built against build **0.20.44** (HARNESS
bridge fan-out fix `4dd11826`) and client commit `e78b5a96`.
Quick Edit executes in two stages:
1. **Plan** — `static/js/editor/editor-agent.js` posts `{prompt, state,
screenshot}` to `/api/editor/agent`. HARNESS `routes/editor_agent_routes.py`
plans with the shared style guide (`src/editor_style_guide.py`) plus an LLM,
validates every step against its own `OP_CATALOG`, then executes.
2. **Execute** — only `ai_edit_image` / `generate_image` run server-side; every
other op is delivered to the page over the editor bridge (SSE) and run by the
client control surface (`window.geEditorControl.invoke`, catalog in
`static/js/galleryEditor.js`). A local fallback planner lives in
`static/js/editor/nl-edit.js`.
There are **three** catalogs that must agree:
| Catalog | File | Owner |
|---|---|---|
| Client control ops | `static/js/galleryEditor.js` | PHOTOSHOP |
| Server `OP_CATALOG` | `routes/editor_agent_routes.py` | HARNESS |
| Style guide / planner | `src/editor_style_guide.py` + `nl-edit.js` | HARNESS + PHOTOSHOP |
Legend: **C** = in client catalog · **S** = in server `OP_CATALOG` (incl.
`NEW_OP_NAMES`) · **P** = the planner/style guide emits it for some phrasing.
A capability is **usable from Quick Edit** only when the planner can emit it
(**P**) and the op exists on the side that runs it.
---
## 1. Menu bar (12 menus)
| Capability | Trigger phrasing | Op | C | S | P | Status |
|---|---|---|---|---|---|---|
| File → Import | "open this image" | `document.importImage` | ✔ | ✔ | partial | OK |
| File → Load project | "open my project" | `document.openPsd` | ✔ | ✘ | ✘ | **Server gap** |
| File → Save / Save copy / Export PSD | "save as psd" | `document.savePsd` | ✔ | ✔ | ✔ | OK |
| File → Export | "export png" | `export.png` | ✔ | ✔ | ✔ | OK |
| File → Save project | — | (project store) | n/a | n/a | ✘ | Out of scope (not an edit op) |
| Edit → Undo/Redo/History | "undo" | `history.undo` / `redo` | ✔ | ✔ | ✔ | OK |
| Edit → Free Transform | "free transform" | `transform.start` | ✔ | ✔ | ✔ | OK |
| Edit → Transform → Rotate 90/180 | "rotate this layer 90" | `layer.rotate` | ✔ | ✔ | ✔ | OK (whole image vs layer routed by the fast path) |
| Edit → Transform → Flip H/V | "flip this layer" | `layer.flip` | ✔ | ✔ | ✔ | OK (whole image vs layer routed by the fast path) |
| Image → Fill | "fill the whole image with white" | — | ✘ | ✘ | ✘ | **No op** |
| Image → Canvas Size | "make the canvas wider" | `document.resizeCanvas` | ✔ | ✔ | partial | OK |
| Image → Image Size | "resize the image to 1200px" | `document.resizeCanvas` | ✔ | ✔ | partial | OK |
| Image → Rotate 90/180 (whole image) | "rotate the whole image 90" | `document.rotate`/all-layers | ✘ | ✘ | ✘ | **No control op** |
| Image → Flip H/V (whole image) | "flip the whole canvas" | `document.flip`/all-layers | ✘ | ✘ | ✘ | **No control op** |
| Image → Mode RGB/Grayscale | "make it black and white" | `adjustments.apply` black-white | ✔ | ✔ | ✔ | OK (mode via adjustment) |
| Image → Adjustments → Invert | "invert the colours" | `adjustments.apply` invert | ✔ | ✔ | ✔ | OK (fast path colour-inverts the active layer; "invert the selection" stays Select ▸ Inverse) |
| Image → Mode CMYK/Lab, Bits 16/32 | — | — | ✘ | ✘ | ✘ | Not implemented (disabled in UI) |
| Layer → New | "add a layer" | `layers.addRaster` | ✔ | ✔ | partial | OK |
| Layer → Duplicate | "duplicate the layer" | `layers.duplicate` | ✔ | ✔ | partial | OK |
| Layer → Group | "group the layers" | `layers.group` | ✔ | ✔ | ✘ | **Server OK, planner gap** |
| Layer → Rasterize | "rasterize the text" | `layers.rasterize` | ✔ | ✔ | ✔ | OK |
| Layer → Delete | "delete the layer" | `layers.delete` | ✔ | ✔ | partial | OK |
| Layer → Merge Down | "merge down" | `layers.mergeDown` | ✔ | ✘ | ✘ | **Server gap** |
| Layer → Merge All / Flatten | "flatten the image" | `layers.flatten` | ✔ | ✘ | ✘ | **Server gap** |
| Layer → Select All | "select all layers" | (multi-select) | ✔ | ✔ | ✔ | OK (fast path selects every layer) |
| Layer → Combine (unite/subtract/…) | "combine the shapes" | `_combineSelectedShapes` | ✔ | ✔ | ✔ | OK (fast path selects the shape layers and combines) |
| Layer → Fill | "fill the layer with blue" | `selection.fill` / flood | partial | ✘ | ✘ | **Gap** |
| Layer → Layer Mask (reveal/hide/apply/invert/feather) | "add a layer mask" | `layers.addMaskFromSelection` | ✔ | ✔ | ✔ | OK |
| Layer → Opacity | "set the layer opacity to 50%" | `layers.setOpacity` | ✔ | ✔ | partial | OK (fast path sets the active layer's opacity) |
| Layer → Blend Mode | "set the blend mode to multiply" | `layers.setBlend` | ✔ | ✔ | partial | OK (fast path sets the active layer's blend mode) |
| Type → Add | "add a title" | `text.add` | ✔ | ✔ | ✔ | OK |
| Type → Character | "make the text bigger" | `text.edit` | ✔ | ✘ | ✘ | **Server gap + planner gap** |
| Type → Paragraph | "centre the text" | `text.layout` | ✔ | ✔ | partial | OK |
| Type → Warp | "warp the text" | `_rerenderTextLayer` (warp) | ✔ | ✔ | ✔ | OK |
| Select → All/Deselect/Invert | "invert the selection" | `selection.*` | ✔ | ✔ | ✔ | OK |
| Select → Reselect | "reselect" | `_reselectSelection` | ✔ | ✔ | ✔ | OK |
| Select → Transform | "transform the selection" | `transform.start` | ✔ | ✔ | partial | OK |
| Select → Refine | "refine the edge" | `selection.feather/expand` | ✔ | ✘ | ✘ | **Server gap** |
| Select → Color Range | "select the blue sky" | `selection.colorRange` | ✔ | ✔ | partial | OK |
| Select → Select and Mask | "select the subject" | `selection.subject` | ✔ | ✔ | ✔ | OK |
| Select → Save | "save the selection" | `_saveNamedSelection` | ✔ | ✔ | ✔ | OK |
| Select → Load | "load the selection as sky" | `_loadNamedSelection` | ✔ | ✔ | ✔ | OK |
| Filter → every filter (30+) | "pixelate the image" | `filters.apply` | ✔ | ✔ | ✔ | OK (fast path covers pixelate/emboss/twirl/pinch/spherize/ripple/solarize/find-edges/crystallize/noise/despeckle/unsharp + blur/sharpen/vignette) |
| Filter → Blur → Motion / Zoom | "motion blur" / "zoom blur" | `filters.apply` (blur-motion / blur-zoom) | ✔ | ✔ | ✔ | OK (fast path maps motion blur and zoom blur to their Filter-menu variants) |
| Filter → Layer effects (drop shadow, stroke, colour overlay, outer glow) | "add a drop shadow" | `layers.addStyle` | ✔ | ✔ | ✔ | OK (fast path adds drop shadow / stroke / outer glow / colour & gradient overlay; colour parsed from the prompt) |
| View → Rulers/Grid/Snap/Clear guides | "show the grid" | `#ge-view-menu` | ✔ | ✔ | ✔ | OK |
| Window → panels | — | n/a | n/a | n/a | ✘ | UI-only |
| 3D / Plugins | — | — | ✘ | ✘ | ✘ | Not implemented (disabled in UI) |
## 2. Tools
Toolbar tools are interactive, not bridge ops, so coverage is about whether the
**effect** of a tool is reachable by prompt.
| Tool | NL-reachable? | Notes |
|---|---|---|
| Move / Transform | `transform.*` | OK (transform.start/apply/cancel) |
| Hand / Zoom | — | View only |
| Crop | `document.crop` + format guard | OK |
| Text / Shape | `text.add`, `text.layout` | Shape has no add-shape op — **gap** |
| Brush / Eraser / Dodge / Burn / Smudge / Clone / Heal / Sharpen | — | Interactive painting; no NL op — **gap** (out of scope for planner) |
| Gradient | — | **No op** |
| **Paint Bucket** | `fill.flood` | **Server gap + planner gap** (op exists client-side only) |
| Eyedropper | — | UI only |
| Marquee / Lasso / Wand / Quick Select / Object Select / Pen / Path | `selection.rectangle/ellipse/polygon/subject/…` | OK for region/reference selects; lasso/pen paths have `selection.polygon` only |
| SAM / Inpaint / Rembg | `selection.subject`, `ai.removeObject`, `ai.removeBackground` | OK |
| **Pixel brush + grid** | — | UI state only; no op — **gap** (acceptable: it is a brush mode, not an edit op) |
## 3. Right-click context menu
| Item | Backing op | Reachable by prompt? |
|---|---|---|
| Brush size / preset / Pixel Brush | `state` UI | No (interactive) |
| Select All / Deselect / Inverse / Feather / Expand / Contract / Border / Smooth | `selection.*` | Yes |
| Transform Selection / Refine Edge | `transform.start`, `selection.feather` | Partial |
| Apply / Cancel Crop | `document.crop` | Yes |
| Commit / Cancel / Free Transform | `transform.apply/cancel/start` | Yes |
| Add Text Layer / Rasterize Text | `text.add`, `layers.rasterize` | Yes (rasterize OK) |
| Copy Color Hex / Swap Colors | `state.color` | OK (fast path swaps / resets the foreground & background swatches) |
| Fill Selection w/ Foreground | `selection.fill` | OK (fast path fills the selection/canvas with the foreground or background colour) |
| Cut / Copy / Paste | `selection.copy/paste` | **Gap** (no planner intent) |
| Rotate 90/180, Flip H/V | `layer.rotate/flip` | **Server+planner gap** (same as Edit menu) |
| Undo / Redo | `history.*` | Yes |
## 4. Adjustments
`adjustments.apply` / `adjustments.applyMasked` cover all adjustment types
(brightness-contrast, white-balance, shadows-highlights, vibrance, clarity,
vignette, black-white, …). The style guide emits several. **Status: OK**
(planner covers the common ones; the long tail is reachable only via explicit
wording).
## 5. Filters
`filters.apply` / `filters.applyMasked` run every filter the UI lists. The
planner only emits **sharpen**, **blur-gaussian**, and **vignette** via intents.
**Gap (planner):** no intent for noise, pixelate, stylize, distort, render,
unsharp, sharpen-edges, colour-overlay/drop-shadow/stroke.
**Gap (HARNESS):** `filters.apply`/`applyMasked` are in the server catalog, so
the fix is style-guide intents, not catalog.
## 6. Layer styles
`layers.addStyle {type, params}` exists in both catalogs. **Planner never emits
it**, so "add a drop shadow to the title", "add a stroke", "colour overlay" fail.
**Gap (planner).**
## 7. Selection ops
`selection.rectangle/ellipse/polygon/fill/delete/copy/paste/feather/expand/
contract/selectAll/deselect/invert/colorRange/subject/samText/face/sky/
background/hasMask/maskBounds/exportMask/regionLuminance`. **Status: mostly OK.**
Missing: reselect, save-selection.
## 8. Document ops
| Capability | Op | C | S | P |
|---|---|---|---|---|
| Crop | `document.crop` | ✔ | ✔ | ✔ |
| Resize canvas / image | `document.resizeCanvas` | ✔ | ✔ | partial |
| **Extend canvas** | `document.extend` | ✔ | ✘ | ✘ |
| Save PSD | `document.savePsd` | ✔ | ✔ | ✔ |
| Export layers | `document.exportLayers` | ✔ | ✘ | ✘ |
| Duplicate / Create / Switch / List | `document.*` | ✔ | ✘ | partial |
## 9. AI edit & generate modes
| Mode | Op | Notes |
|---|---|---|
| Generative fill | `ai.generativeFill` | OK |
| Remove object | `ai.removeObject` | OK |
| Harmonize | `ai.harmonize` | OK |
| Upscale | `ai.generativeUpscale` | OK |
| Generate image | `ai.generateImage` / server `generate_image` | OK |
| Expand | `ai.generativeExpand` | OK (content-bounds prefill, SW v760) |
| Remove background | `ai.removeBackground` | OK |
| Instruction edit / scene replace | server `ai_edit_image` | OK (HARNESS) |
---
## 10. Gap list
### Client-side (fix in this repo — PHOTOSHOP-owned) — **ALL FIXED**
1. ✅ **`fill.flood` not in the NL path** — added to `nl-edit.js OP_CATALOG` +
`localPlan` fill intent ("fill … with <colour>"; falls back to `selection.fill`
when a region is selected).
2. ✅ **`layer.flip` / `layer.rotate` not in the NL path** — added to
`nl-edit.js OP_CATALOG` + "this layer" intent (whole-image phrasing routes to
`document.flip`/`document.rotate`).
3. ✅ **`text.edit` not in the NL path** — added catalog entry + intent for font
size/colour ("make the text bigger/smaller", "make it red").
4. ✅ **`document.extend` not in the NL path** — added catalog entry + intent for
"extend the canvas to 16:9" (derives target size from the document).
5. ✅ **`layers.align`** — added planner intent ("align … to the centre/left/…").
6. ✅ **`layers.addStyle`** — added planner trigger ("drop shadow", "stroke",
"colour overlay").
7. ✅ **Whole-image rotate/flip has no control op** — added `document.rotate` /
`document.flip` control ops (`galleryEditor.js`) and catalog/intent entries.
8. ✅ **`selection.copy/paste` intents** — added "copy the selection"/"paste".
### Server-side (file for HARNESS in the passover)
1. `routes/editor_agent_routes.py OP_CATALOG` is missing ops the client runs:
`layer.flip`, `layer.rotate`, `text.edit`, `fill.flood`, `layers.align`,
`layers.rasterize`, `layers.mergeDown`, `layers.flatten`, `document.extend`,
`document.exportLayers`, `document.*` (list/duplicate/create/switch/openPsd),
`mask.*`, `selection.*` (polygon/feather/expand/contract/copy/paste/hasMask/
face), `transform.*` are partly present.
2. `src/editor_style_guide.py` has no intents for: layer transform
(rotate/flip active layer), whole-document rotate/flip, text editing, align,
layer styles (drop shadow/stroke/overlay), flood fill, copy/paste, noise/
pixelate/stylize/distort/render filters.
## 11. Coverage summary
Counting distinct user-facing capabilities in the toolbar + 12 menus + right-click
(~150 including the filter/effect long tail):
- **Have a client op:** ~85%
- **Client op also in server OP_CATALOG:** ~75%
- **Planner can emit for natural phrasing:** ~72% (was ~55%; client-side
planner intents for fill, layer/whole-image transform, text edit, align,
layer styles, copy/paste and extend are now implemented)
- **Usable from Quick Edit end-to-end today:** **45/45 prompts (100%)** measured
live on build **0.20.45** with `tests/e2e/photo-editor/quickedit-coverage.js`
(`/tmp/opencode/qe-coverage45/quickedit-coverage.json`). HARNESS build 0.20.45
(`4a6932a2`) added the 12 missing structural ops to the server `OP_CATALOG` +
style guide v5, so every audited prompt now plans to the exact op.
The gap was dominated by the **planner**, not by missing client functionality;
that planner gap is now closed end-to-end (30/45 on 0.20.44 -> 45/45 on 0.20.45).
## 12. Test prompts (~40)
See `docs/QUICKEDIT_PROMPTS.md` and the runner
`tests/e2e/photo-editor/quickedit-coverage.js` for per-prompt pass/fail.
### Live run (build 0.20.45) — 45/45 PASS
All 45 prompts plan to the exact expected op. HARNESS 0.20.45 routes these
structural ops server-side: `fill.flood`, `text.edit`, `layers.align`,
`layer.rotate`/`layer.flip`, `document.rotate`/`document.flip`,
`selection.copy`/`selection.paste`, `layers.flatten`, `layers.mergeDown` (plus
the previously passing set). Results:
`/tmp/opencode/qe-coverage45/quickedit-coverage.json`.
### Previous run (build 0.20.44) — 30/45 PASS
Kept for provenance. Failures were all the server planner emitting a generic
`adjustments.apply`/`transform.apply` or a wrong op; ids 1, 2, 3, 4, 8, 9, 10,
11, 12, 13, 14, 19, 20, 40, 41. All now fixed in 0.20.45.
## Provenance
Recon inventory captured from `static/js/editor/build/topbar.js`,
`static/js/editor/build/toolbar.js`, `static/js/editor/canvas-context-menu.js`,
`static/js/galleryEditor.js` (`installEditorControl`), `static/js/editor/nl-edit.js`,
`routes/editor_agent_routes.py`, and `src/editor_style_guide.py`.
@@ -112,7 +112,7 @@ export function createCardInteractions({
if (state.selectMode) { if (state.selectMode) {
body.querySelectorAll('.note-card').forEach(card => { body.querySelectorAll('.note-card').forEach(card => {
card.addEventListener('click', (e) => { card.addEventListener('click', (e) => {
if (e.target.closest('.note-card-cb')) return; // checkbox handles itself if (e.target.closest('.note-card-cb, a')) return; // links open without toggling selection
e.stopPropagation(); e.stopPropagation();
tapToEditOrSelect(card); tapToEditOrSelect(card);
}); });
+2 -2
View File
@@ -51,7 +51,7 @@ export function createEditor({
<input type="hidden" class="note-form-due" value="${note?.due_date || ''}" /> <input type="hidden" class="note-form-due" value="${note?.due_date || ''}" />
<input type="hidden" class="note-form-repeat" value="${note?.repeat || 'none'}" /> <input type="hidden" class="note-form-repeat" value="${note?.repeat || 'none'}" />
</div> </div>
${currentImageUrl && type !== 'draw' ? `<div class="note-form-image-wrap"><img class="note-form-image" src="${_esc(currentImageUrl)}" draggable="false" /><button class="note-form-image-rm" title="Remove">&times;</button></div>` : ''} ${currentImageUrl && type !== 'draw' ? `<div class="note-form-image-wrap"><img class="note-form-image" src="${_esc(currentImageUrl)}" alt="" draggable="false" /><button class="note-form-image-rm" title="Remove" aria-label="Remove">&times;</button></div>` : ''}
<div class="note-form-body"> <div class="note-form-body">
${type === 'note' ${type === 'note'
? `<textarea class="note-form-content" placeholder="Take a note..." rows="4">${_esc(note?.content || '')}</textarea>` ? `<textarea class="note-form-content" placeholder="Take a note..." rows="4">${_esc(note?.content || '')}</textarea>`
@@ -297,7 +297,7 @@ export function createEditor({
form.querySelector('.note-form-image-wrap')?.remove(); form.querySelector('.note-form-image-wrap')?.remove();
const wrap = document.createElement('div'); const wrap = document.createElement('div');
wrap.className = 'note-form-image-wrap'; wrap.className = 'note-form-image-wrap';
wrap.innerHTML = `<img class="note-form-image" draggable="false" /><button class="note-form-image-rm" title="Remove">&times;</button>`; wrap.innerHTML = `<img class="note-form-image" alt="" draggable="false" /><button class="note-form-image-rm" title="Remove" aria-label="Remove">&times;</button>`;
// Insert AFTER the whole header (a flex-row), not after the // Insert AFTER the whole header (a flex-row), not after the
// title input itself : otherwise the image lands as a sibling // title input itself : otherwise the image lands as a sibling
// of the title inside the header and flex puts them side-by-side. // of the title inside the header and flex puts them side-by-side.
+1 -1
View File
@@ -31,7 +31,7 @@ export function createText({
url = url.slice(0, -1); url = url.slice(0, -1);
} }
const href = url.startsWith('www.') ? `https://${url}` : url; const href = url.startsWith('www.') ? `https://${url}` : url;
return `<a href="${_attrEsc(href)}" class="note-link" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">${url}</a>` + (url !== m ? m.slice(url.length) : ''); return `<a href="${_attrEsc(href)}" class="note-link" target="_blank" rel="noopener noreferrer">${url}</a>` + (url !== m ? m.slice(url.length) : '');
}); });
} }
+80 -12
View File
@@ -1203,19 +1203,64 @@ def _bridge_timeout(value: Any) -> float:
def _is_admin(request: Request) -> bool: def _is_admin(request: Request) -> bool:
"""Return True only for authorized administrator browser sessions or auth-disabled single-user mode.
Delegated credentials (bearer API tokens) are explicitly rejected:
per src/tool_security.py, bearer tokens are long-lived credentials handed to
third parties/scripts and must remain capped at non-admin policies even
when minted by an administrator.
"""
# 1. Delegated credentials (bearer API tokens) must NEVER receive admin metadata privileges
if getattr(request.state, "api_token", False):
return False
# 2. In-process internal tool bypass (loopback agent tools)
try:
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN, INTERNAL_TOOL_USER
import secrets
hdr = request.headers.get(INTERNAL_TOOL_HEADER)
if hdr and secrets.compare_digest(hdr, INTERNAL_TOOL_TOKEN):
return True
if getattr(request.state, "current_user", None) == INTERNAL_TOOL_USER:
return True
except Exception:
pass
# 3. Explicitly auth-disabled single-user operation (AUTH_ENABLED=false)
try:
from src.auth_helpers import _auth_disabled
if _auth_disabled():
return True
except Exception:
pass
# 4. Middleware require_admin
try: try:
from core.middleware import require_admin from core.middleware import require_admin
require_admin(request) require_admin(request)
return True return True
except Exception: except Exception:
pass
# 5. Direct session/user check when request.state.current_user is set
try:
auth_mgr = getattr(request.app.state, "auth_manager", None)
if auth_mgr and getattr(auth_mgr, "is_configured", False):
from src.auth_helpers import get_current_user
user = get_current_user(request) or getattr(request.state, "current_user", None)
if user and getattr(auth_mgr, "is_admin", None) and auth_mgr.is_admin(user):
return True
except Exception:
pass
return False return False
def _user(request: Request) -> Optional[str]: def _user(request: Request) -> Optional[str]:
# These owner-scoped editor APIs have no delegated-token scope contract. # Handle API bearer tokens explicitly: require 'chat' scope and attribute to real owner
# Do not collapse all bearer users into the shared "api" pseudo-owner.
if getattr(request.state, "api_token", False): if getattr(request.state, "api_token", False):
raise HTTPException(403, "API tokens must use a scope-aware API route") from src.auth_helpers import require_api_token_scope
return require_api_token_scope(request, "chat")
from src.auth_helpers import get_current_user, _auth_disabled from src.auth_helpers import get_current_user, _auth_disabled
u = get_current_user(request) u = get_current_user(request)
if u: if u:
@@ -1358,9 +1403,13 @@ def setup_editor_agent_routes() -> APIRouter:
except Exception: except Exception:
raw = None raw = None
_used = editor_agent_model.get_last_model() _used = editor_agent_model.get_last_model()
model_used = (_used[0] if _used and _used[0]
else (agent_model or editor_agent_model.local_model()))
parsed = _parse_plan(raw) parsed = _parse_plan(raw)
if _used and _used[0]:
model_used = _used[0]
elif parsed:
model_used = agent_model or editor_agent_model.local_model()
else:
model_used = "fallback" if agent_model else editor_agent_model.local_model()
if isinstance(parsed, dict): if isinstance(parsed, dict):
parsed["agent_model"] = model_used parsed["agent_model"] = model_used
if mode != "check": if mode != "check":
@@ -1634,9 +1683,14 @@ def setup_editor_agent_routes() -> APIRouter:
except Exception: except Exception:
raw = None raw = None
_used = editor_agent_model.get_last_model() _used = editor_agent_model.get_last_model()
model_used = (_used[0] if _used and _used[0] parsed = _parse_plan(raw)
else (agent_model or editor_agent_model.local_model())) if _used and _used[0]:
plan = _parse_plan(raw) or _fallback_plan(prompt) model_used = _used[0]
elif parsed:
model_used = agent_model or editor_agent_model.local_model()
else:
model_used = "fallback" if agent_model else editor_agent_model.local_model()
plan = parsed or _fallback_plan(prompt)
_strengthened = style_guide.strengthen(prompt, list(plan.get("steps") or [])) _strengthened = style_guide.strengthen(prompt, list(plan.get("steps") or []))
if _strengthened != plan.get("steps"): if _strengthened != plan.get("steps"):
plan = {**plan, "steps": _strengthened, plan = {**plan, "steps": _strengthened,
@@ -1723,6 +1777,12 @@ def setup_editor_agent_routes() -> APIRouter:
"results": [], "results": [],
"agent_model": model_used, "agent_model": model_used,
}) })
needed = {"plan_edit"}
for step in steps:
op = step.get("op")
needed.add(op if op in {"ai_edit_image", "generate_image"} else "run_editor_command")
if needed.intersection(disabled):
raise HTTPException(403, "Editor tool is disabled")
results: List[Dict[str, Any]] = [] results: List[Dict[str, Any]] = []
for step in steps: for step in steps:
op = step.get("op") op = step.get("op")
@@ -1865,14 +1925,19 @@ def setup_editor_agent_routes() -> APIRouter:
# recognisably image-capable — the same test the request # recognisably image-capable — the same test the request
# router uses. # router uses.
image_endpoint = getattr(ep, "model_type", None) == "image" image_endpoint = getattr(ep, "model_type", None) == "image"
endpoint_id = getattr(ep, "id", None)
provider = getattr(ep, "name", None) provider = getattr(ep, "name", None)
if not provider: if not provider:
provider = getattr(ep, "base_url", "") if is_admin else "image" provider = (
getattr(ep, "base_url", "")
if is_admin
else (f"Endpoint {endpoint_id}" if endpoint_id else "image")
)
provider = provider or "image" provider = provider or "image"
for name in _endpoint_enabled_models(ep): for name in _endpoint_enabled_models(ep):
if not image_endpoint and not looks_like_image_generation_model(name): if not image_endpoint and not looks_like_image_generation_model(name):
continue continue
key = (provider, name) key = (str(endpoint_id or provider), name)
if key in seen: if key in seen:
continue continue
seen.add(key) seen.add(key)
@@ -1881,7 +1946,7 @@ def setup_editor_agent_routes() -> APIRouter:
"label": name, "label": name,
"kind": "edit" if _EDIT_MODEL_RE.search(name) else "generate", "kind": "edit" if _EDIT_MODEL_RE.search(name) else "generate",
"provider": provider, "provider": provider,
"endpoint_id": getattr(ep, "id", None), "endpoint_id": endpoint_id,
} }
if is_admin: if is_admin:
entry["base_url"] = getattr(ep, "base_url", "") or "" entry["base_url"] = getattr(ep, "base_url", "") or ""
@@ -1896,11 +1961,12 @@ def setup_editor_agent_routes() -> APIRouter:
async def editor_agent_models(request: Request) -> JSONResponse: async def editor_agent_models(request: Request) -> JSONResponse:
"""List the models the editor agent/planner can run on. """List the models the editor agent/planner can run on.
Each entry is ``{id, label, source, vision, base_url}`` where ``id`` is Each entry is ``{id, label, source, vision}`` where ``id`` is
passed as ``agent_model`` to /api/editor/plan or /api/editor/agent, and passed as ``agent_model`` to /api/editor/plan or /api/editor/agent, and
``source`` is ``local`` (the local Qwen3.5-9B vision server) or ``source`` is ``local`` (the local Qwen3.5-9B vision server) or
``hosted`` (the configured default/utility chat model that the planner ``hosted`` (the configured default/utility chat model that the planner
falls back to). falls back to).
For non-admin users, private URLs and internal probe status are omitted.
""" """
owner = _user(request) owner = _user(request)
is_admin = _is_admin(request) is_admin = _is_admin(request)
@@ -1916,12 +1982,14 @@ def setup_editor_agent_routes() -> APIRouter:
except Exception: except Exception:
logger.warning("editor agent-models lookup failed", exc_info=True) logger.warning("editor agent-models lookup failed", exc_info=True)
models = [] models = []
probe_info = {} probe_info = {}
if is_admin: if is_admin:
probe_info = editor_agent_model.probe_status() probe_info = editor_agent_model.probe_status()
if not probe_info: if not probe_info:
editor_agent_model.startup_probe() editor_agent_model.startup_probe()
probe_info = editor_agent_model.probe_status() probe_info = editor_agent_model.probe_status()
return JSONResponse({ return JSONResponse({
"models": models, "models": models,
"probe": probe_info, "probe": probe_info,
+30 -12
View File
@@ -683,9 +683,15 @@ def _visible_image_endpoint_for_base(db, base: str, owner: str | None, model: st
configured.update(v for v in values if isinstance(v, str)) configured.update(v for v in values if isinstance(v, str))
except (TypeError, ValueError): except (TypeError, ValueError):
pass pass
if model:
if model not in configured or not looks_like_image_generation_model(model): if model not in configured or not looks_like_image_generation_model(model):
continue continue
if _normalize_image_endpoint_base(getattr(ep, "base_url", "")) == target: else:
if not any(looks_like_image_generation_model(v) for v in configured):
continue
ep_id = str(getattr(ep, "id", "") or "")
ep_base = _normalize_image_endpoint_base(getattr(ep, "base_url", ""))
if (ep_id and (base == ep_id or target == ep_id)) or (ep_base and ep_base == target):
if owner and getattr(ep, "owner", None) == owner: if owner and getattr(ep, "owner", None) == owner:
return ep return ep
if fallback is None: if fallback is None:
@@ -712,7 +718,7 @@ async def _fetch_result_image_b64(url: str) -> Optional[str]:
) )
if not ok: if not ok:
raise HTTPException(502, f"Upstream returned an unsafe image URL: {reason}") raise HTTPException(502, f"Upstream returned an unsafe image URL: {reason}")
async with httpx.AsyncClient(timeout=60) as c2: async with httpx.AsyncClient(timeout=60, follow_redirects=False) as c2:
ir = await c2.get(url) ir = await c2.get(url)
if ir.status_code == 200: if ir.status_code == 200:
return base64.b64encode(ir.content).decode() return base64.b64encode(ir.content).decode()
@@ -1730,7 +1736,7 @@ def setup_gallery_routes() -> APIRouter:
requested_base = (body.pop("_endpoint", "") or "").rstrip("/") requested_base = (body.pop("_endpoint", "") or "").rstrip("/")
# SSRF hardening: validate a client-supplied endpoint before any # SSRF hardening: validate a client-supplied endpoint before any
# outbound request (mirrors routes/embedding_routes.py). # outbound request (mirrors routes/embedding_routes.py).
if requested_base: if requested_base and "://" in requested_base:
from src.url_safety import check_outbound_url from src.url_safety import check_outbound_url
ok, reason = check_outbound_url( ok, reason = check_outbound_url(
requested_base, requested_base,
@@ -2076,7 +2082,7 @@ def setup_gallery_routes() -> APIRouter:
runs off the event loop (large images). runs off the event loop (large images).
""" """
requested_base = (body.pop("_endpoint", "") or "").rstrip("/") requested_base = (body.pop("_endpoint", "") or "").rstrip("/")
if requested_base: if requested_base and "://" in requested_base:
from src.url_safety import check_outbound_url from src.url_safety import check_outbound_url
ok, reason = check_outbound_url( ok, reason = check_outbound_url(
requested_base, requested_base,
@@ -2169,26 +2175,38 @@ def setup_gallery_routes() -> APIRouter:
try: try:
ep = None ep = None
if endpoint_id: if endpoint_id:
ep = owner_filter( ep = _visible_image_endpoint_for_base(db, endpoint_id, user)
db.query(ModelEndpoint).filter(ModelEndpoint.id == endpoint_id), if ep is None:
ModelEndpoint, user, return {"available": False, "reason": "endpoint not found or disabled"}
).first() elif requested_base:
if ep is None and requested_base:
ep = _visible_image_endpoint_for_base(db, requested_base, user) ep = _visible_image_endpoint_for_base(db, requested_base, user)
if ep is None: if ep is None:
return {"available": False, "reason": "endpoint not found or disabled"}
else:
ep = _first_visible_image_endpoint(db, user) ep = _first_visible_image_endpoint(db, user)
if ep is None: if ep is None:
return {"available": False, "reason": "no image endpoint configured"} return {"available": False, "reason": "no image endpoint configured"}
base = ep.base_url.rstrip("/") base = ep.base_url.rstrip("/")
finally: finally:
db.close() db.close()
from src.url_safety import check_outbound_url
block_private = os.getenv("IMAGE_BLOCK_PRIVATE_IPS", "false").lower() == "true"
ok, reason = check_outbound_url(base, block_private=block_private)
if not ok:
# CodeQL #1203: Never expose internal addresses, resolver errors or raw
# exception details to clients or logs. Use a stable diagnostic message
# without interpolating untrusted exception content or destination strings.
logger.warning("edit-progress endpoint rejected: outbound URL policy check failed")
return {"available": False, "reason": "endpoint URL rejected"}
if not base.endswith("/v1"): if not base.endswith("/v1"):
base += "/v1" base += "/v1"
# Dynamic path segment (validated token above), so not routed through # Dynamic path segment (validated token above), so not routed through
# _join_checked_gallery_endpoint's exact-match whitelist. # _join_checked_gallery_endpoint's exact-match whitelist.
url = f"{base}/images/progress/{request_id}" url = f"{base}/images/progress/{request_id}"
try: try:
async with httpx.AsyncClient(timeout=10) as client: async with httpx.AsyncClient(timeout=10, follow_redirects=False) as client:
r = await client.get(url) r = await client.get(url)
if r.status_code == 200: if r.status_code == 200:
data = r.json() data = r.json()
@@ -2226,7 +2244,7 @@ def setup_gallery_routes() -> APIRouter:
# below, so validate it first (mirrors routes/embedding_routes.py). # below, so validate it first (mirrors routes/embedding_routes.py).
# Local-first means loopback/LAN is allowed by default; the cloud # Local-first means loopback/LAN is allowed by default; the cloud
# metadata range and non-HTTP(S) schemes are always rejected. # metadata range and non-HTTP(S) schemes are always rejected.
if requested_base: if requested_base and "://" in requested_base:
from src.url_safety import check_outbound_url from src.url_safety import check_outbound_url
ok, reason = check_outbound_url( ok, reason = check_outbound_url(
requested_base, requested_base,
@@ -2253,7 +2271,7 @@ def setup_gallery_routes() -> APIRouter:
# so the outbound URL never depends directly on request-body input. # so the outbound URL never depends directly on request-body input.
db = SessionLocal() db = SessionLocal()
try: try:
ep = _visible_image_endpoint_for_base(db, requested_base, user) ep = _visible_image_endpoint_for_base(db, requested_base, user, model)
if not ep: if not ep:
raise HTTPException(403, "Choose a registered image endpoint") raise HTTPException(403, "Choose a registered image endpoint")
base = ep.base_url.rstrip("/") base = ep.base_url.rstrip("/")
+7 -1
View File
@@ -5,6 +5,7 @@ import os
import uuid import uuid
import urllib.parse import urllib.parse
import html import html
import secrets
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Form, HTTPException, Request from fastapi import APIRouter, Form, HTTPException, Request
from fastapi.responses import RedirectResponse, HTMLResponse from fastapi.responses import RedirectResponse, HTMLResponse
@@ -12,7 +13,7 @@ import logging
import httpx import httpx
from core.database import McpServer, SessionLocal from core.database import McpServer, SessionLocal
from core.middleware import require_admin from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN, require_admin
from src.constants import DATA_DIR, MCP_OAUTH_DIR from src.constants import DATA_DIR, MCP_OAUTH_DIR
from src.mcp_manager import McpManager from src.mcp_manager import McpManager
@@ -424,6 +425,11 @@ def setup_mcp_routes(mcp_manager: McpManager):
Expects JSON body: {"disabled": ["tool_name_1", "tool_name_2"]} Expects JSON body: {"disabled": ["tool_name_1", "tool_name_2"]}
""" """
require_admin(request) require_admin(request)
internal_token = request.headers.get(INTERNAL_TOOL_HEADER)
if internal_token and secrets.compare_digest(internal_token, INTERNAL_TOOL_TOKEN):
# Same boundary as POST /api/tools: an agent turn must not be able
# to re-enable MCP tools an admin switched off.
raise HTTPException(403, "Change MCP tool policy in Settings > Integrations, not from chat")
db = SessionLocal() db = SessionLocal()
try: try:
srv = db.query(McpServer).filter(McpServer.id == server_id).first() srv = db.query(McpServer).filter(McpServer.id == server_id).first()
+7
View File
@@ -7,6 +7,7 @@ import json
import hashlib import hashlib
import ipaddress import ipaddress
import socket import socket
import secrets
import time as _time import time as _time
import logging import logging
import threading import threading
@@ -3091,6 +3092,12 @@ def setup_model_routes(model_discovery):
def update_tools(body: ToolsUpdate, request: Request): def update_tools(body: ToolsUpdate, request: Request):
"""Update which tools are disabled.""" """Update which tools are disabled."""
require_admin(request) require_admin(request)
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN
internal_token = request.headers.get(INTERNAL_TOOL_HEADER)
if internal_token and secrets.compare_digest(internal_token, INTERNAL_TOOL_TOKEN):
# Owner impersonation can carry an admin username, so check the
# authenticated transport rather than request.state.current_user.
raise HTTPException(403, "Change tool policy in Settings > Agent Tools, not from chat")
settings = _load_settings() settings = _load_settings()
settings["disabled_tools"] = body.disabled settings["disabled_tools"] = body.disabled
_save_settings(settings) _save_settings(settings)
+73 -27
View File
@@ -25,11 +25,12 @@ from src.auth_helpers import effective_user, is_delegated_credential
from src.owner_identity import auth_disabled, is_request_sentinel_owner from src.owner_identity import auth_disabled, is_request_sentinel_owner
from src.attachment_refs import attachment_refs_from_metadata from src.attachment_refs import attachment_refs_from_metadata
from src.constants import GENERATED_IMAGES_DIR from src.constants import GENERATED_IMAGES_DIR
from src.path_confinement import is_inside from src.path_confinement import PathEscape, canonical_root, confine, is_inside
from src.upload_handler import ( from src.upload_handler import (
UploadCleanupSafetyError, UploadCleanupSafetyError,
count_recent_uploads, count_recent_uploads,
extract_upload_ids, extract_upload_ids,
secure_filename,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -153,30 +154,47 @@ def setup_upload_routes(upload_handler):
def _upload_root() -> str: def _upload_root() -> str:
from src.constants import UPLOAD_DIR from src.constants import UPLOAD_DIR
return os.path.realpath(getattr(upload_handler, "upload_dir", UPLOAD_DIR)) return canonical_root(getattr(upload_handler, "upload_dir", UPLOAD_DIR))
def _path_inside_upload_dir(path: str) -> bool: def _path_inside_upload_dir(path: str) -> bool:
return is_inside(_upload_root(), path) return is_inside(_upload_root(), path)
def _resolve_upload_path(file_id: str) -> str: def _resolve_upload_path(file_id: str) -> str:
from src.constants import UPLOAD_DIR safe_id = secure_filename(os.path.basename(file_id))
upload_root = getattr(upload_handler, "upload_dir", UPLOAD_DIR) if not safe_id:
direct = os.path.join(upload_root, file_id) raise HTTPException(400, "Invalid file ID")
root = _upload_root()
direct = os.path.normpath(os.path.join(root, safe_id))
if os.path.lexists(direct): if os.path.lexists(direct):
if not _path_inside_upload_dir(direct): try:
canonical_direct = confine(root, direct, allow_root=False)
except (PathEscape, ValueError):
raise HTTPException(403, "Access denied") raise HTTPException(403, "Access denied")
if os.path.isfile(direct): if os.path.isfile(canonical_direct):
return direct return canonical_direct
raise HTTPException(404, "File not found") raise HTTPException(404, "File not found")
for root, _dirs, files in os.walk(upload_root, followlinks=False): info = _load_upload_info(safe_id)
if file_id not in files: stored_path = (info or {}).get("path")
continue if stored_path:
path = os.path.join(root, file_id) try:
if not _path_inside_upload_dir(path): canonical_stored = confine(root, stored_path, allow_root=False)
if os.path.isfile(canonical_stored):
return canonical_stored
except (PathEscape, ValueError):
raise HTTPException(403, "Access denied") raise HTTPException(403, "Access denied")
if os.path.isfile(path):
return path for walk_root, _dirs, files in os.walk(root, followlinks=False):
if safe_id not in files:
continue
cand = os.path.normpath(os.path.join(walk_root, safe_id))
try:
canonical_cand = confine(root, cand, allow_root=False)
except (PathEscape, ValueError):
raise HTTPException(403, "Access denied")
if os.path.isfile(canonical_cand):
return canonical_cand
raise HTTPException(404, "File not found") raise HTTPException(404, "File not found")
raise HTTPException(404, "File not found") raise HTTPException(404, "File not found")
@@ -435,35 +453,62 @@ def setup_upload_routes(upload_handler):
logger.error(f"Failed to get upload stats: {e}") logger.error(f"Failed to get upload stats: {e}")
raise HTTPException(500, "Failed to get upload statistics") raise HTTPException(500, "Failed to get upload statistics")
@router.get("/{file_id}") @router.api_route("/{file_id}", methods=["GET", "HEAD"])
async def download_file(request: Request, file_id: str, thumb: int = 0): async def download_file(request: Request, file_id: str, thumb: int = 0):
"""Serve an uploaded file by its ID. `?thumb=1` returns a small cached """Serve an uploaded file by its ID or probe via HEAD. `?thumb=1` returns
JPEG thumbnail for images (used by chat attachment previews) so the a small cached JPEG thumbnail for images (used by chat attachment previews)
client isn't downloading the full-resolution photo just to show it tiny.""" so the client isn't downloading the full-resolution photo just to show it tiny."""
if not upload_handler.validate_upload_id(file_id): if not upload_handler.validate_upload_id(file_id):
raise HTTPException(400, "Invalid file ID") raise HTTPException(400, "Invalid file ID")
safe_file_id = secure_filename(os.path.basename(file_id))
import mimetypes as _mt import mimetypes as _mt
# Look up original filename and owner from uploads.json # Look up original filename and owner from uploads.json
original_name = file_id original_name = safe_file_id
# _load_upload_index() tolerates a missing/corrupt uploads.json (it falls # _load_upload_index() tolerates a missing/corrupt uploads.json (it falls
# back to the .bak sibling, then to {}), so a truncated DB degrades to # back to the .bak sibling, then to {}), so a truncated DB degrades to
# "no metadata" instead of a 500 from an unhandled JSONDecodeError. # "no metadata" instead of a 500 from an unhandled JSONDecodeError.
db = upload_handler._load_upload_index() db = upload_handler._load_upload_index()
info = next((fi for fi in db.values() if fi.get("id") == file_id), None) info = next((fi for fi in db.values() if fi.get("id") == safe_file_id), None)
if info: if info:
original_name = info.get("name", file_id) original_name = info.get("name", safe_file_id)
file_owner = info.get("owner") if info else None file_owner = info.get("owner") if info else None
_verify_upload_access(request, file_owner) _verify_upload_access(request, file_owner)
path = _resolve_upload_path(file_id) upload_root = _upload_root()
path = _resolve_upload_path(safe_file_id)
try:
path = confine(upload_root, path, allow_root=False)
except (PathEscape, ValueError):
raise HTTPException(403, "Access denied")
if not path.startswith(upload_root + os.sep) or not _path_inside_upload_dir(path):
raise HTTPException(403, "Access denied")
mime = (info or {}).get("mime") or _mt.guess_type(path)[0] or "application/octet-stream" mime = (info or {}).get("mime") or _mt.guess_type(path)[0] or "application/octet-stream"
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
# Downscaled thumbnail for image previews — generated once and cached. # Downscaled thumbnail for image previews — generated once and cached.
# Skip generation during HEAD probe to avoid loading/processing whole image.
if thumb and mime.startswith("image/"): if thumb and mime.startswith("image/"):
thumb_dir = os.path.normpath(os.path.join(upload_root, ".thumbs"))
thumb_path = os.path.normpath(os.path.join(thumb_dir, safe_file_id + ".jpg"))
try:
thumb_path = confine(upload_root, thumb_path, allow_root=False)
except (PathEscape, ValueError):
raise HTTPException(403, "Access denied")
if not thumb_path.startswith(thumb_dir + os.sep) or not _path_inside_upload_dir(thumb_path):
raise HTTPException(403, "Access denied")
if request.method == "HEAD":
if (os.path.isfile(thumb_path)
and os.path.getmtime(thumb_path) >= os.path.getmtime(path)):
return FileResponse(thumb_path, media_type="image/jpeg", headers=UPLOAD_RESPONSE_HEADERS)
# GET may generate a JPEG or fall back to the original on failure.
# Neither representation is known without decoding the image. HEAD
# must not advertise the original/stale file's headers as a thumb.
from fastapi.responses import Response
response = Response(status_code=200, headers=UPLOAD_RESPONSE_HEADERS)
del response.headers["content-length"]
return response
else:
try: try:
from PIL import Image, ImageOps from PIL import Image, ImageOps
thumb_dir = os.path.join(_upload_root(), ".thumbs")
os.makedirs(thumb_dir, exist_ok=True) os.makedirs(thumb_dir, exist_ok=True)
thumb_path = os.path.join(thumb_dir, file_id + ".jpg")
if (not os.path.exists(thumb_path) if (not os.path.exists(thumb_path)
or os.path.getmtime(thumb_path) < os.path.getmtime(path)): or os.path.getmtime(thumb_path) < os.path.getmtime(path)):
im = Image.open(path) im = Image.open(path)
@@ -496,9 +541,10 @@ def setup_upload_routes(upload_handler):
return next((fi for fi in db.values() if fi.get("id") == file_id), None) return next((fi for fi in db.values() if fi.get("id") == file_id), None)
def _vision_cache_path(file_id: str) -> str: def _vision_cache_path(file_id: str) -> str:
cache_dir = os.path.join(_upload_root(), ".vision") safe_id = secure_filename(os.path.basename(file_id))
cache_dir = os.path.normpath(os.path.join(_upload_root(), ".vision"))
os.makedirs(cache_dir, exist_ok=True) os.makedirs(cache_dir, exist_ok=True)
return os.path.join(cache_dir, file_id + ".txt") return os.path.normpath(os.path.join(cache_dir, safe_id + ".txt"))
def _sync_gallery_caption_for_upload(info: dict | None, owner: str | None, text: str) -> None: def _sync_gallery_caption_for_upload(info: dict | None, owner: str | None, text: str) -> None:
"""Copy upload OCR/vision text onto the promoted gallery image row.""" """Copy upload OCR/vision text onto the promoted gallery image row."""
+122
View File
@@ -0,0 +1,122 @@
"""Fail image builds that select an unfixed Debian native Brotli decoder.
Debian Node links libbrotlidec rather than bundling upstream Node's decoder.
Use Debian's backport revision, not BrotliDecoderVersion() or Python wheel
metadata, to establish the package floor for CVE-2026-111497:
https://security-tracker.debian.org/tracker/CVE-2026-111497
"""
import argparse
import ctypes
from pathlib import Path
import platform
import re
import subprocess
import sys
DEBIAN_FIXED = {"12": "1.0.9-2+deb12u1", "13": "1.1.0-2+deb13u1"}
DECODER_SONAME = "libbrotlidec.so.1"
def command(*args):
return subprocess.check_output(args, text=True).strip()
def check_package():
release = platform.freedesktop_os_release()
floor = DEBIAN_FIXED.get(release.get("VERSION_ID"))
if release.get("ID") != "debian" or floor is None:
raise ValueError("Brotli package floor is not defined for this distribution")
record = command(
"dpkg-query", "-W", "-f=${Status}\n${Version}", "libbrotli1"
).splitlines()
if len(record) != 2 or any(not value.strip() for value in record):
raise ValueError("invalid libbrotli1 package record: expected status and version")
status, version = record
if status != "install ok installed":
raise ValueError(f"libbrotli1 is not fully installed (status: {status})")
if subprocess.run(
["dpkg", "--compare-versions", version, "ge", floor], check=False
).returncode != 0:
raise ValueError(f"libbrotli1 {version} does not meet Debian's fixed floor {floor}")
owned = {
Path(line).resolve()
for line in command("dpkg-query", "-L", "libbrotli1").splitlines()
if Path(line).name == DECODER_SONAME
}
if not owned:
raise ValueError("libbrotli1 has no packaged native decoder")
return version, owned
def loaded_path(library):
# Inspect the library actually selected by the dynamic loader. An installed
# fixed package alone would not catch LD_LIBRARY_PATH or /usr/local shadows.
class DlInfo(ctypes.Structure):
_fields_ = [("filename", ctypes.c_char_p), ("base", ctypes.c_void_p),
("symbol", ctypes.c_char_p), ("address", ctypes.c_void_p)]
dladdr = ctypes.CDLL(None).dladdr
dladdr.argtypes = [ctypes.c_void_p, ctypes.POINTER(DlInfo)]
dladdr.restype = ctypes.c_int
info = DlInfo()
if not dladdr(ctypes.cast(library.BrotliDecoderDecompress, ctypes.c_void_p), ctypes.byref(info)):
raise ValueError("cannot identify the loaded Brotli decoder")
return Path(info.filename.decode()).resolve()
def check_decoder(library):
# The fix adds error -17. Older decoders return INVALID here, including
# upstream 1.1.0 and 1.2.0. Fixed Debian 12 and 13 backports expose the same
# marker with different error-string prefixes.
# No malformed attacker-controlled stream is needed for this build check.
library.BrotliDecoderErrorString.argtypes = [ctypes.c_int]
library.BrotliDecoderErrorString.restype = ctypes.c_char_p
if library.BrotliDecoderErrorString(-17) not in (b"BLOCK_SWITCH", b"_ERROR_FORMAT_BLOCK_SWITCH"):
raise ValueError("native decoder lacks the CVE-2026-111497 BLOCK_SWITCH fix marker")
library.BrotliDecoderDecompress.argtypes = [
ctypes.c_size_t, ctypes.c_void_p, ctypes.POINTER(ctypes.c_size_t), ctypes.c_void_p
]
library.BrotliDecoderDecompress.restype = ctypes.c_int
# A small, valid uncompressed Brotli stream must still decode correctly.
encoded = bytes.fromhex("0b058068656c6c6f20776f726c6403")
output = ctypes.create_string_buffer(11)
size = ctypes.c_size_t(len(output))
result = library.BrotliDecoderDecompress(len(encoded), encoded, ctypes.byref(size), output)
if result != 1 or size.value != 11 or output.raw != b"hello world":
raise ValueError("native Brotli decoder failed the valid-stream control")
def check_node(node, decoder):
# ldd inspects the distro executable and its libnode dependency without
# executing JavaScript or relying on process.versions.brotli.
linked = command("ldd", str(node))
match = re.search(r"libbrotlidec\.so\.1\s+=>\s+(/\S+)", linked)
if not match or Path(match[1]).resolve() != decoder:
raise ValueError(f"{node} does not select the checked Debian Brotli decoder")
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--node", type=Path, help="also verify this distro Node executable's decoder")
args = parser.parse_args(argv)
try:
version, owned = check_package()
library = ctypes.CDLL(DECODER_SONAME)
decoder = loaded_path(library)
if decoder not in owned:
raise ValueError(f"selected decoder is outside libbrotli1: {decoder}")
check_decoder(library)
if args.node:
check_node(args.node, decoder)
except (ValueError, OSError, AttributeError, subprocess.CalledProcessError) as exc:
print(f"Brotli acceptance failed: {exc}", file=sys.stderr)
return 1
print(f"Brotli accepted: Debian libbrotli1 {version}, decoder {decoder}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+336
View File
@@ -0,0 +1,336 @@
#!/usr/bin/env python3
"""Sanitize Playwright trace archives to strip sensitive credentials and tokens.
Structured, format-aware fail-closed security:
Replaces naive regex-based replacement with structured JSON / HAR parsing,
recursive key/value redaction, URL credential scrubbing, DOM snapshot sanitization,
safe source code masking, and strict fail-closed purge on corruption or error.
"""
from __future__ import annotations
import json
import logging
import os
import re
import sys
import urllib.parse
import zipfile
from typing import Any, Dict, List, Set
logger = logging.getLogger(__name__)
BINARY_IMAGE_EXTENSIONS = ('.png', '.jpg', '.jpeg', '.webp', '.gif', '.ico')
SENSITIVE_KEY_PATTERN = re.compile(
r'(?i)(password|passwd|secret|token|api_key|apikey|access_token|refresh_token|private_key|auth_token|client_secret|session|session_id|sid|csrf|xsrf|jwt|credential|authorization)'
)
SENSITIVE_PARAM_NAMES = frozenset({
"token", "session", "auth", "password", "passwd", "key", "secret", "api_key",
"apikey", "access_token", "refresh_token", "jwt", "sid", "code", "sig",
"signature", "client_secret", "credential"
})
SENSITIVE_HEADER_NAMES = frozenset({
"authorization", "proxy-authorization", "cookie", "set-cookie", "x-api-key",
"api-key", "x-auth-token", "x-access-token"
})
def sanitize_url(url: str) -> str:
"""Sanitize query parameters and authority credentials in URLs."""
if not isinstance(url, str) or not url.strip():
return url
try:
parts = urllib.parse.urlsplit(url)
except Exception:
return url
netloc = parts.netloc
if "@" in netloc:
userinfo, host = netloc.split("@", 1)
if ":" in userinfo:
user, _ = userinfo.split(":", 1)
netloc = f"{user}:[REDACTED]@{host}"
else:
netloc = f"[REDACTED]@{host}"
query = parts.query
if query:
q_pairs = urllib.parse.parse_qsl(query, keep_blank_values=True)
sanitized_pairs = []
for k, v in q_pairs:
k_lower = k.lower().replace("-", "_")
if k_lower in SENSITIVE_PARAM_NAMES or SENSITIVE_KEY_PATTERN.search(k_lower):
sanitized_pairs.append((k, "[REDACTED]"))
else:
sanitized_pairs.append((k, v))
query = urllib.parse.urlencode(sanitized_pairs)
return urllib.parse.urlunsplit((parts.scheme, netloc, parts.path, query, parts.fragment))
def sanitize_body_text(text: str) -> str:
"""Sanitize request bodies formatted as JSON or URL-encoded form data."""
if not isinstance(text, str) or not text.strip():
return text
# 1. Try JSON
trimmed = text.strip()
if (trimmed.startswith("{") and trimmed.endswith("}")) or (trimmed.startswith("[") and trimmed.endswith("]")):
try:
parsed = json.loads(trimmed)
sanitized = sanitize_structured_obj(parsed)
return json.dumps(sanitized, ensure_ascii=False)
except Exception:
pass
# 2. Try URL-encoded form data
if "=" in trimmed and not trimmed.startswith("<"):
try:
pairs = urllib.parse.parse_qsl(trimmed, keep_blank_values=True)
if pairs and any(k.lower() in SENSITIVE_PARAM_NAMES or SENSITIVE_KEY_PATTERN.search(k) for k, _ in pairs):
out_pairs = []
for k, v in pairs:
k_lower = k.lower().replace("-", "_")
if k_lower in SENSITIVE_PARAM_NAMES or SENSITIVE_KEY_PATTERN.search(k_lower):
out_pairs.append((k, "[REDACTED]"))
else:
out_pairs.append((k, v))
return urllib.parse.urlencode(out_pairs)
except Exception:
pass
return text
def sanitize_structured_obj(obj: Any) -> Any:
"""Recursively sanitize Python objects decoded from JSON or HAR payloads."""
if isinstance(obj, dict):
new_dict: Dict[str, Any] = {}
# Check for header entry with inverted or standard keys: {"name": ..., "value": ...}
name_val = obj.get("name")
val_val = obj.get("value")
if isinstance(name_val, str) and isinstance(val_val, str):
name_lower = name_val.lower().strip()
if name_lower in SENSITIVE_HEADER_NAMES or SENSITIVE_KEY_PATTERN.search(name_lower):
for k, v in obj.items():
if k == "value":
new_dict[k] = "[REDACTED]"
else:
new_dict[k] = sanitize_structured_obj(v)
return new_dict
# Process standard dictionary keys
for k, v in obj.items():
k_lower = str(k).lower().replace("-", "_")
# Action parameters in Playwright traces (e.g. page.fill, page.type)
if k == "params" and isinstance(v, dict):
sanitized_params = sanitize_structured_obj(v)
# If this action involves fill, type, or sensitive inputs, redact value/text
if "value" in sanitized_params:
sanitized_params["value"] = "[REDACTED]"
if "text" in sanitized_params:
sanitized_params["text"] = "[REDACTED]"
new_dict[k] = sanitized_params
continue
# DOM snapshot nodes
if k == "attributes" and isinstance(v, (dict, list)):
if isinstance(v, dict):
attr_dict = dict(v)
type_val = str(attr_dict.get("type") or "").lower()
if type_val == "password" or "password" in str(attr_dict.get("name") or "").lower():
if "value" in attr_dict:
attr_dict["value"] = "[REDACTED]"
new_dict[k] = attr_dict
continue
elif isinstance(v, list):
# List of attribute strings or pairs
new_list = []
is_pw = any("password" in str(x).lower() for x in v)
for x in v:
if is_pw and isinstance(x, str) and not x.startswith("type="):
new_list.append("[REDACTED]")
else:
new_list.append(sanitize_structured_obj(x))
new_dict[k] = new_list
continue
# Request postData / text
if k in ("postData", "postDataText") and isinstance(v, str):
new_dict[k] = sanitize_body_text(v)
continue
# URL fields
if k == "url" and isinstance(v, str):
new_dict[k] = sanitize_url(v)
continue
# Cookie arrays
if k == "cookies" and isinstance(v, list):
sanitized_cookies = []
for c in v:
if isinstance(c, dict):
c_copy = dict(c)
if "value" in c_copy:
c_copy["value"] = "[REDACTED]"
sanitized_cookies.append(c_copy)
else:
sanitized_cookies.append(sanitize_structured_obj(c))
new_dict[k] = sanitized_cookies
continue
# Direct sensitive key matches
if SENSITIVE_KEY_PATTERN.search(k_lower) and isinstance(v, str):
new_dict[k] = "[REDACTED]"
continue
new_dict[k] = sanitize_structured_obj(v)
return new_dict
elif isinstance(obj, list):
return [sanitize_structured_obj(item) for item in obj]
elif isinstance(obj, str):
# Check for inline credentials/tokens
if obj.startswith("Bearer ") or obj.startswith("bearer "):
return "Bearer [REDACTED]"
if obj.startswith("Basic ") or obj.startswith("basic "):
return "Basic [REDACTED]"
return obj
return obj
def sanitize_jsonl_content(data: bytes) -> bytes:
"""Sanitize newline-delimited JSON Lines (.trace, .network, .stacks)."""
lines = data.split(b"\n")
out_lines = []
for line in lines:
if not line.strip():
out_lines.append(line)
continue
try:
parsed = json.loads(line.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError):
# Not valid JSON, keep as-is
out_lines.append(line)
continue
sanitized = sanitize_structured_obj(parsed)
out_lines.append(json.dumps(sanitized, ensure_ascii=False).encode("utf-8"))
return b"\n".join(out_lines)
def sanitize_trace_archive(zp: str) -> bool:
"""Sanitize a single zip archive in a strict fail-closed manner.
If the archive is malformed, partial, unreadable, or encounters an exception
during processing, the archive is permanently deleted so that raw traces
cannot leak.
Returns True if successfully sanitized, False if deleted due to error.
"""
if not os.path.exists(zp):
return False
temp_zp = zp + ".tmp"
try:
if os.path.getsize(zp) == 0:
raise zipfile.BadZipFile("Empty archive")
with zipfile.ZipFile(zp, "r") as zin:
infolist = zin.infolist()
if not infolist:
raise zipfile.BadZipFile("Archive has no entries")
corrupt_file = zin.testzip()
if corrupt_file is not None:
raise zipfile.BadZipFile(f"Corrupt entry detected: {corrupt_file}")
with zipfile.ZipFile(temp_zp, "w", compression=zipfile.ZIP_DEFLATED) as zout:
for item in infolist:
filename_lower = item.filename.lower()
# 1. Binary image screenshots/diagnostics: preserve as-is
if filename_lower.endswith(BINARY_IMAGE_EXTENSIONS):
content = zin.read(item.filename)
zout.writestr(item, content)
continue
# 2. Embedded test source code (e.g. resources/src@... or .js/.ts):
# Mask safely without syntax corruption!
if "src@" in filename_lower or filename_lower.endswith((".js", ".ts", ".mjs", ".py")):
safe_placeholder = b"// [SOURCE CODE REDACTED FOR TRACE CONFIDENTIALITY]\n"
zout.writestr(item, safe_placeholder)
continue
# 3. JSON Lines and structured JSON/HAR traces
content = zin.read(item.filename)
if filename_lower.endswith((".trace", ".network", ".stacks")):
sanitized_content = sanitize_jsonl_content(content)
zout.writestr(item, sanitized_content)
elif filename_lower.endswith((".json", ".har")):
try:
parsed = json.loads(content.decode("utf-8"))
sanitized = sanitize_structured_obj(parsed)
zout.writestr(item, json.dumps(sanitized, ensure_ascii=False).encode("utf-8"))
except Exception:
# If invalid JSON, treat as text line-by-line
zout.writestr(item, sanitize_jsonl_content(content))
else:
# Unknown binary or text member: exclude to fail closed
continue
# Atomic replace
os.replace(temp_zp, zp)
return True
except Exception as exc:
logger.warning("Sanitizing trace %s failed (%s); removing fail-closed", zp, exc)
for path_to_remove in (temp_zp, zp):
if os.path.exists(path_to_remove):
try:
os.remove(path_to_remove)
except OSError as remove_err:
logger.error("Failed to delete unredacted trace %s: %s", path_to_remove, remove_err)
return False
def sanitize_directory(results_dir: str) -> int:
"""Walk results_dir and sanitize every .zip archive fail-closed.
Returns the number of archives processed. Exits with 1 if any archive fails.
"""
if not os.path.exists(results_dir):
return 0
count = 0
all_ok = True
for root, _, files in os.walk(results_dir):
for f in files:
if f.endswith(".zip"):
zp = os.path.join(root, f)
ok = sanitize_trace_archive(zp)
if not ok:
all_ok = False
count += 1
if not all_ok:
sys.exit(1)
return count
def main() -> None:
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} <results_directory>")
sys.exit(1)
target_dir = sys.argv[1]
sanitize_directory(target_dir)
if __name__ == "__main__":
main()
+2 -2
View File
@@ -2834,7 +2834,7 @@ def _parse_qwen_explicit_admin_request(text: str) -> Optional[tuple[str, str]]:
if re.search(r"\b(?:disabled\s+tools?|tools?\s+(?:are\s+)?(?:currently\s+)?disabled)\b", q): if re.search(r"\b(?:disabled\s+tools?|tools?\s+(?:are\s+)?(?:currently\s+)?disabled)\b", q):
return "manage_settings", json.dumps({"action": "list_tools"}) return "manage_settings", json.dumps({"action": "list_tools"})
if re.search(r"\b(?:turn|enable|re-enable|reenable)\b.{0,40}\b(?:image\s+generation|images?|generate_image)\b", q): if re.search(r"\b(?:turn|enable|re-enable|reenable)\b.{0,40}\b(?:image\s+generation|images?|generate_image)\b", q):
return "manage_settings", json.dumps({"action": "enable_tool", "tool": "images"}) return "manage_settings", json.dumps({"action": "list_tools"})
if re.search(r"\b(?:disable|turn\s+off)\b.{0,40}\b(?:image\s+generation|images?|generate_image)\b", q): if re.search(r"\b(?:disable|turn\s+off)\b.{0,40}\b(?:image\s+generation|images?|generate_image)\b", q):
return "manage_settings", json.dumps({"action": "disable_tool", "tool": "images"}) return "manage_settings", json.dumps({"action": "disable_tool", "tool": "images"})
if listish and re.search(r"\bapi\s+tokens?\b|\btokens?\b", q): if listish and re.search(r"\bapi\s+tokens?\b|\btokens?\b", q):
@@ -8057,7 +8057,7 @@ Generate an image. Line 1 = description, line 2 = model name, line 3 = WxH (e.g.
"manage_tokens": "- ```manage_tokens``` — Generate or revoke API access tokens for external integrations. Args (JSON): {\"action\": \"list|create|delete\", ...}", "manage_tokens": "- ```manage_tokens``` — Generate or revoke API access tokens for external integrations. Args (JSON): {\"action\": \"list|create|delete\", ...}",
"manage_documents": "- ```manage_documents``` — List, read/open, delete, or tidy documents in the editor panel. Args (JSON): {\"action\": \"list|read|delete|tidy\", ...}. `list` returns rows like `[Title](#document-<id>) — lang, size, updated 5m ago` sorted MOST-RECENT FIRST; the user clicks the anchor to open. `read` (aliases: view/open/get) takes `document_id` and returns the content. When the user asks \"open/show/read my notes\" or \"what documents do I have\", use this — do NOT shell out, do NOT curl.", "manage_documents": "- ```manage_documents``` — List, read/open, delete, or tidy documents in the editor panel. Args (JSON): {\"action\": \"list|read|delete|tidy\", ...}. `list` returns rows like `[Title](#document-<id>) — lang, size, updated 5m ago` sorted MOST-RECENT FIRST; the user clicks the anchor to open. `read` (aliases: view/open/get) takes `document_id` and returns the content. When the user asks \"open/show/read my notes\" or \"what documents do I have\", use this — do NOT shell out, do NOT curl.",
"manage_research": "- ```manage_research``` — List, read/open, or delete saved DEEP RESEARCH results from the Library. Args (JSON): {\"action\": \"list|read|delete\", \"id\": \"<id>\", \"search\": \"...\"}. `list` returns rows like `[query](#research-<id>) — N sources` MOST-RECENT FIRST; the user clicks to open. `read` (aliases: open/view/get) takes `id` and returns the report text + sources. Use when the user says \"open/read/find/delete my research\" or \"that report\". This IS how you read a finished report: when the user refers to a just-completed deep-research job (\"check it out\", \"read that report\", \"summarize the research\") WITHOUT giving an id, call `manage_research` with `action:list` to get the most-recent id, then `action:read` with that id, and answer from the returned text. Do NOT `web_fetch`/`app_api` the `/api/research/report/{id}` URL — that endpoint renders HTML for the browser, not clean text — and do NOT start a fresh `web_search`/`trigger_research` just to read an existing report. To START new research, use trigger_research instead.", "manage_research": "- ```manage_research``` — List, read/open, or delete saved DEEP RESEARCH results from the Library. Args (JSON): {\"action\": \"list|read|delete\", \"id\": \"<id>\", \"search\": \"...\"}. `list` returns rows like `[query](#research-<id>) — N sources` MOST-RECENT FIRST; the user clicks to open. `read` (aliases: open/view/get) takes `id` and returns the report text + sources. Use when the user says \"open/read/find/delete my research\" or \"that report\". This IS how you read a finished report: when the user refers to a just-completed deep-research job (\"check it out\", \"read that report\", \"summarize the research\") WITHOUT giving an id, call `manage_research` with `action:list` to get the most-recent id, then `action:read` with that id, and answer from the returned text. Do NOT `web_fetch`/`app_api` the `/api/research/report/{id}` URL — that endpoint renders HTML for the browser, not clean text — and do NOT start a fresh `web_search`/`trigger_research` just to read an existing report. To START new research, use trigger_research instead.",
"manage_settings": "- ```manage_settings``` — View/change the REAL app settings (same ones the Settings panel writes) AND turn tools on/off. Change a setting: `{\"action\":\"set\",\"key\":\"...\",\"value\":\"...\"}` — keys accept friendly aliases, e.g. voice→tts_voice, \"search engine\"→search_provider, \"default model\"→default_model, \"teacher model\"→teacher_model, \"task/background model\"→task_model, \"image quality\"→image_quality, \"reminder channel\"→reminder_channel (browser|email|ntfy), \"agent timeout\"/\"max tool calls\"/\"token budget\". Read: `{\"action\":\"get\",\"key\":\"...\"}`; see all: `{\"action\":\"list\"}`; reset one: `{\"action\":\"reset\",\"key\":\"...\"}`. Use this when the user asks to change ANY preference instead of making them open Settings. Secrets/API keys are read-only (tell them to set those in the panel). Tool toggles: `{\"action\":\"disable_tool|enable_tool\",\"tool\":\"shell\"}` (aliases: shell/search/browser/documents/memory/skills/images/tasks/notes/calendar/email), list disabled: `{\"action\":\"list_tools\"}`.", "manage_settings": "- ```manage_settings``` — View/change the REAL app settings (same ones the Settings panel writes) AND turn tools on/off. Change a setting: `{\"action\":\"set\",\"key\":\"...\",\"value\":\"...\"}` — keys accept friendly aliases, e.g. voice→tts_voice, \"search engine\"→search_provider, \"default model\"→default_model, \"teacher model\"→teacher_model, \"task/background model\"→task_model, \"image quality\"→image_quality, \"reminder channel\"→reminder_channel (browser|email|ntfy), \"agent timeout\"/\"max tool calls\"/\"token budget\". Read: `{\"action\":\"get\",\"key\":\"...\"}`; see all: `{\"action\":\"list\"}`; reset one: `{\"action\":\"reset\",\"key\":\"...\"}`. Use this when the user asks to change ANY preference instead of making them open Settings. Secrets/API keys are read-only (tell them to set those in the panel). Tool toggles: `{\"action\":\"disable_tool\",\"tool\":\"shell\"}` (aliases: shell/search/browser/documents/memory/skills/images/tasks/notes/calendar/email), list disabled: `{\"action\":\"list_tools\"}`. You cannot re-enable a disabled tool: that is done by the user in Settings > Agent Tools.",
"manage_notes": """\ "manage_notes": """\
```manage_notes ```manage_notes
{"action": "add", "title": "<short todo>", "due_date": "<natural language or ISO datetime>"} {"action": "add", "title": "<short todo>", "due_date": "<natural language or ISO datetime>"}
+32 -10
View File
@@ -585,7 +585,9 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
return _ALIASES_SET.get(k2, (k or "").strip()) return _ALIASES_SET.get(k2, (k or "").strip())
def _is_managed_key(key): def _is_managed_key(key):
return key in DEFAULT_SETTINGS and key not in RETIRED_SETTING_KEYS # Tool policy is not a general setting, even if a future default
# is added: resetting it must never re-enable disabled tools.
return key != "disabled_tools" and key in DEFAULT_SETTINGS and key not in RETIRED_SETTING_KEYS
_ENUMS = { _ENUMS = {
"image_quality": ["low", "medium", "high"], "image_quality": ["low", "medium", "high"],
@@ -713,13 +715,35 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
save_settings(s) save_settings(s)
return {"response": f"Reset {key} to default ({DEFAULT_SETTINGS[key]}).", "exit_code": 0} return {"response": f"Reset {key} to default ({DEFAULT_SETTINGS[key]}).", "exit_code": 0}
elif action in ("disable_tool", "enable_tool", "list_tools"): elif action == "enable_tool":
# The global disabled_tools denylist is the one hard permission
# boundary the Settings -> Agent Tools panel offers admins. It
# must only ever be *narrowed* from inside an agent turn: a
# model that can call enable_tool can undo the boundary itself,
# and any web page, email, or document the agent reads can ask
# it to (prompt injection re-enabling bash/email/etc.). Widening
# the tool set stays a human action in the Settings panel.
tool_name = (args.get("tool") or args.get("name") or "").strip().lower()
return {
"error": (
f"Re-enabling tools is not available from chat"
f"{f' (requested: {tool_name})' if tool_name else ''}. "
"Disabled tools are an admin policy: ask the user to turn the tool "
"back on in Settings > Agent Tools. You can still disable tools "
"(disable_tool) or list the current state (list_tools)."
),
"exit_code": 1,
}
elif action in ("disable_tool", "list_tools"):
# Tool-toggle actions. These edit settings.json:disabled_tools # Tool-toggle actions. These edit settings.json:disabled_tools
# (the global list read on every chat request) rather than # (the global list read on every chat request) rather than
# prefs.json. Friendly aliases accepted: "shell" -> "bash", # prefs.json. Friendly aliases accepted: "shell" -> "bash",
# "search" -> "web_search", "browser" -> browser tools, # "search" -> "web_search", "browser" -> browser tools,
# "documents" -> the document tool set, "memory" -> # "documents" -> the document tool set, "memory" ->
# manage_memory, etc. # manage_memory, etc. Only disable_tool mutates: see the
# enable_tool branch above for why the agent can never widen
# the denylist.
from src.settings import get_setting, save_settings, load_settings from src.settings import get_setting, save_settings, load_settings
_ALIASES = { _ALIASES = {
"shell": ["bash"], "shell": ["bash"],
@@ -753,7 +777,8 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
"response": ( "response": (
f"Currently disabled: {', '.join(current) if current else '(none)'}.\n" f"Currently disabled: {', '.join(current) if current else '(none)'}.\n"
"Common toggles: shell (bash), search (web_search), browser, documents, " "Common toggles: shell (bash), search (web_search), browser, documents, "
"memory, skills, images, tasks, notes, calendar, email." "memory, skills, images, tasks, notes, calendar, email. "
"To re-enable a tool, open Settings > Agent Tools."
), ),
"disabled": list(current), "disabled": list(current),
"exit_code": 0, "exit_code": 0,
@@ -767,22 +792,19 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
settings = load_settings() settings = load_settings()
current = list(settings.get("disabled_tools") or []) current = list(settings.get("disabled_tools") or [])
before = set(current) before = set(current)
if action == "disable_tool":
for t in targets: for t in targets:
if t not in current: if t not in current:
current.append(t) current.append(t)
else: # enable_tool
current = [t for t in current if t not in targets]
after = set(current) after = set(current)
settings["disabled_tools"] = current settings["disabled_tools"] = current
save_settings(settings) save_settings(settings)
verb = "Disabled" if action == "disable_tool" else "Enabled"
changed = sorted(after.symmetric_difference(before)) changed = sorted(after.symmetric_difference(before))
return { return {
"response": ( "response": (
f"{verb} {tool_name} ({', '.join(targets)}). " f"Disabled {tool_name} ({', '.join(targets)}). "
f"Now disabled: {', '.join(current) if current else '(none)'}." f"Now disabled: {', '.join(current) if current else '(none)'}. "
"Re-enabling is done by the user in Settings > Agent Tools."
), ),
"changed": changed, "changed": changed,
"disabled": list(current), "disabled": list(current),
+239 -10
View File
@@ -339,6 +339,229 @@ async def self_check(
return out return out
def _resolve_override_candidate(override: str, owner: Optional[str]) -> Optional[Dict[str, Any]]:
"""Resolve an explicit agent_model override under server authority.
Guarantees:
- Client-provided raw URLs never determine arbitrary outbound network destinations.
- Registered endpoint IDs resolve only to enabled endpoints visible to the owner.
- Shared endpoints (owner IS NULL) remain visible to authorized users.
- Disabled, foreign-owned, forged, or unconfigured destinations fail closed (return None).
- Admin privileges do not allow arbitrary unconfigured destinations.
"""
raw = str(override or "").strip()
if not raw:
return None
if "::" in raw:
target, requested_model = raw.split("::", 1)
target = target.strip()
requested_model = requested_model.strip()
else:
target = ""
requested_model = raw
# 1. Explicit local selection or matching a configured local base
if target == "local":
bases = local_bases()
if not bases:
return None
return {
"base": bases[0],
"model": requested_model or local_model(),
"headers": None,
"source": "override",
}
for lb in local_bases():
if target and target.rstrip("/") == lb.rstrip("/"):
return {
"base": lb,
"model": requested_model or local_model(),
"headers": None,
"source": "override",
}
# 2. Explicit hosted fallback selection
try:
from src.endpoint_resolver import resolve_endpoint
for prefix in ("utility", "default"):
try:
url, model, headers = resolve_endpoint(prefix, owner=owner)
except Exception:
continue
if url and model:
if target in ("default", "hosted"):
return {
"base": url,
"model": requested_model or model,
"headers": headers,
"source": "hosted",
}
if target and target.rstrip("/") == url.rstrip("/"):
if not requested_model or requested_model == model:
return {
"base": url,
"model": requested_model or model,
"headers": headers,
"source": "hosted",
}
except Exception:
pass
if target in ("default", "hosted"):
return None
# 3. Bare model id without endpoint prefix
if not target:
if requested_model == "local":
bases = local_bases()
return {"base": bases[0], "model": local_model(), "headers": None, "source": "local"} if bases else None
if requested_model in ("default", "hosted"):
return _resolve_override_candidate("default::", owner)
# Exact match with local model
if requested_model == local_model():
bases = local_bases()
if bases:
return {"base": bases[0], "model": local_model(), "headers": None, "source": "local"}
# Exact match with hosted default/utility
try:
from src.endpoint_resolver import resolve_endpoint
for prefix in ("utility", "default"):
try:
url, model, headers = resolve_endpoint(prefix, owner=owner)
except Exception:
continue
if url and model and model == requested_model:
return {"base": url, "model": model, "headers": headers, "source": "hosted"}
except Exception:
pass
# Check registered database endpoints for this bare model
try:
from core.database import SessionLocal, ModelEndpoint
from src.auth_helpers import owner_filter, _auth_disabled
from src.endpoint_resolver import (
resolve_endpoint_runtime,
build_chat_url,
build_headers,
_endpoint_enabled_models,
_endpoint_hidden_models,
)
db = SessionLocal()
try:
if not owner and not _auth_disabled():
q = db.query(ModelEndpoint).filter(
ModelEndpoint.is_enabled == True,
ModelEndpoint.owner == None,
)
else:
q = owner_filter(
db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True),
ModelEndpoint,
owner,
)
matching_eps = []
for ep in q.all():
enabled_models = _endpoint_enabled_models(ep)
hidden_models = _endpoint_hidden_models(ep)
if requested_model in enabled_models and requested_model not in hidden_models:
matching_eps.append(ep)
# Avoid model ID collision across providers: only accept if unique
if len(matching_eps) == 1:
ep = matching_eps[0]
base, api_key = resolve_endpoint_runtime(ep, owner=owner)
return {
"base": build_chat_url(base),
"model": requested_model,
"headers": build_headers(api_key, base),
"source": "registered",
"endpoint_id": ep.id,
}
finally:
db.close()
except Exception:
pass
return None
# 4. Target is an endpoint_id or a registered legacy endpoint URL
try:
from core.database import SessionLocal, ModelEndpoint
from src.auth_helpers import owner_filter, _auth_disabled
from src.endpoint_resolver import (
resolve_endpoint_runtime,
build_chat_url,
build_headers,
_endpoint_enabled_models,
_endpoint_hidden_models,
_first_chat_model,
)
db = SessionLocal()
try:
if not owner and not _auth_disabled():
q = db.query(ModelEndpoint).filter(
ModelEndpoint.is_enabled == True,
ModelEndpoint.owner == None,
)
else:
q = owner_filter(
db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True),
ModelEndpoint,
owner,
)
# Lookup by ID
ep = q.filter(ModelEndpoint.id == target).first()
# If not found by ID, match legacy registered base URL (strictly registered rows only)
if ep is None and ("://" in target or target.startswith("http")):
target_norm = target.rstrip("/")
for cand_ep in q.all():
cand_base = (cand_ep.base_url or "").rstrip("/")
if (
cand_base == target_norm
or cand_base + "/v1" == target_norm
or cand_base == target_norm + "/v1"
):
ep = cand_ep
break
if ep is None:
# Target is disabled, foreign-owned, nonexistent, or arbitrary unconfigured URL -> fail closed!
return None
enabled_models = _endpoint_enabled_models(ep)
hidden_models = _endpoint_hidden_models(ep)
if requested_model:
if requested_model in hidden_models:
return None
if enabled_models and requested_model not in enabled_models:
# Model not served by this endpoint -> fail closed against forged model IDs
return None
model = requested_model
else:
model = _first_chat_model(enabled_models)
if not model:
return None
base, api_key = resolve_endpoint_runtime(ep, owner=owner)
return {
"base": build_chat_url(base),
"model": model,
"headers": build_headers(api_key, base),
"source": "registered",
"endpoint_id": ep.id,
}
finally:
db.close()
except Exception:
return None
def _candidates(owner: Optional[str], override: Optional[str]) -> List[Dict[str, Any]]: def _candidates(owner: Optional[str], override: Optional[str]) -> List[Dict[str, Any]]:
cands: List[Dict[str, Any]] = [] cands: List[Dict[str, Any]] = []
for base in local_bases(): for base in local_bases():
@@ -357,16 +580,13 @@ def _candidates(owner: Optional[str], override: Optional[str]) -> List[Dict[str,
except Exception: except Exception:
pass pass
if override: if override:
base, model = _split_override(override) cand = _resolve_override_candidate(override, owner)
# A request may choose a configured endpoint, never introduce a URL. if cand is not None:
# Prefer an exact model match (including hosted credentials) over the cands.insert(0, cand)
# first local endpoint when a bare model id is supplied. return cands
matching = [c for c in cands if not base or str(c["base"]).rstrip("/") == base.rstrip("/")] # Unauthorized, invalid, foreign, disabled, or forged override:
selected = next((c for c in matching if c["model"] == model), None) # Fail closed! Never silently dispatch sensitive user images/state to an unexpected fallback.
if selected is None and model and matching and matching[0]["source"] == "local": return []
selected = {**matching[0], "model": model}
if selected is not None:
cands.insert(0, {**selected, "source": "override"})
return cands return cands
@@ -438,9 +658,11 @@ async def list_editor_agent_models(owner: Optional[str] = None) -> List[Dict[str
"source": "local", "source": "local",
"vision": True, "vision": True,
"base_url": base, "base_url": base,
"endpoint_id": "local",
}) })
try: try:
from src.endpoint_resolver import resolve_endpoint from src.endpoint_resolver import resolve_endpoint
from src.settings import get_user_setting, load_settings
for prefix in ("utility", "default"): for prefix in ("utility", "default"):
try: try:
@@ -451,12 +673,19 @@ async def list_editor_agent_models(owner: Optional[str] = None) -> List[Dict[str
key = ("hosted", model) key = ("hosted", model)
if key not in seen: if key not in seen:
seen.add(key) seen.add(key)
ep_id = None
try:
settings = load_settings()
ep_id = (get_user_setting(f"{prefix}_endpoint_id", owner or "", settings.get(f"{prefix}_endpoint_id", "")) or "").strip() or None
except Exception:
pass
models.append({ models.append({
"id": model, "id": model,
"label": model, "label": model,
"source": "hosted", "source": "hosted",
"vision": None, "vision": None,
"base_url": url, "base_url": url,
"endpoint_id": ep_id or prefix,
}) })
break break
except Exception: except Exception:
+3 -2
View File
@@ -1556,8 +1556,9 @@ def _build_chatgpt_responses_payload(
"stream": stream, "stream": stream,
"store": False, "store": False,
} }
if not _restricts_temperature(model): # ChatGPT plan Responses requests reject temperature for every model.
payload["temperature"] = temperature # The common caller still passes its preset value, but this provider must
# omit the field even for model names outside the known reasoning families.
# ChatGPT Subscription Codex API does not support max_output_tokens — # ChatGPT Subscription Codex API does not support max_output_tokens —
# passing it returns HTTP 400 "Unsupported parameter: max_output_tokens". # passing it returns HTTP 400 "Unsupported parameter: max_output_tokens".
# Do not include it in the payload. # Do not include it in the payload.
+1 -1
View File
@@ -410,7 +410,7 @@ _PRIVATE_ACTION_WRITES: Mapping[str, frozenset[str]] = MappingProxyType(
"manage_endpoints": frozenset({"add", "delete", "enable", "disable"}), "manage_endpoints": frozenset({"add", "delete", "enable", "disable"}),
"manage_mcp": frozenset({"add", "delete", "enable", "disable", "reconnect"}), "manage_mcp": frozenset({"add", "delete", "enable", "disable", "reconnect"}),
"manage_settings": frozenset( "manage_settings": frozenset(
{"set", "delete", "reset", "disable_tool", "enable_tool"} {"set", "delete", "reset", "disable_tool"}
), ),
"manage_tokens": frozenset({"create", "delete"}), "manage_tokens": frozenset({"create", "delete"}),
"manage_webhooks": frozenset({"add", "delete", "enable", "disable"}), "manage_webhooks": frozenset({"add", "delete", "enable", "disable"}),
+1 -1
View File
@@ -145,7 +145,7 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = {
"manage_tokens": "API token management: list, create, or delete API access tokens.", "manage_tokens": "API token management: list, create, or delete API access tokens.",
"manage_documents": "List, read, delete, or tidy documents in the editor panel. action='list' returns clickable rows (most-recent first) so the user can open any doc by clicking. action='read' (aka view/open/get) with document_id returns the content; supports offset=<N> + limit=<N> to page through large docs (response includes next_offset when more remains, so you can keep calling with offset=next_offset). action='delete' with document_id removes a doc (only way to delete). Use this for ANY 'show/read/list/open my documents/docs/files/notes' request — never shell or curl.", "manage_documents": "List, read, delete, or tidy documents in the editor panel. action='list' returns clickable rows (most-recent first) so the user can open any doc by clicking. action='read' (aka view/open/get) with document_id returns the content; supports offset=<N> + limit=<N> to page through large docs (response includes next_offset when more remains, so you can keep calling with offset=next_offset). action='delete' with document_id removes a doc (only way to delete). Use this for ANY 'show/read/list/open my documents/docs/files/notes' request — never shell or curl.",
"manage_research": "List, read/open, or delete saved DEEP RESEARCH results from the Library. action='list' returns clickable [query](#research-<id>) rows (most-recent first). action='read' (aka open/view/get) with id returns the report + sources. action='delete' with id removes it. Use this for ANY 'open/read/find/delete my research / that report / the research on X' request. NOTE: this is for EXISTING research; to START new research use trigger_research.", "manage_research": "List, read/open, or delete saved DEEP RESEARCH results from the Library. action='list' returns clickable [query](#research-<id>) rows (most-recent first). action='read' (aka open/view/get) with id returns the report + sources. action='delete' with id removes it. Use this for ANY 'open/read/find/delete my research / that report / the research on X' request. NOTE: this is for EXISTING research; to START new research use trigger_research.",
"manage_settings": "Change ANY real app setting (the ones the Settings panel writes) so the user never has to open it: TTS voice/provider/speed, STT, search engine + result count, default/teacher/task/utility/vision/image/research models, image quality, reminder channel (browser/email/ntfy), agent timeout/tool-call budget, and more. action=set with key (friendly aliases ok: voice, 'search engine', 'default model', 'teacher model', 'image quality', 'reminder channel'...) + value; get/list/reset too. Also toggles tools on/off (disable_tool/enable_tool/list_tools). Secrets/API keys are read-only. Use for any 'change my…/set my…/use X for…/turn on…' preference request.", "manage_settings": "Change ANY real app setting (the ones the Settings panel writes) so the user never has to open it: TTS voice/provider/speed, STT, search engine + result count, default/teacher/task/utility/vision/image/research models, image quality, reminder channel (browser/email/ntfy), agent timeout/tool-call budget, and more. action=set with key (friendly aliases ok: voice, 'search engine', 'default model', 'teacher model', 'image quality', 'reminder channel'...) + value; get/list/reset too. Also turns tools off globally (disable_tool) and lists disabled ones (list_tools); re-enabling is only done by the user in Settings > Agent Tools. Secrets/API keys are read-only. Use for any 'change my…/set my…/use X for…/turn on…' preference request.",
"create_session": "Create a new chat with a name and model.", "create_session": "Create a new chat with a name and model.",
"list_sessions": "List all chats with their metadata (the UI calls these 'chats'). Use for 'list my chats', 'rename all my chats' (list first, then manage_session to rename each).", "list_sessions": "List all chats with their metadata (the UI calls these 'chats'). Use for 'list my chats', 'rename all my chats' (list first, then manage_session to rename each).",
"send_to_session": "Send a message to another chat. Cross-chat communication.", "send_to_session": "Send a message to another chat. Cross-chat communication.",
+3 -3
View File
@@ -1292,14 +1292,14 @@ FUNCTION_TOOL_SCHEMAS = [
"type": "function", "type": "function",
"function": { "function": {
"name": "manage_settings", "name": "manage_settings",
"description": "Manage user preferences and settings. Use `disable_tool`/`enable_tool`/`list_tools` to turn individual tools on or off globally (e.g. shell, search, browser, documents, memory, skills, images, tasks, notes, calendar, email). Use list/get/set/delete for free-form preferences.", "description": "Manage user preferences and settings. Use `disable_tool`/`list_tools` to turn individual tools off globally or list what is disabled (e.g. shell, search, browser, documents, memory, skills, images, tasks, notes, calendar, email). Disabled tools can only be re-enabled by the user in Settings > Agent Tools, never from chat. Use list/get/set/delete for free-form preferences.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
"action": {"type": "string", "enum": ["list", "get", "set", "delete", "disable_tool", "enable_tool", "list_tools"]}, "action": {"type": "string", "enum": ["list", "get", "set", "delete", "disable_tool", "list_tools"]},
"key": {"type": "string", "description": "Setting key (for get/set/delete)"}, "key": {"type": "string", "description": "Setting key (for get/set/delete)"},
"value": {"description": "Setting value (for set) — can be string, number, boolean, or object"}, "value": {"description": "Setting value (for set) — can be string, number, boolean, or object"},
"tool": {"type": "string", "description": "Tool name to disable/enable (for disable_tool/enable_tool). Accepts aliases: shell, search, browser, documents, memory, skills, images, tasks, notes, calendar, email — or a raw tool name like 'bash' or 'web_search'."} "tool": {"type": "string", "description": "Tool name to disable (for disable_tool). Accepts aliases: shell, search, browser, documents, memory, skills, images, tasks, notes, calendar, email — or a raw tool name like 'bash' or 'web_search'."}
}, },
"required": ["action"] "required": ["action"]
} }
+6
View File
@@ -769,6 +769,8 @@ _APP_API_BLOCKLIST_PREFIXES = (
# {"tasks": []} to /api/cookbook/state, which overwrote the whole file. # {"tasks": []} to /api/cookbook/state, which overwrote the whole file.
# Use dedicated tools or UI flows instead. # Use dedicated tools or UI flows instead.
_APP_API_BLOCKLIST_METHOD_PATH = ( _APP_API_BLOCKLIST_METHOD_PATH = (
("POST", "/api/tools"), # global tool policy is changed by a human in Settings
("PATCH", "/api/mcp/servers"), # per-server MCP tool policy and server toggles, same reason
("GET", "/api/email/accounts"), # owner-filtered in tool context; use list_email_accounts MCP tool ("GET", "/api/email/accounts"), # owner-filtered in tool context; use list_email_accounts MCP tool
("POST", "/api/cookbook/state"), # whole-file overwrite — agent must use serve_preset/serve_model instead ("POST", "/api/cookbook/state"), # whole-file overwrite — agent must use serve_preset/serve_model instead
("DELETE", "/api/cookbook/state"), ("DELETE", "/api/cookbook/state"),
@@ -893,6 +895,10 @@ async def do_app_api(content: str, owner: Optional[str] = None) -> Dict:
if method not in ("GET", "POST", "PUT", "PATCH", "DELETE"): if method not in ("GET", "POST", "PUT", "PATCH", "DELETE"):
return {"error": f"Unsupported method: {method}", "exit_code": 1} return {"error": f"Unsupported method: {method}", "exit_code": 1}
if any(method == m and path.startswith(p) for m, p in _APP_API_BLOCKLIST_METHOD_PATH): if any(method == m and path.startswith(p) for m, p in _APP_API_BLOCKLIST_METHOD_PATH):
if path.startswith("/api/tools"):
return {"error": "Tool policy cannot be replaced from chat. Re-enable tools in Settings > Agent Tools; use manage_settings to list or disable them.", "exit_code": 1}
if path.startswith("/api/mcp/servers"):
return {"error": "MCP server and tool policy cannot be changed from chat. Ask the user to change it in Settings > Integrations.", "exit_code": 1}
if "/api/email/accounts" in path: if "/api/email/accounts" in path:
return {"error": "Don't use /api/email/accounts via app_api — it is owner-filtered in tool context and may return empty. Use the `list_email_accounts` email tool, then pass `account` to list_emails/read_email.", "exit_code": 1} return {"error": "Don't use /api/email/accounts via app_api — it is owner-filtered in tool context and may return empty. Use the `list_email_accounts` email tool, then pass `account` to list_emails/read_email.", "exit_code": 1}
if "/api/cookbook/packages/install" in path: if "/api/cookbook/packages/install" in path:
+24 -1
View File
@@ -155,11 +155,34 @@ body {
@font-face { font-family: 'Fira Code'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-Regular.woff2') format('woff2'); } @font-face { font-family: 'Fira Code'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Fira Code'; font-weight: 600; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-SemiBold.woff2') format('woff2'); } @font-face { font-family: 'Fira Code'; font-weight: 600; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-SemiBold.woff2') format('woff2'); }
/* Self-hosted OpenDyslexic — dyslexia-friendly accessibility font option (SIL OFL 1.1) */ /* Self-hosted OpenDyslexic — dyslexia-friendly accessibility font option (SIL OFL 1.1) */
@font-face { font-family: 'OpenDyslexic'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/OpenDyslexic-Regular.woff2') format('woff2'); } @font-face { font-family: 'OpenDyslexic'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/OpenDyslexic-Regular.woff2') format('woff2'); }
@font-face { font-family: 'OpenDyslexic'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/OpenDyslexic-Bold.woff2') format('woff2'); } @font-face { font-family: 'OpenDyslexic'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/OpenDyslexic-Bold.woff2') format('woff2'); }
/* Self-hosted editor text fonts (SIL OFL 1.1). These are bundled so the text
tool renders distinct, expected faces on every device — phones (Android/iOS)
do not ship Arial/Verdana/Georgia/Times/Courier/Impact, which previously
collapsed every font choice onto the same system fallback. */
@font-face { font-family: 'Inter'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/Inter-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Inter'; font-weight: 500; font-style: normal; font-display: swap; src: url('/static/fonts/Inter-Medium.woff2') format('woff2'); }
@font-face { font-family: 'Inter'; font-weight: 600; font-style: normal; font-display: swap; src: url('/static/fonts/Inter-SemiBold.woff2') format('woff2'); }
@font-face { font-family: 'Roboto'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/Roboto-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Roboto'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/Roboto-Bold.woff2') format('woff2'); }
@font-face { font-family: 'Playfair Display'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/PlayfairDisplay-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Playfair Display'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/PlayfairDisplay-Bold.woff2') format('woff2'); }
@font-face { font-family: 'Lobster'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/Lobster-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Bebas Neue'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/BebasNeue-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Roboto Mono'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/RobotoMono-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Roboto Mono'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/RobotoMono-Bold.woff2') format('woff2'); }
@font-face { font-family: 'Space Grotesk'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/SpaceGrotesk-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Space Grotesk'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/SpaceGrotesk-Bold.woff2') format('woff2'); }
@font-face { font-family: 'Fraunces'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/Fraunces-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Fraunces'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/Fraunces-Bold.woff2') format('woff2'); }
@font-face { font-family: 'Syne'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/Syne-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Syne'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/Syne-Bold.woff2') format('woff2'); }
@font-face { font-family: 'Bungee'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/Bungee-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Instrument Serif'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/InstrumentSerif-Regular.woff2') format('woff2'); }
/* Code block baseline */ /* Code block baseline */
pre, code, .hljs { pre, code, .hljs {
font-size: 11px; font-size: 11px;
+66 -7
View File
@@ -315,7 +315,7 @@ body.bg-pattern-ascii-fireflies {
width: 240px; width: 240px;
background: var(--sidebar-bg, var(--panel)); background: var(--sidebar-bg, var(--panel));
border-right: 1px solid var(--border); border-right: 1px solid var(--border);
transition: width 0.25s ease, opacity 0.2s ease, padding 0.25s ease; transition: width 0.25s ease, opacity 0.2s ease, padding 0.25s ease, visibility 0.25s;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex-shrink: 0; flex-shrink: 0;
@@ -361,12 +361,23 @@ body.bg-pattern-ascii-fireflies {
/* !important so it beats the inline width init.js restores from storage — /* !important so it beats the inline width init.js restores from storage —
otherwise the width never changes and only opacity animates, making the otherwise the width never changes and only opacity animates, making the
collapse look instant. With this, width animates from the inline value collapse look instant. With this, width animates from the inline value
down to 0 via the .sidebar width transition. */ down to 0 via the .sidebar width transition.
display overrides the global .hidden utility, which would otherwise
remove the box before any transition can run. visibility (transitioned
on .sidebar) flips to hidden only once the collapse has finished,
taking the closed sidebar out of the tab order. */
display: flex;
width: 0 !important; width: 0 !important;
padding: 0 !important; padding: 0 !important;
border: none; border: none;
overflow: hidden; overflow: hidden;
opacity: 0; opacity: 0;
visibility: hidden;
pointer-events: none;
}
/* IDs so this also beats the mobile drawer's !important transition. */
@media (prefers-reduced-motion: reduce) {
#sidebar, #icon-rail { transition: none !important; }
} }
/* ===== Sidebar User Bar ===== */ /* ===== Sidebar User Bar ===== */
.sidebar-user-bar { .sidebar-user-bar {
@@ -449,10 +460,6 @@ body.bg-pattern-ascii-fireflies {
} }
.sidebar.hidden .sidebar-user-bar {
display: none;
}
/* Sticky sidebar header — logo, never scrolls */ /* Sticky sidebar header — logo, never scrolls */
.sidebar-header { .sidebar-header {
display: flex; display: flex;
@@ -673,6 +680,24 @@ body.bg-pattern-ascii-fireflies {
margin-bottom: calc(-1 * var(--safe-bottom)); margin-bottom: calc(-1 * var(--safe-bottom));
padding-bottom: calc(8px + var(--safe-bottom)); padding-bottom: calc(8px + var(--safe-bottom));
} }
/* Tuck the rail under the sidebar (which paints above it) and slide it
out as the sidebar collapses, so the combined width eases instead of
jumping by the rail's 48px when the two swap. */
.icon-rail,
.icon-rail.right-side {
margin-inline-end: -48px;
transition: margin-inline-end 0.25s ease, display 0.25s allow-discrete;
}
body.sidebar-collapsed:not(.hamburger-only) .icon-rail,
html.ody-sidebar-mini .icon-rail {
margin-inline-end: 0;
}
@starting-style {
body.sidebar-collapsed:not(.hamburger-only) .icon-rail,
html.ody-sidebar-mini .icon-rail {
margin-inline-end: -48px;
}
}
} }
.icon-rail-divider { .icon-rail-divider {
width: 24px; width: 24px;
@@ -5116,7 +5141,7 @@ body.bg-pattern-ascii-fireflies {
width: 80% !important; width: 80% !important;
max-width: 340px; max-width: 340px;
box-shadow: 4px 0 20px rgba(0,0,0,0.5); box-shadow: 4px 0 20px rgba(0,0,0,0.5);
transition: transform 0.25s ease, opacity 0.25s ease !important; transition: transform 0.25s ease, opacity 0.25s ease, visibility 0.25s !important;
opacity: 1 !important; opacity: 1 !important;
overflow: visible !important; overflow: visible !important;
/* Force opaque background — Firefox mobile renders the /* Force opaque background — Firefox mobile renders the
@@ -5147,6 +5172,7 @@ body.bg-pattern-ascii-fireflies {
} }
html.ody-sidebar-off .sidebar, html.ody-sidebar-off .sidebar,
html.ody-mobile-startup-sidebar-hidden .sidebar { html.ody-mobile-startup-sidebar-hidden .sidebar {
width: 80% !important;
transform: translateX(-100%) !important; transform: translateX(-100%) !important;
pointer-events: none !important; pointer-events: none !important;
opacity: 0 !important; opacity: 0 !important;
@@ -7230,6 +7256,39 @@ body.bg-pattern-ascii-fireflies {
text-align: center; text-align: center;
} }
/* Inline assistant markdown images (`![alt](url)`) — rendered by
static/js/markdown.js as a.clickable <a class="chat-image"> wrapper. */
a.chat-image {
display: inline-block;
max-width: 100%;
margin: 0.35rem 0;
}
a.chat-image img.chat-inline-image {
display: block;
max-width: 100%;
height: auto;
border-radius: 8px;
cursor: zoom-in;
}
a.chat-image .chat-image-fallback {
display: none;
font-size: 0.8rem;
color: var(--color-muted, #94a3b8);
word-break: break-all;
padding: 0.5rem 0.75rem;
border: 1px dashed var(--border, #475569);
border-radius: 8px;
background: var(--panel, #202428);
}
a.chat-image.image-broken img.chat-inline-image {
display: none;
}
a.chat-image.image-broken .chat-image-fallback {
display: block;
text-decoration: underline;
cursor: pointer;
}
/* Setup wizard */ /* Setup wizard */
.setup-wizard { padding: 16px; max-width: 500px; } .setup-wizard { padding: 16px; max-width: 500px; }
.setup-wizard .setup-title { font-size: 1.1rem; font-weight: 600; margin-bottom: 12px; color: var(--fg); } .setup-wizard .setup-title { font-size: 1.1rem; font-weight: 600; margin-bottom: 12px; color: var(--fg); }
+14
View File
@@ -22,6 +22,20 @@
/* Session items */ /* Session items */
.session-item { position: relative; } .session-item { position: relative; }
/* Title fade: the server auto-renames a chat shortly after its first
prompt, which re-renders the sidebar. Fade the new title in so the
change reads as smooth rather than a snap. */
.session-item .grow.session-title-updated {
animation: sessionTitleFade 0.5s ease;
}
@keyframes sessionTitleFade {
from { opacity: 0; transform: translateY(2px); }
to { opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.session-item .grow.session-title-updated { animation: none; }
}
.text-ellipsis { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .text-ellipsis { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.session-menu-btn { padding: 0 2px !important; min-width: 20px; height: 20px; display: inline-flex !important; align-items: center; justify-content: center; background: none !important; border-color: transparent !important; } .session-menu-btn { padding: 0 2px !important; min-width: 20px; height: 20px; display: inline-flex !important; align-items: center; justify-content: center; background: none !important; border-color: transparent !important; }
.session-menu-btn:hover { background: none !important; border-color: transparent !important; } .session-menu-btn:hover { background: none !important; border-color: transparent !important; }
+1
View File
@@ -704,6 +704,7 @@ textarea.memory-add-input {
#serve-bulk-bar, #serve-bulk-bar,
#gallery-bulk-bar, #gallery-bulk-bar,
#gallery-editor-drafts-bulk, #gallery-editor-drafts-bulk,
#notes-bulk-bar,
#memory-modal .memory-bulk-bar { #memory-modal .memory-bulk-bar {
padding-right: 18px; padding-right: 18px;
} }
File diff suppressed because it is too large Load Diff
+340
View File
@@ -978,3 +978,343 @@ body.doc-find-active mark.doc-find-mark.current {
color: var(--accent, var(--red)); color: var(--accent, var(--red));
vertical-align: -2px; vertical-align: -2px;
} }
/* Colour chips: every pickable colour input renders as a circle, regardless of
the container it sits in (options bar .ge-text-field, Character panel,
gradient stops, export matte, etc.). Forced with !important so parent
input[type="color"] rules can't square them off. */
input.ge-color-picker,
input.ge-color-picker.cp-swatch-input,
.ge-text-field input.ge-color-picker,
.ge-swatch input.ge-color-picker,
.ge-filter-row input.ge-color-picker,
.ge-gradient-color-row input.ge-color-picker {
border-radius: 50% !important;
}
input.ge-color-picker::-webkit-color-swatch {
border: 0 !important;
border-radius: 50% !important;
}
input.ge-color-picker::-moz-color-swatch {
border: 0 !important;
border-radius: 50% !important;
}
/* ---------------------------------------------------------------------------
Editor theme alignment: map the editor's controls onto the app's design
tokens (static/css/00-tokens.css) so the editor matches the rest of the
product. No new colours — only the shared CSS variables.
--------------------------------------------------------------------------- */
/* Checkboxes + radios → the app's circular dot (see 17-research.css). */
body.ge-editor-open input[type="checkbox"],
body.ge-editor-open input[type="radio"] {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
margin: 0;
padding: 0;
border: 1.5px solid var(--border);
border-radius: 50%;
background: var(--bg);
cursor: pointer;
position: relative;
flex: 0 0 auto;
vertical-align: middle;
transition: background 0.12s ease, border-color 0.12s ease;
}
body.ge-editor-open input[type="checkbox"]:checked,
body.ge-editor-open input[type="radio"]:checked {
background: var(--fg);
border-color: var(--fg);
}
body.ge-editor-open input[type="checkbox"]:focus-visible,
body.ge-editor-open input[type="radio"]:focus-visible {
outline: 2px solid var(--red);
outline-offset: 1px;
}
/* Selects → the app's select tokens. */
body.ge-editor-open select {
background: var(--select-bg, var(--bg));
color: var(--select-fg, var(--fg));
border: 1px solid var(--border);
border-radius: 6px;
}
body.ge-editor-open select option {
background: var(--select-option-bg, var(--panel));
color: var(--select-option-fg, var(--fg));
}
body.ge-editor-open select option:checked,
body.ge-editor-open select option:hover {
background: var(--select-option-active-bg, var(--panel));
}
body.ge-editor-open select:focus-visible {
outline: 1px solid var(--red);
outline-offset: 1px;
}
/* Text / number inputs → token background, border and focus ring. */
body.ge-editor-open input[type="text"],
body.ge-editor-open input[type="number"],
body.ge-editor-open input[type="search"],
body.ge-editor-open textarea {
background: var(--bg);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 6px;
}
body.ge-editor-open input[type="text"]:focus-visible,
body.ge-editor-open input[type="number"]:focus-visible,
body.ge-editor-open input[type="search"]:focus-visible,
body.ge-editor-open textarea:focus-visible {
outline: 1px solid var(--red);
outline-offset: 1px;
}
/* Range sliders → the app's accent. */
body.ge-editor-open input[type="range"] {
accent-color: var(--accent, var(--red));
}
/* Scrollbars → the app's 8px red-thumb recipe (00-tokens.css). */
body.ge-editor-open * {
scrollbar-width: thin;
scrollbar-color: var(--red) var(--panel);
}
body.ge-editor-open *::-webkit-scrollbar {
width: 8px;
height: 8px;
}
body.ge-editor-open *::-webkit-scrollbar-track {
background: var(--panel);
}
body.ge-editor-open *::-webkit-scrollbar-thumb {
background-color: var(--red);
border-radius: 4px;
border: 2px solid var(--panel);
}
body.ge-editor-open *::-webkit-scrollbar-thumb:hover {
background-color: color-mix(in srgb, var(--red) 80%, white);
}
/* ---------------------------------------------------------------------------
Editor theme alignment (part 2): buttons, segmented toggles, tabs, list
rows, panel headers, badges, focus rings, number fields and colour-picker
chrome mapped onto the app's shared design tokens. Existing tokens only —
no new colours, and no geometry beyond colour / border / radius / focus.
--------------------------------------------------------------------------- */
/* The editor references two secondary / muted variables that the app token
layer never defines; map them onto the real tokens so every
var(--fg-muted) / var(--bg-secondary) editor rule resolves. */
body.ge-editor-open {
--fg-muted: var(--color-muted);
--bg-secondary: var(--panel);
}
/* Buttons → the app's `.cal-btn` recipe (5px radius; the primary button
uses the accent fill with a --bg foreground instead of a hard-coded #fff). */
body.ge-editor-open .ge-btn {
border-radius: 5px;
}
body.ge-editor-open .ge-btn-primary {
background: var(--accent, var(--red));
border-color: var(--accent, var(--red));
color: var(--bg);
}
body.ge-editor-open .ge-btn-primary:hover {
background: var(--accent, var(--red));
opacity: 0.9;
}
body.ge-editor-open .ge-win-close:hover {
background: var(--accent, var(--red));
color: var(--bg);
}
/* Segmented / option toggles, command palette and channel rows → the app's
accent token (--red) instead of the editor's blue #4c8bf5 fallback. */
body.ge-editor-open .ge-opt-mode.active,
body.ge-editor-open .ge-crop-preset.active,
body.ge-editor-open .ge-command-item.active,
body.ge-editor-open .ge-command-item:hover {
background: var(--accent, var(--red));
border-color: var(--accent, var(--red));
color: var(--bg);
}
body.ge-editor-open .ge-channel-row.active,
body.ge-editor-open .ge-dock-tabs-menu-item:hover,
body.ge-editor-open .ge-filter-menu .dropdown-item-compact:focus-visible,
body.ge-editor-open .ge-view-menu .dropdown-item-compact:focus-visible,
body.ge-editor-open .ge-window-menu .dropdown-item-compact:focus-visible,
body.ge-editor-open .ge-help-menu .dropdown-item-compact:focus-visible,
body.ge-editor-open .ge-3d-menu .dropdown-item-compact:focus-visible,
body.ge-editor-open .ge-plugins-menu .dropdown-item-compact:focus-visible {
background: color-mix(in srgb, var(--accent, var(--red)) 22%, transparent);
}
body.ge-editor-open .ge-sam-brush-add.active,
body.ge-editor-open .ge-sam-brush-subtract.active,
body.ge-editor-open .ge-actions-controls .ge-btn.active {
border-color: var(--accent, var(--red));
color: var(--accent, var(--red));
}
/* Tabs → the app tab convention: the active tab's underline is the accent. */
body.ge-editor-open .ge-dock-tab.active {
border-bottom-color: var(--accent, var(--red));
}
/* Colour-picker chrome → the token accent instead of the blue fallback. */
body.ge-editor-open .ge-swatch.active {
outline-color: var(--accent, var(--red));
}
/* Badges / status pills → the app's --bg foreground on an accent fill. */
body.ge-editor-open .ge-tool-clear {
color: var(--bg);
}
/* Panel headers → the app's muted section-title token. */
body.ge-editor-open .ge-panel-title,
body.ge-editor-open .ge-info-label {
color: var(--color-muted);
}
/* Focus rings → the app's 1px --red ring (matching the earlier input/select
block) on the controls that previously fell back to the UA outline. */
body.ge-editor-open .ge-btn:focus-visible,
body.ge-editor-open .ge-tool-btn:focus-visible,
body.ge-editor-open .ge-doc-tab:focus-visible,
body.ge-editor-open .ge-history-row:focus-visible,
body.ge-editor-open .ge-swatch:focus-visible,
body.ge-editor-open .ge-opt-mode:focus-visible,
body.ge-editor-open .ge-stepper button:focus-visible {
outline: 1px solid var(--red);
outline-offset: 1px;
}
/* ---------------------------------------------------------------------------
Editor theme alignment (part 3): the setup / preflight checklist and the
remaining checkbox list-row / badge chrome mapped onto the app's shared
design tokens. Existing tokens only — no new colours, and no geometry beyond
colour / border / radius / focus. Scoped to body.ge-editor-open.
--------------------------------------------------------------------------- */
/* Checklist card + rows → the app's panel / border / muted tokens. */
body.ge-editor-open .ge-preflight-card {
background: var(--panel);
color: var(--fg);
border-color: var(--border);
}
body.ge-editor-open .ge-preflight-head p,
body.ge-editor-open .ge-preflight-hint,
body.ge-editor-open .ge-preflight-detail,
body.ge-editor-open .ge-preflight-remember,
body.ge-editor-open .ge-preflight-close {
color: var(--color-muted);
}
body.ge-editor-open .ge-preflight-close:hover {
background: color-mix(in srgb, var(--fg) 8%, transparent);
color: var(--fg);
}
body.ge-editor-open .ge-preflight-row {
background: color-mix(in srgb, var(--panel) 92%, var(--fg) 4%);
border-color: var(--border);
}
body.ge-editor-open .ge-preflight-row.is-done {
border-color: color-mix(in srgb, var(--green) 45%, var(--border));
}
body.ge-editor-open .ge-preflight-row.is-todo {
border-color: var(--red);
}
body.ge-editor-open .ge-preflight-row.is-optional {
border-color: color-mix(in srgb, var(--accent-warm) 45%, var(--border));
}
/* Row badges → the semantic tokens (done = green, todo = red, optional = warm). */
body.ge-editor-open .ge-preflight-badge {
background: color-mix(in srgb, var(--fg) 10%, transparent);
color: var(--fg);
}
body.ge-editor-open .ge-preflight-row.is-done .ge-preflight-badge {
background: color-mix(in srgb, var(--green) 20%, transparent);
color: var(--green);
}
body.ge-editor-open .ge-preflight-row.is-todo .ge-preflight-badge {
background: color-mix(in srgb, var(--red) 18%, transparent);
color: var(--red);
}
body.ge-editor-open .ge-preflight-row.is-optional .ge-preflight-badge {
background: color-mix(in srgb, var(--accent-warm) 16%, transparent);
color: var(--accent-warm);
}
body.ge-editor-open .ge-preflight-opt {
background: color-mix(in srgb, var(--fg) 8%, transparent);
color: var(--color-muted);
}
/* Checklist actions / footer → token buttons and borders. */
body.ge-editor-open .ge-preflight-act {
background: color-mix(in srgb, var(--fg) 8%, transparent);
border-color: var(--border);
color: var(--fg);
}
body.ge-editor-open .ge-preflight-act:hover {
background: color-mix(in srgb, var(--fg) 16%, transparent);
}
body.ge-editor-open .ge-preflight-foot {
border-top-color: var(--border);
}
body.ge-editor-open .ge-preflight-summary {
color: var(--green);
}
body.ge-editor-open .ge-preflight-start {
background: var(--accent, var(--red));
color: var(--bg);
}
body.ge-editor-open .ge-preflight-start:hover {
background: var(--accent, var(--red));
opacity: 0.9;
}
body.ge-editor-open .ge-setup-btn {
border-color: var(--border);
color: var(--color-muted);
}
body.ge-editor-open .ge-setup-btn:hover {
background: color-mix(in srgb, var(--fg) 10%, transparent);
color: var(--fg);
}
/* The options-bar checkbox overrode the shared dot with a square + a drawn
::after tick; force it back to the app's 14px circular dot, filled with the
highlight (accent) colour when checked (part 1). */
body.ge-editor-open .ge-opt input[type="checkbox"],
body.ge-editor-open .ge-opt input[type="radio"] {
border-radius: 50%;
border-width: 1.5px;
}
body.ge-editor-open .ge-opt input[type="checkbox"]:checked,
body.ge-editor-open .ge-opt input[type="radio"]:checked {
background: var(--accent, var(--red));
border-color: var(--accent, var(--red));
}
body.ge-editor-open .ge-opt input[type="checkbox"]:checked::after {
content: none;
}
/* Remaining list-row / thumbnail chrome → panel + border + muted tokens. */
body.ge-editor-open .ge-history-row-dot {
background: var(--color-muted);
}
body.ge-editor-open .ge-layer-inline-thumb {
background: var(--panel);
border-color: var(--border);
}
body.ge-editor-open .ge-channel-row,
body.ge-editor-open .ge-channel-name {
color: var(--fg);
}
body.ge-editor-open .ge-channel-eye {
color: var(--color-muted);
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -664,6 +664,7 @@
<div class="theme-fd-group"> <div class="theme-fd-group">
<label class="theme-fd-label">Font</label> <label class="theme-fd-label">Font</label>
<select id="theme-font-select" class="theme-fd-select" aria-label="Font"> <select id="theme-font-select" class="theme-fd-select" aria-label="Font">
<option value="gohu">GohuFont</option>
<option value="mono">Monospace</option> <option value="mono">Monospace</option>
<option value="sans">Sans-serif</option> <option value="sans">Sans-serif</option>
<option value="serif">Serif</option> <option value="serif">Serif</option>
+61 -1
View File
@@ -18,7 +18,13 @@
'use strict'; 'use strict';
// Click-as-button rows we want reachable by keyboard. // Click-as-button rows we want reachable by keyboard.
var ROW_SELECTOR = ['#sidebar .list-item', '#user-bar-profile'].join(','); var ROW_SELECTOR = [
'#sidebar .list-item',
'#user-bar-profile',
'.sidebar-brand',
'.export-dropdown-item',
'.dropdown-item'
].join(',');
// Native interactive descendants. If a row contains one of these we must // Native interactive descendants. If a row contains one of these we must
// NOT give the row role="button" — a button inside a button is invalid // NOT give the row role="button" — a button inside a button is invalid
@@ -55,6 +61,31 @@
(root || document).querySelectorAll(ROW_SELECTOR).forEach(enhanceRow); (root || document).querySelectorAll(ROW_SELECTOR).forEach(enhanceRow);
} }
// Settings form labels are written as <label class="settings-label">Text</label>
// with the control as a sibling/descendant, but are not associated via for/id,
// so screen readers cannot name the inputs. Associate each label with its
// control when the mapping is unambiguous (a single form control per row).
var settingsLabelSeq = 0;
function associateSettingsLabels(root) {
(root || document).querySelectorAll('.settings-label').forEach(function (label) {
if (label.hasAttribute('for')) return;
var control = null;
var sib = label.nextElementSibling;
if (sib && /^(INPUT|SELECT|TEXTAREA)$/.test(sib.tagName)) {
control = sib;
} else {
var row = label.closest('.settings-row') || label.parentElement;
if (row) {
var controls = row.querySelectorAll('input,select,textarea');
if (controls.length === 1) control = controls[0];
}
}
if (!control || control.closest('label')) return;
if (!control.id) control.id = 'a11y-settings-' + (++settingsLabelSeq);
if (!control.getAttribute('aria-label')) label.setAttribute('for', control.id);
});
}
// ---- Modal dialogs ----------------------------------------------------- // ---- Modal dialogs -----------------------------------------------------
// Odysseus modals are plain <div class="modal-content"> boxes. Marking // Odysseus modals are plain <div class="modal-content"> boxes. Marking
// them as ARIA dialogs lets screen readers announce them as dialogs and // them as ARIA dialogs lets screen readers announce them as dialogs and
@@ -118,9 +149,38 @@
el.click(); el.click();
}); });
// Disclosure buttons: keep aria-expanded in sync with their controlled
// menu/dropdown, regardless of which module toggles its visibility.
var DISCLOSURE_MAP = [
{ button: 'session-sort-btn', menu: 'session-sort-dropdown' },
{ button: 'export-dl-btn', menu: 'export-dropdown-menu' },
{ button: 'overflow-plus-btn', menu: 'overflow-menu' }
];
function syncDisclosures() {
DISCLOSURE_MAP.forEach(function (d) {
var btn = document.getElementById(d.button);
if (!btn) return;
var menu = document.getElementById(d.menu);
var open = false;
if (menu) {
var cs = window.getComputedStyle(menu);
open = cs.display !== 'none' && menu.offsetWidth > 0;
}
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
});
}
function init() { function init() {
enhanceAll(document); enhanceAll(document);
enhanceModals(document); enhanceModals(document);
associateSettingsLabels(document);
// Any click (the dropdown toggles, outside-click closes, etc.) may change
// a disclosure's open state; re-sync on the next tick.
document.addEventListener('click', function () {
if (window.setTimeout) setTimeout(syncDisclosures, 0);
});
// Sidebar content is re-rendered as the user navigates (session lists, // Sidebar content is re-rendered as the user navigates (session lists,
// tool sub-rows, etc.). Watch for new rows and enhance them too. // tool sub-rows, etc.). Watch for new rows and enhance them too.
+7 -5
View File
@@ -763,7 +763,7 @@ function renderFeatherlessPanel(panel, ep, row) {
const sortedIds = Array.from(enabledSet).sort((a, b) => a.localeCompare(b)); const sortedIds = Array.from(enabledSet).sort((a, b) => a.localeCompare(b));
enabledListEl.innerHTML = sortedIds.map(id => { enabledListEl.innerHTML = sortedIds.map(id => {
const displayName = id.split('/').pop() || id; const displayName = id.split('/').pop() || id;
const mode = ['none', 'compact', 'full'].includes(String(toolModes[id] || '').toLowerCase()) const mode = ['none', 'compact', 'full', 'spec'].includes(String(toolModes[id] || '').toLowerCase())
? String(toolModes[id]).toLowerCase() ? String(toolModes[id]).toLowerCase()
: ''; : '';
return `<div title="${esc(id)}" data-ep-model-row data-model-id="${esc(id)}" class="adm-model-row"> return `<div title="${esc(id)}" data-ep-model-row data-model-id="${esc(id)}" class="adm-model-row">
@@ -777,7 +777,8 @@ function renderFeatherlessPanel(panel, ep, row) {
<select class="adm-model-tool-mode admin-tools-select" data-ep-model-id="${esc(id)}" data-original-tool-mode="${esc(toolModes[id] || '')}"> <select class="adm-model-tool-mode admin-tools-select" data-ep-model-id="${esc(id)}" data-original-tool-mode="${esc(toolModes[id] || '')}">
<option value="" ${mode === '' ? 'selected' : ''}>Auto</option> <option value="" ${mode === '' ? 'selected' : ''}>Auto</option>
<option value="full" ${mode === 'full' ? 'selected' : ''}>Regular tools</option> <option value="full" ${mode === 'full' ? 'selected' : ''}>Regular tools</option>
<option value="compact" ${mode === 'compact' ? 'selected' : ''}>Odysseus compact</option> <option value="compact" ${mode === 'compact' ? 'selected' : ''}>Ajax Compact</option>
<option value="spec" ${mode === 'spec' ? 'selected' : ''}>Ajax Spec (router)</option>
<option value="none" ${mode === 'none' ? 'selected' : ''}>Tools off</option> <option value="none" ${mode === 'none' ? 'selected' : ''}>Tools off</option>
</select> </select>
</div> </div>
@@ -1356,7 +1357,7 @@ async function loadEndpoints() {
<a href="#" data-ep-select-none="${epId}">None</a> <a href="#" data-ep-select-none="${epId}">None</a>
</span> </span>
</div>${warningHtml}${showSearch ? `<input type="search" class="mcp-tools-search" placeholder="Search ${sortedModels.length} models..." data-ep-search="${epId}">` : ''}<div class="mcp-tools-list">` + sortedModels.map(m => { </div>${warningHtml}${showSearch ? `<input type="search" class="mcp-tools-search" placeholder="Search ${sortedModels.length} models..." data-ep-search="${epId}">` : ''}<div class="mcp-tools-list">` + sortedModels.map(m => {
const mode = ['none', 'compact', 'full'].includes(String(m.tool_mode || '').toLowerCase()) const mode = ['none', 'compact', 'full', 'spec'].includes(String(m.tool_mode || '').toLowerCase())
? String(m.tool_mode).toLowerCase() ? String(m.tool_mode).toLowerCase()
: ''; : '';
return `<div title="${esc(m.id)}" data-ep-model-row data-search="${esc((m.display + ' ' + m.id).toLowerCase())}" class="adm-model-row"> return `<div title="${esc(m.id)}" data-ep-model-row data-search="${esc((m.display + ' ' + m.id).toLowerCase())}" class="adm-model-row">
@@ -1370,7 +1371,8 @@ async function loadEndpoints() {
<select class="adm-model-tool-mode admin-tools-select" data-ep-model-id="${esc(m.id)}" data-original-tool-mode="${esc(m.tool_mode || '')}" data-tool-mode-touched="0" title="Auto uses Odysseus compact for Odysseus/Ajax names and Regular tools for every other model"> <select class="adm-model-tool-mode admin-tools-select" data-ep-model-id="${esc(m.id)}" data-original-tool-mode="${esc(m.tool_mode || '')}" data-tool-mode-touched="0" title="Auto uses Odysseus compact for Odysseus/Ajax names and Regular tools for every other model">
<option value="" ${mode === '' ? 'selected' : ''}>Auto</option> <option value="" ${mode === '' ? 'selected' : ''}>Auto</option>
<option value="full" ${mode === 'full' ? 'selected' : ''}>Regular tools</option> <option value="full" ${mode === 'full' ? 'selected' : ''}>Regular tools</option>
<option value="compact" ${mode === 'compact' ? 'selected' : ''}>Odysseus compact</option> <option value="compact" ${mode === 'compact' ? 'selected' : ''}>Ajax Compact</option>
<option value="spec" ${mode === 'spec' ? 'selected' : ''}>Ajax Spec (router)</option>
<option value="none" ${mode === 'none' ? 'selected' : ''}>Tools off</option> <option value="none" ${mode === 'none' ? 'selected' : ''}>Tools off</option>
</select> </select>
</div> </div>
@@ -1440,7 +1442,7 @@ async function _saveEpModelState(epId, panel) {
const value = String(sel.value || '').toLowerCase(); const value = String(sel.value || '').toLowerCase();
const original = String(sel.dataset.originalToolMode || '').toLowerCase(); const original = String(sel.dataset.originalToolMode || '').toLowerCase();
if (modelId && (sel.dataset.toolModeTouched === '1' || original)) { if (modelId && (sel.dataset.toolModeTouched === '1' || original)) {
modelToolModes[modelId] = ['none', 'compact', 'full'].includes(value) ? value : ''; modelToolModes[modelId] = ['none', 'compact', 'full', 'spec'].includes(value) ? value : '';
} }
}); });
const total = panel.querySelectorAll('input.adm-cb-hidden[type=checkbox]').length; const total = panel.querySelectorAll('input.adm-cb-hidden[type=checkbox]').length;
+279
View File
@@ -44,6 +44,283 @@ import { invalidateSettings } from './appConfig.js';
const RUN_ID_ABORT_GRACE_MS = 2000; // timeout waits this long for a run-id header before hard-aborting const RUN_ID_ABORT_GRACE_MS = 2000; // timeout waits this long for a run-id header before hard-aborting
const RESEARCH_SVG = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>'; const RESEARCH_SVG = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>';
// Inline chat images share the editor's 8192px dimension ceiling, with a
// conservative 16MP budget for its multiple canvas surfaces. No blob URLs are
// needed here: chat previews use server-authorized upload references.
const _MAX_DATA_URL_LENGTH = 10 * 1024 * 1024;
const _MAX_INLINE_BYTES = 7 * 1024 * 1024;
const _MAX_IMAGE_DIMENSION = 8192;
const _MAX_IMAGE_PIXELS = 16 * 1024 * 1024;
let _inlineRasterCache = null; // One bounded source; repeated actions reuse its decode.
function _imageDimensionsAllowed(width, height) {
return width > 0 && height > 0 && width <= _MAX_IMAGE_DIMENSION &&
height <= _MAX_IMAGE_DIMENSION && width * height <= _MAX_IMAGE_PIXELS;
}
function _rasterDimensions(bytes, mime) {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const ascii = (offset, text) => [...text].every((char, i) => bytes[offset + i] === char.charCodeAt(0));
if (mime === 'image/png' && bytes.length >= 33 &&
bytes[0] === 137 && ascii(1, 'PNG\r\n\x1a\n') && view.getUint32(8) === 13 && ascii(12, 'IHDR')) {
return [view.getUint32(16), view.getUint32(20)];
}
if (mime === 'image/gif' && bytes.length >= 13 && (ascii(0, 'GIF87a') || ascii(0, 'GIF89a'))) {
return [view.getUint16(6, true), view.getUint16(8, true)];
}
if (mime === 'image/jpeg' && bytes[0] === 255 && bytes[1] === 216) {
let pos = 2;
while (pos + 4 <= bytes.length) {
if (bytes[pos++] !== 255) return null;
while (bytes[pos] === 255) pos++;
const marker = bytes[pos++];
if (marker === 218 || marker === 217) break;
if (marker === 1 || (marker >= 208 && marker <= 215)) continue;
if (pos + 2 > bytes.length) return null;
const size = view.getUint16(pos);
if (size < 2 || pos + size > bytes.length) return null;
if (marker >= 192 && marker <= 207 && ![196, 200, 204].includes(marker) && size >= 8) {
return [view.getUint16(pos + 5), view.getUint16(pos + 3)];
}
pos += size;
}
}
if (mime === 'image/webp' && bytes.length >= 30 && ascii(0, 'RIFF') &&
view.getUint32(4, true) + 8 === bytes.length && ascii(8, 'WEBP')) {
if (ascii(12, 'VP8X')) return [1 + bytes[24] + (bytes[25] << 8) + (bytes[26] << 16), 1 + bytes[27] + (bytes[28] << 8) + (bytes[29] << 16)];
if (ascii(12, 'VP8L') && bytes[20] === 47) return [1 + ((bytes[21] | (bytes[22] << 8)) & 16383), 1 + (((bytes[22] >> 6) | (bytes[23] << 2) | (bytes[24] << 10)) & 16383)];
if (ascii(12, 'VP8 ') && bytes[23] === 157 && bytes[24] === 1 && bytes[25] === 42) return [view.getUint16(26, true) & 16383, view.getUint16(28, true) & 16383];
}
return null;
}
function _inlineRaster(url) {
if (_inlineRasterCache?.url === url) return _inlineRasterCache;
const result = { url, valid: false };
_inlineRasterCache = result;
if (url.length > _MAX_DATA_URL_LENGTH) return result;
const match = /^data:(image\/(?:png|jpeg|jpg|webp|gif));base64,/i.exec(url);
if (!match) return result;
const payload = url.slice(match[0].length);
if (!payload || payload.length % 4 || !/^[A-Za-z0-9+/]*={0,2}$/.test(payload)) return result;
const padding = payload.endsWith('==') ? 2 : payload.endsWith('=') ? 1 : 0;
const size = payload.length / 4 * 3 - padding;
if (size > _MAX_INLINE_BYTES) return result;
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
if (padding && (alphabet.indexOf(payload[payload.length - padding - 1]) & (padding === 2 ? 15 : 3))) return result;
try {
const bytes = typeof Uint8Array.fromBase64 === 'function' ? Uint8Array.fromBase64(payload) : (() => {
const binary = atob(payload); const array = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) array[i] = binary.charCodeAt(i);
return array;
})();
const mime = match[1].toLowerCase().replace('image/jpg', 'image/jpeg');
const dimensions = _rasterDimensions(bytes, mime);
if (!dimensions || !_imageDimensionsAllowed(...dimensions)) return result;
Object.assign(result, { valid: true, bytes, mime, dimensions });
} catch (_) {}
return result;
}
// This synchronous guard rejects untrusted references before any fetch/decode.
// Inline images additionally require a successful browser decode before launch.
function _isAuthorizedImageUrl(url) {
if (!url || typeof url !== 'string') return false;
const trimmed = url.trim();
if (trimmed.startsWith('data:')) return _inlineRaster(trimmed).valid;
if (!trimmed || trimmed.startsWith('blob:') || trimmed.startsWith('//')) return false;
try {
const base = window.location.origin;
const parsed = new URL(trimmed, base);
return parsed.origin === base && !parsed.search && !parsed.hash &&
/^\/api\/(?:upload|gallery)\/[A-Za-z0-9_\-]+(?:\.[a-zA-Z0-9]+)?$/.test(parsed.pathname);
} catch (_) { return false; }
}
async function _validateInlineImage(url) {
const raster = _inlineRaster(url.trim());
if (!raster.valid) return false;
if (!raster.decoded) raster.decoded = (async () => {
try {
const image = await createImageBitmap(new Blob([raster.bytes], { type: raster.mime }));
try {
return _imageDimensionsAllowed(image.width, image.height) &&
image.width * image.height === raster.dimensions[0] * raster.dimensions[1];
} finally { image.close(); }
} catch (_) { return false; }
finally { raster.bytes = null; }
})();
return raster.decoded;
}
// Find the specific user message that originated the turn containing container.
function _findOriginatingUserMessage(container) {
if (!container) return null;
let curr = container.previousElementSibling;
while (curr) {
if (curr.classList && curr.classList.contains('msg-user')) {
return curr;
}
curr = curr.previousElementSibling;
}
const box = document.getElementById('chat-history');
if (box && box.contains(container)) {
const all = Array.from(box.querySelectorAll('.msg, .agent-thread, .ai-msg-wrap, .round-holder'));
const idx = all.findIndex((el) => el === container || el.contains(container));
if (idx > 0) {
for (let i = idx - 1; i >= 0; i--) {
if (all[i].classList && all[i].classList.contains('msg-user')) {
return all[i];
}
}
}
}
return null;
}
// Resolve the image attached to the exact turn associated with container / action.
// Never falls back to unrelated images from earlier conversation turns.
function _attachedImageForTurn(container, action) {
const explicitId = action && (action.imageId || action.image_id || action.fileId || action.file_id);
if (explicitId && typeof explicitId === 'string' && /^[A-Za-z0-9_\-]+(?:\.[A-Za-z0-9]+)?$/.test(explicitId)) {
return (API_BASE || '') + '/api/upload/' + explicitId;
}
const explicitUrl = action && (action.imageUrl || action.image_url);
if (explicitUrl) return _isAuthorizedImageUrl(explicitUrl) ? explicitUrl : null;
const userMsg = _findOriginatingUserMessage(container);
if (!userMsg) return null;
const previews = userMsg.querySelectorAll(
'.attach-image-preview[data-file-id], .attach-card[data-file-id]'
);
for (let i = previews.length - 1; i >= 0; i--) {
const fid = previews[i].dataset ? previews[i].dataset.fileId : null;
if (fid && /^[A-Za-z0-9_\-]+(?:\.[A-Za-z0-9]+)?$/.test(fid)) {
return (API_BASE || '') + '/api/upload/' + fid;
}
const img = previews[i].querySelector('img');
const src = img ? (img.getAttribute('src') || '') : '';
if (_isAuthorizedImageUrl(src)) return src;
}
const imgs = userMsg.querySelectorAll('.attach-image-preview img, .attach-card img');
for (let i = imgs.length - 1; i >= 0; i--) {
const src = imgs[i].getAttribute('src') || '';
if (_isAuthorizedImageUrl(src)) return src;
}
return null;
}
function _currentAttachedImageUrl(container, action) {
return _attachedImageForTurn(container, action);
}
function _notifyError(msg) {
try {
if (uiModule && typeof uiModule.showError === 'function') {
uiModule.showError(msg);
return;
}
} catch (_) {}
console.warn('[chat]', msg);
}
// Render the one-click "Open in editor & apply" button on a photo-edit reply
// that had no open editor (photo_editor_action kind 'open_and_apply').
function renderPhotoEditorApplyButton(container, action) {
if (!container || !action || action.kind !== 'open_and_apply') return;
if (container.querySelector('.chat-open-in-editor-btn')) return;
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'chat-open-in-editor-btn';
btn.textContent = 'Open in editor & apply';
btn.style.cssText = 'margin-top:8px;padding:6px 12px;border-radius:6px;border:1px solid rgba(120,120,120,0.4);background:transparent;color:inherit;cursor:pointer;font:inherit;';
btn.addEventListener('click', async () => {
if (btn.disabled) return;
btn.disabled = true;
try {
let imageUrl = _attachedImageForTurn(container, action);
if (!imageUrl) {
_notifyError('No attached photo found for this conversation turn.');
btn.disabled = false;
return;
}
if (!_isAuthorizedImageUrl(imageUrl)) {
_notifyError('Unauthorized or invalid image reference.');
btn.disabled = false;
return;
}
if (imageUrl.trim().startsWith('data:') && !await _validateInlineImage(imageUrl)) {
_notifyError('Invalid or oversized image attachment.');
return;
}
// Gallery references name an owner-scoped metadata resource, not image bytes.
if (!imageUrl.trim().startsWith('data:') && new URL(imageUrl, window.location.origin).pathname.startsWith('/api/gallery/')) {
const response = await fetch(imageUrl, { credentials: 'same-origin' });
if (!response.ok) { _notifyError('The gallery image is unavailable.'); return; }
const image = await response.json();
const source = new URL(image.url || '', window.location.origin);
if (source.origin !== window.location.origin || source.search || source.hash ||
!/^\/api\/generated-image\/[A-Za-z0-9_.-]+$/.test(source.pathname)) {
_notifyError('Invalid gallery image reference.'); return;
}
imageUrl = source.href;
}
// Verify attachment is accessible and authorized on the server before launching editor
if (imageUrl.includes('/api/upload/')) {
try {
const probe = await fetch(imageUrl, { method: 'HEAD' });
if (!probe.ok) {
if (probe.status === 404 || probe.status === 410) {
_notifyError('The attached image is no longer available or upload has expired.');
} else if (probe.status === 401 || probe.status === 403) {
_notifyError('You are not authorized to edit this attached image.');
} else {
_notifyError('Could not verify image attachment authorization.');
}
btn.disabled = false;
return;
}
} catch (_) {
_notifyError('Could not verify image attachment authorization.');
btn.disabled = false;
return;
}
}
if (typeof window.gePhotoEditorApply !== 'function') {
await import('/static/js/galleryEditor.js?v=20260909movepicklayer1');
}
await window.gePhotoEditorApply({
imageId: (action && (action.imageId || action.image_id)) || null,
imageUrl,
prompt: action.prompt || '',
displayName: 'Chat edit',
});
} catch (err) {
console.error('[chat] open_and_apply failed', err);
_notifyError('Failed to apply edit: ' + (err.message || err));
} finally {
btn.disabled = false;
}
});
container.appendChild(btn);
}
if (typeof window !== 'undefined') {
window.__chatPhotoEditorTestHooks = {
_isAuthorizedImageUrl,
_findOriginatingUserMessage,
_attachedImageForTurn,
renderPhotoEditorApplyButton,
};
}
let API_BASE = ''; let API_BASE = '';
let currentAbort = null; let currentAbort = null;
let isStreaming = false; let isStreaming = false;
@@ -4095,6 +4372,7 @@ import { invalidateSettings } from './appConfig.js';
threadAbove.classList.add('has-bottom'); threadAbove.classList.add('has-bottom');
} }
terminalFinalResponseRendered = _renderTerminalAnswer(finalText, '', json.render_owner, json.replacement_scope); terminalFinalResponseRendered = _renderTerminalAnswer(finalText, '', json.render_owner, json.replacement_scope);
if (json.photo_editor_action) renderPhotoEditorApplyButton(roundHolder, json.photo_editor_action);
lastContentRoundHolder = roundHolder || lastContentRoundHolder; lastContentRoundHolder = roundHolder || lastContentRoundHolder;
uiModule.scrollHistory(); uiModule.scrollHistory();
continue; continue;
@@ -6519,6 +6797,7 @@ import { invalidateSettings } from './appConfig.js';
gotDelta = true; gotDelta = true;
try { spinner.destroy(); } catch (_) {} try { spinner.destroy(); } catch (_) {}
renderDelta(json); renderDelta(json);
if (json.photo_editor_action) renderPhotoEditorApplyButton(roundHolder, json.photo_editor_action);
replayRendering.settle(); replayRendering.settle();
} else if (json.delta) { } else if (json.delta) {
if (!replayRendering.accepts(json)) continue; if (!replayRendering.accepts(json)) continue;
+5 -1
View File
@@ -457,6 +457,7 @@ function _makeActionBtn(className, title, text, handler) {
btn.className = className; btn.className = className;
btn.type = 'button'; btn.type = 'button';
btn.title = title; btn.title = title;
btn.setAttribute('aria-label', title);
btn.textContent = text; btn.textContent = text;
btn.addEventListener('click', handler); btn.addEventListener('click', handler);
return btn; return btn;
@@ -2375,6 +2376,7 @@ export function createMsgFooter(msgElement, { animate = false } = {}) {
moreBtn.className = 'msg-action-btn msg-more-btn'; moreBtn.className = 'msg-action-btn msg-more-btn';
moreBtn.type = 'button'; moreBtn.type = 'button';
moreBtn.title = 'More actions'; moreBtn.title = 'More actions';
moreBtn.setAttribute('aria-label', 'More actions');
moreBtn.textContent = '\u00B7\u00B7\u00B7'; moreBtn.textContent = '\u00B7\u00B7\u00B7';
moreBtn.addEventListener('click', (e) => { moreBtn.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
@@ -2571,6 +2573,7 @@ export function createUserMsgFooter(msgElement) {
moreBtn.className = 'msg-action-btn msg-more-btn'; moreBtn.className = 'msg-action-btn msg-more-btn';
moreBtn.type = 'button'; moreBtn.type = 'button';
moreBtn.title = 'More actions'; moreBtn.title = 'More actions';
moreBtn.setAttribute('aria-label', 'More actions');
moreBtn.textContent = '\u00B7\u00B7\u00B7'; moreBtn.textContent = '\u00B7\u00B7\u00B7';
moreBtn.addEventListener('click', (e) => { moreBtn.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
@@ -3443,7 +3446,8 @@ export function addMessage(role, content, modelName, metadata) {
for (const ev of roundTools) { for (const ev of roundTools) {
const image = generatedImageResult(ev); const image = generatedImageResult(ev);
if (image) { if (image) {
box.appendChild(buildImageBubble(image.image_url, image.image_prompt, image.image_model, image.image_size, image.image_quality, image.image_id)); const imgBubble = buildImageBubble(image.image_url, image.image_prompt, image.image_model, image.image_size, image.image_quality, image.image_id);
box.appendChild(imgBubble);
} }
} }
} }
+2
View File
@@ -212,6 +212,8 @@ export function handleUIControl(uiData) {
var fn = mod.openPanel || mod.openNotes || (mod.default && (mod.default.openPanel || mod.default.openNotes)); var fn = mod.openPanel || mod.openNotes || (mod.default && (mod.default.openPanel || mod.default.openNotes));
if (fn) fn(); if (fn) fn();
}).catch(function(){}); }).catch(function(){});
} else if (panel === 'tasks') {
document.getElementById('tool-tasks-btn')?.click();
} else if (panel === 'theme' || panel === 'themes') { } else if (panel === 'theme' || panel === 'themes') {
import('./theme.js?v=20260911organsrain1').then(function(mod) { import('./theme.js?v=20260911organsrain1').then(function(mod) {
var fn = mod.togglePopup || (mod.default && mod.default.togglePopup); var fn = mod.togglePopup || (mod.default && mod.default.togglePopup);
+3 -3
View File
@@ -390,11 +390,11 @@ export function runHTML(code, panel) {
* Main entry point — called when a Run button is clicked * Main entry point — called when a Run button is clicked
*/ */
export function run(btn) { export function run(btn) {
const code = btn.getAttribute('data-code');
const lang = (btn.getAttribute('data-lang') || '').toLowerCase();
const pre = btn.closest('pre'); const pre = btn.closest('pre');
if (!pre) return; if (!pre) return;
const codeEl = pre.querySelector('code');
const code = codeEl ? codeEl.textContent : btn.getAttribute('data-code');
const lang = (btn.getAttribute('data-lang') || '').toLowerCase();
const panel = getOrCreatePanel(pre); const panel = getOrCreatePanel(pre);
if (!code || !code.trim()) { if (!code || !code.trim()) {
+23 -7
View File
@@ -72,7 +72,12 @@ function hexToHsv(hex) { const { r, g, b } = hexToRgb(hex); return rgbToHsv(r, g
// ── Storage ─────────────────────────────────────────────────────────── // ── Storage ───────────────────────────────────────────────────────────
function getRecents() { function getRecents() {
try { return JSON.parse(localStorage.getItem(LS_RECENT) || '[]'); } try {
const values = JSON.parse(localStorage.getItem(LS_RECENT) || '[]');
return Array.isArray(values)
? values.filter(value => typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value)).slice(0, MAX_RECENT)
: [];
}
catch { return []; } catch { return []; }
} }
@@ -85,17 +90,28 @@ function addRecent(hex) {
} }
// ── Suggestions based on current color (5 harmony swatches) ────────── // ── Suggestions based on current color (5 harmony swatches) ──────────
function computeSuggestions() { function suggestionsForHsv(h, s, v) {
// Complement, analogous ±30°, split-complement (+150), tone shift // Complement, analogous ±30°, split-complement (+150), tone shift
return [ return [
{ hex: hsvToHex(_h + 180, _s, _v), label: 'Complement' }, { hex: hsvToHex(h + 180, s, v), label: 'Complement' },
{ hex: hsvToHex(_h + 30, _s, _v), label: 'Analogous +30°' }, { hex: hsvToHex(h + 30, s, v), label: 'Analogous +30°' },
{ hex: hsvToHex(_h - 30, _s, _v), label: 'Analogous -30°' }, { hex: hsvToHex(h - 30, s, v), label: 'Analogous -30°' },
{ hex: hsvToHex(_h + 150, _s, _v), label: 'Split-complement' }, { hex: hsvToHex(h + 150, s, v), label: 'Split-complement' },
{ hex: hsvToHex(_h, _s, clamp(_v > 50 ? _v - 30 : _v + 30, 10, 95)), label: 'Tone shift' }, { hex: hsvToHex(h, s, clamp(v > 50 ? v - 30 : v + 30, 10, 95)), label: 'Tone shift' },
]; ];
} }
// ── Reusable helpers for hosts that surface swatches inline ──────────
// (e.g. the editor's SWATCHES panel showing suggestions + recents).
function computeSuggestions() { return suggestionsForHsv(_h, _s, _v); }
export function getRecentColors() { return getRecents(); }
export function recordRecentColor(hex) { addRecent(hex); }
export function suggestColorsFor(hex) {
const { h, s, v } = hexToHsv(hex || '#000000');
return suggestionsForHsv(h, s, v);
}
// ── Popover build ───────────────────────────────────────────────────── // ── Popover build ─────────────────────────────────────────────────────
function buildPopover() { function buildPopover() {
const p = document.createElement('div'); const p = document.createElement('div');
+2 -1
View File
@@ -1498,7 +1498,8 @@ function _rerenderCachedModels() {
const _ggufOptions = _ggufChoices.map(f => const _ggufOptions = _ggufChoices.map(f =>
`<option value="${esc(f.rel_path)}"${f.rel_path === _defaultGguf ? ' selected' : ''}>${esc(_ggufFileLabel(f))}</option>` `<option value="${esc(f.rel_path)}"${f.rel_path === _defaultGguf ? ' selected' : ''}>${esc(_ggufFileLabel(f))}</option>`
).join(''); ).join('');
const _defaultServeModel = (m.is_local_dir && m.path ? `${m.path}/${repo}` : repo); const _minimaxM3Snapshot = '~/.cache/huggingface/hub/models--cyankiwi--MiniMax-M3-AWQ-INT4/snapshots/4082acbbec1236d21828d55b6bb0fe02ade4ab5b';
const _defaultServeModel = _isMiniMaxM3 ? _minimaxM3Snapshot : (m.is_local_dir && m.path ? `${m.path}/${repo}` : repo);
const _savedModelPath = String(svm('model_path', _defaultServeModel) || '').trim(); const _savedModelPath = String(svm('model_path', _defaultServeModel) || '').trim();
const _modelPathValue = _savedModelPath || _defaultServeModel; const _modelPathValue = _savedModelPath || _defaultServeModel;
const _defaultServedModelName = _isMiniMaxM3 ? repo : ''; const _defaultServedModelName = _isMiniMaxM3 ? repo : '';
+42 -5
View File
@@ -1996,6 +1996,17 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
// Active drop mode for the PDF toolbar — toolbar buttons set this; the // Active drop mode for the PDF toolbar — toolbar buttons set this; the
// next click on a page consumes it. null means clicks do nothing. // next click on a page consumes it. null means clicks do nothing.
let _pdfDropMode = null; let _pdfDropMode = null;
let _pdfAnnotationDragging = false;
function _setPdfAnnotationDragging(dragging) {
_pdfAnnotationDragging = dragging;
for (const ref of _pdfPaneAnnotationsByDoc.get(activeDocId) || []) {
ref._refreshHandles?.();
if (dragging) {
const menu = ref.wrap?.querySelector('.pdf-annotation-text-menu');
if (menu) menu.style.display = 'none';
}
}
}
// Per-doc last-used line spacing for text annotations. Once the user picks // Per-doc last-used line spacing for text annotations. Once the user picks
// 1.6 for one box, every text box dropped after that defaults to 1.6. // 1.6 for one box, every text box dropped after that defaults to 1.6.
const _pdfLastLineHeight = new Map(); // docId -> number const _pdfLastLineHeight = new Map(); // docId -> number
@@ -2290,8 +2301,16 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
// active (toolbar buttons set the mode). Without a mode, clicking does // active (toolbar buttons set the mode). Without a mode, clicking does
// nothing — keeps page interactions predictable so users don't get // nothing — keeps page interactions predictable so users don't get
// surprise boxes from stray clicks. // surprise boxes from stray clicks.
const isBlankPageTarget = target => target === pageWrap
|| (target?.tagName === 'IMG' && target.parentElement === pageWrap);
let placementStartedOnPage = false;
pageWrap.addEventListener('pointerdown', ev => {
placementStartedOnPage = isBlankPageTarget(ev.target);
}, true);
pageWrap.addEventListener('click', (ev) => { pageWrap.addEventListener('click', (ev) => {
if (ev.target !== pageWrap && ev.target.tagName !== 'IMG') return; const canPlace = placementStartedOnPage;
placementStartedOnPage = false;
if (!canPlace || !isBlankPageTarget(ev.target) || _pdfAnnotationDragging) return;
if (!_pdfDropMode) return; if (!_pdfDropMode) return;
const rect = pageWrap.getBoundingClientRect(); const rect = pageWrap.getBoundingClientRect();
const xPct = ((ev.clientX - rect.left) / rect.width) * 100; const xPct = ((ev.clientX - rect.left) / rect.width) * 100;
@@ -2423,9 +2442,11 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
// Set handle visibility together; clicking/tapping the annotation itself // Set handle visibility together; clicking/tapping the annotation itself
// brings hidden controls back. // brings hidden controls back.
let handlesRequested = _isTouch;
const _setHandlesVisible = (show) => { const _setHandlesVisible = (show) => {
if (!_pdfAnnotationDragging) handlesRequested = show;
const dismissed = wrap.dataset.controlsDismissed === '1'; const dismissed = wrap.dataset.controlsDismissed === '1';
const v = (show && !dismissed) ? '' : 'none'; const v = (show && !dismissed && !_pdfAnnotationDragging) ? '' : 'none';
del.style.display = v; del.style.display = v;
grip.style.display = v; grip.style.display = v;
resize.style.display = v; resize.style.display = v;
@@ -2446,7 +2467,8 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
_setHandlesVisible(true); _setHandlesVisible(true);
}); });
const ref = { id: ann.id, page: ann.page, x: ann.x, y: ann.y, w: ann.w, h: ann.h, el: input, wrap, kind, _setHandlesVisible }; const ref = { id: ann.id, page: ann.page, x: ann.x, y: ann.y, w: ann.w, h: ann.h, el: input, wrap, kind, _setHandlesVisible,
_refreshHandles: () => _setHandlesVisible(handlesRequested) };
if (kind === 'check') { if (kind === 'check') {
// Stamp checkmark — value is fixed, nothing to listen for. // Stamp checkmark — value is fixed, nothing to listen for.
@@ -2568,7 +2590,12 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
ref._autoGrow = _autoGrow; ref._autoGrow = _autoGrow;
} }
del.addEventListener('pointerdown', ev => {
ev.preventDefault();
ev.stopPropagation();
});
del.addEventListener('click', (ev) => { del.addEventListener('click', (ev) => {
ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
_pushPdfUndoSnapshot(); _pushPdfUndoSnapshot();
_removeAnnotation(ref); _removeAnnotation(ref);
@@ -2580,6 +2607,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
ev.stopPropagation(); ev.stopPropagation();
_pushPdfUndoSnapshot(); _pushPdfUndoSnapshot();
try { grip.setPointerCapture(ev.pointerId); } catch (_) {} try { grip.setPointerCapture(ev.pointerId); } catch (_) {}
_setPdfAnnotationDragging(true);
// Hide the × and resize handles while moving so they don't obscure the // Hide the × and resize handles while moving so they don't obscure the
// box — easier to see exactly where it lands. Restored on release. // box — easier to see exactly where it lands. Restored on release.
del.style.display = 'none'; del.style.display = 'none';
@@ -2598,11 +2626,14 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
const onUp = () => { const onUp = () => {
document.removeEventListener('pointermove', onMove); document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onUp); document.removeEventListener('pointerup', onUp);
document.removeEventListener('pointercancel', onUp);
_setPdfAnnotationDragging(false);
_setHandlesVisible(true); _setHandlesVisible(true);
_schedulePdfPaneSave(); _schedulePdfPaneSave();
}; };
document.addEventListener('pointermove', onMove); document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onUp); document.addEventListener('pointerup', onUp);
document.addEventListener('pointercancel', onUp);
}); });
// Drag bottom-right corner to resize. Width/height stored as percentages. // Drag bottom-right corner to resize. Width/height stored as percentages.
@@ -2611,6 +2642,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
ev.stopPropagation(); ev.stopPropagation();
_pushPdfUndoSnapshot(); _pushPdfUndoSnapshot();
try { resize.setPointerCapture(ev.pointerId); } catch (_) {} try { resize.setPointerCapture(ev.pointerId); } catch (_) {}
_setPdfAnnotationDragging(true);
// Hide the × and move handles while resizing — clean view of the box edge. // Hide the × and move handles while resizing — clean view of the box edge.
del.style.display = 'none'; del.style.display = 'none';
grip.style.display = 'none'; grip.style.display = 'none';
@@ -2628,11 +2660,14 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
const onUp = () => { const onUp = () => {
document.removeEventListener('pointermove', onMove); document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onUp); document.removeEventListener('pointerup', onUp);
document.removeEventListener('pointercancel', onUp);
_setPdfAnnotationDragging(false);
_setHandlesVisible(true); _setHandlesVisible(true);
_schedulePdfPaneSave(); _schedulePdfPaneSave();
}; };
document.addEventListener('pointermove', onMove); document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onUp); document.addEventListener('pointerup', onUp);
document.addEventListener('pointercancel', onUp);
}); });
// Text options menu — opened from the floating … button so the spacing // Text options menu — opened from the floating … button so the spacing
@@ -15239,7 +15274,8 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
menu.style.top = (rect.bottom + 2) + 'px'; menu.style.top = (rect.bottom + 2) + 'px';
menu.style.right = (window.innerWidth - rect.right) + 'px'; menu.style.right = (window.innerWidth - rect.right) + 'px';
menu.style.left = 'auto'; menu.style.left = 'auto';
menu.style.zIndex = '9999'; const editorPane = document.querySelector('.doc-editor-pane');
menu.style.zIndex = String(Math.max(topPortalZ(), (parseInt(getComputedStyle(editorPane).zIndex, 10) || 0) + 1));
const langLabel = lang ? lang.toUpperCase() : 'TXT'; const langLabel = lang ? lang.toUpperCase() : 'TXT';
// Form-backed markdown doc → primary export is the filled PDF, not the // Form-backed markdown doc → primary export is the filled PDF, not the
@@ -15314,7 +15350,8 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
} }
// Outside-click AND Escape both route through the central esc-stack via // Outside-click AND Escape both route through the central esc-stack via
// bindMenuDismiss; onClose owns the actual node removal. // bindMenuDismiss; onClose owns the actual node removal.
const close = bindMenuDismiss(menu, () => { menu.remove(); }); const exportBtn = document.getElementById('doc-footer-export-btn');
const close = bindMenuDismiss(menu, () => { menu.remove(); }, (ev) => !menu.contains(ev.target) && !exportBtn?.contains(ev.target));
} }
function _richTextExportCss() { function _richTextExportCss() {
+33 -1
View File
@@ -31,10 +31,15 @@ export function enable(containerId, itemSelector, options = {}) {
onReorder: options.onReorder || null, onReorder: options.onReorder || null,
handleSelector: options.handleSelector || null, handleSelector: options.handleSelector || null,
excludeSelector: options.excludeSelector || null, excludeSelector: options.excludeSelector || null,
ignoreSelector: options.ignoreSelector || null,
// When > 0 the pointer must travel this many pixels before the drag
// starts, so rows that also handle plain clicks stay clickable.
threshold: Math.max(0, Number(options.threshold) || 0),
storageKey: options.storageKey || null, storageKey: options.storageKey || null,
}; };
let draggedEl = null; let draggedEl = null;
let pending = null;
let placeholder = null; let placeholder = null;
let offsetY = 0; let offsetY = 0;
let items = []; let items = [];
@@ -130,6 +135,12 @@ export function enable(containerId, itemSelector, options = {}) {
draggedEl.style.top = snapTop + 'px'; draggedEl.style.top = snapTop + 'px';
setTimeout(() => { setTimeout(() => {
// The list may have been re-rendered mid-drag (e.g. dropping a layer
// effect copies it and rebuilds the panel), detaching both nodes.
if (!placeholder.parentNode || !draggedEl.isConnected) {
placeholder.remove();
return;
}
placeholder.parentNode.insertBefore(draggedEl, placeholder); placeholder.parentNode.insertBefore(draggedEl, placeholder);
placeholder.remove(); placeholder.remove();
draggedEl.classList.remove('dragging'); draggedEl.classList.remove('dragging');
@@ -158,10 +169,21 @@ export function enable(containerId, itemSelector, options = {}) {
function onMouseDown(e) { function onMouseDown(e) {
if (e.button !== 0) return; if (e.button !== 0) return;
if (config.handleSelector && !e.target.closest(config.handleSelector)) return; if (config.handleSelector && !e.target.closest(config.handleSelector)) return;
if (config.ignoreSelector && e.target.closest(config.ignoreSelector)) return;
const item = e.target.closest(itemSelector); const item = e.target.closest(itemSelector);
if (!item || !container.contains(item)) return; if (!item || !container.contains(item)) return;
if (config.excludeSelector && item.matches(config.excludeSelector)) return; if (config.excludeSelector && item.matches(config.excludeSelector)) return;
if (config.threshold > 0) {
// Defer the lift until the pointer actually moves, so a plain click
// (select layer, open the rename field) is left untouched.
pending = { item, startY: e.clientY };
e.preventDefault();
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
return;
}
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
startDrag(e.clientY, item); startDrag(e.clientY, item);
@@ -169,11 +191,21 @@ export function enable(containerId, itemSelector, options = {}) {
document.addEventListener('mouseup', onMouseUp); document.addEventListener('mouseup', onMouseUp);
} }
function onMouseMove(e) { moveDrag(e.clientY); } function onMouseMove(e) {
if (pending) {
if (Math.abs(e.clientY - pending.startY) < config.threshold) return;
const { item, startY } = pending;
pending = null;
e.preventDefault();
startDrag(startY, item);
}
moveDrag(e.clientY);
}
function onMouseUp() { function onMouseUp() {
document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp); document.removeEventListener('mouseup', onMouseUp);
pending = null;
endDrag(); endDrag();
} }
+243
View File
@@ -0,0 +1,243 @@
/**
* Actions panel (PhotoCraft parity, Phase 11) — a lightweight macro
* recorder/player. Every step maps to a known editor command by CSS
* selector; recording captures clicks on those selectors, and play
* replays them in order. Named actions persist to localStorage.
*
* The module owns no editor state: it only dispatches clicks on the
* selectors it is given.
*/
export const ACTION_COMMANDS = Object.freeze([
['flip-h', 'Flip Horizontal', '[data-image-action="flip-h"]'],
['flip-v', 'Flip Vertical', '[data-image-action="flip-v"]'],
['rotate-90', 'Rotate 90° CW', '[data-image-action="rotate-90"]'],
['rotate-180', 'Rotate 180°', '[data-image-action="rotate-180"]'],
['fill', 'Fill Foreground', '[data-image-action="fill"]'],
['merge-down', 'Merge Down', '#ge-merge-down'],
['flatten', 'Flatten', '#ge-flatten'],
['select-all', 'Select All', '[data-selection-action="all"]'],
['deselect', 'Deselect', '[data-selection-action="deselect"]'],
['invert-selection', 'Invert Selection', '[data-selection-action="invert"]'],
['blur-gaussian', 'Gaussian Blur', '[data-filter-action="blur-gaussian"]'],
['blur-motion', 'Motion Blur', '[data-filter-action="blur-motion"]'],
['noise-add', 'Add Noise', '[data-filter-action="noise-add"]'],
['pixelate-mosaic', 'Pixelate', '[data-filter-action="pixelate-mosaic"]'],
['sharpen', 'Sharpen', '[data-filter-action="sharpen"]'],
['stylize-emboss', 'Emboss', '[data-filter-action="stylize-emboss"]'],
['distort-twirl', 'Twirl', '[data-filter-action="distort-twirl"]'],
['rembg', 'Remove Background', '[data-tool-option="rembg"]'],
['inpaint', 'Inpaint', '[data-tool="inpaint"]'],
]);
const STORAGE_KEY = 'ge-actions';
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text != null) node.textContent = text;
return node;
}
function commandById(id) {
return ACTION_COMMANDS.find(command => command[0] === id) || null;
}
export function createActionsPanel({ showToast = () => {}, getLayers = null, selectLayer = null } = {}) {
let steps = [];
let recording = false;
let saved = loadSaved();
const body = el('div', 'ge-panel-body ge-actions-body');
const nameRow = el('div', 'ge-actions-name-row');
const nameInput = document.createElement('input');
nameInput.type = 'text';
nameInput.placeholder = 'Action name';
nameInput.className = 'ge-actions-name';
const saveButton = el('button', 'ge-btn ge-btn-sm', 'Save');
saveButton.type = 'button';
nameRow.append(nameInput, saveButton);
const addRow = el('div', 'ge-actions-add-row');
const addSelect = document.createElement('select');
addSelect.className = 'ge-actions-command';
ACTION_COMMANDS.forEach(([id, label]) => addSelect.appendChild(new Option(label, id)));
const addButton = el('button', 'ge-btn ge-btn-sm', 'Add');
addButton.type = 'button';
addRow.append(addSelect, addButton);
const controls = el('div', 'ge-actions-controls');
const recordButton = el('button', 'ge-btn ge-btn-sm', 'Record');
recordButton.type = 'button';
const playButton = el('button', 'ge-btn ge-btn-sm', 'Play');
playButton.type = 'button';
const clearButton = el('button', 'ge-btn ge-btn-sm', 'Clear');
clearButton.type = 'button';
controls.append(recordButton, playButton, clearButton);
const batchRow = el('div', 'ge-actions-batch-row');
const batchSelect = document.createElement('select');
batchSelect.className = 'ge-actions-batch-select';
const batchButton = el('button', 'ge-btn ge-btn-sm', 'Batch');
batchButton.type = 'button';
batchButton.title = 'Apply the selected action to every layer';
const batchDocsButton = el('button', 'ge-btn ge-btn-sm', 'Batch docs');
batchDocsButton.type = 'button';
batchDocsButton.title = 'Apply the selected action to every open document';
batchRow.append(batchSelect, batchButton, batchDocsButton);
const list = el('div', 'ge-actions-list');
const savedList = el('div', 'ge-actions-saved');
body.append(nameRow, addRow, controls, batchRow, list, savedList);
function renderList() {
list.innerHTML = '';
if (!steps.length) {
list.appendChild(el('p', 'ge-panel-empty', 'No steps. Add commands or record.'));
return;
}
steps.forEach((step, index) => {
const command = commandById(step);
const row = el('div', 'ge-actions-step');
row.append(
el('span', 'ge-actions-step-index', String(index + 1)),
el('span', 'ge-actions-step-label', command ? command[1] : step),
);
const remove = el('button', 'ge-action-remove', '✕');
remove.type = 'button';
remove.addEventListener('click', () => { steps.splice(index, 1); renderList(); });
row.appendChild(remove);
list.appendChild(row);
});
}
function renderSaved() {
savedList.innerHTML = '';
syncBatchSelect();
const names = Object.keys(saved);
if (!names.length) return;
savedList.appendChild(el('div', 'ge-panel-title', 'SAVED'));
names.forEach(name => {
const row = el('div', 'ge-actions-saved-row');
const loadButton = el('button', 'ge-btn ge-btn-sm', name);
loadButton.type = 'button';
loadButton.addEventListener('click', () => { steps = saved[name].slice(); renderList(); });
const deleteButton = el('button', 'ge-action-remove', '✕');
deleteButton.type = 'button';
deleteButton.addEventListener('click', () => { delete saved[name]; persist(); renderSaved(); });
row.append(loadButton, deleteButton);
savedList.appendChild(row);
});
}
function persist() {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(saved)); } catch { /* ignore */ }
}
function syncBatchSelect() {
const names = Object.keys(saved);
const previous = batchSelect.value;
batchSelect.innerHTML = '';
if (!names.length) {
batchSelect.appendChild(new Option('No saved actions', ''));
batchButton.disabled = true;
batchDocsButton.disabled = true;
return;
}
batchButton.disabled = false;
batchDocsButton.disabled = false;
names.forEach(name => batchSelect.appendChild(new Option(name, name)));
if (names.includes(previous)) batchSelect.value = previous;
}
function loadSaved() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
const parsed = raw ? JSON.parse(raw) : null;
return parsed && typeof parsed === 'object' ? parsed : {};
} catch { return {}; }
}
function runStep(step) {
const command = commandById(step);
if (!command) return;
document.querySelector(command[2])?.click();
}
function play() {
steps.forEach((step, index) => setTimeout(() => runStep(step), index * 60));
showToast(`Playing ${steps.length} action${steps.length === 1 ? '' : 's'}`);
}
async function runOnAllLayers() {
const name = batchSelect.value;
const action = name && saved[name] ? saved[name].slice() : null;
if (!action || !action.length) { showToast('Select a saved action to batch'); return; }
if (!getLayers || !selectLayer) { showToast('Batch is unavailable'); return; }
const layers = getLayers() || [];
if (layers.length < 1) { showToast('No layers to batch'); return; }
showToast(`Batching "${name}" over ${layers.length} layer${layers.length === 1 ? '' : 's'}`);
for (const layer of layers) {
selectLayer(layer.id);
for (const step of action) {
runStep(step);
await new Promise(resolve => setTimeout(resolve, 80));
}
}
showToast('Batch complete');
}
function onDocumentClick(event) {
if (!recording) return;
const match = ACTION_COMMANDS.find(command => event.target.closest?.(command[2]));
if (match && !steps.includes(match[0])) {
steps.push(match[0]);
renderList();
}
}
function toggleRecord() {
recording = !recording;
recordButton.classList.toggle('active', recording);
recordButton.textContent = recording ? 'Stop' : 'Record';
showToast(recording ? 'Recording actions…' : 'Recording stopped');
}
addButton.addEventListener('click', () => { steps.push(addSelect.value); renderList(); });
recordButton.addEventListener('click', toggleRecord);
playButton.addEventListener('click', play);
batchButton.addEventListener('click', () => { runOnAllLayers(); });
batchDocsButton.addEventListener('click', async () => {
const name = batchSelect.value;
if (!name) { showToast('Select a saved action to batch'); return; }
try {
const response = await window.geEditorControl?.invoke?.('actions.batchDocuments', { name });
const results = (response && response.result) || [];
const ok = results.filter((entry) => entry.ok).length;
showToast(`Batch docs: ${ok}/${results.length} document${results.length === 1 ? '' : 's'}`);
} catch (error) {
showToast('Batch docs failed: ' + (error?.message || error));
}
});
clearButton.addEventListener('click', () => { steps = []; renderList(); });
saveButton.addEventListener('click', () => {
const name = (nameInput.value || '').trim();
if (!name) return;
saved[name] = steps.slice();
persist();
renderSaved();
showToast(`Saved action "${name}"`);
});
document.addEventListener('click', onDocumentClick, true);
const element = el('div', 'ge-panel');
element.id = 'ge-panel-actions';
element.appendChild(el('div', 'ge-panel-title', 'ACTIONS'));
element.appendChild(body);
renderList();
renderSaved();
return { element, play, run: runStep, runOnAllLayers, getSteps: () => steps.slice(), setSteps: (next) => { steps = next.slice(); renderList(); } };
}
+17 -10
View File
@@ -2,10 +2,12 @@
import { applyAdjustment } from './fx/pixel-pass.js'; import { applyAdjustment } from './fx/pixel-pass.js';
import { defaultAdjParams } from './layer-helpers.js'; import { defaultAdjParams } from './layer-helpers.js';
import { renderWithLayerMasks } from './composite-helpers.js'; import { renderWithLayerMasks } from './composite-helpers.js';
import { blendDraw } from './blend-modes.js';
const TYPES = new Set([ const TYPES = new Set([
'brightness-contrast', 'brightness-contrast',
'exposure', 'exposure',
'develop',
'white-balance', 'white-balance',
'hue-saturation', 'hue-saturation',
'vibrance', 'vibrance',
@@ -16,6 +18,12 @@ const TYPES = new Set([
'color-balance', 'color-balance',
'selective-color', 'selective-color',
'gradient-map', 'gradient-map',
'invert',
'posterize',
'threshold',
'photo-filter',
'channel-mixer',
'color-lookup',
]); ]);
function clone(value) { function clone(value) {
@@ -52,6 +60,11 @@ export function normalizeAdjustmentData(value) {
params.ranges[name] = { ...clone(defaults.ranges[name]), ...(params.ranges[name] || {}) }; params.ranges[name] = { ...clone(defaults.ranges[name]), ...(params.ranges[name] || {}) };
} }
} }
if (type === 'channel-mixer') {
for (const name of ['red', 'green', 'blue', 'constant']) {
params[name] = { ...clone(defaults[name]), ...(params[name] || {}) };
}
}
return { type, params }; return { type, params };
} }
@@ -109,11 +122,8 @@ export function drawAdjustmentLayer(target, editorState, layer, clippingBase, re
adjusted = clipped; adjusted = clipped;
} }
target.globalAlpha = Number.isFinite(layer.opacity) ? layer.opacity : 1; const opacity = Number.isFinite(layer.opacity) ? layer.opacity : 1;
target.globalCompositeOperation = layer.blendMode || 'source-over'; blendDraw(target, adjusted, layer.blendMode, opacity, 0, 0);
target.drawImage(adjusted, 0, 0);
target.globalAlpha = 1;
target.globalCompositeOperation = 'source-over';
return true; return true;
} }
@@ -154,11 +164,8 @@ export async function drawAdjustmentLayerAsync(target, editorState, layer, clipp
clippedCtx.globalCompositeOperation = 'source-over'; clippedCtx.globalCompositeOperation = 'source-over';
adjusted = clipped; adjusted = clipped;
} }
target.globalAlpha = Number.isFinite(layer.opacity) ? layer.opacity : 1; const opacity = Number.isFinite(layer.opacity) ? layer.opacity : 1;
target.globalCompositeOperation = layer.blendMode || 'source-over'; blendDraw(target, adjusted, layer.blendMode, opacity, 0, 0);
target.drawImage(adjusted, 0, 0);
target.globalAlpha = 1;
target.globalCompositeOperation = 'source-over';
return true; return true;
} }
+243 -68
View File
@@ -27,6 +27,12 @@
import { state } from './state.js'; import { state } from './state.js';
import { sortModelIds } from '../modelSort.js'; import { sortModelIds } from '../modelSort.js';
// Last result of loadAIModels() — consumed by the editor setup checklist
// (static/js/editor/preflight.js) to decide whether an image provider is
// reachable and which image models are available.
let _aiModelsStatus = { ready: false, models: [], error: null, at: 0 };
export function getAIModelsStatus() { return _aiModelsStatus; }
export function resolveInpaintModel(select) { export function resolveInpaintModel(select) {
const options = Array.from(select?.options || []).filter(option => const options = Array.from(select?.options || []).filter(option =>
!option.disabled && option.value && option.value !== '__serve_cookbook__'); !option.disabled && option.value && option.value !== '__serve_cookbook__');
@@ -34,6 +40,70 @@ export function resolveInpaintModel(select) {
return explicit || options.find(option => /inpaint|edit|fill/i.test(option.value)) || options[0] || null; return explicit || options.find(option => /inpaint|edit|fill/i.test(option.value)) || options[0] || null;
} }
// Resolve the model the Quick Edit box will use. Prefers the explicit Quick
// Edit picker (#ge-ai-command-model); falls back to the inpaint picker / Auto.
export function resolveQuickEditModel() {
const sel = document.getElementById('ge-ai-command-model');
if (sel && sel.value && sel.value !== '__serve_cookbook__') {
const opt = sel.options[sel.selectedIndex];
const raw = sel.value || '';
const idx = raw.indexOf('::');
return {
endpoint: idx < 0 ? raw : raw.slice(0, idx),
model: idx < 0 ? '' : raw.slice(idx + 2),
label: ((opt && opt.textContent) || '').trim(),
};
}
const inp = document.getElementById('ge-ai-inpaint');
const opt = resolveInpaintModel(inp);
if (!opt) return { endpoint: '', model: '', label: '' };
const raw = opt.value || '';
const idx = raw.indexOf('::');
return {
endpoint: idx < 0 ? raw : raw.slice(0, idx),
model: idx < 0 ? '' : raw.slice(idx + 2),
label: ((opt.textContent || '').trim().replace(/^Auto\s*\((.*)\)$/, '$1')),
};
}
// Resolve the Quick Edit AGENT (planner) model — the vision-language model
// that reads the canvas screenshot and plans the edit. Default is the local
// Qwen3.5-9B VL server on seneca; "Server default" keeps the HARNESS planner.
export function resolveQuickEditAgentModel() {
const sel = document.getElementById('ge-ai-command-agent-model');
const raw = (sel && sel.value) || '';
if (!raw || raw === 'default') {
return { agent: '', endpoint: '', label: 'Server default' };
}
const idx = raw.indexOf('::');
const endpoint = idx < 0 ? '' : raw.slice(0, idx);
const agent = idx < 0 ? raw : raw.slice(idx + 2);
const opt = sel.options[sel.selectedIndex];
return { agent, endpoint, label: ((opt && opt.textContent) || '').trim() };
}
// Quick Edit agent (planner) model options. Kept small + explicit;
// dynamically populated from /api/editor/agent-models.
export const QUICK_EDIT_AGENT_OPTIONS = [
{ value: 'default', label: 'Server default (hosted planner)' },
];
// can see which model Quick Edit / inpaint will call.
export function updateFooterEditModel() {
const el = document.getElementById('ge-status-model');
if (!el) return;
const info = resolveQuickEditModel();
if (!info.label && !info.model) {
el.textContent = 'Edit: none';
el.title = 'No AI edit model available';
return;
}
const label = info.label || info.model;
el.textContent = `Edit: ${label}`;
el.title = `AI edit model: ${label}`;
}
// Heuristic classifier on a model id + endpoint name. A model can be: // Heuristic classifier on a model id + endpoint name. A model can be:
// - gen: text-to-image generation // - gen: text-to-image generation
// - inpaint: image+mask edit (inpaint / img2img) // - inpaint: image+mask edit (inpaint / img2img)
@@ -70,12 +140,41 @@ export function modelCaps(modelId, endpointName, endpointType) {
return { gen: false, inpaint: false }; return { gen: false, inpaint: false };
} }
export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2img }) { // Global listeners resolve live controls instead of retaining editor instances.
function syncCommandModel(value) {
const select = document.getElementById('ge-ai-command-model');
if (!select || !select._modelsReady) return;
if (value && ![...select.options].some(o => !o.disabled && o.value === value && value !== '__serve_cookbook__')) {
if (localStorage.getItem('ge-ai-command-model') === value) localStorage.removeItem('ge-ai-command-model');
value = '';
}
if (select.value !== value) {
select.value = value;
if (select.value !== value) select.value = '';
updateFooterEditModel();
}
}
window.addEventListener('ge-ai-command-model-change', e => syncCommandModel(e.detail?.value || ''));
window.addEventListener('storage', e => {
if (e.key === 'ge-ai-command-model') syncCommandModel(e.newValue || '');
});
window.addEventListener('ge:model-endpoints-updated', e => {
document.getElementById('ge-ai-command-model')?._refreshAIModels?.(e.detail?.baseUrl || '');
});
export function wireAIModelSelectors({ container, apiBase = '', openCookbookForImg2img = () => {} } = {}) {
const root = container || document;
root._aiModelListeners?.abort();
const listeners = new AbortController();
root._aiModelListeners = listeners;
const listen = (target, type, handler, options = {}) => target?.addEventListener(type, handler, {
...(typeof options === 'boolean' ? { capture: options } : options), signal: listeners.signal,
});
// Delegated handler for the "+ Serve a model in Cookbook…" sentinel // Delegated handler for the "+ Serve a model in Cookbook…" sentinel
// option — catches clicks regardless of whether loadAIModels has // option — catches clicks regardless of whether loadAIModels has
// rewired the individual select yet and survives any innerHTML // rewired the individual select yet and survives any innerHTML
// reset later. // reset later.
container.addEventListener('change', (e) => { listen(root, 'change', (e) => {
const sel = e.target.closest('select'); const sel = e.target.closest('select');
if (!sel) return; if (!sel) return;
if (sel.value !== '__serve_cookbook__') return; if (sel.value !== '__serve_cookbook__') return;
@@ -85,72 +184,120 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
openCookbookForImg2img(); openCookbookForImg2img();
}); });
// Track prior value so we can restore it after the sentinel fires. // Track prior value so we can restore it after the sentinel fires.
container.addEventListener('focus', (e) => { listen(root, 'focus', (e) => {
const sel = e.target.closest('select'); const sel = e.target.closest('select');
if (sel && sel.value !== '__serve_cookbook__') sel._prevServeValue = sel.value; if (sel && sel.value !== '__serve_cookbook__') sel._prevServeValue = sel.value;
}, true); }, true);
const aiGenSelect = document.getElementById('ge-ai-model'); const aiGenSelect = document.getElementById('ge-ai-model');
const aiInpaintSelect = document.getElementById('ge-ai-inpaint'); const aiInpaintSelect = document.getElementById('ge-ai-inpaint');
const aiCommandSelect = document.getElementById('ge-ai-command-model');
const aiCommandAgentSelect = document.getElementById('ge-ai-command-agent-model');
if (aiInpaintSelect) listen(aiInpaintSelect, 'change', () => updateFooterEditModel());
// The Quick Edit agent (planner) picker: dynamically populates from
// /api/editor/agent-models; restore/save choice from localStorage.
const populateAgentSelect = async () => {
if (!aiCommandAgentSelect) return;
const prevVal = aiCommandAgentSelect.value;
let agentOptions = [...QUICK_EDIT_AGENT_OPTIONS];
try {
const res = await fetch(`${apiBase}/api/editor/agent-models`);
if (res.ok) {
const data = await res.json();
if (listeners.signal.aborted || !root.isConnected) return;
const serverModels = data.models || [];
for (const m of serverModels) {
const optVal = `${m.source === 'local' ? 'local' : (m.endpoint_id ? String(m.endpoint_id) : '')}::${m.id}`;
const label = m.label || m.id;
if (!agentOptions.some(o => o.value === optVal)) {
agentOptions.push({ value: optVal, label });
}
}
}
} catch (_) {}
aiCommandAgentSelect.innerHTML = '';
for (const opt of agentOptions) {
const el = document.createElement('option');
el.value = opt.value;
el.textContent = opt.label;
aiCommandAgentSelect.appendChild(el);
}
try {
const saved = localStorage.getItem('ge-ai-command-agent-model');
if (saved && [...aiCommandAgentSelect.options].some(o => o.value === saved)) {
aiCommandAgentSelect.value = saved;
} else if (prevVal && [...aiCommandAgentSelect.options].some(o => o.value === prevVal)) {
aiCommandAgentSelect.value = prevVal;
}
} catch {}
if (aiCommandAgentSelect._agentWired !== listeners) {
aiCommandAgentSelect._agentWired = listeners;
listen(aiCommandAgentSelect, 'change', () => {
try { localStorage.setItem('ge-ai-command-agent-model', aiCommandAgentSelect.value); } catch {}
});
}
};
// The global Gen model dropdown was removed from the editor topbar; // The global Gen model dropdown was removed from the editor topbar;
// only bail if there's nothing to populate at all (neither the Gen // only bail if there's nothing to populate at all (neither the Gen
// select nor the inpaint select nor any per-tool select). // select nor the inpaint select nor any per-tool select).
if (!aiGenSelect && !aiInpaintSelect && if (!aiGenSelect && !aiInpaintSelect && !aiCommandSelect && !aiCommandAgentSelect &&
!document.querySelector('select.ge-tool-model')) return; !document.querySelector('select.ge-tool-model')) return;
void populateAgentSelect();
async function loadAIModels(opts = {}) { async function loadAIModels(opts = {}) {
try { try {
const selectBaseUrl = opts.selectBaseUrl || ''; const selectBaseUrl = opts.selectBaseUrl || '';
const prevGenValue = aiGenSelect?.value || ''; const prevGenValue = aiGenSelect?.value || '';
const prevInpaintValue = aiInpaintSelect?.value || ''; const prevInpaintValue = aiInpaintSelect?.value || '';
const res = await fetch(`${apiBase}/api/model-endpoints`); const res = await fetch(`${apiBase}/api/editor/image-models`);
if (listeners.signal.aborted || !root.isConnected) return;
if (!res.ok) throw new Error(`Could not load image models (${res.status})`); if (!res.ok) throw new Error(`Could not load image models (${res.status})`);
const endpoints = await res.json(); const data = await res.json();
if (listeners.signal.aborted || !root.isConnected) return;
const modelsList = data.models || [];
if (aiGenSelect) aiGenSelect.innerHTML = '<option value="">None</option>'; if (aiGenSelect) aiGenSelect.innerHTML = '<option value="">None</option>';
if (aiInpaintSelect) aiInpaintSelect.innerHTML = '<option value="">Auto</option>'; if (aiInpaintSelect) aiInpaintSelect.innerHTML = '<option value="">Auto</option>';
if (aiCommandSelect) aiCommandSelect.innerHTML = '<option value="">Auto (default)</option>';
const perToolSelects = Array.from(document.querySelectorAll('select.ge-tool-model')); const perToolSelects = Array.from(document.querySelectorAll('select.ge-tool-model'));
for (const ts of perToolSelects) ts.innerHTML = '<option value="">Auto</option>'; for (const ts of perToolSelects) ts.innerHTML = '<option value="">Auto</option>';
let firstGen = null; let firstGen = null;
let selectedGen = null; let selectedGen = null;
let selectedInpaint = null; let selectedInpaint = null;
for (const ep of endpoints) { const loaded = [];
if (!ep.is_enabled) continue; for (const m of modelsList) {
const hasListedModels = Array.isArray(ep.models) && ep.models.length; const modelId = m.id || '';
const models = hasListedModels ? sortModelIds(ep.models) : ['']; const caps = modelCaps(modelId, m.provider, m.kind);
const isImageEndpoint = (ep.model_type || '').toLowerCase() === 'image'; const canGen = Boolean(caps.gen || m.kind === 'generate');
// Image/inpaint endpoints can be called by URL even when their const canInpaint = Boolean(caps.inpaint || m.kind === 'edit');
// /models cache is still empty, so don't strand a freshly served if (!canGen && !canInpaint) continue;
// Cookbook model as "(offline)" in the editor picker.
const epUsable = !!ep.online || isImageEndpoint; // Use opaque endpoint_id if available; never fall back to raw base_url
for (const modelId of models) { const endpointKey = m.endpoint_id ? String(m.endpoint_id) : (m.source === 'local' ? 'local' : '');
const caps = modelCaps(modelId || ep.name, ep.name, ep.model_type); const value = endpointKey ? `${endpointKey}::${modelId}` : modelId;
if (!caps.gen && !caps.inpaint) continue; const shortModel = modelId ? String(modelId).split('/').pop() : (m.label || m.provider);
// Encode "<base_url>::<model_id>" so the value carries both pieces. const epHint = m.provider && m.provider !== modelId && m.provider !== 'image' ? ` · ${m.provider}` : '';
const value = `${ep.base_url}::${modelId}`; const label = `${shortModel}${epHint}`;
const shortModel = modelId ? String(modelId).split('/').pop() : (ep.name || ep.base_url); const epUsable = true;
const epHint = modelId && ep.name && ep.name !== modelId ? ` · ${ep.name}` : ''; loaded.push({ value, label, gen: canGen, inpaint: canInpaint, usable: epUsable, endpoint: endpointKey, model: modelId });
const label = `${shortModel}${epHint}${epUsable ? '' : ' (offline)'}`; if (canGen && aiGenSelect) {
if (caps.gen && aiGenSelect) {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = value; opt.value = value;
opt.textContent = label; opt.textContent = label;
opt.disabled = !epUsable; opt.disabled = !epUsable;
aiGenSelect.appendChild(opt); aiGenSelect.appendChild(opt);
if (epUsable && !firstGen) firstGen = value; if (epUsable && !firstGen) firstGen = value;
if (epUsable && selectBaseUrl && ep.base_url === selectBaseUrl && !selectedGen) selectedGen = value; if (epUsable && selectBaseUrl && (endpointKey === selectBaseUrl || m.base_url === selectBaseUrl) && !selectedGen) selectedGen = value;
} }
if (caps.inpaint && aiInpaintSelect) { if (canInpaint && aiInpaintSelect) {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = value; opt.value = value;
opt.textContent = label; opt.textContent = label;
opt.disabled = !epUsable; opt.disabled = !epUsable;
aiInpaintSelect.appendChild(opt); aiInpaintSelect.appendChild(opt);
if (epUsable && selectBaseUrl && ep.base_url === selectBaseUrl && !selectedInpaint) selectedInpaint = value; if (epUsable && selectBaseUrl && (endpointKey === selectBaseUrl || m.base_url === selectBaseUrl) && !selectedInpaint) selectedInpaint = value;
} }
// Per-tool selectors get every img2img-capable entry. Both if (canInpaint || canGen) {
// caps.inpaint AND caps.gen models work for harmonize /
// style / upscale (anything that can do img2img).
if (caps.inpaint || caps.gen) {
for (const ts of perToolSelects) { for (const ts of perToolSelects) {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = value; opt.value = value;
@@ -158,6 +305,12 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
opt.disabled = !epUsable; opt.disabled = !epUsable;
ts.appendChild(opt); ts.appendChild(opt);
} }
if (aiCommandSelect) {
const opt = document.createElement('option');
opt.value = value;
opt.textContent = label;
opt.disabled = !epUsable;
aiCommandSelect.appendChild(opt);
} }
} }
} }
@@ -173,6 +326,7 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
const auto = resolveInpaintModel({ options: aiInpaintSelect.options, value: '' }); const auto = resolveInpaintModel({ options: aiInpaintSelect.options, value: '' });
aiInpaintSelect.options[0].textContent = auto ? `Auto (${auto.textContent})` : 'Auto (no available model)'; aiInpaintSelect.options[0].textContent = auto ? `Auto (${auto.textContent})` : 'Auto (no available model)';
} }
updateFooterEditModel();
// Append the "Serve a model in Cookbook…" sentinel at the // Append the "Serve a model in Cookbook…" sentinel at the
// bottom of every model dropdown. // bottom of every model dropdown.
const appendServeSentinel = (sel) => { const appendServeSentinel = (sel) => {
@@ -188,21 +342,52 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
for (const ts of perToolSelects) appendServeSentinel(ts); for (const ts of perToolSelects) appendServeSentinel(ts);
if (aiGenSelect) appendServeSentinel(aiGenSelect); if (aiGenSelect) appendServeSentinel(aiGenSelect);
if (aiInpaintSelect) appendServeSentinel(aiInpaintSelect); if (aiInpaintSelect) appendServeSentinel(aiInpaintSelect);
// Wire the sentinel on the Gen + Inpaint selects too. if (aiCommandSelect) appendServeSentinel(aiCommandSelect);
const wireServeSentinel = (sel) => { // Sentinel changes are handled by the component's delegated listener.
if (!sel) return; if (aiCommandSelect) {
let prev = sel.value; aiCommandSelect._modelsReady = true;
sel.addEventListener('change', () => { try {
if (sel.value === '__serve_cookbook__') { const rawSaved = localStorage.getItem('ge-ai-command-model') || '';
sel.value = prev; let saved = rawSaved;
openCookbookForImg2img(); if (saved.startsWith('http://') || saved.startsWith('https://') || saved.includes('://') || saved.startsWith('::')) {
return; const modelPart = saved.includes('::') ? saved.split('::').pop() : '';
const matching = loaded.filter(l => l.model === modelPart);
if (matching.length === 1) {
saved = matching[0].value;
} else {
saved = '';
} }
prev = sel.value; try {
if (saved) localStorage.setItem('ge-ai-command-model', saved);
else localStorage.removeItem('ge-ai-command-model');
} catch {}
}
if (saved && [...aiCommandSelect.options].some(o => o.value === saved)) {
aiCommandSelect.value = saved;
} else {
aiCommandSelect.value = '';
}
if (aiCommandSelect.value !== rawSaved) {
if (aiCommandSelect.value) localStorage.setItem('ge-ai-command-model', aiCommandSelect.value);
else localStorage.removeItem('ge-ai-command-model');
window.dispatchEvent(new CustomEvent('ge-ai-command-model-change', { detail: { value: aiCommandSelect.value } }));
}
} catch {}
if (aiCommandSelect._commandWired !== listeners) {
aiCommandSelect._commandWired = listeners;
listen(aiCommandSelect, 'change', () => {
if (aiCommandSelect.value === '__serve_cookbook__') return;
const val = aiCommandSelect.value || '';
try {
if (val) localStorage.setItem('ge-ai-command-model', aiCommandSelect.value);
else localStorage.removeItem('ge-ai-command-model');
} catch {}
updateFooterEditModel();
window.dispatchEvent(new CustomEvent('ge-ai-command-model-change', { detail: { value: val } }));
}); });
}; }
wireServeSentinel(aiGenSelect); }
wireServeSentinel(aiInpaintSelect);
// Restore each per-tool selection from localStorage. // Restore each per-tool selection from localStorage.
for (const ts of perToolSelects) { for (const ts of perToolSelects) {
const key = 'ge-tool-model-' + ts.dataset.geToolModel; const key = 'ge-tool-model-' + ts.dataset.geToolModel;
@@ -213,7 +398,7 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
} }
} catch {} } catch {}
let prevValue = ts.value; let prevValue = ts.value;
ts.addEventListener('change', () => { listen(ts, 'change', () => {
if (ts.value === '__serve_cookbook__') { if (ts.value === '__serve_cookbook__') {
ts.value = prevValue; ts.value = prevValue;
openCookbookForImg2img(); openCookbookForImg2img();
@@ -223,40 +408,30 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
try { localStorage.setItem(key, ts.value); } catch {} try { localStorage.setItem(key, ts.value); } catch {}
}); });
} }
_aiModelsStatus = { ready: true, models: loaded, error: null, at: Date.now() };
try { window.dispatchEvent(new CustomEvent('ge:ai-models-status')); } catch {}
} catch (e) { } catch (e) {
if (listeners.signal.aborted || !root.isConnected) return;
// Fetch failed — still give the user the affordance to set up // Fetch failed — still give the user the affordance to set up
// a model. Otherwise the dropdown shows only "Auto" with no // a model. Otherwise the dropdown shows only "Auto" with no
// hint about what to do next. // hint about what to do next.
const fallback = '<option value="">Auto</option><option disabled>Could not load image models</option><option value="__serve_cookbook__">+ Serve a model in Cookbook…</option>'; const fallback = '<option value="">Auto</option><option disabled>Could not load image models</option><option value="__serve_cookbook__">+ Serve a model in Cookbook…</option>';
if (aiGenSelect) aiGenSelect.innerHTML = fallback; if (aiGenSelect) aiGenSelect.innerHTML = fallback;
if (aiInpaintSelect) aiInpaintSelect.innerHTML = fallback; if (aiInpaintSelect) aiInpaintSelect.innerHTML = fallback;
if (aiCommandSelect) aiCommandSelect.innerHTML = fallback;
document.querySelectorAll('select.ge-tool-model').forEach(ts => { ts.innerHTML = fallback; }); document.querySelectorAll('select.ge-tool-model').forEach(ts => { ts.innerHTML = fallback; });
const wireServe = (sel) => { void populateAgentSelect();
if (!sel) return; updateFooterEditModel();
let prev = sel.value; _aiModelsStatus = { ready: false, models: [], error: String(e?.message || e), at: Date.now() };
sel.addEventListener('change', () => { try { window.dispatchEvent(new CustomEvent('ge:ai-models-status')); } catch {}
if (sel.value === '__serve_cookbook__') {
sel.value = prev;
openCookbookForImg2img();
return;
}
prev = sel.value;
});
};
wireServe(aiGenSelect);
wireServe(aiInpaintSelect);
document.querySelectorAll('select.ge-tool-model').forEach(wireServe);
} }
} }
loadAIModels(); loadAIModels();
const onModelEndpointsUpdated = (e) => { if (aiCommandSelect) aiCommandSelect._refreshAIModels = baseUrl => {
if (!container.isConnected) { if (listeners.signal.aborted || !root.isConnected) return;
window.removeEventListener('ge:model-endpoints-updated', onModelEndpointsUpdated); void loadAIModels({ selectBaseUrl: baseUrl });
return; void populateAgentSelect();
}
loadAIModels({ selectBaseUrl: e.detail?.baseUrl || '' });
}; };
window.addEventListener('ge:model-endpoints-updated', onModelEndpointsUpdated);
// Re-fetch the model list when the user opens the inpaint dropdown, // Re-fetch the model list when the user opens the inpaint dropdown,
// so a model served via Cookbook mid-edit shows up without having to // so a model served via Cookbook mid-edit shows up without having to
// close and reopen the editor. Debounced to one refresh per 3s so // close and reopen the editor. Debounced to one refresh per 3s so
@@ -275,5 +450,5 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
if ([...sel.options].some(o => o.value === keep)) sel.value = keep; if ([...sel.options].some(o => o.value === keep)) sel.value = keep;
}); });
}; };
container.addEventListener('mousedown', refreshOnOpen, true); listen(root, 'mousedown', refreshOnOpen, true);
} }
+85
View File
@@ -0,0 +1,85 @@
/**
* AI settings dialog.
*
* Holds the image-edit model, the AI agent (vision) model, and the opt-in
* "Double-check AI edits" toggle. Opened from the Quick Edit gear button and
* from the editor setup checklist; keeps the Quick Edit box itself uncluttered.
*/
export function createAiSettings({ container }) {
const el = document.createElement('div');
el.className = 'ge-ai-settings';
el.hidden = true;
el.setAttribute('role', 'dialog');
el.setAttribute('aria-modal', 'true');
el.setAttribute('aria-label', 'AI settings');
el.innerHTML = `
<div class="ge-ai-settings-card">
<div class="ge-ai-settings-head">
<h3>AI settings</h3>
<button type="button" class="ge-ai-settings-close" data-ai-settings-close aria-label="Close">×</button>
</div>
<div class="ge-ai-settings-body">
<label class="ge-ai-settings-label" for="ge-ai-command-model">Image edit model</label>
<select class="ge-ai-command-model" id="ge-ai-command-model" title="Image edit model" aria-label="Image edit model"></select>
<label class="ge-ai-settings-label" for="ge-ai-command-agent-model">AI agent (vision) model</label>
<select class="ge-ai-command-model" id="ge-ai-command-agent-model" title="Quick Edit agent model" aria-label="Quick Edit agent model"></select>
<p class="ge-ai-settings-note">Recommended, not required — the vision agent plans edits and can double-check results.</p>
<label class="ge-ai-settings-label" for="ge-look-strength">Look strength</label>
<div class="ge-ai-settings-strength">
<input type="range" id="ge-look-strength" min="0.5" max="2" step="0.1" value="1.4" aria-label="Look grade strength" />
<span class="ge-ai-settings-strength-val" id="ge-look-strength-val">1.4×</span>
</div>
<p class="ge-ai-settings-note">How strongly named looks (Kodachrome, teal and orange, …) are graded.</p>
<label class="ge-ai-settings-opt" title="Ask the vision model to review the result and retry on a clear failure">
<input type="checkbox" id="ge-ai-command-selfcheck" /> Double-check AI edits
</label>
</div>
<div class="ge-ai-settings-foot">
<button type="button" class="ge-ai-settings-done" data-ai-settings-close>Done</button>
</div>
</div>`;
function open(focusId) {
el.hidden = false;
if (focusId) {
const target = document.getElementById(focusId);
if (target) {
try { target.focus({ preventScroll: true }); } catch (_) { target.focus(); }
}
}
}
function close() { el.hidden = true; }
el.addEventListener('click', (e) => {
if (e.target === el || e.target.closest('[data-ai-settings-close]')) {
e.preventDefault();
close();
}
});
const onKey = (e) => { if (e.key === 'Escape' && !el.hidden) close(); };
document.addEventListener('keydown', onKey);
container.appendChild(el);
const strengthInput = el.querySelector('#ge-look-strength');
const strengthVal = el.querySelector('#ge-look-strength-val');
if (strengthInput) {
try {
const saved = parseFloat(localStorage.getItem('ge-look-strength'));
if (Number.isFinite(saved) && saved > 0) strengthInput.value = String(saved);
} catch (_) {}
if (strengthVal) strengthVal.textContent = Number(strengthInput.value).toFixed(1) + '×';
strengthInput.addEventListener('input', () => {
const v = Number(strengthInput.value);
if (strengthVal) strengthVal.textContent = v.toFixed(1) + '×';
try { localStorage.setItem('ge-look-strength', String(v)); } catch (_) {}
});
}
function destroy() {
document.removeEventListener('keydown', onKey);
el.remove();
}
return { element: el, open, close, destroy };
}
+32 -11
View File
@@ -215,21 +215,19 @@ export function wireAIToolsMisc({
function addEmptyLayer() { function addEmptyLayer() {
saveState('Add layer'); saveState('Add layer');
const layer = createLayer('Layer ' + state.layers.length, state.imgWidth, state.imgHeight); const layer = createLayer('Layer ' + state.layers.length, state.imgWidth, state.imgHeight);
state.layers.push(layer); // Photoshop inserts the new layer directly above the selected one.
const activeIndex = state.layers.findIndex(item => item.id === state.activeLayerId);
const insertIndex = activeIndex >= 0 ? activeIndex + 1 : state.layers.length;
state.layers.splice(insertIndex, 0, layer);
state.activeLayerId = layer.id; state.activeLayerId = layer.id;
renderLayerPanel(); renderLayerPanel();
composite(); composite();
} }
const addButton = document.getElementById('ge-add-layer'); const buildFxMenu = (anchor) => {
addButton?.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
document.querySelector('.ge-add-layer-menu')?.remove(); document.querySelector('.ge-add-layer-menu')?.remove();
const menu = document.createElement('div'); const menu = document.createElement('div');
menu.className = 'ge-add-layer-menu ge-frosted'; menu.className = 'ge-add-layer-menu ge-frosted';
menu.innerHTML = ` menu.innerHTML = `
<button type="button" data-layer-kind="raster"><span class="ge-add-layer-symbol">+</span><span>Pixel Layer</span></button>
<span class="ge-add-layer-divider"></span>
<button type="button" data-adjustment-type="levels"><span class="ge-add-layer-symbol">▥</span><span>Levels</span></button> <button type="button" data-adjustment-type="levels"><span class="ge-add-layer-symbol">▥</span><span>Levels</span></button>
<button type="button" data-adjustment-type="curves"><span class="ge-add-layer-symbol">⌁</span><span>Curves</span></button> <button type="button" data-adjustment-type="curves"><span class="ge-add-layer-symbol">⌁</span><span>Curves</span></button>
<button type="button" data-adjustment-type="exposure"><span class="ge-add-layer-symbol">☼</span><span>Exposure</span></button> <button type="button" data-adjustment-type="exposure"><span class="ge-add-layer-symbol">☼</span><span>Exposure</span></button>
@@ -242,9 +240,16 @@ export function wireAIToolsMisc({
<button type="button" data-adjustment-type="color-balance"><span class="ge-add-layer-symbol">◒</span><span>Color Balance</span></button> <button type="button" data-adjustment-type="color-balance"><span class="ge-add-layer-symbol">◒</span><span>Color Balance</span></button>
<button type="button" data-adjustment-type="selective-color"><span class="ge-add-layer-symbol">◎</span><span>Selective Color</span></button> <button type="button" data-adjustment-type="selective-color"><span class="ge-add-layer-symbol">◎</span><span>Selective Color</span></button>
<button type="button" data-adjustment-type="gradient-map"><span class="ge-add-layer-symbol">▰</span><span>Gradient Map</span></button> <button type="button" data-adjustment-type="gradient-map"><span class="ge-add-layer-symbol">▰</span><span>Gradient Map</span></button>
<span class="ge-add-layer-divider"></span>
<button type="button" data-adjustment-type="invert"><span class="ge-add-layer-symbol">◑</span><span>Invert</span></button>
<button type="button" data-adjustment-type="posterize"><span class="ge-add-layer-symbol">▤</span><span>Posterize</span></button>
<button type="button" data-adjustment-type="threshold"><span class="ge-add-layer-symbol">◧</span><span>Threshold</span></button>
<button type="button" data-adjustment-type="photo-filter"><span class="ge-add-layer-symbol">◍</span><span>Photo Filter</span></button>
<button type="button" data-adjustment-type="channel-mixer"><span class="ge-add-layer-symbol">⇄</span><span>Channel Mixer</span></button>
<button type="button" data-adjustment-type="color-lookup"><span class="ge-add-layer-symbol">▦</span><span>Color Lookup</span></button>
`; `;
document.body.appendChild(menu); document.body.appendChild(menu);
const rect = addButton.getBoundingClientRect(); const rect = anchor.getBoundingClientRect();
const menuRect = menu.getBoundingClientRect(); const menuRect = menu.getBoundingClientRect();
menu.style.left = `${Math.max(8, Math.min(window.innerWidth - menuRect.width - 8, rect.right - menuRect.width))}px`; menu.style.left = `${Math.max(8, Math.min(window.innerWidth - menuRect.width - 8, rect.right - menuRect.width))}px`;
menu.style.top = `${Math.max(8, Math.min(window.innerHeight - menuRect.height - 8, rect.bottom + 5))}px`; menu.style.top = `${Math.max(8, Math.min(window.innerHeight - menuRect.height - 8, rect.bottom + 5))}px`;
@@ -253,7 +258,7 @@ export function wireAIToolsMisc({
document.removeEventListener('pointerdown', away, true); document.removeEventListener('pointerdown', away, true);
}; };
const away = pointerEvent => { const away = pointerEvent => {
if (!menu.contains(pointerEvent.target) && pointerEvent.target !== addButton) close(); if (!menu.contains(pointerEvent.target) && pointerEvent.target !== anchor) close();
}; };
requestAnimationFrame(() => document.addEventListener('pointerdown', away, true)); requestAnimationFrame(() => document.addEventListener('pointerdown', away, true));
menu.addEventListener('click', clickEvent => { menu.addEventListener('click', clickEvent => {
@@ -261,10 +266,26 @@ export function wireAIToolsMisc({
if (!button) return; if (!button) return;
clickEvent.preventDefault(); clickEvent.preventDefault();
clickEvent.stopPropagation(); clickEvent.stopPropagation();
if (button.dataset.layerKind === 'raster') addEmptyLayer(); if (button.dataset.adjustmentType) openAdjustmentLayer?.(button.dataset.adjustmentType, anchor);
else if (button.dataset.adjustmentType) openAdjustmentLayer?.(button.dataset.adjustmentType, addButton);
close(); close();
}); });
};
// "+" adds a plain pixel layer directly.
const addButton = document.getElementById('ge-add-layer');
addButton?.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
document.querySelector('.ge-add-layer-menu')?.remove();
addEmptyLayer();
});
// "fx" opens the adjustment-layer chooser.
const addFxButton = document.getElementById('ge-add-fx');
addFxButton?.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
buildFxMenu(addFxButton);
}); });
return { addEmptyLayer }; return { addEmptyLayer };
+35
View File
@@ -0,0 +1,35 @@
// Pure aspect-fitting geometry for the editor. When a whole-image resize is
// asked for a target aspect that differs from the current image, content must
// be cropped to that aspect (centred on a focus point) and then scaled
// UNIFORMLY — never stretched.
//
// aspectFitCropRect returns the document-space rectangle to crop FIRST so a
// subsequent uniform scale-to-{targetW,targetH} preserves the subject aspect
// ratio. Returns the full image unchanged when the aspect already matches.
export function aspectFitCropRect({ width, height, focusX, focusY, targetAspect, tolerance = 0.005 } = {}) {
const w = Number(width);
const h = Number(height);
const a = Number(targetAspect);
if (!(w > 0) || !(h > 0) || !(a > 0)) return null;
const curAspect = w / h;
if (Math.abs(curAspect - a) / a <= tolerance) return { x: 0, y: 0, width: Math.round(w), height: Math.round(h) };
const fx = Number.isFinite(Number(focusX)) ? Number(focusX) : w / 2;
const fy = Number.isFinite(Number(focusY)) ? Number(focusY) : h / 2;
let cw;
let ch;
if (curAspect > a) {
ch = h;
cw = Math.max(1, Math.round(ch * a));
} else {
cw = w;
ch = Math.max(1, Math.round(cw / a));
}
let x = Math.round(fx - cw / 2);
let y = Math.round(fy - ch / 2);
x = Math.max(0, Math.min(w - cw, x));
y = Math.max(0, Math.min(h - ch, y));
return { x, y, width: cw, height: ch };
}
export default { aspectFitCropRect };
+35
View File
@@ -0,0 +1,35 @@
/**
* Batch runner — a small, dependency-free orchestrator that applies one action
* to every item in a list (open documents), isolating each item so a failure on
* one never aborts the batch. Pure async control flow: the caller supplies
* `runOne`, so the same loop is unit-testable without an editor.
*/
/**
* @param {object} options
* @param {Array<{id:string,name?:string}>} options.documents
* @param {(doc:object, index:number)=>Promise<any>} options.runOne
* @param {number} [options.delayMs]
* @param {(done:number,total:number,doc:object)=>void} [options.onProgress]
* @returns {Promise<Array<{id,name,ok,output?,error?}>>}
*/
export async function runBatchOverDocuments({ documents, runOne, delayMs = 0, onProgress = null } = {}) {
const list = Array.isArray(documents) ? documents.filter(Boolean) : [];
const results = [];
for (let index = 0; index < list.length; index += 1) {
const doc = list[index];
try {
const output = await runOne(doc, index);
results.push({ id: doc.id, name: doc.name, ok: true, output: output === undefined ? null : output });
} catch (error) {
results.push({ id: doc.id, name: doc.name, ok: false, error: String((error && error.message) || error) });
}
if (typeof onProgress === 'function') {
try { onProgress(index + 1, list.length, doc); } catch { /* progress is best-effort */ }
}
if (delayMs > 0) await new Promise(resolve => setTimeout(resolve, delayMs));
}
return results;
}
export default { runBatchOverDocuments };
+256
View File
@@ -0,0 +1,256 @@
/**
* Layer/graphic blend modes.
*
* Canvas2D can only composite a subset of the Photoshop blend modes natively.
* This module exposes that native set plus the "extra" modes PhotoCraft offers
* (Dissolve, Linear Burn, Darker Color, Linear Dodge/Add, Lighter Color,
* Vivid Light, Linear Light, Pin Light, Hard Mix, Subtract, Divide) and a
* single `blendDraw` entry point that both paths go through.
*
* The non-native modes follow the W3C compositing spec (separable + the two
* non-separable luminance modes) and operate on ImageData in the overlap of
* the source and the destination so rendering stays reasonably cheap.
*/
export const NATIVE_BLEND_MODES = new Set([
'source-over',
'multiply',
'screen',
'overlay',
'darken',
'lighten',
'color-dodge',
'color-burn',
'hard-light',
'soft-light',
'difference',
'exclusion',
'hue',
'saturation',
'color',
'luminosity',
]);
const NATIVE_ALIASES = {
'linear-dodge': 'lighter',
add: 'lighter',
};
export const EXTRA_BLEND_MODES = [
{ value: 'linear-dodge', label: 'Linear Dodge (Add)' },
{ value: 'linear-burn', label: 'Linear Burn' },
{ value: 'darker-color', label: 'Darker Color' },
{ value: 'lighter-color', label: 'Lighter Color' },
{ value: 'vivid-light', label: 'Vivid Light' },
{ value: 'linear-light', label: 'Linear Light' },
{ value: 'pin-light', label: 'Pin Light' },
{ value: 'hard-mix', label: 'Hard Mix' },
{ value: 'subtract', label: 'Subtract' },
{ value: 'divide', label: 'Divide' },
{ value: 'dissolve', label: 'Dissolve' },
];
const NON_SEPARABLE = new Set(['darker-color', 'lighter-color']);
export function isNativeBlendMode(mode) {
if (!mode) return true;
return NATIVE_BLEND_MODES.has(mode) || Object.prototype.hasOwnProperty.call(NATIVE_ALIASES, mode);
}
export function isKnownBlendMode(mode) {
if (!mode) return true;
return isNativeBlendMode(mode) || EXTRA_BLEND_MODES.some(entry => entry.value === mode);
}
function clamp01(value) {
return value < 0 ? 0 : value > 1 ? 1 : value;
}
function lum(r, g, b) {
return 0.299 * r + 0.587 * g + 0.114 * b;
}
// Separable per-channel blend functions on normalized [0,1] values.
function blendChannel(mode, cb, cs) {
switch (mode) {
case 'normal':
return cs;
case 'multiply':
return cb * cs;
case 'screen':
return cb + cs - cb * cs;
case 'overlay':
return cb <= 0.5 ? 2 * cb * cs : 1 - 2 * (1 - cb) * (1 - cs);
case 'darken':
return Math.min(cb, cs);
case 'lighten':
return Math.max(cb, cs);
case 'color-dodge':
if (cb === 0) return 0;
if (cs === 1) return 1;
return Math.min(1, cb / (1 - cs));
case 'color-burn':
if (cb === 1) return 1;
if (cs === 0) return 0;
return 1 - Math.min(1, (1 - cb) / cs);
case 'hard-light':
return cs <= 0.5 ? 2 * cs * cb : 1 - 2 * (1 - cs) * (1 - cb);
case 'soft-light': {
if (cs <= 0.5) return cb - (1 - 2 * cs) * cb * (1 - cb);
const d = cb <= 0.25 ? ((16 * cb - 12) * cb + 4) * cb : Math.sqrt(cb);
return cb + (2 * cs - 1) * (d - cb);
}
case 'difference':
return Math.abs(cb - cs);
case 'exclusion':
return cb + cs - 2 * cb * cs;
case 'linear-burn':
return clamp01(cb + cs - 1);
case 'linear-dodge':
return clamp01(cb + cs);
case 'vivid-light':
return cs <= 0.5
? (cs === 0 ? 0 : 1 - Math.min(1, (1 - cb) / (2 * cs)))
: (cs === 1 ? 1 : Math.min(1, cb / (2 * (1 - cs))));
case 'linear-light':
return clamp01(cb + 2 * cs - 1);
case 'pin-light':
return cs <= 0.5 ? Math.min(cb, 2 * cs) : Math.max(cb, 2 * cs - 1);
case 'hard-mix': {
const vivid = cs <= 0.5
? (cs === 0 ? 0 : 1 - Math.min(1, (1 - cb) / (2 * cs)))
: (cs === 1 ? 1 : Math.min(1, cb / (2 * (1 - cs))));
return vivid < 0.5 ? 0 : 1;
}
case 'subtract':
return clamp01(cb - cs);
case 'divide':
return cs === 0 ? 1 : clamp01(cb / cs);
default:
return cs;
}
}
function dissolveRandom(i) {
let seed = (i * 2654435761) >>> 0;
seed ^= seed >>> 13;
seed = (seed * 1274126177) >>> 0;
return seed / 4294967296;
}
function ensureSourceData(source) {
const sw = source.width || source.naturalWidth || 0;
const sh = source.height || source.naturalHeight || 0;
if (!sw || !sh) return null;
const scratch = document.createElement('canvas');
scratch.width = sw;
scratch.height = sh;
const sctx = scratch.getContext('2d');
sctx.drawImage(source, 0, 0, sw, sh);
return { data: sctx.getImageData(0, 0, sw, sh).data, sw, sh };
}
function blendCustom(ctx, source, mode, alpha, dx, dy) {
if (alpha <= 0) return;
const src = ensureSourceData(source);
if (!src) return;
const tw = ctx.canvas.width;
const th = ctx.canvas.height;
const sw = src.sw;
const sh = src.sh;
const sx0 = Math.max(0, -dx);
const sy0 = Math.max(0, -dy);
const dx0 = Math.max(0, dx);
const dy0 = Math.max(0, dy);
const w = Math.min(sw - sx0, tw - dx0);
const h = Math.min(sh - sy0, th - dy0);
if (w <= 0 || h <= 0) return;
const dst = ctx.getImageData(dx0, dy0, w, h);
const dd = dst.data;
const sd = src.data;
const nonSeparable = NON_SEPARABLE.has(mode);
const dissolve = mode === 'dissolve';
for (let y = 0; y < h; y += 1) {
for (let x = 0; x < w; x += 1) {
const si = ((y + sy0) * sw + (x + sx0)) * 4;
const di = (y * w + x) * 4;
const sa = (sd[si + 3] / 255) * alpha;
const ba = dd[di + 3] / 255;
if (dissolve) {
const seed = (y + dy0) * 73856093 ^ (x + dx0) * 19349663;
if (dissolveRandom(seed) >= sa) continue;
dd[di] = sd[si];
dd[di + 1] = sd[si + 1];
dd[di + 2] = sd[si + 2];
dd[di + 3] = 255;
continue;
}
if (sa === 0) continue;
const sr = sd[si] / 255;
const sg = sd[si + 1] / 255;
const sb = sd[si + 2] / 255;
const br = dd[di] / 255;
const bg = dd[di + 1] / 255;
const bb = dd[di + 2] / 255;
let mergedR;
let mergedG;
let mergedB;
if (nonSeparable) {
const backdropLum = lum(br, bg, bb);
const sourceLum = lum(sr, sg, sb);
const useSource = mode === 'darker-color' ? sourceLum < backdropLum : sourceLum > backdropLum;
mergedR = useSource ? sr : br;
mergedG = useSource ? sg : bg;
mergedB = useSource ? sb : bb;
} else {
mergedR = blendChannel(mode, br, sr);
mergedG = blendChannel(mode, bg, sg);
mergedB = blendChannel(mode, bb, sb);
}
const srEff = (1 - ba) * sr + ba * mergedR;
const sgEff = (1 - ba) * sg + ba * mergedG;
const sbEff = (1 - ba) * sb + ba * mergedB;
const ao = sa + ba * (1 - sa);
if (ao <= 0) continue;
const cr = (sa * srEff + ba * br * (1 - sa)) / ao;
const cg = (sa * sgEff + ba * bg * (1 - sa)) / ao;
const cbOut = (sa * sbEff + ba * bb * (1 - sa)) / ao;
dd[di] = Math.round(clamp01(cr) * 255);
dd[di + 1] = Math.round(clamp01(cg) * 255);
dd[di + 2] = Math.round(clamp01(cbOut) * 255);
dd[di + 3] = Math.round(ao * 255);
}
}
ctx.putImageData(dst, dx0, dy0);
}
/**
* Composite `source` onto a 2D context (or canvas) using `mode` at `alpha`.
* Native modes use globalCompositeOperation; the rest fall back to pixel math.
*/
export function blendDraw(target, source, mode, alpha = 1, dx = 0, dy = 0) {
if (!target || !source) return;
const ctx = typeof target.getContext === 'function' ? target.getContext('2d') : target;
const a = Number.isFinite(alpha) ? clamp01(alpha) : 1;
const normalized = mode || 'source-over';
if (isNativeBlendMode(normalized)) {
const prevAlpha = ctx.globalAlpha;
const prevOp = ctx.globalCompositeOperation;
ctx.globalAlpha = a;
ctx.globalCompositeOperation = NATIVE_ALIASES[normalized] || normalized;
ctx.drawImage(source, dx, dy);
ctx.globalAlpha = prevAlpha;
ctx.globalCompositeOperation = prevOp;
return;
}
blendCustom(ctx, source, normalized, a, dx, dy);
}
+30 -20
View File
@@ -24,6 +24,32 @@ export function createBrushSampler({ getDiameter, getSpacing, getSmoothing, emit
emit(emitted); emit(emitted);
}; };
// Emit evenly spaced dabs from the last emitted sample toward `target`,
// carrying the sub-spacing remainder forward. Shared by drag updates and
// the final pointer-up so a stroke ends on the same spacing grid instead
// of stamping one detached dab (which reads as a dot where drawing stopped).
const emitSpaced = (target) => {
if (!emitted) return;
const start = { ...emitted };
const dx = target.x - start.x;
const dy = target.y - start.y;
const distance = Math.hypot(dx, dy);
if (!distance) return;
const diameter = Math.max(1, Number(getDiameter()) || 1);
const spacing = Math.max(0.5, diameter * Math.max(0.01, Number(getSpacing()) || 0.01));
let travelled = spacing - remainder;
while (travelled <= distance) {
const t = travelled / distance;
output({
x: start.x + dx * t,
y: start.y + dy * t,
pressure: start.pressure + (target.pressure - start.pressure) * t,
});
travelled += spacing;
}
remainder = Math.max(0, distance - (travelled - spacing));
};
const begin = (sample) => { const begin = (sample) => {
emitted = null; emitted = null;
filtered = { ...sample, pressure: normalisePressure(sample.pressure) }; filtered = { ...sample, pressure: normalisePressure(sample.pressure) };
@@ -41,30 +67,14 @@ export function createBrushSampler({ getDiameter, getSpacing, getSmoothing, emit
y: filtered.y + (raw.y - filtered.y) * response, y: filtered.y + (raw.y - filtered.y) * response,
pressure: filtered.pressure + (raw.pressure - filtered.pressure) * response, pressure: filtered.pressure + (raw.pressure - filtered.pressure) * response,
}; };
const start = { ...emitted }; emitSpaced(filtered);
const dx = filtered.x - start.x;
const dy = filtered.y - start.y;
const distance = Math.hypot(dx, dy);
if (!distance) return;
const diameter = Math.max(1, Number(getDiameter()) || 1);
const spacing = Math.max(0.5, diameter * Math.max(0.01, Number(getSpacing()) || 0.01));
let travelled = spacing - remainder;
while (travelled <= distance) {
const t = travelled / distance;
output({
x: start.x + dx * t,
y: start.y + dy * t,
pressure: start.pressure + (filtered.pressure - start.pressure) * t,
});
travelled += spacing;
}
remainder = Math.max(0, distance - (travelled - spacing));
}; };
const end = (sample) => { const end = (sample) => {
// Reach the true pointer-up point along the spacing grid rather than with
// a single far dab, so the stroke does not terminate in a detached dot.
if (sample && emitted) { if (sample && emitted) {
const finalSample = { ...sample, pressure: normalisePressure(sample.pressure) }; emitSpaced({ ...sample, pressure: normalisePressure(sample.pressure) });
if (Math.hypot(finalSample.x - emitted.x, finalSample.y - emitted.y) > 0.25) output(finalSample);
} }
emitted = null; emitted = null;
filtered = null; filtered = null;
+3
View File
@@ -4,6 +4,7 @@ const BUILT_INS = [
{ id: 'soft-round', name: 'Soft Round', size: 80, opacity: 100, flow: 35, softness: 100, spacing: 12, smoothing: 25, blendMode: 'source-over' }, { id: 'soft-round', name: 'Soft Round', size: 80, opacity: 100, flow: 35, softness: 100, spacing: 12, smoothing: 25, blendMode: 'source-over' },
{ id: 'hard-round', name: 'Hard Round', size: 24, opacity: 100, flow: 100, softness: 0, spacing: 18, smoothing: 15, blendMode: 'source-over' }, { id: 'hard-round', name: 'Hard Round', size: 24, opacity: 100, flow: 100, softness: 0, spacing: 18, smoothing: 15, blendMode: 'source-over' },
{ id: 'detail', name: 'Detail', size: 6, opacity: 100, flow: 70, softness: 20, spacing: 10, smoothing: 55, blendMode: 'source-over' }, { id: 'detail', name: 'Detail', size: 6, opacity: 100, flow: 70, softness: 20, spacing: 10, smoothing: 55, blendMode: 'source-over' },
{ id: 'pixel-1px', name: 'Pixel', size: 1, opacity: 100, flow: 100, softness: 0, spacing: 1, smoothing: 0, blendMode: 'source-over', pixel: true },
]; ];
export function loadBrushPresets(storage = globalThis.localStorage) { export function loadBrushPresets(storage = globalThis.localStorage) {
@@ -31,6 +32,7 @@ export function captureBrushPreset(state, name) {
spacing: state.brushSpacing, spacing: state.brushSpacing,
smoothing: state.brushSmoothing, smoothing: state.brushSmoothing,
blendMode: state.brushBlendMode, blendMode: state.brushBlendMode,
pixel: !!state.brushPixel,
}; };
} }
@@ -43,4 +45,5 @@ export function applyBrushPreset(state, preset) {
state.brushSpacing = preset.spacing; state.brushSpacing = preset.spacing;
state.brushSmoothing = preset.smoothing; state.brushSmoothing = preset.smoothing;
state.brushBlendMode = preset.blendMode || 'source-over'; state.brushBlendMode = preset.blendMode || 'source-over';
state.brushPixel = !!preset.pixel;
} }
+80
View File
@@ -0,0 +1,80 @@
/** Command palette (Ctrl/Cmd+K): fuzzy list of tools, panels and actions. */
export function createCommandPalette({ commands = [], onRun = null } = {}) {
const overlay = document.createElement('div');
overlay.className = 'ge-command-palette';
overlay.hidden = true;
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-label', 'Command palette');
overlay.innerHTML = `
<div class="ge-command-panel">
<input class="ge-command-input" type="text" placeholder="Type a command..." aria-label="Command" autocomplete="off" />
<div class="ge-command-list" role="listbox"></div>
</div>`;
document.body.appendChild(overlay);
const input = overlay.querySelector('.ge-command-input');
const list = overlay.querySelector('.ge-command-list');
let filtered = [];
let activeIndex = 0;
function render() {
const query = input.value.trim().toLowerCase();
filtered = commands.filter(cmd => !query || String(cmd.label).toLowerCase().includes(query));
list.innerHTML = '';
if (!filtered.length) {
const empty = document.createElement('p');
empty.className = 'ge-command-empty';
empty.textContent = 'No matching commands';
list.appendChild(empty);
return;
}
if (activeIndex >= filtered.length) activeIndex = 0;
filtered.forEach((cmd, index) => {
const row = document.createElement('button');
row.type = 'button';
row.className = `ge-command-item${index === activeIndex ? ' active' : ''}`;
row.setAttribute('role', 'option');
row.setAttribute('aria-selected', index === activeIndex ? 'true' : 'false');
row.textContent = cmd.label;
row.addEventListener('click', () => run(cmd));
list.appendChild(row);
});
}
function run(cmd) {
close();
if (typeof cmd.run === 'function') cmd.run();
else if (typeof onRun === 'function') onRun(cmd);
}
function open() {
overlay.hidden = false;
input.value = '';
activeIndex = 0;
render();
input.focus();
}
function close() {
overlay.hidden = true;
}
input.addEventListener('input', () => { activeIndex = 0; render(); });
input.addEventListener('keydown', (event) => {
if (event.key === 'Escape') { event.preventDefault(); close(); }
else if (event.key === 'ArrowDown') { event.preventDefault(); activeIndex = Math.min(activeIndex + 1, filtered.length - 1); render(); }
else if (event.key === 'ArrowUp') { event.preventDefault(); activeIndex = Math.max(activeIndex - 1, 0); render(); }
else if (event.key === 'Enter') { event.preventDefault(); if (filtered[activeIndex]) run(filtered[activeIndex]); }
});
overlay.addEventListener('pointerdown', (event) => { if (event.target === overlay) close(); });
return {
element: overlay,
open,
close,
toggle: () => (overlay.hidden ? open() : close()),
isOpen: () => !overlay.hidden,
};
}
+399 -148
View File
@@ -6,10 +6,10 @@
* sections are all toggled `display:none` here; the tool-switch handler * sections are all toggled `display:none` here; the tool-switch handler
* in galleryEditor.js shows the section matching the active tool. * in galleryEditor.js shows the section matching the active tool.
* *
* @param {{ color: string, brushSize: number, wandTolerance: number }} ctx * @param {{ color: string, brushSize: number, wandTolerance: number, bucketTolerance: number, cropAspectPreset: string, eyedropperSampleSize: number }} ctx
* @returns {string} * @returns {string}
*/ */
export function controlsHTML({ color, brushSize, wandTolerance }) { export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance = 32, cropAspectPreset = 'free', eyedropperSampleSize = 1 }) {
const brushSliderValue = Math.round(Math.log(Math.max(1, brushSize)) / Math.log(800) * 1000); const brushSliderValue = Math.round(Math.log(Math.max(1, brushSize)) / Math.log(800) * 1000);
return ` return `
<div id="ge-pen-section" style="display:none;"> <div id="ge-pen-section" style="display:none;">
@@ -19,6 +19,28 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
<button type="button" class="ge-btn ge-btn-sm" id="ge-pen-cancel" title="Cancel path (Escape)"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m18 6-12 12M6 6l12 12"/></svg> Cancel</button> <button type="button" class="ge-btn ge-btn-sm" id="ge-pen-cancel" title="Cancel path (Escape)"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m18 6-12 12M6 6l12 12"/></svg> Cancel</button>
</div> </div>
</div> </div>
<div id="ge-liquify-section" style="display:none;">
<div class="ge-section-title">Liquify</div>
<div class="ge-control-row">
<label for="ge-liquify-mode">Mode</label>
<select id="ge-liquify-mode" class="ge-ctl-select">
<option value="push">Push</option>
<option value="twirl">Twirl</option>
<option value="pucker">Pucker</option>
<option value="bloat">Bloat</option>
<option value="smooth">Smooth</option>
</select>
</div>
<div class="ge-control-row ge-eraser-row">
<label><span id="ge-liquify-size-label">120px</span></label>
<input type="range" id="ge-liquify-size" min="10" max="600" value="120" />
</div>
<div class="ge-control-row ge-eraser-row">
<label>Strength <span id="ge-liquify-strength-label">50%</span></label>
<input type="range" id="ge-liquify-strength" min="1" max="100" value="50" />
</div>
<div class="ge-control-row ge-liquify-hint">Drag on the canvas to warp pixels. One undo step per stroke.</div>
</div>
<div class="ge-layer-geometry-section" id="ge-layer-geometry-section"> <div class="ge-layer-geometry-section" id="ge-layer-geometry-section">
<div class="ge-section-title"><span>Position</span><span id="ge-layer-geometry-name"></span></div> <div class="ge-section-title"><span>Position</span><span id="ge-layer-geometry-name"></span></div>
<div class="ge-layer-geometry-grid"> <div class="ge-layer-geometry-grid">
@@ -33,16 +55,22 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
<label>Color</label> <label>Color</label>
<input type="color" class="ge-color-picker" value="${color}" /> <input type="color" class="ge-color-picker" value="${color}" />
</div> </div>
<div class="ge-control-row"> <div class="ge-control-row ge-brush-size-row">
<label>Size <span class="ge-size-label">${brushSize}px</span></label> <label><span class="ge-size-label">${brushSize}px</span></label>
<input type="range" class="ge-size-slider" min="0" max="1000" value="${brushSliderValue}" /> <input type="range" class="ge-size-slider" min="0" max="1000" value="${brushSliderValue}" />
</div> <span class="ge-brush-more-wrap">
<button type="button" class="ge-btn ge-btn-sm ge-brush-more-btn" id="ge-brush-more" aria-haspopup="true" aria-expanded="false" title="More brush options (spacing, smoothing, blend, opacity, pen pressure)" aria-label="More brush options">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>
</button>
<div class="ge-brush-more-menu" id="ge-brush-more-menu" hidden>
<div class="ge-section-title">Stroke</div> <div class="ge-section-title">Stroke</div>
<div class="ge-control-row ge-eraser-row"> <div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-brush-preview-spacing" aria-hidden="true"></span>
<label>Spacing <span id="ge-brush-spacing-label">15%</span></label> <label>Spacing <span id="ge-brush-spacing-label">15%</span></label>
<input type="range" id="ge-brush-spacing" min="1" max="100" value="15" /> <input type="range" id="ge-brush-spacing" min="1" max="100" value="15" />
</div> </div>
<div class="ge-control-row ge-eraser-row"> <div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-brush-preview-smoothing" aria-hidden="true"></span>
<label>Smoothing <span id="ge-brush-smoothing-label">25%</span></label> <label>Smoothing <span id="ge-brush-smoothing-label">25%</span></label>
<input type="range" id="ge-brush-smoothing" min="0" max="95" value="25" /> <input type="range" id="ge-brush-smoothing" min="0" max="95" value="25" />
</div> </div>
@@ -54,11 +82,79 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
<option value="soft-light">Soft Light</option><option value="color">Color</option> <option value="soft-light">Soft Light</option><option value="color">Color</option>
</select> </select>
</div> </div>
<div class="ge-brush-stroke-group" data-stroke-tool="brush">
<div class="ge-section-title" id="ge-stroke-title-brush">Brush</div>
<div class="ge-control-row ge-eraser-row" id="ge-brush-opacity-row">
<span class="ge-eraser-preview" id="ge-brush-preview-opacity" aria-hidden="true"></span>
<label>Opacity <span id="ge-brush-opacity-label">100%</span></label>
<input type="range" id="ge-brush-opacity" min="10" max="100" value="100" />
</div>
<div class="ge-control-row ge-eraser-row" id="ge-brush-flow-row">
<span class="ge-eraser-preview" id="ge-brush-preview-flow" aria-hidden="true"></span>
<label>Flow <span id="ge-brush-flow-label">100%</span></label>
<input type="range" id="ge-brush-flow" min="5" max="100" value="100" />
</div>
<div class="ge-control-row ge-eraser-row" id="ge-brush-softness-row">
<span class="ge-eraser-preview" id="ge-brush-preview-softness" aria-hidden="true"></span>
<label>Softness <span id="ge-brush-softness-label">100%</span></label>
<input type="range" id="ge-brush-softness" min="0" max="300" value="100" title="Soft brush edge — blurs the stroke's alpha for a feathered fade at the perimeter." />
</div>
</div>
<div class="ge-brush-stroke-group" data-stroke-tool="eraser">
<div class="ge-section-title">Eraser</div>
<div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-eraser-preview-opacity" aria-hidden="true"></span>
<label>Opacity <span id="ge-eraser-opacity-label">100%</span></label>
<input type="range" id="ge-eraser-opacity" min="10" max="100" value="100" />
</div>
<div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-eraser-preview-flow" aria-hidden="true"></span>
<label>Flow <span id="ge-eraser-flow-label">100%</span></label>
<input type="range" id="ge-eraser-flow" min="5" max="100" value="100" />
</div>
<div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-eraser-preview-softness" aria-hidden="true"></span>
<label>Softness <span id="ge-eraser-softness-label">100%</span></label>
<input type="range" id="ge-eraser-softness" min="0" max="300" value="100" title="Soft brush edge — blurs the stroke's alpha so the eraser fades out at the perimeter." />
</div>
</div>
<div class="ge-brush-stroke-group" data-stroke-tool="clone">
<div class="ge-section-title" id="ge-stroke-title-clone">Clone</div>
<div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-clone-preview-opacity" aria-hidden="true"></span>
<label>Opacity <span id="ge-clone-opacity-label">100%</span></label>
<input type="range" id="ge-clone-opacity" min="10" max="100" value="100" />
</div>
<div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-clone-preview-flow" aria-hidden="true"></span>
<label>Flow <span id="ge-clone-flow-label">100%</span></label>
<input type="range" id="ge-clone-flow" min="5" max="100" value="100" />
</div>
<div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-clone-preview-softness" aria-hidden="true"></span>
<label>Softness <span id="ge-clone-softness-label">100%</span></label>
<input type="range" id="ge-clone-softness" min="0" max="300" value="100" title="Soft brush edge — blurs each stamp for a feathered fade." />
</div>
</div>
<div class="ge-brush-stroke-group" data-stroke-tool="smudge">
<div class="ge-section-title">Smudge</div>
<div class="ge-control-row ge-eraser-row" id="ge-smudge-strength-row">
<span class="ge-eraser-preview" id="ge-smudge-preview-strength" aria-hidden="true"></span>
<label>Strength <span id="ge-smudge-strength-label">65%</span></label>
<input type="range" id="ge-smudge-strength" min="5" max="100" value="65" title="How strongly Smudge carries sampled pixels into the stroke." />
</div>
</div>
<div class="ge-control-row ge-brush-pressure-row"> <div class="ge-control-row ge-brush-pressure-row">
<span class="ge-pressure-option" title="Pen pressure changes brush size"><span>Size</span><label class="toggle-switch"><input type="checkbox" id="ge-pressure-size" checked /><span class="toggle-slider"></span></label></span>
<span class="ge-pressure-option" title="Pen pressure changes opacity"><span>Opacity</span><label class="toggle-switch"><input type="checkbox" id="ge-pressure-opacity" /><span class="toggle-slider"></span></label></span>
<span class="ge-pressure-option" title="Pen pressure changes flow"><span>Flow</span><label class="toggle-switch"><input type="checkbox" id="ge-pressure-flow" checked /><span class="toggle-slider"></span></label></span> <span class="ge-pressure-option" title="Pen pressure changes flow"><span>Flow</span><label class="toggle-switch"><input type="checkbox" id="ge-pressure-flow" checked /><span class="toggle-slider"></span></label></span>
</div> </div>
</div>
</span>
</div>
<div class="ge-control-row ge-eraser-row" id="ge-dodgeburn-intensity-row" style="display:none;">
<span class="ge-eraser-preview" id="ge-dodgeburn-preview" aria-hidden="true"></span>
<label title="Intensity">Int <span id="ge-dodgeburn-intensity-label">25%</span></label>
<input type="range" id="ge-dodgeburn-intensity" min="0" max="100" value="25" title="How strongly Dodge lightens or Burn darkens each dab." />
</div>
<div class="ge-control-row ge-brush-preset-row"> <div class="ge-control-row ge-brush-preset-row">
<select id="ge-brush-preset" aria-label="Brush preset"></select> <select id="ge-brush-preset" aria-label="Brush preset"></select>
<button type="button" class="ge-btn ge-btn-sm" id="ge-brush-preset-save" title="Save current brush preset">Save</button> <button type="button" class="ge-btn ge-btn-sm" id="ge-brush-preset-save" title="Save current brush preset">Save</button>
@@ -96,19 +192,47 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
</span> </span>
</div> </div>
<div class="ge-control-row"><label for="ge-eyedropper-sample">Sample</label><select id="ge-eyedropper-sample"><option value="composite">All layers</option><option value="layer">Active layer</option></select></div> <div class="ge-control-row"><label for="ge-eyedropper-sample">Sample</label><select id="ge-eyedropper-sample"><option value="composite">All layers</option><option value="layer">Active layer</option></select></div>
<div class="ge-control-row"><label for="ge-eyedropper-sample-size">Sample Size</label><select id="ge-eyedropper-sample-size" title="Point samples one pixel; the averages blur the reading over an N-by-N block.">${[1, 3, 5, 11, 31, 51].map((n) => `<option value="${n}"${Number(eyedropperSampleSize) === n ? ' selected' : ''}>${n === 1 ? 'Point Sample' : `${n} by ${n} Average`}</option>`).join('')}</select></div>
</div>
<div class="ge-bucket-section" id="ge-bucket-section" style="display:none;">
<div class="ge-section-title">Paint Bucket</div>
<div class="ge-control-row ge-eraser-row">
<label for="ge-bucket-tolerance">Tolerance <span id="ge-bucket-tolerance-label">${bucketTolerance}</span></label>
<input type="range" id="ge-bucket-tolerance" min="0" max="255" value="${bucketTolerance}" title="How different a neighbouring pixel can be and still be filled (0-255)." />
</div>
</div>
<div class="ge-crop-section" id="ge-crop-section" style="display:none;">
<div class="ge-section-title">Crop</div>
<div class="ge-control-row ge-crop-preset-row" id="ge-crop-presets" role="group" aria-label="Crop aspect ratio">
<button type="button" class="ge-btn ge-btn-sm ge-crop-preset${cropAspectPreset === 'free' ? ' active' : ''}" data-crop-preset="free" title="Crop freely (no aspect lock)">Free</button>
<button type="button" class="ge-btn ge-btn-sm ge-crop-preset${cropAspectPreset === 'original' ? ' active' : ''}" data-crop-preset="original" title="Keep the original image aspect ratio">Original</button>
<button type="button" class="ge-btn ge-btn-sm ge-crop-preset${cropAspectPreset === '1:1' ? ' active' : ''}" data-crop-preset="1:1" title="Square (1:1)">1:1</button>
<button type="button" class="ge-btn ge-btn-sm ge-crop-preset${cropAspectPreset === '4:5' ? ' active' : ''}" data-crop-preset="4:5" title="Portrait (4:5)">4:5</button>
<button type="button" class="ge-btn ge-btn-sm ge-crop-preset${cropAspectPreset === '16:9' ? ' active' : ''}" data-crop-preset="16:9" title="Widescreen (16:9)">16:9</button>
<label class="ge-crop-custom" title="Type any ratio, e.g. 3:2 or 21:9">
<input type="text" id="ge-crop-custom-ratio" inputmode="numeric" placeholder="W:H" aria-label="Custom crop ratio" spellcheck="false" autocomplete="off" />
</label>
</div>
<p class="ge-section-hint">Pick a ratio, then drag on the canvas. Press Enter to apply.</p>
</div> </div>
<div class="ge-text-section" id="ge-text-section" style="display:none;"> <div class="ge-text-section" id="ge-text-section" style="display:none;">
<textarea id="ge-text-content" rows="3" placeholder="Type text..." aria-label="Text content"></textarea> <textarea id="ge-text-content" rows="2" placeholder="Type text..." aria-label="Text content"></textarea>
<div class="ge-text-grid"> <div class="ge-text-grid">
<label class="ge-text-field ge-text-font-field"><span>Font</span> <label class="ge-text-field ge-text-font-field"><span>Font</span>
<select id="ge-text-font"> <select id="ge-text-font">
<option value="Arial">Arial</option> <option value="Inter">Inter</option>
<option value="Verdana">Verdana</option> <option value="Roboto">Roboto</option>
<option value="Playfair Display">Playfair Display</option>
<option value="Lobster">Lobster</option>
<option value="Bebas Neue">Bebas Neue</option>
<option value="Roboto Mono">Roboto Mono</option>
<option value="Space Grotesk">Space Grotesk</option>
<option value="Fraunces">Fraunces</option>
<option value="Syne">Syne</option>
<option value="Bungee">Bungee</option>
<option value="Instrument Serif">Instrument Serif</option>
<option value="Georgia">Georgia</option> <option value="Georgia">Georgia</option>
<option value="Times New Roman">Times New Roman</option>
<option value="Courier New">Courier New</option> <option value="Courier New">Courier New</option>
<option value="Impact">Impact</option>
<option value="system-ui">System</option>
</select> </select>
</label> </label>
<label class="ge-text-field ge-text-size-field"><span>Size</span> <label class="ge-text-field ge-text-size-field"><span>Size</span>
@@ -124,6 +248,7 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
<button type="button" class="ge-text-toggle" data-text-align="center" title="Align center" aria-pressed="false">&#8596;</button> <button type="button" class="ge-text-toggle" data-text-align="center" title="Align center" aria-pressed="false">&#8596;</button>
<button type="button" class="ge-text-toggle" data-text-align="right" title="Align right" aria-pressed="false">&#8677;</button> <button type="button" class="ge-text-toggle" data-text-align="right" title="Align right" aria-pressed="false">&#8677;</button>
</div> </div>
<div class="ge-opt-more" id="ge-text-more" hidden>
<div class="ge-text-grid"> <div class="ge-text-grid">
<label class="ge-text-field"><span>Line</span> <label class="ge-text-field"><span>Line</span>
<input id="ge-text-line-height" type="number" min="0.5" max="5" step="0.1" value="1.2" /> <input id="ge-text-line-height" type="number" min="0.5" max="5" step="0.1" value="1.2" />
@@ -150,6 +275,38 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
<input type="color" class="ge-color-picker" id="ge-text-stroke-color" value="#000000" title="Stroke color" aria-label="Stroke color" /> <input type="color" class="ge-color-picker" id="ge-text-stroke-color" value="#000000" title="Stroke color" aria-label="Stroke color" />
</div> </div>
<label class="ge-text-auto-width-option"><span>Auto width</span><span class="toggle-switch"><input type="checkbox" id="ge-text-auto-width" /><span class="toggle-slider"></span></span></label> <label class="ge-text-auto-width-option"><span>Auto width</span><span class="toggle-switch"><input type="checkbox" id="ge-text-auto-width" /><span class="toggle-slider"></span></span></label>
<div class="ge-text-grid">
<label class="ge-text-field"><span>Writing</span>
<select id="ge-text-direction"><option value="horizontal">Horizontal</option><option value="vertical">Vertical</option></select>
</label>
<label class="ge-text-field"><span>Warp</span>
<select id="ge-text-warp">
<option value="none">None</option>
<option value="arc">Arc</option>
<option value="arch">Arch</option>
<option value="bulge">Bulge</option>
<option value="flag">Flag</option>
<option value="wave">Wave</option>
<option value="rise">Rise</option>
<option value="fisheye">Fisheye</option>
<option value="inflate">Inflate</option>
<option value="squeeze">Squeeze</option>
<option value="twist">Twist</option>
</select>
</label>
</div>
<div class="ge-text-grid">
<label class="ge-text-field"><span>Warp amt</span>
<input id="ge-text-warp-amount" type="range" min="-100" max="100" step="1" value="0" />
</label>
</div>
<div class="ge-text-grid ge-text-opentype-row">
<label class="ge-text-field ge-text-inline-check"><span>Small caps</span><input type="checkbox" id="ge-text-small-caps" /></label>
<label class="ge-text-field ge-text-inline-check"><span>Kerning</span><input type="checkbox" id="ge-text-kerning" checked /></label>
<label class="ge-text-field ge-text-inline-check"><span>Ligatures</span><input type="checkbox" id="ge-text-ligatures" checked /></label>
</div>
</div>
<button type="button" class="ge-btn ge-btn-sm ge-more-toggle" id="ge-text-more-toggle" aria-expanded="false" aria-controls="ge-text-more">More</button>
<button type="button" class="ge-btn ge-btn-sm" id="ge-text-rasterize">Rasterize</button> <button type="button" class="ge-btn ge-btn-sm" id="ge-text-rasterize">Rasterize</button>
</div> </div>
<div class="ge-shape-section" id="ge-shape-section" style="display:none;"> <div class="ge-shape-section" id="ge-shape-section" style="display:none;">
@@ -164,6 +321,7 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
<label class="ge-text-field"><span>Fill</span><input type="color" class="ge-color-picker" id="ge-shape-fill" value="${color}" /></label> <label class="ge-text-field"><span>Fill</span><input type="color" class="ge-color-picker" id="ge-shape-fill" value="${color}" /></label>
<label class="ge-text-field"><span>Stroke</span><input type="color" class="ge-color-picker" id="ge-shape-stroke" value="#111111" /></label> <label class="ge-text-field"><span>Stroke</span><input type="color" class="ge-color-picker" id="ge-shape-stroke" value="#111111" /></label>
</div> </div>
<div class="ge-opt-more" id="ge-shape-more" hidden>
<div class="ge-text-grid"> <div class="ge-text-grid">
<label class="ge-text-field"><span>Fill type</span> <label class="ge-text-field"><span>Fill type</span>
<select id="ge-shape-fill-type"><option value="solid">Solid</option><option value="linear-gradient">Linear gradient</option></select> <select id="ge-shape-fill-type"><option value="solid">Solid</option><option value="linear-gradient">Linear gradient</option></select>
@@ -184,8 +342,26 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
<label class="ge-text-field"><span>Radius</span><input id="ge-shape-radius" type="number" min="0" max="5000" step="1" value="0" /></label> <label class="ge-text-field"><span>Radius</span><input id="ge-shape-radius" type="number" min="0" max="5000" step="1" value="0" /></label>
</div> </div>
<label class="ge-text-field ge-shape-sides-field" hidden><span>Sides</span><input id="ge-shape-sides" type="number" min="3" max="24" step="1" value="5" /></label> <label class="ge-text-field ge-shape-sides-field" hidden><span>Sides</span><input id="ge-shape-sides" type="number" min="3" max="24" step="1" value="5" /></label>
</div>
<button type="button" class="ge-btn ge-btn-sm ge-more-toggle" id="ge-shape-more-toggle" aria-expanded="false" aria-controls="ge-shape-more">More</button>
<button type="button" class="ge-btn ge-btn-sm" id="ge-shape-rasterize">Rasterize</button> <button type="button" class="ge-btn ge-btn-sm" id="ge-shape-rasterize">Rasterize</button>
</div> </div>
<div class="ge-path-section" id="ge-path-section" style="display:none;">
<div class="ge-section-title">Path</div>
<p class="ge-section-hint">Click to add anchors; double-click or press Enter to finish.</p>
<div class="ge-text-grid">
<label class="ge-text-field"><span>Fill</span><input type="color" class="ge-color-picker" id="ge-path-fill" value="${color}" /></label>
<label class="ge-text-field"><span>Stroke</span><input type="color" class="ge-color-picker" id="ge-path-stroke" value="#111111" /></label>
</div>
<div class="ge-text-grid">
<label class="ge-text-field"><span>Width</span><input id="ge-path-stroke-width" type="number" min="0" max="500" step="1" value="2" /></label>
<label class="ge-text-field ge-path-closed-field"><span>Close path</span><input id="ge-path-closed" type="checkbox" checked /></label>
</div>
<div class="ge-control-row" style="display:flex;gap:4px;">
<button type="button" class="ge-btn ge-btn-sm" id="ge-path-finish">Finish</button>
<button type="button" class="ge-btn ge-btn-sm" id="ge-path-cancel">Cancel</button>
</div>
</div>
<div class="ge-marquee-section" id="ge-marquee-section" style="display:none;"> <div class="ge-marquee-section" id="ge-marquee-section" style="display:none;">
<div class="ge-control-row" style="display:flex;gap:4px;margin-bottom:4px;"> <div class="ge-control-row" style="display:flex;gap:4px;margin-bottom:4px;">
<button type="button" class="ge-btn ge-btn-sm ge-marquee-shape-btn active" data-marquee-shape="rectangle" title="Rectangular marquee" aria-pressed="true"> <button type="button" class="ge-btn ge-btn-sm ge-marquee-shape-btn active" data-marquee-shape="rectangle" title="Rectangular marquee" aria-pressed="true">
@@ -198,14 +374,13 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn active" data-wand-mode="replace" title="New selection">New</button> <button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn active" data-wand-mode="replace" title="New selection">New</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="add" title="Add to selection">+</button> <button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="add" title="Add to selection">+</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="subtract" title="Subtract from selection">−</button> <button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="subtract" title="Subtract from selection">−</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="intersect" title="Intersect selection">∩</button> <button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="intersect" title="Intersect selection"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" style="position:relative;top:2px"><circle cx="9" cy="12" r="6"/><circle cx="15" cy="12" r="6"/></svg></button>
</div> </div>
<div class="ge-marquee-constraint-row"> <div class="ge-marquee-constraint-row">
<label for="ge-marquee-constraint">Style</label> <label for="ge-marquee-constraint">Style</label>
<select id="ge-marquee-constraint" title="Marquee sizing style"> <select id="ge-marquee-constraint" title="Marquee sizing style">
<option value="free">Free</option> <option value="free">Free</option>
<option value="ratio">Fixed ratio</option> <option value="ratio">Fixed ratio</option>
<option value="size">Fixed size</option>
</select> </select>
<div class="ge-marquee-dimensions" hidden> <div class="ge-marquee-dimensions" hidden>
<label>W <input type="number" id="ge-marquee-width" min="1" step="1" value="1" inputmode="decimal" /></label> <label>W <input type="number" id="ge-marquee-width" min="1" step="1" value="1" inputmode="decimal" /></label>
@@ -215,16 +390,18 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
</button> </button>
</div> </div>
</div> </div>
<div class="ge-control-row ge-actions" style="margin-top:4px;flex-wrap:wrap;"> <div class="ge-control-row ge-actions" style="margin-top:2px;flex-wrap:wrap;">
<button class="ge-btn ge-btn-sm ge-mask-vis-btn visible" id="ge-marquee-vis" title="Hide selection overlay" aria-label="Toggle selection overlay"> <button class="ge-btn ge-btn-sm ge-mask-vis-btn visible" id="ge-marquee-vis" title="Hide selection overlay" aria-label="Toggle selection overlay">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
</button> </button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-marquee-clear" title="Clear selection">Clear</button> <button class="ge-btn ge-btn-sm" id="ge-marquee-clear" title="Clear selection" aria-label="Clear selection">
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-marquee-invert" title="Invert selection">Invert</button> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-marquee-delete" title="Erase selected pixels">Erase</button> </button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-marquee-copy" title="Copy selection to a new layer">Copy Layer</button> <button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-marquee-invert" title="Invert selection"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 1 0 18z" fill="currentColor" stroke="none"/></svg>Invert</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-marquee-mask" title="Add selection to mask">To Mask</button> <button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-marquee-delete" title="Erase selected pixels"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7 21-4-4a2 2 0 0 1 0-3l9-9a2 2 0 0 1 3 0l4 4a2 2 0 0 1 0 3l-9 9a2 2 0 0 1-3 0Z"/><path d="M22 21H7"/></svg>Erase</button>
<button type="button" class="ge-btn ge-btn-sm ge-btn-iconlabel ge-quick-mask-toggle" title="Edit selection as Quick Mask (Q)" aria-pressed="false">Quick Mask</button> <button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-marquee-copy" title="Copy selection to a new layer"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>Copy Layer</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-marquee-mask" title="Add selection to mask"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="5"/></svg>To Mask</button>
<button type="button" class="ge-btn ge-btn-sm ge-btn-iconlabel ge-quick-mask-toggle" title="Edit selection as Quick Mask (Q)" aria-pressed="false"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9" stroke-dasharray="3 3"/><circle cx="12" cy="12" r="3.5"/></svg>Quick Mask</button>
</div> </div>
</div> </div>
<div class="ge-lasso-section" id="ge-lasso-section" style="display:none;"> <div class="ge-lasso-section" id="ge-lasso-section" style="display:none;">
@@ -232,8 +409,13 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn active" data-wand-mode="replace" title="New selection">New</button> <button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn active" data-wand-mode="replace" title="New selection">New</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="add" title="Add to selection">+</button> <button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="add" title="Add to selection">+</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="subtract" title="Subtract from selection">−</button> <button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="subtract" title="Subtract from selection">−</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="intersect" title="Intersect selection">∩</button> <button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="intersect" title="Intersect selection"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" style="position:relative;top:2px"><circle cx="9" cy="12" r="6"/><circle cx="15" cy="12" r="6"/></svg></button>
</div> </div>
<span class="ge-lasso-more-wrap">
<button type="button" class="ge-btn ge-btn-sm ge-lasso-more-btn" id="ge-lasso-more" aria-haspopup="true" aria-expanded="false" aria-label="More lasso options (feather, edge stroke, magnetic)" title="More lasso options (feather, edge stroke, magnetic)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
</button>
<div class="ge-lasso-more-menu" id="ge-lasso-more-menu" hidden>
<div class="ge-control-row ge-eraser-row ge-sel-refine" id="ge-lasso-refine-feather" style="display:none;"> <div class="ge-control-row ge-eraser-row ge-sel-refine" id="ge-lasso-refine-feather" style="display:none;">
<span class="ge-eraser-preview" id="ge-lasso-feather-preview" aria-hidden="true"></span> <span class="ge-eraser-preview" id="ge-lasso-feather-preview" aria-hidden="true"></span>
<label>Feather <span id="ge-lasso-feather-label">0px</span></label> <label>Feather <span id="ge-lasso-feather-label">0px</span></label>
@@ -244,38 +426,45 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
<label>Edge stroke <span id="ge-lasso-grow-label">0px</span></label> <label>Edge stroke <span id="ge-lasso-grow-label">0px</span></label>
<input type="range" id="ge-lasso-grow" min="-40" max="40" value="0" title="Expand (+) or contract (−) the selection before baking." /> <input type="range" id="ge-lasso-grow" min="-40" max="40" value="0" title="Expand (+) or contract (−) the selection before baking." />
</div> </div>
<div class="ge-control-row ge-actions" style="margin-top:4px;flex-wrap:wrap;"> <div class="ge-control-row">
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-lasso-invert" title="Invert selection (Ctrl+Alt+Shift+I)"> <button type="button" class="ge-btn ge-btn-sm" id="ge-lasso-magnetic" aria-pressed="false" title="Snap the outline to image edges as you drag.">Magnetic</button>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg> </div>
Invert <div class="ge-control-row ge-eraser-row" id="ge-lasso-mag-width-row" style="display:none;">
<label>Width <span id="ge-lasso-mag-width-label">12px</span></label>
<input type="range" id="ge-lasso-mag-width" min="2" max="60" value="12" title="Search radius for the strongest nearby edge." />
</div>
<div class="ge-control-row ge-eraser-row" id="ge-lasso-mag-contrast-row" style="display:none;">
<label>Contrast <span id="ge-lasso-mag-contrast-label">20%</span></label>
<input type="range" id="ge-lasso-mag-contrast" min="0" max="100" value="20" title="Minimum edge strength to snap to." />
</div>
<p class="ge-lasso-tip">Draw a freehand selection. Esc to cancel.</p>
</div>
</span>
<div class="ge-control-row ge-actions" style="margin-top:2px;flex-wrap:wrap;">
<button class="ge-btn ge-btn-sm ge-mask-vis-btn visible" id="ge-lasso-vis" title="Hide selection overlay" aria-label="Toggle selection overlay">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
</button> </button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-lasso-delete" title="Delete selected pixels from the layer"> <button class="ge-btn ge-btn-sm" id="ge-lasso-clear" title="Clear selection" aria-label="Clear selection">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
Delete
</button> </button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-lasso-copy" title="Copy selection to new layer"> <button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-lasso-invert" title="Invert selection (Ctrl+Alt+Shift+I)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 1 0 18z" fill="currentColor" stroke="none"/></svg>Invert</button>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg> <button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-lasso-delete" title="Erase selected pixels"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7 21-4-4a2 2 0 0 1 0-3l9-9a2 2 0 0 1 3 0l4 4a2 2 0 0 1 0 3l-9 9a2 2 0 0 1-3 0Z"/><path d="M22 21H7"/></svg>Erase</button>
Copy Layer <button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-lasso-copy" title="Copy selection to a new layer"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>Copy Layer</button>
</button> <button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-lasso-mask" title="Add selection to mask"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="5"/></svg>To Mask</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-lasso-mask" title="Convert selection to inpaint mask"> <button type="button" class="ge-btn ge-btn-sm ge-btn-iconlabel ge-quick-mask-toggle" title="Edit selection as Quick Mask (Q)" aria-pressed="false"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9" stroke-dasharray="3 3"/><circle cx="12" cy="12" r="3.5"/></svg>Quick Mask</button>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"/></svg>
To Mask
</button>
<button type="button" class="ge-btn ge-btn-sm ge-btn-iconlabel ge-quick-mask-toggle" title="Edit selection as Quick Mask (Q)" aria-pressed="false">Quick Mask</button>
</div> </div>
<p style="font-size:9px;opacity:0.4;margin:4px 0 0;">Draw a freehand selection. Esc to cancel.</p>
</div> </div>
<div class="ge-wand-section" id="ge-wand-section" style="display:none;"> <div class="ge-wand-section" id="ge-wand-section" style="display:none;">
<div class="ge-control-row" style="display:flex;gap:4px;margin-bottom:4px;" title="How the next click combines with the current selection. Shift / Alt held during a click override this for one click."> <div class="ge-control-row" style="display:flex;gap:4px;margin-bottom:4px;" title="How the next click combines with the current selection. Shift / Alt held during a click override this for one click.">
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn active" data-wand-mode="replace" title="Replace selection on each click">New</button> <button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn active" data-wand-mode="replace" title="Replace selection on each click">New</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="add" title="Add to selection (Shift)">+ Add</button> <button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="add" title="Add to selection (Shift)">+</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="subtract" title="Subtract from selection (Alt)">− Subtract</button> <button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="subtract" title="Subtract from selection (Alt)">−</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="intersect" title="Intersect selection">∩</button> <button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="intersect" title="Intersect selection"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" style="position:relative;top:2px"><circle cx="9" cy="12" r="6"/><circle cx="15" cy="12" r="6"/></svg></button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-live-btn" id="ge-wand-live" title="Retune selection while dragging tolerance" aria-pressed="false">Live</button>
</div> </div>
<div class="ge-control-row ge-eraser-row"> <div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-wand-tol-preview" aria-hidden="true"></span> <span class="ge-eraser-preview" id="ge-wand-tol-preview" aria-hidden="true"></span>
<label>Tolerance <span id="ge-wand-tol-label">${wandTolerance}</span></label> <label>Tolerance <span id="ge-wand-tol-label">${wandTolerance}</span></label>
<button type="button" class="ge-btn ge-btn-sm ge-wand-live-btn" id="ge-wand-live" title="Retune selection while dragging tolerance" aria-pressed="false">Live</button>
<input type="range" id="ge-wand-tolerance" min="0" max="100" value="${wandTolerance}" /> <input type="range" id="ge-wand-tolerance" min="0" max="100" value="${wandTolerance}" />
</div> </div>
<div class="ge-control-row ge-eraser-row ge-sel-refine" id="ge-wand-refine-feather" style="display:none;"> <div class="ge-control-row ge-eraser-row ge-sel-refine" id="ge-wand-refine-feather" style="display:none;">
@@ -288,41 +477,38 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
<label>Edge stroke <span id="ge-wand-grow-label">0px</span></label> <label>Edge stroke <span id="ge-wand-grow-label">0px</span></label>
<input type="range" id="ge-wand-grow" min="-40" max="40" value="0" title="Expand (+) or contract (−) the selection before baking." /> <input type="range" id="ge-wand-grow" min="-40" max="40" value="0" title="Expand (+) or contract (−) the selection before baking." />
</div> </div>
<div class="ge-control-row ge-actions" style="margin-top:4px;flex-wrap:wrap;"> <div class="ge-control-row ge-actions" style="margin-top:2px;flex-wrap:wrap;">
<button class="ge-btn ge-btn-sm ge-mask-vis-btn visible" id="ge-wand-vis" title="Hide selection overlay" aria-label="Toggle selection overlay"> <button class="ge-btn ge-btn-sm ge-mask-vis-btn visible" id="ge-wand-vis" title="Hide selection overlay" aria-label="Toggle selection overlay">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
</button> </button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-wand-clear" title="Clear the selection"> <button class="ge-btn ge-btn-sm" id="ge-wand-clear" title="Clear selection" aria-label="Clear selection">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
Clear
</button> </button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-wand-invert" title="Invert selection (Ctrl+Alt+Shift+I)"> <button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-wand-invert" title="Invert selection (Ctrl+Alt+Shift+I)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 1 0 18z" fill="currentColor" stroke="none"/></svg>
Invert Invert
</button> </button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-wand-delete" title="Delete selected pixels from the layer"> <button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-wand-delete" title="Erase selected pixels">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7 21-4-4a2 2 0 0 1 0-3l9-9a2 2 0 0 1 3 0l4 4a2 2 0 0 1 0 3l-9 9a2 2 0 0 1-3 0Z"/><path d="M22 21H7"/></svg>
Erase Erase
</button> </button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-wand-copy" title="Copy selection to a new layer"> <button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-wand-copy" title="Copy selection to a new layer">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
Copy Layer Copy Layer
</button> </button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-wand-mask" title="Add selection to the inpaint mask"> <button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-wand-mask" title="Add selection to mask">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="5"/></svg>
To Mask To Mask
</button> </button>
<button type="button" class="ge-btn ge-btn-sm ge-btn-iconlabel ge-quick-mask-toggle" title="Edit selection as Quick Mask (Q)" aria-pressed="false">Quick Mask</button> <button type="button" class="ge-btn ge-btn-sm ge-btn-iconlabel ge-quick-mask-toggle" title="Edit selection as Quick Mask (Q)" aria-pressed="false"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9" stroke-dasharray="3 3"/><circle cx="12" cy="12" r="3.5"/></svg>Quick Mask</button>
</div> </div>
<p style="font-size:9px;opacity:0.4;margin:4px 0 0;">Click a region to select similar pixels. Shift+click to add, Alt+click to subtract. Esc to clear.</p>
</div> </div>
<div class="ge-sam-section" id="ge-sam-section" style="display:none;"> <div class="ge-sam-section" id="ge-sam-section" style="display:none;">
<div class="ge-section-title ge-section-title-with-help"><span>SAM</span><span class="ge-section-help" tabindex="0" role="img" aria-label="SAM selection help" title="Click an object for visual SAM selection, or type a neutral object label and use Find. The text is only used to locate a region before SAM creates the mask.">?</span></div>
<div class="ge-control-row" style="display:flex;gap:4px;margin-bottom:4px;" title="How the next SAM selection combines with the current selection. Shift / Alt held during a click override this for one click."> <div class="ge-control-row" style="display:flex;gap:4px;margin-bottom:4px;" title="How the next SAM selection combines with the current selection. Shift / Alt held during a click override this for one click.">
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn active" data-wand-mode="replace" title="Replace selection">New</button> <button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn active" data-wand-mode="replace" title="Replace selection">New</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="add" title="Add to selection">+ Add</button> <button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="add" title="Add to selection">+</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="subtract" title="Subtract from selection">− Subtract</button> <button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="subtract" title="Subtract from selection">−</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="intersect" title="Intersect selection">∩</button> <button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="intersect" title="Intersect selection"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" style="position:relative;top:2px"><circle cx="9" cy="12" r="6"/><circle cx="15" cy="12" r="6"/></svg></button>
</div> </div>
<div class="ge-control-row" style="display:flex;gap:6px;align-items:center;min-width:0;"> <div class="ge-control-row" style="display:flex;gap:6px;align-items:center;min-width:0;">
<input type="text" class="ge-inpaint-prompt" id="ge-sam-query" placeholder="Object to select..." style="flex:1 1 auto;min-width:0;" /> <input type="text" class="ge-inpaint-prompt" id="ge-sam-query" placeholder="Object to select..." style="flex:1 1 auto;min-width:0;" />
@@ -331,21 +517,120 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
Find Find
</button> </button>
</div> </div>
<div class="ge-control-row ge-actions" style="margin-top:4px;flex-wrap:wrap;"> <span class="ge-wand-more-wrap">
<button type="button" class="ge-btn ge-btn-sm ge-wand-more-btn" id="ge-sam-more" aria-haspopup="true" aria-expanded="false" aria-label="More SAM options (help)" title="More SAM options (help)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg></button>
<div class="ge-wand-more-menu" id="ge-sam-more-menu" hidden>
<p class="ge-wand-tip">Click an object for visual SAM selection, or type a neutral object label and use Find. The text is only used to locate a region before SAM creates the mask. Shift adds, Alt subtracts, Esc clears.</p>
</div>
</span>
<div class="ge-control-row ge-actions" style="margin-top:2px;flex-wrap:wrap;">
<button class="ge-btn ge-btn-sm ge-mask-vis-btn visible" id="ge-sam-vis" title="Hide selection overlay" aria-label="Toggle selection overlay"> <button class="ge-btn ge-btn-sm ge-mask-vis-btn visible" id="ge-sam-vis" title="Hide selection overlay" aria-label="Toggle selection overlay">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
</button> </button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-sam-clear" title="Clear the selection"> <button class="ge-btn ge-btn-sm" id="ge-sam-clear" title="Clear selection" aria-label="Clear selection">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
Clear
</button> </button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-sam-mask" title="Add selection to the inpaint mask"> <button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-sam-invert" title="Invert selection (Ctrl+Alt+Shift+I)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 1 0 18z" fill="currentColor" stroke="none"/></svg>
Invert
</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-sam-delete" title="Erase selected pixels">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7 21-4-4a2 2 0 0 1 0-3l9-9a2 2 0 0 1 3 0l4 4a2 2 0 0 1 0 3l-9 9a2 2 0 0 1-3 0Z"/><path d="M22 21H7"/></svg>
Erase
</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-sam-copy" title="Copy selection to a new layer">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
Copy Layer
</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-sam-mask" title="Add selection to mask">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="5"/></svg>
To Mask To Mask
</button> </button>
<button type="button" class="ge-btn ge-btn-sm ge-btn-iconlabel ge-quick-mask-toggle" title="Edit selection as Quick Mask (Q)" aria-pressed="false">Quick Mask</button> <button type="button" class="ge-btn ge-btn-sm ge-btn-iconlabel ge-quick-mask-toggle" title="Edit selection as Quick Mask (Q)" aria-pressed="false"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9" stroke-dasharray="3 3"/><circle cx="12" cy="12" r="3.5"/></svg>Quick Mask</button>
</div>
</div>
<div class="ge-quickselect-section" id="ge-quickselect-section" style="display:none;">
<div class="ge-control-row" style="display:flex;gap:4px;margin-bottom:4px;" title="How the brush combines with the current selection. Shift / Alt held during a stroke override this.">
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn active" data-wand-mode="replace" title="Replace selection">New</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="add" title="Add to selection">+</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="subtract" title="Subtract from selection">−</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="intersect" title="Intersect selection"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" style="position:relative;top:2px"><circle cx="9" cy="12" r="6"/><circle cx="15" cy="12" r="6"/></svg></button>
</div>
<div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-quickselect-size-preview" aria-hidden="true"></span>
<label>Brush <span id="ge-quickselect-size-label">24</span></label>
<input type="range" class="ge-quickselect-size" id="ge-quickselect-size" min="4" max="200" value="24" />
</div>
<span class="ge-wand-more-wrap">
<button type="button" class="ge-btn ge-btn-sm ge-wand-more-btn" id="ge-quickselect-more" aria-haspopup="true" aria-expanded="false" aria-label="More Quick Select options (help)" title="More Quick Select options (help)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg></button>
<div class="ge-wand-more-menu" id="ge-quickselect-more-menu" hidden>
<p class="ge-wand-tip">Paint over a region; the brush grows the selection to similar nearby pixels. Shift adds, Alt subtracts, Shift+Alt intersects.</p>
</div>
</span>
<div class="ge-control-row ge-actions" style="margin-top:2px;flex-wrap:wrap;">
<button class="ge-btn ge-btn-sm ge-mask-vis-btn visible" id="ge-quickselect-vis" title="Hide selection overlay" aria-label="Toggle selection overlay">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
<button class="ge-btn ge-btn-sm" id="ge-quickselect-clear" title="Clear selection" aria-label="Clear selection">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-quickselect-invert" title="Invert selection (Ctrl+Alt+Shift+I)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 1 0 18z" fill="currentColor" stroke="none"/></svg>
Invert
</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-quickselect-delete" title="Erase selected pixels">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7 21-4-4a2 2 0 0 1 0-3l9-9a2 2 0 0 1 3 0l4 4a2 2 0 0 1 0 3l-9 9a2 2 0 0 1-3 0Z"/><path d="M22 21H7"/></svg>
Erase
</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-quickselect-copy" title="Copy selection to a new layer">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
Copy Layer
</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-quickselect-mask" title="Add selection to mask">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="5"/></svg>
To Mask
</button>
<button type="button" class="ge-btn ge-btn-sm ge-btn-iconlabel ge-quick-mask-toggle" title="Edit selection as Quick Mask (Q)" aria-pressed="false"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9" stroke-dasharray="3 3"/><circle cx="12" cy="12" r="3.5"/></svg>Quick Mask</button>
</div>
</div>
<div class="ge-objectselect-section" id="ge-objectselect-section" style="display:none;">
<div class="ge-control-row" style="display:flex;gap:4px;margin-bottom:4px;" title="How the object selection combines with the current selection. Shift / Alt held during a stroke override this.">
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn active" data-wand-mode="replace" title="Replace selection">New</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="add" title="Add to selection">+</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="subtract" title="Subtract from selection">−</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="intersect" title="Intersect selection"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" style="position:relative;top:2px"><circle cx="9" cy="12" r="6"/><circle cx="15" cy="12" r="6"/></svg></button>
</div>
<span class="ge-wand-more-wrap">
<button type="button" class="ge-btn ge-btn-sm ge-wand-more-btn" id="ge-objectselect-more" aria-haspopup="true" aria-expanded="false" aria-label="More Object Select options (help)" title="More Object Select options (help)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg></button>
<div class="ge-wand-more-menu" id="ge-objectselect-more-menu" hidden>
<p class="ge-wand-tip">Draw over an object; the SAM model selects the whole object. Hold Alt to mark pixels to exclude. Shift adds, Esc clears.</p>
</div>
</span>
<div class="ge-control-row ge-actions" style="margin-top:2px;flex-wrap:wrap;">
<button class="ge-btn ge-btn-sm ge-mask-vis-btn visible" id="ge-objectselect-vis" title="Hide selection overlay" aria-label="Toggle selection overlay">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
<button class="ge-btn ge-btn-sm" id="ge-objectselect-clear" title="Clear selection" aria-label="Clear selection">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-objectselect-invert" title="Invert selection (Ctrl+Alt+Shift+I)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 1 0 18z" fill="currentColor" stroke="none"/></svg>
Invert
</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-objectselect-delete" title="Erase selected pixels">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7 21-4-4a2 2 0 0 1 0-3l9-9a2 2 0 0 1 3 0l4 4a2 2 0 0 1 0 3l-9 9a2 2 0 0 1-3 0Z"/><path d="M22 21H7"/></svg>
Erase
</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-objectselect-copy" title="Copy selection to a new layer">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
Copy Layer
</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-objectselect-mask" title="Add selection to mask">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="5"/></svg>
To Mask
</button>
<button type="button" class="ge-btn ge-btn-sm ge-btn-iconlabel ge-quick-mask-toggle" title="Edit selection as Quick Mask (Q)" aria-pressed="false"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9" stroke-dasharray="3 3"/><circle cx="12" cy="12" r="3.5"/></svg>Quick Mask</button>
</div> </div>
<p style="font-size:9px;opacity:0.4;margin:4px 0 0;">Click an object, or type a neutral object label. Shift adds, Alt subtracts.</p>
</div> </div>
<div class="ge-inpaint-section" id="ge-inpaint-section" style="display:none;"> <div class="ge-inpaint-section" id="ge-inpaint-section" style="display:none;">
<div class="ge-inpaint-popover-head" data-inpaint-drag> <div class="ge-inpaint-popover-head" data-inpaint-drag>
@@ -448,72 +733,19 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
</p> </p>
<div class="ge-clone-source-status" id="ge-clone-source-status" aria-live="polite"> <div class="ge-clone-source-status" id="ge-clone-source-status" aria-live="polite">
<span id="ge-clone-source-label">No source selected</span> <span id="ge-clone-source-label">No source selected</span>
<button type="button" class="ge-btn ge-btn-sm ge-icon-btn" id="ge-clone-source-clear" title="Clear sampled source" aria-label="Clear sampled source">×</button> <button type="button" class="ge-btn ge-btn-sm" id="ge-clone-source-clear" title="Clear sampled source" aria-label="Clear sampled source">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg>
</button>
</div> </div>
<div class="ge-control-row"> <div class="ge-control-row">
<label for="ge-clone-sample-mode">Sample</label> <label for="ge-clone-sample-mode">Sample</label>
<select id="ge-clone-sample-mode"> <select id="ge-clone-sample-mode" class="ge-ctl-select">
<option value="active-layer">Active layer</option> <option value="active-layer">Active layer</option>
<option value="composite">All visible layers</option> <option value="composite">All visible layers</option>
</select> </select>
</div> </div>
<div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-clone-preview-opacity" aria-hidden="true"></span>
<label>Opacity <span id="ge-clone-opacity-label">100%</span></label>
<input type="range" id="ge-clone-opacity" min="10" max="100" value="100" />
</div>
<div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-clone-preview-flow" aria-hidden="true"></span>
<label>Flow <span id="ge-clone-flow-label">100%</span></label>
<input type="range" id="ge-clone-flow" min="5" max="100" value="100" />
</div>
<div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-clone-preview-softness" aria-hidden="true"></span>
<label>Softness <span id="ge-clone-softness-label">100%</span></label>
<input type="range" id="ge-clone-softness" min="0" max="300" value="100" title="Soft brush edge — blurs each stamp for a feathered fade." />
</div>
</div>
<div class="ge-eraser-section" id="ge-brush-section" style="display:none;">
<div class="ge-section-title"><span>Brush</span></div>
<div class="ge-control-row ge-eraser-row" id="ge-smudge-strength-row" style="display:none;">
<span class="ge-eraser-preview" id="ge-smudge-preview-strength" aria-hidden="true"></span>
<label>Strength <span id="ge-smudge-strength-label">65%</span></label>
<input type="range" id="ge-smudge-strength" min="5" max="100" value="65" title="How strongly Smudge carries sampled pixels into the stroke." />
</div>
<div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-brush-preview-opacity" aria-hidden="true"></span>
<label>Opacity <span id="ge-brush-opacity-label">100%</span></label>
<input type="range" id="ge-brush-opacity" min="10" max="100" value="100" />
</div>
<div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-brush-preview-flow" aria-hidden="true"></span>
<label>Flow <span id="ge-brush-flow-label">100%</span></label>
<input type="range" id="ge-brush-flow" min="5" max="100" value="100" />
</div>
<div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-brush-preview-softness" aria-hidden="true"></span>
<label>Softness <span id="ge-brush-softness-label">100%</span></label>
<input type="range" id="ge-brush-softness" min="0" max="300" value="100" title="Soft brush edge — blurs the stroke's alpha for a feathered fade at the perimeter." />
</div>
</div>
<div class="ge-eraser-section" id="ge-eraser-section" style="display:none;">
<div class="ge-section-title">Eraser</div>
<div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-eraser-preview-opacity" aria-hidden="true"></span>
<label>Opacity <span id="ge-eraser-opacity-label">100%</span></label>
<input type="range" id="ge-eraser-opacity" min="10" max="100" value="100" />
</div>
<div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-eraser-preview-flow" aria-hidden="true"></span>
<label>Flow <span id="ge-eraser-flow-label">100%</span></label>
<input type="range" id="ge-eraser-flow" min="5" max="100" value="100" />
</div>
<div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-eraser-preview-softness" aria-hidden="true"></span>
<label>Softness <span id="ge-eraser-softness-label">100%</span></label>
<input type="range" id="ge-eraser-softness" min="0" max="300" value="100" title="Soft brush edge — blurs the stroke's alpha so the eraser fades out at the perimeter." />
</div>
</div> </div>
<div class="ge-transform-section" id="ge-transform-section" style="display:none;"></div>
<div class="ge-sharpen-section" id="ge-sharpen-section" style="display:none;"> <div class="ge-sharpen-section" id="ge-sharpen-section" style="display:none;">
<div class="ge-control-row ge-eraser-row"> <div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-sharpen-preview" aria-hidden="true"></span> <span class="ge-eraser-preview" id="ge-sharpen-preview" aria-hidden="true"></span>
@@ -619,27 +851,6 @@ export function layerPanelHTML() {
return `<div class="ge-layers-header"> return `<div class="ge-layers-header">
<span class="ge-layers-grab"></span> <span class="ge-layers-grab"></span>
<span class="ge-layers-title">Layers</span> <span class="ge-layers-title">Layers</span>
<div class="ge-layer-header-tools" role="group" aria-label="Layer actions">
<button type="button" class="ge-btn ge-btn-sm ge-icon-btn" id="ge-add-layer" title="New layer" aria-label="New layer">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>
</button>
<button class="ge-btn ge-btn-sm ge-icon-btn" id="ge-merge-down" title="Merge down" aria-label="Merge down">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="6 13 12 19 18 13"/></svg>
</button>
<button class="ge-btn ge-btn-sm ge-icon-btn" id="ge-merge-all" title="Merge all" aria-label="Merge all">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v6M9 6l3-3 3 3M3 14h18M12 14v7M9 18l3 3 3-3"/></svg>
</button>
<button class="ge-btn ge-btn-sm ge-icon-btn" id="ge-flatten" title="Flatten copy (keeps originals)" aria-label="Flatten copy">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 L4 6 L4 18 L12 22 L20 18 L20 6 Z"/><path d="M12 2 L12 22"/><path d="M4 6 L20 6"/><path d="M4 18 L20 18"/></svg>
</button>
<button class="ge-btn ge-btn-sm ge-icon-btn" id="ge-select-all-layers" title="Select all layers" aria-label="Select all layers">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="7" width="13" height="13" rx="1"/><path d="M4 16H3V3h13v1"/></svg>
</button>
<button class="ge-btn ge-btn-sm ge-icon-btn" id="ge-group-selected" title="Group selected layers" aria-label="Group selected layers">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h7l2 2h9v10H3z"/></svg>
</button>
<button class="ge-btn ge-btn-sm" id="ge-layer-fill" title="Fill selected layer or selection with foreground color (Alt+Backspace)">Fill</button>
</div>
</div> </div>
<div class="ge-layer-blend-row"> <div class="ge-layer-blend-row">
<label for="ge-layer-blend">Blend</label> <label for="ge-layer-blend">Blend</label>
@@ -660,7 +871,21 @@ export function layerPanelHTML() {
<option value="saturation">Saturation</option> <option value="saturation">Saturation</option>
<option value="color">Color</option> <option value="color">Color</option>
<option value="luminosity">Luminosity</option> <option value="luminosity">Luminosity</option>
<option value="dissolve">Dissolve</option>
<option value="linear-burn">Linear Burn</option>
<option value="linear-dodge">Linear Dodge (Add)</option>
<option value="darker-color">Darker Color</option>
<option value="lighter-color">Lighter Color</option>
<option value="vivid-light">Vivid Light</option>
<option value="linear-light">Linear Light</option>
<option value="pin-light">Pin Light</option>
<option value="hard-mix">Hard Mix</option>
<option value="subtract">Subtract</option>
<option value="divide">Divide</option>
</select> </select>
<label class="ge-layer-opacity-head" for="ge-layer-header-opacity" title="Active layer opacity">Opacity
<input type="number" id="ge-layer-header-opacity" min="0" max="100" step="1" value="100" />
</label>
</div> </div>
<div class="ge-layer-selection-bar" id="ge-layer-selection-bar" hidden> <div class="ge-layer-selection-bar" id="ge-layer-selection-bar" hidden>
<span id="ge-layer-selection-count">2 layers</span> <span id="ge-layer-selection-count">2 layers</span>
@@ -676,5 +901,31 @@ export function layerPanelHTML() {
<button class="ge-icon-btn danger" id="ge-selected-delete" title="Delete selected layers" aria-label="Delete selected layers">×</button> <button class="ge-icon-btn danger" id="ge-selected-delete" title="Delete selected layers" aria-label="Delete selected layers">×</button>
</div> </div>
<div class="ge-layers-list" id="ge-layers-list"></div> <div class="ge-layers-list" id="ge-layers-list"></div>
<div class="ge-layer-tools" id="ge-layer-tools" aria-label="Selected layer tools"></div>`; <div class="ge-layers-footer">
<div class="ge-layer-header-tools" role="group" aria-label="Layer actions">
<button type="button" class="ge-btn ge-btn-sm ge-icon-btn" id="ge-add-layer" title="New layer" aria-label="New layer">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>
</button>
<button type="button" class="ge-btn ge-btn-sm ge-icon-btn ge-add-fx" id="ge-add-fx" title="New adjustment (fx) layer" aria-label="New adjustment layer">
<span class="ge-add-fx-glyph" aria-hidden="true">fx</span>
</button>
<button class="ge-btn ge-btn-sm ge-icon-btn" id="ge-merge-down" title="Merge down" aria-label="Merge down">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="6 13 12 19 18 13"/></svg>
</button>
<button class="ge-btn ge-btn-sm ge-icon-btn" id="ge-merge-all" title="Merge all" aria-label="Merge all">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v6M9 6l3-3 3 3M3 14h18M12 14v7M9 18l3 3 3-3"/></svg>
</button>
<button class="ge-btn ge-btn-sm ge-icon-btn" id="ge-flatten" title="Flatten copy (keeps originals)" aria-label="Flatten copy">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 L4 6 L4 18 L12 22 L20 18 L20 6 Z"/><path d="M12 2 L12 22"/><path d="M4 6 L20 6"/><path d="M4 18 L20 18"/></svg>
</button>
<button class="ge-btn ge-btn-sm ge-icon-btn" id="ge-select-all-layers" title="Select all layers" aria-label="Select all layers">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="7" width="13" height="13" rx="1"/><path d="M4 16H3V3h13v1"/></svg>
</button>
<button class="ge-btn ge-btn-sm ge-icon-btn" id="ge-group-selected" title="Group selected layers" aria-label="Group selected layers">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h7l2 2h9v10H3z"/></svg>
</button>
<button class="ge-btn ge-btn-sm" id="ge-layer-fill" title="Fill selected layer or selection with foreground color (Alt+Backspace)">Fill</button>
</div>
<div class="ge-layer-tools" id="ge-layer-tools" aria-label="Selected layer tools"></div>
</div>`;
} }
+551
View File
@@ -0,0 +1,551 @@
/**
* Docked panel stack (PhotoCraft-parity phase 0/3).
*
* Wraps a list of existing panel elements into a Photoshop-style dock:
* several panel GROUPS are visible at once, stacked vertically. Each
* group has clickable tabs (one panel visible per group), a collapse
* control that shrinks it to its tab strip, and a drag divider so the
* user can resize the group above.
*
* Purely presentational: it never owns editor state. Each entry is an
* element that already exists elsewhere (so all existing ids, classes
* and event listeners keep working). Switching a tab only toggles the
* `hidden` attribute on the panel wrapper.
*
* A compact `<select>` variant (`compact: true`) is kept as a fallback
* for narrow columns where a multi-group stack will not fit.
*
* @param {{
* entries: Array<{id: string, label: string, element: HTMLElement, group?: string}>,
* persistKey?: string,
* defaultId?: string,
* compact?: boolean,
* }} opts
* @returns {{ dock: HTMLDivElement, show: (id: string) => void, readonly activeId: string|null }}
*/
export function buildDock({ entries = [], persistKey = 'ge-dock-active', defaultId = null, compact = false, onCloseGroup = null } = {}) {
const dock = document.createElement('div');
dock.className = 'ge-dock';
if (compact) dock.classList.add('ge-dock--compact');
// Collect entries into ordered groups (Photoshop panel groups).
const groups = [];
entries.forEach(entry => {
const name = entry.group || 'Panels';
let group = groups.find(g => g.name === name);
if (!group) {
group = { name, entries: [], size: entry.groupSize || null, collapsed: !!entry.groupCollapsed };
groups.push(group);
}
group.entries.push(entry);
});
const groupOfId = new Map();
groups.forEach(group => group.entries.forEach(e => groupOfId.set(e.id, group)));
let saved = {};
try { saved = JSON.parse(localStorage.getItem(persistKey) || '{}') || {}; } catch {}
const activeByGroup = new Map();
groups.forEach(group => {
const fallback = group.entries[0]?.id || null;
const savedId = saved[group.name];
const pick = (savedId && group.entries.some(e => e.id === savedId)) ? savedId
: (defaultId && group.entries.some(e => e.id === defaultId)) ? defaultId
: fallback;
activeByGroup.set(group.name, pick);
});
let activeId = null;
const persist = () => {
const out = {};
activeByGroup.forEach((v, k) => { if (v) out[k] = v; });
try { localStorage.setItem(persistKey, JSON.stringify(out)); } catch {}
};
const groupEls = new Map();
const detachPlacers = new Map();
const activateInGroup = (group, id) => {
activeByGroup.set(group.name, id);
activeId = id;
groupEls.get(group.name)?.querySelectorAll('.ge-dock-tab').forEach(tab => {
const on = tab.dataset.panel === id;
tab.classList.toggle('active', on);
tab.setAttribute('aria-selected', String(on));
});
group.entries.forEach(entry => {
const panel = groupEls.get(group.name)?.querySelector(`.ge-dock-panel[data-panel="${entry.id}"]`);
if (panel) panel.hidden = entry.id !== id;
});
// Keep the group's undock control anchored beside the active tab.
detachPlacers.get(group.name)?.();
persist();
};
function show(id) {
const group = groupOfId.get(id);
if (!group) return;
groupEls.get(group.name)?.classList.remove('is-collapsed');
activateInGroup(group, id);
}
// A dock tab plus a small "undock" control (Photoshop lets you tear a single
// panel tab out of its group). Used both for the initial build and when a
// floated panel is re-docked.
function makeTabItem(group, entry, groupEl) {
const item = document.createElement('div');
item.className = 'ge-dock-tab-item';
item.dataset.panel = entry.id;
const tab = document.createElement('button');
tab.type = 'button';
tab.className = 'ge-dock-tab';
tab.dataset.panel = entry.id;
tab.textContent = entry.label;
tab.setAttribute('role', 'tab');
tab.setAttribute('aria-selected', 'false');
tab.title = entry.label;
tab.addEventListener('click', () => {
groupEl.classList.remove('is-collapsed');
activateInGroup(group, entry.id);
});
item.appendChild(tab);
return item;
}
// Float a single panel into its own draggable window (Photoshop undock-tab).
function floatPanel(group, entry) {
const groupEl = groupEls.get(group.name);
if (!groupEl) return;
const panelEl = groupEl.querySelector(`.ge-dock-panel[data-panel="${entry.id}"]`);
if (!panelEl || panelEl.closest('.ge-float-window')) return;
const win = document.createElement('div');
win.className = 'ge-float-window';
win.innerHTML = `
<div class="ge-float-head">
<span class="ge-float-title">${entry.label}</span>
<span class="ge-float-btns">
<button class="ge-float-dock" type="button" title="Dock panel" aria-label="Dock panel">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M15 3v18"/></svg>
</button>
</span>
</div>
<div class="ge-float-body"></div>`;
win.querySelector('.ge-float-body').appendChild(panelEl);
panelEl.hidden = false;
document.body.appendChild(win);
const r = groupEl.getBoundingClientRect();
win.style.left = `${Math.max(8, Math.min(window.innerWidth - 240, r.left - 260))}px`;
win.style.top = `${Math.max(8, r.top)}px`;
makeFloatDraggable(win, win.querySelector('.ge-float-head'));
const tabsEl = groupEl.querySelector('.ge-dock-tabs');
const itemEl = tabsEl?.querySelector(`.ge-dock-tab-item[data-panel="${entry.id}"]`);
itemEl?.remove();
const wasActive = activeId === entry.id;
win.querySelector('.ge-float-dock').addEventListener('click', () => {
const bodyEl = groupEl.querySelector('.ge-dock-panels');
if (bodyEl) bodyEl.appendChild(panelEl); else groupEl.appendChild(panelEl);
panelEl.hidden = activeId !== entry.id;
win.remove();
if (tabsEl) tabsEl.appendChild(makeTabItem(group, entry, groupEl));
if (wasActive) activateInGroup(group, entry.id);
detachPlacers.get(group.name)?.();
syncTabsOverflow(groupEl);
});
if (wasActive) {
const next = group.entries.find(e => {
const p = groupEl.querySelector(`.ge-dock-panel[data-panel="${e.id}"]`);
return p && !p.closest('.ge-float-window');
});
if (next) activateInGroup(group, next.id);
}
syncTabsOverflow(groupEl);
}
if (compact) {
const tabbar = document.createElement('div');
tabbar.className = 'ge-dock-tabbar';
const select = document.createElement('select');
select.className = 'ge-dock-select';
select.setAttribute('aria-label', 'Editor panel');
const optgroups = new Map();
entries.forEach(({ id, label, group }) => {
const option = document.createElement('option');
option.value = id;
option.textContent = label;
if (group) {
let optgroup = optgroups.get(group);
if (!optgroup) {
optgroup = document.createElement('optgroup');
optgroup.label = group;
optgroups.set(group, optgroup);
select.appendChild(optgroup);
}
optgroup.appendChild(option);
} else {
select.appendChild(option);
}
});
tabbar.appendChild(select);
const panels = document.createElement('div');
panels.className = 'ge-dock-panels';
entries.forEach(({ id, element }) => {
const panel = document.createElement('div');
panel.className = 'ge-dock-panel';
panel.dataset.panel = id;
if (element) panel.appendChild(element);
panels.appendChild(panel);
});
dock.appendChild(tabbar);
dock.appendChild(panels);
const applyFlat = (id) => {
activeId = id;
panels.querySelectorAll('.ge-dock-panel').forEach(p => { p.hidden = p.dataset.panel !== id; });
const group = groupOfId.get(id);
if (group) activeByGroup.set(group.name, id);
persist();
};
select.addEventListener('change', () => applyFlat(select.value));
const initial = defaultId || entries[0]?.id || null;
if (initial) { select.value = initial; applyFlat(initial); }
return { dock, show, get activeId() { return activeId; } };
}
// Stacked mode: one `.ge-dock-group` per panel group, with a drag
// divider between groups (skip a leading divider).
groups.forEach((group, groupIndex) => {
if (groupIndex > 0) {
const divider = document.createElement('div');
divider.className = 'ge-dock-divider';
divider.setAttribute('role', 'separator');
divider.setAttribute('aria-orientation', 'horizontal');
divider.title = 'Drag to resize · double-click to expand/collapse a panel group';
dock.appendChild(divider);
wireDivider(divider, groupIndex);
}
const groupEl = document.createElement('div');
groupEl.className = 'ge-dock-group';
groupEl.dataset.group = group.name;
if (group.size) {
groupEl.style.flex = `0 0 ${group.size}px`;
groupEl.style.height = `${group.size}px`;
}
if (group.collapsed) groupEl.classList.add('is-collapsed');
const head = document.createElement('div');
head.className = 'ge-dock-group-head';
const tabs = document.createElement('div');
tabs.className = 'ge-dock-tabs';
tabs.setAttribute('role', 'tablist');
group.entries.forEach(entry => {
tabs.appendChild(makeTabItem(group, entry, groupEl));
});
const collapse = document.createElement('button');
collapse.type = 'button';
collapse.className = 'ge-dock-collapse';
collapse.title = 'Collapse panel group';
collapse.setAttribute('aria-label', 'Collapse panel group');
collapse.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>';
collapse.addEventListener('click', () => {
const collapsed = groupEl.classList.toggle('is-collapsed');
collapse.title = collapsed ? 'Expand panel group' : 'Collapse panel group';
collapse.setAttribute('aria-label', collapse.title);
});
// Overflow affordance: when a group's tabs do not fit the column,
// show a chevron that opens a menu of every tab so none become
// unreachable or unreadable (Photoshop-style tab overflow).
const tabsMore = document.createElement('button');
tabsMore.type = 'button';
tabsMore.className = 'ge-dock-tabs-more';
tabsMore.title = 'More panels';
tabsMore.setAttribute('aria-label', 'More panels');
tabsMore.hidden = true;
tabsMore.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><circle cx="6" cy="12" r="1.9"/><circle cx="12" cy="12" r="1.9"/><circle cx="18" cy="12" r="1.9"/></svg>';
tabsMore.addEventListener('click', (e) => {
e.stopPropagation();
openTabsMenu(tabsMore, group, groupEl);
});
head.appendChild(tabs);
head.appendChild(tabsMore);
head.appendChild(collapse);
const body = document.createElement('div');
body.className = 'ge-dock-panels';
group.entries.forEach(entry => {
const panel = document.createElement('div');
panel.className = 'ge-dock-panel';
panel.dataset.panel = entry.id;
if (entry.element) panel.appendChild(entry.element);
body.appendChild(panel);
});
groupEl.appendChild(head);
groupEl.appendChild(body);
dock.appendChild(groupEl);
groupEls.set(group.name, groupEl);
syncTabsOverflow(groupEl);
});
// Re-evaluate tab overflow whenever the column width changes, and keep any
// torn-off (floating) panels on-screen after a viewport resize so they never
// drift off the edge or vanish.
window.addEventListener('resize', () => {
groupEls.forEach(el => syncTabsOverflow(el));
document.querySelectorAll('.ge-float-window').forEach((w) => {
const r = w.getBoundingClientRect();
const maxL = Math.max(8, window.innerWidth - r.width - 8);
const maxT = Math.max(8, window.innerHeight - 48);
w.style.left = Math.min(Math.max(8, r.left), maxL) + 'px';
w.style.top = Math.min(Math.max(8, r.top), maxT) + 'px';
});
});
// The dock is built before it is attached to the DOM (so its width is 0 at
// build time and the overflow check above cannot see the real column width).
// Observe size changes to recompute overflow once it is laid out and whenever
// the panel is resized, collapsed or expanded — otherwise a tab strip that
// overflows keeps the chevron hidden and clips its trailing labels.
if (typeof ResizeObserver !== 'undefined') {
const ro = new ResizeObserver(() => groupEls.forEach(el => syncTabsOverflow(el)));
ro.observe(dock);
}
requestAnimationFrame(() => groupEls.forEach(el => syncTabsOverflow(el)));
function syncTabsOverflow(groupEl) {
const tabsEl = groupEl.querySelector('.ge-dock-tabs');
const moreEl = groupEl.querySelector('.ge-dock-tabs-more');
if (!tabsEl || !moreEl) return;
const items = Array.from(tabsEl.querySelectorAll('.ge-dock-tab-item'));
// Reset any previous hiding so widths are measured at their natural size.
items.forEach(item => { item.style.display = ''; });
const overflowing = tabsEl.scrollWidth - tabsEl.clientWidth > 1;
// Keep the menu reachable even when the tabs fit, so its "Close <group>"
// action (and every tab) stays available for every section.
moreEl.hidden = !(overflowing || typeof onCloseGroup === 'function');
if (!overflowing) return;
// Photoshop-style tab overflow: rather than clipping a label mid-word
// ("PATTERNS" -> "P…", "CHARACTER" -> "CHARA"), keep only the tabs that
// fit whole and let the chevron expose the rest. The active tab is always
// kept visible so the current panel never loses its anchor.
const active = tabsEl.querySelector('.ge-dock-tab.active');
const activeItem = active ? active.closest('.ge-dock-tab-item') : null;
const limit = tabsEl.clientWidth;
let gap = 0;
try { gap = parseFloat(getComputedStyle(tabsEl).columnGap) || 0; } catch {}
// Keep the leading run of tabs that fits, then tuck the trailing tabs
// (and everything after the first that no longer fits) into the chevron.
// The active tab is always kept visible so the current panel stays anchored.
let used = 0;
let cutting = false;
items.forEach(item => {
const w = item.getBoundingClientRect().width;
if (item === activeItem || (!cutting && used + w <= limit + 0.5)) {
used += w + gap;
} else {
cutting = true;
item.style.display = 'none';
}
});
}
let tabsMenu = null;
function closeTabsMenu() {
if (!tabsMenu) return;
tabsMenu.remove();
tabsMenu = null;
document.removeEventListener('pointerdown', onTabsMenuDocDown, true);
}
function onTabsMenuDocDown(e) {
if (tabsMenu && !tabsMenu.contains(e.target) && !e.target.closest?.('.ge-dock-tabs-more')) closeTabsMenu();
}
function openTabsMenu(anchor, group, groupEl) {
closeTabsMenu();
tabsMenu = document.createElement('div');
tabsMenu.className = 'ge-dock-tabs-menu';
group.entries.forEach(entry => {
const item = document.createElement('button');
item.type = 'button';
item.className = 'ge-dock-tabs-menu-item';
item.textContent = entry.label;
item.addEventListener('click', () => {
groupEl.classList.remove('is-collapsed');
activateInGroup(group, entry.id);
syncTabsOverflow(groupEl);
closeTabsMenu();
});
tabsMenu.appendChild(item);
});
// Close the whole section (panel group) from the same menu — Photoshop's
// "Close Tab Group". Only offered when the host supplies a handler.
if (typeof onCloseGroup === 'function') {
const sep = document.createElement('div');
sep.className = 'ge-dock-tabs-menu-sep';
tabsMenu.appendChild(sep);
const closeItem = document.createElement('button');
closeItem.type = 'button';
closeItem.className = 'ge-dock-tabs-menu-item ge-dock-tabs-menu-close';
closeItem.textContent = `Close ${group.name}`;
closeItem.addEventListener('click', () => {
closeTabsMenu();
try { onCloseGroup(group.name); } catch (_) {}
});
tabsMenu.appendChild(closeItem);
}
document.body.appendChild(tabsMenu);
const r = anchor.getBoundingClientRect();
tabsMenu.style.left = `${Math.max(4, Math.min(window.innerWidth - 160, r.left))}px`;
tabsMenu.style.top = `${r.bottom + 2}px`;
document.addEventListener('pointerdown', onTabsMenuDocDown, true);
}
function wireDivider(divider, groupIndex) {
const groupEl = () => dock.querySelectorAll('.ge-dock-group')[groupIndex - 1];
let startY = 0;
let startH = 0;
let resizing = false;
let moved = false;
divider.addEventListener('pointerdown', (event) => {
const target = groupEl();
if (!target) return;
resizing = true;
moved = false;
startY = event.clientY;
startH = target.getBoundingClientRect().height;
divider.setPointerCapture(event.pointerId);
document.body.style.cursor = 'ns-resize';
event.preventDefault();
});
divider.addEventListener('pointermove', (event) => {
if (!resizing) return;
const target = groupEl();
if (!target) return;
// A real drag supersedes any double-click expand state (a plain click,
// which a double-click fires, must not, or the toggle would never fire).
if (!moved) { moved = true; target.dataset.dockExpanded = '0'; target.classList.remove('is-expanded'); }
const next = Math.max(28, startH + (event.clientY - startY));
// Fixed basis (no grow/shrink): a `1 1` basis lets the height-constrained
// dock shrink the dragged group straight back to 0, so the divider looked
// dead. Keeping it `0 0` holds the size and lets the dock scroll instead.
target.style.flex = `0 0 ${Math.round(next)}px`;
target.style.height = `${Math.round(next)}px`;
});
const end = (event) => {
if (!resizing) return;
resizing = false;
document.body.style.cursor = '';
try { divider.releasePointerCapture?.(event.pointerId); } catch {}
};
divider.addEventListener('pointerup', end);
divider.addEventListener('pointercancel', end);
// Double-click toggles the group above between "show everything" and the
// previous size. Handy on a cramped sidebar: one double-click unrolls the
// whole group (its body is normally height-clamped and scrolls), another
// restores exactly what it was.
divider.addEventListener('dblclick', (event) => {
event.preventDefault();
const target = groupEl();
if (!target) return;
const body = target.querySelector('.ge-dock-panels');
if (target.dataset.dockExpanded === '1') {
const prev = parseFloat(target.dataset.dockPrevSize || '');
const restore = Number.isFinite(prev) && prev > 0 ? prev : 200;
target.style.flex = `0 0 ${Math.round(restore)}px`;
target.style.height = `${Math.round(restore)}px`;
target.dataset.dockExpanded = '0';
target.classList.remove('is-expanded');
return;
}
const head = target.querySelector('.ge-dock-tabs');
const headH = head ? head.getBoundingClientRect().height : 0;
const bodyH = body ? body.scrollHeight : 0;
const full = Math.ceil(headH + bodyH + 2);
target.dataset.dockPrevSize = String(Math.round(target.getBoundingClientRect().height));
target.classList.remove('is-collapsed');
target.style.flex = `0 0 ${full}px`;
target.style.height = `${full}px`;
target.dataset.dockExpanded = '1';
target.classList.add('is-expanded');
});
}
// Move a panel group out of the dock into a floating, draggable
// window; the window offers a "Dock" control that returns the exact
// same `.ge-dock-group` node (DOM identity preserved) to its original
// position in the stack.
function detachGroup(group, groupEl, dockEl) {
if (groupEl.classList.contains('is-floating')) return;
const prevDivider = groupEl.previousElementSibling;
const hadDivider = !!prevDivider && prevDivider.classList.contains('ge-dock-divider');
if (hadDivider) prevDivider.hidden = true;
const dockIndex = Array.from(dockEl.querySelectorAll('.ge-dock-group')).indexOf(groupEl);
groupEl.classList.add('is-floating');
const win = document.createElement('div');
win.className = 'ge-float-window';
win.dataset.group = group.name;
win.innerHTML = `
<div class="ge-float-head">
<span class="ge-float-title">${group.name}</span>
<span class="ge-float-btns">
<button class="ge-float-dock" type="button" title="Dock panel group" aria-label="Dock panel group">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M15 3v18"/></svg>
</button>
</span>
</div>
<div class="ge-float-body"></div>`;
win.querySelector('.ge-float-body').appendChild(groupEl);
document.body.appendChild(win);
const r = groupEl.getBoundingClientRect();
win.style.left = `${Math.max(8, Math.min(window.innerWidth - 260, r.left - 60))}px`;
win.style.top = `${Math.max(8, r.top)}px`;
makeFloatDraggable(win, win.querySelector('.ge-float-head'));
win.querySelector('.ge-float-dock').addEventListener('click', () => {
const groupsNow = Array.from(dockEl.querySelectorAll('.ge-dock-group'));
const anchor = groupsNow[dockIndex] || null;
if (anchor) dockEl.insertBefore(groupEl, anchor); else dockEl.appendChild(groupEl);
if (hadDivider) prevDivider.hidden = false;
groupEl.classList.remove('is-floating');
win.remove();
});
}
function makeFloatDraggable(win, handle) {
if (!handle) return;
handle.style.touchAction = 'none';
handle.addEventListener('pointerdown', (e) => {
if (e.target.closest('button')) return;
e.preventDefault();
const startX = e.clientX, startY = e.clientY;
const r0 = win.getBoundingClientRect();
try { handle.setPointerCapture(e.pointerId); } catch {}
handle.style.cursor = 'grabbing';
const onMove = (ev) => {
win.style.left = `${Math.max(0, Math.min(window.innerWidth - 80, r0.left + (ev.clientX - startX)))}px`;
win.style.top = `${Math.max(0, Math.min(window.innerHeight - 30, r0.top + (ev.clientY - startY)))}px`;
};
const onUp = () => {
try { handle.releasePointerCapture(e.pointerId); } catch {}
handle.style.cursor = '';
handle.removeEventListener('pointermove', onMove);
handle.removeEventListener('pointerup', onUp);
};
handle.addEventListener('pointermove', onMove);
handle.addEventListener('pointerup', onUp);
});
}
groupEls.forEach((groupEl, name) => {
const group = groups.find(g => g.name === name);
const id = activeByGroup.get(name) || group?.entries[0]?.id;
if (id) activateInGroup(group, id);
});
return {
dock,
show,
get activeId() { return activeId; },
};
}
+274
View File
@@ -0,0 +1,274 @@
/**
* Contextual tool options bar — a Photoshop-style strip that sits
* directly under the topbar and shows quick controls for the active
* tool.
*
* This is a *mirror* of the authoritative controls in the right-hand
* panel: it never owns state. Every value is delegated to the existing
* input element (by selector) so the two views can never disagree —
* editing here re-dispatches the same `input`/`change` event the real
* control listens for, and the real control pushes updates back into
* the bar.
*
* On phones (<= 820px) the bar is hidden by CSS and the right-panel
* bottom sheet remains the single surface, exactly as before.
*/
import { TOOLS } from './toolbar.js?v=20261010brushpresets';
import { state } from '../state.js';
// Per-tool option sources. Each value is a CSS selector for an existing
// control inside the right panel; `null` means "no such control".
const BRUSH_TOOLS = {
brush: { size: '.ge-size-slider', opacity: '#ge-brush-opacity', flow: '#ge-brush-flow', softness: '#ge-brush-softness' },
smudge: { size: '.ge-size-slider', opacity: '#ge-brush-opacity', flow: '#ge-brush-flow', softness: '#ge-brush-softness' },
dodge: { size: '.ge-size-slider', opacity: '#ge-brush-opacity', flow: '#ge-brush-flow', softness: '#ge-brush-softness', intensity: '#ge-dodgeburn-intensity' },
burn: { size: '.ge-size-slider', opacity: '#ge-brush-opacity', flow: '#ge-brush-flow', softness: '#ge-brush-softness', intensity: '#ge-dodgeburn-intensity' },
eraser: { size: '.ge-size-slider', opacity: '#ge-eraser-opacity', flow: '#ge-eraser-flow', softness: '#ge-eraser-softness' },
clone: { size: '.ge-size-slider', opacity: '#ge-clone-opacity', flow: '#ge-clone-flow', softness: '#ge-clone-softness' },
heal: { size: '.ge-size-slider', opacity: '#ge-clone-opacity', flow: '#ge-clone-flow', softness: '#ge-clone-softness' },
};
const SELECTION_TOOLS = {
marquee: { modes: null, feather: null },
lasso: { modes: null, feather: '#ge-lasso-feather' },
wand: { modes: null, feather: '#ge-wand-feather' },
quickselect: { modes: null, feather: null },
objectselect: { modes: null, feather: null },
sam: { modes: null, feather: null },
};
// Liquify is not a brush: it exposes its own mode plus dab size and strength.
const LIQUIFY_TOOLS = {
liquify: { size: '#ge-liquify-size', strength: '#ge-liquify-strength' },
};
const INTERSECT_ICON = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" style="position:relative;top:2px"><circle cx="9" cy="12" r="6"/><circle cx="15" cy="12" r="6"/></svg>';
const MODE_LABELS = [['replace', 'New'], ['add', '+'], ['subtract', '−'], ['intersect', INTERSECT_ICON]];
function resolve(selector) {
if (!selector) return null;
return selector.startsWith('#')
? document.getElementById(selector.slice(1))
: document.querySelector(selector);
}
function makeRange(label, sourceSelector, { suffix = '' } = {}) {
const source = resolve(sourceSelector);
const value = source ? source.value : '0';
const min = source?.getAttribute('min') ?? '0';
const max = source?.getAttribute('max') ?? '100';
const step = source?.getAttribute('step') ?? '1';
return `
<div class="ge-opt" data-opt="range">
<label class="ge-opt-label">${label}<span class="ge-opt-value" data-opt-value>--</span></label>
<input type="range" data-opt-range data-src="${sourceSelector}" min="${min}" max="${max}" step="${step}" value="${value}" />
</div>`;
}
function makeSelect(label, sourceSelector) {
const source = resolve(sourceSelector);
const options = source
? [...source.options].map(o => `<option value="${o.value}">${o.textContent}</option>`).join('')
: '';
const value = source ? source.value : '';
return `
<div class="ge-opt" data-opt="select">
<label class="ge-opt-label">${label}</label>
<select data-opt-select data-src="${sourceSelector}">${options}</select>
</div>`;
}
function makeAutoSelect() {
return `
<label class="ge-opt ge-auto-select-option" data-opt="check">
<input type="checkbox" id="ge-auto-select-layer" class="ge-ctl-check" />
<span class="ge-opt-label">Auto-Select</span>
</label>
<div class="ge-opt" data-opt="select">
<label class="ge-opt-label" for="ge-auto-select-target">Target</label>
<select id="ge-auto-select-target">
<option value="layer">Layer</option>
<option value="group">Group</option>
</select>
</div>`;
}
function makeModes(sectionSelector) {
const buttons = MODE_LABELS.map(([mode, text]) =>
`<button type="button" class="ge-opt-mode" data-opt-mode="${mode}" data-section="${sectionSelector}">${text}</button>`
).join('');
return `<div class="ge-opt" data-opt="modes"><span class="ge-opt-label">Mode</span><span class="ge-opt-modes">${buttons}</span></div>`;
}
function buildToolControls(toolId) {
const parts = [];
if (BRUSH_TOOLS[toolId]) {
const src = BRUSH_TOOLS[toolId];
parts.push(makeRange('Size', src.size, { suffix: 'px' }));
parts.push(makeRange('Opacity', src.opacity));
parts.push(makeRange('Flow', src.flow));
parts.push(makeRange('Softness', src.softness));
if (src.intensity) parts.push(makeRange('Intensity', src.intensity));
} else if (SELECTION_TOOLS[toolId]) {
const src = SELECTION_TOOLS[toolId];
if (src.modes) parts.push(makeModes(src.modes));
if (src.feather) parts.push(makeRange('Feather', src.feather, { suffix: 'px' }));
} else if (LIQUIFY_TOOLS[toolId]) {
const src = LIQUIFY_TOOLS[toolId];
parts.push(makeSelect('Mode', '#ge-liquify-mode'));
parts.push(makeRange('Size', src.size, { suffix: 'px' }));
parts.push(makeRange('Strength', src.strength));
} else if (toolId === 'move') {
parts.push(makeAutoSelect());
}
if (!parts.length) {
return '';
}
return parts.join('');
}
function valueLabelOf(source, value) {
// Prefer the sibling label the right panel already maintains so the
// two surfaces always show the same formatted number.
const row = source?.closest?.('.ge-eraser-row, .ge-control-row');
const label = row?.querySelector('span[id$="-label"], span[id$="-opacity-label"], span[id$="-flow-label"], span[id$="-softness-label"]');
const text = label?.textContent?.trim();
return text || (typeof value === 'string' ? value : String(value));
}
/** Refresh every mirrored control inside `bar` from its source. */
export function syncOptionsBar(bar) {
if (!bar) return;
bar.querySelectorAll('[data-src]').forEach(el => {
const source = resolve(el.dataset.src);
if (!source) return;
if (el.tagName === 'SELECT') { el.value = source.value; return; }
el.value = source.value;
const field = el.closest('.ge-opt');
const out = field?.querySelector('[data-opt-value]');
if (out) out.textContent = valueLabelOf(source, source.value);
});
// Selection mode buttons.
bar.querySelectorAll('[data-opt-mode]').forEach(btn => {
const source = resolve(`${btn.dataset.section} [data-wand-mode="${btn.dataset.optMode}"]`);
btn.classList.toggle('active', !!source && source.classList.contains('active'));
});
// Move-tool auto-select (owned here rather than in the right panel).
const autoSelect = bar.querySelector('#ge-auto-select-layer');
if (autoSelect) autoSelect.checked = !!state.autoSelectLayer;
const target = bar.querySelector('#ge-auto-select-target');
if (target) target.value = state.autoSelectTarget === 'group' ? 'group' : 'layer';
}
/** Render the controls for `toolId` and wire delegation + mirroring. */
export function renderOptionsBar(bar, toolId) {
if (!bar) return;
bar.dataset.optionsTool = toolId;
const hasControls = !!(BRUSH_TOOLS[toolId] || SELECTION_TOOLS[toolId] || LIQUIFY_TOOLS[toolId] || toolId === 'gradient' || toolId === 'move');
// Keep the strip mounted at a stable height for every tool. Hiding it
// for tools without options reflows the centered canvas-area and makes
// the canvas jump vertically when switching tools; an empty strip is
// visually harmless.
bar.classList.toggle('ge-options-empty', !hasControls);
// Brush-family tools now expose their secondary rows (spacing / smoothing /
// blend / pen pressure) through their own caret dropdown in the strip, so
// the generic "..." overflow button is retired for every tool.
const moreBtn = bar.querySelector('#ge-options-more');
if (moreBtn) {
const showMore = false;
moreBtn.hidden = !showMore;
if (!showMore) {
bar.classList.remove('ge-more-open');
moreBtn.setAttribute('aria-expanded', 'false');
}
}
const icon = bar.querySelector('.ge-options-tool-icon');
const name = bar.querySelector('.ge-options-tool-name');
const meta = TOOLS[toolId];
if (name) name.textContent = meta?.label || toolId || '';
if (icon) icon.innerHTML = meta?.icon || '';
const body = bar.querySelector('.ge-options-body');
if (body) body.innerHTML = buildToolControls(toolId);
// Mirror right-panel changes back into the bar.
bar.querySelectorAll('[data-src]').forEach(el => {
const source = resolve(el.dataset.src);
if (!source || source.dataset.geOptMirror) return;
source.dataset.geOptMirror = '1';
source.addEventListener('input', () => syncOptionsBar(bar));
source.addEventListener('change', () => syncOptionsBar(bar));
});
syncOptionsBar(bar);
}
/**
* Build the (empty) options-bar shell. Call `renderOptionsBar` after
* the right-panel controls exist so delegation can resolve them.
*/
export function buildOptionsBar() {
const bar = document.createElement('div');
bar.className = 'ge-options-bar';
bar.id = 'ge-options-bar';
bar.innerHTML = `
<div class="ge-options-tool">
<span class="ge-options-tool-icon" aria-hidden="true"></span>
<span class="ge-options-tool-name"></span>
</div>
<div class="ge-options-sep" aria-hidden="true"></div>
<div class="ge-options-body"></div>
<button type="button" class="ge-options-more" id="ge-options-more" title="More options" aria-label="More options" aria-haspopup="true" aria-expanded="false" hidden>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/></svg>
</button>`;
const more = bar.querySelector('#ge-options-more');
more.addEventListener('click', (event) => {
event.stopPropagation();
const open = bar.classList.toggle('ge-more-open');
more.setAttribute('aria-expanded', open ? 'true' : 'false');
});
// Delegation: ranges + selects write through to their real control.
bar.addEventListener('input', event => {
const el = event.target.closest('[data-opt-range]');
if (!el) return;
const source = resolve(el.dataset.src);
if (!source) return;
source.value = el.value;
source.dispatchEvent(new Event('input', { bubbles: true }));
const out = el.closest('.ge-opt')?.querySelector('[data-opt-value]');
if (out) out.textContent = valueLabelOf(source, source.value);
});
bar.addEventListener('change', event => {
if (event.target.id === 'ge-auto-select-layer') {
state.autoSelectLayer = event.target.checked;
bar.dispatchEvent(new CustomEvent('ge-editor-prefs-change', { bubbles: true }));
return;
}
if (event.target.id === 'ge-auto-select-target') {
state.autoSelectTarget = event.target.value === 'group' ? 'group' : 'layer';
bar.dispatchEvent(new CustomEvent('ge-editor-prefs-change', { bubbles: true }));
return;
}
const el = event.target.closest('[data-opt-select]');
if (!el) return;
const source = resolve(el.dataset.src);
if (!source) return;
source.value = el.value;
source.dispatchEvent(new Event('change', { bubbles: true }));
});
bar.addEventListener('click', event => {
const btn = event.target.closest('[data-opt-mode]');
if (!btn) return;
const source = resolve(`${btn.dataset.section} [data-wand-mode="${btn.dataset.optMode}"]`);
source?.click();
syncOptionsBar(bar);
});
return bar;
}
+12
View File
@@ -34,6 +34,8 @@ export function shortcutsPopupHTML() {
<div><kbd>W</kbd> Wand</div> <div><kbd>W</kbd> Wand</div>
<div><kbd>${TOOL_SHORTCUTS.marquee}</kbd> Marquee</div> <div><kbd>${TOOL_SHORTCUTS.marquee}</kbd> Marquee</div>
<div><kbd>C</kbd> Crop</div> <div><kbd>C</kbd> Crop</div>
<div><kbd>${TOOL_SHORTCUTS.gradient}</kbd> Gradient</div>
<div><kbd>${TOOL_SHORTCUTS.zoom}</kbd> Zoom <span style="opacity:0.5">(Alt-click = zoom out)</span></div>
</div> </div>
<div class="ge-shortcuts-col"> <div class="ge-shortcuts-col">
<h5>Edit</h5> <h5>Edit</h5>
@@ -62,6 +64,13 @@ export function shortcutsPopupHTML() {
<div><kbd>]</kbd> Brush size +</div> <div><kbd>]</kbd> Brush size +</div>
<div>Drag tolerance slider → live wand retune</div> <div>Drag tolerance slider → live wand retune</div>
</div> </div>
<div class="ge-shortcuts-col">
<h5>Touch</h5>
<div>Pinch → Zoom</div>
<div>Two-finger drag → Pan</div>
<div>Two-finger tap → Undo</div>
<div>Three-finger tap → Redo</div>
</div>
</div> </div>
<div style="margin-top:8px;font-size:10px;opacity:0.5;text-align:center;">Press <kbd>?</kbd> or click the keyboard icon to toggle.</div> <div style="margin-top:8px;font-size:10px;opacity:0.5;text-align:center;">Press <kbd>?</kbd> or click the keyboard icon to toggle.</div>
`; `;
@@ -81,6 +90,7 @@ export function historyPanelHTML(historyIcon) {
<button class="ge-adj-min" type="button" title="Minimise">&minus;</button> <button class="ge-adj-min" type="button" title="Minimise">&minus;</button>
</span> </span>
</div> </div>
<div class="ge-history-snapshots" id="ge-history-snapshots"></div>
<div class="ge-history-list" id="ge-history-list"></div> <div class="ge-history-list" id="ge-history-list"></div>
`; `;
} }
@@ -95,6 +105,7 @@ export function canvasSizePromptHTML() {
<div class="modal-content ge-canvas-prompt"> <div class="modal-content ge-canvas-prompt">
<div class="modal-header"><h4 id="ge-canvas-prompt-title">New project</h4></div> <div class="modal-header"><h4 id="ge-canvas-prompt-title">New project</h4></div>
<div class="modal-body"> <div class="modal-body">
<div class="ge-canvas-preview" hidden><img id="ge-canvas-prompt-preview" alt="Image preview" /></div>
<div class="ge-canvas-prompt-section-label"><svg class="ge-canvas-prompt-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"></rect><path d="M3 9h18M9 3v18"></path></svg><span>Resolution templates</span></div> <div class="ge-canvas-prompt-section-label"><svg class="ge-canvas-prompt-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"></rect><path d="M3 9h18M9 3v18"></path></svg><span>Resolution templates</span></div>
<div class="ge-canvas-preset-grid" role="group" aria-label="Resolution templates"> <div class="ge-canvas-preset-grid" role="group" aria-label="Resolution templates">
<button type="button" class="ge-canvas-preset" data-canvas-preset="1024x1024"><svg class="ge-canvas-preset-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="4" y="4" width="16" height="16" rx="1"></rect></svg><span>Square</span><strong>1024 × 1024</strong></button> <button type="button" class="ge-canvas-preset" data-canvas-preset="1024x1024"><svg class="ge-canvas-preset-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="4" y="4" width="16" height="16" rx="1"></rect></svg><span>Square</span><strong>1024 × 1024</strong></button>
@@ -126,6 +137,7 @@ export function canvasSizePromptHTML() {
<div class="ge-canvas-resize-options"> <div class="ge-canvas-resize-options">
<label class="ge-canvas-lock-option"><input type="checkbox" id="ge-canvas-prompt-lock" /> <span>Keep proportions</span></label> <label class="ge-canvas-lock-option"><input type="checkbox" id="ge-canvas-prompt-lock" /> <span>Keep proportions</span></label>
<label class="ge-canvas-interpolation-field"><span class="ge-canvas-control-label"><svg class="ge-canvas-prompt-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 18 9 13l3 3 8-8"></path><path d="M15 8h5v5"></path></svg><span>Interpolation</span></span><select id="ge-canvas-prompt-interpolation"><option value="high">Smooth</option><option value="medium">Balanced</option><option value="low">Crisp</option></select></label> <label class="ge-canvas-interpolation-field"><span class="ge-canvas-control-label"><svg class="ge-canvas-prompt-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 18 9 13l3 3 8-8"></path><path d="M15 8h5v5"></path></svg><span>Interpolation</span></span><select id="ge-canvas-prompt-interpolation"><option value="high">Smooth</option><option value="medium">Balanced</option><option value="low">Crisp</option></select></label>
<label class="ge-canvas-resolution-field" hidden><span class="ge-canvas-control-label"><span>Resolution</span></span><input type="number" id="ge-canvas-prompt-resolution" min="1" max="1000" value="72" /><span class="ge-canvas-resolution-unit">px/inch</span></label>
</div> </div>
</div> </div>
<div class="modal-footer ge-canvas-prompt-footer"> <div class="modal-footer ge-canvas-prompt-footer">
+89 -5
View File
@@ -13,7 +13,7 @@
* values). * values).
* *
* @param {{ * @param {{
* controlsHTML: (ctx: {color, brushSize, wandTolerance}) => string, * controlsHTML: (ctx: {color, brushSize, wandTolerance, bucketTolerance, cropAspectPreset}) => string,
* layerPanelHTML: () => string, * layerPanelHTML: () => string,
* }} build * }} build
* *
@@ -22,13 +22,19 @@
* controls: HTMLDivElement, * controls: HTMLDivElement,
* layerPanel: HTMLDivElement, * layerPanel: HTMLDivElement,
* panelResize: HTMLDivElement, * panelResize: HTMLDivElement,
* dock: {dock: HTMLDivElement, show: (id: string) => void}|null,
* }} * }}
*/ */
import { state } from '../state.js'; import { state } from '../state.js';
import { buildDock } from './dock.js';
export function buildRightPanel({ controlsHTML, layerPanelHTML }) { export function buildRightPanel({ controlsHTML, layerPanelHTML, extraPanels = [], onCloseGroup = null }) {
const rightPanel = document.createElement('div'); const rightPanel = document.createElement('div');
rightPanel.className = 'ge-right-panel'; rightPanel.className = 'ge-right-panel';
// Desktop: present Tool Options + Layers as tabs in a dock. Mobile
// (≤ 700 px) keeps the existing bottom-sheet model, where the two
// panels are independent sheets, so the dock is bypassed entirely.
const useDock = window.innerWidth > 700;
// Controls section. // Controls section.
const controls = document.createElement('div'); const controls = document.createElement('div');
@@ -66,8 +72,11 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML }) {
color: state.color, color: state.color,
brushSize: state.brushSize, brushSize: state.brushSize,
wandTolerance: state.wandTolerance, wandTolerance: state.wandTolerance,
bucketTolerance: state.bucketTolerance,
cropAspectPreset: state.cropAspectPreset,
eyedropperSampleSize: state.eyedropperSampleSize,
}); });
rightPanel.appendChild(controls); if (!useDock) rightPanel.appendChild(controls);
// Mobile only (≤ 700 px — matches the .ge-editor-body column-stack // Mobile only (≤ 700 px — matches the .ge-editor-body column-stack
// breakpoint): the right panel becomes a transformed bottom-sheet, // breakpoint): the right panel becomes a transformed bottom-sheet,
// so any position:fixed descendant gets trapped by the transform // so any position:fixed descendant gets trapped by the transform
@@ -99,7 +108,7 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML }) {
const layerPanel = document.createElement('div'); const layerPanel = document.createElement('div');
layerPanel.className = 'ge-layers'; layerPanel.className = 'ge-layers';
layerPanel.innerHTML = layerPanelHTML(); layerPanel.innerHTML = layerPanelHTML();
rightPanel.appendChild(layerPanel); if (!useDock) rightPanel.appendChild(layerPanel);
// Mobile: tap the header grab handle or swipe up/down to toggle // Mobile: tap the header grab handle or swipe up/down to toggle
// the layers sheet between peek and expanded. The peek state // the layers sheet between peek and expanded. The peek state
// always shows the active layer so users never lose access to it. // always shows the active layer so users never lose access to it.
@@ -164,6 +173,81 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML }) {
} }
} }
// Mobile panel access (≤ 700 px). The desktop dock is bypassed on
// phones, which left the whole studio panel set (Swatches, Gradients,
// Patterns, Properties, Adjustments, Histogram, Info, Channels, Paths,
// Character, Navigator, History, Actions) unreachable — there was no way
// to add an adjustment or pick a colour from the sheet. Mount the same
// entries in a compact `<select>` overlay reachable from a "Panels"
// button in the layers header, without touching the peek/expand model.
let mobilePanels = null;
if (Array.isArray(extraPanels) && extraPanels.length) {
const mDock = buildDock({
entries: extraPanels,
persistKey: 'ge-dock-active',
defaultId: null,
compact: true,
});
const overlay = document.createElement('div');
overlay.className = 'ge-mobile-panels';
overlay.hidden = true;
const head = document.createElement('div');
head.className = 'ge-mobile-panels-head';
const title = document.createElement('span');
title.className = 'ge-mobile-panels-title';
title.textContent = 'Panels';
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 'ge-mobile-panels-close';
closeBtn.setAttribute('aria-label', 'Close panels');
closeBtn.textContent = '✕';
head.append(title, closeBtn);
overlay.append(head, mDock.dock);
rightPanel.appendChild(overlay);
closeBtn.addEventListener('click', () => { overlay.hidden = true; });
const hdr = layerPanel.querySelector('.ge-layers-header');
if (hdr) {
const panelsBtn = document.createElement('button');
panelsBtn.type = 'button';
panelsBtn.className = 'ge-layers-panels-btn';
panelsBtn.textContent = 'Panels';
panelsBtn.title = 'Show panels';
panelsBtn.addEventListener('click', (e) => {
e.stopPropagation();
overlay.hidden = !overlay.hidden;
});
hdr.appendChild(panelsBtn);
if (useDock) panelsBtn.style.display = 'none';
}
if (useDock) overlay.style.display = 'none';
mobilePanels = overlay;
}
// Desktop dock: Tool Options + Layers as tabs (PhotoCraft-parity).
// The panels are only re-parented, so every existing selector and
// listener keeps working; switching tabs just toggles `hidden`.
let dock = null;
if (Array.isArray(extraPanels) && extraPanels.length) {
dock = buildDock({
entries: [
...(Array.isArray(extraPanels) ? extraPanels : []),
],
defaultId: (Array.isArray(extraPanels) && extraPanels[0]?.id) || null,
persistKey: 'ge-dock-active',
// Narrow desktop columns get the compact selector fallback; the
// multi-group stack needs a bit more room to be usable.
compact: window.innerWidth <= 820,
onCloseGroup,
});
rightPanel.appendChild(dock.dock);
if (!useDock) dock.dock.style.display = 'none';
}
if (useDock) {
rightPanel.classList.add('ge-right-panel--dock');
}
rightPanel.appendChild(layerPanel);
// Horizontal drag handle on the LEFT edge of the right panel — drag // Horizontal drag handle on the LEFT edge of the right panel — drag
// left to widen, right to narrow. Persists chosen width in // left to widen, right to narrow. Persists chosen width in
// localStorage so it survives reopens. (Earlier version was a // localStorage so it survives reopens. (Earlier version was a
@@ -205,5 +289,5 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML }) {
} catch {} } catch {}
}); });
return { rightPanel, controls, layerPanel, panelResize }; return { rightPanel, controls, layerPanel, panelResize, dock, mobilePanels };
} }
+285 -62
View File
@@ -6,85 +6,308 @@
* mask plumbing, etc.) stays in the caller and arrives here as the * mask plumbing, etc.) stays in the caller and arrives here as the
* `onSelectTool` callback. * `onSelectTool` callback.
* *
* Layout mirrors PhotoCraft's rail: ordered sections, with related tools
* collapsed into a single flyout slot (face = current member, caret opens
* the family). Selecting a flyout member promotes it to the slot face.
*
* @param {{ * @param {{
* currentTool: string, * currentTool: string,
* onSelectTool: (toolId: string, btn: HTMLButtonElement, toolbar: HTMLDivElement) => void, * onSelectTool: (toolId: string, btn: HTMLButtonElement, toolbar: HTMLDivElement) => void,
* onClearSelection: (which: 'marquee'|'lasso'|'wand') => void, * onClearSelection: (which: 'marquee'|'lasso'|'wand'|'sam') => void,
* }} ctx * }} ctx
* @returns {{ toolbar: HTMLDivElement, toolKeyMap: Record<string,string> }} * @returns {{
* toolbar: HTMLDivElement,
* toolKeyMap: Record<string,string>,
* syncActive: (toolId: string) => void,
* }}
*/ */
import { TOOL_SHORTCUTS } from '../tool-shortcuts.js'; import { TOOL_SHORTCUTS } from '../tool-shortcuts.js';
import { loadBrushPresets } from '../brush-presets.js';
const CLEAR_X = '<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>';
const CARET = '<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>';
export const TOOLS = {
move: { label: 'Move', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="5 9 2 12 5 15"/><polyline points="9 5 12 2 15 5"/><polyline points="15 19 12 22 9 19"/><polyline points="19 9 22 12 19 15"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="12" y1="2" x2="12" y2="22"/></svg>' },
hand: { label: 'Hand', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 11V6a2 2 0 0 0-4 0v4"/><path d="M14 10V4a2 2 0 0 0-4 0v6"/><path d="M10 10V5a2 2 0 0 0-4 0v9"/><path d="M6 13.5 4.5 12A2.1 2.1 0 0 0 2 15l4.5 5A6 6 0 0 0 11 22h2a7 7 0 0 0 7-7v-4a2 2 0 0 0-4 0v1"/></svg>' },
zoom: { label: 'Zoom', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>' },
crop: { label: 'Crop', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2v14a2 2 0 0 0 2 2h14"/><path d="M18 22V8a2 2 0 0 0-2-2H2"/></svg>' },
transform: { label: 'Transform', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>' },
text: { label: 'Text', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/></svg>' },
shape: { label: 'Shape', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="12" height="12" rx="1"/><circle cx="17" cy="15" r="4"/></svg>' },
brush: { label: 'Brush', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"/></svg>' },
gradient: { label: 'Gradient', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M6 18 18 6" opacity=".2"/><path d="M9 18 18 9" opacity=".5"/><path d="M12 18 18 12"/></svg>' },
bucket: { label: 'Paint Bucket', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m19 11-8-8-8.6 8.6a2 2 0 0 0 0 2.8l5.2 5.2c.8.8 2 .8 2.8 0L19 11Z"/><path d="m5 2 5 5"/><path d="M2 13h15"/><path d="M22 20a2 2 0 1 1-4 0c0-1.6 1.7-2.4 2-4 .3 1.6 2 2.4 2 4Z"/></svg>' },
eraser: { label: 'Eraser', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19.4 14.6 14.6 19.4a2 2 0 0 1-2.83 0L4.6 12.23a2 2 0 0 1 0-2.83l7.17-7.17a2 2 0 0 1 2.83 0l4.8 4.8a2 2 0 0 1 0 2.83Z"/><line x1="22" y1="21" x2="7" y2="21"/><line x1="14" y1="3" x2="9" y2="8"/></svg>' },
pixel: { label: 'Pixel', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" stroke="none" aria-hidden="true"><rect x="4" y="4" width="7" height="7"/><rect x="13" y="13" width="7" height="7"/><rect x="13" y="4" width="7" height="7" opacity="0.35"/><rect x="4" y="13" width="7" height="7" opacity="0.35"/></svg>' },
eyedropper: { label: 'Eyedropper', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m2 22 1-1h3l9-9"/><path d="M3 21v-3l9-9"/><path d="m15 6 3.4-3.4a2.1 2.1 0 1 1 3 3L18 9l.9.9a1 1 0 0 1 0 1.4l-1.6 1.6a1 1 0 0 1-1.4 0L12 8.4a1 1 0 0 1 0-1.4l1.6-1.6a1 1 0 0 1 1.4 0l.9.9Z"/></svg>' },
smudge: { label: 'Smudge', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.5 12V5.5a1.75 1.75 0 1 1 3.5 0V12"/><path d="M16 12v-1.5a1.5 1.5 0 0 1 3 0V16a5 5 0 0 1-5 5h-2.2a4.5 4.5 0 0 1-3.5-1.7l-3.2-4a1.6 1.6 0 0 1 2.4-2.1l1.8 1.9"/></svg>' },
clone: { label: 'Clone', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="9" r="3"/><path d="M9 12l-3 4h12l-3-4"/><path d="M4 20h16"/></svg>' },
heal: { label: 'Healing', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="m5 19 14-14"/><path d="M7 5h4M9 3v4M13 17h4M15 15v4"/></svg>' },
patch: { label: 'Patch', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="10" height="10" rx="1" stroke-dasharray="3 2"/><path d="M13 13h8v8h-8z"/></svg>' },
dodge: { label: 'Dodge', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="m9.5 14.5-6 6"/><circle cx="14" cy="10" r="6" fill="currentColor"/></svg>' },
burn: { label: 'Burn', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 22c4 0 7-3 7-7 0-5-4-8-7-13-3 5-7 8-7 13 0 4 3 7 7 7Z"/><path d="M9 16c1.5 1 4.5 1 6 0"/></svg>' },
liquify: { label: 'Liquify', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 8c2.5-3 5-3 7.5 0s5 3 7.5 0"/><path d="M3 14c2.5-3 5-3 7.5 0s5 3 7.5 0"/><circle cx="17.5" cy="19" r="2"/></svg>' },
marquee: { label: 'Marquee', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="1" stroke-dasharray="3 3"/></svg>' },
lasso: { label: 'Lasso', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 22a5 5 0 0 1-2-4"/><path d="M3.3 13.9A6.9 6.9 0 0 1 2 9c0-4 4-7 10-7s10 3 10 7-4 7-10 7"/><path d="M5 18a4.6 4.6 0 0 1-1.5-3.5"/><circle cx="5" cy="19" r="2"/></svg>' },
pen: { label: 'Pen selection', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m12 19 7-7 3 3-7 7-3-3zM18 13l-1.5-7.5L2 2l3.5 14.5L13 18M2 2l7.5 7.5"/><circle cx="10" cy="10" r="2"/></svg>' },
path: { label: 'Pen Path', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20 20 4"/><path d="M4 20h4M20 4v4"/><circle cx="4" cy="20" r="2"/><circle cx="20" cy="4" r="2"/></svg>' },
wand: { label: 'Wand', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 4V2"/><path d="M15 16v-2"/><path d="M8 9h2"/><path d="M20 9h2"/><path d="M17.8 11.8L19 13"/><path d="M15 9h0"/><path d="M17.8 6.2L19 5"/><path d="M3 21l9-9"/><path d="M12.2 6.2L11 5"/></svg>' },
quickselect: { label: 'Quick Select', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v4M12 17v4M3 12h4M17 12h4"/><circle cx="12" cy="12" r="4"/><path d="M12 8a4 4 0 0 1 0 8"/></svg>' },
objectselect: { label: 'Object Select', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2" stroke-dasharray="3 3"/><path d="M9 9h6v6H9z"/></svg>' },
sam: { label: 'SAM', ai: true, icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7c3-3 13-3 16 0"/><path d="M4 17c3 3 13 3 16 0"/><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3"/></svg>' },
inpaint: { label: 'Inpaint', ai: true, icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"/></svg>' },
rembg: { label: 'Bg Remove', ai: true, icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><line x1="20" y1="4" x2="8.12" y2="15.88"/><line x1="14.47" y1="14.48" x2="20" y2="20"/><line x1="8.12" y1="8.12" x2="12" y2="12"/></svg>' },
sharpen: { label: 'Sharpen', ai: true, icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l2.4 6.6L21 11l-6.6 2.4L12 20l-2.4-6.6L3 11l6.6-2.4z"/></svg>' },
};
// Ordered sections. A string is a single-tool slot; an array is a flyout
// slot whose first member is the default face.
const SECTIONS = [
['move'],
[['marquee', 'lasso'], ['wand', 'quickselect', 'objectselect', 'sam']],
[['crop', 'transform'], ['brush', 'eraser', 'pixel'], ['clone', 'heal', 'patch'], ['smudge', 'dodge', 'burn', 'liquify'], ['gradient', 'bucket'], 'eyedropper'],
[['pen', 'path'], ['text', 'shape'], ['inpaint', 'rembg', 'sharpen']],
[['hand', 'zoom']],
];
export function buildToolbar({ currentTool, onSelectTool, onClearSelection }) { export function buildToolbar({ currentTool, onSelectTool, onClearSelection }) {
const toolbar = document.createElement('div'); const toolbar = document.createElement('div');
toolbar.className = 'ge-toolbar'; toolbar.className = 'ge-toolbar';
const tools = [ // The tool slots live in their own scroller so the colour swatches can sit
{ id: 'move', label: 'Move', icon: '✥' }, // in a fixed footer below it. With the swatches inside the scroller they
{ id: 'hand', label: 'Hand', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 11V6a2 2 0 0 0-4 0v4"/><path d="M14 10V4a2 2 0 0 0-4 0v6"/><path d="M10 10V5a2 2 0 0 0-4 0v9"/><path d="M6 13.5 4.5 12A2.1 2.1 0 0 0 2 15l4.5 5A6 6 0 0 0 11 22h2a7 7 0 0 0 7-7v-4a2 2 0 0 0-4 0v1"/></svg>' }, // were sticky over the last slots and swallowed their clicks.
{ id: 'crop', label: 'Crop', icon: '✂' }, const scroller = document.createElement('div');
{ id: 'transform', label: 'Transform', icon: '⤢' }, scroller.className = 'ge-toolbar-scroll';
{ id: 'text', label: 'Text', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/></svg>' }, toolbar.appendChild(scroller);
{ id: 'shape', label: 'Shape', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="12" height="12" rx="1"/><circle cx="17" cy="15" r="4"/></svg>' },
{ sep: true },
{ id: 'brush', label: 'Brush', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"/></svg>' },
{ id: 'gradient', label: 'Gradient', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20 20 4"/><path d="M6 18 18 6" opacity=".45"/><path d="M8 16 16 8" opacity=".2"/></svg>' },
{ id: 'eraser', label: 'Eraser', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19.4 14.6 14.6 19.4a2 2 0 0 1-2.83 0L4.6 12.23a2 2 0 0 1 0-2.83l7.17-7.17a2 2 0 0 1 2.83 0l4.8 4.8a2 2 0 0 1 0 2.83Z"/><line x1="22" y1="21" x2="7" y2="21"/><line x1="14" y1="3" x2="9" y2="8"/></svg>' },
{ id: 'eyedropper', label: 'Eyedropper', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m19 3 2 2-9.5 9.5-3-3Z"/><path d="m8.5 11.5-5 5V21h4.5l5-5"/></svg>' },
{ id: 'smudge', label: 'Smudge', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 19c2-3 4-5 6-6 2-1 4-3 4-6 0-2-1-4-3-4s-3 2-3 4v5"/><path d="M9 13c-2 0-4 1-5 3-.7 1.2-.1 3 1.4 3H18c1.7 0 3-1.3 3-3 0-1.1-.9-2-2-2h-4"/></svg>' },
{ sep: true },
{ id: 'clone', label: 'Clone', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="9" r="3"/><path d="M9 12l-3 4h12l-3-4"/><path d="M4 20h16"/></svg>' },
{ id: 'heal', label: 'Healing', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="m5 19 14-14"/><path d="M7 5h4M9 3v4M13 17h4M15 15v4"/></svg>' },
{ id: 'dodge', label: 'Dodge', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="10" cy="10" r="6"/><path d="m14.5 14.5 6 6"/></svg>' },
{ id: 'burn', label: 'Burn', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 22c4 0 7-3 7-7 0-5-4-8-7-13-3 5-7 8-7 13 0 4 3 7 7 7Z"/><path d="M9 16c1.5 1 4.5 1 6 0"/></svg>' },
{ id: 'marquee', label: 'Marquee', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="1" stroke-dasharray="3 3"/></svg>' },
{ id: 'lasso', label: 'Lasso', icon: '⟡' },
{ id: 'pen', label: 'Pen selection', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m12 19 7-7 3 3-7 7-3-3zM18 13l-1.5-7.5L2 2l3.5 14.5L13 18M2 2l7.5 7.5"/><circle cx="10" cy="10" r="2"/></svg>' },
{ id: 'wand', label: 'Wand', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 4V2"/><path d="M15 16v-2"/><path d="M8 9h2"/><path d="M20 9h2"/><path d="M17.8 11.8L19 13"/><path d="M15 9h0"/><path d="M17.8 6.2L19 5"/><path d="M3 21l9-9"/><path d="M12.2 6.2L11 5"/></svg>' },
{ id: 'sam', label: 'SAM', ai: true, icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7c3-3 13-3 16 0"/><path d="M4 17c3 3 13 3 16 0"/><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3"/></svg>' },
{ sep: true },
{ id: 'inpaint', label: 'Inpaint', ai: true, icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"/></svg>' },
{ id: 'rembg', ai: true, label: 'Bg Remove', icon: '✄' },
{ id: 'sharpen', ai: true, label: 'Sharpen', icon: '◈' },
];
const toolKeyMap = {}; const toolKeyMap = {};
for (const t of tools) { const slots = [];
t.key = TOOL_SHORTCUTS[t.id];
if (t.sep) { for (const id of Object.keys(TOOLS)) {
const sep = document.createElement('div'); const key = TOOL_SHORTCUTS[id];
sep.className = 'ge-tool-sep'; if (key) toolKeyMap[key.toLowerCase()] = id;
sep.textContent = t.label;
toolbar.appendChild(sep);
continue;
} }
if (t.key) toolKeyMap[t.key.toLowerCase()] = t.id;
const btn = document.createElement('button'); const setInner = (btn, toolId) => {
btn.className = 'ge-tool-btn' + (t.id === currentTool ? ' active' : ''); const t = TOOLS[toolId];
btn.dataset.tool = t.id; btn.dataset.tool = toolId;
btn.title = t.label + (t.key ? ` (${t.key})` : ''); btn.title = t.label + (TOOL_SHORTCUTS[toolId] ? ` (${TOOL_SHORTCUTS[toolId]})` : '');
if (t.id === 'pen') btn.title += ' - Click anchors; drag for curves; close path or press Enter'; if (toolId === 'pen') btn.title += ' - Click anchors; drag for curves; close path or press Enter';
// Heavy 4-point AI star marker for AI-backed tools — sits just to if (toolId === 'path') btn.title += ' - Click to add anchors; double-click or press Enter to finish';
// the left of the icon so the user can spot AI vs local tools at a
// glance now that the "AI Tools" separator is gone.
const aiStar = t.ai ? '<span class="ge-tool-ai" title="AI">✦</span>' : '';
btn.classList.toggle('is-ai', !!t.ai); btn.classList.toggle('is-ai', !!t.ai);
// Selection-clear badge — rendered only for tools that can hold a const aiStar = t.ai ? '<span class="ge-tool-ai" title="AI">✦</span>' : '';
// selection (lasso, wand). Inpaint masks are first-class sub-layers const clearTitle = toolId === 'sam' ? 'Open SAM prompt' : 'Clear selection';
// now so they get their own delete-X in the layer panel. const clearBadge = ['marquee', 'lasso', 'quickselect', 'objectselect', 'wand', 'sam'].includes(toolId)
const clearTitle = t.id === 'sam' ? 'Open SAM prompt' : 'Clear selection'; ? '<span class="ge-tool-clear" title="' + clearTitle + '" data-clear-tool="' + toolId + '">' + CLEAR_X + '</span>'
const clearBadge = (t.id === 'marquee' || t.id === 'lasso' || t.id === 'wand' || t.id === 'sam')
? '<span class="ge-tool-clear" title="' + clearTitle + '" data-clear-tool="' + t.id + '">' +
'<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>' +
'</span>'
: ''; : '';
btn.innerHTML = `${aiStar}<span class="ge-tool-icon"${t.small ? ' style="font-size:14px"' : ''}>${t.icon}</span><span class="ge-tool-label">${t.label}</span>${clearBadge}`; btn.innerHTML = `${aiStar}<span class="ge-tool-icon"${t.small ? ' style="font-size:14px"' : ''}>${t.icon}</span>${clearBadge}`;
// Clear-badge click stops propagation so the tool itself doesn't
// toggle; the actual clear is handled by the caller.
btn.querySelector('.ge-tool-clear')?.addEventListener('click', (ev) => { btn.querySelector('.ge-tool-clear')?.addEventListener('click', (ev) => {
ev.stopPropagation(); ev.stopPropagation();
onClearSelection(ev.currentTarget.dataset.clearTool); onClearSelection(ev.currentTarget.dataset.clearTool);
}); });
btn.addEventListener('click', () => onSelectTool(t.id, btn, toolbar)); };
toolbar.appendChild(btn);
const closeAllFlyouts = () => {
toolbar.querySelectorAll('.ge-tool-slot.open').forEach(s => s.classList.remove('open'));
toolbar.classList.remove('ge-flyout-open');
toolbar.closest('.ge-toolbar-wrap')?.classList.remove('ge-flyout-open');
};
const openFlyout = (slotEl, face) => {
const isOpen = slotEl.classList.contains('open');
closeAllFlyouts();
if (isOpen) return;
slotEl.classList.add('open');
toolbar.classList.add('ge-flyout-open');
toolbar.closest('.ge-toolbar-wrap')?.classList.add('ge-flyout-open');
const flyout = slotEl.querySelector('.ge-tool-flyout');
if (!flyout) return;
const r = face.getBoundingClientRect();
// The flyout is position:fixed, but an ancestor with a transform (the
// gallery modal sets an identity matrix) becomes its containing block, so
// viewport coords must be offset by that block's origin or the popup lands
// far from the button.
const cb = flyout.offsetParent;
const cbRect = cb ? cb.getBoundingClientRect() : { left: 0, top: 0 };
const cbX = cbRect.left + (cb ? cb.clientLeft : 0);
const cbY = cbRect.top + (cb ? cb.clientTop : 0);
if (window.innerWidth <= 820) {
flyout.style.left = (Math.max(r.left, 4) - cbX) + 'px';
flyout.style.top = (r.bottom + 4 - cbY) + 'px';
} else {
flyout.style.left = (r.right + 4 - cbX) + 'px';
flyout.style.top = (Math.max(r.top, 4) - cbY) + 'px';
} }
return { toolbar, toolKeyMap }; // Keep the list inside the window. The rail's lower slots (brush with its
// presets, clone, smudge) open a tall menu that would otherwise spill past
// the bottom edge, leaving the lower rows impossible to click.
const fw = flyout.offsetWidth;
const fh = flyout.offsetHeight;
const viewLeft = (parseFloat(flyout.style.left) || 0) + cbX;
const viewTop = (parseFloat(flyout.style.top) || 0) + cbY;
const maxLeft = Math.max(4, window.innerWidth - fw - 4);
const maxTop = Math.max(4, window.innerHeight - fh - 4);
if (viewLeft > maxLeft) flyout.style.left = (maxLeft - cbX) + 'px';
if (viewTop > maxTop) flyout.style.top = (maxTop - cbY) + 'px';
};
document.addEventListener('click', (ev) => {
if (!toolbar.contains(ev.target)) closeAllFlyouts();
});
let first = true;
for (const section of SECTIONS) {
if (!first) {
const sep = document.createElement('div');
sep.className = 'ge-tool-sep';
scroller.appendChild(sep);
}
first = false;
for (const entry of section) {
const members = Array.isArray(entry) ? entry : [entry];
const slotEl = document.createElement('div');
slotEl.className = 'ge-tool-slot';
const face = document.createElement('button');
face.type = 'button';
face.className = 'ge-tool-btn ge-tool-slot-face';
const faceId = members.includes(currentTool) ? currentTool : members[0];
setInner(face, faceId);
face.addEventListener('click', () => {
closeAllFlyouts();
onSelectTool(face.dataset.tool, face, toolbar);
});
slotEl.appendChild(face);
const slot = { el: slotEl, face, members, current: faceId, items: [] };
if (members.length > 1) {
// Photoshop-style corner triangle marks a slot whose family has
// more tools behind it. It lives on the slot (not on the face) so
// re-rendering the face icon when a flyout member is picked never
// wipes the hint away.
slotEl.insertAdjacentHTML('beforeend', '<span class="ge-tool-flyout-tri" aria-hidden="true"></span>');
const tri = slotEl.querySelector('.ge-tool-flyout-tri');
tri?.addEventListener('click', (ev) => {
ev.stopPropagation();
openFlyout(slotEl, face);
});
const flyout = document.createElement('div');
flyout.className = 'ge-tool-flyout';
for (const id of members) {
const item = document.createElement('button');
item.type = 'button';
item.className = 'ge-tool-btn ge-tool-flyout-item';
setInner(item, id);
// Keep `data-tool` unique to the slot face so `[data-tool="X"]`
// selectors are unambiguous; flyout members expose the option id.
item.dataset.toolOption = id;
delete item.dataset.tool;
item.addEventListener('click', (ev) => {
ev.stopPropagation();
setInner(face, id);
slot.current = id;
closeAllFlyouts();
onSelectTool(id, face, toolbar);
});
item.insertAdjacentHTML('beforeend', `<span class="ge-tool-flyout-label">${TOOLS[id].label}</span>`);
flyout.appendChild(item);
slot.items.push(item);
}
// Photoshop-style: the Brush slot's flyout also lists the brush
// presets, so any saved presets are reachable straight from the tool
// button. Picking one switches to the Brush and applies the preset
// through the options-bar picker so the size and stroke controls stay
// in sync. Pixel is its own tool (above), so it is NOT listed here as a
// brush type.
if (members.includes('brush')) {
const brushPresets = loadBrushPresets().filter((preset) => !preset.pixel);
if (brushPresets.length) {
const sep = document.createElement('div');
sep.className = 'ge-tool-flyout-sep';
flyout.appendChild(sep);
const heading = document.createElement('div');
heading.className = 'ge-tool-flyout-heading';
heading.textContent = 'Brushes';
flyout.appendChild(heading);
for (const preset of brushPresets) {
const presetItem = document.createElement('button');
presetItem.type = 'button';
presetItem.className = 'ge-tool-btn ge-tool-flyout-item ge-tool-flyout-preset';
presetItem.dataset.brushPreset = preset.id;
// Each preset row shows a glyph that reflects the preset's tip:
// a 1px checker square for the pixel brush, a solid disc for a
// hard round, a soft-edged disc for a soft round, and a mid disc
// in between — so a row is recognisable at a glance.
const soft = typeof preset.softness === 'number' ? preset.softness : 100;
const presetIcon = preset.pixel
? '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none" aria-hidden="true"><rect x="6" y="6" width="5" height="5"/><rect x="13" y="13" width="5" height="5"/><rect x="13" y="6" width="5" height="5" opacity="0.35"/><rect x="6" y="13" width="5" height="5" opacity="0.35"/></svg>'
: soft <= 20
? '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none" aria-hidden="true"><circle cx="12" cy="12" r="6.5"/></svg>'
: soft >= 70
? '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="6.5" fill="currentColor" opacity="0.3"/><circle cx="12" cy="12" r="6.5"/></svg>'
: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><circle cx="12" cy="12" r="6.5" fill="currentColor" opacity="0.65"/><circle cx="12" cy="12" r="6.5"/></svg>';
const icon = document.createElement('span');
icon.className = 'ge-tool-icon';
icon.innerHTML = presetIcon;
const label = document.createElement('span');
label.className = 'ge-tool-flyout-label';
label.textContent = preset.name;
presetItem.append(icon, label);
presetItem.addEventListener('click', (ev) => {
ev.stopPropagation();
setInner(face, 'brush');
slot.current = 'brush';
onSelectTool('brush', face, toolbar);
const picker = document.getElementById('ge-brush-preset');
if (picker) {
picker.value = preset.id;
picker.dispatchEvent(new Event('change', { bubbles: true }));
}
closeAllFlyouts();
});
flyout.appendChild(presetItem);
}
}
}
slotEl.appendChild(flyout);
face.addEventListener('contextmenu', (ev) => {
ev.preventDefault();
openFlyout(slotEl, face);
});
}
slots.push(slot);
scroller.appendChild(slotEl);
}
}
// Foreground / background color swatches (Photoshop-style) pinned to
// the bottom of the tool rail, with a swap control.
const swatches = document.createElement('div');
swatches.className = 'ge-tool-swatches';
swatches.innerHTML = `
<label class="ge-swatch ge-swatch-bg" title="Background color">
<input type="color" id="ge-bg-color" value="#ffffff" aria-label="Background color" />
</label>
<label class="ge-swatch ge-swatch-fg" title="Foreground color">
<input type="color" id="ge-fg-color" value="#000000" aria-label="Foreground color" />
</label>
<button type="button" class="ge-swatch-swap" id="ge-swap-colors" title="Swap foreground and background colors" aria-label="Swap colors">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 4 3 8l4 4"/><path d="M3 8h13a4 4 0 0 1 0 8h-2"/><path d="M17 20l4-4-4-4"/></svg>
</button>`;
toolbar.appendChild(swatches);
const syncActive = (toolId) => {
for (const slot of slots) {
if (!slot.members.includes(toolId)) continue;
if (slot.current !== toolId) setInner(slot.face, toolId);
slot.current = toolId;
slot.face.classList.add('active');
slot.items.forEach(it => it.classList.toggle('active', (it.dataset.toolOption || it.dataset.tool) === toolId));
}
};
// Initial active state.
syncActive(currentTool);
return { toolbar, toolKeyMap, syncActive };
} }
+429 -40
View File
@@ -13,47 +13,92 @@ export function buildTopbar() {
topBar.className = 'ge-topbar'; topBar.className = 'ge-topbar';
topBar.innerHTML = ` topBar.innerHTML = `
<div class="ge-topbar-left"> <div class="ge-topbar-left">
<span class="ge-alpha-badge" title="This editor is in active development — expect rough edges">ALPHA</span> <div class="ge-file-wrap">
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-undo" title="Undo"> <button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-file-menu-btn" title="File actions" aria-haspopup="true">
<span class="ge-stacked-glyph">↩</span> <span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3v4a1 1 0 0 0 1 1h4"/><path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2z"/></svg><span class="ge-stacked-caret">▾</span></span>
<span class="ge-stacked-label">UNDO</span> <span class="ge-stacked-label">File</span>
</button>
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-redo" title="Redo">
<span class="ge-stacked-glyph">↪</span>
<span class="ge-stacked-label">REDO</span>
</button>
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-history-btn" title="History — click an entry to jump to that state" aria-label="History">
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 4v6h6"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/><polyline points="12 7 12 12 16 14"/></svg></span>
<span class="ge-stacked-label">HISTORY</span>
</button>
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-compare-btn" title="Show the document before editing" aria-label="Show the document before editing" aria-pressed="false">
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v18"></path><path d="M5 7h14M5 17h14"></path></svg></span>
<span class="ge-stacked-label">BEFORE</span>
</button> </button>
<div class="ge-file-menu dropdown" id="ge-file-menu" hidden>
<div class="ge-menu-label">New</div>
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="file-new" aria-haspopup="true" aria-expanded="false"><span>New Image</span><span class="ge-submenu-caret">›</span></button>
<div class="ge-submenu" data-submenu-panel="file-new">
<button class="dropdown-item-compact" data-file-action="new-default"><span>Default</span><span class="dropdown-shortcut">1920 × 1080</span></button>
<button class="dropdown-item-compact" data-file-action="new-square"><span>Square</span><span class="dropdown-shortcut">1080 × 1080</span></button>
<button class="dropdown-item-compact" data-file-action="new-portrait"><span>Portrait</span><span class="dropdown-shortcut">1080 × 1350</span></button>
<button class="dropdown-item-compact" data-file-action="new-story"><span>Story</span><span class="dropdown-shortcut">1080 × 1920</span></button>
<button class="dropdown-item-compact" data-file-action="new-clipboard"><span>From Clipboard</span></button>
<button class="dropdown-item-compact" data-file-action="new-custom"><span>Custom Size…</span><span class="dropdown-shortcut">Ctrl+N</span></button>
</div> </div>
<div class="ge-topbar-right"> <div class="ge-menu-label">Open</div>
<div class="ge-view-wrap"> <button class="dropdown-item-compact" data-file-action="import"><span>Import Image…</span></button>
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-view-menu-btn" title="Canvas view" aria-haspopup="true"> <button class="dropdown-item-compact" data-file-action="load-project"><span>Load Project…</span></button>
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/></svg><span class="ge-stacked-caret">▾</span></span> <div class="ge-menu-label">Save</div>
<span class="ge-stacked-label">VIEW</span> <button class="dropdown-item-compact" data-file-action="save"><span>Save Over Original</span><span class="dropdown-shortcut">Ctrl+S</span></button>
<button class="dropdown-item-compact" data-file-action="save-copy"><span>Save As Copy</span><span class="dropdown-shortcut">Ctrl+Shift+S</span></button>
<button class="dropdown-item-compact" data-file-action="export"><span>Export Image…</span></button>
<button class="dropdown-item-compact" data-file-action="export-psd"><span>Export Layers as PSD…</span></button>
<div class="ge-menu-label">Project</div>
<button class="dropdown-item-compact" data-file-action="save-project"><span>Save Project (.json)</span></button>
</div>
</div>
<div class="ge-edit-wrap">
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-edit-menu-btn" title="Edit actions" aria-haspopup="true">
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4Z"/></svg><span class="ge-stacked-caret">▾</span></span>
<span class="ge-stacked-label">Edit</span>
</button> </button>
<div class="ge-view-menu dropdown" id="ge-view-menu" hidden> <div class="ge-edit-menu dropdown" id="ge-edit-menu" hidden>
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="rulers"><span class="dropdown-icon ge-view-check">✓</span><span>Rulers</span></button> <div class="ge-menu-label">History</div>
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="grid"><span class="dropdown-icon ge-view-check">✓</span><span>Grid</span></button> <button class="dropdown-item-compact" data-edit-action="undo"><span>Undo</span><span class="dropdown-shortcut">Ctrl+Z</span></button>
<label class="ge-view-grid-size"><span>Grid size</span><input id="ge-grid-size" type="number" min="2" max="1000" step="1" value="16"><span>px</span></label> <button class="dropdown-item-compact" data-edit-action="redo"><span>Redo</span><span class="dropdown-shortcut">Ctrl+Shift+Z</span></button>
<div class="dropdown-section-divider"></div> <button class="dropdown-item-compact" data-edit-action="history"><span>History…</span></button>
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="snap"><span class="dropdown-icon ge-view-check">✓</span><span>Snap</span></button> <div class="ge-menu-label">Clipboard</div>
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="snap-grid"><span class="dropdown-icon ge-view-check">✓</span><span>Snap to grid</span></button> <button class="dropdown-item-compact" data-edit-action="copy-merged" title="Copy the flattened visible image to the clipboard"><span>Copy Merged</span><span class="dropdown-shortcut">Ctrl+Shift+C</span></button>
<button class="dropdown-item-compact" data-view-action="clear-guides"><span class="dropdown-icon">×</span><span>Clear guides</span></button> <div class="ge-menu-label">Transform (active layer)</div>
<button class="dropdown-item-compact" data-edit-action="rotate-90" title="Rotate the active layer 90° CW"><span>Rotate 90° CW</span></button>
<button class="dropdown-item-compact" data-edit-action="rotate-180" title="Rotate the active layer 180°"><span>Rotate 180°</span></button>
<button class="dropdown-item-compact" data-edit-action="flip-h" title="Flip the active layer horizontally"><span>Flip Horizontal</span></button>
<button class="dropdown-item-compact" data-edit-action="flip-v" title="Flip the active layer vertically"><span>Flip Vertical</span></button>
<div class="ge-menu-label">Content-Aware</div>
<button class="dropdown-item-compact" data-edit-action="content-aware-move" title="Move the selection content and fill the hole it leaves behind"><span>Content-Aware Move…</span></button>
<div class="ge-menu-label">Stroke</div>
<button class="dropdown-item-compact" data-edit-action="stroke" title="Stroke the active layer or selection"><span>Stroke…</span></button>
<div class="ge-menu-label">Presets</div>
<button class="dropdown-item-compact" data-edit-action="define-brush" title="Save the current brush settings as a reusable preset"><span>Define Brush Preset…</span></button>
</div> </div>
</div> </div>
<div class="ge-image-wrap"> <div class="ge-image-wrap">
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-image-menu-btn" title="Image actions" aria-haspopup="true"> <button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-image-menu-btn" title="Image actions" aria-haspopup="true">
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg><span class="ge-stacked-caret">▾</span></span> <span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg><span class="ge-stacked-caret">▾</span></span>
<span class="ge-stacked-label">IMAGE</span> <span class="ge-stacked-label">Image</span>
</button> </button>
<div class="ge-image-menu dropdown" id="ge-image-menu" hidden> <div class="ge-image-menu dropdown" id="ge-image-menu" hidden>
<button class="dropdown-item-compact" data-image-action="fill" title="Fill with foreground color (Alt+Backspace)"><span>Fill</span></button> <button class="dropdown-item-compact" data-image-action="fill" title="Fill with foreground color (Alt+Backspace)"><span>Fill</span></button>
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="image-adjustments" aria-haspopup="true" aria-expanded="false"><span>Adjustments</span><span class="ge-submenu-caret">›</span></button>
<div class="ge-submenu" data-submenu-panel="image-adjustments">
<button class="dropdown-item-compact" data-image-action="desaturate" title="Desaturate"><span>Desaturate</span></button>
<button class="dropdown-item-compact" data-image-action="auto-tone" title="Auto Tone"><span>Auto Tone</span></button>
<button class="dropdown-item-compact" data-image-action="auto-contrast" title="Auto Contrast"><span>Auto Contrast</span></button>
<button class="dropdown-item-compact" data-image-action="auto-color" title="Auto Color"><span>Auto Color</span></button>
<button class="dropdown-item-compact" data-image-action="equalize" title="Equalize the histogram of the active layer"><span>Equalize</span></button>
<button class="dropdown-item-compact" data-image-action="replace-color" title="Recolour pixels near a target colour"><span>Replace Color…</span></button>
<button class="dropdown-item-compact" data-image-action="adj-brightness-contrast" title="Brightness/Contrast"><span>Brightness/Contrast…</span></button>
<button class="dropdown-item-compact" data-image-action="adj-hue-saturation" title="Hue/Saturation"><span>Hue/Saturation…</span></button>
<button class="dropdown-item-compact" data-image-action="adj-selective-color" title="Selective Color"><span>Selective Color…</span></button>
<button class="dropdown-item-compact" data-image-action="adj-levels" title="Levels"><span>Levels…</span></button>
<button class="dropdown-item-compact" data-image-action="adj-curves" title="Curves"><span>Curves…</span></button>
<button class="dropdown-item-compact" data-image-action="adj-exposure" title="Exposure"><span>Exposure…</span></button>
<button class="dropdown-item-compact" data-image-action="adj-develop" title="Develop (Camera Raw)"><span>Develop…</span></button>
<button class="dropdown-item-compact" data-image-action="adj-vibrance" title="Vibrance"><span>Vibrance…</span></button>
<button class="dropdown-item-compact" data-image-action="adj-color-balance" title="Color Balance"><span>Color Balance…</span></button>
<button class="dropdown-item-compact" data-image-action="adj-black-white" title="Black &amp; White"><span>Black &amp; White…</span></button>
<button class="dropdown-item-compact" data-image-action="adj-photo-filter" title="Photo Filter"><span>Photo Filter…</span></button>
<button class="dropdown-item-compact" data-image-action="adj-channel-mixer" title="Channel Mixer"><span>Channel Mixer…</span></button>
<button class="dropdown-item-compact" data-image-action="adj-invert" title="Invert"><span>Invert</span></button>
<button class="dropdown-item-compact" data-image-action="adj-posterize" title="Posterize"><span>Posterize…</span></button>
<button class="dropdown-item-compact" data-image-action="adj-threshold" title="Threshold"><span>Threshold…</span></button>
<button class="dropdown-item-compact" data-image-action="adj-gradient-map" title="Gradient Map"><span>Gradient Map…</span></button>
<button class="dropdown-item-compact" data-image-action="adj-shadows-highlights" title="Shadows/Highlights"><span>Shadows/Highlights…</span></button>
</div>
<button class="dropdown-item-compact" data-image-action="canvas-size"> <button class="dropdown-item-compact" data-image-action="canvas-size">
<span class="dropdown-icon">⤢</span> <span class="dropdown-icon">⤢</span>
<span>Canvas Size...</span> <span>Canvas Size...</span>
@@ -62,37 +107,131 @@ export function buildTopbar() {
<span class="dropdown-icon"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/></svg></span> <span class="dropdown-icon"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/></svg></span>
<span>Image Size...</span> <span>Image Size...</span>
</button> </button>
<div class="ge-filter-submenu-label">Transform</div> <div class="ge-filter-submenu-label">Trim</div>
<button class="dropdown-item-compact" data-image-action="trim" title="Trim transparent edges from the active layer"><span>Trim…</span></button>
<div class="ge-filter-submenu-label">Image Rotation (whole image)</div>
<button class="dropdown-item-compact" data-image-action="rotate-90"> <button class="dropdown-item-compact" data-image-action="rotate-90">
<span class="dropdown-icon"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-3-6.7"/><polyline points="21 3 21 9 15 9"/></svg></span> <span class="dropdown-icon"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-3-6.7"/><polyline points="21 3 21 9 15 9"/></svg></span>
<span>Rotate 90° CW</span> <span>Rotate 90° CW</span>
</button> </button>
<button class="dropdown-item-compact" data-image-action="rotate-ccw">
<span class="dropdown-icon"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 3-6.7"/><polyline points="3 3 3 9 9 9"/></svg></span>
<span>Rotate 90° CCW</span>
</button>
<button class="dropdown-item-compact" data-image-action="rotate-180"> <button class="dropdown-item-compact" data-image-action="rotate-180">
<span class="dropdown-icon"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg></span> <span class="dropdown-icon"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg></span>
<span>Rotate 180°</span> <span>Rotate 180°</span>
</button> </button>
<button class="dropdown-item-compact" data-image-action="rotate-arbitrary">
<span class="dropdown-icon"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg></span>
<span>Arbitrary…</span>
</button>
<button class="dropdown-item-compact" data-image-action="perspective-crop">
<span class="dropdown-icon"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6l18-2-2 14-16-2z"/><circle cx="3" cy="6" r="1.6" fill="currentColor" stroke="none"/><circle cx="21" cy="4" r="1.6" fill="currentColor" stroke="none"/><circle cx="19" cy="18" r="1.6" fill="currentColor" stroke="none"/><circle cx="3" cy="16" r="1.6" fill="currentColor" stroke="none"/></svg></span>
<span>Perspective Crop…</span>
</button>
<button class="dropdown-item-compact" data-image-action="flip-h"> <button class="dropdown-item-compact" data-image-action="flip-h">
<span class="dropdown-icon"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 7v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7"/><line x1="12" y1="3" x2="12" y2="21"/><polyline points="7 11 4 7 7 3"/><polyline points="17 11 20 7 17 3"/></svg></span> <span class="dropdown-icon"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 7v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7"/><line x1="12" y1="3" x2="12" y2="21"/><polyline points="7 11 4 7 7 3"/><polyline points="17 11 20 7 17 3"/></svg></span>
<span>Flip horizontal</span> <span>Flip Canvas Horizontal</span>
</button> </button>
<button class="dropdown-item-compact" data-image-action="flip-v"> <button class="dropdown-item-compact" data-image-action="flip-v">
<span class="dropdown-icon"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h10"/><line x1="3" y1="12" x2="21" y2="12"/><polyline points="11 7 7 4 3 7"/><polyline points="11 17 7 20 3 17"/></svg></span> <span class="dropdown-icon"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h10"/><line x1="3" y1="12" x2="21" y2="12"/><polyline points="11 7 7 4 3 7"/><polyline points="11 17 7 20 3 17"/></svg></span>
<span>Flip vertical</span> <span>Flip Canvas Vertical</span>
</button> </button>
<div class="ge-filter-submenu-label">Mode</div>
<button class="dropdown-item-compact" data-image-action="mode-rgb"><span>RGB Color</span></button>
<button class="dropdown-item-compact" data-image-action="mode-grayscale"><span>Grayscale</span></button>
<button class="dropdown-item-compact" data-image-action="mode-cmyk" disabled title="CMYK needs a color-managed pipeline"><span>CMYK Color</span></button>
<button class="dropdown-item-compact" data-image-action="mode-lab" disabled title="Lab needs a color-managed pipeline"><span>Lab Color</span></button>
<div class="ge-filter-submenu-label">Bits/Channel</div>
<button class="dropdown-item-compact" data-image-action="bits-8"><span>8 Bits/Channel</span></button>
<button class="dropdown-item-compact" data-image-action="bits-16" disabled title="16-bit needs a 16-bit working canvas"><span>16 Bits/Channel</span></button>
<button class="dropdown-item-compact" data-image-action="bits-32" disabled title="32-bit needs a float working canvas"><span>32 Bits/Channel</span></button>
</div>
</div>
<div class="ge-layer-wrap">
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-layer-menu-btn" title="Layer actions" aria-haspopup="true">
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3 3 8l9 5 9-5-9-5z"/><path d="m3 13 9 5 9-5"/></svg><span class="ge-stacked-caret">▾</span></span>
<span class="ge-stacked-label">Layer</span>
</button>
<div class="ge-layer-menu dropdown" id="ge-layer-menu" hidden>
<div class="ge-menu-label">New</div>
<button class="dropdown-item-compact" data-layer-action="new"><span>New Layer…</span></button>
<button class="dropdown-item-compact" data-layer-action="new-fill"><span>New Fill Layer…</span></button>
<button class="dropdown-item-compact" data-layer-action="duplicate"><span>Duplicate Layer</span><span class="dropdown-shortcut">Ctrl+J</span></button>
<button class="dropdown-item-compact" data-layer-action="group"><span>Group Selected</span></button>
<button class="dropdown-item-compact" data-layer-action="rasterize"><span>Rasterize Layer</span></button>
<div class="ge-menu-label">Modify</div>
<button class="dropdown-item-compact" data-layer-action="delete"><span>Delete Layer</span><span class="dropdown-shortcut">Del</span></button>
<div class="ge-menu-label">Arrange</div>
<button class="dropdown-item-compact" data-layer-action="arrange-front"><span>Bring to Front</span></button>
<button class="dropdown-item-compact" data-layer-action="arrange-forward"><span>Bring Forward</span></button>
<button class="dropdown-item-compact" data-layer-action="arrange-backward"><span>Send Backward</span></button>
<button class="dropdown-item-compact" data-layer-action="arrange-back"><span>Send to Back</span></button>
<div class="ge-menu-label">Merge</div>
<button class="dropdown-item-compact" data-layer-action="merge-down"><span>Merge Down</span><span class="dropdown-shortcut">Ctrl+E</span></button>
<button class="dropdown-item-compact" data-layer-action="merge-layers"><span>Merge Layers</span></button>
<button class="dropdown-item-compact" data-layer-action="merge-all"><span>Merge All</span></button>
<button class="dropdown-item-compact" data-layer-action="merge-visible"><span>Merge Visible</span></button>
<button class="dropdown-item-compact" data-layer-action="flatten"><span>Flatten Image</span></button>
<div class="ge-menu-label">Select</div>
<button class="dropdown-item-compact" data-layer-action="select-all"><span>Select All Layers</span></button>
<div class="ge-menu-label">Vector Mask</div>
<button class="dropdown-item-compact" data-layer-action="vector-mask-reveal" title="Add a mask that reveals the whole layer"><span>Reveal All</span></button>
<button class="dropdown-item-compact" data-layer-action="vector-mask-hide" title="Add a mask that hides the whole layer"><span>Hide All</span></button>
<div class="ge-menu-label">Combine Shapes</div>
<button class="dropdown-item-compact" data-layer-action="combine-unite"><span>Unite Shapes</span></button>
<button class="dropdown-item-compact" data-layer-action="combine-subtract"><span>Subtract Shapes</span></button>
<button class="dropdown-item-compact" data-layer-action="combine-intersect"><span>Intersect Shapes</span></button>
<button class="dropdown-item-compact" data-layer-action="combine-exclude"><span>Exclude Shapes</span></button>
<div class="ge-menu-label">Fill</div>
<button class="dropdown-item-compact" data-layer-action="fill"><span>Fill</span></button>
</div>
</div>
<div class="ge-type-wrap">
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-type-menu-btn" title="Type actions" aria-haspopup="true">
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 7 4 4 20 4 20 7"/><line x1="9" y1="20" x2="15" y2="20"/><line x1="12" y1="4" x2="12" y2="20"/></svg><span class="ge-stacked-caret">▾</span></span>
<span class="ge-stacked-label">Type</span>
</button>
<div class="ge-type-menu dropdown" id="ge-type-menu" hidden>
<div class="ge-menu-label">Create</div>
<button class="dropdown-item-compact" data-type-action="add"><span>Add Text Layer</span></button>
<div class="ge-menu-label">Panels</div>
<button class="dropdown-item-compact" data-type-action="character"><span>Character…</span></button>
<button class="dropdown-item-compact" data-type-action="paragraph"><span>Paragraph…</span></button>
<div class="ge-menu-label">Warp</div>
<button class="dropdown-item-compact" data-type-action="warp"><span>Warp Text…</span></button>
<div class="ge-menu-label">Path</div>
<button class="dropdown-item-compact" data-type-action="text-on-path" title="Lay the active text layer along the selected path layer"><span>Text on a Path</span></button>
<button class="dropdown-item-compact" data-type-action="text-on-circle" title="Wrap the active text layer around a circle"><span>Text on a Circle</span></button>
<button class="dropdown-item-compact" data-type-action="text-off-path" title="Return the text layer to a straight baseline"><span>Remove from Path</span></button>
</div> </div>
</div> </div>
<div class="ge-selection-wrap"> <div class="ge-selection-wrap">
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-selection-menu-btn" title="Selection actions" aria-haspopup="true"> <button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-selection-menu-btn" title="Selection actions" aria-haspopup="true">
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="1" stroke-dasharray="3 3"/></svg><span class="ge-stacked-caret">▾</span></span> <span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="1" stroke-dasharray="3 3"/></svg><span class="ge-stacked-caret">▾</span></span>
<span class="ge-stacked-label">SELECT</span> <span class="ge-stacked-label">Select</span>
</button> </button>
<div class="ge-selection-menu dropdown" id="ge-selection-menu" hidden> <div class="ge-selection-menu dropdown" id="ge-selection-menu" hidden>
<button class="dropdown-item-compact" data-selection-action="all"><span>Select All</span><span class="dropdown-shortcut">Ctrl+A</span></button> <button class="dropdown-item-compact" data-selection-action="all"><span>Select All</span><span class="dropdown-shortcut">Ctrl+A</span></button>
<button class="dropdown-item-compact" data-selection-action="deselect"><span>Deselect</span><span class="dropdown-shortcut">Ctrl+D</span></button> <button class="dropdown-item-compact" data-selection-action="deselect"><span>Deselect</span><span class="dropdown-shortcut">Ctrl+D</span></button>
<button class="dropdown-item-compact" data-selection-action="reselect"><span>Reselect</span></button> <button class="dropdown-item-compact" data-selection-action="reselect"><span>Reselect</span></button>
<button class="dropdown-item-compact" data-selection-action="invert"><span>Invert</span><span class="dropdown-shortcut">Ctrl+Alt+Shift+I</span></button> <button class="dropdown-item-compact" data-selection-action="invert"><span>Invert</span><span class="dropdown-shortcut">Ctrl+Alt+Shift+I</span></button>
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="selection-modify" aria-haspopup="true" aria-expanded="false"><span>Modify</span><span class="ge-submenu-caret">›</span></button>
<div class="ge-submenu" data-submenu-panel="selection-modify">
<button class="dropdown-item-compact" data-selection-action="modifyBorder"><span>Border…</span></button>
<button class="dropdown-item-compact" data-selection-action="modifySmooth"><span>Smooth…</span></button>
<button class="dropdown-item-compact" data-selection-action="modifyExpand"><span>Expand…</span></button>
<button class="dropdown-item-compact" data-selection-action="modifyContract"><span>Contract…</span></button>
<button class="dropdown-item-compact" data-selection-action="modifyFeather"><span>Feather…</span></button>
</div>
<button class="dropdown-item-compact" data-selection-action="grow"><span>Grow</span></button>
<button class="dropdown-item-compact" data-selection-action="similar"><span>Similar</span></button>
<button class="dropdown-item-compact" data-selection-action="subject"><span>Subject</span></button>
<button class="dropdown-item-compact" data-selection-action="transform"><span>Transform Selection</span></button> <button class="dropdown-item-compact" data-selection-action="transform"><span>Transform Selection</span></button>
<button class="dropdown-item-compact" data-selection-action="refine"><span>Refine Selection…</span></button> <button class="dropdown-item-compact" data-selection-action="refine"><span>Refine Selection…</span></button>
<button class="dropdown-item-compact" data-selection-action="colorRange"><span>Color Range…</span></button>
<button class="dropdown-item-compact" data-selection-action="selectAndMask"><span>Select and Mask…</span></button>
<div class="dropdown-section-divider"></div> <div class="dropdown-section-divider"></div>
<div class="ge-selection-save-row"> <div class="ge-selection-save-row">
<input id="ge-selection-name" type="text" maxlength="100" placeholder="Selection name" aria-label="Saved selection name" /> <input id="ge-selection-name" type="text" maxlength="100" placeholder="Selection name" aria-label="Saved selection name" />
@@ -107,10 +246,13 @@ export function buildTopbar() {
<div class="ge-filter-wrap"> <div class="ge-filter-wrap">
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-filter-menu-btn" title="Filters" aria-haspopup="true"> <button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-filter-menu-btn" title="Filters" aria-haspopup="true">
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 5h16M7 12h10M10 19h4"/></svg><span class="ge-stacked-caret">▾</span></span> <span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 5h16M7 12h10M10 19h4"/></svg><span class="ge-stacked-caret">▾</span></span>
<span class="ge-stacked-label">FILTER</span> <span class="ge-stacked-label">Filter</span>
</button> </button>
<div class="ge-filter-menu dropdown" id="ge-filter-menu" hidden> <div class="ge-filter-menu dropdown" id="ge-filter-menu" hidden>
<div class="ge-filter-submenu-label">Retained effects</div> <div class="ge-menu-label">Enhance</div>
<button class="dropdown-item-compact" data-filter-action="dehaze"><span class="dropdown-icon" aria-hidden="true">≈</span><span>Haze Removal…</span></button>
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="filter-retained" aria-haspopup="true" aria-expanded="false"><span>Retained effects</span><span class="ge-submenu-caret">›</span></button>
<div class="ge-submenu" data-submenu-panel="filter-retained">
<button class="dropdown-item-compact" data-filter-action="effect-blur-gaussian"> <button class="dropdown-item-compact" data-filter-action="effect-blur-gaussian">
<span class="dropdown-icon ge-blur-icon ge-blur-gaussian" aria-hidden="true"></span> <span class="dropdown-icon ge-blur-icon ge-blur-gaussian" aria-hidden="true"></span>
<span>Gaussian Blur (retained)…</span> <span>Gaussian Blur (retained)…</span>
@@ -131,12 +273,28 @@ export function buildTopbar() {
<span class="dropdown-icon" aria-hidden="true">□</span> <span class="dropdown-icon" aria-hidden="true">□</span>
<span>Stroke (retained)…</span> <span>Stroke (retained)…</span>
</button> </button>
<div class="ge-filter-submenu-label">Presets</div> <button class="dropdown-item-compact" data-filter-action="effect-motion-blur">
<span class="dropdown-icon" aria-hidden="true">↔</span>
<span>Motion Blur (retained)…</span>
</button>
<button class="dropdown-item-compact" data-filter-action="effect-zoom-blur">
<span class="dropdown-icon" aria-hidden="true">✳</span>
<span>Zoom Blur (retained)…</span>
</button>
<button class="dropdown-item-compact" data-filter-action="effect-pixelate">
<span class="dropdown-icon" aria-hidden="true">▦</span>
<span>Pixelate (retained)…</span>
</button>
</div>
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="filter-presets" aria-haspopup="true" aria-expanded="false"><span>Presets</span><span class="ge-submenu-caret">›</span></button>
<div class="ge-submenu" data-submenu-panel="filter-presets">
<button class="dropdown-item-compact" data-filter-action="effect-preset-soft-blur"><span>Soft Blur</span></button> <button class="dropdown-item-compact" data-filter-action="effect-preset-soft-blur"><span>Soft Blur</span></button>
<button class="dropdown-item-compact" data-filter-action="effect-preset-crisp-detail"><span>Crisp Detail</span></button> <button class="dropdown-item-compact" data-filter-action="effect-preset-crisp-detail"><span>Crisp Detail</span></button>
<button class="dropdown-item-compact" data-filter-action="effect-preset-soft-shadow"><span>Soft Shadow</span></button> <button class="dropdown-item-compact" data-filter-action="effect-preset-soft-shadow"><span>Soft Shadow</span></button>
<button class="dropdown-item-compact" data-filter-action="effect-preset-white-outline"><span>White Outline</span></button> <button class="dropdown-item-compact" data-filter-action="effect-preset-white-outline"><span>White Outline</span></button>
<div class="ge-filter-submenu-label">Blur</div> </div>
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="filter-blur" aria-haspopup="true" aria-expanded="false"><span>Blur</span><span class="ge-submenu-caret">›</span></button>
<div class="ge-submenu" data-submenu-panel="filter-blur">
<button class="dropdown-item-compact" data-filter-action="blur-gaussian"> <button class="dropdown-item-compact" data-filter-action="blur-gaussian">
<span class="dropdown-icon ge-blur-icon ge-blur-gaussian" aria-hidden="true"></span> <span class="dropdown-icon ge-blur-icon ge-blur-gaussian" aria-hidden="true"></span>
<span>Gaussian Blur…</span> <span>Gaussian Blur…</span>
@@ -145,12 +303,229 @@ export function buildTopbar() {
<span class="dropdown-icon ge-blur-icon ge-blur-zoom" aria-hidden="true"></span> <span class="dropdown-icon ge-blur-icon ge-blur-zoom" aria-hidden="true"></span>
<span>Zoom Blur…</span> <span>Zoom Blur…</span>
</button> </button>
<button class="dropdown-item-compact" data-filter-action="blur-motion"><span>Motion Blur…</span></button>
<button class="dropdown-item-compact" data-filter-action="radial-blur"><span>Radial Blur…</span></button>
<button class="dropdown-item-compact" data-filter-action="blur-box"><span>Box Blur…</span></button>
<button class="dropdown-item-compact" data-filter-action="blur-surface"><span>Surface Blur…</span></button>
</div>
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="filter-sharpen" aria-haspopup="true" aria-expanded="false"><span>Sharpen</span><span class="ge-submenu-caret">›</span></button>
<div class="ge-submenu" data-submenu-panel="filter-sharpen">
<button class="dropdown-item-compact" data-filter-action="sharpen"><span>Sharpen…</span></button>
<button class="dropdown-item-compact" data-filter-action="sharpen-edges"><span>Sharpen Edges…</span></button>
<button class="dropdown-item-compact" data-filter-action="sharpen-more"><span>Sharpen More…</span></button>
<button class="dropdown-item-compact" data-filter-action="unsharp-mask"><span>Unsharp Mask…</span></button>
<button class="dropdown-item-compact" data-filter-action="smart-sharpen"><span>Smart Sharpen…</span></button>
</div>
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="filter-noise" aria-haspopup="true" aria-expanded="false"><span>Noise</span><span class="ge-submenu-caret">›</span></button>
<div class="ge-submenu" data-submenu-panel="filter-noise">
<button class="dropdown-item-compact" data-filter-action="noise-add"><span>Add Noise…</span></button>
<button class="dropdown-item-compact" data-filter-action="noise-despeckle"><span>Despeckle…</span></button>
<button class="dropdown-item-compact" data-filter-action="reduce-noise"><span>Reduce Noise…</span></button>
<button class="dropdown-item-compact" data-filter-action="dust-scratches"><span>Dust &amp; Scratches…</span></button>
<button class="dropdown-item-compact" data-filter-action="noise-median"><span>Median…</span></button>
</div>
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="filter-pixelate" aria-haspopup="true" aria-expanded="false"><span>Pixelate</span><span class="ge-submenu-caret">›</span></button>
<div class="ge-submenu" data-submenu-panel="filter-pixelate">
<button class="dropdown-item-compact" data-filter-action="pixelate-mosaic"><span>Mosaic…</span></button>
<button class="dropdown-item-compact" data-filter-action="pixelate-crystallize"><span>Crystallize…</span></button>
<button class="dropdown-item-compact" data-filter-action="pixelate-mosaic-tiles"><span>Mosaic Tiles…</span></button>
</div>
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="filter-stylize" aria-haspopup="true" aria-expanded="false"><span>Stylize</span><span class="ge-submenu-caret">›</span></button>
<div class="ge-submenu" data-submenu-panel="filter-stylize">
<button class="dropdown-item-compact" data-filter-action="stylize-emboss"><span>Emboss…</span></button>
<button class="dropdown-item-compact" data-filter-action="stylize-find-edges"><span>Find Edges…</span></button>
<button class="dropdown-item-compact" data-filter-action="stylize-glowing-edges"><span>Glowing Edges…</span></button>
<button class="dropdown-item-compact" data-filter-action="stylize-solarize"><span>Solarize…</span></button>
<button class="dropdown-item-compact" data-filter-action="stylize-oil-paint"><span>Oil Paint…</span></button>
<button class="dropdown-item-compact" data-filter-action="stylize-diffuse"><span>Diffuse…</span></button>
<button class="dropdown-item-compact" data-filter-action="stylize-wind"><span>Wind…</span></button>
<button class="dropdown-item-compact" data-filter-action="stylize-trace-contour"><span>Trace Contour…</span></button>
</div>
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="filter-sketch" aria-haspopup="true" aria-expanded="false"><span>Sketch</span><span class="ge-submenu-caret">›</span></button>
<div class="ge-submenu" data-submenu-panel="filter-sketch">
<button class="dropdown-item-compact" data-filter-action="sketch-halftone-pattern"><span>Halftone Pattern…</span></button>
<button class="dropdown-item-compact" data-filter-action="sketch-reticulation"><span>Reticulation…</span></button>
<button class="dropdown-item-compact" data-filter-action="sketch-note-paper"><span>Note Paper…</span></button>
<button class="dropdown-item-compact" data-filter-action="sketch-charcoal"><span>Charcoal…</span></button>
<button class="dropdown-item-compact" data-filter-action="sketch-chalk-charcoal"><span>Chalk &amp; Charcoal…</span></button>
<button class="dropdown-item-compact" data-filter-action="sketch-graphic-pen"><span>Graphic Pen…</span></button>
<button class="dropdown-item-compact" data-filter-action="sketch-photocopy"><span>Photocopy…</span></button>
<button class="dropdown-item-compact" data-filter-action="sketch-water-paper"><span>Water Paper…</span></button>
</div>
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="filter-artistic" aria-haspopup="true" aria-expanded="false"><span>Artistic</span><span class="ge-submenu-caret">›</span></button>
<div class="ge-submenu" data-submenu-panel="filter-artistic">
<button class="dropdown-item-compact" data-filter-action="artistic-poster-edges"><span>Poster Edges…</span></button>
<button class="dropdown-item-compact" data-filter-action="artistic-cutout"><span>Cutout…</span></button>
</div>
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="filter-brush" aria-haspopup="true" aria-expanded="false"><span>Brush Strokes</span><span class="ge-submenu-caret">›</span></button>
<div class="ge-submenu" data-submenu-panel="filter-brush">
<button class="dropdown-item-compact" data-filter-action="brush-ink-outlines"><span>Ink Outlines…</span></button>
<button class="dropdown-item-compact" data-filter-action="brush-crosshatch"><span>Crosshatch…</span></button>
</div>
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="filter-texture" aria-haspopup="true" aria-expanded="false"><span>Texture</span><span class="ge-submenu-caret">›</span></button>
<div class="ge-submenu" data-submenu-panel="filter-texture">
<button class="dropdown-item-compact" data-filter-action="texture-stained-glass"><span>Stained Glass…</span></button>
<button class="dropdown-item-compact" data-filter-action="texture-grain"><span>Grain…</span></button>
<button class="dropdown-item-compact" data-filter-action="texture-texturizer"><span>Texturizer…</span></button>
<button class="dropdown-item-compact" data-filter-action="texture-patchwork"><span>Patchwork…</span></button>
<button class="dropdown-item-compact" data-filter-action="texture-craquelure"><span>Craquelure…</span></button>
</div>
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="filter-distort" aria-haspopup="true" aria-expanded="false"><span>Distort</span><span class="ge-submenu-caret">›</span></button>
<div class="ge-submenu" data-submenu-panel="filter-distort">
<button class="dropdown-item-compact" data-filter-action="distort-twirl"><span>Twirl…</span></button>
<button class="dropdown-item-compact" data-filter-action="distort-pinch"><span>Pinch…</span></button>
<button class="dropdown-item-compact" data-filter-action="distort-spherize"><span>Spherize…</span></button>
<button class="dropdown-item-compact" data-filter-action="distort-ripple"><span>Ripple…</span></button>
<button class="dropdown-item-compact" data-filter-action="distort-polar"><span>Polar Coordinates…</span></button>
<button class="dropdown-item-compact" data-filter-action="distort-lens-correction"><span>Lens Correction…</span></button>
<button class="dropdown-item-compact" data-filter-action="distort-ocean-ripple"><span>Ocean Ripple…</span></button>
<button class="dropdown-item-compact" data-filter-action="distort-glass"><span>Glass…</span></button>
<button class="dropdown-item-compact" data-filter-action="distort-shear"><span>Shear…</span></button>
</div>
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="filter-render" aria-haspopup="true" aria-expanded="false"><span>Render</span><span class="ge-submenu-caret">›</span></button>
<div class="ge-submenu" data-submenu-panel="filter-render">
<button class="dropdown-item-compact" data-filter-action="render-clouds"><span>Clouds…</span></button>
<button class="dropdown-item-compact" data-filter-action="render-difference-clouds"><span>Difference Clouds…</span></button>
<button class="dropdown-item-compact" data-filter-action="render-lens-flare"><span>Lens Flare…</span></button>
<button class="dropdown-item-compact" data-filter-action="render-fibers"><span>Fibers…</span></button>
</div>
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="filter-other" aria-haspopup="true" aria-expanded="false"><span>Other</span><span class="ge-submenu-caret">›</span></button>
<div class="ge-submenu" data-submenu-panel="filter-other">
<button class="dropdown-item-compact" data-filter-action="other-offset"><span>Offset…</span></button>
<button class="dropdown-item-compact" data-filter-action="other-custom"><span>Custom…</span></button>
<button class="dropdown-item-compact" data-filter-action="other-maximum"><span>Maximum…</span></button>
<button class="dropdown-item-compact" data-filter-action="other-minimum"><span>Minimum…</span></button>
</div> </div>
</div> </div>
<span class="ge-topbar-sep"></span> </div>
<div class="ge-3d-wrap">
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-3d-menu-btn" title="3D" aria-haspopup="true">
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 3 7v10l9 5 9-5V7z"/><path d="M3 7l9 5 9-5"/><path d="M12 12v10"/></svg><span class="ge-stacked-caret">▾</span></span>
<span class="ge-stacked-label">3D</span>
</button>
<div class="ge-3d-menu dropdown" id="ge-3d-menu" hidden>
<div class="ge-menu-label">New 3D</div>
<button class="dropdown-item-compact" data-3d-action="from-file" disabled title="3D is not available in this build"><span>New 3D Layer from File…</span></button>
<button class="dropdown-item-compact" data-3d-action="from-layer" disabled title="3D is not available in this build"><span>New Mesh from Layer…</span></button>
<div class="ge-menu-label">Render</div>
<button class="dropdown-item-compact" data-3d-action="render" disabled title="3D is not available in this build"><span>Render 3D Layer</span></button>
<div class="ge-menu-label">3D Printing</div>
<button class="dropdown-item-compact" data-3d-action="print" disabled title="3D is not available in this build"><span>Export as 3D…</span></button>
<div class="dropdown-section-divider"></div>
<button class="dropdown-item-compact" data-3d-action="about" disabled title="3D is not available in this build"><span>About 3D support</span></button>
</div>
</div>
<div class="ge-view-wrap">
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-view-menu-btn" title="Canvas view" aria-haspopup="true">
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/></svg><span class="ge-stacked-caret">▾</span></span>
<span class="ge-stacked-label">View</span>
</button>
<div class="ge-view-menu dropdown" id="ge-view-menu" hidden>
<div class="ge-menu-label">Overlays</div>
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="rulers"><span class="dropdown-icon ge-view-check">✓</span><span>Rulers</span></button>
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="grid"><span class="dropdown-icon ge-view-check">✓</span><span>Grid</span></button>
<label class="ge-view-grid-size"><span>Grid size</span><input id="ge-grid-size" type="number" min="2" max="1000" step="1" value="16"><span>px</span></label>
<div class="ge-menu-label">Snapping</div>
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="snap"><span class="dropdown-icon ge-view-check">✓</span><span>Snap</span></button>
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="snap-grid"><span class="dropdown-icon ge-view-check">✓</span><span>Snap to grid</span></button>
<button class="dropdown-item-compact" data-view-action="new-h-guide" title="Add a horizontal guide across the middle"><span class="dropdown-icon">⊢</span><span>New horizontal guide</span></button>
<button class="dropdown-item-compact" data-view-action="new-v-guide" title="Add a vertical guide down the middle"><span class="dropdown-icon">⊣</span><span>New vertical guide</span></button>
<button class="dropdown-item-compact" data-view-action="clear-guides"><span class="dropdown-icon">×</span><span>Clear guides</span></button>
<div class="ge-menu-label">Panels</div>
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="panel-options"><span class="dropdown-icon ge-view-check">✓</span><span>Tool Options</span></button>
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="panel-layers"><span class="dropdown-icon ge-view-check">✓</span><span>Layers</span></button>
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="panel-swatches"><span class="dropdown-icon ge-view-check">✓</span><span>Swatches</span></button>
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="panel-gradients"><span class="dropdown-icon ge-view-check">✓</span><span>Gradients</span></button>
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="panel-patterns"><span class="dropdown-icon ge-view-check">✓</span><span>Patterns</span></button>
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="panel-properties"><span class="dropdown-icon ge-view-check">✓</span><span>Properties</span></button>
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="panel-adjustments"><span class="dropdown-icon ge-view-check">✓</span><span>Adjustments</span></button>
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="panel-histogram"><span class="dropdown-icon ge-view-check">✓</span><span>Histogram</span></button>
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="panel-info"><span class="dropdown-icon ge-view-check">✓</span><span>Info</span></button>
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="panel-channels"><span class="dropdown-icon ge-view-check">✓</span><span>Channels</span></button>
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="panel-paths"><span class="dropdown-icon ge-view-check">✓</span><span>Paths</span></button>
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="panel-character"><span class="dropdown-icon ge-view-check">✓</span><span>Character</span></button>
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="panel-navigator"><span class="dropdown-icon ge-view-check">✓</span><span>Navigator</span></button>
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="panel-history"><span class="dropdown-icon ge-view-check">✓</span><span>History</span></button>
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="panel-actions"><span class="dropdown-icon ge-view-check">✓</span><span>Actions</span></button>
<div class="ge-menu-label">Panel layout</div>
<button class="dropdown-item-compact" data-view-action="panel-show-all"><span class="dropdown-icon">▣</span><span>Show all</span></button>
<button class="dropdown-item-compact" data-view-action="panel-hide-all"><span class="dropdown-icon">▢</span><span>Hide all</span></button>
<button class="dropdown-item-compact" data-view-action="panel-reset"><span class="dropdown-icon">↺</span><span>Reset to default</span></button>
<div class="ge-menu-label">Screen</div>
<button class="dropdown-item-compact" data-view-action="screen-mode" title="Toggle a distraction-free, maximized screen mode"><span class="dropdown-icon">⛶</span><span>Screen Mode</span></button>
</div>
</div>
<div class="ge-plugins-wrap">
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-plugins-menu-btn" title="Plugins" aria-haspopup="true">
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h4a2 2 0 1 1 4 0h4v4a2 2 0 0 0 0 4v4H4v-4a2 2 0 0 0 0-4z"/></svg><span class="ge-stacked-caret">▾</span></span>
<span class="ge-stacked-label">Plugins</span>
</button>
<div class="ge-plugins-menu dropdown" id="ge-plugins-menu" hidden>
<div class="ge-menu-label">Plugins</div>
<button class="dropdown-item-compact" data-plugins-action="browse" disabled title="Plugins are not available in this build"><span>Browse Plugins…</span></button>
<button class="dropdown-item-compact" data-plugins-action="manage" disabled title="Plugins are not available in this build"><span>Manage Plugins…</span></button>
<div class="dropdown-section-divider"></div>
<button class="dropdown-item-compact" data-plugins-action="script" disabled title="Plugins are not available in this build"><span>Run Editor Script…</span></button>
</div>
</div>
<div class="ge-window-wrap">
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-window-menu-btn" title="Window panels" aria-haspopup="true">
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/></svg><span class="ge-stacked-caret">▾</span></span>
<span class="ge-stacked-label">Window</span>
</button>
<div class="ge-window-menu dropdown" id="ge-window-menu" hidden>
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="window-workspace" aria-haspopup="true" aria-expanded="false"><span>Workspace</span><span class="ge-submenu-caret">›</span></button>
<div class="ge-submenu" data-submenu-panel="window-workspace">
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="workspace-essentials"><span class="dropdown-icon ge-window-check">●</span><span>Essentials</span></button>
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="workspace-photography"><span class="dropdown-icon ge-window-check">●</span><span>Photography</span></button>
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="workspace-painting"><span class="dropdown-icon ge-window-check">●</span><span>Painting</span></button>
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="workspace-graphic"><span class="dropdown-icon ge-window-check">●</span><span>Graphic &amp; Web</span></button>
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="workspace-minimal"><span class="dropdown-icon ge-window-check">●</span><span>Minimal</span></button>
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="workspace-custom"><span class="dropdown-icon ge-window-check">●</span><span>Custom</span></button>
<div class="ge-submenu-sep"></div>
<button type="button" class="dropdown-item-compact ge-window-save" data-window-action="workspace-save"><span class="dropdown-icon">+</span><span>Save Current</span></button>
</div>
<div class="ge-menu-label">Panels</div>
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="quick-edit"><span class="dropdown-icon ge-window-check">●</span><span>Quick Edit</span></button>
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="options"><span class="dropdown-icon ge-window-check">●</span><span>Tool Options</span></button>
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="layers"><span class="dropdown-icon ge-window-check">●</span><span>Layers</span></button>
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="properties"><span class="dropdown-icon ge-window-check">●</span><span>Properties</span></button>
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="character"><span class="dropdown-icon ge-window-check">●</span><span>Character</span></button>
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="adjustments"><span class="dropdown-icon ge-window-check">●</span><span>Adjustments</span></button>
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="swatches"><span class="dropdown-icon ge-window-check">●</span><span>Swatches</span></button>
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="gradients"><span class="dropdown-icon ge-window-check">●</span><span>Gradients</span></button>
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="channels"><span class="dropdown-icon ge-window-check">●</span><span>Channels</span></button>
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="paths"><span class="dropdown-icon ge-window-check">●</span><span>Paths</span></button>
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="patterns"><span class="dropdown-icon ge-window-check">●</span><span>Patterns</span></button>
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="histogram"><span class="dropdown-icon ge-window-check">●</span><span>Histogram</span></button>
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="info"><span class="dropdown-icon ge-window-check">●</span><span>Info</span></button>
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="navigator"><span class="dropdown-icon ge-window-check">●</span><span>Navigator</span></button>
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="history"><span class="dropdown-icon ge-window-check">●</span><span>History</span></button>
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="actions"><span class="dropdown-icon ge-window-check">●</span><span>Actions</span></button>
<div class="ge-menu-label">Help</div>
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="shortcuts"><span class="dropdown-icon ge-window-check">●</span><span>Keyboard Shortcuts</span></button>
</div>
</div>
<div class="ge-help-wrap">
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-help-menu-btn" title="Help" aria-haspopup="true">
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg><span class="ge-stacked-caret">▾</span></span>
<span class="ge-stacked-label">Help</span>
</button>
<div class="ge-help-menu dropdown" id="ge-help-menu" hidden>
<div class="ge-menu-label">Help</div>
<button class="dropdown-item-compact" data-help-action="shortcuts"><span>Keyboard Shortcuts</span></button>
<button class="dropdown-item-compact" data-help-action="about"><span>About Ajax Image Editor</span></button>
</div>
</div>
</div>
<div class="ge-topbar-right">
<span class="ge-alpha-badge" title="This editor is in active development — expect rough edges">ALPHA</span>
<button class="ge-btn ge-btn-sm" id="ge-shortcuts-btn" title="Keyboard shortcuts (?)" aria-label="Shortcuts"> <button class="ge-btn ge-btn-sm" id="ge-shortcuts-btn" title="Keyboard shortcuts (?)" aria-label="Shortcuts">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="position:relative;top:2px;"><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M6 10h.01M10 10h.01M14 10h.01M18 10h.01M7 14h10"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="position:relative;top:2px;"><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M6 10h.01M10 10h.01M14 10h.01M18 10h.01M7 14h10"/></svg>
</button> </button>
<button class="ge-btn ge-btn-sm" id="ge-maximize-toggle" title="Maximize editor" aria-label="Maximize editor" aria-pressed="false">
<svg class="ge-max-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
</button>
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-import-topbar" title="Import image as layer"> <button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-import-topbar" title="Import image as layer">
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12"/><polyline points="7 8 12 3 17 8"/><path d="M5 14v5h14v-5"/></svg></span> <span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12"/><polyline points="7 8 12 3 17 8"/><path d="M5 14v5h14v-5"/></svg></span>
<span class="ge-stacked-label">IMPORT</span> <span class="ge-stacked-label">IMPORT</span>
@@ -160,7 +535,9 @@ export function buildTopbar() {
<span class="ge-stacked-glyph"> <span class="ge-stacked-glyph">
<svg hidden class="ge-save-state-icon ge-save-state-dirty" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 3h12l4 4v14H4z"/><path d="M8 3v6h8V3"/><path d="m16 3 6 6m0-6-6 6" stroke="var(--fg)" stroke-width="3"/></svg> <svg hidden class="ge-save-state-icon ge-save-state-dirty" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 3h12l4 4v14H4z"/><path d="M8 3v6h8V3"/><path d="m16 3 6 6m0-6-6 6" stroke="var(--fg)" stroke-width="3"/></svg>
<svg class="ge-save-state-icon ge-save-state-saved" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg> <svg class="ge-save-state-icon ge-save-state-saved" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>
<svg hidden class="ge-save-state-icon ge-save-state-error" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
</span> </span>
<span class="ge-stacked-label ge-save-action-label">SAVE</span>
<span class="ge-stacked-label ge-save-label" id="ge-draft-status" role="status" aria-live="polite">Saved</span> <span class="ge-stacked-label ge-save-label" id="ge-draft-status" role="status" aria-live="polite">Saved</span>
</button> </button>
<div class="ge-save-menu dropdown" id="ge-save-menu" hidden> <div class="ge-save-menu dropdown" id="ge-save-menu" hidden>
@@ -179,6 +556,10 @@ export function buildTopbar() {
<span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></span> <span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></span>
<span>Export image...</span> <span>Export image...</span>
</button> </button>
<button class="dropdown-item-compact" id="ge-export-png" title="Download the flattened image as a PNG file">
<span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 15l4-4 5 5"/><circle cx="9" cy="8" r="1.3"/></svg></span>
<span>Save as PNG</span>
</button>
<div class="dropdown-section-divider"></div> <div class="dropdown-section-divider"></div>
<div class="dropdown-section-label">Project</div> <div class="dropdown-section-label">Project</div>
<button class="dropdown-item-compact" id="ge-save-project" title="Save layered project (.json) — keeps every layer editable for later"> <button class="dropdown-item-compact" id="ge-save-project" title="Save layered project (.json) — keeps every layer editable for later">
@@ -200,7 +581,15 @@ export function buildZoomFooter() {
const footer = document.createElement('div'); const footer = document.createElement('div');
footer.className = 'ge-editor-footer'; footer.className = 'ge-editor-footer';
footer.innerHTML = ` footer.innerHTML = `
<span class="ge-canvas-size" id="ge-canvas-size" title="Canvas size" hidden></span> <div class="ge-footer-history" role="group" aria-label="History">
<button class="ge-btn ge-btn-sm" id="ge-undo" title="Undo" aria-label="Undo">↩</button>
<button class="ge-btn ge-btn-sm" id="ge-redo" title="Redo" aria-label="Redo">↪</button>
<button class="ge-btn ge-btn-sm" id="ge-history-btn" title="History — click an entry to jump to that state" aria-label="History"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 4v6h6"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/><polyline points="12 7 12 12 16 14"/></svg></button>
<button class="ge-btn ge-btn-sm" id="ge-compare-btn" title="Show the document before editing" aria-label="Show the document before editing" aria-pressed="false"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v18"/><path d="M5 7h14M5 17h14"/></svg></button>
</div>
<span class="ge-status-tool" id="ge-status-tool" aria-live="polite"></span>
<span class="ge-canvas-size" id="ge-canvas-size" title="Document size"></span>
<span class="ge-status-model" id="ge-status-model" title="AI edit model"></span>
<div class="ge-footer-zoom" role="group" aria-label="Canvas zoom"> <div class="ge-footer-zoom" role="group" aria-label="Canvas zoom">
<button class="ge-btn ge-btn-sm" id="ge-zoom-out" title="Zoom out" aria-label="Zoom out">&minus;</button> <button class="ge-btn ge-btn-sm" id="ge-zoom-out" title="Zoom out" aria-label="Zoom out">&minus;</button>
<span class="ge-zoom-stack" aria-live="off"> <span class="ge-zoom-stack" aria-live="off">
+34 -2
View File
@@ -65,6 +65,38 @@ export function transformPopupHTML() {
<button type="button" data-spin="up" tabindex="-1" aria-label="Rotate +1°">+</button> <button type="button" data-spin="up" tabindex="-1" aria-label="Rotate +1°">+</button>
</span> </span>
</div> </div>
<div class="ge-transform-field">
<label>⇱</label>
<input type="number" class="ge-transform-popup-input" id="ge-transform-skew-x" step="1" value="0" aria-label="Horizontal skew" />
<span class="ge-transform-spin" data-spin-for="ge-transform-skew-x">
<button type="button" data-spin="down" tabindex="-1" aria-label="Skew X -1°">−</button>
<button type="button" data-spin="up" tabindex="-1" aria-label="Skew X +1°">+</button>
</span>
</div>
<div class="ge-transform-field">
<label>⇲</label>
<input type="number" class="ge-transform-popup-input" id="ge-transform-skew-y" step="1" value="0" aria-label="Vertical skew" />
<span class="ge-transform-spin" data-spin-for="ge-transform-skew-y">
<button type="button" data-spin="down" tabindex="-1" aria-label="Skew Y -1°">−</button>
<button type="button" data-spin="up" tabindex="-1" aria-label="Skew Y +1°">+</button>
</span>
</div>
<div class="ge-transform-field">
<label>⤒</label>
<input type="number" class="ge-transform-popup-input" id="ge-transform-persp-x" step="1" value="0" aria-label="Horizontal perspective" />
<span class="ge-transform-spin" data-spin-for="ge-transform-persp-x">
<button type="button" data-spin="down" tabindex="-1" aria-label="Perspective X -1">−</button>
<button type="button" data-spin="up" tabindex="-1" aria-label="Perspective X +1">+</button>
</span>
</div>
<div class="ge-transform-field">
<label>⤓</label>
<input type="number" class="ge-transform-popup-input" id="ge-transform-persp-y" step="1" value="0" aria-label="Vertical perspective" />
<span class="ge-transform-spin" data-spin-for="ge-transform-persp-y">
<button type="button" data-spin="down" tabindex="-1" aria-label="Perspective Y -1">−</button>
<button type="button" data-spin="up" tabindex="-1" aria-label="Perspective Y +1">+</button>
</span>
</div>
<span class="ge-transform-quick" aria-label="Transform actions"> <span class="ge-transform-quick" aria-label="Transform actions">
<button type="button" class="ge-transform-quick-btn" id="ge-transform-flip-h" title="Flip horizontally" aria-label="Flip horizontally"> <button type="button" class="ge-transform-quick-btn" id="ge-transform-flip-h" title="Flip horizontally" aria-label="Flip horizontally">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v18"/><path d="m8 7-4 5 4 5"/><path d="m16 7 4 5-4 5"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v18"/><path d="m8 7-4 5 4 5"/><path d="m16 7 4 5-4 5"/></svg>
@@ -79,8 +111,8 @@ export function transformPopupHTML() {
<button type="button" class="ge-btn ge-btn-sm" id="ge-transform-cancel-btn">Cancel</button> <button type="button" class="ge-btn ge-btn-sm" id="ge-transform-cancel-btn">Cancel</button>
<button type="button" class="ge-btn ge-btn-sm ge-btn-primary" id="ge-transform-apply">Apply</button> <button type="button" class="ge-btn ge-btn-sm ge-btn-primary" id="ge-transform-apply">Apply</button>
</div> </div>
<label class="ge-transform-preserve-field"> <label class="ge-transform-preserve-field" title="Preserve source pixels (applies the transform non-destructively)">
<input type="checkbox" id="ge-transform-preserve-source" /> <input type="checkbox" id="ge-transform-preserve-source" aria-label="Preserve source pixels" />
<span>Preserve source pixels</span> <span>Preserve source pixels</span>
</label> </label>
<p class="ge-transform-popup-hint">Type <strong>-</strong> before W / H to flip.</p> <p class="ge-transform-popup-hint">Type <strong>-</strong> before W / H to flip.</p>
+319
View File
@@ -0,0 +1,319 @@
/**
* Tool-aware canvas context menu (Photoshop-style right-click).
*
* A right-click on the image opens a menu whose items depend on the
* active tool: paint tools get brush size/presets, selection tools get
* select/feather/expand actions, the crop tool gets Apply/Cancel, the
* transform tool gets commit/cancel, and so on. Every action re-uses the
* existing control ops (window.geEditorControl.invoke) or clicks the
* existing topbar menu items, so there is a single source of truth.
*
* Wire it with wireCanvasContextMenu(canvasArea) from canvas-events.js.
*/
import { state } from './state.js';
const PAINT = ['brush', 'eraser', 'clone', 'heal', 'dodge', 'burn', 'smudge', 'inpaint', 'rembg', 'sharpen'];
const SELECT = ['marquee', 'lasso', 'wand', 'quickselect', 'objectselect', 'pen', 'path', 'sam'];
const TRANSFORM = ['move', 'transform'];
let rootMenu = null;
let subMenu = null;
let cleanups = [];
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const hasSelection = () => !!(state.wandMask || (state.lassoPoints && state.lassoPoints.length >= 3));
function invoke(op, params) {
try { return window.geEditorControl?.invoke(op, params || {}); } catch (err) { console.warn('[ge-canvas-menu]', op, err); }
}
function clickEl(selector) { document.querySelector(selector)?.click(); }
function clickMenu(selector) { document.querySelector(selector)?.click(); }
function dispatchEscape() {
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
}
function stepBrushSize(factor) {
const slider = document.querySelector('.ge-size-slider');
if (!slider) return;
const next = Math.max(1, Math.min(800, Math.round((state.brushSize || 1) * factor)));
slider.value = String(Math.round(Math.log(next) / Math.log(800) * 1000));
slider.dispatchEvent(new Event('input', { bubbles: true }));
}
function applyPresetId(id) {
const sel = document.getElementById('ge-brush-preset');
if (!sel) return;
sel.value = id;
sel.dispatchEvent(new Event('change', { bubbles: true }));
}
function presetItems() {
const sel = document.getElementById('ge-brush-preset');
if (!sel) return [];
return [...sel.options].map((opt) => ({
label: opt.textContent,
checked: opt.value === sel.value,
onSelect: () => applyPresetId(opt.value),
}));
}
function promptNumber(message, fallback, run) {
const raw = window.prompt(message, String(fallback));
if (raw == null) return;
const value = Number(raw);
if (Number.isFinite(value)) run(value);
}
/** Build the menu item descriptors for a tool. Exported for tests. */
export function buildCanvasMenuItems(tool) {
const items = [];
const push = (...x) => items.push(...x);
const sep = () => items.push({ type: 'sep' });
const sel = hasSelection();
if (PAINT.includes(tool)) {
push(
{ label: 'Increase Brush Size', hint: ']', onSelect: () => stepBrushSize(1.25) },
{ label: 'Decrease Brush Size', hint: '[', onSelect: () => stepBrushSize(0.8) },
{ label: 'Brush Preset', submenu: () => presetItems() },
{
label: 'Pixel Brush',
checked: !!state.brushPixel,
onSelect: () => applyPresetId(state.brushPixel ? 'hard-round' : 'pixel-1px'),
},
);
sep();
} else if (SELECT.includes(tool)) {
push(
{ label: 'Select All', hint: 'Ctrl+A', onSelect: () => invoke('selection.selectAll') },
{ label: 'Deselect', hint: 'Ctrl+D', disabled: !sel, onSelect: () => invoke('selection.deselect') },
{ label: 'Inverse Selection', disabled: !sel, onSelect: () => invoke('selection.invert') },
{ label: 'Transform Selection', disabled: !sel, onSelect: () => clickMenu('[data-selection-action="transform"]') },
{ label: 'Refine Edge…', disabled: !sel, onSelect: () => clickMenu('[data-selection-action="refine"]') },
);
sep();
push(
{ label: 'Feather…', disabled: !sel, onSelect: () => promptNumber('Feather radius (px)', 8, (v) => invoke('selection.feather', { radius: v })) },
{ label: 'Expand', disabled: !sel, onSelect: () => invoke('selection.expand', { amount: 4 }) },
{ label: 'Contract', disabled: !sel, onSelect: () => invoke('selection.contract', { amount: 4 }) },
);
if (sel) {
push(
{ label: 'Fill Selection', onSelect: () => invoke('selection.fill', { color: state.color }) },
{ label: 'Delete Selection', hint: 'Del', onSelect: () => invoke('selection.delete') },
);
}
sep();
} else if (tool === 'crop' || state.cropRect) {
const cropping = !!(state.cropRect || state.cropping || state.cropMoving);
push(
{ label: 'Apply Crop', disabled: !cropping, onSelect: () => clickEl('.ge-crop-apply-btn') },
{ label: 'Cancel Crop', hint: 'Esc', disabled: !cropping, onSelect: () => dispatchEscape() },
);
sep();
} else if (TRANSFORM.includes(tool) || state.transformActive) {
push(
{ label: 'Commit Transform', hint: 'Enter', disabled: !state.transformActive, onSelect: () => invoke('transform.apply') },
{ label: 'Cancel Transform', hint: 'Esc', disabled: !state.transformActive, onSelect: () => invoke('transform.cancel') },
{ label: 'Free Transform', hint: 'Ctrl+T', disabled: state.transformActive, onSelect: () => invoke('transform.start') },
);
sep();
} else if (tool === 'text') {
push(
{ label: 'Add Text Layer', onSelect: () => invoke('text.add', { text: 'Text' }) },
{ label: 'Rasterize Text', onSelect: () => invoke('layers.rasterize') },
);
sep();
} else if (tool === 'eyedropper') {
push(
{ label: 'Copy Color Hex', onSelect: () => { try { navigator.clipboard?.writeText(state.color || ''); } catch (_) {} } },
{ label: 'Swap Foreground / Background', onSelect: () => clickEl('#ge-swap-colors') },
);
sep();
} else if (tool === 'hand') {
push(
{ label: 'Zoom In', hint: 'Ctrl++', onSelect: () => clickEl('#ge-zoom-in') },
{ label: 'Zoom Out', hint: 'Ctrl+-', onSelect: () => clickEl('#ge-zoom-out') },
{ label: 'Fit on Screen', hint: 'Ctrl+0', onSelect: () => clickEl('#ge-zoom-100') },
);
sep();
} else if (tool === 'gradient' || tool === 'shape') {
push(
{ label: 'Fill Selection with Foreground', disabled: !sel, onSelect: () => invoke('selection.fill', { color: state.color }) },
);
sep();
}
if (!SELECT.includes(tool) && sel) {
push(
{ label: 'Deselect', hint: 'Ctrl+D', onSelect: () => invoke('selection.deselect') },
{ label: 'Inverse Selection', onSelect: () => invoke('selection.invert') },
{ label: 'Fill Selection', onSelect: () => invoke('selection.fill', { color: state.color }) },
{ label: 'Delete Selection', hint: 'Del', onSelect: () => invoke('selection.delete') },
);
sep();
}
push(
{ label: 'Cut', hint: 'Ctrl+X', disabled: !sel, onSelect: () => invoke('selection.copy', { cut: true }) },
{ label: 'Copy', hint: 'Ctrl+C', disabled: !sel, onSelect: () => invoke('selection.copy') },
{ label: 'Paste', hint: 'Ctrl+V', onSelect: () => invoke('selection.paste') },
sep(),
{ label: 'Select All', hint: 'Ctrl+A', onSelect: () => invoke('selection.selectAll') },
{ label: 'Deselect', hint: 'Ctrl+D', disabled: !sel, onSelect: () => invoke('selection.deselect') },
sep(),
{ label: 'Rotate 90° CW', onSelect: () => invoke('layer.rotate', { degrees: 90 }) },
{ label: 'Rotate 180°', onSelect: () => invoke('layer.rotate', { degrees: 180 }) },
{ label: 'Flip Horizontal', onSelect: () => invoke('layer.flip', { axis: 'h' }) },
{ label: 'Flip Vertical', onSelect: () => invoke('layer.flip', { axis: 'v' }) },
sep(),
{ label: 'Undo', hint: 'Ctrl+Z', onSelect: () => invoke('history.undo') },
{ label: 'Redo', hint: 'Ctrl+Shift+Z', onSelect: () => invoke('history.redo') },
);
return items;
}
function buildItemEl(item, close) {
if (item.type === 'sep') {
const hr = document.createElement('div');
hr.className = 'ge-canvas-menu-sep';
return hr;
}
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'ge-canvas-menu-item';
btn.setAttribute('role', 'menuitem');
btn.disabled = !!item.disabled;
if (item.checked) btn.classList.add('is-checked');
const lead = document.createElement('span');
lead.className = 'ge-canvas-menu-lead';
lead.textContent = item.checked ? '✓' : '';
const label = document.createElement('span');
label.className = 'ge-canvas-menu-label';
label.textContent = item.label;
btn.append(lead, label);
if (item.submenu) {
btn.classList.add('has-submenu');
const caret = document.createElement('span');
caret.className = 'ge-canvas-menu-caret';
caret.textContent = '›';
btn.appendChild(caret);
btn.addEventListener('pointerenter', () => openSubmenu(btn, item.submenu));
} else if (item.hint) {
const hint = document.createElement('span');
hint.className = 'ge-canvas-menu-hint';
hint.textContent = item.hint;
btn.appendChild(hint);
}
btn.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
if (item.disabled) return;
if (item.submenu) return;
try { item.onSelect?.(); } finally { close(); }
});
btn.addEventListener('pointerenter', () => { closeSubmenu(); });
return btn;
}
function buildMenuEl(items, close) {
const menu = document.createElement('div');
menu.className = 'ge-canvas-menu';
menu.setAttribute('role', 'menu');
for (const item of items) menu.appendChild(buildItemEl(item, close));
return menu;
}
function placeMenu(menu, x, y) {
menu.style.left = '0px';
menu.style.top = '0px';
document.body.appendChild(menu);
const rect = menu.getBoundingClientRect();
const left = clamp(x, 4, Math.max(4, window.innerWidth - rect.width - 4));
const top = clamp(y, 4, Math.max(4, window.innerHeight - rect.height - 4));
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
}
function closeSubmenu() {
subMenu?.remove();
subMenu = null;
}
function openSubmenu(anchor, spec) {
closeSubmenu();
const items = typeof spec === 'function' ? spec() : spec;
if (!items || !items.length) return;
subMenu = buildMenuEl(items, closeAll);
subMenu.classList.add('ge-canvas-menu-sub');
document.body.appendChild(subMenu);
const a = anchor.getBoundingClientRect();
const r = subMenu.getBoundingClientRect();
let left = a.right - 4;
if (left + r.width > window.innerWidth - 4) left = a.left - r.width + 4;
const top = clamp(a.top - 4, 4, Math.max(4, window.innerHeight - r.height - 4));
subMenu.style.left = `${left}px`;
subMenu.style.top = `${top}px`;
}
function moveFocus(menu, delta) {
const focusables = [...menu.querySelectorAll('.ge-canvas-menu-item:not([disabled])')];
if (!focusables.length) return;
const current = focusables.indexOf(document.activeElement);
const next = clamp((current < 0 ? (delta > 0 ? -1 : 0) : current) + delta, 0, focusables.length - 1);
focusables[next]?.focus();
}
export function closeCanvasContextMenu() {
for (const fn of cleanups) { try { fn(); } catch (_) {} }
cleanups = [];
closeSubmenu();
rootMenu?.remove();
rootMenu = null;
}
/** Open the tool-aware menu at a pointer event (or {clientX, clientY}). */
export function openCanvasContextMenu(event) {
if (!state.editorOpen || !event) return;
closeCanvasContextMenu();
const menu = buildMenuEl(buildCanvasMenuItems(state.tool), closeCanvasContextMenu);
placeMenu(menu, event.clientX, event.clientY);
rootMenu = menu;
menu.querySelector('.ge-canvas-menu-item:not([disabled])')?.focus();
const onDown = (ev) => {
if (menu.contains(ev.target)) return;
if (subMenu && subMenu.contains(ev.target)) return;
closeCanvasContextMenu();
};
const onKey = (ev) => {
if (ev.key === 'Escape') { ev.preventDefault(); closeCanvasContextMenu(); }
else if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') { ev.preventDefault(); moveFocus(menu, ev.key === 'ArrowDown' ? 1 : -1); }
else if (ev.key === 'Enter') {
const active = document.activeElement;
if (active?.classList?.contains('ge-canvas-menu-item')) { ev.preventDefault(); active.click(); }
}
};
const onAway = () => closeCanvasContextMenu();
document.addEventListener('pointerdown', onDown, true);
document.addEventListener('keydown', onKey, true);
window.addEventListener('resize', onAway);
window.addEventListener('blur', onAway);
cleanups.push(() => document.removeEventListener('pointerdown', onDown, true));
cleanups.push(() => document.removeEventListener('keydown', onKey, true));
cleanups.push(() => window.removeEventListener('resize', onAway));
cleanups.push(() => window.removeEventListener('blur', onAway));
}
/** Wire right-click on the image (and the surrounding canvas area). */
export function wireCanvasContextMenu(canvasArea, { signal } = {}) {
const canvas = state.mainCanvas;
if (!canvas) return;
const opts = signal ? { signal } : undefined;
canvas.addEventListener('contextmenu', (event) => {
event.preventDefault();
openCanvasContextMenu(event);
}, opts);
canvasArea?.addEventListener('contextmenu', (event) => {
if (event.target === canvas) return;
if (event.target.closest?.('input, textarea, .ge-canvas-menu')) return;
event.preventDefault();
openCanvasContextMenu(event);
}, opts);
}
+22 -1
View File
@@ -8,8 +8,29 @@
* @param {HTMLCanvasElement} canvas * @param {HTMLCanvasElement} canvas
* @returns {{x: number, y: number}} * @returns {{x: number, y: number}}
*/ */
import { state } from './state.js';
let _cachedCanvas = null;
let _cachedRect = null;
let _cachedRectTime = 0;
export function invalidateCanvasCoordsCache() {
_cachedCanvas = null;
_cachedRect = null;
_cachedRectTime = 0;
}
export function canvasCoords(e, canvas) { export function canvasCoords(e, canvas) {
const rect = canvas.getBoundingClientRect(); const now = performance.now();
let rect;
if (_cachedCanvas === canvas && _cachedRect && (state.drawing || (now - _cachedRectTime < 64))) {
rect = _cachedRect;
} else {
rect = canvas.getBoundingClientRect();
_cachedCanvas = canvas;
_cachedRect = rect;
_cachedRectTime = now;
}
const scaleX = canvas.width / rect.width; const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height; const scaleY = canvas.height / rect.height;
const clientX = e.touches && e.touches.length ? e.touches[0].clientX : e.clientX; const clientX = e.touches && e.touches.length ? e.touches[0].clientX : e.clientX;
+123 -8
View File
@@ -16,6 +16,12 @@
* touchmove 1 finger → continueDraw * touchmove 1 finger → continueDraw
* touchend → endDraw; touchcancel → cancelDraw * touchend → endDraw; touchcancel → cancelDraw
* touchstart 2 fingers → pinch-zoom + 2-finger pan * touchstart 2 fingers → pinch-zoom + 2-finger pan
* 2-finger tap → undo (onTwoFingerTap)
* 3-finger tap → redo (onThreeFingerTap)
*
* A multi-finger gesture reverts the accidental single-finger stroke
* begun by the first finger to land (cancelDraw), so a pinch or a
* tap never leaves a stray dab behind.
* *
* Pan: * Pan:
* Hand tool / held Space / middle mouse can drag directly over the * Hand tool / held Space / middle mouse can drag directly over the
@@ -33,23 +39,53 @@
* cancelDraw?: () => void, * cancelDraw?: () => void,
* updateBrushCursor: (e: Event) => void, * updateBrushCursor: (e: Event) => void,
* syncZoomControls?: () => void, * syncZoomControls?: () => void,
* onTwoFingerTap?: () => void,
* onThreeFingerTap?: () => void,
* }} ctx * }} ctx
*/ */
import { state } from './state.js'; import { state } from './state.js';
import { invalidateCanvasCoordsCache } from './canvas-coords.js';
import { import {
applyCanvasPan, applyCanvasPan,
isDirectPanIntent, isDirectPanIntent,
nextPanOffset, nextPanOffset,
syncPanCursor, syncPanCursor,
} from './canvas-navigation.js'; } from './canvas-navigation.js';
import { wireCanvasContextMenu } from './canvas-context-menu.js';
let canvasWindowBindings; let canvasWindowBindings;
export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw, cancelDraw, updateBrushCursor, updateEyedropperPreview, syncZoomControls, onViewportChange }) { export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw, cancelDraw, updateBrushCursor, updateEyedropperPreview, syncZoomControls, onViewportChange, onTwoFingerTap, onThreeFingerTap }) {
canvasWindowBindings?.abort(); canvasWindowBindings?.abort();
canvasWindowBindings = new AbortController(); canvasWindowBindings = new AbortController();
const { signal } = canvasWindowBindings; const { signal } = canvasWindowBindings;
// Invalidate coordinate cache on layout, scroll, resize or orientation shifts
window.addEventListener('resize', invalidateCanvasCoordsCache, { signal, passive: true });
window.addEventListener('scroll', invalidateCanvasCoordsCache, { signal, passive: true });
window.addEventListener('orientationchange', invalidateCanvasCoordsCache, { signal, passive: true });
canvasArea.addEventListener('scroll', invalidateCanvasCoordsCache, { passive: true });
let suppressMouseUntil = 0; let suppressMouseUntil = 0;
// Tool-aware right-click menu on the image and the surrounding area.
wireCanvasContextMenu(canvasArea, { signal });
// Window-level drag handlers exist so a gesture can continue past the
// canvas edge. But the direct-manipulation selection/crop rects are
// built from raw pointer coords, so pointer motion over editor chrome
// (topbar, toolbars, panels) must not feed the rect. Brush/transform/
// move/resize gestures DO keep tracking over chrome — overshooting the
// canvas edge is normal for those and was the reason for the window
// binding.
const UI_CHROME_SELECTOR = '.ge-topbar, .ge-toolbar, .ge-controls, .ge-right-panel, '
+ '.ge-dock, .ge-options-bar, .ge-canvas-toolbar, button, input, select, textarea';
const isUiChrome = e => !!e.target?.closest?.(UI_CHROME_SELECTOR);
// Only in-progress selection/crop drags are affected by the chrome
// guard. A committed cropRect/selection is a resting state — clicking
// its Apply button must not be treated as "strayed onto chrome".
const directGestureActive = () => !!(
state.selectionMoving || state.marqueeActive ||
state.lassoActive || (state.lassoPoints && state.lassoPoints.length) ||
state.cropping || state.cropMoving
);
// Mouse — mousedown stays on the canvas; mousemove/up are bound to // Mouse — mousedown stays on the canvas; mousemove/up are bound to
// the WINDOW so a drag can continue (and end) past the canvas edge. // the WINDOW so a drag can continue (and end) past the canvas edge.
// Critical for the Resize tool where users overshoot. // Critical for the Resize tool where users overshoot.
@@ -60,12 +96,25 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
}); });
window.addEventListener('mousemove', (e) => { window.addEventListener('mousemove', (e) => {
if (Date.now() < suppressMouseUntil) return; if (Date.now() < suppressMouseUntil) return;
// Only shield the raw-coordinate selection/crop rects from chrome.
if (directGestureActive() && isUiChrome(e)) return;
continueDraw(e); continueDraw(e);
}, { signal }); }, { signal });
window.addEventListener('mouseup', (e) => { window.addEventListener('mouseup', (e) => {
if (Date.now() < suppressMouseUntil) return; if (Date.now() < suppressMouseUntil) return;
// Releasing over chrome commits the gesture; the marquee/crop end
// paths clamp the rect to the document, so an overshoot onto a panel
// is kept (Photoshop behaviour), not discarded.
endDraw(e); endDraw(e);
}, { signal }); }, { signal });
// A tool switch happens on the tool button, before the release. Cancel
// any in-progress direct gesture first so its move is reverted rather
// than committed by the subsequent chrome mouseup. Explicit tool
// switches are the only place we abandon a selection/crop gesture.
window.addEventListener('mousedown', (e) => {
if (!directGestureActive()) return;
if (e.target?.closest?.('.ge-tool-btn')) cancelDraw?.();
}, { capture: true, signal });
// Preserve pressure and browser-coalesced samples for pen input. // Preserve pressure and browser-coalesced samples for pen input.
// Compatibility mouse events are briefly suppressed to avoid a // Compatibility mouse events are briefly suppressed to avoid a
// duplicate stroke after pointerup. // duplicate stroke after pointerup.
@@ -94,12 +143,15 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
}; };
state.mainCanvas.addEventListener('pointerup', endPen); state.mainCanvas.addEventListener('pointerup', endPen);
state.mainCanvas.addEventListener('pointercancel', endPen); state.mainCanvas.addEventListener('pointercancel', endPen);
// Lasso can start OUTSIDE the canvas — fallback mousedown on the // Lasso and Crop can start OUTSIDE the canvas — fallback mousedown on the
// surrounding canvas-area so the user can begin a lasso path in // surrounding canvas-area so the user can begin a path/rect in the empty
// the empty space around the image. Other tools stay canvas-only. // space around the image. Other tools stay canvas-only.
canvasArea.addEventListener('mousedown', (e) => { canvasArea.addEventListener('mousedown', (e) => {
if (state.tool !== 'lasso') return; if (state.tool !== 'lasso' && state.tool !== 'crop') return;
if (e.target === state.mainCanvas) return; // already handled if (e.target === state.mainCanvas) return; // already handled
// Ignore presses on editor chrome inside the area (the crop Apply panel,
// popups, buttons) so clicking Apply does not start a fresh crop.
if (e.target.closest?.('button, input, select, textarea, .ge-crop-apply, .ge-adj-popup, .ge-transform-popup, .ge-fx-popup, .ge-inpaint-popup, .ge-controls, .ge-right-panel, .ge-fx-menu')) return;
beginDraw(e); beginDraw(e);
}); });
state.mainCanvas.addEventListener('mouseenter', (e) => { state.mainCanvas.addEventListener('mouseenter', (e) => {
@@ -121,11 +173,22 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
}); });
// Touch — single finger draws; two fingers pan + pinch-zoom. // Touch — single finger draws; two fingers pan + pinch-zoom.
// A quick two-finger tap = undo, a quick three-finger tap = redo.
let multiActive = false; let multiActive = false;
let multiStartDist = 0; let multiStartDist = 0;
let multiStartZoom = 1; let multiStartZoom = 1;
let multiStartCenter = { x: 0, y: 0 }; let multiStartCenter = { x: 0, y: 0 };
let multiStartPan = { x: 0, y: 0 }; let multiStartPan = { x: 0, y: 0 };
// Tap-gesture tracking (mobile undo/redo). A gesture starts when the
// first finger lands and is decided when the last finger lifts: at
// most TAP_MAX_MS elapsed, the centre never moved more than
// TAP_MAX_MOVE px, and the peak finger count was 2 (undo) or 3 (redo).
const TAP_MAX_MS = 300;
const TAP_MAX_MOVE = 12;
let gesturePeakTouches = 0;
let gestureStartAt = 0;
let gestureStartCenter = null;
let gestureMoved = false;
const touchInfo = (e) => { const touchInfo = (e) => {
const t1 = e.touches[0], t2 = e.touches[1]; const t1 = e.touches[0], t2 = e.touches[1];
const cx = (t1.clientX + t2.clientX) / 2; const cx = (t1.clientX + t2.clientX) / 2;
@@ -134,12 +197,30 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
const dy = t2.clientY - t1.clientY; const dy = t2.clientY - t1.clientY;
return { cx, cy, dist: Math.hypot(dx, dy) }; return { cx, cy, dist: Math.hypot(dx, dy) };
}; };
const touchCenter = (e) => {
if (!e.touches || !e.touches.length) return null;
let x = 0, y = 0;
for (const t of e.touches) { x += t.clientX; y += t.clientY; }
return { x: x / e.touches.length, y: y / e.touches.length };
};
const applyCanvasOffset = (x, y) => applyCanvasPan(state, canvasArea, x, y); const applyCanvasOffset = (x, y) => applyCanvasPan(state, canvasArea, x, y);
state.mainCanvas.addEventListener('touchstart', (e) => { state.mainCanvas.addEventListener('touchstart', (e) => {
e.preventDefault(); e.preventDefault();
if (gesturePeakTouches === 0) {
// First contact of a fresh gesture (finger counts are read from
// e.touches so simultaneous touches are handled too).
gesturePeakTouches = Math.max(1, e.touches.length);
gestureStartAt = Date.now();
gestureStartCenter = touchCenter(e);
gestureMoved = false;
} else if (e.touches.length > gesturePeakTouches) {
gesturePeakTouches = e.touches.length;
}
if (e.touches.length >= 2) { if (e.touches.length >= 2) {
// End any in-progress single-finger draw before switching modes. // Revert the accidental single-finger stroke the first finger
if (!multiActive) endDraw(); // began, then switch to pinch/pan so multi-finger input never
// leaves a stray dab behind.
if (!multiActive) cancelDraw?.();
multiActive = true; multiActive = true;
const info = touchInfo(e); const info = touchInfo(e);
multiStartDist = info.dist; multiStartDist = info.dist;
@@ -154,17 +235,26 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
}, { passive: false }); }, { passive: false });
state.mainCanvas.addEventListener('touchmove', (e) => { state.mainCanvas.addEventListener('touchmove', (e) => {
e.preventDefault(); e.preventDefault();
if (!gestureMoved && gestureStartCenter) {
const c = touchCenter(e);
if (c && Math.hypot(c.x - gestureStartCenter.x, c.y - gestureStartCenter.y) > TAP_MAX_MOVE) {
gestureMoved = true;
}
}
if (multiActive && e.touches.length >= 2) { if (multiActive && e.touches.length >= 2) {
const info = touchInfo(e); const info = touchInfo(e);
const ratio = info.dist / Math.max(1, multiStartDist); const ratio = info.dist / Math.max(1, multiStartDist);
const newZoom = Math.max(0.1, Math.min(5, multiStartZoom * ratio)); const newZoom = Math.max(0.1, Math.min(5, multiStartZoom * ratio));
if (Math.abs(newZoom - state.zoom) > 0.001) { if (Math.abs(newZoom - state.zoom) > 0.001) {
state.zoom = newZoom; state.zoom = newZoom;
// A pinch is an explicit zoom; layout refits must not undo it.
state.zoomViewMode = 'scale';
state.mainCanvas.style.width = (state.imgWidth * state.zoom) + 'px'; state.mainCanvas.style.width = (state.imgWidth * state.zoom) + 'px';
state.mainCanvas.style.height = (state.imgHeight * state.zoom) + 'px'; state.mainCanvas.style.height = (state.imgHeight * state.zoom) + 'px';
const label = state.container.querySelector('.ge-zoom-label'); const label = state.container.querySelector('.ge-zoom-label');
if (label) label.textContent = Math.round(state.zoom * 100) + '%'; if (label) label.textContent = Math.round(state.zoom * 100) + '%';
syncZoomControls?.(); syncZoomControls?.();
invalidateCanvasCoordsCache();
onViewportChange?.(); onViewportChange?.();
} }
const dx = info.cx - multiStartCenter.x; const dx = info.cx - multiStartCenter.x;
@@ -177,14 +267,36 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
continueDraw(e); continueDraw(e);
}, { passive: false }); }, { passive: false });
state.mainCanvas.addEventListener('touchend', (e) => { state.mainCanvas.addEventListener('touchend', (e) => {
// Decide the gesture only once every finger has lifted.
if (e.touches.length === 0 && gesturePeakTouches >= 2) {
const quick = !gestureMoved && (Date.now() - gestureStartAt) <= TAP_MAX_MS;
const peak = gesturePeakTouches;
gesturePeakTouches = 0;
gestureStartCenter = null;
gestureMoved = false;
multiActive = false;
if (quick) {
if (peak === 2) onTwoFingerTap?.();
else if (peak >= 3) onThreeFingerTap?.();
}
return;
}
if (multiActive) { if (multiActive) {
if (e.touches.length < 2) multiActive = false; if (e.touches.length < 2) multiActive = false;
return; return;
} }
endDraw(e); endDraw(e);
if (e.touches.length === 0) {
gesturePeakTouches = 0;
gestureStartCenter = null;
gestureMoved = false;
}
}); });
state.mainCanvas.addEventListener('touchcancel', () => { state.mainCanvas.addEventListener('touchcancel', () => {
multiActive = false; multiActive = false;
gesturePeakTouches = 0;
gestureStartCenter = null;
gestureMoved = false;
cancelDraw?.(); cancelDraw?.();
}); });
@@ -203,7 +315,10 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
}; };
canvasArea.addEventListener('pointerdown', (e) => { canvasArea.addEventListener('pointerdown', (e) => {
const directPan = isDirectPanIntent(state.tool, state.spacePanActive, e.button); const directPan = isDirectPanIntent(state.tool, state.spacePanActive, e.button);
if (state.tool === 'lasso' && !directPan) return; // With the crop (or lasso) tool, an empty-area press draws a rect/path
// rather than panning — so the crop can be pulled out past the image.
// Space-held / middle-mouse (directPan) still pans.
if ((state.tool === 'lasso' || state.tool === 'crop') && !directPan) return;
if (e.target.closest('button, input, .ge-adj-popup, .ge-transform-popup, .ge-fx-popup, .ge-inpaint-popup, .ge-controls, .ge-right-panel, .ge-fx-menu')) return; if (e.target.closest('button, input, .ge-adj-popup, .ge-transform-popup, .ge-fx-popup, .ge-inpaint-popup, .ge-controls, .ge-right-panel, .ge-fx-menu')) return;
const onImage = e.target === state.mainCanvas || e.target === state.transformOverlay; const onImage = e.target === state.mainCanvas || e.target === state.transformOverlay;
if (onImage && !directPan) return; if (onImage && !directPan) return;
+44 -1
View File
@@ -18,7 +18,7 @@
* }} deps * }} deps
*/ */
import { state } from './state.js'; import { state } from './state.js';
import { flipDocument, rotateDocument } from './document-geometry.js'; import { flipDocument, rotateDocument, rotateDocumentArbitrary, flipLayer as flipLayerGeometry, rotateLayer as rotateLayerGeometry } from './document-geometry.js';
export function createCanvasTransforms({ export function createCanvasTransforms({
saveState, composite, fitZoom, showCanvasLoading, hideCanvasLoading, renderLayerPanel, saveState, composite, fitZoom, showCanvasLoading, hideCanvasLoading, renderLayerPanel,
@@ -52,6 +52,29 @@ export function createCanvasTransforms({
}); });
}, },
/**
* Rotate the entire document by an arbitrary angle in degrees
* (clockwise; negative for counter-clockwise), expanding the canvas
* so nothing is clipped. Right angles use the exact integer path.
*/
rotateAllArbitrary(deg) {
if (!state.layers.length) return;
saveState(`Rotate ${deg}°`);
showCanvasLoading('Rotating…');
requestAnimationFrame(() => {
try {
const result = rotateDocumentArbitrary(state, deg);
const sizeLabel = document.getElementById('ge-canvas-size');
if (sizeLabel && result) sizeLabel.textContent = `${result.width}×${result.height}`;
fitZoom();
composite();
renderLayerPanel?.();
} finally {
hideCanvasLoading();
}
});
},
/** /**
* Mirror every layer horizontally ('h') or vertically ('v'). * Mirror every layer horizontally ('h') or vertically ('v').
* Canvas dimensions don't change. Each layer offset is reflected * Canvas dimensions don't change. Each layer offset is reflected
@@ -64,5 +87,25 @@ export function createCanvasTransforms({
composite(); composite();
renderLayerPanel?.(); renderLayerPanel?.();
}, },
/** Flip only the active layer, in place (Edit ▸ Transform ▸ Flip). */
flipLayer(axis) {
const layer = state.layers.find((item) => item.id === state.activeLayerId);
if (!layer) return;
saveState(axis === 'h' ? 'Flip layer horizontal' : 'Flip layer vertical');
flipLayerGeometry(state, layer, axis);
composite();
renderLayerPanel?.();
},
/** Rotate only the active layer by 90/180/270°, centre preserved. */
rotateLayer(deg) {
const layer = state.layers.find((item) => item.id === state.activeLayerId);
if (!layer) return;
saveState(`Rotate layer ${deg}°`);
rotateLayerGeometry(state, layer, deg);
composite();
renderLayerPanel?.();
},
}; };
} }
+55 -14
View File
@@ -2,11 +2,12 @@
* Paste + drag-and-drop import handlers. Both add an image to the * Paste + drag-and-drop import handlers. Both add an image to the
* editor as a new layer: * editor as a new layer:
* *
* - Paste (Ctrl+V): checks `state.internalClipboard` first (set by * - Paste (Ctrl+V): uses the system clipboard's `image/*` items first
* lasso copy/cut), then falls back to the system clipboard's * (so an external screenshot always wins), then falls back to
* `image/*` items. Layer is named "Pasted Selection" or "Pasted" * `state.internalClipboard` (set by lasso copy/cut) when the OS
* and becomes active; the tool snaps to Move so the user can * clipboard has no image. Layer is named "Pasted Selection" or
* reposition it immediately. * "Pasted" and becomes active; the tool snaps to Move so the user
* can reposition it immediately.
* - Drop: any `image/*` file dragged from the OS / another tab. * - Drop: any `image/*` file dragged from the OS / another tab.
* Shows a "Drop image to add as new layer" overlay mid-drag. Each * Shows a "Drop image to add as new layer" overlay mid-drag. Each
* dropped image is routed through `handleImportedImage` so canvas- * dropped image is routed through `handleImportedImage` so canvas-
@@ -38,15 +39,8 @@ export function wireClipboardAndDrop({
clipboardBindings?.abort(); clipboardBindings?.abort();
clipboardBindings = new AbortController(); clipboardBindings = new AbortController();
const { signal } = clipboardBindings; const { signal } = clipboardBindings;
// ── Paste ──
window.addEventListener('paste', (e) => {
if (!state.editorOpen || state.container !== container) return;
// Editable fields keep native paste. The Clipboard button focuses the
// canvas container before asking for a keyboard paste when read() is
// unavailable on an insecure origin.
if (e.target?.isContentEditable || e.target?.closest?.('input, textarea, select, [role="dialog"]')) return;
function pasteAsLayer(imgSource, label, offset = { x: 0, y: 0 }) { const pasteAsLayer = (imgSource, label, offset = { x: 0, y: 0 }) => {
if (!state.editorOpen) return; // user closed mid-paste if (!state.editorOpen) return; // user closed mid-paste
saveState(); saveState();
const layer = createLayer(label || 'Pasted', imgSource.width, imgSource.height); const layer = createLayer(label || 'Pasted', imgSource.width, imgSource.height);
@@ -66,10 +60,30 @@ export function wireClipboardAndDrop({
renderLayerPanel(); renderLayerPanel();
composite(); composite();
uiModule.showToast('Pasted as new layer'); uiModule.showToast('Pasted as new layer');
} };
// ── Paste ──
let pasteKeyPending = false;
window.addEventListener('paste', (e) => {
if (!state.editorOpen || state.container !== container) return;
// Editable fields INSIDE the editor keep native paste (the Clipboard button
// focuses the canvas container before asking for a keyboard paste when
// read() is unavailable on an insecure origin). Fields OUTSIDE the editor —
// notably the chat composer sitting behind the editor modal — must NOT
// swallow an image paste: while the editor is open it owns clipboard images,
// so we intercept here (capture phase) before the global chat attach
// listener in app.js can stage the file into the chatbox.
// NOTE: do NOT add `[role="dialog"]` to this guard — the editor itself lives
// inside the gallery modal's role="dialog" modal-content, so that would
// swallow every paste (including the internal Clipboard + Ctrl+V).
const target = e.target;
// A paste dispatched at window has a non-Node target; contains() would throw.
const insideEditor = target instanceof Node && !!container && container.contains(target);
if (insideEditor && (target.isContentEditable || target.closest?.('input, textarea, select'))) return;
const imageItem = Array.from(e.clipboardData?.items || []).find(item => item.type.startsWith('image/')); const imageItem = Array.from(e.clipboardData?.items || []).find(item => item.type.startsWith('image/'));
if (imageItem) { if (imageItem) {
pasteKeyPending = false;
e.preventDefault(); e.preventDefault();
e.stopImmediatePropagation(); e.stopImmediatePropagation();
const blob = imageItem.getAsFile(); const blob = imageItem.getAsFile();
@@ -85,12 +99,39 @@ export function wireClipboardAndDrop({
return; return;
} }
if (state.internalClipboard && !e.defaultPrevented && !e.clipboardData?.types?.includes?.('text/plain')) { if (state.internalClipboard && !e.defaultPrevented && !e.clipboardData?.types?.includes?.('text/plain')) {
pasteKeyPending = false;
e.preventDefault(); e.preventDefault();
e.stopImmediatePropagation(); e.stopImmediatePropagation();
pasteAsLayer(state.internalClipboard, 'Pasted Selection', state.internalClipboardOffset || { x: 0, y: 0 }); pasteAsLayer(state.internalClipboard, 'Pasted Selection', state.internalClipboardOffset || { x: 0, y: 0 });
} }
}, { capture: true, signal }); }, { capture: true, signal });
// ── Ctrl/Cmd+V ──
// Do NOT intercept the keystroke: preventing the default here bypasses the
// browser's own `paste` event and always re-pasted `state.internalClipboard`,
// so a screenshot taken outside the gallery could never be pasted. The paste
// listener above always prefers real `image/*` clipboard items; this key
// handler only covers the insecure-origin case where copying a selection
// never reached the OS clipboard, so the paste event delivers no image (and
// possibly stale text) and nothing usable arrives.
window.addEventListener('keydown', (e) => {
if (!(state.editorOpen && state.container === container)) return;
if (!(e.ctrlKey || e.metaKey) || e.altKey || e.shiftKey) return;
if (String(e.key || '').toLowerCase() !== 'v') return;
if (!state.internalClipboard) return;
const target = e.target;
if (target && (target.isContentEditable || target.closest?.('input, textarea, select'))) return;
pasteKeyPending = true;
setTimeout(() => {
// The paste event runs before this timer and clears the flag whenever it
// received a real clipboard image (or already pasted the internal copy).
if (!pasteKeyPending) return;
pasteKeyPending = false;
if (!state.internalClipboard) return;
pasteAsLayer(state.internalClipboard, 'Pasted Selection', state.internalClipboardOffset || { x: 0, y: 0 });
}, 0);
}, { capture: true, signal });
// ── Drag-and-drop ── // ── Drag-and-drop ──
// Visual drop-zone overlay appears mid-drag; routes via // Visual drop-zone overlay appears mid-drag; routes via
// handleImportedImage so the import respects canvas resizing rules // handleImportedImage so the import respects canvas resizing rules
+56
View File
@@ -0,0 +1,56 @@
/**
* Color-mode support (PhotoCraft parity, Phase 10 slice).
*
* The editor works in an RGBA8 canvas, so only RGB and Grayscale are
* convertible destructively; CMYK/Lab/Indexed remain metadata-only menu
* entries (disabled) because they need a color-managed pipeline we do not
* have. Bit depth is tracked as document metadata only.
*/
export const COLOR_MODES = Object.freeze(['rgb', 'grayscale', 'cmyk', 'lab', 'indexed']);
export const BIT_DEPTHS = Object.freeze([8, 16, 32]);
/** True for modes we can actually convert layer pixels into/out of. */
export function isConvertibleMode(mode) {
return mode === 'rgb' || mode === 'grayscale';
}
/** Convert an ImageData-like buffer to grayscale in place (Rec. 709 luma). */
export function rgbaToGrayscale(data) {
for (let i = 0; i < data.length; i += 4) {
const luma = Math.round(0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2]);
data[i] = luma;
data[i + 1] = luma;
data[i + 2] = luma;
}
return data;
}
/** Convert a single canvas to the requested mode in place. Returns true on success. */
export function convertCanvasToMode(canvas, mode) {
if (!canvas || !canvas.getContext) return false;
if (!isConvertibleMode(mode)) return false;
if (mode === 'grayscale') {
const ctx = canvas.getContext('2d');
const image = ctx.getImageData(0, 0, canvas.width, canvas.height);
rgbaToGrayscale(image.data);
ctx.putImageData(image, 0, 0);
}
// RGB is the native working space; nothing to do.
return true;
}
/** Apply a mode to every layer canvas. Returns the number of layers converted. */
export function applyColorMode(layers, mode) {
if (!isConvertibleMode(mode)) return 0;
let converted = 0;
for (const layer of layers || []) {
if (layer && layer.canvas && convertCanvasToMode(layer.canvas, mode)) converted += 1;
}
return converted;
}
/** Only 8 bits/channel is supported by the RGBA8 working canvas. */
export function supportsBitDepth(bits) {
return bits === 8;
}
+201
View File
@@ -0,0 +1,201 @@
/*
* Editor control kit — shared theming for the range/number/select/checkbox
* controls scattered across the editor. It only ADDS classes and small helper
* affordances (range fill, custom steppers, scrubby labels); it never changes
* the value semantics of a control. It is strictly SCOPED to editor surfaces
* (the editor shell and its dialogs) so the rest of the app is untouched, and
* it skips controls that already have a bespoke implementation (e.g. the
* transform popup's own spinner).
*
* Styling lives in static/css/13-image-editor.css (section "Editor control kit").
*/
export const CONTROL_KIT_VERSION = '1.0';
const RANGE_SELECTOR = 'input[type="range"]';
const NUMBER_SELECTOR = 'input[type="number"]';
const CHECK_SELECTOR = 'input[type="checkbox"]';
const SELECT_SELECTOR = 'select';
// Only controls inside one of these surfaces are themed. Dialogs are appended
// to <body> outside #gallery-editor-container, so they are listed explicitly.
const EDITOR_ROOT_SELECTOR = [
'#gallery-editor-container',
'.ge-adj-popup',
'.ge-filter-overlay',
'.ge-canvas-size-overlay',
'#ge-canvas-size-overlay',
'.ge-float-window',
'.ge-transform-popup',
].join(', ');
function inEditor(el) {
return !!(el && el.closest && el.closest(EDITOR_ROOT_SELECTOR));
}
function isSkipped(el) {
if (!el || el.__geKit) return true;
if (!inEditor(el)) return true;
if (el.closest('[data-ge-nokit]')) return true;
// The transform popup renders its own themed spinner for its inputs.
if (el.closest('.ge-transform-field')) return true;
return false;
}
function numberStep(el) {
const raw = Number(el.getAttribute('step'));
if (Number.isFinite(raw) && raw > 0) return raw;
return 1;
}
function clampToRange(el, value) {
const min = el.hasAttribute('min') ? Number(el.min) : -Infinity;
const max = el.hasAttribute('max') ? Number(el.max) : Infinity;
let v = value;
if (Number.isFinite(min)) v = Math.max(min, v);
if (Number.isFinite(max)) v = Math.min(max, v);
return v;
}
function attachScrub(handle, el) {
handle.classList.add('ge-scrub');
handle.addEventListener('pointerdown', (e) => {
if (el.disabled || el.readOnly) return;
e.preventDefault();
handle.classList.add('ge-scrubbing');
const startX = e.clientX;
const startVal = Number(el.value) || 0;
const step = numberStep(el);
let moved = false;
const move = (ev) => {
const dx = ev.clientX - startX;
if (Math.abs(dx) < 2) return;
moved = true;
const factor = ev.shiftKey ? 10 : 1;
const next = clampToRange(el, startVal + Math.round(dx) * step * factor * 0.1);
if (String(next) !== el.value) {
el.value = String(next);
el.dispatchEvent(new Event('input', { bubbles: true }));
}
};
const up = () => {
document.removeEventListener('pointermove', move);
document.removeEventListener('pointerup', up);
handle.classList.remove('ge-scrubbing');
if (moved) el.dispatchEvent(new Event('change', { bubbles: true }));
};
document.addEventListener('pointermove', move);
document.addEventListener('pointerup', up);
});
}
function decorateNumber(el) {
if (isSkipped(el)) return;
el.classList.add('ge-ctl-num');
el.__geKit = true;
if (el.disabled || el.readOnly) return;
// Photoshop-style scrub-on-label: drag a sibling <span> label to change the
// value; the field also keeps native arrow-key stepping. We do NOT inject a
// separate +/- stepper — most editor number fields live in display:grid
// labels, where an injected element wraps onto its own grid row and shows as
// an empty pill.
const label = el.closest('label');
if (label) {
const handle = label.querySelector(':scope > span');
if (handle && !handle.classList.contains('ge-scrub')) attachScrub(handle, el);
}
}
function updateRangeFill(el) {
const min = Number(el.min);
const max = Number(el.max);
const value = Number(el.value);
if (Number.isFinite(min) && Number.isFinite(max) && max > min) {
const pct = ((value - min) / (max - min)) * 100;
el.style.setProperty('--ge-pct', `${Math.max(0, Math.min(100, pct))}%`);
} else {
el.style.setProperty('--ge-pct', '50%');
}
}
function decorateRange(el) {
if (isSkipped(el)) return;
el.classList.add('ge-ctl-range');
el.__geKit = true;
updateRangeFill(el);
}
function decorateSelect(el) {
if (isSkipped(el)) return;
el.classList.add('ge-ctl-select');
el.__geKit = true;
}
function decorateCheck(el) {
if (isSkipped(el)) return;
el.classList.add('ge-ctl-check');
el.__geKit = true;
}
export function initControlKit(root = document) {
if (!root || !root.querySelectorAll) return 0;
// If the root itself is a control, decorate just it; otherwise its subtree.
let count = 0;
const decorate = (el) => {
if (el.matches(RANGE_SELECTOR)) { if (!isSkipped(el)) { decorateRange(el); count += 1; } }
else if (el.matches(NUMBER_SELECTOR)) { if (!isSkipped(el)) { decorateNumber(el); count += 1; } }
else if (el.matches(SELECT_SELECTOR)) { if (!isSkipped(el)) { decorateSelect(el); count += 1; } }
else if (el.matches(CHECK_SELECTOR)) { if (!isSkipped(el)) { decorateCheck(el); count += 1; } }
};
if (root.nodeType === 1) decorate(root);
root.querySelectorAll(RANGE_SELECTOR).forEach((el) => { if (!isSkipped(el)) { decorateRange(el); count += 1; } });
root.querySelectorAll(NUMBER_SELECTOR).forEach((el) => { if (!isSkipped(el)) { decorateNumber(el); count += 1; } });
root.querySelectorAll(SELECT_SELECTOR).forEach((el) => { if (!isSkipped(el)) { decorateSelect(el); count += 1; } });
root.querySelectorAll(CHECK_SELECTOR).forEach((el) => { if (!isSkipped(el)) { decorateCheck(el); count += 1; } });
return count;
}
let installed = false;
/**
* Install once: decorates the current editor DOM, keeps range fills in sync,
* and watches for controls that appear later (dialogs, popups, dock panels).
* Only nodes that are (or contain) an editor surface are processed.
*/
export function installControlKit() {
if (installed) return;
installed = true;
const boot = () => {
document.querySelectorAll(EDITOR_ROOT_SELECTOR).forEach((root) => initControlKit(root));
};
boot();
document.addEventListener(
'input',
(e) => {
const t = e.target;
if (t && t.matches && t.matches(RANGE_SELECTOR)) updateRangeFill(t);
},
true
);
const observer = new MutationObserver((mutations) => {
for (const m of mutations) {
m.addedNodes.forEach((node) => {
if (node.nodeType !== 1) return;
const isRoot = node.matches && node.matches(EDITOR_ROOT_SELECTOR);
const containsRoot = node.querySelector && node.querySelector(EDITOR_ROOT_SELECTOR);
if (isRoot) {
initControlKit(node);
} else if (containsRoot) {
node.querySelectorAll(EDITOR_ROOT_SELECTOR).forEach((root) => initControlKit(root));
} else if (inEditor(node)) {
initControlKit(node);
}
});
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
export default { CONTROL_KIT_VERSION, initControlKit, installControlKit };
+8
View File
@@ -24,6 +24,8 @@ const SUPPORTED_BLEND_MODES = new Set([
'source-over', 'multiply', 'screen', 'overlay', 'soft-light', 'hard-light', 'source-over', 'multiply', 'screen', 'overlay', 'soft-light', 'hard-light',
'darken', 'lighten', 'color-dodge', 'color-burn', 'difference', 'exclusion', 'darken', 'lighten', 'color-dodge', 'color-burn', 'difference', 'exclusion',
'hue', 'saturation', 'color', 'luminosity', 'hue', 'saturation', 'color', 'luminosity',
'dissolve', 'linear-burn', 'linear-dodge', 'darker-color', 'lighter-color',
'vivid-light', 'linear-light', 'pin-light', 'hard-mix', 'subtract', 'divide',
]); ]);
function normalizeGuideValues(values, fallback = []) { function normalizeGuideValues(values, fallback = []) {
@@ -51,6 +53,8 @@ export function normalizeEditorView(view, fallback = {}) {
gridSize: Math.max(2, Math.min(1000, Math.round(Number.isFinite(rawGridSize) ? rawGridSize : 16))), gridSize: Math.max(2, Math.min(1000, Math.round(Number.isFinite(rawGridSize) ? rawGridSize : 16))),
snapEnabled: Boolean(source.snapEnabled ?? fallback.snapEnabled ?? false), snapEnabled: Boolean(source.snapEnabled ?? fallback.snapEnabled ?? false),
snapToGrid: Boolean(source.snapToGrid ?? fallback.snapToGrid ?? false), snapToGrid: Boolean(source.snapToGrid ?? fallback.snapToGrid ?? false),
autoSelectLayer: Boolean(source.autoSelectLayer ?? fallback.autoSelectLayer ?? false),
autoSelectTarget: (source.autoSelectTarget ?? fallback.autoSelectTarget) === 'group' ? 'group' : 'layer',
guides: { guides: {
vertical: normalizeGuideValues(guides.vertical, fallbackGuides.vertical), vertical: normalizeGuideValues(guides.vertical, fallbackGuides.vertical),
horizontal: normalizeGuideValues(guides.horizontal, fallbackGuides.horizontal), horizontal: normalizeGuideValues(guides.horizontal, fallbackGuides.horizontal),
@@ -592,6 +596,8 @@ export function prepareEditorDocument(raw) {
v: EDITOR_DOCUMENT_VERSION, v: EDITOR_DOCUMENT_VERSION,
imgWidth: width, imgWidth: width,
imgHeight: height, imgHeight: height,
colorMode: migrated.colorMode === 'grayscale' ? 'grayscale' : 'rgb',
bitDepth: 8,
activeLayerId, activeLayerId,
view: normalizeEditorView(migrated.view), view: normalizeEditorView(migrated.view),
groups, groups,
@@ -634,6 +640,8 @@ export function serializeEditorDocument(state, extras = {}) {
imageId: state.imageId || null, imageId: state.imageId || null,
imgWidth: state.imgWidth, imgWidth: state.imgWidth,
imgHeight: state.imgHeight, imgHeight: state.imgHeight,
colorMode: state.colorMode || 'rgb',
bitDepth: state.bitDepth || 8,
activeLayerId: state.activeLayerId || null, activeLayerId: state.activeLayerId || null,
nextLayerId: state.nextLayerId, nextLayerId: state.nextLayerId,
view: normalizeEditorView(state), view: normalizeEditorView(state),
+280 -7
View File
@@ -9,18 +9,22 @@
import { flipTextLayer, rotateTextLayer, scaleTextLayer } from './text-layer.js'; import { flipTextLayer, rotateTextLayer, scaleTextLayer } from './text-layer.js';
import { flipShapeLayer, rotateShapeLayer, scaleShapeLayer } from './shape-layer.js'; import { flipShapeLayer, rotateShapeLayer, scaleShapeLayer } from './shape-layer.js';
import { renderPlacedLayer, transformPlacedData } from './placed-layer.js'; import { affineBounds, renderPlacedLayer, transformPlacedData } from './placed-layer.js';
function makeCanvas(width, height) { function makeCanvas(width, height) {
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(width)); canvas.width = Math.max(1, Math.round(Number.isFinite(width) ? width : 1));
canvas.height = Math.max(1, Math.round(height)); canvas.height = Math.max(1, Math.round(Number.isFinite(height) ? height : 1));
return canvas; return canvas;
} }
function renderWindow(source, x, y, width, height) { function renderWindow(source, x, y, width, height) {
const out = makeCanvas(width, height); const out = makeCanvas(width, height);
// A freshly-created / empty layer can legitimately have a 0-sized canvas;
// drawImage() throws on those, which would abort cropping the whole document.
if (source && source.width > 0 && source.height > 0) {
out.getContext('2d').drawImage(source, -x, -y); out.getContext('2d').drawImage(source, -x, -y);
}
return out; return out;
} }
@@ -50,6 +54,25 @@ function renderRotated(source, degrees) {
return out; return out;
} }
function renderRotatedArbitrary(source, degrees) {
const radians = (degrees * Math.PI) / 180;
const cos = Math.cos(radians);
const sin = Math.sin(radians);
const width = Math.abs(source.width * cos) + Math.abs(source.height * sin);
const height = Math.abs(source.width * sin) + Math.abs(source.height * cos);
const out = makeCanvas(Math.ceil(width), Math.ceil(height));
// Empty layers can have a 0-sized canvas; drawImage() throws on those.
if (source && source.width > 0 && source.height > 0) {
const ctx = out.getContext('2d');
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.translate(out.width / 2, out.height / 2);
ctx.rotate(radians);
ctx.drawImage(source, -source.width / 2, -source.height / 2);
}
return out;
}
function renderFlipped(source, axis) { function renderFlipped(source, axis) {
const out = makeCanvas(source.width, source.height); const out = makeCanvas(source.width, source.height);
const ctx = out.getContext('2d'); const ctx = out.getContext('2d');
@@ -172,10 +195,20 @@ export function clipPolygonToRect(points, width, height) {
} }
function normalizeCropRect(editorState, rect) { function normalizeCropRect(editorState, rect) {
const x = Math.max(0, Math.min(editorState.imgWidth - 1, Math.round(rect.x))); // Accept either {w,h} (internal) or {width,height} (control-op params).
const y = Math.max(0, Math.min(editorState.imgHeight - 1, Math.round(rect.y))); const rawW = Number.isFinite(rect.w) ? rect.w : rect.width;
const width = Math.max(1, Math.min(editorState.imgWidth - x, Math.round(rect.w))); const rawH = Number.isFinite(rect.h) ? rect.h : rect.height;
const height = Math.max(1, Math.min(editorState.imgHeight - y, Math.round(rect.h))); // The rect may reach BEYOND the current image on any side: a crop that
// overshoots an edge EXPANDS the canvas there (the new region is
// transparent) instead of being clamped back inside. renderWindow() already
// places the source at (-x,-y) in an output of any size, so allowing
// negative x/y and out-of-bounds width/height is all that is needed.
// Only guard against absurd values.
const MAX = 20000;
const x = Math.max(-MAX, Math.min(MAX, Math.round(rect.x)));
const y = Math.max(-MAX, Math.min(MAX, Math.round(rect.y)));
const width = Math.max(1, Math.min(MAX, Math.round(Number.isFinite(rawW) ? rawW : editorState.imgWidth)));
const height = Math.max(1, Math.min(MAX, Math.round(Number.isFinite(rawH) ? rawH : editorState.imgHeight)));
return { x, y, width, height }; return { x, y, width, height };
} }
@@ -501,6 +534,121 @@ export function rotateDocument(editorState, degrees) {
return { width: newWidth, height: newHeight, degrees: normalized }; return { width: newWidth, height: newHeight, degrees: normalized };
} }
/**
* Rotate the whole document by an arbitrary angle (degrees, clockwise),
* expanding the canvas so nothing is clipped. Right angles delegate to the
* exact integer path; other angles resample every layer, mask, and selection
* about the old image centre and re-place each layer's centre in the new
* frame. Guides are dropped because arbitrary rotation leaves no
* axis-aligned reference.
*/
export function rotateDocumentArbitrary(editorState, degrees) {
const raw = Number(degrees);
if (!Number.isFinite(raw)) return null;
const normalized = ((raw % 360) + 360) % 360;
if (normalized === 0) {
return { width: editorState.imgWidth, height: editorState.imgHeight, degrees: 0 };
}
if ([90, 180, 270].includes(normalized)) return rotateDocument(editorState, normalized);
const radians = (normalized * Math.PI) / 180;
const cos = Math.cos(radians);
const sin = Math.sin(radians);
const oldWidth = editorState.imgWidth;
const oldHeight = editorState.imgHeight;
const newWidth = Math.max(1, Math.ceil(Math.abs(oldWidth * cos) + Math.abs(oldHeight * sin)));
const newHeight = Math.max(1, Math.ceil(Math.abs(oldWidth * sin) + Math.abs(oldHeight * cos)));
const mapPoint = (x, y) => ({
x: cos * (x - oldWidth / 2) - sin * (y - oldHeight / 2) + newWidth / 2,
y: sin * (x - oldWidth / 2) + cos * (y - oldHeight / 2) + newHeight / 2,
});
for (const layer of editorState.layers) {
const before = layerBounds(editorState, layer);
const mapped = mapPoint(before.centerX, before.centerY);
let placedOffset = null;
if (layer.kind === 'text' && layer.text) {
rotateTextLayer(layer, normalized);
} else if (layer.kind === 'shape' && layer.shape) {
rotateShapeLayer(layer, normalized);
} else if (layer.kind === 'placed' && layer.placed) {
// Rotate the placed matrix about its centre, then translate that centre
// to where the document rotation maps it.
const matrix = [
cos, sin, -sin, cos,
before.centerX - cos * before.centerX + sin * before.centerY + (mapped.x - before.centerX),
before.centerY - sin * before.centerX - cos * before.centerY + (mapped.y - before.centerY),
];
layer.placed = transformPlacedData(layer.placed, matrix);
placedOffset = renderPlacedLayer(layer)?.offset || null;
} else {
replaceCanvas(layer, renderRotatedArbitrary(layer.canvas, normalized));
}
const layerWidth = layer.canvas.width;
const layerHeight = layer.canvas.height;
for (const mask of layer.masks || []) {
const oldMaskWidth = mask.canvas.width;
const oldMaskHeight = mask.canvas.height;
const maskOffset = mask.offset || { x: 0, y: 0 };
replaceCanvas(mask, renderRotatedArbitrary(mask.canvas, normalized));
const cx = (Number(maskOffset.x) || 0) + oldMaskWidth / 2;
const cy = (Number(maskOffset.y) || 0) + oldMaskHeight / 2;
if ((mask.space || (mask.mode === 'layer' ? 'layer' : 'document')) === 'layer') {
// Layer-space masks rotate with their layer's canvas.
const nx = cos * (cx - before.width / 2) - sin * (cy - before.height / 2) + layerWidth / 2;
const ny = sin * (cx - before.width / 2) + cos * (cy - before.height / 2) + layerHeight / 2;
mask.offset = {
x: Math.round(nx - mask.canvas.width / 2),
y: Math.round(ny - mask.canvas.height / 2),
};
} else {
const mappedCenter = mapPoint(cx, cy);
mask.offset = {
x: Math.round(mappedCenter.x - mask.canvas.width / 2),
y: Math.round(mappedCenter.y - mask.canvas.height / 2),
};
}
}
editorState.layerOffsets.set(layer.id, placedOffset || {
x: Math.round(mapped.x - layerWidth / 2),
y: Math.round(mapped.y - layerHeight / 2),
});
invalidateLayerCaches(layer);
}
for (const group of editorState.layerGroups || []) {
for (const mask of group.masks || []) {
const oldMaskWidth = mask.canvas.width;
const oldMaskHeight = mask.canvas.height;
const maskOffset = mask.offset || { x: 0, y: 0 };
replaceCanvas(mask, renderRotatedArbitrary(mask.canvas, normalized));
const mappedCenter = mapPoint(
(Number(maskOffset.x) || 0) + oldMaskWidth / 2,
(Number(maskOffset.y) || 0) + oldMaskHeight / 2,
);
mask.offset = {
x: Math.round(mappedCenter.x - mask.canvas.width / 2),
y: Math.round(mappedCenter.y - mask.canvas.height / 2),
};
}
}
if (editorState.wandMask) {
replaceStandaloneCanvas(editorState.wandMask, renderRotatedArbitrary(editorState.wandMask, normalized));
}
forEachStoredSelection(editorState, canvas => {
replaceStandaloneCanvas(canvas, renderRotatedArbitrary(canvas, normalized));
});
if (editorState.wandLastSeed) {
const mappedSeed = mapPoint(editorState.wandLastSeed.x, editorState.wandLastSeed.y);
editorState.wandLastSeed = { ...editorState.wandLastSeed, x: mappedSeed.x, y: mappedSeed.y };
}
editorState.lassoPoints = (editorState.lassoPoints || []).map(point => mapPoint(point.x, point.y));
// Arbitrary rotation leaves no axis-aligned reference, so guides are dropped.
editorState.guides = { vertical: [], horizontal: [] };
syncDocumentCanvas(editorState, newWidth, newHeight);
return { width: newWidth, height: newHeight, degrees: normalized };
}
/** Flip every layer, mask, offset, and active selection across the document. */ /** Flip every layer, mask, offset, and active selection across the document. */
export function flipDocument(editorState, axis) { export function flipDocument(editorState, axis) {
if (axis !== 'h' && axis !== 'v') return null; if (axis !== 'h' && axis !== 'v') return null;
@@ -573,3 +721,128 @@ export function flipDocument(editorState, axis) {
syncDocumentCanvas(editorState, width, height); syncDocumentCanvas(editorState, width, height);
return { width, height, axis }; return { width, height, axis };
} }
// ── Single-layer transforms (Edit ▸ Transform semantics) ──
// Unlike the document-wide helpers above, these mutate ONE layer in place:
// the document bounds, other layers, and the active selection are untouched.
// The layer's visual centre is preserved so a rotation does not fling it
// across the canvas.
function layerBounds(editorState, layer) {
if (layer.kind === 'placed' && layer.placed?.sourceCanvas) {
const sw = layer.placed.sourceWidth || layer.placed.sourceCanvas.width;
const sh = layer.placed.sourceHeight || layer.placed.sourceCanvas.height;
return affineBounds(layer.placed.matrix, sw, sh);
}
const offset = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
const width = layer.canvas.width;
const height = layer.canvas.height;
return {
x: offset.x,
y: offset.y,
width,
height,
centerX: offset.x + width / 2,
centerY: offset.y + height / 2,
};
}
function recenterLayer(editorState, layer, before) {
const width = layer.canvas.width;
const height = layer.canvas.height;
editorState.layerOffsets.set(layer.id, {
x: Math.round(before.centerX - width / 2),
y: Math.round(before.centerY - height / 2),
});
}
function flipMatrixAbout(centerX, centerY, axis) {
return axis === 'h'
? [-1, 0, 0, 1, 2 * centerX, 0]
: [1, 0, 0, -1, 0, 2 * centerY];
}
function rotateMatrixAbout(centerX, centerY, degrees) {
const radians = (degrees * Math.PI) / 180;
const cos = Math.round(Math.cos(radians));
const sin = Math.round(Math.sin(radians));
// T(center) · R · T(-center)
return [
cos, sin, -sin, cos,
centerX - cos * centerX + sin * centerY,
centerY - sin * centerX - cos * centerY,
];
}
function transformLayerMasks(layer, transform) {
for (const mask of layer.masks || []) {
transform(mask);
}
}
/** Flip a single layer horizontally ('h') or vertically ('v'), in place. */
export function flipLayer(editorState, layer, axis) {
if (!layer || (axis !== 'h' && axis !== 'v')) return null;
const before = layerBounds(editorState, layer);
if (layer.kind === 'text' && layer.text) flipTextLayer(layer, axis);
else if (layer.kind === 'shape' && layer.shape) flipShapeLayer(layer, axis);
else if (layer.kind === 'placed' && layer.placed) {
layer.placed = transformPlacedData(layer.placed, flipMatrixAbout(before.centerX, before.centerY, axis));
const rendered = renderPlacedLayer(layer);
if (rendered) editorState.layerOffsets.set(layer.id, rendered.offset);
} else {
replaceCanvas(layer, renderFlipped(layer.canvas, axis));
recenterLayer(editorState, layer, before);
}
transformLayerMasks(layer, (mask) => {
const maskWidth = mask.canvas.width;
const maskHeight = mask.canvas.height;
const maskOffset = mask.offset || { x: 0, y: 0 };
replaceCanvas(mask, renderFlipped(mask.canvas, axis));
if ((mask.space || (mask.mode === 'layer' ? 'layer' : 'document')) === 'layer') {
const ox = Number(maskOffset.x) || 0;
const oy = Number(maskOffset.y) || 0;
mask.offset = axis === 'h'
? { x: before.width - ox - maskWidth, y: oy }
: { x: ox, y: before.height - oy - maskHeight };
}
});
invalidateLayerCaches(layer);
return { axis };
}
/** Rotate a single layer by 90/180/270°, keeping its centre fixed. */
export function rotateLayer(editorState, layer, degrees) {
const normalized = ((degrees % 360) + 360) % 360;
if (!layer || ![90, 180, 270].includes(normalized)) return null;
const before = layerBounds(editorState, layer);
if (layer.kind === 'text' && layer.text) rotateTextLayer(layer, normalized);
else if (layer.kind === 'shape' && layer.shape) rotateShapeLayer(layer, normalized);
else if (layer.kind === 'placed' && layer.placed) {
layer.placed = transformPlacedData(layer.placed, rotateMatrixAbout(before.centerX, before.centerY, normalized));
const rendered = renderPlacedLayer(layer);
if (rendered) editorState.layerOffsets.set(layer.id, rendered.offset);
} else {
replaceCanvas(layer, renderRotated(layer.canvas, normalized));
recenterLayer(editorState, layer, before);
}
transformLayerMasks(layer, (mask) => {
const oldMaskWidth = mask.canvas.width;
const oldMaskHeight = mask.canvas.height;
const maskOffset = mask.offset || { x: 0, y: 0 };
replaceCanvas(mask, renderRotated(mask.canvas, normalized));
if ((mask.space || (mask.mode === 'layer' ? 'layer' : 'document')) === 'layer') {
const ox = Number(maskOffset.x) || 0;
const oy = Number(maskOffset.y) || 0;
if (normalized === 90) {
mask.offset = { x: before.height - oy - oldMaskHeight, y: ox };
} else if (normalized === 180) {
mask.offset = { x: before.width - ox - oldMaskWidth, y: before.height - oy - oldMaskHeight };
} else {
mask.offset = { x: oy, y: before.width - ox - oldMaskWidth };
}
}
});
invalidateLayerCaches(layer);
return { degrees: normalized };
}
+72
View File
@@ -0,0 +1,72 @@
// Makes a modal dialog draggable by its header, Photoshop-style.
//
// On the first grab the dialog is switched to `position:fixed` anchored at
// its current on-screen box, so it follows the pointer independently of the
// host modal's centering/transform layout. Dragging is clamped to the
// viewport and ignores presses that start on interactive controls (buttons,
// inputs, selects) so clicking Cancel/Create never becomes a drag.
export function makeDraggable(dialog, handle) {
if (!dialog || !handle || dialog.__geDraggable) return;
dialog.__geDraggable = true;
handle.classList.add('ge-draggable-handle');
let startX = 0;
let startY = 0;
let originLeft = 0;
let originTop = 0;
let dragging = false;
const onDown = (event) => {
if (event.button != null && event.button !== 0) return;
if (event.target?.closest?.('button, input, select, textarea, a, [role="button"]')) return;
const rect = dialog.getBoundingClientRect();
dialog.style.position = 'fixed';
dialog.style.margin = '0';
dialog.style.left = `${rect.left}px`;
dialog.style.top = `${rect.top}px`;
dialog.style.right = 'auto';
dialog.style.bottom = 'auto';
dialog.style.transform = 'none';
startX = event.clientX;
startY = event.clientY;
originLeft = rect.left;
originTop = rect.top;
dragging = true;
handle.setPointerCapture?.(event.pointerId);
event.preventDefault();
};
const onMove = (event) => {
if (!dragging) return;
const width = dialog.offsetWidth;
const height = dialog.offsetHeight;
const left = Math.max(0, Math.min(window.innerWidth - width, originLeft + (event.clientX - startX)));
const top = Math.max(0, Math.min(window.innerHeight - height, originTop + (event.clientY - startY)));
dialog.style.left = `${left}px`;
dialog.style.top = `${top}px`;
};
const onUp = (event) => {
if (!dragging) return;
dragging = false;
handle.releasePointerCapture?.(event.pointerId);
};
handle.addEventListener('pointerdown', onDown);
handle.addEventListener('pointermove', onMove);
handle.addEventListener('pointerup', onUp);
handle.addEventListener('pointercancel', onUp);
}
// Clears the inline positioning set by makeDraggable so the next open
// re-centers the dialog inside its host modal.
export function resetDraggable(dialog) {
if (!dialog) return;
dialog.style.position = '';
dialog.style.margin = '';
dialog.style.left = '';
dialog.style.top = '';
dialog.style.right = '';
dialog.style.bottom = '';
dialog.style.transform = '';
}
+374
View File
@@ -0,0 +1,374 @@
// Client side of the scoped "photo_editor" agent path.
//
// The editor Quick Edit popup sends {prompt, session, state, screenshot} to the
// app's editor-agent endpoint. That agent turn is offered ONLY the editor tool
// family (plan_edit / run_editor_command / get_editor_screenshot / list_layers)
// plus ask_user; the model's run_editor_command calls are executed in-process by
// the owner-scoped bridge (/api/editor/bridge/*), which delivers each command to
// this browser subscriber. We stream those executed steps into the popup and
// fold the whole run into a single undo group.
//
// The endpoint is HARNESS-owned; if it is not present yet (404/405) `run()`
// resolves {unavailable:true} so the caller can fall back to the local planner.
//
// Payload: { prompt, session, editor_session, state, screenshot }
// - session / editor_session : the editor bridge session id (scopes commands)
// - state : document.info + layers.list (+ imageId/name)
// - screenshot : data:image/png;base64,... of the canvas
// Response: { ok?, reply?, tools_offered?[], steps?[], trace?[] } (any subset).
export const EDITOR_AGENT_VERSION = '1.0';
export const DEFAULT_AGENT_ENDPOINT = '/api/editor/agent';
import { formatCropRect, isGenericIntent } from './nl-edit.js';
export function createEditorAgent({
control,
endpoint = DEFAULT_AGENT_ENDPOINT,
fetchImpl = null,
getSession = null,
getModel = null,
getAgentModel = null,
onStep = null,
} = {}) {
if (!control || typeof control.invoke !== 'function') {
throw new Error('createEditorAgent requires a control with invoke()');
}
const doFetch = fetchImpl || (typeof fetch !== 'undefined' ? fetch : null);
let activeStep = null;
let running = false;
let executed = null;
// Called by the editor bridge for every queued command it executes. While an
// agent run is in flight this streams the step into the popup.
function reportStep(payload, cmd) {
const op = (cmd && cmd.op) || (payload && payload.op) || '';
const entry = {
op,
ok: !!(payload && payload.ok),
error: payload && payload.error ? payload.error : null,
ms: payload && payload.ms != null ? payload.ms : null,
params: cmd && cmd.params ? JSON.stringify(cmd.params).slice(0, 400) : null,
screenshot: payload && payload.screenshot ? payload.screenshot : null,
};
if (executed) executed.push(entry);
if (activeStep) { try { activeStep(entry); } catch (_) {} }
return entry;
}
function isRunning() { return running; }
async function collectState() {
const state = {};
try { Object.assign(state, (await control.invoke('document.info')).result || {}); } catch (_) {}
try {
const ls = await control.invoke('layers.list');
if (ls && ls.ok) state.layers = ls.result;
} catch (_) {}
return state;
}
// Mean absolute pixel delta (0..1) between two data-URL images, computed on a
// 64px thumbnail. Browser-only; resolves null when it cannot measure.
async function dataUrlDelta(a, b) {
try {
if (typeof document === 'undefined' || !a || !b) return null;
const load = (url) => new Promise((res, rej) => { const im = new Image(); im.onload = () => res(im); im.onerror = rej; im.src = url; });
const [ia, ib] = await Promise.all([load(a), load(b)]);
const w = 64, h = 64;
const ca = document.createElement('canvas'); ca.width = w; ca.height = h;
const cb = document.createElement('canvas'); cb.width = w; cb.height = h;
ca.getContext('2d').drawImage(ia, 0, 0, w, h);
cb.getContext('2d').drawImage(ib, 0, 0, w, h);
const da = ca.getContext('2d').getImageData(0, 0, w, h).data;
const db = cb.getContext('2d').getImageData(0, 0, w, h).data;
let sum = 0;
for (let i = 0; i < da.length; i += 4) sum += Math.abs(da[i] - db[i]) + Math.abs(da[i + 1] - db[i + 1]) + Math.abs(da[i + 2] - db[i + 2]);
return sum / ((da.length / 4) * 3 * 255);
} catch (_) { return null; }
}
// Fraction of near-white (clipped-highlight) pixels in a data-URL image, on a
// 96px thumbnail. Browser-only; resolves null when it cannot measure.
async function dataUrlClipFraction(url) {
try {
if (typeof document === 'undefined' || !url) return null;
const load = (u) => new Promise((res, rej) => { const im = new Image(); im.onload = () => res(im); im.onerror = rej; im.src = u; });
const im = await load(url);
const w = 96, h = 96;
const c = document.createElement('canvas'); c.width = w; c.height = h;
c.getContext('2d').drawImage(im, 0, 0, w, h);
const d = c.getContext('2d').getImageData(0, 0, w, h).data;
let clipped = 0, total = 0;
for (let i = 0; i < d.length; i += 4) {
total++;
const lum = 0.299 * d[i] + 0.587 * d[i + 1] + 0.114 * d[i + 2];
if (lum > 250) clipped++;
}
return total ? clipped / total : null;
} catch (_) { return null; }
}
// Exposure summary of a data-URL image on a 96px thumbnail: fractions of dark
// (shadow) and bright (highlight) pixels plus the mean luminance. Used to decide
// whether a whole-image Auto Tone is actually warranted for a portrait.
async function dataUrlExposure(url) {
try {
if (typeof document === 'undefined' || !url) return null;
const load = (u) => new Promise((res, rej) => { const im = new Image(); im.onload = () => res(im); im.onerror = rej; im.src = u; });
const im = await load(url);
const w = 96, h = 96;
const c = document.createElement('canvas'); c.width = w; c.height = h;
c.getContext('2d').drawImage(im, 0, 0, w, h);
const d = c.getContext('2d').getImageData(0, 0, w, h).data;
let dark = 0, bright = 0, sum = 0, total = 0;
for (let i = 0; i < d.length; i += 4) {
total++;
const lum = 0.299 * d[i] + 0.587 * d[i + 1] + 0.114 * d[i + 2];
sum += lum;
if (lum < 42) dark++;
if (lum > 214) bright++;
}
return total ? { dark: dark / total, bright: bright / total, mean: sum / total / 255 } : null;
} catch (_) { return null; }
}
async function run(prompt, { onStep: stepCb = null, signal = null } = {}) {
if (!prompt || !String(prompt).trim()) throw new Error('empty prompt');
if (!doFetch || !endpoint) return { unavailable: true, ok: false };
const session = getSession ? getSession() : 'default';
let modelInfo = null;
try { modelInfo = getModel ? getModel() : null; } catch (_) { modelInfo = null; }
let agentInfo = null;
try { agentInfo = getAgentModel ? getAgentModel() : null; } catch (_) { agentInfo = null; }
const report = stepCb || onStep || null;
activeStep = report;
executed = [];
running = true;
// Honest before-image (captured before any client pre-step) for the
// imperceptible-edit check, then the history mark scoping the undo group.
let beforeShot = null;
try { const s = await control.invoke('screenshot'); beforeShot = s && s.result ? s.result.dataUrl : null; } catch (_) {}
let mark = 0;
try { mark = (await control.invoke('history.mark')).result; } catch (_) {}
// Generic intents may get an Auto Tone/Color strengthen below IF the agent's
// own plan turned out imperceptible (avoids double-processing a dark image).
const generic = isGenericIntent(prompt);
// Portrait/teeth intents: the planner sometimes leads with a whole-image
// Auto Tone, which lightens and whitens the face ("pale/airbrushed"). Suppress
// photo.autoTone during the run unless the photo is genuinely under- or
// over-exposed (checked from the before-image histogram).
const lp = String(prompt).toLowerCase();
const portraitIntent = /skin|complexion|blemish|acne|pores|smoot|retouch|look (better|good|amazing)|flawless|face|portrait/.test(lp);
const teethIntent = /teeth|smile/.test(lp);
let origInvoke = null;
const restoreInvoke = () => { if (origInvoke) { control.invoke = origInvoke; origInvoke = null; } };
if (portraitIntent || teethIntent) {
const exposure = await dataUrlExposure(beforeShot);
const needsTone = !!(exposure && (exposure.dark > 0.32 || exposure.bright > 0.32));
if (!needsTone) {
origInvoke = control.invoke;
const prev = origInvoke;
control.invoke = function (op, params) {
if (op === 'photo.autoTone') return Promise.resolve({ ok: true, op, result: { skipped: true, reason: 'portrait-no-auto-tone' } });
return prev.call(this, op, params);
};
}
}
const state = await collectState();
let screenshot = null;
try {
const shot = await control.invoke('screenshot');
screenshot = shot && shot.result ? shot.result.dataUrl : null;
} catch (_) {}
let res = null;
try {
res = await doFetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: signal || undefined,
body: JSON.stringify({
prompt, session, editor_session: session, state, screenshot,
model: (modelInfo && modelInfo.model) || '',
endpoint: (modelInfo && modelInfo.endpoint) || '',
model_label: (modelInfo && modelInfo.label) || '',
agent_model: (agentInfo && agentInfo.agent) || '',
agent_endpoint: (agentInfo && agentInfo.endpoint) || '',
agent_label: (agentInfo && agentInfo.label) || '',
}),
});
} catch (e) {
activeStep = null; running = false; restoreInvoke();
return { unavailable: true, ok: false, error: String(e && e.message ? e.message : e) };
}
// No agent endpoint yet -> let the caller fall back to the local planner.
if (!res || res.status === 404 || res.status === 405) {
activeStep = null; running = false; restoreInvoke();
return { unavailable: true, ok: false };
}
let data = null;
try { data = await res.json(); } catch (_) {}
const trace = executed;
// How many history entries did the agent's bridge commands add?
let applied = 0;
try {
const nowMark = (await control.invoke('history.mark')).result;
applied = Math.max(0, Number(nowMark || 0) - Number(mark || 0));
} catch (_) {}
const needsProvider = !!(data && (data.needs_provider || data.requires_provider || data.needsProvider));
const blocked = !res.ok || !data || data.ok === false || needsProvider || !!(data && data.blocked);
// A clarification: the planner returned no steps but asked a question. Do NOT
// run any post-guard (especially the imperceptible-edit tone strengthen),
// which would silently tweak the image for a prompt the model did not understand.
const planSteps = Array.isArray(data && data.steps) ? data.steps : [];
const asked = !blocked && planSteps.length === 0 && !!((data && (data.reply || data.message)));
activeStep = null; running = false;
// Safety net: if the agent could not fulfil the request, roll back every
// partial/substitute edit it applied instead of leaving a wrong edit behind.
if (blocked && applied > 0) {
for (let i = 0; i < applied; i++) {
try { await control.invoke('history.undo'); } catch (_) { break; }
}
} else if (!blocked && !asked) {
// Format/platform requests: guarantee the target aspect crop even when the
// agent's own plan omitted document.crop (e.g. "make it instagram ready").
// Do NOT crop when the request is to EXTEND the canvas (that adds area,
// it does not crop the image).
const extendIntent = /\b(extend|expand)\b|\badd\b[^.]*\b(canvas|border|space|margin)\b|\b(wider|taller|letterbox)\b/i.test(prompt);
const docOps = (trace || []).map((t) => t && t.op);
const alreadyFormatted = extendIntent || docOps.includes('document.crop') || docOps.includes('document.resizeCanvas') || docOps.includes('document.extend');
try {
const info = (await control.invoke('document.info')).result || {};
const rect = alreadyFormatted ? null : formatCropRect(prompt, info.width, info.height);
if (rect && (Math.abs((info.width || 0) - rect.width) > 2 || Math.abs((info.height || 0) - rect.height) > 2)) {
await control.invoke('document.crop', rect);
}
} catch (_) {}
// Skin/teeth retouch routing guard: rather than let a portrait request
// degrade to a destructive global blur, apply the specialised masked,
// texture-preserving retouch ops when the agent did not.
try {
const traceOps = (trace || []).map((t) => t && t.op);
const lp = String(prompt).toLowerCase();
const skinReq = /skin|complexion|blemish|acne|pores|smoot|retouch|look (better|good|amazing)|flawless/.test(lp);
// If the agent softened the WHOLE image as a stand-in for skin retouch,
// revert that destructive global filter before applying the real one.
if (skinReq && !traceOps.includes('retouch.skin')) {
const last = trace[trace.length - 1];
if (last && last.op === 'filters.apply' && last.ok) {
try {
await control.invoke('history.undo');
executed.push({ op: 'history.undo', ok: true, error: null, source: 'guard:global-blur' });
} catch (_) {}
}
}
if (skinReq && !traceOps.includes('retouch.skin')) {
await control.invoke('retouch.skin', {});
executed.push({ op: 'retouch.skin', ok: true, error: null, source: 'guard' });
}
// Portrait chain must not brighten: if a global tone pass runs after the
// skin retouch, revert it so the face keeps its original luminance.
if (skinReq) {
const skinIdx = traceOps.lastIndexOf('retouch.skin');
const autoIdx = traceOps.lastIndexOf('photo.autoTone');
const lastOp = traceOps[traceOps.length - 1];
if (skinIdx >= 0 && autoIdx > skinIdx && lastOp === 'photo.autoTone') {
try {
await control.invoke('history.undo');
executed.push({ op: 'history.undo', ok: true, error: null, source: 'guard:portrait-brighten' });
} catch (_) {}
}
}
if (/teeth|smile/.test(lp) && !traceOps.includes('retouch.teeth')) {
await control.invoke('retouch.teeth', {});
executed.push({ op: 'retouch.teeth', ok: true, error: null, source: 'guard' });
}
} catch (_) {}
// Title/text placement guard: place titles RELATIVE to the image layer
// bounds (top-centre with a margin) at ~10% of the image height, so they
// sit inside the photo instead of the empty canvas margin.
try {
const titleIntent = /\btitle\b|\bcaption\b|\bheading\b|\bwatermark\b|\bwordmark\b|\badd\s+(?:a\s+)?(?:text|label)\b|\bwrite\b/i.test(String(prompt));
const traceOps2 = (trace || []).map((t) => t && t.op);
if (titleIntent && traceOps2.includes('text.add')) {
await control.invoke('text.layout', { anchor: 'top-center', sizePct: 0.13, marginPct: 0.05 });
executed.push({ op: 'text.layout', ok: true, error: null, source: 'guard' });
}
} catch (_) {}
// Imperceptible-edit guard: when a GENERIC look/brighten request barely
// changed the image, strengthen it so generic requests are visible but
// still natural. Never run this for structural/action prompts (add a
// layer, resize, add a title, align…) — a tone tweak there is wrong.
try {
if (beforeShot && generic) {
const after = (await control.invoke('screenshot')).result;
const delta = await dataUrlDelta(beforeShot, after && after.dataUrl);
if (delta != null && delta < 0.02) {
try { await control.invoke('photo.autoTone', {}); } catch (_) {}
await control.invoke('adjustments.apply', { type: 'vibrance', params: { vibrance: 22 } });
await control.invoke('adjustments.apply', { type: 'brightness-contrast', params: { brightness: 5, contrast: 8 } });
}
}
} catch (_) {}
// Blown-highlight guard: agents sometimes over-lift exposure/shadows and
// clip large areas to white; step back up to two ops if that happened.
try {
if (beforeShot) {
const beforeClip = await dataUrlClipFraction(beforeShot);
let after = (await control.invoke('screenshot')).result;
let afterClip = await dataUrlClipFraction(after && after.dataUrl);
for (let guard = 0; guard < 2 && beforeClip != null && afterClip != null && (afterClip - beforeClip) > 0.12; guard++) {
await control.invoke('history.undo');
executed.push({ op: 'history.undo', ok: true, error: null, source: 'guard:clipped-highlights' });
after = (await control.invoke('screenshot')).result;
afterClip = await dataUrlClipFraction(after && after.dataUrl);
}
}
} catch (_) {}
// Fold every op the agent ran (via the bridge) into one undo group.
try { await control.invoke('history.collapse', { mark, label: `AI edit: ${String(prompt).slice(0, 60)}` }); } catch (_) {}
}
if (blocked || asked) {
const message = (asked ? (data && (data.reply || data.message)) : null)
|| (data && (data.message || data.reply)) || (needsProvider
? 'Removing or adding objects needs an image generation provider. Enable one in Settings > Models.'
: ((data && data.error) || `Agent error (${res ? res.status : 'no response'})`));
restoreInvoke();
return {
unavailable: false, ok: false, blocked: true, needsProvider, message,
asked,
error: (data && data.error) || null,
reply: (data && (data.reply || data.message)) || '',
toolsOffered: (data && (data.tools_offered || data.tools)) || null,
steps: [], trace, session, applied, rolledBack: applied,
};
}
restoreInvoke();
return {
unavailable: false,
ok: true,
reply: data.reply || data.message || '',
toolsOffered: data.tools_offered || data.tools || null,
steps: Array.isArray(data.steps) ? data.steps : [],
trace,
session,
applied,
};
}
return { version: EDITOR_AGENT_VERSION, endpoint, run, reportStep, isRunning };
}
export default createEditorAgent;
+104
View File
@@ -0,0 +1,104 @@
// Browser side of the editor agent bridge. When the editor is open it
// subscribes to the app's SSE command stream and executes queued ops through
// the editor control API, then posts the result (+ a screenshot) back.
//
// Protocol (see routes/editor_agent_routes.py):
// GET /api/editor/bridge/events?session=<id> -> SSE `data: {id,op,params}`
// POST /api/editor/bridge/result {id,ok,result,error,screenshot,ms}
//
// The agent enqueues with POST /api/editor/bridge/command {session,steps},
// optionally with wait:true to block until this subscriber posts the result.
export const BRIDGE_VERSION = '1.0';
export function startEditorBridge({
control,
session = 'default',
baseUrl = '',
EventSourceImpl = null,
fetchImpl = null,
onCommand = null,
} = {}) {
if (!control || typeof control.invoke !== 'function') {
throw new Error('startEditorBridge requires a control with invoke()');
}
const ES = EventSourceImpl || (typeof EventSource !== 'undefined' ? EventSource : null);
const doFetch = fetchImpl || (typeof fetch !== 'undefined' ? fetch : null);
const eventUrl = `${baseUrl}/api/editor/bridge/events?session=${encodeURIComponent(session)}`;
const resultUrl = `${baseUrl}/api/editor/bridge/result`;
let source = null;
let timer = null;
let retry = 0;
let stopped = false;
// Execute one queued command and report the outcome (with an after screenshot).
async function runAndReport(cmd) {
const id = cmd && cmd.id;
const started = Date.now();
let ok = false;
let result = null;
let error = null;
let screenshot = null;
try {
const out = await control.invoke(cmd.op, cmd.params || {});
ok = !!(out && out.ok);
result = out ? (out.result ?? null) : null;
if (!ok) error = (out && out.error) || 'op failed';
} catch (e) {
error = String(e && e.message ? e.message : e);
}
try {
const shot = await control.invoke('screenshot');
screenshot = shot && shot.result ? shot.result.dataUrl : null;
} catch (_) { /* screenshot is best-effort */ }
const payload = { id, ok, result, error, screenshot, ms: Date.now() - started };
if (onCommand) { try { onCommand(payload, cmd); } catch (_) {} }
if (doFetch) {
try {
await doFetch(resultUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
} catch (_) { /* the agent may have timed out; ignore */ }
}
return payload;
}
function connect() {
if (stopped || !ES) return;
try { source = new ES(eventUrl); } catch (_) { return; }
source.onopen = () => { retry = 0; };
source.onmessage = (ev) => {
retry = 0;
let cmd = null;
try { cmd = JSON.parse(ev.data); } catch (_) { return; }
if (!cmd || !cmd.op) return;
// Fire-and-forget; results go back over /result, not the SSE stream.
runAndReport(cmd);
};
source.onerror = () => {
try { source && source.close(); } catch (_) {}
if (stopped) return;
retry += 1;
timer = setTimeout(connect, Math.min(5000, 500 * retry));
};
}
connect();
return {
version: BRIDGE_VERSION,
session,
eventUrl,
resultUrl,
runAndReport,
stop() {
stopped = true;
try { source && source.close(); } catch (_) {}
if (timer) clearTimeout(timer);
},
};
}
export default startEditorBridge;
+97
View File
@@ -0,0 +1,97 @@
// Programmatic control surface for the PhotoCraft editor.
//
// This module turns the editor's internal operations into a stable,
// JSON-friendly command API so an MCP server (or the in-editor natural
// language entrypoint) can drive the editor without poking at private
// closures. The heavy lifting lives in galleryEditor.js, which builds the
// handler map from its in-scope functions and installs it on
// window.geEditorControl.
//
// Contract:
// control.invoke(op, params) -> Promise<{ ok, result } | { ok:false, error }>
// control.run(steps) -> Promise<Array<stepResult>> (sequential)
// control.list() -> [{ op, description }]
// control.describe(op) -> { op, description } | null
//
// Every op name is namespaced: document.*, layers.*, selection.*,
// adjustments.*, filters.*, transform.*, text.*, history.*, export.*.
export const EDITOR_CONTROL_VERSION = '1.0';
function normalizeError(err) {
if (!err) return 'Unknown error';
if (typeof err === 'string') return err;
return err.message || String(err);
}
export function createEditorControl({ handlers = {}, getState = null, version = EDITOR_CONTROL_VERSION } = {}) {
const registry = new Map();
for (const [op, entry] of Object.entries(handlers)) {
if (!entry) continue;
if (typeof entry === 'function') {
registry.set(op, { run: entry, description: '' });
} else if (typeof entry.run === 'function') {
registry.set(op, { run: entry.run, description: entry.description || '' });
}
}
function describe(op) {
const entry = registry.get(op);
if (!entry) return null;
return { op, description: entry.description };
}
function list() {
return Array.from(registry.keys())
.sort()
.map((op) => ({ op, description: registry.get(op).description }));
}
function snapshot() {
if (typeof getState !== 'function') return {};
try {
return getState() || {};
} catch (_err) {
return {};
}
}
async function invoke(op, params = {}) {
const entry = registry.get(op);
if (!entry) {
return { ok: false, error: `Unknown editor op: ${op}`, op };
}
try {
const result = await entry.run(params || {}, { state: snapshot(), control });
return { ok: true, op, result: result === undefined ? null : result };
} catch (err) {
return { ok: false, op, error: normalizeError(err) };
}
}
async function run(steps) {
const list = Array.isArray(steps) ? steps : [];
const results = [];
for (const step of list) {
const op = step && step.op;
const params = (step && step.params) || {};
// eslint-disable-next-line no-await-in-loop
const res = await invoke(op, params);
results.push(res);
if (!res.ok) break;
}
return results;
}
const control = {
version,
installed: true,
list,
describe,
invoke,
run,
};
return control;
}
export default createEditorControl;
+36
View File
@@ -65,6 +65,42 @@ function renderEffects(source, effects, masks) {
ctx.filter = 'none'; ctx.filter = 'none';
} else if (effect.type === 'sharpen') { } else if (effect.type === 'sharpen') {
ctx.drawImage(sharpenCanvas(current, effect.params.amount), 0, 0); ctx.drawImage(sharpenCanvas(current, effect.params.amount), 0, 0);
} else if (effect.type === 'motion-blur') {
const dist = effect.params.distance;
const rad = (effect.params.angle || 0) * Math.PI / 180;
const dx = Math.cos(rad);
const dy = Math.sin(rad);
const samples = Math.max(1, Math.min(64, Math.round(dist) || 1));
ctx.globalAlpha = 1 / samples;
for (let s = 0; s < samples; s += 1) {
const t = (samples > 1 ? (s / (samples - 1) - 0.5) : 0) * dist;
ctx.drawImage(current, dx * t, dy * t);
}
ctx.globalAlpha = 1;
} else if (effect.type === 'zoom-blur') {
const amt = effect.params.amount / 100;
const samples = Math.max(1, Math.min(48, Math.round(amt * 48) || 1));
const cx = next.width / 2;
const cy = next.height / 2;
ctx.globalAlpha = 1 / samples;
for (let s = 0; s < samples; s += 1) {
const scale = 1 + (s / samples) * amt * 0.5;
const dw = next.width * scale;
const dh = next.height * scale;
ctx.drawImage(current, cx - dw / 2, cy - dh / 2, dw, dh);
}
ctx.globalAlpha = 1;
} else if (effect.type === 'pixelate') {
const size = Math.max(1, effect.params.size);
const sw = Math.max(1, Math.round(next.width / size));
const sh = Math.max(1, Math.round(next.height / size));
const tiny = new OffscreenCanvas(sw, sh);
const tctx = tiny.getContext('2d');
tctx.imageSmoothingEnabled = false;
tctx.drawImage(current, 0, 0, sw, sh);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(tiny, 0, 0, next.width, next.height);
ctx.imageSmoothingEnabled = true;
} else if (effect.type === 'color-overlay') { } else if (effect.type === 'color-overlay') {
ctx.drawImage(current, 0, 0); ctx.drawImage(current, 0, 0);
ctx.globalAlpha = effect.params.opacity; ctx.globalAlpha = effect.params.opacity;
+51 -1
View File
@@ -9,7 +9,7 @@ export function effectsWithPreview(owner) {
return index < 0 ? [...effects, preview] : effects.map((effect, i) => i === index ? preview : effect); return index < 0 ? [...effects, preview] : effects.map((effect, i) => i === index ? preview : effect);
} }
const EFFECT_TYPES = new Set(['gaussian-blur', 'sharpen', 'color-overlay', 'drop-shadow', 'stroke', 'linear-gradient', 'radial-gradient']); const EFFECT_TYPES = new Set(['gaussian-blur', 'sharpen', 'motion-blur', 'zoom-blur', 'pixelate', 'color-overlay', 'drop-shadow', 'stroke', 'linear-gradient', 'radial-gradient']);
for (const type of Object.keys(LAYER_STYLES)) EFFECT_TYPES.add(type); for (const type of Object.keys(LAYER_STYLES)) EFFECT_TYPES.add(type);
export const EFFECT_PRESETS = { export const EFFECT_PRESETS = {
@@ -41,6 +41,9 @@ export function defaultEffectParams(type) {
if (LAYER_STYLES[type]) return styleParams(type); if (LAYER_STYLES[type]) return styleParams(type);
if (type === 'gaussian-blur') return { radius: 6 }; if (type === 'gaussian-blur') return { radius: 6 };
if (type === 'sharpen') return { amount: 0.5 }; if (type === 'sharpen') return { amount: 0.5 };
if (type === 'motion-blur') return { distance: 20, angle: 0 };
if (type === 'zoom-blur') return { amount: 40 };
if (type === 'pixelate') return { size: 8 };
if (type === 'color-overlay') return { color: '#ffffff', opacity: 0.2, blendMode: 'source-atop' }; if (type === 'color-overlay') return { color: '#ffffff', opacity: 0.2, blendMode: 'source-atop' };
if (type === 'drop-shadow') return { color: '#000000', opacity: 0.45, blur: 12, x: 4, y: 6 }; if (type === 'drop-shadow') return { color: '#000000', opacity: 0.45, blur: 12, x: 4, y: 6 };
if (type === 'stroke') return { color: '#ffffff', opacity: 1, width: 3 }; if (type === 'stroke') return { color: '#ffffff', opacity: 1, width: 3 };
@@ -66,6 +69,12 @@ export function normalizeEffect(effect) {
if (LAYER_STYLES[type]) Object.assign(params, styleParams(type, params)); if (LAYER_STYLES[type]) Object.assign(params, styleParams(type, params));
if (type === 'gaussian-blur') params.radius = Math.max(0, Math.min(200, Number(params.radius) || 0)); if (type === 'gaussian-blur') params.radius = Math.max(0, Math.min(200, Number(params.radius) || 0));
if (type === 'sharpen') params.amount = Math.max(0, Math.min(1, Number(params.amount) || 0)); if (type === 'sharpen') params.amount = Math.max(0, Math.min(1, Number(params.amount) || 0));
if (type === 'motion-blur') {
params.distance = Math.max(0, Math.min(200, Number(params.distance) || 0));
params.angle = Math.max(-180, Math.min(180, Number(params.angle) || 0));
}
if (type === 'zoom-blur') params.amount = Math.max(0, Math.min(100, Number(params.amount) || 0));
if (type === 'pixelate') params.size = Math.max(1, Math.min(200, Math.round(Number(params.size) || 1)));
if (type === 'color-overlay') { if (type === 'color-overlay') {
params.color = /^#[0-9a-f]{6}$/i.test(params.color) ? params.color : defaults.color; params.color = /^#[0-9a-f]{6}$/i.test(params.color) ? params.color : defaults.color;
params.opacity = Math.max(0, Math.min(1, Number(params.opacity) || 0)); params.opacity = Math.max(0, Math.min(1, Number(params.opacity) || 0));
@@ -114,6 +123,9 @@ export function effectLabel(type) {
return { return {
'gaussian-blur': 'Gaussian Blur', 'gaussian-blur': 'Gaussian Blur',
sharpen: 'Sharpen', sharpen: 'Sharpen',
'motion-blur': 'Motion Blur',
'zoom-blur': 'Zoom Blur',
pixelate: 'Pixelate',
'color-overlay': 'Color Overlay', 'color-overlay': 'Color Overlay',
'drop-shadow': 'Drop Shadow', 'drop-shadow': 'Drop Shadow',
'stroke': 'Stroke', 'stroke': 'Stroke',
@@ -212,6 +224,44 @@ export function renderEffects(source, effects = [], shouldContinue = () => true)
const sharpened = sharpenCanvas(current, effect.params.amount, shouldContinue); const sharpened = sharpenCanvas(current, effect.params.amount, shouldContinue);
if (!sharpened) return current; if (!sharpened) return current;
ctx.drawImage(sharpened, 0, 0); ctx.drawImage(sharpened, 0, 0);
} else if (effect.type === 'motion-blur') {
const dist = effect.params.distance;
const rad = (effect.params.angle || 0) * Math.PI / 180;
const dx = Math.cos(rad);
const dy = Math.sin(rad);
const samples = Math.max(1, Math.min(64, Math.round(dist) || 1));
ctx.globalAlpha = 1 / samples;
for (let s = 0; s < samples; s += 1) {
const t = (samples > 1 ? (s / (samples - 1) - 0.5) : 0) * dist;
ctx.drawImage(current, dx * t, dy * t);
}
ctx.globalAlpha = 1;
} else if (effect.type === 'zoom-blur') {
const amt = effect.params.amount / 100;
const samples = Math.max(1, Math.min(48, Math.round(amt * 48) || 1));
const cx = next.width / 2;
const cy = next.height / 2;
ctx.globalAlpha = 1 / samples;
for (let s = 0; s < samples; s += 1) {
const scale = 1 + (s / samples) * amt * 0.5;
const dw = next.width * scale;
const dh = next.height * scale;
ctx.drawImage(current, cx - dw / 2, cy - dh / 2, dw, dh);
}
ctx.globalAlpha = 1;
} else if (effect.type === 'pixelate') {
const size = Math.max(1, effect.params.size);
const sw = Math.max(1, Math.round(next.width / size));
const sh = Math.max(1, Math.round(next.height / size));
const tiny = document.createElement('canvas');
tiny.width = sw;
tiny.height = sh;
const tctx = tiny.getContext('2d');
tctx.imageSmoothingEnabled = false;
tctx.drawImage(current, 0, 0, sw, sh);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(tiny, 0, 0, next.width, next.height);
ctx.imageSmoothingEnabled = true;
} else if (effect.type === 'color-overlay') { } else if (effect.type === 'color-overlay') {
ctx.drawImage(current, 0, 0); ctx.drawImage(current, 0, 0);
ctx.globalAlpha = effect.params.opacity; ctx.globalAlpha = effect.params.opacity;
+12
View File
@@ -1,9 +1,12 @@
/** Controlled image export dialog and canvas encoding helpers. */ /** Controlled image export dialog and canvas encoding helpers. */
import { exportPsd } from './psd.js';
const FORMAT_META = { const FORMAT_META = {
png: { mime: 'image/png', extension: 'png', quality: undefined }, png: { mime: 'image/png', extension: 'png', quality: undefined },
jpeg: { mime: 'image/jpeg', extension: 'jpg', quality: 0.9 }, jpeg: { mime: 'image/jpeg', extension: 'jpg', quality: 0.9 },
webp: { mime: 'image/webp', extension: 'webp', quality: 0.9 }, webp: { mime: 'image/webp', extension: 'webp', quality: 0.9 },
psd: { mime: 'image/vnd.adobe.photoshop', extension: 'psd', quality: undefined },
}; };
function clampNumber(value, min, max, fallback) { function clampNumber(value, min, max, fallback) {
@@ -43,6 +46,14 @@ export function prepareExportCanvas(sourceCanvas, settings) {
export function encodeExportCanvas(sourceCanvas, settings) { export function encodeExportCanvas(sourceCanvas, settings) {
const normalized = normalizeExportSettings(settings, sourceCanvas.width, sourceCanvas.height); const normalized = normalizeExportSettings(settings, sourceCanvas.width, sourceCanvas.height);
const output = prepareExportCanvas(sourceCanvas, normalized); const output = prepareExportCanvas(sourceCanvas, normalized);
if (normalized.format === 'psd') {
const bytes = exportPsd(output);
return Promise.resolve({
blob: new Blob([bytes], { type: FORMAT_META.psd.mime }),
settings: normalized,
extension: 'psd',
});
}
const meta = FORMAT_META[normalized.format]; const meta = FORMAT_META[normalized.format];
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
output.toBlob( output.toBlob(
@@ -86,6 +97,7 @@ export function openExportDialog({
<button type="button" class="active" data-format="png">PNG</button> <button type="button" class="active" data-format="png">PNG</button>
<button type="button" data-format="jpeg">JPEG</button> <button type="button" data-format="jpeg">JPEG</button>
<button type="button" data-format="webp">WebP</button> <button type="button" data-format="webp">WebP</button>
<button type="button" data-format="psd">PSD</button>
</div></div> </div></div>
<div class="ge-export-dimensions"> <div class="ge-export-dimensions">
<label class="ge-export-field"><span>Width</span><input id="ge-export-width" type="number" min="1" max="16384" value="${sourceCanvas.width}" /></label> <label class="ge-export-field"><span>Width</span><input id="ge-export-width" type="number" min="1" max="16384" value="${sourceCanvas.width}" /></label>
+238
View File
@@ -0,0 +1,238 @@
/**
* Haze removal (dehaze) — a compact, offline dark-channel-prior implementation
* with a guided-filter refined transmission map.
*
* The dark channel of a hazy photo is dominated by the atmospheric light, so the
* haze can be estimated and removed from a single image:
* 1. dark channel D(x) = min over {R,G,B} of a small local window (min of the
* channels, then a separable minimum filter). The window is kept small
* (patch of ~7-15px) so it does not smear a rectangular halo around hard
* edges.
* 2. atmospheric light A = the mean colour of the brightest 0.1% of the
* dark channel (a robust estimate; a single brightest cell is noisy).
* 3. transmission t(x) = 1 - omega * D(x) / max(A), clamped to >= t0 (0.1).
* 4. refine t with a guided filter (He et al. 2010) using the luminance as the
* guide, so the map follows real edges instead of the min-filter blocks.
* 5. recover J = (I - A) / max(t, t0) + A.
*
* The dark channel, atmospheric light and guided filter run on a bounded working
* copy (<= maxDim) and the refined transmission is up-sampled smoothly, so the
* pass stays interactive on large documents. No network or AI; deterministic.
*/
function clamp(value, lo, hi) {
return value < lo ? lo : value > hi ? hi : value;
}
/* Separable minimum filter (horizontal then vertical) over a Float array. */
function minFilter2D(values, width, height, radius) {
if (radius < 1) return values.slice();
const tmp = new Float32Array(width * height);
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
let min = Infinity;
for (let d = -radius; d <= radius; d += 1) {
const xx = x + d;
if (xx < 0 || xx >= width) continue;
const v = values[y * width + xx];
if (v < min) min = v;
}
tmp[y * width + x] = min;
}
}
const out = new Float32Array(width * height);
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
let min = Infinity;
for (let d = -radius; d <= radius; d += 1) {
const yy = y + d;
if (yy < 0 || yy >= height) continue;
const v = tmp[yy * width + x];
if (v < min) min = v;
}
out[y * width + x] = min;
}
}
return out;
}
/* O(N) mean (box) filter via a summed-area table. */
function boxFilter(src, width, height, radius) {
const stride = width + 1;
const integral = new Float64Array(stride * (height + 1));
for (let y = 0; y < height; y += 1) {
let rowSum = 0;
for (let x = 0; x < width; x += 1) {
rowSum += src[y * width + x];
integral[(y + 1) * stride + (x + 1)] = integral[y * stride + (x + 1)] + rowSum;
}
}
const out = new Float32Array(width * height);
for (let y = 0; y < height; y += 1) {
const y0 = Math.max(0, y - radius);
const y1 = Math.min(height - 1, y + radius);
for (let x = 0; x < width; x += 1) {
const x0 = Math.max(0, x - radius);
const x1 = Math.min(width - 1, x + radius);
const area = (x1 - x0 + 1) * (y1 - y0 + 1);
const sum = integral[(y1 + 1) * stride + (x1 + 1)]
- integral[y0 * stride + (x1 + 1)]
- integral[(y1 + 1) * stride + x0]
+ integral[y0 * stride + x0];
out[y * width + x] = sum / area;
}
}
return out;
}
/*
* Guided filter (He, Sun, Tang 2010) — edge-preserving smoothing of `p` guided
* by `guide`. Both arrays are 0..1; `eps` is the regularisation (1e-3 typical).
*/
function guidedFilter(guide, p, width, height, radius, eps) {
const n = width * height;
const meanGuide = boxFilter(guide, width, height, radius);
const meanP = boxFilter(p, width, height, radius);
const gg = new Float32Array(n);
const gp = new Float32Array(n);
for (let i = 0; i < n; i += 1) {
gg[i] = guide[i] * guide[i];
gp[i] = guide[i] * p[i];
}
const corrGG = boxFilter(gg, width, height, radius);
const corrGP = boxFilter(gp, width, height, radius);
const a = new Float32Array(n);
const b = new Float32Array(n);
for (let i = 0; i < n; i += 1) {
const varG = corrGG[i] - meanGuide[i] * meanGuide[i];
const covGP = corrGP[i] - meanGuide[i] * meanP[i];
a[i] = covGP / (varG + eps);
b[i] = meanP[i] - a[i] * meanGuide[i];
}
const meanA = boxFilter(a, width, height, radius);
const meanB = boxFilter(b, width, height, radius);
const q = new Float32Array(n);
for (let i = 0; i < n; i += 1) q[i] = meanA[i] * guide[i] + meanB[i];
return q;
}
export function dehazeImageData(src, width, height, options = {}) {
const strength = clamp(Number(options.strength ?? 0.8), 0, 1);
if (strength <= 0 || width < 2 || height < 2) return src;
const omega = clamp(Number(options.omega ?? 0.95), 0, 1) * strength;
// Small dark-channel patch (Photoshop-style 7-15px) keeps the halo tight.
const patch = Math.max(3, Math.min(31, Math.round(Number(options.patch) || 15)));
const guideRadius = Math.max(1, Math.round(Number(options.guideRadius) || 40));
const eps = Math.max(1e-6, Number(options.eps) || 1e-3);
const t0 = clamp(Number(options.t0 ?? 0.1), 0.02, 1);
const maxDim = Math.max(16, Number(options.maxDim) || 1024);
const scale = Math.min(1, maxDim / Math.max(width, height));
const sw = Math.max(2, Math.round(width * scale));
const sh = Math.max(2, Math.round(height * scale));
// Radius that yields a ~patch-pixel window at the working resolution.
const radius = Math.max(1, Math.round((patch - 1) / 2 * scale));
// Block down-sample: the darkest per-pixel min(R,G,B) in each cell, its colour
// (for the atmospheric light) and the mean luminance (the guided-filter guide).
const dark = new Float32Array(sw * sh);
const darkColor = new Float32Array(sw * sh * 3);
const lum = new Float32Array(sw * sh);
const bw = width / sw;
const bh = height / sh;
for (let sy = 0; sy < sh; sy += 1) {
const y0 = Math.floor(sy * bh);
const y1 = Math.min(height, Math.max(y0 + 1, Math.ceil((sy + 1) * bh)));
for (let sx = 0; sx < sw; sx += 1) {
const x0 = Math.floor(sx * bw);
const x1 = Math.min(width, Math.max(x0 + 1, Math.ceil((sx + 1) * bw)));
let best = 256;
let br = 0;
let bg = 0;
let bb = 0;
let lumSum = 0;
let count = 0;
for (let y = y0; y < y1; y += 1) {
for (let x = x0; x < x1; x += 1) {
const i = (y * width + x) * 4;
const r = src[i];
const g = src[i + 1];
const b = src[i + 2];
const mn = Math.min(r, g, b);
if (mn < best) { best = mn; br = r; bg = g; bb = b; }
lumSum += (r * 0.299 + g * 0.587 + b * 0.114) / 255;
count += 1;
}
}
const k = sy * sw + sx;
dark[k] = best;
darkColor[k * 3] = br;
darkColor[k * 3 + 1] = bg;
darkColor[k * 3 + 2] = bb;
lum[k] = count ? lumSum / count : 0;
}
}
const filtered = minFilter2D(dark, sw, sh, radius);
// Atmospheric light: mean colour of the brightest 0.1% of the dark channel.
const total = sw * sh;
const topCount = Math.max(1, Math.round(total * 0.001));
const order = new Int32Array(total);
for (let k = 0; k < total; k += 1) order[k] = k;
const ordered = Array.from(order).sort((a, b) => filtered[b] - filtered[a]);
let ar = 0;
let ag = 0;
let ab = 0;
for (let n = 0; n < topCount; n += 1) {
const idx = ordered[n];
ar += darkColor[idx * 3];
ag += darkColor[idx * 3 + 1];
ab += darkColor[idx * 3 + 2];
}
ar /= topCount;
ag /= topCount;
ab /= topCount;
const aLum = Math.max(1, Math.max(ar, ag, ab));
// Raw per-cell transmission (clamped to >= t0), then guided-filter refined.
const rawT = new Float32Array(total);
for (let k = 0; k < total; k += 1) {
rawT[k] = clamp(1 - omega * (filtered[k] / aLum), t0, 1);
}
const refined = guidedFilter(lum, rawT, sw, sh, guideRadius, eps);
const small = new Uint8ClampedArray(sw * sh * 4);
for (let k = 0; k < sw * sh; k += 1) {
const v = Math.round(clamp(refined[k], t0, 1) * 255);
small[k * 4] = v;
small[k * 4 + 1] = v;
small[k * 4 + 2] = v;
small[k * 4 + 3] = 255;
}
let tFull = null;
if (typeof document !== 'undefined') {
const sm = document.createElement('canvas');
sm.width = sw;
sm.height = sh;
sm.getContext('2d').putImageData(new ImageData(small, sw, sh), 0, 0);
const big = document.createElement('canvas');
big.width = width;
big.height = height;
const bctx = big.getContext('2d');
bctx.imageSmoothingEnabled = true;
bctx.imageSmoothingQuality = 'high';
bctx.drawImage(sm, 0, 0, width, height);
tFull = bctx.getImageData(0, 0, width, height).data;
}
const out = new Uint8ClampedArray(src.length);
for (let i = 0, p = 0; i < src.length; i += 4, p += 1) {
const t = tFull ? Math.max(t0, tFull[p * 4] / 255) : 1;
out[i] = clamp((src[i] - ar) / t + ar, 0, 255);
out[i + 1] = clamp((src[i + 1] - ag) / t + ag, 0, 255);
out[i + 2] = clamp((src[i + 2] - ab) / t + ab, 0, 255);
out[i + 3] = src[i + 3];
}
return out;
}
File diff suppressed because it is too large Load Diff
+196 -7
View File
@@ -48,8 +48,9 @@ import {
defaultAdjParams, defaultAdjParams,
} from '../layer-helpers.js'; } from '../layer-helpers.js';
import { drawHistogram } from './histogram.js'; import { drawHistogram } from './histogram.js';
import { parseCubeLut, generateCubeLut } from './cube-lut.js';
export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, getAdjustmentSource = null }) { export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, getAdjustmentSource = null, schedulePersist = null }) {
function suppressLayerGhostTap() { function suppressLayerGhostTap() {
window.__geSuppressLayerTapUntil = Date.now() + 650; window.__geSuppressLayerTapUntil = Date.now() + 650;
} }
@@ -152,6 +153,12 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
{ type: 'color-balance', label: 'Color Balance' }, { type: 'color-balance', label: 'Color Balance' },
{ type: 'selective-color', label: 'Selective Color' }, { type: 'selective-color', label: 'Selective Color' },
{ type: 'gradient-map', label: 'Gradient Map' }, { type: 'gradient-map', label: 'Gradient Map' },
{ type: 'invert', label: 'Invert' },
{ type: 'posterize', label: 'Posterize' },
{ type: 'threshold', label: 'Threshold' },
{ type: 'photo-filter', label: 'Photo Filter' },
{ type: 'channel-mixer', label: 'Channel Mixer' },
{ type: 'color-lookup', label: 'Color Lookup' },
]; ];
menu.innerHTML = items.map(i => menu.innerHTML = items.map(i =>
`<button class="ge-fx-menu-item" data-fx-type="${i.type}"><span class="ge-fx-menu-icon">${ADJ_ICONS[i.type] || ''}</span><span>${i.label}</span></button>` `<button class="ge-fx-menu-item" data-fx-type="${i.type}"><span class="ge-fx-menu-icon">${ADJ_ICONS[i.type] || ''}</span><span>${i.label}</span></button>`
@@ -332,7 +339,7 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
<span class="ge-adj-icon">${ADJ_ICONS[type] || ''}</span> <span class="ge-adj-icon">${ADJ_ICONS[type] || ''}</span>
<span class="ge-adj-title">${adjLayerLabel(type)}</span> <span class="ge-adj-title">${adjLayerLabel(type)}</span>
<span class="ge-head-btns"> <span class="ge-head-btns">
<button class="ge-adj-min" type="button" title="Minimise">&minus;</button> <button class="ge-adj-close" type="button" title="Close" aria-label="Close">&times;</button>
</span> </span>
</div> </div>
<label class="ge-adj-preset-field">Preset <label class="ge-adj-preset-field">Preset
@@ -382,7 +389,6 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
} }
pop.querySelector('.ge-adj-close')?.addEventListener('click', closeAdjPopup); pop.querySelector('.ge-adj-close')?.addEventListener('click', closeAdjPopup);
pop.querySelector('.ge-adj-min')?.addEventListener('click', () => minimiseAdjPopup(pop));
// Drag by head — anywhere except buttons. Mobile pins via !important // Drag by head — anywhere except buttons. Mobile pins via !important
// rules; setProperty with 'important' lets inline styles win during drag. // rules; setProperty with 'important' lets inline styles win during drag.
const head = pop.querySelector('[data-adj-drag]'); const head = pop.querySelector('[data-adj-drag]');
@@ -505,6 +511,7 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
composite(); composite();
renderLayerPanel(); renderLayerPanel();
closeAdjPopup(); closeAdjPopup();
schedulePersist?.();
}); });
} }
@@ -529,15 +536,22 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
if (!p.channels[channel]) p.channels[channel] = defaultAdjParams('levels').channels[channel]; if (!p.channels[channel]) p.channels[channel] = defaultAdjParams('levels').channels[channel];
return p.channels[channel]; return p.channels[channel];
}; };
// Params come from project files and drafts, which only validate the
// adjustment type; coerce anything interpolated into markup below.
const finite = value => (Number.isFinite(Number(value)) ? Number(value) : 0);
const hexColor = (value, fallback) => (/^#[0-9a-f]{6}$/i.test(value) ? value : fallback);
const revertIcon = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>'; const revertIcon = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>';
const sliderRow = (key, label, min, max, value, suffix) => ` const sliderRow = (key, label, min, max, rawValue, suffix, defaultValue) => {
<div class="ge-adj-row" data-adj-key="${key}"> const value = finite(rawValue);
return `
<div class="ge-adj-row" data-adj-key="${key}"${defaultValue != null ? ` data-default="${defaultValue}"` : ''}>
<label>${label}</label> <label>${label}</label>
<input type="range" min="${min}" max="${max}" value="${value}" data-key="${key}" /> <input type="range" min="${min}" max="${max}" value="${value}" data-key="${key}" />
<span class="ge-adj-value">${value}${suffix || ''}</span> <span class="ge-adj-value">${value}${suffix || ''}</span>
<button class="ge-adj-revert" type="button" title="Reset this slider" data-revert-key="${key}">${revertIcon}</button> <button class="ge-adj-revert" type="button" title="Reset this slider" data-revert-key="${key}">${revertIcon}</button>
</div> </div>
`; `;
};
if (type === 'brightness-contrast') { if (type === 'brightness-contrast') {
const bSlider = Math.round((p.brightness - 1) * 100); const bSlider = Math.round((p.brightness - 1) * 100);
const cSlider = Math.round((p.contrast - 1) * 100); const cSlider = Math.round((p.contrast - 1) * 100);
@@ -551,6 +565,25 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
${sliderRow('offset', 'Offset', -50, 50, Math.round(p.offset * 100), '')} ${sliderRow('offset', 'Offset', -50, 50, Math.round(p.offset * 100), '')}
${sliderRow('gamma', 'Gamma', 10, 300, Math.round(p.gamma * 100), 'γ')} ${sliderRow('gamma', 'Gamma', 10, 300, Math.round(p.gamma * 100), 'γ')}
`; `;
} else if (type === 'develop') {
body.innerHTML = `
<div class="ge-adj-develop">
<div class="ge-adj-group-label">Basic</div>
${sliderRow('temperature', 'Temperature', -100, 100, Math.round(p.temperature || 0), '', 0)}
${sliderRow('tint', 'Tint', -100, 100, Math.round(p.tint || 0), '', 0)}
${sliderRow('exposure', 'Exposure', -500, 500, Math.round((p.exposure || 0) * 100), ' EV', 0)}
${sliderRow('contrast', 'Contrast', -100, 100, Math.round(p.contrast || 0), '', 0)}
${sliderRow('highlights', 'Highlights', -100, 100, Math.round(p.highlights || 0), '', 0)}
${sliderRow('shadows', 'Shadows', -100, 100, Math.round(p.shadows || 0), '', 0)}
${sliderRow('whites', 'Whites', -100, 100, Math.round(p.whites || 0), '', 0)}
${sliderRow('blacks', 'Blacks', -100, 100, Math.round(p.blacks || 0), '', 0)}
<div class="ge-adj-group-label">Presence</div>
${sliderRow('clarity', 'Clarity', -100, 100, Math.round(p.clarity || 0), '', 0)}
${sliderRow('vibrance', 'Vibrance', -100, 100, Math.round(p.vibrance || 0), '', 0)}
${sliderRow('saturation', 'Saturation', -100, 100, Math.round(p.saturation || 0), '', 0)}
<p class="ge-adj-help">Camera-Raw-style Basic panel: white balance, exposure and tone, then clarity, vibrance and saturation.</p>
</div>
`;
} else if (type === 'white-balance') { } else if (type === 'white-balance') {
body.innerHTML = ` body.innerHTML = `
${sliderRow('temperature', 'Temperature', -100, 100, Math.round(p.temperature), '')} ${sliderRow('temperature', 'Temperature', -100, 100, Math.round(p.temperature), '')}
@@ -667,7 +700,7 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
}); });
} else if (type === 'color-balance') { } else if (type === 'color-balance') {
// Color-tinted slider ends so the user sees what direction does what. // Color-tinted slider ends so the user sees what direction does what.
const cbRow = (key, leftCol, rightCol, label, value) => ` const cbRow = (key, leftCol, rightCol, label, rawValue, value = finite(rawValue)) => `
<div class="ge-adj-row ge-adj-cb-row" data-adj-key="${key}"> <div class="ge-adj-row ge-adj-cb-row" data-adj-key="${key}">
<span class="ge-adj-cb-dot" style="background:${leftCol}"></span> <span class="ge-adj-cb-dot" style="background:${leftCol}"></span>
<input type="range" min="-100" max="100" value="${value}" data-key="${key}" /> <input type="range" min="-100" max="100" value="${value}" data-key="${key}" />
@@ -724,6 +757,9 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
buildAdjBody(layer, type, body, popEl); buildAdjBody(layer, type, body, popEl);
}); });
} else if (type === 'gradient-map') { } else if (type === 'gradient-map') {
const gradientDefaults = defaultAdjParams('gradient-map');
p.shadows = hexColor(p.shadows, gradientDefaults.shadows);
p.highlights = hexColor(p.highlights, gradientDefaults.highlights);
body.innerHTML = ` body.innerHTML = `
<div class="ge-gradient-map-preview" style="--ge-gradient-start:${p.shadows};--ge-gradient-end:${p.highlights}"></div> <div class="ge-gradient-map-preview" style="--ge-gradient-start:${p.shadows};--ge-gradient-end:${p.highlights}"></div>
<div class="ge-gradient-color-row"> <div class="ge-gradient-color-row">
@@ -746,6 +782,134 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
p.reverse = event.target.checked; p.reverse = event.target.checked;
scheduleAdjRefresh(layer); scheduleAdjRefresh(layer);
}); });
} else if (type === 'invert') {
body.innerHTML = `
<p class="ge-adj-help">Inverts every color channel in the source layer.</p>
`;
} else if (type === 'posterize') {
body.innerHTML = `
${sliderRow('levels', 'Levels', 2, 255, Math.round(p.levels ?? 4), '')}
<p class="ge-adj-help">Reduces each channel to a limited number of tonal steps.</p>
`;
} else if (type === 'threshold') {
body.innerHTML = `
${sliderRow('level', 'Threshold', 0, 255, Math.round(p.level ?? 128), '')}
<p class="ge-adj-help">Pixels brighter than the threshold become white, the rest black.</p>
`;
} else if (type === 'photo-filter') {
body.innerHTML = `
<div class="ge-adj-row">
<label>Color</label>
<input type="color" data-photo-key="color" value="${hexColor(p.color, '#ec8a00')}">
</div>
${sliderRow('density', 'Density', 0, 100, Math.round(p.density ?? 25), '%')}
<label class="ge-adj-toggle-row"><span>Preserve luminosity</span><input type="checkbox" data-photo-preserve${p.preserve_luminosity ? ' checked' : ''}><span class="ge-toggle-track"></span></label>
`;
body.querySelector('[data-photo-key="color"]')?.addEventListener('input', event => {
p.color = event.target.value;
scheduleAdjRefresh(layer);
});
body.querySelector('[data-photo-preserve]')?.addEventListener('change', event => {
p.preserve_luminosity = event.target.checked;
scheduleAdjRefresh(layer);
});
} else if (type === 'channel-mixer') {
const channels = ['red', 'green', 'blue'];
const ch = channels.includes(popEl._cmMixChannel) ? popEl._cmMixChannel : 'red';
popEl._cmMixChannel = ch;
if (!p[ch]) p[ch] = defaultAdjParams(type)[ch];
if (!p.constant) p.constant = defaultAdjParams(type).constant;
const values = p[ch];
body.innerHTML = `
<label class="ge-adj-channel-field">Output channel
<select class="ge-adj-cm-channel-select">
<option value="red"${ch === 'red' ? ' selected' : ''}>Red</option>
<option value="green"${ch === 'green' ? ' selected' : ''}>Green</option>
<option value="blue"${ch === 'blue' ? ' selected' : ''}>Blue</option>
</select>
</label>
${sliderRow(`${ch}-r`, 'Red', -200, 200, Math.round(values.r ?? 0), '%')}
${sliderRow(`${ch}-g`, 'Green', -200, 200, Math.round(values.g ?? 0), '%')}
${sliderRow(`${ch}-b`, 'Blue', -200, 200, Math.round(values.b ?? 0), '%')}
${sliderRow(`${ch}-c`, 'Constant', -100, 100, Math.round(p.constant[ch] ?? 0), '')}
<label class="ge-adj-toggle-row"><span>Monochrome</span><input type="checkbox" data-cm-monochrome${p.monochrome ? ' checked' : ''}><span class="ge-toggle-track"></span></label>
<p class="ge-adj-help">Mixes source channels into the selected output channel.</p>
`;
body.querySelector('.ge-adj-cm-channel-select')?.addEventListener('change', event => {
popEl._cmMixChannel = event.target.value;
body.innerHTML = '';
buildAdjBody(layer, type, body, popEl);
});
body.querySelector('[data-cm-monochrome]')?.addEventListener('change', event => {
p.monochrome = event.target.checked;
scheduleAdjRefresh(layer);
});
} else if (type === 'color-lookup') {
const presets = ['none', 'teal_orange', 'cold', 'warm', 'high_contrast', 'sepia'];
const presetLabels = { none: 'None', teal_orange: 'Teal & Orange', cold: 'Cold', warm: 'Warm', high_contrast: 'High Contrast', sepia: 'Sepia' };
const strength = p.strength == null ? 100 : p.strength;
const hasCube = !!(p.cube && p.cube.size && p.cube.data);
const lutName = hasCube ? `Loaded LUT · ${finite(p.cube.size)}³` : 'No .cube loaded';
body.innerHTML = `
<label class="ge-adj-channel-field">Lookup preset
<select class="ge-adj-lut-select">
${presets.map(name => `<option value="${name}"${(p.preset || 'none') === name ? ' selected' : ''}>${presetLabels[name]}</option>`).join('')}
</select>
</label>
<div class="ge-adj-lut-actions">
<label class="ge-btn ge-btn-sm ge-adj-lut-load">Load .cube…
<input type="file" accept=".cube,text/plain" hidden />
</label>
<button type="button" class="ge-btn ge-btn-sm ge-adj-lut-export"${hasCube ? '' : ' disabled'}>Export .cube</button>
</div>
<div class="ge-adj-lut-name">${lutName}</div>
${sliderRow('strength', 'Strength', 0, 100, strength, '%', 100)}
<p class="ge-adj-help">Import an Adobe .cube 3D LUT (or pick a built-in look). Strength blends the LUT with the original pixels.</p>
`;
body.querySelector('.ge-adj-lut-select')?.addEventListener('change', event => {
p.preset = event.target.value;
scheduleAdjRefresh(layer);
});
const fileInput = body.querySelector('.ge-adj-lut-load input[type="file"]');
fileInput?.addEventListener('change', async () => {
const file = fileInput.files && fileInput.files[0];
if (!file) return;
try {
const text = await file.text();
const lut = parseCubeLut(text);
p.cube = {
size: lut.size,
domainMin: Array.from(lut.domainMin),
domainMax: Array.from(lut.domainMax),
data: Array.from(lut.data),
};
if (p.strength == null) p.strength = 100;
const nameEl = body.querySelector('.ge-adj-lut-name');
if (nameEl) nameEl.textContent = `Loaded ${file.name} · ${lut.size}³`;
const exp = body.querySelector('.ge-adj-lut-export');
if (exp) exp.disabled = false;
scheduleAdjRefresh(layer);
} catch (err) {
const nameEl = body.querySelector('.ge-adj-lut-name');
if (nameEl) nameEl.textContent = 'Could not read .cube: ' + (err?.message || err);
}
});
body.querySelector('.ge-adj-lut-export')?.addEventListener('click', () => {
const cube = p.cube && p.cube.size ? p.cube : null;
if (!cube) return;
const text = generateCubeLut({
size: cube.size,
domainMin: cube.domainMin || [0, 0, 0],
domainMax: cube.domainMax || [1, 1, 1],
data: cube.data,
});
const blob = new Blob([text], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = 'look.cube';
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
});
} }
// Wire all sliders. // Wire all sliders.
body.querySelectorAll('input[type="range"]').forEach(sl => { body.querySelectorAll('input[type="range"]').forEach(sl => {
@@ -769,7 +933,7 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
function revertAdjKey(layer, type, key) { function revertAdjKey(layer, type, key) {
const defaults = defaultAdjParams(type); const defaults = defaultAdjParams(type);
const p = layer._stagedAdj.params; const p = layer._stagedAdj.params;
if (['brightness-contrast', 'exposure', 'white-balance', 'hue-saturation', 'vibrance', 'black-white', 'shadows-highlights', 'gradient-map'].includes(type)) { if (['brightness-contrast', 'exposure', 'white-balance', 'hue-saturation', 'vibrance', 'black-white', 'shadows-highlights', 'gradient-map', 'posterize', 'threshold', 'photo-filter', 'color-lookup'].includes(type)) {
p[key] = defaults[key]; p[key] = defaults[key];
} else if (type === 'levels') { } else if (type === 'levels') {
const channel = ['red', 'green', 'blue'].includes(p.channel) ? p.channel : 'rgb'; const channel = ['red', 'green', 'blue'].includes(p.channel) ? p.channel : 'rgb';
@@ -779,6 +943,10 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
} else if (type === 'color-balance') { } else if (type === 'color-balance') {
const [tone, ch] = key.split('-'); const [tone, ch] = key.split('-');
p[tone][ch] = defaults[tone][ch]; p[tone][ch] = defaults[tone][ch];
} else if (type === 'channel-mixer') {
const [ch, comp] = key.split('-');
if (comp === 'c') p.constant[ch] = defaults.constant[ch];
else p[ch][comp] = defaults[ch][comp];
} else if (type === 'selective-color') { } else if (type === 'selective-color') {
p.ranges[p.range][key] = defaults.ranges[p.range][key]; p.ranges[p.range][key] = defaults.ranges[p.range][key];
} }
@@ -791,6 +959,10 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
const raw = parseInt(sl.value, 10); const raw = parseInt(sl.value, 10);
const valEl = sl.parentElement.querySelector('.ge-adj-value'); const valEl = sl.parentElement.querySelector('.ge-adj-value');
const p = layer._stagedAdj.params; const p = layer._stagedAdj.params;
const row = sl.closest('.ge-adj-row');
if (row && row.hasAttribute('data-default')) {
row.classList.toggle('is-modified', Number(sl.value) !== Number(row.getAttribute('data-default')));
}
let display = String(raw); let display = String(raw);
if (type === 'brightness-contrast' || type === 'hue-saturation') { if (type === 'brightness-contrast' || type === 'hue-saturation') {
if (key === 'brightness' || key === 'contrast' || key === 'saturation') { if (key === 'brightness' || key === 'contrast' || key === 'saturation') {
@@ -804,6 +976,11 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
if (key === 'exposure') { p.exposure = raw / 100; display = (raw / 100).toFixed(2) + ' EV'; } if (key === 'exposure') { p.exposure = raw / 100; display = (raw / 100).toFixed(2) + ' EV'; }
else if (key === 'offset') { p.offset = raw / 100; display = (raw / 100).toFixed(2); } else if (key === 'offset') { p.offset = raw / 100; display = (raw / 100).toFixed(2); }
else if (key === 'gamma') { p.gamma = raw / 100; display = (raw / 100).toFixed(2) + 'γ'; } else if (key === 'gamma') { p.gamma = raw / 100; display = (raw / 100).toFixed(2) + 'γ'; }
} else if (type === 'develop') {
if (key === 'exposure') { p.exposure = raw / 100; display = (raw / 100).toFixed(2) + ' EV'; }
else { p[key] = raw; }
} else if (type === 'color-lookup') {
p.strength = raw; display = raw + '%';
} else if (type === 'white-balance') { } else if (type === 'white-balance') {
p[key] = raw; p[key] = raw;
} else if (type === 'vibrance') { } else if (type === 'vibrance') {
@@ -828,6 +1005,18 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
} else if (type === 'gradient-map') { } else if (type === 'gradient-map') {
p.midpoint = raw; p.midpoint = raw;
display = raw + '%'; display = raw + '%';
} else if (type === 'posterize') {
p.levels = raw;
} else if (type === 'threshold') {
p.level = raw;
} else if (type === 'photo-filter') {
p.density = raw;
display = raw + '%';
} else if (type === 'channel-mixer') {
const [ch, comp] = key.split('-');
if (comp === 'c') p.constant[ch] = raw;
else p[ch][comp] = raw;
display = raw + (comp === 'c' ? '' : '%');
} }
if (valEl) valEl.textContent = display; if (valEl) valEl.textContent = display;
scheduleAdjRefresh(layer); scheduleAdjRefresh(layer);
+146
View File
@@ -0,0 +1,146 @@
/**
* Adobe .cube 3D LUT support — parse, apply (trilinear), and generate.
*
* Pure functions (no DOM, no network, no module state) so they run identically
* on the main thread, inside the adjustment worker, and under node in tests.
*
* Data layout: `data` is a flat RGB array with the RED component varying
* fastest, then green, then blue — the ordering the .cube spec uses for its
* row list. Sample index = (ri + gi*size + bi*size*size) * 3.
*/
function clamp01(v) {
return v < 0 ? 0 : v > 1 ? 1 : v;
}
function sampleChannel(data, size, ri, gi, bi, ch) {
return data[(ri + gi * size + bi * size * size) * 3 + ch];
}
/** Parse an Adobe .cube text file into { size, domainMin, domainMax, data }. */
export function parseCubeLut(text) {
if (typeof text !== 'string' || !text.trim()) throw new Error('empty .cube data');
const lines = text.split(/\r?\n/);
let size = 0;
let domainMin = [0, 0, 0];
let domainMax = [1, 1, 1];
const values = [];
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const parts = line.split(/\s+/);
const keyword = parts[0].toUpperCase();
if (keyword === 'TITLE') continue;
if (keyword === 'LUT_3D_SIZE') { size = Math.max(2, Math.round(Number(parts[1]) || 0)); continue; }
if (keyword === 'LUT_1D_SIZE') throw new Error('1D .cube LUTs are not supported');
if (keyword === 'DOMAIN_MIN') { domainMin = [Number(parts[1]) || 0, Number(parts[2]) || 0, Number(parts[3]) || 0]; continue; }
if (keyword === 'DOMAIN_MAX') { domainMax = [Number(parts[1]) || 1, Number(parts[2]) || 1, Number(parts[3]) || 1]; continue; }
const r = Number(parts[0]);
const g = Number(parts[1]);
const b = Number(parts[2]);
if (Number.isFinite(r) && Number.isFinite(g) && Number.isFinite(b)) values.push(r, g, b);
}
if (!size) throw new Error('missing LUT_3D_SIZE');
const expected = size * size * size * 3;
if (values.length < expected) throw new Error(`incomplete .cube data (${values.length / 3} of ${size ** 3} rows)`);
for (let i = 0; i < size; i += 1) {
if (domainMax[i] <= domainMin[i]) domainMax[i] = domainMin[i] + 1;
}
return { size, domainMin, domainMax, data: new Float64Array(values.slice(0, expected)) };
}
/** Trilinear lookup of an already domain-normalized [0,1] rgb triple. */
function trilinear(lut, r, g, b) {
const n = lut.size;
const max = n - 1;
const fr = clamp01(r) * max;
const fg = clamp01(g) * max;
const fb = clamp01(b) * max;
const r0 = Math.floor(fr); const r1 = Math.min(max, r0 + 1); const tr = fr - r0;
const g0 = Math.floor(fg); const g1 = Math.min(max, g0 + 1); const tg = fg - g0;
const b0 = Math.floor(fb); const b1 = Math.min(max, b0 + 1); const tb = fb - b0;
const out = [0, 0, 0];
for (let ch = 0; ch < 3; ch += 1) {
const c000 = sampleChannel(lut.data, n, r0, g0, b0, ch);
const c100 = sampleChannel(lut.data, n, r1, g0, b0, ch);
const c010 = sampleChannel(lut.data, n, r0, g1, b0, ch);
const c110 = sampleChannel(lut.data, n, r1, g1, b0, ch);
const c001 = sampleChannel(lut.data, n, r0, g0, b1, ch);
const c101 = sampleChannel(lut.data, n, r1, g0, b1, ch);
const c011 = sampleChannel(lut.data, n, r0, g1, b1, ch);
const c111 = sampleChannel(lut.data, n, r1, g1, b1, ch);
const c00 = c000 + (c100 - c000) * tr;
const c10 = c010 + (c110 - c010) * tr;
const c01 = c001 + (c101 - c001) * tr;
const c11 = c011 + (c111 - c011) * tr;
const c0 = c00 + (c10 - c00) * tg;
const c1 = c01 + (c11 - c01) * tg;
out[ch] = c0 + (c1 - c0) * tb;
}
return out;
}
/**
* Apply a parsed LUT in place to an RGBA byte array.
* @param {Uint8ClampedArray|Uint8Array} data RGBA bytes
* @param {number} strength 0..1 blend (1 = full LUT)
*/
export function applyCubeLut(data, width, height, lut, { strength = 1 } = {}) {
if (!lut || !lut.data || !lut.size) return data;
const s = clamp01(Number.isFinite(strength) ? strength : 1);
if (s <= 0) return data;
const { domainMin, domainMax } = lut;
const n = data.length;
const out = [0, 0, 0];
for (let i = 0; i < n; i += 4) {
const r = (data[i] / 255 - domainMin[0]) / (domainMax[0] - domainMin[0]);
const g = (data[i + 1] / 255 - domainMin[1]) / (domainMax[1] - domainMin[1]);
const b = (data[i + 2] / 255 - domainMin[2]) / (domainMax[2] - domainMin[2]);
const mapped = trilinear(lut, r, g, b);
data[i] = Math.round((data[i] / 255 + (clamp01(mapped[0]) - data[i] / 255) * s) * 255);
data[i + 1] = Math.round((data[i + 1] / 255 + (clamp01(mapped[1]) - data[i + 1] / 255) * s) * 255);
data[i + 2] = Math.round((data[i + 2] / 255 + (clamp01(mapped[2]) - data[i + 2] / 255) * s) * 255);
}
return data;
}
/** Serialize a { size, data } LUT to .cube text (r fastest). */
export function generateCubeLut(lut) {
const size = lut?.size;
if (!size || !lut.data) throw new Error('lut with size + data required');
const domainMin = lut.domainMin || [0, 0, 0];
const domainMax = lut.domainMax || [1, 1, 1];
const lines = [
'TITLE "odysseus"',
`LUT_3D_SIZE ${size}`,
`DOMAIN_MIN ${domainMin[0].toFixed(6)} ${domainMin[1].toFixed(6)} ${domainMin[2].toFixed(6)}`,
`DOMAIN_MAX ${domainMax[0].toFixed(6)} ${domainMax[1].toFixed(6)} ${domainMax[2].toFixed(6)}`,
];
for (let b = 0; b < size; b += 1) {
for (let g = 0; g < size; g += 1) {
for (let r = 0; r < size; r += 1) {
const o = (r + g * size + b * size * size) * 3;
lines.push(`${lut.data[o].toFixed(6)} ${lut.data[o + 1].toFixed(6)} ${lut.data[o + 2].toFixed(6)}`);
}
}
}
return lines.join('\n') + '\n';
}
/** Identity LUT of the given grid size. */
export function identityLut(size = 2) {
const n = Math.max(2, Math.round(size));
const data = new Float64Array(n * n * n * 3);
const max = n - 1;
for (let b = 0; b < n; b += 1) {
for (let g = 0; g < n; g += 1) {
for (let r = 0; r < n; r += 1) {
const o = (r + g * n + b * n * n) * 3;
data[o] = r / max; data[o + 1] = g / max; data[o + 2] = b / max;
}
}
}
return { size: n, domainMin: [0, 0, 0], domainMax: [1, 1, 1], data };
}
export default { parseCubeLut, applyCubeLut, generateCubeLut, identityLut };
+161
View File
@@ -0,0 +1,161 @@
/**
* Camera-Raw-style "Develop" tonal pipeline — a single deterministic pass that
* mirrors the Basic slider set (white balance, exposure, highlights, shadows,
* whites, blacks, contrast, clarity, vibrance, saturation).
*
* Pure pixel math: no DOM, no network, no module state, so it runs identically
* on the main thread and inside the adjustment worker. Work is done in
* [0,1] floats and hue is preserved by scaling the RGB triplet by the ratio
* between the shaped luma and the original luma, so a tonal move never drifts
* the colour.
*/
function clamp01(value) {
return value < 0 ? 0 : value > 1 ? 1 : value;
}
function finiteOr(value, fallback) {
const number = Number(value);
return Number.isFinite(number) ? number : fallback;
}
export const DEVELOP_DEFAULTS = Object.freeze({
exposure: 0,
contrast: 0,
highlights: 0,
shadows: 0,
whites: 0,
blacks: 0,
temperature: 0,
tint: 0,
clarity: 0,
vibrance: 0,
saturation: 0,
});
/** Separable box blur of a scalar field — used for the clarity local contrast. */
function boxBlurField(field, width, height, radius) {
if (radius < 1) return field;
const tmp = new Float32Array(field.length);
const out = new Float32Array(field.length);
const window = radius * 2 + 1;
for (let y = 0; y < height; y += 1) {
const row = y * width;
let sum = 0;
for (let x = -radius; x <= radius; x += 1) sum += field[row + Math.max(0, Math.min(width - 1, x))];
for (let x = 0; x < width; x += 1) {
tmp[row + x] = sum / window;
const add = field[row + Math.min(width - 1, x + radius + 1)];
const sub = field[row + Math.max(0, x - radius)];
sum += add - sub;
}
}
for (let x = 0; x < width; x += 1) {
let sum = 0;
for (let y = -radius; y <= radius; y += 1) sum += tmp[Math.max(0, Math.min(height - 1, y)) * width + x];
for (let y = 0; y < height; y += 1) {
out[y * width + x] = sum / window;
const add = tmp[Math.min(height - 1, y + radius + 1) * width + x];
const sub = tmp[Math.max(0, y - radius) * width + x];
sum += add - sub;
}
}
return out;
}
/**
* Apply the Develop pipeline in place on an RGBA byte array.
* @returns {Uint8ClampedArray} the same array (mutated) for chaining.
*/
export function developImageData(data, width, height, params = {}) {
const p = {
exposure: finiteOr(params.exposure, 0),
contrast: finiteOr(params.contrast, 0),
highlights: finiteOr(params.highlights, 0),
shadows: finiteOr(params.shadows, 0),
whites: finiteOr(params.whites, 0),
blacks: finiteOr(params.blacks, 0),
temperature: finiteOr(params.temperature, 0),
tint: finiteOr(params.tint, 0),
clarity: finiteOr(params.clarity, 0),
vibrance: finiteOr(params.vibrance, 0),
saturation: finiteOr(params.saturation, 0),
};
const active = Object.keys(p).some(key => Math.abs(p[key]) > 1e-6);
if (!active || width <= 0 || height <= 0) return data;
const n = width * height;
const exp = Math.pow(2, p.exposure);
const t = p.temperature / 100;
const ti = p.tint / 100;
// Temperature: +warms (more red, less blue). Tint: +magenta (less green).
const rMul = exp * (1 + 0.30 * t) * (1 + 0.10 * ti);
const gMul = exp * (1 - 0.20 * ti);
const bMul = exp * (1 - 0.30 * t) * (1 + 0.10 * ti);
const hi = p.highlights / 100;
const sh = p.shadows / 100;
const wh = p.whites / 100;
const bl = p.blacks / 100;
const con = p.contrast / 100;
const vib = p.vibrance / 100;
const sat = p.saturation / 100;
const clarity = p.clarity / 100;
const lum = new Float32Array(n);
for (let i = 0; i < n; i += 1) {
const o = i * 4;
let r = clamp01((data[o] / 255) * rMul);
let g = clamp01((data[o + 1] / 255) * gMul);
let b = clamp01((data[o + 2] / 255) * bMul);
const base = 0.2126 * r + 0.7152 * g + 0.0722 * b;
let y = base;
if (sh) y += sh * 0.5 * (1 - y) * (1 - y); // + brightens shadows
if (hi) y += hi * 0.5 * y * y; // + brightens / - recovers highlights
if (wh) y += wh * 0.30 * y * y; // whites: endpoint at the top
if (bl) y -= bl * 0.30 * (1 - y) * (1 - y); // blacks: endpoint at the bottom
y = clamp01(y);
if (con) y = clamp01(0.5 + (y - 0.5) * (1 + con * 0.9)); // S-curve about mid-grey
if (base > 1e-5) {
const k = y / base;
r = clamp01(r * k);
g = clamp01(g * k);
b = clamp01(b * k);
} else {
r = g = b = y;
}
lum[i] = 0.2126 * r + 0.7152 * g + 0.0722 * b;
if (vib || sat) {
const avg = (r + g + b) / 3;
const mx = Math.max(r, g, b);
const mn = Math.min(r, g, b);
const chroma = mx > 0 ? (mx - mn) / mx : 0;
const amount = sat + vib * (1 - chroma);
if (amount) {
r = clamp01(r + (r - avg) * amount);
g = clamp01(g + (g - avg) * amount);
b = clamp01(b + (b - avg) * amount);
}
}
data[o] = Math.round(r * 255);
data[o + 1] = Math.round(g * 255);
data[o + 2] = Math.round(b * 255);
}
if (clarity) {
const radius = Math.max(2, Math.round(Math.min(width, height) * 0.01));
const blurred = boxBlurField(lum, width, height, radius);
for (let i = 0; i < n; i += 1) {
const detail = (lum[i] - blurred[i]) * clarity * 1.2;
const o = i * 4;
data[o] = Math.max(0, Math.min(255, data[o] + detail * 255));
data[o + 1] = Math.max(0, Math.min(255, data[o + 1] + detail * 255));
data[o + 2] = Math.max(0, Math.min(255, data[o + 2] + detail * 255));
}
}
return data;
}
export default { developImageData, DEVELOP_DEFAULTS };
+159 -16
View File
@@ -14,6 +14,9 @@
* { type: 'curves', params: { points: {rgb, red, green, blue} } } * { type: 'curves', params: { points: {rgb, red, green, blue} } }
* { type: 'color-balance', params: { shadows, midtones, highlights } } * { type: 'color-balance', params: { shadows, midtones, highlights } }
*/ */
import { developImageData } from './develop.js';
import { applyCubeLut } from './cube-lut.js';
function clampByte(value) { function clampByte(value) {
return value < 0 ? 0 : value > 255 ? 255 : Math.round(value); return value < 0 ? 0 : value > 255 ? 255 : Math.round(value);
} }
@@ -136,6 +139,12 @@ export function applyAdjustment(srcCanvas, adj) {
const img = octx.getImageData(0, 0, w, h); const img = octx.getImageData(0, 0, w, h);
const d = img.data; const d = img.data;
if (adj.type === 'develop') {
const p = adj.params || {};
developImageData(d, w, h, p);
octx.putImageData(img, 0, 0);
return out;
}
if (adj.type === 'exposure') { if (adj.type === 'exposure') {
const p = adj.params || {}; const p = adj.params || {};
const multiplier = Math.pow(2, finiteOr(p.exposure, 0)); const multiplier = Math.pow(2, finiteOr(p.exposure, 0));
@@ -182,13 +191,20 @@ export function applyAdjustment(srcCanvas, adj) {
if (adj.type === 'vibrance') { if (adj.type === 'vibrance') {
const amount = Math.max(-1, Math.min(1, finiteOr(adj.params?.vibrance, 0) / 100)); const amount = Math.max(-1, Math.min(1, finiteOr(adj.params?.vibrance, 0) / 100));
// Scale chroma around luminance by a factor that leans on low-saturation
// pixels. Doing it directly in RGB (no HSL round-trip) keeps near-gray
// pixels near-gray instead of pinning them to an absolute saturation floor,
// which previously turned faint hue noise (e.g. lifted shadow haze) into
// false-colour banding.
for (let i = 0; i < d.length; i += 4) { for (let i = 0; i < d.length; i += 4) {
const [hue, saturation, lightness] = rgbToHsl(d[i], d[i + 1], d[i + 2]); const r = d[i], g = d[i + 1], b = d[i + 2];
const adjustedSaturation = amount >= 0 const max = Math.max(r, g, b), min = Math.min(r, g, b);
? saturation + (1 - saturation) * amount const avg = (r + g + b) / 3;
: saturation * (1 + amount); const sat = max <= 0 ? 0 : (max - min) / max;
const rgb = hslToRgb(hue, Math.max(0, Math.min(1, adjustedSaturation)), lightness); const factor = 1 + (1 - sat) * amount;
d[i] = clampByte(rgb[0]); d[i + 1] = clampByte(rgb[1]); d[i + 2] = clampByte(rgb[2]); d[i] = clampByte(avg + (r - avg) * factor);
d[i + 1] = clampByte(avg + (g - avg) * factor);
d[i + 2] = clampByte(avg + (b - avg) * factor);
} }
octx.putImageData(img, 0, 0); octx.putImageData(img, 0, 0);
return out; return out;
@@ -213,16 +229,23 @@ export function applyAdjustment(srcCanvas, adj) {
if (adj.type === 'shadows-highlights') { if (adj.type === 'shadows-highlights') {
const shadows = Math.max(-1, Math.min(1, finiteOr(adj.params?.shadows, 0) / 100)); const shadows = Math.max(-1, Math.min(1, finiteOr(adj.params?.shadows, 0) / 100));
const highlights = Math.max(-1, Math.min(1, finiteOr(adj.params?.highlights, 0) / 100)); const highlights = Math.max(-1, Math.min(1, finiteOr(adj.params?.highlights, 0) / 100));
const toneAdjust = (value, amount, weight) => amount >= 0 // Endpoint-preserving tonal lift. A linear "add toward white" lift turns
? value + (255 - value) * amount * weight // every near-black pixel grey (flattening the whole image under a veil);
: value + value * amount * weight; // instead scale luminance with a gamma curve so 0 stays 0 and 255 stays 255
// while shadows/midtones open up. Colour is preserved via the luminance ratio.
for (let i = 0; i < d.length; i += 4) { for (let i = 0; i < d.length; i += 4) {
const luminance = (0.2126 * d[i] + 0.7152 * d[i + 1] + 0.0722 * d[i + 2]) / 255; const r = d[i], g = d[i + 1], b = d[i + 2];
const shadowWeight = (1 - luminance) ** 2; const luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
const highlightWeight = luminance ** 2; if (luminance <= 1 / 255) continue;
d[i] = clampByte(toneAdjust(toneAdjust(d[i], shadows, shadowWeight), highlights, highlightWeight)); const shadowWeight = (1 - luminance) * (1 - luminance);
d[i + 1] = clampByte(toneAdjust(toneAdjust(d[i + 1], shadows, shadowWeight), highlights, highlightWeight)); const highlightWeight = luminance * luminance;
d[i + 2] = clampByte(toneAdjust(toneAdjust(d[i + 2], shadows, shadowWeight), highlights, highlightWeight)); let exponent = 1;
if (shadows) exponent /= (1 + shadows * shadowWeight * 0.85);
if (highlights) exponent /= (1 + highlights * highlightWeight * 0.85);
const ratio = Math.pow(luminance, exponent - 1);
d[i] = clampByte(r * ratio);
d[i + 1] = clampByte(g * ratio);
d[i + 2] = clampByte(b * ratio);
} }
octx.putImageData(img, 0, 0); octx.putImageData(img, 0, 0);
return out; return out;
@@ -261,7 +284,17 @@ export function applyAdjustment(srcCanvas, adj) {
if (adj.type === 'color-balance') { if (adj.type === 'color-balance') {
const cb = adj.params; const cb = adj.params;
const scale = 0.6; const scale = 0.6;
const s = cb.shadows, m = cb.midtones, hi = cb.highlights; // Be robust to malformed planner params: a tone may arrive as a bare
// number instead of {r,g,b}, which would otherwise produce NaN pixels
// (a blank white canvas). Coerce every tone to finite {r,g,b}.
const toTone = (v) => {
if (v && typeof v === 'object') {
return { r: Number.isFinite(v.r) ? v.r : 0, g: Number.isFinite(v.g) ? v.g : 0, b: Number.isFinite(v.b) ? v.b : 0 };
}
const n = Number.isFinite(v) ? v : 0;
return { r: n, g: n, b: n };
};
const s = toTone(cb.shadows), m = toTone(cb.midtones), hi = toTone(cb.highlights);
const sR = s.r*scale, sG = s.g*scale, sB = s.b*scale; const sR = s.r*scale, sG = s.g*scale, sB = s.b*scale;
const mR = m.r*scale, mG = m.g*scale, mB = m.b*scale; const mR = m.r*scale, mG = m.g*scale, mB = m.b*scale;
const hR = hi.r*scale, hG = hi.g*scale, hB = hi.b*scale; const hR = hi.r*scale, hG = hi.g*scale, hB = hi.b*scale;
@@ -327,6 +360,116 @@ export function applyAdjustment(srcCanvas, adj) {
return out; return out;
} }
if (adj.type === 'invert') {
for (let i = 0; i < d.length; i += 4) {
d[i] = 255 - d[i];
d[i + 1] = 255 - d[i + 1];
d[i + 2] = 255 - d[i + 2];
}
octx.putImageData(img, 0, 0);
return out;
}
if (adj.type === 'posterize') {
const levels = Math.max(2, Math.min(255, Math.round(finiteOr(adj.params?.levels, 4))));
const step = 255 / (levels - 1);
for (let i = 0; i < d.length; i += 4) {
d[i] = clampByte(Math.round(d[i] / step) * step);
d[i + 1] = clampByte(Math.round(d[i + 1] / step) * step);
d[i + 2] = clampByte(Math.round(d[i + 2] / step) * step);
}
octx.putImageData(img, 0, 0);
return out;
}
if (adj.type === 'threshold') {
const level = Math.max(0, Math.min(255, Math.round(finiteOr(adj.params?.level, 128))));
for (let i = 0; i < d.length; i += 4) {
const luminance = 0.2126 * d[i] + 0.7152 * d[i + 1] + 0.0722 * d[i + 2];
const value = luminance >= level ? 255 : 0;
d[i] = value; d[i + 1] = value; d[i + 2] = value;
}
octx.putImageData(img, 0, 0);
return out;
}
if (adj.type === 'photo-filter') {
const p = adj.params || {};
const color = parseHexColor(p.color, 'ec8a00');
const density = Math.max(0, Math.min(1, finiteOr(p.density, 25) / 100));
const preserve = p.preserve_luminosity !== false;
for (let i = 0; i < d.length; i += 4) {
const sourceLum = 0.2126 * d[i] + 0.7152 * d[i + 1] + 0.0722 * d[i + 2];
let r = d[i] * (1 - density) + color[0] * density;
let g = d[i + 1] * (1 - density) + color[1] * density;
let b = d[i + 2] * (1 - density) + color[2] * density;
if (preserve) {
const filteredLum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
if (filteredLum > 0.001) { const scale = sourceLum / filteredLum; r *= scale; g *= scale; b *= scale; }
}
d[i] = clampByte(r); d[i + 1] = clampByte(g); d[i + 2] = clampByte(b);
}
octx.putImageData(img, 0, 0);
return out;
}
if (adj.type === 'channel-mixer') {
const p = adj.params || {};
const red = p.red || { r: 100, g: 0, b: 0 };
const green = p.green || { r: 0, g: 100, b: 0 };
const blue = p.blue || { r: 0, g: 0, b: 100 };
const constant = p.constant || { r: 0, g: 0, b: 0 };
const mono = !!p.monochrome;
for (let i = 0; i < d.length; i += 4) {
const r = d[i], g = d[i + 1], b = d[i + 2];
const nr = (r * finiteOr(red.r, 100) + g * finiteOr(red.g, 0) + b * finiteOr(red.b, 0)) / 100 + finiteOr(constant.r, 0) * 2.55;
const ng = (r * finiteOr(green.r, 0) + g * finiteOr(green.g, 100) + b * finiteOr(green.b, 0)) / 100 + finiteOr(constant.g, 0) * 2.55;
const nb = (r * finiteOr(blue.r, 0) + g * finiteOr(blue.g, 0) + b * finiteOr(blue.b, 100)) / 100 + finiteOr(constant.b, 0) * 2.55;
if (mono) { d[i] = d[i + 1] = d[i + 2] = clampByte(nr); continue; }
d[i] = clampByte(nr); d[i + 1] = clampByte(ng); d[i + 2] = clampByte(nb);
}
octx.putImageData(img, 0, 0);
return out;
}
if (adj.type === 'color-lookup') {
const preset = String(adj.params?.preset || 'none');
// An imported .cube LUT (Adobe Cube spec) takes precedence over the small
// built-in preset matrices. `params.cube` is stored serializable:
// { size, domainMin:[3], domainMax:[3], data:[size^3*3] } (red fastest)
const cube = adj.params?.cube;
if (cube && cube.size && cube.data) {
const lut = {
size: Number(cube.size),
domainMin: Array.isArray(cube.domainMin) ? cube.domainMin.slice(0, 3) : [0, 0, 0],
domainMax: Array.isArray(cube.domainMax) ? cube.domainMax.slice(0, 3) : [1, 1, 1],
data: cube.data instanceof Float64Array ? cube.data : new Float64Array(cube.data),
};
const strength = adj.params?.strength == null ? 1 : Math.max(0, Math.min(1, Number(adj.params.strength) / 100));
applyCubeLut(d, w, h, lut, { strength });
octx.putImageData(img, 0, 0);
return out;
}
const matrices = {
teal_orange: [[1.05, -0.05, 0.0], [0.0, 1.0, 0.0], [0.0, -0.02, 1.05]],
cold: [[0.95, 0.0, 0.05], [0.0, 1.0, 0.03], [0.02, 0.05, 1.12]],
warm: [[1.12, 0.02, 0.0], [0.03, 1.0, 0.0], [0.0, 0.02, 0.92]],
high_contrast: [[1.35, -0.18, -0.17], [-0.17, 1.35, -0.18], [-0.18, -0.17, 1.35]],
sepia: [[0.393, 0.769, 0.189], [0.349, 0.686, 0.168], [0.272, 0.534, 0.131]],
};
const m = matrices[preset];
if (m) {
for (let i = 0; i < d.length; i += 4) {
const r = d[i], g = d[i + 1], b = d[i + 2];
d[i] = clampByte(r * m[0][0] + g * m[0][1] + b * m[0][2]);
d[i + 1] = clampByte(r * m[1][0] + g * m[1][1] + b * m[1][2]);
d[i + 2] = clampByte(r * m[2][0] + g * m[2][1] + b * m[2][2]);
}
}
octx.putImageData(img, 0, 0);
return out;
}
return out; return out;
} }
+18 -6
View File
@@ -24,17 +24,29 @@ function pixelSlots(snapshot) {
return slots; return slots;
} }
function samePixels(image, other) {
if (image === other) return true;
if (image.width !== other.width || image.height !== other.height || image.data.length !== other.data.length) return false;
const length = image.data.length;
// Compare 4 bytes at a time: the byte-by-byte loop dominated selection
// commits on large documents (every layer of the previous snapshot was
// re-scanned one byte at a time).
if (length % 4 === 0) {
const a = new Uint32Array(image.data.buffer, image.data.byteOffset, length >> 2);
const b = new Uint32Array(other.data.buffer, other.data.byteOffset, length >> 2);
for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) return false;
return true;
}
for (let i = 0; i < length; i += 1) if (image.data[i] !== other.data[i]) return false;
return true;
}
export function shareSnapshotPixels(snapshot, previous) { export function shareSnapshotPixels(snapshot, previous) {
if (!previous) return snapshot; if (!previous) return snapshot;
const candidates = pixelSlots(previous).map(([object, key]) => object[key]); const candidates = pixelSlots(previous).map(([object, key]) => object[key]);
for (const [object, key] of pixelSlots(snapshot)) { for (const [object, key] of pixelSlots(snapshot)) {
const image = object[key]; const image = object[key];
const match = candidates.find(other => { const match = candidates.find(other => samePixels(image, other));
if (image === other) return true;
if (image.width !== other.width || image.height !== other.height || image.data.length !== other.data.length) return false;
for (let i = 0; i < image.data.length; i++) if (image.data[i] !== other.data[i]) return false;
return true;
});
if (match) object[key] = match; if (match) object[key] = match;
} }
return snapshot; return snapshot;
+108 -12
View File
@@ -9,6 +9,10 @@
* @param {{ * @param {{
* undo: () => void, * undo: () => void,
* redo: () => void, * redo: () => void,
* takeSnapshot?: (label?: string) => ({ id: string, label: string } | void),
* restoreSnapshot?: (id: string) => void,
* deleteSnapshot?: (id: string) => void,
* listSnapshots?: () => ({ id: string, label: string, ts: number }[]),
* }} deps * }} deps
* *
* @returns {{ * @returns {{
@@ -22,7 +26,7 @@ import modalManager from '../modalManager.js';
import { HISTORY_ICON, relTime } from './layer-helpers.js'; import { HISTORY_ICON, relTime } from './layer-helpers.js';
import { historyPanelHTML } from './build/popups.js'; import { historyPanelHTML } from './build/popups.js';
export function createHistoryPanel({ undo, redo }) { export function createHistoryPanel({ undo, redo, takeSnapshot, restoreSnapshot, deleteSnapshot, listSnapshots }) {
function jumpToHistory(offset) { function jumpToHistory(offset) {
if (offset === 0) return; if (offset === 0) return;
if (offset < 0) { if (offset < 0) {
@@ -81,11 +85,27 @@ export function createHistoryPanel({ undo, redo }) {
panel.innerHTML = historyPanelHTML(HISTORY_ICON); panel.innerHTML = historyPanelHTML(HISTORY_ICON);
document.body.appendChild(panel); document.body.appendChild(panel);
state.historyPanelEl = panel; state.historyPanelEl = panel;
// Fill in the snapshot + timeline content FIRST so the panel has its real
// height before we measure it for on-screen clamping below.
refreshHistoryPanelIfOpen();
const btn = document.getElementById('ge-history-btn'); const btn = document.getElementById('ge-history-btn');
if (btn) { if (btn) {
const r = btn.getBoundingClientRect(); const r = btn.getBoundingClientRect();
panel.style.top = (r.bottom + 6) + 'px';
panel.style.left = Math.max(8, r.left) + 'px'; panel.style.left = Math.max(8, r.left) + 'px';
panel.style.top = (r.bottom + 6) + 'px';
// Anchor under the button, but if the panel would run off the bottom of
// the viewport (the History button lives in the footer) flip it above the
// button and clamp both edges so it is always fully visible.
const ph = panel.offsetHeight || 200;
const pw = panel.offsetWidth || 240;
let top = r.bottom + 6;
let left = Math.max(8, Math.min(r.left, window.innerWidth - pw - 8));
if (top + ph > window.innerHeight - 8) {
top = Math.max(8, r.top - ph - 6);
if (top < 8) top = Math.max(8, window.innerHeight - ph - 8);
}
panel.style.top = top + 'px';
panel.style.left = left + 'px';
} }
panel.querySelector('.ge-adj-min').addEventListener('click', minimiseHistoryPanel); panel.querySelector('.ge-adj-min').addEventListener('click', minimiseHistoryPanel);
// Click anywhere outside the panel (or trigger button) closes it. // Click anywhere outside the panel (or trigger button) closes it.
@@ -94,6 +114,10 @@ export function createHistoryPanel({ undo, redo }) {
if (!state.historyPanelEl) return; if (!state.historyPanelEl) return;
if (state.historyPanelEl.contains(ev.target)) return; if (state.historyPanelEl.contains(ev.target)) return;
if (btn && (ev.target === btn || btn.contains(ev.target))) return; if (btn && (ev.target === btn || btn.contains(ev.target))) return;
// A click inside a topbar dropdown (Window/Edit > History) is an
// explicit toggle, not an "away" click — don't close on its
// pointerdown or the row's click would immediately reopen it.
if (ev.target.closest && ev.target.closest('.dropdown')) return;
closeHistoryPanel(); closeHistoryPanel();
document.removeEventListener('pointerdown', onAway, true); document.removeEventListener('pointerdown', onAway, true);
}; };
@@ -134,16 +158,13 @@ export function createHistoryPanel({ undo, redo }) {
}; };
document.addEventListener('keydown', onKey, true); document.addEventListener('keydown', onKey, true);
panel._escHandler = onKey; panel._escHandler = onKey;
refreshHistoryPanelIfOpen();
} }
function refreshHistoryPanelIfOpen() { // Render the chronological timeline (oldest/past -> Current -> future) into
if (!state.historyPanelEl) return; // any list element. Shared by the floating panel and the docked History
const list = state.historyPanelEl.querySelector('#ge-history-list'); // panel, matching Photoshop's docked History timeline.
function renderHistoryList(list) {
if (!list) return; if (!list) return;
// Chronological order — oldest at top, latest at bottom. Past
// (undo) states first, then Current, then future (redo) states.
const rows = []; const rows = [];
for (let i = 0; i < state.undoStack.length; i++) { for (let i = 0; i < state.undoStack.length; i++) {
const s = state.undoStack[i]; const s = state.undoStack[i];
@@ -167,10 +188,85 @@ export function createHistoryPanel({ undo, redo }) {
jumpToHistory(off); jumpToHistory(off);
}); });
}); });
// Scroll the current marker into view. // Reveal the current marker without disturbing the surrounding chrome.
// Element.scrollIntoView() bubbles up and scrolls EVERY scrollable
// ancestor, which yanked the whole right-hand dock down to the History
// group on load (hiding the Color/Properties tab strips). Instead scroll
// only the list itself, or the dock group's own panel when the list is
// not the scroller — so the docked History auto-reveals the current step
// while the tab strips above stay put.
const cur = list.querySelector('.current'); const cur = list.querySelector('.current');
if (cur) cur.scrollIntoView({ block: 'center' }); if (cur) {
const scroller = list.scrollHeight > list.clientHeight + 1
? list
: list.closest('.ge-dock-panels');
if (scroller) {
const c = cur.getBoundingClientRect();
const s = scroller.getBoundingClientRect();
if (c.bottom > s.bottom) scroller.scrollTop += (c.bottom - s.bottom) + 8;
else if (c.top < s.top) scroller.scrollTop -= (s.top - c.top) + 8;
}
}
} }
return { toggleHistoryPanel, refreshHistoryPanelIfOpen, jumpToHistory }; // Named snapshots (Photoshop-style restore points) section — a camera
// button plus one row per snapshot. Rendered above the timeline in both
// the floating panel and the docked History panel.
const SNAPSHOT_ICON = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"></path><circle cx="12" cy="13" r="4"></circle></svg>';
function renderSnapshots(container) {
if (!container) return;
const snaps = (typeof listSnapshots === 'function') ? (listSnapshots() || []) : [];
const rows = snaps.map(s => `
<div class="ge-history-snap-row" data-snap-id="${s.id}">
<button class="ge-history-snap-restore" type="button" data-snap-id="${s.id}" title="Restore this snapshot">
<span class="ge-history-snap-dot"></span>
<span class="ge-history-row-label">${(s.label || 'Snapshot').replace(/[<>&]/g, '')}</span>
</button>
<span class="ge-history-row-time">${relTime(s.ts)}</span>
<button class="ge-history-snap-del" type="button" data-snap-id="${s.id}" title="Delete snapshot" aria-label="Delete snapshot">&times;</button>
</div>`).join('');
container.innerHTML = `
<div class="ge-history-snap-head">
<span class="ge-history-snap-title">Snapshots</span>
<button class="ge-history-snap-new" type="button" title="Create a new snapshot">${SNAPSHOT_ICON}</button>
</div>
<div class="ge-history-snap-rows">${rows || '<div class="ge-history-snap-empty">No snapshots yet</div>'}</div>
`;
container.querySelector('.ge-history-snap-new')?.addEventListener('click', () => {
if (typeof takeSnapshot !== 'function') return;
const n = ((typeof listSnapshots === 'function' && listSnapshots()) || []).length + 1;
const label = window.prompt('Snapshot name', 'Snapshot ' + n);
if (label == null) return;
takeSnapshot(label.trim() || ('Snapshot ' + n));
});
container.querySelectorAll('.ge-history-snap-restore').forEach(btn => {
btn.addEventListener('click', () => { if (typeof restoreSnapshot === 'function') restoreSnapshot(btn.dataset.snapId); });
});
container.querySelectorAll('.ge-history-snap-del').forEach(btn => {
btn.addEventListener('click', () => { if (typeof deleteSnapshot === 'function') deleteSnapshot(btn.dataset.snapId); });
});
}
// Refresh the floating panel (if open) and the docked History panel (if
// present) — every undo/redo/edit already calls this.
function refreshHistoryPanelIfOpen() {
if (state.historyPanelEl) {
renderSnapshots(state.historyPanelEl.querySelector('#ge-history-snapshots'));
renderHistoryList(state.historyPanelEl.querySelector('#ge-history-list'));
}
const dockList = document.getElementById('ge-dock-history-list');
if (dockList) {
let dockSnaps = document.getElementById('ge-dock-history-snapshots');
if (!dockSnaps) {
dockSnaps = document.createElement('div');
dockSnaps.id = 'ge-dock-history-snapshots';
dockSnaps.className = 'ge-history-snapshots';
dockList.parentNode?.insertBefore(dockSnaps, dockList);
}
renderSnapshots(dockSnaps);
renderHistoryList(dockList);
}
}
return { toggleHistoryPanel, refreshHistoryPanelIfOpen, renderHistoryList, renderSnapshots, jumpToHistory };
} }
+89 -5
View File
@@ -11,7 +11,7 @@
* Ctrl+D deselect (clears wand + lasso) * Ctrl+D deselect (clears wand + lasso)
* Ctrl+S save (Shift = save as / export to gallery) * Ctrl+S save (Shift = save as / export to gallery)
* Ctrl+Shift+T open resize popup * Ctrl+Shift+T open resize popup
* Ctrl+Alt+T start free transform * Ctrl+T start free transform (Ctrl+Alt+T also works)
* Ctrl+Alt+Shift+I invert selection (Ctrl+Alt+I also works) * Ctrl+Alt+Shift+I invert selection (Ctrl+Alt+I also works)
* Ctrl+Alt+J new empty layer * Ctrl+Alt+J new empty layer
* Ctrl/Cmd+J copy selected pixels to a layer, or duplicate the layer * Ctrl/Cmd+J copy selected pixels to a layer, or duplicate the layer
@@ -20,6 +20,7 @@
* Ctrl+C/X copy / cut the selected surface (image clipboard * Ctrl+C/X copy / cut the selected surface (image clipboard
* + internal clipboard, preserving its document offset) * + internal clipboard, preserving its document offset)
* Ctrl+V (handled by the paste event listener) * Ctrl+V (handled by the paste event listener)
* Ctrl+ / - / 0 zoom in / out / fit (overrides browser zoom)
* Tool keys (V, B, E, L, …) → toolbar click * Tool keys (V, B, E, L, …) → toolbar click
* Hold Space temporarily pan without changing the active tool * Hold Space temporarily pan without changing the active tool
* [ / ] shrink / grow brush size proportionally * [ / ] shrink / grow brush size proportionally
@@ -81,6 +82,12 @@ export function wireKeyboardShortcuts(deps) {
target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT' || target.isContentEditable target.tagName === 'SELECT' || target.isContentEditable
); );
// A focused range/checkbox/button widget is still an <input>, but it is not
// a text field: Delete/Backspace there should fall through to the editor
// (e.g. delete the selected layer) instead of being swallowed.
const isNonTextInput = (target) => target && target.tagName === 'INPUT' && (
!['text', 'search', 'email', 'url', 'password', 'number', 'tel'].includes(target.type)
);
const releaseTemporaryPan = () => setTemporaryPan?.(false); const releaseTemporaryPan = () => setTemporaryPan?.(false);
document.addEventListener('keyup', (e) => { document.addEventListener('keyup', (e) => {
@@ -93,7 +100,13 @@ export function wireKeyboardShortcuts(deps) {
if (!state.editorOpen || e.defaultPrevented || e.isComposing) return; if (!state.editorOpen || e.defaultPrevented || e.isComposing) return;
if (e.target?.closest?.('#styled-confirm-overlay')) return; if (e.target?.closest?.('#styled-confirm-overlay')) return;
// Fields and text layers own native editing, including undo and clipboard. // Fields and text layers own native editing, including undo and clipboard.
if (isTypingTarget(e.target)) return; // Let Delete/Backspace out of non-text widgets (range/checkbox/button) so
// they still delete the selected layer. Enter still confirms an active
// transform even from a transform field, so typing a value then hitting
// Enter accepts the transform instead of doing nothing.
if (isTypingTarget(e.target) &&
!(isNonTextInput(e.target) && (e.key === 'Delete' || e.key === 'Backspace')) &&
!(state.transformActive && e.key === 'Enter')) return;
if (e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey && e.key === 'Backspace') { if (e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey && e.key === 'Backspace') {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@@ -175,7 +188,15 @@ export function wireKeyboardShortcuts(deps) {
return; return;
} }
if (e.shiftKey && e.key === 'T') { e.preventDefault(); e.stopPropagation(); resizeCustomPrompt(); return; } if (e.shiftKey && e.key === 'T') { e.preventDefault(); e.stopPropagation(); resizeCustomPrompt(); return; }
if (e.altKey && e.code === 'KeyT') { e.preventDefault(); e.stopPropagation(); startTransform(); return; } // Ctrl/Cmd+N — new document. The File menu owns the size dialog, so
// click its row rather than duplicating the prompt here.
if (!e.shiftKey && !e.altKey && e.code === 'KeyN') {
const row = document.querySelector('[data-file-action="new-custom"]');
if (row) { e.preventDefault(); e.stopPropagation(); row.click(); return; }
}
// Ctrl/Cmd+T — free transform (Photoshop). Ctrl+Shift+T stays the canvas
// resize prompt and Ctrl+Alt+T remains a legacy alias.
if (!e.shiftKey && e.code === 'KeyT') { e.preventDefault(); e.stopPropagation(); startTransform(); return; }
// Ctrl+Alt+Shift+I (or the previous Ctrl+Alt+I) inverts the selection. Uses e.code so // Ctrl+Alt+Shift+I (or the previous Ctrl+Alt+I) inverts the selection. Uses e.code so
// Alt-modified key values (e.g. `ˆ` on Mac with Option+I) // Alt-modified key values (e.g. `ˆ` on Mac with Option+I)
// don't break the match. // don't break the match.
@@ -186,6 +207,13 @@ export function wireKeyboardShortcuts(deps) {
} }
return; return;
} }
// Ctrl+Alt+Shift+E — stamp visible: merge every visible layer into a new
// layer on top of the stack while keeping the originals (Photoshop).
// Flatten Copy in the layer footer already does exactly that, so click it.
if (e.altKey && e.shiftKey && e.code === 'KeyE') {
const stamp = document.getElementById('ge-flatten');
if (stamp) { e.preventDefault(); e.stopPropagation(); stamp.click(); return; }
}
// Ctrl+Alt+J — new empty layer. // Ctrl+Alt+J — new empty layer.
if (e.altKey && e.code === 'KeyJ') { if (e.altKey && e.code === 'KeyJ') {
e.preventDefault(); e.preventDefault();
@@ -193,6 +221,12 @@ export function wireKeyboardShortcuts(deps) {
addEmptyLayer(); addEmptyLayer();
return; return;
} }
// Ctrl+Alt+L — Levels adjustment. The Image menu owns the row and its
// dialog, so click it instead of duplicating the adjustment here.
if (e.altKey && e.code === 'KeyL') {
const row = document.querySelector('[data-image-action="adj-levels"]');
if (row) { e.preventDefault(); e.stopPropagation(); row.click(); return; }
}
// Ctrl/Cmd+J duplicates the active layer through the layer panel's // Ctrl/Cmd+J duplicates the active layer through the layer panel's
// existing implementation, which preserves masks and effects. // existing implementation, which preserves masks and effects.
if (!e.altKey && e.code === 'KeyJ') { if (!e.altKey && e.code === 'KeyJ') {
@@ -242,10 +276,30 @@ export function wireKeyboardShortcuts(deps) {
} }
// Ctrl+V handled by the paste event listener. // Ctrl+V handled by the paste event listener.
if (e.key === 'v') { /* no-op here */ } if (e.key === 'v') { /* no-op here */ }
// Ctrl/Cmd +/- zoom the canvas in/out and Ctrl+0 fits — overriding the
// browser's page zoom (we own the shortcut inside the editor).
if (e.key === '+' || e.key === '=' || e.code === 'Equal' || e.code === 'NumpadAdd') {
e.preventDefault();
e.stopPropagation();
document.getElementById('ge-zoom-in')?.click();
return;
}
if (e.key === '-' || e.key === '_' || e.code === 'Minus' || e.code === 'NumpadSubtract') {
e.preventDefault();
e.stopPropagation();
document.getElementById('ge-zoom-out')?.click();
return;
}
if (e.key === '0' || e.code === 'Digit0' || e.code === 'Numpad0') {
e.preventDefault();
e.stopPropagation();
document.getElementById('ge-zoom-100')?.click();
return;
}
return; return;
} }
// Tool shortcuts (only when not typing in an input). // Tool shortcuts (only when not typing in an input).
if (isTypingTarget(e.target)) return; if (isTypingTarget(e.target) && !isNonTextInput(e.target)) return;
// Delete pixels for a selection, otherwise delete the selected layer(s). // Delete pixels for a selection, otherwise delete the selected layer(s).
// Clipboard shortcuts above retain ownership of Ctrl/Cmd+X and C. // Clipboard shortcuts above retain ownership of Ctrl/Cmd+X and C.
@@ -282,11 +336,41 @@ export function wireKeyboardShortcuts(deps) {
toggleQuickMask?.(); toggleQuickMask?.();
return; return;
} }
// Photoshop-compatible colour keys (tool shortcuts use neither): X swaps
// the foreground/background colours, D resets them to black/white.
if (!e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) {
const colorKey = e.key.toLowerCase();
if (colorKey === 'x') {
e.preventDefault();
e.stopPropagation();
window.geSwapColors?.();
return;
}
if (colorKey === 'd') {
e.preventDefault();
e.stopPropagation();
window.geResetColors?.();
return;
}
}
const toolId = toolKeyMap[e.key.toLowerCase()]; const toolId = toolKeyMap[e.key.toLowerCase()];
if (toolId) { if (toolId) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const toolBtn = toolbar.querySelector(`[data-tool="${toolId}"]`); // A slot can host several tools (gradient + paint bucket, brush + eraser
// + pixel). The main key re-activates whatever member the slot is
// currently showing, so picking Paint Bucket and then tapping G keeps
// Paint Bucket instead of snapping back to the default. A key for a
// secondary member (e.g. E for eraser) still picks that exact tool.
const flyoutItem = toolbar.querySelector(`[data-tool-option="${toolId}"]`);
const slotEl = flyoutItem ? flyoutItem.closest('.ge-tool-slot') : null;
let toolBtn = null;
if (slotEl) {
const firstMember = slotEl.querySelector('.ge-tool-flyout-item[data-tool-option]');
const defaultTool = firstMember ? firstMember.dataset.toolOption : null;
if (toolId === defaultTool) toolBtn = slotEl.querySelector('.ge-tool-slot-face');
}
toolBtn = toolBtn || toolbar.querySelector(`[data-tool="${toolId}"]`) || flyoutItem;
if (toolBtn) toolBtn.click(); if (toolBtn) toolBtn.click();
return; return;
} }
+9 -13
View File
@@ -1,5 +1,7 @@
/** Alpha clipping for flat layer stacks and isolated groups. */ /** Alpha clipping for flat layer stacks and isolated groups. */
import { blendDraw } from './blend-modes.js';
function scopeByLayer(editorState) { function scopeByLayer(editorState) {
const scopes = new Map(); const scopes = new Map();
for (const group of editorState.layerGroups || []) { for (const group of editorState.layerGroups || []) {
@@ -18,7 +20,9 @@ export function clippingBaseForLayer(editorState, layerId) {
for (let cursor = index - 1; cursor >= 0; cursor -= 1) { for (let cursor = index - 1; cursor >= 0; cursor -= 1) {
const candidate = layers[cursor]; const candidate = layers[cursor];
if ((scopes.get(candidate.id) || null) !== scope) return null; if ((scopes.get(candidate.id) || null) !== scope) return null;
if (candidate.kind === 'adjustment') return null; // Skip over adjacent adjustment layers so a stack of clipped adjustments
// all resolve to the same underlying pixel layer.
if (candidate.kind === 'adjustment') continue;
if (!candidate.clipped) return candidate; if (!candidate.clipped) return candidate;
} }
return null; return null;
@@ -61,9 +65,7 @@ export function drawLayerStack(target, editorState, layers, renderLayer, renderA
const drawNormal = layer => { const drawNormal = layer => {
if (!layer.visible) return; if (!layer.visible) return;
const offset = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 }; const offset = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
target.globalAlpha = layer.opacity; blendDraw(target, renderLayer(layer), layer.blendMode, layer.opacity, offset.x, offset.y);
target.globalCompositeOperation = layer.blendMode || 'source-over';
target.drawImage(renderLayer(layer), offset.x, offset.y);
}; };
for (const layer of layers) { for (const layer of layers) {
if (layer.kind === 'adjustment' && renderAdjustment) { if (layer.kind === 'adjustment' && renderAdjustment) {
@@ -88,9 +90,7 @@ export function drawLayerStack(target, editorState, layers, renderLayer, renderA
const baseOffset = editorState.layerOffsets.get(base.id) || { x: 0, y: 0 }; const baseOffset = editorState.layerOffsets.get(base.id) || { x: 0, y: 0 };
scratchCtx.drawImage(renderLayer(base), baseOffset.x, baseOffset.y); scratchCtx.drawImage(renderLayer(base), baseOffset.x, baseOffset.y);
scratchCtx.globalCompositeOperation = 'source-over'; scratchCtx.globalCompositeOperation = 'source-over';
target.globalAlpha = layer.opacity; blendDraw(target, scratch, layer.blendMode, layer.opacity, 0, 0);
target.globalCompositeOperation = layer.blendMode || 'source-over';
target.drawImage(scratch, 0, 0);
} }
target.globalAlpha = 1; target.globalAlpha = 1;
target.globalCompositeOperation = 'source-over'; target.globalCompositeOperation = 'source-over';
@@ -102,11 +102,9 @@ export async function drawLayerStackAsync(target, editorState, layers, renderLay
const drawNormal = async layer => { const drawNormal = async layer => {
if (!layer.visible || !shouldContinue()) return; if (!layer.visible || !shouldContinue()) return;
const offset = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 }; const offset = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
target.globalAlpha = layer.opacity;
target.globalCompositeOperation = layer.blendMode || 'source-over';
const rendered = await renderLayer(layer); const rendered = await renderLayer(layer);
if (!shouldContinue() || !rendered) return; if (!shouldContinue() || !rendered) return;
target.drawImage(rendered, offset.x, offset.y); blendDraw(target, rendered, layer.blendMode, layer.opacity, offset.x, offset.y);
}; };
for (const layer of layers) { for (const layer of layers) {
if (!shouldContinue()) return; if (!shouldContinue()) return;
@@ -135,9 +133,7 @@ export async function drawLayerStackAsync(target, editorState, layers, renderLay
const baseOffset = editorState.layerOffsets.get(base.id) || { x: 0, y: 0 }; const baseOffset = editorState.layerOffsets.get(base.id) || { x: 0, y: 0 };
scratchCtx.drawImage(renderedBase, baseOffset.x, baseOffset.y); scratchCtx.drawImage(renderedBase, baseOffset.x, baseOffset.y);
scratchCtx.globalCompositeOperation = 'source-over'; scratchCtx.globalCompositeOperation = 'source-over';
target.globalAlpha = layer.opacity; blendDraw(target, scratch, layer.blendMode, layer.opacity, 0, 0);
target.globalCompositeOperation = layer.blendMode || 'source-over';
target.drawImage(scratch, 0, 0);
} }
target.globalAlpha = 1; target.globalAlpha = 1;
target.globalCompositeOperation = 'source-over'; target.globalCompositeOperation = 'source-over';

Some files were not shown because too many files have changed in this diff Show More