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