diff --git a/src/config/pixel-editor.ts b/src/config/pixel-editor.ts index 213adc4..6235a57 100644 --- a/src/config/pixel-editor.ts +++ b/src/config/pixel-editor.ts @@ -14,3 +14,16 @@ export const GRID_MIN_ZOOM = 4; // Shift+wheel pixel accumulation required to step brush size up/down by one. export const BRUSH_RESIZE_WHEEL_THRESHOLD = 50; + +// Move tool grab region — cells of slack around the glyph's pixel rect. +// Inside this region the Move tool grabs the glyph; outside it pans the canvas. +export const GLYPH_GRAB_PADDING_CELLS = 1; + +// Outline color drawn around the glyph's pixel rect when the Move tool hovers it. +// Distinct from the cyan cap-height guide and the white cell boundary. +export const GLYPH_GRAB_OUTLINE_COLOR = 'oklch(0.75 0.15 80)'; + +// Minimum canvas size as a multiple of the viewport on each axis. Keeps the +// scroll container with somewhere to pan to even at low zoom levels — without +// this the canvas could be smaller than the viewport and pan would be a no-op. +export const CANVAS_VIEWPORT_OVERSCAN = 2.5; diff --git a/src/config/zoom.ts b/src/config/zoom.ts index ac7944a..2613e52 100644 --- a/src/config/zoom.ts +++ b/src/config/zoom.ts @@ -2,3 +2,11 @@ export const ZOOM_MIN = 2; export const ZOOM_MAX = 32; export const ZOOM_DEFAULT = 8; export const ZOOM_PRESETS = [2, 4, 8, 12, 16, 24, 32] as const; + +// Zoom level the Shift+1 / "100%" shortcut targets. +export const ZOOM_REFERENCE = ZOOM_DEFAULT; + +// Exponential sensitivity for Ctrl/Cmd+wheel zoom. Each wheel pixel multiplies +// zoom by exp(-deltaY * ZOOM_WHEEL_SENSITIVITY); 0.0025 ≈ 9% zoom change per +// 35-pixel wheel tick, which feels close to Figma. +export const ZOOM_WHEEL_SENSITIVITY = 0.0025; diff --git a/src/features/editor/EditorScreen.tsx b/src/features/editor/EditorScreen.tsx index 0146c2a..344e3eb 100644 --- a/src/features/editor/EditorScreen.tsx +++ b/src/features/editor/EditorScreen.tsx @@ -8,10 +8,12 @@ import { GLYPH_LIST_INITIAL_WIDTH_PX, GLYPH_LIST_MAX_WIDTH_PX, GLYPH_LIST_MIN_WIDTH_PX, + ZOOM_REFERENCE, } from '@/config'; import { getGlyphsForProject } from '@/db/glyphs'; import { saveGlyphs } from '@/db/glyphs'; import { ExportDialog } from '@/features/export/ExportDialog'; +import { useUndoRedo } from '@/hooks/useUndoRedo'; import { useStore } from '@/store'; import type { EditorTool } from '@/store/editorSlice'; @@ -19,6 +21,7 @@ import { AtlasFloat } from './AtlasFloat'; import { GlyphList } from './glyph-list/GlyphList'; import { HelpOverlay } from './HelpOverlay'; import { PixelEditor } from './pixel-editor/PixelEditor'; +import { zoomToFitLevel } from './pixel-editor/zoom-helpers'; import { PreviewFloat } from './PreviewFloat'; import { SettingsDialog } from './SettingsDialog'; import { EditorToolbar } from './toolbar/EditorToolbar'; @@ -97,8 +100,10 @@ export const EditorScreen = (): React.JSX.Element => { const selectedCodePoint = useStore((state) => state.selectedCodePoint); const glyphs = useStore((state) => state.glyphs); const upsertGlyph = useStore((state) => state.upsertGlyph); - const undo = useStore((state) => state.undo); - const redo = useStore((state) => state.redo); + const { undo, redo } = useUndoRedo(); + const setZoomLevel = useStore((state) => state.setZoomLevel); + const requestRecenter = useStore((state) => state.requestRecenter); + const pushUndo = useStore((state) => state.pushUndo); // Track the tool to restore after Space/Alt temporary overrides const toolBeforeOverride = useRef(null); @@ -125,74 +130,14 @@ export const EditorScreen = (): React.JSX.Element => { // Undo / redo if (ctrl && e.key.toLowerCase() === 'z' && !e.shiftKey) { e.preventDefault(); - - if (selectedCodePoint === null) { - return; - } - - const glyph = glyphs.find((g) => g.codePoint === selectedCodePoint); - - if (!glyph) { - return; - } - - const snapshot = undo(selectedCodePoint, { - pixels: new Uint8Array(glyph.pixels), - xoffset: glyph.xoffset, - yoffset: glyph.yoffset, - }); - - if (!snapshot) { - return; - } - - const updated = { - ...glyph, - pixels: snapshot.pixels, - xoffset: snapshot.xoffset, - yoffset: snapshot.yoffset, - isDirty: true, - }; - - upsertGlyph(updated); - void saveGlyphs([updated]); + undo(); return; } if (ctrl && e.key.toLowerCase() === 'z' && e.shiftKey) { e.preventDefault(); - - if (selectedCodePoint === null) { - return; - } - - const glyph = glyphs.find((g) => g.codePoint === selectedCodePoint); - - if (!glyph) { - return; - } - - const snapshot = redo(selectedCodePoint, { - pixels: new Uint8Array(glyph.pixels), - xoffset: glyph.xoffset, - yoffset: glyph.yoffset, - }); - - if (!snapshot) { - return; - } - - const updated = { - ...glyph, - pixels: snapshot.pixels, - xoffset: snapshot.xoffset, - yoffset: snapshot.yoffset, - isDirty: true, - }; - - upsertGlyph(updated); - void saveGlyphs([updated]); + redo(); return; } @@ -258,6 +203,71 @@ export const EditorScreen = (): React.JSX.Element => { return; } + // Shift+1 → zoom to 100% (ZOOM_REFERENCE); Shift+0 → zoom to fit. + if (e.shiftKey && !ctrl && !e.altKey && (e.key === '1' || e.key === '0')) { + e.preventDefault(); + + if (e.key === '1') { + setZoomLevel(ZOOM_REFERENCE); + requestRecenter(); + } else if (currentProject) { + const container = document.querySelector('[data-editor-canvas-container]'); + const viewport = container + ? { width: container.clientWidth, height: container.clientHeight } + : { width: window.innerWidth, height: window.innerHeight }; + + setZoomLevel(zoomToFitLevel(currentProject.settings, viewport)); + requestRecenter(); + } + + return; + } + + // Arrow keys nudge glyph offset by 1px when the Move tool is active. + // Each press is its own undo step (matches Figma). + if ( + activeTool === 'move' && + !ctrl && + !e.altKey && + !e.shiftKey && + (e.key === 'ArrowLeft' || + e.key === 'ArrowRight' || + e.key === 'ArrowUp' || + e.key === 'ArrowDown') && + selectedCodePoint !== null + ) { + const glyph = glyphs.find((glyphItem) => glyphItem.codePoint === selectedCodePoint); + + if (!glyph) { + return; + } + + e.preventDefault(); + + const dx = e.key === 'ArrowLeft' ? -1 : e.key === 'ArrowRight' ? 1 : 0; + const dy = e.key === 'ArrowUp' ? -1 : e.key === 'ArrowDown' ? 1 : 0; + + pushUndo(selectedCodePoint, { + pixels: new Uint8Array(glyph.pixels), + width: glyph.width, + height: glyph.height, + xoffset: glyph.xoffset, + yoffset: glyph.yoffset, + }); + + const updated = { + ...glyph, + xoffset: glyph.xoffset + dx, + yoffset: glyph.yoffset + dy, + isDirty: true, + }; + + upsertGlyph(updated); + void saveGlyphs([updated]); + + return; + } + // Tool switching switch (e.key.toLowerCase()) { case 'b': @@ -283,11 +293,16 @@ export const EditorScreen = (): React.JSX.Element => { return; } - // Space — temporarily activate move tool - if (e.key === ' ' && !e.repeat && activeTool !== 'move') { + // Space — temporarily activate move tool. Always preventDefault so the + // browser doesn't scroll the editor container on space keydown (including + // auto-repeats after the first press). + if (e.key === ' ') { e.preventDefault(); - toolBeforeOverride.current = activeTool; - setActiveTool('move'); + + if (!e.repeat && activeTool !== 'move') { + toolBeforeOverride.current = activeTool; + setActiveTool('move'); + } } // Alt — invert active tool (pencil↔eraser) @@ -318,7 +333,16 @@ export const EditorScreen = (): React.JSX.Element => { window.removeEventListener('keyup', onKeyUp); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [showGrid, activeTool, selectedCodePoint, glyphs, glyphListCollapsed, atlasOpen, previewOpen]); + }, [ + showGrid, + activeTool, + selectedCodePoint, + glyphs, + glyphListCollapsed, + atlasOpen, + previewOpen, + currentProject, + ]); if (!currentProject) { return ; diff --git a/src/features/editor/HelpOverlay.tsx b/src/features/editor/HelpOverlay.tsx index 818fdf4..b11ae6d 100644 --- a/src/features/editor/HelpOverlay.tsx +++ b/src/features/editor/HelpOverlay.tsx @@ -12,7 +12,11 @@ interface Props { const TOOLS = [ { key: 'B', tool: 'Pencil', description: 'Draw pixels onto the glyph' }, { key: 'E', tool: 'Eraser', description: 'Erase pixels from the glyph' }, - { key: 'M', tool: 'Move', description: 'Drag the glyph bitmap to reposition it' }, + { + key: 'M', + tool: 'Move', + description: 'Drag the glyph to reposition it; drag anywhere else to pan the canvas', + }, { key: 'Z', tool: 'Zoom', description: 'Click to zoom in; Alt+click to zoom out' }, ]; @@ -21,6 +25,14 @@ const MODIFIERS = [ { keys: ['Space'], description: 'Hold to temporarily activate the move tool' }, ]; +const NAVIGATION = [ + { keys: ['Scroll'], description: 'Pan the canvas (two-finger trackpad or mouse wheel)' }, + { keys: ['Ctrl', 'Scroll'], description: 'Zoom toward the cursor (also trackpad pinch)' }, + { keys: ['Shift', '1'], description: 'Zoom to 100%' }, + { keys: ['Shift', '0'], description: 'Zoom to fit' }, + { keys: ['Arrow'], description: 'Nudge glyph offset by 1px (Move tool active)' }, +]; + const SHORTCUTS = [ { keys: ['Ctrl', 'Z'], description: 'Undo' }, { keys: ['Ctrl', 'Shift', 'Z'], description: 'Redo' }, @@ -71,6 +83,18 @@ export const HelpOverlay = ({ open, onOpenChange }: Props): React.JSX.Element => ))} +
+

+ Navigation +

+ {NAVIGATION.map(({ keys, description }) => ( +
+ {description} + +
+ ))} +
+

General diff --git a/src/features/editor/PreviewFloat.tsx b/src/features/editor/PreviewFloat.tsx index 83aa0cc..9f3aaaf 100644 --- a/src/features/editor/PreviewFloat.tsx +++ b/src/features/editor/PreviewFloat.tsx @@ -70,10 +70,16 @@ export const PreviewFloat = ({ open, onClose }: Props): React.JSX.Element => { const advance = glyph ? glyph.xadvance : Math.round(currentProject.settings.fontSize * PREVIEW_MISSING_GLYPH_ADVANCE_RATIO); - const placeholderHeight = currentProject.settings.fontSize * PREVIEW_PLACEHOLDER_HEIGHT_RATIO; + const placeholderHeight = + currentProject.settings.fontSize * PREVIEW_PLACEHOLDER_HEIGHT_RATIO; context.strokeStyle = 'rgba(255,255,255,0.2)'; - context.strokeRect(x + 0.5, base - placeholderHeight + 0.5, advance - 2, placeholderHeight - 1); + context.strokeRect( + x + 0.5, + base - placeholderHeight + 0.5, + advance - 2, + placeholderHeight - 1, + ); x += advance + spacing.x; continue; diff --git a/src/features/editor/glyph-list/GlyphList.tsx b/src/features/editor/glyph-list/GlyphList.tsx index d770a4f..bf7ee66 100644 --- a/src/features/editor/glyph-list/GlyphList.tsx +++ b/src/features/editor/glyph-list/GlyphList.tsx @@ -169,6 +169,8 @@ export const GlyphList = ({ pushUndo(selectedGlyph.codePoint, { pixels: new Uint8Array(selectedGlyph.pixels), + width: selectedGlyph.width, + height: selectedGlyph.height, xoffset: selectedGlyph.xoffset, yoffset: selectedGlyph.yoffset, }); @@ -386,9 +388,7 @@ export const GlyphList = ({
{hasSourceFont && (
- + 0) { + return ZOOM_PRESETS.find((preset) => preset > currentZoom) ?? currentZoom; + } + + return [...ZOOM_PRESETS].reverse().find((preset) => preset < currentZoom) ?? currentZoom; +} + +function clamp(value: number, lo: number, hi: number): number { + return Math.max(lo, Math.min(hi, value)); } export const PixelEditor = (): React.JSX.Element => { @@ -34,6 +46,7 @@ export const PixelEditor = (): React.JSX.Element => { const brushSize = useStore((state) => state.brushSize); const zoomLevel = useStore((state) => state.zoomLevel); const showGrid = useStore((state) => state.showGrid); + const pendingRecenter = useStore((state) => state.pendingRecenter); const setZoomLevel = useStore((state) => state.setZoomLevel); const setBrushSize = useStore((state) => state.setBrushSize); const upsertGlyph = useStore((state) => state.upsertGlyph); @@ -46,30 +59,62 @@ export const PixelEditor = (): React.JSX.Element => { const glyph = glyphs.find((glyphItem) => glyphItem.codePoint === selectedCodePoint) ?? null; + // overGrab drives both the JSX cursor and the grab-outline pass in drawCanvas; + // lifting it to React state keeps the cursor in sync without flicker. + const [overGrab, setOverGrab] = useState(false); + const stateRef = useRef({ glyph, activeTool, brushSize, zoomLevel, showGrid, + overGrab: false, isDrawing: false, lastPixel: -1, cursorCell: null as { col: number; row: number } | null, moveOrigin: null as { x: number; y: number; xoffset: number; yoffset: number } | null, moveDelta: { dx: 0, dy: 0 }, + isPanning: false, + panOrigin: null as { + clientX: number; + clientY: number; + scrollLeft: number; + scrollTop: number; + } | null, shiftWheelAccum: 0, + // The actual origin used by the last drawCanvas pass — stashed here so + // pointer-to-cell mapping uses the exact same origin (including any + // viewport-overscan adjustment) that the canvas was painted with. + lastLayout: { + originX: 0, + originY: 0, + canvasCols: 0, + canvasRows: 0, + labelGutterPx: 0, + }, + // Pending zoom-at-cursor recenter — cell coordinates and viewport-relative + // cursor position, captured at the moment Ctrl/Cmd+wheel fires. After the + // next paint the effect on zoomLevel applies the scroll so the cell stays + // under the cursor. + pendingZoomCursor: null as { + cellCol: number; + cellRow: number; + viewportX: number; + viewportY: number; + } | null, }); - // eslint-disable-next-line react-hooks/refs - stateRef.current.glyph = glyph; - // eslint-disable-next-line react-hooks/refs - stateRef.current.activeTool = activeTool; - // eslint-disable-next-line react-hooks/refs - stateRef.current.brushSize = brushSize; - // eslint-disable-next-line react-hooks/refs - stateRef.current.zoomLevel = zoomLevel; - // eslint-disable-next-line react-hooks/refs - stateRef.current.showGrid = showGrid; + // Sync the latest props/state into the ref so the imperative event handlers + // and the canvas draw routine always read fresh values without re-binding. + useEffect(() => { + stateRef.current.glyph = glyph; + stateRef.current.activeTool = activeTool; + stateRef.current.brushSize = brushSize; + stateRef.current.zoomLevel = zoomLevel; + stateRef.current.showGrid = showGrid; + stateRef.current.overGrab = overGrab; + }); const drawCanvas = useCallback(() => { const canvas = canvasRef.current; @@ -94,30 +139,34 @@ export const PixelEditor = (): React.JSX.Element => { const renderXoffset = origin ? origin.xoffset + dx : layoutXoffset; const renderYoffset = origin ? origin.yoffset + dy : layoutYoffset; - // Symmetric overflow padding around the cell defines the canvas size floor. - // Holds the cell at the canvas (and therefore viewport) centre during normal - // drags; the canvas only grows beyond this floor when a glyph is pushed - // further than the padding allows. - const PAD_COLS = Math.ceil(fontSize * OVERFLOW_PADDING_MULTIPLIER); - const PAD_ROWS = Math.ceil(lineHeight * OVERFLOW_PADDING_MULTIPLIER); - - const glyphLeft = currentGlyph ? Math.min(layoutXoffset, renderXoffset) : 0; - const glyphTop = currentGlyph ? Math.min(layoutYoffset, renderYoffset) : 0; - const glyphRight = currentGlyph - ? Math.max(layoutXoffset + currentGlyph.width, renderXoffset + currentGlyph.width) - : 0; - const glyphBottom = currentGlyph - ? Math.max(layoutYoffset + currentGlyph.height, renderYoffset + currentGlyph.height) - : 0; - const originX = Math.min(-PAD_COLS, glyphLeft); - const originY = Math.min(-PAD_ROWS, glyphTop); - const rightExtent = Math.max(fontSize + PAD_COLS, glyphRight); - const bottomExtent = Math.max(lineHeight + PAD_ROWS, glyphBottom); - const canvasCols = rightExtent - originX; - const canvasRows = bottomExtent - originY; + // The layout bounding box covers both the original and dragged-to glyph + // position so panning during a drag never reveals empty canvas. + const container = containerRef.current; + const dragBounds = currentGlyph + ? { + xoffset: Math.min(layoutXoffset, renderXoffset), + yoffset: Math.min(layoutYoffset, renderYoffset), + width: + Math.max(layoutXoffset + currentGlyph.width, renderXoffset + currentGlyph.width) - + Math.min(layoutXoffset, renderXoffset), + height: + Math.max(layoutYoffset + currentGlyph.height, renderYoffset + currentGlyph.height) - + Math.min(layoutYoffset, renderYoffset), + } + : null; + const layout = computeCanvasLayout( + project.settings, + dragBounds, + zoom, + container ? { width: container.clientWidth, height: container.clientHeight } : null, + ); + const { originX, originY, canvasCols, canvasRows } = layout; // Measure the widest label so the right-hand gutter actually fits the text. - const labelSize = Math.max(LABEL_SIZE_MIN_PX, Math.min(LABEL_SIZE_MAX_PX, zoom * LABEL_SIZE_SCALE)); + const labelSize = Math.max( + LABEL_SIZE_MIN_PX, + Math.min(LABEL_SIZE_MAX_PX, zoom * LABEL_SIZE_SCALE), + ); const labelGutterPx = (() => { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const measureContext = canvas.getContext('2d')!; // canvas is a real DOM element @@ -134,6 +183,17 @@ export const PixelEditor = (): React.JSX.Element => { canvas.width = canvasCols * zoom + labelGutterPx; canvas.height = canvasRows * zoom; + + // Stash the layout so pointer-to-cell mapping and the post-paint + // zoom-at-cursor recenter both use the same origin the canvas was + // actually painted with. + stateRef.current.lastLayout = { + originX, + originY, + canvasCols, + canvasRows, + labelGutterPx, + }; // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const context = canvas.getContext('2d')!; // canvas is a real DOM element @@ -218,6 +278,32 @@ export const PixelEditor = (): React.JSX.Element => { context.restore(); } + // Move-tool grab outline — drawn around the glyph's pixel rect when the + // cursor is over the grab region. Skipped while actively dragging the glyph. + if ( + stateRef.current.activeTool === 'move' && + stateRef.current.overGrab && + !stateRef.current.moveOrigin && + currentGlyph && + currentGlyph.width > 0 && + currentGlyph.height > 0 + ) { + // Outline encloses the full grab region — the glyph rect expanded by + // GLYPH_GRAB_PADDING_CELLS on every side, matching isOverGlyphGrab. + const padding = GLYPH_GRAB_PADDING_CELLS; + const rectX = (renderXoffset - padding - originX) * zoom + 0.5; + const rectY = (renderYoffset - padding - originY) * zoom + 0.5; + const rectWidth = (currentGlyph.width + padding * 2) * zoom - 1; + const rectHeight = (currentGlyph.height + padding * 2) * zoom - 1; + + context.save(); + context.strokeStyle = GLYPH_GRAB_OUTLINE_COLOR; + context.lineWidth = 1; + context.setLineDash([]); + context.strokeRect(rectX, rectY, rectWidth, rectHeight); + context.restore(); + } + // Grid overlay (cell area only) if (grid && zoom >= GRID_MIN_ZOOM && currentGlyph) { context.strokeStyle = 'rgba(255,255,255,0.08)'; @@ -261,7 +347,70 @@ export const PixelEditor = (): React.JSX.Element => { useEffect(() => { drawCanvas(); - }, [glyph, zoomLevel, showGrid, drawCanvas]); + + // After repainting, apply any pending zoom-at-cursor scroll adjustment. + // The new origin lives in stateRef.current.lastLayout (just written by + // drawCanvas), so we can place the captured cell directly under the cursor. + const pending = stateRef.current.pendingZoomCursor; + + if (pending) { + const container = containerRef.current; + + if (container) { + const { originX, originY } = stateRef.current.lastLayout; + const cellCanvasX = (pending.cellCol - originX) * zoomLevel; + const cellCanvasY = (pending.cellRow - originY) * zoomLevel; + + container.scrollLeft = cellCanvasX - pending.viewportX; + container.scrollTop = cellCanvasY - pending.viewportY; + } + + stateRef.current.pendingZoomCursor = null; + } + }, [glyph, zoomLevel, showGrid, overGrab, drawCanvas]); + + // Hide the brush highlight whenever the container scrolls (trackpad pan, + // mouse wheel, or programmatic). The pointer is stationary on screen during + // a scroll while the underlying cell shifts, so the cached cursorCell would + // be visually misleading. Refreshed on the next pointermove. + useEffect(() => { + const container = containerRef.current; + + if (!container) { + return; + } + + function handleScroll(): void { + if (stateRef.current.cursorCell) { + stateRef.current.cursorCell = null; + drawCanvas(); + } + } + + container.addEventListener('scroll', handleScroll, { passive: true }); + + return () => { + container.removeEventListener('scroll', handleScroll); + }; + }, [drawCanvas]); + + // Redraw when the scroll container changes size — the canvas dimensions + // depend on container.clientWidth/Height via CANVAS_VIEWPORT_OVERSCAN. + useEffect(() => { + const container = containerRef.current; + + if (!container || typeof ResizeObserver === 'undefined') { + return; + } + + const observer = new ResizeObserver(() => { + drawCanvas(); + }); + + observer.observe(container); + + return () => observer.disconnect(); + }, [drawCanvas]); // Prevent the browser from scrolling the container when dragging on the canvas. // Must be native non-passive listeners — React synthetic events are passive by default. @@ -272,8 +421,8 @@ export const PixelEditor = (): React.JSX.Element => { return; } - function prevent(e: PointerEvent): void { - e.preventDefault(); + function prevent(event: PointerEvent): void { + event.preventDefault(); } canvas.addEventListener('pointerdown', prevent, { passive: false }); @@ -285,30 +434,225 @@ export const PixelEditor = (): React.JSX.Element => { }; }, []); - function cellFromEvent(e: React.PointerEvent): { col: number; row: number } | null { + // Center the glyph cell inside the scroll container's viewport. Used on + // initial mount, glyph changes, and after zoom-to-fit / zoom-to-100%. + // The canvas is much larger than the cell (overscan padding), so centering + // the cell — not the canvas — keeps the meaningful content in view. + const recenterCanvas = useCallback(() => { const canvas = canvasRef.current; + const container = containerRef.current; + const project = currentProject; + + if (!canvas || !container || !project) { + return; + } + + const zoom = stateRef.current.zoomLevel; const currentGlyph = stateRef.current.glyph; + const { fontSize, lineHeight } = project.settings; + // Shared layout math with drawCanvas — single source of truth for where + // the cell sits within the (possibly overscan-padded) canvas. + const { originX, originY } = computeCanvasLayout( + project.settings, + currentGlyph + ? { + xoffset: currentGlyph.xoffset, + yoffset: currentGlyph.yoffset, + width: currentGlyph.width, + height: currentGlyph.height, + } + : null, + zoom, + { width: container.clientWidth, height: container.clientHeight }, + ); + const cellCenterX = (-originX + fontSize / 2) * zoom; + const cellCenterY = (-originY + lineHeight / 2) * zoom; + + container.scrollLeft = Math.max(0, cellCenterX - container.clientWidth / 2); + container.scrollTop = Math.max(0, cellCenterY - container.clientHeight / 2); + }, [currentProject]); + + // Native non-passive wheel listener: + // - Shift+wheel → adjust brush size (existing behavior) + // - Ctrl/Meta+wheel → zoom toward the cursor (also catches trackpad pinch) + // - plain wheel → fall through to native container scrolling (pan) + // Bound natively because React's synthetic wheel listeners are passive in + // modern React, so preventDefault() is ignored. + useEffect(() => { + const container = containerRef.current; + + if (!container) { + return; + } + + function handleWheel(event: WheelEvent): void { + // Shift-only → brush size + if (event.shiftKey && !event.ctrlKey && !event.metaKey) { + event.preventDefault(); + stateRef.current.shiftWheelAccum += event.deltaY; + + if (Math.abs(stateRef.current.shiftWheelAccum) >= BRUSH_RESIZE_WHEEL_THRESHOLD) { + const step = stateRef.current.shiftWheelAccum < 0 ? 1 : -1; + + setBrushSize(stateRef.current.brushSize + step); + stateRef.current.shiftWheelAccum = 0; + } + + return; + } + + // Ctrl/Meta+wheel (and trackpad pinch) → smooth continuous zoom at cursor. + // Capture the cell under the cursor and the viewport-relative cursor + // position; an effect on zoomLevel applies the scroll once the canvas + // has been repainted at the new zoom (and the new origin is known). + if (event.ctrlKey || event.metaKey) { + event.preventDefault(); + + const currentZoom = stateRef.current.zoomLevel; + const nextZoom = clamp( + currentZoom * Math.exp(-event.deltaY * ZOOM_WHEEL_SENSITIVITY), + ZOOM_MIN, + ZOOM_MAX, + ); + + if (nextZoom === currentZoom) { + return; + } + + const canvas = canvasRef.current; + const containerEl = containerRef.current; + + if (canvas && containerEl) { + const canvasRect = canvas.getBoundingClientRect(); + const containerRect = containerEl.getBoundingClientRect(); + const { originX, originY } = stateRef.current.lastLayout; + // Convert the cursor's canvas-pixel position to cell-space using the + // ORIGIN the canvas was just painted with — independent of zoom. + const cellCol = (event.clientX - canvasRect.left) / currentZoom + originX; + const cellRow = (event.clientY - canvasRect.top) / currentZoom + originY; + + stateRef.current.pendingZoomCursor = { + cellCol, + cellRow, + viewportX: event.clientX - containerRect.left, + viewportY: event.clientY - containerRect.top, + }; + } + + setZoomLevel(nextZoom); + + return; + } + + // Plain wheel / two-finger trackpad → let the container scroll natively. + } + + container.addEventListener('wheel', handleWheel, { passive: false }); + + return () => { + container.removeEventListener('wheel', handleWheel); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Zoom toward a specific client point, keeping the canvas cell under that + // point fixed in the viewport. Captures the cell coordinate at the current + // zoom; a useEffect on zoomLevel applies the scroll after the canvas has + // repainted at the new zoom (and the new origin is known). + function zoomTowardClientPoint(nextZoom: number, clientX: number, clientY: number): void { + const currentZoom = stateRef.current.zoomLevel; + + if (nextZoom === currentZoom) { + return; + } + + const canvas = canvasRef.current; + const containerEl = containerRef.current; + + if (canvas && containerEl) { + const canvasRect = canvas.getBoundingClientRect(); + const containerRect = containerEl.getBoundingClientRect(); + const { originX, originY } = stateRef.current.lastLayout; + const cellCol = (clientX - canvasRect.left) / currentZoom + originX; + const cellRow = (clientY - canvasRect.top) / currentZoom + originY; + + stateRef.current.pendingZoomCursor = { + cellCol, + cellRow, + viewportX: clientX - containerRect.left, + viewportY: clientY - containerRect.top, + }; + } + + setZoomLevel(nextZoom); + } + + // Recenter when an external action (zoom-to-fit / 100%) requests it. + // Runs after the drawCanvas effect, so lastLayout reflects the new zoom. + useEffect(() => { + if (pendingRecenter === 0) { + return; + } + + recenterCanvas(); + }, [pendingRecenter, recenterCanvas]); + + // Center on initial mount and whenever the selected glyph changes. + // Runs after the drawCanvas effect declared above, so lastLayout has been + // refreshed for the new glyph before we read it. + useEffect(() => { + recenterCanvas(); + }, [selectedCodePoint, recenterCanvas]); + + function cellFromClientPoint( + clientX: number, + clientY: number, + ): { col: number; row: number } | null { + const canvas = canvasRef.current; const project = currentProject; - if (!canvas || !currentGlyph || !project) { + if (!canvas || !project) { return null; } const rect = canvas.getBoundingClientRect(); const zoom = stateRef.current.zoomLevel; - // Mirror the padded-floor origin from drawCanvas so cursor → cell mapping - // aligns with the canvas the user sees. - const padCols = Math.ceil(project.settings.fontSize * OVERFLOW_PADDING_MULTIPLIER); - const padRows = Math.ceil(project.settings.lineHeight * OVERFLOW_PADDING_MULTIPLIER); - const originX = Math.min(-padCols, currentGlyph.xoffset); - const originY = Math.min(-padRows, currentGlyph.yoffset); + // Read the exact origin the canvas was last painted with — any + // viewport-overscan growth must be reflected here or pointer → cell + // mapping ends up far to the right/below the visible glyph. + const { originX, originY } = stateRef.current.lastLayout; return { - col: Math.floor((e.clientX - rect.left) / zoom) + originX, - row: Math.floor((e.clientY - rect.top) / zoom) + originY, + col: Math.floor((clientX - rect.left) / zoom) + originX, + row: Math.floor((clientY - rect.top) / zoom) + originY, }; } + function cellFromEvent(event: React.PointerEvent): { col: number; row: number } | null { + return cellFromClientPoint(event.clientX, event.clientY); + } + + // Move-tool hit-test: is the cell inside the glyph's pixel rect, expanded by + // GLYPH_GRAB_PADDING_CELLS for a forgiving target on thin glyphs? Empty + // glyphs (0×0) fall back to a single-cell grab target at the offset point. + function isOverGlyphGrab(cell: { col: number; row: number } | null): boolean { + const currentGlyph = stateRef.current.glyph; + + if (!cell || !currentGlyph) { + return false; + } + + const padding = GLYPH_GRAB_PADDING_CELLS; + const width = Math.max(1, currentGlyph.width); + const height = Math.max(1, currentGlyph.height); + const left = currentGlyph.xoffset - padding; + const top = currentGlyph.yoffset - padding; + const right = currentGlyph.xoffset + width + padding; + const bottom = currentGlyph.yoffset + height + padding; + + return cell.col >= left && cell.col < right && cell.row >= top && cell.row < bottom; + } + function applyPaint(cell: { col: number; row: number } | null): void { const currentGlyph = stateRef.current.glyph; const project = currentProject; @@ -393,12 +737,7 @@ export const PixelEditor = (): React.JSX.Element => { const bufferX = px - newLeft; const bufferY = py - newTop; - if ( - bufferX < 0 || - bufferY < 0 || - bufferX >= newWidth || - bufferY >= newHeight - ) { + if (bufferX < 0 || bufferY < 0 || bufferX >= newWidth || bufferY >= newHeight) { continue; } @@ -430,112 +769,173 @@ export const PixelEditor = (): React.JSX.Element => { drawCanvas(); } - function onPointerDown(e: React.PointerEvent): void { + function onPointerDown(event: React.PointerEvent): void { const currentGlyph = stateRef.current.glyph; - if (!currentGlyph) { - return; - } - + // Zoom tool — discrete step at cursor. Works without a glyph. if (stateRef.current.activeTool === 'zoom') { - const zoomIn = !e.altKey; - const currentZoom = stateRef.current.zoomLevel; - const nextZoom = zoomIn - ? (ZOOM_PRESETS.find((z) => z > currentZoom) ?? currentZoom) - : ([...ZOOM_PRESETS].reverse().find((z) => z < currentZoom) ?? currentZoom); - - if (nextZoom === currentZoom) { - return; - } + const direction: 1 | -1 = event.altKey ? -1 : 1; - // Zoom towards cursor: keep the canvas point under the cursor fixed - const canvas = canvasRef.current; - const container = containerRef.current; + zoomTowardClientPoint( + nextZoomStep(stateRef.current.zoomLevel, direction), + event.clientX, + event.clientY, + ); - if (canvas && container) { - const rect = canvas.getBoundingClientRect(); - const cx = e.clientX - rect.left; // cursor offset within canvas - const cy = e.clientY - rect.top; - const scale = nextZoom / currentZoom; - // After zoom the canvas resizes; adjust scroll so cursor point stays put - const containerRect = container.getBoundingClientRect(); - const scrollX = container.scrollLeft + cx * scale - (e.clientX - containerRect.left); - const scrollY = container.scrollTop + cy * scale - (e.clientY - containerRect.top); + return; + } - setZoomLevel(nextZoom); - requestAnimationFrame(() => { - container.scrollLeft = scrollX; - container.scrollTop = scrollY; + // Move tool — branches into glyph-drag (over the glyph rect) or canvas-pan. + if (stateRef.current.activeTool === 'move') { + const cell = cellFromEvent(event); + const overGlyph = isOverGlyphGrab(cell); + + event.currentTarget.setPointerCapture(event.pointerId); + + if (overGlyph && currentGlyph) { + // Glyph drag — undoable. + pushUndo(currentGlyph.codePoint, { + pixels: new Uint8Array(currentGlyph.pixels), + width: currentGlyph.width, + height: currentGlyph.height, + xoffset: currentGlyph.xoffset, + yoffset: currentGlyph.yoffset, }); + stateRef.current.moveOrigin = { + x: event.clientX, + y: event.clientY, + xoffset: currentGlyph.xoffset, + yoffset: currentGlyph.yoffset, + }; + stateRef.current.isDrawing = true; + (event.currentTarget as HTMLCanvasElement).style.cursor = 'grabbing'; } else { - setZoomLevel(nextZoom); + // Pan drag — ephemeral, NOT undoable. + const container = containerRef.current; + + if (container) { + stateRef.current.panOrigin = { + clientX: event.clientX, + clientY: event.clientY, + scrollLeft: container.scrollLeft, + scrollTop: container.scrollTop, + }; + stateRef.current.isPanning = true; + (event.currentTarget as HTMLCanvasElement).style.cursor = 'grabbing'; + } } return; } - e.currentTarget.setPointerCapture(e.pointerId); + // Pencil / Eraser — need a glyph to paint into. + if (!currentGlyph) { + return; + } + + event.currentTarget.setPointerCapture(event.pointerId); pushUndo(currentGlyph.codePoint, { pixels: new Uint8Array(currentGlyph.pixels), + width: currentGlyph.width, + height: currentGlyph.height, xoffset: currentGlyph.xoffset, yoffset: currentGlyph.yoffset, }); stateRef.current.isDrawing = true; stateRef.current.lastPixel = -1; - - if (stateRef.current.activeTool === 'move') { - stateRef.current.moveOrigin = { - x: e.clientX, - y: e.clientY, - xoffset: currentGlyph.xoffset, - yoffset: currentGlyph.yoffset, - }; - (e.currentTarget as HTMLCanvasElement).style.cursor = 'grabbing'; - } else { - stateRef.current.moveOrigin = null; - applyPaint(cellFromEvent(e)); - } + stateRef.current.moveOrigin = null; + applyPaint(cellFromEvent(event)); } - function onPointerMove(e: React.PointerEvent): void { - if (stateRef.current.activeTool === 'move') { - if (!stateRef.current.isDrawing) { - return; - } - - const origin = stateRef.current.moveOrigin; + function onPointerMove(event: React.PointerEvent): void { + // Active pan drag — adjust container scroll directly. The container's + // scroll listener handles hiding the brush highlight; nothing else to do. + if (stateRef.current.isPanning) { + const container = containerRef.current; + const origin = stateRef.current.panOrigin; - if (!origin) { - return; + if (container && origin) { + container.scrollLeft = origin.scrollLeft - (event.clientX - origin.clientX); + container.scrollTop = origin.scrollTop - (event.clientY - origin.clientY); } + return; + } + + // Active glyph drag — move tool with moveOrigin set. + if (stateRef.current.activeTool === 'move' && stateRef.current.moveOrigin) { + const origin = stateRef.current.moveOrigin; const zoom = stateRef.current.zoomLevel; stateRef.current.moveDelta = { - dx: Math.round((e.clientX - origin.x) / zoom), - dy: Math.round((e.clientY - origin.y) / zoom), + dx: Math.round((event.clientX - origin.x) / zoom), + dy: Math.round((event.clientY - origin.y) / zoom), }; drawCanvas(); - } else { - // Always update cursor cell for highlight, paint only while drawing - const cell = cellFromEvent(e); + + return; + } + + // Move tool, hovering — recompute over-glyph state for the outline + cursor. + if (stateRef.current.activeTool === 'move') { + const cell = cellFromEvent(event); + const overGlyph = isOverGlyphGrab(cell); stateRef.current.cursorCell = cell; - if (stateRef.current.isDrawing) { - applyPaint(cell); + if (overGlyph !== stateRef.current.overGrab) { + setOverGrab(overGlyph); } - drawCanvas(); + return; } + + // Pencil/Eraser — update brush highlight and paint if drawing. + const cell = cellFromEvent(event); + + stateRef.current.cursorCell = cell; + + if (stateRef.current.isDrawing) { + applyPaint(cell); + } + + drawCanvas(); } function onPointerLeave(): void { stateRef.current.cursorCell = null; + + if (stateRef.current.overGrab) { + setOverGrab(false); + } + drawCanvas(); } - function onPointerUp(e: React.PointerEvent): void { + function onPointerUp(event: React.PointerEvent): void { + // Pan drag — release without saving, toasting, or pushing undo. + if (stateRef.current.isPanning) { + stateRef.current.isPanning = false; + stateRef.current.panOrigin = null; + + try { + event.currentTarget.releasePointerCapture(event.pointerId); + } catch { + // pointer capture may already be released; ignore. + } + + (event.currentTarget as HTMLCanvasElement).style.cursor = stateRef.current.overGrab + ? 'move' + : 'grab'; + + // Refresh cursorCell so the brush highlight reappears at the right + // place when the user toggles back to a paint tool (e.g. releasing Space). + stateRef.current.cursorCell = cellFromEvent(event); + drawCanvas(); + + return; + } + const currentGlyph = stateRef.current.glyph; let didSave = false; @@ -556,7 +956,9 @@ export const PixelEditor = (): React.JSX.Element => { didSave = true; } - (e.currentTarget as HTMLCanvasElement).style.cursor = 'grab'; + (event.currentTarget as HTMLCanvasElement).style.cursor = stateRef.current.overGrab + ? 'move' + : 'grab'; } else if (stateRef.current.isDrawing) { didSave = true; } @@ -581,25 +983,6 @@ export const PixelEditor = (): React.JSX.Element => { stateRef.current.moveDelta = { dx: 0, dy: 0 }; } - function onWheel(e: React.WheelEvent): void { - e.preventDefault(); - - if (e.shiftKey) { - stateRef.current.shiftWheelAccum += e.deltaY; - - if (Math.abs(stateRef.current.shiftWheelAccum) >= BRUSH_RESIZE_WHEEL_THRESHOLD) { - const step = stateRef.current.shiftWheelAccum < 0 ? 1 : -1; - - setBrushSize(stateRef.current.brushSize + step); - stateRef.current.shiftWheelAccum = 0; - } - } else { - const delta = e.deltaY < 0 ? 1 : -1; - - setZoomLevel(clamp(zoomLevel + delta, ZOOM_MIN, ZOOM_MAX)); - } - } - // No project open at all if (!currentProject) { return ( @@ -609,38 +992,54 @@ export const PixelEditor = (): React.JSX.Element => { ); } + const moveCursor = overGrab ? 'move' : 'grab'; + const canvasCursor = + activeTool === 'move' + ? moveCursor + : activeTool === 'zoom' + ? 'zoom-in' + : activeTool === 'eraser' + ? 'cell' + : 'crosshair'; + return (
- {!glyph ? ( - Select a glyph to edit - ) : ( - - )} + {/* Inner wrapper centers the canvas when it's smaller than the viewport + and grows to the canvas size when it's larger — so the scroll + container has somewhere to pan to once the canvas exceeds the + viewport. inline-flex (not flex) keeps the wrapper from collapsing + to the parent's width when the canvas is wider than the viewport, + so scrollWidth reflects the canvas's actual painted width and + recenterCanvas / pointer mapping work. */} +
+ {!glyph ? ( + Select a glyph to edit + ) : ( + + )} +
); }; diff --git a/src/features/editor/pixel-editor/zoom-helpers.ts b/src/features/editor/pixel-editor/zoom-helpers.ts new file mode 100644 index 0000000..c7bff8a --- /dev/null +++ b/src/features/editor/pixel-editor/zoom-helpers.ts @@ -0,0 +1,103 @@ +import { + CANVAS_VIEWPORT_OVERSCAN, + OVERFLOW_PADDING_MULTIPLIER, + ZOOM_MIN, + ZOOM_PRESETS, +} from '@/config'; + +interface FontMetrics { + fontSize: number; + lineHeight: number; +} + +interface GlyphRect { + xoffset: number; + yoffset: number; + width: number; + height: number; +} + +export interface CanvasLayout { + originX: number; + originY: number; + canvasCols: number; + canvasRows: number; +} + +// Single source of truth for where the glyph cell lives within the canvas. +// drawCanvas paints with this layout; recenterCanvas reads it to put the cell +// in the middle of the viewport. Whenever either side diverges from the other, +// pointer-to-cell mapping and centering both break — keep them in sync via +// this helper. +export function computeCanvasLayout( + settings: FontMetrics, + glyphRect: GlyphRect | null, + zoom: number, + viewport: { width: number; height: number } | null, +): CanvasLayout { + const padCols = Math.ceil(settings.fontSize * OVERFLOW_PADDING_MULTIPLIER); + const padRows = Math.ceil(settings.lineHeight * OVERFLOW_PADDING_MULTIPLIER); + + const glyphLeft = glyphRect?.xoffset ?? 0; + const glyphTop = glyphRect?.yoffset ?? 0; + const glyphRight = glyphRect ? glyphLeft + glyphRect.width : 0; + const glyphBottom = glyphRect ? glyphTop + glyphRect.height : 0; + + // Minimum padding on every side: the overflow constant, plus — when a + // viewport is given — enough extra room to fill (OVERSCAN - 1) viewports + // around the cell. With OVERSCAN = 2.5 that's ~0.75 viewports of canvas on + // each side. The +1 viewport itself is the cell area; everything beyond is + // pan headroom that must be applied evenly so the cell stays centered + // regardless of the OVERSCAN value. + const overscanCols = viewport + ? Math.ceil((viewport.width * (CANVAS_VIEWPORT_OVERSCAN - 1)) / 2 / zoom) + : 0; + const overscanRows = viewport + ? Math.ceil((viewport.height * (CANVAS_VIEWPORT_OVERSCAN - 1)) / 2 / zoom) + : 0; + + const leftPad = padCols + overscanCols; + const rightPad = padCols + overscanCols; + const topPad = padRows + overscanRows; + const bottomPad = padRows + overscanRows; + + // Each side independently grows to whichever is further: the symmetric + // padding around the cell, or far enough out to cover the glyph rect. + const originX = Math.min(-leftPad, glyphLeft); + const originY = Math.min(-topPad, glyphTop); + const rightExtent = Math.max(settings.fontSize + rightPad, glyphRight); + const bottomExtent = Math.max(settings.lineHeight + bottomPad, glyphBottom); + + return { + originX, + originY, + canvasCols: rightExtent - originX, + canvasRows: bottomExtent - originY, + }; +} + +// Largest zoom preset where the centered cell + overflow padding fits inside +// the editor viewport on both axes. Falls back to ZOOM_MIN when nothing fits. +export function zoomToFitLevel( + settings: FontMetrics, + viewport: { width: number; height: number }, +): number { + const padCols = Math.ceil(settings.fontSize * OVERFLOW_PADDING_MULTIPLIER); + const padRows = Math.ceil(settings.lineHeight * OVERFLOW_PADDING_MULTIPLIER); + const widthCells = settings.fontSize + 2 * padCols; + const heightCells = settings.lineHeight + 2 * padRows; + + if (widthCells <= 0 || heightCells <= 0) { + return ZOOM_MIN; + } + + let best = ZOOM_MIN; + + for (const preset of ZOOM_PRESETS) { + if (preset * widthCells <= viewport.width && preset * heightCells <= viewport.height) { + best = preset; + } + } + + return best; +} diff --git a/src/features/editor/toolbar/EditorToolbar.tsx b/src/features/editor/toolbar/EditorToolbar.tsx index cfd52bd..f695521 100644 --- a/src/features/editor/toolbar/EditorToolbar.tsx +++ b/src/features/editor/toolbar/EditorToolbar.tsx @@ -2,20 +2,25 @@ import { Grid2x2, Grid2x2X, ImageIcon, + Maximize2, Minus, Move, Pencil, Plus, + Redo2, SlidersHorizontal, Type, + Undo2, ZoomIn, } from 'lucide-react'; import React from 'react'; import { Button } from '@/components/ui/button'; import { Slider } from '@/components/ui/slider'; -import { DEFAULT_ALPHA_THRESHOLD, MAX_BRUSH_SIZE, ZOOM_PRESETS } from '@/config'; +import { DEFAULT_ALPHA_THRESHOLD, MAX_BRUSH_SIZE, ZOOM_PRESETS, ZOOM_REFERENCE } from '@/config'; import { saveProject } from '@/db'; +import { zoomToFitLevel } from '@/features/editor/pixel-editor/zoom-helpers'; +import { useUndoRedo } from '@/hooks/useUndoRedo'; import { cn } from '@/lib/utils'; import { useStore } from '@/store'; @@ -38,14 +43,16 @@ export const EditorToolbar = ({ const setBrushSize = useStore((state) => state.setBrushSize); const zoomLevel = useStore((state) => state.zoomLevel); const setZoomLevel = useStore((state) => state.setZoomLevel); + const requestRecenter = useStore((state) => state.requestRecenter); const showGrid = useStore((state) => state.showGrid); const setShowGrid = useStore((state) => state.setShowGrid); const currentProject = useStore((state) => state.currentProject); const updateCurrentProject = useStore((state) => state.updateCurrentProject); const alphaThreshold = currentProject?.settings.alphaThreshold ?? DEFAULT_ALPHA_THRESHOLD; + const { undo, redo, canUndo, canRedo } = useUndoRedo(); function zoomIn(): void { - const next = ZOOM_PRESETS.find((z) => z > zoomLevel); + const next = ZOOM_PRESETS.find((preset) => preset > zoomLevel); if (next) { setZoomLevel(next); @@ -53,13 +60,32 @@ export const EditorToolbar = ({ } function zoomOut(): void { - const next = [...ZOOM_PRESETS].reverse().find((z) => z < zoomLevel); + const next = [...ZOOM_PRESETS].reverse().find((preset) => preset < zoomLevel); if (next) { setZoomLevel(next); } } + function zoomToReference(): void { + setZoomLevel(ZOOM_REFERENCE); + requestRecenter(); + } + + function zoomToFit(): void { + if (!currentProject) { + return; + } + + const container = document.querySelector('[data-editor-canvas-container]'); + const viewport = container + ? { width: container.clientWidth, height: container.clientHeight } + : { width: window.innerWidth, height: window.innerHeight }; + + setZoomLevel(zoomToFitLevel(currentProject.settings, viewport)); + requestRecenter(); + } + const toolBtn = ( tool: typeof activeTool, icon: React.ReactNode, @@ -82,6 +108,31 @@ export const EditorToolbar = ({ return (
+ + + +
+ {toolBtn('pencil', , 'Pencil (B)')} {toolBtn('eraser', , 'Eraser (E)')} @@ -121,42 +172,6 @@ export const EditorToolbar = ({
- {currentProject?.settings.sourceFontId && ( - <> -
- -
- -
- { - if (!currentProject) { - return; - } - - const next = typeof value === 'number' ? value : value[0]; - const settings = { ...currentProject.settings, alphaThreshold: next }; - - updateCurrentProject({ settings }); - void saveProject({ ...currentProject, settings, updatedAt: Date.now() }); - }} - aria-label="Alpha threshold" - /> -
- - {alphaThreshold} - -
- - )} -
{toolBtn('move', , 'Move (M)')} @@ -174,9 +189,9 @@ export const EditorToolbar = ({ - {zoomLevel}× + {Math.round(zoomLevel * 10) / 10}× - -
- + + + + {currentProject?.settings.sourceFontId && ( + <> +
+ +
+ +
+ { + if (!currentProject) { + return; + } + + const next = typeof value === 'number' ? value : value[0]; + const settings = { ...currentProject.settings, alphaThreshold: next }; + + updateCurrentProject({ settings }); + void saveProject({ ...currentProject, settings, updatedAt: Date.now() }); + }} + aria-label="Alpha threshold" + /> +
+ + {alphaThreshold} + +
+ + )} + +
+