Files
sm-journal-plugin/assets/src/blogeditor/element.ts
Jakub Zych 4b09bd9312 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
2026-11-04 10:35:19 +01:00

754 lines
27 KiB
TypeScript

import { Editor } from '@tiptap/core'
import { parseEmbedUrl } from './embed'
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'
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'
/**
* 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
}
/**
* 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
#linkExtras?: LinkExtras
#embedForm?: InlineForm
#imageForm?: ImageForm
#outline?: Outline
#options: EditorOptions = readOptions(null)
#showingSource = false
#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
#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()
this.#outline?.schedule()
}
/** 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.#options = readOptions(this.getAttribute('options'))
const options = this.#options
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),
headingLevels: options.headingLevels,
})
this.#linkForm = this.#form('link', labels.linkUrl, [
['apply', labels.linkApply, () => this.#applyLink()],
['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 = buildImageForm(labels, () => this.#insertImage(), () => this.#nextImage())
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
// 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,
)
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, options.headingLevels),
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()
this.#enqueue(files)
return true
},
},
onUpdate: ({ transaction, appendedTransactions }) => {
if (!transaction.docChanged && !appendedTransactions.some((tr) => tr.docChanged)) return
this.#mode = 'wysiwyg'
this.#emitChange()
this.#schedulePreview()
this.#outline?.schedule()
},
onTransaction: () => this.#toolbar?.refresh(),
})
this.#blocks = captureBlocks(initial, this.#editor.schema)
this.#syncAttributes()
this.showSource(false)
if (this.#previewOn) this.showPreview(true)
this.#outline.refresh()
}
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.#outline?.stop()
this.#queue = []
// 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)
this.#outline?.refresh()
}
/**
* 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()
this.#outline?.schedule()
}
#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 {
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 {
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
}
// 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, rel } }] })
.unsetMark('link')
.run()
} else {
editor.chain().focus().extendMarkRange('link').setLink({ href: url, rel }).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, J4) -----------------------------------------------------------
#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)
this.#enqueue(images)
try {
input.value = ''
} catch {
// A test double may not allow resetting the value.
}
}
/** 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, choice, 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('')
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
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 form = this.#imageForm
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.#queue.shift()
if (this.#imageForm) this.#imageForm.root.hidden = true
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}`
}