Skip to content
Closed
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 .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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,16 @@ initializeOpBlockNoteExtensions({
});
```

Optionally, you can pass a `currentUserId`: the id of the user editing the document. With `UserMentionAuthorExtension` registered on the editor (see below), every user mention inserted there - picked, pasted or duplicated - is stored with a `data-mentioned-by` attribute naming that user, while moved mentions and those from collaborators keep their author. Without a `currentUserId`, inserted mentions get no author.

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

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

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

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

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

Build the slash menu:
Expand Down Expand Up @@ -110,6 +121,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 @@ -121,6 +134,7 @@ return (
getItems={getSlashItems}
/>
<OpenProjectHashMenu />
<OpenProjectMentionMenu />
</BlockNoteView>
);
```
Expand Down
4 changes: 2 additions & 2 deletions lib/components/BlockWorkPackage/BlockCards.tsx
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
31 changes: 8 additions & 23 deletions lib/components/BlockWorkPackage/BlockWorkPackage.tsx
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 @@ -14,20 +14,20 @@ import { UnavailableCard } from '../WorkPackage/UnavailableCard';
import { WpOptionsPopover } from '../WorkPackage/OptionsPopover';
import { WorkPackageSearchPopover } from '../Search/WorkPackageSearchPopover';
import { CreateWorkPackageModal } from '../CreateWorkPackage';
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 @@ -69,6 +69,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 @@ -140,23 +141,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 @@ -185,7 +170,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
3 changes: 2 additions & 1 deletion lib/components/BlockWorkPackage/blockConfig.ts
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
2 changes: 1 addition & 1 deletion lib/components/CreateWorkPackage/Suggestions.tsx
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
4 changes: 2 additions & 2 deletions lib/components/CreateWorkPackage/atoms.ts
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
137 changes: 18 additions & 119 deletions lib/components/HashMenu/HashWpMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,128 +1,27 @@
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 { BlockCard } from '../BlockWorkPackage/BlockCard';
import { defaultWpVariables, menuSurfaceStyles } from '../WorkPackage/atoms';
import { SearchMessage } from '../Search/SearchContainer';
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 type { RefObject } from 'react';
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 type { WorkPackage } from '../../openProjectTypes';
import type { SuggestionMenuComponent, SuggestionSearchState } from '../../hooks/useSuggestionSearch';
import { BlockCard } from '../BlockWorkPackage/BlockCard';
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]);
};

useActiveOptionInView(menuRef, 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>
);
}

if (error || results.length === 0) {
return (
<Menu>
<SearchMessage>{error ? t('search.error') : t('search.noResults')}</SearchMessage>
</Menu>
);
}

return (
<Menu
ref={menuRef}
role="listbox"
aria-label={t('search.dropdownAriaLabel')}
onMouseLeave={canHover ? () => setHoveredIndex(null) : undefined}
>
{results.map((wp, index) => (
<MenuItem
key={wp.id}
$highlighted={highlightedIndex === index}
onMouseMove={canHover ? () => setHoveredIndex(index) : undefined}
role="option"
aria-selected={selectedIndex === index}
onMouseDown={(e) => e.preventDefault()}
{...tapProps(() => pick(index))}
>
<BlockCard workPackage={wp} inDropdown />
</MenuItem>
))}
</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 />}
/>
);
};

Expand Down
10 changes: 4 additions & 6 deletions lib/components/HashMenu/OpenProjectHashMenu.tsx
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
9 changes: 2 additions & 7 deletions lib/components/HashMenu/editorUtils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { AnyEditor } from '../../editorTypes';
import type { WorkPackage } from '../../openProjectTypes';
import { chipContentOf, promoteInlineChipToBlockAt } from '../../utils/inlineChipActions';
import { insertInline } from '../../utils/insertInline';
import { INLINE_WP_TYPE } from '../../utils/nodeTypes';
import type { BlockWpSize, InlineWpSize } from '../WorkPackage/types';
import type { HashTarget } from './hashTrigger';
Expand Down Expand Up @@ -60,17 +61,11 @@ function insertChipAtCursor(
if (withTrailingSpace) content.push({ type: 'text', text: ' ', styles: {} });

const chipPosition = editor.transact((tr) => tr.selection.from);
(editor.insertInlineContent as (content:unknown[]) => void)(content);
insertInline(editor, 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 `##`-and-longer triggers,
Expand Down
Loading
Loading