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>
418 lines
14 KiB
TypeScript
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>
|
|
|
|
</>
|
|
);
|
|
}
|