Skip to content

Commit

Permalink
feat(pte): create new text blocks if needed (#6560)
Browse files Browse the repository at this point in the history
* feat(pte): create new text blocks if needed

* chore(pte): add tests
  • Loading branch information
pedrobonamin authored May 6, 2024
1 parent 3b3125c commit cadd496
Show file tree
Hide file tree
Showing 6 changed files with 554 additions and 3 deletions.
28 changes: 28 additions & 0 deletions packages/@sanity/portable-text-editor/src/editor/Editable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
import {
type BaseRange,
Editor,
Node,
type NodeEntry,
type Operation,
Path,
Expand Down Expand Up @@ -50,6 +51,7 @@ import {
type ScrollSelectionIntoViewFunction,
} from '../types/editor'
import {type HotkeyOptions} from '../types/options'
import {type SlateTextBlock, type VoidElement} from '../types/slate'
import {debugWithName} from '../utils/debug'
import {moveRangeByOperation, toPortableTextRange, toSlateRange} from '../utils/ranges'
import {normalizeSelection} from '../utils/selection'
Expand Down Expand Up @@ -118,6 +120,7 @@ export const PortableTextEditable = forwardRef(function PortableTextEditable(
onBeforeInput,
onPaste,
onCopy,
onClick,
rangeDecorations,
renderAnnotation,
renderBlock,
Expand Down Expand Up @@ -444,6 +447,30 @@ export const PortableTextEditable = forwardRef(function PortableTextEditable(
[onFocus, portableTextEditor, change$, slateEditor],
)

const handleClick = useCallback(
(event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
if (onClick) {
onClick(event)
}
// Inserts a new block if it's clicking on the editor, focused on the last block and it's a void element
if (slateEditor.selection && event.target === event.currentTarget) {
const [lastBlock, path] = Node.last(slateEditor, [])
const focusPath = slateEditor.selection.focus.path.slice(0, 1)
const lastPath = path.slice(0, 1)
if (Path.equals(focusPath, lastPath)) {
const node = Node.descendant(slateEditor, path.slice(0, 1)) as
| SlateTextBlock
| VoidElement
if (lastBlock && Editor.isVoid(slateEditor, node)) {
Transforms.insertNodes(slateEditor, slateEditor.pteCreateEmptyBlock())
slateEditor.onChange()
}
}
}
},
[onClick, slateEditor],
)

const handleOnBlur: FocusEventHandler<HTMLDivElement> = useCallback(
(event) => {
if (onBlur) {
Expand Down Expand Up @@ -630,6 +657,7 @@ export const PortableTextEditable = forwardRef(function PortableTextEditable(
decorate={decorate}
onBlur={handleOnBlur}
onCopy={handleCopy}
onClick={handleClick}
onDOMBeforeInput={handleOnBeforeInput}
onFocus={handleOnFocus}
onKeyDown={handleKeyDown}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import {describe, expect, it, jest} from '@jest/globals'
import {fireEvent, render, waitFor} from '@testing-library/react'
import {createRef, type RefObject} from 'react'

import {PortableTextEditor} from '../PortableTextEditor'
import {PortableTextEditorTester, schemaType} from './PortableTextEditorTester'
import {getEditableElement} from './utils'

describe('adds empty text block if its needed', () => {
const newBlock = {
_type: 'myTestBlockType',
_key: '3',
style: 'normal',
markDefs: [],
children: [
{
_type: 'span',
_key: '2',
text: '',
marks: [],
},
],
}
it('adds a new block at the bottom, when clicking on the portable text editor, because the only block is void and user is focused on that one', async () => {
const initialValue = [
{
_key: 'b',
_type: 'someObject',
},
]

const initialSelection = {
focus: {path: [{_key: 'b'}], offset: 0},
anchor: {path: [{_key: 'b'}], offset: 0},
}

const editorRef: RefObject<PortableTextEditor> = createRef()
const onChange = jest.fn()
const component = render(
<PortableTextEditorTester
onChange={onChange}
ref={editorRef}
schemaType={schemaType}
value={initialValue}
/>,
)
const element = await getEditableElement(component)

const editor = editorRef.current
const inlineType = editor?.schemaTypes.inlineObjects.find((t) => t.name === 'someObject')
await waitFor(async () => {
if (editor && inlineType && element) {
PortableTextEditor.focus(editor)
PortableTextEditor.select(editor, initialSelection)
fireEvent.click(element)
expect(PortableTextEditor.getValue(editor)).toEqual([initialValue[0], newBlock])
}
})
})
it('should not add blocks if the last element is a text block', async () => {
const initialValue = [
{
_key: 'b',
_type: 'someObject',
},
{
_type: 'myTestBlockType',
_key: '3',
style: 'normal',
markDefs: [],
children: [
{
_type: 'span',
_key: '2',
text: '',
marks: [],
},
],
},
]

const initialSelection = {
focus: {path: [{_key: 'b'}], offset: 0},
anchor: {path: [{_key: 'b'}], offset: 0},
}

const editorRef: RefObject<PortableTextEditor> = createRef()
const onChange = jest.fn()
const component = render(
<PortableTextEditorTester
onChange={onChange}
ref={editorRef}
schemaType={schemaType}
value={initialValue}
/>,
)
const element = await getEditableElement(component)

const editor = editorRef.current
const inlineType = editor?.schemaTypes.inlineObjects.find((t) => t.name === 'someObject')
await waitFor(async () => {
if (editor && inlineType && element) {
PortableTextEditor.focus(editor)
PortableTextEditor.select(editor, initialSelection)
fireEvent.click(element)
expect(PortableTextEditor.getValue(editor)).toEqual(initialValue)
}
})
})
it('should not add blocks if the last element is void, but its not focused on that one', async () => {
const initialValue = [
{
_key: 'a',
_type: 'someObject',
},
{
_type: 'myTestBlockType',
_key: 'b',
style: 'normal',
markDefs: [],
children: [
{
_type: 'span',
_key: 'b1',
text: '',
marks: [],
},
],
},
{
_key: 'c',
_type: 'someObject',
},
]

const initialSelection = {
focus: {path: [{_key: 'b'}, 'children', {_key: 'b1'}], offset: 2},
anchor: {path: [{_key: 'b'}, 'children', {_key: 'b1'}], offset: 2},
}

const editorRef: RefObject<PortableTextEditor> = createRef()
const onChange = jest.fn()
const component = render(
<PortableTextEditorTester
onChange={onChange}
ref={editorRef}
schemaType={schemaType}
value={initialValue}
/>,
)
const element = await getEditableElement(component)

const editor = editorRef.current
const inlineType = editor?.schemaTypes.inlineObjects.find((t) => t.name === 'someObject')
await waitFor(async () => {
if (editor && inlineType && element) {
PortableTextEditor.focus(editor)
PortableTextEditor.select(editor, initialSelection)
fireEvent.click(element)
expect(PortableTextEditor.getValue(editor)).toEqual(initialValue)
}
})
})
it('should not add blocks if the last element is void, and its focused on that one when clicking', async () => {
const initialValue = [
{
_key: 'a',
_type: 'someObject',
},
{
_type: 'myTestBlockType',
_key: 'b',
style: 'normal',
markDefs: [],
children: [
{
_type: 'span',
_key: 'b1',
text: '',
marks: [],
},
],
},
{
_key: 'c',
_type: 'someObject',
},
]

const initialSelection = {
focus: {path: [{_key: 'c'}], offset: 0},
anchor: {path: [{_key: 'c'}], offset: 0},
}

const editorRef: RefObject<PortableTextEditor> = createRef()
const onChange = jest.fn()
const component = render(
<PortableTextEditorTester
onChange={onChange}
ref={editorRef}
schemaType={schemaType}
value={initialValue}
/>,
)
const element = await getEditableElement(component)

const editor = editorRef.current
const inlineType = editor?.schemaTypes.inlineObjects.find((t) => t.name === 'someObject')
await waitFor(async () => {
if (editor && inlineType && element) {
PortableTextEditor.focus(editor)
PortableTextEditor.select(editor, initialSelection)
fireEvent.click(element)
expect(PortableTextEditor.getValue(editor)).toEqual(initialValue.concat(newBlock))
}
})
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// This utils are inspired from https://github.dev/mwood23/slate-test-utils/blob/master/src/buildTestHarness.tsx
import {act, fireEvent, type render} from '@testing-library/react'
import {parseHotkey} from 'is-hotkey-esm'

export async function triggerKeyboardEvent(hotkey: string, element: Element): Promise<void> {
return act(async () => {
const eventProps = parseHotkey(hotkey)
const values = hotkey.split('+')

fireEvent(
element,
new window.KeyboardEvent('keydown', {
key: values[values.length - 1],
code: `${eventProps.which}`,
keyCode: eventProps.which,
bubbles: true,
...eventProps,
}),
)
})
}

export async function getEditableElement(component: ReturnType<typeof render>): Promise<Element> {
await act(async () => component)
const element = component.container.querySelector('[data-slate-editor="true"]')
if (!element) {
throw new Error('Could not find element')
}
/**
* Manually add this because JSDom doesn't implement this and Slate checks for it
* internally before doing stuff.
*
* https://github.com/jsdom/jsdom/issues/1670
*/
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
element.isContentEditable = true
return element
}
Loading

0 comments on commit cadd496

Please sign in to comment.