summaryrefslogtreecommitdiffstats
path: root/web_src/js/markup
diff options
context:
space:
mode:
Diffstat (limited to 'web_src/js/markup')
-rw-r--r--web_src/js/markup/anchors.js70
-rw-r--r--web_src/js/markup/asciicast.js17
-rw-r--r--web_src/js/markup/codecopy.js21
-rw-r--r--web_src/js/markup/common.js8
-rw-r--r--web_src/js/markup/content.js18
-rw-r--r--web_src/js/markup/math.js47
-rw-r--r--web_src/js/markup/mermaid.js74
-rw-r--r--web_src/js/markup/tasklist.js90
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;
+ }
+ }
+}