👁️
5
fork

Configure Feed

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

enhance editor and lexicon with more types

+682 -51
+87 -1
lexicons/com/deckbelcher/richtext.json
··· 31 31 "type": "union", 32 32 "refs": [ 33 33 "#paragraphBlock", 34 - "#headingBlock" 34 + "#headingBlock", 35 + "#codeBlock", 36 + "#bulletListBlock", 37 + "#orderedListBlock", 38 + "#horizontalRuleBlock" 35 39 ] 36 40 }, 37 41 "description": "Array of blocks (paragraphs, headings, etc)." ··· 90 94 "required": [ 91 95 "level" 92 96 ] 97 + }, 98 + "codeBlock": { 99 + "type": "object", 100 + "properties": { 101 + "text": { 102 + "type": "string", 103 + "maxLength": 100000, 104 + "description": "The code content (plain text, no facets)." 105 + }, 106 + "language": { 107 + "type": "string", 108 + "maxLength": 50, 109 + "description": "Optional language identifier for syntax highlighting." 110 + } 111 + }, 112 + "description": "A code block with optional language hint.", 113 + "required": [ 114 + "text" 115 + ] 116 + }, 117 + "bulletListBlock": { 118 + "type": "object", 119 + "properties": { 120 + "items": { 121 + "type": "array", 122 + "items": { 123 + "type": "ref", 124 + "ref": "#listItem" 125 + }, 126 + "description": "The list items." 127 + } 128 + }, 129 + "description": "An unordered (bullet) list.", 130 + "required": [ 131 + "items" 132 + ] 133 + }, 134 + "listItem": { 135 + "type": "object", 136 + "properties": { 137 + "text": { 138 + "type": "string", 139 + "maxLength": 100000, 140 + "maxGraphemes": 10000, 141 + "description": "The plain text content (no markdown symbols)." 142 + }, 143 + "facets": { 144 + "type": "array", 145 + "items": { 146 + "type": "ref", 147 + "ref": "com.deckbelcher.richtext.facet" 148 + }, 149 + "description": "Annotations of text (formatting, mentions, links, etc)." 150 + } 151 + }, 152 + "description": "A single list item with text and optional facets." 153 + }, 154 + "orderedListBlock": { 155 + "type": "object", 156 + "properties": { 157 + "items": { 158 + "type": "array", 159 + "items": { 160 + "type": "ref", 161 + "ref": "#listItem" 162 + }, 163 + "description": "The list items." 164 + }, 165 + "start": { 166 + "type": "integer", 167 + "description": "Starting number (default 1)." 168 + } 169 + }, 170 + "description": "An ordered (numbered) list.", 171 + "required": [ 172 + "items" 173 + ] 174 + }, 175 + "horizontalRuleBlock": { 176 + "type": "object", 177 + "properties": {}, 178 + "description": "A horizontal rule (thematic break)." 93 179 } 94 180 } 95 181 }
+12
package-lock.json
··· 34 34 "prosemirror-keymap": "^1.2.3", 35 35 "prosemirror-markdown": "^1.13.2", 36 36 "prosemirror-model": "^1.25.4", 37 + "prosemirror-schema-list": "^1.5.1", 37 38 "prosemirror-state": "^1.4.4", 38 39 "prosemirror-view": "^1.41.4", 39 40 "react": "^19.2.0", ··· 7489 7490 "license": "MIT", 7490 7491 "dependencies": { 7491 7492 "orderedmap": "^2.0.0" 7493 + } 7494 + }, 7495 + "node_modules/prosemirror-schema-list": { 7496 + "version": "1.5.1", 7497 + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", 7498 + "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", 7499 + "license": "MIT", 7500 + "dependencies": { 7501 + "prosemirror-model": "^1.0.0", 7502 + "prosemirror-state": "^1.0.0", 7503 + "prosemirror-transform": "^1.7.3" 7492 7504 } 7493 7505 }, 7494 7506 "node_modules/prosemirror-state": {
+1
package.json
··· 48 48 "prosemirror-keymap": "^1.2.3", 49 49 "prosemirror-markdown": "^1.13.2", 50 50 "prosemirror-model": "^1.25.4", 51 + "prosemirror-schema-list": "^1.5.1", 51 52 "prosemirror-state": "^1.4.4", 52 53 "prosemirror-view": "^1.41.4", 53 54 "react": "^19.2.0",
+20 -1
src/components/deck/PrimerSection.tsx
··· 7 7 import { lexiconToTree, treeToLexicon } from "@/lib/richtext-convert"; 8 8 import { type PMDocJSON, useProseMirror } from "@/lib/useProseMirror"; 9 9 10 + type Block = NonNullable<Document["content"]>[number]; 11 + 12 + function getBlockPlainText(block: Block): string { 13 + switch (block.$type) { 14 + case "com.deckbelcher.richtext#headingBlock": 15 + case "com.deckbelcher.richtext#paragraphBlock": 16 + return block.text ?? ""; 17 + case "com.deckbelcher.richtext#codeBlock": 18 + return block.text; 19 + case "com.deckbelcher.richtext#bulletListBlock": 20 + case "com.deckbelcher.richtext#orderedListBlock": 21 + return block.items.map((item) => item.text ?? "").join("\n"); 22 + case "com.deckbelcher.richtext#horizontalRuleBlock": 23 + return "---"; 24 + default: 25 + return ""; 26 + } 27 + } 28 + 10 29 interface PrimerSectionProps { 11 30 primer?: Document; 12 31 onSave?: (doc: Document) => void; ··· 52 71 // Get plain text for content check and line count 53 72 const plainText = useMemo(() => { 54 73 if (!primer?.content) return ""; 55 - return primer.content.map((block) => block.text ?? "").join("\n"); 74 + return primer.content.map(getBlockPlainText).join("\n"); 56 75 }, [primer]); 57 76 58 77 const hasContent = plainText.trim().length > 0;
+11
src/components/richtext/ProseMirrorEditor.tsx
··· 2 2 import { history, redo, undo } from "prosemirror-history"; 3 3 import { keymap } from "prosemirror-keymap"; 4 4 import type { Node as ProseMirrorNode } from "prosemirror-model"; 5 + import { 6 + liftListItem, 7 + sinkListItem, 8 + splitListItem, 9 + } from "prosemirror-schema-list"; 5 10 import { EditorState } from "prosemirror-state"; 6 11 import { EditorView } from "prosemirror-view"; 7 12 import { useCallback, useEffect, useRef, useState } from "react"; ··· 58 63 "Mod-b": toggleMark(schema.marks.strong), 59 64 "Mod-i": toggleMark(schema.marks.em), 60 65 "Mod-`": toggleMark(schema.marks.code), 66 + }), 67 + // List keybindings - must come before baseKeymap 68 + keymap({ 69 + Enter: splitListItem(schema.nodes.list_item), 70 + Tab: sinkListItem(schema.nodes.list_item), 71 + "Shift-Tab": liftListItem(schema.nodes.list_item), 61 72 }), 62 73 keymap(baseKeymap), 63 74 // Trigger React re-render on state changes for toolbar updates
+69 -3
src/components/richtext/RichtextRenderer.tsx
··· 1 1 import { sanitizeUrl } from "@braintree/sanitize-url"; 2 2 import { memo, type ReactNode } from "react"; 3 3 import type { 4 + BulletListBlock, 5 + CodeBlock, 4 6 Document, 5 7 HeadingBlock, 8 + HorizontalRuleBlock, 9 + ListItem, 10 + OrderedListBlock, 6 11 ParagraphBlock, 7 12 } from "@/lib/lexicons/types/com/deckbelcher/richtext"; 8 13 import type { Main as Facet } from "@/lib/lexicons/types/com/deckbelcher/richtext/facet"; 9 14 import { segmentize } from "@/lib/richtext-convert"; 10 15 16 + type Block = 17 + | ParagraphBlock 18 + | HeadingBlock 19 + | CodeBlock 20 + | BulletListBlock 21 + | OrderedListBlock 22 + | HorizontalRuleBlock; 23 + 11 24 export interface RichtextRendererProps { 12 25 doc: Document | undefined; 13 26 className?: string; ··· 34 47 const BlockRenderer = memo(function BlockRenderer({ 35 48 block, 36 49 }: { 37 - block: ParagraphBlock | HeadingBlock; 50 + block: Block; 38 51 }): ReactNode { 39 52 switch (block.$type) { 40 53 case "com.deckbelcher.richtext#headingBlock": { ··· 62 75 } 63 76 } 64 77 78 + case "com.deckbelcher.richtext#codeBlock": 79 + return ( 80 + <pre className="bg-gray-100 dark:bg-slate-800 rounded-lg p-3 my-2 overflow-x-auto"> 81 + <code className="font-mono text-sm text-gray-800 dark:text-gray-200"> 82 + {block.text} 83 + </code> 84 + </pre> 85 + ); 86 + 87 + case "com.deckbelcher.richtext#bulletListBlock": 88 + return ( 89 + <ul className="list-disc list-inside my-2 space-y-1"> 90 + {block.items.map((item, i) => ( 91 + // biome-ignore lint/suspicious/noArrayIndexKey: doc is immutable 92 + <ListItemRenderer key={i} item={item} /> 93 + ))} 94 + </ul> 95 + ); 96 + 97 + case "com.deckbelcher.richtext#orderedListBlock": 98 + return ( 99 + <ol 100 + className="list-decimal list-inside my-2 space-y-1" 101 + start={block.start ?? 1} 102 + > 103 + {block.items.map((item, i) => ( 104 + // biome-ignore lint/suspicious/noArrayIndexKey: doc is immutable 105 + <ListItemRenderer key={i} item={item} /> 106 + ))} 107 + </ol> 108 + ); 109 + 110 + case "com.deckbelcher.richtext#horizontalRuleBlock": 111 + return <hr className="my-4 border-gray-300 dark:border-slate-600" />; 112 + 65 113 default: { 66 - const isEmpty = !block.text?.trim(); 114 + const para = block as ParagraphBlock; 115 + const isEmpty = !para.text?.trim(); 67 116 return ( 68 117 <p> 69 118 {isEmpty ? ( 70 119 <br /> 71 120 ) : ( 72 - <TextWithFacets text={block.text} facets={block.facets} /> 121 + <TextWithFacets text={para.text} facets={para.facets} /> 73 122 )} 74 123 </p> 75 124 ); 76 125 } 77 126 } 127 + }); 128 + 129 + const ListItemRenderer = memo(function ListItemRenderer({ 130 + item, 131 + }: { 132 + item: ListItem; 133 + }): ReactNode { 134 + const isEmpty = !item.text?.trim(); 135 + return ( 136 + <li> 137 + {isEmpty ? ( 138 + <br /> 139 + ) : ( 140 + <TextWithFacets text={item.text} facets={item.facets} /> 141 + )} 142 + </li> 143 + ); 78 144 }); 79 145 80 146 function TextWithFacets({
+161 -18
src/components/richtext/Toolbar.tsx
··· 1 - import { Bold, Code, Italic, Link } from "lucide-react"; 2 - import { toggleMark } from "prosemirror-commands"; 3 - import type { MarkType } from "prosemirror-model"; 1 + import { 2 + Bold, 3 + Code, 4 + Heading1, 5 + Heading2, 6 + Italic, 7 + Link, 8 + List, 9 + ListOrdered, 10 + Minus, 11 + Redo, 12 + SquareCode, 13 + Undo, 14 + } from "lucide-react"; 15 + import { setBlockType, toggleMark } from "prosemirror-commands"; 16 + import { redo, undo } from "prosemirror-history"; 17 + import type { MarkType, NodeType } from "prosemirror-model"; 18 + import { liftListItem, wrapInList } from "prosemirror-schema-list"; 19 + import { TextSelection } from "prosemirror-state"; 4 20 import type { EditorView } from "prosemirror-view"; 5 21 import { useCallback, useState } from "react"; 6 22 import { LinkModal } from "./LinkModal"; ··· 15 31 initialUrl: "", 16 32 initialText: "", 17 33 showTextInput: false, 18 - // Range of existing link being edited, if any 19 34 linkRange: null as { from: number; to: number } | null, 20 35 }); 21 36 22 37 const handleLinkSubmit = useCallback( 23 38 (url: string, text?: string) => { 24 39 if (!view) return; 25 - // Default to https:// if no protocol provided 26 40 const href = /^[a-z][a-z0-9+.-]*:/i.test(url) ? url : `https://${url}`; 27 41 const linkMark = view.state.schema.marks.link.create({ href }); 28 42 const tr = view.state.tr; 29 43 30 44 if (linkModalState.linkRange) { 31 - // Editing existing link - remove old mark first, then add new one 32 45 const { from, to } = linkModalState.linkRange; 33 46 tr.removeMark(from, to, view.state.schema.marks.link); 34 47 tr.addMark(from, to, linkMark); 35 48 } else { 36 - // New link 37 49 const { from, to } = view.state.selection; 38 50 const selectedText = view.state.doc.textBetween(from, to); 39 51 ··· 57 69 const { state } = view; 58 70 const { schema } = state; 59 71 72 + // Mark helpers 60 73 const isMarkActive = (markType: MarkType) => { 61 74 const { from, $from, to, empty } = state.selection; 62 75 if (empty) { ··· 72 85 }; 73 86 }; 74 87 88 + // Block type helpers 89 + const isBlockActive = ( 90 + nodeType: NodeType, 91 + attrs?: Record<string, unknown>, 92 + ) => { 93 + const { $from } = state.selection; 94 + for (let d = $from.depth; d > 0; d--) { 95 + const node = $from.node(d); 96 + if (node.type === nodeType) { 97 + if (!attrs) return true; 98 + return Object.entries(attrs).every( 99 + ([key, value]) => node.attrs[key] === value, 100 + ); 101 + } 102 + } 103 + return false; 104 + }; 105 + 106 + const setBlock = (nodeType: NodeType, attrs?: Record<string, unknown>) => { 107 + return () => { 108 + // If already this block type, convert back to paragraph 109 + if (isBlockActive(nodeType, attrs)) { 110 + setBlockType(schema.nodes.paragraph)(state, view.dispatch); 111 + } else { 112 + setBlockType(nodeType, attrs)(state, view.dispatch); 113 + } 114 + view.focus(); 115 + }; 116 + }; 117 + 118 + const toggleList = (listType: NodeType) => { 119 + return () => { 120 + if (isBlockActive(listType)) { 121 + liftListItem(schema.nodes.list_item)(state, view.dispatch); 122 + } else { 123 + wrapInList(listType)(state, view.dispatch); 124 + } 125 + view.focus(); 126 + }; 127 + }; 128 + 129 + const insertHorizontalRule = () => { 130 + const tr = state.tr.replaceSelectionWith( 131 + schema.nodes.horizontal_rule.create(), 132 + ); 133 + // Always insert a paragraph after HR and move cursor there 134 + const insertPos = tr.doc.content.size; 135 + tr.insert(insertPos, schema.nodes.paragraph.create()); 136 + // Move selection to the new paragraph (position inside the paragraph) 137 + tr.setSelection(TextSelection.near(tr.doc.resolve(insertPos + 1))); 138 + view.dispatch(tr); 139 + view.focus(); 140 + }; 141 + 142 + // Link modal logic 75 143 const openLinkModal = () => { 76 144 const { from, to, $from } = state.selection; 77 145 const selectedText = state.doc.textBetween(from, to); 78 146 79 - // Check for existing link mark - either at cursor or in selection 80 147 let linkMark = schema.marks.link.isInSet($from.marks()); 81 148 let existingUrl = linkMark?.attrs.href as string | undefined; 82 149 83 - // If selection spans text, check if it contains a link 84 150 if (!linkMark && from !== to) { 85 151 state.doc.nodesBetween(from, to, (node) => { 86 - if (linkMark) return false; // Already found one 152 + if (linkMark) return false; 87 153 const mark = schema.marks.link.isInSet(node.marks); 88 154 if (mark) { 89 155 linkMark = mark; ··· 93 159 }); 94 160 } 95 161 96 - // Find the full extent of the link mark if editing 97 162 let linkRange: { from: number; to: number } | null = null; 98 163 if (linkMark) { 99 - // Walk through parent's inline content to find link boundaries 100 - // We check actual node marks, not insertion marks (which differ for non-inclusive marks) 101 164 const $pos = from !== to ? state.doc.resolve(from + 1) : $from; 102 165 const parent = $pos.parent; 103 166 const parentOffset = $pos.start(); ··· 109 172 110 173 parent.forEach((node) => { 111 174 if (foundEnd) return; 112 - 113 175 const nodeEnd = pos + node.nodeSize; 114 176 const nodeLinkMark = schema.marks.link.isInSet(node.marks); 115 177 ··· 119 181 } else if (linkStart !== null) { 120 182 foundEnd = true; 121 183 } 122 - 123 184 pos = nodeEnd; 124 185 }); 125 186 ··· 137 198 setLinkModalOpen(true); 138 199 }; 139 200 201 + const runUndo = () => { 202 + undo(state, view.dispatch); 203 + view.focus(); 204 + }; 205 + 206 + const runRedo = () => { 207 + redo(state, view.dispatch); 208 + view.focus(); 209 + }; 210 + 140 211 return ( 141 212 <> 142 - <div className="flex items-center gap-1 p-2 border-b border-gray-300 dark:border-slate-700 bg-gray-50 dark:bg-slate-800/50"> 213 + <div className="flex flex-wrap items-center gap-1 p-2 border-b border-gray-300 dark:border-slate-700 bg-gray-50 dark:bg-slate-800/50"> 214 + {/* History */} 215 + <ToolbarButton onClick={runUndo} active={false} title="Undo (Cmd+Z)"> 216 + <Undo className="w-4 h-4" /> 217 + </ToolbarButton> 218 + <ToolbarButton 219 + onClick={runRedo} 220 + active={false} 221 + title="Redo (Cmd+Shift+Z)" 222 + > 223 + <Redo className="w-4 h-4" /> 224 + </ToolbarButton> 225 + 226 + <ToolbarDivider /> 227 + 228 + {/* Inline marks */} 143 229 <ToolbarButton 144 230 onClick={toggleMarkCommand(schema.marks.strong)} 145 231 active={isMarkActive(schema.marks.strong)} ··· 157 243 <ToolbarButton 158 244 onClick={toggleMarkCommand(schema.marks.code)} 159 245 active={isMarkActive(schema.marks.code)} 160 - title="Code (Cmd+`)" 246 + title="Inline Code (Cmd+`)" 161 247 > 162 248 <Code className="w-4 h-4" /> 163 249 </ToolbarButton> 164 - <div className="w-px h-5 bg-gray-300 dark:bg-slate-600 mx-1" /> 165 250 <ToolbarButton 166 251 onClick={openLinkModal} 167 252 active={isMarkActive(schema.marks.link)} ··· 169 254 > 170 255 <Link className="w-4 h-4" /> 171 256 </ToolbarButton> 257 + 258 + <ToolbarDivider /> 259 + 260 + {/* Block types */} 261 + <ToolbarButton 262 + onClick={setBlock(schema.nodes.heading, { level: 1 })} 263 + active={isBlockActive(schema.nodes.heading, { level: 1 })} 264 + title="Heading 1" 265 + > 266 + <Heading1 className="w-4 h-4" /> 267 + </ToolbarButton> 268 + <ToolbarButton 269 + onClick={setBlock(schema.nodes.heading, { level: 2 })} 270 + active={isBlockActive(schema.nodes.heading, { level: 2 })} 271 + title="Heading 2" 272 + > 273 + <Heading2 className="w-4 h-4" /> 274 + </ToolbarButton> 275 + <ToolbarButton 276 + onClick={setBlock(schema.nodes.code_block)} 277 + active={isBlockActive(schema.nodes.code_block)} 278 + title="Code Block" 279 + > 280 + <SquareCode className="w-4 h-4" /> 281 + </ToolbarButton> 282 + 283 + <ToolbarDivider /> 284 + 285 + {/* Lists */} 286 + <ToolbarButton 287 + onClick={toggleList(schema.nodes.bullet_list)} 288 + active={isBlockActive(schema.nodes.bullet_list)} 289 + title="Bullet List" 290 + > 291 + <List className="w-4 h-4" /> 292 + </ToolbarButton> 293 + <ToolbarButton 294 + onClick={toggleList(schema.nodes.ordered_list)} 295 + active={isBlockActive(schema.nodes.ordered_list)} 296 + title="Numbered List" 297 + > 298 + <ListOrdered className="w-4 h-4" /> 299 + </ToolbarButton> 300 + 301 + <ToolbarDivider /> 302 + 303 + {/* Insert */} 304 + <ToolbarButton 305 + onClick={insertHorizontalRule} 306 + active={false} 307 + title="Horizontal Rule" 308 + > 309 + <Minus className="w-4 h-4" /> 310 + </ToolbarButton> 172 311 </div> 173 312 <LinkModal 174 313 isOpen={linkModalOpen} ··· 180 319 /> 181 320 </> 182 321 ); 322 + } 323 + 324 + function ToolbarDivider() { 325 + return <div className="w-px h-5 bg-gray-300 dark:bg-slate-600 mx-1" />; 183 326 } 184 327 185 328 interface ToolbarButtonProps {
-8
src/components/richtext/inputRules.ts
··· 2 2 InputRule, 3 3 inputRules, 4 4 textblockTypeInputRule, 5 - wrappingInputRule, 6 5 } from "prosemirror-inputrules"; 7 6 import type { MarkType, NodeType, Schema } from "prosemirror-model"; 8 7 ··· 36 35 if (schema.nodes.code_block) { 37 36 rules.push( 38 37 textblockTypeInputRule(/^```$/, schema.nodes.code_block as NodeType), 39 - ); 40 - } 41 - 42 - // Blockquote: > at start of line 43 - if (schema.nodes.blockquote) { 44 - rules.push( 45 - wrappingInputRule(/^\s*>\s$/, schema.nodes.blockquote as NodeType), 46 38 ); 47 39 } 48 40
+20 -9
src/lib/__tests__/richtext-convert.test.ts
··· 1 1 import fc from "fast-check"; 2 2 import { describe, expect, it } from "vitest"; 3 3 import { schema } from "@/components/richtext/schema"; 4 + import type { 5 + HeadingBlock, 6 + ParagraphBlock, 7 + } from "@/lib/lexicons/types/com/deckbelcher/richtext"; 4 8 import { 5 9 type LexiconDocument, 6 10 lexiconToTree, 7 11 treeToLexicon, 8 12 } from "@/lib/richtext-convert"; 13 + 14 + /** Type helper for tests that expect paragraph/heading blocks */ 15 + type TextBlock = ParagraphBlock | HeadingBlock; 9 16 10 17 describe("treeToLexicon", () => { 11 18 describe("paragraphs", () => { ··· 307 314 ]), 308 315 ]); 309 316 const result = treeToLexicon(doc); 317 + const block = result.content[0] as TextBlock; 310 318 311 319 // Marks with the same byte range are merged into a single facet 312 - expect(result.content[0]?.facets).toHaveLength(1); 313 - expect(result.content[0]?.facets?.[0]).toMatchObject({ 320 + expect(block.facets).toHaveLength(1); 321 + expect(block.facets?.[0]).toMatchObject({ 314 322 index: { byteStart: 0, byteEnd: 4 }, 315 323 features: expect.arrayContaining([ 316 324 { $type: "com.deckbelcher.richtext.facet#bold" }, ··· 334 342 ]), 335 343 ]); 336 344 const result = treeToLexicon(doc); 345 + const block = result.content[0] as TextBlock; 337 346 338 347 // Should have facets for the bold region and italic region 339 348 // Bold: "BOLD both" = bytes 7-16 340 349 // Italic: "both ITALIC" = bytes 12-23 341 - expect(result.content[0]?.text).toBe("normal BOLD both ITALIC normal"); 350 + expect(block.text).toBe("normal BOLD both ITALIC normal"); 342 351 343 - const facets = result.content[0]?.facets ?? []; 352 + const facets = block.facets ?? []; 344 353 const boldFacet = facets.find( 345 354 (f) => f.features[0]?.$type === "com.deckbelcher.richtext.facet#bold", 346 355 ); ··· 362 371 ]), 363 372 ]); 364 373 const result = treeToLexicon(doc); 374 + const block = result.content[0] as TextBlock; 365 375 366 376 // Marks with the same byte range are merged into a single facet 367 - expect(result.content[0]?.facets).toHaveLength(1); 368 - expect(result.content[0]?.facets?.[0]).toMatchObject({ 377 + expect(block.facets).toHaveLength(1); 378 + expect(block.facets?.[0]).toMatchObject({ 369 379 index: { byteStart: 0, byteEnd: 5 }, 370 380 features: expect.arrayContaining([ 371 381 { $type: "com.deckbelcher.richtext.facet#bold" }, ··· 1190 1200 1191 1201 for (let i = 0; i < doc.childCount; i++) { 1192 1202 const blockText = doc.child(i).textContent; 1193 - const lexiconBlock = lexicon.content[i]; 1203 + const lexiconBlock = lexicon.content[i] as TextBlock | undefined; 1194 1204 const lexiconText = lexiconBlock?.text ?? ""; 1195 1205 1196 1206 if (blockText !== lexiconText) { ··· 1210 1220 1211 1221 for (let i = 0; i < doc.childCount; i++) { 1212 1222 const block = doc.child(i); 1213 - const lexiconBlock = lexicon.content[i]; 1223 + const lexiconBlock = lexicon.content[i] as TextBlock | undefined; 1214 1224 1215 1225 // Count unique marks in this block 1216 1226 const markTypes = new Set<string>(); ··· 1244 1254 fc.property(arbDocument, (doc) => { 1245 1255 const lexicon = treeToLexicon(doc); 1246 1256 1247 - for (const block of lexicon.content) { 1257 + for (const lexiconBlock of lexicon.content) { 1258 + const block = lexiconBlock as TextBlock; 1248 1259 const text = block.text ?? ""; 1249 1260 const textBytes = new TextEncoder().encode(text); 1250 1261 const facets = block.facets ?? [];
+111 -1
src/lib/lexicons/types/com/deckbelcher/richtext.ts
··· 2 2 import * as v from "@atcute/lexicons/validations"; 3 3 import * as ComDeckbelcherRichtextFacet from "./richtext/facet.js"; 4 4 5 + const _bulletListBlockSchema = /*#__PURE__*/ v.object({ 6 + $type: /*#__PURE__*/ v.optional( 7 + /*#__PURE__*/ v.literal("com.deckbelcher.richtext#bulletListBlock"), 8 + ), 9 + /** 10 + * The list items. 11 + */ 12 + get items() { 13 + return /*#__PURE__*/ v.array(listItemSchema); 14 + }, 15 + }); 16 + const _codeBlockSchema = /*#__PURE__*/ v.object({ 17 + $type: /*#__PURE__*/ v.optional( 18 + /*#__PURE__*/ v.literal("com.deckbelcher.richtext#codeBlock"), 19 + ), 20 + /** 21 + * Optional language identifier for syntax highlighting. 22 + * @maxLength 50 23 + */ 24 + language: /*#__PURE__*/ v.optional( 25 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 26 + /*#__PURE__*/ v.stringLength(0, 50), 27 + ]), 28 + ), 29 + /** 30 + * The code content (plain text, no facets). 31 + * @maxLength 100000 32 + */ 33 + text: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 34 + /*#__PURE__*/ v.stringLength(0, 100000), 35 + ]), 36 + }); 5 37 const _documentSchema = /*#__PURE__*/ v.object({ 6 38 $type: /*#__PURE__*/ v.optional( 7 39 /*#__PURE__*/ v.literal("com.deckbelcher.richtext#document"), ··· 11 43 */ 12 44 get content() { 13 45 return /*#__PURE__*/ v.array( 14 - /*#__PURE__*/ v.variant([headingBlockSchema, paragraphBlockSchema]), 46 + /*#__PURE__*/ v.variant([ 47 + bulletListBlockSchema, 48 + codeBlockSchema, 49 + headingBlockSchema, 50 + horizontalRuleBlockSchema, 51 + orderedListBlockSchema, 52 + paragraphBlockSchema, 53 + ]), 15 54 ); 16 55 }, 17 56 }); ··· 47 86 ]), 48 87 ), 49 88 }); 89 + const _horizontalRuleBlockSchema = /*#__PURE__*/ v.object({ 90 + $type: /*#__PURE__*/ v.optional( 91 + /*#__PURE__*/ v.literal("com.deckbelcher.richtext#horizontalRuleBlock"), 92 + ), 93 + }); 94 + const _listItemSchema = /*#__PURE__*/ v.object({ 95 + $type: /*#__PURE__*/ v.optional( 96 + /*#__PURE__*/ v.literal("com.deckbelcher.richtext#listItem"), 97 + ), 98 + /** 99 + * Annotations of text (formatting, mentions, links, etc). 100 + */ 101 + get facets() { 102 + return /*#__PURE__*/ v.optional( 103 + /*#__PURE__*/ v.array(ComDeckbelcherRichtextFacet.mainSchema), 104 + ); 105 + }, 106 + /** 107 + * The plain text content (no markdown symbols). 108 + * @maxLength 100000 109 + * @maxGraphemes 10000 110 + */ 111 + text: /*#__PURE__*/ v.optional( 112 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 113 + /*#__PURE__*/ v.stringLength(0, 100000), 114 + /*#__PURE__*/ v.stringGraphemes(0, 10000), 115 + ]), 116 + ), 117 + }); 50 118 const _mainSchema = /*#__PURE__*/ v.object({ 51 119 $type: /*#__PURE__*/ v.optional( 52 120 /*#__PURE__*/ v.literal("com.deckbelcher.richtext"), ··· 71 139 ]), 72 140 ), 73 141 }); 142 + const _orderedListBlockSchema = /*#__PURE__*/ v.object({ 143 + $type: /*#__PURE__*/ v.optional( 144 + /*#__PURE__*/ v.literal("com.deckbelcher.richtext#orderedListBlock"), 145 + ), 146 + /** 147 + * The list items. 148 + */ 149 + get items() { 150 + return /*#__PURE__*/ v.array(listItemSchema); 151 + }, 152 + /** 153 + * Starting number (default 1). 154 + */ 155 + start: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.integer()), 156 + }); 74 157 const _paragraphBlockSchema = /*#__PURE__*/ v.object({ 75 158 $type: /*#__PURE__*/ v.optional( 76 159 /*#__PURE__*/ v.literal("com.deckbelcher.richtext#paragraphBlock"), ··· 96 179 ), 97 180 }); 98 181 182 + type bulletListBlock$schematype = typeof _bulletListBlockSchema; 183 + type codeBlock$schematype = typeof _codeBlockSchema; 99 184 type document$schematype = typeof _documentSchema; 100 185 type headingBlock$schematype = typeof _headingBlockSchema; 186 + type horizontalRuleBlock$schematype = typeof _horizontalRuleBlockSchema; 187 + type listItem$schematype = typeof _listItemSchema; 101 188 type main$schematype = typeof _mainSchema; 189 + type orderedListBlock$schematype = typeof _orderedListBlockSchema; 102 190 type paragraphBlock$schematype = typeof _paragraphBlockSchema; 103 191 192 + export interface bulletListBlockSchema extends bulletListBlock$schematype {} 193 + export interface codeBlockSchema extends codeBlock$schematype {} 104 194 export interface documentSchema extends document$schematype {} 105 195 export interface headingBlockSchema extends headingBlock$schematype {} 196 + export interface horizontalRuleBlockSchema 197 + extends horizontalRuleBlock$schematype {} 198 + export interface listItemSchema extends listItem$schematype {} 106 199 export interface mainSchema extends main$schematype {} 200 + export interface orderedListBlockSchema extends orderedListBlock$schematype {} 107 201 export interface paragraphBlockSchema extends paragraphBlock$schematype {} 108 202 203 + export const bulletListBlockSchema = 204 + _bulletListBlockSchema as bulletListBlockSchema; 205 + export const codeBlockSchema = _codeBlockSchema as codeBlockSchema; 109 206 export const documentSchema = _documentSchema as documentSchema; 110 207 export const headingBlockSchema = _headingBlockSchema as headingBlockSchema; 208 + export const horizontalRuleBlockSchema = 209 + _horizontalRuleBlockSchema as horizontalRuleBlockSchema; 210 + export const listItemSchema = _listItemSchema as listItemSchema; 111 211 export const mainSchema = _mainSchema as mainSchema; 212 + export const orderedListBlockSchema = 213 + _orderedListBlockSchema as orderedListBlockSchema; 112 214 export const paragraphBlockSchema = 113 215 _paragraphBlockSchema as paragraphBlockSchema; 114 216 217 + export interface BulletListBlock 218 + extends v.InferInput<typeof bulletListBlockSchema> {} 219 + export interface CodeBlock extends v.InferInput<typeof codeBlockSchema> {} 115 220 export interface Document extends v.InferInput<typeof documentSchema> {} 116 221 export interface HeadingBlock extends v.InferInput<typeof headingBlockSchema> {} 222 + export interface HorizontalRuleBlock 223 + extends v.InferInput<typeof horizontalRuleBlockSchema> {} 224 + export interface ListItem extends v.InferInput<typeof listItemSchema> {} 117 225 export interface Main extends v.InferInput<typeof mainSchema> {} 226 + export interface OrderedListBlock 227 + extends v.InferInput<typeof orderedListBlockSchema> {} 118 228 export interface ParagraphBlock 119 229 extends v.InferInput<typeof paragraphBlockSchema> {}
+138 -9
src/lib/richtext-convert.ts
··· 1 1 import type { Mark, Node as ProseMirrorNode } from "prosemirror-model"; 2 2 import { schema } from "@/components/richtext/schema"; 3 3 import type { 4 + BulletListBlock, 5 + CodeBlock, 4 6 HeadingBlock, 7 + HorizontalRuleBlock, 5 8 Document as LexiconDocument, 9 + ListItem, 10 + OrderedListBlock, 6 11 ParagraphBlock, 7 12 } from "@/lib/lexicons/types/com/deckbelcher/richtext"; 8 13 import type { ··· 23 28 ? T & { $type: NonNullable<T["$type"]> } 24 29 : T; 25 30 26 - type Block = Typed<ParagraphBlock | HeadingBlock>; 31 + type Block = Typed< 32 + | ParagraphBlock 33 + | HeadingBlock 34 + | CodeBlock 35 + | BulletListBlock 36 + | OrderedListBlock 37 + | HorizontalRuleBlock 38 + >; 27 39 type Feature = Typed<Bold | Italic | Code | Link>; 28 40 29 41 /** ··· 33 45 const content: Block[] = []; 34 46 35 47 doc.forEach((block) => { 36 - if (block.type.name === "paragraph") { 37 - content.push(paragraphToLexicon(block)); 38 - } else if (block.type.name === "heading") { 39 - content.push(headingToLexicon(block)); 48 + switch (block.type.name) { 49 + case "paragraph": 50 + content.push(paragraphToLexicon(block)); 51 + break; 52 + case "heading": 53 + content.push(headingToLexicon(block)); 54 + break; 55 + case "code_block": 56 + content.push(codeBlockToLexicon(block)); 57 + break; 58 + case "bullet_list": 59 + content.push(bulletListToLexicon(block)); 60 + break; 61 + case "ordered_list": 62 + content.push(orderedListToLexicon(block)); 63 + break; 64 + case "horizontal_rule": 65 + content.push(horizontalRuleToLexicon()); 66 + break; 40 67 } 41 - // TODO: handle other block types (code_block, blockquote) 42 68 }); 43 69 44 70 return { content }; ··· 71 97 } 72 98 73 99 /** 100 + * Convert a code_block node to lexicon format. 101 + */ 102 + function codeBlockToLexicon(node: ProseMirrorNode): Typed<CodeBlock> { 103 + return { 104 + $type: "com.deckbelcher.richtext#codeBlock", 105 + text: node.textContent, 106 + language: (node.attrs.params as string) || undefined, 107 + }; 108 + } 109 + 110 + /** 111 + * Collect children of a node into an array. 112 + */ 113 + function childrenOf(node: ProseMirrorNode): ProseMirrorNode[] { 114 + const children: ProseMirrorNode[] = []; 115 + node.forEach((child) => { 116 + children.push(child); 117 + }); 118 + return children; 119 + } 120 + 121 + /** 122 + * Convert a bullet_list node to lexicon format. 123 + */ 124 + function bulletListToLexicon(node: ProseMirrorNode): Typed<BulletListBlock> { 125 + return { 126 + $type: "com.deckbelcher.richtext#bulletListBlock", 127 + items: childrenOf(node).map(listItemToLexicon), 128 + }; 129 + } 130 + 131 + /** 132 + * Convert an ordered_list node to lexicon format. 133 + */ 134 + function orderedListToLexicon(node: ProseMirrorNode): Typed<OrderedListBlock> { 135 + const start = (node.attrs.order as number) || 1; 136 + return { 137 + $type: "com.deckbelcher.richtext#orderedListBlock", 138 + items: childrenOf(node).map(listItemToLexicon), 139 + start: start !== 1 ? start : undefined, 140 + }; 141 + } 142 + 143 + /** 144 + * Convert a list_item node to lexicon format. 145 + * Extracts text from the first paragraph child. 146 + */ 147 + function listItemToLexicon(node: ProseMirrorNode): Typed<ListItem> { 148 + const firstParagraph = node.firstChild; 149 + 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 + }; 156 + } 157 + return { $type: "com.deckbelcher.richtext#listItem" }; 158 + } 159 + 160 + /** 161 + * Convert a horizontal_rule node to lexicon format. 162 + */ 163 + function horizontalRuleToLexicon(): Typed<HorizontalRuleBlock> { 164 + return { $type: "com.deckbelcher.richtext#horizontalRuleBlock" }; 165 + } 166 + 167 + /** 74 168 * Extract text content and facets from a block node. 75 169 */ 76 170 function extractTextAndFacets(node: ProseMirrorNode): { ··· 224 318 return schema.node("doc", null, blocks); 225 319 } 226 320 227 - function lexiconBlockToTree( 228 - block: ParagraphBlock | HeadingBlock, 229 - ): ProseMirrorNode { 321 + function lexiconBlockToTree(block: Block): ProseMirrorNode { 230 322 switch (block.$type) { 231 323 case "com.deckbelcher.richtext#headingBlock": 232 324 return lexiconHeadingToTree(block); 325 + case "com.deckbelcher.richtext#codeBlock": 326 + return lexiconCodeBlockToTree(block); 327 + case "com.deckbelcher.richtext#bulletListBlock": 328 + return lexiconBulletListToTree(block); 329 + case "com.deckbelcher.richtext#orderedListBlock": 330 + return lexiconOrderedListToTree(block); 331 + case "com.deckbelcher.richtext#horizontalRuleBlock": 332 + return schema.node("horizontal_rule"); 233 333 default: 234 334 return lexiconParagraphToTree(block as ParagraphBlock); 235 335 } ··· 259 359 { level }, 260 360 nodes.length > 0 ? nodes : undefined, 261 361 ); 362 + } 363 + 364 + function lexiconCodeBlockToTree(block: CodeBlock): ProseMirrorNode { 365 + return schema.node( 366 + "code_block", 367 + { params: block.language || "" }, 368 + block.text ? [schema.text(block.text)] : undefined, 369 + ); 370 + } 371 + 372 + function lexiconBulletListToTree(block: BulletListBlock): ProseMirrorNode { 373 + const items = block.items.map(lexiconListItemToTree); 374 + return schema.node("bullet_list", null, items); 375 + } 376 + 377 + function lexiconOrderedListToTree(block: OrderedListBlock): ProseMirrorNode { 378 + const items = block.items.map(lexiconListItemToTree); 379 + return schema.node("ordered_list", { order: block.start || 1 }, items); 380 + } 381 + 382 + function lexiconListItemToTree(item: ListItem): ProseMirrorNode { 383 + const text = item.text || ""; 384 + if (!text) { 385 + return schema.node("list_item", null, [schema.node("paragraph")]); 386 + } 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 + ]); 262 391 } 263 392 264 393 export interface Segment {
+52 -1
typelex/richtext.tsp
··· 23 23 model Document { 24 24 /** Array of blocks (paragraphs, headings, etc). */ 25 25 @required 26 - content: (ParagraphBlock | HeadingBlock | unknown)[]; 26 + content: ( 27 + | ParagraphBlock 28 + | HeadingBlock 29 + | CodeBlock 30 + | BulletListBlock 31 + | OrderedListBlock 32 + | HorizontalRuleBlock 33 + | unknown 34 + )[]; 27 35 } 28 36 29 37 /** A paragraph block with text and optional facets. */ ··· 53 61 /** Annotations of text (formatting, mentions, links, etc). */ 54 62 facets?: com.deckbelcher.richtext.facet.Main[]; 55 63 } 64 + 65 + /** A code block with optional language hint. */ 66 + model CodeBlock { 67 + /** The code content (plain text, no facets). */ 68 + @required 69 + @maxLength(100000) 70 + text: string; 71 + 72 + /** Optional language identifier for syntax highlighting. */ 73 + @maxLength(50) 74 + language?: string; 75 + } 76 + 77 + /** An unordered (bullet) list. */ 78 + model BulletListBlock { 79 + /** The list items. */ 80 + @required 81 + items: ListItem[]; 82 + } 83 + 84 + /** An ordered (numbered) list. */ 85 + model OrderedListBlock { 86 + /** The list items. */ 87 + @required 88 + items: ListItem[]; 89 + 90 + /** Starting number (default 1). */ 91 + start?: integer; 92 + } 93 + 94 + /** A single list item with text and optional facets. */ 95 + model ListItem { 96 + /** The plain text content (no markdown symbols). */ 97 + @maxGraphemes(10000) 98 + @maxLength(100000) 99 + text?: string; 100 + 101 + /** Annotations of text (formatting, mentions, links, etc). */ 102 + facets?: com.deckbelcher.richtext.facet.Main[]; 103 + } 104 + 105 + /** A horizontal rule (thematic break). */ 106 + model HorizontalRuleBlock {} 56 107 }