From 884f6b3c3ec677e663b0f624f0e22029800e19b4 Mon Sep 17 00:00:00 2001 From: Sean Sun <1194458432@qq.com> Date: Sat, 25 Apr 2026 00:25:41 +0800 Subject: [PATCH] feat(image-editor): wire Marquee selection + Burn tool + Rotate View MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three more PS-staple tools moved out of the stub bucket. Bundled together because they're independent additions that don't conflict. ## Marquee (M) Adds a real marquee selection. Drag-to-define a rectangular selection; the selection persists in `EditorState.selection` (history-tracked, .json round-trip), shown as a static marching-ants outline (white dashes over black halo). Cmd/Ctrl+A select all (full canvas), Cmd/Ctrl+D deselect. - New `Interaction` kind in Canvas: `marquee-drawing` (mousedown → start; mousemove → update; mouseup commits via `onCommitSelection` if the drag is non-trivial). Live preview rendered after `renderTo` so it sits above all other content. - `EditorState.selection` lives in original-image preview-pixel space; parent shifts cropped-canvas coords back by the active crop origin before storing. - `render.ts` gains a `liveCanvas` flag (true on the live preview, false on export) — gates marching-ants chrome so it never bakes into pixels. - `OptionsBar` marquee variant: hint text + "Deselect" button when a selection is active. - `serialize.ts` adds `selection` to the parsed state. ## Burn Mirror of Dodge from PR #42. New `'burn'` value in the `Tool` union; `Canvas.tsx`'s dodge branch now handles dodge OR burn (white+lighter vs. black+multiply). Palette gets a Moon icon next to the dodge Sun. `OptionsBar` shares the brush variant with a burn-specific hint. Layer-list label distinguishes burn strokes. ## Rotate View (R) View-only canvas rotation. New `viewRotation` state (0/90/180/270) in ImageEditor; Workspace applies it as a CSS `rotate()` on the wrapper transform. Pixels are not modified — purely a viewing aid. Toggled by the R keyboard shortcut OR by clicking the palette button (intercepted in `trySetTool` since rotateView has no "tool mode" — every click just cycles the rotation). `STUB_TOOLS` shrinks: `marquee` and `rotateView` removed. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/image-editor/Canvas.tsx | 84 ++++++++++++++++++-- src/components/image-editor/OptionsBar.tsx | 43 ++++++++-- src/components/image-editor/ToolsPalette.tsx | 14 +++- src/components/image-editor/Workspace.tsx | 6 +- src/components/image-editor/tool-meta.ts | 3 - src/i18n/en.json | 4 + src/i18n/zh-CN.json | 4 + src/lib/image-editor/render.ts | 43 ++++++++++ src/lib/image-editor/serialize.ts | 1 + src/lib/image-editor/types.ts | 9 +++ src/pages/ImageEditor.tsx | 67 +++++++++++++++- 11 files changed, 258 insertions(+), 20 deletions(-) diff --git a/src/components/image-editor/Canvas.tsx b/src/components/image-editor/Canvas.tsx index 11b190d..4b39642 100644 --- a/src/components/image-editor/Canvas.tsx +++ b/src/components/image-editor/Canvas.tsx @@ -82,6 +82,13 @@ type Props = { * pixels at scale=previewScale). Layer commit happens in the parent. */ onCommitGradient?: (start: Point, end: Point) => void + /** + * Called by the Marquee tool when a non-trivial drag commits. Rect is in + * cropped-canvas preview-pixel space; the parent shifts by the active + * crop origin to land in original-image preview-pixel space (matching how + * shape coords are stored). + */ + onCommitSelection?: (rect: { x: number; y: number; w: number; h: number }) => void } type Interaction = @@ -107,6 +114,8 @@ type Interaction = | { kind: 'crop-pending'; rect: { x: number; y: number; w: number; h: number } } /** Gradient drag in progress — start + current end point in canvas pixels. */ | { kind: 'gradient-drawing'; start: Point; end: Point } + /** Marquee selection drag — rect in canvas-pixel space. */ + | { kind: 'marquee-drawing'; rect: { x: number; y: number; w: number; h: number } } export const Canvas = forwardRef(function Canvas( { @@ -126,6 +135,7 @@ export const Canvas = forwardRef(function Canvas( onCommitCrop, onBucketClick, onCommitGradient, + onCommitSelection, }, ref, ) { @@ -187,6 +197,7 @@ export const Canvas = forwardRef(function Canvas( interaction.kind === 'drawing' ? { layer: interaction.layer } : undefined, selection: selectionLayer ? { layer: selectionLayer } : undefined, imageCache, + liveCanvas: true, }) // Crop preview overlay — drawn AFTER the image render so it sits on top. // Lives only on the live canvas; the export canvas (separate renderTo @@ -198,6 +209,11 @@ export const Canvas = forwardRef(function Canvas( if (interaction.kind === 'gradient-drawing') { drawGradientOverlay(canvasRef.current, interaction.start, interaction.end) } + // Marquee preview rect — same look as the committed selection so the user + // sees the live shape they're drawing. + if (interaction.kind === 'marquee-drawing') { + drawMarqueePreview(canvasRef.current, interaction.rect) + } }, [image, effectiveState, interaction, selectionLayer, previewScale, imageCache]) useImperativeHandle( @@ -312,6 +328,12 @@ export const Canvas = forwardRef(function Canvas( return } + // Marquee: drag a rectangular selection. Commit on mouseup if non-trivial. + if (tool === 'marquee') { + setInteraction({ kind: 'marquee-drawing', rect: { x: p.x, y: p.y, w: 0, h: 0 } }) + return + } + // Drawing tools take priority over selection. if (tool !== 'none') { startDrawing(p) @@ -372,6 +394,15 @@ export const Canvas = forwardRef(function Canvas( return } + if (interaction.kind === 'marquee-drawing') { + const r = interaction.rect + setInteraction({ + kind: 'marquee-drawing', + rect: { x: r.x, y: r.y, w: p.x - r.x, h: p.y - r.y }, + }) + return + } + if (interaction.kind === 'drawing') { updateDrawing(p) return @@ -415,6 +446,14 @@ export const Canvas = forwardRef(function Canvas( setInteraction({ kind: 'idle' }) return } + if (interaction.kind === 'marquee-drawing') { + const r = interaction.rect + if (Math.abs(r.w) >= 4 && Math.abs(r.h) >= 4) { + onCommitSelection?.(r) + } + setInteraction({ kind: 'idle' }) + return + } if (interaction.kind === 'drawing') { if (!shouldDiscardDrawing(interaction.layer)) { onCommitLayer(interaction.layer) @@ -512,21 +551,23 @@ export const Canvas = forwardRef(function Canvas( }, } as AnnotationLayer, }) - } else if (tool === 'dodge') { - // Dodge layer defaults to 30% opacity so a single stroke gives a soft - // brightening — repeat strokes to build it up, just like PS. + } else if (tool === 'dodge' || tool === 'burn') { + // Dodge / Burn share the brush-stroke + low-opacity build-up pattern. + // Burn paints black with 'multiply' op for darkening; dodge paints + // white with 'lighter' for brightening. + const isBurn = tool === 'burn' setInteraction({ kind: 'drawing', layer: { - ...baseLayer('Dodge'), + ...baseLayer(isBurn ? 'Burn' : 'Dodge'), opacity: 30 as const, kind: 'annotation', shape: { kind: 'brush', points: [p], - color: '#ffffff', + color: isBurn ? '#000000' : '#ffffff', strokeWidth: toolStrokeWidth, - mode: 'dodge', + mode: isBurn ? 'burn' : 'dodge', }, } as AnnotationLayer, }) @@ -611,7 +652,7 @@ export const Canvas = forwardRef(function Canvas( setHoverCursor('crosshair') return } - if (tool === 'bucket' || tool === 'gradient') { + if (tool === 'bucket' || tool === 'gradient' || tool === 'marquee') { setHoverCursor('crosshair') return } @@ -774,6 +815,35 @@ function drawGradientOverlay( ctx.restore() } +/** + * In-progress marquee selection preview — same look as the committed selection + * (white dashes over a black halo). Coords in canvas-pixel space; identity + * transform keeps placement crisp. + */ +function drawMarqueePreview( + canvas: HTMLCanvasElement | null, + rect: { x: number; y: number; w: number; h: number }, +) { + if (!canvas) return + const ctx = canvas.getContext('2d') + if (!ctx) return + const rx = Math.min(rect.x, rect.x + rect.w) + const ry = Math.min(rect.y, rect.y + rect.h) + const rw = Math.abs(rect.w) + const rh = Math.abs(rect.h) + if (rw < 1 || rh < 1) return + ctx.save() + ctx.setTransform(1, 0, 0, 1, 0, 0) + ctx.strokeStyle = '#000000' + ctx.lineWidth = 1 + ctx.strokeRect(rx + 0.5, ry + 0.5, rw - 1, rh - 1) + ctx.strokeStyle = '#ffffff' + ctx.setLineDash([4, 3]) + ctx.strokeRect(rx + 0.5, ry + 0.5, rw - 1, rh - 1) + ctx.setLineDash([]) + ctx.restore() +} + /** * Read a single pixel from the canvas at the given bitmap coords and return * its colour as #rrggbb. Out-of-bounds clicks return null. diff --git a/src/components/image-editor/OptionsBar.tsx b/src/components/image-editor/OptionsBar.tsx index c90c5a5..60a8bb1 100644 --- a/src/components/image-editor/OptionsBar.tsx +++ b/src/components/image-editor/OptionsBar.tsx @@ -18,6 +18,9 @@ type Props = { /** True when an applied crop is in state — surfaces "Clear crop" button. */ hasActiveCrop?: boolean onClearCrop?: () => void + /** True when a marquee selection is active — surfaces "Deselect" button. */ + hasSelection?: boolean + onClearSelection?: () => void } /** @@ -41,6 +44,8 @@ export function OptionsBar({ stubMessage, hasActiveCrop, onClearCrop, + hasSelection, + onClearSelection, }: Props) { const { t } = useTranslation() @@ -56,9 +61,35 @@ export function OptionsBar({ ) } - // Brush / pencil / eraser / dodge — stroke width (+ color for brush only; - // dodge always paints white via 'lighter' composite, eraser cuts alpha). - if (tool === 'brush' || tool === 'eraser' || tool === 'dodge') { + // Marquee — show selection-state hint + "Deselect" button when active. + if (tool === 'marquee') { + return ( +
+
+ + {t('pages.imageEditor.marqueeHint')} + +
+ {hasSelection && ( +
+ +
+ )} +
+ ) + } + + // Brush / pencil / eraser / dodge / burn — stroke width (+ color for brush + // only; dodge/burn override via mode). + if (tool === 'brush' || tool === 'eraser' || tool === 'dodge' || tool === 'burn') { return (
@@ -91,10 +122,12 @@ export function OptionsBar({ />
)} - {tool === 'dodge' && ( + {(tool === 'dodge' || tool === 'burn') && (
- {t('pages.imageEditor.dodgeHint')} + {tool === 'burn' + ? t('pages.imageEditor.burnHint') + : t('pages.imageEditor.dodgeHint')}
)} diff --git a/src/components/image-editor/ToolsPalette.tsx b/src/components/image-editor/ToolsPalette.tsx index 69bc26a..5137ceb 100644 --- a/src/components/image-editor/ToolsPalette.tsx +++ b/src/components/image-editor/ToolsPalette.tsx @@ -11,10 +11,12 @@ import { Frame, Hand, Lasso, + Moon, MousePointer2, PaintBucket, PenTool, Pipette, + RefreshCw, RotateCw, Ruler, ScanLine, @@ -54,7 +56,6 @@ const GROUPS: ToolDef[][] = [ icon: , labelKey: 'pages.imageEditor.tool.marquee', shortcut: 'M', - stub: true, }, { id: 'lasso', @@ -121,6 +122,11 @@ const GROUPS: ToolDef[][] = [ labelKey: 'pages.imageEditor.tool.dodge', shortcut: 'O', }, + { + id: 'burn', + icon: , + labelKey: 'pages.imageEditor.tool.burn', + }, ], // 3. Vector / type group [ @@ -161,6 +167,12 @@ const GROUPS: ToolDef[][] = [ shortcut: 'H', stub: true, }, + { + id: 'rotateView', + icon: , + labelKey: 'pages.imageEditor.tool.rotateView', + shortcut: 'R', + }, { id: 'zoom', icon: , labelKey: 'pages.imageEditor.tool.zoom', shortcut: 'Z' }, ], ] diff --git a/src/components/image-editor/Workspace.tsx b/src/components/image-editor/Workspace.tsx index 05a265b..d72bf87 100644 --- a/src/components/image-editor/Workspace.tsx +++ b/src/components/image-editor/Workspace.tsx @@ -20,6 +20,8 @@ type Props = { setPan: (pan: { x: number; y: number }) => void /** True while Space is held — workspace handles drag-to-pan. */ panMode: boolean + /** View-only canvas rotation (degrees) — applied as CSS rotate to the wrapper. */ + viewRotation?: 0 | 90 | 180 | 270 /** Cmd/Ctrl + wheel callback. (clientX, clientY, factor). */ onWheelZoom?: (clientX: number, clientY: number, factor: number) => void /** A file dragged into the workspace from the desktop. */ @@ -37,7 +39,7 @@ type Props = { * logic runs against the bubbled event. */ export const Workspace = forwardRef(function Workspace( - { zoom, pan, setPan, panMode, onWheelZoom, onDropFile, children }, + { zoom, pan, setPan, panMode, viewRotation = 0, onWheelZoom, onDropFile, children }, ref, ) { const outerRef = useRef(null) @@ -134,7 +136,7 @@ export const Workspace = forwardRef(function Workspace( ref={wrapperRef} className="rounded shadow-lg ring-1 ring-black/20" style={{ - transform: `translate(${effectivePan.x}px, ${effectivePan.y}px) scale(${zoom})`, + transform: `translate(${effectivePan.x}px, ${effectivePan.y}px) scale(${zoom}) rotate(${viewRotation}deg)`, transformOrigin: 'center', transition: dragging ? 'none' : 'transform 90ms ease-out', }} diff --git a/src/components/image-editor/tool-meta.ts b/src/components/image-editor/tool-meta.ts index 9300f53..27b9c13 100644 --- a/src/components/image-editor/tool-meta.ts +++ b/src/components/image-editor/tool-meta.ts @@ -8,7 +8,6 @@ import type { Tool } from '@/lib/image-editor/types' * duplication isn't worth a build-time dance. */ export const STUB_TOOLS: ReadonlySet = new Set([ - 'marquee', 'lasso', 'polyLasso', 'wand', @@ -17,8 +16,6 @@ export const STUB_TOOLS: ReadonlySet = new Set([ 'historyBrush', 'pen', 'arrowPath', - 'hand', - 'rotateView', 'frame', 'note', ]) diff --git a/src/i18n/en.json b/src/i18n/en.json index d0f17bc..448574a 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -528,6 +528,10 @@ "gradientHint": "Gradient (G): drag from start to end. Foreground at the start, background at the end. Result lands as a new layer.", "blurHint": "Blur: drag a rect on the canvas — the area inside gets blurred at render time. Adjustable via the layer's bounds; default radius is 8 px.", "dodgeHint": "Dodge (O): drag to brighten — like PS, repeat strokes to build up the effect. Layer opacity controls intensity.", + "burnHint": "Burn: drag to darken. Repeat strokes to build up; layer opacity controls intensity.", + "marqueeHint": "Marquee (M): drag a rectangular selection. Cmd/Ctrl+A select all · Cmd/Ctrl+D deselect.", + "deselect": "Deselect", + "rotateViewHint": "Rotate View (R): each click rotates the workspace 90°. Pixels are not modified — purely a viewing aid.", "textToolHint": "Type (T): click on the canvas to add text.", "toolStubHint": "{{tool}} is a placeholder — the palette button is here for parity with Photoshop, but the tool isn't implemented yet.", "toolStubToast": "{{tool}} is not yet implemented.", diff --git a/src/i18n/zh-CN.json b/src/i18n/zh-CN.json index f3c1ced..30ccd1c 100644 --- a/src/i18n/zh-CN.json +++ b/src/i18n/zh-CN.json @@ -528,6 +528,10 @@ "gradientHint": "渐变 (G):从起点拖到终点,起点用前景色,终点用背景色,结果作为新图层。", "blurHint": "模糊:在画布上拖出一个矩形 —— 渲染时该区域会被模糊。模糊半径默认 8 px,区域大小可拖角调整。", "dodgeHint": "减淡 (O):拖动以提亮 —— 与 PS 一致,重复涂抹可叠加效果。图层不透明度控制强度。", + "burnHint": "加深:拖动以压暗。重复涂抹叠加效果,图层不透明度控制强度。", + "marqueeHint": "矩形选框 (M):拖出矩形选区。Cmd/Ctrl+A 全选 · Cmd/Ctrl+D 取消选区。", + "deselect": "取消选区", + "rotateViewHint": "旋转视图 (R):每次点击旋转工作区 90°,仅影响显示,不修改像素。", "textToolHint": "文字 (T):在画布上单击以新增文字。", "toolStubHint": "{{tool}} 仅作占位 —— 工具栏按钮是为了对齐 Photoshop 而保留,但功能尚未实现。", "toolStubToast": "{{tool}} 暂未实现。", diff --git a/src/lib/image-editor/render.ts b/src/lib/image-editor/render.ts index 2f2669f..750a108 100644 --- a/src/lib/image-editor/render.ts +++ b/src/lib/image-editor/render.ts @@ -33,6 +33,11 @@ export type RenderInput = { selection?: { layer: Layer } /** Cache of HTMLImageElements keyed by dataUrl, for image-shape layers. */ imageCache?: ImageCache + /** + * True when rendering the live canvas (vs. export). Gates UI-only chrome + * like marching-ants marquee selection that shouldn't bake into exports. + */ + liveCanvas?: boolean } export function dimsAfterRotation( @@ -203,6 +208,44 @@ export function renderTo(canvas: HTMLCanvasElement, input: RenderInput): void { if (input.selection) { drawSelectionChrome(ctx, shiftForCrop(input.selection.layer), annoScale) } + + // Marquee selection (state.selection) — UI affordance only, gated on + // `liveCanvas` so it never bakes into an export. + if (input.liveCanvas && state.selection) { + drawMarqueeChrome(ctx, state.selection, cropOriginX, cropOriginY, annoScale) + } +} + +/** + * Draw the active marquee selection — a dashed white outline (with a thin + * black halo for legibility on any background). Coords are in original-image + * preview-pixel space; we shift by -cropOrigin to land in cropped-canvas + * coords, then scale. + */ +function drawMarqueeChrome( + ctx: CanvasRenderingContext2D, + sel: Rect, + cropOriginX: number, + cropOriginY: number, + scale: number, +) { + const r = normalizeRect(sel) + const x = (r.x - cropOriginX) * scale + const y = (r.y - cropOriginY) * scale + const w = r.w * scale + const h = r.h * scale + ctx.save() + // Black halo + ctx.strokeStyle = '#000000' + ctx.lineWidth = Math.max(1, scale) + ctx.setLineDash([]) + ctx.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1) + // White dashes on top + ctx.strokeStyle = '#ffffff' + ctx.setLineDash([4 * scale, 3 * scale]) + ctx.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1) + ctx.setLineDash([]) + ctx.restore() } /** diff --git a/src/lib/image-editor/serialize.ts b/src/lib/image-editor/serialize.ts index ac33817..a204987 100644 --- a/src/lib/image-editor/serialize.ts +++ b/src/lib/image-editor/serialize.ts @@ -46,6 +46,7 @@ export function parseProject(text: string): Project { transforms: { ...fallback.transforms, ...obj.state.transforms }, adjust: { ...fallback.adjust, ...obj.state.adjust }, cropRect: obj.state.cropRect, + selection: obj.state.selection, }, } } diff --git a/src/lib/image-editor/types.ts b/src/lib/image-editor/types.ts index fb00504..bcaf465 100644 --- a/src/lib/image-editor/types.ts +++ b/src/lib/image-editor/types.ts @@ -67,6 +67,7 @@ export type Tool = | 'rotateView' | 'frame' | 'note' + | 'burn' export type Transforms = { rotation: Rotation @@ -227,6 +228,14 @@ export type EditorState = { layers: Layer[] // ordered bottom→top transforms: Transforms adjust: Adjustments + /** + * Active marquee selection. Stored in the same coordinate space shape coords + * use (post-rotation preview-pixel space, relative to the original image). + * Other tools (bucket / brush / fill) can later restrict their effect to + * this rect. Cleared via Cmd+D, replaced via re-marquee, persisted through + * undo/redo + .json save. + */ + selection?: Rect /** * Optional crop region. Stored in the same coordinate space shape coords use * (post-rotation preview-canvas pixels), so it's applied after transforms. diff --git a/src/pages/ImageEditor.tsx b/src/pages/ImageEditor.tsx index 3a53d48..8918b97 100644 --- a/src/pages/ImageEditor.tsx +++ b/src/pages/ImageEditor.tsx @@ -95,6 +95,12 @@ export function ImageEditorPage() { const [zoom, setZoom] = useState(1) const [pan, setPan] = useState({ x: 0, y: 0 }) const [panMode, setPanMode] = useState(false) + /** + * View-only canvas rotation in degrees (0 / 90 / 180 / 270). Lives outside + * EditorState because it doesn't affect pixels — just how the canvas is + * displayed in the workspace. Cycled by the Rotate View tool (R). + */ + const [viewRotation, setViewRotation] = useState<0 | 90 | 180 | 270>(0) const ZOOM_MIN = 0.1 const ZOOM_MAX = 8 @@ -135,9 +141,15 @@ export function ImageEditorPage() { [t], ) - // Try to set tool; if it's in the stub set, show a toast and don't change state. + // Try to set tool; if it's in the stub set, show a toast and don't change + // state. Rotate View doesn't have a tool mode — it's a one-shot action that + // cycles the workspace rotation 0 → 90 → 180 → 270 → 0 each time clicked. const trySetTool = useCallback( (next: Tool) => { + if (next === 'rotateView') { + setViewRotation((r) => ((r + 90) % 360) as 0 | 90 | 180 | 270) + return + } if (STUB_TOOLS.has(next)) { stubMsg(t(`pages.imageEditor.tool.${next}`)) return @@ -168,6 +180,25 @@ export function ImageEditorPage() { if (e.key === 'j' || e.key === 'J') { e.preventDefault(); duplicateRef.current(); return } if (e.key === ']') { e.preventDefault(); moveLayerRef.current(e.shiftKey ? 'front' : 'forward'); return } if (e.key === '[') { e.preventDefault(); moveLayerRef.current(e.shiftKey ? 'back' : 'backward'); return } + // Cmd+D = deselect, Cmd+A = select all (PS conventions). Both no-op + // when there's no image yet. + if (e.key === 'd' || e.key === 'D') { + e.preventDefault() + history.set({ ...state, selection: undefined }) + return + } + if (e.key === 'a' || e.key === 'A') { + e.preventDefault() + if (image) { + const { baseW, baseH } = dimsAfterRotation(image, state) + const previewScale = Math.min(1, PREVIEW_MAX / Math.max(baseW, baseH, 1)) + history.set({ + ...state, + selection: { x: 0, y: 0, w: baseW * previewScale, h: baseH * previewScale }, + }) + } + return + } return } @@ -180,6 +211,12 @@ export function ImageEditorPage() { } if (e.key === 'f' || e.key === 'F') { e.preventDefault(); setFocused((v) => !v); return } + // Rotate View — R cycles the workspace display rotation 0→90→180→270. + if (e.key === 'r' || e.key === 'R') { + e.preventDefault() + setViewRotation((r) => ((r + 90) % 360) as 0 | 90 | 180 | 270) + return + } if (e.key === 'Enter' && canvasRef.current?.hasPendingCrop()) { e.preventDefault() canvasRef.current.commitPendingCrop() @@ -231,7 +268,7 @@ export function ImageEditorPage() { window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keyup', onKeyUp) } - }, [focused, zoomIn, zoomOut, zoomReset, swapColors, resetColors, selectedLayerId, trySetTool]) + }, [focused, zoomIn, zoomOut, zoomReset, swapColors, resetColors, selectedLayerId, trySetTool, history, state, image]) // ── Layer state helpers ────────────────────────────────────────────────── const setLayers = useCallback( @@ -320,6 +357,28 @@ export function ImageEditorPage() { }, [history, state, t], ) + /** + * Commit a marquee selection — `rect` arrives in cropped-canvas + * preview-pixel space; we shift back by the crop origin to land in + * original-image preview-pixel space (where shape coords live). + */ + const handleCommitSelection = useCallback( + (rect: { x: number; y: number; w: number; h: number }) => { + const cropOriginX = state.cropRect + ? Math.min(state.cropRect.x, state.cropRect.x + state.cropRect.w) + : 0 + const cropOriginY = state.cropRect + ? Math.min(state.cropRect.y, state.cropRect.y + state.cropRect.h) + : 0 + const x0 = Math.min(rect.x, rect.x + rect.w) + cropOriginX + const y0 = Math.min(rect.y, rect.y + rect.h) + cropOriginY + const w = Math.abs(rect.w) + const h = Math.abs(rect.h) + history.set({ ...state, selection: { x: x0, y: y0, w, h } }) + }, + [history, state], + ) + const handleClearCrop = useCallback(() => { if (!state.cropRect) return history.set({ ...state, cropRect: undefined }) @@ -654,6 +713,8 @@ export function ImageEditorPage() { isStubTool={STUB_TOOLS.has(tool)} hasActiveCrop={!!state.cropRect} onClearCrop={handleClearCrop} + hasSelection={!!state.selection} + onClearSelection={() => history.set({ ...state, selection: undefined })} /> @@ -722,6 +784,7 @@ export function ImageEditorPage() { onBucketClick={handleBucketFill} bucketTolerance={bucketTolerance} onCommitGradient={handleCommitGradient} + onCommitSelection={handleCommitSelection} />