kaneo (minimalist kanban) fork to experiment adding a tangled integration
github.com/usekaneo/kaneo
1import { Extension, findChildren } from "@tiptap/core";
2import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
3import type { EditorState, Transaction } from "@tiptap/pm/state";
4import { Plugin, PluginKey } from "@tiptap/pm/state";
5import { Decoration, DecorationSet } from "@tiptap/pm/view";
6import type { Highlighter } from "shiki";
7
8type ShikiCodeBlockOptions = {
9 defaultLanguage: string;
10 highlighter: Highlighter | null | (() => Highlighter | null);
11 resolveLanguage: (language: string) => string;
12 themeDark: string;
13 themeLight: string;
14};
15
16export const SHIKI_CODEBLOCK_REFRESH_META = "shiki-codeblock-refresh";
17const shikiPluginKey = new PluginKey("shiki-codeblock");
18
19function getCurrentTheme(options: ShikiCodeBlockOptions) {
20 if (typeof document === "undefined") return options.themeDark;
21 return document.documentElement.classList.contains("dark")
22 ? options.themeDark
23 : options.themeLight;
24}
25
26function resolveHighlighter(options: ShikiCodeBlockOptions) {
27 if (typeof options.highlighter === "function") {
28 return options.highlighter();
29 }
30
31 return options.highlighter;
32}
33
34function getDecorations(doc: ProseMirrorNode, options: ShikiCodeBlockOptions) {
35 const highlighter = resolveHighlighter(options);
36 if (!highlighter) return DecorationSet.empty;
37
38 const decorations: Decoration[] = [];
39 const theme = getCurrentTheme(options);
40
41 findChildren(doc, (node) => node.type.name === "codeBlock").forEach(
42 (block) => {
43 const code = block.node.textContent;
44 if (!code) return;
45
46 const rawLanguage =
47 (block.node.attrs.language as string | undefined) || "";
48 const normalizedLanguage = options.resolveLanguage(
49 rawLanguage || options.defaultLanguage,
50 );
51 let tokensResult: ReturnType<Highlighter["codeToTokens"]>;
52
53 try {
54 tokensResult = highlighter.codeToTokens(code, {
55 lang: normalizedLanguage as never,
56 theme: theme as never,
57 });
58 } catch {
59 tokensResult = highlighter.codeToTokens(code, {
60 lang: "text" as never,
61 theme: theme as never,
62 });
63 }
64
65 let from = block.pos + 1;
66 for (
67 let lineIndex = 0;
68 lineIndex < tokensResult.tokens.length;
69 lineIndex += 1
70 ) {
71 const line = tokensResult.tokens[lineIndex];
72 for (const token of line) {
73 const text = token.content || "";
74 if (!text.length) continue;
75
76 const to = from + text.length;
77 const styles: string[] = [];
78 if (token.color) styles.push(`color:${token.color}`);
79 if (typeof token.fontStyle === "number") {
80 if ((token.fontStyle & 1) !== 0) styles.push("font-style:italic");
81 if ((token.fontStyle & 2) !== 0) styles.push("font-weight:600");
82 if ((token.fontStyle & 4) !== 0) {
83 styles.push("text-decoration:underline");
84 }
85 }
86
87 if (styles.length > 0) {
88 decorations.push(
89 Decoration.inline(from, to, { style: styles.join(";") }),
90 );
91 }
92 from = to;
93 }
94
95 if (lineIndex < tokensResult.tokens.length - 1) {
96 from += 1;
97 }
98 }
99 },
100 );
101
102 return DecorationSet.create(doc, decorations);
103}
104
105export const ShikiCodeBlock = Extension.create<ShikiCodeBlockOptions>({
106 name: "shikiCodeBlock",
107
108 addOptions() {
109 return {
110 defaultLanguage: "text",
111 highlighter: null,
112 resolveLanguage: (language: string) => language,
113 themeDark: "github-dark",
114 themeLight: "github-light",
115 };
116 },
117
118 addProseMirrorPlugins() {
119 const options = this.options;
120
121 return [
122 new Plugin({
123 key: shikiPluginKey,
124 state: {
125 init: (_config: unknown, state: EditorState) =>
126 getDecorations(state.doc, options),
127 apply: (transaction: Transaction, decorationSet: DecorationSet) => {
128 if (
129 transaction.docChanged ||
130 transaction.getMeta(SHIKI_CODEBLOCK_REFRESH_META)
131 ) {
132 return getDecorations(transaction.doc, options);
133 }
134
135 return decorationSet.map(transaction.mapping, transaction.doc);
136 },
137 },
138 props: {
139 decorations(state: EditorState) {
140 return shikiPluginKey.getState(state);
141 },
142 },
143 }),
144 ];
145 },
146});