Skip to content

Fix ten editor issues: selections, clipboard, colour picker and filters - #13

Merged
dariomory merged 11 commits into
masterfrom
fix/editor-round-1
Sep 3, 2026
Merged

dariomory merged 11 commits into
masterfrom
fix/editor-round-1

Conversation

@dariomory

@dariomory dariomory commented Sep 3, 2026 •

Copy link
Copy Markdown
Member

Fixes #3, Fixes #4, Fixes #5, Fixes #6, Fixes #7, Fixes #8, Fixes #9, Fixes #10, Fixes #11, Fixes #12, Fixes #16, Fixes #17, Fixes #18, Fixes #19

Fourteen issues, one commit each, in two rounds — ten reported up front, four more found during the QA pass. Issues #1 and #2 are deliberately not here; they have their own branches (#15 and #14) so they stay independently reviewable.

The bugs that were deeper than they looked

Painting ignored selections entirely (#3). paintAt drew straight onto the layer canvas with no clipping at all. Selections already carry a geometric path, so clipping now builds a Path2D and uses a canvas clip region rather than compositing through the raster mask — which matters because it stays correct for the eraser, and the eraser draws with destination-out.

Moving selected pixels was broken past the missing binding (#11). The compositor caches one GPU texture per layer generation, but the floating-selection code mutated the layer canvas through touchPixels, which bumps only the global generation. Pixels moved in memory while the screen showed a stale texture.

Only the first undo ever reached the screen (#16). The same cache, a different cause: pixelStore.replaceAll cleared the layer generations, so every restore reset them to 1 and from the second restore onward the generation matched what was already cached, so the upload was skipped. Generations are monotonic now.

Shortcuts died after touching a slider (#17). The key handler treated every <input> as text entry, and a range input keeps focus after a drag — so adjusting the brush size silently disabled Ctrl+Z until you clicked elsewhere. This is most of why undo felt erratic.

Paste from File Explorer never ran (#10, then again in QA). The Win32 CF_HDROP reader was correct throughout — src-tauri/examples/clipboard_files.rs exercises it without launching the app. The fault was that handlePaste checked the in-process copy first and returned unconditionally, so one in-app Ctrl+C shadowed every later paste from outside.

Everything in the branch

Issue Change
#3 Brush and eraser clipped to the active selection
#4 Lasso cursor rebuilt as a centred crosshair with its hotspot on the crosshair
#5 Brush resize uses Pointer Lock instead of pinning the OS cursor
#6 Ctrl+D deselects when a selection is active; Ctrl+J always duplicates
#7 Eyedropper returns to the previous tool after picking
#8 Colour picker: hue/saturation wheel, value slider, RGB/HSL/alpha/hex
#9 Ctrl+C and Ctrl+X on a selection, with an exact in-process paste
#10 Paste and drag-drop of image files from File Explorer
#11 Cursor tool moves the selected chunk; Alt+drag moves only the outline
#12 Adjustments and Filters under the Image menu, all selection-aware
#16 Undo and redo apply on every step, not just the first
#17 Shortcuts keep working after using a slider
#18 Filter dialog opens top-left and leaves the canvas undimmed
#19 Eraser sits above the eyedropper in the tool column

Incidental fixes found on the way: MenuBar gained submenu support, and two bugs fell out of it — the chevron was part of the accessible name, and clicking a submenu already opened by hover toggled it shut. Modal gained a dialog role. The options bar claimed brush resize was Ctrl+Alt+right-drag when the handler only wants Alt. The floating-selection drag preview re-encoded a document-sized PNG through toDataURL on every pointer move. The Edit menu read Redo Current instead of Redo Brush.

Verification

  • 57 unit tests, up from 21 — selection geometry, colour conversion, and every adjustment and effect. The filter maths works on a structural pixel buffer rather than ImageData so it is testable directly; jsdom has neither ImageData nor Path2D.
  • 11 behavioural checks in site/scripts/verify-editor.mjs (pnpm verify from site/) drive the real editor in a browser and sample the canvas: paint clipping in and out of a selection, brush resize monotonicity, moving pixels versus outline, cut/paste, filters scoped to a selection, filter preview and cancel, and undo of both a brush stroke and an erase.
  • pnpm exec tsc --noEmit and cargo check both clean.

Two things still need a human on the desktop build, because neither path exists in a browser: the Alt+right-drag resize (#5 — the original jitter came from the native SetCursorPos) and File Explorer paste and drag-drop (#10 — CF_HDROP is Win32 only).

Docs

site/src/content/docs/selections.md claimed painting was already clipped to the selection. It was not, until this branch. That page, the shortcut tables, the roadmap and the landing-page feature lists are corrected, and the screenshots are re-captured.

paintAt drew straight onto the layer canvas, so a stroke crossing a
marquee, lasso or wand boundary painted over the whole layer.

Selections already carry a geometric path, so clipping uses a canvas
clip region built from it rather than compositing through the raster
mask - that stays correct for the eraser, which draws with
destination-out. A selection that encloses nothing now clips everything
away instead of painting freely.

selectionGeometry is kept free of DOM types so it is unit testable;
jsdom has no Path2D. Adds site/scripts/verify-editor.mjs, which drives
the real editor in a browser and samples the canvas to prove paint lands
inside the selection and nowhere else.

Fixes #3
The lasso cursor was a rope glyph with its hotspot at 3 3, so the point
it actually selected from sat outside the drawn shape. Replaced with a
centred crosshair carrying a small lasso mark, hotspot on the crosshair,
drawn with a dark halo under a light stroke so it stays visible on both
light and dark photos - matching how the wand and eyedropper cursors are
built.

Fixes #4
Ctrl+D always duplicated the layer, which is rarely what you want while
a selection is active; it now deselects when there is a selection and
duplicates otherwise. Ctrl+J duplicates unconditionally, and the menus
name the new bindings.

The eyedropper also now hands back whichever tool was active before it,
so sampling a colour mid-stroke no longer costs a trip to the toolbar.

Fixes #6
Fixes #7
Alt+right-drag pinned the OS cursor with SetCursorPos after every move,
which fired a synthetic move of its own; the ignoreNext flag meant to
swallow those dropped real events too, so the size lurched.

Pointer lock gives relative deltas without moving the cursor at all, and
releasing it puts the cursor back where the drag began - the behaviour
that was being approximated. pin_cursor stays as the fallback when a
lock is refused.

The new brush-resize check asserts the size climbs without reversing.
It cannot reproduce the original jitter, which needed the native
SetCursorPos path, so the desktop build still wants a manual pass.

Fixes #5
Dragging inside a selection with Cursor (A) now floats and moves the
selected pixels; Alt+drag moves only the outline, which also carries the
hit test and the paint clip with it. The selection hit test runs before
the focus box so dragging inside a selection no longer flips to Move.

Moving pixels was silently broken beyond the missing binding. The
compositor caches one GPU texture per layer generation, and the floating
selection mutated the layer canvas through touchPixels, which bumps only
the global generation - so the pixels moved in memory while the screen
kept showing the stale texture. extractFloatingSelection and
stampFloatingSelection now invalidate the layer they mutate, the way the
paint path already did.

The drag preview also re-encoded a document-sized PNG through toDataURL
on every pointer move; it now draws the floating canvas directly.

Fixes #11
There was no way to choose a colour: the only route was sampling one
with the eyedropper, and both swatches were inert.

Adds a hue/saturation wheel with a value slider, plus RGB, HSL, alpha
and hex fields that all drive each other. HSV is held as the source of
truth while the dialog is open so dragging through black or grey does
not throw away the hue. Both the options bar chip and the properties
panel swatch now open it.

Also corrects the options bar hint, which promised Ctrl+Alt+right-drag
for brush size when the handler only wants Alt.

Fixes #8
Both Edit entries were greyed out, so a selection could be made but
never taken anywhere. Ctrl+C and Ctrl+X now render the selected region
through the selection mask, cropped to its bounds rather than the whole
document, and Cut clears it as one undoable step.

The region is kept in process as well as written to the system
clipboard, so pasting back inside Photovibe is exact instead of a PNG
round-trip. The menu entries enable and disable with the selection.

Fixes #9
Copying a file in Explorer puts a CF_HDROP path list on the clipboard
rather than a bitmap, so readImage saw nothing and Ctrl+V did nothing.
Adds a read_clipboard_files command that reads that list through the
Win32 clipboard API, and Paste now falls back to loading the first image
path when there is no bitmap.

Dropping a file did nothing either. The desktop webview swallows the DOM
drop event, so the app listens to Tauri onDragDropEvent for paths, with
an HTML5 drop handler covering the browser build. Both show a drop
target while a file is over the canvas.

Fixes #10
The Image menu offered only size, and the Filter menu was entirely
disabled. Adds Adjustments (brightness/contrast, exposure,
hue/saturation, levels, invert, grayscale, sepia) and Filters (gaussian
blur, sharpen, vignette, add noise), every one of which respects an
active marquee or lasso and falls back to the whole layer otherwise.

The dialog previews on the canvas itself by running the filter against
a snapshot of the affected region, so Cancel restores exactly and Apply
opens the history step on the unfiltered pixels rather than on a
preview.

The maths works on a structural pixel buffer rather than ImageData, so
it is unit tested directly - jsdom has no ImageData. Blur is separable
and weights colour by alpha so transparent pixels do not bleed dark
edges inward.

MenuBar grew submenu support along the way. Two bugs fell out of that:
the chevron was part of the accessible name, and clicking a submenu
already opened by hover toggled it shut. Modal now carries a dialog
role.

Fixes #12
The selections page claimed painting was already clipped to the
selection; it was not until this round, and the page now also covers
moving the chunk versus the outline, cut and copy, and filters.

Shortcuts corrected: duplicate layer moved to Ctrl+J, Ctrl+D deselects,
brush resize is Alt+right-drag rather than Ctrl+Alt, and the eyedropper
returns to the previous tool. Adds the colour picker and drag-and-drop.

The roadmap and the landing page lists move adjustments, filters and
cut/copy out of what is coming and into what works. Screenshots
re-captured for the new menus and options bar.
Paste from File Explorer never ran: handlePaste checked the in-process
copy first and returned unconditionally, so a single Ctrl+C inside the
app shadowed every later paste from outside it. The system clipboard is
now consulted first - our own Copy writes a PNG there too, and PNG round
trips losslessly, so nothing is given up. The Win32 CF_HDROP reader was
correct all along; src-tauri/examples/clipboard_files.rs exercises it
without launching the app.

Only the first undo reached the screen. replaceAll cleared the layer
generations, so every restore reset them to 1 and the compositor, which
caches one texture per layer generation, skipped the upload from the
second restore onwards. Generations are now monotonic. Redo also took
its label from the caller's placeholder, so the menu read Redo Current.

Shortcuts died after touching a slider: the key handler treated every
INPUT as text entry, and a range input keeps focus after a drag. Only
real text entry swallows shortcuts now.

The filter dialog covered and dimmed the canvas it was previewing on.
It now opens top-left and dims the interface around the canvas with four
panels, leaving the image itself at full brightness.

Eraser moved above the eyedropper in the tool column.

Fixes #16
Fixes #17
Fixes #18
Fixes #19
@dariomory

Copy link
Copy Markdown
Member Author

Second round — fixes from the first QA pass

Fixes #16, Fixes #17, Fixes #18, Fixes #19

Paste from File Explorer never ran (QA step 17). Not the Win32 code — that was correct. handlePaste checked the in-process copy first and returned unconditionally, so a single Ctrl+C inside the app shadowed every later paste from outside it. Since QA step 8 exercises in-app copy before step 17 gets there, it could never work in that order. The system clipboard is consulted first now; our own Copy also writes a PNG there, and PNG round-trips losslessly, so nothing is given up. src-tauri/examples/clipboard_files.rs exercises the CF_HDROP reader without launching the app.

Only the first undo reached the screen (#16). pixelStore.replaceAll cleared the layer generations, so every restore reset them to 1; Compositor.ensureLayerTexture caches one texture per layer generation, so from the second restore onwards the generation matched what was already cached and the upload was skipped. Pixels were restored correctly in memory the whole time — the screen kept the stale texture. Generations are monotonic now. The Edit menu also read Redo Current because the redo entry took its label from the caller's placeholder.

Shortcuts died after touching a slider (#17). The key handler treated every INPUT as text entry, and a range input keeps focus after a drag — so adjusting brush size silently disabled Ctrl+Z until you clicked elsewhere. This is most of why undo felt unreliable.

The filter dialog covered and dimmed the canvas it previews on (#18). It opens top-left now, and the dim is drawn as four panels around the canvas, so the image keeps its true colours while the interface recedes.

Eraser moved above the eyedropper (#19). Shortcuts unchanged.

Two new checks cover the undo regression: undo-brush asserts one undo removes exactly one stroke and redo restores it, and undo-eraser asserts an erase can be undone. The suite is 11 checks now, all passing.

@dariomory
dariomory merged commit e325679 into master Sep 3, 2026
2 checks passed
@dariomory
dariomory deleted the fix/editor-round-1 branch September 3, 2026 09:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment