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