diff --git a/web_src/css/base.css b/web_src/css/base.css index 4dc19d9a5b..c0caee5191 100644 --- a/web_src/css/base.css +++ b/web_src/css/base.css @@ -30,6 +30,7 @@ --page-spacing: 16px; /* space between page elements */ --page-margin-x: 32px; /* minimum space on left and right side of page */ --page-space-bottom: 64px; /* space between last page element and footer */ + --transition-hover-fade: opacity 0.2s ease; /* fade transition for elements that show on hover */ /* z-index */ --z-index-modal: 1001; /* modal dialog, hard-coded from Fomantic modal.css */ diff --git a/web_src/css/markup/codecopy.css b/web_src/css/markup/codecopy.css index 5a7b9955e7..c48f641f68 100644 --- a/web_src/css/markup/codecopy.css +++ b/web_src/css/markup/codecopy.css @@ -3,8 +3,9 @@ top: 8px; right: 6px; padding: 9px; - visibility: hidden; - animation: fadeout 0.2s both; + visibility: hidden; /* prevent from click events even opacity=0 */ + opacity: 0; + transition: var(--transition-hover-fade); } /* adjustments for comment content having only 14px font size */ @@ -23,8 +24,17 @@ background: var(--color-secondary-dark-1) !important; } +/* all rendered code-block elements are in their container, +the manually written code-block elements on "packages" pages don't have the container */ .markup .code-block-container:hover .code-copy, .markup .code-block:hover .code-copy { visibility: visible; - animation: fadein 0.2s both; + opacity: 1; +} + +@media (hover: none) { + .markup .code-copy { + visibility: visible; + opacity: 1; + } } diff --git a/web_src/css/modules/animations.css b/web_src/css/modules/animations.css index aedf53569a..65d7a90e97 100644 --- a/web_src/css/modules/animations.css +++ b/web_src/css/modules/animations.css @@ -82,15 +82,6 @@ code.language-math.is-loading::after { } } -@keyframes fadeout { - 0% { - opacity: 1; - } - 100% { - opacity: 0; - } -} - /* 1p5 means 1-point-5. it can't use "pulse" here, otherwise the animation is not right (maybe due to some conflicts */ @keyframes pulse-1p5 { 0% { diff --git a/web_src/js/markup/mermaid.ts b/web_src/js/markup/mermaid.ts index 5d37c81b8f..cd62990ffd 100644 --- a/web_src/js/markup/mermaid.ts +++ b/web_src/js/markup/mermaid.ts @@ -1,16 +1,56 @@ import {isDarkTheme, parseDom} from '../utils.ts'; import {makeCodeCopyButton} from './codecopy.ts'; import {displayError} from './common.ts'; -import {createElementFromAttrs, queryElems} from '../utils/dom.ts'; -import {html, htmlRaw} from '../utils/html.ts'; +import {createElementFromAttrs, createElementFromHTML, getCssRootVariablesText, queryElems} from '../utils/dom.ts'; +import {html} from '../utils/html.ts'; import {load as loadYaml} from 'js-yaml'; import type {MermaidConfig} from 'mermaid'; const {mermaidMaxSourceCharacters} = window.config; -const iframeCss = `:root {color-scheme: normal} -body {margin: 0; padding: 0; overflow: hidden} -#mermaid {display: block; margin: 0 auto}`; +function getIframeCss(): string { + // Inherit some styles (e.g.: root variables) from parent document. + // The buttons should use the same styles as `button.code-copy`, and align with it. + return ` +${getCssRootVariablesText()} + +html, body { height: 100%; } +body { margin: 0; padding: 0; overflow: hidden; } +#mermaid { display: block; margin: 0 auto; } + +.view-controller { + position: absolute; + z-index: 1; + right: 5px; + bottom: 5px; + display: flex; + gap: 4px; + visibility: hidden; + opacity: 0; + transition: var(--transition-hover-fade); + margin-right: 0.25em; +} +body:hover .view-controller { visibility: visible; opacity: 1; } +@media (hover: none) { + .view-controller { visibility: visible; opacity: 1; } +} +.view-controller button { + cursor: pointer; + display: inline-flex; + justify-content: center; + align-items: center; + line-height: 1; + padding: 7.5px 10px; + border: 1px solid var(--color-light-border); + border-radius: var(--border-radius); + background: var(--color-button); + color: var(--color-text); + user-select: none; +} +.view-controller button:hover { background: var(--color-secondary); } +.view-controller button:active { background: var(--color-secondary-dark-1); } +`; +} function isSourceTooLarge(source: string) { return mermaidMaxSourceCharacters >= 0 && source.length > mermaidMaxSourceCharacters; @@ -77,6 +117,76 @@ async function loadMermaid(needElkRender: boolean) { }; } +function initMermaidViewController(dragElement: SVGSVGElement) { + let inited = false, isDragging = false; + let currentScale = 1, initLeft = 0, lastLeft = 0, lastTop = 0, lastPageX = 0, lastPageY = 0; + const container = dragElement.parentElement!; + + 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; + initLeft = container.getBoundingClientRect().width / 2 - dragElement.getBoundingClientRect().width / 2; + resetView(); + }; + + for (const el of queryElems(container, '[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; + if (target.closest('div, p, a, span, button, input')) return; // don't start the drag if the click is on an interactive element (e.g.: link, button) or text element + + 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 { @@ -107,6 +217,13 @@ export async function initMarkupCodeMermaid(elMarkup: HTMLElement): Promise { + 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) { @@ -122,27 +239,37 @@ export async function initMarkupCodeMermaid(elMarkup: HTMLElement): Promise`; + // 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', 'is-loading'); - iframe.srcdoc = html``; + // the styles are not ready, so don't really render anything before the "load" event, to avoid flicker of unstyled content + iframe.srcdoc = html``; // 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); - if (iframeHeightFromViewBox) iframe.style.height = `${iframeHeightFromViewBox}px`; + 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', () => { - // same origin, so we can operate "iframe body" and all elements directly + // 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); + const iframeBody = iframe.contentDocument!.body; iframeBody.append(svgNode); bindFunctions?.(iframeBody); // follow "mermaid.render" doc, attach event handlers to the svg's container + iframeBody.append(createElementFromHTML(viewControllerHtml)); // 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 && iframeBody.clientHeight) iframe.style.height = `${iframeBody.clientHeight}px`; + if (!iframeHeightFromViewBox) applyMermaidIframeHeight(iframe, iframeBody.clientHeight); iframe.classList.remove('is-loading'); + + initMermaidViewController(svgNode); }); const container = createElementFromAttrs('div', {class: 'mermaid-block'}, iframe, makeCodeCopyButton({'data-clipboard-text': source})); diff --git a/web_src/js/utils/dom.ts b/web_src/js/utils/dom.ts index dc504b7056..12b984a9d5 100644 --- a/web_src/js/utils/dom.ts +++ b/web_src/js/utils/dom.ts @@ -352,6 +352,22 @@ export function isPlainClick(e: MouseEvent) { return e.button === 0 && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey; } +let cssRootVariablesTextCache: string = ''; +export function getCssRootVariablesText(): string { + if (cssRootVariablesTextCache) return cssRootVariablesTextCache; + const style = getComputedStyle(document.documentElement); + let text = ':root {\n'; + for (let i = 0; i < style.length; i++) { + const name = style.item(i); + if (name.startsWith('--')) { + text += ` ${name}: ${style.getPropertyValue(name)};\n`; + } + } + text += '}\n'; + cssRootVariablesTextCache = text; + return text; +} + let elemIdCounter = 0; export function generateElemId(prefix: string = ''): string { return `${prefix}${elemIdCounter++}`;