feat: editor heading levels, outline, link rel, generic-anchor warning and image naming

- 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
This commit is contained in:
Jakub Zych
2026-11-04 10:35:19 +01:00
parent 391435b4bf
commit 4b09bd9312
16 changed files with 1482 additions and 170 deletions

View File

@@ -418,3 +418,179 @@ golem15-journal-blogeditor .ProseMirror-selectednode.blogeditor__embed,
golem15-journal-blogeditor .ProseMirror img.ProseMirror-selectednode {
outline: 2px solid var(--c-ring);
}
/*
* J2 outline (Phase 33, UI-SPEC C28): a full-width bar between the panes
* and the status line, and the panel it opens. Metrics copy the editor's
* own: 6x8px bar padding, 8px form padding, 12px per heading level.
*/
golem15-journal-blogeditor .blogeditor__outline {
border-top: 1px solid var(--c-border);
}
golem15-journal-blogeditor .blogeditor__outline-toggle {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 8px;
width: 100%;
padding: 6px 8px;
border: 0;
border-radius: 0;
background: var(--c-subtle);
font-size: 13px;
text-align: left;
}
golem15-journal-blogeditor .blogeditor__outline-toggle:hover {
background: var(--c-hover);
}
golem15-journal-blogeditor .blogeditor__outline-problems,
golem15-journal-blogeditor .blogeditor__outline-flag,
golem15-journal-blogeditor .blogeditor__warning {
display: inline-flex;
align-items: center;
gap: 4px;
}
golem15-journal-blogeditor .blogeditor__alert {
flex: none;
}
golem15-journal-blogeditor .blogeditor__outline-panel {
padding: 8px;
border-top: 1px solid var(--c-border);
font-size: 13px;
}
golem15-journal-blogeditor .blogeditor__outline-list {
display: flex;
flex-direction: column;
gap: 2px;
margin: 0;
padding: 0;
list-style: none;
}
golem15-journal-blogeditor .blogeditor__outline-item {
padding-inline-start: calc(var(--blogeditor-outline-depth, 0) * 12px);
}
/* A long heading wraps under its text, the level tag hanging beside it. */
golem15-journal-blogeditor .blogeditor__outline-target {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
gap: 8px;
width: 100%;
font-size: 13px;
text-align: left;
white-space: normal;
overflow-wrap: anywhere;
}
golem15-journal-blogeditor .blogeditor__outline-level {
padding: 0 4px;
border-radius: 4px;
background: var(--c-subtle);
font-family: "DM Mono", ui-monospace, monospace;
font-size: 12px;
}
golem15-journal-blogeditor .blogeditor__outline-text--empty,
golem15-journal-blogeditor .blogeditor__outline-flag,
golem15-journal-blogeditor .blogeditor__outline-empty,
golem15-journal-blogeditor .blogeditor__outline-heading {
color: var(--c-muted);
}
golem15-journal-blogeditor .blogeditor__outline-flags {
display: flex;
flex-wrap: wrap;
gap: 2px 8px;
padding: 0 8px 4px;
}
golem15-journal-blogeditor .blogeditor__outline-empty,
golem15-journal-blogeditor .blogeditor__outline-heading {
margin: 0;
}
golem15-journal-blogeditor .blogeditor__outline-links {
display: flex;
flex-direction: column;
gap: 2px;
margin-top: 8px;
}
golem15-journal-blogeditor .blogeditor__outline-heading {
font-size: 12px;
}
/* J3 link form: the rel checkboxes, their hint and the generic-text warning. */
golem15-journal-blogeditor .blogeditor__rel {
display: flex;
flex: 1 1 100%;
flex-wrap: wrap;
align-items: center;
gap: 6px 8px;
margin: 0;
padding: 0;
border: 0;
font-size: 13px;
}
golem15-journal-blogeditor .blogeditor__rel legend {
float: left;
margin-right: 8px;
padding: 0;
font-size: 12px;
color: var(--c-muted);
}
golem15-journal-blogeditor .blogeditor__form label.blogeditor__check {
flex: 0 0 auto;
flex-direction: row;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--c-text);
}
golem15-journal-blogeditor .blogeditor__form input[type="checkbox"] {
width: auto;
margin: 0;
padding: 0;
}
golem15-journal-blogeditor .blogeditor__form input:disabled {
background: var(--c-subtle);
color: var(--c-muted);
}
golem15-journal-blogeditor .blogeditor__hint {
flex-basis: 100%;
margin: 0;
font-size: 12px;
color: var(--c-muted);
}
golem15-journal-blogeditor .blogeditor__warning {
flex-basis: 100%;
margin: 0;
font-size: 13px;
color: var(--c-text);
}
/* J4 image form: the file name with its fixed extension. */
golem15-journal-blogeditor .blogeditor__filename-row {
display: flex;
align-items: center;
gap: 4px;
}
golem15-journal-blogeditor .blogeditor__ext {
font-size: 13px;
color: var(--c-muted);
}

View File

@@ -1,7 +1,10 @@
import { Editor } from '@tiptap/core'
import { parseEmbedUrl } from './embed'
import { EDIT_HTML_EVENT, isPolicyUrl, preservationExtensions } from './extensions'
import { DEFAULT_HEADING_LEVELS, EDIT_HTML_EVENT, isPolicyUrl, preservationExtensions } from './extensions'
import { buildImageForm, type ImageChoice, type ImageForm } from './imageform'
import { format, labelsFor, type Labels } from './labels'
import { buildLinkExtras, type LinkExtras } from './linkform'
import { buildOutline, normalizeAnchor, type Outline } from './outline'
import { buildPreviewDocument, PREVIEW_DEBOUNCE_MS, PREVIEW_SANDBOX, resolvePreviewColors } from './preview'
import { captureBlocks, serializeDocument, type Blocks } from './serializer'
import { buildToolbar, type Toolbar } from './toolbar'
@@ -18,6 +21,48 @@ export const UPLOAD_TIMEOUT_MS = 60_000
type Mode = 'clean' | 'wysiwyg' | 'source'
/**
* The generic link texts when the options carry none: the seed lists of the
* Journal settings (classes.DefaultGenericAnchors).
*/
export const DEFAULT_GENERIC_ANCHORS: readonly string[] = [
'click here', 'here', 'more', 'read more', 'learn more', 'this link', 'link',
'kliknij tutaj', 'tutaj', 'tu', 'więcej', 'czytaj więcej', 'czytaj dalej', 'ten link',
]
/** The element options the editor reads (the field's typeOptions). */
export interface EditorOptions {
/** The offered heading levels (J1): [2, 3, 4], or [1, 2, 3, 4] while the H1 setting is off. */
headingLevels: number[]
/** The generic link texts, normalized (normalizeAnchor). */
genericAnchors: string[]
}
/**
* readOptions reads the options attribute (JSON): headingLevels (integers 1
* to 6, default [2, 3, 4]) and genericAnchors (strings, default
* DEFAULT_GENERIC_ANCHORS). Anything else, or invalid JSON, is the default.
*/
export function readOptions(raw: string | null): EditorOptions {
let parsed: Record<string, unknown> = {}
try {
const value: unknown = raw ? JSON.parse(raw) : {}
if (value && typeof value === 'object' && !Array.isArray(value)) parsed = value as Record<string, unknown>
} catch {
// Invalid JSON: the defaults.
}
const levels = Array.isArray(parsed.headingLevels)
? [...new Set(parsed.headingLevels.filter((n): n is number => Number.isInteger(n) && n >= 1 && n <= 6))].sort()
: []
const anchors = Array.isArray(parsed.genericAnchors)
? parsed.genericAnchors.filter((t): t is string => typeof t === 'string')
: [...DEFAULT_GENERIC_ANCHORS]
return {
headingLevels: levels.length > 0 ? levels : [...DEFAULT_HEADING_LEVELS],
genericAnchors: [...new Set(anchors.map(normalizeAnchor).filter((t) => t !== ''))],
}
}
interface InlineForm {
root: HTMLDivElement
error: HTMLParagraphElement
@@ -58,11 +103,15 @@ export class BlogEditorElement extends HTMLElement {
#status?: HTMLParagraphElement
#fileInput?: HTMLInputElement
#linkForm?: InlineForm
#linkExtras?: LinkExtras
#embedForm?: InlineForm
#imageForm?: InlineForm
#imageForm?: ImageForm
#outline?: Outline
#options: EditorOptions = readOptions(null)
#showingSource = false
#pending = new Map<string, { name: string; timer: ReturnType<typeof setTimeout> }>()
#ready: { url: string; name: string }[] = []
#pending = new Map<string, { name: string; choice: ImageChoice; timer: ReturnType<typeof setTimeout> }>()
/** The picked files waiting for their image form (J4). */
#queue: File[] = []
/** Whether this element shows the preview pane (per instance, off by default). */
#previewOn = false
#panes?: HTMLDivElement
@@ -100,6 +149,7 @@ export class BlogEditorElement extends HTMLElement {
if (this.#textarea) this.#textarea.value = v
this.#load(v)
this.#schedulePreview()
this.#outline?.schedule()
}
/** The TipTap editor while the element is connected (toolbar and tests). */
@@ -113,6 +163,8 @@ export class BlogEditorElement extends HTMLElement {
this.replaceChildren()
this.#labels = labelsFor(this.getAttribute('ui-locale'))
const labels = this.#labels
this.#options = readOptions(this.getAttribute('options'))
const options = this.#options
this.#toolbar = buildToolbar({
editor: () => this.#editor,
@@ -123,6 +175,7 @@ export class BlogEditorElement extends HTMLElement {
insertRawHtml: () => this.#insertRawHtml(),
showSource: (on) => this.showSource(on),
togglePreview: () => this.showPreview(!this.#previewOn),
headingLevels: options.headingLevels,
})
this.#linkForm = this.#form('link', labels.linkUrl, [
@@ -130,11 +183,18 @@ export class BlogEditorElement extends HTMLElement {
['remove', labels.linkRemove, () => this.#removeLink()],
['cancel', labels.cancel, () => this.#close(this.#linkForm)],
])
// J3: the rel checkboxes after the URL input, the generic-anchor
// warning above the buttons.
const extras = buildLinkExtras(labels, options.genericAnchors)
this.#linkExtras = extras
const urlField = this.#linkForm.root.querySelector('label')!
urlField.after(extras.fieldset, extras.warning)
urlField.querySelector('input')!.addEventListener('input', (event) => extras.setUrl((event.target as HTMLInputElement).value))
this.#embedForm = this.#form('embed', labels.embedUrl, [
['insert', labels.embedInsert, () => this.#insertEmbed()],
['cancel', labels.cancel, () => this.#close(this.#embedForm)],
])
this.#imageForm = this.#imageFormElement()
this.#imageForm = buildImageForm(labels, () => this.#insertImage(), () => this.#nextImage())
const mount = document.createElement('div')
mount.className = 'blogeditor__content'
@@ -182,12 +242,22 @@ export class BlogEditorElement extends HTMLElement {
panes.append(main, previewPane)
this.#panes = panes
// J2: the outline bar between the panes and the status line.
this.#outline = buildOutline({
labels,
editor: () => this.#editor,
sourceText: () => (this.#showingSource && this.#textarea ? this.#textarea.value : null),
allowsH1: options.headingLevels.includes(1),
genericAnchors: options.genericAnchors,
})
this.append(
this.#toolbar.element,
this.#linkForm.root,
this.#embedForm.root,
this.#imageForm.root,
panes,
this.#outline.element,
status,
fileInput,
)
@@ -201,7 +271,7 @@ export class BlogEditorElement extends HTMLElement {
element: mount,
injectCSS: false,
editable: !this.hasAttribute('disabled'),
extensions: preservationExtensions(labels),
extensions: preservationExtensions(labels, options.headingLevels),
content: initial,
editorProps: {
handleDrop: (_view, event, _slice, moved) => {
@@ -209,7 +279,7 @@ export class BlogEditorElement extends HTMLElement {
const files = Array.from((event as DragEvent).dataTransfer?.files ?? []).filter(isImage)
if (files.length === 0) return false
event.preventDefault()
for (const file of files) this.#upload(file)
this.#enqueue(files)
return true
},
},
@@ -218,6 +288,7 @@ export class BlogEditorElement extends HTMLElement {
this.#mode = 'wysiwyg'
this.#emitChange()
this.#schedulePreview()
this.#outline?.schedule()
},
onTransaction: () => this.#toolbar?.refresh(),
})
@@ -225,6 +296,7 @@ export class BlogEditorElement extends HTMLElement {
this.#syncAttributes()
this.showSource(false)
if (this.#previewOn) this.showPreview(true)
this.#outline.refresh()
}
disconnectedCallback(): void {
@@ -234,7 +306,8 @@ export class BlogEditorElement extends HTMLElement {
this.#pending.clear()
clearTimeout(this.#previewTimer)
this.#previewTimer = undefined
this.#ready = []
this.#outline?.stop()
this.#queue = []
// Keep an edited value readable while the element is detached.
if (this.#mode === 'wysiwyg' && this.#editor) {
this.#value = this.value
@@ -267,6 +340,7 @@ export class BlogEditorElement extends HTMLElement {
this.#close(this.#embedForm)
}
this.#toolbar?.selectTab(on)
this.#outline?.refresh()
}
/**
@@ -350,6 +424,7 @@ export class BlogEditorElement extends HTMLElement {
this.#mode = 'source'
this.#emitChange()
this.#schedulePreview()
this.#outline?.schedule()
}
#emitChange(): void {
@@ -449,9 +524,45 @@ export class BlogEditorElement extends HTMLElement {
// Links ----------------------------------------------------------------------
#openLink(): void {
if (!this.#editor?.isEditable) return
const href = this.#editor.getAttributes('link').href
this.#open(this.#linkForm, typeof href === 'string' ? href : '')
const editor = this.#editor
if (!editor?.isEditable) return
const attrs = editor.getAttributes('link')
const href = typeof attrs.href === 'string' ? attrs.href : ''
this.#open(this.#linkForm, href)
this.#linkExtras?.setUrl(href)
this.#linkExtras?.setRel(attrs.rel)
this.#linkExtras?.setText(this.#linkText())
}
/** The text the link form links: the existing link's text, else the selection. */
#linkText(): string {
const editor = this.#editor
if (!editor) return ''
const { state } = editor
let { from, to } = state.selection
if (editor.isActive('link')) {
const type = state.schema.marks.link
const $from = state.doc.resolve(from)
const start = $from.parent.childAfter($from.parentOffset)
if (type && start.node && type.isInSet(start.node.marks)) {
// The whole run of text carrying the same link mark.
const parentStart = $from.start()
let s0 = parentStart + start.offset
let e0 = s0 + start.node.nodeSize
const href = editor.getAttributes('link').href
$from.parent.forEach((child, offset) => {
const mark = child.marks.find((m) => m.type === type)
if (!mark || mark.attrs.href !== href) return
const a = parentStart + offset
const b = a + child.nodeSize
if (b === s0) s0 = a
if (a === e0) e0 = b
})
from = s0
to = e0
}
}
return state.doc.textBetween(from, to, ' ')
}
#applyLink(): void {
@@ -466,17 +577,19 @@ export class BlogEditorElement extends HTMLElement {
this.#linkForm.error.textContent = this.#labels.linkInvalid
return
}
// J3: rel only on an external link, with the checked tokens (D-80, D-83).
const rel = this.#linkExtras?.rel(url) ?? null
const { empty } = editor.state.selection
if (empty && !editor.isActive('link')) {
// Nothing selected: insert the URL as the link text.
editor
.chain()
.focus()
.insertContent({ type: 'text', text: url, marks: [{ type: 'link', attrs: { href: url } }] })
.insertContent({ type: 'text', text: url, marks: [{ type: 'link', attrs: { href: url, rel } }] })
.unsetMark('link')
.run()
} else {
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
editor.chain().focus().extendMarkRange('link').setLink({ href: url, rel }).run()
}
this.#close(this.#linkForm)
}
@@ -531,7 +644,7 @@ export class BlogEditorElement extends HTMLElement {
if (at >= 0) textarea.setSelectionRange(at, at + html.length)
}
// Uploads (D-18) -------------------------------------------------------------
// Uploads (D-18, J4) -----------------------------------------------------------
#picked(): void {
const input = this.#fileInput
@@ -539,7 +652,7 @@ export class BlogEditorElement extends HTMLElement {
const files = Array.from(input.files ?? [])
const images = files.filter(isImage)
if (images.length < files.length) this.#setStatus(this.#labels.notAnImage)
for (const file of images) this.#upload(file)
this.#enqueue(images)
try {
input.value = ''
} catch {
@@ -547,14 +660,20 @@ export class BlogEditorElement extends HTMLElement {
}
}
#upload(file: File): void {
/** Queues picked or dropped images; each gets its image form in turn (J4). */
#enqueue(files: File[]): void {
this.#queue.push(...files)
this.#showImageForm()
}
#upload(file: File, choice: ImageChoice): void {
const id = uploadRequestId()
const name = file.name
const timer = setTimeout(() => {
if (!this.#pending.delete(id)) return
this.#setStatus(format(this.#labels.uploadTimeout, { name }))
}, UPLOAD_TIMEOUT_MS)
this.#pending.set(id, { name, timer })
this.#pending.set(id, { name, choice, timer })
this.#setStatus(format(this.#labels.uploading, { name }))
this.dispatchEvent(new CustomEvent(UPLOAD_EVENT, { bubbles: true, composed: true, detail: { id, file } }))
}
@@ -573,66 +692,35 @@ export class BlogEditorElement extends HTMLElement {
return
}
this.#setStatus('')
this.#ready.push({ url, name: pending.name })
this.#showImageForm()
}
#imageFormElement(): InlineForm {
const labels = this.#labels
const root = document.createElement('div')
root.className = 'blogeditor__form'
root.dataset.form = 'image'
root.hidden = true
const field = (name: string, text: string) => {
const label = document.createElement('label')
const caption = document.createElement('span')
caption.textContent = text
const input = document.createElement('input')
input.type = 'text'
input.name = name
input.autocomplete = 'off'
label.append(caption, input)
return label
}
root.append(field('alt', labels.altText), field('caption', labels.caption))
root.append(this.#action('insert', labels.insertImage, () => this.#insertImage()))
root.append(this.#action('cancel', labels.cancel, () => this.#nextImage()))
const error = document.createElement('p')
error.className = 'blogeditor__error'
error.dataset.error = ''
error.setAttribute('aria-live', 'polite')
root.append(error)
return { root, error }
const editor = this.#editor
if (!editor) return
const { alt, caption } = pending.choice
if (caption) editor.chain().focus().setFigure({ src: url, alt, caption }).run()
else editor.chain().focus().setImage({ src: url, alt }).run()
}
#showImageForm(): void {
const form = this.#imageForm
if (!form || !form.root.hidden || this.#ready.length === 0) return
for (const input of Array.from(form.root.querySelectorAll('input'))) input.value = ''
form.error.textContent = ''
form.root.hidden = false
form.root.querySelector<HTMLInputElement>('input[name="alt"]')?.focus()
const file = this.#queue[0]
if (!form || !form.root.hidden || !file) return
form.open(file)
}
/** Insert: uploads the file under the chosen name; the image is inserted on the result. */
#insertImage(): void {
const editor = this.#editor
const form = this.#imageForm
const ready = this.#ready[0]
if (!editor || !form || !ready) return
const alt = this.#inputOf(form, 'alt')
const caption = this.#inputOf(form, 'caption')
if (!alt) {
form.error.textContent = this.#labels.altRequired
return
}
if (caption) editor.chain().focus().setFigure({ src: ready.url, alt, caption }).run()
else editor.chain().focus().setImage({ src: ready.url, alt }).run()
const file = this.#queue[0]
if (!this.#editor || !form || !file) return
const choice = form.choice()
if (!choice) return
const renamed = choice.fileName === file.name ? file : new File([file], choice.fileName, { type: file.type, lastModified: file.lastModified })
this.#upload(renamed, choice)
this.#nextImage()
}
#nextImage(): void {
this.#ready.shift()
if (this.#imageForm) this.#close(this.#imageForm)
this.#queue.shift()
if (this.#imageForm) this.#imageForm.root.hidden = true
this.#showImageForm()
}
}

View File

@@ -529,22 +529,62 @@ export const PlainTableHeader = TableHeader.extend({
},
})
/** The heading levels the editor offers when no option says otherwise (J1). */
export const DEFAULT_HEADING_LEVELS: readonly number[] = [2, 3, 4]
/**
* schemaHeadingLevels are the heading levels the schema holds for the
* offered ones: the offered levels and every lower level (5 and 6), so
* stored H5 and H6 survive an edit, and H1 only when it is offered.
*/
export function schemaHeadingLevels(offered: readonly number[]): (1 | 2 | 3 | 4 | 5 | 6)[] {
const allowsH1 = offered.includes(1)
return ([1, 2, 3, 4, 5, 6] as const).filter((level) => level > 1 || allowsH1)
}
/**
* HeadingOneAsTwo parses an h1 (pasted or loaded) as a level 2 heading while
* the schema has no level 1 (the H1 setting is on, J1), so the editor never
* holds an H1. It is a parse rule for the heading node carried by a node
* type the parser never creates.
*/
export const HeadingOneAsTwo = Node.create({
name: 'headingOneAsTwo',
content: 'inline*',
parseHTML() {
return [
{
tag: 'h1',
node: 'heading',
getAttrs: (element) => ({ level: 2, htmlAttrs: captureAttributes(element as HTMLElement, []) }),
},
]
},
renderHTML() {
return ['h2', 0]
},
})
/**
* preservationExtensions is the editor schema (D-03, D-04): StarterKit
* without the trailing node and with links that gain no target, rel or class
* and accept only policy URLs; inline images without base64; tables; and
* the preservation nodes and marks.
* the preservation nodes and marks. headingLevels are the offered heading
* levels (J1): without 1, an h1 is parsed as a level 2 heading.
*/
export function preservationExtensions(labels: Labels = labelsFor('en')): AnyExtension[] {
export function preservationExtensions(labels: Labels = labelsFor('en'), headingLevels: readonly number[] = DEFAULT_HEADING_LEVELS): AnyExtension[] {
const levels = schemaHeadingLevels(headingLevels)
return [
StarterKit.configure({
trailingNode: false,
heading: { levels },
link: {
openOnClick: false,
HTMLAttributes: { target: null, rel: null, class: null },
isAllowedUri: (url) => isPolicyUrl(url),
},
}),
...(levels.includes(1) ? [] : [HeadingOneAsTwo]),
Image.configure({ inline: true, allowBase64: false }),
PlainTable,
TableRow,

View File

@@ -0,0 +1,167 @@
import type { Labels } from './labels'
/** The longest file name stem mediaFileName keeps (D-85). */
export const MAX_FILE_STEM = 80
const POLISH: Record<string, string> = { ą: 'a', ć: 'c', ę: 'e', ł: 'l', ń: 'n', ó: 'o', ś: 's', ź: 'z', ż: 'z' }
/**
* slugFileStem is the J4 slug rule (D-85), the same as the server's
* classes.MediaFileName: lowercase, Polish diacritics transliterated, every
* other run of characters outside a-z and 0-9 one hyphen, trimmed, at most
* 80 characters, and "image" when nothing is left.
*/
export function slugFileStem(name: string): string {
let out = ''
let dash = false
for (const ch of name.toLowerCase()) {
const c = POLISH[ch] ?? ch
if (/^[a-z0-9]$/.test(c)) {
out += c
dash = false
} else if (!dash) {
out += '-'
dash = true
}
}
out = out.replace(/^-+|-+$/g, '').slice(0, MAX_FILE_STEM).replace(/-+$/, '')
return out === '' ? 'image' : out
}
/** splitFileName splits a file name into its stem and its lower-case extension (with the dot, or ""). */
export function splitFileName(name: string): { stem: string; ext: string } {
const base = name.replace(/\\/g, '/').split('/').pop() ?? ''
const dot = base.lastIndexOf('.')
if (dot >= 0) {
const ext = base.slice(dot).toLowerCase()
if (/^\.[a-z0-9]+$/.test(ext)) return { stem: base.slice(0, dot), ext }
}
return { stem: base, ext: '' }
}
/** mediaFileName applies slugFileStem to a file name and keeps its extension, lowercased. */
export function mediaFileName(name: string): string {
const { stem, ext } = splitFileName(name)
return slugFileStem(stem) + ext
}
/** What the image form answers on Insert. */
export interface ImageChoice {
/** The file name to upload under: the slugged stem and the fixed extension. */
fileName: string
alt: string
caption: string
decorative: boolean
}
/** The J4 image form, one picked file at a time. */
export interface ImageForm {
root: HTMLDivElement
error: HTMLParagraphElement
/** Shows the form for file: the slug of its name, its extension, empty alt and caption. */
open: (file: File) => void
/** Validates and returns the choice, or shows altRequired and returns null. */
choice: () => ImageChoice | null
}
/**
* buildImageForm builds the J4 inline image form: fileName (prefilled with
* the slug of the original name, slugged again on blur and on Insert, the
* extension shown as fixed text after it, fileNameHint under it), altText
* (required unless the image is decorative), the decorative checkbox (it
* clears and disables the alt input; the image gets alt="") and caption.
*/
export function buildImageForm(labels: Labels, onInsert: () => void, onCancel: () => void): ImageForm {
const root = document.createElement('div')
root.className = 'blogeditor__form'
root.dataset.form = 'image'
root.hidden = true
const textField = (name: string, caption: string) => {
const label = document.createElement('label')
const text = document.createElement('span')
text.textContent = caption
const input = document.createElement('input')
input.type = 'text'
input.name = name
input.autocomplete = 'off'
label.append(text, input)
return { label, input }
}
const name = textField('fileName', labels.fileName)
name.label.classList.add('blogeditor__filename')
const nameRow = document.createElement('span')
nameRow.className = 'blogeditor__filename-row'
const ext = document.createElement('span')
ext.className = 'blogeditor__ext'
nameRow.append(name.input, ext)
const hint = document.createElement('span')
hint.className = 'blogeditor__hint'
hint.textContent = labels.fileNameHint
name.label.append(nameRow, hint)
name.input.addEventListener('blur', () => {
name.input.value = slugFileStem(name.input.value)
})
const alt = textField('alt', labels.altText)
const decorativeLabel = document.createElement('label')
decorativeLabel.className = 'blogeditor__check'
const decorative = document.createElement('input')
decorative.type = 'checkbox'
decorative.name = 'decorative'
decorativeLabel.append(decorative, document.createTextNode(labels.decorative))
decorative.addEventListener('change', () => {
if (decorative.checked) alt.input.value = ''
alt.input.disabled = decorative.checked
if (decorative.checked) error.textContent = ''
})
const caption = textField('caption', labels.caption)
const button = (action: string, text: string, run: () => void) => {
const b = document.createElement('button')
b.type = 'button'
b.dataset.action = action
b.textContent = text
b.addEventListener('click', run)
return b
}
const error = document.createElement('p')
error.className = 'blogeditor__error'
error.dataset.error = ''
error.setAttribute('aria-live', 'polite')
root.append(name.label, alt.label, decorativeLabel, caption.label, button('insert', labels.insertImage, onInsert), button('cancel', labels.cancel, onCancel), error)
return {
root,
error,
open: (file) => {
const parts = splitFileName(file.name)
name.input.value = slugFileStem(parts.stem)
ext.textContent = parts.ext
alt.input.value = ''
alt.input.disabled = false
decorative.checked = false
caption.input.value = ''
error.textContent = ''
root.hidden = false
alt.input.focus()
},
choice: () => {
name.input.value = slugFileStem(name.input.value)
const altText = alt.input.value.trim()
if (!decorative.checked && altText === '') {
error.textContent = labels.altRequired
return null
}
error.textContent = ''
return {
fileName: name.input.value + ext.textContent,
alt: decorative.checked ? '' : altText,
caption: caption.input.value.trim(),
decorative: decorative.checked,
}
},
}
}

View File

@@ -1,5 +1,13 @@
/**
* A CLDR plural map: one/other in English, one/few/many/other in Polish
* (UI-SPEC zero-one-many); {count} is the number.
*/
export type Plural = Partial<Record<Intl.LDMLPluralRule, string>> & { other: string }
/** Every label, prompt and error the Blog Editor shows. */
export interface Labels {
/** The language the plural rules of this table follow. */
language: string
tabWysiwyg: string
tabSource: string
toolbar: string
@@ -45,9 +53,35 @@ export interface Labels {
embedCard: string
preview: string
previewFrame: string
heading1: string
outline: string
outlineShow: string
outlineHide: string
outlineCount: Plural
outlineProblems: Plural
outlineEmpty: string
flagSkipped: string
flagEmpty: string
flagLinkOnly: string
flagH1: string
outlineLinks: string
flagGeneric: string
linkRel: string
relNofollow: string
relSponsored: string
relUgc: string
relHint: string
genericWarning: string
fileName: string
fileNameHint: string
decorative: string
}
/** The keys of the labels that are plain strings. */
export type TextLabel = { [K in keyof Labels]: Labels[K] extends string ? K : never }[keyof Labels]
const en: Labels = {
language: 'en',
tabWysiwyg: 'WYSIWYG',
tabSource: 'Source',
toolbar: 'Formatting',
@@ -86,16 +120,39 @@ const en: Labels = {
notAnImage: 'Only JPEG, PNG, GIF and WebP images can be uploaded.',
altText: 'Alt text (required)',
caption: 'Caption (optional)',
altRequired: 'Enter the alt text.',
altRequired: 'Enter the alt text, or mark the image as decorative.',
insertImage: 'Insert image',
rawHtmlCard: 'HTML block',
editAsHtml: 'Edit as HTML',
embedCard: 'Embedded video',
preview: 'Preview',
previewFrame: 'Post preview',
heading1: 'Heading 1',
outline: 'Outline',
outlineShow: 'Show outline',
outlineHide: 'Hide outline',
outlineCount: { one: '{count} heading', other: '{count} headings' },
outlineProblems: { one: '{count} problem', other: '{count} problems' },
outlineEmpty: 'No headings yet. Headings split the text into sections for readers and search engines.',
flagSkipped: 'Skipped level: H{from} → H{to}',
flagEmpty: 'Empty heading',
flagLinkOnly: 'Heading is only a link',
flagH1: 'H1 in the text: saved as H2',
outlineLinks: 'Links',
flagGeneric: 'Link text “{text}” does not say where it leads',
linkRel: 'Link attributes',
relNofollow: 'nofollow',
relSponsored: 'sponsored (paid)',
relUgc: 'ugc (user content)',
relHint: 'Only for links to other sites. Links within the site never get these.',
genericWarning: '“{text}” does not say where the link leads. Use words that describe the target page.',
fileName: 'File name',
fileNameHint: 'Lowercase words joined with hyphens, e.g. lion-s-mane-in-the-forest.',
decorative: 'Decorative image (no alt text)',
}
const pl: Labels = {
language: 'pl',
tabWysiwyg: 'WYSIWYG',
tabSource: 'Źródło',
toolbar: 'Formatowanie',
@@ -134,13 +191,35 @@ const pl: Labels = {
notAnImage: 'Można wysłać tylko obrazy JPEG, PNG, GIF i WebP.',
altText: 'Tekst alternatywny (wymagany)',
caption: 'Podpis (opcjonalny)',
altRequired: 'Wpisz tekst alternatywny.',
altRequired: 'Wpisz tekst alternatywny lub oznacz obraz jako dekoracyjny.',
insertImage: 'Wstaw obraz',
rawHtmlCard: 'Blok HTML',
editAsHtml: 'Edytuj jako HTML',
embedCard: 'Osadzone wideo',
preview: 'Podgląd',
previewFrame: 'Podgląd wpisu',
heading1: 'Nagłówek 1',
outline: 'Konspekt',
outlineShow: 'Pokaż konspekt',
outlineHide: 'Ukryj konspekt',
outlineCount: { one: '{count} nagłówek', few: '{count} nagłówki', many: '{count} nagłówków', other: '{count} nagłówka' },
outlineProblems: { one: '{count} problem', few: '{count} problemy', many: '{count} problemów', other: '{count} problemu' },
outlineEmpty: 'Brak nagłówków. Nagłówki dzielą tekst na części dla czytelników i wyszukiwarek.',
flagSkipped: 'Pominięty poziom: H{from} → H{to}',
flagEmpty: 'Pusty nagłówek',
flagLinkOnly: 'Nagłówek jest tylko linkiem',
flagH1: 'H1 w tekście: zostanie zapisany jako H2',
outlineLinks: 'Linki',
flagGeneric: 'Tekst linku „{text}” nie mówi, dokąd prowadzi',
linkRel: 'Atrybuty linku',
relNofollow: 'nofollow',
relSponsored: 'sponsored (płatny)',
relUgc: 'ugc (od użytkowników)',
relHint: 'Tylko dla linków do innych witryn. Linki w obrębie witryny nigdy ich nie dostają.',
genericWarning: '„{text}” nie mówi, dokąd prowadzi link. Użyj słów opisujących stronę docelową.',
fileName: 'Nazwa pliku',
fileNameHint: 'Małe litery i łączniki, np. lwia-grzywa-w-lesie.',
decorative: 'Obraz dekoracyjny (bez tekstu alternatywnego)',
}
const TABLES: Record<string, Labels> = { en, pl }
@@ -158,3 +237,17 @@ export function labelsFor(uiLocale: string | null | undefined): Labels {
export function format(label: string, values: Record<string, string>): string {
return label.replace(/\{(\w+)\}/g, (match, key: string) => values[key] ?? match)
}
/**
* plural picks the CLDR form of a plural label for count in the table's
* language and fills {count} (and any other placeholder in values).
*/
export function plural(labels: Labels, forms: Plural, count: number, values: Record<string, string> = {}): string {
let rule: Intl.LDMLPluralRule = 'other'
try {
rule = new Intl.PluralRules(labels.language).select(count)
} catch {
// An engine without Intl.PluralRules falls back to the other form.
}
return format(forms[rule] ?? forms.other, { ...values, count: String(count) })
}

View File

@@ -0,0 +1,108 @@
import { format, type Labels } from './labels'
import { alertIcon, isGenericAnchor } from './outline'
/** The rel tokens the link form writes and the server policy keeps (D-83). */
export const REL_TOKENS = ['nofollow', 'sponsored', 'ugc'] as const
export type RelToken = (typeof REL_TOKENS)[number]
/**
* isExternalUrl reports whether url is an absolute http or https URL, the
* only links that may carry rel (D-80): links within the site never do.
*/
export function isExternalUrl(url: string): boolean {
const trimmed = url.trim()
if (!/^https?:\/\//i.test(trimmed)) return false
try {
return new URL(trimmed).host !== ''
} catch {
return false
}
}
/** relTokens reads the known rel tokens of a stored rel value, in REL_TOKENS order. */
export function relTokens(rel: unknown): RelToken[] {
if (typeof rel !== 'string') return []
const present = rel.toLowerCase().split(/\s+/)
return REL_TOKENS.filter((t) => present.includes(t))
}
/** The J3 additions to the link form. */
export interface LinkExtras {
/** The fieldset with the three rel checkboxes and the hint. */
fieldset: HTMLFieldSetElement
/** The generic-anchor warning line, placed above the buttons. */
warning: HTMLParagraphElement
/** Enables the checkboxes for an external URL, else disables them and shows the hint. */
setUrl: (url: string) => void
/** Checks the checkboxes of a stored rel value. */
setRel: (rel: unknown) => void
/** The rel value to write for url: the checked tokens of an external URL, else null. */
rel: (url: string) => string | null
/** Shows the warning when the link text is on the generic list, hides it otherwise. */
setText: (text: string) => void
}
/**
* buildLinkExtras builds the J3 part of the link form: a fieldset labelled
* linkRel with nofollow, sponsored and ugc checkboxes, enabled only for an
* absolute http(s) URL (relHint otherwise), and the genericWarning line
* (aria-live polite) shown when the link text is on the generic list. The
* warning never blocks Apply (D-81).
*/
export function buildLinkExtras(labels: Labels, genericAnchors: readonly string[]): LinkExtras {
const fieldset = document.createElement('fieldset')
fieldset.className = 'blogeditor__rel'
const legend = document.createElement('legend')
legend.textContent = labels.linkRel
fieldset.append(legend)
const boxes = new Map<RelToken, HTMLInputElement>()
const names: Record<RelToken, string> = { nofollow: labels.relNofollow, sponsored: labels.relSponsored, ugc: labels.relUgc }
for (const token of REL_TOKENS) {
const label = document.createElement('label')
label.className = 'blogeditor__check'
const box = document.createElement('input')
box.type = 'checkbox'
box.name = `rel-${token}`
box.value = token
label.append(box, document.createTextNode(names[token]))
fieldset.append(label)
boxes.set(token, box)
}
const hint = document.createElement('p')
hint.className = 'blogeditor__hint'
hint.textContent = labels.relHint
fieldset.append(hint)
const warning = document.createElement('p')
warning.className = 'blogeditor__warning'
warning.dataset.warning = ''
warning.setAttribute('aria-live', 'polite')
warning.hidden = true
const setUrl = (url: string) => {
const external = isExternalUrl(url)
for (const box of boxes.values()) box.disabled = !external
hint.hidden = external
}
return {
fieldset,
warning,
setUrl,
setRel: (rel) => {
const tokens = relTokens(rel)
for (const [token, box] of boxes) box.checked = tokens.includes(token)
},
rel: (url) => {
if (!isExternalUrl(url)) return null
const checked = REL_TOKENS.filter((t) => boxes.get(t)!.checked)
return checked.length > 0 ? checked.join(' ') : null
},
setText: (text) => {
const generic = isGenericAnchor(text, genericAnchors)
warning.hidden = !generic
warning.replaceChildren(...(generic ? [alertIcon(), document.createTextNode(format(labels.genericWarning, { text: text.trim() }))] : []))
},
}
}

View File

@@ -0,0 +1,302 @@
import type { Editor } from '@tiptap/core'
import { format, plural, type Labels } from './labels'
/** How long after the last change the outline recomputes (J2). */
export const OUTLINE_DEBOUNCE_MS = 300
/** The text-free alert icon the outline flags carry (triangle-alert). */
const ALERT_PATHS = ['m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3', 'M12 9v4', 'M12 17h.01']
/** One heading of the outline, in document order. */
export interface OutlineHeading {
level: number
text: string
/** Every non-blank character of the heading sits inside a link. */
linkOnly: boolean
/** The heading's document position in the WYSIWYG tab, -1 in the Source tab. */
pos: number
}
/** The outline facts: headings and the texts of the links. */
export interface OutlineFacts {
headings: OutlineHeading[]
links: string[]
}
/** What the outline reads from the element. */
export interface OutlineHost {
labels: Labels
editor: () => Editor | undefined
/** The Source tab's text while it defines the value, null otherwise. */
sourceText: () => string | null
/** Whether level 1 is offered (the H1 setting is off). */
allowsH1: boolean
/** The generic link texts, normalized (normalizeAnchor). */
genericAnchors: readonly string[]
}
export interface Outline {
element: HTMLDivElement
/** Recomputes OUTLINE_DEBOUNCE_MS after the last call. */
schedule: () => void
/** Recomputes now. */
refresh: () => void
/** Stops a pending recompute. */
stop: () => void
}
/**
* normalizeAnchor is a link text as the generic list compares it: trimmed,
* lower-cased, punctuation and symbols removed, white space collapsed.
*/
export function normalizeAnchor(text: string): string {
return text
.toLowerCase()
.replace(/[\p{P}\p{S}]+/gu, '')
.replace(/\s+/g, ' ')
.trim()
}
/** isGenericAnchor reports whether a link text is on the normalized generic list. */
export function isGenericAnchor(text: string, list: readonly string[]): boolean {
const n = normalizeAnchor(text)
return n !== '' && list.includes(n)
}
/** factsFromEditor reads the headings and links of the editor document. */
export function factsFromEditor(editor: Editor): OutlineFacts {
const headings: OutlineHeading[] = []
const links: string[] = []
editor.state.doc.descendants((node, pos) => {
if (node.type.name === 'heading') {
let linked = 0
let all = 0
node.descendants((child) => {
if (!child.isText) return true
const chars = (child.text ?? '').replace(/\s+/g, '').length
all += chars
if (child.marks.some((m) => m.type.name === 'link')) linked += chars
return false
})
headings.push({ level: Number(node.attrs.level) || 2, text: node.textContent.trim(), linkOnly: all > 0 && linked === all, pos })
}
if (node.isTextblock) {
let current = ''
let href: unknown = null
const flush = () => {
if (current.trim() !== '') links.push(current)
current = ''
href = null
}
node.forEach((child) => {
const link = child.isText ? child.marks.find((m) => m.type.name === 'link') : undefined
if (link && link.attrs.href === href) {
current += child.text ?? ''
return
}
flush()
if (link) {
current = child.text ?? ''
href = link.attrs.href
}
})
flush()
return false
}
return true
})
return { headings, links }
}
/** factsFromSource reads the headings and links of HTML source text. */
export function factsFromSource(html: string): OutlineFacts {
const doc = document.implementation.createHTMLDocument('')
const host = doc.createElement('div')
host.innerHTML = html
const headings: OutlineHeading[] = []
for (const el of Array.from(host.querySelectorAll('h1, h2, h3, h4, h5, h6'))) {
const text = (el.textContent ?? '').replace(/\s+/g, ' ').trim()
const linkedText = Array.from(el.querySelectorAll('a'))
.map((a) => a.textContent ?? '')
.join('')
.replace(/\s+/g, '')
headings.push({ level: Number(el.tagName.slice(1)), text, linkOnly: text !== '' && linkedText.length === text.replace(/\s+/g, '').length, pos: -1 })
}
const links = Array.from(host.querySelectorAll('a'))
.map((a) => a.textContent ?? '')
.filter((t) => t.trim() !== '')
return { headings, links }
}
/** One heading's flags as label texts. */
export function headingFlags(facts: OutlineFacts, labels: Labels, opts: { allowsH1: boolean; source: boolean }): string[][] {
let previous = 1 // the page title is the H1
return facts.headings.map((h) => {
const flags: string[] = []
if (h.level > previous + 1) flags.push(format(labels.flagSkipped, { from: String(previous), to: String(h.level) }))
if (h.text === '') flags.push(labels.flagEmpty)
else if (h.linkOnly) flags.push(labels.flagLinkOnly)
if (h.level === 1 && !opts.allowsH1 && opts.source) flags.push(labels.flagH1)
previous = h.level
return flags
})
}
export function alertIcon(): SVGSVGElement {
const ns = 'http://www.w3.org/2000/svg'
const svg = document.createElementNS(ns, 'svg')
svg.setAttribute('class', 'blogeditor__alert')
svg.setAttribute('viewBox', '0 0 24 24')
svg.setAttribute('width', '14')
svg.setAttribute('height', '14')
svg.setAttribute('fill', 'none')
svg.setAttribute('stroke', 'currentColor')
svg.setAttribute('stroke-width', '2')
svg.setAttribute('stroke-linecap', 'round')
svg.setAttribute('stroke-linejoin', 'round')
svg.setAttribute('aria-hidden', 'true')
for (const d of ALERT_PATHS) {
const path = document.createElementNS(ns, 'path')
path.setAttribute('d', d)
svg.append(path)
}
return svg
}
/** flagLine is one 13px muted flag with the alert icon. */
function flagLine(text: string): HTMLSpanElement {
const span = document.createElement('span')
span.className = 'blogeditor__outline-flag'
span.append(alertIcon(), document.createTextNode(text))
return span
}
/**
* buildOutline builds the J2 outline: a full-width toggle bar (collapsed by
* default, aria-expanded) with the heading count and, when there are any,
* the problem count, and a panel listing the headings in document order
* with their flags (skipped level, empty, only a link, an H1 in the Source
* tab while the H1 setting is on), then the links whose text is on the
* generic list. Activating a heading puts the caret in it. The outline
* reads the document and never edits it, and never takes focus by itself.
*/
export function buildOutline(host: OutlineHost): Outline {
const { labels } = host
const element = document.createElement('div')
element.className = 'blogeditor__outline'
const toggle = document.createElement('button')
toggle.type = 'button'
toggle.className = 'blogeditor__outline-toggle'
toggle.setAttribute('aria-expanded', 'false')
toggle.title = labels.outlineShow
const summary = document.createElement('span')
summary.className = 'blogeditor__outline-summary'
const problems = document.createElement('span')
problems.className = 'blogeditor__outline-problems'
problems.hidden = true
toggle.append(summary, problems)
const panel = document.createElement('div')
panel.className = 'blogeditor__outline-panel'
panel.hidden = true
panel.id = `blogeditor-outline-${Math.random().toString(36).slice(2)}`
toggle.setAttribute('aria-controls', panel.id)
toggle.addEventListener('click', () => {
const open = panel.hidden
panel.hidden = !open
toggle.setAttribute('aria-expanded', String(open))
toggle.title = open ? labels.outlineHide : labels.outlineShow
if (open) refresh()
})
element.append(toggle, panel)
let timer: ReturnType<typeof setTimeout> | undefined
const render = (facts: OutlineFacts, source: boolean) => {
const flags = headingFlags(facts, labels, { allowsH1: host.allowsH1, source })
const generic = facts.links.filter((t) => isGenericAnchor(t, host.genericAnchors))
const problemCount = flags.reduce((n, f) => n + f.length, 0) + generic.length
summary.textContent = `${labels.outline} · ${plural(labels, labels.outlineCount, facts.headings.length)}`
problems.hidden = problemCount === 0
problems.replaceChildren(...(problemCount > 0 ? [alertIcon(), document.createTextNode(plural(labels, labels.outlineProblems, problemCount))] : []))
if (panel.hidden) return
const parts: HTMLElement[] = []
if (facts.headings.length === 0) {
const empty = document.createElement('p')
empty.className = 'blogeditor__outline-empty'
empty.textContent = labels.outlineEmpty
parts.push(empty)
} else {
const base = host.allowsH1 ? 1 : 2
const list = document.createElement('ol')
list.className = 'blogeditor__outline-list'
facts.headings.forEach((h, i) => {
const item = document.createElement('li')
item.className = 'blogeditor__outline-item'
item.style.setProperty('--blogeditor-outline-depth', String(Math.max(0, h.level - base)))
const target = document.createElement('button')
target.type = 'button'
target.className = 'blogeditor__outline-target'
const tag = document.createElement('span')
tag.className = 'blogeditor__outline-level'
tag.textContent = `H${h.level}`
const text = document.createElement('span')
text.className = h.text === '' ? 'blogeditor__outline-text blogeditor__outline-text--empty' : 'blogeditor__outline-text'
text.textContent = h.text === '' ? labels.flagEmpty : h.text
target.append(tag, text)
target.addEventListener('click', () => focusHeading(h))
item.append(target)
const shown = h.text === '' ? flags[i]!.filter((f) => f !== labels.flagEmpty) : flags[i]!
if (shown.length > 0) {
const line = document.createElement('span')
line.className = 'blogeditor__outline-flags'
line.append(...shown.map(flagLine))
item.append(line)
}
list.append(item)
})
parts.push(list)
}
if (generic.length > 0) {
const links = document.createElement('div')
links.className = 'blogeditor__outline-links'
const title = document.createElement('p')
title.className = 'blogeditor__outline-heading'
title.textContent = labels.outlineLinks
links.append(title, ...generic.map((t) => flagLine(format(labels.flagGeneric, { text: t.trim() }))))
parts.push(links)
}
panel.replaceChildren(...parts)
}
const focusHeading = (h: OutlineHeading) => {
const editor = host.editor()
if (!editor || h.pos < 0 || host.sourceText() !== null) return
editor.chain().focus().setTextSelection(h.pos + 1).scrollIntoView().run()
}
const refresh = () => {
clearTimeout(timer)
timer = undefined
const source = host.sourceText()
const editor = host.editor()
if (source !== null) render(factsFromSource(source), true)
else if (editor) render(factsFromEditor(editor), false)
}
return {
element,
schedule: () => {
clearTimeout(timer)
timer = setTimeout(refresh, OUTLINE_DEBOUNCE_MS)
},
refresh,
stop: () => {
clearTimeout(timer)
timer = undefined
},
}
}

View File

@@ -1,5 +1,5 @@
import type { Editor } from '@tiptap/core'
import type { Labels } from './labels'
import type { Labels, TextLabel } from './labels'
/** What the toolbar needs from the element. */
export interface ToolbarHost {
@@ -12,6 +12,11 @@ export interface ToolbarHost {
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 {
@@ -29,7 +34,7 @@ export interface Toolbar {
interface ButtonSpec {
command: string
glyph: string
label: keyof Labels
label: TextLabel
run: (editor: Editor, host: ToolbarHost) => void
active?: (editor: Editor) => boolean
enabled?: (editor: Editor) => boolean
@@ -39,12 +44,32 @@ interface ButtonSpec {
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') },
{ 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 }) },
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') },
@@ -77,7 +102,8 @@ const GROUPS: ButtonSpec[][] = [
/**
* buildToolbar builds the formatting toolbar and the WYSIWYG/Source tabs
* (D-04): paragraph and headings 2 to 4, bold, italic, link, lists,
* (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.
*/
@@ -96,6 +122,8 @@ export function buildToolbar(host: ToolbarHost): Toolbar {
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