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