diff options
Diffstat (limited to 'web_src/js/markup')
-rw-r--r-- | web_src/js/markup/anchors.js | 70 | ||||
-rw-r--r-- | web_src/js/markup/asciicast.js | 17 | ||||
-rw-r--r-- | web_src/js/markup/codecopy.js | 21 | ||||
-rw-r--r-- | web_src/js/markup/common.js | 8 | ||||
-rw-r--r-- | web_src/js/markup/content.js | 18 | ||||
-rw-r--r-- | web_src/js/markup/math.js | 47 | ||||
-rw-r--r-- | web_src/js/markup/mermaid.js | 74 | ||||
-rw-r--r-- | web_src/js/markup/tasklist.js | 90 |
8 files changed, 345 insertions, 0 deletions
diff --git a/web_src/js/markup/anchors.js b/web_src/js/markup/anchors.js new file mode 100644 index 00000000..0e2c9271 --- /dev/null +++ b/web_src/js/markup/anchors.js @@ -0,0 +1,70 @@ +import {svg} from '../svg.js'; + +const addPrefix = (str) => `user-content-${str}`; +const removePrefix = (str) => str.replace(/^user-content-/, ''); +const hasPrefix = (str) => str.startsWith('user-content-'); + +// scroll to anchor while respecting the `user-content` prefix that exists on the target +function scrollToAnchor(encodedId) { + if (!encodedId) return; + const id = decodeURIComponent(encodedId); + const prefixedId = addPrefix(id); + let el = document.getElementById(prefixedId); + + // check for matching user-generated `a[name]` + if (!el) { + const nameAnchors = document.getElementsByName(prefixedId); + if (nameAnchors.length) { + el = nameAnchors[0]; + } + } + + // compat for links with old 'user-content-' prefixed hashes + if (!el && hasPrefix(id)) { + return document.getElementById(id)?.scrollIntoView(); + } + + el?.scrollIntoView(); +} + +export function initMarkupAnchors() { + const markupEls = document.querySelectorAll('.markup'); + if (!markupEls.length) return; + + for (const markupEl of markupEls) { + // create link icons for markup headings, the resulting link href will remove `user-content-` + for (const heading of markupEl.querySelectorAll('h1, h2, h3, h4, h5, h6')) { + const a = document.createElement('a'); + a.classList.add('anchor'); + a.setAttribute('href', `#${encodeURIComponent(removePrefix(heading.id))}`); + a.innerHTML = svg('octicon-link'); + heading.prepend(a); + } + + // remove `user-content-` prefix from links so they don't show in url bar when clicked + for (const a of markupEl.querySelectorAll('a[href^="#"]')) { + const href = a.getAttribute('href'); + if (!href.startsWith('#user-content-')) continue; + a.setAttribute('href', `#${removePrefix(href.substring(1))}`); + } + + // add `user-content-` prefix to user-generated `a[name]` link targets + // TODO: this prefix should be added in backend instead + for (const a of markupEl.querySelectorAll('a[name]')) { + const name = a.getAttribute('name'); + if (!name) continue; + a.setAttribute('name', addPrefix(a.name)); + } + + for (const a of markupEl.querySelectorAll('a[href^="#"]')) { + a.addEventListener('click', (e) => { + scrollToAnchor(e.currentTarget.getAttribute('href')?.substring(1)); + }); + } + } + + // scroll to anchor unless the browser has already scrolled somewhere during page load + if (!document.querySelector(':target')) { + scrollToAnchor(window.location.hash?.substring(1)); + } +} diff --git a/web_src/js/markup/asciicast.js b/web_src/js/markup/asciicast.js new file mode 100644 index 00000000..97b18743 --- /dev/null +++ b/web_src/js/markup/asciicast.js @@ -0,0 +1,17 @@ +export async function renderAsciicast() { + const els = document.querySelectorAll('.asciinema-player-container'); + if (!els.length) return; + + const [player] = await Promise.all([ + import(/* webpackChunkName: "asciinema-player" */'asciinema-player'), + import(/* webpackChunkName: "asciinema-player" */'asciinema-player/dist/bundle/asciinema-player.css'), + ]); + + for (const el of els) { + player.create(el.getAttribute('data-asciinema-player-src'), el, { + // poster (a preview frame) to display until the playback is started. + // Set it to 1 hour (also means the end if the video is shorter) to make the preview frame show more. + poster: 'npt:1:0:0', + }); + } +} diff --git a/web_src/js/markup/codecopy.js b/web_src/js/markup/codecopy.js new file mode 100644 index 00000000..078d7412 --- /dev/null +++ b/web_src/js/markup/codecopy.js @@ -0,0 +1,21 @@ +import {svg} from '../svg.js'; + +export function makeCodeCopyButton() { + const button = document.createElement('button'); + button.classList.add('code-copy', 'ui', 'button'); + button.innerHTML = svg('octicon-copy'); + return button; +} + +export function renderCodeCopy() { + const els = document.querySelectorAll('.markup .code-block code'); + if (!els.length) return; + + for (const el of els) { + if (!el.textContent) continue; + const btn = makeCodeCopyButton(); + // remove final trailing newline introduced during HTML rendering + btn.setAttribute('data-clipboard-text', el.textContent.replace(/\r?\n$/, '')); + el.after(btn); + } +} diff --git a/web_src/js/markup/common.js b/web_src/js/markup/common.js new file mode 100644 index 00000000..aff4a324 --- /dev/null +++ b/web_src/js/markup/common.js @@ -0,0 +1,8 @@ +export function displayError(el, err) { + el.classList.remove('is-loading'); + const errorNode = document.createElement('pre'); + errorNode.setAttribute('class', 'ui message error markup-block-error'); + errorNode.textContent = err.str || err.message || String(err); + el.before(errorNode); + el.setAttribute('data-render-done', 'true'); +} diff --git a/web_src/js/markup/content.js b/web_src/js/markup/content.js new file mode 100644 index 00000000..1d29dc07 --- /dev/null +++ b/web_src/js/markup/content.js @@ -0,0 +1,18 @@ +import {renderMermaid} from './mermaid.js'; +import {renderMath} from './math.js'; +import {renderCodeCopy} from './codecopy.js'; +import {renderAsciicast} from './asciicast.js'; +import {initMarkupTasklist} from './tasklist.js'; + +// code that runs for all markup content +export function initMarkupContent() { + renderMermaid(); + renderMath(); + renderCodeCopy(); + renderAsciicast(); +} + +// code that only runs for comments +export function initCommentContent() { + initMarkupTasklist(); +} diff --git a/web_src/js/markup/math.js b/web_src/js/markup/math.js new file mode 100644 index 00000000..872e50a4 --- /dev/null +++ b/web_src/js/markup/math.js @@ -0,0 +1,47 @@ +import {displayError} from './common.js'; + +function targetElement(el) { + // The target element is either the current element if it has the + // `is-loading` class or the pre that contains it + return el.classList.contains('is-loading') ? el : el.closest('pre'); +} + +export async function renderMath() { + const els = document.querySelectorAll('.markup code.language-math'); + if (!els.length) return; + + const [{default: katex}] = await Promise.all([ + import(/* webpackChunkName: "katex" */'katex'), + import(/* webpackChunkName: "katex" */'katex/dist/katex.css'), + ]); + + const MAX_CHARS = 1000; + const MAX_SIZE = 25; + const MAX_EXPAND = 1000; + + for (const el of els) { + const target = targetElement(el); + if (target.hasAttribute('data-render-done')) continue; + const source = el.textContent; + + if (source.length > MAX_CHARS) { + displayError(target, new Error(`Math source of ${source.length} characters exceeds the maximum allowed length of ${MAX_CHARS}.`)); + continue; + } + + const displayMode = el.classList.contains('display'); + const nodeName = displayMode ? 'p' : 'span'; + + try { + const tempEl = document.createElement(nodeName); + katex.render(source, tempEl, { + maxSize: MAX_SIZE, + maxExpand: MAX_EXPAND, + displayMode, + }); + target.replaceWith(tempEl); + } catch (error) { + displayError(target, error); + } + } +} diff --git a/web_src/js/markup/mermaid.js b/web_src/js/markup/mermaid.js new file mode 100644 index 00000000..0549fb3e --- /dev/null +++ b/web_src/js/markup/mermaid.js @@ -0,0 +1,74 @@ +import {isDarkTheme} from '../utils.js'; +import {makeCodeCopyButton} from './codecopy.js'; +import {displayError} from './common.js'; + +const {mermaidMaxSourceCharacters} = window.config; + +// margin removal is for https://github.com/mermaid-js/mermaid/issues/4907 +const iframeCss = `:root {color-scheme: normal} +body {margin: 0; padding: 0; overflow: hidden} +#mermaid {display: block; margin: 0 auto} +blockquote, dd, dl, figure, h1, h2, h3, h4, h5, h6, hr, p, pre {margin: 0}`; + +export async function renderMermaid() { + const els = document.querySelectorAll('.markup code.language-mermaid'); + if (!els.length) return; + + const {default: mermaid} = await import(/* webpackChunkName: "mermaid" */'mermaid'); + + mermaid.initialize({ + startOnLoad: false, + theme: isDarkTheme() ? 'dark' : 'neutral', + securityLevel: 'strict', + }); + + for (const el of els) { + const pre = el.closest('pre'); + if (pre.hasAttribute('data-render-done')) continue; + + const source = el.textContent; + if (mermaidMaxSourceCharacters >= 0 && source.length > mermaidMaxSourceCharacters) { + displayError(pre, new Error(`Mermaid source of ${source.length} characters exceeds the maximum allowed length of ${mermaidMaxSourceCharacters}.`)); + continue; + } + + try { + await mermaid.parse(source); + } catch (err) { + displayError(pre, err); + continue; + } + + try { + // can't use bindFunctions here because we can't cross the iframe boundary. This + // means js-based interactions won't work but they aren't intended to work either + const {svg} = await mermaid.render('mermaid', source); + + const iframe = document.createElement('iframe'); + iframe.classList.add('markup-render', 'tw-invisible'); + iframe.srcdoc = `<html><head><style>${iframeCss}</style></head><body>${svg}</body></html>`; + + const mermaidBlock = document.createElement('div'); + mermaidBlock.classList.add('mermaid-block', 'is-loading', 'tw-hidden'); + mermaidBlock.append(iframe); + + const btn = makeCodeCopyButton(); + btn.setAttribute('data-clipboard-text', source); + mermaidBlock.append(btn); + + iframe.addEventListener('load', () => { + pre.replaceWith(mermaidBlock); + mermaidBlock.classList.remove('tw-hidden'); + iframe.style.height = `${iframe.contentWindow.document.body.clientHeight}px`; + setTimeout(() => { // avoid flash of iframe background + mermaidBlock.classList.remove('is-loading'); + iframe.classList.remove('tw-invisible'); + }, 0); + }); + + document.body.append(mermaidBlock); + } catch (err) { + displayError(pre, err); + } + } +} diff --git a/web_src/js/markup/tasklist.js b/web_src/js/markup/tasklist.js new file mode 100644 index 00000000..375810dc --- /dev/null +++ b/web_src/js/markup/tasklist.js @@ -0,0 +1,90 @@ +import {POST} from '../modules/fetch.js'; +import {showErrorToast} from '../modules/toast.js'; + +const preventListener = (e) => e.preventDefault(); + +/** + * Attaches `input` handlers to markdown rendered tasklist checkboxes in comments. + * + * When a checkbox value changes, the corresponding [ ] or [x] in the markdown string + * is set accordingly and sent to the server. On success it updates the raw-content on + * error it resets the checkbox to its original value. + */ +export function initMarkupTasklist() { + for (const el of document.querySelectorAll(`.markup[data-can-edit=true]`) || []) { + const container = el.parentNode; + const checkboxes = el.querySelectorAll(`.task-list-item input[type=checkbox]`); + + for (const checkbox of checkboxes) { + if (checkbox.hasAttribute('data-editable')) { + return; + } + + checkbox.setAttribute('data-editable', 'true'); + checkbox.addEventListener('input', async () => { + const checkboxCharacter = checkbox.checked ? 'x' : ' '; + const position = parseInt(checkbox.getAttribute('data-source-position')) + 1; + + const rawContent = container.querySelector('.raw-content'); + const oldContent = rawContent.textContent; + + const encoder = new TextEncoder(); + const buffer = encoder.encode(oldContent); + // Indexes may fall off the ends and return undefined. + if (buffer[position - 1] !== '['.codePointAt(0) || + buffer[position] !== ' '.codePointAt(0) && buffer[position] !== 'x'.codePointAt(0) && buffer[position] !== 'X'.codePointAt(0) || + buffer[position + 1] !== ']'.codePointAt(0)) { + // Position is probably wrong. Revert and don't allow change. + checkbox.checked = !checkbox.checked; + throw new Error(`Expected position to be space, x or X and surrounded by brackets, but it's not: position=${position}`); + } + buffer.set(encoder.encode(checkboxCharacter), position); + const newContent = new TextDecoder().decode(buffer); + + if (newContent === oldContent) { + return; + } + + // Prevent further inputs until the request is done. This does not use the + // `disabled` attribute because it causes the border to flash on click. + for (const checkbox of checkboxes) { + checkbox.addEventListener('click', preventListener); + } + + try { + const editContentZone = container.querySelector('.edit-content-zone'); + const updateUrl = editContentZone.getAttribute('data-update-url'); + const context = editContentZone.getAttribute('data-context'); + const contentVersion = editContentZone.getAttribute('data-content-version'); + + const requestBody = new FormData(); + requestBody.append('ignore_attachments', 'true'); + requestBody.append('content', newContent); + requestBody.append('context', context); + requestBody.append('content_version', contentVersion); + const response = await POST(updateUrl, {data: requestBody}); + const data = await response.json(); + if (response.status === 400) { + showErrorToast(data.errorMessage); + return; + } + editContentZone.setAttribute('data-content-version', data.contentVersion); + rawContent.textContent = newContent; + } catch (err) { + checkbox.checked = !checkbox.checked; + console.error(err); + } + + // Enable input on checkboxes again + for (const checkbox of checkboxes) { + checkbox.removeEventListener('click', preventListener); + } + }); + } + + // Enable the checkboxes as they are initially disabled by the markdown renderer + for (const checkbox of checkboxes) { + checkbox.disabled = false; + } + } +} |