Skip to content

Commit

Permalink
fix(core): presence cursor
Browse files Browse the repository at this point in the history
  • Loading branch information
hermanwikner committed Apr 8, 2024
1 parent 52e1bfa commit 6c27d08
Show file tree
Hide file tree
Showing 4 changed files with 129 additions and 73 deletions.
18 changes: 15 additions & 3 deletions packages/sanity/src/core/form/inputs/PortableText/Compositor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ interface InputProps extends ArrayOfObjectsInputProps<PortableTextBlock> {
onActivate: () => void
onCopy?: OnCopyFn
onPaste?: OnPasteFn
onPortalElementChange?: (el: HTMLDivElement | null) => void
onToggleFullscreen: () => void
path: Path
rangeDecorations?: RangeDecoration[]
Expand Down Expand Up @@ -62,10 +63,11 @@ export function Compositor(props: Omit<InputProps, 'schemaType' | 'arrayFunction
onItemRemove,
onPaste,
onPathFocus,
onPortalElementChange,
onToggleFullscreen,
path,
readOnly,
rangeDecorations,
readOnly,
renderAnnotation,
renderBlock,
renderBlockActions,
Expand Down Expand Up @@ -384,6 +386,15 @@ export function Compositor(props: Omit<InputProps, 'schemaType' | 'arrayFunction
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []) // Only at mount time!

const handleSetPortalElement = useCallback(
(el: HTMLDivElement | null) => {
setPortalElement(el)

onPortalElementChange?.(el)
},
[onPortalElementChange],
)

const editorNode = useMemo(
() => (
<Editor
Expand All @@ -402,7 +413,7 @@ export function Compositor(props: Omit<InputProps, 'schemaType' | 'arrayFunction
renderAnnotation={editorRenderAnnotation}
renderBlock={editorRenderBlock}
renderChild={editorRenderChild}
setPortalElement={setPortalElement}
setPortalElement={handleSetPortalElement}
scrollElement={scrollElement}
setScrollElement={setScrollElement}
/>
Expand All @@ -411,6 +422,7 @@ export function Compositor(props: Omit<InputProps, 'schemaType' | 'arrayFunction
// Keep only stable ones here!
[
ariaDescribedBy,
initialSelection,
editorHotkeys,
isActive,
isFullscreen,
Expand All @@ -424,7 +436,7 @@ export function Compositor(props: Omit<InputProps, 'schemaType' | 'arrayFunction
editorRenderAnnotation,
editorRenderBlock,
editorRenderChild,
initialSelection,
handleSetPortalElement,
scrollElement,
],
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,10 @@ import {PortableTextMarkersProvider} from './contexts/PortableTextMarkers'
import {PortableTextMemberItemsProvider} from './contexts/PortableTextMembers'
import {usePortableTextMemberItemsFromProps} from './hooks/usePortableTextMembers'
import {InvalidValue as RespondToInvalidContent} from './InvalidValue'
import {usePresenceCursorDecorations} from './presence-cursors'
import {
type PresenceCursorDecorationsHookProps,
usePresenceCursorDecorations,
} from './presence-cursors'
import {usePatches} from './usePatches'

/** @internal */
Expand Down Expand Up @@ -87,10 +90,18 @@ export function PortableTextInput(props: PortableTextInputProps) {
const {onBlur} = elementProps
const defaultEditorRef = useRef<PortableTextEditor | null>(null)
const editorRef = editorRefProp || defaultEditorRef
const innerElementRef = useRef<HTMLDivElement | null>(null)
const [currentPortalElement, setCurrentPortalElement] = useState<HTMLElement | null>(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,
Expand Down Expand Up @@ -121,8 +132,6 @@ export function PortableTextInput(props: PortableTextInputProps) {
}> = useMemo(() => new Subject(), [])
const patches$ = useMemo(() => patchSubject.asObservable(), [patchSubject])

const innerElementRef = useRef<HTMLDivElement | null>(null)

const handleToggleFullscreen = useCallback(() => {
setIsFullscreen((v) => {
const next = !v
Expand Down Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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)<StyledProps>(({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)<StyledProps>(({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)<StyledProps>(({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<StyledProps>(({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 = (
<TooltipContentBox $tints={tints} sizing="border">
<Text size={1} weight="medium">
{user.displayName}
</Text>
</TooltipContentBox>
const popoverContent = (
<Tooltip
content={
<TooltipContent $tints={tints} padding={1}>
<Text size={1} weight="medium">
{user.displayName}
</Text>
</TooltipContent>
}
padding={0}
placement="top"
portal
shadow={0}
>
<Dot $tints={tints} contentEditable={false} />
</Tooltip>
)

return (
<RootSpan>
{children}

<RelativeSpan contentEditable={false}>
<UITooltip
content={tooltipContent}
padding={0}
placement="top"
portal
radius={4}
shadow={0}
>
<DotSpan contentEditable={false} $tints={tints} />
</UITooltip>

<CursorSpan contentEditable={false} $tints={tints} />
</RelativeSpan>
</RootSpan>
<DotPopover
$tints={tints}
content={popoverContent}
contentEditable={false}
floatingBoundary={boundaryElement}
open
placement="top"
portal
referenceBoundary={boundaryElement}
shadow={0}
>
<CursorLine $tints={tints} contentEditable={false} />
</DotPopover>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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[] => {
Expand All @@ -26,13 +29,13 @@ export function usePresenceCursorDecorations(props: PresenceCursorDecorationsHoo
if (isRange) return null

return {
component: ({children}) => (
<UserPresenceCursor user={presence.user}>{children}</UserPresenceCursor>
component: () => (
<UserPresenceCursor boundaryElement={boundaryElement} user={presence.user} />
),
selection: presence?.selection,
}
}) as RangeDecoration[]

return decorations.filter(Boolean)
}, [childPresence])
}, [boundaryElement, childPresence])
}

0 comments on commit 6c27d08

Please sign in to comment.