Skip to content
Merged
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
7 changes: 7 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@
"gulp-sass": "6.0.0",
"gulp-sourcemaps": "3.0.0",
"identity-obj-proxy": "^3.0.0",
"ist": "1.1.7",
"jest": "^29.7.0",
"jest-css-modules": "^2.1.0",
"jest-environment-jsdom": "^29.7.0",
Expand Down
8 changes: 4 additions & 4 deletions src/extensions/markdown/Lists/actions.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import {liftListItem, sinkListItem} from 'prosemirror-schema-list';
import {liftListItem} from 'prosemirror-schema-list';

import type {ActionSpec, ExtensionDeps} from '../../../core';

import {toList} from './commands';
import {sinkOnlySelectedListItem, toList} from './commands';
import {ListNode} from './const';
import {blType, isIntoListOfType, liType, olType} from './utils';

Expand Down Expand Up @@ -34,8 +34,8 @@ export const actions = {

sinkListItem: ({schema}: ExtensionDeps): ActionSpec => {
return {
isEnable: sinkListItem(liType(schema)),
run: sinkListItem(liType(schema)),
isEnable: sinkOnlySelectedListItem(liType(schema)),
run: sinkOnlySelectedListItem(liType(schema)),
};
},
};
235 changes: 235 additions & 0 deletions src/extensions/markdown/Lists/commands.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
// eslint-disable-next-line import/no-extraneous-dependencies
import ist from 'ist';
import type {Node} from 'prosemirror-model';
import {
type Command,
EditorState,
NodeSelection,
Selection,
TextSelection,
} from 'prosemirror-state';
import {doc, eq, li, p, schema, ul} from 'prosemirror-test-builder';

import {sinkOnlySelectedListItem} from 'src/extensions/markdown/Lists/commands';

function selFor(doc: Node) {
const a = (doc as any).tag.a,
b = (doc as any).tag.b;
if (a !== null) {
const $a = doc.resolve(a);
if ($a.parent.inlineContent)
return new TextSelection($a, b !== null ? doc.resolve(b) : undefined);
else return new NodeSelection($a);
}
return Selection.atStart(doc);
}

function apply(doc: Node, command: Command, result: Node | null) {
let state = EditorState.create({doc, selection: selFor(doc)});
// eslint-disable-next-line no-return-assign
command(state, (tr) => (state = state.apply(tr)));
ist(state.doc, result || doc, eq);
// eslint-disable-next-line no-eq-null
if (result && (result as any).tag.a != null) ist(state.selection, selFor(result), eq);
}

describe('sinkOnlySelectedListItem', () => {
const sink = sinkOnlySelectedListItem(schema.nodes.list_item);

it('can wrap a simple item in a list', () =>
apply(
doc(ul(li(p('one')), li(p('t<a><b>wo')), li(p('three')))),
sink,
doc(ul(li(p('one'), ul(li(p('two')))), li(p('three')))),
));

it("won't wrap the first item in a sublist", () =>
apply(doc(ul(li(p('o<a><b>ne')), li(p('two')), li(p('three')))), sink, null));

it("will move an item's content into the item above", () =>
apply(
doc(ul(li(p('one')), li(p('...'), ul(li(p('two')))), li(p('t<a><b>hree')))),
sink,
doc(ul(li(p('one')), li(p('...'), ul(li(p('two')), li(p('three')))))),
));

it('transforms a complex nested list with selection markers', () =>
apply(
doc(
ul(
li(p('aa')),
li(
p('b<a>b'),
ul(
li(p('c<b>c')),
li(p('dd'), ul(li(p('ee')), li(p('ss')))),
li(p('zz')),
li(p('ww')),
),
),
li(p('pp')),
li(p('hh')),
),
),
sink,
doc(
ul(
li(
p('aa'),
ul(
li(p('bb'), ul(li(p('cc')))),
li(p('dd'), ul(li(p('ee')), li(p('ss')))),
li(p('zz')),
li(p('ww')),
),
),
li(p('pp')),
li(p('hh')),
),
),
));

it('sinks a top-level list item with double selection markers into the previous item', () =>
apply(
doc(
ul(
li(p('aa')),
li(
p('b<a><b>b'),
ul(
li(p('cc')),
li(p('dd'), ul(li(p('ee')), li(p('ss')))),
li(p('zz')),
li(p('ww')),
),
),
li(p('pp')),
li(p('hh')),
),
),
sink,
doc(
ul(
li(
p('aa'),
ul(
li(p('bb')),
li(p('cc')),
li(p('dd'), ul(li(p('ee')), li(p('ss')))),
li(p('zz')),
li(p('ww')),
),
),
li(p('pp')),
li(p('hh')),
),
),
));

it('sinks nested list items into a deeper hierarchy when selection spans multiple items', () =>
apply(
doc(
ul(
li(p('aa')),
li(
p('bb'),
ul(
li(p('cc')),
li(p('d<a>d'), ul(li(p('ee')), li(p('ss')))),
li(p('z<b>z')),
li(p('ww')),
),
),
li(p('pp')),
li(p('hh')),
),
),
sink,
doc(
ul(
li(p('aa')),
li(
p('bb'),
ul(
li(p('cc'), ul(li(p('dd'), ul(li(p('ee')), li(p('ss')))), li(p('zz')))),
li(p('ww')),
),
),
li(p('pp')),
li(p('hh')),
),
),
));

// expected result should be the same as
// sinks nested list items into a deeper hierarchy when selection spans multiple items
it('sinks nested list items with an upward staircase selection', () =>
apply(
doc(
ul(
li(p('aa')),
li(
p('bb'),
ul(
li(p('cc')),
li(p('dd'), ul(li(p('ee')), li(p('s<a>s')))),
li(p('z<b>z')),
li(p('ww')),
),
),
li(p('pp')),
li(p('hh')),
),
),
sink,
doc(
ul(
li(p('aa')),
li(
p('bb'),
ul(
li(p('cc'), ul(li(p('dd'), ul(li(p('ee')), li(p('ss')))), li(p('zz')))),
li(p('ww')),
),
),
li(p('pp')),
li(p('hh')),
),
),
));

it('sinks a top-level list item with mixed selection markers from both levels', () =>
apply(
doc(
ul(
li(p('aa')),
li(
p('b<a>b'),
ul(
li(p('cc')),
li(p('dd'), ul(li(p('ee')), li(p('s<b>s')))),
li(p('zz')),
li(p('ww')),
),
),
li(p('pp')),
li(p('hh')),
),
),
sink,
doc(
ul(
li(
p('aa'),
ul(
li(p('bb'), ul(li(p('cc')), li(p('dd'), ul(li(p('ee')), li(p('ss')))))),
li(p('zz')),
li(p('ww')),
),
),
li(p('pp')),
li(p('hh')),
),
),
));
});
99 changes: 97 additions & 2 deletions src/extensions/markdown/Lists/commands.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {NodeType} from 'prosemirror-model';
import {Fragment, type NodeRange, type NodeType, Slice} from 'prosemirror-model';
import {wrapInList} from 'prosemirror-schema-list';
import type {Command} from 'prosemirror-state';
import type {Command, Transaction} from 'prosemirror-state';
import {ReplaceAroundStep, liftTarget} from 'prosemirror-transform';

import {joinPreviousBlock} from '../../../commands/join';

Expand All @@ -23,3 +24,97 @@ export const joinPrevList = joinPreviousBlock({
checkPrevNode: isListNode,
skipNode: isListOrItemNode,
});

/*
Simplified `sinkListItem` from `prosemirror-schema-list` without `state`/`dispatch`,
sinks list items deeper.
*/
const sink = (tr: Transaction, range: NodeRange, itemType: NodeType) => {
const before = tr.mapping.map(range.start);
const after = tr.mapping.map(range.end);
const startIndex = tr.mapping.map(range.startIndex);

const parent = range.parent;
const nodeBefore = parent.child(startIndex - 1);

const nestedBefore = nodeBefore.lastChild && nodeBefore.lastChild.type === parent.type;
const inner = Fragment.from(nestedBefore ? itemType.create() : null);
const slice = new Slice(
Fragment.from(itemType.create(null, Fragment.from(parent.type.create(null, inner)))),
nestedBefore ? 3 : 1,
0,
);

tr.step(
new ReplaceAroundStep(
before - (nestedBefore ? 3 : 1),
after,
before,
after,
slice,
1,
true,
),
);
return true;
};

export function sinkOnlySelectedListItem(itemType: NodeType): Command {
return ({tr, selection}, dispatch) => {
const {$from, $to} = selection;
const selectionRange = $from.blockRange(
$to,
(node) => node.childCount > 0 && node.firstChild!.type === itemType,
);
if (!selectionRange) {
return false;
}

const {startIndex, parent, start, end} = selectionRange;
if (startIndex === 0) {
return false;
}

const nodeBefore = parent.child(startIndex - 1);
if (nodeBefore.type !== itemType) {
return false;
}

if (dispatch) {
// lifts following list items sequentially to prepare correct nesting structure
let currentEnd = end - 1;
while (currentEnd > start) {
const selectionEnd = tr.mapping.map($to.pos);

const $candidateBlockEnd = tr.doc.resolve(currentEnd);
const candidateBlockStartPos = $candidateBlockEnd.before($candidateBlockEnd.depth);
const $candidateBlockStart = tr.doc.resolve(candidateBlockStartPos);
const candidateBlockRange = $candidateBlockStart.blockRange($candidateBlockEnd);

if (candidateBlockRange?.start) {
const $rangeStart = tr.doc.resolve(candidateBlockRange.start);
const shouldLift =
candidateBlockRange.start > selectionEnd && isListNode($rangeStart.parent);

if (shouldLift) {
currentEnd = candidateBlockRange.start;

const targetDepth = liftTarget(candidateBlockRange);
if (targetDepth !== null) {
tr.lift(candidateBlockRange, targetDepth);
}
}
}

currentEnd--;
}

// sinks the selected list item deeper into the list hierarchy
sink(tr, selectionRange, itemType);

dispatch(tr.scrollIntoView());
return true;
}
return false;
};
}
Loading
Loading