diff --git a/src/vs/editor/browser/controller/editContext/clipboardUtils.ts b/src/vs/editor/browser/controller/editContext/clipboardUtils.ts index 66c01da1d36..09ca8745315 100644 --- a/src/vs/editor/browser/controller/editContext/clipboardUtils.ts +++ b/src/vs/editor/browser/controller/editContext/clipboardUtils.ts @@ -6,8 +6,57 @@ import { IViewModel } from '../../../common/viewModel.js'; import { Range } from '../../../common/core/range.js'; import { isWindows } from '../../../../base/common/platform.js'; import { Mimes } from '../../../../base/common/mime.js'; +import { ViewContext } from '../../../common/viewModel/viewContext.js'; +import { ILogService, LogLevel } from '../../../../platform/log/common/log.js'; +import { EditorOption, IComputedEditorOptions } from '../../../common/config/editorOptions.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; -export function getDataToCopy(viewModel: IViewModel, modelSelections: Range[], emptySelectionClipboard: boolean, copyWithSyntaxHighlighting: boolean): ClipboardDataToCopy { +export function ensureClipboardGetsEditorSelection(e: ClipboardEvent, context: ViewContext, logService: ILogService, isFirefox: boolean): void { + const viewModel = context.viewModel; + const options = context.configuration.options; + let id: string | undefined = undefined; + if (logService.getLevel() === LogLevel.Trace) { + id = generateUuid(); + } + + const { dataToCopy, storedMetadata } = generateDataToCopyAndStoreInMemory(viewModel, options, id, isFirefox); + + // !!!!! + // This is a workaround for what we think is an Electron bug where + // execCommand('copy') does not always work (it does not fire a clipboard event) + // !!!!! + // We signal that we have executed a copy command + CopyOptions.electronBugWorkaroundCopyEventHasFired = true; + + e.preventDefault(); + if (e.clipboardData) { + ClipboardEventUtils.setTextData(e.clipboardData, dataToCopy.text, dataToCopy.html, storedMetadata); + } + logService.trace('ensureClipboardGetsEditorSelection with id : ', id, ' with text.length: ', dataToCopy.text.length); +} + +export function generateDataToCopyAndStoreInMemory(viewModel: IViewModel, options: IComputedEditorOptions, id: string | undefined, isFirefox: boolean) { + const emptySelectionClipboard = options.get(EditorOption.emptySelectionClipboard); + const copyWithSyntaxHighlighting = options.get(EditorOption.copyWithSyntaxHighlighting); + const selections = viewModel.getCursorStates().map(cursorState => cursorState.modelState.selection); + const dataToCopy = getDataToCopy(viewModel, selections, emptySelectionClipboard, copyWithSyntaxHighlighting); + const storedMetadata: ClipboardStoredMetadata = { + version: 1, + id, + isFromEmptySelection: dataToCopy.isFromEmptySelection, + multicursorText: dataToCopy.multicursorText, + mode: dataToCopy.mode + }; + InMemoryClipboardMetadataManager.INSTANCE.set( + // When writing "LINE\r\n" to the clipboard and then pasting, + // Firefox pastes "LINE\n", so let's work around this quirk + (isFirefox ? dataToCopy.text.replace(/\r\n/g, '\n') : dataToCopy.text), + storedMetadata + ); + return { dataToCopy, storedMetadata }; +} + +function getDataToCopy(viewModel: IViewModel, modelSelections: Range[], emptySelectionClipboard: boolean, copyWithSyntaxHighlighting: boolean): ClipboardDataToCopy { const rawTextToCopy = viewModel.getPlainTextToCopy(modelSelections, emptySelectionClipboard, isWindows); const newLineCharacter = viewModel.model.getEOL(); @@ -79,7 +128,8 @@ export interface ClipboardStoredMetadata { } export const CopyOptions = { - forceCopyWithSyntaxHighlighting: false + forceCopyWithSyntaxHighlighting: false, + electronBugWorkaroundCopyEventHasFired: false }; interface InMemoryClipboardMetadata { diff --git a/src/vs/editor/browser/controller/editContext/native/nativeEditContext.ts b/src/vs/editor/browser/controller/editContext/native/nativeEditContext.ts index 53c4692efa6..6334e8bdfe1 100644 --- a/src/vs/editor/browser/controller/editContext/native/nativeEditContext.ts +++ b/src/vs/editor/browser/controller/editContext/native/nativeEditContext.ts @@ -16,7 +16,7 @@ import { ViewConfigurationChangedEvent, ViewCursorStateChangedEvent, ViewDecorat import { ViewContext } from '../../../../common/viewModel/viewContext.js'; import { RestrictedRenderingContext, RenderingContext, HorizontalPosition } from '../../../view/renderingContext.js'; import { ViewController } from '../../../view/viewController.js'; -import { ClipboardEventUtils, ClipboardStoredMetadata, getDataToCopy, InMemoryClipboardMetadataManager } from '../clipboardUtils.js'; +import { ClipboardEventUtils, ensureClipboardGetsEditorSelection, InMemoryClipboardMetadataManager } from '../clipboardUtils.js'; import { AbstractEditContext } from '../editContext.js'; import { editContextAddDisposableListener, FocusTracker, ITypeData } from './nativeEditContextUtils.js'; import { ScreenReaderSupport } from './screenReaderSupport.js'; @@ -31,8 +31,7 @@ import { IEditorAriaOptions } from '../../../editorBrowser.js'; import { isHighSurrogate, isLowSurrogate } from '../../../../../base/common/strings.js'; import { IME } from '../../../../../base/common/ime.js'; import { OffsetRange } from '../../../../common/core/ranges/offsetRange.js'; -import { ILogService, LogLevel } from '../../../../../platform/log/common/log.js'; -import { generateUuid } from '../../../../../base/common/uuid.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; import { inputLatency } from '../../../../../base/browser/performance.js'; // Corresponds to classes in nativeEditContext.css @@ -115,14 +114,14 @@ export class NativeEditContext extends AbstractEditContext { this._register(addDisposableListener(this.domNode.domNode, 'copy', (e) => { this.logService.trace('NativeEditContext#copy'); - this._ensureClipboardGetsEditorSelection(e); + ensureClipboardGetsEditorSelection(e, this._context, this.logService, isFirefox); })); this._register(addDisposableListener(this.domNode.domNode, 'cut', (e) => { this.logService.trace('NativeEditContext#cut'); // Pretend here we touched the text area, as the `cut` event will most likely // result in a `selectionchange` event which we want to ignore this._screenReaderSupport.onWillCut(); - this._ensureClipboardGetsEditorSelection(e); + ensureClipboardGetsEditorSelection(e, this._context, this.logService, isFirefox); this.logService.trace('NativeEditContext#cut (before viewController.cut)'); this._viewController.cut(); })); @@ -569,34 +568,4 @@ export class NativeEditContext extends AbstractEditContext { } this._editContext.updateCharacterBounds(e.rangeStart, characterBounds); } - - private _ensureClipboardGetsEditorSelection(e: ClipboardEvent): void { - const options = this._context.configuration.options; - const emptySelectionClipboard = options.get(EditorOption.emptySelectionClipboard); - const copyWithSyntaxHighlighting = options.get(EditorOption.copyWithSyntaxHighlighting); - const selections = this._context.viewModel.getCursorStates().map(cursorState => cursorState.modelState.selection); - const dataToCopy = getDataToCopy(this._context.viewModel, selections, emptySelectionClipboard, copyWithSyntaxHighlighting); - let id = undefined; - if (this.logService.getLevel() === LogLevel.Trace) { - id = generateUuid(); - } - const storedMetadata: ClipboardStoredMetadata = { - version: 1, - id, - isFromEmptySelection: dataToCopy.isFromEmptySelection, - multicursorText: dataToCopy.multicursorText, - mode: dataToCopy.mode - }; - InMemoryClipboardMetadataManager.INSTANCE.set( - // When writing "LINE\r\n" to the clipboard and then pasting, - // Firefox pastes "LINE\n", so let's work around this quirk - (isFirefox ? dataToCopy.text.replace(/\r\n/g, '\n') : dataToCopy.text), - storedMetadata - ); - e.preventDefault(); - if (e.clipboardData) { - ClipboardEventUtils.setTextData(e.clipboardData, dataToCopy.text, dataToCopy.html, storedMetadata); - } - this.logService.trace('NativeEditContext#_ensureClipboardGetsEditorSelectios with id : ', id, ' with text.length: ', dataToCopy.text.length); - } } diff --git a/src/vs/editor/browser/controller/editContext/textArea/textAreaEditContext.ts b/src/vs/editor/browser/controller/editContext/textArea/textAreaEditContext.ts index eb40085a1c2..a70e24547f3 100644 --- a/src/vs/editor/browser/controller/editContext/textArea/textAreaEditContext.ts +++ b/src/vs/editor/browser/controller/editContext/textArea/textAreaEditContext.ts @@ -37,7 +37,6 @@ import { IInstantiationService } from '../../../../../platform/instantiation/com import { AbstractEditContext } from '../editContext.js'; import { ICompositionData, IPasteData, ITextAreaInputHost, TextAreaInput, TextAreaWrapper } from './textAreaEditContextInput.js'; import { ariaLabelForScreenReaderContent, newlinecount, SimplePagedScreenReaderStrategy } from '../screenReaderUtils.js'; -import { ClipboardDataToCopy, getDataToCopy } from '../clipboardUtils.js'; import { _debugComposition, ITypeData, TextAreaState } from './textAreaEditContextState.js'; import { getMapForWordSeparators, WordCharacterClass } from '../../../../common/core/wordCharacterClassifier.js'; @@ -126,7 +125,6 @@ export class TextAreaEditContext extends AbstractEditContext { private _contentHeight: number; private _fontInfo: FontInfo; private _emptySelectionClipboard: boolean; - private _copyWithSyntaxHighlighting: boolean; /** * Defined only when the text area is visible (composition case). @@ -169,7 +167,6 @@ export class TextAreaEditContext extends AbstractEditContext { this._contentHeight = layoutInfo.height; this._fontInfo = options.get(EditorOption.fontInfo); this._emptySelectionClipboard = options.get(EditorOption.emptySelectionClipboard); - this._copyWithSyntaxHighlighting = options.get(EditorOption.copyWithSyntaxHighlighting); this._visibleTextArea = null; this._selections = [new Selection(1, 1, 1, 1)]; @@ -205,9 +202,7 @@ export class TextAreaEditContext extends AbstractEditContext { const simplePagedScreenReaderStrategy = new SimplePagedScreenReaderStrategy(); const textAreaInputHost: ITextAreaInputHost = { - getDataToCopy: (): ClipboardDataToCopy => { - return getDataToCopy(this._context.viewModel, this._modelSelections, this._emptySelectionClipboard, this._copyWithSyntaxHighlighting); - }, + context: this._context, getScreenReaderContent: (): TextAreaState => { if (this._accessibilitySupport === AccessibilitySupport.Disabled) { // We know for a fact that a screen reader is not attached @@ -573,7 +568,6 @@ export class TextAreaEditContext extends AbstractEditContext { this._contentHeight = layoutInfo.height; this._fontInfo = options.get(EditorOption.fontInfo); this._emptySelectionClipboard = options.get(EditorOption.emptySelectionClipboard); - this._copyWithSyntaxHighlighting = options.get(EditorOption.copyWithSyntaxHighlighting); this.textArea.setAttribute('wrap', this._textAreaWrapping && !this._visibleTextArea ? 'on' : 'off'); const { tabSize } = this._context.viewModel.model.getOptions(); this.textArea.domNode.style.tabSize = `${tabSize * this._fontInfo.spaceWidth}px`; diff --git a/src/vs/editor/browser/controller/editContext/textArea/textAreaEditContextInput.ts b/src/vs/editor/browser/controller/editContext/textArea/textAreaEditContextInput.ts index 4e4cb103bc5..fa7ecddebff 100644 --- a/src/vs/editor/browser/controller/editContext/textArea/textAreaEditContextInput.ts +++ b/src/vs/editor/browser/controller/editContext/textArea/textAreaEditContextInput.ts @@ -17,10 +17,10 @@ import * as strings from '../../../../../base/common/strings.js'; import { Position } from '../../../../common/core/position.js'; import { Selection } from '../../../../common/core/selection.js'; import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; -import { ILogService, LogLevel } from '../../../../../platform/log/common/log.js'; -import { ClipboardDataToCopy, ClipboardEventUtils, ClipboardStoredMetadata, InMemoryClipboardMetadataManager } from '../clipboardUtils.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { ClipboardEventUtils, ClipboardStoredMetadata, ensureClipboardGetsEditorSelection, InMemoryClipboardMetadataManager } from '../clipboardUtils.js'; import { _debugComposition, ITextAreaWrapper, ITypeData, TextAreaState } from './textAreaEditContextState.js'; -import { generateUuid } from '../../../../../base/common/uuid.js'; +import { ViewContext } from '../../../../common/viewModel/viewContext.js'; export namespace TextAreaSyntethicEvents { export const Tap = '-monaco-textarea-synthetic-tap'; @@ -37,7 +37,7 @@ export interface IPasteData { } export interface ITextAreaInputHost { - getDataToCopy(): ClipboardDataToCopy; + readonly context: ViewContext | null; getScreenReaderContent(): TextAreaState; deduceModelPosition(viewAnchorPosition: Position, deltaOffset: number, lineFeedCnt: number): Position; } @@ -363,13 +363,17 @@ export class TextAreaInput extends Disposable { // result in a `selectionchange` event which we want to ignore this._textArea.setIgnoreSelectionChangeTime('received cut event'); - this._ensureClipboardGetsEditorSelection(e); + if (this._host.context) { + ensureClipboardGetsEditorSelection(e, this._host.context, this._logService, this._browser.isFirefox); + } this._asyncTriggerCut.schedule(); })); this._register(this._textArea.onCopy((e) => { this._logService.trace(`TextAreaInput#onCopy`, e); - this._ensureClipboardGetsEditorSelection(e); + if (this._host.context) { + ensureClipboardGetsEditorSelection(e, this._host.context, this._logService, this._browser.isFirefox); + } })); this._register(this._textArea.onPaste((e) => { @@ -608,33 +612,6 @@ export class TextAreaInput extends Disposable { } this._setAndWriteTextAreaState(reason, this._host.getScreenReaderContent()); } - - private _ensureClipboardGetsEditorSelection(e: ClipboardEvent): void { - const dataToCopy = this._host.getDataToCopy(); - let id = undefined; - if (this._logService.getLevel() === LogLevel.Trace) { - id = generateUuid(); - } - const storedMetadata: ClipboardStoredMetadata = { - version: 1, - id, - isFromEmptySelection: dataToCopy.isFromEmptySelection, - multicursorText: dataToCopy.multicursorText, - mode: dataToCopy.mode - }; - InMemoryClipboardMetadataManager.INSTANCE.set( - // When writing "LINE\r\n" to the clipboard and then pasting, - // Firefox pastes "LINE\n", so let's work around this quirk - (this._browser.isFirefox ? dataToCopy.text.replace(/\r\n/g, '\n') : dataToCopy.text), - storedMetadata - ); - - e.preventDefault(); - if (e.clipboardData) { - ClipboardEventUtils.setTextData(e.clipboardData, dataToCopy.text, dataToCopy.html, storedMetadata); - } - this._logService.trace('TextAreaEditContextInput#_ensureClipboardGetsEditorSelection with id : ', id, ' with text.length: ', dataToCopy.text.length); - } } export class TextAreaWrapper extends Disposable implements ICompleteTextAreaWrapper { diff --git a/src/vs/editor/contrib/clipboard/browser/clipboard.ts b/src/vs/editor/contrib/clipboard/browser/clipboard.ts index f795302fae8..2157be89da4 100644 --- a/src/vs/editor/contrib/clipboard/browser/clipboard.ts +++ b/src/vs/editor/contrib/clipboard/browser/clipboard.ts @@ -17,9 +17,9 @@ import { KeybindingWeight } from '../../../../platform/keybinding/common/keybind import { ILogService } from '../../../../platform/log/common/log.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; -import { CopyOptions, InMemoryClipboardMetadataManager } from '../../../browser/controller/editContext/clipboardUtils.js'; +import { CopyOptions, generateDataToCopyAndStoreInMemory, InMemoryClipboardMetadataManager } from '../../../browser/controller/editContext/clipboardUtils.js'; import { NativeEditContextRegistry } from '../../../browser/controller/editContext/native/nativeEditContextRegistry.js'; -import { ICodeEditor } from '../../../browser/editorBrowser.js'; +import { IActiveCodeEditor, ICodeEditor } from '../../../browser/editorBrowser.js'; import { Command, EditorAction, MultiCommand, registerEditorAction } from '../../../browser/editorExtensions.js'; import { ICodeEditorService } from '../../../browser/services/codeEditorService.js'; import { EditorOption } from '../../../common/config/editorOptions.js'; @@ -173,6 +173,7 @@ class ExecCommandCopyWithSyntaxHighlightingAction extends EditorAction { public run(accessor: ServicesAccessor, editor: ICodeEditor): void { const logService = accessor.get(ILogService); + const clipboardService = accessor.get(IClipboardService); logService.trace('ExecCommandCopyWithSyntaxHighlightingAction#run'); if (!editor.hasModel()) { return; @@ -187,12 +188,29 @@ class ExecCommandCopyWithSyntaxHighlightingAction extends EditorAction { CopyOptions.forceCopyWithSyntaxHighlighting = true; editor.focus(); logService.trace('ExecCommandCopyWithSyntaxHighlightingAction (before execCommand copy)'); - editor.getContainerDomNode().ownerDocument.execCommand('copy'); + executeClipboardCopyWithWorkaround(editor, clipboardService); logService.trace('ExecCommandCopyWithSyntaxHighlightingAction (after execCommand copy)'); CopyOptions.forceCopyWithSyntaxHighlighting = false; } } +function executeClipboardCopyWithWorkaround(editor: IActiveCodeEditor, clipboardService: IClipboardService) { + // !!!!! + // This is a workaround for what we think is an Electron bug where + // execCommand('copy') does not always work (it does not fire a clipboard event) + // We will use this as a signal that we have executed a copy command + // !!!!! + CopyOptions.electronBugWorkaroundCopyEventHasFired = false; + editor.getContainerDomNode().ownerDocument.execCommand('copy'); + if (platform.isNative && CopyOptions.electronBugWorkaroundCopyEventHasFired === false) { + // We have encountered the Electron bug! + // As a workaround, we will write (only the plaintext data) to the clipboard in a different way + // We will use the clipboard service (which in the native case will go to electron's clipboard API) + const { dataToCopy } = generateDataToCopyAndStoreInMemory(editor._getViewModel(), editor.getOptions(), undefined, browser.isFirefox); + clipboardService.writeText(dataToCopy.text); + } +} + function registerExecCommandImpl(target: MultiCommand | undefined, browserCommand: 'cut' | 'copy'): void { if (!target) { return; @@ -201,10 +219,11 @@ function registerExecCommandImpl(target: MultiCommand | undefined, browserComman // 1. handle case when focus is in editor. target.addImplementation(10000, 'code-editor', (accessor: ServicesAccessor, args: unknown) => { const logService = accessor.get(ILogService); + const clipboardService = accessor.get(IClipboardService); logService.trace('registerExecCommandImpl (addImplementation code-editor for : ', browserCommand, ')'); // Only if editor text focus (i.e. not if editor has widget focus). const focusedEditor = accessor.get(ICodeEditorService).getFocusedCodeEditor(); - if (focusedEditor && focusedEditor.hasTextFocus()) { + if (focusedEditor && focusedEditor.hasTextFocus() && focusedEditor.hasModel()) { // Do not execute if there is no selection and empty selection clipboard is off const emptySelectionClipboard = focusedEditor.getOption(EditorOption.emptySelectionClipboard); const selection = focusedEditor.getSelection(); @@ -216,13 +235,17 @@ function registerExecCommandImpl(target: MultiCommand | undefined, browserComman logCopyCommand(focusedEditor); // execCommand(copy) works for edit context, but not execCommand(cut). logService.trace('registerExecCommandImpl (before execCommand copy)'); - focusedEditor.getContainerDomNode().ownerDocument.execCommand('copy'); + executeClipboardCopyWithWorkaround(focusedEditor, clipboardService); focusedEditor.trigger(undefined, Handler.Cut, undefined); logService.trace('registerExecCommandImpl (after execCommand copy)'); } else { logCopyCommand(focusedEditor); logService.trace('registerExecCommandImpl (before execCommand ' + browserCommand + ')'); - focusedEditor.getContainerDomNode().ownerDocument.execCommand(browserCommand); + if (browserCommand === 'copy') { + executeClipboardCopyWithWorkaround(focusedEditor, clipboardService); + } else { + focusedEditor.getContainerDomNode().ownerDocument.execCommand(browserCommand); + } logService.trace('registerExecCommandImpl (after execCommand ' + browserCommand + ')'); } return true; diff --git a/src/vs/editor/test/browser/controller/imeTester.ts b/src/vs/editor/test/browser/controller/imeTester.ts index 1f6a67c9ccd..da5deba4a5d 100644 --- a/src/vs/editor/test/browser/controller/imeTester.ts +++ b/src/vs/editor/test/browser/controller/imeTester.ts @@ -112,15 +112,7 @@ function doCreateTest(description: string, inputStr: string, expectedStr: string const model = new SingleLineTestModel('some text'); const screenReaderStrategy = new SimplePagedScreenReaderStrategy(); const textAreaInputHost: ITextAreaInputHost = { - getDataToCopy: () => { - return { - isFromEmptySelection: false, - multicursorText: null, - text: '', - html: undefined, - mode: null - }; - }, + context: null, getScreenReaderContent: (): TextAreaState => { const selection = new Selection(1, 1 + cursorOffset, 1, 1 + cursorOffset + cursorLength); diff --git a/src/vs/editor/test/browser/controller/textAreaInput.test.ts b/src/vs/editor/test/browser/controller/textAreaInput.test.ts index 2ef8dcb1ce6..7e8d404a2b3 100644 --- a/src/vs/editor/test/browser/controller/textAreaInput.test.ts +++ b/src/vs/editor/test/browser/controller/textAreaInput.test.ts @@ -13,7 +13,6 @@ import { IRecorded, IRecordedEvent, IRecordedTextareaState } from './imeRecorded import { TestAccessibilityService } from '../../../../platform/accessibility/test/common/testAccessibilityService.js'; import { NullLogService } from '../../../../platform/log/common/log.js'; import { IBrowser, ICompleteTextAreaWrapper, ITextAreaInputHost, TextAreaInput } from '../../../browser/controller/editContext/textArea/textAreaEditContextInput.js'; -import { ClipboardDataToCopy } from '../../../browser/controller/editContext/clipboardUtils.js'; import { TextAreaState } from '../../../browser/controller/editContext/textArea/textAreaEditContextState.js'; suite('TextAreaInput', () => { @@ -49,9 +48,7 @@ suite('TextAreaInput', () => { async function simulateInteraction(recorded: IRecorded): Promise { const disposables = new DisposableStore(); const host: ITextAreaInputHost = { - getDataToCopy: function (): ClipboardDataToCopy { - throw new Error('Function not implemented.'); - }, + context: null, getScreenReaderContent: function (): TextAreaState { return new TextAreaState('', 0, 0, null, undefined); },