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
67 changes: 67 additions & 0 deletions src/extensions/yfm/Checkbox/Checkbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const {

const {
doc,
p,
b,
checkbox,
cbInput,
Expand Down Expand Up @@ -125,4 +126,70 @@ describe('Checkbox extension', () => {
[fixPastePlugin()],
);
});

it('should parse dom with multiple checkboxes without id', () => {
parseDOM(
schema,
`
<input type="checkbox">
<label>First checkbox</label>
<input type="checkbox" checked="">
<label>Second checkbox</label>
`,
doc(
checkbox(cbInput(), cbLabel('First checkbox')),
checkbox(cbInput({checked: 'true'}), cbLabel('Second checkbox')),
),
[fixPastePlugin()],
);
});

it('should create empty label when next sibling is not a label', () => {
parseDOM(
schema,
`<input type="checkbox"><span>Not a label</span>`,
doc(checkbox(cbInput(), cbLabel()), p('Not a label')),
[fixPastePlugin()],
);
});

it('should parse multiple checkboxes wrapped in div.checkbox', () => {
parseDOM(
schema,
`
<div class="checkbox">
<input type="checkbox" id="checkbox0">
<label for="checkbox0">Task 1</label>
</div>
<div class="checkbox">
<input type="checkbox" id="checkbox1" checked="true">
<label for="checkbox1">Task 2</label>
</div>`,
doc(
checkbox(cbInput(), cbLabel('Task 1')),
checkbox(cbInput({checked: 'true'}), cbLabel('Task 2')),
),
[fixPastePlugin()],
);
});

it('should parse checkboxes with special characters in id', () => {
parseDOM(
schema,
`
<div class="checkbox">
<input type="checkbox" id="my:invalid[id]">
<label for="my:invalid[id]">Task with invalid ID</label>
</div>
<div class="checkbox">
<input type="checkbox" id="checkbox1" checked="true">
<label for="checkbox1">Task with valid ID</label>
</div>`,
doc(
checkbox(cbInput(), cbLabel('Task with invalid ID')),
checkbox(cbInput({checked: 'true'}), cbLabel('Task with valid ID')),
),
[fixPastePlugin()],
);
});
});
67 changes: 41 additions & 26 deletions src/extensions/yfm/Checkbox/CheckboxSpecs/schema.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {Fragment, type NodeSpec} from 'prosemirror-model';
import {Fragment, type NodeSpec, type Schema} from 'prosemirror-model';

import type {PlaceholderOptions} from '../../../../utils/placeholder';

Expand Down Expand Up @@ -26,36 +26,30 @@ export const getSchemaSpecs = (
tag: 'div.checkbox',
priority: 100,
getContent(node, schema) {
const input = (node as HTMLElement).querySelector<HTMLInputElement>(
'input[type=checkbox]',
);
const label = (node as HTMLElement).querySelector<HTMLLabelElement>(
'label[for]',
);
if (node instanceof HTMLElement) {
const input = node.querySelector<HTMLInputElement>('input[type=checkbox]');

const checked = input?.checked ? 'true' : null;
const text = label?.textContent;

return Fragment.from([
checkboxInputType(schema).create({[CheckboxAttr.Checked]: checked}),
checkboxLabelType(schema).create(null, text ? schema.text(text) : null),
]);
if (input && input instanceof HTMLInputElement) {
const label = findLabelForInput(input);
return createCheckboxFragment(
schema,
input.checked,
label?.textContent,
);
}
}
return Fragment.empty;
},
},
{
tag: 'input[type=checkbox]',
priority: 50,
getContent(node, schema) {
const id = (node as HTMLElement).id;
const checked = (node as HTMLInputElement).checked ? 'true' : null;
const text = node.parentNode?.querySelector<HTMLLabelElement>(
`label[for=${id}]`,
)?.textContent;

return Fragment.from([
checkboxInputType(schema).create({[CheckboxAttr.Checked]: checked}),
checkboxLabelType(schema).create(null, text ? schema.text(text) : null),
]);
getContent(input, schema) {
if (input instanceof HTMLInputElement) {
const label = findLabelForInput(input);
return createCheckboxFragment(schema, input.checked, label?.textContent);
}
return Fragment.empty;
},
},
],
Expand Down Expand Up @@ -94,7 +88,7 @@ export const getSchemaSpecs = (
{
// input handled by checkbox node parse rule
// ignore label
tag: 'input[type=checkbox] ~ label[for]',
tag: 'input[type=checkbox] ~ label',
ignore: true,
consuming: true,
},
Expand All @@ -119,3 +113,24 @@ export const getSchemaSpecs = (
complex: 'leaf',
},
});

// fallback for invalid HTML (input without id + label without for)
function findNextSiblingLabel(element: HTMLInputElement): HTMLLabelElement | null {
const nextSibling = element.nextElementSibling;
return nextSibling instanceof HTMLLabelElement ? nextSibling : null;
}

function findLabelForInput(element: HTMLInputElement): HTMLLabelElement | null {
return element.labels?.[0] || findNextSiblingLabel(element);
}

function createCheckboxFragment(
schema: Schema<any, any>,
checked: boolean | null,
labelText: string | null | undefined,
): Fragment {
return Fragment.from([
checkboxInputType(schema).create({[CheckboxAttr.Checked]: checked ? 'true' : null}),
checkboxLabelType(schema).create(null, labelText ? schema.text(labelText) : null),
]);
}
Loading