Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 77 additions & 7 deletions src/components/image-editor/Canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -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<CanvasHandle, Props>(function Canvas(
{
Expand All @@ -126,6 +135,7 @@ export const Canvas = forwardRef<CanvasHandle, Props>(function Canvas(
onCommitCrop,
onBucketClick,
onCommitGradient,
onCommitSelection,
},
ref,
) {
Expand Down Expand Up @@ -187,6 +197,7 @@ export const Canvas = forwardRef<CanvasHandle, Props>(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
Expand All @@ -198,6 +209,11 @@ export const Canvas = forwardRef<CanvasHandle, Props>(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(
Expand Down Expand Up @@ -312,6 +328,12 @@ export const Canvas = forwardRef<CanvasHandle, Props>(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)
Expand Down Expand Up @@ -372,6 +394,15 @@ export const Canvas = forwardRef<CanvasHandle, Props>(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
Expand Down Expand Up @@ -415,6 +446,14 @@ export const Canvas = forwardRef<CanvasHandle, Props>(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)
Expand Down Expand Up @@ -512,21 +551,23 @@ export const Canvas = forwardRef<CanvasHandle, Props>(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,
})
Expand Down Expand Up @@ -611,7 +652,7 @@ export const Canvas = forwardRef<CanvasHandle, Props>(function Canvas(
setHoverCursor('crosshair')
return
}
if (tool === 'bucket' || tool === 'gradient') {
if (tool === 'bucket' || tool === 'gradient' || tool === 'marquee') {
setHoverCursor('crosshair')
return
}
Expand Down Expand Up @@ -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.
Expand Down
43 changes: 38 additions & 5 deletions src/components/image-editor/OptionsBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

/**
Expand All @@ -41,6 +44,8 @@ export function OptionsBar({
stubMessage,
hasActiveCrop,
onClearCrop,
hasSelection,
onClearSelection,
}: Props) {
const { t } = useTranslation()

Expand All @@ -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 (
<div className="pf-options">
<div className="pf-opt-group">
<span className="pf-opt-label" style={{ marginRight: 0 }}>
{t('pages.imageEditor.marqueeHint')}
</span>
</div>
{hasSelection && (
<div className="pf-opt-group" style={{ borderRight: 0 }}>
<button
type="button"
className="pf-opt-btn"
onClick={onClearSelection}
style={{ width: 'auto', padding: '0 8px' }}
title={t('pages.imageEditor.deselect')}
>
{t('pages.imageEditor.deselect')}
</button>
</div>
)}
</div>
)
}

// 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 (
<div className="pf-options">
<div className="pf-opt-group">
Expand Down Expand Up @@ -91,10 +122,12 @@ export function OptionsBar({
/>
</div>
)}
{tool === 'dodge' && (
{(tool === 'dodge' || tool === 'burn') && (
<div className="pf-opt-group" style={{ borderRight: 0 }}>
<span className="pf-opt-label" style={{ marginRight: 0 }}>
{t('pages.imageEditor.dodgeHint')}
{tool === 'burn'
? t('pages.imageEditor.burnHint')
: t('pages.imageEditor.dodgeHint')}
</span>
</div>
)}
Expand Down
14 changes: 13 additions & 1 deletion src/components/image-editor/ToolsPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ import {
Frame,
Hand,
Lasso,
Moon,
MousePointer2,
PaintBucket,
PenTool,
Pipette,
RefreshCw,
RotateCw,
Ruler,
ScanLine,
Expand Down Expand Up @@ -54,7 +56,6 @@ const GROUPS: ToolDef[][] = [
icon: <SquareDashed className="h-4 w-4" />,
labelKey: 'pages.imageEditor.tool.marquee',
shortcut: 'M',
stub: true,
},
{
id: 'lasso',
Expand Down Expand Up @@ -121,6 +122,11 @@ const GROUPS: ToolDef[][] = [
labelKey: 'pages.imageEditor.tool.dodge',
shortcut: 'O',
},
{
id: 'burn',
icon: <Moon className="h-4 w-4" />,
labelKey: 'pages.imageEditor.tool.burn',
},
],
// 3. Vector / type group
[
Expand Down Expand Up @@ -161,6 +167,12 @@ const GROUPS: ToolDef[][] = [
shortcut: 'H',
stub: true,
},
{
id: 'rotateView',
icon: <RefreshCw className="h-4 w-4" />,
labelKey: 'pages.imageEditor.tool.rotateView',
shortcut: 'R',
},
{ id: 'zoom', icon: <Search className="h-4 w-4" />, labelKey: 'pages.imageEditor.tool.zoom', shortcut: 'Z' },
],
]
Expand Down
6 changes: 4 additions & 2 deletions src/components/image-editor/Workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -37,7 +39,7 @@ type Props = {
* logic runs against the bubbled event.
*/
export const Workspace = forwardRef<WorkspaceHandle, Props>(function Workspace(
{ zoom, pan, setPan, panMode, onWheelZoom, onDropFile, children },
{ zoom, pan, setPan, panMode, viewRotation = 0, onWheelZoom, onDropFile, children },
ref,
) {
const outerRef = useRef<HTMLDivElement | null>(null)
Expand Down Expand Up @@ -134,7 +136,7 @@ export const Workspace = forwardRef<WorkspaceHandle, Props>(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',
}}
Expand Down
3 changes: 0 additions & 3 deletions src/components/image-editor/tool-meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Tool> = new Set<Tool>([
'marquee',
'lasso',
'polyLasso',
'wand',
Expand All @@ -17,8 +16,6 @@ export const STUB_TOOLS: ReadonlySet<Tool> = new Set<Tool>([
'historyBrush',
'pen',
'arrowPath',
'hand',
'rotateView',
'frame',
'note',
])
4 changes: 4 additions & 0 deletions src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
4 changes: 4 additions & 0 deletions src/i18n/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}} 暂未实现。",
Expand Down
Loading
Loading