diff --git a/apps/vscode/CHANGELOG.md b/apps/vscode/CHANGELOG.md index b7feefb2..7fe9b73e 100644 --- a/apps/vscode/CHANGELOG.md +++ b/apps/vscode/CHANGELOG.md @@ -8,8 +8,9 @@ - Fixed cell formatting sometimes deleting code at the end of the cell (). - Removed visual/source toggle button from Positron editor action bar. You can still switch between editing modes using the command palette or context menu (). - Improved checkbox list item appearance in the Visual Editor (). -- Fix columns2 and columns3 snippets leaving `:::` in the document (). -- Add fragment snippet (). +- Fixed columns2 and columns3 snippets leaving `:::` in the document (). +- Added fragment snippet (). +- Fixed a bug where visual editor removed the first nested checkbox (). ## 1.128.0 (Release on 2026-01-08) diff --git a/apps/vscode/src/test/examples/nested-checked-list.qmd b/apps/vscode/src/test/examples/nested-checked-list.qmd new file mode 100644 index 00000000..610e7d3e --- /dev/null +++ b/apps/vscode/src/test/examples/nested-checked-list.qmd @@ -0,0 +1,19 @@ +- + + - [x] hello + + - [x] hello + + - hello + +This is what you get from the Visual Editor when you create a nested list in it: + +-
+ + - [x] hello + + - [x] hello + + - hello + +
\ No newline at end of file diff --git a/apps/vscode/src/test/quartoDoc.test.ts b/apps/vscode/src/test/quartoDoc.test.ts index 9e0c804d..498d835d 100644 --- a/apps/vscode/src/test/quartoDoc.test.ts +++ b/apps/vscode/src/test/quartoDoc.test.ts @@ -55,6 +55,14 @@ suite("Quarto basics", function () { assert.equal(vscode.window.activeTextEditor, editor, 'quarto extension interferes with other files opened in VSCode!'); }); + + test("Roundtrip doesn't change nested-checked-list.qmd", async function () { + const { doc } = await openAndShowTextDocument("nested-checked-list.qmd"); + + const { before, after } = await roundtrip(doc); + + assert.equal(before, after); + }); }); /** diff --git a/packages/editor/src/nodes/list/list-checked.ts b/packages/editor/src/nodes/list/list-checked.ts index d9a2aff2..8c011711 100644 --- a/packages/editor/src/nodes/list/list-checked.ts +++ b/packages/editor/src/nodes/list/list-checked.ts @@ -20,7 +20,7 @@ import { findParentNodeOfType, NodeWithPos, setTextSelection } from 'prosemirror import { InputRule, wrappingInputRule } from 'prosemirror-inputrules'; import { ProsemirrorCommand, EditorCommandId } from '../../api/command'; -import { PandocToken, mapTokens } from '../../api/pandoc'; +import { PandocToken } from '../../api/pandoc'; // custom NodeView that accomodates display / interaction with item check boxes export class CheckedListItemNodeView implements NodeView { @@ -28,7 +28,7 @@ export class CheckedListItemNodeView implements NodeView { public readonly contentDOM: HTMLElement; constructor(node: ProsemirrorNode, view: EditorView, getPos: () => number) { - + // create root li element this.dom = window.document.createElement('li'); if (node.attrs.tight) { @@ -167,7 +167,7 @@ export function checkedListItemInputRule() { } export interface InputRuleWithHandler extends InputRule { - handler: (state: EditorState, match: RegExpMatchArray, start: number, end: number) => Transaction + handler: (state: EditorState, match: RegExpMatchArray, start: number, end: number) => Transaction; } // allow users to begin a new checked list by typing [x] or [ ] at the beginning of a line @@ -212,50 +212,43 @@ export function fragmentWithCheck(schema: Schema, fragment: Fragment, checked: b const kCheckedChar = '☒'; const kUncheckedChar = '☐'; -export function tokensWithChecked(tokens: PandocToken[]): { checked: null | boolean; tokens: PandocToken[] } { - // will set this flag based on inspecting the first Str token - let checked: null | boolean | undefined; - let lastWasChecked = false; - - // map tokens - const mappedTokens = mapTokens(tokens, tok => { - // if the last token was checked then strip the next space - if (tok.t === 'Space' && lastWasChecked) { - lastWasChecked = false; - return { - t: 'Str', - c: '', - }; - } - - // derive 'checked' from first chraracter of first Str token encountered - // if we find checked or unchecked then set the flag and strip off - // the first 2 chraracters (the check and the space after it) - else if (tok.t === 'Str' && checked === undefined) { - let text = tok.c as string; - if (text.charAt(0) === kCheckedChar) { - checked = true; - lastWasChecked = true; - text = text.slice(1); - } else if (text.charAt(0) === kUncheckedChar) { - checked = false; - lastWasChecked = true; - text = text.slice(1); - } else { - checked = null; - } - return { - t: 'Str', - c: text, - }; +/** + * example of `tokens`: + * ```json + * {t:"Para",c:[{t:"Str",c:"☒"},{t:"Space"},{t:"Str",c:"example"}]} + * ``` + * + * this function takes that and returns: + * + * ```json + * { + * checked: true, + * tokens: {t:"Para",c:[{t:"Str",c:""},{t:"Space"},{t:"Str",c:"example"}]}, + * } + * ``` + * + * notice that the `☒` character was removed from the first "Str" node. + */ +export function tokensWithChecked(tokens: PandocToken[]): { checked: null | boolean; tokens: PandocToken[]; } { + const checkNode = tokens[0]?.c?.[0]; + const checkChar = checkNode?.t === 'Str' ? checkNode.c.charAt(0) : ''; + const checked = checkChar === kCheckedChar ? + true : checkChar === kUncheckedChar + ? false : + null; + + const modifiedTokens = structuredClone(tokens); + // if checked, then strip the `☒` or `☐` from the first node and remove the following space (if there is one) + if (checked === true || checked === false) { + if (modifiedTokens[0].c[1].t === 'Space') { + modifiedTokens[0].c.splice(0, 2); // remove the first two tokens } else { - return tok; + modifiedTokens[0].c.splice(0, 1); // remove the first token } - }); + } - // return return { - checked: checked !== undefined ? checked : null, - tokens: mappedTokens, + checked, + tokens: modifiedTokens, }; } diff --git a/packages/editor/src/nodes/list/list-pandoc.ts b/packages/editor/src/nodes/list/list-pandoc.ts index 68b526e3..655c310c 100644 --- a/packages/editor/src/nodes/list/list-pandoc.ts +++ b/packages/editor/src/nodes/list/list-pandoc.ts @@ -86,24 +86,25 @@ export function readPandocList(nodeType: NodeType, capabilities: ListCapabilitie writer.logExampleList(); } } - + const children = getChildren(tok); - const attrs = { ...getAttrs(tok), - tight: children.length && children[0].length && children[0][0].t === 'Plain' - }; + const attrs = { + ...getAttrs(tok), + tight: children.length && children[0].length && children[0][0].t === 'Plain' + }; writer.openNode(nodeType, attrs); children.forEach((child: PandocToken[]) => { // setup tokens/attribs for output let tokens = child; - const childAttrs: { checked: null | boolean } = { checked: null }; + const childAttrs: { checked: null | boolean; } = { checked: null }; // special task list processing if the current format supports task lists if (capabilities.tasks) { // look for checkbox in first character of child tokens // if we see it, remove it and set childAttrs.checked as appropriate - const childWithChecked = tokensWithChecked(child); - childAttrs.checked = childWithChecked.checked; - tokens = childWithChecked.tokens; + const { tokens: modifiedTokens, checked } = tokensWithChecked(child); + childAttrs.checked = checked; + tokens = modifiedTokens; } // process children @@ -165,7 +166,7 @@ function listNodeOptions(node: ProsemirrorNode, capabilities: ListCapabilities): // (allow case of [paragraph,list] which is just a nested list) node.forEach(item => { if (options.tight && item.childCount > 1) { - if (item.childCount > 2 || !isList(item.child(1)) ) { + if (item.childCount > 2 || !isList(item.child(1))) { options.tight = false; } }