diff --git a/helpers/HTMLView.js b/helpers/HTMLView.js index abe0e015a..29d3e1a5a 100644 --- a/helpers/HTMLView.js +++ b/helpers/HTMLView.js @@ -5,9 +5,7 @@ // Last updated 2025-05-31 by @jgclark // --------------------------------------------------------- import showdown from 'showdown' // for Markdown -> HTML from https://github.com/showdownjs/showdown -import { - hasFrontMatter -} from '@helpers/NPFrontMatter' +import { hasFrontMatter } from '@helpers/NPFrontMatter' import { getFolderFromFilename } from '@helpers/folders' import { clo, logDebug, logError, logInfo, logWarn, JSP, timer } from '@helpers/dev' import { getStoredWindowRect, isHTMLWindowOpen, storeWindowRect } from '@helpers/NPWindows' @@ -15,6 +13,7 @@ import { generateCSSFromTheme, RGBColourConvert } from '@helpers/NPThemeToCSS' import { isTermInEventLinkHiddenPart, isTermInNotelinkOrURI, isTermInMarkdownPath } from '@helpers/paragraph' import { RE_EVENT_LINK, RE_SYNC_MARKER, formRegExForUsersOpenTasks } from '@helpers/regex' import { getTimeBlockString, isTimeBlockLine } from '@helpers/timeblocks' +import { createOpenOrDeleteNoteCallbackUrl } from '@helpers/general' // --------------------------------------------------------- // Constants and Types @@ -101,7 +100,6 @@ export function getCallbackCodeString(jsFunctionName: string, commandName: strin ` } - /** * Convert a note's content to HTML and include any images as base64 * @param {string} content @@ -153,6 +151,26 @@ export async function getNoteContentAsHTML(content: string, note: TNote): Promis } } + // Ensure horizontal rules have a blank line before them (required by showdown) + // Track which HRs already had blank lines for extra padding + // HR patterns: ---, ___, *** (with optional spaces between) + const HR_REGEX = /^(\s*)([-_*])\s*\2\s*\2\s*$/ + const HR_MARKER = '' + for (let i = 1; i < lines.length; i++) { + if (lines[i].match(HR_REGEX)) { + if (lines[i - 1].trim() === '') { + // Already has blank line - mark it for extra padding + lines[i] = `${HR_MARKER}${lines[i]}` + } else { + // Insert blank line before HR if previous line isn't already blank + lines.splice(i, 0, '') + i++ // skip the newly inserted blank line + } + } + } + + lines = addParagraphBreakHints(lines) + // Make this proper Markdown -> HTML via showdown library // Set some options to turn on various more advanced HTML conversions (see actual code at https://github.com/showdownjs/showdown/blob/master/src/options.js#L109): const converterOptions = { @@ -164,22 +182,58 @@ export async function getNoteContentAsHTML(content: string, note: TNote): Promis tasklists: true, metadata: false, // otherwise metadata is swallowed requireSpaceBeforeHeadingText: true, - simpleLineBreaks: true // Makes this GFM style. TODO: make an option? + simpleLineBreaks: true, // Makes this GFM style. TODO: make an option? } const converter = new showdown.Converter(converterOptions) let body = converter.makeHtml(lines.join(`\n`)) - body = `${body}` // fix for bug in showdown - + + // Add CSS for proper spacing and layout + const inlineStyles = `` + body = inlineStyles + body + + // Replace markers for HRs that had blank lines with classed HRs + body = body.replace(/
/g, '
') + const imgTagRegex = /' } } - /** * This function creates the webkit console.log/error handler for HTML messages to get back to NP console.log * @returns {string} - the javascript (without a tag) @@ -566,7 +612,10 @@ export async function showHTMLV2(body: string, opts: HtmlWindowOptions): Promise try { const screenWidth = NotePlan.environment.screenWidth const screenHeight = NotePlan.environment.screenHeight - logDebug('HTMLView / showHTMLV2', `starting with customId ${opts.customId ?? ''} and reuseUsersWindowRect ${String(opts.reuseUsersWindowRect) ?? '??'} for screen dimensions ${screenWidth}x${screenHeight}`) + logDebug( + 'HTMLView / showHTMLV2', + `starting with customId ${opts.customId ?? ''} and reuseUsersWindowRect ${String(opts.reuseUsersWindowRect) ?? '??'} for screen dimensions ${screenWidth}x${screenHeight}`, + ) // Assemble the parts of the HTML into a single string const fullHTMLStr = assembleHTMLParts(body, opts) @@ -592,8 +641,8 @@ export async function showHTMLV2(body: string, opts: HtmlWindowOptions): Promise winOptions = { x: opts.x ?? (screenWidth - (screenWidth - (opts.paddingWidth ?? 0) * 2)) / 2, y: opts.y ?? (screenHeight - (screenHeight - (opts.paddingHeight ?? 0) * 2)) / 2, - width: opts.width ?? (screenWidth - (opts.paddingWidth ?? 0) * 2), - height: opts.height ?? (screenHeight - (opts.paddingHeight ?? 0) * 2), + width: opts.width ?? screenWidth - (opts.paddingWidth ?? 0) * 2, + height: opts.height ?? screenHeight - (opts.paddingHeight ?? 0) * 2, shouldFocus: opts.shouldFocus, id: cId, // don't need both ... but trying to work out which is the current one for the API windowId: cId, @@ -603,7 +652,6 @@ export async function showHTMLV2(body: string, opts: HtmlWindowOptions): Promise // logDebug('showHTMLV2', `- Trying to use user's saved Rect from pref for ${cId}`) const storedRect = getStoredWindowRect(cId) if (storedRect) { - winOptions = { x: storedRect.x, y: storedRect.y, @@ -958,18 +1006,18 @@ export function convertBoldAndItalicToHTML(input: string): string { // of the form `![📅](2023-01-13 18:00:::F9766457-9C4E-49C8-BC45-D8D821280889:::NA:::Contact X about Y:::#63DA38)` export function simplifyNPEventLinksForHTML(input: string): string { try { - let output = input - const captures = output.match(RE_EVENT_LINK) - if (captures) { - clo(captures, 'results from NP event link matches:') - // Matches come in threes (plus full match), so process four at a time - for (let c = 0; c < captures.length; c = c + 3) { - const eventLink = captures[c] - const eventTitle = captures[c + 1] - const eventColor = captures[c + 2] - output = output.replace(eventLink, ` ${eventTitle}`) + let output = input + const captures = output.match(RE_EVENT_LINK) + if (captures) { + clo(captures, 'results from NP event link matches:') + // Matches come in threes (plus full match), so process four at a time + for (let c = 0; c < captures.length; c = c + 3) { + const eventLink = captures[c] + const eventTitle = captures[c + 1] + const eventColor = captures[c + 2] + output = output.replace(eventLink, ` ${eventTitle}`) + } } - } // logDebug('simplifyNPEventLinksForHTML', `{${input}} -> {${output}}`) return output } catch (error) { @@ -982,16 +1030,16 @@ export function simplifyNPEventLinksForHTML(input: string): string { // (This also helps remove false positives for ! priority indicator) export function simplifyInlineImagesForHTML(input: string): string { try { - let output = input - const captures = output.match(/!\[image\]\([^\)]+\)/g) - if (captures) { - // clo(captures, 'results from embedded image match:') - for (const capture of captures) { - // logDebug(`simplifyInlineImagesForHTML`, capture) - output = output.replace(capture, ` `) - // logDebug(`simplifyInlineImagesForHTML`, `-> ${output}`) + let output = input + const captures = output.match(/!\[image\]\([^\)]+\)/g) + if (captures) { + // clo(captures, 'results from embedded image match:') + for (const capture of captures) { + // logDebug(`simplifyInlineImagesForHTML`, capture) + output = output.replace(capture, ` `) + // logDebug(`simplifyInlineImagesForHTML`, `-> ${output}`) + } } - } // logDebug('simplifyInlineImagesForHTML', `{${input}} -> {${output}}`) return output } catch (error) { @@ -1020,8 +1068,14 @@ export function convertHashtagsToHTML(input: string): string { // logDebug('convertHashtagsToHTML', `results from hashtag matches: ${String(matches)}`) for (const match of matches) { // logDebug('convertHashtagsToHTML', `- match: ${String(match)}`) - if (isTermInNotelinkOrURI(match, output) || isTermInMarkdownPath(match, output) || isTermInEventLinkHiddenPart(match, output) || isTermAColorStyleDefinition(match, output) - ) { continue } + if ( + isTermInNotelinkOrURI(match, output) || + isTermInMarkdownPath(match, output) || + isTermInEventLinkHiddenPart(match, output) || + isTermAColorStyleDefinition(match, output) + ) { + continue + } output = output.replace(match, `${match}`) } } @@ -1033,9 +1087,8 @@ export function convertHashtagsToHTML(input: string): string { } } - function isTermAColorStyleDefinition(term: string, input: string): boolean { - const RE_CSS_STYLE_DEFINITION = new RegExp(`style="color:\\s*${term}"`, "i") + const RE_CSS_STYLE_DEFINITION = new RegExp(`style="color:\\s*${term}"`, 'i') return RE_CSS_STYLE_DEFINITION.test(input) } @@ -1052,7 +1105,7 @@ export function convertMentionsToHTML(input: string): string { // regex from @EduardMe's file // const RE_MENTION_G = new RegExp(/(\s|^|\"|\'|\(|\[|\{)(?!@[\d[:punct:]]+(\s|$))(@([^[:punct:]\s]|[\-_\/])+?\(.*?\)|@([^[:punct:]\s]|[\-_\/])+)/, 'g') // regex from @EduardMe's file, without [:punct:] - // const RE_MENTION_G = new RegExp(/(\s|^|\"|\'|\(|\[|\{)(?!@[\d\`\"]+(\s|$))(@([^\`\"\s]|[\-_\/])+?\(.*?\)|@([^\`\"\s]|[\-_\/])+)/, 'g') + // const RE_MENTION_G = new RegExp(/(\s|^|\"|\'|\(|\[|\{)(?!@[\d\`\"]+(\s|$))(@([^\`\"\s]|[\-_\/])+?\(.*?\)|@([^\`\"\s]|[\-_\/])+)/, 'g') // now copes with Unicode characters, with help from https://stackoverflow.com/a/74926188/3238281 const RE_MENTION_G = new RegExp(/\B@((?![\p{N}_]+(?:$|\s|\b))(?:[\p{L}\p{M}\p{N}_\/\-]{1,60})(\(.*?\))?)/, 'gu') const matches = input.match(RE_MENTION_G) @@ -1060,7 +1113,9 @@ export function convertMentionsToHTML(input: string): string { // logDebug('convertMentionsToHTML', `results from mention matches: ${String(matches)}`) for (const match of matches) { // logDebug('convertMentionsToHTML', `- match: ${String(match)}`) - if (isTermInNotelinkOrURI(match, output) || isTermInMarkdownPath(match, output) || isTermInEventLinkHiddenPart(match, output)) { continue } + if (isTermInNotelinkOrURI(match, output) || isTermInMarkdownPath(match, output) || isTermInEventLinkHiddenPart(match, output)) { + continue + } output = output.replace(match, `${match}`) } } @@ -1104,6 +1159,120 @@ export function convertHighlightsToHTML(input: string): string { return output } +function addParagraphBreakHints(lines: Array): Array { + const output: Array = [] + for (let i = 0; i < lines.length; i++) { + const currentLine = lines[i] + output.push(currentLine) + + const trimmedCurrent = currentLine.trim() + if (trimmedCurrent === '') { + continue + } + + const nextLine = lines[i + 1] + if (nextLine == null) { + continue + } + const trimmedNext = nextLine.trim() + if (trimmedNext === '') { + continue + } + + const currentIsList = isListLine(trimmedCurrent) + const nextIsList = isListLine(trimmedNext) + + if ((currentIsList && !nextIsList) || isIsolatedWikiLink(trimmedCurrent)) { + output.push('') + } + } + return output +} + +function isListLine(line: string): boolean { + return /^([*\-+]|\d+\.)\s+/.test(line) +} + +function isIsolatedWikiLink(line: string): boolean { + return /^\[\[[^\]]+\]\]$/.test(line) +} + +function convertWikiLinksToHTML(input: string): string { + return input.replace(/\[\[(.+?)\]\]/g, (_match, content) => { + const { url, displayText } = buildNotePlanLinkFromWikiContent(content) + const safeText = escapeHTML(displayText) + if (!url) { + return safeText + } + return `${safeText}` + }) +} + +function buildNotePlanLinkFromWikiContent(content: string): { url: string | null, displayText: string } { + let linkTarget = content.trim() + let displayText = linkTarget + + const aliasIndex = linkTarget.indexOf('|') + if (aliasIndex !== -1) { + displayText = linkTarget.slice(aliasIndex + 1).trim() || linkTarget.slice(0, aliasIndex).trim() + linkTarget = linkTarget.slice(0, aliasIndex) + } + + let heading = '' + const headingIndex = linkTarget.indexOf('#') + if (headingIndex !== -1) { + heading = linkTarget.slice(headingIndex + 1).trim() + linkTarget = linkTarget.slice(0, headingIndex) + } + + let blockID = '' + const blockIndex = linkTarget.indexOf('^') + if (blockIndex !== -1) { + blockID = linkTarget.slice(blockIndex).trim() + linkTarget = linkTarget.slice(0, blockIndex) + } + + const trimmedTarget = linkTarget.trim() + if (trimmedTarget.length === 0) { + return { url: null, displayText } + } + + const calendarMatch = trimmedTarget.match(/^(\d{4}-\d{2}-\d{2}|\d{8})$/) + if (calendarMatch) { + const dateID = trimmedTarget.includes('-') ? trimmedTarget : `${trimmedTarget.slice(0, 4)}-${trimmedTarget.slice(4, 6)}-${trimmedTarget.slice(6)}` + const url = createOpenOrDeleteNoteCallbackUrl(dateID, 'date', heading) + return { url, displayText } + } + + const defaultExt = DataStore?.defaultFileExtension ?? 'md' + const possibleFilename = trimmedTarget.endsWith(`.${defaultExt}`) ? trimmedTarget : `${trimmedTarget}.${defaultExt}` + + let targetNote = typeof DataStore?.projectNoteByFilename === 'function' ? DataStore.projectNoteByFilename(possibleFilename) : null + + if (!targetNote) { + const possibleNotes = DataStore?.projectNoteByTitle?.(trimmedTarget, true, true) ?? [] + if (possibleNotes.length > 0) { + targetNote = possibleNotes.find((n) => n?.title === trimmedTarget) ?? possibleNotes[0] + } + } + + if (targetNote) { + if (blockID && targetNote?.title) { + const urlForBlock = createOpenOrDeleteNoteCallbackUrl(targetNote.title, 'title', null, null, false, blockID) + return { url: urlForBlock, displayText } + } + const urlForFilename = createOpenOrDeleteNoteCallbackUrl(targetNote.filename, 'filename', heading) + return { url: urlForFilename, displayText } + } + + const fallbackUrl = createOpenOrDeleteNoteCallbackUrl(trimmedTarget, 'title', heading) + return { url: fallbackUrl, displayText } +} + +function escapeHTML(input: string): string { + return input.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''') +} + /** * Display time blocks with .timeBlock style * Note: uses definition of time block syntax from plugin helpers, not directly from NP itself. So it may vary slightly. diff --git a/helpers/__tests__/HTMLView.test.js b/helpers/__tests__/HTMLView.test.js index 66e1b624c..4dd2c82ab 100644 --- a/helpers/__tests__/HTMLView.test.js +++ b/helpers/__tests__/HTMLView.test.js @@ -198,16 +198,65 @@ describe('replaceMarkdownLinkWithHTMLLink()' /* function */, () => { expect(result).toEqual(orig) }) test('should replace a url', () => { - const orig = 'foo [link](http://) bar' + const orig = '[foo](http://bar.com)' + const expected = 'foo' const result = h.replaceMarkdownLinkWithHTMLLink(orig) - const expected = 'foo link bar' expect(result).toEqual(expected) }) - test('should replace > 1 url', () => { - const orig = 'foo [link](http://) bar [link2](http://) baz [link3](noteplan://)' - const result = h.replaceMarkdownLinkWithHTMLLink(orig) - const expected = 'foo link bar link2 baz link3' - expect(result).toEqual(expected) +}) + +describe('getNoteContentAsHTML()', () => { + test('converts wiki links to noteplan callback URLs', async () => { + const targetNote = { + filename: 'Projects/Personal Projects/Anique Music/Rehearsal Setup.md', + title: 'Rehearsal Setup for Playback Tracks', + } + DataStore.projectNoteByTitle = jest.fn().mockReturnValue([targetNote]) + DataStore.projectNoteByFilename = jest.fn().mockReturnValue(targetNote) + + const result = await h.getNoteContentAsHTML('[[Rehearsal Setup for Playback Tracks]]', { + filename: 'Projects/Test/Test Note.md', + title: 'Test Note', + type: 'Notes', + }) + + const expectedUrl = + 'noteplan://x-callback-url/openNote?filename=Projects%2FPersonal%20Projects%2FAnique%20Music%2FRehearsal%20Setup.md' + expect(result).toContain( + `Rehearsal Setup for Playback Tracks`, + ) + }) + + test('keeps wiki-link paragraph separate from following text', async () => { + const targetNote = { + filename: 'Library/Example.md', + title: 'Example', + } + DataStore.projectNoteByTitle = jest.fn().mockReturnValue([targetNote]) + DataStore.projectNoteByFilename = jest.fn().mockReturnValue(targetNote) + + const result = await h.getNoteContentAsHTML('[[Example]]\nFollow up text', { + filename: 'Projects/Test/Test Note.md', + title: 'Test Note', + type: 'Notes', + }) + + expect(result).toContain('

Follow up text

') + expect(result).not.toMatch(/]*>Example<\/a>
Follow up text/) + }) + + test('ensures text following a list is not captured inside the list item', async () => { + const note = { + filename: 'Projects/Test/Test Note.md', + title: 'Test Note', + type: 'Notes', + } + + const markdown = '- Item one\n- Item two\nFollow up text' + const result = await h.getNoteContentAsHTML(markdown, note) + + expect(result).toContain('

Follow up text

') + expect(result).not.toMatch(/
  • [^<]*Follow up text/) }) }) diff --git a/np.Preview/CHANGELOG.md b/np.Preview/CHANGELOG.md index 7fb230e17..e17b9ee58 100644 --- a/np.Preview/CHANGELOG.md +++ b/np.Preview/CHANGELOG.md @@ -1,6 +1,24 @@ # What's Changed in 🖥️ Previews plugin? See [website README for more details](https://github.com/NotePlan/plugins/tree/main/np.Preview), and how to configure it. +## [1.0.0] - 2025-11-07 @dwertheimer + +Arbitrary elevation to 1.0.0 + +### General HTML Helper Change +- convert `[[wikilinks]]` in HTML output to be real NotePlan callback URLs rather than just underlined text that does nothing +- fix bug in showdown relative to NP to ensure horizontal rules have a blank line before them so they render properly +- add paragraph break hints so isolated wikilinks and text following lists render in their own paragraphs + +### Preview-specific Changes +- move preview-specific spacing and typography to `previewStyles` file, with: + - table formatting + - body padding + - h1 line-height + - list indentation/line-height tweaks + - internal note links (no underline, bold on hover) + - adjust horizontal rule appearance + ## [0.4.5] - 2025-03-14 - upgraded to use Mermaid v11.x diff --git a/np.Preview/plugin.json b/np.Preview/plugin.json index 970c8787a..7904789b0 100644 --- a/np.Preview/plugin.json +++ b/np.Preview/plugin.json @@ -7,8 +7,8 @@ "plugin.icon": "", "plugin.author": "Jonathan Clark", "plugin.url": "https://github.com/NotePlan/plugins/tree/main/np.Preview/", - "plugin.version": "0.4.5", - "plugin.lastUpdateInfo": "v0.4.5: updated to use Mermaid v11.\nv0.4.4: added embed images to preview, fixed some bugs", + "plugin.version": "1.0.0", + "plugin.lastUpdateInfo": "v1.0.0: improved wiki-link handling, preview styling tweaks.\nv0.4.5: updated to use Mermaid v11.\nv0.4.4: added embed images to preview, fixed some bugs", "plugin.changelog": "https://github.com/NotePlan/plugins/blob/main/np.Preview/CHANGELOG.md", "plugin.dependencies": [], "plugin.requiredFiles": [ diff --git a/np.Preview/src/previewMain.js b/np.Preview/src/previewMain.js index 4f0b8daca..925fa41a0 100644 --- a/np.Preview/src/previewMain.js +++ b/np.Preview/src/previewMain.js @@ -5,18 +5,14 @@ // by Jonathan Clark, last updated 2025-03-14 for v0.4.5 //-------------------------------------------------------------- - // import open, { openApp, apps } from 'open' import pluginJson from '../plugin.json' +import { getPreviewSpecificCSS } from './previewStyles' import { logDebug, logError, logWarn } from '@helpers/dev' import { addTrigger } from '@helpers/NPFrontMatter' import { displayTitle } from '@helpers/general' -import { - getNoteContentAsHTML, - type HtmlWindowOptions, - showHTMLV2 -} from '@helpers/HTMLView' +import { getNoteContentAsHTML, type HtmlWindowOptions, showHTMLV2 } from '@helpers/HTMLView' import { showMessageYesNo } from '@helpers/userInput' //-------------------------------------------------------------- @@ -33,13 +29,11 @@ const initMathJaxScripts = ` // Set up for Mermaid, using live copy of the Mermaid library (for now) // is current NP theme dark or light? -const isDarkTheme = (Editor.currentTheme.mode === 'dark') +const isDarkTheme = Editor.currentTheme.mode === 'dark' // Note: using CDN version of mermaid.js, because whatever we tried for a packaged local version didn't work for Gantt charts. function initMermaidScripts(mermaidTheme?: string): string { - const mermaidThemeToUse = mermaidTheme - ? mermaidTheme : isDarkTheme - ? 'dark' : 'default' + const mermaidThemeToUse = mermaidTheme ? mermaidTheme : isDarkTheme ? 'dark' : 'default' return `