diff --git a/docs/QUICKEDIT_COVERAGE.md b/docs/QUICKEDIT_COVERAGE.md new file mode 100644 index 000000000..895d84502 --- /dev/null +++ b/docs/QUICKEDIT_COVERAGE.md @@ -0,0 +1,255 @@ +# Quick Edit Coverage Audit + +Audit of how much of the PhotoCraft editor is reachable through **Quick Edit** +(the natural-language editor agent). Built against build **0.20.44** (HARNESS +bridge fan-out fix `4dd11826`) and client commit `e78b5a96`. + +Quick Edit executes in two stages: + +1. **Plan** — `static/js/editor/editor-agent.js` posts `{prompt, state, + screenshot}` to `/api/editor/agent`. HARNESS `routes/editor_agent_routes.py` + plans with the shared style guide (`src/editor_style_guide.py`) plus an LLM, + validates every step against its own `OP_CATALOG`, then executes. +2. **Execute** — only `ai_edit_image` / `generate_image` run server-side; every + other op is delivered to the page over the editor bridge (SSE) and run by the + client control surface (`window.geEditorControl.invoke`, catalog in + `static/js/galleryEditor.js`). A local fallback planner lives in + `static/js/editor/nl-edit.js`. + +There are **three** catalogs that must agree: + +| Catalog | File | Owner | +|---|---|---| +| Client control ops | `static/js/galleryEditor.js` | PHOTOSHOP | +| Server `OP_CATALOG` | `routes/editor_agent_routes.py` | HARNESS | +| Style guide / planner | `src/editor_style_guide.py` + `nl-edit.js` | HARNESS + PHOTOSHOP | + +Legend: **C** = in client catalog · **S** = in server `OP_CATALOG` (incl. +`NEW_OP_NAMES`) · **P** = the planner/style guide emits it for some phrasing. +A capability is **usable from Quick Edit** only when the planner can emit it +(**P**) and the op exists on the side that runs it. + +--- + +## 1. Menu bar (12 menus) + +| Capability | Trigger phrasing | Op | C | S | P | Status | +|---|---|---|---|---|---|---| +| File → Import | "open this image" | `document.importImage` | ✔ | ✔ | partial | OK | +| File → Load project | "open my project" | `document.openPsd` | ✔ | ✘ | ✘ | **Server gap** | +| File → Save / Save copy / Export PSD | "save as psd" | `document.savePsd` | ✔ | ✔ | ✔ | OK | +| File → Export | "export png" | `export.png` | ✔ | ✔ | ✔ | OK | +| File → Save project | — | (project store) | n/a | n/a | ✘ | Out of scope (not an edit op) | +| Edit → Undo/Redo/History | "undo" | `history.undo` / `redo` | ✔ | ✔ | ✔ | OK | +| Edit → Free Transform | "free transform" | `transform.start` | ✔ | ✔ | ✔ | OK | +| Edit → Transform → Rotate 90/180 | "rotate this layer 90" | `layer.rotate` | ✔ | ✔ | ✔ | OK (whole image vs layer routed by the fast path) | +| Edit → Transform → Flip H/V | "flip this layer" | `layer.flip` | ✔ | ✔ | ✔ | OK (whole image vs layer routed by the fast path) | +| Image → Fill | "fill the whole image with white" | — | ✘ | ✘ | ✘ | **No op** | +| Image → Canvas Size | "make the canvas wider" | `document.resizeCanvas` | ✔ | ✔ | partial | OK | +| Image → Image Size | "resize the image to 1200px" | `document.resizeCanvas` | ✔ | ✔ | partial | OK | +| Image → Rotate 90/180 (whole image) | "rotate the whole image 90" | `document.rotate`/all-layers | ✘ | ✘ | ✘ | **No control op** | +| Image → Flip H/V (whole image) | "flip the whole canvas" | `document.flip`/all-layers | ✘ | ✘ | ✘ | **No control op** | +| Image → Mode RGB/Grayscale | "make it black and white" | `adjustments.apply` black-white | ✔ | ✔ | ✔ | OK (mode via adjustment) | +| Image → Adjustments → Invert | "invert the colours" | `adjustments.apply` invert | ✔ | ✔ | ✔ | OK (fast path colour-inverts the active layer; "invert the selection" stays Select ▸ Inverse) | +| Image → Mode CMYK/Lab, Bits 16/32 | — | — | ✘ | ✘ | ✘ | Not implemented (disabled in UI) | +| Layer → New | "add a layer" | `layers.addRaster` | ✔ | ✔ | partial | OK | +| Layer → Duplicate | "duplicate the layer" | `layers.duplicate` | ✔ | ✔ | partial | OK | +| Layer → Group | "group the layers" | `layers.group` | ✔ | ✔ | ✘ | **Server OK, planner gap** | +| Layer → Rasterize | "rasterize the text" | `layers.rasterize` | ✔ | ✔ | ✔ | OK | +| Layer → Delete | "delete the layer" | `layers.delete` | ✔ | ✔ | partial | OK | +| Layer → Merge Down | "merge down" | `layers.mergeDown` | ✔ | ✘ | ✘ | **Server gap** | +| Layer → Merge All / Flatten | "flatten the image" | `layers.flatten` | ✔ | ✘ | ✘ | **Server gap** | +| Layer → Select All | "select all layers" | (multi-select) | ✔ | ✔ | ✔ | OK (fast path selects every layer) | +| Layer → Combine (unite/subtract/…) | "combine the shapes" | `_combineSelectedShapes` | ✔ | ✔ | ✔ | OK (fast path selects the shape layers and combines) | +| Layer → Fill | "fill the layer with blue" | `selection.fill` / flood | partial | ✘ | ✘ | **Gap** | +| Layer → Layer Mask (reveal/hide/apply/invert/feather) | "add a layer mask" | `layers.addMaskFromSelection` | ✔ | ✔ | ✔ | OK | +| Layer → Opacity | "set the layer opacity to 50%" | `layers.setOpacity` | ✔ | ✔ | partial | OK (fast path sets the active layer's opacity) | +| Layer → Blend Mode | "set the blend mode to multiply" | `layers.setBlend` | ✔ | ✔ | partial | OK (fast path sets the active layer's blend mode) | +| Type → Add | "add a title" | `text.add` | ✔ | ✔ | ✔ | OK | +| Type → Character | "make the text bigger" | `text.edit` | ✔ | ✘ | ✘ | **Server gap + planner gap** | +| Type → Paragraph | "centre the text" | `text.layout` | ✔ | ✔ | partial | OK | +| Type → Warp | "warp the text" | `_rerenderTextLayer` (warp) | ✔ | ✔ | ✔ | OK | +| Select → All/Deselect/Invert | "invert the selection" | `selection.*` | ✔ | ✔ | ✔ | OK | +| Select → Reselect | "reselect" | `_reselectSelection` | ✔ | ✔ | ✔ | OK | +| Select → Transform | "transform the selection" | `transform.start` | ✔ | ✔ | partial | OK | +| Select → Refine | "refine the edge" | `selection.feather/expand` | ✔ | ✘ | ✘ | **Server gap** | +| Select → Color Range | "select the blue sky" | `selection.colorRange` | ✔ | ✔ | partial | OK | +| Select → Select and Mask | "select the subject" | `selection.subject` | ✔ | ✔ | ✔ | OK | +| Select → Save | "save the selection" | `_saveNamedSelection` | ✔ | ✔ | ✔ | OK | +| Select → Load | "load the selection as sky" | `_loadNamedSelection` | ✔ | ✔ | ✔ | OK | +| Filter → every filter (30+) | "pixelate the image" | `filters.apply` | ✔ | ✔ | ✔ | OK (fast path covers pixelate/emboss/twirl/pinch/spherize/ripple/solarize/find-edges/crystallize/noise/despeckle/unsharp + blur/sharpen/vignette) | +| Filter → Blur → Motion / Zoom | "motion blur" / "zoom blur" | `filters.apply` (blur-motion / blur-zoom) | ✔ | ✔ | ✔ | OK (fast path maps motion blur and zoom blur to their Filter-menu variants) | +| Filter → Layer effects (drop shadow, stroke, colour overlay, outer glow) | "add a drop shadow" | `layers.addStyle` | ✔ | ✔ | ✔ | OK (fast path adds drop shadow / stroke / outer glow / colour & gradient overlay; colour parsed from the prompt) | +| View → Rulers/Grid/Snap/Clear guides | "show the grid" | `#ge-view-menu` | ✔ | ✔ | ✔ | OK | +| Window → panels | — | n/a | n/a | n/a | ✘ | UI-only | +| 3D / Plugins | — | — | ✘ | ✘ | ✘ | Not implemented (disabled in UI) | + +## 2. Tools + +Toolbar tools are interactive, not bridge ops, so coverage is about whether the +**effect** of a tool is reachable by prompt. + +| Tool | NL-reachable? | Notes | +|---|---|---| +| Move / Transform | `transform.*` | OK (transform.start/apply/cancel) | +| Hand / Zoom | — | View only | +| Crop | `document.crop` + format guard | OK | +| Text / Shape | `text.add`, `text.layout` | Shape has no add-shape op — **gap** | +| Brush / Eraser / Dodge / Burn / Smudge / Clone / Heal / Sharpen | — | Interactive painting; no NL op — **gap** (out of scope for planner) | +| Gradient | — | **No op** | +| **Paint Bucket** | `fill.flood` | **Server gap + planner gap** (op exists client-side only) | +| Eyedropper | — | UI only | +| Marquee / Lasso / Wand / Quick Select / Object Select / Pen / Path | `selection.rectangle/ellipse/polygon/subject/…` | OK for region/reference selects; lasso/pen paths have `selection.polygon` only | +| SAM / Inpaint / Rembg | `selection.subject`, `ai.removeObject`, `ai.removeBackground` | OK | +| **Pixel brush + grid** | — | UI state only; no op — **gap** (acceptable: it is a brush mode, not an edit op) | + +## 3. Right-click context menu + +| Item | Backing op | Reachable by prompt? | +|---|---|---| +| Brush size / preset / Pixel Brush | `state` UI | No (interactive) | +| Select All / Deselect / Inverse / Feather / Expand / Contract / Border / Smooth | `selection.*` | Yes | +| Transform Selection / Refine Edge | `transform.start`, `selection.feather` | Partial | +| Apply / Cancel Crop | `document.crop` | Yes | +| Commit / Cancel / Free Transform | `transform.apply/cancel/start` | Yes | +| Add Text Layer / Rasterize Text | `text.add`, `layers.rasterize` | Yes (rasterize OK) | +| Copy Color Hex / Swap Colors | `state.color` | OK (fast path swaps / resets the foreground & background swatches) | +| Fill Selection w/ Foreground | `selection.fill` | OK (fast path fills the selection/canvas with the foreground or background colour) | +| Cut / Copy / Paste | `selection.copy/paste` | **Gap** (no planner intent) | +| Rotate 90/180, Flip H/V | `layer.rotate/flip` | **Server+planner gap** (same as Edit menu) | +| Undo / Redo | `history.*` | Yes | + +## 4. Adjustments + +`adjustments.apply` / `adjustments.applyMasked` cover all adjustment types +(brightness-contrast, white-balance, shadows-highlights, vibrance, clarity, +vignette, black-white, …). The style guide emits several. **Status: OK** +(planner covers the common ones; the long tail is reachable only via explicit +wording). + +## 5. Filters + +`filters.apply` / `filters.applyMasked` run every filter the UI lists. The +planner only emits **sharpen**, **blur-gaussian**, and **vignette** via intents. +**Gap (planner):** no intent for noise, pixelate, stylize, distort, render, +unsharp, sharpen-edges, colour-overlay/drop-shadow/stroke. +**Gap (HARNESS):** `filters.apply`/`applyMasked` are in the server catalog, so +the fix is style-guide intents, not catalog. + +## 6. Layer styles + +`layers.addStyle {type, params}` exists in both catalogs. **Planner never emits +it**, so "add a drop shadow to the title", "add a stroke", "colour overlay" fail. +**Gap (planner).** + +## 7. Selection ops + +`selection.rectangle/ellipse/polygon/fill/delete/copy/paste/feather/expand/ +contract/selectAll/deselect/invert/colorRange/subject/samText/face/sky/ +background/hasMask/maskBounds/exportMask/regionLuminance`. **Status: mostly OK.** +Missing: reselect, save-selection. + +## 8. Document ops + +| Capability | Op | C | S | P | +|---|---|---|---|---| +| Crop | `document.crop` | ✔ | ✔ | ✔ | +| Resize canvas / image | `document.resizeCanvas` | ✔ | ✔ | partial | +| **Extend canvas** | `document.extend` | ✔ | ✘ | ✘ | +| Save PSD | `document.savePsd` | ✔ | ✔ | ✔ | +| Export layers | `document.exportLayers` | ✔ | ✘ | ✘ | +| Duplicate / Create / Switch / List | `document.*` | ✔ | ✘ | partial | + +## 9. AI edit & generate modes + +| Mode | Op | Notes | +|---|---|---| +| Generative fill | `ai.generativeFill` | OK | +| Remove object | `ai.removeObject` | OK | +| Harmonize | `ai.harmonize` | OK | +| Upscale | `ai.generativeUpscale` | OK | +| Generate image | `ai.generateImage` / server `generate_image` | OK | +| Expand | `ai.generativeExpand` | OK (content-bounds prefill, SW v760) | +| Remove background | `ai.removeBackground` | OK | +| Instruction edit / scene replace | server `ai_edit_image` | OK (HARNESS) | + +--- + +## 10. Gap list + +### Client-side (fix in this repo — PHOTOSHOP-owned) — **ALL FIXED** +1. ✅ **`fill.flood` not in the NL path** — added to `nl-edit.js OP_CATALOG` + + `localPlan` fill intent ("fill … with "; 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`. diff --git a/static/css/00-tokens.css b/static/css/00-tokens.css index 1105ba70c..b43865ac3 100644 --- a/static/css/00-tokens.css +++ b/static/css/00-tokens.css @@ -155,11 +155,34 @@ body { @font-face { font-family: 'Fira Code'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-Regular.woff2') format('woff2'); } @font-face { font-family: 'Fira Code'; font-weight: 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; diff --git a/static/css/13-image-editor.css b/static/css/13-image-editor.css index 1fb216bc2..b70bfc069 100644 --- a/static/css/13-image-editor.css +++ b/static/css/13-image-editor.css @@ -103,6 +103,543 @@ margin: 0 4px; } +/* Contextual tool options bar (between topbar and editor body) */ +.ge-options-bar { + display: flex; + align-items: center; + gap: 10px; + flex: 0 0 auto; + flex-wrap: nowrap; + row-gap: 4px; + min-height: 40px; + padding: 4px 10px; + box-sizing: border-box; + border-bottom: 1px solid var(--border); + background: color-mix(in srgb, var(--panel) 92%, var(--fg) 8%); + position: relative; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: thin; +} +/* A wand "More" dropdown must escape the bar's scroll clip while open. */ +.ge-options-bar:has(#ge-wand-more-menu:not([hidden])) { overflow: visible; } +.ge-options-bar:has(#ge-brush-more-menu:not([hidden])) { overflow: visible; } +.ge-options-bar:has(#ge-lasso-more-menu:not([hidden])) { overflow: visible; } +.ge-options-bar:has(#ge-sam-more-menu:not([hidden])) { overflow: visible; } +.ge-options-bar:has(#ge-quickselect-more-menu:not([hidden])) { overflow: visible; } +.ge-options-bar:has(#ge-objectselect-more-menu:not([hidden])) { overflow: visible; } +.ge-options-tool { + display: flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; + font-size: 11px; + font-weight: 600; + color: color-mix(in srgb, var(--fg) 85%, transparent); +} +.ge-options-tool-icon { + display: inline-flex; + width: 18px; + height: 18px; + align-items: center; + justify-content: center; +} +.ge-options-sep { + width: 1px; + height: 20px; + background: var(--border); + flex: 0 0 auto; +} +.ge-options-body { + display: flex; + align-items: center; + gap: 14px; + /* Shrink to content instead of growing: an empty mirror body otherwise + expands and leaves a large blank gap before the relocated tool controls, + pushing them away from the left edge of the strip. */ + flex: 0 1 auto; + min-width: 0; +} +/* Tool options moved out of the dock into the options bar (Photoshop): + the real controls flow inline on the SAME single thin row, horizontally + scrollable when they do not fit. Section headings/hints are dropped in + this compact strip; the authoritative sliders mirror the contextual + strip, so duplicated ranges are hidden. */ +.ge-options-bar .ge-controls.ge-controls--in-options { + /* Grow to fill the strip so the selection action row can be pinned to the + right edge of the screen. */ + flex: 1 1 auto; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + gap: 14px; + padding: 0; + margin: 0; + border: 0; + min-width: 0; + max-height: none; + overflow: visible; +} +.ge-controls--in-options .ge-section-title, +.ge-controls--in-options .ge-section-hint { display: none; } +/* The "rembg not installed" notice would otherwise be a two-line block and + double the strip height; keep the install action, drop the essay. */ +.ge-controls--in-options #ge-rembg-dep-missing { + padding: 0; + border: 0; + background: none; + gap: 8px; +} +.ge-controls--in-options #ge-rembg-dep-missing .ge-dep-notice-text { display: none; } +.ge-controls--in-options > section, +.ge-controls--in-options > div { flex: 0 0 auto; } +/* Lay out every visible tool section as one horizontal strip. Hidden + sections carry an inline display:none and are left untouched; visible + ones must override their inline display:block, hence !important. */ +.ge-controls--in-options > div:not([style*="display:none"]):not([style*="display: none"]) { + display: flex !important; + flex-direction: row !important; + flex-wrap: nowrap !important; + align-items: center; + gap: 12px; + /* Let the active tool section span the strip so its action row can be + pushed to the right edge. */ + flex: 1 1 auto; +} +/* The Text tool's option set is too wide for the thin strip: let the bar grow + and wrap onto extra rows instead of clipping the trailing fields. */ +.ge-options-bar.ge-options-tall { + flex-wrap: wrap; + align-items: center; + overflow-y: visible; + row-gap: 4px; + padding-top: 6px; + padding-bottom: 6px; +} +/* The active tool's name already sits in the rail; on the tall (Text) strip + its label would claim a whole line and shove the controls down. */ +.ge-options-bar.ge-options-tall .ge-options-tool { display: none; } +.ge-options-bar.ge-options-tall .ge-controls--in-options { + flex: 1 1 auto; + flex-wrap: wrap !important; + row-gap: 4px; +} +.ge-options-bar.ge-options-tall .ge-controls--in-options > div:not([style*="display:none"]):not([style*="display: none"]) { + flex-wrap: wrap !important; + row-gap: 4px; +} +/* The Text section's own field groups must wrap onto their own rows too, + otherwise the trailing format/Warp fields run past the right edge. */ +.ge-options-bar.ge-options-tall .ge-controls--in-options .ge-text-section { + display: flex !important; + flex-wrap: wrap !important; + align-items: flex-end; + gap: 8px; + width: 100%; +} +.ge-options-bar.ge-options-tall .ge-controls--in-options .ge-text-section > * { + flex: 0 0 auto; +} +.ge-options-bar.ge-options-tall .ge-controls--in-options .ge-text-section > textarea { + flex: 1 1 240px; + min-width: 240px; +} + +/* The brush sliders (Opacity/Flow/Softness) are a touch tight against the + strip width once the Color/Size/preset controls are inline; trim the + gutters so the whole brush strip fits on one line. */ +.ge-controls--in-options #ge-brush-section { gap: 6px; } +/* The brush controls (Color/Size/preset/caret) hug their content; only the + trailing slider section grows, so the Opacity/Flow/Softness group sits + right after the caret instead of leaving a wide gap. */ +.ge-controls--in-options #ge-brush-controls { flex: 0 0 auto; } +.ge-controls--in-options .ge-control-row { + margin: 0; + flex: 0 0 auto; + display: flex; + flex-direction: row; + align-items: center; + gap: 6px; +} +.ge-controls--in-options .ge-control-row label { white-space: nowrap; } +/* The selection action row (visibility, clear ×, Invert, Erase, Copy Layer, + To Mask, Quick Mask) rides a touch high in the thin strip; nudge it up 2px + to sit on the same optical line as the Style/Free row before it. */ +.ge-controls--in-options .ge-control-row.ge-actions { position: relative; top: -2px; margin-left: auto; } +/* The action buttons themselves ride ~2px below the mode/Style rows in the + thin strip; lift them so the whole row reads on one line (wand + marquee). */ +.ge-controls--in-options .ge-control-row.ge-actions .ge-btn { + position: relative; + top: -2px; +} +/* Pen selection: the action row belongs on the left edge (not pushed right by + the shared auto margin), and its ✓ should sit on the same optical line as + the ✕ in Cancel. */ +#ge-pen-section .ge-control-row.ge-actions { margin-left: 0; } +#ge-pen-commit svg { position: relative; top: 2px; } +/* Nudge the Clear ✕ glyph down 1px, and light it up (accent) while a + selection is active so it is obvious there is something to clear. */ +#ge-marquee-clear svg, +#ge-wand-clear svg, +#ge-lasso-clear svg, +#ge-sam-clear svg, +#ge-quickselect-clear svg, +#ge-objectselect-clear svg { position: relative; top: 1px; } +#ge-marquee-clear.has-selection, +#ge-wand-clear.has-selection, +#ge-lasso-clear.has-selection, +#ge-sam-clear.has-selection, +#ge-quickselect-clear.has-selection, +#ge-objectselect-clear.has-selection { color: var(--accent, var(--red)); border-color: color-mix(in srgb, var(--accent, var(--red)) 55%, var(--border)); } +/* The Clone "clear sampled source" ✕ matches the wand/marquee ✕: the SVG cross + sits 1px lower, and the button lights up (accent) while a source is set. */ +#ge-clone-source-clear svg { position: relative; top: -1px; } +#ge-clone-source-clear.has-selection svg { top: 1px; } +#ge-clone-source-clear.has-selection { color: var(--accent, var(--red)); border-color: color-mix(in srgb, var(--accent, var(--red)) 55%, var(--border)); } +/* Every clear ✕ is the same square target, wherever it lives: the wand/ + marquee/lasso/sam/quickselect/objectselect ones sit in the options strip + while the Clone one sits in the right panel, but they must read (and hit) + identically instead of inheriting each container's line-height. */ +#ge-marquee-clear, +#ge-wand-clear, +#ge-lasso-clear, +#ge-sam-clear, +#ge-quickselect-clear, +#ge-objectselect-clear, +#ge-clone-source-clear { + width: 26px; + height: 26px; + min-width: 26px; + min-height: 26px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + line-height: 1; +} +/* The marquee Style row lives in the nowrap options strip. When "Fixed + ratio/size" reveals the W/H inputs, their default intrinsic width + inflates the grid's max-content and the + + +

Recommended, not required — the vision agent plans edits and can double-check results.

+ +
+ + 1.4× +
+

How strongly named looks (Kodachrome, teal and orange, …) are graded.

+ + +
+ +
+ `; + + 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 }; +} diff --git a/static/js/editor/ai-tools-misc.js b/static/js/editor/ai-tools-misc.js index b9cb9fea1..094d2cdf8 100644 --- a/static/js/editor/ai-tools-misc.js +++ b/static/js/editor/ai-tools-misc.js @@ -215,21 +215,19 @@ export function wireAIToolsMisc({ function addEmptyLayer() { saveState('Add layer'); const layer = createLayer('Layer ' + state.layers.length, state.imgWidth, state.imgHeight); - state.layers.push(layer); + // Photoshop inserts the new layer directly above the selected one. + const activeIndex = state.layers.findIndex(item => item.id === state.activeLayerId); + const insertIndex = activeIndex >= 0 ? activeIndex + 1 : state.layers.length; + state.layers.splice(insertIndex, 0, layer); state.activeLayerId = layer.id; renderLayerPanel(); composite(); } - const addButton = document.getElementById('ge-add-layer'); - addButton?.addEventListener('click', event => { - event.preventDefault(); - event.stopPropagation(); + const buildFxMenu = (anchor) => { document.querySelector('.ge-add-layer-menu')?.remove(); const menu = document.createElement('div'); menu.className = 'ge-add-layer-menu ge-frosted'; menu.innerHTML = ` - - @@ -242,9 +240,16 @@ export function wireAIToolsMisc({ + + + + + + + `; document.body.appendChild(menu); - const rect = addButton.getBoundingClientRect(); + const rect = anchor.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`; @@ -253,7 +258,7 @@ export function wireAIToolsMisc({ document.removeEventListener('pointerdown', away, true); }; const away = pointerEvent => { - if (!menu.contains(pointerEvent.target) && pointerEvent.target !== addButton) close(); + if (!menu.contains(pointerEvent.target) && pointerEvent.target !== anchor) close(); }; requestAnimationFrame(() => document.addEventListener('pointerdown', away, true)); menu.addEventListener('click', clickEvent => { @@ -261,10 +266,26 @@ export function wireAIToolsMisc({ if (!button) return; clickEvent.preventDefault(); clickEvent.stopPropagation(); - if (button.dataset.layerKind === 'raster') addEmptyLayer(); - else if (button.dataset.adjustmentType) openAdjustmentLayer?.(button.dataset.adjustmentType, addButton); + if (button.dataset.adjustmentType) openAdjustmentLayer?.(button.dataset.adjustmentType, anchor); 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 }; diff --git a/static/js/editor/aspect-fit.js b/static/js/editor/aspect-fit.js new file mode 100644 index 000000000..55933f855 --- /dev/null +++ b/static/js/editor/aspect-fit.js @@ -0,0 +1,35 @@ +// Pure aspect-fitting geometry for the editor. When a whole-image resize is +// asked for a target aspect that differs from the current image, content must +// be cropped to that aspect (centred on a focus point) and then scaled +// UNIFORMLY — never stretched. +// +// aspectFitCropRect returns the document-space rectangle to crop FIRST so a +// subsequent uniform scale-to-{targetW,targetH} preserves the subject aspect +// ratio. Returns the full image unchanged when the aspect already matches. + +export function aspectFitCropRect({ width, height, focusX, focusY, targetAspect, tolerance = 0.005 } = {}) { + const w = Number(width); + const h = Number(height); + const a = Number(targetAspect); + if (!(w > 0) || !(h > 0) || !(a > 0)) return null; + const curAspect = w / h; + if (Math.abs(curAspect - a) / a <= tolerance) return { x: 0, y: 0, width: Math.round(w), height: Math.round(h) }; + const fx = Number.isFinite(Number(focusX)) ? Number(focusX) : w / 2; + const fy = Number.isFinite(Number(focusY)) ? Number(focusY) : h / 2; + let cw; + let ch; + if (curAspect > a) { + ch = h; + cw = Math.max(1, Math.round(ch * a)); + } else { + cw = w; + ch = Math.max(1, Math.round(cw / a)); + } + let x = Math.round(fx - cw / 2); + let y = Math.round(fy - ch / 2); + x = Math.max(0, Math.min(w - cw, x)); + y = Math.max(0, Math.min(h - ch, y)); + return { x, y, width: cw, height: ch }; +} + +export default { aspectFitCropRect }; diff --git a/static/js/editor/batch-runner.js b/static/js/editor/batch-runner.js new file mode 100644 index 000000000..f8dd421a4 --- /dev/null +++ b/static/js/editor/batch-runner.js @@ -0,0 +1,35 @@ +/** + * Batch runner — a small, dependency-free orchestrator that applies one action + * to every item in a list (open documents), isolating each item so a failure on + * one never aborts the batch. Pure async control flow: the caller supplies + * `runOne`, so the same loop is unit-testable without an editor. + */ + +/** + * @param {object} options + * @param {Array<{id:string,name?:string}>} options.documents + * @param {(doc:object, index:number)=>Promise} options.runOne + * @param {number} [options.delayMs] + * @param {(done:number,total:number,doc:object)=>void} [options.onProgress] + * @returns {Promise>} + */ +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 }; diff --git a/static/js/editor/blend-modes.js b/static/js/editor/blend-modes.js new file mode 100644 index 000000000..ee3f3f8e6 --- /dev/null +++ b/static/js/editor/blend-modes.js @@ -0,0 +1,256 @@ +/** + * Layer/graphic blend modes. + * + * Canvas2D can only composite a subset of the Photoshop blend modes natively. + * This module exposes that native set plus the "extra" modes PhotoCraft offers + * (Dissolve, Linear Burn, Darker Color, Linear Dodge/Add, Lighter Color, + * Vivid Light, Linear Light, Pin Light, Hard Mix, Subtract, Divide) and a + * single `blendDraw` entry point that both paths go through. + * + * The non-native modes follow the W3C compositing spec (separable + the two + * non-separable luminance modes) and operate on ImageData in the overlap of + * the source and the destination so rendering stays reasonably cheap. + */ + +export const NATIVE_BLEND_MODES = new Set([ + 'source-over', + 'multiply', + 'screen', + 'overlay', + 'darken', + 'lighten', + 'color-dodge', + 'color-burn', + 'hard-light', + 'soft-light', + 'difference', + 'exclusion', + 'hue', + 'saturation', + 'color', + 'luminosity', +]); + +const NATIVE_ALIASES = { + 'linear-dodge': 'lighter', + add: 'lighter', +}; + +export const EXTRA_BLEND_MODES = [ + { value: 'linear-dodge', label: 'Linear Dodge (Add)' }, + { value: 'linear-burn', label: 'Linear Burn' }, + { value: 'darker-color', label: 'Darker Color' }, + { value: 'lighter-color', label: 'Lighter Color' }, + { value: 'vivid-light', label: 'Vivid Light' }, + { value: 'linear-light', label: 'Linear Light' }, + { value: 'pin-light', label: 'Pin Light' }, + { value: 'hard-mix', label: 'Hard Mix' }, + { value: 'subtract', label: 'Subtract' }, + { value: 'divide', label: 'Divide' }, + { value: 'dissolve', label: 'Dissolve' }, +]; + +const NON_SEPARABLE = new Set(['darker-color', 'lighter-color']); + +export function isNativeBlendMode(mode) { + if (!mode) return true; + return NATIVE_BLEND_MODES.has(mode) || Object.prototype.hasOwnProperty.call(NATIVE_ALIASES, mode); +} + +export function isKnownBlendMode(mode) { + if (!mode) return true; + return isNativeBlendMode(mode) || EXTRA_BLEND_MODES.some(entry => entry.value === mode); +} + +function clamp01(value) { + return value < 0 ? 0 : value > 1 ? 1 : value; +} + +function lum(r, g, b) { + return 0.299 * r + 0.587 * g + 0.114 * b; +} + +// Separable per-channel blend functions on normalized [0,1] values. +function blendChannel(mode, cb, cs) { + switch (mode) { + case 'normal': + return cs; + case 'multiply': + return cb * cs; + case 'screen': + return cb + cs - cb * cs; + case 'overlay': + return cb <= 0.5 ? 2 * cb * cs : 1 - 2 * (1 - cb) * (1 - cs); + case 'darken': + return Math.min(cb, cs); + case 'lighten': + return Math.max(cb, cs); + case 'color-dodge': + if (cb === 0) return 0; + if (cs === 1) return 1; + return Math.min(1, cb / (1 - cs)); + case 'color-burn': + if (cb === 1) return 1; + if (cs === 0) return 0; + return 1 - Math.min(1, (1 - cb) / cs); + case 'hard-light': + return cs <= 0.5 ? 2 * cs * cb : 1 - 2 * (1 - cs) * (1 - cb); + case 'soft-light': { + if (cs <= 0.5) return cb - (1 - 2 * cs) * cb * (1 - cb); + const d = cb <= 0.25 ? ((16 * cb - 12) * cb + 4) * cb : Math.sqrt(cb); + return cb + (2 * cs - 1) * (d - cb); + } + case 'difference': + return Math.abs(cb - cs); + case 'exclusion': + return cb + cs - 2 * cb * cs; + case 'linear-burn': + return clamp01(cb + cs - 1); + case 'linear-dodge': + return clamp01(cb + cs); + case 'vivid-light': + return cs <= 0.5 + ? (cs === 0 ? 0 : 1 - Math.min(1, (1 - cb) / (2 * cs))) + : (cs === 1 ? 1 : Math.min(1, cb / (2 * (1 - cs)))); + case 'linear-light': + return clamp01(cb + 2 * cs - 1); + case 'pin-light': + return cs <= 0.5 ? Math.min(cb, 2 * cs) : Math.max(cb, 2 * cs - 1); + case 'hard-mix': { + const vivid = cs <= 0.5 + ? (cs === 0 ? 0 : 1 - Math.min(1, (1 - cb) / (2 * cs))) + : (cs === 1 ? 1 : Math.min(1, cb / (2 * (1 - cs)))); + return vivid < 0.5 ? 0 : 1; + } + case 'subtract': + return clamp01(cb - cs); + case 'divide': + return cs === 0 ? 1 : clamp01(cb / cs); + default: + return cs; + } +} + +function dissolveRandom(i) { + let seed = (i * 2654435761) >>> 0; + seed ^= seed >>> 13; + seed = (seed * 1274126177) >>> 0; + return seed / 4294967296; +} + +function ensureSourceData(source) { + const sw = source.width || source.naturalWidth || 0; + const sh = source.height || source.naturalHeight || 0; + if (!sw || !sh) return null; + const scratch = document.createElement('canvas'); + scratch.width = sw; + scratch.height = sh; + const sctx = scratch.getContext('2d'); + sctx.drawImage(source, 0, 0, sw, sh); + return { data: sctx.getImageData(0, 0, sw, sh).data, sw, sh }; +} + +function blendCustom(ctx, source, mode, alpha, dx, dy) { + if (alpha <= 0) return; + const src = ensureSourceData(source); + if (!src) return; + const tw = ctx.canvas.width; + const th = ctx.canvas.height; + const sw = src.sw; + const sh = src.sh; + const sx0 = Math.max(0, -dx); + const sy0 = Math.max(0, -dy); + const dx0 = Math.max(0, dx); + const dy0 = Math.max(0, dy); + const w = Math.min(sw - sx0, tw - dx0); + const h = Math.min(sh - sy0, th - dy0); + if (w <= 0 || h <= 0) return; + + const dst = ctx.getImageData(dx0, dy0, w, h); + const dd = dst.data; + const sd = src.data; + const nonSeparable = NON_SEPARABLE.has(mode); + const dissolve = mode === 'dissolve'; + + for (let y = 0; y < h; y += 1) { + for (let x = 0; x < w; x += 1) { + const si = ((y + sy0) * sw + (x + sx0)) * 4; + const di = (y * w + x) * 4; + const sa = (sd[si + 3] / 255) * alpha; + const ba = dd[di + 3] / 255; + + if (dissolve) { + const seed = (y + dy0) * 73856093 ^ (x + dx0) * 19349663; + if (dissolveRandom(seed) >= sa) continue; + dd[di] = sd[si]; + dd[di + 1] = sd[si + 1]; + dd[di + 2] = sd[si + 2]; + dd[di + 3] = 255; + continue; + } + + if (sa === 0) continue; + + const sr = sd[si] / 255; + const sg = sd[si + 1] / 255; + const sb = sd[si + 2] / 255; + const br = dd[di] / 255; + const bg = dd[di + 1] / 255; + const bb = dd[di + 2] / 255; + + let mergedR; + let mergedG; + let mergedB; + if (nonSeparable) { + const backdropLum = lum(br, bg, bb); + const sourceLum = lum(sr, sg, sb); + const useSource = mode === 'darker-color' ? sourceLum < backdropLum : sourceLum > backdropLum; + mergedR = useSource ? sr : br; + mergedG = useSource ? sg : bg; + mergedB = useSource ? sb : bb; + } else { + mergedR = blendChannel(mode, br, sr); + mergedG = blendChannel(mode, bg, sg); + mergedB = blendChannel(mode, bb, sb); + } + + const srEff = (1 - ba) * sr + ba * mergedR; + const sgEff = (1 - ba) * sg + ba * mergedG; + const sbEff = (1 - ba) * sb + ba * mergedB; + + const ao = sa + ba * (1 - sa); + if (ao <= 0) continue; + const cr = (sa * srEff + ba * br * (1 - sa)) / ao; + const cg = (sa * sgEff + ba * bg * (1 - sa)) / ao; + const cbOut = (sa * sbEff + ba * bb * (1 - sa)) / ao; + + dd[di] = Math.round(clamp01(cr) * 255); + dd[di + 1] = Math.round(clamp01(cg) * 255); + dd[di + 2] = Math.round(clamp01(cbOut) * 255); + dd[di + 3] = Math.round(ao * 255); + } + } + ctx.putImageData(dst, dx0, dy0); +} + +/** + * Composite `source` onto a 2D context (or canvas) using `mode` at `alpha`. + * Native modes use globalCompositeOperation; the rest fall back to pixel math. + */ +export function blendDraw(target, source, mode, alpha = 1, dx = 0, dy = 0) { + if (!target || !source) return; + const ctx = typeof target.getContext === 'function' ? target.getContext('2d') : target; + const a = Number.isFinite(alpha) ? clamp01(alpha) : 1; + const normalized = mode || 'source-over'; + if (isNativeBlendMode(normalized)) { + const prevAlpha = ctx.globalAlpha; + const prevOp = ctx.globalCompositeOperation; + ctx.globalAlpha = a; + ctx.globalCompositeOperation = NATIVE_ALIASES[normalized] || normalized; + ctx.drawImage(source, dx, dy); + ctx.globalAlpha = prevAlpha; + ctx.globalCompositeOperation = prevOp; + return; + } + blendCustom(ctx, source, normalized, a, dx, dy); +} diff --git a/static/js/editor/brush-engine.js b/static/js/editor/brush-engine.js index 3c6950f98..97c9d093a 100644 --- a/static/js/editor/brush-engine.js +++ b/static/js/editor/brush-engine.js @@ -24,6 +24,32 @@ 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) }; @@ -41,30 +67,14 @@ export function createBrushSampler({ getDiameter, getSpacing, getSmoothing, emit y: filtered.y + (raw.y - filtered.y) * response, pressure: filtered.pressure + (raw.pressure - filtered.pressure) * response, }; - 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)); + emitSpaced(filtered); }; 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) { - const finalSample = { ...sample, pressure: normalisePressure(sample.pressure) }; - if (Math.hypot(finalSample.x - emitted.x, finalSample.y - emitted.y) > 0.25) output(finalSample); + emitSpaced({ ...sample, pressure: normalisePressure(sample.pressure) }); } emitted = null; filtered = null; diff --git a/static/js/editor/brush-presets.js b/static/js/editor/brush-presets.js index 5c1eab246..dc1ad0f8d 100644 --- a/static/js/editor/brush-presets.js +++ b/static/js/editor/brush-presets.js @@ -4,6 +4,7 @@ const BUILT_INS = [ { id: 'soft-round', name: 'Soft Round', size: 80, opacity: 100, flow: 35, softness: 100, spacing: 12, smoothing: 25, blendMode: 'source-over' }, { id: '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) { @@ -31,6 +32,7 @@ export function captureBrushPreset(state, name) { spacing: state.brushSpacing, smoothing: state.brushSmoothing, blendMode: state.brushBlendMode, + pixel: !!state.brushPixel, }; } @@ -43,4 +45,5 @@ export function applyBrushPreset(state, preset) { state.brushSpacing = preset.spacing; state.brushSmoothing = preset.smoothing; state.brushBlendMode = preset.blendMode || 'source-over'; + state.brushPixel = !!preset.pixel; } diff --git a/static/js/editor/build/command-palette.js b/static/js/editor/build/command-palette.js new file mode 100644 index 000000000..2bef2c873 --- /dev/null +++ b/static/js/editor/build/command-palette.js @@ -0,0 +1,80 @@ +/** Command palette (Ctrl/Cmd+K): fuzzy list of tools, panels and actions. */ + +export function createCommandPalette({ commands = [], onRun = null } = {}) { + const overlay = document.createElement('div'); + overlay.className = 'ge-command-palette'; + overlay.hidden = true; + overlay.setAttribute('role', 'dialog'); + overlay.setAttribute('aria-modal', 'true'); + overlay.setAttribute('aria-label', 'Command palette'); + overlay.innerHTML = ` +
+ +
+
`; + 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, + }; +} diff --git a/static/js/editor/build/controls.js b/static/js/editor/build/controls.js index 74f9d1fcb..fafa4a0ac 100644 --- a/static/js/editor/build/controls.js +++ b/static/js/editor/build/controls.js @@ -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 }} ctx + * @param {{ color: string, brushSize: number, wandTolerance: number, bucketTolerance: number, cropAspectPreset: string, eyedropperSampleSize: number }} ctx * @returns {string} */ -export function controlsHTML({ color, brushSize, wandTolerance }) { +export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance = 32, cropAspectPreset = 'free', eyedropperSampleSize = 1 }) { const brushSliderValue = Math.round(Math.log(Math.max(1, brushSize)) / Math.log(800) * 1000); return ` +
Position
@@ -33,31 +55,105 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
-
- +
+ + + + +
-
Stroke
-
- - -
-
- - -
-
- - -
-
- Size - Opacity - Flow +
@@ -96,19 +192,47 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
+
+
+ + + +
+