- 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
79 lines
3.1 KiB
TypeScript
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 ?? ''
|
|
}
|