Repository navigation
text-align
#919
Replies: 3 comments
|
I was able to have this implemented with the help of Claude AI in my Rails project:
import { Extension, configure } from "lexxy"
function findCommand(editor, typeName) {
for (const [cmd] of editor._commands) {
if (cmd.type === typeName) return cmd
}
return null
}
// Lexical element format values → CSS text-align values
// (from Lexical's B$3 constant in lexxy.js)
const FORMAT_ALIGNMENT = { 1: "left", 2: "center", 3: "right", 4: "justify", 5: "start", 6: "end" }
const AlignIcons = {
left: `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
<path d="M2 4C2 3.44772 2.44772 3 3 3H15C15.5523 3 16 3.44772 16 5C16 5.55228 15.5523 6 15 6H3C2.44772 6 2 5.55228 2 5V4ZM2 9C2 8.44772 2.44772 8 3 8H11C11.5523 8 12 8.44772 12 9C12 9.55228 11.5523 10 11 10H3C2.44772 10 2 9.55228 2 9ZM3 13C2.44772 13 2 13.4477 2 14C2 14.5523 2.44772 15 3 15H15C15.5523 15 16 14.5523 16 14C16 13.4477 15.5523 13 15 13H3ZM2 18C2 17.4477 2.44772 17 3 17H9C9.55228 17 10 17.4477 10 18C10 18.5523 9.55228 19 9 19H3C2.44772 19 2 18.5523 2 18Z"/>
</svg>`,
center: `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
<path d="M3 3C2.44772 3 2 3.44772 2 4C2 4.55228 2.44772 5 3 5H15C15.5523 5 16 4.55228 16 4C16 3.44772 15.5523 3 15 3H3ZM5 8C4.44772 8 4 8.44772 4 9C4 9.55228 4.44772 10 5 10H13C13.5523 10 14 9.55228 14 9C14 8.44772 13.5523 8 13 8H5ZM2 14C2 13.4477 2.44772 13 3 13H15C15.5523 13 16 13.4477 16 14C16 14.5523 15.5523 15 15 15H3C2.44772 15 2 14.5523 2 14ZM6 17C5.44772 17 5 17.4477 5 18C5 18.5523 5.44772 19 6 19H12C12.5523 19 13 18.5523 13 18C13 17.4477 12.5523 17 12 17H6Z"/>
</svg>`,
right: `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
<path d="M3 3C2.44772 3 2 3.44772 2 4C2 4.55228 2.44772 5 3 5H15C15.5523 5 16 4.55228 16 4C16 3.44772 15.5523 3 15 3H3ZM7 8C6.44772 8 6 8.44772 6 9C6 9.55228 6.44772 10 7 10H15C15.5523 10 16 9.55228 16 9C16 8.44772 15.5523 8 15 8H7ZM2 14C2 13.4477 2.44772 13 3 13H15C15.5523 13 16 13.4477 16 14C16 14.5523 15.5523 15 15 15H3C2.44772 15 2 14.5523 2 14ZM9 17C8.44772 17 8 17.4477 8 18C8 18.5523 8.44772 19 9 19H15C15.5523 19 16 18.5523 16 18C16 17.4477 15.5523 17 15 17H9Z"/>
</svg>`,
justify: `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
<path d="M3 3C2.44772 3 2 3.44772 2 4C2 4.55228 2.44772 5 3 5H15C15.5523 5 16 4.55228 16 4C16 3.44772 15.5523 3 15 3H3ZM3 8C2.44772 8 2 8.44772 2 9C2 9.55228 2.44772 10 3 10H15C15.5523 10 16 9.55228 16 9C16 8.44772 15.5523 8 15 8H3ZM2 14C2 13.4477 2.44772 13 3 13H15C15.5523 13 16 13.4477 16 14C16 14.5523 15.5523 15 15 15H3C2.44772 15 2 14.5523 2 14ZM3 17C2.44772 17 2 17.4477 2 18C2 18.5523 2.44772 19 3 19H15C15.5523 19 16 18.5523 16 18C16 17.4477 15.5523 17 15 17H3Z"/>
</svg>`,
textAlign: `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
<path d="M3 3C2.44772 3 2 3.44772 2 4C2 4.55228 2.44772 5 3 5H15C15.5523 5 16 4.55228 16 4C16 3.44772 15.5523 3 15 3H3ZM3 8C2.44772 8 2 8.44772 2 9C2 9.55228 2.44772 10 3 10H15C15.5523 10 16 9.55228 16 9C16 8.44772 15.5523 8 15 8H3ZM2 14C2 13.4477 2.44772 13 3 13H15C15.5523 13 16 13.4477 16 14C16 14.5523 15.5523 15 15 15H3C2.44772 15 2 14.5523 2 14Z"/>
</svg>`
}
const ALIGNMENTS = [
{ name: "left", label: "Align left" },
{ name: "center", label: "Center" },
{ name: "right", label: "Align right" },
{ name: "justify", label: "Justify" },
]
class TextAlignmentExtension extends Extension {
get enabled() {
return this.editorElement.supportsRichText
}
initializeToolbar(toolbar) {
this.#buildToolbarDropdown(toolbar)
this.#fixTextAlignStripping()
}
// Lexxy's bundled DOMPurify sanitizer only allows "color" and "background-color" as CSS properties,
// stripping text-align from the HTML stored in cachedValue. This intercepts writes to cachedValue
// to re-inject text-align styles by reading the alignment format from the Lexical editor state.
#fixTextAlignStripping() {
const editorElement = this.editorElement
let _cachedValue = null
Object.defineProperty(editorElement, "cachedValue", {
get() {
return _cachedValue
},
set(html) {
if (html) {
// Count top-level HTML elements to verify index mapping
const template = document.createElement("template")
template.innerHTML = html
const htmlElementCount = template.content.children.length
const alignments = readAlignmentsFromEditorState(this.editor, htmlElementCount)
if (alignments.length > 0) {
html = injectTextAlignStyles(html, alignments)
}
}
_cachedValue = html
},
configurable: true,
enumerable: true,
})
}
#buildToolbarDropdown(toolbar) {
const details = document.createElement("details")
details.className = "lexxy-editor__toolbar-dropdown lexxy-editor__toolbar-dropdown--chevron"
details.setAttribute("name", "lexxy-dropdown")
const summary = document.createElement("summary")
summary.className = "lexxy-editor__toolbar-button"
summary.setAttribute("name", "text-align")
summary.setAttribute("title", "Text alignment")
summary.innerHTML = AlignIcons.textAlign
details.appendChild(summary)
const dropdownList = document.createElement("div")
dropdownList.className = "lexxy-editor__toolbar-dropdown-list"
for (const alignment of ALIGNMENTS) {
const button = document.createElement("button")
button.type = "button"
button.setAttribute("name", `align-${alignment.name}`)
button.setAttribute("title", alignment.label)
button.innerHTML = `${AlignIcons[alignment.name]} <span>${alignment.label}</span>`
button.addEventListener("click", () => {
this.#dispatchAlignment(alignment.name)
details.open = false
})
dropdownList.appendChild(button)
}
details.appendChild(dropdownList)
// Insert before the spacer/undo-redo area
const spacer = toolbar.querySelector(".lexxy-editor__toolbar-spacer")
if (spacer) {
spacer.before(details)
} else {
toolbar.appendChild(details)
}
}
#dispatchAlignment(alignment) {
const editor = this.editorElement.editor
const cmd = findCommand(editor, "FORMAT_ELEMENT_COMMAND")
if (cmd) {
editor.dispatchCommand(cmd, alignment)
}
}
}
// Read text-align formats from Lexical's editor state for top-level block elements.
// Walks the root node's children via the internal linked list (__first → __next).
// Uses htmlIndex (not nodeIndex) to account for node types like provisonal_paragraph
// that exist in the editor state but don't produce HTML elements.
function readAlignmentsFromEditorState(editor, htmlElementCount) {
const alignments = []
if (!editor) return alignments
// Node types that exist in the editor state but don't produce HTML output
const NO_HTML_OUTPUT_TYPES = new Set(["provisonal_paragraph"])
editor.getEditorState().read(() => {
const nodeMap = editor.getEditorState()._nodeMap
const root = nodeMap.get("root")
if (!root) return
let childKey = root.__first
let htmlIndex = 0
let nodeCount = 0
while (childKey) {
const node = nodeMap.get(childKey)
if (!node) break
if (NO_HTML_OUTPUT_TYPES.has(node.__type)) {
childKey = node.__next
nodeCount++
continue
}
const align = FORMAT_ALIGNMENT[node.__format]
if (align) {
alignments.push({ index: htmlIndex, align })
}
htmlIndex++
nodeCount++
childKey = node.__next
}
// Safety check: if the HTML element count doesn't match our computed htmlIndex,
// the mapping is unreliable — clear alignments to avoid corrupting the wrong element
if (htmlElementCount !== undefined && htmlIndex !== htmlElementCount) {
console.warn("[TextAlign] index mismatch: htmlIndex", htmlIndex, "vs htmlElementCount", htmlElementCount, "- skipping injection")
alignments.length = 0
}
})
return alignments
}
// Parse the sanitized HTML, apply text-align to matching top-level elements, and serialize back.
function injectTextAlignStyles(html, alignments) {
const template = document.createElement("template")
template.innerHTML = html
const children = template.content.children
for (const { index, align } of alignments) {
if (index < children.length) {
children[index].style.textAlign = align
}
}
const container = document.createElement("div")
container.appendChild(template.content.cloneNode(true))
return container.innerHTML
}
configure({ global: { extensions: [TextAlignmentExtension] } }) |
0 replies
|
How are you dealing with the sanitizer filtering out |
0 replies
|
Sorry, I was on holiday for the past 2 weeks and missed your comment. import { Extension, configure } from "lexxy"
function findCommand(editor, typeName) {
for (const [cmd] of editor._commands) {
if (cmd.type === typeName) return cmd
}
return null
}
// Lexical element format values → CSS text-align values
// (from Lexical's B$3 constant in lexxy.js)
const FORMAT_ALIGNMENT = { 1: "left", 2: "center", 3: "right", 4: "justify", 5: "start", 6: "end" }
const AlignIcons = {
left: `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
<path d="M2 4C2 3.44772 2.44772 3 3 3H15C15.5523 3 16 3.44772 16 5C16 5.55228 15.5523 6 15 6H3C2.44772 6 2 5.55228 2 5V4ZM2 9C2 8.44772 2.44772 8 3 8H11C11.5523 8 12 8.44772 12 9C12 9.55228 11.5523 10 11 10H3C2.44772 10 2 9.55228 2 9ZM3 13C2.44772 13 2 13.4477 2 14C2 14.5523 2.44772 15 3 15H15C15.5523 15 16 14.5523 16 14C16 13.4477 15.5523 13 15 13H3ZM2 18C2 17.4477 2.44772 17 3 17H9C9.55228 17 10 17.4477 10 18C10 18.5523 9.55228 19 9 19H3C2.44772 19 2 18.5523 2 18Z"/>
</svg>`,
center: `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
<path d="M3 3C2.44772 3 2 3.44772 2 4C2 4.55228 2.44772 5 3 5H15C15.5523 5 16 4.55228 16 4C16 3.44772 15.5523 3 15 3H3ZM5 8C4.44772 8 4 8.44772 4 9C4 9.55228 4.44772 10 5 10H13C13.5523 10 14 9.55228 14 9C14 8.44772 13.5523 8 13 8H5ZM2 14C2 13.4477 2.44772 13 3 13H15C15.5523 13 16 13.4477 16 14C16 14.5523 15.5523 15 15 15H3C2.44772 15 2 14.5523 2 14ZM6 17C5.44772 17 5 17.4477 5 18C5 18.5523 5.44772 19 6 19H12C12.5523 19 13 18.5523 13 18C13 17.4477 12.5523 17 12 17H6Z"/>
</svg>`,
right: `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
<path d="M3 3C2.44772 3 2 3.44772 2 4C2 4.55228 2.44772 5 3 5H15C15.5523 5 16 4.55228 16 4C16 3.44772 15.5523 3 15 3H3ZM7 8C6.44772 8 6 8.44772 6 9C6 9.55228 6.44772 10 7 10H15C15.5523 10 16 9.55228 16 9C16 8.44772 15.5523 8 15 8H7ZM2 14C2 13.4477 2.44772 13 3 13H15C15.5523 13 16 13.4477 16 14C16 14.5523 15.5523 15 15 15H3C2.44772 15 2 14.5523 2 14ZM9 17C8.44772 17 8 17.4477 8 18C8 18.5523 8.44772 19 9 19H15C15.5523 19 16 18.5523 16 18C16 17.4477 15.5523 17 15 17H9Z"/>
</svg>`,
justify: `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
<path d="M3 3C2.44772 3 2 3.44772 2 4C2 4.55228 2.44772 5 3 5H15C15.5523 5 16 4.55228 16 4C16 3.44772 15.5523 3 15 3H3ZM3 8C2.44772 8 2 8.44772 2 9C2 9.55228 2.44772 10 3 10H15C15.5523 10 16 9.55228 16 9C16 8.44772 15.5523 8 15 8H3ZM2 14C2 13.4477 2.44772 13 3 13H15C15.5523 13 16 13.4477 16 14C16 14.5523 15.5523 15 15 15H3C2.44772 15 2 14.5523 2 14ZM3 17C2.44772 17 2 17.4477 2 18C2 18.5523 2.44772 19 3 19H15C15.5523 19 16 18.5523 16 18C16 17.4477 15.5523 17 15 17H3Z"/>
</svg>`,
textAlign: `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
<path d="M3 3C2.44772 3 2 3.44772 2 4C2 4.55228 2.44772 5 3 5H15C15.5523 5 16 4.55228 16 4C16 3.44772 15.5523 3 15 3H3ZM3 8C2.44772 8 2 8.44772 2 9C2 9.55228 2.44772 10 3 10H15C15.5523 10 16 9.55228 16 9C16 8.44772 15.5523 8 15 8H3ZM2 14C2 13.4477 2.44772 13 3 13H15C15.5523 13 16 13.4477 16 14C16 14.5523 15.5523 15 15 15H3C2.44772 15 2 14.5523 2 14Z"/>
</svg>`
}
const ALIGNMENTS = [
{ name: "left", label: "Align left" },
{ name: "center", label: "Center" },
{ name: "right", label: "Align right" },
{ name: "justify", label: "Justify" },
]
class TextAlignmentExtension extends Extension {
get enabled() {
return this.editorElement.supportsRichText
}
initializeToolbar(toolbar) {
this.#buildToolbarDropdown(toolbar)
this.#fixTextAlignStripping()
}
// Lexxy's bundled DOMPurify sanitizer only allows "color" and "background-color" as CSS properties,
// stripping text-align from the HTML stored in cachedValue. This intercepts writes to cachedValue
// to re-inject text-align styles by reading the alignment format from the Lexical editor state.
#fixTextAlignStripping() {
const editorElement = this.editorElement
let _cachedValue = null
Object.defineProperty(editorElement, "cachedValue", {
get() {
return _cachedValue
},
set(html) {
if (html) {
// Count top-level HTML elements to verify index mapping
const template = document.createElement("template")
template.innerHTML = html
const htmlElementCount = template.content.children.length
const alignments = readAlignmentsFromEditorState(this.editor, htmlElementCount)
if (alignments.length > 0) {
html = injectTextAlignStyles(html, alignments)
}
}
_cachedValue = html
},
configurable: true,
enumerable: true,
})
}
#buildToolbarDropdown(toolbar) {
const details = document.createElement("details")
details.className = "lexxy-editor__toolbar-dropdown lexxy-editor__toolbar-dropdown--chevron"
details.setAttribute("name", "lexxy-dropdown")
const summary = document.createElement("summary")
summary.className = "lexxy-editor__toolbar-button"
summary.setAttribute("name", "text-align")
summary.setAttribute("title", "Text alignment")
summary.innerHTML = AlignIcons.textAlign
details.appendChild(summary)
const dropdownList = document.createElement("div")
dropdownList.className = "lexxy-editor__toolbar-dropdown-list"
for (const alignment of ALIGNMENTS) {
const button = document.createElement("button")
button.type = "button"
button.setAttribute("name", `align-${alignment.name}`)
button.setAttribute("title", alignment.label)
button.innerHTML = `${AlignIcons[alignment.name]} <span>${alignment.label}</span>`
button.addEventListener("click", () => {
this.#dispatchAlignment(alignment.name)
details.open = false
})
dropdownList.appendChild(button)
}
details.appendChild(dropdownList)
// Insert before the spacer/undo-redo area
const spacer = toolbar.querySelector(".lexxy-editor__toolbar-spacer")
if (spacer) {
spacer.before(details)
} else {
toolbar.appendChild(details)
}
}
#dispatchAlignment(alignment) {
const editor = this.editorElement.editor
const cmd = findCommand(editor, "FORMAT_ELEMENT_COMMAND")
if (cmd) {
editor.dispatchCommand(cmd, alignment)
}
}
}
// Read text-align formats from Lexical's editor state for top-level block elements.
// Walks the root node's children via the internal linked list (__first → __next).
// Uses htmlIndex (not nodeIndex) to account for node types like provisonal_paragraph
// that exist in the editor state but don't produce HTML elements.
function readAlignmentsFromEditorState(editor, htmlElementCount) {
const alignments = []
if (!editor) return alignments
// Node types that exist in the editor state but don't produce HTML output
const NO_HTML_OUTPUT_TYPES = new Set(["provisonal_paragraph"])
editor.getEditorState().read(() => {
const nodeMap = editor.getEditorState()._nodeMap
const root = nodeMap.get("root")
if (!root) return
let childKey = root.__first
let htmlIndex = 0
let nodeCount = 0
while (childKey) {
const node = nodeMap.get(childKey)
if (!node) break
if (NO_HTML_OUTPUT_TYPES.has(node.__type)) {
childKey = node.__next
nodeCount++
continue
}
const align = FORMAT_ALIGNMENT[node.__format]
if (align) {
alignments.push({ index: htmlIndex, align })
}
htmlIndex++
nodeCount++
childKey = node.__next
}
// Safety check: if the HTML element count doesn't match our computed htmlIndex,
// the mapping is unreliable — clear alignments to avoid corrupting the wrong element
if (htmlElementCount !== undefined && htmlIndex !== htmlElementCount) {
console.warn("[TextAlign] index mismatch: htmlIndex", htmlIndex, "vs htmlElementCount", htmlElementCount, "- skipping injection")
alignments.length = 0
}
})
return alignments
}
// Parse the sanitized HTML, apply text-align to matching top-level elements, and serialize back.
function injectTextAlignStyles(html, alignments) {
const template = document.createElement("template")
template.innerHTML = html
const children = template.content.children
for (const { index, align } of alignments) {
if (index < children.length) {
children[index].style.textAlign = align
}
}
const container = document.createElement("div")
container.appendChild(template.content.cloneNode(true))
return container.innerHTML
}
configure({ global: { extensions: [TextAlignmentExtension] } }) |
0 replies
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment

Uh oh!
There was an error while loading. Please reload this page.
Are there any plans to have a way for the user to define text-align property (left, right, justify,...)?

It is currently possible in the Lexical playground.
This would be a nice addition imho.
All reactions