From 7fb81604be0478230d7fe3e1a4ae0916c10002ff Mon Sep 17 00:00:00 2001
From: Myriad-Dreamin <35292584+Myriad-Dreamin@users.noreply.github.com>
Date: Mon, 17 Mar 2025 16:00:11 +0800
Subject: [PATCH] feat: paste uri smartly (#1500)
---
editors/vscode/src/features/drop-paste.def.ts | 16 +-
editors/vscode/src/features/drop-paste.ts | 234 +++++++++++++++---
2 files changed, 214 insertions(+), 36 deletions(-)
diff --git a/editors/vscode/src/features/drop-paste.def.ts b/editors/vscode/src/features/drop-paste.def.ts
index 1af1760eb..e30a49254 100644
--- a/editors/vscode/src/features/drop-paste.def.ts
+++ b/editors/vscode/src/features/drop-paste.def.ts
@@ -64,25 +64,25 @@ export const typstPasteLinkEditKind = vscode.DocumentDropOrPasteEditKind.Empty.a
);
/** Kind for normal markdown links, i.e. include "path/to/file.typ" */
-export const typstPasteUriEditKind = typstPasteLinkEditKind.append("uri");
+export const typstUriEditKind = typstPasteLinkEditKind.append("uri");
-export const typstPasteImageEditKind = typstPasteLinkEditKind.append("image");
+export const typstImageEditKind = typstPasteLinkEditKind.append("image");
export const Mime = {
textUriList: "text/uri-list",
textPlain: "text/plain",
} as const;
-export const mediaMimes = new Set([
+export const typstSupportedMimes = new Set([
"image/avif",
"image/bmp",
"image/gif",
"image/jpeg",
"image/png",
"image/webp",
- "video/mp4",
- "video/ogg",
- "audio/mpeg",
- "audio/aac",
- "audio/x-wav",
+ // "video/mp4",
+ // "video/ogg",
+ // "audio/mpeg",
+ // "audio/aac",
+ // "audio/x-wav",
]);
diff --git a/editors/vscode/src/features/drop-paste.ts b/editors/vscode/src/features/drop-paste.ts
index b340293bd..44a7c6ac7 100644
--- a/editors/vscode/src/features/drop-paste.ts
+++ b/editors/vscode/src/features/drop-paste.ts
@@ -7,12 +7,12 @@ import { dirname, extname, basename, relative } from "path";
import { typstDocumentSelector } from "../util";
import {
Mime,
- mediaMimes,
+ typstSupportedMimes,
PasteResourceKind,
pasteResourceKinds as pasteResourceKinds,
- typstPasteImageEditKind,
+ typstImageEditKind,
typstPasteLinkEditKind,
- typstPasteUriEditKind,
+ typstUriEditKind,
Schemes,
} from "./drop-paste.def";
import { IContext } from "../context";
@@ -24,16 +24,17 @@ export function dragAndDropActivate(context: IContext) {
}
export function copyAndPasteActivate(context: IContext) {
- const providedEditKinds = [
- typstPasteLinkEditKind,
- typstPasteUriEditKind,
- typstPasteImageEditKind,
- ];
+ const providedEditKinds = [typstPasteLinkEditKind, typstUriEditKind, typstImageEditKind];
+ const sel = typstDocumentSelector;
context.subscriptions.push(
- vscode.languages.registerDocumentPasteEditProvider(typstDocumentSelector, new PasteProvider(), {
+ vscode.languages.registerDocumentPasteEditProvider(sel, new PasteUriProvider(), {
+ providedPasteEditKinds: [typstPasteLinkEditKind],
+ pasteMimeTypes: PasteUriProvider.mimeTypes,
+ }),
+ vscode.languages.registerDocumentPasteEditProvider(sel, new PasteResourceProvider(), {
providedPasteEditKinds: providedEditKinds,
- pasteMimeTypes: PasteProvider.mimeTypes,
+ pasteMimeTypes: PasteResourceProvider.mimeTypes,
}),
);
}
@@ -54,12 +55,21 @@ interface ResolvedEdits {
}
class DropOrPasteContext {
+ title: string;
+ editKind = typstUriEditKind;
+
constructor(
private kind: A,
private context: vscode.DocumentPasteEditContext | undefined,
private document: vscode.TextDocument,
private token: vscode.CancellationToken,
- ) {}
+ ) {
+ if (this.kind === DropPasteAction.Drop) {
+ this.title = "Drop (Typst)";
+ } else {
+ this.title = "Paste (Typst)";
+ }
+ }
private readonly _yieldTo = [
vscode.DocumentDropOrPasteEditKind.Text,
@@ -79,7 +89,7 @@ class DropOrPasteContext {
dropEdit.yieldTo = [...this._yieldTo, ...edit.yieldTo];
return dropEdit as EditClass;
} else {
- const pasteEdit = new vscode.DocumentPasteEdit(edit.snippet, "Paste", typstPasteUriEditKind);
+ const pasteEdit = new vscode.DocumentPasteEdit(edit.snippet, this.title, this.editKind);
pasteEdit.additionalEdit = edit.additionalEdits;
pasteEdit.yieldTo = [...this._yieldTo, ...edit.yieldTo];
return pasteEdit as EditClass;
@@ -95,6 +105,7 @@ class DropOrPasteContext {
if (mediaFiles) {
const edit = await this.handleMediaFiles(ranges, mediaFiles);
if (edit) {
+ this.editKind = typstImageEditKind;
this.resolved.push(edit);
return this.wrapRangeAsLinkContent();
}
@@ -102,7 +113,7 @@ class DropOrPasteContext {
const uriList = await this.takeUriList(dataTransfer);
if (uriList) {
- const edit = await this.editByUriList(ranges, uriList);
+ const edit = await this.editByUriList(ranges, uriList, false);
if (edit) {
this.resolved.push(edit);
return this.wrapRangeAsLinkContent();
@@ -113,6 +124,32 @@ class DropOrPasteContext {
return false;
}
+ async pasteUri(ranges: readonly vscode.Range[], dataTransfer: vscode.DataTransfer) {
+ this.editKind = typstUriEditKind;
+ this.title = "Paste Link (Typst)";
+ const item = dataTransfer.get(Mime.textPlain);
+ const text = await item?.asString();
+ if (this.token.isCancellationRequested || !text) {
+ return;
+ }
+
+ // TODO: If the user has explicitly requested to paste as a typst link,
+ // try to paste even if we don't have a valid uri
+ const uriText = findValidUriInText(text);
+ if (!uriText) {
+ return;
+ }
+
+ const uriList = UriList.from(uriText);
+ const edit = await this.editByUriList(ranges, uriList, false);
+ if (edit) {
+ this.resolved.push(edit);
+ return this.wrapRangeAsLinkContent();
+ }
+
+ return false;
+ }
+
wrapRangeAsLinkContent(): boolean {
// todo: link content support
// if (
@@ -134,7 +171,7 @@ class DropOrPasteContext {
const fileEntries = coalesce(
await Promise.all(
Array.from(dataTransfer, async ([mime, item]): Promise => {
- if (!mediaMimes.has(mime)) {
+ if (!typstSupportedMimes.has(mime)) {
return;
}
@@ -183,7 +220,7 @@ class DropOrPasteContext {
if (
uriList.entries.length === 1 &&
[Schemes.http, Schemes.https].includes(uriList.entries[0].uri.scheme as Schemes) &&
- !this.context?.only?.contains(typstPasteUriEditKind)
+ !this.context?.only?.contains(typstUriEditKind)
) {
const text = await dataTransfer.get(Mime.textPlain)?.asString();
if (this.token.isCancellationRequested) {
@@ -203,7 +240,7 @@ class DropOrPasteContext {
mediaFiles.map((entry) => ({ uri: entry.uri, str: entry.uri.toString() })),
);
- return this.editByUriList(ranges, mediaUriList, (additionalEdits) => {
+ return this.editByUriList(ranges, mediaUriList, true, (additionalEdits) => {
for (const entry of mediaFiles) {
if (entry.newFile) {
additionalEdits.createFile(entry.uri, {
@@ -218,6 +255,7 @@ class DropOrPasteContext {
async editByUriList(
ranges: readonly vscode.Range[],
uriList: UriList,
+ isMedia: boolean,
createAdditionalEdits?: (additionalEdits: vscode.WorkspaceEdit) => void,
): Promise {
if (uriList.entries.length !== 1) {
@@ -239,12 +277,14 @@ class DropOrPasteContext {
const additionalImports = new Set();
+ let resolved = true;
for (const range of orderedRanges) {
const snippetEdit = await this.createUriListSnippet(uriList, range, {
- placeholderText: range.isEmpty ? undefined : this.document.getText(range),
+ isMedia,
placeholderStartIndex: allRangesAreEmpty ? 1 : placeHolderStartIndex,
});
if (!snippetEdit) {
+ resolved = false;
continue;
}
@@ -263,6 +303,10 @@ class DropOrPasteContext {
}
}
+ if (!resolved) {
+ return;
+ }
+
const imports = Array.from(additionalImports).sort();
if (imports.length > 0) {
additionalEdits.insert(this.document.uri, new vscode.Position(0, 0), imports.join(""));
@@ -280,16 +324,77 @@ class DropOrPasteContext {
async createUriListSnippet(
uriList: UriList,
range: vscode.Range,
- _exts: { placeholderText: string | undefined; placeholderStartIndex: number },
+ exts: { isMedia: boolean; placeholderStartIndex: number },
) {
if (uriList.entries.length !== 1) {
vscode.window.showErrorMessage("Only one URI can be pasted at a time.");
return;
}
+ const entry = uriList.entries[0];
+
+ if (exts.isMedia || entry.uri.scheme === "file" || entry.uri.scheme === "untitled") {
+ return this.createLocalUriListSnippet(entry.uri, range, exts);
+ } else {
+ return this.createRemoteUriListSnippet(entry.uri, range, exts);
+ }
+ }
+
+ async createRemoteUriListSnippet(
+ dragFileUri: vscode.Uri,
+ range: vscode.Range,
+ _exts: { placeholderStartIndex: number },
+ ) {
+ // todo: check valid plain url, because some url may contain invalid characters for http markup.
+ if (range.isEmpty) {
+ return;
+ }
+
+ const res = await vscode.commands.executeCommand<
+ [{ mode: "math" | "markup" | "code" | "comment" | "string" | "raw" }]
+ >("tinymist.interactCodeContext", {
+ textDocument: {
+ uri: this.document.uri.toString(),
+ },
+ query: [
+ {
+ kind: "modeAt",
+ position: {
+ line: range.start.line,
+ character: range.start.character,
+ },
+ },
+ ],
+ });
+
+ const linkText = dragFileUri.toString();
+ const wrappedText = this.document.getText(range);
+ let text = "";
+ switch (res?.[0]?.mode || undefined) {
+ case "markup":
+ text = `#link("${escapeStr(linkText)}")[${wrappedText}]`;
+ break;
+ case "math":
+ text = `#link("${escapeStr(linkText)}", $${wrappedText}$)`;
+ break;
+ case "code":
+ text = `link("${escapeStr(linkText)}", {${wrappedText}})`;
+ break;
+ case "string":
+ case "comment":
+ case "raw":
+ return undefined;
+ }
+
+ return [text, []] as const;
+ }
+
+ async createLocalUriListSnippet(
+ dragFileUri: vscode.Uri,
+ range: vscode.Range,
+ _exts: { placeholderStartIndex: number },
+ ) {
const dropFileUri = this.document.uri;
- // todo: multiple files
- const dragFileUri = uriList.entries[0].uri;
let dragFilePath = "";
const workspaceFolder = vscode.workspace.getWorkspaceFolder(dragFileUri);
@@ -301,8 +406,8 @@ class DropOrPasteContext {
dragFilePath = relative(dirname(dropFileUri.fsPath), dragFileUri.fsPath);
}
- const barPath = dragFilePath.replace(/\\/g, "/");
- const strPath = `"${barPath}"`;
+ const barStrPath = escapeStr(dragFilePath.replace(/\\/g, "/"));
+ const strPath = `"${barStrPath}"`;
let codeSnippet = strPath;
const resourceKind: PasteResourceKind | undefined =
pasteResourceKinds[extname(dragFileUri.fsPath)];
@@ -374,7 +479,7 @@ class DropOrPasteContext {
});
let text = codeSnippet;
- switch (res?.[0]?.mode) {
+ switch (res?.[0]?.mode || undefined) {
case "math":
case "markup":
text = `#${codeSnippet}`;
@@ -382,11 +487,13 @@ class DropOrPasteContext {
case "code":
text = codeSnippet;
break;
+ case "string":
+ text = barStrPath;
+ break;
case "comment":
case "raw":
- case "string":
- text = barPath;
- break;
+ case undefined:
+ return undefined;
}
const additionalImports = [];
@@ -430,8 +537,8 @@ export class DropProvider implements vscode.DocumentDropEditProvider {
}
}
-export class PasteProvider implements vscode.DocumentPasteEditProvider {
- public static readonly mimeTypes = [Mime.textUriList, "files", ...mediaMimes];
+export class PasteResourceProvider implements vscode.DocumentPasteEditProvider {
+ public static readonly mimeTypes = [Mime.textUriList, "files", ...typstSupportedMimes];
public async provideDocumentPasteEdits(
document: vscode.TextDocument,
@@ -452,6 +559,27 @@ export class PasteProvider implements vscode.DocumentPasteEditProvider {
}
}
+export class PasteUriProvider implements vscode.DocumentPasteEditProvider {
+ public static readonly mimeTypes = [Mime.textPlain];
+
+ public async provideDocumentPasteEdits(
+ document: vscode.TextDocument,
+ ranges: readonly vscode.Range[],
+ dataTransfer: vscode.DataTransfer,
+ context: vscode.DocumentPasteEditContext,
+ token: vscode.CancellationToken,
+ ): Promise {
+ const ctx = new PasteContext(DropPasteAction.Paste, context, document, token);
+
+ const transferred = await ctx.pasteUri(ranges, dataTransfer);
+ if (!transferred || token.isCancellationRequested) {
+ return;
+ }
+
+ return ctx.finalize();
+ }
+}
+
type OverwriteBehavior = "overwrite" | "nameIncrementally";
export interface CopyFileConfiguration {
@@ -598,6 +726,56 @@ export class UriList {
) {}
}
+const externalUriSchemes: ReadonlySet = new Set([
+ Schemes.http,
+ Schemes.https,
+ Schemes.mailto,
+ Schemes.file,
+]);
+
+export function findValidUriInText(text: string): string | undefined {
+ const trimmedUrlList = text.trim();
+
+ if (
+ !/^\S+$/.test(trimmedUrlList) || // Uri must consist of a single sequence of characters without spaces
+ !trimmedUrlList.includes(":") // And it must have colon somewhere for the scheme. We will verify the schema again later
+ ) {
+ return;
+ }
+
+ let uri: vscode.Uri;
+ try {
+ uri = vscode.Uri.parse(trimmedUrlList);
+ } catch {
+ // Could not parse
+ return;
+ }
+
+ // `Uri.parse` is lenient and will return a `file:` uri even for non-uri text such as `abc`
+ // Make sure that the resolved scheme starts the original text
+ if (!trimmedUrlList.toLowerCase().startsWith(uri.scheme.toLowerCase() + ":")) {
+ return;
+ }
+
+ // Only enable for an allow list of schemes. Otherwise this can be accidentally activated for non-uri text
+ // such as `c:\abc` or `value:foo`
+ if (!externalUriSchemes.has(uri.scheme.toLowerCase())) {
+ return;
+ }
+
+ // Some part of the uri must not be empty
+ // This disables the feature for text such as `http:`
+ if (!uri.authority && uri.path.length < 2 && !uri.query && !uri.fragment) {
+ return;
+ }
+
+ return trimmedUrlList;
+}
+
function coalesce(array: ReadonlyArray): T[] {
return array.filter((e) => !!e);
}
+
+function escapeStr(str: string): string {
+ return str.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
+}