a tool for shared writing and social publishing

add did mention facet

+140 -11
+5
actions/publishToPublication.ts
··· 544 544 $type: "pub.leaflet.richtext.facet#strikethrough", 545 545 }); 546 546 547 + if (d.attributes?.didMention) 548 + facet.features.push({ 549 + $type: "pub.leaflet.richtext.facet#didMention", 550 + did: d.attributes.didMention.did, 551 + }); 547 552 if (d.attributes?.code) 548 553 facet.features.push({ $type: "pub.leaflet.richtext.facet#code" }); 549 554 if (d.attributes?.highlight)
+2 -1
components/Blocks/TextBlock/RenderYJSFragment.tsx
··· 27 27 return ( 28 28 <BlockWrapper wrapper={wrapper} attrs={attrs}> 29 29 {children.length === 0 ? ( 30 - <div /> 30 + <br /> 31 31 ) : ( 32 32 node.toArray().map((node, index) => { 33 33 if (node.constructor === XmlText) { ··· 103 103 strong?: {}; 104 104 code?: {}; 105 105 em?: {}; 106 + didMention?: { did: string }; 106 107 underline?: {}; 107 108 strikethrough?: {}; 108 109 highlight?: { color: string };
+57 -10
components/Blocks/TextBlock/index.tsx
··· 1 - import { useRef, useEffect, useState } from "react"; 1 + import { useRef, useEffect, useState, useCallback } from "react"; 2 2 import { elementId } from "src/utils/elementId"; 3 3 import { useReplicache, useEntity } from "src/replicache"; 4 4 import { isVisible } from "src/utils/isVisible"; 5 5 import { EditorState, TextSelection } from "prosemirror-state"; 6 + import { EditorView } from "prosemirror-view"; 6 7 import { RenderYJSFragment } from "./RenderYJSFragment"; 7 8 import { useInitialPageLoad } from "components/InitialPageLoadProvider"; 8 9 import { BlockProps } from "../Block"; ··· 23 24 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 24 25 import { DotLoader } from "components/utils/DotLoader"; 25 26 import { useMountProsemirror } from "./mountProsemirror"; 27 + import { schema } from "./schema"; 28 + import { MentionAutocomplete } from "app/[leaflet_id]/publish/BskyPostEditorProsemirror"; 26 29 27 30 const HeadingStyle = { 28 31 1: "text-xl font-bold", ··· 183 186 let editorState = useEditorStates( 184 187 (s) => s.editorStates[props.entityID], 185 188 )?.editor; 189 + let { viewRef, handleMentionSelect, setMentionState } = useMentionState( 190 + props.entityID, 191 + ); 186 192 187 - let { mountRef, actionTimeout } = useMountProsemirror({ 188 - props, 189 - }); 193 + let { mountRef, actionTimeout } = useMountProsemirror({ props }); 190 194 191 195 return ( 192 196 <> ··· 199 203 ? "blockquote pt-3" 200 204 : "blockquote" 201 205 : "" 202 - } 203 - 204 - `} 206 + }`} 205 207 > 206 208 <pre 207 209 data-entityid={props.entityID} ··· 249 251 ${props.className}`} 250 252 ref={mountRef} 251 253 /> 254 + {editorState && focused && ( 255 + <MentionAutocomplete 256 + editorState={editorState} 257 + view={viewRef} 258 + onSelect={handleMentionSelect} 259 + onMentionStateChange={(active, range, selectedMention) => { 260 + setMentionState({ active, range, selectedMention }); 261 + }} 262 + /> 263 + )} 252 264 {editorState?.doc.textContent.length === 0 && 253 265 props.previousBlock === null && 254 266 props.nextBlock === null ? ( ··· 439 451 ); 440 452 }; 441 453 442 - const useMentionState = () => { 443 - const [editorState, setEditorState] = useState<EditorState | null>(null); 454 + const useMentionState = (entityID: string) => { 455 + let view = useEditorStates((s) => s.editorStates[entityID])?.view; 456 + let viewRef = useRef(view || null); 457 + viewRef.current = view || null; 444 458 const [mentionState, setMentionState] = useState<{ 445 459 active: boolean; 446 460 range: { from: number; to: number } | null; ··· 448 462 }>({ active: false, range: null, selectedMention: null }); 449 463 const mentionStateRef = useRef(mentionState); 450 464 mentionStateRef.current = mentionState; 451 - return { mentionStateRef }; 465 + 466 + const handleMentionSelect = useCallback( 467 + ( 468 + mention: { handle: string; did: string }, 469 + range: { from: number; to: number }, 470 + ) => { 471 + let view = useEditorStates.getState().editorStates[entityID]?.view; 472 + if (!view) return; 473 + const { from, to } = range; 474 + const tr = view.state.tr; 475 + 476 + // Delete the query text (keep the @) 477 + tr.delete(from + 1, to); 478 + 479 + // Insert the mention text after the @ 480 + const mentionText = mention.handle; 481 + tr.insertText(mentionText, from + 1); 482 + 483 + // Apply mention mark to @ and handle 484 + tr.addMark( 485 + from, 486 + from + 1 + mentionText.length, 487 + schema.marks.didMention.create({ did: mention.did }), 488 + ); 489 + 490 + // Add a space after the mention 491 + tr.insertText(" ", from + 1 + mentionText.length); 492 + 493 + view.dispatch(tr); 494 + view.focus(); 495 + }, 496 + [], 497 + ); 498 + return { mentionStateRef, handleMentionSelect, viewRef, setMentionState }; 452 499 };
+27
components/Blocks/TextBlock/schema.ts
··· 103 103 return ["a", { href, target: "_blank" }, 0]; 104 104 }, 105 105 } as MarkSpec, 106 + 107 + didMention: { 108 + attrs: { 109 + did: {}, 110 + }, 111 + inclusive: false, 112 + parseDOM: [ 113 + { 114 + tag: "span.didMention", 115 + getAttrs(dom: HTMLElement) { 116 + return { 117 + did: dom.getAttribute("data-did"), 118 + }; 119 + }, 120 + }, 121 + ], 122 + toDOM(node) { 123 + return [ 124 + "span", 125 + { 126 + class: "didMention text-accent-contrast", 127 + "data-did": node.attrs.did, 128 + }, 129 + 0, 130 + ]; 131 + }, 132 + } as MarkSpec, 106 133 }, 107 134 nodes: { 108 135 doc: { content: "block" },
+12
lexicons/api/lexicons.ts
··· 1861 1861 type: 'union', 1862 1862 refs: [ 1863 1863 'lex:pub.leaflet.richtext.facet#link', 1864 + 'lex:pub.leaflet.richtext.facet#didMention', 1864 1865 'lex:pub.leaflet.richtext.facet#code', 1865 1866 'lex:pub.leaflet.richtext.facet#highlight', 1866 1867 'lex:pub.leaflet.richtext.facet#underline', ··· 1897 1898 properties: { 1898 1899 uri: { 1899 1900 type: 'string', 1901 + }, 1902 + }, 1903 + }, 1904 + didMention: { 1905 + type: 'object', 1906 + description: 'Facet feature for mentioning a did.', 1907 + required: ['did'], 1908 + properties: { 1909 + did: { 1910 + type: 'string', 1911 + format: 'did', 1900 1912 }, 1901 1913 }, 1902 1914 },
+17
lexicons/api/types/pub/leaflet/richtext/facet.ts
··· 20 20 index: ByteSlice 21 21 features: ( 22 22 | $Typed<Link> 23 + | $Typed<DidMention> 23 24 | $Typed<Code> 24 25 | $Typed<Highlight> 25 26 | $Typed<Underline> ··· 72 73 73 74 export function validateLink<V>(v: V) { 74 75 return validate<Link & V>(v, id, hashLink) 76 + } 77 + 78 + /** Facet feature for mentioning a did. */ 79 + export interface DidMention { 80 + $type?: 'pub.leaflet.richtext.facet#didMention' 81 + did: string 82 + } 83 + 84 + const hashDidMention = 'didMention' 85 + 86 + export function isDidMention<V>(v: V) { 87 + return is$typed(v, id, hashDidMention) 88 + } 89 + 90 + export function validateDidMention<V>(v: V) { 91 + return validate<DidMention & V>(v, id, hashDidMention) 75 92 } 76 93 77 94 /** Facet feature for inline code. */
+14
lexicons/pub/leaflet/richtext/facet.json
··· 20 20 "type": "union", 21 21 "refs": [ 22 22 "#link", 23 + "#didMention", 23 24 "#code", 24 25 "#highlight", 25 26 "#underline", ··· 59 60 "properties": { 60 61 "uri": { 61 62 "type": "string" 63 + } 64 + } 65 + }, 66 + "didMention": { 67 + "type": "object", 68 + "description": "Facet feature for mentioning a did.", 69 + "required": [ 70 + "did" 71 + ], 72 + "properties": { 73 + "did": { 74 + "type": "string", 75 + "format": "did" 62 76 } 63 77 } 64 78 },
+6
lexicons/src/facet.ts
··· 9 9 uri: { type: "string" }, 10 10 }, 11 11 }, 12 + didMention: { 13 + type: "object", 14 + description: "Facet feature for mentioning a did.", 15 + required: ["did"], 16 + properties: { did: { type: "string", format: "did" } }, 17 + }, 12 18 code: { 13 19 type: "object", 14 20 description: "Facet feature for inline code.",