Skip to content

Commit b4813cc

Browse files
committed
feat(workpackages): support hashtag levels for linking (#=XXS, ##=XS, ###=S)
1 parent 22eaf67 commit b4813cc

File tree

3 files changed

+293
-5
lines changed

3 files changed

+293
-5
lines changed
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
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+
}

lib/index.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
11
import "./services/i18n.ts";
2-
export { openProjectWorkPackageBlockSpec, inlineWorkPackageSpec, workPackageSlashMenu, ShadowDomWrapper } from "./components";
2+
export {
3+
openProjectWorkPackageBlockSpec,
4+
inlineWorkPackageSpec,
5+
workPackageSlashMenu,
6+
ShadowDomWrapper,
7+
} from "./components";
38
export { initializeOpBlockNoteExtensions } from "./initialize";
49
export { wpBridge } from "./services/wpBridge.ts";
510
export type { WpResizePayload, WpDeletePayload, WpToInlinePayload } from "./services/wpBridge.ts";
611
export { makeInstanceId } from "./services/utils.ts";
7-
export type { InlineWpSize } from "./components/InlineWorkPackage/types";
12+
export type { InlineWpSize } from "./components/InlineWorkPackage/types";
13+
export { createHashWpMenuComponent, isHashWpQuery } from "./components/InlineWorkPackage/HashMenu.tsx";
14+
export type { HashMenuItem } from "./components/InlineWorkPackage/HashMenu.tsx";
15+
export { useWorkPackageSearch } from "./hooks/useWorkPackageSearch";
16+
export type { WorkPackage } from "./openProjectTypes";

src/App.tsx

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback } from "react";
1+
import { useCallback, useEffect, useMemo, useRef } from "react";
22
import { BlockNoteSchema } from "@blocknote/core";
33
import { filterSuggestionItems } from "@blocknote/core/extensions";
44
import "@blocknote/core/fonts/inter.css";
@@ -14,7 +14,12 @@ import {
1414
openProjectWorkPackageBlockSpec,
1515
inlineWorkPackageSpec,
1616
workPackageSlashMenu,
17+
createHashWpMenuComponent,
18+
isHashWpQuery,
1719
} from "../lib";
20+
import type { HashMenuItem } from "../lib";
21+
import { useWorkPackageSearch } from "../lib/hooks/useWorkPackageSearch";
22+
import type { WorkPackage } from "../lib/openProjectTypes";
1823
import "./fetchOverride";
1924

2025
const schema = BlockNoteSchema.create().extend({
@@ -44,17 +49,51 @@ export default function App() {
4449
[]
4550
);
4651

47-
const getItems = useCallback(
52+
const getSlashItems = useCallback(
4853
async (query: string) =>
4954
filterSuggestionItems(getCustomSlashMenuItems(editor), query),
5055
[editor, getCustomSlashMenuItems]
5156
);
5257

58+
const { searchResults, setSearchQuery } = useWorkPackageSearch();
59+
const searchResultsRef = useRef<WorkPackage[]>([]);
60+
61+
useEffect(() => {
62+
searchResultsRef.current = searchResults;
63+
}, [searchResults]);
64+
65+
const getHashItems = useCallback(
66+
async (query: string): Promise<HashMenuItem[]> => {
67+
if (!isHashWpQuery(query)) return [];
68+
setSearchQuery(query);
69+
70+
const results = searchResultsRef.current;
71+
const count = Math.max(results.length, 1);
72+
return Array.from({ length: count }, () => ({
73+
title: query,
74+
onItemClick: () => {},
75+
}));
76+
},
77+
[setSearchQuery]
78+
);
79+
80+
const HashWpMenu = useMemo(
81+
() => createHashWpMenuComponent(editor as any, searchResultsRef),
82+
[editor]
83+
);
84+
5385
return (
5486
<BlockNoteView editor={editor} slashMenu={false}>
5587
<SuggestionMenuController
5688
triggerCharacter="/"
57-
getItems={getItems}
89+
getItems={getSlashItems}
90+
/>
91+
92+
<SuggestionMenuController
93+
triggerCharacter="#"
94+
getItems={getHashItems}
95+
suggestionMenuComponent={HashWpMenu}
96+
onItemClick={(item) => item.onItemClick()}
5897
/>
5998
</BlockNoteView>
6099
);

0 commit comments

Comments
 (0)