From f1ba4ea09b59b4b65c9dc8e1f60c021368ceff49 Mon Sep 17 00:00:00 2001 From: Herman Wikner Date: Thu, 4 Apr 2024 16:10:38 +0200 Subject: [PATCH] fix(core): presence cursor --- .../form/inputs/PortableText/Compositor.tsx | 18 ++- .../inputs/PortableText/PortableTextInput.tsx | 24 ++- .../presence-cursors/UserPresenceCursor.tsx | 145 +++++++++++------- .../usePresenceCursorDecorations.tsx | 15 +- 4 files changed, 129 insertions(+), 73 deletions(-) diff --git a/packages/sanity/src/core/form/inputs/PortableText/Compositor.tsx b/packages/sanity/src/core/form/inputs/PortableText/Compositor.tsx index 38d5ee896195..b5db7c740201 100644 --- a/packages/sanity/src/core/form/inputs/PortableText/Compositor.tsx +++ b/packages/sanity/src/core/form/inputs/PortableText/Compositor.tsx @@ -35,6 +35,7 @@ interface InputProps extends ArrayOfObjectsInputProps { onActivate: () => void onCopy?: OnCopyFn onPaste?: OnPasteFn + onPortalElementChange?: (el: HTMLDivElement | null) => void onToggleFullscreen: () => void path: Path rangeDecorations?: RangeDecoration[] @@ -62,10 +63,11 @@ export function Compositor(props: Omit { + setPortalElement(el) + + onPortalElementChange?.(el) + }, + [onPortalElementChange], + ) + const editorNode = useMemo( () => ( @@ -411,6 +422,7 @@ export function Compositor(props: Omit(null) const editorRef = editorRefProp || defaultEditorRef + const innerElementRef = useRef(null) + const [currentPortalElement, setCurrentPortalElement] = useState(null) - const presenceCursorDecorations = usePresenceCursorDecorations({ - path: props.path, - }) + const presenceCursorDecorations = usePresenceCursorDecorations( + useMemo( + (): PresenceCursorDecorationsHookProps => ({ + boundaryElement: currentPortalElement || innerElementRef.current, + path: props.path, + }), + [currentPortalElement, props.path], + ), + ) // This handle will allow for natively calling .focus // on the returned component and have the PortableTextEditor focused, @@ -121,8 +132,6 @@ export function PortableTextInput(props: PortableTextInputProps) { }> = useMemo(() => new Subject(), []) const patches$ = useMemo(() => patchSubject.asObservable(), [patchSubject]) - const innerElementRef = useRef(null) - const handleToggleFullscreen = useCallback(() => { setIsFullscreen((v) => { const next = !v @@ -301,10 +310,11 @@ export function PortableTextInput(props: PortableTextInputProps) { isActive={isActive} isFullscreen={isFullscreen} onActivate={handleActivate} - onItemRemove={onItemRemove} onCopy={onCopy} onInsert={onInsert} + onItemRemove={onItemRemove} onPaste={onPaste} + onPortalElementChange={setCurrentPortalElement} onToggleFullscreen={handleToggleFullscreen} rangeDecorations={rangeDecorations} renderBlockActions={renderBlockActions} diff --git a/packages/sanity/src/core/form/inputs/PortableText/presence-cursors/UserPresenceCursor.tsx b/packages/sanity/src/core/form/inputs/PortableText/presence-cursors/UserPresenceCursor.tsx index 7d29977a90fd..466dd469c9c8 100644 --- a/packages/sanity/src/core/form/inputs/PortableText/presence-cursors/UserPresenceCursor.tsx +++ b/packages/sanity/src/core/form/inputs/PortableText/presence-cursors/UserPresenceCursor.tsx @@ -1,10 +1,15 @@ import {type ColorTints} from '@sanity/color' import {type User} from '@sanity/types' -// eslint-disable-next-line no-restricted-imports -import {Box, Text, Tooltip as UITooltip} from '@sanity/ui' +import { + // eslint-disable-next-line no-restricted-imports + Popover, + Stack, + Text, + // eslint-disable-next-line no-restricted-imports + Tooltip, +} from '@sanity/ui' import {type Theme} from '@sanity/ui/theme' -import {type ReactNode} from 'react' -import styled, {css} from 'styled-components' +import {css, styled} from 'styled-components' import {useUserColor} from '../../../../user-color/hooks' @@ -13,95 +18,121 @@ const DARK_SCHEME_TINT = 400 const getTint = (isDark: boolean) => (isDark ? DARK_SCHEME_TINT : LIGHT_SCHEME_TINT) -const RootSpan = styled.span`` - -const RelativeSpan = styled.span` - position: relative; - width: 0; -` +interface StyledProps { + theme: Theme + $tints: ColorTints +} -const DotSpan = styled.span<{theme: Theme; $tints: ColorTints}>(({theme, $tints}) => { +// The dot needs to be positioned using a Popover to ensure it doesn't interfere +// with the editor's text. Utilizing a Popover allows rendering it atop the cursor +// in a portal. This is essential as it won't be rendered inside the editor, +// thus preventing any interference with the text nodes. +const DotPopover = styled(Popover)(({theme, $tints}) => { const isDark = theme.sanity.color.dark const bg = $tints[getTint(isDark)].hex return css` - position: absolute; - top: 1px; - transform: translate(-40%, -100%); - width: 8px; - height: 8px; - border-radius: 50%; background-color: ${bg}; + position: relative; + z-index: 0; + + // We only want to show the popover (i.e. the dot) on the top of + // the care. Therefore, we hide the popover when it's not placed on top + // due to auto-placement. + &:not([data-placement='top']) { + display: none; + } ` }) -const CursorSpan = styled.span<{theme: Theme; $tints: ColorTints}>(({theme, $tints}) => { +const Dot = styled(Stack)(({theme, $tints}) => { const isDark = theme.sanity.color.dark const bg = $tints[getTint(isDark)].hex return css` + background-color: ${bg}; + border-radius: 50%; + bottom: 0; + height: 8px; + left: 50%; position: absolute; - top: 1px; - width: 1px; - height: 1em; - background: ${bg}; - pointer-events: none; + right: 0; + top: -4px; + transform: translateX(-50%); + width: 8px; ` }) -const TooltipContentBox = styled(Box)<{theme: Theme; $tints: ColorTints}>(({theme, $tints}) => { - const radius = theme.sanity.radius[2] +const TooltipContent = styled(Stack)(({theme, $tints}) => { const isDark = theme.sanity.color.dark const bg = $tints[getTint(isDark)].hex - const fg = $tints[isDark ? 900 : 50].hex + const fg = $tints[isDark ? 950 : 50].hex + const radius = theme.sanity.radius[2] return css` background-color: ${bg}; border-radius: ${radius}px; --card-fg-color: ${fg}; - padding: 5px 4px; + ` +}) - [data-ui='Text'] { - font-size: 0.75em; - } +const CursorLine = styled.span(({theme, $tints}) => { + const isDark = theme.sanity.color.dark + const bg = $tints[getTint(isDark)].hex + + return css` + border-left: 1px solid transparent; + border-right: 1px solid transparent; + margin-left: -1px; + margin-right: -1px; + pointer-events: none; + position: relative; + word-break: normal; + border-color: ${bg}; + box-sizing: border-box; ` }) interface UserPresenceCursorProps { - children: ReactNode + boundaryElement: HTMLElement | null user: User } -export function UserPresenceCursor(props: UserPresenceCursorProps) { - const {user, children} = props +export function UserPresenceCursor(props: UserPresenceCursorProps): JSX.Element { + const {boundaryElement, user} = props const {tints} = useUserColor(user.id) - const tooltipContent = ( - - - {user.displayName} - - + const popoverContent = ( + + + {user.displayName} + + + } + padding={0} + placement="top" + portal + shadow={0} + > + + ) return ( - - {children} - - - - - - - - - + + + ) } diff --git a/packages/sanity/src/core/form/inputs/PortableText/presence-cursors/usePresenceCursorDecorations.tsx b/packages/sanity/src/core/form/inputs/PortableText/presence-cursors/usePresenceCursorDecorations.tsx index c77d720206a8..3a8135c3b520 100644 --- a/packages/sanity/src/core/form/inputs/PortableText/presence-cursors/usePresenceCursorDecorations.tsx +++ b/packages/sanity/src/core/form/inputs/PortableText/presence-cursors/usePresenceCursorDecorations.tsx @@ -6,12 +6,15 @@ import {useMemo} from 'react' import {useChildPresence} from '../../../studio/contexts/Presence' import {UserPresenceCursor} from './UserPresenceCursor' -interface PresenceCursorDecorationsHookProps { +export interface PresenceCursorDecorationsHookProps { + boundaryElement: HTMLElement | null path: Path } -export function usePresenceCursorDecorations(props: PresenceCursorDecorationsHookProps) { - const {path} = props +export function usePresenceCursorDecorations( + props: PresenceCursorDecorationsHookProps, +): RangeDecoration[] { + const {boundaryElement, path} = props const childPresence = useChildPresence(path) return useMemo((): RangeDecoration[] => { @@ -26,13 +29,13 @@ export function usePresenceCursorDecorations(props: PresenceCursorDecorationsHoo if (isRange) return null return { - component: ({children}) => ( - {children} + component: () => ( + ), selection: presence?.selection, } }) as RangeDecoration[] return decorations.filter(Boolean) - }, [childPresence]) + }, [boundaryElement, childPresence]) }