Skip to content

Commit eeaaab6

Browse files
committed
Implement SPI to support search highlight
1 parent 9e3574c commit eeaaab6

8 files changed

Lines changed: 449 additions & 0 deletions

File tree

main.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,13 @@ import { imageHoverPreview, keyboardShortcut, updateBehavior } from './src/setti
2727
import { localized } from './src/strings';
2828
import { macOSTahoe } from './src/utils';
2929

30+
import {
31+
performSearch,
32+
setSearchMatchIndex,
33+
clearSearch,
34+
searchCounterInfo,
35+
} from './src/search';
36+
3037
if (window.__markeditPreviewInitialized__) {
3138
console.error('MarkEdit Preview has already been initialized. Multiple initializations may cause unexpected behavior.');
3239
} else {
@@ -45,6 +52,14 @@ if (window.__markeditPreviewInitialized__) {
4552
// Allow other extensions or scripts to generate the HTML
4653
window.MarkEditGetHtml ??= generateStaticHtml;
4754

55+
// Expose bridge API for CoreEditor to call functions in the preview
56+
window.__markeditPreviewSPI__ = {
57+
performSearch,
58+
setSearchMatchIndex,
59+
clearSearch,
60+
searchCounterInfo,
61+
};
62+
4863
MarkEdit.addMainMenuItem({
4964
title: localized('viewMode'),
5065
icon: macOSTahoe() ? 'eye' : undefined,

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"@typescript-eslint/parser": "^8.32.1",
2525
"cross-env": "^7.0.3",
2626
"eslint": "^9.27.0",
27+
"happy-dom": "^20.8.9",
2728
"markedit-api": "https://github.com/MarkEdit-app/MarkEdit-api#v0.21.0",
2829
"markedit-vite": "https://github.com/MarkEdit-app/MarkEdit-vite#v0.4.0",
2930
"typescript": "^5.0.0",
@@ -33,6 +34,7 @@
3334
},
3435
"dependencies": {
3536
"katex": "^0.16.40",
37+
"mark.js": "^8.11.1",
3638
"markdown-it": "^14.1.1",
3739
"markdown-it-anchor": "^9.2.0",
3840
"markdown-it-footnote": "^4.0.0",

src/search.ts

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import Mark from 'mark.js';
2+
import { currentViewMode, getPreviewPane, ViewMode } from './view';
3+
import { themeName } from './settings';
4+
5+
const MARK_MATCH_CLASS = 'markedit-preview-mark';
6+
const MARK_HIGHLIGHTED_CLASS = 'markedit-preview-mark-highlighted';
7+
8+
let isApplying = false;
9+
let currentOptions: SearchOptions | undefined;
10+
let currentIndex = 0;
11+
let markElements: HTMLElement[] = [];
12+
let contentObserver: MutationObserver | null = null;
13+
let markStyleSheet: HTMLStyleElement | null = null;
14+
15+
// Mirrors CoreEditor's EditorColors.searchMatch, keyed by the plugin's themeName setting.
16+
const searchMatchColors: Record<string, { light: string; dark: string }> = {
17+
'github': { light: '#fae17d7f', dark: '#f2cc607f' },
18+
'cobalt': { light: '#cad40f66', dark: '#cad40f66' },
19+
'dracula': { light: '#ffffff40', dark: '#ffffff40' },
20+
'minimal': { light: '#fae17d7f', dark: '#f2cc607f' },
21+
'night-owl': { light: '#5f7e9779', dark: '#5f7e9779' },
22+
'rose-pine': { light: '#6e6a864c', dark: '#6e6a8666' },
23+
'solarized': { light: '#f4c09d', dark: '#584032' },
24+
'synthwave84': { light: '#d18616bb', dark: '#d18616bb' },
25+
'winter-is-coming': { light: '#cee1f0', dark: '#103362' },
26+
'xcode': { light: '#e4e4e4', dark: '#545558' },
27+
};
28+
29+
export interface SearchOptions {
30+
search: string;
31+
caseSensitive: boolean;
32+
diacriticInsensitive: boolean;
33+
wholeWord: boolean;
34+
regexp: boolean;
35+
}
36+
37+
export interface SearchCounterInfo {
38+
numberOfItems: number;
39+
currentIndex: number;
40+
}
41+
42+
export function performSearch(options: SearchOptions) {
43+
currentOptions = options;
44+
currentIndex = 0;
45+
46+
if (options.search.length === 0) {
47+
clearSearch();
48+
return;
49+
}
50+
51+
const container = getPreviewPane();
52+
remarkWithNewOptions(container);
53+
observeContentChanges(container);
54+
}
55+
56+
export function setSearchMatchIndex(index: number) {
57+
if (markElements.length === 0) {
58+
return;
59+
}
60+
61+
// The editor may have more matches than the rendered preview (e.g. inside code fences);
62+
// use modulo to stay in range rather than clamping to the last element.
63+
currentIndex = index % markElements.length;
64+
highlightCurrent();
65+
}
66+
67+
export function clearSearch() {
68+
contentObserver?.disconnect();
69+
contentObserver = null;
70+
currentOptions = undefined;
71+
currentIndex = 0;
72+
markElements = [];
73+
new Mark(getPreviewPane()).unmark();
74+
}
75+
76+
// Returns undefined outside overlay mode so the editor's own counter is used instead.
77+
export function searchCounterInfo(): SearchCounterInfo | undefined {
78+
if (currentViewMode() !== ViewMode.preview) {
79+
return undefined;
80+
}
81+
82+
return { numberOfItems: markElements.length, currentIndex };
83+
}
84+
85+
function remarkWithNewOptions(container: HTMLElement) {
86+
const options = currentOptions;
87+
if (options === undefined || options.search.length === 0) {
88+
return;
89+
}
90+
91+
if (isApplying) {
92+
return;
93+
}
94+
95+
updateStyles();
96+
isApplying = true;
97+
98+
const { search, caseSensitive, wholeWord, diacriticInsensitive, regexp } = options;
99+
const marker = new Mark(container);
100+
101+
const onComplete = () => {
102+
markElements = Array.from(container.querySelectorAll<HTMLElement>(`.${MARK_MATCH_CLASS}`));
103+
currentIndex = markElements.length > 0 ? Math.min(currentIndex, markElements.length - 1) : 0;
104+
highlightCurrent();
105+
isApplying = false;
106+
};
107+
108+
marker.unmark({
109+
done: () => {
110+
if (regexp) {
111+
try {
112+
const flags = caseSensitive ? '' : 'i';
113+
marker.markRegExp(new RegExp(search, flags), {
114+
className: MARK_MATCH_CLASS,
115+
done: onComplete,
116+
});
117+
} catch {
118+
isApplying = false;
119+
currentIndex = 0;
120+
markElements = [];
121+
}
122+
} else {
123+
marker.mark(search, {
124+
className: MARK_MATCH_CLASS,
125+
caseSensitive,
126+
diacritics: diacriticInsensitive,
127+
separateWordSearch: false,
128+
accuracy: wholeWord ? 'exactly' : 'partially',
129+
done: onComplete,
130+
});
131+
}
132+
},
133+
});
134+
}
135+
136+
// Show the current-match indicator only when not in side-by-side mode, where
137+
// the editor's own highlight is already visible and indices may not correspond.
138+
function highlightCurrent() {
139+
const shouldHighlight = currentViewMode() !== ViewMode.sideBySide;
140+
markElements.forEach((mark, index) => {
141+
mark.classList.toggle(MARK_HIGHLIGHTED_CLASS, shouldHighlight && index === currentIndex);
142+
});
143+
144+
if (shouldHighlight && markElements.length > 0) {
145+
markElements[currentIndex].scrollIntoView({ behavior: 'smooth', block: 'center' });
146+
}
147+
}
148+
149+
function observeContentChanges(container: HTMLElement) {
150+
contentObserver?.disconnect();
151+
152+
// Observe only direct children — fires on preview re-renders (innerHTML
153+
// replacement) but not on mark.js changes inside nested block elements.
154+
contentObserver = new MutationObserver(() => {
155+
if (!isApplying) {
156+
remarkWithNewOptions(container);
157+
}
158+
});
159+
160+
contentObserver.observe(container, { childList: true });
161+
}
162+
163+
// Mirrors .cm-searchMatch / .cm-searchMatch-selected from CoreEditor's builder.ts.
164+
// Light/dark colors follow the preview's own themeName, not the editor's active theme.
165+
function updateStyles() {
166+
if (markStyleSheet === null) {
167+
markStyleSheet = document.createElement('style');
168+
document.head.appendChild(markStyleSheet);
169+
}
170+
171+
const { light, dark } = searchMatchColors[themeName] ?? searchMatchColors['github'];
172+
markStyleSheet.textContent = [
173+
`.${MARK_MATCH_CLASS} { background: ${light} !important; color: inherit !important; }`,
174+
`.${MARK_HIGHLIGHTED_CLASS} { background: #ffff00 !important; color: #000000 !important; box-shadow: 0px 0px 2px 1px rgba(0, 0, 0, 0.2); }`,
175+
'@media (prefers-color-scheme: dark) {',
176+
` .${MARK_MATCH_CLASS} { background: ${dark} !important; }`,
177+
'}',
178+
].join('\n');
179+
}

tests/search.test.ts

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
// @vitest-environment happy-dom
2+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
3+
4+
const mockMatchCount = vi.hoisted(() => ({ value: 1 }));
5+
const mockViewState = vi.hoisted(() => ({
6+
mode: 'preview' as string,
7+
pane: null as HTMLElement | null,
8+
}));
9+
10+
vi.mock('mark.js', () => ({
11+
default: class MockMark {
12+
private container: Element;
13+
constructor(container: Element) { this.container = container; }
14+
15+
mark(_keyword: string, options?: { className?: string; done?: (n: number) => void }) {
16+
for (let i = 0; i < mockMatchCount.value; i++) {
17+
const el = document.createElement('mark');
18+
el.className = options?.className ?? '';
19+
this.container.appendChild(el);
20+
}
21+
options?.done?.(mockMatchCount.value);
22+
}
23+
24+
markRegExp(_re: RegExp, options?: { className?: string; done?: (n: number) => void }) {
25+
for (let i = 0; i < mockMatchCount.value; i++) {
26+
const el = document.createElement('mark');
27+
el.className = options?.className ?? '';
28+
this.container.appendChild(el);
29+
}
30+
options?.done?.(mockMatchCount.value);
31+
}
32+
33+
unmark(options?: { done?: () => void }) {
34+
this.container.querySelectorAll('mark').forEach(el => el.remove());
35+
options?.done?.();
36+
}
37+
},
38+
}));
39+
40+
vi.mock('../src/settings', () => ({ themeName: 'github' }));
41+
42+
vi.mock('../src/view', () => ({
43+
ViewMode: { edit: 'edit', sideBySide: 'side-by-side', preview: 'preview' },
44+
currentViewMode: vi.fn(() => mockViewState.mode),
45+
getPreviewPane: vi.fn(() => mockViewState.pane),
46+
}));
47+
48+
import { performSearch, setSearchMatchIndex, clearSearch, searchCounterInfo } from '../src/search';
49+
50+
const baseOptions = {
51+
search: 'hello',
52+
caseSensitive: false,
53+
diacriticInsensitive: false,
54+
wholeWord: false,
55+
regexp: false,
56+
};
57+
58+
beforeEach(() => {
59+
mockViewState.pane = document.createElement('div');
60+
document.body.appendChild(mockViewState.pane);
61+
mockViewState.mode = 'preview';
62+
mockMatchCount.value = 1;
63+
clearSearch();
64+
});
65+
66+
afterEach(() => {
67+
document.body.innerHTML = '';
68+
mockViewState.pane = null;
69+
});
70+
71+
describe('searchCounterInfo', () => {
72+
it('returns undefined in edit mode', () => {
73+
mockViewState.mode = 'edit';
74+
expect(searchCounterInfo()).toBeUndefined();
75+
});
76+
77+
it('returns undefined in side-by-side mode', () => {
78+
mockViewState.mode = 'side-by-side';
79+
expect(searchCounterInfo()).toBeUndefined();
80+
});
81+
82+
it('returns zero counter in preview mode with no active search', () => {
83+
expect(searchCounterInfo()).toEqual({ numberOfItems: 0, currentIndex: 0 });
84+
});
85+
86+
it('reflects mark count after search', () => {
87+
mockMatchCount.value = 3;
88+
performSearch(baseOptions);
89+
expect(searchCounterInfo()).toEqual({ numberOfItems: 3, currentIndex: 0 });
90+
});
91+
});
92+
93+
describe('performSearch', () => {
94+
it('clears marks when query is empty', () => {
95+
mockMatchCount.value = 2;
96+
performSearch(baseOptions);
97+
performSearch({ ...baseOptions, search: '' });
98+
expect(searchCounterInfo()?.numberOfItems).toBe(0);
99+
});
100+
101+
it('resets currentIndex to 0 on new search', () => {
102+
mockMatchCount.value = 3;
103+
performSearch(baseOptions);
104+
setSearchMatchIndex(2);
105+
performSearch({ ...baseOptions, search: 'world' });
106+
expect(searchCounterInfo()?.currentIndex).toBe(0);
107+
});
108+
109+
it('handles regexp queries', () => {
110+
performSearch({ ...baseOptions, regexp: true });
111+
expect(searchCounterInfo()?.numberOfItems).toBe(1);
112+
});
113+
114+
it('handles invalid regexp without throwing', () => {
115+
expect(() => {
116+
performSearch({ ...baseOptions, regexp: true, search: '[invalid' });
117+
}).not.toThrow();
118+
});
119+
});
120+
121+
describe('setSearchMatchIndex', () => {
122+
it('is a no-op when there are no marks', () => {
123+
setSearchMatchIndex(5);
124+
expect(searchCounterInfo()?.currentIndex).toBe(0);
125+
});
126+
127+
it('sets the current index within range', () => {
128+
mockMatchCount.value = 3;
129+
performSearch(baseOptions);
130+
setSearchMatchIndex(1);
131+
expect(searchCounterInfo()?.currentIndex).toBe(1);
132+
});
133+
134+
it('wraps index using modulo when out of range', () => {
135+
mockMatchCount.value = 3;
136+
performSearch(baseOptions);
137+
setSearchMatchIndex(5); // 5 % 3 = 2
138+
expect(searchCounterInfo()?.currentIndex).toBe(2);
139+
});
140+
141+
it('wraps to 0 when index equals mark count', () => {
142+
mockMatchCount.value = 3;
143+
performSearch(baseOptions);
144+
setSearchMatchIndex(3); // 3 % 3 = 0
145+
expect(searchCounterInfo()?.currentIndex).toBe(0);
146+
});
147+
});
148+
149+
describe('clearSearch', () => {
150+
it('resets mark count to 0', () => {
151+
mockMatchCount.value = 3;
152+
performSearch(baseOptions);
153+
clearSearch();
154+
expect(searchCounterInfo()?.numberOfItems).toBe(0);
155+
});
156+
157+
it('resets currentIndex to 0', () => {
158+
mockMatchCount.value = 3;
159+
performSearch(baseOptions);
160+
setSearchMatchIndex(2);
161+
clearSearch();
162+
expect(searchCounterInfo()?.currentIndex).toBe(0);
163+
});
164+
});

tsconfig.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"lib": ["es2019", "dom"],
77
"noImplicitAny": true,
88
"moduleResolution": "node",
9+
"ignoreDeprecations": "6.0",
910
"strictNullChecks": true,
1011
"importHelpers": true,
1112
"noEmit": true,

0 commit comments

Comments
 (0)