Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/config/lexxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const presets = new Configuration({
attachments: true,
markdown: true,
multiLine: true,
mixedLists: true,
richText: true,
toolbar: {
upload: "both"
Expand Down
14 changes: 11 additions & 3 deletions src/editor/command_dispatcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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)
}
Expand All @@ -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)
}
Expand Down
102 changes: 102 additions & 0 deletions src/editor/contents.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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() {
Expand Down
4 changes: 3 additions & 1 deletion src/editor/selection.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
}
Expand Down
4 changes: 4 additions & 0 deletions src/elements/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
93 changes: 87 additions & 6 deletions src/extensions/format_escape_extension.js
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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(),
Expand All @@ -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)
}
})
}
Expand Down Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion src/helpers/lexical_helper.js
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading