- the blogeditor and mlblogeditor types serve headingLevels and genericAnchors through RequestOptions, per schema request - J1: H2 to H4 with a pasted or loaded H1 parsed as H2 while the H1 setting is on, H1 to H4 while it is off; H5 and H6 still parse - J2: the outline bar and panel (skipped, empty, link-only, H1 in Source, generic link texts), 300 ms after a change, collapsed, read-only - J3: nofollow/sponsored/ugc checkboxes for absolute http(s) links only, and a non-blocking warning for a generic link text - J4: one form per picked file with the slugged file name, alt required unless decorative (alt=""); Insert uploads the renamed file and the image is inserted on the upload result - assets/dist rebuilt
97 lines
4.0 KiB
TypeScript
97 lines
4.0 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 ?? ''
|
|
}
|
|
|
|
/**
|
|
* chooseImage fills the open J4 image form (alt, caption, decorative, file
|
|
* name) and presses Insert, which uploads the file.
|
|
*/
|
|
export function chooseImage(el: HTMLElement, choice: { alt?: string; caption?: string; decorative?: boolean; fileName?: string } = {}): void {
|
|
const imageForm = form(el, 'image')
|
|
if (!imageForm) throw new Error('the image form is not open')
|
|
if (choice.fileName !== undefined) fill(imageForm.querySelector('input[name="fileName"]'), choice.fileName)
|
|
if (choice.decorative) {
|
|
const box = imageForm.querySelector<HTMLInputElement>('input[name="decorative"]')!
|
|
box.checked = true
|
|
box.dispatchEvent(new Event('change', { bubbles: true }))
|
|
}
|
|
if (choice.alt !== undefined) fill(imageForm.querySelector('input[name="alt"]'), choice.alt)
|
|
if (choice.caption !== undefined) fill(imageForm.querySelector('input[name="caption"]'), choice.caption)
|
|
click(imageForm.querySelector('[data-action="insert"]'))
|
|
}
|