Files
sm-journal-plugin/assets/src/blogeditor/toolbar.ts
Jakub Zych df67939251 feat(editor): toggleable preview pane in a sandboxed frame
- A per-element Preview toggle (aria-pressed, off by default) splits the editor 50/50 with an auto-fit grid that wraps the pane below on narrow widths.
- The pane is an iframe with an empty sandbox, content set only through srcdoc, created on first use; it reads only the value getter, so previewing never edits or emits summer-change.
2026-10-10 13:53:48 +02:00

182 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
/** Switches the preview pane on or off. */
togglePreview: () => 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
/** Marks the preview toggle as pressed or not. */
setPreview: (on: 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, 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 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))
},
}
}