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