Add workaround for cases when execComand('copy') doesn't work with EditContext (#280300)

This commit is contained in:
Alexandru Dima
2025-12-01 14:38:39 +01:00
committed by GitHub
parent 8f9dd683ca
commit 884364539d
7 changed files with 98 additions and 96 deletions

View File

@@ -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 {

View File

@@ -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);
}
}

View File

@@ -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`;

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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);

View File

@@ -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<OutoingEvent[]> {
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);
},