返回 oh-my-ppt
HtmlEditorGuidesOverlay.tsx
根目录 / src / renderer / src / components / html-editor / HtmlEditorGuidesOverlay.tsx
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
281 lines Plain Text