1
0
mirror of https://github.com/ianstormtaylor/slate.git synced 2025-08-12 10:14:02 +02:00

Simplify implementation of custom editor styling (#5278)

* Switch back to using inline styles for default editor styles

* Add example page and test for editor styling

* Add section in docs for editor styling

* Add test for editor height being set to placeholder height

* Add changeset
This commit is contained in:
Kyle McLean
2023-01-31 19:17:27 -07:00
committed by GitHub
parent 0f83810704
commit 9c4097a26f
13 changed files with 257 additions and 94 deletions

View File

@@ -0,0 +1,6 @@
---
'slate-react': minor
'slate': patch
---
Revert to using inline styles for default editor styles

View File

@@ -136,3 +136,24 @@ const Toolbar = () => {
``` ```
Because the `<Toolbar>` uses the `useSlate` hook to retrieve the context, it will re-render whenever the editor changes, so that the active state of the buttons stays in sync. Because the `<Toolbar>` uses the `useSlate` hook to retrieve the context, it will re-render whenever the editor changes, so that the active state of the buttons stays in sync.
## Editor Styling
Custom styles can be applied to the editor itself by using the `style` prop on the `<Editable>` component.
```jsx
const MyEditor = () => {
const [editor] = useState(() => withReact(createEditor()))
return (
<Slate editor={editor}>
<Editable style={{ minHeight: '200px', backgroundColor: 'lime' }} />
</Slate>
)
}
```
It is also possible to apply custom styles with a stylesheet and `className`. However, Slate uses inline styles to provide some default styles for the editor. Because inline styles take precedence over stylesheets, styles you provide using stylesheets will not override the default styles. If you are trying to use a stylesheet and your rules are not taking effect, do one of the following:
- Provide your styles using the `style` prop instead of a stylesheet, which overrides the default inline styles.
- Pass the `disableDefaultStyles` prop to the `<Editable>` component.
- Use `!important` in your stylesheet declarations to make them override the inline styles.

View File

@@ -54,7 +54,7 @@ import {
EDITOR_TO_ELEMENT, EDITOR_TO_ELEMENT,
EDITOR_TO_FORCE_RENDER, EDITOR_TO_FORCE_RENDER,
EDITOR_TO_PENDING_INSERTION_MARKS, EDITOR_TO_PENDING_INSERTION_MARKS,
EDITOR_TO_STYLE_ELEMENT, EDITOR_TO_PLACEHOLDER_ELEMENT,
EDITOR_TO_USER_MARKS, EDITOR_TO_USER_MARKS,
EDITOR_TO_USER_SELECTION, EDITOR_TO_USER_SELECTION,
EDITOR_TO_WINDOW, EDITOR_TO_WINDOW,
@@ -66,7 +66,6 @@ import {
NODE_TO_ELEMENT, NODE_TO_ELEMENT,
PLACEHOLDER_SYMBOL, PLACEHOLDER_SYMBOL,
} from '../utils/weak-maps' } from '../utils/weak-maps'
import { whereIfSupported } from '../utils/where-if-supported'
import { RestoreDOM } from './restore-dom/restore-dom' import { RestoreDOM } from './restore-dom/restore-dom'
import { useAndroidInputManager } from '../hooks/android-input-manager/use-android-input-manager' import { useAndroidInputManager } from '../hooks/android-input-manager/use-android-input-manager'
import { useTrackUserInput } from '../hooks/use-track-user-input' import { useTrackUserInput } from '../hooks/use-track-user-input'
@@ -77,9 +76,6 @@ const Children = (props: Parameters<typeof useChildren>[0]) => (
<React.Fragment>{useChildren(props)}</React.Fragment> <React.Fragment>{useChildren(props)}</React.Fragment>
) )
// The number of Editable components currently mounted.
let mountedCount = 0
/** /**
* `RenderElementProps` are passed to the `renderElement` handler. * `RenderElementProps` are passed to the `renderElement` handler.
*/ */
@@ -125,6 +121,7 @@ export type EditableProps = {
renderPlaceholder?: (props: RenderPlaceholderProps) => JSX.Element renderPlaceholder?: (props: RenderPlaceholderProps) => JSX.Element
scrollSelectionIntoView?: (editor: ReactEditor, domRange: DOMRange) => void scrollSelectionIntoView?: (editor: ReactEditor, domRange: DOMRange) => void
as?: React.ElementType as?: React.ElementType
disableDefaultStyles?: boolean
} & React.TextareaHTMLAttributes<HTMLDivElement> } & React.TextareaHTMLAttributes<HTMLDivElement>
/** /**
@@ -142,8 +139,9 @@ export const Editable = (props: EditableProps) => {
renderLeaf, renderLeaf,
renderPlaceholder = props => <DefaultPlaceholder {...props} />, renderPlaceholder = props => <DefaultPlaceholder {...props} />,
scrollSelectionIntoView = defaultScrollSelectionIntoView, scrollSelectionIntoView = defaultScrollSelectionIntoView,
style = {}, style: userStyle = {},
as: Component = 'div', as: Component = 'div',
disableDefaultStyles = false,
...attributes ...attributes
} = props } = props
const editor = useSlate() const editor = useSlate()
@@ -806,45 +804,9 @@ export const Editable = (props: EditableProps) => {
}) })
}) })
useEffect(() => { const placeholderHeight = EDITOR_TO_PLACEHOLDER_ELEMENT.get(
mountedCount++ editor
)?.getBoundingClientRect()?.height
if (mountedCount === 1) {
// Set global default styles for editors.
const defaultStylesElement = document.createElement('style')
defaultStylesElement.setAttribute('data-slate-default-styles', 'true')
const selector = '[data-slate-editor]'
const defaultStyles =
// 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;`
defaultStylesElement.innerHTML = whereIfSupported(selector, defaultStyles)
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 ( return (
<ReadOnlyContext.Provider value={readOnly}> <ReadOnlyContext.Provider value={readOnly}>
@@ -875,7 +837,6 @@ export const Editable = (props: EditableProps) => {
: 'false' : 'false'
} }
data-slate-editor data-slate-editor
data-slate-editor-id={editor.id}
data-slate-node="value" data-slate-node="value"
// explicitly set this // explicitly set this
contentEditable={!readOnly} contentEditable={!readOnly}
@@ -885,7 +846,26 @@ export const Editable = (props: EditableProps) => {
zindex={-1} zindex={-1}
suppressContentEditableWarning suppressContentEditableWarning
ref={ref} ref={ref}
style={style} style={{
...(disableDefaultStyles
? {}
: {
// 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',
// Make the minimum height that of the placeholder.
...(placeholderHeight
? { minHeight: placeholderHeight }
: {}),
}),
// Allow for passed-in styles to override anything.
...userStyle,
}}
onBeforeInput={useCallback( onBeforeInput={useCallback(
(event: React.FormEvent<HTMLDivElement>) => { (event: React.FormEvent<HTMLDivElement>) => {
// COMPAT: Certain browsers don't support the `beforeinput` event, so we // COMPAT: Certain browsers don't support the `beforeinput` event, so we

View File

@@ -5,11 +5,10 @@ import String from './string'
import { import {
PLACEHOLDER_SYMBOL, PLACEHOLDER_SYMBOL,
EDITOR_TO_PLACEHOLDER_ELEMENT, EDITOR_TO_PLACEHOLDER_ELEMENT,
EDITOR_TO_STYLE_ELEMENT, EDITOR_TO_FORCE_RENDER,
} from '../utils/weak-maps' } from '../utils/weak-maps'
import { RenderLeafProps, RenderPlaceholderProps } from './editable' import { RenderLeafProps, RenderPlaceholderProps } from './editable'
import { useSlateStatic } from '../hooks/use-slate-static' import { useSlateStatic } from '../hooks/use-slate-static'
import { whereIfSupported } from '../utils/where-if-supported'
/** /**
* Individual leaves in a text node with unique formatting. * Individual leaves in a text node with unique formatting.
@@ -32,6 +31,7 @@ const Leaf = (props: {
renderLeaf = (props: RenderLeafProps) => <DefaultLeaf {...props} />, renderLeaf = (props: RenderLeafProps) => <DefaultLeaf {...props} />,
} = props } = props
const lastPlaceholderRef = useRef<HTMLSpanElement | null>(null)
const placeholderRef = useRef<HTMLSpanElement | null>(null) const placeholderRef = useRef<HTMLSpanElement | null>(null)
const editor = useSlateStatic() const editor = useSlateStatic()
@@ -62,28 +62,24 @@ const Leaf = (props: {
} else if (placeholderEl) { } else if (placeholderEl) {
// Create a new observer and observe the placeholder element. // Create a new observer and observe the placeholder element.
const ResizeObserver = window.ResizeObserver || ResizeObserverPolyfill const ResizeObserver = window.ResizeObserver || ResizeObserverPolyfill
placeholderResizeObserver.current = new ResizeObserver(([{ target }]) => { placeholderResizeObserver.current = new ResizeObserver(() => {
const styleElement = EDITOR_TO_STYLE_ELEMENT.get(editor) // Force a re-render of the editor so its min-height can be updated
if (styleElement) { // to the new height of the placeholder.
// Make the min-height the height of the placeholder. const forceRender = EDITOR_TO_FORCE_RENDER.get(editor)
const selector = `[data-slate-editor-id="${editor.id}"]` forceRender?.()
const styles = `min-height: ${target.clientHeight}px;`
styleElement.innerHTML = whereIfSupported(selector, styles)
}
}) })
placeholderResizeObserver.current.observe(placeholderEl) placeholderResizeObserver.current.observe(placeholderEl)
} }
if (!placeholderEl) { if (!placeholderEl && lastPlaceholderRef.current) {
// No placeholder element, so no need for a resize observer. // No placeholder element, so no need for a resize observer.
const styleElement = EDITOR_TO_STYLE_ELEMENT.get(editor) // Force a re-render of the editor so its min-height can be reset.
if (styleElement) { const forceRender = EDITOR_TO_FORCE_RENDER.get(editor)
// No min-height if there is no placeholder. forceRender?.()
styleElement.innerHTML = ''
}
} }
lastPlaceholderRef.current = placeholderRef.current
return () => { return () => {
EDITOR_TO_PLACEHOLDER_ELEMENT.delete(editor) EDITOR_TO_PLACEHOLDER_ELEMENT.delete(editor)
} }

View File

@@ -29,10 +29,6 @@ export const EDITOR_TO_KEY_TO_ELEMENT: WeakMap<
Editor, Editor,
WeakMap<Key, HTMLElement> WeakMap<Key, HTMLElement>
> = new WeakMap() > = new WeakMap()
export const EDITOR_TO_STYLE_ELEMENT: WeakMap<
Editor,
HTMLStyleElement
> = new WeakMap()
/** /**
* Weak maps for storing editor-related state. * Weak maps for storing editor-related state.

View File

@@ -1,22 +0,0 @@
/**
* Returns a set of rules that use the `:where` selector if it is supported,
* otherwise it falls back to the provided selector on its own.
*
* The `:where` selector is used to give a selector a lower specificity,
* allowing the rule to be overridden by a user-defined stylesheet.
*
* Older browsers do not support the `:where` selector.
* If it is not supported, the selector will be used without `:where`,
* which means that the rule will have a higher specificity and a user-defined
* stylesheet will not be able to override it easily.
*/
export function whereIfSupported(selector: string, styles: string): string {
return (
`@supports (selector(:where(${selector}))) {` +
`:where(${selector}) { ${styles} }` +
`}` +
`@supports not (selector(:where(${selector}))) {` +
`${selector} { ${styles} }` +
`}`
)
}

View File

@@ -16,8 +16,6 @@ import {
import { DIRTY_PATHS, DIRTY_PATH_KEYS, FLUSHING } from './utils/weak-maps' import { DIRTY_PATHS, DIRTY_PATH_KEYS, FLUSHING } from './utils/weak-maps'
import { TextUnit } from './interfaces/types' import { TextUnit } from './interfaces/types'
let nextEditorId = 0
/** /**
* Create a new Slate `Editor` object. * Create a new Slate `Editor` object.
*/ */
@@ -28,7 +26,6 @@ export const createEditor = (): Editor => {
operations: [], operations: [],
selection: null, selection: null,
marks: null, marks: null,
id: nextEditorId++,
isInline: () => false, isInline: () => false,
isVoid: () => false, isVoid: () => false,
markableVoid: () => false, markableVoid: () => false,

View File

@@ -57,7 +57,6 @@ export interface BaseEditor {
selection: Selection selection: Selection
operations: Operation[] operations: Operation[]
marks: EditorMarks | null marks: EditorMarks | null
readonly id: number
// Schema-specific node behaviors. // Schema-specific node behaviors.
isInline: (element: Element) => boolean isInline: (element: Element) => boolean

View File

@@ -14,4 +14,21 @@ test.describe('placeholder example', () => {
'renderPlaceholder' 'renderPlaceholder'
) )
}) })
test('renders editor tall enough to fit placeholder', async ({ page }) => {
const slateEditor = page.locator('[data-slate-editor=true]')
const placeholderElement = page.locator('[data-slate-placeholder=true]')
const editorBoundingBox = await slateEditor.boundingBox()
const placeholderBoundingBox = await placeholderElement.boundingBox()
if (!editorBoundingBox)
throw new Error('Could not get bounding box for editor')
if (!placeholderBoundingBox)
throw new Error('Could not get bounding box for placeholder')
expect(editorBoundingBox.height).toBeGreaterThanOrEqual(
placeholderBoundingBox.height
)
})
}) })

View File

@@ -0,0 +1,113 @@
import { test, expect } from '@playwright/test'
test.describe('styling example', () => {
test.beforeEach(
async ({ page }) =>
await page.goto('http://localhost:3000/examples/styling')
)
test('applies styles to editor from style prop', async ({ page }) => {
page.waitForLoadState('domcontentloaded')
const editor = page.locator('[data-slate-editor=true]').nth(0)
const styles = await editor.evaluate(el => {
const {
backgroundColor,
minHeight,
outlineWidth,
outlineStyle,
outlineColor,
position,
whiteSpace,
wordWrap,
} = window.getComputedStyle(el)
return {
backgroundColor,
minHeight,
outlineWidth,
outlineStyle,
outlineColor,
position,
whiteSpace,
wordWrap,
}
})
// Provided styles
expect(styles.backgroundColor).toBe('rgb(255, 230, 156)')
expect(styles.minHeight).toBe('200px')
expect(styles.outlineWidth).toBe('2px')
expect(styles.outlineStyle).toBe('solid')
expect(styles.outlineColor).toBe('rgb(0, 128, 0)')
// Default styles
expect(styles.position).toBe('relative')
expect(styles.whiteSpace).toBe('pre-wrap')
expect(styles.wordWrap).toBe('break-word')
})
test('applies styles to editor from className prop', async ({ page }) => {
page.waitForLoadState('domcontentloaded')
const editor = page.locator('[data-slate-editor=true]').nth(1)
const styles = await editor.evaluate(el => {
const {
backgroundColor,
paddingTop,
paddingRight,
paddingBottom,
paddingLeft,
fontSize,
minHeight,
outlineWidth,
outlineStyle,
outlineColor,
borderTopLeftRadius,
borderTopRightRadius,
borderBottomRightRadius,
borderBottomLeftRadius,
outlineOffset,
position,
whiteSpace,
wordWrap,
} = window.getComputedStyle(el)
return {
backgroundColor,
paddingTop,
paddingRight,
paddingBottom,
paddingLeft,
fontSize,
minHeight,
outlineWidth,
outlineStyle,
outlineColor,
borderTopLeftRadius,
borderTopRightRadius,
borderBottomRightRadius,
borderBottomLeftRadius,
outlineOffset,
position,
whiteSpace,
wordWrap,
}
})
expect(styles.backgroundColor).toBe('rgb(218, 225, 255)')
expect(styles.paddingTop).toBe('40px')
expect(styles.paddingRight).toBe('40px')
expect(styles.paddingBottom).toBe('40px')
expect(styles.paddingLeft).toBe('40px')
expect(styles.fontSize).toBe('20px')
expect(styles.minHeight).toBe('150px')
expect(styles.borderBottomLeftRadius).toBe('20px')
expect(styles.borderBottomRightRadius).toBe('20px')
expect(styles.borderTopLeftRadius).toBe('20px')
expect(styles.borderTopRightRadius).toBe('20px')
expect(styles.outlineOffset).toBe('-20px')
expect(styles.outlineWidth).toBe('3px')
expect(styles.outlineStyle).toBe('dashed')
expect(styles.outlineColor).toBe('rgb(0, 94, 128)')
expect(styles.whiteSpace).toBe('pre-wrap')
})
})

47
site/examples/styling.tsx Normal file
View File

@@ -0,0 +1,47 @@
import React, { useMemo } from 'react'
import { createEditor } from 'slate'
import { Slate, Editable, withReact } from 'slate-react'
import { withHistory } from 'slate-history'
const StylingExample = () => {
const editor1 = useMemo(() => withHistory(withReact(createEditor())), [])
const editor2 = useMemo(() => withHistory(withReact(createEditor())), [])
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '40px' }}>
<Slate
editor={editor1}
value={[
{
type: 'paragraph',
children: [{ text: 'This editor is styled using the style prop.' }],
},
]}
>
<Editable
style={{
backgroundColor: 'rgb(255, 230, 156)',
minHeight: '200px',
outline: 'rgb(0, 128, 0) solid 2px',
}}
/>
</Slate>
<Slate
editor={editor2}
value={[
{
type: 'paragraph',
children: [
{ text: 'This editor is styled using the className prop.' },
],
},
]}
>
<Editable className="fancy" disableDefaultStyles />
</Slate>
</div>
)
}
export default StylingExample

View File

@@ -25,6 +25,7 @@ import ReadOnly from '../../examples/read-only'
import RichText from '../../examples/richtext' import RichText from '../../examples/richtext'
import SearchHighlighting from '../../examples/search-highlighting' import SearchHighlighting from '../../examples/search-highlighting'
import ShadowDOM from '../../examples/shadow-dom' import ShadowDOM from '../../examples/shadow-dom'
import Styling from '../../examples/styling'
import Tables from '../../examples/tables' import Tables from '../../examples/tables'
import IFrames from '../../examples/iframe' import IFrames from '../../examples/iframe'
import CustomPlaceholder from '../../examples/custom-placeholder' import CustomPlaceholder from '../../examples/custom-placeholder'
@@ -51,6 +52,7 @@ const EXAMPLES = [
['Rich Text', RichText, 'richtext'], ['Rich Text', RichText, 'richtext'],
['Search Highlighting', SearchHighlighting, 'search-highlighting'], ['Search Highlighting', SearchHighlighting, 'search-highlighting'],
['Shadow DOM', ShadowDOM, 'shadow-dom'], ['Shadow DOM', ShadowDOM, 'shadow-dom'],
['Styling', Styling, 'styling'],
['Tables', Tables, 'tables'], ['Tables', Tables, 'tables'],
['Rendering in iframes', IFrames, 'iframe'], ['Rendering in iframes', IFrames, 'iframe'],
['Custom placeholder', CustomPlaceholder, 'custom-placeholder'], ['Custom placeholder', CustomPlaceholder, 'custom-placeholder'],

View File

@@ -78,3 +78,14 @@ iframe {
[data-slate-editor] > * + * { [data-slate-editor] > * + * {
margin-top: 1em; margin-top: 1em;
} }
.fancy {
background-color: rgb(218, 225, 255);
padding: 40px;
font-size: 20px;
min-height: 150px;
outline: 3px dashed rgb(0, 94, 128);
border-radius: 20px;
outline-offset: -20px;
white-space: pre-wrap;
}