Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for pasting images into markdown notebook cells #156847

Merged
merged 29 commits into from Aug 8, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
0973969
dataflow support for updated metadata
Jul 22, 2022
f95bbec
update cellAttachmentRenderer.ts to reflect metadata being a getter()…
Jul 22, 2022
589d22c
document paste additions
Jul 25, 2022
e3ef66d
Merge branch 'main' into mlively/imagePaste
Jul 25, 2022
9908f42
Merge branch 'main' into mlively/imagePaste
Jul 25, 2022
5bffbdd
update condition to re-render cells, now includes metadata changes
Jul 25, 2022
90c4760
Merge branch 'main' into mlively/imagePaste
Jul 26, 2022
1ed7ca0
Merge branch 'main' into mlively/imagePaste
Jul 27, 2022
f8b7b68
Merge branch 'main' into mlively/imagePaste
Jul 28, 2022
6b548a3
paste API working, debugging command added
Jul 28, 2022
0ea89d0
Merge branch 'main' into mlively/imagePaste
Jul 28, 2022
0cb767f
paste working with metadata. needs numbering, and cleaning upon delete
Aug 1, 2022
9efc1d3
Merge branch 'main' into mlively/updateCellMetadata
Aug 1, 2022
9fecfbe
paste screenshot works fully
Aug 1, 2022
64dd6b5
Merge branch 'main' into mlively/imagePaste
Aug 1, 2022
d4a9077
remove debugging command. Cleaning.
Aug 1, 2022
3cf46d3
notebook cells now re-render upon metadata changes
Aug 2, 2022
acae990
Merge branch 'mlively/updateCellMetadata' into mlively/imagePaste
Aug 2, 2022
129f32e
changed name validity checking, remove unneeded function
Aug 2, 2022
ff6308b
Merge branch 'main' into mlively/imagePaste
Aug 3, 2022
fed747e
use _document for cell data, use snippet choice, dto fix
Aug 3, 2022
cff08ac
Merge branch 'main' into mlively/imagePaste
Aug 4, 2022
c431dda
Merge branch 'main' into mlively/imagePaste
Aug 4, 2022
d50b090
return subscription, for loop, uri fix, alter metadata in-place, bett…
Aug 4, 2022
d89a959
metadata fix, object.equals, fix cellAttRenderer metadata call
Aug 4, 2022
5f0c10c
Merge branch 'main' into mlively/imagePaste
Aug 4, 2022
4f517ba
added comment with source of encodeBase64
Aug 5, 2022
3d239dd
Merge branch 'main' into mlively/imagePaste
Aug 8, 2022
8ec3c3b
gate mkdn image paste behind experimental setting
Aug 8, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
18 changes: 17 additions & 1 deletion extensions/ipynb/package.json
Expand Up @@ -9,7 +9,8 @@
"vscode": "^1.57.0"
},
"enabledApiProposals": [
"notebookWorkspaceEdit"
"notebookWorkspaceEdit",
"documentPaste"
],
"activationEvents": [
"*"
Expand All @@ -27,6 +28,21 @@
}
},
"contributes": {
"configuration":[
{
"properties": {
"ipynb.experimental.pasteImages.enabled":{
"type": "boolean",
"scope": "resource",
"markdownDescription": "%ipynb.experimental.pasteImages.enabled%",
"default": false,
"tags": [
"experimental"
]
}
}
}
],
"commands": [
{
"command": "ipynb.newUntitledIpynb",
Expand Down
3 changes: 2 additions & 1 deletion extensions/ipynb/package.nls.json
@@ -1,4 +1,5 @@
{
"displayName": ".ipynb support",
"description": "Provides basic support for opening and reading Jupyter's .ipynb notebook files"
"description": "Provides basic support for opening and reading Jupyter's .ipynb notebook files",
"ipynb.experimental.pasteImages.enabled":"Enable/Disable pasting images into markdown cells within ipynb files. Requires enabling `#ipynb.experimental.pasteImages.enabled#`."
}
2 changes: 1 addition & 1 deletion extensions/ipynb/src/cellAttachmentRenderer.ts
Expand Up @@ -22,7 +22,7 @@ export async function activate(ctx: RendererContext<void>) {
md.renderer.rules.image = (tokens: MarkdownItToken[], idx: number, options, env, self) => {
const token = tokens[idx];
const src = token.attrGet('src');
const attachments: Record<string, Record<string, string>> = env.outputItem.metadata().custom?.attachments;
const attachments: Record<string, Record<string, string>> = env.outputItem.metadata.custom?.attachments;
if (attachments && src) {
const imageAttachment = attachments[src.replace('attachment:', '')];
if (imageAttachment) {
Expand Down
4 changes: 4 additions & 0 deletions extensions/ipynb/src/ipynbMain.ts
Expand Up @@ -6,6 +6,7 @@
import * as vscode from 'vscode';
import { ensureAllNewCellsHaveCellIds } from './cellIdService';
import { NotebookSerializer } from './notebookSerializer';
import * as NotebookImagePaste from './notebookImagePaste';

// From {nbformat.INotebookMetadata} in @jupyterlab/coreutils
type NotebookMetadata = {
Expand Down Expand Up @@ -77,12 +78,15 @@ export function activate(context: vscode.ExtensionContext) {
await vscode.window.showNotebookDocument(document);
}));

context.subscriptions.push(NotebookImagePaste.imagePasteSetup());

// Update new file contribution
vscode.extensions.onDidChange(() => {
vscode.commands.executeCommand('setContext', 'jupyterEnabled', vscode.extensions.getExtension('ms-toolsai.jupyter'));
});
vscode.commands.executeCommand('setContext', 'jupyterEnabled', vscode.extensions.getExtension('ms-toolsai.jupyter'));


return {
exportNotebook: (notebook: vscode.NotebookData): string => {
return exportNotebook(notebook, serializer);
Expand Down
143 changes: 143 additions & 0 deletions extensions/ipynb/src/notebookImagePaste.ts
@@ -0,0 +1,143 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import * as vscode from 'vscode';

class CopyPasteEditProvider implements vscode.DocumentPasteEditProvider {

async provideDocumentPasteEdits(
_document: vscode.TextDocument,
_ranges: readonly vscode.Range[],
dataTransfer: vscode.DataTransfer,
_token: vscode.CancellationToken
): Promise<vscode.DocumentPasteEdit | undefined> {

const enabled = vscode.workspace.getConfiguration('ipynb', _document).get('experimental.pasteImages.enabled', false);
if (!enabled) {
return undefined;
}

// get b64 data from paste
// TODO: dataTransfer.get() limits to one image pasted
const dataItem = dataTransfer.get('image/png');
Yoyokrazy marked this conversation as resolved.
Show resolved Hide resolved
Yoyokrazy marked this conversation as resolved.
Show resolved Hide resolved
if (!dataItem) {
return undefined;
}
const fileDataAsUint8 = await dataItem.asFile()?.data();
if (!fileDataAsUint8) {
return undefined;
}

// get filename data from paste
let pasteFilename = dataItem.asFile()?.name;
if (!pasteFilename) {
return undefined;
}
const separatorIndex = pasteFilename?.lastIndexOf('.');
const filename = pasteFilename?.slice(0, separatorIndex);
const filetype = pasteFilename?.slice(separatorIndex);
if (!filename || !filetype) {
return undefined;
}

// get notebook cell data
let notebookUri;
let currentCell;
for (const notebook of vscode.workspace.notebookDocuments) {
if (notebook.uri.path === _document.uri.path) {
for (const cell of notebook.getCells()) {
if (cell.document === _document) {
currentCell = cell;
notebookUri = notebook.uri;
break;
}
}
}
}
if (!currentCell || !notebookUri) {
return undefined;
}

// create updated metadata for cell (prep for WorkspaceEdit)
const b64string = encodeBase64(fileDataAsUint8);
const startingAttachments = currentCell.metadata?.custom?.attachments;
if (!startingAttachments) {
currentCell.metadata.custom['attachments'] = { [pasteFilename]: { 'image/png': b64string } };
} else {
for (let appendValue = 2; pasteFilename in startingAttachments; appendValue++) {
const objEntries = Object.entries(startingAttachments[pasteFilename]);
if (objEntries.length) { // check that mime:b64 are present
const [, attachmentb64] = objEntries[0];
if (attachmentb64 !== b64string) { // append a "-#" here. same name, diff data. this matches jupyter behavior
pasteFilename = filename.concat(`-${appendValue}`) + filetype;
}
}
}
currentCell.metadata.custom.attachments[pasteFilename] = { 'image/png': b64string };
}

const metadataNotebookEdit = vscode.NotebookEdit.updateCellMetadata(currentCell.index, currentCell.metadata);
const workspaceEdit = new vscode.WorkspaceEdit();
if (metadataNotebookEdit) {
workspaceEdit.set(notebookUri, [metadataNotebookEdit]);
}

// create a snippet for paste
const pasteSnippet = new vscode.SnippetString();
pasteSnippet.appendText('![');
pasteSnippet.appendPlaceholder(`${pasteFilename}`);
pasteSnippet.appendText(`](attachment:${pasteFilename})`);

return { insertText: pasteSnippet, additionalEdit: workspaceEdit };
}
}

export function imagePasteSetup() {
const selector: vscode.DocumentSelector = { notebookType: 'jupyter-notebook', language: 'markdown' }; // this is correct provider
return vscode.languages.registerDocumentPasteEditProvider(selector, new CopyPasteEditProvider(), {
pasteMimeTypes: ['image/png'],
});
}

/**
* Taken from https://github.com/microsoft/vscode/blob/743b016722db90df977feecde0a4b3b4f58c2a4c/src/vs/base/common/buffer.ts#L350-L387
*/
function encodeBase64(buffer: Uint8Array, padded = true, urlSafe = false) {
Yoyokrazy marked this conversation as resolved.
Show resolved Hide resolved
const base64Alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
const base64UrlSafeAlphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';

const dictionary = urlSafe ? base64UrlSafeAlphabet : base64Alphabet;
let output = '';

const remainder = buffer.byteLength % 3;

let i = 0;
for (; i < buffer.byteLength - remainder; i += 3) {
const a = buffer[i + 0];
const b = buffer[i + 1];
const c = buffer[i + 2];

output += dictionary[a >>> 2];
output += dictionary[(a << 4 | b >>> 4) & 0b111111];
output += dictionary[(b << 2 | c >>> 6) & 0b111111];
output += dictionary[c & 0b111111];
}

if (remainder === 1) {
const a = buffer[i + 0];
output += dictionary[a >>> 2];
output += dictionary[(a << 4) & 0b111111];
if (padded) { output += '=='; }
} else if (remainder === 2) {
const a = buffer[i + 0];
const b = buffer[i + 1];
output += dictionary[a >>> 2];
output += dictionary[(a << 4 | b >>> 4) & 0b111111];
output += dictionary[(b << 2) & 0b111111];
if (padded) { output += '='; }
}

return output;
}
3 changes: 2 additions & 1 deletion extensions/ipynb/tsconfig.json
Expand Up @@ -9,6 +9,7 @@
"include": [
"src/**/*",
"../../src/vscode-dts/vscode.d.ts",
"../../src/vscode-dts/vscode.proposed.notebookWorkspaceEdit.d.ts"
"../../src/vscode-dts/vscode.proposed.notebookWorkspaceEdit.d.ts",
"../../src/vscode-dts/vscode.proposed.documentPaste.d.ts"
]
}
Expand Up @@ -13,6 +13,7 @@ import { getExtensionForMimeType } from 'vs/base/common/mime';
import { FileAccess, Schemas } from 'vs/base/common/network';
import { isMacintosh, isWeb } from 'vs/base/common/platform';
import { dirname, joinPath } from 'vs/base/common/resources';
import { equals } from 'vs/base/common/objects';
import { URI } from 'vs/base/common/uri';
import * as UUID from 'vs/base/common/uuid';
import { TokenizationRegistry } from 'vs/editor/common/languages';
Expand Down Expand Up @@ -1042,7 +1043,7 @@ var requirejs = (function() {
}

const sameContent = newContent.content === entry.content;
const sameMetadata = newContent.metadata === entry.metadata;
const sameMetadata = (equals(newContent.metadata, entry.metadata));
if (!sameContent || !sameMetadata || !entry.visible) {
this._sendMessageToWebview({
type: 'showMarkupCell',
Expand Down
Expand Up @@ -1668,6 +1668,7 @@ async function webviewPreloads(ctx: PreloadContext) {
private _content: { readonly value: string; readonly version: number; readonly metadata: NotebookCellMetadata };

constructor(id: string, mime: string, content: string, top: number, metadata: NotebookCellMetadata) {
const self = this;
this.id = id;
this._content = { value: content, version: 0, metadata: metadata };

Expand All @@ -1679,8 +1680,8 @@ async function webviewPreloads(ctx: PreloadContext) {
id,
mime,

metadata: (): NotebookCellMetadata => {
return this._content.metadata;
get metadata(): NotebookCellMetadata {
return self._content.metadata;
},

text: (): string => {
Expand Down