Files
Jakub Zych 086e485215 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
2026-10-10 11:53:07 +02:00

79 lines
3.1 KiB
TypeScript

import type { Editor } from '@tiptap/core'
import '../../assets/src/blogeditor/index'
import { BlogEditorElement, TAG } from '../../assets/src/blogeditor/index'
export interface Mounted {
el: BlogEditorElement
editor: Editor
changes: string[]
uploads: { id: string; file: File }[]
}
/** mount creates a connected editor element, optionally with a value. */
export function mount(value?: string, attributes: Record<string, string> = {}): Mounted {
const el = document.createElement(TAG) as BlogEditorElement
for (const [name, val] of Object.entries(attributes)) el.setAttribute(name, val)
const changes: string[] = []
const uploads: { id: string; file: File }[] = []
el.addEventListener('summer-change', (e) => changes.push((e as CustomEvent<{ value: string }>).detail.value))
el.addEventListener('summer-upload', (e) => uploads.push((e as CustomEvent<{ id: string; file: File }>).detail))
if (value !== undefined) el.value = value
document.body.append(el)
return { el, editor: el.editor!, changes, uploads }
}
/** type inserts text at the selection, as typing does. */
export function type(editor: Editor, text: string): void {
editor.view.dispatch(editor.state.tr.insertText(text))
}
/** insertAtBlock types text at the start of the top-level textblock index. */
export function insertAtBlock(editor: Editor, index: number, text: string): void {
let pos = -1
editor.state.doc.forEach((node, offset, i) => {
if (i === index) pos = node.isTextblock ? offset + 1 : -1
})
if (pos < 0) throw new Error(`block ${index} is not a textblock`)
editor.view.dispatch(editor.state.tr.insertText(text, pos))
}
/** insertAtEveryTextblock types text at the start of every textblock. */
export function insertAtEveryTextblock(editor: Editor, text: string): void {
const positions: number[] = []
editor.state.doc.descendants((node, pos) => {
if (node.isTextblock) positions.push(pos + 1)
return true
})
let tr = editor.state.tr
for (const pos of positions.reverse()) tr = tr.insertText(text, pos)
editor.view.dispatch(tr)
}
/** button returns the toolbar button for a command. */
export function button(el: HTMLElement, command: string): HTMLButtonElement {
const found = el.querySelector<HTMLButtonElement>(`button[data-command="${command}"]`)
if (!found) throw new Error(`no toolbar button ${command}`)
return found
}
/** form returns the inline form of a kind, or null when it is closed. */
export function form(el: HTMLElement, kind: string): HTMLElement | null {
const found = el.querySelector<HTMLElement>(`[data-form="${kind}"]`)
return found && !found.hidden ? found : null
}
export function click(el: Element | null | undefined): void {
if (!el) throw new Error('nothing to click')
;(el as HTMLElement).click()
}
export function fill(input: Element | null | undefined, value: string): void {
if (!input) throw new Error('no input')
;(input as HTMLInputElement).value = value
input.dispatchEvent(new Event('input', { bubbles: true }))
}
export function status(el: HTMLElement): string {
return el.querySelector('[role="status"]')?.textContent ?? ''
}