Path: blob/main/extensions/markdown-language-features/preview-src/index.ts
5241 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();2425// eslint-disable-next-line local/code-no-any-casts26const originalState = vscode.getState() ?? {} as any;27const state = {28...originalState,29...getData<any>('data-state')30};3132if (typeof originalState.scrollProgress !== 'undefined' && originalState?.resource !== state.resource) {33state.scrollProgress = 0;34}3536// Make sure to sync VS Code state here37vscode.setState(state);3839const messaging = createPosterForVsCode(vscode, settings);4041window.cspAlerter.setPoster(messaging);42window.styleLoadingMonitor.setPoster(messaging);434445function doAfterImagesLoaded(cb: () => void) {46const imgElements = document.getElementsByTagName('img');47if (imgElements.length > 0) {48const ps = Array.from(imgElements, e => {49if (e.complete) {50return Promise.resolve();51} else {52return new Promise<void>((resolve) => {53e.addEventListener('load', () => resolve());54e.addEventListener('error', () => resolve());55});56}57});58Promise.all(ps).then(() => setTimeout(cb, 0));59} else {60setTimeout(cb, 0);61}62}6364onceDocumentLoaded(() => {65// Load initial html66const htmlParser = new DOMParser();67const markDownHtml = htmlParser.parseFromString(68getRawData('data-initial-md-content'),69'text/html'70);7172const newElements = [...markDownHtml.body.children];73document.body.append(...newElements);74for (const el of newElements) {75if (el instanceof HTMLElement) {76domEval(el);77}78}7980// Restore81const scrollProgress = state.scrollProgress;82addImageContexts();83if (typeof scrollProgress === 'number' && !settings.settings.fragment) {84doAfterImagesLoaded(() => {85scrollDisabledCount += 1;86// Always set scroll of at least 1 to prevent VS Code's webview code from auto scrolling us87const scrollToY = Math.max(1, scrollProgress * document.body.clientHeight);88window.scrollTo(0, scrollToY);89});90return;91}9293if (settings.settings.scrollPreviewWithEditor) {94doAfterImagesLoaded(() => {95// Try to scroll to fragment if available96if (settings.settings.fragment) {97let fragment: string;98try {99fragment = encodeURIComponent(settings.settings.fragment);100} catch {101fragment = settings.settings.fragment;102}103state.fragment = undefined;104vscode.setState(state);105106const element = getLineElementForFragment(fragment, documentVersion);107if (element) {108scrollDisabledCount += 1;109scrollToRevealSourceLine(element.line, documentVersion, settings);110}111} else {112if (!isNaN(settings.settings.line!)) {113scrollDisabledCount += 1;114scrollToRevealSourceLine(settings.settings.line!, documentVersion, settings);115}116}117});118}119120if (typeof settings.settings.selectedLine === 'number') {121marker.onDidChangeTextEditorSelection(settings.settings.selectedLine, documentVersion);122}123});124125const onUpdateView = (() => {126const doScroll = throttle((line: number) => {127scrollDisabledCount += 1;128doAfterImagesLoaded(() => scrollToRevealSourceLine(line, documentVersion, settings));129}, 50);130131return (line: number) => {132if (!isNaN(line)) {133state.line = line;134135doScroll(line);136}137};138})();139140window.addEventListener('resize', () => {141scrollDisabledCount += 1;142updateScrollProgress();143}, true);144145function addImageContexts() {146const images = document.getElementsByTagName('img');147let idNumber = 0;148for (const img of images) {149img.id = 'image-' + idNumber;150idNumber += 1;151const imageSource = img.getAttribute('data-src');152const isLocalFile = imageSource && !(isOfScheme(Schemes.http, imageSource) || isOfScheme(Schemes.https, imageSource));153const webviewSection = isLocalFile ? 'localImage' : 'image';154img.setAttribute('data-vscode-context', JSON.stringify({ webviewSection, id: img.id, 'preventDefaultContextMenuItems': true, resource: documentResource, imageSource }));155}156}157158async function copyImage(image: HTMLImageElement, retries = 5) {159if (!document.hasFocus() && retries > 0) {160// copyImage is called at the same time as webview.reveal, which means this function is running whilst the webview is gaining focus.161// Since navigator.clipboard.write requires the document to be focused, we need to wait for focus.162// 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.163setTimeout(() => { copyImage(image, retries - 1); }, 20);164return;165}166167try {168await navigator.clipboard.write([new ClipboardItem({169'image/png': new Promise((resolve) => {170const canvas = document.createElement('canvas');171if (canvas !== null) {172canvas.width = image.naturalWidth;173canvas.height = image.naturalHeight;174const context = canvas.getContext('2d');175context?.drawImage(image, 0, 0);176}177canvas.toBlob((blob) => {178if (blob) {179resolve(blob);180}181canvas.remove();182}, 'image/png');183})184})]);185} catch (e) {186console.error(e);187const selection = window.getSelection();188if (!selection) {189await navigator.clipboard.writeText(image.getAttribute('data-src') ?? image.src);190return;191}192selection.removeAllRanges();193const range = document.createRange();194range.selectNode(image);195selection.addRange(range);196document.execCommand('copy');197selection.removeAllRanges();198}199}200201window.addEventListener('message', async event => {202const data = event.data as ToWebviewMessage.Type;203switch (data.type) {204case 'copyImage': {205const img = document.getElementById(data.id);206if (img instanceof HTMLImageElement) {207copyImage(img);208}209return;210}211case 'onDidChangeTextEditorSelection':212if (data.source === documentResource) {213marker.onDidChangeTextEditorSelection(data.line, documentVersion);214}215return;216217case 'updateView':218if (data.source === documentResource) {219onUpdateView(data.line);220}221return;222223case 'updateContent': {224const root = document.querySelector('.markdown-body')!;225226const parser = new DOMParser();227const 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 safe228229// Strip out meta http-equiv tags230for (const metaElement of Array.from(newContent.querySelectorAll('meta'))) {231if (metaElement.hasAttribute('http-equiv')) {232metaElement.remove();233}234}235236if (data.source !== documentResource) {237documentResource = data.source;238const newBody = newContent.querySelector('.markdown-body')!;239root.replaceWith(newBody);240domEval(newBody);241} else {242const newRoot = newContent.querySelector('.markdown-body')!;243244// Move styles to head245// This prevents an ugly flash of unstyled content246const styles = newRoot.querySelectorAll('link');247for (const style of styles) {248style.remove();249}250newRoot.prepend(...styles);251252// eslint-disable-next-line local/code-no-any-casts253morphdom(root, newRoot, {254childrenOnly: true,255onBeforeElUpdated: (fromEl: Element, toEl: Element) => {256if (areNodesEqual(fromEl, toEl)) {257// areEqual doesn't look at `data-line` so copy those over manually258const fromLines = fromEl.querySelectorAll('[data-line]');259const toLines = toEl.querySelectorAll('[data-line]');260if (fromLines.length !== toLines.length) {261console.log('unexpected line number change');262}263264for (let i = 0; i < fromLines.length; ++i) {265const fromChild = fromLines[i];266const toChild = toLines[i];267if (toChild) {268fromChild.setAttribute('data-line', toChild.getAttribute('data-line')!);269}270}271272return false;273}274275if (fromEl.tagName === 'DETAILS' && toEl.tagName === 'DETAILS') {276if (fromEl.hasAttribute('open')) {277toEl.setAttribute('open', '');278}279}280281return true;282},283addChild: (parentNode: Node, childNode: Node) => {284parentNode.appendChild(childNode);285if (childNode instanceof HTMLElement) {286domEval(childNode);287}288}289} as any);290}291292++documentVersion;293294window.dispatchEvent(new CustomEvent('vscode.markdown.updateContent'));295addImageContexts();296break;297}298}299}, false);300301302303document.addEventListener('dblclick', event => {304if (!settings.settings.doubleClickToSwitchToEditor) {305return;306}307308// Disable double-click to switch editor for .copilotmd files309if (documentResource.endsWith('.copilotmd')) {310return;311}312313// Ignore clicks on links314for (let node = event.target as HTMLElement; node; node = node.parentNode as HTMLElement) {315if (node.tagName === 'A') {316return;317}318}319320const offset = event.pageY;321const line = getEditorLineNumberForPageOffset(offset, documentVersion);322if (typeof line === 'number' && !isNaN(line)) {323messaging.postMessage('didClick', { line: Math.floor(line) });324}325});326327const passThroughLinkSchemes = ['http:', 'https:', 'mailto:', 'vscode:', 'vscode-insiders:'];328329document.addEventListener('click', event => {330if (!event) {331return;332}333334let node: any = event.target;335while (node) {336if (node.tagName && node.tagName === 'A' && node.href) {337if (node.getAttribute('href').startsWith('#')) {338return;339}340341let hrefText = node.getAttribute('data-href');342if (!hrefText) {343hrefText = node.getAttribute('href');344// Pass through known schemes345if (passThroughLinkSchemes.some(scheme => hrefText.startsWith(scheme))) {346return;347}348}349350// If original link doesn't look like a url, delegate back to VS Code to resolve351if (!/^[a-z\-]+:/i.test(hrefText)) {352messaging.postMessage('openLink', { href: hrefText });353event.preventDefault();354event.stopPropagation();355return;356}357358return;359}360node = node.parentNode;361}362}, true);363364window.addEventListener('scroll', throttle(() => {365updateScrollProgress();366367if (scrollDisabledCount > 0) {368scrollDisabledCount -= 1;369} else {370const line = getEditorLineNumberForPageOffset(window.scrollY, documentVersion);371if (typeof line === 'number' && !isNaN(line)) {372messaging.postMessage('revealLine', { line });373}374}375}, 50));376377function updateScrollProgress() {378state.scrollProgress = window.scrollY / document.body.clientHeight;379vscode.setState(state);380}381382383/**384* Compares two nodes for morphdom to see if they are equal.385*386* This skips some attributes that should not cause equality to fail.387*/388function areNodesEqual(a: Element, b: Element): boolean {389const skippedAttrs = [390'open', // for details391];392393if (a.isEqualNode(b)) {394return true;395}396397if (a.tagName !== b.tagName || a.textContent !== b.textContent) {398return false;399}400401const aAttrs = [...a.attributes].filter(attr => !skippedAttrs.includes(attr.name));402const bAttrs = [...b.attributes].filter(attr => !skippedAttrs.includes(attr.name));403if (aAttrs.length !== bAttrs.length) {404return false;405}406407for (let i = 0; i < aAttrs.length; ++i) {408const aAttr = aAttrs[i];409const bAttr = bAttrs[i];410if (aAttr.name !== bAttr.name) {411return false;412}413if (aAttr.value !== bAttr.value && aAttr.name !== 'data-line') {414return false;415}416}417418const aChildren = Array.from(a.children);419const bChildren = Array.from(b.children);420421return aChildren.length === bChildren.length && aChildren.every((x, i) => areNodesEqual(x, bChildren[i]));422}423424425function domEval(el: Element): void {426const preservedScriptAttributes: (keyof HTMLScriptElement)[] = [427'type', 'src', 'nonce', 'noModule', 'async',428];429430const scriptNodes = el.tagName === 'SCRIPT' ? [el] : Array.from(el.getElementsByTagName('script'));431432for (const node of scriptNodes) {433if (!(node instanceof HTMLElement)) {434continue;435}436437const scriptTag = document.createElement('script');438const trustedScript = node.innerText;439scriptTag.text = trustedScript as string;440for (const key of preservedScriptAttributes) {441const val = node.getAttribute?.(key);442if (val) {443// eslint-disable-next-line local/code-no-any-casts444scriptTag.setAttribute(key, val as any);445}446}447448node.insertAdjacentElement('afterend', scriptTag);449node.remove();450}451}452453454