- 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
210 lines
8.3 KiB
TypeScript
210 lines
8.3 KiB
TypeScript
import type { Editor } from '@tiptap/core'
|
||
import type { Labels, TextLabel } 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
|
||
/** Switches the preview pane on or off. */
|
||
togglePreview: () => void
|
||
/**
|
||
* The heading levels the toolbar offers (the element's headingLevels
|
||
* option): 2 to 4, with 1 first when the H1 setting is off (J1).
|
||
*/
|
||
headingLevels: readonly number[]
|
||
}
|
||
|
||
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
|
||
/** Marks the preview toggle as pressed or not. */
|
||
setPreview: (on: boolean) => void
|
||
}
|
||
|
||
interface ButtonSpec {
|
||
command: string
|
||
glyph: string
|
||
label: TextLabel
|
||
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')
|
||
|
||
type Level = 1 | 2 | 3 | 4
|
||
|
||
/** headingButton is the toolbar button of one heading level. */
|
||
function headingButton(level: Level): ButtonSpec {
|
||
return {
|
||
command: `heading${level}`,
|
||
glyph: `H${level}`,
|
||
label: `heading${level}` as TextLabel,
|
||
run: (e) => e.chain().focus().toggleHeading({ level }).run(),
|
||
active: (e) => e.isActive('heading', { level }),
|
||
}
|
||
}
|
||
|
||
/** headingLevelOf is the level of a heading button's command, 0 otherwise. */
|
||
function headingLevelOf(command: string): number {
|
||
const m = /^heading([1-6])$/.exec(command)
|
||
return m ? Number(m[1]) : 0
|
||
}
|
||
|
||
const GROUPS: ButtonSpec[][] = [
|
||
[
|
||
{ command: 'paragraph', glyph: 'P', label: 'paragraph', run: (e) => e.chain().focus().setParagraph().run(), active: (e) => e.isActive('paragraph') },
|
||
headingButton(1),
|
||
headingButton(2),
|
||
headingButton(3),
|
||
headingButton(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 the offered heading levels (2 to 4, with 1 first
|
||
* when the H1 setting is off, J1), bold, italic, link, lists,
|
||
* blockquote, tables, image, embed, raw HTML block, undo and redo, plus the
|
||
* preview toggle.
|
||
*/
|
||
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 level = headingLevelOf(spec.command)
|
||
if (level > 0 && !host.headingLevels.includes(level)) continue
|
||
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)
|
||
|
||
// 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
|
||
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()
|
||
},
|
||
setPreview: (on) => {
|
||
preview.setAttribute('aria-pressed', String(on))
|
||
},
|
||
}
|
||
}
|