feat(editor): toggleable preview pane in a sandboxed frame

- A per-element Preview toggle (aria-pressed, off by default) splits the editor 50/50 with an auto-fit grid that wraps the pane below on narrow widths.
- The pane is an iframe with an empty sandbox, content set only through srcdoc, created on first use; it reads only the value getter, so previewing never edits or emits summer-change.
This commit is contained in:
2026-10-10 13:53:48 +02:00
parent 9d5bfc4a35
commit df67939251
8 changed files with 372 additions and 30 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -46,6 +46,11 @@ golem15-journal-blogeditor .blogeditor__tabs {
margin-left: auto;
}
golem15-journal-blogeditor .blogeditor__view {
display: flex;
gap: 2px;
}
golem15-journal-blogeditor .blogeditor__form {
display: flex;
flex-wrap: wrap;
@@ -170,6 +175,37 @@ golem15-journal-blogeditor .blogeditor__source:focus {
outline-offset: -2px;
}
/*
* Preview split: two equal columns when the element is at least about 720px
* wide; below that auto-fit collapses to one column and the preview wraps
* below the editor. The element's width drives it, not the viewport's.
*/
golem15-journal-blogeditor .blogeditor__panes--split {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr));
}
golem15-journal-blogeditor .blogeditor__main {
min-width: 0;
}
golem15-journal-blogeditor .blogeditor__preview {
display: flex;
min-width: 0;
min-height: 360px;
background: var(--c-surface);
outline: 1px solid var(--c-border);
}
golem15-journal-blogeditor .blogeditor__preview iframe {
display: block;
flex: 1 1 auto;
width: 100%;
min-height: 360px;
border: 0;
background: var(--c-surface);
}
golem15-journal-blogeditor [hidden] {
display: none !important;
}

View File

@@ -2,6 +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 { captureBlocks, serializeDocument, type Blocks } from './serializer'
import { buildToolbar, type Toolbar } from './toolbar'
@@ -62,6 +63,12 @@ export class BlogEditorElement extends HTMLElement {
#showingSource = false
#pending = new Map<string, { name: string; timer: ReturnType<typeof setTimeout> }>()
#ready: { url: string; name: string }[] = []
/** Whether this element shows the preview pane (per instance, off by default). */
#previewOn = false
#panes?: HTMLDivElement
#previewPane?: HTMLDivElement
#previewFrame?: HTMLIFrameElement
#previewTimer?: ReturnType<typeof setTimeout>
constructor() {
super()
@@ -114,6 +121,7 @@ export class BlogEditorElement extends HTMLElement {
pickImage: () => this.#fileInput?.click(),
insertRawHtml: () => this.#insertRawHtml(),
showSource: (on) => this.showSource(on),
togglePreview: () => this.showPreview(!this.#previewOn),
})
this.#linkForm = this.#form('link', labels.linkUrl, [
@@ -154,13 +162,31 @@ export class BlogEditorElement extends HTMLElement {
fileInput.addEventListener('change', () => this.#picked())
this.#fileInput = fileInput
// The editor and the Source tab sit in the main pane; the preview pane
// beside it is hidden until the toggle is pressed.
const main = document.createElement('div')
main.className = 'blogeditor__main'
main.append(mount, textarea)
// The frame itself is created on the first switch-on (#previewFrameOf),
// so an element that never previews holds no iframe.
const previewPane = document.createElement('div')
previewPane.className = 'blogeditor__preview'
previewPane.hidden = true
this.#previewPane = previewPane
this.#previewFrame = undefined
const panes = document.createElement('div')
panes.className = 'blogeditor__panes'
panes.append(main, previewPane)
this.#panes = panes
this.append(
this.#toolbar.element,
this.#linkForm.root,
this.#embedForm.root,
this.#imageForm.root,
mount,
textarea,
panes,
status,
fileInput,
)
@@ -196,6 +222,7 @@ export class BlogEditorElement extends HTMLElement {
this.#blocks = captureBlocks(initial, this.#editor.schema)
this.#syncAttributes()
this.showSource(false)
if (this.#previewOn) this.showPreview(true)
}
disconnectedCallback(): void {
@@ -203,6 +230,8 @@ export class BlogEditorElement extends HTMLElement {
this.removeEventListener(EDIT_HTML_EVENT, this.#onEditHtml)
for (const { timer } of this.#pending.values()) clearTimeout(timer)
this.#pending.clear()
clearTimeout(this.#previewTimer)
this.#previewTimer = undefined
this.#ready = []
// Keep an edited value readable while the element is detached.
if (this.#mode === 'wysiwyg' && this.#editor) {
@@ -237,6 +266,54 @@ export class BlogEditorElement extends HTMLElement {
this.#toolbar?.selectTab(on)
}
/**
* Shows or hides the preview pane. The preview is read-only: it reads the
* value getter and never edits, emits summer-change or changes the value.
*/
showPreview(on: boolean): void {
this.#previewOn = on
if (!this.#panes || !this.#previewPane) return
this.#previewPane.hidden = !on
this.#panes.classList.toggle('blogeditor__panes--split', on)
this.#toolbar?.setPreview(on)
if (on) {
this.#renderPreview()
} else {
clearTimeout(this.#previewTimer)
this.#previewTimer = undefined
}
}
/**
* Renders the current value into the preview frame. It reads only the
* value getter: an untouched document returns the exact string without
* the serializer, and nothing in the stored-value path is written.
*/
#renderPreview(): void {
clearTimeout(this.#previewTimer)
this.#previewTimer = undefined
if (!this.#previewOn) return
const frame = this.#previewFrameOf()
if (!frame) return
const html = this.value
frame.srcdoc = buildPreviewDocument(html, { labels: this.#labels })
}
/** Returns the preview frame, creating it in the pane on first use. */
#previewFrameOf(): HTMLIFrameElement | undefined {
if (this.#previewFrame) return this.#previewFrame
if (!this.#previewPane) return undefined
const frame = document.createElement('iframe')
// An empty sandbox: no scripts, an opaque origin. Content enters only
// through srcdoc; there is never a src, an allow or another token.
frame.setAttribute('sandbox', PREVIEW_SANDBOX)
frame.setAttribute('referrerpolicy', 'no-referrer')
frame.title = this.#labels.previewFrame
this.#previewPane.append(frame)
this.#previewFrame = frame
return frame
}
/**
* Parses html into the editor without an update event (Pitfall 4) and
* outside the undo history, so undo never brings back a replaced value.

View File

@@ -43,6 +43,8 @@ export interface Labels {
rawHtmlCard: string
editAsHtml: string
embedCard: string
preview: string
previewFrame: string
}
const en: Labels = {
@@ -89,6 +91,8 @@ const en: Labels = {
rawHtmlCard: 'HTML block',
editAsHtml: 'Edit as HTML',
embedCard: 'Embedded video',
preview: 'Preview',
previewFrame: 'Post preview',
}
const pl: Labels = {
@@ -135,6 +139,8 @@ const pl: Labels = {
rawHtmlCard: 'Blok HTML',
editAsHtml: 'Edytuj jako HTML',
embedCard: 'Osadzone wideo',
preview: 'Podgląd',
previewFrame: 'Podgląd wpisu',
}
const TABLES: Record<string, Labels> = { en, pl }

View File

@@ -0,0 +1,41 @@
import type { Labels } from './labels'
/**
* The preview pane's iframe sandbox attribute: an empty token list, the most
* restrictive sandbox. Pasted or imported HTML runs no script, the frame
* gets an opaque origin (it cannot read the admin's cookies, storage or DOM)
* and it cannot submit forms, open popups or navigate the top window.
*/
export const PREVIEW_SANDBOX = ''
/**
* The Content-Security-Policy the preview document declares on top of the
* admin page's policy it inherits: only inline styles and images load.
*/
export const PREVIEW_CSP = "default-src 'none'; img-src * data:; style-src 'unsafe-inline'"
/** How long the preview waits after the last change before it re-renders. */
export const PREVIEW_DEBOUNCE_MS = 300
export interface PreviewOptions {
labels: Labels
lang?: string | null
}
/**
* 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.
*/
export function buildPreviewDocument(html: string, _options: PreviewOptions): string {
return (
'<!doctype html><html><head>' +
'<meta charset="utf-8">' +
`<meta http-equiv="Content-Security-Policy" content="${PREVIEW_CSP}">` +
'<meta name="referrer" content="no-referrer">' +
'</head><body>' +
`<article class="post-body">${html}</article>` +
'</body></html>'
)
}

View File

@@ -10,6 +10,8 @@ export interface ToolbarHost {
pickImage: () => void
insertRawHtml: () => void
showSource: (on: boolean) => void
/** Switches the preview pane on or off. */
togglePreview: () => void
}
export interface Toolbar {
@@ -20,6 +22,8 @@ export interface Toolbar {
selectTab: (source: boolean) => void
/** Disables every control (the element's disabled attribute). */
setDisabled: (disabled: boolean) => void
/** Marks the preview toggle as pressed or not. */
setPreview: (on: boolean) => void
}
interface ButtonSpec {
@@ -74,7 +78,8 @@ const GROUPS: ButtonSpec[][] = [
/**
* buildToolbar builds the formatting toolbar and the WYSIWYG/Source tabs
* (D-04): paragraph and headings 2 to 4, bold, italic, link, lists,
* blockquote, tables, image, embed, raw HTML block, undo and redo.
* blockquote, tables, image, embed, raw HTML block, undo and redo, plus the
* preview toggle.
*/
export function buildToolbar(host: ToolbarHost): Toolbar {
const { labels } = host
@@ -127,7 +132,23 @@ export function buildToolbar(host: ToolbarHost): Toolbar {
const wysiwygTab = tab('wysiwyg', labels.tabWysiwyg)
const sourceTab = tab('source', labels.tabSource)
element.append(tools, tabs)
// The preview is a toggle, not a tab: it sits outside the tablist and
// outside the buttons refresh() disables, because a read-only preview works
// in the Source tab, when disabled and before an editor exists.
const view = document.createElement('div')
view.className = 'blogeditor__view'
const preview = document.createElement('button')
preview.type = 'button'
preview.dataset.command = 'preview'
preview.textContent = labels.preview
preview.title = labels.preview
preview.setAttribute('aria-label', labels.preview)
preview.setAttribute('aria-pressed', 'false')
preview.addEventListener('mousedown', (event) => event.preventDefault())
preview.addEventListener('click', () => host.togglePreview())
view.append(preview)
element.append(tools, tabs, view)
let disabled = false
let source = false
@@ -153,5 +174,8 @@ export function buildToolbar(host: ToolbarHost): Toolbar {
disabled = on
refresh()
},
setPreview: (on) => {
preview.setAttribute('aria-pressed', String(on))
},
}
}

View File

@@ -0,0 +1,158 @@
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 type { BlogEditorElement } from '../../assets/src/blogeditor/index'
import { button, click, mount } from './helpers'
// Wrap the real serializer so the tests can tell whether the preview path
// ever serializes an untouched document.
vi.mock('../../assets/src/blogeditor/serializer', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../assets/src/blogeditor/serializer')>()
return { ...actual, serializeDocument: vi.fn(actual.serializeDocument) }
})
afterEach(() => {
vi.useRealTimers()
vi.mocked(serializeDocument).mockClear()
document.body.replaceChildren()
})
function pane(el: BlogEditorElement): HTMLElement {
const found = el.querySelector<HTMLElement>('.blogeditor__preview')
if (!found) throw new Error('no preview pane')
return found
}
function frame(el: BlogEditorElement): HTMLIFrameElement {
const found = el.querySelector<HTMLIFrameElement>('.blogeditor__preview iframe')
if (!found) throw new Error('no preview frame')
return found
}
function srcdoc(el: BlogEditorElement): string {
return frame(el).getAttribute('srcdoc') ?? ''
}
describe('blog editor preview: toggle', () => {
it('is off by default and toggles the split pane with aria-pressed', () => {
const { el } = mount('<p>Hello</p>')
const toggle = button(el, 'preview')
expect(toggle.textContent).toBe('Preview')
expect(toggle.getAttribute('aria-label')).toBe('Preview')
expect(toggle.getAttribute('aria-pressed')).toBe('false')
expect(pane(el).hidden).toBe(true)
click(toggle)
expect(toggle.getAttribute('aria-pressed')).toBe('true')
expect(pane(el).hidden).toBe(false)
expect(el.querySelector('.blogeditor__panes')!.classList.contains('blogeditor__panes--split')).toBe(true)
// Rendered right away, with no timer advanced.
expect(srcdoc(el)).toContain('<p>Hello</p>')
click(toggle)
expect(toggle.getAttribute('aria-pressed')).toBe('false')
expect(pane(el).hidden).toBe(true)
expect(el.querySelector('.blogeditor__panes')!.classList.contains('blogeditor__panes--split')).toBe(false)
})
it('is labelled through the ui-locale', () => {
const { el: pl } = mount('<p>x</p>', { 'ui-locale': 'pl' })
expect(button(pl, 'preview').textContent).toBe('Podgląd')
expect(button(pl, 'preview').getAttribute('aria-label')).toBe('Podgląd')
click(button(pl, 'preview'))
expect(frame(pl).title).toBe('Podgląd wpisu')
const { el: en } = mount('<p>x</p>', { 'ui-locale': 'en' })
click(button(en, 'preview'))
expect(frame(en).title).toBe('Post preview')
})
it('creates no iframe until the preview is first switched on', () => {
const { el } = mount('<p>x</p>')
expect(el.querySelector('iframe')).toBeNull()
click(button(el, 'preview'))
expect(el.querySelectorAll('iframe')).toHaveLength(1)
click(button(el, 'preview'))
click(button(el, 'preview'))
expect(el.querySelectorAll('iframe')).toHaveLength(1)
})
it('keeps its state per element', () => {
const { el: first } = mount('<p>one</p>')
const { el: second } = mount('<p>two</p>')
click(button(first, 'preview'))
expect(button(first, 'preview').getAttribute('aria-pressed')).toBe('true')
expect(button(second, 'preview').getAttribute('aria-pressed')).toBe('false')
expect(pane(second).hidden).toBe(true)
})
it('stays usable in the Source tab and when disabled', () => {
const { el: source } = mount('<p>s</p>')
source.showSource(true)
expect(button(source, 'preview').disabled).toBe(false)
click(button(source, 'preview'))
expect(button(source, 'preview').getAttribute('aria-pressed')).toBe('true')
expect(pane(source).hidden).toBe(false)
const { el: disabled } = mount('<p>d</p>', { disabled: '' })
expect(button(disabled, 'preview').disabled).toBe(false)
click(button(disabled, 'preview'))
expect(button(disabled, 'preview').getAttribute('aria-pressed')).toBe('true')
expect(pane(disabled).hidden).toBe(false)
})
})
describe('blog editor preview: sandbox', () => {
it('uses an empty sandbox and only srcdoc', () => {
const { el } = mount('<p>x</p>')
click(button(el, 'preview'))
const f = frame(el)
expect(f.getAttribute('sandbox')).toBe('')
const sandbox = f.getAttribute('sandbox') ?? ''
expect(sandbox).not.toMatch(/allow-scripts/)
expect(sandbox).not.toMatch(/allow-same-origin/)
expect(f.hasAttribute('src')).toBe(false)
expect(f.hasAttribute('allow')).toBe(false)
expect(f.getAttribute('referrerpolicy')).toBe('no-referrer')
const doc = srcdoc(el)
expect(doc).toContain(`<meta http-equiv="Content-Security-Policy" content="${PREVIEW_CSP}">`)
expect(doc).toContain('<meta name="referrer" content="no-referrer">')
// The head starts with the charset, then the CSP, then the referrer meta.
expect(doc.indexOf('<meta charset="utf-8">')).toBeLessThan(doc.indexOf('Content-Security-Policy'))
expect(doc.indexOf('Content-Security-Policy')).toBeLessThan(doc.indexOf('name="referrer"'))
})
})
describe('blog editor preview: value contract', () => {
it('never touches an untouched imported post', () => {
const { el, editor, changes } = mount(fixture)
const before = editor.state.doc
const toggle = button(el, 'preview')
click(toggle)
click(toggle)
click(toggle)
expect(changes).toEqual([])
expect(el.value).toBe(fixture)
expect(editor.state.doc).toBe(before)
expect(serializeDocument).not.toHaveBeenCalled()
expect(srcdoc(el)).toContain('Jan Kowalski')
el.value = fixture
expect(changes).toEqual([])
})
it('reads a touched document through the value getter without an event', () => {
const { el, editor, changes } = mount('<p>a</p>')
editor.view.dispatch(editor.state.tr.insertText('X', 1))
const toggle = button(el, 'preview')
click(toggle)
expect(changes).toHaveLength(1)
expect(srcdoc(el)).toContain('<p>Xa</p>')
expect(serializeDocument).toHaveBeenCalled()
click(toggle)
click(toggle)
expect(changes).toHaveLength(1)
})
})