Compare commits

..

9 Commits

Author SHA1 Message Date
hamid zarghami 677c62e6eb fix extra light weight
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-11 09:39:03 +03:30
hamid zarghami e0eb29488a position x and y in line 2026-07-11 09:31:40 +03:30
hamid zarghami ae3787241a object box smaller 2026-07-11 09:21:13 +03:30
hamid zarghami 9e105606a3 fix blur in viewer 2026-07-11 09:10:41 +03:30
hamid zarghami c6bb275d05 fix order in blur layers + fix aligment 2026-07-11 09:05:13 +03:30
hamid zarghami 4273e899ca cursor ignore
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-08 16:44:46 +03:30
hamid zarghami 0361e05acb blur
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-08 16:42:55 +03:30
hamid zarghami 33cce93064 bg opcity
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-08 15:50:21 +03:30
hamid zarghami a2a591420c color opacity
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-08 09:21:41 +03:30
40 changed files with 962 additions and 293 deletions
+35
View File
@@ -0,0 +1,35 @@
# Dependencies
node_modules/
# Build outputs
dist/
dist-ssr/
build/
out/
# Caches
.cache/
.vite/
# Coverage
coverage/
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Environment / secrets
.env
.env.*
!.env.example
# Editor / IDE
.vscode/
!.vscode/extensions.json
.idea/
# OS files
.DS_Store
+11
View File
@@ -23,6 +23,7 @@
"moment-jalaali": "^0.10.4", "moment-jalaali": "^0.10.4",
"page-flip": "^2.0.7", "page-flip": "^2.0.7",
"react": "^19.2.0", "react": "^19.2.0",
"react-colorful": "^5.7.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-dropzone": "^14.3.8", "react-dropzone": "^14.3.8",
"react-i18next": "^16.3.0", "react-i18next": "^16.3.0",
@@ -4556,6 +4557,16 @@
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
} }
}, },
"node_modules/react-colorful": {
"version": "5.7.0",
"resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.7.0.tgz",
"integrity": "sha512-fuesYIemttah97XmsIHmz4OORDHiSFzyc9HMAIrCHJou2jaRQmL8cFJ76K4zQhhj8jzwOBlOi4BaGTjjOZCfTg==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/react-date-object": { "node_modules/react-date-object": {
"version": "2.1.9", "version": "2.1.9",
"resolved": "https://registry.npmjs.org/react-date-object/-/react-date-object-2.1.9.tgz", "resolved": "https://registry.npmjs.org/react-date-object/-/react-date-object-2.1.9.tgz",
+1
View File
@@ -25,6 +25,7 @@
"moment-jalaali": "^0.10.4", "moment-jalaali": "^0.10.4",
"page-flip": "^2.0.7", "page-flip": "^2.0.7",
"react": "^19.2.0", "react": "^19.2.0",
"react-colorful": "^5.7.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-dropzone": "^14.3.8", "react-dropzone": "^14.3.8",
"react-i18next": "^16.3.0", "react-i18next": "^16.3.0",
+182 -103
View File
@@ -1,127 +1,206 @@
import { type FC, type InputHTMLAttributes, useState, useRef, useEffect } from 'react' import { type FC, type InputHTMLAttributes, useEffect, useRef, useState } from "react";
import { clx } from '../helpers/utils' import { HexAlphaColorPicker, HexColorInput, HexColorPicker } from "react-colorful";
import Error from './Error' import { clx } from "../helpers/utils";
import Error from "./Error";
type Props = { type Props = {
label?: string label?: string;
className?: string className?: string;
error_text?: string error_text?: string;
isNotRequired?: boolean isNotRequired?: boolean;
value?: string value?: string;
onChange?: (value: string) => void onChange?: (value: string) => void;
} & Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value'> opacity?: number;
onOpacityChange?: (value: number) => void;
opacityLabel?: string;
} & Omit<InputHTMLAttributes<HTMLInputElement>, "onChange" | "value">;
const ColorPicker: FC<Props> = (props: Props) => { const ColorPicker: FC<Props> = (props: Props) => {
const [color, setColor] = useState<string>(props.value || '#000000') const [color, setColor] = useState<string>(props.value || "#000000");
const colorInputRef = useRef<HTMLInputElement>(null) const [opacityDisplay, setOpacityDisplay] = useState<string>(`${props.opacity ?? 100}`);
const pickerOpenGuardRef = useRef(false) const [isOpen, setIsOpen] = useState(false);
const wrapperRef = useRef<HTMLDivElement>(null);
const showOpacity = props.onOpacityChange !== undefined;
const inputClass = clx( const clampOpacity = (rawValue: number): number => Math.min(100, Math.max(0, rawValue));
'w-full bg-white h-[34px] text-black block pl-10 pr-10 text-xs rounded-xl border border-border',
props.readOnly && 'bg-gray-100 border-0 text-description',
props.className
)
const handleColorChange = (event: React.ChangeEvent<HTMLInputElement>) => { const normalizeHex = (value: string): string => {
const newColor = event.target.value if (!value) return "#000000";
setColor(newColor) const withHash = value.startsWith("#") ? value : `#${value}`;
props.onChange?.(newColor) const shortMatch = /^#([0-9A-Fa-f]{3})$/.exec(withHash);
if (shortMatch) {
const [r, g, b] = shortMatch[1]!.split("");
return `#${r}${r}${g}${g}${b}${b}`.toLowerCase();
} }
const longMatch = /^#([0-9A-Fa-f]{6})$/.exec(withHash);
const handleTextChange = (event: React.ChangeEvent<HTMLInputElement>) => { if (longMatch) {
const value = event.target.value return `#${longMatch[1]}`.toLowerCase();
if (/^#[0-9A-Fa-f]{0,6}$/.test(value) || value === '') {
const finalColor = value || '#000000'
setColor(finalColor)
props.onChange?.(finalColor)
}
} }
return "#000000";
};
const openNativeColorPicker = () => { const toHexAlpha = (hex: string, opacity: number): string => {
if (props.readOnly) return const normalizedHex = normalizeHex(hex);
if (pickerOpenGuardRef.current) return const alpha = Math.round((clampOpacity(opacity) / 100) * 255)
pickerOpenGuardRef.current = true .toString(16)
colorInputRef.current?.click() .padStart(2, "0");
window.setTimeout(() => { return `${normalizedHex}${alpha}`;
pickerOpenGuardRef.current = false };
}, 400)
const fromHexAlpha = (hexAlpha: string): { hex: string; opacity: number } => {
const match = /^#([0-9A-Fa-f]{8})$/.exec(hexAlpha);
if (!match) {
return {
hex: normalizeHex(hexAlpha),
opacity: 100,
};
} }
const rgbaHex = match[1]!.toLowerCase();
const rgb = `#${rgbaHex.slice(0, 6)}`;
const alphaHex = rgbaHex.slice(6, 8);
const opacity = Math.round((parseInt(alphaHex, 16) / 255) * 100);
return { hex: rgb, opacity: clampOpacity(opacity) };
};
useEffect(() => { const inputClass = clx("w-full bg-white h-[34px] text-black block pl-10 pr-10 text-xs rounded-xl border border-border", props.readOnly && "bg-gray-100 border-0 text-description");
if (props.value !== undefined) {
setColor(props.value)
}
}, [props.value])
return ( const handleColorChange = (newColor: string) => {
<div className='w-full'> const normalized = normalizeHex(newColor);
<label className='text-xs'> setColor(normalized);
{props.label} props.onChange?.(normalized);
</label> };
<div const togglePicker = () => {
className={clx( if (props.readOnly) return;
'w-full relative mt-1 rounded-xl', setIsOpen((prev) => !prev);
!props.readOnly && 'cursor-pointer' };
)}
onClick={!props.readOnly ? openNativeColorPicker : undefined}
>
<input
type='text'
value={color}
onChange={handleTextChange}
placeholder='#000000'
readOnly={props.readOnly}
title={
!props.readOnly
? 'کلیک: پالت رنگ — با Tab وارد فیلد شوید تا کد را تایپ کنید'
: undefined
}
className={`${inputClass} cursor-pointer`}
/>
<span useEffect(() => {
aria-hidden if (props.value !== undefined) {
className={clx( setColor(normalizeHex(props.value));
'absolute top-0 bottom-0 my-auto left-1 flex items-center justify-center min-h-[34px] min-w-[34px] rounded-lg pointer-events-none', }
props.readOnly && 'opacity-50' }, [props.value]);
)}
> useEffect(() => {
{/* <img if (props.opacity !== undefined) {
setOpacityDisplay(`${props.opacity}`);
}
}, [props.opacity]);
useEffect(() => {
if (!isOpen) return;
const handlePointerDownOutside = (event: MouseEvent) => {
if (!wrapperRef.current?.contains(event.target as Node)) {
setIsOpen(false);
}
};
window.addEventListener("mousedown", handlePointerDownOutside);
return () => window.removeEventListener("mousedown", handlePointerDownOutside);
}, [isOpen]);
const handleOpacityChange = (rawValue: string) => {
setOpacityDisplay(rawValue);
const numValue = parseInt(rawValue, 10);
if (!Number.isNaN(numValue)) {
props.onOpacityChange?.(clampOpacity(numValue));
}
};
const handleOpacityBlur = (rawValue: string) => {
const numValue = parseInt(rawValue, 10) || 0;
const clampedValue = clampOpacity(numValue);
setOpacityDisplay(`${clampedValue}`);
props.onOpacityChange?.(clampedValue);
};
const handleAlphaPickerChange = (hexAlpha: string) => {
const { hex, opacity } = fromHexAlpha(hexAlpha);
setColor(hex);
setOpacityDisplay(`${opacity}`);
props.onChange?.(hex);
props.onOpacityChange?.(opacity);
};
const colorField = (
<div className={clx("w-full", showOpacity && "flex-1")}>
<label className="text-xs">{props.label}</label>
<div className={clx("w-full relative mt-1 rounded-xl", !props.readOnly && "cursor-pointer")}>
<input
type="text"
value={color}
onChange={(event) => handleColorChange(event.target.value)}
placeholder="#000000"
readOnly={props.readOnly}
title={!props.readOnly ? "کلیک برای باز کردن انتخابگر رنگ" : undefined}
onClick={togglePicker}
className={`${inputClass} ${props.readOnly ? "" : "cursor-pointer"}`}
/>
<span
aria-hidden
className={clx("absolute top-0 bottom-0 my-auto left-1 flex items-center justify-center min-h-[34px] min-w-[34px] rounded-lg pointer-events-none", props.readOnly && "opacity-50")}
>
{/* <img
src={ColorfilterIcon} src={ColorfilterIcon}
alt='' alt=''
className='size-7 select-none' className='size-7 select-none'
/> */} /> */}
</span> </span>
<span <span
aria-hidden aria-hidden
className={clx( className={clx("absolute top-0 bottom-0 my-auto right-1 flex items-center justify-center min-h-[34px] min-w-[34px] rounded-lg pointer-events-none", props.readOnly && "opacity-50")}
'absolute top-0 bottom-0 my-auto right-1 flex items-center justify-center min-h-[34px] min-w-[34px] rounded-lg pointer-events-none', >
props.readOnly && 'opacity-50' <span className="block w-5 h-5 rounded-full border border-border shadow-sm" style={{ backgroundColor: color }} />
)} </span>
>
<span
className='block w-5 h-5 rounded-full border border-border shadow-sm'
style={{ backgroundColor: color }}
/>
</span>
<input {isOpen && !props.readOnly && (
ref={colorInputRef} <div className="absolute right-0 z-50 mt-2 w-full rounded-xl border border-border bg-white p-3 shadow-lg space-y-3">
type='color' <div className="space-y-1">
value={color} {showOpacity ? (
onChange={handleColorChange} <HexAlphaColorPicker color={toHexAlpha(color, Number(opacityDisplay) || 100)} onChange={handleAlphaPickerChange} />
className='absolute opacity-0 pointer-events-none w-0 h-0' ) : (
/> <HexColorPicker color={color} onChange={handleColorChange} />
)}
</div> </div>
<div className="flex items-center gap-2">
{props.error_text && ( <HexColorInput color={color} onChange={handleColorChange} prefixed className="h-9 max-w-[115px] flex-1 rounded-lg border border-border px-2 text-xs" />
<Error errorText={props.error_text} /> {showOpacity && (
<div className="flex-1">
<input
type="number"
min={0}
max={100}
value={opacityDisplay}
onChange={(e) => handleOpacityChange(e.target.value)}
onBlur={(e) => handleOpacityBlur(e.target.value)}
className="h-9 w-full rounded-lg border border-border px-2 text-xs"
title={props.opacityLabel ?? "شفافیت"}
/>
</div>
)}
</div>
{showOpacity && (
<div className="text-xs text-gray-500">
{props.opacityLabel ?? "شفافیت"}: {opacityDisplay}%
</div>
)} )}
</div> </div>
) )}
} </div>
</div>
);
export default ColorPicker return (
<div ref={wrapperRef} className={clx("w-full", props.className)}>
{colorField}
{props.error_text && <Error errorText={props.error_text} />}
</div>
);
};
export default ColorPicker;
+2 -1
View File
@@ -2,6 +2,7 @@ import React from "react";
import { Group, Rect, Text } from "react-konva"; import { Group, Rect, Text } from "react-konva";
import Konva from "konva"; import Konva from "konva";
import type { EditorObject } from "@/pages/editor/store/editorStore"; import type { EditorObject } from "@/pages/editor/store/editorStore";
import { getColorWithOpacity } from "@/pages/editor/utils/colorOpacity";
type EditorTableProps = { type EditorTableProps = {
obj: EditorObject; obj: EditorObject;
@@ -80,7 +81,7 @@ const EditorTable = ({ obj, selectedCellId, onCellClick, onCellDblClick, onDragE
y={y} y={y}
width={cellWidth} width={cellWidth}
height={cellHeight} height={cellHeight}
fill={cell.background} fill={getColorWithOpacity(cell.background, cell.backgroundOpacity ?? 100)}
stroke={isCellSelected ? "#3b82f6" : stroke || "#808080"} stroke={isCellSelected ? "#3b82f6" : stroke || "#808080"}
strokeWidth={isCellSelected ? 3 : strokeWidth || 1} strokeWidth={isCellSelected ? 3 : strokeWidth || 1}
cornerRadius={isTopLeft ? [8, 0, 0, 0] : isTopRight ? [0, 8, 0, 0] : 0} cornerRadius={isTopLeft ? [8, 0, 0, 0] : isTopRight ? [0, 8, 0, 0] : 0}
+15 -1
View File
@@ -14,6 +14,7 @@ import { useCustomShapeDrawing } from "./canvas/useCustomShapeDrawing";
import { usePencilDrawing } from "./canvas/usePencilDrawing"; import { usePencilDrawing } from "./canvas/usePencilDrawing";
import ObjectsLayer from "./canvas/ObjectsLayer"; import ObjectsLayer from "./canvas/ObjectsLayer";
import GuidesLayer from "./canvas/GuidesLayer"; import GuidesLayer from "./canvas/GuidesLayer";
import BlurBackdropLayer from "./canvas/BlurBackdropLayer";
import CustomShapeDrawingLayer from "./canvas/CustomShapeDrawingLayer"; import CustomShapeDrawingLayer from "./canvas/CustomShapeDrawingLayer";
import PencilDrawingLayer from "./canvas/PencilDrawingLayer"; import PencilDrawingLayer from "./canvas/PencilDrawingLayer";
import { import {
@@ -27,6 +28,7 @@ import ZoomControls from "./ZoomControls";
import EditorCanvasToolbar from "./EditorCanvasToolbar"; import EditorCanvasToolbar from "./EditorCanvasToolbar";
import { createScopedId } from "../store/editorStore.helpers"; import { createScopedId } from "../store/editorStore.helpers";
import { getKonvaGradientProps } from "../utils/gradient"; import { getKonvaGradientProps } from "../utils/gradient";
import { getColorWithOpacity } from "../utils/colorOpacity";
import { STICKER_DRAG_MIME } from "../types/IconTypes"; import { STICKER_DRAG_MIME } from "../types/IconTypes";
import { GALLERY_DRAG_MIME } from "../types/GalleryTypes"; import { GALLERY_DRAG_MIME } from "../types/GalleryTypes";
import { import {
@@ -72,7 +74,12 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => {
const currentPage = pages.find((page) => page.id === currentPageId); const currentPage = pages.find((page) => page.id === currentPageId);
const bgColor = const bgColor =
currentPage?.backgroundType === 'color' || currentPage?.backgroundType === 'gradient' currentPage?.backgroundType === 'color'
? getColorWithOpacity(
currentPage.backgroundColor,
currentPage.backgroundOpacity ?? 100,
)
: currentPage?.backgroundType === 'gradient'
? currentPage.backgroundColor ? currentPage.backgroundColor
: '#ffffff'; : '#ffffff';
const bgGradient = currentPage?.backgroundGradient; const bgGradient = currentPage?.backgroundGradient;
@@ -621,6 +628,13 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => {
<CustomShapeDrawingLayer drawingState={drawingState} /> <CustomShapeDrawingLayer drawingState={drawingState} />
<PencilDrawingLayer drawingState={pencilDrawingState} /> <PencilDrawingLayer drawingState={pencilDrawingState} />
</Stage> </Stage>
<BlurBackdropLayer
objects={objects}
stageWidth={stageSize.width}
stageHeight={stageSize.height}
scale={finalScale}
stageRef={stageRef}
/>
</div> </div>
</div> </div>
</div> </div>
+15 -17
View File
@@ -1,10 +1,9 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useState } from 'react'
import { clx } from '@/helpers/utils' import { clx } from '@/helpers/utils'
import { useEditorStore } from '../store/editorStore' import { useEditorStore } from '../store/editorStore'
import type { DisplayStyle, BackgroundType } from '../store/editorStore' import type { DisplayStyle, BackgroundType } from '../store/editorStore'
import UploadBoxDraggble from '@/components/UploadBoxDraggble' import UploadBoxDraggble from '@/components/UploadBoxDraggble'
import { useSingleUpload } from '@/pages/uploader/hooks/useUploaderData' import { useSingleUpload } from '@/pages/uploader/hooks/useUploaderData'
import ColorsImage from '@/assets/images/colors.png'
import ColorPicker from '@/components/ColorPicker' import ColorPicker from '@/components/ColorPicker'
import Select from '@/components/Select' import Select from '@/components/Select'
import { toCssLinearGradient } from '../utils/gradient' import { toCssLinearGradient } from '../utils/gradient'
@@ -39,11 +38,11 @@ const SettingsPanel = () => {
updateDocumentSettings, updateDocumentSettings,
updateCurrentPageBackground, updateCurrentPageBackground,
} = useEditorStore() } = useEditorStore()
const colorInputRef = useRef<HTMLInputElement>(null)
const { mutateAsync: uploadFile, isPending: mediaLoading } = useSingleUpload() const { mutateAsync: uploadFile, isPending: mediaLoading } = useSingleUpload()
const currentPage = pages.find((page) => page.id === currentPageId) const currentPage = pages.find((page) => page.id === currentPageId)
const backgroundType = currentPage?.backgroundType ?? 'color' const backgroundType = currentPage?.backgroundType ?? 'color'
const backgroundColor = currentPage?.backgroundColor ?? '#ffffff' const backgroundColor = currentPage?.backgroundColor ?? '#ffffff'
const backgroundOpacity = currentPage?.backgroundOpacity ?? 100
const backgroundGradient = currentPage?.backgroundGradient ?? { const backgroundGradient = currentPage?.backgroundGradient ?? {
from: '#ffffff', from: '#ffffff',
to: '#e2e8f0', to: '#e2e8f0',
@@ -234,30 +233,29 @@ const SettingsPanel = () => {
/> />
))} ))}
{/* Color Wheel */} <div className="w-full mt-2">
<button <ColorPicker
onClick={() => colorInputRef.current?.click()} label="رنگ دلخواه"
className="w-9 h-9 rounded-xl border-2 border-transparent hover:scale-105 transition-all overflow-hidden relative"
title="رنگ دلخواه"
>
<img src={ColorsImage} alt="انتخاب رنگ" className="w-full h-full object-cover" />
<input
ref={colorInputRef}
type="color"
value={backgroundColor} value={backgroundColor}
onChange={(e) => updateCurrentPageBackground({ backgroundColor: e.target.value })} opacity={backgroundOpacity}
className="absolute inset-0 opacity-0 w-full h-full cursor-pointer" onChange={(value) =>
updateCurrentPageBackground({ backgroundColor: value })
}
onOpacityChange={(value) =>
updateCurrentPageBackground({ backgroundOpacity: value })
}
/> />
</button> </div>
</div> </div>
{/* نمایش رنگ انتخاب‌شده */} {/* نمایش رنگ انتخاب‌شده */}
<div className="mt-3 flex items-center gap-2"> <div className="mt-3 flex items-center gap-2">
<div <div
className="w-6 h-6 rounded-lg border border-border" className="w-6 h-6 rounded-lg border border-border"
style={{ backgroundColor }} style={{ backgroundColor, opacity: backgroundOpacity / 100 }}
/> />
<span className="text-xs text-gray-500 font-mono">{backgroundColor}</span> <span className="text-xs text-gray-500 font-mono">{backgroundColor}</span>
<span className="text-xs text-gray-400">{backgroundOpacity}%</span>
</div> </div>
</div> </div>
)} )}
@@ -0,0 +1,131 @@
import { useEffect, useMemo, useState } from "react";
import type Konva from "konva";
import type { EditorObject } from "../../store/editorStore";
import {
buildBlurOverlayStyle,
buildBlurStackEntries,
isBlurBackdropObject,
} from "../../utils/blurOverlayLayout";
import OcclusionObjectsStage from "./OcclusionObjectsStage";
type BlurBackdropLayerProps = {
objects: EditorObject[];
stageWidth: number;
stageHeight: number;
/** Stage scale applied to Konva (editor internal scale + zoom). */
scale: number;
stageRef: React.RefObject<Konva.Stage | null>;
};
const BlurBackdropLayer = ({ objects, stageWidth, stageHeight, scale, stageRef }: BlurBackdropLayerProps) => {
const [dragRevision, setDragRevision] = useState(0);
const [draggedBlurObjectId, setDraggedBlurObjectId] = useState<string | null>(null);
useEffect(() => {
const stage = stageRef.current;
if (!stage) return;
let raf = 0;
const bump = () => setDragRevision((v) => v + 1);
const scheduleBump = () => {
if (raf) return;
raf = window.requestAnimationFrame(() => {
raf = 0;
bump();
});
};
const resolveBlurObjectId = (nodeId: string): string | null => {
if (!nodeId) return null;
const direct = objects.find((obj) => obj.id === nodeId && isBlurBackdropObject(obj));
if (direct) return direct.id;
if (nodeId.startsWith("masked-")) {
const maskedId = nodeId.slice("masked-".length);
const masked = objects.find((obj) => obj.id === maskedId && isBlurBackdropObject(obj));
if (masked) return masked.id;
}
return null;
};
const onDragStart = (event: Konva.KonvaEventObject<DragEvent>) => {
const target = event.target;
const id = typeof target.id === "function" ? target.id() : "";
const blurObjectId = resolveBlurObjectId(id);
if (blurObjectId) {
setDraggedBlurObjectId(blurObjectId);
}
scheduleBump();
};
const onDragMove = () => scheduleBump();
const onDragEnd = () => {
setDraggedBlurObjectId(null);
scheduleBump();
};
stage.on("dragstart", onDragStart);
stage.on("dragmove", onDragMove);
stage.on("dragend", onDragEnd);
return () => {
stage.off("dragstart", onDragStart);
stage.off("dragmove", onDragMove);
stage.off("dragend", onDragEnd);
if (raf) window.cancelAnimationFrame(raf);
};
}, [objects, stageRef]);
const stackEntries = useMemo(() => buildBlurStackEntries(objects), [objects]);
const livePositions = useMemo(() => {
if (!draggedBlurObjectId) return new Map<string, { x: number; y: number }>();
const stage = stageRef.current;
if (!stage) return new Map<string, { x: number; y: number }>();
const node =
stage.findOne(`#${draggedBlurObjectId}`) ??
stage.findOne(`#masked-${draggedBlurObjectId}`);
if (node && "getAbsolutePosition" in node) {
return new Map([[draggedBlurObjectId, node.getAbsolutePosition()]]);
}
return new Map<string, { x: number; y: number }>();
}, [draggedBlurObjectId, dragRevision, stageRef]);
if (stackEntries.length === 0) return null;
return (
<div
style={{
position: "absolute",
inset: 0,
width: stageWidth * scale,
height: stageHeight * scale,
pointerEvents: "none",
}}
>
{stackEntries.map((entry) => {
const livePosition = livePositions.get(entry.blurObject.id) ?? null;
const style = buildBlurOverlayStyle(entry.blurObject, scale, livePosition);
return (
<div key={entry.key} style={{ position: "absolute", inset: 0, pointerEvents: "none" }}>
<div style={style} />
<OcclusionObjectsStage
objects={entry.occlusionObjects}
allObjects={objects}
stageWidth={stageWidth}
stageHeight={stageHeight}
scale={scale}
/>
</div>
);
})}
</div>
);
};
export default BlurBackdropLayer;
@@ -0,0 +1,76 @@
import { Stage, Layer, Group } from "react-konva";
import ObjectRenderer from "./ObjectRenderer";
import type { EditorObject } from "../../store/editorStore";
type OcclusionObjectsStageProps = {
objects: EditorObject[];
allObjects: EditorObject[];
stageWidth: number;
stageHeight: number;
scale: number;
};
/** Re-renders objects above a blur overlay so they stay crisp (display-only). */
const OcclusionObjectsStage = ({
objects,
allObjects,
stageWidth,
stageHeight,
scale,
}: OcclusionObjectsStageProps) => {
if (objects.length === 0) return null;
const renderObject = (obj: EditorObject) => (
<ObjectRenderer
key={`occlusion-${obj.id}`}
obj={obj}
isSelected={false}
transformerRef={{ current: null }}
layerRef={{ current: null }}
onSelect={() => {}}
onUpdate={() => {}}
draggable={false}
allObjects={allObjects}
stageWidth={stageWidth}
stageHeight={stageHeight}
/>
);
return (
<Stage
width={stageWidth * scale}
height={stageHeight * scale}
scaleX={scale}
scaleY={scale}
listening={false}
style={{
position: "absolute",
inset: 0,
pointerEvents: "none",
}}
>
<Layer listening={false}>
{objects
.filter((obj) => obj.type === "group")
.map(renderObject)}
{objects
.filter((obj) => !obj.groupId && obj.type !== "group")
.map(renderObject)}
{objects
.filter((obj) => obj.groupId && obj.type !== "group")
.map((obj) => {
const groupObject = objects.find((o) => o.id === obj.groupId);
if (!groupObject) return null;
return (
<Group key={`occlusion-group-member-${obj.id}`} listening={false}>
{renderObject(obj)}
</Group>
);
})}
</Layer>
</Stage>
);
};
export default OcclusionObjectsStage;
@@ -8,6 +8,10 @@ interface TransformerControlsProps {
isGroupedWithMask: boolean; isGroupedWithMask: boolean;
} }
const SELECTION_BLUE = "#93c5fd";
const ANCHOR_SIZE = 5;
const ROTATE_ANCHOR_OFFSET = 22;
const TransformerControls = ({ transformerRef, selectedObject, isGroupedWithMask }: TransformerControlsProps) => { const TransformerControls = ({ transformerRef, selectedObject, isGroupedWithMask }: TransformerControlsProps) => {
if (!selectedObject) return null; if (!selectedObject) return null;
@@ -43,12 +47,18 @@ const TransformerControls = ({ transformerRef, selectedObject, isGroupedWithMask
}} }}
ignoreStroke={isText} ignoreStroke={isText}
perfectDrawEnabled={false} perfectDrawEnabled={false}
anchorFill="#3b82f6" anchorFill={SELECTION_BLUE}
anchorStroke="#ffffff" anchorStroke="#ffffff"
borderStroke="#3b82f6" borderStroke={SELECTION_BLUE}
borderStrokeWidth={1} borderStrokeWidth={1}
padding={-2} padding={-2}
anchorSize={8} anchorSize={ANCHOR_SIZE}
rotateAnchorOffset={ROTATE_ANCHOR_OFFSET}
anchorStyleFunc={(anchor) => {
if (anchor.hasName("rotater")) {
anchor.cornerRadius(ANCHOR_SIZE / 2);
}
}}
rotateEnabled={!isGroup && !isGroupedWithMask} rotateEnabled={!isGroup && !isGroupedWithMask}
enabledAnchors={enabledAnchors} enabledAnchors={enabledAnchors}
/> />
@@ -67,6 +67,7 @@ const buildPencilObject = (
points: DrawPoint[], points: DrawPoint[],
stroke: string, stroke: string,
strokeWidth: number, strokeWidth: number,
opacity: number,
): EditorObject => { ): EditorObject => {
const xs = points.map((p) => p.x); const xs = points.map((p) => p.x);
const ys = points.map((p) => p.y); const ys = points.map((p) => p.y);
@@ -89,7 +90,7 @@ const buildPencilObject = (
stroke, stroke,
strokeWidth, strokeWidth,
tension: DEFAULT_TENSION, tension: DEFAULT_TENSION,
opacity: 100, opacity,
rotation: 0, rotation: 0,
}; };
}; };
@@ -100,6 +101,7 @@ export const usePencilDrawing = (stageRef: React.RefObject<Konva.Stage | null>)
const pointerIdRef = useRef<number | null>(null); const pointerIdRef = useRef<number | null>(null);
const strokeRef = useRef("#000000"); const strokeRef = useRef("#000000");
const strokeWidthRef = useRef(3); const strokeWidthRef = useRef(3);
const opacityRef = useRef(100);
const [drawingState, setDrawingState] = useState<PencilDrawingState>(INITIAL_STATE); const [drawingState, setDrawingState] = useState<PencilDrawingState>(INITIAL_STATE);
@@ -141,7 +143,12 @@ export const usePencilDrawing = (stageRef: React.RefObject<Konva.Stage | null>)
return; return;
} }
const obj = buildPencilObject(points, strokeRef.current, strokeWidthRef.current); const obj = buildPencilObject(
points,
strokeRef.current,
strokeWidthRef.current,
opacityRef.current,
);
useEditorStore.getState().commitObjectHistoryBeforeChange(); useEditorStore.getState().commitObjectHistoryBeforeChange();
addObject(obj); addObject(obj);
reset(); reset();
@@ -165,6 +172,7 @@ export const usePencilDrawing = (stageRef: React.RefObject<Konva.Stage | null>)
(pos: DrawPoint, pointerId: number) => { (pos: DrawPoint, pointerId: number) => {
strokeRef.current = defaults.stroke; strokeRef.current = defaults.stroke;
strokeWidthRef.current = defaults.strokeWidth; strokeWidthRef.current = defaults.strokeWidth;
opacityRef.current = defaults.opacity;
isDrawingRef.current = true; isDrawingRef.current = true;
pointerIdRef.current = pointerId; pointerIdRef.current = pointerId;
pointsRef.current = [pos]; pointsRef.current = [pos];
@@ -22,6 +22,8 @@ const PencilInstruction: FC = () => {
<ColorPicker <ColorPicker
label="رنگ قلم" label="رنگ قلم"
value={defaults.stroke} value={defaults.stroke}
opacity={defaults.opacity}
onOpacityChange={(value) => updateDefaults({ opacity: value })}
onChange={(value) => updateDefaults({ stroke: value })} onChange={(value) => updateDefaults({ stroke: value })}
/> />
<Input <Input
@@ -106,13 +106,6 @@ const TextInstruction: FC = () => {
} }
}; };
// State محلی برای نمایش شفافیت
const [opacityDisplay, setOpacityDisplay] = useState<string>(`${formState.opacity}`);
useEffect(() => {
setOpacityDisplay(`${formState.opacity}`);
}, [formState.opacity]);
return ( return (
<div> <div>
@@ -164,41 +157,14 @@ const TextInstruction: FC = () => {
/> />
</div> </div>
<div className="mt-4 flex flex-col gap-2"> <div className="mt-4">
<div className="flex gap-3"> <ColorPicker
<ColorPicker label="رنگ"
label="رنگ" value={formState.fill}
className="flex-1" opacity={formState.opacity}
value={formState.fill} onOpacityChange={(value) => handleChange("opacity", value)}
onChange={(value) => handleChange("fill", value)} onChange={(value) => handleChange("fill", value)}
/> />
<div className="w-28">
<Input
type="number"
label="شفافیت"
className="px-3"
value={opacityDisplay}
onChange={(e) => {
const value = e.target.value;
setOpacityDisplay(value);
// اعمال تغییرات به صورت لایو اگر مقدار معتبر است
const numValue = parseInt(value);
if (!isNaN(numValue) && numValue >= 0 && numValue <= 100) {
handleChange("opacity", numValue);
}
}}
onBlur={(e) => {
const numValue = parseInt(e.target.value) || 0;
const clampedValue = Math.min(100, Math.max(0, numValue));
setOpacityDisplay(`${clampedValue}`);
handleChange("opacity", clampedValue);
}}
min={0}
max={100}
/>
</div>
</div>
</div> </div>
{/* <div className="mt-4 flex gap-2"> {/* <div className="mt-4 flex gap-2">
@@ -22,6 +22,7 @@ const CustomShapeSettings = ({ selectedObject, onUpdate }: CustomShapeSettingsPr
}; };
const stroke = selectedObject.stroke ?? "#1e40af"; const stroke = selectedObject.stroke ?? "#1e40af";
const strokeWidth = selectedObject.strokeWidth ?? 2; const strokeWidth = selectedObject.strokeWidth ?? 2;
const opacity = selectedObject.opacity ?? 100;
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -29,6 +30,8 @@ const CustomShapeSettings = ({ selectedObject, onUpdate }: CustomShapeSettingsPr
label="رنگ پر" label="رنگ پر"
value={fill} value={fill}
readOnly={fillType === "gradient"} readOnly={fillType === "gradient"}
opacity={opacity}
onOpacityChange={(value) => onUpdate(selectedObject.id, { opacity: value })}
onChange={(value) => onUpdate(selectedObject.id, { fill: value })} onChange={(value) => onUpdate(selectedObject.id, { fill: value })}
/> />
@@ -15,6 +15,7 @@ const GridSettings = ({ selectedObject }: GridSettingsProps) => {
removeRow, removeRow,
removeColumn, removeColumn,
changeCellBackground, changeCellBackground,
changeCellBackgroundOpacity,
selectedCellId, selectedCellId,
} = useEditorStore(); } = useEditorStore();
@@ -50,6 +51,12 @@ const GridSettings = ({ selectedObject }: GridSettingsProps) => {
} }
}; };
const handleCellBackgroundOpacityChange = (opacity: number) => {
if (selectedCellId && tableDataObj) {
changeCellBackgroundOpacity(selectedObject.id, selectedCellId, opacity);
}
};
const selectedCell = selectedCellId && tableDataObj ? tableDataObj.cells[selectedCellId] : null; const selectedCell = selectedCellId && tableDataObj ? tableDataObj.cells[selectedCellId] : null;
return ( return (
@@ -111,6 +118,8 @@ const GridSettings = ({ selectedObject }: GridSettingsProps) => {
<ColorPicker <ColorPicker
label="رنگ پس‌زمینه سلول" label="رنگ پس‌زمینه سلول"
value={selectedCell.background} value={selectedCell.background}
opacity={selectedCell.backgroundOpacity ?? 100}
onOpacityChange={handleCellBackgroundOpacityChange}
onChange={handleCellBackgroundChange} onChange={handleCellBackgroundChange}
/> />
</div> </div>
@@ -13,6 +13,10 @@ const LineSettings = ({ selectedObject, onUpdate }: LineSettingsProps) => {
<ColorPicker <ColorPicker
label="رنگ خط" label="رنگ خط"
value={selectedObject.stroke || "#000000"} value={selectedObject.stroke || "#000000"}
opacity={selectedObject.opacity ?? 100}
onOpacityChange={(value) =>
onUpdate(selectedObject.id, { opacity: value })
}
onChange={(value) => onUpdate(selectedObject.id, { stroke: value })} onChange={(value) => onUpdate(selectedObject.id, { stroke: value })}
/> />
<Input <Input
@@ -51,6 +51,10 @@ const LinkSettings = ({ selectedObject, onUpdate }: LinkSettingsProps) => {
<ColorPicker <ColorPicker
label="رنگ متن" label="رنگ متن"
value={selectedObject.fill || "#0000ff"} value={selectedObject.fill || "#0000ff"}
opacity={selectedObject.opacity ?? 100}
onOpacityChange={(value) =>
onUpdate(selectedObject.id, { opacity: value })
}
onChange={(value) => onUpdate(selectedObject.id, { fill: value })} onChange={(value) => onUpdate(selectedObject.id, { fill: value })}
/> />
</> </>
@@ -10,6 +10,7 @@ type ShapeSettingsProps = {
const ShapeSettings = ({ selectedObject, onUpdate }: ShapeSettingsProps) => { const ShapeSettings = ({ selectedObject, onUpdate }: ShapeSettingsProps) => {
const baseWidth = selectedObject.width ?? 100; const baseWidth = selectedObject.width ?? 100;
const baseHeight = selectedObject.height ?? 100; const baseHeight = selectedObject.height ?? 100;
const baseOpacity = selectedObject.opacity ?? 100;
const baseFill = selectedObject.fill ?? "#3b82f6"; const baseFill = selectedObject.fill ?? "#3b82f6";
const fillType = selectedObject.fillType ?? "solid"; const fillType = selectedObject.fillType ?? "solid";
const gradient = selectedObject.gradient ?? { const gradient = selectedObject.gradient ?? {
@@ -50,6 +51,12 @@ const ShapeSettings = ({ selectedObject, onUpdate }: ShapeSettingsProps) => {
label="رنگ پس‌زمینه" label="رنگ پس‌زمینه"
value={baseFill} value={baseFill}
readOnly={fillType === "gradient"} readOnly={fillType === "gradient"}
opacity={baseOpacity}
onOpacityChange={(value) =>
onUpdate(selectedObject.id, {
opacity: value,
})
}
onChange={(value) => onChange={(value) =>
onUpdate(selectedObject.id, { onUpdate(selectedObject.id, {
fill: value, fill: value,
@@ -102,6 +102,10 @@ const TextSettings = ({ selectedObject, onUpdate }: TextSettingsProps) => {
<ColorPicker <ColorPicker
label="رنگ متن" label="رنگ متن"
value={selectedObject.fill || "#000000"} value={selectedObject.fill || "#000000"}
opacity={selectedObject.opacity ?? 100}
onOpacityChange={(value) =>
onUpdate(selectedObject.id, { opacity: value })
}
onChange={(value) => onUpdate(selectedObject.id, { fill: value })} onChange={(value) => onUpdate(selectedObject.id, { fill: value })}
/> />
</> </>
@@ -1,47 +1,48 @@
import type { EditorObject } from "../../../store/editorStore";
import Input from "@/components/Input"; import Input from "@/components/Input";
import type { EditorObject } from "../../../store/editorStore";
type TransformSettingsProps = { type TransformSettingsProps = {
selectedObject: EditorObject; selectedObject: EditorObject;
onUpdate: (id: string, updates: Partial<EditorObject>) => void; onUpdate: (id: string, updates: Partial<EditorObject>) => void;
}; };
const TransformSettings = ({ selectedObject, onUpdate }: TransformSettingsProps) => { const TransformSettings = ({ selectedObject, onUpdate }: TransformSettingsProps) => {
return ( return (
<div className="pt-4 flex flex-col gap-4 border-t border-border"> <div className="pt-4 flex flex-col gap-4 border-t border-border">
<Input <div className="flex gap-4">
label="موقعیت X" <Input
type="number" label="موقعیت X"
value={Math.round(selectedObject.x)} type="number"
onChange={(e) => value={Math.round(selectedObject.x)}
onUpdate(selectedObject.id, { onChange={(e) =>
x: parseInt(e.target.value) || 0, onUpdate(selectedObject.id, {
}) x: parseInt(e.target.value) || 0,
} })
/> }
<Input />
label="موقعیت Y" <Input
type="number" label="موقعیت Y"
value={Math.round(selectedObject.y)} type="number"
onChange={(e) => value={Math.round(selectedObject.y)}
onUpdate(selectedObject.id, { onChange={(e) =>
y: parseInt(e.target.value) || 0, onUpdate(selectedObject.id, {
}) y: parseInt(e.target.value) || 0,
} })
/> }
<Input />
label="چرخش (درجه)" </div>
type="number" <Input
value={selectedObject.rotation || 0} label="چرخش (درجه)"
onChange={(e) => type="number"
onUpdate(selectedObject.id, { value={selectedObject.rotation || 0}
rotation: parseInt(e.target.value) || 0, onChange={(e) =>
}) onUpdate(selectedObject.id, {
} rotation: parseInt(e.target.value) || 0,
/> })
</div> }
); />
</div>
);
}; };
export default TransformSettings; export default TransformSettings;
@@ -3,7 +3,8 @@ import { Star } from "react-konva";
import Konva from "konva"; import Konva from "konva";
import type { ShapeProps } from "./types"; import type { ShapeProps } from "./types";
import { getKonvaGradientProps } from "../../utils/gradient"; import { getKonvaGradientProps } from "../../utils/gradient";
import { getKonvaBlurProps, useShapeBlurCache } from "../../utils/shapeBlur"; import { getColorWithOpacity } from "../../utils/colorOpacity";
import { useShapeBlurCache } from "../../utils/shapeBlur";
const AbstractShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapeProps) => { const AbstractShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapeProps) => {
const shapeRef = useRef<Konva.Star>(null); const shapeRef = useRef<Konva.Star>(null);
@@ -19,9 +20,13 @@ const AbstractShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shape
const actualStrokeWidth = obj.strokeWidth ?? 0; const actualStrokeWidth = obj.strokeWidth ?? 0;
const hasStroke = actualStrokeWidth > 0; const hasStroke = actualStrokeWidth > 0;
const blurProps = isMask ? {} : getKonvaBlurProps(obj.blur); const blurRadius = obj.blur ?? 0;
const blurHitFill = blurRadius > 0 ? getColorWithOpacity(obj.fill ?? "#000000", 0.5) : undefined;
// Editor blur is implemented via backdrop-filter (see BlurBackdropLayer).
// Avoid applying Konva blur filters on the element itself.
const blurProps = {};
useShapeBlurCache(shapeRef, isMask ? 0 : obj.blur, [ useShapeBlurCache(shapeRef, 0, [
obj.width, obj.width,
obj.height, obj.height,
obj.fill, obj.fill,
@@ -29,7 +34,6 @@ const AbstractShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shape
obj.gradient, obj.gradient,
obj.stroke, obj.stroke,
actualStrokeWidth, actualStrokeWidth,
obj.blur,
]); ]);
// برای نمایش انتخاب: اگر strokeWidth واقعی > 0 است، از آن استفاده کن، وگرنه stroke را برای انتخاب نمایش بده // برای نمایش انتخاب: اگر strokeWidth واقعی > 0 است، از آن استفاده کن، وگرنه stroke را برای انتخاب نمایش بده
@@ -44,14 +48,14 @@ const AbstractShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shape
const displayStrokeWidth = isSelected const displayStrokeWidth = isSelected
? (hasStroke ? actualStrokeWidth : 3) ? (hasStroke ? actualStrokeWidth : 3)
: (isMask && showGuide ? 2 : actualStrokeWidth); : (isMask && showGuide ? 2 : actualStrokeWidth);
const gradientProps = isMask const gradientProps = isMask || blurRadius > 0
? {} ? {}
: getKonvaGradientProps( : getKonvaGradientProps(
obj.fillType, obj.fillType,
obj.gradient, obj.gradient,
obj.width || 100, obj.width || 100,
obj.height || 100, obj.height || 100,
"centered", "centered",
); );
return ( return (
@@ -64,12 +68,14 @@ const AbstractShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shape
numPoints={5} numPoints={5}
innerRadius={innerRadius} innerRadius={innerRadius}
outerRadius={radius} outerRadius={radius}
fill={isMask ? "transparent" : obj.fill} // When blur is enabled, tint is rendered by BlurBackdropLayer.
fill={isMask ? "transparent" : blurRadius > 0 ? blurHitFill : (obj.fill ?? "#000000")}
{...gradientProps} {...gradientProps}
stroke={displayStroke} stroke={displayStroke}
strokeWidth={displayStrokeWidth} strokeWidth={displayStrokeWidth}
dash={isMask && showGuide ? [10, 5] : undefined} dash={isMask && showGuide ? [10, 5] : undefined}
rotation={obj.rotation || 0} rotation={obj.rotation || 0}
opacity={(obj.opacity ?? 100) / 100}
{...blurProps} {...blurProps}
draggable={draggable} draggable={draggable}
onClick={(e) => { onClick={(e) => {
@@ -42,6 +42,7 @@ const ArrowShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapePro
hitStrokeWidth={20} hitStrokeWidth={20}
perfectDrawEnabled={false} perfectDrawEnabled={false}
rotation={obj.rotation || 0} rotation={obj.rotation || 0}
opacity={(obj.opacity ?? 100) / 100}
draggable={draggable} draggable={draggable}
onClick={(e) => { onClick={(e) => {
if (shapeRef.current) { if (shapeRef.current) {
@@ -3,7 +3,8 @@ import { Circle } from "react-konva";
import Konva from "konva"; import Konva from "konva";
import type { ShapeProps } from "./types"; import type { ShapeProps } from "./types";
import { getKonvaGradientProps } from "../../utils/gradient"; import { getKonvaGradientProps } from "../../utils/gradient";
import { getKonvaBlurProps, useShapeBlurCache } from "../../utils/shapeBlur"; import { getColorWithOpacity } from "../../utils/colorOpacity";
import { useShapeBlurCache } from "../../utils/shapeBlur";
const CircleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapeProps) => { const CircleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapeProps) => {
const shapeRef = useRef<Konva.Circle>(null); const shapeRef = useRef<Konva.Circle>(null);
@@ -15,16 +16,19 @@ const CircleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapePr
const actualStrokeWidth = obj.strokeWidth ?? 0; const actualStrokeWidth = obj.strokeWidth ?? 0;
const hasStroke = actualStrokeWidth > 0; const hasStroke = actualStrokeWidth > 0;
const blurProps = isMask ? {} : getKonvaBlurProps(obj.blur); const blurRadius = obj.blur ?? 0;
const blurHitFill = blurRadius > 0 ? getColorWithOpacity(obj.fill ?? "#000000", 0.5) : undefined;
// Editor blur is implemented via backdrop-filter (see BlurBackdropLayer).
// Avoid applying Konva blur filters on the element itself.
const blurProps = {};
useShapeBlurCache(shapeRef, isMask ? 0 : obj.blur, [ useShapeBlurCache(shapeRef, 0, [
obj.width, obj.width,
obj.fill, obj.fill,
obj.fillType, obj.fillType,
obj.gradient, obj.gradient,
obj.stroke, obj.stroke,
actualStrokeWidth, actualStrokeWidth,
obj.blur,
]); ]);
// برای نمایش انتخاب: اگر strokeWidth واقعی > 0 است، از آن استفاده کن، وگرنه stroke را برای انتخاب نمایش بده // برای نمایش انتخاب: اگر strokeWidth واقعی > 0 است، از آن استفاده کن، وگرنه stroke را برای انتخاب نمایش بده
@@ -40,9 +44,9 @@ const CircleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapePr
? (hasStroke ? actualStrokeWidth : 3) ? (hasStroke ? actualStrokeWidth : 3)
: (isMask && showGuide ? 2 : actualStrokeWidth); : (isMask && showGuide ? 2 : actualStrokeWidth);
const diameter = obj.width || 100; const diameter = obj.width || 100;
const gradientProps = isMask const gradientProps = isMask || blurRadius > 0
? {} ? {}
: getKonvaGradientProps(obj.fillType, obj.gradient, diameter, diameter, "centered"); : getKonvaGradientProps(obj.fillType, obj.gradient, diameter, diameter, "centered");
return ( return (
<Circle <Circle
@@ -52,12 +56,14 @@ const CircleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapePr
x={obj.x} x={obj.x}
y={obj.y} y={obj.y}
radius={(obj.width || 50) / 2} radius={(obj.width || 50) / 2}
fill={isMask ? "transparent" : obj.fill} // When blur is enabled, tint is rendered by BlurBackdropLayer.
fill={isMask ? "transparent" : blurRadius > 0 ? blurHitFill : (obj.fill ?? "#000000")}
{...gradientProps} {...gradientProps}
stroke={displayStroke} stroke={displayStroke}
strokeWidth={displayStrokeWidth} strokeWidth={displayStrokeWidth}
dash={isMask && showGuide ? [10, 5] : undefined} dash={isMask && showGuide ? [10, 5] : undefined}
rotation={obj.rotation || 0} rotation={obj.rotation || 0}
opacity={(obj.opacity ?? 100) / 100}
{...blurProps} {...blurProps}
draggable={draggable} draggable={draggable}
onClick={(e) => { onClick={(e) => {
@@ -26,6 +26,7 @@ const LineShape = ({ obj, onSelect, onUpdate, draggable }: ShapeProps) => {
hitStrokeWidth={20} hitStrokeWidth={20}
perfectDrawEnabled={false} perfectDrawEnabled={false}
rotation={obj.rotation || 0} rotation={obj.rotation || 0}
opacity={(obj.opacity ?? 100) / 100}
draggable={draggable} draggable={draggable}
onClick={(e) => { onClick={(e) => {
if (shapeRef.current) { if (shapeRef.current) {
@@ -35,6 +35,7 @@ const LinkShape = ({
fontStyle={fontWeight} fontStyle={fontWeight}
underline={true} underline={true}
rotation={obj.rotation || 0} rotation={obj.rotation || 0}
opacity={(obj.opacity ?? 100) / 100}
draggable={draggable} draggable={draggable}
onClick={(e) => { onClick={(e) => {
// در حالت editor، لینک‌ها قابل کلیک نیستند // در حالت editor، لینک‌ها قابل کلیک نیستند
@@ -3,7 +3,8 @@ import { Rect } from "react-konva";
import Konva from "konva"; import Konva from "konva";
import type { ShapeProps } from "./types"; import type { ShapeProps } from "./types";
import { getKonvaGradientProps } from "../../utils/gradient"; import { getKonvaGradientProps } from "../../utils/gradient";
import { getKonvaBlurProps, useShapeBlurCache } from "../../utils/shapeBlur"; import { getColorWithOpacity } from "../../utils/colorOpacity";
import { useShapeBlurCache } from "../../utils/shapeBlur";
const RectangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapeProps) => { const RectangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapeProps) => {
const shapeRef = useRef<Konva.Rect>(null); const shapeRef = useRef<Konva.Rect>(null);
@@ -16,9 +17,13 @@ const RectangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shap
const actualStrokeWidth = obj.strokeWidth ?? 0; const actualStrokeWidth = obj.strokeWidth ?? 0;
const hasStroke = actualStrokeWidth > 0; const hasStroke = actualStrokeWidth > 0;
const cornerRadius = Math.max(0, obj.borderRadius ?? 0); const cornerRadius = Math.max(0, obj.borderRadius ?? 0);
const blurProps = isMask ? {} : getKonvaBlurProps(obj.blur); const blurRadius = obj.blur ?? 0;
const blurHitFill = blurRadius > 0 ? getColorWithOpacity(obj.fill ?? "#000000", 0.5) : undefined;
// Editor blur is implemented via backdrop-filter (see BlurBackdropLayer),
// so we intentionally do not apply Konva blur filters here.
const blurProps = {};
useShapeBlurCache(shapeRef, isMask ? 0 : obj.blur, [ useShapeBlurCache(shapeRef, 0, [
obj.width, obj.width,
obj.height, obj.height,
cornerRadius, cornerRadius,
@@ -27,7 +32,6 @@ const RectangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shap
obj.gradient, obj.gradient,
obj.stroke, obj.stroke,
actualStrokeWidth, actualStrokeWidth,
obj.blur,
]); ]);
// برای نمایش انتخاب: اگر strokeWidth واقعی > 0 است، از آن استفاده کن، وگرنه stroke را برای انتخاب نمایش بده // برای نمایش انتخاب: اگر strokeWidth واقعی > 0 است، از آن استفاده کن، وگرنه stroke را برای انتخاب نمایش بده
@@ -42,7 +46,7 @@ const RectangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shap
const displayStrokeWidth = isSelected const displayStrokeWidth = isSelected
? (hasStroke ? actualStrokeWidth : 3) ? (hasStroke ? actualStrokeWidth : 3)
: (isMask && showGuide ? 2 : actualStrokeWidth); : (isMask && showGuide ? 2 : actualStrokeWidth);
const gradientProps = isMask const gradientProps = isMask || blurRadius > 0
? {} ? {}
: getKonvaGradientProps( : getKonvaGradientProps(
obj.fillType, obj.fillType,
@@ -62,12 +66,15 @@ const RectangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shap
width={obj.width || 100} width={obj.width || 100}
height={obj.height || 100} height={obj.height || 100}
cornerRadius={cornerRadius} cornerRadius={cornerRadius}
fill={isMask ? "transparent" : obj.fill} // When blur is enabled, visual tint is rendered by BlurBackdropLayer.
// Use a tiny non-zero alpha so Konva hit-detection still works for dragging.
fill={isMask ? "transparent" : blurRadius > 0 ? blurHitFill : (obj.fill ?? "#000000")}
{...gradientProps} {...gradientProps}
stroke={displayStroke} stroke={displayStroke}
strokeWidth={displayStrokeWidth} strokeWidth={displayStrokeWidth}
dash={isMask && showGuide ? [10, 5] : undefined} dash={isMask && showGuide ? [10, 5] : undefined}
rotation={obj.rotation || 0} rotation={obj.rotation || 0}
opacity={(obj.opacity ?? 100) / 100}
{...blurProps} {...blurProps}
draggable={draggable} draggable={draggable}
onClick={(e) => { onClick={(e) => {
@@ -4,12 +4,16 @@ import Konva from "konva";
import type { TextShapeProps } from "./types"; import type { TextShapeProps } from "./types";
import { getFontFamily } from "@/pages/editor/utils/fontFamily"; import { getFontFamily } from "@/pages/editor/utils/fontFamily";
import { import {
buildCanvasFont,
buildKonvaCanvasFont,
DEFAULT_TEXT_MAX_WIDTH_PX, DEFAULT_TEXT_MAX_WIDTH_PX,
getEffectiveTextWidth, getEffectiveTextWidth,
getKonvaFontStyle,
measureTextBlock, measureTextBlock,
resolveTextMaxWidth, resolveTextMaxWidth,
usesWrappedLayout, usesWrappedLayout,
} from "@/pages/editor/utils/textStyle"; } from "@/pages/editor/utils/textStyle";
import { getColorWithOpacity } from "@/pages/editor/utils/colorOpacity";
const getFontWeight = (fontWeight?: string): string => { const getFontWeight = (fontWeight?: string): string => {
if (!fontWeight) return "normal"; if (!fontWeight) return "normal";
@@ -29,28 +33,6 @@ const getFontWeight = (fontWeight?: string): string => {
return weightMap[fontWeight] || "normal"; return weightMap[fontWeight] || "normal";
}; };
/** Konva.Text فقط `fontStyle` دارد (نه fontWeight جداگانه)؛ مقدار باید normal|bold|italic باشد. */
const getKonvaFontStyle = (fontWeight?: string): string => {
const w = getFontWeight(fontWeight);
if (w === "bold" || w === "bolder") return "bold";
const n = parseInt(w, 10);
if (!Number.isNaN(n) && n >= 600) return "bold";
return "normal";
};
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 = ({ const TextShape = ({
obj, obj,
onSelect, onSelect,
@@ -184,11 +166,9 @@ const TextShape = ({
} }
const specs = [ const specs = [
`${fontSize}px ${resolvedFamily}`, buildCanvasFont(fontSize, obj.fontFamily, obj.fontWeight),
buildKonvaCanvasFont(fontSize, obj.fontFamily, obj.fontWeight),
`${konvaStyle} normal ${fontSize}px ${resolvedFamily}`, `${konvaStyle} normal ${fontSize}px ${resolvedFamily}`,
`bold ${fontSize}px ${resolvedFamily}`,
`300 ${fontSize}px ${resolvedFamily}`,
`200 ${fontSize}px ${resolvedFamily}`,
]; ];
void Promise.all( void Promise.all(
@@ -3,7 +3,8 @@ import { RegularPolygon } from "react-konva";
import Konva from "konva"; import Konva from "konva";
import type { ShapeProps } from "./types"; import type { ShapeProps } from "./types";
import { getKonvaGradientProps } from "../../utils/gradient"; import { getKonvaGradientProps } from "../../utils/gradient";
import { getKonvaBlurProps, useShapeBlurCache } from "../../utils/shapeBlur"; import { getColorWithOpacity } from "../../utils/colorOpacity";
import { useShapeBlurCache } from "../../utils/shapeBlur";
const TriangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapeProps) => { const TriangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapeProps) => {
const shapeRef = useRef<Konva.RegularPolygon>(null); const shapeRef = useRef<Konva.RegularPolygon>(null);
@@ -16,17 +17,20 @@ const TriangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shape
const actualStrokeWidth = obj.strokeWidth ?? 0; const actualStrokeWidth = obj.strokeWidth ?? 0;
const hasStroke = actualStrokeWidth > 0; const hasStroke = actualStrokeWidth > 0;
const blurProps = isMask ? {} : getKonvaBlurProps(obj.blur); const blurRadius = obj.blur ?? 0;
const blurHitFill = blurRadius > 0 ? getColorWithOpacity(obj.fill ?? "#000000", 0.5) : undefined;
// Editor blur is implemented via backdrop-filter (see BlurBackdropLayer).
// Avoid applying Konva blur filters on the element itself.
const blurProps = {};
useShapeBlurCache(shapeRef, isMask ? 0 : obj.blur, [ useShapeBlurCache(shapeRef, 0, [
obj.width, obj.width,
obj.height,
obj.fill, obj.fill,
obj.fillType, obj.fillType,
obj.gradient, obj.gradient,
obj.stroke, obj.stroke,
actualStrokeWidth, actualStrokeWidth,
obj.blur, obj.height,
]); ]);
// برای نمایش انتخاب: اگر strokeWidth واقعی > 0 است، از آن استفاده کن، وگرنه stroke را برای انتخاب نمایش بده // برای نمایش انتخاب: اگر strokeWidth واقعی > 0 است، از آن استفاده کن، وگرنه stroke را برای انتخاب نمایش بده
@@ -41,14 +45,14 @@ const TriangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shape
const displayStrokeWidth = isSelected const displayStrokeWidth = isSelected
? (hasStroke ? actualStrokeWidth : 3) ? (hasStroke ? actualStrokeWidth : 3)
: (isMask && showGuide ? 2 : actualStrokeWidth); : (isMask && showGuide ? 2 : actualStrokeWidth);
const gradientProps = isMask const gradientProps = isMask || blurRadius > 0
? {} ? {}
: getKonvaGradientProps( : getKonvaGradientProps(
obj.fillType, obj.fillType,
obj.gradient, obj.gradient,
obj.width || 100, obj.width || 100,
obj.height || 100, obj.height || 100,
"centered", "centered",
); );
return ( return (
@@ -60,12 +64,14 @@ const TriangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shape
y={obj.y + (obj.height || 100) / 2} y={obj.y + (obj.height || 100) / 2}
sides={3} sides={3}
radius={radius} radius={radius}
fill={isMask ? "transparent" : obj.fill} // When blur is enabled, tint is rendered by BlurBackdropLayer.
fill={isMask ? "transparent" : blurRadius > 0 ? blurHitFill : (obj.fill ?? "#000000")}
{...gradientProps} {...gradientProps}
stroke={displayStroke} stroke={displayStroke}
strokeWidth={displayStrokeWidth} strokeWidth={displayStrokeWidth}
dash={isMask && showGuide ? [10, 5] : undefined} dash={isMask && showGuide ? [10, 5] : undefined}
rotation={obj.rotation || 0} rotation={obj.rotation || 0}
opacity={(obj.opacity ?? 100) / 100}
{...blurProps} {...blurProps}
draggable={draggable} draggable={draggable}
onClick={(e) => { onClick={(e) => {
@@ -44,6 +44,7 @@ export const createInitialCells = (
id: cellId, id: cellId,
text: "", text: "",
background: "#f5f5f5", background: "#f5f5f5",
backgroundOpacity: 100,
width: cellWidth, width: cellWidth,
height: cellHeight, height: cellHeight,
}; };
@@ -59,6 +60,7 @@ export const createInitialPage = (name: string): Page => ({
guides: [], guides: [],
backgroundType: "color", backgroundType: "color",
backgroundColor: "#ffffff", backgroundColor: "#ffffff",
backgroundOpacity: 100,
backgroundGradient: { backgroundGradient: {
from: "#ffffff", from: "#ffffff",
to: "#e2e8f0", to: "#e2e8f0",
+34
View File
@@ -68,6 +68,7 @@ type PageBackgroundSettings = Pick<
Page, Page,
| "backgroundType" | "backgroundType"
| "backgroundColor" | "backgroundColor"
| "backgroundOpacity"
| "backgroundGradient" | "backgroundGradient"
| "backgroundImageUrl" | "backgroundImageUrl"
| "backgroundVideoUrl" | "backgroundVideoUrl"
@@ -127,6 +128,11 @@ type EditorStoreType = {
cellId: string, cellId: string,
color: string, color: string,
) => void; ) => void;
changeCellBackgroundOpacity: (
tableId: string,
cellId: string,
opacity: number,
) => void;
applyTableResize: ( applyTableResize: (
tableId: string, tableId: string,
newWidth: number, newWidth: number,
@@ -186,6 +192,7 @@ const DEFAULT_DOCUMENT_SETTINGS: DocumentSettings = {
smartGuide: true, smartGuide: true,
backgroundType: "color", backgroundType: "color",
backgroundColor: "#ffffff", backgroundColor: "#ffffff",
backgroundOpacity: 100,
backgroundGradient: { backgroundGradient: {
from: "#ffffff", from: "#ffffff",
to: "#e2e8f0", to: "#e2e8f0",
@@ -640,6 +647,7 @@ export const useEditorStore = create<EditorStoreType>((set, get) => {
id: cellId, id: cellId,
text: "", text: "",
background: "#f5f5f5", background: "#f5f5f5",
backgroundOpacity: 100,
width: tableData.cellWidth, width: tableData.cellWidth,
height: tableData.cellHeight, height: tableData.cellHeight,
}; };
@@ -670,6 +678,7 @@ export const useEditorStore = create<EditorStoreType>((set, get) => {
id: cellId, id: cellId,
text: "", text: "",
background: "#f5f5f5", background: "#f5f5f5",
backgroundOpacity: 100,
width: tableData.cellWidth, width: tableData.cellWidth,
height: tableData.cellHeight, height: tableData.cellHeight,
}; };
@@ -776,6 +785,28 @@ export const useEditorStore = create<EditorStoreType>((set, get) => {
}, },
}); });
}, },
changeCellBackgroundOpacity: (tableId, cellId, opacity) => {
get().commitObjectHistoryBeforeChange();
const state = get();
const obj = state.objects.find((o) => o.id === tableId);
if (!obj || !obj.tableData) return;
const { tableData } = obj;
const updatedCells = {
...tableData.cells,
[cellId]: {
...tableData.cells[cellId],
backgroundOpacity: Math.min(100, Math.max(0, opacity)),
},
};
state.updateObject(tableId, {
tableData: {
...tableData,
cells: updatedCells,
},
});
},
applyTableResize: (tableId, newWidth, newHeight) => { applyTableResize: (tableId, newWidth, newHeight) => {
const state = get(); const state = get();
const obj = state.objects.find((o) => o.id === tableId); const obj = state.objects.find((o) => o.id === tableId);
@@ -858,6 +889,7 @@ export const useEditorStore = create<EditorStoreType>((set, get) => {
guides: [...pageToDuplicate.guides], guides: [...pageToDuplicate.guides],
backgroundType: pageToDuplicate.backgroundType, backgroundType: pageToDuplicate.backgroundType,
backgroundColor: pageToDuplicate.backgroundColor, backgroundColor: pageToDuplicate.backgroundColor,
backgroundOpacity: pageToDuplicate.backgroundOpacity ?? 100,
backgroundGradient: pageToDuplicate.backgroundGradient, backgroundGradient: pageToDuplicate.backgroundGradient,
backgroundImageUrl: pageToDuplicate.backgroundImageUrl, backgroundImageUrl: pageToDuplicate.backgroundImageUrl,
backgroundVideoUrl: pageToDuplicate.backgroundVideoUrl, backgroundVideoUrl: pageToDuplicate.backgroundVideoUrl,
@@ -1053,6 +1085,8 @@ export const useEditorStore = create<EditorStoreType>((set, get) => {
page.backgroundType ?? fallbackSettings.backgroundType ?? "color", page.backgroundType ?? fallbackSettings.backgroundType ?? "color",
backgroundColor: backgroundColor:
page.backgroundColor ?? fallbackSettings.backgroundColor ?? "#ffffff", page.backgroundColor ?? fallbackSettings.backgroundColor ?? "#ffffff",
backgroundOpacity:
page.backgroundOpacity ?? fallbackSettings.backgroundOpacity ?? 100,
backgroundGradient: page.backgroundGradient ?? backgroundGradient: page.backgroundGradient ??
fallbackSettings.backgroundGradient ?? { fallbackSettings.backgroundGradient ?? {
from: "#ffffff", from: "#ffffff",
@@ -33,6 +33,8 @@ export type TableCell = {
id: string; id: string;
text: string; text: string;
background: string; background: string;
/** شفافیت پس‌زمینه سلول (۰–۱۰۰) */
backgroundOpacity?: number;
width: number; width: number;
height: number; height: number;
}; };
@@ -121,6 +123,7 @@ export type Page = {
guides: PageGuide[]; guides: PageGuide[];
backgroundType: BackgroundType; backgroundType: BackgroundType;
backgroundColor: string; backgroundColor: string;
backgroundOpacity?: number;
backgroundGradient?: LinearGradient; backgroundGradient?: LinearGradient;
backgroundImageUrl: string; backgroundImageUrl: string;
backgroundVideoUrl: string; backgroundVideoUrl: string;
@@ -137,6 +140,7 @@ export type DocumentSettings = {
smartGuide: boolean; smartGuide: boolean;
backgroundType: BackgroundType; backgroundType: BackgroundType;
backgroundColor: string; backgroundColor: string;
backgroundOpacity?: number;
backgroundGradient?: LinearGradient; backgroundGradient?: LinearGradient;
backgroundImageUrl: string; backgroundImageUrl: string;
backgroundVideoUrl: string; backgroundVideoUrl: string;
@@ -3,6 +3,7 @@ import { create } from "zustand";
export type PencilDefaults = { export type PencilDefaults = {
stroke: string; stroke: string;
strokeWidth: number; strokeWidth: number;
opacity: number;
}; };
type PencilDefaultsStoreType = { type PencilDefaultsStoreType = {
@@ -14,6 +15,7 @@ export const usePencilDefaultsStore = create<PencilDefaultsStoreType>((set) => (
defaults: { defaults: {
stroke: "#000000", stroke: "#000000",
strokeWidth: 3, strokeWidth: 3,
opacity: 100,
}, },
updateDefaults: (updates) => updateDefaults: (updates) =>
set((state) => ({ set((state) => ({
+178
View File
@@ -0,0 +1,178 @@
import type { CSSProperties } from "react";
import type { EditorObject } from "../store/editorStore";
import { getColorWithOpacity } from "./colorOpacity";
import { toCssGradientAngle, type LinearGradient } from "./gradient";
const TRIANGLE_TOP_Y_PERCENT = 6.69873;
const TRIANGLE_BOTTOM_Y_PERCENT = 93.30127;
const BLUR_TINT_OPACITY_FACTOR = 0.25;
export type BlurOverlayGeometry = {
leftPx: number;
topPx: number;
widthPx: number;
heightPx: number;
borderRadiusPx: number;
clipPath?: string;
transformOrigin: CSSProperties["transformOrigin"];
rotation: number;
};
export function isBlurBackdropObject(obj: EditorObject): boolean {
if (obj.visible === false) return false;
if (obj.isMask) return false;
if (obj.type !== "rectangle") return false;
return (obj.blur ?? 0) > 0;
}
/** Logical stage coords → overlay pixel space (matches Konva getAbsolutePosition after stage scale). */
export function computeBlurOverlayGeometry(
obj: EditorObject,
scale: number,
livePosition?: { x: number; y: number } | null,
): BlurOverlayGeometry {
const rotation = obj.rotation ?? 0;
const shapeType = obj.shapeType ?? "square";
const anchorX =
livePosition?.x ??
(shapeType === "triangle" || shapeType === "abstract"
? ((obj.x ?? 0) + (obj.width ?? 100) / 2) * scale
: (obj.x ?? 0) * scale);
const anchorY =
livePosition?.y ??
(shapeType === "triangle" || shapeType === "abstract"
? ((obj.y ?? 0) + (obj.height ?? 100) / 2) * scale
: (obj.y ?? 0) * scale);
if (shapeType === "circle") {
const sizePx = (obj.width ?? 100) * scale;
const radiusPx = sizePx / 2;
return {
leftPx: anchorX - radiusPx,
topPx: anchorY - radiusPx,
widthPx: sizePx,
heightPx: sizePx,
borderRadiusPx: radiusPx,
transformOrigin: "center center",
rotation,
};
}
if (shapeType === "triangle") {
const side = Math.min(obj.width ?? 100, obj.height ?? 100);
const radiusPx = (side / 2) * scale;
return {
leftPx: anchorX - radiusPx,
topPx: anchorY - radiusPx,
widthPx: side * scale,
heightPx: side * scale,
borderRadiusPx: 0,
clipPath: `polygon(50% ${TRIANGLE_TOP_Y_PERCENT}%, 0% ${TRIANGLE_BOTTOM_Y_PERCENT}%, 100% ${TRIANGLE_BOTTOM_Y_PERCENT}%)`,
transformOrigin: "center center",
rotation,
};
}
if (shapeType === "abstract") {
const rawW = obj.width ?? 100;
const rawH = obj.height ?? 100;
const baseRadius = (Math.min(rawW, rawH) / 2) * scale;
const radius = baseRadius * 0.85;
return {
leftPx: anchorX - radius,
topPx: anchorY - radius,
widthPx: radius * 2,
heightPx: radius * 2,
borderRadiusPx: 0,
clipPath:
"polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%)",
transformOrigin: "center center",
rotation,
};
}
return {
leftPx: anchorX,
topPx: anchorY,
widthPx: (obj.width ?? 100) * scale,
heightPx: (obj.height ?? 100) * scale,
borderRadiusPx: Math.max(0, obj.borderRadius ?? 0) * scale,
transformOrigin: "0% 0%",
rotation,
};
}
export function buildBlurOverlayStyle(
obj: EditorObject,
scale: number,
livePosition?: { x: number; y: number } | null,
): CSSProperties {
const blur = obj.blur ?? 0;
const blurPx = blur * scale;
const geometry = computeBlurOverlayGeometry(obj, scale, livePosition);
const objOpacity = obj.opacity ?? 100;
const tintOpacity = Math.max(0, Math.min(100, objOpacity * BLUR_TINT_OPACITY_FACTOR));
const defaultFill = "#3b82f6";
const overlayBackground: CSSProperties =
obj.fillType === "gradient" && obj.gradient
? (() => {
const g = obj.gradient as LinearGradient;
const angle = toCssGradientAngle(g.angle);
const from = getColorWithOpacity(g.from, tintOpacity);
const to = getColorWithOpacity(g.to, tintOpacity);
return {
backgroundImage: `linear-gradient(${angle}deg, ${from} 0%, ${to} 100%)`,
backgroundColor: "transparent",
};
})()
: {
backgroundColor: getColorWithOpacity(obj.fill ?? defaultFill, tintOpacity),
};
return {
position: "absolute",
left: `${geometry.leftPx}px`,
top: `${geometry.topPx}px`,
width: `${geometry.widthPx}px`,
height: `${geometry.heightPx}px`,
pointerEvents: "none",
backdropFilter: `blur(${blurPx}px)`,
WebkitBackdropFilter: `blur(${blurPx}px)`,
...overlayBackground,
overflow:
geometry.clipPath
? "hidden"
: geometry.borderRadiusPx > 0
? "hidden"
: undefined,
borderRadius: geometry.borderRadiusPx > 0 ? `${geometry.borderRadiusPx}px` : undefined,
clipPath: geometry.clipPath,
transform: geometry.rotation ? `rotate(${geometry.rotation}deg)` : undefined,
transformOrigin: geometry.transformOrigin,
};
}
export type BlurStackEntry = {
key: string;
blurObject: EditorObject;
/** Objects rendered above this blur so they stay crisp. */
occlusionObjects: EditorObject[];
};
export function buildBlurStackEntries(objects: EditorObject[]): BlurStackEntry[] {
const entries: BlurStackEntry[] = [];
for (let i = 0; i < objects.length; i++) {
const obj = objects[i];
if (!isBlurBackdropObject(obj)) continue;
entries.push({
key: obj.id,
blurObject: obj,
occlusionObjects: objects.slice(i + 1),
});
}
return entries;
}
+28
View File
@@ -0,0 +1,28 @@
/** Converts a hex color + 0100 opacity to rgba (editor/viewer convention). */
export const getColorWithOpacity = (fill?: string, opacity?: number): string => {
if (!fill) return "#000000";
if (opacity === undefined || opacity >= 100) return fill;
const hex = fill.replace("#", "");
if (!/^[0-9A-Fa-f]{3,8}$/.test(hex)) return fill;
const paddedHex =
hex.length === 3
? hex
.split("")
.map((c) => c + c)
.join("")
: hex.length === 4
? hex
.split("")
.map((c) => c + c)
.join("")
: hex.padEnd(6, "0").slice(0, 6);
const r = parseInt(paddedHex.substring(0, 2), 16);
const g = parseInt(paddedHex.substring(2, 4), 16);
const b = parseInt(paddedHex.substring(4, 6), 16);
const alpha = opacity / 100;
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
};
+14 -8
View File
@@ -11,12 +11,18 @@ export const getCssFontWeight = (fontWeight?: string): number => {
return 400; return 400;
}; };
/** Konva.Text uses `fontStyle: bold` (canvas keyword), not numeric weight. */ /**
export const usesKonvaBoldStyle = (fontWeight?: string): boolean => { * Konva.Text `fontStyle` — supports `normal`, `bold`, or numeric strings like `200`.
if (!fontWeight || fontWeight === "normal") return false; * @see https://konvajs.org/api/Konva.Text.html#fontStyle
if (fontWeight === "bold" || fontWeight === "bolder") return true; */
export const getKonvaFontStyle = (fontWeight?: string): string => {
if (!fontWeight || fontWeight === "normal") return "normal";
if (fontWeight === "bold" || fontWeight === "bolder") return "bold";
const n = parseInt(fontWeight, 10); const n = parseInt(fontWeight, 10);
return !Number.isNaN(n) && n >= 600; if (!Number.isNaN(n)) return n.toString();
return "normal";
}; };
/** Canvas/CSS `font` string shared by editor measurement and viewer layout. */ /** Canvas/CSS `font` string shared by editor measurement and viewer layout. */
@@ -30,15 +36,15 @@ export const buildCanvasFont = (
return `${weight} ${fontSize}px ${family}`; return `${weight} ${fontSize}px ${family}`;
}; };
/** Canvas font string that matches Konva.Text (`fontStyle: bold`). */ /** Canvas font string that matches Konva.Text `_getContextFont()`. */
export const buildKonvaCanvasFont = ( export const buildKonvaCanvasFont = (
fontSize: number, fontSize: number,
fontFamily?: string, fontFamily?: string,
fontWeight?: string, fontWeight?: string,
): string => { ): string => {
const family = getFontFamily(fontFamily); const family = getFontFamily(fontFamily);
const style = usesKonvaBoldStyle(fontWeight) ? "bold" : "normal"; const style = getKonvaFontStyle(fontWeight);
return `${style} ${fontSize}px ${family}`; return `${style} normal ${fontSize}px ${family}`;
}; };
const LINE_BREAK_RE = /[\r\n\u2028\u2029]/; const LINE_BREAK_RE = /[\r\n\u2028\u2029]/;
+46 -9
View File
@@ -11,7 +11,11 @@ import {
resolveTextMaxWidth, resolveTextMaxWidth,
usesWrappedLayout, usesWrappedLayout,
} from '@/pages/editor/utils/textStyle'; } from '@/pages/editor/utils/textStyle';
import { getCssBlurStyle } from '@/pages/editor/utils/shapeBlur'; import {
buildBlurOverlayStyle,
buildBlurStackEntries,
isBlurBackdropObject,
} from '@/pages/editor/utils/blurOverlayLayout';
import { getCssBorderRadius } from '@/pages/editor/utils/borderRadius'; import { getCssBorderRadius } from '@/pages/editor/utils/borderRadius';
import '@/pages/viewer/styles/entranceAnimations.css'; import '@/pages/viewer/styles/entranceAnimations.css';
import { mergeEntranceAnimationStyle } from '@/pages/viewer/utils/entranceAnimationStyle'; import { mergeEntranceAnimationStyle } from '@/pages/viewer/utils/entranceAnimationStyle';
@@ -57,6 +61,7 @@ type BookPageProps = {
onLinkClick?: (linkUrl: string) => void; onLinkClick?: (linkUrl: string) => void;
backgroundType?: 'color' | 'gradient' | 'image' | 'video'; backgroundType?: 'color' | 'gradient' | 'image' | 'video';
backgroundColor?: string; backgroundColor?: string;
backgroundOpacity?: number;
backgroundGradient?: { backgroundGradient?: {
from: string; from: string;
to: string; to: string;
@@ -94,9 +99,10 @@ type BookPageProps = {
* نیاز به forwardRef دارد تا react-pageflip بتواند ref را مدیریت کند * نیاز به forwardRef دارد تا react-pageflip بتواند ref را مدیریت کند
*/ */
const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>( const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
({ page, scale = 1, pageWidth, pageHeight, onLinkClick, backgroundType, backgroundColor, backgroundGradient, backgroundImageUrl, backgroundVideoUrl, entrancePhase = 'idle', disableEntranceAnimations = false, onDeferredEntranceReady, hideMediaLoadingOverlay = false, staticMedia = false, showPaperShadow = true, useHardPage = false, idSuffix = '' }, ref) => { ({ page, scale = 1, pageWidth, pageHeight, onLinkClick, backgroundType, backgroundColor, backgroundOpacity, backgroundGradient, backgroundImageUrl, backgroundVideoUrl, entrancePhase = 'idle', disableEntranceAnimations = false, onDeferredEntranceReady, hideMediaLoadingOverlay = false, staticMedia = false, showPaperShadow = true, useHardPage = false, idSuffix = '' }, ref) => {
const effectiveBackgroundType = backgroundType ?? page.backgroundType ?? 'color'; const effectiveBackgroundType = backgroundType ?? page.backgroundType ?? 'color';
const effectiveBackgroundColor = backgroundColor ?? page.backgroundColor ?? '#ffffff'; const effectiveBackgroundColor = backgroundColor ?? page.backgroundColor ?? '#ffffff';
const effectiveBackgroundOpacity = backgroundOpacity ?? page.backgroundOpacity ?? 100;
const effectiveBackgroundGradient = backgroundGradient ?? page.backgroundGradient; const effectiveBackgroundGradient = backgroundGradient ?? page.backgroundGradient;
const effectiveBackgroundImageUrl = backgroundImageUrl ?? page.backgroundImageUrl ?? ''; const effectiveBackgroundImageUrl = backgroundImageUrl ?? page.backgroundImageUrl ?? '';
const effectiveBackgroundVideoUrl = backgroundVideoUrl ?? page.backgroundVideoUrl ?? ''; const effectiveBackgroundVideoUrl = backgroundVideoUrl ?? page.backgroundVideoUrl ?? '';
@@ -211,11 +217,15 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
return null; return null;
} }
// Blur shapes use backdrop-filter overlays (see blur stack below), not CSS filter on the shape.
if (isBlurBackdropObject(obj)) {
return null;
}
const objectFillStyle: React.CSSProperties = const objectFillStyle: React.CSSProperties =
obj.fillType === 'gradient' && obj.gradient obj.fillType === 'gradient' && obj.gradient
? { backgroundImage: toCssLinearGradient(obj.gradient) } ? { backgroundImage: toCssLinearGradient(obj.gradient) }
: { backgroundColor: obj.fill || 'transparent' }; : { backgroundColor: obj.fill || 'transparent' };
const shapeBlurStyle = getCssBlurStyle(obj.blur, scale);
switch (obj.type) { switch (obj.type) {
case 'text': { case 'text': {
@@ -535,7 +545,6 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
height: `${radius * 2}px`, height: `${radius * 2}px`,
borderRadius: '50%', borderRadius: '50%',
...objectFillStyle, ...objectFillStyle,
...shapeBlurStyle,
border: hasStroke && obj.stroke border: hasStroke && obj.stroke
? `${actualStrokeWidth * scale}px solid ${obj.stroke}` ? `${actualStrokeWidth * scale}px solid ${obj.stroke}`
: 'none', : 'none',
@@ -608,7 +617,6 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
width: `${size}px`, width: `${size}px`,
height: `${size}px`, height: `${size}px`,
opacity: (obj.opacity ?? 100) / 100, opacity: (obj.opacity ?? 100) / 100,
...shapeBlurStyle,
transform: obj.rotation ? `rotate(${obj.rotation}deg)` : undefined, transform: obj.rotation ? `rotate(${obj.rotation}deg)` : undefined,
transformOrigin: 'center center', transformOrigin: 'center center',
zIndex: index, zIndex: index,
@@ -667,7 +675,6 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
height: `${radius * 2}px`, height: `${radius * 2}px`,
clipPath: 'polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%)', clipPath: 'polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%)',
...objectFillStyle, ...objectFillStyle,
...shapeBlurStyle,
border: obj.stroke border: obj.stroke
? `${(obj.strokeWidth || 1) * scale}px solid ${obj.stroke}` ? `${(obj.strokeWidth || 1) * scale}px solid ${obj.stroke}`
: 'none', : 'none',
@@ -690,7 +697,6 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
width: `${(obj.width || 100) * scale}px`, width: `${(obj.width || 100) * scale}px`,
height: `${(obj.height || 100) * scale}px`, height: `${(obj.height || 100) * scale}px`,
...objectFillStyle, ...objectFillStyle,
...shapeBlurStyle,
borderRadius: `${Math.max(0, (obj.borderRadius || 0) * scale)}px`, borderRadius: `${Math.max(0, (obj.borderRadius || 0) * scale)}px`,
border: hasStroke && obj.stroke border: hasStroke && obj.stroke
? `${actualStrokeWidth * scale}px solid ${obj.stroke}` ? `${actualStrokeWidth * scale}px solid ${obj.stroke}`
@@ -950,7 +956,10 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
key={cellId} key={cellId}
style={{ style={{
border: stroke ? `${(strokeWidth || 1) * scale}px solid ${stroke}` : `${1 * scale}px solid #ccc`, border: stroke ? `${(strokeWidth || 1) * scale}px solid ${stroke}` : `${1 * scale}px solid #ccc`,
backgroundColor: cell?.background || '#ffffff', backgroundColor: getColorWithOpacity(
cell?.background || '#ffffff',
cell?.backgroundOpacity,
),
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
@@ -1031,7 +1040,7 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
? { ? {
backgroundImage: toCssLinearGradient(effectiveBackgroundGradient), backgroundImage: toCssLinearGradient(effectiveBackgroundGradient),
} }
: { backgroundColor: effectiveBackgroundColor }; : { backgroundColor: getColorWithOpacity(effectiveBackgroundColor, effectiveBackgroundOpacity) };
const showLoadingOverlay = !hideMediaLoadingOverlay && !isMediaReady; const showLoadingOverlay = !hideMediaLoadingOverlay && !isMediaReady;
@@ -1097,6 +1106,34 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
)} )}
<div style={{ position: 'absolute', inset: 0, zIndex: 1 }}> <div style={{ position: 'absolute', inset: 0, zIndex: 1 }}>
{page.elements.map((element, index) => renderObject(element, index, page.elements))} {page.elements.map((element, index) => renderObject(element, index, page.elements))}
{buildBlurStackEntries(page.elements).map((entry) => {
const blurIndex = page.elements.findIndex((el) => el.id === entry.blurObject.id);
const blurOverlayStyle = buildBlurOverlayStyle(entry.blurObject, scale);
return (
<div
key={`blur-stack-${entry.key}`}
style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}
>
<div
style={withEntrance(
blurOverlayStyle,
entry.blurObject,
blurIndex >= 0 ? blurIndex : 0,
{
transformOrigin: blurOverlayStyle.transformOrigin,
rotationDeg: entry.blurObject.rotation ?? 0,
},
)}
/>
{entry.occlusionObjects.map((obj) => {
const index = page.elements.findIndex((el) => el.id === obj.id);
if (index < 0) return null;
return renderObject(obj, index, page.elements);
})}
</div>
);
})}
</div> </div>
</div> </div>
{showLoadingOverlay && ( {showLoadingOverlay && (
+3 -2
View File
@@ -193,12 +193,13 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
); );
const backgroundType = documentSettings?.backgroundType ?? "color"; const backgroundType = documentSettings?.backgroundType ?? "color";
const backgroundColor = documentSettings?.backgroundColor ?? "#ffffff"; const backgroundColor = documentSettings?.backgroundColor ?? "#ffffff";
const backgroundOpacity = documentSettings?.backgroundOpacity ?? 100;
const backgroundGradient = documentSettings?.backgroundGradient; const backgroundGradient = documentSettings?.backgroundGradient;
const backgroundImageUrl = documentSettings?.backgroundImageUrl ?? ""; const backgroundImageUrl = documentSettings?.backgroundImageUrl ?? "";
const backgroundVideoUrl = documentSettings?.backgroundVideoUrl ?? ""; const backgroundVideoUrl = documentSettings?.backgroundVideoUrl ?? "";
const pageBackgroundDefaults = useMemo( const pageBackgroundDefaults = useMemo(
() => ({ backgroundType, backgroundColor, backgroundGradient, backgroundImageUrl, backgroundVideoUrl }), () => ({ backgroundType, backgroundColor, backgroundOpacity, backgroundGradient, backgroundImageUrl, backgroundVideoUrl }),
[backgroundType, backgroundColor, backgroundGradient, backgroundImageUrl, backgroundVideoUrl], [backgroundType, backgroundColor, backgroundOpacity, backgroundGradient, backgroundImageUrl, backgroundVideoUrl],
); );
useEffect(() => { useEffect(() => {
+1
View File
@@ -7,6 +7,7 @@ export type PageData = {
elements: EditorObject[]; elements: EditorObject[];
backgroundType?: "color" | "gradient" | "image" | "video"; backgroundType?: "color" | "gradient" | "image" | "video";
backgroundColor?: string; backgroundColor?: string;
backgroundOpacity?: number;
backgroundGradient?: { backgroundGradient?: {
from: string; from: string;
to: string; to: string;
@@ -7,6 +7,7 @@ type ViewerDataPage = {
name: string; name: string;
backgroundType?: "color" | "gradient" | "image" | "video"; backgroundType?: "color" | "gradient" | "image" | "video";
backgroundColor?: string; backgroundColor?: string;
backgroundOpacity?: number;
backgroundGradient?: { backgroundGradient?: {
from: string; from: string;
to: string; to: string;
@@ -203,6 +204,7 @@ export function transformViewerDataToPages(data: ViewerData): PageData[] {
elements: objects, elements: objects,
backgroundType: page.backgroundType, backgroundType: page.backgroundType,
backgroundColor: page.backgroundColor, backgroundColor: page.backgroundColor,
backgroundOpacity: page.backgroundOpacity,
backgroundGradient: page.backgroundGradient, backgroundGradient: page.backgroundGradient,
backgroundImageUrl: page.backgroundImageUrl, backgroundImageUrl: page.backgroundImageUrl,
backgroundVideoUrl: page.backgroundVideoUrl, backgroundVideoUrl: page.backgroundVideoUrl,
+2
View File
@@ -3,6 +3,7 @@ import type { PageData } from "@/pages/viewer/types";
export type PageBackgroundDefaults = { export type PageBackgroundDefaults = {
backgroundType?: "color" | "gradient" | "image" | "video"; backgroundType?: "color" | "gradient" | "image" | "video";
backgroundColor?: string; backgroundColor?: string;
backgroundOpacity?: number;
backgroundGradient?: { from: string; to: string; angle: number }; backgroundGradient?: { from: string; to: string; angle: number };
backgroundImageUrl?: string; backgroundImageUrl?: string;
backgroundVideoUrl?: string; backgroundVideoUrl?: string;
@@ -13,6 +14,7 @@ export function resolvePageBackground(page: PageData, defaults: PageBackgroundDe
return { return {
backgroundType: page.backgroundType ?? defaults.backgroundType ?? "color", backgroundType: page.backgroundType ?? defaults.backgroundType ?? "color",
backgroundColor: page.backgroundColor ?? defaults.backgroundColor ?? "#ffffff", backgroundColor: page.backgroundColor ?? defaults.backgroundColor ?? "#ffffff",
backgroundOpacity: page.backgroundOpacity ?? defaults.backgroundOpacity ?? 100,
backgroundGradient: page.backgroundGradient ?? defaults.backgroundGradient ?? { from: "#ffffff", to: "#ffffff", angle: 0 }, backgroundGradient: page.backgroundGradient ?? defaults.backgroundGradient ?? { from: "#ffffff", to: "#ffffff", angle: 0 },
backgroundImageUrl: page.backgroundImageUrl ?? defaults.backgroundImageUrl ?? "", backgroundImageUrl: page.backgroundImageUrl ?? defaults.backgroundImageUrl ?? "",
backgroundVideoUrl: page.backgroundVideoUrl ?? defaults.backgroundVideoUrl ?? "", backgroundVideoUrl: page.backgroundVideoUrl ?? defaults.backgroundVideoUrl ?? "",