| 1 | import { |
| 2 | useCallback, |
| 3 | useEffect, |
| 4 | useLayoutEffect, |
| 5 | useRef, |
| 6 | useState, |
| 7 | type PointerEvent as ReactPointerEvent, |
| 8 | type RefObject |
| 9 | } from 'react' |
| 10 | import { useHtmlEditorUiStore } from '../../store/htmlEditorUiStore' |
| 11 | import type { EditSnapPoints, EditSnapSettings } from '@arcsin1/presentation-editor-runtime' |
| 12 | |
| 13 | export interface GuidesSnapBridge { |
| 14 | setEditSnapSettings: (settings: EditSnapSettings) => Promise<boolean> |
| 15 | readEditSnapPoints: () => Promise<EditSnapPoints> |
| 16 | } |
| 17 | |
| 18 | export const RULER_SIZE = 18 |
| 19 | export const RULER_GAP = 4 |
| 20 | export const EDITOR_INSET = RULER_SIZE + RULER_GAP |
| 21 | const TICK_STEP = 20 |
| 22 | const MAJOR_STEP = 100 |
| 23 | |
| 24 | type Axis = 'vertical' | 'horizontal' |
| 25 | |
| 26 | interface Metrics { |
| 27 | left: number |
| 28 | top: number |
| 29 | width: number |
| 30 | height: number |
| 31 | rootW: number |
| 32 | rootH: number |
| 33 | } |
| 34 | |
| 35 | /** |
| 36 | * 独立 HTML 编辑器的标尺 + 辅助线 + 网格 overlay(document 模式专用,重写)。 |
| 37 | * - 标尺条固定在画布左上 margin(始终可见),刻度随滚动更新(左标尺)。 |
| 38 | * - 网格随内容滚动;辅助线可从标尺拖出、拖动、双击删除。 |
| 39 | * - 通过 snapBridge 把 guides/grid/snap 同步给 presentation editor runtime,元素可吸附。 |
| 40 | */ |
| 41 | export function HtmlEditorGuidesOverlay({ |
| 42 | rootRef, |
| 43 | scrollRef, |
| 44 | hostRef, |
| 45 | previewIframeRef, |
| 46 | designWidth, |
| 47 | contentHeight, |
| 48 | scale, |
| 49 | selectedPageId, |
| 50 | reloadSignal |
| 51 | }: { |
| 52 | rootRef: RefObject<HTMLDivElement | null> |
| 53 | scrollRef: RefObject<HTMLDivElement | null> |
| 54 | hostRef: RefObject<HTMLDivElement | null> |
| 55 | previewIframeRef: RefObject<GuidesSnapBridge | null> |
| 56 | designWidth: number |
| 57 | contentHeight: number |
| 58 | scale: number |
| 59 | selectedPageId: string |
| 60 | reloadSignal: number |
| 61 | }): React.JSX.Element | null { |
| 62 | const [m, setM] = useState<Metrics | null>(null) |
| 63 | |
| 64 | const editorSnapEnabled = useHtmlEditorUiStore((s) => s.editorSnapEnabled) |
| 65 | const editorGridVisible = useHtmlEditorUiStore((s) => s.editorGridVisible) |
| 66 | const editorGridSize = useHtmlEditorUiStore((s) => s.editorGridSize) |
| 67 | const guides = |
| 68 | useHtmlEditorUiStore((s) => s.editorGuidesByPage[selectedPageId]) ?? { |
| 69 | vertical: [], |
| 70 | horizontal: [] |
| 71 | } |
| 72 | const addEditorGuide = useHtmlEditorUiStore((s) => s.addEditorGuide) |
| 73 | const moveEditorGuide = useHtmlEditorUiStore((s) => s.moveEditorGuide) |
| 74 | const removeEditorGuide = useHtmlEditorUiStore((s) => s.removeEditorGuide) |
| 75 | |
| 76 | const measure = useCallback((): void => { |
| 77 | const root = rootRef.current |
| 78 | const host = hostRef.current |
| 79 | if (!root || !host) { |
| 80 | setM(null) |
| 81 | return |
| 82 | } |
| 83 | const rr = root.getBoundingClientRect() |
| 84 | const hr = host.getBoundingClientRect() |
| 85 | setM({ |
| 86 | left: hr.left - rr.left, |
| 87 | top: hr.top - rr.top, |
| 88 | width: hr.width, |
| 89 | height: hr.height, |
| 90 | rootW: rr.width, |
| 91 | rootH: rr.height |
| 92 | }) |
| 93 | }, [hostRef, rootRef]) |
| 94 | |
| 95 | useLayoutEffect(() => { |
| 96 | measure() |
| 97 | const scroll = scrollRef.current |
| 98 | if (scroll) { |
| 99 | const onScroll = (): void => measure() |
| 100 | scroll.addEventListener('scroll', onScroll, { passive: true }) |
| 101 | return () => scroll.removeEventListener('scroll', onScroll) |
| 102 | } |
| 103 | return undefined |
| 104 | }, [scrollRef, measure]) |
| 105 | |
| 106 | useEffect(() => { |
| 107 | const root = rootRef.current |
| 108 | const host = hostRef.current |
| 109 | if (!root || !host) return undefined |
| 110 | const ro = new ResizeObserver(() => measure()) |
| 111 | ro.observe(root) |
| 112 | ro.observe(host) |
| 113 | return () => ro.disconnect() |
| 114 | }, [hostRef, rootRef, measure]) |
| 115 | |
| 116 | // 同步 snap 设置(guides/grid/enable)给 presentation editor runtime |
| 117 | useEffect(() => { |
| 118 | void previewIframeRef.current?.setEditSnapSettings({ |
| 119 | enabled: editorSnapEnabled, |
| 120 | guides: { vertical: guides.vertical, horizontal: guides.horizontal }, |
| 121 | grid: { enabled: editorGridVisible, size: editorGridSize } |
| 122 | }) |
| 123 | }, [ |
| 124 | previewIframeRef, |
| 125 | editorSnapEnabled, |
| 126 | editorGridVisible, |
| 127 | editorGridSize, |
| 128 | guides.vertical, |
| 129 | guides.horizontal, |
| 130 | reloadSignal, |
| 131 | selectedPageId |
| 132 | ]) |
| 133 | |
| 134 | const dragRef = useRef<{ axis: Axis; index: number } | null>(null) |
| 135 | |
| 136 | const startGuideDrag = (axis: Axis, index: number) => (event: ReactPointerEvent): void => { |
| 137 | event.preventDefault() |
| 138 | event.stopPropagation() |
| 139 | dragRef.current = { axis, index } |
| 140 | const onMove = (e: PointerEvent): void => { |
| 141 | const cur = dragRef.current |
| 142 | const mm = m |
| 143 | if (!cur || !mm) return |
| 144 | const pos = |
| 145 | cur.axis === 'vertical' |
| 146 | ? (e.clientX - (mm.left + (rootRef.current?.getBoundingClientRect().left ?? 0))) / scale |
| 147 | : (e.clientY - (mm.top + (rootRef.current?.getBoundingClientRect().top ?? 0))) / scale |
| 148 | if (Number.isFinite(pos) && pos >= 0) { |
| 149 | moveEditorGuide(selectedPageId, cur.axis, cur.index, Math.round(pos * 10) / 10) |
| 150 | } |
| 151 | } |
| 152 | const onUp = (): void => { |
| 153 | dragRef.current = null |
| 154 | window.removeEventListener('pointermove', onMove) |
| 155 | window.removeEventListener('pointerup', onUp) |
| 156 | } |
| 157 | window.addEventListener('pointermove', onMove) |
| 158 | window.addEventListener('pointerup', onUp) |
| 159 | } |
| 160 | |
| 161 | const addGuideFromRuler = (axis: Axis) => (event: React.MouseEvent): void => { |
| 162 | if (!m) return |
| 163 | const root = rootRef.current |
| 164 | if (!root) return |
| 165 | const rr = root.getBoundingClientRect() |
| 166 | const pos = |
| 167 | axis === 'vertical' |
| 168 | ? (event.clientX - rr.left - m.left) / scale |
| 169 | : (event.clientY - rr.top - m.top) / scale |
| 170 | if (!Number.isFinite(pos) || pos < 0) return |
| 171 | addEditorGuide(selectedPageId, axis, Math.round(pos * 10) / 10) |
| 172 | } |
| 173 | |
| 174 | if (!m) return null |
| 175 | |
| 176 | const canvasW = designWidth * scale |
| 177 | const cx = m.left |
| 178 | const cy = m.top |
| 179 | const hTicks = Array.from({ length: Math.floor(designWidth / TICK_STEP) + 1 }, (_, i) => i * TICK_STEP) |
| 180 | const vTicks = Array.from( |
| 181 | { length: Math.floor(contentHeight / TICK_STEP) + 1 }, |
| 182 | (_, i) => i * TICK_STEP |
| 183 | ) |
| 184 | |
| 185 | const guideLineClass = |
| 186 | 'pointer-events-auto absolute bg-[#ff5a5f]/80 shadow-[0_0_0_0.5px_rgba(255,90,95,0.4)]' |
| 187 | |
| 188 | return ( |
| 189 | <div className="pointer-events-none absolute inset-0 z-20 select-none"> |
| 190 | {/* 顶部标尺(固定在顶部 margin,始终可见) */} |
| 191 | <div |
| 192 | className="pointer-events-auto absolute top-0 overflow-hidden border-b border-[#c9c0ad]/70 bg-[#eee8dc]/96 text-[8px] text-[#746d60]" |
| 193 | style={{ left: cx, width: canvasW, height: RULER_SIZE, cursor: 'crosshair' }} |
| 194 | onMouseDown={addGuideFromRuler('vertical')} |
| 195 | > |
| 196 | {hTicks.map((value) => { |
| 197 | const major = value % MAJOR_STEP === 0 |
| 198 | return ( |
| 199 | <span |
| 200 | key={value} |
| 201 | className="pointer-events-none absolute bottom-0 border-l border-[#8f8778]/70" |
| 202 | style={{ left: value * scale, height: major ? 10 : 5 }} |
| 203 | > |
| 204 | {major && value > 0 && ( |
| 205 | <span className="absolute -left-2.5 -top-2.5 w-8 text-center">{value}</span> |
| 206 | )} |
| 207 | </span> |
| 208 | ) |
| 209 | })} |
| 210 | </div> |
| 211 | |
| 212 | {/* 左侧标尺(固定在左侧 margin,刻度随滚动) */} |
| 213 | <div |
| 214 | className="pointer-events-auto absolute left-0 top-0 overflow-hidden border-r border-[#c9c0ad]/70 bg-[#eee8dc]/96 text-[8px] text-[#746d60]" |
| 215 | style={{ width: RULER_SIZE, height: m.rootH, cursor: 'crosshair' }} |
| 216 | onMouseDown={addGuideFromRuler('horizontal')} |
| 217 | > |
| 218 | {vTicks.map((value) => { |
| 219 | const major = value % MAJOR_STEP === 0 |
| 220 | const top = cy + value * scale |
| 221 | if (top < -20 || top > m.rootH + 20) return null |
| 222 | return ( |
| 223 | <span |
| 224 | key={value} |
| 225 | className="pointer-events-none absolute right-0 border-t border-[#8f8778]/70" |
| 226 | style={{ top, width: major ? 10 : 5, height: 0 }} |
| 227 | > |
| 228 | {major && value > 0 && ( |
| 229 | <span className="absolute -left-1 -top-2 w-8 text-center">{value}</span> |
| 230 | )} |
| 231 | </span> |
| 232 | ) |
| 233 | })} |
| 234 | </div> |
| 235 | |
| 236 | {/* 左上角块 */} |
| 237 | <div |
| 238 | className="absolute left-0 top-0 border-b border-r border-[#c9c0ad]/70 bg-[#eee8dc]/96" |
| 239 | style={{ width: RULER_SIZE, height: RULER_SIZE }} |
| 240 | /> |
| 241 | |
| 242 | {/* 网格(随内容滚动) */} |
| 243 | {editorGridVisible && ( |
| 244 | <div |
| 245 | className="pointer-events-none absolute" |
| 246 | style={{ |
| 247 | left: cx, |
| 248 | top: cy, |
| 249 | width: canvasW, |
| 250 | height: contentHeight * scale, |
| 251 | backgroundImage: |
| 252 | 'linear-gradient(to right, rgba(77,174,255,0.16) 1px, transparent 1px), linear-gradient(to bottom, rgba(77,174,255,0.16) 1px, transparent 1px)', |
| 253 | backgroundSize: `${editorGridSize * scale}px ${editorGridSize * scale}px` |
| 254 | }} |
| 255 | /> |
| 256 | )} |
| 257 | |
| 258 | {/* 垂直辅助线 */} |
| 259 | {guides.vertical.map((g, index) => ( |
| 260 | <div |
| 261 | key={`v-${index}`} |
| 262 | className={guideLineClass} |
| 263 | style={{ left: cx + g * scale, top: 0, width: 1, height: m.rootH, cursor: 'ew-resize' }} |
| 264 | onPointerDown={startGuideDrag('vertical', index)} |
| 265 | onDoubleClick={() => removeEditorGuide(selectedPageId, 'vertical', index)} |
| 266 | /> |
| 267 | ))} |
| 268 | {/* 水平辅助线 */} |
| 269 | {guides.horizontal.map((g, index) => ( |
| 270 | <div |
| 271 | key={`h-${index}`} |
| 272 | className={guideLineClass} |
| 273 | style={{ top: cy + g * scale, left: 0, width: m.rootW, height: 1, cursor: 'ns-resize' }} |
| 274 | onPointerDown={startGuideDrag('horizontal', index)} |
| 275 | onDoubleClick={() => removeEditorGuide(selectedPageId, 'horizontal', index)} |
| 276 | /> |
| 277 | ))} |
| 278 | </div> |
| 279 | ) |
| 280 | } |
| 281 |