1
0
mirror of https://github.com/ianstormtaylor/slate.git synced 2025-01-17 21:49:20 +01:00

Compare only decorations offsets in MemoizedText. Code highlighting example improvements. (#5271)

* add basePath prop to Range inside slate-react custom types, calculate absolute ranges on passing them into TextComponent

* code highlighting example improvements, minor markdown preview refactoring

* changeset added

* Revert "add basePath prop to Range inside slate-react custom types, calculate absolute ranges on passing them into TextComponent"

This reverts commit afa085c289bc67ce3d27dd33b1f074ab8153efe8.

* add basePath prop to Point inside slate-react custom types, resolve relative ranges on passing them to TextComponent

* Update changeset

* linter fixes

* remove redundant checks inside renderElement function

* custom types fixes for Range and Point in examples

* wrap intervals and ranges extractors in useMemo hook for running them only if editor.children is changed

* revert basePath changes, compare only offsets for MemoizedText decorations

* use an element as a key in decorations ranges map instead of id

* simplify code highlighting implementation, make code block nested

* fix code-highlighting example, add toolbar code block button

* remove redundant code

* fix code highlighting playwright integration test
This commit is contained in:
Sergei Dedkov 2023-02-10 00:53:21 +06:00 committed by GitHub
parent 7d1e60b88f
commit 9635b992a0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 782 additions and 272 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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('<h1>')
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: `<body>
<h1 class="title">Testing html</h1>
</body>`,
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: `<Title title="title" renderIcon={() => <Icon />} />`,
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
}

View File

@ -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 => <Leaf {...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 (
<Slate editor={editor} value={initialValue}>
<div
contentEditable={false}
style={{ position: 'relative', top: '5px', right: '5px' }}
>
<h3>
Select a language
<select
value={language}
style={{ float: 'right' }}
onChange={e => setLanguage(e.target.value)}
>
<option value="js">JavaScript</option>
<option value="css">CSS</option>
<option value="html">HTML</option>
<option value="python">Python</option>
<option value="sql">SQL</option>
<option value="java">Java</option>
<option value="php">PHP</option>
</select>
</h3>
</div>
<ExampleToolbar />
<SetNodeToDecorations />
<Editable
decorate={decorate}
renderElement={renderElement}
renderLeaf={renderLeaf}
placeholder="Write some code..."
onKeyDown={onKeyDown}
/>
<style>{prismThemeCss}</style>
</Slate>
)
}
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 (
<div
{...attributes}
className={css(`
font-family: monospace;
font-size: 16px;
line-height: 20px;
margin-top: 0;
background: rgba(0, 20, 60, .03);
padding: 5px 13px;
`)}
style={{ position: 'relative' }}
spellCheck={false}
>
<LanguageSelect
value={element.language}
onChange={e => setLanguage(e.target.value)}
/>
{children}
</div>
)
}
if (element.type === CodeLineType) {
return (
<div {...attributes} style={{ position: 'relative' }}>
{children}
</div>
)
}
const Tag = editor.isInline(element) ? 'span' : 'div'
return (
<Tag {...attributes} style={{ position: 'relative' }}>
{children}
</Tag>
)
}
// different token types, styles found on Prismjs website
const Leaf = ({ attributes, children, leaf }) => {
const ExampleToolbar = () => {
return (
<span
{...attributes}
className={css`
font-family: monospace;
background: hsla(0, 0%, 100%, .5);
<Toolbar>
<CodeBlockButton />
</Toolbar>
)
}
${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 (
<Button
data-test-id="code-block-button"
active
onMouseDown={event => {
event.preventDefault()
handleClick()
}}
>
<Icon>code</Icon>
</Button>
)
}
const renderLeaf = (props: RenderLeafProps) => {
const { attributes, children, leaf } = props
const { text, ...rest } = leaf
return (
<span {...attributes} className={Object.keys(rest).join(' ')}>
{children}
</span>
)
}
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<Element, Range[]>()
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 (
<select
data-test-id="language-select"
contentEditable={false}
className={css`
position: absolute;
right: 5px;
top: 5px;
z-index: 1;
`}
{...props}
>
<option value="css">CSS</option>
<option value="html">HTML</option>
<option value="java">Java</option>
<option value="javascript">JavaScript</option>
<option value="jsx">JSX</option>
<option value="markdown">Markdown</option>
<option value="php">PHP</option>
<option value="python">Python</option>
<option value="sql">SQL</option>
<option value="tsx">TSX</option>
<option value="typescript">TypeScript</option>
</select>
)
}
const mergeMaps = <K, V>(...maps: Map<K, V>[]) => {
const map = new Map<K, V>()
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: '<h1>Hi!</h1>',
},
],
children: [{ text: 'A line of text in a paragraph.' }]
}
]
const App = () => {
const [editor] = useState(() => withReact(createEditor()))
return (
<Slate editor={editor} value={initialValue}>
<Editable />
</Slate>
)
}`),
},
{
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: /<!--[^\n]*-->/, 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

View File

@ -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<Element, Range[]>
}
declare module 'slate' {
interface CustomTypes {
Editor: CustomEditor
Element: CustomElement
Text: CustomText | EmptyText
Range: BaseRange & {
[key: string]: unknown
}
}
}

View File

@ -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 => <Leaf {...props} />, [])
const editor = useMemo(() => withHistory(withReact(createEditor())), [])

View File

@ -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<PrismToken | string>
): 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
}

View File

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