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 }>() #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 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(`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 = '
' 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 + '
'.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('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 | 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}` }