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 = {} try { const value: unknown = raw ? JSON.parse(raw) : {} if (value && typeof value === 'object' && !Array.isArray(value)) parsed = value as Record } 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 }>() /** 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 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(`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 = '
' 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, 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 | 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}` }