1+ import type { FC , RefObject } from "react" ;
2+ import type { BlockNoteEditor } from "@blocknote/core" ;
3+ import type { SuggestionMenuProps } from "@blocknote/react" ;
4+ import styled from "styled-components" ;
5+ import { BlockCard } from "../BlockWorkPackage/BlockCard" ;
6+ import { defaultWpVariables } from "../WorkPackage/atoms" ;
7+ import type { InlineWpSize } from "./types" ;
8+ import { makeInstanceId } from "../../services/utils" ;
9+ import type { WorkPackage } from "../../openProjectTypes" ;
10+
11+ type AnyEditor = BlockNoteEditor < any , any , any > ;
12+
13+ export function isHashWpQuery ( query : string ) : boolean {
14+ return query . trim ( ) . length > 0 ;
15+ }
16+
17+ export interface HashMenuItem {
18+ title : string ;
19+ onItemClick : ( ) => void ;
20+ }
21+
22+ // Determines chip size based on the number of # characters before the query:
23+ // #query -> xxs, ##query -> xs, ###query -> s
24+ function getSizeFromCurrentBlock ( editor : AnyEditor ) : InlineWpSize {
25+ const block = editor . getTextCursorPosition ( ) ?. block ;
26+ if ( ! block ) return "xxs" ;
27+
28+ const content = ( block . content ?? [ ] ) as any [ ] ;
29+
30+ for ( const node of content ) {
31+ if ( node . type !== "text" ) continue ;
32+ const text = node . text as string ;
33+ const match = text . match ( / ( # + ) [ ^ # ] / ) ;
34+ if ( match ) {
35+ const hashCount = match [ 1 ] . length ;
36+ if ( hashCount >= 3 ) return "s" ;
37+ if ( hashCount === 2 ) return "xs" ;
38+ return "xxs" ;
39+ }
40+ }
41+
42+ return "xxs" ;
43+ }
44+
45+ // Removes the # trigger text (and any extra # symbols) from the current block.
46+ // Used in the mouse path on Enter, BlockNote already clears #query itself,
47+ // but may leave extra # characters (e.g. ## from ###query), so we call this
48+ // after rAF in the keyboard path too to clean up any leftovers.
49+ function clearTriggerText ( editor : AnyEditor ) : string | null {
50+ const block = editor . getTextCursorPosition ( ) ?. block ;
51+ if ( ! block ) return null ;
52+
53+ const content = ( block . content ?? [ ] ) as any [ ] ;
54+
55+ const triggerNodeIndex = content . findIndex ( ( n ) => {
56+ if ( n . type !== "text" ) return false ;
57+ return / # + / . test ( n . text as string ) ;
58+ } ) ;
59+
60+ // No # found BlockNote already cleaned everything, nothing to do
61+ if ( triggerNodeIndex === - 1 ) return null ;
62+
63+ const triggerNode = content [ triggerNodeIndex ] as { type : string ; text : string ; styles : any } ;
64+ const text = triggerNode . text ;
65+
66+ // Preserve any text that was typed before the # in the same node (e.g. "Hello " from "Hello ##query")
67+ const hashIndex = text . search ( / # / ) ;
68+ const textBefore = hashIndex > 0 ? text . slice ( 0 , hashIndex ) : null ;
69+
70+ const cleanedContent = [
71+ ...content . slice ( 0 , triggerNodeIndex ) ,
72+ ...( textBefore ? [ { type : "text" , text : textBefore , styles : triggerNode . styles } ] : [ ] ) ,
73+ ] ;
74+
75+ editor . updateBlock ( block . id , { content : cleanedContent } as any ) ;
76+ return block . id ;
77+ }
78+
79+ // Mouse path: inserts chip at current cursor position via insertInlineContent.
80+ // Works correctly because e.preventDefault() stops BlockNote from moving the cursor.
81+ function insertWpChip ( editor : AnyEditor , wp : WorkPackage , size : InlineWpSize ) : void {
82+ const instanceId = makeInstanceId ( ) ;
83+
84+ ( editor . insertInlineContent as ( content : unknown [ ] ) => void ) ( [
85+ { type : "inlineWorkPackage" , props : { wpid : String ( wp . id ) , instanceId, size } } ,
86+ { type : "text" , text : " " , styles : { } } ,
87+ ] ) ;
88+
89+ requestAnimationFrame ( ( ) => {
90+ editor . focus ( ) ;
91+ const cursor = editor . getTextCursorPosition ( ) ;
92+ if ( cursor ?. block ?. id ) {
93+ editor . setTextCursorPosition ( cursor . block . id , "end" ) ;
94+ }
95+ } ) ;
96+ }
97+
98+ // Keyboard (Enter) path: inserts chip directly into block content by ID,
99+ // bypassing cursor position entirely to avoid race conditions with
100+ // BlockNote's Enter handling which moves the cursor to a new block.
101+ function insertWpChipIntoBlock ( editor : AnyEditor , blockId : string , wp : WorkPackage , size : InlineWpSize ) : void {
102+ const instanceId = makeInstanceId ( ) ;
103+ const block = editor . getBlock ( blockId ) ;
104+ if ( ! block ) return ;
105+
106+ const content = ( block . content ?? [ ] ) as any [ ] ;
107+
108+ editor . updateBlock ( blockId , {
109+ content : [
110+ ...content ,
111+ { type : "inlineWorkPackage" , props : { wpid : String ( wp . id ) , instanceId, size } } ,
112+ { type : "text" , text : " " , styles : { } } ,
113+ ] ,
114+ } as any ) ;
115+
116+ requestAnimationFrame ( ( ) => {
117+ editor . focus ( ) ;
118+ editor . setTextCursorPosition ( blockId , "end" ) ;
119+ } ) ;
120+ }
121+
122+ const Menu = styled . div . attrs ( { className : "op-bn-hash-menu" } ) `
123+ ${ defaultWpVariables }
124+ background: var(--bn-colors-menu-background, #fff);
125+ box-shadow: var(--bn-shadow-medium);
126+ border-radius: var(--bn-border-radius-large);
127+ padding: var(--spacer-s);
128+ min-width: 320px;
129+ max-width: 480px;
130+ ` ;
131+
132+ const MenuItem = styled . div < { $selected : boolean } > `
133+ border-radius: var(--bn-border-radius-small);
134+ background: ${ ( { $selected } ) =>
135+ $selected ? "var(--bn-colors-highlights-gray-background, #f0f0f0)" : "transparent" } ;
136+ cursor: pointer;
137+ padding: 0 var(--spacer-s);
138+
139+ &:hover {
140+ background: var(--bn-colors-highlights-gray-background, #f0f0f0);
141+ }
142+ ` ;
143+
144+ const EmptyState = styled . div `
145+ padding: var(--spacer-m) var(--spacer-l);
146+ font-size: 0.85em;
147+ color: var(--bn-colors-highlights-gray-text, #888);
148+ ` ;
149+
150+ const MAX_RESULTS = 5 ;
151+
152+ export function createHashWpMenuComponent (
153+ editor : AnyEditor ,
154+ // Ref is populated by the parent component via useWorkPackageSearch.
155+ // We use a ref (not state) so that getItems can return the correct item
156+ // count for keyboard navigation without causing extra re-renders.
157+ resultsRef : RefObject < WorkPackage [ ] > ,
158+ ) : FC < SuggestionMenuProps < HashMenuItem > > {
159+ const HashWpMenuComponent : FC < SuggestionMenuProps < HashMenuItem > > = ( {
160+ items,
161+ selectedIndex,
162+ } ) => {
163+ const searchQuery = items [ 0 ] ?. title ?? "" ;
164+ const visibleResults = ( resultsRef . current ?? [ ] ) . slice ( 0 , MAX_RESULTS ) ;
165+
166+ // Mutate each item's onItemClick so BlockNote's keyboard handler
167+ // (Enter / PgUp / PgDn) calls the correct insertion for that result.
168+ // size and blockId are captured synchronously here while # is still in
169+ // the document. Insertion is deferred via rAF so it runs after BlockNote
170+ // finishes its own cleanup (removing #query and creating a new block on Enter).
171+ visibleResults . forEach ( ( wp , index ) => {
172+ if ( items [ index ] ) {
173+ const size = getSizeFromCurrentBlock ( editor ) ;
174+ const blockId = editor . getTextCursorPosition ( ) ?. block ?. id ;
175+ items [ index ] . onItemClick = ( ) => {
176+ requestAnimationFrame ( ( ) => {
177+ if ( ! blockId ) return ;
178+ editor . focus ( ) ;
179+
180+ // BlockNote splits the block on Enter — remove the new empty block it created
181+ const currentBlock = editor . getTextCursorPosition ( ) ?. block ;
182+ if ( currentBlock && currentBlock . id !== blockId ) {
183+ editor . removeBlocks ( [ currentBlock . id ] ) ;
184+ }
185+
186+ // Clean up any leftover # symbols BlockNote didn't remove (e.g. ## from ###query)
187+ clearTriggerText ( editor ) ;
188+
189+ // Insert directly into the original block by ID, not by cursor position
190+ insertWpChipIntoBlock ( editor , blockId , wp , size ) ;
191+ } ) ;
192+ } ;
193+ }
194+ } ) ;
195+
196+ if ( ! searchQuery ) {
197+ return (
198+ < Menu >
199+ < EmptyState > Type to search work packages…</ EmptyState >
200+ </ Menu >
201+ ) ;
202+ }
203+
204+ if ( visibleResults . length === 0 ) {
205+ return (
206+ < Menu >
207+ < EmptyState > No results for "{ searchQuery } "</ EmptyState >
208+ </ Menu >
209+ ) ;
210+ }
211+
212+ return (
213+ < Menu >
214+ { visibleResults . map ( ( wp , index ) => (
215+ < MenuItem
216+ key = { wp . id }
217+ $selected = { selectedIndex === index }
218+ // Mouse path: e.preventDefault() stops BlockNote from doing its own
219+ // cleanup, so we clear the trigger text manually before inserting.
220+ onMouseDown = { ( e ) => {
221+ e . preventDefault ( ) ;
222+ const size = getSizeFromCurrentBlock ( editor ) ;
223+ const blockId = clearTriggerText ( editor ) ;
224+ if ( blockId ) {
225+ editor . focus ( ) ;
226+ editor . setTextCursorPosition ( blockId , "end" ) ;
227+ }
228+ insertWpChip ( editor , wp , size ) ;
229+ } }
230+ >
231+ < BlockCard workPackage = { wp } inDropdown />
232+ </ MenuItem >
233+ ) ) }
234+ </ Menu >
235+ ) ;
236+ } ;
237+
238+ HashWpMenuComponent . displayName = "HashWpMenu" ;
239+ return HashWpMenuComponent ;
240+ }
0 commit comments