- 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>
412 lines
13 KiB
TypeScript
412 lines
13 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,
|
|
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>
|
|
|
|
</>
|
|
);
|
|
}
|