diff --git a/apps/vscode/CHANGELOG.md b/apps/vscode/CHANGELOG.md index 3d48dc13e..3c984cf8c 100644 --- a/apps/vscode/CHANGELOG.md +++ b/apps/vscode/CHANGELOG.md @@ -3,6 +3,7 @@ ## 1.134.0 (Unreleased) - The "Preview" and "Preview Format..." commands now show in the Positron Notebook Editor overflow menu (). +- Fixed a bug where pasting a DOI after `@` in the visual editor did not offer to insert a citation on Windows (or, for plain-text pastes, on any platform) (). - In Positron, Jupyter Notebooks (`.ipynb`) are now exported via the new unified "Export" command, rather than the "Quarto: Convert to .qmd" command (). - Fixed a bug where formatting a code cell stripped leading empty lines. Leading empty lines between option directives and code are now preserved, and two or more leading empty lines are collapsed to one (). - Fixed a bug where IPython magics (`%`, `%%`) and shell escapes (`!`) in Python code cells produced spurious diagnostics from language servers like Pyrefly and Ruff. These lines are now commented out in the virtual document handed to language servers (). diff --git a/apps/vscode/package.json b/apps/vscode/package.json index bcd715d53..18f605c82 100644 --- a/apps/vscode/package.json +++ b/apps/vscode/package.json @@ -1551,6 +1551,7 @@ "@vscode/test-electron": "^2.5.2", "@vscode/vsce": "^2.15.0", "build": "*", + "editor": "*", "esbuild": "^0.16.7", "eslint": "^8.28.0", "mocha": "^9.2.1", diff --git a/apps/vscode/src/test/editor-paste.test.ts b/apps/vscode/src/test/editor-paste.test.ts new file mode 100644 index 000000000..5711cb673 --- /dev/null +++ b/apps/vscode/src/test/editor-paste.test.ts @@ -0,0 +1,157 @@ +/* + * editor-paste.test.ts + * + * Copyright (C) 2025 by Posit Software, PBC + * + * Unless you have received this program directly from Posit Software pursuant + * to the terms of a commercial license agreement with Posit Software, then + * this program is licensed to you under the terms of version 3 of the + * GNU Affero General Public License. This program is distributed WITHOUT + * ANY EXPRESS OR IMPLIED WARRANTY, INCLUDING THOSE OF NON-INFRINGEMENT, + * MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Please refer to the + * AGPL (http://www.gnu.org/licenses/agpl-3.0.txt) for more details. + * + */ + +// unit tests for the editor's generic paste handlers. these live in the +// vscode extension test suite because it is the only test harness that +// runs in CI; the tests exercise the editor package directly and do not +// use the vscode API. + +import * as assert from "assert"; + +import { Schema, Slice } from "prosemirror-model"; +import { EditorState, TextSelection } from "prosemirror-state"; +import { EditorView } from "prosemirror-view"; + +import pasteExtension from "editor/src/behaviors/paste"; +import { ExtensionContext } from "editor/src/api/extension"; + +const kWindowsUserAgent = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"; +const kMacUserAgent = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"; + +// just enough schema to place a selection inside (or outside) a cite_id mark +const schema = new Schema({ + nodes: { + doc: { content: "block+" }, + paragraph: { group: "block", content: "inline*" }, + text: { group: "inline" }, + }, + marks: { + cite_id: {}, + }, +}); + +// doc with a cite_id marked citation in the first paragraph and +// plain text in the second +const doc = schema.node("doc", null, [ + schema.node("paragraph", null, [schema.text("@10.1000/182", [schema.marks.cite_id.create()])]), + schema.node("paragraph", null, [schema.text("some plain text")]), +]); +const kInsideCiteIdPos = 5; +const kOutsideCiteIdPos = 18; + +function editorView(pos: number): EditorView { + const state = EditorState.create({ + schema, + doc, + selection: TextSelection.create(doc, pos), + }); + return { state, dispatch: () => undefined } as unknown as EditorView; +} + +function pasteHandler() { + const context = { + markdown: { + allowMarkdownPaste: () => true, + markdownToSlice: () => new Promise(() => undefined), + }, + } as unknown as ExtensionContext; + const extension = pasteExtension(context); + const plugin = extension.plugins(schema)[0]; + const handlePaste = plugin.props.handlePaste!; + return (view: EditorView, event: ClipboardEvent) => { + return handlePaste.call(plugin, view, event, Slice.empty) as boolean; + }; +} + +function clipboardEvent(data: Record): ClipboardEvent { + return { + clipboardData: { + types: Object.keys(data), + getData: (type: string) => data[type] || "", + }, + } as unknown as ClipboardEvent; +} + +// the handlers detect the platform via navigator.userAgent (which may not +// exist at all in the extension host, depending on the node version) +const kNavigatorDescriptor = Object.getOwnPropertyDescriptor(globalThis, "navigator"); +function setUserAgent(userAgent: string) { + Object.defineProperty(globalThis, "navigator", { + value: { userAgent }, + configurable: true, + }); +} + +const kDOIText = "https://doi.org/10.1000/182"; +const kDOIHtml = 'https://doi.org/10.1000/182'; +const kWordHtml = '

content

'; + +suite("Editor Paste Handlers", function () { + suiteTeardown(function () { + if (kNavigatorDescriptor) { + Object.defineProperty(globalThis, "navigator", kNavigatorDescriptor); + } else { + Reflect.deleteProperty(globalThis, "navigator"); + } + }); + + test("pass on pastes inside a citation id (text/html on windows)", function () { + // a DOI copied from a browser carries text/html, which the windows + // paste handler would otherwise consume before the cite mark's paste + // handler could offer to insert a citation from the DOI + // (https://github.com/rstudio/rstudio/issues/18295) + setUserAgent(kWindowsUserAgent); + const event = clipboardEvent({ "text/plain": kDOIText, "text/html": kDOIHtml }); + assert.strictEqual(pasteHandler()(editorView(kInsideCiteIdPos), event), false); + }); + + test("pass on pastes inside a citation id (text/plain)", function () { + setUserAgent(kMacUserAgent); + const event = clipboardEvent({ "text/plain": kDOIText }); + assert.strictEqual(pasteHandler()(editorView(kInsideCiteIdPos), event), false); + }); + + test("pass on pastes inside a citation id (office content)", function () { + setUserAgent(kMacUserAgent); + const event = clipboardEvent({ "text/plain": "content", "text/html": kWordHtml }); + assert.strictEqual(pasteHandler()(editorView(kInsideCiteIdPos), event), false); + }); + + test("handle text/html pastes outside of citation ids on windows", function () { + setUserAgent(kWindowsUserAgent); + const event = clipboardEvent({ "text/plain": kDOIText, "text/html": kDOIHtml }); + assert.strictEqual(pasteHandler()(editorView(kOutsideCiteIdPos), event), true); + }); + + test("do not handle text/html pastes outside of citation ids on other platforms", function () { + setUserAgent(kMacUserAgent); + const event = clipboardEvent({ "text/plain": kDOIText, "text/html": kDOIHtml }); + assert.strictEqual(pasteHandler()(editorView(kOutsideCiteIdPos), event), false); + }); + + test("handle office content pastes outside of citation ids", function () { + setUserAgent(kMacUserAgent); + const event = clipboardEvent({ "text/plain": "content", "text/html": kWordHtml }); + assert.strictEqual(pasteHandler()(editorView(kOutsideCiteIdPos), event), true); + }); + + test("handle text/plain pastes outside of citation ids as markdown", function () { + setUserAgent(kMacUserAgent); + const event = clipboardEvent({ "text/plain": kDOIText }); + assert.strictEqual(pasteHandler()(editorView(kOutsideCiteIdPos), event), true); + }); +}); diff --git a/packages/editor/src/behaviors/paste.ts b/packages/editor/src/behaviors/paste.ts index ab4ad93cc..c2094f7db 100644 --- a/packages/editor/src/behaviors/paste.ts +++ b/packages/editor/src/behaviors/paste.ts @@ -25,6 +25,7 @@ import { clearFormatting } from '../api/formatting'; import { EditorCommandId, ProsemirrorCommand } from '../api/command'; import { pasteTransaction } from '../api/clipboard'; import { isLink, linkPasteHandler } from '../api/link'; +import { markIsActive } from '../api/mark'; const kTextPlain = "text/plain"; const kTextHtml = "text/html"; @@ -148,7 +149,13 @@ function pasteMarkdownHandler( event: ClipboardEvent, pasteRaw: boolean, slice: Slice) => { - + + // pastes inside a citation id (e.g. a DOI pasted after '@') are handled + // by the cite mark's paste handler (https://github.com/rstudio/rstudio/issues/18295) + if (citeIdIsActive(schema, view.state)) { + return false; + } + // must be in a location valid for a markdown paste if (!editorMarkdown.allowMarkdownPaste(view.state)) { return false; @@ -218,6 +225,12 @@ function pasteHtmlHandler( if (!event.clipboardData) { return false; } + + // pastes inside a citation id (e.g. a DOI pasted after '@') are handled + // by the cite mark's paste handler (https://github.com/rstudio/rstudio/issues/18295) + if (citeIdIsActive(schema, view.state)) { + return false; + } // helper to paste slice const pasteSlice = (s: Slice) => { @@ -269,6 +282,12 @@ function pasteHtmlHandler( } +// detect a paste inside a citation id (the schema may not define the +// cite_id mark if citations are not enabled for the format) +function citeIdIsActive(schema: Schema, state: EditorState) { + return !!schema.marks.cite_id && markIsActive(state, schema.marks.cite_id); +} + // detect vscode paste const kVscodeEditorData = "vscode-editor-data"; const kVscodeRegex = /^