Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 6 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ Create the editor:
const editor = useCreateBlockNote({ schema });
```

Build the slash and hash menus:
Build the slash menu:

```tsx
const getSlashItems = useCallback(
Expand All @@ -96,8 +96,6 @@ const getSlashItems = useCallback(
),
[editor]
);

const { getHashItems, HashWpMenu } = useHashWpMenu(editor);
```

`OpenProjectFormattingToolbar` is BlockNote's formatting toolbar with everything this library adds to it - currently a "Create work package" button on a text selection: the selected text names the work package, and the rich link for it takes the text's place in the document once it exists - a card where the selection hands over whole paragraphs, an inline chip within a line of text. It is offered for a selection that reads as a subject, so not for one that already holds a work package. Render it beside the editor and turn off the toolbar BlockNote brings itself, with `formattingToolbar={false}`.
Expand All @@ -106,6 +104,10 @@ Where the host has toolbar items of its own to place, compose the toolbar by han

`getOpenProjectSlashMenuItems` returns every item this library offers: linking an existing work package, and creating a new one through a form and linking it. Both insert a card on an empty line and an inline chip within a line of text.

`OpenProjectHashMenu` is the `#` command for linking a work package: typing anything after the hash searches, and how many hashes stand before the query decides what the picked work package becomes - `#` a chip carrying its ID, `##` one that adds the type and the subject, `###` one that adds the status as well, and `####` a card of its own. Invoked within a line, `####` breaks the line around the card, the way resizing an inline link into a card does. A longer run of hashes is not a command and stays on screen as typed. Render it beside the editor.

Where the host builds the `#` menu itself, `useHashWpMenu(editor)` hands back the pieces instead: `getHashItems`, `HashWpMenu` and `shouldOpenHashMenu`. All three belong on the `SuggestionMenuController` - the last one is what keeps a run longer than `####` from searching.

The create form is built from the work package form endpoint of the API, so the attributes it asks for - and their labels - come from the OpenProject instance: subject, project, type, assignee, plus every other attribute the selected type requires. Attributes the API already has a default for (status and priority, for instance) are left to it and are not shown, required or not.

Include everything in a `BlockNoteView`:
Expand All @@ -118,11 +120,7 @@ return (
triggerCharacter="/"
getItems={getSlashItems}
/>
<SuggestionMenuController
triggerCharacter="#"
getItems={getHashItems}
suggestionMenuComponent={HashWpMenu}
/>
<OpenProjectHashMenu />
</BlockNoteView>
);
```
Expand Down
2 changes: 1 addition & 1 deletion lib/components/HashMenu/HashWpMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { useTranslation } from 'react-i18next';
* a flex item won't actually clip / scroll unless its min-height is
* explicitly zero. (Harmless on non-flex layouts.)
*/
const Menu = styled.div.attrs({ className: 'op-bn-hash-menu' })`
const Menu = styled.div.attrs({ className: 'op-bn-hash-menu', 'data-testid': 'hash-menu' })`
${defaultWpVariables}
${menuSurfaceStyles}
box-shadow: var(--bn-shadow-medium);
Expand Down
19 changes: 19 additions & 0 deletions lib/components/HashMenu/OpenProjectHashMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { SuggestionMenuController, useBlockNoteEditor } from '@blocknote/react';
import { canOpenHashMenu } from './hashTrigger';
import { useHashWpMenu } from './useHashWpMenu';

const HASH_TRIGGER_CHARACTER = '#';

export const OpenProjectHashMenu = () => {
const editor = useBlockNoteEditor();
const { getHashItems, HashWpMenu } = useHashWpMenu(editor);

return (
<SuggestionMenuController
triggerCharacter={HASH_TRIGGER_CHARACTER}
getItems={getHashItems}
shouldOpen={canOpenHashMenu}
suggestionMenuComponent={HashWpMenu}
/>
);
};
132 changes: 66 additions & 66 deletions lib/components/HashMenu/editorUtils.ts
Original file line number Diff line number Diff line change
@@ -1,94 +1,94 @@
import type { AnyEditor } from '../../editorTypes';
import { chipContentOf } from '../../utils/inlineChipActions';
import type { InlineWpSize } from '../WorkPackage/types';
import type { WorkPackage } from '../../openProjectTypes';
import { chipContentOf, promoteInlineChipToBlockAt } from '../../utils/inlineChipActions';
import { INLINE_WP_TYPE } from '../../utils/nodeTypes';
import type { BlockWpSize, InlineWpSize } from '../WorkPackage/types';
import type { HashTarget } from './hashTrigger';

interface RawNode {
type:string;
text?:string;
props?:Record<string, string | undefined>;
[key:string]:unknown;
}

/**
* Determines the inline chip size based on how many `#` characters
* the user typed before the search query in the current block.
*
* #query -> "xxs" (ID only)
* ##query -> "xs" (ID + Type + Subject)
* ###query -> "s" (ID + Type + Status + Subject)
*/
export function getSizeFromCurrentBlock(editor:AnyEditor):InlineWpSize {
const block = editor.getTextCursorPosition()?.block;
if (!block) return 'xxs';
// Discarded right away, so the cheapest chip to render will do.
const SPLIT_MARKER_SIZE:InlineWpSize = 'xxs';

const content = (block.content ?? []) as RawNode[];
let lastHashCount:number | null = null;

for (const node of content) {
if (node.type !== 'text') continue;
const text = node.text ?? '';
const matches = [...text.matchAll(/#+/g)];
if (matches.length > 0) {
lastHashCount = matches[matches.length - 1][0].length;
}
}

if (lastHashCount === null) return 'xxs';
if (lastHashCount >= 3) return 's';
if (lastHashCount === 2) return 'xs';
return 'xxs';
export function insertWpForTarget(
editor:AnyEditor,
workPackage:WorkPackage,
target:HashTarget
):void {
if (target.kind === 'inline') insertWpChip(editor, workPackage, target.size);
else if (target.kind === 'block') insertWpBlockCard(editor, workPackage, target.size);
}

/**
* Inserts a chip followed by a trailing space at the cursor, then removes any
* leftover trigger hashes immediately before it.
*
* The chip and its trailing space are inserted together; `insertInlineContent`
* leaves the cursor directly after the space, which is exactly where we want it.
* We deliberately avoid placing the cursor with a separate selection
* transaction — under real-time collaboration (Yjs/Hocuspocus) any selection
* change dispatched after the menu insertion leaves the editor in a state where
* the following native keyboard input (e.g. Backspace) is silently dropped.
* Relying on the natural post-insertion cursor sidesteps that entirely.
* `insertInlineContent` leaves the cursor directly after the space, which is
* exactly where we want it. We deliberately avoid placing the cursor with a
* separate selection transaction — under real-time collaboration
* (Yjs/Hocuspocus) any selection change dispatched after the menu insertion
* leaves the editor in a state where the following native keyboard input
* (e.g. Backspace) is silently dropped.
*/
export function insertWpChip(editor:AnyEditor, wp:WorkPackage, size:InlineWpSize):void {
(editor.insertInlineContent as (content:unknown[]) => void)([
chipContentOf(wp, size),
{ type: 'text', text: ' ', styles: {} },
]);

// The chip (nodeSize 1) and its trailing space (length 1) were just inserted;
// insertInlineContent leaves the cursor right after the space.
const chipPosition = editor.prosemirrorState.selection.from - 2;
removeTriggerBeforeChip(editor, chipPosition);
export function insertWpChip(
editor:AnyEditor,
workPackage:WorkPackage,
size:InlineWpSize
):void {
insertChipAtCursor(editor, workPackage, size, { withTrailingSpace: true });
editor.focus();
}

function insertWpBlockCard(
editor:AnyEditor,
workPackage:WorkPackage,
size:BlockWpSize
):void {
editor.transact(() => {
const chipPosition = insertChipAtCursor(editor, workPackage, SPLIT_MARKER_SIZE, {
withTrailingSpace: false,
});
promoteInlineChipToBlockAt(editor, chipPosition, size);
});
}

function insertChipAtCursor(
editor:AnyEditor,
workPackage:WorkPackage,
size:InlineWpSize,
{ withTrailingSpace }:{ withTrailingSpace:boolean }
):number {
const content:unknown[] = [chipContentOf(workPackage, size)];
if (withTrailingSpace) content.push({ type: 'text', text: ' ', styles: {} });

const chipPosition = editor.transact((tr) => tr.selection.from);
(editor.insertInlineContent as (content:unknown[]) => void)(content);

return removeTriggerBeforeChip(editor, chipPosition);
}

export function restoreHashQuery(editor:AnyEditor, query:string):void {
(editor.insertInlineContent as (content:unknown[]) => void)([
{ type: 'text', text: `#${query}`, styles: {} },
]);
}

/**
* Removes the leftover trigger hashes (`#`/`##`) that BlockNote's suggestion menu
* leaves directly before the chip for `##`/`###` triggers.
* Removes the leftover trigger hashes (`#`/`##`/`###`) that BlockNote's
* suggestion menu leaves directly before the chip for `##`-and-longer triggers,
* and returns the chip's position afterwards.
*/
export function removeTriggerBeforeChip(editor:AnyEditor, chipPosition:number):void {
const { doc } = editor.prosemirrorState;

const chipNode = doc.nodeAt(chipPosition);
if (chipNode?.type.name !== 'openProjectWorkPackageInline') return;
export function removeTriggerBeforeChip(editor:AnyEditor, chipPosition:number):number {
return editor.transact((tr) => {
const chipNode = tr.doc.nodeAt(chipPosition);
if (chipNode?.type.name !== INLINE_WP_TYPE) return chipPosition;

const nodeBeforeChip = doc.resolve(chipPosition).nodeBefore;
if (!nodeBeforeChip?.isText || nodeBeforeChip.text == null) return;
const nodeBeforeChip = tr.doc.resolve(chipPosition).nodeBefore;
if (!nodeBeforeChip?.isText || nodeBeforeChip.text == null) return chipPosition;

const match = /#+$/.exec(nodeBeforeChip.text);
if (!match) return;
const match = /#+$/.exec(nodeBeforeChip.text);
if (!match) return chipPosition;

const triggerStart = chipPosition - (nodeBeforeChip.text.length - match.index);
editor.transact((tr) => {
const triggerStart = chipPosition - (nodeBeforeChip.text.length - match.index);
tr.delete(triggerStart, chipPosition);
return triggerStart;
});
}
67 changes: 67 additions & 0 deletions lib/components/HashMenu/hashTrigger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import type { Node as ProsemirrorNode } from 'prosemirror-model';
import type { Transaction } from 'prosemirror-state';
import type { AnyEditor } from '../../editorTypes';
import type { BlockWpSize, InlineWpSize } from '../WorkPackage/types';

/**
* What the hash menu inserts for the work package the user picks. How many `#`
* characters precede the search query decides it:
*
* #query -> inline chip, "xxs" (ID only)
* ##query -> inline chip, "xs" (ID + type + subject)
* ###query -> inline chip, "s" (ID + type + status + subject)
* ####query -> block card, "m"
* #####... -> nothing; the hashes stay on screen as typed
*/
export type HashTarget =
| { kind:'inline'; size:InlineWpSize }
| { kind:'block'; size:BlockWpSize }
| { kind:'none' };

const TARGET_BY_HASH_COUNT:readonly HashTarget[] = [
{ kind: 'inline', size: 'xxs' },
{ kind: 'inline', size: 'xs' },
{ kind: 'inline', size: 's' },
{ kind: 'block', size: 'm' },
];

const NO_TARGET:HashTarget = { kind: 'none' };

export const MAX_TRIGGER_HASHES = TARGET_BY_HASH_COUNT.length;

export function hashTargetFor(editor:AnyEditor, query:string):HashTarget {
const { doc, selection } = editor.prosemirrorState;
const queryStart = Math.max(0, selection.from - query.length);
const hashCount = trailingHashes(doc, queryStart) + leadingHashes(query);

return TARGET_BY_HASH_COUNT[hashCount - 1] ?? NO_TARGET;
}

/**
* Runs before the typed `#` is inserted, so the run standing in the document is
* one shorter than the one the user ends up with: at `###` this opens the menu
* that `####` needs, at `####` it refuses the fifth hash.
*/
export function canOpenHashMenu(transaction:Transaction):boolean {
return trailingHashes(transaction.doc, transaction.selection.from) < MAX_TRIGGER_HASHES;
}
Comment thread
ihordubas99 marked this conversation as resolved.

// Keeps hash runs on either side of an atom node (an inline chip) apart.
const LEAF_PLACEHOLDER = '\uFFFC';

function trailingHashes(doc:ProsemirrorNode, position:number):number {
const resolved = doc.resolve(position);
if (!resolved.parent.isTextblock) return 0;

const textBefore = resolved.parent.textBetween(
0,
resolved.parentOffset,
undefined,
LEAF_PLACEHOLDER
);
return /#*$/.exec(textBefore)?.[0].length ?? 0;
}

function leadingHashes(query:string):number {
return /^#*/.exec(query)?.[0].length ?? 0;
}
3 changes: 2 additions & 1 deletion lib/components/HashMenu/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { OpenProjectHashMenu } from './OpenProjectHashMenu';
export { createHashWpMenuComponent } from './HashWpMenu';
export { useHashWpMenu } from './useHashWpMenu';
export { isHashWpQuery } from './types';
export type { HashMenuItem, HashSearchState } from './types';
export type { HashMenuItem, HashSearchState } from './types';
15 changes: 11 additions & 4 deletions lib/components/HashMenu/useHashWpMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import { useCallback, useMemo, useRef } from 'react';
import { MAX_SEARCH_RESULTS, useWorkPackageSearch } from '../../hooks/useWorkPackageSearch';
import { createHashWpMenuComponent } from './HashWpMenu';
import { isHashWpQuery } from './types';
import { getSizeFromCurrentBlock, insertWpChip, restoreHashQuery } from './editorUtils';
import { canOpenHashMenu, hashTargetFor } from './hashTrigger';
import { insertWpForTarget, restoreHashQuery } from './editorUtils';
import type { HashMenuItem, HashSearchState } from './types';
import type { AnyEditor } from '../../editorTypes';
import { cacheColors } from '../../services/colors';
import { closeSuggestionMenu } from '../../utils/suggestionMenu';

export function useHashWpMenu(editor:AnyEditor) {
const { search } = useWorkPackageSearch();
Expand All @@ -26,6 +28,12 @@ export function useHashWpMenu(editor:AnyEditor) {
async (query:string):Promise<HashMenuItem[]> => {
latestQueryRef.current = query;

const target = hashTargetFor(editor, query);
if (target.kind === 'none') {
closeSuggestionMenu(editor);
return [];
}

if (!isHashWpQuery(query)) {
searchStateRef.current = { query, results: [], error: null };
return placeholderItems(query);
Expand All @@ -41,11 +49,10 @@ export function useHashWpMenu(editor:AnyEditor) {

if (results.length === 0) return placeholderItems(query);

const size = getSizeFromCurrentBlock(editor);
return results.map((wp) => ({
title: query,
onItemClick: () => {
insertWpChip(editor, wp, size);
insertWpForTarget(editor, wp, target);
},
}));
} catch (error) {
Expand All @@ -70,5 +77,5 @@ export function useHashWpMenu(editor:AnyEditor) {
);
/* eslint-enable react-hooks/refs */

return { getHashItems, HashWpMenu };
return { getHashItems, HashWpMenu, shouldOpenHashMenu: canOpenHashMenu };
}
2 changes: 1 addition & 1 deletion lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export {
export type { CreateWorkPackageFromSelection } from './components';
export { initializeOpBlockNoteExtensions } from './initialize';
export type { InlineWpSize, BlockWpSize, WpSize } from './components/WorkPackage/types';
export { createHashWpMenuComponent, isHashWpQuery, useHashWpMenu } from './components/HashMenu';
export { OpenProjectHashMenu, createHashWpMenuComponent, isHashWpQuery, useHashWpMenu } from './components/HashMenu';
export type { HashMenuItem } from './components/HashMenu';
export { useWorkPackageSearch } from './hooks/useWorkPackageSearch';
export type { WorkPackage } from './openProjectTypes';
8 changes: 5 additions & 3 deletions lib/utils/inlineChipActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,10 @@ export function chipContentOf(workPackage:WorkPackage, size:InlineWpSize):ChipCo
}

function chipAt(editor:AnyEditor, position:number):ProsemirrorNode | null {
const node = editor.prosemirrorState.doc.nodeAt(position);
return node?.type.name === INLINE_WP_TYPE ? node : null;
return editor.transact((tr) => {
const node = tr.doc.nodeAt(position);
return node?.type.name === INLINE_WP_TYPE ? node : null;
});
}

export function selectInlineChipAt(editor:AnyEditor, position:number):void {
Expand Down Expand Up @@ -123,7 +125,7 @@ export function promoteInlineChipToBlockAt(
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const displayId = ((node.attrs.displayId || String(node.attrs.wpid)) as string);

const $position = editor.prosemirrorState.doc.resolve(position);
const $position = editor.transact((tr) => tr.doc.resolve(position));

let blockId:string | undefined;
for (let depth = $position.depth; depth > 0; depth--) {
Expand Down
7 changes: 7 additions & 0 deletions lib/utils/suggestionMenu.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { SuggestionMenu } from '@blocknote/core/extensions';
import type { AnyEditor } from '../editorTypes';

/** Dismisses the open suggestion menu, leaving the typed text untouched. */
export function closeSuggestionMenu(editor:AnyEditor):void {
editor.getExtension(SuggestionMenu)?.closeMenu();
}
Loading
Loading