1
0
mirror of https://github.com/ianstormtaylor/slate.git synced 2025-04-22 14:21:54 +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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
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.
## 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_FORCE_RENDER,
EDITOR_TO_PENDING_INSERTION_MARKS,
EDITOR_TO_STYLE_ELEMENT,
EDITOR_TO_PLACEHOLDER_ELEMENT,
EDITOR_TO_USER_MARKS,
EDITOR_TO_USER_SELECTION,
EDITOR_TO_WINDOW,
@ -66,7 +66,6 @@ import {
NODE_TO_ELEMENT,
PLACEHOLDER_SYMBOL,
} from '../utils/weak-maps'
import { whereIfSupported } from '../utils/where-if-supported'
import { RestoreDOM } from './restore-dom/restore-dom'
import { useAndroidInputManager } from '../hooks/android-input-manager/use-android-input-manager'
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>
)
// The number of Editable components currently mounted.
let mountedCount = 0
/**
* `RenderElementProps` are passed to the `renderElement` handler.
*/
@ -125,6 +121,7 @@ export type EditableProps = {
renderPlaceholder?: (props: RenderPlaceholderProps) => JSX.Element
scrollSelectionIntoView?: (editor: ReactEditor, domRange: DOMRange) => void
as?: React.ElementType
disableDefaultStyles?: boolean
} & React.TextareaHTMLAttributes<HTMLDivElement>
/**
@ -142,8 +139,9 @@ export const Editable = (props: EditableProps) => {
renderLeaf,
renderPlaceholder = props => <DefaultPlaceholder {...props} />,
scrollSelectionIntoView = defaultScrollSelectionIntoView,
style = {},
style: userStyle = {},
as: Component = 'div',
disableDefaultStyles = false,
...attributes
} = props
const editor = useSlate()
@ -806,45 +804,9 @@ 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')
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)
}
}, [])
const placeholderHeight = EDITOR_TO_PLACEHOLDER_ELEMENT.get(
editor
)?.getBoundingClientRect()?.height
return (
<ReadOnlyContext.Provider value={readOnly}>
@ -875,7 +837,6 @@ export const Editable = (props: EditableProps) => {
: 'false'
}
data-slate-editor
data-slate-editor-id={editor.id}
data-slate-node="value"
// explicitly set this
contentEditable={!readOnly}
@ -885,7 +846,26 @@ export const Editable = (props: EditableProps) => {
zindex={-1}
suppressContentEditableWarning
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(
(event: React.FormEvent<HTMLDivElement>) => {
// COMPAT: Certain browsers don't support the `beforeinput` event, so we

View File

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

View File

@ -29,10 +29,6 @@ export const EDITOR_TO_KEY_TO_ELEMENT: WeakMap<
Editor,
WeakMap<Key, HTMLElement>
> = new WeakMap()
export const EDITOR_TO_STYLE_ELEMENT: WeakMap<
Editor,
HTMLStyleElement
> = new WeakMap()
/**
* 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 { TextUnit } from './interfaces/types'
let nextEditorId = 0
/**
* Create a new Slate `Editor` object.
*/
@ -28,7 +26,6 @@ export const createEditor = (): Editor => {
operations: [],
selection: null,
marks: null,
id: nextEditorId++,
isInline: () => false,
isVoid: () => false,
markableVoid: () => false,

View File

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

View File

@ -14,4 +14,21 @@ test.describe('placeholder example', () => {
'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 SearchHighlighting from '../../examples/search-highlighting'
import ShadowDOM from '../../examples/shadow-dom'
import Styling from '../../examples/styling'
import Tables from '../../examples/tables'
import IFrames from '../../examples/iframe'
import CustomPlaceholder from '../../examples/custom-placeholder'
@ -51,6 +52,7 @@ const EXAMPLES = [
['Rich Text', RichText, 'richtext'],
['Search Highlighting', SearchHighlighting, 'search-highlighting'],
['Shadow DOM', ShadowDOM, 'shadow-dom'],
['Styling', Styling, 'styling'],
['Tables', Tables, 'tables'],
['Rendering in iframes', IFrames, 'iframe'],
['Custom placeholder', CustomPlaceholder, 'custom-placeholder'],

View File

@ -78,3 +78,14 @@ iframe {
[data-slate-editor] > * + * {
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;
}