Compare commits

...

12 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
hamid zarghami 3c764a1652 spinner to loading page compeletely
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-05 16:26:37 +03:30
hamid zarghami 954ad48486 ankle gredient 2026-07-05 16:18:57 +03:30
hamid zarghami 368cace143 border radius for all shape 2026-07-05 15:52:39 +03:30
51 changed files with 1357 additions and 412 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",
"page-flip": "^2.0.7",
"react": "^19.2.0",
"react-colorful": "^5.7.0",
"react-dom": "^19.2.0",
"react-dropzone": "^14.3.8",
"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"
}
},
"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": {
"version": "2.1.9",
"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",
"page-flip": "^2.0.7",
"react": "^19.2.0",
"react-colorful": "^5.7.0",
"react-dom": "^19.2.0",
"react-dropzone": "^14.3.8",
"react-i18next": "^16.3.0",
+182 -103
View File
@@ -1,127 +1,206 @@
import { type FC, type InputHTMLAttributes, useState, useRef, useEffect } from 'react'
import { clx } from '../helpers/utils'
import Error from './Error'
import { type FC, type InputHTMLAttributes, useEffect, useRef, useState } from "react";
import { HexAlphaColorPicker, HexColorInput, HexColorPicker } from "react-colorful";
import { clx } from "../helpers/utils";
import Error from "./Error";
type Props = {
label?: string
className?: string
error_text?: string
isNotRequired?: boolean
value?: string
onChange?: (value: string) => void
} & Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value'>
label?: string;
className?: string;
error_text?: string;
isNotRequired?: boolean;
value?: string;
onChange?: (value: string) => void;
opacity?: number;
onOpacityChange?: (value: number) => void;
opacityLabel?: string;
} & Omit<InputHTMLAttributes<HTMLInputElement>, "onChange" | "value">;
const ColorPicker: FC<Props> = (props: Props) => {
const [color, setColor] = useState<string>(props.value || '#000000')
const colorInputRef = useRef<HTMLInputElement>(null)
const pickerOpenGuardRef = useRef(false)
const [color, setColor] = useState<string>(props.value || "#000000");
const [opacityDisplay, setOpacityDisplay] = useState<string>(`${props.opacity ?? 100}`);
const [isOpen, setIsOpen] = useState(false);
const wrapperRef = useRef<HTMLDivElement>(null);
const showOpacity = props.onOpacityChange !== undefined;
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',
props.className
)
const clampOpacity = (rawValue: number): number => Math.min(100, Math.max(0, rawValue));
const handleColorChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const newColor = event.target.value
setColor(newColor)
props.onChange?.(newColor)
const normalizeHex = (value: string): string => {
if (!value) return "#000000";
const withHash = value.startsWith("#") ? value : `#${value}`;
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 handleTextChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value
if (/^#[0-9A-Fa-f]{0,6}$/.test(value) || value === '') {
const finalColor = value || '#000000'
setColor(finalColor)
props.onChange?.(finalColor)
}
const longMatch = /^#([0-9A-Fa-f]{6})$/.exec(withHash);
if (longMatch) {
return `#${longMatch[1]}`.toLowerCase();
}
return "#000000";
};
const openNativeColorPicker = () => {
if (props.readOnly) return
if (pickerOpenGuardRef.current) return
pickerOpenGuardRef.current = true
colorInputRef.current?.click()
window.setTimeout(() => {
pickerOpenGuardRef.current = false
}, 400)
const toHexAlpha = (hex: string, opacity: number): string => {
const normalizedHex = normalizeHex(hex);
const alpha = Math.round((clampOpacity(opacity) / 100) * 255)
.toString(16)
.padStart(2, "0");
return `${normalizedHex}${alpha}`;
};
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(() => {
if (props.value !== undefined) {
setColor(props.value)
}
}, [props.value])
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");
return (
<div className='w-full'>
<label className='text-xs'>
{props.label}
</label>
const handleColorChange = (newColor: string) => {
const normalized = normalizeHex(newColor);
setColor(normalized);
props.onChange?.(normalized);
};
<div
className={clx(
'w-full relative mt-1 rounded-xl',
!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`}
/>
const togglePicker = () => {
if (props.readOnly) return;
setIsOpen((prev) => !prev);
};
<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
useEffect(() => {
if (props.value !== undefined) {
setColor(normalizeHex(props.value));
}
}, [props.value]);
useEffect(() => {
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}
alt=''
className='size-7 select-none'
/> */}
</span>
</span>
<span
aria-hidden
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'
)}
>
<span
className='block w-5 h-5 rounded-full border border-border shadow-sm'
style={{ backgroundColor: color }}
/>
</span>
<span
aria-hidden
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")}
>
<span className="block w-5 h-5 rounded-full border border-border shadow-sm" style={{ backgroundColor: color }} />
</span>
<input
ref={colorInputRef}
type='color'
value={color}
onChange={handleColorChange}
className='absolute opacity-0 pointer-events-none w-0 h-0'
/>
{isOpen && !props.readOnly && (
<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">
<div className="space-y-1">
{showOpacity ? (
<HexAlphaColorPicker color={toHexAlpha(color, Number(opacityDisplay) || 100)} onChange={handleAlphaPickerChange} />
) : (
<HexColorPicker color={color} onChange={handleColorChange} />
)}
</div>
{props.error_text && (
<Error errorText={props.error_text} />
<div className="flex items-center gap-2">
<HexColorInput color={color} onChange={handleColorChange} prefixed className="h-9 max-w-[115px] flex-1 rounded-lg border border-border px-2 text-xs" />
{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 Konva from "konva";
import type { EditorObject } from "@/pages/editor/store/editorStore";
import { getColorWithOpacity } from "@/pages/editor/utils/colorOpacity";
type EditorTableProps = {
obj: EditorObject;
@@ -80,7 +81,7 @@ const EditorTable = ({ obj, selectedCellId, onCellClick, onCellDblClick, onDragE
y={y}
width={cellWidth}
height={cellHeight}
fill={cell.background}
fill={getColorWithOpacity(cell.background, cell.backgroundOpacity ?? 100)}
stroke={isCellSelected ? "#3b82f6" : stroke || "#808080"}
strokeWidth={isCellSelected ? 3 : strokeWidth || 1}
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 ObjectsLayer from "./canvas/ObjectsLayer";
import GuidesLayer from "./canvas/GuidesLayer";
import BlurBackdropLayer from "./canvas/BlurBackdropLayer";
import CustomShapeDrawingLayer from "./canvas/CustomShapeDrawingLayer";
import PencilDrawingLayer from "./canvas/PencilDrawingLayer";
import {
@@ -27,6 +28,7 @@ import ZoomControls from "./ZoomControls";
import EditorCanvasToolbar from "./EditorCanvasToolbar";
import { createScopedId } from "../store/editorStore.helpers";
import { getKonvaGradientProps } from "../utils/gradient";
import { getColorWithOpacity } from "../utils/colorOpacity";
import { STICKER_DRAG_MIME } from "../types/IconTypes";
import { GALLERY_DRAG_MIME } from "../types/GalleryTypes";
import {
@@ -72,7 +74,12 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => {
const currentPage = pages.find((page) => page.id === currentPageId);
const bgColor =
currentPage?.backgroundType === 'color' || currentPage?.backgroundType === 'gradient'
currentPage?.backgroundType === 'color'
? getColorWithOpacity(
currentPage.backgroundColor,
currentPage.backgroundOpacity ?? 100,
)
: currentPage?.backgroundType === 'gradient'
? currentPage.backgroundColor
: '#ffffff';
const bgGradient = currentPage?.backgroundGradient;
@@ -621,6 +628,13 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => {
<CustomShapeDrawingLayer drawingState={drawingState} />
<PencilDrawingLayer drawingState={pencilDrawingState} />
</Stage>
<BlurBackdropLayer
objects={objects}
stageWidth={stageSize.width}
stageHeight={stageSize.height}
scale={finalScale}
stageRef={stageRef}
/>
</div>
</div>
</div>
+17 -18
View File
@@ -1,12 +1,12 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useState } from 'react'
import { clx } from '@/helpers/utils'
import { useEditorStore } from '../store/editorStore'
import type { DisplayStyle, BackgroundType } from '../store/editorStore'
import UploadBoxDraggble from '@/components/UploadBoxDraggble'
import { useSingleUpload } from '@/pages/uploader/hooks/useUploaderData'
import ColorsImage from '@/assets/images/colors.png'
import ColorPicker from '@/components/ColorPicker'
import Select from '@/components/Select'
import { toCssLinearGradient } from '../utils/gradient'
const PRESET_COLORS = [
'#a8edcf',
@@ -38,11 +38,11 @@ const SettingsPanel = () => {
updateDocumentSettings,
updateCurrentPageBackground,
} = useEditorStore()
const colorInputRef = useRef<HTMLInputElement>(null)
const { mutateAsync: uploadFile, isPending: mediaLoading } = useSingleUpload()
const currentPage = pages.find((page) => page.id === currentPageId)
const backgroundType = currentPage?.backgroundType ?? 'color'
const backgroundColor = currentPage?.backgroundColor ?? '#ffffff'
const backgroundOpacity = currentPage?.backgroundOpacity ?? 100
const backgroundGradient = currentPage?.backgroundGradient ?? {
from: '#ffffff',
to: '#e2e8f0',
@@ -233,30 +233,29 @@ const SettingsPanel = () => {
/>
))}
{/* Color Wheel */}
<button
onClick={() => colorInputRef.current?.click()}
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"
<div className="w-full mt-2">
<ColorPicker
label="رنگ دلخواه"
value={backgroundColor}
onChange={(e) => updateCurrentPageBackground({ backgroundColor: e.target.value })}
className="absolute inset-0 opacity-0 w-full h-full cursor-pointer"
opacity={backgroundOpacity}
onChange={(value) =>
updateCurrentPageBackground({ backgroundColor: value })
}
onOpacityChange={(value) =>
updateCurrentPageBackground({ backgroundOpacity: value })
}
/>
</button>
</div>
</div>
{/* نمایش رنگ انتخاب‌شده */}
<div className="mt-3 flex items-center gap-2">
<div
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-400">{backgroundOpacity}%</span>
</div>
</div>
)}
@@ -322,7 +321,7 @@ const SettingsPanel = () => {
<div
className="h-12 rounded-xl border border-border"
style={{
backgroundImage: `linear-gradient(${backgroundGradient.angle}deg, ${backgroundGradient.from} 0%, ${backgroundGradient.to} 100%)`,
backgroundImage: toCssLinearGradient(backgroundGradient),
}}
/>
</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;
@@ -88,7 +88,7 @@ const ObjectRenderer = ({
groupNode?.clearCache();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [shouldApplyMask, isSelected, maskRelXDep, maskRelYDep, maskShape?.width, maskShape?.height, maskShape?.rotation, obj.width, obj.height, obj.rotation, obj.maskInvert, obj.strokeWidth, obj.stroke, obj.fill, obj.fillType, obj.gradient, obj.blur]);
}, [shouldApplyMask, isSelected, maskRelXDep, maskRelYDep, maskShape?.width, maskShape?.height, maskShape?.rotation, obj.width, obj.height, obj.rotation, obj.maskInvert, obj.strokeWidth, obj.stroke, obj.fill, obj.fillType, obj.gradient, obj.blur, obj.borderRadius]);
// Refresh cache after transformer is attached (when isSelected changes)
useEffect(() => {
@@ -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;
}
const SELECTION_BLUE = "#93c5fd";
const ANCHOR_SIZE = 5;
const ROTATE_ANCHOR_OFFSET = 22;
const TransformerControls = ({ transformerRef, selectedObject, isGroupedWithMask }: TransformerControlsProps) => {
if (!selectedObject) return null;
@@ -43,12 +47,18 @@ const TransformerControls = ({ transformerRef, selectedObject, isGroupedWithMask
}}
ignoreStroke={isText}
perfectDrawEnabled={false}
anchorFill="#3b82f6"
anchorFill={SELECTION_BLUE}
anchorStroke="#ffffff"
borderStroke="#3b82f6"
borderStroke={SELECTION_BLUE}
borderStrokeWidth={1}
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}
enabledAnchors={enabledAnchors}
/>
@@ -67,6 +67,7 @@ const buildPencilObject = (
points: DrawPoint[],
stroke: string,
strokeWidth: number,
opacity: number,
): EditorObject => {
const xs = points.map((p) => p.x);
const ys = points.map((p) => p.y);
@@ -89,7 +90,7 @@ const buildPencilObject = (
stroke,
strokeWidth,
tension: DEFAULT_TENSION,
opacity: 100,
opacity,
rotation: 0,
};
};
@@ -100,6 +101,7 @@ export const usePencilDrawing = (stageRef: React.RefObject<Konva.Stage | null>)
const pointerIdRef = useRef<number | null>(null);
const strokeRef = useRef("#000000");
const strokeWidthRef = useRef(3);
const opacityRef = useRef(100);
const [drawingState, setDrawingState] = useState<PencilDrawingState>(INITIAL_STATE);
@@ -141,7 +143,12 @@ export const usePencilDrawing = (stageRef: React.RefObject<Konva.Stage | null>)
return;
}
const obj = buildPencilObject(points, strokeRef.current, strokeWidthRef.current);
const obj = buildPencilObject(
points,
strokeRef.current,
strokeWidthRef.current,
opacityRef.current,
);
useEditorStore.getState().commitObjectHistoryBeforeChange();
addObject(obj);
reset();
@@ -165,6 +172,7 @@ export const usePencilDrawing = (stageRef: React.RefObject<Konva.Stage | null>)
(pos: DrawPoint, pointerId: number) => {
strokeRef.current = defaults.stroke;
strokeWidthRef.current = defaults.strokeWidth;
opacityRef.current = defaults.opacity;
isDrawingRef.current = true;
pointerIdRef.current = pointerId;
pointsRef.current = [pos];
@@ -22,6 +22,8 @@ const PencilInstruction: FC = () => {
<ColorPicker
label="رنگ قلم"
value={defaults.stroke}
opacity={defaults.opacity}
onOpacityChange={(value) => updateDefaults({ opacity: value })}
onChange={(value) => updateDefaults({ stroke: value })}
/>
<Input
@@ -106,13 +106,6 @@ const TextInstruction: FC = () => {
}
};
// State محلی برای نمایش شفافیت
const [opacityDisplay, setOpacityDisplay] = useState<string>(`${formState.opacity}`);
useEffect(() => {
setOpacityDisplay(`${formState.opacity}`);
}, [formState.opacity]);
return (
<div>
@@ -164,41 +157,14 @@ const TextInstruction: FC = () => {
/>
</div>
<div className="mt-4 flex flex-col gap-2">
<div className="flex gap-3">
<ColorPicker
label="رنگ"
className="flex-1"
value={formState.fill}
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 className="mt-4">
<ColorPicker
label="رنگ"
value={formState.fill}
opacity={formState.opacity}
onOpacityChange={(value) => handleChange("opacity", value)}
onChange={(value) => handleChange("fill", value)}
/>
</div>
{/* <div className="mt-4 flex gap-2">
@@ -22,6 +22,7 @@ const CustomShapeSettings = ({ selectedObject, onUpdate }: CustomShapeSettingsPr
};
const stroke = selectedObject.stroke ?? "#1e40af";
const strokeWidth = selectedObject.strokeWidth ?? 2;
const opacity = selectedObject.opacity ?? 100;
return (
<div className="space-y-4">
@@ -29,6 +30,8 @@ const CustomShapeSettings = ({ selectedObject, onUpdate }: CustomShapeSettingsPr
label="رنگ پر"
value={fill}
readOnly={fillType === "gradient"}
opacity={opacity}
onOpacityChange={(value) => onUpdate(selectedObject.id, { opacity: value })}
onChange={(value) => onUpdate(selectedObject.id, { fill: value })}
/>
@@ -15,6 +15,7 @@ const GridSettings = ({ selectedObject }: GridSettingsProps) => {
removeRow,
removeColumn,
changeCellBackground,
changeCellBackgroundOpacity,
selectedCellId,
} = 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;
return (
@@ -111,6 +118,8 @@ const GridSettings = ({ selectedObject }: GridSettingsProps) => {
<ColorPicker
label="رنگ پس‌زمینه سلول"
value={selectedCell.background}
opacity={selectedCell.backgroundOpacity ?? 100}
onOpacityChange={handleCellBackgroundOpacityChange}
onChange={handleCellBackgroundChange}
/>
</div>
@@ -13,6 +13,10 @@ const LineSettings = ({ selectedObject, onUpdate }: LineSettingsProps) => {
<ColorPicker
label="رنگ خط"
value={selectedObject.stroke || "#000000"}
opacity={selectedObject.opacity ?? 100}
onOpacityChange={(value) =>
onUpdate(selectedObject.id, { opacity: value })
}
onChange={(value) => onUpdate(selectedObject.id, { stroke: value })}
/>
<Input
@@ -51,6 +51,10 @@ const LinkSettings = ({ selectedObject, onUpdate }: LinkSettingsProps) => {
<ColorPicker
label="رنگ متن"
value={selectedObject.fill || "#0000ff"}
opacity={selectedObject.opacity ?? 100}
onOpacityChange={(value) =>
onUpdate(selectedObject.id, { opacity: value })
}
onChange={(value) => onUpdate(selectedObject.id, { fill: value })}
/>
</>
@@ -10,6 +10,7 @@ type ShapeSettingsProps = {
const ShapeSettings = ({ selectedObject, onUpdate }: ShapeSettingsProps) => {
const baseWidth = selectedObject.width ?? 100;
const baseHeight = selectedObject.height ?? 100;
const baseOpacity = selectedObject.opacity ?? 100;
const baseFill = selectedObject.fill ?? "#3b82f6";
const fillType = selectedObject.fillType ?? "solid";
const gradient = selectedObject.gradient ?? {
@@ -50,6 +51,12 @@ const ShapeSettings = ({ selectedObject, onUpdate }: ShapeSettingsProps) => {
label="رنگ پس‌زمینه"
value={baseFill}
readOnly={fillType === "gradient"}
opacity={baseOpacity}
onOpacityChange={(value) =>
onUpdate(selectedObject.id, {
opacity: value,
})
}
onChange={(value) =>
onUpdate(selectedObject.id, {
fill: value,
@@ -31,6 +31,17 @@ const SizeSettings = ({ selectedObject, onUpdate, defaultWidth = 100, defaultHei
})
}
/>
<Input
label="گردی گوشه"
type="number"
value={selectedObject.borderRadius ?? 0}
onChange={(e) =>
onUpdate(selectedObject.id, {
borderRadius: Math.max(0, parseInt(e.target.value) || 0),
})
}
min={0}
/>
</>
);
};
@@ -102,6 +102,10 @@ const TextSettings = ({ selectedObject, onUpdate }: TextSettingsProps) => {
<ColorPicker
label="رنگ متن"
value={selectedObject.fill || "#000000"}
opacity={selectedObject.opacity ?? 100}
onOpacityChange={(value) =>
onUpdate(selectedObject.id, { opacity: value })
}
onChange={(value) => onUpdate(selectedObject.id, { fill: value })}
/>
</>
@@ -1,47 +1,48 @@
import type { EditorObject } from "../../../store/editorStore";
import Input from "@/components/Input";
import type { EditorObject } from "../../../store/editorStore";
type TransformSettingsProps = {
selectedObject: EditorObject;
onUpdate: (id: string, updates: Partial<EditorObject>) => void;
selectedObject: EditorObject;
onUpdate: (id: string, updates: Partial<EditorObject>) => void;
};
const TransformSettings = ({ selectedObject, onUpdate }: TransformSettingsProps) => {
return (
<div className="pt-4 flex flex-col gap-4 border-t border-border">
<Input
label="موقعیت X"
type="number"
value={Math.round(selectedObject.x)}
onChange={(e) =>
onUpdate(selectedObject.id, {
x: parseInt(e.target.value) || 0,
})
}
/>
<Input
label="موقعیت Y"
type="number"
value={Math.round(selectedObject.y)}
onChange={(e) =>
onUpdate(selectedObject.id, {
y: parseInt(e.target.value) || 0,
})
}
/>
<Input
label="چرخش (درجه)"
type="number"
value={selectedObject.rotation || 0}
onChange={(e) =>
onUpdate(selectedObject.id, {
rotation: parseInt(e.target.value) || 0,
})
}
/>
</div>
);
return (
<div className="pt-4 flex flex-col gap-4 border-t border-border">
<div className="flex gap-4">
<Input
label="موقعیت X"
type="number"
value={Math.round(selectedObject.x)}
onChange={(e) =>
onUpdate(selectedObject.id, {
x: parseInt(e.target.value) || 0,
})
}
/>
<Input
label="موقعیت Y"
type="number"
value={Math.round(selectedObject.y)}
onChange={(e) =>
onUpdate(selectedObject.id, {
y: parseInt(e.target.value) || 0,
})
}
/>
</div>
<Input
label="چرخش (درجه)"
type="number"
value={selectedObject.rotation || 0}
onChange={(e) =>
onUpdate(selectedObject.id, {
rotation: parseInt(e.target.value) || 0,
})
}
/>
</div>
);
};
export default TransformSettings;
@@ -3,7 +3,8 @@ import { Star } from "react-konva";
import Konva from "konva";
import type { ShapeProps } from "./types";
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 shapeRef = useRef<Konva.Star>(null);
@@ -19,9 +20,13 @@ const AbstractShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shape
const actualStrokeWidth = obj.strokeWidth ?? 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.height,
obj.fill,
@@ -29,7 +34,6 @@ const AbstractShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shape
obj.gradient,
obj.stroke,
actualStrokeWidth,
obj.blur,
]);
// برای نمایش انتخاب: اگر strokeWidth واقعی > 0 است، از آن استفاده کن، وگرنه stroke را برای انتخاب نمایش بده
@@ -44,14 +48,14 @@ const AbstractShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shape
const displayStrokeWidth = isSelected
? (hasStroke ? actualStrokeWidth : 3)
: (isMask && showGuide ? 2 : actualStrokeWidth);
const gradientProps = isMask
? {}
: getKonvaGradientProps(
obj.fillType,
obj.gradient,
obj.width || 100,
obj.height || 100,
"centered",
const gradientProps = isMask || blurRadius > 0
? {}
: getKonvaGradientProps(
obj.fillType,
obj.gradient,
obj.width || 100,
obj.height || 100,
"centered",
);
return (
@@ -64,12 +68,14 @@ const AbstractShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shape
numPoints={5}
innerRadius={innerRadius}
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}
stroke={displayStroke}
strokeWidth={displayStrokeWidth}
dash={isMask && showGuide ? [10, 5] : undefined}
rotation={obj.rotation || 0}
opacity={(obj.opacity ?? 100) / 100}
{...blurProps}
draggable={draggable}
onClick={(e) => {
@@ -42,6 +42,7 @@ const ArrowShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapePro
hitStrokeWidth={20}
perfectDrawEnabled={false}
rotation={obj.rotation || 0}
opacity={(obj.opacity ?? 100) / 100}
draggable={draggable}
onClick={(e) => {
if (shapeRef.current) {
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef } from "react";
import { Rect, Group, Circle, Text as KonvaText } from "react-konva";
import Konva from "konva";
import type { ShapeProps } from "./types";
import { getObjectBorderRadius } from "../../utils/borderRadius";
const AudioShape = ({
obj,
@@ -15,6 +16,7 @@ const AudioShape = ({
const containerWidth = obj.width || 320;
const containerHeight = obj.height || 56;
const cornerRadius = getObjectBorderRadius(obj.borderRadius ?? 8);
const barY = containerHeight * 0.55;
const barHeight = Math.max(4, containerHeight * 0.12);
const playRadius = Math.min(18, containerHeight * 0.35);
@@ -70,7 +72,7 @@ const AudioShape = ({
width={containerWidth}
height={containerHeight}
fill="#f3f4f6"
cornerRadius={8}
cornerRadius={cornerRadius}
stroke={isSelected ? "#3b82f6" : "#d1d5db"}
strokeWidth={isSelected ? 3 : 1}
onClick={handleGroupClick}
@@ -3,7 +3,8 @@ import { Circle } from "react-konva";
import Konva from "konva";
import type { ShapeProps } from "./types";
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 shapeRef = useRef<Konva.Circle>(null);
@@ -15,16 +16,19 @@ const CircleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapePr
const actualStrokeWidth = obj.strokeWidth ?? 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.fill,
obj.fillType,
obj.gradient,
obj.stroke,
actualStrokeWidth,
obj.blur,
]);
// برای نمایش انتخاب: اگر strokeWidth واقعی > 0 است، از آن استفاده کن، وگرنه stroke را برای انتخاب نمایش بده
@@ -40,9 +44,9 @@ const CircleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapePr
? (hasStroke ? actualStrokeWidth : 3)
: (isMask && showGuide ? 2 : actualStrokeWidth);
const diameter = obj.width || 100;
const gradientProps = isMask
? {}
: getKonvaGradientProps(obj.fillType, obj.gradient, diameter, diameter, "centered");
const gradientProps = isMask || blurRadius > 0
? {}
: getKonvaGradientProps(obj.fillType, obj.gradient, diameter, diameter, "centered");
return (
<Circle
@@ -52,12 +56,14 @@ const CircleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapePr
x={obj.x}
y={obj.y}
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}
stroke={displayStroke}
strokeWidth={displayStrokeWidth}
dash={isMask && showGuide ? [10, 5] : undefined}
rotation={obj.rotation || 0}
opacity={(obj.opacity ?? 100) / 100}
{...blurProps}
draggable={draggable}
onClick={(e) => {
@@ -1,9 +1,10 @@
import { useEffect, useRef } from "react";
import { Image as KonvaImage } from "react-konva";
import { Image as KonvaImage, Group, Rect } from "react-konva";
import Konva from "konva";
import useImage from "use-image";
import type { ShapeProps } from "./types";
import { clampPositionToStage } from "../../utils/stageBounds";
import { createRoundedRectClipFunc, getObjectBorderRadius } from "../../utils/borderRadius";
const ImageObject = ({
obj,
@@ -17,9 +18,8 @@ const ImageObject = ({
}: ShapeProps) => {
const [image, status] = useImage(obj.imageUrl || "");
const shapeRef = useRef<Konva.Image>(null);
const groupRef = useRef<Konva.Group>(null);
// Notify parent (ObjectRenderer) as soon as the image is decoded and ready.
// This lets the masked group re-cache itself after the image content appears.
useEffect(() => {
if (status === "loaded" && onImageReady) {
onImageReady();
@@ -31,6 +31,84 @@ const ImageObject = ({
const width = obj.width || image.width;
const height = obj.height || image.height;
const hasStageBounds = stageWidth != null && stageHeight != null;
const cornerRadius = getObjectBorderRadius(obj.borderRadius);
const clipFunc = createRoundedRectClipFunc(width, height, cornerRadius);
const dragBoundFunc =
draggable && hasStageBounds
? (pos: { x: number; y: number }) =>
clampPositionToStage(
pos.x,
pos.y,
width,
height,
stageWidth!,
stageHeight!,
)
: undefined;
const handleDragEnd = (e: Konva.KonvaEventObject<DragEvent>) => {
const node = e.target;
let x = node.x();
let y = node.y();
if (hasStageBounds) {
({ x, y } = clampPositionToStage(
x,
y,
width,
height,
stageWidth!,
stageHeight!,
));
node.position({ x, y });
}
onUpdate(obj.id, { x, y });
};
const handleClick = (e: Konva.KonvaEventObject<MouseEvent>) => {
const node = cornerRadius > 0 ? groupRef.current : shapeRef.current;
if (node) {
onSelect(obj.id, node, e);
}
};
if (cornerRadius > 0) {
return (
<Group
ref={groupRef}
id={obj.id}
name="canvas-object"
x={obj.x}
y={obj.y}
rotation={obj.rotation || 0}
draggable={draggable}
clipFunc={clipFunc}
dragBoundFunc={dragBoundFunc}
onClick={handleClick}
onDragEnd={handleDragEnd}
>
<KonvaImage
x={0}
y={0}
image={image}
width={width}
height={height}
/>
{isSelected && (
<Rect
x={0}
y={0}
width={width}
height={height}
stroke="#3b82f6"
strokeWidth={3}
cornerRadius={cornerRadius}
listening={false}
/>
)}
</Group>
);
}
return (
<KonvaImage
@@ -46,44 +124,11 @@ const ImageObject = ({
strokeWidth={isSelected ? 3 : 0}
rotation={obj.rotation || 0}
draggable={draggable}
dragBoundFunc={
draggable && hasStageBounds
? (pos) =>
clampPositionToStage(
pos.x,
pos.y,
width,
height,
stageWidth!,
stageHeight!,
)
: undefined
}
onClick={(e) => {
if (shapeRef.current) {
onSelect(obj.id, shapeRef.current, e);
}
}}
onDragEnd={(e) => {
const node = e.target;
let x = node.x();
let y = node.y();
if (hasStageBounds) {
({ x, y } = clampPositionToStage(
x,
y,
width,
height,
stageWidth!,
stageHeight!,
));
node.position({ x, y });
}
onUpdate(obj.id, { x, y });
}}
dragBoundFunc={dragBoundFunc}
onClick={handleClick}
onDragEnd={handleDragEnd}
/>
);
};
export default ImageObject;
@@ -26,6 +26,7 @@ const LineShape = ({ obj, onSelect, onUpdate, draggable }: ShapeProps) => {
hitStrokeWidth={20}
perfectDrawEnabled={false}
rotation={obj.rotation || 0}
opacity={(obj.opacity ?? 100) / 100}
draggable={draggable}
onClick={(e) => {
if (shapeRef.current) {
@@ -35,6 +35,7 @@ const LinkShape = ({
fontStyle={fontWeight}
underline={true}
rotation={obj.rotation || 0}
opacity={(obj.opacity ?? 100) / 100}
draggable={draggable}
onClick={(e) => {
// در حالت editor، لینک‌ها قابل کلیک نیستند
@@ -3,7 +3,8 @@ import { Rect } from "react-konva";
import Konva from "konva";
import type { ShapeProps } from "./types";
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 shapeRef = useRef<Konva.Rect>(null);
@@ -16,9 +17,13 @@ const RectangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shap
const actualStrokeWidth = obj.strokeWidth ?? 0;
const hasStroke = actualStrokeWidth > 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.height,
cornerRadius,
@@ -27,7 +32,6 @@ const RectangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shap
obj.gradient,
obj.stroke,
actualStrokeWidth,
obj.blur,
]);
// برای نمایش انتخاب: اگر strokeWidth واقعی > 0 است، از آن استفاده کن، وگرنه stroke را برای انتخاب نمایش بده
@@ -42,7 +46,7 @@ const RectangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shap
const displayStrokeWidth = isSelected
? (hasStroke ? actualStrokeWidth : 3)
: (isMask && showGuide ? 2 : actualStrokeWidth);
const gradientProps = isMask
const gradientProps = isMask || blurRadius > 0
? {}
: getKonvaGradientProps(
obj.fillType,
@@ -62,12 +66,15 @@ const RectangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shap
width={obj.width || 100}
height={obj.height || 100}
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}
stroke={displayStroke}
strokeWidth={displayStrokeWidth}
dash={isMask && showGuide ? [10, 5] : undefined}
rotation={obj.rotation || 0}
opacity={(obj.opacity ?? 100) / 100}
{...blurProps}
draggable={draggable}
onClick={(e) => {
@@ -4,12 +4,16 @@ import Konva from "konva";
import type { TextShapeProps } from "./types";
import { getFontFamily } from "@/pages/editor/utils/fontFamily";
import {
buildCanvasFont,
buildKonvaCanvasFont,
DEFAULT_TEXT_MAX_WIDTH_PX,
getEffectiveTextWidth,
getKonvaFontStyle,
measureTextBlock,
resolveTextMaxWidth,
usesWrappedLayout,
} from "@/pages/editor/utils/textStyle";
import { getColorWithOpacity } from "@/pages/editor/utils/colorOpacity";
const getFontWeight = (fontWeight?: string): string => {
if (!fontWeight) return "normal";
@@ -29,28 +33,6 @@ const getFontWeight = (fontWeight?: string): string => {
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 = ({
obj,
onSelect,
@@ -184,11 +166,9 @@ const TextShape = ({
}
const specs = [
`${fontSize}px ${resolvedFamily}`,
buildCanvasFont(fontSize, obj.fontFamily, obj.fontWeight),
buildKonvaCanvasFont(fontSize, obj.fontFamily, obj.fontWeight),
`${konvaStyle} normal ${fontSize}px ${resolvedFamily}`,
`bold ${fontSize}px ${resolvedFamily}`,
`300 ${fontSize}px ${resolvedFamily}`,
`200 ${fontSize}px ${resolvedFamily}`,
];
void Promise.all(
@@ -3,7 +3,8 @@ import { RegularPolygon } from "react-konva";
import Konva from "konva";
import type { ShapeProps } from "./types";
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 shapeRef = useRef<Konva.RegularPolygon>(null);
@@ -16,17 +17,20 @@ const TriangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shape
const actualStrokeWidth = obj.strokeWidth ?? 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.height,
obj.fill,
obj.fillType,
obj.gradient,
obj.stroke,
actualStrokeWidth,
obj.blur,
obj.height,
]);
// برای نمایش انتخاب: اگر strokeWidth واقعی > 0 است، از آن استفاده کن، وگرنه stroke را برای انتخاب نمایش بده
@@ -41,14 +45,14 @@ const TriangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shape
const displayStrokeWidth = isSelected
? (hasStroke ? actualStrokeWidth : 3)
: (isMask && showGuide ? 2 : actualStrokeWidth);
const gradientProps = isMask
? {}
: getKonvaGradientProps(
obj.fillType,
obj.gradient,
obj.width || 100,
obj.height || 100,
"centered",
const gradientProps = isMask || blurRadius > 0
? {}
: getKonvaGradientProps(
obj.fillType,
obj.gradient,
obj.width || 100,
obj.height || 100,
"centered",
);
return (
@@ -60,12 +64,14 @@ const TriangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shape
y={obj.y + (obj.height || 100) / 2}
sides={3}
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}
stroke={displayStroke}
strokeWidth={displayStrokeWidth}
dash={isMask && showGuide ? [10, 5] : undefined}
rotation={obj.rotation || 0}
opacity={(obj.opacity ?? 100) / 100}
{...blurProps}
draggable={draggable}
onClick={(e) => {
@@ -4,6 +4,7 @@ import Konva from "konva";
import useImage from "use-image";
import type { ShapeProps } from "./types";
import { createPortal } from "react-dom";
import { createRoundedRectClipFunc, getObjectBorderRadius } from "../../utils/borderRadius";
const VideoShape = ({
obj,
@@ -72,6 +73,8 @@ const VideoShape = ({
const containerWidth = obj.width || 400;
const containerHeight = obj.height || 300;
const cornerRadius = getObjectBorderRadius(obj.borderRadius);
const clipFunc = createRoundedRectClipFunc(containerWidth, containerHeight, cornerRadius);
// هم‌تراز با viewer که object-fit: contain دارد
const imageLayout = useMemo(() => {
@@ -151,10 +154,12 @@ const VideoShape = ({
width={containerWidth}
height={containerHeight}
fill="transparent"
cornerRadius={cornerRadius}
stroke={isSelected ? "#3b82f6" : "#666666"}
strokeWidth={isSelected ? 3 : (obj.strokeWidth ?? 0)}
onClick={handleGroupClick}
/>
<Group clipFunc={clipFunc}>
{image ? (
<>
<Rect
@@ -184,6 +189,7 @@ const VideoShape = ({
onClick={handleGroupClick}
/>
)}
</Group>
<Group
name="playButton"
onClick={handlePlayClick}
@@ -44,6 +44,7 @@ export const createInitialCells = (
id: cellId,
text: "",
background: "#f5f5f5",
backgroundOpacity: 100,
width: cellWidth,
height: cellHeight,
};
@@ -59,6 +60,7 @@ export const createInitialPage = (name: string): Page => ({
guides: [],
backgroundType: "color",
backgroundColor: "#ffffff",
backgroundOpacity: 100,
backgroundGradient: {
from: "#ffffff",
to: "#e2e8f0",
+34
View File
@@ -68,6 +68,7 @@ type PageBackgroundSettings = Pick<
Page,
| "backgroundType"
| "backgroundColor"
| "backgroundOpacity"
| "backgroundGradient"
| "backgroundImageUrl"
| "backgroundVideoUrl"
@@ -127,6 +128,11 @@ type EditorStoreType = {
cellId: string,
color: string,
) => void;
changeCellBackgroundOpacity: (
tableId: string,
cellId: string,
opacity: number,
) => void;
applyTableResize: (
tableId: string,
newWidth: number,
@@ -186,6 +192,7 @@ const DEFAULT_DOCUMENT_SETTINGS: DocumentSettings = {
smartGuide: true,
backgroundType: "color",
backgroundColor: "#ffffff",
backgroundOpacity: 100,
backgroundGradient: {
from: "#ffffff",
to: "#e2e8f0",
@@ -640,6 +647,7 @@ export const useEditorStore = create<EditorStoreType>((set, get) => {
id: cellId,
text: "",
background: "#f5f5f5",
backgroundOpacity: 100,
width: tableData.cellWidth,
height: tableData.cellHeight,
};
@@ -670,6 +678,7 @@ export const useEditorStore = create<EditorStoreType>((set, get) => {
id: cellId,
text: "",
background: "#f5f5f5",
backgroundOpacity: 100,
width: tableData.cellWidth,
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) => {
const state = get();
const obj = state.objects.find((o) => o.id === tableId);
@@ -858,6 +889,7 @@ export const useEditorStore = create<EditorStoreType>((set, get) => {
guides: [...pageToDuplicate.guides],
backgroundType: pageToDuplicate.backgroundType,
backgroundColor: pageToDuplicate.backgroundColor,
backgroundOpacity: pageToDuplicate.backgroundOpacity ?? 100,
backgroundGradient: pageToDuplicate.backgroundGradient,
backgroundImageUrl: pageToDuplicate.backgroundImageUrl,
backgroundVideoUrl: pageToDuplicate.backgroundVideoUrl,
@@ -1053,6 +1085,8 @@ export const useEditorStore = create<EditorStoreType>((set, get) => {
page.backgroundType ?? fallbackSettings.backgroundType ?? "color",
backgroundColor:
page.backgroundColor ?? fallbackSettings.backgroundColor ?? "#ffffff",
backgroundOpacity:
page.backgroundOpacity ?? fallbackSettings.backgroundOpacity ?? 100,
backgroundGradient: page.backgroundGradient ??
fallbackSettings.backgroundGradient ?? {
from: "#ffffff",
@@ -33,6 +33,8 @@ export type TableCell = {
id: string;
text: string;
background: string;
/** شفافیت پس‌زمینه سلول (۰–۱۰۰) */
backgroundOpacity?: number;
width: number;
height: number;
};
@@ -121,6 +123,7 @@ export type Page = {
guides: PageGuide[];
backgroundType: BackgroundType;
backgroundColor: string;
backgroundOpacity?: number;
backgroundGradient?: LinearGradient;
backgroundImageUrl: string;
backgroundVideoUrl: string;
@@ -137,6 +140,7 @@ export type DocumentSettings = {
smartGuide: boolean;
backgroundType: BackgroundType;
backgroundColor: string;
backgroundOpacity?: number;
backgroundGradient?: LinearGradient;
backgroundImageUrl: string;
backgroundVideoUrl: string;
@@ -3,6 +3,7 @@ import { create } from "zustand";
export type PencilDefaults = {
stroke: string;
strokeWidth: number;
opacity: number;
};
type PencilDefaultsStoreType = {
@@ -14,6 +15,7 @@ export const usePencilDefaultsStore = create<PencilDefaultsStoreType>((set) => (
defaults: {
stroke: "#000000",
strokeWidth: 3,
opacity: 100,
},
updateDefaults: (updates) =>
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;
}
+33
View File
@@ -0,0 +1,33 @@
import type Konva from "konva";
export const getObjectBorderRadius = (borderRadius?: number): number =>
Math.max(0, borderRadius ?? 0);
export const clampBorderRadius = (
radius: number,
width: number,
height: number,
): number => Math.max(0, Math.min(radius, width / 2, height / 2));
export const createRoundedRectClipFunc = (
width: number,
height: number,
radius: number,
): Konva.ContainerConfig["clipFunc"] => {
const r = clampBorderRadius(radius, width, height);
if (r <= 0) return undefined;
return (ctx) => {
ctx.beginPath();
ctx.roundRect(0, 0, width, height, r);
ctx.closePath();
};
};
export const getCssBorderRadius = (
borderRadius: number | undefined,
scale: number,
): string | undefined => {
const r = getObjectBorderRadius(borderRadius) * scale;
return r > 0 ? `${r}px` : undefined;
};
+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})`;
};
+12 -7
View File
@@ -25,23 +25,24 @@ const getGradientPoints = (
): { start: Point; end: Point } => {
const safeWidth = Math.max(1, width);
const safeHeight = Math.max(1, height);
// App/UI angle: 0° = top→bottom, 90° = left→right (clockwise).
const rad = toRadians(normalizeAngle(angle));
const dx = Math.cos(rad);
const dy = Math.sin(rad);
const half = Math.sqrt(safeWidth * safeWidth + safeHeight * safeHeight) / 2;
const halfX = Math.sin(rad) * half;
const halfY = Math.cos(rad) * half;
if (mode === "centered") {
return {
start: { x: -dx * half, y: -dy * half },
end: { x: dx * half, y: dy * half },
start: { x: -halfX, y: -halfY },
end: { x: halfX, y: halfY },
};
}
const cx = safeWidth / 2;
const cy = safeHeight / 2;
return {
start: { x: cx - dx * half, y: cy - dy * half },
end: { x: cx + dx * half, y: cy + dy * half },
start: { x: cx - halfX, y: cy - halfY },
end: { x: cx + halfX, y: cy + halfY },
};
};
@@ -91,7 +92,11 @@ export const getSvgGradientEndpoints = (
};
};
/** Convert app/UI angle to CSS linear-gradient degrees (0deg = upward in CSS). */
export const toCssGradientAngle = (angle: number) =>
normalizeAngle(180 - normalizeAngle(angle));
export const toCssLinearGradient = (gradient: LinearGradient | undefined) => {
if (!gradient) return undefined;
return `linear-gradient(${normalizeAngle(gradient.angle)}deg, ${gradient.from} 0%, ${gradient.to} 100%)`;
return `linear-gradient(${toCssGradientAngle(gradient.angle)}deg, ${gradient.from} 0%, ${gradient.to} 100%)`;
};
+14 -8
View File
@@ -11,12 +11,18 @@ export const getCssFontWeight = (fontWeight?: string): number => {
return 400;
};
/** Konva.Text uses `fontStyle: bold` (canvas keyword), not numeric weight. */
export const usesKonvaBoldStyle = (fontWeight?: string): boolean => {
if (!fontWeight || fontWeight === "normal") return false;
if (fontWeight === "bold" || fontWeight === "bolder") return true;
/**
* Konva.Text `fontStyle` — supports `normal`, `bold`, or numeric strings like `200`.
* @see https://konvajs.org/api/Konva.Text.html#fontStyle
*/
export const getKonvaFontStyle = (fontWeight?: string): string => {
if (!fontWeight || fontWeight === "normal") return "normal";
if (fontWeight === "bold" || fontWeight === "bolder") return "bold";
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. */
@@ -30,15 +36,15 @@ export const buildCanvasFont = (
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 = (
fontSize: number,
fontFamily?: string,
fontWeight?: string,
): string => {
const family = getFontFamily(fontFamily);
const style = usesKonvaBoldStyle(fontWeight) ? "bold" : "normal";
return `${style} ${fontSize}px ${family}`;
const style = getKonvaFontStyle(fontWeight);
return `${style} normal ${fontSize}px ${family}`;
};
const LINE_BREAK_RE = /[\r\n\u2028\u2029]/;
+189 -77
View File
@@ -1,4 +1,4 @@
import { forwardRef, memo } from 'react';
import { forwardRef, memo, useEffect, useRef } from 'react';
import { type PageData } from '../types';
import type { EditorObject } from '@/pages/editor/store/editorStore';
import { toCssLinearGradient, getSvgGradientEndpoints } from '@/pages/editor/utils/gradient';
@@ -11,11 +11,17 @@ import {
resolveTextMaxWidth,
usesWrappedLayout,
} 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 '@/pages/viewer/styles/entranceAnimations.css';
import { mergeEntranceAnimationStyle } from '@/pages/viewer/utils/entranceAnimationStyle';
import { getMaskImageStyle, getMaskedLayout } from '@/pages/viewer/utils/maskStyle';
import type { EntrancePhase } from '@/pages/viewer/hooks/useBookEntranceController';
import { usePageMediaReady } from '@/pages/viewer/hooks/usePageMediaReady';
const getRasterObjectStyle = (
obj: EditorObject,
@@ -27,6 +33,7 @@ const getRasterObjectStyle = (
const scaleY = obj.scaleY ?? 1;
const width = obj.width != null ? obj.width * scaleX * scale : undefined;
const height = obj.height != null ? obj.height * scaleY * scale : undefined;
const borderRadius = getCssBorderRadius(obj.borderRadius, scale);
return {
...baseStyle,
@@ -36,6 +43,7 @@ const getRasterObjectStyle = (
height: height != null ? `${height}px` : 'auto',
objectFit: 'fill',
zIndex: index,
...(borderRadius ? { borderRadius, overflow: 'hidden' as const } : {}),
};
};
@@ -53,6 +61,7 @@ type BookPageProps = {
onLinkClick?: (linkUrl: string) => void;
backgroundType?: 'color' | 'gradient' | 'image' | 'video';
backgroundColor?: string;
backgroundOpacity?: number;
backgroundGradient?: {
from: string;
to: string;
@@ -64,6 +73,10 @@ type BookPageProps = {
entrancePhase?: EntrancePhase;
/** در پیش‌نمایش کاتالوگ انیمی션 ورود غیرفعال باشد */
disableEntranceAnimations?: boolean;
/** وقتی رسانهٔ صفحه دیرتر از زمان‌بندی انیمیشن لود شد */
onDeferredEntranceReady?: (pageId: number) => void;
/** لایهٔ اسپینر لود رسانه را نشان نده (مثلاً ذره‌بین) */
hideMediaLoadingOverlay?: boolean;
/** ویدیو/صوت فقط به‌صورت کاور — بدون کنترل و بدون کلیک */
staticMedia?: boolean;
/** سایهٔ لبهٔ کاغذ (گرادیان داخل DOM — سازگار با iOS قدیمی) */
@@ -86,8 +99,41 @@ type BookPageProps = {
* نیاز به forwardRef دارد تا react-pageflip بتواند ref را مدیریت کند
*/
const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
({ page, scale = 1, pageWidth, pageHeight, onLinkClick, backgroundType, backgroundColor, backgroundGradient, backgroundImageUrl, backgroundVideoUrl, entrancePhase = 'idle', disableEntranceAnimations = 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 effectiveBackgroundColor = backgroundColor ?? page.backgroundColor ?? '#ffffff';
const effectiveBackgroundOpacity = backgroundOpacity ?? page.backgroundOpacity ?? 100;
const effectiveBackgroundGradient = backgroundGradient ?? page.backgroundGradient;
const effectiveBackgroundImageUrl = backgroundImageUrl ?? page.backgroundImageUrl ?? '';
const effectiveBackgroundVideoUrl = backgroundVideoUrl ?? page.backgroundVideoUrl ?? '';
const isMediaReady = usePageMediaReady(
page,
{
backgroundType: effectiveBackgroundType,
backgroundImageUrl: effectiveBackgroundImageUrl,
backgroundVideoUrl: effectiveBackgroundVideoUrl,
},
!hideMediaLoadingOverlay,
);
const deferredEntranceRef = useRef(false);
const phase: EntrancePhase = disableEntranceAnimations ? 'settled' : entrancePhase;
const effectivePhase: EntrancePhase =
hideMediaLoadingOverlay || isMediaReady ? phase : 'idle';
useEffect(() => {
if (disableEntranceAnimations || hideMediaLoadingOverlay) return;
if (phase === 'play' && !isMediaReady) {
deferredEntranceRef.current = true;
}
}, [phase, isMediaReady, disableEntranceAnimations, hideMediaLoadingOverlay]);
useEffect(() => {
if (!isMediaReady || !deferredEntranceRef.current) return;
deferredEntranceRef.current = false;
onDeferredEntranceReady?.(page.id);
}, [isMediaReady, onDeferredEntranceReady, page.id]);
// تابع برای تبدیل opacity به رنگ (مثل editor)
// در editor، getColorWithOpacity انتظار opacity 0-100 دارد
// در dataTransformer، opacity از 0-1 به 0-100 تبدیل شده است
@@ -131,7 +177,7 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
rotationDeg?: number;
},
) =>
mergeEntranceAnimationStyle(style, obj, scale, index, phase, {
mergeEntranceAnimationStyle(style, obj, scale, index, effectivePhase, {
...extra,
flyLayoutPx,
});
@@ -171,11 +217,15 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
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 =
obj.fillType === 'gradient' && obj.gradient
? { backgroundImage: toCssLinearGradient(obj.gradient) }
: { backgroundColor: obj.fill || 'transparent' };
const shapeBlurStyle = getCssBlurStyle(obj.blur, scale);
switch (obj.type) {
case 'text': {
@@ -277,7 +327,17 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
/>
);
case 'video':
case 'video': {
const videoBorderRadius = getCssBorderRadius(obj.borderRadius, scale);
const videoStyle: React.CSSProperties = {
...baseStyle,
width: obj.width ? `${obj.width * scale}px` : 'auto',
height: obj.height ? `${obj.height * scale}px` : 'auto',
objectFit: 'contain',
zIndex: index,
...(videoBorderRadius ? { borderRadius: videoBorderRadius, overflow: 'hidden' } : {}),
};
if (options.staticMedia) {
return (
<video
@@ -288,11 +348,7 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
preload="metadata"
style={applyStyle(
{
...baseStyle,
width: obj.width ? `${obj.width * scale}px` : 'auto',
height: obj.height ? `${obj.height * scale}px` : 'auto',
objectFit: 'contain',
zIndex: index,
...videoStyle,
pointerEvents: 'none',
},
obj,
@@ -314,11 +370,7 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
controls
style={applyStyle(
{
...baseStyle,
width: obj.width ? `${obj.width * scale}px` : 'auto',
height: obj.height ? `${obj.height * scale}px` : 'auto',
objectFit: 'contain',
zIndex: index,
...videoStyle,
pointerEvents: 'auto',
},
obj,
@@ -335,20 +387,26 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
}}
/>
);
}
case 'audio': {
const audioBorderRadius = getCssBorderRadius(obj.borderRadius ?? 8, scale);
const audioBaseStyle: React.CSSProperties = {
...baseStyle,
width: obj.width ? `${obj.width * scale}px` : `${320 * scale}px`,
height: obj.height ? `${obj.height * scale}px` : `${56 * scale}px`,
zIndex: index,
...(audioBorderRadius ? { borderRadius: audioBorderRadius, overflow: 'hidden' } : {}),
};
case 'audio':
if (options.staticMedia) {
return (
<div
key={obj.id || index}
style={applyStyle(
{
...baseStyle,
width: obj.width ? `${obj.width * scale}px` : `${320 * scale}px`,
height: obj.height ? `${obj.height * scale}px` : `${56 * scale}px`,
...audioBaseStyle,
backgroundColor: '#f3f4f6',
borderRadius: `${4 * scale}px`,
zIndex: index,
pointerEvents: 'none',
},
obj,
@@ -365,10 +423,7 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
preload="metadata"
style={applyStyle(
{
...baseStyle,
width: obj.width ? `${obj.width * scale}px` : `${320 * scale}px`,
height: obj.height ? `${obj.height * scale}px` : `${56 * scale}px`,
zIndex: index,
...audioBaseStyle,
pointerEvents: 'auto',
},
obj,
@@ -385,6 +440,7 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
}}
/>
);
}
case 'link': {
const isInternalLink = obj.linkUrl?.startsWith('page://');
@@ -489,7 +545,6 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
height: `${radius * 2}px`,
borderRadius: '50%',
...objectFillStyle,
...shapeBlurStyle,
border: hasStroke && obj.stroke
? `${actualStrokeWidth * scale}px solid ${obj.stroke}`
: 'none',
@@ -540,6 +595,16 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
const fillColor = obj.fill || '#000000';
const strokeColor = obj.stroke || 'transparent';
const gradientId = `triangle-grad-${obj.id}-${index}${idSuffix}`;
const triangleGradientEndpoints =
obj.fillType === 'gradient' && obj.gradient
? getSvgGradientEndpoints(
obj.gradient,
baseWidth * scale,
baseHeight * scale,
'centered',
{ x: halfSize, y: halfSize },
)
: null;
return (
<svg
@@ -552,7 +617,6 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
width: `${size}px`,
height: `${size}px`,
opacity: (obj.opacity ?? 100) / 100,
...shapeBlurStyle,
transform: obj.rotation ? `rotate(${obj.rotation}deg)` : undefined,
transformOrigin: 'center center',
zIndex: index,
@@ -564,25 +628,24 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
)}
viewBox={`0 0 ${size} ${size}`}
>
{obj.fillType === 'gradient' && obj.gradient ? (
{triangleGradientEndpoints ? (
<defs>
<linearGradient
id={gradientId}
gradientUnits="userSpaceOnUse"
x1="0"
y1="0"
x2={size}
y2={size}
gradientTransform={`rotate(${obj.gradient.angle}, ${size / 2}, ${size / 2})`}
x1={triangleGradientEndpoints.x1}
y1={triangleGradientEndpoints.y1}
x2={triangleGradientEndpoints.x2}
y2={triangleGradientEndpoints.y2}
>
<stop offset="0%" stopColor={obj.gradient.from} />
<stop offset="100%" stopColor={obj.gradient.to} />
<stop offset="0%" stopColor={obj.gradient!.from} />
<stop offset="100%" stopColor={obj.gradient!.to} />
</linearGradient>
</defs>
) : null}
<polygon
points={`${topX},${topY} ${bottomLeftX},${bottomY} ${bottomRightX},${bottomY}`}
fill={obj.fillType === 'gradient' && obj.gradient ? `url(#${gradientId})` : fillColor}
fill={triangleGradientEndpoints ? `url(#${gradientId})` : fillColor}
stroke={strokeColor}
strokeWidth={strokeWidth}
/>
@@ -612,7 +675,6 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
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%)',
...objectFillStyle,
...shapeBlurStyle,
border: obj.stroke
? `${(obj.strokeWidth || 1) * scale}px solid ${obj.stroke}`
: 'none',
@@ -635,7 +697,6 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
width: `${(obj.width || 100) * scale}px`,
height: `${(obj.height || 100) * scale}px`,
...objectFillStyle,
...shapeBlurStyle,
borderRadius: `${Math.max(0, (obj.borderRadius || 0) * scale)}px`,
border: hasStroke && obj.stroke
? `${actualStrokeWidth * scale}px solid ${obj.stroke}`
@@ -895,7 +956,10 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
key={cellId}
style={{
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',
alignItems: 'center',
justifyContent: 'center',
@@ -964,12 +1028,6 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
const width = pageWidth ?? 794 * scale;
const height = pageHeight ?? 1123 * scale;
const effectiveBackgroundType = backgroundType ?? page.backgroundType ?? 'color';
const effectiveBackgroundColor = backgroundColor ?? page.backgroundColor ?? '#ffffff';
const effectiveBackgroundGradient = backgroundGradient ?? page.backgroundGradient;
const effectiveBackgroundImageUrl = backgroundImageUrl ?? page.backgroundImageUrl ?? '';
const effectiveBackgroundVideoUrl = backgroundVideoUrl ?? page.backgroundVideoUrl ?? '';
const bgStyle: React.CSSProperties =
effectiveBackgroundType === 'image' && effectiveBackgroundImageUrl
? {
@@ -982,7 +1040,9 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
? {
backgroundImage: toCssLinearGradient(effectiveBackgroundGradient),
}
: { backgroundColor: effectiveBackgroundColor };
: { backgroundColor: getColorWithOpacity(effectiveBackgroundColor, effectiveBackgroundOpacity) };
const showLoadingOverlay = !hideMediaLoadingOverlay && !isMediaReady;
return (
<div
@@ -998,49 +1058,101 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
width: `${width}px`,
height: `${height}px`,
zIndex: 1,
...(useHardPage
...(useHardPage && !showLoadingOverlay
? { backgroundColor: effectiveBackgroundColor, ...bgStyle }
: {}),
}}
>
{!useHardPage && (
<>
<div
aria-hidden
style={{
position: 'absolute',
inset: 0,
...bgStyle,
pointerEvents: 'none',
zIndex: 0,
}}
/>
{effectiveBackgroundType === 'video' && effectiveBackgroundVideoUrl && (
<video
<div
style={{
position: 'absolute',
inset: 0,
visibility: showLoadingOverlay ? 'hidden' : 'visible',
}}
>
{!useHardPage && (
<>
<div
aria-hidden
src={effectiveBackgroundVideoUrl}
autoPlay
loop
muted
playsInline
preload="metadata"
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
...bgStyle,
pointerEvents: 'none',
zIndex: 0,
}}
/>
)}
</>
)}
<div style={{ position: 'absolute', inset: 0, zIndex: 1 }}>
{page.elements.map((element, index) => renderObject(element, index, page.elements))}
{effectiveBackgroundType === 'video' && effectiveBackgroundVideoUrl && (
<video
aria-hidden
src={effectiveBackgroundVideoUrl}
autoPlay
loop
muted
playsInline
preload="metadata"
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
pointerEvents: 'none',
zIndex: 0,
}}
/>
)}
</>
)}
<div style={{ position: 'absolute', inset: 0, zIndex: 1 }}>
{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>
{useHardPage && showPaperShadow && <div aria-hidden className="page-paper-shadow" />}
{showLoadingOverlay && (
<div
className="flex items-center justify-center bg-gray-100"
style={{
position: 'absolute',
inset: 0,
zIndex: 2,
}}
role="status"
aria-label="در حال بارگذاری صفحه"
>
<div className="size-8 rounded-full border-2 border-gray-200 border-t-gray-600 animate-spin" />
</div>
)}
{useHardPage && showPaperShadow && !showLoadingOverlay && (
<div aria-hidden className="page-paper-shadow" />
)}
</div>
);
}),
+5 -2
View File
@@ -193,12 +193,13 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
);
const backgroundType = documentSettings?.backgroundType ?? "color";
const backgroundColor = documentSettings?.backgroundColor ?? "#ffffff";
const backgroundOpacity = documentSettings?.backgroundOpacity ?? 100;
const backgroundGradient = documentSettings?.backgroundGradient;
const backgroundImageUrl = documentSettings?.backgroundImageUrl ?? "";
const backgroundVideoUrl = documentSettings?.backgroundVideoUrl ?? "";
const pageBackgroundDefaults = useMemo(
() => ({ backgroundType, backgroundColor, backgroundGradient, backgroundImageUrl, backgroundVideoUrl }),
[backgroundType, backgroundColor, backgroundGradient, backgroundImageUrl, backgroundVideoUrl],
() => ({ backgroundType, backgroundColor, backgroundOpacity, backgroundGradient, backgroundImageUrl, backgroundVideoUrl }),
[backgroundType, backgroundColor, backgroundOpacity, backgroundGradient, backgroundImageUrl, backgroundVideoUrl],
);
useEffect(() => {
@@ -240,6 +241,7 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
const {
scheduleEntranceForIndices,
getEntrancePhase,
replayEntranceForPage,
reset: resetEntrance,
} = useBookEntranceController({ pages });
@@ -627,6 +629,7 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
key={page.id}
page={page}
entrancePhase={getEntrancePhase(page.id)}
onDeferredEntranceReady={replayEntranceForPage}
scale={contentScale}
pageWidth={pagePixelWidth}
pageHeight={pagePixelHeight}
@@ -113,6 +113,7 @@ const Magnifier = memo(
backgroundVideoUrl={background.backgroundVideoUrl}
useHardPage={useHardPage}
disableEntranceAnimations
hideMediaLoadingOverlay
showPaperShadow={false}
idSuffix="-magnifier"
/>
@@ -115,9 +115,31 @@ export function useBookEntranceController({ pages }: Options) {
setPlayingPageIds(new Set());
}, [clearPlayTimer]);
/** وقتی رسانهٔ صفحه دیرتر از زمان‌بندی انیمیشن لود شد، پخش را دوباره شروع می‌کند */
const replayEntranceForPage = useCallback(
(pageId: number) => {
if (!playedPageIdsRef.current.has(pageId)) return;
setPlayingPageIds((prev) => {
if (prev.has(pageId)) return prev;
const next = new Set(prev);
next.add(pageId);
return next;
});
clearPlayTimer(pageId);
playTimersRef.current.set(
pageId,
setTimeout(() => finishPlaying(pageId), MAX_ENTRANCE_PLAY_MS),
);
},
[clearPlayTimer, finishPlaying],
);
return {
scheduleEntranceForIndices,
getEntrancePhase,
replayEntranceForPage,
reset,
};
}
@@ -0,0 +1,69 @@
import { useEffect, useState } from 'react';
import type { PageData } from '../types';
import {
extractPageMediaAssets,
preloadPagesMedia,
} from '../utils/pageMediaUrls';
type PageBackground = Pick<
PageData,
'backgroundType' | 'backgroundImageUrl' | 'backgroundVideoUrl'
>;
function mergePageBackground(
page: PageData,
background?: Partial<PageBackground>,
): PageData {
if (!background) return page;
return {
...page,
backgroundType: background.backgroundType ?? page.backgroundType,
backgroundImageUrl:
background.backgroundImageUrl ?? page.backgroundImageUrl,
backgroundVideoUrl:
background.backgroundVideoUrl ?? page.backgroundVideoUrl,
};
}
export function usePageMediaReady(
page: PageData,
background?: Partial<PageBackground>,
enabled = true,
) {
const [isReady, setIsReady] = useState(false);
const mediaKey = enabled
? extractPageMediaAssets([mergePageBackground(page, background)])
.map((asset) => `${asset.kind}:${asset.url}`)
.sort()
.join('|')
: '';
useEffect(() => {
if (!enabled) {
setIsReady(true);
return;
}
const mergedPage = mergePageBackground(page, background);
const assets = extractPageMediaAssets([mergedPage]);
if (assets.length === 0) {
setIsReady(true);
return;
}
let cancelled = false;
setIsReady(false);
void preloadPagesMedia([mergedPage]).then(() => {
if (!cancelled) setIsReady(true);
});
return () => {
cancelled = true;
};
}, [enabled, page.id, mediaKey, background?.backgroundType, background?.backgroundImageUrl, background?.backgroundVideoUrl]);
return isReady;
}
+1
View File
@@ -7,6 +7,7 @@ export type PageData = {
elements: EditorObject[];
backgroundType?: "color" | "gradient" | "image" | "video";
backgroundColor?: string;
backgroundOpacity?: number;
backgroundGradient?: {
from: string;
to: string;
+3 -1
View File
@@ -7,6 +7,7 @@ type ViewerDataPage = {
name: string;
backgroundType?: "color" | "gradient" | "image" | "video";
backgroundColor?: string;
backgroundOpacity?: number;
backgroundGradient?: {
from: string;
to: string;
@@ -127,7 +128,7 @@ export function transformViewerDataToPages(data: ViewerData): PageData[] {
if (obj.type === "rectangle" && obj.shapeType) {
baseObject.shapeType = obj.shapeType as EditorObject["shapeType"];
}
if (obj.type === "rectangle" && obj.borderRadius !== undefined) {
if (obj.borderRadius !== undefined) {
baseObject.borderRadius = obj.borderRadius;
}
if (obj.type === "rectangle" && obj.blur !== undefined) {
@@ -203,6 +204,7 @@ export function transformViewerDataToPages(data: ViewerData): PageData[] {
elements: objects,
backgroundType: page.backgroundType,
backgroundColor: page.backgroundColor,
backgroundOpacity: page.backgroundOpacity,
backgroundGradient: page.backgroundGradient,
backgroundImageUrl: page.backgroundImageUrl,
backgroundVideoUrl: page.backgroundVideoUrl,
+2
View File
@@ -3,6 +3,7 @@ import type { PageData } from "@/pages/viewer/types";
export type PageBackgroundDefaults = {
backgroundType?: "color" | "gradient" | "image" | "video";
backgroundColor?: string;
backgroundOpacity?: number;
backgroundGradient?: { from: string; to: string; angle: number };
backgroundImageUrl?: string;
backgroundVideoUrl?: string;
@@ -13,6 +14,7 @@ export function resolvePageBackground(page: PageData, defaults: PageBackgroundDe
return {
backgroundType: page.backgroundType ?? defaults.backgroundType ?? "color",
backgroundColor: page.backgroundColor ?? defaults.backgroundColor ?? "#ffffff",
backgroundOpacity: page.backgroundOpacity ?? defaults.backgroundOpacity ?? 100,
backgroundGradient: page.backgroundGradient ?? defaults.backgroundGradient ?? { from: "#ffffff", to: "#ffffff", angle: 0 },
backgroundImageUrl: page.backgroundImageUrl ?? defaults.backgroundImageUrl ?? "",
backgroundVideoUrl: page.backgroundVideoUrl ?? defaults.backgroundVideoUrl ?? "",
+5 -1
View File
@@ -41,7 +41,11 @@ export function extractPageMediaAssets(
for (const element of page.elements) {
if (element.visible === false) continue;
if (element.type === 'image' || element.type === 'sticker') {
if (
element.type === 'image' ||
element.type === 'sticker' ||
element.type === 'document'
) {
addImageUrl(assets, element.imageUrl);
} else if (element.type === 'video') {
addVideoUrl(assets, element.videoUrl);