-
Notifications
You must be signed in to change notification settings - Fork 1.8k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Feature: Wrap nested lists in previous listitem instead of in a new listitem #2951
Comments
+1 for this one. Besides CSS / styling issues, I'd also argue the structure of the internal editor state is now also "incorrect" for nested lists:
The nested list is visually a child of "b", but in the node hierarchy it's a sibling of "b" |
+1. This is a blocker in situations where we can't apply styles to the rendered output. |
I've been looking at re-working our nested lists logic, partially to fix this problem. One issue is that, AFAICT, it's impossible to get browsers to render a list with an indented first item using valid, semantic HTML (without using additional styles): https://codesandbox.io/s/hardcore-voice-53j9vz?file=/index.html In fact, you'll run into the same issue any time you try to indent a list item more than one level away from it's parent indentation level. The only way around this while preserving the free-from indentation behavior that currently exists is to directly nest UL elements, which is not valid HTML. Another option is to rely on styling to apply indentation, which compounds both the semantics problem and the rendering problem. We could constrain users to indenting only one level below the parent, but I don't think that's the right solution - ultimately it should be possible to implement an unrestricted indent experience. One thing I am going to try is seeing if we can change how we nest in the basic case outlined above, where you just have a single level of indentation. This is, in fact, incorrect in that it should be a child rather than a sibling of the thing it's indented "under". However, I still don't see a better way of handling more than one level of indentation here.
I'm not sure this is a blocker, given that it's possible to render the EditorState to HTML according to whatever logic you want. There's no requirement to use Lexical's HTML rendering implementation. |
@acywatson thanks for looking into this! I'm guessing the spec isn't explicit about this behavior. Here's what I observed:
I agree with changing the behavior for single level indentation to make it a sibling. We can then write our own logic to customize multiple levels of nesting. |
The more I think about lists and indents, the more convinced I am that I have sent this proposal to the organization that is in charge of standardizing HTML. I know that in the very difficult case that it is seriously considered it would take years to implement, but hey... at least I tried 😂🤷♂️ |
@acywatson this IS a blocker because Copy Paste is not behaving properly even amongst lexical instances #3740 |
@acywatson and @thegreatercurve, I'm following the conversation on #3740, where it looks like the copy-paste issue has already been fixed (at #3757). Regarding the problem of prefixes in enumerated lists, I can't find a case where it is counter-intuitive with the current implementation. However, you said in that thread that you will change the list's behavior. What is the change you plan to make (and why)? Forbid a |
Selected quotes from my comment above: With respect to "Forbid a li from indenting more than one level from her parent?"
So, no, I don't think we should do that. With respect to "What is the change you plan to make (and why)?":
So, my current thinking is that we should try to change it such that a single level of indentation is semantically correct. We can do this within the HTML spec. What we can't do is create semantically correct structures with more than one level of indentation relative to the parent. However, my hope is that making things work correctly in the base, single-level-of-indentation case will make it smoother without regressing the more complex case of multiple indentation levels. We shall see how the implementation goes. |
I think you might be right about this. I can't rightly think of anything that would be worse about that than the current nesting approach. And there are certainly some things that would be better/simpler. Bottom line is that I just don't think the expressive rich-text use cases covered by modern editors were top-of-mind when the HTML spec was created. |
I would say that, strictly speaking, nesting and indenting is not the same thing. As I see it, indenting is primarily a visual thing while nesting (of lists at least) is primarily a semantic feature to define a parent-children relationship. Then we use indentation to visualize that relationship. Hence imo, indenting should be done with CSS while nesting should be done semantically with HTML. With this premise, I don't think it makes sense to use nesting to indent an item more than one level bellow the parent. It would technically make the item an orphan (or perhaps just make the parent nameless 😅). I don't really understand why anyone would want to indent an item multiple levels, but that's just me. I respect that you want to make the editor as flexible as possible. Maybe it would be possible to make the first indentation use nesting, while subsequent indentations only use CSS? Personally though, I would be perfectly happy with constraining to one level of indentation bellow the parent, as long as the HTML is valid and semantically correct. I would think that valid HTML/semantics would be a high priority to ensure accessibility, compatibility and consistent behavior across browsers. |
I am someone who heavily relies on indenting items multiple times. In my experience Lexical is the only RTE library that gets this functionality right, so disabling it would be a huge regression (for me). |
It is, which is exactly why this whole thing is a problem in the first place. If we didn't care about this, we would just do it the way other text editors do, which is by using CSS or just relying on user-agent styles produced by invalid HTML (i.e., directly nesting ul tags)
This is, of course, true. The browser rendering of a list is just the conventional visualization of that semantic structure. The problem is that modern rich-text editing experiences almost universally support this type of multiple-level indentation, whereas the HTML spec simply doesn't. There's no clean way around it.
I think the best solution is ultimately different treatment of single vs multiple levels, but exactly how that implementation will work, I'm still not 100%. |
Any updates on this? It seems pretty well established that there's no "right" way to do it, but Lexical handles it differently than most other solutions I've seen and it is really difficult to work with from a styling standpoint. Not having the ability to use CSS counters makes styling lists nicely a near impossibility since custom counters are used to overcome the shortcomings of HTML's in-built list styling. In Slate using the old <ol class="NumberList">
<li class="ListItemRoot">
<p class="Paragraph"><span>ABC</span></p>
<ol class="NumberList">
<li class="ListItemRoot">
<p class="Paragraph"><span>DEF</span></p>
</li>
<li class="ListItemRoot">
<p class="Paragraph"><span>GHI</span></p>
</li>
</ol>
</li>
</ol> I have found this structure to be extremely flexible to work with. It displays properly in all modern browsers. It supports CSS list counters. It's easy to conceptualize. I see this issue has been around for quite a while - I wonder what the odds are that the way lists are handled will change to accommodate this? I wouldn't call this a hard blocker for us, but it's certainly a considerable issue which I see no workaround for without inventing our own new list plugin - but that seems unsustainable and a shame since the Lexical team is already maintaining one that's so close to being everything we need. |
Yea, it works fine until you start to think about multiple levels of indentation. That's the crux of the issue. Would love to change this and probably will at some point, but I can't make guarantees about timeline. What are you trying to do that you can't do now? |
The primary issue is that I don't see how CSS counters can be applied to multi-level lists the lists as they're structured right now. Maybe I'm just not being imaginative enough - but I don't see any way to avoid the wrong list counters from the previous level of the list when indenting.
I'm not clear on that. I guess you mean directly indenting a child twice from a parent? Because as far as I can tell it nests beautifully as long as you don't allow that. And I think that that would be an extremely niche scenario, far more than expecting CSS counters to work with the lists. I feel like double indenting would constitute an orphan, as @halvor07 put it. But if it were necessary, couldn't it be achieved with an additional className or data attribute to indicate the block needs to be double/triple/quadruple indented? I mean - there's no way to do both things at the same time it seems - but breaking a crucial styling feature seems very problematic to me. Edit: example of the problem with CSS counters: |
Yes indeed. Am I overlooking a way to achieve that with CSS counters using either Edit: We also use use the nesting for styling purposes - eg to display decorations on all the child list elements. That seems possible with either format, but with the format Lexical uses now it seems quite difficult and expensive - whereas with the other structure it's simple and performant via CSS - no JS required. |
I made this work in the playground by just deleting this line:
This is using theming though, so per our other thread may or may not be as easy for you. |
@acywatson I hate to add on but I just ninja-edited before you commented that. Would you have any suggestions for how to handle our other issue in a reasonable way? I'm giving your suggestion a test now, and thank you kindly for it. |
I guess you can do this the same way - using those nested levels theme classes. Perhaps you could use level-based inline styles applied in createDOM/updateDOM. |
Just FYI, I was attempting to implement the counter styles via I'm not sure if I was clear in my original explanation of the styled list children - the nesting styles would only be applied to items that are children of the currently focused list. I suppose this can still be achieved though, with |
Yea I would have to consider that for a bit - I don't think there is much (functionally) that can't be achieved in some way with the current setup. That said, I do understand the limitations and have acknowledged those above. I would like to remedy those, but everything is a matter of prioritization. |
For those just wanting their lists to work right, the playground solves this here
For me with tailwindcss, this was as easy as adding this theme
|
I am using this function and its working for me for above problem . Please try it @rzrotem : function func(html: string): string { let lis = doc.querySelectorAll('li.editor-listitem'); // Iterate over all li elements
} // Convert the modified DOM back to an HTML string and return it |
Would for example a placeholder |
Hello, |
I ran into this issue while verifying our Payload CMS serializer which displayed multiple list markers. The I usually take notes during meetings using deeply nested bullet lists which get archived in Confluence so far. Confluence uses TinyMCE which has a lot of list related bugs. Editing works fine in Lexical and with this serializer change the output is pure HTML again. /**
* Normalize children of a list to use HTML stacking without needing any CSS quirks.
*
* Note: changes a single level, call again for sublists while rendering.
*
* @param children children of a list
* @returns
*/
function fixListItemNesting(children: SerializedLexicalNode[]) {
const res: SerializedLexicalNode[] = [];
for (const node of children) {
//validate
if (node.type !== 'listitem') {
//unexpected item found
res.push(node);
continue;
}
//check li > ol, li > ul
const listItemNode = node as SerializedListItemNode;
const children = listItemNode.children;
if (children.length === 1 && children[0].type === 'list' && res.length > 0) {
const prev = res.pop() as SerializedListNode;
if (prev.type !== 'listitem') {
res.push(prev);
res.push(node);
continue;
}
res.push({
...prev,
children: [
...prev.children,
children[0]
]
} as SerializedListNode);
//debug
//console.log('Merged:');
//console.dir(res.at(-1));
continue;
}
res.push(node);
}
//debug
//console.dir(res);
return res;
} Sample code from the serializer: const listNode = node as SerializedListNode;
const listType = listNode.listType; //'number' | 'bullet' | 'check'
if (listType !== 'number' && listType !== 'bullet') {
logError(`unsupported list type: ${listType}`);
continue;
}
const ListElement = listNode.tag;
res.push(
<ListElement
key={i}
>
<SerializeNodes
nodes={fixListItemNesting(listNode.children)}
lang={lang}
i18n={i18n}
blockRenderers={blockRenderers}
inlineBlockRenderers={inlineBlockRenderers}
/>
</ListElement>
); |
Posting my fix. I used the toHtml from some other post and modified the list/list item to deal with this. Modify the rest as needed. import {
EditorState,
SerializedLexicalNode,
SerializedParagraphNode,
SerializedTabNode,
SerializedTextNode
} from 'lexical'
//import { SerializedImageNode } from './nodes/ImageNode'
import { SerializedLinkNode } from '@lexical/link'
import { SerializedCodeNode } from '@lexical/code'
import { SerializedHeadingNode, SerializedQuoteNode } from '@lexical/rich-text'
import {
ListType,
SerializedListItemNode,
SerializedListNode
} from '@lexical/list'
import {
SerializedTableCellNode,
SerializedTableNode,
SerializedTableRowNode
} from '@lexical/table'
import escapeText from './escapeText'
async function toHTML(state: EditorState): Promise<string> {
const es = state.toJSON() // _nodeMap.get('root')
// if (!root) {
// return ''
// }
const root = es.root
let html = ''
for (const node of root.children) {
html += await dumpNode(node)
}
return html
}
async function dumpNode(node: SerializedLexicalNode): Promise<string> {
// console.log('node:', node)
switch (node.type) {
case 'paragraph':
return dumpParagraph(node as SerializedParagraphNode)
case 'text':
return dumpText(node as SerializedTextNode)
case 'tab':
return dumpTab(node as SerializedTabNode)
case 'code-highlight':
return dumpCodeText(node)
case 'linebreak':
return '<br />'
case 'list':
return dumpList(node as SerializedListNode)
case 'tablecell':
return dumpTableCell(node as SerializedTableCellNode)
case 'tablerow':
return dumpTableRow(node as SerializedTableRowNode)
case 'table':
return dumpTable(node as SerializedTableNode)
case 'quote':
return dumpQuote(node as SerializedQuoteNode)
case 'heading':
// tag
return dumpHeading(node as SerializedHeadingNode)
case 'code':
return dumpCode(node as SerializedCodeNode)
case 'autolink':
case 'link':
return dumpLink(node as SerializedLinkNode)
// case 'image':
// return dumpImage(node as SerializedImageNode)
}
return ''
}
async function dumpTableCell(node: SerializedTableCellNode): Promise<string> {
let content = ''
for (const child of node.children) {
content += await dumpNode(child)
}
if (node.headerState === 0) {
return `<td>${content}</td>\n`
}
return `<th>${content}</th>\n`
}
async function dumpTableRow(node: SerializedTableRowNode): Promise<string> {
let content = ''
for (const child of node.children) {
content += await dumpNode(child)
}
return `<tr>${content}</tr>\n`
}
async function dumpTable(node: SerializedTableNode): Promise<string> {
let content = ''
for (const child of node.children) {
content += await dumpNode(child)
}
return `<table>${content}</table>\n`
}
async function dumpListItem(
node: SerializedListItemNode,
listType: ListType,
itemIndex: number,
depth: number
): Promise<string> {
let text = ''
let nestedListHtml = ''
let hasText = false // Track if there's actual text in <li>
for (const child of node.children) {
if (child.type === 'list') {
nestedListHtml = await dumpList(child as SerializedListNode, depth + 1)
} else {
text += await dumpNode(child)
hasText = true
}
}
if (hasText) {
return `<li>${text}${nestedListHtml}</li>\n`
}
if (nestedListHtml) {
return nestedListHtml // If only a nested list exists, merge it without an empty <li>
}
return '' // If completely empty, return nothing
}
async function dumpList(node: SerializedListNode, depth = 0): Promise<string> {
let text = ''
let itemIndex = 1 // Start numbering properly
const typeAttr = depth === 1 ? ' type="a"' : depth === 2 ? ' type="i"' : ''
for (const child of node.children) {
const listItemHtml = await dumpListItem(
child as SerializedListItemNode,
node.listType,
itemIndex,
depth
)
if (listItemHtml.includes('<li')) {
itemIndex++ // Only increment when a valid <li> is added
}
text += listItemHtml
}
if (!text.trim()) return ''
switch (node.listType) {
case 'bullet':
case 'check':
return `<ul>${text}</ul>\n`
case 'number':
return `<ol${typeAttr} start="${1}">${text}</ol>\n` // Ensuring the numbering starts at 1
}
throw new Error('Invalid listType: ' + node.listType)
}
async function dumpQuote(node: SerializedQuoteNode): Promise<string> {
let text = ''
for (const child of node.children) {
text += await dumpNode(child)
}
return `<blockquote>${text}</blockquote>\n`
}
async function dumpHeading(node: SerializedHeadingNode): Promise<string> {
let text = ''
for (const child of node.children) {
text += await dumpNode(child)
}
return `<${node.tag}>${text}</${node.tag}>\n`
}
//
async function dumpCodeText(node: any): Promise<string> {
const text = escapeText(node.text)
return `<span${
node.highlightType ? ' class="' + 'token-' + node.highlightType + '"' : ''
}>${text}</span>`
}
// async function dumpImage(node: SerializedImageNode): Promise<string> {
// let src = node.src
// if (src.startsWith('data:image')) {
// // we upload image to server
// const resp = await fetch('/api/upload', {
// method: 'POST',
// credentials: 'include',
// body: JSON.stringify({ src: src })
// })
// const data = await resp.json()
// if (data.code !== 200) {
// throw new Error(data.message)
// }
// console.log('data.src:', data.data.src)
// src = data.data.src
// }
// return `<img src="${src}" ${node.width ? 'width="' + node.width + '"' : ''} ${
// node.height ? 'height="' + node.height + '"' : ''
// } alt="${node.altText}" max-width="${node.maxWidth}">\n`
// }
// async function dumpVideo(node: any) {}
async function dumpParagraph(node: SerializedParagraphNode): Promise<string> {
let html = ''
for (const child of node.children) {
html += await dumpNode(child)
}
if (node.format === '' && node.indent === 0) {
return '<p>' + html + '</p>\n'
}
let styles = ''
if (node.indent !== 0) {
styles += 'padding-inline-start: calc(40px);'
}
if (node.format !== '') {
styles += 'text-align: ' + node.format + ';'
}
return `<p style="${styles}">${html}</p>\n`
}
async function dumpLink(node: SerializedLinkNode): Promise<string> {
let text = ''
for (const child of node.children) {
text += await dumpNode(child)
}
return `<a href="${node.url}" target="_blank">${text}</a>`
}
// type: code
async function dumpCode(node: SerializedCodeNode): Promise<string> {
let code = ''
for (const child of node.children) {
code += await dumpNode(child)
}
return `<pre ${
node.language ? 'data-highlight-language="' + node.language + '"' : ''
}>${code}</pre>`
}
async function dumpTab(node: SerializedTabNode): Promise<string> {
return '<span> </span>'
}
async function dumpText(node: SerializedTextNode): Promise<string> {
let html = escapeText(node.text)
const attrs = []
// klass = []
const format = node.format
if (format & 0x01) {
// bold
attrs.push('strong')
}
if (format & 0x02) {
// italic
attrs.push('em')
}
if (format & 0x04) {
// strikethrough
attrs.push('s')
}
if (format & 0x08) {
// underline
attrs.push('u')
}
if (format & 0x10) {
// code
attrs.push('code')
}
if (format & 0x20) {
// subscript
attrs.push('sub')
}
if (format & 0x40) {
// superscript
attrs.push('sup')
}
if (format & 0x80) {
}
if (format & 0x100) {
}
if (format & 0x200) {
}
for (const attr of attrs) {
html = '<' + attr + '>' + html + '</' + attr + '>'
}
// if (klass.length > 0) {
// html = '<span class="' + klass.join(' ') + '">' + html + '</span>'
// }
return html
}
export default toHTML Then I output with |
Lexical version: 0.3.11
Current solution
Currently nested lists are added wrapped inside a new listitem:
This makes things like prefixes for nested list (1.1, 1.2 [...]) way harder to achieve. We can´t easily make use of incrementing css counters and autoincrements won´t work correctly since there is another list item in the first level list. Actually we need to set
list-style-type:"none";
on nested lists to get rid of multiple (and incorrect) counters.Alternative solution
An alternative solution would be to wrap the nested list inside the previous listitem.
This would allow us to achieve things like prefixes way easier since we can just use css counters which won´t get mixed up by a wrong number of listitems. We can also make use of the autoincrement since there is no wrong number of listitems in the previous list level.
The text was updated successfully, but these errors were encountered: