返回 oh-my-ppt
edit-html.tsx
根目录 / src / renderer / src / pages / edit-html.tsx
1 import { useCallback, useEffect, useRef, useState, type ReactElement } from 'react'
2 import { useParams } from 'react-router-dom'
3 import { Copy, Loader2, RefreshCw, Trash2 } from 'lucide-react'
4 import { useT } from '@renderer/i18n'
5 import {
6 HtmlEditorCanvas,
7 type HtmlEditorCanvasHandle
8 } from '../components/html-editor/HtmlEditorCanvas'
9 import { HtmlEditorInsertRibbon } from '../components/html-editor/HtmlEditorInsertRibbon'
10 import { HtmlEditorHistoryDialog } from '../components/html-editor/HtmlEditorHistoryDialog'
11 import {
12 HtmlEditorToolbar,
13 type HtmlEditorMode
14 } from '../components/html-editor/HtmlEditorToolbar'
15 import { HtmlEditorAiPanel } from '../components/html-editor/HtmlEditorAiPanel'
16 import { HtmlEditorInspectorPanel } from '../components/html-editor/HtmlEditorInspectorPanel'
17 import { TooltipProvider } from '../components/ui/Tooltip'
18 import { useHtmlElementInsertion } from '../components/html-editor/useHtmlElementInsertion'
19 import { useHtmlEditorStore } from '../store/htmlEditorStore'
20 import { useHtmlEditStore } from '../store/htmlEditStore'
21 import { useHtmlEditHistoryStore } from '../store/htmlEditHistoryStore'
22 import { useHtmlEditorUiStore } from '../store/htmlEditorUiStore'
23 import { useHtmlEditorAiStore } from '../store/htmlEditorAiStore'
24
25 /**
26 * 独立 HTML 编辑器页面(/edit-html)。与 session-edit 完全解耦:
27 * 内存编辑(零 DB / 零 git),工作文件落 <storage>/html-editor/。
28 * 组合自有 HtmlEditorCanvas(document 模式)+ HtmlEditorGuidesOverlay + HTML 专用检视面板。
29 */
30 export function EditHtmlPage(): ReactElement {
31 const t = useT()
32 const { id } = useParams<{ id: string }>()
33
34 const docId = useHtmlEditorStore((s) => s.docId)
35 const title = useHtmlEditorStore((s) => s.title)
36 const htmlPath = useHtmlEditorStore((s) => s.htmlPath)
37 const designWidth = useHtmlEditorStore((s) => s.designWidth)
38 const importing = useHtmlEditorStore((s) => s.importing)
39 const loadError = useHtmlEditorStore((s) => s.error)
40
41 const selection = useHtmlEditStore((s) => s.selection)
42 const draft = useHtmlEditStore((s) => s.draft)
43 const aiModeEnabled = useHtmlEditorAiStore((s) => s.enabled)
44 const reloadSignal = useHtmlEditorUiStore((s) => s.previewKey)
45
46 const canvasRef = useRef<HtmlEditorCanvasHandle>(null)
47 const [historyOpen, setHistoryOpen] = useState(false)
48 const [mode, setMode] = useState<HtmlEditorMode>('preview')
49 const [activePreviewUrl, setActivePreviewUrl] = useState<string | undefined>()
50
51 const isEditing = mode === 'edit'
52 const isAiInspecting = isEditing && aiModeEnabled
53
54 // 挂载:注入 htmlEditStore 上下文
55 useEffect(() => {
56 useHtmlEditStore.getState().attach({
57 t: t as unknown as (key: string, params?: Record<string, unknown>) => string,
58 requestRefresh: () => useHtmlEditorUiStore.getState().bumpPreviewKey(),
59 bumpThumbnail: () => {
60 /* 内存编辑无需缩略图 */
61 },
62 getPageContext: () => {
63 const s = useHtmlEditorStore.getState()
64 if (!s.docId || !s.htmlPath) return null
65 return { pageId: s.docId, htmlPath: s.htmlPath, sessionId: 'html-editor' }
66 }
67 })
68 return () => {
69 useHtmlEditStore.getState().reset()
70 useHtmlEditHistoryStore.getState().clear()
71 useHtmlEditorAiStore.getState().reset()
72 useHtmlEditorUiStore.getState().clearSelectedElement()
73 }
74 }, [t])
75
76 useEffect(() => {
77 useHtmlEditorAiStore.getState().reset()
78 setActivePreviewUrl(undefined)
79 }, [docId])
80
81 // 按 :id 打开文档(列表页/导入跳转过来时加载)
82 useEffect(() => {
83 if (!id) return
84 if (useHtmlEditorStore.getState().docId === id) return
85 void useHtmlEditorStore.getState().openDocument(id)
86 }, [id])
87
88 const setCanvasHandle = useCallback((handle: HtmlEditorCanvasHandle | null): void => {
89 canvasRef.current = handle
90 useHtmlEditStore.getState().setIframeHandle(handle)
91 }, [])
92
93 const insertion = useHtmlElementInsertion({ designWidth: designWidth || 1280, t })
94
95 const handleRestored = useCallback((html: string): void => {
96 useHtmlEditorStore.getState().setHtml(html)
97 useHtmlEditStore.getState().reset()
98 useHtmlEditHistoryStore.getState().clear()
99 useHtmlEditorUiStore.getState().bumpPreviewKey()
100 }, [])
101
102 const handleModeChange = useCallback(
103 (nextMode: HtmlEditorMode): void => {
104 if (nextMode === mode) return
105 if (nextMode === 'preview') {
106 useHtmlEditorAiStore.getState().setEnabled(false)
107 useHtmlEditorUiStore.getState().clearSelectedElement()
108 useHtmlEditStore.getState().cancelEdit()
109 }
110 setMode(nextMode)
111 },
112 [mode]
113 )
114
115 const handleActiveUrlChange = useCallback((url: string): void => {
116 setActivePreviewUrl((current) => (current === url ? current : url))
117 }, [])
118
119 // 键盘:撤销/重做/保存/删除/退出
120 useEffect(() => {
121 if (!isEditing) return
122 const onKeyDown = (event: KeyboardEvent): void => {
123 const target = event.target
124 const editable =
125 target instanceof Element &&
126 Boolean(
127 target.closest('input, textarea, select, [contenteditable="true"], [contenteditable=""]')
128 )
129 if ((event.metaKey || event.ctrlKey) && !editable) {
130 const key = event.key.toLowerCase()
131 if (key === 'z') {
132 event.preventDefault()
133 if (event.shiftKey) useHtmlEditStore.getState().redo()
134 else useHtmlEditStore.getState().undo()
135 return
136 }
137 if (key === 'y') {
138 event.preventDefault()
139 useHtmlEditStore.getState().redo()
140 return
141 }
142 if (key === 's') {
143 event.preventDefault()
144 void useHtmlEditStore.getState().save()
145 return
146 }
147 }
148 if (event.key === 'Escape') {
149 if (useHtmlEditorAiStore.getState().enabled) {
150 useHtmlEditorAiStore.getState().setEnabled(false)
151 useHtmlEditorUiStore.getState().clearSelectedElement()
152 } else {
153 useHtmlEditStore.getState().cancelEdit()
154 }
155 return
156 }
157 if ((event.key === 'Delete' || event.key === 'Backspace') && !editable && selection) {
158 event.preventDefault()
159 useHtmlEditStore.getState().deleteSelected()
160 }
161 }
162 window.addEventListener('keydown', onKeyDown)
163 return () => window.removeEventListener('keydown', onKeyDown)
164 }, [isEditing, selection])
165
166 // 加载中(openDocument 进行时)
167 if (!docId && loadError) {
168 return (
169 <div className="flex h-screen w-screen flex-col items-center justify-center gap-3 bg-[#f5f1e8] text-sm text-[#8a8676]">
170 <p className="max-w-md text-center text-[#8e5a53]">{loadError}</p>
171 <button
172 type="button"
173 onClick={() => void useHtmlEditorStore.getState().openDocument(id || '')}
174 disabled={importing || !id}
175 className="inline-flex items-center gap-1.5 rounded-md border border-[#c9c0ad] px-3 py-1.5 text-[#3e4a32] hover:bg-[#ece5d6] disabled:opacity-50"
176 >
177 <RefreshCw className="h-3.5 w-3.5" />
178 {t('htmlEditor.retry')}
179 </button>
180 </div>
181 )
182 }
183
184 if (!docId) {
185 return (
186 <div className="flex h-screen w-screen flex-col items-center justify-center gap-3 bg-[#f5f1e8] text-sm text-[#8a8676]">
187 <Loader2 className="h-5 w-5 animate-spin text-[#657050]" aria-hidden="true" />
188 <span>{t('common.loading')}</span>
189 </div>
190 )
191 }
192
193 return (
194 <TooltipProvider delayDuration={180}>
195 <div className="flex h-screen w-screen flex-col bg-[#efe9dc]">
196 {/* 顶部工具条 */}
197 <HtmlEditorToolbar
198 onOpenHistory={() => setHistoryOpen(true)}
199 mode={mode}
200 onModeChange={handleModeChange}
201 />
202
203 <div className="flex min-h-0 flex-1">
204 {/* 左侧插入条(hover 画廊) */}
205 {isEditing && (
206 <HtmlEditorInsertRibbon insertion={insertion} disabled={!docId || aiModeEnabled} />
207 )}
208
209 {/* 画布 */}
210 <main className="relative min-w-0 flex-1">
211 <div
212 className={`absolute inset-0 overflow-hidden ${
213 isAiInspecting ? 'rounded-xl bg-[#e8e1d4] p-3' : 'bg-[#f5f1e8]'
214 }`}
215 >
216 <HtmlEditorCanvas
217 ref={setCanvasHandle}
218 key={`hedit-${docId}-${reloadSignal}`}
219 htmlPath={htmlPath ?? undefined}
220 pageId={docId ?? undefined}
221 title={title || 'html-editor'}
222 designWidth={designWidth || 1280}
223 reloadSignal={reloadSignal}
224 inspectable
225 playback={!isEditing}
226 activeUrl={activePreviewUrl}
227 onActiveUrlChange={handleActiveUrlChange}
228 inspecting={isAiInspecting}
229 interactionMode={isAiInspecting ? 'ai-inspect' : isEditing ? 'edit' : 'preview'}
230 editMode={isEditing && !isAiInspecting}
231 onSelectorSelected={(selector, label, elementTag, elementText) =>
232 useHtmlEditorUiStore
233 .getState()
234 .setSelectedElement(selector, label, elementTag, elementText)
235 }
236 onElementMoved={(payload) => useHtmlEditStore.getState().handleMoved(payload)}
237 onElementSelected={(payload) => useHtmlEditStore.getState().selectElement(payload)}
238 onInspectExit={() => {
239 if (useHtmlEditorAiStore.getState().enabled) {
240 useHtmlEditorAiStore.getState().setEnabled(false)
241 useHtmlEditorUiStore.getState().clearSelectedElement()
242 } else {
243 useHtmlEditStore.getState().cancelEdit()
244 }
245 }}
246 onDidReload={() => useHtmlEditStore.getState().replayPending()}
247 onDeleteRequest={(selector) =>
248 useHtmlEditStore.getState().deleteBySelector(selector)
249 }
250 />
251 </div>
252 </main>
253
254 {/* 右侧检视面板(仅选中元素时显示) */}
255 {isAiInspecting ? (
256 <HtmlEditorAiPanel />
257 ) : isEditing && selection ? (
258 <aside className="flex w-72 shrink-0 flex-col border-l border-[#e2dccf] bg-[#f5f1e8]">
259 <div className="flex shrink-0 items-center gap-1 border-b border-[#e2dccf] px-2 py-1.5">
260 <button
261 type="button"
262 onClick={() => void insertion.copyElement()}
263 title={t('sessionDetail.copyElement')}
264 className="rounded-md p-1.5 text-[#5d6b4d] hover:bg-[#ece5d6]"
265 >
266 <Copy className="h-3.5 w-3.5" />
267 </button>
268 <button
269 type="button"
270 onClick={() => useHtmlEditStore.getState().deleteSelected()}
271 title={t('htmlEditor.delete')}
272 className="rounded-md p-1.5 text-[#8e5a53] hover:bg-[#f3e6e2]"
273 >
274 <Trash2 className="h-3.5 w-3.5" />
275 </button>
276 </div>
277 <div className="min-h-0 flex-1 overflow-y-auto">
278 <HtmlEditorInspectorPanel
279 selection={selection}
280 draft={draft}
281 onDraftChange={(d, options) =>
282 useHtmlEditStore.getState().updateDraft(d, options)
283 }
284 onClose={() => useHtmlEditStore.getState().cancelEdit()}
285 />
286 </div>
287 </aside>
288 ) : null}
289 </div>
290 </div>
291 <HtmlEditorHistoryDialog
292 open={historyOpen}
293 onOpenChange={setHistoryOpen}
294 docId={docId ?? ''}
295 onRestored={handleRestored}
296 />
297 </TooltipProvider>
298 )
299 }
300
300 lines Plain Text