| 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 |