返回 oh-my-ppt
sync-element.ts
根目录 / src / main / element-editor / sync-element.ts
1 /** Synchronize a structured element update into a page HTML file. */
2 import * as cheerio from 'cheerio'
3 import type { AnyNode } from 'domhandler'
4 import { allocateBlockId, attrEscape } from './shared'
5
6 export const SYNC_ELEMENT_ATTR = 'data-ppt-sync-element-id'
7
8 const SYNC_ID_PREFIX = 'sync-'
9
10 export type ApplySyncElementResult = {
11 html: string
12 syncElementId: string
13 changed: boolean
14 inserted: boolean
15 updated: boolean
16 }
17
18 const normalizeSyncElementId = (value: unknown): string => {
19 if (typeof value !== 'string') return ''
20 const text = value.trim()
21 if (!text || text.length > 80) return ''
22 return /^[a-zA-Z0-9_-]+$/.test(text) ? text : ''
23 }
24
25 const createSyncElementId = (): string => `${SYNC_ID_PREFIX}${allocateBlockId().replace(/^select-arcsin1-/, '')}`
26
27 function findFirstElement($: cheerio.CheerioAPI): cheerio.Cheerio<AnyNode> {
28 const bodyChildren = $('body').children()
29 const element = bodyChildren.filter((_, node) => {
30 const tagName = String((node as { tagName?: string }).tagName || '').toLowerCase()
31 return tagName !== 'style'
32 })
33 if (element.length > 0) return element.first()
34 return $.root().children().first()
35 }
36
37 function readSyncIdFromFragment(sourceHtmlFragment: string): string {
38 const source = cheerio.load(sourceHtmlFragment, { scriptingEnabled: false })
39 const element = findFirstElement(source)
40 return normalizeSyncElementId(element.attr(SYNC_ELEMENT_ATTR))
41 }
42
43 function buildSourceElementHtml(args: {
44 sourceHtmlFragment: string
45 syncElementId: string
46 blockId: string
47 }): string {
48 const source = cheerio.load(args.sourceHtmlFragment, { scriptingEnabled: false })
49 const element = findFirstElement(source)
50 if (element.length === 0) throw new Error('无法读取要同步的元素')
51 element.attr(SYNC_ELEMENT_ATTR, args.syncElementId)
52 element.attr('data-block-id', args.blockId)
53 return element.toString()
54 }
55
56 function findElementBySyncId(
57 $: cheerio.CheerioAPI,
58 syncElementId: string
59 ): cheerio.Cheerio<AnyNode> {
60 let matched: cheerio.Cheerio<AnyNode> | null = null
61 $(`[${SYNC_ELEMENT_ATTR}]`).each((_, node) => {
62 if (matched) return
63 const item = $(node)
64 if ((item.attr(SYNC_ELEMENT_ATTR) || '').trim() === syncElementId) {
65 matched = item
66 }
67 })
68 return matched || $([])
69 }
70
71 function resolveInsertParent($: cheerio.CheerioAPI): cheerio.Cheerio<AnyNode> {
72 const selectors = [
73 'main[data-role="content"]',
74 '[data-role="content"]',
75 '.ppt-page-content main',
76 '.ppt-page-content',
77 'body'
78 ]
79 for (const selector of selectors) {
80 const parent = $(selector).first()
81 if (parent.length > 0) return parent
82 }
83 return $.root()
84 }
85
86 export function applySyncElementToPageHtml(args: {
87 html: string
88 sourceHtmlFragment: string
89 syncElementId?: string
90 preserveSourceBlockId?: string
91 }): ApplySyncElementResult {
92 const requestedSyncId = normalizeSyncElementId(args.syncElementId)
93 const fragmentSyncId = readSyncIdFromFragment(args.sourceHtmlFragment)
94 const syncElementId = requestedSyncId || fragmentSyncId || createSyncElementId()
95 const $ = cheerio.load(args.html, { scriptingEnabled: false })
96 const existing = findElementBySyncId($, syncElementId)
97 const existingBlockId = (existing.attr('data-block-id') || '').trim()
98 const sourceBlockId =
99 typeof args.preserveSourceBlockId === 'string' ? args.preserveSourceBlockId.trim() : ''
100 const blockId = existingBlockId || sourceBlockId || allocateBlockId()
101 const nextElementHtml = buildSourceElementHtml({
102 sourceHtmlFragment: args.sourceHtmlFragment,
103 syncElementId,
104 blockId
105 })
106
107 let inserted = false
108 let updated = false
109 if (existing.length > 0) {
110 const before = existing.toString()
111 existing.replaceWith(nextElementHtml)
112 updated = before !== nextElementHtml
113 } else {
114 resolveInsertParent($).append(`\n${nextElementHtml}\n`)
115 inserted = true
116 }
117
118 const nextHtml = $.html()
119 return {
120 html: nextHtml,
121 syncElementId,
122 changed: inserted || updated || nextHtml !== args.html,
123 inserted,
124 updated
125 }
126 }
127
128 export const syncElementSelector = (syncElementId: string): string =>
129 `[${SYNC_ELEMENT_ATTR}="${attrEscape(syncElementId)}"]`
130
130 lines TYPESCRIPT