diff --git a/config/jest-unit.config.js b/config/jest-unit.config.js index 4524dfee..5818ce01 100644 --- a/config/jest-unit.config.js +++ b/config/jest-unit.config.js @@ -7,7 +7,10 @@ module.exports = { }, rootDir: "../", testPathIgnorePatterns: ["/node_modules/", String.raw`\.e2e\.test`], - setupFilesAfterEnv: ["/test/matchers.ts"], + setupFilesAfterEnv: [ + "/test/setup.ts", + "/test/matchers.ts", + ], transform: { "^.+\\.ts$": [ "ts-jest", diff --git a/package-lock.json b/package-lock.json index 67b1a0bf..e00fffab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "prosemirror-schema-list": "^1.3.0", "prosemirror-state": "^1.4.3", "prosemirror-transform": "^1.8.0", + "prosemirror-utils": "^1.2.1-0", "prosemirror-view": "^1.33.1" }, "devDependencies": { @@ -10832,6 +10833,15 @@ "prosemirror-model": "^1.0.0" } }, + "node_modules/prosemirror-utils": { + "version": "1.2.1-0", + "resolved": "https://registry.npmjs.org/prosemirror-utils/-/prosemirror-utils-1.2.1-0.tgz", + "integrity": "sha512-YJNjxSAFhV+w7/nKfLU4SJ0sYEHDJuaIvIxp6V1DgrVFSBBksnvHZTGPVmKGYEnbTqr0kG8HXqopNKPoQtT+3A==", + "peerDependencies": { + "prosemirror-model": "^1.19.2", + "prosemirror-state": "^1.4.3" + } + }, "node_modules/prosemirror-view": { "version": "1.33.3", "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.33.3.tgz", @@ -21581,6 +21591,12 @@ "prosemirror-model": "^1.0.0" } }, + "prosemirror-utils": { + "version": "1.2.1-0", + "resolved": "https://registry.npmjs.org/prosemirror-utils/-/prosemirror-utils-1.2.1-0.tgz", + "integrity": "sha512-YJNjxSAFhV+w7/nKfLU4SJ0sYEHDJuaIvIxp6V1DgrVFSBBksnvHZTGPVmKGYEnbTqr0kG8HXqopNKPoQtT+3A==", + "requires": {} + }, "prosemirror-view": { "version": "1.33.3", "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.33.3.tgz", diff --git a/package.json b/package.json index 4f60985d..fcb317c3 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,7 @@ "prosemirror-schema-list": "^1.3.0", "prosemirror-state": "^1.4.3", "prosemirror-transform": "^1.8.0", + "prosemirror-utils": "^1.2.1-0", "prosemirror-view": "^1.33.1" }, "peerDependencies": { diff --git a/src/rich-text/commands/index.ts b/src/rich-text/commands/index.ts index 3f38745d..0c66224b 100644 --- a/src/rich-text/commands/index.ts +++ b/src/rich-text/commands/index.ts @@ -20,6 +20,7 @@ import { insertParagraphIfAtDocEnd } from "./helpers"; import { inTable } from "./tables"; export * from "./tables"; +export * from "./list"; // indent code with four [SPACE] characters (hope you aren't a "tabs" person) const CODE_INDENT_STR = " "; diff --git a/src/rich-text/commands/list.ts b/src/rich-text/commands/list.ts new file mode 100644 index 00000000..000c9999 --- /dev/null +++ b/src/rich-text/commands/list.ts @@ -0,0 +1,123 @@ +import { NodeType, Node } from "prosemirror-model"; +import { Command, EditorState, Transaction } from "prosemirror-state"; +import { canJoin } from "prosemirror-transform"; +import { findParentNode } from "prosemirror-utils"; +import { wrapInList, liftListItem } from "prosemirror-schema-list"; + +/** + * Toggles a list. + * When the provided list type wrapper (e.g. bullet_list) is inactive then wrap the list with + * this type. When it is active then remove the selected line from the list. + * + * @param listType - the list node type + * @param itemType - the list item node type + */ +export function toggleList(listType: NodeType, itemType: NodeType): Command { + return (state: EditorState, dispatch?: (tr: Transaction) => void) => { + const { $from, $to } = state.tr.selection; + const range = $from.blockRange($to); + + if (!range) { + return false; + } + + const parentList = findParentNode((node) => isListType(node.type))( + state.tr.selection + ); + + if (parentList) { + return liftListItem(itemType)(state, dispatch); + } + + return wrapAndMaybeJoinList(listType)(state, dispatch); + }; +} + +/** + * Wraps the selected content in a list and attempts to join the newly wrapped list + * with exisiting list(s) of the same type. + * + * @param nodeType - the list node type + */ +export function wrapAndMaybeJoinList(nodeType: NodeType) { + return function (state: EditorState, dispatch: (tr: Transaction) => void) { + return wrapInList(nodeType)(state, (tr) => { + dispatch?.(tr); + const { tr: newTr } = state.apply(tr); + maybeJoinList(newTr); + dispatch?.(newTr); + }); + }; +} + +/** + * Joins lists when they are of the same type. + * Inspired by https://github.com/remirror/remirror/blob/main/packages/remirror__extension-list/src/list-commands.ts#L535 + * + * @param tr - the transaction + */ +export function maybeJoinList(tr: Transaction): boolean { + const $from = tr.selection.$from; + + let joinable: number[] = []; + let index: number; + let parent: Node; + let before: Node | null | undefined; + let after: Node | null | undefined; + + for (let depth = $from.depth; depth >= 0; depth--) { + parent = $from.node(depth); + + // join backward + index = $from.index(depth); + before = parent.maybeChild(index - 1); + after = parent.maybeChild(index); + + if ( + before && + after && + before.type.name === after.type.name && + isListType(before.type) + ) { + const pos = $from.before(depth + 1); + joinable.push(pos); + } + + // join forward + index = $from.indexAfter(depth); + before = parent.maybeChild(index - 1); + after = parent.maybeChild(index); + + if ( + before && + after && + before.type.name === after.type.name && + isListType(before.type) + ) { + const pos = $from.after(depth + 1); + joinable.push(pos); + } + } + + // sort `joinable` reversely + joinable = [...new Set(joinable)].sort((a, b) => b - a); + let updated = false; + + for (const pos of joinable) { + if (canJoin(tr.doc, pos)) { + tr.join(pos); + updated = true; + } + } + + return updated; +} + +/** + * Checks if the node type is a list type (e.g. "bullet_list", "ordered_list", etc...). + * + * @param type - the node type + */ +export function isListType(type: NodeType) { + return !!type.name.includes("_list"); +} diff --git a/src/rich-text/key-bindings.ts b/src/rich-text/key-bindings.ts index d731d1e4..eda9c81a 100644 --- a/src/rich-text/key-bindings.ts +++ b/src/rich-text/key-bindings.ts @@ -31,6 +31,7 @@ import { unindentCodeBlockLinesCommand, toggleHeadingLevel, toggleTagLinkCommand, + toggleList, } from "./commands"; export function allKeymaps( @@ -74,8 +75,8 @@ export function allKeymaps( "Mod-k": toggleMark(schema.marks.code), "Mod-g": insertRichTextImageCommand, "Ctrl-g": insertRichTextImageCommand, - "Mod-o": wrapIn(schema.nodes.ordered_list), - "Mod-u": wrapIn(schema.nodes.bullet_list), + "Mod-o": toggleList(schema.nodes.ordered_list, schema.nodes.list_item), + "Mod-u": toggleList(schema.nodes.bullet_list, schema.nodes.list_item), "Mod-h": toggleHeadingLevel(), "Mod-r": insertRichTextHorizontalRuleCommand, "Mod-m": setBlockType(schema.nodes.code_block), diff --git a/src/shared/menu/entries.ts b/src/shared/menu/entries.ts index 7afef7b2..03a2a2f5 100644 --- a/src/shared/menu/entries.ts +++ b/src/shared/menu/entries.ts @@ -40,6 +40,7 @@ import { insertRichTextImageCommand, insertRichTextHorizontalRuleCommand, insertRichTextTableCommand, + toggleList, } from "../../rich-text/commands"; import { _t } from "../localization"; import { makeMenuButton, makeMenuDropdown } from "./helpers"; @@ -440,7 +441,10 @@ export const createMenuEntries = ( { key: "toggleOrderedList", richText: { - command: toggleWrapIn(schema.nodes.ordered_list), + command: toggleList( + schema.nodes.ordered_list, + schema.nodes.list_item + ), active: nodeTypeActive(schema.nodes.ordered_list), }, commonmark: orderedListCommand, @@ -455,7 +459,10 @@ export const createMenuEntries = ( { key: "toggleUnorderedList", richText: { - command: toggleWrapIn(schema.nodes.bullet_list), + command: toggleList( + schema.nodes.bullet_list, + schema.nodes.list_item + ), active: nodeTypeActive(schema.nodes.bullet_list), }, commonmark: unorderedListCommand, diff --git a/test/rich-text/commands/list.test.ts b/test/rich-text/commands/list.test.ts new file mode 100644 index 00000000..6f03d80a --- /dev/null +++ b/test/rich-text/commands/list.test.ts @@ -0,0 +1,244 @@ +import { TextSelection } from "prosemirror-state"; +import { applySelection, createState, createView } from "../test-helpers"; +import { + toggleList, + wrapAndMaybeJoinList, + maybeJoinList, + isListType, +} from "../../../src/rich-text/commands"; + +describe("toggleList", () => { + it("should wrap the selected text in a list when it is inactive", () => { + let state = createState( + "

List Item 1

List Item 2

List Item 3

", + [] + ); + const view = createView(state); + + // select all list items + state = applySelection(state, 3, state.doc.nodeSize - 4); + + const command = toggleList( + state.schema.nodes.bullet_list, + state.schema.nodes.list_item + ); + + command(state, view.dispatch.bind(view)); + + expect(view.state.doc).toMatchNodeTree({ + "type.name": "doc", + "childCount": 1, + "content": [ + { + "type.name": "bullet_list", + "childCount": 3, + "content": [ + { + "type.name": "list_item", + "content": [ + { + "type.name": "paragraph", + "content": [ + { + "type.name": "text", + "text": "List Item 1", + }, + ], + }, + ], + }, + { + "type.name": "list_item", + "content": [ + { + "type.name": "paragraph", + "content": [ + { + "type.name": "text", + "text": "List Item 2", + }, + ], + }, + ], + }, + { + "type.name": "list_item", + "content": [ + { + "type.name": "paragraph", + "content": [ + { + "type.name": "text", + "text": "List Item 3", + }, + ], + }, + ], + }, + ], + }, + ], + }); + }); + + it("should remove the selected text from the list when it is active", () => { + let state = createState( + "
  • List Item 1
  • List Item 2
  • List Item 3
", + [] + ); + const view = createView(state); + + // select all list items + state = applySelection(state, 8, state.doc.nodeSize - 10); + + const command = toggleList( + state.schema.nodes.bullet_list, + state.schema.nodes.list_item + ); + + command(state, view.dispatch.bind(view)); + + expect(view.state.doc).toMatchNodeTree({ + "type.name": "doc", + "childCount": 3, + "content": [ + { + "type.name": "paragraph", + "content": [ + { + "type.name": "text", + "text": "List Item 1", + }, + ], + }, + { + "type.name": "paragraph", + "content": [ + { + "type.name": "text", + "text": "List Item 2", + }, + ], + }, + { + "type.name": "paragraph", + "content": [ + { + "type.name": "text", + "text": "List Item 3", + }, + ], + }, + ], + }); + }); +}); + +describe("wrapAndMaybeJoinList", () => { + it("should wrap the selected content in a list and join with existing list(s) of the same type", () => { + let state = createState( + "
  • List Item 1

List Item 2

  • List Item 3
", + [] + ); + const view = createView(state); + + // select List Item 2 + state = applySelection(state, 17, state.doc.nodeSize - 21); + + const command = wrapAndMaybeJoinList(state.schema.nodes.bullet_list); + + command(state, view.dispatch.bind(view)); + + expect(view.state.doc).toMatchNodeTree({ + "type.name": "doc", + "childCount": 1, + "content": [ + { + "type.name": "bullet_list", + "childCount": 3, + "content": [ + { + "type.name": "list_item", + "content": [ + { + "type.name": "paragraph", + "content": [ + { + "type.name": "text", + "text": "List Item 1", + }, + ], + }, + ], + }, + { + "type.name": "list_item", + "content": [ + { + "type.name": "paragraph", + "content": [ + { + "type.name": "text", + "text": "List Item 2", + }, + ], + }, + ], + }, + { + "type.name": "list_item", + "content": [ + { + "type.name": "paragraph", + "content": [ + { + "type.name": "text", + "text": "List Item 3", + }, + ], + }, + ], + }, + ], + }, + ], + }); + }); +}); + +describe("maybeJoinList", () => { + it("should join two lists of the same type", () => { + const state = createState( + "
  • List Item 1
  • List Item 2
", + [] + ); + const tr = state.tr.setSelection(TextSelection.create(state.doc, 3, 3)); + const result = maybeJoinList(tr); + expect(result).toBe(true); + expect(tr.doc.childCount).toBe(1); // The two lists should have been joined into one + }); + + it("should not join two lists of different types", () => { + const state = createState( + "
  • List Item 1
  1. List Item 2
", + [] + ); + const tr = state.tr.setSelection(TextSelection.create(state.doc, 3, 3)); + const result = maybeJoinList(tr); + expect(result).toBe(false); + expect(tr.doc.childCount).toBe(2); // The two lists should not have been joined + }); +}); + +describe("isListType", () => { + it("should return true if the node type is a list type", () => { + const schema = createState("", []).schema; + const bulletList = schema.nodes.bullet_list; + const orderedList = schema.nodes.ordered_list; + const listItem = schema.nodes.list_item; + + expect(isListType(bulletList)).toBe(true); + expect(isListType(orderedList)).toBe(true); + expect(isListType(listItem)).toBe(false); + }); +}); diff --git a/test/rich-text/list.e2e.test.ts b/test/rich-text/list.e2e.test.ts new file mode 100644 index 00000000..bf4c3121 --- /dev/null +++ b/test/rich-text/list.e2e.test.ts @@ -0,0 +1,65 @@ +import { test, expect, Page } from "@playwright/test"; +import { switchMode, editorSelector, clearEditor } from "../e2e-helpers"; + +test.describe("rich-text list", () => { + let page: Page; + test.beforeEach(async ({ browser }) => { + page = await browser.newPage(); + await page.goto("/"); + await switchMode(page, "rich-text"); + await clearEditor(page); + }); + test.afterEach(async () => { + await page.close(); + }); + + test("should toggle the selected text to and from a list when the list button is clicked", async () => { + const editor = page.locator(editorSelector); + + await editor.pressSequentially("List Item 1"); + await editor.press("Enter"); + await editor.pressSequentially("List Item 2"); + await editor.press("Enter"); + await editor.pressSequentially("List Item 3"); + + await editor.selectText(); + + await page.getByLabel("Bulleted list").click(); + + await expect(editor.getByRole("list")).toBeVisible(); + await expect(editor.getByRole("listitem")).toHaveText([ + "List Item 1", + "List Item 2", + "List Item 3", + ]); + + await page.getByLabel("Bulleted list").click(); + + await expect(editor.getByRole("list")).not.toBeVisible(); + }); + + test("should attempt to insert an item into an existing list of the same type when appropriate", async () => { + const editor = page.locator(editorSelector); + + await editor.pressSequentially("- List Item 1"); + await editor.press("Enter"); + await editor.press("Enter"); + await editor.pressSequentially("List Item 2"); + await editor.press("Enter"); + await editor.pressSequentially("- List Item 3"); + + await expect(editor.getByRole("list")).toHaveCount(2); + + // move cursor on the second list item + await editor.getByText("List Item 2").click(); + + await page.getByLabel("Bulleted list").click(); + + await expect(editor.getByRole("list")).toHaveCount(1); + await expect(editor.getByRole("listitem")).toHaveText([ + "List Item 1", + "List Item 2", + "List Item 3", + ]); + }); +}); diff --git a/test/setup.ts b/test/setup.ts new file mode 100644 index 00000000..4f3e694e --- /dev/null +++ b/test/setup.ts @@ -0,0 +1,29 @@ +// JSDOM stub for getBoundingClientRect +// https://github.com/jsdom/jsdom/issues/3002 +document.createRange = () => { + const range = new Range(); + + range.getBoundingClientRect = () => { + return { + x: 0, + y: 0, + bottom: 0, + height: 0, + left: 0, + right: 0, + top: 0, + width: 0, + toJSON: () => {}, + }; + }; + + range.getClientRects = () => { + return { + item: () => null, + length: 0, + *[Symbol.iterator]() {}, + }; + }; + + return range; +};