Add rich-text alignment controls (left/center/right)
Shared RichTextEditor gains @tiptap/extension-text-align for heading and paragraph nodes, serializing alignment as inline text-align on the block node so it round-trips through save/reload. Fixed once at the shared component so it also flows into the upcoming rich_text and two_column page blocks. Server sanitize allowlist now permits `style` on p/h1-h6, constrained by allowedStyles to text-align (left/right/center/justify) only; all other CSS properties and values are stripped. Step 1 of the CMS Page Builder spec. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
14
client/package-lock.json
generated
14
client/package-lock.json
generated
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tiptap/extension-image": "^2.27.2",
|
"@tiptap/extension-image": "^2.27.2",
|
||||||
"@tiptap/extension-link": "^2.27.2",
|
"@tiptap/extension-link": "^2.27.2",
|
||||||
|
"@tiptap/extension-text-align": "^2.27.2",
|
||||||
"@tiptap/react": "^2.27.2",
|
"@tiptap/react": "^2.27.2",
|
||||||
"@tiptap/starter-kit": "^2.27.2",
|
"@tiptap/starter-kit": "^2.27.2",
|
||||||
"diff": "^5.2.2",
|
"diff": "^5.2.2",
|
||||||
@@ -1483,6 +1484,19 @@
|
|||||||
"@tiptap/core": "^2.7.0"
|
"@tiptap/core": "^2.7.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tiptap/extension-text-align": {
|
||||||
|
"version": "2.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-2.27.2.tgz",
|
||||||
|
"integrity": "sha512-0Pyks6Hu+Q/+9+5/osoSv0SP6jIerdWMYbi13aaZLsJoj3lBj5WNaE11JtAwSFN5sx0IbqhDSlp1zkvRnzgZ8g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tiptap/extension-text-style": {
|
"node_modules/@tiptap/extension-text-style": {
|
||||||
"version": "2.27.2",
|
"version": "2.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.27.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.27.2.tgz",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tiptap/extension-image": "^2.27.2",
|
"@tiptap/extension-image": "^2.27.2",
|
||||||
"@tiptap/extension-link": "^2.27.2",
|
"@tiptap/extension-link": "^2.27.2",
|
||||||
|
"@tiptap/extension-text-align": "^2.27.2",
|
||||||
"@tiptap/react": "^2.27.2",
|
"@tiptap/react": "^2.27.2",
|
||||||
"@tiptap/starter-kit": "^2.27.2",
|
"@tiptap/starter-kit": "^2.27.2",
|
||||||
"diff": "^5.2.2",
|
"diff": "^5.2.2",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useEditor, EditorContent } from '@tiptap/react'
|
|||||||
import StarterKit from '@tiptap/starter-kit'
|
import StarterKit from '@tiptap/starter-kit'
|
||||||
import Link from '@tiptap/extension-link'
|
import Link from '@tiptap/extension-link'
|
||||||
import Image from '@tiptap/extension-image'
|
import Image from '@tiptap/extension-image'
|
||||||
|
import TextAlign from '@tiptap/extension-text-align'
|
||||||
import { api } from '../api/client.js'
|
import { api } from '../api/client.js'
|
||||||
|
|
||||||
// Toolbar button.
|
// Toolbar button.
|
||||||
@@ -25,6 +26,22 @@ function escapeHtml(s) {
|
|||||||
return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c])
|
return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Alignment glyph: three lines justified to the given side.
|
||||||
|
function AlignIcon({ align }) {
|
||||||
|
const rows = {
|
||||||
|
left: [[2, 14], [2, 10], [2, 12]],
|
||||||
|
center: [[2, 14], [4, 12], [3, 13]],
|
||||||
|
right: [[2, 14], [6, 14], [4, 14]],
|
||||||
|
}[align]
|
||||||
|
return (
|
||||||
|
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true">
|
||||||
|
{rows.map(([x1, x2], i) => (
|
||||||
|
<line key={i} x1={x1} y1={4 + i * 4} x2={x2} y2={4 + i * 4} />
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Toolbar variants:
|
// Toolbar variants:
|
||||||
// 'full' — every control, incl. the internal wiki-page link picker (wiki use).
|
// 'full' — every control, incl. the internal wiki-page link picker (wiki use).
|
||||||
// 'post' — full minus the wiki-page picker (no page-list context in posts).
|
// 'post' — full minus the wiki-page picker (no page-list context in posts).
|
||||||
@@ -42,6 +59,11 @@ export default function RichTextEditor({ value, onChange, pages = [], variant =
|
|||||||
StarterKit.configure({ heading: { levels: [2, 3] } }),
|
StarterKit.configure({ heading: { levels: [2, 3] } }),
|
||||||
Link.configure({ openOnClick: false, autolink: true }),
|
Link.configure({ openOnClick: false, autolink: true }),
|
||||||
Image.configure({ inline: false }),
|
Image.configure({ inline: false }),
|
||||||
|
// Alignment stored as `text-align` on the block node (heading/paragraph),
|
||||||
|
// so it round-trips through save/reload as inline style. Shared here means
|
||||||
|
// every consumer — post editor, and the future rich_text / two_column
|
||||||
|
// blocks — gets it for free.
|
||||||
|
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
||||||
],
|
],
|
||||||
content: value || '',
|
content: value || '',
|
||||||
onUpdate: ({ editor }) => onChange(editor.getHTML()),
|
onUpdate: ({ editor }) => onChange(editor.getHTML()),
|
||||||
@@ -131,6 +153,16 @@ export default function RichTextEditor({ value, onChange, pages = [], variant =
|
|||||||
—
|
—
|
||||||
</Btn>
|
</Btn>
|
||||||
<span className="rte-sep" />
|
<span className="rte-sep" />
|
||||||
|
<Btn title="Align left" active={editor.isActive({ textAlign: 'left' })} onClick={() => editor.chain().focus().setTextAlign('left').run()}>
|
||||||
|
<AlignIcon align="left" />
|
||||||
|
</Btn>
|
||||||
|
<Btn title="Align center" active={editor.isActive({ textAlign: 'center' })} onClick={() => editor.chain().focus().setTextAlign('center').run()}>
|
||||||
|
<AlignIcon align="center" />
|
||||||
|
</Btn>
|
||||||
|
<Btn title="Align right" active={editor.isActive({ textAlign: 'right' })} onClick={() => editor.chain().focus().setTextAlign('right').run()}>
|
||||||
|
<AlignIcon align="right" />
|
||||||
|
</Btn>
|
||||||
|
<span className="rte-sep" />
|
||||||
<Btn title="Link" active={editor.isActive('link')} onClick={setLink}>
|
<Btn title="Link" active={editor.isActive('link')} onClick={setLink}>
|
||||||
🔗
|
🔗
|
||||||
</Btn>
|
</Btn>
|
||||||
|
|||||||
@@ -18,6 +18,19 @@ const OPTIONS = {
|
|||||||
span: ['data-wiki-slug'], // marks internal wiki links (used from Phase 3)
|
span: ['data-wiki-slug'], // marks internal wiki links (used from Phase 3)
|
||||||
th: ['colspan', 'rowspan'],
|
th: ['colspan', 'rowspan'],
|
||||||
td: ['colspan', 'rowspan'],
|
td: ['colspan', 'rowspan'],
|
||||||
|
// Block alignment from the rich-text editor. `style` is only honored for the
|
||||||
|
// properties/values whitelisted in allowedStyles below — everything else in
|
||||||
|
// the style attribute is stripped.
|
||||||
|
p: ['style'],
|
||||||
|
h1: ['style'], h2: ['style'], h3: ['style'],
|
||||||
|
h4: ['style'], h5: ['style'], h6: ['style'],
|
||||||
|
},
|
||||||
|
// Restrict inline styles to text-align (left/right/center/justify) only. Any
|
||||||
|
// other CSS property, or an unlisted value, is discarded.
|
||||||
|
allowedStyles: {
|
||||||
|
'*': {
|
||||||
|
'text-align': [/^(left|right|center|justify)$/],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
// http/https for links and images, mailto for links, plus relative URLs so
|
// http/https for links and images, mailto for links, plus relative URLs so
|
||||||
// uploaded images (/uploads/...) and internal links (/wiki/...) pass through.
|
// uploaded images (/uploads/...) and internal links (/wiki/...) pass through.
|
||||||
|
|||||||
Reference in New Issue
Block a user