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:
@@ -330,3 +330,16 @@
|
|||||||
background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import { useEffect, useRef, useState } from 'react';
|
|||||||
import { useCanvasStore, useLayerStore, useToolStore } from '@/store';
|
import { useCanvasStore, useLayerStore, useToolStore } from '@/store';
|
||||||
import { useHistoryStore } from '@/store/history-store';
|
import { useHistoryStore } from '@/store/history-store';
|
||||||
import { useSelectionStore } from '@/store/selection-store';
|
import { useSelectionStore } from '@/store/selection-store';
|
||||||
|
import { useTextStore } from '@/store/text-store';
|
||||||
import { drawMarchingAnts } from '@/lib/selection-utils';
|
import { drawMarchingAnts } from '@/lib/selection-utils';
|
||||||
import { getContext, drawGrid, drawCheckerboard } from '@/lib/canvas-utils';
|
import { getContext, drawGrid, drawCheckerboard } from '@/lib/canvas-utils';
|
||||||
|
import { renderText } from '@/lib/text-utils';
|
||||||
import { DrawCommand } from '@/core/commands';
|
import { DrawCommand } from '@/core/commands';
|
||||||
import {
|
import {
|
||||||
PencilTool,
|
PencilTool,
|
||||||
@@ -25,6 +27,7 @@ import {
|
|||||||
} from '@/tools';
|
} from '@/tools';
|
||||||
import type { PointerState } from '@/types';
|
import type { PointerState } from '@/types';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { OnCanvasTextEditor } from './on-canvas-text-editor';
|
||||||
|
|
||||||
// Tool instances
|
// Tool instances
|
||||||
const tools: Record<string, BaseTool> = {
|
const tools: Record<string, BaseTool> = {
|
||||||
@@ -66,6 +69,7 @@ export function CanvasWithTools() {
|
|||||||
const { activeTool, settings } = useToolStore();
|
const { activeTool, settings } = useToolStore();
|
||||||
const { executeCommand } = useHistoryStore();
|
const { executeCommand } = useHistoryStore();
|
||||||
const { activeSelection, selectionType, isMarching } = useSelectionStore();
|
const { activeSelection, selectionType, isMarching } = useSelectionStore();
|
||||||
|
const { textObjects, editingTextId } = useTextStore();
|
||||||
const [marchingOffset, setMarchingOffset] = useState(0);
|
const [marchingOffset, setMarchingOffset] = useState(0);
|
||||||
|
|
||||||
const [isPanning, setIsPanning] = useState(false);
|
const [isPanning, setIsPanning] = useState(false);
|
||||||
@@ -129,6 +133,27 @@ export function CanvasWithTools() {
|
|||||||
ctx.globalAlpha = 1;
|
ctx.globalAlpha = 1;
|
||||||
ctx.globalCompositeOperation = 'source-over';
|
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)
|
// Draw grid if enabled (only within canvas bounds)
|
||||||
if (showGrid) {
|
if (showGrid) {
|
||||||
drawGrid(ctx, gridSize, 'rgba(0, 0, 0, 0.15)', width, height);
|
drawGrid(ctx, gridSize, 'rgba(0, 0, 0, 0.15)', width, height);
|
||||||
@@ -141,7 +166,7 @@ export function CanvasWithTools() {
|
|||||||
|
|
||||||
// Restore context state
|
// Restore context state
|
||||||
ctx.restore();
|
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
|
// Marching ants animation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -353,6 +378,9 @@ export function CanvasWithTools() {
|
|||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
className="absolute inset-0"
|
className="absolute inset-0"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* On-canvas text editor */}
|
||||||
|
<OnCanvasTextEditor />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
411
components/canvas/on-canvas-text-editor.tsx
Normal file
411
components/canvas/on-canvas-text-editor.tsx
Normal 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>
|
||||||
|
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,7 +9,6 @@ import { ToolOptions } from './tool-options';
|
|||||||
import { PanelDock } from './panel-dock';
|
import { PanelDock } from './panel-dock';
|
||||||
import { ThemeToggle } from './theme-toggle';
|
import { ThemeToggle } from './theme-toggle';
|
||||||
import { ToolPalette } from '@/components/tools';
|
import { ToolPalette } from '@/components/tools';
|
||||||
import { TextDialog } from '@/components/modals/text-dialog';
|
|
||||||
import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts';
|
import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts';
|
||||||
import { useFileOperations } from '@/hooks/use-file-operations';
|
import { useFileOperations } from '@/hooks/use-file-operations';
|
||||||
import { useDragDrop } from '@/hooks/use-drag-drop';
|
import { useDragDrop } from '@/hooks/use-drag-drop';
|
||||||
@@ -191,9 +190,6 @@ export function EditorLayout() {
|
|||||||
{/* Right: Panel Dock */}
|
{/* Right: Panel Dock */}
|
||||||
<PanelDock />
|
<PanelDock />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Text Dialog */}
|
|
||||||
<TextDialog />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,10 +17,13 @@ const DEFAULT_SETTINGS: TextSettings = {
|
|||||||
|
|
||||||
export const useTextStore = create<ITextStore>()(
|
export const useTextStore = create<ITextStore>()(
|
||||||
persist(
|
persist(
|
||||||
(set) => ({
|
(set, get) => ({
|
||||||
settings: { ...DEFAULT_SETTINGS },
|
settings: { ...DEFAULT_SETTINGS },
|
||||||
isDialogOpen: false,
|
textObjects: [],
|
||||||
clickPosition: null,
|
isOnCanvasEditorActive: false,
|
||||||
|
editorPosition: null,
|
||||||
|
editorText: '',
|
||||||
|
editingTextId: null,
|
||||||
|
|
||||||
setText: (text) =>
|
setText: (text) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
@@ -77,21 +80,134 @@ export const useTextStore = create<ITextStore>()(
|
|||||||
settings: { ...state.settings, ...settings },
|
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({
|
set({
|
||||||
isDialogOpen: true,
|
isOnCanvasEditorActive: false,
|
||||||
clickPosition: { x, y },
|
editorPosition: null,
|
||||||
|
editorText: '',
|
||||||
|
editingTextId: null,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
closeDialog: () =>
|
updateEditorPosition: (canvasX, canvasY) =>
|
||||||
set({
|
set({
|
||||||
isDialogOpen: false,
|
editorPosition: { x: canvasX, y: canvasY },
|
||||||
clickPosition: null,
|
}),
|
||||||
|
|
||||||
|
updateEditorText: (text) =>
|
||||||
|
set({
|
||||||
|
editorText: text,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'text-storage',
|
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)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,9 +18,18 @@ export class TextTool extends BaseTool {
|
|||||||
const layer = this.getActiveLayer();
|
const layer = this.getActiveLayer();
|
||||||
if (!layer) return;
|
if (!layer) return;
|
||||||
|
|
||||||
// Open text dialog at click position
|
const { activateOnCanvasEditor, getTextObjectAt } = useTextStore.getState();
|
||||||
const { openDialog } = useTextStore.getState();
|
|
||||||
openDialog(pointer.x, pointer.y);
|
// 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 {
|
onPointerMove(): void {
|
||||||
|
|||||||
@@ -16,10 +16,34 @@ export interface TextSettings {
|
|||||||
letterSpacing: number;
|
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 {
|
export interface TextStore {
|
||||||
settings: TextSettings;
|
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
|
// Setters
|
||||||
setText: (text: string) => void;
|
setText: (text: string) => void;
|
||||||
@@ -34,7 +58,15 @@ export interface TextStore {
|
|||||||
setLetterSpacing: (letterSpacing: number) => void;
|
setLetterSpacing: (letterSpacing: number) => void;
|
||||||
updateSettings: (settings: Partial<TextSettings>) => void;
|
updateSettings: (settings: Partial<TextSettings>) => void;
|
||||||
|
|
||||||
// Dialog control
|
// Text object management
|
||||||
openDialog: (x: number, y: number) => void;
|
addTextObject: (textObject: Omit<TextObject, 'id'>) => void;
|
||||||
closeDialog: () => 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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user