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;
|
||||
}
|
||||
}
|
||||
|
||||
/* 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 { 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>
|
||||
);
|
||||
}
|
||||
|
||||
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 { 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user