diff --git a/packages/app-cli/tests/MdToHtml.ts b/packages/app-cli/tests/MdToHtml.ts index a5571bb89..1c2615bf6 100644 --- a/packages/app-cli/tests/MdToHtml.ts +++ b/packages/app-cli/tests/MdToHtml.ts @@ -1,4 +1,4 @@ -import MdToHtml from '@joplin/renderer/MdToHtml'; +import MdToHtml, { LinkRenderingType } from '@joplin/renderer/MdToHtml'; const { filename } = require('@joplin/lib/path-utils'); import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils'; import shim from '@joplin/lib/shim'; @@ -218,6 +218,9 @@ describe('MdToHtml', () => { const mdToHtmlLinkifyOn = newTestMdToHtml({ pluginOptions: { linkify: { enabled: true }, + link_open: { + linkRenderingType: LinkRenderingType.HrefHandler, + }, }, }); @@ -227,29 +230,52 @@ describe('MdToHtml', () => { }, }); + const renderOptions = { + bodyOnly: true, + plainResourceRendering: true, + linkRenderingType: LinkRenderingType.HrefHandler, + }; + for (const testCase of testCases) { const [input, expectedLinkifyOff, expectedLinkifyOn] = testCase; { - const actual = await mdToHtmlLinkifyOn.render(input, null, { - bodyOnly: true, - plainResourceRendering: true, - }); + const actual = await mdToHtmlLinkifyOn.render(input, null, renderOptions); expect(actual.html).toBe(expectedLinkifyOn); } { - const actual = await mdToHtmlLinkifyOff.render(input, null, { - bodyOnly: true, - plainResourceRendering: true, - }); + const actual = await mdToHtmlLinkifyOff.render(input, null, renderOptions); expect(actual.html).toBe(expectedLinkifyOff); } } })); + it.each([ + '[test](http://example.com/)', + '[test](mailto:test@example.com)', + ])('should add onclick handlers to links when linkRenderingType is JavaScriptHandler (%j)', async (markdown) => { + const mdToHtml = newTestMdToHtml(); + + const renderWithoutOnClickOptions = { + bodyOnly: true, + linkRenderingType: LinkRenderingType.HrefHandler, + }; + expect( + (await mdToHtml.render(markdown, undefined, renderWithoutOnClickOptions)).html, + ).not.toContain('onclick'); + + const renderWithOnClickOptions = { + bodyOnly: true, + linkRenderingType: LinkRenderingType.JavaScriptHandler, + }; + expect( + (await mdToHtml.render(markdown, undefined, renderWithOnClickOptions)).html, + ).toMatch(//); + }); + it('should return attributes of line numbers', (async () => { const mdToHtml = newTestMdToHtml(); diff --git a/packages/app-desktop/gui/NoteEditor/utils/markupRenderOptions.ts b/packages/app-desktop/gui/NoteEditor/utils/markupRenderOptions.ts index f3e39db64..16a47d094 100644 --- a/packages/app-desktop/gui/NoteEditor/utils/markupRenderOptions.ts +++ b/packages/app-desktop/gui/NoteEditor/utils/markupRenderOptions.ts @@ -1,3 +1,4 @@ +import { LinkRenderingType } from '@joplin/renderer/MdToHtml'; import { MarkupToHtmlOptions } from './types'; export default (override: MarkupToHtmlOptions = null): MarkupToHtmlOptions => { @@ -7,7 +8,7 @@ export default (override: MarkupToHtmlOptions = null): MarkupToHtmlOptions => { checkboxRenderingType: 2, }, link_open: { - linkRenderingType: 2, + linkRenderingType: LinkRenderingType.HrefHandler, }, }, replaceResourceInternalToExternalLinks: true, diff --git a/packages/app-desktop/integration-tests/markdownEditor.spec.ts b/packages/app-desktop/integration-tests/markdownEditor.spec.ts index 790dc6552..75cb53734 100644 --- a/packages/app-desktop/integration-tests/markdownEditor.spec.ts +++ b/packages/app-desktop/integration-tests/markdownEditor.spec.ts @@ -77,6 +77,24 @@ test.describe('markdownEditor', () => { await mainScreen.noteEditor.toggleEditorsButton.click(); await expectToBeRendered(); + + // Clicking on the PDF link should attempt to open it in a viewer + await expect(pdfLink).toBeVisible(); + + const nextOpenFilePromise = electronApp.evaluate(({ shell }) => { + return new Promise(resolve => { + const openPath = async (url: string) => { + resolve(url); + return ''; + }; + shell.openPath = openPath; + }); + }); + await pdfLink.click(); + expect(await nextOpenFilePromise).toMatch(/\.pdf$/); + + // Should not have rendered something else in the viewer frame + await expectToBeRendered(); }); test('preview pane should render video attachments', async ({ mainWindow, electronApp }) => { diff --git a/packages/app-mobile/commands/util/showResource.ts b/packages/app-mobile/commands/util/showResource.ts index cda52e0f4..b10cc3b95 100644 --- a/packages/app-mobile/commands/util/showResource.ts +++ b/packages/app-mobile/commands/util/showResource.ts @@ -14,7 +14,7 @@ const showResource = async (item: ResourceEntity) => { if (shim.mobilePlatform() === 'web') { const url = URL.createObjectURL(await shim.fsDriver().fileAtPath(resourcePath)); const w = window.open(url, '_blank'); - w.addEventListener('close', () => { + w?.addEventListener('close', () => { URL.revokeObjectURL(url); }, { once: true }); } else { diff --git a/packages/lib/services/interop/InteropService_Exporter_Html.ts b/packages/lib/services/interop/InteropService_Exporter_Html.ts index 8cb2b4fff..e8aa08e62 100644 --- a/packages/lib/services/interop/InteropService_Exporter_Html.ts +++ b/packages/lib/services/interop/InteropService_Exporter_Html.ts @@ -14,6 +14,7 @@ const { themeStyle } = require('../../theme'); const { escapeHtml } = require('../../string-utils.js'); import { assetsToHeaders } from '@joplin/renderer'; import getPluginSettingValue from '../plugins/utils/getPluginSettingValue'; +import { LinkRenderingType } from '@joplin/renderer/MdToHtml'; export default class InteropService_Exporter_Html extends InteropService_Exporter_Base { @@ -115,8 +116,14 @@ export default class InteropService_Exporter_Html extends InteropService_Exporte const bodyMd = await this.processNoteResources_(item); const result = await this.markupToHtml_.render(item.markup_language, bodyMd, this.style_, { resources: this.resources_, - plainResourceRendering: true, settingValue: getPluginSettingValue, + + plainResourceRendering: true, + plugins: { + link_open: { + linkRenderingType: LinkRenderingType.HrefHandler, + }, + }, }); const noteContent = []; if (item.title) noteContent.push(`
${escapeHtml(item.title)}
`); diff --git a/packages/renderer/MdToHtml.ts b/packages/renderer/MdToHtml.ts index 3170e2193..d069024fb 100644 --- a/packages/renderer/MdToHtml.ts +++ b/packages/renderer/MdToHtml.ts @@ -146,6 +146,14 @@ interface PluginContext { }; } +export enum LinkRenderingType { + // linkRenderingType = 1 is the regular rendering and clicking on it is handled via embedded JS (in onclick attribute) + JavaScriptHandler = 1, + + // linkRenderingType = 2 gives a plain link with no JS. Caller needs to handle clicking on the link. + HrefHandler = 2, +} + export interface RuleOptions { context: PluginContext; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied @@ -174,9 +182,7 @@ export interface RuleOptions { enableLongPress?: boolean; // Use by `link_open` rule. - // linkRenderingType = 1 is the regular rendering and clicking on it is handled via embedded JS (in onclick attribute) - // linkRenderingType = 2 gives a plain link with no JS. Caller needs to handle clicking on the link. - linkRenderingType?: number; + linkRenderingType?: LinkRenderingType; // A list of MIME types for which an edit button appears on tap/hover. // Used by the image editor in the mobile app. diff --git a/packages/renderer/MdToHtml/linkReplacement.ts b/packages/renderer/MdToHtml/linkReplacement.ts index b8f025a4f..ca48ee17e 100644 --- a/packages/renderer/MdToHtml/linkReplacement.ts +++ b/packages/renderer/MdToHtml/linkReplacement.ts @@ -1,3 +1,4 @@ +import { LinkRenderingType } from '../MdToHtml'; import { ItemIdToUrlHandler, OptionsResourceModel } from '../types'; import * as utils from '../utils'; import createEventHandlingAttrs from './createEventHandlingAttrs'; @@ -11,7 +12,7 @@ export interface Options { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied resources?: any; ResourceModel?: OptionsResourceModel; - linkRenderingType?: number; + linkRenderingType?: LinkRenderingType; plainResourceRendering?: boolean; postMessageSyntax?: string; enableLongPress?: boolean; @@ -27,16 +28,14 @@ export interface LinkReplacementResult { } export default function(href: string, options: Options = null): LinkReplacementResult { - options = { - title: '', - resources: {}, - ResourceModel: null, - linkRenderingType: 1, - plainResourceRendering: false, - postMessageSyntax: 'postMessage', - enableLongPress: false, - ...options, - }; + options = { ...options }; + options.title ??= ''; + options.resources ??= {}; + options.ResourceModel ??= null; + options.linkRenderingType ??= LinkRenderingType.JavaScriptHandler; + options.plainResourceRendering ??= false; + options.postMessageSyntax ??= 'postMessage'; + options.enableLongPress ??= false; const resourceHrefInfo = urlUtils.parseResourceUrl(href); const isResourceUrl = options.resources && !!resourceHrefInfo; @@ -129,12 +128,15 @@ export default function(href: string, options: Options = null): LinkReplacementR if (addedHrefAttr) { // Done -- the HREF has already bee set. - } else if (options.plainResourceRendering || options.linkRenderingType === 2) { + } else if (options.plainResourceRendering || options.linkRenderingType === LinkRenderingType.HrefHandler) { icon = ''; attrHtml.push(`href='${htmlentities(href)}'`); } else { attrHtml.push(`href='${htmlentities(hrefAttr)}'`); - if (js) attrHtml.push(js); + } + + if (js && options.linkRenderingType === LinkRenderingType.JavaScriptHandler) { + attrHtml.push(js); } return { diff --git a/packages/renderer/MdToHtml/rules/link_close.ts b/packages/renderer/MdToHtml/rules/link_close.ts index 0ca17e4cd..9bdc2ecb6 100644 --- a/packages/renderer/MdToHtml/rules/link_close.ts +++ b/packages/renderer/MdToHtml/rules/link_close.ts @@ -1,7 +1,7 @@ // This rule is used to add a media player for certain resource types below // the link. -import { RuleOptions } from '../../MdToHtml'; +import { LinkRenderingType, RuleOptions } from '../../MdToHtml'; import renderMedia, { Options as RenderMediaOptions } from '../renderMedia'; @@ -23,7 +23,7 @@ function plugin(markdownIt: any, ruleOptions: RuleOptions) { const defaultOutput = defaultRender(tokens, idx, options, env, self); const link = ruleOptions.context.currentLinks.pop(); - if (!link || ruleOptions.linkRenderingType === 2 || ruleOptions.plainResourceRendering) return defaultOutput; + if (!link || ruleOptions.linkRenderingType === LinkRenderingType.HrefHandler || ruleOptions.plainResourceRendering) return defaultOutput; return [defaultOutput, renderMedia(link, ruleOptions as RenderMediaOptions, linkIndexes)].join(''); };