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
13 changes: 13 additions & 0 deletions src/config/pixel-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
8 changes: 8 additions & 0 deletions src/config/zoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
162 changes: 93 additions & 69 deletions src/features/editor/EditorScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,20 @@ 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';

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';
Expand Down Expand Up @@ -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<EditorTool | null>(null);
Expand All @@ -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;
}
Expand Down Expand Up @@ -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<HTMLElement>('[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':
Expand All @@ -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)
Expand Down Expand Up @@ -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 <Navigate to="/" replace />;
Expand Down
26 changes: 25 additions & 1 deletion src/features/editor/HelpOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
];

Expand All @@ -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' },
Expand Down Expand Up @@ -71,6 +83,18 @@ export const HelpOverlay = ({ open, onOpenChange }: Props): React.JSX.Element =>
))}
</section>

<section className="grid gap-1.5">
<h3 className="text-muted-foreground text-[10px] font-medium tracking-wider uppercase">
Navigation
</h3>
{NAVIGATION.map(({ keys, description }) => (
<div key={keys.join('+')} className="flex items-center justify-between gap-4">
<span className="text-muted-foreground text-xs">{description}</span>
<Keys keys={keys} />
</div>
))}
</section>

<section className="grid gap-1.5">
<h3 className="text-muted-foreground text-[10px] font-medium tracking-wider uppercase">
General
Expand Down
10 changes: 8 additions & 2 deletions src/features/editor/PreviewFloat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 3 additions & 3 deletions src/features/editor/glyph-list/GlyphList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down Expand Up @@ -386,9 +388,7 @@ export const GlyphList = ({
<div className="border-border/30 bg-muted/40 flex flex-col gap-1.5 border-b px-2 py-1.5">
{hasSourceFont && (
<div className="flex items-center gap-2">
<Label className="text-muted-foreground shrink-0 text-[10px]">
α cutoff
</Label>
<Label className="text-muted-foreground shrink-0 text-[10px]">α cutoff</Label>
<Slider
value={[glyph.alphaThreshold ?? currentProject.settings.alphaThreshold]}
min={0}
Expand Down
Loading
Loading