feat(editor): blog editor element with exact untouched value

- golem15-journal-blogeditor (BlogEditorElement): light-DOM element with a
  TipTap WYSIWYG tab and a Source tab that edits the exact HTML string
- The value is the exact served string until a user edit; external values
  go in with emitUpdate false; summer-change fires on user edits only and
  an echo set is a no-op
- StarterKit without the trailing node, links without target, rel or class;
  injectCSS false with the ProseMirror base rules in blogeditor.css under
  the element tag, colours from --c-* only
- Committed bundle embedded through admin.go; vitest smoke on an imported
  post fixture (footer, style block, spans, figure, table, entities)
This commit is contained in:
2026-10-10 11:35:51 +02:00
parent 234e6169cc
commit 47f3032dcf
10 changed files with 745 additions and 2 deletions

View File

@@ -0,0 +1,229 @@
/*
* Blog Editor styles (D-13). Every selector sits under the element tag and
* every colour comes from the admin's public --c-* variables; TipTap's own
* injected stylesheet is off (injectCSS false), so its ProseMirror base
* rules are repeated here.
*/
golem15-journal-blogeditor {
display: block;
border: 1px solid var(--c-border);
border-radius: 12px;
background: var(--c-surface);
color: var(--c-text);
}
golem15-journal-blogeditor[invalid] {
border-color: var(--c-danger);
}
golem15-journal-blogeditor .blogeditor__bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
padding: 6px 8px;
border-bottom: 1px solid var(--c-border);
background: var(--c-subtle);
border-radius: 12px 12px 0 0;
}
golem15-journal-blogeditor .blogeditor__tabs {
display: flex;
gap: 2px;
margin-left: auto;
}
golem15-journal-blogeditor button {
font: inherit;
font-size: 13px;
line-height: 1.2;
padding: 4px 8px;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: var(--c-text);
cursor: pointer;
}
golem15-journal-blogeditor button:hover {
background: var(--c-hover);
}
golem15-journal-blogeditor button:focus-visible {
outline: 2px solid var(--c-ring);
outline-offset: 1px;
}
golem15-journal-blogeditor button[aria-selected="true"],
golem15-journal-blogeditor button[aria-pressed="true"] {
background: var(--c-sel);
}
golem15-journal-blogeditor button:disabled {
color: var(--c-muted);
cursor: default;
}
golem15-journal-blogeditor .blogeditor__content {
min-height: 240px;
max-height: 70vh;
overflow: auto;
}
golem15-journal-blogeditor .blogeditor__source {
display: block;
box-sizing: border-box;
width: 100%;
min-height: 320px;
padding: 12px 16px;
border: 0;
border-radius: 0 0 12px 12px;
background: var(--c-bg);
color: var(--c-text);
font-family: "DM Mono", ui-monospace, monospace;
font-size: 13px;
line-height: 1.5;
resize: vertical;
}
golem15-journal-blogeditor .blogeditor__source:focus {
outline: 2px solid var(--c-ring);
outline-offset: -2px;
}
golem15-journal-blogeditor [hidden] {
display: none !important;
}
/* ProseMirror base rules (TipTap's injected style, repeated here). */
golem15-journal-blogeditor .ProseMirror {
position: relative;
word-wrap: break-word;
white-space: pre-wrap;
white-space: break-spaces;
-webkit-font-variant-ligatures: none;
font-variant-ligatures: none;
font-feature-settings: "liga" 0;
min-height: 240px;
padding: 12px 16px;
outline: none;
font-size: 15px;
line-height: 1.6;
}
golem15-journal-blogeditor .ProseMirror[contenteditable="false"] {
color: var(--c-muted);
}
golem15-journal-blogeditor .ProseMirror [contenteditable="false"] {
white-space: normal;
}
golem15-journal-blogeditor .ProseMirror [contenteditable="false"] [contenteditable="true"] {
white-space: pre-wrap;
}
golem15-journal-blogeditor .ProseMirror pre {
white-space: pre-wrap;
}
golem15-journal-blogeditor img.ProseMirror-separator {
display: inline !important;
border: none !important;
margin: 0 !important;
width: 0 !important;
height: 0 !important;
}
golem15-journal-blogeditor .ProseMirror-gapcursor {
display: none;
pointer-events: none;
position: absolute;
margin: 0;
}
golem15-journal-blogeditor .ProseMirror-gapcursor::after {
content: "";
display: block;
position: absolute;
top: -2px;
width: 20px;
border-top: 1px solid var(--c-text);
animation: blogeditor-cursor-blink 1.1s steps(2, start) infinite;
}
@keyframes blogeditor-cursor-blink {
to {
visibility: hidden;
}
}
golem15-journal-blogeditor .ProseMirror-hideselection *::selection {
background: transparent;
}
golem15-journal-blogeditor .ProseMirror-hideselection * {
caret-color: transparent;
}
golem15-journal-blogeditor .ProseMirror-focused .ProseMirror-gapcursor {
display: block;
}
/* Content. */
golem15-journal-blogeditor .ProseMirror > * + * {
margin-top: 0.75em;
}
golem15-journal-blogeditor .ProseMirror :where(p, ul, ol, blockquote, table, figure, pre) {
margin-bottom: 0;
}
golem15-journal-blogeditor .ProseMirror h2 { font-size: 1.5em; font-weight: 600; margin: 1em 0 0.5em; }
golem15-journal-blogeditor .ProseMirror h3 { font-size: 1.25em; font-weight: 600; margin: 1em 0 0.5em; }
golem15-journal-blogeditor .ProseMirror h4 { font-size: 1.1em; font-weight: 600; margin: 1em 0 0.5em; }
golem15-journal-blogeditor .ProseMirror ul { list-style: disc; padding-left: 1.5em; }
golem15-journal-blogeditor .ProseMirror ol { list-style: decimal; padding-left: 1.5em; }
golem15-journal-blogeditor .ProseMirror a {
color: var(--c-primary);
text-decoration: underline;
}
golem15-journal-blogeditor .ProseMirror blockquote {
margin-left: 0;
padding-left: 1em;
border-left: 3px solid var(--c-border-strong);
color: var(--c-muted);
}
golem15-journal-blogeditor .ProseMirror code {
padding: 0 4px;
border-radius: 4px;
background: var(--c-subtle);
font-family: "DM Mono", ui-monospace, monospace;
font-size: 0.9em;
}
golem15-journal-blogeditor .ProseMirror pre {
padding: 8px 12px;
border-radius: 8px;
background: var(--c-subtle);
}
golem15-journal-blogeditor .ProseMirror pre code {
padding: 0;
background: transparent;
}
golem15-journal-blogeditor .ProseMirror hr {
border: 0;
border-top: 1px solid var(--c-border-strong);
}
golem15-journal-blogeditor .ProseMirror img {
max-width: 100%;
height: auto;
}

View File

@@ -0,0 +1,192 @@
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import { serializeDocument } from './serializer'
export const TAG = 'golem15-journal-blogeditor'
export const CHANGE_EVENT = 'summer-change'
type Mode = 'clean' | 'wysiwyg' | 'source'
/**
* 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. It never receives or sends credentials.
*/
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 = ''
#mode: Mode = 'clean'
#editor?: Editor
#mount?: HTMLDivElement
#textarea?: HTMLTextAreaElement
#tabs: { wysiwyg?: HTMLButtonElement; source?: HTMLButtonElement } = {}
#showingSource = false
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.#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'
this.#parsed = v
if (this.#textarea) this.#textarea.value = v
this.#editor?.commands.setContent(v, { emitUpdate: false })
}
/** 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()
const bar = document.createElement('div')
bar.className = 'blogeditor__bar'
const tabs = document.createElement('div')
tabs.className = 'blogeditor__tabs'
tabs.setAttribute('role', 'tablist')
this.#tabs.wysiwyg = this.#tab('WYSIWYG', () => this.showSource(false))
this.#tabs.source = this.#tab('Source', () => this.showSource(true))
tabs.append(this.#tabs.wysiwyg, this.#tabs.source)
bar.append(tabs)
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.value = this.value
textarea.addEventListener('input', () => this.#sourceInput())
this.#textarea = textarea
this.append(bar, mount, textarea)
this.#parsed = this.value
this.#editor = new Editor({
element: mount,
injectCSS: false,
editable: !this.hasAttribute('disabled'),
extensions: [
StarterKit.configure({
trailingNode: false,
link: { openOnClick: false, HTMLAttributes: { target: null, rel: null, class: null } },
}),
],
content: this.#parsed,
onUpdate: ({ transaction, appendedTransactions }) => {
if (!transaction.docChanged && !appendedTransactions.some((tr) => tr.docChanged)) return
this.#mode = 'wysiwyg'
this.#emitChange()
},
})
this.#syncAttributes()
this.showSource(false)
}
disconnectedCallback(): void {
this.#editor?.destroy()
this.#editor = undefined
}
attributeChangedCallback(): void {
this.#syncAttributes()
}
/** 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.#parsed = this.#sourceText
this.#editor?.commands.setContent(this.#sourceText, { emitUpdate: false })
}
this.#showingSource = on
this.#textarea.hidden = !on
this.#mount.hidden = on
this.#tabs.wysiwyg?.setAttribute('aria-selected', String(!on))
this.#tabs.source?.setAttribute('aria-selected', String(on))
}
#tab(label: string, onClick: () => void): HTMLButtonElement {
const button = document.createElement('button')
button.type = 'button'
button.className = 'blogeditor__tab'
button.setAttribute('role', 'tab')
button.textContent = label
button.addEventListener('click', onClick)
return button
}
#sourceInput(): void {
if (!this.#textarea) return
this.#sourceText = this.#textarea.value
this.#mode = 'source'
this.#emitChange()
}
#emitChange(): void {
this.dispatchEvent(new CustomEvent(CHANGE_EVENT, { bubbles: true, composed: true, detail: { value: this.value } }))
}
#syncAttributes(): void {
const disabled = this.hasAttribute('disabled')
const invalid = this.hasAttribute('invalid')
if (this.#editor && this.#editor.isEditable === disabled) this.#editor.setEditable(!disabled, false)
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')
}
}
}

View File

@@ -0,0 +1,10 @@
import './blogeditor.css'
import { BlogEditorElement, TAG } from './element'
// The admin SPA loads this script once on every admin page (D-13); defining
// the tag twice would throw, so a second evaluation is a no-op.
if (!customElements.get(TAG)) {
customElements.define(TAG, BlogEditorElement)
}
export { BlogEditorElement, TAG }

View File

@@ -0,0 +1,14 @@
import type { Editor } from '@tiptap/core'
import { DOMSerializer } from '@tiptap/pm/model'
/**
* serializeDocument returns the HTML of the editor document. It is the only
* source of the element's value after a user edit; editor.getHTML() is never
* the stored value.
*/
export function serializeDocument(editor: Editor): string {
const serializer = DOMSerializer.fromSchema(editor.schema)
const host = document.createElement('div')
host.appendChild(serializer.serializeFragment(editor.state.doc.content, { document }))
return host.innerHTML
}