Files
sm-journal-plugin/assets/src/blogeditor/element.ts
Jakub Zych b997a416a7 fix(16.2): IN-04 make upload ids without crypto.randomUUID
crypto.randomUUID exists only in a secure context, so an admin served
over plain HTTP from a LAN host threw on every editor upload while the
status line still said uploading. uploadRequestId falls back to a v4
UUID from crypto.getRandomValues, or a time and counter id when no
crypto object exists.
2026-10-10 16:39:29 +02:00

666 lines
23 KiB
TypeScript

import { Editor } from '@tiptap/core'
import { parseEmbedUrl } from './embed'
import { EDIT_HTML_EVENT, isPolicyUrl, preservationExtensions } from './extensions'
import { format, labelsFor, type Labels } from './labels'
import { buildPreviewDocument, PREVIEW_DEBOUNCE_MS, PREVIEW_SANDBOX, resolvePreviewColors } from './preview'
import { captureBlocks, serializeDocument, type Blocks } from './serializer'
import { buildToolbar, type Toolbar } from './toolbar'
export const TAG = 'golem15-journal-blogeditor'
export const CHANGE_EVENT = 'summer-change'
export const UPLOAD_EVENT = 'summer-upload'
export const UPLOAD_RESULT_EVENT = 'summer-upload-result'
/** The image types the picker offers and the editor uploads (D-18). */
export const IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
/** How long the element waits for summer-upload-result. */
export const UPLOAD_TIMEOUT_MS = 60_000
type Mode = 'clean' | 'wysiwyg' | 'source'
interface InlineForm {
root: HTMLDivElement
error: HTMLParagraphElement
}
/**
* BlogEditorElement is the Journal Blog Editor (D-01, D-03, D-13): a light-DOM
* custom element with a WYSIWYG tab on TipTap and a Source tab that edits the
* exact HTML string.
*
* Contract: the value property holds the HTML string; the attributes are
* field-name, record-id, locale, ui-locale, options (JSON), disabled and
* invalid; a bubbling summer-change event with detail.value is dispatched on
* user edits only. The element returns the exact string it was given until
* the user edits, and setting value to the string it currently returns is a
* no-op. Images are handed to the host page as summer-upload {id, file} and
* inserted when it answers summer-upload-result {id, url} (or shows
* {id, error}). It never receives or sends credentials and never sanitizes:
* the server does that on save.
*/
export class BlogEditorElement extends HTMLElement {
static observedAttributes = ['disabled', 'invalid', 'locale']
/** The exact string the element was given (D-03). */
#value = ''
/** The text of the Source tab while it defines the value. */
#sourceText = ''
/** The string the editor content was last parsed from. */
#parsed = ''
/** That string split into top-level blocks, for block reuse. */
#blocks: Blocks | null = null
#mode: Mode = 'clean'
#editor?: Editor
#labels: Labels = labelsFor('en')
#toolbar?: Toolbar
#mount?: HTMLDivElement
#textarea?: HTMLTextAreaElement
#status?: HTMLParagraphElement
#fileInput?: HTMLInputElement
#linkForm?: InlineForm
#embedForm?: InlineForm
#imageForm?: InlineForm
#showingSource = false
#pending = new Map<string, { name: string; timer: ReturnType<typeof setTimeout> }>()
#ready: { url: string; name: string }[] = []
/** Whether this element shows the preview pane (per instance, off by default). */
#previewOn = false
#panes?: HTMLDivElement
#previewPane?: HTMLDivElement
#previewFrame?: HTMLIFrameElement
#previewTimer?: ReturnType<typeof setTimeout>
constructor() {
super()
// A value set on the element before the tag was defined is an own
// property that shadows the accessor; move it through the setter.
if (Object.prototype.hasOwnProperty.call(this, 'value')) {
const early = (this as unknown as { value: unknown }).value
delete (this as unknown as { value?: unknown }).value
this.value = early as string
}
}
get value(): string {
switch (this.#mode) {
case 'source':
return this.#sourceText
case 'wysiwyg':
return this.#editor ? serializeDocument(this.#editor, this.#blocks) : this.#value
default:
return this.#value
}
}
set value(next: string) {
const v = typeof next === 'string' ? next : next == null ? '' : String(next)
if (v === this.value) return // echo of our own summer-change
this.#value = v
this.#mode = 'clean'
if (this.#textarea) this.#textarea.value = v
this.#load(v)
this.#schedulePreview()
}
/** The TipTap editor while the element is connected (toolbar and tests). */
get editor(): Editor | undefined {
return this.#editor
}
connectedCallback(): void {
if (this.#editor) return
this.classList.add('blogeditor')
this.replaceChildren()
this.#labels = labelsFor(this.getAttribute('ui-locale'))
const labels = this.#labels
this.#toolbar = buildToolbar({
editor: () => this.#editor,
labels,
openLink: () => this.#openLink(),
openEmbed: () => this.#openEmbed(),
pickImage: () => this.#fileInput?.click(),
insertRawHtml: () => this.#insertRawHtml(),
showSource: (on) => this.showSource(on),
togglePreview: () => this.showPreview(!this.#previewOn),
})
this.#linkForm = this.#form('link', labels.linkUrl, [
['apply', labels.linkApply, () => this.#applyLink()],
['remove', labels.linkRemove, () => this.#removeLink()],
['cancel', labels.cancel, () => this.#close(this.#linkForm)],
])
this.#embedForm = this.#form('embed', labels.embedUrl, [
['insert', labels.embedInsert, () => this.#insertEmbed()],
['cancel', labels.cancel, () => this.#close(this.#embedForm)],
])
this.#imageForm = this.#imageFormElement()
const mount = document.createElement('div')
mount.className = 'blogeditor__content'
this.#mount = mount
const textarea = document.createElement('textarea')
textarea.className = 'blogeditor__source'
textarea.spellcheck = false
textarea.hidden = true
textarea.setAttribute('aria-label', labels.tabSource)
textarea.value = this.value
textarea.addEventListener('input', () => this.#sourceInput())
this.#textarea = textarea
const status = document.createElement('p')
status.className = 'blogeditor__status'
status.setAttribute('role', 'status')
status.setAttribute('aria-live', 'polite')
this.#status = status
const fileInput = document.createElement('input')
fileInput.type = 'file'
fileInput.accept = IMAGE_TYPES.join(',')
fileInput.multiple = true
fileInput.hidden = true
fileInput.addEventListener('change', () => this.#picked())
this.#fileInput = fileInput
// The editor and the Source tab sit in the main pane; the preview pane
// beside it is hidden until the toggle is pressed.
const main = document.createElement('div')
main.className = 'blogeditor__main'
main.append(mount, textarea)
// The frame itself is created on the first switch-on (#previewFrameOf),
// so an element that never previews holds no iframe.
const previewPane = document.createElement('div')
previewPane.className = 'blogeditor__preview'
previewPane.hidden = true
this.#previewPane = previewPane
this.#previewFrame = undefined
const panes = document.createElement('div')
panes.className = 'blogeditor__panes'
panes.append(main, previewPane)
this.#panes = panes
this.append(
this.#toolbar.element,
this.#linkForm.root,
this.#embedForm.root,
this.#imageForm.root,
panes,
status,
fileInput,
)
this.addEventListener(UPLOAD_RESULT_EVENT, this.#onUploadResult)
this.addEventListener(EDIT_HTML_EVENT, this.#onEditHtml)
const initial = this.value
this.#parsed = initial
this.#editor = new Editor({
element: mount,
injectCSS: false,
editable: !this.hasAttribute('disabled'),
extensions: preservationExtensions(labels),
content: initial,
editorProps: {
handleDrop: (_view, event, _slice, moved) => {
if (moved) return false
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)
return true
},
},
onUpdate: ({ transaction, appendedTransactions }) => {
if (!transaction.docChanged && !appendedTransactions.some((tr) => tr.docChanged)) return
this.#mode = 'wysiwyg'
this.#emitChange()
this.#schedulePreview()
},
onTransaction: () => this.#toolbar?.refresh(),
})
this.#blocks = captureBlocks(initial, this.#editor.schema)
this.#syncAttributes()
this.showSource(false)
if (this.#previewOn) this.showPreview(true)
}
disconnectedCallback(): void {
this.removeEventListener(UPLOAD_RESULT_EVENT, this.#onUploadResult)
this.removeEventListener(EDIT_HTML_EVENT, this.#onEditHtml)
for (const { timer } of this.#pending.values()) clearTimeout(timer)
this.#pending.clear()
clearTimeout(this.#previewTimer)
this.#previewTimer = undefined
this.#ready = []
// Keep an edited value readable while the element is detached.
if (this.#mode === 'wysiwyg' && this.#editor) {
this.#value = this.value
this.#mode = 'clean'
}
this.#editor?.destroy()
this.#editor = undefined
}
attributeChangedCallback(): void {
this.#syncAttributes()
this.#schedulePreview()
}
/** Switches between the WYSIWYG and the Source tab. */
showSource(on: boolean): void {
if (!this.#textarea || !this.#mount) return
if (on) {
this.#textarea.value = this.value
} else if (this.#showingSource && this.#mode === 'source' && this.#sourceText !== this.#parsed) {
// Re-parse only when the source text changed; the value stays the
// exact text until the next WYSIWYG edit.
this.#load(this.#sourceText)
}
this.#showingSource = on
this.#textarea.hidden = !on
this.#mount.hidden = on
if (on) {
this.#close(this.#linkForm)
this.#close(this.#embedForm)
}
this.#toolbar?.selectTab(on)
}
/**
* Shows or hides the preview pane. The preview is read-only: it reads the
* value getter and never edits, emits summer-change or changes the value.
*/
showPreview(on: boolean): void {
this.#previewOn = on
if (!this.#panes || !this.#previewPane) return
this.#previewPane.hidden = !on
this.#panes.classList.toggle('blogeditor__panes--split', on)
this.#toolbar?.setPreview(on)
if (on) {
this.#renderPreview()
} else {
clearTimeout(this.#previewTimer)
this.#previewTimer = undefined
}
}
/**
* Renders the current value into the preview frame. It reads only the
* value getter: an untouched document returns the exact string without
* the serializer, and nothing in the stored-value path is written.
*/
#renderPreview(): void {
clearTimeout(this.#previewTimer)
this.#previewTimer = undefined
if (!this.#previewOn) return
const frame = this.#previewFrameOf()
if (!frame) return
const html = this.value
const doc = buildPreviewDocument(html, {
labels: this.#labels,
lang: this.getAttribute('locale'),
...resolvePreviewColors(this),
})
// An identical document would only reload the frame.
if (frame.getAttribute('srcdoc') !== doc) frame.srcdoc = doc
}
/**
* Re-renders the preview PREVIEW_DEBOUNCE_MS after the last change, in
* both tabs; nothing is scheduled while the preview is off.
*/
#schedulePreview(): void {
if (!this.#previewOn || !this.#previewFrame) return
clearTimeout(this.#previewTimer)
this.#previewTimer = setTimeout(() => this.#renderPreview(), PREVIEW_DEBOUNCE_MS)
}
/** Returns the preview frame, creating it in the pane on first use. */
#previewFrameOf(): HTMLIFrameElement | undefined {
if (this.#previewFrame) return this.#previewFrame
if (!this.#previewPane) return undefined
const frame = document.createElement('iframe')
// An empty sandbox: no scripts, an opaque origin. Content enters only
// through srcdoc; there is never a src, an allow or another token.
frame.setAttribute('sandbox', PREVIEW_SANDBOX)
frame.setAttribute('referrerpolicy', 'no-referrer')
frame.title = this.#labels.previewFrame
this.#previewPane.append(frame)
this.#previewFrame = frame
return frame
}
/**
* Parses html into the editor without an update event (Pitfall 4) and
* outside the undo history, so undo never brings back a replaced value.
*/
#load(html: string): void {
this.#parsed = html
if (!this.#editor) return
this.#editor.chain().setMeta('addToHistory', false).setContent(html, { emitUpdate: false }).run()
this.#blocks = captureBlocks(html, this.#editor.schema)
}
#sourceInput(): void {
if (!this.#textarea) return
this.#sourceText = this.#textarea.value
this.#mode = 'source'
this.#emitChange()
this.#schedulePreview()
}
#emitChange(): void {
this.dispatchEvent(new CustomEvent(CHANGE_EVENT, { bubbles: true, composed: true, detail: { value: this.value } }))
}
#setStatus(text: string): void {
if (this.#status) this.#status.textContent = text
}
#syncAttributes(): void {
const disabled = this.hasAttribute('disabled')
const invalid = this.hasAttribute('invalid')
if (this.#editor && this.#editor.isEditable === disabled) this.#editor.setEditable(!disabled, false)
this.#toolbar?.setDisabled(disabled)
if (this.#textarea) {
this.#textarea.readOnly = disabled
if (invalid) this.#textarea.setAttribute('aria-invalid', 'true')
else this.#textarea.removeAttribute('aria-invalid')
}
const view = this.#editor?.view.dom
if (view) {
if (invalid) view.setAttribute('aria-invalid', 'true')
else view.removeAttribute('aria-invalid')
const locale = this.getAttribute('locale')
if (locale) view.setAttribute('lang', locale)
else view.removeAttribute('lang')
}
}
// Inline forms -----------------------------------------------------------
#form(kind: string, label: string, actions: [string, string, () => void][]): InlineForm {
const root = document.createElement('div')
root.className = 'blogeditor__form'
root.dataset.form = kind
root.hidden = true
const field = document.createElement('label')
const caption = document.createElement('span')
caption.textContent = label
const input = document.createElement('input')
input.type = 'text'
input.name = 'url'
input.autocomplete = 'off'
input.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault()
actions[0]![2]()
} else if (event.key === 'Escape') {
event.preventDefault()
this.#close(form)
}
})
field.append(caption, input)
root.append(field)
for (const [action, text, run] of actions) root.append(this.#action(action, text, run))
const error = document.createElement('p')
error.className = 'blogeditor__error'
error.dataset.error = ''
error.setAttribute('aria-live', 'polite')
root.append(error)
const form = { root, error }
return form
}
#action(action: string, text: string, run: () => void): HTMLButtonElement {
const button = document.createElement('button')
button.type = 'button'
button.dataset.action = action
button.textContent = text
button.addEventListener('click', run)
return button
}
#open(form: InlineForm | undefined, value = ''): void {
if (!form) return
for (const other of [this.#linkForm, this.#embedForm]) if (other && other !== form) this.#close(other)
form.error.textContent = ''
form.root.hidden = false
const input = form.root.querySelector('input')
if (input) {
input.value = value
input.focus()
}
}
#close(form: InlineForm | undefined): void {
if (!form) return
form.root.hidden = true
form.error.textContent = ''
}
#inputOf(form: InlineForm | undefined, name = 'url'): string {
return (form?.root.querySelector<HTMLInputElement>(`input[name="${name}"]`)?.value ?? '').trim()
}
// Links ----------------------------------------------------------------------
#openLink(): void {
if (!this.#editor?.isEditable) return
const href = this.#editor.getAttributes('link').href
this.#open(this.#linkForm, typeof href === 'string' ? href : '')
}
#applyLink(): void {
const editor = this.#editor
if (!editor || !this.#linkForm) return
const url = this.#inputOf(this.#linkForm)
if (url === '') {
this.#removeLink()
return
}
if (!isPolicyUrl(url)) {
this.#linkForm.error.textContent = this.#labels.linkInvalid
return
}
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 } }] })
.unsetMark('link')
.run()
} else {
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
}
this.#close(this.#linkForm)
}
#removeLink(): void {
this.#editor?.chain().focus().extendMarkRange('link').unsetLink().run()
this.#close(this.#linkForm)
}
// Embeds ---------------------------------------------------------------------
#openEmbed(): void {
if (!this.#editor?.isEditable) return
this.#open(this.#embedForm)
}
#insertEmbed(): void {
if (!this.#editor || !this.#embedForm) return
const embed = parseEmbedUrl(this.#inputOf(this.#embedForm))
if (!embed) {
this.#embedForm.error.textContent = this.#labels.embedInvalid
return
}
this.#editor.chain().focus().setEmbed(embed).run()
this.#close(this.#embedForm)
}
// Raw HTML -------------------------------------------------------------------
#insertRawHtml(): void {
const editor = this.#editor
if (!editor?.isEditable) return
const before = this.value
const html = '<div></div>'
if (!editor.chain().focus().insertRawHtml(html).run()) return
const after = this.value
let at = 0
while (at < before.length && at < after.length && before[at] === after[at]) at++
this.showSource(true)
const caret = after.startsWith(html, at) ? at + '<div>'.length : at
this.#textarea?.focus()
this.#textarea?.setSelectionRange(caret, caret)
}
#onEditHtml = (event: Event): void => {
const html = (event as CustomEvent<{ html?: string }>).detail?.html ?? ''
this.showSource(true)
const textarea = this.#textarea
if (!textarea) return
textarea.focus()
const at = html ? textarea.value.indexOf(html) : -1
if (at >= 0) textarea.setSelectionRange(at, at + html.length)
}
// Uploads (D-18) -------------------------------------------------------------
#picked(): void {
const input = this.#fileInput
if (!input) return
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)
try {
input.value = ''
} catch {
// A test double may not allow resetting the value.
}
}
#upload(file: File): 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.#setStatus(format(this.#labels.uploading, { name }))
this.dispatchEvent(new CustomEvent(UPLOAD_EVENT, { bubbles: true, composed: true, detail: { id, file } }))
}
#onUploadResult = (event: Event): void => {
const detail = (event as CustomEvent<{ id?: unknown; url?: unknown; error?: unknown }>).detail ?? {}
const id = typeof detail.id === 'string' ? detail.id : ''
const pending = this.#pending.get(id)
if (!pending) return
clearTimeout(pending.timer)
this.#pending.delete(id)
const url = typeof detail.url === 'string' ? detail.url : ''
if (typeof detail.error === 'string' || !url || !isPolicyUrl(url)) {
const error = typeof detail.error === 'string' && detail.error ? detail.error : pending.name
this.#setStatus(format(this.#labels.uploadFailed, { error }))
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 }
}
#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()
}
#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()
this.#nextImage()
}
#nextImage(): void {
this.#ready.shift()
if (this.#imageForm) this.#close(this.#imageForm)
this.#showImageForm()
}
}
function isImage(file: File): boolean {
return IMAGE_TYPES.includes(file.type)
}
let uploadCounter = 0
/**
* uploadRequestId returns a new id for a summer-upload request. It is
* crypto.randomUUID where the admin runs in a secure context. Over plain
* HTTP from a host other than localhost randomUUID does not exist, so it is
* a version 4 UUID built from crypto.getRandomValues (available in every
* context), or a time and counter id when no crypto object exists at all.
*/
export function uploadRequestId(): string {
const c: Partial<Crypto> | undefined = globalThis.crypto
if (c && typeof c.randomUUID === 'function') return c.randomUUID()
if (c && typeof c.getRandomValues === 'function') {
const bytes = c.getRandomValues(new Uint8Array(16))
bytes[6] = (bytes[6]! & 0x0f) | 0x40
bytes[8] = (bytes[8]! & 0x3f) | 0x80
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
}
uploadCounter += 1
return `upload-${Date.now().toString(36)}-${uploadCounter}`
}