👁️
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

support nested lists

+235 -23
+9 -1
lexicons/com/deckbelcher/richtext.json
··· 147 147 "ref": "com.deckbelcher.richtext.facet" 148 148 }, 149 149 "description": "Annotations of text (formatting, mentions, links, etc)." 150 + }, 151 + "sublist": { 152 + "type": "union", 153 + "refs": [ 154 + "#bulletListBlock", 155 + "#orderedListBlock" 156 + ], 157 + "description": "Optional nested sublist (bullet or ordered)." 150 158 } 151 159 }, 152 - "description": "A single list item with text and optional facets." 160 + "description": "A single list item with text, optional facets, and optional sublist." 153 161 }, 154 162 "orderedListBlock": { 155 163 "type": "object",
+23 -2
src/components/richtext/RichtextRenderer.tsx
··· 86 86 87 87 case "com.deckbelcher.richtext#bulletListBlock": 88 88 return ( 89 - <ul className="list-disc pl-6 my-2 space-y-1"> 89 + <ul className="list-disc pl-6 my-2 space-y-1 [&_ul]:list-[circle] [&_ul_ul]:list-[square]"> 90 90 {block.items.map((item, i) => ( 91 91 // biome-ignore lint/suspicious/noArrayIndexKey: doc is immutable 92 92 <ListItemRenderer key={i} item={item} /> ··· 97 97 case "com.deckbelcher.richtext#orderedListBlock": 98 98 return ( 99 99 <ol 100 - className="list-decimal pl-6 my-2 space-y-1" 100 + className="list-decimal pl-6 my-2 space-y-1 [&_ol]:list-[lower-alpha] [&_ol_ol]:list-[lower-roman]" 101 101 start={block.start ?? 1} 102 102 > 103 103 {block.items.map((item, i) => ( ··· 132 132 item: ListItem; 133 133 }): ReactNode { 134 134 const isEmpty = !item.text?.trim(); 135 + const sublistType = (item.sublist as { $type?: string } | undefined)?.$type; 136 + 135 137 return ( 136 138 <li> 137 139 {isEmpty ? ( 138 140 <br /> 139 141 ) : ( 140 142 <TextWithFacets text={item.text} facets={item.facets} /> 143 + )} 144 + {sublistType === "com.deckbelcher.richtext#bulletListBlock" && ( 145 + <ul className="list-disc pl-6 mt-1 space-y-1"> 146 + {(item.sublist as BulletListBlock).items.map((subItem, i) => ( 147 + // biome-ignore lint/suspicious/noArrayIndexKey: doc is immutable 148 + <ListItemRenderer key={i} item={subItem} /> 149 + ))} 150 + </ul> 151 + )} 152 + {sublistType === "com.deckbelcher.richtext#orderedListBlock" && ( 153 + <ol 154 + className="list-decimal pl-6 mt-1 space-y-1" 155 + start={(item.sublist as OrderedListBlock).start ?? 1} 156 + > 157 + {(item.sublist as OrderedListBlock).items.map((subItem, i) => ( 158 + // biome-ignore lint/suspicious/noArrayIndexKey: doc is immutable 159 + <ListItemRenderer key={i} item={subItem} /> 160 + ))} 161 + </ol> 141 162 )} 142 163 </li> 143 164 );
+134 -4
src/lib/__tests__/richtext-convert.test.ts
··· 326 326 }); 327 327 }); 328 328 329 + describe("nested lists", () => { 330 + it("converts bullet list with nested bullet list", () => { 331 + const doc = schema.node("doc", null, [ 332 + schema.node("bullet_list", null, [ 333 + schema.node("list_item", null, [ 334 + schema.node("paragraph", null, [schema.text("Parent item")]), 335 + schema.node("bullet_list", null, [ 336 + schema.node("list_item", null, [ 337 + schema.node("paragraph", null, [schema.text("Nested item 1")]), 338 + ]), 339 + schema.node("list_item", null, [ 340 + schema.node("paragraph", null, [schema.text("Nested item 2")]), 341 + ]), 342 + ]), 343 + ]), 344 + ]), 345 + ]); 346 + const result = treeToLexicon(doc); 347 + const block = result.content[0] as BulletListBlock; 348 + 349 + expect(block.items).toHaveLength(1); 350 + expect(block.items[0].text).toBe("Parent item"); 351 + expect(block.items[0].sublist).toBeDefined(); 352 + expect(block.items[0].sublist?.$type).toBe( 353 + "com.deckbelcher.richtext#bulletListBlock", 354 + ); 355 + const sublist = block.items[0].sublist as BulletListBlock; 356 + expect(sublist.items).toHaveLength(2); 357 + expect(sublist.items[0].text).toBe("Nested item 1"); 358 + expect(sublist.items[1].text).toBe("Nested item 2"); 359 + }); 360 + 361 + it("converts ordered list with nested ordered list", () => { 362 + const doc = schema.node("doc", null, [ 363 + schema.node("ordered_list", { order: 1 }, [ 364 + schema.node("list_item", null, [ 365 + schema.node("paragraph", null, [schema.text("Step 1")]), 366 + schema.node("ordered_list", { order: 1 }, [ 367 + schema.node("list_item", null, [ 368 + schema.node("paragraph", null, [schema.text("Step 1.a")]), 369 + ]), 370 + ]), 371 + ]), 372 + schema.node("list_item", null, [ 373 + schema.node("paragraph", null, [schema.text("Step 2")]), 374 + ]), 375 + ]), 376 + ]); 377 + const result = treeToLexicon(doc); 378 + const block = result.content[0] as OrderedListBlock; 379 + 380 + expect(block.items).toHaveLength(2); 381 + expect(block.items[0].text).toBe("Step 1"); 382 + expect(block.items[0].sublist?.$type).toBe( 383 + "com.deckbelcher.richtext#orderedListBlock", 384 + ); 385 + expect(block.items[1].text).toBe("Step 2"); 386 + expect(block.items[1].sublist).toBeUndefined(); 387 + }); 388 + 389 + it("converts mixed nested lists (bullet in ordered)", () => { 390 + const doc = schema.node("doc", null, [ 391 + schema.node("ordered_list", { order: 1 }, [ 392 + schema.node("list_item", null, [ 393 + schema.node("paragraph", null, [schema.text("Main point")]), 394 + schema.node("bullet_list", null, [ 395 + schema.node("list_item", null, [ 396 + schema.node("paragraph", null, [schema.text("Sub-bullet")]), 397 + ]), 398 + ]), 399 + ]), 400 + ]), 401 + ]); 402 + const result = treeToLexicon(doc); 403 + const block = result.content[0] as OrderedListBlock; 404 + 405 + expect(block.items[0].sublist?.$type).toBe( 406 + "com.deckbelcher.richtext#bulletListBlock", 407 + ); 408 + }); 409 + 410 + it("roundtrips nested list structure", () => { 411 + const doc = schema.node("doc", null, [ 412 + schema.node("bullet_list", null, [ 413 + schema.node("list_item", null, [ 414 + schema.node("paragraph", null, [schema.text("Parent")]), 415 + schema.node("bullet_list", null, [ 416 + schema.node("list_item", null, [ 417 + schema.node("paragraph", null, [schema.text("Child")]), 418 + ]), 419 + ]), 420 + ]), 421 + ]), 422 + ]); 423 + const lexicon = treeToLexicon(doc); 424 + const result = lexiconToTree(lexicon); 425 + 426 + expect(result.eq(doc)).toBe(true); 427 + }); 428 + }); 429 + 329 430 describe("horizontal rules", () => { 330 431 it("converts horizontal rule", () => { 331 432 const doc = schema.node("doc", null, [schema.node("horizontal_rule")]); ··· 1501 1602 ), 1502 1603 ); 1503 1604 1504 - // Arbitrary for a list item 1505 - const arbListItem = arbParagraphContent.map((content) => 1605 + // Arbitrary for a flat list item (no nesting) 1606 + const arbFlatListItem = arbParagraphContent.map((content) => 1506 1607 schema.node("list_item", null, [schema.node("paragraph", null, content)]), 1507 1608 ); 1508 1609 1509 - // Arbitrary for bullet list (1-4 items) 1610 + // Arbitrary for flat bullet list (no nesting) 1611 + const arbFlatBulletList = fc 1612 + .array(arbFlatListItem, { minLength: 1, maxLength: 3 }) 1613 + .map((items) => schema.node("bullet_list", null, items)); 1614 + 1615 + // Arbitrary for flat ordered list (no nesting) 1616 + const arbFlatOrderedList = fc 1617 + .tuple( 1618 + fc.array(arbFlatListItem, { minLength: 1, maxLength: 3 }), 1619 + fc.integer({ min: 1, max: 10 }), 1620 + ) 1621 + .map(([items, start]) => 1622 + schema.node("ordered_list", { order: start }, items), 1623 + ); 1624 + 1625 + // Arbitrary for a nested sublist (bullet or ordered, flat) 1626 + const arbSublist = fc.oneof(arbFlatBulletList, arbFlatOrderedList); 1627 + 1628 + // Arbitrary for a list item with optional nested sublist 1629 + const arbListItem = fc 1630 + .tuple(arbParagraphContent, fc.option(arbSublist, { nil: undefined })) 1631 + .map(([content, sublist]) => { 1632 + const children = [schema.node("paragraph", null, content)]; 1633 + if (sublist) { 1634 + children.push(sublist); 1635 + } 1636 + return schema.node("list_item", null, children); 1637 + }); 1638 + 1639 + // Arbitrary for bullet list (1-4 items, may have nesting) 1510 1640 const arbBulletList = fc 1511 1641 .array(arbListItem, { minLength: 1, maxLength: 4 }) 1512 1642 .map((items) => schema.node("bullet_list", null, items)); 1513 1643 1514 - // Arbitrary for ordered list with optional start number 1644 + // Arbitrary for ordered list with optional start number (may have nesting) 1515 1645 const arbOrderedList = fc 1516 1646 .tuple( 1517 1647 fc.array(arbListItem, { minLength: 1, maxLength: 4 }),
+8
src/lib/lexicons/types/com/deckbelcher/richtext.ts
··· 104 104 ); 105 105 }, 106 106 /** 107 + * Optional nested sublist (bullet or ordered). 108 + */ 109 + get sublist() { 110 + return /*#__PURE__*/ v.optional( 111 + /*#__PURE__*/ v.variant([bulletListBlockSchema, orderedListBlockSchema]), 112 + ); 113 + }, 114 + /** 107 115 * The plain text content (no markdown symbols). 108 116 * @maxLength 100000 109 117 * @maxGraphemes 10000
+57 -15
src/lib/richtext-convert.ts
··· 142 142 143 143 /** 144 144 * Convert a list_item node to lexicon format. 145 - * Extracts text from the first paragraph child. 145 + * Extracts text from the first paragraph child and any nested list. 146 146 */ 147 147 function listItemToLexicon(node: ProseMirrorNode): Typed<ListItem> { 148 - const firstParagraph = node.firstChild; 148 + const children = childrenOf(node); 149 + const firstParagraph = children[0]; 150 + 151 + let text: string | undefined; 152 + let facets: Facet[] | undefined; 153 + 149 154 if (firstParagraph?.type.name === "paragraph") { 150 - const { text, facets } = extractTextAndFacets(firstParagraph); 151 - return { 152 - $type: "com.deckbelcher.richtext#listItem", 153 - text: text || undefined, 154 - facets: facets.length > 0 ? facets : undefined, 155 - }; 155 + const extracted = extractTextAndFacets(firstParagraph); 156 + text = extracted.text || undefined; 157 + facets = extracted.facets.length > 0 ? extracted.facets : undefined; 156 158 } 157 - return { $type: "com.deckbelcher.richtext#listItem" }; 159 + 160 + // Look for nested list after the first paragraph 161 + let sublist: Typed<BulletListBlock> | Typed<OrderedListBlock> | undefined; 162 + for (let i = 1; i < children.length; i++) { 163 + const child = children[i]; 164 + if (child.type.name === "bullet_list") { 165 + sublist = bulletListToLexicon(child); 166 + break; 167 + } 168 + if (child.type.name === "ordered_list") { 169 + sublist = orderedListToLexicon(child); 170 + break; 171 + } 172 + } 173 + 174 + return { 175 + $type: "com.deckbelcher.richtext#listItem", 176 + text, 177 + facets, 178 + sublist, 179 + }; 158 180 } 159 181 160 182 /** ··· 381 403 382 404 function lexiconListItemToTree(item: ListItem): ProseMirrorNode { 383 405 const text = item.text || ""; 384 - if (!text) { 385 - return schema.node("list_item", null, [schema.node("paragraph")]); 406 + const content: ProseMirrorNode[] = []; 407 + 408 + // First paragraph (required by schema) 409 + if (text) { 410 + const nodes = textAndFacetsToNodes(text, item.facets || []); 411 + content.push( 412 + schema.node("paragraph", null, nodes.length > 0 ? nodes : undefined), 413 + ); 414 + } else { 415 + content.push(schema.node("paragraph")); 386 416 } 387 - const nodes = textAndFacetsToNodes(text, item.facets || []); 388 - return schema.node("list_item", null, [ 389 - schema.node("paragraph", null, nodes.length > 0 ? nodes : undefined), 390 - ]); 417 + 418 + // Nested sublist if present 419 + if (item.sublist) { 420 + const sublistType = (item.sublist as { $type?: string }).$type; 421 + if (sublistType === "com.deckbelcher.richtext#bulletListBlock") { 422 + content.push( 423 + lexiconBulletListToTree(item.sublist as unknown as BulletListBlock), 424 + ); 425 + } else if (sublistType === "com.deckbelcher.richtext#orderedListBlock") { 426 + content.push( 427 + lexiconOrderedListToTree(item.sublist as unknown as OrderedListBlock), 428 + ); 429 + } 430 + } 431 + 432 + return schema.node("list_item", null, content); 391 433 } 392 434 393 435 export interface Segment {
+4 -1
typelex/richtext.tsp
··· 91 91 start?: integer; 92 92 } 93 93 94 - /** A single list item with text and optional facets. */ 94 + /** A single list item with text, optional facets, and optional sublist. */ 95 95 model ListItem { 96 96 /** The plain text content (no markdown symbols). */ 97 97 @maxGraphemes(10000) ··· 100 100 101 101 /** Annotations of text (formatting, mentions, links, etc). */ 102 102 facets?: com.deckbelcher.richtext.facet.Main[]; 103 + 104 + /** Optional nested sublist (bullet or ordered). */ 105 + sublist?: BulletListBlock | OrderedListBlock | unknown; 103 106 } 104 107 105 108 /** A horizontal rule (thematic break). */