diff --git a/app/globals.css b/app/globals.css index e84feecd..b42e4e50 100644 --- a/app/globals.css +++ b/app/globals.css @@ -291,6 +291,12 @@ pre.shiki { @apply py-[1.5px]; } +/* Underline mention nodes when selected in ProseMirror */ +.ProseMirror .atMention.ProseMirror-selectednode, +.ProseMirror .didMention.ProseMirror-selectednode { + text-decoration: underline; +} + .ProseMirror:focus-within .selection-highlight { background-color: transparent; } diff --git a/components/Blocks/TextBlock/index.tsx b/components/Blocks/TextBlock/index.tsx index a6d6a38b..27cb3cfc 100644 --- a/components/Blocks/TextBlock/index.tsx +++ b/components/Blocks/TextBlock/index.tsx @@ -491,12 +491,25 @@ const useMentionState = (entityID: string) => { const pos = view.state.selection.from; setMentionInsertPos(pos); - // Get coordinates for the popup + // Get coordinates for the popup relative to the positioned parent const coords = view.coordsAtPos(pos - 1); // Position of the @ - setMentionCoords({ - top: coords.bottom + window.scrollY, - left: coords.left + window.scrollX, - }); + + // Find the relative positioned parent container + const editorEl = view.dom; + const container = editorEl.closest('.relative') as HTMLElement | null; + + if (container) { + const containerRect = container.getBoundingClientRect(); + setMentionCoords({ + top: coords.bottom - containerRect.top, + left: coords.left - containerRect.left, + }); + } else { + setMentionCoords({ + top: coords.bottom, + left: coords.left, + }); + } setMentionOpen(true); }, [entityID]); diff --git a/components/Blocks/TextBlock/inputRules.ts b/components/Blocks/TextBlock/inputRules.ts index 8f1381c3..f20e9465 100644 --- a/components/Blocks/TextBlock/inputRules.ts +++ b/components/Blocks/TextBlock/inputRules.ts @@ -192,12 +192,23 @@ export const inputrules = ( return tr; }), - // Mention - @ at start of line or after space + // Mention - @ at start of line, after space, or after hard break new InputRule(/(?:^|\s)@$/, (state, match, start, end) => { if (!openMentionAutocomplete) return null; // Schedule opening the autocomplete after the transaction is applied setTimeout(() => openMentionAutocomplete(), 0); return null; // Let the @ be inserted normally }), + // Mention - @ immediately after a hard break (hard breaks are nodes, not text) + new InputRule(/@$/, (state, match, start, end) => { + if (!openMentionAutocomplete) return null; + // Check if the character before @ is a hard break node + const $pos = state.doc.resolve(start); + const nodeBefore = $pos.nodeBefore; + if (nodeBefore && nodeBefore.type.name === "hard_break") { + setTimeout(() => openMentionAutocomplete(), 0); + } + return null; // Let the @ be inserted normally + }), ], }); diff --git a/components/Mention.tsx b/components/Mention.tsx index c9f5f252..35b76bf2 100644 --- a/components/Mention.tsx +++ b/components/Mention.tsx @@ -1,7 +1,6 @@ "use client"; import { Agent } from "@atproto/api"; import { useState, useEffect, Fragment, useRef, useCallback } from "react"; -import { createPortal } from "react-dom"; import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; import * as Popover from "@radix-ui/react-popover"; import { EditorView } from "prosemirror-view"; @@ -165,23 +164,20 @@ export function MentionAutocomplete(props: { return ( - {createPortal( - , - document.body, - )} + e.preventDefault()} className={`dropdownMenu group/mention-menu z-20 bg-bg-page flex data-[side=top]:flex-col-reverse flex-col @@ -189,10 +185,10 @@ export function MentionAutocomplete(props: { border border-border rounded-md shadow-md sm:max-w-xs w-[1000px] max-w-(--radix-popover-content-available-width) max-h-(--radix-popover-content-available-height) - overflow-y-scroll`} + overflow-hidden`} > - {/* Dropdown Header */} -
+ {/* Dropdown Header - sticky */} +
@@ -217,84 +213,86 @@ export function MentionAutocomplete(props: { />
- {sortedSuggestions.length === 0 ? ( -
- {searchQuery - ? noResults - ? "No results found..." - : "Searching..." - : scope.type === "publication" - ? "Start typing to search posts" - : "Start typing to search people and publications"} -
- ) : ( -
    - {sortedSuggestions.map((result, index) => { - const prevResult = sortedSuggestions[index - 1]; - const showHeader = - index === 0 || - (prevResult && prevResult.type !== result.type); +
    + {sortedSuggestions.length === 0 ? ( +
    + {searchQuery + ? noResults + ? "No results found..." + : "Searching..." + : scope.type === "publication" + ? "Start typing to search posts" + : "Start typing to search people and publications"} +
    + ) : ( +
      + {sortedSuggestions.map((result, index) => { + const prevResult = sortedSuggestions[index - 1]; + const showHeader = + index === 0 || + (prevResult && prevResult.type !== result.type); - return ( - - {showHeader && ( - <> - {index > 0 && ( -
      - )} -
      - {getHeader(result.type, scope)} -
      - - )} - {result.type === "did" ? ( - { - props.onSelect(result); - props.onOpenChange(false); - }} - onMouseDown={(e) => e.preventDefault()} - displayName={result.displayName} - handle={result.handle} - avatar={result.avatar} - selected={index === suggestionIndex} - /> - ) : result.type === "publication" ? ( - { - props.onSelect(result); - props.onOpenChange(false); - }} - onMouseDown={(e) => e.preventDefault()} - pubName={result.name} - uri={result.uri} - selected={index === suggestionIndex} - onPostsClick={() => { - handleScopeChange({ - type: "publication", - uri: result.uri, - name: result.name, - }); - }} - /> - ) : ( - { - props.onSelect(result); - props.onOpenChange(false); - }} - onMouseDown={(e) => e.preventDefault()} - title={result.title} - selected={index === suggestionIndex} - /> - )} -
      - ); - })} -
    - )} + return ( + + {showHeader && ( + <> + {index > 0 && ( +
    + )} +
    + {getHeader(result.type, scope)} +
    + + )} + {result.type === "did" ? ( + { + props.onSelect(result); + props.onOpenChange(false); + }} + onMouseDown={(e) => e.preventDefault()} + displayName={result.displayName} + handle={result.handle} + avatar={result.avatar} + selected={index === suggestionIndex} + /> + ) : result.type === "publication" ? ( + { + props.onSelect(result); + props.onOpenChange(false); + }} + onMouseDown={(e) => e.preventDefault()} + pubName={result.name} + uri={result.uri} + selected={index === suggestionIndex} + onPostsClick={() => { + handleScopeChange({ + type: "publication", + uri: result.uri, + name: result.name, + }); + }} + /> + ) : ( + { + props.onSelect(result); + props.onOpenChange(false); + }} + onMouseDown={(e) => e.preventDefault()} + title={result.title} + selected={index === suggestionIndex} + /> + )} +
    + ); + })} +
+ )} +
@@ -458,7 +456,13 @@ const ScopeHeader = (props: { }; export type Mention = - | { type: "did"; handle: string; did: string; displayName?: string; avatar?: string } + | { + type: "did"; + handle: string; + did: string; + displayName?: string; + avatar?: string; + } | { type: "publication"; uri: string; name: string } | { type: "post"; uri: string; title: string };