1
0
mirror of https://github.com/ianstormtaylor/slate.git synced 2025-08-13 18:53:59 +02:00

slate-react: MVP for working with non-global window objects (fix for #3819) (#4079)

* mvp implementation for working with non-global window instances

* remove unused element renderer

* fix typo in comment

* fix wrong example reference

* Add @babel/helper-call-delegate to fix build error

Co-authored-by: Lukas Buenger <lukasbuenger@gmail.com>
This commit is contained in:
Sunny Hirai
2021-02-16 19:40:15 -08:00
committed by GitHub
parent 6d66d87f67
commit 513771c82a
9 changed files with 263 additions and 12 deletions

View File

@@ -31,6 +31,7 @@
"devDependencies": { "devDependencies": {
"@babel/cli": "^7.7.4", "@babel/cli": "^7.7.4",
"@babel/core": "^7.7.4", "@babel/core": "^7.7.4",
"@babel/helper-call-delegate": "^7.7.4",
"@babel/plugin-external-helpers": "^7.7.4", "@babel/plugin-external-helpers": "^7.7.4",
"@babel/plugin-proposal-class-properties": "^7.7.4", "@babel/plugin-proposal-class-properties": "^7.7.4",
"@babel/plugin-transform-modules-commonjs": "^7.7.4", "@babel/plugin-transform-modules-commonjs": "^7.7.4",

View File

@@ -29,6 +29,7 @@ import {
DOMElement, DOMElement,
DOMNode, DOMNode,
DOMRange, DOMRange,
getDefaultView,
isDOMElement, isDOMElement,
isDOMNode, isDOMNode,
isDOMText, isDOMText,
@@ -42,6 +43,7 @@ import {
NODE_TO_ELEMENT, NODE_TO_ELEMENT,
IS_FOCUSED, IS_FOCUSED,
PLACEHOLDER_SYMBOL, PLACEHOLDER_SYMBOL,
EDITOR_TO_WINDOW,
} from '../utils/weak-maps' } from '../utils/weak-maps'
// COMPAT: Firefox/Edge Legacy don't support the `beforeinput` event // COMPAT: Firefox/Edge Legacy don't support the `beforeinput` event
@@ -132,7 +134,9 @@ export const Editable = (props: EditableProps) => {
// Update element-related weak maps with the DOM element ref. // Update element-related weak maps with the DOM element ref.
useIsomorphicLayoutEffect(() => { useIsomorphicLayoutEffect(() => {
if (ref.current) { let window
if (ref.current && (window = getDefaultView(ref.current))) {
EDITOR_TO_WINDOW.set(editor, window)
EDITOR_TO_ELEMENT.set(editor, ref.current) EDITOR_TO_ELEMENT.set(editor, ref.current)
NODE_TO_ELEMENT.set(editor, ref.current) NODE_TO_ELEMENT.set(editor, ref.current)
ELEMENT_TO_NODE.set(ref.current, editor) ELEMENT_TO_NODE.set(ref.current, editor)
@@ -144,6 +148,7 @@ export const Editable = (props: EditableProps) => {
// Whenever the editor updates, make sure the DOM selection state is in sync. // Whenever the editor updates, make sure the DOM selection state is in sync.
useIsomorphicLayoutEffect(() => { useIsomorphicLayoutEffect(() => {
const { selection } = editor const { selection } = editor
const window = ReactEditor.getWindow(editor)
const domSelection = window.getSelection() const domSelection = window.getSelection()
if (state.isComposing || !domSelection || !ReactEditor.isFocused(editor)) { if (state.isComposing || !domSelection || !ReactEditor.isFocused(editor)) {
@@ -354,8 +359,9 @@ export const Editable = (props: EditableProps) => {
case 'insertFromYank': case 'insertFromYank':
case 'insertReplacementText': case 'insertReplacementText':
case 'insertText': { case 'insertText': {
if (data instanceof DataTransfer) { const window = ReactEditor.getWindow(editor)
ReactEditor.insertData(editor, data) if (data instanceof window.DataTransfer) {
ReactEditor.insertData(editor, data as DataTransfer)
} else if (typeof data === 'string') { } else if (typeof data === 'string') {
Editor.insertText(editor, data) Editor.insertText(editor, data)
} }
@@ -394,6 +400,7 @@ export const Editable = (props: EditableProps) => {
const onDOMSelectionChange = useCallback( const onDOMSelectionChange = useCallback(
throttle(() => { throttle(() => {
if (!readOnly && !state.isComposing && !state.isUpdatingSelection) { if (!readOnly && !state.isComposing && !state.isUpdatingSelection) {
const window = ReactEditor.getWindow(editor)
const { activeElement } = window.document const { activeElement } = window.document
const el = ReactEditor.toDOMNode(editor, editor) const el = ReactEditor.toDOMNode(editor, editor)
const domSelection = window.getSelection() const domSelection = window.getSelection()
@@ -436,6 +443,7 @@ export const Editable = (props: EditableProps) => {
// fire for any change to the selection inside the editor. (2019/11/04) // fire for any change to the selection inside the editor. (2019/11/04)
// https://github.com/facebook/react/issues/5785 // https://github.com/facebook/react/issues/5785
useIsomorphicLayoutEffect(() => { useIsomorphicLayoutEffect(() => {
const window = ReactEditor.getWindow(editor)
window.document.addEventListener('selectionchange', onDOMSelectionChange) window.document.addEventListener('selectionchange', onDOMSelectionChange)
return () => { return () => {
@@ -527,6 +535,8 @@ export const Editable = (props: EditableProps) => {
return return
} }
const window = ReactEditor.getWindow(editor)
// COMPAT: If the current `activeElement` is still the previous // COMPAT: If the current `activeElement` is still the previous
// one, this is due to the window being blurred when the tab // one, this is due to the window being blurred when the tab
// itself becomes unfocused, so we want to abort early to allow to // itself becomes unfocused, so we want to abort early to allow to
@@ -735,6 +745,7 @@ export const Editable = (props: EditableProps) => {
!isEventHandled(event, attributes.onFocus) !isEventHandled(event, attributes.onFocus)
) { ) {
const el = ReactEditor.toDOMNode(editor, editor) const el = ReactEditor.toDOMNode(editor, editor)
const window = ReactEditor.getWindow(editor)
state.latestElement = window.document.activeElement state.latestElement = window.document.activeElement
// COMPAT: If the editor has nested editable elements, the focus // COMPAT: If the editor has nested editable elements, the focus

View File

@@ -10,6 +10,7 @@ import {
NODE_TO_INDEX, NODE_TO_INDEX,
NODE_TO_KEY, NODE_TO_KEY,
NODE_TO_PARENT, NODE_TO_PARENT,
EDITOR_TO_WINDOW,
} from '../utils/weak-maps' } from '../utils/weak-maps'
import { import {
DOMElement, DOMElement,
@@ -20,6 +21,7 @@ import {
DOMStaticRange, DOMStaticRange,
isDOMElement, isDOMElement,
normalizeDOMPoint, normalizeDOMPoint,
isDOMSelection,
} from '../utils/dom' } from '../utils/dom'
/** /**
@@ -32,6 +34,18 @@ export interface ReactEditor extends Editor {
} }
export const ReactEditor = { export const ReactEditor = {
/**
* Return the host window of the current editor.
*/
getWindow(editor: ReactEditor): Window {
const window = EDITOR_TO_WINDOW.get(editor)
if (!window) {
throw new Error('Unable to find a host window element for this editor')
}
return window
},
/** /**
* Find a key for a Slate node. * Find a key for a Slate node.
*/ */
@@ -104,7 +118,7 @@ export const ReactEditor = {
blur(editor: ReactEditor): void { blur(editor: ReactEditor): void {
const el = ReactEditor.toDOMNode(editor, editor) const el = ReactEditor.toDOMNode(editor, editor)
IS_FOCUSED.set(editor, false) IS_FOCUSED.set(editor, false)
const window = ReactEditor.getWindow(editor)
if (window.document.activeElement === el) { if (window.document.activeElement === el) {
el.blur() el.blur()
} }
@@ -118,6 +132,7 @@ export const ReactEditor = {
const el = ReactEditor.toDOMNode(editor, editor) const el = ReactEditor.toDOMNode(editor, editor)
IS_FOCUSED.set(editor, true) IS_FOCUSED.set(editor, true)
const window = ReactEditor.getWindow(editor)
if (window.document.activeElement !== el) { if (window.document.activeElement !== el) {
el.focus({ preventScroll: true }) el.focus({ preventScroll: true })
} }
@@ -129,6 +144,7 @@ export const ReactEditor = {
deselect(editor: ReactEditor): void { deselect(editor: ReactEditor): void {
const { selection } = editor const { selection } = editor
const window = ReactEditor.getWindow(editor)
const domSelection = window.getSelection() const domSelection = window.getSelection()
if (domSelection && domSelection.rangeCount > 0) { if (domSelection && domSelection.rangeCount > 0) {
@@ -284,6 +300,7 @@ export const ReactEditor = {
? domAnchor ? domAnchor
: ReactEditor.toDOMPoint(editor, focus) : ReactEditor.toDOMPoint(editor, focus)
const window = ReactEditor.getWindow(editor)
const domRange = window.document.createRange() const domRange = window.document.createRange()
const [startNode, startOffset] = isBackward ? domFocus : domAnchor const [startNode, startOffset] = isBackward ? domFocus : domAnchor
const [endNode, endOffset] = isBackward ? domAnchor : domFocus const [endNode, endOffset] = isBackward ? domAnchor : domFocus
@@ -410,6 +427,7 @@ export const ReactEditor = {
// can determine what the offset relative to the text node is. // can determine what the offset relative to the text node is.
if (leafNode) { if (leafNode) {
textNode = leafNode.closest('[data-slate-node="text"]')! textNode = leafNode.closest('[data-slate-node="text"]')!
const window = ReactEditor.getWindow(editor)
const range = window.document.createRange() const range = window.document.createRange()
range.setStart(textNode, 0) range.setStart(textNode, 0)
range.setEnd(nearestNode, nearestOffset) range.setEnd(nearestNode, nearestOffset)
@@ -476,8 +494,7 @@ export const ReactEditor = {
editor: ReactEditor, editor: ReactEditor,
domRange: DOMRange | DOMStaticRange | DOMSelection domRange: DOMRange | DOMStaticRange | DOMSelection
): Range { ): Range {
const el = const el = isDOMSelection(domRange)
domRange instanceof Selection
? domRange.anchorNode ? domRange.anchorNode
: domRange.startContainer : domRange.startContainer
let anchorNode let anchorNode
@@ -487,7 +504,7 @@ export const ReactEditor = {
let isCollapsed let isCollapsed
if (el) { if (el) {
if (domRange instanceof Selection) { if (isDOMSelection(domRange)) {
anchorNode = domRange.anchorNode anchorNode = domRange.anchorNode
anchorOffset = domRange.anchorOffset anchorOffset = domRange.anchorOffset
focusNode = domRange.focusNode focusNode = domRange.focusNode

View File

@@ -23,8 +23,26 @@ export {
DOMStaticRange, DOMStaticRange,
} }
declare global {
interface Window {
Selection: typeof Selection['constructor']
DataTransfer: typeof DataTransfer['constructor']
Node: typeof Node['constructor']
}
}
export type DOMPoint = [Node, number] export type DOMPoint = [Node, number]
/**
* Returns the host window of a DOM node
*/
export const getDefaultView = (value: any): Window | null => {
return (
(value && value.ownerDocument && value.ownerDocument.defaultView) || null
)
}
/** /**
* Check if a DOM node is a comment node. * Check if a DOM node is a comment node.
*/ */
@@ -46,7 +64,17 @@ export const isDOMElement = (value: any): value is DOMElement => {
*/ */
export const isDOMNode = (value: any): value is DOMNode => { export const isDOMNode = (value: any): value is DOMNode => {
return value instanceof Node const window = getDefaultView(value)
return !!window && value instanceof window.Node
}
/**
* Check if a value is a DOM selection.
*/
export const isDOMSelection = (value: any): value is DOMSelection => {
const window = value && value.anchorNode && getDefaultView(value.anchorNode)
return !!window && value instanceof window.Selection
} }
/** /**

View File

@@ -14,7 +14,7 @@ export const NODE_TO_PARENT: WeakMap<Node, Ancestor> = new WeakMap()
* Weak maps that allow us to go between Slate nodes and DOM nodes. These * Weak maps that allow us to go between Slate nodes and DOM nodes. These
* are used to resolve DOM event-related logic into Slate actions. * are used to resolve DOM event-related logic into Slate actions.
*/ */
export const EDITOR_TO_WINDOW: WeakMap<Editor, Window> = new WeakMap()
export const EDITOR_TO_ELEMENT: WeakMap<Editor, HTMLElement> = new WeakMap() export const EDITOR_TO_ELEMENT: WeakMap<Editor, HTMLElement> = new WeakMap()
export const EDITOR_TO_PLACEHOLDER: WeakMap<Editor, string> = new WeakMap() export const EDITOR_TO_PLACEHOLDER: WeakMap<Editor, string> = new WeakMap()
export const ELEMENT_TO_NODE: WeakMap<HTMLElement, Node> = new WeakMap() export const ELEMENT_TO_NODE: WeakMap<HTMLElement, Node> = new WeakMap()

158
site/examples/iframe.js Normal file
View File

@@ -0,0 +1,158 @@
import React, { useCallback, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import isHotkey from 'is-hotkey'
import { Editable, withReact, useSlate, Slate, ReactEditor } from 'slate-react'
import { Editor, createEditor } 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 IFrameExample = () => {
const [value, setValue] = useState(initialValue)
const renderElement = useCallback(
({ attributes, children }) => <p {...attributes}>{children}</p>,
[]
)
const renderLeaf = useCallback(props => <Leaf {...props} />, [])
const editor = useMemo(() => withHistory(withReact(createEditor())), [])
const handleBlur = useCallback(() => ReactEditor.deselect(editor), [editor])
return (
<Slate editor={editor} value={value} onChange={value => setValue(value)}>
<Toolbar>
<MarkButton format="bold" icon="format_bold" />
<MarkButton format="italic" icon="format_italic" />
<MarkButton format="underline" icon="format_underlined" />
<MarkButton format="code" icon="code" />
</Toolbar>
<IFrame onBlur={handleBlur}>
<Editable
renderElement={renderElement}
renderLeaf={renderLeaf}
placeholder="Enter some rich text…"
spellCheck
autoFocus
onKeyDown={event => {
for (const hotkey in HOTKEYS) {
if (isHotkey(hotkey, event)) {
event.preventDefault()
const mark = HOTKEYS[hotkey]
toggleMark(editor, mark)
}
}
}}
/>
</IFrame>
</Slate>
)
}
const toggleMark = (editor, format) => {
const isActive = isMarkActive(editor, format)
if (isActive) {
Editor.removeMark(editor, format)
} else {
Editor.addMark(editor, format, true)
}
}
const isMarkActive = (editor, format) => {
const marks = Editor.marks(editor)
return marks ? marks[format] === true : false
}
const Leaf = ({ attributes, children, leaf }) => {
if (leaf.bold) {
children = <strong>{children}</strong>
}
if (leaf.code) {
children = <code>{children}</code>
}
if (leaf.italic) {
children = <em>{children}</em>
}
if (leaf.underline) {
children = <u>{children}</u>
}
return <span {...attributes}>{children}</span>
}
const MarkButton = ({ format, icon }) => {
const editor = useSlate()
return (
<Button
active={isMarkActive(editor, format)}
onMouseDown={event => {
event.preventDefault()
toggleMark(editor, format)
}}
>
<Icon>{icon}</Icon>
</Button>
)
}
const IFrame = ({ children, ...props }) => {
const [contentRef, setContentRef] = useState(null)
const mountNode =
contentRef &&
contentRef.contentWindow &&
contentRef.contentWindow.document.body
return (
<iframe {...props} ref={setContentRef}>
{mountNode && createPortal(React.Children.only(children), mountNode)}
</iframe>
)
}
const initialValue = [
{
type: 'paragraph',
children: [
{
text: 'In this example, the document gets rendered into a controlled ',
},
{ text: '<iframe>', code: true },
{
text: '. This is ',
},
{
text: 'particularly',
italic: true,
},
{
text:
' useful, when you need to separate the styles for your editor contents from the ones addressing your UI.',
},
],
},
{
type: 'paragraph',
children: [
{
text: 'This also the only reliable method to preview any ',
},
{
text: 'media queries',
bold: true,
},
{
text: ' in your CSS.',
},
],
},
]
export default IFrameExample

View File

@@ -25,6 +25,7 @@ import RichText from '../../examples/richtext'
import SearchHighlighting from '../../examples/search-highlighting' import SearchHighlighting from '../../examples/search-highlighting'
import CodeHighlighting from '../../examples/code-highlighting' import CodeHighlighting from '../../examples/code-highlighting'
import Tables from '../../examples/tables' import Tables from '../../examples/tables'
import IFrames from '../../examples/iframe'
// node // node
import { getAllExamples } from '../api' import { getAllExamples } from '../api'
@@ -48,6 +49,7 @@ const EXAMPLES = [
['Search Highlighting', SearchHighlighting, 'search-highlighting'], ['Search Highlighting', SearchHighlighting, 'search-highlighting'],
['Code Highlighting', CodeHighlighting, 'code-highlighting'], ['Code Highlighting', CodeHighlighting, 'code-highlighting'],
['Tables', Tables, 'tables'], ['Tables', Tables, 'tables'],
['Rendering in iframes', IFrames, 'iframe'],
] ]
const Header = props => ( const Header = props => (

View File

@@ -70,6 +70,11 @@ input:focus {
border-color: blue; border-color: blue;
} }
iframe {
width: 100%;
border: 1px solid #eee;
}
[data-slate-editor] > * + * { [data-slate-editor] > * + * {
margin-top: 1em; margin-top: 1em;
} }

View File

@@ -191,6 +191,14 @@
"@babel/helper-annotate-as-pure" "^7.10.4" "@babel/helper-annotate-as-pure" "^7.10.4"
"@babel/types" "^7.10.4" "@babel/types" "^7.10.4"
"@babel/helper-call-delegate@^7.7.4":
version "7.12.13"
resolved "https://registry.yarnpkg.com/@babel/helper-call-delegate/-/helper-call-delegate-7.12.13.tgz#119ef367451f90bed006c685816ba60fc33fee78"
integrity sha512-K1kF0RXK/GpdS9OZDlBllG0+RQQtyzG/TC+nk0VkrUry4l4Xh2T7HdDsDOVlXQY/KcqvE/JQ84pKjKucdrg3FQ==
dependencies:
"@babel/helper-hoist-variables" "^7.12.13"
"@babel/types" "^7.12.13"
"@babel/helper-compilation-targets@^7.10.4", "@babel/helper-compilation-targets@^7.9.6": "@babel/helper-compilation-targets@^7.10.4", "@babel/helper-compilation-targets@^7.9.6":
version "7.10.4" version "7.10.4"
resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.10.4.tgz#804ae8e3f04376607cc791b9d47d540276332bd2" resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.10.4.tgz#804ae8e3f04376607cc791b9d47d540276332bd2"
@@ -263,6 +271,13 @@
dependencies: dependencies:
"@babel/types" "^7.10.4" "@babel/types" "^7.10.4"
"@babel/helper-hoist-variables@^7.12.13":
version "7.12.13"
resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.12.13.tgz#13aba58b7480b502362316ea02f52cca0e9796cd"
integrity sha512-KSC5XSj5HreRhYQtZ3cnSnQwDzgnbdUDEFsxkN0m6Q3WrCRt72xrnZ8+h+pX7YxM7hr87zIO3a/v5p/H3TrnVw==
dependencies:
"@babel/types" "^7.12.13"
"@babel/helper-member-expression-to-functions@^7.10.4", "@babel/helper-member-expression-to-functions@^7.10.5": "@babel/helper-member-expression-to-functions@^7.10.4", "@babel/helper-member-expression-to-functions@^7.10.5":
version "7.11.0" version "7.11.0"
resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.11.0.tgz#ae69c83d84ee82f4b42f96e2a09410935a8f26df" resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.11.0.tgz#ae69c83d84ee82f4b42f96e2a09410935a8f26df"
@@ -357,6 +372,11 @@
resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.10.4.tgz#a78c7a7251e01f616512d31b10adcf52ada5e0d2" resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.10.4.tgz#a78c7a7251e01f616512d31b10adcf52ada5e0d2"
integrity sha512-3U9y+43hz7ZM+rzG24Qe2mufW5KhvFg/NhnNph+i9mgCtdTCtMJuI1TMkrIUiK7Ix4PYlRF9I5dhqaLYA/ADXw== integrity sha512-3U9y+43hz7ZM+rzG24Qe2mufW5KhvFg/NhnNph+i9mgCtdTCtMJuI1TMkrIUiK7Ix4PYlRF9I5dhqaLYA/ADXw==
"@babel/helper-validator-identifier@^7.12.11":
version "7.12.11"
resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.12.11.tgz#c9a1f021917dcb5ccf0d4e453e399022981fc9ed"
integrity sha512-np/lG3uARFybkoHokJUmf1QfEvRVCPbmQeUQpKow5cQ3xWrV9i3rUHodKDJPQfTVX61qKi+UdYk8kik84n7XOw==
"@babel/helper-wrap-function@^7.10.4": "@babel/helper-wrap-function@^7.10.4":
version "7.10.4" version "7.10.4"
resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.10.4.tgz#8a6f701eab0ff39f765b5a1cfef409990e624b87" resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.10.4.tgz#8a6f701eab0ff39f765b5a1cfef409990e624b87"
@@ -1275,6 +1295,15 @@
lodash "^4.17.19" lodash "^4.17.19"
to-fast-properties "^2.0.0" to-fast-properties "^2.0.0"
"@babel/types@^7.12.13":
version "7.12.13"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.12.13.tgz#8be1aa8f2c876da11a9cf650c0ecf656913ad611"
integrity sha512-oKrdZTld2im1z8bDwTOQvUbxKwE+854zc16qWZQlcTqMN00pWxHQ4ZeOq0yDMnisOpRykH2/5Qqcrk/OlbAjiQ==
dependencies:
"@babel/helper-validator-identifier" "^7.12.11"
lodash "^4.17.19"
to-fast-properties "^2.0.0"
"@emotion/cache@^10.0.27": "@emotion/cache@^10.0.27":
version "10.0.29" version "10.0.29"
resolved "https://registry.yarnpkg.com/@emotion/cache/-/cache-10.0.29.tgz#87e7e64f412c060102d589fe7c6dc042e6f9d1e0" resolved "https://registry.yarnpkg.com/@emotion/cache/-/cache-10.0.29.tgz#87e7e64f412c060102d589fe7c6dc042e6f9d1e0"