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:
parent
0f83810704
commit
9c4097a26f
6
.changeset/shy-laws-argue.md
Normal file
6
.changeset/shy-laws-argue.md
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
'slate-react': minor
|
||||
'slate': patch
|
||||
---
|
||||
|
||||
Revert to using inline styles for default editor styles
|
@ -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.
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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.
|
||||
|
@ -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} }` +
|
||||
`}`
|
||||
)
|
||||
}
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
|
113
playwright/integration/examples/styling.test.ts
Normal file
113
playwright/integration/examples/styling.test.ts
Normal 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
47
site/examples/styling.tsx
Normal 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
|
@ -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'],
|
||||
|
@ -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;
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user