Skip to content

Commit f655b5c

Browse files
authoredFeb 21, 2024
feat(form/inputs): control PortableTextEditor instance via ref (#5793)
* feat(form/inputs): PortableTextInput can now use a editor ref This will allow us to control the PortableTextEditor from the outside. * feat(portable-text-editor): support setting editorRef through props This allows for setting the editorRef from the outside. If not provided, a internal one will be used as before. * test(playwright-ct): add test for new editorRef prop for PortableTextInput
1 parent adb43b0 commit f655b5c

File tree

5 files changed

+103
-36
lines changed

5 files changed

+103
-36
lines changed
 

‎packages/@sanity/portable-text-editor/src/editor/PortableTextEditor.tsx

+9-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
type PortableTextObject,
1010
type SpanSchemaType,
1111
} from '@sanity/types'
12-
import {Component, type PropsWithChildren} from 'react'
12+
import {Component, type MutableRefObject, type PropsWithChildren} from 'react'
1313
import {Subject} from 'rxjs'
1414

1515
import {
@@ -80,6 +80,11 @@ export type PortableTextEditorProps = PropsWithChildren<{
8080
* Backward compatibility (renamed to patches$).
8181
*/
8282
incomingPatches$?: PatchObservable
83+
84+
/**
85+
* A ref to the editor instance
86+
*/
87+
editorRef?: MutableRefObject<PortableTextEditor | null>
8388
}>
8489

8590
/**
@@ -129,6 +134,9 @@ export class PortableTextEditor extends Component<PortableTextEditorProps> {
129134
: compileType(this.props.schemaType),
130135
)
131136
}
137+
if (this.props.editorRef !== prevProps.editorRef && this.props.editorRef) {
138+
this.props.editorRef.current = this
139+
}
132140
}
133141

134142
public setEditable = (editable: EditableAPI) => {

‎packages/sanity/playwright-ct/tests/formBuilder/inputs/PortableText/Input.spec.tsx

+20-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import {expect, test} from '@playwright/experimental-ct-react'
2+
import {type PortableTextEditor} from '@sanity/portable-text-editor'
3+
import {type RefObject} from 'react'
24

35
import {testHelpers} from '../../../utils/testHelpers'
4-
import InputStory from './InputStory'
6+
import {InputStory} from './InputStory'
57

68
test.describe('Portable Text Input', () => {
79
test.describe('Activation', () => {
@@ -41,4 +43,21 @@ test.describe('Portable Text Input', () => {
4143
await expect($placeholder).not.toBeVisible()
4244
})
4345
})
46+
47+
test.describe('Editor Ref', () => {
48+
test(`Editor can be controlled from outside the Input using the editorRef prop`, async ({
49+
mount,
50+
page,
51+
}) => {
52+
let ref: undefined | RefObject<PortableTextEditor | null>
53+
const getRef = (editorRef: RefObject<PortableTextEditor | null>) => {
54+
ref = editorRef
55+
}
56+
await mount(<InputStory getRef={getRef} />)
57+
const $editor = page.getByTestId('pt-input-with-editor-ref')
58+
await expect($editor).toBeVisible()
59+
// If the ref has .schemaTypes.block, it means the editorRef was set correctly
60+
expect(ref?.current?.schemaTypes.block).toBeDefined()
61+
})
62+
})
4463
})

‎packages/sanity/playwright-ct/tests/formBuilder/inputs/PortableText/InputStory.tsx

+47-18
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,62 @@
1+
import {type PortableTextEditor} from '@sanity/portable-text-editor'
12
import {defineArrayMember, defineField, defineType} from '@sanity/types'
3+
import {createRef, type RefObject, useMemo, useState} from 'react'
4+
import {type InputProps, type PortableTextInputProps} from 'sanity'
25

36
import {TestForm} from '../../utils/TestForm'
47
import {TestWrapper} from '../../utils/TestWrapper'
58

6-
const SCHEMA_TYPES = [
7-
defineType({
8-
type: 'document',
9-
name: 'test',
10-
title: 'Test',
11-
fields: [
12-
defineField({
13-
type: 'array',
14-
name: 'body',
15-
of: [
16-
defineArrayMember({
17-
type: 'block',
9+
export function InputStory(props: {
10+
getRef?: (editorRef: RefObject<PortableTextEditor | null>) => void
11+
}) {
12+
// Use a state as ref here to be make sure we are able to call the ref callback when
13+
// the ref is ready
14+
const [editorRef, setEditorRef] = useState<RefObject<PortableTextEditor | null>>({current: null})
15+
if (props.getRef && editorRef.current) {
16+
props.getRef(editorRef)
17+
}
18+
19+
const schemaTypes = useMemo(
20+
() => [
21+
defineType({
22+
type: 'document',
23+
name: 'test',
24+
title: 'Test',
25+
fields: [
26+
defineField({
27+
type: 'array',
28+
name: 'body',
29+
of: [
30+
defineArrayMember({
31+
type: 'block',
32+
}),
33+
],
34+
components: {
35+
input: (inputProps: InputProps) => {
36+
const editorProps = {
37+
...inputProps,
38+
editorRef: createRef(),
39+
} as PortableTextInputProps
40+
if (editorProps.editorRef) {
41+
setEditorRef(editorProps.editorRef)
42+
}
43+
return (
44+
<div data-testid="pt-input-with-editor-ref">
45+
{inputProps.renderDefault(editorProps)}
46+
</div>
47+
)
48+
},
49+
},
1850
}),
1951
],
2052
}),
2153
],
22-
}),
23-
]
54+
[],
55+
)
2456

25-
export function InputStory() {
2657
return (
27-
<TestWrapper schemaTypes={SCHEMA_TYPES}>
58+
<TestWrapper schemaTypes={schemaTypes}>
2859
<TestForm />
2960
</TestWrapper>
3061
)
3162
}
32-
33-
export default InputStory

‎packages/sanity/src/core/form/inputs/PortableText/PortableTextInput.tsx

+17-15
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ export interface PortableTextMemberItem {
6060
*/
6161
export function PortableTextInput(props: PortableTextInputProps) {
6262
const {
63+
editorRef: editorRefProp,
6364
elementProps,
6465
hotkeys,
6566
markers = EMPTY_ARRAY,
@@ -78,8 +79,12 @@ export function PortableTextInput(props: PortableTextInputProps) {
7879
} = props
7980

8081
const {onBlur} = elementProps
82+
const defaultEditorRef = useRef<PortableTextEditor | null>(null)
83+
const editorRef = editorRefProp || defaultEditorRef
8184

82-
// Make the PTE focusable from the outside
85+
// This handle will allow for natively calling .focus
86+
// on the returned component and have the PortableTextEditor focused,
87+
// simulating a native input element (like with an string input)
8388
useImperativeHandle(elementProps.ref, () => ({
8489
focus() {
8590
if (editorRef.current) {
@@ -89,7 +94,6 @@ export function PortableTextInput(props: PortableTextInputProps) {
8994
}))
9095

9196
const {subscribe} = usePatches({path})
92-
const editorRef = useRef<PortableTextEditor | null>(null)
9397
const [ignoreValidationError, setIgnoreValidationError] = useState(false)
9498
const [invalidValue, setInvalidValue] = useState<InvalidValue | null>(null)
9599
const [isFullscreen, setIsFullscreen] = useState(false)
@@ -110,17 +114,15 @@ export function PortableTextInput(props: PortableTextInputProps) {
110114
const innerElementRef = useRef<HTMLDivElement | null>(null)
111115

112116
const handleToggleFullscreen = useCallback(() => {
113-
if (editorRef.current) {
114-
setIsFullscreen((v) => {
115-
const next = !v
116-
if (next) {
117-
telemetry.log(PortableTextInputExpanded)
118-
} else {
119-
telemetry.log(PortableTextInputCollapsed)
120-
}
121-
return next
122-
})
123-
}
117+
setIsFullscreen((v) => {
118+
const next = !v
119+
if (next) {
120+
telemetry.log(PortableTextInputExpanded)
121+
} else {
122+
telemetry.log(PortableTextInputCollapsed)
123+
}
124+
return next
125+
})
124126
}, [telemetry])
125127

126128
// Reset invalidValue if new value is coming in from props
@@ -253,7 +255,7 @@ export function PortableTextInput(props: PortableTextInputProps) {
253255
PortableTextEditor.focus(editorRef.current)
254256
}
255257
}
256-
}, [isActive])
258+
}, [editorRef, isActive])
257259

258260
return (
259261
<Box ref={innerElementRef}>
@@ -262,10 +264,10 @@ export function PortableTextInput(props: PortableTextInputProps) {
262264
<PortableTextMarkersProvider markers={markers}>
263265
<PortableTextMemberItemsProvider memberItems={portableTextMemberItems}>
264266
<PortableTextEditor
265-
ref={editorRef}
266267
patches$={patches$}
267268
onChange={handleEditorChange}
268269
maxBlocks={undefined} // TODO: from schema?
270+
ref={editorRef}
269271
readOnly={isOffline || readOnly}
270272
schemaType={schemaType}
271273
value={value}

‎packages/sanity/src/core/form/types/inputProps.ts

+10-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import {type HotkeyOptions, type OnCopyFn, type OnPasteFn} from '@sanity/portable-text-editor'
1+
import {
2+
type HotkeyOptions,
3+
type OnCopyFn,
4+
type OnPasteFn,
5+
type PortableTextEditor,
6+
} from '@sanity/portable-text-editor'
27
import {
38
type ArraySchemaType,
49
type BooleanSchemaType,
@@ -488,6 +493,10 @@ export type PrimitiveInputProps = StringInputProps | BooleanInputProps | NumberI
488493
* */
489494
export interface PortableTextInputProps
490495
extends ArrayOfObjectsInputProps<PortableTextBlock, ArraySchemaType<PortableTextBlock>> {
496+
/**
497+
* A React Ref that can reference the underlying editor instance
498+
*/
499+
editorRef?: React.MutableRefObject<PortableTextEditor | null>
491500
/**
492501
* Assign hotkeys that can be attached to custom editing functions
493502
*/

0 commit comments

Comments
 (0)
Please sign in to comment.