Skip to content

Commit

Permalink
feat: codeblock highlight use shiki
Browse files Browse the repository at this point in the history
  • Loading branch information
hunghg255 committed Aug 19, 2024
1 parent f523370 commit 1cde81d
Show file tree
Hide file tree
Showing 9 changed files with 472 additions and 43 deletions.
9 changes: 7 additions & 2 deletions docs/extensions/CodeBlock/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ next:
link: /extensions/Color/index.md
---

# CodeBlock

The `CodeBlock` extension allows you to add code blocks to your editor. It uses [Shiki](https://shiki.style/guide/) for syntax highlighting.

## Usage

```tsx
Expand All @@ -17,7 +21,8 @@ import { createLowlight, common } from 'lowlight'; // [!code ++]
const extensions = [
...,
// Import Extensions Here
CodeBlock.configure({ lowlight: createLowlight(common) }), // [!code ++]
CodeBlock.configure({ defaultTheme: 'dracula' }), // [!code ++]
];

```

- You can write `` ```ts ``, press <kbd>Enter</kbd>, and write some code! It loads the language on the fly.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,6 @@
"@tiptap/extension-character-count": "^2.6.3",
"@tiptap/extension-code": "^2.6.3",
"@tiptap/extension-code-block": "^2.6.3",
"@tiptap/extension-code-block-lowlight": "^2.6.3",
"@tiptap/extension-color": "^2.6.3",
"@tiptap/extension-document": "^2.6.3",
"@tiptap/extension-dropcursor": "^2.6.3",
Expand Down Expand Up @@ -114,6 +113,7 @@
"lucide-react": "^0.427.0",
"md5": "^2.3.0",
"react-colorful": "^5.6.1",
"shiki": "^1.13.0",
"tippy.js": "^6.3.7",
"valtio": "^1.13.2"
},
Expand Down
4 changes: 2 additions & 2 deletions playground/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,13 +101,13 @@ const extensions = [
Code.configure({
toolbar: false,
}),
CodeBlock.configure({ lowlight: createLowlight(common) }),
CodeBlock.configure({ defaultTheme: 'dracula' }),
ColumnToolbar,
Table,
Iframe.configure({ spacer: true }),
]

const DEFAULT = `<h1 style="text-align: center">Rc Tiptap Editor</h1><p>A modern WYSIWYG rich text editor based on <a target="_blank" rel="noopener noreferrer nofollow" class="link" href="https://github.com/scrumpy/tiptap">tiptap</a> and <a target="_blank" rel="noopener noreferrer nofollow" class="link" href="https://ui.shadcn.com/">shadcn ui</a> for Reactjs</p><p></p><p style="text-align: center"></p><p style="text-align: center"><img height="auto" src="https://picsum.photos/1920/1080.webp?t=1" width="500"></p><p></p><div data-type="horizontalRule"><hr></div><h2>Demo</h2><p>👉<a target="_blank" rel="noopener noreferrer nofollow" class="link" href="https://reactjs-tiptap-editor.vercel.app/">Demo</a></p><h2>Features</h2><ul><li><p>Use <a target="_blank" rel="noopener noreferrer nofollow" class="link" href="https://ui.shadcn.com/">shadcn ui</a> components</p></li><li><p>Markdown support</p></li><li><p>TypeScript support</p></li><li><p>I18n support</p></li><li><p>React support</p></li><li><p>Slash Commands</p></li><li><p>Multi Column</p></li><li><p>TailwindCss</p></li><li><p>Support emoji</p></li><li><p>Support iframe</p></li></ul><h2>Installation</h2><pre><code>pnpm add reactjs-tiptap-editor</code></pre><p></p>`
const DEFAULT = `<h1 style="text-align: center">Rich Text Editor</h1><p>A modern WYSIWYG rich text editor based on <a target="_blank" rel="noopener noreferrer nofollow" class="link" href="https://github.com/scrumpy/tiptap">tiptap</a> and <a target="_blank" rel="noopener noreferrer nofollow" class="link" href="https://ui.shadcn.com/">shadcn ui</a> for Reactjs</p><p></p><p style="text-align: center"></p><p style="text-align: center"><img height="auto" src="https://picsum.photos/1920/1080.webp?t=1" width="500"></p><p></p><div data-type="horizontalRule"><hr></div><h2>Demo</h2><p>👉<a target="_blank" rel="noopener noreferrer nofollow" class="link" href="https://reactjs-tiptap-editor.vercel.app/">Demo</a></p><h2>Features</h2><ul><li><p>Use <a target="_blank" rel="noopener noreferrer nofollow" class="link" href="https://ui.shadcn.com/">shadcn ui</a> components</p></li><li><p>Markdown support</p></li><li><p>TypeScript support</p></li><li><p>I18n support</p></li><li><p>React support</p></li><li><p>Slash Commands</p></li><li><p>Multi Column</p></li><li><p>TailwindCss</p></li><li><p>Support emoji</p></li><li><p>Support iframe</p></li></ul><h2>Installation</h2><pre><code>pnpm add reactjs-tiptap-editor</code class="language-bash"></pre><p></p>`

function debounce(func: any, wait: number) {
let timeout: NodeJS.Timeout
Expand Down
23 changes: 3 additions & 20 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 14 additions & 0 deletions src/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,20 @@ export const DEFAULT_FONT_FAMILY_LIST = [
'cursive',
]

export const DEFAULT_LANGUAGE_CODE_BLOCK = [
'html',
'css',
'js',
'ts',
]

export const MAP_LANGUAGE_CODE_LABELS = {
html: 'HTML',
css: 'CSS',
js: 'JavaScript',
ts: 'TypeScript',
} as any

/** Default font size list */
export const DEFAULT_FONT_SIZE_LIST = [
'10px',
Expand Down
57 changes: 39 additions & 18 deletions src/extensions/CodeBlock/CodeBlock.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,49 @@
import { CodeBlockLowlight as TiptapCodeBlock } from '@tiptap/extension-code-block-lowlight'
import type { CodeBlockLowlightOptions as TiptapCodeBlockOptions } from '@tiptap/extension-code-block-lowlight'

import { ActionButton } from '@/components'
import type { BundledLanguage, BundledTheme } from 'shiki'
import type { CodeBlockOptions as CodeBlockExtOptions } from '@tiptap/extension-code-block'
import CodeBlockExt from '@tiptap/extension-code-block'
import type { GeneralOptions } from '@/types'
import { ShikiPlugin } from '@/extensions/CodeBlock/shiki-plugin'
import CodeBlockActiveButton from '@/extensions/CodeBlock/components/CodeBlockActiveButton'
import { DEFAULT_LANGUAGE_CODE_BLOCK } from '@/constants'

export interface CodeBlockOptions
extends TiptapCodeBlockOptions,
GeneralOptions<CodeBlockOptions> {}
extends GeneralOptions<CodeBlockExtOptions> {
languages?: BundledLanguage[]
defaultTheme: BundledTheme
}

export const CodeBlock = TiptapCodeBlock.extend<CodeBlockOptions>({
export const CodeBlock = CodeBlockExt.extend<CodeBlockOptions>({
addOptions() {
return {
...this.parent?.(),
defaultLanguage: null,
button: ({ editor, t }: any) => ({
component: ActionButton,
componentProps: {
action: () => editor.commands.toggleCodeBlock(),
isActive: () => editor.isActive('codeBlock') || false,
disabled: !editor.can().toggleCodeBlock(),
icon: 'Code2',
tooltip: t('editor.codeblock.tooltip'),
},
}),
languages: [],
button: ({ editor, t, extension }: any) => {
const languages = extension?.options?.languages?.length ? extension?.options?.languages : DEFAULT_LANGUAGE_CODE_BLOCK

return {
component: CodeBlockActiveButton,
componentProps: {
action: (language = 'js') => editor.commands.setCodeBlock({
language,
}),
isActive: () => editor.isActive('codeBlock') || false,
disabled: !editor.can().toggleCodeBlock(),
icon: 'Code2',
tooltip: t('editor.codeblock.tooltip'),
languages,
},
}
},
}
},
addProseMirrorPlugins() {
return [
...(this.parent?.() || []),
ShikiPlugin({
name: this.name,
defaultLanguage: null,
defaultTheme: this.options.defaultTheme,
}),
]
},
})
66 changes: 66 additions & 0 deletions src/extensions/CodeBlock/components/CodeBlockActiveButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import React, { useMemo } from 'react'

import type { BundledLanguage } from 'shiki'
import {
ActionButton,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components'
import { MAP_LANGUAGE_CODE_LABELS } from '@/constants'

interface Props {
editor: any
disabled?: boolean
color?: string
shortcutKeys?: string[]
maxHeight?: string | number
tooltip?: string
languages?: BundledLanguage[]
action: (language: string) => void
icon?: any
}

function CodeBlockActiveButton({ action, languages, ...props }: Props) {
const onClick = (language: string) => {
action(language)
}

const langs = useMemo(() => {
return languages?.map((language) => {
const title = MAP_LANGUAGE_CODE_LABELS[language] || language

return {
title,
// icon: language.icon,
language,
}
})
}, [languages])

return (
<DropdownMenu>
<DropdownMenuTrigger disabled={props?.disabled} asChild>
<ActionButton
tooltip={props?.tooltip}
disabled={props?.disabled}
icon={props?.icon}
/>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-full">
{langs?.map((item: any) => {
return (
<DropdownMenuItem key={`codeblock-${item.title}`} onClick={() => onClick(item.language)}>
<div className="h-full ml-1">
{item.title}
</div>
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
)
}

export default CodeBlockActiveButton
133 changes: 133 additions & 0 deletions src/extensions/CodeBlock/highlighter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import type {
BundledLanguage,
BundledTheme,
Highlighter,
} from 'shiki'
import {
bundledLanguages,
bundledThemes,
createHighlighter,
} from 'shiki'
import { findChildren } from '@tiptap/core'
import type { Node as ProsemirrorNode } from '@tiptap/pm/model'

let highlighter: Highlighter | undefined
let highlighterPromise: Promise<void> | undefined
const loadingLanguages = new Set<BundledLanguage>()
const loadingThemes = new Set<BundledTheme>()

interface HighlighterOptions {
themes: (BundledTheme | null | undefined)[]
languages: (BundledLanguage | null | undefined)[]
}

export function resetHighlighter() {
highlighter = undefined
highlighterPromise = undefined
loadingLanguages.clear()
loadingThemes.clear()
}

export function getShiki() {
return highlighter
}

/**
* Load the highlighter. Makes sure the highlighter is only loaded once.
*/
export function loadHighlighter(opts: HighlighterOptions) {
if (!highlighter && !highlighterPromise) {
const themes = opts.themes.filter(
(theme): theme is BundledTheme => !!theme && theme in bundledThemes,
)
const langs = opts.languages.filter(
(lang): lang is BundledLanguage => !!lang && lang in bundledLanguages,
)
highlighterPromise = createHighlighter({ themes, langs }).then((h) => {
highlighter = h
})
return highlighterPromise
}

if (highlighterPromise) {
return highlighterPromise
}
}

/**
* Loads a theme if it's valid and not yet loaded.
* @returns true or false depending on if it got loaded.
*/
export async function loadTheme(theme: BundledTheme) {
if (
highlighter
&& !highlighter.getLoadedThemes().includes(theme)
&& !loadingThemes.has(theme)
&& theme in bundledThemes
) {
loadingThemes.add(theme)
await highlighter.loadTheme(theme)
loadingThemes.delete(theme)
return true
}

return false
}

/**
* Loads a language if it's valid and not yet loaded
* @returns true or false depending on if it got loaded.
*/
export async function loadLanguage(language: BundledLanguage) {
if (
highlighter
&& !highlighter.getLoadedLanguages().includes(language)
&& !loadingLanguages.has(language)
&& language in bundledLanguages
) {
loadingLanguages.add(language)
await highlighter.loadLanguage(language)
loadingLanguages.delete(language)
return true
}

return false
}

/**
* Initializes the highlighter based on the prosemirror document,
* with the themes and languages in the document.
*/
export async function initHighlighter({
doc,
name,
defaultTheme,
defaultLanguage,
}: {
doc: ProsemirrorNode
name: string
defaultLanguage: BundledLanguage | null | undefined
defaultTheme: BundledTheme
}) {
const codeBlocks = findChildren(doc, node => node.type.name === name)

const themes = [
...codeBlocks.map(block => block.node.attrs.theme as BundledTheme),
defaultTheme,
]
const languages = [
...codeBlocks.map(block => block.node.attrs.language as BundledLanguage),
defaultLanguage,
]

if (!highlighter) {
const loader = loadHighlighter({ languages, themes })
await loader
}
else {
await Promise.all([
...themes.flatMap(theme => loadTheme(theme)),
...languages.flatMap(language => !!language && loadLanguage(language)),
])
}
}
Loading

0 comments on commit 1cde81d

Please sign in to comment.