Skip to content
Open
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
1 change: 1 addition & 0 deletions extensions/blocknote-extensions/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,4 @@ test/lib/components/integration/__screenshots__/*

# test attachments
.vitest-attachments
.vitest/
18 changes: 16 additions & 2 deletions extensions/blocknote-extensions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,16 @@ initializeOpBlockNoteExtensions({
});
```

Optionally, you can pass a `documentId`: the id of the edited document. Every user mention inserted in the editor, whether picked, pasted or duplicated, then notifies the mentioned user through the OpenProject API. Moved mentions and mentions inserted by collaborators notify nobody. This needs `UserMentionNotificationExtension` registered on the editor (see below).

```js
initializeOpBlockNoteExtensions({
baseUrl: 'https://my.openproject.url',
locale: 'en',
documentId: 5,
});
```

Then set up a BlockNote schema extending it with the block and inline specs:

```tsx
Expand All @@ -69,15 +79,16 @@ const schema = BlockNoteSchema.create().extend({
},
inlineContentSpecs: {
openProjectWorkPackageInline: openProjectWorkPackageInlineSpec,
openProjectUserMention: openProjectUserMentionSpec,
},
});
type EditorType = typeof schema.BlockNoteEditor;
```

Create the editor:
Create the editor with `UserMentionNotificationExtension`, which BlockNote does not pick up from the inline content specs:

```tsx
const editor = useCreateBlockNote({ schema });
const editor = useCreateBlockNote({ schema, extensions: [UserMentionNotificationExtension] });
```

Build the slash menu:
Expand Down Expand Up @@ -108,6 +119,8 @@ Where the host builds the `#` menu itself, `useHashWpMenu(editor)` hands back th

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.

`OpenProjectMentionMenu` is the `@` command for mentioning a user: it searches the users who are not locked and are members of the document's project by name, login or email. It opens only where a word starts, so an `@` within an email address stays text. The picked user becomes an inline mention, which is stored as OpenProject's `<mention>` tag. Render it beside the editor.

Include everything in a `BlockNoteView`:

```tsx
Expand All @@ -119,6 +132,7 @@ return (
getItems={getSlashItems}
/>
<OpenProjectHashMenu />
<OpenProjectMentionMenu />
</BlockNoteView>
);
```
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import styled from 'styled-components';
import type { WorkPackage } from '../../openProjectTypes';
import { linkToWorkPackage } from '../../services/openProjectApi';
import { editorThemeVariables } from '../shared/theme';
import {
defaultWpVariables,
WorkPackageId,
WorkPackageType,
WorkPackageStatus,
Expand Down Expand Up @@ -50,7 +50,7 @@ function buildTitle(workPackage:WorkPackage, linkTitle:boolean) {
}

const CardBase = styled.div<{ $inDropdown:boolean }>`
${defaultWpVariables}
${editorThemeVariables}
padding: var(--spacer-m) var(--spacer-l);
border-radius: var(--bn-border-radius);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { BlockNoteEditor, SideMenuExtension } from '@blocknote/core';
import { useSelectedBlocks } from '@blocknote/react';
import { useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import styled from 'styled-components';
import { useWorkPackage } from '../../hooks/useWorkPackage';
import { useColors } from '../../services/colors';
Expand All @@ -13,20 +13,20 @@ import { BlockCard } from './BlockCard';
import { UnavailableCard } from '../WorkPackage/UnavailableCard';
import { WpOptionsPopover } from '../WorkPackage/OptionsPopover';
import { PendingWorkPackagePopover } from '../WorkPackage/PendingWorkPackagePopover';
import { defaultWpVariables, nonSelectableStyles } from '../WorkPackage/atoms';
import { CHIP_STYLES } from '../WorkPackage/tokens';
import { BLOCK_FOCUS_SHADOW, editorThemeVariables, nonSelectableStyles } from '../shared/theme';
import { moveCursorAfterBlock } from '../../utils/cursor';
import { hideSafariPhantomSelection, selectBlockNode } from '../../utils/selection';
import { pendingBlockRegistry } from './pendingBlockRegistry';
import { useSuppressFormattingToolbar } from '../../hooks/useSuppressFormattingToolbar';
import { usePressOutside } from '../../hooks/usePressOutside';
import { useTapActivation } from '../../utils/tapActivation';

const Block = styled.div.attrs({ className: 'op-bn-extensions', 'data-testid': 'block-wp-wrapper' })<{ $pending?:boolean; $selected?:boolean }>`
${defaultWpVariables}
${editorThemeVariables}
background-color: ${({ $pending }) => ($pending ? 'transparent' : 'var(--op-chip-bg)')};
${nonSelectableStyles}
border-radius: var(--bn-border-radius);
box-shadow: ${({ $selected }) => ($selected ? CHIP_STYLES.focusShadow : 'none')};
box-shadow: ${({ $selected }) => ($selected ? BLOCK_FOCUS_SHADOW : 'none')};
${({ $pending }) => $pending && 'position: relative;'}
`;

Expand Down Expand Up @@ -68,6 +68,7 @@ export const BlockWorkPackageComponent = ({
const selectedBlocks = useSelectedBlocks(editor);
const isBlockSelected = selectedBlocks.some((b) => b.id === block.id);
const [isOptionsOpen, setIsOptionsOpen] = useState(false);
const closeOptions = useCallback(() => setIsOptionsOpen(false), []);

useEffect(() => {
if (!isBlockSelected) return;
Expand Down Expand Up @@ -139,23 +140,7 @@ export const BlockWorkPackageComponent = ({
sideMenu?.blockDragStart(e.nativeEvent, block as any);
};

// Touch is listened for in its own right: a tap another element answers
// never becomes a mousedown.
useEffect(() => {
if (!isOptionsOpen) return;
const handlePressOutside = (e:Event) => {
const path = e.composedPath();
if (cardRef.current && !path.includes(cardRef.current)) {
setIsOptionsOpen(false);
}
};
document.addEventListener('mousedown', handlePressOutside);
document.addEventListener('touchstart', handlePressOutside);
return () => {
document.removeEventListener('mousedown', handlePressOutside);
document.removeEventListener('touchstart', handlePressOutside);
};
}, [isOptionsOpen]);
usePressOutside(cardRef, isOptionsOpen, closeOptions);

const handleConvertToInline = (size:InlineWpSize) => {
if (!block.props.wpid) return;
Expand Down Expand Up @@ -184,7 +169,7 @@ export const BlockWorkPackageComponent = ({
currentBlockSize={cardSize}
// eslint-disable-next-line react-hooks/refs
anchorEl={cardRef.current}
onClose={() => setIsOptionsOpen(false)}
onClose={closeOptions}
onConvertToInline={handleConvertToInline}
onConvertToBlock={handleResizeBlock}
onResizeBlock={handleResizeBlock}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { createBlockConfig } from '@blocknote/core';
import { BLOCK_WP_TYPE } from '../../utils/nodeTypes';

// The cast keeps the generics wide - inferred ones narrow props to the literal
// prop schema, which the spec renderers and externalHtml are not typed for.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
export const blockConfig = createBlockConfig((() => ({
type: 'openProjectWorkPackageBlock' as const,
type: BLOCK_WP_TYPE,
propSchema: {
wpid: { default: undefined, type: 'number' },
size: { default: 'm', type: 'string' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
XCircleIcon,
} from '@primer/octicons-react';
import { wantsMotion } from '../../utils/motion';
import { useAnchoredPopover } from '../WorkPackage/anchoredPopover';
import { useAnchoredPopover } from '../shared/anchoredPopover';
import { useActiveOptionInView } from '../../hooks/useActiveOptionInView';
import type { AllowedValue, ListedValue } from './formSchema';
import {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import styled, { css, keyframes } from 'styled-components';
import { defaultWpVariables } from '../WorkPackage/atoms';
import { editorThemeVariables } from '../shared/theme';
import { defaultColorStyles, typeTextColor } from '../../services/colors';

const surfaceColor = 'var(--op-create-wp-surface)';
Expand Down Expand Up @@ -83,7 +83,7 @@ export const Overlay = styled.div.attrs({
className: 'op-bn-create-wp-overlay',
'data-testid': 'create-wp-overlay',
})`
${defaultWpVariables}
${editorThemeVariables}

--op-create-wp-shadow: 0 4px 24px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0, 0, 0, 0.04);
--op-create-wp-surface: var(--bgColor-default, #ffffff);
Expand Down
146 changes: 19 additions & 127 deletions extensions/blocknote-extensions/lib/components/HashMenu/HashWpMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,137 +1,29 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { FC, RefObject } from 'react';
import type { SuggestionMenuProps } from '@blocknote/react';
import styled from 'styled-components';
import type { RefObject } from 'react';
import { useTranslation } from 'react-i18next';
import type { WorkPackage } from '../../openProjectTypes';
import type { SuggestionMenuComponent, SuggestionSearchState } from '../../hooks/useSuggestionSearch';
import { BlockCard } from '../BlockWorkPackage/BlockCard';
import { defaultWpVariables, menuSurfaceStyles } from '../WorkPackage/atoms';
import { SearchMessage } from '../Search/SearchContainer';
import { CreateWorkPackageOption } from '../Search/CreateWorkPackageOption';
import { Spinner } from '../Spinner';
import { supportsHover } from '../../utils/device';
import { useActiveOptionInView } from '../../hooks/useActiveOptionInView';
import { useTapActivation } from '../../utils/tapActivation';
import type { HashMenuItem, HashSearchState } from './types';
import { useTranslation } from 'react-i18next';

/*
* BlockNote's GenericPopover wrapper (data-floating-ui-focusable) is the
* element FloatingUI applies max-height to via its `size` middleware, but
* this `Menu` element is the painted surface (white background, shadow,
* rounded corners). Without our own height limit and overflow constraint,
* the result rows render outside the painted card and ghost over the
* editor content behind — visible on iOS Safari in particular.
*
* The painted surface and the overflow container must be the same
* element, so the rounded corners clip the scroll area cleanly. The
* `min-height: 0` line is the iOS Safari flex-child-with-overflow quirk:
* 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', 'data-testid': 'hash-menu' })`
${defaultWpVariables}
${menuSurfaceStyles}
box-shadow: var(--bn-shadow-medium);
border-radius: var(--bn-border-radius-large);
padding: var(--spacer-s);
min-width: 320px;
max-width: 480px;
overflow-y: auto;
min-height: 0;
`;

const MenuItem = styled.div.attrs({ className: 'op-bn-hash-menu-item' })<{ $highlighted:boolean }>`
border-radius: var(--bn-border-radius-small);
background: ${({ $highlighted }) =>
$highlighted ? 'var(--op-item-hover-bg)' : 'transparent'};
cursor: pointer;
padding: 0 var(--spacer-s);
`;
import { SuggestionListbox } from '../shared/SuggestionListbox';

export function createHashWpMenuComponent(
searchStateRef:RefObject<HashSearchState>,
):FC<SuggestionMenuProps<HashMenuItem>> {
const HashWpMenuComponent:FC<SuggestionMenuProps<HashMenuItem>> = ({
items,
loadingState,
selectedIndex,
onItemClick,
}) => {
searchStateRef:RefObject<SuggestionSearchState<WorkPackage>>,
):SuggestionMenuComponent {
const HashWpMenuComponent:SuggestionMenuComponent = (props) => {
const { t } = useTranslation();
const { query, results, error } = searchStateRef.current;

const canHover = useMemo(() => supportsHover(), []);
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const menuRef = useRef<HTMLDivElement>(null);
const tapProps = useTapActivation();

const pick = (index:number) => {
if (items[index]) onItemClick?.(items[index]);
};

// Without results the first item is the placeholder, which has no row.
const createIndex = items.findIndex((item) => item.createsWorkPackage);
const rowIndexOf = (index:number) => (index === createIndex ? results.length : index);

useActiveOptionInView(menuRef, rowIndexOf(selectedIndex ?? -1), results);

useEffect(() => {
setHoveredIndex(null);
}, [selectedIndex, items]);

const highlightedIndex = hoveredIndex ?? selectedIndex;

if (loadingState !== 'loaded') {
return (
<Menu>
<SearchMessage>
{t('hashMenu.typeToSearch')}
<Spinner />
</SearchMessage>
</Menu>
);
}

if (!query) {
return (
<Menu>
<SearchMessage>{t('hashMenu.typeToSearch')}</SearchMessage>
</Menu>
);
}

const nothingFound = results.length === 0 && (
<SearchMessage>{error ? t('search.error') : t('search.noResults')}</SearchMessage>
);

if (nothingFound && createIndex === -1) return <Menu>{nothingFound}</Menu>;

const optionProps = (index:number) => ({
$highlighted: highlightedIndex === index,
onMouseMove: canHover ? () => setHoveredIndex(index) : undefined,
role: 'option',
'aria-selected': selectedIndex === index,
onMouseDown: (event:React.MouseEvent) => event.preventDefault(),
...tapProps(() => pick(index)),
});

return (
<Menu ref={menuRef} onMouseLeave={canHover ? () => setHoveredIndex(null) : undefined}>
{nothingFound}

<div role="listbox" aria-label={t('search.dropdownAriaLabel')}>
{results.map((wp, index) => (
<MenuItem key={wp.id} {...optionProps(index)}>
<BlockCard workPackage={wp} inDropdown />
</MenuItem>
))}

{createIndex !== -1 && (
<MenuItem {...optionProps(createIndex)}>
<CreateWorkPackageOption />
</MenuItem>
)}
</div>
</Menu>
<SuggestionListbox
{...props}
name="hash-menu"
ariaLabel={t('search.dropdownAriaLabel')}
loadingMessage={t('hashMenu.typeToSearch')}
searchState={searchStateRef.current}
promptMessage={t('hashMenu.typeToSearch')}
optionKey={(workPackage) => workPackage.id}
renderOption={(workPackage) => <BlockCard workPackage={workPackage} inDropdown />}
trailingOption={<CreateWorkPackageOption />}
/>
);
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,16 @@
import { SuggestionMenuController, useBlockNoteEditor } from '@blocknote/react';
import { canOpenHashMenu } from './hashTrigger';
import { HASH_TRIGGER } from './hashTrigger';
import { useHashWpMenu } from './useHashWpMenu';

const HASH_TRIGGER_CHARACTER = '#';

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

return (
<SuggestionMenuController
triggerCharacter={HASH_TRIGGER_CHARACTER}
triggerCharacter={HASH_TRIGGER}
getItems={getHashItems}
shouldOpen={canOpenHashMenu}
shouldOpen={shouldOpenHashMenu}
suggestionMenuComponent={HashWpMenu}
/>
);
Expand Down
Loading
Loading