├── .gitignore ├── .nvmrc ├── LICENSE.md ├── index.html ├── package.json ├── readme.md ├── src ├── __example │ └── __example.tsx ├── auto-complete │ └── index.tsx ├── duplicating-nodes │ └── index.tsx ├── element-placeholders │ └── index.tsx ├── entities │ └── entities.tsx ├── highlight-last-selection │ └── index.tsx ├── iframe-elements │ └── iframe-elements.tsx ├── index.css ├── index.tsx └── types.d.ts ├── tsconfig.json ├── yarn-error.log └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | .cache 2 | .parcel-cache 3 | .DS_Store 4 | node_modules 5 | dist -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 14 -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) <2021> 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Slate Patterns 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "slate-patterns", 3 | "version": "0.0.1", 4 | "description": "Patterns for slate-js", 5 | "author": "Julian Krispel-Samsel", 6 | "license": "MIT", 7 | "repository": "juliankrispel/slate-patterns", 8 | "private": true, 9 | "homepage": "jkrsp.com", 10 | "main": "dist/index.js", 11 | "scripts": { 12 | "build": "rm -rf dist && ./node_modules/.bin/tsc", 13 | "prepublish": "npm run build", 14 | "start": "parcel serve index.html", 15 | "build:example": "rm -rf build-site && parcel build --public-url \"/slate-patterns/\" -d \"build-site\" index.html", 16 | "deploy": "npm run build:example && gh-pages -d build-site" 17 | }, 18 | "peerDependencies": { 19 | "react": "^17.0.1" 20 | }, 21 | "devDependencies": { 22 | "@emotion/css": "^11.1.3", 23 | "@types/react": "^17.0.0", 24 | "@types/react-dom": "^17.0.0", 25 | "@types/react-highlight-words": "^0.16.2", 26 | "emotion": "^11.0.0", 27 | "gh-pages": "^3.1.0", 28 | "parcel": "^2.0.0-beta.1", 29 | "react": "^17.0.1", 30 | "react-dom": "^17.0.1", 31 | "typescript": "^4.1.3", 32 | "typography": "^0.16.19", 33 | "use-debounced-effect-hook": "^1.1.62" 34 | }, 35 | "dependencies": { 36 | "@types/react-router-dom": "^5.1.7", 37 | "filestack-js": "^3.25.0", 38 | "lodash.debounce": "^4.0.8", 39 | "nanoid": "^3.3.1", 40 | "react-highlight-words": "^0.17.0", 41 | "react-router-dom": "^5.2.0", 42 | "slate": "^0.62.1", 43 | "slate-history": "^0.62.0", 44 | "slate-react": "^0.62.1", 45 | "use-debounced-effect": "^1.2.0", 46 | "use-text-selection": "^1.1.3", 47 | "zustand": "^3.7.1" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | Set of patterns I use across projects. 2 | 3 | These patterns are compatible with `slate 0.61` and up 4 | -------------------------------------------------------------------------------- /src/__example/__example.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo, useState } from "react"; 2 | import { createEditor, Node, Transforms } from "slate"; 3 | import { withHistory } from "slate-history"; 4 | import { Editable, ReactEditor, Slate, withReact } from "slate-react"; 5 | 6 | export function Example() { 7 | const editor = useMemo(() => withHistory(withReact(createEditor())) , []) 8 | 9 | const [value, setValue] = useState([ 10 | { 11 | children: [{ 12 | text: "Hey there" 13 | }], 14 | }, 15 | ]); 16 | 17 | return 18 | 19 | 20 | } 21 | 22 | -------------------------------------------------------------------------------- /src/auto-complete/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useLayoutEffect, useMemo, useState, useCallback } from "react"; 2 | import { createEditor, Editor, Node, Range, Transforms } from "slate"; 3 | import { withHistory } from "slate-history"; 4 | import { DefaultElement, Editable, ReactEditor, Slate, useSlateStatic, withReact } from "slate-react"; 5 | import { debounce } from "lodash"; 6 | import Highlighter from "react-highlight-words"; 7 | import { useTextSelection } from 'use-text-selection' 8 | 9 | type ApiResponse = { 10 | timestamp?: string 11 | error?: string 12 | message?: string 13 | results?: DadJoke[] 14 | } 15 | 16 | type DadJoke = { 17 | id: string 18 | joke: string 19 | } 20 | 21 | function AutoComplete({ query }: { query: string }) { 22 | const editor = useSlateStatic() 23 | const [options, setOptions] = useState([]); 24 | const [error, setError] = useState() 25 | 26 | const sel = useTextSelection() 27 | 28 | const { selection } = editor 29 | 30 | const apiQuery = useCallback(debounce(async (term: string) => { 31 | console.log('api query') 32 | try { 33 | const res = await fetch(`https://icanhazdadjoke.com/search?term=${term}&limit=10`, { 34 | headers: { 35 | 'accept': 'application/json' 36 | }, 37 | }) 38 | const json: ApiResponse = await res.json() 39 | console.log(json.results) 40 | if (json.message != null) { 41 | setOptions([]) 42 | setError(json.message) 43 | } else if (json.results != null) { 44 | setOptions(json.results) 45 | setError(undefined) 46 | } 47 | } catch (err) { 48 | setError(err.message) 49 | // throw new Error(err) 50 | } 51 | }, 600), []) 52 | 53 | useEffect(() => void apiQuery(query), [query]) 54 | 55 | return ( 56 |
67 | {error != null && ( 68 |
69 | {error} 70 |
71 | )} 72 |
73 | {options.map((val) => ( 74 | 119 | ))} 120 |
121 |
122 | ); 123 | } 124 | 125 | export function AutoCompleteEditor() { 126 | const editor = useMemo(() => withHistory(withReact(createEditor())) , []) 127 | 128 | const [value, setValue] = useState([ 129 | { 130 | children: [ 131 | { 132 | text: "", 133 | }, 134 | ], 135 | }, 136 | ]); 137 | 138 | const { selection } = editor 139 | 140 | let showAutoComplete = false 141 | let searchString = '' 142 | 143 | if (selection != null && Range.isCollapsed(selection)) { 144 | const [_, path] = Editor.node(editor, selection, { depth: 1 }) 145 | const range = Editor.range(editor, Editor.start(editor, path), selection.focus) 146 | const text = Editor.string(editor, range) 147 | const matches = text.match(/@([^\s]+$)/) 148 | 149 | if (matches != null) { 150 | showAutoComplete = true 151 | searchString = matches[1] 152 | } 153 | } 154 | 155 | return ( 156 | 157 | { 158 | if (props.element.type === 'dad-joke') { 159 | return
160 | {props.children} 161 |
162 | } 163 | return 164 | }}/> 165 | {showAutoComplete && } 166 |
167 | ); 168 | } 169 | 170 | -------------------------------------------------------------------------------- /src/duplicating-nodes/index.tsx: -------------------------------------------------------------------------------- 1 | import { Descendant, Element } from "slate"; 2 | 3 | export function cloneChildren(children: Descendant[]): Descendant[] { 4 | return children.map((node) => { 5 | if (Element.isElement(node)) { 6 | return { 7 | ...node, 8 | children: cloneChildren(node.children), 9 | }; 10 | } 11 | 12 | return { ...node }; 13 | }); 14 | } -------------------------------------------------------------------------------- /src/element-placeholders/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo, useState } from "react"; 2 | import { createEditor, Editor, Text, Node, Range } from "slate"; 3 | import { withHistory } from "slate-history"; 4 | import { DefaultLeaf, Editable, ReactEditor, Slate, withReact } from "slate-react"; 5 | 6 | export function ElementPlaceholders() { 7 | const editor = useMemo(() => withHistory(withReact(createEditor())) , []) 8 | 9 | const [value, setValue] = useState([ 10 | { 11 | children: [{ 12 | text: "Hey there" 13 | }], 14 | }, 15 | ]); 16 | 17 | return ( 18 | 19 | 24 | 25 | ); 26 | } 27 | 28 | -------------------------------------------------------------------------------- /src/entities/entities.tsx: -------------------------------------------------------------------------------- 1 | import React, { Children, useMemo, useState } from "react"; 2 | import { createEditor, Editor, Node, Transforms } from "slate"; 3 | import { withHistory } from "slate-history"; 4 | import create from 'zustand' 5 | import { DefaultElement, Editable, Slate, withReact } from "slate-react"; 6 | import { init } from 'filestack-js'; 7 | import { nanoid } from 'nanoid' 8 | const filestack = init('AmInuwFLJS2qXojnOhFZyz'); 9 | 10 | type Entity = { 11 | url?: string 12 | } 13 | 14 | type EntityState = { 15 | entities: { 16 | [entityId: string]: Entity; 17 | }; 18 | upsertEntity: (entityId: string, entity: Entity) => void; 19 | }; 20 | 21 | const useStore = create((set) => ({ 22 | entities: { 23 | e1: { 24 | url: "https://placekitten.com/400/300", 25 | }, 26 | }, 27 | upsertEntity: (id, entity) => 28 | set((state) => ({ 29 | entities: { 30 | ...state.entities, 31 | [id]: entity, 32 | }, 33 | })), 34 | })); 35 | 36 | export function Entities() { 37 | const editor = useMemo(() => { 38 | const _editor = withHistory(withReact(createEditor())); 39 | const { isVoid } = _editor; 40 | _editor.isVoid = (el) => el.id != null || isVoid(el); 41 | return _editor; 42 | }, []); 43 | 44 | const [value, setValue] = useState([ 45 | { 46 | children: [ 47 | { 48 | text: "Hey there", 49 | }, 50 | ], 51 | }, 52 | { 53 | id: "e1", 54 | children: [{ text: "" }], 55 | }, 56 | ]); 57 | 58 | return ( 59 | 60 | { 63 | const files = Array.from(event.dataTransfer.items).map( 64 | (item) => item.getAsFile() as File 65 | ) 66 | 67 | files.forEach(async (file) => { 68 | const id = nanoid() 69 | const entityState = useStore.getState() 70 | entityState.upsertEntity(id, {}); 71 | Transforms.insertNodes(editor, { id, children: [{ text: "" }] }); 72 | const uploaded = await filestack.upload(file) 73 | entityState.upsertEntity(id, { url: uploaded.url }); 74 | }); 75 | }} 76 | renderElement={(props) => { 77 | const entity = useStore( 78 | (state) => 79 | props.element.id != null && state.entities[props.element.id] 80 | ); 81 | 82 | if (entity) { 83 | return ( 84 |
85 | {entity.url != null ? ( 86 | 87 | ) : ( 88 | Loading 89 | )} 90 | {props.children} 91 |
92 | ); 93 | } 94 | 95 | return ; 96 | }} 97 | /> 98 |
99 | ); 100 | } 101 | 102 | -------------------------------------------------------------------------------- /src/highlight-last-selection/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useMemo, useState } from "react"; 2 | import { createEditor, Editor, Node, Range, Text } from "slate"; 3 | import { withHistory } from "slate-history"; 4 | import { DefaultLeaf, Editable, ReactEditor, Slate, useEditor, useSelected, useSlate, withReact } from "slate-react"; 5 | 6 | export function EditableWithDecorate() { 7 | const editor = useSlate() 8 | const [lastActiveSelection, setLastActiveSelection] = useState() 9 | 10 | useEffect(() => { 11 | if (editor.selection != null) setLastActiveSelection(editor.selection) 12 | }, [editor.selection]) 13 | 14 | const decorate = useCallback( 15 | ([node, path]) => { 16 | if ( 17 | Text.isText(node) && 18 | editor.selection == null && 19 | lastActiveSelection != null 20 | ) { 21 | const intersection = Range.intersection(lastActiveSelection, Editor.range(editor, path)) 22 | 23 | if (intersection == null) { 24 | return [] 25 | } 26 | 27 | const range = { 28 | highlighted: true, 29 | ...intersection 30 | }; 31 | 32 | return [range] 33 | } 34 | return []; 35 | }, 36 | [lastActiveSelection] 37 | ); 38 | 39 | return { 41 | if (props.leaf.highlighted) { 42 | return {props.children} 43 | } 44 | return 45 | }} 46 | decorate={decorate} 47 | placeholder="Write something..." 48 | /> 49 | } 50 | 51 | export function HighlightLastActiveSelection() { 52 | const editor = useMemo(() => withHistory(withReact(createEditor())) , []) 53 | 54 | const [value, setValue] = useState([ 55 | { 56 | children: [{ 57 | text: "Make a text selection and click on the above input" 58 | }] 59 | }, 60 | { children: [{ 61 | text: "You'll then see that your last selection will be highlighted in yellow" 62 | }] 63 | }, 64 | ]); 65 | 66 | return
67 | 68 | 69 | 70 | 71 |
72 | } 73 | 74 | -------------------------------------------------------------------------------- /src/iframe-elements/iframe-elements.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo, useState } from "react"; 2 | import { createEditor, Descendant, Node, Transforms } from "slate"; 3 | import { withHistory } from "slate-history"; 4 | import { Editable, ReactEditor, Slate, withReact } from "slate-react"; 5 | 6 | export function IFrameElements() { 7 | const editor = useMemo(() => { 8 | const _editor = withHistory(withReact(createEditor())) 9 | _editor.isVoid = (el) => el.type === 'youtube' 10 | return _editor 11 | }, []) 12 | 13 | const [value, setValue] = useState([ 14 | { 15 | children: [{ 16 | text: "Hey there" 17 | }], 18 | }, 19 | { 20 | type: 'youtube', 21 | videoId: 'CvZjupLir-8', 22 | children: [{ 23 | text: '' 24 | }] 25 | }, 26 | { 27 | children: [{ 28 | text: "" 29 | }], 30 | }, 31 | ]); 32 | 33 | const youtubeRegex = /^(?:(?:https?:)?\/\/)?(?:(?:www|m)\.)?(?:(?:youtube\.com|youtu.be))(?:\/(?:[\w\-]+\?v=|embed\/|v\/)?)([\w\-]+)(?:\S+)?$/ 34 | 35 | return 36 | { 38 | const pastedText = event.clipboardData?.getData('text')?.trim() 39 | const matches = pastedText.match(youtubeRegex) 40 | if (matches != null) { 41 | const [_, videoId] = matches 42 | event.preventDefault() 43 | Transforms.insertNodes(editor, [{ 44 | type: 'youtube', 45 | videoId, 46 | children: [{ 47 | text: '' 48 | }] 49 | }]) 50 | } 51 | }} 52 | renderElement={({ attributes, element, children }) => { 53 | if (element.type === 'youtube') { 54 | return
55 | 61 | {children} 62 |
63 | } else { 64 | return

{children}

65 | } 66 | }} 67 | placeholder="Write something..."/> 68 |
69 | } 70 | 71 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: sans-serif; 3 | font-size: 1.5em; 4 | line-height: 1.3em; 5 | padding: 2em; 6 | } 7 | 8 | blockquote { 9 | padding: 1em; 10 | margin: .5em 0 1.2em; 11 | color: white; 12 | background: rgb(170, 208, 255); 13 | border-radius: 5px; 14 | position: relative; 15 | background: #00bd61; 16 | border-radius: .4em; 17 | 18 | } 19 | 20 | blockquote:after { 21 | content: ''; 22 | position: absolute; 23 | bottom: 0; 24 | left: 50%; 25 | width: 0; 26 | height: 0; 27 | border: 21px solid transparent; 28 | border-top-color: #00bd61; 29 | border-bottom: 0; 30 | border-left: 0; 31 | margin-left: -10.5px; 32 | margin-bottom: -21px; 33 | } 34 | 35 | .options { 36 | background: #eee; 37 | } 38 | 39 | .option { 40 | border: none; 41 | display: block; 42 | width: 100%; 43 | font-size: .8em; 44 | border-top: 1px solid #ccc; 45 | font-size: 1em; 46 | text-align: left; 47 | padding: .5em; 48 | background: none; 49 | } -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { createElement } from 'react' 2 | import { render } from "react-dom"; 3 | import { 4 | BrowserRouter as Router, 5 | Switch, 6 | Route, 7 | Link, 8 | useHistory, 9 | Redirect, 10 | useLocation 11 | } from "react-router-dom"; 12 | import { AutoCompleteEditor } from './auto-complete'; 13 | import { ElementPlaceholders } from './element-placeholders'; 14 | import { Entities } from './entities/entities'; 15 | import { HighlightLastActiveSelection } from './highlight-last-selection'; 16 | import { IFrameElements } from './iframe-elements/iframe-elements'; 17 | import './index.css' 18 | 19 | const pages: { 20 | [key: string]: { 21 | title: string, 22 | component: any 23 | } 24 | } = { 25 | '/element-placeholders': { 26 | title: 'Element placeholders', 27 | component: ElementPlaceholders, 28 | }, 29 | '/iframe-embeds': { 30 | title: 'I frame embeds', 31 | component: IFrameElements, 32 | }, 33 | '/highlight-last-active-selection': { 34 | title: 'Highlight last active selection', 35 | component: HighlightLastActiveSelection, 36 | }, 37 | '/auto-complete': { 38 | title: 'Auto complete', 39 | component: AutoCompleteEditor, 40 | }, 41 | '/entities': { 42 | title: 'Entities', 43 | component: Entities, 44 | }, 45 | } 46 | 47 | function Nav() { 48 | const history = useHistory() 49 | const location = useLocation() 50 | 51 | return ( 52 | 66 | ); 67 | } 68 | 69 | function App () { 70 | return ( 71 | 72 | 75 |
76 | 77 | {Object.keys(pages).map((path) => ( 78 | 79 | {createElement(pages[path].component)} 80 | 81 | ))} 82 | 83 | 84 | 85 | 86 |
87 |
88 | ); 89 | } 90 | 91 | render(, document.getElementById('root')) -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | import { BaseElement, BaseText } from "slate"; 2 | import { ReactEditor } from "slate-react"; 3 | 4 | export type NorrisJoke = { 5 | icon_url: string, 6 | id: string, 7 | updated_at: string, 8 | url: string, 9 | value: string 10 | } 11 | 12 | declare module 'slate' { 13 | interface CustomTypes { 14 | Editor: ReactEditor 15 | Element: BaseElement & { 16 | videoId?: string 17 | type?: 'youtube' | 'dad-joke' 18 | id?: string 19 | } 20 | Text: BaseText & { 21 | highlighted?: boolean 22 | placeholder?: boolean 23 | } 24 | } 25 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "commonjs", 5 | "lib": ["DOM", "es2020"], 6 | "outDir": "dist", 7 | "rootDir": "src", 8 | "jsx": "react", 9 | "strict": true, 10 | "esModuleInterop": true, 11 | "declaration": true, 12 | "skipLibCheck": true, 13 | "forceConsistentCasingInFileNames": true 14 | }, 15 | "include": ["src"], 16 | "exclude": ["node_modules"] 17 | } 18 | --------------------------------------------------------------------------------