返回 oh-my-ppt
html-editor-handlers.ts
根目录 / src / main / html-editor / html-editor-handlers.ts
1 import { app, BrowserWindow, dialog, ipcMain, shell, type WebContents } from 'electron'
2 import fs from 'fs'
3 import path from 'path'
4 import { pathToFileURL } from 'node:url'
5 import log from 'electron-log/main.js'
6 import { nanoid } from 'nanoid'
7 import * as cheerio from 'cheerio'
8 import type { IpcContext } from '../ipc/context'
9 import { allowLocalAssetRoot } from '../io/local-asset-roots'
10 import {
11 clampDragValue,
12 clampSizeValue,
13 ensureElementAnchorInHtml,
14 normalizeChildStyleUpdates,
15 normalizeLayoutIslandStyle,
16 patchAddElement,
17 patchDraggedElementStyle,
18 patchElementProperties,
19 patchGenericElementProperties,
20 removeLegacyVideoAutoplayScript,
21 stableSelectorFor
22 } from '../element-editor/shared'
23 import { normalizeImportedHtml } from './html-editor-import'
24 import {
25 commitHtmlFile,
26 ensureHtmlRepo,
27 getHtmlRepoHead,
28 readHtmlAtCommit,
29 restoreHtmlFileAtCommit,
30 restoreHtmlRepoHead
31 } from './html-editor-git'
32 import {
33 refreshHtmlEditorCoverThumbnail,
34 warmHtmlEditorCoverThumbnails
35 } from './html-editor-thumbnail'
36 import {
37 getHtmlEditorMediaExtensions,
38 importHtmlEditorMedia,
39 listHtmlEditorMedia,
40 type HtmlEditorMediaType
41 } from './html-editor-media'
42
43 const HTML_EDITOR_DIRNAME = 'html-editor'
44 const HTML_EDITOR_HTML_CACHE_LIMIT = 24
45 const htmlDocumentHtmlCache = new Map<string, string>()
46 const htmlDocumentOpenCache = new Map<
47 string,
48 { html: string; modifiedAtMs: number; size: number }
49 >()
50
51 function rememberHtmlEditorDocumentHtml(docId: string, html: string): void {
52 if (!docId || !html) return
53 htmlDocumentHtmlCache.delete(docId)
54 htmlDocumentHtmlCache.set(docId, html)
55 while (htmlDocumentHtmlCache.size > HTML_EDITOR_HTML_CACHE_LIMIT) {
56 const oldestDocId = htmlDocumentHtmlCache.keys().next().value
57 if (!oldestDocId) break
58 htmlDocumentHtmlCache.delete(oldestDocId)
59 }
60 }
61
62 function rememberHtmlEditorOpenHtml(
63 docId: string,
64 html: string,
65 file: { mtimeMs: number; size: number }
66 ): void {
67 if (!docId || !html) return
68 htmlDocumentOpenCache.delete(docId)
69 htmlDocumentOpenCache.set(docId, { html, modifiedAtMs: file.mtimeMs, size: file.size })
70 while (htmlDocumentOpenCache.size > HTML_EDITOR_HTML_CACHE_LIMIT) {
71 const oldestDocId = htmlDocumentOpenCache.keys().next().value
72 if (!oldestDocId) break
73 htmlDocumentOpenCache.delete(oldestDocId)
74 }
75 }
76
77 function forgetHtmlEditorDocumentHtml(docId: string): void {
78 htmlDocumentHtmlCache.delete(docId)
79 htmlDocumentOpenCache.delete(docId)
80 }
81
82 export function resolveHtmlEditorDocumentPath(input: {
83 storagePath: string
84 docId: string
85 storedHtmlPath: string
86 }): string {
87 const expectedPath = path.resolve(
88 input.storagePath,
89 HTML_EDITOR_DIRNAME,
90 input.docId,
91 'current.html'
92 )
93 if (path.resolve(input.storedHtmlPath) !== expectedPath) {
94 throw new Error('HTML 编辑文档路径无效')
95 }
96 return expectedPath
97 }
98
99 function asRecord(payload: unknown): Record<string, unknown> {
100 return payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
101 }
102
103 function resolveRuntimeScriptHrefs(): string[] {
104 const resourcesDir = app.isPackaged
105 ? path.join(process.resourcesPath, 'app.asar.unpacked', 'resources')
106 : path.join(process.cwd(), 'resources')
107 return ['chart.v4.js', 'ppt-runtime.js']
108 .map((fileName) => path.join(resourcesDir, fileName))
109 .filter((filePath) => fs.existsSync(filePath))
110 .map((filePath) => pathToFileURL(filePath).href)
111 }
112
113 export interface HtmlEditorImportResult {
114 docId: string
115 title: string
116 htmlPath: string
117 sourcePath: string
118 designWidth: number
119 html: string
120 }
121
122 /**
123 * 把一批编辑应用到 html 串(纯函数,便于单测)。
124 * 编排复制自 `edit:save-batch`(deletes → adds → drags → text → property → 清理),
125 * 去掉文件读取、sessionId 校验、git history——输入即真相源 html 串。
126 */
127 export function applyEditsToHtml(
128 html: string,
129 pageId: string,
130 batch: {
131 dragEdits?: unknown
132 textEdits?: unknown
133 propertyEdits?: unknown
134 deletes?: unknown
135 addElements?: unknown
136 }
137 ): { html: string; warnings: string[] } {
138 const warnings: string[] = []
139 let out = html
140
141 const rawDeletes = Array.isArray(batch.deletes) ? batch.deletes : []
142 const rawAddElements = Array.isArray(batch.addElements) ? batch.addElements : []
143 const rawDrag = Array.isArray(batch.dragEdits) ? batch.dragEdits : []
144 const rawText = Array.isArray(batch.textEdits) ? batch.textEdits : []
145 const rawProperty = Array.isArray(batch.propertyEdits) ? batch.propertyEdits : []
146
147 // deletes(含 art-text <style> 清理)
148 for (const item of rawDeletes) {
149 if (!item || typeof item !== 'object') continue
150 const d = item as { selector?: unknown }
151 const selector = typeof d.selector === 'string' ? d.selector.trim() : ''
152 if (!selector) continue
153 const $ = cheerio.load(out, { scriptingEnabled: false })
154 const target = $(selector).first()
155 if (target.length > 0) {
156 const artTextBlockId =
157 target.attr('data-ppt-art-text') !== undefined
158 ? (target.attr('data-block-id') || '').trim()
159 : ''
160 if (artTextBlockId) {
161 $('style[data-ppt-art-text-style]').each((_, styleNode) => {
162 const style = $(styleNode)
163 if ((style.attr('data-ppt-art-text-style') || '') === artTextBlockId) style.remove()
164 })
165 }
166 target.remove()
167 out = $.html()
168 }
169 }
170
171 // adds
172 for (const item of rawAddElements) {
173 if (!item || typeof item !== 'object') continue
174 const e = item as {
175 parentSelector?: unknown
176 htmlFragment?: unknown
177 insertIndex?: unknown
178 }
179 const parentSelector = typeof e.parentSelector === 'string' ? e.parentSelector.trim() : ''
180 const htmlFragment = typeof e.htmlFragment === 'string' ? e.htmlFragment : ''
181 if (!parentSelector || !htmlFragment) continue
182 const insertIndex = typeof e.insertIndex === 'number' ? e.insertIndex : -1
183 out = patchAddElement(out, parentSelector, htmlFragment, insertIndex)
184 }
185
186 // drags
187 for (const item of rawDrag) {
188 if (!item || typeof item !== 'object') continue
189 const e = item as {
190 selector?: unknown
191 x?: unknown
192 y?: unknown
193 width?: unknown
194 height?: unknown
195 childUpdates?: unknown
196 layoutIsland?: unknown
197 isAbsoluteMode?: unknown
198 zIndex?: unknown
199 zIndexOnly?: unknown
200 }
201 const selector = typeof e.selector === 'string' ? e.selector.trim() : ''
202 if (!selector) continue
203 const zIndex = typeof e.zIndex === 'number' ? e.zIndex : undefined
204 const zIndexOnly = !!e.zIndexOnly
205 out = patchDraggedElementStyle(
206 out,
207 selector,
208 clampDragValue(e.x),
209 clampDragValue(e.y),
210 clampSizeValue(e.width),
211 clampSizeValue(e.height),
212 normalizeChildStyleUpdates(e.childUpdates),
213 !!e.isAbsoluteMode,
214 zIndex,
215 zIndexOnly,
216 normalizeLayoutIslandStyle(e.layoutIsland)
217 )
218 }
219
220 // text
221 for (const item of rawText) {
222 if (!item || typeof item !== 'object') continue
223 const e = item as { selector?: unknown; patch?: unknown }
224 const selector = typeof e.selector === 'string' ? e.selector.trim() : ''
225 if (!selector) continue
226 const rawPatch =
227 e.patch && typeof e.patch === 'object' ? (e.patch as Record<string, unknown>) : {}
228 const rawStyle =
229 rawPatch.style && typeof rawPatch.style === 'object'
230 ? (rawPatch.style as Record<string, unknown>)
231 : {}
232 out = patchElementProperties(out, selector, {
233 html: typeof rawPatch.html === 'string' ? rawPatch.html : undefined,
234 text: typeof rawPatch.text === 'string' ? rawPatch.text : undefined,
235 style: {
236 color: typeof rawStyle.color === 'string' ? rawStyle.color : undefined,
237 fontSize: typeof rawStyle.fontSize === 'string' ? rawStyle.fontSize : undefined,
238 fontWeight: typeof rawStyle.fontWeight === 'string' ? rawStyle.fontWeight : undefined,
239 textAlign: typeof rawStyle.textAlign === 'string' ? rawStyle.textAlign : undefined
240 }
241 })
242 }
243
244 // property(blockId 优先解析 selector)
245 for (const item of rawProperty) {
246 if (!item || typeof item !== 'object') continue
247 const e = item as { selector?: unknown; blockId?: unknown; patch?: unknown }
248 const selector = typeof e.selector === 'string' ? e.selector.trim() : ''
249 const blockId = typeof e.blockId === 'string' ? e.blockId.trim() : ''
250 if (!selector && !blockId) continue
251 const $ = cheerio.load(out, { scriptingEnabled: false })
252 const blockSelector = blockId ? stableSelectorFor(pageId, blockId) : ''
253 const resolvedSelector =
254 blockSelector && $(blockSelector).first().length > 0
255 ? blockSelector
256 : selector && $(selector).first().length > 0
257 ? selector
258 : ''
259 if (!resolvedSelector) {
260 warnings.push(`属性编辑目标不存在:${blockId || selector}`)
261 continue
262 }
263 const patch = e.patch && typeof e.patch === 'object' ? (e.patch as Record<string, unknown>) : {}
264 const style = patch.style && typeof patch.style === 'object' ? patch.style : undefined
265 const attrs = patch.attrs && typeof patch.attrs === 'object' ? patch.attrs : undefined
266 const formula = patch.formula && typeof patch.formula === 'object' ? patch.formula : undefined
267 const chart = patch.chart && typeof patch.chart === 'object' ? patch.chart : undefined
268 try {
269 out = patchGenericElementProperties(out, resolvedSelector, {
270 text: typeof patch.text === 'string' ? patch.text : undefined,
271 html: typeof patch.html === 'string' ? patch.html : undefined,
272 formula: formula as Parameters<typeof patchGenericElementProperties>[2]['formula'],
273 chart: chart as Parameters<typeof patchGenericElementProperties>[2]['chart'],
274 textTarget: patch.textTarget,
275 style: style as Parameters<typeof patchGenericElementProperties>[2]['style'],
276 attrs: attrs as Parameters<typeof patchGenericElementProperties>[2]['attrs']
277 })
278 } catch (error) {
279 warnings.push(
280 error instanceof Error
281 ? `属性编辑失败:${error.message}`
282 : `属性编辑失败:${blockId || selector}`
283 )
284 }
285 }
286
287 out = removeLegacyVideoAutoplayScript(out)
288 return { html: out, warnings }
289 }
290
291 async function resolveHtmlEditorDocument(
292 ctx: Pick<IpcContext, 'db' | 'resolveStoragePath'>,
293 docId: string
294 ) {
295 const doc = await ctx.db.getHtmlEditDocument(docId)
296 if (!doc) throw new Error('文档不存在')
297 const storagePath = await ctx.resolveStoragePath()
298 const htmlPath = resolveHtmlEditorDocumentPath({
299 storagePath,
300 docId: doc.id,
301 storedHtmlPath: doc.htmlPath
302 })
303 return { doc, htmlPath, dir: path.dirname(htmlPath) }
304 }
305
306 export async function resolveHtmlEditorDocumentWorkspace(
307 ctx: Pick<IpcContext, 'db' | 'resolveStoragePath'>,
308 docId: string
309 ): Promise<string> {
310 if (!docId) throw new Error('HTML 文档 ID 不能为空')
311 const document = await resolveHtmlEditorDocument(ctx, docId)
312 return document.dir
313 }
314
315 export async function applyHtmlEditsForDocument(
316 ctx: Pick<IpcContext, 'db' | 'resolveStoragePath'>,
317 args: {
318 docId: string
319 html?: string
320 batch: {
321 dragEdits?: unknown
322 textEdits?: unknown
323 propertyEdits?: unknown
324 deletes?: unknown
325 addElements?: unknown
326 }
327 message?: string
328 }
329 ): Promise<{ html: string; warnings: string[]; changed: boolean }> {
330 if (!args.docId) throw new Error('applyEdits 参数无效')
331 const document = await resolveHtmlEditorDocument(ctx, args.docId)
332 const html =
333 args.html ||
334 htmlDocumentHtmlCache.get(args.docId) ||
335 (await fs.promises.readFile(document.htmlPath, 'utf-8'))
336 if (!html) throw new Error('applyEdits 参数无效')
337 const { html: next, warnings } = applyEditsToHtml(html, args.docId, args.batch)
338 if (next === html) {
339 rememberHtmlEditorDocumentHtml(args.docId, html)
340 return { html: next, warnings, changed: false }
341 }
342 await ensureHtmlRepo(document.dir)
343 const previousCommit = await getHtmlRepoHead(document.dir)
344 const previousHtml = await fs.promises.readFile(document.htmlPath, 'utf-8')
345 const message = args.message || '编辑'
346 try {
347 await fs.promises.writeFile(document.htmlPath, next, 'utf-8')
348 const commitSha = await commitHtmlFile(document.dir, 'current.html', message)
349 await ctx.db.createHtmlEditVersionAndTouch({
350 id: nanoid(12),
351 docId: args.docId,
352 commitSha,
353 message,
354 createdAt: Date.now()
355 })
356 rememberHtmlEditorDocumentHtml(args.docId, next)
357 refreshHtmlEditorCoverThumbnail({
358 id: document.doc.id,
359 htmlPath: document.htmlPath,
360 designWidth: document.doc.designWidth
361 })
362 } catch (error) {
363 await restoreHtmlFileAtCommit(document.dir, 'current.html', previousCommit).catch(
364 (rollbackError) => {
365 log.error('[html-editor:applyEdits] git rollback failed', {
366 message: rollbackError instanceof Error ? rollbackError.message : String(rollbackError)
367 })
368 }
369 )
370 await restoreHtmlRepoHead(document.dir, previousCommit).catch((rollbackError) => {
371 log.error('[html-editor:applyEdits] git head rollback failed', {
372 message: rollbackError instanceof Error ? rollbackError.message : String(rollbackError)
373 })
374 })
375 await fs.promises.writeFile(document.htmlPath, previousHtml, 'utf-8').catch((rollbackError) => {
376 log.error('[html-editor:applyEdits] rollback failed', {
377 message: rollbackError instanceof Error ? rollbackError.message : String(rollbackError)
378 })
379 })
380 rememberHtmlEditorDocumentHtml(args.docId, previousHtml)
381 throw error
382 }
383 return { html: next, warnings, changed: true }
384 }
385
386 export function registerHtmlEditorHandlers(ctx: IpcContext): void {
387 const { mainWindow, resolveStoragePath, db } = ctx
388
389 const resolveOwnerWindow = (sender: WebContents): BrowserWindow =>
390 BrowserWindow.fromWebContents(sender) ?? BrowserWindow.getFocusedWindow() ?? mainWindow
391
392 const resolveDocument = (docId: string) => resolveHtmlEditorDocument(ctx, docId)
393
394 // ─── html-editor:listMedia ─────────────────────────────
395 ipcMain.handle('html-editor:listMedia', async (_event, payload: unknown) => {
396 const r = asRecord(payload)
397 const docId = typeof r.docId === 'string' ? r.docId.trim() : ''
398 const mediaType: HtmlEditorMediaType = r.mediaType === 'video' ? 'video' : 'image'
399 if (!docId) throw new Error('文档 ID 不能为空')
400
401 const document = await resolveDocument(docId)
402 allowLocalAssetRoot(document.dir)
403 return { assets: await listHtmlEditorMedia({ workspaceDir: document.dir, mediaType }) }
404 })
405
406 // ─── html-editor:chooseAndImportMedia ──────────────────
407 ipcMain.handle('html-editor:chooseAndImportMedia', async (event, payload: unknown) => {
408 const r = asRecord(payload)
409 const docId = typeof r.docId === 'string' ? r.docId.trim() : ''
410 const mediaType: HtmlEditorMediaType = r.mediaType === 'video' ? 'video' : 'image'
411 if (!docId) throw new Error('文档 ID 不能为空')
412
413 const document = await resolveDocument(docId)
414 const ownerWindow = resolveOwnerWindow(event.sender)
415 const result = await dialog.showOpenDialog(ownerWindow, {
416 title: mediaType === 'video' ? '选择视频' : '选择图片',
417 buttonLabel: '添加',
418 properties: ['openFile'],
419 filters: [
420 {
421 name: mediaType === 'video' ? 'Videos' : 'Images',
422 extensions: getHtmlEditorMediaExtensions(mediaType)
423 }
424 ]
425 })
426 if (result.canceled || result.filePaths.length === 0) return { cancelled: true }
427
428 const media = await importHtmlEditorMedia({
429 workspaceDir: document.dir,
430 sourcePath: result.filePaths[0],
431 mediaType
432 })
433 allowLocalAssetRoot(document.dir)
434 return { cancelled: false, ...media }
435 })
436
437 // ─── html-editor:import ────────────────────────────────
438 ipcMain.handle('html-editor:import', async (event) => {
439 let storagePath: string
440 try {
441 storagePath = await resolveStoragePath()
442 } catch {
443 return { cancelled: true, reason: 'storage-not-configured' }
444 }
445
446 const ownerWindow = resolveOwnerWindow(event.sender)
447 const openResult = await dialog.showOpenDialog(ownerWindow, {
448 title: '导入 HTML 文件',
449 buttonLabel: '导入',
450 properties: ['openFile'],
451 filters: [
452 { name: 'HTML', extensions: ['html', 'htm'] },
453 { name: '所有文件', extensions: ['*'] }
454 ]
455 })
456 if (openResult.canceled || openResult.filePaths.length === 0) {
457 return { cancelled: true, reason: 'user-cancelled' }
458 }
459
460 const sourcePath = openResult.filePaths[0]
461 let workingDir: string | null = null
462 try {
463 const raw = await fs.promises.readFile(sourcePath, 'utf-8')
464 const docId = 'hedit-' + nanoid(10)
465 const { html, designWidth, title } = normalizeImportedHtml({
466 html: raw,
467 sourceDir: path.dirname(sourcePath),
468 docId,
469 runtimeScriptHrefs: resolveRuntimeScriptHrefs()
470 })
471 const dir = path.join(storagePath, HTML_EDITOR_DIRNAME, docId)
472 workingDir = dir
473 const htmlPath = path.join(dir, 'current.html')
474 await ensureHtmlRepo(dir)
475 await fs.promises.writeFile(htmlPath, html, 'utf-8')
476 const commitSha = await commitHtmlFile(dir, 'current.html', '导入')
477 const now = Date.now()
478 await db.createHtmlEditDocumentWithVersion({
479 document: {
480 id: docId,
481 title,
482 sourcePath,
483 htmlPath,
484 designWidth,
485 createdAt: now,
486 updatedAt: now
487 },
488 version: {
489 id: nanoid(12),
490 commitSha,
491 message: '导入',
492 createdAt: now
493 }
494 })
495 rememberHtmlEditorDocumentHtml(docId, html)
496 refreshHtmlEditorCoverThumbnail({ id: docId, htmlPath, designWidth })
497 const result: HtmlEditorImportResult = {
498 docId,
499 title,
500 htmlPath,
501 sourcePath,
502 designWidth,
503 html
504 }
505 log.info('[html-editor:import] ok', { docId, sourcePath, commitSha })
506 return { cancelled: false, ...result }
507 } catch (error) {
508 if (workingDir) {
509 await fs.promises.rm(workingDir, { recursive: true, force: true }).catch((cleanupError) => {
510 log.warn('[html-editor:import] cleanup failed', {
511 workingDir,
512 message: cleanupError instanceof Error ? cleanupError.message : String(cleanupError)
513 })
514 })
515 }
516 log.error('[html-editor:import] failed', {
517 sourcePath,
518 message: error instanceof Error ? error.message : String(error)
519 })
520 throw error
521 }
522 })
523
524 // ─── html-editor:ensureAnchor ──────────────────────────
525 ipcMain.handle('html-editor:ensureAnchor', async (_event, payload: unknown) => {
526 const r = asRecord(payload)
527 const html = typeof r.html === 'string' ? r.html : ''
528 const pageId = typeof r.pageId === 'string' ? r.pageId : ''
529 const selector = typeof r.selector === 'string' ? r.selector : ''
530 if (!html || !pageId || !selector) throw new Error('ensureAnchor 参数无效')
531 const result = ensureElementAnchorInHtml(html, {
532 pageId,
533 selector,
534 elementTag: typeof r.elementTag === 'string' ? r.elementTag : undefined,
535 formula: r.formula as Parameters<typeof ensureElementAnchorInHtml>[1]['formula']
536 })
537 rememberHtmlEditorDocumentHtml(pageId, result.html)
538 return result
539 })
540
541 // ─── html-editor:applyEdits ────────────────────────────
542 ipcMain.handle('html-editor:applyEdits', async (_event, payload: unknown) => {
543 const r = asRecord(payload)
544 const html = typeof r.html === 'string' ? r.html : ''
545 const pageId = typeof r.pageId === 'string' ? r.pageId : ''
546 return applyHtmlEditsForDocument(ctx, {
547 docId: pageId,
548 html,
549 batch: {
550 dragEdits: r.dragEdits,
551 textEdits: r.textEdits,
552 propertyEdits: r.propertyEdits,
553 deletes: r.deletes,
554 addElements: r.addElements
555 }
556 })
557 })
558
559 // ─── html-editor:listVersions ──────────────────────────
560 ipcMain.handle('html-editor:listVersions', async (_event, payload: unknown) => {
561 const r = asRecord(payload)
562 const docId = typeof r.docId === 'string' ? r.docId : ''
563 if (!docId) return { versions: [] }
564 const rows = await db.listHtmlEditVersions(docId)
565 return {
566 versions: rows.map((v) => ({
567 id: v.id,
568 commitSha: v.commitSha,
569 message: v.message,
570 createdAt: v.createdAt
571 }))
572 }
573 })
574
575 // ─── html-editor:restoreVersion ────────────────────────
576 ipcMain.handle('html-editor:restoreVersion', async (_event, payload: unknown) => {
577 const r = asRecord(payload)
578 const docId = typeof r.docId === 'string' ? r.docId : ''
579 const versionId = typeof r.versionId === 'string' ? r.versionId : ''
580 if (!docId || !versionId) throw new Error('参数无效')
581 const version = await db.getHtmlEditVersion(versionId)
582 if (!version || version.docId !== docId) throw new Error('版本不存在')
583 const document = await resolveDocument(docId)
584 const html = await readHtmlAtCommit(document.dir, 'current.html', version.commitSha)
585 await ensureHtmlRepo(document.dir)
586 const previousCommit = await getHtmlRepoHead(document.dir)
587 const previousHtml = await fs.promises.readFile(document.htmlPath, 'utf-8')
588 try {
589 await fs.promises.writeFile(document.htmlPath, html, 'utf-8')
590 const commitSha =
591 previousHtml === html
592 ? version.commitSha
593 : await commitHtmlFile(document.dir, 'current.html', '恢复')
594 await db.createHtmlEditVersionAndTouch({
595 id: nanoid(12),
596 docId,
597 commitSha,
598 message: '恢复',
599 createdAt: Date.now()
600 })
601 rememberHtmlEditorDocumentHtml(docId, html)
602 refreshHtmlEditorCoverThumbnail({
603 id: document.doc.id,
604 htmlPath: document.htmlPath,
605 designWidth: document.doc.designWidth
606 })
607 return { html }
608 } catch (error) {
609 await restoreHtmlFileAtCommit(document.dir, 'current.html', previousCommit).catch(
610 (rollbackError) => {
611 log.error('[html-editor:restoreVersion] git rollback failed', {
612 message: rollbackError instanceof Error ? rollbackError.message : String(rollbackError)
613 })
614 }
615 )
616 await restoreHtmlRepoHead(document.dir, previousCommit).catch((rollbackError) => {
617 log.error('[html-editor:restoreVersion] git head rollback failed', {
618 message: rollbackError instanceof Error ? rollbackError.message : String(rollbackError)
619 })
620 })
621 await fs.promises
622 .writeFile(document.htmlPath, previousHtml, 'utf-8')
623 .catch((rollbackError) => {
624 log.error('[html-editor:restoreVersion] rollback failed', {
625 message: rollbackError instanceof Error ? rollbackError.message : String(rollbackError)
626 })
627 })
628 rememberHtmlEditorDocumentHtml(docId, previousHtml)
629 throw error
630 }
631 })
632
633 // ─── html-editor:export ────────────────────────────────
634 ipcMain.handle('html-editor:export', async (event, payload: unknown) => {
635 const r = asRecord(payload)
636 const html = typeof r.html === 'string' ? r.html : ''
637 if (!html) throw new Error('导出内容为空')
638 const ownerWindow = resolveOwnerWindow(event.sender)
639 const saveResult = await dialog.showSaveDialog(ownerWindow, {
640 title: '导出 HTML',
641 defaultPath: typeof r.suggestedName === 'string' ? r.suggestedName : 'edited.html',
642 filters: [{ name: 'HTML', extensions: ['html', 'htm'] }]
643 })
644 if (saveResult.canceled || !saveResult.filePath) return { cancelled: true }
645 await fs.promises.writeFile(saveResult.filePath, html, 'utf-8')
646 return { cancelled: false, path: saveResult.filePath }
647 })
648
649 // ─── html-editor:openInBrowser ─────────────────────────
650 ipcMain.handle('html-editor:openInBrowser', async (_event, payload: unknown) => {
651 const r = asRecord(payload)
652 const docId = typeof r.docId === 'string' ? r.docId : ''
653 if (!docId) return { ok: false }
654 try {
655 const document = await resolveDocument(docId)
656 const error = await shell.openPath(document.htmlPath)
657 if (error) {
658 log.warn('[html-editor:openInBrowser] failed', {
659 htmlPath: document.htmlPath,
660 message: error
661 })
662 return { ok: false }
663 }
664 return { ok: true }
665 } catch (error) {
666 log.warn('[html-editor:openInBrowser] failed', {
667 message: error instanceof Error ? error.message : String(error)
668 })
669 return { ok: false }
670 }
671 })
672
673 // ─── html-editor:revealFile ─────────────────────────────
674 ipcMain.handle('html-editor:revealFile', async (_event, payload: unknown) => {
675 const r = asRecord(payload)
676 const docId = typeof r.docId === 'string' ? r.docId.trim() : ''
677 if (!docId) return { ok: false }
678 try {
679 const document = await resolveDocument(docId)
680 await fs.promises.access(document.htmlPath, fs.constants.R_OK)
681 shell.showItemInFolder(document.htmlPath)
682 return { ok: true }
683 } catch (error) {
684 log.warn('[html-editor:revealFile] failed', {
685 docId,
686 message: error instanceof Error ? error.message : String(error)
687 })
688 return { ok: false }
689 }
690 })
691
692 // ─── html-editor:listDocuments ─────────────────────────
693 ipcMain.handle('html-editor:listDocuments', async () => {
694 const docs = await db.listHtmlEditDocuments()
695 const thumbnails = await warmHtmlEditorCoverThumbnails(docs)
696 return {
697 documents: docs.map((d) => ({
698 id: d.id,
699 title: d.title,
700 sourcePath: d.sourcePath,
701 htmlPath: d.htmlPath,
702 designWidth: d.designWidth,
703 updatedAt: d.updatedAt,
704 thumbnailPath: thumbnails.get(d.id) || null
705 }))
706 }
707 })
708
709 // ─── html-editor:listMessages ──────────────────────────
710 ipcMain.handle('html-editor:listMessages', async (_event, payload: unknown) => {
711 const r = asRecord(payload)
712 const docId = typeof r.docId === 'string' ? r.docId.trim() : ''
713 if (!docId) return { messages: [] }
714 const rows = await db.listHtmlEditMessages(docId)
715 return {
716 messages: rows.map((message) => ({
717 id: message.id,
718 role: message.role === 'assistant' ? 'assistant' : 'user',
719 content: message.content,
720 intent: message.intent || undefined,
721 plan: message.planJson ? parseHtmlEditorMessagePlan(message.planJson) : null,
722 requiresConfirmation: message.requiresConfirmation === 1,
723 selectedElement: message.selectedSelector
724 ? {
725 selector: message.selectedSelector,
726 label: message.selectedLabel || undefined,
727 elementTag: message.selectedElementTag || undefined,
728 elementText: message.selectedElementText || undefined
729 }
730 : undefined,
731 createdAt: message.createdAt
732 }))
733 }
734 })
735
736 // ─── html-editor:clearMessages ─────────────────────────
737 ipcMain.handle('html-editor:clearMessages', async (_event, payload: unknown) => {
738 const r = asRecord(payload)
739 const docId = typeof r.docId === 'string' ? r.docId.trim() : ''
740 if (!docId) return { ok: false }
741 await db.clearHtmlEditMessages(docId)
742 return { ok: true }
743 })
744
745 // ─── html-editor:openDocument ──────────────────────────
746 ipcMain.handle('html-editor:openDocument', async (_event, payload: unknown) => {
747 const r = asRecord(payload)
748 const docId = typeof r.docId === 'string' ? r.docId : ''
749 if (!docId) throw new Error('参数无效')
750 const document = await resolveDocument(docId)
751 const { doc } = document
752 let file: { mtimeMs: number; size: number }
753 try {
754 file = await fs.promises.stat(document.htmlPath)
755 } catch (error) {
756 throw new Error('HTML 文档文件不存在或无法读取', { cause: error })
757 }
758 const cached = htmlDocumentOpenCache.get(doc.id)
759 let html =
760 cached && cached.modifiedAtMs === file.mtimeMs && cached.size === file.size ? cached.html : ''
761 if (!html) {
762 let htmlMatchesDisk = true
763 let needsFileMetadataRefresh = false
764 try {
765 html = await fs.promises.readFile(document.htmlPath, 'utf-8')
766 } catch (error) {
767 throw new Error('HTML 文档文件不存在或无法读取', { cause: error })
768 }
769 const normalized = normalizeImportedHtml({
770 html,
771 sourceDir: path.dirname(doc.sourcePath || document.htmlPath),
772 docId: doc.id,
773 defaultDesignWidth: doc.designWidth,
774 runtimeScriptHrefs: resolveRuntimeScriptHrefs()
775 })
776 if (normalized.html !== html) {
777 try {
778 await fs.promises.writeFile(document.htmlPath, normalized.html, 'utf-8')
779 needsFileMetadataRefresh = true
780 await ensureHtmlRepo(document.dir)
781 const commitSha = await commitHtmlFile(document.dir, 'current.html', '补全编辑运行时')
782 await db.createHtmlEditVersionAndTouch({
783 id: nanoid(12),
784 docId: doc.id,
785 commitSha,
786 message: '补全编辑运行时',
787 createdAt: Date.now()
788 })
789 } catch (error) {
790 log.warn('[html-editor:openDocument] runtime migration failed', {
791 docId: doc.id,
792 message: error instanceof Error ? error.message : String(error)
793 })
794 htmlMatchesDisk = false
795 }
796 html = normalized.html
797 }
798 if (htmlMatchesDisk) {
799 if (needsFileMetadataRefresh) file = await fs.promises.stat(document.htmlPath)
800 rememberHtmlEditorOpenHtml(doc.id, html, file)
801 }
802 }
803 const result: HtmlEditorImportResult = {
804 docId: doc.id,
805 title: doc.title,
806 htmlPath: document.htmlPath,
807 sourcePath: doc.sourcePath ?? '',
808 designWidth: doc.designWidth,
809 html
810 }
811 rememberHtmlEditorDocumentHtml(doc.id, html)
812 return { cancelled: false, ...result }
813 })
814
815 // ─── html-editor:cleanup(只删数据库记录,不删磁盘文件) ─────
816 ipcMain.handle('html-editor:cleanup', async (_event, payload: unknown) => {
817 const r = asRecord(payload)
818 const docId = typeof r.docId === 'string' ? r.docId : ''
819 if (!docId) return { ok: false }
820 try {
821 await db.deleteHtmlEditDocument(docId)
822 forgetHtmlEditorDocumentHtml(docId)
823 return { ok: true }
824 } catch (error) {
825 log.warn('[html-editor:cleanup] failed', {
826 message: error instanceof Error ? error.message : String(error)
827 })
828 return { ok: false }
829 }
830 })
831 }
832
833 function parseHtmlEditorMessagePlan(value: string): unknown {
834 try {
835 return JSON.parse(value)
836 } catch {
837 return null
838 }
839 }
840
840 lines TYPESCRIPT