Skip to content

Commit 998c820

Browse files
authored
feat(list): enhance rich text list editing experience (#298)
1 parent 1a289c0 commit 998c820

File tree

10 files changed

+495
-5
lines changed

10 files changed

+495
-5
lines changed

config/jest-unit.config.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@ module.exports = {
77
},
88
rootDir: "../",
99
testPathIgnorePatterns: ["/node_modules/", String.raw`\.e2e\.test`],
10-
setupFilesAfterEnv: ["<rootDir>/test/matchers.ts"],
10+
setupFilesAfterEnv: [
11+
"<rootDir>/test/setup.ts",
12+
"<rootDir>/test/matchers.ts",
13+
],
1114
transform: {
1215
"^.+\\.ts$": [
1316
"ts-jest",

package-lock.json

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@
102102
"prosemirror-schema-list": "^1.3.0",
103103
"prosemirror-state": "^1.4.3",
104104
"prosemirror-transform": "^1.8.0",
105+
"prosemirror-utils": "^1.2.1-0",
105106
"prosemirror-view": "^1.33.1"
106107
},
107108
"peerDependencies": {

src/rich-text/commands/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { insertParagraphIfAtDocEnd } from "./helpers";
2020
import { inTable } from "./tables";
2121

2222
export * from "./tables";
23+
export * from "./list";
2324

2425
// indent code with four [SPACE] characters (hope you aren't a "tabs" person)
2526
const CODE_INDENT_STR = " ";

src/rich-text/commands/list.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { NodeType, Node } from "prosemirror-model";
2+
import { Command, EditorState, Transaction } from "prosemirror-state";
3+
import { canJoin } from "prosemirror-transform";
4+
import { findParentNode } from "prosemirror-utils";
5+
import { wrapInList, liftListItem } from "prosemirror-schema-list";
6+
7+
/**
8+
* Toggles a list.
9+
* When the provided list type wrapper (e.g. bullet_list) is inactive then wrap the list with
10+
* this type. When it is active then remove the selected line from the list.
11+
*
12+
* @param listType - the list node type
13+
* @param itemType - the list item node type
14+
*/
15+
export function toggleList(listType: NodeType, itemType: NodeType): Command {
16+
return (state: EditorState, dispatch?: (tr: Transaction) => void) => {
17+
const { $from, $to } = state.tr.selection;
18+
const range = $from.blockRange($to);
19+
20+
if (!range) {
21+
return false;
22+
}
23+
24+
const parentList = findParentNode((node) => isListType(node.type))(
25+
state.tr.selection
26+
);
27+
28+
if (parentList) {
29+
return liftListItem(itemType)(state, dispatch);
30+
}
31+
32+
return wrapAndMaybeJoinList(listType)(state, dispatch);
33+
};
34+
}
35+
36+
/**
37+
* Wraps the selected content in a list and attempts to join the newly wrapped list
38+
* with exisiting list(s) of the same type.
39+
*
40+
* @param nodeType - the list node type
41+
*/
42+
export function wrapAndMaybeJoinList(nodeType: NodeType) {
43+
return function (state: EditorState, dispatch: (tr: Transaction) => void) {
44+
return wrapInList(nodeType)(state, (tr) => {
45+
dispatch?.(tr);
46+
const { tr: newTr } = state.apply(tr);
47+
maybeJoinList(newTr);
48+
dispatch?.(newTr);
49+
});
50+
};
51+
}
52+
53+
/**
54+
* Joins lists when they are of the same type.
55+
* Inspired by https://github.com/remirror/remirror/blob/main/packages/remirror__extension-list/src/list-commands.ts#L535
56+
*
57+
* @param tr - the transaction
58+
*/
59+
export function maybeJoinList(tr: Transaction): boolean {
60+
const $from = tr.selection.$from;
61+
62+
let joinable: number[] = [];
63+
let index: number;
64+
let parent: Node;
65+
let before: Node | null | undefined;
66+
let after: Node | null | undefined;
67+
68+
for (let depth = $from.depth; depth >= 0; depth--) {
69+
parent = $from.node(depth);
70+
71+
// join backward
72+
index = $from.index(depth);
73+
before = parent.maybeChild(index - 1);
74+
after = parent.maybeChild(index);
75+
76+
if (
77+
before &&
78+
after &&
79+
before.type.name === after.type.name &&
80+
isListType(before.type)
81+
) {
82+
const pos = $from.before(depth + 1);
83+
joinable.push(pos);
84+
}
85+
86+
// join forward
87+
index = $from.indexAfter(depth);
88+
before = parent.maybeChild(index - 1);
89+
after = parent.maybeChild(index);
90+
91+
if (
92+
before &&
93+
after &&
94+
before.type.name === after.type.name &&
95+
isListType(before.type)
96+
) {
97+
const pos = $from.after(depth + 1);
98+
joinable.push(pos);
99+
}
100+
}
101+
102+
// sort `joinable` reversely
103+
joinable = [...new Set(joinable)].sort((a, b) => b - a);
104+
let updated = false;
105+
106+
for (const pos of joinable) {
107+
if (canJoin(tr.doc, pos)) {
108+
tr.join(pos);
109+
updated = true;
110+
}
111+
}
112+
113+
return updated;
114+
}
115+
116+
/**
117+
* Checks if the node type is a list type (e.g. "bullet_list", "ordered_list", etc...).
118+
*
119+
* @param type - the node type
120+
*/
121+
export function isListType(type: NodeType) {
122+
return !!type.name.includes("_list");
123+
}

src/rich-text/key-bindings.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
unindentCodeBlockLinesCommand,
3232
toggleHeadingLevel,
3333
toggleTagLinkCommand,
34+
toggleList,
3435
} from "./commands";
3536

3637
export function allKeymaps(
@@ -74,8 +75,8 @@ export function allKeymaps(
7475
"Mod-k": toggleMark(schema.marks.code),
7576
"Mod-g": insertRichTextImageCommand,
7677
"Ctrl-g": insertRichTextImageCommand,
77-
"Mod-o": wrapIn(schema.nodes.ordered_list),
78-
"Mod-u": wrapIn(schema.nodes.bullet_list),
78+
"Mod-o": toggleList(schema.nodes.ordered_list, schema.nodes.list_item),
79+
"Mod-u": toggleList(schema.nodes.bullet_list, schema.nodes.list_item),
7980
"Mod-h": toggleHeadingLevel(),
8081
"Mod-r": insertRichTextHorizontalRuleCommand,
8182
"Mod-m": setBlockType(schema.nodes.code_block),

src/shared/menu/entries.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
insertRichTextImageCommand,
4141
insertRichTextHorizontalRuleCommand,
4242
insertRichTextTableCommand,
43+
toggleList,
4344
} from "../../rich-text/commands";
4445
import { _t } from "../localization";
4546
import { makeMenuButton, makeMenuDropdown } from "./helpers";
@@ -440,7 +441,10 @@ export const createMenuEntries = (
440441
{
441442
key: "toggleOrderedList",
442443
richText: {
443-
command: toggleWrapIn(schema.nodes.ordered_list),
444+
command: toggleList(
445+
schema.nodes.ordered_list,
446+
schema.nodes.list_item
447+
),
444448
active: nodeTypeActive(schema.nodes.ordered_list),
445449
},
446450
commonmark: orderedListCommand,
@@ -455,7 +459,10 @@ export const createMenuEntries = (
455459
{
456460
key: "toggleUnorderedList",
457461
richText: {
458-
command: toggleWrapIn(schema.nodes.bullet_list),
462+
command: toggleList(
463+
schema.nodes.bullet_list,
464+
schema.nodes.list_item
465+
),
459466
active: nodeTypeActive(schema.nodes.bullet_list),
460467
},
461468
commonmark: unorderedListCommand,

0 commit comments

Comments
 (0)