diff --git a/README.md b/README.md
index 9eaa4f3a..46cf76d5 100644
--- a/README.md
+++ b/README.md
@@ -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(
@@ -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}`.
@@ -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`:
@@ -118,11 +120,7 @@ return (
triggerCharacter="/"
getItems={getSlashItems}
/>
-
+
);
```
diff --git a/lib/components/HashMenu/HashWpMenu.tsx b/lib/components/HashMenu/HashWpMenu.tsx
index dfecc1ed..6c3b48a1 100644
--- a/lib/components/HashMenu/HashWpMenu.tsx
+++ b/lib/components/HashMenu/HashWpMenu.tsx
@@ -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);
diff --git a/lib/components/HashMenu/OpenProjectHashMenu.tsx b/lib/components/HashMenu/OpenProjectHashMenu.tsx
new file mode 100644
index 00000000..1941f85e
--- /dev/null
+++ b/lib/components/HashMenu/OpenProjectHashMenu.tsx
@@ -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 (
+
+ );
+};
diff --git a/lib/components/HashMenu/editorUtils.ts b/lib/components/HashMenu/editorUtils.ts
index 19990343..3fe990d5 100644
--- a/lib/components/HashMenu/editorUtils.ts
+++ b/lib/components/HashMenu/editorUtils.ts
@@ -1,70 +1,70 @@
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;
- [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: {} },
@@ -72,23 +72,23 @@ export function restoreHashQuery(editor:AnyEditor, query:string):void {
}
/**
- * 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;
});
}
diff --git a/lib/components/HashMenu/hashTrigger.ts b/lib/components/HashMenu/hashTrigger.ts
new file mode 100644
index 00000000..a4cbcdbd
--- /dev/null
+++ b/lib/components/HashMenu/hashTrigger.ts
@@ -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;
+}
+
+// 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;
+}
diff --git a/lib/components/HashMenu/index.ts b/lib/components/HashMenu/index.ts
index b946ae0c..bc02f640 100644
--- a/lib/components/HashMenu/index.ts
+++ b/lib/components/HashMenu/index.ts
@@ -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';
\ No newline at end of file
+export type { HashMenuItem, HashSearchState } from './types';
diff --git a/lib/components/HashMenu/useHashWpMenu.ts b/lib/components/HashMenu/useHashWpMenu.ts
index 8988b6bd..44b71c2e 100644
--- a/lib/components/HashMenu/useHashWpMenu.ts
+++ b/lib/components/HashMenu/useHashWpMenu.ts
@@ -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();
@@ -26,6 +28,12 @@ export function useHashWpMenu(editor:AnyEditor) {
async (query:string):Promise => {
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);
@@ -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) {
@@ -70,5 +77,5 @@ export function useHashWpMenu(editor:AnyEditor) {
);
/* eslint-enable react-hooks/refs */
- return { getHashItems, HashWpMenu };
+ return { getHashItems, HashWpMenu, shouldOpenHashMenu: canOpenHashMenu };
}
diff --git a/lib/index.ts b/lib/index.ts
index 34bf5cc0..2d7b89ff 100644
--- a/lib/index.ts
+++ b/lib/index.ts
@@ -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';
diff --git a/lib/utils/inlineChipActions.ts b/lib/utils/inlineChipActions.ts
index 64cfe69b..6baec18e 100644
--- a/lib/utils/inlineChipActions.ts
+++ b/lib/utils/inlineChipActions.ts
@@ -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 {
@@ -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--) {
diff --git a/lib/utils/suggestionMenu.ts b/lib/utils/suggestionMenu.ts
new file mode 100644
index 00000000..1e57b1d0
--- /dev/null
+++ b/lib/utils/suggestionMenu.ts
@@ -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();
+}
diff --git a/src/App.tsx b/src/App.tsx
index 1a2c8087..4e3be69b 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -15,7 +15,7 @@ import {
openProjectWorkPackageInlineSpec,
getOpenProjectSlashMenuItems,
OpenProjectFormattingToolbar,
- useHashWpMenu,
+ OpenProjectHashMenu,
} from '../lib';
import './fetchOverride';
@@ -52,8 +52,6 @@ export default function App() {
[editor]
);
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
- const { getHashItems, HashWpMenu } = useHashWpMenu(editor as any);
return (
@@ -63,11 +61,7 @@ export default function App() {
getItems={getSlashItems}
/>
-
+
);
}
diff --git a/test/helpers/editorHelpers.ts b/test/helpers/editorHelpers.ts
index 1e95a47f..56f41004 100644
--- a/test/helpers/editorHelpers.ts
+++ b/test/helpers/editorHelpers.ts
@@ -33,12 +33,33 @@ export async function insertInlineWorkPackageViaSlashMenu(searchTerm='Fix', resu
await expect.element(page.getByText(resultTerm)).toBeVisible();
}
-export async function insertInlineWorkPackageViaHash(hashes:string) {
- await openEditorAndType(`${hashes}Fix`);
+export const BLOCK_CARD_HASHES = '####';
+
+async function pickHashMenuResult() {
await expect.element(page.getByText('Fix login bug')).toBeVisible();
await userEvent.click(page.getByText('Fix login bug'));
}
+async function insertWorkPackageViaHash(trigger:string) {
+ await openEditorAndType(`${trigger}Fix`);
+ await pickHashMenuResult();
+}
+
+export async function insertInlineWorkPackageViaHash(hashes:string) {
+ await insertWorkPackageViaHash(hashes);
+}
+
+export async function insertBlockWorkPackageViaHash(textBefore = '') {
+ await insertWorkPackageViaHash(`${textBefore}${BLOCK_CARD_HASHES}`);
+ await expect.element(page.getByTestId('block-card')).toBeVisible();
+}
+
+export async function insertBlockWorkPackageViaHashAtCursor() {
+ await userEvent.keyboard(`${BLOCK_CARD_HASHES}Fix`);
+ await pickHashMenuResult();
+ await expect.element(page.getByTestId('block-card')).toBeVisible();
+}
+
// Inline chip - popover & size menu
export async function openInlineWorkPackagePopover(displayId = '#123') {
await userEvent.click(page.getByText(displayId).first());
@@ -94,11 +115,6 @@ export async function convertToCompactCard(displayId = '#123') {
}
export async function insertInlineWorkPackageViaHashWithTextBefore(before:string) {
- const editorEl = page.getByRole('textbox');
- await expect.element(editorEl).toBeVisible();
- await userEvent.click(editorEl);
- await userEvent.type(editorEl, `${before}#Fix`);
- await expect.element(page.getByText('Fix login bug')).toBeVisible();
- await userEvent.click(page.getByText('Fix login bug'));
+ await insertWorkPackageViaHash(`${before}#`);
await expect.element(page.getByText('#123')).toBeVisible();
}
\ No newline at end of file
diff --git a/test/helpers/headlessEditor.ts b/test/helpers/headlessEditor.ts
new file mode 100644
index 00000000..bbd5506f
--- /dev/null
+++ b/test/helpers/headlessEditor.ts
@@ -0,0 +1,91 @@
+import { BlockNoteEditor, BlockNoteSchema } from '@blocknote/core';
+import { TextSelection } from 'prosemirror-state';
+import {
+ openProjectWorkPackageBlockSpec,
+ openProjectWorkPackageInlineSpec,
+} from '../../lib';
+import type { AnyEditor } from '../../lib/editorTypes';
+import { INLINE_WP_TYPE } from '../../lib/utils/nodeTypes';
+
+const schema = BlockNoteSchema.create().extend({
+ blockSpecs: {
+ openProjectWorkPackageBlock: openProjectWorkPackageBlockSpec(),
+ },
+ inlineContentSpecs: {
+ openProjectWorkPackageInline: openProjectWorkPackageInlineSpec,
+ },
+});
+
+export interface InlineNode {
+ type:string;
+ text?:string;
+ props?:Record;
+}
+
+export function createHeadlessEditorWithBlocks(blocks:unknown[]) {
+ return BlockNoteEditor.create({ schema, initialContent: blocks as never });
+}
+
+export function createHeadlessEditor(content:unknown[] | string) {
+ return createHeadlessEditorWithBlocks([{ type: 'paragraph', content }]);
+}
+
+export function createHeadlessEditorWithText(text:string) {
+ const editor = createHeadlessEditor(text);
+ editor.setTextCursorPosition(editor.document[0], 'end');
+ return editor;
+}
+
+export function chipContent(wpid:string, size = 's') {
+ return { type: INLINE_WP_TYPE, props: { wpid, size } };
+}
+
+export function textContent(value:string) {
+ return { type: 'text', text: value, styles: {} };
+}
+
+export function blockContent(editor:AnyEditor, blockIndex = 0):InlineNode[] {
+ return (editor.document[blockIndex]?.content ?? []) as InlineNode[];
+}
+
+export function blockTypes(editor:AnyEditor):string[] {
+ return editor.document.map((block) => block.type as string);
+}
+
+export function blockText(editor:AnyEditor, blockIndex = 0):string {
+ return blockContent(editor, blockIndex).map((node) => node.text ?? '').join('');
+}
+
+export function nthChipPosition(editor:AnyEditor, ordinal = 0):number {
+ let seen = -1;
+ let found = -1;
+ editor.prosemirrorState.doc.descendants((node, position) => {
+ if (found !== -1) return false;
+ if (node.type.name === INLINE_WP_TYPE) {
+ seen += 1;
+ if (seen === ordinal) {
+ found = position;
+ return false;
+ }
+ }
+ return true;
+ });
+ return found;
+}
+
+export function placeCursorAfterText(editor:AnyEditor, marker:string):void {
+ let target = -1;
+ editor.prosemirrorState.doc.descendants((node, position) => {
+ if (target !== -1) return false;
+ if (node.isText && node.text?.includes(marker)) {
+ target = position + node.text.indexOf(marker) + marker.length;
+ return false;
+ }
+ return true;
+ });
+ if (target === -1) throw new Error(`"${marker}" is not in the document`);
+
+ editor.transact((tr) => {
+ tr.setSelection(TextSelection.create(tr.doc, target));
+ });
+}
diff --git a/test/helpers/renderEditor.tsx b/test/helpers/renderEditor.tsx
index bb2ade9e..620ad597 100644
--- a/test/helpers/renderEditor.tsx
+++ b/test/helpers/renderEditor.tsx
@@ -11,7 +11,7 @@ import {
openProjectWorkPackageInlineSpec,
getOpenProjectSlashMenuItems,
OpenProjectFormattingToolbar,
- useHashWpMenu,
+ OpenProjectHashMenu,
ShadowDomWrapper,
} from '../../lib';
@@ -42,8 +42,6 @@ function Editor({ onEditor, schema, editable = true, initialContent }:EditorOpti
});
onEditor?.(editor);
- const { getHashItems, HashWpMenu } = useHashWpMenu(editor as any);
-
const getSlashItems = useCallback(
async (query:string) =>
filterSuggestionItems(
@@ -61,11 +59,7 @@ function Editor({ onEditor, schema, editable = true, initialContent }:EditorOpti
-
+
);
diff --git a/test/lib/components/HashMenu/editorUtils.test.ts b/test/lib/components/HashMenu/editorUtils.test.ts
new file mode 100644
index 00000000..8057e71c
--- /dev/null
+++ b/test/lib/components/HashMenu/editorUtils.test.ts
@@ -0,0 +1,153 @@
+// @vitest-environment jsdom
+import { describe, it, expect } from 'vitest';
+import {
+ insertWpChip,
+ insertWpForTarget,
+ removeTriggerBeforeChip,
+} from '../../../../lib/components/HashMenu/editorUtils';
+import { INLINE_WP_TYPE, BLOCK_WP_TYPE } from '../../../../lib/utils/nodeTypes';
+import type { WorkPackage } from '../../../../lib/openProjectTypes';
+import {
+ blockContent,
+ blockText,
+ blockTypes,
+ chipContent,
+ createHeadlessEditor,
+ createHeadlessEditorWithText,
+ nthChipPosition,
+ placeCursorAfterText,
+ textContent,
+} from '../../../helpers/headlessEditor';
+
+const workPackage = { id: 1, displayId: '1', subject: 'Fix bug' } as unknown as WorkPackage;
+
+describe('removeTriggerBeforeChip', () => {
+ it('removes a single leftover # before the chip', () => {
+ const editor = createHeadlessEditor([textContent('Hello #'), chipContent('1', 'xxs')]);
+
+ removeTriggerBeforeChip(editor, nthChipPosition(editor));
+
+ expect(blockText(editor)).toBe('Hello ');
+ });
+
+ it('removes multiple leftover hashes (##, ###) before the chip', () => {
+ const editor = createHeadlessEditor([textContent('Hello ###'), chipContent('1')]);
+
+ removeTriggerBeforeChip(editor, nthChipPosition(editor));
+
+ expect(blockText(editor)).toBe('Hello ');
+ });
+
+ it('returns the position the chip moved to', () => {
+ const editor = createHeadlessEditor([textContent('Hello ###'), chipContent('1')]);
+ const positionBefore = nthChipPosition(editor);
+
+ const positionAfter = removeTriggerBeforeChip(editor, positionBefore);
+
+ expect(positionAfter).toBe(positionBefore - 3);
+ expect(positionAfter).toBe(nthChipPosition(editor));
+ });
+
+ it('leaves an earlier # in the line alone — removes only the trailing hashes', () => {
+ const editor = createHeadlessEditor([
+ textContent('Pre #one #two #'),
+ chipContent('1', 'xxs'),
+ ]);
+
+ removeTriggerBeforeChip(editor, nthChipPosition(editor));
+
+ expect(blockText(editor)).toBe('Pre #one #two ');
+ });
+
+ it('preserves a preceding #word — does not over-delete an existing hash like "#42"', () => {
+ const editor = createHeadlessEditor([textContent('See PR #42'), chipContent('1', 'xxs')]);
+
+ removeTriggerBeforeChip(editor, nthChipPosition(editor));
+
+ expect(blockText(editor)).toBe('See PR #42');
+ });
+
+ it('removes the previous text node entirely when only # remains', () => {
+ const editor = createHeadlessEditor([textContent('#'), chipContent('1', 'xxs')]);
+
+ removeTriggerBeforeChip(editor, nthChipPosition(editor));
+
+ expect(blockContent(editor)[0].type).toBe(INLINE_WP_TYPE);
+ });
+
+ it('does nothing if the given position is not a chip', () => {
+ const editor = createHeadlessEditorWithText('Hello #foo');
+ const before = JSON.stringify(editor.document);
+
+ expect(removeTriggerBeforeChip(editor, 0)).toBe(0);
+ expect(JSON.stringify(editor.document)).toBe(before);
+ });
+
+ it('does nothing when there is no preceding text node', () => {
+ const editor = createHeadlessEditor([chipContent('1', 'xxs')]);
+ const before = JSON.stringify(editor.document);
+
+ removeTriggerBeforeChip(editor, nthChipPosition(editor));
+
+ expect(JSON.stringify(editor.document)).toBe(before);
+ });
+});
+
+describe('insertWpChip', () => {
+ it('inserts a chip with the work package ID and size, followed by a space', () => {
+ const editor = createHeadlessEditorWithText('test ');
+
+ insertWpChip(editor, workPackage, 'xxs');
+
+ expect(blockContent(editor)).toMatchObject([
+ { type: 'text', text: 'test ' },
+ { type: INLINE_WP_TYPE, props: { wpid: '1', size: 'xxs' } },
+ { type: 'text', text: ' ' },
+ ]);
+ });
+});
+
+describe('insertWpForTarget', () => {
+ it('inserts an inline chip of the target size, replacing the trigger hashes', () => {
+ const editor = createHeadlessEditorWithText('##');
+
+ insertWpForTarget(editor, workPackage, { kind: 'inline', size: 'xs' });
+
+ expect(blockTypes(editor)).toEqual(['paragraph']);
+ expect(blockContent(editor)).toMatchObject([
+ { type: INLINE_WP_TYPE, props: { wpid: '1', size: 'xs' } },
+ { type: 'text', text: ' ' },
+ ]);
+ });
+
+ it('inserts a block card, replacing the trigger hashes and leaving no chip', () => {
+ const editor = createHeadlessEditorWithText('###');
+
+ insertWpForTarget(editor, workPackage, { kind: 'block', size: 'm' });
+
+ expect(blockTypes(editor)).toEqual([BLOCK_WP_TYPE, 'paragraph']);
+ expect(editor.document[0].props).toMatchObject({ wpid: 1, size: 'm', displayId: '1' });
+ expect(nthChipPosition(editor)).toBe(-1);
+ });
+
+ it('moves text that follows the trigger below the card', () => {
+ const editor = createHeadlessEditor('### World');
+ placeCursorAfterText(editor, '###');
+
+ insertWpForTarget(editor, workPackage, { kind: 'block', size: 'm' });
+
+ expect(blockTypes(editor)).toEqual([BLOCK_WP_TYPE, 'paragraph']);
+ expect(blockText(editor, 1)).toBe(' World');
+ });
+
+ it('splits the line, keeping the text before and after the trigger around the card', () => {
+ const editor = createHeadlessEditor('Hello ### World');
+ placeCursorAfterText(editor, '###');
+
+ insertWpForTarget(editor, workPackage, { kind: 'block', size: 'm' });
+
+ expect(blockTypes(editor)).toEqual(['paragraph', BLOCK_WP_TYPE, 'paragraph']);
+ expect(blockText(editor, 0)).toBe('Hello ');
+ expect(blockText(editor, 2)).toBe(' World');
+ });
+});
diff --git a/test/lib/components/HashMenu/hashTrigger.test.ts b/test/lib/components/HashMenu/hashTrigger.test.ts
new file mode 100644
index 00000000..b42ef836
--- /dev/null
+++ b/test/lib/components/HashMenu/hashTrigger.test.ts
@@ -0,0 +1,80 @@
+// @vitest-environment jsdom
+import { describe, it, expect } from 'vitest';
+import {
+ canOpenHashMenu,
+ hashTargetFor,
+ MAX_TRIGGER_HASHES,
+} from '../../../../lib/components/HashMenu/hashTrigger';
+import type { HashTarget } from '../../../../lib/components/HashMenu/hashTrigger';
+import {
+ chipContent,
+ createHeadlessEditor,
+ createHeadlessEditorWithText,
+ textContent,
+} from '../../../helpers/headlessEditor';
+
+function targetFor(blockText:string, query:string):HashTarget {
+ return hashTargetFor(createHeadlessEditorWithText(blockText), query);
+}
+
+describe('hashTargetFor', () => {
+ it('maps a hash run to an inline chip size', () => {
+ expect(targetFor('#bug', 'bug')).toEqual({ kind: 'inline', size: 'xxs' });
+ expect(targetFor('##bug', 'bug')).toEqual({ kind: 'inline', size: 'xs' });
+ expect(targetFor('###bug', 'bug')).toEqual({ kind: 'inline', size: 's' });
+ });
+
+ it('maps four hashes to a block card', () => {
+ expect(targetFor('####bug', 'bug')).toEqual({ kind: 'block', size: 'm' });
+ });
+
+ it('resolves to none for a longer run — the hashes stay literal', () => {
+ expect(targetFor('#####bug', '#bug')).toEqual({ kind: 'none' });
+ expect(targetFor('######bug', '##bug')).toEqual({ kind: 'none' });
+ });
+
+ it('resolves to none when no hash precedes the query', () => {
+ expect(targetFor('bug', 'bug')).toEqual({ kind: 'none' });
+ });
+
+ it('counts only the run directly before the query', () => {
+ expect(targetFor('Hello ####bug', 'bug')).toEqual({ kind: 'block', size: 'm' });
+ expect(targetFor('#42 ##bug', 'bug')).toEqual({ kind: 'inline', size: 'xs' });
+ expect(targetFor('#### #bug', 'bug')).toEqual({ kind: 'inline', size: 'xxs' });
+ });
+
+ it('does not join runs separated by an inline chip', () => {
+ const editor = createHeadlessEditor([
+ textContent('##'),
+ chipContent('1', 'xxs'),
+ textContent('##bug'),
+ ]);
+ editor.setTextCursorPosition(editor.document[0], 'end');
+
+ expect(hashTargetFor(editor, 'bug')).toEqual({ kind: 'inline', size: 'xs' });
+ });
+});
+
+describe('canOpenHashMenu', () => {
+ function canOpenNextHashAfter(standingText:string):boolean {
+ const editor = createHeadlessEditorWithText(standingText);
+ return canOpenHashMenu(editor.prosemirrorState.tr);
+ }
+
+ it('opens for every hash up to and including the one that means a block card', () => {
+ expect(canOpenNextHashAfter('')).toBe(true);
+ expect(canOpenNextHashAfter('Hello ')).toBe(true);
+ expect(canOpenNextHashAfter('#')).toBe(true);
+ expect(canOpenNextHashAfter('##')).toBe(true);
+ expect(canOpenNextHashAfter('###')).toBe(true);
+ });
+
+ it('refuses the hash that would grow the run past the longest supported one', () => {
+ expect(canOpenNextHashAfter('#'.repeat(MAX_TRIGGER_HASHES))).toBe(false);
+ expect(canOpenNextHashAfter('#'.repeat(MAX_TRIGGER_HASHES + 1))).toBe(false);
+ });
+
+ it('starts over after a non-hash character', () => {
+ expect(canOpenNextHashAfter('#### ')).toBe(true);
+ });
+});
diff --git a/test/lib/components/hashMenu.test.ts b/test/lib/components/hashMenu.test.ts
deleted file mode 100644
index 08e3978a..00000000
--- a/test/lib/components/hashMenu.test.ts
+++ /dev/null
@@ -1,234 +0,0 @@
-// @vitest-environment jsdom
-import { describe, it, expect } from 'vitest';
-import { BlockNoteEditor, BlockNoteSchema } from '@blocknote/core';
-import {
- openProjectWorkPackageBlockSpec,
- openProjectWorkPackageInlineSpec,
-} from '../../../lib';
-import {
- getSizeFromCurrentBlock,
- insertWpChip,
- removeTriggerBeforeChip,
-} from '../../../lib/components/HashMenu/editorUtils';
-
-const schema = BlockNoteSchema.create().extend({
- blockSpecs: {
- openProjectWorkPackageBlock: openProjectWorkPackageBlockSpec(),
- },
- inlineContentSpecs: {
- openProjectWorkPackageInline: openProjectWorkPackageInlineSpec,
- },
-});
-
-function createTestEditor(text:string) {
- const editor = BlockNoteEditor.create({
- schema,
- initialContent: [{ type: 'paragraph', content: text }],
- });
-
- const block = editor.document[0];
- editor.setTextCursorPosition(block, 'end');
-
- return editor;
-}
-
-function createEditorWithContent(content:any[]) {
- return BlockNoteEditor.create({
- schema,
- initialContent: [
- {
- type: 'paragraph',
- content,
- } as any,
- ],
- });
-}
-
-// Position of the inline work package chip in the document, or -1 if absent.
-function findChipPosition(editor:BlockNoteEditor):number {
- let found = -1;
- editor.prosemirrorState.doc.descendants((node, position) => {
- if (found !== -1) return false;
- if (node.type.name === 'openProjectWorkPackageInline') {
- found = position;
- return false;
- }
- return true;
- });
- return found;
-}
-
-describe('getSizeFromCurrentBlock', () => {
- it('returns xxs for #', () => {
- const editor = createTestEditor('#foo');
- expect(getSizeFromCurrentBlock(editor as any)).toBe('xxs');
- });
-
- it('returns xs for ##', () => {
- const editor = createTestEditor('##foo');
- expect(getSizeFromCurrentBlock(editor as any)).toBe('xs');
- });
-
- it('returns s for ### or more', () => {
- expect(getSizeFromCurrentBlock(createTestEditor('###foo') as any)).toBe('s');
- expect(getSizeFromCurrentBlock(createTestEditor('####foo') as any)).toBe('s');
- });
-
- it('returns xxs if no hashes', () => {
- const editor = createTestEditor('foo');
- expect(getSizeFromCurrentBlock(editor as any)).toBe('xxs');
- });
-
- it('uses the last hash trigger in the block', () => {
- const editor = createTestEditor('#first ##bug');
- expect(getSizeFromCurrentBlock(editor as any)).toBe('xs');
- });
-});
-
-describe('removeTriggerBeforeChip', () => {
- // BlockNote's suggestion menu removes the active `#query`; this function only
- // strips the leftover bare trigger hashes it leaves for `##`/`###`.
-
- it('removes a single leftover # before the chip', () => {
- const editor = createEditorWithContent([
- { type: 'text', text: 'Hello #', styles: {} },
- {
- type: 'openProjectWorkPackageInline',
- props: { wpid: '1', size: 'xxs' },
- },
- ]);
-
- removeTriggerBeforeChip(editor as any, findChipPosition(editor));
-
- const block = editor.getBlock(editor.document[0].id);
- expect((block?.content as any)[0].text).toBe('Hello ');
- });
-
- it('removes multiple leftover hashes (##, ###) before the chip', () => {
- const editor = createEditorWithContent([
- { type: 'text', text: 'Hello ###', styles: {} },
- {
- type: 'openProjectWorkPackageInline',
- props: { wpid: '1', size: 's' },
- },
- ]);
-
- removeTriggerBeforeChip(editor as any, findChipPosition(editor));
-
- const block = editor.getBlock(editor.document[0].id);
- expect((block?.content as any)[0].text).toBe('Hello ');
- });
-
- it('leaves an earlier # in the line alone — removes only the trailing hashes', () => {
- const editor = createEditorWithContent([
- { type: 'text', text: 'Pre #one #two #', styles: {} },
- {
- type: 'openProjectWorkPackageInline',
- props: { wpid: '1', size: 'xxs' },
- },
- ]);
-
- removeTriggerBeforeChip(editor as any, findChipPosition(editor));
-
- const block = editor.getBlock(editor.document[0].id);
- expect((block?.content as any)[0].text).toBe('Pre #one #two ');
- });
-
- it('preserves a preceding #word — does not over-delete an existing hash like "#42"', () => {
- // Regression: when the line already contains "#42" and a chip is inserted right
- // after it (BlockNote has already removed the real "#query" trigger), the "#42"
- // must survive — there are no leftover bare hashes to strip.
- const editor = createEditorWithContent([
- { type: 'text', text: 'See PR #42', styles: {} },
- {
- type: 'openProjectWorkPackageInline',
- props: { wpid: '1', size: 'xxs' },
- },
- ]);
-
- removeTriggerBeforeChip(editor as any, findChipPosition(editor));
-
- const block = editor.getBlock(editor.document[0].id);
- expect((block?.content as any)[0].text).toBe('See PR #42');
- });
-
- it('removes the previous text node entirely when only # remains', () => {
- const editor = createEditorWithContent([
- { type: 'text', text: '#', styles: {} },
- {
- type: 'openProjectWorkPackageInline',
- props: { wpid: '1', size: 'xxs' },
- },
- ]);
-
- removeTriggerBeforeChip(editor as any, findChipPosition(editor));
-
- const block = editor.getBlock(editor.document[0].id);
- expect((block?.content as any)[0].type).toBe('openProjectWorkPackageInline');
- });
-
- it('does nothing if the given position is not a chip', () => {
- const editor = createTestEditor('Hello #foo');
- const before = JSON.stringify(editor.document);
-
- removeTriggerBeforeChip(editor as any, 0);
-
- expect(JSON.stringify(editor.document)).toBe(before);
- });
-
- it('does nothing when there is no preceding text node', () => {
- const editor = createEditorWithContent([
- {
- type: 'openProjectWorkPackageInline',
- props: { wpid: '1', size: 'xxs' },
- },
- ]);
-
- const before = JSON.stringify(editor.document);
- removeTriggerBeforeChip(editor as any, findChipPosition(editor));
-
- expect(JSON.stringify(editor.document)).toBe(before);
- });
-});
-
-describe('insertWpChip', () => {
- it('inserts a chip with the work package ID and size', () => {
- const editor = createTestEditor('test ');
-
- insertWpChip(
- editor as any,
- { id: 1, subject: 'Fix bug' } as any,
- 'xxs',
- );
-
- const block = editor.getBlock(editor.document[0].id);
- const chip = (block?.content as any[]).find(
- (n) => n.type === 'openProjectWorkPackageInline',
- );
-
- expect(chip).toBeDefined();
- expect(chip.props.wpid).toBe('1');
- expect(chip.props.size).toBe('xxs');
- });
-
- it('inserts a trailing space after the chip', () => {
- const editor = createTestEditor('test ');
-
- insertWpChip(
- editor as any,
- { id: 1, subject: 'Fix bug' } as any,
- 'xxs',
- );
-
- const block = editor.getBlock(editor.document[0].id);
- const content = block?.content as any[];
-
- const chipIdx = content.findIndex(
- (n) => n.type === 'openProjectWorkPackageInline',
- );
- const afterChip = content[chipIdx + 1];
-
- expect(afterChip?.type).toBe('text');
- expect(afterChip?.text).toBe(' ');
- });
-});
diff --git a/test/lib/components/integration/blockWorkPackageHashTrigger.browser.test.tsx b/test/lib/components/integration/blockWorkPackageHashTrigger.browser.test.tsx
new file mode 100644
index 00000000..ee9229c7
--- /dev/null
+++ b/test/lib/components/integration/blockWorkPackageHashTrigger.browser.test.tsx
@@ -0,0 +1,83 @@
+import { describe, it, expect } from 'vitest';
+import { page, userEvent } from 'vitest/browser';
+import { renderEditor } from '../../../helpers/renderEditor';
+import {
+ BLOCK_CARD_HASHES,
+ insertBlockWorkPackageViaHash,
+ insertBlockWorkPackageViaHashAtCursor,
+ openEditorAndType,
+} from '../../../helpers/editorHelpers';
+
+const inlineChip = () =>
+ document.querySelector('[data-inline-content-type="openProjectWorkPackageInline"]');
+
+describe('Block card - #### trigger', () => {
+ it('#### inserts a block card instead of an inline chip', async () => {
+ renderEditor();
+
+ await insertBlockWorkPackageViaHash();
+
+ await expect.element(page.getByText('Fix login bug')).toBeVisible();
+ await expect.element(page.getByText('In Progress')).toBeVisible();
+ await expect.element(page.getByTestId('op-bn-work-package--type')).toBeVisible();
+ expect(inlineChip()).toBeNull();
+ });
+
+ it('leaves no trigger hashes behind', async () => {
+ renderEditor();
+
+ await insertBlockWorkPackageViaHash();
+
+ await expect.element(page.getByRole('textbox')).not.toHaveTextContent('###');
+ });
+
+ it('moves the card onto its own line when invoked after text', async () => {
+ renderEditor();
+
+ await insertBlockWorkPackageViaHash('Hello ');
+
+ await expect.element(page.getByText('Hello')).toBeVisible();
+ const helloEl = page.getByText('Hello').element();
+ const blockCardEl = page.getByTestId('block-card').element();
+ expect(blockCardEl.contains(helloEl)).toBe(false);
+ expect(helloEl.compareDocumentPosition(blockCardEl) & Node.DOCUMENT_POSITION_FOLLOWING)
+ .toBeTruthy();
+ });
+
+ it('splits the sentence it is invoked in around the card', async () => {
+ renderEditor();
+
+ await openEditorAndType('Hello World');
+ await userEvent.keyboard('{ArrowLeft>6/}');
+ await insertBlockWorkPackageViaHashAtCursor();
+
+ await expect.element(page.getByText('Hello', { exact: true })).toBeVisible();
+ await expect.element(page.getByText('World', { exact: true })).toBeVisible();
+ await expect.element(page.getByText('Hello World')).not.toBeInTheDocument();
+
+ const helloEl = page.getByText('Hello', { exact: true }).element();
+ const blockCardEl = page.getByTestId('block-card').element();
+ const worldEl = page.getByText('World', { exact: true }).element();
+ expect(helloEl.compareDocumentPosition(blockCardEl) & Node.DOCUMENT_POSITION_FOLLOWING)
+ .toBeTruthy();
+ expect(blockCardEl.compareDocumentPosition(worldEl) & Node.DOCUMENT_POSITION_FOLLOWING)
+ .toBeTruthy();
+ });
+
+ it('stops searching past #### and leaves the hashes as typed', async () => {
+ renderEditor();
+
+ await openEditorAndType(BLOCK_CARD_HASHES);
+ await expect.element(page.getByTestId('hash-menu')).toBeVisible();
+
+ await userEvent.keyboard('#');
+ await expect.element(page.getByTestId('hash-menu')).not.toBeInTheDocument();
+
+ await userEvent.keyboard('Fix');
+
+ await expect.element(page.getByTestId('hash-menu')).not.toBeInTheDocument();
+ await expect.element(page.getByRole('textbox')).toHaveTextContent('#####Fix');
+ expect(inlineChip()).toBeNull();
+ expect(document.querySelector('[data-testid="block-card"]')).toBeNull();
+ });
+});
diff --git a/test/lib/components/integration/editor.undo.browser.test.tsx b/test/lib/components/integration/editor.undo.browser.test.tsx
index d25061e2..65567825 100644
--- a/test/lib/components/integration/editor.undo.browser.test.tsx
+++ b/test/lib/components/integration/editor.undo.browser.test.tsx
@@ -3,6 +3,7 @@ import { page, userEvent } from 'vitest/browser';
import { renderEditor } from '../../../helpers/renderEditor';
import {
insertInlineWorkPackageViaSlashMenu,
+ insertBlockWorkPackageViaHash,
insertInlineWorkPackageViaHash,
convertToCompactCard,
openInlineWorkPackageSizeMenu,
@@ -64,6 +65,16 @@ describe('Undo (Ctrl+Z)', () => {
await expect.element(page.getByTestId('op-bn-work-package--type')).not.toBeInTheDocument();
});
+ it('undoes block card inserted via #### hash — card is gone', async () => {
+ renderEditor();
+ await insertBlockWorkPackageViaHash();
+
+ await ctrl_z();
+
+ await expect.element(page.getByTestId('block-card')).not.toBeInTheDocument();
+ await expect.element(page.getByText('#123')).not.toBeInTheDocument();
+ });
+
it('undoes inline -> block conversion — block card is gone, inline chip is restored', async () => {
renderEditor();
await insertInlineWorkPackageViaSlashMenu();
diff --git a/test/lib/utils/inlineChipActions.test.ts b/test/lib/utils/inlineChipActions.test.ts
index d098615c..cd52bc87 100644
--- a/test/lib/utils/inlineChipActions.test.ts
+++ b/test/lib/utils/inlineChipActions.test.ts
@@ -1,75 +1,24 @@
// @vitest-environment jsdom
import { describe, it, expect } from 'vitest';
-import { BlockNoteEditor, BlockNoteSchema } from '@blocknote/core';
-import {
- openProjectWorkPackageBlockSpec,
- openProjectWorkPackageInlineSpec,
-} from '../../../lib';
import {
findPendingInlineChip,
removeInlineChipAt,
promoteInlineChipToBlockAt,
convertBlockToInlineChip,
} from '../../../lib/utils/inlineChipActions';
-
-const schema = BlockNoteSchema.create().extend({
- blockSpecs: {
- openProjectWorkPackageBlock: openProjectWorkPackageBlockSpec(),
- },
- inlineContentSpecs: {
- openProjectWorkPackageInline: openProjectWorkPackageInlineSpec,
- },
-});
-
-
-function createEditorWithContent(content:any[]) {
- return BlockNoteEditor.create({
- schema,
- initialContent: [
- {
- type: 'paragraph',
- content,
-
- } as any,
- ],
- });
-}
-
-function chip(wpid:string, size = 's') {
- return { type: 'openProjectWorkPackageInline', props: { wpid, size } };
-}
-
-function text(value:string) {
- return { type: 'text', text: value, styles: {} };
-}
-
-// Position of the nth (0-based) inline chip in the document.
-function nthChipPosition(editor:BlockNoteEditor, ordinal:number):number {
- let seen = -1;
- let found = -1;
- editor.prosemirrorState.doc.descendants((node, position) => {
- if (found !== -1) return false;
- if (node.type.name === 'openProjectWorkPackageInline') {
- seen += 1;
- if (seen === ordinal) {
- found = position;
- return false;
- }
- }
- return true;
- });
- return found;
-}
-
-
-function blockContent(editor:BlockNoteEditor, blockIndex = 0):any[] {
-
- return (editor.document[blockIndex]?.content ?? []) as any[];
-}
+import { BLOCK_WP_TYPE, INLINE_WP_TYPE } from '../../../lib/utils/nodeTypes';
+import {
+ blockContent,
+ chipContent,
+ createHeadlessEditor,
+ createHeadlessEditorWithBlocks,
+ nthChipPosition,
+ textContent,
+} from '../../helpers/headlessEditor';
describe('findPendingInlineChip', () => {
it('finds the chip with the given pending wpid', () => {
- const editor = createEditorWithContent([text('a'), chip('pending:x'), text('b')]);
+ const editor = createHeadlessEditor([textContent('a'), chipContent('pending:x'), textContent('b')]);
const found = findPendingInlineChip(editor.prosemirrorState.doc, 'pending:x');
@@ -79,13 +28,13 @@ describe('findPendingInlineChip', () => {
});
it('returns null when no chip matches', () => {
- const editor = createEditorWithContent([text('a'), chip('1')]);
+ const editor = createHeadlessEditor([textContent('a'), chipContent('1')]);
expect(findPendingInlineChip(editor.prosemirrorState.doc, 'pending:x')).toBeNull();
});
it('returns null when the wpid does not carry the pending prefix, even if a matching chip exists', () => {
- const editor = createEditorWithContent([chip('1')]);
+ const editor = createHeadlessEditor([chipContent('1')]);
expect(findPendingInlineChip(editor.prosemirrorState.doc, '1')).toBeNull();
});
@@ -93,18 +42,18 @@ describe('findPendingInlineChip', () => {
describe('removeInlineChipAt', () => {
it('removes exactly the chip at the given position', () => {
- const editor = createEditorWithContent([chip('1'), text(' between '), chip('1')]);
+ const editor = createHeadlessEditor([chipContent('1'), textContent(' between '), chipContent('1')]);
removeInlineChipAt(editor, nthChipPosition(editor, 1));
const content = blockContent(editor);
- const chips = content.filter((n) => n.type === 'openProjectWorkPackageInline');
+ const chips = content.filter((n) => n.type === INLINE_WP_TYPE);
expect(chips).toHaveLength(1);
- expect(content[0].type).toBe('openProjectWorkPackageInline');
+ expect(content[0].type).toBe(INLINE_WP_TYPE);
});
it('does nothing when the position does not hold a chip', () => {
- const editor = createEditorWithContent([text('abc'), chip('1')]);
+ const editor = createHeadlessEditor([textContent('abc'), chipContent('1')]);
const before = JSON.stringify(editor.document);
removeInlineChipAt(editor, 1);
@@ -115,58 +64,55 @@ describe('removeInlineChipAt', () => {
describe('promoteInlineChipToBlockAt', () => {
it('replaces a lone chip with a block card', () => {
- const editor = createEditorWithContent([chip('7')]);
+ const editor = createHeadlessEditor([chipContent('7')]);
promoteInlineChipToBlockAt(editor, nthChipPosition(editor, 0), 'm');
const blocks = editor.document;
- expect(blocks[0].type).toBe('openProjectWorkPackageBlock');
-
- expect((blocks[0].props as any).wpid).toBe(7);
-
- expect((blocks[0].props as any).size).toBe('m');
+ expect(blocks[0].type).toBe(BLOCK_WP_TYPE);
+ expect(blocks[0].props).toMatchObject({ wpid: 7, size: 'm' });
});
it('keeps content before the chip in the original paragraph', () => {
- const editor = createEditorWithContent([text('before '), chip('7')]);
+ const editor = createHeadlessEditor([textContent('before '), chipContent('7')]);
promoteInlineChipToBlockAt(editor, nthChipPosition(editor, 0), 'm');
const blocks = editor.document;
expect(blocks[0].type).toBe('paragraph');
expect(blockContent(editor, 0)[0].text).toBe('before ');
- expect(blocks[1].type).toBe('openProjectWorkPackageBlock');
+ expect(blocks[1].type).toBe(BLOCK_WP_TYPE);
});
it('moves content after the chip into a new paragraph below the block', () => {
- const editor = createEditorWithContent([text('before '), chip('7'), text(' after')]);
+ const editor = createHeadlessEditor([textContent('before '), chipContent('7'), textContent(' after')]);
promoteInlineChipToBlockAt(editor, nthChipPosition(editor, 0), 'l');
const blocks = editor.document;
expect(blocks[0].type).toBe('paragraph');
- expect(blocks[1].type).toBe('openProjectWorkPackageBlock');
+ expect(blocks[1].type).toBe(BLOCK_WP_TYPE);
expect(blocks[2].type).toBe('paragraph');
expect(blockContent(editor, 2)[0].text).toBe(' after');
});
it('promotes the correct chip when identical chips share the paragraph', () => {
- const editor = createEditorWithContent([chip('7'), text(' mid '), chip('7'), text(' tail')]);
+ const editor = createHeadlessEditor([chipContent('7'), textContent(' mid '), chipContent('7'), textContent(' tail')]);
promoteInlineChipToBlockAt(editor, nthChipPosition(editor, 1), 'm');
const blocks = editor.document;
// First chip stays inline in the first paragraph.
const firstParagraphChips = blockContent(editor, 0).filter(
- (n) => n.type === 'openProjectWorkPackageInline'
+ (n) => n.type === INLINE_WP_TYPE
);
expect(firstParagraphChips).toHaveLength(1);
- expect(blocks[1].type).toBe('openProjectWorkPackageBlock');
+ expect(blocks[1].type).toBe(BLOCK_WP_TYPE);
expect(blockContent(editor, 2)[0].text).toBe(' tail');
});
it('does nothing for a pending (non-numeric) wpid', () => {
- const editor = createEditorWithContent([chip('pending:x')]);
+ const editor = createHeadlessEditor([chipContent('pending:x')]);
const before = JSON.stringify(editor.document);
promoteInlineChipToBlockAt(editor, nthChipPosition(editor, 0), 'm');
@@ -177,25 +123,18 @@ describe('promoteInlineChipToBlockAt', () => {
describe('convertBlockToInlineChip', () => {
it('replaces the block card with an inline chip paragraph', () => {
- const editor = BlockNoteEditor.create({
- schema,
- initialContent: [
- {
- type: 'openProjectWorkPackageBlock',
- props: { wpid: 7, size: 'm', displayId: '7' },
-
- } as any,
- ],
- });
+ const editor = createHeadlessEditorWithBlocks([
+ { type: BLOCK_WP_TYPE, props: { wpid: 7, size: 'm', displayId: '7' } },
+ ]);
const blockId = editor.document[0].id;
convertBlockToInlineChip(editor, blockId, 7, 'xs');
const blocks = editor.document;
expect(blocks[0].type).toBe('paragraph');
- const inserted = blockContent(editor, 0)[0];
- expect(inserted.type).toBe('openProjectWorkPackageInline');
- expect(inserted.props.wpid).toBe('7');
- expect(inserted.props.size).toBe('xs');
+ expect(blockContent(editor, 0)[0]).toMatchObject({
+ type: INLINE_WP_TYPE,
+ props: { wpid: '7', size: 'xs' },
+ });
});
});