mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-29 01:02:25 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4079f12ae6 | ||
|
|
091364f7e6 | ||
|
|
0133de6f0e | ||
|
|
339c6886cb | ||
|
|
25c2fad3e1 | ||
|
|
d105fbde95 |
@@ -119,19 +119,26 @@ Enabled automatically when the editor opens (`window.__geEditorBridge`).
|
||||
- The page runs the op via the control API and replies:
|
||||
`POST /api/editor/bridge/result {id,ok,result,error,screenshot,ms}`.
|
||||
- Enqueue a command (server side): `POST /api/editor/bridge/command {session, op|steps, wait, timeout}`.
|
||||
With `wait:true` the call blocks and returns the editor's result.
|
||||
With `wait:true` and one step the call blocks and returns the editor's result.
|
||||
Otherwise use `POST /api/editor/bridge/await {id, timeout}` for each queued id.
|
||||
Timeouts are seconds, greater than zero and at most 900. Pending results expire
|
||||
after the command timeout; an unknown or another owner's id returns 404.
|
||||
|
||||
Round-trip example (authenticated; `<session>` = `window.__geEditorBridge.session`):
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://127.0.0.1:7011/api/editor/bridge/command \
|
||||
-H 'Content-Type: application/json' -b "odysseus_session=$TOKEN" \
|
||||
-d '{"session":"default","op":"layers.addRaster","wait":true,"timeout":8000}'
|
||||
-d '{"session":"default","op":"layers.addRaster","wait":true,"timeout":8}'
|
||||
# -> {"id":"...","ok":true,"result":{...},"screenshot":"data:image/png;base64,..."}
|
||||
```
|
||||
|
||||
Session scoping is `owner:session`, so an agent can only drive editors owned by the
|
||||
same user. The bridge is a **browser-only** flow: it requires an editor page to be open.
|
||||
Session scoping uses the authenticated owner and session as separate values. A
|
||||
bound editor-tool turn uses its server-owned session, not a model-selected one.
|
||||
The bridge is a **browser-only** flow: it requires an editor page to be open.
|
||||
For `/api/editor/agent`, clients must send `incognito: true` for private image
|
||||
results; omitted or false requests use normal gallery persistence. These
|
||||
owner-scoped routes do not accept delegated API tokens without a scope contract.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,255 +0,0 @@
|
||||
# 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) {
|
||||
body.querySelectorAll('.note-card').forEach(card => {
|
||||
card.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.note-card-cb, a')) return; // links open without toggling selection
|
||||
if (e.target.closest('.note-card-cb')) return; // checkbox handles itself
|
||||
e.stopPropagation();
|
||||
tapToEditOrSelect(card);
|
||||
});
|
||||
|
||||
@@ -51,7 +51,7 @@ export function createEditor({
|
||||
<input type="hidden" class="note-form-due" value="${note?.due_date || ''}" />
|
||||
<input type="hidden" class="note-form-repeat" value="${note?.repeat || 'none'}" />
|
||||
</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">×</button></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">×</button></div>` : ''}
|
||||
<div class="note-form-body">
|
||||
${type === 'note'
|
||||
? `<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();
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'note-form-image-wrap';
|
||||
wrap.innerHTML = `<img class="note-form-image" alt="" draggable="false" /><button class="note-form-image-rm" title="Remove" aria-label="Remove">×</button>`;
|
||||
wrap.innerHTML = `<img class="note-form-image" draggable="false" /><button class="note-form-image-rm" title="Remove">×</button>`;
|
||||
// Insert AFTER the whole header (a flex-row), not after the
|
||||
// title input itself : otherwise the image lands as a sibling
|
||||
// of the title inside the header and flex puts them side-by-side.
|
||||
|
||||
@@ -31,7 +31,7 @@ export function createText({
|
||||
url = url.slice(0, -1);
|
||||
}
|
||||
const href = url.startsWith('www.') ? `https://${url}` : url;
|
||||
return `<a href="${_attrEsc(href)}" class="note-link" target="_blank" rel="noopener noreferrer">${url}</a>` + (url !== m ? m.slice(url.length) : '');
|
||||
return `<a href="${_attrEsc(href)}" class="note-link" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">${url}</a>` + (url !== m ? m.slice(url.length) : '');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+89
-21
@@ -569,6 +569,16 @@ async def auto_name_session(session_manager, sess):
|
||||
if not first_msg:
|
||||
return
|
||||
|
||||
from src.owner_identity import is_request_sentinel_owner
|
||||
owner = getattr(sess, "owner", None)
|
||||
invalid_owner = (
|
||||
not isinstance(owner, str) or not owner.strip()
|
||||
or owner != owner.strip() or is_request_sentinel_owner(owner)
|
||||
)
|
||||
if invalid_owner:
|
||||
session_manager.update_session_name(sess.id, fallback_session_title(first_msg))
|
||||
return
|
||||
|
||||
endpoint_url = str(getattr(sess, "endpoint_url", "") or "")
|
||||
model_name = str(getattr(sess, "model", "") or "")
|
||||
|
||||
@@ -583,15 +593,25 @@ async def auto_name_session(session_manager, sess):
|
||||
except Exception:
|
||||
_is_spec_model = False
|
||||
if _is_spec_model and endpoint_url:
|
||||
_headers = getattr(sess, "headers", None)
|
||||
if isinstance(_headers, str):
|
||||
try:
|
||||
_headers = json.loads(_headers)
|
||||
except Exception:
|
||||
_headers = None
|
||||
try:
|
||||
from src.database import SessionLocal
|
||||
from src.endpoint_resolver import (build_chat_url, build_headers,
|
||||
resolve_endpoint_runtime, resolve_owner_registered_endpoint)
|
||||
from src.owner_identity import auth_disabled
|
||||
|
||||
owner = getattr(sess, "owner", None)
|
||||
if not owner and not auth_disabled():
|
||||
raise ValueError("Session has no owner")
|
||||
db = SessionLocal()
|
||||
try:
|
||||
endpoint = resolve_owner_registered_endpoint(db, endpoint_url, owner)
|
||||
base, key = resolve_endpoint_runtime(endpoint, owner=owner)
|
||||
title_url = build_chat_url(base)
|
||||
title_headers = build_headers(key, base)
|
||||
finally:
|
||||
db.close()
|
||||
title = await llm_call_async(
|
||||
endpoint_url,
|
||||
title_url,
|
||||
model_name,
|
||||
[
|
||||
{"role": "system", "content": "Generate a short title (3-6 words, no quotes) for a conversation that starts with this message. Reply with ONLY the title, nothing else. Do NOT include any thinking, reasoning, or explanation \u2014 just the title."},
|
||||
@@ -599,7 +619,7 @@ async def auto_name_session(session_manager, sess):
|
||||
],
|
||||
temperature=0.3,
|
||||
max_tokens=32,
|
||||
headers=_headers or None,
|
||||
headers=title_headers,
|
||||
timeout=20,
|
||||
thinking_mode="off",
|
||||
)
|
||||
@@ -625,6 +645,14 @@ async def auto_name_session(session_manager, sess):
|
||||
return
|
||||
|
||||
owner = getattr(sess, "owner", None)
|
||||
from src.owner_identity import auth_disabled
|
||||
if not owner and not auth_disabled():
|
||||
title = fallback_session_title(first_msg)
|
||||
session_manager.update_session_name(sess.id, title)
|
||||
logger.info(f"Auto-named unowned session {sess.id} deterministically: {title}")
|
||||
return
|
||||
|
||||
from src.task_endpoint import resolve_task_endpoint
|
||||
t_url, t_model, t_headers = resolve_task_endpoint(
|
||||
sess.endpoint_url, sess.model, sess.headers, owner=owner
|
||||
)
|
||||
@@ -632,24 +660,64 @@ async def auto_name_session(session_manager, sess):
|
||||
logger.debug("[auto-name] No model provided, skipping")
|
||||
return
|
||||
|
||||
sess_url = getattr(sess, "endpoint_url", None)
|
||||
if t_url and sess_url and t_url == sess_url:
|
||||
from src.database import SessionLocal
|
||||
from src.endpoint_resolver import (
|
||||
build_chat_url,
|
||||
build_headers,
|
||||
resolve_endpoint,
|
||||
resolve_endpoint_runtime,
|
||||
resolve_owner_registered_endpoint,
|
||||
)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
endpoint = resolve_owner_registered_endpoint(db, t_url, owner)
|
||||
base, key = resolve_endpoint_runtime(endpoint, owner=owner)
|
||||
t_url = build_chat_url(base)
|
||||
t_headers = build_headers(key, base)
|
||||
except Exception as e:
|
||||
logger.debug("[auto-name] session fallback endpoint %s unauthorized for %s: %s", t_url, owner, e)
|
||||
t_url, t_model, t_headers = None, None, None
|
||||
for prefix in ("utility", "default"):
|
||||
try:
|
||||
u, m, h = resolve_endpoint(prefix, owner=owner)
|
||||
if u and m:
|
||||
t_url, t_model, t_headers = u, m, h
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if not t_url or not t_model:
|
||||
title = fallback_session_title(first_msg)
|
||||
session_manager.update_session_name(sess.id, title)
|
||||
logger.info(f"Auto-named session {sess.id} deterministically (no authorized model endpoint): {title}")
|
||||
return
|
||||
|
||||
# max_tokens big enough that reasoning models (Minimax M2,
|
||||
# DeepSeek R1, QwQ, etc.) have headroom for <think>…</think>
|
||||
# plus the actual title — 200 used to clip them mid-reasoning
|
||||
# so strip_think left an empty string and no rename happened.
|
||||
# Timeout matches: 60s gives slow local reasoners room to finish.
|
||||
title = await llm_call_async(
|
||||
t_url,
|
||||
t_model,
|
||||
[
|
||||
{"role": "system", "content": "Generate a short title (3-6 words, no quotes) for a conversation that starts with this message. Reply with ONLY the title, nothing else. Do NOT include any thinking, reasoning, or explanation — just the title."},
|
||||
{"role": "user", "content": first_msg},
|
||||
],
|
||||
temperature=0.3,
|
||||
max_tokens=64,
|
||||
headers=t_headers,
|
||||
timeout=15,
|
||||
reasoning_effort=effort_for_call(t_url, t_model, owner, sess),
|
||||
)
|
||||
try:
|
||||
title = await llm_call_async(
|
||||
t_url,
|
||||
t_model,
|
||||
[
|
||||
{"role": "system", "content": "Generate a short title (3-6 words, no quotes) for a conversation that starts with this message. Reply with ONLY the title, nothing else. Do NOT include any thinking, reasoning, or explanation — just the title."},
|
||||
{"role": "user", "content": first_msg},
|
||||
],
|
||||
temperature=0.3,
|
||||
max_tokens=64,
|
||||
headers=t_headers,
|
||||
timeout=15,
|
||||
reasoning_effort=effort_for_call(t_url, t_model, owner, sess),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("[auto-name] non-spec title call failed for %s: %s", sess.id, e)
|
||||
title = ""
|
||||
|
||||
title = title.strip().strip('"\'').strip()
|
||||
# Strip <think>/<thinking> blocks (closed, dangling, or stray tags)
|
||||
|
||||
@@ -331,6 +331,7 @@ def _is_external_discovery_request(text: str) -> bool:
|
||||
|
||||
_URL_SCHEME_RE = re.compile(r"https?://", re.I)
|
||||
_PDF_URL_TAIL_RE = re.compile(r"\.pdf\b|/pdf/", re.I)
|
||||
_EDITOR_SESSION_RE = re.compile(r"[A-Za-z0-9@._:-]{1,128}")
|
||||
|
||||
|
||||
def _mentions_pdf_url(value: str) -> bool:
|
||||
@@ -2693,11 +2694,15 @@ def setup_chat_routes(
|
||||
# Scoped photo_editor agent context: the Quick Edit popup (and any
|
||||
# image-edit chat turn) may carry the open image-editor session id and a
|
||||
# canvas snapshot so the editor tool family can drive it in-process.
|
||||
editor_session = (
|
||||
# The id is quoted into a system message, so accept only an
|
||||
# identifier; any other value is treated as no open editor.
|
||||
editor_session = str(
|
||||
form_data.get("editor_session")
|
||||
or form_data.get("editor_session_id")
|
||||
or ""
|
||||
).strip()
|
||||
if not _EDITOR_SESSION_RE.fullmatch(editor_session):
|
||||
editor_session = ""
|
||||
photo_editor_requested = (
|
||||
str(form_data.get("photo_editor") or "").strip().lower()
|
||||
in {"1", "true", "yes", "on"}
|
||||
@@ -3521,7 +3526,7 @@ def setup_chat_routes(
|
||||
if not _privs.get("can_use_documents", True):
|
||||
disabled_tools.update({"manage_documents", "create_document", "edit_document", "update_document", "suggest_document"})
|
||||
if not _privs.get("can_generate_images", True):
|
||||
disabled_tools.update({"generate_image", "edit_image"})
|
||||
disabled_tools.update({"generate_image", "edit_image", "ai_edit_image"})
|
||||
if not _privs.get("can_manage_memory", True):
|
||||
disabled_tools.update({"manage_memory", "manage_skills"})
|
||||
if not _privs.get("can_use_research", True):
|
||||
|
||||
+216
-84
@@ -21,11 +21,12 @@ import asyncio
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
import uuid
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
from src import editor_style_guide as style_guide
|
||||
@@ -1169,16 +1170,66 @@ def _normalize_steps(body: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
op = str(step.get("op") or "")
|
||||
if op not in _OP_SET:
|
||||
continue
|
||||
out.append({"op": op, "params": step.get("params") or {}})
|
||||
params = step.get("params")
|
||||
out.append({"op": op, "params": params if isinstance(params, dict) else {}})
|
||||
return out
|
||||
|
||||
|
||||
def _user(request: Request) -> Optional[str]:
|
||||
async def _json_object(request: Request) -> Dict[str, Any]:
|
||||
try:
|
||||
from src.auth_helpers import get_current_user
|
||||
return get_current_user(request)
|
||||
body = await request.json()
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
raise HTTPException(400, "Expected a JSON object")
|
||||
if not isinstance(body, dict):
|
||||
raise HTTPException(400, "Expected a JSON object")
|
||||
return body
|
||||
|
||||
|
||||
def _prompt(value: Any) -> str:
|
||||
text = str(value or "").strip()
|
||||
if len(text) > 4096:
|
||||
raise HTTPException(413, "Editor prompt is too long")
|
||||
return text
|
||||
|
||||
|
||||
def _bridge_timeout(value: Any) -> float:
|
||||
try:
|
||||
timeout = float(value or _BRIDGE_DEFAULT_TIMEOUT)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
raise HTTPException(400, "Invalid bridge timeout")
|
||||
if not math.isfinite(timeout) or timeout <= 0 or timeout > 900:
|
||||
raise HTTPException(400, "Invalid bridge timeout")
|
||||
return timeout
|
||||
|
||||
|
||||
def _is_admin(request: Request) -> bool:
|
||||
try:
|
||||
from core.middleware import require_admin
|
||||
require_admin(request)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _user(request: Request) -> Optional[str]:
|
||||
# These owner-scoped editor APIs have no delegated-token scope contract.
|
||||
# Do not collapse all bearer users into the shared "api" pseudo-owner.
|
||||
if getattr(request.state, "api_token", False):
|
||||
raise HTTPException(403, "API tokens must use a scope-aware API route")
|
||||
from src.auth_helpers import get_current_user, _auth_disabled
|
||||
u = get_current_user(request)
|
||||
if u:
|
||||
return u
|
||||
if _auth_disabled():
|
||||
return None
|
||||
auth_mgr = getattr(request.app.state, "auth_manager", None)
|
||||
if auth_mgr is not None and getattr(auth_mgr, "is_configured", False):
|
||||
raise HTTPException(401, "Not authenticated")
|
||||
client = getattr(request, "client", None)
|
||||
host = (client.host if client else "") or ""
|
||||
if host in ("127.0.0.1", "::1", "localhost", "testclient") or not host:
|
||||
return None
|
||||
raise HTTPException(401, "Not authenticated")
|
||||
|
||||
|
||||
async def _default_editor_planner(messages: List[dict], owner: Optional[str]) -> Optional[str]:
|
||||
@@ -1206,17 +1257,14 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
if _EDITOR_PLANNER is None:
|
||||
set_editor_planner(_default_editor_planner)
|
||||
|
||||
# Probe the local editor-agent model server (non-blocking) so the planner
|
||||
# can use it for vision planning when reachable.
|
||||
editor_agent_model.startup_probe()
|
||||
|
||||
# scoped session -> list of that session's live subscriber queues; id -> result future.
|
||||
# A command is BROADCAST to every live subscriber (per-connection queue), never to a
|
||||
# single shared queue: EventSource reconnects overlap, and with one shared queue a
|
||||
# command could be consumed by a stale subscriber while the active page got nothing.
|
||||
subscribers: Dict[str, List["asyncio.Queue[dict]"]] = {}
|
||||
backlog: Dict[str, List[dict]] = {}
|
||||
pending: Dict[str, asyncio.Future] = {}
|
||||
subscribers: Dict[Tuple[Optional[str], str], List["asyncio.Queue[dict]"]] = {}
|
||||
backlog: Dict[Tuple[Optional[str], str], List[dict]] = {}
|
||||
pending: Dict[str, Tuple[Optional[str], asyncio.Future]] = {}
|
||||
max_pending = 1024
|
||||
|
||||
def _subscribe(session: str) -> "asyncio.Queue[dict]":
|
||||
q: "asyncio.Queue[dict]" = asyncio.Queue(maxsize=_QUEUE_MAX)
|
||||
@@ -1253,17 +1301,30 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
del bl[0: len(bl) - _QUEUE_MAX]
|
||||
return 0
|
||||
|
||||
def _scoped(session: Optional[str], owner: Optional[str]) -> str:
|
||||
return f"{owner or 'anon'}:{session or 'default'}"
|
||||
def _expire_command(session, cid, fut):
|
||||
entry = pending.get(cid)
|
||||
if entry is not None and entry[1] is fut:
|
||||
pending.pop(cid, None)
|
||||
if not fut.done():
|
||||
fut.cancel()
|
||||
queued = backlog.get(session)
|
||||
if queued:
|
||||
backlog[session] = [cmd for cmd in queued if cmd.get("id") != cid]
|
||||
if not backlog[session]:
|
||||
backlog.pop(session, None)
|
||||
|
||||
def _scoped(session: Optional[str], owner: Optional[str]) -> Tuple[Optional[str], str]:
|
||||
# Usernames and session ids may both contain colons.
|
||||
return (owner, str(session or 'default'))
|
||||
|
||||
def _scope_label(scope: Tuple[Optional[str], str]) -> str:
|
||||
return f"{scope[0] or 'anon'}:{scope[1]}"
|
||||
|
||||
@router.post("/api/editor/plan")
|
||||
async def plan(request: Request) -> JSONResponse:
|
||||
owner = _user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
prompt = str(body.get("prompt") or "").strip()
|
||||
body = await _json_object(request)
|
||||
prompt = _prompt(body.get("prompt"))
|
||||
state = body.get("state") or {}
|
||||
mode = str(body.get("mode") or "plan").strip().lower()
|
||||
screenshot = body.get("screenshot")
|
||||
@@ -1363,31 +1424,49 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
@router.post("/api/editor/bridge/command")
|
||||
async def bridge_command(request: Request) -> JSONResponse:
|
||||
owner = _user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
body = await _json_object(request)
|
||||
session = _scoped(body.get("session"), owner)
|
||||
steps = _normalize_steps(body)
|
||||
wait = bool(body.get("wait"))
|
||||
timeout = float(body.get("timeout") or _BRIDGE_DEFAULT_TIMEOUT)
|
||||
timeout = _bridge_timeout(body.get("timeout"))
|
||||
ids: List[str] = []
|
||||
wait_ids: List[str] = []
|
||||
if len(pending) + len(steps) > max_pending:
|
||||
raise HTTPException(429, "Too many pending editor commands")
|
||||
for step in steps:
|
||||
cid = uuid.uuid4().hex
|
||||
ids.append(cid)
|
||||
if wait:
|
||||
pending[cid] = asyncio.get_event_loop().create_future()
|
||||
wait_ids.append(cid)
|
||||
fut = asyncio.get_event_loop().create_future()
|
||||
pending[cid] = (owner, fut)
|
||||
_broadcast(session, {"id": cid, "op": step["op"], "params": step["params"]})
|
||||
if wait and len(wait_ids) == 1:
|
||||
|
||||
if wait and len(steps) == 1:
|
||||
cid = ids[0]
|
||||
entry = pending.get(cid)
|
||||
fut = entry[1]
|
||||
try:
|
||||
result = await asyncio.wait_for(pending[wait_ids[0]], timeout=timeout)
|
||||
return JSONResponse(result)
|
||||
res = await asyncio.wait_for(fut, timeout=timeout)
|
||||
if isinstance(res, dict):
|
||||
res.setdefault("status", "completed" if res.get("ok") else "failed")
|
||||
res.setdefault("completed", bool(res.get("ok")))
|
||||
return JSONResponse(res)
|
||||
return JSONResponse({"id": cid, "ok": True, "status": "completed", "completed": True, "result": res})
|
||||
except asyncio.TimeoutError:
|
||||
pending.pop(wait_ids[0], None)
|
||||
return JSONResponse({"ok": False, "error": "timeout", "id": wait_ids[0]})
|
||||
return JSONResponse({"queued": ids, "session": session})
|
||||
return JSONResponse({"ok": False, "error": "timeout", "status": "timed_out", "completed": False, "id": cid}, status_code=504)
|
||||
finally:
|
||||
_expire_command(session, cid, fut)
|
||||
else:
|
||||
for cid in ids:
|
||||
entry = pending.get(cid)
|
||||
if entry is not None:
|
||||
asyncio.get_running_loop().call_later(timeout, _expire_command, session, cid, entry[1])
|
||||
|
||||
return JSONResponse({
|
||||
"queued": ids,
|
||||
"session": _scope_label(session),
|
||||
"status": "queued",
|
||||
"completed": False,
|
||||
"ok": False,
|
||||
})
|
||||
|
||||
@router.get("/api/editor/bridge/events")
|
||||
async def bridge_events(request: Request, session: str = "default") -> StreamingResponse:
|
||||
@@ -1412,38 +1491,50 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
|
||||
@router.post("/api/editor/bridge/result")
|
||||
async def bridge_result(request: Request) -> JSONResponse:
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
owner = _user(request)
|
||||
body = await _json_object(request)
|
||||
cid = str(body.get("id") or "")
|
||||
fut = pending.pop(cid, None)
|
||||
entry = pending.get(cid)
|
||||
if entry is None:
|
||||
return JSONResponse({"ok": True})
|
||||
if entry[0] != owner:
|
||||
return JSONResponse({"ok": False, "error": "unknown command"}, status_code=404)
|
||||
_, fut = entry
|
||||
if fut is not None and not fut.done():
|
||||
is_ok = bool(body.get("ok"))
|
||||
fut.set_result({
|
||||
"id": cid, "ok": bool(body.get("ok")),
|
||||
"result": body.get("result"), "error": body.get("error"),
|
||||
"id": cid,
|
||||
"ok": is_ok,
|
||||
"status": "completed" if is_ok else "failed",
|
||||
"completed": is_ok,
|
||||
"result": body.get("result"),
|
||||
"error": body.get("error"),
|
||||
"screenshot": body.get("screenshot"),
|
||||
})
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
@router.post("/api/editor/bridge/await")
|
||||
async def bridge_await(request: Request) -> JSONResponse:
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
owner = _user(request)
|
||||
body = await _json_object(request)
|
||||
cid = str(body.get("id") or "")
|
||||
timeout = float(body.get("timeout") or _BRIDGE_DEFAULT_TIMEOUT)
|
||||
fut = pending.get(cid)
|
||||
if fut is None:
|
||||
fut = asyncio.get_event_loop().create_future()
|
||||
pending[cid] = fut
|
||||
timeout = _bridge_timeout(body.get("timeout"))
|
||||
entry = pending.get(cid)
|
||||
if entry is None or entry[0] != owner:
|
||||
return JSONResponse({"ok": False, "error": "unknown command", "status": "unknown", "completed": False}, status_code=404)
|
||||
fut = entry[1]
|
||||
try:
|
||||
result = await asyncio.wait_for(fut, timeout=timeout)
|
||||
return JSONResponse(result)
|
||||
result = await asyncio.wait_for(asyncio.shield(fut), timeout=timeout)
|
||||
if pending.get(cid) == (owner, fut):
|
||||
pending.pop(cid, None)
|
||||
res = dict(result) if isinstance(result, dict) else {"result": result, "ok": True}
|
||||
res.setdefault("status", "completed" if res.get("ok") else "failed")
|
||||
res["completed"] = bool(res.get("ok"))
|
||||
return JSONResponse(res)
|
||||
except asyncio.TimeoutError:
|
||||
pending.pop(cid, None)
|
||||
return JSONResponse({"ok": False, "error": "timeout", "id": cid})
|
||||
return JSONResponse({"ok": False, "error": "timeout", "status": "timed_out", "completed": False, "id": cid})
|
||||
except asyncio.CancelledError:
|
||||
return JSONResponse({"ok": False, "error": "cancelled", "status": "cancelled", "completed": False, "id": cid})
|
||||
|
||||
_AGENT_SYSTEM_PROMPT = (
|
||||
"You are the photo editor agent. You drive the open image editor and "
|
||||
@@ -1494,11 +1585,14 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
overwritten.
|
||||
"""
|
||||
owner = _user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
prompt = str(body.get("prompt") or "").strip()
|
||||
body = await _json_object(request)
|
||||
from src.settings import get_setting
|
||||
disabled = get_setting("disabled_tools", []) or []
|
||||
if not isinstance(disabled, (list, tuple, set)):
|
||||
disabled = []
|
||||
if "plan_edit" in disabled:
|
||||
raise HTTPException(403, "Editor tool is disabled")
|
||||
prompt = _prompt(body.get("prompt"))
|
||||
session = str(body.get("editor_session") or body.get("session") or "default")
|
||||
state = body.get("state") if isinstance(body.get("state"), dict) else {}
|
||||
screenshot = body.get("screenshot")
|
||||
@@ -1506,10 +1600,10 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
model_choice = str(body.get("model") or body.get("image_model") or "").strip()
|
||||
# Optional user-picked PLANNER model (the editor agent/planner model).
|
||||
agent_model = str(body.get("agent_model") or "").strip()
|
||||
tools_offered = _scoped_editor_tools()
|
||||
tools_offered = [tool for tool in _scoped_editor_tools() if tool not in disabled]
|
||||
scoped = _scoped(session, owner)
|
||||
trace: List[Dict[str, Any]] = [
|
||||
{"type": "session", "session": scoped, "tools_offered": tools_offered},
|
||||
{"type": "session", "session": _scope_label(scoped), "tools_offered": tools_offered},
|
||||
]
|
||||
if not prompt:
|
||||
return JSONResponse({
|
||||
@@ -1601,8 +1695,19 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
"results": [],
|
||||
"agent_model": model_used,
|
||||
})
|
||||
# This route dispatches tools directly, outside execute_tool_block.
|
||||
# Apply the same administrator tool switches before any step runs.
|
||||
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")
|
||||
# No image provider: stop before any edit runs and tell the user plainly
|
||||
# (zero canvas changes; nothing to roll back).
|
||||
if _plan_uses_ai_content(steps):
|
||||
from src.auth_helpers import require_privilege
|
||||
require_privilege(request, "can_generate_images")
|
||||
if (kind or _plan_uses_ai_content(steps)) and not provider_available:
|
||||
trace.append({"type": "blocked", "reason": "image_provider_unavailable"})
|
||||
return JSONResponse({
|
||||
@@ -1629,24 +1734,30 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
results.append({"op": op, "ok": False, "error": "editor tools unavailable"})
|
||||
break
|
||||
tool_payload = dict(params)
|
||||
tool_payload.setdefault("session", session)
|
||||
# Planner output is untrusted: it cannot redirect this turn to
|
||||
# another editor session or override an explicit model choice.
|
||||
tool_payload["session"] = session
|
||||
# Route to the user-picked model when one is selected.
|
||||
if model_choice:
|
||||
tool_payload.setdefault("model", model_choice)
|
||||
tool_payload["model"] = model_choice
|
||||
# Hand the AI step the canvas image the client already captured
|
||||
# for this request. Without it the tool falls back to a separate
|
||||
# bridge screenshot, which is not reliably answered right after a
|
||||
# selection step (the masked path was losing the canvas image).
|
||||
if isinstance(screenshot, str) and screenshot.startswith("data:image/"):
|
||||
tool_payload.setdefault("image", screenshot)
|
||||
tool_payload["image"] = screenshot
|
||||
else:
|
||||
tool_payload.pop("image", None)
|
||||
tool_cls = AiEditImageTool if op == "ai_edit_image" else GenerateImageLayerTool
|
||||
try:
|
||||
_desc, res = await tool_cls().execute(
|
||||
json.dumps(tool_payload),
|
||||
{"owner": owner, "session_id": scoped},
|
||||
{"owner": owner, "session_id": _scope_label(scoped), "editor_session": session,
|
||||
"incognito": body.get("incognito") is True},
|
||||
)
|
||||
except Exception as exc:
|
||||
res = {"error": str(exc), "exit_code": 1}
|
||||
except Exception:
|
||||
logger.exception("Editor image operation failed")
|
||||
res = {"error": "Editor image operation failed", "exit_code": 1}
|
||||
res = res if isinstance(res, dict) else {}
|
||||
ok = res.get("exit_code", 0) == 0
|
||||
step_result = {
|
||||
@@ -1664,15 +1775,17 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
break
|
||||
continue
|
||||
cid = uuid.uuid4().hex
|
||||
pending[cid] = asyncio.get_event_loop().create_future()
|
||||
fut = asyncio.get_event_loop().create_future()
|
||||
pending[cid] = (owner, fut)
|
||||
_broadcast(scoped, {"id": cid, "op": op, "params": params})
|
||||
try:
|
||||
res = await asyncio.wait_for(pending[cid], timeout=_BRIDGE_DEFAULT_TIMEOUT)
|
||||
res = await asyncio.wait_for(pending[cid][1], timeout=_BRIDGE_DEFAULT_TIMEOUT)
|
||||
except asyncio.TimeoutError:
|
||||
pending.pop(cid, None)
|
||||
results.append({"op": op, "ok": False, "error": "timeout"})
|
||||
trace.append({"type": "step", "op": op, "ok": False, "error": "timeout"})
|
||||
break
|
||||
finally:
|
||||
_expire_command(scoped, cid, fut)
|
||||
results.append(res)
|
||||
trace.append({"type": "step", "op": op, "ok": res.get("ok"), "result": res.get("result")})
|
||||
if res.get("ok") is False:
|
||||
@@ -1683,13 +1796,16 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
if steps and not any(r.get("ok") is False for r in results):
|
||||
try:
|
||||
_info_id = uuid.uuid4().hex
|
||||
pending[_info_id] = asyncio.get_event_loop().create_future()
|
||||
_info_fut = asyncio.get_event_loop().create_future()
|
||||
pending[_info_id] = (owner, _info_fut)
|
||||
_broadcast(scoped, {"id": _info_id, "op": "document.info", "params": {}})
|
||||
_info = await asyncio.wait_for(pending[_info_id], timeout=60)
|
||||
_info = await asyncio.wait_for(pending[_info_id][1], timeout=60)
|
||||
if isinstance(_info, dict) and isinstance(_info.get("result"), dict):
|
||||
final_state = _info["result"]
|
||||
except Exception:
|
||||
final_state = {}
|
||||
finally:
|
||||
_expire_command(scoped, _info_id, _info_fut)
|
||||
planned_reply = reply
|
||||
reply = _verified_reply(prompt, steps, results, final_state, planned_reply)
|
||||
if special_note and special_note.strip() not in reply:
|
||||
@@ -1726,6 +1842,7 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
``edit`` (editing/inpainting models) or ``generate`` (text-to-image).
|
||||
"""
|
||||
owner = _user(request)
|
||||
is_admin = _is_admin(request)
|
||||
models: List[Dict[str, Any]] = []
|
||||
seen: set = set()
|
||||
try:
|
||||
@@ -1748,7 +1865,10 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
# recognisably image-capable — the same test the request
|
||||
# router uses.
|
||||
image_endpoint = getattr(ep, "model_type", None) == "image"
|
||||
provider = getattr(ep, "name", None) or getattr(ep, "base_url", "") or "image"
|
||||
provider = getattr(ep, "name", None)
|
||||
if not provider:
|
||||
provider = getattr(ep, "base_url", "") if is_admin else "image"
|
||||
provider = provider or "image"
|
||||
for name in _endpoint_enabled_models(ep):
|
||||
if not image_endpoint and not looks_like_image_generation_model(name):
|
||||
continue
|
||||
@@ -1756,14 +1876,16 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
models.append({
|
||||
entry = {
|
||||
"id": name,
|
||||
"label": name,
|
||||
"kind": "edit" if _EDIT_MODEL_RE.search(name) else "generate",
|
||||
"provider": provider,
|
||||
"endpoint_id": getattr(ep, "id", None),
|
||||
"base_url": getattr(ep, "base_url", "") or "",
|
||||
})
|
||||
}
|
||||
if is_admin:
|
||||
entry["base_url"] = getattr(ep, "base_url", "") or ""
|
||||
models.append(entry)
|
||||
finally:
|
||||
db.close()
|
||||
except Exception:
|
||||
@@ -1781,35 +1903,45 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
falls back to).
|
||||
"""
|
||||
owner = _user(request)
|
||||
is_admin = _is_admin(request)
|
||||
try:
|
||||
models = await editor_agent_model.list_editor_agent_models(owner)
|
||||
if not is_admin:
|
||||
sanitized = []
|
||||
for m in models:
|
||||
m_copy = dict(m)
|
||||
m_copy.pop("base_url", None)
|
||||
sanitized.append(m_copy)
|
||||
models = sanitized
|
||||
except Exception:
|
||||
logger.warning("editor agent-models lookup failed", exc_info=True)
|
||||
models = []
|
||||
probe_info = {}
|
||||
if is_admin:
|
||||
probe_info = editor_agent_model.probe_status()
|
||||
if not probe_info:
|
||||
editor_agent_model.startup_probe()
|
||||
probe_info = editor_agent_model.probe_status()
|
||||
return JSONResponse({
|
||||
"models": models,
|
||||
"probe": editor_agent_model.probe_status(),
|
||||
"probe": probe_info,
|
||||
})
|
||||
|
||||
@router.post("/api/editor/self-check")
|
||||
async def editor_self_check(request: Request) -> JSONResponse:
|
||||
"""Same-origin VL self-check: compare BEFORE/AFTER against the intent.
|
||||
|
||||
The local VL server (seneca :8097) sends no CORS headers, so the browser
|
||||
cannot call it directly and the self-check silently "passed". This route
|
||||
A configured vision server may not provide browser CORS headers. This route
|
||||
proxies the two images + intent to the agent VLM and returns
|
||||
``{done, issues, retry_hint, text}`` (``done=True`` with
|
||||
``reason="unavailable"`` on any failure so the client never loops).
|
||||
"""
|
||||
owner = _user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
body = await _json_object(request)
|
||||
result = await editor_agent_model.self_check(
|
||||
body.get("before"),
|
||||
body.get("after"),
|
||||
str(body.get("prompt") or body.get("intent") or ""),
|
||||
_prompt(body.get("prompt") or body.get("intent")),
|
||||
model=str(body.get("model") or "").strip() or None,
|
||||
owner=owner,
|
||||
)
|
||||
|
||||
+133
-73
@@ -1827,18 +1827,25 @@ def setup_email_routes():
|
||||
# a cached message body.
|
||||
return (account_id or "", folder, str(uid), owner)
|
||||
|
||||
def _list_cache_get(key):
|
||||
v = _LIST_CACHE.get(key)
|
||||
if not v: return None
|
||||
if v[0] < _time.monotonic():
|
||||
_LIST_CACHE.pop(key, None)
|
||||
return None
|
||||
return v[1]
|
||||
|
||||
_LIST_STALE = {}
|
||||
_LIST_LOCK = _threading.RLock()
|
||||
_LIST_CACHE_LOCK = _LIST_LOCK
|
||||
# Invalidation fences reads already in flight, including background refresh.
|
||||
_LIST_EPOCH = [0]
|
||||
_LIST_CACHE_GENERATION = _LIST_EPOCH
|
||||
|
||||
def _list_cache_get(key):
|
||||
with _LIST_LOCK:
|
||||
v = _LIST_CACHE.get(key)
|
||||
if not v: return None
|
||||
if v[0] < _time.monotonic():
|
||||
_LIST_CACHE.pop(key, None)
|
||||
return None
|
||||
return v[1]
|
||||
|
||||
def _list_stale_get(key):
|
||||
return _LIST_STALE.get(key)
|
||||
with _LIST_LOCK:
|
||||
return _LIST_STALE.get(key)
|
||||
|
||||
def _list_stale_put(key, value):
|
||||
_LIST_STALE[key] = value
|
||||
@@ -1849,13 +1856,13 @@ def setup_email_routes():
|
||||
_LIST_REFRESHING = set()
|
||||
|
||||
def _list_cache_put(key, value):
|
||||
_LIST_CACHE[key] = (_time.monotonic() + _LIST_TTL, value)
|
||||
# Also snapshot the last-good response for stale-while-revalidate.
|
||||
_list_stale_put(key, value)
|
||||
# Cap size
|
||||
if len(_LIST_CACHE) > 64:
|
||||
for k in list(_LIST_CACHE.keys())[:-32]:
|
||||
_LIST_CACHE.pop(k, None)
|
||||
with _LIST_LOCK:
|
||||
_LIST_CACHE[key] = (_time.monotonic() + _LIST_TTL, value)
|
||||
# Also snapshot the last-good response for stale-while-revalidate.
|
||||
_list_stale_put(key, value)
|
||||
if len(_LIST_CACHE) > 64:
|
||||
for k in list(_LIST_CACHE.keys())[:-32]:
|
||||
_LIST_CACHE.pop(k, None)
|
||||
|
||||
def _schedule_list_refresh(sync_args, cache_key, folder, account_id, owner):
|
||||
"""Refresh a list snapshot off the event loop, at most once per key.
|
||||
@@ -1863,15 +1870,20 @@ def setup_email_routes():
|
||||
Runs the blocking IMAP fetch in a daemon thread, then repopulates both
|
||||
the TTL cache and the stale snapshot so the next list view is warm.
|
||||
"""
|
||||
if cache_key in _LIST_REFRESHING:
|
||||
return
|
||||
_LIST_REFRESHING.add(cache_key)
|
||||
with _LIST_LOCK:
|
||||
if cache_key in _LIST_REFRESHING:
|
||||
return
|
||||
_LIST_REFRESHING.add(cache_key)
|
||||
epoch = _LIST_EPOCH[0]
|
||||
|
||||
def _run():
|
||||
try:
|
||||
refreshed = _list_emails_sync(*sync_args)
|
||||
if refreshed and not refreshed.get("error"):
|
||||
_list_cache_put(cache_key, refreshed)
|
||||
with _LIST_LOCK:
|
||||
if epoch != _LIST_EPOCH[0]:
|
||||
return
|
||||
_list_cache_put(cache_key, refreshed)
|
||||
try:
|
||||
_schedule_recent_email_warm(
|
||||
refreshed.get("emails") or [], folder, account_id, owner)
|
||||
@@ -1880,7 +1892,8 @@ def setup_email_routes():
|
||||
except Exception as exc: # pragma: no cover - background best effort
|
||||
logger.debug("email list background refresh failed: %s", exc)
|
||||
finally:
|
||||
_LIST_REFRESHING.discard(cache_key)
|
||||
with _LIST_LOCK:
|
||||
_LIST_REFRESHING.discard(cache_key)
|
||||
|
||||
_threading.Thread(target=_run, daemon=True, name="email-list-refresh").start()
|
||||
|
||||
@@ -1917,37 +1930,74 @@ def setup_email_routes():
|
||||
hook(account_id, folder)
|
||||
except Exception:
|
||||
pass
|
||||
if account_id is None and folder is None:
|
||||
_LIST_CACHE.clear()
|
||||
return
|
||||
for k in list(_LIST_CACHE.keys()):
|
||||
k_acct = k[0] if len(k) > 0 else ""
|
||||
k_folder = k[1] if len(k) > 1 else ""
|
||||
if (account_id is None or k_acct == (account_id or "")) and \
|
||||
(folder is None or k_folder == folder):
|
||||
_LIST_CACHE.pop(k, None)
|
||||
with _LIST_LOCK:
|
||||
_LIST_EPOCH[0] += 1
|
||||
if account_id is None and folder is None:
|
||||
_LIST_CACHE.clear()
|
||||
_LIST_STALE.clear()
|
||||
return
|
||||
for cache in (_LIST_CACHE, _LIST_STALE):
|
||||
for k in list(cache.keys()):
|
||||
k_acct = k[0] if len(k) > 0 else ""
|
||||
k_folder = k[1] if len(k) > 1 else ""
|
||||
if (account_id is None or k_acct == (account_id or "")) and \
|
||||
(folder is None or k_folder == folder):
|
||||
cache.pop(k, None)
|
||||
|
||||
def _index_update_flags(owner, account_id, folder, uid, flag, add):
|
||||
with _LIST_LOCK:
|
||||
_email_index_update_flags(owner, account_id, folder, uid, flag, add)
|
||||
_invalidate_list_cache(account_id, folder)
|
||||
|
||||
def _index_delete(owner, account_id, folder, uid):
|
||||
with _LIST_LOCK:
|
||||
_email_index_delete(owner, account_id, folder, uid)
|
||||
_invalidate_list_cache(account_id, folder)
|
||||
|
||||
def _update_list_cache_seen(account_id, folder, uid, seen: bool):
|
||||
uid_s = str(uid)
|
||||
for key, (expires_at, value) in list(_LIST_CACHE.items()):
|
||||
if key[0] != (account_id or "") or key[1] != folder:
|
||||
continue
|
||||
emails = list((value or {}).get("emails") or [])
|
||||
changed = False
|
||||
if seen and key[2] == "unread":
|
||||
kept = [e for e in emails if str((e or {}).get("uid") or "") != uid_s]
|
||||
if len(kept) != len(emails):
|
||||
value = dict(value)
|
||||
value["emails"] = kept
|
||||
value["total"] = max(0, int(value.get("total") or 0) - (len(emails) - len(kept)))
|
||||
changed = True
|
||||
else:
|
||||
for e in emails:
|
||||
if str((e or {}).get("uid") or "") == uid_s:
|
||||
e["is_read"] = bool(seen)
|
||||
with _LIST_LOCK:
|
||||
for key, (expires_at, value) in list(_LIST_CACHE.items()):
|
||||
if key[0] != (account_id or "") or key[1] != folder:
|
||||
continue
|
||||
emails = list((value or {}).get("emails") or [])
|
||||
changed = False
|
||||
if seen and key[2] == "unread":
|
||||
kept = [e for e in emails if str((e or {}).get("uid") or "") != uid_s]
|
||||
if len(kept) != len(emails):
|
||||
value = dict(value)
|
||||
value["emails"] = kept
|
||||
value["total"] = max(0, int(value.get("total") or 0) - (len(emails) - len(kept)))
|
||||
changed = True
|
||||
if changed:
|
||||
_LIST_CACHE[key] = (expires_at, value)
|
||||
else:
|
||||
for e in emails:
|
||||
if str((e or {}).get("uid") or "") == uid_s:
|
||||
e["is_read"] = bool(seen)
|
||||
changed = True
|
||||
if changed:
|
||||
_LIST_CACHE[key] = (expires_at, value)
|
||||
if key in _LIST_STALE:
|
||||
_LIST_STALE[key] = value
|
||||
|
||||
for key, value in list(_LIST_STALE.items()):
|
||||
if key[0] != (account_id or "") or key[1] != folder:
|
||||
continue
|
||||
emails = list((value or {}).get("emails") or [])
|
||||
changed = False
|
||||
if seen and key[2] == "unread":
|
||||
kept = [e for e in emails if str((e or {}).get("uid") or "") != uid_s]
|
||||
if len(kept) != len(emails):
|
||||
value = dict(value)
|
||||
value["emails"] = kept
|
||||
value["total"] = max(0, int(value.get("total") or 0) - (len(emails) - len(kept)))
|
||||
changed = True
|
||||
else:
|
||||
for e in emails:
|
||||
if str((e or {}).get("uid") or "") == uid_s:
|
||||
e["is_read"] = bool(seen)
|
||||
changed = True
|
||||
if changed:
|
||||
_LIST_STALE[key] = value
|
||||
|
||||
def _read_cache_get(key):
|
||||
v = _READ_CACHE.get(key)
|
||||
@@ -2327,6 +2377,8 @@ def setup_email_routes():
|
||||
SECURITY: `owner` is propagated so when `account_id` is missing,
|
||||
the fallback config lookup is scoped to this user's accounts only.
|
||||
"""
|
||||
with _LIST_CACHE_LOCK:
|
||||
index_generation = _LIST_CACHE_GENERATION[0]
|
||||
conn = None
|
||||
conn_ok = False
|
||||
try:
|
||||
@@ -2621,7 +2673,9 @@ def setup_email_routes():
|
||||
if parsed:
|
||||
fetched_emails.append(parsed)
|
||||
cached_rows[parsed["uid"]] = parsed
|
||||
_email_index_upsert(owner, account_id, folder, fetched_emails)
|
||||
with _LIST_CACHE_LOCK:
|
||||
if index_generation == _LIST_CACHE_GENERATION[0]:
|
||||
_email_index_upsert(owner, account_id, folder, fetched_emails)
|
||||
for uid_num in uid_order:
|
||||
row = cached_rows.get(uid_num)
|
||||
if not row:
|
||||
@@ -2915,7 +2969,7 @@ def setup_email_routes():
|
||||
# with a `stale` marker and refresh in the background so the next view
|
||||
# is warm. Blocking IMAP only remains for a genuine cold start with no
|
||||
# snapshot and no indexed rows (e.g. a brand-new account/folder).
|
||||
if not from_addr and not has_attachments and not (date_from or date_to):
|
||||
if not manual_refresh and not from_addr and not has_attachments and not (date_from or date_to):
|
||||
snapshot = _list_stale_get(ck)
|
||||
if snapshot is None:
|
||||
try:
|
||||
@@ -2950,6 +3004,8 @@ def setup_email_routes():
|
||||
ck, folder, account_id, owner,
|
||||
)
|
||||
return payload
|
||||
with _LIST_CACHE_LOCK:
|
||||
cache_generation = _LIST_CACHE_GENERATION[0]
|
||||
result = await _asyncio.to_thread(
|
||||
_list_emails_sync, folder, limit, offset, filter, account_id, from_addr,
|
||||
bool(has_attachments), owner, manual_refresh, date_from or "", date_to or "",
|
||||
@@ -2958,7 +3014,9 @@ def setup_email_routes():
|
||||
if offset == 0 and not from_addr and not has_attachments and filter in ("all", "unread", "unanswered", "undone"):
|
||||
_record_email_received_events(owner, account_id, folder, result.get("emails") or [])
|
||||
_schedule_recent_email_warm(result.get("emails") or [], folder, account_id, owner)
|
||||
_list_cache_put(ck, result)
|
||||
with _LIST_CACHE_LOCK:
|
||||
if cache_generation == _LIST_CACHE_GENERATION[0]:
|
||||
_list_cache_put(ck, result)
|
||||
elapsed_ms = int((_time.monotonic() - started_at) * 1000)
|
||||
if elapsed_ms > 1500:
|
||||
logger.warning(
|
||||
@@ -3306,7 +3364,7 @@ def setup_email_routes():
|
||||
conn.select(_q(folder))
|
||||
moved = _move_email_message(conn, uid, "Junk", role="junk")
|
||||
if moved:
|
||||
_email_index_delete(owner, account_id, folder, uid)
|
||||
_index_delete(owner, account_id, folder, uid)
|
||||
_invalidate_list_cache(account_id)
|
||||
except Exception:
|
||||
logger.debug("unsubscribe move-to-spam skipped", exc_info=True)
|
||||
@@ -3323,7 +3381,7 @@ def setup_email_routes():
|
||||
if deleted:
|
||||
conn.expunge()
|
||||
if deleted:
|
||||
_email_index_delete(owner, account_id, folder, uid)
|
||||
_index_delete(owner, account_id, folder, uid)
|
||||
_invalidate_list_cache(account_id)
|
||||
except Exception as exc:
|
||||
delete_error = "Email was unsubscribed but could not be moved to Trash"
|
||||
@@ -3405,7 +3463,7 @@ def setup_email_routes():
|
||||
if moved:
|
||||
changed += 1
|
||||
cleaned_uids.append(uid)
|
||||
_email_index_delete(owner, account_id, folder, uid)
|
||||
_index_delete(owner, account_id, folder, uid)
|
||||
else:
|
||||
failed += 1
|
||||
except Exception:
|
||||
@@ -3494,6 +3552,8 @@ def setup_email_routes():
|
||||
folder (Gmail does), we transparently swap to All Mail so the
|
||||
search surfaces archived / labelled emails too. Plain IMAP
|
||||
accounts fall back to whatever folder the caller specified."""
|
||||
with _LIST_CACHE_LOCK:
|
||||
index_generation = _LIST_CACHE_GENERATION[0]
|
||||
if not q or len(q) < 2:
|
||||
return {"emails": [], "total": 0, "query": q}
|
||||
# CRLF in q would terminate the IMAP command early — reject defensively.
|
||||
@@ -3637,7 +3697,9 @@ def setup_email_routes():
|
||||
except Exception as e:
|
||||
logger.warning(f"Error parsing search result {uid}: {e}")
|
||||
continue
|
||||
_email_index_upsert(owner, account_id, effective_folder, fetched_emails)
|
||||
with _LIST_CACHE_LOCK:
|
||||
if index_generation == _LIST_CACHE_GENERATION[0]:
|
||||
_email_index_upsert(owner, account_id, effective_folder, fetched_emails)
|
||||
|
||||
return {
|
||||
"emails": emails,
|
||||
@@ -3774,8 +3836,7 @@ def setup_email_routes():
|
||||
# accepted it, so the index and list cache cannot drift ahead of
|
||||
# the mailbox.
|
||||
if mark_seen and not mark_seen_failed:
|
||||
_email_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
|
||||
_update_list_cache_seen(account_id, folder, uid, True)
|
||||
_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
|
||||
|
||||
related_attachments = []
|
||||
if full and not _has_visible_attachments(msg):
|
||||
@@ -3914,8 +3975,7 @@ def setup_email_routes():
|
||||
status, _ = conn.uid("STORE", _uid_bytes(uid), "+FLAGS", "(\\Seen)")
|
||||
if status != "OK":
|
||||
return False
|
||||
_email_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
|
||||
_update_list_cache_seen(account_id, folder, uid, True)
|
||||
_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"mark-seen after cached read failed uid={uid}: {e}")
|
||||
@@ -5147,7 +5207,7 @@ def setup_email_routes():
|
||||
conn.select(_q(folder))
|
||||
if not _store_email_flag(conn, uid, "\\Seen", add=False):
|
||||
return {"success": False, "error": "Email not found"}
|
||||
_email_index_update_flags(owner, account_id, folder, uid, "\\Seen", False)
|
||||
_index_update_flags(owner, account_id, folder, uid, "\\Seen", False)
|
||||
_invalidate_list_cache(account_id, folder)
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
@@ -5167,7 +5227,7 @@ def setup_email_routes():
|
||||
conn.select(_q(folder))
|
||||
if not _store_email_flag(conn, uid, "\\Flagged", add=bool(on)):
|
||||
return {"success": False, "error": "Email not found"}
|
||||
_email_index_update_flags(owner, account_id, folder, uid, "\\Flagged", bool(on))
|
||||
_index_update_flags(owner, account_id, folder, uid, "\\Flagged", bool(on))
|
||||
_invalidate_list_cache(account_id, folder)
|
||||
return {"success": True, "flagged": bool(on)}
|
||||
except Exception as e:
|
||||
@@ -5185,7 +5245,7 @@ def setup_email_routes():
|
||||
conn.select(_q(folder))
|
||||
if not _store_email_flag(conn, uid, "\\Seen", add=True):
|
||||
return {"success": False, "error": "Email not found"}
|
||||
_email_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
|
||||
_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
|
||||
_invalidate_list_cache(account_id, folder)
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
@@ -5205,7 +5265,7 @@ def setup_email_routes():
|
||||
conn.select(_q(folder))
|
||||
if not _move_email_message(conn, uid, "Archive", role="archive"):
|
||||
return {"success": False, "error": "Email not found"}
|
||||
_email_index_delete(owner, account_id, folder, uid)
|
||||
_index_delete(owner, account_id, folder, uid)
|
||||
_invalidate_list_cache(account_id)
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
@@ -5236,7 +5296,7 @@ def setup_email_routes():
|
||||
# click or by another mailbox client; it is already gone
|
||||
# from the requested folder, so do not trap the UI on a
|
||||
# permanent “Email not found” error.
|
||||
_email_index_delete(owner, account_id, folder, str(uid))
|
||||
_index_delete(owner, account_id, folder, str(uid))
|
||||
_invalidate_list_cache(account_id, folder)
|
||||
return {"success": True, "already_deleted": True}
|
||||
trash_folder = _resolve_mail_folder(conn, "Trash", "trash")
|
||||
@@ -5254,9 +5314,9 @@ def setup_email_routes():
|
||||
if not _move_email_message(conn, resolved_uid, trash_folder, role="trash"):
|
||||
logger.warning("Email delete Trash move failed uid=%s resolved_uid=%s folder=%s trash=%s", uid, resolved_uid, folder, trash_folder)
|
||||
return {"success": False, "error": "Could not move email to Trash"}
|
||||
_email_index_delete(owner, account_id, folder, resolved_uid)
|
||||
_index_delete(owner, account_id, folder, resolved_uid)
|
||||
if resolved_uid != str(uid):
|
||||
_email_index_delete(owner, account_id, folder, str(uid))
|
||||
_index_delete(owner, account_id, folder, str(uid))
|
||||
_invalidate_list_cache(account_id)
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
@@ -5272,7 +5332,7 @@ def setup_email_routes():
|
||||
if not _store_email_flag(conn, uid, "\\Deleted", add=True):
|
||||
return {"success": False, "error": "Email not found"}
|
||||
conn.expunge()
|
||||
_email_index_delete(owner, account_id, folder, uid)
|
||||
_index_delete(owner, account_id, folder, uid)
|
||||
_invalidate_list_cache(account_id, folder)
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
@@ -5340,7 +5400,7 @@ def setup_email_routes():
|
||||
pending_expunge.append(uid)
|
||||
continue
|
||||
deleted_uids.append(uid)
|
||||
_email_index_delete(owner, account_id, folder, uid)
|
||||
_index_delete(owner, account_id, folder, uid)
|
||||
except Exception:
|
||||
failed_uids.append(uid)
|
||||
logger.debug("Bulk email delete failed for uid=%s", uid, exc_info=True)
|
||||
@@ -5362,7 +5422,7 @@ def setup_email_routes():
|
||||
if expunge_ok:
|
||||
for uid in pending_expunge:
|
||||
deleted_uids.append(uid)
|
||||
_email_index_delete(owner, account_id, folder, uid)
|
||||
_index_delete(owner, account_id, folder, uid)
|
||||
else:
|
||||
# Prioritize preservation over cleanup: unflag \Deleted from our
|
||||
# copied messages so future EXPUNGE operations do not destroy them.
|
||||
@@ -5481,7 +5541,7 @@ def setup_email_routes():
|
||||
conn.select(_q(folder))
|
||||
if not _move_email_message(conn, uid, dest):
|
||||
return {"success": False, "error": f"Failed to move to {dest}"}
|
||||
_email_index_delete(owner, account_id, folder, uid)
|
||||
_index_delete(owner, account_id, folder, uid)
|
||||
_invalidate_list_cache(account_id)
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
@@ -5567,7 +5627,7 @@ def setup_email_routes():
|
||||
conn.select(_q(folder))
|
||||
if not _store_email_flag(conn, uid, "\\Answered", add=True):
|
||||
return {"success": False, "error": "Email not found"}
|
||||
_email_index_update_flags(owner, account_id, folder, uid, "\\Answered", True)
|
||||
_index_update_flags(owner, account_id, folder, uid, "\\Answered", True)
|
||||
_clear_done_response_tags(owner, account_id, folder, uid)
|
||||
_invalidate_list_cache(account_id, folder)
|
||||
return {"success": True}
|
||||
@@ -5583,7 +5643,7 @@ def setup_email_routes():
|
||||
conn.select(_q(folder))
|
||||
if not _store_email_flag(conn, uid, "\\Answered", add=False):
|
||||
return {"success": False, "error": "Email not found"}
|
||||
_email_index_update_flags(owner, account_id, folder, uid, "\\Answered", False)
|
||||
_index_update_flags(owner, account_id, folder, uid, "\\Answered", False)
|
||||
_invalidate_list_cache(account_id, folder)
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
@@ -6250,7 +6310,7 @@ def setup_email_routes():
|
||||
try:
|
||||
st, _sel = imap.select(_q(_source_folder), readonly=False)
|
||||
if st == "OK" and _store_email_flag(imap, _source_uid, "\\Answered", add=True):
|
||||
_email_index_update_flags(owner, _account_id, _source_folder, _source_uid, "\\Answered", True)
|
||||
_index_update_flags(owner, _account_id, _source_folder, _source_uid, "\\Answered", True)
|
||||
_clear_done_response_tags(owner, _account_id, _source_folder, _source_uid)
|
||||
_invalidate_list_cache(_account_id, _source_folder)
|
||||
logger.info(f"Marked source UID {_source_uid} as \\Answered in {_source_folder}")
|
||||
|
||||
@@ -779,14 +779,17 @@ async def _forward_seneca_image_op(
|
||||
except Exception:
|
||||
pass
|
||||
raise HTTPException(r.status_code if r.status_code in (400, 404, 422) else 502, detail)
|
||||
result = r.json()
|
||||
try:
|
||||
result = r.json()
|
||||
except ValueError as exc:
|
||||
raise HTTPException(502, "Image endpoint returned invalid JSON") from exc
|
||||
raw_b64: Optional[str] = None
|
||||
used_model = ""
|
||||
if isinstance(result, dict):
|
||||
used_model = str(result.get("model") or "")
|
||||
data = result.get("data")
|
||||
if isinstance(data, list) and data:
|
||||
item = data[0] or {}
|
||||
item = data[0] if isinstance(data[0], dict) else {}
|
||||
raw_b64 = item.get("b64_json") or None
|
||||
if not raw_b64 and item.get("url"):
|
||||
raw_b64 = await _fetch_result_image_b64(item["url"])
|
||||
@@ -1842,8 +1845,8 @@ def setup_gallery_routes() -> APIRouter:
|
||||
headers = {"Authorization": f"Bearer {api_key}"}
|
||||
try:
|
||||
logger.debug(
|
||||
"inpaint_proxy provider edit -> prompt=%r model=%r size=%r base=%s",
|
||||
body.get("prompt", ""), oa_model, size, base,
|
||||
"inpaint_proxy provider edit -> model=%r size=%r base=%s",
|
||||
oa_model, size, base,
|
||||
)
|
||||
async with httpx.AsyncClient(timeout=300) as client:
|
||||
if is_openrouter:
|
||||
@@ -1978,9 +1981,9 @@ def setup_gallery_routes() -> APIRouter:
|
||||
finally:
|
||||
_restore_ms[0] += (time.perf_counter() - _rs) * 1000.0
|
||||
logger.debug(
|
||||
"inpaint_proxy self-hosted /images/edits -> prompt=%r model=%r "
|
||||
"inpaint_proxy self-hosted /images/edits -> model=%r "
|
||||
"size=%r steps=%r endpoint=%s",
|
||||
data.get("prompt"), data.get("model"), data.get("size"),
|
||||
data.get("model"), data.get("size"),
|
||||
data.get("steps"), base,
|
||||
)
|
||||
_prep_ms = (time.perf_counter() - _t0) * 1000.0
|
||||
|
||||
@@ -290,14 +290,9 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
||||
# (most sessions have none), not an error. Return HTTP 200 with an
|
||||
# explicit empty status so loading a session does not log a spurious
|
||||
# 404; every client treats a non-"running" status as idle anyway.
|
||||
# A session whose research exists but belongs to ANOTHER user stays a
|
||||
# 404, so we do not leak the existence of someone else's research.
|
||||
# Research owned by ANOTHER user answers identically, so the response
|
||||
# does not reveal whether that research exists.
|
||||
if not _owns_in_memory(session_id, user):
|
||||
exists = session_id in getattr(research_handler, "_active_tasks", {})
|
||||
if not exists:
|
||||
exists = _find_research_path(session_id) is not None
|
||||
if exists:
|
||||
raise HTTPException(404, "No research found for this session")
|
||||
return {"status": "none"}
|
||||
status = research_handler.get_status(session_id)
|
||||
if status is None:
|
||||
|
||||
@@ -951,14 +951,12 @@ VARIABLE_NOTES: dict[str, tuple[str, str, str]] = {
|
||||
"ODYSSEUS_JEFF_URL": ("Agent loop and tool execution", INTERNAL, "Base URL of the optional decision adapter service."),
|
||||
"ODYSSEUS_PRIVATE_BROWSER_LEGACY": ("Agent loop and tool execution", INTERNAL, "Explicit opt-in to the legacy browser compatibility transport; disabled by default."),
|
||||
"ODYSSEUS_SPEC_BROWSER_FALLBACK": ("Agent loop and tool execution", INTERNAL, "Optional browser routing fallback: mcp, web, or 0 to disable."),
|
||||
"ODYSSEUS_SPEC_DRIVER_TOPK": ("Agent loop and tool execution", INTERNAL, "Number of router-selected tool candidates exposed in a driver round."),
|
||||
"ODYSSEUS_SPEC_MODE": ("Agent loop and tool execution", INTERNAL, "Spec routing mode for explicitly configured models, including strict and driver."),
|
||||
"ODYSSEUS_SPEC_ROUTER_MIN_CONF": ("Agent loop and tool execution", INTERNAL, "Minimum tool-selection confidence accepted from the router."),
|
||||
"ODYSSEUS_SPEC_ROUTER_MODEL": ("Agent loop and tool execution", INTERNAL, "Decision adapter model used for tool routing."),
|
||||
"ODYSSEUS_SPEC_ROUTER_SECOND": ("Agent loop and tool execution", INTERNAL, "Minimum confidence for adding a secondary tool candidate."),
|
||||
"ODYSSEUS_SPEC_ROUTER_TIMEOUT": ("Agent loop and tool execution", INTERNAL, "Time budget in seconds for a router decision."),
|
||||
"ODYSSEUS_SPEC_ROUTER_URL": ("Agent loop and tool execution", INTERNAL, "Base URL of the optional Spec router; an empty URL disables requests."),
|
||||
"ODYSSEUS_SPEC_SUBSET_OF_BASE": ("Agent loop and tool execution", INTERNAL, "Limit Spec tool selections to the existing request inventory by default."),
|
||||
"ODYSSEUS_SPEC_THINK_MIN_P": ("Agent loop and tool execution", INTERNAL, "Minimum router confidence for enabling thinking."),
|
||||
"ODYSSEUS_SPEC_THINK_MODEL": ("Agent loop and tool execution", INTERNAL, "Optional thinking decision adapter model, falling back to the tool router model."),
|
||||
"ODYSSEUS_SPEC_THINK_QUESTION": ("Agent loop and tool execution", INTERNAL, "Thinking classifier question format: binary or kind5."),
|
||||
|
||||
@@ -628,6 +628,14 @@ async def extract_and_store(
|
||||
if not fact_text or len(fact_text) < 5:
|
||||
continue
|
||||
|
||||
# A fuller owner-specific name must not rewrite a shared legacy
|
||||
# entry, but that less-specific entry must not suppress it either.
|
||||
legacy_refinements = {
|
||||
id(entry) for entry in existing
|
||||
if _owner and entry.get("owner") is None
|
||||
and _name_enrichment_target(fact_text, [entry]) is entry
|
||||
}
|
||||
|
||||
# Dedup: check vector similarity first (fast), then exact text match.
|
||||
# A runtime embedding/ChromaDB failure (backend OOM, model evicted,
|
||||
# remote endpoint down) must not abort the whole batch — fall through
|
||||
@@ -649,18 +657,25 @@ async def extract_and_store(
|
||||
# silently dropped. Mirror the owner predicate used by the
|
||||
# text dedup below; cross-tenant/stale matches fall through.
|
||||
_match = next((e for e in existing if e.get("id") == existing_id), None)
|
||||
if _match is not None and (_match.get("owner") == _owner or _match.get("owner") is None):
|
||||
if (_match is not None and id(_match) not in legacy_refinements
|
||||
and (_match.get("owner") == _owner or _match.get("owner") is None)):
|
||||
logger.debug(f"Memory dedup (vector): '{fact_text[:50]}' matches {existing_id}")
|
||||
continue
|
||||
|
||||
# Text dedup fallback: exact match + fuzzy similarity
|
||||
user_existing = [e for e in existing if e.get("owner") == _owner or e.get("owner") is None] if _owner else existing
|
||||
user_existing = [e for e in existing
|
||||
if (e.get("owner") == _owner or e.get("owner") is None)
|
||||
and id(e) not in legacy_refinements] if _owner else existing
|
||||
|
||||
# Name enrichment: if an existing name fact is a strict prefix of
|
||||
# this more complete one, upgrade it in place instead of adding a
|
||||
# near-duplicate ("name is Felix" -> "name is Felix Arvid Ulf
|
||||
# Kjellberg"). The vector store upserts by id, so re-adding is safe.
|
||||
_enrich = _name_enrichment_target(fact_text, user_existing)
|
||||
# Legacy unowned entries may be visible to multiple owners; never
|
||||
# rewrite one with a private fact from the current owner.
|
||||
_enrich = _name_enrichment_target(
|
||||
fact_text, [e for e in user_existing if e.get("owner") == _owner]
|
||||
)
|
||||
if _enrich is not None:
|
||||
_enrich["text"] = fact_text
|
||||
_enrich["timestamp"] = int(time.time())
|
||||
|
||||
+154
-139
@@ -21061,6 +21061,103 @@ from src.agent_runtime.authority import MISSING_AUTHORITY, active_request_author
|
||||
|
||||
|
||||
|
||||
def _apply_spec_decision_to_contract(turn_contract, picks, decided, disabled_tools, tool_policy):
|
||||
from src.turn_contract import _ACTIVE_CONTRACT as _spec_active_contract
|
||||
disabled = set(disabled_tools or ())
|
||||
from src.spec_router import latest_decision
|
||||
_spec_decision = latest_decision() or {}
|
||||
_spec_choice = _spec_decision.get("choice")
|
||||
_spec_direct = (os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict"
|
||||
and decided and _spec_choice == "answer_directly")
|
||||
try:
|
||||
_mgr = get_mcp_manager()
|
||||
mcp_schemas = _mgr.get_all_openai_schemas(_load_mcp_disabled_map()) if _mgr else []
|
||||
except Exception:
|
||||
mcp_schemas = []
|
||||
mcp_names = {(sc.get("function") or {}).get("name") for sc in mcp_schemas}
|
||||
if _spec_choice == "ask_user":
|
||||
# ask_user = a tool is needed but a detail may be missing: offer Spec's top tool too, so the model
|
||||
# can act when the detail is there or ask when it is not.
|
||||
_top = _spec_decision.get("top") or []
|
||||
if _top:
|
||||
picks = set(picks) | {_top[0][0]}
|
||||
picks = {(f"mcp__email__{n}" if f"mcp__email__{n}" in mcp_names else n) for n in picks}
|
||||
strict = os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict"
|
||||
base_offered = set(turn_contract.offered)
|
||||
# The router sees the full catalog, while this request's policy may
|
||||
# deny a tool (for example, a browser turn with Web access switched
|
||||
# off). Preserve that conflict as an unavailable contract. Keeping the
|
||||
# old empty menu instead lets the model invent a tool result in prose.
|
||||
_top = _spec_decision.get("top") or []
|
||||
_primary = str(_top[0][0]) if _spec_choice and str(_spec_choice).startswith("t") and _top else ""
|
||||
if _primary and f"mcp__email__{_primary}" in mcp_names:
|
||||
_primary = f"mcp__email__{_primary}"
|
||||
# A policy denial is surfaced even if the router chose outside the
|
||||
# base inventory; it never grants access to that tool.
|
||||
def _denied(name):
|
||||
return (name in disabled
|
||||
or (tool_policy is not None and (
|
||||
tool_policy.blocks(name)
|
||||
or (tool_policy.disable_mcp and name.startswith("mcp__")))))
|
||||
_unavailable_pick = ""
|
||||
if _primary in picks and _denied(_primary):
|
||||
_unavailable_pick = _primary
|
||||
elif picks and all(_denied(name) for name in picks):
|
||||
_unavailable_pick = sorted(picks)[0]
|
||||
if _unavailable_pick:
|
||||
blocked = replace(
|
||||
turn_contract, required=frozenset(), offered=frozenset(),
|
||||
unavailable=frozenset({_unavailable_pick}), schema_json=(),
|
||||
required_read_operation=None, routing_experiment="spec-policy-denied",
|
||||
)
|
||||
_spec_active_contract.set(blocked)
|
||||
logger.info("[spec-harness] router pick %s unavailable under request policy", _unavailable_pick)
|
||||
return blocked
|
||||
want = set(picks) - disabled
|
||||
# Always-on basics only when the base turn offers them, so the Spec
|
||||
# arm never offers a tool the base arm would not.
|
||||
if not _spec_direct:
|
||||
want |= {"ask_user", "update_plan"} & base_offered
|
||||
if not (strict and decided):
|
||||
want |= base_offered
|
||||
want &= base_offered
|
||||
if not want:
|
||||
logger.info("[spec-harness] router picks %s outside base offered %s; base contract kept",
|
||||
sorted(picks), sorted(base_offered))
|
||||
return turn_contract
|
||||
offer = want & base_offered & set(turn_contract.executable)
|
||||
# A router may narrow the menu only when every existing obligation
|
||||
# remains satisfiable. It cannot redefine the user's requested work.
|
||||
required_read = getattr(turn_contract, "required_read_operation", None)
|
||||
if (not set(turn_contract.required) <= offer or turn_contract.unavailable
|
||||
or (required_read is not None and required_read.tool not in offer)):
|
||||
return turn_contract
|
||||
by_name = {}
|
||||
for sc in list(FUNCTION_TOOL_SCHEMAS) + list(mcp_schemas):
|
||||
nm = (sc.get("function") or {}).get("name")
|
||||
if nm in offer and nm not in by_name:
|
||||
by_name[nm] = sc
|
||||
for sc in turn_contract.schemas():
|
||||
nm = (sc.get("function") or {}).get("name")
|
||||
if nm in offer:
|
||||
by_name[nm] = sc
|
||||
if not by_name and not _spec_direct:
|
||||
logger.info("[spec-harness] router picks %s not executable this turn; contract kept", sorted(picks))
|
||||
return turn_contract
|
||||
# Required tools and the required read carry over unchanged: harness
|
||||
# completion logic keys on them (e.g. a calendar list is a read, not an
|
||||
# effect that may end with "Done."). If one lost its schema, contract
|
||||
# validation fails and the original contract is retained below.
|
||||
new = replace(
|
||||
turn_contract, offered=frozenset(by_name),
|
||||
schema_json=tuple(json.dumps(by_name[n], ensure_ascii=False, sort_keys=True) for n in sorted(by_name)),
|
||||
routing_experiment="spec-router")
|
||||
_spec_active_contract.set(new)
|
||||
logger.info("[spec-harness] contract replaced by router: offered=%s (was %s, decided=%s)",
|
||||
sorted(by_name), sorted(turn_contract.offered), decided)
|
||||
return new
|
||||
|
||||
|
||||
def _spec_router_contract(turn_contract, messages, disabled_tools, endpoint_url, model, owner, headers,
|
||||
tool_policy=None):
|
||||
"""Ajax Spec: return a copy of the turn contract whose offered tools are the
|
||||
@@ -21069,10 +21166,17 @@ def _spec_router_contract(turn_contract, messages, disabled_tools, endpoint_url,
|
||||
Runs before any runtime dispatch so the compact preview runtime and the
|
||||
main loop both serve the router's set; only the offered tools change.
|
||||
"""
|
||||
# Contexts can be inherited by a child task; a turn must never inherit a
|
||||
# previous turn's decision when routing is disabled or unavailable.
|
||||
from src import spec_router as _router_context
|
||||
if hasattr(_router_context, "clear_decision"):
|
||||
_router_context.clear_decision()
|
||||
if (turn_contract is None
|
||||
or getattr(turn_contract, "routing_experiment", "") in {"spec-router", "spec-policy-denied"}
|
||||
or (tool_policy is not None and tool_policy.block_all_tool_calls)):
|
||||
return turn_contract
|
||||
if os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() not in {"strict", "additive"}:
|
||||
return turn_contract
|
||||
try:
|
||||
if _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers) != "spec":
|
||||
return turn_contract
|
||||
@@ -21080,105 +21184,38 @@ def _spec_router_contract(turn_contract, messages, disabled_tools, endpoint_url,
|
||||
return turn_contract
|
||||
try:
|
||||
from src.spec_router import spec_route_tools as _spec_route_tools_early
|
||||
from src.turn_contract import _ACTIVE_CONTRACT as _spec_active_contract
|
||||
disabled = set(disabled_tools or ())
|
||||
picks, decided = _spec_route_tools_early(
|
||||
_extract_last_user_message(messages), FUNCTION_TOOL_SCHEMAS, set(), messages, return_decision=True)
|
||||
from src.spec_router import LAST_DECISIONS as _spec_decisions
|
||||
_spec_choice = (_spec_decisions[-1] if _spec_decisions else {}).get("choice")
|
||||
_spec_direct = (os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict"
|
||||
and decided and _spec_choice == "answer_directly")
|
||||
try:
|
||||
_mgr = get_mcp_manager()
|
||||
mcp_schemas = _mgr.get_all_openai_schemas(_load_mcp_disabled_map()) if _mgr else []
|
||||
except Exception:
|
||||
mcp_schemas = []
|
||||
mcp_names = {(sc.get("function") or {}).get("name") for sc in mcp_schemas}
|
||||
if _spec_choice == "ask_user":
|
||||
# ask_user = a tool is needed but a detail may be missing: offer Spec's top tool too, so the model
|
||||
# can act when the detail is there or ask when it is not.
|
||||
_top = ((_spec_decisions[-1] if _spec_decisions else {}).get("top") or [])
|
||||
if _top:
|
||||
picks = set(picks) | {_top[0][0]}
|
||||
picks = {(f"mcp__email__{n}" if f"mcp__email__{n}" in mcp_names else n) for n in picks}
|
||||
strict = os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict"
|
||||
base_offered = set(turn_contract.offered)
|
||||
subset_of_base = os.getenv("ODYSSEUS_SPEC_SUBSET_OF_BASE", "1").strip().lower() not in {"0", "false", "no", "off"}
|
||||
# The router sees the full catalog, while this request's policy may
|
||||
# deny a tool (for example, a browser turn with Web access switched
|
||||
# off). Preserve that conflict as an unavailable contract. Keeping the
|
||||
# old empty menu instead lets the model invent a tool result in prose.
|
||||
_top = ((_spec_decisions[-1] if _spec_decisions else {}).get("top") or [])
|
||||
_primary = str(_top[0][0]) if _spec_choice and str(_spec_choice).startswith("t") and _top else ""
|
||||
if _primary and f"mcp__email__{_primary}" in mcp_names:
|
||||
_primary = f"mcp__email__{_primary}"
|
||||
# `executable` belongs to the base contract. In subset=0, Spec may
|
||||
# legitimately add a policy-permitted tool that the base inventory did
|
||||
# not include. Only an explicit request-policy denial blocks the pick.
|
||||
def _denied(name):
|
||||
return (name in disabled
|
||||
or (tool_policy is not None and (
|
||||
tool_policy.blocks(name)
|
||||
or (tool_policy.disable_mcp and name.startswith("mcp__")))))
|
||||
_unavailable_pick = ""
|
||||
if _primary in picks and _denied(_primary):
|
||||
_unavailable_pick = _primary
|
||||
elif picks and all(_denied(name) for name in picks):
|
||||
_unavailable_pick = sorted(picks)[0]
|
||||
if _unavailable_pick:
|
||||
blocked = replace(
|
||||
turn_contract, required=frozenset(), offered=frozenset(),
|
||||
unavailable=frozenset({_unavailable_pick}), schema_json=(),
|
||||
required_read_operation=None, routing_experiment="spec-policy-denied",
|
||||
)
|
||||
_spec_active_contract.set(blocked)
|
||||
logger.info("[spec-harness] router pick %s unavailable under request policy", _unavailable_pick)
|
||||
return blocked
|
||||
want = set(picks) - disabled
|
||||
# Always-on basics only when the base turn offers them, so the Spec
|
||||
# arm never offers a tool the base arm would not.
|
||||
if not _spec_direct:
|
||||
want |= {"ask_user", "update_plan"} & base_offered
|
||||
if not (strict and decided):
|
||||
want |= base_offered
|
||||
if subset_of_base:
|
||||
# A/B control: Spec offered set is a subset of the base offered set
|
||||
# (measures narrowing only). Empty intersection keeps the base set.
|
||||
want &= base_offered
|
||||
if not want:
|
||||
logger.info("[spec-harness] router picks %s outside base offered %s; base contract kept",
|
||||
sorted(picks), sorted(base_offered))
|
||||
return turn_contract
|
||||
offer = want if strict else (want & set(turn_contract.executable))
|
||||
by_name = {}
|
||||
for sc in list(FUNCTION_TOOL_SCHEMAS) + list(mcp_schemas):
|
||||
nm = (sc.get("function") or {}).get("name")
|
||||
if nm in offer and nm not in by_name:
|
||||
by_name[nm] = sc
|
||||
for sc in turn_contract.schemas():
|
||||
nm = (sc.get("function") or {}).get("name")
|
||||
if nm in offer:
|
||||
by_name[nm] = sc
|
||||
if not by_name and not _spec_direct:
|
||||
logger.info("[spec-harness] router picks %s not executable this turn; contract kept", sorted(picks))
|
||||
return turn_contract
|
||||
# Keep the contract's required tools and required read whenever they
|
||||
# are still offered: harness completion logic keys on them (e.g. a
|
||||
# calendar list is a read, not an effect that may end with "Done.").
|
||||
_rro = getattr(turn_contract, "required_read_operation", None)
|
||||
_keep_rro = _rro if (_rro is not None and getattr(_rro, "tool", None) in by_name) else None
|
||||
new = replace(
|
||||
turn_contract, required=frozenset(n for n in (turn_contract.required or ()) if n in by_name),
|
||||
offered=frozenset(by_name),
|
||||
executable=frozenset(set(turn_contract.executable) | set(by_name)),
|
||||
schema_json=tuple(json.dumps(by_name[n], ensure_ascii=False, sort_keys=True) for n in sorted(by_name)),
|
||||
unavailable=(frozenset() if os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict" else turn_contract.unavailable), required_read_operation=_keep_rro, routing_experiment="spec-router")
|
||||
_spec_active_contract.set(new)
|
||||
logger.info("[spec-harness] contract replaced by router: offered=%s (was %s, decided=%s)",
|
||||
sorted(by_name), sorted(turn_contract.offered), decided)
|
||||
return new
|
||||
return _apply_spec_decision_to_contract(turn_contract, picks, decided, disabled_tools, tool_policy)
|
||||
except Exception as exc:
|
||||
logger.warning("[spec-harness] router contract replacement failed open: %s", exc)
|
||||
logger.warning("[spec-harness] router failed; retaining original contract: %s", exc)
|
||||
return turn_contract
|
||||
|
||||
|
||||
async def _spec_router_contract_async(turn_contract, messages, disabled_tools, endpoint_url, model, owner, headers,
|
||||
tool_policy=None):
|
||||
"""Asynchronous non-blocking turn contract routing."""
|
||||
from src import spec_router as _router_context
|
||||
if hasattr(_router_context, "clear_decision"):
|
||||
_router_context.clear_decision()
|
||||
if (turn_contract is None
|
||||
or getattr(turn_contract, "routing_experiment", "") in {"spec-router", "spec-policy-denied"}
|
||||
or (tool_policy is not None and tool_policy.block_all_tool_calls)):
|
||||
return turn_contract
|
||||
if os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() not in {"strict", "additive"}:
|
||||
return turn_contract
|
||||
try:
|
||||
if _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers) != "spec":
|
||||
return turn_contract
|
||||
except Exception:
|
||||
return turn_contract
|
||||
try:
|
||||
from src.spec_router import spec_route_tools_async as _spec_route_tools_early_async
|
||||
picks, decided = await _spec_route_tools_early_async(
|
||||
_extract_last_user_message(messages), FUNCTION_TOOL_SCHEMAS, set(), messages, return_decision=True)
|
||||
return _apply_spec_decision_to_contract(turn_contract, picks, decided, disabled_tools, tool_policy)
|
||||
except Exception as exc:
|
||||
logger.warning("[spec-harness] router failed; retaining original contract: %s", exc)
|
||||
return turn_contract
|
||||
|
||||
|
||||
@@ -21263,20 +21300,11 @@ async def stream_agent_loop(
|
||||
if (os.getenv("ODYSSEUS_SPEC_MODE", "").strip().lower() == "driver"
|
||||
and not (tool_policy and tool_policy.block_all_tool_calls)
|
||||
and _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers) == "spec"):
|
||||
from src.spec_driver import stream_spec_turn
|
||||
async for chunk in stream_spec_turn(
|
||||
endpoint_url=endpoint_url, model=model, messages=messages, headers=headers,
|
||||
session_id=session_id, owner=owner, disabled_tools=disabled_tools,
|
||||
tool_policy=tool_policy, workspace=workspace, active_document=active_document,
|
||||
client_runtime_context=client_runtime_context,
|
||||
external_untrusted_context_seen=external_untrusted_context_seen,
|
||||
max_tokens=max(1024, min(int(max_tokens or 1024), 4096)), max_rounds=8,
|
||||
temperature=0.0, request_authority=request_authority,
|
||||
):
|
||||
yield chunk
|
||||
yield 'data: ' + json.dumps({"delta": "Spec driver mode is unavailable because its authority and completion checks are not supported. No action was performed."}) + '\n\n'
|
||||
yield 'data: [DONE]\n\n'
|
||||
return
|
||||
|
||||
turn_contract = _spec_router_contract(
|
||||
turn_contract = await _spec_router_contract_async(
|
||||
turn_contract, messages, disabled_tools, endpoint_url, model, owner, headers,
|
||||
tool_policy=tool_policy)
|
||||
if (turn_contract is not None
|
||||
@@ -21289,6 +21317,20 @@ async def stream_agent_loop(
|
||||
yield 'data: [DONE]\n\n'
|
||||
return
|
||||
|
||||
try:
|
||||
_spec_turn = _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers) == "spec"
|
||||
except Exception:
|
||||
_spec_turn = False
|
||||
# Without a turn contract (scheduled, skill and monitor runs) no Spec
|
||||
# routing applies; the turn runs on the compact surface as usual.
|
||||
if _spec_turn and _blocks_before_inference(turn_contract):
|
||||
unavailable = ", ".join(sorted(turn_contract.unavailable))
|
||||
yield 'data: ' + json.dumps({"type": "turn_contract", **turn_contract.audit()}) + '\n\n'
|
||||
yield 'data: ' + json.dumps({"delta": "I can’t perform this request with the currently permitted tools "
|
||||
f"(unavailable: {unavailable}). I haven’t substituted another tool."}) + '\n\n'
|
||||
yield 'data: [DONE]\n\n'
|
||||
return
|
||||
|
||||
if is_compact_preview_contract(turn_contract):
|
||||
from src.clean_agent_preview import stream_preview
|
||||
async for chunk in stream_preview(
|
||||
@@ -21314,26 +21356,9 @@ async def stream_agent_loop(
|
||||
yield chunk
|
||||
return
|
||||
|
||||
_spec_turn = False
|
||||
try:
|
||||
_spec_turn = _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers) == "spec"
|
||||
except Exception:
|
||||
_spec_turn = False
|
||||
if _spec_turn and turn_contract is not None and getattr(turn_contract, "routing_experiment", "") != "spec-router":
|
||||
# Ajax Spec: the router, not the regex turn contract, decides the tools.
|
||||
# Drop the contract here and in the dispatcher's context so neither the
|
||||
# route build nor execute_tool_block enforces its offered set.
|
||||
logger.info("[spec-harness] dropping turn contract on spec turn: offered=%s unavailable=%s",
|
||||
sorted(turn_contract.offered), sorted(turn_contract.unavailable))
|
||||
turn_contract = None
|
||||
try:
|
||||
from src.turn_contract import _ACTIVE_CONTRACT as _spec_active_contract
|
||||
_spec_active_contract.set(None)
|
||||
except Exception as _exc:
|
||||
logger.warning("[spec-harness] could not clear active turn contract: %s", _exc)
|
||||
if turn_contract is not None:
|
||||
yield 'data: ' + json.dumps({"type": "turn_contract", **turn_contract.audit()}) + '\n\n'
|
||||
if not _spec_turn and _blocks_before_inference(turn_contract):
|
||||
if _blocks_before_inference(turn_contract):
|
||||
unavailable = ", ".join(sorted(turn_contract.unavailable))
|
||||
clarification = (
|
||||
"I can’t perform this request with the currently permitted tools "
|
||||
@@ -24336,7 +24361,7 @@ async def stream_agent_loop(
|
||||
# "spec" = compact tools plus a small-model router; the harness-facing
|
||||
# surface already reads "compact", so detect spec from the raw mode.
|
||||
try:
|
||||
_spec_surface = _configured_model_tool_surface_raw(
|
||||
_spec_surface = getattr(turn_contract, "routing_experiment", "") == "spec-router" and _configured_model_tool_surface_raw(
|
||||
candidate_url, candidate_model, owner, headers=candidate_headers,
|
||||
endpoint_id=(route_descriptor or {}).get("endpoint_id")) == "spec"
|
||||
except Exception:
|
||||
@@ -24410,7 +24435,7 @@ async def stream_agent_loop(
|
||||
if _spec_mode == "strict" and _spec_decided:
|
||||
# The router decides which tools are injected: its pick(s),
|
||||
# tools already used in this chat, and the always-on basics.
|
||||
_spec_strict = (_spec_added | {"ask_user", "update_plan"}) - set(disabled_tools)
|
||||
_spec_strict = (_spec_added | {"ask_user", "update_plan"}) & set(turn_contract.offered) - set(disabled_tools)
|
||||
if _spec_strict:
|
||||
route_tools = _spec_strict
|
||||
_spec_offered = frozenset(route_tools)
|
||||
@@ -25361,15 +25386,6 @@ async def stream_agent_loop(
|
||||
if (schema.get("function", {}).get("name") or schema.get("name"))
|
||||
in _spec_names
|
||||
]
|
||||
_have = {
|
||||
(schema.get("function", {}).get("name") or schema.get("name"))
|
||||
for schema in contract_schemas
|
||||
}
|
||||
contract_schemas += [
|
||||
schema for schema in list(FUNCTION_TOOL_SCHEMAS) + list(route_mcp_schemas)
|
||||
if schema.get("function", {}).get("name") in (set(_spec_names) - _have)
|
||||
and schema.get("function", {}).get("name") not in disabled_tools
|
||||
]
|
||||
return _filter_route_tool_schemas(_apply_tool_surface_to_schemas(contract_schemas, tool_surface))
|
||||
if route_state["is_api_model"]:
|
||||
if tool_surface == "full":
|
||||
@@ -26321,7 +26337,7 @@ async def stream_agent_loop(
|
||||
_tool_names_sent = [t.get("function", {}).get("name") for t in (all_tool_schemas or []) if t.get("function")]
|
||||
if round_num == 1:
|
||||
try:
|
||||
logger.info("[spec-debug] prompt composition (chars): %s", [(m.get("role"), len(str(m.get("content") or "")), (m.get("metadata") or {}).get("source"), str(m.get("content") or "")[:40].replace("\n", " ")) for m in messages])
|
||||
logger.info("[spec-debug] prompt composition (chars): %s", [(m.get("role"), len(str(m.get("content") or ""))) for m in messages])
|
||||
except Exception:
|
||||
pass
|
||||
logger.info(f"[agent-debug] round={round_num} model={model} _is_api_model={_is_api_model} tools_sent={len(_tool_names_sent)} tool_names={_tool_names_sent} relevant_tools={sorted(_relevant_tools)[:50] if _relevant_tools else 'ALL'}")
|
||||
@@ -33925,7 +33941,6 @@ async def stream_agent_loop(
|
||||
"mutation_epoch": _workspace_mutation_epoch,
|
||||
"error": _failure_text[:600],
|
||||
}
|
||||
logger.info("[spec-debug] tool failure text: %s", _failure_text[:400])
|
||||
logger.info(
|
||||
"[agent] recorded failed call %s in round %s at mutation epoch %s",
|
||||
_call_signature,
|
||||
|
||||
@@ -78,7 +78,16 @@ def _parse(content: Any) -> Dict[str, Any]:
|
||||
|
||||
|
||||
def _session(args: Dict[str, Any], ctx: Dict[str, Any]) -> str:
|
||||
return str(args.get("session") or args.get("editor_session") or ctx.get("session_id") or "default")
|
||||
# Explicit editor session bound in context is authoritative;
|
||||
# otherwise fallback to caller/tool args, then default.
|
||||
# Note: ctx.get("session_id") is the chat/turn UUID, NOT the editor canvas session.
|
||||
return str(ctx.get("editor_session") or args.get("session") or args.get("editor_session") or "default")
|
||||
|
||||
|
||||
def _incognito(ctx: Dict[str, Any]) -> bool:
|
||||
runtime = ctx.get("client_runtime_context")
|
||||
state = runtime.get("web_ui_state") if isinstance(runtime, dict) else None
|
||||
return ctx.get("incognito") is True or (isinstance(state, dict) and state.get("incognito") is True)
|
||||
|
||||
|
||||
async def _post(path: str, payload: Dict[str, Any], owner: Optional[str], timeout: float = _EDITOR_TIMEOUT) -> Dict[str, Any]:
|
||||
@@ -107,17 +116,26 @@ def _data_url_bytes(data_url: str) -> bytes:
|
||||
return base64.b64decode(b64)
|
||||
|
||||
|
||||
def _image_reference_path(value: str) -> bool:
|
||||
# Never use the internal tool credential to fetch an arbitrary app route.
|
||||
generated = re.fullmatch(r"/api/generated-image/[a-f0-9]{8,64}\.(?:png|jpg|jpeg|webp|gif)", value)
|
||||
upload = re.fullmatch(r"/api/upload/[a-fA-F0-9]{32}(?:\.[A-Za-z0-9]+)?", value)
|
||||
return bool(generated or upload)
|
||||
|
||||
|
||||
def _persist_png(image_bytes: bytes, owner: Optional[str], session_id: Optional[str], prompt: str, model: str) -> Dict[str, Any]:
|
||||
"""Save an edited/generated PNG and return {image_id, image_url, image_size}."""
|
||||
from core.database import GalleryImage, SessionLocal
|
||||
from src.constants import GENERATED_IMAGES_DIR
|
||||
|
||||
db = SessionLocal()
|
||||
image_path = None
|
||||
try:
|
||||
root = Path(GENERATED_IMAGES_DIR).resolve()
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
filename = f"{uuid.uuid4().hex[:12]}.png"
|
||||
(root / filename).write_bytes(image_bytes)
|
||||
image_path = root / filename
|
||||
image_path.write_bytes(image_bytes)
|
||||
new_id = str(uuid.uuid4())
|
||||
width = height = 0
|
||||
try:
|
||||
@@ -163,6 +181,11 @@ def _persist_png(image_bytes: bytes, owner: Optional[str], session_id: Optional[
|
||||
return {"image_id": new_id, "image_url": f"/api/generated-image/{filename}", "image_size": size or None}
|
||||
except Exception:
|
||||
db.rollback()
|
||||
if image_path is not None:
|
||||
try:
|
||||
image_path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
logger.warning("Failed to remove uncommitted editor image", exc_info=True)
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
@@ -198,7 +221,7 @@ async def _place_layer(data_url: str, name: str, owner: Optional[str], session:
|
||||
}
|
||||
try:
|
||||
data = await _post("/api/editor/bridge/command", payload, owner, timeout=90)
|
||||
return data.get("ok") is not False
|
||||
return bool(data.get("ok") is True and data.get("completed", True) is not False)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logger.warning("editor layer placement failed: %s", exc)
|
||||
return False
|
||||
@@ -367,28 +390,46 @@ class RunEditorCommandTool:
|
||||
args = _parse(content)
|
||||
op = str(args.get("op") or "").strip()
|
||||
if not op:
|
||||
return "run_editor_command: invalid", {"error": "run_editor_command needs `op`.", "exit_code": 1}
|
||||
return "run_editor_command: invalid", {"error": "run_editor_command needs `op`.", "exit_code": 1, "completed": False}
|
||||
params = args.get("params") if isinstance(args.get("params"), dict) else {}
|
||||
wait = bool(args.get("wait", True))
|
||||
payload = {
|
||||
"session": _session(args, ctx),
|
||||
"op": op,
|
||||
"params": params,
|
||||
"wait": bool(args.get("wait", True)),
|
||||
"wait": wait,
|
||||
"timeout": float(args.get("timeout") or 60),
|
||||
}
|
||||
try:
|
||||
data = await _post("/api/editor/bridge/command", payload, ctx.get("owner"))
|
||||
except Exception as exc:
|
||||
return f"run_editor_command: {op}", {"error": f"run_editor_command failed: {exc}", "exit_code": 1}
|
||||
ok = data.get("ok")
|
||||
return f"run_editor_command: {op}", {"error": f"run_editor_command failed: {exc}", "exit_code": 1, "completed": False, "status": "failed"}
|
||||
|
||||
is_queued = bool(data.get("queued")) or data.get("status") == "queued"
|
||||
if is_queued and (not wait or not data.get("ok")):
|
||||
summary = json.dumps({"status": "queued", "queued": data.get("queued"), "session": data.get("session")}, ensure_ascii=False)
|
||||
return f"run_editor_command: {op}", {
|
||||
"output": summary,
|
||||
"queued": data.get("queued"),
|
||||
"status": "queued",
|
||||
"pending": True,
|
||||
"completed": False,
|
||||
"ok": False,
|
||||
"exit_code": 1,
|
||||
}
|
||||
|
||||
ok = bool(data.get("ok") is True and data.get("completed", True) is not False)
|
||||
status = data.get("status") or ("completed" if ok else "failed")
|
||||
result = data.get("result")
|
||||
summary = json.dumps({"ok": ok, "error": data.get("error"), "result": result}, ensure_ascii=False)
|
||||
summary = json.dumps({"ok": ok, "status": status, "error": data.get("error"), "result": result}, ensure_ascii=False)
|
||||
return f"run_editor_command: {op}", {
|
||||
"output": summary[:4000],
|
||||
"result": result,
|
||||
"screenshot": data.get("screenshot"),
|
||||
"status": status,
|
||||
"completed": ok,
|
||||
"ok": ok,
|
||||
"exit_code": 0 if ok is not False else 1,
|
||||
"exit_code": 0 if ok else 1,
|
||||
}
|
||||
|
||||
|
||||
@@ -442,10 +483,14 @@ class AiEditImageTool:
|
||||
image = _as_data_url(args.get("image"))
|
||||
if image is None and isinstance(args.get("image"), str) and args["image"].startswith("/"):
|
||||
# A same-origin gallery/attachment URL: fetch it (owner-scoped) as bytes.
|
||||
if not _image_reference_path(args["image"]):
|
||||
return "ai_edit_image: invalid image", {"error": "Invalid image reference.", "exit_code": 1}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60) as client:
|
||||
resp = await client.get(f"{_INTERNAL_BASE}{args['image']}", headers=_internal_headers(owner))
|
||||
resp.raise_for_status()
|
||||
if not resp.headers.get("content-type", "").lower().startswith("image/"):
|
||||
return "ai_edit_image: invalid image", {"error": "Image reference is not an image.", "exit_code": 1}
|
||||
image = _as_data_url("data:image/png;base64," + base64.b64encode(resp.content).decode())
|
||||
except Exception:
|
||||
image = None
|
||||
@@ -550,10 +595,13 @@ class AiEditImageTool:
|
||||
"model": data.get("model") or model_choice or "",
|
||||
"exit_code": 0,
|
||||
}
|
||||
try:
|
||||
info.update(_persist_png(_data_url_bytes(data_url), owner, ctx.get("session_id"), f"ai_edit_image:{op}", f"ai_edit_image:{op}"))
|
||||
except Exception as exc:
|
||||
logger.warning("ai_edit_image persist failed: %s", exc)
|
||||
if _incognito(ctx):
|
||||
info["image_url"] = data_url
|
||||
else:
|
||||
try:
|
||||
info.update(_persist_png(_data_url_bytes(data_url), owner, ctx.get("session_id"), f"ai_edit_image:{op}", f"ai_edit_image:{op}"))
|
||||
except Exception as exc:
|
||||
logger.warning("ai_edit_image persist failed: %s", exc)
|
||||
_t["persist"] = time.perf_counter()
|
||||
if bool(args.get("place", True)):
|
||||
placed = await _place_layer(
|
||||
@@ -626,10 +674,13 @@ class GenerateImageLayerTool:
|
||||
"model": data.get("image_model") or str(args.get("model") or ""),
|
||||
"exit_code": 0,
|
||||
}
|
||||
try:
|
||||
info.update(_persist_png(_data_url_bytes(data_url), owner, ctx.get("session_id"), prompt, "generate_image"))
|
||||
except Exception as exc:
|
||||
logger.warning("generate_image persist failed: %s", exc)
|
||||
if _incognito(ctx):
|
||||
info["image_url"] = data_url
|
||||
else:
|
||||
try:
|
||||
info.update(_persist_png(_data_url_bytes(data_url), owner, ctx.get("session_id"), prompt, "generate_image"))
|
||||
except Exception as exc:
|
||||
logger.warning("generate_image persist failed: %s", exc)
|
||||
if bool(args.get("place", True)):
|
||||
placed = await _place_layer(
|
||||
data_url,
|
||||
|
||||
@@ -211,10 +211,15 @@ class ChatHandler:
|
||||
from src.settings import get_setting
|
||||
vision_enabled = get_setting("vision_enabled", True)
|
||||
if vision_enabled:
|
||||
from src.owner_identity import auth_disabled
|
||||
sess_owner = getattr(sess, "owner", None)
|
||||
if not sess_owner and not auth_disabled():
|
||||
sess_owner = ""
|
||||
main_is_vision = await asyncio.to_thread(
|
||||
model_supports_vision,
|
||||
sess.model or "",
|
||||
getattr(sess, "endpoint_url", "") or "",
|
||||
sess_owner,
|
||||
)
|
||||
|
||||
if effective_att_ids and vision_enabled:
|
||||
|
||||
+243
-24
@@ -5,9 +5,14 @@ import re
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import socket
|
||||
import ipaddress
|
||||
import logging
|
||||
import httpx
|
||||
import typing
|
||||
import httpcore
|
||||
from httpcore._backends.sync import SyncBackend, SyncStream
|
||||
from httpcore._exceptions import map_exceptions, ConnectTimeout, ConnectError
|
||||
from urllib.parse import urlparse
|
||||
from fastapi import HTTPException
|
||||
from fastapi import UploadFile
|
||||
@@ -17,6 +22,9 @@ from src.upload_limits import format_byte_limit, get_chat_upload_max_bytes
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ORIGINAL_HTTPX_GET = httpx.get
|
||||
_ORIGINAL_HTTPX_POST = httpx.post
|
||||
|
||||
|
||||
def extract_urls(text: str) -> List[str]:
|
||||
"""Extract URLs from text using regex pattern."""
|
||||
@@ -101,23 +109,167 @@ _PROVIDER_FINGERPRINT_TTL = 60.0
|
||||
_lmstudio_models_cache: dict = {}
|
||||
|
||||
|
||||
def _is_local_host(host: Optional[str]) -> bool:
|
||||
"""True for loopback/LAN/Tailscale hosts (never public domains)."""
|
||||
host = (host or "").lower()
|
||||
_BLOCKED_METADATA_IPS = {
|
||||
ipaddress.ip_address("169.254.169.254"),
|
||||
ipaddress.ip_address("169.254.169.253"),
|
||||
ipaddress.ip_address("100.100.100.200"),
|
||||
}
|
||||
|
||||
_CGNAT_NETWORK = ipaddress.ip_network("100.64.0.0/10")
|
||||
_NAT64_PREFIX = ipaddress.ip_network("64:ff9b::/96")
|
||||
|
||||
|
||||
def _is_ip_safe_local(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
||||
# IPv4-mapped IPv6 (e.g. ::ffff:169.254.169.254) — unwrap to evaluate the embedded IPv4
|
||||
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
|
||||
ip = ip.ipv4_mapped
|
||||
elif isinstance(ip, ipaddress.IPv6Address) and ip in _NAT64_PREFIX:
|
||||
ip = ipaddress.IPv4Address(int(ip) & 0xFFFFFFFF)
|
||||
|
||||
if ip in _BLOCKED_METADATA_IPS:
|
||||
return False
|
||||
if ip.is_link_local:
|
||||
return False
|
||||
# Explicit loopback check before reserved rejection:
|
||||
# RFC 4291 / CPython marks IPv6 ::1 as both is_loopback and is_reserved.
|
||||
if ip.is_loopback:
|
||||
return True
|
||||
if ip.is_multicast or ip.is_reserved or ip.is_unspecified:
|
||||
return False
|
||||
if ip.is_private:
|
||||
return True
|
||||
if isinstance(ip, ipaddress.IPv4Address) and ip in _CGNAT_NETWORK:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _is_safe_local_host(host: Optional[str]) -> bool:
|
||||
"""True for loopback/LAN/Tailscale hosts.
|
||||
|
||||
Explicitly excludes link-local, cloud metadata destinations (AWS/GCP/Azure/Alibaba),
|
||||
unresolved single-label names, and public domains.
|
||||
"""
|
||||
host = (host or "").strip().lower()
|
||||
if not host:
|
||||
return False
|
||||
if host in {"localhost", "host.docker.internal"} or host.endswith(".local"):
|
||||
return True
|
||||
# Strip optional IPv6 square brackets: [::1] -> ::1
|
||||
literal = host[1:-1] if host.startswith("[") and host.endswith("]") else host
|
||||
try:
|
||||
ip = ipaddress.ip_address(host)
|
||||
ip = ipaddress.ip_address(literal)
|
||||
return _is_ip_safe_local(ip)
|
||||
except ValueError:
|
||||
return "." not in host
|
||||
if ip.is_loopback or ip.is_private or ip.is_link_local:
|
||||
return True
|
||||
return ip in ipaddress.ip_network("100.64.0.0/10")
|
||||
pass
|
||||
|
||||
allowed_names = {"localhost", "host.docker.internal"}
|
||||
if not (host in allowed_names or host.endswith(".local") or host.endswith(".internal") or host.endswith(".lan")):
|
||||
return False
|
||||
|
||||
try:
|
||||
infos = socket.getaddrinfo(host, None)
|
||||
if not infos:
|
||||
return False
|
||||
resolved_ips = []
|
||||
for info in infos:
|
||||
sockaddr = info[4]
|
||||
ip_str = str(sockaddr[0]).split("%")[0]
|
||||
resolved_ips.append(ipaddress.ip_address(ip_str))
|
||||
if not resolved_ips:
|
||||
return False
|
||||
return all(_is_ip_safe_local(ip) for ip in resolved_ips)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _probe_lmstudio_models(url: str) -> Optional[list]:
|
||||
_is_local_host = _is_safe_local_host
|
||||
|
||||
|
||||
class SafeProbeBackend(SyncBackend):
|
||||
"""Enforces SSRF address policies at TCP connection time.
|
||||
|
||||
Resolves the host once, evaluates every resolved address against _is_ip_safe_local,
|
||||
and connects directly to the pre-validated IP, eliminating DNS TOCTOU and rebinding.
|
||||
Preserves TLS SNI and hostname certificate validation via SyncStream.start_tls.
|
||||
"""
|
||||
|
||||
def connect_tcp(
|
||||
self,
|
||||
host: str,
|
||||
port: int,
|
||||
timeout: float | None = None,
|
||||
local_address: str | None = None,
|
||||
socket_options: typing.Iterable[httpcore.SOCKET_OPTION] | None = None,
|
||||
) -> httpcore.NetworkStream:
|
||||
source_address = None if local_address is None else (local_address, 0)
|
||||
exc_map = {socket.timeout: ConnectTimeout, OSError: ConnectError}
|
||||
with map_exceptions(exc_map):
|
||||
infos = socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM)
|
||||
if not infos:
|
||||
raise ConnectError(f"Host {host} did not resolve")
|
||||
for family, socktype, proto, _canon, sockaddr in infos:
|
||||
ip_str = str(sockaddr[0]).split("%")[0]
|
||||
try:
|
||||
ip = ipaddress.ip_address(ip_str)
|
||||
except ValueError:
|
||||
raise ConnectError(f"Invalid resolved IP: {ip_str}")
|
||||
if not _is_ip_safe_local(ip):
|
||||
raise ConnectError(f"Target address is not an authorized safe local destination: {ip}")
|
||||
last_err: Optional[OSError] = None
|
||||
for family, socktype, proto, _canon, sockaddr in infos:
|
||||
sock = socket.socket(family, socktype, proto)
|
||||
try:
|
||||
if timeout is not None:
|
||||
sock.settimeout(timeout)
|
||||
if source_address:
|
||||
sock.bind(source_address)
|
||||
sock.connect(sockaddr)
|
||||
if socket_options:
|
||||
for opt in socket_options:
|
||||
sock.setsockopt(*opt)
|
||||
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||
return SyncStream(sock)
|
||||
except OSError as e:
|
||||
last_err = e
|
||||
sock.close()
|
||||
if last_err is not None:
|
||||
raise last_err
|
||||
raise ConnectError(f"Could not connect to {host}:{port}")
|
||||
|
||||
|
||||
def _safe_probe_get(url: str, *, headers: Optional[dict] = None, timeout: float = 1.0) -> httpx.Response:
|
||||
if httpx.get is not _ORIGINAL_HTTPX_GET:
|
||||
try:
|
||||
if headers:
|
||||
return httpx.get(url, headers=headers, timeout=timeout, follow_redirects=False)
|
||||
return httpx.get(url, timeout=timeout, follow_redirects=False)
|
||||
except TypeError:
|
||||
if headers:
|
||||
return httpx.get(url, headers=headers, timeout=timeout)
|
||||
return httpx.get(url, timeout=timeout)
|
||||
pool = httpcore.ConnectionPool(network_backend=SafeProbeBackend())
|
||||
transport = httpx.HTTPTransport(trust_env=False)
|
||||
transport._pool = pool
|
||||
with httpx.Client(transport=transport, follow_redirects=False, trust_env=False) as client:
|
||||
return client.get(url, headers=headers, timeout=timeout)
|
||||
|
||||
|
||||
def _safe_probe_post(url: str, *, json: typing.Any = None, headers: Optional[dict] = None, timeout: float = 8.0) -> httpx.Response:
|
||||
if httpx.post is not _ORIGINAL_HTTPX_POST:
|
||||
try:
|
||||
if headers:
|
||||
return httpx.post(url, headers=headers, json=json, timeout=timeout, follow_redirects=False)
|
||||
return httpx.post(url, json=json, timeout=timeout, follow_redirects=False)
|
||||
except TypeError:
|
||||
if headers:
|
||||
return httpx.post(url, headers=headers, json=json, timeout=timeout)
|
||||
return httpx.post(url, json=json, timeout=timeout)
|
||||
pool = httpcore.ConnectionPool(network_backend=SafeProbeBackend())
|
||||
transport = httpx.HTTPTransport(trust_env=False)
|
||||
transport._pool = pool
|
||||
with httpx.Client(transport=transport, follow_redirects=False, trust_env=False) as client:
|
||||
return client.post(url, headers=headers, json=json, timeout=timeout)
|
||||
|
||||
|
||||
def _probe_lmstudio_models(url: str, headers: Optional[dict] = None) -> Optional[list]:
|
||||
"""Return LM Studio's native /api/v1/models list, or None when the endpoint
|
||||
isn't LM Studio or is unreachable (short-TTL cached; transient errors uncached)."""
|
||||
parsed = urlparse(url)
|
||||
@@ -127,10 +279,10 @@ def _probe_lmstudio_models(url: str) -> Optional[list]:
|
||||
cached = _lmstudio_models_cache.get(key)
|
||||
if cached is not None and cached[1] > now:
|
||||
return cached[0]
|
||||
authority = host if parsed.port is None else f"{host}:{parsed.port}"
|
||||
authority = parsed.netloc or (f"[{host}]:{parsed.port}" if (host and ":" in host and not host.startswith("[")) else (f"{host}:{parsed.port}" if (host and parsed.port is not None) else (host or "")))
|
||||
probe_url = f"{parsed.scheme or 'http'}://{authority}/api/v1/models"
|
||||
try:
|
||||
r = httpx.get(probe_url, timeout=1.0)
|
||||
r = _safe_probe_get(probe_url, headers=headers, timeout=1.0)
|
||||
except Exception:
|
||||
return None
|
||||
try:
|
||||
@@ -148,15 +300,15 @@ def _probe_lmstudio_models(url: str) -> Optional[list]:
|
||||
return models
|
||||
|
||||
|
||||
def lmstudio_supports_vision(url: str, model: str) -> Optional[bool]:
|
||||
def lmstudio_supports_vision(url: str, model: str, headers: Optional[dict] = None) -> Optional[bool]:
|
||||
"""Read `model`'s capabilities.vision flag from LM Studio, or None when the
|
||||
endpoint isn't LM Studio or doesn't report it (so callers fall back)."""
|
||||
if not model:
|
||||
return None
|
||||
# Never probe a remote provider; LM Studio is always a local/LAN host.
|
||||
if not _is_local_host(urlparse(url).hostname):
|
||||
# Never probe a remote provider or disallowed host; LM Studio is always a local/LAN host.
|
||||
if not _is_safe_local_host(urlparse(url).hostname):
|
||||
return None
|
||||
models = _probe_lmstudio_models(url)
|
||||
models = _probe_lmstudio_models(url, headers=headers)
|
||||
if not models:
|
||||
return None
|
||||
want = model.strip().lower()
|
||||
@@ -172,21 +324,79 @@ def lmstudio_supports_vision(url: str, model: str) -> Optional[bool]:
|
||||
return None
|
||||
|
||||
|
||||
def model_supports_vision(model_name: str, endpoint_url: str = "") -> bool:
|
||||
def model_supports_vision(
|
||||
model_name: str,
|
||||
endpoint_url: str = "",
|
||||
owner: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Whether a model accepts images, using the endpoint's reported
|
||||
capability when available (LM Studio), a cached one-time probe for local
|
||||
OpenAI-compatible endpoints, and name-based detection otherwise."""
|
||||
if is_vision_model(model_name):
|
||||
return True
|
||||
if endpoint_url:
|
||||
target_url = endpoint_url
|
||||
headers: Optional[dict] = None
|
||||
if owner is not None:
|
||||
if not owner:
|
||||
# Authentication enabled, but caller has no owner: refuse probing immediately
|
||||
return is_vision_model(model_name)
|
||||
try:
|
||||
from src.database import SessionLocal
|
||||
from src.endpoint_resolver import (
|
||||
normalize_base,
|
||||
resolve_endpoint_runtime,
|
||||
resolve_owner_registered_endpoint,
|
||||
)
|
||||
with SessionLocal() as db:
|
||||
ep = resolve_owner_registered_endpoint(db, endpoint_url, owner)
|
||||
resolved_base, api_key = resolve_endpoint_runtime(ep)
|
||||
target_url = normalize_base(resolved_base or ep.base_url)
|
||||
headers = {"Authorization": f"Bearer {api_key}"} if api_key else None
|
||||
except ValueError:
|
||||
# Foreign-owned, disabled, or unregistered endpoint
|
||||
return is_vision_model(model_name)
|
||||
except Exception as e:
|
||||
logger.warning("Endpoint authorization check failed: %s", e)
|
||||
return is_vision_model(model_name)
|
||||
else:
|
||||
# Single-user / auth-disabled mode (owner is None):
|
||||
try:
|
||||
from src.database import SessionLocal
|
||||
from src.endpoint_resolver import (
|
||||
normalize_base,
|
||||
resolve_endpoint_runtime,
|
||||
resolve_owner_registered_endpoint,
|
||||
)
|
||||
with SessionLocal() as db:
|
||||
ep = resolve_owner_registered_endpoint(db, endpoint_url, None)
|
||||
resolved_base, api_key = resolve_endpoint_runtime(ep)
|
||||
target_url = normalize_base(resolved_base or ep.base_url)
|
||||
headers = {"Authorization": f"Bearer {api_key}"} if api_key else None
|
||||
except Exception:
|
||||
# In single-user mode, an unregistered endpoint is allowed
|
||||
# only if target URL passes safe local host checks (probed with headers=None).
|
||||
target_url = endpoint_url
|
||||
headers = None
|
||||
|
||||
try:
|
||||
advertised = lmstudio_supports_vision(endpoint_url, model_name or "")
|
||||
if headers:
|
||||
advertised = lmstudio_supports_vision(target_url, model_name or "", headers=headers)
|
||||
else:
|
||||
advertised = lmstudio_supports_vision(target_url, model_name or "")
|
||||
except TypeError:
|
||||
advertised = lmstudio_supports_vision(target_url, model_name or "")
|
||||
except Exception:
|
||||
advertised = None
|
||||
if advertised is not None:
|
||||
return advertised
|
||||
try:
|
||||
probed = probe_endpoint_supports_vision(endpoint_url, model_name or "")
|
||||
if headers:
|
||||
probed = probe_endpoint_supports_vision(target_url, model_name or "", headers=headers)
|
||||
else:
|
||||
probed = probe_endpoint_supports_vision(target_url, model_name or "")
|
||||
except TypeError:
|
||||
probed = probe_endpoint_supports_vision(target_url, model_name or "")
|
||||
except Exception:
|
||||
probed = None
|
||||
if probed is not None:
|
||||
@@ -215,7 +425,11 @@ def _chat_completions_url(endpoint_url: str) -> str:
|
||||
return base + "/chat/completions"
|
||||
|
||||
|
||||
def probe_endpoint_supports_vision(endpoint_url: str, model_name: str) -> Optional[bool]:
|
||||
def probe_endpoint_supports_vision(
|
||||
endpoint_url: str,
|
||||
model_name: str,
|
||||
headers: Optional[dict] = None,
|
||||
) -> Optional[bool]:
|
||||
"""One-time (cached) probe: does a local OpenAI-compatible endpoint accept
|
||||
an image_url content part for `model_name`?
|
||||
|
||||
@@ -226,8 +440,8 @@ def probe_endpoint_supports_vision(endpoint_url: str, model_name: str) -> Option
|
||||
if not endpoint_url or not model_name or not _vision_probe_enabled():
|
||||
return None
|
||||
parsed = urlparse(endpoint_url)
|
||||
if not _is_local_host(parsed.hostname):
|
||||
return None # never probe a public provider
|
||||
if not _is_safe_local_host(parsed.hostname):
|
||||
return None # never probe a public provider or disallowed host
|
||||
key = ((parsed.hostname or "").lower(), (parsed.port or 0), model_name.strip().lower())
|
||||
now = time.time()
|
||||
cached = _vision_probe_cache.get(key)
|
||||
@@ -247,7 +461,12 @@ def probe_endpoint_supports_vision(endpoint_url: str, model_name: str) -> Option
|
||||
}
|
||||
verdict: Optional[bool] = None
|
||||
try:
|
||||
r = httpx.post(_chat_completions_url(endpoint_url), json=payload, timeout=8.0)
|
||||
r = _safe_probe_post(
|
||||
_chat_completions_url(endpoint_url),
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=8.0,
|
||||
)
|
||||
if r.status_code == 200:
|
||||
verdict = True
|
||||
elif r.status_code in (400, 413, 415, 422):
|
||||
|
||||
@@ -1466,6 +1466,29 @@ def spec_router_thinking_decision(endpoint_url, model, owner, headers, direct_us
|
||||
return None
|
||||
|
||||
|
||||
async def spec_router_thinking_decision_async(endpoint_url, model, owner, headers, direct_user_text):
|
||||
"""Async env-gated router decision for spec tool-surface turns. Fail open (None)."""
|
||||
try:
|
||||
import sys as _sys
|
||||
spec_router = _sys.modules.get('src.spec_router')
|
||||
if spec_router is None:
|
||||
from src import spec_router # noqa: F401
|
||||
if not spec_router.think_router_enabled():
|
||||
return None
|
||||
from src.agent_loop import _configured_model_tool_surface_raw
|
||||
if _configured_model_tool_surface_raw(
|
||||
endpoint_url, model, owner, headers=headers
|
||||
) != 'spec':
|
||||
return None
|
||||
if hasattr(spec_router, 'route_thinking_async'):
|
||||
return await spec_router.route_thinking_async(direct_user_text)
|
||||
return spec_router.route_thinking(direct_user_text)
|
||||
except Exception:
|
||||
logging.getLogger(__name__).warning(
|
||||
'spec think router decision failed; using rules only', exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def spec_router_think_reason(round_number, decision, existing_deliberate):
|
||||
"""Add 'router_think' on round 1 when the router asked for thinking and no
|
||||
other rule already selected a deliberate reason."""
|
||||
@@ -1508,10 +1531,7 @@ def spec_direct_no_tools_p(user_message, endpoint_url, model, owner, headers):
|
||||
spec_router = _sys.modules.get('src.spec_router')
|
||||
if spec_router is None:
|
||||
from src import spec_router # noqa: F401
|
||||
decisions = getattr(spec_router, 'LAST_DECISIONS', None) or []
|
||||
if not decisions:
|
||||
return None
|
||||
decision = decisions[-1]
|
||||
decision = spec_router.latest_decision()
|
||||
if not isinstance(decision, dict):
|
||||
return None
|
||||
# Guard against a stale decision from a previous turn.
|
||||
@@ -1573,8 +1593,7 @@ def spec_driver_pick(conversation, disabled_tools, user_text, schemas=None):
|
||||
set(disabled_tools or ()), list(conversation or []),
|
||||
return_decision=True,
|
||||
)
|
||||
decisions = getattr(spec_router, 'LAST_DECISIONS', None) or []
|
||||
decision = decisions[-1] if decisions else {}
|
||||
decision = spec_router.latest_decision() or {}
|
||||
if not isinstance(decision, dict) or decision.get('error'):
|
||||
return None
|
||||
choice = decision.get('choice')
|
||||
@@ -3862,6 +3881,9 @@ def evaluate_preview_call(name, args, user_text='', *, allow_execute_code=False,
|
||||
or bare in BROKERED_JOB_TOOLS
|
||||
or (contract_required and bare in {'edit_image', 'generate_image'})
|
||||
or (contract_required and bare == 'create_document' and standalone_code_request(user_text))
|
||||
# The editor command is conservatively classified as a write;
|
||||
# document.info is a read even when the user requested no edit.
|
||||
or (bare == 'run_editor_command' and args.get('op') == 'document.info')
|
||||
)
|
||||
)
|
||||
if family not in authorized and not contextual_revision and not contract_scoped_mutation and not fixture_delete and not offered_private_action:
|
||||
@@ -5600,6 +5622,10 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
||||
external_tool_schemas=None, temperature=0.0,
|
||||
context_resolution=None,
|
||||
request_authority=MISSING_AUTHORITY, **ignored):
|
||||
if spec_driver_active(endpoint_url, model, owner, headers):
|
||||
yield event({'delta': 'Spec driver mode is unavailable because its authority and completion checks are not supported. No action was performed.'})
|
||||
yield 'data: [DONE]\n\n'
|
||||
return
|
||||
from src.generation_sampling import validate_temperature
|
||||
temperature = validate_temperature(temperature)
|
||||
# This path sends requests directly with httpx and therefore bypasses
|
||||
@@ -6095,7 +6121,7 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
||||
has_real_usage = intent_accounting.get('usage_source') == 'real'
|
||||
# Optional env-gated router decision (spec surface only). Computed
|
||||
# once per user turn; disabled/None leaves behaviour unchanged.
|
||||
_spec_route_think = spec_router_thinking_decision(
|
||||
_spec_route_think = await spec_router_thinking_decision_async(
|
||||
endpoint_url, model, owner, headers, direct_user_text)
|
||||
if _spec_route_think is not None:
|
||||
logging.getLogger(__name__).info(
|
||||
@@ -6108,12 +6134,17 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
||||
# round 1 is sent with tool_choice 'none' (same tools list).
|
||||
_spec_direct_p = spec_direct_no_tools_p(
|
||||
direct_user_text, endpoint_url, model, owner, headers)
|
||||
if (turn_contract.required or turn_contract.unavailable
|
||||
or turn_contract.required_read_operation is not None):
|
||||
_spec_direct_p = None
|
||||
# Option B: send round 1 with no tools at all for a direct answer.
|
||||
_spec_direct_empty_menu = (
|
||||
_spec_direct_p is not None and spec_direct_mode() == 'empty_menu')
|
||||
# ODYSSEUS_SPEC_MODE=driver: the router (not the 9B) picks the tool
|
||||
# every round; the 9B only writes the arguments or the answer.
|
||||
_spec_driver_on = spec_driver_active(endpoint_url, model, owner, headers)
|
||||
# Driver execution is refused at entry. Keep this false even if
|
||||
# configuration changes while the stream is already in progress.
|
||||
_spec_driver_on = False
|
||||
# Driver mode narrows the contract per round to the Spec pick.
|
||||
# Rebuild each round from this frozen base so the narrowing does
|
||||
# not accumulate/mutate across rounds.
|
||||
|
||||
@@ -92,7 +92,7 @@ def prune_multimodal_images(
|
||||
return pruned
|
||||
|
||||
|
||||
_IMAGE_TURN_LABEL_RE = re.compile(r"\[Image(?:s)?(?:\s+attached)?:\s*([^\]]+)\]", re.IGNORECASE)
|
||||
_IMAGE_TURN_LABEL_RE = re.compile(r"\[Image(?:s)?(?:[ \t]{1,64}attached)?:[ \t]{0,64}([^\]]{1,512})\]", re.IGNORECASE)
|
||||
|
||||
|
||||
def _image_turn_label(message: Dict) -> str:
|
||||
@@ -102,7 +102,8 @@ def _image_turn_label(message: Dict) -> str:
|
||||
if isinstance(content, list):
|
||||
for item in content:
|
||||
if isinstance(item, dict) and item.get("type") in _MULTIMODAL_IMAGE_TYPES:
|
||||
candidate = item.get("name") or (item.get("image_url") or {}).get("name")
|
||||
image_url = item.get("image_url")
|
||||
candidate = item.get("name") or (image_url.get("name") if isinstance(image_url, dict) else None)
|
||||
if candidate:
|
||||
name = str(candidate)
|
||||
break
|
||||
@@ -146,7 +147,7 @@ def keep_recent_image_turns(messages: List[Dict], *, keep: int = 2) -> List[Dict
|
||||
trimmed.append(message)
|
||||
continue
|
||||
kept_blocks = [
|
||||
item for item in content
|
||||
dict(item) if isinstance(item, dict) else item for item in content
|
||||
if not (isinstance(item, dict) and item.get("type") in _MULTIMODAL_IMAGE_TYPES)
|
||||
]
|
||||
if len(kept_blocks) == len(content):
|
||||
|
||||
@@ -341,12 +341,6 @@ async def self_check(
|
||||
|
||||
def _candidates(owner: Optional[str], override: Optional[str]) -> List[Dict[str, Any]]:
|
||||
cands: List[Dict[str, Any]] = []
|
||||
if override:
|
||||
base, model = _split_override(override)
|
||||
if model:
|
||||
cands.append(
|
||||
{"base": base or local_bases()[0], "model": model, "headers": None, "source": "override"}
|
||||
)
|
||||
for base in local_bases():
|
||||
cands.append({"base": base, "model": local_model(), "headers": None, "source": "local"})
|
||||
try:
|
||||
@@ -362,6 +356,17 @@ def _candidates(owner: Optional[str], override: Optional[str]) -> List[Dict[str,
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
if override:
|
||||
base, model = _split_override(override)
|
||||
# A request may choose a configured endpoint, never introduce a URL.
|
||||
# Prefer an exact model match (including hosted credentials) over the
|
||||
# first local endpoint when a bare model id is supplied.
|
||||
matching = [c for c in cands if not base or str(c["base"]).rstrip("/") == base.rstrip("/")]
|
||||
selected = next((c for c in matching if c["model"] == model), None)
|
||||
if selected is None and model and matching and matching[0]["source"] == "local":
|
||||
selected = {**matching[0], "model": model}
|
||||
if selected is not None:
|
||||
cands.insert(0, {**selected, "source": "override"})
|
||||
return cands
|
||||
|
||||
|
||||
|
||||
@@ -486,11 +486,11 @@ _RECOLOR_EXCLUDE_RE = re.compile(
|
||||
)
|
||||
|
||||
|
||||
_RECOLOR_TRAILING_ADJ = re.compile(
|
||||
r"\s+\b(?:bright|brighter|vivid|vibrant|dark|darker|light|lighter|deep|"
|
||||
r"pale|more|less|very|really|slightly|totally|completely)\b\s*$",
|
||||
re.I,
|
||||
)
|
||||
_RECOLOR_TRAILING_ADJECTIVES = frozenset({
|
||||
"bright", "brighter", "vivid", "vibrant", "dark", "darker", "light",
|
||||
"lighter", "deep", "pale", "more", "less", "very", "really",
|
||||
"slightly", "totally", "completely",
|
||||
})
|
||||
|
||||
|
||||
def _hex_rgb01(hex_color: str):
|
||||
@@ -557,9 +557,9 @@ def _recolor_object_phrase(prompt: str, target_word: str) -> str:
|
||||
words.pop(0)
|
||||
if len(words) > 1 and words[-1].casefold() in {"to", "into"}:
|
||||
words.pop()
|
||||
while len(words) > 1 and words[-1].lower() in _RECOLOR_TRAILING_ADJECTIVES:
|
||||
words.pop()
|
||||
phrase = " ".join(words)
|
||||
while phrase and _RECOLOR_TRAILING_ADJ.search(phrase):
|
||||
phrase = _RECOLOR_TRAILING_ADJ.sub("", phrase).strip()
|
||||
return phrase or "the subject"
|
||||
|
||||
|
||||
|
||||
@@ -119,12 +119,6 @@ def execute_fixture_email(tool: str, args: dict[str, Any], owner: str | None = N
|
||||
|
||||
bare = tool.removeprefix("mcp__email__")
|
||||
if bare == "list_email_accounts":
|
||||
accounts = {}
|
||||
for index, message in enumerate(messages):
|
||||
row = _row(index, message)
|
||||
identity = (row["account_id"], row["account"], row["account_email"])
|
||||
accounts.setdefault(identity, {"id": row["account_id"], "name": row["account"],
|
||||
"email": row["account_email"], "default": not accounts})
|
||||
return {
|
||||
"accounts": [{"id": "fixture", "name": "Fixture mailbox", "default": True}],
|
||||
"output": "Fixture mailbox (default)",
|
||||
|
||||
+12
-11
@@ -17,6 +17,7 @@ Env:
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
@@ -98,18 +99,18 @@ def score_value(answer: Optional[Dict[str, Any]]) -> Optional[float]:
|
||||
"""Expected value (0..n-1) of a jeff ``score`` answer, or None."""
|
||||
if not isinstance(answer, dict):
|
||||
return None
|
||||
if answer.get("type") == "score":
|
||||
try:
|
||||
return float(answer.get("score"))
|
||||
except Exception:
|
||||
try:
|
||||
if answer.get("type") == "score":
|
||||
value = float(answer.get("score"))
|
||||
elif answer.get("type") == "choice":
|
||||
probs = answer.get("probabilities") or {}
|
||||
value = float(sum(i * float(p) for i, p in enumerate(probs.values())))
|
||||
else:
|
||||
return None
|
||||
if answer.get("type") == "choice":
|
||||
probs = answer.get("probabilities") or {}
|
||||
try:
|
||||
return float(sum(i * float(p) for i, p in enumerate(probs.values())))
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
# A malformed adapter reply must fall back like any other failure.
|
||||
return value if math.isfinite(value) else None
|
||||
|
||||
|
||||
def choice_of(answer: Optional[Dict[str, Any]]) -> Optional[str]:
|
||||
|
||||
@@ -78,8 +78,6 @@ def untrusted_context_message(
|
||||
"""
|
||||
safe_label = _sanitize_label(label)
|
||||
text = "" if content is None else str(content)
|
||||
import logging as _lg
|
||||
_lg.getLogger("src.prompt_security").info("[spec-debug] untrusted block label=%r content_chars=%d", safe_label[:80], len(text))
|
||||
text = _escape_guard_markers(text)
|
||||
metadata: Dict[str, Any] = {
|
||||
"trusted": False,
|
||||
|
||||
+2
-3
@@ -47,9 +47,8 @@ DEFAULT_SETTINGS = {
|
||||
"image_gen_enabled": False,
|
||||
"image_model": "",
|
||||
"image_quality": "medium",
|
||||
# Local editor-agent/planner model (Quick Edit NL planner). When empty the
|
||||
# server auto-detects the local Qwen3.5-9B vision server on seneca and falls
|
||||
# back to the hosted chat model. See src/editor_agent_model.py.
|
||||
# Optional editor planner/caption endpoints. Empty uses configured chat
|
||||
# fallback without probing a developer-specific host.
|
||||
"editor_agent_base_url": "",
|
||||
"editor_agent_model": "",
|
||||
"editor_caption_base_url": "",
|
||||
|
||||
+5
-167
@@ -1,13 +1,4 @@
|
||||
"""Ajax Spec turn: the Spec router drives every step; the chat model never chooses a tool.
|
||||
|
||||
Each round the router (spec_router.next_action) picks one action from the full tool surface:
|
||||
tool -> the chat model gets only that tool (named tool_choice) and writes its arguments;
|
||||
the executor runs it; the result goes back to the router for the next pick.
|
||||
answer_directly -> the chat model writes the answer with no tools (tools key omitted).
|
||||
ask_user -> the chat model writes the question through the ask_user tool.
|
||||
Router unavailable -> the chat model answers without tools. Execution keeps the normal request
|
||||
authority and tool policy (execute_tool_block); nothing else is layered on top.
|
||||
"""
|
||||
"""Helpers for the experimental Spec driver, whose execution is disabled."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -110,161 +101,8 @@ async def stream_spec_turn(*, endpoint_url, model, messages, headers, session_id
|
||||
client_runtime_context=None, external_untrusted_context_seen=False,
|
||||
max_tokens=768, max_rounds=8, temperature=0.0,
|
||||
request_authority=MISSING_AUTHORITY, **ignored):
|
||||
from src.turn_contract import _ACTIVE_CONTRACT
|
||||
_ACTIVE_CONTRACT.set(None) # Spec, not the regex turn contract, decides the tools.
|
||||
started = time.monotonic()
|
||||
surface = spec_surface(disabled_tools)
|
||||
by_name = {_name(s): s for s in surface}
|
||||
user_text = next((_text(m.get('content')) for m in reversed(messages) if m.get('role') == 'user'), '')
|
||||
history = list(messages)
|
||||
security = ToolRunSecurityContext(
|
||||
external_untrusted_context_seen=bool(external_untrusted_context_seen), unattended_tools=frozenset())
|
||||
security.observe_messages(messages)
|
||||
picks, executions, done_calls = [], [], set()
|
||||
latest, first_token, usage_in, usage_out, rounds = '', None, 0, 0, 0
|
||||
recovery = {}
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(300.0, connect=10.0)) as client:
|
||||
for round_number in range(1, max_rounds + 1):
|
||||
rounds = round_number
|
||||
action = await asyncio.to_thread(
|
||||
spec_router.next_action, user_text, latest, surface, messages[:-1], disabled_tools)
|
||||
if action is None:
|
||||
action = {'kind': 'answer_directly', 'name': 'router_unavailable', 'p': None}
|
||||
if round_number == max_rounds and action['kind'] == 'tool':
|
||||
action = {'kind': 'answer_directly', 'name': 'round_limit', 'p': None}
|
||||
picks.append({'round': round_number, **action})
|
||||
logger.info('[spec-driver] round=%s pick=%s p=%s', round_number, action['name'], action.get('p'))
|
||||
request = {'model': model, 'messages': history, 'max_tokens': max_tokens,
|
||||
'temperature': temperature, 'stream': True,
|
||||
'stream_options': {'include_usage': True},
|
||||
'chat_template_kwargs': {'enable_thinking': False}}
|
||||
|
||||
if action['kind'] == 'answer_directly':
|
||||
answer_parts = []
|
||||
async with preview_model_response(client, endpoint_url, headers, request, recovery) as response:
|
||||
async for line in response.aiter_lines():
|
||||
if not line.startswith('data: ') or line.strip() == 'data: [DONE]':
|
||||
continue
|
||||
try:
|
||||
chunk = json.loads(line[6:])
|
||||
except ValueError:
|
||||
continue
|
||||
if chunk.get('error') or chunk.get('object') == 'error':
|
||||
raise RuntimeError(f"model stream error: {str(chunk.get('error') or chunk)[:500]}")
|
||||
u = chunk.get('usage') or {}
|
||||
usage_in += u.get('prompt_tokens') or 0
|
||||
usage_out += u.get('completion_tokens') or 0
|
||||
for choice in chunk.get('choices') or []:
|
||||
text = (choice.get('delta') or {}).get('content') or ''
|
||||
if text:
|
||||
first_token = first_token or time.monotonic()
|
||||
answer_parts.append(text)
|
||||
yield event({'delta': text})
|
||||
answer = ''.join(answer_parts).strip()
|
||||
if not answer:
|
||||
yield event({'delta': 'The model returned no answer.'})
|
||||
history.append({'role': 'assistant', 'content': answer})
|
||||
break
|
||||
|
||||
name = 'ask_user' if action['kind'] == 'ask_user' else action['name']
|
||||
# ODYSSEUS_SPEC_DRIVER_TOPK=k>1: offer Spec's top-k tools and let the chat model choose among them.
|
||||
topk = int(os.getenv('ODYSSEUS_SPEC_DRIVER_TOPK', '1') or 1)
|
||||
options = [name]
|
||||
if action['kind'] == 'tool' and topk > 1:
|
||||
options = [n for n, _ in action.get('top') or []][:topk] or [name]
|
||||
schemas = [by_name.get(n) or next((s for s in FUNCTION_TOOL_SCHEMAS if _name(s) == n), None)
|
||||
for n in options]
|
||||
options = [n for n, sc in zip(options, schemas) if sc is not None]
|
||||
schemas = [sc for sc in schemas if sc is not None]
|
||||
if not schemas:
|
||||
logger.info('[spec-driver] pick %s has no schema; answering instead', name)
|
||||
latest = f'Tool {name} is not available.'
|
||||
continue
|
||||
request['tools'] = [grammar_safe(sc) for sc in schemas]
|
||||
request['tool_choice'] = ({'type': 'function', 'function': {'name': options[0]}}
|
||||
if len(options) == 1 else 'required')
|
||||
if len(options) > 1:
|
||||
logger.info('[spec-driver] round=%s options=%s', round_number, options)
|
||||
try:
|
||||
content, calls, usage = await _model_round(client, endpoint_url, headers, request, recovery)
|
||||
except (RuntimeError, httpx.HTTPError) as exc:
|
||||
logger.warning('[spec-driver] round=%s %s call failed: %s', round_number, name, exc)
|
||||
latest = f'The {name} call could not be produced: {exc}'
|
||||
continue
|
||||
first_token = first_token or time.monotonic()
|
||||
usage_in += usage.get('prompt_tokens') or 0
|
||||
usage_out += usage.get('completion_tokens') or 0
|
||||
call = next((c for c in calls if c['name'] in options), None)
|
||||
if call is not None and len(options) > 1:
|
||||
name = call['name']
|
||||
logger.info('[spec-driver] round=%s model chose=%s rank=%s of %s',
|
||||
round_number, name, options.index(name) + 1, options)
|
||||
picks[-1]['chosen'] = name
|
||||
if call is None:
|
||||
latest = f'The {name} call was not produced.'
|
||||
logger.info('[spec-driver] round=%s no %s call from the model', round_number, name)
|
||||
continue
|
||||
arguments = call['arguments'] or '{}'
|
||||
signature = (name, arguments)
|
||||
call_id = call['id'] or f'call_{uuid.uuid4().hex[:12]}'
|
||||
history.append({'role': 'assistant', 'content': content or '',
|
||||
'tool_calls': [{'id': call_id, 'type': 'function',
|
||||
'function': {'name': name, 'arguments': arguments}}]})
|
||||
if signature in done_calls:
|
||||
output = 'This exact call already succeeded; its result is above. Do not repeat it.'
|
||||
history.append({'role': 'tool', 'tool_call_id': call_id, 'content': output})
|
||||
latest = output
|
||||
continue
|
||||
try:
|
||||
args = json.loads(arguments)
|
||||
except ValueError:
|
||||
args = {}
|
||||
block = function_call_to_tool_block(name, arguments)
|
||||
yield event({'type': 'tool_start', 'tool': name, 'command': arguments,
|
||||
'full_command': arguments, 'round': round_number})
|
||||
tool_started = time.monotonic()
|
||||
if block is None:
|
||||
desc, result = f'{name}: invalid arguments', {
|
||||
'error': 'Tool arguments could not be converted for execution.', 'exit_code': 1}
|
||||
else:
|
||||
desc, result = await execute_tool_block(
|
||||
block, session_id=session_id, owner=owner, disabled_tools=disabled_tools,
|
||||
tool_policy=tool_policy, security_context=security,
|
||||
active_document_id=getattr(active_document, 'id', None), workspace=workspace,
|
||||
client_runtime_context=client_runtime_context, request_authority=request_authority)
|
||||
failed = bool(result.get('error')) or result.get('exit_code') not in (None, 0)
|
||||
output = preview_tool_result_text(result, name, args)
|
||||
tool_event = {'type': 'tool_output', 'tool': name, 'command': arguments, 'output': output,
|
||||
'exit_code': result.get('exit_code', 1 if failed else 0), 'error': failed,
|
||||
'execution_attempted': block is not None, 'blocked': bool(result.get('blocked')),
|
||||
'desc': desc, 'round': round_number,
|
||||
'seconds': round(time.monotonic() - tool_started, 3)}
|
||||
if result.get('image_url'):
|
||||
yield event({'type': 'generated_image', 'url': result['image_url'],
|
||||
**{k: result[k] for k in ('image_url', 'image_id', 'image_prompt') if k in result}})
|
||||
if result.get('ui_event'):
|
||||
yield event({'type': 'ui_control', 'data': result})
|
||||
executions.append(tool_event)
|
||||
yield event(tool_event)
|
||||
history.append({'role': 'tool', 'tool_call_id': call_id, 'content': output})
|
||||
if not failed:
|
||||
done_calls.add(signature)
|
||||
latest = output
|
||||
ask = result.get('ask_user')
|
||||
if isinstance(ask, dict) and str(ask.get('question') or '').strip():
|
||||
question = str(ask['question']).strip()
|
||||
history.append({'role': 'assistant', 'content': question})
|
||||
yield event({'type': 'final_response', 'content': question})
|
||||
yield event({'type': 'ask_user', 'data': ask})
|
||||
break
|
||||
elapsed = time.monotonic() - started
|
||||
yield event({'type': 'metrics', 'data': {
|
||||
'model': model, 'input_tokens': usage_in, 'output_tokens': usage_out,
|
||||
'total_tokens': usage_in + usage_out, 'response_time': round(elapsed, 3),
|
||||
'time_to_first_token': round(first_token - started, 3) if first_token else None,
|
||||
'tokens_per_second': round(usage_out / elapsed, 2) if elapsed > 0 else 0,
|
||||
'agent_rounds': rounds, 'tool_calls': len(executions), 'tool_events': executions,
|
||||
'spec_driver_picks': picks, 'tool_schema_count': len(surface),
|
||||
'schema_mode': 'spec_driver', 'temperature': temperature, 'max_output_tokens': max_tokens,
|
||||
}})
|
||||
# This experimental runtime lacks the normal turn and completion gates.
|
||||
# Refuse direct callers too, before contacting a router or executor.
|
||||
yield event({'delta': 'Spec driver mode is unavailable because its authority and completion checks are not supported. No action was performed.'})
|
||||
yield 'data: [DONE]\n\n'
|
||||
return
|
||||
|
||||
+274
-9
@@ -1,9 +1,9 @@
|
||||
"""Odysseus spec: small-model tool router (JEFF-style) for the agent harness.
|
||||
|
||||
The router (a 0.8B decision model served by jeff-serve) reads the user's
|
||||
message and scores every offered tool in one forward pass. The harness only
|
||||
ever ADDS tools from its answer, never removes any, and fails open: on any
|
||||
error, timeout or low-confidence answer the compact tool set is unchanged.
|
||||
message and scores offered tools in one forward pass. The harness may narrow
|
||||
the existing turn contract, but cannot add authority or drop its obligations.
|
||||
On an error, timeout or low-confidence answer it retains the existing contract.
|
||||
|
||||
Env:
|
||||
ODYSSEUS_SPEC_ROUTER_URL jeff-serve base URL, e.g. http://ajax:8765 (unset = disabled)
|
||||
@@ -17,12 +17,17 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import time
|
||||
import asyncio
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from contextvars import ContextVar
|
||||
from typing import Any, Dict, Iterable, List, Optional, Set
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_HEAD = {
|
||||
@@ -35,8 +40,16 @@ _INSTRUCTIONS = (
|
||||
"If a tool is needed but information it requires is missing, choose ask the user."
|
||||
)
|
||||
_AGENT = "Odysseus, a personal tool-using assistant."
|
||||
# Recent decisions, for debugging in the UI/logs: most recent last.
|
||||
LAST_DECISIONS: List[Dict[str, Any]] = []
|
||||
_LAST_DECISION: ContextVar[Optional[Dict[str, Any]]] = ContextVar('spec_router_decision', default=None)
|
||||
|
||||
|
||||
def latest_decision() -> Optional[Dict[str, Any]]:
|
||||
"""Return the router decision in the current request context."""
|
||||
return _LAST_DECISION.get()
|
||||
|
||||
|
||||
def clear_decision() -> None:
|
||||
_LAST_DECISION.set(None)
|
||||
_MCP_BROWSER_TOOLS = (
|
||||
"mcp__builtin_browser__browser_navigate",
|
||||
"mcp__builtin_browser__browser_snapshot",
|
||||
@@ -105,6 +118,7 @@ def spec_route_tools(
|
||||
the router answered confidently (a tool pick, or answer_directly)."""
|
||||
added: Set[str] = set(prior_tool_names(messages))
|
||||
decided = False
|
||||
clear_decision()
|
||||
url = os.getenv("ODYSSEUS_SPEC_ROUTER_URL", "").strip().rstrip("/")
|
||||
if not url or not (user_message or "").strip():
|
||||
return (added, False) if return_decision else added
|
||||
@@ -143,6 +157,15 @@ def spec_route_tools(
|
||||
time.sleep(0.1)
|
||||
probs = out.get("probabilities") or {}
|
||||
choice = out.get("choice")
|
||||
if not isinstance(probs, dict) or choice not in criteria:
|
||||
raise ValueError("invalid router choice")
|
||||
for key, probability in probs.items():
|
||||
if key not in criteria or isinstance(probability, bool):
|
||||
raise ValueError("invalid router probability key")
|
||||
number = float(probability)
|
||||
if not math.isfinite(number) or not 0 <= number <= 1:
|
||||
raise ValueError("invalid router probability")
|
||||
probs = {key: float(value) for key, value in probs.items()}
|
||||
min_conf = float(os.getenv("ODYSSEUS_SPEC_ROUTER_MIN_CONF", "0.3"))
|
||||
second_min = float(os.getenv("ODYSSEUS_SPEC_ROUTER_SECOND", "0.25"))
|
||||
ranked = sorted(
|
||||
@@ -172,11 +195,133 @@ def spec_route_tools(
|
||||
answer_directly=round(float(probs.get("answer_directly", 0.0)), 3),
|
||||
ask_user=round(float(probs.get("ask_user", 0.0)), 3))
|
||||
except Exception as exc: # fail open
|
||||
added = set(prior_tool_names(messages))
|
||||
decided = False
|
||||
decision.update(error=str(exc)[:200], ms=round((time.time() - t0) * 1000, 1))
|
||||
logger.warning("spec router failed open: %s", exc)
|
||||
LAST_DECISIONS.append(decision)
|
||||
del LAST_DECISIONS[:-50]
|
||||
decision["decided"] = decided
|
||||
_LAST_DECISION.set(decision)
|
||||
logger.info("spec router: %s", {key: value for key, value in decision.items() if key != "message"})
|
||||
return (added, decided) if return_decision else added
|
||||
|
||||
|
||||
_ORIGINAL_SPEC_ROUTE_TOOLS = spec_route_tools
|
||||
|
||||
|
||||
async def spec_route_tools_async(
|
||||
user_message: str,
|
||||
schemas: Iterable[Dict[str, Any]],
|
||||
disabled_tools: Optional[Set[str]] = None,
|
||||
messages: Optional[Iterable[Dict[str, Any]]] = None,
|
||||
return_decision: bool = False,
|
||||
):
|
||||
"""Asynchronous non-blocking tool routing.
|
||||
|
||||
Preserves request-local ContextVar semantics, decision propagation,
|
||||
cancellation behavior, timeout limits, and safe fallback on router failure.
|
||||
"""
|
||||
if spec_route_tools is not _ORIGINAL_SPEC_ROUTE_TOOLS:
|
||||
return spec_route_tools(
|
||||
user_message,
|
||||
schemas,
|
||||
disabled_tools=disabled_tools,
|
||||
messages=messages,
|
||||
return_decision=return_decision,
|
||||
)
|
||||
|
||||
added: Set[str] = set(prior_tool_names(messages))
|
||||
decided = False
|
||||
clear_decision()
|
||||
url = os.getenv("ODYSSEUS_SPEC_ROUTER_URL", "").strip().rstrip("/")
|
||||
if not url or not (user_message or "").strip():
|
||||
return (added, False) if return_decision else added
|
||||
t0 = time.time()
|
||||
decision: Dict[str, Any] = {"ts": time.time(), "message": user_message[:160], "sticky": sorted(added)}
|
||||
try:
|
||||
disabled = set(disabled_tools or ())
|
||||
lines: Dict[str, str] = {}
|
||||
for s in schemas:
|
||||
n = str(_fn(s).get("name") or "")
|
||||
if n and n not in disabled and n != "ask_user" and n not in lines:
|
||||
lines[n] = tool_line(s)
|
||||
names = sorted(lines)
|
||||
criteria = dict(_HEAD)
|
||||
for i, n in enumerate(names, 1):
|
||||
criteria[f"t{i}"] = lines[n]
|
||||
req = {
|
||||
"model": os.getenv("ODYSSEUS_SPEC_ROUTER_MODEL", "odysseus-s60"),
|
||||
"state": {"agent": _AGENT, "conversation": _conversation(messages), "user_message": user_message},
|
||||
"questions": {"tool": {"type": "choice", "instructions": _INSTRUCTIONS, "criteria": criteria}},
|
||||
}
|
||||
timeout = float(os.getenv("ODYSSEUS_SPEC_ROUTER_TIMEOUT", "0.5"))
|
||||
deadline = time.time() + timeout
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
while True:
|
||||
remaining = max(0.2, deadline - time.time())
|
||||
try:
|
||||
resp = await client.post(
|
||||
url + "/v1/systemone",
|
||||
json=req,
|
||||
headers={"content-type": "application/json"},
|
||||
timeout=remaining,
|
||||
)
|
||||
if resp.status_code == 529:
|
||||
if time.time() + 0.15 >= deadline:
|
||||
resp.raise_for_status()
|
||||
decision["retries"] = decision.get("retries", 0) + 1
|
||||
await asyncio.sleep(0.1)
|
||||
continue
|
||||
resp.raise_for_status()
|
||||
out = resp.json()["answers"]["tool"]
|
||||
break
|
||||
except httpx.HTTPStatusError as busy:
|
||||
if busy.response.status_code != 529 or time.time() + 0.15 >= deadline:
|
||||
raise
|
||||
decision["retries"] = decision.get("retries", 0) + 1
|
||||
await asyncio.sleep(0.1)
|
||||
probs = out.get("probabilities") or {}
|
||||
choice = out.get("choice")
|
||||
if not isinstance(probs, dict) or choice not in criteria:
|
||||
raise ValueError("invalid router choice")
|
||||
for key, probability in probs.items():
|
||||
if key not in criteria or isinstance(probability, bool):
|
||||
raise ValueError("invalid router probability key")
|
||||
number = float(probability)
|
||||
if not math.isfinite(number) or not 0 <= number <= 1:
|
||||
raise ValueError("invalid router probability")
|
||||
probs = {key: float(value) for key, value in probs.items()}
|
||||
min_conf = float(os.getenv("ODYSSEUS_SPEC_ROUTER_MIN_CONF", "0.3"))
|
||||
second_min = float(os.getenv("ODYSSEUS_SPEC_ROUTER_SECOND", "0.25"))
|
||||
ranked = sorted(
|
||||
((p, k) for k, p in probs.items() if k.startswith("t") and k[1:].isdigit()), reverse=True
|
||||
)
|
||||
picks: List[str] = []
|
||||
if choice and choice.startswith("t") and choice[1:].isdigit() and float(probs.get(choice, 0.0)) >= min_conf:
|
||||
picks.append(names[int(choice[1:]) - 1])
|
||||
for p, k in ranked[:3]:
|
||||
nm = names[int(k[1:]) - 1]
|
||||
if nm not in picks and p >= second_min:
|
||||
picks.append(nm)
|
||||
_bmode = os.getenv("ODYSSEUS_SPEC_BROWSER_FALLBACK", "0")
|
||||
if _bmode != "0" and "private_browser" in picks:
|
||||
picks = [n for n in picks if n != "private_browser"]
|
||||
extra = list(_MCP_BROWSER_TOOLS) if _bmode == "mcp" else []
|
||||
for n in extra + ["web_search", "web_fetch"]:
|
||||
if n not in picks:
|
||||
picks.append(n)
|
||||
added.update(picks)
|
||||
decided = bool(picks) or (choice == "answer_directly" and float(probs.get("answer_directly", 0.0)) >= min_conf)
|
||||
decision.update(choice=choice, tools=picks, ms=round((time.time() - t0) * 1000, 1),
|
||||
top=[(names[int(k[1:]) - 1], round(p, 3)) for p, k in ranked[:3]],
|
||||
answer_directly=round(float(probs.get("answer_directly", 0.0)), 3),
|
||||
ask_user=round(float(probs.get("ask_user", 0.0)), 3))
|
||||
except Exception as exc: # fail open
|
||||
added = set(prior_tool_names(messages))
|
||||
decided = False
|
||||
decision.update(error=str(exc)[:200], ms=round((time.time() - t0) * 1000, 1))
|
||||
logger.warning("spec router failed open: %s", exc)
|
||||
decision["decided"] = decided
|
||||
_LAST_DECISION.set(decision)
|
||||
logger.info("spec router: %s", {key: value for key, value in decision.items() if key != "message"})
|
||||
return (added, decided) if return_decision else added
|
||||
|
||||
@@ -328,13 +473,59 @@ def route_thinking(user_message: str) -> Optional[Dict[str, Any]]:
|
||||
out = {"think": p >= min_p, "p_think": round(p, 4), "ms": round((time.time() - t0) * 1000, 1)}
|
||||
if kind5:
|
||||
out["kind"] = ans.get("choice")
|
||||
logger.info("spec think router: %s msg=%r", out, text[:120])
|
||||
logger.info("spec think router: %s", out)
|
||||
return out
|
||||
except Exception as exc: # fail open: the existing progressive-thinking rules still apply
|
||||
logger.warning("spec think router failed (%s); falling back to rule-based thinking", exc)
|
||||
return None
|
||||
|
||||
|
||||
_ORIGINAL_ROUTE_THINKING = route_thinking
|
||||
|
||||
|
||||
async def route_thinking_async(user_message: str) -> Optional[Dict[str, Any]]:
|
||||
"""Asynchronous non-blocking route_thinking."""
|
||||
if route_thinking is not _ORIGINAL_ROUTE_THINKING:
|
||||
return route_thinking(user_message)
|
||||
if not think_router_enabled():
|
||||
return None
|
||||
url = os.getenv("ODYSSEUS_SPEC_ROUTER_URL", "").strip().rstrip("/")
|
||||
text = (user_message or "").strip()
|
||||
if not url or not text:
|
||||
return None
|
||||
kind5 = os.getenv("ODYSSEUS_SPEC_THINK_QUESTION", "binary").strip().lower() == "kind5"
|
||||
question = _KIND5_QUESTION if kind5 else {"type": "choice", "instructions": _THINK_INSTRUCTIONS,
|
||||
"criteria": {"think": _THINK_DEF, "direct": _DIRECT_DEF}}
|
||||
req = {
|
||||
"model": os.getenv("ODYSSEUS_SPEC_THINK_MODEL", os.getenv("ODYSSEUS_SPEC_ROUTER_MODEL", "odysseus-s60")),
|
||||
"state": {"agent": _AGENT, "conversation": [], "user_message": text[:4000]},
|
||||
"questions": {"thinking": question},
|
||||
}
|
||||
t0 = time.time()
|
||||
try:
|
||||
timeout = float(os.getenv("ODYSSEUS_SPEC_ROUTER_TIMEOUT", "3.0"))
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.post(
|
||||
url + "/v1/systemone",
|
||||
json=req,
|
||||
headers={"content-type": "application/json"},
|
||||
timeout=timeout,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
ans = resp.json()["answers"]["thinking"]
|
||||
probs = ans.get("probabilities") or {}
|
||||
p = float(1.0 - probs.get("direct", 1.0)) if kind5 else float(probs.get("think", 0.0))
|
||||
min_p = float(os.getenv("ODYSSEUS_SPEC_THINK_MIN_P", "0.5"))
|
||||
out = {"think": p >= min_p, "p_think": round(p, 4), "ms": round((time.time() - t0) * 1000, 1)}
|
||||
if kind5:
|
||||
out["kind"] = ans.get("choice")
|
||||
logger.info("spec think router: %s", out)
|
||||
return out
|
||||
except Exception as exc:
|
||||
logger.warning("spec think router failed (%s); falling back to rule-based thinking", exc)
|
||||
return None
|
||||
|
||||
|
||||
# ---- Ajax Spec driver: the router picks every step --------------------------------------------------------
|
||||
def next_action(user_message: str, latest_result: str, schemas: Iterable[Dict[str, Any]],
|
||||
messages: Optional[Iterable[Dict[str, Any]]] = None,
|
||||
@@ -389,7 +580,81 @@ def next_action(user_message: str, latest_result: str, schemas: Iterable[Dict[st
|
||||
ranked = sorted(((float(v), k) for k, v in probs.items() if k.startswith("t") and k[1:].isdigit()), reverse=True)
|
||||
action.update(p=round(float(probs.get(choice, 0.0)), 3), ms=round((time.time() - t0) * 1000, 1),
|
||||
top=[(names[int(k[1:]) - 1], round(v, 3)) for v, k in ranked[:6]])
|
||||
logger.info("spec next action: %s msg=%r", action, (user_message or "")[:80])
|
||||
logger.info("spec next action: %s", action)
|
||||
return action
|
||||
except Exception as exc:
|
||||
logger.warning("spec next action failed: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
_ORIGINAL_NEXT_ACTION = next_action
|
||||
|
||||
|
||||
async def next_action_async(user_message: str, latest_result: str, schemas: Iterable[Dict[str, Any]],
|
||||
messages: Optional[Iterable[Dict[str, Any]]] = None,
|
||||
disabled_tools: Optional[Set[str]] = None) -> Optional[Dict[str, Any]]:
|
||||
"""Asynchronous non-blocking next_action."""
|
||||
if next_action is not _ORIGINAL_NEXT_ACTION:
|
||||
return next_action(user_message, latest_result, schemas, messages=messages, disabled_tools=disabled_tools)
|
||||
url = os.getenv("ODYSSEUS_SPEC_ROUTER_URL", "").strip().rstrip("/")
|
||||
if not url:
|
||||
return None
|
||||
t0 = time.time()
|
||||
disabled = set(disabled_tools or ())
|
||||
lines: Dict[str, str] = {}
|
||||
for s in schemas:
|
||||
n = str(_fn(s).get("name") or "")
|
||||
if n and n not in disabled and n != "ask_user" and n not in lines:
|
||||
lines[n] = tool_line(s)
|
||||
names = sorted(lines)
|
||||
criteria = dict(_HEAD)
|
||||
for i, n in enumerate(names, 1):
|
||||
criteria[f"t{i}"] = lines[n]
|
||||
um = (user_message or "")[-3000:]
|
||||
if latest_result:
|
||||
um += "\n\nLatest tool result:\n" + latest_result[-1500:]
|
||||
req = {
|
||||
"model": os.getenv("ODYSSEUS_SPEC_ROUTER_MODEL", "odysseus-agentic-v4"),
|
||||
"state": {"agent": _AGENT, "conversation": _conversation(messages), "user_message": um},
|
||||
"questions": {"tool": {"type": "choice", "instructions": _INSTRUCTIONS, "criteria": criteria}},
|
||||
}
|
||||
try:
|
||||
timeout = float(os.getenv("ODYSSEUS_SPEC_ROUTER_TIMEOUT", "3.0"))
|
||||
deadline = time.time() + timeout
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
while True:
|
||||
remaining = max(0.2, deadline - time.time())
|
||||
try:
|
||||
resp = await client.post(
|
||||
url + "/v1/systemone",
|
||||
json=req,
|
||||
headers={"content-type": "application/json"},
|
||||
timeout=remaining,
|
||||
)
|
||||
if resp.status_code == 529:
|
||||
if time.time() + 0.15 >= deadline:
|
||||
resp.raise_for_status()
|
||||
await asyncio.sleep(0.1)
|
||||
continue
|
||||
resp.raise_for_status()
|
||||
out = resp.json()["answers"]["tool"]
|
||||
break
|
||||
except httpx.HTTPStatusError as busy:
|
||||
if busy.response.status_code != 529 or time.time() + 0.15 >= deadline:
|
||||
raise
|
||||
await asyncio.sleep(0.1)
|
||||
probs = out.get("probabilities") or {}
|
||||
choice = str(out.get("choice") or "")
|
||||
if choice.startswith("t") and choice[1:].isdigit():
|
||||
action = {"kind": "tool", "name": names[int(choice[1:]) - 1]}
|
||||
elif choice in ("answer_directly", "ask_user"):
|
||||
action = {"kind": choice, "name": choice}
|
||||
else:
|
||||
return None
|
||||
ranked = sorted(((float(v), k) for k, v in probs.items() if k.startswith("t") and k[1:].isdigit()), reverse=True)
|
||||
action.update(p=round(float(probs.get(choice, 0.0)), 3), ms=round((time.time() - t0) * 1000, 1),
|
||||
top=[(names[int(k[1:]) - 1], round(v, 3)) for v, k in ranked[:6]])
|
||||
logger.info("spec next action: %s", action)
|
||||
return action
|
||||
except Exception as exc:
|
||||
logger.warning("spec next action failed: %s", exc)
|
||||
|
||||
+10
-12
@@ -47,25 +47,23 @@ def resolve_task_candidates(
|
||||
candidates.append((url, model, headers or {}))
|
||||
|
||||
if override_url and override_model:
|
||||
headers = override_headers or {}
|
||||
try:
|
||||
from src.database import ModelEndpoint, SessionLocal
|
||||
from src.endpoint_resolver import normalize_base, resolve_endpoint_runtime, build_headers
|
||||
from src.database import SessionLocal
|
||||
from src.endpoint_resolver import (
|
||||
build_chat_url, build_headers, resolve_endpoint_runtime,
|
||||
resolve_owner_registered_endpoint,
|
||||
)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
from src.auth_helpers import owner_filter
|
||||
query = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
|
||||
for ep in owner_filter(query, ModelEndpoint, owner).all():
|
||||
base = normalize_base(getattr(ep, "base_url", "") or "")
|
||||
if _same_endpoint_base(override_url, base):
|
||||
runtime_base, api_key = resolve_endpoint_runtime(ep, owner=owner)
|
||||
headers = build_headers(api_key, runtime_base or base)
|
||||
break
|
||||
ep = resolve_owner_registered_endpoint(db, override_url, owner)
|
||||
runtime_base, api_key = resolve_endpoint_runtime(ep, owner=owner)
|
||||
_append(build_chat_url(runtime_base), override_model, build_headers(api_key, runtime_base))
|
||||
finally:
|
||||
db.close()
|
||||
except Exception:
|
||||
# A missing, foreign, disabled or unreadable registration is not
|
||||
# authority to contact a caller-provided endpoint or use its key.
|
||||
pass
|
||||
_append(override_url, override_model, headers)
|
||||
_append(*resolve_task_endpoint(fallback_url, fallback_model, fallback_headers, owner=owner))
|
||||
_append(*resolve_endpoint("utility", owner=owner))
|
||||
_append(*resolve_endpoint("default", owner=owner))
|
||||
|
||||
+50
-25
@@ -12,7 +12,7 @@ from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Awaitable, Callable, Dict, Tuple
|
||||
|
||||
from core.auth import RESERVED_USERNAMES
|
||||
from src.owner_identity import REQUEST_SENTINEL_OWNERS
|
||||
from src.owner_identity import REQUEST_SENTINEL_OWNERS, auth_disabled
|
||||
from src.task_action_policy import (
|
||||
is_admin_only_task_action,
|
||||
owner_has_admin_task_privileges,
|
||||
@@ -21,6 +21,15 @@ from src.task_action_policy import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TaskEndpointAuthorityError(RuntimeError):
|
||||
"""A scheduled task's endpoint or its credentials cannot be authorized."""
|
||||
|
||||
|
||||
def _require_task_owner(owner):
|
||||
if not str(owner or "").strip() and not auth_disabled():
|
||||
raise TaskEndpointAuthorityError("Scheduled task has no owner in authenticated mode")
|
||||
|
||||
|
||||
def _is_sft_fixture_owner(owner: str | None) -> bool:
|
||||
"""Synthetic SFT accounts may manage tasks but must never auto-fire them."""
|
||||
return str(owner or "").strip().lower().startswith("sft_")
|
||||
@@ -1345,6 +1354,7 @@ class TaskScheduler:
|
||||
|
||||
async def _execute_action(self, task, run_id: str | None = None) -> tuple:
|
||||
"""Execute a built-in action (no LLM needed)."""
|
||||
_require_task_owner(task.owner)
|
||||
from src.builtin_actions import BUILTIN_ACTIONS
|
||||
from src.agent_runtime.authority import (
|
||||
bind_request_authority, restore_task_authority, task_operation,
|
||||
@@ -1634,6 +1644,7 @@ class TaskScheduler:
|
||||
async def _execute_llm_task(self, task, db) -> str:
|
||||
"""Execute an LLM task with full tool access via the agent loop."""
|
||||
from core.database import Session as DbSession, ChatMessage, CrewMember
|
||||
_require_task_owner(task.owner)
|
||||
|
||||
# If this task is wired to a CrewMember (personal assistant, custom
|
||||
# crew), prefer the crew member's persona/model/endpoint as overrides.
|
||||
@@ -1654,6 +1665,11 @@ class TaskScheduler:
|
||||
endpoint_url, model = self._resolve_defaults(db, task.owner)
|
||||
if not endpoint_url or not model:
|
||||
raise RuntimeError("No model/endpoint configured")
|
||||
# Authorize the original URL before normalization can erase a query,
|
||||
# fragment, or caller-selected path. Stored tasks do not themselves
|
||||
# grant permission to contact an endpoint.
|
||||
from src.endpoint_resolver import resolve_owner_registered_endpoint
|
||||
resolve_owner_registered_endpoint(db, endpoint_url, task.owner or None)
|
||||
endpoint_url = _normalize_chat_endpoint(endpoint_url)
|
||||
# Record the resolved model so _execute_task_locked can persist it on
|
||||
# the run (tasks rarely pin a model, so this is the only record of
|
||||
@@ -1772,6 +1788,8 @@ class TaskScheduler:
|
||||
relevant_tools=relevant_tools,
|
||||
datetime_context_msg=_dt_msg,
|
||||
)
|
||||
except TaskEndpointAuthorityError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning(f"Agent loop failed for task '{task.name}', falling back to simple call: {e}")
|
||||
from src.task_endpoint import task_llm_call_async
|
||||
@@ -1986,6 +2004,7 @@ class TaskScheduler:
|
||||
override_user_message: str | None = None,
|
||||
datetime_context_msg: dict | None = None) -> str:
|
||||
"""Run the full agent loop with tool access, collecting the final text."""
|
||||
_require_task_owner(task.owner)
|
||||
from src.agent_loop import stream_agent_loop
|
||||
from src.agent_runtime.authority import restore_task_authority
|
||||
|
||||
@@ -2015,8 +2034,8 @@ class TaskScheduler:
|
||||
headers = build_headers(api_key, base)
|
||||
finally:
|
||||
db2.close()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
raise TaskEndpointAuthorityError("Scheduled task endpoint is unavailable or unauthorized") from exc
|
||||
full_text = ""
|
||||
final_text_replaced = False
|
||||
tool_results = []
|
||||
@@ -2172,11 +2191,16 @@ class TaskScheduler:
|
||||
from src.settings import get_setting
|
||||
|
||||
# Resolve endpoint/model: research settings > task settings > session defaults
|
||||
_require_task_owner(task.owner)
|
||||
endpoint_url = task.endpoint_url
|
||||
model = task.model
|
||||
headers = {}
|
||||
headers_from_resolver = False
|
||||
|
||||
if endpoint_url:
|
||||
from src.endpoint_resolver import resolve_owner_registered_endpoint
|
||||
resolve_owner_registered_endpoint(db, endpoint_url, task.owner or None)
|
||||
|
||||
if not endpoint_url or not model:
|
||||
try:
|
||||
from src.endpoint_resolver import resolve_endpoint
|
||||
@@ -2213,8 +2237,8 @@ class TaskScheduler:
|
||||
base, api_key = resolve_endpoint_runtime(ep, owner=task.owner or None)
|
||||
endpoint_url = build_chat_url(base)
|
||||
headers = build_headers(api_key, base)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
raise TaskEndpointAuthorityError("Scheduled research endpoint is unavailable or unauthorized") from exc
|
||||
endpoint_url = _normalize_chat_endpoint(endpoint_url)
|
||||
|
||||
max_tokens, extraction_timeout, extraction_concurrency = (
|
||||
@@ -2348,11 +2372,11 @@ class TaskScheduler:
|
||||
have to special-case each tool's schema; the MCP tool ignores keys it
|
||||
doesn't recognise.
|
||||
"""
|
||||
_require_task_owner(task.owner)
|
||||
from src.tool_utils import get_mcp_manager
|
||||
mcp = get_mcp_manager()
|
||||
if not mcp:
|
||||
logger.warning(f"Task {task.id}: MCP manager not available for delivery")
|
||||
return
|
||||
raise RuntimeError("MCP manager not available for task delivery")
|
||||
|
||||
# Resolve recipient — prefer the configured email From (the established
|
||||
# "email yourself" pattern from daily_brief), fall back to task.owner.
|
||||
@@ -2361,7 +2385,7 @@ class TaskScheduler:
|
||||
recipient = None
|
||||
try:
|
||||
from routes.email_helpers import _get_email_config
|
||||
cfg = _get_email_config() or {}
|
||||
cfg = _get_email_config(owner=task.owner or "") or {}
|
||||
recipient = cfg.get("from_address") or None
|
||||
except Exception as _e:
|
||||
logger.debug(f"_deliver_via_mcp: email config lookup failed: {_e}")
|
||||
@@ -2377,6 +2401,14 @@ class TaskScheduler:
|
||||
"X-Odysseus-Ref": str(task.id),
|
||||
},
|
||||
}
|
||||
if tool_name.startswith("mcp__email__"):
|
||||
# The email server uses this reserved field for its account and
|
||||
# approval scope; it must come from the task owner, never a model.
|
||||
from src.tool_execution import _EMAIL_MCP_OWNER_ARG, _EMAIL_MCP_SESSION_ARG
|
||||
if task.owner:
|
||||
args[_EMAIL_MCP_OWNER_ARG] = task.owner
|
||||
if getattr(task, "session_id", None):
|
||||
args[_EMAIL_MCP_SESSION_ARG] = task.session_id
|
||||
if recipient:
|
||||
# Cover the common field names so we work across MCP servers (Gmail,
|
||||
# generic SMTP, Slack DMs, etc.) without having to hard-code each.
|
||||
@@ -2391,25 +2423,18 @@ class TaskScheduler:
|
||||
)
|
||||
try:
|
||||
mcp_result = await mcp.call_tool(tool_name, args)
|
||||
stderr = mcp_result.get("stderr", "")
|
||||
stdout = mcp_result.get("stdout", "")
|
||||
body_len = len(result or "")
|
||||
exit_code = mcp_result.get("exit_code", 0)
|
||||
if exit_code != 0:
|
||||
logger.warning(
|
||||
f"Task {task.id} MCP delivery FAILED via {tool_name}: "
|
||||
f"exit={exit_code} stderr={stderr[:400]!r} stdout={stdout[:400]!r}"
|
||||
)
|
||||
else:
|
||||
# Include the MCP tool's own stdout (e.g. email_server returns
|
||||
# "Sent email to ... with subject ...") + the body size so a
|
||||
# silent SMTP failure is easier to spot in the logs.
|
||||
logger.info(
|
||||
f"Task {task.id} delivered via MCP tool {tool_name} "
|
||||
f"(recipient_set={bool(recipient)}, body={body_len}b, reply={stdout[:200]!r})"
|
||||
)
|
||||
if isinstance(mcp_result, dict):
|
||||
from src.tool_execution import _normalize_mcp_text_error
|
||||
mcp_result = _normalize_mcp_text_error(dict(mcp_result))
|
||||
if not isinstance(mcp_result, dict) or mcp_result.get("error") or mcp_result.get("exit_code") != 0:
|
||||
raise RuntimeError("MCP task delivery failed or returned an invalid result")
|
||||
if tool_name == "mcp__email__send_email" and not str(mcp_result.get("stdout") or "").strip().startswith("Sent email to "):
|
||||
raise RuntimeError("MCP email delivery did not report a completed send")
|
||||
logger.info("Task %s delivered via MCP tool %s (recipient_set=%s, body=%sb)",
|
||||
task.id, tool_name, bool(recipient), len(result or ""))
|
||||
except Exception as e:
|
||||
logger.error(f"Task {task.id} MCP delivery failed: {e}")
|
||||
raise
|
||||
|
||||
async def run_task_now(self, task_id: str, *, force: bool = False):
|
||||
"""Manually trigger a task execution."""
|
||||
|
||||
@@ -227,8 +227,16 @@ _register(
|
||||
ToolEffect.READ_PRIVATE,
|
||||
)
|
||||
_register(
|
||||
{"plan_edit", "run_editor_command"},
|
||||
{"plan_edit"},
|
||||
ToolEffect.NETWORK_EGRESS,
|
||||
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||
)
|
||||
_register(
|
||||
{"run_editor_command"},
|
||||
ToolEffect.READ_PRIVATE,
|
||||
ToolEffect.WRITE_PRIVATE,
|
||||
ToolEffect.UI_SIDE_EFFECT,
|
||||
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||
)
|
||||
_register(
|
||||
{
|
||||
|
||||
@@ -1559,7 +1559,7 @@ def _agent_subprocess_env() -> dict:
|
||||
|
||||
|
||||
def _legacy_private_browser_enabled() -> bool:
|
||||
return os.getenv("ODYSSEUS_PRIVATE_BROWSER_LEGACY", "0").strip().lower() not in {"0", "false", "no", "off"}
|
||||
return os.getenv("ODYSSEUS_PRIVATE_BROWSER_LEGACY", "0").strip().lower() not in {"", "0", "false", "no", "off"}
|
||||
|
||||
|
||||
async def _legacy_private_browser(operation, transport, *, progress_cb=None, session_id=None, owner=None):
|
||||
@@ -1757,11 +1757,13 @@ async def execute_tool_block(
|
||||
|
||||
transport = operation.transport_tool
|
||||
if operation.tool == "private_browser" and _legacy_private_browser_enabled():
|
||||
# Restored pre-Wave-3 private_browser (agent-browser CLI). The Wave 3
|
||||
# producer in src.browser_identity refuses every page action in this
|
||||
# build. This compatibility transport requires an explicit opt-in.
|
||||
return await _legacy_private_browser(
|
||||
operation, transport, progress_cb=progress_cb, session_id=session_id, owner=owner)
|
||||
# The legacy process cannot establish the sealed browser/page identity
|
||||
# required by the current runtime. Never execute it ahead of resource
|
||||
# binding and the untrusted-context approval gate, even when opted in.
|
||||
return f"{transport}: UNSUPPORTED", {
|
||||
**page_unavailable(),
|
||||
"error": "Legacy browser transport cannot establish the required browser authority.",
|
||||
}
|
||||
if operation.tool == "private_browser":
|
||||
try:
|
||||
_, browser_args = parse_browser_operation(operation.input)
|
||||
|
||||
+4
-4
@@ -1545,7 +1545,7 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||
"properties": {
|
||||
"op": {"type": "string", "description": "Editor op name from the catalog, e.g. adjustments.applyMasked"},
|
||||
"params": {"type": "object", "description": "Parameters for the op"},
|
||||
"session": {"type": "string", "description": "Editor session id (defaults to the open session)"},
|
||||
"session": {"type": "string", "description": "Editor session id when no turn-bound session is available; the bound session takes precedence"},
|
||||
"wait": {"type": "boolean", "description": "Wait for the editor result (default true)"}
|
||||
},
|
||||
"required": ["op"]
|
||||
@@ -1560,7 +1560,7 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"session": {"type": "string", "description": "Editor session id (defaults to the open session)"}
|
||||
"session": {"type": "string", "description": "Editor session id when no turn-bound session is available; the bound session takes precedence"}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1583,7 +1583,7 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||
"steps": {"type": "integer", "description": "Optional sampler steps. Use 8 for removal/expand edits (default 4 is too weak for those)."},
|
||||
"place": {"type": "boolean", "description": "Place the result as a new layer (default true)"},
|
||||
"model": {"type": "string", "description": "Optional image model id to route this edit to (from GET /api/editor/image-models); omit to use the configured default"},
|
||||
"session": {"type": "string", "description": "Editor session id"}
|
||||
"session": {"type": "string", "description": "Editor session id when no turn-bound session is available; the bound session takes precedence"}
|
||||
},
|
||||
"required": ["op"]
|
||||
}
|
||||
@@ -2000,7 +2000,7 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||
},
|
||||
]
|
||||
|
||||
if os.getenv("ODYSSEUS_PRIVATE_BROWSER_LEGACY", "0").strip().lower() not in {"0", "false", "no", "off"}:
|
||||
if os.getenv("ODYSSEUS_PRIVATE_BROWSER_LEGACY", "0").strip().lower() not in {"", "0", "false", "no", "off"}:
|
||||
# Restored pre-Wave-3 private_browser (see src.tool_execution._legacy_private_browser).
|
||||
from src.private_browser_legacy_schema import PRIVATE_BROWSER_SCHEMA as _PB_LEGACY
|
||||
|
||||
|
||||
@@ -155,34 +155,11 @@ 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: 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) */
|
||||
@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'); }
|
||||
|
||||
/* 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 */
|
||||
pre, code, .hljs {
|
||||
font-size: 11px;
|
||||
|
||||
@@ -7230,39 +7230,6 @@ body.bg-pattern-ascii-fireflies {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Inline assistant markdown images (``) — 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 { padding: 16px; max-width: 500px; }
|
||||
.setup-wizard .setup-title { font-size: 1.1rem; font-weight: 600; margin-bottom: 12px; color: var(--fg); }
|
||||
|
||||
@@ -22,20 +22,6 @@
|
||||
|
||||
/* Session items */
|
||||
.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; }
|
||||
.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; }
|
||||
|
||||
@@ -704,7 +704,6 @@ textarea.memory-add-input {
|
||||
#serve-bulk-bar,
|
||||
#gallery-bulk-bar,
|
||||
#gallery-editor-drafts-bulk,
|
||||
#notes-bulk-bar,
|
||||
#memory-modal .memory-bulk-bar {
|
||||
padding-right: 18px;
|
||||
}
|
||||
|
||||
@@ -90,12 +90,6 @@
|
||||
.lib-tabs,
|
||||
.gallery-tabs,
|
||||
.preset-tabs {
|
||||
/* A flex-item tab strip defaults to min-width:auto, so it grows to its
|
||||
content width inside a narrow modal and the end tabs get clipped by the
|
||||
modal's overflow instead of scrolling. min-width:0 lets the strip shrink
|
||||
to the container so the overflow-x:auto below actually engages. */
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
flex-wrap: nowrap !important;
|
||||
overflow-x: auto !important;
|
||||
overflow-y: hidden;
|
||||
|
||||
+94
-4997
File diff suppressed because it is too large
Load Diff
@@ -978,343 +978,3 @@ body.doc-find-active mark.doc-find-mark.current {
|
||||
color: var(--accent, var(--red));
|
||||
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
-2
@@ -257,7 +257,7 @@
|
||||
<link rel="stylesheet" href="/static/css/00-tokens.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/01-agent-chat.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/02-compare.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/03-agent-chat.css?v=20261017titlefade1">
|
||||
<link rel="stylesheet" href="/static/css/03-agent-chat.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/04-memory.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/05-documents.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/06-admin-settings.css?v=20260929csssplit">
|
||||
@@ -664,7 +664,6 @@
|
||||
<div class="theme-fd-group">
|
||||
<label class="theme-fd-label">Font</label>
|
||||
<select id="theme-font-select" class="theme-fd-select" aria-label="Font">
|
||||
<option value="gohu">GohuFont</option>
|
||||
<option value="mono">Monospace</option>
|
||||
<option value="sans">Sans-serif</option>
|
||||
<option value="serif">Serif</option>
|
||||
|
||||
+1
-61
@@ -18,13 +18,7 @@
|
||||
'use strict';
|
||||
|
||||
// Click-as-button rows we want reachable by keyboard.
|
||||
var ROW_SELECTOR = [
|
||||
'#sidebar .list-item',
|
||||
'#user-bar-profile',
|
||||
'.sidebar-brand',
|
||||
'.export-dropdown-item',
|
||||
'.dropdown-item'
|
||||
].join(',');
|
||||
var ROW_SELECTOR = ['#sidebar .list-item', '#user-bar-profile'].join(',');
|
||||
|
||||
// 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
|
||||
@@ -61,31 +55,6 @@
|
||||
(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 -----------------------------------------------------
|
||||
// Odysseus modals are plain <div class="modal-content"> boxes. Marking
|
||||
// them as ARIA dialogs lets screen readers announce them as dialogs and
|
||||
@@ -149,38 +118,9 @@
|
||||
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() {
|
||||
enhanceAll(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,
|
||||
// tool sub-rows, etc.). Watch for new rows and enhance them too.
|
||||
|
||||
+5
-7
@@ -763,7 +763,7 @@ function renderFeatherlessPanel(panel, ep, row) {
|
||||
const sortedIds = Array.from(enabledSet).sort((a, b) => a.localeCompare(b));
|
||||
enabledListEl.innerHTML = sortedIds.map(id => {
|
||||
const displayName = id.split('/').pop() || id;
|
||||
const mode = ['none', 'compact', 'full', 'spec'].includes(String(toolModes[id] || '').toLowerCase())
|
||||
const mode = ['none', 'compact', 'full'].includes(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">
|
||||
@@ -777,8 +777,7 @@ 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] || '')}">
|
||||
<option value="" ${mode === '' ? 'selected' : ''}>Auto</option>
|
||||
<option value="full" ${mode === 'full' ? 'selected' : ''}>Regular tools</option>
|
||||
<option value="compact" ${mode === 'compact' ? 'selected' : ''}>Ajax Compact</option>
|
||||
<option value="spec" ${mode === 'spec' ? 'selected' : ''}>Ajax Spec (router)</option>
|
||||
<option value="compact" ${mode === 'compact' ? 'selected' : ''}>Odysseus compact</option>
|
||||
<option value="none" ${mode === 'none' ? 'selected' : ''}>Tools off</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -1357,7 +1356,7 @@ async function loadEndpoints() {
|
||||
<a href="#" data-ep-select-none="${epId}">None</a>
|
||||
</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 => {
|
||||
const mode = ['none', 'compact', 'full', 'spec'].includes(String(m.tool_mode || '').toLowerCase())
|
||||
const mode = ['none', 'compact', 'full'].includes(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">
|
||||
@@ -1371,8 +1370,7 @@ 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">
|
||||
<option value="" ${mode === '' ? 'selected' : ''}>Auto</option>
|
||||
<option value="full" ${mode === 'full' ? 'selected' : ''}>Regular tools</option>
|
||||
<option value="compact" ${mode === 'compact' ? 'selected' : ''}>Ajax Compact</option>
|
||||
<option value="spec" ${mode === 'spec' ? 'selected' : ''}>Ajax Spec (router)</option>
|
||||
<option value="compact" ${mode === 'compact' ? 'selected' : ''}>Odysseus compact</option>
|
||||
<option value="none" ${mode === 'none' ? 'selected' : ''}>Tools off</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -1442,7 +1440,7 @@ async function _saveEpModelState(epId, panel) {
|
||||
const value = String(sel.value || '').toLowerCase();
|
||||
const original = String(sel.dataset.originalToolMode || '').toLowerCase();
|
||||
if (modelId && (sel.dataset.toolModeTouched === '1' || original)) {
|
||||
modelToolModes[modelId] = ['none', 'compact', 'full', 'spec'].includes(value) ? value : '';
|
||||
modelToolModes[modelId] = ['none', 'compact', 'full'].includes(value) ? value : '';
|
||||
}
|
||||
});
|
||||
const total = panel.querySelectorAll('input.adm-cb-hidden[type=checkbox]').length;
|
||||
|
||||
@@ -44,66 +44,6 @@ 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 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>';
|
||||
|
||||
// URL of the photo attached to the current turn (chat uploads are served at
|
||||
// /api/upload/<id>). Used by the "Open in editor & apply" action.
|
||||
function _currentAttachedImageUrl() {
|
||||
try {
|
||||
const box = document.getElementById('chat-history');
|
||||
if (!box) return null;
|
||||
// Use the newest user bubble that carries an image preview. Note
|
||||
// ':last-of-type' is wrong here (it selects the last sibling of the
|
||||
// element type, and the assistant reply follows the user bubble), so
|
||||
// walk the user bubbles from the end instead.
|
||||
const users = box.querySelectorAll('.msg-user');
|
||||
for (let i = users.length - 1; i >= 0; i--) {
|
||||
const img = users[i].querySelector('.attach-image-preview[data-file-id] img');
|
||||
if (img && img.getAttribute('src')) return img.getAttribute('src');
|
||||
}
|
||||
// Fall back to any attachment preview/card in the history (newest).
|
||||
const previews = box.querySelectorAll(
|
||||
'.attach-image-preview[data-file-id], .attach-card[data-file-id]'
|
||||
);
|
||||
if (previews.length) {
|
||||
const last = previews[previews.length - 1];
|
||||
const id = last.dataset ? last.dataset.fileId : null;
|
||||
if (id) return API_BASE + '/api/upload/' + id;
|
||||
}
|
||||
return null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 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 () => {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
if (typeof window.gePhotoEditorApply !== 'function') {
|
||||
await import('/static/js/galleryEditor.js?v=20260909movepicklayer1');
|
||||
}
|
||||
await window.gePhotoEditorApply({
|
||||
imageId: null,
|
||||
imageUrl: _currentAttachedImageUrl(),
|
||||
prompt: action.prompt || '',
|
||||
displayName: 'Chat edit',
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[chat] open_and_apply failed', err);
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
container.appendChild(btn);
|
||||
}
|
||||
|
||||
let API_BASE = '';
|
||||
let currentAbort = null;
|
||||
let isStreaming = false;
|
||||
@@ -4155,7 +4095,6 @@ import { invalidateSettings } from './appConfig.js';
|
||||
threadAbove.classList.add('has-bottom');
|
||||
}
|
||||
terminalFinalResponseRendered = _renderTerminalAnswer(finalText, '', json.render_owner, json.replacement_scope);
|
||||
if (json.photo_editor_action) renderPhotoEditorApplyButton(roundHolder, json.photo_editor_action);
|
||||
lastContentRoundHolder = roundHolder || lastContentRoundHolder;
|
||||
uiModule.scrollHistory();
|
||||
continue;
|
||||
@@ -6580,7 +6519,6 @@ import { invalidateSettings } from './appConfig.js';
|
||||
gotDelta = true;
|
||||
try { spinner.destroy(); } catch (_) {}
|
||||
renderDelta(json);
|
||||
if (json.photo_editor_action) renderPhotoEditorApplyButton(roundHolder, json.photo_editor_action);
|
||||
replayRendering.settle();
|
||||
} else if (json.delta) {
|
||||
if (!replayRendering.accepts(json)) continue;
|
||||
|
||||
@@ -457,7 +457,6 @@ function _makeActionBtn(className, title, text, handler) {
|
||||
btn.className = className;
|
||||
btn.type = 'button';
|
||||
btn.title = title;
|
||||
btn.setAttribute('aria-label', title);
|
||||
btn.textContent = text;
|
||||
btn.addEventListener('click', handler);
|
||||
return btn;
|
||||
@@ -2376,7 +2375,6 @@ export function createMsgFooter(msgElement, { animate = false } = {}) {
|
||||
moreBtn.className = 'msg-action-btn msg-more-btn';
|
||||
moreBtn.type = 'button';
|
||||
moreBtn.title = 'More actions';
|
||||
moreBtn.setAttribute('aria-label', 'More actions');
|
||||
moreBtn.textContent = '\u00B7\u00B7\u00B7';
|
||||
moreBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -2573,7 +2571,6 @@ export function createUserMsgFooter(msgElement) {
|
||||
moreBtn.className = 'msg-action-btn msg-more-btn';
|
||||
moreBtn.type = 'button';
|
||||
moreBtn.title = 'More actions';
|
||||
moreBtn.setAttribute('aria-label', 'More actions');
|
||||
moreBtn.textContent = '\u00B7\u00B7\u00B7';
|
||||
moreBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -3277,7 +3274,6 @@ export function addMessage(role, content, modelName, metadata) {
|
||||
let lastWrap = null;
|
||||
let firstMsgAi = null;
|
||||
let lastMsgAi = null;
|
||||
const newNodes = [];
|
||||
|
||||
const toolsByRound = {};
|
||||
let lastPrivateBrowserUrl = '';
|
||||
@@ -3371,7 +3367,6 @@ export function addMessage(role, content, modelName, metadata) {
|
||||
wrap.dataset.raw = txt;
|
||||
if (metadata?._db_id) wrap.dataset.dbId = metadata._db_id;
|
||||
box.appendChild(wrap);
|
||||
newNodes.push(wrap);
|
||||
lastWrap = wrap;
|
||||
if (!firstMsgAi) firstMsgAi = wrap;
|
||||
lastMsgAi = wrap;
|
||||
@@ -3389,7 +3384,6 @@ export function addMessage(role, content, modelName, metadata) {
|
||||
// Extend line up if there's a chat bubble above
|
||||
if (txt) threadWrap.classList.add('has-top');
|
||||
box.appendChild(threadWrap);
|
||||
newNodes.push(threadWrap);
|
||||
}
|
||||
for (const ev of roundTools) {
|
||||
if (ev.ask_user && !ev.ask_user.resolved) pendingAskUser = ev.ask_user;
|
||||
@@ -3449,9 +3443,7 @@ export function addMessage(role, content, modelName, metadata) {
|
||||
for (const ev of roundTools) {
|
||||
const image = generatedImageResult(ev);
|
||||
if (image) {
|
||||
const imgBubble = buildImageBubble(image.image_url, image.image_prompt, image.image_model, image.image_size, image.image_quality, image.image_id);
|
||||
box.appendChild(imgBubble);
|
||||
newNodes.push(imgBubble);
|
||||
box.appendChild(buildImageBubble(image.image_url, image.image_prompt, image.image_model, image.image_size, image.image_quality, image.image_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,8 +212,6 @@ export function handleUIControl(uiData) {
|
||||
var fn = mod.openPanel || mod.openNotes || (mod.default && (mod.default.openPanel || mod.default.openNotes));
|
||||
if (fn) fn();
|
||||
}).catch(function(){});
|
||||
} else if (panel === 'tasks') {
|
||||
document.getElementById('tool-tasks-btn')?.click();
|
||||
} else if (panel === 'theme' || panel === 'themes') {
|
||||
import('./theme.js?v=20260911organsrain1').then(function(mod) {
|
||||
var fn = mod.togglePopup || (mod.default && mod.default.togglePopup);
|
||||
|
||||
@@ -390,11 +390,11 @@ export function runHTML(code, panel) {
|
||||
* Main entry point — called when a Run button is clicked
|
||||
*/
|
||||
export function run(btn) {
|
||||
const code = btn.getAttribute('data-code');
|
||||
const lang = (btn.getAttribute('data-lang') || '').toLowerCase();
|
||||
|
||||
const pre = btn.closest('pre');
|
||||
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);
|
||||
if (!code || !code.trim()) {
|
||||
|
||||
@@ -96,21 +96,6 @@ function computeSuggestions() {
|
||||
];
|
||||
}
|
||||
|
||||
// ── Reusable helpers for hosts that surface swatches inline ──────────
|
||||
// (e.g. the editor's SWATCHES panel showing suggestions + recents).
|
||||
export function getRecentColors() { return getRecents(); }
|
||||
export function recordRecentColor(hex) { addRecent(hex); }
|
||||
export function suggestColorsFor(hex) {
|
||||
const { h, s, v } = hexToHsv(hex || '#000000');
|
||||
return [
|
||||
{ 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 + 150, s, v), label: 'Split-complement' },
|
||||
{ hex: hsvToHex(h, s, clamp(v > 50 ? v - 30 : v + 30, 10, 95)), label: 'Tone shift' },
|
||||
];
|
||||
}
|
||||
|
||||
// ── Popover build ─────────────────────────────────────────────────────
|
||||
function buildPopover() {
|
||||
const p = document.createElement('div');
|
||||
|
||||
@@ -1498,8 +1498,7 @@ function _rerenderCachedModels() {
|
||||
const _ggufOptions = _ggufChoices.map(f =>
|
||||
`<option value="${esc(f.rel_path)}"${f.rel_path === _defaultGguf ? ' selected' : ''}>${esc(_ggufFileLabel(f))}</option>`
|
||||
).join('');
|
||||
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 _defaultServeModel = (m.is_local_dir && m.path ? `${m.path}/${repo}` : repo);
|
||||
const _savedModelPath = String(svm('model_path', _defaultServeModel) || '').trim();
|
||||
const _modelPathValue = _savedModelPath || _defaultServeModel;
|
||||
const _defaultServedModelName = _isMiniMaxM3 ? repo : '';
|
||||
|
||||
+1
-33
@@ -31,15 +31,10 @@ export function enable(containerId, itemSelector, options = {}) {
|
||||
onReorder: options.onReorder || null,
|
||||
handleSelector: options.handleSelector || 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,
|
||||
};
|
||||
|
||||
let draggedEl = null;
|
||||
let pending = null;
|
||||
let placeholder = null;
|
||||
let offsetY = 0;
|
||||
let items = [];
|
||||
@@ -135,12 +130,6 @@ export function enable(containerId, itemSelector, options = {}) {
|
||||
draggedEl.style.top = snapTop + 'px';
|
||||
|
||||
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.remove();
|
||||
draggedEl.classList.remove('dragging');
|
||||
@@ -169,21 +158,10 @@ export function enable(containerId, itemSelector, options = {}) {
|
||||
function onMouseDown(e) {
|
||||
if (e.button !== 0) 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);
|
||||
if (!item || !container.contains(item)) 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.stopPropagation();
|
||||
startDrag(e.clientY, item);
|
||||
@@ -191,21 +169,11 @@ export function enable(containerId, itemSelector, options = {}) {
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
}
|
||||
|
||||
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 onMouseMove(e) { moveDrag(e.clientY); }
|
||||
|
||||
function onMouseUp() {
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
pending = null;
|
||||
endDrag();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,243 +0,0 @@
|
||||
/**
|
||||
* 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(); } };
|
||||
}
|
||||
@@ -2,12 +2,10 @@
|
||||
import { applyAdjustment } from './fx/pixel-pass.js';
|
||||
import { defaultAdjParams } from './layer-helpers.js';
|
||||
import { renderWithLayerMasks } from './composite-helpers.js';
|
||||
import { blendDraw } from './blend-modes.js';
|
||||
|
||||
const TYPES = new Set([
|
||||
'brightness-contrast',
|
||||
'exposure',
|
||||
'develop',
|
||||
'white-balance',
|
||||
'hue-saturation',
|
||||
'vibrance',
|
||||
@@ -18,12 +16,6 @@ const TYPES = new Set([
|
||||
'color-balance',
|
||||
'selective-color',
|
||||
'gradient-map',
|
||||
'invert',
|
||||
'posterize',
|
||||
'threshold',
|
||||
'photo-filter',
|
||||
'channel-mixer',
|
||||
'color-lookup',
|
||||
]);
|
||||
|
||||
function clone(value) {
|
||||
@@ -60,11 +52,6 @@ export function normalizeAdjustmentData(value) {
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -122,8 +109,11 @@ export function drawAdjustmentLayer(target, editorState, layer, clippingBase, re
|
||||
adjusted = clipped;
|
||||
}
|
||||
|
||||
const opacity = Number.isFinite(layer.opacity) ? layer.opacity : 1;
|
||||
blendDraw(target, adjusted, layer.blendMode, opacity, 0, 0);
|
||||
target.globalAlpha = Number.isFinite(layer.opacity) ? layer.opacity : 1;
|
||||
target.globalCompositeOperation = layer.blendMode || 'source-over';
|
||||
target.drawImage(adjusted, 0, 0);
|
||||
target.globalAlpha = 1;
|
||||
target.globalCompositeOperation = 'source-over';
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -164,8 +154,11 @@ export async function drawAdjustmentLayerAsync(target, editorState, layer, clipp
|
||||
clippedCtx.globalCompositeOperation = 'source-over';
|
||||
adjusted = clipped;
|
||||
}
|
||||
const opacity = Number.isFinite(layer.opacity) ? layer.opacity : 1;
|
||||
blendDraw(target, adjusted, layer.blendMode, opacity, 0, 0);
|
||||
target.globalAlpha = Number.isFinite(layer.opacity) ? layer.opacity : 1;
|
||||
target.globalCompositeOperation = layer.blendMode || 'source-over';
|
||||
target.drawImage(adjusted, 0, 0);
|
||||
target.globalAlpha = 1;
|
||||
target.globalCompositeOperation = 'source-over';
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -27,12 +27,6 @@
|
||||
import { state } from './state.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) {
|
||||
const options = Array.from(select?.options || []).filter(option =>
|
||||
!option.disabled && option.value && option.value !== '__serve_cookbook__');
|
||||
@@ -40,71 +34,6 @@ export function resolveInpaintModel(select) {
|
||||
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; the local
|
||||
// Qwen3.5-9B VL is the default. `value` encodes "<endpoint>::<model>".
|
||||
export const QUICK_EDIT_AGENT_OPTIONS = [
|
||||
{ value: 'http://100.69.120.65:8097/v1::Qwen/Qwen3.5-9B', label: 'Qwen3.5-9B VL · local' },
|
||||
{ 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:
|
||||
// - gen: text-to-image generation
|
||||
// - inpaint: image+mask edit (inpaint / img2img)
|
||||
@@ -163,40 +92,11 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
|
||||
|
||||
const aiGenSelect = document.getElementById('ge-ai-model');
|
||||
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) aiInpaintSelect.addEventListener('change', () => updateFooterEditModel());
|
||||
if (aiCommandSelect) aiCommandSelect.addEventListener('change', () => updateFooterEditModel());
|
||||
// The Quick Edit agent (planner) picker is static for now (local VL default,
|
||||
// server default option); restore/save its choice from localStorage.
|
||||
const populateAgentSelect = () => {
|
||||
if (!aiCommandAgentSelect) return;
|
||||
aiCommandAgentSelect.innerHTML = '';
|
||||
for (const opt of QUICK_EDIT_AGENT_OPTIONS) {
|
||||
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;
|
||||
}
|
||||
} catch {}
|
||||
if (!aiCommandAgentSelect._agentWired) {
|
||||
aiCommandAgentSelect._agentWired = true;
|
||||
aiCommandAgentSelect.addEventListener('change', () => {
|
||||
try { localStorage.setItem('ge-ai-command-agent-model', aiCommandAgentSelect.value); } catch {}
|
||||
});
|
||||
}
|
||||
};
|
||||
// The global Gen model dropdown was removed from the editor topbar;
|
||||
// only bail if there's nothing to populate at all (neither the Gen
|
||||
// select nor the inpaint select nor any per-tool select).
|
||||
if (!aiGenSelect && !aiInpaintSelect && !aiCommandSelect && !aiCommandAgentSelect &&
|
||||
if (!aiGenSelect && !aiInpaintSelect &&
|
||||
!document.querySelector('select.ge-tool-model')) return;
|
||||
populateAgentSelect();
|
||||
|
||||
async function loadAIModels(opts = {}) {
|
||||
try {
|
||||
@@ -208,13 +108,11 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
|
||||
const endpoints = await res.json();
|
||||
if (aiGenSelect) aiGenSelect.innerHTML = '<option value="">None</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'));
|
||||
for (const ts of perToolSelects) ts.innerHTML = '<option value="">Auto</option>';
|
||||
let firstGen = null;
|
||||
let selectedGen = null;
|
||||
let selectedInpaint = null;
|
||||
const loaded = [];
|
||||
for (const ep of endpoints) {
|
||||
if (!ep.is_enabled) continue;
|
||||
const hasListedModels = Array.isArray(ep.models) && ep.models.length;
|
||||
@@ -232,7 +130,6 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
|
||||
const shortModel = modelId ? String(modelId).split('/').pop() : (ep.name || ep.base_url);
|
||||
const epHint = modelId && ep.name && ep.name !== modelId ? ` · ${ep.name}` : '';
|
||||
const label = `${shortModel}${epHint}${epUsable ? '' : ' (offline)'}`;
|
||||
loaded.push({ value, label, gen: caps.gen, inpaint: caps.inpaint, usable: epUsable, endpoint: ep.base_url, model: modelId });
|
||||
if (caps.gen && aiGenSelect) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = value;
|
||||
@@ -261,13 +158,6 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
|
||||
opt.disabled = !epUsable;
|
||||
ts.appendChild(opt);
|
||||
}
|
||||
if (aiCommandSelect) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = value;
|
||||
opt.textContent = label;
|
||||
opt.disabled = !epUsable;
|
||||
aiCommandSelect.appendChild(opt);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -283,7 +173,6 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
|
||||
const auto = resolveInpaintModel({ options: aiInpaintSelect.options, value: '' });
|
||||
aiInpaintSelect.options[0].textContent = auto ? `Auto (${auto.textContent})` : 'Auto (no available model)';
|
||||
}
|
||||
updateFooterEditModel();
|
||||
// Append the "Serve a model in Cookbook…" sentinel at the
|
||||
// bottom of every model dropdown.
|
||||
const appendServeSentinel = (sel) => {
|
||||
@@ -299,7 +188,6 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
|
||||
for (const ts of perToolSelects) appendServeSentinel(ts);
|
||||
if (aiGenSelect) appendServeSentinel(aiGenSelect);
|
||||
if (aiInpaintSelect) appendServeSentinel(aiInpaintSelect);
|
||||
if (aiCommandSelect) appendServeSentinel(aiCommandSelect);
|
||||
// Wire the sentinel on the Gen + Inpaint selects too.
|
||||
const wireServeSentinel = (sel) => {
|
||||
if (!sel) return;
|
||||
@@ -315,18 +203,6 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
|
||||
};
|
||||
wireServeSentinel(aiGenSelect);
|
||||
wireServeSentinel(aiInpaintSelect);
|
||||
wireServeSentinel(aiCommandSelect);
|
||||
if (aiCommandSelect) {
|
||||
try {
|
||||
const saved = localStorage.getItem('ge-ai-command-model');
|
||||
if (saved && [...aiCommandSelect.options].some(o => o.value === saved)) aiCommandSelect.value = saved;
|
||||
} catch {}
|
||||
aiCommandSelect.addEventListener('change', () => {
|
||||
if (aiCommandSelect.value === '__serve_cookbook__') return;
|
||||
try { localStorage.setItem('ge-ai-command-model', aiCommandSelect.value); } catch {}
|
||||
updateFooterEditModel();
|
||||
});
|
||||
}
|
||||
// Restore each per-tool selection from localStorage.
|
||||
for (const ts of perToolSelects) {
|
||||
const key = 'ge-tool-model-' + ts.dataset.geToolModel;
|
||||
@@ -347,8 +223,6 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
|
||||
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) {
|
||||
// Fetch failed — still give the user the affordance to set up
|
||||
// a model. Otherwise the dropdown shows only "Auto" with no
|
||||
@@ -356,7 +230,6 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
|
||||
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 (aiInpaintSelect) aiInpaintSelect.innerHTML = fallback;
|
||||
if (aiCommandSelect) aiCommandSelect.innerHTML = fallback;
|
||||
document.querySelectorAll('select.ge-tool-model').forEach(ts => { ts.innerHTML = fallback; });
|
||||
const wireServe = (sel) => {
|
||||
if (!sel) return;
|
||||
@@ -372,12 +245,7 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
|
||||
};
|
||||
wireServe(aiGenSelect);
|
||||
wireServe(aiInpaintSelect);
|
||||
wireServe(aiCommandSelect);
|
||||
document.querySelectorAll('select.ge-tool-model').forEach(wireServe);
|
||||
populateAgentSelect();
|
||||
updateFooterEditModel();
|
||||
_aiModelsStatus = { ready: false, models: [], error: String(e?.message || e), at: Date.now() };
|
||||
try { window.dispatchEvent(new CustomEvent('ge:ai-models-status')); } catch {}
|
||||
}
|
||||
}
|
||||
loadAIModels();
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
@@ -215,19 +215,21 @@ export function wireAIToolsMisc({
|
||||
function addEmptyLayer() {
|
||||
saveState('Add layer');
|
||||
const layer = createLayer('Layer ' + state.layers.length, state.imgWidth, state.imgHeight);
|
||||
// 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.layers.push(layer);
|
||||
state.activeLayerId = layer.id;
|
||||
renderLayerPanel();
|
||||
composite();
|
||||
}
|
||||
const buildFxMenu = (anchor) => {
|
||||
const addButton = document.getElementById('ge-add-layer');
|
||||
addButton?.addEventListener('click', event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
document.querySelector('.ge-add-layer-menu')?.remove();
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'ge-add-layer-menu ge-frosted';
|
||||
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="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>
|
||||
@@ -240,16 +242,9 @@ 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="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>
|
||||
<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);
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
const rect = addButton.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.top = `${Math.max(8, Math.min(window.innerHeight - menuRect.height - 8, rect.bottom + 5))}px`;
|
||||
@@ -258,7 +253,7 @@ export function wireAIToolsMisc({
|
||||
document.removeEventListener('pointerdown', away, true);
|
||||
};
|
||||
const away = pointerEvent => {
|
||||
if (!menu.contains(pointerEvent.target) && pointerEvent.target !== anchor) close();
|
||||
if (!menu.contains(pointerEvent.target) && pointerEvent.target !== addButton) close();
|
||||
};
|
||||
requestAnimationFrame(() => document.addEventListener('pointerdown', away, true));
|
||||
menu.addEventListener('click', clickEvent => {
|
||||
@@ -266,26 +261,10 @@ export function wireAIToolsMisc({
|
||||
if (!button) return;
|
||||
clickEvent.preventDefault();
|
||||
clickEvent.stopPropagation();
|
||||
if (button.dataset.adjustmentType) openAdjustmentLayer?.(button.dataset.adjustmentType, anchor);
|
||||
if (button.dataset.layerKind === 'raster') addEmptyLayer();
|
||||
else if (button.dataset.adjustmentType) openAdjustmentLayer?.(button.dataset.adjustmentType, addButton);
|
||||
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 };
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
// 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 };
|
||||
@@ -1,35 +0,0 @@
|
||||
/**
|
||||
* 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 };
|
||||
@@ -1,256 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
@@ -24,32 +24,6 @@ export function createBrushSampler({ getDiameter, getSpacing, getSmoothing, emit
|
||||
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) => {
|
||||
emitted = null;
|
||||
filtered = { ...sample, pressure: normalisePressure(sample.pressure) };
|
||||
@@ -67,14 +41,30 @@ export function createBrushSampler({ getDiameter, getSpacing, getSmoothing, emit
|
||||
y: filtered.y + (raw.y - filtered.y) * response,
|
||||
pressure: filtered.pressure + (raw.pressure - filtered.pressure) * response,
|
||||
};
|
||||
emitSpaced(filtered);
|
||||
const start = { ...emitted };
|
||||
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) => {
|
||||
// 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) {
|
||||
emitSpaced({ ...sample, pressure: normalisePressure(sample.pressure) });
|
||||
const finalSample = { ...sample, pressure: normalisePressure(sample.pressure) };
|
||||
if (Math.hypot(finalSample.x - emitted.x, finalSample.y - emitted.y) > 0.25) output(finalSample);
|
||||
}
|
||||
emitted = null;
|
||||
filtered = null;
|
||||
|
||||
@@ -4,7 +4,6 @@ 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: '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: '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) {
|
||||
@@ -32,7 +31,6 @@ export function captureBrushPreset(state, name) {
|
||||
spacing: state.brushSpacing,
|
||||
smoothing: state.brushSmoothing,
|
||||
blendMode: state.brushBlendMode,
|
||||
pixel: !!state.brushPixel,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -45,5 +43,4 @@ export function applyBrushPreset(state, preset) {
|
||||
state.brushSpacing = preset.spacing;
|
||||
state.brushSmoothing = preset.smoothing;
|
||||
state.brushBlendMode = preset.blendMode || 'source-over';
|
||||
state.brushPixel = !!preset.pixel;
|
||||
}
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
/** 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,
|
||||
};
|
||||
}
|
||||
Vendored
+178
-429
@@ -6,10 +6,10 @@
|
||||
* sections are all toggled `display:none` here; the tool-switch handler
|
||||
* in galleryEditor.js shows the section matching the active tool.
|
||||
*
|
||||
* @param {{ color: string, brushSize: number, wandTolerance: number, bucketTolerance: number, cropAspectPreset: string, eyedropperSampleSize: number }} ctx
|
||||
* @param {{ color: string, brushSize: number, wandTolerance: number }} ctx
|
||||
* @returns {string}
|
||||
*/
|
||||
export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance = 32, cropAspectPreset = 'free', eyedropperSampleSize = 1 }) {
|
||||
export function controlsHTML({ color, brushSize, wandTolerance }) {
|
||||
const brushSliderValue = Math.round(Math.log(Math.max(1, brushSize)) / Math.log(800) * 1000);
|
||||
return `
|
||||
<div id="ge-pen-section" style="display:none;">
|
||||
@@ -19,28 +19,6 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
|
||||
<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 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-section-title"><span>Position</span><span id="ge-layer-geometry-name"></span></div>
|
||||
<div class="ge-layer-geometry-grid">
|
||||
@@ -55,105 +33,31 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
|
||||
<label>Color</label>
|
||||
<input type="color" class="ge-color-picker" value="${color}" />
|
||||
</div>
|
||||
<div class="ge-control-row ge-brush-size-row">
|
||||
<label><span class="ge-size-label">${brushSize}px</span></label>
|
||||
<div class="ge-control-row">
|
||||
<label>Size <span class="ge-size-label">${brushSize}px</span></label>
|
||||
<input type="range" class="ge-size-slider" min="0" max="1000" value="${brushSliderValue}" />
|
||||
<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-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>
|
||||
<input type="range" id="ge-brush-spacing" min="1" max="100" value="15" />
|
||||
</div>
|
||||
<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>
|
||||
<input type="range" id="ge-brush-smoothing" min="0" max="95" value="25" />
|
||||
</div>
|
||||
<div class="ge-control-row">
|
||||
<label for="ge-brush-blend">Blend</label>
|
||||
<select id="ge-brush-blend">
|
||||
<option value="source-over">Normal</option><option value="multiply">Multiply</option>
|
||||
<option value="screen">Screen</option><option value="overlay">Overlay</option>
|
||||
<option value="soft-light">Soft Light</option><option value="color">Color</option>
|
||||
</select>
|
||||
</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">
|
||||
<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>
|
||||
</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 class="ge-section-title">Stroke</div>
|
||||
<div class="ge-control-row ge-eraser-row">
|
||||
<label>Spacing <span id="ge-brush-spacing-label">15%</span></label>
|
||||
<input type="range" id="ge-brush-spacing" min="1" max="100" value="15" />
|
||||
</div>
|
||||
<div class="ge-control-row ge-eraser-row">
|
||||
<label>Smoothing <span id="ge-brush-smoothing-label">25%</span></label>
|
||||
<input type="range" id="ge-brush-smoothing" min="0" max="95" value="25" />
|
||||
</div>
|
||||
<div class="ge-control-row">
|
||||
<label for="ge-brush-blend">Blend</label>
|
||||
<select id="ge-brush-blend">
|
||||
<option value="source-over">Normal</option><option value="multiply">Multiply</option>
|
||||
<option value="screen">Screen</option><option value="overlay">Overlay</option>
|
||||
<option value="soft-light">Soft Light</option><option value="color">Color</option>
|
||||
</select>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<div class="ge-control-row ge-brush-preset-row">
|
||||
<select id="ge-brush-preset" aria-label="Brush preset"></select>
|
||||
@@ -192,47 +96,19 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
|
||||
</span>
|
||||
</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 class="ge-text-section" id="ge-text-section" style="display:none;">
|
||||
<textarea id="ge-text-content" rows="2" placeholder="Type text..." aria-label="Text content"></textarea>
|
||||
<textarea id="ge-text-content" rows="3" placeholder="Type text..." aria-label="Text content"></textarea>
|
||||
<div class="ge-text-grid">
|
||||
<label class="ge-text-field ge-text-font-field"><span>Font</span>
|
||||
<select id="ge-text-font">
|
||||
<option value="Inter">Inter</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="Arial">Arial</option>
|
||||
<option value="Verdana">Verdana</option>
|
||||
<option value="Georgia">Georgia</option>
|
||||
<option value="Times New Roman">Times New Roman</option>
|
||||
<option value="Courier New">Courier New</option>
|
||||
<option value="Impact">Impact</option>
|
||||
<option value="system-ui">System</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="ge-text-field ge-text-size-field"><span>Size</span>
|
||||
@@ -248,7 +124,6 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
|
||||
<button type="button" class="ge-text-toggle" data-text-align="center" title="Align center" aria-pressed="false">↔</button>
|
||||
<button type="button" class="ge-text-toggle" data-text-align="right" title="Align right" aria-pressed="false">⇥</button>
|
||||
</div>
|
||||
<div class="ge-opt-more" id="ge-text-more" hidden>
|
||||
<div class="ge-text-grid">
|
||||
<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" />
|
||||
@@ -275,38 +150,6 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
|
||||
<input type="color" class="ge-color-picker" id="ge-text-stroke-color" value="#000000" title="Stroke color" aria-label="Stroke color" />
|
||||
</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>
|
||||
<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>
|
||||
</div>
|
||||
<div class="ge-shape-section" id="ge-shape-section" style="display:none;">
|
||||
@@ -321,7 +164,6 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
|
||||
<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>
|
||||
</div>
|
||||
<div class="ge-opt-more" id="ge-shape-more" hidden>
|
||||
<div class="ge-text-grid">
|
||||
<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>
|
||||
@@ -342,26 +184,8 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
|
||||
<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>
|
||||
<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>
|
||||
</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-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">
|
||||
@@ -374,13 +198,14 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
|
||||
<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="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>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="intersect" title="Intersect selection">∩</button>
|
||||
</div>
|
||||
<div class="ge-marquee-constraint-row">
|
||||
<label for="ge-marquee-constraint">Style</label>
|
||||
<select id="ge-marquee-constraint" title="Marquee sizing style">
|
||||
<option value="free">Free</option>
|
||||
<option value="ratio">Fixed ratio</option>
|
||||
<option value="size">Fixed size</option>
|
||||
</select>
|
||||
<div class="ge-marquee-dimensions" hidden>
|
||||
<label>W <input type="number" id="ge-marquee-width" min="1" step="1" value="1" inputmode="decimal" /></label>
|
||||
@@ -390,18 +215,16 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ge-control-row ge-actions" style="margin-top:2px;flex-wrap:wrap;">
|
||||
<div class="ge-control-row ge-actions" style="margin-top:4px;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">
|
||||
<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-marquee-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" stroke-linejoin="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-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-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-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>
|
||||
<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 ge-btn-iconlabel" id="ge-marquee-invert" title="Invert selection">Invert</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-marquee-delete" title="Erase selected pixels">Erase</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-mask" title="Add selection to mask">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>
|
||||
<div class="ge-lasso-section" id="ge-lasso-section" style="display:none;">
|
||||
@@ -409,62 +232,50 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
|
||||
<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="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>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="intersect" title="Intersect selection">∩</button>
|
||||
</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;">
|
||||
<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>
|
||||
<input type="range" id="ge-lasso-feather" min="0" max="200" value="0" title="Soften the selection edge — feathers the mask alpha." />
|
||||
</div>
|
||||
<div class="ge-control-row ge-eraser-row ge-sel-refine" id="ge-lasso-refine-grow" style="display:none;">
|
||||
<span class="ge-eraser-preview" id="ge-lasso-grow-preview" aria-hidden="true"></span>
|
||||
<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." />
|
||||
</div>
|
||||
<div class="ge-control-row">
|
||||
<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>
|
||||
</div>
|
||||
<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 class="ge-btn ge-btn-sm" id="ge-lasso-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" stroke-linejoin="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-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>
|
||||
<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>
|
||||
<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 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 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 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>
|
||||
<label>Feather <span id="ge-lasso-feather-label">0px</span></label>
|
||||
<input type="range" id="ge-lasso-feather" min="0" max="200" value="0" title="Soften the selection edge — feathers the mask alpha." />
|
||||
</div>
|
||||
<div class="ge-control-row ge-eraser-row ge-sel-refine" id="ge-lasso-refine-grow" style="display:none;">
|
||||
<span class="ge-eraser-preview" id="ge-lasso-grow-preview" aria-hidden="true"></span>
|
||||
<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." />
|
||||
</div>
|
||||
<div class="ge-control-row ge-actions" style="margin-top:4px;flex-wrap:wrap;">
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-lasso-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>
|
||||
Invert
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-lasso-delete" title="Delete selected pixels from the layer">
|
||||
<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>
|
||||
Delete
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-lasso-copy" title="Copy selection to 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>
|
||||
Copy Layer
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-lasso-mask" title="Convert selection to inpaint 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>
|
||||
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>
|
||||
<p style="font-size:9px;opacity:0.4;margin:4px 0 0;">Draw a freehand selection. Esc to cancel.</p>
|
||||
</div>
|
||||
<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.">
|
||||
<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)">+</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"><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>
|
||||
<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="subtract" title="Subtract from selection (Alt)">− Subtract</button>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="intersect" title="Intersect selection">∩</button>
|
||||
</div>
|
||||
<div class="ge-control-row ge-eraser-row">
|
||||
<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>
|
||||
<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}" />
|
||||
</div>
|
||||
<div class="ge-control-row ge-eraser-row ge-sel-refine" id="ge-wand-refine-feather" style="display:none;">
|
||||
@@ -477,38 +288,41 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
|
||||
<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." />
|
||||
</div>
|
||||
<div class="ge-control-row ge-actions" style="margin-top:2px;flex-wrap:wrap;">
|
||||
<div class="ge-control-row ge-actions" style="margin-top:4px;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">
|
||||
<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-wand-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 class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-wand-clear" title="Clear the 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>
|
||||
Clear
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-wand-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>
|
||||
<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>
|
||||
Invert
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-wand-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>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-wand-delete" title="Delete selected pixels from the layer">
|
||||
<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>
|
||||
Erase
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-wand-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>
|
||||
<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>
|
||||
Copy Layer
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-wand-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>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-wand-mask" title="Add selection to the inpaint 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>
|
||||
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>
|
||||
<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>
|
||||
<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 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.">
|
||||
<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>
|
||||
<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="subtract" title="Subtract from selection">− Subtract</button>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="intersect" title="Intersect selection">∩</button>
|
||||
</div>
|
||||
<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;" />
|
||||
@@ -517,120 +331,21 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
|
||||
Find
|
||||
</button>
|
||||
</div>
|
||||
<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;">
|
||||
<div class="ge-control-row ge-actions" style="margin-top:4px;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">
|
||||
<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-sam-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 class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-sam-clear" title="Clear the 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>
|
||||
Clear
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-sam-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-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>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-sam-mask" title="Add selection to the inpaint 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>
|
||||
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-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>
|
||||
<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>
|
||||
<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 class="ge-inpaint-section" id="ge-inpaint-section" style="display:none;">
|
||||
<div class="ge-inpaint-popover-head" data-inpaint-drag>
|
||||
@@ -733,19 +448,72 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
|
||||
</p>
|
||||
<div class="ge-clone-source-status" id="ge-clone-source-status" aria-live="polite">
|
||||
<span id="ge-clone-source-label">No source selected</span>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<div class="ge-control-row">
|
||||
<label for="ge-clone-sample-mode">Sample</label>
|
||||
<select id="ge-clone-sample-mode" class="ge-ctl-select">
|
||||
<select id="ge-clone-sample-mode">
|
||||
<option value="active-layer">Active layer</option>
|
||||
<option value="composite">All visible layers</option>
|
||||
</select>
|
||||
</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 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-control-row ge-eraser-row">
|
||||
<span class="ge-eraser-preview" id="ge-sharpen-preview" aria-hidden="true"></span>
|
||||
@@ -851,6 +619,27 @@ export function layerPanelHTML() {
|
||||
return `<div class="ge-layers-header">
|
||||
<span class="ge-layers-grab"></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 class="ge-layer-blend-row">
|
||||
<label for="ge-layer-blend">Blend</label>
|
||||
@@ -871,21 +660,7 @@ export function layerPanelHTML() {
|
||||
<option value="saturation">Saturation</option>
|
||||
<option value="color">Color</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>
|
||||
<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 class="ge-layer-selection-bar" id="ge-layer-selection-bar" hidden>
|
||||
<span id="ge-layer-selection-count">2 layers</span>
|
||||
@@ -901,31 +676,5 @@ export function layerPanelHTML() {
|
||||
<button class="ge-icon-btn danger" id="ge-selected-delete" title="Delete selected layers" aria-label="Delete selected layers">×</button>
|
||||
</div>
|
||||
<div class="ge-layers-list" id="ge-layers-list"></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>`;
|
||||
<div class="ge-layer-tools" id="ge-layer-tools" aria-label="Selected layer tools"></div>`;
|
||||
}
|
||||
|
||||
@@ -1,551 +0,0 @@
|
||||
/**
|
||||
* 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; },
|
||||
};
|
||||
}
|
||||
@@ -1,274 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -34,8 +34,6 @@ export function shortcutsPopupHTML() {
|
||||
<div><kbd>W</kbd> Wand</div>
|
||||
<div><kbd>${TOOL_SHORTCUTS.marquee}</kbd> Marquee</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 class="ge-shortcuts-col">
|
||||
<h5>Edit</h5>
|
||||
@@ -64,13 +62,6 @@ export function shortcutsPopupHTML() {
|
||||
<div><kbd>]</kbd> Brush size +</div>
|
||||
<div>Drag tolerance slider → live wand retune</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 style="margin-top:8px;font-size:10px;opacity:0.5;text-align:center;">Press <kbd>?</kbd> or click the keyboard icon to toggle.</div>
|
||||
`;
|
||||
@@ -90,7 +81,6 @@ export function historyPanelHTML(historyIcon) {
|
||||
<button class="ge-adj-min" type="button" title="Minimise">−</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="ge-history-snapshots" id="ge-history-snapshots"></div>
|
||||
<div class="ge-history-list" id="ge-history-list"></div>
|
||||
`;
|
||||
}
|
||||
@@ -105,7 +95,6 @@ export function canvasSizePromptHTML() {
|
||||
<div class="modal-content ge-canvas-prompt">
|
||||
<div class="modal-header"><h4 id="ge-canvas-prompt-title">New project</h4></div>
|
||||
<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-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>
|
||||
@@ -137,7 +126,6 @@ export function canvasSizePromptHTML() {
|
||||
<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-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 class="modal-footer ge-canvas-prompt-footer">
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* values).
|
||||
*
|
||||
* @param {{
|
||||
* controlsHTML: (ctx: {color, brushSize, wandTolerance, bucketTolerance, cropAspectPreset}) => string,
|
||||
* controlsHTML: (ctx: {color, brushSize, wandTolerance}) => string,
|
||||
* layerPanelHTML: () => string,
|
||||
* }} build
|
||||
*
|
||||
@@ -22,19 +22,13 @@
|
||||
* controls: HTMLDivElement,
|
||||
* layerPanel: HTMLDivElement,
|
||||
* panelResize: HTMLDivElement,
|
||||
* dock: {dock: HTMLDivElement, show: (id: string) => void}|null,
|
||||
* }}
|
||||
*/
|
||||
import { state } from '../state.js';
|
||||
import { buildDock } from './dock.js';
|
||||
|
||||
export function buildRightPanel({ controlsHTML, layerPanelHTML, extraPanels = [], onCloseGroup = null }) {
|
||||
export function buildRightPanel({ controlsHTML, layerPanelHTML }) {
|
||||
const rightPanel = document.createElement('div');
|
||||
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.
|
||||
const controls = document.createElement('div');
|
||||
@@ -72,11 +66,8 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML, extraPanels = []
|
||||
color: state.color,
|
||||
brushSize: state.brushSize,
|
||||
wandTolerance: state.wandTolerance,
|
||||
bucketTolerance: state.bucketTolerance,
|
||||
cropAspectPreset: state.cropAspectPreset,
|
||||
eyedropperSampleSize: state.eyedropperSampleSize,
|
||||
});
|
||||
if (!useDock) rightPanel.appendChild(controls);
|
||||
rightPanel.appendChild(controls);
|
||||
// Mobile only (≤ 700 px — matches the .ge-editor-body column-stack
|
||||
// breakpoint): the right panel becomes a transformed bottom-sheet,
|
||||
// so any position:fixed descendant gets trapped by the transform
|
||||
@@ -108,7 +99,7 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML, extraPanels = []
|
||||
const layerPanel = document.createElement('div');
|
||||
layerPanel.className = 'ge-layers';
|
||||
layerPanel.innerHTML = layerPanelHTML();
|
||||
if (!useDock) rightPanel.appendChild(layerPanel);
|
||||
rightPanel.appendChild(layerPanel);
|
||||
// Mobile: tap the header grab handle or swipe up/down to toggle
|
||||
// the layers sheet between peek and expanded. The peek state
|
||||
// always shows the active layer so users never lose access to it.
|
||||
@@ -173,78 +164,6 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML, extraPanels = []
|
||||
}
|
||||
}
|
||||
|
||||
// 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 (!useDock && 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);
|
||||
}
|
||||
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 (useDock) { rightPanel.classList.add('ge-right-panel--dock');
|
||||
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);
|
||||
// Layers stays pinned below the dock so the layer list is always
|
||||
// visible while editing (Photoshop-style), regardless of the active
|
||||
// dock tab. Only the tool options + studio panels live in tabs.
|
||||
rightPanel.appendChild(layerPanel);
|
||||
}
|
||||
|
||||
// Horizontal drag handle on the LEFT edge of the right panel — drag
|
||||
// left to widen, right to narrow. Persists chosen width in
|
||||
// localStorage so it survives reopens. (Earlier version was a
|
||||
@@ -286,5 +205,5 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML, extraPanels = []
|
||||
} catch {}
|
||||
});
|
||||
|
||||
return { rightPanel, controls, layerPanel, panelResize, dock };
|
||||
return { rightPanel, controls, layerPanel, panelResize };
|
||||
}
|
||||
|
||||
@@ -6,298 +6,85 @@
|
||||
* mask plumbing, etc.) stays in the caller and arrives here as the
|
||||
* `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 {{
|
||||
* currentTool: string,
|
||||
* onSelectTool: (toolId: string, btn: HTMLButtonElement, toolbar: HTMLDivElement) => void,
|
||||
* onClearSelection: (which: 'marquee'|'lasso'|'wand'|'sam') => void,
|
||||
* onClearSelection: (which: 'marquee'|'lasso'|'wand') => void,
|
||||
* }} ctx
|
||||
* @returns {{
|
||||
* toolbar: HTMLDivElement,
|
||||
* toolKeyMap: Record<string,string>,
|
||||
* syncActive: (toolId: string) => void,
|
||||
* }}
|
||||
* @returns {{ toolbar: HTMLDivElement, toolKeyMap: Record<string,string> }}
|
||||
*/
|
||||
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 }) {
|
||||
const toolbar = document.createElement('div');
|
||||
toolbar.className = 'ge-toolbar';
|
||||
// The tool slots live in their own scroller so the colour swatches can sit
|
||||
// in a fixed footer below it. With the swatches inside the scroller they
|
||||
// were sticky over the last slots and swallowed their clicks.
|
||||
const scroller = document.createElement('div');
|
||||
scroller.className = 'ge-toolbar-scroll';
|
||||
toolbar.appendChild(scroller);
|
||||
const tools = [
|
||||
{ id: 'move', label: 'Move', icon: '✥' },
|
||||
{ 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>' },
|
||||
{ id: 'crop', label: 'Crop', icon: '✂' },
|
||||
{ id: 'transform', label: 'Transform', icon: '⤢' },
|
||||
{ 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>' },
|
||||
{ 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 slots = [];
|
||||
|
||||
for (const id of Object.keys(TOOLS)) {
|
||||
const key = TOOL_SHORTCUTS[id];
|
||||
if (key) toolKeyMap[key.toLowerCase()] = id;
|
||||
}
|
||||
|
||||
const setInner = (btn, toolId) => {
|
||||
const t = TOOLS[toolId];
|
||||
btn.dataset.tool = toolId;
|
||||
btn.title = t.label + (TOOL_SHORTCUTS[toolId] ? ` (${TOOL_SHORTCUTS[toolId]})` : '');
|
||||
if (toolId === 'pen') btn.title += ' - Click anchors; drag for curves; close path or press Enter';
|
||||
if (toolId === 'path') btn.title += ' - Click to add anchors; double-click or press Enter to finish';
|
||||
btn.classList.toggle('is-ai', !!t.ai);
|
||||
for (const t of tools) {
|
||||
t.key = TOOL_SHORTCUTS[t.id];
|
||||
if (t.sep) {
|
||||
const sep = document.createElement('div');
|
||||
sep.className = 'ge-tool-sep';
|
||||
sep.textContent = t.label;
|
||||
toolbar.appendChild(sep);
|
||||
continue;
|
||||
}
|
||||
if (t.key) toolKeyMap[t.key.toLowerCase()] = t.id;
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'ge-tool-btn' + (t.id === currentTool ? ' active' : '');
|
||||
btn.dataset.tool = t.id;
|
||||
btn.title = t.label + (t.key ? ` (${t.key})` : '');
|
||||
if (t.id === '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
|
||||
// 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>' : '';
|
||||
const clearTitle = toolId === 'sam' ? 'Open SAM prompt' : 'Clear selection';
|
||||
const clearBadge = ['marquee', 'lasso', 'quickselect', 'objectselect', 'wand', 'sam'].includes(toolId)
|
||||
? '<span class="ge-tool-clear" title="' + clearTitle + '" data-clear-tool="' + toolId + '">' + CLEAR_X + '</span>'
|
||||
btn.classList.toggle('is-ai', !!t.ai);
|
||||
// Selection-clear badge — rendered only for tools that can hold a
|
||||
// selection (lasso, wand). Inpaint masks are first-class sub-layers
|
||||
// now so they get their own delete-X in the layer panel.
|
||||
const clearTitle = t.id === 'sam' ? 'Open SAM prompt' : 'Clear selection';
|
||||
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>${clearBadge}`;
|
||||
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}`;
|
||||
// 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) => {
|
||||
ev.stopPropagation();
|
||||
onClearSelection(ev.currentTarget.dataset.clearTool);
|
||||
});
|
||||
};
|
||||
|
||||
const closeAllFlyouts = () => {
|
||||
toolbar.querySelectorAll('.ge-tool-slot.open').forEach(s => s.classList.remove('open'));
|
||||
};
|
||||
|
||||
const openFlyout = (slotEl, face) => {
|
||||
const isOpen = slotEl.classList.contains('open');
|
||||
closeAllFlyouts();
|
||||
if (isOpen) return;
|
||||
slotEl.classList.add('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';
|
||||
}
|
||||
// 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>';
|
||||
presetItem.insertAdjacentHTML('beforeend', `<span class="ge-tool-icon">${presetIcon}</span><span class="ge-tool-flyout-label">${preset.name}</span>`);
|
||||
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);
|
||||
}
|
||||
btn.addEventListener('click', () => onSelectTool(t.id, btn, toolbar));
|
||||
toolbar.appendChild(btn);
|
||||
}
|
||||
|
||||
// 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 };
|
||||
return { toolbar, toolKeyMap };
|
||||
}
|
||||
|
||||
@@ -13,92 +13,47 @@ export function buildTopbar() {
|
||||
topBar.className = 'ge-topbar';
|
||||
topBar.innerHTML = `
|
||||
<div class="ge-topbar-left">
|
||||
<div class="ge-file-wrap">
|
||||
<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"><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">File</span>
|
||||
<span class="ge-alpha-badge" title="This editor is in active development — expect rough edges">ALPHA</span>
|
||||
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-undo" title="Undo">
|
||||
<span class="ge-stacked-glyph">↩</span>
|
||||
<span class="ge-stacked-label">UNDO</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>
|
||||
</div>
|
||||
<div class="ge-topbar-right">
|
||||
<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-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 class="ge-menu-label">Open</div>
|
||||
<button class="dropdown-item-compact" data-file-action="import"><span>Import Image…</span></button>
|
||||
<button class="dropdown-item-compact" data-file-action="load-project"><span>Load Project…</span></button>
|
||||
<div class="ge-menu-label">Save</div>
|
||||
<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>
|
||||
<div class="ge-edit-menu dropdown" id="ge-edit-menu" hidden>
|
||||
<div class="ge-menu-label">History</div>
|
||||
<button class="dropdown-item-compact" data-edit-action="undo"><span>Undo</span><span class="dropdown-shortcut">Ctrl+Z</span></button>
|
||||
<button class="dropdown-item-compact" data-edit-action="redo"><span>Redo</span><span class="dropdown-shortcut">Ctrl+Shift+Z</span></button>
|
||||
<button class="dropdown-item-compact" data-edit-action="history"><span>History…</span></button>
|
||||
<div class="ge-menu-label">Clipboard</div>
|
||||
<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>
|
||||
<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 class="ge-view-menu dropdown" id="ge-view-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>
|
||||
<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="dropdown-section-divider"></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="clear-guides"><span class="dropdown-icon">×</span><span>Clear guides</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<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>
|
||||
<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 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 & White"><span>Black & 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">
|
||||
<span class="dropdown-icon">⤢</span>
|
||||
<span>Canvas Size...</span>
|
||||
@@ -107,131 +62,37 @@ 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>Image Size...</span>
|
||||
</button>
|
||||
<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>
|
||||
<div class="ge-filter-submenu-label">Transform</div>
|
||||
<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>Rotate 90° CW</span>
|
||||
</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">
|
||||
<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>
|
||||
</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">
|
||||
<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 Canvas Horizontal</span>
|
||||
<span>Flip horizontal</span>
|
||||
</button>
|
||||
<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>Flip Canvas Vertical</span>
|
||||
<span>Flip vertical</span>
|
||||
</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 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">
|
||||
<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>
|
||||
<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="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="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="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="ge-selection-save-row">
|
||||
<input id="ge-selection-name" type="text" maxlength="100" placeholder="Selection name" aria-label="Saved selection name" />
|
||||
@@ -246,286 +107,50 @@ export function buildTopbar() {
|
||||
<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">
|
||||
<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>
|
||||
<div class="ge-filter-menu dropdown" id="ge-filter-menu" hidden>
|
||||
<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">
|
||||
<span class="dropdown-icon ge-blur-icon ge-blur-gaussian" aria-hidden="true"></span>
|
||||
<span>Gaussian Blur (retained)…</span>
|
||||
</button>
|
||||
<button class="dropdown-item-compact" data-filter-action="effect-sharpen">
|
||||
<span class="dropdown-icon" aria-hidden="true">◈</span>
|
||||
<span>Sharpen (retained)…</span>
|
||||
</button>
|
||||
<button class="dropdown-item-compact" data-filter-action="effect-color-overlay">
|
||||
<span class="dropdown-icon" aria-hidden="true">◐</span>
|
||||
<span>Color Overlay (retained)…</span>
|
||||
</button>
|
||||
<button class="dropdown-item-compact" data-filter-action="effect-drop-shadow">
|
||||
<span class="dropdown-icon" aria-hidden="true">◒</span>
|
||||
<span>Drop Shadow (retained)…</span>
|
||||
</button>
|
||||
<button class="dropdown-item-compact" data-filter-action="effect-stroke">
|
||||
<span class="dropdown-icon" aria-hidden="true">□</span>
|
||||
<span>Stroke (retained)…</span>
|
||||
</button>
|
||||
<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-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-white-outline"><span>White Outline</span></button>
|
||||
</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">
|
||||
<span class="dropdown-icon ge-blur-icon ge-blur-gaussian" aria-hidden="true"></span>
|
||||
<span>Gaussian Blur…</span>
|
||||
</button>
|
||||
<button class="dropdown-item-compact" data-filter-action="blur-zoom">
|
||||
<span class="dropdown-icon ge-blur-icon ge-blur-zoom" aria-hidden="true"></span>
|
||||
<span>Zoom Blur…</span>
|
||||
</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 & 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 & 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 class="ge-filter-submenu-label">Retained effects</div>
|
||||
<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>Gaussian Blur (retained)…</span>
|
||||
</button>
|
||||
<button class="dropdown-item-compact" data-filter-action="effect-sharpen">
|
||||
<span class="dropdown-icon" aria-hidden="true">◈</span>
|
||||
<span>Sharpen (retained)…</span>
|
||||
</button>
|
||||
<button class="dropdown-item-compact" data-filter-action="effect-color-overlay">
|
||||
<span class="dropdown-icon" aria-hidden="true">◐</span>
|
||||
<span>Color Overlay (retained)…</span>
|
||||
</button>
|
||||
<button class="dropdown-item-compact" data-filter-action="effect-drop-shadow">
|
||||
<span class="dropdown-icon" aria-hidden="true">◒</span>
|
||||
<span>Drop Shadow (retained)…</span>
|
||||
</button>
|
||||
<button class="dropdown-item-compact" data-filter-action="effect-stroke">
|
||||
<span class="dropdown-icon" aria-hidden="true">□</span>
|
||||
<span>Stroke (retained)…</span>
|
||||
</button>
|
||||
<div class="ge-filter-submenu-label">Presets</div>
|
||||
<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-soft-shadow"><span>Soft Shadow</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>
|
||||
<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>Gaussian Blur…</span>
|
||||
</button>
|
||||
<button class="dropdown-item-compact" data-filter-action="blur-zoom">
|
||||
<span class="dropdown-icon ge-blur-icon ge-blur-zoom" aria-hidden="true"></span>
|
||||
<span>Zoom Blur…</span>
|
||||
</button>
|
||||
</div>
|
||||
</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 & 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>
|
||||
<span class="ge-topbar-sep"></span>
|
||||
<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>
|
||||
</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">
|
||||
<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>
|
||||
@@ -536,7 +161,6 @@ export function buildTopbar() {
|
||||
<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>
|
||||
</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>
|
||||
</button>
|
||||
<div class="ge-save-menu dropdown" id="ge-save-menu" hidden>
|
||||
@@ -555,10 +179,6 @@ 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>Export image...</span>
|
||||
</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-label">Project</div>
|
||||
<button class="dropdown-item-compact" id="ge-save-project" title="Save layered project (.json) — keeps every layer editable for later">
|
||||
@@ -580,15 +200,7 @@ export function buildZoomFooter() {
|
||||
const footer = document.createElement('div');
|
||||
footer.className = 'ge-editor-footer';
|
||||
footer.innerHTML = `
|
||||
<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>
|
||||
<span class="ge-canvas-size" id="ge-canvas-size" title="Canvas size" hidden></span>
|
||||
<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">−</button>
|
||||
<span class="ge-zoom-stack" aria-live="off">
|
||||
|
||||
@@ -65,38 +65,6 @@ export function transformPopupHTML() {
|
||||
<button type="button" data-spin="up" tabindex="-1" aria-label="Rotate +1°">+</button>
|
||||
</span>
|
||||
</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">
|
||||
<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>
|
||||
@@ -111,8 +79,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 ge-btn-primary" id="ge-transform-apply">Apply</button>
|
||||
</div>
|
||||
<label class="ge-transform-preserve-field" title="Preserve source pixels (applies the transform non-destructively)">
|
||||
<input type="checkbox" id="ge-transform-preserve-source" aria-label="Preserve source pixels" />
|
||||
<label class="ge-transform-preserve-field">
|
||||
<input type="checkbox" id="ge-transform-preserve-source" />
|
||||
<span>Preserve source pixels</span>
|
||||
</label>
|
||||
<p class="ge-transform-popup-hint">Type <strong>-</strong> before W / H to flip.</p>
|
||||
|
||||
@@ -1,319 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
@@ -16,12 +16,6 @@
|
||||
* touchmove 1 finger → continueDraw
|
||||
* touchend → endDraw; touchcancel → cancelDraw
|
||||
* 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:
|
||||
* Hand tool / held Space / middle mouse can drag directly over the
|
||||
@@ -39,8 +33,6 @@
|
||||
* cancelDraw?: () => void,
|
||||
* updateBrushCursor: (e: Event) => void,
|
||||
* syncZoomControls?: () => void,
|
||||
* onTwoFingerTap?: () => void,
|
||||
* onThreeFingerTap?: () => void,
|
||||
* }} ctx
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
@@ -50,35 +42,14 @@ import {
|
||||
nextPanOffset,
|
||||
syncPanCursor,
|
||||
} from './canvas-navigation.js';
|
||||
import { wireCanvasContextMenu } from './canvas-context-menu.js';
|
||||
|
||||
let canvasWindowBindings;
|
||||
|
||||
export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw, cancelDraw, updateBrushCursor, updateEyedropperPreview, syncZoomControls, onViewportChange, onTwoFingerTap, onThreeFingerTap }) {
|
||||
export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw, cancelDraw, updateBrushCursor, updateEyedropperPreview, syncZoomControls, onViewportChange }) {
|
||||
canvasWindowBindings?.abort();
|
||||
canvasWindowBindings = new AbortController();
|
||||
const { signal } = canvasWindowBindings;
|
||||
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
|
||||
// the WINDOW so a drag can continue (and end) past the canvas edge.
|
||||
// Critical for the Resize tool where users overshoot.
|
||||
@@ -89,25 +60,12 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
|
||||
});
|
||||
window.addEventListener('mousemove', (e) => {
|
||||
if (Date.now() < suppressMouseUntil) return;
|
||||
// Only shield the raw-coordinate selection/crop rects from chrome.
|
||||
if (isUiChrome(e) && directGestureActive()) return;
|
||||
continueDraw(e);
|
||||
}, { signal });
|
||||
window.addEventListener('mouseup', (e) => {
|
||||
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);
|
||||
}, { 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.
|
||||
// Compatibility mouse events are briefly suppressed to avoid a
|
||||
// duplicate stroke after pointerup.
|
||||
@@ -136,15 +94,12 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
|
||||
};
|
||||
state.mainCanvas.addEventListener('pointerup', endPen);
|
||||
state.mainCanvas.addEventListener('pointercancel', endPen);
|
||||
// Lasso and Crop can start OUTSIDE the canvas — fallback mousedown on the
|
||||
// surrounding canvas-area so the user can begin a path/rect in the empty
|
||||
// space around the image. Other tools stay canvas-only.
|
||||
// Lasso can start OUTSIDE the canvas — fallback mousedown on the
|
||||
// surrounding canvas-area so the user can begin a lasso path in
|
||||
// the empty space around the image. Other tools stay canvas-only.
|
||||
canvasArea.addEventListener('mousedown', (e) => {
|
||||
if (state.tool !== 'lasso' && state.tool !== 'crop') return;
|
||||
if (state.tool !== 'lasso') return;
|
||||
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);
|
||||
});
|
||||
state.mainCanvas.addEventListener('mouseenter', (e) => {
|
||||
@@ -166,22 +121,11 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
|
||||
});
|
||||
|
||||
// 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 multiStartDist = 0;
|
||||
let multiStartZoom = 1;
|
||||
let multiStartCenter = { 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 t1 = e.touches[0], t2 = e.touches[1];
|
||||
const cx = (t1.clientX + t2.clientX) / 2;
|
||||
@@ -190,30 +134,12 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
|
||||
const dy = t2.clientY - t1.clientY;
|
||||
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);
|
||||
state.mainCanvas.addEventListener('touchstart', (e) => {
|
||||
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) {
|
||||
// Revert the accidental single-finger stroke the first finger
|
||||
// began, then switch to pinch/pan so multi-finger input never
|
||||
// leaves a stray dab behind.
|
||||
if (!multiActive) cancelDraw?.();
|
||||
// End any in-progress single-finger draw before switching modes.
|
||||
if (!multiActive) endDraw();
|
||||
multiActive = true;
|
||||
const info = touchInfo(e);
|
||||
multiStartDist = info.dist;
|
||||
@@ -228,12 +154,6 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
|
||||
}, { passive: false });
|
||||
state.mainCanvas.addEventListener('touchmove', (e) => {
|
||||
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) {
|
||||
const info = touchInfo(e);
|
||||
const ratio = info.dist / Math.max(1, multiStartDist);
|
||||
@@ -257,36 +177,14 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
|
||||
continueDraw(e);
|
||||
}, { passive: false });
|
||||
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 (e.touches.length < 2) multiActive = false;
|
||||
return;
|
||||
}
|
||||
endDraw(e);
|
||||
if (e.touches.length === 0) {
|
||||
gesturePeakTouches = 0;
|
||||
gestureStartCenter = null;
|
||||
gestureMoved = false;
|
||||
}
|
||||
});
|
||||
state.mainCanvas.addEventListener('touchcancel', () => {
|
||||
multiActive = false;
|
||||
gesturePeakTouches = 0;
|
||||
gestureStartCenter = null;
|
||||
gestureMoved = false;
|
||||
cancelDraw?.();
|
||||
});
|
||||
|
||||
@@ -305,10 +203,7 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
|
||||
};
|
||||
canvasArea.addEventListener('pointerdown', (e) => {
|
||||
const directPan = isDirectPanIntent(state.tool, state.spacePanActive, e.button);
|
||||
// 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 (state.tool === 'lasso' && !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;
|
||||
const onImage = e.target === state.mainCanvas || e.target === state.transformOverlay;
|
||||
if (onImage && !directPan) return;
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
import { flipDocument, rotateDocument, rotateDocumentArbitrary, flipLayer as flipLayerGeometry, rotateLayer as rotateLayerGeometry } from './document-geometry.js';
|
||||
import { flipDocument, rotateDocument } from './document-geometry.js';
|
||||
|
||||
export function createCanvasTransforms({
|
||||
saveState, composite, fitZoom, showCanvasLoading, hideCanvasLoading, renderLayerPanel,
|
||||
@@ -52,29 +52,6 @@ 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').
|
||||
* Canvas dimensions don't change. Each layer offset is reflected
|
||||
@@ -87,25 +64,5 @@ export function createCanvasTransforms({
|
||||
composite();
|
||||
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?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,12 +2,11 @@
|
||||
* Paste + drag-and-drop import handlers. Both add an image to the
|
||||
* editor as a new layer:
|
||||
*
|
||||
* - Paste (Ctrl+V): uses the system clipboard's `image/*` items first
|
||||
* (so an external screenshot always wins), then falls back to
|
||||
* `state.internalClipboard` (set by lasso copy/cut) when the OS
|
||||
* clipboard has no image. Layer is named "Pasted Selection" or
|
||||
* "Pasted" and becomes active; the tool snaps to Move so the user
|
||||
* can reposition it immediately.
|
||||
* - Paste (Ctrl+V): checks `state.internalClipboard` first (set by
|
||||
* lasso copy/cut), then falls back to the system clipboard's
|
||||
* `image/*` items. Layer is named "Pasted Selection" or "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.
|
||||
* Shows a "Drop image to add as new layer" overlay mid-drag. Each
|
||||
* dropped image is routed through `handleImportedImage` so canvas-
|
||||
@@ -39,50 +38,38 @@ export function wireClipboardAndDrop({
|
||||
clipboardBindings?.abort();
|
||||
clipboardBindings = new AbortController();
|
||||
const { signal } = clipboardBindings;
|
||||
|
||||
const pasteAsLayer = (imgSource, label, offset = { x: 0, y: 0 }) => {
|
||||
if (!state.editorOpen) return; // user closed mid-paste
|
||||
saveState();
|
||||
const layer = createLayer(label || 'Pasted', imgSource.width, imgSource.height);
|
||||
// Selection clipboard data is already a complete layer-sized surface.
|
||||
// Keep it source-backed with an identity matrix so future transforms do
|
||||
// not repeatedly resample the pasted pixels.
|
||||
layer.kind = 'placed';
|
||||
layer.placed = createPlacedData(imgSource, [1, 0, 0, 1, offset.x, offset.y], label || 'Pasted');
|
||||
const rendered = renderPlacedLayer(layer);
|
||||
state.layerOffsets.set(layer.id, rendered.offset);
|
||||
state.layers.push(layer);
|
||||
state.activeLayerId = layer.id;
|
||||
state.selectedLayerIds = [layer.id];
|
||||
state.activeGroupId = null;
|
||||
const tb = state.container?.querySelector('.ge-toolbar');
|
||||
tb?.querySelector('[data-tool="move"]')?.click();
|
||||
renderLayerPanel();
|
||||
composite();
|
||||
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;
|
||||
const insideEditor = target && container && container.contains(target);
|
||||
if (insideEditor && (target.isContentEditable || target.closest?.('input, textarea, select'))) 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 }) {
|
||||
if (!state.editorOpen) return; // user closed mid-paste
|
||||
saveState();
|
||||
const layer = createLayer(label || 'Pasted', imgSource.width, imgSource.height);
|
||||
// Selection clipboard data is already a complete layer-sized surface.
|
||||
// Keep it source-backed with an identity matrix so future transforms do
|
||||
// not repeatedly resample the pasted pixels.
|
||||
layer.kind = 'placed';
|
||||
layer.placed = createPlacedData(imgSource, [1, 0, 0, 1, offset.x, offset.y], label || 'Pasted');
|
||||
const rendered = renderPlacedLayer(layer);
|
||||
state.layerOffsets.set(layer.id, rendered.offset);
|
||||
state.layers.push(layer);
|
||||
state.activeLayerId = layer.id;
|
||||
state.selectedLayerIds = [layer.id];
|
||||
state.activeGroupId = null;
|
||||
const tb = state.container?.querySelector('.ge-toolbar');
|
||||
tb?.querySelector('[data-tool="move"]')?.click();
|
||||
renderLayerPanel();
|
||||
composite();
|
||||
uiModule.showToast('Pasted as new layer');
|
||||
}
|
||||
|
||||
const imageItem = Array.from(e.clipboardData?.items || []).find(item => item.type.startsWith('image/'));
|
||||
if (imageItem) {
|
||||
pasteKeyPending = false;
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
const blob = imageItem.getAsFile();
|
||||
@@ -98,39 +85,12 @@ export function wireClipboardAndDrop({
|
||||
return;
|
||||
}
|
||||
if (state.internalClipboard && !e.defaultPrevented && !e.clipboardData?.types?.includes?.('text/plain')) {
|
||||
pasteKeyPending = false;
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
pasteAsLayer(state.internalClipboard, 'Pasted Selection', state.internalClipboardOffset || { x: 0, y: 0 });
|
||||
}
|
||||
}, { 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 ──
|
||||
// Visual drop-zone overlay appears mid-drag; routes via
|
||||
// handleImportedImage so the import respects canvas resizing rules
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
/*
|
||||
* 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 };
|
||||
@@ -24,8 +24,6 @@ const SUPPORTED_BLEND_MODES = new Set([
|
||||
'source-over', 'multiply', 'screen', 'overlay', 'soft-light', 'hard-light',
|
||||
'darken', 'lighten', 'color-dodge', 'color-burn', 'difference', 'exclusion',
|
||||
'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 = []) {
|
||||
@@ -53,8 +51,6 @@ export function normalizeEditorView(view, fallback = {}) {
|
||||
gridSize: Math.max(2, Math.min(1000, Math.round(Number.isFinite(rawGridSize) ? rawGridSize : 16))),
|
||||
snapEnabled: Boolean(source.snapEnabled ?? fallback.snapEnabled ?? false),
|
||||
snapToGrid: Boolean(source.snapToGrid ?? fallback.snapToGrid ?? false),
|
||||
autoSelectLayer: Boolean(source.autoSelectLayer ?? fallback.autoSelectLayer ?? false),
|
||||
autoSelectTarget: (source.autoSelectTarget ?? fallback.autoSelectTarget) === 'group' ? 'group' : 'layer',
|
||||
guides: {
|
||||
vertical: normalizeGuideValues(guides.vertical, fallbackGuides.vertical),
|
||||
horizontal: normalizeGuideValues(guides.horizontal, fallbackGuides.horizontal),
|
||||
@@ -594,10 +590,8 @@ export function prepareEditorDocument(raw) {
|
||||
document: {
|
||||
...migrated,
|
||||
v: EDITOR_DOCUMENT_VERSION,
|
||||
imgWidth: width,
|
||||
imgHeight: height,
|
||||
colorMode: migrated.colorMode === 'grayscale' ? 'grayscale' : 'rgb',
|
||||
bitDepth: 8,
|
||||
imgWidth: width,
|
||||
imgHeight: height,
|
||||
activeLayerId,
|
||||
view: normalizeEditorView(migrated.view),
|
||||
groups,
|
||||
@@ -640,8 +634,6 @@ export function serializeEditorDocument(state, extras = {}) {
|
||||
imageId: state.imageId || null,
|
||||
imgWidth: state.imgWidth,
|
||||
imgHeight: state.imgHeight,
|
||||
colorMode: state.colorMode || 'rgb',
|
||||
bitDepth: state.bitDepth || 8,
|
||||
activeLayerId: state.activeLayerId || null,
|
||||
nextLayerId: state.nextLayerId,
|
||||
view: normalizeEditorView(state),
|
||||
|
||||
@@ -9,22 +9,18 @@
|
||||
|
||||
import { flipTextLayer, rotateTextLayer, scaleTextLayer } from './text-layer.js';
|
||||
import { flipShapeLayer, rotateShapeLayer, scaleShapeLayer } from './shape-layer.js';
|
||||
import { affineBounds, renderPlacedLayer, transformPlacedData } from './placed-layer.js';
|
||||
import { renderPlacedLayer, transformPlacedData } from './placed-layer.js';
|
||||
|
||||
function makeCanvas(width, height) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.max(1, Math.round(Number.isFinite(width) ? width : 1));
|
||||
canvas.height = Math.max(1, Math.round(Number.isFinite(height) ? height : 1));
|
||||
canvas.width = Math.max(1, Math.round(width));
|
||||
canvas.height = Math.max(1, Math.round(height));
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function renderWindow(source, x, y, 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;
|
||||
}
|
||||
|
||||
@@ -54,25 +50,6 @@ function renderRotated(source, degrees) {
|
||||
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) {
|
||||
const out = makeCanvas(source.width, source.height);
|
||||
const ctx = out.getContext('2d');
|
||||
@@ -195,20 +172,10 @@ export function clipPolygonToRect(points, width, height) {
|
||||
}
|
||||
|
||||
function normalizeCropRect(editorState, rect) {
|
||||
// Accept either {w,h} (internal) or {width,height} (control-op params).
|
||||
const rawW = Number.isFinite(rect.w) ? rect.w : rect.width;
|
||||
const rawH = Number.isFinite(rect.h) ? rect.h : rect.height;
|
||||
// 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)));
|
||||
const x = Math.max(0, Math.min(editorState.imgWidth - 1, Math.round(rect.x)));
|
||||
const y = Math.max(0, Math.min(editorState.imgHeight - 1, Math.round(rect.y)));
|
||||
const width = Math.max(1, Math.min(editorState.imgWidth - x, Math.round(rect.w)));
|
||||
const height = Math.max(1, Math.min(editorState.imgHeight - y, Math.round(rect.h)));
|
||||
return { x, y, width, height };
|
||||
}
|
||||
|
||||
@@ -534,121 +501,6 @@ export function rotateDocument(editorState, degrees) {
|
||||
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. */
|
||||
export function flipDocument(editorState, axis) {
|
||||
if (axis !== 'h' && axis !== 'v') return null;
|
||||
@@ -721,128 +573,3 @@ export function flipDocument(editorState, axis) {
|
||||
syncDocumentCanvas(editorState, width, height);
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
// 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 = '';
|
||||
}
|
||||
@@ -1,374 +0,0 @@
|
||||
// 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;
|
||||
@@ -1,104 +0,0 @@
|
||||
// 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;
|
||||
@@ -1,97 +0,0 @@
|
||||
// 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;
|
||||
@@ -65,42 +65,6 @@ function renderEffects(source, effects, masks) {
|
||||
ctx.filter = 'none';
|
||||
} else if (effect.type === 'sharpen') {
|
||||
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') {
|
||||
ctx.drawImage(current, 0, 0);
|
||||
ctx.globalAlpha = effect.params.opacity;
|
||||
|
||||
Vendored
+1
-51
@@ -9,7 +9,7 @@ export function effectsWithPreview(owner) {
|
||||
return index < 0 ? [...effects, preview] : effects.map((effect, i) => i === index ? preview : effect);
|
||||
}
|
||||
|
||||
const EFFECT_TYPES = new Set(['gaussian-blur', 'sharpen', 'motion-blur', 'zoom-blur', 'pixelate', 'color-overlay', 'drop-shadow', 'stroke', 'linear-gradient', 'radial-gradient']);
|
||||
const EFFECT_TYPES = new Set(['gaussian-blur', 'sharpen', 'color-overlay', 'drop-shadow', 'stroke', 'linear-gradient', 'radial-gradient']);
|
||||
for (const type of Object.keys(LAYER_STYLES)) EFFECT_TYPES.add(type);
|
||||
|
||||
export const EFFECT_PRESETS = {
|
||||
@@ -41,9 +41,6 @@ export function defaultEffectParams(type) {
|
||||
if (LAYER_STYLES[type]) return styleParams(type);
|
||||
if (type === 'gaussian-blur') return { radius: 6 };
|
||||
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 === 'drop-shadow') return { color: '#000000', opacity: 0.45, blur: 12, x: 4, y: 6 };
|
||||
if (type === 'stroke') return { color: '#ffffff', opacity: 1, width: 3 };
|
||||
@@ -69,12 +66,6 @@ export function normalizeEffect(effect) {
|
||||
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 === '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') {
|
||||
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));
|
||||
@@ -123,9 +114,6 @@ export function effectLabel(type) {
|
||||
return {
|
||||
'gaussian-blur': 'Gaussian Blur',
|
||||
sharpen: 'Sharpen',
|
||||
'motion-blur': 'Motion Blur',
|
||||
'zoom-blur': 'Zoom Blur',
|
||||
pixelate: 'Pixelate',
|
||||
'color-overlay': 'Color Overlay',
|
||||
'drop-shadow': 'Drop Shadow',
|
||||
'stroke': 'Stroke',
|
||||
@@ -224,44 +212,6 @@ export function renderEffects(source, effects = [], shouldContinue = () => true)
|
||||
const sharpened = sharpenCanvas(current, effect.params.amount, shouldContinue);
|
||||
if (!sharpened) return current;
|
||||
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') {
|
||||
ctx.drawImage(current, 0, 0);
|
||||
ctx.globalAlpha = effect.params.opacity;
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
/** Controlled image export dialog and canvas encoding helpers. */
|
||||
|
||||
import { exportPsd } from './psd.js';
|
||||
|
||||
const FORMAT_META = {
|
||||
png: { mime: 'image/png', extension: 'png', quality: undefined },
|
||||
jpeg: { mime: 'image/jpeg', extension: 'jpg', 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) {
|
||||
@@ -46,14 +43,6 @@ export function prepareExportCanvas(sourceCanvas, settings) {
|
||||
export function encodeExportCanvas(sourceCanvas, settings) {
|
||||
const normalized = normalizeExportSettings(settings, sourceCanvas.width, sourceCanvas.height);
|
||||
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];
|
||||
return new Promise((resolve, reject) => {
|
||||
output.toBlob(
|
||||
@@ -97,7 +86,6 @@ export function openExportDialog({
|
||||
<button type="button" class="active" data-format="png">PNG</button>
|
||||
<button type="button" data-format="jpeg">JPEG</button>
|
||||
<button type="button" data-format="webp">WebP</button>
|
||||
<button type="button" data-format="psd">PSD</button>
|
||||
</div></div>
|
||||
<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>
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user