diff --git a/client/package-lock.json b/client/package-lock.json index 7c50d39..834e8bf 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@tiptap/extension-image": "^2.27.2", "@tiptap/extension-link": "^2.27.2", + "@tiptap/extension-text-align": "^2.27.2", "@tiptap/react": "^2.27.2", "@tiptap/starter-kit": "^2.27.2", "diff": "^5.2.2", @@ -1483,6 +1484,19 @@ "@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": { "version": "2.27.2", "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.27.2.tgz", diff --git a/client/package.json b/client/package.json index 4b1948f..250c40d 100644 --- a/client/package.json +++ b/client/package.json @@ -11,6 +11,7 @@ "dependencies": { "@tiptap/extension-image": "^2.27.2", "@tiptap/extension-link": "^2.27.2", + "@tiptap/extension-text-align": "^2.27.2", "@tiptap/react": "^2.27.2", "@tiptap/starter-kit": "^2.27.2", "diff": "^5.2.2", diff --git a/client/src/components/RichTextEditor.jsx b/client/src/components/RichTextEditor.jsx index 0f81773..9d7d233 100644 --- a/client/src/components/RichTextEditor.jsx +++ b/client/src/components/RichTextEditor.jsx @@ -3,6 +3,7 @@ import { useEditor, EditorContent } from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' import Link from '@tiptap/extension-link' import Image from '@tiptap/extension-image' +import TextAlign from '@tiptap/extension-text-align' import { api } from '../api/client.js' // Toolbar button. @@ -25,6 +26,22 @@ function escapeHtml(s) { 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 ( + + ) +} + // Toolbar variants: // '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). @@ -42,6 +59,11 @@ export default function RichTextEditor({ value, onChange, pages = [], variant = StarterKit.configure({ heading: { levels: [2, 3] } }), Link.configure({ openOnClick: false, autolink: true }), 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 || '', onUpdate: ({ editor }) => onChange(editor.getHTML()), @@ -131,6 +153,16 @@ export default function RichTextEditor({ value, onChange, pages = [], variant = — + editor.chain().focus().setTextAlign('left').run()}> + + + editor.chain().focus().setTextAlign('center').run()}> + + + editor.chain().focus().setTextAlign('right').run()}> + + + 🔗 diff --git a/server/src/utils/sanitizeHtml.js b/server/src/utils/sanitizeHtml.js index 15d57ee..f5d4edc 100644 --- a/server/src/utils/sanitizeHtml.js +++ b/server/src/utils/sanitizeHtml.js @@ -18,6 +18,19 @@ const OPTIONS = { span: ['data-wiki-slug'], // marks internal wiki links (used from Phase 3) th: ['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 // uploaded images (/uploads/...) and internal links (/wiki/...) pass through.