Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(core): implement presence cursors #6081

Merged
merged 36 commits into from
Apr 26, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
4056387
feat(portable-text-editor): improve range decorations perf by compari…
skogsmaskin Apr 22, 2024
7911308
test(portable-text-editor): add tests for range decorations rendering
skogsmaskin Apr 22, 2024
fe4a016
fixup! feat(portable-text-editor): improve range decorations perf by …
skogsmaskin Apr 22, 2024
7babe0f
test(portable-text-editor): improve some minor test things
skogsmaskin Apr 22, 2024
11d9fde
fix(core): update z-index on PTE activate overlay
hermanwikner Apr 11, 2024
91f5ff2
feat(core): extend presence data model with `selection`
hermanwikner Apr 11, 2024
175fc86
feat(structure): include `selection` in presence data
hermanwikner Apr 11, 2024
df92de1
feat(core): implement presence cursors in PTE
hermanwikner Apr 11, 2024
295455c
test(core): add presence cursor workshop story
hermanwikner Apr 11, 2024
25a5e24
refactor(core): use `getTheme_v2` to access theme values
hermanwikner Apr 16, 2024
0df4e98
fix(core): introduce `OnPathFocusPayload` to improve typing
hermanwikner Apr 16, 2024
c82d9fd
fix(form/inputs): perf optimization for PT-input decorators
skogsmaskin Apr 17, 2024
def0d46
refactor(form/inputs): also display user presence when user is select…
skogsmaskin Apr 17, 2024
6256217
fix(core): render presence cursors inline instead of in a portal
hermanwikner Apr 18, 2024
b8ec79c
fix(core): broken workshop story
hermanwikner Apr 18, 2024
8e72405
feat(core): reset presence selection on blur in PTE
hermanwikner Apr 18, 2024
8f1fe3b
fix(structure): add accidentially removed setFocusPath (DocumentPaneP…
skogsmaskin Apr 18, 2024
a8e21ca
fix(core): use `useFormFieldPresence` in `usePresenceCursorDecorations`
hermanwikner Apr 18, 2024
6d87a9e
fix(core): set `focusPath` when receiving a mutation event if there a…
hermanwikner Apr 18, 2024
1c48355
fix(form/inputs): break don't return
skogsmaskin Apr 18, 2024
7f4e25f
fix(form/inputs): remove ref that should not be set here
skogsmaskin Apr 18, 2024
ce64287
fix(form/inputs): sort hook deps list
skogsmaskin Apr 18, 2024
8d913b4
fix(form/inputs): reconcile presence decorations for PT-Input
skogsmaskin Apr 19, 2024
4852256
fix(form/inputs): return early if focusPath is already selected in PT…
skogsmaskin Apr 19, 2024
1416e9e
refactor(form/inputs): throttle reporting of focusPath and presence u…
skogsmaskin Apr 19, 2024
4edd935
fix(core): import of `FormNodePresence`
hermanwikner Apr 19, 2024
5aa6583
fix(form/inputs): remove lastActive as dep for PT-presence range deco…
skogsmaskin Apr 19, 2024
a30362c
test(core): add presence cursor workshop story
hermanwikner Apr 19, 2024
134d33a
fix(core): remove unnecessary z-index in PTE activate overlay
hermanwikner Apr 19, 2024
b91ccf2
fix(core): prevent presence cursor user name from being selected
hermanwikner Apr 22, 2024
04838dc
fix(form/inputs): fix issue where perf opt on focusPath tracking brok…
skogsmaskin Apr 23, 2024
90634e7
test(core): update snapshots in `Studio.test`
hermanwikner Apr 25, 2024
865fd72
refactor(core): move presence decorations according to user edits
skogsmaskin Apr 25, 2024
4cd93f6
refactor(core/form): remove complexity from PortableTextInput
skogsmaskin Apr 26, 2024
1f8fc66
refactor(structure): announce presence throttled
skogsmaskin Apr 26, 2024
6c8a26c
refactor(core): clean up presence cursors code + add comments
hermanwikner Apr 26, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
feat(core): implement presence cursors in PTE
  • Loading branch information
hermanwikner committed Apr 26, 2024
commit df92de13a80041b848a45189f57e972d4e1006f9
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,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 @@ -64,10 +65,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 @@ -386,6 +388,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 @@ -405,7 +416,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 @@ -429,6 +440,7 @@ export function Compositor(props: Omit<InputProps, 'schemaType' | 'arrayFunction
editorRenderAnnotation,
editorRenderBlock,
editorRenderChild,
handleSetPortalElement,
scrollElement,
],
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import {
type EditorChange,
type EditorSelection,
type InvalidValue,
type Patch as EditorPatch,
type Patch,
PortableTextEditor,
type RangeDecoration,
} from '@sanity/portable-text-editor'
import {useTelemetry} from '@sanity/telemetry/react'
import {isKeySegment, type Path, type PortableTextBlock} from '@sanity/types'
import {isKeySegment, type PortableTextBlock} from '@sanity/types'
import {Box, useToast} from '@sanity/ui'
import {
type MutableRefObject,
Expand All @@ -33,6 +35,10 @@ import {PortableTextMarkersProvider} from './contexts/PortableTextMarkers'
import {PortableTextMemberItemsProvider} from './contexts/PortableTextMembers'
import {usePortableTextMemberItemsFromProps} from './hooks/usePortableTextMembers'
import {InvalidValue as RespondToInvalidContent} from './InvalidValue'
import {
type PresenceCursorDecorationsHookProps,
usePresenceCursorDecorations,
} from './presence-cursors'
import {usePatches} from './usePatches'

/** @internal */
Expand Down Expand Up @@ -73,7 +79,7 @@ export function PortableTextInput(props: PortableTextInputProps): ReactNode {
onPathFocus,
path,
readOnly,
rangeDecorations,
rangeDecorations: rangeDecorationsProp,
renderBlockActions,
renderCustomMarkers,
schemaType,
Expand All @@ -83,6 +89,18 @@ export function PortableTextInput(props: PortableTextInputProps): ReactNode {
const {onBlur, onFocus, ref: elementRef} = 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(
useMemo(
(): PresenceCursorDecorationsHookProps => ({
boundaryElement: currentPortalElement || innerElementRef.current,
path: props.path,
}),
[currentPortalElement, props.path],
),
)

const {subscribe} = usePatches({path})
const [ignoreValidationError, setIgnoreValidationError] = useState(false)
Expand Down Expand Up @@ -145,22 +163,25 @@ export function PortableTextInput(props: PortableTextInputProps): ReactNode {
// It's a bit ugly right here, but it's a rather simple way to support the Presentation tool without
// having to change the PTE's internals.
const setFocusPathFromEditorSelection = useCallback(
(focusPath: Path) => {
(selection: EditorSelection) => {
const focusPath = selection?.focus.path

if (!focusPath) return

// Test if the focusPath is pointing directly to a span
const isSpanPath =
focusPath.length === 3 && // A span path is always 3 segments long
focusPath[1] === 'children' && // Is a child of a block
isKeySegment(focusPath[2]) && // Contains the key of the child
!portableTextMemberItems.some(
(item) => isKeySegment(focusPath[2]) && item.member.key === focusPath[2]._key,
) // Not an inline object (it would be a member in this list, where spans are not). By doing this check we avoid depending on the value.
if (isSpanPath) {
// Append `.text` to the focusPath
onPathFocus(focusPath.concat('text'))
} else {
// Call normally
onPathFocus(focusPath)
}
)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we please have this with explicit comments as it were? It's explaining the "hack".

const nextFocusPath = isSpanPath ? focusPath.concat(['text']) : focusPath

onPathFocus(nextFocusPath, {
selection,
})
},
[onPathFocus, portableTextMemberItems],
)
Expand All @@ -184,7 +205,7 @@ export function PortableTextInput(props: PortableTextInputProps): ReactNode {
// call through startTransition
startTransition(() => {
if (change.selection) {
setFocusPathFromEditorSelection(change.selection.focus.path)
setFocusPathFromEditorSelection(change.selection)
}
})
break
Expand Down Expand Up @@ -251,6 +272,10 @@ export function PortableTextInput(props: PortableTextInputProps): ReactNode {
}
}, [editorRef, isActive])

const rangeDecorations = useMemo((): RangeDecoration[] => {
return [...(rangeDecorationsProp || EMPTY_ARRAY), ...presenceCursorDecorations]
}, [presenceCursorDecorations, rangeDecorationsProp])

return (
<Box ref={elementProps.ref}>
hermanwikner marked this conversation as resolved.
Show resolved Hide resolved
{!ignoreValidationError && respondToInvalidContent}
Expand All @@ -274,10 +299,11 @@ export function PortableTextInput(props: PortableTextInputProps): ReactNode {
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
@@ -0,0 +1,212 @@
import {type ColorTints} from '@sanity/color'
import {type User} from '@sanity/types'
import {
Box,
Flex,
// eslint-disable-next-line no-restricted-imports
Popover,
Text,
} from '@sanity/ui'
import {type Theme} from '@sanity/ui/theme'
import {AnimatePresence, motion, type Transition, type Variants} from 'framer-motion'
import {useCallback, useState} from 'react'
import {css, styled} from 'styled-components'

import {useUserColor} from '../../../../user-color/hooks'

const DEBUG_HOVER_TARGET = false

const DOT_SIZE = 6

const LIGHT_SCHEME_TINT = 500
const DARK_SCHEME_TINT = 400

const CONTENT_BOX_VARIANTS: Variants = {
animate: {opacity: 1, scaleX: 1, scaleY: 1},
exit: {opacity: 0, scaleX: 0, scaleY: 0.5},
initial: {opacity: 0, scaleX: 0, scaleY: 0.5},
}

const CONTENT_BOX_TRANSITION: Transition = {
duration: 0.3,
ease: 'easeInOut',
type: 'spring',
bounce: 0,
}

const CONTENT_TEXT_VARIANTS: Variants = {
animate: {opacity: 1},
exit: {opacity: 0},
initial: {opacity: 0},
}

const CONTENT_TEXT_TRANSITION: Transition = {
duration: 0.2,
delay: 0.15,
}

const getTint = (isDark: boolean) => (isDark ? DARK_SCHEME_TINT : LIGHT_SCHEME_TINT)

interface StyledProps {
theme: Theme
$tints: ColorTints
}

// 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`
background-color: ${bg};
position: relative;
z-index: 0;

// We only want to show the popover (i.e. the dot) on the top of
// the cursor. Therefore, we hide the popover when it's not placed on top
// due to auto-placement.
&:not([data-placement='top']) {
display: none;
}
`
})

const PopoverContentFlex = styled(Flex)<StyledProps>(({theme, $tints}) => {
const isDark = theme.sanity.color.dark
const bg = $tints[getTint(isDark)].hex
const fg = $tints[isDark ? 950 : 50].hex

return css`
position: absolute;

// Increase the hover target area to make it easier
// to make it easier to display the user's name.
width: calc(${DOT_SIZE}px * 2.5);
height: calc(${DOT_SIZE}px * 4);

top: -${DOT_SIZE * 1.5}px;
left: 50%;
transform: translateX(-50%);

--presence-cursor-fg: ${fg};
--presence-cursor-bg: ${bg};

&[data-debug-hover-target='true'] {
outline: 1px solid magenta;
}
`
})

const CursorLine = styled.span<StyledProps>(({theme, $tints}) => {
const isDark = theme.sanity.color.dark
pedrobonamin marked this conversation as resolved.
Show resolved Hide resolved
const bg = $tints[getTint(isDark)].hex

return css`
border-left: 1px solid transparent;
margin-left: -1px;
pointer-events: none;
position: relative;
word-break: normal;
border-color: ${bg};
box-sizing: border-box;
`
})

const CursorDot = styled.div`
background-color: var(--presence-cursor-bg);
border-radius: 50%;
width: ${DOT_SIZE}px;
height: ${DOT_SIZE}px;
`

const UserBox = styled(motion(Box))(({theme}) => {
const radius = theme.sanity.radius[4]

return css`
position: absolute;
top: ${DOT_SIZE * 0.5}px;
left: ${DOT_SIZE * 0.5}px;
transform-origin: left;
white-space: nowrap;
padding: 0.2em 0.25em;
box-sizing: border-box;
border-radius: ${radius}px;
background-color: var(--presence-cursor-bg);
`
})

const UserText = styled(motion(Text))`
color: var(--presence-cursor-fg);
`

interface UserPresenceCursorProps {
boundaryElement: HTMLElement | null
user: User
}

export function UserPresenceCursor(props: UserPresenceCursorProps): JSX.Element {
const {boundaryElement, user} = props
const {tints} = useUserColor(user.id)
const [hovered, setHovered] = useState<boolean>(false)

const handleMouseEnter = useCallback(() => setHovered(true), [])
const handleMouseLeave = useCallback(() => setHovered(false), [])

const popoverContent = (
<PopoverContentFlex
$tints={tints}
align="center"
contentEditable={false}
data-debug-hover-target={DEBUG_HOVER_TARGET}
justify="center"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<CursorDot contentEditable={false} />

<AnimatePresence>
{hovered && (
<UserBox
animate="animate"
exit="exit"
flex={1}
initial="initial"
transition={CONTENT_BOX_TRANSITION}
variants={CONTENT_BOX_VARIANTS}
>
<UserText
animate="animate"
exit="exit"
initial="initial"
size={0}
transition={CONTENT_TEXT_TRANSITION}
variants={CONTENT_TEXT_VARIANTS}
weight="medium"
>
{user.displayName}
</UserText>
</UserBox>
)}
</AnimatePresence>
</PopoverContentFlex>
)

return (
<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
@@ -0,0 +1 @@
export * from './usePresenceCursorDecorations'
Loading