Path: blob/main/extensions/markdown-language-features/preview-src/index.ts
3292 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { ActiveLineMarker } from './activeLineMarker';6import { onceDocumentLoaded } from './events';7import { createPosterForVsCode } from './messaging';8import { getEditorLineNumberForPageOffset, scrollToRevealSourceLine, getLineElementForFragment } from './scroll-sync';9import { SettingsManager, getData, getRawData } from './settings';10import throttle = require('lodash.throttle');11import morphdom from 'morphdom';12import type { ToWebviewMessage } from '../types/previewMessaging';13import { isOfScheme, Schemes } from '../src/util/schemes';1415let scrollDisabledCount = 0;1617const marker = new ActiveLineMarker();18const settings = new SettingsManager();1920let documentVersion = 0;21let documentResource = settings.settings.source;2223const vscode = acquireVsCodeApi();2425const originalState = vscode.getState() ?? {} as any;26const state = {27...originalState,28...getData<any>('data-state')29};3031if (typeof originalState.scrollProgress !== 'undefined' && originalState?.resource !== state.resource) {32state.scrollProgress = 0;33}3435// Make sure to sync VS Code state here36vscode.setState(state);3738const messaging = createPosterForVsCode(vscode, settings);3940window.cspAlerter.setPoster(messaging);41window.styleLoadingMonitor.setPoster(messaging);424344function doAfterImagesLoaded(cb: () => void) {45const imgElements = document.getElementsByTagName('img');46if (imgElements.length > 0) {47const ps = Array.from(imgElements, e => {48if (e.complete) {49return Promise.resolve();50} else {51return new Promise<void>((resolve) => {52e.addEventListener('load', () => resolve());53e.addEventListener('error', () => resolve());54});55}56});57Promise.all(ps).then(() => setTimeout(cb, 0));58} else {59setTimeout(cb, 0);60}61}6263onceDocumentLoaded(() => {64// Load initial html65const htmlParser = new DOMParser();66const markDownHtml = htmlParser.parseFromString(67getRawData('data-initial-md-content'),68'text/html'69);7071const newElements = [...markDownHtml.body.children];72document.body.append(...newElements);73for (const el of newElements) {74if (el instanceof HTMLElement) {75domEval(el);76}77}7879// Restore80const scrollProgress = state.scrollProgress;81addImageContexts();82if (typeof scrollProgress === 'number' && !settings.settings.fragment) {83doAfterImagesLoaded(() => {84scrollDisabledCount += 1;85// Always set scroll of at least 1 to prevent VS Code's webview code from auto scrolling us86const scrollToY = Math.max(1, scrollProgress * document.body.clientHeight);87window.scrollTo(0, scrollToY);88});89return;90}9192if (settings.settings.scrollPreviewWithEditor) {93doAfterImagesLoaded(() => {94// Try to scroll to fragment if available95if (settings.settings.fragment) {96let fragment: string;97try {98fragment = encodeURIComponent(settings.settings.fragment);99} catch {100fragment = settings.settings.fragment;101}102state.fragment = undefined;103vscode.setState(state);104105const element = getLineElementForFragment(fragment, documentVersion);106if (element) {107scrollDisabledCount += 1;108scrollToRevealSourceLine(element.line, documentVersion, settings);109}110} else {111if (!isNaN(settings.settings.line!)) {112scrollDisabledCount += 1;113scrollToRevealSourceLine(settings.settings.line!, documentVersion, settings);114}115}116});117}118119if (typeof settings.settings.selectedLine === 'number') {120marker.onDidChangeTextEditorSelection(settings.settings.selectedLine, documentVersion);121}122});123124const onUpdateView = (() => {125const doScroll = throttle((line: number) => {126scrollDisabledCount += 1;127doAfterImagesLoaded(() => scrollToRevealSourceLine(line, documentVersion, settings));128}, 50);129130return (line: number) => {131if (!isNaN(line)) {132state.line = line;133134doScroll(line);135}136};137})();138139window.addEventListener('resize', () => {140scrollDisabledCount += 1;141updateScrollProgress();142}, true);143144function addImageContexts() {145const images = document.getElementsByTagName('img');146let idNumber = 0;147for (const img of images) {148img.id = 'image-' + idNumber;149idNumber += 1;150const imageSource = img.getAttribute('data-src');151const isLocalFile = imageSource && !(isOfScheme(Schemes.http, imageSource) || isOfScheme(Schemes.https, imageSource));152const webviewSection = isLocalFile ? 'localImage' : 'image';153img.setAttribute('data-vscode-context', JSON.stringify({ webviewSection, id: img.id, 'preventDefaultContextMenuItems': true, resource: documentResource, imageSource }));154}155}156157async function copyImage(image: HTMLImageElement, retries = 5) {158if (!document.hasFocus() && retries > 0) {159// copyImage is called at the same time as webview.reveal, which means this function is running whilst the webview is gaining focus.160// Since navigator.clipboard.write requires the document to be focused, we need to wait for focus.161// We cannot use a listener, as there is a high chance the focus is gained during the setup of the listener resulting in us missing it.162setTimeout(() => { copyImage(image, retries - 1); }, 20);163return;164}165166try {167await navigator.clipboard.write([new ClipboardItem({168'image/png': new Promise((resolve) => {169const canvas = document.createElement('canvas');170if (canvas !== null) {171canvas.width = image.naturalWidth;172canvas.height = image.naturalHeight;173const context = canvas.getContext('2d');174context?.drawImage(image, 0, 0);175}176canvas.toBlob((blob) => {177if (blob) {178resolve(blob);179}180canvas.remove();181}, 'image/png');182})183})]);184} catch (e) {185console.error(e);186const selection = window.getSelection();187if (!selection) {188await navigator.clipboard.writeText(image.getAttribute('data-src') ?? image.src);189return;190}191selection.removeAllRanges();192const range = document.createRange();193range.selectNode(image);194selection.addRange(range);195document.execCommand('copy');196selection.removeAllRanges();197}198}199200window.addEventListener('message', async event => {201const data = event.data as ToWebviewMessage.Type;202switch (data.type) {203case 'copyImage': {204const img = document.getElementById(data.id);205if (img instanceof HTMLImageElement) {206copyImage(img);207}208return;209}210case 'onDidChangeTextEditorSelection':211if (data.source === documentResource) {212marker.onDidChangeTextEditorSelection(data.line, documentVersion);213}214return;215216case 'updateView':217if (data.source === documentResource) {218onUpdateView(data.line);219}220return;221222case 'updateContent': {223const root = document.querySelector('.markdown-body')!;224225const parser = new DOMParser();226const newContent = parser.parseFromString(data.content, 'text/html'); // CodeQL [SM03712] This renderers content from the workspace into the Markdown preview. Webviews (and the markdown preview) have many other security measures in place to make this safe227228// Strip out meta http-equiv tags229for (const metaElement of Array.from(newContent.querySelectorAll('meta'))) {230if (metaElement.hasAttribute('http-equiv')) {231metaElement.remove();232}233}234235if (data.source !== documentResource) {236documentResource = data.source;237const newBody = newContent.querySelector('.markdown-body')!;238root.replaceWith(newBody);239domEval(newBody);240} else {241const newRoot = newContent.querySelector('.markdown-body')!;242243// Move styles to head244// This prevents an ugly flash of unstyled content245const styles = newRoot.querySelectorAll('link');246for (const style of styles) {247style.remove();248}249newRoot.prepend(...styles);250251morphdom(root, newRoot, {252childrenOnly: true,253onBeforeElUpdated: (fromEl: Element, toEl: Element) => {254if (areNodesEqual(fromEl, toEl)) {255// areEqual doesn't look at `data-line` so copy those over manually256const fromLines = fromEl.querySelectorAll('[data-line]');257const toLines = toEl.querySelectorAll('[data-line]');258if (fromLines.length !== toLines.length) {259console.log('unexpected line number change');260}261262for (let i = 0; i < fromLines.length; ++i) {263const fromChild = fromLines[i];264const toChild = toLines[i];265if (toChild) {266fromChild.setAttribute('data-line', toChild.getAttribute('data-line')!);267}268}269270return false;271}272273if (fromEl.tagName === 'DETAILS' && toEl.tagName === 'DETAILS') {274if (fromEl.hasAttribute('open')) {275toEl.setAttribute('open', '');276}277}278279return true;280},281addChild: (parentNode: Node, childNode: Node) => {282parentNode.appendChild(childNode);283if (childNode instanceof HTMLElement) {284domEval(childNode);285}286}287} as any);288}289290++documentVersion;291292window.dispatchEvent(new CustomEvent('vscode.markdown.updateContent'));293addImageContexts();294break;295}296}297}, false);298299300301document.addEventListener('dblclick', event => {302if (!settings.settings.doubleClickToSwitchToEditor) {303return;304}305306// Ignore clicks on links307for (let node = event.target as HTMLElement; node; node = node.parentNode as HTMLElement) {308if (node.tagName === 'A') {309return;310}311}312313const offset = event.pageY;314const line = getEditorLineNumberForPageOffset(offset, documentVersion);315if (typeof line === 'number' && !isNaN(line)) {316messaging.postMessage('didClick', { line: Math.floor(line) });317}318});319320const passThroughLinkSchemes = ['http:', 'https:', 'mailto:', 'vscode:', 'vscode-insiders:'];321322document.addEventListener('click', event => {323if (!event) {324return;325}326327let node: any = event.target;328while (node) {329if (node.tagName && node.tagName === 'A' && node.href) {330if (node.getAttribute('href').startsWith('#')) {331return;332}333334let hrefText = node.getAttribute('data-href');335if (!hrefText) {336hrefText = node.getAttribute('href');337// Pass through known schemes338if (passThroughLinkSchemes.some(scheme => hrefText.startsWith(scheme))) {339return;340}341}342343// If original link doesn't look like a url, delegate back to VS Code to resolve344if (!/^[a-z\-]+:/i.test(hrefText)) {345messaging.postMessage('openLink', { href: hrefText });346event.preventDefault();347event.stopPropagation();348return;349}350351return;352}353node = node.parentNode;354}355}, true);356357window.addEventListener('scroll', throttle(() => {358updateScrollProgress();359360if (scrollDisabledCount > 0) {361scrollDisabledCount -= 1;362} else {363const line = getEditorLineNumberForPageOffset(window.scrollY, documentVersion);364if (typeof line === 'number' && !isNaN(line)) {365messaging.postMessage('revealLine', { line });366}367}368}, 50));369370function updateScrollProgress() {371state.scrollProgress = window.scrollY / document.body.clientHeight;372vscode.setState(state);373}374375376/**377* Compares two nodes for morphdom to see if they are equal.378*379* This skips some attributes that should not cause equality to fail.380*/381function areNodesEqual(a: Element, b: Element): boolean {382const skippedAttrs = [383'open', // for details384];385386if (a.isEqualNode(b)) {387return true;388}389390if (a.tagName !== b.tagName || a.textContent !== b.textContent) {391return false;392}393394const aAttrs = [...a.attributes].filter(attr => !skippedAttrs.includes(attr.name));395const bAttrs = [...b.attributes].filter(attr => !skippedAttrs.includes(attr.name));396if (aAttrs.length !== bAttrs.length) {397return false;398}399400for (let i = 0; i < aAttrs.length; ++i) {401const aAttr = aAttrs[i];402const bAttr = bAttrs[i];403if (aAttr.name !== bAttr.name) {404return false;405}406if (aAttr.value !== bAttr.value && aAttr.name !== 'data-line') {407return false;408}409}410411const aChildren = Array.from(a.children);412const bChildren = Array.from(b.children);413414return aChildren.length === bChildren.length && aChildren.every((x, i) => areNodesEqual(x, bChildren[i]));415}416417418function domEval(el: Element): void {419const preservedScriptAttributes: (keyof HTMLScriptElement)[] = [420'type', 'src', 'nonce', 'noModule', 'async',421];422423const scriptNodes = el.tagName === 'SCRIPT' ? [el] : Array.from(el.getElementsByTagName('script'));424425for (const node of scriptNodes) {426if (!(node instanceof HTMLElement)) {427continue;428}429430const scriptTag = document.createElement('script');431const trustedScript = node.innerText;432scriptTag.text = trustedScript as string;433for (const key of preservedScriptAttributes) {434const val = node.getAttribute?.(key);435if (val) {436scriptTag.setAttribute(key, val as any);437}438}439440node.insertAdjacentElement('afterend', scriptTag);441node.remove();442}443}444445446