feat(editor): live preview with embed cards and post typography

- The preview re-renders 300 ms after the last WYSIWYG edit, Source input or external value, never while off or after detach.
- YouTube placeholders show as img.youtube.com thumbnail cards with no player; the preview copy drops active elements and on* attributes, and its stylesheet uses colours resolved from the host's --c-* values.
This commit is contained in:
2026-10-10 13:56:57 +02:00
parent df67939251
commit c1f6a83c2f
4 changed files with 392 additions and 50 deletions

File diff suppressed because one or more lines are too long

View File

@@ -2,7 +2,7 @@ import { Editor } from '@tiptap/core'
import { parseEmbedUrl } from './embed'
import { EDIT_HTML_EVENT, isPolicyUrl, preservationExtensions } from './extensions'
import { format, labelsFor, type Labels } from './labels'
import { buildPreviewDocument, PREVIEW_SANDBOX } from './preview'
import { buildPreviewDocument, PREVIEW_DEBOUNCE_MS, PREVIEW_SANDBOX, resolvePreviewColors } from './preview'
import { captureBlocks, serializeDocument, type Blocks } from './serializer'
import { buildToolbar, type Toolbar } from './toolbar'
@@ -99,6 +99,7 @@ export class BlogEditorElement extends HTMLElement {
this.#mode = 'clean'
if (this.#textarea) this.#textarea.value = v
this.#load(v)
this.#schedulePreview()
}
/** The TipTap editor while the element is connected (toolbar and tests). */
@@ -216,6 +217,7 @@ export class BlogEditorElement extends HTMLElement {
if (!transaction.docChanged && !appendedTransactions.some((tr) => tr.docChanged)) return
this.#mode = 'wysiwyg'
this.#emitChange()
this.#schedulePreview()
},
onTransaction: () => this.#toolbar?.refresh(),
})
@@ -244,6 +246,7 @@ export class BlogEditorElement extends HTMLElement {
attributeChangedCallback(): void {
this.#syncAttributes()
this.#schedulePreview()
}
/** Switches between the WYSIWYG and the Source tab. */
@@ -296,7 +299,23 @@ export class BlogEditorElement extends HTMLElement {
const frame = this.#previewFrameOf()
if (!frame) return
const html = this.value
frame.srcdoc = buildPreviewDocument(html, { labels: this.#labels })
const doc = buildPreviewDocument(html, {
labels: this.#labels,
lang: this.getAttribute('locale'),
...resolvePreviewColors(this),
})
// An identical document would only reload the frame.
if (frame.getAttribute('srcdoc') !== doc) frame.srcdoc = doc
}
/**
* Re-renders the preview PREVIEW_DEBOUNCE_MS after the last change, in
* both tabs; nothing is scheduled while the preview is off.
*/
#schedulePreview(): void {
if (!this.#previewOn || !this.#previewFrame) return
clearTimeout(this.#previewTimer)
this.#previewTimer = setTimeout(() => this.#renderPreview(), PREVIEW_DEBOUNCE_MS)
}
/** Returns the preview frame, creating it in the pane on first use. */
@@ -330,6 +349,7 @@ export class BlogEditorElement extends HTMLElement {
this.#sourceText = this.#textarea.value
this.#mode = 'source'
this.#emitChange()
this.#schedulePreview()
}
#emitChange(): void {

View File

@@ -1,3 +1,5 @@
import { EMBED_PROVIDERS, isEmbedProvider } from './embed'
import { inert } from './extensions'
import type { Labels } from './labels'
/**
@@ -17,25 +19,179 @@ export const PREVIEW_CSP = "default-src 'none'; img-src * data:; style-src 'unsa
/** How long the preview waits after the last change before it re-renders. */
export const PREVIEW_DEBOUNCE_MS = 300
/** The colours the preview stylesheet uses, as concrete CSS values. */
export interface PreviewColors {
surface: string
text: string
muted: string
border: string
borderStrong: string
subtle: string
primary: string
}
/** Neutral light-theme values for anything the host does not provide. */
export const PREVIEW_DEFAULTS: { colors: PreviewColors; fontFamily: string } = {
colors: {
surface: '#ffffff',
text: '#1a1a1a',
muted: '#6b7280',
border: '#e5e7eb',
borderStrong: '#9ca3af',
subtle: '#f6f7f9',
primary: '#2563eb',
},
fontFamily: 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
}
export interface PreviewOptions {
labels: Labels
lang?: string | null
colors?: PreviewColors
fontFamily?: string
}
/** The host variable each preview colour is read from. */
const COLOR_VARIABLES: Record<keyof PreviewColors, string> = {
surface: '--c-surface',
text: '--c-text',
muted: '--c-muted',
border: '--c-border',
borderStrong: '--c-border-strong',
subtle: '--c-subtle',
primary: '--c-primary',
}
/** The active elements the preview copy leaves out. */
const DROPPED_ELEMENTS = 'script, iframe, frame, frameset, object, embed, meta, base, link'
const LANG = /^[A-Za-z]{2,3}(-[A-Za-z0-9]{1,8})*$/
/**
* safeCssValue returns value trimmed, or fallback when it is empty, longer
* than 200 characters or holds a character that could close the style
* element or the declaration (angle or curly brackets, a semicolon, a
* backslash or a comment opener). A host value can then never inject a rule.
*/
export function safeCssValue(value: string | null | undefined, fallback: string): string {
const v = (value ?? '').trim()
if (v === '' || v.length > 200 || /[<>{};\\]/.test(v) || v.includes('/*')) return fallback
return v
}
/**
* resolvePreviewColors reads the host's --c-* variables and font family as
* computed values. CSS variables do not cross into the srcdoc document, so
* the preview stylesheet carries the concrete values.
*/
export function resolvePreviewColors(host: Element): { colors: PreviewColors; fontFamily: string } {
const style = getComputedStyle(host)
const colors = { ...PREVIEW_DEFAULTS.colors }
for (const key of Object.keys(COLOR_VARIABLES) as (keyof PreviewColors)[]) {
colors[key] = safeCssValue(style.getPropertyValue(COLOR_VARIABLES[key]), PREVIEW_DEFAULTS.colors[key])
}
return { colors, fontFamily: safeCssValue(style.fontFamily, PREVIEW_DEFAULTS.fontFamily) }
}
/**
* youtubeThumbnail returns the thumbnail URL for a YouTube id. Call it only
* with an id that passed the provider's idPattern.
*/
export function youtubeThumbnail(id: string): string {
return `https://img.youtube.com/vi/${id}/hqdefault.jpg`
}
/**
* previewBody returns the HTML the preview shows. It works on a copy parsed
* in the inert document: active elements and on* attributes are left out
* (defense in depth on top of the sandbox) and each embed placeholder
* becomes a thumbnail card, never a player. The stored value never changes.
*/
export function previewBody(html: string, labels: Labels): string {
const doc = inert()
const template = doc.createElement('template')
template.innerHTML = html
const root = template.content
for (const el of Array.from(root.querySelectorAll(DROPPED_ELEMENTS))) el.remove()
for (const el of Array.from(root.querySelectorAll('*'))) {
for (const attr of Array.from(el.attributes)) {
if (attr.name.toLowerCase().startsWith('on')) el.removeAttribute(attr.name)
}
}
for (const figure of Array.from(root.querySelectorAll('figure[data-embed]'))) {
const provider = figure.getAttribute('data-embed') ?? ''
const id = figure.getAttribute('data-id') ?? ''
// Exactly the editor's placeholder, the same test as the Embed node.
if (figure.attributes.length !== 2 || figure.childNodes.length !== 0) continue
if (!isEmbedProvider(provider) || !EMBED_PROVIDERS[provider].idPattern.test(id)) continue
const card = doc.createElement('figure')
card.className = 'embed-card'
const img = doc.createElement('img')
img.setAttribute('src', youtubeThumbnail(id))
img.setAttribute('alt', '')
img.setAttribute('referrerpolicy', 'no-referrer')
img.setAttribute('loading', 'lazy')
img.setAttribute('width', '480')
img.setAttribute('height', '360')
const caption = doc.createElement('figcaption')
caption.textContent = `${labels.embedCard}: ${EMBED_PROVIDERS[provider].name} ${id}`
card.append(img, caption)
figure.replaceWith(card)
}
const out = doc.createElement('div')
out.append(root)
return out.innerHTML
}
/** previewStyles returns the post-body typography with concrete values. */
function previewStyles(c: PreviewColors, font: string): string {
const mono = '"DM Mono", ui-monospace, SFMono-Regular, Menlo, monospace'
return [
`body{margin:0;padding:16px 20px;background:${c.surface};color:${c.text};font-family:${font};font-size:16px;line-height:1.7;overflow-wrap:anywhere}`,
'.post-body{max-width:72ch;margin-left:auto;margin-right:auto}',
'h1,h2,h3,h4{font-weight:600;margin:1.4em 0 0.5em}',
'h1{font-size:1.875em}h2{font-size:1.5em}h3{font-size:1.25em}h4{font-size:1.1em}',
'p,ul,ol,blockquote,table,figure,pre{margin:0 0 1em}',
'ul{list-style:disc;padding-left:1.5em}ol{list-style:decimal;padding-left:1.5em}',
`a{color:${c.primary};text-decoration:underline}`,
`blockquote{border-left:3px solid ${c.borderStrong};padding-left:1em;color:${c.muted}}`,
`code{padding:0 4px;border-radius:4px;background:${c.subtle};font-family:${mono};font-size:0.9em}`,
`pre{padding:8px 12px;border-radius:8px;background:${c.subtle};font-family:${mono};overflow:auto}`,
'pre code{padding:0;background:transparent}',
`hr{border:0;border-top:1px solid ${c.borderStrong}}`,
'img{max-width:100%;height:auto}',
'table{border-collapse:collapse;width:100%}',
`td,th{border:1px solid ${c.borderStrong};padding:4px 8px;vertical-align:top}`,
`th{background:${c.subtle};font-weight:600;text-align:left}`,
`figcaption{font-size:13px;color:${c.muted};margin-top:4px}`,
`.embed-card{max-width:480px;border:1px solid ${c.border};border-radius:8px;overflow:hidden;background:${c.subtle}}`,
'.embed-card img{display:block;width:100%;height:auto;aspect-ratio:16/9;object-fit:cover}',
'.embed-card figcaption{padding:8px 12px;margin:0}',
].join('\n')
}
/**
* buildPreviewDocument returns the srcdoc for the preview frame: a full
* document whose head starts with the charset, the CSP and the no-referrer
* metas, and whose body wraps the HTML in article.post-body. CSS variables
* do not cross into the frame, so it carries its own stylesheet.
* metas, then one stylesheet with the post-body typography, and whose body
* wraps previewBody(html) in article.post-body. CSS variables do not cross
* into the frame, so the stylesheet holds concrete colours.
*/
export function buildPreviewDocument(html: string, _options: PreviewOptions): string {
export function buildPreviewDocument(html: string, options: PreviewOptions): string {
const lang = options.lang && LANG.test(options.lang) ? ` lang="${options.lang}"` : ''
const colors = { ...PREVIEW_DEFAULTS.colors, ...options.colors }
const font = options.fontFamily || PREVIEW_DEFAULTS.fontFamily
return (
'<!doctype html><html><head>' +
`<!doctype html><html${lang}><head>` +
'<meta charset="utf-8">' +
`<meta http-equiv="Content-Security-Policy" content="${PREVIEW_CSP}">` +
'<meta name="referrer" content="no-referrer">' +
`<style>${previewStyles(colors, font)}</style>` +
'</head><body>' +
`<article class="post-body">${html}</article>` +
`<article class="post-body">${previewBody(html, options.labels)}</article>` +
'</body></html>'
)
}

View File

@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import fixture from './fixtures/imported-post.html?raw'
import { serializeDocument } from '../../assets/src/blogeditor/serializer'
import { PREVIEW_CSP } from '../../assets/src/blogeditor/preview'
import { PREVIEW_CSP, PREVIEW_DEBOUNCE_MS, PREVIEW_DEFAULTS, safeCssValue } from '../../assets/src/blogeditor/preview'
import type { BlogEditorElement } from '../../assets/src/blogeditor/index'
import { button, click, mount } from './helpers'
@@ -156,3 +156,168 @@ describe('blog editor preview: value contract', () => {
expect(changes).toHaveLength(1)
})
})
/** Switches fake timers on (after mount, before the toggle) and the preview on. */
function previewWithTimers(el: BlogEditorElement): void {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
click(button(el, 'preview'))
}
describe('blog editor preview: live updates', () => {
it('re-renders a WYSIWYG edit after the debounce', () => {
const { el, editor } = mount('<p>a</p>')
previewWithTimers(el)
expect(srcdoc(el)).toContain('<p>a</p>')
editor.view.dispatch(editor.state.tr.insertText('X', 1))
vi.advanceTimersByTime(PREVIEW_DEBOUNCE_MS - 1)
expect(srcdoc(el)).toContain('<p>a</p>')
expect(srcdoc(el)).not.toContain('<p>Xa</p>')
vi.advanceTimersByTime(1)
expect(srcdoc(el)).toContain('<p>Xa</p>')
})
it('restarts the debounce on every change', () => {
const { el, editor } = mount('<p>a</p>')
previewWithTimers(el)
editor.view.dispatch(editor.state.tr.insertText('X', 1))
vi.advanceTimersByTime(200)
editor.view.dispatch(editor.state.tr.insertText('Y', 1))
vi.advanceTimersByTime(200)
expect(srcdoc(el)).not.toContain('Xa')
expect(srcdoc(el)).not.toContain('YXa')
vi.advanceTimersByTime(100)
expect(srcdoc(el)).toContain('<p>YXa</p>')
})
it('re-renders Source tab input without an extra event', () => {
const { el, changes } = mount('<p>a</p>')
previewWithTimers(el)
el.showSource(true)
const textarea = el.querySelector('textarea')!
textarea.value = '<h2>From source</h2>'
textarea.dispatchEvent(new Event('input'))
vi.advanceTimersByTime(PREVIEW_DEBOUNCE_MS)
expect(srcdoc(el)).toContain('<h2>From source</h2>')
expect(changes).toEqual(['<h2>From source</h2>'])
})
it('re-renders an external value without an event', () => {
const { el, changes } = mount('<p>a</p>')
previewWithTimers(el)
el.value = '<p>New</p>'
vi.advanceTimersByTime(PREVIEW_DEBOUNCE_MS)
expect(srcdoc(el)).toContain('<p>New</p>')
expect(changes).toEqual([])
})
it('schedules nothing while it is off', () => {
const { el, editor } = mount('<p>a</p>')
previewWithTimers(el)
click(button(el, 'preview'))
const last = srcdoc(el)
editor.view.dispatch(editor.state.tr.insertText('X', 1))
vi.advanceTimersByTime(1000)
expect(srcdoc(el)).toBe(last)
})
it('drops a pending render when the element is detached', () => {
const { el, editor } = mount('<p>a</p>')
previewWithTimers(el)
const last = srcdoc(el)
editor.view.dispatch(editor.state.tr.insertText('X', 1))
el.remove()
expect(() => vi.advanceTimersByTime(1000)).not.toThrow()
expect(srcdoc(el)).toBe(last)
})
it('keeps an untouched post exact after renders', () => {
const { el, changes } = mount(fixture)
previewWithTimers(el)
vi.advanceTimersByTime(1000)
expect(changes).toEqual([])
expect(el.value).toBe(fixture)
expect(serializeDocument).not.toHaveBeenCalled()
})
})
describe('blog editor preview: content', () => {
const placeholder = '<figure data-embed="youtube" data-id="dQw4w9WgXcQ"></figure>'
it('shows a YouTube placeholder as a thumbnail card, never a player', () => {
const { el } = mount(placeholder)
click(button(el, 'preview'))
const doc = srcdoc(el)
expect(doc).toContain('https://img.youtube.com/vi/dQw4w9WgXcQ/hqdefault.jpg')
expect(doc).toContain('referrerpolicy="no-referrer"')
expect(doc).toContain('Embedded video: YouTube dQw4w9WgXcQ')
expect(doc).not.toMatch(/<iframe/i)
expect(el.value).toBe(placeholder)
const { el: pl } = mount(placeholder, { 'ui-locale': 'pl' })
click(button(pl, 'preview'))
expect(srcdoc(pl)).toContain('Osadzone wideo: YouTube dQw4w9WgXcQ')
})
it('requests no thumbnail for an invalid placeholder', () => {
const { el } = mount('<figure data-embed="youtube" data-id="short"></figure>')
click(button(el, 'preview'))
expect(srcdoc(el)).not.toContain('img.youtube.com')
})
it('drops active content from the preview copy only', () => {
const value =
'<p>a</p><script>alert(1)</script><iframe src="https://example.test/"></iframe>' +
'<img src="/x.png" onerror="alert(1)"><meta http-equiv="refresh" content="0;url=https://example.test/">'
const { el, changes } = mount(value)
click(button(el, 'preview'))
const doc = srcdoc(el)
expect(doc).not.toMatch(/<script/i)
expect(doc).not.toMatch(/<iframe/i)
expect(doc).not.toMatch(/onerror/i)
expect(doc).not.toMatch(/http-equiv="refresh"/i)
expect(doc).toContain('<p>a</p>')
expect(doc).toContain('src="/x.png"')
expect(el.value).toBe(value)
expect(changes).toEqual([])
})
it('resolves colours from the host or uses the defaults', () => {
const { el } = mount('<p>a</p>')
el.style.setProperty('--c-text', 'rgb(1, 2, 3)')
click(button(el, 'preview'))
expect(srcdoc(el)).toContain('rgb(1, 2, 3)')
expect(srcdoc(el)).not.toContain('var(--c-')
const { el: plain } = mount('<p>b</p>')
click(button(plain, 'preview'))
expect(srcdoc(plain)).toContain(PREVIEW_DEFAULTS.colors.text)
expect(srcdoc(plain)).not.toContain('var(--c-')
})
it('sets the document language and the post-body typography', () => {
const { el } = mount('<p>a</p>', { locale: 'pl' })
click(button(el, 'preview'))
expect(srcdoc(el)).toContain('<html lang="pl">')
const { el: odd } = mount('<p>a</p>', { locale: 'pl"><x' })
click(button(odd, 'preview'))
const doc = srcdoc(odd)
expect(doc).toContain('<html>')
expect(doc).not.toContain('lang=')
expect(doc.match(/<style/g)).toHaveLength(1)
for (const rule of ['.post-body', 'blockquote', 'figcaption', '.embed-card']) expect(doc).toContain(rule)
expect(doc).toContain('<article class="post-body">')
})
})
describe('safeCssValue', () => {
it('falls back for anything that could leave the declaration', () => {
for (const bad of ['', ' ', 'red}', 'a<b', 'red; color: blue', 'x'.repeat(201), 'red\\', 'red /* x */', 'a>b', 'a{b'])
expect(safeCssValue(bad, 'fallback')).toBe('fallback')
})
it('returns a safe value trimmed', () => {
expect(safeCssValue(' rgb(1, 2, 3) ', 'fallback')).toBe('rgb(1, 2, 3)')
expect(safeCssValue('"DM Sans", sans-serif', 'fallback')).toBe('"DM Sans", sans-serif')
})
})