Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion third_party/muya/src/editor/linkMouseEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import { getLinkInfo } from '../utils/getLinkInfo';
// `mu-raw-html` is added to every inline HTML tag (`<u>`, `<mark>`,
// `<sub>`, `<sup>`, `<a>` …), 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}`,
Expand Down
11 changes: 10 additions & 1 deletion third_party/muya/src/selection/ImageSelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand Down
85 changes: 85 additions & 0 deletions third_party/muya/src/selection/__tests__/linkedImageClick.spec.ts
Original file line number Diff line number Diff line change
@@ -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<Window>).MUYA_VERSION;
});

function boot(markdown: string): Muya {
const host = document.createElement('div');
document.body.appendChild(host);
const muya = new Muya(host, { markdown } as ConstructorParameters<typeof Muya>[1]);
muya.init();
bootedMuyas.push(muya);
return muya;
}

function injectImg(muya: Muya, src: string): HTMLImageElement {
const wrapper = muya.domNode.querySelector<HTMLElement>(
`span.${CLASS_NAMES.MU_INLINE_IMAGE}`,
)!;
const container = wrapper.querySelector<HTMLElement>(
`.${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<HTMLElement>(
`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');
});
});
Original file line number Diff line number Diff line change
@@ -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<string, unknown>)[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();
});
});
60 changes: 30 additions & 30 deletions third_party/muya/src/utils/prism/loadLanguage.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import components from 'prismjs/components.js';
import getLoader from 'prismjs/dependencies';
import { getDefer } from '../index';

interface ILangLoadStatus {
lang: string;
Expand All @@ -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<string, () => Promise<unknown>>;

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 = [];
Expand Down Expand Up @@ -81,43 +86,38 @@ function initLoadLanguage(Prism: IPrismLike) {
if (!Array.isArray(langs))
langs = [langs];

const promises: Promise<ILangLoadStatus>[] = [];
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<ILangLoadStatus>();
promises.push(defer.promise);
const loadComponent = async (lang: string): Promise<void> => {
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<void>, after: () => Promise<void>) =>
before.then(after),
parallel: (values: Promise<void>[]) => Promise.all(values),
});

return Promise.all(promises);
return statuses;
};
}

Expand Down
Loading