返回 oh-my-ppt
MasterElementsEditor.tsx
根目录 / src / renderer / src / components / master-elements / MasterElementsEditor.tsx
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
700 lines Plain Text