a tool for shared writing and social publishing

bunch of small fixes

+134 -100
+6
app/globals.css
··· 291 291 @apply py-[1.5px]; 292 292 } 293 293 294 + /* Underline mention nodes when selected in ProseMirror */ 295 + .ProseMirror .atMention.ProseMirror-selectednode, 296 + .ProseMirror .didMention.ProseMirror-selectednode { 297 + text-decoration: underline; 298 + } 299 + 294 300 .ProseMirror:focus-within .selection-highlight { 295 301 background-color: transparent; 296 302 }
+18 -5
components/Blocks/TextBlock/index.tsx
··· 491 491 const pos = view.state.selection.from; 492 492 setMentionInsertPos(pos); 493 493 494 - // Get coordinates for the popup 494 + // Get coordinates for the popup relative to the positioned parent 495 495 const coords = view.coordsAtPos(pos - 1); // Position of the @ 496 - setMentionCoords({ 497 - top: coords.bottom + window.scrollY, 498 - left: coords.left + window.scrollX, 499 - }); 496 + 497 + // Find the relative positioned parent container 498 + const editorEl = view.dom; 499 + const container = editorEl.closest('.relative') as HTMLElement | null; 500 + 501 + if (container) { 502 + const containerRect = container.getBoundingClientRect(); 503 + setMentionCoords({ 504 + top: coords.bottom - containerRect.top, 505 + left: coords.left - containerRect.left, 506 + }); 507 + } else { 508 + setMentionCoords({ 509 + top: coords.bottom, 510 + left: coords.left, 511 + }); 512 + } 500 513 setMentionOpen(true); 501 514 }, [entityID]); 502 515
+12 -1
components/Blocks/TextBlock/inputRules.ts
··· 192 192 return tr; 193 193 }), 194 194 195 - // Mention - @ at start of line or after space 195 + // Mention - @ at start of line, after space, or after hard break 196 196 new InputRule(/(?:^|\s)@$/, (state, match, start, end) => { 197 197 if (!openMentionAutocomplete) return null; 198 198 // Schedule opening the autocomplete after the transaction is applied 199 199 setTimeout(() => openMentionAutocomplete(), 0); 200 + return null; // Let the @ be inserted normally 201 + }), 202 + // Mention - @ immediately after a hard break (hard breaks are nodes, not text) 203 + new InputRule(/@$/, (state, match, start, end) => { 204 + if (!openMentionAutocomplete) return null; 205 + // Check if the character before @ is a hard break node 206 + const $pos = state.doc.resolve(start); 207 + const nodeBefore = $pos.nodeBefore; 208 + if (nodeBefore && nodeBefore.type.name === "hard_break") { 209 + setTimeout(() => openMentionAutocomplete(), 0); 210 + } 200 211 return null; // Let the @ be inserted normally 201 212 }), 202 213 ],
+98 -94
components/Mention.tsx
··· 1 1 "use client"; 2 2 import { Agent } from "@atproto/api"; 3 3 import { useState, useEffect, Fragment, useRef, useCallback } from "react"; 4 - import { createPortal } from "react-dom"; 5 4 import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; 6 5 import * as Popover from "@radix-ui/react-popover"; 7 6 import { EditorView } from "prosemirror-view"; ··· 165 164 166 165 return ( 167 166 <Popover.Root open> 168 - {createPortal( 169 - <Popover.Anchor 170 - style={{ 171 - top: props.coords.top - 24, 172 - left: props.coords.left, 173 - height: 24, 174 - position: "absolute", 175 - }} 176 - />, 177 - document.body, 178 - )} 167 + <Popover.Anchor 168 + style={{ 169 + top: props.coords.top - 24, 170 + left: props.coords.left, 171 + height: 24, 172 + position: "absolute", 173 + }} 174 + /> 179 175 <Popover.Portal> 180 176 <Popover.Content 181 177 ref={contentRef} 182 178 align="start" 183 179 sideOffset={4} 184 - collisionPadding={20} 180 + collisionPadding={32} 185 181 onOpenAutoFocus={(e) => e.preventDefault()} 186 182 className={`dropdownMenu group/mention-menu z-20 bg-bg-page 187 183 flex data-[side=top]:flex-col-reverse flex-col ··· 189 185 border border-border rounded-md shadow-md 190 186 sm:max-w-xs w-[1000px] max-w-(--radix-popover-content-available-width) 191 187 max-h-(--radix-popover-content-available-height) 192 - overflow-y-scroll`} 188 + overflow-hidden`} 193 189 > 194 - {/* Dropdown Header */} 195 - <div className="flex flex-col items-center gap-2 px-2 py-1 border-b group-data-[side=top]/mention-menu:border-b-0 group-data-[side=top]/mention-menu:border-t border-border-light"> 190 + {/* Dropdown Header - sticky */} 191 + <div className="flex flex-col items-center gap-2 px-2 py-1 border-b group-data-[side=top]/mention-menu:border-b-0 group-data-[side=top]/mention-menu:border-t border-border-light bg-bg-page sticky top-0 group-data-[side=top]/mention-menu:sticky group-data-[side=top]/mention-menu:bottom-0 group-data-[side=top]/mention-menu:top-auto z-10 shrink-0"> 196 192 <div className="flex items-center gap-1 flex-1 min-w-0 text-primary"> 197 193 <div className="text-tertiary"> 198 194 <SearchTiny className="w-4 h-4 shrink-0" /> ··· 217 213 /> 218 214 </div> 219 215 </div> 220 - {sortedSuggestions.length === 0 ? ( 221 - <div className="text-sm text-tertiary italic px-3 py-1 text-center"> 222 - {searchQuery 223 - ? noResults 224 - ? "No results found..." 225 - : "Searching..." 226 - : scope.type === "publication" 227 - ? "Start typing to search posts" 228 - : "Start typing to search people and publications"} 229 - </div> 230 - ) : ( 231 - <ul className="list-none p-0 text-sm flex flex-col group-data-[side=top]/mention-menu:flex-col-reverse"> 232 - {sortedSuggestions.map((result, index) => { 233 - const prevResult = sortedSuggestions[index - 1]; 234 - const showHeader = 235 - index === 0 || 236 - (prevResult && prevResult.type !== result.type); 216 + <div className="overflow-y-auto flex-1 min-h-0"> 217 + {sortedSuggestions.length === 0 ? ( 218 + <div className="text-sm text-tertiary italic px-3 py-1 text-center"> 219 + {searchQuery 220 + ? noResults 221 + ? "No results found..." 222 + : "Searching..." 223 + : scope.type === "publication" 224 + ? "Start typing to search posts" 225 + : "Start typing to search people and publications"} 226 + </div> 227 + ) : ( 228 + <ul className="list-none p-0 text-sm flex flex-col group-data-[side=top]/mention-menu:flex-col-reverse"> 229 + {sortedSuggestions.map((result, index) => { 230 + const prevResult = sortedSuggestions[index - 1]; 231 + const showHeader = 232 + index === 0 || 233 + (prevResult && prevResult.type !== result.type); 237 234 238 - return ( 239 - <Fragment 240 - key={result.type === "did" ? result.did : result.uri} 241 - > 242 - {showHeader && ( 243 - <> 244 - {index > 0 && ( 245 - <hr className="border-border-light mx-1 my-1" /> 246 - )} 247 - <div className="text-xs text-tertiary font-bold pt-1 px-2"> 248 - {getHeader(result.type, scope)} 249 - </div> 250 - </> 251 - )} 252 - {result.type === "did" ? ( 253 - <DidResult 254 - onClick={() => { 255 - props.onSelect(result); 256 - props.onOpenChange(false); 257 - }} 258 - onMouseDown={(e) => e.preventDefault()} 259 - displayName={result.displayName} 260 - handle={result.handle} 261 - avatar={result.avatar} 262 - selected={index === suggestionIndex} 263 - /> 264 - ) : result.type === "publication" ? ( 265 - <PublicationResult 266 - onClick={() => { 267 - props.onSelect(result); 268 - props.onOpenChange(false); 269 - }} 270 - onMouseDown={(e) => e.preventDefault()} 271 - pubName={result.name} 272 - uri={result.uri} 273 - selected={index === suggestionIndex} 274 - onPostsClick={() => { 275 - handleScopeChange({ 276 - type: "publication", 277 - uri: result.uri, 278 - name: result.name, 279 - }); 280 - }} 281 - /> 282 - ) : ( 283 - <PostResult 284 - onClick={() => { 285 - props.onSelect(result); 286 - props.onOpenChange(false); 287 - }} 288 - onMouseDown={(e) => e.preventDefault()} 289 - title={result.title} 290 - selected={index === suggestionIndex} 291 - /> 292 - )} 293 - </Fragment> 294 - ); 295 - })} 296 - </ul> 297 - )} 235 + return ( 236 + <Fragment 237 + key={result.type === "did" ? result.did : result.uri} 238 + > 239 + {showHeader && ( 240 + <> 241 + {index > 0 && ( 242 + <hr className="border-border-light mx-1 my-1" /> 243 + )} 244 + <div className="text-xs text-tertiary font-bold pt-1 px-2"> 245 + {getHeader(result.type, scope)} 246 + </div> 247 + </> 248 + )} 249 + {result.type === "did" ? ( 250 + <DidResult 251 + onClick={() => { 252 + props.onSelect(result); 253 + props.onOpenChange(false); 254 + }} 255 + onMouseDown={(e) => e.preventDefault()} 256 + displayName={result.displayName} 257 + handle={result.handle} 258 + avatar={result.avatar} 259 + selected={index === suggestionIndex} 260 + /> 261 + ) : result.type === "publication" ? ( 262 + <PublicationResult 263 + onClick={() => { 264 + props.onSelect(result); 265 + props.onOpenChange(false); 266 + }} 267 + onMouseDown={(e) => e.preventDefault()} 268 + pubName={result.name} 269 + uri={result.uri} 270 + selected={index === suggestionIndex} 271 + onPostsClick={() => { 272 + handleScopeChange({ 273 + type: "publication", 274 + uri: result.uri, 275 + name: result.name, 276 + }); 277 + }} 278 + /> 279 + ) : ( 280 + <PostResult 281 + onClick={() => { 282 + props.onSelect(result); 283 + props.onOpenChange(false); 284 + }} 285 + onMouseDown={(e) => e.preventDefault()} 286 + title={result.title} 287 + selected={index === suggestionIndex} 288 + /> 289 + )} 290 + </Fragment> 291 + ); 292 + })} 293 + </ul> 294 + )} 295 + </div> 298 296 </Popover.Content> 299 297 </Popover.Portal> 300 298 </Popover.Root> ··· 458 456 }; 459 457 460 458 export type Mention = 461 - | { type: "did"; handle: string; did: string; displayName?: string; avatar?: string } 459 + | { 460 + type: "did"; 461 + handle: string; 462 + did: string; 463 + displayName?: string; 464 + avatar?: string; 465 + } 462 466 | { type: "publication"; uri: string; name: string } 463 467 | { type: "post"; uri: string; title: string }; 464 468