Skip to content

Commit bdcea25

Browse files
committedMay 12, 2023
fix(form/inputs): improve tracking of formState focusPath for PT-input (#4466)
* fix(desk): DocumentPane must be keyed on id The document id must be part of the key here, or focusPath isn't propery reset when changing documents. * fix(form/inputs): improve tracking of formState focusPath for PT-input This will improve tracking of the formState focus in the PT-input. It can now restore focus on single spans of text. Also improved how we restore the editor selection when toggling fullscreen. Renamed hook from useScrollToOpenendMember to useTrackFocusPath as it covers more what it does. Made some small adjustments so that we scroll to both object blocks and text spans. * fix(portable-text-editor): emit selection after focused and unchanged If the selection is the same as when focus was lost, the editor will not emit a new one because there is no onChange done to the editor instance. Explicitly emit the selection here in that case.
1 parent 5780487 commit bdcea25

File tree

6 files changed

+69
-95
lines changed

6 files changed

+69
-95
lines changed
 

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

+10-1
Original file line numberDiff line numberDiff line change
@@ -284,8 +284,17 @@ export const PortableTextEditable = forwardRef(function PortableTextEditable(
284284
)
285285

286286
const handleOnFocus = useCallback(() => {
287+
const selection = PortableTextEditor.getSelection(portableTextEditor)
287288
change$.next({type: 'focus'})
288-
}, [change$])
289+
const newSelection = PortableTextEditor.getSelection(portableTextEditor)
290+
// If the selection is the same, emit it explicitly here as there is no actual onChange event triggered.
291+
if (selection === newSelection) {
292+
change$.next({
293+
type: 'selection',
294+
selection,
295+
})
296+
}
297+
}, [change$, portableTextEditor])
289298

290299
const handleOnBlur = useCallback(() => {
291300
change$.next({type: 'blur'})

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

+14-18
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {RenderBlockActionsCallback} from './types'
2222
import {Editor} from './Editor'
2323
import {ExpandedLayer, Root} from './Compositor.styles'
2424
import {useHotkeys} from './hooks/useHotKeys'
25-
import {useScrollToOpenedMember} from './hooks/useScrollToOpenedMember'
25+
import {useTrackFocusPath} from './hooks/useTrackFocusPath'
2626

2727
interface InputProps extends ArrayOfObjectsInputProps<PortableTextBlock> {
2828
hasFocus: boolean
@@ -45,7 +45,6 @@ export type PortableTextEditorElement = HTMLDivElement | HTMLSpanElement | null
4545
export function Compositor(props: Omit<InputProps, 'schemaType' | 'arrayFunctions'>) {
4646
const {
4747
changed,
48-
focused,
4948
focusPath = EMPTY_ARRAY,
5049
hasFocus,
5150
hotkeys,
@@ -94,18 +93,15 @@ export function Compositor(props: Omit<InputProps, 'schemaType' | 'arrayFunction
9493
const _renderBlockActions = !!value && renderBlockActions ? renderBlockActions : undefined
9594
const _renderCustomMarkers = !!value && renderCustomMarkers ? renderCustomMarkers : undefined
9695

97-
const initialSelection = useMemo(
98-
(): EditorSelection => {
99-
return focusPath.length > 0
100-
? {
101-
anchor: {path: focusPath, offset: 0},
102-
focus: {path: focusPath, offset: 0},
103-
}
104-
: null
105-
},
96+
const initialSelection = useMemo((): EditorSelection => {
97+
return focusPath.length > 0
98+
? {
99+
anchor: {path: focusPath, offset: 0},
100+
focus: {path: focusPath, offset: 0},
101+
}
102+
: null
106103
// eslint-disable-next-line react-hooks/exhaustive-deps
107-
[] // Only initial
108-
)
104+
}, []) // only initial!
109105

110106
const [portalElement, setPortalElement] = useState<HTMLDivElement | null>(null)
111107

@@ -318,15 +314,15 @@ export function Compositor(props: Omit<InputProps, 'schemaType' | 'arrayFunction
318314

319315
// Keep only stable ones here!
320316
[
321-
editorHotkeys,
322-
handleToggleFullscreen,
323317
hasFocus,
318+
editorHotkeys,
324319
initialSelection,
325320
isActive,
326321
isFullscreen,
327-
onCopy,
328322
onItemOpen,
323+
onCopy,
329324
onPaste,
325+
handleToggleFullscreen,
330326
path,
331327
readOnly,
332328
renderAnnotation,
@@ -349,7 +345,7 @@ export function Compositor(props: Omit<InputProps, 'schemaType' | 'arrayFunction
349345
)
350346

351347
// Scroll to the DOM element of the "opened" portable text member when relevant.
352-
useScrollToOpenedMember({
348+
useTrackFocusPath({
353349
editorRootPath: path,
354350
focusPath,
355351
boundaryElement: boundaryElement || undefined,
@@ -361,7 +357,7 @@ export function Compositor(props: Omit<InputProps, 'schemaType' | 'arrayFunction
361357
<ActivateOnFocus onActivate={onActivate} isOverlayActive={!isActive}>
362358
<ChangeIndicator
363359
disabled={isFullscreen}
364-
hasFocus={Boolean(focused)}
360+
hasFocus={hasFocus}
365361
isChanged={changed}
366362
path={path}
367363
>

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

+16-7
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
usePortableTextEditor,
1313
RenderStyleFunction,
1414
RenderListItemFunction,
15+
usePortableTextEditorSelection,
1516
} from '@sanity/portable-text-editor'
1617
import {Path} from '@sanity/types'
1718
import {BoundaryElementProvider, useBoundaryElement, useGlobalKeyDown, useLayer} from '@sanity/ui'
@@ -86,6 +87,7 @@ export function Editor(props: EditorProps) {
8687
const {isTopLayer} = useLayer()
8788
const editableRef = useRef<HTMLDivElement | null>(null)
8889
const editor = usePortableTextEditor()
90+
const selection = usePortableTextEditorSelection()
8991

9092
const {element: boundaryElement} = useBoundaryElement()
9193

@@ -109,6 +111,20 @@ export function Editor(props: EditorProps) {
109111

110112
const scrollSelectionIntoView = useScrollSelectionIntoView(scrollElement)
111113

114+
// Restore the React editor selection and focus when toggling fullscreen
115+
// Note that the selection itself is not part of the dependencies here (use the last known from the PTE instance)
116+
useEffect(() => {
117+
if (selection) {
118+
PortableTextEditor.select(editor, selection)
119+
}
120+
if (hasFocus) {
121+
PortableTextEditor.focus(editor)
122+
} else {
123+
PortableTextEditor.blur(editor)
124+
}
125+
// eslint-disable-next-line react-hooks/exhaustive-deps
126+
}, [editor, isFullscreen]) // skip selection dep.
127+
112128
const editable = useMemo(
113129
() => (
114130
<PortableTextEditable
@@ -151,13 +167,6 @@ export function Editor(props: EditorProps) {
151167
[editor, onItemOpen, path]
152168
)
153169

154-
// Focus the editor if we have focus and the editor is re-rendered (toggling fullscreen for instance)
155-
useEffect(() => {
156-
if (hasFocus) {
157-
PortableTextEditor.focus(editor)
158-
}
159-
}, [editor, hasFocus])
160-
161170
return (
162171
<Root $fullscreen={isFullscreen} data-testid="pt-editor">
163172
{isActive && (

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

+11-50
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ import React, {
2222
} from 'react'
2323
import {Subject} from 'rxjs'
2424
import {Box, useToast} from '@sanity/ui'
25-
import scrollIntoView from 'scroll-into-view-if-needed'
2625
import {debounce} from 'lodash'
2726
import {FormPatch, SANITY_PATCH_TYPE} from '../../patch'
2827
import {ArrayOfObjectsItemMember, ObjectFormNode} from '../../store'
@@ -100,33 +99,18 @@ export function PortableTextInput(props: PortableTextInputProps) {
10099

101100
const {subscribe} = usePatches({path})
102101
const editorRef = useRef<PortableTextEditor | null>(null)
103-
const [hasFocus, setHasFocus] = useState(false)
104102
const [ignoreValidationError, setIgnoreValidationError] = useState(false)
105103
const [invalidValue, setInvalidValue] = useState<InvalidValue | null>(null)
106104
const [isFullscreen, setIsFullscreen] = useState(false)
107105
const [isActive, setIsActive] = useState(false)
108106

109-
// Set as active whenever we have focus inside the editor.
110-
useEffect(() => {
111-
if (focused || hasFocus || focusPath.length) {
112-
setIsActive(true)
113-
}
114-
}, [hasFocus, focusPath, focused])
107+
// Let formState decide if we are focused or not.
108+
const hasFocus = Boolean(focused) || focusPath.length > 0
115109

116-
// Set focused if the focusPath includes the path
110+
// Set active if focused
117111
useEffect(() => {
118-
if (focused) {
112+
if (hasFocus) {
119113
setIsActive(true)
120-
setHasFocus(true)
121-
}
122-
}, [focused])
123-
124-
// Scroll into view when focused
125-
useEffect(() => {
126-
if (hasFocus && innerElementRef.current) {
127-
scrollIntoView(innerElementRef.current, {
128-
scrollMode: 'if-needed',
129-
})
130114
}
131115
}, [hasFocus])
132116

@@ -253,24 +237,20 @@ export function PortableTextInput(props: PortableTextInputProps) {
253237
return items
254238
}, [members, props])
255239

256-
const hasOpenItem = useMemo(() => {
257-
return portableTextMemberItems.some((item) => item.member.open)
258-
}, [portableTextMemberItems])
259-
260240
// Sets the focusPath from editor selection (when typing, moving the cursor, clicking around)
261241
// This doesn't need to be immediate, so debounce it as it impacts performance.
262242
const setFocusPathDebounced = useMemo(
263243
() =>
264244
debounce(
265245
(sel: EditorSelection) => {
266-
if (sel && hasFocus) {
246+
if (sel) {
267247
onPathFocus(sel.focus.path)
268248
}
269249
},
270250
500,
271-
{trailing: true, leading: false}
251+
{trailing: true, leading: true}
272252
),
273-
[hasFocus, onPathFocus]
253+
[onPathFocus]
274254
)
275255

276256
// Handle editor changes
@@ -284,10 +264,7 @@ export function PortableTextInput(props: PortableTextInputProps) {
284264
setFocusPathDebounced(change.selection)
285265
break
286266
case 'focus':
287-
setHasFocus(true)
288-
break
289-
case 'blur':
290-
setHasFocus(false)
267+
setIsActive(true)
291268
break
292269
case 'undo':
293270
case 'redo':
@@ -335,23 +312,11 @@ export function PortableTextInput(props: PortableTextInputProps) {
335312
const handleActivate = useCallback((): void => {
336313
if (!isActive) {
337314
setIsActive(true)
338-
if (!hasFocus) {
339-
if (editorRef.current) {
340-
PortableTextEditor.focus(editorRef.current)
341-
}
342-
setHasFocus(true)
315+
if (editorRef.current) {
316+
PortableTextEditor.focus(editorRef.current)
343317
}
344318
}
345-
}, [hasFocus, isActive])
346-
347-
// If the editor that has an opened item and isn't focused - scroll to the input if needed.
348-
useEffect(() => {
349-
if (!hasFocus && hasOpenItem && innerElementRef.current) {
350-
scrollIntoView(innerElementRef.current, {
351-
scrollMode: 'if-needed',
352-
})
353-
}
354-
}, [hasFocus, hasOpenItem])
319+
}, [isActive])
355320

356321
return (
357322
<Box ref={innerElementRef}>
@@ -370,8 +335,6 @@ export function PortableTextInput(props: PortableTextInputProps) {
370335
>
371336
<Compositor
372337
{...props}
373-
focused={focused}
374-
focusPath={focusPath}
375338
hasFocus={hasFocus}
376339
hotkeys={hotkeys}
377340
isActive={isActive}
@@ -382,10 +345,8 @@ export function PortableTextInput(props: PortableTextInputProps) {
382345
onInsert={onInsert}
383346
onPaste={onPaste}
384347
onToggleFullscreen={handleToggleFullscreen}
385-
readOnly={readOnly}
386348
renderBlockActions={renderBlockActions}
387349
renderCustomMarkers={renderCustomMarkers}
388-
value={value}
389350
/>
390351
</PortableTextEditor>
391352
</PortableTextMemberItemsProvider>

‎packages/sanity/src/core/form/inputs/PortableText/hooks/useScrollToOpenedMember.tsx ‎packages/sanity/src/core/form/inputs/PortableText/hooks/useTrackFocusPath.tsx

+17-18
Original file line numberDiff line numberDiff line change
@@ -11,38 +11,37 @@ interface Props {
1111
onItemClose: () => void
1212
}
1313

14-
// This hook will scroll to the "opened" portable text object's editor dom node.
15-
// If the opened item is a regular text block, place the cursor there as well.
16-
export function useScrollToOpenedMember(props: Props): void {
14+
// This hook will track the focusPath and make sure editor content is visible and focused accordingly.
15+
export function useTrackFocusPath(props: Props): void {
1716
const {focusPath, editorRootPath, boundaryElement, onItemClose} = props
1817
const portableTextMemberItems = usePortableTextMemberItems()
1918
const editor = usePortableTextEditor()
2019

2120
useEffect(() => {
22-
// Don't do anything if there isn't focus
21+
// Don't do anything if no internal focusPath
2322
if (focusPath.length === 0) {
2423
return
2524
}
26-
// Find the highest opened member item and scroll to it
25+
// Find the most specific opened member item and scroll to it
2726
const memberItem = portableTextMemberItems
2827
.filter((item) => item.member.open)
2928
.sort((a, b) => b.member.item.path.length - a.member.item.path.length)[0]
30-
if (memberItem && memberItem.elementRef?.current && memberItem.member.open) {
31-
scrollIntoView(memberItem.elementRef.current, {
32-
boundary: boundaryElement,
33-
scrollMode: 'if-needed',
29+
if (memberItem && memberItem.elementRef?.current) {
30+
if (boundaryElement) {
31+
// Scroll the boundary element into view
32+
scrollIntoView(boundaryElement, {
33+
scrollMode: 'if-needed',
34+
})
35+
}
36+
// Make a selection in the editor
37+
PortableTextEditor.select(editor, {
38+
anchor: {path: focusPath, offset: 0},
39+
focus: {path: focusPath, offset: 0},
3440
})
35-
// If this is a text block (and not a child within), place the cursor in the beginning of it.
36-
const isChildPath = focusPath.slice(editorRootPath.length).length > 1
37-
if (memberItem.kind === 'textBlock' && !isChildPath) {
38-
const relativePath = memberItem.member.item.path.slice(editorRootPath.length).slice(0, 1)
41+
if (memberItem.kind === 'textBlock') {
42+
PortableTextEditor.focus(editor)
3943
// "auto-close" regular text blocks or they get sticky here when trying to focus on an other field
4044
// There is no natural way of closing them (however opening something else would close them)
41-
PortableTextEditor.select(editor, {
42-
anchor: {path: relativePath, offset: 0},
43-
focus: {path: relativePath, offset: 0},
44-
})
45-
PortableTextEditor.focus(editor)
4645
onItemClose()
4746
}
4847
}

‎packages/sanity/src/desk/panes/document/DocumentPane.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ function DocumentPaneInner(props: DocumentPaneProviderProps) {
141141
<DocumentPaneProvider
142142
// this needs to be here to avoid formState from being re-used across (incompatible) document types
143143
// see https://github.com/sanity-io/sanity/discussions/3794 for a description of the problem
144-
key={documentType}
144+
key={`${documentType}-${options.id}`}
145145
{...providerProps}
146146
>
147147
{/* NOTE: this is a temporary location for this provider until we */}

0 commit comments

Comments
 (0)
Failed to load comments.