Skip to content

Commit 76e01d1

Browse files
committed
feat(text editor): handle list commands correctly
- Move functionality out to separate util files
1 parent 70f116f commit 76e01d1

File tree

7 files changed

+442
-132
lines changed

7 files changed

+442
-132
lines changed

src/components/text-editor/prosemirror-adapter/menu/menu-commands.ts

Lines changed: 123 additions & 131 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,25 @@
1+
/* eslint-disable multiline-ternary */
2+
13
import { toggleMark, setBlockType, wrapIn, lift } from 'prosemirror-commands';
24
import { Schema, MarkType, NodeType, Attrs } from 'prosemirror-model';
3-
import { findWrapping, liftTarget } from 'prosemirror-transform';
4-
import {
5-
Command,
6-
EditorState,
7-
Transaction,
8-
TextSelection,
9-
} from 'prosemirror-state';
5+
import { wrapInList, sinkListItem } from 'prosemirror-schema-list';
6+
import { Command, EditorState, TextSelection } from 'prosemirror-state';
107
import { EditorMenuTypes, EditorTextLink, LevelMapping } from './types';
8+
import {
9+
setActiveMethodForMark,
10+
setActiveMethodForNode,
11+
setActiveMethodForWrap,
12+
} from './utils/active-state-utils';
13+
import {
14+
isInListOfType,
15+
getOtherListType,
16+
removeListNodes,
17+
convertAllListNodes,
18+
toggleList,
19+
} from './utils/list-utils';
20+
import { adjustSelectionToFullBlocks } from './utils/selection-utils';
21+
import { copyPasteLinkCommand } from './utils/link-utils';
22+
import { findAncestorDepthOfType } from './utils/node-utils';
1123

1224
type CommandFunction = (
1325
schema: Schema,
@@ -23,62 +35,6 @@ export interface CommandWithActive extends Command {
2335
active?: (state: EditorState) => boolean;
2436
}
2537

26-
const setActiveMethodForMark = (
27-
command: CommandWithActive,
28-
markType: MarkType,
29-
) => {
30-
command.active = (state) => {
31-
const { from, $from, to, empty } = state.selection;
32-
if (empty) {
33-
return !!markType.isInSet(state.storedMarks || $from.marks());
34-
} else {
35-
return state.doc.rangeHasMark(from, to, markType);
36-
}
37-
};
38-
};
39-
40-
const setActiveMethodForNode = (
41-
command: CommandWithActive,
42-
nodeType: NodeType,
43-
level?: number,
44-
) => {
45-
command.active = (state) => {
46-
const { $from } = state.selection;
47-
const node = $from.node($from.depth);
48-
49-
if (node && node.type.name === nodeType.name) {
50-
if (nodeType.name === LevelMapping.Heading && level) {
51-
return node.attrs.level === level;
52-
}
53-
54-
return true;
55-
}
56-
57-
return false;
58-
};
59-
};
60-
61-
const setActiveMethodForWrap = (
62-
command: CommandWithActive,
63-
nodeType: NodeType,
64-
) => {
65-
command.active = (state) => {
66-
const { from, to } = state.selection;
67-
68-
for (let pos = from; pos <= to; pos++) {
69-
const resolvedPos = state.doc.resolve(pos);
70-
for (let i = resolvedPos.depth; i > 0; i--) {
71-
const node = resolvedPos.node(i);
72-
if (node && node.type.name === nodeType.name) {
73-
return true;
74-
}
75-
}
76-
}
77-
78-
return false;
79-
};
80-
};
81-
8238
const createInsertLinkCommand: CommandFunction = (
8339
schema: Schema,
8440
_: EditorMenuTypes,
@@ -212,16 +168,6 @@ const toggleNodeType = (
212168
};
213169
};
214170

215-
export const isValidUrl = (text: string): boolean => {
216-
try {
217-
new URL(text);
218-
} catch {
219-
return false;
220-
}
221-
222-
return true;
223-
};
224-
225171
const createSetNodeTypeCommand = (
226172
schema: Schema,
227173
nodeType: string,
@@ -269,82 +215,128 @@ const createWrapInCommand = (
269215
return command;
270216
};
271217

272-
const toggleList = (listType) => {
273-
return (state, dispatch) => {
274-
const { $from, $to } = state.selection;
275-
const range = $from.blockRange($to);
218+
/**
219+
* Handles list operations when there is no selection.
220+
*
221+
* @param state - The current editor state.
222+
* @param type - The type of list to toggle.
223+
* @param schema - The ProseMirror schema.
224+
* @param otherType - The other type of list to convert to.
225+
* @param dispatch - The dispatch function.
226+
* @returns A command for handling list operations when there is no selection.
227+
*/
228+
const handleListNoSelection = (state, type, schema, otherType, dispatch) => {
229+
const { $from } = state.selection;
230+
const blockFrom = $from.start();
231+
const blockTo = $from.end();
232+
const adjustedTr = state.tr.setSelection(
233+
new TextSelection(
234+
state.doc.resolve(blockFrom),
235+
state.doc.resolve(blockTo),
236+
),
237+
);
238+
const newState = state.apply(adjustedTr);
276239

277-
if (!range) {
278-
return false;
279-
}
240+
if (isInListOfType(newState, type)) {
241+
return removeListNodes(newState, type, schema, dispatch);
242+
}
280243

281-
const wrapping = range && findWrapping(range, listType);
244+
if (isInListOfType(newState, otherType)) {
245+
return convertAllListNodes(newState, otherType, type, dispatch);
246+
}
282247

283-
if (wrapping) {
284-
// Wrap the selection in a list
285-
if (dispatch) {
286-
dispatch(state.tr.wrap(range, wrapping).scrollIntoView());
287-
}
248+
return toggleList(type)(newState, dispatch);
249+
};
288250

251+
/**
252+
* Handles list operations when there is a selection.
253+
*
254+
* @param state - The current editor state.
255+
* @param type - The type of list to toggle.
256+
* @param schema - The ProseMirror schema.
257+
* @param otherType - The other type of list to convert to.
258+
* @param dispatch - The dispatch function.
259+
* @returns A command for handling list operations when there is a selection.
260+
*/
261+
const handleListWithSelection = (state, type, schema, otherType, dispatch) => {
262+
const { $from } = state.selection;
263+
const listItemType = schema.nodes.list_item;
264+
const ancestorDepth = findAncestorDepthOfType($from, listItemType);
265+
266+
// If an ancestor of type list_item is found, attempt to sink that list_item.
267+
if (ancestorDepth !== null) {
268+
if (sinkListItem(listItemType)(state, dispatch)) {
289269
return true;
290-
} else {
291-
// Check if we are in a list item and lift out of the list
292-
const liftRange = range && liftTarget(range);
293-
if (liftRange !== null) {
294-
if (dispatch) {
295-
dispatch(state.tr.lift(range, liftRange).scrollIntoView());
296-
}
270+
}
271+
}
297272

298-
return true;
299-
}
273+
if (isInListOfType(state, type)) {
274+
return removeListNodes(state, type, schema, dispatch);
275+
}
300276

301-
return false;
302-
}
303-
};
304-
};
277+
if (otherType && isInListOfType(state, otherType)) {
278+
return convertAllListNodes(state, otherType, type, dispatch);
279+
}
305280

306-
const createListCommand = (
307-
schema: Schema,
308-
listType: string,
309-
): CommandWithActive => {
310-
const type: NodeType | undefined = schema.nodes[listType];
311-
if (!type) {
312-
throw new Error(`List type "${listType}" not found in schema`);
281+
const { from, to } = adjustSelectionToFullBlocks(state);
282+
if (from >= to) {
283+
return false;
313284
}
314285

315-
const command: CommandWithActive = toggleList(type);
316-
setActiveMethodForWrap(command, type);
286+
const modifiedTr = state.tr.setSelection(
287+
new TextSelection(state.doc.resolve(from), state.doc.resolve(to)),
288+
);
289+
const updatedState = state.apply(modifiedTr);
317290

318-
return command;
291+
return wrapInList(type)(updatedState, dispatch);
319292
};
320293

321-
const copyPasteLinkCommand: Command = (
322-
state: EditorState,
323-
dispatch: (tr: Transaction) => void,
324-
) => {
325-
const { from, to } = state.selection;
326-
if (from === to) {
327-
return false;
294+
/**
295+
* Creates a command for toggling list types.
296+
*
297+
* @param schema - The ProseMirror schema.
298+
* @param listTypeName - The name of the list type to toggle.
299+
* @returns A command for toggling list types.
300+
*/
301+
export const createListCommand = (schema, listTypeName) => {
302+
const type = schema.nodes[listTypeName];
303+
if (!type) {
304+
throw new Error(`List type "${listTypeName}" not found in schema`);
328305
}
329306

330-
const clipboardData = (window as any).clipboardData;
331-
if (!clipboardData) {
332-
return false;
333-
}
307+
const command = (state, dispatch) => {
308+
const { $from, $to } = state.selection;
309+
const noSelection = $from === $to;
310+
// Get the other list type for the current list type
311+
// This is used to convert all list items to the other list type
312+
// when toggling list types
313+
const otherType = getOtherListType(schema, listTypeName);
314+
315+
return noSelection
316+
? handleListNoSelection(state, type, schema, otherType, dispatch)
317+
: handleListWithSelection(state, type, schema, otherType, dispatch);
318+
};
334319

335-
const copyPastedText = clipboardData.getData('text');
336-
if (!isValidUrl(copyPastedText)) {
337-
return false;
338-
}
320+
command.active = (state) => {
321+
let isActive = false;
322+
state.doc.nodesBetween(
323+
state.selection.from,
324+
state.selection.to,
325+
(node) => {
326+
if (node.type === type) {
327+
isActive = true;
328+
329+
return false;
330+
}
339331

340-
const linkMark = state.schema.marks.link.create({
341-
href: copyPastedText,
342-
target: isExternalLink(copyPastedText) ? '_blank' : null,
343-
});
332+
return true;
333+
},
334+
);
344335

345-
const selectedText = state.doc.textBetween(from, to, ' ');
346-
const newLink = state.schema.text(selectedText, [linkMark]);
347-
dispatch(state.tr.replaceWith(from, to, newLink));
336+
return isActive;
337+
};
338+
339+
return command;
348340
};
349341

350342
const commandMapping: CommandMapping = {
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { MarkType, NodeType } from 'prosemirror-model';
2+
import { LevelMapping } from '../types';
3+
import { CommandWithActive } from '../menu-commands';
4+
5+
export const setActiveMethodForMark = (
6+
command: CommandWithActive,
7+
markType: MarkType,
8+
) => {
9+
command.active = (state) => {
10+
const { from, $from, to, empty } = state.selection;
11+
if (empty) {
12+
return !!markType.isInSet(state.storedMarks || $from.marks());
13+
}
14+
15+
return state.doc.rangeHasMark(from, to, markType);
16+
};
17+
};
18+
19+
export const setActiveMethodForNode = (
20+
command: CommandWithActive,
21+
nodeType: NodeType,
22+
level?: number,
23+
) => {
24+
command.active = (state) => {
25+
const { $from } = state.selection;
26+
const node = $from.node($from.depth);
27+
28+
if (node && node.type.name === nodeType.name) {
29+
if (nodeType.name === LevelMapping.Heading && level) {
30+
return node.attrs.level === level;
31+
}
32+
33+
return true;
34+
}
35+
36+
return false;
37+
};
38+
};
39+
40+
export const setActiveMethodForWrap = (
41+
command: CommandWithActive,
42+
nodeType: NodeType,
43+
) => {
44+
command.active = (state) => {
45+
const { from, to } = state.selection;
46+
for (let pos = from; pos <= to; pos++) {
47+
const resolvedPos = state.doc.resolve(pos);
48+
for (let i = resolvedPos.depth; i > 0; i--) {
49+
const node = resolvedPos.node(i);
50+
if (node && node.type.name === nodeType.name) {
51+
return true;
52+
}
53+
}
54+
}
55+
56+
return false;
57+
};
58+
};

0 commit comments

Comments
 (0)