diff --git a/.changeset/strange-pens-lie.md b/.changeset/strange-pens-lie.md new file mode 100644 index 0000000000..94101c85e3 --- /dev/null +++ b/.changeset/strange-pens-lie.md @@ -0,0 +1,6 @@ +--- +'slate': minor +'slate-react': minor +--- + +Use stylesheet for default styles on Editable components diff --git a/packages/slate-react/package.json b/packages/slate-react/package.json index c375e02c15..85c2be7850 100644 --- a/packages/slate-react/package.json +++ b/packages/slate-react/package.json @@ -30,6 +30,7 @@ "@types/react": "^16.9.13", "@types/react-dom": "^16.9.4", "@types/react-test-renderer": "^16.8.0", + "@types/resize-observer-browser": "^0.1.7", "react": ">=16.8.0", "react-dom": ">=16.8.0", "react-test-renderer": ">=16.8.0", diff --git a/packages/slate-react/src/components/editable.tsx b/packages/slate-react/src/components/editable.tsx index e4f202fd2d..80e0c30f11 100644 --- a/packages/slate-react/src/components/editable.tsx +++ b/packages/slate-react/src/components/editable.tsx @@ -55,6 +55,7 @@ import { EDITOR_TO_ELEMENT, EDITOR_TO_FORCE_RENDER, EDITOR_TO_PENDING_INSERTION_MARKS, + EDITOR_TO_STYLE_ELEMENT, EDITOR_TO_USER_MARKS, EDITOR_TO_USER_SELECTION, EDITOR_TO_WINDOW, @@ -76,6 +77,9 @@ const Children = (props: Parameters[0]) => ( {useChildren(props)} ) +// The number of Editable components currently mounted. +let mountedCount = 0 + /** * `RenderElementProps` are passed to the `renderElement` handler. */ @@ -802,6 +806,46 @@ export const Editable = (props: EditableProps) => { }) }) + useEffect(() => { + mountedCount++ + + if (mountedCount === 1) { + // Set global default styles for editors. + const defaultStylesElement = document.createElement('style') + defaultStylesElement.setAttribute('data-slate-default-styles', 'true') + defaultStylesElement.innerHTML = + // :where is used to give these rules lower specificity so user stylesheets can override them. + `:where([data-slate-editor]) {` + + // Allow positioning relative to the editable element. + `position: relative;` + + // Prevent the default outline styles. + `outline: none;` + + // Preserve adjacent whitespace and new lines. + `white-space: pre-wrap;` + + // Allow words to break if they are too long. + `word-wrap: break-word;` + + `}` + document.head.appendChild(defaultStylesElement) + } + + return () => { + mountedCount-- + + if (mountedCount <= 0) + document.querySelector('style[data-slate-default-styles]')?.remove() + } + }, []) + + useEffect(() => { + const styleElement = document.createElement('style') + document.head.appendChild(styleElement) + EDITOR_TO_STYLE_ELEMENT.set(editor, styleElement) + return () => { + styleElement.remove() + EDITOR_TO_STYLE_ELEMENT.delete(editor) + } + }, []) + return ( @@ -831,6 +875,7 @@ export const Editable = (props: EditableProps) => { : 'false' } data-slate-editor + data-slate-editor-id={editor.id} data-slate-node="value" // explicitly set this contentEditable={!readOnly} @@ -840,18 +885,7 @@ export const Editable = (props: EditableProps) => { zindex={-1} suppressContentEditableWarning ref={ref} - style={{ - // Allow positioning relative to the editable element. - position: 'relative', - // Prevent the default outline styles. - outline: 'none', - // Preserve adjacent whitespace and new lines. - whiteSpace: 'pre-wrap', - // Allow words to break if they are too long. - wordWrap: 'break-word', - // Allow for passed-in styles to override anything. - ...style, - }} + style={style} onBeforeInput={useCallback( (event: React.FormEvent) => { // COMPAT: Certain browsers don't support the `beforeinput` event, so we diff --git a/packages/slate-react/src/components/leaf.tsx b/packages/slate-react/src/components/leaf.tsx index ef6ebf6490..823a56c9b7 100644 --- a/packages/slate-react/src/components/leaf.tsx +++ b/packages/slate-react/src/components/leaf.tsx @@ -4,10 +4,10 @@ import String from './string' import { PLACEHOLDER_SYMBOL, EDITOR_TO_PLACEHOLDER_ELEMENT, + EDITOR_TO_STYLE_ELEMENT, } from '../utils/weak-maps' import { RenderLeafProps, RenderPlaceholderProps } from './editable' import { useSlateStatic } from '../hooks/use-slate-static' -import { ReactEditor } from '..' /** * Individual leaves in a text node with unique formatting. @@ -33,19 +33,54 @@ const Leaf = (props: { const placeholderRef = useRef(null) const editor = useSlateStatic() + const placeholderResizeObserver = useRef(null) + + useEffect(() => { + return () => { + if (placeholderResizeObserver.current) { + placeholderResizeObserver.current.disconnect() + } + } + }, []) + useEffect(() => { const placeholderEl = placeholderRef?.current - const editorEl = ReactEditor.toDOMNode(editor, editor) - if (!placeholderEl || !editorEl) { - return + if (placeholderEl) { + EDITOR_TO_PLACEHOLDER_ELEMENT.set(editor, placeholderEl) + } else { + EDITOR_TO_PLACEHOLDER_ELEMENT.delete(editor) } - editorEl.style.minHeight = `${placeholderEl.clientHeight}px` - EDITOR_TO_PLACEHOLDER_ELEMENT.set(editor, placeholderEl) + if (placeholderResizeObserver.current) { + // Update existing observer. + placeholderResizeObserver.current.disconnect() + if (placeholderEl) + placeholderResizeObserver.current.observe(placeholderEl) + } else if (placeholderEl) { + // Create a new observer and observe the placeholder element. + placeholderResizeObserver.current = new ResizeObserver(([{ target }]) => { + const styleElement = EDITOR_TO_STYLE_ELEMENT.get(editor) + if (styleElement) { + // Make the min-height the height of the placeholder. + const minHeight = `${target.clientHeight}px` + styleElement.innerHTML = `:where([data-slate-editor-id="${editor.id}"]) { min-height: ${minHeight}; }` + } + }) + + placeholderResizeObserver.current.observe(placeholderEl) + } + + if (!placeholderEl) { + // No placeholder element, so no need for a resize observer. + const styleElement = EDITOR_TO_STYLE_ELEMENT.get(editor) + if (styleElement) { + // No min-height if there is no placeholder. + styleElement.innerHTML = '' + } + } return () => { - editorEl.style.minHeight = 'auto' EDITOR_TO_PLACEHOLDER_ELEMENT.delete(editor) } }, [placeholderRef, leaf]) diff --git a/packages/slate-react/src/utils/weak-maps.ts b/packages/slate-react/src/utils/weak-maps.ts index 8834d77783..7634ea9983 100644 --- a/packages/slate-react/src/utils/weak-maps.ts +++ b/packages/slate-react/src/utils/weak-maps.ts @@ -29,6 +29,10 @@ export const EDITOR_TO_KEY_TO_ELEMENT: WeakMap< Editor, WeakMap > = new WeakMap() +export const EDITOR_TO_STYLE_ELEMENT: WeakMap< + Editor, + HTMLStyleElement +> = new WeakMap() /** * Weak maps for storing editor-related state. diff --git a/packages/slate-react/test/index.spec.tsx b/packages/slate-react/test/index.spec.tsx index 0270c9b143..a226ea173a 100644 --- a/packages/slate-react/test/index.spec.tsx +++ b/packages/slate-react/test/index.spec.tsx @@ -8,7 +8,15 @@ const createNodeMock = () => ({ getRootNode: () => global.document, }) +class MockResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + describe('slate-react', () => { + window.ResizeObserver = MockResizeObserver as any + describe('Editable', () => { describe('NODE_TO_KEY logic', () => { it('should not unmount the node that gets split on a split_node operation', async () => { diff --git a/packages/slate/src/create-editor.ts b/packages/slate/src/create-editor.ts index b2cd9f512b..874e41dcf9 100644 --- a/packages/slate/src/create-editor.ts +++ b/packages/slate/src/create-editor.ts @@ -16,6 +16,8 @@ import { import { DIRTY_PATHS, DIRTY_PATH_KEYS, FLUSHING } from './utils/weak-maps' import { TextUnit } from './interfaces/types' +let nextEditorId = 0 + /** * Create a new Slate `Editor` object. */ @@ -26,6 +28,7 @@ export const createEditor = (): Editor => { operations: [], selection: null, marks: null, + id: nextEditorId++, isInline: () => false, isVoid: () => false, markableVoid: () => false, diff --git a/packages/slate/src/interfaces/editor.ts b/packages/slate/src/interfaces/editor.ts index 9d620b95e2..432cba21c9 100644 --- a/packages/slate/src/interfaces/editor.ts +++ b/packages/slate/src/interfaces/editor.ts @@ -57,6 +57,7 @@ export interface BaseEditor { selection: Selection operations: Operation[] marks: EditorMarks | null + readonly id: number // Schema-specific node behaviors. isInline: (element: Element) => boolean diff --git a/yarn.lock b/yarn.lock index b579292e8c..c8ac0a9aea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3739,6 +3739,13 @@ __metadata: languageName: node linkType: hard +"@types/resize-observer-browser@npm:^0.1.7": + version: 0.1.7 + resolution: "@types/resize-observer-browser@npm:0.1.7" + checksum: 0377eaac8bb7a17b983b49a156006032380b459bfebefc54a5aa2f7f8a9786d2b60723e8837c61ef733330b478f4f26293e9edbdc8006238e4f80c878c56c988 + languageName: node + linkType: hard + "@types/resolve@npm:0.0.8": version: 0.0.8 resolution: "@types/resolve@npm:0.0.8" @@ -14235,6 +14242,7 @@ resolve@^2.0.0-next.3: "@types/react": ^16.9.13 "@types/react-dom": ^16.9.4 "@types/react-test-renderer": ^16.8.0 + "@types/resize-observer-browser": ^0.1.7 direction: ^1.0.3 is-hotkey: ^0.1.6 is-plain-object: ^5.0.0