(null);
useEffect(() => {
setSelectedIndex(0);
}, [items]);
// Scroll selected into view
useEffect(() => {
const el = listRef.current?.children[selectedIndex] as HTMLElement | undefined;
el?.scrollIntoView({ block: "nearest" });
}, [selectedIndex]);
const selectItem = useCallback(
(index: number) => {
const item = items[index];
if (item) {
command(item);
}
},
[items, command],
);
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
if (event.key === "ArrowUp") {
setSelectedIndex((i) => (i + items.length - 1) % items.length);
return true;
}
if (event.key === "ArrowDown") {
setSelectedIndex((i) => (i + 1) % items.length);
return true;
}
if (event.key === "Enter") {
selectItem(selectedIndex);
return true;
}
return false;
},
}));
if (items.length === 0) {
return (
);
}
return (
{items.map((item, index) => (
))}
);
},
);
CommandList.displayName = "CommandList";
// --- Floating wrapper that renders into a portal ---
function SlashPopupRenderer({
items,
command,
clientRect,
componentRef,
}: {
items: SlashItem[];
command: (item: SlashItem) => void;
clientRect: (() => DOMRect | null) | null | undefined;
componentRef: React.RefObject;
}) {
const popupRef = useRef(null);
// Position popup near the cursor
useLayoutEffect(() => {
if (!popupRef.current || !clientRect) {return;}
const rect = clientRect();
if (!rect) {return;}
const el = popupRef.current;
el.style.position = "fixed";
el.style.left = `${rect.left}px`;
el.style.top = `${rect.bottom + 4}px`;
el.style.zIndex = "50";
}, [clientRect, items]);
return createPortal(
,
document.body,
);
}
// --- Shared suggestion render factory ---
function createSuggestionRenderer() {
return () => {
let container: HTMLDivElement | null = null;
let root: ReturnType | null = null;
const componentRef: React.RefObject = { current: null };
return {
onStart: (props: {
items: SlashItem[];
command: (item: SlashItem) => void;
clientRect?: (() => DOMRect | null) | null;
}) => {
container = document.createElement("div");
document.body.appendChild(container);
void import("react-dom/client").then(({ createRoot }) => {
root = createRoot(container!);
root.render(
,
);
});
},
onUpdate: (props: {
items: SlashItem[];
command: (item: SlashItem) => void;
clientRect?: (() => DOMRect | null) | null;
}) => {
root?.render(
,
);
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") {
root?.unmount();
container?.remove();
container = null;
root = null;
return true;
}
return componentRef.current?.onKeyDown(props) ?? false;
},
onExit: () => {
root?.unmount();
container?.remove();
container = null;
root = null;
},
};
};
}
// --- Extension factories ---
/**
* "/" slash command -- markdown block commands only (headings, lists, code, etc.)
*/
export function createSlashCommand() {
const blockCommands = buildBlockCommands();
return Extension.create({
name: "slashCommand",
addOptions() {
return {
suggestion: {
char: "/",
pluginKey: slashCommandPluginKey,
startOfLine: false,
command: ({ editor, range, props: item }: { editor: Editor; range: Range; props: SlashItem }) => {
item.command({ editor, range });
},
items: ({ query }: { query: string }) => {
const q = query.toLowerCase();
if (!q) {return blockCommands;}
return blockCommands.filter(
(item) =>
item.title.toLowerCase().includes(q) ||
(item.description?.toLowerCase().includes(q) ?? false),
);
},
render: createSuggestionRenderer(),
} satisfies Partial>,
};
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
...this.options.suggestion,
}),
];
},
});
}
/**
* "@" mention command -- unified workspace search (files + objects + entries).
* Accepts a search function from the useSearchIndex hook for fast fuzzy matching.
*/
export function createWorkspaceMention(searchFn: MentionSearchFn) {
return Extension.create({
name: "fileMention",
addOptions() {
return {
suggestion: {
char: "@",
pluginKey: fileMentionPluginKey,
startOfLine: false,
command: ({ editor, range, props: item }: { editor: Editor; range: Range; props: SlashItem }) => {
item.command({ editor, range });
},
items: ({ query }: { query: string }) => {
const results = searchFn(query, 15);
return results.map(searchItemToSlashItem);
},
render: createSuggestionRenderer(),
} satisfies Partial>,
};
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
...this.options.suggestion,
}),
];
},
});
}
/**
* "@" mention command -- legacy file-only cross-linking (fallback).
* @deprecated Use createWorkspaceMention with useSearchIndex instead.
*/
export function createFileMention(tree: TreeNode[]) {
function flattenTree(nodes: TreeNode[]): TreeNode[] {
const result: TreeNode[] = [];
for (const node of nodes) {
if (node.type !== "folder") {result.push(node);}
if (node.children) {result.push(...flattenTree(node.children));}
}
return result;
}
const flatFiles = flattenTree(tree);
const searchItems: SearchIndexItem[] = flatFiles.map((node) => ({
id: node.path,
label: node.name.replace(/\.md$/, ""),
sublabel: node.path,
kind: (node.type === "object" ? "object" : "file") as SearchIndexItem["kind"],
path: node.path,
nodeType: node.type as SearchIndexItem["nodeType"],
}));
const searchFn: MentionSearchFn = (query, limit = 15) => {
if (!query) {return searchItems.slice(0, limit);}
const q = query.toLowerCase();
return searchItems
.filter(
(item) =>
item.label.toLowerCase().includes(q) ||
(item.sublabel?.toLowerCase().includes(q) ?? false),
)
.slice(0, limit);
};
return createWorkspaceMention(searchFn);
}