Implement link editor (#20)

* feat: implement link editor

* feat: update slate to 0.58.3
This commit is contained in:
George 2020-06-21 23:52:52 +03:00 committed by GitHub
parent ce0c86fb3c
commit eb370eaa1e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 175 additions and 51 deletions

View File

@ -31,7 +31,7 @@
"@types/socket.io": "^2.1.4", "@types/socket.io": "^2.1.4",
"automerge": "0.14.0", "automerge": "0.14.0",
"lodash": "^4.17.15", "lodash": "^4.17.15",
"slate": "0.58.0", "slate": "0.58.3",
"socket.io": "^2.3.0", "socket.io": "^2.3.0",
"typescript": "^3.8.3" "typescript": "^3.8.3"
}, },

View File

@ -26,7 +26,7 @@
}, },
"dependencies": { "dependencies": {
"automerge": "0.14.0", "automerge": "0.14.0",
"slate": "0.58.0", "slate": "0.58.3",
"typescript": "^3.8.3" "typescript": "^3.8.3"
}, },
"devDependencies": { "devDependencies": {

View File

@ -28,7 +28,7 @@
"@babel/preset-react": "^7.0.0", "@babel/preset-react": "^7.0.0",
"@slate-collaborative/bridge": "0.6.1", "@slate-collaborative/bridge": "0.6.1",
"automerge": "0.14.0", "automerge": "0.14.0",
"slate": "0.58.0", "slate": "0.58.3",
"socket.io-client": "^2.3.0", "socket.io-client": "^2.3.0",
"typescript": "^3.8.3" "typescript": "^3.8.3"
}, },

View File

@ -8,6 +8,7 @@
"@slate-collaborative/backend": "^0.6.2", "@slate-collaborative/backend": "^0.6.2",
"@slate-collaborative/client": "^0.6.2", "@slate-collaborative/client": "^0.6.2",
"@types/faker": "^4.1.5", "@types/faker": "^4.1.5",
"@types/is-url": "^1.2.28",
"@types/jest": "24.0.18", "@types/jest": "24.0.18",
"@types/node": "12.7.5", "@types/node": "12.7.5",
"@types/randomcolor": "^0.5.4", "@types/randomcolor": "^0.5.4",
@ -16,15 +17,16 @@
"cross-env": "^6.0.3", "cross-env": "^6.0.3",
"express": "^4.17.1", "express": "^4.17.1",
"faker": "^4.1.0", "faker": "^4.1.0",
"is-url": "^1.2.4",
"lodash": "^4.17.15", "lodash": "^4.17.15",
"nodemon": "^1.19.2", "nodemon": "^1.19.2",
"randomcolor": "^0.5.4", "randomcolor": "^0.5.4",
"react": "^16.9.0", "react": "^16.9.0",
"react-dom": "^16.9.0", "react-dom": "^16.9.0",
"react-scripts": "3.1.2", "react-scripts": "3.1.2",
"slate": "0.58.0", "slate": "0.58.3",
"slate-history": "0.58.0", "slate-history": "0.58.3",
"slate-react": "0.58.0", "slate-react": "0.58.3",
"typescript": "^3.8.3" "typescript": "^3.8.3"
}, },
"scripts": { "scripts": {

View File

@ -14,6 +14,8 @@ import { Instance, Title, H4, Button } from './Components'
import EditorFrame from './EditorFrame' import EditorFrame from './EditorFrame'
import { withLinks } from './plugins/link'
const defaultValue: Node[] = [ const defaultValue: Node[] = [
{ {
type: 'paragraph', type: 'paragraph',
@ -47,7 +49,7 @@ const Client: React.FC<ClientProps> = ({ id, name, slug, removeUser }) => {
) )
const editor = useMemo(() => { const editor = useMemo(() => {
const slateEditor = withReact(withHistory(createEditor())) const slateEditor = withLinks(withReact(withHistory(createEditor())))
const origin = const origin =
process.env.NODE_ENV === 'production' process.env.NODE_ENV === 'production'

View File

@ -83,4 +83,11 @@ export const ClientFrame = styled.div`
color: #aaa; color: #aaa;
font-style: italic; font-style: italic;
} }
a {
color: purple;
text-decoration: none;
}
a:visited {
color: darkmagenta;
}
` `

View File

@ -1,6 +1,7 @@
import React, { useCallback } from 'react' import React, { useCallback } from 'react'
import { Transforms, Editor, Node } from 'slate' import { Node } from 'slate'
import { import {
Slate, Slate,
ReactEditor, ReactEditor,
@ -13,7 +14,9 @@ import { ClientFrame, IconButton, Icon } from './Components'
import Caret from './Caret' import Caret from './Caret'
const LIST_TYPES: string[] = ['numbered-list', 'bulleted-list'] import { isBlockActive, toggleBlock } from './plugins/block'
import { isMarkActive, toggleMark } from './plugins/mark'
import { isLinkActive, insertLink, unwrapLink } from './plugins/link'
export interface EditorFrame { export interface EditorFrame {
editor: ReactEditor editor: ReactEditor
@ -51,11 +54,15 @@ const EditorFrame: React.FC<EditorFrame> = ({
<MarkButton format="italic" icon="format_italic" /> <MarkButton format="italic" icon="format_italic" />
<MarkButton format="underline" icon="format_underlined" /> <MarkButton format="underline" icon="format_underlined" />
<MarkButton format="code" icon="code" /> <MarkButton format="code" icon="code" />
<BlockButton format="heading-one" icon="looks_one" /> <BlockButton format="heading-one" icon="looks_one" />
<BlockButton format="heading-two" icon="looks_two" /> <BlockButton format="heading-two" icon="looks_two" />
<BlockButton format="block-quote" icon="format_quote" /> <BlockButton format="block-quote" icon="format_quote" />
<BlockButton format="numbered-list" icon="format_list_numbered" /> <BlockButton format="numbered-list" icon="format_list_numbered" />
<BlockButton format="bulleted-list" icon="format_list_bulleted" /> <BlockButton format="bulleted-list" icon="format_list_bulleted" />
<LinkButton />
</div> </div>
<Editable <Editable
@ -70,50 +77,14 @@ const EditorFrame: React.FC<EditorFrame> = ({
export default EditorFrame export default EditorFrame
const toggleBlock = (editor: any, format: any) => {
const isActive = isBlockActive(editor, format)
const isList = LIST_TYPES.includes(format)
Transforms.unwrapNodes(editor, {
match: n => LIST_TYPES.includes(n.type as any),
split: true
})
Transforms.setNodes(editor, {
type: isActive ? 'paragraph' : isList ? 'list-item' : format
})
if (!isActive && isList) {
const block = { type: format, children: [] }
Transforms.wrapNodes(editor, block)
}
}
const toggleMark = (editor: any, format: any) => {
const isActive = isMarkActive(editor, format)
if (isActive) {
Editor.removeMark(editor, format)
} else {
Editor.addMark(editor, format, true)
}
}
const isBlockActive = (editor: any, format: any) => {
const [match] = Editor.nodes(editor, {
match: n => n.type === format
})
return !!match
}
const isMarkActive = (editor: any, format: any) => {
const marks = Editor.marks(editor)
return marks ? marks[format] === true : false
}
const Element: React.FC<any> = ({ attributes, children, element }) => { const Element: React.FC<any> = ({ attributes, children, element }) => {
switch (element.type) { switch (element.type) {
case 'link':
return (
<a {...attributes} href={element.href}>
{children}
</a>
)
case 'block-quote': case 'block-quote':
return <blockquote {...attributes}>{children}</blockquote> return <blockquote {...attributes}>{children}</blockquote>
case 'bulleted-list': case 'bulleted-list':
@ -193,3 +164,26 @@ const MarkButton: React.FC<any> = ({ format, icon }) => {
</IconButton> </IconButton>
) )
} }
const LinkButton = () => {
const editor = useSlate()
const isActive = isLinkActive(editor)
return (
<IconButton
active={isActive}
onMouseDown={event => {
event.preventDefault()
if (isActive) return unwrapLink(editor)
const url = window.prompt('Enter the URL of the link:')
url && insertLink(editor, url)
}}
>
<Icon className="material-icons">link</Icon>
</IconButton>
)
}

View File

@ -0,0 +1,30 @@
import { Transforms, Editor } from 'slate'
const LIST_TYPES: string[] = ['numbered-list', 'bulleted-list']
export const toggleBlock = (editor: any, format: any) => {
const isActive = isBlockActive(editor, format)
const isList = LIST_TYPES.includes(format)
Transforms.unwrapNodes(editor, {
match: n => LIST_TYPES.includes(n.type as any),
split: true
})
Transforms.setNodes(editor, {
type: isActive ? 'paragraph' : isList ? 'list-item' : format
})
if (!isActive && isList) {
const block = { type: format, children: [] }
Transforms.wrapNodes(editor, block)
}
}
export const isBlockActive = (editor: any, format: any) => {
const [match] = Editor.nodes(editor, {
match: n => n.type === format
})
return !!match
}

View File

@ -0,0 +1,73 @@
import isUrl from 'is-url'
import { Transforms, Editor, Range } from 'slate'
export interface LinkEditor extends Editor {
insertData: (data: any) => void
}
export const withLinks = <T extends Editor>(editor: T) => {
const e = editor as T & LinkEditor
const { insertData, insertText, isInline } = e
e.isInline = (element: any) => {
return element.type === 'link' ? true : isInline(element)
}
e.insertText = (text: string) => {
if (text && isUrl(text)) {
wrapLink(editor, text)
} else {
insertText(text)
}
}
e.insertData = (data: any) => {
const text = data.getData('text/plain')
if (text && isUrl(text)) {
wrapLink(editor, text)
} else {
insertData(data)
}
}
return editor
}
export const insertLink = (editor: Editor, href: string) => {
if (editor.selection) {
wrapLink(editor, href)
}
}
export const isLinkActive = (editor: Editor) => {
const [link] = Editor.nodes(editor, { match: n => n.type === 'link' })
return !!link
}
export const unwrapLink = (editor: Editor) => {
Transforms.unwrapNodes(editor, { match: n => n.type === 'link' })
}
export const wrapLink = (editor: Editor, href: string) => {
if (isLinkActive(editor)) {
unwrapLink(editor)
}
const { selection } = editor
const isCollapsed = selection && Range.isCollapsed(selection)
const link = {
type: 'link',
href,
children: isCollapsed ? [{ text: href }] : []
}
if (isCollapsed) {
Transforms.insertNodes(editor, link)
} else {
Transforms.wrapNodes(editor, link, { split: true })
Transforms.collapse(editor, { edge: 'end' })
}
}

View File

@ -0,0 +1,16 @@
import { Editor } from 'slate'
export const toggleMark = (editor: Editor, format: any) => {
const isActive = isMarkActive(editor, format)
if (isActive) {
Editor.removeMark(editor, format)
} else {
Editor.addMark(editor, format, true)
}
}
export const isMarkActive = (editor: Editor, format: any) => {
const marks = Editor.marks(editor)
return marks ? marks[format] === true : false
}