返回 oh-my-ppt
handlers.ts
根目录 / src / main / element-editor / handlers.ts
1 /** IPC handlers for structured element editing, distinct from the HTML workspace editor. */
2 import { ipcMain } from 'electron'
3 import log from 'electron-log/main.js'
4 import fs from 'fs'
5 import path from 'path'
6 import * as cheerio from 'cheerio'
7 import type { IpcContext } from '../ipc/context'
8 import { GitHistoryService } from '../history/git-history-service'
9 import {
10 parseElementAnimationConfig,
11 patchElementAnimationConfig
12 } from '../animation/element-animation'
13 import { validateDataAnimPatch } from '../animation/data-anim-validator'
14 import type { ElementAnimationPatch } from '../../shared/element-animation'
15 import { ensureSessionRuntimeCompatible } from '../session/runtime-assets'
16 import {
17 withHtmlFileLock,
18 clampDragValue,
19 clampSizeValue,
20 normalizeChildStyleUpdates,
21 normalizeLayoutIslandStyle,
22 normalizeText,
23 patchDraggedElementStyle,
24 patchElementProperties,
25 patchGenericElementProperties,
26 ensureElementAnchorInHtml,
27 patchAddElement,
28 removeLegacyVideoAutoplayScript,
29 stableSelectorFor
30 } from './shared'
31 import { applySyncElementToPageHtml } from './sync-element'
32
33 export function registerEditorHandlers(ctx: IpcContext): void {
34 const { normalizeSessionId, assertPathInAllowedRoots, db, resolveSessionProjectDir } = ctx
35
36 // ─── element-anchor:ensure ──────────────────────────────
37
38 ipcMain.handle('element-anchor:ensure', async (_event, payload: unknown) => {
39 if (!payload || typeof payload !== 'object') {
40 throw new Error('元素锚定参数无效')
41 }
42 const record = payload as {
43 sessionId?: unknown
44 htmlPath?: unknown
45 pageId?: unknown
46 selector?: unknown
47 elementTag?: unknown
48 formula?: unknown
49 }
50 const sessionId = normalizeSessionId(record.sessionId)
51 const htmlPath = typeof record.htmlPath === 'string' ? record.htmlPath : ''
52 const pageId = typeof record.pageId === 'string' ? record.pageId.trim() : ''
53 const selector = typeof record.selector === 'string' ? record.selector.trim() : ''
54 const elementTag = typeof record.elementTag === 'string' ? record.elementTag.trim() : ''
55 const formula = record.formula && typeof record.formula === 'object' ? record.formula : undefined
56 if (!htmlPath) throw new Error('页面路径不能为空')
57 if (!pageId) throw new Error('pageId 不能为空')
58 if (!selector) throw new Error('元素 selector 不能为空')
59
60 const safeHtmlPath = await assertPathInAllowedRoots({
61 filePath: htmlPath,
62 mode: 'write',
63 sessionId,
64 htmlOnly: true
65 })
66 return await withHtmlFileLock(safeHtmlPath, async () => {
67 const html = await fs.promises.readFile(safeHtmlPath, 'utf-8')
68 const result = ensureElementAnchorInHtml(html, {
69 pageId,
70 selector,
71 elementTag,
72 formula: formula as Parameters<typeof ensureElementAnchorInHtml>[1]['formula']
73 })
74 if (result.changed) {
75 await fs.promises.writeFile(safeHtmlPath, result.html, 'utf-8')
76 }
77 return {
78 success: true,
79 selector: result.selector,
80 blockId: result.blockId,
81 changed: result.changed
82 }
83 })
84 })
85
86 // ─── element-animation:get / set ───────────────────────
87
88 ipcMain.handle('element-animation:get', async (_event, payload: unknown) => {
89 if (!payload || typeof payload !== 'object') {
90 throw new Error('元素动画参数无效')
91 }
92 const record = payload as {
93 sessionId?: unknown
94 htmlPath?: unknown
95 pageId?: unknown
96 selector?: unknown
97 }
98 const sessionId = normalizeSessionId(record.sessionId)
99 const htmlPath = typeof record.htmlPath === 'string' ? record.htmlPath : ''
100 const pageId = typeof record.pageId === 'string' ? record.pageId.trim() : ''
101 const selector = typeof record.selector === 'string' ? record.selector.trim() : ''
102 if (!sessionId) throw new Error('缺少 sessionId')
103 if (!htmlPath) throw new Error('页面路径不能为空')
104 if (!pageId) throw new Error('pageId 不能为空')
105 if (!selector) throw new Error('元素 selector 不能为空')
106
107 const safeHtmlPath = await assertPathInAllowedRoots({
108 filePath: htmlPath,
109 mode: 'read',
110 sessionId,
111 htmlOnly: true
112 })
113 return await withHtmlFileLock(safeHtmlPath, async () => {
114 const html = await fs.promises.readFile(safeHtmlPath, 'utf-8')
115 return { animation: parseElementAnimationConfig(html, selector) }
116 })
117 })
118
119 ipcMain.handle('element-animation:set', async (_event, payload: unknown) => {
120 if (!payload || typeof payload !== 'object') {
121 throw new Error('元素动画参数无效')
122 }
123 const record = payload as {
124 sessionId?: unknown
125 htmlPath?: unknown
126 pageId?: unknown
127 selector?: unknown
128 patch?: unknown
129 }
130 const sessionId = normalizeSessionId(record.sessionId)
131 const htmlPath = typeof record.htmlPath === 'string' ? record.htmlPath : ''
132 const pageId = typeof record.pageId === 'string' ? record.pageId.trim() : ''
133 const selector = typeof record.selector === 'string' ? record.selector.trim() : ''
134 const patch =
135 record.patch && typeof record.patch === 'object'
136 ? (record.patch as ElementAnimationPatch)
137 : null
138 if (!sessionId) throw new Error('缺少 sessionId')
139 if (!htmlPath) throw new Error('页面路径不能为空')
140 if (!pageId) throw new Error('pageId 不能为空')
141 if (!selector) throw new Error('元素 selector 不能为空')
142 if (!patch) throw new Error('元素动画 patch 不能为空')
143 const session = await db.getSession(sessionId)
144 if (!session) throw new Error('会话不存在或已被删除')
145
146 const safeHtmlPath = await assertPathInAllowedRoots({
147 filePath: htmlPath,
148 mode: 'write',
149 sessionId,
150 htmlOnly: true
151 })
152 const projectDir = await resolveSessionProjectDir(sessionId)
153 await ensureSessionRuntimeCompatible(ctx, projectDir)
154 const history = new GitHistoryService(db)
155 await history.ensureBaseline(sessionId, projectDir).catch((error) => {
156 log.warn('[element-animation:set] ensure history baseline failed', {
157 sessionId,
158 message: error instanceof Error ? error.message : String(error)
159 })
160 })
161
162 const result = await withHtmlFileLock(safeHtmlPath, async () => {
163 const html = await fs.promises.readFile(safeHtmlPath, 'utf-8')
164 const next = patchElementAnimationConfig(html, selector, patch)
165 // Fail only on contract violations this patch newly introduces on the target.
166 // Pre-existing violations elsewhere must not block the targeted edit.
167 const { newErrors } = validateDataAnimPatch(html, next.html)
168 if (newErrors.length > 0) {
169 throw new Error(`元素动画验证失败:${newErrors.join('; ')}`)
170 }
171 if (next.changed) {
172 await fs.promises.writeFile(safeHtmlPath, next.html, 'utf-8')
173 }
174 return next
175 })
176
177 if (result.changed) {
178 await history.recordOperation({
179 sessionId,
180 projectDir,
181 type: 'edit',
182 scope: 'selector',
183 prompt: result.config
184 ? `为元素设置动画:${result.config.type} ${result.config.durationMs}ms`
185 : '关闭元素动画',
186 metadata: {
187 action: 'setElementAnimation',
188 pageId,
189 selector,
190 animation: result.config
191 }
192 })
193 }
194
195 return {
196 success: true,
197 changed: result.changed,
198 animation: result.config
199 }
200 })
201
202 // ─── element-editor:delete-element ──────────────────────
203
204 ipcMain.handle('element-editor:delete-element', async (_event, payload: unknown) => {
205 if (!payload || typeof payload !== 'object') {
206 throw new Error('删除元素参数无效')
207 }
208 const record = payload as {
209 sessionId?: unknown
210 htmlPath?: unknown
211 pageId?: unknown
212 selector?: unknown
213 }
214 const sessionId = normalizeSessionId(record.sessionId)
215 const htmlPath = typeof record.htmlPath === 'string' ? record.htmlPath : ''
216 const pageId = typeof record.pageId === 'string' ? record.pageId.trim() : ''
217 const selector = typeof record.selector === 'string' ? record.selector.trim() : ''
218 if (!htmlPath) throw new Error('页面路径不能为空')
219 if (!pageId) throw new Error('pageId 不能为空')
220 if (!selector) throw new Error('删除元素 selector 不能为空')
221
222 const safeHtmlPath = await assertPathInAllowedRoots({
223 filePath: htmlPath,
224 mode: 'write',
225 sessionId,
226 htmlOnly: true
227 })
228 await withHtmlFileLock(safeHtmlPath, async () => {
229 const html = await fs.promises.readFile(safeHtmlPath, 'utf-8')
230 const $ = cheerio.load(html, { scriptingEnabled: false })
231 const target = $(selector).first()
232 if (!target || target.length === 0) {
233 throw new Error('无法定位删除元素:页面内容可能已经变化')
234 }
235 target.remove()
236 await fs.promises.writeFile(safeHtmlPath, $.html(), 'utf-8')
237 })
238 if (sessionId) {
239 const projectDir = await resolveSessionProjectDir(sessionId)
240 await new GitHistoryService(db).recordOperation({
241 sessionId,
242 projectDir,
243 type: 'edit',
244 scope: 'selector',
245 prompt: '删除元素',
246 metadata: { pageId, selector, action: 'delete' }
247 })
248 }
249 return { success: true }
250 })
251
252 // ─── edit:save-batch ────────────────────────────────────
253
254 ipcMain.handle('edit:save-batch', async (_event, payload: unknown) => {
255 if (!payload || typeof payload !== 'object') {
256 throw new Error('批量保存参数无效')
257 }
258 const record = payload as {
259 sessionId?: unknown
260 pageId?: unknown
261 htmlPath?: unknown
262 dragEdits?: unknown
263 textEdits?: unknown
264 propertyEdits?: unknown
265 deletes?: unknown
266 addElements?: unknown
267 prompt?: unknown
268 }
269 const sessionId = normalizeSessionId(record.sessionId)
270 const pageId = typeof record.pageId === 'string' ? record.pageId.trim() : ''
271 const htmlPath = typeof record.htmlPath === 'string' ? record.htmlPath : ''
272 if (!sessionId) throw new Error('缺少 sessionId')
273 if (!pageId) throw new Error('缺少 pageId')
274 if (!htmlPath) throw new Error('缺少 htmlPath')
275
276 const rawDrag = Array.isArray(record.dragEdits) ? record.dragEdits : []
277 const rawText = Array.isArray(record.textEdits) ? record.textEdits : []
278 const rawProperty = Array.isArray(record.propertyEdits) ? record.propertyEdits : []
279 const rawDeletes = Array.isArray(record.deletes) ? record.deletes : []
280 const rawAddElements = Array.isArray(record.addElements) ? record.addElements : []
281
282 const safeHtmlPath = await assertPathInAllowedRoots({
283 filePath: htmlPath,
284 mode: 'write',
285 sessionId,
286 htmlOnly: true
287 })
288
289 let deleteCount = 0
290 let addCount = 0
291 const warnings: string[] = []
292 await withHtmlFileLock(safeHtmlPath, async () => {
293 let html = await fs.promises.readFile(safeHtmlPath, 'utf-8')
294
295 // Apply deletes first
296 for (const item of rawDeletes) {
297 if (!item || typeof item !== 'object') continue
298 const d = item as { selector?: unknown }
299 const selector = typeof d.selector === 'string' ? d.selector.trim() : ''
300 if (!selector) continue
301 const $ = cheerio.load(html, { scriptingEnabled: false })
302 const target = $(selector).first()
303 if (target.length > 0) {
304 const artTextBlockId =
305 target.attr('data-ppt-art-text') !== undefined
306 ? (target.attr('data-block-id') || '').trim()
307 : ''
308 if (artTextBlockId) {
309 $('style[data-ppt-art-text-style]').each((_, styleNode) => {
310 const style = $(styleNode)
311 if ((style.attr('data-ppt-art-text-style') || '') === artTextBlockId) {
312 style.remove()
313 }
314 })
315 }
316 target.remove()
317 html = $.html()
318 deleteCount++
319 }
320 }
321
322 // Apply add elements (after deletes, before drag/text)
323 for (const item of rawAddElements) {
324 if (!item || typeof item !== 'object') continue
325 const e = item as {
326 parentSelector?: unknown
327 htmlFragment?: unknown
328 insertIndex?: unknown
329 }
330 const parentSelector = typeof e.parentSelector === 'string' ? e.parentSelector.trim() : ''
331 const htmlFragment = typeof e.htmlFragment === 'string' ? e.htmlFragment : ''
332 if (!parentSelector || !htmlFragment) continue
333 const insertIndex = typeof e.insertIndex === 'number' ? e.insertIndex : -1
334 html = patchAddElement(html, parentSelector, htmlFragment, insertIndex)
335 addCount++
336 }
337
338 // Apply drag edits
339 for (const item of rawDrag) {
340 if (!item || typeof item !== 'object') continue
341 const e = item as {
342 selector?: unknown
343 x?: unknown
344 y?: unknown
345 width?: unknown
346 height?: unknown
347 childUpdates?: unknown
348 layoutIsland?: unknown
349 isAbsoluteMode?: unknown
350 zIndex?: unknown
351 zIndexOnly?: unknown
352 }
353 const selector = typeof e.selector === 'string' ? e.selector.trim() : ''
354 if (!selector) continue
355 const zIndex = typeof e.zIndex === 'number' ? e.zIndex : undefined
356 const zIndexOnly = !!e.zIndexOnly
357 html = patchDraggedElementStyle(
358 html,
359 selector,
360 clampDragValue(e.x),
361 clampDragValue(e.y),
362 clampSizeValue(e.width),
363 clampSizeValue(e.height),
364 normalizeChildStyleUpdates(e.childUpdates),
365 !!e.isAbsoluteMode,
366 zIndex,
367 zIndexOnly,
368 normalizeLayoutIslandStyle(e.layoutIsland)
369 )
370 }
371
372 // Apply text edits
373 for (const item of rawText) {
374 if (!item || typeof item !== 'object') continue
375 const e = item as {
376 selector?: unknown
377 patch?: unknown
378 }
379 const selector = typeof e.selector === 'string' ? e.selector.trim() : ''
380 if (!selector) continue
381 const rawPatch =
382 e.patch && typeof e.patch === 'object' ? (e.patch as Record<string, unknown>) : {}
383 const rawStyle =
384 rawPatch.style && typeof rawPatch.style === 'object'
385 ? (rawPatch.style as Record<string, unknown>)
386 : {}
387 html = patchElementProperties(html, selector, {
388 html: typeof rawPatch.html === 'string' ? rawPatch.html : undefined,
389 text: typeof rawPatch.text === 'string' ? rawPatch.text : undefined,
390 style: {
391 color: typeof rawStyle.color === 'string' ? rawStyle.color : undefined,
392 fontSize: typeof rawStyle.fontSize === 'string' ? rawStyle.fontSize : undefined,
393 fontWeight: typeof rawStyle.fontWeight === 'string' ? rawStyle.fontWeight : undefined,
394 textAlign: typeof rawStyle.textAlign === 'string' ? rawStyle.textAlign : undefined
395 }
396 })
397 }
398
399 // Apply generic property edits
400 for (const item of rawProperty) {
401 if (!item || typeof item !== 'object') continue
402 const e = item as {
403 selector?: unknown
404 blockId?: unknown
405 patch?: unknown
406 }
407 const selector = typeof e.selector === 'string' ? e.selector.trim() : ''
408 const blockId = typeof e.blockId === 'string' ? e.blockId.trim() : ''
409 if (!selector && !blockId) continue
410 const $ = cheerio.load(html, { scriptingEnabled: false })
411 const blockSelector = blockId ? stableSelectorFor(pageId, blockId) : ''
412 const resolvedSelector =
413 blockSelector && $(blockSelector).first().length > 0
414 ? blockSelector
415 : selector && $(selector).first().length > 0
416 ? selector
417 : ''
418 if (!resolvedSelector) {
419 warnings.push(`属性编辑目标不存在:${blockId || selector}`)
420 continue
421 }
422 const patch = e.patch && typeof e.patch === 'object' ? (e.patch as Record<string, unknown>) : {}
423 const style = patch.style && typeof patch.style === 'object' ? patch.style : undefined
424 const attrs = patch.attrs && typeof patch.attrs === 'object' ? patch.attrs : undefined
425 const formula = patch.formula && typeof patch.formula === 'object' ? patch.formula : undefined
426 const chart = patch.chart && typeof patch.chart === 'object' ? patch.chart : undefined
427 try {
428 html = patchGenericElementProperties(html, resolvedSelector, {
429 text: typeof patch.text === 'string' ? patch.text : undefined,
430 html: typeof patch.html === 'string' ? patch.html : undefined,
431 formula: formula as Parameters<typeof patchGenericElementProperties>[2]['formula'],
432 chart: chart as Parameters<typeof patchGenericElementProperties>[2]['chart'],
433 textTarget: patch.textTarget,
434 style: style as Parameters<typeof patchGenericElementProperties>[2]['style'],
435 attrs: attrs as Parameters<typeof patchGenericElementProperties>[2]['attrs']
436 })
437 } catch (error) {
438 warnings.push(
439 error instanceof Error
440 ? `属性编辑失败:${error.message}`
441 : `属性编辑失败:${blockId || selector}`
442 )
443 }
444 }
445
446 html = removeLegacyVideoAutoplayScript(html)
447 await fs.promises.writeFile(safeHtmlPath, html, 'utf-8')
448 })
449
450 // Record history snapshot
451 const projectDir = await resolveSessionProjectDir(sessionId)
452 const dragCount = rawDrag.length
453 const textCount = rawText.length
454 const propertyCount = rawProperty.length
455 const prompt = typeof record.prompt === 'string' ? record.prompt : '手动调整'
456 await new GitHistoryService(db).recordOperation({
457 sessionId,
458 projectDir,
459 type: 'edit',
460 scope: 'selector',
461 prompt,
462 metadata: { pageId, dragCount, textCount, propertyCount, deleteCount, addCount }
463 })
464
465 return { success: true, dragCount, textCount, propertyCount, deleteCount, addCount, warnings }
466 })
467
468 // ─── element-editor:apply-sync-to-all-pages ─────────────
469
470 ipcMain.handle('element-editor:apply-sync-to-all-pages', async (_event, payload: unknown) => {
471 if (!payload || typeof payload !== 'object') {
472 throw new Error('同步元素参数无效')
473 }
474 const record = payload as {
475 sessionId?: unknown
476 pageId?: unknown
477 htmlPath?: unknown
478 sourceHtmlFragment?: unknown
479 syncElementId?: unknown
480 sourceBlockId?: unknown
481 }
482 const sessionId = normalizeSessionId(record.sessionId)
483 const pageId = typeof record.pageId === 'string' ? record.pageId.trim() : ''
484 const htmlPath = typeof record.htmlPath === 'string' ? record.htmlPath : ''
485 const sourceHtmlFragment =
486 typeof record.sourceHtmlFragment === 'string' ? record.sourceHtmlFragment.trim() : ''
487 const syncElementId =
488 typeof record.syncElementId === 'string' ? record.syncElementId.trim() : undefined
489 const sourceBlockId =
490 typeof record.sourceBlockId === 'string' ? record.sourceBlockId.trim() : undefined
491 if (!sessionId) throw new Error('缺少 sessionId')
492 if (!pageId) throw new Error('缺少 pageId')
493 if (!htmlPath) throw new Error('缺少 htmlPath')
494 if (!sourceHtmlFragment) throw new Error('缺少要同步的元素')
495
496 const session = await db.getSession(sessionId)
497 if (!session) throw new Error('会话不存在或已被删除')
498 const projectDir = await resolveSessionProjectDir(sessionId)
499 const safeSourceHtmlPath = await assertPathInAllowedRoots({
500 filePath: htmlPath,
501 mode: 'write',
502 sessionId,
503 htmlOnly: true
504 })
505 const pages = await db.listSessionPages(sessionId)
506 if (pages.length === 0) throw new Error('没有可同步的页面')
507
508 let resolvedSyncElementId = syncElementId || ''
509 let changedCount = 0
510 let insertedCount = 0
511 let updatedCount = 0
512 const changedPageIds: string[] = []
513
514 for (const page of pages) {
515 const rawPagePath = page.html_path || `${page.file_slug}.html`
516 const candidatePath = path.isAbsolute(rawPagePath)
517 ? rawPagePath
518 : path.join(projectDir, rawPagePath)
519 if (!fs.existsSync(candidatePath)) continue
520 const safePagePath = await assertPathInAllowedRoots({
521 filePath: candidatePath,
522 mode: 'write',
523 sessionId,
524 htmlOnly: true
525 })
526 const isSourcePage = path.resolve(safePagePath) === path.resolve(safeSourceHtmlPath)
527 const result = await withHtmlFileLock(safePagePath, async () => {
528 const html = await fs.promises.readFile(safePagePath, 'utf-8')
529 const patched = applySyncElementToPageHtml({
530 html,
531 sourceHtmlFragment,
532 syncElementId: resolvedSyncElementId || undefined,
533 preserveSourceBlockId: isSourcePage ? sourceBlockId : undefined
534 })
535 if (patched.changed) {
536 await fs.promises.writeFile(safePagePath, patched.html, 'utf-8')
537 }
538 return patched
539 })
540 if (!resolvedSyncElementId) resolvedSyncElementId = result.syncElementId
541 if (result.changed) {
542 changedCount++
543 if (result.inserted) insertedCount++
544 if (result.updated) updatedCount++
545 changedPageIds.push(page.file_slug)
546 }
547 }
548
549 if (changedCount > 0) {
550 await new GitHistoryService(db).recordOperation({
551 sessionId,
552 projectDir,
553 type: 'edit',
554 scope: 'deck',
555 prompt: '同步元素到所有页面',
556 metadata: {
557 action: 'applySyncElementToAllPages',
558 sourcePageId: pageId,
559 syncElementId: resolvedSyncElementId,
560 changedCount,
561 insertedCount,
562 updatedCount,
563 changedPageIds
564 }
565 })
566 }
567
568 return {
569 success: true,
570 syncElementId: resolvedSyncElementId,
571 changedCount,
572 insertedCount,
573 updatedCount
574 }
575 })
576
577 // ─── drag-editor:update-element-layout ──────────────────
578
579 ipcMain.handle('drag-editor:update-element-layout', async (_event, payload: unknown) => {
580 if (!payload || typeof payload !== 'object') {
581 throw new Error('拖拽更新参数无效')
582 }
583 const record = payload as {
584 sessionId?: unknown
585 htmlPath?: unknown
586 pageId?: unknown
587 selector?: unknown
588 x?: unknown
589 y?: unknown
590 width?: unknown
591 height?: unknown
592 childUpdates?: unknown
593 layoutIsland?: unknown
594 isAbsoluteMode?: unknown
595 }
596 const sessionId = normalizeSessionId(record.sessionId)
597 const htmlPath = typeof record.htmlPath === 'string' ? record.htmlPath : ''
598 const selector = typeof record.selector === 'string' ? record.selector.trim() : ''
599 const pageId = typeof record.pageId === 'string' ? record.pageId.trim() : ''
600 if (!htmlPath) throw new Error('页面路径不能为空')
601 if (!pageId) throw new Error('pageId 不能为空')
602 if (!selector) throw new Error('拖拽元素 selector 不能为空')
603
604 const safeHtmlPath = await assertPathInAllowedRoots({
605 filePath: htmlPath,
606 mode: 'write',
607 sessionId,
608 htmlOnly: true
609 })
610 await withHtmlFileLock(safeHtmlPath, async () => {
611 const html = await fs.promises.readFile(safeHtmlPath, 'utf-8')
612 const nextHtml = patchDraggedElementStyle(
613 html,
614 selector,
615 clampDragValue(record.x),
616 clampDragValue(record.y),
617 clampSizeValue(record.width),
618 clampSizeValue(record.height),
619 normalizeChildStyleUpdates(record.childUpdates),
620 !!record.isAbsoluteMode,
621 undefined,
622 undefined,
623 normalizeLayoutIslandStyle(record.layoutIsland)
624 )
625 await fs.promises.writeFile(safeHtmlPath, nextHtml, 'utf-8')
626 })
627 return { success: true }
628 })
629
630 // ─── text-editor:update-element-text ────────────────────
631
632 ipcMain.handle('text-editor:update-element-text', async (_event, payload: unknown) => {
633 if (!payload || typeof payload !== 'object') {
634 throw new Error('文字更新参数无效')
635 }
636 const record = payload as {
637 sessionId?: unknown
638 htmlPath?: unknown
639 pageId?: unknown
640 selector?: unknown
641 text?: unknown
642 }
643 const sessionId = normalizeSessionId(record.sessionId)
644 const htmlPath = typeof record.htmlPath === 'string' ? record.htmlPath : ''
645 const pageId = typeof record.pageId === 'string' ? record.pageId.trim() : ''
646 const selector = typeof record.selector === 'string' ? record.selector.trim() : ''
647 const text = normalizeText(record.text)
648 if (!htmlPath) throw new Error('页面路径不能为空')
649 if (!pageId) throw new Error('pageId 不能为空')
650 if (!selector) throw new Error('文字元素 selector 不能为空')
651 if (!text) throw new Error('文字不能为空')
652 if (text.length > 500) throw new Error('文字不能超过 500 个字符')
653
654 const safeHtmlPath = await assertPathInAllowedRoots({
655 filePath: htmlPath,
656 mode: 'write',
657 sessionId,
658 htmlOnly: true
659 })
660 await withHtmlFileLock(safeHtmlPath, async () => {
661 const html = await fs.promises.readFile(safeHtmlPath, 'utf-8')
662 const nextHtml = patchElementProperties(html, selector, { text })
663 await fs.promises.writeFile(safeHtmlPath, nextHtml, 'utf-8')
664 })
665 return { success: true }
666 })
667
668 // ─── text-editor:update-element-properties ──────────────
669
670 ipcMain.handle('text-editor:update-element-properties', async (_event, payload: unknown) => {
671 if (!payload || typeof payload !== 'object') {
672 throw new Error('文字属性更新参数无效')
673 }
674 const record = payload as {
675 sessionId?: unknown
676 htmlPath?: unknown
677 pageId?: unknown
678 selector?: unknown
679 patch?: unknown
680 }
681 const sessionId = normalizeSessionId(record.sessionId)
682 const htmlPath = typeof record.htmlPath === 'string' ? record.htmlPath : ''
683 const pageId = typeof record.pageId === 'string' ? record.pageId.trim() : ''
684 const selector = typeof record.selector === 'string' ? record.selector.trim() : ''
685 const rawPatch =
686 record.patch && typeof record.patch === 'object'
687 ? (record.patch as {
688 text?: unknown
689 html?: unknown
690 formula?: unknown
691 textTarget?: unknown
692 style?: unknown
693 })
694 : {}
695 const rawStyle =
696 rawPatch.style && typeof rawPatch.style === 'object'
697 ? (rawPatch.style as Record<string, unknown>)
698 : {}
699 if (!htmlPath) throw new Error('页面路径不能为空')
700 if (!pageId) throw new Error('pageId 不能为空')
701 if (!selector) throw new Error('文字元素 selector 不能为空')
702
703 const safeHtmlPath = await assertPathInAllowedRoots({
704 filePath: htmlPath,
705 mode: 'write',
706 sessionId,
707 htmlOnly: true
708 })
709 await withHtmlFileLock(safeHtmlPath, async () => {
710 const html = await fs.promises.readFile(safeHtmlPath, 'utf-8')
711 const nextHtml = patchElementProperties(html, selector, {
712 html: typeof rawPatch.html === 'string' ? rawPatch.html : undefined,
713 text: typeof rawPatch.text === 'string' ? rawPatch.text : undefined,
714 textTarget: rawPatch.textTarget,
715 style: {
716 color: typeof rawStyle.color === 'string' ? rawStyle.color : undefined,
717 fontSize: typeof rawStyle.fontSize === 'string' ? rawStyle.fontSize : undefined,
718 fontWeight: typeof rawStyle.fontWeight === 'string' ? rawStyle.fontWeight : undefined,
719 textAlign: typeof rawStyle.textAlign === 'string' ? rawStyle.textAlign : undefined
720 }
721 })
722 await fs.promises.writeFile(safeHtmlPath, nextHtml, 'utf-8')
723 })
724 return { success: true }
725 })
726 }
727
727 lines TYPESCRIPT