feat(text-tool): implement Photoshop-style on-canvas text editor

- Replaced modal dialog with inline on-canvas text editor
- Text objects stored as editable entities (non-rasterized)
- Live preview with transparent textarea overlay
- Click on existing text to re-edit
- Drag transform handles to move text
- Auto-commit on click outside (via overlay)
- Text selection with visible highlight
- Hidden original text during editing to prevent double vision
- Position alignment fixes for editing existing text
- Keyboard shortcuts: Ctrl+Enter to commit, Escape to cancel

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-21 14:31:47 +01:00
parent 4e6dc6cb14
commit 69a468141c
7 changed files with 628 additions and 23 deletions

View File

@@ -330,3 +330,16 @@
background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
}
}
/* Text editor - make text visible when selected */
@layer components {
.text-editor-input::selection {
background-color: var(--primary);
color: var(--primary-foreground);
}
.text-editor-input::-moz-selection {
background-color: var(--primary);
color: var(--primary-foreground);
}
}

View File

@@ -4,8 +4,10 @@ import { useEffect, useRef, useState } from 'react';
import { useCanvasStore, useLayerStore, useToolStore } from '@/store';
import { useHistoryStore } from '@/store/history-store';
import { useSelectionStore } from '@/store/selection-store';
import { useTextStore } from '@/store/text-store';
import { drawMarchingAnts } from '@/lib/selection-utils';
import { getContext, drawGrid, drawCheckerboard } from '@/lib/canvas-utils';
import { renderText } from '@/lib/text-utils';
import { DrawCommand } from '@/core/commands';
import {
PencilTool,
@@ -25,6 +27,7 @@ import {
} from '@/tools';
import type { PointerState } from '@/types';
import { cn } from '@/lib/utils';
import { OnCanvasTextEditor } from './on-canvas-text-editor';
// Tool instances
const tools: Record<string, BaseTool> = {
@@ -66,6 +69,7 @@ export function CanvasWithTools() {
const { activeTool, settings } = useToolStore();
const { executeCommand } = useHistoryStore();
const { activeSelection, selectionType, isMarching } = useSelectionStore();
const { textObjects, editingTextId } = useTextStore();
const [marchingOffset, setMarchingOffset] = useState(0);
const [isPanning, setIsPanning] = useState(false);
@@ -129,6 +133,27 @@ export function CanvasWithTools() {
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
// Draw text objects (skip the one being edited)
textObjects.forEach((textObj) => {
// Don't render text that's currently being edited
if (editingTextId && textObj.id === editingTextId) {
return;
}
renderText(ctx, textObj.x, textObj.y, {
text: textObj.text,
fontFamily: textObj.fontFamily,
fontSize: textObj.fontSize,
fontStyle: textObj.fontStyle,
fontWeight: textObj.fontWeight,
color: textObj.color,
align: textObj.align,
baseline: textObj.baseline,
lineHeight: textObj.lineHeight,
letterSpacing: textObj.letterSpacing,
});
});
// Draw grid if enabled (only within canvas bounds)
if (showGrid) {
drawGrid(ctx, gridSize, 'rgba(0, 0, 0, 0.15)', width, height);
@@ -141,7 +166,7 @@ export function CanvasWithTools() {
// Restore context state
ctx.restore();
}, [layers, width, height, zoom, offsetX, offsetY, showGrid, gridSize, backgroundColor, selection, pointer, activeSelection, isMarching, marchingOffset]);
}, [layers, width, height, zoom, offsetX, offsetY, showGrid, gridSize, backgroundColor, selection, pointer, activeSelection, isMarching, marchingOffset, textObjects, editingTextId]);
// Marching ants animation
useEffect(() => {
@@ -353,6 +378,9 @@ export function CanvasWithTools() {
ref={canvasRef}
className="absolute inset-0"
/>
{/* On-canvas text editor */}
<OnCanvasTextEditor />
</div>
);
}

View File

@@ -0,0 +1,411 @@
'use client';
import { useEffect, useRef, useState, useCallback } from 'react';
import { useTextStore } from '@/store/text-store';
import { useCanvasStore } from '@/store/canvas-store';
import { useLayerStore } from '@/store/layer-store';
import { useHistoryStore } from '@/store/history-store';
import { TextTool } from '@/tools/text-tool';
import { renderText } from '@/lib/text-utils';
import { DrawCommand } from '@/core/commands/draw-command';
export function OnCanvasTextEditor() {
const {
isOnCanvasEditorActive,
editorPosition,
editorText,
editingTextId,
settings,
updateEditorText,
updateEditorPosition,
deactivateOnCanvasEditor,
addTextObject,
updateTextObject,
setText,
} = useTextStore();
const { zoom, offsetX, offsetY, width: canvasWidth, height: canvasHeight } = useCanvasStore();
const { getActiveLayer } = useLayerStore();
const { executeCommand } = useHistoryStore();
const [screenPosition, setScreenPosition] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
const [dragStart, setDragStart] = useState({ x: 0, y: 0, initialCanvasX: 0, initialCanvasY: 0 });
const [textBounds, setTextBounds] = useState({ width: 0, height: 0 });
const textareaRef = useRef<HTMLTextAreaElement>(null);
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const commitTextRef = useRef<(() => void) | null>(null);
// Calculate screen position from canvas position
const calculateScreenPosition = useCallback(() => {
if (!editorPosition || !containerRef.current) return { x: 0, y: 0 };
const container = containerRef.current.parentElement;
if (!container) return { x: 0, y: 0 };
const rect = container.getBoundingClientRect();
// Apply canvas transformation: canvas coords -> screen coords
// The canvas uses: translate(offsetX + width/2, offsetY + height/2), scale(zoom), translate(-canvasWidth/2, -canvasHeight/2)
const transformedX = (editorPosition.x - canvasWidth / 2) * zoom + (rect.width / 2) + offsetX;
const transformedY = (editorPosition.y - canvasHeight / 2) * zoom + (rect.height / 2) + offsetY;
return {
x: transformedX,
y: transformedY,
};
}, [editorPosition, zoom, offsetX, offsetY, canvasWidth, canvasHeight]);
// Update screen position when canvas transforms or position changes
useEffect(() => {
if (isOnCanvasEditorActive && editorPosition) {
const pos = calculateScreenPosition();
setScreenPosition(pos);
}
}, [isOnCanvasEditorActive, editorPosition, calculateScreenPosition]);
// Measure text bounds
const measureTextBounds = useCallback((text: string) => {
if (!previewCanvasRef.current) {
return { width: 200, height: settings.fontSize * settings.lineHeight };
}
const canvas = previewCanvasRef.current;
const ctx = canvas.getContext('2d');
if (!ctx) {
return { width: 200, height: settings.fontSize * settings.lineHeight };
}
// Measure text
ctx.font = `${settings.fontStyle} ${settings.fontWeight} ${settings.fontSize}px ${settings.fontFamily}`;
const lines = text ? text.split('\n') : [''];
let maxWidth = 0;
lines.forEach((line) => {
const width = ctx.measureText(line || ' ').width; // Measure space if empty
if (width > maxWidth) maxWidth = width;
});
// Add padding for better visibility and ensure minimum size
const width = Math.max(maxWidth + 40, 200);
const height = Math.max(lines.length * settings.fontSize * settings.lineHeight + 10, settings.fontSize * settings.lineHeight + 10);
return { width, height };
}, [settings]);
// Update text bounds when text or settings change
useEffect(() => {
const bounds = measureTextBounds(editorText);
setTextBounds(bounds);
}, [editorText, settings, measureTextBounds]);
// Render text preview on canvas
useEffect(() => {
if (!previewCanvasRef.current) return;
const canvas = previewCanvasRef.current;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Set canvas size to match text bounds (scaled)
canvas.width = textBounds.width * zoom;
canvas.height = textBounds.height * zoom;
// Clear canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (!editorText) return;
// Scale context for rendering
ctx.save();
ctx.scale(zoom, zoom);
// Render text at origin with 'top' baseline so it's visible
// Add small padding from top edge
renderText(ctx, 10, 0, { ...settings, text: editorText, baseline: 'top' });
ctx.restore();
}, [editorText, settings, textBounds, zoom]);
// Handle commit text
const commitText = useCallback(() => {
if (!editorPosition) return;
if (!editorText.trim()) {
// Empty text, just close editor
deactivateOnCanvasEditor();
return;
}
const activeLayer = getActiveLayer();
if (!activeLayer) {
deactivateOnCanvasEditor();
return;
}
if (editingTextId) {
// Update existing text object - update position if moved
updateTextObject(editingTextId, {
x: editorPosition.x + 10,
y: editorPosition.y,
text: editorText,
fontFamily: settings.fontFamily,
fontSize: settings.fontSize,
fontStyle: settings.fontStyle,
fontWeight: settings.fontWeight,
color: settings.color,
align: settings.align,
baseline: 'top',
lineHeight: settings.lineHeight,
letterSpacing: settings.letterSpacing,
});
} else {
// Create new text object
addTextObject({
layerId: activeLayer.id,
x: editorPosition.x + 10,
y: editorPosition.y,
text: editorText,
fontFamily: settings.fontFamily,
fontSize: settings.fontSize,
fontStyle: settings.fontStyle,
fontWeight: settings.fontWeight,
color: settings.color,
align: settings.align,
baseline: 'top',
lineHeight: settings.lineHeight,
letterSpacing: settings.letterSpacing,
});
}
// Update settings text for future use
setText(editorText);
// Close editor
deactivateOnCanvasEditor();
}, [editorPosition, editorText, editingTextId, settings, deactivateOnCanvasEditor, addTextObject, updateTextObject, setText, getActiveLayer]);
// Store commitText in ref for click outside handler
useEffect(() => {
commitTextRef.current = commitText;
}, [commitText]);
// Handle keyboard shortcuts
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && e.ctrlKey) {
e.preventDefault();
commitText();
} else if (e.key === 'Escape') {
e.preventDefault();
deactivateOnCanvasEditor();
}
},
[commitText, deactivateOnCanvasEditor]
);
// Handle drag start
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
if (!editorPosition) return;
setIsDragging(true);
setDragStart({
x: e.clientX,
y: e.clientY,
initialCanvasX: editorPosition.x,
initialCanvasY: editorPosition.y,
});
e.preventDefault();
e.stopPropagation();
},
[editorPosition]
);
// Handle drag move
const handleMouseMove = useCallback(
(e: globalThis.MouseEvent) => {
if (!isDragging) return;
const deltaX = e.clientX - dragStart.x;
const deltaY = e.clientY - dragStart.y;
// Convert screen delta to canvas delta
const canvasDeltaX = deltaX / zoom;
const canvasDeltaY = deltaY / zoom;
updateEditorPosition(
dragStart.initialCanvasX + canvasDeltaX,
dragStart.initialCanvasY + canvasDeltaY
);
},
[isDragging, dragStart, zoom, updateEditorPosition]
);
// Handle drag end
const handleMouseUp = useCallback(() => {
setIsDragging(false);
}, []);
// Attach global mouse listeners for dragging
useEffect(() => {
if (isDragging) {
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}
}, [isDragging, handleMouseMove, handleMouseUp]);
// No separate click-outside handler needed - using overlay approach
// Focus textarea when editor activates
useEffect(() => {
if (isOnCanvasEditorActive) {
setTimeout(() => {
textareaRef.current?.focus();
}, 10);
}
}, [isOnCanvasEditorActive]);
if (!isOnCanvasEditorActive || !editorPosition) return null;
const scaledWidth = textBounds.width * zoom;
const scaledHeight = textBounds.height * zoom;
return (
<>
{/* Full-screen overlay to capture outside clicks */}
<div
className="fixed inset-0"
style={{
zIndex: 999,
pointerEvents: 'auto',
}}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
commitText();
}}
/>
<div
ref={containerRef}
className="absolute"
style={{
left: `${screenPosition.x}px`,
top: `${screenPosition.y}px`,
width: `${scaledWidth}px`,
height: `${scaledHeight}px`,
zIndex: 1000,
pointerEvents: 'auto',
}}
>
{/* Bounding box - visual only */}
<div
className="absolute inset-0 border-2 border-primary pointer-events-none"
style={{
borderStyle: 'dashed',
}}
>
{/* Transform handles - draggable */}
<div
className="absolute w-2 h-2 bg-white border-2 border-primary rounded-full -left-1 -top-1 cursor-move"
style={{ pointerEvents: 'auto' }}
onMouseDown={handleMouseDown}
/>
<div
className="absolute w-2 h-2 bg-white border-2 border-primary rounded-full left-1/2 -translate-x-1/2 -top-1 cursor-move"
style={{ pointerEvents: 'auto' }}
onMouseDown={handleMouseDown}
/>
<div
className="absolute w-2 h-2 bg-white border-2 border-primary rounded-full -right-1 -top-1 cursor-move"
style={{ pointerEvents: 'auto' }}
onMouseDown={handleMouseDown}
/>
<div
className="absolute w-2 h-2 bg-white border-2 border-primary rounded-full -left-1 top-1/2 -translate-y-1/2 cursor-move"
style={{ pointerEvents: 'auto' }}
onMouseDown={handleMouseDown}
/>
<div
className="absolute w-2 h-2 bg-white border-2 border-primary rounded-full -right-1 top-1/2 -translate-y-1/2 cursor-move"
style={{ pointerEvents: 'auto' }}
onMouseDown={handleMouseDown}
/>
<div
className="absolute w-2 h-2 bg-white border-2 border-primary rounded-full -left-1 -bottom-1 cursor-move"
style={{ pointerEvents: 'auto' }}
onMouseDown={handleMouseDown}
/>
<div
className="absolute w-2 h-2 bg-white border-2 border-primary rounded-full left-1/2 -translate-x-1/2 -bottom-1 cursor-move"
style={{ pointerEvents: 'auto' }}
onMouseDown={handleMouseDown}
/>
<div
className="absolute w-2 h-2 bg-white border-2 border-primary rounded-full -right-1 -bottom-1 cursor-move"
style={{ pointerEvents: 'auto' }}
onMouseDown={handleMouseDown}
/>
</div>
{/* Text preview canvas */}
<canvas
ref={previewCanvasRef}
className="absolute left-0 top-0 pointer-events-none"
style={{
width: `${scaledWidth}px`,
height: `${scaledHeight}px`,
zIndex: 1,
}}
/>
{/* Transparent textarea for input */}
<textarea
ref={textareaRef}
value={editorText}
onChange={(e) => updateEditorText(e.target.value)}
onKeyDown={handleKeyDown}
onMouseDown={(e) => {
// Stop propagation to prevent overlay from committing during text interaction
e.stopPropagation();
}}
wrap="off"
spellCheck={false}
autoComplete="off"
className="absolute left-0 top-0 resize-none overflow-hidden outline-none border-none text-editor-input"
style={{
width: `${scaledWidth}px`,
height: `${scaledHeight}px`,
background: 'transparent',
color: 'transparent',
caretColor: settings.color,
fontFamily: settings.fontFamily,
fontSize: `${settings.fontSize * zoom}px`,
fontStyle: settings.fontStyle,
fontWeight: settings.fontWeight,
lineHeight: settings.lineHeight,
letterSpacing: `${settings.letterSpacing * zoom}px`,
textAlign: settings.align,
padding: 0,
paddingLeft: `${10 * zoom}px`,
margin: 0,
whiteSpace: 'pre',
zIndex: 10,
userSelect: 'text',
WebkitUserSelect: 'text',
MozUserSelect: 'text',
cursor: 'text',
}}
/>
</div>
</>
);
}

View File

@@ -9,7 +9,6 @@ import { ToolOptions } from './tool-options';
import { PanelDock } from './panel-dock';
import { ThemeToggle } from './theme-toggle';
import { ToolPalette } from '@/components/tools';
import { TextDialog } from '@/components/modals/text-dialog';
import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts';
import { useFileOperations } from '@/hooks/use-file-operations';
import { useDragDrop } from '@/hooks/use-drag-drop';
@@ -191,9 +190,6 @@ export function EditorLayout() {
{/* Right: Panel Dock */}
<PanelDock />
</div>
{/* Text Dialog */}
<TextDialog />
</div>
);
}

View File

@@ -17,10 +17,13 @@ const DEFAULT_SETTINGS: TextSettings = {
export const useTextStore = create<ITextStore>()(
persist(
(set) => ({
(set, get) => ({
settings: { ...DEFAULT_SETTINGS },
isDialogOpen: false,
clickPosition: null,
textObjects: [],
isOnCanvasEditorActive: false,
editorPosition: null,
editorText: '',
editingTextId: null,
setText: (text) =>
set((state) => ({
@@ -77,21 +80,134 @@ export const useTextStore = create<ITextStore>()(
settings: { ...state.settings, ...settings },
})),
openDialog: (x, y) =>
// Text object management
addTextObject: (textObject) =>
set((state) => ({
textObjects: [
...state.textObjects,
{
...textObject,
id: `text-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
},
],
})),
updateTextObject: (id, updates) =>
set((state) => ({
textObjects: state.textObjects.map((obj) =>
obj.id === id ? { ...obj, ...updates } : obj
),
})),
deleteTextObject: (id) =>
set((state) => ({
textObjects: state.textObjects.filter((obj) => obj.id !== id),
})),
getTextObjectAt: (x, y, layerId) => {
const state = get();
// Create temporary canvas for text measurement
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) return null;
// Find text objects at position (reverse order to get topmost)
for (let i = state.textObjects.length - 1; i >= 0; i--) {
const obj = state.textObjects[i];
if (obj.layerId !== layerId) continue;
// Measure text properly
ctx.font = `${obj.fontStyle} ${obj.fontWeight} ${obj.fontSize}px ${obj.fontFamily}`;
const lines = obj.text.split('\n');
let maxWidth = 0;
lines.forEach((line) => {
const metrics = ctx.measureText(line || ' ');
if (metrics.width > maxWidth) maxWidth = metrics.width;
});
// Account for the padding we add in the editor (40px horizontal, 10px vertical)
const width = maxWidth + 40;
const height = lines.length * obj.fontSize * obj.lineHeight + 10;
// The text is stored at obj.x, obj.y (which already includes the +10 offset from editor)
// But we need to check against the editor bounds which start 10px before
const boundX = obj.x - 10;
const boundY = obj.y;
if (
x >= boundX &&
x <= boundX + width &&
y >= boundY &&
y <= boundY + height
) {
return obj;
}
}
return null;
},
activateOnCanvasEditor: (canvasX, canvasY, textId) => {
const state = get();
if (textId) {
// Editing existing text
const textObj = state.textObjects.find((obj) => obj.id === textId);
if (textObj) {
set({
isOnCanvasEditorActive: true,
// Subtract the 10px padding offset to position editor correctly
editorPosition: { x: textObj.x - 10, y: textObj.y },
editorText: textObj.text,
editingTextId: textId,
settings: {
...state.settings,
fontFamily: textObj.fontFamily,
fontSize: textObj.fontSize,
fontStyle: textObj.fontStyle,
fontWeight: textObj.fontWeight,
color: textObj.color,
align: textObj.align,
baseline: textObj.baseline,
lineHeight: textObj.lineHeight,
letterSpacing: textObj.letterSpacing,
},
});
}
} else {
// Creating new text
set({
isOnCanvasEditorActive: true,
editorPosition: { x: canvasX, y: canvasY },
editorText: '',
editingTextId: null,
});
}
},
deactivateOnCanvasEditor: () =>
set({
isDialogOpen: true,
clickPosition: { x, y },
isOnCanvasEditorActive: false,
editorPosition: null,
editorText: '',
editingTextId: null,
}),
closeDialog: () =>
updateEditorPosition: (canvasX, canvasY) =>
set({
isDialogOpen: false,
clickPosition: null,
editorPosition: { x: canvasX, y: canvasY },
}),
updateEditorText: (text) =>
set({
editorText: text,
}),
}),
{
name: 'text-storage',
partialize: (state) => ({ settings: state.settings }), // Only persist settings, not dialog state
partialize: (state) => ({
settings: state.settings,
}), // Only persist settings, not text objects (they're saved with project)
}
)
);

View File

@@ -18,9 +18,18 @@ export class TextTool extends BaseTool {
const layer = this.getActiveLayer();
if (!layer) return;
// Open text dialog at click position
const { openDialog } = useTextStore.getState();
openDialog(pointer.x, pointer.y);
const { activateOnCanvasEditor, getTextObjectAt } = useTextStore.getState();
// Check if clicking on existing text object
const existingText = getTextObjectAt(pointer.x, pointer.y, layer.id);
if (existingText) {
// Edit existing text
activateOnCanvasEditor(pointer.x, pointer.y, existingText.id);
} else {
// Create new text
activateOnCanvasEditor(pointer.x, pointer.y);
}
}
onPointerMove(): void {

View File

@@ -16,10 +16,34 @@ export interface TextSettings {
letterSpacing: number;
}
export interface TextObject {
id: string;
layerId: string;
x: number;
y: number;
text: string;
fontFamily: string;
fontSize: number;
fontStyle: FontStyle;
fontWeight: FontWeight;
color: string;
align: TextAlign;
baseline: TextBaseline;
lineHeight: number;
letterSpacing: number;
}
export interface TextStore {
settings: TextSettings;
isDialogOpen: boolean;
clickPosition: { x: number; y: number } | null;
// Text objects (non-rasterized text)
textObjects: TextObject[];
// On-canvas editor state
isOnCanvasEditorActive: boolean;
editorPosition: { x: number; y: number } | null; // Canvas coordinates
editorText: string; // Current text being edited
editingTextId: string | null; // ID of text object being edited (null for new text)
// Setters
setText: (text: string) => void;
@@ -34,7 +58,15 @@ export interface TextStore {
setLetterSpacing: (letterSpacing: number) => void;
updateSettings: (settings: Partial<TextSettings>) => void;
// Dialog control
openDialog: (x: number, y: number) => void;
closeDialog: () => void;
// Text object management
addTextObject: (textObject: Omit<TextObject, 'id'>) => void;
updateTextObject: (id: string, updates: Partial<TextObject>) => void;
deleteTextObject: (id: string) => void;
getTextObjectAt: (x: number, y: number, layerId: string) => TextObject | null;
// On-canvas editor control
activateOnCanvasEditor: (canvasX: number, canvasY: number, textId?: string) => void;
deactivateOnCanvasEditor: () => void;
updateEditorPosition: (canvasX: number, canvasY: number) => void;
updateEditorText: (text: string) => void;
}