diff --git a/main.ts b/main.ts index 2fb9fc2..4406f03 100644 --- a/main.ts +++ b/main.ts @@ -27,6 +27,13 @@ import { imageHoverPreview, keyboardShortcut, updateBehavior } from './src/setti import { localized } from './src/strings'; import { macOSTahoe } from './src/utils'; +import { + performSearch, + setSearchMatchIndex, + clearSearch, + searchCounterInfo, +} from './src/search'; + if (window.__markeditPreviewInitialized__) { console.error('MarkEdit Preview has already been initialized. Multiple initializations may cause unexpected behavior.'); } else { @@ -45,6 +52,14 @@ if (window.__markeditPreviewInitialized__) { // Allow other extensions or scripts to generate the HTML window.MarkEditGetHtml ??= generateStaticHtml; +// Expose bridge API for CoreEditor to call functions in the preview +window.__markeditPreviewSPI__ = { + performSearch, + setSearchMatchIndex, + clearSearch, + searchCounterInfo, +}; + MarkEdit.addMainMenuItem({ title: localized('viewMode'), icon: macOSTahoe() ? 'eye' : undefined, diff --git a/package.json b/package.json index 8ba8cbb..cd57626 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@typescript-eslint/parser": "^8.32.1", "cross-env": "^7.0.3", "eslint": "^9.27.0", + "happy-dom": "^20.8.9", "markedit-api": "https://github.com/MarkEdit-app/MarkEdit-api#v0.21.0", "markedit-vite": "https://github.com/MarkEdit-app/MarkEdit-vite#v0.4.0", "typescript": "^5.0.0", @@ -33,6 +34,7 @@ }, "dependencies": { "katex": "^0.16.40", + "mark.js": "^8.11.1", "markdown-it": "^14.1.1", "markdown-it-anchor": "^9.2.0", "markdown-it-footnote": "^4.0.0", diff --git a/src/search.ts b/src/search.ts new file mode 100644 index 0000000..5d09b03 --- /dev/null +++ b/src/search.ts @@ -0,0 +1,179 @@ +import Mark from 'mark.js'; +import { currentViewMode, getPreviewPane, ViewMode } from './view'; +import { themeName } from './settings'; + +const MARK_MATCH_CLASS = 'markedit-preview-mark'; +const MARK_HIGHLIGHTED_CLASS = 'markedit-preview-mark-highlighted'; + +let isApplying = false; +let currentOptions: SearchOptions | undefined; +let currentIndex = 0; +let markElements: HTMLElement[] = []; +let contentObserver: MutationObserver | null = null; +let markStyleSheet: HTMLStyleElement | null = null; + +// Mirrors CoreEditor's EditorColors.searchMatch, keyed by the plugin's themeName setting. +const searchMatchColors: Record = { + 'github': { light: '#fae17d7f', dark: '#f2cc607f' }, + 'cobalt': { light: '#cad40f66', dark: '#cad40f66' }, + 'dracula': { light: '#ffffff40', dark: '#ffffff40' }, + 'minimal': { light: '#fae17d7f', dark: '#f2cc607f' }, + 'night-owl': { light: '#5f7e9779', dark: '#5f7e9779' }, + 'rose-pine': { light: '#6e6a864c', dark: '#6e6a8666' }, + 'solarized': { light: '#f4c09d', dark: '#584032' }, + 'synthwave84': { light: '#d18616bb', dark: '#d18616bb' }, + 'winter-is-coming': { light: '#cee1f0', dark: '#103362' }, + 'xcode': { light: '#e4e4e4', dark: '#545558' }, +}; + +export interface SearchOptions { + search: string; + caseSensitive: boolean; + diacriticInsensitive: boolean; + wholeWord: boolean; + regexp: boolean; +} + +export interface SearchCounterInfo { + numberOfItems: number; + currentIndex: number; +} + +export function performSearch(options: SearchOptions) { + currentOptions = options; + currentIndex = 0; + + if (options.search.length === 0) { + clearSearch(); + return; + } + + const container = getPreviewPane(); + remarkWithNewOptions(container); + observeContentChanges(container); +} + +export function setSearchMatchIndex(index: number) { + if (markElements.length === 0) { + return; + } + + // The editor may have more matches than the rendered preview (e.g. inside code fences); + // use modulo to stay in range rather than clamping to the last element. + currentIndex = index % markElements.length; + highlightCurrent(); +} + +export function clearSearch() { + contentObserver?.disconnect(); + contentObserver = null; + currentOptions = undefined; + currentIndex = 0; + markElements = []; + new Mark(getPreviewPane()).unmark(); +} + +// Returns undefined outside overlay mode so the editor's own counter is used instead. +export function searchCounterInfo(): SearchCounterInfo | undefined { + if (currentViewMode() !== ViewMode.preview) { + return undefined; + } + + return { numberOfItems: markElements.length, currentIndex }; +} + +function remarkWithNewOptions(container: HTMLElement) { + const options = currentOptions; + if (options === undefined || options.search.length === 0) { + return; + } + + if (isApplying) { + return; + } + + updateStyles(); + isApplying = true; + + const { search, caseSensitive, wholeWord, diacriticInsensitive, regexp } = options; + const marker = new Mark(container); + + const onComplete = () => { + markElements = Array.from(container.querySelectorAll(`.${MARK_MATCH_CLASS}`)); + currentIndex = markElements.length > 0 ? Math.min(currentIndex, markElements.length - 1) : 0; + highlightCurrent(); + isApplying = false; + }; + + marker.unmark({ + done: () => { + if (regexp) { + try { + const flags = caseSensitive ? '' : 'i'; + marker.markRegExp(new RegExp(search, flags), { + className: MARK_MATCH_CLASS, + done: onComplete, + }); + } catch { + isApplying = false; + currentIndex = 0; + markElements = []; + } + } else { + marker.mark(search, { + className: MARK_MATCH_CLASS, + caseSensitive, + diacritics: diacriticInsensitive, + separateWordSearch: false, + accuracy: wholeWord ? 'exactly' : 'partially', + done: onComplete, + }); + } + }, + }); +} + +// Show the current-match indicator only when not in side-by-side mode, where +// the editor's own highlight is already visible and indices may not correspond. +function highlightCurrent() { + const shouldHighlight = currentViewMode() !== ViewMode.sideBySide; + markElements.forEach((mark, index) => { + mark.classList.toggle(MARK_HIGHLIGHTED_CLASS, shouldHighlight && index === currentIndex); + }); + + if (shouldHighlight && markElements.length > 0) { + markElements[currentIndex].scrollIntoView({ behavior: 'smooth', block: 'center' }); + } +} + +function observeContentChanges(container: HTMLElement) { + contentObserver?.disconnect(); + + // Observe only direct children — fires on preview re-renders (innerHTML + // replacement) but not on mark.js changes inside nested block elements. + contentObserver = new MutationObserver(() => { + if (!isApplying) { + remarkWithNewOptions(container); + } + }); + + contentObserver.observe(container, { childList: true }); +} + +// Mirrors .cm-searchMatch / .cm-searchMatch-selected from CoreEditor's builder.ts. +// Light/dark colors follow the preview's own themeName, not the editor's active theme. +function updateStyles() { + if (markStyleSheet === null) { + markStyleSheet = document.createElement('style'); + document.head.appendChild(markStyleSheet); + } + + const { light, dark } = searchMatchColors[themeName] ?? searchMatchColors['github']; + markStyleSheet.textContent = [ + `.${MARK_MATCH_CLASS} { background: ${light} !important; color: inherit !important; }`, + `.${MARK_HIGHLIGHTED_CLASS} { background: #ffff00 !important; color: #000000 !important; box-shadow: 0px 0px 2px 1px rgba(0, 0, 0, 0.2); }`, + '@media (prefers-color-scheme: dark) {', + ` .${MARK_MATCH_CLASS} { background: ${dark} !important; }`, + '}', + ].join('\n'); +} diff --git a/tests/search.test.ts b/tests/search.test.ts new file mode 100644 index 0000000..58b8856 --- /dev/null +++ b/tests/search.test.ts @@ -0,0 +1,164 @@ +// @vitest-environment happy-dom +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const mockMatchCount = vi.hoisted(() => ({ value: 1 })); +const mockViewState = vi.hoisted(() => ({ + mode: 'preview' as string, + pane: null as HTMLElement | null, +})); + +vi.mock('mark.js', () => ({ + default: class MockMark { + private container: Element; + constructor(container: Element) { this.container = container; } + + mark(_keyword: string, options?: { className?: string; done?: (n: number) => void }) { + for (let i = 0; i < mockMatchCount.value; i++) { + const el = document.createElement('mark'); + el.className = options?.className ?? ''; + this.container.appendChild(el); + } + options?.done?.(mockMatchCount.value); + } + + markRegExp(_re: RegExp, options?: { className?: string; done?: (n: number) => void }) { + for (let i = 0; i < mockMatchCount.value; i++) { + const el = document.createElement('mark'); + el.className = options?.className ?? ''; + this.container.appendChild(el); + } + options?.done?.(mockMatchCount.value); + } + + unmark(options?: { done?: () => void }) { + this.container.querySelectorAll('mark').forEach(el => el.remove()); + options?.done?.(); + } + }, +})); + +vi.mock('../src/settings', () => ({ themeName: 'github' })); + +vi.mock('../src/view', () => ({ + ViewMode: { edit: 'edit', sideBySide: 'side-by-side', preview: 'preview' }, + currentViewMode: vi.fn(() => mockViewState.mode), + getPreviewPane: vi.fn(() => mockViewState.pane), +})); + +import { performSearch, setSearchMatchIndex, clearSearch, searchCounterInfo } from '../src/search'; + +const baseOptions = { + search: 'hello', + caseSensitive: false, + diacriticInsensitive: false, + wholeWord: false, + regexp: false, +}; + +beforeEach(() => { + mockViewState.pane = document.createElement('div'); + document.body.appendChild(mockViewState.pane); + mockViewState.mode = 'preview'; + mockMatchCount.value = 1; + clearSearch(); +}); + +afterEach(() => { + document.body.innerHTML = ''; + mockViewState.pane = null; +}); + +describe('searchCounterInfo', () => { + it('returns undefined in edit mode', () => { + mockViewState.mode = 'edit'; + expect(searchCounterInfo()).toBeUndefined(); + }); + + it('returns undefined in side-by-side mode', () => { + mockViewState.mode = 'side-by-side'; + expect(searchCounterInfo()).toBeUndefined(); + }); + + it('returns zero counter in preview mode with no active search', () => { + expect(searchCounterInfo()).toEqual({ numberOfItems: 0, currentIndex: 0 }); + }); + + it('reflects mark count after search', () => { + mockMatchCount.value = 3; + performSearch(baseOptions); + expect(searchCounterInfo()).toEqual({ numberOfItems: 3, currentIndex: 0 }); + }); +}); + +describe('performSearch', () => { + it('clears marks when query is empty', () => { + mockMatchCount.value = 2; + performSearch(baseOptions); + performSearch({ ...baseOptions, search: '' }); + expect(searchCounterInfo()?.numberOfItems).toBe(0); + }); + + it('resets currentIndex to 0 on new search', () => { + mockMatchCount.value = 3; + performSearch(baseOptions); + setSearchMatchIndex(2); + performSearch({ ...baseOptions, search: 'world' }); + expect(searchCounterInfo()?.currentIndex).toBe(0); + }); + + it('handles regexp queries', () => { + performSearch({ ...baseOptions, regexp: true }); + expect(searchCounterInfo()?.numberOfItems).toBe(1); + }); + + it('handles invalid regexp without throwing', () => { + expect(() => { + performSearch({ ...baseOptions, regexp: true, search: '[invalid' }); + }).not.toThrow(); + }); +}); + +describe('setSearchMatchIndex', () => { + it('is a no-op when there are no marks', () => { + setSearchMatchIndex(5); + expect(searchCounterInfo()?.currentIndex).toBe(0); + }); + + it('sets the current index within range', () => { + mockMatchCount.value = 3; + performSearch(baseOptions); + setSearchMatchIndex(1); + expect(searchCounterInfo()?.currentIndex).toBe(1); + }); + + it('wraps index using modulo when out of range', () => { + mockMatchCount.value = 3; + performSearch(baseOptions); + setSearchMatchIndex(5); // 5 % 3 = 2 + expect(searchCounterInfo()?.currentIndex).toBe(2); + }); + + it('wraps to 0 when index equals mark count', () => { + mockMatchCount.value = 3; + performSearch(baseOptions); + setSearchMatchIndex(3); // 3 % 3 = 0 + expect(searchCounterInfo()?.currentIndex).toBe(0); + }); +}); + +describe('clearSearch', () => { + it('resets mark count to 0', () => { + mockMatchCount.value = 3; + performSearch(baseOptions); + clearSearch(); + expect(searchCounterInfo()?.numberOfItems).toBe(0); + }); + + it('resets currentIndex to 0', () => { + mockMatchCount.value = 3; + performSearch(baseOptions); + setSearchMatchIndex(2); + clearSearch(); + expect(searchCounterInfo()?.currentIndex).toBe(0); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 61e9624..3ece713 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,7 @@ "lib": ["es2019", "dom"], "noImplicitAny": true, "moduleResolution": "node", + "ignoreDeprecations": "6.0", "strictNullChecks": true, "importHelpers": true, "noEmit": true, diff --git a/types/global.d.ts b/types/global.d.ts index e8f3d1f..374c23b 100644 --- a/types/global.d.ts +++ b/types/global.d.ts @@ -1,6 +1,14 @@ +import type { SearchOptions, SearchCounterInfo } from '../src/search'; + declare global { interface Window { __markeditPreviewInitialized__: boolean; + __markeditPreviewSPI__?: { + performSearch(options: SearchOptions): void; + setSearchMatchIndex(index: number): void; + clearSearch(): void; + searchCounterInfo(): SearchCounterInfo | undefined; + }; MarkEditGetHtml: (styled: boolean) => Promise; } } diff --git a/types/markjs.d.ts b/types/markjs.d.ts new file mode 100644 index 0000000..7e552d4 --- /dev/null +++ b/types/markjs.d.ts @@ -0,0 +1,24 @@ +// Partial type declarations for mark.js (https://github.com/julmot/mark.js). +declare module 'mark.js' { + export default class Mark { + constructor(context: Element | string); + + mark(keyword: string, options?: { + className?: string; + caseSensitive?: boolean; + separateWordSearch?: boolean; + accuracy?: 'partially' | 'complementary' | 'exactly'; + diacritics?: boolean; + done?: (totalMatches: number) => void; + }): void; + + markRegExp(regexp: RegExp, options?: { + className?: string; + done?: (totalMatches: number) => void; + }): void; + + unmark(options?: { + done?: () => void; + }): void; + } +} diff --git a/yarn.lock b/yarn.lock index b4e9335..ea1a845 100644 --- a/yarn.lock +++ b/yarn.lock @@ -755,6 +755,13 @@ resolved "https://registry.yarnpkg.com/@types/mdurl/-/mdurl-2.0.0.tgz#d43878b5b20222682163ae6f897b20447233bdfd" integrity sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg== +"@types/node@*", "@types/node@>=20.0.0": + version "25.5.0" + resolved "https://registry.yarnpkg.com/@types/node/-/node-25.5.0.tgz#5c99f37c443d9ccc4985866913f1ed364217da31" + integrity sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw== + dependencies: + undici-types "~7.18.0" + "@types/node@^22.0.0": version "22.19.15" resolved "https://registry.yarnpkg.com/@types/node/-/node-22.19.15.tgz#6091d99fdf7c08cb57dc8b1345d407ba9a1df576" @@ -767,6 +774,18 @@ resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11" integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw== +"@types/whatwg-mimetype@^3.0.2": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz#e5e06dcd3e92d4e622ef0129637707d66c28d6a4" + integrity sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA== + +"@types/ws@^8.18.1": + version "8.18.1" + resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.18.1.tgz#48464e4bf2ddfd17db13d845467f6070ffea4aa9" + integrity sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg== + dependencies: + "@types/node" "*" + "@typescript-eslint/eslint-plugin@^8.32.1": version "8.56.1" resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz#b1ce606d87221daec571e293009675992f0aae76" @@ -1436,6 +1455,11 @@ entities@^4.4.0: resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== +entities@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/entities/-/entities-7.0.1.tgz#26e8a88889db63417dcb9a1e79a3f1bc92b5976b" + integrity sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA== + es-module-lexer@^1.7.0: version "1.7.0" resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz#9159601561880a85f2734560a9099b2c31e5372a" @@ -1663,6 +1687,18 @@ hachure-fill@^0.5.2: resolved "https://registry.yarnpkg.com/hachure-fill/-/hachure-fill-0.5.2.tgz#d19bc4cc8750a5962b47fb1300557a85fcf934cc" integrity sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg== +happy-dom@^20.8.9: + version "20.8.9" + resolved "https://registry.yarnpkg.com/happy-dom/-/happy-dom-20.8.9.tgz#aeacc9ab9aacada379bce0ba37b2f02dabbba286" + integrity sha512-Tz23LR9T9jOGVZm2x1EPdXqwA37G/owYMxRwU0E4miurAtFsPMQ1d2Jc2okUaSjZqAFz2oEn3FLXC5a0a+siyA== + dependencies: + "@types/node" ">=20.0.0" + "@types/whatwg-mimetype" "^3.0.2" + "@types/ws" "^8.18.1" + entities "^7.0.1" + whatwg-mimetype "^3.0.0" + ws "^8.18.3" + has-flag@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" @@ -1843,6 +1879,11 @@ magic-string@^0.30.21: dependencies: "@jridgewell/sourcemap-codec" "^1.5.5" +mark.js@^8.11.1: + version "8.11.1" + resolved "https://registry.yarnpkg.com/mark.js/-/mark.js-8.11.1.tgz#180f1f9ebef8b0e638e4166ad52db879beb2ffc5" + integrity sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ== + markdown-it-anchor@^9.2.0: version "9.2.0" resolved "https://registry.yarnpkg.com/markdown-it-anchor/-/markdown-it-anchor-9.2.0.tgz#89375d9a2a79336403ab7c4fd36b1965cc45e5c8" @@ -2309,6 +2350,11 @@ undici-types@~6.21.0: resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb" integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ== +undici-types@~7.18.0: + version "7.18.2" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.18.2.tgz#29357a89e7b7ca4aef3bf0fd3fd0cd73884229e9" + integrity sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w== + uri-js@^4.2.2: version "4.4.1" resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" @@ -2408,6 +2454,11 @@ w3c-keyname@^2.2.4: resolved "https://registry.yarnpkg.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz#7b17c8c6883d4e8b86ac8aba79d39e880f8869c5" integrity sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ== +whatwg-mimetype@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz#5fa1a7623867ff1af6ca3dc72ad6b8a4208beba7" + integrity sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q== + which@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" @@ -2428,6 +2479,11 @@ word-wrap@^1.2.5: resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== +ws@^8.18.3: + version "8.20.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.20.0.tgz#4cd9532358eba60bc863aad1623dfb045a4d4af8" + integrity sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA== + yaml@^2.8.3: version "2.8.3" resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.8.3.tgz#a0d6bd2efb3dd03c59370223701834e60409bd7d"