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