feat(editor): preservation schema, block reuse, toolbar, uploads and embeds

- preservationExtensions: raw HTML cards for unmodelled blocks (shown as
  text), figure with figcaption, the YouTube embed placeholder node, span
  marks, a global attribute map (never on* handlers), tables without
  colgroup or min-width, inline images without base64
- serializeDocument keeps the original bytes of every top-level block still
  equal to the loaded one, so editing one paragraph changes only that
  paragraph; tables serialize with thead and bare cell text
- Links accept only http, https, mailto and relative URLs (isPolicyUrl, the
  rule both sanitizer policies apply) and gain no target, rel or class
- Toolbar with headings, marks, lists, links, blockquote, table editing,
  images, embeds, raw HTML block, undo and redo; en and pl labels
- Images from the picker or a drop go out as summer-upload and are inserted
  on summer-upload-result with alt text and an optional caption
- editor-corpus.json: the editor own output, rebuilt and checked by
  corpus.test.ts (UPDATE_CORPUS=1 rewrites it) for the policy tests
- README Blog Editor section
This commit is contained in:
2026-10-10 11:53:07 +02:00
parent 47f3032dcf
commit 086e485215
14 changed files with 2604 additions and 99 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

@@ -28,12 +28,90 @@ golem15-journal-blogeditor .blogeditor__bar {
border-radius: 12px 12px 0 0;
}
golem15-journal-blogeditor .blogeditor__tools {
display: flex;
flex-wrap: wrap;
gap: 4px 12px;
}
golem15-journal-blogeditor .blogeditor__group {
display: flex;
flex-wrap: wrap;
gap: 2px;
}
golem15-journal-blogeditor .blogeditor__tabs {
display: flex;
gap: 2px;
margin-left: auto;
}
golem15-journal-blogeditor .blogeditor__form {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 6px 8px;
padding: 8px;
border-bottom: 1px solid var(--c-border);
background: var(--c-surface);
}
golem15-journal-blogeditor .blogeditor__form label {
display: flex;
flex: 1 1 220px;
flex-direction: column;
gap: 2px;
font-size: 12px;
color: var(--c-muted);
}
golem15-journal-blogeditor .blogeditor__form input {
box-sizing: border-box;
width: 100%;
padding: 5px 8px;
border: 1px solid var(--c-border-strong);
border-radius: 6px;
background: var(--c-bg);
color: var(--c-text);
font: inherit;
font-size: 13px;
}
golem15-journal-blogeditor .blogeditor__form input::placeholder {
color: var(--c-placeholder);
}
golem15-journal-blogeditor .blogeditor__form input:focus {
outline: 2px solid var(--c-ring);
outline-offset: -1px;
}
golem15-journal-blogeditor .blogeditor__form button[data-action="apply"],
golem15-journal-blogeditor .blogeditor__form button[data-action="insert"] {
background: var(--c-primary);
color: var(--c-on-primary);
}
golem15-journal-blogeditor .blogeditor__error {
flex-basis: 100%;
margin: 0;
font-size: 12px;
color: var(--c-danger);
}
golem15-journal-blogeditor .blogeditor__error:empty,
golem15-journal-blogeditor .blogeditor__status:empty {
display: none;
}
golem15-journal-blogeditor .blogeditor__status {
margin: 0;
padding: 6px 12px;
border-top: 1px solid var(--c-border);
font-size: 12px;
color: var(--c-muted);
}
golem15-journal-blogeditor button {
font: inherit;
font-size: 13px;
@@ -227,3 +305,80 @@ golem15-journal-blogeditor .ProseMirror img {
max-width: 100%;
height: auto;
}
golem15-journal-blogeditor .ProseMirror table {
border-collapse: collapse;
width: 100%;
}
golem15-journal-blogeditor .ProseMirror :where(td, th) {
border: 1px solid var(--c-border-strong);
padding: 4px 8px;
vertical-align: top;
}
golem15-journal-blogeditor .ProseMirror th {
background: var(--c-subtle);
font-weight: 600;
text-align: left;
}
golem15-journal-blogeditor .ProseMirror :where(td, th) > p {
margin: 0;
}
golem15-journal-blogeditor .ProseMirror .selectedCell {
background: var(--c-sel);
}
golem15-journal-blogeditor .ProseMirror figure {
margin-left: 0;
margin-right: 0;
}
golem15-journal-blogeditor .ProseMirror figcaption {
margin-top: 4px;
font-size: 13px;
color: var(--c-muted);
}
/* Raw HTML and embed cards: never live markup. */
golem15-journal-blogeditor .blogeditor__raw,
golem15-journal-blogeditor .blogeditor__embed {
border: 1px dashed var(--c-border-strong);
border-radius: 8px;
background: var(--c-subtle);
color: var(--c-muted);
font-size: 13px;
}
golem15-journal-blogeditor .blogeditor__embed {
padding: 16px;
text-align: center;
}
golem15-journal-blogeditor .blogeditor__card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 4px 4px 4px 10px;
border-bottom: 1px dashed var(--c-border);
}
golem15-journal-blogeditor .blogeditor__raw-source {
max-height: 160px;
margin: 0;
padding: 8px 10px;
overflow: auto;
background: transparent;
font-family: "DM Mono", ui-monospace, monospace;
font-size: 12px;
white-space: pre-wrap;
}
golem15-journal-blogeditor .ProseMirror-selectednode.blogeditor__raw,
golem15-journal-blogeditor .ProseMirror-selectednode.blogeditor__embed,
golem15-journal-blogeditor .ProseMirror img.ProseMirror-selectednode {
outline: 2px solid var(--c-ring);
}

View File

@@ -1,12 +1,27 @@
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import { serializeDocument } from './serializer'
import { parseEmbedUrl } from './embed'
import { EDIT_HTML_EVENT, isPolicyUrl, preservationExtensions } from './extensions'
import { format, labelsFor, type Labels } from './labels'
import { captureBlocks, serializeDocument, type Blocks } from './serializer'
import { buildToolbar, type Toolbar } from './toolbar'
export const TAG = 'golem15-journal-blogeditor'
export const CHANGE_EVENT = 'summer-change'
export const UPLOAD_EVENT = 'summer-upload'
export const UPLOAD_RESULT_EVENT = 'summer-upload-result'
/** The image types the picker offers and the editor uploads (D-18). */
export const IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
/** How long the element waits for summer-upload-result. */
export const UPLOAD_TIMEOUT_MS = 60_000
type Mode = 'clean' | 'wysiwyg' | 'source'
interface InlineForm {
root: HTMLDivElement
error: HTMLParagraphElement
}
/**
* BlogEditorElement is the Journal Blog Editor (D-01, D-03, D-13): a light-DOM
* custom element with a WYSIWYG tab on TipTap and a Source tab that edits the
@@ -17,7 +32,10 @@ type Mode = 'clean' | 'wysiwyg' | 'source'
* invalid; a bubbling summer-change event with detail.value is dispatched on
* user edits only. The element returns the exact string it was given until
* the user edits, and setting value to the string it currently returns is a
* no-op. It never receives or sends credentials.
* no-op. Images are handed to the host page as summer-upload {id, file} and
* inserted when it answers summer-upload-result {id, url} (or shows
* {id, error}). It never receives or sends credentials and never sanitizes:
* the server does that on save.
*/
export class BlogEditorElement extends HTMLElement {
static observedAttributes = ['disabled', 'invalid', 'locale']
@@ -28,12 +46,22 @@ export class BlogEditorElement extends HTMLElement {
#sourceText = ''
/** The string the editor content was last parsed from. */
#parsed = ''
/** That string split into top-level blocks, for block reuse. */
#blocks: Blocks | null = null
#mode: Mode = 'clean'
#editor?: Editor
#labels: Labels = labelsFor('en')
#toolbar?: Toolbar
#mount?: HTMLDivElement
#textarea?: HTMLTextAreaElement
#tabs: { wysiwyg?: HTMLButtonElement; source?: HTMLButtonElement } = {}
#status?: HTMLParagraphElement
#fileInput?: HTMLInputElement
#linkForm?: InlineForm
#embedForm?: InlineForm
#imageForm?: InlineForm
#showingSource = false
#pending = new Map<string, { name: string; timer: ReturnType<typeof setTimeout> }>()
#ready: { url: string; name: string }[] = []
constructor() {
super()
@@ -51,7 +79,7 @@ export class BlogEditorElement extends HTMLElement {
case 'source':
return this.#sourceText
case 'wysiwyg':
return this.#editor ? serializeDocument(this.#editor) : this.#value
return this.#editor ? serializeDocument(this.#editor, this.#blocks) : this.#value
default:
return this.#value
}
@@ -62,9 +90,8 @@ export class BlogEditorElement extends HTMLElement {
if (v === this.value) return // echo of our own summer-change
this.#value = v
this.#mode = 'clean'
this.#parsed = v
if (this.#textarea) this.#textarea.value = v
this.#editor?.commands.setContent(v, { emitUpdate: false })
this.#load(v)
}
/** The TipTap editor while the element is connected (toolbar and tests). */
@@ -76,16 +103,29 @@ export class BlogEditorElement extends HTMLElement {
if (this.#editor) return
this.classList.add('blogeditor')
this.replaceChildren()
this.#labels = labelsFor(this.getAttribute('ui-locale'))
const labels = this.#labels
const bar = document.createElement('div')
bar.className = 'blogeditor__bar'
const tabs = document.createElement('div')
tabs.className = 'blogeditor__tabs'
tabs.setAttribute('role', 'tablist')
this.#tabs.wysiwyg = this.#tab('WYSIWYG', () => this.showSource(false))
this.#tabs.source = this.#tab('Source', () => this.showSource(true))
tabs.append(this.#tabs.wysiwyg, this.#tabs.source)
bar.append(tabs)
this.#toolbar = buildToolbar({
editor: () => this.#editor,
labels,
openLink: () => this.#openLink(),
openEmbed: () => this.#openEmbed(),
pickImage: () => this.#fileInput?.click(),
insertRawHtml: () => this.#insertRawHtml(),
showSource: (on) => this.showSource(on),
})
this.#linkForm = this.#form('link', labels.linkUrl, [
['apply', labels.linkApply, () => this.#applyLink()],
['remove', labels.linkRemove, () => this.#removeLink()],
['cancel', labels.cancel, () => this.#close(this.#linkForm)],
])
this.#embedForm = this.#form('embed', labels.embedUrl, [
['insert', labels.embedInsert, () => this.#insertEmbed()],
['cancel', labels.cancel, () => this.#close(this.#embedForm)],
])
this.#imageForm = this.#imageFormElement()
const mount = document.createElement('div')
mount.className = 'blogeditor__content'
@@ -95,35 +135,80 @@ export class BlogEditorElement extends HTMLElement {
textarea.className = 'blogeditor__source'
textarea.spellcheck = false
textarea.hidden = true
textarea.setAttribute('aria-label', labels.tabSource)
textarea.value = this.value
textarea.addEventListener('input', () => this.#sourceInput())
this.#textarea = textarea
this.append(bar, mount, textarea)
const status = document.createElement('p')
status.className = 'blogeditor__status'
status.setAttribute('role', 'status')
status.setAttribute('aria-live', 'polite')
this.#status = status
this.#parsed = this.value
const fileInput = document.createElement('input')
fileInput.type = 'file'
fileInput.accept = IMAGE_TYPES.join(',')
fileInput.multiple = true
fileInput.hidden = true
fileInput.addEventListener('change', () => this.#picked())
this.#fileInput = fileInput
this.append(
this.#toolbar.element,
this.#linkForm.root,
this.#embedForm.root,
this.#imageForm.root,
mount,
textarea,
status,
fileInput,
)
this.addEventListener(UPLOAD_RESULT_EVENT, this.#onUploadResult)
this.addEventListener(EDIT_HTML_EVENT, this.#onEditHtml)
const initial = this.value
this.#parsed = initial
this.#editor = new Editor({
element: mount,
injectCSS: false,
editable: !this.hasAttribute('disabled'),
extensions: [
StarterKit.configure({
trailingNode: false,
link: { openOnClick: false, HTMLAttributes: { target: null, rel: null, class: null } },
}),
],
content: this.#parsed,
extensions: preservationExtensions(labels),
content: initial,
editorProps: {
handleDrop: (_view, event, _slice, moved) => {
if (moved) return false
const files = Array.from((event as DragEvent).dataTransfer?.files ?? []).filter(isImage)
if (files.length === 0) return false
event.preventDefault()
for (const file of files) this.#upload(file)
return true
},
},
onUpdate: ({ transaction, appendedTransactions }) => {
if (!transaction.docChanged && !appendedTransactions.some((tr) => tr.docChanged)) return
this.#mode = 'wysiwyg'
this.#emitChange()
},
onTransaction: () => this.#toolbar?.refresh(),
})
this.#blocks = captureBlocks(initial, this.#editor.schema)
this.#syncAttributes()
this.showSource(false)
}
disconnectedCallback(): void {
this.removeEventListener(UPLOAD_RESULT_EVENT, this.#onUploadResult)
this.removeEventListener(EDIT_HTML_EVENT, this.#onEditHtml)
for (const { timer } of this.#pending.values()) clearTimeout(timer)
this.#pending.clear()
this.#ready = []
// Keep an edited value readable while the element is detached.
if (this.#mode === 'wysiwyg' && this.#editor) {
this.#value = this.value
this.#mode = 'clean'
}
this.#editor?.destroy()
this.#editor = undefined
}
@@ -140,24 +225,27 @@ export class BlogEditorElement extends HTMLElement {
} else if (this.#showingSource && this.#mode === 'source' && this.#sourceText !== this.#parsed) {
// Re-parse only when the source text changed; the value stays the
// exact text until the next WYSIWYG edit.
this.#parsed = this.#sourceText
this.#editor?.commands.setContent(this.#sourceText, { emitUpdate: false })
this.#load(this.#sourceText)
}
this.#showingSource = on
this.#textarea.hidden = !on
this.#mount.hidden = on
this.#tabs.wysiwyg?.setAttribute('aria-selected', String(!on))
this.#tabs.source?.setAttribute('aria-selected', String(on))
if (on) {
this.#close(this.#linkForm)
this.#close(this.#embedForm)
}
this.#toolbar?.selectTab(on)
}
#tab(label: string, onClick: () => void): HTMLButtonElement {
const button = document.createElement('button')
button.type = 'button'
button.className = 'blogeditor__tab'
button.setAttribute('role', 'tab')
button.textContent = label
button.addEventListener('click', onClick)
return button
/**
* Parses html into the editor without an update event (Pitfall 4) and
* outside the undo history, so undo never brings back a replaced value.
*/
#load(html: string): void {
this.#parsed = html
if (!this.#editor) return
this.#editor.chain().setMeta('addToHistory', false).setContent(html, { emitUpdate: false }).run()
this.#blocks = captureBlocks(html, this.#editor.schema)
}
#sourceInput(): void {
@@ -171,10 +259,15 @@ export class BlogEditorElement extends HTMLElement {
this.dispatchEvent(new CustomEvent(CHANGE_EVENT, { bubbles: true, composed: true, detail: { value: this.value } }))
}
#setStatus(text: string): void {
if (this.#status) this.#status.textContent = text
}
#syncAttributes(): void {
const disabled = this.hasAttribute('disabled')
const invalid = this.hasAttribute('invalid')
if (this.#editor && this.#editor.isEditable === disabled) this.#editor.setEditable(!disabled, false)
this.#toolbar?.setDisabled(disabled)
if (this.#textarea) {
this.#textarea.readOnly = disabled
if (invalid) this.#textarea.setAttribute('aria-invalid', 'true')
@@ -189,4 +282,264 @@ export class BlogEditorElement extends HTMLElement {
else view.removeAttribute('lang')
}
}
// Inline forms -----------------------------------------------------------
#form(kind: string, label: string, actions: [string, string, () => void][]): InlineForm {
const root = document.createElement('div')
root.className = 'blogeditor__form'
root.dataset.form = kind
root.hidden = true
const field = document.createElement('label')
const caption = document.createElement('span')
caption.textContent = label
const input = document.createElement('input')
input.type = 'text'
input.name = 'url'
input.autocomplete = 'off'
input.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault()
actions[0]![2]()
} else if (event.key === 'Escape') {
event.preventDefault()
this.#close(form)
}
})
field.append(caption, input)
root.append(field)
for (const [action, text, run] of actions) root.append(this.#action(action, text, run))
const error = document.createElement('p')
error.className = 'blogeditor__error'
error.dataset.error = ''
error.setAttribute('aria-live', 'polite')
root.append(error)
const form = { root, error }
return form
}
#action(action: string, text: string, run: () => void): HTMLButtonElement {
const button = document.createElement('button')
button.type = 'button'
button.dataset.action = action
button.textContent = text
button.addEventListener('click', run)
return button
}
#open(form: InlineForm | undefined, value = ''): void {
if (!form) return
for (const other of [this.#linkForm, this.#embedForm]) if (other && other !== form) this.#close(other)
form.error.textContent = ''
form.root.hidden = false
const input = form.root.querySelector('input')
if (input) {
input.value = value
input.focus()
}
}
#close(form: InlineForm | undefined): void {
if (!form) return
form.root.hidden = true
form.error.textContent = ''
}
#inputOf(form: InlineForm | undefined, name = 'url'): string {
return (form?.root.querySelector<HTMLInputElement>(`input[name="${name}"]`)?.value ?? '').trim()
}
// Links ----------------------------------------------------------------------
#openLink(): void {
if (!this.#editor?.isEditable) return
const href = this.#editor.getAttributes('link').href
this.#open(this.#linkForm, typeof href === 'string' ? href : '')
}
#applyLink(): void {
const editor = this.#editor
if (!editor || !this.#linkForm) return
const url = this.#inputOf(this.#linkForm)
if (url === '') {
this.#removeLink()
return
}
if (!isPolicyUrl(url)) {
this.#linkForm.error.textContent = this.#labels.linkInvalid
return
}
const { empty } = editor.state.selection
if (empty && !editor.isActive('link')) {
// Nothing selected: insert the URL as the link text.
editor
.chain()
.focus()
.insertContent({ type: 'text', text: url, marks: [{ type: 'link', attrs: { href: url } }] })
.unsetMark('link')
.run()
} else {
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
}
this.#close(this.#linkForm)
}
#removeLink(): void {
this.#editor?.chain().focus().extendMarkRange('link').unsetLink().run()
this.#close(this.#linkForm)
}
// Embeds ---------------------------------------------------------------------
#openEmbed(): void {
if (!this.#editor?.isEditable) return
this.#open(this.#embedForm)
}
#insertEmbed(): void {
if (!this.#editor || !this.#embedForm) return
const embed = parseEmbedUrl(this.#inputOf(this.#embedForm))
if (!embed) {
this.#embedForm.error.textContent = this.#labels.embedInvalid
return
}
this.#editor.chain().focus().setEmbed(embed).run()
this.#close(this.#embedForm)
}
// Raw HTML -------------------------------------------------------------------
#insertRawHtml(): void {
const editor = this.#editor
if (!editor?.isEditable) return
const before = this.value
const html = '<div></div>'
if (!editor.chain().focus().insertRawHtml(html).run()) return
const after = this.value
let at = 0
while (at < before.length && at < after.length && before[at] === after[at]) at++
this.showSource(true)
const caret = after.startsWith(html, at) ? at + '<div>'.length : at
this.#textarea?.focus()
this.#textarea?.setSelectionRange(caret, caret)
}
#onEditHtml = (event: Event): void => {
const html = (event as CustomEvent<{ html?: string }>).detail?.html ?? ''
this.showSource(true)
const textarea = this.#textarea
if (!textarea) return
textarea.focus()
const at = html ? textarea.value.indexOf(html) : -1
if (at >= 0) textarea.setSelectionRange(at, at + html.length)
}
// Uploads (D-18) -------------------------------------------------------------
#picked(): void {
const input = this.#fileInput
if (!input) return
const files = Array.from(input.files ?? [])
const images = files.filter(isImage)
if (images.length < files.length) this.#setStatus(this.#labels.notAnImage)
for (const file of images) this.#upload(file)
try {
input.value = ''
} catch {
// A test double may not allow resetting the value.
}
}
#upload(file: File): void {
const id = crypto.randomUUID()
const name = file.name
const timer = setTimeout(() => {
if (!this.#pending.delete(id)) return
this.#setStatus(format(this.#labels.uploadTimeout, { name }))
}, UPLOAD_TIMEOUT_MS)
this.#pending.set(id, { name, timer })
this.#setStatus(format(this.#labels.uploading, { name }))
this.dispatchEvent(new CustomEvent(UPLOAD_EVENT, { bubbles: true, composed: true, detail: { id, file } }))
}
#onUploadResult = (event: Event): void => {
const detail = (event as CustomEvent<{ id?: unknown; url?: unknown; error?: unknown }>).detail ?? {}
const id = typeof detail.id === 'string' ? detail.id : ''
const pending = this.#pending.get(id)
if (!pending) return
clearTimeout(pending.timer)
this.#pending.delete(id)
const url = typeof detail.url === 'string' ? detail.url : ''
if (typeof detail.error === 'string' || !url || !isPolicyUrl(url)) {
const error = typeof detail.error === 'string' && detail.error ? detail.error : pending.name
this.#setStatus(format(this.#labels.uploadFailed, { error }))
return
}
this.#setStatus('')
this.#ready.push({ url, name: pending.name })
this.#showImageForm()
}
#imageFormElement(): InlineForm {
const labels = this.#labels
const root = document.createElement('div')
root.className = 'blogeditor__form'
root.dataset.form = 'image'
root.hidden = true
const field = (name: string, text: string) => {
const label = document.createElement('label')
const caption = document.createElement('span')
caption.textContent = text
const input = document.createElement('input')
input.type = 'text'
input.name = name
input.autocomplete = 'off'
label.append(caption, input)
return label
}
root.append(field('alt', labels.altText), field('caption', labels.caption))
root.append(this.#action('insert', labels.insertImage, () => this.#insertImage()))
root.append(this.#action('cancel', labels.cancel, () => this.#nextImage()))
const error = document.createElement('p')
error.className = 'blogeditor__error'
error.dataset.error = ''
error.setAttribute('aria-live', 'polite')
root.append(error)
return { root, error }
}
#showImageForm(): void {
const form = this.#imageForm
if (!form || !form.root.hidden || this.#ready.length === 0) return
for (const input of Array.from(form.root.querySelectorAll('input'))) input.value = ''
form.error.textContent = ''
form.root.hidden = false
form.root.querySelector<HTMLInputElement>('input[name="alt"]')?.focus()
}
#insertImage(): void {
const editor = this.#editor
const form = this.#imageForm
const ready = this.#ready[0]
if (!editor || !form || !ready) return
const alt = this.#inputOf(form, 'alt')
const caption = this.#inputOf(form, 'caption')
if (!alt) {
form.error.textContent = this.#labels.altRequired
return
}
if (caption) editor.chain().focus().setFigure({ src: ready.url, alt, caption }).run()
else editor.chain().focus().setImage({ src: ready.url, alt }).run()
this.#nextImage()
}
#nextImage(): void {
this.#ready.shift()
if (this.#imageForm) this.#close(this.#imageForm)
this.#showImageForm()
}
}
function isImage(file: File): boolean {
return IMAGE_TYPES.includes(file.type)
}

View File

@@ -0,0 +1,59 @@
/**
* Embed providers (D-05). Stored posts hold only the placeholder
* <figure data-embed="PROVIDER" data-id="ID"></figure>; the layer turns it
* into the real iframe. Adding a provider is one entry here and one in the
* layer's table.
*/
export interface EmbedProvider {
/** The name shown on the editor card. */
name: string
/** The id the placeholder may hold. */
idPattern: RegExp
/** Returns the id from a provider URL, or null. */
idFromUrl: (url: URL) => string | null
}
const YOUTUBE_HOSTS = new Set(['youtube.com', 'www.youtube.com', 'm.youtube.com', 'music.youtube.com', 'youtube-nocookie.com', 'www.youtube-nocookie.com'])
export const EMBED_PROVIDERS = {
youtube: {
name: 'YouTube',
idPattern: /^[A-Za-z0-9_-]{11}$/,
idFromUrl(url: URL): string | null {
const host = url.hostname.toLowerCase()
if (host === 'youtu.be') {
return url.pathname.split('/')[1] ?? null
}
if (!YOUTUBE_HOSTS.has(host)) return null
if (url.pathname === '/watch') return url.searchParams.get('v')
const match = /^\/(?:shorts|embed)\/([^/]+)\/?$/.exec(url.pathname)
return match ? match[1]! : null
},
},
} as const satisfies Record<string, EmbedProvider>
export type EmbedProviderName = keyof typeof EMBED_PROVIDERS
export function isEmbedProvider(name: string): name is EmbedProviderName {
return Object.prototype.hasOwnProperty.call(EMBED_PROVIDERS, name)
}
/**
* parseEmbedUrl returns the provider and id for a supported video URL
* (youtube.com/watch?v=, youtu.be/, youtube.com/shorts/ and
* youtube.com/embed/ for YouTube), or null for anything else.
*/
export function parseEmbedUrl(input: string): { provider: EmbedProviderName; id: string } | null {
let url: URL
try {
url = new URL(input.trim())
} catch {
return null
}
if (url.protocol !== 'https:' && url.protocol !== 'http:') return null
for (const provider of Object.keys(EMBED_PROVIDERS) as EmbedProviderName[]) {
const id = EMBED_PROVIDERS[provider].idFromUrl(url)
if (id && EMBED_PROVIDERS[provider].idPattern.test(id)) return { provider, id }
}
return null
}

View File

@@ -0,0 +1,535 @@
import { Extension, Mark, Node, mergeAttributes, type AnyExtension } from '@tiptap/core'
import type { DOMOutputSpec } from '@tiptap/pm/model'
import Image from '@tiptap/extension-image'
import { Table, TableCell, TableHeader, TableRow } from '@tiptap/extension-table'
import StarterKit from '@tiptap/starter-kit'
import { EMBED_PROVIDERS, isEmbedProvider } from './embed'
import { labelsFor, type Labels } from './labels'
/** The event a raw HTML card dispatches to open its source in the Source tab. */
export const EDIT_HTML_EVENT = 'blogeditor-edit-html'
/**
* Block elements the schema does not model. Each becomes one atomic rawHtml
* node holding its outer HTML, shown in the editor as text (D-04).
*/
export const RAW_HTML_TAGS = [
'footer',
'section',
'aside',
'header',
'nav',
'details',
'style',
'div',
'article',
'main',
'address',
'dl',
'iframe',
'video',
'audio',
'picture',
'object',
'embed',
'form',
'svg',
'math',
'script',
'noscript',
] as const
const URL_IGNORED = /[\x00-\x20\x7f]+/g
const URL_SCHEME = /^([a-z][a-z0-9+.-]*):/i
const POLICY_SCHEMES = ['http', 'https', 'mailto']
/**
* isPolicyUrl reports whether url is one the server and layer sanitizer
* policies keep (D-05, D-10 amended): a relative URL (including # and ?) or
* an http, https or mailto URL. The scheme is read after removing ASCII
* control characters, spaces and DEL, as browsers do, so "java\tscript:" is
* refused. It mirrors allowedUrl in the layer's htmlGate.ts.
*/
export function isPolicyUrl(url: string | null | undefined): boolean {
if (url == null) return false
const scheme = URL_SCHEME.exec(url.replace(URL_IGNORED, ''))
if (scheme && !POLICY_SCHEMES.includes(scheme[1]!.toLowerCase())) return false
try {
new URL(url, 'https://journal.invalid/')
return true
} catch {
return false
}
}
/**
* The attributes each node or mark models itself; htmlAttrs captures every
* other attribute. Event handler attributes (on*) are never captured, so
* they never reach the live admin DOM.
*/
const MODELLED: Record<string, readonly string[]> = {
orderedList: ['start', 'type'],
link: ['href', 'target', 'rel', 'class', 'title'],
image: ['src', 'alt', 'title', 'width', 'height'],
figureImage: ['src', 'alt', 'title', 'width', 'height'],
tableCell: ['colspan', 'rowspan', 'colwidth'],
tableHeader: ['colspan', 'rowspan', 'colwidth'],
}
const HTML_ATTRIBUTE_TYPES = [
'paragraph',
'heading',
'blockquote',
'bulletList',
'orderedList',
'listItem',
'codeBlock',
'horizontalRule',
'hardBreak',
'table',
'tableRow',
'tableCell',
'tableHeader',
'image',
'figure',
'figureImage',
'figcaption',
'link',
]
function isHandlerName(name: string): boolean {
return /^on/i.test(name)
}
/** captureAttributes returns the element's attributes outside skip, in order. */
function captureAttributes(element: HTMLElement, skip: readonly string[]): Record<string, string> | null {
const out: Record<string, string> = {}
let any = false
for (const attr of Array.from(element.attributes)) {
const name = attr.name.toLowerCase()
if (skip.includes(name) || isHandlerName(name)) continue
out[attr.name] = attr.value
any = true
}
return any ? out : null
}
function renderCaptured(captured: unknown): Record<string, string> {
if (!captured || typeof captured !== 'object') return {}
const out: Record<string, string> = {}
for (const [name, value] of Object.entries(captured as Record<string, unknown>)) {
if (isHandlerName(name) || typeof value !== 'string') continue
out[name] = value
}
return out
}
/**
* HtmlAttributes keeps the attributes the schema does not model (class,
* data-*, itemprop, style and others) on paragraphs, headings, lists, list
* items, blockquotes, tables, cells, images and links, so they survive an
* edit elsewhere in the document (D-03).
*/
export const HtmlAttributes = Extension.create({
name: 'htmlAttributes',
addGlobalAttributes() {
return HTML_ATTRIBUTE_TYPES.map((type) => ({
types: [type],
attributes: {
htmlAttrs: {
default: null,
keepOnSplit: false,
parseHTML: (element: HTMLElement) => captureAttributes(element, MODELLED[type] ?? []),
renderHTML: (attributes: Record<string, unknown>) => renderCaptured(attributes.htmlAttrs),
},
},
}))
},
})
/** Span keeps inline spans with their attributes (style, class) as a mark. */
export const Span = Mark.create({
name: 'span',
priority: 40,
addAttributes() {
return {
htmlAttrs: {
default: null,
parseHTML: (element: HTMLElement) => captureAttributes(element, []),
renderHTML: (attributes: Record<string, unknown>) => renderCaptured(attributes.htmlAttrs),
},
}
},
parseHTML() {
return [{ tag: 'span', getAttrs: (element) => ((element as HTMLElement).attributes.length > 0 ? null : false) }]
},
renderHTML({ HTMLAttributes }) {
return ['span', HTMLAttributes, 0]
},
})
let inertDocument: Document | undefined
/**
* inert returns a document without a browsing context: markup parsed into
* it loads no image and runs no handler, so stored HTML never reaches the
* live admin DOM.
*/
export function inert(): Document {
inertDocument ??= document.implementation.createHTMLDocument('')
return inertDocument
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
rawHtml: {
/** Inserts an atomic raw HTML block. */
insertRawHtml: (html: string) => ReturnType
}
embed: {
/** Inserts an embed placeholder for an allowlisted provider. */
setEmbed: (attributes: { provider: string; id: string }) => ReturnType
}
figure: {
/** Inserts an image with a caption as figure, img and figcaption. */
setFigure: (attributes: { src: string; alt: string; caption: string }) => ReturnType
}
}
}
export interface RawHtmlOptions {
labels: Labels
}
/**
* RawHtml is an atomic block holding the outer HTML of markup the schema
* does not model (D-04). Its card renders the source with textContent,
* never as live DOM.
*/
export const RawHtml = Node.create<RawHtmlOptions>({
name: 'rawHtml',
group: 'block',
atom: true,
selectable: true,
draggable: true,
priority: 10,
addOptions() {
return { labels: labelsFor('en') }
},
addAttributes() {
return {
html: {
default: '',
parseHTML: (element: HTMLElement) => element.outerHTML,
renderHTML: () => ({}),
},
}
},
parseHTML() {
// The last rule takes a figure that is not exactly img plus an optional
// figcaption.
return [...RAW_HTML_TAGS, 'figure'].map((tag) => ({ tag, priority: 10 }))
},
renderHTML({ node }) {
// Serializers only (the editor shows the card): parsed in the inert
// document and returned whole.
const template = inert().createElement('template')
template.innerHTML = String(node.attrs.html ?? '')
// ProseMirror accepts any DOM node here; the typing names only elements.
return template.content as unknown as DOMOutputSpec
},
addCommands() {
return {
insertRawHtml:
(html) =>
({ commands }) =>
commands.insertContent({ type: this.name, attrs: { html } }),
}
},
addNodeView() {
const labels = this.options.labels
return ({ node }) => {
const dom = document.createElement('div')
dom.className = 'blogeditor__raw'
dom.contentEditable = 'false'
const head = document.createElement('div')
head.className = 'blogeditor__card-head'
const title = document.createElement('span')
title.textContent = labels.rawHtmlCard
const edit = document.createElement('button')
edit.type = 'button'
edit.textContent = labels.editAsHtml
head.append(title, edit)
const source = document.createElement('pre')
source.className = 'blogeditor__raw-source'
source.textContent = String(node.attrs.html ?? '')
dom.append(head, source)
let current = node
edit.addEventListener('click', (event) => {
event.preventDefault()
dom.dispatchEvent(
new CustomEvent(EDIT_HTML_EVENT, { bubbles: true, detail: { html: String(current.attrs.html ?? '') } }),
)
})
return {
dom,
update: (next) => {
if (next.type !== current.type) return false
current = next
source.textContent = String(next.attrs.html ?? '')
return true
},
stopEvent: (event) => event.target === edit,
ignoreMutation: () => true,
}
}
},
})
export interface EmbedOptions {
labels: Labels
}
/**
* Embed is the allowlisted video placeholder (D-05). It renders exactly
* <figure data-embed="PROVIDER" data-id="ID"></figure>; the editor shows a
* card and never an iframe. The layer expands the placeholder on the site.
*/
export const Embed = Node.create<EmbedOptions>({
name: 'embed',
group: 'block',
atom: true,
selectable: true,
draggable: true,
priority: 70,
addOptions() {
return { labels: labelsFor('en') }
},
addAttributes() {
return {
provider: { default: null, parseHTML: (element: HTMLElement) => element.getAttribute('data-embed'), renderHTML: () => ({}) },
id: { default: null, parseHTML: (element: HTMLElement) => element.getAttribute('data-id'), renderHTML: () => ({}) },
}
},
parseHTML() {
return [
{
tag: 'figure[data-embed]',
priority: 70,
getAttrs: (element) => {
const el = element as HTMLElement
const provider = el.getAttribute('data-embed') ?? ''
const id = el.getAttribute('data-id') ?? ''
// Exactly the placeholder: two attributes and no content.
if (el.attributes.length !== 2 || el.childNodes.length !== 0) return false
if (!isEmbedProvider(provider) || !EMBED_PROVIDERS[provider].idPattern.test(id)) return false
return null
},
},
]
},
renderHTML({ node }) {
return ['figure', { 'data-embed': node.attrs.provider, 'data-id': node.attrs.id }]
},
addCommands() {
return {
setEmbed:
({ provider, id }) =>
({ commands }) => {
if (!isEmbedProvider(provider) || !EMBED_PROVIDERS[provider].idPattern.test(id)) return false
return commands.insertContent({ type: this.name, attrs: { provider, id } })
},
}
},
addNodeView() {
const labels = this.options.labels
return ({ node }) => {
const dom = document.createElement('div')
dom.className = 'blogeditor__embed'
dom.contentEditable = 'false'
const render = (n: typeof node) => {
const provider = String(n.attrs.provider ?? '')
const name = isEmbedProvider(provider) ? EMBED_PROVIDERS[provider].name : provider
dom.textContent = `${labels.embedCard}: ${name} ${String(n.attrs.id ?? '')}`
}
render(node)
let current = node
return {
dom,
update: (next) => {
if (next.type !== current.type) return false
current = next
render(next)
return true
},
ignoreMutation: () => true,
}
}
},
})
function isFigureShape(element: HTMLElement): boolean {
if (element.hasAttribute('data-embed')) return false
const children: Element[] = []
for (const child of Array.from(element.childNodes)) {
if (child.nodeType === 1) children.push(child as Element)
else if (child.nodeType === 3 && /[^ \t\r\n\f]/.test(child.nodeValue ?? '')) return false
else if (child.nodeType !== 3) return false
}
if (children.length < 1 || children.length > 2) return false
const img = children[0]!
if (img.nodeName !== 'IMG') return false
const src = img.getAttribute('src')
if (!src || /^\s*data:/i.test(src)) return false
return children.length === 1 || children[1]!.nodeName === 'FIGCAPTION'
}
/** Figure holds an image and an optional caption (D-18). */
export const Figure = Node.create({
name: 'figure',
group: 'block',
content: 'figureImage figcaption?',
draggable: true,
isolating: true,
priority: 60,
parseHTML() {
return [
{
tag: 'figure',
priority: 60,
getAttrs: (element) => (isFigureShape(element as HTMLElement) ? null : false),
},
]
},
renderHTML({ HTMLAttributes }) {
return ['figure', HTMLAttributes, 0]
},
addCommands() {
return {
setFigure:
({ src, alt, caption }) =>
({ commands }) => {
const content: Record<string, unknown>[] = [{ type: 'figureImage', attrs: { src, alt } }]
if (caption) content.push({ type: 'figcaption', content: [{ type: 'text', text: caption }] })
return commands.insertContent({ type: this.name, content })
},
}
},
})
/** FigureImage is the img inside a figure. */
export const FigureImage = Node.create({
name: 'figureImage',
atom: true,
selectable: false,
priority: 60,
addAttributes() {
return {
src: { default: null },
alt: { default: null },
title: { default: null },
width: { default: null },
height: { default: null },
}
},
parseHTML() {
return [{ tag: 'img[src]:not([src^="data:"])', context: 'figure/', priority: 60 }]
},
renderHTML({ HTMLAttributes }) {
return ['img', HTMLAttributes]
},
})
/** Figcaption is the editable caption of a figure. */
export const Figcaption = Node.create({
name: 'figcaption',
content: 'inline*',
priority: 60,
parseHTML() {
return [{ tag: 'figcaption', context: 'figure/' }]
},
renderHTML({ HTMLAttributes }) {
return ['figcaption', HTMLAttributes, 0]
},
})
/** Tables render without TipTap's colgroup and min-width style. */
export const PlainTable = Table.extend({
renderHTML({ HTMLAttributes }) {
return ['table', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), ['tbody', 0]]
},
})
// Cell alignment is kept through htmlAttrs (style or align, as stored)
// instead of TipTap's align attribute, which rewrites it as a style.
function withoutAlign<T extends Record<string, unknown>>(attributes: T | undefined): Omit<T, 'align'> {
const { align: _align, ...rest } = attributes ?? ({} as T)
return rest
}
export const PlainTableCell = TableCell.extend({
addAttributes() {
return withoutAlign(this.parent?.())
},
})
export const PlainTableHeader = TableHeader.extend({
addAttributes() {
return withoutAlign(this.parent?.())
},
})
/**
* preservationExtensions is the editor schema (D-03, D-04): StarterKit
* without the trailing node and with links that gain no target, rel or class
* and accept only policy URLs; inline images without base64; tables; and
* the preservation nodes and marks.
*/
export function preservationExtensions(labels: Labels = labelsFor('en')): AnyExtension[] {
return [
StarterKit.configure({
trailingNode: false,
link: {
openOnClick: false,
HTMLAttributes: { target: null, rel: null, class: null },
isAllowedUri: (url) => isPolicyUrl(url),
},
}),
Image.configure({ inline: true, allowBase64: false }),
PlainTable,
TableRow,
PlainTableHeader,
PlainTableCell,
Figure,
FigureImage,
Figcaption,
Embed.configure({ labels }),
RawHtml.configure({ labels }),
Span,
HtmlAttributes,
]
}

View File

@@ -0,0 +1,154 @@
/** Every label, prompt and error the Blog Editor shows. */
export interface Labels {
tabWysiwyg: string
tabSource: string
toolbar: string
paragraph: string
heading2: string
heading3: string
heading4: string
bold: string
italic: string
bulletList: string
orderedList: string
link: string
blockquote: string
insertTable: string
addRow: string
deleteRow: string
addColumn: string
deleteColumn: string
deleteTable: string
image: string
embed: string
rawHtml: string
undo: string
redo: string
linkUrl: string
linkApply: string
linkRemove: string
linkInvalid: string
embedUrl: string
embedInsert: string
embedInvalid: string
cancel: string
uploading: string
uploadFailed: string
uploadTimeout: string
notAnImage: string
altText: string
caption: string
altRequired: string
insertImage: string
rawHtmlCard: string
editAsHtml: string
embedCard: string
}
const en: Labels = {
tabWysiwyg: 'WYSIWYG',
tabSource: 'Source',
toolbar: 'Formatting',
paragraph: 'Paragraph',
heading2: 'Heading 2',
heading3: 'Heading 3',
heading4: 'Heading 4',
bold: 'Bold',
italic: 'Italic',
bulletList: 'Bullet list',
orderedList: 'Numbered list',
link: 'Link',
blockquote: 'Quote',
insertTable: 'Insert table',
addRow: 'Add row',
deleteRow: 'Delete row',
addColumn: 'Add column',
deleteColumn: 'Delete column',
deleteTable: 'Delete table',
image: 'Image',
embed: 'Embed video',
rawHtml: 'HTML block',
undo: 'Undo',
redo: 'Redo',
linkUrl: 'Link URL',
linkApply: 'Apply',
linkRemove: 'Remove link',
linkInvalid: 'Links must use http, https, mailto or a relative URL.',
embedUrl: 'YouTube URL',
embedInsert: 'Insert',
embedInvalid: 'This video URL is not supported. Paste a YouTube link.',
cancel: 'Cancel',
uploading: 'Uploading {name}…',
uploadFailed: 'Upload failed: {error}',
uploadTimeout: 'The upload of {name} took too long.',
notAnImage: 'Only JPEG, PNG, GIF and WebP images can be uploaded.',
altText: 'Alt text (required)',
caption: 'Caption (optional)',
altRequired: 'Enter the alt text.',
insertImage: 'Insert image',
rawHtmlCard: 'HTML block',
editAsHtml: 'Edit as HTML',
embedCard: 'Embedded video',
}
const pl: Labels = {
tabWysiwyg: 'WYSIWYG',
tabSource: 'Źródło',
toolbar: 'Formatowanie',
paragraph: 'Akapit',
heading2: 'Nagłówek 2',
heading3: 'Nagłówek 3',
heading4: 'Nagłówek 4',
bold: 'Pogrubienie',
italic: 'Kursywa',
bulletList: 'Lista punktowana',
orderedList: 'Lista numerowana',
link: 'Link',
blockquote: 'Cytat',
insertTable: 'Wstaw tabelę',
addRow: 'Dodaj wiersz',
deleteRow: 'Usuń wiersz',
addColumn: 'Dodaj kolumnę',
deleteColumn: 'Usuń kolumnę',
deleteTable: 'Usuń tabelę',
image: 'Obraz',
embed: 'Osadź wideo',
rawHtml: 'Blok HTML',
undo: 'Cofnij',
redo: 'Ponów',
linkUrl: 'Adres linku',
linkApply: 'Zastosuj',
linkRemove: 'Usuń link',
linkInvalid: 'Link musi używać http, https, mailto albo adresu względnego.',
embedUrl: 'Adres filmu YouTube',
embedInsert: 'Wstaw',
embedInvalid: 'Ten adres filmu nie jest obsługiwany. Wklej link do YouTube.',
cancel: 'Anuluj',
uploading: 'Wysyłanie {name}…',
uploadFailed: 'Wysyłanie nie powiodło się: {error}',
uploadTimeout: 'Wysyłanie {name} trwało zbyt długo.',
notAnImage: 'Można wysłać tylko obrazy JPEG, PNG, GIF i WebP.',
altText: 'Tekst alternatywny (wymagany)',
caption: 'Podpis (opcjonalny)',
altRequired: 'Wpisz tekst alternatywny.',
insertImage: 'Wstaw obraz',
rawHtmlCard: 'Blok HTML',
editAsHtml: 'Edytuj jako HTML',
embedCard: 'Osadzone wideo',
}
const TABLES: Record<string, Labels> = { en, pl }
/**
* labelsFor returns the labels for the admin UI locale (the element's
* ui-locale attribute): the table for its language subtag, else English.
*/
export function labelsFor(uiLocale: string | null | undefined): Labels {
const language = (uiLocale ?? '').toLowerCase().split(/[-_]/)[0] ?? ''
return TABLES[language] ?? en
}
/** format replaces {name} placeholders in a label. */
export function format(label: string, values: Record<string, string>): string {
return label.replace(/\{(\w+)\}/g, (match, key: string) => values[key] ?? match)
}

View File

@@ -1,14 +1,305 @@
import type { Editor } from '@tiptap/core'
import { DOMSerializer } from '@tiptap/pm/model'
import { elementFromString, type Editor } from '@tiptap/core'
import { DOMParser as PMDOMParser, DOMSerializer, type Node as PMNode, type Schema } from '@tiptap/pm/model'
import { inert } from './extensions'
/**
* serializeDocument returns the HTML of the editor document. It is the only
* source of the element's value after a user edit; editor.getHTML() is never
* the stored value.
* One top-level piece of the original HTML: its exact source text and the
* nodes it parses to on its own. Whitespace and comments parse to no node.
*/
export function serializeDocument(editor: Editor): string {
const serializer = DOMSerializer.fromSchema(editor.schema)
const host = document.createElement('div')
host.appendChild(serializer.serializeFragment(editor.state.doc.content, { document }))
interface Segment {
html: string
nodes: PMNode[]
}
/**
* Blocks is the original HTML split into top-level blocks (RESEARCH A3).
* Each block keeps its exact source and the text between it and the
* previous block, so unedited blocks keep their bytes after an edit.
*/
export interface Blocks {
blocks: { before: string; html: string; nodes: PMNode[] }[]
trailing: string
}
const VOID_TAGS = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr'])
const RAW_TEXT_TAGS = new Set(['script', 'style', 'textarea', 'title', 'xmp', 'iframe', 'noembed', 'noframes'])
const TAG_NAME = /^<\/?([a-zA-Z][^\s/>]*)/
/** tagEnd returns the index after the '>' closing the tag at start, or -1. */
function tagEnd(html: string, start: number): number {
let quote = ''
let prev = ''
for (let i = start + 1; i < html.length; i++) {
const c = html[i]!
if (quote) {
if (c === quote) quote = ''
continue
}
if ((c === '"' || c === "'") && prev === '=') {
quote = c
continue
}
if (c === '>') return i + 1
if (!/\s/.test(c)) prev = c
}
return -1
}
/** rawTextEnd returns the index after the end tag of a raw text element, or -1. */
function rawTextEnd(html: string, from: number, name: string): number {
const close = new RegExp(`</${name}(?=[\\s/>])`, 'ig')
close.lastIndex = from
const match = close.exec(html)
return match ? tagEnd(html, match.index) : -1
}
/** elementEnd returns the index after the element whose start tag ends at from. */
function elementEnd(html: string, from: number, name: string): number {
const stack = [name]
let i = from
while (stack.length > 0) {
const lt = html.indexOf('<', i)
if (lt < 0) return -1
if (html.startsWith('<!--', lt)) {
const end = html.indexOf('-->', lt + 4)
if (end < 0) return -1
i = end + 3
continue
}
const match = TAG_NAME.exec(html.slice(lt, lt + 64))
if (!match) {
i = lt + 1
continue
}
const tag = match[1]!.toLowerCase()
const end = tagEnd(html, lt)
if (end < 0) return -1
i = end
if (html[lt + 1] === '/') {
const at = stack.lastIndexOf(tag)
if (at >= 0) stack.length = at
continue
}
if (VOID_TAGS.has(tag) || html[end - 2] === '/') continue
if (RAW_TEXT_TAGS.has(tag)) {
i = rawTextEnd(html, end, tag)
if (i < 0) return -1
continue
}
stack.push(tag)
}
return i
}
/**
* splitTopLevel splits html into its top-level pieces (elements, text runs,
* comments) by source position, or returns null when it cannot.
*/
function splitTopLevel(html: string): string[] | null {
const pieces: string[] = []
let i = 0
while (i < html.length) {
const start = i
if (html[i] !== '<') {
const lt = html.indexOf('<', i)
i = lt < 0 ? html.length : lt
} else if (html.startsWith('<!--', i)) {
const end = html.indexOf('-->', i + 4)
if (end < 0) return null
i = end + 3
} else if (html.startsWith('<!', i) || html.startsWith('<?', i) || html.startsWith('</', i)) {
const end = tagEnd(html, i)
if (end < 0) return null
i = end
} else {
const match = TAG_NAME.exec(html.slice(i, i + 64))
if (!match) {
const lt = html.indexOf('<', i + 1)
i = lt < 0 ? html.length : lt
} else {
const tag = match[1]!.toLowerCase()
const end = tagEnd(html, i)
if (end < 0) return null
if (VOID_TAGS.has(tag) || html[end - 2] === '/') i = end
else if (RAW_TEXT_TAGS.has(tag)) i = rawTextEnd(html, end, tag)
else i = elementEnd(html, end, tag)
if (i < 0) return null
}
}
pieces.push(html.slice(start, i))
}
return pieces
}
/** isGap reports whether a piece parses to no block: whitespace or a comment. */
function isGap(piece: string): boolean {
return /^\s*$/.test(piece) || (piece.startsWith('<!--') && piece.endsWith('-->'))
}
/**
* captureBlocks splits the original HTML into top-level blocks and parses
* each alone, recording its nodes, its exact outer HTML and the text
* between blocks. It returns null when the pieces do not parse to the same
* DOM as the whole (then every edited document is serialized whole).
*/
export function captureBlocks(html: string, schema: Schema): Blocks | null {
const pieces = splitTopLevel(html)
if (!pieces) return null
const whole = elementFromString(html).innerHTML
const joined = pieces.map((piece) => elementFromString(piece).innerHTML).join('')
if (whole !== joined) return null
const parser = PMDOMParser.fromSchema(schema)
const segments: Segment[] = pieces.map((piece) => {
if (isGap(piece)) return { html: piece, nodes: [] }
const nodes: PMNode[] = []
parser.parseSlice(elementFromString(piece)).content.forEach((node) => nodes.push(node))
return { html: piece, nodes }
})
const blocks: Blocks['blocks'] = []
let pending = ''
for (const segment of segments) {
if (segment.nodes.length === 0) {
pending += segment.html
continue
}
blocks.push({ before: pending, html: segment.html, nodes: segment.nodes })
pending = ''
}
return { blocks, trailing: pending }
}
/** tidyTables applies the stored table form to serialized tables. */
function tidyTables(root: Element): void {
for (const table of Array.from(root.querySelectorAll('table'))) {
const tbody = Array.from(table.children).find((child) => child.nodeName === 'TBODY')
const first = tbody?.firstElementChild
// A leading row of header cells goes into thead.
if (tbody && first && first.children.length > 0 && Array.from(first.children).every((cell) => cell.nodeName === 'TH')) {
const thead = table.ownerDocument.createElement('thead')
thead.append(first)
table.insertBefore(thead, tbody)
if (tbody.children.length === 0) tbody.remove()
}
}
// A cell holding one plain paragraph holds its text directly.
for (const cell of Array.from(root.querySelectorAll('td, th'))) {
const only = cell.childNodes.length === 1 ? cell.firstChild : null
if (only && only.nodeName === 'P' && (only as Element).attributes.length === 0) {
cell.replaceChildren(...Array.from(only.childNodes))
}
}
}
/** serializeNode returns the HTML of one top-level node. */
function serializeNode(node: PMNode, serializer: DOMSerializer): string {
if (node.type.name === 'rawHtml') return String(node.attrs.html ?? '')
const doc = inert()
const host = doc.createElement('div')
host.appendChild(serializer.serializeNode(node, { document: doc }))
tidyTables(host)
return host.innerHTML
}
function sameNodes(a: readonly PMNode[], b: readonly PMNode[], from: number): boolean {
if (from + a.length > b.length) return false
for (let i = 0; i < a.length; i++) {
if (!a[i]!.eq(b[from + i]!)) return false
}
return true
}
/**
* align matches captured blocks to the document's top-level nodes in order
* (longest common subsequence by node count) and returns the pairs.
*/
function align(blocks: Blocks['blocks'], nodes: readonly PMNode[]): { block: number; node: number }[] {
const pairs: { block: number; node: number }[] = []
// Unchanged prefix and suffix first, so a single edit costs no table.
let lo = 0
let at = 0
while (lo < blocks.length && sameNodes(blocks[lo]!.nodes, nodes, at)) {
pairs.push({ block: lo, node: at })
at += blocks[lo]!.nodes.length
lo++
}
const tail: { block: number; node: number }[] = []
let hi = blocks.length
let end = nodes.length
while (hi > lo && end - blocks[hi - 1]!.nodes.length >= at && sameNodes(blocks[hi - 1]!.nodes, nodes, end - blocks[hi - 1]!.nodes.length)) {
hi--
end -= blocks[hi]!.nodes.length
tail.unshift({ block: hi, node: end })
}
const rows = hi - lo
const cols = end - at
if (rows > 0 && cols > 0) {
const width = cols + 1
const score = new Int32Array((rows + 1) * width)
const matches = (b: number, n: number) => sameNodes(blocks[lo + b]!.nodes, nodes, at + n) && at + n + blocks[lo + b]!.nodes.length <= end
for (let b = rows - 1; b >= 0; b--) {
for (let n = cols - 1; n >= 0; n--) {
let best = Math.max(score[(b + 1) * width + n]!, score[b * width + n + 1]!)
if (matches(b, n)) {
const size = blocks[lo + b]!.nodes.length
best = Math.max(best, size + score[(b + 1) * width + n + size]!)
}
score[b * width + n] = best
}
}
let b = 0
let n = 0
while (b < rows && n < cols) {
const size = blocks[lo + b]!.nodes.length
if (matches(b, n) && score[b * width + n] === size + score[(b + 1) * width + n + size]) {
pairs.push({ block: lo + b, node: at + n })
b++
n += size
} else if (score[(b + 1) * width + n]! >= score[b * width + n + 1]!) {
b++
} else {
n++
}
}
}
return pairs.concat(tail)
}
/**
* serializeDocument returns the HTML of the edited document (D-03). Runs of
* top-level nodes still equal (Node.eq) to the captured blocks are emitted
* as their original source; raw HTML blocks are emitted verbatim; the rest
* is serialized with the schema, tables in their stored form. It is the
* element's value after a user edit; editor.getHTML() never is.
*/
export function serializeDocument(editor: Editor, blocks: Blocks | null): string {
const serializer = DOMSerializer.fromSchema(editor.schema)
const nodes: PMNode[] = []
editor.state.doc.forEach((node) => nodes.push(node))
const captured = blocks?.blocks ?? []
const pairs = align(captured, nodes)
let out = ''
let nextBlock = 0
let nextNode = 0
const emitGap = (block: number, node: number) => {
if (node > nextNode) {
// Replaced or inserted nodes keep the separator of the first block
// they replace.
if (block > nextBlock) out += captured[nextBlock]!.before
for (let i = nextNode; i < node; i++) out += serializeNode(nodes[i]!, serializer)
}
}
for (const pair of pairs) {
emitGap(pair.block, pair.node)
const block = captured[pair.block]!
out += block.before + block.html
nextBlock = pair.block + 1
nextNode = pair.node + block.nodes.length
}
emitGap(captured.length, nodes.length)
if (blocks && (pairs.length > 0 || nodes.length === 0)) out += blocks.trailing
return out
}

View File

@@ -0,0 +1,157 @@
import type { Editor } from '@tiptap/core'
import type { Labels } from './labels'
/** What the toolbar needs from the element. */
export interface ToolbarHost {
editor: () => Editor | undefined
labels: Labels
openLink: () => void
openEmbed: () => void
pickImage: () => void
insertRawHtml: () => void
showSource: (on: boolean) => void
}
export interface Toolbar {
element: HTMLDivElement
/** Updates pressed and enabled states from the editor. */
refresh: () => void
/** Marks the selected tab. */
selectTab: (source: boolean) => void
/** Disables every control (the element's disabled attribute). */
setDisabled: (disabled: boolean) => void
}
interface ButtonSpec {
command: string
glyph: string
label: keyof Labels
run: (editor: Editor, host: ToolbarHost) => void
active?: (editor: Editor) => boolean
enabled?: (editor: Editor) => boolean
/** Runs without the editor (opens a form or the file picker). */
host?: boolean
}
const inTable = (editor: Editor) => editor.isActive('table')
const GROUPS: ButtonSpec[][] = [
[
{ command: 'paragraph', glyph: 'P', label: 'paragraph', run: (e) => e.chain().focus().setParagraph().run(), active: (e) => e.isActive('paragraph') },
{ command: 'heading2', glyph: 'H2', label: 'heading2', run: (e) => e.chain().focus().toggleHeading({ level: 2 }).run(), active: (e) => e.isActive('heading', { level: 2 }) },
{ command: 'heading3', glyph: 'H3', label: 'heading3', run: (e) => e.chain().focus().toggleHeading({ level: 3 }).run(), active: (e) => e.isActive('heading', { level: 3 }) },
{ command: 'heading4', glyph: 'H4', label: 'heading4', run: (e) => e.chain().focus().toggleHeading({ level: 4 }).run(), active: (e) => e.isActive('heading', { level: 4 }) },
],
[
{ command: 'bold', glyph: 'B', label: 'bold', run: (e) => e.chain().focus().toggleBold().run(), active: (e) => e.isActive('bold') },
{ command: 'italic', glyph: 'I', label: 'italic', run: (e) => e.chain().focus().toggleItalic().run(), active: (e) => e.isActive('italic') },
{ command: 'link', glyph: 'Link', label: 'link', run: (_e, h) => h.openLink(), active: (e) => e.isActive('link'), host: true },
],
[
{ command: 'bulletList', glyph: '•', label: 'bulletList', run: (e) => e.chain().focus().toggleBulletList().run(), active: (e) => e.isActive('bulletList') },
{ command: 'orderedList', glyph: '1.', label: 'orderedList', run: (e) => e.chain().focus().toggleOrderedList().run(), active: (e) => e.isActive('orderedList') },
{ command: 'blockquote', glyph: '“”', label: 'blockquote', run: (e) => e.chain().focus().toggleBlockquote().run(), active: (e) => e.isActive('blockquote') },
],
[
{ command: 'insertTable', glyph: 'Table', label: 'insertTable', run: (e) => e.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() },
{ command: 'addRow', glyph: '+Row', label: 'addRow', run: (e) => e.chain().focus().addRowAfter().run(), enabled: inTable },
{ command: 'deleteRow', glyph: '−Row', label: 'deleteRow', run: (e) => e.chain().focus().deleteRow().run(), enabled: inTable },
{ command: 'addColumn', glyph: '+Col', label: 'addColumn', run: (e) => e.chain().focus().addColumnAfter().run(), enabled: inTable },
{ command: 'deleteColumn', glyph: '−Col', label: 'deleteColumn', run: (e) => e.chain().focus().deleteColumn().run(), enabled: inTable },
{ command: 'deleteTable', glyph: '×Table', label: 'deleteTable', run: (e) => e.chain().focus().deleteTable().run(), enabled: inTable },
],
[
{ command: 'image', glyph: 'Image', label: 'image', run: (_e, h) => h.pickImage(), host: true },
{ command: 'embed', glyph: 'Video', label: 'embed', run: (_e, h) => h.openEmbed(), host: true },
{ command: 'rawHtml', glyph: 'HTML', label: 'rawHtml', run: (_e, h) => h.insertRawHtml(), host: true },
],
[
{ command: 'undo', glyph: 'Undo', label: 'undo', run: (e) => e.chain().focus().undo().run(), enabled: (e) => e.can().undo() },
{ command: 'redo', glyph: 'Redo', label: 'redo', run: (e) => e.chain().focus().redo().run(), enabled: (e) => e.can().redo() },
],
]
/**
* 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.
*/
export function buildToolbar(host: ToolbarHost): Toolbar {
const { labels } = host
const element = document.createElement('div')
element.className = 'blogeditor__bar'
const tools = document.createElement('div')
tools.className = 'blogeditor__tools'
tools.setAttribute('role', 'toolbar')
tools.setAttribute('aria-label', labels.toolbar)
const buttons: { spec: ButtonSpec; button: HTMLButtonElement }[] = []
for (const group of GROUPS) {
const wrap = document.createElement('div')
wrap.className = 'blogeditor__group'
for (const spec of group) {
const button = document.createElement('button')
button.type = 'button'
button.dataset.command = spec.command
button.textContent = spec.glyph
button.title = labels[spec.label]
button.setAttribute('aria-label', labels[spec.label])
// Keep the editor selection when a button is pressed.
button.addEventListener('mousedown', (event) => event.preventDefault())
button.addEventListener('click', () => {
const editor = host.editor()
if (!editor) return
if (!spec.host && !editor.isEditable) return
spec.run(editor, host)
})
wrap.append(button)
buttons.push({ spec, button })
}
tools.append(wrap)
}
const tabs = document.createElement('div')
tabs.className = 'blogeditor__tabs'
tabs.setAttribute('role', 'tablist')
const tab = (name: 'wysiwyg' | 'source', label: string) => {
const button = document.createElement('button')
button.type = 'button'
button.dataset.tab = name
button.setAttribute('role', 'tab')
button.textContent = label
button.addEventListener('click', () => host.showSource(name === 'source'))
tabs.append(button)
return button
}
const wysiwygTab = tab('wysiwyg', labels.tabWysiwyg)
const sourceTab = tab('source', labels.tabSource)
element.append(tools, tabs)
let disabled = false
let source = false
const refresh = () => {
const editor = host.editor()
for (const { spec, button } of buttons) {
if (spec.active) button.setAttribute('aria-pressed', String(!!editor && spec.active(editor)))
const enabled = !!editor && !disabled && !source && (spec.enabled ? spec.enabled(editor) : true)
button.disabled = !enabled
}
}
return {
element,
refresh,
selectTab: (on) => {
source = on
wysiwygTab.setAttribute('aria-selected', String(!on))
sourceTab.setAttribute('aria-selected', String(on))
refresh()
},
setDisabled: (on) => {
disabled = on
refresh()
},
}
}