Files
paint-ui/components/canvas/on-canvas-text-editor.tsx
Sebastian Krüger 4693f5613b feat(text-tool): add Delete/Backspace key to remove empty text objects
Users can now delete text objects by:
1. Click on a text to edit it
2. Clear all text content (or start with empty text)
3. Press Delete or Backspace to remove the entire text object

This provides an intuitive way to remove unwanted text without requiring
a separate delete tool or context menu.

Keyboard shortcuts:
- Ctrl+Enter: Commit text
- Escape: Cancel editing
- Delete/Backspace (on empty text): Delete entire text object

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 15:23:43 +01:00

418 lines
14 KiB
TypeScript

'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,
deleteTextObject,
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();
} else if ((e.key === 'Delete' || e.key === 'Backspace') && editingTextId && !editorText.trim()) {
// Delete the entire text object if it's empty and we're editing an existing text
e.preventDefault();
deleteTextObject(editingTextId);
deactivateOnCanvasEditor();
}
},
[commitText, deactivateOnCanvasEditor, editingTextId, editorText, deleteTextObject]
);
// 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>
</>
);
}