Skip to content

Commit

Permalink
Add drop feedback UX (#179434)
Browse files Browse the repository at this point in the history
For #179430

Adds two new UX components:

- An inline progress icon shown when a drop operation takes over 500ms. This replaces the notification. You can click on it to cancel the drop

- Post drop, a drop feedback icon that lets you drop the file in a different way. This lets you drop the file as plain text for instance instead of as a markdown link
  • Loading branch information
mjbvz authored Apr 11, 2023
1 parent 5d3f960 commit e926267
Show file tree
Hide file tree
Showing 22 changed files with 653 additions and 139 deletions.
3 changes: 2 additions & 1 deletion extensions/markdown-language-features/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
"Programming Languages"
],
"enabledApiProposals": [
"documentPaste"
"documentPaste",
"dropMetadata"
],
"activationEvents": [
"onLanguage:markdown",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ class PasteEditProvider implements vscode.DocumentPasteEditProvider {
}

const snippet = await tryGetUriListSnippet(document, dataTransfer, token);
return snippet ? new vscode.DocumentPasteEdit(snippet) : undefined;
return snippet ? new vscode.DocumentPasteEdit(snippet.snippet) : undefined;
}

private async _makeCreateImagePasteEdit(document: vscode.TextDocument, file: vscode.DataTransferFile, token: vscode.CancellationToken): Promise<vscode.DocumentPasteEdit | undefined> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,23 @@ export function registerDropIntoEditorSupport(selector: vscode.DocumentSelector)
}

const snippet = await tryGetUriListSnippet(document, dataTransfer, token);
return snippet ? new vscode.DocumentDropEdit(snippet) : undefined;
if (!snippet) {
return undefined;
}

const edit = new vscode.DocumentDropEdit(snippet.snippet);
edit.label = snippet.label;
return edit;
}
}, {
id: 'vscode.markdown.insertLink',
dropMimeTypes: [
'text/uri-list'
]
});
}

export async function tryGetUriListSnippet(document: vscode.TextDocument, dataTransfer: vscode.DataTransfer, token: vscode.CancellationToken): Promise<vscode.SnippetString | undefined> {
export async function tryGetUriListSnippet(document: vscode.TextDocument, dataTransfer: vscode.DataTransfer, token: vscode.CancellationToken): Promise<{ snippet: vscode.SnippetString; label: string } | undefined> {
const urlList = await dataTransfer.get('text/uri-list')?.asString();
if (!urlList || token.isCancellationRequested) {
return undefined;
Expand All @@ -58,7 +69,17 @@ export async function tryGetUriListSnippet(document: vscode.TextDocument, dataTr
}
}

return createUriListSnippet(document, uris);
const snippet = createUriListSnippet(document, uris);
if (!snippet) {
return undefined;
}

return {
snippet: snippet,
label: uris.length > 1
? vscode.l10n.t('Insert uri links')
: vscode.l10n.t('Insert uri link')
};
}

interface UriListSnippetOptions {
Expand Down
3 changes: 2 additions & 1 deletion extensions/markdown-language-features/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"include": [
"src/**/*",
"../../src/vscode-dts/vscode.d.ts",
"../../src/vscode-dts/vscode.proposed.documentPaste.d.ts"
"../../src/vscode-dts/vscode.proposed.documentPaste.d.ts",
"../../src/vscode-dts/vscode.proposed.dropMetadata.d.ts"
]
}
3 changes: 2 additions & 1 deletion src/vs/editor/browser/widget/codeEditorWidget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2242,7 +2242,7 @@ class EditorDecorationsCollection implements editorCommon.IEditorDecorationsColl
this.set([]);
}

public set(newDecorations: IModelDeltaDecoration[]): void {
public set(newDecorations: readonly IModelDeltaDecoration[]): string[] {
try {
this._isChangingDecorations = true;
this._editor.changeDecorations((accessor) => {
Expand All @@ -2251,6 +2251,7 @@ class EditorDecorationsCollection implements editorCommon.IEditorDecorationsColl
} finally {
this._isChangingDecorations = false;
}
return this._decorationIds;
}
}

Expand Down
26 changes: 23 additions & 3 deletions src/vs/editor/common/config/editorOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4701,10 +4701,16 @@ class EditorWrappingInfoComputer extends ComputedEditorOption<EditorOption.wrapp
*/
export interface IDropIntoEditorOptions {
/**
* Enable the dropping into editor.
* Enable dropping into editor.
* Defaults to true.
*/
enabled?: boolean;

/**
* Controls if a widget is shown after a drop.
* Defaults to 'afterDrop'.
*/
showDropSelector?: 'afterDrop' | 'never';
}

/**
Expand All @@ -4715,7 +4721,7 @@ export type EditorDropIntoEditorOptions = Readonly<Required<IDropIntoEditorOptio
class EditorDropIntoEditor extends BaseEditorOption<EditorOption.dropIntoEditor, IDropIntoEditorOptions, EditorDropIntoEditorOptions> {

constructor() {
const defaults: EditorDropIntoEditorOptions = { enabled: true };
const defaults: EditorDropIntoEditorOptions = { enabled: true, showDropSelector: 'afterDrop' };
super(
EditorOption.dropIntoEditor, 'dropIntoEditor', defaults,
{
Expand All @@ -4724,6 +4730,19 @@ class EditorDropIntoEditor extends BaseEditorOption<EditorOption.dropIntoEditor,
default: defaults.enabled,
markdownDescription: nls.localize('dropIntoEditor.enabled', "Controls whether you can drag and drop a file into a text editor by holding down `shift` (instead of opening the file in an editor)."),
},
'editor.dropIntoEditor.showDropSelector': {
type: 'string',
markdownDescription: nls.localize('dropIntoEditor.showDropSelector', "Controls if a widget is shown when dropping files into the editor. This widget lets you control how the file is dropped."),
enum: [
'afterDrop',
'never'
],
enumDescriptions: [
nls.localize('dropIntoEditor.showDropSelector.afterDrop', "Show the drop selector widget after a file is dropped into the editor."),
nls.localize('dropIntoEditor.showDropSelector.never', "Never show the drop selector widget. Instead the default drop provider is always used."),
],
default: 'afterDrop',
},
}
);
}
Expand All @@ -4734,7 +4753,8 @@ class EditorDropIntoEditor extends BaseEditorOption<EditorOption.dropIntoEditor,
}
const input = _input as IDropIntoEditorOptions;
return {
enabled: boolean(input.enabled, this.defaultValue.enabled)
enabled: boolean(input.enabled, this.defaultValue.enabled),
showDropSelector: stringSet(input.showDropSelector, this.defaultValue.showDropSelector, ['afterDrop', 'never']),
};
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/vs/editor/common/editorCommon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -545,7 +545,7 @@ export interface IEditorDecorationsCollection {
/**
* Replace all previous decorations with `newDecorations`.
*/
set(newDecorations: IModelDeltaDecoration[]): void;
set(newDecorations: readonly IModelDeltaDecoration[]): string[];
/**
* Remove all previous decorations.
*/
Expand Down
5 changes: 5 additions & 0 deletions src/vs/editor/common/languages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1917,6 +1917,8 @@ export enum ExternalUriOpenerPriority {
* @internal
*/
export interface DocumentOnDropEdit {
readonly label: string;

insertText: string | { snippet: string };
additionalEdit?: WorkspaceEdit;
}
Expand All @@ -1925,5 +1927,8 @@ export interface DocumentOnDropEdit {
* @internal
*/
export interface DocumentOnDropEditProvider {
readonly id?: string;
readonly dropMimeTypes?: readonly string[];

provideDocumentOnDropEdits(model: model.ITextModel, position: IPosition, dataTransfer: VSDataTransfer, token: CancellationToken): ProviderResult<DocumentOnDropEdit>;
}
2 changes: 1 addition & 1 deletion src/vs/editor/common/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,7 @@ export interface IModelDecorationsChangeAccessor {
* @param newDecorations Array describing what decorations should result after the call.
* @return An array containing the new decorations identifiers.
*/
deltaDecorations(oldDecorations: string[], newDecorations: IModelDeltaDecoration[]): string[];
deltaDecorations(oldDecorations: readonly string[], newDecorations: readonly IModelDeltaDecoration[]): string[];
}

/**
Expand Down
109 changes: 109 additions & 0 deletions src/vs/editor/contrib/dropIntoEditor/browser/defaultOnDropProviders.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { CancellationToken } from 'vs/base/common/cancellation';
import { UriList, VSDataTransfer } from 'vs/base/common/dataTransfer';
import { Mimes } from 'vs/base/common/mime';
import { Schemas } from 'vs/base/common/network';
import { relativePath } from 'vs/base/common/resources';
import { URI } from 'vs/base/common/uri';
import { IPosition } from 'vs/editor/common/core/position';
import { DocumentOnDropEdit, DocumentOnDropEditProvider } from 'vs/editor/common/languages';
import { ITextModel } from 'vs/editor/common/model';
import { ILanguageFeaturesService } from 'vs/editor/common/services/languageFeatures';
import { localize } from 'vs/nls';
import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace';

class DefaultTextDropProvider implements DocumentOnDropEditProvider {

readonly id = 'text';
readonly dropMimeTypes = [Mimes.text, 'text'];

async provideDocumentOnDropEdits(_model: ITextModel, _position: IPosition, dataTransfer: VSDataTransfer, _token: CancellationToken): Promise<DocumentOnDropEdit | undefined> {
const textEntry = dataTransfer.get('text') ?? dataTransfer.get(Mimes.text);
if (textEntry) {
const text = await textEntry.asString();
return {
label: localize('defaultDropProvider.text.label', "Drop as plain text"),
insertText: text
};
}

return undefined;
}
}

class DefaultUriListDropProvider implements DocumentOnDropEditProvider {

readonly id = 'uri';
readonly dropMimeTypes = [Mimes.uriList];

constructor(
@IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService
) { }

async provideDocumentOnDropEdits(_model: ITextModel, _position: IPosition, dataTransfer: VSDataTransfer, _token: CancellationToken): Promise<DocumentOnDropEdit | undefined> {
const urlListEntry = dataTransfer.get(Mimes.uriList);
if (urlListEntry) {
const urlList = await urlListEntry.asString();
const entry = this.getUriListInsertText(urlList);
if (entry) {
return {
label: entry.count > 1
? localize('defaultDropProvider.uri.label', "Drop as uri")
: localize('defaultDropProvider.uriList.label', "Drop as uri list"),
insertText: entry.snippet
};
}
}

return undefined;
}

private getUriListInsertText(strUriList: string): { snippet: string; count: number } | undefined {
const entries: { readonly uri: URI; readonly originalText: string }[] = [];
for (const entry of UriList.parse(strUriList)) {
try {
entries.push({ uri: URI.parse(entry), originalText: entry });
} catch {
// noop
}
}

if (!entries.length) {
return;
}

const snippet = entries
.map(({ uri, originalText }) => {
const root = this._workspaceContextService.getWorkspaceFolder(uri);
if (root) {
const rel = relativePath(root.uri, uri);
if (rel) {
return rel;
}
}

return uri.scheme === Schemas.file ? uri.fsPath : originalText;
})
.join(' ');

return { snippet, count: entries.length };
}
}


let registeredDefaultProviders = false;
export function registerDefaultDropProviders(
languageFeaturesService: ILanguageFeaturesService,
workspaceContextService: IWorkspaceContextService,
) {
if (!registeredDefaultProviders) {
registeredDefaultProviders = true;

languageFeaturesService.documentOnDropEditProvider.register('*', new DefaultTextDropProvider());
languageFeaturesService.documentOnDropEditProvider.register('*', new DefaultUriListDropProvider(workspaceContextService));
}
}
Loading

0 comments on commit e926267

Please sign in to comment.