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

feat: add controls provider to render controls outside slate context #5459

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
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
5 changes: 5 additions & 0 deletions .changeset/dirty-swans-allow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'slate-react': minor
---

Add a ToolbarProvider component to render toolbar outside the slate context.
102 changes: 102 additions & 0 deletions packages/slate-react/src/components/provider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { Descendant } from 'slate'
import { FocusedContext } from '../hooks/use-focused'
import { useIsomorphicLayoutEffect } from '../hooks/use-isomorphic-layout-effect'
import { SlateContext, SlateContextValue } from '../hooks/use-slate'
import {
useSelectorContext,
SlateSelectorContext,
} from '../hooks/use-slate-selector'
import { EditorContext } from '../hooks/use-slate-static'
import { ReactEditor } from '../plugin/react-editor'
import { REACT_MAJOR_VERSION } from '../utils/environment'
import { EDITOR_TO_ON_CHANGE } from '../utils/weak-maps'

/**
* A wrapper around the provider to handle `onChange` events, because the editor
* is a mutable singleton so it won't ever register as "changed" otherwise.
*/
export const Provider = (props: {
editor: ReactEditor
context: SlateContextValue
children: React.ReactNode
onChange?: (value: Descendant[]) => void
setContext: React.Dispatch<SlateContextValue>
}) => {
const { editor, context, children, onChange, setContext } = props
const unmountRef = useRef(false)

const {
selectorContext,
onChange: handleSelectorChange,
} = useSelectorContext(editor)

const onContextChange = useCallback(() => {
if (onChange) {
onChange(editor.children)
}

setContext(prevContext => ({
v: prevContext.v + 1,
editor,
}))
handleSelectorChange(editor)
}, [editor, handleSelectorChange, onChange])

useEffect(() => {
const onChangesSet = EDITOR_TO_ON_CHANGE.get(editor) ?? new Set()

onChangesSet.add(onContextChange)

EDITOR_TO_ON_CHANGE.set(editor, onChangesSet)

return () => {
const onChangesSet = EDITOR_TO_ON_CHANGE.get(editor) ?? new Set()

onChangesSet.delete(onContextChange)

EDITOR_TO_ON_CHANGE.set(editor, onChangesSet)
unmountRef.current = true
}
}, [editor, onContextChange])

const [isFocused, setIsFocused] = useState(ReactEditor.isFocused(editor))

useEffect(() => {
setIsFocused(ReactEditor.isFocused(editor))
}, [editor])

useIsomorphicLayoutEffect(() => {
const fn = () => setIsFocused(ReactEditor.isFocused(editor))
if (REACT_MAJOR_VERSION >= 17) {
// In React >= 17 onFocus and onBlur listen to the focusin and focusout events during the bubbling phase.
// Therefore in order for <Editable />'s handlers to run first, which is necessary for ReactEditor.isFocused(editor)
// to return the correct value, we have to listen to the focusin and focusout events without useCapture here.
document.addEventListener('focusin', fn)
document.addEventListener('focusout', fn)
return () => {
document.removeEventListener('focusin', fn)
document.removeEventListener('focusout', fn)
}
} else {
document.addEventListener('focus', fn, true)
document.addEventListener('blur', fn, true)
return () => {
document.removeEventListener('focus', fn, true)
document.removeEventListener('blur', fn, true)
}
}
}, [])

return (
<SlateSelectorContext.Provider value={selectorContext}>
<SlateContext.Provider value={context}>
<EditorContext.Provider value={context.editor}>
<FocusedContext.Provider value={isFocused}>
{children}
</FocusedContext.Provider>
</EditorContext.Provider>
</SlateContext.Provider>
</SlateSelectorContext.Provider>
)
}
89 changes: 10 additions & 79 deletions packages/slate-react/src/components/slate.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,9 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import React from 'react'
import { Descendant, Editor, Node, Scrubber } from 'slate'
import { FocusedContext } from '../hooks/use-focused'
import { useIsomorphicLayoutEffect } from '../hooks/use-isomorphic-layout-effect'
import { SlateContext, SlateContextValue } from '../hooks/use-slate'
import {
useSelectorContext,
SlateSelectorContext,
} from '../hooks/use-slate-selector'
import { EditorContext } from '../hooks/use-slate-static'
import { SlateContextValue } from '../hooks/use-slate'
import { ReactEditor } from '../plugin/react-editor'
import { REACT_MAJOR_VERSION } from '../utils/environment'
import { EDITOR_TO_ON_CHANGE } from '../utils/weak-maps'

/**
* A wrapper around the provider to handle `onChange` events, because the editor
* is a mutable singleton so it won't ever register as "changed" otherwise.
*/
import { Provider } from './provider'

export const Slate = (props: {
editor: ReactEditor
Expand All @@ -24,7 +12,6 @@ export const Slate = (props: {
onChange?: (value: Descendant[]) => void
}) => {
const { editor, children, onChange, initialValue, ...rest } = props
const unmountRef = useRef(false)

const [context, setContext] = React.useState<SlateContextValue>(() => {
if (!Node.isNodeList(initialValue)) {
Expand All @@ -44,69 +31,13 @@ export const Slate = (props: {
return { v: 0, editor }
})

const {
selectorContext,
onChange: handleSelectorChange,
} = useSelectorContext(editor)

const onContextChange = useCallback(() => {
if (onChange) {
onChange(editor.children)
}

setContext(prevContext => ({
v: prevContext.v + 1,
editor,
}))
handleSelectorChange(editor)
}, [editor, handleSelectorChange, onChange])

useEffect(() => {
EDITOR_TO_ON_CHANGE.set(editor, onContextChange)

return () => {
EDITOR_TO_ON_CHANGE.set(editor, () => {})
unmountRef.current = true
}
}, [editor, onContextChange])

const [isFocused, setIsFocused] = useState(ReactEditor.isFocused(editor))

useEffect(() => {
setIsFocused(ReactEditor.isFocused(editor))
}, [editor])

useIsomorphicLayoutEffect(() => {
const fn = () => setIsFocused(ReactEditor.isFocused(editor))
if (REACT_MAJOR_VERSION >= 17) {
// In React >= 17 onFocus and onBlur listen to the focusin and focusout events during the bubbling phase.
// Therefore in order for <Editable />'s handlers to run first, which is necessary for ReactEditor.isFocused(editor)
// to return the correct value, we have to listen to the focusin and focusout events without useCapture here.
document.addEventListener('focusin', fn)
document.addEventListener('focusout', fn)
return () => {
document.removeEventListener('focusin', fn)
document.removeEventListener('focusout', fn)
}
} else {
document.addEventListener('focus', fn, true)
document.addEventListener('blur', fn, true)
return () => {
document.removeEventListener('focus', fn, true)
document.removeEventListener('blur', fn, true)
}
}
}, [])

return (
<SlateSelectorContext.Provider value={selectorContext}>
<SlateContext.Provider value={context}>
<EditorContext.Provider value={context.editor}>
<FocusedContext.Provider value={isFocused}>
{children}
</FocusedContext.Provider>
</EditorContext.Provider>
</SlateContext.Provider>
</SlateSelectorContext.Provider>
<Provider
editor={editor}
context={context}
onChange={onChange}
children={children}
setContext={setContext}
/>
)
}
31 changes: 31 additions & 0 deletions packages/slate-react/src/components/toolbar-provider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import React from 'react'
import { Editor, Scrubber } from 'slate'
import { SlateContextValue } from '../hooks/use-slate'
import { ReactEditor } from '../plugin/react-editor'

import { Provider } from './provider'

export const ToolbarProvider = (props: {
editor: ReactEditor
children: React.ReactNode
}) => {
const { editor, children } = props

const [context, setContext] = React.useState<SlateContextValue>(() => {
if (!Editor.isEditor(editor)) {
throw new Error(
`[Slate] editor is invalid! You passed: ${Scrubber.stringify(editor)}`
)
}
return { v: 0, editor }
})

return (
<Provider
editor={editor}
context={context}
children={children}
setContext={setContext}
/>
)
}
1 change: 1 addition & 0 deletions packages/slate-react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export {
export { DefaultElement } from './components/element'
export { DefaultLeaf } from './components/leaf'
export { Slate } from './components/slate'
export { ToolbarProvider } from './components/toolbar-provider'

// Hooks
export { useEditor } from './hooks/use-editor'
Expand Down
4 changes: 2 additions & 2 deletions packages/slate-react/src/plugin/with-react.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,8 +338,8 @@ export const withReact = <T extends BaseEditor>(
maybeBatchUpdates(() => {
const onContextChange = EDITOR_TO_ON_CHANGE.get(e)

if (onContextChange) {
onContextChange()
if (onContextChange?.size) {
onContextChange.forEach(callback => callback())
}

onChange(options)
Expand Down
2 changes: 1 addition & 1 deletion packages/slate-react/src/utils/weak-maps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export const EDITOR_TO_USER_SELECTION: WeakMap<
* Weak map for associating the context `onChange` context with the plugin.
*/

export const EDITOR_TO_ON_CHANGE = new WeakMap<Editor, () => void>()
export const EDITOR_TO_ON_CHANGE = new WeakMap<Editor, Set<() => void>>()

/**
* Weak maps for saving pending state on composition stage.
Expand Down
Loading