diff --git a/.changeset/dull-eels-hammer.md b/.changeset/dull-eels-hammer.md
new file mode 100644
index 000000000..6b10d6912
--- /dev/null
+++ b/.changeset/dull-eels-hammer.md
@@ -0,0 +1,5 @@
+---
+'slate-react': minor
+---
+
+If TextComponent decorations keep the same offsets and only paths are changed, prevent re-rendering because only decoration offsets matter when leaves are calculated.
diff --git a/package.json b/package.json
index 8fbc8f92b..50f28f142 100644
--- a/package.json
+++ b/package.json
@@ -60,6 +60,7 @@
"@types/lodash": "^4.14.149",
"@types/mocha": "^5.2.7",
"@types/node": "^16.11.26",
+ "@types/prismjs": "^1.26.0",
"@types/react": "^16.9.13",
"@types/react-dom": "^16.9.4",
"@typescript-eslint/eslint-plugin": "^5.30.5",
@@ -86,7 +87,7 @@
"npm-run-all": "^4.1.2",
"playwright": "^1.29.1",
"prettier": "^1.19.1",
- "prismjs": "^1.5.1",
+ "prismjs": "^1.29.0",
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-error-boundary": "^1.2.5",
diff --git a/packages/slate-react/src/components/element.tsx b/packages/slate-react/src/components/element.tsx
index 52b152103..6d20b0354 100644
--- a/packages/slate-react/src/components/element.tsx
+++ b/packages/slate-react/src/components/element.tsx
@@ -12,7 +12,7 @@ import {
NODE_TO_INDEX,
EDITOR_TO_KEY_TO_ELEMENT,
} from '../utils/weak-maps'
-import { isDecoratorRangeListEqual } from '../utils/range-list'
+import { isElementDecorationsEqual } from '../utils/range-list'
import {
RenderElementProps,
RenderLeafProps,
@@ -139,7 +139,7 @@ const MemoizedElement = React.memo(Element, (prev, next) => {
prev.element === next.element &&
prev.renderElement === next.renderElement &&
prev.renderLeaf === next.renderLeaf &&
- isDecoratorRangeListEqual(prev.decorations, next.decorations) &&
+ isElementDecorationsEqual(prev.decorations, next.decorations) &&
(prev.selection === next.selection ||
(!!prev.selection &&
!!next.selection &&
diff --git a/packages/slate-react/src/components/text.tsx b/packages/slate-react/src/components/text.tsx
index 1e0a05437..aec1a369b 100644
--- a/packages/slate-react/src/components/text.tsx
+++ b/packages/slate-react/src/components/text.tsx
@@ -2,7 +2,7 @@ import React, { useRef } from 'react'
import { Element, Range, Text as SlateText } from 'slate'
import { ReactEditor, useSlateStatic } from '..'
import { useIsomorphicLayoutEffect } from '../hooks/use-isomorphic-layout-effect'
-import { isDecoratorRangeListEqual } from '../utils/range-list'
+import { isTextDecorationsEqual } from '../utils/range-list'
import {
EDITOR_TO_KEY_TO_ELEMENT,
ELEMENT_TO_NODE,
@@ -79,7 +79,7 @@ const MemoizedText = React.memo(Text, (prev, next) => {
next.isLast === prev.isLast &&
next.renderLeaf === prev.renderLeaf &&
next.text === prev.text &&
- isDecoratorRangeListEqual(next.decorations, prev.decorations)
+ isTextDecorationsEqual(next.decorations, prev.decorations)
)
})
diff --git a/packages/slate-react/src/utils/range-list.ts b/packages/slate-react/src/utils/range-list.ts
index 71aff0697..eae4ccf5e 100644
--- a/packages/slate-react/src/utils/range-list.ts
+++ b/packages/slate-react/src/utils/range-list.ts
@@ -7,6 +7,16 @@ export const shallowCompare = (obj1: {}, obj2: {}) =>
key => obj2.hasOwnProperty(key) && obj1[key] === obj2[key]
)
+const isDecorationFlagsEqual = (range: Range, other: Range) => {
+ const { anchor: rangeAnchor, focus: rangeFocus, ...rangeOwnProps } = range
+ const { anchor: otherAnchor, focus: otherFocus, ...otherOwnProps } = other
+
+ return (
+ range[PLACEHOLDER_SYMBOL] === other[PLACEHOLDER_SYMBOL] &&
+ shallowCompare(rangeOwnProps, otherOwnProps)
+ )
+}
+
/**
* Check if a list of decorator ranges are equal to another.
*
@@ -15,7 +25,7 @@ export const shallowCompare = (obj1: {}, obj2: {}) =>
* kept in order, and the odd case where they aren't is okay to re-render for.
*/
-export const isDecoratorRangeListEqual = (
+export const isElementDecorationsEqual = (
list: Range[],
another: Range[]
): boolean => {
@@ -27,13 +37,39 @@ export const isDecoratorRangeListEqual = (
const range = list[i]
const other = another[i]
- const { anchor: rangeAnchor, focus: rangeFocus, ...rangeOwnProps } = range
- const { anchor: otherAnchor, focus: otherFocus, ...otherOwnProps } = other
+ if (!Range.equals(range, other) || !isDecorationFlagsEqual(range, other)) {
+ return false
+ }
+ }
+ return true
+}
+
+/**
+ * Check if a list of decorator ranges are equal to another.
+ *
+ * PERF: this requires the two lists to also have the ranges inside them in the
+ * same order, but this is an okay constraint for us since decorations are
+ * kept in order, and the odd case where they aren't is okay to re-render for.
+ */
+
+export const isTextDecorationsEqual = (
+ list: Range[],
+ another: Range[]
+): boolean => {
+ if (list.length !== another.length) {
+ return false
+ }
+
+ for (let i = 0; i < list.length; i++) {
+ const range = list[i]
+ const other = another[i]
+
+ // compare only offsets because paths doesn't matter for text
if (
- !Range.equals(range, other) ||
- range[PLACEHOLDER_SYMBOL] !== other[PLACEHOLDER_SYMBOL] ||
- !shallowCompare(rangeOwnProps, otherOwnProps)
+ range.anchor.offset !== other.anchor.offset ||
+ range.focus.offset !== other.focus.offset ||
+ !isDecorationFlagsEqual(range, other)
) {
return false
}
diff --git a/playwright/integration/examples/code-highlighting.test.ts b/playwright/integration/examples/code-highlighting.test.ts
index 18285f880..8d74c7b2c 100644
--- a/playwright/integration/examples/code-highlighting.test.ts
+++ b/playwright/integration/examples/code-highlighting.test.ts
@@ -1,57 +1,122 @@
-import { test, expect } from '@playwright/test'
+import { test, expect, Page } from '@playwright/test'
test.describe('code highlighting', () => {
- const slateEditor = '[data-slate-node="element"]'
- const leafNode = 'span[data-slate-leaf="true"]'
-
test.beforeEach(async ({ page }) => {
page.goto('http://localhost:3000/examples/code-highlighting')
})
- test('highlights HTML tags', async ({ page }) => {
- const outer = page
- .locator(slateEditor)
- .locator('span')
- .nth(0)
- .locator(leafNode)
- .nth(0)
- await expect(await outer.textContent()).toContain('
')
- await expect(outer).toHaveCSS('color', 'rgb(153, 0, 85)')
- })
+ for (const testCase of getTestCases()) {
+ const { language, content, highlights } = testCase
- test('highlights javascript syntax', async ({ page }) => {
- const JSCode = 'const slateVar = 30;'
- await page.locator('select').selectOption('JavaScript') // Select the 'JavaScript' option
- await expect(await page.locator('select').inputValue()).toBe('js') // Confirm value to avoid race condition
+ test(`code highlighting ${language}`, async ({ page }) => {
+ await setText(page, content, language)
- await page.locator(slateEditor).click() // focus on the editor
- const isMac = await page.evaluate(() => {
- return /Mac|iPhone|iPod|iPad/i.test(navigator.platform)
+ const tokens = await page
+ .locator('[data-slate-editor] [data-slate-string]')
+ .all()
+
+ for (const [index, token] of tokens.entries()) {
+ const highlight = highlights[index]
+ const textContent = await token.textContent()
+
+ await expect(textContent).toEqual(highlight[0])
+ await expect(token).toHaveCSS('color', highlight[1])
+ }
})
- if (isMac) {
- await page.keyboard.press('Meta+A')
- } else {
- await page.keyboard.press('Control+A')
- }
- await page.keyboard.type(JSCode) // Type JavaScript code
- await page.keyboard.press('Enter')
-
- expect(
- await page
- .locator(slateEditor)
- .locator('span')
- .nth(0)
- .locator(leafNode)
- .nth(0)
- .textContent()
- ).toContain('const')
- await expect(
- page
- .locator(slateEditor)
- .locator('span')
- .nth(0)
- .locator(leafNode)
- .nth(0)
- ).toHaveCSS('color', 'rgb(0, 119, 170)')
- })
+ }
})
+
+// it also tests if select and code block button works the right way
+async function setText(page: Page, text: string, language: string) {
+ await page.locator('[data-slate-editor]').fill('') // clear editor
+ await page.getByTestId('code-block-button').click() // convert first and the only one paragraph to code block
+ await page.getByTestId('language-select').selectOption({ value: language }) // select the language option
+
+ await page.keyboard.type(text) // type text
+}
+
+function getTestCases() {
+ const testCases: {
+ language: string
+ content: string
+ highlights: [string, string][]
+ }[] = [
+ {
+ language: 'css',
+ content: `body {
+ background-color: lightblue;
+}`,
+ highlights: [
+ ['body', 'rgb(102, 153, 0)'],
+ [' ', 'rgb(0, 0, 0)'],
+ ['{', 'rgb(153, 153, 153)'],
+ [' ', 'rgb(0, 0, 0)'],
+ ['background-color', 'rgb(153, 0, 85)'],
+ [':', 'rgb(153, 153, 153)'],
+ [' lightblue', 'rgb(0, 0, 0)'],
+ [';', 'rgb(153, 153, 153)'],
+ ['}', 'rgb(153, 153, 153)'],
+ ],
+ },
+ {
+ language: 'html',
+ content: `
+ Testing html
+`,
+ highlights: [
+ ['<', 'rgb(153, 0, 85)'],
+ ['body', 'rgb(153, 0, 85)'],
+ ['>', 'rgb(153, 0, 85)'],
+ [' ', 'rgb(0, 0, 0)'],
+ ['<', 'rgb(153, 0, 85)'],
+ ['h1', 'rgb(153, 0, 85)'],
+ [' ', 'rgb(153, 0, 85)'],
+ ['class', 'rgb(102, 153, 0)'],
+ ['=', 'rgb(0, 119, 170)'],
+ ['"', 'rgb(0, 119, 170)'],
+ ['title', 'rgb(0, 119, 170)'],
+ ['"', 'rgb(0, 119, 170)'],
+ ['>', 'rgb(153, 0, 85)'],
+ ['Testing html', 'rgb(0, 0, 0)'],
+ ['', 'rgb(153, 0, 85)'],
+ ['h1', 'rgb(153, 0, 85)'],
+ ['>', 'rgb(153, 0, 85)'],
+ ['', 'rgb(153, 0, 85)'],
+ ['body', 'rgb(153, 0, 85)'],
+ ['>', 'rgb(153, 0, 85)'],
+ ],
+ },
+ {
+ language: 'jsx',
+ content: ` } />`,
+ highlights: [
+ ['<', 'rgb(153, 0, 85)'],
+ ['Title', 'rgb(221, 74, 104)'],
+ [' ', 'rgb(153, 0, 85)'],
+ ['title', 'rgb(102, 153, 0)'],
+ ['=', 'rgb(0, 119, 170)'],
+ ['"', 'rgb(0, 119, 170)'],
+ ['title', 'rgb(0, 119, 170)'],
+ ['"', 'rgb(0, 119, 170)'],
+ [' ', 'rgb(153, 0, 85)'],
+ ['renderIcon', 'rgb(102, 153, 0)'],
+ ['=', 'rgb(153, 0, 85)'],
+ ['{', 'rgb(153, 0, 85)'],
+ ['(', 'rgb(153, 0, 85)'],
+ [')', 'rgb(153, 0, 85)'],
+ [' ', 'rgb(153, 0, 85)'],
+ ['=>', 'rgb(154, 110, 58)'],
+ [' ', 'rgb(153, 0, 85)'],
+ ['<', 'rgb(153, 0, 85)'],
+ ['Icon', 'rgb(221, 74, 104)'],
+ [' ', 'rgb(153, 0, 85)'],
+ ['/>', 'rgb(153, 0, 85)'],
+ ['}', 'rgb(153, 0, 85)'],
+ [' ', 'rgb(153, 0, 85)'],
+ ['/>', 'rgb(153, 0, 85)'],
+ ],
+ },
+ ]
+
+ return testCases
+}
diff --git a/site/examples/code-highlighting.tsx b/site/examples/code-highlighting.tsx
index 655eedd6b..ee18236c7 100644
--- a/site/examples/code-highlighting.tsx
+++ b/site/examples/code-highlighting.tsx
@@ -1,237 +1,505 @@
import Prism from 'prismjs'
+import 'prismjs/components/prism-javascript'
+import 'prismjs/components/prism-jsx'
+import 'prismjs/components/prism-typescript'
+import 'prismjs/components/prism-tsx'
+import 'prismjs/components/prism-markdown'
import 'prismjs/components/prism-python'
import 'prismjs/components/prism-php'
import 'prismjs/components/prism-sql'
import 'prismjs/components/prism-java'
-import React, { useState, useCallback, useMemo } from 'react'
-import { Slate, Editable, withReact } from 'slate-react'
-import { Text, createEditor, Element as SlateElement, Descendant } from 'slate'
+import React, { useCallback, useMemo, useState } from 'react'
+import {
+ createEditor,
+ Node,
+ Editor,
+ Range,
+ Element,
+ Transforms,
+ NodeEntry,
+} from 'slate'
+import {
+ withReact,
+ Slate,
+ Editable,
+ RenderElementProps,
+ RenderLeafProps,
+ useSlate,
+ ReactEditor,
+ useSlateStatic,
+} from 'slate-react'
import { withHistory } from 'slate-history'
+import isHotkey from 'is-hotkey'
import { css } from '@emotion/css'
+import { CodeBlockElement } from './custom-types'
+import { normalizeTokens } from '../utils/normalize-tokens'
+import { Button, Icon, Toolbar } from '../components'
+
+const ParagraphType = 'paragraph'
+const CodeBlockType = 'code-block'
+const CodeLineType = 'code-line'
const CodeHighlightingExample = () => {
- const [language, setLanguage] = useState('html')
- const renderLeaf = useCallback(props => , [])
- const editor = useMemo(() => withHistory(withReact(createEditor())), [])
+ const [editor] = useState(() => withHistory(withReact(createEditor())))
- // decorate function depends on the language selected
- const decorate = useCallback(
- ([node, path]) => {
- const ranges = []
- if (!Text.isText(node)) {
- return ranges
- }
- const tokens = Prism.tokenize(node.text, Prism.languages[language])
- let start = 0
-
- for (const token of tokens) {
- const length = getLength(token)
- const end = start + length
-
- if (typeof token !== 'string') {
- ranges.push({
- [token.type]: true,
- anchor: { path, offset: start },
- focus: { path, offset: end },
- })
- }
-
- start = end
- }
-
- return ranges
- },
- [language]
- )
+ const decorate = useDecorate(editor)
+ const onKeyDown = useOnKeydown(editor)
return (
-
-
- Select a language
-
-
-
+
+
+
)
}
-const getLength = token => {
- if (typeof token === 'string') {
- return token.length
- } else if (typeof token.content === 'string') {
- return token.content.length
- } else {
- return token.content.reduce((l, t) => l + getLength(t), 0)
+const renderElement = (props: RenderElementProps) => {
+ const { attributes, children, element } = props
+ const editor = useSlateStatic()
+
+ if (element.type === CodeBlockType) {
+ const setLanguage = (language: string) => {
+ const path = ReactEditor.findPath(editor, element)
+ Transforms.setNodes(editor, { language }, { at: path })
+ }
+
+ return (
+
+ setLanguage(e.target.value)}
+ />
+ {children}
+
+ )
}
+
+ if (element.type === CodeLineType) {
+ return (
+
+ {children}
+
+ )
+ }
+
+ const Tag = editor.isInline(element) ? 'span' : 'div'
+ return (
+
+ {children}
+
+ )
}
-// different token types, styles found on Prismjs website
-const Leaf = ({ attributes, children, leaf }) => {
+const ExampleToolbar = () => {
return (
-
+
+
+ )
+}
- ${leaf.comment &&
- css`
- color: slategray;
- `}
+const CodeBlockButton = () => {
+ const editor = useSlateStatic()
+ const handleClick = () => {
+ Transforms.wrapNodes(
+ editor,
+ { type: CodeBlockType, language: 'html', children: [] },
+ {
+ match: n => Element.isElement(n) && n.type === ParagraphType,
+ split: true,
+ }
+ )
+ Transforms.setNodes(
+ editor,
+ { type: CodeLineType },
+ { match: n => Element.isElement(n) && n.type === ParagraphType }
+ )
+ }
- ${(leaf.operator || leaf.url) &&
- css`
- color: #9a6e3a;
- `}
- ${leaf.keyword &&
- css`
- color: #07a;
- `}
- ${(leaf.variable || leaf.regex) &&
- css`
- color: #e90;
- `}
- ${(leaf.number ||
- leaf.boolean ||
- leaf.tag ||
- leaf.constant ||
- leaf.symbol ||
- leaf['attr-name'] ||
- leaf.selector) &&
- css`
- color: #905;
- `}
- ${leaf.punctuation &&
- css`
- color: #999;
- `}
- ${(leaf.string || leaf.char) &&
- css`
- color: #690;
- `}
- ${(leaf.function || leaf['class-name']) &&
- css`
- color: #dd4a68;
- `}
- `}
+ return (
+
+ )
+}
+
+const renderLeaf = (props: RenderLeafProps) => {
+ const { attributes, children, leaf } = props
+ const { text, ...rest } = leaf
+
+ return (
+
{children}
)
}
-const initialValue: Descendant[] = [
+const useDecorate = (editor: Editor) => {
+ return useCallback(([node, path]) => {
+ if (Element.isElement(node) && node.type === CodeLineType) {
+ const ranges = editor.nodeToDecorations.get(node) || []
+ return ranges
+ }
+
+ return []
+ }, [])
+}
+
+const getChildNodeToDecorations = ([block, blockPath]: NodeEntry<
+ CodeBlockElement
+>) => {
+ const nodeToDecorations = new Map()
+
+ const text = block.children.map(line => Node.string(line)).join('\n')
+ const language = block.language
+ const tokens = Prism.tokenize(text, Prism.languages[language])
+ const normalizedTokens = normalizeTokens(tokens) // make tokens flat and grouped by line
+ const blockChildren = block.children as Element[]
+
+ for (let index = 0; index < normalizedTokens.length; index++) {
+ const tokens = normalizedTokens[index]
+ const element = blockChildren[index]
+
+ if (!nodeToDecorations.has(element)) {
+ nodeToDecorations.set(element, [])
+ }
+
+ let start = 0
+ for (const token of tokens) {
+ const length = token.content.length
+ if (!length) {
+ continue
+ }
+
+ const end = start + length
+
+ const path = [...blockPath, index, 0]
+ const range = {
+ anchor: { path, offset: start },
+ focus: { path, offset: end },
+ token: true,
+ ...Object.fromEntries(token.types.map(type => [type, true])),
+ }
+
+ nodeToDecorations.get(element)!.push(range)
+
+ start = end
+ }
+ }
+
+ return nodeToDecorations
+}
+
+// precalculate editor.nodeToDecorations map to use it inside decorate function then
+const SetNodeToDecorations = () => {
+ const editor = useSlate()
+
+ useMemo(() => {
+ const blockEntries = Array.from(
+ Editor.nodes(editor, {
+ at: [],
+ mode: 'highest',
+ match: n => Element.isElement(n) && n.type === CodeBlockType,
+ })
+ )
+
+ const nodeToDecorations = mergeMaps(
+ ...blockEntries.map(getChildNodeToDecorations)
+ )
+
+ editor.nodeToDecorations = nodeToDecorations
+ }, [editor.children])
+
+ return null
+}
+
+const useOnKeydown = (editor: Editor) => {
+ const onKeyDown: React.KeyboardEventHandler = useCallback(e => {
+ if (isHotkey('tab', e)) {
+ // handle tab key, insert spaces
+ e.preventDefault()
+
+ Editor.insertText(editor, ' ')
+ }
+ }, [])
+
+ return onKeyDown
+}
+
+const LanguageSelect = (props: JSX.IntrinsicElements['select']) => {
+ return (
+
+ )
+}
+
+const mergeMaps = (...maps: Map[]) => {
+ const map = new Map()
+
+ for (const m of maps) {
+ for (const item of m) {
+ map.set(...item)
+ }
+ }
+
+ return map
+}
+
+const toChildren = (content: string) => [{ text: content }]
+const toCodeLines = (content: string): Element[] =>
+ content
+ .split('\n')
+ .map(line => ({ type: CodeLineType, children: toChildren(line) }))
+
+const initialValue: Element[] = [
+ {
+ type: ParagraphType,
+ children: toChildren(
+ "Here's one containing a single paragraph block with some text in it:"
+ ),
+ },
+ {
+ type: CodeBlockType,
+ language: 'jsx',
+ children: toCodeLines(`// Add the initial value.
+const initialValue = [
{
type: 'paragraph',
- children: [
- {
- text: 'Hi!
',
- },
- ],
+ children: [{ text: 'A line of text in a paragraph.' }]
+ }
+]
+
+const App = () => {
+ const [editor] = useState(() => withReact(createEditor()))
+
+ return (
+
+
+
+ )
+}`),
+ },
+ {
+ type: ParagraphType,
+ children: toChildren(
+ 'If you are using TypeScript, you will also need to extend the Editor with ReactEditor and add annotations as per the documentation on TypeScript. The example below also includes the custom types required for the rest of this example.'
+ ),
+ },
+ {
+ type: CodeBlockType,
+ language: 'typescript',
+ children: toCodeLines(`// TypeScript users only add this code
+import { BaseEditor, Descendant } from 'slate'
+import { ReactEditor } from 'slate-react'
+
+type CustomElement = { type: 'paragraph'; children: CustomText[] }
+type CustomText = { text: string }
+
+declare module 'slate' {
+ interface CustomTypes {
+ Editor: BaseEditor & ReactEditor
+ Element: CustomElement
+ Text: CustomText
+ }
+}`),
+ },
+ {
+ type: ParagraphType,
+ children: toChildren('There you have it!'),
},
]
-// modifications and additions to prism library
+// Prismjs theme stored as a string instead of emotion css function.
+// It is useful for copy/pasting different themes. Also lets keeping simpler Leaf implementation
+// In the real project better to use just css file
+const prismThemeCss = `
+/**
+ * prism.js default theme for JavaScript, CSS and HTML
+ * Based on dabblet (http://dabblet.com)
+ * @author Lea Verou
+ */
-Prism.languages.python = Prism.languages.extend('python', {})
-Prism.languages.insertBefore('python', 'prolog', {
- comment: { pattern: /##[^\n]*/, alias: 'comment' },
-})
-Prism.languages.javascript = Prism.languages.extend('javascript', {})
-Prism.languages.insertBefore('javascript', 'prolog', {
- comment: { pattern: /\/\/[^\n]*/, alias: 'comment' },
-})
-Prism.languages.html = Prism.languages.extend('html', {})
-Prism.languages.insertBefore('html', 'prolog', {
- comment: { pattern: //, alias: 'comment' },
-})
-Prism.languages.markdown = Prism.languages.extend('markup', {})
-Prism.languages.insertBefore('markdown', 'prolog', {
- blockquote: { pattern: /^>(?:[\t ]*>)*/m, alias: 'punctuation' },
- code: [
- { pattern: /^(?: {4}|\t).+/m, alias: 'keyword' },
- { pattern: /``.+?``|`[^`\n]+`/, alias: 'keyword' },
- ],
- title: [
- {
- pattern: /\w+.*(?:\r?\n|\r)(?:==+|--+)/,
- alias: 'important',
- inside: { punctuation: /==+$|--+$/ },
- },
- {
- pattern: /(^\s*)#+.+/m,
- lookbehind: !0,
- alias: 'important',
- inside: { punctuation: /^#+|#+$/ },
- },
- ],
- hr: {
- pattern: /(^\s*)([*-])([\t ]*\2){2,}(?=\s*$)/m,
- lookbehind: !0,
- alias: 'punctuation',
- },
- list: {
- pattern: /(^\s*)(?:[*+-]|\d+\.)(?=[\t ].)/m,
- lookbehind: !0,
- alias: 'punctuation',
- },
- 'url-reference': {
- pattern: /!?\[[^\]]+\]:[\t ]+(?:\S+|<(?:\\.|[^>\\])+>)(?:[\t ]+(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\((?:\\.|[^)\\])*\)))?/,
- inside: {
- variable: { pattern: /^(!?\[)[^\]]+/, lookbehind: !0 },
- string: /(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\((?:\\.|[^)\\])*\))$/,
- punctuation: /^[\[\]!:]|[<>]/,
- },
- alias: 'url',
- },
- bold: {
- pattern: /(^|[^\\])(\*\*|__)(?:(?:\r?\n|\r)(?!\r?\n|\r)|.)+?\2/,
- lookbehind: !0,
- inside: { punctuation: /^\*\*|^__|\*\*$|__$/ },
- },
- italic: {
- pattern: /(^|[^\\])([*_])(?:(?:\r?\n|\r)(?!\r?\n|\r)|.)+?\2/,
- lookbehind: !0,
- inside: { punctuation: /^[*_]|[*_]$/ },
- },
- url: {
- pattern: /!?\[[^\]]+\](?:\([^\s)]+(?:[\t ]+"(?:\\.|[^"\\])*")?\)| ?\[[^\]\n]*\])/,
- inside: {
- variable: { pattern: /(!?\[)[^\]]+(?=\]$)/, lookbehind: !0 },
- string: { pattern: /"(?:\\.|[^"\\])*"(?=\)$)/ },
- },
- },
-})
-Prism.languages.markdown.bold.inside.url = Prism.util.clone(
- Prism.languages.markdown.url
-)
-Prism.languages.markdown.italic.inside.url = Prism.util.clone(
- Prism.languages.markdown.url
-)
-Prism.languages.markdown.bold.inside.italic = Prism.util.clone(
- Prism.languages.markdown.italic
-)
-Prism.languages.markdown.italic.inside.bold = Prism.util.clone(Prism.languages.markdown.bold); // prettier-ignore
+code[class*="language-"],
+pre[class*="language-"] {
+ color: black;
+ background: none;
+ text-shadow: 0 1px white;
+ font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
+ font-size: 1em;
+ text-align: left;
+ white-space: pre;
+ word-spacing: normal;
+ word-break: normal;
+ word-wrap: normal;
+ line-height: 1.5;
+
+ -moz-tab-size: 4;
+ -o-tab-size: 4;
+ tab-size: 4;
+
+ -webkit-hyphens: none;
+ -moz-hyphens: none;
+ -ms-hyphens: none;
+ hyphens: none;
+}
+
+pre[class*="language-"]::-moz-selection, pre[class*="language-"] ::-moz-selection,
+code[class*="language-"]::-moz-selection, code[class*="language-"] ::-moz-selection {
+ text-shadow: none;
+ background: #b3d4fc;
+}
+
+pre[class*="language-"]::selection, pre[class*="language-"] ::selection,
+code[class*="language-"]::selection, code[class*="language-"] ::selection {
+ text-shadow: none;
+ background: #b3d4fc;
+}
+
+@media print {
+ code[class*="language-"],
+ pre[class*="language-"] {
+ text-shadow: none;
+ }
+}
+
+/* Code blocks */
+pre[class*="language-"] {
+ padding: 1em;
+ margin: .5em 0;
+ overflow: auto;
+}
+
+:not(pre) > code[class*="language-"],
+pre[class*="language-"] {
+ background: #f5f2f0;
+}
+
+/* Inline code */
+:not(pre) > code[class*="language-"] {
+ padding: .1em;
+ border-radius: .3em;
+ white-space: normal;
+}
+
+.token.comment,
+.token.prolog,
+.token.doctype,
+.token.cdata {
+ color: slategray;
+}
+
+.token.punctuation {
+ color: #999;
+}
+
+.token.namespace {
+ opacity: .7;
+}
+
+.token.property,
+.token.tag,
+.token.boolean,
+.token.number,
+.token.constant,
+.token.symbol,
+.token.deleted {
+ color: #905;
+}
+
+.token.selector,
+.token.attr-name,
+.token.string,
+.token.char,
+.token.builtin,
+.token.inserted {
+ color: #690;
+}
+
+.token.operator,
+.token.entity,
+.token.url,
+.language-css .token.string,
+.style .token.string {
+ color: #9a6e3a;
+ /* This background color was intended by the author of this theme. */
+ background: hsla(0, 0%, 100%, .5);
+}
+
+.token.atrule,
+.token.attr-value,
+.token.keyword {
+ color: #07a;
+}
+
+.token.function,
+.token.class-name {
+ color: #DD4A68;
+}
+
+.token.regex,
+.token.important,
+.token.variable {
+ color: #e90;
+}
+
+.token.important,
+.token.bold {
+ font-weight: bold;
+}
+.token.italic {
+ font-style: italic;
+}
+
+.token.entity {
+ cursor: help;
+}
+`
export default CodeHighlightingExample
diff --git a/site/examples/custom-types.d.ts b/site/examples/custom-types.d.ts
index d7ccb300f..3d99b33a6 100644
--- a/site/examples/custom-types.d.ts
+++ b/site/examples/custom-types.d.ts
@@ -1,12 +1,4 @@
-import {
- Text,
- createEditor,
- Node,
- Element,
- Editor,
- Descendant,
- BaseEditor,
-} from 'slate'
+import { Descendant, BaseEditor, BaseRange, Range, Element } from 'slate'
import { ReactEditor } from 'slate-react'
import { HistoryEditor } from 'slate-history'
@@ -79,6 +71,17 @@ export type TitleElement = { type: 'title'; children: Descendant[] }
export type VideoElement = { type: 'video'; url: string; children: EmptyText[] }
+export type CodeBlockElement = {
+ type: 'code-block'
+ language: string
+ children: Descendant[]
+}
+
+export type CodeLineElement = {
+ type: 'code-line'
+ children: Descendant[]
+}
+
type CustomElement =
| BlockQuoteElement
| BulletedListElement
@@ -97,6 +100,8 @@ type CustomElement =
| TableCellElement
| TitleElement
| VideoElement
+ | CodeBlockElement
+ | CodeLineElement
export type CustomText = {
bold?: boolean
@@ -109,12 +114,19 @@ export type EmptyText = {
text: string
}
-export type CustomEditor = BaseEditor & ReactEditor & HistoryEditor
+export type CustomEditor = BaseEditor &
+ ReactEditor &
+ HistoryEditor & {
+ nodeToDecorations?: Map
+ }
declare module 'slate' {
interface CustomTypes {
Editor: CustomEditor
Element: CustomElement
Text: CustomText | EmptyText
+ Range: BaseRange & {
+ [key: string]: unknown
+ }
}
}
diff --git a/site/examples/markdown-preview.tsx b/site/examples/markdown-preview.tsx
index 78811540f..4f4288d9f 100644
--- a/site/examples/markdown-preview.tsx
+++ b/site/examples/markdown-preview.tsx
@@ -1,13 +1,11 @@
import Prism from 'prismjs'
+import 'prismjs/components/prism-markdown'
import React, { useCallback, useMemo } from 'react'
import { Slate, Editable, withReact } from 'slate-react'
import { Text, createEditor, Descendant } from 'slate'
import { withHistory } from 'slate-history'
import { css } from '@emotion/css'
-// eslint-disable-next-line
-;Prism.languages.markdown=Prism.languages.extend("markup",{}),Prism.languages.insertBefore("markdown","prolog",{blockquote:{pattern:/^>(?:[\t ]*>)*/m,alias:"punctuation"},code:[{pattern:/^(?: {4}|\t).+/m,alias:"keyword"},{pattern:/``.+?``|`[^`\n]+`/,alias:"keyword"}],title:[{pattern:/\w+.*(?:\r?\n|\r)(?:==+|--+)/,alias:"important",inside:{punctuation:/==+$|--+$/}},{pattern:/(^\s*)#+.+/m,lookbehind:!0,alias:"important",inside:{punctuation:/^#+|#+$/}}],hr:{pattern:/(^\s*)([*-])([\t ]*\2){2,}(?=\s*$)/m,lookbehind:!0,alias:"punctuation"},list:{pattern:/(^\s*)(?:[*+-]|\d+\.)(?=[\t ].)/m,lookbehind:!0,alias:"punctuation"},"url-reference":{pattern:/!?\[[^\]]+\]:[\t ]+(?:\S+|<(?:\\.|[^>\\])+>)(?:[\t ]+(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\((?:\\.|[^)\\])*\)))?/,inside:{variable:{pattern:/^(!?\[)[^\]]+/,lookbehind:!0},string:/(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\((?:\\.|[^)\\])*\))$/,punctuation:/^[\[\]!:]|[<>]/},alias:"url"},bold:{pattern:/(^|[^\\])(\*\*|__)(?:(?:\r?\n|\r)(?!\r?\n|\r)|.)+?\2/,lookbehind:!0,inside:{punctuation:/^\*\*|^__|\*\*$|__$/}},italic:{pattern:/(^|[^\\])([*_])(?:(?:\r?\n|\r)(?!\r?\n|\r)|.)+?\2/,lookbehind:!0,inside:{punctuation:/^[*_]|[*_]$/}},url:{pattern:/!?\[[^\]]+\](?:\([^\s)]+(?:[\t ]+"(?:\\.|[^"\\])*")?\)| ?\[[^\]\n]*\])/,inside:{variable:{pattern:/(!?\[)[^\]]+(?=\]$)/,lookbehind:!0},string:{pattern:/"(?:\\.|[^"\\])*"(?=\)$)/}}}}),Prism.languages.markdown.bold.inside.url=Prism.util.clone(Prism.languages.markdown.url),Prism.languages.markdown.italic.inside.url=Prism.util.clone(Prism.languages.markdown.url),Prism.languages.markdown.bold.inside.italic=Prism.util.clone(Prism.languages.markdown.italic),Prism.languages.markdown.italic.inside.bold=Prism.util.clone(Prism.languages.markdown.bold); // prettier-ignore
-
const MarkdownPreviewExample = () => {
const renderLeaf = useCallback(props => , [])
const editor = useMemo(() => withHistory(withReact(createEditor())), [])
diff --git a/site/utils/normalize-tokens.ts b/site/utils/normalize-tokens.ts
new file mode 100644
index 000000000..fe2537bcd
--- /dev/null
+++ b/site/utils/normalize-tokens.ts
@@ -0,0 +1,117 @@
+/**
+ * Copied from prism-react-renderer repo
+ * https://github.com/FormidableLabs/prism-react-renderer/blob/master/src/utils/normalizeTokens.js
+ * */
+
+import Prism from 'prismjs'
+
+type PrismToken = Prism.Token
+type Token = {
+ types: string[]
+ content: string
+ empty?: boolean
+}
+
+const newlineRe = /\r\n|\r|\n/
+
+// Empty lines need to contain a single empty token, denoted with { empty: true }
+const normalizeEmptyLines = (line: Token[]) => {
+ if (line.length === 0) {
+ line.push({
+ types: ['plain'],
+ content: '\n',
+ empty: true,
+ })
+ } else if (line.length === 1 && line[0].content === '') {
+ line[0].content = '\n'
+ line[0].empty = true
+ }
+}
+
+const appendTypes = (types: string[], add: string[] | string): string[] => {
+ const typesSize = types.length
+ if (typesSize > 0 && types[typesSize - 1] === add) {
+ return types
+ }
+
+ return types.concat(add)
+}
+
+// Takes an array of Prism's tokens and groups them by line, turning plain
+// strings into tokens as well. Tokens can become recursive in some cases,
+// which means that their types are concatenated. Plain-string tokens however
+// are always of type "plain".
+// This is not recursive to avoid exceeding the call-stack limit, since it's unclear
+// how nested Prism's tokens can become
+export const normalizeTokens = (
+ tokens: Array
+): Token[][] => {
+ const typeArrStack: string[][] = [[]]
+ const tokenArrStack = [tokens]
+ const tokenArrIndexStack = [0]
+ const tokenArrSizeStack = [tokens.length]
+
+ let i = 0
+ let stackIndex = 0
+ let currentLine = []
+
+ const acc = [currentLine]
+
+ while (stackIndex > -1) {
+ while (
+ (i = tokenArrIndexStack[stackIndex]++) < tokenArrSizeStack[stackIndex]
+ ) {
+ let content
+ let types = typeArrStack[stackIndex]
+
+ const tokenArr = tokenArrStack[stackIndex]
+ const token = tokenArr[i]
+
+ // Determine content and append type to types if necessary
+ if (typeof token === 'string') {
+ types = stackIndex > 0 ? types : ['plain']
+ content = token
+ } else {
+ types = appendTypes(types, token.type)
+ if (token.alias) {
+ types = appendTypes(types, token.alias)
+ }
+
+ content = token.content
+ }
+
+ // If token.content is an array, increase the stack depth and repeat this while-loop
+ if (typeof content !== 'string') {
+ stackIndex++
+ typeArrStack.push(types)
+ tokenArrStack.push(content)
+ tokenArrIndexStack.push(0)
+ tokenArrSizeStack.push(content.length)
+ continue
+ }
+
+ // Split by newlines
+ const splitByNewlines = content.split(newlineRe)
+ const newlineCount = splitByNewlines.length
+
+ currentLine.push({ types, content: splitByNewlines[0] })
+
+ // Create a new line for each string on a new line
+ for (let i = 1; i < newlineCount; i++) {
+ normalizeEmptyLines(currentLine)
+ acc.push((currentLine = []))
+ currentLine.push({ types, content: splitByNewlines[i] })
+ }
+ }
+
+ // Decreate the stack depth
+ stackIndex--
+ typeArrStack.pop()
+ tokenArrStack.pop()
+ tokenArrIndexStack.pop()
+ tokenArrSizeStack.pop()
+ }
+
+ normalizeEmptyLines(currentLine)
+ return acc
+}
diff --git a/yarn.lock b/yarn.lock
index bf36564f7..3ae82b42b 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3729,6 +3729,13 @@ __metadata:
languageName: node
linkType: hard
+"@types/prismjs@npm:^1.26.0":
+ version: 1.26.0
+ resolution: "@types/prismjs@npm:1.26.0"
+ checksum: cd5e7a6214c1f4213ec512a5fcf6d8fe37a56b813fc57ac95b5ff5ee074742bfdbd2f2730d9fd985205bf4586728e09baa97023f739e5aa1c9735a7c1ecbd11a
+ languageName: node
+ linkType: hard
+
"@types/prop-types@npm:*":
version: 15.7.4
resolution: "@types/prop-types@npm:15.7.4"
@@ -12341,10 +12348,10 @@ __metadata:
languageName: node
linkType: hard
-"prismjs@npm:^1.5.1":
- version: 1.24.1
- resolution: "prismjs@npm:1.24.1"
- checksum: e5d14a4ba56773122039295bd760c72106acc964e04cb9831b9ae7e7a58f67ccac6c053e77e21f1018a3684f31d35bb065c0c81fd4ff00b73b1570c3ace4aef0
+"prismjs@npm:^1.29.0":
+ version: 1.29.0
+ resolution: "prismjs@npm:1.29.0"
+ checksum: 007a8869d4456ff8049dc59404e32d5666a07d99c3b0e30a18bd3b7676dfa07d1daae9d0f407f20983865fd8da56de91d09cb08e6aa61f5bc420a27c0beeaf93
languageName: node
linkType: hard
@@ -13851,6 +13858,7 @@ resolve@^2.0.0-next.3:
"@types/lodash": ^4.14.149
"@types/mocha": ^5.2.7
"@types/node": ^16.11.26
+ "@types/prismjs": ^1.26.0
"@types/react": ^16.9.13
"@types/react-dom": ^16.9.4
"@typescript-eslint/eslint-plugin": ^5.30.5
@@ -13877,7 +13885,7 @@ resolve@^2.0.0-next.3:
npm-run-all: ^4.1.2
playwright: ^1.29.1
prettier: ^1.19.1
- prismjs: ^1.5.1
+ prismjs: ^1.29.0
react: ^16.12.0
react-dom: ^16.12.0
react-error-boundary: ^1.2.5