Skip to content

Commit c7b152d

Browse files
committed
add editor highlighting
1 parent eaa2247 commit c7b152d

File tree

11 files changed

+333
-71
lines changed

11 files changed

+333
-71
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
This plugin integrates [shiki](https://shiki.style/) via [Expressive Code](https://expressive-code.com/) into Obsidian, providing better syntax highlighting for over 100 languages.
44

5-
This plugin works in reading mode and in live preview mode, as long as your cursor is not inside the code block.
5+
This plugin works in reading, live preview and edit mode, providing a consistent experience across the app.
66

77
Below is an example with line numbers, a custom header, and line highlighting.
88

bun.lockb

2.71 KB
Binary file not shown.

exampleVault/Untitled.md

Whitespace-only changes.

exampleVault/index.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
test: hello
33
---
44

5-
TypeScript code
5+
TypeScript codearsiNOM
66

77
```ts title="A part of ParsiNOM" {13-15, 22-29} showLineNumbers
88
export class Parser<const SType extends STypeBase> {
@@ -87,7 +87,6 @@ Bash
8787
echo "Hello"
8888
```
8989

90-
9190
```diff
9291
+ this line will be marked as inserted
9392
- this line will be marked as deleted

package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@
2323
"author": "Moritz Jung",
2424
"license": "GPL-3.0",
2525
"devDependencies": {
26+
"@codemirror/language": "^6.10.1",
27+
"@codemirror/state": "^6.4.1",
28+
"@codemirror/view": "^6.26.1",
2629
"@expressive-code/core": "^0.33.5",
2730
"@expressive-code/plugin-collapsible-sections": "^0.33.5",
2831
"@expressive-code/plugin-frames": "^0.33.5",
@@ -31,6 +34,7 @@
3134
"@expressive-code/plugin-text-markers": "^0.33.5",
3235
"@happy-dom/global-registrator": "^14.3.6",
3336
"@lemons_dev/parsinom": "^0.0.12",
37+
"@lezer/common": "^1.2.1",
3438
"@tsconfig/svelte": "^5.0.3",
3539
"@types/bun": "^1.0.10",
3640
"@typescript-eslint/eslint-plugin": "^7.3.1",
@@ -43,6 +47,7 @@
4347
"eslint-plugin-import": "^2.29.1",
4448
"eslint-plugin-isaacscript": "^3.12.2",
4549
"eslint-plugin-only-warn": "^1.1.0",
50+
"itertools-ts": "^1.27.1",
4651
"obsidian": "latest",
4752
"prettier": "^3.2.5",
4853
"prettier-plugin-svelte": "^3.2.2",

src/Cm6_Util.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { type EditorState } from '@codemirror/state';
2+
import { type DecorationSet } from '@codemirror/view';
3+
4+
export class Cm6_Util {
5+
/**
6+
* Checks if two ranges overlap.
7+
*
8+
* @param fromA
9+
* @param toA
10+
* @param fromB
11+
* @param toB
12+
*/
13+
static checkRangeOverlap(fromA: number, toA: number, fromB: number, toB: number): boolean {
14+
return fromA <= toB && fromB <= toA;
15+
}
16+
17+
/**
18+
* Gets the editor content of a given range.
19+
*
20+
* @param state
21+
* @param from
22+
* @param to
23+
*/
24+
static getContent(state: EditorState, from: number, to: number): string {
25+
return state.sliceDoc(from, to);
26+
}
27+
28+
/**
29+
* Checks if a decoration exists in a given range.
30+
*
31+
* @param decorations
32+
* @param from
33+
* @param to
34+
*/
35+
static existsDecorationBetween(decorations: DecorationSet, from: number, to: number): boolean {
36+
let exists = false;
37+
decorations.between(from, to, () => {
38+
exists = true;
39+
});
40+
return exists;
41+
}
42+
}

src/Cm6_ViewPlugin.ts

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import type ShikiPlugin from 'src/main';
2+
import { Decoration, type DecorationSet, type EditorView, ViewPlugin, type ViewUpdate } from '@codemirror/view';
3+
import { type Range } from '@codemirror/state';
4+
import { type SyntaxNode } from '@lezer/common';
5+
import { syntaxTree } from '@codemirror/language';
6+
import { Cm6_Util } from './Cm6_Util';
7+
import { type ThemedToken } from 'shiki';
8+
9+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
10+
export function createCm6Plugin(plugin: ShikiPlugin): ViewPlugin<any> {
11+
return ViewPlugin.fromClass(
12+
class {
13+
decorations: DecorationSet;
14+
15+
constructor(view: EditorView) {
16+
this.decorations = Decoration.none;
17+
this.updateWidgets(view);
18+
}
19+
20+
/**
21+
* Triggered by codemirror when the view updates.
22+
* Depending on the update type, the decorations are either updated or recreated.
23+
*
24+
* @param update
25+
*/
26+
update(update: ViewUpdate): void {
27+
this.decorations = this.decorations.map(update.changes);
28+
29+
if (update.docChanged || update.selectionSet) {
30+
this.updateWidgets(update.view);
31+
}
32+
}
33+
34+
/**
35+
* Updates all the widgets by traversing the syntax tree.
36+
*
37+
* @param view
38+
*/
39+
updateWidgets(view: EditorView): void {
40+
let lang = '';
41+
let state: SyntaxNode[] = [];
42+
43+
// const t1 = performance.now();
44+
45+
syntaxTree(view.state).iterate({
46+
enter: nodeRef => {
47+
const node = nodeRef.node;
48+
49+
const props: Set<string> = new Set<string>(node.type.name?.split('_'));
50+
51+
if (props.has('formatting')) {
52+
return;
53+
}
54+
55+
if (props.has('HyperMD-codeblock') && !props.has('HyperMD-codeblock-begin') && !props.has('HyperMD-codeblock-end')) {
56+
state.push(node);
57+
return;
58+
}
59+
60+
if (props.has('HyperMD-codeblock-begin')) {
61+
const content = Cm6_Util.getContent(view.state, node.from, node.to);
62+
63+
lang = content.match(/^```(\S+)/)?.[1] ?? '';
64+
}
65+
66+
if (props.has('HyperMD-codeblock-end')) {
67+
if (state.length > 0) {
68+
const start = state[0].from;
69+
const end = state[state.length - 1].to;
70+
71+
const content = Cm6_Util.getContent(view.state, start, end);
72+
73+
// const t2 = performance.now();
74+
75+
this.renderWidget(start, end, lang, content)
76+
.then(decorations => {
77+
this.removeDecoration(start, end);
78+
this.addDecoration(start, end, decorations);
79+
// console.log('Highlighted widget in', performance.now() - t2, 'ms');
80+
})
81+
.catch(console.error);
82+
}
83+
84+
lang = '';
85+
state = [];
86+
}
87+
},
88+
});
89+
90+
// console.log('Traversed syntax tree in', performance.now() - t1, 'ms');
91+
}
92+
93+
/**
94+
* Removes all decorations at a given node.
95+
*
96+
* @param from
97+
* @param to
98+
*/
99+
removeDecoration(from: number, to: number): void {
100+
this.decorations = this.decorations.update({
101+
filterFrom: from,
102+
filterTo: to,
103+
filter: (_from3, _to3, _decoration) => {
104+
return false;
105+
},
106+
});
107+
}
108+
109+
/**
110+
* Adds a widget at a given node if it does not exist yet.
111+
*
112+
* @param from
113+
* @param to
114+
* @param newDecorations
115+
*/
116+
addDecoration(from: number, to: number, newDecorations: Range<Decoration>[]): void {
117+
// check if the decoration already exists and only add it if it does not exist
118+
if (Cm6_Util.existsDecorationBetween(this.decorations, from, to)) {
119+
return;
120+
}
121+
122+
if (newDecorations.length === 0) {
123+
return;
124+
}
125+
126+
this.decorations = this.decorations.update({
127+
add: newDecorations,
128+
});
129+
}
130+
131+
/**
132+
* Renders a singe widget of the given widget type at a given node.
133+
*
134+
* @param from
135+
* @param to
136+
* @param language
137+
* @param content
138+
*/
139+
async renderWidget(from: number, to: number, language: string, content: string): Promise<Range<Decoration>[]> {
140+
const highlight = await plugin.getHighlightTokens(content, language);
141+
142+
if (!highlight) {
143+
return [];
144+
}
145+
146+
const tokens = highlight.tokens.flat(1);
147+
148+
const decorations: Range<Decoration>[] = [];
149+
150+
for (let i = 0; i < tokens.length; i++) {
151+
const token = tokens[i];
152+
const nextToken: ThemedToken | undefined = tokens[i + 1];
153+
154+
decorations.push(
155+
Decoration.mark({
156+
attributes: {
157+
style: `color: ${token.color}`,
158+
},
159+
}).range(from + token.offset, nextToken ? from + nextToken.offset : to),
160+
);
161+
}
162+
163+
return decorations;
164+
}
165+
166+
/**
167+
* Triggered by codemirror when the view plugin is destroyed.
168+
* Unloads all widgets.
169+
*/
170+
destroy(): void {
171+
this.decorations = Decoration.none;
172+
}
173+
},
174+
{
175+
decorations: v => v.decorations,
176+
},
177+
);
178+
}

src/CodeBlock.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,17 @@ export class CodeBlock extends MarkdownRenderChild {
66
plugin: ShikiPlugin;
77
source: string;
88
language: string;
9-
languageShorthand: string;
9+
languageName: string;
1010
ctx: MarkdownPostProcessorContext;
1111
cachedMetaString: string;
1212

13-
constructor(plugin: ShikiPlugin, containerEl: HTMLElement, source: string, language: string, languageShorthand: string, ctx: MarkdownPostProcessorContext) {
13+
constructor(plugin: ShikiPlugin, containerEl: HTMLElement, source: string, language: string, languageName: string, ctx: MarkdownPostProcessorContext) {
1414
super(containerEl);
1515

1616
this.plugin = plugin;
1717
this.source = source;
1818
this.language = language;
19-
this.languageShorthand = languageShorthand;
19+
this.languageName = languageName;
2020
this.ctx = ctx;
2121
this.cachedMetaString = '';
2222
}
@@ -32,7 +32,7 @@ export class CodeBlock extends MarkdownRenderChild {
3232
const startLine = lines[sectionInfo.lineStart];
3333

3434
// regexp to match the text after the code block language
35-
const regex = new RegExp('^[^`~]*?(```+|~~~+)' + this.languageShorthand + ' (.*)', 'g');
35+
const regex = new RegExp('^[^`~]*?(```+|~~~+)' + this.languageName + ' (.*)', 'g');
3636
const match = regex.exec(startLine);
3737
if (match !== null) {
3838
return match[2];
@@ -66,13 +66,13 @@ export class CodeBlock extends MarkdownRenderChild {
6666
}
6767
}
6868

69-
public async onload(): Promise<void> {
69+
public onload(): void {
7070
super.onload();
7171

7272
this.plugin.addActiveCodeBlock(this);
7373

7474
this.cachedMetaString = this.getMetaString();
75-
await this.render(this.cachedMetaString);
75+
void this.render(this.cachedMetaString);
7676
}
7777

7878
public onunload(): void {

src/ECTheme.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@ export const EC_THEME: ExpressiveCodeEngineConfig['styleOverrides'] = {
44
borderColor: 'var(--shiki-code-block-border-color)',
55
borderRadius: 'var(--shiki-code-block-border-radius)',
66
borderWidth: 'var(--shiki-code-block-border-width)',
7-
codeBackground: 'var(--code-background)',
7+
codeBackground: 'var(--shiki-code-background)',
88
codeFontFamily: 'var(--font-monospace)',
99
codeFontSize: 'var(--code-size)',
1010
codeFontWeight: 'var(--font-normal)',
11-
codeForeground: 'var(--code-normal)',
11+
codeForeground: 'var(--shiki-code-normal)',
1212
codeLineHeight: 'var(--line-height-normal)',
1313
codePaddingBlock: 'var(--size-4-3)',
1414
codePaddingInline: 'var(--size-4-4)',
@@ -45,30 +45,30 @@ export const EC_THEME: ExpressiveCodeEngineConfig['styleOverrides'] = {
4545
markBorderColor: 'var(--shiki-highlight-neutral)',
4646
},
4747
frames: {
48-
editorActiveTabBackground: 'var(--code-background)',
48+
editorActiveTabBackground: 'var(--shiki-code-background)',
4949
editorActiveTabBorderColor: 'transparent',
5050
editorActiveTabForeground: 'var(--text-normal)',
5151
editorActiveTabIndicatorBottomColor: 'transparent',
5252
editorActiveTabIndicatorHeight: '2px',
5353
editorActiveTabIndicatorTopColor: 'var(--shiki-highlight-neutral)',
54-
editorBackground: 'var(--code-background)',
54+
editorBackground: 'var(--shiki-code-background)',
5555
editorTabBarBackground: 'var(--color-primary)',
5656
editorTabBarBorderBottomColor: 'transparent',
5757
editorTabBarBorderColor: 'transparent',
5858
editorTabBorderRadius: 'var(--shiki-code-border-radius)',
5959
editorTabsMarginBlockStart: '0',
6060
editorTabsMarginInlineStart: '0',
6161
frameBoxShadowCssValue: 'none',
62-
inlineButtonBackground: 'var(--interactive-normal)',
62+
inlineButtonBackground: 'var(--background-modifier-hover)',
6363
inlineButtonBackgroundActiveOpacity: '1',
6464
inlineButtonBackgroundHoverOrFocusOpacity: '1',
65-
inlineButtonBackgroundIdleOpacity: '1',
65+
inlineButtonBackgroundIdleOpacity: '0',
6666
inlineButtonBorder: 'var(--shiki-code-border-color)',
6767
inlineButtonBorderOpacity: '1',
6868
inlineButtonForeground: 'var(--text-normal)',
6969
shadowColor: 'transparent',
70-
terminalBackground: 'var(--code-background)',
71-
terminalTitlebarBackground: 'var(--code-background)',
70+
terminalBackground: 'var(--shiki-code-background)',
71+
terminalTitlebarBackground: 'var(--shiki-code-background)',
7272
terminalTitlebarBorderBottomColor: 'transparent',
7373
terminalTitlebarDotsForeground: 'var(--shiki-terminal-dots-color)',
7474
terminalTitlebarDotsOpacity: '1',

0 commit comments

Comments
 (0)