mirror of
https://github.com/ianstormtaylor/slate.git
synced 2025-04-21 22:02:05 +02:00
Official custom inlines example (#4615)
* Official custom inlines example This generalizes the "links" example to an "inlines" example, adding a new example of an inline: an "editable button". Firstly, this is important to demonstrate that Slate really does allow _custom_ elements, and not just "standard" ones like links that you'll find in any editor. Secondly, it's important to show an example of an inline where "offset" movement should be used. With links, it's arguable that the cursor positions <link>foo<cursor/></link> and <link>foo</link><cursor/> should be considered the same, because they display in the same position. But with the editable button, the cursor is clearly in a different position, and so offset movement should be used. * lint * fix integration test * update readme * try again
This commit is contained in:
parent
72160fac08
commit
f1b7d18f43
11
cypress/integration/examples/inlines.ts
Normal file
11
cypress/integration/examples/inlines.ts
Normal file
@ -0,0 +1,11 @@
|
||||
describe('Inlines example', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit('examples/inlines')
|
||||
})
|
||||
|
||||
it('contains link', () => {
|
||||
cy.findByRole('textbox')
|
||||
.find('a')
|
||||
.should('contain.text', 'hyperlink')
|
||||
})
|
||||
})
|
@ -1,11 +0,0 @@
|
||||
describe('Links example', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit('examples/links')
|
||||
})
|
||||
|
||||
it('contains link', () => {
|
||||
cy.findByRole('textbox')
|
||||
.find('a')
|
||||
.should('contain.text', 'hyperlinks')
|
||||
})
|
||||
})
|
@ -6,7 +6,7 @@ This directory contains a set of examples that give you an idea for how you migh
|
||||
- [**Rich text**](./richtext.tsx) — showing the features you'd expect from a basic editor.
|
||||
- [**Forced Layout**](./forced-layout.tsx) - showing how to use constraints to enforce a document structure.
|
||||
- [**Markdown Shortcuts**](./markdown-shortcuts.tsx) — showing how to add key handlers for Markdown-like shortcuts.
|
||||
- [**Links**](./links.tsx) — showing how wrap text in inline nodes with associated data.
|
||||
- [**Inlines**](./inlines.tsx) — showing how wrap text in inline nodes with associated data.
|
||||
- [**Images**](./images.tsx) — showing how to use void (text-less) nodes to add images.
|
||||
- [**Hovering toolbar**](./hovering-toolbar.tsx) — showing how a hovering toolbar can be implemented.
|
||||
- [**Tables**](./tables.tsx) — showing how to nest blocks to render more advanced components.
|
||||
|
3
site/examples/custom-types.d.ts
vendored
3
site/examples/custom-types.d.ts
vendored
@ -40,6 +40,8 @@ export type ImageElement = {
|
||||
|
||||
export type LinkElement = { type: 'link'; url: string; children: Descendant[] }
|
||||
|
||||
export type ButtonElement = { type: 'button'; children: Descendant[] }
|
||||
|
||||
export type ListItemElement = { type: 'list-item'; children: Descendant[] }
|
||||
|
||||
export type MentionElement = {
|
||||
@ -69,6 +71,7 @@ type CustomElement =
|
||||
| HeadingTwoElement
|
||||
| ImageElement
|
||||
| LinkElement
|
||||
| ButtonElement
|
||||
| ListItemElement
|
||||
| MentionElement
|
||||
| ParagraphElement
|
||||
|
332
site/examples/inlines.tsx
Normal file
332
site/examples/inlines.tsx
Normal file
@ -0,0 +1,332 @@
|
||||
import React, { useState, useMemo } from 'react'
|
||||
import isUrl from 'is-url'
|
||||
import { isKeyHotkey } from 'is-hotkey'
|
||||
import { css } from 'emotion'
|
||||
import { Editable, withReact, useSlate, useSelected } from 'slate-react'
|
||||
import * as SlateReact from 'slate-react'
|
||||
import {
|
||||
Transforms,
|
||||
Editor,
|
||||
Range,
|
||||
createEditor,
|
||||
Element as SlateElement,
|
||||
Descendant,
|
||||
} from 'slate'
|
||||
import { withHistory } from 'slate-history'
|
||||
import { LinkElement, ButtonElement } from './custom-types'
|
||||
|
||||
import { Button, Icon, Toolbar } from '../components'
|
||||
|
||||
const initialValue: Descendant[] = [
|
||||
{
|
||||
type: 'paragraph',
|
||||
children: [
|
||||
{
|
||||
text:
|
||||
'In addition to block nodes, you can create inline nodes. Here is a ',
|
||||
},
|
||||
{
|
||||
type: 'link',
|
||||
url: 'https://en.wikipedia.org/wiki/Hypertext',
|
||||
children: [{ text: 'hyperlink' }],
|
||||
},
|
||||
{
|
||||
text: ', and here is a more unusual inline: an ',
|
||||
},
|
||||
{
|
||||
type: 'button',
|
||||
children: [{ text: 'editable button' }],
|
||||
},
|
||||
{
|
||||
text: '!',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
children: [
|
||||
{
|
||||
text:
|
||||
'There are two ways to add links. You can either add a link via the toolbar icon above, or if you want in on a little secret, copy a URL to your keyboard and paste it while a range of text is selected.',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
const InlinesExample = () => {
|
||||
const [value, setValue] = useState<Descendant[]>(initialValue)
|
||||
const editor = useMemo(
|
||||
() => withInlines(withHistory(withReact(createEditor()))),
|
||||
[]
|
||||
)
|
||||
|
||||
const onKeyDown: React.KeyboardEventHandler<HTMLInputElement> = event => {
|
||||
const { selection } = editor
|
||||
|
||||
// Default left/right behavior is unit:'character'.
|
||||
// This fails to distinguish between two cursor positions, such as
|
||||
// <inline>foo<cursor/></inline> vs <inline>foo</inline><cursor/>.
|
||||
// Here we modify the behavior to unit:'offset'.
|
||||
// This lets the user step into and out of the inline without stepping over characters.
|
||||
// You may wish to customize this further to only use unit:'offset' in specific cases.
|
||||
if (selection && Range.isCollapsed(selection)) {
|
||||
const { nativeEvent } = event
|
||||
if (isKeyHotkey('left', nativeEvent)) {
|
||||
event.preventDefault()
|
||||
Transforms.move(editor, { unit: 'offset', reverse: true })
|
||||
return
|
||||
}
|
||||
if (isKeyHotkey('right', nativeEvent)) {
|
||||
event.preventDefault()
|
||||
Transforms.move(editor, { unit: 'offset' })
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SlateReact.Slate
|
||||
editor={editor}
|
||||
value={value}
|
||||
onChange={value => setValue(value)}
|
||||
>
|
||||
<Toolbar>
|
||||
<AddLinkButton />
|
||||
<RemoveLinkButton />
|
||||
<ToggleEditableButtonButton />
|
||||
</Toolbar>
|
||||
<Editable
|
||||
renderElement={props => <Element {...props} />}
|
||||
placeholder="Enter some text..."
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
</SlateReact.Slate>
|
||||
)
|
||||
}
|
||||
|
||||
const withInlines = editor => {
|
||||
const { insertData, insertText, isInline } = editor
|
||||
|
||||
editor.isInline = element =>
|
||||
['link', 'button'].includes(element.type) || isInline(element)
|
||||
|
||||
editor.insertText = text => {
|
||||
if (text && isUrl(text)) {
|
||||
wrapLink(editor, text)
|
||||
} else {
|
||||
insertText(text)
|
||||
}
|
||||
}
|
||||
|
||||
editor.insertData = data => {
|
||||
const text = data.getData('text/plain')
|
||||
|
||||
if (text && isUrl(text)) {
|
||||
wrapLink(editor, text)
|
||||
} else {
|
||||
insertData(data)
|
||||
}
|
||||
}
|
||||
|
||||
return editor
|
||||
}
|
||||
|
||||
const insertLink = (editor, url) => {
|
||||
if (editor.selection) {
|
||||
wrapLink(editor, url)
|
||||
}
|
||||
}
|
||||
|
||||
const insertButton = editor => {
|
||||
if (editor.selection) {
|
||||
wrapButton(editor)
|
||||
}
|
||||
}
|
||||
|
||||
const isLinkActive = editor => {
|
||||
const [link] = Editor.nodes(editor, {
|
||||
match: n =>
|
||||
!Editor.isEditor(n) && SlateElement.isElement(n) && n.type === 'link',
|
||||
})
|
||||
return !!link
|
||||
}
|
||||
|
||||
const isButtonActive = editor => {
|
||||
const [button] = Editor.nodes(editor, {
|
||||
match: n =>
|
||||
!Editor.isEditor(n) && SlateElement.isElement(n) && n.type === 'button',
|
||||
})
|
||||
return !!button
|
||||
}
|
||||
|
||||
const unwrapLink = editor => {
|
||||
Transforms.unwrapNodes(editor, {
|
||||
match: n =>
|
||||
!Editor.isEditor(n) && SlateElement.isElement(n) && n.type === 'link',
|
||||
})
|
||||
}
|
||||
|
||||
const unwrapButton = editor => {
|
||||
Transforms.unwrapNodes(editor, {
|
||||
match: n =>
|
||||
!Editor.isEditor(n) && SlateElement.isElement(n) && n.type === 'button',
|
||||
})
|
||||
}
|
||||
|
||||
const wrapLink = (editor, url: string) => {
|
||||
if (isLinkActive(editor)) {
|
||||
unwrapLink(editor)
|
||||
}
|
||||
|
||||
const { selection } = editor
|
||||
const isCollapsed = selection && Range.isCollapsed(selection)
|
||||
const link: LinkElement = {
|
||||
type: 'link',
|
||||
url,
|
||||
children: isCollapsed ? [{ text: url }] : [],
|
||||
}
|
||||
|
||||
if (isCollapsed) {
|
||||
Transforms.insertNodes(editor, link)
|
||||
} else {
|
||||
Transforms.wrapNodes(editor, link, { split: true })
|
||||
Transforms.collapse(editor, { edge: 'end' })
|
||||
}
|
||||
}
|
||||
|
||||
const wrapButton = editor => {
|
||||
if (isButtonActive(editor)) {
|
||||
unwrapButton(editor)
|
||||
}
|
||||
|
||||
const { selection } = editor
|
||||
const isCollapsed = selection && Range.isCollapsed(selection)
|
||||
const button: ButtonElement = {
|
||||
type: 'button',
|
||||
children: isCollapsed ? [{ text: 'Edit me!' }] : [],
|
||||
}
|
||||
|
||||
if (isCollapsed) {
|
||||
Transforms.insertNodes(editor, button)
|
||||
} else {
|
||||
Transforms.wrapNodes(editor, button, { split: true })
|
||||
Transforms.collapse(editor, { edge: 'end' })
|
||||
}
|
||||
}
|
||||
|
||||
// Put this at the start and end of an inline component to work around this Chromium bug:
|
||||
// https://bugs.chromium.org/p/chromium/issues/detail?id=1249405
|
||||
const InlineChromiumBugfix = () => (
|
||||
<span
|
||||
contentEditable={false}
|
||||
className={css`
|
||||
font-size: 0;
|
||||
`}
|
||||
>
|
||||
${String.fromCodePoint(160) /* Non-breaking space */}
|
||||
</span>
|
||||
)
|
||||
|
||||
const LinkComponent = ({ attributes, children, element }) => {
|
||||
const selected = useSelected()
|
||||
return (
|
||||
<a
|
||||
{...attributes}
|
||||
href={element.url}
|
||||
className={
|
||||
selected
|
||||
? css`
|
||||
box-shadow: 0 0 0 3px #ddd;
|
||||
`
|
||||
: ''
|
||||
}
|
||||
>
|
||||
<InlineChromiumBugfix />
|
||||
{children}
|
||||
<InlineChromiumBugfix />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
const EditableButtonComponent = ({ attributes, children }) => {
|
||||
return (
|
||||
<button
|
||||
{...attributes}
|
||||
onClick={ev => ev.preventDefault()}
|
||||
// Margin is necessary to clearly show the cursor adjacent to the button
|
||||
className={css`
|
||||
margin: 0 0.1em;
|
||||
`}
|
||||
>
|
||||
<InlineChromiumBugfix />
|
||||
{children}
|
||||
<InlineChromiumBugfix />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const Element = props => {
|
||||
const { attributes, children, element } = props
|
||||
switch (element.type) {
|
||||
case 'link':
|
||||
return <LinkComponent {...props} />
|
||||
case 'button':
|
||||
return <EditableButtonComponent {...props} />
|
||||
default:
|
||||
return <p {...attributes}>{children}</p>
|
||||
}
|
||||
}
|
||||
|
||||
const AddLinkButton = () => {
|
||||
const editor = useSlate()
|
||||
return (
|
||||
<Button
|
||||
active={isLinkActive(editor)}
|
||||
onMouseDown={event => {
|
||||
event.preventDefault()
|
||||
const url = window.prompt('Enter the URL of the link:')
|
||||
if (!url) return
|
||||
insertLink(editor, url)
|
||||
}}
|
||||
>
|
||||
<Icon>link</Icon>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
const RemoveLinkButton = () => {
|
||||
const editor = useSlate()
|
||||
|
||||
return (
|
||||
<Button
|
||||
active={isLinkActive(editor)}
|
||||
onMouseDown={event => {
|
||||
if (isLinkActive(editor)) {
|
||||
unwrapLink(editor)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon>link_off</Icon>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
const ToggleEditableButtonButton = () => {
|
||||
const editor = useSlate()
|
||||
return (
|
||||
<Button
|
||||
active
|
||||
onMouseDown={event => {
|
||||
event.preventDefault()
|
||||
if (isButtonActive(editor)) {
|
||||
unwrapButton(editor)
|
||||
} else {
|
||||
insertButton(editor)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon>smart_button</Icon>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export default InlinesExample
|
@ -1,200 +0,0 @@
|
||||
import React, { useState, useMemo } from 'react'
|
||||
import isUrl from 'is-url'
|
||||
import { css } from 'emotion'
|
||||
import { Slate, Editable, withReact, useSlate, useSelected } from 'slate-react'
|
||||
import {
|
||||
Transforms,
|
||||
Editor,
|
||||
Range,
|
||||
createEditor,
|
||||
Element as SlateElement,
|
||||
Descendant,
|
||||
} from 'slate'
|
||||
import { withHistory } from 'slate-history'
|
||||
import { LinkElement } from './custom-types'
|
||||
|
||||
import { Button, Icon, Toolbar } from '../components'
|
||||
|
||||
const LinkExample = () => {
|
||||
const [value, setValue] = useState<Descendant[]>(initialValue)
|
||||
const editor = useMemo(
|
||||
() => withLinks(withHistory(withReact(createEditor()))),
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<Slate editor={editor} value={value} onChange={value => setValue(value)}>
|
||||
<Toolbar>
|
||||
<LinkButton />
|
||||
<RemoveLinkButton />
|
||||
</Toolbar>
|
||||
<Editable
|
||||
renderElement={props => <Element {...props} />}
|
||||
placeholder="Enter some text..."
|
||||
/>
|
||||
</Slate>
|
||||
)
|
||||
}
|
||||
|
||||
const withLinks = editor => {
|
||||
const { insertData, insertText, isInline } = editor
|
||||
|
||||
editor.isInline = element => {
|
||||
return element.type === 'link' ? true : isInline(element)
|
||||
}
|
||||
|
||||
editor.insertText = text => {
|
||||
if (text && isUrl(text)) {
|
||||
wrapLink(editor, text)
|
||||
} else {
|
||||
insertText(text)
|
||||
}
|
||||
}
|
||||
|
||||
editor.insertData = data => {
|
||||
const text = data.getData('text/plain')
|
||||
|
||||
if (text && isUrl(text)) {
|
||||
wrapLink(editor, text)
|
||||
} else {
|
||||
insertData(data)
|
||||
}
|
||||
}
|
||||
|
||||
return editor
|
||||
}
|
||||
|
||||
const insertLink = (editor, url) => {
|
||||
if (editor.selection) {
|
||||
wrapLink(editor, url)
|
||||
}
|
||||
}
|
||||
|
||||
const isLinkActive = editor => {
|
||||
const [link] = Editor.nodes(editor, {
|
||||
match: n =>
|
||||
!Editor.isEditor(n) && SlateElement.isElement(n) && n.type === 'link',
|
||||
})
|
||||
return !!link
|
||||
}
|
||||
|
||||
const unwrapLink = editor => {
|
||||
Transforms.unwrapNodes(editor, {
|
||||
match: n =>
|
||||
!Editor.isEditor(n) && SlateElement.isElement(n) && n.type === 'link',
|
||||
})
|
||||
}
|
||||
|
||||
const wrapLink = (editor, url) => {
|
||||
if (isLinkActive(editor)) {
|
||||
unwrapLink(editor)
|
||||
}
|
||||
|
||||
const { selection } = editor
|
||||
const isCollapsed = selection && Range.isCollapsed(selection)
|
||||
const link: LinkElement = {
|
||||
type: 'link',
|
||||
url,
|
||||
children: isCollapsed ? [{ text: url }] : [],
|
||||
}
|
||||
|
||||
if (isCollapsed) {
|
||||
Transforms.insertNodes(editor, link)
|
||||
} else {
|
||||
Transforms.wrapNodes(editor, link, { split: true })
|
||||
Transforms.collapse(editor, { edge: 'end' })
|
||||
}
|
||||
}
|
||||
|
||||
const LinkComponent = ({ attributes, children, element }) => {
|
||||
const selected = useSelected()
|
||||
return (
|
||||
<a
|
||||
{...attributes}
|
||||
href={element.url}
|
||||
className={
|
||||
selected
|
||||
? css`
|
||||
box-shadow: 0 0 0 3px #ddd;
|
||||
`
|
||||
: ''
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
const Element = props => {
|
||||
const { attributes, children, element } = props
|
||||
switch (element.type) {
|
||||
case 'link':
|
||||
return <LinkComponent {...props} />
|
||||
default:
|
||||
return <p {...attributes}>{children}</p>
|
||||
}
|
||||
}
|
||||
|
||||
const LinkButton = () => {
|
||||
const editor = useSlate()
|
||||
return (
|
||||
<Button
|
||||
active={isLinkActive(editor)}
|
||||
onMouseDown={event => {
|
||||
event.preventDefault()
|
||||
const url = window.prompt('Enter the URL of the link:')
|
||||
if (!url) return
|
||||
insertLink(editor, url)
|
||||
}}
|
||||
>
|
||||
<Icon>link</Icon>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
const RemoveLinkButton = () => {
|
||||
const editor = useSlate()
|
||||
|
||||
return (
|
||||
<Button
|
||||
active={isLinkActive(editor)}
|
||||
onMouseDown={event => {
|
||||
if (isLinkActive(editor)) {
|
||||
unwrapLink(editor)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon>link_off</Icon>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
const initialValue: Descendant[] = [
|
||||
{
|
||||
type: 'paragraph',
|
||||
children: [
|
||||
{
|
||||
text: 'In addition to block nodes, you can create inline nodes, like ',
|
||||
},
|
||||
{
|
||||
type: 'link',
|
||||
url: 'https://en.wikipedia.org/wiki/Hypertext',
|
||||
children: [{ text: 'hyperlinks' }],
|
||||
},
|
||||
{
|
||||
text: '!',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
children: [
|
||||
{
|
||||
text:
|
||||
'This example shows hyperlinks in action. It features two ways to add links. You can either add a link via the toolbar icon above, or if you want in on a little secret, copy a URL to your keyboard and paste it while a range of text is selected.',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export default LinkExample
|
@ -15,7 +15,7 @@ import ForcedLayout from '../../examples/forced-layout'
|
||||
import HoveringToolbar from '../../examples/hovering-toolbar'
|
||||
import HugeDocument from '../../examples/huge-document'
|
||||
import Images from '../../examples/images'
|
||||
import Links from '../../examples/links'
|
||||
import Inlines from '../../examples/inlines'
|
||||
import MarkdownPreview from '../../examples/markdown-preview'
|
||||
import MarkdownShortcuts from '../../examples/markdown-shortcuts'
|
||||
import Mentions from '../../examples/mentions'
|
||||
@ -41,7 +41,7 @@ const EXAMPLES = [
|
||||
['Hovering Toolbar', HoveringToolbar, 'hovering-toolbar'],
|
||||
['Huge Document', HugeDocument, 'huge-document'],
|
||||
['Images', Images, 'images'],
|
||||
['Links', Links, 'links'],
|
||||
['Inlines', Inlines, 'inlines'],
|
||||
['Markdown Preview', MarkdownPreview, 'markdown-preview'],
|
||||
['Markdown Shortcuts', MarkdownShortcuts, 'markdown-shortcuts'],
|
||||
['Mentions', Mentions, 'mentions'],
|
||||
|
Loading…
x
Reference in New Issue
Block a user