Repository navigation
[BNE-148] Create work package link block via hash command #246
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
ihordubas99
merged 1 commit into
dev
from
feature/bne-148-create-work-package-link-block-via-hash-command
Sep 30, 2026
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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} | ||
| /> | ||
| ); | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| }); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
|
|
||
| // 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; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.