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:
2
assets/dist/blogeditor.css
vendored
2
assets/dist/blogeditor.css
vendored
File diff suppressed because one or more lines are too long
50
assets/dist/blogeditor.js
vendored
50
assets/dist/blogeditor.js
vendored
File diff suppressed because one or more lines are too long
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
|
||||
41
assets/src/blogeditor/preview.ts
Normal file
41
assets/src/blogeditor/preview.ts
Normal 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>'
|
||||
)
|
||||
}
|
||||
@@ -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))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
158
tests/blogeditor/preview.test.ts
Normal file
158
tests/blogeditor/preview.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user