| 1 | import { useEffect, useMemo, useRef, useState } from 'react' |
| 2 | import { FolderOpen, ImageIcon, Trash2, Upload } from 'lucide-react' |
| 3 | import { useT } from '@renderer/i18n' |
| 4 | import { ipc } from '@renderer/lib/ipc' |
| 5 | import { useMasterWorkbenchStore, useSessionStore, useToastStore } from '@renderer/store' |
| 6 | import { localAssetUrl } from '@shared/local-asset' |
| 7 | import { trySessionSlideSize } from '@shared/slide-size' |
| 8 | import { |
| 9 | normalizeMasterElementsConfig, |
| 10 | type MasterElementPosition, |
| 11 | type MasterElementSize, |
| 12 | type MasterElementsConfig |
| 13 | } from '@shared/master' |
| 14 | import { AssetPickerDialog } from '../session-detail/modal/AssetPickerDialog' |
| 15 | import { Button } from '../ui/Button' |
| 16 | import { Checkbox } from '../ui/Checkbox' |
| 17 | import { Input } from '../ui/Input' |
| 18 | import { ColorPicker } from '../ui/ColorPicker' |
| 19 | import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/Tooltip' |
| 20 | |
| 21 | type ElementKey = 'logo' | 'footer' | 'pageNumber' | 'watermark' |
| 22 | type ResizeHandle = 'n' | 's' | 'w' | 'e' | 'nw' | 'ne' | 'sw' | 'se' |
| 23 | |
| 24 | type PointerState = { |
| 25 | key: ElementKey |
| 26 | mode: 'move' | 'resize' |
| 27 | handle?: ResizeHandle |
| 28 | startClientX: number |
| 29 | startClientY: number |
| 30 | startPosition: MasterElementPosition |
| 31 | startSize: MasterElementSize |
| 32 | } |
| 33 | |
| 34 | const positionKeyByElement: Record< |
| 35 | ElementKey, |
| 36 | 'logoPosition' | 'footerPosition' | 'pageNumberPosition' | 'watermarkPosition' |
| 37 | > = { |
| 38 | logo: 'logoPosition', |
| 39 | footer: 'footerPosition', |
| 40 | pageNumber: 'pageNumberPosition', |
| 41 | watermark: 'watermarkPosition' |
| 42 | } |
| 43 | |
| 44 | const sizeKeyByElement: Record< |
| 45 | ElementKey, |
| 46 | 'logoSize' | 'footerSize' | 'pageNumberSize' | 'watermarkSize' |
| 47 | > = { |
| 48 | logo: 'logoSize', |
| 49 | footer: 'footerSize', |
| 50 | pageNumber: 'pageNumberSize', |
| 51 | watermark: 'watermarkSize' |
| 52 | } |
| 53 | |
| 54 | const minSizeByElement: Record<ElementKey, MasterElementSize> = { |
| 55 | logo: { width: 4, height: 3 }, |
| 56 | footer: { width: 12, height: 2 }, |
| 57 | pageNumber: { width: 3, height: 2 }, |
| 58 | watermark: { width: 4, height: 4 } |
| 59 | } |
| 60 | |
| 61 | const handleClasses: Record<ResizeHandle, string> = { |
| 62 | n: '-top-1 left-1/2 -translate-x-1/2 cursor-ns-resize', |
| 63 | s: '-bottom-1 left-1/2 -translate-x-1/2 cursor-ns-resize', |
| 64 | nw: '-left-1 -top-1 cursor-nwse-resize', |
| 65 | ne: '-right-1 -top-1 cursor-nesw-resize', |
| 66 | sw: '-bottom-1 -left-1 cursor-nesw-resize', |
| 67 | se: '-bottom-1 -right-1 cursor-nwse-resize', |
| 68 | w: '-left-1 top-1/2 -translate-y-1/2 cursor-ew-resize', |
| 69 | e: '-right-1 top-1/2 -translate-y-1/2 cursor-ew-resize' |
| 70 | } |
| 71 | |
| 72 | const clamp = (value: number, min: number, max: number): number => |
| 73 | Math.round(Math.min(max, Math.max(min, value)) * 100) / 100 |
| 74 | |
| 75 | const MAX_CANVAS_WIDTH = 540 |
| 76 | const MAX_CANVAS_HEIGHT = 300 |
| 77 | const DEFAULT_LOGO_BOUNDS: MasterElementSize = { width: 16, height: 10 } |
| 78 | |
| 79 | const fitLogoSizeToCanvas = ( |
| 80 | imageWidth: number, |
| 81 | imageHeight: number, |
| 82 | slideAspectRatio: number |
| 83 | ): MasterElementSize => { |
| 84 | const imageAspectRatio = imageWidth / imageHeight |
| 85 | if (!Number.isFinite(imageAspectRatio) || imageAspectRatio <= 0 || slideAspectRatio <= 0) { |
| 86 | return { ...DEFAULT_LOGO_BOUNDS } |
| 87 | } |
| 88 | const canvasAspectRatio = imageAspectRatio / slideAspectRatio |
| 89 | const width = Math.min(DEFAULT_LOGO_BOUNDS.width, DEFAULT_LOGO_BOUNDS.height * canvasAspectRatio) |
| 90 | const height = Math.min(DEFAULT_LOGO_BOUNDS.height, DEFAULT_LOGO_BOUNDS.width / canvasAspectRatio) |
| 91 | return { |
| 92 | width: clamp(width, 1, 100), |
| 93 | height: clamp(height, 1, 100) |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | const readImageSize = (source: string): Promise<{ width: number; height: number } | null> => |
| 98 | new Promise((resolve) => { |
| 99 | const image = new Image() |
| 100 | image.onload = () => resolve({ width: image.naturalWidth, height: image.naturalHeight }) |
| 101 | image.onerror = () => resolve(null) |
| 102 | image.src = source |
| 103 | }) |
| 104 | |
| 105 | const resizeElement = ( |
| 106 | position: MasterElementPosition, |
| 107 | size: MasterElementSize, |
| 108 | handle: ResizeHandle, |
| 109 | deltaX: number, |
| 110 | deltaY: number, |
| 111 | minSize: MasterElementSize |
| 112 | ): { position: MasterElementPosition; size: MasterElementSize } => { |
| 113 | const right = position.x + size.width |
| 114 | const bottom = position.y + size.height |
| 115 | const resizeFromWest = handle === 'w' || handle === 'nw' || handle === 'sw' |
| 116 | const resizeFromNorth = handle === 'n' || handle === 'nw' || handle === 'ne' |
| 117 | const resizeHorizontally = resizeFromWest || handle === 'e' || handle === 'ne' || handle === 'se' |
| 118 | const resizeVertically = resizeFromNorth || handle === 's' || handle === 'sw' || handle === 'se' |
| 119 | const left = resizeFromWest |
| 120 | ? clamp(position.x + deltaX, 0, right - minSize.width) |
| 121 | : position.x |
| 122 | const nextRight = resizeHorizontally && !resizeFromWest |
| 123 | ? clamp(right + deltaX, position.x + minSize.width, 100) |
| 124 | : right |
| 125 | const top = resizeFromNorth |
| 126 | ? clamp(position.y + deltaY, 0, bottom - minSize.height) |
| 127 | : position.y |
| 128 | const nextBottom = resizeVertically && !resizeFromNorth |
| 129 | ? clamp(bottom + deltaY, position.y + minSize.height, 100) |
| 130 | : bottom |
| 131 | |
| 132 | return { |
| 133 | position: { x: left, y: top }, |
| 134 | size: { |
| 135 | width: clamp(nextRight - left, minSize.width, 100), |
| 136 | height: clamp(nextBottom - top, minSize.height, 100) |
| 137 | } |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | export function MasterElementsEditor(): React.JSX.Element { |
| 142 | const t = useT() |
| 143 | const config = useMasterWorkbenchStore((state) => state.config) |
| 144 | const updateConfig = useMasterWorkbenchStore((state) => state.updateConfig) |
| 145 | const currentSession = useSessionStore((state) => state.currentSession) |
| 146 | const sessionId = currentSession?.id || '' |
| 147 | const toastError = useToastStore((state) => state.error) |
| 148 | const [pickerOpen, setPickerOpen] = useState(false) |
| 149 | const [uploading, setUploading] = useState(false) |
| 150 | const [logoPreviewUrl, setLogoPreviewUrl] = useState<string | null>(null) |
| 151 | const [pointerState, setPointerState] = useState<PointerState | null>(null) |
| 152 | const [selectedElement, setSelectedElement] = useState<ElementKey | null>(null) |
| 153 | const canvasRef = useRef<HTMLDivElement>(null) |
| 154 | const watermarkTextRef = useRef<HTMLSpanElement>(null) |
| 155 | const elements = normalizeMasterElementsConfig(config.elements) |
| 156 | const slideSize = trySessionSlideSize(currentSession) |
| 157 | const aspectRatio = slideSize ? `${slideSize.width} / ${slideSize.height}` : '16 / 9' |
| 158 | const canvasWidth = slideSize |
| 159 | ? Math.min(MAX_CANVAS_WIDTH, Math.round((slideSize.width / slideSize.height) * MAX_CANVAS_HEIGHT)) |
| 160 | : MAX_CANVAS_WIDTH |
| 161 | const canvasHeight = slideSize |
| 162 | ? Math.round(canvasWidth * (slideSize.height / slideSize.width)) |
| 163 | : Math.round(canvasWidth * (9 / 16)) |
| 164 | const slideAspectRatio = slideSize ? slideSize.width / slideSize.height : 16 / 9 |
| 165 | const visibleElements = { |
| 166 | logo: elements.showLogo && Boolean(elements.logoImage), |
| 167 | footer: elements.showFooter && Boolean(elements.footerText), |
| 168 | pageNumber: elements.showPageNumber, |
| 169 | watermark: elements.showWatermark && Boolean(elements.watermarkText) |
| 170 | } |
| 171 | |
| 172 | const updateElements = (patch: Partial<MasterElementsConfig>): void => |
| 173 | updateConfig({ elements: { ...elements, ...patch } }) |
| 174 | |
| 175 | useEffect(() => { |
| 176 | if (!sessionId || !elements.logoImage) { |
| 177 | setLogoPreviewUrl(null) |
| 178 | return |
| 179 | } |
| 180 | let cancelled = false |
| 181 | void ipc |
| 182 | .listAssets(sessionId, 'image') |
| 183 | .then(({ assets }) => { |
| 184 | const image = assets.find((asset) => asset.relativePath === elements.logoImage) |
| 185 | if (!cancelled) setLogoPreviewUrl(image ? localAssetUrl(image.absolutePath) : null) |
| 186 | }) |
| 187 | .catch(() => { |
| 188 | if (!cancelled) setLogoPreviewUrl(null) |
| 189 | }) |
| 190 | return () => { |
| 191 | cancelled = true |
| 192 | } |
| 193 | }, [elements.logoImage, sessionId]) |
| 194 | |
| 195 | useEffect(() => { |
| 196 | if (selectedElement && !visibleElements[selectedElement]) setSelectedElement(null) |
| 197 | }, [selectedElement, visibleElements.footer, visibleElements.logo, visibleElements.pageNumber, visibleElements.watermark]) |
| 198 | |
| 199 | useEffect(() => { |
| 200 | if (!pointerState) return |
| 201 | const updateElement = (event: PointerEvent): void => { |
| 202 | const canvas = canvasRef.current |
| 203 | if (!canvas) return |
| 204 | const rect = canvas.getBoundingClientRect() |
| 205 | if (rect.width <= 0 || rect.height <= 0) return |
| 206 | const deltaX = ((event.clientX - pointerState.startClientX) / rect.width) * 100 |
| 207 | const deltaY = ((event.clientY - pointerState.startClientY) / rect.height) * 100 |
| 208 | if (pointerState.mode === 'move') { |
| 209 | const position = { |
| 210 | x: clamp( |
| 211 | pointerState.startPosition.x + deltaX, |
| 212 | 0, |
| 213 | 100 - pointerState.startSize.width |
| 214 | ), |
| 215 | y: clamp( |
| 216 | pointerState.startPosition.y + deltaY, |
| 217 | 0, |
| 218 | 100 - pointerState.startSize.height |
| 219 | ) |
| 220 | } |
| 221 | updateElements({ [positionKeyByElement[pointerState.key]]: position }) |
| 222 | return |
| 223 | } |
| 224 | const resized = resizeElement( |
| 225 | pointerState.startPosition, |
| 226 | pointerState.startSize, |
| 227 | pointerState.handle as ResizeHandle, |
| 228 | deltaX, |
| 229 | deltaY, |
| 230 | minSizeByElement[pointerState.key] |
| 231 | ) |
| 232 | updateElements({ |
| 233 | [positionKeyByElement[pointerState.key]]: resized.position, |
| 234 | [sizeKeyByElement[pointerState.key]]: resized.size, |
| 235 | ...(pointerState.key === 'watermark' ? { watermarkSizeAuto: false } : {}) |
| 236 | }) |
| 237 | } |
| 238 | const stopInteraction = (): void => setPointerState(null) |
| 239 | window.addEventListener('pointermove', updateElement) |
| 240 | window.addEventListener('pointerup', stopInteraction) |
| 241 | window.addEventListener('pointercancel', stopInteraction) |
| 242 | return () => { |
| 243 | window.removeEventListener('pointermove', updateElement) |
| 244 | window.removeEventListener('pointerup', stopInteraction) |
| 245 | window.removeEventListener('pointercancel', stopInteraction) |
| 246 | } |
| 247 | }, [elements, pointerState, updateConfig]) |
| 248 | |
| 249 | useEffect(() => { |
| 250 | if (!elements.watermarkSizeAuto || !visibleElements.watermark) return |
| 251 | const canvas = canvasRef.current |
| 252 | const text = watermarkTextRef.current |
| 253 | if (!canvas || !text) return |
| 254 | const canvasWidth = canvas.getBoundingClientRect().width |
| 255 | if (canvasWidth <= 0) return |
| 256 | const width = clamp( |
| 257 | ((text.getBoundingClientRect().width + 16) / canvasWidth) * 100, |
| 258 | minSizeByElement.watermark.width, |
| 259 | 100 |
| 260 | ) |
| 261 | if (Math.abs(width - elements.watermarkSize.width) < 0.1) return |
| 262 | updateElements({ watermarkSize: { ...elements.watermarkSize, width } }) |
| 263 | }, [ |
| 264 | elements.watermarkSize, |
| 265 | elements.watermarkSizeAuto, |
| 266 | elements.watermarkText, |
| 267 | updateConfig, |
| 268 | visibleElements.watermark |
| 269 | ]) |
| 270 | |
| 271 | const selectLogo = async (relativePath: string, absolutePath?: string): Promise<void> => { |
| 272 | let source = absolutePath ? localAssetUrl(absolutePath) : null |
| 273 | if (!source && sessionId) { |
| 274 | try { |
| 275 | const { assets } = await ipc.listAssets(sessionId, 'image') |
| 276 | const image = assets.find((asset) => asset.relativePath === relativePath) |
| 277 | source = image ? localAssetUrl(image.absolutePath) : null |
| 278 | } catch { |
| 279 | source = null |
| 280 | } |
| 281 | } |
| 282 | const naturalSize = source ? await readImageSize(source) : null |
| 283 | updateElements({ |
| 284 | logoImage: relativePath, |
| 285 | showLogo: true, |
| 286 | logoSize: naturalSize |
| 287 | ? fitLogoSizeToCanvas(naturalSize.width, naturalSize.height, slideAspectRatio) |
| 288 | : { ...DEFAULT_LOGO_BOUNDS } |
| 289 | }) |
| 290 | if (source) setLogoPreviewUrl(source) |
| 291 | } |
| 292 | |
| 293 | const uploadLogo = async (): Promise<void> => { |
| 294 | if (!sessionId || uploading) return |
| 295 | setUploading(true) |
| 296 | try { |
| 297 | const result = await ipc.chooseAndUploadAssets(sessionId, 'image') |
| 298 | const image = result.assets[0] |
| 299 | if (!result.cancelled && image) { |
| 300 | await selectLogo(image.relativePath, image.absolutePath) |
| 301 | } |
| 302 | } catch (error) { |
| 303 | toastError(error instanceof Error ? error.message : t('sessionDetail.masterLogoUploadFailed')) |
| 304 | } finally { |
| 305 | setUploading(false) |
| 306 | } |
| 307 | } |
| 308 | |
| 309 | const elementStyles = useMemo( |
| 310 | () => ({ |
| 311 | logo: { |
| 312 | left: `${elements.logoPosition.x}%`, |
| 313 | top: `${elements.logoPosition.y}%`, |
| 314 | width: `${elements.logoSize.width}%`, |
| 315 | height: `${elements.logoSize.height}%` |
| 316 | }, |
| 317 | footer: { |
| 318 | left: `${elements.footerPosition.x}%`, |
| 319 | top: `${elements.footerPosition.y}%`, |
| 320 | width: `${elements.footerSize.width}%`, |
| 321 | height: `${elements.footerSize.height}%` |
| 322 | }, |
| 323 | pageNumber: { |
| 324 | left: `${elements.pageNumberPosition.x}%`, |
| 325 | top: `${elements.pageNumberPosition.y}%`, |
| 326 | width: `${elements.pageNumberSize.width}%`, |
| 327 | height: `${elements.pageNumberSize.height}%` |
| 328 | }, |
| 329 | watermark: { |
| 330 | left: `${elements.watermarkPosition.x}%`, |
| 331 | top: `${elements.watermarkPosition.y}%`, |
| 332 | width: `${elements.watermarkSize.width}%`, |
| 333 | height: `${elements.watermarkSize.height}%` |
| 334 | } |
| 335 | }), |
| 336 | [elements] |
| 337 | ) |
| 338 | |
| 339 | const previewWatermarkFontSize = (height: number): number => |
| 340 | Math.max(8, Math.min(36, canvasHeight * (height / 100) * 0.45)) |
| 341 | const previewTextFontSize = (fontSize: number): number => |
| 342 | Math.max(8, Math.min(36, fontSize * (canvasHeight / (slideSize?.height || 720)))) |
| 343 | const textPreviewHeight = (fontSize: number): string => |
| 344 | `${clamp((fontSize / (slideSize?.height || 720)) * 150, 2, 100)}%` |
| 345 | |
| 346 | const updateElementNumber = ( |
| 347 | key: 'footerFontSize' | 'pageNumberFontSize' | 'watermarkRotation', |
| 348 | value: string |
| 349 | ): void => { |
| 350 | const parsed = Number(value) |
| 351 | if (!Number.isFinite(parsed)) return |
| 352 | const [min, max] = key === 'watermarkRotation' ? [-180, 180] : [8, 160] |
| 353 | updateElements({ [key]: Math.round(clamp(parsed, min, max)) } as Partial<MasterElementsConfig>) |
| 354 | } |
| 355 | |
| 356 | const updateWatermarkRotationFromPointer = ( |
| 357 | event: React.PointerEvent<HTMLButtonElement> |
| 358 | ): void => { |
| 359 | const rect = event.currentTarget.getBoundingClientRect() |
| 360 | const centerX = rect.left + rect.width / 2 |
| 361 | const centerY = rect.top + rect.height / 2 |
| 362 | if (Math.hypot(event.clientX - centerX, event.clientY - centerY) < rect.width * 0.22) return |
| 363 | const degrees = (Math.atan2(event.clientY - centerY, event.clientX - centerX) * 180) / Math.PI |
| 364 | const rotation = Math.round(((degrees + 270) % 360 + 360) % 360) - 180 |
| 365 | updateElements({ watermarkRotation: rotation === -180 ? 180 : rotation }) |
| 366 | } |
| 367 | |
| 368 | const beginInteraction = ( |
| 369 | key: ElementKey, |
| 370 | event: React.PointerEvent<HTMLElement>, |
| 371 | mode: PointerState['mode'], |
| 372 | handle?: ResizeHandle |
| 373 | ): void => { |
| 374 | if (event.button !== 0) return |
| 375 | event.preventDefault() |
| 376 | event.stopPropagation() |
| 377 | setSelectedElement(key) |
| 378 | setPointerState({ |
| 379 | key, |
| 380 | mode, |
| 381 | handle, |
| 382 | startClientX: event.clientX, |
| 383 | startClientY: event.clientY, |
| 384 | startPosition: elements[positionKeyByElement[key]], |
| 385 | startSize: elements[sizeKeyByElement[key]] |
| 386 | }) |
| 387 | } |
| 388 | |
| 389 | const renderResizeHandles = (key: ElementKey): React.JSX.Element | null => { |
| 390 | if (selectedElement !== key || (key !== 'logo' && key !== 'watermark')) return null |
| 391 | return ( |
| 392 | <> |
| 393 | <div className="pointer-events-none absolute inset-0 z-20 border border-dashed border-[#71805d]" /> |
| 394 | {Object.keys(handleClasses).map((handle) => ( |
| 395 | <button |
| 396 | key={handle} |
| 397 | type="button" |
| 398 | className={`absolute z-30 h-2 w-2 rounded-full border border-white bg-[#71805d] p-0 shadow-sm ${handleClasses[handle as ResizeHandle]}`} |
| 399 | aria-label={`Resize ${key}`} |
| 400 | onPointerDown={(event) => |
| 401 | beginInteraction(key, event, 'resize', handle as ResizeHandle) |
| 402 | } |
| 403 | /> |
| 404 | ))} |
| 405 | </> |
| 406 | ) |
| 407 | } |
| 408 | |
| 409 | return ( |
| 410 | <> |
| 411 | <div className="grid items-start gap-5 md:grid-cols-[minmax(0,1fr)_280px]"> |
| 412 | <div className="flex min-w-0 justify-center rounded-md border border-[#e4dac9] bg-[#f7f2e8]/68 p-3"> |
| 413 | <div |
| 414 | ref={canvasRef} |
| 415 | className="relative max-w-full select-none overflow-hidden border border-[#cfc3ae] bg-[#fffdf8] shadow-[0_12px_28px_rgba(73,61,43,0.12)]" |
| 416 | style={{ aspectRatio, width: `${canvasWidth}px` }} |
| 417 | onPointerDown={() => setSelectedElement(null)} |
| 418 | > |
| 419 | <div className="pointer-events-none absolute inset-[7%] border border-dashed border-[#d9cfbd]" /> |
| 420 | <div className="pointer-events-none absolute left-[10%] right-[10%] top-[24%] h-px bg-[#eee5d7]" /> |
| 421 | <div className="pointer-events-none absolute left-[10%] right-[10%] top-[58%] h-px bg-[#eee5d7]" /> |
| 422 | <div className="pointer-events-none absolute inset-0 bg-[linear-gradient(135deg,rgba(232,242,221,.32),transparent_48%)]" /> |
| 423 | |
| 424 | {visibleElements.logo && ( |
| 425 | <div |
| 426 | className={`absolute z-10 cursor-grab touch-none active:cursor-grabbing ${selectedElement === 'logo' ? 'z-20' : ''}`} |
| 427 | style={elementStyles.logo} |
| 428 | onPointerDown={(event) => beginInteraction('logo', event, 'move')} |
| 429 | > |
| 430 | <img |
| 431 | src={logoPreviewUrl || ''} |
| 432 | alt="" |
| 433 | className="h-full w-full object-contain object-left-top" |
| 434 | draggable={false} |
| 435 | /> |
| 436 | {renderResizeHandles('logo')} |
| 437 | </div> |
| 438 | )} |
| 439 | |
| 440 | {visibleElements.footer && ( |
| 441 | <div |
| 442 | className={`absolute z-10 cursor-grab touch-none active:cursor-grabbing ${selectedElement === 'footer' ? 'z-20' : ''}`} |
| 443 | style={{ ...elementStyles.footer, height: textPreviewHeight(elements.footerFontSize) }} |
| 444 | onPointerDown={(event) => beginInteraction('footer', event, 'move')} |
| 445 | > |
| 446 | <div |
| 447 | className="flex h-full w-full items-center truncate" |
| 448 | style={{ |
| 449 | color: elements.footerColor, |
| 450 | fontSize: `${previewTextFontSize(elements.footerFontSize)}px` |
| 451 | }} |
| 452 | > |
| 453 | {elements.footerText} |
| 454 | </div> |
| 455 | {renderResizeHandles('footer')} |
| 456 | </div> |
| 457 | )} |
| 458 | |
| 459 | {visibleElements.pageNumber && ( |
| 460 | <div |
| 461 | className={`absolute z-10 cursor-grab touch-none active:cursor-grabbing ${selectedElement === 'pageNumber' ? 'z-20' : ''}`} |
| 462 | style={{ |
| 463 | ...elementStyles.pageNumber, |
| 464 | height: textPreviewHeight(elements.pageNumberFontSize) |
| 465 | }} |
| 466 | onPointerDown={(event) => beginInteraction('pageNumber', event, 'move')} |
| 467 | > |
| 468 | <div |
| 469 | className="flex h-full w-full items-center justify-end tabular-nums" |
| 470 | style={{ |
| 471 | color: elements.pageNumberColor, |
| 472 | fontSize: `${previewTextFontSize(elements.pageNumberFontSize)}px` |
| 473 | }} |
| 474 | > |
| 475 | 1 |
| 476 | </div> |
| 477 | {renderResizeHandles('pageNumber')} |
| 478 | </div> |
| 479 | )} |
| 480 | |
| 481 | {visibleElements.watermark && ( |
| 482 | <div |
| 483 | className={`absolute z-10 cursor-grab touch-none active:cursor-grabbing ${selectedElement === 'watermark' ? 'z-20' : ''}`} |
| 484 | style={elementStyles.watermark} |
| 485 | onPointerDown={(event) => beginInteraction('watermark', event, 'move')} |
| 486 | > |
| 487 | <div |
| 488 | className="flex h-full w-full items-center justify-center truncate text-center font-bold text-[#738065]/55" |
| 489 | style={{ |
| 490 | fontSize: `${previewWatermarkFontSize(elements.watermarkSize.height)}px`, |
| 491 | transform: `rotate(${elements.watermarkRotation}deg)` |
| 492 | }} |
| 493 | > |
| 494 | <span ref={watermarkTextRef} className="inline-block shrink-0 whitespace-nowrap"> |
| 495 | {elements.watermarkText} |
| 496 | </span> |
| 497 | </div> |
| 498 | {renderResizeHandles('watermark')} |
| 499 | </div> |
| 500 | )} |
| 501 | </div> |
| 502 | </div> |
| 503 | |
| 504 | <div className="grid gap-4"> |
| 505 | <div className="space-y-2"> |
| 506 | <div className="flex items-center justify-between gap-3 text-sm text-[#4a563d]"> |
| 507 | <span>{t('sessionDetail.masterElementLogo')}</span> |
| 508 | <label className="flex items-center gap-2 text-xs text-[#667257]"> |
| 509 | <Checkbox |
| 510 | checked={elements.showLogo} |
| 511 | disabled={!elements.logoImage} |
| 512 | onCheckedChange={(checked) => updateElements({ showLogo: checked === true })} |
| 513 | /> |
| 514 | {t('sessionDetail.masterElementVisible')} |
| 515 | </label> |
| 516 | </div> |
| 517 | <div className="flex items-center gap-2"> |
| 518 | <div className="flex h-12 min-w-0 flex-1 items-center justify-center overflow-hidden rounded border border-[#e4dac9] bg-[#fffdf8] p-1.5 text-xs text-[#667257]"> |
| 519 | {logoPreviewUrl ? ( |
| 520 | <img src={logoPreviewUrl} alt="" className="h-full max-w-full object-contain" /> |
| 521 | ) : ( |
| 522 | <ImageIcon className="h-4 w-4 shrink-0" /> |
| 523 | )} |
| 524 | </div> |
| 525 | {elements.logoImage && ( |
| 526 | <Tooltip> |
| 527 | <TooltipTrigger asChild> |
| 528 | <Button |
| 529 | type="button" |
| 530 | variant="ghost" |
| 531 | size="sm" |
| 532 | className="h-8 w-8 shrink-0 p-0 text-[#a14f4a]" |
| 533 | aria-label={t('sessionDetail.masterLogoClear')} |
| 534 | onClick={() => updateElements({ logoImage: null, showLogo: false })} |
| 535 | > |
| 536 | <Trash2 className="h-3.5 w-3.5" /> |
| 537 | </Button> |
| 538 | </TooltipTrigger> |
| 539 | <TooltipContent>{t('sessionDetail.masterLogoClear')}</TooltipContent> |
| 540 | </Tooltip> |
| 541 | )} |
| 542 | <Button |
| 543 | type="button" |
| 544 | variant="outline" |
| 545 | size="sm" |
| 546 | className="h-8 w-8 shrink-0 p-0" |
| 547 | disabled={!sessionId || uploading} |
| 548 | aria-label={t('sessionDetail.masterLogoChoose')} |
| 549 | onClick={() => setPickerOpen(true)} |
| 550 | > |
| 551 | <FolderOpen className="h-3.5 w-3.5" /> |
| 552 | </Button> |
| 553 | <Button |
| 554 | type="button" |
| 555 | size="sm" |
| 556 | className="h-8 w-8 shrink-0 p-0" |
| 557 | disabled={!sessionId || uploading} |
| 558 | aria-label={t('sessionDetail.masterLogoUpload')} |
| 559 | onClick={() => void uploadLogo()} |
| 560 | > |
| 561 | <Upload className="h-3.5 w-3.5" /> |
| 562 | </Button> |
| 563 | </div> |
| 564 | </div> |
| 565 | |
| 566 | <div className="grid gap-1.5 text-sm text-[#4a563d]"> |
| 567 | <div className="flex items-center justify-between gap-3"> |
| 568 | <span>{t('sessionDetail.masterFooter')}</span> |
| 569 | <label className="flex items-center gap-2 text-xs text-[#667257]"> |
| 570 | <Checkbox |
| 571 | checked={elements.showFooter} |
| 572 | onCheckedChange={(checked) => updateElements({ showFooter: checked === true })} |
| 573 | /> |
| 574 | {t('sessionDetail.masterElementVisible')} |
| 575 | </label> |
| 576 | </div> |
| 577 | <div className="flex items-center gap-2"> |
| 578 | <Input |
| 579 | value={elements.footerText} |
| 580 | maxLength={180} |
| 581 | className="h-8 min-w-0 flex-1" |
| 582 | onChange={(event) => updateElements({ footerText: event.target.value })} |
| 583 | /> |
| 584 | <Input |
| 585 | key={`footer-font-size-${elements.footerFontSize}`} |
| 586 | type="number" |
| 587 | min={8} |
| 588 | max={160} |
| 589 | step={1} |
| 590 | defaultValue={elements.footerFontSize} |
| 591 | className="h-8 w-14 shrink-0 px-1.5 text-center" |
| 592 | title={t('sessionDetail.masterElementFontSize')} |
| 593 | aria-label={t('sessionDetail.masterElementFontSize')} |
| 594 | onBlur={(event) => updateElementNumber('footerFontSize', event.target.value)} |
| 595 | /> |
| 596 | <ColorPicker |
| 597 | value={elements.footerColor} |
| 598 | allowAlpha={false} |
| 599 | ariaLabel={t('sessionDetail.masterElementColor')} |
| 600 | onChange={(footerColor) => updateElements({ footerColor })} |
| 601 | /> |
| 602 | </div> |
| 603 | </div> |
| 604 | |
| 605 | <div className="grid gap-1.5 text-sm text-[#4a563d]"> |
| 606 | <div className="flex items-center justify-between gap-3"> |
| 607 | <span>{t('sessionDetail.masterWatermark')}</span> |
| 608 | <label className="flex items-center gap-2 text-xs text-[#667257]"> |
| 609 | <Checkbox |
| 610 | checked={elements.showWatermark} |
| 611 | onCheckedChange={(checked) => updateElements({ showWatermark: checked === true })} |
| 612 | /> |
| 613 | {t('sessionDetail.masterElementVisible')} |
| 614 | </label> |
| 615 | </div> |
| 616 | <div className="flex items-center gap-3"> |
| 617 | <Input |
| 618 | value={elements.watermarkText} |
| 619 | maxLength={80} |
| 620 | className="h-8 min-w-0 flex-1" |
| 621 | onChange={(event) => updateElements({ watermarkText: event.target.value })} |
| 622 | /> |
| 623 | <button |
| 624 | type="button" |
| 625 | className="relative h-20 w-20 shrink-0 touch-none border-[9px] border-[#dce0da] bg-[#fbfaf7] shadow-[inset_0_1px_2px_rgba(70,80,60,0.08)]" |
| 626 | style={{ borderRadius: '50%' }} |
| 627 | aria-label={t('sessionDetail.masterWatermarkRotation')} |
| 628 | title={`${t('sessionDetail.masterWatermarkRotation')}: ${elements.watermarkRotation}°`} |
| 629 | onPointerDown={(event) => { |
| 630 | if (event.button !== 0) return |
| 631 | event.preventDefault() |
| 632 | event.currentTarget.setPointerCapture(event.pointerId) |
| 633 | updateWatermarkRotationFromPointer(event) |
| 634 | }} |
| 635 | onPointerMove={(event) => { |
| 636 | if (event.currentTarget.hasPointerCapture(event.pointerId)) { |
| 637 | updateWatermarkRotationFromPointer(event) |
| 638 | } |
| 639 | }} |
| 640 | onPointerUp={(event) => { |
| 641 | if (event.currentTarget.hasPointerCapture(event.pointerId)) { |
| 642 | event.currentTarget.releasePointerCapture(event.pointerId) |
| 643 | } |
| 644 | }} |
| 645 | > |
| 646 | <span className="pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-[10px] font-medium tabular-nums text-[#7c8779]"> |
| 647 | {elements.watermarkRotation}° |
| 648 | </span> |
| 649 | <span |
| 650 | className="absolute h-4 w-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white bg-[#4f5d43] shadow-[0_1px_4px_rgba(48,57,42,0.28)]" |
| 651 | style={{ |
| 652 | left: `${50 + Math.sin((elements.watermarkRotation * Math.PI) / 180) * 40}%`, |
| 653 | top: `${50 - Math.cos((elements.watermarkRotation * Math.PI) / 180) * 40}%` |
| 654 | }} |
| 655 | /> |
| 656 | </button> |
| 657 | </div> |
| 658 | </div> |
| 659 | |
| 660 | <div className="flex items-center justify-between gap-3 border-t border-[#e6ddcf] pt-4 text-sm text-[#4a563d]"> |
| 661 | <span>{t('sessionDetail.masterShowPageNumber')}</span> |
| 662 | <div className="flex items-center gap-2"> |
| 663 | <Input |
| 664 | key={`page-number-font-size-${elements.pageNumberFontSize}`} |
| 665 | type="number" |
| 666 | min={8} |
| 667 | max={160} |
| 668 | step={1} |
| 669 | defaultValue={elements.pageNumberFontSize} |
| 670 | className="h-8 w-14 px-1.5 text-center" |
| 671 | title={t('sessionDetail.masterElementFontSize')} |
| 672 | aria-label={t('sessionDetail.masterElementFontSize')} |
| 673 | onBlur={(event) => updateElementNumber('pageNumberFontSize', event.target.value)} |
| 674 | /> |
| 675 | <ColorPicker |
| 676 | value={elements.pageNumberColor} |
| 677 | allowAlpha={false} |
| 678 | ariaLabel={t('sessionDetail.masterElementColor')} |
| 679 | onChange={(pageNumberColor) => updateElements({ pageNumberColor })} |
| 680 | /> |
| 681 | <Checkbox |
| 682 | checked={elements.showPageNumber} |
| 683 | onCheckedChange={(checked) => updateElements({ showPageNumber: checked === true })} |
| 684 | /> |
| 685 | </div> |
| 686 | </div> |
| 687 | </div> |
| 688 | </div> |
| 689 | |
| 690 | <AssetPickerDialog |
| 691 | sessionId={sessionId} |
| 692 | assetType="image" |
| 693 | open={pickerOpen} |
| 694 | onClose={() => setPickerOpen(false)} |
| 695 | onConfirm={(relativePath) => void selectLogo(relativePath)} |
| 696 | /> |
| 697 | </> |
| 698 | ) |
| 699 | } |
| 700 |