1
0
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:
Jim Fisher 2021-10-22 21:19:41 +01:00 committed by GitHub
parent 72160fac08
commit f1b7d18f43
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 349 additions and 214 deletions

View 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')
})
})

View File

@ -1,11 +0,0 @@
describe('Links example', () => {
beforeEach(() => {
cy.visit('examples/links')
})
it('contains link', () => {
cy.findByRole('textbox')
.find('a')
.should('contain.text', 'hyperlinks')
})
})

View File

@ -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.

View File

@ -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
View 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

View File

@ -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

View File

@ -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'],