From ff1b5ecce97069d965e5609a7d5913a1d1de490b Mon Sep 17 00:00:00 2001 From: Yauheni Date: Wed, 21 Jun 2023 17:17:08 +0200 Subject: [PATCH 1/4] feat: add controls provider to render controls outside slate context --- .../src/components/controls-provider.tsx | 31 ++ .../slate-react/src/components/provider.tsx | 102 +++++++ packages/slate-react/src/components/slate.tsx | 89 +----- packages/slate-react/src/index.ts | 1 + packages/slate-react/src/plugin/with-react.ts | 4 +- packages/slate-react/src/utils/weak-maps.ts | 2 +- site/examples/richtext-detached-controls.tsx | 284 ++++++++++++++++++ site/pages/examples/[example].tsx | 6 + 8 files changed, 437 insertions(+), 82 deletions(-) create mode 100644 packages/slate-react/src/components/controls-provider.tsx create mode 100644 packages/slate-react/src/components/provider.tsx create mode 100644 site/examples/richtext-detached-controls.tsx diff --git a/packages/slate-react/src/components/controls-provider.tsx b/packages/slate-react/src/components/controls-provider.tsx new file mode 100644 index 0000000000..86541a083a --- /dev/null +++ b/packages/slate-react/src/components/controls-provider.tsx @@ -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 ControlsProvider = (props: { + editor: ReactEditor + children: React.ReactNode +}) => { + const { editor, children } = props + + const [context, setContext] = React.useState(() => { + if (!Editor.isEditor(editor)) { + throw new Error( + `[Slate] editor is invalid! You passed: ${Scrubber.stringify(editor)}` + ) + } + return { v: 0, editor } + }) + + return ( + + ) +} diff --git a/packages/slate-react/src/components/provider.tsx b/packages/slate-react/src/components/provider.tsx new file mode 100644 index 0000000000..0be4c69e98 --- /dev/null +++ b/packages/slate-react/src/components/provider.tsx @@ -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 { IS_REACT_VERSION_17_OR_ABOVE } 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 +}) => { + 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 (IS_REACT_VERSION_17_OR_ABOVE) { + // In React >= 17 onFocus and onBlur listen to the focusin and focusout events during the bubbling phase. + // Therefore in order for '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 ( + + + + + {children} + + + + + ) +} diff --git a/packages/slate-react/src/components/slate.tsx b/packages/slate-react/src/components/slate.tsx index 83204940f5..621e691f45 100644 --- a/packages/slate-react/src/components/slate.tsx +++ b/packages/slate-react/src/components/slate.tsx @@ -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 @@ -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(() => { if (!Node.isNodeList(initialValue)) { @@ -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 '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 ( - - - - - {children} - - - - + ) } diff --git a/packages/slate-react/src/index.ts b/packages/slate-react/src/index.ts index d8cb331f21..becfdbf7ba 100644 --- a/packages/slate-react/src/index.ts +++ b/packages/slate-react/src/index.ts @@ -10,6 +10,7 @@ export { export { DefaultElement } from './components/element' export { DefaultLeaf } from './components/leaf' export { Slate } from './components/slate' +export { ControlsProvider } from './components/controls-provider' // Hooks export { useEditor } from './hooks/use-editor' diff --git a/packages/slate-react/src/plugin/with-react.ts b/packages/slate-react/src/plugin/with-react.ts index 15a8d70654..1fc9378271 100644 --- a/packages/slate-react/src/plugin/with-react.ts +++ b/packages/slate-react/src/plugin/with-react.ts @@ -338,8 +338,8 @@ export const withReact = ( maybeBatchUpdates(() => { const onContextChange = EDITOR_TO_ON_CHANGE.get(e) - if (onContextChange) { - onContextChange() + if (onContextChange?.size) { + onContextChange.forEach(callback => callback()) } onChange(options) diff --git a/packages/slate-react/src/utils/weak-maps.ts b/packages/slate-react/src/utils/weak-maps.ts index acad54176f..fb592e17eb 100644 --- a/packages/slate-react/src/utils/weak-maps.ts +++ b/packages/slate-react/src/utils/weak-maps.ts @@ -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 void>() +export const EDITOR_TO_ON_CHANGE = new WeakMap void>>() /** * Weak maps for saving pending state on composition stage. diff --git a/site/examples/richtext-detached-controls.tsx b/site/examples/richtext-detached-controls.tsx new file mode 100644 index 0000000000..d52b0883c8 --- /dev/null +++ b/site/examples/richtext-detached-controls.tsx @@ -0,0 +1,284 @@ +import React, { useCallback, useMemo } from 'react' +import isHotkey from 'is-hotkey' +import { + Editable, + withReact, + useSlate, + Slate, + ControlsProvider, +} from 'slate-react' +import { + Editor, + Transforms, + createEditor, + Descendant, + Element as SlateElement, +} from 'slate' +import { withHistory } from 'slate-history' + +import { Button, Icon, Toolbar } from '../components' + +const HOTKEYS = { + 'mod+b': 'bold', + 'mod+i': 'italic', + 'mod+u': 'underline', + 'mod+`': 'code', +} + +const LIST_TYPES = ['numbered-list', 'bulleted-list'] +const TEXT_ALIGN_TYPES = ['left', 'center', 'right', 'justify'] + +const RichTextDetachedControlsExample = () => { + const renderElement = useCallback(props => , []) + const renderLeaf = useCallback(props => , []) + const editor = useMemo(() => withHistory(withReact(createEditor())), []) + + return ( + <> + + + + + + + + + + + + + + + + + + + + { + for (const hotkey in HOTKEYS) { + if (isHotkey(hotkey, event as any)) { + event.preventDefault() + const mark = HOTKEYS[hotkey] + toggleMark(editor, mark) + } + } + }} + /> + + + ) +} + +const toggleBlock = (editor, format) => { + const isActive = isBlockActive( + editor, + format, + TEXT_ALIGN_TYPES.includes(format) ? 'align' : 'type' + ) + const isList = LIST_TYPES.includes(format) + + Transforms.unwrapNodes(editor, { + match: n => + !Editor.isEditor(n) && + SlateElement.isElement(n) && + LIST_TYPES.includes(n.type) && + !TEXT_ALIGN_TYPES.includes(format), + split: true, + }) + let newProperties: Partial + if (TEXT_ALIGN_TYPES.includes(format)) { + newProperties = { + align: isActive ? undefined : format, + } + } else { + newProperties = { + type: isActive ? 'paragraph' : isList ? 'list-item' : format, + } + } + Transforms.setNodes(editor, newProperties) + + if (!isActive && isList) { + const block = { type: format, children: [] } + Transforms.wrapNodes(editor, block) + } +} + +const toggleMark = (editor, format) => { + const isActive = isMarkActive(editor, format) + + if (isActive) { + Editor.removeMark(editor, format) + } else { + Editor.addMark(editor, format, true) + } +} + +const isBlockActive = (editor, format, blockType = 'type') => { + const { selection } = editor + if (!selection) return false + + const [match] = Array.from( + Editor.nodes(editor, { + at: Editor.unhangRange(editor, selection), + match: n => + !Editor.isEditor(n) && + SlateElement.isElement(n) && + n[blockType] === format, + }) + ) + + return !!match +} + +const isMarkActive = (editor, format) => { + const marks = Editor.marks(editor) + return marks ? marks[format] === true : false +} + +const Element = ({ attributes, children, element }) => { + const style = { textAlign: element.align } + switch (element.type) { + case 'block-quote': + return ( +
+ {children} +
+ ) + case 'bulleted-list': + return ( +
    + {children} +
+ ) + case 'heading-one': + return ( +

+ {children} +

+ ) + case 'heading-two': + return ( +

+ {children} +

+ ) + case 'list-item': + return ( +
  • + {children} +
  • + ) + case 'numbered-list': + return ( +
      + {children} +
    + ) + default: + return ( +

    + {children} +

    + ) + } +} + +const Leaf = ({ attributes, children, leaf }) => { + if (leaf.bold) { + children = {children} + } + + if (leaf.code) { + children = {children} + } + + if (leaf.italic) { + children = {children} + } + + if (leaf.underline) { + children = {children} + } + + return {children} +} + +const BlockButton = ({ format, icon }) => { + const editor = useSlate() + return ( + + ) +} + +const MarkButton = ({ format, icon }) => { + const editor = useSlate() + return ( + + ) +} + +const initialValue: Descendant[] = [ + { + type: 'paragraph', + children: [ + { text: 'This is editable ' }, + { text: 'rich', bold: true }, + { text: ' text, ' }, + { text: 'much', italic: true }, + { text: ' better than a ' }, + { text: '