Files
paint-ui/store/text-store.ts
Sebastian Krüger 69a468141c 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>
2025-11-21 14:31:47 +01:00

214 lines
6.2 KiB
TypeScript

import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { TextSettings, TextStore as ITextStore } from '@/types/text';
const DEFAULT_SETTINGS: TextSettings = {
text: '',
fontFamily: 'Arial',
fontSize: 48,
fontStyle: 'normal',
fontWeight: 'normal',
color: '#000000',
align: 'left',
baseline: 'alphabetic',
lineHeight: 1.2,
letterSpacing: 0,
};
export const useTextStore = create<ITextStore>()(
persist(
(set, get) => ({
settings: { ...DEFAULT_SETTINGS },
textObjects: [],
isOnCanvasEditorActive: false,
editorPosition: null,
editorText: '',
editingTextId: null,
setText: (text) =>
set((state) => ({
settings: { ...state.settings, text },
})),
setFontFamily: (fontFamily) =>
set((state) => ({
settings: { ...state.settings, fontFamily },
})),
setFontSize: (fontSize) =>
set((state) => ({
settings: { ...state.settings, fontSize: Math.max(8, Math.min(500, fontSize)) },
})),
setFontStyle: (fontStyle) =>
set((state) => ({
settings: { ...state.settings, fontStyle },
})),
setFontWeight: (fontWeight) =>
set((state) => ({
settings: { ...state.settings, fontWeight },
})),
setColor: (color) =>
set((state) => ({
settings: { ...state.settings, color },
})),
setAlign: (align) =>
set((state) => ({
settings: { ...state.settings, align },
})),
setBaseline: (baseline) =>
set((state) => ({
settings: { ...state.settings, baseline },
})),
setLineHeight: (lineHeight) =>
set((state) => ({
settings: { ...state.settings, lineHeight: Math.max(0.5, Math.min(3, lineHeight)) },
})),
setLetterSpacing: (letterSpacing) =>
set((state) => ({
settings: { ...state.settings, letterSpacing: Math.max(-10, Math.min(50, letterSpacing)) },
})),
updateSettings: (settings) =>
set((state) => ({
settings: { ...state.settings, ...settings },
})),
// 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({
isOnCanvasEditorActive: false,
editorPosition: null,
editorText: '',
editingTextId: null,
}),
updateEditorPosition: (canvasX, canvasY) =>
set({
editorPosition: { x: canvasX, y: canvasY },
}),
updateEditorText: (text) =>
set({
editorText: text,
}),
}),
{
name: 'text-storage',
partialize: (state) => ({
settings: state.settings,
}), // Only persist settings, not text objects (they're saved with project)
}
)
);