text component
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
import { type FC, type SelectHTMLAttributes } from 'react'
|
||||
import { clx } from '../helpers/utils'
|
||||
import { ArrowDown2 } from 'iconsax-react'
|
||||
import Error from './Error'
|
||||
|
||||
export type ItemsSelectType = {
|
||||
value: string,
|
||||
label: string,
|
||||
}
|
||||
type Props = {
|
||||
className?: string,
|
||||
items: ItemsSelectType[],
|
||||
error_text?: string,
|
||||
placeholder?: string,
|
||||
label?: string,
|
||||
value?: string | number,
|
||||
} & SelectHTMLAttributes<HTMLSelectElement>
|
||||
|
||||
const Select: FC<Props> = (props: Props) => {
|
||||
return (
|
||||
<div className='w-full relative'>
|
||||
{
|
||||
props.label &&
|
||||
<label className='text-sm'>
|
||||
{props.label}
|
||||
</label>
|
||||
}
|
||||
<select {...props} className={clx(
|
||||
'w-full text-black block border appearance-none border-border px-[10px] h-10 text-sm rounded-[10px] bg-white',
|
||||
props.className,
|
||||
props.label && 'mt-1'
|
||||
)}>
|
||||
{
|
||||
props.placeholder &&
|
||||
<option value="" disabled>{props.placeholder}</option>
|
||||
}
|
||||
{
|
||||
props.items?.map((item) => {
|
||||
return (
|
||||
<option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</option>
|
||||
)
|
||||
})
|
||||
}
|
||||
</select>
|
||||
<ArrowDown2 size={16} color='black' className={clx(
|
||||
'absolute z-0 top-3 left-2',
|
||||
props.label && 'top-10'
|
||||
)} />
|
||||
{
|
||||
props.error_text && props.error_text !== '' ?
|
||||
<Error
|
||||
errorText={props.error_text}
|
||||
/>
|
||||
: null
|
||||
}
|
||||
</div>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
export default Select
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import Konva from "konva";
|
||||
import { useEditorStore, type EditorObject } from "@/pages/editor/store/editorStore";
|
||||
import { useTextDefaultsStore } from "@/pages/editor/store/textDefaultsStore";
|
||||
import { createDrawingObject } from "./drawingUtils";
|
||||
|
||||
export const useDrawingHandlers = () => {
|
||||
@@ -10,6 +11,7 @@ export const useDrawingHandlers = () => {
|
||||
const transformerRef = useRef<Konva.Transformer>(null);
|
||||
|
||||
const { tool, addObject, updateObject, setSelectedObjectId, selectedObjectId } = useEditorStore();
|
||||
const { defaults } = useTextDefaultsStore();
|
||||
|
||||
useEffect(() => {
|
||||
setTempObject(null);
|
||||
@@ -59,8 +61,13 @@ export const useDrawingHandlers = () => {
|
||||
x: pointerPos.x,
|
||||
y: pointerPos.y,
|
||||
text: "متن جدید",
|
||||
fontSize: 24,
|
||||
fill: "#000000",
|
||||
fontSize: defaults.fontSize,
|
||||
fontFamily: defaults.fontFamily,
|
||||
fontWeight: defaults.fontWeight,
|
||||
fill: defaults.fill,
|
||||
opacity: defaults.opacity,
|
||||
letterSpacing: defaults.letterSpacing,
|
||||
wordSpacing: defaults.wordSpacing,
|
||||
};
|
||||
addObject(newText);
|
||||
setSelectedObjectId(newText.id);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Trash } from "iconsax-react";
|
||||
import type { EditorObject } from "../../store/editorStore";
|
||||
import {
|
||||
TextSettings,
|
||||
ShapeSettings,
|
||||
LineSettings,
|
||||
SizeSettings,
|
||||
@@ -10,6 +9,7 @@ import {
|
||||
TransformSettings,
|
||||
GridSettings,
|
||||
} from "./settings";
|
||||
import TextInstruction from "./instructions/TextInstruction";
|
||||
|
||||
type ObjectSettingsProps = {
|
||||
selectedObject: EditorObject;
|
||||
@@ -30,7 +30,7 @@ const ObjectSettings = ({ selectedObject, onUpdate, onDelete }: ObjectSettingsPr
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{selectedObject.type === "text" && <TextSettings selectedObject={selectedObject} onUpdate={onUpdate} />}
|
||||
{selectedObject.type === "text" && <TextInstruction />}
|
||||
|
||||
{(selectedObject.type === "rectangle" || selectedObject.type === "circle") && (
|
||||
<ShapeSettings selectedObject={selectedObject} onUpdate={onUpdate} />
|
||||
|
||||
@@ -1,8 +1,170 @@
|
||||
const TextInstruction = () => (
|
||||
<div className="text-sm text-gray-600">
|
||||
<p>برای افزودن متن، روی کانوس کلیک کنید</p>
|
||||
</div>
|
||||
);
|
||||
import { type FC, useRef, useState, useEffect } from "react";
|
||||
import Input from "@/components/Input";
|
||||
import Select from "@/components/Select";
|
||||
import { useEditorStore } from "@/pages/editor/store/editorStore";
|
||||
import { useTextDefaultsStore } from "@/pages/editor/store/textDefaultsStore";
|
||||
|
||||
const fontOptions = [
|
||||
{ label: "ایرانسل", value: "1" },
|
||||
{ label: "فونت 2", value: "2" },
|
||||
];
|
||||
|
||||
const fontWeightOptions = [
|
||||
{ label: "Bold", value: "bold" },
|
||||
{ label: "Normal", value: "normal" },
|
||||
{ label: "Light", value: "300" },
|
||||
];
|
||||
|
||||
const TextInstruction: FC = () => {
|
||||
const { selectedObjectId, objects, updateObject } = useEditorStore();
|
||||
const { defaults, updateDefaults } = useTextDefaultsStore();
|
||||
|
||||
const selectedObject = selectedObjectId
|
||||
? objects.find((obj) => obj.id === selectedObjectId && obj.type === "text")
|
||||
: null;
|
||||
|
||||
const isEditing = !!selectedObject;
|
||||
const currentValues = isEditing
|
||||
? {
|
||||
fontFamily: selectedObject.fontFamily || defaults.fontFamily,
|
||||
fontWeight: selectedObject.fontWeight || defaults.fontWeight,
|
||||
fontSize: selectedObject.fontSize || defaults.fontSize,
|
||||
fill: selectedObject.fill || defaults.fill,
|
||||
opacity: selectedObject.opacity ?? defaults.opacity,
|
||||
letterSpacing: selectedObject.letterSpacing ?? defaults.letterSpacing,
|
||||
wordSpacing: selectedObject.wordSpacing ?? defaults.wordSpacing,
|
||||
}
|
||||
: defaults;
|
||||
|
||||
const handleChange = (field: string, value: string | number) => {
|
||||
if (isEditing && selectedObject) {
|
||||
updateObject(selectedObject.id, { [field]: value });
|
||||
} else {
|
||||
updateDefaults({ [field]: value });
|
||||
}
|
||||
};
|
||||
|
||||
const hexColor = (currentValues.fill || "#000000").replace("#", "").toUpperCase();
|
||||
const opacityValue = currentValues.opacity;
|
||||
const colorInputRef = useRef<HTMLInputElement>(null);
|
||||
const [opacityInput, setOpacityInput] = useState<string>(`${opacityValue}`);
|
||||
|
||||
useEffect(() => {
|
||||
setOpacityInput(`${opacityValue}`);
|
||||
}, [opacityValue]);
|
||||
|
||||
const handleColorClick = () => {
|
||||
colorInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleOpacityChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value.replace("%", "").trim();
|
||||
setOpacityInput(value);
|
||||
};
|
||||
|
||||
const handleOpacityBlur = () => {
|
||||
const numValue = parseInt(opacityInput) || 0;
|
||||
const clampedValue = Math.min(100, Math.max(0, numValue));
|
||||
setOpacityInput(`${clampedValue}`);
|
||||
if (clampedValue !== opacityValue) {
|
||||
handleChange("opacity", clampedValue);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="font-bold">متن</h2>
|
||||
<div className="mt-8">
|
||||
<Select
|
||||
items={fontOptions}
|
||||
placeholder="انتخاب فونت"
|
||||
label="فونت"
|
||||
value={currentValues.fontFamily}
|
||||
onChange={(e) => handleChange("fontFamily", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mt-4">
|
||||
<Select
|
||||
label="وزن"
|
||||
items={fontWeightOptions}
|
||||
value={currentValues.fontWeight}
|
||||
onChange={(e) => handleChange("fontWeight", e.target.value)}
|
||||
/>
|
||||
|
||||
<Input
|
||||
value={`${currentValues.fontSize}px`}
|
||||
label="سایز"
|
||||
onChange={(e) => {
|
||||
const value = e.target.value.replace("px", "").trim();
|
||||
const numValue = parseInt(value) || 0;
|
||||
handleChange("fontSize", numValue);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="text-sm">رنگ</div>
|
||||
<div className="mt-2 border border-border rounded-xl p-2 flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleColorClick}
|
||||
className="flex-1 flex items-center gap-1.5 cursor-pointer hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<div
|
||||
className="size-6 rounded"
|
||||
style={{ backgroundColor: currentValues.fill }}
|
||||
/>
|
||||
<div className="text-sm">{hexColor}</div>
|
||||
</button>
|
||||
<input
|
||||
ref={colorInputRef}
|
||||
type="color"
|
||||
value={currentValues.fill}
|
||||
onChange={(e) => handleChange("fill", e.target.value)}
|
||||
className="hidden"
|
||||
/>
|
||||
<div className="text-sm flex px-2 mr-3 border-r border-border">
|
||||
<input
|
||||
type="text"
|
||||
className="text-center w-14 border-none outline-none bg-transparent text-sm"
|
||||
value={`${opacityInput}%`}
|
||||
onChange={handleOpacityChange}
|
||||
onBlur={handleOpacityBlur}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex gap-2">
|
||||
<Input
|
||||
label="فاصله کلمات"
|
||||
value={`${currentValues.wordSpacing}px`}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value.replace("px", "").trim();
|
||||
const numValue = parseInt(value) || 0;
|
||||
handleChange("wordSpacing", numValue);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="فاصله حروف"
|
||||
value={`${currentValues.letterSpacing}px`}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value.replace("px", "").trim();
|
||||
const numValue = parseInt(value) || 0;
|
||||
handleChange("letterSpacing", numValue);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TextInstruction;
|
||||
|
||||
|
||||
@@ -1,8 +1,38 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, useMemo } from "react";
|
||||
import { Text } from "react-konva";
|
||||
import Konva from "konva";
|
||||
import type { TextShapeProps } from "./types";
|
||||
|
||||
const getFontFamily = (fontFamily?: string): string => {
|
||||
const fontMap: Record<string, string> = {
|
||||
"1": "irancell",
|
||||
"2": "irancell",
|
||||
};
|
||||
return fontMap[fontFamily || "1"] || "irancell";
|
||||
};
|
||||
|
||||
const getFontWeight = (fontWeight?: string): string | number => {
|
||||
const weightMap: Record<string, string | number> = {
|
||||
bold: "600",
|
||||
normal: "300",
|
||||
"300": "300",
|
||||
};
|
||||
return weightMap[fontWeight || "normal"] || "300";
|
||||
};
|
||||
|
||||
const getColorWithOpacity = (fill?: string, opacity?: number): string => {
|
||||
if (!fill) return "#000000";
|
||||
if (opacity === undefined || opacity === 100) return fill;
|
||||
|
||||
const hex = fill.replace("#", "");
|
||||
const r = parseInt(hex.substring(0, 2), 16);
|
||||
const g = parseInt(hex.substring(2, 4), 16);
|
||||
const b = parseInt(hex.substring(4, 6), 16);
|
||||
const alpha = opacity / 100;
|
||||
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
};
|
||||
|
||||
const TextShape = ({
|
||||
obj,
|
||||
isSelected,
|
||||
@@ -21,6 +51,10 @@ const TextShape = ({
|
||||
}
|
||||
}, [isSelected, transformerRef]);
|
||||
|
||||
const fontFamily = useMemo(() => getFontFamily(obj.fontFamily), [obj.fontFamily]);
|
||||
const fontWeight = useMemo(() => getFontWeight(obj.fontWeight), [obj.fontWeight]);
|
||||
const fillColor = useMemo(() => getColorWithOpacity(obj.fill, obj.opacity), [obj.fill, obj.opacity]);
|
||||
|
||||
return (
|
||||
<Text
|
||||
ref={shapeRef}
|
||||
@@ -28,8 +62,10 @@ const TextShape = ({
|
||||
y={obj.y}
|
||||
text={obj.text || ""}
|
||||
fontSize={obj.fontSize || 24}
|
||||
fill={obj.fill || "#000000"}
|
||||
fontFamily="irancell"
|
||||
fill={fillColor}
|
||||
fontFamily={fontFamily}
|
||||
fontWeight={fontWeight}
|
||||
letterSpacing={obj.letterSpacing}
|
||||
width={obj.width}
|
||||
height={obj.height}
|
||||
rotation={obj.rotation || 0}
|
||||
@@ -90,7 +126,9 @@ const TextShape = ({
|
||||
textarea.style.background = "transparent";
|
||||
textarea.style.outline = "none";
|
||||
textarea.style.resize = "none";
|
||||
textarea.style.fontFamily = "irancell";
|
||||
textarea.style.fontFamily = fontFamily;
|
||||
textarea.style.fontWeight = String(fontWeight);
|
||||
textarea.style.letterSpacing = `${obj.letterSpacing || 0}px`;
|
||||
textarea.style.direction = "rtl";
|
||||
textarea.focus();
|
||||
|
||||
|
||||
@@ -28,6 +28,11 @@ export type EditorObject = {
|
||||
strokeWidth?: number;
|
||||
text?: string;
|
||||
fontSize?: number;
|
||||
fontFamily?: string;
|
||||
fontWeight?: string;
|
||||
letterSpacing?: number;
|
||||
wordSpacing?: number;
|
||||
opacity?: number;
|
||||
imageUrl?: string;
|
||||
videoUrl?: string;
|
||||
linkUrl?: string;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
export type TextDefaults = {
|
||||
fontFamily: string;
|
||||
fontWeight: string;
|
||||
fontSize: number;
|
||||
fill: string;
|
||||
opacity: number;
|
||||
letterSpacing: number;
|
||||
wordSpacing: number;
|
||||
};
|
||||
|
||||
type TextDefaultsStoreType = {
|
||||
defaults: TextDefaults;
|
||||
updateDefaults: (updates: Partial<TextDefaults>) => void;
|
||||
};
|
||||
|
||||
export const useTextDefaultsStore = create<TextDefaultsStoreType>((set) => ({
|
||||
defaults: {
|
||||
fontFamily: "1",
|
||||
fontWeight: "bold",
|
||||
fontSize: 16,
|
||||
fill: "#A27BC1",
|
||||
opacity: 100,
|
||||
letterSpacing: 10,
|
||||
wordSpacing: 10,
|
||||
},
|
||||
updateDefaults: (updates) =>
|
||||
set((state) => ({
|
||||
defaults: { ...state.defaults, ...updates },
|
||||
})),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user