feat: vendor gitea 1.16.2
This commit is contained in:
@@ -1,5 +1,20 @@
|
||||
import {svg} from '../svg.ts';
|
||||
|
||||
// Rendered content from users have IDs prefixed with `user-content-` to avoid conflicts with other IDs on the page.
|
||||
// - security concern: elements with IDs can affect frontend logic, for example: sending requests.
|
||||
// To make end users have better experience, the prefixes are stripped from the href attributes of links.
|
||||
// The same as GitHub: backend generates anchor `id="user-content-faq"` but the link shown to users is `href="#faq"`.
|
||||
//
|
||||
// At the moment, the anchor processing works like this:
|
||||
// - backend adds `user-content-` prefix for elements like `<h1 id>` and `<a href>`
|
||||
// - js adds the `user-content-` prefix to user-generated `<a name>` targets
|
||||
// - js intercepts the hash navigation on page load and whenever a link is clicked
|
||||
// to add the prefix so the correct prefixed `id`/`name` element is focused
|
||||
//
|
||||
// TODO: ideally, backend should be able to generate elements with necessary anchors,
|
||||
// backend doesn't need to add the prefix to `href`, then frontend doesn't need to spend
|
||||
// time on adding new elements or removing the prefixes.
|
||||
|
||||
const addPrefix = (str: string): string => `user-content-${str}`;
|
||||
const removePrefix = (str: string): string => str.replace(/^user-content-/, '');
|
||||
const hasPrefix = (str: string): boolean => str.startsWith('user-content-');
|
||||
@@ -7,7 +22,7 @@ const hasPrefix = (str: string): boolean => str.startsWith('user-content-');
|
||||
// scroll to anchor while respecting the `user-content` prefix that exists on the target
|
||||
function scrollToAnchor(encodedId?: string): void {
|
||||
// FIXME: need to rewrite this function with new a better markup anchor generation logic, too many tricks here
|
||||
let elemId: string;
|
||||
let elemId: string | undefined;
|
||||
try {
|
||||
elemId = decodeURIComponent(encodedId ?? '');
|
||||
} catch {} // ignore the errors, since the "encodedId" is from user's input
|
||||
@@ -44,7 +59,7 @@ export function initMarkupAnchors(): void {
|
||||
// remove `user-content-` prefix from links so they don't show in url bar when clicked
|
||||
for (const a of markupEl.querySelectorAll<HTMLAnchorElement>('a[href^="#"]')) {
|
||||
const href = a.getAttribute('href');
|
||||
if (!href.startsWith('#user-content-')) continue;
|
||||
if (!href?.startsWith('#user-content-')) continue;
|
||||
a.setAttribute('href', `#${removePrefix(href.substring(1))}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,12 +3,11 @@ import {queryElems} from '../utils/dom.ts';
|
||||
export async function initMarkupRenderAsciicast(elMarkup: HTMLElement): Promise<void> {
|
||||
queryElems(elMarkup, '.asciinema-player-container', async (el) => {
|
||||
const [player] = await Promise.all([
|
||||
// @ts-expect-error: module exports no types
|
||||
import(/* webpackChunkName: "asciinema-player" */'asciinema-player'),
|
||||
import(/* webpackChunkName: "asciinema-player" */'asciinema-player/dist/bundle/asciinema-player.css'),
|
||||
import('asciinema-player'),
|
||||
import('asciinema-player/dist/bundle/asciinema-player.css'),
|
||||
]);
|
||||
|
||||
player.create(el.getAttribute('data-asciinema-player-src'), el, {
|
||||
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',
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
import {svg} from '../svg.ts';
|
||||
import {queryElems} from '../utils/dom.ts';
|
||||
import {createElementFromAttrs, queryElems} from '../utils/dom.ts';
|
||||
|
||||
export function makeCodeCopyButton(): HTMLButtonElement {
|
||||
const button = document.createElement('button');
|
||||
button.classList.add('code-copy', 'ui', 'button');
|
||||
button.innerHTML = svg('octicon-copy');
|
||||
return button;
|
||||
export function makeCodeCopyButton(attrs: Record<string, string> = {}): HTMLButtonElement {
|
||||
const btn = createElementFromAttrs<HTMLButtonElement>('button', {
|
||||
class: 'ui compact icon button code-copy auto-hide-control',
|
||||
...attrs,
|
||||
});
|
||||
btn.innerHTML = svg('octicon-copy');
|
||||
return btn;
|
||||
}
|
||||
|
||||
export function initMarkupCodeCopy(elMarkup: HTMLElement): void {
|
||||
// .markup .code-block code
|
||||
queryElems(elMarkup, '.code-block code', (el) => {
|
||||
if (!el.textContent) return;
|
||||
const btn = makeCodeCopyButton();
|
||||
// remove final trailing newline introduced during HTML rendering
|
||||
btn.setAttribute('data-clipboard-text', el.textContent.replace(/\r?\n$/, ''));
|
||||
const btn = makeCodeCopyButton({
|
||||
'data-clipboard-text': el.textContent.replace(/\r?\n$/, ''),
|
||||
});
|
||||
// we only want to use `.code-block-container` if it exists, no matter `.code-block` exists or not.
|
||||
const btnContainer = el.closest('.code-block-container') ?? el.closest('.code-block');
|
||||
const btnContainer = el.closest('.code-block-container') ?? el.closest('.code-block')!;
|
||||
btnContainer.append(btn);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,19 +3,29 @@ import {initMarkupCodeMath} from './math.ts';
|
||||
import {initMarkupCodeCopy} from './codecopy.ts';
|
||||
import {initMarkupRenderAsciicast} from './asciicast.ts';
|
||||
import {initMarkupTasklist} from './tasklist.ts';
|
||||
import {registerGlobalSelectorFunc} from '../modules/observer.ts';
|
||||
import {initMarkupRenderIframe} from './render-iframe.ts';
|
||||
import {registerGlobalInitFunc, registerGlobalSelectorFunc} from '../modules/observer.ts';
|
||||
import {initExternalRenderIframe} from './render-iframe.ts';
|
||||
import {initMarkupRefIssue} from './refissue.ts';
|
||||
import {toggleElemClass} from '../utils/dom.ts';
|
||||
|
||||
// code that runs for all markup content
|
||||
export function initMarkupContent(): void {
|
||||
registerGlobalInitFunc('initExternalRenderIframe', initExternalRenderIframe);
|
||||
registerGlobalSelectorFunc('.markup', (el: HTMLElement) => {
|
||||
if (el.matches('.truncated-markup')) {
|
||||
// when the rendered markup is truncated (e.g.: user's home activity feed)
|
||||
// we should not initialize any of the features (e.g.: code copy button), due to:
|
||||
// * truncated markup already means that the container doesn't want to show complex contents
|
||||
// * truncated markup may contain incomplete HTML/mermaid elements
|
||||
// so the only thing we need to do is to remove the "is-loading" class added by the backend render.
|
||||
toggleElemClass(el.querySelectorAll('.is-loading'), 'is-loading', false);
|
||||
return;
|
||||
}
|
||||
initMarkupCodeCopy(el);
|
||||
initMarkupTasklist(el);
|
||||
initMarkupCodeMermaid(el);
|
||||
initMarkupCodeMath(el);
|
||||
initMarkupRenderAsciicast(el);
|
||||
initMarkupRenderIframe(el);
|
||||
initMarkupRefIssue(el);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,8 +16,8 @@ export async function initMarkupCodeMath(elMarkup: HTMLElement): Promise<void> {
|
||||
// .markup code.language-math'
|
||||
queryElems(elMarkup, 'code.language-math', async (el) => {
|
||||
const [{default: katex}] = await Promise.all([
|
||||
import(/* webpackChunkName: "katex" */'katex'),
|
||||
import(/* webpackChunkName: "katex" */'katex/dist/katex.css'),
|
||||
import('katex'),
|
||||
import('katex/dist/katex.css'),
|
||||
]);
|
||||
|
||||
const MAX_CHARS = 1000;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import {sourceNeedsElk} from './mermaid.ts';
|
||||
import {dedent} from '../utils/testhelper.ts';
|
||||
|
||||
test('MermaidConfigLayoutCheck', () => {
|
||||
expect(sourceNeedsElk(dedent(`
|
||||
flowchart TB
|
||||
elk --> B
|
||||
`))).toEqual(false);
|
||||
|
||||
expect(sourceNeedsElk(dedent(`
|
||||
---
|
||||
config:
|
||||
layout : elk
|
||||
---
|
||||
flowchart TB
|
||||
A --> B
|
||||
`))).toEqual(true);
|
||||
|
||||
expect(sourceNeedsElk(dedent(`
|
||||
---
|
||||
config:
|
||||
layout: elk.layered
|
||||
---
|
||||
flowchart TB
|
||||
A --> B
|
||||
`))).toEqual(true);
|
||||
|
||||
expect(sourceNeedsElk(`
|
||||
%%{ init : { "flowchart": { "defaultRenderer": "elk" } } }%%
|
||||
flowchart TB
|
||||
A --> B
|
||||
`)).toEqual(true);
|
||||
|
||||
expect(sourceNeedsElk(dedent(`
|
||||
---
|
||||
config:
|
||||
layout: 123
|
||||
---
|
||||
%%{ init : { "class": { "defaultRenderer": "elk.any" } } }%%
|
||||
flowchart TB
|
||||
A --> B
|
||||
`))).toEqual(true);
|
||||
|
||||
expect(sourceNeedsElk(`
|
||||
%%{init:{
|
||||
"layout" : "elk.layered"
|
||||
}}%%
|
||||
flowchart TB
|
||||
A --> B
|
||||
`)).toEqual(true);
|
||||
|
||||
expect(sourceNeedsElk(`
|
||||
%%{ initialize: {
|
||||
'layout' : 'elk.layered'
|
||||
}}%%
|
||||
flowchart TB
|
||||
A --> B
|
||||
`)).toEqual(true);
|
||||
});
|
||||
@@ -1,89 +1,250 @@
|
||||
import {isDarkTheme} from '../utils.ts';
|
||||
import {makeCodeCopyButton} from './codecopy.ts';
|
||||
import {displayError} from './common.ts';
|
||||
import {queryElems} from '../utils/dom.ts';
|
||||
import {createElementFromAttrs, createElementFromHTML, queryElems} from '../utils/dom.ts';
|
||||
import {html, htmlRaw} from '../utils/html.ts';
|
||||
import {load as loadYaml} from 'js-yaml';
|
||||
import type {MermaidConfig} from 'mermaid';
|
||||
import {svg} from '../svg.ts';
|
||||
|
||||
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}`;
|
||||
function getIframeCss(): string {
|
||||
return `
|
||||
html, body { height: 100%; }
|
||||
body { margin: 0; padding: 0; overflow: hidden; }
|
||||
#mermaid { display: block; margin: 0 auto; }
|
||||
`;
|
||||
}
|
||||
|
||||
function isSourceTooLarge(source: string) {
|
||||
return mermaidMaxSourceCharacters >= 0 && source.length > mermaidMaxSourceCharacters;
|
||||
}
|
||||
|
||||
function parseYamlInitConfig(source: string): MermaidConfig | null {
|
||||
// ref: https://github.com/mermaid-js/mermaid/blob/develop/packages/mermaid/src/diagram-api/regexes.ts
|
||||
const yamlFrontMatterRegex = /^---\s*[\n\r](.*?)[\n\r]---\s*[\n\r]+/s;
|
||||
const frontmatter = (yamlFrontMatterRegex.exec(source) || [])[1];
|
||||
if (!frontmatter) return null;
|
||||
try {
|
||||
return (loadYaml(frontmatter) as {config: MermaidConfig})?.config;
|
||||
} catch {
|
||||
console.error('invalid or unsupported mermaid init YAML config', frontmatter);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseJsonInitConfig(source: string): MermaidConfig | null {
|
||||
// https://mermaid.js.org/config/directives.html#declaring-directives
|
||||
// Do as dirty as mermaid does: https://github.com/mermaid-js/mermaid/blob/develop/packages/mermaid/src/utils.ts
|
||||
// It can even accept invalid JSON string like:
|
||||
// %%{initialize: { 'logLevel': 'fatal', "theme":'dark', 'startOnLoad': true } }%%
|
||||
const jsonInitConfigRegex = /%%\{\s*(init|initialize)\s*:\s*(.*?)\}%%/s;
|
||||
const jsonInitText = (jsonInitConfigRegex.exec(source) || [])[2];
|
||||
if (!jsonInitText) return null;
|
||||
try {
|
||||
const processed = jsonInitText.trim().replace(/'/g, '"');
|
||||
return JSON.parse(processed);
|
||||
} catch {
|
||||
console.error('invalid or unsupported mermaid init JSON config', jsonInitText);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function configValueIsElk(layoutOrRenderer: string | undefined) {
|
||||
if (typeof layoutOrRenderer !== 'string') return false;
|
||||
return layoutOrRenderer === 'elk' || layoutOrRenderer.startsWith('elk.');
|
||||
}
|
||||
|
||||
function configContainsElk(config: MermaidConfig | null) {
|
||||
if (!config) return false;
|
||||
// Check the layout from the following properties:
|
||||
// * config.layout
|
||||
// * config.{any-diagram-config}.defaultRenderer
|
||||
// Although only a few diagram types like "flowchart" support "defaultRenderer",
|
||||
// as long as there is no side effect, here do a general check for all properties of "config", for ease of maintenance
|
||||
return configValueIsElk(config.layout) || Object.values(config).some((diagCfg) => configValueIsElk(diagCfg?.defaultRenderer));
|
||||
}
|
||||
|
||||
export function sourceNeedsElk(source: string) {
|
||||
if (isSourceTooLarge(source)) return false;
|
||||
const configYaml = parseYamlInitConfig(source), configJson = parseJsonInitConfig(source);
|
||||
return configContainsElk(configYaml) || configContainsElk(configJson);
|
||||
}
|
||||
|
||||
async function loadMermaid(needElkRender: boolean) {
|
||||
const mermaidPromise = import('mermaid');
|
||||
const elkPromise = needElkRender ? import('@mermaid-js/layout-elk') : null;
|
||||
const results = await Promise.all([mermaidPromise, elkPromise]);
|
||||
return {
|
||||
mermaid: results[0].default,
|
||||
elkLayouts: results[1]?.default,
|
||||
};
|
||||
}
|
||||
|
||||
function initMermaidViewController(viewController: Element, dragElement: SVGSVGElement) {
|
||||
let inited = false, isDragging = false;
|
||||
let currentScale = 1, initLeft = 0, lastLeft = 0, lastTop = 0, lastPageX = 0, lastPageY = 0;
|
||||
|
||||
const resetView = () => {
|
||||
currentScale = 1;
|
||||
lastLeft = initLeft;
|
||||
lastTop = 0;
|
||||
dragElement.style.left = `${lastLeft}px`;
|
||||
dragElement.style.top = `${lastTop}px`;
|
||||
dragElement.style.position = 'absolute';
|
||||
dragElement.style.margin = '0';
|
||||
};
|
||||
|
||||
const initAbsolutePosition = () => {
|
||||
if (inited) return;
|
||||
// if we need to drag or zoom, use absolute position and get the current "left" from the "margin: auto" layout.
|
||||
inited = true;
|
||||
const container = dragElement.parentElement!;
|
||||
initLeft = container.getBoundingClientRect().width / 2 - dragElement.getBoundingClientRect().width / 2;
|
||||
resetView();
|
||||
};
|
||||
|
||||
for (const el of viewController.querySelectorAll('[data-control-action]')) {
|
||||
el.addEventListener('click', () => {
|
||||
initAbsolutePosition();
|
||||
switch (el.getAttribute('data-control-action')) {
|
||||
case 'zoom-in':
|
||||
currentScale *= 1.2;
|
||||
break;
|
||||
case 'zoom-out':
|
||||
currentScale /= 1.2;
|
||||
break;
|
||||
case 'reset':
|
||||
resetView();
|
||||
break;
|
||||
}
|
||||
dragElement.style.transform = `scale(${currentScale})`;
|
||||
});
|
||||
}
|
||||
|
||||
dragElement.addEventListener('mousedown', (e) => {
|
||||
if (e.button !== 0 || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return; // only left mouse button can drag
|
||||
const target = e.target as Element;
|
||||
// don't start the drag if the click is on an interactive element (e.g.: link, button) or text element
|
||||
if (target.closest('div, p, a, span, button, input, text')) return;
|
||||
|
||||
initAbsolutePosition();
|
||||
isDragging = true;
|
||||
lastPageX = e.pageX;
|
||||
lastPageY = e.pageY;
|
||||
dragElement.style.cursor = 'grabbing';
|
||||
});
|
||||
|
||||
dragElement.ownerDocument.addEventListener('mousemove', (e) => {
|
||||
if (!isDragging) return;
|
||||
lastLeft = e.pageX - lastPageX + lastLeft;
|
||||
lastTop = e.pageY - lastPageY + lastTop;
|
||||
dragElement.style.left = `${lastLeft}px`;
|
||||
dragElement.style.top = `${lastTop}px`;
|
||||
lastPageX = e.pageX;
|
||||
lastPageY = e.pageY;
|
||||
});
|
||||
|
||||
dragElement.ownerDocument.addEventListener('mouseup', () => {
|
||||
if (!isDragging) return;
|
||||
isDragging = false;
|
||||
dragElement.style.removeProperty('cursor');
|
||||
});
|
||||
}
|
||||
|
||||
let elkLayoutsRegistered = false;
|
||||
|
||||
export async function initMarkupCodeMermaid(elMarkup: HTMLElement): Promise<void> {
|
||||
// .markup code.language-mermaid
|
||||
queryElems(elMarkup, 'code.language-mermaid', async (el) => {
|
||||
const {default: mermaid} = await import(/* webpackChunkName: "mermaid" */'mermaid');
|
||||
const mermaidBlocks: Array<{source: string, parentContainer: HTMLElement}> = [];
|
||||
const attrMermaidRendered = 'data-markup-mermaid-rendered';
|
||||
let needElkRender = false;
|
||||
for (const elCodeBlock of queryElems(elMarkup, 'code.language-mermaid')) {
|
||||
const parentContainer = elCodeBlock.closest('pre')!; // it must exist, if no, there must be a bug
|
||||
if (parentContainer.hasAttribute(attrMermaidRendered)) continue;
|
||||
parentContainer.setAttribute(attrMermaidRendered, 'true');
|
||||
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: isDarkTheme() ? 'dark' : 'neutral',
|
||||
securityLevel: 'strict',
|
||||
suppressErrorRendering: true,
|
||||
});
|
||||
const source = elCodeBlock.textContent ?? '';
|
||||
needElkRender = needElkRender || sourceNeedsElk(source);
|
||||
mermaidBlocks.push({source, parentContainer});
|
||||
}
|
||||
if (!mermaidBlocks.length) return;
|
||||
|
||||
const pre = el.closest('pre');
|
||||
if (pre.hasAttribute('data-render-done')) return;
|
||||
const {mermaid, elkLayouts} = await loadMermaid(needElkRender);
|
||||
if (elkLayouts && !elkLayoutsRegistered) {
|
||||
mermaid.registerLayoutLoaders(elkLayouts);
|
||||
elkLayoutsRegistered = true;
|
||||
}
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: isDarkTheme() ? 'dark' : 'neutral', // TODO: maybe it should use "darkMode" to adopt more user-specified theme instead of just "dark" or "neutral"
|
||||
securityLevel: 'strict',
|
||||
suppressErrorRendering: true,
|
||||
});
|
||||
|
||||
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}.`));
|
||||
return;
|
||||
const iframeStyleText = getIframeCss();
|
||||
const applyMermaidIframeHeight = (iframe: HTMLIFrameElement, height: number) => {
|
||||
if (!height) return;
|
||||
// use a min-height to make sure the buttons won't overlap.
|
||||
iframe.style.height = `${Math.max(height, 85)}px`;
|
||||
};
|
||||
|
||||
// mermaid is a globally shared instance, its document also says "Multiple calls to this function will be enqueued to run serially."
|
||||
// so here we just simply render the mermaid blocks one by one, no need to do "Promise.all" concurrently
|
||||
for (const block of mermaidBlocks) {
|
||||
const {source, parentContainer} = block;
|
||||
if (isSourceTooLarge(source)) {
|
||||
displayError(parentContainer, 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);
|
||||
return;
|
||||
}
|
||||
// render the mermaid diagram to svg text, and parse it to a DOM node
|
||||
const {svg: svgText, bindFunctions} = await mermaid.render('mermaid', source, parentContainer);
|
||||
const svgNode = createElementFromHTML<SVGSVGElement>(svgText);
|
||||
|
||||
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 viewControllerHtml = html`
|
||||
<div class="view-controller auto-hide-control flex-text-block">
|
||||
<button type="button" class="ui tiny compact icon button" data-control-action="zoom-in">${htmlRaw(svg('octicon-zoom-in', 12))}</button>
|
||||
<button type="button" class="ui tiny compact icon button" data-control-action="reset">${htmlRaw(svg('octicon-sync', 12))}</button>
|
||||
<button type="button" class="ui tiny compact icon button" data-control-action="zoom-out">${htmlRaw(svg('octicon-zoom-out', 12))}</button>
|
||||
</div>
|
||||
`;
|
||||
const viewController = createElementFromHTML(viewControllerHtml);
|
||||
|
||||
// create an iframe to sandbox the svg with styles, and set correct height by reading svg's viewBox height
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.classList.add('markup-content-iframe', 'tw-invisible');
|
||||
iframe.srcdoc = html`<html><head><style>${htmlRaw(iframeCss)}</style></head><body>${htmlRaw(svg)}</body></html>`;
|
||||
iframe.classList.add('markup-content-iframe', 'is-loading');
|
||||
// the styles are not ready, so don't really render anything before the "load" event, to avoid flicker of unstyled content
|
||||
iframe.srcdoc = html`<html><head></head><body></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);
|
||||
|
||||
const updateIframeHeight = () => {
|
||||
const body = iframe.contentWindow?.document?.body;
|
||||
if (body) {
|
||||
iframe.style.height = `${body.clientHeight}px`;
|
||||
}
|
||||
};
|
||||
// although the "viewBox" is optional, mermaid's output should always have a correct viewBox with width and height
|
||||
const iframeHeightFromViewBox = Math.ceil(svgNode.viewBox?.baseVal?.height ?? 0);
|
||||
applyMermaidIframeHeight(iframe, iframeHeightFromViewBox);
|
||||
|
||||
// the iframe will be fully reloaded if its DOM context is changed (e.g.: moved in the DOM tree).
|
||||
// to avoid unnecessary reloading, we should insert the iframe to its final position only once.
|
||||
iframe.addEventListener('load', () => {
|
||||
pre.replaceWith(mermaidBlock);
|
||||
mermaidBlock.classList.remove('tw-hidden');
|
||||
updateIframeHeight();
|
||||
setTimeout(() => { // avoid flash of iframe background
|
||||
mermaidBlock.classList.remove('is-loading');
|
||||
iframe.classList.remove('tw-invisible');
|
||||
}, 0);
|
||||
// same origin, so we can operate "iframe head/body" and all elements directly
|
||||
const style = document.createElement('style');
|
||||
style.textContent = iframeStyleText;
|
||||
iframe.contentDocument!.head.append(style);
|
||||
|
||||
// update height when element's visibility state changes, for example when the diagram is inside
|
||||
// a <details> + <summary> block and the <details> block becomes visible upon user interaction, it
|
||||
// would initially set a incorrect height and the correct height is set during this callback.
|
||||
(new IntersectionObserver(() => {
|
||||
updateIframeHeight();
|
||||
}, {root: document.documentElement})).observe(iframe);
|
||||
const iframeBody = iframe.contentDocument!.body;
|
||||
iframeBody.append(svgNode);
|
||||
bindFunctions?.(iframeBody); // follow "mermaid.render" doc, attach event handlers to the svg's container
|
||||
|
||||
// according to mermaid, the viewBox height should always exist, here just a fallback for unknown cases.
|
||||
// and keep in mind: clientHeight can be 0 if the element is hidden (display: none).
|
||||
if (!iframeHeightFromViewBox) applyMermaidIframeHeight(iframe, iframeBody.clientHeight);
|
||||
iframe.classList.remove('is-loading');
|
||||
initMermaidViewController(viewController, svgNode);
|
||||
});
|
||||
|
||||
document.body.append(mermaidBlock);
|
||||
const container = createElementFromAttrs('div', {class: 'mermaid-block'}, iframe, viewController);
|
||||
parentContainer.replaceWith(container);
|
||||
} catch (err) {
|
||||
displayError(pre, err);
|
||||
displayError(parentContainer, err);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {queryElems} from '../utils/dom.ts';
|
||||
import {parseIssueHref} from '../utils.ts';
|
||||
import {createApp} from 'vue';
|
||||
import ContextPopup from '../components/ContextPopup.vue';
|
||||
import {createTippy, getAttachedTippyInstance} from '../modules/tippy.ts';
|
||||
|
||||
export function initMarkupRefIssue(el: HTMLElement) {
|
||||
@@ -11,15 +10,23 @@ export function initMarkupRefIssue(el: HTMLElement) {
|
||||
});
|
||||
}
|
||||
|
||||
export function showMarkupRefIssuePopup(e: MouseEvent | FocusEvent) {
|
||||
function showMarkupRefIssuePopup(e: MouseEvent | FocusEvent) {
|
||||
const refIssue = e.currentTarget as HTMLElement;
|
||||
if (getAttachedTippyInstance(refIssue)) return;
|
||||
if (refIssue.classList.contains('ref-external-issue')) return;
|
||||
|
||||
const issuePathInfo = parseIssueHref(refIssue.getAttribute('href'));
|
||||
const issuePathInfo = parseIssueHref(refIssue.getAttribute('href')!);
|
||||
if (!issuePathInfo.ownerName) return;
|
||||
|
||||
const el = document.createElement('div');
|
||||
const onShowAsync = async () => {
|
||||
const {default: ContextPopup} = await import('../components/ContextPopup.vue');
|
||||
const view = createApp(ContextPopup, {
|
||||
// backend: GetIssueInfo
|
||||
loadIssueInfoUrl: `${window.config.appSubUrl}/${issuePathInfo.ownerName}/${issuePathInfo.repoName}/issues/${issuePathInfo.indexString}/info`,
|
||||
});
|
||||
view.mount(el);
|
||||
};
|
||||
const tippy = createTippy(refIssue, {
|
||||
theme: 'default',
|
||||
content: el,
|
||||
@@ -29,13 +36,7 @@ export function showMarkupRefIssuePopup(e: MouseEvent | FocusEvent) {
|
||||
role: 'dialog',
|
||||
interactiveBorder: 5,
|
||||
// onHide() { return false }, // help to keep the popup and debug the layout
|
||||
onShow: () => {
|
||||
const view = createApp(ContextPopup, {
|
||||
// backend: GetIssueInfo
|
||||
loadIssueInfoUrl: `${window.config.appSubUrl}/${issuePathInfo.ownerName}/${issuePathInfo.repoName}/issues/${issuePathInfo.indexString}/info`,
|
||||
});
|
||||
view.mount(el);
|
||||
},
|
||||
onShow: () => { onShowAsync() },
|
||||
});
|
||||
tippy.show();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import {navigateToIframeLink} from './render-iframe.ts';
|
||||
|
||||
describe('navigateToIframeLink', () => {
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
|
||||
const assignSpy = vi.spyOn(window.location, 'assign').mockImplementation(() => undefined);
|
||||
|
||||
test('safe links', () => {
|
||||
navigateToIframeLink('http://example.com', '_blank');
|
||||
expect(openSpy).toHaveBeenCalledWith('http://example.com/', '_blank', 'noopener,noreferrer');
|
||||
vi.clearAllMocks();
|
||||
|
||||
navigateToIframeLink('https://example.com', '_self');
|
||||
expect(assignSpy).toHaveBeenCalledWith('https://example.com/');
|
||||
vi.clearAllMocks();
|
||||
|
||||
navigateToIframeLink('https://example.com', null);
|
||||
expect(assignSpy).toHaveBeenCalledWith('https://example.com/');
|
||||
vi.clearAllMocks();
|
||||
|
||||
navigateToIframeLink('/path', '');
|
||||
expect(assignSpy).toHaveBeenCalledWith('http://localhost:3000/path');
|
||||
vi.clearAllMocks();
|
||||
|
||||
// input can be any type & any value, keep the same behavior as `window.location.href = 0`
|
||||
navigateToIframeLink(0, {});
|
||||
expect(assignSpy).toHaveBeenCalledWith('http://localhost:3000/0');
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('unsafe links', () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
window.location.href = 'http://localhost:3000/';
|
||||
|
||||
// eslint-disable-next-line no-script-url
|
||||
navigateToIframeLink('javascript:void(0);', '_blank');
|
||||
expect(openSpy).toHaveBeenCalledTimes(0);
|
||||
expect(assignSpy).toHaveBeenCalledTimes(0);
|
||||
expect(window.location.href).toBe('http://localhost:3000/');
|
||||
vi.clearAllMocks();
|
||||
|
||||
navigateToIframeLink('data:image/svg+xml;utf8,<svg></svg>', '');
|
||||
expect(openSpy).toHaveBeenCalledTimes(0);
|
||||
expect(assignSpy).toHaveBeenCalledTimes(0);
|
||||
expect(window.location.href).toBe('http://localhost:3000/');
|
||||
errorSpy.mockRestore();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
});
|
||||
@@ -1,21 +1,59 @@
|
||||
import {generateElemId, queryElemChildren} from '../utils/dom.ts';
|
||||
import {generateElemId} from '../utils/dom.ts';
|
||||
import {isDarkTheme} from '../utils.ts';
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
|
||||
export async function loadRenderIframeContent(iframe: HTMLIFrameElement) {
|
||||
const iframeSrcUrl = iframe.getAttribute('data-src');
|
||||
function safeRenderIframeLink(link: any): string | null {
|
||||
try {
|
||||
const url = new URL(`${link}`, window.location.href);
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
console.error(`Unsupported link protocol: ${link}`);
|
||||
return null;
|
||||
}
|
||||
return url.href;
|
||||
} catch (e) {
|
||||
console.error(`Failed to parse link: ${link}, error: ${e}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// This function is only designed for "open-link" command from iframe, is not suitable for other contexts.
|
||||
// Because other link protocols are directly handled by the iframe, but not here.
|
||||
// Arguments can be any type & any value, they are from "message" event's data which is not controlled by us.
|
||||
export function navigateToIframeLink(unsafeLink: any, target: any) {
|
||||
const linkHref = safeRenderIframeLink(unsafeLink);
|
||||
if (linkHref === null) return;
|
||||
if (target === '_blank') {
|
||||
window.open(linkHref, '_blank', 'noopener,noreferrer');
|
||||
return;
|
||||
}
|
||||
// treat all other targets including ("_top", "_self", etc.) as same tab navigation
|
||||
window.location.assign(linkHref);
|
||||
}
|
||||
|
||||
function getRealBackgroundColor(el: HTMLElement) {
|
||||
for (let n = el; n; n = n.parentElement!) {
|
||||
const style = window.getComputedStyle(n);
|
||||
const bgColor = style.backgroundColor;
|
||||
// 'rgba(0, 0, 0, 0)' is how most browsers represent transparent
|
||||
if (bgColor !== 'rgba(0, 0, 0, 0)' && bgColor !== 'transparent') {
|
||||
return bgColor;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export async function initExternalRenderIframe(iframe: HTMLIFrameElement) {
|
||||
const iframeSrcUrl = iframe.getAttribute('data-src')!;
|
||||
if (!iframe.id) iframe.id = generateElemId('gitea-iframe-');
|
||||
|
||||
window.addEventListener('message', (e) => {
|
||||
if (e.source !== iframe.contentWindow) return;
|
||||
if (!e.data?.giteaIframeCmd || e.data?.giteaIframeId !== iframe.id) return;
|
||||
const cmd = e.data.giteaIframeCmd;
|
||||
if (cmd === 'resize') {
|
||||
iframe.style.height = `${e.data.iframeHeight}px`;
|
||||
} else if (cmd === 'open-link') {
|
||||
if (e.data.anchorTarget === '_blank') {
|
||||
window.open(e.data.openLink, '_blank');
|
||||
} else {
|
||||
window.location.href = e.data.openLink;
|
||||
}
|
||||
navigateToIframeLink(e.data.openLink, e.data.anchorTarget);
|
||||
} else {
|
||||
throw new Error(`Unknown gitea iframe cmd: ${cmd}`);
|
||||
}
|
||||
@@ -24,9 +62,11 @@ export async function loadRenderIframeContent(iframe: HTMLIFrameElement) {
|
||||
const u = new URL(iframeSrcUrl, window.location.origin);
|
||||
u.searchParams.set('gitea-is-dark-theme', String(isDarkTheme()));
|
||||
u.searchParams.set('gitea-iframe-id', iframe.id);
|
||||
iframe.src = u.href;
|
||||
}
|
||||
u.searchParams.set('gitea-iframe-bgcolor', getRealBackgroundColor(iframe));
|
||||
|
||||
export function initMarkupRenderIframe(el: HTMLElement) {
|
||||
queryElemChildren(el, 'iframe.external-render-iframe', loadRenderIframeContent);
|
||||
// It must use "srcdoc" here, because our backend always sends CSP sandbox directive for the rendered content
|
||||
// (to protect from XSS risks), so we can't use "src" to load the content directly, otherwise there will be console errors like:
|
||||
// Unsafe attempt to load URL http://localhost:3000/test from frame with URL http://localhost:3000/test
|
||||
const resp = await GET(u.href);
|
||||
iframe.srcdoc = await resp.text();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import {toggleTasklistCheckbox} from './tasklist.ts';
|
||||
|
||||
test('toggleTasklistCheckbox', () => {
|
||||
expect(toggleTasklistCheckbox('- [ ] task', 3, true)).toEqual('- [x] task');
|
||||
expect(toggleTasklistCheckbox('- [x] task', 3, false)).toEqual('- [ ] task');
|
||||
expect(toggleTasklistCheckbox('- [ ] task', 0, true)).toBeNull();
|
||||
expect(toggleTasklistCheckbox('- [ ] task', 99, true)).toBeNull();
|
||||
expect(toggleTasklistCheckbox('😀 - [ ] task', 8, true)).toEqual('😀 - [x] task');
|
||||
});
|
||||
@@ -3,6 +3,23 @@ import {showErrorToast} from '../modules/toast.ts';
|
||||
|
||||
const preventListener = (e: Event) => e.preventDefault();
|
||||
|
||||
/**
|
||||
* Toggle a task list checkbox in markdown content.
|
||||
* `position` is the byte offset of the space or `x` character inside `[ ]`.
|
||||
* Returns the updated content, or null if the position is invalid.
|
||||
*/
|
||||
export function toggleTasklistCheckbox(content: string, position: number, checked: boolean): string | null {
|
||||
const buffer = new TextEncoder().encode(content);
|
||||
// Indexes may fall off the ends and return undefined.
|
||||
if (buffer[position - 1] !== '['.charCodeAt(0) ||
|
||||
buffer[position] !== ' '.charCodeAt(0) && buffer[position] !== 'x'.charCodeAt(0) ||
|
||||
buffer[position + 1] !== ']'.charCodeAt(0)) {
|
||||
return null;
|
||||
}
|
||||
buffer[position] = checked ? 'x'.charCodeAt(0) : ' '.charCodeAt(0);
|
||||
return new TextDecoder().decode(buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches `input` handlers to markdown rendered tasklist checkboxes in comments.
|
||||
*
|
||||
@@ -13,7 +30,7 @@ const preventListener = (e: Event) => e.preventDefault();
|
||||
export function initMarkupTasklist(elMarkup: HTMLElement): void {
|
||||
if (!elMarkup.matches('[data-can-edit=true]')) return;
|
||||
|
||||
const container = elMarkup.parentNode;
|
||||
const container = elMarkup.parentNode!;
|
||||
const checkboxes = elMarkup.querySelectorAll<HTMLInputElement>(`.task-list-item input[type=checkbox]`);
|
||||
|
||||
for (const checkbox of checkboxes) {
|
||||
@@ -23,24 +40,17 @@ export function initMarkupTasklist(elMarkup: HTMLElement): void {
|
||||
|
||||
checkbox.setAttribute('data-editable', 'true');
|
||||
checkbox.addEventListener('input', async () => {
|
||||
const checkboxCharacter = checkbox.checked ? 'x' : ' ';
|
||||
const position = parseInt(checkbox.getAttribute('data-source-position')) + 1;
|
||||
const position = parseInt(checkbox.getAttribute('data-source-position')!) + 1;
|
||||
|
||||
const rawContent = container.querySelector('.raw-content');
|
||||
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 + 1] !== ']'.codePointAt(0)) {
|
||||
// Position is probably wrong. Revert and don't allow change.
|
||||
const newContent = toggleTasklistCheckbox(oldContent, position, checkbox.checked);
|
||||
if (newContent === null) {
|
||||
// Position is probably wrong. Revert and don't allow change.
|
||||
checkbox.checked = !checkbox.checked;
|
||||
throw new Error(`Expected position to be space 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;
|
||||
@@ -53,10 +63,10 @@ export function initMarkupTasklist(elMarkup: HTMLElement): void {
|
||||
}
|
||||
|
||||
try {
|
||||
const editContentZone = container.querySelector<HTMLDivElement>('.edit-content-zone');
|
||||
const updateUrl = editContentZone.getAttribute('data-update-url');
|
||||
const context = editContentZone.getAttribute('data-context');
|
||||
const contentVersion = editContentZone.getAttribute('data-content-version');
|
||||
const editContentZone = container.querySelector<HTMLDivElement>('.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');
|
||||
|
||||
Reference in New Issue
Block a user