Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/markdown-language-features/preview-src/index.ts
3292 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { ActiveLineMarker } from './activeLineMarker';
7
import { onceDocumentLoaded } from './events';
8
import { createPosterForVsCode } from './messaging';
9
import { getEditorLineNumberForPageOffset, scrollToRevealSourceLine, getLineElementForFragment } from './scroll-sync';
10
import { SettingsManager, getData, getRawData } from './settings';
11
import throttle = require('lodash.throttle');
12
import morphdom from 'morphdom';
13
import type { ToWebviewMessage } from '../types/previewMessaging';
14
import { isOfScheme, Schemes } from '../src/util/schemes';
15
16
let scrollDisabledCount = 0;
17
18
const marker = new ActiveLineMarker();
19
const settings = new SettingsManager();
20
21
let documentVersion = 0;
22
let documentResource = settings.settings.source;
23
24
const vscode = acquireVsCodeApi();
25
26
const originalState = vscode.getState() ?? {} as any;
27
const state = {
28
...originalState,
29
...getData<any>('data-state')
30
};
31
32
if (typeof originalState.scrollProgress !== 'undefined' && originalState?.resource !== state.resource) {
33
state.scrollProgress = 0;
34
}
35
36
// Make sure to sync VS Code state here
37
vscode.setState(state);
38
39
const messaging = createPosterForVsCode(vscode, settings);
40
41
window.cspAlerter.setPoster(messaging);
42
window.styleLoadingMonitor.setPoster(messaging);
43
44
45
function doAfterImagesLoaded(cb: () => void) {
46
const imgElements = document.getElementsByTagName('img');
47
if (imgElements.length > 0) {
48
const ps = Array.from(imgElements, e => {
49
if (e.complete) {
50
return Promise.resolve();
51
} else {
52
return new Promise<void>((resolve) => {
53
e.addEventListener('load', () => resolve());
54
e.addEventListener('error', () => resolve());
55
});
56
}
57
});
58
Promise.all(ps).then(() => setTimeout(cb, 0));
59
} else {
60
setTimeout(cb, 0);
61
}
62
}
63
64
onceDocumentLoaded(() => {
65
// Load initial html
66
const htmlParser = new DOMParser();
67
const markDownHtml = htmlParser.parseFromString(
68
getRawData('data-initial-md-content'),
69
'text/html'
70
);
71
72
const newElements = [...markDownHtml.body.children];
73
document.body.append(...newElements);
74
for (const el of newElements) {
75
if (el instanceof HTMLElement) {
76
domEval(el);
77
}
78
}
79
80
// Restore
81
const scrollProgress = state.scrollProgress;
82
addImageContexts();
83
if (typeof scrollProgress === 'number' && !settings.settings.fragment) {
84
doAfterImagesLoaded(() => {
85
scrollDisabledCount += 1;
86
// Always set scroll of at least 1 to prevent VS Code's webview code from auto scrolling us
87
const scrollToY = Math.max(1, scrollProgress * document.body.clientHeight);
88
window.scrollTo(0, scrollToY);
89
});
90
return;
91
}
92
93
if (settings.settings.scrollPreviewWithEditor) {
94
doAfterImagesLoaded(() => {
95
// Try to scroll to fragment if available
96
if (settings.settings.fragment) {
97
let fragment: string;
98
try {
99
fragment = encodeURIComponent(settings.settings.fragment);
100
} catch {
101
fragment = settings.settings.fragment;
102
}
103
state.fragment = undefined;
104
vscode.setState(state);
105
106
const element = getLineElementForFragment(fragment, documentVersion);
107
if (element) {
108
scrollDisabledCount += 1;
109
scrollToRevealSourceLine(element.line, documentVersion, settings);
110
}
111
} else {
112
if (!isNaN(settings.settings.line!)) {
113
scrollDisabledCount += 1;
114
scrollToRevealSourceLine(settings.settings.line!, documentVersion, settings);
115
}
116
}
117
});
118
}
119
120
if (typeof settings.settings.selectedLine === 'number') {
121
marker.onDidChangeTextEditorSelection(settings.settings.selectedLine, documentVersion);
122
}
123
});
124
125
const onUpdateView = (() => {
126
const doScroll = throttle((line: number) => {
127
scrollDisabledCount += 1;
128
doAfterImagesLoaded(() => scrollToRevealSourceLine(line, documentVersion, settings));
129
}, 50);
130
131
return (line: number) => {
132
if (!isNaN(line)) {
133
state.line = line;
134
135
doScroll(line);
136
}
137
};
138
})();
139
140
window.addEventListener('resize', () => {
141
scrollDisabledCount += 1;
142
updateScrollProgress();
143
}, true);
144
145
function addImageContexts() {
146
const images = document.getElementsByTagName('img');
147
let idNumber = 0;
148
for (const img of images) {
149
img.id = 'image-' + idNumber;
150
idNumber += 1;
151
const imageSource = img.getAttribute('data-src');
152
const isLocalFile = imageSource && !(isOfScheme(Schemes.http, imageSource) || isOfScheme(Schemes.https, imageSource));
153
const webviewSection = isLocalFile ? 'localImage' : 'image';
154
img.setAttribute('data-vscode-context', JSON.stringify({ webviewSection, id: img.id, 'preventDefaultContextMenuItems': true, resource: documentResource, imageSource }));
155
}
156
}
157
158
async function copyImage(image: HTMLImageElement, retries = 5) {
159
if (!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.
163
setTimeout(() => { copyImage(image, retries - 1); }, 20);
164
return;
165
}
166
167
try {
168
await navigator.clipboard.write([new ClipboardItem({
169
'image/png': new Promise((resolve) => {
170
const canvas = document.createElement('canvas');
171
if (canvas !== null) {
172
canvas.width = image.naturalWidth;
173
canvas.height = image.naturalHeight;
174
const context = canvas.getContext('2d');
175
context?.drawImage(image, 0, 0);
176
}
177
canvas.toBlob((blob) => {
178
if (blob) {
179
resolve(blob);
180
}
181
canvas.remove();
182
}, 'image/png');
183
})
184
})]);
185
} catch (e) {
186
console.error(e);
187
const selection = window.getSelection();
188
if (!selection) {
189
await navigator.clipboard.writeText(image.getAttribute('data-src') ?? image.src);
190
return;
191
}
192
selection.removeAllRanges();
193
const range = document.createRange();
194
range.selectNode(image);
195
selection.addRange(range);
196
document.execCommand('copy');
197
selection.removeAllRanges();
198
}
199
}
200
201
window.addEventListener('message', async event => {
202
const data = event.data as ToWebviewMessage.Type;
203
switch (data.type) {
204
case 'copyImage': {
205
const img = document.getElementById(data.id);
206
if (img instanceof HTMLImageElement) {
207
copyImage(img);
208
}
209
return;
210
}
211
case 'onDidChangeTextEditorSelection':
212
if (data.source === documentResource) {
213
marker.onDidChangeTextEditorSelection(data.line, documentVersion);
214
}
215
return;
216
217
case 'updateView':
218
if (data.source === documentResource) {
219
onUpdateView(data.line);
220
}
221
return;
222
223
case 'updateContent': {
224
const root = document.querySelector('.markdown-body')!;
225
226
const parser = new DOMParser();
227
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
228
229
// Strip out meta http-equiv tags
230
for (const metaElement of Array.from(newContent.querySelectorAll('meta'))) {
231
if (metaElement.hasAttribute('http-equiv')) {
232
metaElement.remove();
233
}
234
}
235
236
if (data.source !== documentResource) {
237
documentResource = data.source;
238
const newBody = newContent.querySelector('.markdown-body')!;
239
root.replaceWith(newBody);
240
domEval(newBody);
241
} else {
242
const newRoot = newContent.querySelector('.markdown-body')!;
243
244
// Move styles to head
245
// This prevents an ugly flash of unstyled content
246
const styles = newRoot.querySelectorAll('link');
247
for (const style of styles) {
248
style.remove();
249
}
250
newRoot.prepend(...styles);
251
252
morphdom(root, newRoot, {
253
childrenOnly: true,
254
onBeforeElUpdated: (fromEl: Element, toEl: Element) => {
255
if (areNodesEqual(fromEl, toEl)) {
256
// areEqual doesn't look at `data-line` so copy those over manually
257
const fromLines = fromEl.querySelectorAll('[data-line]');
258
const toLines = toEl.querySelectorAll('[data-line]');
259
if (fromLines.length !== toLines.length) {
260
console.log('unexpected line number change');
261
}
262
263
for (let i = 0; i < fromLines.length; ++i) {
264
const fromChild = fromLines[i];
265
const toChild = toLines[i];
266
if (toChild) {
267
fromChild.setAttribute('data-line', toChild.getAttribute('data-line')!);
268
}
269
}
270
271
return false;
272
}
273
274
if (fromEl.tagName === 'DETAILS' && toEl.tagName === 'DETAILS') {
275
if (fromEl.hasAttribute('open')) {
276
toEl.setAttribute('open', '');
277
}
278
}
279
280
return true;
281
},
282
addChild: (parentNode: Node, childNode: Node) => {
283
parentNode.appendChild(childNode);
284
if (childNode instanceof HTMLElement) {
285
domEval(childNode);
286
}
287
}
288
} as any);
289
}
290
291
++documentVersion;
292
293
window.dispatchEvent(new CustomEvent('vscode.markdown.updateContent'));
294
addImageContexts();
295
break;
296
}
297
}
298
}, false);
299
300
301
302
document.addEventListener('dblclick', event => {
303
if (!settings.settings.doubleClickToSwitchToEditor) {
304
return;
305
}
306
307
// Ignore clicks on links
308
for (let node = event.target as HTMLElement; node; node = node.parentNode as HTMLElement) {
309
if (node.tagName === 'A') {
310
return;
311
}
312
}
313
314
const offset = event.pageY;
315
const line = getEditorLineNumberForPageOffset(offset, documentVersion);
316
if (typeof line === 'number' && !isNaN(line)) {
317
messaging.postMessage('didClick', { line: Math.floor(line) });
318
}
319
});
320
321
const passThroughLinkSchemes = ['http:', 'https:', 'mailto:', 'vscode:', 'vscode-insiders:'];
322
323
document.addEventListener('click', event => {
324
if (!event) {
325
return;
326
}
327
328
let node: any = event.target;
329
while (node) {
330
if (node.tagName && node.tagName === 'A' && node.href) {
331
if (node.getAttribute('href').startsWith('#')) {
332
return;
333
}
334
335
let hrefText = node.getAttribute('data-href');
336
if (!hrefText) {
337
hrefText = node.getAttribute('href');
338
// Pass through known schemes
339
if (passThroughLinkSchemes.some(scheme => hrefText.startsWith(scheme))) {
340
return;
341
}
342
}
343
344
// If original link doesn't look like a url, delegate back to VS Code to resolve
345
if (!/^[a-z\-]+:/i.test(hrefText)) {
346
messaging.postMessage('openLink', { href: hrefText });
347
event.preventDefault();
348
event.stopPropagation();
349
return;
350
}
351
352
return;
353
}
354
node = node.parentNode;
355
}
356
}, true);
357
358
window.addEventListener('scroll', throttle(() => {
359
updateScrollProgress();
360
361
if (scrollDisabledCount > 0) {
362
scrollDisabledCount -= 1;
363
} else {
364
const line = getEditorLineNumberForPageOffset(window.scrollY, documentVersion);
365
if (typeof line === 'number' && !isNaN(line)) {
366
messaging.postMessage('revealLine', { line });
367
}
368
}
369
}, 50));
370
371
function updateScrollProgress() {
372
state.scrollProgress = window.scrollY / document.body.clientHeight;
373
vscode.setState(state);
374
}
375
376
377
/**
378
* Compares two nodes for morphdom to see if they are equal.
379
*
380
* This skips some attributes that should not cause equality to fail.
381
*/
382
function areNodesEqual(a: Element, b: Element): boolean {
383
const skippedAttrs = [
384
'open', // for details
385
];
386
387
if (a.isEqualNode(b)) {
388
return true;
389
}
390
391
if (a.tagName !== b.tagName || a.textContent !== b.textContent) {
392
return false;
393
}
394
395
const aAttrs = [...a.attributes].filter(attr => !skippedAttrs.includes(attr.name));
396
const bAttrs = [...b.attributes].filter(attr => !skippedAttrs.includes(attr.name));
397
if (aAttrs.length !== bAttrs.length) {
398
return false;
399
}
400
401
for (let i = 0; i < aAttrs.length; ++i) {
402
const aAttr = aAttrs[i];
403
const bAttr = bAttrs[i];
404
if (aAttr.name !== bAttr.name) {
405
return false;
406
}
407
if (aAttr.value !== bAttr.value && aAttr.name !== 'data-line') {
408
return false;
409
}
410
}
411
412
const aChildren = Array.from(a.children);
413
const bChildren = Array.from(b.children);
414
415
return aChildren.length === bChildren.length && aChildren.every((x, i) => areNodesEqual(x, bChildren[i]));
416
}
417
418
419
function domEval(el: Element): void {
420
const preservedScriptAttributes: (keyof HTMLScriptElement)[] = [
421
'type', 'src', 'nonce', 'noModule', 'async',
422
];
423
424
const scriptNodes = el.tagName === 'SCRIPT' ? [el] : Array.from(el.getElementsByTagName('script'));
425
426
for (const node of scriptNodes) {
427
if (!(node instanceof HTMLElement)) {
428
continue;
429
}
430
431
const scriptTag = document.createElement('script');
432
const trustedScript = node.innerText;
433
scriptTag.text = trustedScript as string;
434
for (const key of preservedScriptAttributes) {
435
const val = node.getAttribute?.(key);
436
if (val) {
437
scriptTag.setAttribute(key, val as any);
438
}
439
}
440
441
node.insertAdjacentElement('afterend', scriptTag);
442
node.remove();
443
}
444
}
445
446