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:
229
assets/src/blogeditor/blogeditor.css
Normal file
229
assets/src/blogeditor/blogeditor.css
Normal 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;
|
||||
}
|
||||
192
assets/src/blogeditor/element.ts
Normal file
192
assets/src/blogeditor/element.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
}
|
||||
10
assets/src/blogeditor/index.ts
Normal file
10
assets/src/blogeditor/index.ts
Normal 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 }
|
||||
14
assets/src/blogeditor/serializer.ts
Normal file
14
assets/src/blogeditor/serializer.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user