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.
666 lines
23 KiB
TypeScript
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}`
|
|
}
|