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

fix(core): collapsed range decorations #6568

Merged
merged 4 commits into from
May 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
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>
)
}
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
Loading