From 33cce9306478cbeb1fb2c626234e84356cac876d Mon Sep 17 00:00:00 2001 From: hamid zarghami Date: Wed, 8 Jul 2026 15:50:21 +0330 Subject: [PATCH] bg opcity --- package-lock.json | 11 + package.json | 1 + src/components/ColorPicker.tsx | 321 ++++++++++-------- src/pages/editor/components/EditorCanvas.tsx | 8 +- src/pages/editor/components/SettingsPanel.tsx | 32 +- src/pages/editor/store/editorStore.helpers.ts | 1 + src/pages/editor/store/editorStore.ts | 5 + src/pages/editor/store/editorStore.types.ts | 2 + src/pages/viewer/components/BookPage.tsx | 6 +- src/pages/viewer/components/BookViewer.tsx | 5 +- src/pages/viewer/types/index.ts | 1 + src/pages/viewer/utils/dataTransformer.ts | 2 + src/pages/viewer/utils/pageBackground.ts | 2 + 13 files changed, 230 insertions(+), 167 deletions(-) diff --git a/package-lock.json b/package-lock.json index bdcdb22..dacc919 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 21b5015..0fe5cb8 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/ColorPicker.tsx b/src/components/ColorPicker.tsx index 2415ec0..cb9c7d0 100644 --- a/src/components/ColorPicker.tsx +++ b/src/components/ColorPicker.tsx @@ -1,175 +1,206 @@ -import { type FC, type InputHTMLAttributes, useState, useRef, useEffect } from 'react' -import { clx } from '../helpers/utils' -import Error from './Error' -import Input from './Input' +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 - opacity?: number - onOpacityChange?: (value: number) => void - opacityLabel?: string -} & Omit, '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, "onChange" | "value">; const ColorPicker: FC = (props: Props) => { - const [color, setColor] = useState(props.value || '#000000') - const [opacityDisplay, setOpacityDisplay] = useState(`${props.opacity ?? 100}`) - const colorInputRef = useRef(null) - const pickerOpenGuardRef = useRef(false) - const showOpacity = props.onOpacityChange !== undefined + const [color, setColor] = useState(props.value || "#000000"); + const [opacityDisplay, setOpacityDisplay] = useState(`${props.opacity ?? 100}`); + const [isOpen, setIsOpen] = useState(false); + const wrapperRef = useRef(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', - ) + const clampOpacity = (rawValue: number): number => Math.min(100, Math.max(0, rawValue)); - const handleColorChange = (event: React.ChangeEvent) => { - 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) => { - 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"); - useEffect(() => { - if (props.opacity !== undefined) { - setOpacityDisplay(`${props.opacity}`) - } - }, [props.opacity]) + const handleColorChange = (newColor: string) => { + const normalized = normalizeHex(newColor); + setColor(normalized); + props.onChange?.(normalized); + }; - const handleOpacityChange = (rawValue: string) => { - setOpacityDisplay(rawValue) - const numValue = parseInt(rawValue, 10) - if (!Number.isNaN(numValue) && numValue >= 0 && numValue <= 100) { - props.onOpacityChange?.(numValue) - } + const togglePicker = () => { + if (props.readOnly) return; + setIsOpen((prev) => !prev); + }; + + useEffect(() => { + if (props.value !== undefined) { + setColor(normalizeHex(props.value)); } + }, [props.value]); - const handleOpacityBlur = (rawValue: string) => { - const numValue = parseInt(rawValue, 10) || 0 - const clampedValue = Math.min(100, Math.max(0, numValue)) - setOpacityDisplay(`${clampedValue}`) - props.onOpacityChange?.(clampedValue) + useEffect(() => { + if (props.opacity !== undefined) { + setOpacityDisplay(`${props.opacity}`); } + }, [props.opacity]); - const colorField = ( -
{ + 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 = ( +
+ + +
+ handleColorChange(event.target.value)} + placeholder="#000000" + readOnly={props.readOnly} + title={!props.readOnly ? "کلیک برای باز کردن انتخابگر رنگ" : undefined} + onClick={togglePicker} + className={`${inputClass} ${props.readOnly ? "" : "cursor-pointer"}`} + /> + + - - -
- - - - {/* */} - + - - - + + + - + {isOpen && !props.readOnly && ( +
+
+ {showOpacity ? ( + + ) : ( + + )}
-
- ) - - return ( -
- {colorField} - {showOpacity && ( -
- handleOpacityChange(e.target.value)} - onBlur={(e) => handleOpacityBlur(e.target.value)} - min={0} - max={100} - /> +
+ + {showOpacity && ( +
+ 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 ?? "شفافیت"} + />
+ )} +
+ {showOpacity && ( +
+ {props.opacityLabel ?? "شفافیت"}: {opacityDisplay}% +
)} +
+ )} +
+
+ ); - {props.error_text && ( - - )} -
- ) -} + return ( +
+ {colorField} -export default ColorPicker + {props.error_text && } +
+ ); +}; +export default ColorPicker; diff --git a/src/pages/editor/components/EditorCanvas.tsx b/src/pages/editor/components/EditorCanvas.tsx index 0daa528..5925bc3 100644 --- a/src/pages/editor/components/EditorCanvas.tsx +++ b/src/pages/editor/components/EditorCanvas.tsx @@ -27,6 +27,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 +73,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; diff --git a/src/pages/editor/components/SettingsPanel.tsx b/src/pages/editor/components/SettingsPanel.tsx index dd3b794..a51a340 100644 --- a/src/pages/editor/components/SettingsPanel.tsx +++ b/src/pages/editor/components/SettingsPanel.tsx @@ -1,10 +1,9 @@ -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' @@ -39,11 +38,11 @@ const SettingsPanel = () => { updateDocumentSettings, updateCurrentPageBackground, } = useEditorStore() - const colorInputRef = useRef(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', @@ -234,30 +233,29 @@ const SettingsPanel = () => { /> ))} - {/* Color Wheel */} - +
{/* نمایش رنگ انتخاب‌شده */}
{backgroundColor} + {backgroundOpacity}%
)} diff --git a/src/pages/editor/store/editorStore.helpers.ts b/src/pages/editor/store/editorStore.helpers.ts index fd7bcad..2c83d14 100644 --- a/src/pages/editor/store/editorStore.helpers.ts +++ b/src/pages/editor/store/editorStore.helpers.ts @@ -60,6 +60,7 @@ export const createInitialPage = (name: string): Page => ({ guides: [], backgroundType: "color", backgroundColor: "#ffffff", + backgroundOpacity: 100, backgroundGradient: { from: "#ffffff", to: "#e2e8f0", diff --git a/src/pages/editor/store/editorStore.ts b/src/pages/editor/store/editorStore.ts index bad9b00..09d4aca 100644 --- a/src/pages/editor/store/editorStore.ts +++ b/src/pages/editor/store/editorStore.ts @@ -68,6 +68,7 @@ type PageBackgroundSettings = Pick< Page, | "backgroundType" | "backgroundColor" + | "backgroundOpacity" | "backgroundGradient" | "backgroundImageUrl" | "backgroundVideoUrl" @@ -191,6 +192,7 @@ const DEFAULT_DOCUMENT_SETTINGS: DocumentSettings = { smartGuide: true, backgroundType: "color", backgroundColor: "#ffffff", + backgroundOpacity: 100, backgroundGradient: { from: "#ffffff", to: "#e2e8f0", @@ -887,6 +889,7 @@ export const useEditorStore = create((set, get) => { guides: [...pageToDuplicate.guides], backgroundType: pageToDuplicate.backgroundType, backgroundColor: pageToDuplicate.backgroundColor, + backgroundOpacity: pageToDuplicate.backgroundOpacity ?? 100, backgroundGradient: pageToDuplicate.backgroundGradient, backgroundImageUrl: pageToDuplicate.backgroundImageUrl, backgroundVideoUrl: pageToDuplicate.backgroundVideoUrl, @@ -1082,6 +1085,8 @@ export const useEditorStore = create((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", diff --git a/src/pages/editor/store/editorStore.types.ts b/src/pages/editor/store/editorStore.types.ts index 7eb46ac..59568db 100644 --- a/src/pages/editor/store/editorStore.types.ts +++ b/src/pages/editor/store/editorStore.types.ts @@ -123,6 +123,7 @@ export type Page = { guides: PageGuide[]; backgroundType: BackgroundType; backgroundColor: string; + backgroundOpacity?: number; backgroundGradient?: LinearGradient; backgroundImageUrl: string; backgroundVideoUrl: string; @@ -139,6 +140,7 @@ export type DocumentSettings = { smartGuide: boolean; backgroundType: BackgroundType; backgroundColor: string; + backgroundOpacity?: number; backgroundGradient?: LinearGradient; backgroundImageUrl: string; backgroundVideoUrl: string; diff --git a/src/pages/viewer/components/BookPage.tsx b/src/pages/viewer/components/BookPage.tsx index 34a8121..5b6d9e7 100644 --- a/src/pages/viewer/components/BookPage.tsx +++ b/src/pages/viewer/components/BookPage.tsx @@ -57,6 +57,7 @@ type BookPageProps = { onLinkClick?: (linkUrl: string) => void; backgroundType?: 'color' | 'gradient' | 'image' | 'video'; backgroundColor?: string; + backgroundOpacity?: number; backgroundGradient?: { from: string; to: string; @@ -94,9 +95,10 @@ type BookPageProps = { * نیاز به forwardRef دارد تا react-pageflip بتواند ref را مدیریت کند */ const BookPage = memo(forwardRef( - ({ page, scale = 1, pageWidth, pageHeight, onLinkClick, backgroundType, backgroundColor, backgroundGradient, backgroundImageUrl, backgroundVideoUrl, entrancePhase = 'idle', disableEntranceAnimations = false, onDeferredEntranceReady, hideMediaLoadingOverlay = false, staticMedia = false, showPaperShadow = true, useHardPage = false, idSuffix = '' }, ref) => { + ({ page, scale = 1, pageWidth, pageHeight, onLinkClick, backgroundType, backgroundColor, backgroundOpacity, backgroundGradient, backgroundImageUrl, backgroundVideoUrl, entrancePhase = 'idle', disableEntranceAnimations = false, onDeferredEntranceReady, hideMediaLoadingOverlay = false, staticMedia = false, showPaperShadow = true, useHardPage = false, idSuffix = '' }, ref) => { const effectiveBackgroundType = backgroundType ?? page.backgroundType ?? 'color'; const 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 ?? ''; @@ -1034,7 +1036,7 @@ const BookPage = memo(forwardRef( ? { backgroundImage: toCssLinearGradient(effectiveBackgroundGradient), } - : { backgroundColor: effectiveBackgroundColor }; + : { backgroundColor: getColorWithOpacity(effectiveBackgroundColor, effectiveBackgroundOpacity) }; const showLoadingOverlay = !hideMediaLoadingOverlay && !isMediaReady; diff --git a/src/pages/viewer/components/BookViewer.tsx b/src/pages/viewer/components/BookViewer.tsx index 62dfb7e..9eb5c30 100644 --- a/src/pages/viewer/components/BookViewer.tsx +++ b/src/pages/viewer/components/BookViewer.tsx @@ -193,12 +193,13 @@ const BookViewer: FC = ({ 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(() => { diff --git a/src/pages/viewer/types/index.ts b/src/pages/viewer/types/index.ts index 7f267e4..f3417dc 100644 --- a/src/pages/viewer/types/index.ts +++ b/src/pages/viewer/types/index.ts @@ -7,6 +7,7 @@ export type PageData = { elements: EditorObject[]; backgroundType?: "color" | "gradient" | "image" | "video"; backgroundColor?: string; + backgroundOpacity?: number; backgroundGradient?: { from: string; to: string; diff --git a/src/pages/viewer/utils/dataTransformer.ts b/src/pages/viewer/utils/dataTransformer.ts index 8cc1d6f..a4f152c 100644 --- a/src/pages/viewer/utils/dataTransformer.ts +++ b/src/pages/viewer/utils/dataTransformer.ts @@ -7,6 +7,7 @@ type ViewerDataPage = { name: string; backgroundType?: "color" | "gradient" | "image" | "video"; backgroundColor?: string; + backgroundOpacity?: number; backgroundGradient?: { from: string; to: string; @@ -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, diff --git a/src/pages/viewer/utils/pageBackground.ts b/src/pages/viewer/utils/pageBackground.ts index c571ed7..e8368d3 100644 --- a/src/pages/viewer/utils/pageBackground.ts +++ b/src/pages/viewer/utils/pageBackground.ts @@ -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 ?? "",