| 1 | import * as cheerio from 'cheerio' |
| 2 | import { describe, expect, it, vi } from 'vitest' |
| 3 | |
| 4 | vi.mock('../../../src/main/html-editor/html-editor-thumbnail', () => ({ |
| 5 | refreshHtmlEditorCoverThumbnail: vi.fn(), |
| 6 | warmHtmlEditorCoverThumbnails: vi.fn(async () => new Map()) |
| 7 | })) |
| 8 | |
| 9 | import { applyEditsToHtml } from '../../../src/main/html-editor/html-editor-handlers' |
| 10 | import { resolveHtmlEditorDocumentPath } from '../../../src/main/html-editor/html-editor-handlers' |
| 11 | import { ensureElementAnchorInHtml } from '../../../src/main/element-editor/shared' |
| 12 | |
| 13 | const PAGE_ID = 'd1' |
| 14 | |
| 15 | const sampleHtml = (inner = '<div id="el1" class="box">Hello</div>'): string => |
| 16 | `<html><body data-page-id="${PAGE_ID}"><main class="ppt-page-root" data-ppt-guard-root="1" data-ppt-width="1280"><div class="ppt-page-fit-scope">${inner}</div></main></body></html>` |
| 17 | |
| 18 | const load = (html: string) => cheerio.load(html, { scriptingEnabled: false }) |
| 19 | |
| 20 | describe('applyEditsToHtml', () => { |
| 21 | it('applies a text edit via patchElementProperties (anchored element)', () => { |
| 22 | const anchor = ensureElementAnchorInHtml(sampleHtml(), { |
| 23 | pageId: PAGE_ID, |
| 24 | selector: '#el1' |
| 25 | }) |
| 26 | const { html } = applyEditsToHtml(anchor.html, PAGE_ID, { |
| 27 | textEdits: [{ selector: '#el1', patch: { text: 'World', style: { color: '#ff0000' } } }] |
| 28 | }) |
| 29 | expect(load(html)('#el1').text()).toBe('World') |
| 30 | }) |
| 31 | |
| 32 | it('replaces an element class through a property edit', () => { |
| 33 | const { html } = applyEditsToHtml( |
| 34 | sampleHtml('<div id="el1" class="text-gray-800 font-bold">Hello</div>'), |
| 35 | PAGE_ID, |
| 36 | { |
| 37 | propertyEdits: [ |
| 38 | { |
| 39 | selector: '#el1', |
| 40 | patch: { attrs: { className: 'text-red-500 font-bold' } } |
| 41 | } |
| 42 | ] |
| 43 | } |
| 44 | ) |
| 45 | expect(load(html)('#el1').attr('class')).toBe('text-red-500 font-bold') |
| 46 | }) |
| 47 | |
| 48 | it('adds an element into the fit-scope parent', () => { |
| 49 | const { html } = applyEditsToHtml(sampleHtml(), PAGE_ID, { |
| 50 | addElements: [ |
| 51 | { |
| 52 | parentSelector: '.ppt-page-fit-scope', |
| 53 | htmlFragment: '<div id="el2">New</div>', |
| 54 | insertIndex: -1 |
| 55 | } |
| 56 | ] |
| 57 | }) |
| 58 | expect(load(html)('#el2').text()).toBe('New') |
| 59 | expect(load(html)('#el2').attr('style')).toMatch(/position:\s*relative/) |
| 60 | expect(load(html)('#el2').attr('style')).toMatch(/z-index:\s*20/) |
| 61 | expect(load(html)('#el1').text()).toBe('Hello') // 原元素仍在 |
| 62 | }) |
| 63 | |
| 64 | it('preserves an explicit z-index on an added element', () => { |
| 65 | const { html } = applyEditsToHtml(sampleHtml(), PAGE_ID, { |
| 66 | addElements: [ |
| 67 | { |
| 68 | parentSelector: '.ppt-page-fit-scope', |
| 69 | htmlFragment: '<div id="el2" style="z-index: 99">New</div>', |
| 70 | insertIndex: -1 |
| 71 | } |
| 72 | ] |
| 73 | }) |
| 74 | expect(load(html)('#el2').attr('style')).toMatch(/z-index:\s*99/) |
| 75 | expect(load(html)('#el2').attr('style')).not.toMatch(/z-index:\s*20/) |
| 76 | }) |
| 77 | |
| 78 | it('deletes an element by selector', () => { |
| 79 | const { html } = applyEditsToHtml(sampleHtml(), PAGE_ID, { |
| 80 | deletes: [{ selector: '#el1' }] |
| 81 | }) |
| 82 | expect(load(html)('#el1').length).toBe(0) |
| 83 | }) |
| 84 | |
| 85 | it('applies a drag edit without removing the element (sets inline style)', () => { |
| 86 | const { html } = applyEditsToHtml(sampleHtml(), PAGE_ID, { |
| 87 | dragEdits: [ |
| 88 | { |
| 89 | selector: '#el1', |
| 90 | x: 100, |
| 91 | y: 50, |
| 92 | width: 200, |
| 93 | height: 80, |
| 94 | childUpdates: [], |
| 95 | isAbsoluteMode: true |
| 96 | } |
| 97 | ] |
| 98 | }) |
| 99 | const el = load(html)('#el1') |
| 100 | expect(el.length).toBe(1) |
| 101 | expect(el.attr('style')).toMatch(/position:\s*absolute/) |
| 102 | expect(el.attr('style')).toMatch(/left:\s*100px/) |
| 103 | expect(el.attr('style')).toMatch(/top:\s*50px/) |
| 104 | expect(el.attr('style')).toMatch(/width:\s*200px/) |
| 105 | expect(el.attr('style')).toMatch(/height:\s*80px/) |
| 106 | }) |
| 107 | |
| 108 | it('persists a flow resize as translate and actual dimensions', () => { |
| 109 | const { html } = applyEditsToHtml( |
| 110 | sampleHtml('<div id="el1" style="width: 240px; height: 100px">Hello</div>'), |
| 111 | PAGE_ID, |
| 112 | { |
| 113 | dragEdits: [ |
| 114 | { |
| 115 | selector: '#el1', |
| 116 | x: 20, |
| 117 | y: -8, |
| 118 | width: 360, |
| 119 | height: 75, |
| 120 | childUpdates: [], |
| 121 | isAbsoluteMode: false |
| 122 | } |
| 123 | ] |
| 124 | } |
| 125 | ) |
| 126 | |
| 127 | const el = load(html)('#el1') |
| 128 | expect(el.attr('style')).toMatch(/width:\s*360px/) |
| 129 | expect(el.attr('style')).toMatch(/height:\s*75px/) |
| 130 | expect(el.attr('style')).toMatch(/translate:\s*var\(--ppt-drag-x, 0px\) var\(--ppt-drag-y, 0px\)/) |
| 131 | }) |
| 132 | |
| 133 | it('freezes a flex layout island before persisting the resized element geometry', () => { |
| 134 | const { html } = applyEditsToHtml( |
| 135 | sampleHtml(` |
| 136 | <section id="island" style="display: flex"> |
| 137 | <article id="item-a"><p style="font-size: 24px">Text</p></article> |
| 138 | <article id="item-b"><img alt="Visual"></article> |
| 139 | </section> |
| 140 | `), |
| 141 | PAGE_ID, |
| 142 | { |
| 143 | dragEdits: [ |
| 144 | { |
| 145 | selector: '#item-a', |
| 146 | x: 20, |
| 147 | y: 30, |
| 148 | width: 260, |
| 149 | height: 120, |
| 150 | childUpdates: [], |
| 151 | isAbsoluteMode: true, |
| 152 | layoutIsland: { |
| 153 | selector: '#island', |
| 154 | width: 640, |
| 155 | height: 360, |
| 156 | children: [ |
| 157 | { index: 0, x: 20, y: 30, width: 220, height: 120 }, |
| 158 | { index: 1, x: 300, y: 30, width: 300, height: 180 } |
| 159 | ] |
| 160 | } |
| 161 | } |
| 162 | ] |
| 163 | } |
| 164 | ) |
| 165 | |
| 166 | const $ = load(html) |
| 167 | expect($('#island').attr('data-ppt-layout-frozen')).toBe('1') |
| 168 | expect($('#island').attr('style')).toMatch(/display:\s*block/) |
| 169 | expect($('#item-a').attr('style')).toMatch(/position:\s*absolute/) |
| 170 | expect($('#item-a').attr('style')).toMatch(/width:\s*260px/) |
| 171 | expect($('#item-b').attr('style')).toMatch(/left:\s*300px/) |
| 172 | expect($('#item-a p').attr('style')).toContain('font-size: 24px') |
| 173 | expect($('#item-a').attr('style')).not.toContain('scale:') |
| 174 | }) |
| 175 | |
| 176 | it('resolves a property edit by blockId after anchoring', () => { |
| 177 | const anchor = ensureElementAnchorInHtml(sampleHtml(), { |
| 178 | pageId: PAGE_ID, |
| 179 | selector: '#el1' |
| 180 | }) |
| 181 | expect(anchor.changed).toBe(true) |
| 182 | expect(anchor.blockId).toBeTruthy() |
| 183 | const { html, warnings } = applyEditsToHtml(anchor.html, PAGE_ID, { |
| 184 | propertyEdits: [ |
| 185 | { |
| 186 | selector: '#el1', |
| 187 | blockId: anchor.blockId, |
| 188 | patch: { style: { opacity: 0.5 } } |
| 189 | } |
| 190 | ] |
| 191 | }) |
| 192 | expect(warnings).toEqual([]) |
| 193 | expect(load(html)('#el1').attr('style')).toContain('0.5') |
| 194 | }) |
| 195 | |
| 196 | it('records a warning when a property edit target does not exist', () => { |
| 197 | const { warnings } = applyEditsToHtml(sampleHtml(), PAGE_ID, { |
| 198 | propertyEdits: [{ selector: '#nope', patch: { style: { opacity: 0.5 } } }] |
| 199 | }) |
| 200 | expect(warnings.length).toBe(1) |
| 201 | expect(warnings[0]).toContain('#nope') |
| 202 | }) |
| 203 | |
| 204 | it('applies deletes before adds (no conflict)', () => { |
| 205 | const { html } = applyEditsToHtml(sampleHtml(), PAGE_ID, { |
| 206 | deletes: [{ selector: '#el1' }], |
| 207 | addElements: [ |
| 208 | { |
| 209 | parentSelector: '.ppt-page-fit-scope', |
| 210 | htmlFragment: '<div id="el2">New</div>', |
| 211 | insertIndex: -1 |
| 212 | } |
| 213 | ] |
| 214 | }) |
| 215 | const $ = load(html) |
| 216 | expect($('#el1').length).toBe(0) |
| 217 | expect($('#el2').length).toBe(1) |
| 218 | }) |
| 219 | |
| 220 | it('is pure: does not mutate the input string and needs no filesystem', () => { |
| 221 | const original = sampleHtml() |
| 222 | applyEditsToHtml(original, PAGE_ID, { |
| 223 | deletes: [{ selector: '#el1' }] |
| 224 | }) |
| 225 | expect(load(original)('#el1').length).toBe(1) // 输入未变 |
| 226 | }) |
| 227 | }) |
| 228 | |
| 229 | describe('resolveHtmlEditorDocumentPath', () => { |
| 230 | it('accepts only the registered current.html path for a document', () => { |
| 231 | expect( |
| 232 | resolveHtmlEditorDocumentPath({ |
| 233 | storagePath: '/tmp/storage', |
| 234 | docId: 'hedit-1', |
| 235 | storedHtmlPath: '/tmp/storage/html-editor/hedit-1/current.html' |
| 236 | }) |
| 237 | ).toBe('/tmp/storage/html-editor/hedit-1/current.html') |
| 238 | }) |
| 239 | |
| 240 | it('rejects a path outside the document directory', () => { |
| 241 | expect(() => |
| 242 | resolveHtmlEditorDocumentPath({ |
| 243 | storagePath: '/tmp/storage', |
| 244 | docId: 'hedit-1', |
| 245 | storedHtmlPath: '/tmp/storage/other.html' |
| 246 | }) |
| 247 | ).toThrow('HTML 编辑文档路径无效') |
| 248 | }) |
| 249 | }) |
| 250 |