feat(editor): preservation schema, block reuse, toolbar, uploads and embeds
- preservationExtensions: raw HTML cards for unmodelled blocks (shown as text), figure with figcaption, the YouTube embed placeholder node, span marks, a global attribute map (never on* handlers), tables without colgroup or min-width, inline images without base64 - serializeDocument keeps the original bytes of every top-level block still equal to the loaded one, so editing one paragraph changes only that paragraph; tables serialize with thead and bare cell text - Links accept only http, https, mailto and relative URLs (isPolicyUrl, the rule both sanitizer policies apply) and gain no target, rel or class - Toolbar with headings, marks, lists, links, blockquote, table editing, images, embeds, raw HTML block, undo and redo; en and pl labels - Images from the picker or a drop go out as summer-upload and are inserted on summer-upload-result with alt text and an optional caption - editor-corpus.json: the editor own output, rebuilt and checked by corpus.test.ts (UPDATE_CORPUS=1 rewrites it) for the policy tests - README Blog Editor section
This commit is contained in:
33
README.md
33
README.md
@@ -149,6 +149,31 @@ func (Post) TranslatableIndexes() []string { return []string{"slug"} }
|
||||
func (Post) MorphName() string { return `Golem15\Journal\Models\Post` }
|
||||
```
|
||||
|
||||
### Blog Editor
|
||||
|
||||
`assets/src/blogeditor` is the Blog Editor admin element, the custom element `golem15-journal-blogeditor` (`BlogEditorElement`). It is a WYSIWYG HTML editor on TipTap (ProseMirror, MIT) with a Source tab that shows and edits the exact HTML string. The toolbar offers paragraphs and headings 2 to 4, bold, italic, bullet and ordered lists, links, blockquotes, tables, images, YouTube embeds, a raw HTML block, undo and redo. Labels follow the `ui-locale` attribute (`en`, `pl`).
|
||||
|
||||
The element follows the admin field type element contract: the `value` property holds the HTML string; the attributes are `field-name`, `record-id`, `locale`, `ui-locale`, `options`, `disabled` and `invalid`; a bubbling `summer-change` event with `detail.value` fires on user edits only. It styles itself only through the admin `--c-*` variables, in `blogeditor.css` under its tag (TipTap's injected stylesheet is off), and never receives or sends credentials.
|
||||
|
||||
- **Untouched values.** The element returns the exact string it was given until the user edits; an untouched imported post is saved byte for byte. Setting `value` to the string it currently returns is a no-op. After an edit, top-level blocks that are still equal to the loaded ones keep their original bytes, so editing one paragraph changes only that paragraph. Markup the schema does not model (`footer`, `section`, `aside`, `header`, `nav`, `details`, `style`, `div` and similar blocks, and a `figure` that is not an image with an optional caption) becomes a raw HTML card that shows its source as text and is written back verbatim. Unmodelled attributes on paragraphs, headings, lists, blockquotes, tables, cells, images and links, and `span` elements with attributes, survive an edit; event handler attributes (`on*`) are never kept.
|
||||
- **Embeds.** The embed button takes a YouTube URL (`youtube.com/watch?v=`, `youtu.be/`, `youtube.com/shorts/`, `youtube.com/embed/`) and stores exactly `<figure data-embed="youtube" data-id="ID"></figure>`, where `ID` matches `^[A-Za-z0-9_-]{11}$`. The admin shows a card and never an iframe; the site layer expands the placeholder. Another provider is one entry in `EMBED_PROVIDERS` (`embed.ts`) and one in the layer.
|
||||
- **Links.** The editor creates and keeps links only to `http`, `https`, `mailto` and relative URLs (`isPolicyUrl` in `extensions.ts`), the schemes both sanitizer policies keep; the scheme is read after removing control characters and spaces. A created link has `href` only; stored links keep their own `target` and `rel`, which the server strips on an edited save.
|
||||
- **Images.** An image chosen with the file picker (JPEG, PNG, GIF, WebP) or dropped on the editor is handed to the admin page as a bubbling `summer-upload` event with `detail {id, file}`. The admin uploads it with its own session and answers on the element with `summer-upload-result` `{id, url}` or `{id, error}`. On a URL the editor asks for the alt text (required) and an optional caption and inserts `<img src alt>`, or `<figure>` with `<img>` and `<figcaption>` when a caption is given. An error, or no answer within 60 seconds, is shown in the status line and nothing is inserted.
|
||||
- **Sanitizing.** The element never sanitizes. Every admin save goes through `classes.SanitizeHTML`, which strips what the policy does not allow.
|
||||
|
||||
The element is built by the plugin's own JS build into `assets/dist/blogeditor.js` and `assets/dist/blogeditor.css`. Both files are committed and embedded through `admin.go`, so a host application needs no Node:
|
||||
|
||||
```sh
|
||||
npm ci # exact versions from package-lock.json
|
||||
npm run build # typecheck, then vite build into assets/dist
|
||||
npm test # vitest on happy-dom
|
||||
bash scripts/check-dist.sh # fails when assets/dist differs from a fresh build
|
||||
```
|
||||
|
||||
After changing `assets/src`, run `npm run build` and commit `assets/dist`.
|
||||
|
||||
`tests/blogeditor/fixtures/editor-corpus.json` holds the editor's own output, built through editor commands: an embed, images with and without a caption, a table with a header row, links, headings, lists (one starting at 3), a blockquote, inline marks, a code block, a horizontal rule, a hard break, and one document combining them. `tests/blogeditor/corpus.test.ts` rebuilds it and fails when the committed file differs; `UPDATE_CORPUS=1 npm test` rewrites it. The Go policy test and the site layer check the same entries against both sanitizer policies, so entry names stay stable.
|
||||
|
||||
## API reference
|
||||
|
||||
| Identifier | Description |
|
||||
@@ -201,6 +226,7 @@ Both commands write JSON files (one per post slug), matching the PHP `journal:ex
|
||||
- SummerCMS modules: backpack, beachcomber, bonfire, cabana, lagoon/attach, pact, party, surf.
|
||||
- Compiled plugin: `golem15.translate`.
|
||||
- Third-party: `gorm.io/gorm`, `github.com/go-gormigrate/gormigrate/v2`, `github.com/yuin/goldmark`, `github.com/microcosm-cc/bluemonday`, `golang.org/x/net/html`, `gocloud.dev`. Test-only: `github.com/testcontainers/testcontainers-go`.
|
||||
- Blog Editor JS build (not needed by a host application): `@tiptap/core`, `@tiptap/pm`, `@tiptap/starter-kit`, `@tiptap/extension-table` and `@tiptap/extension-image` 3.31.4, bundled into `assets/dist`; dev-only `vite`, `vitest`, `happy-dom` and `typescript`, pinned exactly in `package.json`.
|
||||
|
||||
## Testing
|
||||
|
||||
@@ -209,3 +235,10 @@ go test ./...
|
||||
```
|
||||
|
||||
Postgres integration smokes start Docker unless `-short`. Missing Docker is a failure, not a skip, unless `-short`.
|
||||
|
||||
The Blog Editor has its own suite and drift gate:
|
||||
|
||||
```sh
|
||||
npm test
|
||||
bash scripts/check-dist.sh
|
||||
```
|
||||
|
||||
2
assets/dist/blogeditor.css
vendored
2
assets/dist/blogeditor.css
vendored
File diff suppressed because one or more lines are too long
93
assets/dist/blogeditor.js
vendored
93
assets/dist/blogeditor.js
vendored
File diff suppressed because one or more lines are too long
@@ -28,12 +28,90 @@ golem15-journal-blogeditor .blogeditor__bar {
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
|
||||
golem15-journal-blogeditor .blogeditor__tools {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 12px;
|
||||
}
|
||||
|
||||
golem15-journal-blogeditor .blogeditor__group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
golem15-journal-blogeditor .blogeditor__tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
golem15-journal-blogeditor .blogeditor__form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 6px 8px;
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--c-border);
|
||||
background: var(--c-surface);
|
||||
}
|
||||
|
||||
golem15-journal-blogeditor .blogeditor__form label {
|
||||
display: flex;
|
||||
flex: 1 1 220px;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--c-muted);
|
||||
}
|
||||
|
||||
golem15-journal-blogeditor .blogeditor__form input {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
padding: 5px 8px;
|
||||
border: 1px solid var(--c-border-strong);
|
||||
border-radius: 6px;
|
||||
background: var(--c-bg);
|
||||
color: var(--c-text);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
golem15-journal-blogeditor .blogeditor__form input::placeholder {
|
||||
color: var(--c-placeholder);
|
||||
}
|
||||
|
||||
golem15-journal-blogeditor .blogeditor__form input:focus {
|
||||
outline: 2px solid var(--c-ring);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
golem15-journal-blogeditor .blogeditor__form button[data-action="apply"],
|
||||
golem15-journal-blogeditor .blogeditor__form button[data-action="insert"] {
|
||||
background: var(--c-primary);
|
||||
color: var(--c-on-primary);
|
||||
}
|
||||
|
||||
golem15-journal-blogeditor .blogeditor__error {
|
||||
flex-basis: 100%;
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--c-danger);
|
||||
}
|
||||
|
||||
golem15-journal-blogeditor .blogeditor__error:empty,
|
||||
golem15-journal-blogeditor .blogeditor__status:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
golem15-journal-blogeditor .blogeditor__status {
|
||||
margin: 0;
|
||||
padding: 6px 12px;
|
||||
border-top: 1px solid var(--c-border);
|
||||
font-size: 12px;
|
||||
color: var(--c-muted);
|
||||
}
|
||||
|
||||
golem15-journal-blogeditor button {
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
@@ -227,3 +305,80 @@ golem15-journal-blogeditor .ProseMirror img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
golem15-journal-blogeditor .ProseMirror table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
golem15-journal-blogeditor .ProseMirror :where(td, th) {
|
||||
border: 1px solid var(--c-border-strong);
|
||||
padding: 4px 8px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
golem15-journal-blogeditor .ProseMirror th {
|
||||
background: var(--c-subtle);
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
golem15-journal-blogeditor .ProseMirror :where(td, th) > p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
golem15-journal-blogeditor .ProseMirror .selectedCell {
|
||||
background: var(--c-sel);
|
||||
}
|
||||
|
||||
golem15-journal-blogeditor .ProseMirror figure {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
golem15-journal-blogeditor .ProseMirror figcaption {
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--c-muted);
|
||||
}
|
||||
|
||||
/* Raw HTML and embed cards: never live markup. */
|
||||
golem15-journal-blogeditor .blogeditor__raw,
|
||||
golem15-journal-blogeditor .blogeditor__embed {
|
||||
border: 1px dashed var(--c-border-strong);
|
||||
border-radius: 8px;
|
||||
background: var(--c-subtle);
|
||||
color: var(--c-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
golem15-journal-blogeditor .blogeditor__embed {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
golem15-journal-blogeditor .blogeditor__card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 4px 4px 4px 10px;
|
||||
border-bottom: 1px dashed var(--c-border);
|
||||
}
|
||||
|
||||
golem15-journal-blogeditor .blogeditor__raw-source {
|
||||
max-height: 160px;
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
overflow: auto;
|
||||
background: transparent;
|
||||
font-family: "DM Mono", ui-monospace, monospace;
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
golem15-journal-blogeditor .ProseMirror-selectednode.blogeditor__raw,
|
||||
golem15-journal-blogeditor .ProseMirror-selectednode.blogeditor__embed,
|
||||
golem15-journal-blogeditor .ProseMirror img.ProseMirror-selectednode {
|
||||
outline: 2px solid var(--c-ring);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
import { Editor } from '@tiptap/core'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { serializeDocument } from './serializer'
|
||||
import { parseEmbedUrl } from './embed'
|
||||
import { EDIT_HTML_EVENT, isPolicyUrl, preservationExtensions } from './extensions'
|
||||
import { format, labelsFor, type Labels } from './labels'
|
||||
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
|
||||
@@ -17,7 +32,10 @@ type Mode = 'clean' | 'wysiwyg' | 'source'
|
||||
* 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.
|
||||
* 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']
|
||||
@@ -28,12 +46,22 @@ export class BlogEditorElement extends HTMLElement {
|
||||
#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
|
||||
#tabs: { wysiwyg?: HTMLButtonElement; source?: HTMLButtonElement } = {}
|
||||
#status?: HTMLParagraphElement
|
||||
#fileInput?: HTMLInputElement
|
||||
#linkForm?: InlineForm
|
||||
#embedForm?: InlineForm
|
||||
#imageForm?: InlineForm
|
||||
#showingSource = false
|
||||
#pending = new Map<string, { name: string; timer: ReturnType<typeof setTimeout> }>()
|
||||
#ready: { url: string; name: string }[] = []
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
@@ -51,7 +79,7 @@ export class BlogEditorElement extends HTMLElement {
|
||||
case 'source':
|
||||
return this.#sourceText
|
||||
case 'wysiwyg':
|
||||
return this.#editor ? serializeDocument(this.#editor) : this.#value
|
||||
return this.#editor ? serializeDocument(this.#editor, this.#blocks) : this.#value
|
||||
default:
|
||||
return this.#value
|
||||
}
|
||||
@@ -62,9 +90,8 @@ export class BlogEditorElement extends HTMLElement {
|
||||
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 })
|
||||
this.#load(v)
|
||||
}
|
||||
|
||||
/** The TipTap editor while the element is connected (toolbar and tests). */
|
||||
@@ -76,16 +103,29 @@ export class BlogEditorElement extends HTMLElement {
|
||||
if (this.#editor) return
|
||||
this.classList.add('blogeditor')
|
||||
this.replaceChildren()
|
||||
this.#labels = labelsFor(this.getAttribute('ui-locale'))
|
||||
const labels = this.#labels
|
||||
|
||||
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)
|
||||
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),
|
||||
})
|
||||
|
||||
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'
|
||||
@@ -95,35 +135,80 @@ export class BlogEditorElement extends HTMLElement {
|
||||
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
|
||||
|
||||
this.append(bar, mount, textarea)
|
||||
const status = document.createElement('p')
|
||||
status.className = 'blogeditor__status'
|
||||
status.setAttribute('role', 'status')
|
||||
status.setAttribute('aria-live', 'polite')
|
||||
this.#status = status
|
||||
|
||||
this.#parsed = this.value
|
||||
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
|
||||
|
||||
this.append(
|
||||
this.#toolbar.element,
|
||||
this.#linkForm.root,
|
||||
this.#embedForm.root,
|
||||
this.#imageForm.root,
|
||||
mount,
|
||||
textarea,
|
||||
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: [
|
||||
StarterKit.configure({
|
||||
trailingNode: false,
|
||||
link: { openOnClick: false, HTMLAttributes: { target: null, rel: null, class: null } },
|
||||
}),
|
||||
],
|
||||
content: this.#parsed,
|
||||
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()
|
||||
},
|
||||
onTransaction: () => this.#toolbar?.refresh(),
|
||||
})
|
||||
this.#blocks = captureBlocks(initial, this.#editor.schema)
|
||||
this.#syncAttributes()
|
||||
this.showSource(false)
|
||||
}
|
||||
|
||||
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()
|
||||
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
|
||||
}
|
||||
@@ -140,24 +225,27 @@ export class BlogEditorElement extends HTMLElement {
|
||||
} 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.#load(this.#sourceText)
|
||||
}
|
||||
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))
|
||||
if (on) {
|
||||
this.#close(this.#linkForm)
|
||||
this.#close(this.#embedForm)
|
||||
}
|
||||
this.#toolbar?.selectTab(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
|
||||
/**
|
||||
* 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 {
|
||||
@@ -171,10 +259,15 @@ export class BlogEditorElement extends HTMLElement {
|
||||
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')
|
||||
@@ -189,4 +282,264 @@ export class BlogEditorElement extends HTMLElement {
|
||||
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<HTMLInputElement>(`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 = '<div></div>'
|
||||
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 + '<div>'.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 = crypto.randomUUID()
|
||||
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<HTMLInputElement>('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)
|
||||
}
|
||||
|
||||
59
assets/src/blogeditor/embed.ts
Normal file
59
assets/src/blogeditor/embed.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Embed providers (D-05). Stored posts hold only the placeholder
|
||||
* <figure data-embed="PROVIDER" data-id="ID"></figure>; the layer turns it
|
||||
* into the real iframe. Adding a provider is one entry here and one in the
|
||||
* layer's table.
|
||||
*/
|
||||
export interface EmbedProvider {
|
||||
/** The name shown on the editor card. */
|
||||
name: string
|
||||
/** The id the placeholder may hold. */
|
||||
idPattern: RegExp
|
||||
/** Returns the id from a provider URL, or null. */
|
||||
idFromUrl: (url: URL) => string | null
|
||||
}
|
||||
|
||||
const YOUTUBE_HOSTS = new Set(['youtube.com', 'www.youtube.com', 'm.youtube.com', 'music.youtube.com', 'youtube-nocookie.com', 'www.youtube-nocookie.com'])
|
||||
|
||||
export const EMBED_PROVIDERS = {
|
||||
youtube: {
|
||||
name: 'YouTube',
|
||||
idPattern: /^[A-Za-z0-9_-]{11}$/,
|
||||
idFromUrl(url: URL): string | null {
|
||||
const host = url.hostname.toLowerCase()
|
||||
if (host === 'youtu.be') {
|
||||
return url.pathname.split('/')[1] ?? null
|
||||
}
|
||||
if (!YOUTUBE_HOSTS.has(host)) return null
|
||||
if (url.pathname === '/watch') return url.searchParams.get('v')
|
||||
const match = /^\/(?:shorts|embed)\/([^/]+)\/?$/.exec(url.pathname)
|
||||
return match ? match[1]! : null
|
||||
},
|
||||
},
|
||||
} as const satisfies Record<string, EmbedProvider>
|
||||
|
||||
export type EmbedProviderName = keyof typeof EMBED_PROVIDERS
|
||||
|
||||
export function isEmbedProvider(name: string): name is EmbedProviderName {
|
||||
return Object.prototype.hasOwnProperty.call(EMBED_PROVIDERS, name)
|
||||
}
|
||||
|
||||
/**
|
||||
* parseEmbedUrl returns the provider and id for a supported video URL
|
||||
* (youtube.com/watch?v=, youtu.be/, youtube.com/shorts/ and
|
||||
* youtube.com/embed/ for YouTube), or null for anything else.
|
||||
*/
|
||||
export function parseEmbedUrl(input: string): { provider: EmbedProviderName; id: string } | null {
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(input.trim())
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (url.protocol !== 'https:' && url.protocol !== 'http:') return null
|
||||
for (const provider of Object.keys(EMBED_PROVIDERS) as EmbedProviderName[]) {
|
||||
const id = EMBED_PROVIDERS[provider].idFromUrl(url)
|
||||
if (id && EMBED_PROVIDERS[provider].idPattern.test(id)) return { provider, id }
|
||||
}
|
||||
return null
|
||||
}
|
||||
535
assets/src/blogeditor/extensions.ts
Normal file
535
assets/src/blogeditor/extensions.ts
Normal file
@@ -0,0 +1,535 @@
|
||||
import { Extension, Mark, Node, mergeAttributes, type AnyExtension } from '@tiptap/core'
|
||||
import type { DOMOutputSpec } from '@tiptap/pm/model'
|
||||
import Image from '@tiptap/extension-image'
|
||||
import { Table, TableCell, TableHeader, TableRow } from '@tiptap/extension-table'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { EMBED_PROVIDERS, isEmbedProvider } from './embed'
|
||||
import { labelsFor, type Labels } from './labels'
|
||||
|
||||
/** The event a raw HTML card dispatches to open its source in the Source tab. */
|
||||
export const EDIT_HTML_EVENT = 'blogeditor-edit-html'
|
||||
|
||||
/**
|
||||
* Block elements the schema does not model. Each becomes one atomic rawHtml
|
||||
* node holding its outer HTML, shown in the editor as text (D-04).
|
||||
*/
|
||||
export const RAW_HTML_TAGS = [
|
||||
'footer',
|
||||
'section',
|
||||
'aside',
|
||||
'header',
|
||||
'nav',
|
||||
'details',
|
||||
'style',
|
||||
'div',
|
||||
'article',
|
||||
'main',
|
||||
'address',
|
||||
'dl',
|
||||
'iframe',
|
||||
'video',
|
||||
'audio',
|
||||
'picture',
|
||||
'object',
|
||||
'embed',
|
||||
'form',
|
||||
'svg',
|
||||
'math',
|
||||
'script',
|
||||
'noscript',
|
||||
] as const
|
||||
|
||||
const URL_IGNORED = /[\x00-\x20\x7f]+/g
|
||||
const URL_SCHEME = /^([a-z][a-z0-9+.-]*):/i
|
||||
const POLICY_SCHEMES = ['http', 'https', 'mailto']
|
||||
|
||||
/**
|
||||
* isPolicyUrl reports whether url is one the server and layer sanitizer
|
||||
* policies keep (D-05, D-10 amended): a relative URL (including # and ?) or
|
||||
* an http, https or mailto URL. The scheme is read after removing ASCII
|
||||
* control characters, spaces and DEL, as browsers do, so "java\tscript:" is
|
||||
* refused. It mirrors allowedUrl in the layer's htmlGate.ts.
|
||||
*/
|
||||
export function isPolicyUrl(url: string | null | undefined): boolean {
|
||||
if (url == null) return false
|
||||
const scheme = URL_SCHEME.exec(url.replace(URL_IGNORED, ''))
|
||||
if (scheme && !POLICY_SCHEMES.includes(scheme[1]!.toLowerCase())) return false
|
||||
try {
|
||||
new URL(url, 'https://journal.invalid/')
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The attributes each node or mark models itself; htmlAttrs captures every
|
||||
* other attribute. Event handler attributes (on*) are never captured, so
|
||||
* they never reach the live admin DOM.
|
||||
*/
|
||||
const MODELLED: Record<string, readonly string[]> = {
|
||||
orderedList: ['start', 'type'],
|
||||
link: ['href', 'target', 'rel', 'class', 'title'],
|
||||
image: ['src', 'alt', 'title', 'width', 'height'],
|
||||
figureImage: ['src', 'alt', 'title', 'width', 'height'],
|
||||
tableCell: ['colspan', 'rowspan', 'colwidth'],
|
||||
tableHeader: ['colspan', 'rowspan', 'colwidth'],
|
||||
}
|
||||
|
||||
const HTML_ATTRIBUTE_TYPES = [
|
||||
'paragraph',
|
||||
'heading',
|
||||
'blockquote',
|
||||
'bulletList',
|
||||
'orderedList',
|
||||
'listItem',
|
||||
'codeBlock',
|
||||
'horizontalRule',
|
||||
'hardBreak',
|
||||
'table',
|
||||
'tableRow',
|
||||
'tableCell',
|
||||
'tableHeader',
|
||||
'image',
|
||||
'figure',
|
||||
'figureImage',
|
||||
'figcaption',
|
||||
'link',
|
||||
]
|
||||
|
||||
function isHandlerName(name: string): boolean {
|
||||
return /^on/i.test(name)
|
||||
}
|
||||
|
||||
/** captureAttributes returns the element's attributes outside skip, in order. */
|
||||
function captureAttributes(element: HTMLElement, skip: readonly string[]): Record<string, string> | null {
|
||||
const out: Record<string, string> = {}
|
||||
let any = false
|
||||
for (const attr of Array.from(element.attributes)) {
|
||||
const name = attr.name.toLowerCase()
|
||||
if (skip.includes(name) || isHandlerName(name)) continue
|
||||
out[attr.name] = attr.value
|
||||
any = true
|
||||
}
|
||||
return any ? out : null
|
||||
}
|
||||
|
||||
function renderCaptured(captured: unknown): Record<string, string> {
|
||||
if (!captured || typeof captured !== 'object') return {}
|
||||
const out: Record<string, string> = {}
|
||||
for (const [name, value] of Object.entries(captured as Record<string, unknown>)) {
|
||||
if (isHandlerName(name) || typeof value !== 'string') continue
|
||||
out[name] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* HtmlAttributes keeps the attributes the schema does not model (class,
|
||||
* data-*, itemprop, style and others) on paragraphs, headings, lists, list
|
||||
* items, blockquotes, tables, cells, images and links, so they survive an
|
||||
* edit elsewhere in the document (D-03).
|
||||
*/
|
||||
export const HtmlAttributes = Extension.create({
|
||||
name: 'htmlAttributes',
|
||||
|
||||
addGlobalAttributes() {
|
||||
return HTML_ATTRIBUTE_TYPES.map((type) => ({
|
||||
types: [type],
|
||||
attributes: {
|
||||
htmlAttrs: {
|
||||
default: null,
|
||||
keepOnSplit: false,
|
||||
parseHTML: (element: HTMLElement) => captureAttributes(element, MODELLED[type] ?? []),
|
||||
renderHTML: (attributes: Record<string, unknown>) => renderCaptured(attributes.htmlAttrs),
|
||||
},
|
||||
},
|
||||
}))
|
||||
},
|
||||
})
|
||||
|
||||
/** Span keeps inline spans with their attributes (style, class) as a mark. */
|
||||
export const Span = Mark.create({
|
||||
name: 'span',
|
||||
priority: 40,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
htmlAttrs: {
|
||||
default: null,
|
||||
parseHTML: (element: HTMLElement) => captureAttributes(element, []),
|
||||
renderHTML: (attributes: Record<string, unknown>) => renderCaptured(attributes.htmlAttrs),
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'span', getAttrs: (element) => ((element as HTMLElement).attributes.length > 0 ? null : false) }]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['span', HTMLAttributes, 0]
|
||||
},
|
||||
})
|
||||
|
||||
let inertDocument: Document | undefined
|
||||
|
||||
/**
|
||||
* inert returns a document without a browsing context: markup parsed into
|
||||
* it loads no image and runs no handler, so stored HTML never reaches the
|
||||
* live admin DOM.
|
||||
*/
|
||||
export function inert(): Document {
|
||||
inertDocument ??= document.implementation.createHTMLDocument('')
|
||||
return inertDocument
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
rawHtml: {
|
||||
/** Inserts an atomic raw HTML block. */
|
||||
insertRawHtml: (html: string) => ReturnType
|
||||
}
|
||||
embed: {
|
||||
/** Inserts an embed placeholder for an allowlisted provider. */
|
||||
setEmbed: (attributes: { provider: string; id: string }) => ReturnType
|
||||
}
|
||||
figure: {
|
||||
/** Inserts an image with a caption as figure, img and figcaption. */
|
||||
setFigure: (attributes: { src: string; alt: string; caption: string }) => ReturnType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface RawHtmlOptions {
|
||||
labels: Labels
|
||||
}
|
||||
|
||||
/**
|
||||
* RawHtml is an atomic block holding the outer HTML of markup the schema
|
||||
* does not model (D-04). Its card renders the source with textContent,
|
||||
* never as live DOM.
|
||||
*/
|
||||
export const RawHtml = Node.create<RawHtmlOptions>({
|
||||
name: 'rawHtml',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: true,
|
||||
priority: 10,
|
||||
|
||||
addOptions() {
|
||||
return { labels: labelsFor('en') }
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
html: {
|
||||
default: '',
|
||||
parseHTML: (element: HTMLElement) => element.outerHTML,
|
||||
renderHTML: () => ({}),
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
// The last rule takes a figure that is not exactly img plus an optional
|
||||
// figcaption.
|
||||
return [...RAW_HTML_TAGS, 'figure'].map((tag) => ({ tag, priority: 10 }))
|
||||
},
|
||||
|
||||
renderHTML({ node }) {
|
||||
// Serializers only (the editor shows the card): parsed in the inert
|
||||
// document and returned whole.
|
||||
const template = inert().createElement('template')
|
||||
template.innerHTML = String(node.attrs.html ?? '')
|
||||
// ProseMirror accepts any DOM node here; the typing names only elements.
|
||||
return template.content as unknown as DOMOutputSpec
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
insertRawHtml:
|
||||
(html) =>
|
||||
({ commands }) =>
|
||||
commands.insertContent({ type: this.name, attrs: { html } }),
|
||||
}
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
const labels = this.options.labels
|
||||
return ({ node }) => {
|
||||
const dom = document.createElement('div')
|
||||
dom.className = 'blogeditor__raw'
|
||||
dom.contentEditable = 'false'
|
||||
const head = document.createElement('div')
|
||||
head.className = 'blogeditor__card-head'
|
||||
const title = document.createElement('span')
|
||||
title.textContent = labels.rawHtmlCard
|
||||
const edit = document.createElement('button')
|
||||
edit.type = 'button'
|
||||
edit.textContent = labels.editAsHtml
|
||||
head.append(title, edit)
|
||||
const source = document.createElement('pre')
|
||||
source.className = 'blogeditor__raw-source'
|
||||
source.textContent = String(node.attrs.html ?? '')
|
||||
dom.append(head, source)
|
||||
let current = node
|
||||
edit.addEventListener('click', (event) => {
|
||||
event.preventDefault()
|
||||
dom.dispatchEvent(
|
||||
new CustomEvent(EDIT_HTML_EVENT, { bubbles: true, detail: { html: String(current.attrs.html ?? '') } }),
|
||||
)
|
||||
})
|
||||
return {
|
||||
dom,
|
||||
update: (next) => {
|
||||
if (next.type !== current.type) return false
|
||||
current = next
|
||||
source.textContent = String(next.attrs.html ?? '')
|
||||
return true
|
||||
},
|
||||
stopEvent: (event) => event.target === edit,
|
||||
ignoreMutation: () => true,
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export interface EmbedOptions {
|
||||
labels: Labels
|
||||
}
|
||||
|
||||
/**
|
||||
* Embed is the allowlisted video placeholder (D-05). It renders exactly
|
||||
* <figure data-embed="PROVIDER" data-id="ID"></figure>; the editor shows a
|
||||
* card and never an iframe. The layer expands the placeholder on the site.
|
||||
*/
|
||||
export const Embed = Node.create<EmbedOptions>({
|
||||
name: 'embed',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: true,
|
||||
priority: 70,
|
||||
|
||||
addOptions() {
|
||||
return { labels: labelsFor('en') }
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
provider: { default: null, parseHTML: (element: HTMLElement) => element.getAttribute('data-embed'), renderHTML: () => ({}) },
|
||||
id: { default: null, parseHTML: (element: HTMLElement) => element.getAttribute('data-id'), renderHTML: () => ({}) },
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'figure[data-embed]',
|
||||
priority: 70,
|
||||
getAttrs: (element) => {
|
||||
const el = element as HTMLElement
|
||||
const provider = el.getAttribute('data-embed') ?? ''
|
||||
const id = el.getAttribute('data-id') ?? ''
|
||||
// Exactly the placeholder: two attributes and no content.
|
||||
if (el.attributes.length !== 2 || el.childNodes.length !== 0) return false
|
||||
if (!isEmbedProvider(provider) || !EMBED_PROVIDERS[provider].idPattern.test(id)) return false
|
||||
return null
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ node }) {
|
||||
return ['figure', { 'data-embed': node.attrs.provider, 'data-id': node.attrs.id }]
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setEmbed:
|
||||
({ provider, id }) =>
|
||||
({ commands }) => {
|
||||
if (!isEmbedProvider(provider) || !EMBED_PROVIDERS[provider].idPattern.test(id)) return false
|
||||
return commands.insertContent({ type: this.name, attrs: { provider, id } })
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
const labels = this.options.labels
|
||||
return ({ node }) => {
|
||||
const dom = document.createElement('div')
|
||||
dom.className = 'blogeditor__embed'
|
||||
dom.contentEditable = 'false'
|
||||
const render = (n: typeof node) => {
|
||||
const provider = String(n.attrs.provider ?? '')
|
||||
const name = isEmbedProvider(provider) ? EMBED_PROVIDERS[provider].name : provider
|
||||
dom.textContent = `${labels.embedCard}: ${name} ${String(n.attrs.id ?? '')}`
|
||||
}
|
||||
render(node)
|
||||
let current = node
|
||||
return {
|
||||
dom,
|
||||
update: (next) => {
|
||||
if (next.type !== current.type) return false
|
||||
current = next
|
||||
render(next)
|
||||
return true
|
||||
},
|
||||
ignoreMutation: () => true,
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function isFigureShape(element: HTMLElement): boolean {
|
||||
if (element.hasAttribute('data-embed')) return false
|
||||
const children: Element[] = []
|
||||
for (const child of Array.from(element.childNodes)) {
|
||||
if (child.nodeType === 1) children.push(child as Element)
|
||||
else if (child.nodeType === 3 && /[^ \t\r\n\f]/.test(child.nodeValue ?? '')) return false
|
||||
else if (child.nodeType !== 3) return false
|
||||
}
|
||||
if (children.length < 1 || children.length > 2) return false
|
||||
const img = children[0]!
|
||||
if (img.nodeName !== 'IMG') return false
|
||||
const src = img.getAttribute('src')
|
||||
if (!src || /^\s*data:/i.test(src)) return false
|
||||
return children.length === 1 || children[1]!.nodeName === 'FIGCAPTION'
|
||||
}
|
||||
|
||||
/** Figure holds an image and an optional caption (D-18). */
|
||||
export const Figure = Node.create({
|
||||
name: 'figure',
|
||||
group: 'block',
|
||||
content: 'figureImage figcaption?',
|
||||
draggable: true,
|
||||
isolating: true,
|
||||
priority: 60,
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'figure',
|
||||
priority: 60,
|
||||
getAttrs: (element) => (isFigureShape(element as HTMLElement) ? null : false),
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['figure', HTMLAttributes, 0]
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setFigure:
|
||||
({ src, alt, caption }) =>
|
||||
({ commands }) => {
|
||||
const content: Record<string, unknown>[] = [{ type: 'figureImage', attrs: { src, alt } }]
|
||||
if (caption) content.push({ type: 'figcaption', content: [{ type: 'text', text: caption }] })
|
||||
return commands.insertContent({ type: this.name, content })
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
/** FigureImage is the img inside a figure. */
|
||||
export const FigureImage = Node.create({
|
||||
name: 'figureImage',
|
||||
atom: true,
|
||||
selectable: false,
|
||||
priority: 60,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
src: { default: null },
|
||||
alt: { default: null },
|
||||
title: { default: null },
|
||||
width: { default: null },
|
||||
height: { default: null },
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'img[src]:not([src^="data:"])', context: 'figure/', priority: 60 }]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['img', HTMLAttributes]
|
||||
},
|
||||
})
|
||||
|
||||
/** Figcaption is the editable caption of a figure. */
|
||||
export const Figcaption = Node.create({
|
||||
name: 'figcaption',
|
||||
content: 'inline*',
|
||||
priority: 60,
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'figcaption', context: 'figure/' }]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['figcaption', HTMLAttributes, 0]
|
||||
},
|
||||
})
|
||||
|
||||
/** Tables render without TipTap's colgroup and min-width style. */
|
||||
export const PlainTable = Table.extend({
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['table', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), ['tbody', 0]]
|
||||
},
|
||||
})
|
||||
|
||||
// Cell alignment is kept through htmlAttrs (style or align, as stored)
|
||||
// instead of TipTap's align attribute, which rewrites it as a style.
|
||||
function withoutAlign<T extends Record<string, unknown>>(attributes: T | undefined): Omit<T, 'align'> {
|
||||
const { align: _align, ...rest } = attributes ?? ({} as T)
|
||||
return rest
|
||||
}
|
||||
|
||||
export const PlainTableCell = TableCell.extend({
|
||||
addAttributes() {
|
||||
return withoutAlign(this.parent?.())
|
||||
},
|
||||
})
|
||||
|
||||
export const PlainTableHeader = TableHeader.extend({
|
||||
addAttributes() {
|
||||
return withoutAlign(this.parent?.())
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* preservationExtensions is the editor schema (D-03, D-04): StarterKit
|
||||
* without the trailing node and with links that gain no target, rel or class
|
||||
* and accept only policy URLs; inline images without base64; tables; and
|
||||
* the preservation nodes and marks.
|
||||
*/
|
||||
export function preservationExtensions(labels: Labels = labelsFor('en')): AnyExtension[] {
|
||||
return [
|
||||
StarterKit.configure({
|
||||
trailingNode: false,
|
||||
link: {
|
||||
openOnClick: false,
|
||||
HTMLAttributes: { target: null, rel: null, class: null },
|
||||
isAllowedUri: (url) => isPolicyUrl(url),
|
||||
},
|
||||
}),
|
||||
Image.configure({ inline: true, allowBase64: false }),
|
||||
PlainTable,
|
||||
TableRow,
|
||||
PlainTableHeader,
|
||||
PlainTableCell,
|
||||
Figure,
|
||||
FigureImage,
|
||||
Figcaption,
|
||||
Embed.configure({ labels }),
|
||||
RawHtml.configure({ labels }),
|
||||
Span,
|
||||
HtmlAttributes,
|
||||
]
|
||||
}
|
||||
154
assets/src/blogeditor/labels.ts
Normal file
154
assets/src/blogeditor/labels.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
/** Every label, prompt and error the Blog Editor shows. */
|
||||
export interface Labels {
|
||||
tabWysiwyg: string
|
||||
tabSource: string
|
||||
toolbar: string
|
||||
paragraph: string
|
||||
heading2: string
|
||||
heading3: string
|
||||
heading4: string
|
||||
bold: string
|
||||
italic: string
|
||||
bulletList: string
|
||||
orderedList: string
|
||||
link: string
|
||||
blockquote: string
|
||||
insertTable: string
|
||||
addRow: string
|
||||
deleteRow: string
|
||||
addColumn: string
|
||||
deleteColumn: string
|
||||
deleteTable: string
|
||||
image: string
|
||||
embed: string
|
||||
rawHtml: string
|
||||
undo: string
|
||||
redo: string
|
||||
linkUrl: string
|
||||
linkApply: string
|
||||
linkRemove: string
|
||||
linkInvalid: string
|
||||
embedUrl: string
|
||||
embedInsert: string
|
||||
embedInvalid: string
|
||||
cancel: string
|
||||
uploading: string
|
||||
uploadFailed: string
|
||||
uploadTimeout: string
|
||||
notAnImage: string
|
||||
altText: string
|
||||
caption: string
|
||||
altRequired: string
|
||||
insertImage: string
|
||||
rawHtmlCard: string
|
||||
editAsHtml: string
|
||||
embedCard: string
|
||||
}
|
||||
|
||||
const en: Labels = {
|
||||
tabWysiwyg: 'WYSIWYG',
|
||||
tabSource: 'Source',
|
||||
toolbar: 'Formatting',
|
||||
paragraph: 'Paragraph',
|
||||
heading2: 'Heading 2',
|
||||
heading3: 'Heading 3',
|
||||
heading4: 'Heading 4',
|
||||
bold: 'Bold',
|
||||
italic: 'Italic',
|
||||
bulletList: 'Bullet list',
|
||||
orderedList: 'Numbered list',
|
||||
link: 'Link',
|
||||
blockquote: 'Quote',
|
||||
insertTable: 'Insert table',
|
||||
addRow: 'Add row',
|
||||
deleteRow: 'Delete row',
|
||||
addColumn: 'Add column',
|
||||
deleteColumn: 'Delete column',
|
||||
deleteTable: 'Delete table',
|
||||
image: 'Image',
|
||||
embed: 'Embed video',
|
||||
rawHtml: 'HTML block',
|
||||
undo: 'Undo',
|
||||
redo: 'Redo',
|
||||
linkUrl: 'Link URL',
|
||||
linkApply: 'Apply',
|
||||
linkRemove: 'Remove link',
|
||||
linkInvalid: 'Links must use http, https, mailto or a relative URL.',
|
||||
embedUrl: 'YouTube URL',
|
||||
embedInsert: 'Insert',
|
||||
embedInvalid: 'This video URL is not supported. Paste a YouTube link.',
|
||||
cancel: 'Cancel',
|
||||
uploading: 'Uploading {name}…',
|
||||
uploadFailed: 'Upload failed: {error}',
|
||||
uploadTimeout: 'The upload of {name} took too long.',
|
||||
notAnImage: 'Only JPEG, PNG, GIF and WebP images can be uploaded.',
|
||||
altText: 'Alt text (required)',
|
||||
caption: 'Caption (optional)',
|
||||
altRequired: 'Enter the alt text.',
|
||||
insertImage: 'Insert image',
|
||||
rawHtmlCard: 'HTML block',
|
||||
editAsHtml: 'Edit as HTML',
|
||||
embedCard: 'Embedded video',
|
||||
}
|
||||
|
||||
const pl: Labels = {
|
||||
tabWysiwyg: 'WYSIWYG',
|
||||
tabSource: 'Źródło',
|
||||
toolbar: 'Formatowanie',
|
||||
paragraph: 'Akapit',
|
||||
heading2: 'Nagłówek 2',
|
||||
heading3: 'Nagłówek 3',
|
||||
heading4: 'Nagłówek 4',
|
||||
bold: 'Pogrubienie',
|
||||
italic: 'Kursywa',
|
||||
bulletList: 'Lista punktowana',
|
||||
orderedList: 'Lista numerowana',
|
||||
link: 'Link',
|
||||
blockquote: 'Cytat',
|
||||
insertTable: 'Wstaw tabelę',
|
||||
addRow: 'Dodaj wiersz',
|
||||
deleteRow: 'Usuń wiersz',
|
||||
addColumn: 'Dodaj kolumnę',
|
||||
deleteColumn: 'Usuń kolumnę',
|
||||
deleteTable: 'Usuń tabelę',
|
||||
image: 'Obraz',
|
||||
embed: 'Osadź wideo',
|
||||
rawHtml: 'Blok HTML',
|
||||
undo: 'Cofnij',
|
||||
redo: 'Ponów',
|
||||
linkUrl: 'Adres linku',
|
||||
linkApply: 'Zastosuj',
|
||||
linkRemove: 'Usuń link',
|
||||
linkInvalid: 'Link musi używać http, https, mailto albo adresu względnego.',
|
||||
embedUrl: 'Adres filmu YouTube',
|
||||
embedInsert: 'Wstaw',
|
||||
embedInvalid: 'Ten adres filmu nie jest obsługiwany. Wklej link do YouTube.',
|
||||
cancel: 'Anuluj',
|
||||
uploading: 'Wysyłanie {name}…',
|
||||
uploadFailed: 'Wysyłanie nie powiodło się: {error}',
|
||||
uploadTimeout: 'Wysyłanie {name} trwało zbyt długo.',
|
||||
notAnImage: 'Można wysłać tylko obrazy JPEG, PNG, GIF i WebP.',
|
||||
altText: 'Tekst alternatywny (wymagany)',
|
||||
caption: 'Podpis (opcjonalny)',
|
||||
altRequired: 'Wpisz tekst alternatywny.',
|
||||
insertImage: 'Wstaw obraz',
|
||||
rawHtmlCard: 'Blok HTML',
|
||||
editAsHtml: 'Edytuj jako HTML',
|
||||
embedCard: 'Osadzone wideo',
|
||||
}
|
||||
|
||||
const TABLES: Record<string, Labels> = { en, pl }
|
||||
|
||||
/**
|
||||
* labelsFor returns the labels for the admin UI locale (the element's
|
||||
* ui-locale attribute): the table for its language subtag, else English.
|
||||
*/
|
||||
export function labelsFor(uiLocale: string | null | undefined): Labels {
|
||||
const language = (uiLocale ?? '').toLowerCase().split(/[-_]/)[0] ?? ''
|
||||
return TABLES[language] ?? en
|
||||
}
|
||||
|
||||
/** format replaces {name} placeholders in a label. */
|
||||
export function format(label: string, values: Record<string, string>): string {
|
||||
return label.replace(/\{(\w+)\}/g, (match, key: string) => values[key] ?? match)
|
||||
}
|
||||
@@ -1,14 +1,305 @@
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import { DOMSerializer } from '@tiptap/pm/model'
|
||||
import { elementFromString, type Editor } from '@tiptap/core'
|
||||
import { DOMParser as PMDOMParser, DOMSerializer, type Node as PMNode, type Schema } from '@tiptap/pm/model'
|
||||
import { inert } from './extensions'
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* One top-level piece of the original HTML: its exact source text and the
|
||||
* nodes it parses to on its own. Whitespace and comments parse to no node.
|
||||
*/
|
||||
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 }))
|
||||
interface Segment {
|
||||
html: string
|
||||
nodes: PMNode[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocks is the original HTML split into top-level blocks (RESEARCH A3).
|
||||
* Each block keeps its exact source and the text between it and the
|
||||
* previous block, so unedited blocks keep their bytes after an edit.
|
||||
*/
|
||||
export interface Blocks {
|
||||
blocks: { before: string; html: string; nodes: PMNode[] }[]
|
||||
trailing: string
|
||||
}
|
||||
|
||||
const VOID_TAGS = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr'])
|
||||
const RAW_TEXT_TAGS = new Set(['script', 'style', 'textarea', 'title', 'xmp', 'iframe', 'noembed', 'noframes'])
|
||||
const TAG_NAME = /^<\/?([a-zA-Z][^\s/>]*)/
|
||||
|
||||
/** tagEnd returns the index after the '>' closing the tag at start, or -1. */
|
||||
function tagEnd(html: string, start: number): number {
|
||||
let quote = ''
|
||||
let prev = ''
|
||||
for (let i = start + 1; i < html.length; i++) {
|
||||
const c = html[i]!
|
||||
if (quote) {
|
||||
if (c === quote) quote = ''
|
||||
continue
|
||||
}
|
||||
if ((c === '"' || c === "'") && prev === '=') {
|
||||
quote = c
|
||||
continue
|
||||
}
|
||||
if (c === '>') return i + 1
|
||||
if (!/\s/.test(c)) prev = c
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
/** rawTextEnd returns the index after the end tag of a raw text element, or -1. */
|
||||
function rawTextEnd(html: string, from: number, name: string): number {
|
||||
const close = new RegExp(`</${name}(?=[\\s/>])`, 'ig')
|
||||
close.lastIndex = from
|
||||
const match = close.exec(html)
|
||||
return match ? tagEnd(html, match.index) : -1
|
||||
}
|
||||
|
||||
/** elementEnd returns the index after the element whose start tag ends at from. */
|
||||
function elementEnd(html: string, from: number, name: string): number {
|
||||
const stack = [name]
|
||||
let i = from
|
||||
while (stack.length > 0) {
|
||||
const lt = html.indexOf('<', i)
|
||||
if (lt < 0) return -1
|
||||
if (html.startsWith('<!--', lt)) {
|
||||
const end = html.indexOf('-->', lt + 4)
|
||||
if (end < 0) return -1
|
||||
i = end + 3
|
||||
continue
|
||||
}
|
||||
const match = TAG_NAME.exec(html.slice(lt, lt + 64))
|
||||
if (!match) {
|
||||
i = lt + 1
|
||||
continue
|
||||
}
|
||||
const tag = match[1]!.toLowerCase()
|
||||
const end = tagEnd(html, lt)
|
||||
if (end < 0) return -1
|
||||
i = end
|
||||
if (html[lt + 1] === '/') {
|
||||
const at = stack.lastIndexOf(tag)
|
||||
if (at >= 0) stack.length = at
|
||||
continue
|
||||
}
|
||||
if (VOID_TAGS.has(tag) || html[end - 2] === '/') continue
|
||||
if (RAW_TEXT_TAGS.has(tag)) {
|
||||
i = rawTextEnd(html, end, tag)
|
||||
if (i < 0) return -1
|
||||
continue
|
||||
}
|
||||
stack.push(tag)
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
/**
|
||||
* splitTopLevel splits html into its top-level pieces (elements, text runs,
|
||||
* comments) by source position, or returns null when it cannot.
|
||||
*/
|
||||
function splitTopLevel(html: string): string[] | null {
|
||||
const pieces: string[] = []
|
||||
let i = 0
|
||||
while (i < html.length) {
|
||||
const start = i
|
||||
if (html[i] !== '<') {
|
||||
const lt = html.indexOf('<', i)
|
||||
i = lt < 0 ? html.length : lt
|
||||
} else if (html.startsWith('<!--', i)) {
|
||||
const end = html.indexOf('-->', i + 4)
|
||||
if (end < 0) return null
|
||||
i = end + 3
|
||||
} else if (html.startsWith('<!', i) || html.startsWith('<?', i) || html.startsWith('</', i)) {
|
||||
const end = tagEnd(html, i)
|
||||
if (end < 0) return null
|
||||
i = end
|
||||
} else {
|
||||
const match = TAG_NAME.exec(html.slice(i, i + 64))
|
||||
if (!match) {
|
||||
const lt = html.indexOf('<', i + 1)
|
||||
i = lt < 0 ? html.length : lt
|
||||
} else {
|
||||
const tag = match[1]!.toLowerCase()
|
||||
const end = tagEnd(html, i)
|
||||
if (end < 0) return null
|
||||
if (VOID_TAGS.has(tag) || html[end - 2] === '/') i = end
|
||||
else if (RAW_TEXT_TAGS.has(tag)) i = rawTextEnd(html, end, tag)
|
||||
else i = elementEnd(html, end, tag)
|
||||
if (i < 0) return null
|
||||
}
|
||||
}
|
||||
pieces.push(html.slice(start, i))
|
||||
}
|
||||
return pieces
|
||||
}
|
||||
|
||||
/** isGap reports whether a piece parses to no block: whitespace or a comment. */
|
||||
function isGap(piece: string): boolean {
|
||||
return /^\s*$/.test(piece) || (piece.startsWith('<!--') && piece.endsWith('-->'))
|
||||
}
|
||||
|
||||
/**
|
||||
* captureBlocks splits the original HTML into top-level blocks and parses
|
||||
* each alone, recording its nodes, its exact outer HTML and the text
|
||||
* between blocks. It returns null when the pieces do not parse to the same
|
||||
* DOM as the whole (then every edited document is serialized whole).
|
||||
*/
|
||||
export function captureBlocks(html: string, schema: Schema): Blocks | null {
|
||||
const pieces = splitTopLevel(html)
|
||||
if (!pieces) return null
|
||||
const whole = elementFromString(html).innerHTML
|
||||
const joined = pieces.map((piece) => elementFromString(piece).innerHTML).join('')
|
||||
if (whole !== joined) return null
|
||||
|
||||
const parser = PMDOMParser.fromSchema(schema)
|
||||
const segments: Segment[] = pieces.map((piece) => {
|
||||
if (isGap(piece)) return { html: piece, nodes: [] }
|
||||
const nodes: PMNode[] = []
|
||||
parser.parseSlice(elementFromString(piece)).content.forEach((node) => nodes.push(node))
|
||||
return { html: piece, nodes }
|
||||
})
|
||||
|
||||
const blocks: Blocks['blocks'] = []
|
||||
let pending = ''
|
||||
for (const segment of segments) {
|
||||
if (segment.nodes.length === 0) {
|
||||
pending += segment.html
|
||||
continue
|
||||
}
|
||||
blocks.push({ before: pending, html: segment.html, nodes: segment.nodes })
|
||||
pending = ''
|
||||
}
|
||||
return { blocks, trailing: pending }
|
||||
}
|
||||
|
||||
/** tidyTables applies the stored table form to serialized tables. */
|
||||
function tidyTables(root: Element): void {
|
||||
for (const table of Array.from(root.querySelectorAll('table'))) {
|
||||
const tbody = Array.from(table.children).find((child) => child.nodeName === 'TBODY')
|
||||
const first = tbody?.firstElementChild
|
||||
// A leading row of header cells goes into thead.
|
||||
if (tbody && first && first.children.length > 0 && Array.from(first.children).every((cell) => cell.nodeName === 'TH')) {
|
||||
const thead = table.ownerDocument.createElement('thead')
|
||||
thead.append(first)
|
||||
table.insertBefore(thead, tbody)
|
||||
if (tbody.children.length === 0) tbody.remove()
|
||||
}
|
||||
}
|
||||
// A cell holding one plain paragraph holds its text directly.
|
||||
for (const cell of Array.from(root.querySelectorAll('td, th'))) {
|
||||
const only = cell.childNodes.length === 1 ? cell.firstChild : null
|
||||
if (only && only.nodeName === 'P' && (only as Element).attributes.length === 0) {
|
||||
cell.replaceChildren(...Array.from(only.childNodes))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** serializeNode returns the HTML of one top-level node. */
|
||||
function serializeNode(node: PMNode, serializer: DOMSerializer): string {
|
||||
if (node.type.name === 'rawHtml') return String(node.attrs.html ?? '')
|
||||
const doc = inert()
|
||||
const host = doc.createElement('div')
|
||||
host.appendChild(serializer.serializeNode(node, { document: doc }))
|
||||
tidyTables(host)
|
||||
return host.innerHTML
|
||||
}
|
||||
|
||||
function sameNodes(a: readonly PMNode[], b: readonly PMNode[], from: number): boolean {
|
||||
if (from + a.length > b.length) return false
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (!a[i]!.eq(b[from + i]!)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* align matches captured blocks to the document's top-level nodes in order
|
||||
* (longest common subsequence by node count) and returns the pairs.
|
||||
*/
|
||||
function align(blocks: Blocks['blocks'], nodes: readonly PMNode[]): { block: number; node: number }[] {
|
||||
const pairs: { block: number; node: number }[] = []
|
||||
// Unchanged prefix and suffix first, so a single edit costs no table.
|
||||
let lo = 0
|
||||
let at = 0
|
||||
while (lo < blocks.length && sameNodes(blocks[lo]!.nodes, nodes, at)) {
|
||||
pairs.push({ block: lo, node: at })
|
||||
at += blocks[lo]!.nodes.length
|
||||
lo++
|
||||
}
|
||||
const tail: { block: number; node: number }[] = []
|
||||
let hi = blocks.length
|
||||
let end = nodes.length
|
||||
while (hi > lo && end - blocks[hi - 1]!.nodes.length >= at && sameNodes(blocks[hi - 1]!.nodes, nodes, end - blocks[hi - 1]!.nodes.length)) {
|
||||
hi--
|
||||
end -= blocks[hi]!.nodes.length
|
||||
tail.unshift({ block: hi, node: end })
|
||||
}
|
||||
|
||||
const rows = hi - lo
|
||||
const cols = end - at
|
||||
if (rows > 0 && cols > 0) {
|
||||
const width = cols + 1
|
||||
const score = new Int32Array((rows + 1) * width)
|
||||
const matches = (b: number, n: number) => sameNodes(blocks[lo + b]!.nodes, nodes, at + n) && at + n + blocks[lo + b]!.nodes.length <= end
|
||||
for (let b = rows - 1; b >= 0; b--) {
|
||||
for (let n = cols - 1; n >= 0; n--) {
|
||||
let best = Math.max(score[(b + 1) * width + n]!, score[b * width + n + 1]!)
|
||||
if (matches(b, n)) {
|
||||
const size = blocks[lo + b]!.nodes.length
|
||||
best = Math.max(best, size + score[(b + 1) * width + n + size]!)
|
||||
}
|
||||
score[b * width + n] = best
|
||||
}
|
||||
}
|
||||
let b = 0
|
||||
let n = 0
|
||||
while (b < rows && n < cols) {
|
||||
const size = blocks[lo + b]!.nodes.length
|
||||
if (matches(b, n) && score[b * width + n] === size + score[(b + 1) * width + n + size]) {
|
||||
pairs.push({ block: lo + b, node: at + n })
|
||||
b++
|
||||
n += size
|
||||
} else if (score[(b + 1) * width + n]! >= score[b * width + n + 1]!) {
|
||||
b++
|
||||
} else {
|
||||
n++
|
||||
}
|
||||
}
|
||||
}
|
||||
return pairs.concat(tail)
|
||||
}
|
||||
|
||||
/**
|
||||
* serializeDocument returns the HTML of the edited document (D-03). Runs of
|
||||
* top-level nodes still equal (Node.eq) to the captured blocks are emitted
|
||||
* as their original source; raw HTML blocks are emitted verbatim; the rest
|
||||
* is serialized with the schema, tables in their stored form. It is the
|
||||
* element's value after a user edit; editor.getHTML() never is.
|
||||
*/
|
||||
export function serializeDocument(editor: Editor, blocks: Blocks | null): string {
|
||||
const serializer = DOMSerializer.fromSchema(editor.schema)
|
||||
const nodes: PMNode[] = []
|
||||
editor.state.doc.forEach((node) => nodes.push(node))
|
||||
const captured = blocks?.blocks ?? []
|
||||
const pairs = align(captured, nodes)
|
||||
|
||||
let out = ''
|
||||
let nextBlock = 0
|
||||
let nextNode = 0
|
||||
const emitGap = (block: number, node: number) => {
|
||||
if (node > nextNode) {
|
||||
// Replaced or inserted nodes keep the separator of the first block
|
||||
// they replace.
|
||||
if (block > nextBlock) out += captured[nextBlock]!.before
|
||||
for (let i = nextNode; i < node; i++) out += serializeNode(nodes[i]!, serializer)
|
||||
}
|
||||
}
|
||||
for (const pair of pairs) {
|
||||
emitGap(pair.block, pair.node)
|
||||
const block = captured[pair.block]!
|
||||
out += block.before + block.html
|
||||
nextBlock = pair.block + 1
|
||||
nextNode = pair.node + block.nodes.length
|
||||
}
|
||||
emitGap(captured.length, nodes.length)
|
||||
if (blocks && (pairs.length > 0 || nodes.length === 0)) out += blocks.trailing
|
||||
return out
|
||||
}
|
||||
|
||||
157
assets/src/blogeditor/toolbar.ts
Normal file
157
assets/src/blogeditor/toolbar.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import type { Labels } from './labels'
|
||||
|
||||
/** What the toolbar needs from the element. */
|
||||
export interface ToolbarHost {
|
||||
editor: () => Editor | undefined
|
||||
labels: Labels
|
||||
openLink: () => void
|
||||
openEmbed: () => void
|
||||
pickImage: () => void
|
||||
insertRawHtml: () => void
|
||||
showSource: (on: boolean) => void
|
||||
}
|
||||
|
||||
export interface Toolbar {
|
||||
element: HTMLDivElement
|
||||
/** Updates pressed and enabled states from the editor. */
|
||||
refresh: () => void
|
||||
/** Marks the selected tab. */
|
||||
selectTab: (source: boolean) => void
|
||||
/** Disables every control (the element's disabled attribute). */
|
||||
setDisabled: (disabled: boolean) => void
|
||||
}
|
||||
|
||||
interface ButtonSpec {
|
||||
command: string
|
||||
glyph: string
|
||||
label: keyof Labels
|
||||
run: (editor: Editor, host: ToolbarHost) => void
|
||||
active?: (editor: Editor) => boolean
|
||||
enabled?: (editor: Editor) => boolean
|
||||
/** Runs without the editor (opens a form or the file picker). */
|
||||
host?: boolean
|
||||
}
|
||||
|
||||
const inTable = (editor: Editor) => editor.isActive('table')
|
||||
|
||||
const GROUPS: ButtonSpec[][] = [
|
||||
[
|
||||
{ command: 'paragraph', glyph: 'P', label: 'paragraph', run: (e) => e.chain().focus().setParagraph().run(), active: (e) => e.isActive('paragraph') },
|
||||
{ command: 'heading2', glyph: 'H2', label: 'heading2', run: (e) => e.chain().focus().toggleHeading({ level: 2 }).run(), active: (e) => e.isActive('heading', { level: 2 }) },
|
||||
{ command: 'heading3', glyph: 'H3', label: 'heading3', run: (e) => e.chain().focus().toggleHeading({ level: 3 }).run(), active: (e) => e.isActive('heading', { level: 3 }) },
|
||||
{ command: 'heading4', glyph: 'H4', label: 'heading4', run: (e) => e.chain().focus().toggleHeading({ level: 4 }).run(), active: (e) => e.isActive('heading', { level: 4 }) },
|
||||
],
|
||||
[
|
||||
{ command: 'bold', glyph: 'B', label: 'bold', run: (e) => e.chain().focus().toggleBold().run(), active: (e) => e.isActive('bold') },
|
||||
{ command: 'italic', glyph: 'I', label: 'italic', run: (e) => e.chain().focus().toggleItalic().run(), active: (e) => e.isActive('italic') },
|
||||
{ command: 'link', glyph: 'Link', label: 'link', run: (_e, h) => h.openLink(), active: (e) => e.isActive('link'), host: true },
|
||||
],
|
||||
[
|
||||
{ command: 'bulletList', glyph: '•', label: 'bulletList', run: (e) => e.chain().focus().toggleBulletList().run(), active: (e) => e.isActive('bulletList') },
|
||||
{ command: 'orderedList', glyph: '1.', label: 'orderedList', run: (e) => e.chain().focus().toggleOrderedList().run(), active: (e) => e.isActive('orderedList') },
|
||||
{ command: 'blockquote', glyph: '“”', label: 'blockquote', run: (e) => e.chain().focus().toggleBlockquote().run(), active: (e) => e.isActive('blockquote') },
|
||||
],
|
||||
[
|
||||
{ command: 'insertTable', glyph: 'Table', label: 'insertTable', run: (e) => e.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() },
|
||||
{ command: 'addRow', glyph: '+Row', label: 'addRow', run: (e) => e.chain().focus().addRowAfter().run(), enabled: inTable },
|
||||
{ command: 'deleteRow', glyph: '−Row', label: 'deleteRow', run: (e) => e.chain().focus().deleteRow().run(), enabled: inTable },
|
||||
{ command: 'addColumn', glyph: '+Col', label: 'addColumn', run: (e) => e.chain().focus().addColumnAfter().run(), enabled: inTable },
|
||||
{ command: 'deleteColumn', glyph: '−Col', label: 'deleteColumn', run: (e) => e.chain().focus().deleteColumn().run(), enabled: inTable },
|
||||
{ command: 'deleteTable', glyph: '×Table', label: 'deleteTable', run: (e) => e.chain().focus().deleteTable().run(), enabled: inTable },
|
||||
],
|
||||
[
|
||||
{ command: 'image', glyph: 'Image', label: 'image', run: (_e, h) => h.pickImage(), host: true },
|
||||
{ command: 'embed', glyph: 'Video', label: 'embed', run: (_e, h) => h.openEmbed(), host: true },
|
||||
{ command: 'rawHtml', glyph: 'HTML', label: 'rawHtml', run: (_e, h) => h.insertRawHtml(), host: true },
|
||||
],
|
||||
[
|
||||
{ command: 'undo', glyph: 'Undo', label: 'undo', run: (e) => e.chain().focus().undo().run(), enabled: (e) => e.can().undo() },
|
||||
{ command: 'redo', glyph: 'Redo', label: 'redo', run: (e) => e.chain().focus().redo().run(), enabled: (e) => e.can().redo() },
|
||||
],
|
||||
]
|
||||
|
||||
/**
|
||||
* buildToolbar builds the formatting toolbar and the WYSIWYG/Source tabs
|
||||
* (D-04): paragraph and headings 2 to 4, bold, italic, link, lists,
|
||||
* blockquote, tables, image, embed, raw HTML block, undo and redo.
|
||||
*/
|
||||
export function buildToolbar(host: ToolbarHost): Toolbar {
|
||||
const { labels } = host
|
||||
const element = document.createElement('div')
|
||||
element.className = 'blogeditor__bar'
|
||||
|
||||
const tools = document.createElement('div')
|
||||
tools.className = 'blogeditor__tools'
|
||||
tools.setAttribute('role', 'toolbar')
|
||||
tools.setAttribute('aria-label', labels.toolbar)
|
||||
|
||||
const buttons: { spec: ButtonSpec; button: HTMLButtonElement }[] = []
|
||||
for (const group of GROUPS) {
|
||||
const wrap = document.createElement('div')
|
||||
wrap.className = 'blogeditor__group'
|
||||
for (const spec of group) {
|
||||
const button = document.createElement('button')
|
||||
button.type = 'button'
|
||||
button.dataset.command = spec.command
|
||||
button.textContent = spec.glyph
|
||||
button.title = labels[spec.label]
|
||||
button.setAttribute('aria-label', labels[spec.label])
|
||||
// Keep the editor selection when a button is pressed.
|
||||
button.addEventListener('mousedown', (event) => event.preventDefault())
|
||||
button.addEventListener('click', () => {
|
||||
const editor = host.editor()
|
||||
if (!editor) return
|
||||
if (!spec.host && !editor.isEditable) return
|
||||
spec.run(editor, host)
|
||||
})
|
||||
wrap.append(button)
|
||||
buttons.push({ spec, button })
|
||||
}
|
||||
tools.append(wrap)
|
||||
}
|
||||
|
||||
const tabs = document.createElement('div')
|
||||
tabs.className = 'blogeditor__tabs'
|
||||
tabs.setAttribute('role', 'tablist')
|
||||
const tab = (name: 'wysiwyg' | 'source', label: string) => {
|
||||
const button = document.createElement('button')
|
||||
button.type = 'button'
|
||||
button.dataset.tab = name
|
||||
button.setAttribute('role', 'tab')
|
||||
button.textContent = label
|
||||
button.addEventListener('click', () => host.showSource(name === 'source'))
|
||||
tabs.append(button)
|
||||
return button
|
||||
}
|
||||
const wysiwygTab = tab('wysiwyg', labels.tabWysiwyg)
|
||||
const sourceTab = tab('source', labels.tabSource)
|
||||
|
||||
element.append(tools, tabs)
|
||||
|
||||
let disabled = false
|
||||
let source = false
|
||||
const refresh = () => {
|
||||
const editor = host.editor()
|
||||
for (const { spec, button } of buttons) {
|
||||
if (spec.active) button.setAttribute('aria-pressed', String(!!editor && spec.active(editor)))
|
||||
const enabled = !!editor && !disabled && !source && (spec.enabled ? spec.enabled(editor) : true)
|
||||
button.disabled = !enabled
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
element,
|
||||
refresh,
|
||||
selectTab: (on) => {
|
||||
source = on
|
||||
wysiwygTab.setAttribute('aria-selected', String(!on))
|
||||
sourceTab.setAttribute('aria-selected', String(on))
|
||||
refresh()
|
||||
},
|
||||
setDisabled: (on) => {
|
||||
disabled = on
|
||||
refresh()
|
||||
},
|
||||
}
|
||||
}
|
||||
210
tests/blogeditor/corpus.test.ts
Normal file
210
tests/blogeditor/corpus.test.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import { parseEmbedUrl } from '../../assets/src/blogeditor/embed'
|
||||
import { isPolicyUrl } from '../../assets/src/blogeditor/extensions'
|
||||
import { mount, type } from './helpers'
|
||||
|
||||
// The editor's own output (D-05, D-10 amended). Every entry is built through
|
||||
// editor commands and typing only, never by inserting HTML, so it is what
|
||||
// the toolbar and input rules produce. The committed file is read in place
|
||||
// by the Go policy test (classes/editor_policy_test.go) and copied into the
|
||||
// layer, so both sanitizer policies are tested against the real output.
|
||||
// Entry names are stable once committed. UPDATE_CORPUS=1 rewrites the file.
|
||||
const CORPUS = join(dirname(fileURLToPath(import.meta.url)), 'fixtures', 'editor-corpus.json')
|
||||
|
||||
interface Entry {
|
||||
name: string
|
||||
html: string
|
||||
}
|
||||
|
||||
/** startBlock puts the cursor in an empty paragraph at the end of the document. */
|
||||
function startBlock(editor: Editor): void {
|
||||
const { doc } = editor.state
|
||||
const last = doc.lastChild
|
||||
if (last && last.type.name === 'paragraph' && last.content.size === 0) {
|
||||
editor.commands.setTextSelection(doc.content.size - 1)
|
||||
return
|
||||
}
|
||||
editor.commands.insertContentAt(doc.content.size, { type: 'paragraph' })
|
||||
editor.commands.setTextSelection(editor.state.doc.content.size - 1)
|
||||
}
|
||||
|
||||
/** link types text and links it through setLink, then leaves the link. */
|
||||
function link(editor: Editor, text: string, href: string): void {
|
||||
const from = editor.state.selection.from
|
||||
type(editor, text)
|
||||
const to = editor.state.selection.from
|
||||
expect(editor.chain().setTextSelection({ from, to }).setLink({ href }).run(), href).toBe(true)
|
||||
editor.chain().setTextSelection(to).unsetMark('link').run()
|
||||
}
|
||||
|
||||
function mark(editor: Editor, toggle: () => boolean, text: string): void {
|
||||
expect(toggle()).toBe(true)
|
||||
type(editor, text)
|
||||
expect(toggle()).toBe(true)
|
||||
}
|
||||
|
||||
const IMAGE_SRC = '/storage/app/uploads/public/journal/2026/10/turkey-tail.jpg'
|
||||
|
||||
const builders: Record<string, (editor: Editor) => void> = {
|
||||
'embed-youtube': (editor) => {
|
||||
startBlock(editor)
|
||||
const embed = parseEmbedUrl('https://www.youtube.com/watch?v=dQw4w9WgXcQ')!
|
||||
expect(editor.commands.setEmbed(embed)).toBe(true)
|
||||
},
|
||||
'image-without-caption': (editor) => {
|
||||
startBlock(editor)
|
||||
expect(editor.commands.setImage({ src: IMAGE_SRC, alt: 'Turkey Tail: czy to "grzyb odporności"?' })).toBe(true)
|
||||
},
|
||||
'image-with-caption': (editor) => {
|
||||
startBlock(editor)
|
||||
expect(
|
||||
editor.commands.setFigure({ src: IMAGE_SRC, alt: 'Owocniki wrośniaka', caption: 'Owocniki wrośniaka różnobarwnego.' }),
|
||||
).toBe(true)
|
||||
},
|
||||
'table-header-row': (editor) => {
|
||||
startBlock(editor)
|
||||
expect(editor.commands.insertTable({ rows: 3, cols: 3, withHeaderRow: true })).toBe(true)
|
||||
const cells = ['Forma', 'Dawka', 'Uwagi', 'Proszek', '1-2 g', 'z posiłkiem', 'Kapsułki', '2 x 500 mg', 'rano']
|
||||
cells.forEach((text, i) => {
|
||||
if (i > 0) expect(editor.commands.goToNextCell()).toBe(true)
|
||||
type(editor, text)
|
||||
})
|
||||
},
|
||||
links: (editor) => {
|
||||
startBlock(editor)
|
||||
type(editor, 'Źródła: ')
|
||||
link(editor, 'strona', 'http://example.test/')
|
||||
type(editor, ', ')
|
||||
link(editor, 'badania', 'https://example.test/badania?rok=2026#wyniki')
|
||||
type(editor, ', ')
|
||||
link(editor, 'redakcja', 'mailto:redakcja@example.test')
|
||||
type(editor, ' i ')
|
||||
link(editor, 'inny wpis', '/blog/beta-glukany')
|
||||
type(editor, '.')
|
||||
},
|
||||
headings: (editor) => {
|
||||
for (const level of [2, 3, 4] as const) {
|
||||
startBlock(editor)
|
||||
expect(editor.commands.setHeading({ level })).toBe(true)
|
||||
type(editor, `Nagłówek ${level}`)
|
||||
}
|
||||
},
|
||||
'bullet-list': (editor) => {
|
||||
startBlock(editor)
|
||||
expect(editor.commands.toggleBulletList()).toBe(true)
|
||||
type(editor, 'Beta-glukany')
|
||||
expect(editor.commands.splitListItem('listItem')).toBe(true)
|
||||
type(editor, 'Antyoksydanty')
|
||||
},
|
||||
'ordered-list': (editor) => {
|
||||
startBlock(editor)
|
||||
expect(editor.commands.toggleOrderedList()).toBe(true)
|
||||
type(editor, 'Pierwszy krok')
|
||||
expect(editor.commands.splitListItem('listItem')).toBe(true)
|
||||
type(editor, 'Drugi krok')
|
||||
},
|
||||
// The blockquote sits between the two ordered lists, so the combined
|
||||
// document keeps them as two lists instead of joining them.
|
||||
blockquote: (editor) => {
|
||||
startBlock(editor)
|
||||
expect(editor.commands.toggleBlockquote()).toBe(true)
|
||||
type(editor, 'Grzyby funkcjonalne wspierają organizm.')
|
||||
},
|
||||
'ordered-list-start-3': (editor) => {
|
||||
startBlock(editor)
|
||||
expect(editor.commands.toggleOrderedList()).toBe(true)
|
||||
expect(editor.commands.updateAttributes('orderedList', { start: 3 })).toBe(true)
|
||||
type(editor, 'Trzeci krok')
|
||||
expect(editor.commands.splitListItem('listItem')).toBe(true)
|
||||
type(editor, 'Czwarty krok')
|
||||
},
|
||||
'inline-marks': (editor) => {
|
||||
startBlock(editor)
|
||||
type(editor, 'Tekst ')
|
||||
mark(editor, () => editor.commands.toggleBold(), 'pogrubiony')
|
||||
type(editor, ', ')
|
||||
mark(editor, () => editor.commands.toggleItalic(), 'kursywa')
|
||||
type(editor, ', ')
|
||||
mark(editor, () => editor.commands.toggleStrike(), 'przekreślony')
|
||||
type(editor, ', ')
|
||||
mark(editor, () => editor.commands.toggleUnderline(), 'podkreślony')
|
||||
type(editor, ' i ')
|
||||
mark(editor, () => editor.commands.toggleCode(), 'kod')
|
||||
type(editor, '.')
|
||||
},
|
||||
'code-block': (editor) => {
|
||||
startBlock(editor)
|
||||
expect(editor.commands.toggleCodeBlock()).toBe(true)
|
||||
type(editor, 'dawka = 2 * 500 // mg')
|
||||
},
|
||||
'horizontal-rule': (editor) => {
|
||||
startBlock(editor)
|
||||
type(editor, 'Przed linią')
|
||||
expect(editor.commands.setHorizontalRule()).toBe(true)
|
||||
type(editor, 'Po linii')
|
||||
},
|
||||
'hard-break': (editor) => {
|
||||
startBlock(editor)
|
||||
type(editor, 'Pierwsza linia')
|
||||
expect(editor.commands.setHardBreak()).toBe(true)
|
||||
type(editor, 'Druga linia')
|
||||
},
|
||||
}
|
||||
|
||||
function build(steps: ((editor: Editor) => void)[]): string {
|
||||
const { el, editor } = mount('')
|
||||
for (const step of steps) step(editor)
|
||||
const html = el.value
|
||||
el.remove()
|
||||
return html
|
||||
}
|
||||
|
||||
function corpus(): Entry[] {
|
||||
const entries: Entry[] = Object.entries(builders).map(([name, step]) => ({ name, html: build([step]) }))
|
||||
entries.push({ name: 'combined', html: build(Object.values(builders)) })
|
||||
return entries
|
||||
}
|
||||
|
||||
describe('editor corpus (D-05)', () => {
|
||||
const entries = corpus()
|
||||
|
||||
afterAll(() => {
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
it('matches the committed editor-corpus.json', () => {
|
||||
const text = `${JSON.stringify(entries, null, 2)}\n`
|
||||
if (process.env.UPDATE_CORPUS === '1') {
|
||||
writeFileSync(CORPUS, text)
|
||||
}
|
||||
expect(readFileSync(CORPUS, 'utf8')).toBe(text)
|
||||
})
|
||||
|
||||
it('holds no iframe, handler, target, rel or style and only policy URLs', () => {
|
||||
expect(entries.length).toBeGreaterThanOrEqual(15)
|
||||
const doc = document.implementation.createHTMLDocument('')
|
||||
for (const { name, html } of entries) {
|
||||
expect(html, name).not.toMatch(/<iframe|target=|rel=| on[a-z]+=|style=/i)
|
||||
expect(html, name).not.toBe('')
|
||||
expect(html, name).not.toMatch(/<p><\/p>$/)
|
||||
const host = doc.createElement('div')
|
||||
host.innerHTML = html
|
||||
for (const el of Array.from(host.querySelectorAll('[href], [src]'))) {
|
||||
const url = el.getAttribute('href') ?? el.getAttribute('src') ?? ''
|
||||
expect(isPolicyUrl(url), `${name}: ${url}`).toBe(true)
|
||||
}
|
||||
for (const a of Array.from(host.querySelectorAll('a'))) {
|
||||
expect(a.getAttributeNames(), name).toEqual(['href'])
|
||||
}
|
||||
}
|
||||
const html = entries.map((e) => e.html).join('\n')
|
||||
expect(html).toContain('<figure data-embed="youtube" data-id="dQw4w9WgXcQ"></figure>')
|
||||
expect(html).toContain('<figcaption>')
|
||||
expect(html).toContain('<table>')
|
||||
expect(html).toContain('start="3"')
|
||||
})
|
||||
})
|
||||
62
tests/blogeditor/fixtures/editor-corpus.json
Normal file
62
tests/blogeditor/fixtures/editor-corpus.json
Normal file
@@ -0,0 +1,62 @@
|
||||
[
|
||||
{
|
||||
"name": "embed-youtube",
|
||||
"html": "<figure data-embed=\"youtube\" data-id=\"dQw4w9WgXcQ\"></figure>"
|
||||
},
|
||||
{
|
||||
"name": "image-without-caption",
|
||||
"html": "<p><img src=\"/storage/app/uploads/public/journal/2026/10/turkey-tail.jpg\" alt=\"Turkey Tail: czy to "grzyb odporności"?\"></p>"
|
||||
},
|
||||
{
|
||||
"name": "image-with-caption",
|
||||
"html": "<figure><img src=\"/storage/app/uploads/public/journal/2026/10/turkey-tail.jpg\" alt=\"Owocniki wrośniaka\"><figcaption>Owocniki wrośniaka różnobarwnego.</figcaption></figure>"
|
||||
},
|
||||
{
|
||||
"name": "table-header-row",
|
||||
"html": "<table><thead><tr><th>Forma</th><th>Dawka</th><th>Uwagi</th></tr></thead><tbody><tr><td>Proszek</td><td>1-2 g</td><td>z posiłkiem</td></tr><tr><td>Kapsułki</td><td>2 x 500 mg</td><td>rano</td></tr></tbody></table>"
|
||||
},
|
||||
{
|
||||
"name": "links",
|
||||
"html": "<p>Źródła: <a href=\"http://example.test/\">strona</a>, <a href=\"https://example.test/badania?rok=2026#wyniki\">badania</a>, <a href=\"mailto:redakcja@example.test\">redakcja</a> i <a href=\"/blog/beta-glukany\">inny wpis</a>.</p>"
|
||||
},
|
||||
{
|
||||
"name": "headings",
|
||||
"html": "<h2>Nagłówek 2</h2><h3>Nagłówek 3</h3><h4>Nagłówek 4</h4>"
|
||||
},
|
||||
{
|
||||
"name": "bullet-list",
|
||||
"html": "<ul><li><p>Beta-glukany</p></li><li><p>Antyoksydanty</p></li></ul>"
|
||||
},
|
||||
{
|
||||
"name": "ordered-list",
|
||||
"html": "<ol><li><p>Pierwszy krok</p></li><li><p>Drugi krok</p></li></ol>"
|
||||
},
|
||||
{
|
||||
"name": "blockquote",
|
||||
"html": "<blockquote><p>Grzyby funkcjonalne wspierają organizm.</p></blockquote>"
|
||||
},
|
||||
{
|
||||
"name": "ordered-list-start-3",
|
||||
"html": "<ol start=\"3\"><li><p>Trzeci krok</p></li><li><p>Czwarty krok</p></li></ol>"
|
||||
},
|
||||
{
|
||||
"name": "inline-marks",
|
||||
"html": "<p>Tekst <strong>pogrubiony</strong>, <em>kursywa</em>, <s>przekreślony</s>, <u>podkreślony</u> i <code>kod</code>.</p>"
|
||||
},
|
||||
{
|
||||
"name": "code-block",
|
||||
"html": "<pre><code>dawka = 2 * 500 // mg</code></pre>"
|
||||
},
|
||||
{
|
||||
"name": "horizontal-rule",
|
||||
"html": "<p>Przed linią</p><hr><p>Po linii</p>"
|
||||
},
|
||||
{
|
||||
"name": "hard-break",
|
||||
"html": "<p>Pierwsza linia<br>Druga linia</p>"
|
||||
},
|
||||
{
|
||||
"name": "combined",
|
||||
"html": "<figure data-embed=\"youtube\" data-id=\"dQw4w9WgXcQ\"></figure><p><img src=\"/storage/app/uploads/public/journal/2026/10/turkey-tail.jpg\" alt=\"Turkey Tail: czy to "grzyb odporności"?\"></p><figure><img src=\"/storage/app/uploads/public/journal/2026/10/turkey-tail.jpg\" alt=\"Owocniki wrośniaka\"><figcaption>Owocniki wrośniaka różnobarwnego.</figcaption></figure><table><thead><tr><th>Forma</th><th>Dawka</th><th>Uwagi</th></tr></thead><tbody><tr><td>Proszek</td><td>1-2 g</td><td>z posiłkiem</td></tr><tr><td>Kapsułki</td><td>2 x 500 mg</td><td>rano</td></tr></tbody></table><p>Źródła: <a href=\"http://example.test/\">strona</a>, <a href=\"https://example.test/badania?rok=2026#wyniki\">badania</a>, <a href=\"mailto:redakcja@example.test\">redakcja</a> i <a href=\"/blog/beta-glukany\">inny wpis</a>.</p><h2>Nagłówek 2</h2><h3>Nagłówek 3</h3><h4>Nagłówek 4</h4><ul><li><p>Beta-glukany</p></li><li><p>Antyoksydanty</p></li></ul><ol><li><p>Pierwszy krok</p></li><li><p>Drugi krok</p></li></ol><blockquote><p>Grzyby funkcjonalne wspierają organizm.</p></blockquote><ol start=\"3\"><li><p>Trzeci krok</p></li><li><p>Czwarty krok</p></li></ol><p>Tekst <strong>pogrubiony</strong>, <em>kursywa</em>, <s>przekreślony</s>, <u>podkreślony</u> i <code>kod</code>.</p><pre><code>dawka = 2 * 500 // mg</code></pre><p>Przed linią</p><hr><p>Po linii</p><p>Pierwsza linia<br>Druga linia</p>"
|
||||
}
|
||||
]
|
||||
78
tests/blogeditor/helpers.ts
Normal file
78
tests/blogeditor/helpers.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import '../../assets/src/blogeditor/index'
|
||||
import { BlogEditorElement, TAG } from '../../assets/src/blogeditor/index'
|
||||
|
||||
export interface Mounted {
|
||||
el: BlogEditorElement
|
||||
editor: Editor
|
||||
changes: string[]
|
||||
uploads: { id: string; file: File }[]
|
||||
}
|
||||
|
||||
/** mount creates a connected editor element, optionally with a value. */
|
||||
export function mount(value?: string, attributes: Record<string, string> = {}): Mounted {
|
||||
const el = document.createElement(TAG) as BlogEditorElement
|
||||
for (const [name, val] of Object.entries(attributes)) el.setAttribute(name, val)
|
||||
const changes: string[] = []
|
||||
const uploads: { id: string; file: File }[] = []
|
||||
el.addEventListener('summer-change', (e) => changes.push((e as CustomEvent<{ value: string }>).detail.value))
|
||||
el.addEventListener('summer-upload', (e) => uploads.push((e as CustomEvent<{ id: string; file: File }>).detail))
|
||||
if (value !== undefined) el.value = value
|
||||
document.body.append(el)
|
||||
return { el, editor: el.editor!, changes, uploads }
|
||||
}
|
||||
|
||||
/** type inserts text at the selection, as typing does. */
|
||||
export function type(editor: Editor, text: string): void {
|
||||
editor.view.dispatch(editor.state.tr.insertText(text))
|
||||
}
|
||||
|
||||
/** insertAtBlock types text at the start of the top-level textblock index. */
|
||||
export function insertAtBlock(editor: Editor, index: number, text: string): void {
|
||||
let pos = -1
|
||||
editor.state.doc.forEach((node, offset, i) => {
|
||||
if (i === index) pos = node.isTextblock ? offset + 1 : -1
|
||||
})
|
||||
if (pos < 0) throw new Error(`block ${index} is not a textblock`)
|
||||
editor.view.dispatch(editor.state.tr.insertText(text, pos))
|
||||
}
|
||||
|
||||
/** insertAtEveryTextblock types text at the start of every textblock. */
|
||||
export function insertAtEveryTextblock(editor: Editor, text: string): void {
|
||||
const positions: number[] = []
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (node.isTextblock) positions.push(pos + 1)
|
||||
return true
|
||||
})
|
||||
let tr = editor.state.tr
|
||||
for (const pos of positions.reverse()) tr = tr.insertText(text, pos)
|
||||
editor.view.dispatch(tr)
|
||||
}
|
||||
|
||||
/** button returns the toolbar button for a command. */
|
||||
export function button(el: HTMLElement, command: string): HTMLButtonElement {
|
||||
const found = el.querySelector<HTMLButtonElement>(`button[data-command="${command}"]`)
|
||||
if (!found) throw new Error(`no toolbar button ${command}`)
|
||||
return found
|
||||
}
|
||||
|
||||
/** form returns the inline form of a kind, or null when it is closed. */
|
||||
export function form(el: HTMLElement, kind: string): HTMLElement | null {
|
||||
const found = el.querySelector<HTMLElement>(`[data-form="${kind}"]`)
|
||||
return found && !found.hidden ? found : null
|
||||
}
|
||||
|
||||
export function click(el: Element | null | undefined): void {
|
||||
if (!el) throw new Error('nothing to click')
|
||||
;(el as HTMLElement).click()
|
||||
}
|
||||
|
||||
export function fill(input: Element | null | undefined, value: string): void {
|
||||
if (!input) throw new Error('no input')
|
||||
;(input as HTMLInputElement).value = value
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
}
|
||||
|
||||
export function status(el: HTMLElement): string {
|
||||
return el.querySelector('[role="status"]')?.textContent ?? ''
|
||||
}
|
||||
@@ -1,18 +1,12 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } 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 }
|
||||
}
|
||||
import { parseEmbedUrl } from '../../assets/src/blogeditor/embed'
|
||||
import { isPolicyUrl } from '../../assets/src/blogeditor/extensions'
|
||||
import { button, click, fill, form, insertAtBlock, insertAtEveryTextblock, mount, status } from './helpers'
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
@@ -44,8 +38,7 @@ describe('blog editor element (tracer)', () => {
|
||||
})
|
||||
|
||||
it('emits exactly one summer-change per user edit with the element value', () => {
|
||||
const { el, changes } = mount('<p>Hello</p>')
|
||||
const editor = el.editor!
|
||||
const { el, editor, changes } = mount('<p>Hello</p>')
|
||||
editor.view.dispatch(editor.state.tr.insertText('X', 1))
|
||||
expect(changes).toHaveLength(1)
|
||||
expect(changes[0]).toBe(el.value)
|
||||
@@ -75,12 +68,25 @@ describe('blog editor element (tracer)', () => {
|
||||
expect(changes).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps an edited value after it is detached and attached again', () => {
|
||||
const { el, editor, changes } = mount('<p>a</p>')
|
||||
editor.view.dispatch(editor.state.tr.insertText('X', 1))
|
||||
el.remove()
|
||||
expect(el.value).toBe('<p>Xa</p>')
|
||||
document.body.append(el)
|
||||
expect(el.value).toBe('<p>Xa</p>')
|
||||
expect(el.editor!.state.doc.textContent).toBe('Xa')
|
||||
expect(changes).toEqual(['<p>Xa</p>'])
|
||||
})
|
||||
|
||||
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([])
|
||||
// The replaced value is not in the undo history.
|
||||
expect(el.editor!.can().undo()).toBe(false)
|
||||
})
|
||||
|
||||
it('disabled makes the editor and the source read-only; invalid sets aria-invalid', () => {
|
||||
@@ -96,3 +102,400 @@ describe('blog editor element (tracer)', () => {
|
||||
expect(el.editor!.view.dom.hasAttribute('aria-invalid')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('preservation schema', () => {
|
||||
const rawBlocks = [
|
||||
'<footer class="author-footer" itemscope="" itemtype="https://schema.org/Person"><p>Autor</p></footer>',
|
||||
'<section class="s"><p>Sekcja</p></section>',
|
||||
'<aside>Na marginesie</aside>',
|
||||
'<header><h2>Nagłówek</h2></header>',
|
||||
'<nav><a href="/a">a</a></nav>',
|
||||
'<details><summary>Więcej</summary>Treść</details>',
|
||||
'<style>.x { color: red; }</style>',
|
||||
'<div class="box" data-x="1">Pudełko</div>',
|
||||
'<figure class="quote"><blockquote>Cytat</blockquote><figcaption>Autor</figcaption></figure>',
|
||||
]
|
||||
|
||||
it('turns unknown block markup into raw HTML cards that keep it through an edit', () => {
|
||||
const html = `<p>Start</p>\n${rawBlocks.join('\n')}\n`
|
||||
const { el, editor } = mount(html)
|
||||
const types: string[] = []
|
||||
editor.state.doc.forEach((node) => types.push(node.type.name))
|
||||
expect(types).toEqual(['paragraph', ...rawBlocks.map(() => 'rawHtml')])
|
||||
|
||||
const cards = Array.from(el.querySelectorAll('.blogeditor__raw-source')).map((n) => n.textContent)
|
||||
expect(cards).toEqual(rawBlocks)
|
||||
|
||||
insertAtBlock(editor, 0, 'X')
|
||||
expect(el.value).toBe(`<p>XStart</p>\n${rawBlocks.join('\n')}\n`)
|
||||
})
|
||||
|
||||
it('renders a raw HTML card as text, never as live markup', () => {
|
||||
const payload = '<div><img src="x" onerror="window.__blogeditorPwned = 1"><script>window.__blogeditorPwned = 2</script></div>'
|
||||
const { el, editor } = mount(`<p>a</p>${payload}`)
|
||||
expect(editor.state.doc.child(1).type.name).toBe('rawHtml')
|
||||
expect(el.querySelector('.ProseMirror img')).toBeNull()
|
||||
expect(el.querySelector('.ProseMirror script')).toBeNull()
|
||||
expect(el.querySelector('.blogeditor__raw-source')!.textContent).toBe(payload)
|
||||
expect((window as unknown as { __blogeditorPwned?: number }).__blogeditorPwned).toBeUndefined()
|
||||
|
||||
// Handler attributes on modelled nodes never reach the editor DOM.
|
||||
const inline = mount('<p><img src="/a.png" alt="a" onerror="window.__blogeditorPwned = 3" onload="x()"></p>')
|
||||
const img = inline.el.querySelector('.ProseMirror img:not(.ProseMirror-separator)')!
|
||||
expect(img.getAttribute('src')).toBe('/a.png')
|
||||
expect(img.getAttributeNames().filter((name) => name.startsWith('on'))).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps unmodelled attributes on edited blocks and spans as marks', () => {
|
||||
const html =
|
||||
'<p class="lead" data-x="1" itemprop="description">Tekst <span style="color: red;" class="s">czerwony</span></p>' +
|
||||
'<h2 id="h" class="c">Tytuł</h2>' +
|
||||
'<ul class="u"><li data-i="1"><p>Punkt</p></li></ul>' +
|
||||
'<ol start="3" class="o"><li><p>Trzy</p></li></ol>' +
|
||||
'<blockquote class="q"><p>Cytat</p></blockquote>' +
|
||||
'<p><a href="/x" class="lnk" data-t="1">link</a> <img src="/a.png" alt="A" class="photo" data-id="7"></p>' +
|
||||
'<table class="t"><tbody><tr><td class="c" style="text-align: right;">1</td></tr></tbody></table>'
|
||||
const { el, editor } = mount(html)
|
||||
insertAtEveryTextblock(editor, 'X')
|
||||
expect(el.value).toBe(
|
||||
'<p class="lead" data-x="1" itemprop="description">XTekst <span style="color: red;" class="s">czerwony</span></p>' +
|
||||
'<h2 id="h" class="c">XTytuł</h2>' +
|
||||
'<ul class="u"><li data-i="1"><p>XPunkt</p></li></ul>' +
|
||||
'<ol class="o" start="3"><li><p>XTrzy</p></li></ol>' +
|
||||
'<blockquote class="q"><p>XCytat</p></blockquote>' +
|
||||
'<p><a class="lnk" data-t="1" href="/x">Xlink</a> <img class="photo" data-id="7" src="/a.png" alt="A"></p>' +
|
||||
'<table class="t"><tbody><tr><td class="c" style="text-align: right;">X1</td></tr></tbody></table>',
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves every other top-level block byte-identical after editing one paragraph', () => {
|
||||
const { el, editor, changes } = mount(fixture)
|
||||
insertAtBlock(editor, 1, 'X')
|
||||
const expected = fixture.replace('<p>Turkey Tail', '<p>XTurkey Tail')
|
||||
expect(expected).not.toBe(fixture)
|
||||
expect(el.value).toBe(expected)
|
||||
expect(changes).toEqual([expected])
|
||||
|
||||
// Undoing the edit gives back the exact original bytes.
|
||||
expect(editor.commands.undo()).toBe(true)
|
||||
expect(el.value).toBe(fixture)
|
||||
})
|
||||
|
||||
it('serializes tables without colgroup or min-width, with thead and bare cell text', () => {
|
||||
const html = '<table><thead><tr><th>A</th><th>B</th></tr></thead><tbody><tr><td>1</td><td>2</td></tr></tbody></table>'
|
||||
const { el, editor } = mount(html)
|
||||
insertAtEveryTextblock(editor, 'X')
|
||||
expect(el.value).toBe(
|
||||
'<table><thead><tr><th>XA</th><th>XB</th></tr></thead><tbody><tr><td>X1</td><td>X2</td></tr></tbody></table>',
|
||||
)
|
||||
|
||||
const fresh = mount('<p>t</p>')
|
||||
fresh.editor.commands.setTextSelection(1)
|
||||
expect(fresh.editor.commands.insertTable({ rows: 2, cols: 2, withHeaderRow: true })).toBe(true)
|
||||
expect(fresh.el.value).not.toMatch(/colgroup|min-width|style=/)
|
||||
expect(fresh.el.value).toContain('<thead><tr><th></th><th></th></tr></thead><tbody><tr><td></td><td></td></tr></tbody>')
|
||||
expect(fresh.el.value).not.toMatch(/<p><\/p>$/)
|
||||
})
|
||||
|
||||
it('keeps stored link target and rel, adds none and leaves no trailing paragraph', () => {
|
||||
// Text typed at the start of a link joins the link (links are inclusive).
|
||||
// Edited links keep their attributes; their order may change.
|
||||
const { el, editor } = mount('<p><a href="https://x.test/" target="_blank" rel="noopener">x</a> y</p><hr>')
|
||||
insertAtBlock(editor, 0, 'Z')
|
||||
expect(el.value).toBe('<p><a target="_blank" rel="noopener" href="https://x.test/">Zx</a> y</p><hr>')
|
||||
|
||||
const fresh = mount('<p>link</p>')
|
||||
fresh.editor.commands.setTextSelection({ from: 1, to: 5 })
|
||||
expect(fresh.editor.commands.setLink({ href: 'https://example.test/' })).toBe(true)
|
||||
expect(fresh.el.value).toBe('<p><a href="https://example.test/">link</a></p>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('embeds', () => {
|
||||
it('parses supported YouTube URLs to the 11-character id', () => {
|
||||
const id = 'dQw4w9WgXcQ'
|
||||
for (const url of [
|
||||
`https://www.youtube.com/watch?v=${id}`,
|
||||
`https://youtube.com/watch?v=${id}&t=42s`,
|
||||
`https://m.youtube.com/watch?feature=share&v=${id}`,
|
||||
`https://youtu.be/${id}`,
|
||||
`https://youtu.be/${id}?si=abc`,
|
||||
`https://www.youtube.com/shorts/${id}`,
|
||||
`https://www.youtube.com/embed/${id}`,
|
||||
` https://www.youtube-nocookie.com/embed/${id} `,
|
||||
]) {
|
||||
expect(parseEmbedUrl(url), url).toEqual({ provider: 'youtube', id })
|
||||
}
|
||||
for (const url of [
|
||||
'https://vimeo.com/123456',
|
||||
'https://www.youtube.com/watch?v=short',
|
||||
'https://www.youtube.com/watch?v=dQw4w9WgXcQQ',
|
||||
'https://youtu.be/dQw4w9WgXc!',
|
||||
'https://evil.test/embed/dQw4w9WgXcQ',
|
||||
'javascript:alert(1)//youtu.be/dQw4w9WgXcQ',
|
||||
'ftp://youtu.be/dQw4w9WgXcQ',
|
||||
'not a url',
|
||||
'',
|
||||
]) {
|
||||
expect(parseEmbedUrl(url), url).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('round-trips the exact placeholder and shows a card, never an iframe', () => {
|
||||
const placeholder = '<figure data-embed="youtube" data-id="dQw4w9WgXcQ"></figure>'
|
||||
const { el, editor } = mount(`<p>a</p>${placeholder}`)
|
||||
expect(editor.state.doc.child(1).type.name).toBe('embed')
|
||||
expect(el.querySelector('iframe')).toBeNull()
|
||||
expect(el.querySelector('.blogeditor__embed')!.textContent).toContain('YouTube')
|
||||
insertAtBlock(editor, 0, 'X')
|
||||
expect(el.value).toBe(`<p>Xa</p>${placeholder}`)
|
||||
|
||||
const fresh = mount('')
|
||||
expect(fresh.editor.commands.setEmbed({ provider: 'youtube', id: 'dQw4w9WgXcQ' })).toBe(true)
|
||||
expect(fresh.el.value).toBe(placeholder)
|
||||
expect(fresh.editor.commands.setEmbed({ provider: 'youtube', id: 'bad' })).toBe(false)
|
||||
expect(fresh.editor.commands.setEmbed({ provider: 'vimeo', id: 'dQw4w9WgXcQ' })).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps a malformed placeholder as raw HTML', () => {
|
||||
const { editor } = mount('<figure data-embed="youtube" data-id="bad"></figure>')
|
||||
expect(editor.state.doc.child(0).type.name).toBe('rawHtml')
|
||||
})
|
||||
|
||||
it('inserts an embed from the toolbar URL input and refuses unsupported URLs', () => {
|
||||
const { el } = mount('')
|
||||
click(button(el, 'embed'))
|
||||
const embedForm = form(el, 'embed')!
|
||||
fill(embedForm.querySelector('input'), 'https://vimeo.com/1')
|
||||
click(embedForm.querySelector('[data-action="insert"]'))
|
||||
expect(form(el, 'embed')).not.toBeNull()
|
||||
expect(embedForm.querySelector('[data-error]')!.textContent).toBe('This video URL is not supported. Paste a YouTube link.')
|
||||
expect(el.value).toBe('')
|
||||
|
||||
fill(embedForm.querySelector('input'), 'https://youtu.be/dQw4w9WgXcQ')
|
||||
click(embedForm.querySelector('[data-action="insert"]'))
|
||||
expect(form(el, 'embed')).toBeNull()
|
||||
expect(el.value).toBe('<figure data-embed="youtube" data-id="dQw4w9WgXcQ"></figure>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('image uploads', () => {
|
||||
function pick(el: HTMLElement, files: File[]): void {
|
||||
const input = el.querySelector<HTMLInputElement>('input[type="file"]')!
|
||||
expect(input.accept).toBe('image/jpeg,image/png,image/gif,image/webp')
|
||||
Object.defineProperty(input, 'files', { value: files, configurable: true })
|
||||
input.dispatchEvent(new Event('change'))
|
||||
}
|
||||
|
||||
function answer(el: HTMLElement, detail: Record<string, string>): void {
|
||||
el.dispatchEvent(new CustomEvent('summer-upload-result', { detail }))
|
||||
}
|
||||
|
||||
const png = () => new File([new Uint8Array([137, 80, 78, 71])], 'grzyb.png', { type: 'image/png' })
|
||||
|
||||
it('sends a picked image as summer-upload and inserts img with alt text', () => {
|
||||
const { el, uploads } = mount('')
|
||||
click(button(el, 'image'))
|
||||
pick(el, [png()])
|
||||
expect(uploads).toHaveLength(1)
|
||||
expect(uploads[0]!.file.name).toBe('grzyb.png')
|
||||
expect(uploads[0]!.id).toMatch(/[0-9a-f-]{36}/)
|
||||
expect(status(el)).toBe('Uploading grzyb.png…')
|
||||
|
||||
answer(el, { id: 'someone-else', url: '/x.png' })
|
||||
expect(form(el, 'image')).toBeNull()
|
||||
|
||||
answer(el, { id: uploads[0]!.id, url: '/storage/app/uploads/public/journal/grzyb.png' })
|
||||
const imageForm = form(el, 'image')!
|
||||
click(imageForm.querySelector('[data-action="insert"]'))
|
||||
expect(imageForm.querySelector('[data-error]')!.textContent).toBe('Enter the alt text.')
|
||||
expect(el.value).toBe('')
|
||||
|
||||
fill(imageForm.querySelector('input[name="alt"]'), 'Grzyb: czy to "Turkey Tail"?')
|
||||
click(imageForm.querySelector('[data-action="insert"]'))
|
||||
expect(form(el, 'image')).toBeNull()
|
||||
expect(el.value).toBe('<p><img src="/storage/app/uploads/public/journal/grzyb.png" alt="Grzyb: czy to "Turkey Tail"?"></p>')
|
||||
})
|
||||
|
||||
it('inserts figure with figcaption when a caption is given', () => {
|
||||
const { el, uploads } = mount('')
|
||||
pick(el, [png()])
|
||||
answer(el, { id: uploads[0]!.id, url: '/a.png' })
|
||||
const imageForm = form(el, 'image')!
|
||||
fill(imageForm.querySelector('input[name="alt"]'), 'Owocniki')
|
||||
fill(imageForm.querySelector('input[name="caption"]'), 'Owocniki na pniu.')
|
||||
click(imageForm.querySelector('[data-action="insert"]'))
|
||||
expect(el.value).toBe('<figure><img src="/a.png" alt="Owocniki"><figcaption>Owocniki na pniu.</figcaption></figure>')
|
||||
})
|
||||
|
||||
it('shows an upload error and inserts nothing', () => {
|
||||
const { el, uploads, changes } = mount('<p>a</p>')
|
||||
pick(el, [png()])
|
||||
answer(el, { id: uploads[0]!.id, error: 'File too large' })
|
||||
expect(form(el, 'image')).toBeNull()
|
||||
expect(status(el)).toBe('Upload failed: File too large')
|
||||
expect(el.value).toBe('<p>a</p>')
|
||||
expect(changes).toEqual([])
|
||||
})
|
||||
|
||||
it('ends a pending upload after 60 seconds', () => {
|
||||
vi.useFakeTimers()
|
||||
const { el, uploads } = mount('')
|
||||
pick(el, [png()])
|
||||
vi.advanceTimersByTime(60_000)
|
||||
expect(status(el)).toBe('The upload of grzyb.png took too long.')
|
||||
answer(el, { id: uploads[0]!.id, url: '/late.png' })
|
||||
expect(form(el, 'image')).toBeNull()
|
||||
})
|
||||
|
||||
it('uploads image files dropped on the editor', () => {
|
||||
const { el, editor, uploads } = mount('<p>a</p>')
|
||||
let prevented = false
|
||||
const event = {
|
||||
dataTransfer: { files: [png(), new File(['x'], 'notes.txt', { type: 'text/plain' })] },
|
||||
preventDefault: () => {
|
||||
prevented = true
|
||||
},
|
||||
} as unknown as DragEvent
|
||||
const handled = editor.view.someProp('handleDrop', (f) => f(editor.view, event, editor.state.doc.slice(0, 0), false))
|
||||
expect(handled).toBe(true)
|
||||
expect(prevented).toBe(true)
|
||||
expect(uploads.map((u) => u.file.name)).toEqual(['grzyb.png'])
|
||||
expect(el.value).toBe('<p>a</p>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('links and the URL rule', () => {
|
||||
it('accepts only http, https, mailto and relative URLs', () => {
|
||||
for (const url of ['http://a.test', 'https://a.test/x?y#z', 'HTTPS://A.TEST', 'mailto:a@b.test', '/rel', 'rel/path', '#frag', '?q=1', '']) {
|
||||
expect(isPolicyUrl(url), url).toBe(true)
|
||||
}
|
||||
for (const url of [
|
||||
'javascript:alert(1)',
|
||||
'JavaScript:alert(1)',
|
||||
' javascript:alert(1)',
|
||||
'java\tscript:alert(1)',
|
||||
'java\nscript:alert(1)',
|
||||
'vbscript:msgbox(1)',
|
||||
'data:text/html,<b>x</b>',
|
||||
'tel:+48123',
|
||||
'ftp://a.test/',
|
||||
]) {
|
||||
expect(isPolicyUrl(url), JSON.stringify(url)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses links outside the rule in commands and in stored HTML', () => {
|
||||
const { el, editor } = mount('<p>text</p><p><a href="javascript:alert(1)">bad</a></p>')
|
||||
expect(el.querySelector('.ProseMirror a')).toBeNull()
|
||||
editor.commands.setTextSelection({ from: 1, to: 5 })
|
||||
expect(editor.commands.setLink({ href: 'javascript:alert(1)' })).toBe(false)
|
||||
expect(editor.commands.setLink({ href: 'tel:+48123' })).toBe(false)
|
||||
expect(editor.commands.setLink({ href: 'java\tscript:alert(1)' })).toBe(false)
|
||||
})
|
||||
|
||||
it('creates, refuses and removes links through the toolbar input', () => {
|
||||
const { el, editor } = mount('<p>link</p>')
|
||||
editor.commands.setTextSelection({ from: 1, to: 5 })
|
||||
click(button(el, 'link'))
|
||||
const linkForm = form(el, 'link')!
|
||||
fill(linkForm.querySelector('input'), 'data:text/html,x')
|
||||
click(linkForm.querySelector('[data-action="apply"]'))
|
||||
expect(linkForm.querySelector('[data-error]')!.textContent).toBe('Links must use http, https, mailto or a relative URL.')
|
||||
expect(el.value).toBe('<p>link</p>')
|
||||
|
||||
fill(linkForm.querySelector('input'), 'mailto:redakcja@example.test')
|
||||
click(linkForm.querySelector('[data-action="apply"]'))
|
||||
expect(el.value).toBe('<p><a href="mailto:redakcja@example.test">link</a></p>')
|
||||
|
||||
editor.commands.setTextSelection(2)
|
||||
click(button(el, 'link'))
|
||||
const again = form(el, 'link')!
|
||||
expect((again.querySelector('input') as HTMLInputElement).value).toBe('mailto:redakcja@example.test')
|
||||
fill(again.querySelector('input'), '')
|
||||
click(again.querySelector('[data-action="apply"]'))
|
||||
expect(el.value).toBe('<p>link</p>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('toolbar', () => {
|
||||
it('offers every D-04 feature and the tabs', () => {
|
||||
const { el } = mount('')
|
||||
for (const command of [
|
||||
'paragraph',
|
||||
'heading2',
|
||||
'heading3',
|
||||
'heading4',
|
||||
'bold',
|
||||
'italic',
|
||||
'bulletList',
|
||||
'orderedList',
|
||||
'link',
|
||||
'blockquote',
|
||||
'insertTable',
|
||||
'addRow',
|
||||
'deleteRow',
|
||||
'addColumn',
|
||||
'deleteColumn',
|
||||
'deleteTable',
|
||||
'image',
|
||||
'embed',
|
||||
'rawHtml',
|
||||
'undo',
|
||||
'redo',
|
||||
]) {
|
||||
expect(button(el, command).getAttribute('aria-label'), command).toBeTruthy()
|
||||
}
|
||||
expect(el.querySelector('[data-tab="wysiwyg"]')!.textContent).toBe('WYSIWYG')
|
||||
expect(el.querySelector('[data-tab="source"]')!.textContent).toBe('Source')
|
||||
})
|
||||
|
||||
it('uses the Polish labels for ui-locale pl', () => {
|
||||
const { el } = mount('', { 'ui-locale': 'pl' })
|
||||
expect(el.querySelector('[data-tab="source"]')!.textContent).toBe('Źródło')
|
||||
expect(button(el, 'bold').getAttribute('aria-label')).toBe('Pogrubienie')
|
||||
})
|
||||
|
||||
it('formats text and edits tables from the toolbar', () => {
|
||||
const { el, editor } = mount('<p>abc</p>')
|
||||
editor.commands.setTextSelection({ from: 1, to: 4 })
|
||||
click(button(el, 'bold'))
|
||||
expect(el.value).toBe('<p><strong>abc</strong></p>')
|
||||
expect(button(el, 'bold').getAttribute('aria-pressed')).toBe('true')
|
||||
click(button(el, 'undo'))
|
||||
expect(el.value).toBe('<p>abc</p>')
|
||||
click(button(el, 'redo'))
|
||||
expect(el.value).toBe('<p><strong>abc</strong></p>')
|
||||
click(button(el, 'heading3'))
|
||||
expect(el.value).toBe('<h3><strong>abc</strong></h3>')
|
||||
|
||||
editor.commands.setTextSelection(4)
|
||||
click(button(el, 'insertTable'))
|
||||
expect(el.value).toContain('<table><thead><tr><th></th><th></th><th></th></tr></thead>')
|
||||
click(button(el, 'addRow'))
|
||||
click(button(el, 'deleteColumn'))
|
||||
expect(el.value.match(/<tr>/g)).toHaveLength(4)
|
||||
expect(el.value).toContain('<tr><th></th><th></th></tr>')
|
||||
click(button(el, 'deleteTable'))
|
||||
expect(el.value).not.toContain('<table')
|
||||
})
|
||||
|
||||
it('inserts a raw HTML block and opens the source tab at it', () => {
|
||||
const { el, editor } = mount('<p>a</p>')
|
||||
editor.commands.setTextSelection(2)
|
||||
click(button(el, 'rawHtml'))
|
||||
const textarea = el.querySelector('textarea')!
|
||||
expect(textarea.hidden).toBe(false)
|
||||
expect(el.value).toBe('<p>a</p><div></div>')
|
||||
expect(textarea.value).toBe('<p>a</p><div></div>')
|
||||
expect(textarea.selectionStart).toBe('<p>a</p><div>'.length)
|
||||
})
|
||||
|
||||
it('edits a raw HTML card in the source tab', () => {
|
||||
const { el } = mount('<p>a</p>\n<footer>f</footer>')
|
||||
click(el.querySelector('.blogeditor__raw button'))
|
||||
const textarea = el.querySelector('textarea')!
|
||||
expect(textarea.hidden).toBe(false)
|
||||
expect(textarea.value.slice(textarea.selectionStart, textarea.selectionEnd)).toBe('<footer>f</footer>')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user