diff --git a/extensions/blocknote-extensions/README.md b/extensions/blocknote-extensions/README.md index 092bdfdc0663..0a6775c88b8b 100644 --- a/extensions/blocknote-extensions/README.md +++ b/extensions/blocknote-extensions/README.md @@ -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 @@ -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: @@ -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 `` tag. Render it beside the editor. + Include everything in a `BlockNoteView`: ```tsx @@ -119,6 +132,7 @@ return ( getItems={getSlashItems} /> + ); ``` diff --git a/extensions/blocknote-extensions/lib/components/BlockWorkPackage/BlockWorkPackage.tsx b/extensions/blocknote-extensions/lib/components/BlockWorkPackage/BlockWorkPackage.tsx index bdfdf512aa1b..ac2360f96703 100644 --- a/extensions/blocknote-extensions/lib/components/BlockWorkPackage/BlockWorkPackage.tsx +++ b/extensions/blocknote-extensions/lib/components/BlockWorkPackage/BlockWorkPackage.tsx @@ -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'; @@ -18,6 +18,7 @@ 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 }>` @@ -67,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; @@ -138,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; @@ -183,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} diff --git a/extensions/blocknote-extensions/lib/components/BlockWorkPackage/blockConfig.ts b/extensions/blocknote-extensions/lib/components/BlockWorkPackage/blockConfig.ts index 4be495c5e5d7..e8ec2763e1dd 100644 --- a/extensions/blocknote-extensions/lib/components/BlockWorkPackage/blockConfig.ts +++ b/extensions/blocknote-extensions/lib/components/BlockWorkPackage/blockConfig.ts @@ -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' }, diff --git a/extensions/blocknote-extensions/lib/components/HashMenu/HashWpMenu.tsx b/extensions/blocknote-extensions/lib/components/HashMenu/HashWpMenu.tsx index bc245b2f3fad..27bf388c4410 100644 --- a/extensions/blocknote-extensions/lib/components/HashMenu/HashWpMenu.tsx +++ b/extensions/blocknote-extensions/lib/components/HashMenu/HashWpMenu.tsx @@ -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 { editorThemeVariables, menuSurfaceStyles } from '../shared/theme'; -import { SearchMessage } from '../shared/SearchMessage'; 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' })` - ${editorThemeVariables} - ${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, -):FC> { - const HashWpMenuComponent:FC> = ({ - items, - loadingState, - selectedIndex, - onItemClick, - }) => { + searchStateRef:RefObject>, +):SuggestionMenuComponent { + const HashWpMenuComponent:SuggestionMenuComponent = (props) => { const { t } = useTranslation(); - const { query, results, error } = searchStateRef.current; - - const canHover = useMemo(() => supportsHover(), []); - const [hoveredIndex, setHoveredIndex] = useState(null); - const menuRef = useRef(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 ( - - - {t('hashMenu.typeToSearch')} - - - - ); - } - - if (!query) { - return ( - - {t('hashMenu.typeToSearch')} - - ); - } - - const nothingFound = results.length === 0 && ( - {error ? t('search.error') : t('search.noResults')} - ); - - if (nothingFound && createIndex === -1) return {nothingFound}; - - 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 ( - setHoveredIndex(null) : undefined}> - {nothingFound} - -
- {results.map((wp, index) => ( - - - - ))} - - {createIndex !== -1 && ( - - - - )} -
-
+ workPackage.id} + renderOption={(workPackage) => } + trailingOption={} + /> ); }; diff --git a/extensions/blocknote-extensions/lib/components/HashMenu/OpenProjectHashMenu.tsx b/extensions/blocknote-extensions/lib/components/HashMenu/OpenProjectHashMenu.tsx index 1941f85e6803..54d95d832b0f 100644 --- a/extensions/blocknote-extensions/lib/components/HashMenu/OpenProjectHashMenu.tsx +++ b/extensions/blocknote-extensions/lib/components/HashMenu/OpenProjectHashMenu.tsx @@ -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 ( ); diff --git a/extensions/blocknote-extensions/lib/components/HashMenu/editorUtils.ts b/extensions/blocknote-extensions/lib/components/HashMenu/editorUtils.ts index 3fead18d1f9e..f979f486304d 100644 --- a/extensions/blocknote-extensions/lib/components/HashMenu/editorUtils.ts +++ b/extensions/blocknote-extensions/lib/components/HashMenu/editorUtils.ts @@ -5,10 +5,12 @@ import { promoteInlineChipToBlockAt, removePendingInlineChip, } from '../../utils/inlineChipActions'; +import { insertInline, restoreTypedQuery } from '../../utils/insertInline'; import { INLINE_WP_TYPE } from '../../utils/nodeTypes'; import { subjectOf } from '../../utils/subject'; import { makePendingWpid, registerInlineWpCallbacks } from '../InlineWorkPackage/callbacks'; import type { BlockWpSize, InlineWpSize } from '../WorkPackage/types'; +import { HASH_TRIGGER } from './hashTrigger'; import type { HashTarget } from './hashTrigger'; // Discarded right away, so the cheapest chip to render will do. @@ -33,13 +35,11 @@ export function createWpForTarget( registerInlineWpCallbacks( pendingWpid, (workPackage) => replacePendingChip(editor, pendingWpid, () => insertWpForTarget(editor, workPackage, target)), - () => replacePendingChip(editor, pendingWpid, () => restoreHashQuery(editor, query)), + () => replacePendingChip(editor, pendingWpid, () => restoreTypedQuery(editor, HASH_TRIGGER, query)), { mode: 'create', initialSubject: subjectOf(query) } ); - (editor.insertInlineContent as (content:unknown[]) => void)([ - { type: INLINE_WP_TYPE, props: { wpid: pendingWpid } }, - ]); + insertInline(editor, [{ type: INLINE_WP_TYPE, props: { wpid: pendingWpid } }]); } // The cursor stays right behind the pending chip while the form is open, so @@ -94,17 +94,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, diff --git a/extensions/blocknote-extensions/lib/components/HashMenu/hashTrigger.ts b/extensions/blocknote-extensions/lib/components/HashMenu/hashTrigger.ts index a4cbcdbd3bff..ec7113b01177 100644 --- a/extensions/blocknote-extensions/lib/components/HashMenu/hashTrigger.ts +++ b/extensions/blocknote-extensions/lib/components/HashMenu/hashTrigger.ts @@ -2,6 +2,9 @@ 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'; +import { textBefore } from '../../utils/suggestionTrigger'; + +export const HASH_TRIGGER = '#'; /** * What the hash menu inserts for the work package the user picks. How many `#` @@ -46,20 +49,9 @@ 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; + const text = textBefore(doc.resolve(position)) ?? ''; + return /#*$/.exec(text)?.[0].length ?? 0; } function leadingHashes(query:string):number { diff --git a/extensions/blocknote-extensions/lib/components/HashMenu/index.ts b/extensions/blocknote-extensions/lib/components/HashMenu/index.ts index bc02f640e58c..24f9a3256db9 100644 --- a/extensions/blocknote-extensions/lib/components/HashMenu/index.ts +++ b/extensions/blocknote-extensions/lib/components/HashMenu/index.ts @@ -1,5 +1,6 @@ export { OpenProjectHashMenu } from './OpenProjectHashMenu'; export { createHashWpMenuComponent } from './HashWpMenu'; export { useHashWpMenu } from './useHashWpMenu'; +export { HASH_TRIGGER } from './hashTrigger'; export { isHashWpQuery } from './types'; -export type { HashMenuItem, HashSearchState } from './types'; +export type { HashMenuItem } from './types'; diff --git a/extensions/blocknote-extensions/lib/components/HashMenu/types.ts b/extensions/blocknote-extensions/lib/components/HashMenu/types.ts index 09e094761b3a..9f68b9dc7d15 100644 --- a/extensions/blocknote-extensions/lib/components/HashMenu/types.ts +++ b/extensions/blocknote-extensions/lib/components/HashMenu/types.ts @@ -1,17 +1,7 @@ -import type { WorkPackage } from '../../openProjectTypes'; +import type { SuggestionMenuItem } from '../../hooks/useSuggestionSearch'; -export interface HashMenuItem { - title:string; - onItemClick:() => void; - createsWorkPackage?:boolean; -} - -export interface HashSearchState { - query:string; - results:WorkPackage[]; - error:string | null; -} +export type HashMenuItem = SuggestionMenuItem; export function isHashWpQuery(query:string):boolean { return query.trim().length > 0; -} \ No newline at end of file +} diff --git a/extensions/blocknote-extensions/lib/components/HashMenu/useHashWpMenu.ts b/extensions/blocknote-extensions/lib/components/HashMenu/useHashWpMenu.ts index 06c82c75358c..a014a16b5ddd 100644 --- a/extensions/blocknote-extensions/lib/components/HashMenu/useHashWpMenu.ts +++ b/extensions/blocknote-extensions/lib/components/HashMenu/useHashWpMenu.ts @@ -1,101 +1,45 @@ -import { useCallback, useMemo, useRef } from 'react'; -import { MAX_SEARCH_RESULTS, useWorkPackageSearch } from '../../hooks/useWorkPackageSearch'; -import { createHashWpMenuComponent } from './HashWpMenu'; -import { isHashWpQuery } from './types'; -import { canOpenHashMenu, hashTargetFor } from './hashTrigger'; -import { createWpForTarget, insertWpForTarget, restoreHashQuery } from './editorUtils'; -import type { HashTarget } from './hashTrigger'; -import type { HashMenuItem, HashSearchState } from './types'; +import { useCallback } from 'react'; import type { AnyEditor } from '../../editorTypes'; +import type { WorkPackage } from '../../openProjectTypes'; +import { MAX_SEARCH_RESULTS, useWorkPackageSearch } from '../../hooks/useWorkPackageSearch'; +import { useSuggestionSearch } from '../../hooks/useSuggestionSearch'; +import type { SuggestionPlan } from '../../hooks/useSuggestionSearch'; import { cacheColors } from '../../services/colors'; import { canCreateWorkPackages } from '../../services/openProjectApi'; -import { closeSuggestionMenu } from '../../utils/suggestionMenu'; +import { createHashWpMenuComponent } from './HashWpMenu'; +import { createWpForTarget, insertWpForTarget } from './editorUtils'; +import { canOpenHashMenu, HASH_TRIGGER, hashTargetFor } from './hashTrigger'; +import { isHashWpQuery } from './types'; export function useHashWpMenu(editor:AnyEditor) { - const { search } = useWorkPackageSearch(); - const searchStateRef = useRef({ query: '', results: [], error: null }); - const latestQueryRef = useRef(''); - - const placeholderItems = useCallback( - (query:string):HashMenuItem[] => [{ - title: query, - onItemClick: () => { - restoreHashQuery(editor, query); - }, - }], - [editor] - ); - - // Comes last, so it is never what Enter picks before the user moves to it. - const withCreateItem = useCallback( - (items:HashMenuItem[], query:string, target:HashTarget):HashMenuItem[] => { - if (!canCreateWorkPackages()) return items; - - return [...items, { - title: query, - createsWorkPackage: true, - onItemClick: () => { - createWpForTarget(editor, query, target); - }, - }]; - }, - [editor] - ); - - const getHashItems = useCallback( - 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); - } - - await cacheColors(); - - try { - const results = (await search(query)).slice(0, MAX_SEARCH_RESULTS); - - if (latestQueryRef.current !== query) return []; - searchStateRef.current = { query, results, error: null }; - - const resultItems = results.length === 0 - ? placeholderItems(query) - : results.map((wp) => ({ - title: query, - onItemClick: () => { - insertWpForTarget(editor, wp, target); - }, - })); - - return withCreateItem(resultItems, query, target); - } catch (error) { - console.error('[work package search] Failed to load work packages from OpenProject:', error); - if (latestQueryRef.current === query) { - searchStateRef.current = { - query, - results: [], - error: error instanceof Error ? error.message : 'Unknown error', - }; - } - return withCreateItem(placeholderItems(query), query, target); - } - }, - [editor, search, placeholderItems, withCreateItem] - ); - - /* eslint-disable react-hooks/refs */ - const HashWpMenu = useMemo( - () => createHashWpMenuComponent(searchStateRef), - [] - ); - /* eslint-enable react-hooks/refs */ - - return { getHashItems, HashWpMenu, shouldOpenHashMenu: canOpenHashMenu }; + const { search, cancelSearch } = useWorkPackageSearch(); + + const planFor = useCallback((query:string):SuggestionPlan => { + const target = hashTargetFor(editor, query); + if (target.kind === 'none') return { kind: 'close' }; + if (!isHashWpQuery(query)) return { kind: 'prompt' }; + return { + kind: 'search', + pick: (workPackage) => insertWpForTarget(editor, workPackage, target), + trailingItem: canCreateWorkPackages() + ? { title: query, onItemClick: () => createWpForTarget(editor, query, target) } + : undefined, + }; + }, [editor]); + + const searchWithColors = useCallback(async (query:string) => { + await cacheColors(); + return (await search(query)).slice(0, MAX_SEARCH_RESULTS); + }, [search]); + + const { getItems, Menu } = useSuggestionSearch(editor, { + trigger: HASH_TRIGGER, + planFor, + search: searchWithColors, + cancelSearch, + logPrefix: '[work package search] Failed to load work packages from OpenProject:', + createMenu: createHashWpMenuComponent, + }); + + return { getHashItems: getItems, HashWpMenu: Menu, shouldOpenHashMenu: canOpenHashMenu }; } diff --git a/extensions/blocknote-extensions/lib/components/InlineWorkPackage/InlineWorkPackageChip.tsx b/extensions/blocknote-extensions/lib/components/InlineWorkPackage/InlineWorkPackageChip.tsx index ef7824be0b70..fab0b3a81b08 100644 --- a/extensions/blocknote-extensions/lib/components/InlineWorkPackage/InlineWorkPackageChip.tsx +++ b/extensions/blocknote-extensions/lib/components/InlineWorkPackage/InlineWorkPackageChip.tsx @@ -12,18 +12,15 @@ import { WpPreviewPopover } from '../WorkPackage/PreviewPopover'; import { getPendingCallbacks, clearInlineWpCallbacks } from './callbacks'; import type { InlineWpSize } from '../WorkPackage/types'; import type { WorkPackage } from '../../openProjectTypes'; -import { - findInlineChipAtDOM, - selectInlineChipAt, - removeInlineChipAt, - promoteInlineChipToBlockAt, -} from '../../utils/inlineChipActions'; +import { promoteInlineChipToBlockAt } from '../../utils/inlineChipActions'; +import { INLINE_WP_TYPE } from '../../utils/nodeTypes'; import { BlockCard } from '../BlockWorkPackage/BlockCard'; import { useTranslation } from 'react-i18next'; import { formatWorkPackageId } from '../../utils/id'; import { useIsNodeInSelection } from '../../hooks/useIsNodeInSelection'; import { useSuppressFormattingToolbar } from '../../hooks/useSuppressFormattingToolbar'; -import { useTapActivation } from '../../utils/tapActivation'; +import { useInlineNodeOptions } from '../../hooks/useInlineNodeOptions'; +import { usePressOutside } from '../../hooks/usePressOutside'; import type { BlockNoteEditor } from '@blocknote/core'; export interface InlineWorkPackageChipProps { @@ -33,7 +30,7 @@ export interface InlineWorkPackageChipProps { editor?:BlockNoteEditor; // Provided by BlockNote's node view; updates exactly this node instance. updateInlineContent?:(update:{ - type:'openProjectWorkPackageInline'; + type:typeof INLINE_WP_TYPE; props:{ wpid:string; size:string; displayId:string }; }) => void; } @@ -54,24 +51,27 @@ export const InlineWorkPackageChip = ({ inlineContent, contentRef, editor, updat useEffect(() => { if (!wp || !updateInlineContent) return; if (wp.displayId === inlineContent.props.displayId) return; - updateInlineContent({ type: 'openProjectWorkPackageInline', props: { ...inlineContent.props, displayId: wp.displayId } }); + updateInlineContent({ type: INLINE_WP_TYPE, props: { ...inlineContent.props, displayId: wp.displayId } }); }, [wp?.displayId]); // eslint-disable-line react-hooks/exhaustive-deps - const [isSelected, setIsSelected] = useState(false); const chipRef = useRef(null); const [chipEl, setChipEl] = useState(null); + const { optionsOpen, closeOptions, activationProps, findNode, removeNode } = + useInlineNodeOptions(editor, chipRef, INLINE_WP_TYPE); + const preview = useWorkPackagePreview({ enabled: size === 'xxs', - suppressed: isSelected, + suppressed: optionsOpen, // The indicator shows the preview instead of the options menu, not over it. - onOpen: () => setIsSelected(false), + onOpen: closeOptions, }); const { previewOpen, closePreview, triggerProps, cardProps } = preview; const isEditorSelected = useIsNodeInSelection(chipRef, editor); - useSuppressFormattingToolbar(editor, isSelected || previewOpen); + useSuppressFormattingToolbar(editor, previewOpen); + usePressOutside(chipRef, previewOpen, closePreview); const setRef = (node:HTMLElement | null) => { chipRef.current = node; @@ -79,45 +79,7 @@ export const InlineWorkPackageChip = ({ inlineContent, contentRef, editor, updat contentRef(node); }; - const selectWorkPackageNode = () => { - if (!editor || !chipRef.current) return; - const chip = findInlineChipAtDOM(editor, chipRef.current); - if (chip) selectInlineChipAt(editor, chip.position); - editor.getExtension('formattingToolbar')?.store?.setState(false); - }; - - const toggleOptions = () => { - closePreview(); - setIsSelected((prev) => !prev); - selectWorkPackageNode(); - }; - - const tapProps = useTapActivation(); - // The closure is handed to the element, not run while rendering. - // eslint-disable-next-line react-hooks/refs - const onChipActivation = tapProps((event) => { - event?.preventDefault(); - event?.stopPropagation(); - toggleOptions(); - }); - - // Close the options popover and the preview when the user taps outside the chip - useEffect(() => { - if (!isSelected && !previewOpen) return; - const onPressOutside = (e:Event) => { - if (chipRef.current && !chipRef.current.contains(e.target as Node)) { - setIsSelected(false); - closePreview(); - } - }; - // Touch as well: a tap another element answers never becomes a mousedown. - document.addEventListener('mousedown', onPressOutside); - document.addEventListener('touchstart', onPressOutside); - return () => { - document.removeEventListener('mousedown', onPressOutside); - document.removeEventListener('touchstart', onPressOutside); - }; - }, [isSelected, previewOpen, closePreview]); + const onChipActivation = activationProps(closePreview); const optionsPopover = ( setIsSelected(false)} + onClose={closeOptions} onResize={(newSize) => { - updateInlineContent?.({ type: 'openProjectWorkPackageInline', props: { ...inlineContent.props, size: newSize } }); + updateInlineContent?.({ type: INLINE_WP_TYPE, props: { ...inlineContent.props, size: newSize } }); }} onConvertToBlock={(blockSize) => { - if (!editor || !chipRef.current) return; - const chip = findInlineChipAtDOM(editor, chipRef.current); - if (chip) promoteInlineChipToBlockAt(editor, chip.position, blockSize); - }} - onRemove={() => { - if (!editor || !chipRef.current) return; - const chip = findInlineChipAtDOM(editor, chipRef.current); - if (chip) removeInlineChipAt(editor, chip.position); + const chip = findNode(); + if (editor && chip) promoteInlineChipToBlockAt(editor, chip.position, blockSize); }} + onRemove={removeNode} /> ); @@ -182,7 +139,7 @@ export const InlineWorkPackageChip = ({ inlineContent, contentRef, editor, updat // Resolved if (wpid && wp) { // Hidden while the options menu is open so the two popovers never stack. - const showPreview = size === 'xxs' && previewOpen && !isSelected; + const showPreview = size === 'xxs' && previewOpen && !optionsOpen; const chipLabel = t('options.chipAriaLabel', { id: formatWorkPackageId(wp.displayId) }); const hasIndicator = preview.indicatorProps !== undefined; @@ -192,7 +149,7 @@ export const InlineWorkPackageChip = ({ inlineContent, contentRef, editor, updat role={hasIndicator ? undefined : 'button'} aria-label={hasIndicator ? undefined : chipLabel} ref={setRef} - selected={isSelected || isEditorSelected} + selected={optionsOpen || isEditorSelected} {...triggerProps} {...onChipActivation} > @@ -210,7 +167,7 @@ export const InlineWorkPackageChip = ({ inlineContent, contentRef, editor, updat )} - {isSelected && optionsPopover} + {optionsOpen && optionsPopover} ); } @@ -225,10 +182,10 @@ export const InlineWorkPackageChip = ({ inlineContent, contentRef, editor, updat setRef={setRef} // eslint-disable-next-line react-hooks/refs anchorEl={chipRef.current} - selected={isSelected || isEditorSelected} + selected={optionsOpen || isEditorSelected} preview={preview} onActivation={onChipActivation} - optionsPopover={isSelected && optionsPopover} + optionsPopover={optionsOpen && optionsPopover} /> ); } diff --git a/extensions/blocknote-extensions/lib/components/InlineWorkPackage/externalHtml.ts b/extensions/blocknote-extensions/lib/components/InlineWorkPackage/externalHtml.ts index 788c102f5ee5..8f6c304574dc 100644 --- a/extensions/blocknote-extensions/lib/components/InlineWorkPackage/externalHtml.ts +++ b/extensions/blocknote-extensions/lib/components/InlineWorkPackage/externalHtml.ts @@ -6,6 +6,7 @@ import { buildExternalDOM, hashPrefixForSize } from '../WorkPackage/externalHtml'; import { linkToWorkPackage } from '../../services/openProjectApi'; +import { INLINE_WP_TYPE } from '../../utils/nodeTypes'; export interface WorkPackageInlineProps { wpid?:string; @@ -15,7 +16,7 @@ export interface WorkPackageInlineProps { export interface WorkPackageInlineExternalData { attrs:{ - 'data-inline-content-type':'openProjectWorkPackageInline'; + 'data-inline-content-type':typeof INLINE_WP_TYPE; 'data-wpid':string; 'data-size':string; 'data-display-id':string; @@ -33,7 +34,7 @@ export function computeWorkPackageInlineExternalData( const displayId = props.displayId || wpid; return { attrs: { - 'data-inline-content-type': 'openProjectWorkPackageInline', + 'data-inline-content-type': INLINE_WP_TYPE, 'data-wpid': wpid, 'data-size': size ?? 's', 'data-display-id': displayId, @@ -53,7 +54,7 @@ export function buildWorkPackageInlineExternalDOM( export function parseWorkPackageInlineExternalHTML( element:HTMLElement, ):WorkPackageInlineProps | undefined { - if (element.getAttribute('data-inline-content-type') !== 'openProjectWorkPackageInline') { + if (element.getAttribute('data-inline-content-type') !== INLINE_WP_TYPE) { return undefined; } return { diff --git a/extensions/blocknote-extensions/lib/components/InlineWorkPackage/inlineConfig.ts b/extensions/blocknote-extensions/lib/components/InlineWorkPackage/inlineConfig.ts index f88aaf1f752d..bb4b03a5a84a 100644 --- a/extensions/blocknote-extensions/lib/components/InlineWorkPackage/inlineConfig.ts +++ b/extensions/blocknote-extensions/lib/components/InlineWorkPackage/inlineConfig.ts @@ -1,5 +1,7 @@ +import { INLINE_WP_TYPE } from '../../utils/nodeTypes'; + export const inlineConfig = { - type: 'openProjectWorkPackageInline' as const, + type: INLINE_WP_TYPE, propSchema: { wpid: { default: '' }, size: { default: 's' }, diff --git a/extensions/blocknote-extensions/lib/components/MentionMenu/MentionMenu.tsx b/extensions/blocknote-extensions/lib/components/MentionMenu/MentionMenu.tsx new file mode 100644 index 000000000000..1d19a07585b0 --- /dev/null +++ b/extensions/blocknote-extensions/lib/components/MentionMenu/MentionMenu.tsx @@ -0,0 +1,45 @@ +import type { RefObject } from 'react'; +import styled from 'styled-components'; +import { useTranslation } from 'react-i18next'; +import type { Principal } from '../../openProjectTypes'; +import type { SuggestionMenuComponent, SuggestionSearchState } from '../../hooks/useSuggestionSearch'; +import { Avatar } from '../shared/Avatar'; +import { SuggestionListbox } from '../shared/SuggestionListbox'; + +const AVATAR_SIZE = 24; + +const Row = styled.span` + display: flex; + align-items: center; + gap: var(--spacer-m); + padding: var(--spacer-s) 0; + color: var(--bn-colors-editor-text); +`; + +export function createMentionMenuComponent( + searchStateRef:RefObject>, +):SuggestionMenuComponent { + const MentionMenu:SuggestionMenuComponent = (props) => { + const { t } = useTranslation(); + + return ( + principal.id} + renderOption={(principal) => ( + + + {principal.name} + + )} + /> + ); + }; + + MentionMenu.displayName = 'MentionMenu'; + return MentionMenu; +} diff --git a/extensions/blocknote-extensions/lib/components/MentionMenu/OpenProjectMentionMenu.tsx b/extensions/blocknote-extensions/lib/components/MentionMenu/OpenProjectMentionMenu.tsx new file mode 100644 index 000000000000..842d5ef488ea --- /dev/null +++ b/extensions/blocknote-extensions/lib/components/MentionMenu/OpenProjectMentionMenu.tsx @@ -0,0 +1,17 @@ +import { SuggestionMenuController, useBlockNoteEditor } from '@blocknote/react'; +import { MENTION_TRIGGER } from './mentionTrigger'; +import { useMentionMenu } from './useMentionMenu'; + +export const OpenProjectMentionMenu = () => { + const editor = useBlockNoteEditor(); + const { getMentionItems, MentionMenu, shouldOpenMentionMenu } = useMentionMenu(editor); + + return ( + + ); +}; diff --git a/extensions/blocknote-extensions/lib/components/MentionMenu/index.ts b/extensions/blocknote-extensions/lib/components/MentionMenu/index.ts new file mode 100644 index 000000000000..925bd86b6010 --- /dev/null +++ b/extensions/blocknote-extensions/lib/components/MentionMenu/index.ts @@ -0,0 +1,4 @@ +export { OpenProjectMentionMenu } from './OpenProjectMentionMenu'; +export { createMentionMenuComponent } from './MentionMenu'; +export { useMentionMenu } from './useMentionMenu'; +export { canOpenMentionMenu, MENTION_TRIGGER } from './mentionTrigger'; diff --git a/extensions/blocknote-extensions/lib/components/MentionMenu/mentionTrigger.ts b/extensions/blocknote-extensions/lib/components/MentionMenu/mentionTrigger.ts new file mode 100644 index 000000000000..cf986c6427bd --- /dev/null +++ b/extensions/blocknote-extensions/lib/components/MentionMenu/mentionTrigger.ts @@ -0,0 +1,13 @@ +import type { Transaction } from 'prosemirror-state'; +import { textBefore } from '../../utils/suggestionTrigger'; + +export const MENTION_TRIGGER = '@'; + +// Like CKEditor's mention feature: "@" only starts a mention where a word would start. +const OPENS_AFTER = /^$|[\s([{"']$/; + +/** Runs before the typed "@" is inserted. */ +export function canOpenMentionMenu(transaction:Transaction):boolean { + const characterBefore = textBefore(transaction.selection.$from, 1); + return characterBefore !== null && OPENS_AFTER.test(characterBefore); +} diff --git a/extensions/blocknote-extensions/lib/components/MentionMenu/useMentionMenu.ts b/extensions/blocknote-extensions/lib/components/MentionMenu/useMentionMenu.ts new file mode 100644 index 000000000000..3894c498b23b --- /dev/null +++ b/extensions/blocknote-extensions/lib/components/MentionMenu/useMentionMenu.ts @@ -0,0 +1,39 @@ +import { useCallback } from 'react'; +import type { AnyEditor } from '../../editorTypes'; +import type { Principal } from '../../openProjectTypes'; +import { searchMentionableUsers } from '../../services/openProjectApi'; +import { useDebouncedSearch } from '../../hooks/useDebouncedSearch'; +import { useSuggestionSearch } from '../../hooks/useSuggestionSearch'; +import type { SuggestionPlan } from '../../hooks/useSuggestionSearch'; +import { insertInline } from '../../utils/insertInline'; +import { USER_MENTION_TYPE } from '../../utils/nodeTypes'; +import { canOpenMentionMenu, MENTION_TRIGGER } from './mentionTrigger'; +import { createMentionMenuComponent } from './MentionMenu'; + +function insertMention(editor:AnyEditor, principal:Principal):void { + insertInline(editor, [ + { type: USER_MENTION_TYPE, props: { userId: String(principal.id), name: principal.name } }, + { type: 'text', text: ' ', styles: {} }, + ]); + editor.focus(); +} + +export function useMentionMenu(editor:AnyEditor) { + const { search, cancel } = useDebouncedSearch(searchMentionableUsers, { searchBlank: true }); + + const planFor = useCallback( + ():SuggestionPlan => ({ kind: 'search', pick: (principal) => insertMention(editor, principal) }), + [editor] + ); + + const { getItems, Menu } = useSuggestionSearch(editor, { + trigger: MENTION_TRIGGER, + planFor, + search, + cancelSearch: cancel, + logPrefix: '[mention search] Failed to load users from OpenProject:', + createMenu: createMentionMenuComponent, + }); + + return { getMentionItems: getItems, MentionMenu: Menu, shouldOpenMentionMenu: canOpenMentionMenu }; +} diff --git a/extensions/blocknote-extensions/lib/components/UserMention/UserMentionPill.tsx b/extensions/blocknote-extensions/lib/components/UserMention/UserMentionPill.tsx new file mode 100644 index 000000000000..d58d4f982d20 --- /dev/null +++ b/extensions/blocknote-extensions/lib/components/UserMention/UserMentionPill.tsx @@ -0,0 +1,84 @@ +import { useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import styled, { css } from 'styled-components'; +import type { AnyEditor } from '../../editorTypes'; +import { useIsNodeInSelection } from '../../hooks/useIsNodeInSelection'; +import { useInlineNodeOptions } from '../../hooks/useInlineNodeOptions'; +import { linkToUser } from '../../services/openProjectApi'; +import { newTabLinkProps } from '../../utils/links'; +import { USER_MENTION_TYPE } from '../../utils/nodeTypes'; +import { OptionsPopover } from '../shared/OptionsPopover'; +import { INLINE_NODE_STYLES, inlineNodeBaseStyles, inlineNodeContainerStyles } from '../shared/inlineNodeStyles'; +import { Avatar } from '../shared/Avatar'; +import type { UserMentionProps } from './externalHtml'; + +const PILL_PADDING = '2.5px 6px'; +const AVATAR_SIZE = 16; +const AVATAR_INSET = '2px'; +const AVATAR_GAP = '4px'; + +const Pill = styled.span.attrs({ className: 'op-bn-user-mention', contentEditable: false })<{ $selected:boolean }>` + ${inlineNodeContainerStyles} + ${inlineNodeBaseStyles} + padding: ${PILL_PADDING}; + padding-left: ${AVATAR_INSET}; + + /* text-bottom aligns the avatar's own box, so it does not move when the picture replaces the initials */ + & > .op-bn-avatar { + vertical-align: text-bottom; + margin-right: ${AVATAR_GAP}; + } + + ${({ $selected }) => $selected && css`box-shadow: ${INLINE_NODE_STYLES.inlineFocusShadow};`} +`; + +const Name = styled.a` + color: var(--bn-colors-editor-text); + font-weight: 500; + text-decoration: none; + + &:hover { + text-decoration: underline; + } +`; + +interface UserMentionPillProps { + props:UserMentionProps; + editor:AnyEditor; +} + +export function UserMentionPill({ props: { userId, name }, editor }:UserMentionPillProps) { + const { t } = useTranslation(); + const pillRef = useRef(null); + const selected = useIsNodeInSelection(pillRef, editor); + const { optionsOpen, closeOptions, activationProps, removeNode } = + useInlineNodeOptions(editor, pillRef, USER_MENTION_TYPE); + const profileHref = linkToUser(userId); + + return ( + + + + {name} + + {optionsOpen && ( + + )} + + ); +} diff --git a/extensions/blocknote-extensions/lib/components/UserMention/externalHtml.ts b/extensions/blocknote-extensions/lib/components/UserMention/externalHtml.ts new file mode 100644 index 000000000000..88b0702371cc --- /dev/null +++ b/extensions/blocknote-extensions/lib/components/UserMention/externalHtml.ts @@ -0,0 +1,51 @@ +import { escapeHtmlAttribute, escapeHtmlText } from '../../utils/html'; +import { USER_MENTION_TYPE } from '../../utils/nodeTypes'; + +export interface UserMentionProps { + userId:string; + name:string; +} + +/** The exact markup OpenProject stores for a user mention (see Users::ReplaceMentionsService). */ +export function userMentionTag({ userId, name }:UserMentionProps):string { + const text = `@${name}`; + return `${escapeHtmlText(text)}`; +} + +function wrapperAttributes({ userId, name }:UserMentionProps):Record { + return { + 'data-inline-content-type': USER_MENTION_TYPE, + 'data-user-id': userId, + 'data-name': name, + }; +} + +export function buildUserMentionStoredDOM(props:UserMentionProps, document:Document):HTMLElement { + const wrapper = document.createElement('span'); + for (const [attribute, value] of Object.entries(wrapperAttributes(props))) { + wrapper.setAttribute(attribute, value); + } + wrapper.textContent = userMentionTag(props); + return wrapper; +} + +export function parseUserMentionExternalHTML(element:HTMLElement):UserMentionProps | undefined { + // BlockNote's own wrapper omits the props at their default, so one without a user id is left to its content. + if (element.getAttribute('data-inline-content-type') === USER_MENTION_TYPE && element.hasAttribute('data-user-id')) { + return { + userId: element.getAttribute('data-user-id') ?? '', + name: element.getAttribute('data-name') ?? '', + }; + } + + const userId = element.getAttribute('data-id'); + if (element.tagName.toLowerCase() === 'mention' && element.getAttribute('data-type') === 'user' && userId) { + return { + userId, + name: (element.getAttribute('data-text') ?? '').replace(/^@/, ''), + }; + } + + return undefined; +} diff --git a/extensions/blocknote-extensions/lib/components/UserMention/index.ts b/extensions/blocknote-extensions/lib/components/UserMention/index.ts new file mode 100644 index 000000000000..3c4feb178cd4 --- /dev/null +++ b/extensions/blocknote-extensions/lib/components/UserMention/index.ts @@ -0,0 +1 @@ +export { openProjectUserMentionSpec } from './spec'; diff --git a/extensions/blocknote-extensions/lib/components/UserMention/spec.tsx b/extensions/blocknote-extensions/lib/components/UserMention/spec.tsx new file mode 100644 index 000000000000..e95edb79f9d8 --- /dev/null +++ b/extensions/blocknote-extensions/lib/components/UserMention/spec.tsx @@ -0,0 +1,24 @@ +import { createReactInlineContentSpec } from '@blocknote/react'; +import { userMentionConfig } from './userMentionConfig'; +import { userMentionTag, parseUserMentionExternalHTML } from './externalHtml'; +import { UserMentionPill } from './UserMentionPill'; + +export const openProjectUserMentionSpec = createReactInlineContentSpec( + userMentionConfig, + { + render: ({ inlineContent, editor }) => , + + // A root for the same reason as the work package chip: an or + // unknown root lets the Link mark claim the node on paste. + toExternalHTML: ({ inlineContent }) => { + if (!inlineContent.props.userId) return <>; + return ; + }, + + parse: (element) => parseUserMentionExternalHTML(element), + + meta: { + draggable: true, + }, + } +); diff --git a/extensions/blocknote-extensions/lib/components/UserMention/staticSpec.ts b/extensions/blocknote-extensions/lib/components/UserMention/staticSpec.ts new file mode 100644 index 000000000000..b752583fe392 --- /dev/null +++ b/extensions/blocknote-extensions/lib/components/UserMention/staticSpec.ts @@ -0,0 +1,22 @@ +import { createInlineContentSpec } from '@blocknote/core'; +import { userMentionConfig } from './userMentionConfig'; +import { buildUserMentionStoredDOM, parseUserMentionExternalHTML } from './externalHtml'; + +// Only the hocuspocus server uses this spec, and only to store markdown. +export const openProjectUserMentionStaticSpec = createInlineContentSpec( + userMentionConfig, + { + render: (inlineContent) => { + const dom = document.createElement('span'); + dom.textContent = `@${inlineContent.props.name}`; + return { dom }; + }, + + toExternalHTML: (inlineContent) => { + if (!inlineContent.props.userId) return undefined; + return { dom: buildUserMentionStoredDOM(inlineContent.props, document) }; + }, + + parse: (element) => parseUserMentionExternalHTML(element), + } +); diff --git a/extensions/blocknote-extensions/lib/components/UserMention/userMentionConfig.ts b/extensions/blocknote-extensions/lib/components/UserMention/userMentionConfig.ts new file mode 100644 index 000000000000..7edbb476b4fe --- /dev/null +++ b/extensions/blocknote-extensions/lib/components/UserMention/userMentionConfig.ts @@ -0,0 +1,10 @@ +import { USER_MENTION_TYPE } from '../../utils/nodeTypes'; + +export const userMentionConfig = { + type: USER_MENTION_TYPE, + propSchema: { + userId: { default: '' }, + name: { default: '' }, + }, + content: 'none' as const, +}; diff --git a/extensions/blocknote-extensions/lib/components/WorkPackage/OptionsPopover.tsx b/extensions/blocknote-extensions/lib/components/WorkPackage/OptionsPopover.tsx index 22af8f966a8b..28af21946389 100644 --- a/extensions/blocknote-extensions/lib/components/WorkPackage/OptionsPopover.tsx +++ b/extensions/blocknote-extensions/lib/components/WorkPackage/OptionsPopover.tsx @@ -1,20 +1,14 @@ -import { useState, useRef } from 'react'; +import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import type { WorkPackage } from '../../openProjectTypes'; import { linkToWorkPackage } from '../../services/openProjectApi'; import type { InlineWpSize, BlockWpSize } from './types'; import styled from 'styled-components'; -import { editorThemeVariables, menuSurfaceStyles } from '../shared/theme'; -import { useAnchoredPopover, PopoverPortal } from '../shared/anchoredPopover'; import { SizeMenu } from './SizeMenu'; -import { FLOATING_Z_INDEX } from '../../utils/zIndex'; -import { - LinkExternalIcon, - TrashIcon, - ChevronDownIcon, -} from '@primer/octicons-react'; +import { ChevronDownIcon } from '@primer/octicons-react'; import {formatWorkPackageId} from '../../utils/id'; import { useTapActivation } from '../../utils/tapActivation'; +import { OptionsButton, OptionsPopover } from '../shared/OptionsPopover'; export interface WpOptionsProps { wp?:WorkPackage; @@ -30,62 +24,10 @@ export interface WpOptionsProps { onResizeBlock?:(size:BlockWpSize) => void; } -const Popover = styled.div.attrs({ - className: 'op-bn-inline-options', - 'data-testid': 'popover-content', -})` - ${editorThemeVariables} - position: absolute; - z-index: ${FLOATING_Z_INDEX.options}; - ${menuSurfaceStyles} - box-shadow: var(--bn-shadow-medium); - border-radius: var(--bn-border-radius-large); - padding: var(--spacer-s); - display: flex; - align-items: center; - gap: 2px; - bottom: calc(100% + 6px); - left: 0; - white-space: nowrap; -`; - -const PopBtn = styled.button<{ $danger?:boolean }>` - background: none; - border: none; - border-radius: var(--bn-border-radius-small); - padding: var(--spacer-s) var(--spacer-m); - cursor: pointer; - font-size: 0.82em; - color: ${({ $danger }) => - $danger - ? 'var(--mantine-color-red-8)' - : 'var(--bn-colors-editor-text, #333)'}; - display: flex; - align-items: center; - gap: var(--spacer-s); - line-height: 1; - &:hover { - background-color: var( - --bn-colors-highlights-gray-background, - #f5f5f5 - ); - } - svg { flex-shrink: 0; } -`; - -const Divider = styled.div` - width: 1px; - height: 18px; - background: var(--mantine-color-default-border); - margin: 0 2px; -`; - const SizeButtonWrapper = styled.div` position: relative; `; -const IcOpen = () => ; -const IcDelete = () => ; const IcChevron = () => ; export const WpOptionsPopover = ({ @@ -105,8 +47,6 @@ export const WpOptionsPopover = ({ const [showSizes, setShowSizes] = useState(false); const tapProps = useTapActivation(); - const popoverRef = useRef(null); - useAnchoredPopover({ anchorEl, popoverRef, placement: 'above' }); const [sizeButtonEl, setSizeButtonEl] = useState(null); @@ -140,34 +80,17 @@ export const WpOptionsPopover = ({ closeMenu(); }; - const content = ( - // stopPropagation stops the outside-tap handlers from closing the popover. - // Do NOT add preventDefault: on iOS it suppresses the first tap's click, so - // every button then needs a priming tap. - e.stopPropagation()} - onTouchStart={(e) => e.stopPropagation()} + return ( + - {openId && ( - <> - { - event?.stopPropagation(); - window.open(linkToWorkPackage(openId), '_blank', 'noopener,noreferrer'); - })} - > - {t('options.open')} - - - - - )} - - {displayedSize} - + {showSizes && ( )} - - - - { - event?.stopPropagation(); - onRemove?.(); - onClose(); - })} - > - {t('options.remove')} - - + ); - - return {content}; }; diff --git a/extensions/blocknote-extensions/lib/components/WorkPackage/UnavailableCard.tsx b/extensions/blocknote-extensions/lib/components/WorkPackage/UnavailableCard.tsx index fb09eb5247b4..b3939a7c979b 100644 --- a/extensions/blocknote-extensions/lib/components/WorkPackage/UnavailableCard.tsx +++ b/extensions/blocknote-extensions/lib/components/WorkPackage/UnavailableCard.tsx @@ -54,7 +54,14 @@ export const UnavailableCard = ({ headerKey, messageKey, icon, displayId, linkHe {displayId && {formatWorkPackageId(displayId)}} {linkHeader && displayId - ? {header} + ? ( + event.stopPropagation()} + > + {header} + + ) : header} diff --git a/extensions/blocknote-extensions/lib/components/WorkPackage/atoms.tsx b/extensions/blocknote-extensions/lib/components/WorkPackage/atoms.tsx index 8e216bc01346..8df78a196272 100644 --- a/extensions/blocknote-extensions/lib/components/WorkPackage/atoms.tsx +++ b/extensions/blocknote-extensions/lib/components/WorkPackage/atoms.tsx @@ -1,10 +1,10 @@ -import type { MouseEvent } from 'react'; import styled, { css } from 'styled-components'; import { defaultColorStyles, typeTextColor, } from '../../services/colors'; import { linkToWorkPackage } from '../../services/openProjectApi'; +import { newTabLinkProps } from '../../utils/links'; /* lets a line break between two meta parts that are each kept unbroken */ export const WRAP_OPPORTUNITY = '\u200B'; @@ -76,13 +76,7 @@ export const WorkPackageTitle = styled.span.attrs({ overflow-wrap: anywhere; `; -export const workPackageLinkProps = (displayId:string) => ({ - href: linkToWorkPackage(displayId), - target: '_blank' as const, - rel: 'noopener noreferrer', - /* keep link clicks from toggling the surrounding chip/card popover */ - onClick: (e:MouseEvent) => e.stopPropagation(), -}); +export const workPackageLinkProps = (displayId:string) => newTabLinkProps(linkToWorkPackage(displayId)); export const WorkPackageTitleLink = styled.a<{ $compact?:boolean }>` cursor: pointer; diff --git a/extensions/blocknote-extensions/lib/components/index.ts b/extensions/blocknote-extensions/lib/components/index.ts index cdd737b5ddb1..4a4ff0f2e721 100644 --- a/extensions/blocknote-extensions/lib/components/index.ts +++ b/extensions/blocknote-extensions/lib/components/index.ts @@ -4,3 +4,4 @@ export { getOpenProjectSlashMenuItems } from './SlashMenu'; export { ShadowDomWrapper } from './ShadowDomWrapper'; export { OpenProjectFormattingToolbar, useCreateWorkPackageFromSelection } from './FormattingToolbar'; export type { CreateWorkPackageFromSelection } from './FormattingToolbar'; +export { openProjectUserMentionSpec } from './UserMention'; diff --git a/extensions/blocknote-extensions/lib/components/shared/Avatar/Avatar.tsx b/extensions/blocknote-extensions/lib/components/shared/Avatar/Avatar.tsx new file mode 100644 index 000000000000..d970ea602c5f --- /dev/null +++ b/extensions/blocknote-extensions/lib/components/shared/Avatar/Avatar.tsx @@ -0,0 +1,55 @@ +import { useState } from 'react'; +import styled from 'styled-components'; +import { avatarUrlOf, userApiPath } from '../../../services/openProjectApi'; +import { avatarColorOf, initialsOf } from './avatars'; + +interface AvatarProps { + userId:string | number; + name:string; + size:number; +} + +const Circle = styled.span.attrs({ className: 'op-bn-avatar' })<{ $size:number; $color:string }>` + display: inline-flex; + align-items: center; + justify-content: center; + flex: none; + width: ${({ $size }) => $size}px; + height: ${({ $size }) => $size}px; + border-radius: 50%; + overflow: hidden; + background: ${({ $color }) => $color}; + color: #fff; + font-size: ${({ $size }) => Math.round($size * 0.45)}px; + font-weight: 600; + line-height: 1; + vertical-align: middle; +`; + +const Image = styled.img.attrs({ alt: '', draggable: false })` + width: 100%; + height: 100%; + object-fit: cover; +`; + +export function Avatar({ userId, name, size }:AvatarProps) { + const [imageLoaded, setImageLoaded] = useState(false); + const href = userApiPath(userId); + + return ( + + setImageLoaded(true)} + /> + {!imageLoaded && initialsOf(name)} + + ); +} diff --git a/extensions/blocknote-extensions/lib/components/shared/Avatar/avatars.ts b/extensions/blocknote-extensions/lib/components/shared/Avatar/avatars.ts new file mode 100644 index 000000000000..e27a2b24d799 --- /dev/null +++ b/extensions/blocknote-extensions/lib/components/shared/Avatar/avatars.ts @@ -0,0 +1,25 @@ +export interface AvatarPrincipal { + name:string; + href?:string | null; +} + +// Both follow OpenProject's own avatars, so a person looks the same in the editor as elsewhere. + +export function initialsOf(name:string):string { + // Spread to iterate by code points, so an emoji is not split in half. + const characters = [...name]; + const lastSpace = characters.lastIndexOf(' '); + const first = characters[0]?.toUpperCase() ?? ''; + if (lastSpace === -1) return first; + return first + (characters[lastSpace + 1]?.toUpperCase() ?? ''); +} + +export function avatarColorOf(principal:AvatarPrincipal):string { + const id = principal.href?.split('/').pop() ?? ''; + const text = `${id}${principal.name}`; + let hash = 0; + for (let index = 0; index < text.length; index++) { + hash = text.charCodeAt(index) + ((hash << 5) - hash); + } + return `hsl(${hash % 360}, 50%, 30%)`; +} diff --git a/extensions/blocknote-extensions/lib/components/shared/Avatar/index.ts b/extensions/blocknote-extensions/lib/components/shared/Avatar/index.ts new file mode 100644 index 000000000000..d3fb6dfa7c09 --- /dev/null +++ b/extensions/blocknote-extensions/lib/components/shared/Avatar/index.ts @@ -0,0 +1 @@ +export { Avatar } from './Avatar'; diff --git a/extensions/blocknote-extensions/lib/components/shared/OptionsPopover.tsx b/extensions/blocknote-extensions/lib/components/shared/OptionsPopover.tsx new file mode 100644 index 000000000000..43aa206abf0f --- /dev/null +++ b/extensions/blocknote-extensions/lib/components/shared/OptionsPopover.tsx @@ -0,0 +1,137 @@ +import { useRef } from 'react'; +import type { ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; +import styled from 'styled-components'; +import { LinkExternalIcon, TrashIcon } from '@primer/octicons-react'; +import { editorThemeVariables, menuSurfaceStyles } from './theme'; +import { useAnchoredPopover, PopoverPortal } from './anchoredPopover'; +import { FLOATING_Z_INDEX } from '../../utils/zIndex'; +import { useTapActivation } from '../../utils/tapActivation'; + +export interface OptionsPopoverProps { + anchorEl?:HTMLElement | null; + // Without an href there is nothing to open, so the Open button is left out. + openHref?:string; + openAriaLabel?:string; + removeAriaLabel:string; + onRemove?:() => void; + onClose:() => void; + // Extra sections between Open and Remove. + children?:ReactNode; +} + +const Popover = styled.div.attrs({ + className: 'op-bn-inline-options', + 'data-testid': 'popover-content', +})` + ${editorThemeVariables} + position: absolute; + z-index: ${FLOATING_Z_INDEX.options}; + ${menuSurfaceStyles} + box-shadow: var(--bn-shadow-medium); + border-radius: var(--bn-border-radius-large); + padding: var(--spacer-s); + display: flex; + align-items: center; + gap: 2px; + bottom: calc(100% + 6px); + left: 0; + white-space: nowrap; +`; + +export const OptionsButton = styled.button<{ $danger?:boolean }>` + background: none; + border: none; + border-radius: var(--bn-border-radius-small); + padding: var(--spacer-s) var(--spacer-m); + cursor: pointer; + font-size: 0.82em; + color: ${({ $danger }) => + $danger + ? 'var(--mantine-color-red-8)' + : 'var(--bn-colors-editor-text, #333)'}; + display: flex; + align-items: center; + gap: var(--spacer-s); + line-height: 1; + &:hover { + background-color: var( + --bn-colors-highlights-gray-background, + #f5f5f5 + ); + } + svg { flex-shrink: 0; } +`; + +const Divider = styled.div` + width: 1px; + height: 18px; + background: var(--mantine-color-default-border); + margin: 0 2px; +`; + +export const OptionsPopover = ({ + anchorEl, + openHref, + openAriaLabel, + removeAriaLabel, + onRemove, + onClose, + children, +}:OptionsPopoverProps) => { + const { t } = useTranslation(); + const tapProps = useTapActivation(); + const popoverRef = useRef(null); + useAnchoredPopover({ anchorEl, popoverRef, placement: 'above' }); + + const content = ( + // stopPropagation stops the outside-tap handlers from closing the popover. + // Do NOT add preventDefault: on iOS it suppresses the first tap's click, so + // every button then needs a priming tap. + e.stopPropagation()} + onTouchStart={(e) => e.stopPropagation()} + > + {openHref && ( + <> + { + event?.stopPropagation(); + window.open(openHref, '_blank', 'noopener,noreferrer'); + })} + > + {t('options.open')} + + + + + )} + + {children && ( + <> + {children} + + + )} + + { + event?.stopPropagation(); + onRemove?.(); + onClose(); + })} + > + {t('options.remove')} + + + ); + + return {content}; +}; diff --git a/extensions/blocknote-extensions/lib/components/shared/SuggestionListbox.tsx b/extensions/blocknote-extensions/lib/components/shared/SuggestionListbox.tsx new file mode 100644 index 000000000000..12e38aec77f6 --- /dev/null +++ b/extensions/blocknote-extensions/lib/components/shared/SuggestionListbox.tsx @@ -0,0 +1,117 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import type { MouseEvent, ReactNode } from 'react'; +import styled from 'styled-components'; +import { useTranslation } from 'react-i18next'; +import type { SuggestionMenuProps } from '@blocknote/react'; +import type { SuggestionMenuItem, SuggestionSearchState } from '../../hooks/useSuggestionSearch'; +import { editorThemeVariables, menuSurfaceStyles } from './theme'; +import { SearchMessage } from './SearchMessage'; +import { Spinner } from '../Spinner'; +import { supportsHover } from '../../utils/device'; +import { useActiveOptionInView } from '../../hooks/useActiveOptionInView'; +import { useTapActivation } from '../../utils/tapActivation'; + +const Menu = styled.div` + ${editorThemeVariables} + ${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<{ $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); +`; + +interface SuggestionListboxProps extends SuggestionMenuProps { + // Names the classes and test id: `op-bn-`, `op-bn--item`, ``. + name:string; + ariaLabel:string; + loadingMessage:string; + searchState:SuggestionSearchState; + // Shown for an empty query instead of the search outcome. + promptMessage?:string; + optionKey:(option:T) => string | number; + renderOption:(option:T) => ReactNode; + // Rendered for the item flagged `trailing`, after the results. + trailingOption?:ReactNode; +} + +export function SuggestionListbox({ + items, loadingState, selectedIndex, onItemClick, + name, ariaLabel, loadingMessage, searchState, promptMessage, optionKey, renderOption, trailingOption, +}:SuggestionListboxProps) { + const { t } = useTranslation(); + const { query, results: options, error } = searchState; + const canHover = useMemo(() => supportsHover(), []); + const [hoveredIndex, setHoveredIndex] = useState(null); + const menuRef = useRef(null); + const tapProps = useTapActivation(); + + // Without results the first item is the typed query placeholder, which has no row. + const trailingIndex = items.findIndex((item) => item.trailing); + const rowIndexOf = (index:number) => (index === trailingIndex ? options.length : index); + + useActiveOptionInView(menuRef, rowIndexOf(selectedIndex ?? -1), options); + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + setHoveredIndex(null); + }, [selectedIndex, options]); + + const surface = { className: `op-bn-${name}`, 'data-testid': name }; + + let emptyMessage:string | null = null; + if (promptMessage !== undefined && !query) emptyMessage = promptMessage; + else if (error) emptyMessage = t('search.error'); + else if (options.length === 0) emptyMessage = t('search.noResults'); + + const pick = (index:number) => { + const item = items[index]; + if (item) onItemClick?.(item); + }; + + if (loadingState !== 'loaded') { + return {loadingMessage}; + } + + const hasTrailingRow = trailingIndex !== -1 && trailingOption !== undefined; + const message = emptyMessage !== null && {emptyMessage}; + + if (message && !hasTrailingRow) return {message}; + + const highlightedIndex = hoveredIndex ?? selectedIndex; + + const optionProps = (index:number) => ({ + className: `op-bn-${name}-item`, + $highlighted: highlightedIndex === index, + onMouseMove: canHover ? () => setHoveredIndex(index) : undefined, + role: 'option', + 'aria-selected': selectedIndex === index, + onMouseDown: (event:MouseEvent) => event.preventDefault(), + ...tapProps(() => pick(index)), + }); + + return ( + setHoveredIndex(null) : undefined}> + {message} + +
+ {options.map((option, index) => ( + + {renderOption(option)} + + ))} + + {hasTrailingRow && {trailingOption}} +
+
+ ); +} diff --git a/extensions/blocknote-extensions/lib/hooks/useDebouncedSearch.ts b/extensions/blocknote-extensions/lib/hooks/useDebouncedSearch.ts new file mode 100644 index 000000000000..d685b3491287 --- /dev/null +++ b/extensions/blocknote-extensions/lib/hooks/useDebouncedSearch.ts @@ -0,0 +1,47 @@ +import { useCallback, useEffect, useRef } from 'react'; + +interface DebouncedSearchOptions { + debounce?:number; + searchBlank?:boolean; +} + +/** + * For BlockNote's `getItems`, which awaits every call: a call superseded by a + * newer one, or cut off by unmounting, must still settle, so it resolves empty + * instead of hanging. + */ +export function useDebouncedSearch( + search:(query:string) => Promise, + { debounce = 300, searchBlank = false }:DebouncedSearchOptions = {}, +):{ search:(query:string) => Promise; cancel:() => void } { + const timerRef = useRef | null>(null); + const pendingResolveRef = useRef<((results:T[]) => void) | null>(null); + + const cancel = useCallback(() => { + if (timerRef.current !== null) clearTimeout(timerRef.current); + timerRef.current = null; + pendingResolveRef.current?.([]); + pendingResolveRef.current = null; + }, []); + + useEffect(() => cancel, [cancel]); + + const debouncedSearch = useCallback((query:string) => { + cancel(); + + if (!searchBlank && !query.trim()) return Promise.resolve([]); + + return new Promise((resolve, reject) => { + pendingResolveRef.current = resolve; + timerRef.current = setTimeout(() => { + timerRef.current = null; + pendingResolveRef.current = null; + Promise.resolve().then(() => search(query)).then(resolve, (error:unknown) => { + reject(error instanceof Error ? error : new Error(String(error))); + }); + }, debounce); + }); + }, [search, debounce, searchBlank, cancel]); + + return { search: debouncedSearch, cancel }; +} diff --git a/extensions/blocknote-extensions/lib/hooks/useInlineNodeOptions.ts b/extensions/blocknote-extensions/lib/hooks/useInlineNodeOptions.ts new file mode 100644 index 000000000000..07483bfff4d3 --- /dev/null +++ b/extensions/blocknote-extensions/lib/hooks/useInlineNodeOptions.ts @@ -0,0 +1,60 @@ +import { useCallback, useState } from 'react'; +import type { RefObject } from 'react'; +import type { AnyEditor } from '../editorTypes'; +import { findInlineNodeAtDOM, removeInlineNodeAt, selectInlineNodeAt } from '../utils/inlineNodes'; +import type { FoundInlineNode } from '../utils/inlineNodes'; +import { useTapActivation } from '../utils/tapActivation'; +import type { TapActivationProps } from '../utils/tapActivation'; +import { usePressOutside } from './usePressOutside'; +import { useSuppressFormattingToolbar } from './useSuppressFormattingToolbar'; + +export interface InlineNodeOptions { + optionsOpen:boolean; + closeOptions:() => void; + // `beforeToggle` is for a caller with a popover of its own to close first. + activationProps:(beforeToggle?:() => void) => TapActivationProps; + findNode:() => FoundInlineNode | null; + removeNode:() => void; +} + +// The options popover of an atom inline node (work package chip, user mention): +// activating the node selects it and toggles the popover, a press outside closes it. +export function useInlineNodeOptions( + editor:AnyEditor | undefined, + nodeRef:RefObject, + nodeType:string, +):InlineNodeOptions { + const [optionsOpen, setOptionsOpen] = useState(false); + const closeOptions = useCallback(() => setOptionsOpen(false), []); + + useSuppressFormattingToolbar(editor, optionsOpen); + usePressOutside(nodeRef, optionsOpen, closeOptions); + + const findNode = () => { + if (!editor || !nodeRef.current) return null; + return findInlineNodeAtDOM(editor, nodeRef.current, nodeType); + }; + + const selectNode = () => { + if (!editor) return; + const found = findNode(); + if (found) selectInlineNodeAt(editor, found.position, nodeType); + editor.getExtension('formattingToolbar')?.store?.setState(false); + }; + + const removeNode = () => { + const found = findNode(); + if (editor && found) removeInlineNodeAt(editor, found.position, nodeType); + }; + + const tapProps = useTapActivation(); + const activationProps = (beforeToggle?:() => void) => tapProps((event) => { + event?.preventDefault(); + event?.stopPropagation(); + beforeToggle?.(); + setOptionsOpen((open) => !open); + selectNode(); + }); + + return { optionsOpen, closeOptions, activationProps, findNode, removeNode }; +} diff --git a/extensions/blocknote-extensions/lib/hooks/usePressOutside.ts b/extensions/blocknote-extensions/lib/hooks/usePressOutside.ts new file mode 100644 index 000000000000..68be876e3c0e --- /dev/null +++ b/extensions/blocknote-extensions/lib/hooks/usePressOutside.ts @@ -0,0 +1,25 @@ +import { useEffect } from 'react'; +import type { RefObject } from 'react'; + +// Touch is listened for in its own right: a tap another element answers never +// becomes a mousedown. composedPath() sees through a shadow root, where the +// target is retargeted to the host. +export function usePressOutside( + elementRef:RefObject, + active:boolean, + onPressOutside:() => void, +):void { + useEffect(() => { + if (!active) return; + const handlePress = (event:Event) => { + const element = elementRef.current; + if (element && !event.composedPath().includes(element)) onPressOutside(); + }; + document.addEventListener('mousedown', handlePress); + document.addEventListener('touchstart', handlePress); + return () => { + document.removeEventListener('mousedown', handlePress); + document.removeEventListener('touchstart', handlePress); + }; + }, [elementRef, active, onPressOutside]); +} diff --git a/extensions/blocknote-extensions/lib/hooks/useSuggestionSearch.ts b/extensions/blocknote-extensions/lib/hooks/useSuggestionSearch.ts new file mode 100644 index 000000000000..f6590c0f212d --- /dev/null +++ b/extensions/blocknote-extensions/lib/hooks/useSuggestionSearch.ts @@ -0,0 +1,89 @@ +import { useCallback, useMemo, useRef } from 'react'; +import type { FC, RefObject } from 'react'; +import type { SuggestionMenuProps } from '@blocknote/react'; +import type { AnyEditor } from '../editorTypes'; +import { restoreTypedQuery } from '../utils/insertInline'; +import { closeSuggestionMenu } from '../utils/suggestionMenu'; + +export interface SuggestionMenuItem { + title:string; + onItemClick:() => void; + // Comes after the results, so it is never what Enter picks before the user moves to it. + trailing?:boolean; +} + +export interface SuggestionSearchState { + query:string; + results:T[]; + error:string | null; +} + +export type SuggestionPlan = + | { kind:'close' } + | { kind:'prompt' } + | { kind:'search'; pick:(result:T) => void; trailingItem?:SuggestionMenuItem }; + +export type SuggestionMenuComponent = FC>; + +interface SuggestionSearchOptions { + trigger:string; + // Callers pass stable functions: a new `getItems` makes BlockNote search again. + planFor:(query:string) => SuggestionPlan; + search:(query:string) => Promise; + cancelSearch:() => void; + logPrefix:string; + createMenu:(searchStateRef:RefObject>) => SuggestionMenuComponent; +} + +export function useSuggestionSearch( + editor:AnyEditor, + { trigger, planFor, search, cancelSearch, logPrefix, createMenu }:SuggestionSearchOptions, +) { + const searchStateRef = useRef>({ query: '', results: [], error: null }); + const latestRequestRef = useRef(0); + + const getItems = useCallback(async (query:string):Promise => { + latestRequestRef.current += 1; + const request = latestRequestRef.current; + const typedQueryItems = [{ title: query, onItemClick: () => restoreTypedQuery(editor, trigger, query) }]; + + const plan = planFor(query); + if (plan.kind !== 'search') cancelSearch(); + if (plan.kind === 'close') { + closeSuggestionMenu(editor); + return []; + } + if (plan.kind === 'prompt') { + searchStateRef.current = { query, results: [], error: null }; + return typedQueryItems; + } + + const withTrailingItem = (items:SuggestionMenuItem[]) => + (plan.trailingItem ? [...items, { ...plan.trailingItem, trailing: true }] : items); + + try { + const results = await search(query); + if (request !== latestRequestRef.current) return []; + searchStateRef.current = { query, results, error: null }; + + if (results.length === 0) return withTrailingItem(typedQueryItems); + return withTrailingItem(results.map((result) => ({ title: query, onItemClick: () => plan.pick(result) }))); + } catch (error) { + console.error(logPrefix, error); + if (request === latestRequestRef.current) { + searchStateRef.current = { + query, + results: [], + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + return withTrailingItem(typedQueryItems); + } + }, [editor, trigger, planFor, search, cancelSearch, logPrefix]); + + /* eslint-disable react-hooks/refs */ + const Menu = useMemo(() => createMenu(searchStateRef), [createMenu]); + /* eslint-enable react-hooks/refs */ + + return { getItems, Menu }; +} diff --git a/extensions/blocknote-extensions/lib/hooks/useWorkPackageSearch.ts b/extensions/blocknote-extensions/lib/hooks/useWorkPackageSearch.ts index 2c6609a85b37..eedd02a6bd3d 100644 --- a/extensions/blocknote-extensions/lib/hooks/useWorkPackageSearch.ts +++ b/extensions/blocknote-extensions/lib/hooks/useWorkPackageSearch.ts @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import type { WorkPackage } from '../openProjectTypes'; import { searchWorkPackages } from '../services/openProjectApi'; +import { useDebouncedSearch } from './useDebouncedSearch'; export const MAX_SEARCH_RESULTS = 5; @@ -18,10 +19,6 @@ export function useWorkPackageSearch( const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - // Used to cancel debounce in imperative search() - const debounceTimerRef = useRef | null>(null); - const pendingResolveRef = useRef<((results:WorkPackage[]) => void) | null>(null); - // Reactive search (used by SearchDropdown) useEffect(() => { let active = true; @@ -63,40 +60,23 @@ export function useWorkPackageSearch( }; }, [searchQuery, debounce]); - // Imperative search (used by BlockNote getItems — must return results immediately) - const search = useCallback( - (query:string):Promise => { - // A superseded call must still settle, otherwise its caller awaits forever - if (debounceTimerRef.current !== null) { - clearTimeout(debounceTimerRef.current); - debounceTimerRef.current = null; - } - pendingResolveRef.current?.([]); - pendingResolveRef.current = null; + const { search: debouncedSearch, cancel: cancelSearch } = useDebouncedSearch(searchWorkPackages, { debounce }); - if (!query.trim()) { - setSearchResults([]); - return Promise.resolve([]); - } + // Imperative search (used by BlockNote getItems — must return results immediately) + const latestCallRef = useRef(0); - return new Promise((resolve, reject) => { - pendingResolveRef.current = resolve; - debounceTimerRef.current = setTimeout(async () => { - debounceTimerRef.current = null; - pendingResolveRef.current = null; - try { - const results = await searchWorkPackages(query); - setSearchResults(results); - resolve(results); - } catch (error) { - setSearchResults([]); - reject(error instanceof Error ? error : new Error(String(error))); - } - }, debounce); - }); - }, - [debounce] - ); + const search = useCallback(async (query:string):Promise => { + latestCallRef.current += 1; + const call = latestCallRef.current; + try { + const results = await debouncedSearch(query); + if (call === latestCallRef.current) setSearchResults(results); + return results; + } catch (error) { + if (call === latestCallRef.current) setSearchResults([]); + throw error; + } + }, [debouncedSearch]); return { searchQuery, @@ -105,5 +85,6 @@ export function useWorkPackageSearch( loading, error, search, + cancelSearch, }; } diff --git a/extensions/blocknote-extensions/lib/index.ts b/extensions/blocknote-extensions/lib/index.ts index 2d7b89fff92a..44c8b0ad1852 100644 --- a/extensions/blocknote-extensions/lib/index.ts +++ b/extensions/blocknote-extensions/lib/index.ts @@ -2,6 +2,7 @@ import './services/i18n.ts'; export { openProjectWorkPackageBlockSpec, openProjectWorkPackageInlineSpec, + openProjectUserMentionSpec, getOpenProjectSlashMenuItems, OpenProjectFormattingToolbar, useCreateWorkPackageFromSelection, @@ -9,8 +10,11 @@ export { } from './components'; export type { CreateWorkPackageFromSelection } from './components'; export { initializeOpBlockNoteExtensions } from './initialize'; +export { UserMentionNotificationExtension } from './plugins/userMentionNotificationExtension'; export type { InlineWpSize, BlockWpSize, WpSize } from './components/WorkPackage/types'; -export { OpenProjectHashMenu, createHashWpMenuComponent, isHashWpQuery, useHashWpMenu } from './components/HashMenu'; +export { OpenProjectHashMenu, createHashWpMenuComponent, isHashWpQuery, useHashWpMenu, HASH_TRIGGER } from './components/HashMenu'; export type { HashMenuItem } from './components/HashMenu'; export { useWorkPackageSearch } from './hooks/useWorkPackageSearch'; export type { WorkPackage } from './openProjectTypes'; +export { OpenProjectMentionMenu, MENTION_TRIGGER } from './components/MentionMenu'; +export type { SuggestionMenuItem, SuggestionSearchState } from './hooks/useSuggestionSearch'; diff --git a/extensions/blocknote-extensions/lib/initialize.ts b/extensions/blocknote-extensions/lib/initialize.ts index 8b49322b1bd7..e65dda306e5e 100644 --- a/extensions/blocknote-extensions/lib/initialize.ts +++ b/extensions/blocknote-extensions/lib/initialize.ts @@ -7,9 +7,10 @@ export function initializeOpBlockNoteExtensions(config:{ proxyUrl?:string, locale:string, projectId?:string | number, + documentId?:string | number, }) { initOpenProjectApi({ baseUrl: config.baseUrl, proxyUrl: config.proxyUrl }); void probeCreateWorkPackagePermission(); initLanguage(config.locale); - initEditorContext({ projectId: config.projectId }); + initEditorContext({ projectId: config.projectId, documentId: config.documentId }); } diff --git a/extensions/blocknote-extensions/lib/locales/en.ts b/extensions/blocknote-extensions/lib/locales/en.ts index 774c820ef663..75e3d2c96a31 100644 --- a/extensions/blocknote-extensions/lib/locales/en.ts +++ b/extensions/blocknote-extensions/lib/locales/en.ts @@ -98,6 +98,15 @@ export const en = { 'hashMenu': { 'typeToSearch': 'Type to search work packages…' }, + 'mentionMenu': { + 'loading': 'Searching people…', + 'ariaLabel': 'People to mention' + }, + 'mention': { + 'ariaLabel': 'User {{name}}', + 'openAriaLabel': "Open {{name}}'s profile in new tab", + 'removeAriaLabel': 'Remove mention' + }, 'spinner': { 'ariaLabel': 'Loading' }, diff --git a/extensions/blocknote-extensions/lib/openProjectTypes.ts b/extensions/blocknote-extensions/lib/openProjectTypes.ts index 698810bf20b7..b38273620c7b 100644 --- a/extensions/blocknote-extensions/lib/openProjectTypes.ts +++ b/extensions/blocknote-extensions/lib/openProjectTypes.ts @@ -124,3 +124,8 @@ export interface OpenProjectApiErrorBody extends OpenProjectApiErrorEntry { } export type OpColorMode = 'light' | 'dark'; + +export interface Principal { + id:number; + name:string; +} diff --git a/extensions/blocknote-extensions/lib/plugins/userMentionNotificationExtension.ts b/extensions/blocknote-extensions/lib/plugins/userMentionNotificationExtension.ts new file mode 100644 index 000000000000..cba2af1c1a81 --- /dev/null +++ b/extensions/blocknote-extensions/lib/plugins/userMentionNotificationExtension.ts @@ -0,0 +1,8 @@ +import { createExtension } from '@blocknote/core'; +import { userMentionNotificationPlugin } from './userMentionNotificationPlugin'; + +// Registered on the editor because BlockNote ignores the extensions of inline content specs. +export const UserMentionNotificationExtension = createExtension(() => ({ + key: 'userMentionNotification', + prosemirrorPlugins: [userMentionNotificationPlugin()], +}))(); diff --git a/extensions/blocknote-extensions/lib/plugins/userMentionNotificationPlugin.ts b/extensions/blocknote-extensions/lib/plugins/userMentionNotificationPlugin.ts new file mode 100644 index 000000000000..6af9d92956c4 --- /dev/null +++ b/extensions/blocknote-extensions/lib/plugins/userMentionNotificationPlugin.ts @@ -0,0 +1,99 @@ +import { Plugin, PluginKey } from 'prosemirror-state'; +import type { Transaction } from 'prosemirror-state'; +import type { Node as ProsemirrorNode } from 'prosemirror-model'; +import { contextDocumentId } from '../services/editorContext'; +import { notifyMentionedUser } from '../services/openProjectApi'; +import { USER_MENTION_TYPE } from '../utils/nodeTypes'; + +// y-prosemirror's sync key, matched by name like BlockNote does, so the bundle needs no y-prosemirror. +const Y_SYNC_META_KEY = 'y-sync$'; + +/** Per user: mentions inserted minus removed by local transactions. */ +type MentionTally = ReadonlyMap; + +const userMentionNotificationKey = new PluginKey('userMentionNotification'); + +function isFromYjs(transaction:Transaction):boolean { + return transaction.getMeta(Y_SYNC_META_KEY) !== undefined; +} + +function isUserMention(node:ProsemirrorNode):boolean { + return node.type.name === USER_MENTION_TYPE; +} + +function addMentions(counts:Map, userId:string, amount:number):void { + counts.set(userId, (counts.get(userId) ?? 0) + amount); +} + +function mentionedUserId(node:ProsemirrorNode):string { + return String(node.attrs.userId); +} + +// A move removes as many mentions of the user as it inserts, so it nets out. +function mentionChanges(transaction:Transaction):Map { + const changes = new Map(); + let insertedRanges:{ from:number; to:number }[] = []; + transaction.steps.forEach((step, index) => { + const stepMap = step.getMap(); + insertedRanges = insertedRanges + .map(({ from, to }) => ({ from: stepMap.map(from, 1), to: stepMap.map(to, -1) })) + .filter(({ from, to }) => from < to); + + stepMap.forEach((oldStart, oldEnd, newStart, newEnd) => { + if (newStart < newEnd) insertedRanges.push({ from: newStart, to: newEnd }); + transaction.docs[index].nodesBetween(oldStart, oldEnd, (node) => { + if (isUserMention(node)) addMentions(changes, mentionedUserId(node), -1); + }); + }); + }); + + const counted = new Set(); + for (const { from, to } of insertedRanges) { + transaction.doc.nodesBetween(from, to, (node, position) => { + if (!isUserMention(node) || counted.has(position)) return; + counted.add(position); + addMentions(changes, mentionedUserId(node), 1); + }); + } + return changes; +} + +function tallied(tally:MentionTally, transaction:Transaction):MentionTally { + if (!transaction.docChanged || isFromYjs(transaction)) return tally; + + const changes = mentionChanges(transaction); + if (changes.size === 0) return tally; + + const next = new Map(tally); + changes.forEach((change, userId) => addMentions(next, userId, change)); + return next; +} + +function notify(documentId:string | number, userId:string):void { + notifyMentionedUser(documentId, userId).catch((error:unknown) => { + console.error(`[user mention] Failed to notify user ${userId}:`, error); + }); +} + +// Sends from the view, not from `apply`, so a state that is never applied notifies nobody. +export function userMentionNotificationPlugin():Plugin { + return new Plugin({ + key: userMentionNotificationKey, + state: { + init: () => new Map(), + apply: (transaction, tally) => tallied(tally, transaction), + }, + view: () => ({ + update(view, previousState) { + const tally = userMentionNotificationKey.getState(view.state); + const previousTally = userMentionNotificationKey.getState(previousState); + const documentId = contextDocumentId(); + if (!tally || !previousTally || tally === previousTally || documentId === undefined) return; + + tally.forEach((mentions, userId) => { + if (mentions > (previousTally.get(userId) ?? 0)) notify(documentId, userId); + }); + }, + }), + }); +} diff --git a/extensions/blocknote-extensions/lib/server.ts b/extensions/blocknote-extensions/lib/server.ts index 43dae83389f3..fc065c8e49b8 100644 --- a/extensions/blocknote-extensions/lib/server.ts +++ b/extensions/blocknote-extensions/lib/server.ts @@ -32,3 +32,12 @@ export type { WorkPackageInlineExternalData, WorkPackageInlineProps, } from './components/InlineWorkPackage/externalHtml'; + +export { userMentionConfig } from './components/UserMention/userMentionConfig'; +export { openProjectUserMentionStaticSpec } from './components/UserMention/staticSpec'; +export { + buildUserMentionStoredDOM, + userMentionTag, + parseUserMentionExternalHTML, +} from './components/UserMention/externalHtml'; +export type { UserMentionProps } from './components/UserMention/externalHtml'; diff --git a/extensions/blocknote-extensions/lib/services/editorContext.ts b/extensions/blocknote-extensions/lib/services/editorContext.ts index 46e0a9a10a4e..ce76a1f9feec 100644 --- a/extensions/blocknote-extensions/lib/services/editorContext.ts +++ b/extensions/blocknote-extensions/lib/services/editorContext.ts @@ -1,10 +1,19 @@ let projectId:string | number | undefined; +let documentId:string | number | undefined; -/** `projectId` is the project the edited document belongs to, where the application knows it. */ -export function initEditorContext(context:{ projectId?:string | number }):void { +/** + * `projectId` is the project the edited document belongs to, where the application knows it. + * `documentId` is the document mentioned users are notified about. + */ +export function initEditorContext(context:{ projectId?:string | number, documentId?:string | number }):void { projectId = context.projectId === '' ? undefined : context.projectId; + documentId = context.documentId === '' ? undefined : context.documentId; } export function contextProjectId():string | number | undefined { return projectId; } + +export function contextDocumentId():string | number | undefined { + return documentId; +} diff --git a/extensions/blocknote-extensions/lib/services/openProjectApi.ts b/extensions/blocknote-extensions/lib/services/openProjectApi.ts index 54205110d988..2529b44bda33 100644 --- a/extensions/blocknote-extensions/lib/services/openProjectApi.ts +++ b/extensions/blocknote-extensions/lib/services/openProjectApi.ts @@ -4,12 +4,14 @@ import type { HalResource, OpenProjectApiErrorBody, OpenProjectResponse, + Principal, StatusCollection, TypeCollection, WorkPackage, WorkPackageForm, WorkPackagePayload, } from '../openProjectTypes'; +import { contextProjectId } from './editorContext'; let baseUrl = 'https://openproject.local'; let proxyUrl = 'https://openproject.local'; @@ -84,7 +86,7 @@ async function readError(response:Response):Promise { return { message: statusLine, attributeErrors: {} }; } -async function post(endpoint:string, body:unknown):Promise { +async function postRequest(endpoint:string, body:unknown):Promise { const response = await fetch(`${proxyUrl}${endpoint}`, { method: 'POST', headers: { @@ -98,6 +100,11 @@ async function post(endpoint:string, body:unknown):Promise { const { message, attributeErrors } = await readError(response); throw new OpenProjectApiError(message, response.status, attributeErrors); } + return response; +} + +async function post(endpoint:string, body:unknown):Promise { + const response = await postRequest(endpoint, body); return response.json() as Promise; } @@ -111,6 +118,25 @@ export function linkToNewWorkPackage(projectId?:string):string { : `${baseUrl}/work_packages/new`; } +export function linkToUser(userId:string | number):string { + return `${baseUrl}/users/${encodeURIComponent(String(userId))}`; +} + +export function userApiPath(userId:string | number):string { + return `/api/v3/users/${encodeURIComponent(String(userId))}`; +} + +function documentApiPath(documentId:string | number):string { + return `/api/v3/documents/${encodeURIComponent(String(documentId))}`; +} + +const USER_HREF = /^\/api\/v3\/users\/\d+$/; + +/** Only users have a picture; groups and placeholder users stay with their initials. */ +export function avatarUrlOf(principalHref:string | null | undefined):string | undefined { + return principalHref && USER_HREF.test(principalHref) ? `${proxyUrl}${principalHref}/avatar` : undefined; +} + const WP_ID_URL_PATTERN = '\\d+|[A-Za-z][A-Za-z0-9_]*-\\d+'; const WP_ID_REGEX = new RegExp(`^(?:${WP_ID_URL_PATTERN})$`); @@ -360,3 +386,39 @@ export async function searchWorkPackages(query:string):Promise { const data = await get(`/api/v3/work_packages?${params.toString()}`); return data?._embedded?.elements as unknown as WorkPackage[] ?? []; } + +const MAX_MENTION_RESULTS = 5; + +const LOCKED_USER_STATUS = '3'; + +/** The people a document can mention: active users, members of its project where it has one. */ +export async function searchMentionableUsers(query:string):Promise { + const projectId = contextProjectId(); + const trimmedQuery = query.trim(); + + const filters:HalFilter[] = [ + { status: { operator: '!', values: [LOCKED_USER_STATUS] } }, + { type: { operator: '=', values: ['User'] } }, + ...(projectId === undefined ? [] : [{ member: { operator: '=', values: [String(projectId)] } }]), + ...(trimmedQuery ? [TYPEAHEAD_FILTER(trimmedQuery)] : []), + ]; + const params = new URLSearchParams({ + filters: JSON.stringify(filters), + sortBy: JSON.stringify([['name', 'asc']]), + pageSize: String(MAX_MENTION_RESULTS), + }); + + const data = await get>(`/api/v3/principals?${params.toString()}`); + return (data._embedded?.elements ?? []).map(({ id, name }) => ({ id, name })); +} + +/** The API may answer 204 without a body, e.g. for a self-mention. */ +export async function notifyMentionedUser(documentId:string | number, userId:string | number):Promise { + await postRequest('/api/v3/notifications', { + reason: 'mentioned', + _links: { + resource: { href: documentApiPath(documentId) }, + recipient: { href: userApiPath(userId) }, + }, + }); +} diff --git a/extensions/blocknote-extensions/lib/utils/html.ts b/extensions/blocknote-extensions/lib/utils/html.ts new file mode 100644 index 000000000000..05d0c8eecc3e --- /dev/null +++ b/extensions/blocknote-extensions/lib/utils/html.ts @@ -0,0 +1,7 @@ +export function escapeHtmlText(value:string):string { + return value.replace(/&/g, '&').replace(//g, '>'); +} + +export function escapeHtmlAttribute(value:string):string { + return escapeHtmlText(value).replace(/"/g, '"'); +} diff --git a/extensions/blocknote-extensions/lib/utils/inlineChipActions.ts b/extensions/blocknote-extensions/lib/utils/inlineChipActions.ts index 5d9a2f3c3168..062bdd01b688 100644 --- a/extensions/blocknote-extensions/lib/utils/inlineChipActions.ts +++ b/extensions/blocknote-extensions/lib/utils/inlineChipActions.ts @@ -1,70 +1,30 @@ import type { InlineContentFromConfig } from '@blocknote/core'; import type { Node as ProsemirrorNode } from 'prosemirror-model'; -import { NodeSelection } from 'prosemirror-state'; import type { AnyEditor } from '../editorTypes'; import type { WorkPackage } from '../openProjectTypes'; import type { InlineWpSize, BlockWpSize } from '../components/WorkPackage/types'; import { moveCursorAfterBlock } from './cursor'; import { BLOCK_WP_TYPE, INLINE_WP_TYPE } from './nodeTypes'; -import { hideSafariPhantomSelection } from './selection'; +import { inlineNodeAt } from './inlineNodes'; +import type { FoundInlineNode } from './inlineNodes'; import { PENDING_PREFIX } from '../components/InlineWorkPackage/callbacks'; -// Direct, position-based operations on inline work package chips. -// -// The chip's React component locates its own ProseMirror node via its DOM -// element (`findInlineChipAtDOM`) and mutates the document directly. This -// replaces the former event bridge (`wpBridge`) that addressed chips by a -// persisted `instanceId` prop — which leaked into clipboard HTML and required -// paste-time deduplication. A position uniquely identifies one node instance, -// so copies are independent by construction. - // eslint-disable-next-line @typescript-eslint/no-explicit-any type AnyInlineNode = InlineContentFromConfig; -export interface FoundInlineChip { - position:number; - node:ProsemirrorNode; -} - export interface ChipContent { type:typeof INLINE_WP_TYPE; props:{ wpid:string; size:InlineWpSize; displayId:string }; } -/** - * Resolves the chip's own DOM element to its ProseMirror node and position. - * - * `posAtDOM` may return the position directly before the atom node or the - * position inside its node-view wrapper (off by one), so both candidates are - * checked and verified by node type. - */ -export function findInlineChipAtDOM(editor:AnyEditor, chipDom:HTMLElement):FoundInlineChip | null { - const view = editor.prosemirrorView; - if (!view) return null; - - let basePosition:number; - try { - basePosition = view.posAtDOM(chipDom, 0); - } catch { - return null; - } - - for (const position of [basePosition, basePosition - 1]) { - if (position < 0) continue; - const node = view.state.doc.nodeAt(position); - if (node?.type.name === INLINE_WP_TYPE) return { position, node }; - } - return null; -} - /** * Finds a pending chip by its wpid. Only `pending:` placeholder wpids * are unique in the document, so only those can be found reliably. */ -export function findPendingInlineChip(doc:ProsemirrorNode, wpid:string):FoundInlineChip | null { +export function findPendingInlineChip(doc:ProsemirrorNode, wpid:string):FoundInlineNode | null { if (!wpid.startsWith(PENDING_PREFIX)) return null; - let found:FoundInlineChip | null = null; + let found:FoundInlineNode | null = null; doc.descendants((node, position) => { if (found) return false; if (node.type.name === INLINE_WP_TYPE && node.attrs.wpid === wpid) { @@ -93,29 +53,6 @@ export function chipContentOf(workPackage:WorkPackage, size:InlineWpSize):ChipCo }; } -function chipAt(editor:AnyEditor, position:number):ProsemirrorNode | 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 { - if (!chipAt(editor, position)) return; - editor.transact((tr) => { - tr.setSelection(NodeSelection.create(tr.doc, position)); - }); - hideSafariPhantomSelection(editor); -} - -export function removeInlineChipAt(editor:AnyEditor, position:number):void { - const node = chipAt(editor, position); - if (!node) return; - editor.transact((tr) => { - tr.delete(position, position + node.nodeSize); - }); -} - /** * Replaces the inline chip at `position` with a block work package card, * splitting the surrounding paragraph content around it. @@ -125,7 +62,7 @@ export function promoteInlineChipToBlockAt( position:number, size:BlockWpSize = 'm' ):void { - const node = chipAt(editor, position); + const node = inlineNodeAt(editor, position, INLINE_WP_TYPE); if (!node) return; // wpid must be a positive integer diff --git a/extensions/blocknote-extensions/lib/utils/inlineNodes.ts b/extensions/blocknote-extensions/lib/utils/inlineNodes.ts new file mode 100644 index 000000000000..19d07c55e355 --- /dev/null +++ b/extensions/blocknote-extensions/lib/utils/inlineNodes.ts @@ -0,0 +1,67 @@ +import type { Node as ProsemirrorNode } from 'prosemirror-model'; +import { NodeSelection } from 'prosemirror-state'; +import type { AnyEditor } from '../editorTypes'; +import { hideSafariPhantomSelection } from './selection'; + +// Position-based operations on atom inline nodes (work package chips, user +// mentions). A node's React component finds its own position from its DOM +// element, and a position names exactly one node instance, so copies of a node +// stay independent without an id prop that would leak into clipboard HTML. + +export interface FoundInlineNode { + position:number; + node:ProsemirrorNode; +} + +/** + * Resolves a node's own DOM element to its ProseMirror node and position. + * + * `posAtDOM` may return the position directly before the atom node or the + * position inside its node-view wrapper (off by one), so both candidates are + * checked and verified by node type. + */ +export function findInlineNodeAtDOM( + editor:AnyEditor, + dom:HTMLElement, + nodeType:string, +):FoundInlineNode | null { + const view = editor.prosemirrorView; + if (!view) return null; + + let basePosition:number; + try { + basePosition = view.posAtDOM(dom, 0); + } catch { + return null; + } + + for (const position of [basePosition, basePosition - 1]) { + if (position < 0) continue; + const node = view.state.doc.nodeAt(position); + if (node?.type.name === nodeType) return { position, node }; + } + return null; +} + +export function inlineNodeAt(editor:AnyEditor, position:number, nodeType:string):ProsemirrorNode | null { + return editor.transact((tr) => { + const node = tr.doc.nodeAt(position); + return node?.type.name === nodeType ? node : null; + }); +} + +export function selectInlineNodeAt(editor:AnyEditor, position:number, nodeType:string):void { + if (!inlineNodeAt(editor, position, nodeType)) return; + editor.transact((tr) => { + tr.setSelection(NodeSelection.create(tr.doc, position)); + }); + hideSafariPhantomSelection(editor); +} + +export function removeInlineNodeAt(editor:AnyEditor, position:number, nodeType:string):void { + const node = inlineNodeAt(editor, position, nodeType); + if (!node) return; + editor.transact((tr) => { + tr.delete(position, position + node.nodeSize); + }); +} diff --git a/extensions/blocknote-extensions/lib/utils/insertInline.ts b/extensions/blocknote-extensions/lib/utils/insertInline.ts new file mode 100644 index 000000000000..9a5da1cb4f8c --- /dev/null +++ b/extensions/blocknote-extensions/lib/utils/insertInline.ts @@ -0,0 +1,11 @@ +import type { AnyEditor } from '../editorTypes'; + +// insertInlineContent is typed against the editor's own schema; callers here hold an AnyEditor. +export function insertInline(editor:AnyEditor, content:unknown[]):void { + (editor.insertInlineContent as (content:unknown[]) => void)(content); +} + +/** Puts back what the suggestion menu removed when its placeholder item is picked. */ +export function restoreTypedQuery(editor:AnyEditor, trigger:string, query:string):void { + insertInline(editor, [{ type: 'text', text: `${trigger}${query}`, styles: {} }]); +} diff --git a/extensions/blocknote-extensions/lib/utils/links.ts b/extensions/blocknote-extensions/lib/utils/links.ts new file mode 100644 index 000000000000..1b0add27c0ac --- /dev/null +++ b/extensions/blocknote-extensions/lib/utils/links.ts @@ -0,0 +1,3 @@ +export function newTabLinkProps(href:string) { + return { href, target: '_blank' as const, rel: 'noopener noreferrer' }; +} diff --git a/extensions/blocknote-extensions/lib/utils/nodeTypes.ts b/extensions/blocknote-extensions/lib/utils/nodeTypes.ts index c9f8dbe1ac56..f0a4c54f8ef4 100644 --- a/extensions/blocknote-extensions/lib/utils/nodeTypes.ts +++ b/extensions/blocknote-extensions/lib/utils/nodeTypes.ts @@ -1,4 +1,6 @@ -export const INLINE_WP_TYPE = 'openProjectWorkPackageInline'; -export const BLOCK_WP_TYPE = 'openProjectWorkPackageBlock'; +export const INLINE_WP_TYPE = 'openProjectWorkPackageInline' as const; +export const BLOCK_WP_TYPE = 'openProjectWorkPackageBlock' as const; export const WORK_PACKAGE_NODE_TYPES:string[] = [INLINE_WP_TYPE, BLOCK_WP_TYPE]; + +export const USER_MENTION_TYPE = 'openProjectUserMention' as const; diff --git a/extensions/blocknote-extensions/lib/utils/suggestionTrigger.ts b/extensions/blocknote-extensions/lib/utils/suggestionTrigger.ts new file mode 100644 index 000000000000..6ec2b51840ee --- /dev/null +++ b/extensions/blocknote-extensions/lib/utils/suggestionTrigger.ts @@ -0,0 +1,12 @@ +import type { Node as ProsemirrorNode, ResolvedPos } from 'prosemirror-model'; + +// An atom node (an inline chip, a mention) reads as one object character, so +// the text on either side of it stays apart. +const leafText = (leaf:ProsemirrorNode) => (leaf.type.name === 'hardBreak' ? '\n' : ''); + +/** Up to `maxLength` characters of the textblock before `position`; null outside a textblock. */ +export function textBefore(position:ResolvedPos, maxLength = Number.POSITIVE_INFINITY):string | null { + if (!position.parent.isTextblock) return null; + const end = position.parentOffset; + return position.parent.textBetween(Math.max(0, end - maxLength), end, undefined, leafText); +} diff --git a/extensions/blocknote-extensions/lib/utils/tapActivation.ts b/extensions/blocknote-extensions/lib/utils/tapActivation.ts index 6b173881b137..01834ec7280e 100644 --- a/extensions/blocknote-extensions/lib/utils/tapActivation.ts +++ b/extensions/blocknote-extensions/lib/utils/tapActivation.ts @@ -27,19 +27,24 @@ export interface TapActivationProps { * * A touch that landed in a React portal (a popover anchored to the element) * bubbles up the React tree to the element it was rendered from, and a nested - * link or button answers for itself - neither is a tap on the element. + * link or button answers for itself - neither is a tap on the element. The + * nested control is skipped on click too, so a link keeps its navigation. */ export function useTapActivation():(activate:TapAction) => TapActivationProps { const startedAt = useRef<{ x:number; y:number } | null>(null); const activatedAt = useRef(Number.NEGATIVE_INFINITY); - const isOwnTouch = (event:ReactTouchEvent) => { + const isNestedControl = (event:ReactTouchEvent | ReactMouseEvent) => { const target = event.target; if (!(target instanceof Element)) return false; - if (!event.currentTarget.contains(target)) return false; - const nested = target.closest('a, button'); - return !nested || nested === event.currentTarget; + return nested !== null && nested !== event.currentTarget && event.currentTarget.contains(nested); + }; + + const isOwnTouch = (event:ReactTouchEvent) => { + const target = event.target; + if (!(target instanceof Element)) return false; + return event.currentTarget.contains(target) && !isNestedControl(event); }; return (activate:TapAction) => ({ @@ -59,6 +64,7 @@ export function useTapActivation():(activate:TapAction) => TapActivationProps { activate(); }, onClick: (event:ReactMouseEvent) => { + if (isNestedControl(event)) return; if (event.timeStamp - activatedAt.current < GHOST_CLICK_WINDOW) return; activate(event); }, diff --git a/extensions/blocknote-extensions/package-lock.json b/extensions/blocknote-extensions/package-lock.json index ff2e64830642..81a1ac75a4d5 100644 --- a/extensions/blocknote-extensions/package-lock.json +++ b/extensions/blocknote-extensions/package-lock.json @@ -48,7 +48,9 @@ "typescript-eslint": "^8.71.0", "vite": "^8.3.1", "vitest": "^4.1.10", - "vitest-browser-react": "^2.3.0" + "vitest-browser-react": "^2.3.0", + "y-prosemirror": "^1.3.7", + "yjs": "^13.6.33" }, "engines": { "node": ">=24" @@ -3685,6 +3687,17 @@ "dev": true, "license": "ISC" }, + "node_modules/isomorphic.js": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", + "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -5977,6 +5990,98 @@ "dev": true, "license": "MIT" }, + "node_modules/y-prosemirror": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/y-prosemirror/-/y-prosemirror-1.3.7.tgz", + "integrity": "sha512-NpM99WSdD4Fx4if5xOMDpPtU3oAmTSjlzh5U4353ABbRHl1HtAFUx6HlebLZfyFxXN9jzKMDkVbcRjqOZVkYQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lib0": "^0.2.109" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "peerDependencies": { + "prosemirror-model": "^1.7.1", + "prosemirror-state": "^1.2.3", + "prosemirror-view": "^1.9.10", + "y-protocols": "^1.0.1", + "yjs": "^13.5.38" + } + }, + "node_modules/y-prosemirror/node_modules/lib0": { + "version": "0.2.119", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.119.tgz", + "integrity": "sha512-vh+TiejVy8Xm+hCdnRfn9BthGNvNUZdEXltNjVZL3TzRIKP3s1lnQh8pWd/Q0wCgY0FPAGVVjFF4ZiHUV3LFgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "isomorphic.js": "^0.2.4" + }, + "bin": { + "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js", + "0gentesthtml": "bin/gentesthtml.js", + "0serve": "bin/0serve.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, + "node_modules/y-protocols": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.7.tgz", + "integrity": "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lib0": "^0.2.85" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "peerDependencies": { + "yjs": "^13.0.0" + } + }, + "node_modules/y-protocols/node_modules/lib0": { + "version": "0.2.119", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.119.tgz", + "integrity": "sha512-vh+TiejVy8Xm+hCdnRfn9BthGNvNUZdEXltNjVZL3TzRIKP3s1lnQh8pWd/Q0wCgY0FPAGVVjFF4ZiHUV3LFgA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "isomorphic.js": "^0.2.4" + }, + "bin": { + "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js", + "0gentesthtml": "bin/gentesthtml.js", + "0serve": "bin/0serve.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -6023,6 +6128,46 @@ "node": ">=12" } }, + "node_modules/yjs": { + "version": "13.6.33", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.33.tgz", + "integrity": "sha512-q/UYvr1gOk4XII+Cu1CW2dINjYqJJFifTzogEaIARznrvX7uOlHsdTEXcnOBMuIsrCGEEKdNxi74kyMLZlKVfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lib0": "^0.2.99" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, + "node_modules/yjs/node_modules/lib0": { + "version": "0.2.119", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.119.tgz", + "integrity": "sha512-vh+TiejVy8Xm+hCdnRfn9BthGNvNUZdEXltNjVZL3TzRIKP3s1lnQh8pWd/Q0wCgY0FPAGVVjFF4ZiHUV3LFgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "isomorphic.js": "^0.2.4" + }, + "bin": { + "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js", + "0gentesthtml": "bin/gentesthtml.js", + "0serve": "bin/0serve.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/extensions/blocknote-extensions/package.json b/extensions/blocknote-extensions/package.json index bc34c1807ab8..80b543fd2439 100644 --- a/extensions/blocknote-extensions/package.json +++ b/extensions/blocknote-extensions/package.json @@ -86,7 +86,9 @@ "typescript-eslint": "^8.71.0", "vite": "^8.3.1", "vitest": "^4.1.10", - "vitest-browser-react": "^2.3.0" + "vitest-browser-react": "^2.3.0", + "y-prosemirror": "^1.3.7", + "yjs": "^13.6.33" }, "peerDependencies": { "@blocknote/core": ">=0.54.2", diff --git a/extensions/blocknote-extensions/src/App.tsx b/extensions/blocknote-extensions/src/App.tsx index 4e3be69b3aff..18d8db74f3ff 100644 --- a/extensions/blocknote-extensions/src/App.tsx +++ b/extensions/blocknote-extensions/src/App.tsx @@ -16,6 +16,9 @@ import { getOpenProjectSlashMenuItems, OpenProjectFormattingToolbar, OpenProjectHashMenu, + OpenProjectMentionMenu, + openProjectUserMentionSpec, + UserMentionNotificationExtension, } from '../lib'; import './fetchOverride'; @@ -25,6 +28,7 @@ const schema = BlockNoteSchema.create().extend({ }, inlineContentSpecs: { openProjectWorkPackageInline: openProjectWorkPackageInlineSpec, + openProjectUserMention: openProjectUserMentionSpec, }, }); @@ -44,7 +48,7 @@ function buildSlashMenuItems(editor:EditorType) { } export default function App() { - const editor = useCreateBlockNote({ schema }); + const editor = useCreateBlockNote({ schema, extensions: [UserMentionNotificationExtension] }); const getSlashItems = useCallback( // eslint-disable-next-line @typescript-eslint/require-await @@ -62,6 +66,7 @@ export default function App() { /> + ); } diff --git a/extensions/blocknote-extensions/test/helpers/content.ts b/extensions/blocknote-extensions/test/helpers/content.ts new file mode 100644 index 000000000000..5d619ee7fdd7 --- /dev/null +++ b/extensions/blocknote-extensions/test/helpers/content.ts @@ -0,0 +1,17 @@ +import { INLINE_WP_TYPE, USER_MENTION_TYPE } from '../../lib/utils/nodeTypes'; + +export function chipContent(wpid:string, size = 's') { + return { type: INLINE_WP_TYPE, props: { wpid, size } }; +} + +export function mentionContent(userId:string, name:string) { + return { type: USER_MENTION_TYPE, props: { userId, name } }; +} + +export function textContent(value:string) { + return { type: 'text', text: value, styles: {} }; +} + +export function paragraphWith(...content:unknown[]) { + return { type: 'paragraph', content }; +} diff --git a/extensions/blocknote-extensions/test/helpers/documentContext.ts b/extensions/blocknote-extensions/test/helpers/documentContext.ts new file mode 100644 index 000000000000..8788007dc619 --- /dev/null +++ b/extensions/blocknote-extensions/test/helpers/documentContext.ts @@ -0,0 +1,10 @@ +import { onTestFinished } from 'vitest'; +import { contextDocumentId, contextProjectId, initEditorContext } from '../../lib/services/editorContext'; + +/** Makes `documentId` the edited document for the rest of the test. */ +export function editDocument(documentId:string | number):void { + const projectId = contextProjectId(); + const previousDocumentId = contextDocumentId(); + initEditorContext({ projectId, documentId }); + onTestFinished(() => initEditorContext({ projectId, documentId: previousDocumentId })); +} diff --git a/extensions/blocknote-extensions/test/helpers/editorHelpers.ts b/extensions/blocknote-extensions/test/helpers/editorHelpers.ts index 06a65ef45239..0ac5cd4d6b0d 100644 --- a/extensions/blocknote-extensions/test/helpers/editorHelpers.ts +++ b/extensions/blocknote-extensions/test/helpers/editorHelpers.ts @@ -19,12 +19,38 @@ export function touchStartElement(element:Element, touch = touchAt(element)) { })); } -export function tapElement(element:Element) { +export function tapElement(element:Element):TouchEvent { const touch = touchAt(element); touchStartElement(element, touch); - element.dispatchEvent(new TouchEvent('touchend', { + const touchEnd = new TouchEvent('touchend', { bubbles: true, cancelable: true, changedTouches: [touch], touches: [], - })); + }); + element.dispatchEvent(touchEnd); + return touchEnd; +} + +export interface RecordedClick { + defaultPrevented:boolean; + href:string | undefined; +} + +// Records each click as the page saw it, then cancels it so a link does not open a tab. +export async function withNavigationPrevented(act:() => Promise):Promise { + const clicks:RecordedClick[] = []; + const recordClick = (event:MouseEvent) => { + clicks.push({ + defaultPrevented: event.defaultPrevented, + href: (event.target as Element).closest('a')?.href, + }); + event.preventDefault(); + }; + window.addEventListener('click', recordClick); + try { + await act(); + } finally { + window.removeEventListener('click', recordClick); + } + return clicks; } // Insert diff --git a/extensions/blocknote-extensions/test/helpers/headlessEditor.ts b/extensions/blocknote-extensions/test/helpers/headlessEditor.ts index bbd5506ff8b1..65c065f57e4f 100644 --- a/extensions/blocknote-extensions/test/helpers/headlessEditor.ts +++ b/extensions/blocknote-extensions/test/helpers/headlessEditor.ts @@ -1,11 +1,16 @@ import { BlockNoteEditor, BlockNoteSchema } from '@blocknote/core'; -import { TextSelection } from 'prosemirror-state'; +import { withCollaboration } from '@blocknote/core/yjs'; +import { NodeSelection, TextSelection } from 'prosemirror-state'; +import { onTestFinished } from 'vitest'; +import * as Y from 'yjs'; import { openProjectWorkPackageBlockSpec, openProjectWorkPackageInlineSpec, + openProjectUserMentionSpec, + UserMentionNotificationExtension, } from '../../lib'; import type { AnyEditor } from '../../lib/editorTypes'; -import { INLINE_WP_TYPE } from '../../lib/utils/nodeTypes'; +import { INLINE_WP_TYPE, USER_MENTION_TYPE } from '../../lib/utils/nodeTypes'; const schema = BlockNoteSchema.create().extend({ blockSpecs: { @@ -13,6 +18,7 @@ const schema = BlockNoteSchema.create().extend({ }, inlineContentSpecs: { openProjectWorkPackageInline: openProjectWorkPackageInlineSpec, + openProjectUserMention: openProjectUserMentionSpec, }, }); @@ -22,8 +28,37 @@ export interface InlineNode { props?:Record; } +const extensions = [UserMentionNotificationExtension]; + export function createHeadlessEditorWithBlocks(blocks:unknown[]) { - return BlockNoteEditor.create({ schema, initialContent: blocks as never }); + return BlockNoteEditor.create({ schema, extensions, initialContent: blocks as never }); +} + +// Plugin views only exist once the editor is mounted. +export function mountHeadlessEditor(editor:Editor):Editor { + editor.mount(document.createElement('div')); + onTestFinished(() => editor.unmount()); + return editor; +} + +// Two editors whose Y.Docs only exchange changes on `sync()`. +export function createCollaborativeHeadlessEditors() { + const yDocuments = [new Y.Doc(), new Y.Doc()]; + const editors = yDocuments.map((yDocument) => { + const editor = BlockNoteEditor.create(withCollaboration({ + schema, + extensions, + collaboration: { fragment: yDocument.getXmlFragment('document-store'), user: { name: 'Collaborator', color: '#000000' } }, + })); + return mountHeadlessEditor(editor); + }); + + const sync = () => { + Y.applyUpdate(yDocuments[1], Y.encodeStateAsUpdate(yDocuments[0])); + Y.applyUpdate(yDocuments[0], Y.encodeStateAsUpdate(yDocuments[1])); + }; + + return { editors, sync }; } export function createHeadlessEditor(content:unknown[] | string) { @@ -36,14 +71,6 @@ export function createHeadlessEditorWithText(text:string) { 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[]; } @@ -56,12 +83,34 @@ export function blockText(editor:AnyEditor, blockIndex = 0):string { return blockContent(editor, blockIndex).map((node) => node.text ?? '').join(''); } +export function blockTextWithNodes(editor:AnyEditor, blockIndex = 0):string { + return blockContent(editor, blockIndex) + .map((node) => (node.type === 'text' ? node.text : `[${node.type}]`)) + .join(''); +} + +export function mentionsIn(editor:AnyEditor):InlineNode['props'][] { + return editor.document + .flatMap((block) => (Array.isArray(block.content) ? block.content as InlineNode[] : [])) + .filter((node) => node.type === USER_MENTION_TYPE) + .map((node) => node.props); +} + +export function selectedNodeType(editor:AnyEditor):string | undefined { + const { selection } = editor.prosemirrorState; + return selection instanceof NodeSelection ? selection.node.type.name : undefined; +} + export function nthChipPosition(editor:AnyEditor, ordinal = 0):number { + return nthInlineNodePosition(editor, INLINE_WP_TYPE, ordinal); +} + +export function nthInlineNodePosition(editor:AnyEditor, nodeType:string, 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) { + if (node.type.name === nodeType) { seen += 1; if (seen === ordinal) { found = position; diff --git a/extensions/blocknote-extensions/test/helpers/renderEditor.tsx b/extensions/blocknote-extensions/test/helpers/renderEditor.tsx index 620ad5977d03..cd70ec0e3beb 100644 --- a/extensions/blocknote-extensions/test/helpers/renderEditor.tsx +++ b/extensions/blocknote-extensions/test/helpers/renderEditor.tsx @@ -9,11 +9,15 @@ import { render } from 'vitest-browser-react'; import { openProjectWorkPackageBlockSpec, openProjectWorkPackageInlineSpec, + openProjectUserMentionSpec, getOpenProjectSlashMenuItems, OpenProjectFormattingToolbar, OpenProjectHashMenu, + OpenProjectMentionMenu, ShadowDomWrapper, + UserMentionNotificationExtension, } from '../../lib'; +import type { AnyEditor } from '../../lib/editorTypes'; import '@blocknote/core/fonts/inter.css'; import '@blocknote/mantine/style.css'; @@ -25,6 +29,7 @@ const defaultSchema = BlockNoteSchema.create().extend({ }, inlineContentSpecs: { openProjectWorkPackageInline: openProjectWorkPackageInlineSpec, + openProjectUserMention: openProjectUserMentionSpec, }, }); @@ -38,6 +43,7 @@ interface EditorOptions { function Editor({ onEditor, schema, editable = true, initialContent }:EditorOptions) { const editor = useCreateBlockNote({ schema: schema ?? defaultSchema, + extensions: [UserMentionNotificationExtension], ...(initialContent ? { initialContent } : {}), }); onEditor?.(editor); @@ -60,6 +66,7 @@ function Editor({ onEditor, schema, editable = true, initialContent }:EditorOpti + ); @@ -69,6 +76,13 @@ export function renderEditor(opts?:EditorOptions) { return render(); } +export async function renderEditorWithHandle(opts?:Omit):Promise { + let editor = undefined as AnyEditor | undefined; + await renderEditor({ ...opts, onEditor: (created) => { editor = created; } }); + if (!editor) throw new Error('The editor was not created'); + return editor; +} + export async function renderEditorInShadowDom(opts?:EditorOptions) { const host = document.createElement('div'); document.body.appendChild(host); diff --git a/extensions/blocknote-extensions/test/helpers/requestHelpers.ts b/extensions/blocknote-extensions/test/helpers/requestHelpers.ts index d29265a4fc38..07034130070b 100644 --- a/extensions/blocknote-extensions/test/helpers/requestHelpers.ts +++ b/extensions/blocknote-extensions/test/helpers/requestHelpers.ts @@ -1,4 +1,7 @@ +import { onTestFinished } from 'vitest'; import { http, HttpResponse } from 'msw'; +import type { AnyEditor } from '../../lib/editorTypes'; +import { mentionContent } from './content'; import { worker } from '../mocks/browser'; import { requestedWorkPackageIds, workPackageCollection, workPackageFor } from '../mocks/handlers'; @@ -20,6 +23,40 @@ export async function requestsDuring(endpoint:string, act:() => Promise):P return asked; } +function mentionNotification(documentId:number, userId:number) { + return { + reason: 'mentioned', + _links: { + resource: { href: `/api/v3/documents/${documentId}` }, + recipient: { href: `/api/v3/users/${userId}` }, + }, + }; +} + +export function recordMentionNotifications():() => Promise { + const bodies:Promise[] = []; + const record = ({ request }:{ request:Request }) => { + if (request.method === 'POST' && request.url.endsWith('/api/v3/notifications')) bodies.push(request.clone().json()); + }; + + worker.events.on('request:start', record); + onTestFinished(() => worker.events.removeListener('request:start', record)); + + return () => Promise.all(bodies); +} + +const FENCE_USER_ID = 7; + +/** Mentions one more user: once its notification arrives, a late duplicate sent before it would have too. */ +export function fenceMentionNotifications( + editor:AnyEditor, + { documentId, userIds }:{ documentId:number; userIds:number[] } +):ReturnType[] { + editor.insertInlineContent([mentionContent(String(FENCE_USER_ID), 'Anna Kovalenko')]); + + return [...userIds, FENCE_USER_ID].map((userId) => mentionNotification(documentId, userId)); +} + /** Leaves the given work packages out of every batch load, as the API does for invisible ones. */ export function loadUnavailableWorkPackages(...hidden:number[]):void { worker.use( diff --git a/extensions/blocknote-extensions/test/lib/components/HashMenu/editorUtils.test.ts b/extensions/blocknote-extensions/test/lib/components/HashMenu/editorUtils.test.ts index 59594e7fbf54..6bc914fcb044 100644 --- a/extensions/blocknote-extensions/test/lib/components/HashMenu/editorUtils.test.ts +++ b/extensions/blocknote-extensions/test/lib/components/HashMenu/editorUtils.test.ts @@ -13,13 +13,12 @@ import { blockContent, blockText, blockTypes, - chipContent, createHeadlessEditor, createHeadlessEditorWithText, nthChipPosition, placeCursorAfterText, - textContent, } from '../../../helpers/headlessEditor'; +import { chipContent, textContent } from '../../../helpers/content'; const workPackage = { id: 1, displayId: '1', subject: 'Fix bug' } as unknown as WorkPackage; diff --git a/extensions/blocknote-extensions/test/lib/components/HashMenu/hashTrigger.test.ts b/extensions/blocknote-extensions/test/lib/components/HashMenu/hashTrigger.test.ts index b42ef836d9c0..6c7b6957869f 100644 --- a/extensions/blocknote-extensions/test/lib/components/HashMenu/hashTrigger.test.ts +++ b/extensions/blocknote-extensions/test/lib/components/HashMenu/hashTrigger.test.ts @@ -7,11 +7,10 @@ import { } from '../../../../lib/components/HashMenu/hashTrigger'; import type { HashTarget } from '../../../../lib/components/HashMenu/hashTrigger'; import { - chipContent, createHeadlessEditor, createHeadlessEditorWithText, - textContent, } from '../../../helpers/headlessEditor'; +import { chipContent, textContent } from '../../../helpers/content'; function targetFor(blockText:string, query:string):HashTarget { return hashTargetFor(createHeadlessEditorWithText(blockText), query); diff --git a/extensions/blocknote-extensions/test/lib/components/MentionMenu/mentionTrigger.test.ts b/extensions/blocknote-extensions/test/lib/components/MentionMenu/mentionTrigger.test.ts new file mode 100644 index 000000000000..ee95cc9e1b7e --- /dev/null +++ b/extensions/blocknote-extensions/test/lib/components/MentionMenu/mentionTrigger.test.ts @@ -0,0 +1,42 @@ +// @vitest-environment jsdom +import { describe, expect, it } from 'vitest'; +import { canOpenMentionMenu } from '../../../../lib/components/MentionMenu/mentionTrigger'; +import { + createHeadlessEditor, + createHeadlessEditorWithText, +} from '../../../helpers/headlessEditor'; +import { chipContent, mentionContent, textContent } from '../../../helpers/content'; + +function opensAt(editor:ReturnType):boolean { + return editor.transact((transaction) => canOpenMentionMenu(transaction)); +} + +function opensAfter(text:string):boolean { + return opensAt(createHeadlessEditorWithText(text)); +} + +function opensAfterContent(...content:unknown[]):boolean { + const editor = createHeadlessEditor(content); + editor.setTextCursorPosition(editor.document[0], 'end'); + return opensAt(editor); +} + +describe('canOpenMentionMenu', () => { + it('opens at the start of a block', () => expect(opensAfter('')).toBe(true)); + it('opens after a space', () => expect(opensAfter('Thanks ')).toBe(true)); + it('opens after an opening bracket or quote', () => { + expect(opensAfter('(')).toBe(true); + expect(opensAfter('"')).toBe(true); + }); + it('opens after a line break', () => { + const editor = createHeadlessEditorWithText('line\n'); + expect(editor.prosemirrorState.selection.$from.nodeBefore?.type.name).toBe('hardBreak'); + expect(opensAt(editor)).toBe(true); + }); + it('stays closed inside a word, e.g. an email address', () => expect(opensAfter('ihor')).toBe(false)); + it('stays closed after punctuation that does not open a phrase', () => expect(opensAfter('a.')).toBe(false)); + it('stays closed directly after an inline node', () => { + expect(opensAfterContent(textContent('Hi '), mentionContent('7', 'Anna Kovalenko'))).toBe(false); + expect(opensAfterContent(textContent('See '), chipContent('42'))).toBe(false); + }); +}); diff --git a/extensions/blocknote-extensions/test/lib/components/UserMention/externalHtml.test.ts b/extensions/blocknote-extensions/test/lib/components/UserMention/externalHtml.test.ts new file mode 100644 index 000000000000..a89e3701597b --- /dev/null +++ b/extensions/blocknote-extensions/test/lib/components/UserMention/externalHtml.test.ts @@ -0,0 +1,64 @@ +// @vitest-environment jsdom +import { describe, expect, it } from 'vitest'; +import { + buildUserMentionStoredDOM, + userMentionTag, + parseUserMentionExternalHTML, +} from '../../../../lib/components/UserMention/externalHtml'; + +const judith = { userId: '5', name: 'Judith Roth' }; + +describe('user mention external HTML', () => { + it('builds the canonical tag', () => { + expect(userMentionTag(judith)) + .toBe('@Judith Roth'); + }); + + it('wraps the tag as text for the stored markdown', () => { + const wrapper = buildUserMentionStoredDOM(judith, document); + + expect(wrapper.children).toHaveLength(0); + expect(wrapper.textContent).toBe(userMentionTag(judith)); + }); + + it('parses its own wrapper back', () => { + expect(parseUserMentionExternalHTML(buildUserMentionStoredDOM(judith, document))) + .toEqual(judith); + }); + + it('parses a mention copied from a CKEditor field', () => { + const host = document.createElement('div'); + host.innerHTML = userMentionTag(judith); + + expect(parseUserMentionExternalHTML(host.firstElementChild as HTMLElement)).toEqual(judith); + }); + + it('reads back a name with quotes, ampersands and angle brackets from its own tag', () => { + const ann = { userId: '9', name: 'Ann "A&B" ' }; + const host = document.createElement('div'); + host.innerHTML = userMentionTag(ann); + + expect(parseUserMentionExternalHTML(host.firstElementChild as HTMLElement)).toEqual(ann); + }); + + it('leaves a type-only wrapper to the mention inside it', () => { + const wrapper = document.createElement('span'); + wrapper.setAttribute('data-inline-content-type', 'openProjectUserMention'); + + expect(parseUserMentionExternalHTML(wrapper)).toBeUndefined(); + }); + + it('ignores group and work package mentions', () => { + const host = document.createElement('div'); + host.innerHTML = '@Team'; + + expect(parseUserMentionExternalHTML(host.firstElementChild as HTMLElement)).toBeUndefined(); + }); + + it('ignores a user mention without an id', () => { + const host = document.createElement('div'); + host.innerHTML = '@Judith Roth'; + + expect(parseUserMentionExternalHTML(host.firstElementChild as HTMLElement)).toBeUndefined(); + }); +}); diff --git a/extensions/blocknote-extensions/test/lib/components/UserMention/markdown.test.ts b/extensions/blocknote-extensions/test/lib/components/UserMention/markdown.test.ts new file mode 100644 index 000000000000..44af074a26ea --- /dev/null +++ b/extensions/blocknote-extensions/test/lib/components/UserMention/markdown.test.ts @@ -0,0 +1,41 @@ +// @vitest-environment jsdom +import { describe, it, expect } from 'vitest'; +import { BlockNoteEditor, BlockNoteSchema } from '@blocknote/core'; +import { openProjectUserMentionStaticSpec } from '../../../../lib/server'; +import { mentionContent, paragraphWith } from '../../../helpers/content'; + +// Not the shared headless editor: that one uses the React specs, while the stored markdown comes from the server-side static spec. +const schema = BlockNoteSchema.create().extend({ + inlineContentSpecs: { openProjectUserMention: openProjectUserMentionStaticSpec }, +}); + +async function markdownOf(blocks:unknown[]) { + const editor = BlockNoteEditor.create({ schema, initialContent: blocks as never }); + return (await editor.blocksToMarkdownLossy()).trim(); +} + +const judith = '@Judith Roth'; + +describe('user mention markdown export', () => { + it('writes the OpenProject mention tag into a paragraph', async () => { + expect(await markdownOf([paragraphWith('Thanks ', mentionContent('5', 'Judith Roth'), ' for the review')])) + .toBe(`Thanks ${judith} for the review`); + }); + + it('keeps the tag in a list item and after bold text', async () => { + expect(await markdownOf([ + { type: 'bulletListItem', content: [mentionContent('5', 'Judith Roth')] }, + paragraphWith({ type: 'text', text: 'Owner ', styles: { bold: true } }, mentionContent('5', 'Judith Roth')), + ])).toBe(`* ${judith}\n\n**Owner** ${judith}`); + }); + + it('escapes a name so OpenProject reads the same name back', async () => { + expect(await markdownOf([paragraphWith(mentionContent('9', 'Ann "A&B" '))])) + .toBe('@Ann "A&B" <Lee>'); + }); + + it('writes nothing for a mention without a user', async () => { + expect(await markdownOf([paragraphWith('Hi ', mentionContent('', ''), 'there')])) + .toBe('Hi there'); + }); +}); diff --git a/extensions/blocknote-extensions/test/lib/components/integration/editor.drag.browser.test.tsx b/extensions/blocknote-extensions/test/lib/components/integration/editor.drag.browser.test.tsx index d500d65686d7..0f0b92a09181 100644 --- a/extensions/blocknote-extensions/test/lib/components/integration/editor.drag.browser.test.tsx +++ b/extensions/blocknote-extensions/test/lib/components/integration/editor.drag.browser.test.tsx @@ -1,9 +1,16 @@ import { describe, it, expect, afterEach } from 'vitest'; import { page, userEvent } from 'vitest/browser'; -import { renderEditor } from '../../../helpers/renderEditor'; +import { renderEditor, renderEditorWithHandle } from '../../../helpers/renderEditor'; +import { mentionContent, paragraphWith } from '../../../helpers/content'; +import { mentionsIn } from '../../../helpers/headlessEditor'; import { insertInlineWorkPackageViaSlashMenu, convertToCompactCard } from '../../../helpers/editorHelpers'; import { worker } from '../../../mocks/browser'; -import { requestsDuring } from '../../../helpers/requestHelpers'; +import { editDocument } from '../../../helpers/documentContext'; +import { + fenceMentionNotifications, + recordMentionNotifications, + requestsDuring, +} from '../../../helpers/requestHelpers'; describe('Drag and drop - inline chip', () => { afterEach(() => worker.resetHandlers()); @@ -122,3 +129,60 @@ describe('Drag and drop - block card', () => { expect(requests).toHaveLength(0); }); }); + +describe('Drag and drop - user mention', () => { + const peter = mentionContent('8', 'Peter Lang'); + + async function renderMentionBetweenLines() { + const editor = await renderEditorWithHandle({ + initialContent: [paragraphWith('First line'), paragraphWith(peter), paragraphWith('Last line')], + }); + await expect.element(page.getByRole('link', { name: 'Peter Lang' })).toBeVisible(); + return editor; + } + + function blockTextOfMention() { + return document.querySelector('.op-bn-user-mention')!.closest('[data-node-type="blockOuter"]')?.textContent; + } + + it('moves the mention node when dragged by the pill', async () => { + const editor = await renderMentionBetweenLines(); + + await userEvent.dragAndDrop(document.querySelector('.op-bn-user-mention')!, page.getByText('Last line')); + + await expect.poll(blockTextOfMention).toContain('Last'); + expect(mentionsIn(editor)).toEqual([peter.props]); + }); + + it('moves the mention node, not a copy of its link, when dragged by the name', async () => { + const editor = await renderMentionBetweenLines(); + + await userEvent.dragAndDrop(page.getByRole('link', { name: 'Peter Lang' }), page.getByText('Last line')); + + await expect.poll(blockTextOfMention).toContain('Last'); + expect(mentionsIn(editor)).toEqual([peter.props]); + expect(editor.document.flatMap((block) => block.content as { type:string }[]).map((node) => node.type)) + .not.toContain('link'); + }); + + it('can be dragged again after a drop', async () => { + const editor = await renderMentionBetweenLines(); + + await userEvent.dragAndDrop(document.querySelector('.op-bn-user-mention')!, page.getByText('Last line')); + await expect.poll(blockTextOfMention).toContain('Last'); + await userEvent.dragAndDrop(document.querySelector('.op-bn-user-mention')!, page.getByText('First line')); + + await expect.poll(blockTextOfMention).toContain('First'); + expect(mentionsIn(editor)).toEqual([peter.props]); + }); + + it('does not notify the person of a dragged mention', async () => { + editDocument(3); + const notifications = recordMentionNotifications(); + const editor = await renderMentionBetweenLines(); + + await userEvent.dragAndDrop(document.querySelector('.op-bn-user-mention')!, page.getByText('Last line')); + await expect.poll(blockTextOfMention).toContain('Last'); + await expect.poll(notifications).toEqual(fenceMentionNotifications(editor, { documentId: 3, userIds: [] })); + }); +}); diff --git a/extensions/blocknote-extensions/test/lib/components/integration/inlineNodeOptionsShadowDom.browser.test.tsx b/extensions/blocknote-extensions/test/lib/components/integration/inlineNodeOptionsShadowDom.browser.test.tsx new file mode 100644 index 000000000000..33b9da1862bd --- /dev/null +++ b/extensions/blocknote-extensions/test/lib/components/integration/inlineNodeOptionsShadowDom.browser.test.tsx @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; +import { page, userEvent } from 'vitest/browser'; +import type { Locator } from 'vitest/browser'; +import { renderEditorInShadowDom } from '../../../helpers/renderEditor'; +import { chipContent, mentionContent, paragraphWith } from '../../../helpers/content'; + +// Inside a shadow root a document listener sees every press retargeted to the +// host, so these guard that a press on the node itself is not taken as outside. + +const popover = () => page.getByTestId('popover-content'); + +describe('User mention options inside a shadow root', () => { + async function renderMention() { + await renderEditorInShadowDom({ + initialContent: [ + paragraphWith('Thanks ', mentionContent('7', 'Anna Kovalenko'), ' for the review'), + paragraphWith('Somewhere else'), + ], + }); + const pill = page.getByRole('button', { name: 'User Anna Kovalenko' }); + await expect.element(pill).toBeVisible(); + return { pill, avatar: pill.getByTestId('avatar') }; + } + + async function openOptions(avatar:Locator) { + await userEvent.click(avatar); + await expect.element(popover()).toBeVisible(); + } + + it('opens the popover when the pill is clicked', async () => { + const { avatar } = await renderMention(); + + await openOptions(avatar); + + await expect.element(page.getByTestId('remove-btn')).toBeVisible(); + }); + + it('closes the popover when the pill is clicked again', async () => { + const { avatar } = await renderMention(); + await openOptions(avatar); + + await userEvent.click(avatar); + + await expect.element(popover()).not.toBeInTheDocument(); + }); + + it('removes the mention from the Remove button', async () => { + const { pill, avatar } = await renderMention(); + await openOptions(avatar); + + await userEvent.click(page.getByTestId('remove-btn')); + + await expect.element(pill).not.toBeInTheDocument(); + await expect.element(popover()).not.toBeInTheDocument(); + }); + + it('closes the popover on a click outside the pill', async () => { + const { avatar } = await renderMention(); + await openOptions(avatar); + + await userEvent.click(page.getByText('Somewhere else')); + + await expect.element(popover()).not.toBeInTheDocument(); + }); +}); + +describe('Inline work package options inside a shadow root', () => { + it('closes the popover when the chip is clicked again', async () => { + await renderEditorInShadowDom({ initialContent: [paragraphWith('See ', chipContent('123'))] }); + const chip = page.getByText('#123', { exact: true }); + await expect.element(chip).toBeVisible(); + + await userEvent.click(chip); + await expect.element(popover()).toBeVisible(); + + await userEvent.click(chip); + + await expect.element(popover()).not.toBeInTheDocument(); + }); +}); diff --git a/extensions/blocknote-extensions/test/lib/components/integration/userMention.browser.test.tsx b/extensions/blocknote-extensions/test/lib/components/integration/userMention.browser.test.tsx new file mode 100644 index 000000000000..90272e881e92 --- /dev/null +++ b/extensions/blocknote-extensions/test/lib/components/integration/userMention.browser.test.tsx @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { page, userEvent } from 'vitest/browser'; +import { renderEditor, renderEditorWithHandle } from '../../../helpers/renderEditor'; +import { mentionContent, paragraphWith } from '../../../helpers/content'; +import { mentionsIn } from '../../../helpers/headlessEditor'; +import { withNavigationPrevented } from '../../../helpers/editorHelpers'; +import { editDocument } from '../../../helpers/documentContext'; +import { fenceMentionNotifications, recordMentionNotifications } from '../../../helpers/requestHelpers'; + +const anna = mentionContent('7', 'Anna Kovalenko'); + +describe('User mention', () => { + it('shows the avatar and the name, linking to the user in a new tab', async () => { + renderEditor({ initialContent: [paragraphWith('Thanks ', anna)] }); + + const link = page.getByRole('link', { name: 'Anna Kovalenko' }); + await expect.element(link).toHaveAttribute('href', 'http://localhost:3000/users/7'); + await expect.element(link).toHaveAttribute('target', '_blank'); + await expect.element(link).toHaveAttribute('rel', 'noopener noreferrer'); + }); + + it('lets the browser follow the name link on click', async () => { + renderEditor({ initialContent: [paragraphWith('Thanks ', anna)] }); + const link = page.getByRole('link', { name: 'Anna Kovalenko' }); + await expect.element(link).toBeVisible(); + + const clicks = await withNavigationPrevented(() => userEvent.click(link)); + + expect(clicks).toEqual([{ defaultPrevented: false, href: 'http://localhost:3000/users/7' }]); + }); + + it('serialises to a mention element and parses it back', async () => { + const editor = await renderEditorWithHandle({ initialContent: [paragraphWith(anna)] }); + await expect.element(page.getByRole('link', { name: 'Anna Kovalenko' })).toBeVisible(); + + const html = await editor.blocksToHTMLLossy(editor.document); + expect(html).toContain('@Anna Kovalenko'); + + editor.setTextCursorPosition(editor.document[0], 'end'); + editor.pasteHTML(html); + expect(mentionsIn(editor)).toEqual([anna.props, anna.props]); + }); + + it('pastes a CKEditor mention as a mention', async () => { + const editor = await renderEditorWithHandle({ initialContent: [paragraphWith('Hello')] }); + await expect.element(page.getByText('Hello')).toBeVisible(); + + editor.setTextCursorPosition(editor.document[0], 'end'); + editor.pasteHTML('

@Peter Lang

'); + expect(mentionsIn(editor)).toEqual([{ userId: '8', name: 'Peter Lang' }]); + }); + + it('notifies a person pasted twice once', async () => { + editDocument(3); + const notifications = recordMentionNotifications(); + const editor = await renderEditorWithHandle({ initialContent: [paragraphWith('Hello')] }); + await expect.element(page.getByText('Hello')).toBeVisible(); + + const peterTag = '@Peter Lang'; + editor.setTextCursorPosition(editor.document[0], 'end'); + editor.pasteHTML(`

${peterTag} and ${peterTag}

`); + + await expect.poll(notifications).toEqual(fenceMentionNotifications(editor, { documentId: 3, userIds: [8] })); + }); +}); diff --git a/extensions/blocknote-extensions/test/lib/components/integration/userMentionGeometry.browser.test.tsx b/extensions/blocknote-extensions/test/lib/components/integration/userMentionGeometry.browser.test.tsx new file mode 100644 index 000000000000..f3ff232a427f --- /dev/null +++ b/extensions/blocknote-extensions/test/lib/components/integration/userMentionGeometry.browser.test.tsx @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest'; +import { page } from 'vitest/browser'; +import { renderEditor } from '../../../helpers/renderEditor'; +import { mentionContent, paragraphWith } from '../../../helpers/content'; + +function mentionParts(name:string) { + const link = page.getByRole('link', { name }).element(); + const pill = link.closest('.op-bn-user-mention'); + const avatar = pill?.querySelector('.op-bn-avatar'); + if (!pill || !avatar) throw new Error(`mention of ${name} did not render`); + + const pillBox = pill.getBoundingClientRect(); + const avatarBox = avatar.getBoundingClientRect(); + return { + above: pillBox.top - avatarBox.top, + below: avatarBox.bottom - pillBox.bottom, + offset: avatarBox.top - pillBox.top, + gap: link.getBoundingClientRect().left - avatarBox.right, + }; +} + +describe('User mention - the avatar sits inside the pill', () => { + it('keeps picture and initials avatars at the same height, inside the pill, 4px from the name', async () => { + renderEditor({ + initialContent: [paragraphWith(mentionContent('7', 'Anna Kovalenko'), ' and ', mentionContent('8', 'Peter Lang'))], + }); + + await expect.element(page.getByRole('link', { name: 'Peter Lang' })).toBeVisible(); + const annaPicture = page.getByRole('button', { name: 'User Anna Kovalenko' }).element().querySelector('img')!; + await expect.element(annaPicture).toBeVisible(); + await document.fonts.load('500 12px Inter'); + await document.fonts.load('600 7px Inter'); + expect(document.fonts.check('500 12px Inter')).toBe(true); + + const picture = mentionParts('Anna Kovalenko'); + const initials = mentionParts('Peter Lang'); + + for (const [label, parts] of [['picture', picture], ['initials', initials]] as const) { + expect.soft(parts.above, `${label} avatar sticks ${parts.above.toFixed(2)}px above the pill`).toBeLessThanOrEqual(0.5); + expect.soft(parts.below, `${label} avatar hangs ${parts.below.toFixed(2)}px below the pill`).toBeLessThanOrEqual(0.5); + expect.soft(Math.abs(parts.gap - 4), `${label} avatar is ${parts.gap.toFixed(2)}px from the name`).toBeLessThanOrEqual(0.5); + } + expect.soft( + Math.abs(picture.offset - initials.offset), + `picture avatar at ${picture.offset.toFixed(2)}px, initials at ${initials.offset.toFixed(2)}px`, + ).toBeLessThanOrEqual(0.5); + }); +}); diff --git a/extensions/blocknote-extensions/test/lib/components/integration/userMentionMenu.browser.test.tsx b/extensions/blocknote-extensions/test/lib/components/integration/userMentionMenu.browser.test.tsx new file mode 100644 index 000000000000..00bcbec6e913 --- /dev/null +++ b/extensions/blocknote-extensions/test/lib/components/integration/userMentionMenu.browser.test.tsx @@ -0,0 +1,96 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { page, userEvent } from 'vitest/browser'; +import { http, HttpResponse } from 'msw'; +import { worker } from '../../../mocks/browser'; +import { renderEditor, renderEditorWithHandle } from '../../../helpers/renderEditor'; +import { openEditorAndType } from '../../../helpers/editorHelpers'; +import { mentionContent, textContent } from '../../../helpers/content'; +import { blockContent } from '../../../helpers/headlessEditor'; +import { editDocument } from '../../../helpers/documentContext'; +import { fenceMentionNotifications, recordMentionNotifications } from '../../../helpers/requestHelpers'; + +const mentionMenu = () => page.getByTestId('mention-menu'); + +describe('@ mention menu', () => { + afterEach(() => worker.resetHandlers()); + + it('lists people as soon as @ is typed', async () => { + renderEditor(); + await openEditorAndType('Hi @'); + + await expect.element(mentionMenu().getByText('Anna Kovalenko')).toBeVisible(); + await expect.element(mentionMenu().getByText('Peter Lang')).toBeVisible(); + }); + + it('replaces the query with the picked person followed by a space', async () => { + const editor = await renderEditorWithHandle(); + await openEditorAndType('Hi @pet'); + await userEvent.click(mentionMenu().getByText('Peter Lang')); + await userEvent.keyboard('there'); + + await expect.poll(() => blockContent(editor)).toEqual([ + textContent('Hi '), + mentionContent('8', 'Peter Lang'), + textContent(' there'), + ]); + }); + + it('notifies the picked person once', async () => { + editDocument(3); + const notifications = recordMentionNotifications(); + const editor = await renderEditorWithHandle(); + await openEditorAndType('Hi @pet'); + await userEvent.click(mentionMenu().getByText('Peter Lang')); + await userEvent.keyboard('there'); + + await expect.poll(notifications).toEqual(fenceMentionNotifications(editor, { documentId: 3, userIds: [8] })); + }); + + it('picks with the keyboard', async () => { + renderEditor(); + await openEditorAndType('@'); + await expect.element(mentionMenu().getByText('Peter Lang')).toBeVisible(); + await userEvent.keyboard('{ArrowDown}{Enter}'); + + await expect.element(page.getByRole('textbox').getByRole('link', { name: 'Peter Lang' })).toBeVisible(); + }); + + it('keeps the typed text when Enter is pressed and nobody matches', async () => { + renderEditor(); + await openEditorAndType('@zzz'); + await expect.element(mentionMenu()).toHaveTextContent('No results'); + await userEvent.keyboard('{Enter}'); + + await expect.element(page.getByRole('textbox')).toHaveTextContent(/^@zzz$/); + await expect.element(mentionMenu()).not.toBeInTheDocument(); + }); + + it('closes on Escape and keeps the typed query', async () => { + renderEditor(); + await openEditorAndType('@pet'); + await expect.element(mentionMenu().getByText('Peter Lang')).toBeVisible(); + + await userEvent.keyboard('{Escape}'); + + await expect.element(mentionMenu()).not.toBeInTheDocument(); + await expect.element(page.getByRole('textbox')).toHaveTextContent(/^@pet$/); + }); + + it('does not open inside an email address', async () => { + renderEditor(); + await openEditorAndType('ihor@op'); + await expect.element(page.getByRole('textbox')).toHaveTextContent(/^ihor@op$/); + await expect.element(mentionMenu()).not.toBeInTheDocument(); + + await userEvent.keyboard(' @'); + await expect.element(mentionMenu()).toBeVisible(); + }); + + it('says so when the search fails', async () => { + worker.use(http.get('http://localhost:3000/api/v3/principals', () => new HttpResponse(null, { status: 500 }))); + renderEditor(); + await openEditorAndType('@a'); + + await expect.element(mentionMenu()).toHaveTextContent('Error. Unable to load content.'); + }); +}); diff --git a/extensions/blocknote-extensions/test/lib/components/integration/userMentionOptions.browser.test.tsx b/extensions/blocknote-extensions/test/lib/components/integration/userMentionOptions.browser.test.tsx new file mode 100644 index 000000000000..13170adcc88c --- /dev/null +++ b/extensions/blocknote-extensions/test/lib/components/integration/userMentionOptions.browser.test.tsx @@ -0,0 +1,143 @@ +import { describe, expect, it, vi } from 'vitest'; +import { page, userEvent } from 'vitest/browser'; +import type { Locator } from 'vitest/browser'; +import { renderEditorWithHandle } from '../../../helpers/renderEditor'; +import { mentionContent, paragraphWith } from '../../../helpers/content'; +import { blockTextWithNodes, selectedNodeType } from '../../../helpers/headlessEditor'; +import { tapElement, withNavigationPrevented } from '../../../helpers/editorHelpers'; +import { USER_MENTION_TYPE } from '../../../../lib/utils/nodeTypes'; + +const anna = { userId: '7', name: 'Anna Kovalenko' }; +const peter = { userId: '8', name: 'Peter Lang' }; +const OPEN_LABEL = "Open Anna Kovalenko's profile in new tab"; + +const popover = () => page.getByTestId('popover-content'); + +async function renderMention({ person = anna, textAfter = ' for the review' } = {}) { + const editor = await renderEditorWithHandle({ + initialContent: [ + paragraphWith('Thanks ', mentionContent(person.userId, person.name), ...(textAfter ? [textAfter] : [])), + paragraphWith('Somewhere else'), + ], + }); + const link = page.getByRole('link', { name: person.name }); + await expect.element(link).toBeVisible(); + const pill = page.getByRole('button', { name: `User ${person.name}` }); + return { editor, link, pill, avatar: pill.getByTestId('avatar') }; +} + +async function openOptions(avatar:Locator) { + await userEvent.click(avatar); + await expect.element(popover()).toBeVisible(); +} + +describe('User mention options', () => { + it('selects the mention and shows Open and Remove when the pill is clicked', async () => { + const { editor, avatar } = await renderMention(); + await expect.element(avatar.element().querySelector('img')!).toBeVisible(); + + await openOptions(avatar); + + await expect.element(page.getByRole('button', { name: OPEN_LABEL })).toBeVisible(); + await expect.element(page.getByTestId('remove-btn')).toBeVisible(); + expect(selectedNodeType(editor)).toBe(USER_MENTION_TYPE); + }); + + it('opens the popover from the initials of a user without a picture', async () => { + const { avatar } = await renderMention({ person: peter }); + await expect.element(avatar).toHaveTextContent('PL'); + + await openOptions(avatar); + }); + + it('closes the popover when the pill is clicked again', async () => { + const { avatar } = await renderMention(); + await openOptions(avatar); + + await userEvent.click(avatar); + + await expect.element(popover()).not.toBeInTheDocument(); + }); + + it('removes the selected mention with Backspace', async () => { + const { editor, avatar } = await renderMention(); + await openOptions(avatar); + + await userEvent.keyboard('{Backspace}'); + + await expect.poll(() => blockTextWithNodes(editor)).toBe('Thanks for the review'); + }); + + it('removes the selected mention with Delete', async () => { + const { editor, avatar } = await renderMention(); + await openOptions(avatar); + + await userEvent.keyboard('{Delete}'); + + await expect.poll(() => blockTextWithNodes(editor)).toBe('Thanks for the review'); + }); + + it('removes the mention with Backspace from the caret right after it', async () => { + const { editor } = await renderMention({ textAfter: '' }); + editor.focus(); + editor.setTextCursorPosition(editor.document[0], 'end'); + expect(selectedNodeType(editor)).toBeUndefined(); + + await userEvent.keyboard('{Backspace}'); + + await expect.poll(() => blockTextWithNodes(editor)).toBe('Thanks '); + }); + + it('removes the mention and closes the popover from the Remove button', async () => { + const { editor, avatar } = await renderMention(); + await openOptions(avatar); + + await userEvent.click(page.getByTestId('remove-btn')); + + await expect.poll(() => blockTextWithNodes(editor)).toBe('Thanks for the review'); + await expect.element(popover()).not.toBeInTheDocument(); + }); + + it('opens the profile in a new tab from the Open button', async () => { + const openSpy = vi.spyOn(window, 'open').mockReturnValue(null); + try { + const { avatar } = await renderMention(); + await openOptions(avatar); + + await userEvent.click(page.getByRole('button', { name: OPEN_LABEL })); + + expect(openSpy).toHaveBeenCalledWith('http://localhost:3000/users/7', '_blank', 'noopener,noreferrer'); + } finally { + openSpy.mockRestore(); + } + }); + + it('closes the popover on a click outside the pill', async () => { + const { avatar } = await renderMention(); + await openOptions(avatar); + + await userEvent.click(page.getByText('Somewhere else')); + + await expect.element(popover()).not.toBeInTheDocument(); + }); + + it('does not open the popover when the name link is clicked', async () => { + const { link } = await renderMention(); + + const clicks = await withNavigationPrevented(() => userEvent.click(link)); + + expect(clicks).toEqual([{ defaultPrevented: false, href: 'http://localhost:3000/users/7' }]); + await expect.element(popover()).not.toBeInTheDocument(); + }); + + it('opens the popover from one tap on the pill, but not from a tap on the name', async () => { + const { link, avatar } = await renderMention(); + + const nameTap = tapElement(link.element()); + expect(nameTap.defaultPrevented).toBe(false); + await expect.element(popover()).not.toBeInTheDocument(); + + tapElement(avatar.element()); + await expect.element(popover()).toBeVisible(); + }); +}); diff --git a/extensions/blocknote-extensions/test/lib/components/shared/Avatar/avatar.browser.test.tsx b/extensions/blocknote-extensions/test/lib/components/shared/Avatar/avatar.browser.test.tsx new file mode 100644 index 000000000000..107659cd6284 --- /dev/null +++ b/extensions/blocknote-extensions/test/lib/components/shared/Avatar/avatar.browser.test.tsx @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { render } from 'vitest-browser-react'; +import { Avatar } from '../../../../../lib/components/shared/Avatar'; + +async function renderAvatar(userId:number, name:string) { + const { container } = await render(); + const avatar = container.querySelector('.op-bn-avatar')!; + return { avatar, picture: avatar.querySelector('img')! }; +} + +describe('Avatar', () => { + it('shows the uploaded picture instead of the initials once it has loaded', async () => { + const { avatar, picture } = await renderAvatar(7, 'Anna Kovalenko'); + + await expect.element(picture).toBeVisible(); + await expect.element(avatar).not.toHaveTextContent('AK'); + }); + + it('keeps the initials when the user has no avatar', async () => { + const { avatar, picture } = await renderAvatar(8, 'Peter Lang'); + + await expect.poll(() => picture.complete).toBe(true); + expect(picture.naturalWidth).toBe(0); + + await expect.element(avatar).toHaveTextContent('PL'); + await expect.element(picture).not.toBeVisible(); + }); + + it('hides the avatar from screen readers', async () => { + const { avatar } = await renderAvatar(8, 'Peter Lang'); + + expect(avatar.getAttribute('aria-hidden')).toBe('true'); + expect(avatar.hasAttribute('role')).toBe(false); + expect(avatar.hasAttribute('aria-label')).toBe(false); + expect(avatar.hasAttribute('title')).toBe(false); + }); +}); diff --git a/extensions/blocknote-extensions/test/lib/components/shared/Avatar/avatars.test.ts b/extensions/blocknote-extensions/test/lib/components/shared/Avatar/avatars.test.ts new file mode 100644 index 000000000000..1a46a3df4d30 --- /dev/null +++ b/extensions/blocknote-extensions/test/lib/components/shared/Avatar/avatars.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; +import { avatarColorOf, initialsOf } from '../../../../../lib/components/shared/Avatar/avatars'; + +describe('initialsOf', () => { + it('takes the first letters of the first and the last name', () => { + expect(initialsOf('Mira Hofmann')).toBe('MH'); + expect(initialsOf('jean de la cruz')).toBe('JC'); + }); + + it('takes one letter of a single name', () => { + expect(initialsOf('admin')).toBe('A'); + }); + + it('keeps an emoji whole', () => { + expect(initialsOf('🦊 Fox')).toBe('🦊F'); + }); +}); + +describe('avatarColorOf', () => { + it('gives the color OpenProject gives the same person', () => { + expect(avatarColorOf({ name: 'Mira Hofmann', href: '/api/v3/users/3' })).toBe('hsl(-183, 50%, 30%)'); + }); + + it('tells two people of the same name apart', () => { + expect(avatarColorOf({ name: 'Mira Hofmann', href: '/api/v3/users/4' })) + .not.toBe(avatarColorOf({ name: 'Mira Hofmann', href: '/api/v3/users/3' })); + }); +}); diff --git a/extensions/blocknote-extensions/test/lib/hooks/useDebouncedSearch.test.ts b/extensions/blocknote-extensions/test/lib/hooks/useDebouncedSearch.test.ts new file mode 100644 index 000000000000..094602e0c8c9 --- /dev/null +++ b/extensions/blocknote-extensions/test/lib/hooks/useDebouncedSearch.test.ts @@ -0,0 +1,71 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { useDebouncedSearch } from '../../../lib/hooks/useDebouncedSearch'; + +describe('useDebouncedSearch', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('only fetches the last query and settles the superseded one empty', async () => { + const search = vi.fn(async (query:string) => [query]); + const { result } = renderHook(() => useDebouncedSearch(search, { debounce: 300 })); + + const first = result.current.search('a'); + const second = result.current.search('ab'); + await vi.advanceTimersByTimeAsync(300); + + expect(await first).toEqual([]); + expect(await second).toEqual(['ab']); + expect(search).toHaveBeenCalledTimes(1); + }); + + it('skips blank queries unless asked to search them', async () => { + const search = vi.fn(async () => ['x']); + const skipping = renderHook(() => useDebouncedSearch(search, { debounce: 0 })).result.current.search; + const searching = renderHook(() => useDebouncedSearch(search, { debounce: 0, searchBlank: true })).result.current.search; + + expect(await skipping(' ')).toEqual([]); + const blank = searching(''); + await vi.advanceTimersByTimeAsync(0); + expect(await blank).toEqual(['x']); + expect(search).toHaveBeenCalledTimes(1); + }); + + it('rejects with the search error', async () => { + const { result } = renderHook(() => useDebouncedSearch(async () => { throw new Error('boom'); }, { debounce: 0 })); + + const failing = result.current.search('a'); + await Promise.all([ + expect(failing).rejects.toThrow('boom'), + vi.advanceTimersByTimeAsync(0), + ]); + }); + + it('rejects when the search throws synchronously', async () => { + const { result } = renderHook(() => useDebouncedSearch(() => { throw new Error('sync boom'); }, { debounce: 0 })); + + const failing = result.current.search('a'); + await Promise.all([ + expect(failing).rejects.toThrow('sync boom'), + vi.advanceTimersByTimeAsync(0), + ]); + }); + + it('settles a cancelled search empty without fetching', async () => { + const search = vi.fn(async (query:string) => [query]); + const { result } = renderHook(() => useDebouncedSearch(search, { debounce: 300 })); + + const pending = result.current.search('a'); + result.current.cancel(); + await vi.advanceTimersByTimeAsync(300); + + expect(await pending).toEqual([]); + expect(search).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/blocknote-extensions/test/lib/hooks/useSuggestionSearch.test.ts b/extensions/blocknote-extensions/test/lib/hooks/useSuggestionSearch.test.ts new file mode 100644 index 000000000000..906b6d037381 --- /dev/null +++ b/extensions/blocknote-extensions/test/lib/hooks/useSuggestionSearch.test.ts @@ -0,0 +1,132 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { RefObject } from 'react'; +import { renderHook } from '@testing-library/react'; +import { useDebouncedSearch } from '../../../lib/hooks/useDebouncedSearch'; +import { useSuggestionSearch } from '../../../lib/hooks/useSuggestionSearch'; +import type { SuggestionPlan, SuggestionSearchState } from '../../../lib/hooks/useSuggestionSearch'; +import { createHeadlessEditorWithText } from '../../helpers/headlessEditor'; + +interface PendingSearch { + resolve:(results:string[]) => void; + reject:(error:Error) => void; +} + +const searchPlan = ():SuggestionPlan => ({ kind: 'search', pick: () => {} }); + +function renderSuggestionSearch() { + const pending = new Map(); + const search = (query:string) => new Promise((resolve, reject) => { + pending.set(query, { resolve, reject }); + }); + let searchState!:RefObject>; + const createMenu = (ref:RefObject>) => { + searchState = ref; + return () => null; + }; + + const { result } = renderHook(() => useSuggestionSearch(createHeadlessEditorWithText('@'), { + trigger: '@', + planFor: searchPlan, + search, + cancelSearch: () => {}, + logPrefix: '[test search]', + createMenu, + })); + + return { + getItems: result.current.getItems, + searchFor: (query:string) => pending.get(query)!, + state: () => searchState.current, + }; +} + +describe('useSuggestionSearch', () => { + beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('lets only the latest query write the search state', async () => { + const { getItems, searchFor, state } = renderSuggestionSearch(); + const stale = getItems('a'); + const latest = getItems('an'); + + searchFor('an').resolve(['Anna']); + expect(await latest).toHaveLength(1); + searchFor('a').resolve(['Adam', 'Anna']); + + expect(await stale).toEqual([]); + expect(state()).toEqual({ query: 'an', results: ['Anna'], error: null }); + }); + + it('does not let a stale error overwrite the latest results', async () => { + const { getItems, searchFor, state } = renderSuggestionSearch(); + const stale = getItems('a'); + const latest = getItems('an'); + + searchFor('an').resolve(['Anna']); + await latest; + searchFor('a').reject(new Error('offline')); + await stale; + + expect(console.error).toHaveBeenCalledWith('[test search]', new Error('offline')); + expect(state()).toEqual({ query: 'an', results: ['Anna'], error: null }); + }); + + it('lets only the latest call write the state when the same query is asked twice', async () => { + const pending:PendingSearch[] = []; + const search = () => new Promise((resolve, reject) => { pending.push({ resolve, reject }); }); + let searchState!:RefObject>; + const { result } = renderHook(() => useSuggestionSearch(createHeadlessEditorWithText('@'), { + trigger: '@', + planFor: searchPlan, + search, + cancelSearch: () => {}, + logPrefix: '[test search]', + createMenu: (ref) => { searchState = ref; return () => null; }, + })); + + const first = result.current.getItems('a'); + const second = result.current.getItems('a'); + pending[1].resolve(['Anna']); + expect(await second).toHaveLength(1); + pending[0].resolve(['Adam']); + + expect(await first).toEqual([]); + expect(searchState.current).toEqual({ query: 'a', results: ['Anna'], error: null }); + }); + + it('cancels the pending search when the query stops being searchable', async () => { + vi.useFakeTimers(); + try { + const search = vi.fn(async (query:string) => [query]); + const planFor = (query:string):SuggestionPlan => ( + query === '12' ? searchPlan() : { kind: 'prompt' } + ); + const { result } = renderHook(() => { + const debounced = useDebouncedSearch(search, { debounce: 300 }); + return useSuggestionSearch(createHeadlessEditorWithText('#'), { + trigger: '#', + planFor, + search: debounced.search, + cancelSearch: debounced.cancel, + logPrefix: '[test search]', + createMenu: () => () => null, + }); + }); + + const searching = result.current.getItems('12'); + await result.current.getItems(''); + await vi.advanceTimersByTimeAsync(300); + + expect(search).not.toHaveBeenCalled(); + expect(await searching).toEqual([]); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/extensions/blocknote-extensions/test/lib/hooks/useWorkPackageSearch.test.ts b/extensions/blocknote-extensions/test/lib/hooks/useWorkPackageSearch.test.ts new file mode 100644 index 000000000000..911c1ec65bb3 --- /dev/null +++ b/extensions/blocknote-extensions/test/lib/hooks/useWorkPackageSearch.test.ts @@ -0,0 +1,95 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, renderHook } from '@testing-library/react'; +import type { RenderHookResult } from '@testing-library/react'; +import { useWorkPackageSearch } from '../../../lib/hooks/useWorkPackageSearch'; +import { searchWorkPackages } from '../../../lib/services/openProjectApi'; +import type { WorkPackage } from '../../../lib/openProjectTypes'; + +vi.mock('../../../lib/services/openProjectApi', () => ({ + searchWorkPackages: vi.fn(), +})); + +type SearchHook = RenderHookResult, unknown>; + +const workPackage = { id: 12 } as WorkPackage; + +function startSearch({ result }:SearchHook, query:string):Promise { + let call!:Promise; + act(() => { + call = result.current.search(query); + }); + return call; +} + +const passDebounce = () => act(() => vi.advanceTimersByTimeAsync(300)); + +describe('useWorkPackageSearch imperative search', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.mocked(searchWorkPackages).mockReset(); + vi.mocked(searchWorkPackages).mockResolvedValue([workPackage]); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('cancels a pending search when called with a blank query', async () => { + const hook = renderHook(() => useWorkPackageSearch()); + + const pending = startSearch(hook, '12'); + const blank = startSearch(hook, ''); + await passDebounce(); + + expect(await pending).toEqual([]); + expect(await blank).toEqual([]); + expect(searchWorkPackages).not.toHaveBeenCalled(); + expect(hook.result.current.searchResults).toEqual([]); + }); + + it('does not touch state when a call is superseded', async () => { + const hook = renderHook(() => useWorkPackageSearch()); + + const initial = startSearch(hook, '1'); + await passDebounce(); + expect(await initial).toEqual([workPackage]); + expect(hook.result.current.searchResults).toEqual([workPackage]); + + const superseded = startSearch(hook, '2'); + const latest = startSearch(hook, '3'); + expect(await superseded).toEqual([]); + expect(hook.result.current.searchResults).toEqual([workPackage]); + + await passDebounce(); + expect(await latest).toEqual([workPackage]); + }); + + it('rethrows a failed search, but only the latest call clears the results', async () => { + const hook = renderHook(() => useWorkPackageSearch()); + const initial = startSearch(hook, '1'); + await passDebounce(); + expect(await initial).toEqual([workPackage]); + + let failSuperseded:(error:Error) => void = () => {}; + vi.mocked(searchWorkPackages) + .mockImplementationOnce(() => new Promise((_resolve, reject) => { failSuperseded = reject; })) + .mockRejectedValueOnce(new Error('latest failed')); + + const superseded = startSearch(hook, '2'); + await passDebounce(); + const latest = startSearch(hook, '3'); + + await act(async () => { + failSuperseded(new Error('superseded failed')); + await expect(superseded).rejects.toThrow('superseded failed'); + }); + expect(hook.result.current.searchResults).toEqual([workPackage]); + + await Promise.all([ + expect(latest).rejects.toThrow('latest failed'), + passDebounce(), + ]); + expect(hook.result.current.searchResults).toEqual([]); + }); +}); diff --git a/extensions/blocknote-extensions/test/lib/plugins/userMentionNotification.test.ts b/extensions/blocknote-extensions/test/lib/plugins/userMentionNotification.test.ts new file mode 100644 index 000000000000..5ef253832faf --- /dev/null +++ b/extensions/blocknote-extensions/test/lib/plugins/userMentionNotification.test.ts @@ -0,0 +1,146 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'; +import { notifyMentionedUser } from '../../../lib/services/openProjectApi'; +import { USER_MENTION_TYPE } from '../../../lib/utils/nodeTypes'; +import { + blockText, + blockTextWithNodes, + createCollaborativeHeadlessEditors, + createHeadlessEditor, + createHeadlessEditorWithBlocks, + createHeadlessEditorWithText, + mountHeadlessEditor, + nthInlineNodePosition, + placeCursorAfterText, +} from '../../helpers/headlessEditor'; +import { editDocument } from '../../helpers/documentContext'; +import { mentionContent, paragraphWith, textContent } from '../../helpers/content'; + +vi.mock(import('../../../lib/services/openProjectApi'), async (importOriginal) => ({ + ...await importOriginal(), + notifyMentionedUser: vi.fn(() => Promise.resolve()), +})); + +const judith = mentionContent('5', 'Judith Roth'); +const peter = mentionContent('8', 'Peter Lang'); + +describe('UserMentionNotificationExtension', () => { + beforeEach(() => { + vi.mocked(notifyMentionedUser).mockClear(); + editDocument(3); + }); + + it('notifies the user an inserted mention mentions', () => { + const editor = mountHeadlessEditor(createHeadlessEditorWithText('Thanks ')); + + editor.insertInlineContent([judith]); + + expect(notifyMentionedUser).toHaveBeenCalledExactlyOnceWith(3, '5'); + }); + + it('notifies a user mentioned twice in one insertion once', () => { + const editor = mountHeadlessEditor(createHeadlessEditorWithText('Thanks ')); + + editor.insertInlineContent([judith, ' and again ', judith]); + + expect(notifyMentionedUser).toHaveBeenCalledExactlyOnceWith(3, '5'); + }); + + it('notifies each of several users mentioned at once', () => { + const editor = mountHeadlessEditor(createHeadlessEditorWithText('Thanks ')); + + editor.insertInlineContent([judith, ' and ', peter]); + + expect(vi.mocked(notifyMentionedUser).mock.calls).toEqual([[3, '5'], [3, '8']]); + }); + + it('notifies about a duplicated mention', () => { + const editor = mountHeadlessEditor(createHeadlessEditor([judith, textContent(' and')])); + const mentionPosition = nthInlineNodePosition(editor, USER_MENTION_TYPE); + + placeCursorAfterText(editor, 'and'); + editor.transact((transaction) => { + transaction.insert(transaction.selection.from, transaction.doc.nodeAt(mentionPosition)!); + }); + + expect(notifyMentionedUser).toHaveBeenCalledExactlyOnceWith(3, '5'); + }); + + it('does not notify again on the edits that follow', () => { + const editor = mountHeadlessEditor(createHeadlessEditorWithText('Thanks ')); + + editor.insertInlineContent([judith]); + editor.insertInlineContent([' for the review']); + editor.updateBlock(editor.document[0], { type: 'heading' }); + + expect(notifyMentionedUser).toHaveBeenCalledOnce(); + }); + + it('does not notify while typing next to a mention', () => { + const editor = mountHeadlessEditor(createHeadlessEditor([textContent('Thanks '), judith, textContent(' and more')])); + + placeCursorAfterText(editor, 'more'); + editor.insertInlineContent(['!']); + + expect(notifyMentionedUser).not.toHaveBeenCalled(); + }); + + it('does not notify about a mention in a moved block', () => { + const editor = mountHeadlessEditor(createHeadlessEditorWithBlocks([paragraphWith(judith), paragraphWith('Last line')])); + + editor.setTextCursorPosition(editor.document[0], 'end'); + editor.moveBlocksDown(); + + expect(editor.document.map((_block, index) => blockText(editor, index))).toEqual(['Last line', '']); + expect(notifyMentionedUser).not.toHaveBeenCalled(); + }); + + it('does not notify about a mention moved within the text', () => { + const editor = mountHeadlessEditor(createHeadlessEditor([judith, textContent(' and more')])); + const mentionPosition = nthInlineNodePosition(editor, USER_MENTION_TYPE); + + editor.transact((transaction) => { + const mention = transaction.doc.nodeAt(mentionPosition)!; + transaction.delete(mentionPosition, mentionPosition + mention.nodeSize); + transaction.insert(mentionPosition + ' and'.length, mention); + }); + + expect(blockTextWithNodes(editor)).toEqual(' and[openProjectUserMention] more'); + expect(notifyMentionedUser).not.toHaveBeenCalled(); + }); + + it('leaves a mention a collaborator inserted to the collaborator', () => { + const { editors: [mine, theirs], sync } = createCollaborativeHeadlessEditors(); + theirs.setTextCursorPosition(theirs.document[0], 'end'); + theirs.insertInlineContent([judith]); + vi.mocked(notifyMentionedUser).mockClear(); + + sync(); + + expect(blockTextWithNodes(mine)).toEqual('[openProjectUserMention]'); + expect(notifyMentionedUser).not.toHaveBeenCalled(); + }); + + it('does not notify without a document', () => { + editDocument(''); + const editor = mountHeadlessEditor(createHeadlessEditorWithText('Thanks ')); + + editor.insertInlineContent([judith]); + + expect(notifyMentionedUser).not.toHaveBeenCalled(); + }); + + it('logs a failed notification and goes on editing', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + onTestFinished(() => consoleSpy.mockRestore()); + const failure = new Error('Forbidden'); + vi.mocked(notifyMentionedUser).mockRejectedValueOnce(failure); + const editor = mountHeadlessEditor(createHeadlessEditorWithText('Thanks ')); + + editor.insertInlineContent([judith, ' there']); + + expect(blockTextWithNodes(editor)).toEqual('Thanks [openProjectUserMention] there'); + await vi.waitFor(() => expect(consoleSpy) + .toHaveBeenCalledWith('[user mention] Failed to notify user 5:', failure)); + }); +}); diff --git a/extensions/blocknote-extensions/test/lib/services/openProjectApi.test.ts b/extensions/blocknote-extensions/test/lib/services/openProjectApi.test.ts index a48cc9b232f4..d1af8d0f296e 100644 --- a/extensions/blocknote-extensions/test/lib/services/openProjectApi.test.ts +++ b/extensions/blocknote-extensions/test/lib/services/openProjectApi.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'; import { + avatarUrlOf, canCreateWorkPackages, createWorkPackage, fetchAllowedValues, @@ -11,11 +12,16 @@ import { probeCreateWorkPackagePermission, initOpenProjectApi, linkToNewWorkPackage, + linkToUser, linkToWorkPackage, + notifyMentionedUser, OpenProjectApiError, parseWorkPackageUrl, - searchWorkPackages + searchMentionableUsers, + searchWorkPackages, + userApiPath, } from '../../../lib/services/openProjectApi'; +import { initEditorContext } from '../../../lib/services/editorContext'; function mockResponse(props:Partial):Response { return props as Response; @@ -855,6 +861,132 @@ describe('openProjectApi', () => { }); }); + describe('user links', () => { + it('links to the user page, and loads the avatar through the proxy', () => { + initOpenProjectApi({ baseUrl: 'https://example.com/', proxyUrl: 'https://proxy.example.com' }); + + expect(linkToUser(5)).toBe('https://example.com/users/5'); + expect(userApiPath('5')).toBe('/api/v3/users/5'); + expect(avatarUrlOf(userApiPath(5))).toBe('https://proxy.example.com/api/v3/users/5/avatar'); + }); + + it('has no avatar picture for groups and placeholder users', () => { + expect(avatarUrlOf('/api/v3/groups/3')).toBeUndefined(); + expect(avatarUrlOf('/api/v3/placeholder_users/3')).toBeUndefined(); + expect(avatarUrlOf(undefined)).toBeUndefined(); + }); + }); + + describe('notifyMentionedUser', () => { + function stubFetch(response:Partial) { + return vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse(response)); + } + + afterEach(() => vi.restoreAllMocks()); + + it('posts a mention of the user in the document', async () => { + initOpenProjectApi({ baseUrl: 'https://example.com', proxyUrl: 'https://proxy.example.com' }); + const fetchSpy = stubFetch({ ok: true, status: 201, json: async () => ({ _type: 'Notification' }) }); + + await notifyMentionedUser(5, '13'); + + expect(calledUrl(fetchSpy.mock.calls)).toBe('https://proxy.example.com/api/v3/notifications'); + const options = fetchSpy.mock.calls[0][1]!; + expect(options.method).toBe('POST'); + expect(options.headers).toEqual({ + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }); + expect(JSON.parse(options.body as string)).toEqual({ + reason: 'mentioned', + _links: { + resource: { href: '/api/v3/documents/5' }, + recipient: { href: '/api/v3/users/13' }, + }, + }); + }); + + it('resolves when no notification is created and nothing comes back', async () => { + initOpenProjectApi({ baseUrl: 'http://localhost:3000' }); + stubFetch({ ok: true, status: 204, json: async () => { throw new SyntaxError('Unexpected end of JSON input'); } }); + + await expect(notifyMentionedUser('5', '13')).resolves.toBeUndefined(); + }); + + it('rejects with the error the API answers', async () => { + initOpenProjectApi({ baseUrl: 'http://localhost:3000' }); + stubFetch({ + ok: false, + status: 403, + statusText: 'Forbidden', + json: async () => ({ message: 'You are not authorized to access this resource.' }), + }); + + const failure = notifyMentionedUser('5', '13'); + + await expect(failure).rejects.toBeInstanceOf(OpenProjectApiError); + await expect(failure).rejects.toMatchObject({ + message: 'You are not authorized to access this resource.', + responseStatus: 403, + }); + }); + }); + + describe('searchMentionableUsers', () => { + const anna = { id: 7, name: 'Anna Kovalenko', login: 'anna', _links: { self: { href: '/api/v3/users/7' } } }; + let fetchSpy:MockInstance; + + const requestedUrl = () => new URL(calledUrl(fetchSpy.mock.calls)); + const filtersOf = (url:URL) => JSON.parse(url.searchParams.get('filters')!) as unknown[]; + + beforeEach(() => { + initOpenProjectApi({ baseUrl: 'http://localhost:3000' }); + fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse({ + ok: true, + json: async () => ({ _embedded: { elements: [anna] } }), + })); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + initEditorContext({}); + }); + + it('asks for active users of the document project matching the query, sorted by name', async () => { + initEditorContext({ projectId: 12 }); + await searchMentionableUsers('jud'); + + const url = requestedUrl(); + expect(url.pathname).toBe('/api/v3/principals'); + expect(filtersOf(url)).toEqual([ + { status: { operator: '!', values: ['3'] } }, + { type: { operator: '=', values: ['User'] } }, + { member: { operator: '=', values: ['12'] } }, + { typeahead: { operator: '**', values: ['jud'] } }, + ]); + expect(url.searchParams.get('sortBy')).toBe('[["name","asc"]]'); + }); + + it('asks for at most 5 people', async () => { + await searchMentionableUsers('jud'); + + expect(requestedUrl().searchParams.get('pageSize')).toBe('5'); + }); + + it('lists users without a project or a query', async () => { + await searchMentionableUsers(' '); + + expect(filtersOf(requestedUrl())).toEqual([ + { status: { operator: '!', values: ['3'] } }, + { type: { operator: '=', values: ['User'] } }, + ]); + }); + + it('keeps only the id and the name of each user', async () => { + expect(await searchMentionableUsers('ann')).toEqual([{ id: 7, name: 'Anna Kovalenko' }]); + }); + }); + describe('fetchTypes', () => { it('resolves with data on success', async () => { initOpenProjectApi({ baseUrl: 'http://localhost:3000' }); diff --git a/extensions/blocknote-extensions/test/lib/utils/html.test.ts b/extensions/blocknote-extensions/test/lib/utils/html.test.ts new file mode 100644 index 000000000000..45b242b4ba27 --- /dev/null +++ b/extensions/blocknote-extensions/test/lib/utils/html.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; +import { escapeHtmlAttribute, escapeHtmlText } from '../../../lib/utils/html'; + +describe('html escaping', () => { + it('escapes text so it cannot open a tag', () => { + expect(escapeHtmlText('A & B