👁️
5
fork

Configure Feed

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

card references

+379 -118
+14 -1
lexicons/com/deckbelcher/richtext/facet.json
··· 20 20 "#bold", 21 21 "#italic", 22 22 "#code", 23 - "#codeBlock" 23 + "#codeBlock", 24 + "#cardRef" 24 25 ] 25 26 } 26 27 } ··· 108 109 "type": "object", 109 110 "properties": {}, 110 111 "description": "Facet feature for code blocks.\nTypically rendered as `<pre><code>` in HTML." 112 + }, 113 + "cardRef": { 114 + "type": "object", 115 + "properties": { 116 + "scryfallId": { 117 + "type": "string" 118 + } 119 + }, 120 + "description": "Facet feature for a card reference.\nLinks to a Magic: The Gathering card by Scryfall ID.\nThe text is usually the card name.", 121 + "required": [ 122 + "scryfallId" 123 + ] 111 124 } 112 125 } 113 126 }
+197
src/components/richtext/CombinedAutocomplete.tsx
··· 1 + import autocomplete, { 2 + ActionKind, 3 + type AutocompleteAction, 4 + closeAutocomplete, 5 + } from "prosemirror-autocomplete"; 6 + import type { Plugin } from "prosemirror-state"; 7 + import type { AutocompleteCallbacks } from "./useEditorAutocomplete"; 8 + 9 + export interface MentionOption { 10 + handle: string; 11 + did: string; 12 + } 13 + 14 + export interface CardOption { 15 + name: string; 16 + scryfallId: string; 17 + } 18 + 19 + export type CombinedOption = MentionOption | CardOption; 20 + export type AutocompleteType = "mention" | "card"; 21 + 22 + export interface CombinedCallbacks { 23 + mention: AutocompleteCallbacks<MentionOption>; 24 + card: AutocompleteCallbacks<CardOption>; 25 + } 26 + 27 + export function createCombinedAutocompletePlugin( 28 + callbacks: CombinedCallbacks, 29 + ): Plugin[] { 30 + return autocomplete({ 31 + triggers: [ 32 + { name: "mention", trigger: "@" }, 33 + { name: "card", trigger: "[[" }, 34 + ], 35 + reducer: (action: AutocompleteAction) => { 36 + const triggerName = action.type?.name as AutocompleteType | undefined; 37 + 38 + switch (action.kind) { 39 + case ActionKind.open: { 40 + const cb = 41 + triggerName === "card" ? callbacks.card : callbacks.mention; 42 + cb.onStateChange({ 43 + active: true, 44 + query: action.filter || "", 45 + range: action.range, 46 + }); 47 + return true; 48 + } 49 + 50 + case ActionKind.filter: { 51 + const query = action.filter || ""; 52 + 53 + if (triggerName === "mention") { 54 + if (query.includes(" ")) { 55 + closeAutocomplete(action.view); 56 + return true; 57 + } 58 + callbacks.mention.onStateChange({ 59 + active: true, 60 + query, 61 + range: action.range, 62 + }); 63 + } else if (triggerName === "card") { 64 + if (query.includes("]]")) { 65 + closeAutocomplete(action.view); 66 + return true; 67 + } 68 + callbacks.card.onStateChange({ 69 + active: true, 70 + query, 71 + range: action.range, 72 + }); 73 + } 74 + return true; 75 + } 76 + 77 + case ActionKind.up: 78 + case ActionKind.down: 79 + return false; 80 + 81 + case ActionKind.enter: 82 + return false; 83 + 84 + case ActionKind.close: { 85 + callbacks.mention.onStateChange(null); 86 + callbacks.card.onStateChange(null); 87 + return true; 88 + } 89 + 90 + default: 91 + return false; 92 + } 93 + }, 94 + }); 95 + } 96 + 97 + interface MentionPopupContentProps { 98 + options: MentionOption[]; 99 + selectedIndex: number; 100 + onSelectIndex: (i: number) => void; 101 + onSelect: (option: MentionOption) => void; 102 + position: { top: number; left: number }; 103 + } 104 + 105 + export function MentionPopupContent({ 106 + options, 107 + selectedIndex, 108 + onSelectIndex, 109 + onSelect, 110 + position, 111 + }: MentionPopupContentProps) { 112 + return ( 113 + <div 114 + 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" 115 + style={{ top: position.top, left: position.left }} 116 + role="listbox" 117 + > 118 + {options.length === 0 ? ( 119 + <div className="px-3 py-2 text-sm text-gray-500 dark:text-gray-400"> 120 + No matches 121 + </div> 122 + ) : ( 123 + options.map((option, i) => ( 124 + <button 125 + key={option.handle} 126 + type="button" 127 + role="option" 128 + aria-selected={i === selectedIndex} 129 + className={`w-full px-3 py-2 text-left text-sm ${ 130 + i === selectedIndex 131 + ? "bg-cyan-100 dark:bg-cyan-900/30 text-cyan-900 dark:text-cyan-100" 132 + : "text-gray-900 dark:text-gray-100 hover:bg-gray-100 dark:hover:bg-slate-700" 133 + }`} 134 + onMouseEnter={() => onSelectIndex(i)} 135 + onMouseDown={(e) => { 136 + e.preventDefault(); 137 + onSelect(option); 138 + }} 139 + > 140 + <span className="truncate">@{option.handle}</span> 141 + </button> 142 + )) 143 + )} 144 + </div> 145 + ); 146 + } 147 + 148 + interface CardPopupContentProps { 149 + options: CardOption[]; 150 + selectedIndex: number; 151 + onSelectIndex: (i: number) => void; 152 + onSelect: (option: CardOption) => void; 153 + position: { top: number; left: number }; 154 + } 155 + 156 + export function CardPopupContent({ 157 + options, 158 + selectedIndex, 159 + onSelectIndex, 160 + onSelect, 161 + position, 162 + }: CardPopupContentProps) { 163 + return ( 164 + <div 165 + 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-64 max-h-64 overflow-y-auto" 166 + style={{ top: position.top, left: position.left }} 167 + role="listbox" 168 + > 169 + {options.length === 0 ? ( 170 + <div className="px-3 py-2 text-sm text-gray-500 dark:text-gray-400"> 171 + No cards found 172 + </div> 173 + ) : ( 174 + options.map((option, i) => ( 175 + <button 176 + key={option.scryfallId} 177 + type="button" 178 + role="option" 179 + aria-selected={i === selectedIndex} 180 + className={`w-full px-3 py-2 text-left text-sm ${ 181 + i === selectedIndex 182 + ? "bg-amber-100 dark:bg-amber-900/30 text-amber-900 dark:text-amber-100" 183 + : "text-gray-900 dark:text-gray-100 hover:bg-gray-100 dark:hover:bg-slate-700" 184 + }`} 185 + onMouseEnter={() => onSelectIndex(i)} 186 + onMouseDown={(e) => { 187 + e.preventDefault(); 188 + onSelect(option); 189 + }} 190 + > 191 + <span className="truncate">{option.name}</span> 192 + </button> 193 + )) 194 + )} 195 + </div> 196 + ); 197 + }
-110
src/components/richtext/MentionAutocomplete.tsx
··· 1 - import autocomplete, { 2 - ActionKind, 3 - type AutocompleteAction, 4 - closeAutocomplete, 5 - } from "prosemirror-autocomplete"; 6 - import type { Plugin } from "prosemirror-state"; 7 - import type { AutocompleteCallbacks } from "./useEditorAutocomplete"; 8 - 9 - export interface MentionOption { 10 - handle: string; 11 - did: string; 12 - } 13 - 14 - export function createMentionPlugin( 15 - callbacks: AutocompleteCallbacks<MentionOption>, 16 - ): Plugin[] { 17 - return autocomplete({ 18 - triggers: [{ name: "mention", trigger: "@" }], 19 - reducer: (action: AutocompleteAction) => { 20 - switch (action.kind) { 21 - case ActionKind.open: 22 - callbacks.onStateChange({ 23 - active: true, 24 - query: action.filter || "", 25 - range: action.range, 26 - }); 27 - return true; 28 - 29 - case ActionKind.filter: { 30 - const query = action.filter || ""; 31 - if (query.includes(" ")) { 32 - closeAutocomplete(action.view); 33 - return true; 34 - } 35 - callbacks.onStateChange({ 36 - active: true, 37 - query, 38 - range: action.range, 39 - }); 40 - return true; 41 - } 42 - 43 - case ActionKind.up: 44 - case ActionKind.down: 45 - return false; 46 - 47 - case ActionKind.enter: 48 - return false; // Let hook handle selection 49 - 50 - case ActionKind.close: 51 - callbacks.onStateChange(null); 52 - return true; 53 - 54 - default: 55 - return false; 56 - } 57 - }, 58 - }); 59 - } 60 - 61 - interface MentionPopupContentProps { 62 - options: MentionOption[]; 63 - selectedIndex: number; 64 - onSelectIndex: (i: number) => void; 65 - onSelect: (option: MentionOption) => void; 66 - position: { top: number; left: number }; 67 - } 68 - 69 - export function MentionPopupContent({ 70 - options, 71 - selectedIndex, 72 - onSelectIndex, 73 - onSelect, 74 - position, 75 - }: MentionPopupContentProps) { 76 - return ( 77 - <div 78 - 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" 79 - style={{ top: position.top, left: position.left }} 80 - role="listbox" 81 - > 82 - {options.length === 0 ? ( 83 - <div className="px-3 py-2 text-sm text-gray-500 dark:text-gray-400"> 84 - No matches 85 - </div> 86 - ) : ( 87 - options.map((option, i) => ( 88 - <button 89 - key={option.handle} 90 - type="button" 91 - role="option" 92 - aria-selected={i === selectedIndex} 93 - className={`w-full px-3 py-2 text-left text-sm ${ 94 - i === selectedIndex 95 - ? "bg-cyan-100 dark:bg-cyan-900/30 text-cyan-900 dark:text-cyan-100" 96 - : "text-gray-900 dark:text-gray-100 hover:bg-gray-100 dark:hover:bg-slate-700" 97 - }`} 98 - onMouseEnter={() => onSelectIndex(i)} 99 - onMouseDown={(e) => { 100 - e.preventDefault(); 101 - onSelect(option); 102 - }} 103 - > 104 - <span className="truncate">@{option.handle}</span> 105 - </button> 106 - )) 107 - )} 108 - </div> 109 - ); 110 - }
+60 -5
src/components/richtext/ProseMirrorEditor.tsx
··· 9 9 } from "prosemirror-schema-list"; 10 10 import { EditorState } from "prosemirror-state"; 11 11 import { EditorView } from "prosemirror-view"; 12 - import { useCallback, useEffect, useRef, useState } from "react"; 12 + import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 13 13 import { 14 14 type ActorSearchResult, 15 15 searchActorsQueryOptions, 16 16 } from "@/lib/actor-search"; 17 - import { buildInputRules } from "./inputRules"; 17 + import { searchCardsQueryOptions } from "@/lib/queries"; 18 + import type { Card } from "@/lib/search-types"; 18 19 import { 19 - createMentionPlugin, 20 + type CardOption, 21 + CardPopupContent, 22 + type CombinedCallbacks, 23 + createCombinedAutocompletePlugin, 20 24 type MentionOption, 21 25 MentionPopupContent, 22 - } from "./MentionAutocomplete"; 26 + } from "./CombinedAutocomplete"; 27 + import { buildInputRules } from "./inputRules"; 23 28 import { createUpdatePlugin } from "./plugins"; 24 29 import { schema } from "./schema"; 25 30 import { Toolbar } from "./Toolbar"; ··· 94 99 renderPopup: (props) => <MentionPopupContent {...props} />, 95 100 }); 96 101 102 + // Card autocomplete 103 + const handleCardSelect = useCallback( 104 + ( 105 + option: CardOption, 106 + state: { range: { from: number; to: number } }, 107 + view: EditorView, 108 + ) => { 109 + const cardRefNode = schema.nodes.cardRef.create({ 110 + name: option.name, 111 + scryfallId: option.scryfallId, 112 + }); 113 + 114 + const tr = view.state.tr 115 + .delete(state.range.from, state.range.to) 116 + .insert(state.range.from, cardRefNode) 117 + .insertText(" ", state.range.from + 1); 118 + 119 + view.dispatch(tr); 120 + view.focus(); 121 + }, 122 + [], 123 + ); 124 + 125 + const getCardQueryOptions = useCallback( 126 + (query: string) => ({ 127 + ...searchCardsQueryOptions(query, undefined, 10), 128 + select: (result: { cards: Card[]; totalCount: number }): CardOption[] => 129 + result.cards.map((c) => ({ name: c.name, scryfallId: c.id })), 130 + }), 131 + [], 132 + ); 133 + 134 + const card = useEditorAutocomplete({ 135 + viewRef, 136 + containerRef: wrapperRef, 137 + getQueryOptions: getCardQueryOptions, 138 + onSelect: handleCardSelect, 139 + renderPopup: (props) => <CardPopupContent {...props} />, 140 + }); 141 + 142 + // Combined callbacks for the single autocomplete plugin 143 + const combinedCallbacks: CombinedCallbacks = useMemo( 144 + () => ({ 145 + mention: mention.callbacks, 146 + card: card.callbacks, 147 + }), 148 + [mention.callbacks, card.callbacks], 149 + ); 150 + 97 151 // biome-ignore lint/correctness/useExhaustiveDependencies: editor created once on mount 98 152 useEffect(() => { 99 153 if (!containerRef.current) return; ··· 104 158 schema.node("doc", null, [schema.node("paragraph")]), 105 159 schema, 106 160 plugins: [ 107 - ...createMentionPlugin(mention.callbacks), 161 + ...createCombinedAutocompletePlugin(combinedCallbacks), 108 162 buildInputRules(schema), 109 163 history(), 110 164 keymap({ ··· 158 212 <div ref={containerRef} /> 159 213 </div> 160 214 {mention.popup} 215 + {card.popup} 161 216 </div> 162 217 ); 163 218 }
+11
src/components/richtext/RichtextRenderer.tsx
··· 252 252 </Link> 253 253 ); 254 254 255 + case "com.deckbelcher.richtext.facet#cardRef": 256 + return ( 257 + <Link 258 + to="/card/$id" 259 + params={{ id: feature.scryfallId }} 260 + className="inline-flex items-center px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-900/50 text-amber-700 dark:text-amber-300 text-sm font-medium hover:bg-amber-200 dark:hover:bg-amber-900/70" 261 + > 262 + {content} 263 + </Link> 264 + ); 265 + 255 266 default: 256 267 return content; 257 268 }
+35
src/components/richtext/schema.ts
··· 225 225 }, 226 226 ], 227 227 }, 228 + 229 + cardRef: { 230 + inline: true, 231 + group: "inline", 232 + atom: true, 233 + attrs: { 234 + name: { default: "" }, 235 + scryfallId: { default: "" }, 236 + }, 237 + toDOM(node) { 238 + return [ 239 + "span", 240 + { 241 + class: 242 + "inline-flex items-center px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-900/50 text-amber-700 dark:text-amber-300 text-sm font-medium", 243 + "data-cardref": "", 244 + "data-name": node.attrs.name, 245 + "data-scryfall-id": node.attrs.scryfallId, 246 + }, 247 + node.attrs.name, 248 + ]; 249 + }, 250 + parseDOM: [ 251 + { 252 + tag: "span[data-cardref]", 253 + getAttrs(dom) { 254 + if (typeof dom === "string") return false; 255 + return { 256 + name: dom.getAttribute("data-name") ?? "", 257 + scryfallId: dom.getAttribute("data-scryfall-id") ?? "", 258 + }; 259 + }, 260 + }, 261 + ], 262 + }, 228 263 }; 229 264 230 265 const marks: Record<string, MarkSpec> = {
+11
src/lib/lexicons/types/com/deckbelcher/richtext/facet.ts
··· 19 19 */ 20 20 byteStart: /*#__PURE__*/ v.integer(), 21 21 }); 22 + const _cardRefSchema = /*#__PURE__*/ v.object({ 23 + $type: /*#__PURE__*/ v.optional( 24 + /*#__PURE__*/ v.literal("com.deckbelcher.richtext.facet#cardRef"), 25 + ), 26 + scryfallId: /*#__PURE__*/ v.string(), 27 + }); 22 28 const _codeSchema = /*#__PURE__*/ v.object({ 23 29 $type: /*#__PURE__*/ v.optional( 24 30 /*#__PURE__*/ v.literal("com.deckbelcher.richtext.facet#code"), ··· 48 54 return /*#__PURE__*/ v.array( 49 55 /*#__PURE__*/ v.variant([ 50 56 boldSchema, 57 + cardRefSchema, 51 58 codeSchema, 52 59 codeBlockSchema, 53 60 italicSchema, ··· 83 90 84 91 type bold$schematype = typeof _boldSchema; 85 92 type byteSlice$schematype = typeof _byteSliceSchema; 93 + type cardRef$schematype = typeof _cardRefSchema; 86 94 type code$schematype = typeof _codeSchema; 87 95 type codeBlock$schematype = typeof _codeBlockSchema; 88 96 type italic$schematype = typeof _italicSchema; ··· 93 101 94 102 export interface boldSchema extends bold$schematype {} 95 103 export interface byteSliceSchema extends byteSlice$schematype {} 104 + export interface cardRefSchema extends cardRef$schematype {} 96 105 export interface codeSchema extends code$schematype {} 97 106 export interface codeBlockSchema extends codeBlock$schematype {} 98 107 export interface italicSchema extends italic$schematype {} ··· 103 112 104 113 export const boldSchema = _boldSchema as boldSchema; 105 114 export const byteSliceSchema = _byteSliceSchema as byteSliceSchema; 115 + export const cardRefSchema = _cardRefSchema as cardRefSchema; 106 116 export const codeSchema = _codeSchema as codeSchema; 107 117 export const codeBlockSchema = _codeBlockSchema as codeBlockSchema; 108 118 export const italicSchema = _italicSchema as italicSchema; ··· 113 123 114 124 export interface Bold extends v.InferInput<typeof boldSchema> {} 115 125 export interface ByteSlice extends v.InferInput<typeof byteSliceSchema> {} 126 + export interface CardRef extends v.InferInput<typeof cardRefSchema> {} 116 127 export interface Code extends v.InferInput<typeof codeSchema> {} 117 128 export interface CodeBlock extends v.InferInput<typeof codeBlockSchema> {} 118 129 export interface Italic extends v.InferInput<typeof italicSchema> {}
+40 -1
src/lib/richtext-convert.ts
··· 260 260 } 261 261 262 262 byteOffset += textBytes.length; 263 + } else if (child.type.name === "cardRef") { 264 + const name = (child.attrs.name as string) || ""; 265 + const scryfallId = (child.attrs.scryfallId as string) || ""; 266 + const textBytes = new TextEncoder().encode(name); 267 + 268 + textParts.push(name); 269 + 270 + if (scryfallId) { 271 + facets.push({ 272 + index: { 273 + byteStart: byteOffset, 274 + byteEnd: byteOffset + textBytes.length, 275 + }, 276 + features: [ 277 + { 278 + $type: "com.deckbelcher.richtext.facet#cardRef", 279 + scryfallId, 280 + }, 281 + ], 282 + }); 283 + } 284 + 285 + byteOffset += textBytes.length; 263 286 } 264 - // TODO: handle cardRef and other inline nodes 265 287 }); 266 288 267 289 // Close any remaining active marks ··· 515 537 schema.nodes.mention.create({ 516 538 handle, 517 539 did: mentionFeature.did || null, 540 + }), 541 + ); 542 + continue; 543 + } 544 + 545 + // Check for cardRef facet - these become inline nodes 546 + const cardRefFeature = segment.features.find( 547 + (f) => 548 + (f as { $type?: string }).$type === 549 + "com.deckbelcher.richtext.facet#cardRef", 550 + ) as { $type: string; scryfallId?: string } | undefined; 551 + 552 + if (cardRefFeature) { 553 + nodes.push( 554 + schema.nodes.cardRef.create({ 555 + name: segment.text, 556 + scryfallId: cardRefFeature.scryfallId || "", 518 557 }), 519 558 ); 520 559 continue;
+11 -1
typelex/richtext-facet.tsp
··· 11 11 index: ByteSlice; 12 12 13 13 @required 14 - features: (Mention | Link | Tag | Bold | Italic | Code | CodeBlock | unknown)[]; 14 + features: (Mention | Link | Tag | Bold | Italic | Code | CodeBlock | CardRef | unknown)[]; 15 15 } 16 16 17 17 /** ··· 81 81 * Typically rendered as `<pre><code>` in HTML. 82 82 */ 83 83 model CodeBlock {} 84 + 85 + /** 86 + * Facet feature for a card reference. 87 + * Links to a Magic: The Gathering card by Scryfall ID. 88 + * The text is usually the card name. 89 + */ 90 + model CardRef { 91 + @required 92 + scryfallId: string; 93 + } 84 94 }