1- import { useEffect , useId , useRef } from "react" ;
1+ import { useEffect , useId , useRef , useState } from "react" ;
22import { MagnifyingGlassIcon } from "@heroicons/react/24/outline" ;
33import { CATALOG_SECTIONS , commandId , commandLabel , type CommandSupport , type SupportLevel } from "@zplab/core/catalog" ;
44import { useT } from "../../hooks/useT" ;
@@ -7,6 +7,8 @@ import type { CatalogSelection } from "../../hooks/useCatalogSelection";
77import { catalogEmptyText } from "../../lib/catalogText" ;
88import type { Translations } from "../../locales" ;
99import { inputCls } from "../Properties/styles" ;
10+ import { ContextMenu , type MenuSection } from "../ui/ContextMenu" ;
11+ import { useContextMenu } from "../../hooks/useContextMenu" ;
1012
1113type OutputKey = keyof Translations [ "output" ] ;
1214
@@ -28,6 +30,11 @@ const LEVEL: Record<SupportLevel, { cls: string; key: OutputKey }> = {
2830const domId = ( listId : string , key : string ) : string =>
2931 `${ listId } -${ key . replace ( / ^ \^ / , "c" ) . replace ( / ^ ~ / , "t" ) . replace ( / [ ^ A - Z a - z 0 - 9 ] / g, "_" ) } ` ;
3032
33+ /** One entry: the same insert the row's button and double-click already trigger. */
34+ function buildCatalogRowMenu ( label : string , run : ( ( ) => void ) | undefined ) : MenuSection [ ] {
35+ return [ { id : "row" , items : [ { id : "insert" , label, run, disabled : run === undefined } ] } ] ;
36+ }
37+
3138/** Command reference beside the source pane; no `onInsert` means inserting is unavailable. */
3239export function ZplCatalogPanel ( {
3340 selection,
@@ -43,7 +50,12 @@ export function ZplCatalogPanel({
4350 const summaries = useCatalogSummaries ( ) ;
4451 const listId = useId ( ) ;
4552 const listRef = useRef < HTMLUListElement > ( null ) ;
53+ // State, not a ref: the menu's container is read during render.
54+ const [ panelEl , setPanelEl ] = useState < HTMLElement | null > ( null ) ;
55+ // A row chosen under the pointer is in view already, and scrolling would close the menu that opens with it.
56+ const chosenByPointer = useRef < string | null > ( null ) ;
4657 const { query, setQuery, results, ids, entry, emptyReason, shownId, activeIndex, caretVisible, choose, toggle, stepBack, insertTextFor } = selection ;
58+ const { menu, openAtPointer, close } = useContextMenu < MenuSection [ ] > ( ) ;
4759 // Headings only: the catalog is stored in section order, so the arrow walk reads `ids` as is.
4860 const groups = CATALOG_SECTIONS . map ( ( section ) => ( { section, rows : results . filter ( ( e ) => e . section === section . name ) } ) ) . filter (
4961 ( g ) => g . rows . length > 0 ,
@@ -57,9 +69,18 @@ export function ZplCatalogPanel({
5769 const keepEditorFocus = ( e : React . MouseEvent ) : void => {
5870 if ( editorHasFocus ?.( ) ) e . preventDefault ( ) ;
5971 } ;
72+ // A right-click selects the row like a click does, then offers the insert that double-click hides.
73+ const openRowMenu = ( e : React . MouseEvent , id : string ) : void => {
74+ e . preventDefault ( ) ;
75+ chosenByPointer . current = id ;
76+ choose ( id ) ;
77+ openAtPointer ( e , buildCatalogRowMenu ( t . output . catalogInsert , onInsert ? ( ) => onInsert ( insertTextFor ( id ) ) : undefined ) ) ;
78+ } ;
6079
6180 useEffect ( ( ) => {
62- if ( ! shownId ) return ;
81+ const justChosen = chosenByPointer . current ;
82+ chosenByPointer . current = null ;
83+ if ( ! shownId || shownId === justChosen ) return ;
6384 const row = listRef . current ?. querySelector ( `#${ CSS . escape ( domId ( listId , shownId ) ) } ` ) ;
6485 row ?. scrollIntoView ?.( { block : "nearest" } ) ;
6586 // `query` re-runs this when a cleared filter re-mounts the active row.
@@ -78,7 +99,7 @@ export function ZplCatalogPanel({
7899 const next = ids [ Math . min ( ids . length - 1 , Math . max ( 0 , activeIndex < 0 ? enterFrom : activeIndex + step ) ) ] ;
79100 if ( next ) choose ( next ) ;
80101 } else if ( e . key === "Enter" ) {
81- // Enter inserts only the highlighted row. Anything else goes through the detail's button .
102+ // Enter inserts only the highlighted row. Without one, it only selects the caret .
82103 const activeId = ids [ activeIndex ] ;
83104 if ( activeId ) onInsert ?.( insertTextFor ( activeId ) ) ;
84105 else if ( caretVisible ) choose ( caretVisible ) ;
@@ -92,6 +113,7 @@ export function ZplCatalogPanel({
92113 // tabIndex -1 keeps a description click's focus inside the pane, else the session exit's focusout fires.
93114 // The data attributes keep the canvas shortcuts and the session's Escape away from the panel's own keys.
94115 < aside
116+ ref = { setPanelEl }
95117 tabIndex = { - 1 }
96118 onKeyDown = { onPanelKeyDown }
97119 data-text-surface
@@ -196,9 +218,12 @@ export function ZplCatalogPanel({
196218 onMouseDown = { keepEditorFocus }
197219 // The clicks of a double-click must not toggle the pin twice.
198220 onClick = { ( e ) => {
199- if ( e . detail <= 1 ) toggle ( id ) ;
221+ if ( e . detail > 1 ) return ;
222+ chosenByPointer . current = id ;
223+ toggle ( id ) ;
200224 } }
201225 onDoubleClick = { ( ) => onInsert ?.( insertTextFor ( id ) ) }
226+ onContextMenu = { ( e ) => openRowMenu ( e , id ) }
202227 className = { `flex items-baseline gap-2 px-3 py-0.5 cursor-default select-none hover:bg-border/60 ${ active ? "bg-border/60" : "" } ` }
203228 >
204229 { /* Coloured by web support: the answer most users need at a glance. */ }
@@ -214,6 +239,8 @@ export function ZplCatalogPanel({
214239 </ ul >
215240 </ div >
216241 </ div >
242+ { /* Mounted inside the panel: the session exit reads a pointerdown outside it as leaving the edit. */ }
243+ { menu && < ContextMenu sections = { menu . data } x = { menu . x } y = { menu . y } onClose = { close } container = { panelEl } /> }
217244 </ aside >
218245 ) ;
219246}
0 commit comments