feat(editor): blog editor element with exact untouched value
- golem15-journal-blogeditor (BlogEditorElement): light-DOM element with a TipTap WYSIWYG tab and a Source tab that edits the exact HTML string - The value is the exact served string until a user edit; external values go in with emitUpdate false; summer-change fires on user edits only and an echo set is a no-op - StarterKit without the trailing node, links without target, rel or class; injectCSS false with the ProseMirror base rules in blogeditor.css under the element tag, colours from --c-* only - Committed bundle embedded through admin.go; vitest smoke on an imported post fixture (footer, style block, spans, figure, table, entities)
This commit is contained in:
48
tests/blogeditor/fixtures/imported-post.html
Normal file
48
tests/blogeditor/fixtures/imported-post.html
Normal file
@@ -0,0 +1,48 @@
|
||||
<h2>Czym jest ekstrakt z Turkey Tail?</h2>
|
||||
<p>Turkey Tail (<em>Trametes versicolor</em>, wrośniak różnobarwny) to jeden z najlepiej przebadanych grzybów funkcjonalnych. Ekstrakt powstaje z <strong>owocników</strong> poddanych ekstrakcji gorącą wodą, która uwalnia beta-glukany.</p>
|
||||
<h3>Najważniejsze związki aktywne</h3>
|
||||
<ul>
|
||||
<li><strong>PSK</strong> (polisacharyd K, krestin)</li>
|
||||
<li><strong>PSP</strong> (polisacharopeptyd)</li>
|
||||
<li>Beta-glukany i antyoksydanty fenolowe</li>
|
||||
</ul>
|
||||
<h3>Jak stosować?</h3>
|
||||
<ol>
|
||||
<li>Zacznij od mniejszej porcji przez pierwszy tydzień.</li>
|
||||
<li>Przyjmuj ekstrakt z posiłkiem.</li>
|
||||
<li>Obserwuj reakcję organizmu i skonsultuj się z lekarzem.</li>
|
||||
</ol>
|
||||
<blockquote>
|
||||
<p>Grzyby funkcjonalne wspierają organizm, ale nie zastępują leczenia.</p>
|
||||
</blockquote>
|
||||
<p>Więcej przeczytasz w <a href="/blog/beta-glukany">artykule o beta-glukanach</a> & <a href="https://example.test/badania">przeglądzie badań</a>.</p>
|
||||
<p>Data: 12.03 – aktualizacja opisu dawkowania.</p>
|
||||
|
||||
<footer class="author-footer" itemscope itemtype="https://schema.org/Person">
|
||||
<div class="author-info">
|
||||
<img src="https://cdn.example.test/autor.png" alt="Autor" class="author-photo" itemprop="image">
|
||||
<div class="author-text">
|
||||
<div style="font-size: 1.5em; font-weight: bold;" itemprop="name">Jan Kowalski</div>
|
||||
<span style="color: #138c74;" itemprop="jobTitle">Ekspert od grzybów funkcjonalnych</span>
|
||||
<p itemprop="description">Od lat zajmuje się mykologią i suplementacją.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<style>
|
||||
.author-footer { margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #ddd; }
|
||||
.author-info { display: flex; align-items: center; gap: 1rem; }
|
||||
.author-photo { width: 96px; height: 96px; border-radius: 50%; object-fit: cover; }
|
||||
</style>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Forma</th><th>Dawka</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>Proszek</td><td>1–2 g</td></tr>
|
||||
<tr><td>Kapsułki</td><td>2 × 500 mg</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<figure><img src="/storage/app/uploads/public/journal/2026/turkey-tail.jpg" alt="Owocniki Turkey Tail na pniu"><figcaption>Owocniki wrośniaka różnobarwnego.</figcaption></figure>
|
||||
<p>Zobacz też <a href="https://example.test/sklep" target="_blank" rel="noopener">sklep z ekstraktami</a>.</p>
|
||||
<p>Producent nazywa go "grzybem odporności", a badacze "wrośniakiem".</p>
|
||||
98
tests/blogeditor/smoke.test.ts
Normal file
98
tests/blogeditor/smoke.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import fixture from './fixtures/imported-post.html?raw'
|
||||
import '../../assets/src/blogeditor/index'
|
||||
import { BlogEditorElement, TAG } from '../../assets/src/blogeditor/index'
|
||||
|
||||
function mount(value?: string): { el: BlogEditorElement; changes: string[] } {
|
||||
const el = document.createElement(TAG) as BlogEditorElement
|
||||
const changes: string[] = []
|
||||
el.addEventListener('summer-change', (e) => changes.push((e as CustomEvent<{ value: string }>).detail.value))
|
||||
if (value !== undefined) el.value = value
|
||||
document.body.append(el)
|
||||
return { el, changes }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
describe('blog editor element (tracer)', () => {
|
||||
it('defines the custom element tag once', () => {
|
||||
expect(customElements.get(TAG)).toBe(BlogEditorElement)
|
||||
expect(document.createElement(TAG)).toBeInstanceOf(BlogEditorElement)
|
||||
})
|
||||
|
||||
it('returns the imported post byte for byte without an edit', () => {
|
||||
const { el, changes } = mount(fixture)
|
||||
expect(el.editor).toBeDefined()
|
||||
expect(el.value).toBe(fixture)
|
||||
|
||||
// Setting the value after connect is untouched too.
|
||||
const { el: late, changes: lateChanges } = mount()
|
||||
late.value = fixture
|
||||
expect(late.value).toBe(fixture)
|
||||
expect(changes).toEqual([])
|
||||
expect(lateChanges).toEqual([])
|
||||
})
|
||||
|
||||
it('emits no summer-change on set or on an echo set', () => {
|
||||
const { el, changes } = mount(fixture)
|
||||
el.value = fixture
|
||||
el.value = el.value
|
||||
expect(changes).toEqual([])
|
||||
expect(el.value).toBe(fixture)
|
||||
})
|
||||
|
||||
it('emits exactly one summer-change per user edit with the element value', () => {
|
||||
const { el, changes } = mount('<p>Hello</p>')
|
||||
const editor = el.editor!
|
||||
editor.view.dispatch(editor.state.tr.insertText('X', 1))
|
||||
expect(changes).toHaveLength(1)
|
||||
expect(changes[0]).toBe(el.value)
|
||||
expect(el.value).toBe('<p>XHello</p>')
|
||||
|
||||
// The SPA echoes the emitted value back: no reset, no event.
|
||||
el.value = changes[0]!
|
||||
expect(changes).toHaveLength(1)
|
||||
expect(el.value).toBe('<p>XHello</p>')
|
||||
})
|
||||
|
||||
it('shows the exact value in the source tab', () => {
|
||||
const { el, changes } = mount(fixture)
|
||||
el.showSource(true)
|
||||
const textarea = el.querySelector('textarea')!
|
||||
expect(textarea.hidden).toBe(false)
|
||||
expect(textarea.value).toBe(fixture)
|
||||
|
||||
textarea.value = '<p>Edited in source</p>\n<footer>kept</footer>'
|
||||
textarea.dispatchEvent(new Event('input'))
|
||||
expect(changes).toEqual(['<p>Edited in source</p>\n<footer>kept</footer>'])
|
||||
expect(el.value).toBe('<p>Edited in source</p>\n<footer>kept</footer>')
|
||||
|
||||
// Back to WYSIWYG: re-parsed, but the value stays the exact text.
|
||||
el.showSource(false)
|
||||
expect(el.value).toBe('<p>Edited in source</p>\n<footer>kept</footer>')
|
||||
expect(changes).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('a new external value replaces the content without an event', () => {
|
||||
const { el, changes } = mount('<p>One</p>')
|
||||
el.value = '<p>Two</p>'
|
||||
expect(el.value).toBe('<p>Two</p>')
|
||||
expect(el.editor!.state.doc.textContent).toBe('Two')
|
||||
expect(changes).toEqual([])
|
||||
})
|
||||
|
||||
it('disabled makes the editor and the source read-only; invalid sets aria-invalid', () => {
|
||||
const { el } = mount('<p>x</p>')
|
||||
el.setAttribute('disabled', '')
|
||||
el.setAttribute('invalid', '')
|
||||
expect(el.editor!.isEditable).toBe(false)
|
||||
expect(el.querySelector('textarea')!.readOnly).toBe(true)
|
||||
expect(el.editor!.view.dom.getAttribute('aria-invalid')).toBe('true')
|
||||
el.removeAttribute('disabled')
|
||||
el.removeAttribute('invalid')
|
||||
expect(el.editor!.isEditable).toBe(true)
|
||||
expect(el.editor!.view.dom.hasAttribute('aria-invalid')).toBe(false)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user