diff --git a/src/config/lexxy.js b/src/config/lexxy.js index e26c591c1..a5da2f97f 100644 --- a/src/config/lexxy.js +++ b/src/config/lexxy.js @@ -13,6 +13,7 @@ const presets = new Configuration({ attachments: true, markdown: true, multiLine: true, + mixedLists: true, richText: true, toolbar: { upload: "both" diff --git a/src/editor/command_dispatcher.js b/src/editor/command_dispatcher.js index 11d9ef9ff..f80c74c3a 100644 --- a/src/editor/command_dispatcher.js +++ b/src/editor/command_dispatcher.js @@ -20,7 +20,7 @@ import { $createAutoLinkNode, $toggleLink } from "@lexical/link" import { INSERT_TABLE_COMMAND } from "@lexical/table" import { createElement } from "../helpers/html_helper" -import { getListType } from "../helpers/lexical_helper" +import { getListItemNode, getListType } from "../helpers/lexical_helper" import { HorizontalDividerNode } from "../nodes/horizontal_divider_node" import { REMOVE_HIGHLIGHT_COMMAND, TOGGLE_HIGHLIGHT_COMMAND } from "../extensions/highlight_extension" @@ -123,9 +123,13 @@ export class CommandDispatcher { if (!selection) return const anchorNode = selection.anchor.getNode() + const listItem = getListItemNode(anchorNode) - if (this.selection.isInsideList && anchorNode && getListType(anchorNode) === "bullet") { + if (this.selection.isInsideList && getListType(anchorNode) === "bullet") { this.contents.applyParagraphFormat() + } else if (this.selection.isInsideList && listItem && this.editorElement.supportsMixedLists) { + listItem.setListItemType?.("bullet") + this.contents.unwrapListItemIfWrapped(listItem) } else { this.editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined) } @@ -136,9 +140,13 @@ export class CommandDispatcher { if (!selection) return const anchorNode = selection.anchor.getNode() + const listItem = getListItemNode(anchorNode) - if (this.selection.isInsideList && anchorNode && getListType(anchorNode) === "number") { + if (this.selection.isInsideList && getListType(anchorNode) === "number") { this.contents.applyParagraphFormat() + } else if (this.selection.isInsideList && listItem && this.editorElement.supportsMixedLists) { + listItem.setListItemType?.("number") + this.contents.unwrapListItemIfWrapped(listItem) } else { this.editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined) } diff --git a/src/editor/contents.js b/src/editor/contents.js index 80e84419a..f66258f22 100644 --- a/src/editor/contents.js +++ b/src/editor/contents.js @@ -7,6 +7,7 @@ import { import { $generateNodesFromDOM } from "@lexical/html" import { $createCodeNode, $isCodeNode } from "@lexical/code" import { $createHeadingNode, $createQuoteNode, $isQuoteNode } from "@lexical/rich-text" +import { $isListItemNode, $isListNode } from "@lexical/list" import { CustomActionTextAttachmentNode } from "../nodes/custom_action_text_attachment_node" import { $createLinkNode, $toggleLink } from "@lexical/link" import { dispatch, parseHtml } from "../helpers/html_helper" @@ -69,14 +70,115 @@ export default class Contents { const selection = $getSelection() if (!$isRangeSelection(selection)) return + const listItem = this.#findContainingListItem(selection) + if (listItem) { + this.unwrapListItemIfWrapped(listItem) + } + + const savedStyles = this.#captureTextStyles(selection) $setBlocksType(selection, () => $createParagraphNode()) + this.#restoreTextStyles(savedStyles) } applyHeadingFormat(tag) { const selection = $getSelection() if (!$isRangeSelection(selection)) return + const savedStyles = this.#captureTextStyles(selection) $setBlocksType(selection, () => $createHeadingNode(tag)) + this.#restoreTextStyles(savedStyles) + } + + // Save inline styles (keyed by text content + offset) from text nodes in + // the selected blocks so they can be restored after $setBlocksType, which + // can strip styles when converting list items to other block types. + #captureTextStyles(selection) { + const styles = new Map() + for (const node of selection.getNodes()) { + if ($isTextNode(node)) { + const style = node.getStyle() + if (style) styles.set(node.getKey(), style) + } + } + return styles + } + + #restoreTextStyles(savedStyles) { + if (savedStyles.size === 0) return + for (const [ key, style ] of savedStyles) { + const node = $getNodeByKey(key) + if ($isTextNode(node) && !node.getStyle()) { + node.setStyle(style) + } + } + } + + // Find the ListItemNode containing the selection anchor, if any. + #findContainingListItem(selection) { + let current = selection.anchor.getNode() + while (current) { + if ($isListItemNode(current)) return current + current = current.getParent() + } + return null + } + + // Wrap a list item's inline content in a block element (heading, quote). + // If already wrapped, swap the wrapper type. Public for use by slash + // commands and block editing extensions. + wrapListItemContent(listItem, newBlock) { + const children = listItem.getChildren() + + // Already wrapped in a non-paragraph block? Swap the wrapper. + const existingWrapped = children.find(c => + $isElementNode(c) && !$isListNode(c) && !$isParagraphNode(c) + ) + if (existingWrapped) { + for (const child of [ ...existingWrapped.getChildren() ]) { + newBlock.append(child) + } + existingWrapped.replace(newBlock) + newBlock.selectEnd() + return + } + + // Regular inline content → wrap in the new block + for (const child of [ ...children ]) { + if ($isListNode(child)) continue + newBlock.append(child) + } + const firstChild = listItem.getFirstChild() + if (firstChild) { + firstChild.insertBefore(newBlock) + } else { + listItem.append(newBlock) + } + newBlock.selectEnd() + } + + // Unwrap a wrapped block inside a list item if one exists. No-op for + // regular (non-wrapped) list items. Public so command_dispatcher can call it. + unwrapListItemIfWrapped(listItem) { + const children = listItem.getChildren() + const wrappedChild = children.find(c => + $isElementNode(c) && !$isListNode(c) && !$isParagraphNode(c) + ) + if (wrappedChild) this.#unwrapListItemContent(listItem) + } + + // Unwrap a wrapped block back to regular inline list item content. + #unwrapListItemContent(listItem) { + const children = listItem.getChildren() + const wrappedChild = children.find(c => + $isElementNode(c) && !$isListNode(c) && !$isParagraphNode(c) + ) + if (wrappedChild) { + for (const child of [ ...wrappedChild.getChildren() ]) { + listItem.append(child) + } + wrappedChild.remove() + } + listItem.selectEnd() } #applyCodeBlockFormat() { diff --git a/src/editor/selection.js b/src/editor/selection.js index 416cae7a8..d3dca74f4 100644 --- a/src/editor/selection.js +++ b/src/editor/selection.js @@ -125,6 +125,8 @@ export default class Selection { const topLevelElement = anchorNode.getTopLevelElementOrThrow() const listType = getListType(anchorNode) + const listItem = $getNearestNodeOfType(anchorNode, ListItemNode) + const effectiveListType = listItem?.getEffectiveListType?.() ?? listType const headingNode = this.#getNearestHeadingNode(anchorNode) return { @@ -139,7 +141,7 @@ export default class Selection { isInCode: this.#isInCode(selection, anchorNode), headingTag: headingNode?.getTag() ?? null, isInList: listType !== null, - listType, + listType: effectiveListType, isInTable: $getTableCellNodeFromLexicalNode(anchorNode) !== null } } diff --git a/src/elements/editor.js b/src/elements/editor.js index 7fa0df19f..b923918d3 100644 --- a/src/elements/editor.js +++ b/src/elements/editor.js @@ -164,6 +164,10 @@ export class LexicalEditorElement extends HTMLElement { return this.config.get("multiLine") && !this.isSingleLineMode } + get supportsMixedLists() { + return this.supportsRichText && this.config.get("mixedLists") + } + get supportsRichText() { return this.config.get("richText") } diff --git a/src/extensions/format_escape_extension.js b/src/extensions/format_escape_extension.js index 03e5de3d3..7ed0a1973 100644 --- a/src/extensions/format_escape_extension.js +++ b/src/extensions/format_escape_extension.js @@ -1,11 +1,11 @@ -import { $createParagraphNode, $getSelection, $isRangeSelection, $splitNode, COMMAND_PRIORITY_HIGH, COMMAND_PRIORITY_NORMAL, INSERT_PARAGRAPH_COMMAND, KEY_ARROW_DOWN_COMMAND, ParagraphNode, defineExtension } from "lexical" +import { $createParagraphNode, $getSelection, $isParagraphNode, $isRangeSelection, $isTextNode, $splitNode, COMMAND_PRIORITY_HIGH, COMMAND_PRIORITY_NORMAL, INSERT_PARAGRAPH_COMMAND, KEY_ARROW_DOWN_COMMAND, KEY_SPACE_COMMAND, ParagraphNode, defineExtension } from "lexical" import { CodeNode } from "@lexical/code" -import { ListItemNode } from "@lexical/list" +import { $isListItemNode, ListItemNode } from "@lexical/list" import { $isQuoteNode } from "@lexical/rich-text" import { $getNearestNodeOfType, mergeRegister } from "@lexical/utils" import { EarlyEscapeCodeNode } from "../nodes/early_escape_code_node" import { EarlyEscapeListItemNode } from "../nodes/early_escape_list_item_node" -import { $isBlankNode, $isCursorOnLastLine, $trimTrailingBlankNodes } from "../helpers/lexical_helper" +import { $isBlankNode, $isCursorOnLastLine, $trimTrailingBlankNodes, extendConversion } from "../helpers/lexical_helper" import LexxyExtension from "./lexxy_extension" export class FormatEscapeExtension extends LexxyExtension { @@ -15,16 +15,34 @@ export class FormatEscapeExtension extends LexxyExtension { } get lexicalExtension() { + const mixedLists = this.editorElement.supportsMixedLists + + const htmlImport = { } + if (mixedLists) { + htmlImport.li = (element) => { + if (!element.dataset?.listItemType) return null + return { + conversion: extendConversion(ListItemNode, "li", $applyListItemType), + priority: 1 + } + } + } + return defineExtension({ name: "lexxy/format-escape", nodes: [ EarlyEscapeCodeNode, { replace: CodeNode, with: (node) => new EarlyEscapeCodeNode(node.getLanguage()), withKlass: EarlyEscapeCodeNode }, EarlyEscapeListItemNode, - { replace: ListItemNode, with: () => new EarlyEscapeListItemNode(), withKlass: EarlyEscapeListItemNode }, + { replace: ListItemNode, with: (node) => { + const replacement = new EarlyEscapeListItemNode(node.__value, node.__checked) + if (mixedLists && node.__listItemType) replacement.setListItemType(node.__listItemType) + return replacement + }, withKlass: EarlyEscapeListItemNode }, ], + html: { import: htmlImport }, register(editor) { - return mergeRegister( + const registrations = [ editor.registerCommand( INSERT_PARAGRAPH_COMMAND, () => $escapeFromBlockquote(), @@ -35,7 +53,17 @@ export class FormatEscapeExtension extends LexxyExtension { (event) => $handleArrowDownInCodeBlock(event), COMMAND_PRIORITY_NORMAL ) - ) + ] + + if (mixedLists) { + registrations.push( + editor.registerCommand(KEY_SPACE_COMMAND, () => { + return $toggleListItemTypeOnSpace() + }, COMMAND_PRIORITY_HIGH) + ) + } + + return mergeRegister(...registrations) } }) } @@ -69,6 +97,59 @@ function $splitQuoteNode(node, paragraph) { paragraph.selectEnd() } +function $applyListItemType(conversionOutput, element) { + const listItemType = element.dataset.listItemType + if (listItemType === "bullet" || listItemType === "number") { + conversionOutput.node.setListItemType?.(listItemType) + } +} + +const BULLET_TRIGGER = /^[-*+]$/ +const NUMBER_TRIGGER = /^\d{1,}\.$/ + +// Called only when space is typed. Checks if the text before the cursor +// matches a list type trigger (e.g., "- " or "1. ") and toggles the +// list item type accordingly. Uses INSERT_TEXT_COMMAND instead of a +// TextNode transform to avoid running on every text mutation. +function $toggleListItemTypeOnSpace() { + const selection = $getSelection() + if (!$isRangeSelection(selection) || !selection.isCollapsed()) return false + + const anchor = selection.anchor.getNode() + if (!$isTextNode(anchor)) return false + + const parent = anchor.getParent() + let listItem + if ($isListItemNode(parent)) { + listItem = parent + } else if ($isParagraphNode(parent) && $isListItemNode(parent.getParent())) { + listItem = parent.getParent() + } else { + return false + } + + if (!listItem.getEffectiveListType) return false + if (parent.getFirstChild() !== anchor) return false + + // Text content before the space is inserted + const text = anchor.getTextContent().slice(0, selection.anchor.offset) + const effectiveType = listItem.getEffectiveListType() + + if (effectiveType === "number" && BULLET_TRIGGER.test(text)) { + listItem.setListItemType("bullet") + anchor.setTextContent(anchor.getTextContent().slice(selection.anchor.offset)) + anchor.select(0, 0) + return true // consume the space + } else if (effectiveType === "bullet" && NUMBER_TRIGGER.test(text)) { + listItem.setListItemType("number") + anchor.setTextContent(anchor.getTextContent().slice(selection.anchor.offset)) + anchor.select(0, 0) + return true + } + + return false +} + function $handleArrowDownInCodeBlock(event) { const selection = $getSelection() if (!$isRangeSelection(selection) || !selection.isCollapsed()) return false diff --git a/src/helpers/lexical_helper.js b/src/helpers/lexical_helper.js index b21196a75..3f587d609 100644 --- a/src/helpers/lexical_helper.js +++ b/src/helpers/lexical_helper.js @@ -1,6 +1,6 @@ import { $createNodeSelection, $createParagraphNode, $isDecoratorNode, $isElementNode, $isLineBreakNode, $isTextNode, TextNode } from "lexical" import { HISTORY_MERGE_TAG, SKIP_SCROLL_INTO_VIEW_TAG } from "lexical" -import { ListNode } from "@lexical/list" +import { ListItemNode, ListNode } from "@lexical/list" import { $getNearestNodeOfType, $lastToFirstIterator } from "@lexical/utils" import { $wrapNodeInElement } from "@lexical/utils" import { $isAtNodeEnd } from "@lexical/selection" @@ -31,6 +31,10 @@ export function getListType(node) { return list?.getListType() ?? null } +export function getListItemNode(node) { + return $getNearestNodeOfType(node, ListItemNode) +} + export function $isAtNodeEdge(point, atStart = null) { if (atStart === null) { return $isAtNodeEdge(point, true) || $isAtNodeEdge(point, false) diff --git a/src/nodes/early_escape_list_item_node.js b/src/nodes/early_escape_list_item_node.js index 99436fe28..50e70dfb1 100644 --- a/src/nodes/early_escape_list_item_node.js +++ b/src/nodes/early_escape_list_item_node.js @@ -5,10 +5,108 @@ import { $getNearestNodeOfType } from "@lexical/utils" import { $isBlankNode, $trimTrailingBlankNodes } from "../helpers/lexical_helper" export class EarlyEscapeListItemNode extends ListItemNode { + /** @type {'bullet' | 'number' | undefined} */ + __listItemType + $config() { return this.config("early_escape_listitem", { extends: ListItemNode }) } + afterCloneFrom(prevNode) { + super.afterCloneFrom(prevNode) + this.__listItemType = prevNode.__listItemType + } + + getListItemType() { + return this.getLatest().__listItemType + } + + setListItemType(type) { + const self = this.getWritable() + self.__listItemType = type + return self + } + + getEffectiveListType() { + const override = this.getListItemType() + if (override) return override + + const parent = this.getParent() + return $isListNode(parent) ? parent.getListType() : "bullet" + } + + createDOM(config) { + const element = super.createDOM(config) + this.#syncDOMAttributes(element) + return element + } + + updateDOM(prevNode, dom, config) { + const result = super.updateDOM(prevNode, dom, config) + this.#syncDOMAttributes(dom) + return result + } + + #syncDOMAttributes(element) { + if (this.__listItemType) { + element.dataset.listItemType = this.getEffectiveListType() + this.#updateBulletDepth(element) + } else { + delete element.dataset.listItemType + delete element.dataset.bulletDepth + } + } + + #updateBulletDepth(element) { + if (this.getEffectiveListType() === "bullet" && !this.getChildren().some(c => $isListNode(c))) { + const depth = ((this.#computeBulletDepth() - 1) % 3) + 1 + element.dataset.bulletDepth = depth + } else { + delete element.dataset.bulletDepth + } + } + + #computeBulletDepth() { + let depth = 1 + let node = this.getParent() + while ($isListNode(node)) { + const wrapper = node.getParent() + if (!$isListItemNode(wrapper)) break + const outerList = wrapper.getParent() + if (!$isListNode(outerList)) break + const prev = wrapper.getPreviousSibling() + if (!prev || !$isListItemNode(prev)) break + if (prev.getChildren().some(c => $isListNode(c))) break + const isBullet = prev instanceof EarlyEscapeListItemNode && + prev.getEffectiveListType() === "bullet" + if (!isBullet) break + depth++ + node = outerList + } + return depth + } + + exportDOM(editor) { + const result = super.exportDOM(editor) + if (this.getListItemType()) { + result.element.dataset.listItemType = this.getListItemType() + } else { + delete result.element.dataset.listItemType + } + return result + } + + exportJSON() { + return { + ...super.exportJSON(), + listItemType: this.getListItemType() + } + } + + updateFromJSON(serializedNode) { + return super.updateFromJSON(serializedNode).setListItemType(serializedNode.listItemType) + } + insertNewAfter(selection, restoreSelection) { if (this.#shouldEscape(selection)) { return this.#escapeFromList()