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