Skip to content

Commit 9858fd2

Browse files
committed
feat(Checkbox): preserve newlines between checkboxes
1 parent fa94c5b commit 9858fd2

6 files changed

Lines changed: 163 additions & 8 deletions

File tree

src/extensions/yfm/Checkbox/Checkbox.test.ts

Lines changed: 100 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import {builders} from 'prosemirror-test-builder';
2+
import dd from 'ts-dedent';
3+
4+
import {ExtensionsManager} from '#core';
5+
import {BaseNode, BaseSchemaSpecs} from 'src/extensions/base/specs';
6+
import {BoldSpecs, BreakNodeName, BreaksSpecs, boldMarkName} from 'src/extensions/markdown/specs';
27

38
import {parseDOM} from '../../../../tests/parse-dom';
49
import {createMarkupChecker} from '../../../../tests/sameMarkup';
5-
import {ExtensionsManager} from '../../../core';
6-
import {BaseNode, BaseSchemaSpecs} from '../../base/specs';
7-
import {BoldSpecs, boldMarkName} from '../../markdown/specs';
810

911
import {CheckboxAttr, CheckboxNode, CheckboxSpecs} from './CheckboxSpecs';
1012
import {fixPastePlugin} from './plugins/fix-paste';
@@ -192,4 +194,99 @@ describe('Checkbox extension', () => {
192194
[fixPastePlugin()],
193195
);
194196
});
197+
198+
describe('allow multiline', () => {
199+
const {
200+
schema,
201+
markupParser: parser,
202+
serializer,
203+
} = new ExtensionsManager({
204+
extensions: (builder) =>
205+
builder.use(BaseSchemaSpecs, {}).use(BreaksSpecs, {}).use(CheckboxSpecs, {
206+
multiline: true,
207+
checkboxLabelPlaceholder: 'checklist',
208+
}),
209+
}).buildDeps();
210+
211+
const {doc, p, soft, chBox, chInput, chLabel} = builders<
212+
'doc' | 'p' | 'soft' | 'chBox' | 'chInput' | 'chLabel'
213+
>(schema, {
214+
doc: {nodeType: BaseNode.Doc},
215+
p: {nodeType: BaseNode.Paragraph},
216+
soft: {nodeType: BreakNodeName.SoftBreak},
217+
chBox: {nodeType: CheckboxNode.Checkbox},
218+
chInput: {nodeType: CheckboxNode.Input},
219+
chLabel: {nodeType: CheckboxNode.Label},
220+
});
221+
222+
const checker = createMarkupChecker({parser, serializer});
223+
224+
it('should parse multiline checkbox', () => {
225+
checker.same(
226+
dd`
227+
[ ] long
228+
long
229+
checkbox
230+
`,
231+
doc(
232+
chBox(
233+
chInput({[CheckboxAttr.Id]: 'yfm-editor-checkbox0'}),
234+
chLabel(
235+
{[CheckboxAttr.For]: 'yfm-editor-checkbox0'},
236+
'long',
237+
soft(),
238+
'long',
239+
soft(),
240+
'checkbox',
241+
),
242+
),
243+
),
244+
);
245+
});
246+
247+
it('should remember tight checkboxes', () => {
248+
checker.same(
249+
dd`
250+
[ ] one
251+
[ ] two
252+
253+
[X] three
254+
3
255+
[X] four
256+
4
257+
258+
five
259+
`,
260+
doc(
261+
chBox(
262+
{[CheckboxAttr.Tight]: true},
263+
chInput({[CheckboxAttr.Id]: 'yfm-editor-checkbox1'}),
264+
chLabel({[CheckboxAttr.For]: 'yfm-editor-checkbox1'}, 'one'),
265+
),
266+
chBox(
267+
{[CheckboxAttr.Tight]: false},
268+
chInput({[CheckboxAttr.Id]: 'yfm-editor-checkbox2'}),
269+
chLabel({[CheckboxAttr.For]: 'yfm-editor-checkbox2'}, 'two'),
270+
),
271+
chBox(
272+
{[CheckboxAttr.Tight]: true},
273+
chInput({
274+
[CheckboxAttr.Checked]: 'true',
275+
[CheckboxAttr.Id]: 'yfm-editor-checkbox3',
276+
}),
277+
chLabel({[CheckboxAttr.For]: 'yfm-editor-checkbox3'}, 'three', soft(), '3'),
278+
),
279+
chBox(
280+
{[CheckboxAttr.Tight]: null},
281+
chInput({
282+
[CheckboxAttr.Checked]: 'true',
283+
[CheckboxAttr.Id]: 'yfm-editor-checkbox4',
284+
}),
285+
chLabel({[CheckboxAttr.For]: 'yfm-editor-checkbox4'}, 'four', soft(), '4'),
286+
),
287+
p('five'),
288+
),
289+
);
290+
});
291+
});
195292
});

src/extensions/yfm/Checkbox/CheckboxSpecs/const.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export const CheckboxAttr = {
1414
Checked: 'checked',
1515
For: 'for',
1616
Line: 'data-line',
17+
Tight: 'data-tight',
1718
} as const;
1819

1920
export const idPrefix = 'yfm-editor-checkbox';
@@ -24,3 +25,6 @@ export const b = cn('checkbox');
2425
export const checkboxType = nodeTypeFactory(CheckboxNode.Checkbox);
2526
export const checkboxLabelType = nodeTypeFactory(CheckboxNode.Label);
2627
export const checkboxInputType = nodeTypeFactory(CheckboxNode.Input);
28+
29+
export const CHECKBOX_OPEN_TOKEN = 'checkbox_open';
30+
export const CHECKBOX_CLOSE_TOKEN = 'checkbox_close';
Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,50 @@
1-
import type {ParserToken} from '../../../../core';
2-
import {CheckboxNode} from '../const';
1+
import type Token from 'markdown-it/lib/token';
2+
3+
import type {ParserToken} from '#core';
4+
5+
import {CheckboxAttr, CheckboxNode} from '../const';
6+
7+
import {CHECKBOX_CLOSE_TOKEN, CHECKBOX_OPEN_TOKEN} from './const';
8+
9+
const getCheckboxAttrs: ParserToken['getAttrs'] = (token, tokens, index) => {
10+
const tight = checkboxIsTight(tokens, index);
11+
const attrs = token.attrs ? Object.fromEntries(token.attrs) : {};
12+
13+
return {...attrs, [CheckboxAttr.Tight]: tight};
14+
};
315

416
const getAttrs: ParserToken['getAttrs'] = (tok) => (tok.attrs ? Object.fromEntries(tok.attrs) : {});
517

618
export const parserTokens: Record<CheckboxNode, ParserToken> = {
7-
[CheckboxNode.Checkbox]: {name: CheckboxNode.Checkbox, type: 'block', getAttrs},
19+
[CheckboxNode.Checkbox]: {
20+
name: CheckboxNode.Checkbox,
21+
type: 'block',
22+
getAttrs: getCheckboxAttrs,
23+
},
824

925
[CheckboxNode.Input]: {name: CheckboxNode.Input, type: 'node', getAttrs},
1026

1127
[CheckboxNode.Label]: {name: CheckboxNode.Label, type: 'block', getAttrs},
1228
};
29+
30+
function checkboxIsTight(tokens: Token[], index: number): boolean | null {
31+
let closeTokenEndLine: number | undefined;
32+
let nextOpenTokenStartLine: number | undefined;
33+
34+
for (let i = index + 1; i < tokens.length; i++) {
35+
if (tokens[i].type === CHECKBOX_CLOSE_TOKEN) {
36+
closeTokenEndLine = tokens[i].map?.[1];
37+
38+
if (tokens[i + 1]?.type === CHECKBOX_OPEN_TOKEN) {
39+
nextOpenTokenStartLine = tokens[i + 1].map?.[0];
40+
}
41+
42+
break;
43+
}
44+
}
45+
46+
if (!Number.isFinite(closeTokenEndLine) || !Number.isFinite(nextOpenTokenStartLine))
47+
return null;
48+
49+
return closeTokenEndLine === nextOpenTokenStartLine;
50+
}

src/extensions/yfm/Checkbox/CheckboxSpecs/schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export const getSchemaSpecs = (
2626
attrs: {
2727
[CheckboxAttr.Class]: {default: b(null, 'checkbox')},
2828
[CheckboxAttr.Line]: {default: null},
29+
...(opts?.multiline ? {[CheckboxAttr.Tight]: {default: null}} : undefined),
2930
},
3031
parseDOM: [
3132
{

src/extensions/yfm/Checkbox/CheckboxSpecs/serializer.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,23 @@ import {getPlaceholderContent} from '../../../../utils/placeholder';
44
import {CheckboxAttr, CheckboxNode} from './const';
55

66
export const serializerTokens: Record<CheckboxNode, SerializerNodeToken> = {
7-
[CheckboxNode.Checkbox]: (state, node) => {
7+
[CheckboxNode.Checkbox]: (state, node, parent, index) => {
88
state.renderInline(node);
9-
state.closeBlock(node);
9+
10+
// TODO [MAJOR]: remove this check after removing `multiline` option
11+
if (!node.type.spec.attrs?.[CheckboxAttr.Tight]) {
12+
state.closeBlock(node);
13+
return;
14+
}
15+
16+
const tight = node.attrs[CheckboxAttr.Tight];
17+
const nextIsCheckbox = parent.maybeChild(index + 1)?.type.name === CheckboxNode.Checkbox;
18+
19+
if (tight === false || !nextIsCheckbox) {
20+
state.closeBlock(node);
21+
} else {
22+
state.ensureNewLine();
23+
}
1024
},
1125

1226
[CheckboxNode.Input]: (state, node) => {

src/extensions/yfm/Checkbox/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export type CheckboxOptions = Pick<CheckboxSpecsOptions, 'checkboxLabelPlacehold
2626
* Available with @diplodoc/transform v4.68.0 or higher.
2727
* @default false
2828
*/
29+
// TODO [MAJOR]: enable by default and remove option
2930
multiline?: boolean;
3031
};
3132

0 commit comments

Comments
 (0)