From 8731ca4ec17b2e06849b8f93d53ec9c49946f8b1 Mon Sep 17 00:00:00 2001 From: Yuri Karamian Date: Mon, 3 Aug 2026 02:26:39 +0200 Subject: [PATCH] Support mixed nested lists (#39) --- src/lib/components/Editor.svelte | 72 ++++++++++++++++++++++++---- src/lib/editor/mixedLists.ts | 27 +++++++++++ tests/mixed-lists.test.mjs | 80 ++++++++++++++++++++++++++++++++ 3 files changed, 171 insertions(+), 8 deletions(-) create mode 100644 src/lib/editor/mixedLists.ts create mode 100644 tests/mixed-lists.test.mjs diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index 6227666..b2c1e9f 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -48,6 +48,7 @@ import { WrapSelectedText } from '$lib/editor/extensions/wrapSelectedText'; import { calloutGroup, calloutIcon, calloutLabel, CALLOUT_MENU, transformCalloutBlockquotes, serializeCallout } from '$lib/editor/callouts'; import { wrapTextareaSelection } from '$lib/editor/source/selectionPairs'; + import { convertListNode, type MixedListName } from '$lib/editor/mixedLists'; import { relativePath } from '$lib/utils/paths'; import GraphView from './GraphView.svelte'; import TagSuggestInput from './TagSuggestInput.svelte'; @@ -70,6 +71,16 @@ const LARGE_DOC_CHARS = 100_000; let isLargeDoc = $state(false); let editor: Editor | null = null; + const MixedListShortcuts = Extension.create({ + name: 'mixedListShortcuts', + priority: 1000, + addKeyboardShortcuts() { + return { + 'Mod-Shift-8': () => toggleBulletList(), + 'Mod-Shift-9': () => toggleTaskList(), + }; + }, + }); let editorReady = $state(false); let sourceContent = $state(''); let sourceHistory: Array<{ content: string; cursor: number }> = []; @@ -381,9 +392,9 @@ { label: 'Heading 1', aliases: ['h1', 'heading1', 'title'], icon: '', action: () => editor?.chain().focus().toggleHeading({ level: 1 }).run() }, { label: 'Heading 2', aliases: ['h2', 'heading2', 'subtitle'], icon: '', action: () => editor?.chain().focus().toggleHeading({ level: 2 }).run() }, { label: 'Heading 3', aliases: ['h3', 'heading3'], icon: '', action: () => editor?.chain().focus().toggleHeading({ level: 3 }).run() }, - { label: 'Bullet List', aliases: ['ul', 'unordered', 'bullets', 'list'], icon: '', action: () => editor?.chain().focus().toggleBulletList().run() }, + { label: 'Bullet List', aliases: ['ul', 'unordered', 'bullets', 'list'], icon: '', action: toggleBulletList }, { label: 'Numbered List', aliases: ['ol', 'ordered', 'number'], icon: '123', action: () => editor?.chain().focus().toggleOrderedList().run() }, - { label: 'Task List', aliases: ['checklist', 'checkbox', 'todo', 'check'], icon: '', action: () => editor?.chain().focus().toggleTaskList().run() }, + { label: 'Task List', aliases: ['checklist', 'checkbox', 'todo', 'check'], icon: '', action: toggleTaskList }, { label: 'Code Block', aliases: ['code', 'codeblock', 'pre', 'snippet'], icon: '', action: () => editor?.chain().focus().toggleCodeBlock().run() }, { label: 'Secret', aliases: ['secret', 'encrypt', 'password', 'private'], icon: '', action: () => openSecretInsert() }, { label: 'Blockquote', aliases: ['quote', 'blockquote', 'citation'], icon: '', action: () => editor?.chain().focus().toggleBlockquote().run() }, @@ -4334,6 +4345,7 @@ element: editorElement, editable: !$readOnly, extensions: [ + MixedListShortcuts, StarterKit.configure({ codeBlock: false }), Placeholder.configure({ includeChildren: true, @@ -5141,8 +5153,52 @@ closeTextContextMenu(); } + function handleMixedListToggle(targetListName: MixedListName): boolean { + if (!editor) return false; + const { selection, schema } = editor.state; + const { $from: fromResolved, $to: toResolved } = selection; + let listDepth = -1; + + for (let depth = fromResolved.depth; depth > 0; depth--) { + const nodeName = fromResolved.node(depth).type.name; + // An ordered list cannot contain task items. Keep it ordered instead of lifting its item. + if (nodeName === 'orderedList') return targetListName === 'taskList'; + if (nodeName === 'bulletList' || nodeName === 'taskList') { + listDepth = depth; + break; + } + } + + if (listDepth < 0 || toResolved.depth < listDepth) return false; + const listNode = fromResolved.node(listDepth); + if (listNode.type.name === targetListName || toResolved.node(listDepth) !== listNode) return false; + + const convertedList = convertListNode(schema, listNode, targetListName); + if (!convertedList) return false; + const listPos = fromResolved.before(listDepth); + const transaction = editor.state.tr.replaceWith( + listPos, + listPos + listNode.nodeSize, + convertedList + ); + transaction.setSelection(TextSelection.create(transaction.doc, selection.from, selection.to)); + editor.view.dispatch(transaction.scrollIntoView()); + editor.view.focus(); + return true; + } + + function toggleBulletList(): boolean { + if (!editor) return false; + return handleMixedListToggle('bulletList') || editor.chain().focus().toggleBulletList().run(); + } + + function toggleTaskList(): boolean { + if (!editor) return false; + return handleMixedListToggle('taskList') || editor.chain().focus().toggleTaskList().run(); + } + function ctxBulletList() { - editor?.chain().focus().toggleBulletList().run(); + toggleBulletList(); closeTextContextMenu(); } @@ -5152,7 +5208,7 @@ } function ctxTaskList() { - editor?.chain().focus().toggleTaskList().run(); + toggleTaskList(); closeTextContextMenu(); } @@ -6551,13 +6607,13 @@
- - @@ -6741,13 +6797,13 @@
- - diff --git a/src/lib/editor/mixedLists.ts b/src/lib/editor/mixedLists.ts new file mode 100644 index 0000000..95e4dc7 --- /dev/null +++ b/src/lib/editor/mixedLists.ts @@ -0,0 +1,27 @@ +import type { Node as ProseMirrorNode, Schema } from '@tiptap/pm/model'; + +export type MixedListName = 'bulletList' | 'taskList'; + +export function convertListNode( + schema: Schema, + listNode: ProseMirrorNode, + targetListName: MixedListName, +): ProseMirrorNode | null { + if ( + (listNode.type.name !== 'bulletList' && listNode.type.name !== 'taskList') || + listNode.type.name === targetListName + ) { + return null; + } + + const targetListType = schema.nodes[targetListName]; + const targetItemType = schema.nodes[targetListName === 'taskList' ? 'taskItem' : 'listItem']; + if (!targetListType || !targetItemType) return null; + + const itemAttrs = targetListName === 'taskList' ? { checked: false } : null; + const convertedItems = listNode.content.content.map((item) => + targetItemType.create(itemAttrs, item.content, item.marks) + ); + + return targetListType.create(null, convertedItems, listNode.marks); +} diff --git a/tests/mixed-lists.test.mjs b/tests/mixed-lists.test.mjs new file mode 100644 index 0000000..eb071af --- /dev/null +++ b/tests/mixed-lists.test.mjs @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; +import { Schema } from '@tiptap/pm/model'; +import { transformWithEsbuild } from 'vite'; + +const source = await readFile( + new URL('../src/lib/editor/mixedLists.ts', import.meta.url), + 'utf8' +); +const { code } = await transformWithEsbuild(source, 'mixedLists.ts', { + loader: 'ts', + format: 'esm', + target: 'esnext' +}); +const { convertListNode } = await import( + `data:text/javascript;base64,${Buffer.from(code).toString('base64')}` +); + +const schema = new Schema({ + nodes: { + doc: { content: 'block+' }, + paragraph: { content: 'text*', group: 'block' }, + text: { group: 'inline' }, + bulletList: { content: 'listItem+', group: 'block' }, + listItem: { content: 'paragraph block*' }, + taskList: { content: 'taskItem+', group: 'block' }, + taskItem: { + attrs: { checked: { default: false } }, + content: 'paragraph block*' + } + } +}); + +function paragraph(text) { + return schema.node('paragraph', null, [schema.text(text)]); +} + +test('converts every task item in a list to a bullet item', () => { + const sourceList = schema.node('taskList', null, [ + schema.node('taskItem', { checked: true }, [paragraph('First')]), + schema.node('taskItem', { checked: false }, [paragraph('Second')]) + ]); + + const converted = convertListNode(schema, sourceList, 'bulletList'); + + assert.equal(converted.type.name, 'bulletList'); + assert.deepEqual( + converted.content.content.map((item) => item.type.name), + ['listItem', 'listItem'] + ); + assert.equal(converted.textContent, 'FirstSecond'); + assert.equal(sourceList.type.name, 'taskList'); +}); + +test('converts bullet items to unchecked tasks without dropping nested blocks', () => { + const nestedBullets = schema.node('bulletList', null, [ + schema.node('listItem', null, [paragraph('Nested note')]) + ]); + const sourceList = schema.node('bulletList', null, [ + schema.node('listItem', null, [paragraph('Parent'), nestedBullets]) + ]); + + const converted = convertListNode(schema, sourceList, 'taskList'); + const taskItem = converted.firstChild; + + assert.equal(converted.type.name, 'taskList'); + assert.equal(taskItem.type.name, 'taskItem'); + assert.equal(taskItem.attrs.checked, false); + assert.equal(taskItem.lastChild.type.name, 'bulletList'); + assert.equal(taskItem.textContent, 'ParentNested note'); +}); + +test('leaves a list alone when it already has the requested type', () => { + const bullets = schema.node('bulletList', null, [ + schema.node('listItem', null, [paragraph('Unchanged')]) + ]); + + assert.equal(convertListNode(schema, bullets, 'bulletList'), null); +});