From 75f49461de4f3338b99c54beb0f8d233f45ac3e8 Mon Sep 17 00:00:00 2001 From: Anthony Fu Date: Tue, 13 Dec 2022 14:02:43 +0100 Subject: [PATCH] feat: code highlight for tiptap --- components/tiptap/TiptapCodeBlock.vue | 31 ++++++- composables/shiki.ts | 28 ++++-- composables/tiptap.ts | 12 +-- composables/tiptap/shiki.ts | 129 ++++++++++++++++++++++++++ package.json | 2 - pnpm-lock.yaml | 46 --------- styles/global.css | 2 +- 7 files changed, 181 insertions(+), 69 deletions(-) create mode 100644 composables/tiptap/shiki.ts diff --git a/components/tiptap/TiptapCodeBlock.vue b/components/tiptap/TiptapCodeBlock.vue index b7325f08..b9aa0e79 100644 --- a/components/tiptap/TiptapCodeBlock.vue +++ b/components/tiptap/TiptapCodeBlock.vue @@ -4,8 +4,24 @@ import { NodeViewContent, NodeViewWrapper, nodeViewProps } from '@tiptap/vue-3' const props = defineProps(nodeViewProps) const languages = [ - 'js', - 'ts', + 'c', + 'cpp', + 'csharp', + 'css', + 'dart', + 'go', + 'html', + 'java', + 'javascript', + 'jsx', + 'kotlin', + 'python', + 'rust', + 'svelte', + 'swift', + 'tsx', + 'typescript', + 'vue', ] const selectedLanguage = computed({ @@ -20,8 +36,13 @@ const selectedLanguage = computed({ diff --git a/composables/shiki.ts b/composables/shiki.ts index dfc5170d..6d4f6977 100644 --- a/composables/shiki.ts +++ b/composables/shiki.ts @@ -1,11 +1,11 @@ import type { Highlighter, Lang } from 'shiki-es' -export const shiki = ref() +const shiki = ref() const registeredLang = ref(new Map()) let shikiImport: Promise | undefined -export function highlightCode(code: string, lang: Lang) { +export function useHightlighter(lang: Lang) { if (!shikiImport) { shikiImport = import('shiki-es') .then(async (r) => { @@ -25,7 +25,7 @@ export function highlightCode(code: string, lang: Lang) { } if (!shiki.value) - return code + return undefined if (!registeredLang.value.get(lang)) { shiki.value.loadLanguage(lang) @@ -37,11 +37,27 @@ export function highlightCode(code: string, lang: Lang) { console.error(e) registeredLang.value.set(lang, false) }) - return code + return undefined } - return shiki.value.codeToHtml(code, { + return shiki.value +} + +export function useShikiTheme() { + return isDark.value ? 'vitesse-dark' : 'vitesse-light' +} + +export function highlightCode(code: string, lang: Lang) { + const shiki = useHightlighter(lang) + if (!shiki) + return code + + return shiki.codeToHtml(code, { lang, - theme: isDark.value ? 'vitesse-dark' : 'vitesse-light', + theme: useShikiTheme(), }) } + +export function useShiki() { + return shiki +} diff --git a/composables/tiptap.ts b/composables/tiptap.ts index 57fa5e23..544ac6df 100644 --- a/composables/tiptap.ts +++ b/composables/tiptap.ts @@ -1,4 +1,4 @@ -import { Extension, VueNodeViewRenderer, useEditor } from '@tiptap/vue-3' +import { Extension, useEditor } from '@tiptap/vue-3' import Placeholder from '@tiptap/extension-placeholder' import Document from '@tiptap/extension-document' import Paragraph from '@tiptap/extension-paragraph' @@ -10,11 +10,10 @@ import Bold from '@tiptap/extension-bold' import Italic from '@tiptap/extension-italic' import Code from '@tiptap/extension-code' import { Plugin } from 'prosemirror-state' -import CodeBlock from '@tiptap/extension-code-block' import type { Ref } from 'vue' import { HashSuggestion, MentionSuggestion } from './tiptap/suggestion' -import TiptapCodeBlock from '~/components/tiptap/TiptapCodeBlock.vue' +import { CodeBlockShiki } from './tiptap/shiki' export interface UseTiptapOptions { content: Ref @@ -56,12 +55,7 @@ export function useTiptap(options: UseTiptapOptions) { CharacterCount.configure({ limit: characterLimit.value, }), - CodeBlock - .extend({ - addNodeView() { - return VueNodeViewRenderer(TiptapCodeBlock) - }, - }), + CodeBlockShiki, Extension.create({ name: 'api', addKeyboardShortcuts() { diff --git a/composables/tiptap/shiki.ts b/composables/tiptap/shiki.ts new file mode 100644 index 00000000..2e564bc4 --- /dev/null +++ b/composables/tiptap/shiki.ts @@ -0,0 +1,129 @@ +import type { CodeBlockOptions } from '@tiptap/extension-code-block' +import CodeBlock from '@tiptap/extension-code-block' +import { VueNodeViewRenderer } from '@tiptap/vue-3' + +import { findChildren } from '@tiptap/core' +import type { Node as ProsemirrorNode } from 'prosemirror-model' +import { Plugin, PluginKey } from 'prosemirror-state' +import { Decoration, DecorationSet } from 'prosemirror-view' +import TiptapCodeBlock from '~/components/tiptap/TiptapCodeBlock.vue' + +export interface CodeBlockShikiOptions extends CodeBlockOptions { + defaultLanguage: string | null | undefined +} + +export const CodeBlockShiki = CodeBlock.extend({ + addOptions() { + return { + ...this.parent?.(), + defaultLanguage: null, + } + }, + + addProseMirrorPlugins() { + return [ + ...this.parent?.() || [], + ProseMirrorShikiPlugin({ + name: this.name, + }), + ] + }, + + addNodeView() { + return VueNodeViewRenderer(TiptapCodeBlock) + }, +}) + +function getDecorations({ + doc, + name, +}: { doc: ProsemirrorNode; name: string }) { + const decorations: Decoration[] = [] + + findChildren(doc, node => node.type.name === name) + .forEach((block) => { + let from = block.pos + 1 + const language = block.node.attrs.language || 'text' + + const shiki = useHightlighter(language) + + if (!shiki) + return + + const lines = shiki.codeToThemedTokens(block.node.textContent, language, useShikiTheme()) + + lines.forEach((line) => { + line.forEach((token) => { + const decoration = Decoration.inline(from, from + token.content.length, { + style: `color: ${token.color}`, + }) + + decorations.push(decoration) + from += token.content.length + }) + from += 1 + }) + }) + + return DecorationSet.create(doc, decorations) +} + +function ProseMirrorShikiPlugin({ name }: { name: string }) { + const plugin: Plugin = new Plugin({ + key: new PluginKey('shiki'), + + state: { + init: (_, { doc }) => getDecorations({ + doc, + name, + }), + apply: (transaction, decorationSet, oldState, newState) => { + const oldNodeName = oldState.selection.$head.parent.type.name + const newNodeName = newState.selection.$head.parent.type.name + const oldNodes = findChildren(oldState.doc, node => node.type.name === name) + const newNodes = findChildren(newState.doc, node => node.type.name === name) + + if ( + transaction.docChanged + // Apply decorations if: + && ( + // selection includes named node, + [oldNodeName, newNodeName].includes(name) + // OR transaction adds/removes named node, + || newNodes.length !== oldNodes.length + // OR transaction has changes that completely encapsulte a node + // (for example, a transaction that affects the entire document). + // Such transactions can happen during collab syncing via y-prosemirror, for example. + || transaction.steps.some((step) => { + // @ts-expect-error cast + return step.from !== undefined + // @ts-expect-error cast + && step.to !== undefined + && oldNodes.some((node) => { + // @ts-expect-error cast + return node.pos >= step.from + // @ts-expect-error cast + && node.pos + node.node.nodeSize <= step.to + }) + }) + ) + ) { + return getDecorations({ + doc: transaction.doc, + name, + }) + } + + return decorationSet.map(transaction.mapping, transaction.doc) + }, + }, + + props: { + decorations(state) { + return plugin.getState(state) + }, + }, + }) + + return plugin +} diff --git a/package.json b/package.json index 9fb28746..c31ac376 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,6 @@ "@pinia/nuxt": "^0.4.6", "@tiptap/extension-character-count": "2.0.0-beta.204", "@tiptap/extension-code-block": "2.0.0-beta.204", - "@tiptap/extension-code-block-lowlight": "2.0.0-beta.204", "@tiptap/extension-mention": "2.0.0-beta.204", "@tiptap/extension-paragraph": "2.0.0-beta.204", "@tiptap/extension-placeholder": "2.0.0-beta.204", @@ -64,7 +63,6 @@ "js-yaml": "^4.1.0", "jsdom": "^20.0.3", "lint-staged": "^13.0.4", - "lowlight": "^2.8.0", "lru-cache": "^7.14.1", "masto": "^4.7.5", "nuxt": "^3.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index baa7528a..b2419476 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,7 +14,6 @@ specifiers: '@pinia/nuxt': ^0.4.6 '@tiptap/extension-character-count': 2.0.0-beta.204 '@tiptap/extension-code-block': 2.0.0-beta.204 - '@tiptap/extension-code-block-lowlight': 2.0.0-beta.204 '@tiptap/extension-mention': 2.0.0-beta.204 '@tiptap/extension-paragraph': 2.0.0-beta.204 '@tiptap/extension-placeholder': 2.0.0-beta.204 @@ -44,7 +43,6 @@ specifiers: js-yaml: ^4.1.0 jsdom: ^20.0.3 lint-staged: ^13.0.4 - lowlight: ^2.8.0 lru-cache: ^7.14.1 masto: ^4.7.5 nuxt: ^3.0.0 @@ -86,7 +84,6 @@ devDependencies: '@pinia/nuxt': 0.4.6_typescript@4.9.3 '@tiptap/extension-character-count': 2.0.0-beta.204 '@tiptap/extension-code-block': 2.0.0-beta.204 - '@tiptap/extension-code-block-lowlight': 2.0.0-beta.204_czakdrv4w4d5ggkubz4l2tz4ny '@tiptap/extension-mention': 2.0.0-beta.204_ggkstofzpnfxkp3gzsos4mewvi '@tiptap/extension-paragraph': 2.0.0-beta.204 '@tiptap/extension-placeholder': 2.0.0-beta.204 @@ -116,7 +113,6 @@ devDependencies: js-yaml: 4.1.0 jsdom: 20.0.3 lint-staged: 13.0.4 - lowlight: 2.8.0 lru-cache: 7.14.1 masto: 4.7.5 nuxt: 3.0.0_s5ps7njkmjlaqajutnox5ntcla @@ -1376,18 +1372,6 @@ packages: prosemirror-state: 1.4.2 dev: true - /@tiptap/extension-code-block-lowlight/2.0.0-beta.204_czakdrv4w4d5ggkubz4l2tz4ny: - resolution: {integrity: sha512-6n2RWlMv7V3NANK+5UfxOMaK83ps8BucleQ/XdNcZuj/glTZco8Z+2E+kazW92c4IFrSgteriYg5ZqC2NBYXrg==} - peerDependencies: - '@tiptap/core': ^2.0.0-beta.193 - '@tiptap/extension-code-block': ^2.0.0-beta.193 - dependencies: - '@tiptap/extension-code-block': 2.0.0-beta.204 - prosemirror-model: 1.18.3 - prosemirror-state: 1.4.2 - prosemirror-view: 1.29.1 - dev: true - /@tiptap/extension-code-block/2.0.0-beta.204: resolution: {integrity: sha512-IIkZsBT7rxhK7yHnM2LRQfS6i+HNQxU+E6tRtPYF40YSg1xMZSC/xDy0k+NEU/xM6ZVesRofW3voB6svFPPDtw==} peerDependencies: @@ -1644,12 +1628,6 @@ packages: '@types/node': 18.11.10 dev: true - /@types/hast/2.3.4: - resolution: {integrity: sha512-wLEm0QvaoawEDoTRwzTXp4b4jpwiJDvR5KMnFnVodm3scufTlBOWRD6N1OBf9TZMhjlNsSfcO5V+7AF4+Vy+9g==} - dependencies: - '@types/unist': 2.0.6 - dev: true - /@types/js-yaml/4.0.5: resolution: {integrity: sha512-FhpRzf927MNQdRZP0J5DLIdTXhjLYzeUTmLAu69mnVksLH9CJY3IuSeEgbKUki7GQZm0WqDkGzyxju2EZGD2wA==} dev: true @@ -4508,12 +4486,6 @@ packages: reusify: 1.0.4 dev: true - /fault/2.0.1: - resolution: {integrity: sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==} - dependencies: - format: 0.2.2 - dev: true - /fetch-blob/3.2.0: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} @@ -4624,11 +4596,6 @@ packages: mime-types: 2.1.35 dev: true - /format/0.2.2: - resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} - engines: {node: '>=0.4.x'} - dev: true - /formdata-polyfill/4.0.10: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} @@ -4966,11 +4933,6 @@ packages: tslib: 2.4.1 dev: true - /highlight.js/11.7.0: - resolution: {integrity: sha512-1rRqesRFhMO/PRF+G86evnyJkCgaZFOI+Z6kdj15TA18funfoqJXvgPCLSf0SWq3SRfg1j3HlDs8o4s3EGq1oQ==} - engines: {node: '>=12.0.0'} - dev: true - /hookable/5.4.2: resolution: {integrity: sha512-6rOvaUiNKy9lET1X0ECnyZ5O5kSV0PJbtA5yZUgdEF7fGJEVwSLSislltyt7nFwVVALYHQJtfGeAR2Y0A0uJkg==} dev: true @@ -5771,14 +5733,6 @@ packages: tslib: 2.4.1 dev: true - /lowlight/2.8.0: - resolution: {integrity: sha512-WeExw1IKEkel9ZcYwzpvcFzORIB0IlleTcxJYoEuUgHASuYe/OBYbV6ym/AetG7unNVCBU/SXpgTgs2nT93mhg==} - dependencies: - '@types/hast': 2.3.4 - fault: 2.0.1 - highlight.js: 11.7.0 - dev: true - /lru-cache/6.0.0: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} diff --git a/styles/global.css b/styles/global.css index 856cda29..ad6d2274 100644 --- a/styles/global.css +++ b/styles/global.css @@ -104,7 +104,7 @@ body { --at-apply: bg-code text-code px1 py0.5 rounded text-0.9em leading-0.8em; } pre code { - --at-apply: bg-transparent px0 py0 rounded-none; + --at-apply: bg-transparent px0 py0 rounded-none leading-1.6em; } .code-block {