a tool for shared writing and social publishing

WIP styling the popover for mentions

+305 -286
+4 -281
app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx
··· 1 1 "use client"; 2 - import { Agent, AppBskyRichtextFacet, UnicodeString } from "@atproto/api"; 3 - import { 4 - useState, 5 - useCallback, 6 - useRef, 7 - useLayoutEffect, 8 - useEffect, 9 - } from "react"; 10 - import { createPortal } from "react-dom"; 11 - import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; 12 - import * as Popover from "@radix-ui/react-popover"; 13 - import { EditorState, TextSelection, Plugin } from "prosemirror-state"; 2 + import { AppBskyRichtextFacet, UnicodeString } from "@atproto/api"; 3 + import { useState, useCallback, useRef, useLayoutEffect } from "react"; 4 + import { EditorState } from "prosemirror-state"; 14 5 import { EditorView } from "prosemirror-view"; 15 6 import { Schema, MarkSpec, Mark } from "prosemirror-model"; 16 7 import { baseKeymap } from "prosemirror-commands"; ··· 19 10 import { inputRules, InputRule } from "prosemirror-inputrules"; 20 11 import { autolink } from "components/Blocks/TextBlock/autolink-plugin"; 21 12 import { IOSBS } from "app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox"; 22 - import { callRPC } from "app/api/rpc/client"; 23 13 import { schema } from "components/Blocks/TextBlock/schema"; 14 + import { Mention, MentionAutocomplete } from "components/Mention"; 24 15 25 16 // Schema with only links, mentions, and hashtags marks 26 17 const bskyPostSchema = new Schema({ ··· 290 281 <IOSBS view={viewRef} /> 291 282 </div> 292 283 ); 293 - } 294 - 295 - export function MentionAutocomplete(props: { 296 - editorState: EditorState; 297 - view: React.RefObject<EditorView | null>; 298 - onSelect: (mention: Mention, range: { from: number; to: number }) => void; 299 - onMentionStateChange: ( 300 - active: boolean, 301 - range: { from: number; to: number } | null, 302 - selectedMention: Mention | null, 303 - ) => void; 304 - }) { 305 - const [mentionQuery, setMentionQuery] = useState<string | null>(null); 306 - const [mentionRange, setMentionRange] = useState<{ 307 - from: number; 308 - to: number; 309 - } | null>(null); 310 - const [mentionCoords, setMentionCoords] = useState<{ 311 - top: number; 312 - left: number; 313 - } | null>(null); 314 - 315 - const { suggestionIndex, setSuggestionIndex, suggestions } = 316 - useMentionSuggestions(mentionQuery); 317 - 318 - // Check for mention pattern whenever editor state changes 319 - useEffect(() => { 320 - const { $from } = props.editorState.selection; 321 - const textBefore = $from.parent.textBetween( 322 - Math.max(0, $from.parentOffset - 50), 323 - $from.parentOffset, 324 - null, 325 - "\ufffc", 326 - ); 327 - 328 - // Look for @ followed by word characters before cursor 329 - const match = textBefore.match(/(?:^|\s)@([\w.]*)$/); 330 - 331 - if (match && props.view.current) { 332 - const queryBefore = match[1]; 333 - const from = $from.pos - queryBefore.length - 1; 334 - 335 - // Get text after cursor to find the rest of the handle 336 - const textAfter = $from.parent.textBetween( 337 - $from.parentOffset, 338 - Math.min($from.parent.content.size, $from.parentOffset + 50), 339 - null, 340 - "\ufffc", 341 - ); 342 - 343 - // Match word characters after cursor until space or end 344 - const afterMatch = textAfter.match(/^([\w.]*)/); 345 - const queryAfter = afterMatch ? afterMatch[1] : ""; 346 - 347 - // Combine the full handle 348 - const query = queryBefore + queryAfter; 349 - const to = $from.pos + queryAfter.length; 350 - 351 - setMentionQuery(query); 352 - setMentionRange({ from, to }); 353 - 354 - // Get coordinates for the autocomplete popup 355 - const coords = props.view.current.coordsAtPos(from); 356 - setMentionCoords({ 357 - top: coords.bottom + window.scrollY, 358 - left: coords.left + window.scrollX, 359 - }); 360 - setSuggestionIndex(0); 361 - } else { 362 - setMentionQuery(null); 363 - setMentionRange(null); 364 - setMentionCoords(null); 365 - } 366 - }, [props.editorState, props.view, setSuggestionIndex]); 367 - 368 - // Update parent's mention state 369 - useEffect(() => { 370 - const active = mentionQuery !== null && suggestions.length > 0; 371 - const selectedMention = 372 - active && suggestions[suggestionIndex] 373 - ? suggestions[suggestionIndex] 374 - : null; 375 - props.onMentionStateChange(active, mentionRange, selectedMention); 376 - }, [mentionQuery, suggestions, suggestionIndex, mentionRange]); 377 - 378 - // Handle keyboard navigation for arrow keys only 379 - useEffect(() => { 380 - if (!mentionQuery || !props.view.current) return; 381 - 382 - const handleKeyDown = (e: KeyboardEvent) => { 383 - if (suggestions.length === 0) return; 384 - 385 - if (e.key === "ArrowUp") { 386 - e.preventDefault(); 387 - e.stopPropagation(); 388 - 389 - if (suggestionIndex > 0) { 390 - setSuggestionIndex((i) => i - 1); 391 - } 392 - } else if (e.key === "ArrowDown") { 393 - e.preventDefault(); 394 - e.stopPropagation(); 395 - 396 - if (suggestionIndex < suggestions.length - 1) { 397 - setSuggestionIndex((i) => i + 1); 398 - } 399 - } 400 - }; 401 - 402 - const dom = props.view.current.dom; 403 - dom.addEventListener("keydown", handleKeyDown, true); 404 - 405 - return () => { 406 - dom.removeEventListener("keydown", handleKeyDown, true); 407 - }; 408 - }, [ 409 - mentionQuery, 410 - suggestions, 411 - suggestionIndex, 412 - props.view, 413 - setSuggestionIndex, 414 - ]); 415 - 416 - if (!mentionCoords || suggestions.length === 0) return null; 417 - 418 - // The styles in this component should match the Menu styles in components/Layout.tsx 419 - 420 - let menuItemStyle = `menuItem py-0.5! text-secondary flex-col! gap-0! leading-tight text-sm truncate`; 421 - let menuItemSubtextStyle = `text-tertiary italic text-xs font-normal min-w-0 truncate`; 422 - 423 - let menuItemSelectedStyle = `bg-[var(--accent-light)]`; 424 - 425 - return ( 426 - <Popover.Root open> 427 - {createPortal( 428 - <Popover.Anchor 429 - style={{ 430 - top: mentionCoords.top, 431 - left: mentionCoords.left, 432 - position: "absolute", 433 - }} 434 - />, 435 - document.body, 436 - )} 437 - <Popover.Portal> 438 - <Popover.Content 439 - side="bottom" 440 - align="start" 441 - sideOffset={4} 442 - collisionPadding={20} 443 - onOpenAutoFocus={(e) => e.preventDefault()} 444 - className={`dropdownMenu z-20 bg-bg-page flex flex-col p-1 gap-1 border border-border rounded-md shadow-md sm:max-w-xs w-[1000px]`} 445 - > 446 - <ul className="list-none p-0 text-sm"> 447 - {suggestions.map((result, index) => { 448 - if (result.type === "did") 449 - return ( 450 - <div 451 - className={` 452 - ${menuItemStyle} 453 - ${index === suggestionIndex ? menuItemSelectedStyle : ""} 454 - 455 - `} 456 - key={result.did} 457 - onClick={() => { 458 - if (mentionRange) { 459 - props.onSelect(result, mentionRange); 460 - setMentionQuery(null); 461 - setMentionRange(null); 462 - setMentionCoords(null); 463 - } 464 - }} 465 - onMouseDown={(e) => e.preventDefault()} 466 - > 467 - {result.displayName 468 - ? result.displayName 469 - : `@${result.handle}`} 470 - {result.displayName && ( 471 - <div className={menuItemSubtextStyle}> 472 - @{result.handle} 473 - </div> 474 - )} 475 - </div> 476 - ); 477 - if (result.type == "publication") { 478 - return ( 479 - <div 480 - className={` 481 - ${menuItemStyle} 482 - ${index === suggestionIndex ? menuItemSelectedStyle : ""} 483 - `} 484 - key={result.uri} 485 - onClick={() => { 486 - if (mentionRange) { 487 - props.onSelect(result, mentionRange); 488 - setMentionQuery(null); 489 - setMentionRange(null); 490 - setMentionCoords(null); 491 - } 492 - }} 493 - onMouseDown={(e) => e.preventDefault()} 494 - > 495 - {result.name} 496 - <div className={menuItemSubtextStyle}> 497 - Leaflet Publication 498 - </div> 499 - </div> 500 - ); 501 - } 502 - })} 503 - </ul> 504 - </Popover.Content> 505 - </Popover.Portal> 506 - </Popover.Root> 507 - ); 508 - } 509 - 510 - export type Mention = 511 - | { type: "did"; handle: string; did: string; displayName?: string } 512 - | { type: "publication"; uri: string; name: string }; 513 - function useMentionSuggestions(query: string | null) { 514 - const [suggestionIndex, setSuggestionIndex] = useState(0); 515 - const [suggestions, setSuggestions] = useState<Array<Mention>>([]); 516 - 517 - useDebouncedEffect( 518 - async () => { 519 - if (!query) { 520 - setSuggestions([]); 521 - return; 522 - } 523 - 524 - const agent = new Agent("https://public.api.bsky.app"); 525 - const [result, publications] = await Promise.all([ 526 - agent.searchActorsTypeahead({ 527 - q: query, 528 - limit: 8, 529 - }), 530 - callRPC(`search_publication_names`, { query, limit: 8 }), 531 - ]); 532 - setSuggestions([ 533 - ...result.data.actors.map((actor) => ({ 534 - type: "did" as const, 535 - handle: actor.handle, 536 - did: actor.did, 537 - displayName: actor.displayName, 538 - })), 539 - ...publications.result.publications.map((p) => ({ 540 - type: "publication" as const, 541 - uri: p.uri, 542 - name: p.name, 543 - })), 544 - ]); 545 - }, 546 - 300, 547 - [query], 548 - ); 549 - 550 - useEffect(() => { 551 - if (suggestionIndex > suggestions.length - 1) { 552 - setSuggestionIndex(Math.max(0, suggestions.length - 1)); 553 - } 554 - }, [suggestionIndex, suggestions.length]); 555 - 556 - return { 557 - suggestions, 558 - suggestionIndex, 559 - setSuggestionIndex, 560 - }; 561 284 } 562 285 563 286 /**
+3 -5
components/Blocks/TextBlock/index.tsx
··· 25 25 import { DotLoader } from "components/utils/DotLoader"; 26 26 import { useMountProsemirror } from "./mountProsemirror"; 27 27 import { schema } from "./schema"; 28 - import { 29 - addMentionToEditor, 30 - Mention, 31 - MentionAutocomplete, 32 - } from "app/[leaflet_id]/publish/BskyPostEditorProsemirror"; 28 + 29 + import { Mention, MentionAutocomplete } from "components/Mention"; 30 + import { addMentionToEditor } from "app/[leaflet_id]/publish/BskyPostEditorProsemirror"; 33 31 34 32 const HeadingStyle = { 35 33 1: "text-xl font-bold",
+298
components/Mention.tsx
··· 1 + "use client"; 2 + import { Agent } from "@atproto/api"; 3 + import { useState, useEffect } from "react"; 4 + import { createPortal } from "react-dom"; 5 + import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; 6 + import * as Popover from "@radix-ui/react-popover"; 7 + import { EditorState } from "prosemirror-state"; 8 + import { EditorView } from "prosemirror-view"; 9 + import { callRPC } from "app/api/rpc/client"; 10 + import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; 11 + import { onMouseDown } from "src/utils/iosInputMouseDown"; 12 + 13 + export function MentionAutocomplete(props: { 14 + editorState: EditorState; 15 + view: React.RefObject<EditorView | null>; 16 + onSelect: (mention: Mention, range: { from: number; to: number }) => void; 17 + onMentionStateChange: ( 18 + active: boolean, 19 + range: { from: number; to: number } | null, 20 + selectedMention: Mention | null, 21 + ) => void; 22 + }) { 23 + const [mentionQuery, setMentionQuery] = useState<string | null>(null); 24 + const [mentionRange, setMentionRange] = useState<{ 25 + from: number; 26 + to: number; 27 + } | null>(null); 28 + const [mentionCoords, setMentionCoords] = useState<{ 29 + top: number; 30 + left: number; 31 + } | null>(null); 32 + 33 + const { suggestionIndex, setSuggestionIndex, suggestions } = 34 + useMentionSuggestions(mentionQuery); 35 + 36 + // Check for mention pattern whenever editor state changes 37 + useEffect(() => { 38 + const { $from } = props.editorState.selection; 39 + const textBefore = $from.parent.textBetween( 40 + Math.max(0, $from.parentOffset - 50), 41 + $from.parentOffset, 42 + null, 43 + "\ufffc", 44 + ); 45 + 46 + // Look for @ followed by word characters before cursor 47 + const match = textBefore.match(/(?:^|\s)@([\w.]*)$/); 48 + 49 + if (match && props.view.current) { 50 + const queryBefore = match[1]; 51 + const from = $from.pos - queryBefore.length - 1; 52 + 53 + // Get text after cursor to find the rest of the handle 54 + const textAfter = $from.parent.textBetween( 55 + $from.parentOffset, 56 + Math.min($from.parent.content.size, $from.parentOffset + 50), 57 + null, 58 + "\ufffc", 59 + ); 60 + 61 + // Match word characters after cursor until space or end 62 + const afterMatch = textAfter.match(/^([\w.]*)/); 63 + const queryAfter = afterMatch ? afterMatch[1] : ""; 64 + 65 + // Combine the full handle 66 + const query = queryBefore + queryAfter; 67 + const to = $from.pos + queryAfter.length; 68 + 69 + setMentionQuery(query); 70 + setMentionRange({ from, to }); 71 + 72 + // Get coordinates for the autocomplete popup 73 + const coords = props.view.current.coordsAtPos(from); 74 + setMentionCoords({ 75 + top: coords.bottom + window.scrollY, 76 + left: coords.left + window.scrollX, 77 + }); 78 + setSuggestionIndex(0); 79 + } else { 80 + setMentionQuery(null); 81 + setMentionRange(null); 82 + setMentionCoords(null); 83 + } 84 + }, [props.editorState, props.view, setSuggestionIndex]); 85 + 86 + // Update parent's mention state 87 + useEffect(() => { 88 + const active = mentionQuery !== null && suggestions.length > 0; 89 + const selectedMention = 90 + active && suggestions[suggestionIndex] 91 + ? suggestions[suggestionIndex] 92 + : null; 93 + props.onMentionStateChange(active, mentionRange, selectedMention); 94 + }, [mentionQuery, suggestions, suggestionIndex, mentionRange]); 95 + 96 + // Handle keyboard navigation for arrow keys only 97 + useEffect(() => { 98 + if (!mentionQuery || !props.view.current) return; 99 + 100 + const handleKeyDown = (e: KeyboardEvent) => { 101 + if (suggestions.length === 0) return; 102 + 103 + if (e.key === "ArrowUp") { 104 + e.preventDefault(); 105 + e.stopPropagation(); 106 + 107 + if (suggestionIndex > 0) { 108 + setSuggestionIndex((i) => i - 1); 109 + } 110 + } else if (e.key === "ArrowDown") { 111 + e.preventDefault(); 112 + e.stopPropagation(); 113 + 114 + if (suggestionIndex < suggestions.length - 1) { 115 + setSuggestionIndex((i) => i + 1); 116 + } 117 + } 118 + }; 119 + 120 + const dom = props.view.current.dom; 121 + dom.addEventListener("keydown", handleKeyDown, true); 122 + 123 + return () => { 124 + dom.removeEventListener("keydown", handleKeyDown, true); 125 + }; 126 + }, [ 127 + mentionQuery, 128 + suggestions, 129 + suggestionIndex, 130 + props.view, 131 + setSuggestionIndex, 132 + ]); 133 + 134 + if (!mentionCoords || suggestions.length === 0) return null; 135 + let headerStyle = "text-xs text-tertiary font-bold italic pt-1 px-2"; 136 + 137 + return ( 138 + <Popover.Root open> 139 + {createPortal( 140 + <Popover.Anchor 141 + style={{ 142 + top: mentionCoords.top, 143 + left: mentionCoords.left, 144 + position: "absolute", 145 + }} 146 + />, 147 + document.body, 148 + )} 149 + <Popover.Portal> 150 + <Popover.Content 151 + side="bottom" 152 + align="start" 153 + sideOffset={4} 154 + collisionPadding={20} 155 + onOpenAutoFocus={(e) => e.preventDefault()} 156 + className={`dropdownMenu z-20 bg-bg-page flex flex-col p-1 gap-1 border border-border rounded-md shadow-md sm:max-w-xs w-[1000px] max-w-(--radix-popover-content-available-width) 157 + max-h-(--radix-popover-content-available-height) 158 + overflow-y-scroll`} 159 + > 160 + <ul className="list-none p-0 text-sm"> 161 + <div className={headerStyle}>People</div> 162 + {suggestions 163 + .filter((result) => result.type === "did") 164 + .map((result, index) => { 165 + return ( 166 + <Result 167 + key={result.did} 168 + onClick={() => { 169 + if (mentionRange) { 170 + props.onSelect(result, mentionRange); 171 + setMentionQuery(null); 172 + setMentionRange(null); 173 + setMentionCoords(null); 174 + } 175 + }} 176 + onMouseDown={(e) => e.preventDefault()} 177 + result={ 178 + result.displayName 179 + ? result.displayName 180 + : `@${result.handle}` 181 + } 182 + subtext={ 183 + result.displayName ? `@${result.handle}` : undefined 184 + } 185 + selected={index === suggestionIndex} 186 + /> 187 + ); 188 + })} 189 + <hr className="border-border-light mx-1 my-1" /> 190 + <div className={headerStyle}>Publications</div> 191 + {suggestions 192 + .filter((result) => result.type === "publication") 193 + .map((result, index) => { 194 + return ( 195 + <Result 196 + key={result.uri} 197 + onClick={() => { 198 + if (mentionRange) { 199 + props.onSelect(result, mentionRange); 200 + setMentionQuery(null); 201 + setMentionRange(null); 202 + setMentionCoords(null); 203 + } 204 + }} 205 + onMouseDown={(e) => e.preventDefault()} 206 + result={result.name} 207 + selected={index === suggestionIndex} 208 + /> 209 + ); 210 + })} 211 + </ul> 212 + </Popover.Content> 213 + </Popover.Portal> 214 + </Popover.Root> 215 + ); 216 + } 217 + 218 + const Result = (props: { 219 + result: React.ReactNode; 220 + subtext?: React.ReactNode; 221 + onClick: () => void; 222 + onMouseDown: (e: React.MouseEvent) => void; 223 + selected?: boolean; 224 + }) => { 225 + return ( 226 + <div 227 + className={` 228 + menuItem flex-col! gap-0! 229 + text-secondary leading-tight text-sm truncate 230 + ${props.subtext ? "py-1!" : "py-2!"} 231 + ${props.selected ? "bg-[var(--accent-light)]" : ""}`} 232 + onClick={() => props.onClick()} 233 + onMouseDown={(e) => props.onMouseDown(e)} 234 + > 235 + <div className={`flex gap-2 items-center `}> 236 + <div className="truncate w-full grow min-w-0 ">{props.result}</div> 237 + </div> 238 + {props.subtext && ( 239 + <div className="text-tertiary italic text-xs font-normal min-w-0 truncate pb-0.5"> 240 + {props.subtext} 241 + </div> 242 + )} 243 + </div> 244 + ); 245 + }; 246 + 247 + export type Mention = 248 + | { type: "did"; handle: string; did: string; displayName?: string } 249 + | { type: "publication"; uri: string; name: string }; 250 + function useMentionSuggestions(query: string | null) { 251 + const [suggestionIndex, setSuggestionIndex] = useState(0); 252 + const [suggestions, setSuggestions] = useState<Array<Mention>>([]); 253 + 254 + useDebouncedEffect( 255 + async () => { 256 + if (!query) { 257 + setSuggestions([]); 258 + return; 259 + } 260 + 261 + const agent = new Agent("https://public.api.bsky.app"); 262 + const [result, publications] = await Promise.all([ 263 + agent.searchActorsTypeahead({ 264 + q: query, 265 + limit: 8, 266 + }), 267 + callRPC(`search_publication_names`, { query, limit: 8 }), 268 + ]); 269 + setSuggestions([ 270 + ...result.data.actors.map((actor) => ({ 271 + type: "did" as const, 272 + handle: actor.handle, 273 + did: actor.did, 274 + displayName: actor.displayName, 275 + })), 276 + ...publications.result.publications.map((p) => ({ 277 + type: "publication" as const, 278 + uri: p.uri, 279 + name: p.name, 280 + })), 281 + ]); 282 + }, 283 + 300, 284 + [query], 285 + ); 286 + 287 + useEffect(() => { 288 + if (suggestionIndex > suggestions.length - 1) { 289 + setSuggestionIndex(Math.max(0, suggestions.length - 1)); 290 + } 291 + }, [suggestionIndex, suggestions.length]); 292 + 293 + return { 294 + suggestions, 295 + suggestionIndex, 296 + setSuggestionIndex, 297 + }; 298 + }