diff --git a/third_party/muya/src/editor/linkMouseEvents.ts b/third_party/muya/src/editor/linkMouseEvents.ts index 34f9b02..f47f36c 100644 --- a/third_party/muya/src/editor/linkMouseEvents.ts +++ b/third_party/muya/src/editor/linkMouseEvents.ts @@ -36,7 +36,7 @@ import { getLinkInfo } from '../utils/getLinkInfo'; // `mu-raw-html` is added to every inline HTML tag (``, ``, // ``, ``, `` …), so we can't match it loosely — narrow // each entry by the actual tag the renderer emits. -const LINK_SELECTOR = [ +export const LINK_SELECTOR = [ `span.${CLASS_NAMES.MU_LINK}`, `a.${CLASS_NAMES.MU_REFERENCE_LINK}`, `a.${CLASS_NAMES.MU_RAW_HTML}`, diff --git a/third_party/muya/src/selection/ImageSelection.ts b/third_party/muya/src/selection/ImageSelection.ts index cc13b50..afa1363 100644 --- a/third_party/muya/src/selection/ImageSelection.ts +++ b/third_party/muya/src/selection/ImageSelection.ts @@ -3,6 +3,7 @@ import type { Muya } from '../muya'; import type Selection from './index'; import type { IImageSelectionData } from './types'; import { BLOCK_DOM_PROPERTY, CLASS_NAMES } from '../config'; +import { LINK_SELECTOR } from '../editor/linkMouseEvents'; import { isHTMLElement, isKeyboardEvent } from '../utils'; import { getImageInfo, getImageSrc } from '../utils/image'; import { findContentDOM } from './dom'; @@ -102,7 +103,15 @@ class ImageSelection { } if (isHTMLElement(target) && target.tagName === 'IMG') { - if (event instanceof MouseEvent && (event.metaKey || event.ctrlKey)) { + // A linked image (e.g. `[![alt](src)](href)`) renders its image + // wrapper inside a link element. On modifier-click the link handler + // opens the URL; don't also emit the image preview, which would pop + // a viewer over the navigation (#3835). + if ( + event instanceof MouseEvent + && (event.metaKey || event.ctrlKey) + && !imageWrapper.closest(LINK_SELECTOR) + ) { const tokenSrc = imageInfo.token.src || imageInfo.token.attrs.src || ''; const src = getImageSrc(tokenSrc).src || target.getAttribute('src') || ''; if (src) { diff --git a/third_party/muya/src/selection/__tests__/linkedImageClick.spec.ts b/third_party/muya/src/selection/__tests__/linkedImageClick.spec.ts new file mode 100644 index 0000000..af52e20 --- /dev/null +++ b/third_party/muya/src/selection/__tests__/linkedImageClick.spec.ts @@ -0,0 +1,85 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { CLASS_NAMES } from '../../config'; +import { Muya } from '../../muya'; + +const bootedMuyas: Muya[] = []; + +beforeEach(() => { + window.MUYA_VERSION = 'test'; +}); + +afterEach(() => { + while (bootedMuyas.length) + bootedMuyas.pop()!.destroy(); + delete (window as Partial).MUYA_VERSION; +}); + +function boot(markdown: string): Muya { + const host = document.createElement('div'); + document.body.appendChild(host); + const muya = new Muya(host, { markdown } as ConstructorParameters[1]); + muya.init(); + bootedMuyas.push(muya); + return muya; +} + +function injectImg(muya: Muya, src: string): HTMLImageElement { + const wrapper = muya.domNode.querySelector( + `span.${CLASS_NAMES.MU_INLINE_IMAGE}`, + )!; + const container = wrapper.querySelector( + `.${CLASS_NAMES.MU_IMAGE_CONTAINER}`, + )!; + const img = document.createElement('img'); + img.setAttribute('src', src); + container.appendChild(img); + return img; +} + +function ctrlClick(img: HTMLImageElement): void { + img.dispatchEvent( + new MouseEvent('click', { bubbles: true, cancelable: true, ctrlKey: true }), + ); +} + +function captureFormatClickTypes(muya: Muya): string[] { + const types: string[] = []; + muya.eventCenter.on('format-click', (payload: { formatType?: string }) => { + if (payload && payload.formatType) + types.push(payload.formatType); + }); + return types; +} + +describe('linked image modifier-click does not pop the image preview (#3835)', () => { + it('a linked image does not emit an image format-click on Ctrl-click', () => { + const src = 'https://example.com/pic.png'; + const muya = boot(`[![alt](${src})](https://link.example.com)`); + + const wrapper = muya.domNode.querySelector( + `span.${CLASS_NAMES.MU_INLINE_IMAGE}`, + )!; + expect(wrapper.closest(`.${CLASS_NAMES.MU_LINK}`)).not.toBeNull(); + + const img = injectImg(muya, src); + const types = captureFormatClickTypes(muya); + + ctrlClick(img); + + expect(types).not.toContain('image'); + }); + + it('a plain image still emits an image format-click on Ctrl-click', () => { + const src = 'https://example.com/pic.png'; + const muya = boot(`![alt](${src})`); + + const img = injectImg(muya, src); + const types = captureFormatClickTypes(muya); + + ctrlClick(img); + + expect(types).toContain('image'); + }); +}); diff --git a/third_party/muya/src/utils/prism/__tests__/loadLanguageDependencyOrder.spec.ts b/third_party/muya/src/utils/prism/__tests__/loadLanguageDependencyOrder.spec.ts new file mode 100644 index 0000000..3a11563 --- /dev/null +++ b/third_party/muya/src/utils/prism/__tests__/loadLanguageDependencyOrder.spec.ts @@ -0,0 +1,37 @@ +// @vitest-environment happy-dom + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import prism, { loadLanguage } from '../index'; +import { loadedLanguages } from '../loadLanguage'; + +function resetCppState() { + for (const lang of ['c', 'cpp']) { + delete (prism.languages as Record)[lang]; + loadedLanguages.delete(lang); + } +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('loadLanguage dependency order', () => { + it('loads a dependency before the dependent grammar that extends it', async () => { + resetCppState(); + + const addOrder: string[] = []; + const realAdd = loadedLanguages.add.bind(loadedLanguages); + vi.spyOn(loadedLanguages, 'add').mockImplementation((lang: string) => { + addOrder.push(lang); + return realAdd(lang); + }); + + await expect(loadLanguage('cpp')).resolves.toBeDefined(); + + expect(addOrder).toContain('c'); + expect(addOrder).toContain('cpp'); + expect(addOrder.indexOf('c')).toBeLessThan(addOrder.indexOf('cpp')); + expect(prism.languages.cpp).toBeTruthy(); + expect(() => prism.tokenize('int main(){}', prism.languages.cpp)).not.toThrow(); + }); +}); diff --git a/third_party/muya/src/utils/prism/loadLanguage.ts b/third_party/muya/src/utils/prism/loadLanguage.ts index bfcbc59..38255df 100644 --- a/third_party/muya/src/utils/prism/loadLanguage.ts +++ b/third_party/muya/src/utils/prism/loadLanguage.ts @@ -1,6 +1,5 @@ import components from 'prismjs/components.js'; import getLoader from 'prismjs/dependencies'; -import { getDefer } from '../index'; interface ILangLoadStatus { lang: string; @@ -20,10 +19,16 @@ export const loadedLanguages = new Set([ const { languages } = components; const languageModules = import.meta.glob([ - '../../../../../prismjs/components/prism-*.js', - '!../../../../../prismjs/components/prism-*.min.js', + '../../../node_modules/prismjs/components/prism-*.js', + '!../../../node_modules/prismjs/components/prism-*.min.js', ]) as Record Promise>; +function getLanguageModule(lang: string) { + const modulePath = `../../../node_modules/prismjs/components/prism-${lang}.js`; + return languageModules[modulePath] + ?? Object.entries(languageModules).find(([path]) => path.endsWith(`/prism-${lang}.js`))?.[1]; +} + // Look for the origin language by alias export function transformAliasToOrigin(langs: string[]) { const result = []; @@ -81,43 +86,38 @@ function initLoadLanguage(Prism: IPrismLike) { if (!Array.isArray(langs)) langs = [langs]; - const promises: Promise[] = []; + const statuses: ILangLoadStatus[] = []; // The user might have loaded languages via some other way or used `prism.js` which already includes some // We don't need to validate the ids because `getLoader` will ignore invalid ones const loaded = [...loadedLanguages, ...Object.keys(Prism.languages)]; - getLoader(components, langs, loaded).load(async (lang: string) => { - const defer = getDefer(); - promises.push(defer.promise); + const loadComponent = async (lang: string): Promise => { if (!(lang in components.languages)) { - defer.resolve({ - lang, - status: 'noexist', - }); + statuses.push({ lang, status: 'noexist' }); + return; } - else if (loadedLanguages.has(lang)) { - defer.resolve({ - lang, - status: 'cached', - }); + if (loadedLanguages.has(lang)) { + statuses.push({ lang, status: 'cached' }); + return; } - else { - delete Prism.languages[lang]; - const modulePath = `../../../../../prismjs/components/prism-${lang}.js`; - const loadLanguageModule = languageModules[modulePath]; - if (!loadLanguageModule) { - throw new Error(`Prism language module not found: ${lang}`); - } - await loadLanguageModule(); - defer.resolve({ - lang, - status: 'loaded', - }); - loadedLanguages.add(lang); + delete Prism.languages[lang]; + const loadLanguageModule = getLanguageModule(lang); + if (!loadLanguageModule) { + throw new Error(`Prism language module not found: ${lang}`); } + + await loadLanguageModule(); + loadedLanguages.add(lang); + statuses.push({ lang, status: 'loaded' }); + }; + + await getLoader(components, langs, loaded).load(loadComponent, { + series: (before: Promise, after: () => Promise) => + before.then(after), + parallel: (values: Promise[]) => Promise.all(values), }); - return Promise.all(promises); + return statuses; }; }