Compare commits

...

2 Commits

Author SHA1 Message Date
hamid zarghami 1ed198ca69 add max width in text
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-02 12:23:58 +03:30
hamid zarghami d94bf77522 background video 2026-07-02 12:08:29 +03:30
18 changed files with 287 additions and 43 deletions
@@ -32,6 +32,7 @@ function getPageDataFromEditorPage(page: Page): PageData | null {
backgroundColor: page.backgroundColor, backgroundColor: page.backgroundColor,
backgroundGradient: page.backgroundGradient, backgroundGradient: page.backgroundGradient,
backgroundImageUrl: page.backgroundImageUrl, backgroundImageUrl: page.backgroundImageUrl,
backgroundVideoUrl: page.backgroundVideoUrl,
objects: page.objects, objects: page.objects,
}, },
], ],
+17 -2
View File
@@ -76,6 +76,9 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => {
? currentPage.backgroundColor ? currentPage.backgroundColor
: '#ffffff'; : '#ffffff';
const bgGradient = currentPage?.backgroundGradient; const bgGradient = currentPage?.backgroundGradient;
const hasVideoBackground =
currentPage?.backgroundType === "video" &&
Boolean(currentPage.backgroundVideoUrl);
const [bgImage] = useImage( const [bgImage] = useImage(
currentPage?.backgroundType === 'image' currentPage?.backgroundType === 'image'
@@ -527,10 +530,20 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => {
<div className="absolute -top-6 -left-6 w-6 h-6 bg-slate-200 border border-slate-300" /> <div className="absolute -top-6 -left-6 w-6 h-6 bg-slate-200 border border-slate-300" />
<div <div
ref={stageWrapRef} ref={stageWrapRef}
className="shadow-lg" className="relative shadow-lg"
onDragOver={handleSidebarDragOver} onDragOver={handleSidebarDragOver}
onDrop={handleSidebarDrop} onDrop={handleSidebarDrop}
> >
{hasVideoBackground && (
<video
src={currentPage?.backgroundVideoUrl}
autoPlay
loop
muted
playsInline
className="absolute inset-0 w-full h-full object-cover pointer-events-none"
/>
)}
<Stage <Stage
ref={stageRef} ref={stageRef}
width={stageSize.width * finalScale} width={stageSize.width * finalScale}
@@ -549,6 +562,7 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => {
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
> >
<Layer listening={false}> <Layer listening={false}>
{!hasVideoBackground && (
<Rect <Rect
x={0} x={0}
y={0} y={0}
@@ -558,7 +572,8 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => {
{...backgroundGradientProps} {...backgroundGradientProps}
listening={false} listening={false}
/> />
{currentPage?.backgroundType === 'image' && bgImage && ( )}
{!hasVideoBackground && currentPage?.backgroundType === 'image' && bgImage && (
<KonvaImage <KonvaImage
image={bgImage} image={bgImage}
x={0} x={0}
@@ -10,6 +10,9 @@ function isPageEmptyForPreview(page: Page): boolean {
if (page.backgroundType === 'image' && page.backgroundImageUrl.trim() !== '') { if (page.backgroundType === 'image' && page.backgroundImageUrl.trim() !== '') {
return false return false
} }
if (page.backgroundType === 'video' && page.backgroundVideoUrl.trim() !== '') {
return false
}
if (page.backgroundType === 'gradient') return false if (page.backgroundType === 'gradient') return false
return true return true
} }
+71 -3
View File
@@ -27,6 +27,7 @@ const BACKGROUND_TYPE_OPTIONS: { value: BackgroundType; label: string }[] = [
{ value: 'color', label: 'رنگ' }, { value: 'color', label: 'رنگ' },
{ value: 'gradient', label: 'گرادیانت' }, { value: 'gradient', label: 'گرادیانت' },
{ value: 'image', label: 'تصویر' }, { value: 'image', label: 'تصویر' },
{ value: 'video', label: 'ویدیو' },
] ]
const SettingsPanel = () => { const SettingsPanel = () => {
@@ -38,7 +39,7 @@ const SettingsPanel = () => {
updateCurrentPageBackground, updateCurrentPageBackground,
} = useEditorStore() } = useEditorStore()
const colorInputRef = useRef<HTMLInputElement>(null) const colorInputRef = useRef<HTMLInputElement>(null)
const { mutateAsync: uploadFile, isPending: imageLoading } = 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'
@@ -48,6 +49,7 @@ const SettingsPanel = () => {
angle: 135, angle: 135,
} }
const backgroundImageUrl = currentPage?.backgroundImageUrl ?? '' const backgroundImageUrl = currentPage?.backgroundImageUrl ?? ''
const backgroundVideoUrl = currentPage?.backgroundVideoUrl ?? ''
const [uploadedPreviews, setUploadedPreviews] = useState<string[]>( const [uploadedPreviews, setUploadedPreviews] = useState<string[]>(
backgroundImageUrl ? [backgroundImageUrl] : [] backgroundImageUrl ? [backgroundImageUrl] : []
) )
@@ -66,6 +68,11 @@ const SettingsPanel = () => {
) )
}, [backgroundImageUrl]) }, [backgroundImageUrl])
useEffect(() => {
if (backgroundType !== 'video') return
setUploadedPreviews(backgroundVideoUrl ? [backgroundVideoUrl] : [])
}, [backgroundType, backgroundVideoUrl])
const handleImageFiles = async (files: File[]) => { const handleImageFiles = async (files: File[]) => {
if (files.length === 0) return if (files.length === 0) return
const file = files[files.length - 1] const file = files[files.length - 1]
@@ -83,10 +90,31 @@ const SettingsPanel = () => {
} }
} }
const handleVideoFiles = async (files: File[]) => {
if (files.length === 0) return
const file = files[files.length - 1]
if (!file) return
try {
const result = await uploadFile({ file })
const videoUrl = result?.data?.url
if (!videoUrl) return
updateCurrentPageBackground({ backgroundVideoUrl: videoUrl })
setUploadedPreviews([videoUrl])
} catch {
// TODO: show error toast
}
}
const handlePreviewChange = (previews: string[]) => { const handlePreviewChange = (previews: string[]) => {
setUploadedPreviews(previews) setUploadedPreviews(previews)
if (previews.length === 0) { if (previews.length === 0) {
if (backgroundType === 'image') {
updateCurrentPageBackground({ backgroundImageUrl: '' }) updateCurrentPageBackground({ backgroundImageUrl: '' })
} else if (backgroundType === 'video') {
updateCurrentPageBackground({ backgroundVideoUrl: '' })
}
} }
} }
@@ -149,7 +177,7 @@ const SettingsPanel = () => {
<h3 className="text-sm mb-4">پس زمینه</h3> <h3 className="text-sm mb-4">پس زمینه</h3>
{/* نوع پس‌زمینه */} {/* نوع پس‌زمینه */}
<div className="flex items-center gap-5 mb-4"> <div className="flex flex-wrap items-center gap-5 mb-4">
{BACKGROUND_TYPE_OPTIONS.map((opt) => ( {BACKGROUND_TYPE_OPTIONS.map((opt) => (
<label key={opt.value} className="flex items-center gap-1.5 cursor-pointer select-none"> <label key={opt.value} className="flex items-center gap-1.5 cursor-pointer select-none">
<span className="text-xs text-gray-600">{opt.label}</span> <span className="text-xs text-gray-600">{opt.label}</span>
@@ -166,6 +194,8 @@ const SettingsPanel = () => {
}, },
} }
: {}), : {}),
...(opt.value !== 'image' ? { backgroundImageUrl: '' } : {}),
...(opt.value !== 'video' ? { backgroundVideoUrl: '' } : {}),
}) })
} }
className={clx( className={clx(
@@ -301,7 +331,7 @@ const SettingsPanel = () => {
{/* ─── آپلود تصویر ─── */} {/* ─── آپلود تصویر ─── */}
{backgroundType === 'image' && ( {backgroundType === 'image' && (
<div> <div>
{imageLoading ? ( {mediaLoading ? (
<div className="flex flex-col items-center justify-center gap-3 py-8"> <div className="flex flex-col items-center justify-center gap-3 py-8">
<span className="h-6 w-6 animate-spin rounded-full border-2 border-gray-300 border-t-gray-800" /> <span className="h-6 w-6 animate-spin rounded-full border-2 border-gray-300 border-t-gray-800" />
<span className="text-xs text-gray-500">در حال آپلود...</span> <span className="text-xs text-gray-500">در حال آپلود...</span>
@@ -317,6 +347,44 @@ const SettingsPanel = () => {
)} )}
</div> </div>
)} )}
{backgroundType === 'video' && (
<div>
{mediaLoading ? (
<div className="flex flex-col items-center justify-center gap-3 py-8">
<span className="h-6 w-6 animate-spin rounded-full border-2 border-gray-300 border-t-gray-800" />
<span className="text-xs text-gray-500">در حال آپلود...</span>
</div>
) : (
<UploadBoxDraggble
label="ویدیوی پس‌زمینه را آپلود کنید"
onChange={handleVideoFiles}
isMultiple={false}
hidePreview
/>
)}
{backgroundVideoUrl && (
<div className="mt-3 space-y-2">
<video
src={backgroundVideoUrl}
controls
muted
className="w-full h-36 rounded-lg border border-border bg-black object-cover"
/>
<button
type="button"
onClick={() => {
updateCurrentPageBackground({ backgroundVideoUrl: '' })
setUploadedPreviews([])
}}
className="text-xs text-red-500 hover:text-red-600"
>
حذف ویدیو
</button>
</div>
)}
</div>
)}
</section> </section>
</div> </div>
) )
@@ -3,6 +3,7 @@ import Konva from "konva";
import { useEditorStore, type EditorObject } from "@/pages/editor/store/editorStore"; import { useEditorStore, type EditorObject } from "@/pages/editor/store/editorStore";
import { useTextDefaultsStore } from "@/pages/editor/store/textDefaultsStore"; import { useTextDefaultsStore } from "@/pages/editor/store/textDefaultsStore";
import { createDrawingObject } from "./drawingUtils"; import { createDrawingObject } from "./drawingUtils";
import { DEFAULT_TEXT_MAX_WIDTH_PX } from "@/pages/editor/utils/textStyle";
export const useDrawingHandlers = () => { export const useDrawingHandlers = () => {
const [isDrawing, setIsDrawing] = useState(false); const [isDrawing, setIsDrawing] = useState(false);
@@ -82,6 +83,7 @@ export const useDrawingHandlers = () => {
letterSpacing: defaults.letterSpacing, letterSpacing: defaults.letterSpacing,
wordSpacing: defaults.wordSpacing, wordSpacing: defaults.wordSpacing,
height: defaults.fontSize || 24, height: defaults.fontSize || 24,
textMaxWidth: DEFAULT_TEXT_MAX_WIDTH_PX,
}; };
useEditorStore.getState().commitObjectHistoryBeforeChange(); useEditorStore.getState().commitObjectHistoryBeforeChange();
addObject(newText); addObject(newText);
@@ -1,6 +1,7 @@
import type { EditorObject } from "../../../store/editorStore"; import type { EditorObject } from "../../../store/editorStore";
import Input from "@/components/Input"; import Input from "@/components/Input";
import ColorPicker from "@/components/ColorPicker"; import ColorPicker from "@/components/ColorPicker";
import { DEFAULT_TEXT_MAX_WIDTH_PX } from "@/pages/editor/utils/textStyle";
type TextSettingsProps = { type TextSettingsProps = {
selectedObject: EditorObject; selectedObject: EditorObject;
@@ -60,6 +61,18 @@ const TextSettings = ({ selectedObject, onUpdate }: TextSettingsProps) => {
}) })
} }
/> />
<Input
label="حداکثر عرض متن"
type="number"
value={selectedObject.textMaxWidth ?? DEFAULT_TEXT_MAX_WIDTH_PX}
step="1"
min="80"
onChange={(e) =>
onUpdate(selectedObject.id, {
textMaxWidth: Math.max(80, parseNumber(e.target.value) || DEFAULT_TEXT_MAX_WIDTH_PX),
})
}
/>
<div className="space-y-2"> <div className="space-y-2">
<p className="text-sm">چینش متن</p> <p className="text-sm">چینش متن</p>
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">
@@ -4,8 +4,10 @@ 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 {
DEFAULT_TEXT_MAX_WIDTH_PX,
getEffectiveTextWidth, getEffectiveTextWidth,
measureTextBlock, measureTextBlock,
resolveTextMaxWidth,
usesWrappedLayout, usesWrappedLayout,
} from "@/pages/editor/utils/textStyle"; } from "@/pages/editor/utils/textStyle";
@@ -58,6 +60,7 @@ const TextShape = ({
const shapeRef = useRef<Konva.Text>(null); const shapeRef = useRef<Konva.Text>(null);
const groupRef = useRef<Konva.Group>(null); const groupRef = useRef<Konva.Group>(null);
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [maxTextWidth, setMaxTextWidth] = useState<number>(DEFAULT_TEXT_MAX_WIDTH_PX);
const fontSize = obj.fontSize || 24; const fontSize = obj.fontSize || 24;
const allowWrap = usesWrappedLayout( const allowWrap = usesWrappedLayout(
obj.text, obj.text,
@@ -86,6 +89,7 @@ const TextShape = ({
const groupNode = groupRef.current; const groupNode = groupRef.current;
if (!textNode || !groupNode) return; if (!textNode || !groupNode) return;
if (groupNode.scaleX() !== 1 || groupNode.scaleY() !== 1) return; if (groupNode.scaleX() !== 1 || groupNode.scaleY() !== 1) return;
const stage = textNode.getStage();
textNode._setTextData(); textNode._setTextData();
@@ -110,6 +114,14 @@ const TextShape = ({
lineHeight: obj.lineHeight ?? 1.2, lineHeight: obj.lineHeight ?? 1.2,
}); });
const konvaWidth = Math.max(1, Math.ceil(rw)); const konvaWidth = Math.max(1, Math.ceil(rw));
const resolvedMaxWidth = resolveTextMaxWidth({
anchorX: obj.x || 0,
pageWidth: stage?.width(),
defaultMaxWidth: obj.textMaxWidth ?? DEFAULT_TEXT_MAX_WIDTH_PX,
});
if (Math.abs(resolvedMaxWidth - maxTextWidth) > 1) {
setMaxTextWidth(resolvedMaxWidth);
}
const nextWidth = getEffectiveTextWidth( const nextWidth = getEffectiveTextWidth(
konvaWidth, konvaWidth,
obj.text, obj.text,
@@ -122,16 +134,33 @@ const TextShape = ({
}, },
!allowWrap, !allowWrap,
) ?? konvaWidth; ) ?? konvaWidth;
const clampedWidth = Math.min(nextWidth, resolvedMaxWidth);
const forcedWrapByWidth = clampedWidth < nextWidth - 1;
let measuredHeight = rh;
if (forcedWrapByWidth) {
const wrappedMeasureNode = textNode.clone({
width: clampedWidth,
wrap: "word",
x: 0,
y: 0,
});
wrappedMeasureNode._setTextData();
const { height: wrappedHeight } = wrappedMeasureNode.getClientRect({
skipTransform: true,
});
wrappedMeasureNode.destroy();
measuredHeight = Math.max(measuredHeight, wrappedHeight);
}
const nextHeight = Math.max( const nextHeight = Math.max(
1, 1,
Math.ceil(rh), Math.ceil(measuredHeight),
cssMeasured.height, cssMeasured.height,
); );
const widthChanged = Math.abs((obj.width || 0) - nextWidth) > 1; const widthChanged = Math.abs((obj.width || 0) - clampedWidth) > 1;
const heightChanged = Math.abs((obj.height || 0) - nextHeight) > 1; const heightChanged = Math.abs((obj.height || 0) - nextHeight) > 1;
if (widthChanged || heightChanged) { if (widthChanged || heightChanged) {
onUpdate(obj.id, { width: nextWidth, height: nextHeight }); onUpdate(obj.id, { width: clampedWidth, height: nextHeight });
} }
textNode.getLayer()?.batchDraw(); textNode.getLayer()?.batchDraw();
}; };
@@ -191,7 +220,10 @@ const TextShape = ({
obj.lineHeight, obj.lineHeight,
obj.width, obj.width,
obj.height, obj.height,
obj.x,
obj.textMaxWidth,
allowWrap, allowWrap,
maxTextWidth,
onUpdate, onUpdate,
]); ]);
@@ -199,6 +231,8 @@ const TextShape = ({
const konvaFontStyle = useMemo(() => getKonvaFontStyle(obj.fontWeight), [obj.fontWeight]); const konvaFontStyle = useMemo(() => getKonvaFontStyle(obj.fontWeight), [obj.fontWeight]);
const fillColor = useMemo(() => getColorWithOpacity(obj.fill, obj.opacity), [obj.fill, obj.opacity]); const fillColor = useMemo(() => getColorWithOpacity(obj.fill, obj.opacity), [obj.fill, obj.opacity]);
const textAlign = obj.textAlign ?? "right"; const textAlign = obj.textAlign ?? "right";
const wrapByMaxWidth = (obj.width ?? 0) >= maxTextWidth - 1;
const shouldWrap = allowWrap || wrapByMaxWidth;
const handleDblClick = () => { const handleDblClick = () => {
if (!shapeRef.current || !groupRef.current) return; if (!shapeRef.current || !groupRef.current) return;
@@ -238,7 +272,8 @@ const TextShape = ({
position: "absolute", position: "absolute",
top: `${areaPosition.y}px`, top: `${areaPosition.y}px`,
left: `${areaPosition.x}px`, left: `${areaPosition.x}px`,
width: `${box.width}px`, width: `${Math.min(box.width, maxTextWidth * stageScale)}px`,
maxWidth: `${maxTextWidth * stageScale}px`,
minHeight: `${box.height}px`, minHeight: `${box.height}px`,
fontSize: `${(obj.fontSize || 24) * stageScale}px`, fontSize: `${(obj.fontSize || 24) * stageScale}px`,
fontFamily: fontFamily, fontFamily: fontFamily,
@@ -358,9 +393,9 @@ const TextShape = ({
fontStyle={konvaFontStyle} fontStyle={konvaFontStyle}
letterSpacing={obj.letterSpacing ?? 0} letterSpacing={obj.letterSpacing ?? 0}
lineHeight={obj.lineHeight ?? 1.2} lineHeight={obj.lineHeight ?? 1.2}
width={allowWrap ? obj.width || undefined : undefined} width={shouldWrap ? obj.width || maxTextWidth : undefined}
align={textAlign} align={textAlign}
wrap={allowWrap ? "word" : "none"} wrap={shouldWrap ? "word" : "none"}
/> />
</Group> </Group>
); );
@@ -65,6 +65,7 @@ export const createInitialPage = (name: string): Page => ({
angle: 135, angle: 135,
}, },
backgroundImageUrl: "", backgroundImageUrl: "",
backgroundVideoUrl: "",
}); });
export const getDefaultPageName = (index: number): string => export const getDefaultPageName = (index: number): string =>
+5
View File
@@ -70,6 +70,7 @@ type PageBackgroundSettings = Pick<
| "backgroundColor" | "backgroundColor"
| "backgroundGradient" | "backgroundGradient"
| "backgroundImageUrl" | "backgroundImageUrl"
| "backgroundVideoUrl"
>; >;
type EditorStoreType = { type EditorStoreType = {
@@ -191,6 +192,7 @@ const DEFAULT_DOCUMENT_SETTINGS: DocumentSettings = {
angle: 135, angle: 135,
}, },
backgroundImageUrl: "", backgroundImageUrl: "",
backgroundVideoUrl: "",
}; };
export const useEditorStore = create<EditorStoreType>((set, get) => { export const useEditorStore = create<EditorStoreType>((set, get) => {
@@ -858,6 +860,7 @@ export const useEditorStore = create<EditorStoreType>((set, get) => {
backgroundColor: pageToDuplicate.backgroundColor, backgroundColor: pageToDuplicate.backgroundColor,
backgroundGradient: pageToDuplicate.backgroundGradient, backgroundGradient: pageToDuplicate.backgroundGradient,
backgroundImageUrl: pageToDuplicate.backgroundImageUrl, backgroundImageUrl: pageToDuplicate.backgroundImageUrl,
backgroundVideoUrl: pageToDuplicate.backgroundVideoUrl,
}; };
set((state) => ({ set((state) => ({
@@ -1058,6 +1061,8 @@ export const useEditorStore = create<EditorStoreType>((set, get) => {
}, },
backgroundImageUrl: backgroundImageUrl:
page.backgroundImageUrl ?? fallbackSettings.backgroundImageUrl ?? "", page.backgroundImageUrl ?? fallbackSettings.backgroundImageUrl ?? "",
backgroundVideoUrl:
page.backgroundVideoUrl ?? fallbackSettings.backgroundVideoUrl ?? "",
})); }));
const firstPage = normalizedPages[0]; const firstPage = normalizedPages[0];
const currentSettings = get().documentSettings; const currentSettings = get().documentSettings;
+5 -1
View File
@@ -61,6 +61,8 @@ export type EditorObject = {
stroke?: string; stroke?: string;
strokeWidth?: number; strokeWidth?: number;
text?: string; text?: string;
/** حداکثر عرض باکس متن (px) برای wrap پیش‌فرض متن‌های بلند */
textMaxWidth?: number;
fontSize?: number; fontSize?: number;
lineHeight?: number; lineHeight?: number;
textAlign?: "left" | "center" | "right"; textAlign?: "left" | "center" | "right";
@@ -119,10 +121,11 @@ export type Page = {
backgroundColor: string; backgroundColor: string;
backgroundGradient?: LinearGradient; backgroundGradient?: LinearGradient;
backgroundImageUrl: string; backgroundImageUrl: string;
backgroundVideoUrl: string;
}; };
export type DisplayStyle = "single" | "double"; export type DisplayStyle = "single" | "double";
export type BackgroundType = "color" | "gradient" | "image"; export type BackgroundType = "color" | "gradient" | "image" | "video";
export type DocumentSettings = { export type DocumentSettings = {
displayStyle: DisplayStyle; displayStyle: DisplayStyle;
@@ -134,6 +137,7 @@ export type DocumentSettings = {
backgroundColor: string; backgroundColor: string;
backgroundGradient?: LinearGradient; backgroundGradient?: LinearGradient;
backgroundImageUrl: string; backgroundImageUrl: string;
backgroundVideoUrl: string;
}; };
/** تصویر آپلودشده در گالری کاتالوگ (مشترک بین همهٔ صفحات) */ /** تصویر آپلودشده در گالری کاتالوگ (مشترک بین همهٔ صفحات) */
+33
View File
@@ -63,6 +63,39 @@ export const usesWrappedLayout = (
/** Extra px so HTML layout does not wrap tighter than Konva/canvas metrics. */ /** Extra px so HTML layout does not wrap tighter than Konva/canvas metrics. */
export const SINGLE_LINE_WIDTH_SLACK_PX = 12; export const SINGLE_LINE_WIDTH_SLACK_PX = 12;
export const DEFAULT_TEXT_MAX_WIDTH_PX = 520;
const MIN_TEXT_MAX_WIDTH_PX = 80;
/**
* Maximum width for text boxes.
* - never larger than page/view bounds
* - has a sane default for long single-line text
*/
export const resolveTextMaxWidth = (args: {
anchorX: number;
pageWidth?: number;
defaultMaxWidth?: number;
edgePadding?: number;
}): number => {
const {
anchorX,
pageWidth,
defaultMaxWidth = DEFAULT_TEXT_MAX_WIDTH_PX,
edgePadding = 8,
} = args;
const fromAnchor = Math.floor(anchorX - edgePadding);
const fromPage =
pageWidth !== undefined
? Math.floor(Math.min(anchorX - edgePadding, pageWidth - edgePadding))
: fromAnchor;
const hardLimit = Math.max(MIN_TEXT_MAX_WIDTH_PX, fromPage);
return Math.max(
MIN_TEXT_MAX_WIDTH_PX,
Math.min(defaultMaxWidth, hardLimit),
);
};
export type TextMeasureOptions = { export type TextMeasureOptions = {
fontSize: number; fontSize: number;
+58 -6
View File
@@ -5,8 +5,10 @@ import { toCssLinearGradient, getSvgGradientEndpoints } from '@/pages/editor/uti
import { getCustomShapePointBounds } from '@/pages/editor/utils/customShape'; import { getCustomShapePointBounds } from '@/pages/editor/utils/customShape';
import { getFontFamily } from '@/pages/editor/utils/fontFamily'; import { getFontFamily } from '@/pages/editor/utils/fontFamily';
import { import {
DEFAULT_TEXT_MAX_WIDTH_PX,
getCssFontWeight, getCssFontWeight,
getViewerTextLayout, getViewerTextLayout,
resolveTextMaxWidth,
usesWrappedLayout, usesWrappedLayout,
} from '@/pages/editor/utils/textStyle'; } from '@/pages/editor/utils/textStyle';
import '@/pages/viewer/styles/entranceAnimations.css'; import '@/pages/viewer/styles/entranceAnimations.css';
@@ -48,7 +50,7 @@ type BookPageProps = {
pageWidth?: number; pageWidth?: number;
pageHeight?: number; pageHeight?: number;
onLinkClick?: (linkUrl: string) => void; onLinkClick?: (linkUrl: string) => void;
backgroundType?: 'color' | 'gradient' | 'image'; backgroundType?: 'color' | 'gradient' | 'image' | 'video';
backgroundColor?: string; backgroundColor?: string;
backgroundGradient?: { backgroundGradient?: {
from: string; from: string;
@@ -56,6 +58,7 @@ type BookPageProps = {
angle: number; angle: number;
}; };
backgroundImageUrl?: string; backgroundImageUrl?: string;
backgroundVideoUrl?: string;
/** فاز انیمیشن ورود — از BookViewer کنترل می‌شود */ /** فاز انیمیشن ورود — از BookViewer کنترل می‌شود */
entrancePhase?: EntrancePhase; entrancePhase?: EntrancePhase;
/** در پیش‌نمایش کاتالوگ انیمی션 ورود غیرفعال باشد */ /** در پیش‌نمایش کاتالوگ انیمی션 ورود غیرفعال باشد */
@@ -82,7 +85,7 @@ type BookPageProps = {
* نیاز به forwardRef دارد تا react-pageflip بتواند ref را مدیریت کند * نیاز به forwardRef دارد تا react-pageflip بتواند ref را مدیریت کند
*/ */
const BookPage = forwardRef<HTMLDivElement, BookPageProps>( const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
({ page, scale = 1, pageWidth, pageHeight, onLinkClick, backgroundType, backgroundColor, backgroundGradient, backgroundImageUrl, entrancePhase = 'idle', disableEntranceAnimations = false, staticMedia = false, showPaperShadow = true, useHardPage = false, idSuffix = '' }, ref) => { ({ page, scale = 1, pageWidth, pageHeight, onLinkClick, backgroundType, backgroundColor, backgroundGradient, backgroundImageUrl, backgroundVideoUrl, entrancePhase = 'idle', disableEntranceAnimations = false, staticMedia = false, showPaperShadow = true, useHardPage = false, idSuffix = '' }, ref) => {
const phase: EntrancePhase = disableEntranceAnimations ? 'settled' : entrancePhase; const phase: EntrancePhase = disableEntranceAnimations ? 'settled' : entrancePhase;
// تابع برای تبدیل opacity به رنگ (مثل editor) // تابع برای تبدیل opacity به رنگ (مثل editor)
// در editor، getColorWithOpacity انتظار opacity 0-100 دارد // در editor، getColorWithOpacity انتظار opacity 0-100 دارد
@@ -198,14 +201,39 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
measureOpts, measureOpts,
wrapped, wrapped,
}); });
const maxWidthPx = Math.ceil(
resolveTextMaxWidth({
anchorX: (obj.x || 0) * scale,
pageWidth: pageWidth ?? 794 * scale,
defaultMaxWidth: (obj.textMaxWidth ?? DEFAULT_TEXT_MAX_WIDTH_PX) * scale,
}),
);
const wrappedWidthPx =
obj.width !== undefined ? Math.ceil(obj.width * scale) : undefined;
const wrapByMaxWidth =
(wrapped ? wrappedWidthPx : textLayout.widthPx) !== undefined &&
(wrapped ? wrappedWidthPx : textLayout.widthPx)! > maxWidthPx;
const finalWrapped = wrapped || wrapByMaxWidth;
const resolvedWidthPx = finalWrapped
? (wrappedWidthPx !== undefined
? Math.min(wrappedWidthPx, maxWidthPx)
: (textLayout.widthPx !== undefined
? Math.min(textLayout.widthPx, maxWidthPx)
: undefined))
: textLayout.widthPx;
const anchorRightPx = Math.round((obj.x || 0) * scale);
const resolvedLeftPx =
resolvedWidthPx !== undefined
? anchorRightPx - resolvedWidthPx
: textLayout.leftPx;
return ( return (
<div <div
key={obj.id || index} key={obj.id || index}
style={applyStyle( style={applyStyle(
{ {
...textBaseStyle, ...textBaseStyle,
left: `${textLayout.leftPx}px`, left: `${resolvedLeftPx}px`,
width: textLayout.widthPx !== undefined ? `${textLayout.widthPx}px` : 'max-content', width: resolvedWidthPx !== undefined ? `${resolvedWidthPx}px` : 'max-content',
fontSize: `${fontSize * scale}px`, fontSize: `${fontSize * scale}px`,
fontFamily: getFontFamily(obj.fontFamily), fontFamily: getFontFamily(obj.fontFamily),
fontWeight: getCssFontWeight(obj.fontWeight), fontWeight: getCssFontWeight(obj.fontWeight),
@@ -213,10 +241,11 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
textAlign: textLayout.textAlign, textAlign: textLayout.textAlign,
direction: 'rtl', direction: 'rtl',
color: getColorWithOpacity(obj.fill, obj.opacity), color: getColorWithOpacity(obj.fill, obj.opacity),
whiteSpace: wrapped ? 'pre-wrap' : 'nowrap', whiteSpace: finalWrapped ? 'pre-wrap' : 'nowrap',
overflowWrap: wrapped ? 'break-word' : 'normal', overflowWrap: finalWrapped ? 'break-word' : 'normal',
letterSpacing: obj.letterSpacing ? `${obj.letterSpacing * scale}px` : undefined, letterSpacing: obj.letterSpacing ? `${obj.letterSpacing * scale}px` : undefined,
boxSizing: 'content-box', boxSizing: 'content-box',
zIndex: index,
transform: textLayout.transform, transform: textLayout.transform,
transformOrigin: textLayout.transformOrigin, transformOrigin: textLayout.transformOrigin,
}, },
@@ -933,6 +962,7 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
const effectiveBackgroundColor = backgroundColor ?? page.backgroundColor ?? '#ffffff'; const effectiveBackgroundColor = backgroundColor ?? page.backgroundColor ?? '#ffffff';
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 bgStyle: React.CSSProperties = const bgStyle: React.CSSProperties =
effectiveBackgroundType === 'image' && effectiveBackgroundImageUrl effectiveBackgroundType === 'image' && effectiveBackgroundImageUrl
@@ -968,6 +998,7 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
}} }}
> >
{!useHardPage && ( {!useHardPage && (
<>
<div <div
aria-hidden aria-hidden
style={{ style={{
@@ -978,6 +1009,27 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
zIndex: 0, zIndex: 0,
}} }}
/> />
{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 }}> <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))}
+4 -2
View File
@@ -185,9 +185,10 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
const backgroundColor = documentSettings?.backgroundColor ?? "#ffffff"; const backgroundColor = documentSettings?.backgroundColor ?? "#ffffff";
const backgroundGradient = documentSettings?.backgroundGradient; const backgroundGradient = documentSettings?.backgroundGradient;
const backgroundImageUrl = documentSettings?.backgroundImageUrl ?? ""; const backgroundImageUrl = documentSettings?.backgroundImageUrl ?? "";
const backgroundVideoUrl = documentSettings?.backgroundVideoUrl ?? "";
const pageBackgroundDefaults = useMemo( const pageBackgroundDefaults = useMemo(
() => ({ backgroundType, backgroundColor, backgroundGradient, backgroundImageUrl }), () => ({ backgroundType, backgroundColor, backgroundGradient, backgroundImageUrl, backgroundVideoUrl }),
[backgroundType, backgroundColor, backgroundGradient, backgroundImageUrl], [backgroundType, backgroundColor, backgroundGradient, backgroundImageUrl, backgroundVideoUrl],
); );
useEffect(() => { useEffect(() => {
@@ -610,6 +611,7 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
backgroundColor={background.backgroundColor} backgroundColor={background.backgroundColor}
backgroundGradient={background.backgroundGradient} backgroundGradient={background.backgroundGradient}
backgroundImageUrl={background.backgroundImageUrl} backgroundImageUrl={background.backgroundImageUrl}
backgroundVideoUrl={background.backgroundVideoUrl}
useHardPage={legacyIOS} useHardPage={legacyIOS}
/> />
); );
@@ -94,6 +94,7 @@ const Magnifier = memo(
backgroundColor={background.backgroundColor} backgroundColor={background.backgroundColor}
backgroundGradient={background.backgroundGradient} backgroundGradient={background.backgroundGradient}
backgroundImageUrl={background.backgroundImageUrl} backgroundImageUrl={background.backgroundImageUrl}
backgroundVideoUrl={background.backgroundVideoUrl}
useHardPage={useHardPage} useHardPage={useHardPage}
disableEntranceAnimations disableEntranceAnimations
showPaperShadow={false} showPaperShadow={false}
+2 -1
View File
@@ -5,7 +5,7 @@ export type PageData = {
width: number; width: number;
height: number; height: number;
elements: EditorObject[]; elements: EditorObject[];
backgroundType?: "color" | "gradient" | "image"; backgroundType?: "color" | "gradient" | "image" | "video";
backgroundColor?: string; backgroundColor?: string;
backgroundGradient?: { backgroundGradient?: {
from: string; from: string;
@@ -13,5 +13,6 @@ export type PageData = {
angle: number; angle: number;
}; };
backgroundImageUrl?: string; backgroundImageUrl?: string;
backgroundVideoUrl?: string;
}; };
+3 -1
View File
@@ -5,7 +5,7 @@ import type { PageData } from "../types";
type ViewerDataPage = { type ViewerDataPage = {
id: string; id: string;
name: string; name: string;
backgroundType?: "color" | "gradient" | "image"; backgroundType?: "color" | "gradient" | "image" | "video";
backgroundColor?: string; backgroundColor?: string;
backgroundGradient?: { backgroundGradient?: {
from: string; from: string;
@@ -13,6 +13,7 @@ type ViewerDataPage = {
angle: number; angle: number;
}; };
backgroundImageUrl?: string; backgroundImageUrl?: string;
backgroundVideoUrl?: string;
objects: Array<{ objects: Array<{
id: string; id: string;
type: string; type: string;
@@ -200,6 +201,7 @@ export function transformViewerDataToPages(data: ViewerData): PageData[] {
backgroundColor: page.backgroundColor, backgroundColor: page.backgroundColor,
backgroundGradient: page.backgroundGradient, backgroundGradient: page.backgroundGradient,
backgroundImageUrl: page.backgroundImageUrl, backgroundImageUrl: page.backgroundImageUrl,
backgroundVideoUrl: page.backgroundVideoUrl,
}; };
}); });
} }
+3 -1
View File
@@ -1,10 +1,11 @@
import type { PageData } from "@/pages/viewer/types"; import type { PageData } from "@/pages/viewer/types";
export type PageBackgroundDefaults = { export type PageBackgroundDefaults = {
backgroundType?: "color" | "gradient" | "image"; backgroundType?: "color" | "gradient" | "image" | "video";
backgroundColor?: string; backgroundColor?: string;
backgroundGradient?: { from: string; to: string; angle: number }; backgroundGradient?: { from: string; to: string; angle: number };
backgroundImageUrl?: string; backgroundImageUrl?: string;
backgroundVideoUrl?: string;
}; };
/** ترکیب پس‌زمینهٔ اختصاصی صفحه با مقادیر پیش‌فرض سند (همان قاعدهٔ استفاده‌شده در BookViewer) */ /** ترکیب پس‌زمینهٔ اختصاصی صفحه با مقادیر پیش‌فرض سند (همان قاعدهٔ استفاده‌شده در BookViewer) */
@@ -14,5 +15,6 @@ export function resolvePageBackground(page: PageData, defaults: PageBackgroundDe
backgroundColor: page.backgroundColor ?? defaults.backgroundColor ?? "#ffffff", backgroundColor: page.backgroundColor ?? defaults.backgroundColor ?? "#ffffff",
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 ?? "",
}; };
} }
+4
View File
@@ -34,6 +34,8 @@ export function extractPageMediaAssets(
for (const page of pages) { for (const page of pages) {
if (page.backgroundType === 'image') { if (page.backgroundType === 'image') {
addImageUrl(assets, page.backgroundImageUrl); addImageUrl(assets, page.backgroundImageUrl);
} else if (page.backgroundType === 'video') {
addVideoUrl(assets, page.backgroundVideoUrl);
} }
for (const element of page.elements) { for (const element of page.elements) {
@@ -51,6 +53,8 @@ export function extractPageMediaAssets(
if (documentSettings?.backgroundType === 'image') { if (documentSettings?.backgroundType === 'image') {
addImageUrl(assets, documentSettings.backgroundImageUrl); addImageUrl(assets, documentSettings.backgroundImageUrl);
} else if (documentSettings?.backgroundType === 'video') {
addVideoUrl(assets, documentSettings.backgroundVideoUrl);
} }
return Array.from(assets.values()); return Array.from(assets.values());