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:
2
assets/dist/blogeditor.css
vendored
2
assets/dist/blogeditor.css
vendored
File diff suppressed because one or more lines are too long
94
assets/dist/blogeditor.js
vendored
94
assets/dist/blogeditor.js
vendored
File diff suppressed because one or more lines are too long
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
167
assets/src/blogeditor/imageform.ts
Normal file
167
assets/src/blogeditor/imageform.ts
Normal 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,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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) })
|
||||
}
|
||||
|
||||
108
assets/src/blogeditor/linkform.ts
Normal file
108
assets/src/blogeditor/linkform.ts
Normal 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() }))] : []))
|
||||
},
|
||||
}
|
||||
}
|
||||
302
assets/src/blogeditor/outline.ts
Normal file
302
assets/src/blogeditor/outline.ts
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
209
tests/blogeditor/additions.test.ts
Normal file
209
tests/blogeditor/additions.test.ts
Normal 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>')
|
||||
})
|
||||
})
|
||||
@@ -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/)
|
||||
|
||||
@@ -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"]'))
|
||||
}
|
||||
|
||||
@@ -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 "Turkey Tail"?"></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>')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user