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:
85
assets/dist/blogeditor.js
vendored
85
assets/dist/blogeditor.js
vendored
File diff suppressed because one or more lines are too long
@@ -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 {
|
||||
|
||||
@@ -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>'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user