diff --git a/src/components/image-editor/Canvas.tsx b/src/components/image-editor/Canvas.tsx index 433b897..4f07526 100644 --- a/src/components/image-editor/Canvas.tsx +++ b/src/components/image-editor/Canvas.tsx @@ -415,8 +415,27 @@ export const Canvas = forwardRef(function Canvas( return } + // Note: click → prompt → commit a sticky-note marker. Empty text = no-op. + if (tool === 'note') { + const text = window.prompt(t('pages.imageEditor.notePrompt'), '') ?? '' + if (text.trim()) { + onCommitLayer({ + id: crypto.randomUUID(), + name: 'Note', + visible: true, + opacity: 100, + blend: 'normal', + kind: 'annotation', + shape: { kind: 'note', x: p.x, y: p.y, text, color: '#fde047' }, + } as AnnotationLayer) + } + return + } + + // Path Selection (arrowPath) is the vector counterpart of Move — until Pen + // exists end-to-end, treat it identically to the no-tool selection arrow. // Drawing tools take priority over selection. - if (tool !== 'none') { + if (tool !== 'none' && tool !== 'arrowPath') { startDrawing(p) return } @@ -643,6 +662,15 @@ export const Canvas = forwardRef(function Canvas( shape: { kind: 'blur', x: p.x, y: p.y, w: 0, h: 0, radius: 8 }, } as AnnotationLayer, }) + } else if (tool === 'frame') { + setInteraction({ + kind: 'drawing', + layer: { + ...baseLayer('Frame'), + kind: 'annotation', + shape: { kind: 'frame', x: p.x, y: p.y, w: 0, h: 0 }, + } as AnnotationLayer, + }) } else if (tool === 'brush' || tool === 'eraser') { setInteraction({ kind: 'drawing', @@ -708,7 +736,13 @@ export const Canvas = forwardRef(function Canvas( const drawing = interaction.layer if (drawing.kind === 'annotation') { const s = drawing.shape - if (s.kind === 'rect' || s.kind === 'mosaic' || s.kind === 'ellipse' || s.kind === 'blur') { + if ( + s.kind === 'rect' || + s.kind === 'mosaic' || + s.kind === 'ellipse' || + s.kind === 'blur' || + s.kind === 'frame' + ) { setInteraction({ kind: 'drawing', layer: { ...drawing, shape: { ...s, w: p.x - s.x, h: p.y - s.y } } as AnnotationLayer, @@ -765,12 +799,16 @@ export const Canvas = forwardRef(function Canvas( tool === 'marquee' || tool === 'lasso' || tool === 'polyLasso' || - tool === 'wand' + tool === 'wand' || + tool === 'note' || + tool === 'frame' ) { setHoverCursor('crosshair') return } - if (tool !== 'none') { + // Path Selection (arrowPath) shares the move/select cursor logic with + // 'none' — same hit-testing and resize-handle hover beneath. + if (tool !== 'none' && tool !== 'arrowPath') { setHoverCursor('crosshair') return } @@ -843,7 +881,13 @@ function shouldDiscardDrawing(layer: Layer): boolean { } if (layer.kind === 'annotation') { const s = layer.shape - if (s.kind === 'rect' || s.kind === 'mosaic' || s.kind === 'ellipse' || s.kind === 'blur') { + if ( + s.kind === 'rect' || + s.kind === 'mosaic' || + s.kind === 'ellipse' || + s.kind === 'blur' || + s.kind === 'frame' + ) { return Math.abs(s.w) < 4 && Math.abs(s.h) < 4 } if (s.kind === 'arrow' || s.kind === 'line') { diff --git a/src/components/image-editor/LayersPanel.tsx b/src/components/image-editor/LayersPanel.tsx index 8f2b8d7..0228040 100644 --- a/src/components/image-editor/LayersPanel.tsx +++ b/src/components/image-editor/LayersPanel.tsx @@ -186,5 +186,9 @@ function layerLabelKey(layer: Layer): string { return 'pages.imageEditor.annoLabel.line' case 'blur': return 'pages.imageEditor.annoLabel.blur' + case 'note': + return 'pages.imageEditor.annoLabel.note' + case 'frame': + return 'pages.imageEditor.annoLabel.frame' } } diff --git a/src/components/image-editor/OptionsBar.tsx b/src/components/image-editor/OptionsBar.tsx index e45b087..324db5f 100644 --- a/src/components/image-editor/OptionsBar.tsx +++ b/src/components/image-editor/OptionsBar.tsx @@ -394,6 +394,54 @@ export function OptionsBar({ ) } + if (tool === 'hand') { + return ( +
+
+ + {t('pages.imageEditor.handHint')} + +
+
+ ) + } + + if (tool === 'note') { + return ( +
+
+ + {t('pages.imageEditor.noteHint')} + +
+
+ ) + } + + if (tool === 'frame') { + return ( +
+
+ + {t('pages.imageEditor.frameHint')} + +
+
+ ) + } + + if (tool === 'arrowPath') { + return ( +
+
+ + {t('pages.imageEditor.arrowPathHint')} + +
+
+ ) + } + if (tool === 'mask' || tool === 'mosaic' || tool === 'blur') { return (
diff --git a/src/components/image-editor/ToolsPalette.tsx b/src/components/image-editor/ToolsPalette.tsx index 397b754..f9d32b3 100644 --- a/src/components/image-editor/ToolsPalette.tsx +++ b/src/components/image-editor/ToolsPalette.tsx @@ -11,7 +11,9 @@ import { Frame, Hand, Lasso, + LayoutTemplate, Moon, + MousePointer, MousePointer2, PaintBucket, PenTool, @@ -25,6 +27,7 @@ import { Square, SquareDashed, Squircle, + StickyNote, Sun, Type, Wand2, @@ -140,6 +143,12 @@ const GROUPS: ToolDef[][] = [ shortcut: 'P', stub: true, }, + { + id: 'arrowPath', + icon: , + labelKey: 'pages.imageEditor.tool.arrowPath', + shortcut: 'A', + }, { id: 'text', icon: , labelKey: 'pages.imageEditor.tool.text', shortcut: 'T' }, { id: 'rect', icon: , labelKey: 'pages.imageEditor.tool.rect', shortcut: 'U' }, { id: 'ellipse', icon: , labelKey: 'pages.imageEditor.tool.ellipse', shortcut: 'U' }, @@ -158,6 +167,16 @@ const GROUPS: ToolDef[][] = [ // 4. Annotation / mask [ { id: 'mask', icon: , labelKey: 'pages.imageEditor.tool.mask' }, + { + id: 'frame', + icon: , + labelKey: 'pages.imageEditor.tool.frame', + }, + { + id: 'note', + icon: , + labelKey: 'pages.imageEditor.tool.note', + }, { id: 'mosaic', icon: , labelKey: 'pages.imageEditor.tool.mosaic' }, { id: 'blur', icon: , labelKey: 'pages.imageEditor.tool.blur' }, ], @@ -168,7 +187,6 @@ const GROUPS: ToolDef[][] = [ icon: , labelKey: 'pages.imageEditor.tool.hand', shortcut: 'H', - stub: true, }, { id: 'rotateView', diff --git a/src/components/image-editor/tool-meta.ts b/src/components/image-editor/tool-meta.ts index a0523be..f2db64a 100644 --- a/src/components/image-editor/tool-meta.ts +++ b/src/components/image-editor/tool-meta.ts @@ -12,7 +12,5 @@ export const STUB_TOOLS: ReadonlySet = new Set([ 'stamp', 'historyBrush', 'pen', - 'arrowPath', - 'frame', - 'note', + // hand, arrowPath, frame, note are functional — see ImageEditor / Canvas. ]) diff --git a/src/i18n/en.json b/src/i18n/en.json index ab02f7f..3ab5436 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -557,8 +557,13 @@ "toolHint": "Drawing: {{tool}} — drag on the canvas; click the same tool again to deselect", "adjust": "Adjust", "noLayerSelected": "No layer selected", - "focusEnterHint": "Enter focus mode (F) — hides the toolbox chrome and gives the canvas the full viewport", - "focusExitHint": "Exit focus mode (F or Esc)", + "focusEnterHint": "Fullscreen — hide the toolbox chrome and give the canvas the full viewport", + "focusExitHint": "Exit fullscreen (Esc)", + "handHint": "Hand (H): drag to pan the canvas. Same as holding Space with any other tool.", + "noteHint": "Note (N): click to drop a sticky-note marker. Notes show on screen but never bake into export.", + "notePrompt": "Note text", + "frameHint": "Frame: drag to draw a rectangular placeholder frame.", + "arrowPathHint": "Path Selection (A): click to select a vector layer, drag to move. Behaves like the Move tool — full vector-anchor selection arrives with the Pen tool.", "currentTool": "Tool", "zoomFit": "Fit (Cmd/Ctrl+0)", "style": "Style", @@ -599,7 +604,9 @@ "gradient": "Gradient", "blur": "Blur region", "dodge": "Dodge stroke", - "burn": "Burn stroke" + "burn": "Burn stroke", + "note": "Note", + "frame": "Frame" }, "project": "Project", "projectSave": "Save project (.json)", diff --git a/src/i18n/zh-CN.json b/src/i18n/zh-CN.json index 663b126..b31ed14 100644 --- a/src/i18n/zh-CN.json +++ b/src/i18n/zh-CN.json @@ -557,8 +557,13 @@ "toolHint": "绘制:{{tool}} — 在画布上拖拽;再次点击同一工具可取消选择", "adjust": "调整", "noLayerSelected": "未选中图层", - "focusEnterHint": "进入焦点模式(F)——隐藏工具站界面,画布占满整个视口", - "focusExitHint": "退出焦点模式(F 或 Esc)", + "focusEnterHint": "全屏 —— 隐藏工具站界面,画布占满整个视口", + "focusExitHint": "退出全屏(Esc)", + "handHint": "抓手 (H):拖动以平移画布,等同于按住空格 + 任意工具。", + "noteHint": "便签 (N):单击在画布上放一个便签标记。便签只在屏幕上显示,不会写入导出图片。", + "notePrompt": "便签文字", + "frameHint": "画框:拖动绘制一个矩形占位框。", + "arrowPathHint": "路径选择 (A):单击选中矢量图层,拖动移动。当前与移动工具行为一致 —— 完整的锚点选择需待钢笔工具上线。", "currentTool": "工具", "zoomFit": "适配 (Cmd/Ctrl+0)", "style": "样式", @@ -599,7 +604,9 @@ "gradient": "渐变", "blur": "模糊区域", "dodge": "减淡笔触", - "burn": "加深笔触" + "burn": "加深笔触", + "note": "便签", + "frame": "画框" }, "project": "项目", "projectSave": "保存项目 (.json)", diff --git a/src/lib/image-editor/drawing.ts b/src/lib/image-editor/drawing.ts index fdeefdb..7cbe155 100644 --- a/src/lib/image-editor/drawing.ts +++ b/src/lib/image-editor/drawing.ts @@ -3,9 +3,11 @@ import type { BlurShape, BrushShape, EllipseShape, + FrameShape, ImageShape, LineShape, MosaicShape, + NoteShape, RectShape, Shape, TextShape, @@ -55,9 +57,90 @@ export function drawShape( case 'blur': drawBlurRegion(ctx, shape, scale, underlying) break + case 'note': + drawNote(ctx, shape, scale) + break + case 'frame': + drawFrame(ctx, shape, scale) + break } } +/** + * Sticky-note marker. Rendered as a small folded-corner rectangle in the + * given colour with a 1-line text label below it. The full text is in the + * Properties panel on hover; the label is just a preview. + */ +function drawNote(ctx: CanvasRenderingContext2D, s: NoteShape, scale: number) { + const x = s.x * scale + const y = s.y * scale + const size = 16 * scale + const fold = size / 3 + ctx.save() + // Note body (folded-corner pentagon) + ctx.fillStyle = s.color + ctx.strokeStyle = '#1a1a1a' + ctx.lineWidth = Math.max(1, scale) + ctx.beginPath() + ctx.moveTo(x, y) + ctx.lineTo(x + size - fold, y) + ctx.lineTo(x + size, y + fold) + ctx.lineTo(x + size, y + size) + ctx.lineTo(x, y + size) + ctx.closePath() + ctx.fill() + ctx.stroke() + // Folded-corner triangle + ctx.fillStyle = 'rgba(0,0,0,0.18)' + ctx.beginPath() + ctx.moveTo(x + size - fold, y) + ctx.lineTo(x + size - fold, y + fold) + ctx.lineTo(x + size, y + fold) + ctx.closePath() + ctx.fill() + ctx.stroke() + // 1-line preview label below + const label = s.text.split('\n')[0].slice(0, 24) + if (label) { + ctx.fillStyle = '#1a1a1a' + ctx.font = `${Math.round(11 * scale)}px sans-serif` + ctx.textBaseline = 'top' + ctx.fillText(label, x, y + size + 2 * scale) + } + ctx.restore() +} + +/** + * Frame placeholder. Dashed grey rect with a diagonal X across — same visual + * convention PS uses for an empty Frame Tool layer. + */ +function drawFrame(ctx: CanvasRenderingContext2D, s: FrameShape, scale: number) { + const x = (s.w >= 0 ? s.x : s.x + s.w) * scale + const y = (s.h >= 0 ? s.y : s.y + s.h) * scale + const w = Math.abs(s.w) * scale + const h = Math.abs(s.h) * scale + if (w < 1 || h < 1) return + ctx.save() + ctx.strokeStyle = '#888' + ctx.lineWidth = Math.max(1, scale) + ctx.setLineDash([6 * scale, 4 * scale]) + ctx.strokeRect(x, y, w, h) + ctx.setLineDash([]) + ctx.beginPath() + ctx.moveTo(x, y) + ctx.lineTo(x + w, y + h) + ctx.moveTo(x + w, y) + ctx.lineTo(x, y + h) + ctx.stroke() + if (s.name) { + ctx.fillStyle = '#888' + ctx.font = `${Math.round(12 * scale)}px sans-serif` + ctx.textBaseline = 'top' + ctx.fillText(s.name, x + 4 * scale, y + 4 * scale) + } + ctx.restore() +} + /** * Region blur — sample the underlying canvas under the rect, draw it back at * the same place with `ctx.filter = blur(Npx)` applied. Same pattern as diff --git a/src/lib/image-editor/hit.ts b/src/lib/image-editor/hit.ts index df40fa3..76bc7ad 100644 --- a/src/lib/image-editor/hit.ts +++ b/src/lib/image-editor/hit.ts @@ -33,7 +33,12 @@ function getShapeBBox(shape: Shape): Rect { case 'image': case 'ellipse': case 'blur': + case 'frame': return normalizeRect({ x: shape.x, y: shape.y, w: shape.w, h: shape.h }) + case 'note': + // Notes render as a 16-px sticky icon — bbox covers the icon so the + // user can click it to select / move. + return { x: shape.x, y: shape.y, w: 16, h: 16 } case 'arrow': case 'line': { const x = Math.min(shape.x1, shape.x2) @@ -110,6 +115,7 @@ export function getHandles(layer: Layer): Handle[] { case 'image': case 'ellipse': case 'blur': + case 'frame': return rectCornerHandles( normalizeRect({ x: layer.shape.x, @@ -126,6 +132,8 @@ export function getHandles(layer: Layer): Handle[] { ] case 'text': case 'brush': + case 'note': + // Note is move-only; resizing it would just stretch a 16-px icon. return [] } } diff --git a/src/lib/image-editor/render.ts b/src/lib/image-editor/render.ts index 9741c59..3e2219e 100644 --- a/src/lib/image-editor/render.ts +++ b/src/lib/image-editor/render.ts @@ -170,6 +170,14 @@ export function renderTo(canvas: HTMLCanvasElement, input: RenderInput): void { for (const layer of state.layers) { if (!layer.visible) continue + // Notes are UI-only annotations — never bake into export. + if ( + !input.liveCanvas && + layer.kind === 'annotation' && + layer.shape.kind === 'note' + ) { + continue + } const l = shiftForCrop(layer) if (l.kind === 'annotation') { ctx.save() diff --git a/src/lib/image-editor/transform.ts b/src/lib/image-editor/transform.ts index b493edd..23f30f4 100644 --- a/src/lib/image-editor/transform.ts +++ b/src/lib/image-editor/transform.ts @@ -19,6 +19,8 @@ function translateShape(shape: Shape, dx: number, dy: number): Shape { case 'image': case 'ellipse': case 'blur': + case 'frame': + case 'note': return { ...shape, x: shape.x + dx, y: shape.y + dy } case 'arrow': case 'line': @@ -67,7 +69,8 @@ export function resizeLayer( case 'mosaic': case 'image': case 'ellipse': - case 'blur': { + case 'blur': + case 'frame': { const next = resizeRect({ x: s.x, y: s.y, w: s.w, h: s.h }, handleId, newPoint) return { ...layer, shape: { ...s, ...next } } } diff --git a/src/lib/image-editor/types.ts b/src/lib/image-editor/types.ts index d9c3dbc..9264601 100644 --- a/src/lib/image-editor/types.ts +++ b/src/lib/image-editor/types.ts @@ -180,6 +180,30 @@ export type BlurShape = { /** Blur radius in preview-canvas pixels. */ radius: number } +/** + * Sticky-note marker — non-printing annotation with a small icon on the + * canvas. Renderer skips notes when not on the live canvas, so they never + * bake into export. + */ +export type NoteShape = { + kind: 'note' + x: number + y: number + text: string + color: string +} +/** + * Frame placeholder — a rectangular container. Renders as a dashed outline + * with a diagonal X (PS Frame Tool's empty-frame visual). Visible in export. + */ +export type FrameShape = { + kind: 'frame' + x: number + y: number + w: number + h: number + name?: string +} export type Shape = | RectShape @@ -191,6 +215,8 @@ export type Shape = | EllipseShape | LineShape | BlurShape + | NoteShape + | FrameShape /** Drop shadow applied to a layer at render time via canvas shadow* properties. */ export type Shadow = { diff --git a/src/pages/ImageEditor.tsx b/src/pages/ImageEditor.tsx index 05e92a7..9390013 100644 --- a/src/pages/ImageEditor.tsx +++ b/src/pages/ImageEditor.tsx @@ -97,6 +97,9 @@ export function ImageEditorPage() { const [zoom, setZoom] = useState(1) const [pan, setPan] = useState({ x: 0, y: 0 }) const [panMode, setPanMode] = useState(false) + // Effective pan: Space held OR Hand tool active. Workspace + Canvas both + // route mouse drags to panning when this is true. + const effectivePanMode = panMode || tool === 'hand' /** * View-only canvas rotation in degrees (0 / 90 / 180 / 270). Lives outside * EditorState because it doesn't affect pixels — just how the canvas is @@ -213,7 +216,9 @@ export function ImageEditorPage() { } } - if (e.key === 'f' || e.key === 'F') { e.preventDefault(); setFocused((v) => !v); return } + // F intentionally NOT bound — Photoshop's F cycles screen modes; we leave + // the key free for future PS-aligned behaviour. Focus mode is now toggled + // via the green Fullscreen button in the top-right of the tab strip. // Rotate View — R cycles the workspace display rotation 0→90→180→270. if (e.key === 'r' || e.key === 'R') { e.preventDefault() @@ -260,6 +265,8 @@ export function ImageEditorPage() { u: 'rect', h: 'hand', z: 'zoom', + a: 'arrowPath', + n: 'note', } const next = map[e.key.toLowerCase()] if (next) { @@ -869,9 +876,16 @@ export function ImageEditorPage() { className="pf-tab" onClick={() => setFocused((v) => !v)} title={t(focused ? 'pages.imageEditor.focusExitHint' : 'pages.imageEditor.focusEnterHint')} - style={{ color: 'var(--pf-fg-mid)' }} + style={{ + color: '#fff', + background: focused ? '#dc2626' : '#16a34a', + fontWeight: 600, + padding: '0 10px', + }} > - ⛶ F + + ⛶ {t(focused ? 'pages.imageEditor.exitFullscreen' : 'pages.imageEditor.fullscreen')} +
@@ -880,7 +894,7 @@ export function ImageEditorPage() { zoom={zoom} pan={pan} setPan={setPan} - panMode={panMode} + panMode={effectivePanMode} viewRotation={viewRotation} onWheelZoom={zoomAtPoint} onDropFile={handleDropImage} @@ -896,7 +910,7 @@ export function ImageEditorPage() { onSelect={setSelectedLayerId} onCommitLayer={commitLayer} onCommitLayerUpdate={commitLayerUpdate} - panMode={panMode} + panMode={effectivePanMode} imageCache={imageCache} onZoomAt={zoomAtPoint} onPickColor={handlePickColor}