2024-09-11 02:37:36 +00:00
/ * - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
* Copyright ( c ) Microsoft Corporation . All rights reserved .
* Licensed under the MIT License . See License . txt in the project root for license information .
* -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- * /
import { ActiveLineMarker } from './activeLineMarker' ;
import { onceDocumentLoaded } from './events' ;
import { createPosterForVsCode } from './messaging' ;
import { getEditorLineNumberForPageOffset , scrollToRevealSourceLine , getLineElementForFragment } from './scroll-sync' ;
2025-03-01 02:01:53 +00:00
import { SettingsManager , getData , getRawData } from './settings' ;
2024-09-11 02:37:36 +00:00
import throttle = require ( 'lodash.throttle' ) ;
import morphdom from 'morphdom' ;
import type { ToWebviewMessage } from '../types/previewMessaging' ;
2025-03-01 02:01:53 +00:00
import { isOfScheme , Schemes } from '../src/util/schemes' ;
2024-09-11 02:37:36 +00:00
let scrollDisabledCount = 0 ;
const marker = new ActiveLineMarker ( ) ;
const settings = new SettingsManager ( ) ;
let documentVersion = 0 ;
let documentResource = settings . settings . source ;
const vscode = acquireVsCodeApi ( ) ;
const originalState = vscode . getState ( ) ? ? { } as any ;
const state = {
. . . originalState ,
. . . getData < any > ( 'data-state' )
} ;
if ( typeof originalState . scrollProgress !== 'undefined' && originalState ? . resource !== state . resource ) {
state . scrollProgress = 0 ;
}
// Make sure to sync VS Code state here
vscode . setState ( state ) ;
const messaging = createPosterForVsCode ( vscode , settings ) ;
window . cspAlerter . setPoster ( messaging ) ;
window . styleLoadingMonitor . setPoster ( messaging ) ;
function doAfterImagesLoaded ( cb : ( ) = > void ) {
const imgElements = document . getElementsByTagName ( 'img' ) ;
if ( imgElements . length > 0 ) {
const ps = Array . from ( imgElements , e = > {
if ( e . complete ) {
return Promise . resolve ( ) ;
} else {
return new Promise < void > ( ( resolve ) = > {
e . addEventListener ( 'load' , ( ) = > resolve ( ) ) ;
e . addEventListener ( 'error' , ( ) = > resolve ( ) ) ;
} ) ;
}
} ) ;
Promise . all ( ps ) . then ( ( ) = > setTimeout ( cb , 0 ) ) ;
} else {
setTimeout ( cb , 0 ) ;
}
}
onceDocumentLoaded ( ( ) = > {
2025-03-01 02:01:53 +00:00
// Load initial html
const htmlParser = new DOMParser ( ) ;
const markDownHtml = htmlParser . parseFromString (
getRawData ( 'data-initial-md-content' ) ,
'text/html'
) ;
2025-04-29 07:07:20 +00:00
const newElements = [ . . . markDownHtml . body . children ] ;
document . body . append ( . . . newElements ) ;
for ( const el of newElements ) {
if ( el instanceof HTMLElement ) {
domEval ( el ) ;
}
}
2025-03-01 02:01:53 +00:00
// Restore
2024-09-11 02:37:36 +00:00
const scrollProgress = state . scrollProgress ;
addImageContexts ( ) ;
if ( typeof scrollProgress === 'number' && ! settings . settings . fragment ) {
doAfterImagesLoaded ( ( ) = > {
scrollDisabledCount += 1 ;
// Always set scroll of at least 1 to prevent VS Code's webview code from auto scrolling us
const scrollToY = Math . max ( 1 , scrollProgress * document . body . clientHeight ) ;
window . scrollTo ( 0 , scrollToY ) ;
} ) ;
return ;
}
if ( settings . settings . scrollPreviewWithEditor ) {
doAfterImagesLoaded ( ( ) = > {
// Try to scroll to fragment if available
if ( settings . settings . fragment ) {
let fragment : string ;
try {
fragment = encodeURIComponent ( settings . settings . fragment ) ;
} catch {
fragment = settings . settings . fragment ;
}
state . fragment = undefined ;
vscode . setState ( state ) ;
const element = getLineElementForFragment ( fragment , documentVersion ) ;
if ( element ) {
scrollDisabledCount += 1 ;
scrollToRevealSourceLine ( element . line , documentVersion , settings ) ;
}
} else {
if ( ! isNaN ( settings . settings . line ! ) ) {
scrollDisabledCount += 1 ;
scrollToRevealSourceLine ( settings . settings . line ! , documentVersion , settings ) ;
}
}
} ) ;
}
if ( typeof settings . settings . selectedLine === 'number' ) {
marker . onDidChangeTextEditorSelection ( settings . settings . selectedLine , documentVersion ) ;
}
} ) ;
const onUpdateView = ( ( ) = > {
const doScroll = throttle ( ( line : number ) = > {
scrollDisabledCount += 1 ;
doAfterImagesLoaded ( ( ) = > scrollToRevealSourceLine ( line , documentVersion , settings ) ) ;
} , 50 ) ;
return ( line : number ) = > {
if ( ! isNaN ( line ) ) {
state . line = line ;
doScroll ( line ) ;
}
} ;
} ) ( ) ;
window . addEventListener ( 'resize' , ( ) = > {
scrollDisabledCount += 1 ;
updateScrollProgress ( ) ;
} , true ) ;
function addImageContexts() {
const images = document . getElementsByTagName ( 'img' ) ;
let idNumber = 0 ;
for ( const img of images ) {
img . id = 'image-' + idNumber ;
idNumber += 1 ;
2025-03-01 02:01:53 +00:00
const imageSource = img . getAttribute ( 'data-src' ) ;
const isLocalFile = imageSource && ! ( isOfScheme ( Schemes . http , imageSource ) || isOfScheme ( Schemes . https , imageSource ) ) ;
const webviewSection = isLocalFile ? 'localImage' : 'image' ;
img . setAttribute ( 'data-vscode-context' , JSON . stringify ( { webviewSection , id : img.id , 'preventDefaultContextMenuItems' : true , resource : documentResource , imageSource } ) ) ;
2024-09-11 02:37:36 +00:00
}
}
async function copyImage ( image : HTMLImageElement , retries = 5 ) {
if ( ! document . hasFocus ( ) && retries > 0 ) {
// copyImage is called at the same time as webview.reveal, which means this function is running whilst the webview is gaining focus.
// Since navigator.clipboard.write requires the document to be focused, we need to wait for focus.
// 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.
setTimeout ( ( ) = > { copyImage ( image , retries - 1 ) ; } , 20 ) ;
return ;
}
try {
await navigator . clipboard . write ( [ new ClipboardItem ( {
'image/png' : new Promise ( ( resolve ) = > {
const canvas = document . createElement ( 'canvas' ) ;
if ( canvas !== null ) {
canvas . width = image . naturalWidth ;
canvas . height = image . naturalHeight ;
const context = canvas . getContext ( '2d' ) ;
context ? . drawImage ( image , 0 , 0 ) ;
}
canvas . toBlob ( ( blob ) = > {
if ( blob ) {
resolve ( blob ) ;
}
canvas . remove ( ) ;
} , 'image/png' ) ;
} )
} ) ] ) ;
} catch ( e ) {
console . error ( e ) ;
2025-04-29 07:07:20 +00:00
const selection = window . getSelection ( ) ;
if ( ! selection ) {
await navigator . clipboard . writeText ( image . getAttribute ( 'data-src' ) ? ? image . src ) ;
return ;
}
selection . removeAllRanges ( ) ;
const range = document . createRange ( ) ;
range . selectNode ( image ) ;
selection . addRange ( range ) ;
document . execCommand ( 'copy' ) ;
selection . removeAllRanges ( ) ;
2024-09-11 02:37:36 +00:00
}
}
window . addEventListener ( 'message' , async event = > {
const data = event . data as ToWebviewMessage . Type ;
switch ( data . type ) {
case 'copyImage' : {
const img = document . getElementById ( data . id ) ;
if ( img instanceof HTMLImageElement ) {
copyImage ( img ) ;
}
return ;
}
case 'onDidChangeTextEditorSelection' :
if ( data . source === documentResource ) {
marker . onDidChangeTextEditorSelection ( data . line , documentVersion ) ;
}
return ;
case 'updateView' :
if ( data . source === documentResource ) {
onUpdateView ( data . line ) ;
}
return ;
case 'updateContent' : {
const root = document . querySelector ( '.markdown-body' ) ! ;
const parser = new DOMParser ( ) ;
const 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 safe
// Strip out meta http-equiv tags
for ( const metaElement of Array . from ( newContent . querySelectorAll ( 'meta' ) ) ) {
if ( metaElement . hasAttribute ( 'http-equiv' ) ) {
metaElement . remove ( ) ;
}
}
if ( data . source !== documentResource ) {
documentResource = data . source ;
2025-04-29 07:07:20 +00:00
const newBody = newContent . querySelector ( '.markdown-body' ) ! ;
root . replaceWith ( newBody ) ;
domEval ( newBody ) ;
2024-09-11 02:37:36 +00:00
} else {
const newRoot = newContent . querySelector ( '.markdown-body' ) ! ;
// Move styles to head
// This prevents an ugly flash of unstyled content
const styles = newRoot . querySelectorAll ( 'link' ) ;
for ( const style of styles ) {
style . remove ( ) ;
}
newRoot . prepend ( . . . styles ) ;
2025-04-29 07:07:20 +00:00
2024-09-11 02:37:36 +00:00
morphdom ( root , newRoot , {
childrenOnly : true ,
2025-04-29 07:07:20 +00:00
onBeforeElUpdated : ( fromEl : Element , toEl : Element ) = > {
if ( areNodesEqual ( fromEl , toEl ) ) {
2024-09-11 02:37:36 +00:00
// areEqual doesn't look at `data-line` so copy those over manually
const fromLines = fromEl . querySelectorAll ( '[data-line]' ) ;
const toLines = toEl . querySelectorAll ( '[data-line]' ) ;
if ( fromLines . length !== toLines . length ) {
console . log ( 'unexpected line number change' ) ;
}
for ( let i = 0 ; i < fromLines . length ; ++ i ) {
const fromChild = fromLines [ i ] ;
const toChild = toLines [ i ] ;
if ( toChild ) {
fromChild . setAttribute ( 'data-line' , toChild . getAttribute ( 'data-line' ) ! ) ;
}
}
return false ;
}
if ( fromEl . tagName === 'DETAILS' && toEl . tagName === 'DETAILS' ) {
if ( fromEl . hasAttribute ( 'open' ) ) {
toEl . setAttribute ( 'open' , '' ) ;
}
}
return true ;
2025-04-29 07:07:20 +00:00
} ,
addChild : ( parentNode : Node , childNode : Node ) = > {
parentNode . appendChild ( childNode ) ;
if ( childNode instanceof HTMLElement ) {
domEval ( childNode ) ;
}
2024-09-11 02:37:36 +00:00
}
2025-04-29 07:07:20 +00:00
} as any ) ;
2024-09-11 02:37:36 +00:00
}
++ documentVersion ;
window . dispatchEvent ( new CustomEvent ( 'vscode.markdown.updateContent' ) ) ;
addImageContexts ( ) ;
break ;
}
}
} , false ) ;
document . addEventListener ( 'dblclick' , event = > {
if ( ! settings . settings . doubleClickToSwitchToEditor ) {
return ;
}
// Ignore clicks on links
for ( let node = event . target as HTMLElement ; node ; node = node . parentNode as HTMLElement ) {
if ( node . tagName === 'A' ) {
return ;
}
}
const offset = event . pageY ;
const line = getEditorLineNumberForPageOffset ( offset , documentVersion ) ;
if ( typeof line === 'number' && ! isNaN ( line ) ) {
messaging . postMessage ( 'didClick' , { line : Math.floor ( line ) } ) ;
}
} ) ;
const passThroughLinkSchemes = [ 'http:' , 'https:' , 'mailto:' , 'vscode:' , 'vscode-insiders:' ] ;
document . addEventListener ( 'click' , event = > {
if ( ! event ) {
return ;
}
let node : any = event . target ;
while ( node ) {
if ( node . tagName && node . tagName === 'A' && node . href ) {
if ( node . getAttribute ( 'href' ) . startsWith ( '#' ) ) {
return ;
}
let hrefText = node . getAttribute ( 'data-href' ) ;
if ( ! hrefText ) {
2024-09-24 04:46:08 +00:00
hrefText = node . getAttribute ( 'href' ) ;
2024-09-11 02:37:36 +00:00
// Pass through known schemes
2024-09-24 04:46:08 +00:00
if ( passThroughLinkSchemes . some ( scheme = > hrefText . startsWith ( scheme ) ) ) {
2024-09-11 02:37:36 +00:00
return ;
}
}
// If original link doesn't look like a url, delegate back to VS Code to resolve
if ( ! /^[a-z\-]+:/i . test ( hrefText ) ) {
messaging . postMessage ( 'openLink' , { href : hrefText } ) ;
event . preventDefault ( ) ;
event . stopPropagation ( ) ;
return ;
}
return ;
}
node = node . parentNode ;
}
} , true ) ;
window . addEventListener ( 'scroll' , throttle ( ( ) = > {
updateScrollProgress ( ) ;
if ( scrollDisabledCount > 0 ) {
scrollDisabledCount -= 1 ;
} else {
const line = getEditorLineNumberForPageOffset ( window . scrollY , documentVersion ) ;
if ( typeof line === 'number' && ! isNaN ( line ) ) {
messaging . postMessage ( 'revealLine' , { line } ) ;
}
}
} , 50 ) ) ;
function updateScrollProgress() {
state . scrollProgress = window . scrollY / document . body . clientHeight ;
vscode . setState ( state ) ;
}
2025-04-29 07:07:20 +00:00
/ * *
* Compares two nodes for morphdom to see if they are equal .
*
* This skips some attributes that should not cause equality to fail .
* /
function areNodesEqual ( a : Element , b : Element ) : boolean {
const skippedAttrs = [
'open' , // for details
] ;
if ( a . isEqualNode ( b ) ) {
return true ;
}
if ( a . tagName !== b . tagName || a . textContent !== b . textContent ) {
return false ;
}
const aAttrs = [ . . . a . attributes ] . filter ( attr = > ! skippedAttrs . includes ( attr . name ) ) ;
const bAttrs = [ . . . b . attributes ] . filter ( attr = > ! skippedAttrs . includes ( attr . name ) ) ;
if ( aAttrs . length !== bAttrs . length ) {
return false ;
}
for ( let i = 0 ; i < aAttrs . length ; ++ i ) {
const aAttr = aAttrs [ i ] ;
const bAttr = bAttrs [ i ] ;
if ( aAttr . name !== bAttr . name ) {
return false ;
}
if ( aAttr . value !== bAttr . value && aAttr . name !== 'data-line' ) {
return false ;
}
}
const aChildren = Array . from ( a . children ) ;
const bChildren = Array . from ( b . children ) ;
return aChildren . length === bChildren . length && aChildren . every ( ( x , i ) = > areNodesEqual ( x , bChildren [ i ] ) ) ;
}
function domEval ( el : Element ) : void {
const preservedScriptAttributes : ( keyof HTMLScriptElement ) [ ] = [
'type' , 'src' , 'nonce' , 'noModule' , 'async' ,
] ;
const scriptNodes = el . tagName === 'SCRIPT' ? [ el ] : Array . from ( el . getElementsByTagName ( 'script' ) ) ;
for ( const node of scriptNodes ) {
if ( ! ( node instanceof HTMLElement ) ) {
continue ;
}
const scriptTag = document . createElement ( 'script' ) ;
const trustedScript = node . innerText ;
scriptTag . text = trustedScript as string ;
for ( const key of preservedScriptAttributes ) {
const val = node . getAttribute && node . getAttribute ( key ) ;
if ( val ) {
scriptTag . setAttribute ( key , val as any ) ;
}
}
node . insertAdjacentElement ( 'afterend' , scriptTag ) ;
node . remove ( ) ;
}
}