diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index b4f1182cb0a8f4..eee269dcb613bd 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -90,10 +90,11 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Check Markdown editor manifests + - name: Check Markdown editor manifests and clipboard run: | npm run markdown-editor-package-json-check npm --prefix extensions/markdown-language-features run test-markdown-editor-package-json + npm --prefix extensions/markdown-language-features run test-markdown-editor-clipboard - name: Compile & Hygiene run: npm exec -- npm-run-all2 -lp core-ci hygiene eslint valid-layers-check define-class-fields-check vscode-dts-compile-check tsec-compile-check test-build-scripts diff --git a/extensions/markdown-language-features/README.md b/extensions/markdown-language-features/README.md index 531fc922202039..2052521da84b88 100644 --- a/extensions/markdown-language-features/README.md +++ b/extensions/markdown-language-features/README.md @@ -5,18 +5,3 @@ ## Features See [Markdown in Visual Studio Code](https://code.visualstudio.com/docs/languages/markdown) to learn about the features of this extension. - -### Markdown editor commit links - -Commit links such as `[commit](commit:1234567)` render the commit subject and -addition/deletion counts. Opening one shows the commit's changes in the multi-diff -editor. Without a repository hint, the Git extension searches open repositories. - -Use `[commit](commit:1234567?path=../../my-repo)` to select a repository relative to -the Markdown file, including repositories that are not open yet. Absolute file -URIs are also supported. URL-encode paths containing `?`, `#`, or `&`. A hinted -repository must contain the commit; an invalid hint does not fall back to another -repository. Opening a new repository requires a trusted workspace. - -Forge links such as `https://github.com/owner/repo/commit/1234567?path=../repo` -also accept this hint, but the local repository's remote must match the URL. diff --git a/extensions/markdown-language-features/markdown-editor-src/clipboardMedia.ts b/extensions/markdown-language-features/markdown-editor-src/clipboardMedia.ts new file mode 100644 index 00000000000000..04e6acbc040c85 --- /dev/null +++ b/extensions/markdown-language-features/markdown-editor-src/clipboardMedia.ts @@ -0,0 +1,16 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { NativeClipboardData } from '@vscode/markdown-editor'; + +export const clipboardFormatsUnavailableMessage = 'The clipboard formats are not available to this editor. Try copying the media itself instead of its file.'; + +export function getNativeClipboardMedia(data: NativeClipboardData, plainText: boolean): { readonly text: string; readonly images: readonly File[] } { + const images = plainText ? [] : data.files.filter(file => /^(image|video|audio)\//.test(file.type)); + if (!plainText && data.files.length && !images.length && !data.text) { + throw new Error(clipboardFormatsUnavailableMessage); + } + return { text: data.text, images }; +} diff --git a/extensions/markdown-language-features/markdown-editor-src/editor.ts b/extensions/markdown-language-features/markdown-editor-src/editor.ts index d576b42dbda780..9efe3f744dee09 100644 --- a/extensions/markdown-language-features/markdown-editor-src/editor.ts +++ b/extensions/markdown-language-features/markdown-editor-src/editor.ts @@ -123,6 +123,7 @@ class Editor extends Disposable { #rename: RenameController | undefined; #completion: CompletionController | undefined; #diagnostics: DiagnosticsController | undefined; + #imagePaste: ImagePasteController | undefined; #view: EditorView | undefined; #embeddedCodeEditorFactory: LazyCodeBlockEditorFactory | undefined; /** Identifies the authoritative text baseline against which local edits are computed. */ @@ -182,6 +183,10 @@ class Editor extends Disposable { this.model.readonlyMode.set(initialState.readonly, undefined); this._register(this.#connection.register(markdownEditorRenderer, { + readClipboardMedia: (request, _context, { signal }) => { + if (!this.#imagePaste) { throw new Error('The Markdown editor is not ready to paste media.'); } + return this.#imagePaste.readClipboardMedia(request, signal); + }, diagnosticsChanged: () => this.#diagnostics?.refresh(), update: ({ content, editEpoch }) => { // Applying authoritative text maps selection and clears stale pending-paragraph state. @@ -447,7 +452,7 @@ class Editor extends Disposable { // the TextDocument owns the history, and a second local stack would drift // from the Edit menu, dirty state and hot exit. this.#controller = this._register(new EditorController(model, view, { - clipboardStrategy: this._register(new ImagePasteController(model, view, this.#host, () => this.#editEpoch)), + clipboardStrategy: this.#imagePaste = this._register(new ImagePasteController(model, view, this.#host, () => this.#editEpoch)), keyboardProfile: vscodeLocalKeyboardProfile, forwardedKeyboardProfile: vscodeHostKeyboardProfile, historyStrategy: { diff --git a/extensions/markdown-language-features/markdown-editor-src/imagePasteController.ts b/extensions/markdown-language-features/markdown-editor-src/imagePasteController.ts index 9cad5a79864e1d..36efb569e82335 100644 --- a/extensions/markdown-language-features/markdown-editor-src/imagePasteController.ts +++ b/extensions/markdown-language-features/markdown-editor-src/imagePasteController.ts @@ -3,9 +3,11 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { AsyncClipboardStrategy, EditorModel, EditorView, Selection, readNativeClipboard, type IClipboardContext, type IClipboardStrategy } from '@vscode/markdown-editor'; +import { AsyncClipboardStrategy, EditorModel, EditorView, Selection, readNativeClipboard, type IClipboardContext, type IClipboardStrategy, type NativeClipboardData } from '@vscode/markdown-editor'; import { Disposable } from '@vscode/observables'; -import type { MarkdownEditorHost } from '../src/preview/markdownEditorProtocol'; +import type { MarkdownEditorHost, MarkdownEditorRenderer } from '../src/preview/markdownEditorProtocol'; +import { ClipboardMediaStore } from '../src/preview/markdownEditorMediaTransfer'; +import { clipboardFormatsUnavailableMessage, getNativeClipboardMedia } from './clipboardMedia'; export class ImagePasteController extends Disposable implements IClipboardStrategy { readonly #strategy: AsyncClipboardStrategy; @@ -13,6 +15,7 @@ export class ImagePasteController extends Disposable implements IClipboardStrate #disposed = false; #request = 0; #interaction = 0; + #media: ClipboardMediaStore | undefined; constructor( readonly editor: EditorModel, @@ -22,27 +25,28 @@ export class ImagePasteController extends Disposable implements IClipboardStrate readonly clipboard: Clipboard = navigator.clipboard, ) { super(); - this.#strategy = new AsyncClipboardStrategy(clipboard, (context, plainText) => this.#paste(context, async () => { - const native = readNativeClipboard(context.element); + this.#strategy = new AsyncClipboardStrategy(clipboard, (context, plainText, nativeData?: NativeClipboardData) => this.#paste(context, async () => { + const native = nativeData ?? readNativeClipboard(context.element); if (native) { - return { text: native.text, images: plainText ? [] : native.files.filter(file => file.type.startsWith('image/')) }; + return getNativeClipboardMedia(native, plainText); } if (plainText || !clipboard.read) { return { text: await clipboard.readText(), images: [] }; } const items = await clipboard.read(); const images: File[] = []; let text = ''; for (const item of items) { - const type = item.types.find(type => type === 'image/png') ?? item.types.find(type => type.startsWith('image/')); + const type = item.types.find(type => type === 'image/png') ?? item.types.find(type => /^(image|video|audio)\//.test(type)); if (type) { const blob = await item.getType(type); - const extension = type.slice('image/'.length).split('+')[0]; - images.push(new File([blob], `image.${extension}`, { type })); + const [kind, subtype] = type.split('/'); + const extension = subtype.split('+')[0]; + images.push(new File([blob], `${kind}.${extension}`, { type })); } else if (item.types.includes('text/plain')) { text += await (await item.getType('text/plain')).text(); } } if (items.length && !images.length && !text && items.every(item => !item.types.includes('text/plain'))) { - throw new Error('The clipboard formats are not available to this editor. Try copying the image itself instead of its file.'); + throw new Error(clipboardFormatsUnavailableMessage); } return { text, images }; }, plainText)); @@ -54,32 +58,31 @@ export class ImagePasteController extends Disposable implements IClipboardStrate writeText(text: string): Promise { return this.#strategy.writeText(text); } + readClipboardMedia(request: Parameters[0], signal: AbortSignal): ReturnType { + if (!this.#media || this.#disposed) { + throw new Error('The clipboard media paste has expired.'); + } + return this.#media.read(request, signal); + } + connect(context: IClipboardContext): { dispose(): void } { const interact = (): void => { this.#interaction++; }; context.element.addEventListener('pointerdown', interact); context.element.addEventListener('keydown', interact); const connection = this.#strategy.connect(context); - const paste = (event: ClipboardEvent): void => { - if (event.target !== context.element || !event.clipboardData) { return; } - const images = [...event.clipboardData.files].filter(file => file.type.startsWith('image/')); - const text = event.clipboardData.getData('text/plain'); - event.preventDefault(); - event.stopPropagation(); - void this.#paste(context, async () => ({ images, text })); - }; - context.element.addEventListener('paste', paste); return { dispose: () => { connection.dispose(); context.element.removeEventListener('pointerdown', interact); context.element.removeEventListener('keydown', interact); - context.element.removeEventListener('paste', paste); } }; } async #paste(context: IClipboardContext, read: () => Promise<{ readonly images: readonly File[]; readonly text: string }>, plainText = false): Promise { const request = ++this.#request; + this.#media?.dispose(); + this.#media = undefined; const source = this.editor.sourceText.get(); const selection = this.editor.selection.get(); const epoch = this.epoch(); @@ -88,33 +91,24 @@ export class ImagePasteController extends Disposable implements IClipboardStrate this.#showError('Switch to editing mode and place the caret before pasting.'); return; } + let media: ClipboardMediaStore | undefined; try { const { images, text } = await read(); - const payload = await Promise.all(images.map(async image => ({ - name: image.name, - mime: image.type, - base64: await new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(String(reader.result).split(',')[1]); - reader.onerror = () => reject(reader.error ?? new Error('Could not read clipboard image.')); - reader.onabort = () => reject(new Error('Clipboard image reading was cancelled.')); - reader.readAsDataURL(image); - }), - }))); if (this.#disposed || request !== this.#request) { return; } if (source !== this.editor.sourceText.get() || selection !== this.editor.selection.get() || epoch !== this.epoch() || this.editor.readonlyMode.get() || document.activeElement !== context.element) { throw new Error('The editor changed while reading the clipboard. Paste again.'); } this.#status.hidden = true; - if (!payload.length) { + if (!images.length) { if (text) { context.insertText(text, plainText); } return; } - this.#status.textContent = 'Pasting image...'; + this.#status.textContent = 'Pasting media...'; this.#status.hidden = false; + media = this.#media = new ClipboardMediaStore(images); const result = await this.host.pasteImages({ - start: selection.range.start, endExclusive: selection.range.endExclusive, editEpoch: epoch, images: payload, + start: selection.range.start, endExclusive: selection.range.endExclusive, editEpoch: epoch, images: [...media.images], }); if (this.#disposed || request !== this.#request) { return; } this.#status.hidden = true; @@ -126,6 +120,9 @@ export class ImagePasteController extends Disposable implements IClipboardStrate if (!this.#disposed && request === this.#request) { this.#showError(`Could not paste: ${error instanceof Error ? error.message : String(error)}`); } + } finally { + media?.dispose(); + if (this.#media === media) { this.#media = undefined; } } } @@ -136,6 +133,8 @@ export class ImagePasteController extends Disposable implements IClipboardStrate override dispose(): void { this.#disposed = true; + this.#media?.dispose(); + this.#media = undefined; super.dispose(); } } diff --git a/extensions/markdown-language-features/package-lock.json b/extensions/markdown-language-features/package-lock.json index f4afd9cec5e07f..0aa4b535737b55 100644 --- a/extensions/markdown-language-features/package-lock.json +++ b/extensions/markdown-language-features/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@vscode/extension-telemetry": "^0.9.8", - "@vscode/markdown-editor": "0.0.2-121", + "@vscode/markdown-editor": "0.0.2-127", "@vscode/observables": "^0.1.1-0", "@vscode/web-editors": "^0.0.2-41", "dompurify": "^3.4.10", @@ -670,9 +670,9 @@ "integrity": "sha512-ukOMWnCg1tCvT7WnDfsUKQOFDQGsyR5tNgRpwmqi+5/vzU3ghdDXzvIM4IOPdSb3OeSsBNvmSL8nxIVOqi2WXA==" }, "node_modules/@vscode/markdown-editor": { - "version": "0.0.2-121", - "resolved": "https://registry.npmjs.org/@vscode/markdown-editor/-/markdown-editor-0.0.2-121.tgz", - "integrity": "sha512-PkBZf2cGqutKE+t1UThpoRSomyvqHwsiXiCtoG+0/YDTbH6n4WulkIq8fE8jNmyJQtSHJihwJaIwVaeMBOmMXQ==", + "version": "0.0.2-127", + "resolved": "https://registry.npmjs.org/@vscode/markdown-editor/-/markdown-editor-0.0.2-127.tgz", + "integrity": "sha512-YXjQ8j5tq31J50xEEhOG00Le6OP+ovTiH97IzNYdDxPTUFib3OTecFr7iNEPs5jbP+Wl1f8zBiN6GSkFVhkrLg==", "license": "MIT", "dependencies": { "@vscode/codicons": "0.0.46-36", diff --git a/extensions/markdown-language-features/package.json b/extensions/markdown-language-features/package.json index 78b09f67720b97..93a0ec2355a787 100644 --- a/extensions/markdown-language-features/package.json +++ b/extensions/markdown-language-features/package.json @@ -1982,6 +1982,7 @@ "update-markdown-editor-package-json": "node ./scripts/updateMarkdownEditorPackageJson.mts --write", "check-markdown-editor-package-json": "node ./scripts/updateMarkdownEditorPackageJson.mts --check", "test-markdown-editor-package-json": "node --test ./scripts/updateMarkdownEditorPackageJson.test.mts", + "test-markdown-editor-clipboard": "node --test ./scripts/clipboardMedia.test.mts ./scripts/markdownEditorMediaTransfer.test.mts", "compile-web": "npm-run-all2 -lp bundle-web typecheck-web", "bundle-web": "node ./esbuild.browser.mts", "typecheck-web": "node ../../node_modules/@typescript/native/lib/tsc.js --project ./tsconfig.browser.json --noEmit", @@ -1991,7 +1992,7 @@ }, "dependencies": { "@vscode/extension-telemetry": "^0.9.8", - "@vscode/markdown-editor": "0.0.2-121", + "@vscode/markdown-editor": "0.0.2-127", "@vscode/observables": "^0.1.1-0", "@vscode/web-editors": "^0.0.2-41", "dompurify": "^3.4.10", diff --git a/extensions/markdown-language-features/scripts/clipboardMedia.test.mts b/extensions/markdown-language-features/scripts/clipboardMedia.test.mts new file mode 100644 index 00000000000000..20ba25d95a2c4c --- /dev/null +++ b/extensions/markdown-language-features/scripts/clipboardMedia.test.mts @@ -0,0 +1,48 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { getNativeClipboardMedia } from '../markdown-editor-src/clipboardMedia.ts'; + +test('preserves native image, video, and audio file identity and bytes', async () => { + const files = [ + new File([new Uint8Array([1, 2, 3])], 'image.png', { type: 'image/png' }), + new File([new Uint8Array([4, 5, 6])], 'clip.mp4', { type: 'video/mp4' }), + new File([new Uint8Array([7, 8, 9])], 'sound.mp3', { type: 'audio/mpeg' }), + ]; + const result = getNativeClipboardMedia({ text: 'fallback', files }, false); + assert.deepStrictEqual({ + text: result.text, + files: await Promise.all(result.images.map(async file => ({ name: file.name, type: file.type, bytes: [...new Uint8Array(await file.arrayBuffer())] }))), + }, { + text: 'fallback', + files: [ + { name: 'image.png', type: 'image/png', bytes: [1, 2, 3] }, + { name: 'clip.mp4', type: 'video/mp4', bytes: [4, 5, 6] }, + { name: 'sound.mp3', type: 'audio/mpeg', bytes: [7, 8, 9] }, + ], + }); + assert.ok(result.images.every((file, index) => file === files[index])); +}); + +test('reports unsupported and untyped native file-only payloads explicitly', () => { + for (const type of ['application/octet-stream', '']) { + assert.throws(() => getNativeClipboardMedia({ text: '', files: [new File(['data'], 'file.bin', { type })] }, false), /clipboard formats are not available/); + } +}); + +test('preserves plain-text fallback, empty clipboard, and explicit plain-text paste', () => { + const files = [new File(['data'], 'file.bin')]; + assert.deepStrictEqual([ + getNativeClipboardMedia({ text: 'fallback', files }, false), + getNativeClipboardMedia({ text: '', files }, true), + getNativeClipboardMedia({ text: '', files: [] }, false), + ], [ + { text: 'fallback', images: [] }, + { text: '', images: [] }, + { text: '', images: [] }, + ]); +}); diff --git a/extensions/markdown-language-features/scripts/markdownEditorMediaTransfer.test.mts b/extensions/markdown-language-features/scripts/markdownEditorMediaTransfer.test.mts new file mode 100644 index 00000000000000..2715fd802880bd --- /dev/null +++ b/extensions/markdown-language-features/scripts/markdownEditorMediaTransfer.test.mts @@ -0,0 +1,129 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { ClipboardMediaStore, clipboardMediaChunkSize, readClipboardMediaContents } from '../src/preview/markdownEditorMediaTransfer.ts'; + +test('retains metadata without reading files and transfers exact bytes in bounded sequential chunks', async () => { + const bytes = Uint8Array.from({ length: clipboardMediaChunkSize * 2 + 37 }, (_, index) => index % 256); + const slices: [number | undefined, number | undefined][] = []; + class TrackedFile extends File { + override slice(start?: number, end?: number): Blob { + slices.push([start, end]); + return super.slice(start, end); + } + override arrayBuffer(): Promise { + throw new Error('Must not read the whole clipboard file'); + } + } + const store = new ClipboardMediaStore([new TrackedFile([bytes], 'clip.mp4', { type: 'video/mp4' })]); + const controller = new AbortController(); + try { + assert.deepStrictEqual({ images: store.images.map(({ id, ...image }) => ({ ...image, hasId: !!id })), slices }, { + images: [{ name: 'clip.mp4', mime: 'video/mp4', size: bytes.length, hasId: true }], slices: [], + }); + let active = 0; + let peak = 0; + const result = await readClipboardMediaContents(store.images[0], async request => { + peak = Math.max(peak, ++active); + const chunk = await store.read(request, controller.signal); + assert.ok(chunk.base64.length <= 4 * Math.ceil(clipboardMediaChunkSize / 3)); + active--; + return chunk; + }, () => { }); + assert.deepStrictEqual({ bytes: result, slices, peak }, { + bytes, + slices: [[0, clipboardMediaChunkSize], [clipboardMediaChunkSize, clipboardMediaChunkSize * 2], [clipboardMediaChunkSize * 2, bytes.length]], + peak: 1, + }); + } finally { store.dispose(); } +}); + +test('rejects oversized, invalid, unknown and expired chunk requests', async () => { + const store = new ClipboardMediaStore([new File(['abcd'], 'image.png', { type: 'image/png' })]); + const signal = new AbortController().signal; + const request = { id: store.images[0].id, offset: 0, length: 4 }; + try { + for (const invalid of [ + { length: clipboardMediaChunkSize + 1 }, { offset: -1 }, { offset: 0.5 }, + { length: 0 }, { offset: 1 }, { id: 'unknown' }, + ]) { + await assert.rejects(store.read({ ...request, ...invalid }, signal), /Invalid or expired/); + } + store.dispose(); + await assert.rejects(store.read(request, signal), /Invalid or expired/); + } finally { store.dispose(); } +}); + +test('transfers a file larger than 25 MiB without a whole-file base64 payload or a file-size cap', async () => { + const bytes = new Uint8Array(25 * 1024 * 1024 + 1); + bytes[0] = 19; + bytes[bytes.length - 1] = 71; + const store = new ClipboardMediaStore([new File([bytes], 'large.mp4', { type: 'video/mp4' })]); + const signal = new AbortController().signal; + let calls = 0; + try { + const actual = await readClipboardMediaContents(store.images[0], request => { + calls++; + assert.ok(request.length <= clipboardMediaChunkSize); + return store.read(request, signal); + }, () => { }); + assert.deepStrictEqual(actual, bytes); + assert.strictEqual(calls, 101); + } finally { store.dispose(); } +}); + +test('stops after cancellation or a stale edit without requesting the next chunk', async () => { + for (const cancel of [true, false]) { + const store = new ClipboardMediaStore([new File([new Uint8Array(clipboardMediaChunkSize + 1)], 'clip.mp4', { type: 'video/mp4' })]); + const controller = new AbortController(); + let calls = 0; + let stale = false; + try { + await assert.rejects(readClipboardMediaContents(store.images[0], async request => { + calls++; + const result = await store.read(request, controller.signal); + if (cancel) { controller.abort(); } else { stale = true; } + return result; + }, () => { + controller.signal.throwIfAborted(); + if (stale) { throw new Error('stale'); } + })); + assert.strictEqual(calls, 1); + } finally { store.dispose(); } + } +}); + +test('propagates read errors and rejects malformed or truncated responses', async () => { + const file = { id: 'file', name: 'image.png', mime: 'image/png', size: 4 }; + await assert.rejects(readClipboardMediaContents(file, async () => { throw new Error('read failed'); }, () => { }), /read failed/); + for (const base64 of ['!', 'AQID', 'AQIDBAU=', '!!!!!!!!']) { + await assert.rejects(readClipboardMediaContents(file, async () => ({ base64 }), () => { }), /clipboard media chunk/); + } +}); + +test('rejects disposal and cancellation while a chunk read is pending', async () => { + for (const cancel of [true, false]) { + const entered = Promise.withResolvers(); + const finish = Promise.withResolvers(); + class DelayedFile extends File { + override slice(): Blob { + const blob = super.slice(); + blob.arrayBuffer = () => { entered.resolve(); return finish.promise; }; + return blob; + } + } + const store = new ClipboardMediaStore([new DelayedFile(['abcd'], 'image.png', { type: 'image/png' })]); + const controller = new AbortController(); + try { + const result = store.read({ id: store.images[0].id, offset: 0, length: 4 }, controller.signal); + await entered.promise; + if (cancel) { controller.abort(); } else { store.dispose(); } + finish.resolve(new ArrayBuffer(4)); + await assert.rejects(result); + } finally { store.dispose(); } + } +}); diff --git a/extensions/markdown-language-features/src/preview/markdownEditorImagePaste.ts b/extensions/markdown-language-features/src/preview/markdownEditorImagePaste.ts index 59a07d101588fa..f68bc814ba91aa 100644 --- a/extensions/markdown-language-features/src/preview/markdownEditorImagePaste.ts +++ b/extensions/markdown-language-features/src/preview/markdownEditorImagePaste.ts @@ -6,13 +6,15 @@ import * as vscode from 'vscode'; import { TextDocument } from 'vscode-languageserver-textdocument'; import { CopyFilesSettings, ResourcePasteOrDropProvider } from '../languageFeatures/copyFiles/dropOrPasteResource'; -import type { MarkdownEditorHost } from './markdownEditorProtocol'; +import type { MarkdownEditorHost, MarkdownEditorRenderer } from './markdownEditorProtocol'; +import { readClipboardMediaContents } from './markdownEditorMediaTransfer'; export class MarkdownEditorImagePaste implements vscode.Disposable { #pending: vscode.CancellationTokenSource | undefined; readonly #drain: () => Promise; readonly #snapshot: () => { readonly text: string; readonly editEpoch: number }; readonly #isEditable: () => boolean; + readonly #readClipboardMedia: MarkdownEditorRenderer['readClipboardMedia']; readonly #apply: typeof vscode.workspace.applyEdit; constructor( @@ -20,11 +22,13 @@ export class MarkdownEditorImagePaste implements vscode.Disposable { drain: () => Promise, snapshot: () => { readonly text: string; readonly editEpoch: number }, isEditable: () => boolean, + readClipboardMedia: MarkdownEditorRenderer['readClipboardMedia'], apply: typeof vscode.workspace.applyEdit = edit => vscode.workspace.applyEdit(edit), ) { this.#drain = drain; this.#snapshot = snapshot; this.#isEditable = isEditable; + this.#readClipboardMedia = readClipboardMedia; this.#apply = apply; } @@ -38,34 +42,38 @@ export class MarkdownEditorImagePaste implements vscode.Disposable { const checkCurrent = (): void => { if (pending.token.isCancellationRequested) { throw new vscode.CancellationError(); } if (!this.#isEditable() || this.document.isClosed || version !== this.document.version || request.editEpoch !== this.#snapshot().editEpoch) { - throw new Error(vscode.l10n.t('The editor changed. Paste the image again.')); + throw new Error(vscode.l10n.t('The editor changed. Paste the media again.')); } }; checkCurrent(); if (!Number.isSafeInteger(request.start) || !Number.isSafeInteger(request.endExclusive) || request.start < 0 || request.start > request.endExclusive || request.endExclusive > snapshot.text.length || !request.images.length) { - throw new Error(vscode.l10n.t('Invalid image paste range or empty clipboard image list.')); + throw new Error(vscode.l10n.t('Invalid media paste range or empty clipboard media list.')); } if (this.document.isUntitled) { - throw new Error(vscode.l10n.t('Save the Markdown document before pasting an image.')); + throw new Error(vscode.l10n.t('Save the Markdown document before pasting media.')); } const config = vscode.workspace.getConfiguration('markdown', this.document); if (vscode.workspace.getConfiguration('editor', this.document).get('pasteAs.enabled') === false) { - throw new Error(vscode.l10n.t('Image paste is disabled by editor.pasteAs.enabled.')); + throw new Error(vscode.l10n.t('Media paste is disabled by editor.pasteAs.enabled.')); } const enabled = config.get('editor.paste.enabled', true); if (enabled === false || enabled === 'never') { - throw new Error(vscode.l10n.t('Markdown image paste is disabled by markdown.editor.paste.enabled.')); + throw new Error(vscode.l10n.t('Markdown media paste is disabled by markdown.editor.paste.enabled.')); } const transfer = new vscode.DataTransfer(); + const ids = new Set(); request.images.forEach((image, index) => { - if (!/^image\/[\w.+-]+$/.test(image.mime) || !image.name || /[\\/:]/.test(image.name) || image.name === '.' || image.name === '..' - || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(image.base64) || !image.base64) { - throw new Error(vscode.l10n.t('Invalid clipboard image.')); + if (!/^(image|video|audio)\/[\w.+-]+$/.test(image.mime) || !image.name || /[\\/:]/.test(image.name) || image.name === '.' || image.name === '..' + || !image.id || ids.has(image.id) || !Number.isSafeInteger(image.size) || image.size <= 0) { + throw new Error(vscode.l10n.t('Invalid clipboard media.')); } - const contents = Uint8Array.from(atob(image.base64), char => char.charCodeAt(0)); + ids.add(image.id); const item = new vscode.DataTransferItem(''); - item.asFile = () => ({ name: image.name, uri: undefined, data: async () => contents }); + item.asFile = () => ({ + name: image.name, uri: undefined, + data: () => readClipboardMediaContents(image, chunk => this.#readClipboardMedia(chunk), checkCurrent), + }); transfer.set(`${image.mime};index=${index}`, item); }); const edit = await ResourcePasteOrDropProvider.createEditForMediaFiles(this.document, transfer, @@ -73,7 +81,7 @@ export class MarkdownEditorImagePaste implements vscode.Disposable { await this.#drain(); checkCurrent(); if (!edit?.additionalEdits) { - throw new Error(vscode.l10n.t('Image paste requires markdown.editor.paste.copyIntoWorkspace to be mediaFiles.')); + throw new Error(vscode.l10n.t('Media paste requires markdown.editor.paste.copyIntoWorkspace to be mediaFiles.')); } const mirror = TextDocument.create(this.document.uri.toString(), 'markdown', 0, snapshot.text); const start = mirror.positionAt(request.start); @@ -84,7 +92,7 @@ export class MarkdownEditorImagePaste implements vscode.Disposable { // Workspace edits resolve snippet defaults without starting a snippet session in a custom editor. edit.additionalEdits.set(this.document.uri, [vscode.SnippetTextEdit.replace(range, edit.snippet)]); if (!await this.#apply(edit.additionalEdits)) { - throw new Error(vscode.l10n.t('The workspace rejected the image paste.')); + throw new Error(vscode.l10n.t('The workspace rejected the media paste.')); } const current = this.#snapshot(); const newMirror = TextDocument.create(this.document.uri.toString(), 'markdown', 0, current.text); diff --git a/extensions/markdown-language-features/src/preview/markdownEditorMediaTransfer.ts b/extensions/markdown-language-features/src/preview/markdownEditorMediaTransfer.ts new file mode 100644 index 00000000000000..f70b022503463e --- /dev/null +++ b/extensions/markdown-language-features/src/preview/markdownEditorMediaTransfer.ts @@ -0,0 +1,76 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { MarkdownEditorHost, MarkdownEditorRenderer } from './markdownEditorProtocol'; + +export const clipboardMediaChunkSize = 256 * 1024; + +type ClipboardMedia = Parameters[0]['images'][number]; +type ChunkRequest = Parameters[0]; +type ChunkResult = Awaited>; + +export class ClipboardMediaStore { + readonly #files = new Map(); + readonly images: readonly ClipboardMedia[]; + + constructor(files: readonly File[]) { + this.images = files.map(file => { + const id = crypto.randomUUID(); + this.#files.set(id, file); + return { id, name: file.name, mime: file.type, size: file.size }; + }); + } + + async read(request: ChunkRequest, signal: AbortSignal): Promise { + signal.throwIfAborted(); + const file = this.#files.get(request.id); + if (!file || !Number.isSafeInteger(request.offset) || !Number.isSafeInteger(request.length) + || request.offset < 0 || request.length <= 0 || request.length > clipboardMediaChunkSize + || request.offset > file.size - request.length) { + throw new Error('Invalid or expired clipboard media chunk request.'); + } + const bytes = new Uint8Array(await file.slice(request.offset, request.offset + request.length).arrayBuffer()); + signal.throwIfAborted(); + if (this.#files.get(request.id) !== file) { + throw new Error('The clipboard media paste has expired.'); + } + let binary = ''; + for (let offset = 0; offset < bytes.length; offset += 8192) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + 8192)); + } + return { base64: btoa(binary) }; + } + + dispose(): void { + this.#files.clear(); + } +} + +export async function readClipboardMediaContents( + file: ClipboardMedia, + read: (request: ChunkRequest) => Promise, + checkCurrent: () => void, +): Promise { + checkCurrent(); + const contents = new Uint8Array(file.size); + for (let offset = 0; offset < contents.length; offset += clipboardMediaChunkSize) { + checkCurrent(); + const length = Math.min(clipboardMediaChunkSize, contents.length - offset); + const { base64 } = await read({ id: file.id, offset, length }); + checkCurrent(); + if (base64.length !== 4 * Math.ceil(length / 3) + || !/^[A-Za-z0-9+/]*={0,2}$/.test(base64)) { + throw new Error('Invalid clipboard media chunk.'); + } + const binary = atob(base64); + if (binary.length !== length) { + throw new Error('Unexpected clipboard media chunk length.'); + } + for (let index = 0; index < binary.length; index++) { + contents[offset + index] = binary.charCodeAt(index); + } + } + return contents; +} diff --git a/extensions/markdown-language-features/src/preview/markdownEditorProtocol.ts b/extensions/markdown-language-features/src/preview/markdownEditorProtocol.ts index 1700c2b0ff24b1..05b6a29e5603ad 100644 --- a/extensions/markdown-language-features/src/preview/markdownEditorProtocol.ts +++ b/extensions/markdown-language-features/src/preview/markdownEditorProtocol.ts @@ -115,11 +115,12 @@ export const markdownEditorHost = defineInterface({ id: 'markdown.editor.host' } cancelCompletions: requestType(z.object({ requestId: offset }), z.void()), pasteImages: requestType(z.extend(range, { editEpoch: offset, - images: z.array(z.object({ name: z.string(), mime: z.string(), base64: z.string() })), + images: z.array(z.object({ id: z.string(), name: z.string(), mime: z.string(), size: offset })), }), z.object({ offset: z.optional(offset), editEpoch: offset })), }); export const markdownEditorRenderer = defineInterface({ id: 'markdown.editor.renderer' }, { + readClipboardMedia: requestType(z.object({ id: z.string(), offset, length: offset }), z.object({ base64: z.string() })), configurationChanged: notificationType(editorConfiguration), diagnosticsChanged: requestType(z.object({}), z.void()), update: requestType(z.object({ content: z.string(), editEpoch: offset }), z.void()), diff --git a/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts b/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts index d63134b49dbd3b..2c1bb7e1a27f07 100644 --- a/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts +++ b/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts @@ -475,7 +475,8 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT () => webviewPanel.active && !resolveCancellation.token.isCancellationRequested); const imagePaste = new MarkdownEditorImagePaste(document, () => editQueue.drain(), () => ({ text: webviewText, editEpoch: editQueue.epoch }), - () => !readonly && webviewPanel.active && !resolveCancellation.token.isCancellationRequested); + () => !readonly && webviewPanel.active && !resolveCancellation.token.isCancellationRequested, + request => editorWebview.renderer.readClipboardMedia(request)); const navigation = new MarkdownEditorNavigation(document, () => editQueue.drain(), () => ({ text: webviewText, editEpoch: editQueue.epoch }), () => editorWebview.renderer, token => editorWebview.whenReady(token)); diff --git a/extensions/markdown-language-features/src/test/markdownEditorBridge.test.ts b/extensions/markdown-language-features/src/test/markdownEditorBridge.test.ts index 9cb378681fb5d8..0b89a44efd1eb4 100644 --- a/extensions/markdown-language-features/src/test/markdownEditorBridge.test.ts +++ b/extensions/markdown-language-features/src/test/markdownEditorBridge.test.ts @@ -39,6 +39,7 @@ suite('Markdown editor bridge', () => { const reveals: Parameters[0][] = []; let updated: (() => void) | undefined; renderer.register(markdownEditorRenderer, { + readClipboardMedia: () => ({ base64: '' }), diagnosticsChanged: () => { }, update: update => { updates.push(update); updated?.(); }, codeBlockEditorProviders: () => { }, diff --git a/extensions/markdown-language-features/src/test/markdownEditorImagePaste.test.ts b/extensions/markdown-language-features/src/test/markdownEditorImagePaste.test.ts index e193042d6d51b9..82a61d2f2845b5 100644 --- a/extensions/markdown-language-features/src/test/markdownEditorImagePaste.test.ts +++ b/extensions/markdown-language-features/src/test/markdownEditorImagePaste.test.ts @@ -4,15 +4,24 @@ *--------------------------------------------------------------------------------------------*/ import * as assert from 'assert'; -import * as os from 'os'; import 'mocha'; import * as vscode from 'vscode'; import { MarkdownEditorImagePaste } from '../preview/markdownEditorImagePaste'; import { CopyFilesSettings, ResourcePasteOrDropProvider } from '../languageFeatures/copyFiles/dropOrPasteResource'; +import type { MarkdownEditorRenderer } from '../preview/markdownEditorProtocol'; +import { clipboardMediaChunkSize } from '../preview/markdownEditorMediaTransfer'; suite('Markdown editor image paste', () => { const base64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jD1sAAAAASUVORK5CYII='; - const image = { name: 'image.png', mime: 'image/png', base64 }; + const image = { id: 'image', name: 'image.png', mime: 'image/png', size: atob(base64).length }; + let files: Map; + let chunks: Parameters[0][]; + const read: MarkdownEditorRenderer['readClipboardMedia'] = async request => { + chunks.push(request); + const contents = files.get(request.id); + assert.ok(contents, `Missing test clipboard file ${request.id}`); + return { base64: Buffer.from(contents.subarray(request.offset, request.offset + request.length)).toString('base64') }; + }; let root: vscode.Uri; let document: vscode.TextDocument; let state: { text: string; editEpoch: number; editable: boolean }; @@ -26,13 +35,15 @@ suite('Markdown editor image paste', () => { }; setup(async () => { - root = vscode.Uri.joinPath(vscode.Uri.file(os.tmpdir()), `markdown-image-paste-${crypto.randomUUID()}`); + root = vscode.Uri.joinPath(vscode.Uri.file(__dirname), `markdown-image-paste-${crypto.randomUUID()}`); await vscode.workspace.fs.createDirectory(root); const uri = vscode.Uri.joinPath(root, 'document.md'); await vscode.workspace.fs.writeFile(uri, new TextEncoder().encode('# Title\r\n\r\nHERE')); document = await vscode.workspace.openTextDocument(uri); state = { text: '# Title\n\nHERE', editEpoch: 0, editable: true }; - paste = new MarkdownEditorImagePaste(document, async () => { }, () => state, () => state.editable, apply); + files = new Map([[image.id, Uint8Array.from(atob(base64), c => c.charCodeAt(0))]]); + chunks = []; + paste = new MarkdownEditorImagePaste(document, async () => { }, () => state, () => state.editable, read, apply); }); teardown(async () => { @@ -62,9 +73,71 @@ suite('Markdown editor image paste', () => { } finally { token.dispose(); } }); + for (const kind of ['video', 'audio']) { + test(`creates exact ${kind} bytes and the configured media embed`, async () => { + const media = { id: kind, name: kind === 'video' ? 'clip.mp4' : 'sound.mp3', mime: kind === 'video' ? 'video/mp4' : 'audio/mpeg', size: 4 }; + files.set(media.id, new Uint8Array([1, 2, 3, 4])); + const result = await paste.paste({ ...request(), images: [media] }); + assert.deepStrictEqual({ + text: document.getText(), + bytes: [...await vscode.workspace.fs.readFile(vscode.Uri.joinPath(root, media.name))], + result, + }, { + text: `# Title\r\n\r\n<${kind} controls src="${media.name}" title="Title">`, + bytes: [1, 2, 3, 4], + result: { offset: state.text.length, editEpoch: 1 }, + }); + }); + } + + test('reads files lazily, sequentially and in bounded chunks before applying a single workspace edit', async () => { + const bytes = Uint8Array.from({ length: clipboardMediaChunkSize * 2 + 5 }, (_, index) => index % 256); + files.set('video', bytes); + await paste.paste({ ...request(), images: [ + { id: 'video', name: 'clip.mp4', mime: 'video/mp4', size: bytes.length }, image, + ] }); + assert.deepStrictEqual({ + chunks, + bytes: Uint8Array.from(await vscode.workspace.fs.readFile(vscode.Uri.joinPath(root, 'clip.mp4'))), + editEpoch: state.editEpoch, + }, { + chunks: [ + { id: 'video', offset: 0, length: clipboardMediaChunkSize }, + { id: 'video', offset: clipboardMediaChunkSize, length: clipboardMediaChunkSize }, + { id: 'video', offset: clipboardMediaChunkSize * 2, length: 5 }, + { id: 'image', offset: 0, length: image.size }, + ], + bytes, + editEpoch: 1, + }); + }); + + test('stops requesting chunks after stale edits, cancellation or read errors without writing files', async () => { + for (const reason of ['stale', 'cancelled', 'read error']) { + paste.dispose(); + chunks = []; + paste = new MarkdownEditorImagePaste(document, async () => { }, () => state, () => true, async request => { + chunks.push(request); + if (reason === 'stale') { state.editEpoch++; } + if (reason === 'cancelled') { paste.dispose(); } + if (reason === 'read error') { throw new Error('read failed'); } + return { base64: Buffer.alloc(request.length).toString('base64') }; + }, apply); + await assert.rejects(paste.paste({ ...request(), images: [ + { id: 'video', name: 'clip.mp4', mime: 'video/mp4', size: clipboardMediaChunkSize + 1 }, + ] }), reason === 'stale' ? /editor changed/ : reason === 'read error' ? /read failed/ : vscode.CancellationError); + assert.deepStrictEqual({ + chunks: chunks.length, + files: (await vscode.workspace.fs.readDirectory(root)).map(([name]) => name), + text: document.getText(), + }, { chunks: 1, files: ['document.md'], text: '# Title\r\n\r\nHERE' }); + } + }); + test('increments collisions without overwriting and supports multiple images of one MIME type', async () => { await vscode.workspace.fs.writeFile(vscode.Uri.joinPath(root, 'image.png'), new Uint8Array([1, 2, 3])); - await paste.paste({ ...request(), images: [image, { ...image, base64: 'BAUG' }] }); + files.set('second', new Uint8Array([4, 5, 6])); + await paste.paste({ ...request(), images: [image, { ...image, id: 'second', size: 3 }] }); const links = Array.from(document.getText().matchAll(/!\[alt text\]\(([^)]+)\)/g), match => match[1]); assert.deepStrictEqual([...links].sort(), ['image-1.png', 'image-2.png']); assert.strictEqual(document.getText(), `# Title\r\n\r\n![alt text](${links[0]}) ![alt text](${links[1]})`); @@ -95,13 +168,16 @@ suite('Markdown editor image paste', () => { }); test('rejects invalid input, stale epochs, and locked editors before creating files', async () => { - await assert.rejects(paste.paste({ ...request(), images: [{ ...image, name: '../escape.png' }] }), /Invalid clipboard image/); - await assert.rejects(paste.paste({ ...request(), images: [{ ...image, base64: 'invalid!' }] }), /Invalid clipboard image/); - await assert.rejects(paste.paste({ ...request(), endExclusive: 999 }), /Invalid image paste range/); - await assert.rejects(paste.paste({ ...request(), start: -1 }), /Invalid image paste range/); + await assert.rejects(paste.paste({ ...request(), images: [{ ...image, name: '../escape.png' }] }), /Invalid clipboard media/); + await assert.rejects(paste.paste({ ...request(), images: [{ ...image, size: -1 }] }), /Invalid clipboard media/); + await assert.rejects(paste.paste({ ...request(), images: [image, image] }), /Invalid clipboard media/); + await assert.rejects(paste.paste({ ...request(), images: [{ ...image, mime: 'application/octet-stream' }] }), /Invalid clipboard media/); + await assert.rejects(paste.paste({ ...request(), endExclusive: 999 }), /Invalid media paste range/); + await assert.rejects(paste.paste({ ...request(), start: -1 }), /Invalid media paste range/); await assert.rejects(paste.paste({ ...request(), editEpoch: 9 }), /editor changed/); state.editable = false; await assert.rejects(paste.paste(request()), /editor changed/); + assert.deepStrictEqual(chunks, []); assert.deepStrictEqual((await vscode.workspace.fs.readDirectory(root)).map(([name]) => name), ['document.md']); }); @@ -110,12 +186,12 @@ suite('Markdown editor image paste', () => { paste.dispose(); paste = new MarkdownEditorImagePaste(document, async () => { if (++drains === 2) { state.editEpoch++; } - }, () => state, () => true, apply); + }, () => state, () => true, read, apply); await assert.rejects(paste.paste(request()), /editor changed/); assert.deepStrictEqual((await vscode.workspace.fs.readDirectory(root)).map(([name]) => name), ['document.md']); const entered = Promise.withResolvers(); const finish = Promise.withResolvers(); - paste = new MarkdownEditorImagePaste(document, async () => { entered.resolve(); await finish.promise; }, () => state, () => true, apply); + paste = new MarkdownEditorImagePaste(document, async () => { entered.resolve(); await finish.promise; }, () => state, () => true, read, apply); const result = paste.paste(request()); await entered.promise; paste.dispose(); @@ -131,18 +207,18 @@ suite('Markdown editor image paste', () => { await assert.rejects(paste.paste(request()), /disabled/); } finally { await config.update('pasteAs.enabled', previous, vscode.ConfigurationTarget.Global); } const untitled = await vscode.workspace.openTextDocument({ language: 'markdown', content: 'HERE' }); - const unsaved = new MarkdownEditorImagePaste(untitled, async () => { }, () => ({ text: 'HERE', editEpoch: 0 }), () => true); + const unsaved = new MarkdownEditorImagePaste(untitled, async () => { }, () => ({ text: 'HERE', editEpoch: 0 }), () => true, read); try { await assert.rejects(unsaved.paste({ ...request(), start: 0, endExclusive: 4, editEpoch: 0 }), /Save the Markdown/); } finally { unsaved.dispose(); } - paste = new MarkdownEditorImagePaste(document, async () => { }, () => state, () => true, async () => false); + paste = new MarkdownEditorImagePaste(document, async () => { }, () => state, () => true, read, async () => false); await assert.rejects(paste.paste(request()), /workspace rejected/); assert.deepStrictEqual((await vscode.workspace.fs.readDirectory(root)).map(([name]) => name), ['document.md']); }); test('does not return a stale caret when another edit follows application', async () => { paste.dispose(); - paste = new MarkdownEditorImagePaste(document, async () => { }, () => state, () => true, async edit => { + paste = new MarkdownEditorImagePaste(document, async () => { }, () => state, () => true, read, async edit => { await apply(edit); const followup = new vscode.WorkspaceEdit(); followup.insert(document.uri, new vscode.Position(0, 0), 'Other edit\n'); diff --git a/extensions/markdown-language-features/src/test/markdownEditorRpc.test.ts b/extensions/markdown-language-features/src/test/markdownEditorRpc.test.ts index 24d67b59d6e116..34440ae65bb1fa 100644 --- a/extensions/markdown-language-features/src/test/markdownEditorRpc.test.ts +++ b/extensions/markdown-language-features/src/test/markdownEditorRpc.test.ts @@ -6,9 +6,10 @@ import * as assert from 'assert'; import 'mocha'; import { ErrorCode, type InterfaceHandlers, type JsonRpcMessage } from '@vscode/hubrpc'; -import { markdownEditorHost, markdownEditorRenderer } from '../preview/markdownEditorProtocol'; +import { markdownEditorHost, markdownEditorRenderer, type MarkdownEditorRenderer } from '../preview/markdownEditorProtocol'; import { createMarkdownEditorRpcConnection, MarkdownEditorRpcTransport } from '../preview/markdownEditorRpc'; import { RecoveringTaskQueue } from '../preview/recoveringTaskQueue'; +import { clipboardMediaChunkSize, readClipboardMediaContents } from '../preview/markdownEditorMediaTransfer'; suite('Markdown editor RPC', () => { const disposables: { dispose(): void }[] = []; @@ -56,6 +57,7 @@ suite('Markdown editor RPC', () => { }); host.get(markdownEditorRenderer); renderer.register(markdownEditorRenderer, { + readClipboardMedia: () => ({ base64: 'AQ==' }), configurationChanged: () => { }, diagnosticsChanged: () => { }, update: () => { }, @@ -77,6 +79,36 @@ suite('Markdown editor RPC', () => { return { host, renderer, hostInbox, rendererInbox, errors }; } + test('resolves lazy media chunks while the paste request is awaiting its result', async () => { + const bytes = Buffer.alloc(clipboardMediaChunkSize + 3, 71); + const image = { id: 'video', name: 'clip.mp4', mime: 'video/mp4', size: bytes.length }; + const chunks: Parameters[0][] = []; + const { host, renderer, hostInbox, errors } = pair({ + pasteImages: async request => { + assert.deepStrictEqual(request.images, [image]); + const contents = await readClipboardMediaContents(request.images[0], request => remote.readClipboardMedia(request), () => { }); + assert.deepStrictEqual(contents, Uint8Array.from(bytes)); + return { offset: 4, editEpoch: 1 }; + }, + }, { + readClipboardMedia: request => { + chunks.push(request); + return { base64: bytes.subarray(request.offset, request.offset + request.length).toString('base64') }; + }, + }); + const remote = host.get(markdownEditorRenderer); + const request = { start: 0, endExclusive: 0, editEpoch: 0, images: [image] }; + assert.deepStrictEqual(await renderer.get(markdownEditorHost).pasteImages(request), { offset: 4, editEpoch: 1 }); + assert.deepStrictEqual(hostInbox.messages[0], { + channel: 'markdownEditor', messageSecret: 'secret', + message: { jsonrpc: '2.0', id: 1, method: 'markdown.editor.host::pasteImages', params: request }, + }); + assert.deepStrictEqual({ chunks, errors }, { chunks: [ + { id: 'video', offset: 0, length: clipboardMediaChunkSize }, + { id: 'video', offset: clipboardMediaChunkSize, length: 3 }, + ], errors: [] }); + }); + test('sends presentation notifications without request IDs or acknowledgement messages', async () => { const seen: unknown[] = []; const { host, renderer, hostInbox, rendererInbox, errors } = pair({ diff --git a/src/vs/workbench/contrib/webview/browser/pre/index.html b/src/vs/workbench/contrib/webview/browser/pre/index.html index 2bec59d827a768..e7deb1390db2be 100644 --- a/src/vs/workbench/contrib/webview/browser/pre/index.html +++ b/src/vs/workbench/contrib/webview/browser/pre/index.html @@ -5,7 +5,7 @@ + content="default-src 'none'; script-src 'sha256-7cKy9iQfWRXcgRS74x6I4q5cRDkv8nQz51jecqMmQ5w=' 'self'; frame-src 'self'; style-src 'unsafe-inline';"> } */ + const lastFocusedElements = new WeakMap(); + // propagate focus hostMessaging.onMessage('focus', () => { const activeFrame = getActiveFrame(); @@ -1130,6 +1133,12 @@ contentWindow.addEventListener('scroll', handleInnerScroll); contentWindow.addEventListener('wheel', handleWheel); + contentWindow.addEventListener('focusin', () => { + const focused = /** @type {HTMLElement | null} */ (contentDocument.activeElement); + if (focused && focused !== contentDocument.body && focused !== contentDocument.documentElement) { + lastFocusedElements.set(contentDocument, focused); + } + }); if (wasFocused) { contentWindow.focus(); @@ -1273,7 +1282,13 @@ if (!target) { return; } - assertIsDefined(target.contentDocument).execCommand(data); + const contentDocument = assertIsDefined(target.contentDocument); + const lastFocused = lastFocusedElements.get(contentDocument); + if ((contentDocument.activeElement === contentDocument.body || contentDocument.activeElement === contentDocument.documentElement) + && lastFocused?.isConnected && lastFocused.ownerDocument === contentDocument) { + lastFocused.focus({ preventScroll: true }); + } + contentDocument.execCommand(data); }); /** @type {string | undefined} */ diff --git a/src/vs/workbench/contrib/webview/test/node/webviewShellMessaging.test.ts b/src/vs/workbench/contrib/webview/test/node/webviewShellMessaging.test.ts index a3cfd54b9a161d..6a2892d04cc826 100644 --- a/src/vs/workbench/contrib/webview/test/node/webviewShellMessaging.test.ts +++ b/src/vs/workbench/contrib/webview/test/node/webviewShellMessaging.test.ts @@ -5,9 +5,11 @@ import assert from 'assert'; import { readFileSync } from 'fs'; +import { createHash } from 'crypto'; import { FileAccess } from '../../../../../base/common/network.js'; import { join, resolve } from '../../../../../base/common/path.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { upcastPartial } from '../../../../../base/test/common/mock.js'; suite('Webview shell messaging', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -33,4 +35,62 @@ suite('Webview shell messaging', () => { readyPropertyPostMessage: false, }); }); + + test('restores the last editable before native commands, independent of context-menu dismissal', () => { + const compiledDir = FileAccess.asFileUri('vs/workbench/contrib/webview/test/node').fsPath; + const sourceDir = resolve(compiledDir).replaceAll('\\', '/').replace('/out/vs/workbench/', '/src/vs/workbench/'); + const html = readFileSync(join(sourceDir, '../../browser/pre/index.html'), 'utf8'); + const commandHandler = html.match(/hostMessaging\.onMessage\('execCommand',[\s\S]*?\r?\n\t{3}\}\);/)?.[0]; + assert.ok(commandHandler); + const lastFocusedElements = new WeakMap(); + const body = upcastPartial({}); + let activeElement: Element = body; + let targetAtCommand: Element | undefined; + const calls: string[] = []; + const contentDocument = upcastPartial({ + body, + get activeElement() { return activeElement; }, + execCommand: command => { + targetAtCommand = activeElement; + calls.push(command); + return true; + }, + }); + const textarea = upcastPartial({ + isConnected: true, + ownerDocument: contentDocument, + focus: () => { activeElement = textarea; calls.push('focus'); }, + }); + lastFocusedElements.set(contentDocument, textarea); + let execute: ((_event: undefined, command: string) => void) | undefined; + new Function('hostMessaging', 'getActiveFrame', 'assertIsDefined', 'lastFocusedElements', commandHandler)( + { onMessage: (_channel: string, handler: typeof execute) => { execute = handler; } }, + () => ({ contentDocument }), + (value: T): T => value, + lastFocusedElements, + ); + assert.ok(execute); + execute(undefined, 'paste'); + assert.deepStrictEqual({ calls, correctTarget: targetAtCommand === textarea }, { calls: ['focus', 'paste'], correctTarget: true }); + + const input = upcastPartial({}); + activeElement = input; + execute(undefined, 'copy'); + assert.strictEqual(targetAtCommand, input); + + activeElement = body; + lastFocusedElements.set(contentDocument, upcastPartial({ isConnected: false })); + execute(undefined, 'paste'); + assert.strictEqual(targetAtCommand, body); + }); + + test('matches the preload script CSP hash', () => { + const compiledDir = FileAccess.asFileUri('vs/workbench/contrib/webview/test/node').fsPath; + const sourceDir = resolve(compiledDir).replaceAll('\\', '/').replace('/out/vs/workbench/', '/src/vs/workbench/'); + const html = readFileSync(join(sourceDir, '../../browser/pre/index.html'), 'utf8'); + const script = html.match(/