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:
2025-11-21 14:31:47 +01:00
parent 4e6dc6cb14
commit 69a468141c
7 changed files with 628 additions and 23 deletions

View File

@@ -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)
}
)
);