2025-11-21 09:45:05 +01:00
|
|
|
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(
|
2025-11-21 14:31:47 +01:00
|
|
|
(set, get) => ({
|
2025-11-21 09:45:05 +01:00
|
|
|
settings: { ...DEFAULT_SETTINGS },
|
2025-11-21 14:31:47 +01:00
|
|
|
textObjects: [],
|
|
|
|
|
isOnCanvasEditorActive: false,
|
|
|
|
|
editorPosition: null,
|
|
|
|
|
editorText: '',
|
|
|
|
|
editingTextId: null,
|
2025-11-21 09:45:05 +01:00
|
|
|
|
|
|
|
|
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 },
|
|
|
|
|
})),
|
|
|
|
|
|
2025-11-21 14:31:47 +01:00
|
|
|
// 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) =>
|
2025-11-21 09:45:05 +01:00
|
|
|
set({
|
2025-11-21 14:31:47 +01:00
|
|
|
editorPosition: { x: canvasX, y: canvasY },
|
2025-11-21 09:45:05 +01:00
|
|
|
}),
|
|
|
|
|
|
2025-11-21 14:31:47 +01:00
|
|
|
updateEditorText: (text) =>
|
2025-11-21 09:45:05 +01:00
|
|
|
set({
|
2025-11-21 14:31:47 +01:00
|
|
|
editorText: text,
|
2025-11-21 09:45:05 +01:00
|
|
|
}),
|
|
|
|
|
}),
|
|
|
|
|
{
|
|
|
|
|
name: 'text-storage',
|
2025-11-21 14:31:47 +01:00
|
|
|
partialize: (state) => ({
|
|
|
|
|
settings: state.settings,
|
|
|
|
|
}), // Only persist settings, not text objects (they're saved with project)
|
2025-11-21 09:45:05 +01:00
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
);
|