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:
6
admin.go
6
admin.go
@@ -10,14 +10,16 @@ import (
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
// adminFS holds the admin YAML and the nav SVG file by file: the controllers
|
||||
// directory also holds Go sources that are not admin assets.
|
||||
// adminFS holds the admin YAML, the nav SVG and the committed Blog Editor
|
||||
// bundle file by file: the controllers directory also holds Go sources that
|
||||
// are not admin assets, and assets/src is the bundle's source.
|
||||
//
|
||||
//go:embed controllers/posts/config_list.yaml controllers/posts/config_form.yaml controllers/posts/config_filter.yaml models/post/fields.yaml models/post/columns.yaml
|
||||
//go:embed controllers/categories/config_list.yaml controllers/categories/config_form.yaml models/category/fields.yaml models/category/columns.yaml
|
||||
//go:embed controllers/tags/config_list.yaml controllers/tags/config_form.yaml models/tag/fields.yaml models/tag/columns.yaml
|
||||
//go:embed models/settings/fields.yaml
|
||||
//go:embed assets/images/journal-icon.svg
|
||||
//go:embed assets/dist/blogeditor.js assets/dist/blogeditor.css
|
||||
var adminFS embed.FS
|
||||
|
||||
func (p *Plugin) AdminFS() fs.FS { return adminFS }
|
||||
|
||||
1
assets/dist/blogeditor.css
vendored
Normal file
1
assets/dist/blogeditor.css
vendored
Normal file
@@ -0,0 +1 @@
|
||||
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}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}golem15-journal-blogeditor .ProseMirror>*+*{margin-top:.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 .5em}golem15-journal-blogeditor .ProseMirror h3{font-size:1.25em;font-weight:600;margin:1em 0 .5em}golem15-journal-blogeditor .ProseMirror h4{font-size:1.1em;font-weight:600;margin:1em 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:.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}
|
||||
139
assets/dist/blogeditor.js
vendored
Normal file
139
assets/dist/blogeditor.js
vendored
Normal file
File diff suppressed because one or more lines are too long
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
|
||||
}
|
||||
@@ -98,6 +98,16 @@ func TestJournalSVGEmbed(t *testing.T) {
|
||||
t.Fatalf("AdminFS missing journal-icon.svg: %v", err)
|
||||
}
|
||||
_ = f.Close()
|
||||
bundle, err := fs.ReadFile(fsys, "assets/dist/blogeditor.js")
|
||||
if err != nil {
|
||||
t.Fatalf("AdminFS missing the Blog Editor bundle: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(bundle), "golem15-journal-blogeditor") {
|
||||
t.Fatal("blogeditor.js does not define golem15-journal-blogeditor")
|
||||
}
|
||||
if _, err := fs.Stat(fsys, "assets/dist/blogeditor.css"); err != nil {
|
||||
t.Fatalf("AdminFS missing blogeditor.css: %v", err)
|
||||
}
|
||||
filter, err := fs.ReadFile(fsys, "controllers/posts/config_filter.yaml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
48
tests/blogeditor/fixtures/imported-post.html
Normal file
48
tests/blogeditor/fixtures/imported-post.html
Normal file
@@ -0,0 +1,48 @@
|
||||
<h2>Czym jest ekstrakt z Turkey Tail?</h2>
|
||||
<p>Turkey Tail (<em>Trametes versicolor</em>, wrośniak różnobarwny) to jeden z najlepiej przebadanych grzybów funkcjonalnych. Ekstrakt powstaje z <strong>owocników</strong> poddanych ekstrakcji gorącą wodą, która uwalnia beta-glukany.</p>
|
||||
<h3>Najważniejsze związki aktywne</h3>
|
||||
<ul>
|
||||
<li><strong>PSK</strong> (polisacharyd K, krestin)</li>
|
||||
<li><strong>PSP</strong> (polisacharopeptyd)</li>
|
||||
<li>Beta-glukany i antyoksydanty fenolowe</li>
|
||||
</ul>
|
||||
<h3>Jak stosować?</h3>
|
||||
<ol>
|
||||
<li>Zacznij od mniejszej porcji przez pierwszy tydzień.</li>
|
||||
<li>Przyjmuj ekstrakt z posiłkiem.</li>
|
||||
<li>Obserwuj reakcję organizmu i skonsultuj się z lekarzem.</li>
|
||||
</ol>
|
||||
<blockquote>
|
||||
<p>Grzyby funkcjonalne wspierają organizm, ale nie zastępują leczenia.</p>
|
||||
</blockquote>
|
||||
<p>Więcej przeczytasz w <a href="/blog/beta-glukany">artykule o beta-glukanach</a> & <a href="https://example.test/badania">przeglądzie badań</a>.</p>
|
||||
<p>Data: 12.03 – aktualizacja opisu dawkowania.</p>
|
||||
|
||||
<footer class="author-footer" itemscope itemtype="https://schema.org/Person">
|
||||
<div class="author-info">
|
||||
<img src="https://cdn.example.test/autor.png" alt="Autor" class="author-photo" itemprop="image">
|
||||
<div class="author-text">
|
||||
<div style="font-size: 1.5em; font-weight: bold;" itemprop="name">Jan Kowalski</div>
|
||||
<span style="color: #138c74;" itemprop="jobTitle">Ekspert od grzybów funkcjonalnych</span>
|
||||
<p itemprop="description">Od lat zajmuje się mykologią i suplementacją.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<style>
|
||||
.author-footer { margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #ddd; }
|
||||
.author-info { display: flex; align-items: center; gap: 1rem; }
|
||||
.author-photo { width: 96px; height: 96px; border-radius: 50%; object-fit: cover; }
|
||||
</style>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Forma</th><th>Dawka</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>Proszek</td><td>1–2 g</td></tr>
|
||||
<tr><td>Kapsułki</td><td>2 × 500 mg</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<figure><img src="/storage/app/uploads/public/journal/2026/turkey-tail.jpg" alt="Owocniki Turkey Tail na pniu"><figcaption>Owocniki wrośniaka różnobarwnego.</figcaption></figure>
|
||||
<p>Zobacz też <a href="https://example.test/sklep" target="_blank" rel="noopener">sklep z ekstraktami</a>.</p>
|
||||
<p>Producent nazywa go "grzybem odporności", a badacze "wrośniakiem".</p>
|
||||
98
tests/blogeditor/smoke.test.ts
Normal file
98
tests/blogeditor/smoke.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import fixture from './fixtures/imported-post.html?raw'
|
||||
import '../../assets/src/blogeditor/index'
|
||||
import { BlogEditorElement, TAG } from '../../assets/src/blogeditor/index'
|
||||
|
||||
function mount(value?: string): { el: BlogEditorElement; changes: string[] } {
|
||||
const el = document.createElement(TAG) as BlogEditorElement
|
||||
const changes: string[] = []
|
||||
el.addEventListener('summer-change', (e) => changes.push((e as CustomEvent<{ value: string }>).detail.value))
|
||||
if (value !== undefined) el.value = value
|
||||
document.body.append(el)
|
||||
return { el, changes }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
describe('blog editor element (tracer)', () => {
|
||||
it('defines the custom element tag once', () => {
|
||||
expect(customElements.get(TAG)).toBe(BlogEditorElement)
|
||||
expect(document.createElement(TAG)).toBeInstanceOf(BlogEditorElement)
|
||||
})
|
||||
|
||||
it('returns the imported post byte for byte without an edit', () => {
|
||||
const { el, changes } = mount(fixture)
|
||||
expect(el.editor).toBeDefined()
|
||||
expect(el.value).toBe(fixture)
|
||||
|
||||
// Setting the value after connect is untouched too.
|
||||
const { el: late, changes: lateChanges } = mount()
|
||||
late.value = fixture
|
||||
expect(late.value).toBe(fixture)
|
||||
expect(changes).toEqual([])
|
||||
expect(lateChanges).toEqual([])
|
||||
})
|
||||
|
||||
it('emits no summer-change on set or on an echo set', () => {
|
||||
const { el, changes } = mount(fixture)
|
||||
el.value = fixture
|
||||
el.value = el.value
|
||||
expect(changes).toEqual([])
|
||||
expect(el.value).toBe(fixture)
|
||||
})
|
||||
|
||||
it('emits exactly one summer-change per user edit with the element value', () => {
|
||||
const { el, changes } = mount('<p>Hello</p>')
|
||||
const editor = el.editor!
|
||||
editor.view.dispatch(editor.state.tr.insertText('X', 1))
|
||||
expect(changes).toHaveLength(1)
|
||||
expect(changes[0]).toBe(el.value)
|
||||
expect(el.value).toBe('<p>XHello</p>')
|
||||
|
||||
// The SPA echoes the emitted value back: no reset, no event.
|
||||
el.value = changes[0]!
|
||||
expect(changes).toHaveLength(1)
|
||||
expect(el.value).toBe('<p>XHello</p>')
|
||||
})
|
||||
|
||||
it('shows the exact value in the source tab', () => {
|
||||
const { el, changes } = mount(fixture)
|
||||
el.showSource(true)
|
||||
const textarea = el.querySelector('textarea')!
|
||||
expect(textarea.hidden).toBe(false)
|
||||
expect(textarea.value).toBe(fixture)
|
||||
|
||||
textarea.value = '<p>Edited in source</p>\n<footer>kept</footer>'
|
||||
textarea.dispatchEvent(new Event('input'))
|
||||
expect(changes).toEqual(['<p>Edited in source</p>\n<footer>kept</footer>'])
|
||||
expect(el.value).toBe('<p>Edited in source</p>\n<footer>kept</footer>')
|
||||
|
||||
// Back to WYSIWYG: re-parsed, but the value stays the exact text.
|
||||
el.showSource(false)
|
||||
expect(el.value).toBe('<p>Edited in source</p>\n<footer>kept</footer>')
|
||||
expect(changes).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('a new external value replaces the content without an event', () => {
|
||||
const { el, changes } = mount('<p>One</p>')
|
||||
el.value = '<p>Two</p>'
|
||||
expect(el.value).toBe('<p>Two</p>')
|
||||
expect(el.editor!.state.doc.textContent).toBe('Two')
|
||||
expect(changes).toEqual([])
|
||||
})
|
||||
|
||||
it('disabled makes the editor and the source read-only; invalid sets aria-invalid', () => {
|
||||
const { el } = mount('<p>x</p>')
|
||||
el.setAttribute('disabled', '')
|
||||
el.setAttribute('invalid', '')
|
||||
expect(el.editor!.isEditable).toBe(false)
|
||||
expect(el.querySelector('textarea')!.readOnly).toBe(true)
|
||||
expect(el.editor!.view.dom.getAttribute('aria-invalid')).toBe('true')
|
||||
el.removeAttribute('disabled')
|
||||
el.removeAttribute('invalid')
|
||||
expect(el.editor!.isEditable).toBe(true)
|
||||
expect(el.editor!.view.dom.hasAttribute('aria-invalid')).toBe(false)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user