👁️
5
fork

Configure Feed

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

start on tag refs

+377 -5
+75 -4
src/components/richtext/CombinedAutocomplete.tsx
··· 16 16 scryfallId: string; 17 17 } 18 18 19 - export type CombinedOption = MentionOption | CardOption; 20 - export type AutocompleteType = "mention" | "card"; 19 + export interface TagOption { 20 + tag: string; 21 + } 22 + 23 + export type CombinedOption = MentionOption | CardOption | TagOption; 24 + export type AutocompleteType = "mention" | "card" | "tag"; 21 25 22 26 export interface CombinedCallbacks { 23 27 mention: AutocompleteCallbacks<MentionOption>; 24 28 card: AutocompleteCallbacks<CardOption>; 29 + tag: AutocompleteCallbacks<TagOption>; 25 30 } 26 31 27 32 export function createCombinedAutocompletePlugin( ··· 31 36 triggers: [ 32 37 { name: "mention", trigger: "@" }, 33 38 { name: "card", trigger: "[[" }, 39 + { name: "tag", trigger: "#" }, 34 40 ], 35 41 reducer: (action: AutocompleteAction) => { 36 42 // Determine trigger type from action.type, or fall back to trigger string ··· 41 47 ? "card" 42 48 : action.trigger === "@" 43 49 ? "mention" 44 - : undefined); 50 + : action.trigger === "#" 51 + ? "tag" 52 + : undefined); 45 53 46 54 switch (action.kind) { 47 55 case ActionKind.open: { 48 56 const cb = 49 - triggerName === "card" ? callbacks.card : callbacks.mention; 57 + triggerName === "card" 58 + ? callbacks.card 59 + : triggerName === "tag" 60 + ? callbacks.tag 61 + : callbacks.mention; 50 62 cb.onStateChange({ 51 63 active: true, 52 64 query: action.filter || "", ··· 78 90 query, 79 91 range: action.range, 80 92 }); 93 + } else if (triggerName === "tag") { 94 + // Tags can have spaces, no early close 95 + callbacks.tag.onStateChange({ 96 + active: true, 97 + query, 98 + range: action.range, 99 + }); 81 100 } 82 101 return true; 83 102 } ··· 92 111 case ActionKind.close: { 93 112 callbacks.mention.onStateChange(null); 94 113 callbacks.card.onStateChange(null); 114 + callbacks.tag.onStateChange(null); 95 115 return true; 96 116 } 97 117 ··· 203 223 </div> 204 224 ); 205 225 } 226 + 227 + interface TagPopupContentProps { 228 + options: TagOption[]; 229 + selectedIndex: number; 230 + onSelectIndex: (i: number) => void; 231 + onSelect: (option: TagOption) => void; 232 + position: { top: number; left: number }; 233 + } 234 + 235 + export function TagPopupContent({ 236 + options, 237 + selectedIndex, 238 + onSelectIndex, 239 + onSelect, 240 + position, 241 + }: TagPopupContentProps) { 242 + return ( 243 + <div 244 + className="absolute z-50 bg-white dark:bg-slate-800 border border-gray-300 dark:border-slate-600 rounded-lg shadow-lg py-1 w-56 max-h-64 overflow-y-auto" 245 + style={{ top: position.top, left: position.left }} 246 + role="listbox" 247 + > 248 + {options.length === 0 ? ( 249 + <div className="px-3 py-2 text-sm text-gray-500 dark:text-gray-400"> 250 + No tags found 251 + </div> 252 + ) : ( 253 + options.map((option, i) => ( 254 + <button 255 + key={option.tag} 256 + type="button" 257 + role="option" 258 + aria-selected={i === selectedIndex} 259 + className={`w-full px-3 py-2 text-left text-sm ${ 260 + i === selectedIndex 261 + ? "bg-amber-100 dark:bg-amber-900/30 text-amber-900 dark:text-amber-100" 262 + : "text-gray-900 dark:text-gray-100 hover:bg-gray-100 dark:hover:bg-slate-700" 263 + }`} 264 + onMouseEnter={() => onSelectIndex(i)} 265 + onMouseDown={(e) => { 266 + e.preventDefault(); 267 + onSelect(option); 268 + }} 269 + > 270 + <span className="truncate">#{option.tag}</span> 271 + </button> 272 + )) 273 + )} 274 + </div> 275 + ); 276 + }
+51 -1
src/components/richtext/ProseMirrorEditor.tsx
··· 23 23 createCombinedAutocompletePlugin, 24 24 type MentionOption, 25 25 MentionPopupContent, 26 + type TagOption, 27 + TagPopupContent, 26 28 } from "./CombinedAutocomplete"; 27 29 import { buildInputRules } from "./inputRules"; 28 30 import { createUpdatePlugin } from "./plugins"; ··· 36 38 placeholder?: string; 37 39 className?: string; 38 40 showToolbar?: boolean; 41 + availableTags?: string[]; 39 42 } 40 43 41 44 export function ProseMirrorEditor({ ··· 44 47 placeholder = "Write something...", 45 48 className, 46 49 showToolbar = true, 50 + availableTags = [], 47 51 }: ProseMirrorEditorProps) { 48 52 const containerRef = useRef<HTMLDivElement>(null); 49 53 const viewRef = useRef<EditorView | null>(null); ··· 139 143 renderPopup: (props) => <CardPopupContent {...props} />, 140 144 }); 141 145 146 + // Tag autocomplete 147 + const handleTagSelect = useCallback( 148 + ( 149 + option: TagOption, 150 + state: { range: { from: number; to: number } }, 151 + view: EditorView, 152 + ) => { 153 + const tagNode = schema.nodes.tag.create({ 154 + tag: option.tag, 155 + }); 156 + 157 + const tr = view.state.tr 158 + .delete(state.range.from, state.range.to) 159 + .insert(state.range.from, tagNode) 160 + .insertText(" ", state.range.from + 1); 161 + 162 + view.dispatch(tr); 163 + view.focus(); 164 + }, 165 + [], 166 + ); 167 + 168 + const getTagQueryOptions = useCallback( 169 + (query: string) => ({ 170 + queryKey: ["tags", query, availableTags], 171 + queryFn: () => { 172 + const q = query.toLowerCase(); 173 + return availableTags 174 + .filter((t) => t.toLowerCase().includes(q)) 175 + .map((tag) => ({ tag })); 176 + }, 177 + staleTime: Number.POSITIVE_INFINITY, 178 + }), 179 + [availableTags], 180 + ); 181 + 182 + const tag = useEditorAutocomplete({ 183 + viewRef, 184 + containerRef: wrapperRef, 185 + getQueryOptions: getTagQueryOptions, 186 + onSelect: handleTagSelect, 187 + renderPopup: (props) => <TagPopupContent {...props} />, 188 + }); 189 + 142 190 // Combined callbacks for the single autocomplete plugin 143 191 const combinedCallbacks: CombinedCallbacks = useMemo( 144 192 () => ({ 145 193 mention: mention.callbacks, 146 194 card: card.callbacks, 195 + tag: tag.callbacks, 147 196 }), 148 - [mention.callbacks, card.callbacks], 197 + [mention.callbacks, card.callbacks, tag.callbacks], 149 198 ); 150 199 151 200 // biome-ignore lint/correctness/useExhaustiveDependencies: editor created once on mount ··· 213 262 </div> 214 263 {mention.popup} 215 264 {card.popup} 265 + {tag.popup} 216 266 </div> 217 267 ); 218 268 }
+7
src/components/richtext/RichtextRenderer.tsx
··· 282 282 </CardRefLink> 283 283 ); 284 284 285 + case "com.deckbelcher.richtext.facet#tag": 286 + return ( 287 + <span className="inline-flex items-center px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 text-sm font-medium"> 288 + {content} 289 + </span> 290 + ); 291 + 285 292 default: 286 293 return content; 287 294 }
+13
src/components/richtext/Toolbar.tsx
··· 1 1 import { 2 2 Bold, 3 3 Code, 4 + Hash, 4 5 Heading1, 5 6 Heading2, 6 7 Italic, ··· 171 172 172 173 const insertCardTrigger = () => { 173 174 openAutocomplete(view, "[["); 175 + view.focus(); 176 + }; 177 + 178 + const insertTagTrigger = () => { 179 + openAutocomplete(view, "#"); 174 180 view.focus(); 175 181 }; 176 182 ··· 349 355 title="Insert Card Reference ([[)" 350 356 > 351 357 <TextSearch className="w-4 h-4" /> 358 + </ToolbarButton> 359 + <ToolbarButton 360 + onClick={insertTagTrigger} 361 + active={false} 362 + title="Insert Tag (#)" 363 + > 364 + <Hash className="w-4 h-4" /> 352 365 </ToolbarButton> 353 366 </div> 354 367 <LinkModal
+31
src/components/richtext/schema.ts
··· 260 260 }, 261 261 ], 262 262 }, 263 + 264 + tag: { 265 + inline: true, 266 + group: "inline", 267 + atom: true, 268 + attrs: { 269 + tag: { default: "" }, 270 + }, 271 + toDOM(node) { 272 + return [ 273 + "span", 274 + { 275 + class: 276 + "inline-flex items-center px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 text-sm font-medium", 277 + "data-tag": node.attrs.tag, 278 + }, 279 + `#${node.attrs.tag}`, 280 + ]; 281 + }, 282 + parseDOM: [ 283 + { 284 + tag: "span[data-tag]", 285 + getAttrs(dom) { 286 + if (typeof dom === "string") return false; 287 + return { 288 + tag: dom.getAttribute("data-tag") ?? "", 289 + }; 290 + }, 291 + }, 292 + ], 293 + }, 263 294 }; 264 295 265 296 const marks: Record<string, MarkSpec> = {
+161
src/lib/__tests__/richtext-convert.test.ts
··· 1066 1066 expect(para.child(2).text).toBe(" world"); 1067 1067 }); 1068 1068 1069 + it("converts cardRef facet to inline node", () => { 1070 + const lexicon: LexiconDocument = { 1071 + content: [ 1072 + { 1073 + $type: "com.deckbelcher.richtext#paragraphBlock", 1074 + text: "check out Lightning Bolt for removal", 1075 + facets: [ 1076 + { 1077 + index: { byteStart: 10, byteEnd: 24 }, 1078 + features: [ 1079 + { 1080 + $type: "com.deckbelcher.richtext.facet#cardRef", 1081 + scryfallId: "e3285e6b-3e79-4d7c-bf96-d920f973b122", 1082 + }, 1083 + ], 1084 + }, 1085 + ], 1086 + }, 1087 + ], 1088 + }; 1089 + const result = lexiconToTree(lexicon); 1090 + const para = result.child(0); 1091 + 1092 + // Should have 3 children: "check out ", cardRef node, " for removal" 1093 + expect(para.childCount).toBe(3); 1094 + expect(para.child(0).text).toBe("check out "); 1095 + expect(para.child(1).type.name).toBe("cardRef"); 1096 + expect(para.child(1).attrs.name).toBe("Lightning Bolt"); 1097 + expect(para.child(1).attrs.scryfallId).toBe( 1098 + "e3285e6b-3e79-4d7c-bf96-d920f973b122", 1099 + ); 1100 + expect(para.child(2).text).toBe(" for removal"); 1101 + }); 1102 + 1103 + it("converts tag facet to inline node", () => { 1104 + const lexicon: LexiconDocument = { 1105 + content: [ 1106 + { 1107 + $type: "com.deckbelcher.richtext#paragraphBlock", 1108 + text: "tagged with #combo for deck", 1109 + facets: [ 1110 + { 1111 + index: { byteStart: 12, byteEnd: 18 }, 1112 + features: [ 1113 + { 1114 + $type: "com.deckbelcher.richtext.facet#tag", 1115 + tag: "combo", 1116 + }, 1117 + ], 1118 + }, 1119 + ], 1120 + }, 1121 + ], 1122 + }; 1123 + const result = lexiconToTree(lexicon); 1124 + const para = result.child(0); 1125 + 1126 + // Should have 3 children: "tagged with ", tag node, " for deck" 1127 + expect(para.childCount).toBe(3); 1128 + expect(para.child(0).text).toBe("tagged with "); 1129 + expect(para.child(1).type.name).toBe("tag"); 1130 + expect(para.child(1).attrs.tag).toBe("combo"); 1131 + expect(para.child(2).text).toBe(" for deck"); 1132 + }); 1133 + 1069 1134 it("converts adjacent facets", () => { 1070 1135 const lexicon: LexiconDocument = { 1071 1136 content: [ ··· 1430 1495 const result = roundtrip(original); 1431 1496 1432 1497 expect(result.eq(original)).toBe(true); 1498 + }); 1499 + 1500 + it("preserves cardRefs", () => { 1501 + const original = schema.node("doc", null, [ 1502 + schema.node("paragraph", null, [ 1503 + schema.text("run "), 1504 + schema.nodes.cardRef.create({ 1505 + name: "Lightning Bolt", 1506 + scryfallId: "e3285e6b-3e79-4d7c-bf96-d920f973b122", 1507 + }), 1508 + schema.text(" for removal"), 1509 + ]), 1510 + ]); 1511 + const result = roundtrip(original); 1512 + 1513 + expect(result.eq(original)).toBe(true); 1514 + }); 1515 + 1516 + it("converts cardRefs without scryfallId to plain text", () => { 1517 + const original = schema.node("doc", null, [ 1518 + schema.node("paragraph", null, [ 1519 + schema.nodes.cardRef.create({ 1520 + name: "Lightning Bolt", 1521 + scryfallId: "", 1522 + }), 1523 + ]), 1524 + ]); 1525 + 1526 + const lexicon = treeToLexicon(original); 1527 + const paragraph = lexicon.content[0] as { 1528 + text?: string; 1529 + facets?: unknown[]; 1530 + }; 1531 + 1532 + expect(paragraph.text).toBe("Lightning Bolt"); 1533 + expect(paragraph.facets).toBeUndefined(); 1534 + }); 1535 + 1536 + it("preserves multiple cardRefs with correct byte offsets", () => { 1537 + const original = schema.node("doc", null, [ 1538 + schema.node("paragraph", null, [ 1539 + schema.text("run "), 1540 + schema.nodes.cardRef.create({ 1541 + name: "Lightning Bolt", 1542 + scryfallId: "e3285e6b-3e79-4d7c-bf96-d920f973b122", 1543 + }), 1544 + schema.text(" and "), 1545 + schema.nodes.cardRef.create({ 1546 + name: "Path to Exile", 1547 + scryfallId: "163b68e8-33e9-4e4e-a2c1-e1c884c7a3b8", 1548 + }), 1549 + ]), 1550 + ]); 1551 + const result = roundtrip(original); 1552 + 1553 + expect(result.eq(original)).toBe(true); 1554 + }); 1555 + 1556 + it("preserves tags", () => { 1557 + const original = schema.node("doc", null, [ 1558 + schema.node("paragraph", null, [ 1559 + schema.text("tagged with "), 1560 + schema.nodes.tag.create({ tag: "combo" }), 1561 + schema.text(" and "), 1562 + schema.nodes.tag.create({ tag: "budget" }), 1563 + ]), 1564 + ]); 1565 + const result = roundtrip(original); 1566 + 1567 + expect(result.eq(original)).toBe(true); 1568 + }); 1569 + 1570 + it("preserves tags with spaces", () => { 1571 + const original = schema.node("doc", null, [ 1572 + schema.node("paragraph", null, [ 1573 + schema.nodes.tag.create({ tag: "budget friendly" }), 1574 + ]), 1575 + ]); 1576 + const result = roundtrip(original); 1577 + 1578 + expect(result.eq(original)).toBe(true); 1579 + }); 1580 + 1581 + it("converts tags without tag value to plain text", () => { 1582 + const original = schema.node("doc", null, [ 1583 + schema.node("paragraph", null, [schema.nodes.tag.create({ tag: "" })]), 1584 + ]); 1585 + 1586 + const lexicon = treeToLexicon(original); 1587 + const paragraph = lexicon.content[0] as { 1588 + text?: string; 1589 + facets?: unknown[]; 1590 + }; 1591 + 1592 + expect(paragraph.text).toBe("#"); 1593 + expect(paragraph.facets).toBeUndefined(); 1433 1594 }); 1434 1595 1435 1596 it("preserves headings", () => {
+39
src/lib/richtext-convert.ts
··· 283 283 } 284 284 285 285 byteOffset += textBytes.length; 286 + } else if (child.type.name === "tag") { 287 + const tag = (child.attrs.tag as string) || ""; 288 + const displayText = `#${tag}`; 289 + const textBytes = new TextEncoder().encode(displayText); 290 + 291 + textParts.push(displayText); 292 + 293 + if (tag) { 294 + facets.push({ 295 + index: { 296 + byteStart: byteOffset, 297 + byteEnd: byteOffset + textBytes.length, 298 + }, 299 + features: [ 300 + { 301 + $type: "com.deckbelcher.richtext.facet#tag", 302 + tag, 303 + }, 304 + ], 305 + }); 306 + } 307 + 308 + byteOffset += textBytes.length; 286 309 } 287 310 }); 288 311 ··· 556 579 scryfallId: cardRefFeature.scryfallId || "", 557 580 }), 558 581 ); 582 + continue; 583 + } 584 + 585 + // Check for tag facet - these become inline nodes 586 + const tagFeature = segment.features.find( 587 + (f) => 588 + (f as { $type?: string }).$type === 589 + "com.deckbelcher.richtext.facet#tag", 590 + ) as { $type: string; tag?: string } | undefined; 591 + 592 + if (tagFeature) { 593 + // Strip leading # from display text 594 + const tag = 595 + tagFeature.tag || 596 + (segment.text.startsWith("#") ? segment.text.slice(1) : segment.text); 597 + nodes.push(schema.nodes.tag.create({ tag })); 559 598 continue; 560 599 } 561 600