Skip to content

Commit

Permalink
fix(core): collapsed range decorations (#6568)
Browse files Browse the repository at this point in the history
* fix(portable-text-editor): fix loop issue with collapsed decorator

* fix(portable-text-editor): remove unused hook dep

* test(core): presence cursors

* doc(portable-text-editor): add comment

---------

Co-authored-by: Per-Kristian Nordnes <[email protected]>
  • Loading branch information
hermanwikner and skogsmaskin authored May 6, 2024
1 parent 77654c1 commit 70ab283
Show file tree
Hide file tree
Showing 6 changed files with 199 additions and 20 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,7 @@ export const PortableTextEditable = forwardRef(function PortableTextEditable(
// debug('Unsubscribing to changes$')
sub.unsubscribe()
}
}, [change$, restoreSelectionFromProps, syncRangeDecorations])
}, [change$, restoreSelectionFromProps])

// Restore selection from props when it changes
useEffect(() => {
Expand Down Expand Up @@ -591,6 +591,10 @@ export const PortableTextEditable = forwardRef(function PortableTextEditable(
const result = rangeDecorationState.filter((item) => {
// Special case in order to only return one decoration for collapsed ranges
if (SlateRange.isCollapsed(item)) {
// Collapsed ranges should only be decorated if they are on a block child level (length 2)
if (path.length !== 2) {
return false
}
return Path.equals(item.focus.path, path) && Path.equals(item.anchor.path, path)
}
// Include decorations that either include or intersects with this path
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/* eslint-disable max-nested-callbacks */
import {expect, test} from '@playwright/experimental-ct-react'
import {type Page} from '@playwright/test'
import {type SanityDocument} from '@sanity/client'
import {type FormNodePresence} from 'sanity'

import {testHelpers} from '../../../utils/testHelpers'
import {PresenceCursorsStory} from './PresenceCursorsStory'

const TEXT = 'Hello, this is some text in the editor.'

const DOCUMENT: SanityDocument = {
_id: '123',
_type: 'test',
_createdAt: new Date().toISOString(),
_updatedAt: new Date().toISOString(),
_rev: '123',
body: [
{
_type: 'block',
_key: 'a',
children: [{_type: 'span', _key: 'a1', text: TEXT}],
markDefs: [],
},
],
}

const offset1 = TEXT.indexOf('this is')
const offset2 = TEXT.indexOf('some text')

const PRESENCE: FormNodePresence[] = [
{
path: ['body', 'text'],
lastActiveAt: new Date().toISOString(),
sessionId: 'session-A',
selection: {
anchor: {offset: offset1, path: [{_key: 'a'}, 'children', {_key: 'a1'}]},
focus: {offset: offset1, path: [{_key: 'a'}, 'children', {_key: 'a1'}]},
backward: false,
},
user: {
id: 'user-A',
displayName: 'User A',
},
},
{
path: ['body', 'text'],
lastActiveAt: new Date().toISOString(),
sessionId: 'session-B',
selection: {
anchor: {offset: offset2, path: [{_key: 'a'}, 'children', {_key: 'a1'}]},
focus: {offset: offset2, path: [{_key: 'a'}, 'children', {_key: 'a1'}]},
backward: false,
},
user: {
id: 'user-B',
displayName: 'User B',
},
},
]

async function getSiblingTextContent(page: Page) {
return await page.evaluate(() => {
const cursorA = document.querySelector('[data-testid="presence-cursor-User-A"]')
const cursorB = document.querySelector('[data-testid="presence-cursor-User-B"]')

return {
cursorA: cursorA?.nextElementSibling?.textContent,
cursorB: cursorB?.nextElementSibling?.textContent,
}
})
}

test.describe('Portable Text Input', () => {
test.describe('Presence Cursors', () => {
test('should keep position when inserting text in the editor', async ({mount, page}) => {
const {getFocusedPortableTextEditor, insertPortableText} = testHelpers({page})

await mount(<PresenceCursorsStory document={DOCUMENT} presence={PRESENCE} />)

const editor$ = await getFocusedPortableTextEditor('field-body')
const $cursorA = editor$.getByTestId('presence-cursor-User-A')
const $cursorB = editor$.getByTestId('presence-cursor-User-B')

await expect($cursorA).toBeVisible()
await expect($cursorB).toBeVisible()

const siblingContentA = await getSiblingTextContent(page)
expect(siblingContentA.cursorA).toBe('this is ')
expect(siblingContentA.cursorB).toBe('some text in the editor.')

await insertPortableText('INSERTED TEXT. ', editor$)

// Make sure that the cursors keep their position after inserting text
const siblingContentB = await getSiblingTextContent(page)
expect(siblingContentB.cursorA).toBe('this is ')
expect(siblingContentB.cursorB).toBe('some text in the editor.')
})

test.skip('should keep position when deleting text in the editor', async () => {
// todo
})

test.skip('should keep position when pasting text i the editor', async () => {
// todo
})

test.skip('should change position when updating the selection in the editor', async () => {
// todo
})
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import {defineArrayMember, defineField, defineType, type SanityDocument} from '@sanity/types'
import {type FormNodePresence} from 'sanity'

import {TestForm} from '../../utils/TestForm'
import {TestWrapper} from '../../utils/TestWrapper'

const schemaTypes = [
defineType({
type: 'document',
name: 'test',
title: 'Test',
fields: [
defineField({
type: 'array',
name: 'body',
of: [
defineArrayMember({
type: 'block',
}),
],
}),
],
}),
]

interface PresenceCursorsStoryProps {
presence: FormNodePresence[]
document: SanityDocument
}

export function PresenceCursorsStory(props: PresenceCursorsStoryProps) {
const {document, presence} = props

return (
<TestWrapper schemaTypes={schemaTypes}>
<TestForm document={document} presence={presence} />
</TestWrapper>
)
}
34 changes: 23 additions & 11 deletions packages/sanity/playwright-ct/tests/formBuilder/utils/TestForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
EMPTY_ARRAY,
FormBuilder,
type FormBuilderProps,
type FormNodePresence,
getExpandOperations,
type PatchEvent,
setAtPath,
Expand All @@ -21,6 +22,7 @@ import {
} from 'sanity'

import {applyAll} from '../../../../src/core/form/patch/applyPatch'
import {PresenceProvider} from '../../../../src/core/form/studio/contexts/Presence'
import {type FormDocumentValue} from '../../../../src/core/form/types'
import {createMockSanityClient} from '../../mocks/createMockSanityClient'

Expand All @@ -32,17 +34,23 @@ declare global {
}
}

export function TestForm({
focusPath: focusPathFromProps,
onPathFocus: onPathFocusFromProps,
document: documentFromProps,
id: idFromProps = 'root',
}: {
interface TestFormProps {
focusPath?: Path
onPathFocus?: (path: Path) => void
document?: SanityDocument
id?: string
}) {
presence?: FormNodePresence[]
}

export function TestForm(props: TestFormProps) {
const {
document: documentFromProps,
focusPath: focusPathFromProps,
id: idFromProps = 'root',
onPathFocus: onPathFocusFromProps,
presence: presenceFromProps = EMPTY_ARRAY,
} = props

const [validation, setValidation] = useState<ValidationMarker[]>([])
const [openPath, onSetOpenPath] = useState<Path>([])
const [fieldGroupState, onSetFieldGroupState] = useState<StateTree<string>>()
Expand Down Expand Up @@ -106,7 +114,7 @@ export function TestForm({
comparisonValue: null,
fieldGroupState,
openPath,
presence: EMPTY_ARRAY,
presence: presenceFromProps,
validation,
value: document,
})
Expand Down Expand Up @@ -199,7 +207,7 @@ export function TestForm({
onSetFieldSetCollapsed: handleOnSetCollapsedFieldSet,
onSetPathCollapsed: handleOnSetCollapsedPath,
path: EMPTY_ARRAY,
presence: EMPTY_ARRAY,
presence: presenceFromProps,
schemaType: formState?.schemaType || schemaType,
validation,
value: formState?.value as FormDocumentValue,
Expand All @@ -220,13 +228,17 @@ export function TestForm({
handleSetActiveFieldGroup,
idFromProps,
patchChannel,
presenceFromProps,
schemaType,
setOpenPath,
validation,
],
)

return <FormBuilder {...formBuilderProps} />
return (
<PresenceProvider presence={presenceFromProps}>
<FormBuilder {...formBuilderProps} />
</PresenceProvider>
)
}

async function validateStaticDocument(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ import {type SanityClient} from '@sanity/client'
import {Card, LayerProvider, studioTheme, ThemeProvider, ToastProvider} from '@sanity/ui'
import {type ReactNode, Suspense, useEffect, useState} from 'react'
import {
ColorSchemeProvider,
ResourceCacheProvider,
type SchemaTypeDefinition,
SourceProvider,
UserColorManagerProvider,
type Workspace,
WorkspaceProvider,
} from 'sanity'
Expand Down Expand Up @@ -57,13 +59,17 @@ export const TestWrapper = ({
<WorkspaceProvider workspace={mockWorkspace}>
<ResourceCacheProvider>
<SourceProvider source={mockWorkspace.unstable_sources[0]}>
<PaneLayout height="fill">
<Pane id="test-pane">
<PaneContent>
<Card padding={3}>{children}</Card>
</PaneContent>
</Pane>
</PaneLayout>
<ColorSchemeProvider>
<UserColorManagerProvider>
<PaneLayout height="fill">
<Pane id="test-pane">
<PaneContent>
<Card padding={3}>{children}</Card>
</PaneContent>
</Pane>
</PaneLayout>
</UserColorManagerProvider>
</ColorSchemeProvider>
</SourceProvider>
</ResourceCacheProvider>
</WorkspaceProvider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
getTheme_v2,
} from '@sanity/ui/theme'
import {AnimatePresence, motion, type Transition, type Variants} from 'framer-motion'
import {useCallback, useState} from 'react'
import {useCallback, useMemo, useState} from 'react'
import {css, styled} from 'styled-components'

import {useUserColor} from '../../../../user-color/hooks'
Expand Down Expand Up @@ -119,10 +119,16 @@ export function UserPresenceCursor(props: UserPresenceCursorProps): JSX.Element
const handleMouseEnter = useCallback(() => setHovered(true), [])
const handleMouseLeave = useCallback(() => setHovered(false), [])

const testId = useMemo(
() => `presence-cursor-${user.displayName?.split(' ').join('-')}`,
[user.displayName],
)

return (
<CursorLine
$tints={tints}
contentEditable={false}
data-testid={testId}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
Expand Down

0 comments on commit 70ab283

Please sign in to comment.