返回 oh-my-ppt
EditorGuidesOverlay.tsx
根目录 / src / renderer / src / components / session-detail / preview / EditorGuidesOverlay.tsx
1 import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react'
2 import { Grid3X3, Magnet, Trash2 } from 'lucide-react'
3 import { useSessionDetailUiStore } from '@renderer/store'
4 import { useT } from '@renderer/i18n'
5 import { Tooltip, TooltipContent, TooltipTrigger } from '../../ui/Tooltip'
6 import type { PreviewIframeHandle } from '../../preview/PreviewIframe'
7 import type { SlideSizePreset } from '@shared/slide-size'
8
9 type GuideAxis = 'vertical' | 'horizontal'
10
11 interface CanvasMetrics {
12 left: number
13 top: number
14 width: number
15 height: number
16 scale: number
17 }
18
19 interface GuideDragState {
20 pageId: string
21 axis: GuideAxis
22 index: number
23 position: number
24 pointerOffset: number
25 removeOnDrop: boolean
26 }
27
28 interface EditorGuidesOverlayProps {
29 selectedPageId: string
30 frameRef: RefObject<HTMLDivElement | null>
31 canvasHostRef: RefObject<HTMLDivElement | null>
32 previewIframeRef: RefObject<PreviewIframeHandle | null>
33 reloadSignal: number
34 slideSize: SlideSizePreset
35 }
36
37 export const RULER_SIZE = 22
38 export const RULER_GAP = 6
39 export const EDITOR_INSET = RULER_SIZE + RULER_GAP + 8
40 const EMPTY_GUIDES = { vertical: [], horizontal: [] }
41
42 const createTicks = (size: number, step = 20): number[] =>
43 Array.from({ length: Math.floor(size / step) + 1 }, (_, index) => index * step)
44
45 export function EditorGuidesOverlay({
46 selectedPageId,
47 frameRef,
48 canvasHostRef,
49 previewIframeRef,
50 reloadSignal,
51 slideSize
52 }: EditorGuidesOverlayProps): React.JSX.Element | null {
53 const pageWidth = slideSize.width
54 const pageHeight = slideSize.height
55 const horizontalTicks = useMemo(() => createTicks(pageWidth), [pageWidth])
56 const verticalTicks = useMemo(() => createTicks(pageHeight), [pageHeight])
57 const t = useT()
58 const guideSnapPointsRef = useRef<{ x: number[]; y: number[] }>({ x: [], y: [] })
59 const snapSyncTimerRef = useRef<number | null>(null)
60 const snapSyncVersionRef = useRef(0)
61 const editorSnapEnabled = useSessionDetailUiStore((state) => state.editorSnapEnabled)
62 const editorGridVisible = useSessionDetailUiStore((state) => state.editorGridVisible)
63 const editorGridSize = useSessionDetailUiStore((state) => state.editorGridSize)
64 const editorGuides = useSessionDetailUiStore(
65 (state) => state.editorGuidesByPage[selectedPageId] || EMPTY_GUIDES
66 )
67 const setEditorSnapEnabled = useSessionDetailUiStore((state) => state.setEditorSnapEnabled)
68 const setEditorGridVisible = useSessionDetailUiStore((state) => state.setEditorGridVisible)
69 const addEditorGuide = useSessionDetailUiStore((state) => state.addEditorGuide)
70 const moveEditorGuide = useSessionDetailUiStore((state) => state.moveEditorGuide)
71 const removeEditorGuide = useSessionDetailUiStore((state) => state.removeEditorGuide)
72 const [canvasMetrics, setCanvasMetrics] = useState<CanvasMetrics | null>(null)
73 const [guideDrag, setGuideDrag] = useState<GuideDragState | null>(null)
74 const hasEditorGuides = editorGuides.vertical.length > 0 || editorGuides.horizontal.length > 0
75
76 const editSnapSettings = useMemo(
77 () => ({
78 enabled: editorSnapEnabled,
79 guides: editorGuides,
80 grid: { enabled: editorGridVisible, size: editorGridSize }
81 }),
82 [editorGridSize, editorGridVisible, editorGuides, editorSnapEnabled]
83 )
84
85 const syncEditSnapSettings = useCallback(
86 (settings: typeof editSnapSettings): void => {
87 const syncVersion = snapSyncVersionRef.current + 1
88 snapSyncVersionRef.current = syncVersion
89 if (snapSyncTimerRef.current !== null) {
90 window.clearTimeout(snapSyncTimerRef.current)
91 snapSyncTimerRef.current = null
92 }
93 let attempts = 0
94 const trySync = (): void => {
95 if (snapSyncVersionRef.current !== syncVersion) return
96 attempts += 1
97 const handle = previewIframeRef.current
98 if (!handle) {
99 if (attempts < 4) snapSyncTimerRef.current = window.setTimeout(trySync, 50)
100 return
101 }
102 void handle.setEditSnapSettings(settings).then((synced) => {
103 if (snapSyncVersionRef.current !== syncVersion) return
104 if (synced || attempts >= 4) return
105 snapSyncTimerRef.current = window.setTimeout(trySync, 50)
106 })
107 }
108 snapSyncTimerRef.current = window.setTimeout(trySync, 0)
109 },
110 [previewIframeRef]
111 )
112
113 useEffect(() => {
114 return () => {
115 snapSyncVersionRef.current += 1
116 if (snapSyncTimerRef.current !== null) {
117 window.clearTimeout(snapSyncTimerRef.current)
118 snapSyncTimerRef.current = null
119 }
120 }
121 }, [])
122
123 const updateCanvasMetrics = useCallback((): void => {
124 const frame = frameRef.current
125 const host = canvasHostRef.current
126 if (!frame || !host) {
127 setCanvasMetrics(null)
128 return
129 }
130 const frameRect = frame.getBoundingClientRect()
131 const hostRect = host.getBoundingClientRect()
132 const scale = Math.min(hostRect.width / pageWidth, hostRect.height / pageHeight)
133 const width = pageWidth * scale
134 const height = pageHeight * scale
135 setCanvasMetrics({
136 left: hostRect.left - frameRect.left + Math.max(0, (hostRect.width - width) / 2),
137 top: hostRect.top - frameRect.top + Math.max(0, (hostRect.height - height) / 2),
138 width,
139 height,
140 scale
141 })
142 }, [canvasHostRef, frameRef, pageHeight, pageWidth])
143
144 useEffect(() => {
145 updateCanvasMetrics()
146 const frame = frameRef.current
147 const host = canvasHostRef.current
148 if (!frame || !host) return
149 const observer = new ResizeObserver(updateCanvasMetrics)
150 observer.observe(frame)
151 observer.observe(host)
152 return () => observer.disconnect()
153 }, [canvasHostRef, frameRef, selectedPageId, updateCanvasMetrics])
154
155 useEffect(() => {
156 syncEditSnapSettings(editSnapSettings)
157 }, [editSnapSettings, reloadSignal, selectedPageId, syncEditSnapSettings])
158
159 const snapGuidePosition = useCallback(
160 (axis: GuideAxis, rawPosition: number): number => {
161 if (!canvasMetrics) return rawPosition
162 const max = axis === 'vertical' ? pageWidth : pageHeight
163 const clamped = Math.max(0, Math.min(max, rawPosition))
164 if (!editorSnapEnabled) return clamped
165 const candidates =
166 axis === 'vertical' ? guideSnapPointsRef.current.x : guideSnapPointsRef.current.y
167 const threshold = 7 / Math.max(0.01, canvasMetrics.scale)
168 let best = clamped
169 let bestDistance = Number.POSITIVE_INFINITY
170 for (const candidate of candidates) {
171 const distance = Math.abs(candidate - clamped)
172 if (distance <= threshold && distance < bestDistance) {
173 best = candidate
174 bestDistance = distance
175 }
176 }
177 if (editorGridVisible) {
178 const gridPosition = Math.round(clamped / editorGridSize) * editorGridSize
179 const distance = Math.abs(gridPosition - clamped)
180 if (distance <= threshold && distance < bestDistance) best = gridPosition
181 }
182 return Number(Math.max(0, Math.min(max, best)).toFixed(1))
183 },
184 [canvasMetrics, editorGridSize, editorGridVisible, editorSnapEnabled, pageHeight, pageWidth]
185 )
186
187 const rawPositionFromPointer = useCallback(
188 (axis: GuideAxis, clientX: number, clientY: number): number => {
189 const frame = frameRef.current
190 if (!frame || !canvasMetrics) return 0
191 const frameRect = frame.getBoundingClientRect()
192 const pixelPosition =
193 axis === 'vertical'
194 ? clientX - frameRect.left - canvasMetrics.left
195 : clientY - frameRect.top - canvasMetrics.top
196 return pixelPosition / canvasMetrics.scale
197 },
198 [canvasMetrics, frameRef]
199 )
200
201 const positionFromPointer = useCallback(
202 (axis: GuideAxis, clientX: number, clientY: number): number =>
203 snapGuidePosition(axis, rawPositionFromPointer(axis, clientX, clientY)),
204 [rawPositionFromPointer, snapGuidePosition]
205 )
206
207 const isGuideOutsideCanvas = useCallback((axis: GuideAxis, position: number): boolean => {
208 const max = axis === 'vertical' ? pageWidth : pageHeight
209 return position < 0 || position > max
210 }, [pageHeight, pageWidth])
211
212 const guideDragRef = useRef<GuideDragState | null>(null)
213 const guideDragActive = guideDrag !== null
214
215 useEffect(() => {
216 guideDragRef.current = guideDrag
217 }, [guideDrag])
218
219 useEffect(() => {
220 guideDragRef.current = null
221 setGuideDrag(null)
222 }, [selectedPageId])
223
224 const startGuideDrag = useCallback(
225 (axis: GuideAxis, index: number, event: React.PointerEvent): void => {
226 if (!canvasMetrics) return
227 event.preventDefault()
228 event.stopPropagation()
229 guideSnapPointsRef.current = { x: [], y: [] }
230 void previewIframeRef.current?.readEditSnapPoints().then((points) => {
231 guideSnapPointsRef.current = points
232 })
233 const position =
234 editorGuides[axis][index] ?? positionFromPointer(axis, event.clientX, event.clientY)
235 const nextDrag = {
236 pageId: selectedPageId,
237 axis,
238 index,
239 position,
240 pointerOffset: rawPositionFromPointer(axis, event.clientX, event.clientY) - position,
241 removeOnDrop: false
242 }
243 guideDragRef.current = nextDrag
244 setGuideDrag(nextDrag)
245 },
246 [
247 canvasMetrics,
248 editorGuides,
249 positionFromPointer,
250 previewIframeRef,
251 rawPositionFromPointer,
252 selectedPageId
253 ]
254 )
255
256 const addGuideFromRuler = useCallback(
257 (axis: GuideAxis, event: React.MouseEvent): void => {
258 if (!canvasMetrics) return
259 event.preventDefault()
260 event.stopPropagation()
261 const rawPosition = rawPositionFromPointer(axis, event.clientX, event.clientY)
262 if (isGuideOutsideCanvas(axis, rawPosition)) return
263 addEditorGuide(selectedPageId, axis, snapGuidePosition(axis, rawPosition))
264 },
265 [
266 addEditorGuide,
267 canvasMetrics,
268 isGuideOutsideCanvas,
269 rawPositionFromPointer,
270 selectedPageId,
271 snapGuidePosition
272 ]
273 )
274
275 const clearCurrentPageGuides = useCallback((): void => {
276 if (!hasEditorGuides) return
277 for (let index = editorGuides.vertical.length - 1; index >= 0; index -= 1) {
278 removeEditorGuide(selectedPageId, 'vertical', index)
279 }
280 for (let index = editorGuides.horizontal.length - 1; index >= 0; index -= 1) {
281 removeEditorGuide(selectedPageId, 'horizontal', index)
282 }
283 }, [
284 editorGuides.horizontal,
285 editorGuides.vertical,
286 hasEditorGuides,
287 removeEditorGuide,
288 selectedPageId
289 ])
290
291 useEffect(() => {
292 if (!guideDragRef.current) return
293 const onPointerMove = (event: PointerEvent): void => {
294 const current = guideDragRef.current
295 if (!current) return
296 const rawPosition =
297 rawPositionFromPointer(current.axis, event.clientX, event.clientY) - current.pointerOffset
298 const removeOnDrop = isGuideOutsideCanvas(current.axis, rawPosition)
299 const nextDrag = {
300 ...current,
301 position: removeOnDrop
302 ? Number(rawPosition.toFixed(1))
303 : snapGuidePosition(current.axis, rawPosition),
304 removeOnDrop
305 }
306 guideDragRef.current = nextDrag
307 setGuideDrag(nextDrag)
308 }
309 const onPointerUp = (): void => {
310 const current = guideDragRef.current
311 if (!current) return
312 const axis = current.axis
313 if (current.removeOnDrop) {
314 removeEditorGuide(current.pageId, axis, current.index)
315 } else {
316 moveEditorGuide(current.pageId, axis, current.index, current.position)
317 }
318 guideDragRef.current = null
319 setGuideDrag(null)
320 }
321 window.addEventListener('pointermove', onPointerMove)
322 window.addEventListener('pointerup', onPointerUp, { once: true })
323 window.addEventListener('pointercancel', onPointerUp, { once: true })
324 return () => {
325 window.removeEventListener('pointermove', onPointerMove)
326 window.removeEventListener('pointerup', onPointerUp)
327 window.removeEventListener('pointercancel', onPointerUp)
328 }
329 }, [
330 guideDragActive,
331 isGuideOutsideCanvas,
332 moveEditorGuide,
333 rawPositionFromPointer,
334 removeEditorGuide,
335 selectedPageId,
336 snapGuidePosition
337 ])
338
339 if (!canvasMetrics) return null
340
341 return (
342 <>
343 {editorGridVisible && (
344 <div
345 className="pointer-events-none absolute z-20"
346 style={{
347 left: canvasMetrics.left,
348 top: canvasMetrics.top,
349 width: canvasMetrics.width,
350 height: canvasMetrics.height,
351 backgroundImage:
352 'linear-gradient(to right, rgba(77,174,255,0.18) 1px, transparent 1px), linear-gradient(to bottom, rgba(77,174,255,0.18) 1px, transparent 1px)',
353 backgroundSize: `${editorGridSize * canvasMetrics.scale}px ${editorGridSize * canvasMetrics.scale}px`
354 }}
355 />
356 )}
357
358 <div
359 className="absolute z-30 overflow-hidden border border-[#c9c0ae]/75 bg-[#eee8dc]/96 text-[8px] text-[#746d60] shadow-sm"
360 style={{
361 left: canvasMetrics.left,
362 top: canvasMetrics.top - RULER_GAP - RULER_SIZE,
363 width: canvasMetrics.width,
364 height: RULER_SIZE,
365 cursor: 'crosshair'
366 }}
367 onClick={(event) => addGuideFromRuler('vertical', event)}
368 title={t('sessionDetail.editorRulerHint')}
369 >
370 {horizontalTicks.map((value) => {
371 const major = value % 100 === 0
372 return (
373 <span
374 key={value}
375 className="pointer-events-none absolute bottom-0 border-l border-[#8f8778]/70"
376 style={{
377 left: value * canvasMetrics.scale,
378 height: major ? 10 : 5
379 }}
380 >
381 {major && value > 0 && (
382 <span className="absolute -left-2.5 -top-2.5 w-8 text-center">{value}</span>
383 )}
384 </span>
385 )
386 })}
387 </div>
388
389 <div
390 className="absolute z-30 overflow-hidden border border-[#c9c0ae]/75 bg-[#eee8dc]/96 text-[8px] text-[#746d60] shadow-sm"
391 style={{
392 left: canvasMetrics.left - RULER_GAP - RULER_SIZE,
393 top: canvasMetrics.top,
394 width: RULER_SIZE,
395 height: canvasMetrics.height,
396 cursor: 'crosshair'
397 }}
398 onClick={(event) => addGuideFromRuler('horizontal', event)}
399 title={t('sessionDetail.editorRulerHint')}
400 >
401 {verticalTicks.map((value) => {
402 const major = value % 100 === 0
403 return (
404 <span
405 key={value}
406 className="pointer-events-none absolute right-0 border-t border-[#8f8778]/70"
407 style={{
408 top: value * canvasMetrics.scale,
409 width: major ? 10 : 5
410 }}
411 >
412 {major && value > 0 && (
413 <span className="absolute -left-4 -top-2.5 w-7 -rotate-90 text-center">
414 {value}
415 </span>
416 )}
417 </span>
418 )
419 })}
420 </div>
421
422 <Tooltip>
423 <TooltipTrigger asChild>
424 <span
425 className="absolute z-40"
426 style={{
427 left: canvasMetrics.left - RULER_GAP - RULER_SIZE,
428 top: canvasMetrics.top - RULER_GAP - RULER_SIZE,
429 width: RULER_SIZE,
430 height: RULER_SIZE
431 }}
432 >
433 <button
434 type="button"
435 aria-label={t('sessionDetail.editorClearGuides')}
436 aria-disabled={!hasEditorGuides}
437 className={`flex h-full w-full items-center justify-center rounded-tl-md border border-[#c9c0ae]/75 transition-colors ${
438 hasEditorGuides
439 ? 'bg-[#e4ddcf] text-[#746d60] hover:bg-[#d9cfbd]'
440 : 'cursor-not-allowed bg-[#e4ddcf]/70 text-[#a59b8c]'
441 }`}
442 onClick={(event) => {
443 event.preventDefault()
444 event.stopPropagation()
445 if (!hasEditorGuides) return
446 clearCurrentPageGuides()
447 }}
448 >
449 <Trash2 className="h-3 w-3" />
450 </button>
451 </span>
452 </TooltipTrigger>
453 <TooltipContent side="top" align="start">
454 {t('sessionDetail.editorClearGuides')}
455 </TooltipContent>
456 </Tooltip>
457
458 {editorGuides.vertical.map((position, index) =>
459 guideDrag?.axis === 'vertical' && guideDrag.index === index ? null : (
460 <button
461 key={`vertical-${index}`}
462 type="button"
463 className="absolute z-40 w-[7px] -translate-x-1/2 cursor-col-resize border-0 bg-transparent p-0 before:absolute before:left-1/2 before:top-0 before:h-full before:w-px before:-translate-x-1/2 before:bg-[#ff4d8d] before:shadow-[0_0_0_1px_rgba(255,77,141,0.12)]"
464 style={{
465 left: canvasMetrics.left + position * canvasMetrics.scale,
466 top: canvasMetrics.top - RULER_GAP - RULER_SIZE,
467 height: canvasMetrics.height + RULER_GAP + RULER_SIZE
468 }}
469 onPointerDown={(event) => startGuideDrag('vertical', index, event)}
470 title={`${Math.round(position)} px · ${t('sessionDetail.editorGuideRemoveHint')}`}
471 />
472 )
473 )}
474 {editorGuides.horizontal.map((position, index) =>
475 guideDrag?.axis === 'horizontal' && guideDrag.index === index ? null : (
476 <button
477 key={`horizontal-${index}`}
478 type="button"
479 className="absolute z-40 h-[7px] -translate-y-1/2 cursor-row-resize border-0 bg-transparent p-0 before:absolute before:left-0 before:top-1/2 before:h-px before:w-full before:-translate-y-1/2 before:bg-[#ff4d8d] before:shadow-[0_0_0_1px_rgba(255,77,141,0.12)]"
480 style={{
481 left: canvasMetrics.left - RULER_GAP - RULER_SIZE,
482 top: canvasMetrics.top + position * canvasMetrics.scale,
483 width: canvasMetrics.width + RULER_GAP + RULER_SIZE
484 }}
485 onPointerDown={(event) => startGuideDrag('horizontal', index, event)}
486 title={`${Math.round(position)} px · ${t('sessionDetail.editorGuideRemoveHint')}`}
487 />
488 )
489 )}
490
491 {guideDrag?.axis === 'vertical' && (
492 <div
493 className={`pointer-events-none absolute z-50 w-px shadow-[0_0_0_1px_rgba(255,77,141,0.16)] ${
494 guideDrag.removeOnDrop
495 ? 'border-l border-dashed border-[#d75151] bg-transparent opacity-55'
496 : 'bg-[#ff4d8d]'
497 }`}
498 style={{
499 left: canvasMetrics.left + guideDrag.position * canvasMetrics.scale,
500 top: canvasMetrics.top - RULER_GAP - RULER_SIZE,
501 height: canvasMetrics.height + RULER_GAP + RULER_SIZE
502 }}
503 />
504 )}
505 {guideDrag?.axis === 'horizontal' && (
506 <div
507 className={`pointer-events-none absolute z-50 h-px shadow-[0_0_0_1px_rgba(255,77,141,0.16)] ${
508 guideDrag.removeOnDrop
509 ? 'border-t border-dashed border-[#d75151] bg-transparent opacity-55'
510 : 'bg-[#ff4d8d]'
511 }`}
512 style={{
513 left: canvasMetrics.left - RULER_GAP - RULER_SIZE,
514 top: canvasMetrics.top + guideDrag.position * canvasMetrics.scale,
515 width: canvasMetrics.width + RULER_GAP + RULER_SIZE
516 }}
517 />
518 )}
519
520 <div className="absolute right-2 top-1 z-50 flex items-center gap-0.5 rounded-md border border-[#cfc5b4]/80 bg-[#fffaf1]/94 p-0.5 shadow-[0_5px_14px_rgba(88,72,54,0.12)] backdrop-blur-sm">
521 <button
522 type="button"
523 aria-pressed={editorSnapEnabled}
524 aria-label={t('sessionDetail.editorSnap')}
525 title={t('sessionDetail.editorSnap')}
526 className={`flex h-6 w-6 items-center justify-center rounded transition-colors ${
527 editorSnapEnabled ? 'bg-[#dce8cf] text-[#4f613f]' : 'text-[#8b8376] hover:bg-[#eee7da]'
528 }`}
529 onClick={() => setEditorSnapEnabled(!editorSnapEnabled)}
530 >
531 <Magnet className="h-3 w-3" />
532 </button>
533 <button
534 type="button"
535 aria-pressed={editorGridVisible}
536 aria-label={t('sessionDetail.editorGrid')}
537 title={t('sessionDetail.editorGrid')}
538 className={`flex h-6 w-6 items-center justify-center rounded transition-colors ${
539 editorGridVisible ? 'bg-[#dce8cf] text-[#4f613f]' : 'text-[#8b8376] hover:bg-[#eee7da]'
540 }`}
541 onClick={() => setEditorGridVisible(!editorGridVisible)}
542 >
543 <Grid3X3 className="h-3 w-3" />
544 </button>
545 </div>
546 </>
547 )
548 }
549
549 lines Plain Text