From 64fafcef4cd5c08af3d98f1e09e5453e7b85e53f Mon Sep 17 00:00:00 2001 From: Sean Sun <1194458432@qq.com> Date: Sat, 25 Apr 2026 16:18:29 +0800 Subject: [PATCH 1/2] feat(image-editor): green Fullscreen button + wire Hand tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UX adjustments shipped alongside the lasso/wand bundle: - Remove the F keyboard shortcut for focus mode. Photoshop's F cycles screen modes, not "toggle fullscreen UI" — keeping our F bound to a different behaviour conflicted with users' muscle memory. F is now unbound, available for a future PS-aligned screen-mode cycle. - The existing focus toggle in the tab strip becomes a prominent green button (red when active, indicating "click to exit"). i18n labels swap to "Fullscreen" / "Exit fullscreen" so it's immediately clear what the button does. - Hand tool (H) is no longer a stub — it now flips the editor into pan mode while the tool is active, behaving exactly like holding Space. Removed `hand` from STUB_TOOLS, dropped the `stub: true` flag in the palette, added a new `effectivePanMode = panMode || tool === 'hand'` derivation, and routed it to both Workspace and Canvas. New OptionsBar hint variant + i18n string `handHint`. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/image-editor/OptionsBar.tsx | 12 +++++++++++ src/components/image-editor/ToolsPalette.tsx | 1 - src/components/image-editor/tool-meta.ts | 1 + src/i18n/en.json | 5 +++-- src/i18n/zh-CN.json | 5 +++-- src/pages/ImageEditor.tsx | 22 +++++++++++++++----- 6 files changed, 36 insertions(+), 10 deletions(-) diff --git a/src/components/image-editor/OptionsBar.tsx b/src/components/image-editor/OptionsBar.tsx index e45b087..ead31fe 100644 --- a/src/components/image-editor/OptionsBar.tsx +++ b/src/components/image-editor/OptionsBar.tsx @@ -394,6 +394,18 @@ export function OptionsBar({ ) } + if (tool === 'hand') { + return ( +
+
+ + {t('pages.imageEditor.handHint')} + +
+
+ ) + } + 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..a2a8856 100644 --- a/src/components/image-editor/ToolsPalette.tsx +++ b/src/components/image-editor/ToolsPalette.tsx @@ -168,7 +168,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..4fa6269 100644 --- a/src/components/image-editor/tool-meta.ts +++ b/src/components/image-editor/tool-meta.ts @@ -15,4 +15,5 @@ export const STUB_TOOLS: ReadonlySet = new Set([ 'arrowPath', 'frame', 'note', + // hand is functional — see ImageEditor's `effectivePanMode` derivation. ]) diff --git a/src/i18n/en.json b/src/i18n/en.json index ab02f7f..1661b93 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -557,8 +557,9 @@ "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.", "currentTool": "Tool", "zoomFit": "Fit (Cmd/Ctrl+0)", "style": "Style", diff --git a/src/i18n/zh-CN.json b/src/i18n/zh-CN.json index 663b126..e48859b 100644 --- a/src/i18n/zh-CN.json +++ b/src/i18n/zh-CN.json @@ -557,8 +557,9 @@ "toolHint": "绘制:{{tool}} — 在画布上拖拽;再次点击同一工具可取消选择", "adjust": "调整", "noLayerSelected": "未选中图层", - "focusEnterHint": "进入焦点模式(F)——隐藏工具站界面,画布占满整个视口", - "focusExitHint": "退出焦点模式(F 或 Esc)", + "focusEnterHint": "全屏 —— 隐藏工具站界面,画布占满整个视口", + "focusExitHint": "退出全屏(Esc)", + "handHint": "抓手 (H):拖动以平移画布,等同于按住空格 + 任意工具。", "currentTool": "工具", "zoomFit": "适配 (Cmd/Ctrl+0)", "style": "样式", diff --git a/src/pages/ImageEditor.tsx b/src/pages/ImageEditor.tsx index 05e92a7..5ade38f 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() @@ -869,9 +874,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 +892,7 @@ export function ImageEditorPage() { zoom={zoom} pan={pan} setPan={setPan} - panMode={panMode} + panMode={effectivePanMode} viewRotation={viewRotation} onWheelZoom={zoomAtPoint} onDropFile={handleDropImage} @@ -896,7 +908,7 @@ export function ImageEditorPage() { onSelect={setSelectedLayerId} onCommitLayer={commitLayer} onCommitLayerUpdate={commitLayerUpdate} - panMode={panMode} + panMode={effectivePanMode} imageCache={imageCache} onZoomAt={zoomAtPoint} onPickColor={handlePickColor} From 4c2056b6bd2eea4c0dad68702876a453f67deeb7 Mon Sep 17 00:00:00 2001 From: Sean Sun <1194458432@qq.com> Date: Sat, 25 Apr 2026 16:41:01 +0800 Subject: [PATCH 2/2] feat(image-editor): wire Note + Frame + Path Selection (round out PS palette) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three more PS-palette tools join end-to-end, plus the missing palette entries that were never rendered alongside their stub registrations. - **Note (N)** — click to drop a sticky-note marker. New `NoteShape` renders as a 16-px folded-corner pentagon in any colour with a one-line preview label below. Notes are gated on `liveCanvas`, so they appear on screen but never bake into PNG / JPEG / WebP exports. Move-only; no resize handles. - **Frame** — drag a rectangle to place a placeholder frame. New `FrameShape` renders as a dashed grey rect with a diagonal X across it (PS Frame Tool's empty-frame visual). Resizable via the standard 4 corner handles. Visible in export. - **Path Selection (A)** — un-stubbed and aliased to the Move tool's pick-and-move behaviour. Until the Pen tool ships, it functions identically to the no-tool selection arrow; full anchor-level path selection follows when vectors land. Implementation surface: - types.ts: NoteShape + FrameShape added to the Shape union. - drawing.ts: drawNote (sticky-note pentagon + folded corner shadow + preview label), drawFrame (dashed rect + diagonal X + optional name). - hit.ts: bbox + (frame-only) corner handles. Note bbox covers the 16-px icon footprint so users can click to select it. - transform.ts: translate covers both; resize covers frame. - render.ts: skips note layers when not on the live canvas, so they never bake into export. - Canvas.tsx: note (click → window.prompt → commit) sits alongside the other click-only tools (eyedropper / wand). Frame (drag → commit) joins the existing rect-like switch. arrowPath shares the pick-and-move codepath with 'none', including hover cursor. - ToolsPalette.tsx: imports `LayoutTemplate`, `MousePointer`, `StickyNote`; arrowPath joins the vector group, frame + note join the annotation group. (Stub registrations for these existed in STUB_TOOLS already but they had never been rendered.) - tool-meta.ts: STUB_TOOLS shrinks to the four pixel-source tools (spotHeal / stamp / historyBrush / pen) that still need their own PR. - LayersPanel.tsx: layerLabelKey switch gains note + frame branches so the right-sidebar layer list shows readable names. - OptionsBar.tsx: hint variants for note / frame / arrowPath. - ImageEditor.tsx: keyboard map gains 'a' → arrowPath, 'n' → note. - i18n: noteHint / notePrompt / frameHint / arrowPathHint plus annoLabel.note / annoLabel.frame in both en + zh-CN. Bundles the cherry-picked focus-button + Hand-tool fix from PR #44 (the commit landed after the merge cut, so it never reached main on its own). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/image-editor/Canvas.tsx | 54 +++++++++++-- src/components/image-editor/LayersPanel.tsx | 4 + src/components/image-editor/OptionsBar.tsx | 36 +++++++++ src/components/image-editor/ToolsPalette.tsx | 19 +++++ src/components/image-editor/tool-meta.ts | 5 +- src/i18n/en.json | 8 +- src/i18n/zh-CN.json | 8 +- src/lib/image-editor/drawing.ts | 83 ++++++++++++++++++++ src/lib/image-editor/hit.ts | 8 ++ src/lib/image-editor/render.ts | 8 ++ src/lib/image-editor/transform.ts | 5 +- src/lib/image-editor/types.ts | 26 ++++++ src/pages/ImageEditor.tsx | 2 + 13 files changed, 254 insertions(+), 12 deletions(-) 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 ead31fe..324db5f 100644 --- a/src/components/image-editor/OptionsBar.tsx +++ b/src/components/image-editor/OptionsBar.tsx @@ -406,6 +406,42 @@ export function OptionsBar({ ) } + 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 a2a8856..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' }, ], diff --git a/src/components/image-editor/tool-meta.ts b/src/components/image-editor/tool-meta.ts index 4fa6269..f2db64a 100644 --- a/src/components/image-editor/tool-meta.ts +++ b/src/components/image-editor/tool-meta.ts @@ -12,8 +12,5 @@ export const STUB_TOOLS: ReadonlySet = new Set([ 'stamp', 'historyBrush', 'pen', - 'arrowPath', - 'frame', - 'note', - // hand is functional — see ImageEditor's `effectivePanMode` derivation. + // hand, arrowPath, frame, note are functional — see ImageEditor / Canvas. ]) diff --git a/src/i18n/en.json b/src/i18n/en.json index 1661b93..3ab5436 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -560,6 +560,10 @@ "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", @@ -600,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 e48859b..b31ed14 100644 --- a/src/i18n/zh-CN.json +++ b/src/i18n/zh-CN.json @@ -560,6 +560,10 @@ "focusEnterHint": "全屏 —— 隐藏工具站界面,画布占满整个视口", "focusExitHint": "退出全屏(Esc)", "handHint": "抓手 (H):拖动以平移画布,等同于按住空格 + 任意工具。", + "noteHint": "便签 (N):单击在画布上放一个便签标记。便签只在屏幕上显示,不会写入导出图片。", + "notePrompt": "便签文字", + "frameHint": "画框:拖动绘制一个矩形占位框。", + "arrowPathHint": "路径选择 (A):单击选中矢量图层,拖动移动。当前与移动工具行为一致 —— 完整的锚点选择需待钢笔工具上线。", "currentTool": "工具", "zoomFit": "适配 (Cmd/Ctrl+0)", "style": "样式", @@ -600,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 5ade38f..9390013 100644 --- a/src/pages/ImageEditor.tsx +++ b/src/pages/ImageEditor.tsx @@ -265,6 +265,8 @@ export function ImageEditorPage() { u: 'rect', h: 'hand', z: 'zoom', + a: 'arrowPath', + n: 'note', } const next = map[e.key.toLowerCase()] if (next) {