From b3c3e0b0a3770bbdccf8b08ecaef48e516cc55da Mon Sep 17 00:00:00 2001 From: ihordubas99 Date: Fri, 9 Oct 2026 14:20:11 +0300 Subject: [PATCH 1/7] [SE-641] Move shared UI building blocks out of the work package folders --- extensions/blocknote-extensions/.gitignore | 1 + .../BlockWorkPackage/BlockCards.tsx | 4 +- .../BlockWorkPackage/BlockWorkPackage.tsx | 7 ++- .../CreateWorkPackage/Suggestions.tsx | 2 +- .../lib/components/CreateWorkPackage/atoms.ts | 4 +- .../lib/components/HashMenu/HashWpMenu.tsx | 6 +-- .../InlineWorkPackage/chipLayouts.tsx | 42 ++++------------ .../lib/components/Search/SearchContainer.tsx | 17 +------ .../lib/components/Search/SearchDropdown.tsx | 2 +- .../Search/WorkPackageSearchPopover.tsx | 2 +- .../components/WorkPackage/OptionsPopover.tsx | 6 +-- .../components/WorkPackage/PreviewPopover.tsx | 6 +-- .../lib/components/WorkPackage/SizeMenu.tsx | 4 +- .../WorkPackage/UnavailableCard.tsx | 7 ++- .../lib/components/WorkPackage/atoms.tsx | 41 ---------------- .../lib/components/WorkPackage/index.ts | 1 - .../lib/components/WorkPackage/tokens.ts | 20 -------- .../lib/components/shared/SearchMessage.tsx | 14 ++++++ .../anchoredPopover.tsx | 0 .../lib/components/shared/inlineNodeStyles.ts | 48 +++++++++++++++++++ .../lib/components/shared/theme.ts | 42 ++++++++++++++++ 21 files changed, 140 insertions(+), 136 deletions(-) delete mode 100644 extensions/blocknote-extensions/lib/components/WorkPackage/tokens.ts create mode 100644 extensions/blocknote-extensions/lib/components/shared/SearchMessage.tsx rename extensions/blocknote-extensions/lib/components/{WorkPackage => shared}/anchoredPopover.tsx (100%) create mode 100644 extensions/blocknote-extensions/lib/components/shared/inlineNodeStyles.ts create mode 100644 extensions/blocknote-extensions/lib/components/shared/theme.ts diff --git a/extensions/blocknote-extensions/.gitignore b/extensions/blocknote-extensions/.gitignore index 5aa7e6c85d7c..9f3a440e90a7 100644 --- a/extensions/blocknote-extensions/.gitignore +++ b/extensions/blocknote-extensions/.gitignore @@ -35,3 +35,4 @@ test/lib/components/integration/__screenshots__/* # test attachments .vitest-attachments +.vitest/ diff --git a/extensions/blocknote-extensions/lib/components/BlockWorkPackage/BlockCards.tsx b/extensions/blocknote-extensions/lib/components/BlockWorkPackage/BlockCards.tsx index 8cb7e87a7419..517bc861b9d7 100644 --- a/extensions/blocknote-extensions/lib/components/BlockWorkPackage/BlockCards.tsx +++ b/extensions/blocknote-extensions/lib/components/BlockWorkPackage/BlockCards.tsx @@ -1,8 +1,8 @@ import styled from 'styled-components'; import type { WorkPackage } from '../../openProjectTypes'; import { linkToWorkPackage } from '../../services/openProjectApi'; +import { editorThemeVariables } from '../shared/theme'; import { - defaultWpVariables, WorkPackageId, WorkPackageType, WorkPackageStatus, @@ -50,7 +50,7 @@ function buildTitle(workPackage:WorkPackage, linkTitle:boolean) { } const CardBase = styled.div<{ $inDropdown:boolean }>` - ${defaultWpVariables} + ${editorThemeVariables} padding: var(--spacer-m) var(--spacer-l); border-radius: var(--bn-border-radius); diff --git a/extensions/blocknote-extensions/lib/components/BlockWorkPackage/BlockWorkPackage.tsx b/extensions/blocknote-extensions/lib/components/BlockWorkPackage/BlockWorkPackage.tsx index 366fb3e31f2c..bdfdf512aa1b 100644 --- a/extensions/blocknote-extensions/lib/components/BlockWorkPackage/BlockWorkPackage.tsx +++ b/extensions/blocknote-extensions/lib/components/BlockWorkPackage/BlockWorkPackage.tsx @@ -13,8 +13,7 @@ import { BlockCard } from './BlockCard'; import { UnavailableCard } from '../WorkPackage/UnavailableCard'; import { WpOptionsPopover } from '../WorkPackage/OptionsPopover'; import { PendingWorkPackagePopover } from '../WorkPackage/PendingWorkPackagePopover'; -import { defaultWpVariables, nonSelectableStyles } from '../WorkPackage/atoms'; -import { CHIP_STYLES } from '../WorkPackage/tokens'; +import { BLOCK_FOCUS_SHADOW, editorThemeVariables, nonSelectableStyles } from '../shared/theme'; import { moveCursorAfterBlock } from '../../utils/cursor'; import { hideSafariPhantomSelection, selectBlockNode } from '../../utils/selection'; import { pendingBlockRegistry } from './pendingBlockRegistry'; @@ -22,11 +21,11 @@ import { useSuppressFormattingToolbar } from '../../hooks/useSuppressFormattingT import { useTapActivation } from '../../utils/tapActivation'; const Block = styled.div.attrs({ className: 'op-bn-extensions', 'data-testid': 'block-wp-wrapper' })<{ $pending?:boolean; $selected?:boolean }>` - ${defaultWpVariables} + ${editorThemeVariables} background-color: ${({ $pending }) => ($pending ? 'transparent' : 'var(--op-chip-bg)')}; ${nonSelectableStyles} border-radius: var(--bn-border-radius); - box-shadow: ${({ $selected }) => ($selected ? CHIP_STYLES.focusShadow : 'none')}; + box-shadow: ${({ $selected }) => ($selected ? BLOCK_FOCUS_SHADOW : 'none')}; ${({ $pending }) => $pending && 'position: relative;'} `; diff --git a/extensions/blocknote-extensions/lib/components/CreateWorkPackage/Suggestions.tsx b/extensions/blocknote-extensions/lib/components/CreateWorkPackage/Suggestions.tsx index d0bfbb2983be..da1d5ae9b86e 100644 --- a/extensions/blocknote-extensions/lib/components/CreateWorkPackage/Suggestions.tsx +++ b/extensions/blocknote-extensions/lib/components/CreateWorkPackage/Suggestions.tsx @@ -8,7 +8,7 @@ import { XCircleIcon, } from '@primer/octicons-react'; import { wantsMotion } from '../../utils/motion'; -import { useAnchoredPopover } from '../WorkPackage/anchoredPopover'; +import { useAnchoredPopover } from '../shared/anchoredPopover'; import { useActiveOptionInView } from '../../hooks/useActiveOptionInView'; import type { AllowedValue, ListedValue } from './formSchema'; import { diff --git a/extensions/blocknote-extensions/lib/components/CreateWorkPackage/atoms.ts b/extensions/blocknote-extensions/lib/components/CreateWorkPackage/atoms.ts index eceaaee87cb4..2afe7d1a118e 100644 --- a/extensions/blocknote-extensions/lib/components/CreateWorkPackage/atoms.ts +++ b/extensions/blocknote-extensions/lib/components/CreateWorkPackage/atoms.ts @@ -1,5 +1,5 @@ import styled, { css, keyframes } from 'styled-components'; -import { defaultWpVariables } from '../WorkPackage/atoms'; +import { editorThemeVariables } from '../shared/theme'; import { defaultColorStyles, typeTextColor } from '../../services/colors'; const surfaceColor = 'var(--op-create-wp-surface)'; @@ -83,7 +83,7 @@ export const Overlay = styled.div.attrs({ className: 'op-bn-create-wp-overlay', 'data-testid': 'create-wp-overlay', })` - ${defaultWpVariables} + ${editorThemeVariables} --op-create-wp-shadow: 0 4px 24px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0, 0, 0, 0.04); --op-create-wp-surface: var(--bgColor-default, #ffffff); diff --git a/extensions/blocknote-extensions/lib/components/HashMenu/HashWpMenu.tsx b/extensions/blocknote-extensions/lib/components/HashMenu/HashWpMenu.tsx index 54f44c7002b7..bc245b2f3fad 100644 --- a/extensions/blocknote-extensions/lib/components/HashMenu/HashWpMenu.tsx +++ b/extensions/blocknote-extensions/lib/components/HashMenu/HashWpMenu.tsx @@ -3,8 +3,8 @@ import type { FC, RefObject } from 'react'; import type { SuggestionMenuProps } from '@blocknote/react'; import styled from 'styled-components'; import { BlockCard } from '../BlockWorkPackage/BlockCard'; -import { defaultWpVariables, menuSurfaceStyles } from '../WorkPackage/atoms'; -import { SearchMessage } from '../Search/SearchContainer'; +import { editorThemeVariables, menuSurfaceStyles } from '../shared/theme'; +import { SearchMessage } from '../shared/SearchMessage'; import { CreateWorkPackageOption } from '../Search/CreateWorkPackageOption'; import { Spinner } from '../Spinner'; import { supportsHover } from '../../utils/device'; @@ -28,7 +28,7 @@ import { useTranslation } from 'react-i18next'; * explicitly zero. (Harmless on non-flex layouts.) */ const Menu = styled.div.attrs({ className: 'op-bn-hash-menu', 'data-testid': 'hash-menu' })` - ${defaultWpVariables} + ${editorThemeVariables} ${menuSurfaceStyles} box-shadow: var(--bn-shadow-medium); border-radius: var(--bn-border-radius-large); diff --git a/extensions/blocknote-extensions/lib/components/InlineWorkPackage/chipLayouts.tsx b/extensions/blocknote-extensions/lib/components/InlineWorkPackage/chipLayouts.tsx index 76973a005443..3deef3b53965 100644 --- a/extensions/blocknote-extensions/lib/components/InlineWorkPackage/chipLayouts.tsx +++ b/extensions/blocknote-extensions/lib/components/InlineWorkPackage/chipLayouts.tsx @@ -1,38 +1,20 @@ import { css } from 'styled-components'; import styled from 'styled-components'; -import { CHIP_STYLES } from '../WorkPackage/tokens'; -import { defaultWpVariables, nonSelectableStyles } from '../WorkPackage/atoms'; - -const chipBaseStyles = css` - display: inline; - border-radius: ${CHIP_STYLES.radius}; - background: ${CHIP_STYLES.bg}; - font-size: ${CHIP_STYLES.fontSize}; - box-decoration-break: clone; - -webkit-box-decoration-break: clone; - - & > * { - vertical-align: baseline; - } - - & > *:not(:last-child) { - margin-right: ${CHIP_STYLES.gap}; - } -`; +import { INLINE_NODE_STYLES, inlineNodeBaseStyles, inlineNodeContainerStyles } from '../shared/inlineNodeStyles'; export const ChipBaseXXS = styled.span.attrs({ className: 'op-bn-inline-wp-base' })` - ${chipBaseStyles} - padding: ${CHIP_STYLES.padding.xxs}; + ${inlineNodeBaseStyles} + padding: ${INLINE_NODE_STYLES.padding.xxs}; `; export const ChipBaseXS = styled.span.attrs({ className: 'op-bn-inline-wp-base' })` - ${chipBaseStyles} - padding: ${CHIP_STYLES.padding.xs}; + ${inlineNodeBaseStyles} + padding: ${INLINE_NODE_STYLES.padding.xs}; `; export const ChipBaseS = styled.span.attrs({ className: 'op-bn-inline-wp-base' })` - ${chipBaseStyles} - padding: ${CHIP_STYLES.padding.s}; + ${inlineNodeBaseStyles} + padding: ${INLINE_NODE_STYLES.padding.s}; `; export const ChipBase = ChipBaseS; @@ -41,13 +23,7 @@ export const InlineChip = styled.span.attrs({ className: 'op-bn-inline-wp', contentEditable: false, })<{ selected?:boolean }>` - ${defaultWpVariables} - display: inline; - cursor: pointer; - ${nonSelectableStyles} - border-radius: ${CHIP_STYLES.radius}; - position: relative; - line-height: 1; + ${inlineNodeContainerStyles} &:active { cursor: grabbing; @@ -57,7 +33,7 @@ export const InlineChip = styled.span.attrs({ selected && css` & > .op-bn-inline-wp-base { - box-shadow: ${CHIP_STYLES.inlineFocusShadow}; + box-shadow: ${INLINE_NODE_STYLES.inlineFocusShadow}; } `} `; diff --git a/extensions/blocknote-extensions/lib/components/Search/SearchContainer.tsx b/extensions/blocknote-extensions/lib/components/Search/SearchContainer.tsx index 0f123e0aada3..3d2a34bad84c 100644 --- a/extensions/blocknote-extensions/lib/components/Search/SearchContainer.tsx +++ b/extensions/blocknote-extensions/lib/components/Search/SearchContainer.tsx @@ -1,5 +1,5 @@ import styled from 'styled-components'; -import { defaultWpVariables, menuSurfaceStyles } from '../WorkPackage/atoms'; +import { editorThemeVariables, menuSurfaceStyles } from '../shared/theme'; import { FLOATING_Z_INDEX } from '../../utils/zIndex'; export const SEARCH_INPUT_ID = 'op-bn-wp-search-input'; @@ -14,7 +14,7 @@ export const SEARCH_INPUT_ID = 'op-bn-wp-search-input'; export const SearchContainer = styled.div.attrs({ className: 'op-bn-search', })<{ $flipped:boolean }>` - ${defaultWpVariables} + ${editorThemeVariables} position: absolute; z-index: ${FLOATING_Z_INDEX.search}; top: 1.6em; @@ -67,19 +67,6 @@ export const SearchInput = styled.input.attrs({ box-sizing: border-box; `; -export const SearchMessage = styled.div.attrs({ - className: 'op-bn-search--message', - role: 'status', -})` - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--spacer-m); - padding: var(--spacer-m) var(--spacer-l); - font-size: 0.85em; - color: var(--bn-colors-highlights-gray-text, #888); -`; - export const DropdownList = styled.div` min-height: 0; overflow-y: auto; diff --git a/extensions/blocknote-extensions/lib/components/Search/SearchDropdown.tsx b/extensions/blocknote-extensions/lib/components/Search/SearchDropdown.tsx index ba8d76b37907..fe7d49b3eb56 100644 --- a/extensions/blocknote-extensions/lib/components/Search/SearchDropdown.tsx +++ b/extensions/blocknote-extensions/lib/components/Search/SearchDropdown.tsx @@ -11,10 +11,10 @@ import { SearchInput, SearchHeader, SearchLabel, - SearchMessage, DropdownList, DropdownItem, } from './SearchContainer'; +import { SearchMessage } from '../shared/SearchMessage'; import { Spinner } from '../Spinner'; import { CreateWorkPackageOption } from './CreateWorkPackageOption'; diff --git a/extensions/blocknote-extensions/lib/components/Search/WorkPackageSearchPopover.tsx b/extensions/blocknote-extensions/lib/components/Search/WorkPackageSearchPopover.tsx index 646d67192dcb..60735521127f 100644 --- a/extensions/blocknote-extensions/lib/components/Search/WorkPackageSearchPopover.tsx +++ b/extensions/blocknote-extensions/lib/components/Search/WorkPackageSearchPopover.tsx @@ -3,7 +3,7 @@ import type { WorkPackage } from '../../openProjectTypes'; import { SearchContainer } from './SearchContainer'; import { SearchDropdown } from './SearchDropdown'; import { BlockCard } from '../BlockWorkPackage/BlockCard'; -import { useAnchoredPopover, PopoverPortal } from '../WorkPackage/anchoredPopover'; +import { useAnchoredPopover, PopoverPortal } from '../shared/anchoredPopover'; import { useElementHeight } from '../../hooks/useElementHeight'; import { useCanCreateWorkPackages } from '../../hooks/useCanCreateWorkPackages'; diff --git a/extensions/blocknote-extensions/lib/components/WorkPackage/OptionsPopover.tsx b/extensions/blocknote-extensions/lib/components/WorkPackage/OptionsPopover.tsx index 22f8e464b175..22af8f966a8b 100644 --- a/extensions/blocknote-extensions/lib/components/WorkPackage/OptionsPopover.tsx +++ b/extensions/blocknote-extensions/lib/components/WorkPackage/OptionsPopover.tsx @@ -4,8 +4,8 @@ import type { WorkPackage } from '../../openProjectTypes'; import { linkToWorkPackage } from '../../services/openProjectApi'; import type { InlineWpSize, BlockWpSize } from './types'; import styled from 'styled-components'; -import { defaultWpVariables, menuSurfaceStyles } from './atoms'; -import { useAnchoredPopover, PopoverPortal } from './anchoredPopover'; +import { editorThemeVariables, menuSurfaceStyles } from '../shared/theme'; +import { useAnchoredPopover, PopoverPortal } from '../shared/anchoredPopover'; import { SizeMenu } from './SizeMenu'; import { FLOATING_Z_INDEX } from '../../utils/zIndex'; import { @@ -34,7 +34,7 @@ const Popover = styled.div.attrs({ className: 'op-bn-inline-options', 'data-testid': 'popover-content', })` - ${defaultWpVariables} + ${editorThemeVariables} position: absolute; z-index: ${FLOATING_Z_INDEX.options}; ${menuSurfaceStyles} diff --git a/extensions/blocknote-extensions/lib/components/WorkPackage/PreviewPopover.tsx b/extensions/blocknote-extensions/lib/components/WorkPackage/PreviewPopover.tsx index ce5c680691f6..4c956adb2d3c 100644 --- a/extensions/blocknote-extensions/lib/components/WorkPackage/PreviewPopover.tsx +++ b/extensions/blocknote-extensions/lib/components/WorkPackage/PreviewPopover.tsx @@ -1,14 +1,14 @@ import { useRef, type ReactNode } from 'react'; import styled from 'styled-components'; -import { defaultWpVariables, menuSurfaceStyles } from './atoms'; -import { useAnchoredPopover, PopoverPortal } from './anchoredPopover'; +import { editorThemeVariables, menuSurfaceStyles } from '../shared/theme'; +import { useAnchoredPopover, PopoverPortal } from '../shared/anchoredPopover'; import { FLOATING_Z_INDEX } from '../../utils/zIndex'; const PreviewContainer = styled.div.attrs({ className: 'op-bn-wp-preview', 'data-testid': 'wp-preview', })` - ${defaultWpVariables} + ${editorThemeVariables} position: absolute; z-index: ${FLOATING_Z_INDEX.preview}; top: calc(100% + 6px); diff --git a/extensions/blocknote-extensions/lib/components/WorkPackage/SizeMenu.tsx b/extensions/blocknote-extensions/lib/components/WorkPackage/SizeMenu.tsx index 4b479af74afe..9b744b5df5fa 100644 --- a/extensions/blocknote-extensions/lib/components/WorkPackage/SizeMenu.tsx +++ b/extensions/blocknote-extensions/lib/components/WorkPackage/SizeMenu.tsx @@ -1,8 +1,8 @@ import { Fragment, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import styled from 'styled-components'; -import { menuSurfaceStyles } from './atoms'; -import { useAnchoredPopover } from './anchoredPopover'; +import { menuSurfaceStyles } from '../shared/theme'; +import { useAnchoredPopover } from '../shared/anchoredPopover'; import { useTapActivation } from '../../utils/tapActivation'; import type { BlockWpSize, InlineWpSize, WpSize } from './types'; diff --git a/extensions/blocknote-extensions/lib/components/WorkPackage/UnavailableCard.tsx b/extensions/blocknote-extensions/lib/components/WorkPackage/UnavailableCard.tsx index 71720fe08d26..fb09eb5247b4 100644 --- a/extensions/blocknote-extensions/lib/components/WorkPackage/UnavailableCard.tsx +++ b/extensions/blocknote-extensions/lib/components/WorkPackage/UnavailableCard.tsx @@ -1,13 +1,12 @@ import type { ReactNode } from 'react'; import styled from 'styled-components'; import { useTranslation } from 'react-i18next'; +import { editorThemeVariables, NODE_CONTENT_GAP } from '../shared/theme'; import { - defaultWpVariables, WorkPackageId, WorkPackageTitleLink, workPackageLinkProps, } from './atoms'; -import { CHIP_STYLES } from './tokens'; import { formatWorkPackageId } from '../../utils/id'; interface UnavailableCardProps { @@ -21,7 +20,7 @@ interface UnavailableCardProps { const UnavailableWorkPackage = styled.div.attrs({ className: 'op-bn-work-package-placeholder', })` - ${defaultWpVariables} + ${editorThemeVariables} padding: var(--spacer-m) var(--spacer-l); border-radius: var(--bn-border-radius-small); `; @@ -39,7 +38,7 @@ const UnavailableMessageHeader = styled.div.attrs({ color: var(--bn-colors-editor-text) !important; display: flex; align-items: center; - gap: ${CHIP_STYLES.gap}; + gap: ${NODE_CONTENT_GAP}; `; export const UnavailableCard = ({ headerKey, messageKey, icon, displayId, linkHeader }:UnavailableCardProps) => { diff --git a/extensions/blocknote-extensions/lib/components/WorkPackage/atoms.tsx b/extensions/blocknote-extensions/lib/components/WorkPackage/atoms.tsx index bd5b3a00a85e..8e216bc01346 100644 --- a/extensions/blocknote-extensions/lib/components/WorkPackage/atoms.tsx +++ b/extensions/blocknote-extensions/lib/components/WorkPackage/atoms.tsx @@ -2,7 +2,6 @@ import type { MouseEvent } from 'react'; import styled, { css } from 'styled-components'; import { defaultColorStyles, - metaTextColor, typeTextColor, } from '../../services/colors'; import { linkToWorkPackage } from '../../services/openProjectApi'; @@ -10,46 +9,6 @@ import { linkToWorkPackage } from '../../services/openProjectApi'; /* lets a line break between two meta parts that are each kept unbroken */ export const WRAP_OPPORTUNITY = '\u200B'; -export const defaultWpVariables = css` - --spacer-s: 4px; - --spacer-m: 8px; - --spacer-l: 12px; - --spacer-xl: 16px; - --spacer-xxl: 20px; - - /* BlockNote's node-selection outline color; not exposed by BlockNote as a variable, so defined here */ - --blocknote-focus-color: rgb(100, 160, 255); - - --lightness-threshold: 0.453; - --background-alpha: 0.18; - - --op-chip-bg: var(--bgColor-muted, #f6f8fa); - --op-item-hover-bg: var(--bn-colors-highlights-gray-background, #f0f0f0); - --op-wp-meta-color: ${metaTextColor}; - - [data-color-scheme="dark"] & { - --lightness-threshold: 0.6; - --background-alpha: 0.10; - --op-chip-bg: var(--bgColor-muted, #151b23); - --op-item-hover-bg: rgba(255, 255, 255, 0.12); - } -`; - -export const menuSurfaceStyles = css` - background-color: var(--bn-colors-menu-background, #fff); - color: var(--bn-colors-menu-text, #3f3f3f); -`; - -// The -webkit- prefix is not redundant: Safari (including iOS) implements only the prefixed -// property and styled-components v6 no longer auto-prefixes, so unprefixed alone leaves the -// chip text selectable there. Belongs on the container only: `user-select` is not inherited, -// but `auto` on a descendant resolves to `none` under a non-selectable parent. -export const nonSelectableStyles = css` - -webkit-user-select: none; - user-select: none; - -webkit-touch-callout: none; -`; - export const WorkPackageId = styled.span.attrs({ className: 'op-bn-work-package--id', })<{ $compact?:boolean }>` diff --git a/extensions/blocknote-extensions/lib/components/WorkPackage/index.ts b/extensions/blocknote-extensions/lib/components/WorkPackage/index.ts index 17d656e8f685..3f360abf67f3 100644 --- a/extensions/blocknote-extensions/lib/components/WorkPackage/index.ts +++ b/extensions/blocknote-extensions/lib/components/WorkPackage/index.ts @@ -5,7 +5,6 @@ export { WorkPackageTitle, WorkPackageTitleLink, workPackageLinkProps, - defaultWpVariables, } from './atoms'; export { WpOptionsPopover } from './OptionsPopover'; export { WpPreviewPopover } from './PreviewPopover'; diff --git a/extensions/blocknote-extensions/lib/components/WorkPackage/tokens.ts b/extensions/blocknote-extensions/lib/components/WorkPackage/tokens.ts deleted file mode 100644 index 57b4e89ea85f..000000000000 --- a/extensions/blocknote-extensions/lib/components/WorkPackage/tokens.ts +++ /dev/null @@ -1,20 +0,0 @@ -export const CHIP_STYLES = { - bg: 'var(--op-chip-bg)', - radius: 'var(--bn-border-radius)', - gap: '6px', - padding: { - xxs: '2.5px 6px', - xs: '1.5px 6px', - s: '1.5px 6px', - }, - - fontSize: '12px', - - focusShadow: '0 0 0 4px var(--blocknote-focus-color)', - // Inline chips draw the selection ring mostly inward (so it doesn't cover adjacent characters and - // the chip stays flush at line starts) plus a thin 1px outward band so it reads like a slightly - // smaller version of the block card ring. Keeping it mostly inset is what removes the need for a - // horizontal margin that would otherwise indent the chip at line starts. - inlineFocusShadow: - 'inset 0 0 0 2px var(--blocknote-focus-color), 0 0 0 1px var(--blocknote-focus-color)', -} as const; \ No newline at end of file diff --git a/extensions/blocknote-extensions/lib/components/shared/SearchMessage.tsx b/extensions/blocknote-extensions/lib/components/shared/SearchMessage.tsx new file mode 100644 index 000000000000..6e87c838082a --- /dev/null +++ b/extensions/blocknote-extensions/lib/components/shared/SearchMessage.tsx @@ -0,0 +1,14 @@ +import styled from 'styled-components'; + +export const SearchMessage = styled.div.attrs({ + className: 'op-bn-search--message', + role: 'status', +})` + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacer-m); + padding: var(--spacer-m) var(--spacer-l); + font-size: 0.85em; + color: var(--bn-colors-highlights-gray-text, #888); +`; diff --git a/extensions/blocknote-extensions/lib/components/WorkPackage/anchoredPopover.tsx b/extensions/blocknote-extensions/lib/components/shared/anchoredPopover.tsx similarity index 100% rename from extensions/blocknote-extensions/lib/components/WorkPackage/anchoredPopover.tsx rename to extensions/blocknote-extensions/lib/components/shared/anchoredPopover.tsx diff --git a/extensions/blocknote-extensions/lib/components/shared/inlineNodeStyles.ts b/extensions/blocknote-extensions/lib/components/shared/inlineNodeStyles.ts new file mode 100644 index 000000000000..dfb664937cb4 --- /dev/null +++ b/extensions/blocknote-extensions/lib/components/shared/inlineNodeStyles.ts @@ -0,0 +1,48 @@ +import { css } from 'styled-components'; +import { editorThemeVariables, NODE_CONTENT_GAP, nonSelectableStyles } from './theme'; + +export const INLINE_NODE_STYLES = { + bg: 'var(--op-chip-bg)', + radius: 'var(--bn-border-radius)', + padding: { + xxs: '2.5px 6px', + xs: '1.5px 6px', + s: '1.5px 6px', + }, + + fontSize: '12px', + + // Inline chips draw the selection ring mostly inward (so it doesn't cover adjacent characters and + // the chip stays flush at line starts) plus a thin 1px outward band so it reads like a slightly + // smaller version of the block card ring. Keeping it mostly inset is what removes the need for a + // horizontal margin that would otherwise indent the chip at line starts. + inlineFocusShadow: + 'inset 0 0 0 2px var(--blocknote-focus-color), 0 0 0 1px var(--blocknote-focus-color)', +} as const; + +export const inlineNodeBaseStyles = css` + display: inline; + border-radius: ${INLINE_NODE_STYLES.radius}; + background: ${INLINE_NODE_STYLES.bg}; + font-size: ${INLINE_NODE_STYLES.fontSize}; + box-decoration-break: clone; + -webkit-box-decoration-break: clone; + + & > * { + vertical-align: baseline; + } + + & > *:not(:last-child) { + margin-right: ${NODE_CONTENT_GAP}; + } +`; + +export const inlineNodeContainerStyles = css` + ${editorThemeVariables} + display: inline; + ${nonSelectableStyles} + border-radius: ${INLINE_NODE_STYLES.radius}; + position: relative; + line-height: 1; + cursor: pointer; +`; diff --git a/extensions/blocknote-extensions/lib/components/shared/theme.ts b/extensions/blocknote-extensions/lib/components/shared/theme.ts new file mode 100644 index 000000000000..7d16f558dc41 --- /dev/null +++ b/extensions/blocknote-extensions/lib/components/shared/theme.ts @@ -0,0 +1,42 @@ +import { css } from 'styled-components'; +import { metaTextColor } from '../../services/colors'; + +export const editorThemeVariables = css` + --spacer-s: 4px; + --spacer-m: 8px; + --spacer-l: 12px; + --spacer-xl: 16px; + --spacer-xxl: 20px; + + /* BlockNote's node-selection outline color; not exposed by BlockNote as a variable, so defined here */ + --blocknote-focus-color: rgb(100, 160, 255); + + --lightness-threshold: 0.453; + --background-alpha: 0.18; + + --op-chip-bg: var(--bgColor-muted, #f6f8fa); + --op-item-hover-bg: var(--bn-colors-highlights-gray-background, #f0f0f0); + --op-wp-meta-color: ${metaTextColor}; + + [data-color-scheme="dark"] & { + --lightness-threshold: 0.6; + --background-alpha: 0.10; + --op-chip-bg: var(--bgColor-muted, #151b23); + --op-item-hover-bg: rgba(255, 255, 255, 0.12); + } +`; + +// Shared by work package cards and inline nodes. +export const NODE_CONTENT_GAP = '6px'; +export const BLOCK_FOCUS_SHADOW = '0 0 0 4px var(--blocknote-focus-color)'; + +export const menuSurfaceStyles = css` + background-color: var(--bn-colors-menu-background, #fff); + color: var(--bn-colors-menu-text, #3f3f3f); +`; + +export const nonSelectableStyles = css` + -webkit-user-select: none; + user-select: none; + -webkit-touch-callout: none; +`; From 4ea3a5e11185dadf7a0e2c12fc19469f69293a01 Mon Sep 17 00:00:00 2001 From: ihordubas99 Date: Fri, 9 Oct 2026 14:22:43 +0300 Subject: [PATCH 2/7] [SE-641] Share inline node selection and options between node types --- .../BlockWorkPackage/BlockWorkPackage.tsx | 24 +-- .../BlockWorkPackage/blockConfig.ts | 3 +- .../InlineWorkPackageChip.tsx | 91 +++--------- .../InlineWorkPackage/externalHtml.ts | 7 +- .../InlineWorkPackage/inlineConfig.ts | 4 +- .../components/WorkPackage/OptionsPopover.tsx | 123 ++-------------- .../WorkPackage/UnavailableCard.tsx | 9 +- .../lib/components/WorkPackage/atoms.tsx | 10 +- .../lib/components/shared/OptionsPopover.tsx | 137 ++++++++++++++++++ .../lib/hooks/useInlineNodeOptions.ts | 60 ++++++++ .../lib/hooks/usePressOutside.ts | 25 ++++ .../lib/utils/inlineChipActions.ts | 73 +--------- .../lib/utils/inlineNodes.ts | 67 +++++++++ .../blocknote-extensions/lib/utils/links.ts | 3 + .../lib/utils/nodeTypes.ts | 4 +- .../lib/utils/tapActivation.ts | 16 +- .../test/helpers/content.ts | 13 ++ .../test/helpers/headlessEditor.ts | 8 - .../components/HashMenu/editorUtils.test.ts | 3 +- .../components/HashMenu/hashTrigger.test.ts | 3 +- ...nlineNodeOptionsShadowDom.browser.test.tsx | 24 +++ .../test/lib/utils/inlineChipActions.test.ts | 26 +--- .../test/lib/utils/inlineNodes.test.ts | 32 ++++ 23 files changed, 444 insertions(+), 321 deletions(-) create mode 100644 extensions/blocknote-extensions/lib/components/shared/OptionsPopover.tsx create mode 100644 extensions/blocknote-extensions/lib/hooks/useInlineNodeOptions.ts create mode 100644 extensions/blocknote-extensions/lib/hooks/usePressOutside.ts create mode 100644 extensions/blocknote-extensions/lib/utils/inlineNodes.ts create mode 100644 extensions/blocknote-extensions/lib/utils/links.ts create mode 100644 extensions/blocknote-extensions/test/helpers/content.ts create mode 100644 extensions/blocknote-extensions/test/lib/components/integration/inlineNodeOptionsShadowDom.browser.test.tsx create mode 100644 extensions/blocknote-extensions/test/lib/utils/inlineNodes.test.ts 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/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/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/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/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/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/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..2dce30c71fc1 100644 --- a/extensions/blocknote-extensions/lib/utils/nodeTypes.ts +++ b/extensions/blocknote-extensions/lib/utils/nodeTypes.ts @@ -1,4 +1,4 @@ -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]; 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/test/helpers/content.ts b/extensions/blocknote-extensions/test/helpers/content.ts new file mode 100644 index 000000000000..321be13d4d5b --- /dev/null +++ b/extensions/blocknote-extensions/test/helpers/content.ts @@ -0,0 +1,13 @@ +import { INLINE_WP_TYPE } from '../../lib/utils/nodeTypes'; + +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 paragraphWith(...content:unknown[]) { + return { type: 'paragraph', content }; +} diff --git a/extensions/blocknote-extensions/test/helpers/headlessEditor.ts b/extensions/blocknote-extensions/test/helpers/headlessEditor.ts index bbd5506ff8b1..0ce8aff2a456 100644 --- a/extensions/blocknote-extensions/test/helpers/headlessEditor.ts +++ b/extensions/blocknote-extensions/test/helpers/headlessEditor.ts @@ -36,14 +36,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[]; } 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/integration/inlineNodeOptionsShadowDom.browser.test.tsx b/extensions/blocknote-extensions/test/lib/components/integration/inlineNodeOptionsShadowDom.browser.test.tsx new file mode 100644 index 000000000000..1157bef8787a --- /dev/null +++ b/extensions/blocknote-extensions/test/lib/components/integration/inlineNodeOptionsShadowDom.browser.test.tsx @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { page, userEvent } from 'vitest/browser'; +import { renderEditorInShadowDom } from '../../../helpers/renderEditor'; +import { chipContent, 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('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/utils/inlineChipActions.test.ts b/extensions/blocknote-extensions/test/lib/utils/inlineChipActions.test.ts index cd52bc874ae9..995f8f247ba8 100644 --- a/extensions/blocknote-extensions/test/lib/utils/inlineChipActions.test.ts +++ b/extensions/blocknote-extensions/test/lib/utils/inlineChipActions.test.ts @@ -2,19 +2,17 @@ import { describe, it, expect } from 'vitest'; import { findPendingInlineChip, - removeInlineChipAt, promoteInlineChipToBlockAt, convertBlockToInlineChip, } from '../../../lib/utils/inlineChipActions'; import { BLOCK_WP_TYPE, INLINE_WP_TYPE } from '../../../lib/utils/nodeTypes'; import { blockContent, - chipContent, createHeadlessEditor, createHeadlessEditorWithBlocks, nthChipPosition, - textContent, } from '../../helpers/headlessEditor'; +import { chipContent, textContent } from '../../helpers/content'; describe('findPendingInlineChip', () => { it('finds the chip with the given pending wpid', () => { @@ -40,28 +38,6 @@ describe('findPendingInlineChip', () => { }); }); -describe('removeInlineChipAt', () => { - it('removes exactly the chip at the given position', () => { - const editor = createHeadlessEditor([chipContent('1'), textContent(' between '), chipContent('1')]); - - removeInlineChipAt(editor, nthChipPosition(editor, 1)); - - const content = blockContent(editor); - const chips = content.filter((n) => n.type === INLINE_WP_TYPE); - expect(chips).toHaveLength(1); - expect(content[0].type).toBe(INLINE_WP_TYPE); - }); - - it('does nothing when the position does not hold a chip', () => { - const editor = createHeadlessEditor([textContent('abc'), chipContent('1')]); - const before = JSON.stringify(editor.document); - - removeInlineChipAt(editor, 1); - - expect(JSON.stringify(editor.document)).toBe(before); - }); -}); - describe('promoteInlineChipToBlockAt', () => { it('replaces a lone chip with a block card', () => { const editor = createHeadlessEditor([chipContent('7')]); diff --git a/extensions/blocknote-extensions/test/lib/utils/inlineNodes.test.ts b/extensions/blocknote-extensions/test/lib/utils/inlineNodes.test.ts new file mode 100644 index 000000000000..f39b265df6d5 --- /dev/null +++ b/extensions/blocknote-extensions/test/lib/utils/inlineNodes.test.ts @@ -0,0 +1,32 @@ +// @vitest-environment jsdom +import { describe, it, expect } from 'vitest'; +import { removeInlineNodeAt } from '../../../lib/utils/inlineNodes'; +import { INLINE_WP_TYPE } from '../../../lib/utils/nodeTypes'; +import { + blockContent, + createHeadlessEditor, + nthChipPosition, +} from '../../helpers/headlessEditor'; +import { chipContent, textContent } from '../../helpers/content'; + +describe('removeInlineNodeAt', () => { + it('removes exactly the chip at the given position', () => { + const editor = createHeadlessEditor([chipContent('1'), textContent(' between '), chipContent('1')]); + + removeInlineNodeAt(editor, nthChipPosition(editor, 1), INLINE_WP_TYPE); + + const content = blockContent(editor); + const chips = content.filter((n) => n.type === INLINE_WP_TYPE); + expect(chips).toHaveLength(1); + expect(content[0].type).toBe(INLINE_WP_TYPE); + }); + + it('does nothing when the position does not hold a chip', () => { + const editor = createHeadlessEditor([textContent('abc'), chipContent('1')]); + const before = JSON.stringify(editor.document); + + removeInlineNodeAt(editor, 1, INLINE_WP_TYPE); + + expect(JSON.stringify(editor.document)).toBe(before); + }); +}); From f6669391d1ffa3fc1acbd9b3bcb3dada8268bc92 Mon Sep 17 00:00:00 2001 From: ihordubas99 Date: Fri, 9 Oct 2026 14:24:56 +0300 Subject: [PATCH 3/7] [SE-641] Share the suggestion menu infrastructure --- .../lib/components/HashMenu/HashWpMenu.tsx | 146 +++--------------- .../HashMenu/OpenProjectHashMenu.tsx | 10 +- .../lib/components/HashMenu/editorUtils.ts | 16 +- .../lib/components/HashMenu/hashTrigger.ts | 18 +-- .../lib/components/HashMenu/index.ts | 3 +- .../lib/components/HashMenu/types.ts | 16 +- .../lib/components/HashMenu/useHashWpMenu.ts | 129 +++++----------- .../components/shared/SuggestionListbox.tsx | 117 ++++++++++++++ .../lib/hooks/useDebouncedSearch.ts | 45 ++++++ .../lib/hooks/useSuggestionSearch.ts | 86 +++++++++++ .../lib/hooks/useWorkPackageSearch.ts | 52 ++----- extensions/blocknote-extensions/lib/index.ts | 3 +- .../lib/utils/insertInline.ts | 11 ++ .../lib/utils/suggestionTrigger.ts | 12 ++ .../test/lib/hooks/useDebouncedSearch.test.ts | 59 +++++++ .../lib/hooks/useSuggestionSearch.test.ts | 77 +++++++++ .../lib/hooks/useWorkPackageSearch.test.ts | 95 ++++++++++++ 17 files changed, 594 insertions(+), 301 deletions(-) create mode 100644 extensions/blocknote-extensions/lib/components/shared/SuggestionListbox.tsx create mode 100644 extensions/blocknote-extensions/lib/hooks/useDebouncedSearch.ts create mode 100644 extensions/blocknote-extensions/lib/hooks/useSuggestionSearch.ts create mode 100644 extensions/blocknote-extensions/lib/utils/insertInline.ts create mode 100644 extensions/blocknote-extensions/lib/utils/suggestionTrigger.ts create mode 100644 extensions/blocknote-extensions/test/lib/hooks/useDebouncedSearch.test.ts create mode 100644 extensions/blocknote-extensions/test/lib/hooks/useSuggestionSearch.test.ts create mode 100644 extensions/blocknote-extensions/test/lib/hooks/useWorkPackageSearch.test.ts 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..c7c48b2d1e0a 100644 --- a/extensions/blocknote-extensions/lib/components/HashMenu/useHashWpMenu.ts +++ b/extensions/blocknote-extensions/lib/components/HashMenu/useHashWpMenu.ts @@ -1,101 +1,44 @@ -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 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, + 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/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..e1a32202f9b7 --- /dev/null +++ b/extensions/blocknote-extensions/lib/hooks/useDebouncedSearch.ts @@ -0,0 +1,45 @@ +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 = {}, +):(query:string) => Promise { + const timerRef = useRef | null>(null); + const pendingResolveRef = useRef<((results:T[]) => void) | null>(null); + + const settlePending = useCallback(() => { + if (timerRef.current !== null) clearTimeout(timerRef.current); + timerRef.current = null; + pendingResolveRef.current?.([]); + pendingResolveRef.current = null; + }, []); + + useEffect(() => settlePending, [settlePending]); + + return useCallback((query:string) => { + settlePending(); + + 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, settlePending]); +} diff --git a/extensions/blocknote-extensions/lib/hooks/useSuggestionSearch.ts b/extensions/blocknote-extensions/lib/hooks/useSuggestionSearch.ts new file mode 100644 index 000000000000..ac7c489ab6d8 --- /dev/null +++ b/extensions/blocknote-extensions/lib/hooks/useSuggestionSearch.ts @@ -0,0 +1,86 @@ +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; + logPrefix:string; + createMenu:(searchStateRef:RefObject>) => SuggestionMenuComponent; +} + +export function useSuggestionSearch( + editor:AnyEditor, + { trigger, planFor, search, logPrefix, createMenu }:SuggestionSearchOptions, +) { + const searchStateRef = useRef>({ query: '', results: [], error: null }); + const latestQueryRef = useRef(''); + + const getItems = useCallback(async (query:string):Promise => { + latestQueryRef.current = query; + const typedQueryItems = [{ title: query, onItemClick: () => restoreTypedQuery(editor, trigger, query) }]; + + const plan = planFor(query); + 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 (latestQueryRef.current !== query) 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 (latestQueryRef.current === query) { + searchStateRef.current = { + query, + results: [], + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + return withTrailingItem(typedQueryItems); + } + }, [editor, trigger, planFor, search, 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..eb44dcefd8da 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 debouncedSearch = 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, diff --git a/extensions/blocknote-extensions/lib/index.ts b/extensions/blocknote-extensions/lib/index.ts index 2d7b89fff92a..ccd14114454d 100644 --- a/extensions/blocknote-extensions/lib/index.ts +++ b/extensions/blocknote-extensions/lib/index.ts @@ -10,7 +10,8 @@ export { export type { CreateWorkPackageFromSelection } from './components'; export { initializeOpBlockNoteExtensions } from './initialize'; 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 type { SuggestionMenuItem, SuggestionSearchState } from './hooks/useSuggestionSearch'; 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/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/test/lib/hooks/useDebouncedSearch.test.ts b/extensions/blocknote-extensions/test/lib/hooks/useDebouncedSearch.test.ts new file mode 100644 index 000000000000..94516d2ddeb8 --- /dev/null +++ b/extensions/blocknote-extensions/test/lib/hooks/useDebouncedSearch.test.ts @@ -0,0 +1,59 @@ +// @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('a'); + const second = result.current('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; + const searching = renderHook(() => useDebouncedSearch(search, { debounce: 0, searchBlank: true })).result.current; + + 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('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('a'); + await Promise.all([ + expect(failing).rejects.toThrow('sync boom'), + vi.advanceTimersByTimeAsync(0), + ]); + }); +}); 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..990c75d061cc --- /dev/null +++ b/extensions/blocknote-extensions/test/lib/hooks/useSuggestionSearch.test.ts @@ -0,0 +1,77 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { RefObject } from 'react'; +import { renderHook } from '@testing-library/react'; +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, + 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 }); + }); +}); 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([]); + }); +}); From dfe762cf333040e8fbb2189f264fc311e0e92252 Mon Sep 17 00:00:00 2001 From: ihordubas99 Date: Fri, 9 Oct 2026 14:27:15 +0300 Subject: [PATCH 4/7] [SE-641] Add the user mention node --- .../UserMention/UserMentionPill.tsx | 84 ++++++++++ .../components/UserMention/externalHtml.ts | 51 +++++++ .../lib/components/UserMention/index.ts | 1 + .../lib/components/UserMention/spec.tsx | 24 +++ .../lib/components/UserMention/staticSpec.ts | 22 +++ .../UserMention/userMentionConfig.ts | 10 ++ .../lib/components/index.ts | 1 + .../lib/components/shared/Avatar/Avatar.tsx | 55 +++++++ .../lib/components/shared/Avatar/avatars.ts | 25 +++ .../lib/components/shared/Avatar/index.ts | 1 + extensions/blocknote-extensions/lib/index.ts | 1 + .../blocknote-extensions/lib/locales/en.ts | 5 + extensions/blocknote-extensions/lib/server.ts | 9 ++ .../lib/services/openProjectApi.ts | 15 ++ .../blocknote-extensions/lib/utils/html.ts | 7 + .../lib/utils/nodeTypes.ts | 2 + .../test/helpers/content.ts | 6 +- .../test/helpers/editorHelpers.ts | 32 +++- .../test/helpers/headlessEditor.ts | 30 +++- .../test/helpers/renderEditor.tsx | 10 ++ .../UserMention/externalHtml.test.ts | 64 ++++++++ .../components/UserMention/markdown.test.ts | 41 +++++ .../integration/editor.drag.browser.test.tsx | 51 ++++++- ...nlineNodeOptionsShadowDom.browser.test.tsx | 58 ++++++- .../integration/userMention.browser.test.tsx | 50 ++++++ .../userMentionGeometry.browser.test.tsx | 48 ++++++ .../userMentionOptions.browser.test.tsx | 143 ++++++++++++++++++ .../shared/Avatar/avatar.browser.test.tsx | 37 +++++ .../components/shared/Avatar/avatars.test.ts | 28 ++++ .../test/lib/services/openProjectApi.test.ts | 21 ++- .../test/lib/utils/html.test.ts | 12 ++ .../test/lib/utils/inlineNodes.test.ts | 39 ++++- .../test/mocks/handlers.ts | 8 + 33 files changed, 978 insertions(+), 13 deletions(-) create mode 100644 extensions/blocknote-extensions/lib/components/UserMention/UserMentionPill.tsx create mode 100644 extensions/blocknote-extensions/lib/components/UserMention/externalHtml.ts create mode 100644 extensions/blocknote-extensions/lib/components/UserMention/index.ts create mode 100644 extensions/blocknote-extensions/lib/components/UserMention/spec.tsx create mode 100644 extensions/blocknote-extensions/lib/components/UserMention/staticSpec.ts create mode 100644 extensions/blocknote-extensions/lib/components/UserMention/userMentionConfig.ts create mode 100644 extensions/blocknote-extensions/lib/components/shared/Avatar/Avatar.tsx create mode 100644 extensions/blocknote-extensions/lib/components/shared/Avatar/avatars.ts create mode 100644 extensions/blocknote-extensions/lib/components/shared/Avatar/index.ts create mode 100644 extensions/blocknote-extensions/lib/utils/html.ts create mode 100644 extensions/blocknote-extensions/test/lib/components/UserMention/externalHtml.test.ts create mode 100644 extensions/blocknote-extensions/test/lib/components/UserMention/markdown.test.ts create mode 100644 extensions/blocknote-extensions/test/lib/components/integration/userMention.browser.test.tsx create mode 100644 extensions/blocknote-extensions/test/lib/components/integration/userMentionGeometry.browser.test.tsx create mode 100644 extensions/blocknote-extensions/test/lib/components/integration/userMentionOptions.browser.test.tsx create mode 100644 extensions/blocknote-extensions/test/lib/components/shared/Avatar/avatar.browser.test.tsx create mode 100644 extensions/blocknote-extensions/test/lib/components/shared/Avatar/avatars.test.ts create mode 100644 extensions/blocknote-extensions/test/lib/utils/html.test.ts 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/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/index.ts b/extensions/blocknote-extensions/lib/index.ts index ccd14114454d..551f4cfed599 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, diff --git a/extensions/blocknote-extensions/lib/locales/en.ts b/extensions/blocknote-extensions/lib/locales/en.ts index 774c820ef663..71984bbfe36b 100644 --- a/extensions/blocknote-extensions/lib/locales/en.ts +++ b/extensions/blocknote-extensions/lib/locales/en.ts @@ -98,6 +98,11 @@ export const en = { 'hashMenu': { 'typeToSearch': 'Type to search work packages…' }, + '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/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/openProjectApi.ts b/extensions/blocknote-extensions/lib/services/openProjectApi.ts index 54205110d988..7441cf6fbedf 100644 --- a/extensions/blocknote-extensions/lib/services/openProjectApi.ts +++ b/extensions/blocknote-extensions/lib/services/openProjectApi.ts @@ -111,6 +111,21 @@ 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))}`; +} + +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})$`); 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/nodeTypes.ts b/extensions/blocknote-extensions/lib/utils/nodeTypes.ts index 2dce30c71fc1..f0a4c54f8ef4 100644 --- a/extensions/blocknote-extensions/lib/utils/nodeTypes.ts +++ b/extensions/blocknote-extensions/lib/utils/nodeTypes.ts @@ -2,3 +2,5 @@ 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/test/helpers/content.ts b/extensions/blocknote-extensions/test/helpers/content.ts index 321be13d4d5b..5d619ee7fdd7 100644 --- a/extensions/blocknote-extensions/test/helpers/content.ts +++ b/extensions/blocknote-extensions/test/helpers/content.ts @@ -1,9 +1,13 @@ -import { INLINE_WP_TYPE } from '../../lib/utils/nodeTypes'; +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: {} }; } 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 0ce8aff2a456..d9665238bd70 100644 --- a/extensions/blocknote-extensions/test/helpers/headlessEditor.ts +++ b/extensions/blocknote-extensions/test/helpers/headlessEditor.ts @@ -1,11 +1,12 @@ import { BlockNoteEditor, BlockNoteSchema } from '@blocknote/core'; -import { TextSelection } from 'prosemirror-state'; +import { NodeSelection, TextSelection } from 'prosemirror-state'; import { openProjectWorkPackageBlockSpec, openProjectWorkPackageInlineSpec, + openProjectUserMentionSpec, } 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 +14,7 @@ const schema = BlockNoteSchema.create().extend({ }, inlineContentSpecs: { openProjectWorkPackageInline: openProjectWorkPackageInlineSpec, + openProjectUserMention: openProjectUserMentionSpec, }, }); @@ -48,12 +50,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..9e48e4584901 100644 --- a/extensions/blocknote-extensions/test/helpers/renderEditor.tsx +++ b/extensions/blocknote-extensions/test/helpers/renderEditor.tsx @@ -9,11 +9,13 @@ import { render } from 'vitest-browser-react'; import { openProjectWorkPackageBlockSpec, openProjectWorkPackageInlineSpec, + openProjectUserMentionSpec, getOpenProjectSlashMenuItems, OpenProjectFormattingToolbar, OpenProjectHashMenu, ShadowDomWrapper, } from '../../lib'; +import type { AnyEditor } from '../../lib/editorTypes'; import '@blocknote/core/fonts/inter.css'; import '@blocknote/mantine/style.css'; @@ -25,6 +27,7 @@ const defaultSchema = BlockNoteSchema.create().extend({ }, inlineContentSpecs: { openProjectWorkPackageInline: openProjectWorkPackageInlineSpec, + openProjectUserMention: openProjectUserMentionSpec, }, }); @@ -69,6 +72,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/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..5218f76d64a8 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,6 +1,8 @@ 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'; @@ -122,3 +124,50 @@ 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]); + }); +}); 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 index 1157bef8787a..33b9da1862bd 100644 --- a/extensions/blocknote-extensions/test/lib/components/integration/inlineNodeOptionsShadowDom.browser.test.tsx +++ b/extensions/blocknote-extensions/test/lib/components/integration/inlineNodeOptionsShadowDom.browser.test.tsx @@ -1,13 +1,69 @@ 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, paragraphWith } from '../../../helpers/content'; +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'))] }); 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..e57ec1f3bf09 --- /dev/null +++ b/extensions/blocknote-extensions/test/lib/components/integration/userMention.browser.test.tsx @@ -0,0 +1,50 @@ +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'; + +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' }]); + }); +}); 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/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/services/openProjectApi.test.ts b/extensions/blocknote-extensions/test/lib/services/openProjectApi.test.ts index a48cc9b232f4..e5564ce29247 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,10 +12,12 @@ import { probeCreateWorkPackagePermission, initOpenProjectApi, linkToNewWorkPackage, + linkToUser, linkToWorkPackage, OpenProjectApiError, parseWorkPackageUrl, - searchWorkPackages + searchWorkPackages, + userApiPath, } from '../../../lib/services/openProjectApi'; function mockResponse(props:Partial):Response { @@ -855,6 +858,22 @@ 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('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