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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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

View File

@@ -19,6 +19,7 @@ import (
"git.golem15.com/golem15/summercms/modules/pact"
"gocloud.dev/blob"
xhtml "golang.org/x/net/html"
"gorm.io/gorm"
)
// BlogEditorElement is the custom element of the blogeditor and mlblogeditor
@@ -88,31 +89,34 @@ var (
func BlogEditorTypes(app func() *backpack.App) []pact.FormFieldType {
upload := uploadBlogImage(app)
decode := decodeBlogHTMLFor(app)
options := blogRequestOptions(app)
return []pact.FormFieldType{
{
Name: "blogeditor",
Element: BlogEditorElement,
Scripts: []string{"assets/dist/blogeditor.js"},
Styles: []string{"assets/dist/blogeditor.css"},
Options: []string{"uploadFolder"},
CheckOptions: checkBlogOptions,
Decode: decode,
Load: loadBlogHTML,
ListValue: blogListValue,
Upload: upload,
Name: "blogeditor",
Element: BlogEditorElement,
Scripts: []string{"assets/dist/blogeditor.js"},
Styles: []string{"assets/dist/blogeditor.css"},
Options: []string{"uploadFolder"},
CheckOptions: checkBlogOptions,
Decode: decode,
Load: loadBlogHTML,
ListValue: blogListValue,
Upload: upload,
RequestOptions: options,
},
{
Name: "mlblogeditor",
Element: BlogEditorElement,
Translatable: true,
Scripts: []string{"assets/dist/blogeditor.js"},
Styles: []string{"assets/dist/blogeditor.css"},
Options: []string{"htmlColumn", "uploadFolder"},
CheckOptions: checkBlogOptions,
Decode: decode,
Load: loadBlogHTML,
ListValue: blogListValue,
Upload: upload,
Name: "mlblogeditor",
Element: BlogEditorElement,
Translatable: true,
Scripts: []string{"assets/dist/blogeditor.js"},
Styles: []string{"assets/dist/blogeditor.css"},
Options: []string{"htmlColumn", "uploadFolder"},
CheckOptions: checkBlogOptions,
Decode: decode,
Load: loadBlogHTML,
ListValue: blogListValue,
Upload: upload,
RequestOptions: options,
},
}
}
@@ -194,6 +198,25 @@ func h1ConvertedNotice(record any) string {
return EditorH1Converted
}
// blogRequestOptions is the Blog Editor types' RequestOptions (33-01
// P33-02, UI-SPEC C28): headingLevels, [2, 3, 4] while the H1 setting is on
// and [1, 2, 3, 4] while it is off (J1), and genericAnchors, the settings'
// generic link texts (J2, J3). Both are read per schema request, so a
// changed setting reaches the next form that opens. db may be nil; the
// defaults then apply. fields.yaml options with the same names win.
func blogRequestOptions(app func() *backpack.App) func(context.Context, *gorm.DB) (map[string]any, error) {
return func(ctx context.Context, db *gorm.DB) (map[string]any, error) {
levels := []int{2, 3, 4}
if !EffectiveH1ToH2(ctx, db, appConfig(app)) {
levels = []int{1, 2, 3, 4}
}
return map[string]any{
"headingLevels": levels,
"genericAnchors": GenericAnchors(ctx, db),
}, nil
}
}
// appConfig is the config of the application app answers, nil when there
// is none.
func appConfig(app func() *backpack.App) *compass.Config {

View File

@@ -615,3 +615,54 @@ func TestSetImageAlt(t *testing.T) {
t.Fatalf("no RecordSaved with the default and pl content_html: %#v", records.savedEvents())
}
}
// TestBlogEditorRequestOptions (J1, UI-SPEC C28): the post and category
// editor fields serve headingLevels and genericAnchors per form schema
// request, following the H1 setting and the settings' generic link texts.
func TestBlogEditorRequestOptions(t *testing.T) {
env := newPostsEnv(t)
token := env.admin(t, controllers.PermissionAccessPosts, controllers.PermissionAccessCategories)
options := func(t *testing.T, path string) map[string]any {
t.Helper()
rec := env.expect(t, http.StatusOK, http.MethodGet, adminAPI(path+"/schema/form"), token, nil)
var body struct {
Data struct {
Fields []map[string]any `json:"fields"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("schema %s: %v", rec.Body.String(), err)
}
for _, f := range body.Data.Fields {
if f["name"] == "content" {
opts, _ := f["typeOptions"].(map[string]any)
return opts
}
}
t.Fatalf("schema has no content field: %s", rec.Body.String())
return nil
}
for _, path := range []string{adminPostsPath, adminCategoriesPath} {
setH1ToH2(t, env, true)
opts := options(t, path)
anchors, _ := opts["genericAnchors"].([]any)
if fmt.Sprint(opts["headingLevels"]) != "[2 3 4]" || len(anchors) == 0 || anchors[0] != "click here" {
t.Fatalf("%s options with the setting on = %v", path, opts)
}
if opts["uploadFolder"] == nil {
t.Fatalf("%s lost its fields.yaml options: %v", path, opts)
}
setH1ToH2(t, env, false)
if err := env.gdb.Exec(`UPDATE golem15_journal_settings SET generic_anchors = ? WHERE id = 1`, "Tutaj\n\nmore info").Error; err != nil {
t.Fatal(err)
}
opts = options(t, path)
if fmt.Sprint(opts["headingLevels"]) != "[1 2 3 4]" || fmt.Sprint(opts["genericAnchors"]) != "[tutaj more info]" {
t.Fatalf("%s options with the setting off = %v", path, opts)
}
if err := env.gdb.Exec(`UPDATE golem15_journal_settings SET generic_anchors = NULL WHERE id = 1`).Error; err != nil {
t.Fatal(err)
}
}
setH1ToH2(t, env, true)
}

View File

@@ -0,0 +1,209 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { readOptions } from '../../assets/src/blogeditor/element'
import { mediaFileName, slugFileStem } from '../../assets/src/blogeditor/imageform'
import { labelsFor, plural } from '../../assets/src/blogeditor/labels'
import { isExternalUrl } from '../../assets/src/blogeditor/linkform'
import { normalizeAnchor, OUTLINE_DEBOUNCE_MS } from '../../assets/src/blogeditor/outline'
import { button, chooseImage, click, fill, form, insertAtBlock, mount, status } from './helpers'
afterEach(() => {
vi.useRealTimers()
document.body.replaceChildren()
})
const ON = JSON.stringify({ headingLevels: [2, 3, 4], genericAnchors: ['click here', 'more', 'kliknij tutaj'] })
const OFF = JSON.stringify({ headingLevels: [1, 2, 3, 4], genericAnchors: ['click here'] })
function outlineToggle(el: HTMLElement): HTMLButtonElement {
return el.querySelector<HTMLButtonElement>('.blogeditor__outline-toggle')!
}
function openOutline(el: HTMLElement): HTMLElement {
click(outlineToggle(el))
return el.querySelector<HTMLElement>('.blogeditor__outline-panel')!
}
describe('options', () => {
it('reads headingLevels and genericAnchors with defaults', () => {
expect(readOptions(null)).toEqual({ headingLevels: [2, 3, 4], genericAnchors: expect.arrayContaining(['click here', 'czytaj więcej']) })
expect(readOptions('not json').headingLevels).toEqual([2, 3, 4])
expect(readOptions(JSON.stringify({ headingLevels: [4, 1, 9, 'x', 2.5, 2], genericAnchors: [' Read More! ', 3] }))).toEqual({
headingLevels: [1, 2, 4],
genericAnchors: ['read more'],
})
})
})
describe('J1 heading levels', () => {
it('offers H2 to H4 and parses an H1 as an H2 while the H1 setting is on', () => {
const { el, editor } = mount('<h1 class="lead">Title</h1><p>x</p><h5>Small</h5>', { options: ON })
expect(el.querySelector('button[data-command="heading1"]')).toBeNull()
expect(button(el, 'heading2')).toBeTruthy()
const first = editor.state.doc.firstChild!
expect(first.type.name).toBe('heading')
expect(first.attrs.level).toBe(2)
// Untouched, the value is the exact string (D-03).
expect(el.value).toBe('<h1 class="lead">Title</h1><p>x</p><h5>Small</h5>')
insertAtBlock(editor, 1, 'y')
expect(el.value).toBe('<h1 class="lead">Title</h1><p>yx</p><h5>Small</h5>')
insertAtBlock(editor, 0, 'A ')
expect(el.value).toBe('<h2 class="lead">A Title</h2><p>yx</p><h5>Small</h5>')
expect(editor.can().toggleHeading({ level: 1 as 2 })).toBe(false)
})
it('offers H1 to H4 and keeps an H1 while the H1 setting is off', () => {
const { el, editor } = mount('<h1>Title</h1>', { options: OFF })
const commands = Array.from(el.querySelectorAll<HTMLButtonElement>('.blogeditor__group button')).map((b) => b.dataset.command)
expect(commands.slice(0, 5)).toEqual(['paragraph', 'heading1', 'heading2', 'heading3', 'heading4'])
expect(button(el, 'heading1').getAttribute('aria-label')).toBe('Heading 1')
insertAtBlock(editor, 0, 'A ')
expect(el.value).toBe('<h1>A Title</h1>')
})
})
describe('J2 outline', () => {
it('starts collapsed, never takes focus and counts headings and problems', () => {
const { el } = mount('<h3>Deep</h3><h2></h2><h2><a href="/x">Only link</a></h2><p><a href="/y">click here</a></p>', { options: ON })
const toggle = outlineToggle(el)
expect(toggle.getAttribute('aria-expanded')).toBe('false')
expect(document.activeElement).not.toBe(toggle)
expect(el.querySelector<HTMLElement>('.blogeditor__outline-panel')!.hidden).toBe(true)
expect(toggle.textContent).toBe('Outline · 3 headings4 problems')
const panel = openOutline(el)
expect(toggle.getAttribute('aria-expanded')).toBe('true')
const items = Array.from(panel.querySelectorAll('li'))
expect(items.map((li) => li.querySelector('.blogeditor__outline-level')!.textContent)).toEqual(['H3', 'H2', 'H2'])
expect(items[0]!.textContent).toContain('Skipped level: H1 → H3')
expect(items[1]!.textContent).toContain('Empty heading')
expect(items[2]!.textContent).toContain('Heading is only a link')
expect(items[0]!.style.getPropertyValue('--blogeditor-outline-depth')).toBe('1')
expect(panel.querySelector('.blogeditor__outline-links')!.textContent).toBe('LinksLink text “click here” does not say where it leads')
})
it('shows outlineEmpty without headings and uses Polish plurals', () => {
const { el } = mount('<p>Tekst</p>', { options: ON, 'ui-locale': 'pl' })
expect(outlineToggle(el).textContent).toBe('Konspekt · 0 nagłówków')
expect(openOutline(el).textContent).toBe('Brak nagłówków. Nagłówki dzielą tekst na części dla czytelników i wyszukiwarek.')
const pl = labelsFor('pl')
expect(plural(pl, pl.outlineCount, 1)).toBe('1 nagłówek')
expect(plural(pl, pl.outlineCount, 3)).toBe('3 nagłówki')
expect(plural(pl, pl.outlineCount, 5)).toBe('5 nagłówków')
expect(plural(pl, pl.outlineProblems, 22)).toBe('22 problemy')
})
it('recomputes 300 ms after a change and flags an H1 in the Source tab while the setting is on', () => {
vi.useFakeTimers()
const { el, editor, changes } = mount('<h2>One</h2>', { options: ON })
openOutline(el)
insertAtBlock(editor, 0, 'x')
expect(outlineToggle(el).textContent).toBe('Outline · 1 heading')
el.showSource(true)
fill(el.querySelector('textarea'), '<h1>Top</h1><h2>Two</h2>')
vi.advanceTimersByTime(OUTLINE_DEBOUNCE_MS - 1)
expect(outlineToggle(el).textContent).toBe('Outline · 1 heading')
vi.advanceTimersByTime(1)
expect(outlineToggle(el).textContent).toBe('Outline · 2 headings1 problem')
expect(el.querySelector('.blogeditor__outline-panel li')!.textContent).toContain('H1 in the text: saved as H2')
// The outline never edits: only the two user edits changed the value.
expect(changes).toHaveLength(2)
})
it('does not flag an H1 while the setting is off', () => {
const { el } = mount('<h1>Top</h1><h2>Two</h2>', { options: OFF })
el.showSource(true)
expect(outlineToggle(el).textContent).toBe('Outline · 2 headings')
})
})
describe('J3 link form', () => {
it('offers rel only for absolute http(s) URLs and writes the checked tokens', () => {
const { el, editor } = mount('<p>Partner</p>', { options: ON })
editor.commands.setTextSelection({ from: 1, to: 8 })
click(button(el, 'link'))
const linkForm = form(el, 'link')!
const box = (token: string) => linkForm.querySelector<HTMLInputElement>(`input[name="rel-${token}"]`)!
expect(linkForm.querySelector('legend')!.textContent).toBe('Link attributes')
expect(box('nofollow').disabled).toBe(true)
expect(linkForm.querySelector<HTMLElement>('.blogeditor__hint')!.hidden).toBe(false)
fill(linkForm.querySelector('input[name="url"]'), 'https://partner.test/')
expect(box('nofollow').disabled).toBe(false)
expect(linkForm.querySelector<HTMLElement>('.blogeditor__hint')!.hidden).toBe(true)
box('nofollow').checked = true
box('sponsored').checked = true
click(linkForm.querySelector('[data-action="apply"]'))
expect(el.value).toBe('<p><a rel="nofollow sponsored" href="https://partner.test/">Partner</a></p>')
editor.commands.setTextSelection(3)
click(button(el, 'link'))
const again = form(el, 'link')!
expect(again.querySelector<HTMLInputElement>('input[name="rel-sponsored"]')!.checked).toBe(true)
fill(again.querySelector('input[name="url"]'), '/blog/partner')
click(again.querySelector('[data-action="apply"]'))
expect(el.value).toBe('<p><a href="/blog/partner">Partner</a></p>')
})
it('warns about a generic link text without blocking Apply', () => {
const { el, editor } = mount('<p>Click here!</p>', { options: ON })
editor.commands.setTextSelection({ from: 1, to: 12 })
click(button(el, 'link'))
const linkForm = form(el, 'link')!
const warning = linkForm.querySelector<HTMLElement>('[data-warning]')!
expect(warning.hidden).toBe(false)
expect(warning.getAttribute('aria-live')).toBe('polite')
expect(warning.textContent).toBe('“Click here!” does not say where the link leads. Use words that describe the target page.')
fill(linkForm.querySelector('input[name="url"]'), '/blog/x')
click(linkForm.querySelector('[data-action="apply"]'))
expect(el.value).toBe('<p><a href="/blog/x">Click here!</a></p>')
})
it('treats only absolute http(s) URLs as external', () => {
for (const url of ['https://a.test', 'HTTP://a.test/x']) expect(isExternalUrl(url), url).toBe(true)
for (const url of ['/a', 'a.test', 'mailto:a@b.test', '//a.test', 'https://', '#x']) expect(isExternalUrl(url), url).toBe(false)
expect(normalizeAnchor(' Read MORE… ')).toBe('read more')
})
})
describe('J4 image naming', () => {
const pick = (el: HTMLElement, files: File[]) => {
const input = el.querySelector<HTMLInputElement>('input[type="file"]')!
Object.defineProperty(input, 'files', { value: files, configurable: true })
input.dispatchEvent(new Event('change'))
}
const answer = (el: HTMLElement, detail: Record<string, string>) => el.dispatchEvent(new CustomEvent('summer-upload-result', { detail }))
it('slugs the file name before the upload (UI-SPEC J4 backstop)', () => {
expect(mediaFileName('Lwia Grzywa – w lesie!.JPG')).toBe('lwia-grzywa-w-lesie.jpg')
expect(mediaFileName('.png')).toBe('image.png')
expect(slugFileStem('')).toBe('image')
expect(slugFileStem('ŻÓŁĆ gęślą jaźń')).toBe('zolc-gesla-jazn')
expect(slugFileStem('a'.repeat(81))).toHaveLength(80)
expect(slugFileStem('a'.repeat(79) + ' b')).toBe('a'.repeat(79))
const { el, uploads } = mount('', { options: ON })
pick(el, [new File([new Uint8Array([255, 216, 255])], 'Lwia Grzywa – w lesie!.JPG', { type: 'image/jpeg' })])
const imageForm = form(el, 'image')!
const name = imageForm.querySelector<HTMLInputElement>('input[name="fileName"]')!
expect(name.value).toBe('lwia-grzywa-w-lesie')
expect(imageForm.querySelector('.blogeditor__ext')!.textContent).toBe('.jpg')
fill(name, 'Lew w Lesie')
name.dispatchEvent(new Event('blur'))
expect(name.value).toBe('lew-w-lesie')
chooseImage(el, { alt: 'Lew' })
expect(uploads[0]!.file.name).toBe('lew-w-lesie.jpg')
expect(uploads[0]!.file.type).toBe('image/jpeg')
expect(status(el)).toBe('Uploading lew-w-lesie.jpg…')
})
it('inserts alt="" for a decorative image', () => {
const { el, uploads } = mount('', { options: ON })
pick(el, [new File([new Uint8Array([137, 80, 78, 71])], 'kreska.png', { type: 'image/png' })])
const imageForm = form(el, 'image')!
const alt = imageForm.querySelector<HTMLInputElement>('input[name="alt"]')!
fill(alt, 'temporary')
chooseImage(el, { decorative: true })
expect(alt.value).toBe('')
expect(alt.disabled).toBe(true)
answer(el, { id: uploads[0]!.id, url: '/kreska.png' })
expect(el.value).toBe('<p><img src="/kreska.png" alt=""></p>')
})
})

View File

@@ -4,7 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { EMBED_PROVIDERS, isEmbedProvider, parseEmbedUrl } from '../../assets/src/blogeditor/embed'
import { isPolicyUrl } from '../../assets/src/blogeditor/extensions'
import { format, labelsFor } from '../../assets/src/blogeditor/labels'
import { button, click, fill, form, mount, status } from './helpers'
import { button, chooseImage, click, fill, form, mount, status } from './helpers'
afterEach(() => {
vi.useRealTimers()
@@ -179,33 +179,36 @@ describe('upload flow', () => {
}
it('ignores an answer with an id it did not send', () => {
const { el, uploads, changes } = mount('<p>a</p>')
const { el, uploads } = mount('<p>a</p>')
pick(el, [png()])
expect(uploads).toHaveLength(0)
chooseImage(el, { alt: 'Grzyb' })
answer(el, { id: 'not-ours', url: '/x.png' })
answer(el, { id: 'not-ours', error: 'nope' })
expect(form(el, 'image')).toBeNull()
expect(status(el)).toBe('Uploading grzyb.png…')
expect(el.value).toBe('<p>a</p>')
answer(el, { id: uploads[0]!.id, url: '/ours.png' })
expect(form(el, 'image')).not.toBeNull()
expect(changes).toEqual([])
expect(el.value).toContain('<img src="/ours.png" alt="Grzyb">')
})
it('times out after 60 seconds and ignores the late answer', () => {
vi.useFakeTimers()
const { el, uploads } = mount('')
pick(el, [png()])
chooseImage(el, { alt: 'Grzyb' })
vi.advanceTimersByTime(59_999)
expect(status(el)).toBe('Uploading grzyb.png…')
vi.advanceTimersByTime(1)
expect(status(el)).toBe('The upload of grzyb.png took too long.')
answer(el, { id: uploads[0]!.id, url: '/late.png' })
expect(form(el, 'image')).toBeNull()
expect(el.value).toBe('')
})
it('answers each of several uploads by its own id', () => {
const { el, uploads } = mount('')
pick(el, [png(), new File([new Uint8Array([255, 216, 255])], 'b.jpg', { type: 'image/jpeg' })])
chooseImage(el, { alt: 'A' })
chooseImage(el, { alt: 'B' })
expect(uploads).toHaveLength(2)
expect(uploads[0]!.id).not.toBe(uploads[1]!.id)
answer(el, { id: uploads[1]!.id, error: 'Too big' })
@@ -215,6 +218,7 @@ describe('upload flow', () => {
it('refuses an uploaded URL outside the policy', () => {
const { el, uploads } = mount('')
pick(el, [png()])
chooseImage(el, { alt: 'Grzyb' })
answer(el, { id: uploads[0]!.id, url: 'javascript:alert(1)' })
expect(form(el, 'image')).toBeNull()
expect(status(el)).toMatch(/^Upload failed/)

View File

@@ -76,3 +76,21 @@ export function fill(input: Element | null | undefined, value: string): void {
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"]'))
}

View File

@@ -4,7 +4,7 @@ import { BlogEditorElement, TAG } from '../../assets/src/blogeditor/index'
import { uploadRequestId } from '../../assets/src/blogeditor/element'
import { parseEmbedUrl } from '../../assets/src/blogeditor/embed'
import { isPolicyUrl } from '../../assets/src/blogeditor/extensions'
import { button, click, fill, form, insertAtBlock, insertAtEveryTextblock, mount, status } from './helpers'
import { button, chooseImage, click, fill, form, insertAtBlock, insertAtEveryTextblock, mount, status } from './helpers'
afterEach(() => {
vi.useRealTimers()
@@ -320,11 +320,12 @@ describe('image uploads', () => {
const { el, uploads } = mount('')
click(button(el, 'image'))
pick(el, [png()])
chooseImage(el, { alt: 'Grzyb' })
expect(uploads).toHaveLength(1)
expect(uploads[0]!.id).toMatch(uuid)
expect(status(el)).toBe('Uploading grzyb.png…')
answer(el, { id: uploads[0]!.id, url: '/storage/app/uploads/public/journal/grzyb.png' })
expect(form(el, 'image')).not.toBeNull()
expect(el.value).toContain('grzyb.png')
vi.stubGlobal('crypto', undefined)
const plain = [uploadRequestId(), uploadRequestId()]
@@ -336,44 +337,45 @@ describe('image uploads', () => {
expect(globalThis.crypto).toBe(real)
})
it('sends a picked image as summer-upload and inserts img with alt text', () => {
it('opens the image form for a picked image and uploads it on Insert with the alt text (J4)', () => {
const { el, uploads } = mount('')
click(button(el, 'image'))
pick(el, [png()])
expect(uploads).toHaveLength(0)
const imageForm = form(el, 'image')!
expect((imageForm.querySelector('input[name="fileName"]') as HTMLInputElement).value).toBe('grzyb')
expect(imageForm.querySelector('.blogeditor__ext')!.textContent).toBe('.png')
click(imageForm.querySelector('[data-action="insert"]'))
expect(imageForm.querySelector('[data-error]')!.textContent).toBe('Enter the alt text, or mark the image as decorative.')
expect(uploads).toHaveLength(0)
chooseImage(el, { alt: 'Grzyb: czy to "Turkey Tail"?' })
expect(form(el, 'image')).toBeNull()
expect(uploads).toHaveLength(1)
expect(uploads[0]!.file.name).toBe('grzyb.png')
expect(uploads[0]!.id).toMatch(/[0-9a-f-]{36}/)
expect(status(el)).toBe('Uploading grzyb.png…')
answer(el, { id: 'someone-else', url: '/x.png' })
expect(form(el, 'image')).toBeNull()
answer(el, { id: uploads[0]!.id, url: '/storage/app/uploads/public/journal/grzyb.png' })
const imageForm = form(el, 'image')!
click(imageForm.querySelector('[data-action="insert"]'))
expect(imageForm.querySelector('[data-error]')!.textContent).toBe('Enter the alt text.')
expect(el.value).toBe('')
fill(imageForm.querySelector('input[name="alt"]'), 'Grzyb: czy to "Turkey Tail"?')
click(imageForm.querySelector('[data-action="insert"]'))
expect(form(el, 'image')).toBeNull()
answer(el, { id: 'someone-else', url: '/x.png' })
expect(el.value).toBe('')
answer(el, { id: uploads[0]!.id, url: '/storage/app/uploads/public/journal/grzyb.png' })
expect(status(el)).toBe('')
expect(el.value).toBe('<p><img src="/storage/app/uploads/public/journal/grzyb.png" alt="Grzyb: czy to &quot;Turkey Tail&quot;?"></p>')
})
it('inserts figure with figcaption when a caption is given', () => {
const { el, uploads } = mount('')
pick(el, [png()])
chooseImage(el, { alt: 'Owocniki', caption: 'Owocniki na pniu.' })
answer(el, { id: uploads[0]!.id, url: '/a.png' })
const imageForm = form(el, 'image')!
fill(imageForm.querySelector('input[name="alt"]'), 'Owocniki')
fill(imageForm.querySelector('input[name="caption"]'), 'Owocniki na pniu.')
click(imageForm.querySelector('[data-action="insert"]'))
expect(el.value).toBe('<figure><img src="/a.png" alt="Owocniki"><figcaption>Owocniki na pniu.</figcaption></figure>')
})
it('shows an upload error and inserts nothing', () => {
const { el, uploads, changes } = mount('<p>a</p>')
pick(el, [png()])
chooseImage(el, { alt: 'Grzyb' })
answer(el, { id: uploads[0]!.id, error: 'File too large' })
expect(form(el, 'image')).toBeNull()
expect(status(el)).toBe('Upload failed: File too large')
@@ -385,13 +387,14 @@ describe('image uploads', () => {
vi.useFakeTimers()
const { el, uploads } = mount('')
pick(el, [png()])
chooseImage(el, { alt: 'Grzyb' })
vi.advanceTimersByTime(60_000)
expect(status(el)).toBe('The upload of grzyb.png took too long.')
answer(el, { id: uploads[0]!.id, url: '/late.png' })
expect(form(el, 'image')).toBeNull()
expect(el.value).toBe('')
})
it('uploads image files dropped on the editor', () => {
it('opens the image form for image files dropped on the editor', () => {
const { el, editor, uploads } = mount('<p>a</p>')
let prevented = false
const event = {
@@ -403,6 +406,8 @@ describe('image uploads', () => {
const handled = editor.view.someProp('handleDrop', (f) => f(editor.view, event, editor.state.doc.slice(0, 0), false))
expect(handled).toBe(true)
expect(prevented).toBe(true)
expect(form(el, 'image')).not.toBeNull()
chooseImage(el, { alt: 'Grzyb' })
expect(uploads.map((u) => u.file.name)).toEqual(['grzyb.png'])
expect(el.value).toBe('<p>a</p>')
})