Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/src/api/class-browsercontext.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ await context.CloseAsync();

This event is not emitted.

## property: BrowserContext.clipboard
* since: v1.62
- type: <[Clipboard]>

Provides a virtual clipboard that replaces `navigator.clipboard` and `document.execCommand` in every
page of the context.

## property: BrowserContext.clock
* since: v1.45
- type: <[Clock]>
Expand Down
113 changes: 113 additions & 0 deletions docs/src/api/class-clipboard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# class: Clipboard
* since: v1.62

Provides a virtual clipboard for the [BrowserContext]. Access it via
[`property: BrowserContext.clipboard`].

The virtual clipboard is installed into every page in the context on first use — call
[`method: Clipboard.install`] to opt in explicitly, or use any other [Clipboard] method. Until it is
installed, pages keep using the real clipboard, so tests that only exercise keyboard shortcuts are
unaffected. It needs no clipboard permissions or secure context and never touches the operating system
clipboard.

Once installed, it redirects `navigator.clipboard` (only where the browser already exposes it, i.e.
secure contexts), replaces `document.execCommand('copy'|'cut'|'paste')`, and intercepts native
copy/cut/paste keyboard shortcuts (for example `Control+C`/`Control+V`). Copy, cut, and paste dispatch
a real `ClipboardEvent` whose `clipboardData` page handlers can read and override, matching the browser.
Plain text, rich `text/html`, and binary content such as images are supported, and the store is shared
by every page in the context, so a value written in one page can be read in another.

Playwright does not fabricate `navigator.clipboard` or `ClipboardItem` where the browser does not
expose them, so feature detection and code paths that handle a missing clipboard API keep behaving as
they would without Playwright.

Native copy/paste interception relies on event listeners installed when a document loads, so it does
not apply to documents replaced via [`method: Page.setContent`] (which resets the document); navigate
to a page instead. In Firefox, a `paste` handler cannot read `clipboardData` from the synthesized
event — read the pasted value from the DOM or [`method: Clipboard.read`] instead.

```js
await context.clipboard.writeText('hello');
const text = await context.clipboard.readText();
```

Because the clipboard is virtual, it does not interact with the real operating system clipboard.

## async method: Clipboard.install
* since: v1.62

Installs the virtual clipboard into the context's pages. Any other [Clipboard] method installs it
implicitly, so this is only needed to opt in before a page performs clipboard actions (for example
native `Control+C`/`Control+V`) without otherwise using [`property: BrowserContext.clipboard`].

**Usage**

```js
await context.clipboard.install();
```

## async method: Clipboard.readText
* since: v1.62
- returns: <[string]>

Returns the current plain-text contents of the virtual clipboard.

**Usage**

```js
const text = await context.clipboard.readText();
```

## async method: Clipboard.writeText
* since: v1.62

Writes the given plain text to the virtual clipboard.

**Usage**

```js
await context.clipboard.writeText('hello');
```

### param: Clipboard.writeText.text
* since: v1.62
- `text` <[string]>

Text to write to the clipboard.

## async method: Clipboard.read
* since: v1.62
- returns: <[Array]<[Object]>>
- `mimeType` <[string]> The MIME type of the entry, for example `text/plain` or `image/png`.
- `buffer` <[Buffer]> The entry's contents.

Returns every entry currently on the virtual clipboard, including binary types such as images. For
plain text, [`method: Clipboard.readText`] is a convenient shortcut.

**Usage**

```js
const items = await context.clipboard.read();
```

## async method: Clipboard.write
* since: v1.62

Replaces the virtual clipboard contents with the given entries. Use this for binary types such as
images; for plain text, [`method: Clipboard.writeText`] is a convenient shortcut.

**Usage**

```js
await context.clipboard.write([
{ mimeType: 'image/png', buffer: await page.screenshot() },
]);
```

### param: Clipboard.write.items
* since: v1.62
- `items` <[Array]<[Object]>>
- `mimeType` <[string]> The MIME type of the entry, for example `text/plain` or `image/png`.
- `buffer` <[Buffer]> The entry's contents.

Entries to write to the clipboard.
255 changes: 255 additions & 0 deletions packages/injected/src/clipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

// Wire format for the store; binary values are base64-encoded.
export type ClipboardEntry = { type: string, value: string, base64?: boolean };

export type BindingPayload = { action: 'read' } | { action: 'write', items: ClipboardEntry[] };

type GlobalThis = typeof globalThis;

const kBindingName = '__pwClipboardBinding';

export function inject(globalThis: GlobalThis) {
if ((globalThis as any).__pwClipboardInstalled)
return;

function defineHidden(obj: any, name: string, value: any) {
Object.defineProperty(obj, name, { configurable: true, value });
}
defineHidden(globalThis, '__pwClipboardInstalled', true);

const binding = (globalThis as any)[kBindingName] as ((payload: BindingPayload) => Promise<ClipboardEntry[]>) | undefined;
const ClipboardItemCtor: any = (globalThis as any).ClipboardItem;

function read() {
return binding?.({ action: 'read' }) ?? Promise.resolve([]);
}

function write(items: ClipboardEntry[]) {
return binding?.({ action: 'write', items }) ?? Promise.resolve([]);
}

let mirror: ClipboardEntry[] = [];
void read().then(items => {
mirror = items;
});
defineHidden(globalThis, '__pwClipboardSet', (items: ClipboardEntry[]) => {
mirror = items;
});

function valueOf(items: ClipboardEntry[], type: string) {
return items.find(item => item.type === type)?.value ?? '';
}

function setStore(items: ClipboardEntry[]) {
mirror = items;
return write(items);
}

function entriesToDataTransfer(items: ClipboardEntry[]) {
const dataTransfer = new globalThis.DataTransfer();
for (const item of items) {
if (item.base64)
dataTransfer.items.add(new globalThis.File([Uint8Array.from(globalThis.atob(item.value), c => c.charCodeAt(0))], 'clipboard', { type: item.type }));
else
dataTransfer.setData(item.type, item.value);
}
return dataTransfer;
}

function dataTransferToEntries(dataTransfer: DataTransfer): ClipboardEntry[] {
return [...dataTransfer.types].filter(type => type !== 'Files').map(type => ({ type, value: dataTransfer.getData(type) }));
}

// Selections inside form controls are not part of the document selection.
function activeFormControl(): HTMLInputElement | HTMLTextAreaElement | null {
const active = globalThis.document.activeElement as HTMLInputElement | HTMLTextAreaElement | null;
if ((active?.tagName === 'INPUT' || active?.tagName === 'TEXTAREA') && active.selectionStart !== active.selectionEnd)
return active;
return null;
}

function captureSelection() {
const control = activeFormControl();
if (control) {
void setStore([{ type: 'text/plain', value: control.value.slice(control.selectionStart ?? 0, control.selectionEnd ?? 0) }]);
return;
}
const selection = globalThis.document.getSelection();
const container = globalThis.document.createElement('div');
for (let i = 0; i < (selection?.rangeCount ?? 0); i++)
container.appendChild(selection!.getRangeAt(i).cloneContents());
void setStore([
{ type: 'text/plain', value: selection?.toString() ?? '' },
{ type: 'text/html', value: container.innerHTML },
]);
}

function deleteSelection() {
const control = activeFormControl();
if (control) {
const start = control.selectionStart ?? 0;
const end = control.selectionEnd ?? 0;
control.value = control.value.slice(0, start) + control.value.slice(end);
control.setSelectionRange(start, start);
control.dispatchEvent(new globalThis.InputEvent('input', { bubbles: true, inputType: 'deleteByCut' }));
return;
}
globalThis.document.getSelection()?.deleteFromDocument();
}

function insertClipboard(target: any) {
if (target?.tagName === 'INPUT' || target?.tagName === 'TEXTAREA') {
const text = valueOf(mirror, 'text/plain');
const start = target.selectionStart ?? target.value.length;
const end = target.selectionEnd ?? target.value.length;
target.value = target.value.slice(0, start) + text + target.value.slice(end);
// Non-text inputs (number, email, ...) expose a null selectionStart and throw on setSelectionRange.
if (target.selectionStart !== null)
target.setSelectionRange(start + text.length, start + text.length);
target.dispatchEvent(new globalThis.InputEvent('input', { bubbles: true, inputType: 'insertFromPaste' }));
} else {
const selection = globalThis.document.getSelection();
if (!selection?.rangeCount)
return;
const host = target?.closest?.('[contenteditable]') || target;
const text = valueOf(mirror, 'text/plain');
const html = valueOf(mirror, 'text/html');
// contenteditable editors may cancel the paste via beforeinput.
if (host?.dispatchEvent(new globalThis.InputEvent('beforeinput', {
bubbles: true,
cancelable: true,
composed: true,
inputType: 'insertFromPaste',
data: html ? null : text,
})) === false)
return;
const range = selection.getRangeAt(0);
range.deleteContents();
range.insertNode(html ? range.createContextualFragment(html) : globalThis.document.createTextNode(text));
range.collapse(false);
host?.dispatchEvent(new globalThis.InputEvent('input', { bubbles: true, composed: true, inputType: 'insertFromPaste' }));
}
}

// Fire a real ClipboardEvent so page handlers can read/override clipboardData; the synthetic event is untrusted, so the isTrusted-guarded native listeners skip it (no recursion).
function dispatchCopy(target: any, cut: boolean) {
const event = new globalThis.ClipboardEvent(cut ? 'cut' : 'copy', { clipboardData: new globalThis.DataTransfer(), bubbles: true, cancelable: true, composed: true });
if (target?.dispatchEvent(event) === false) {
if (event.clipboardData?.types.length)
void setStore(dataTransferToEntries(event.clipboardData));
return;
}
captureSelection();
if (cut)
deleteSelection();
}

function dispatchPaste(target: any, items: ClipboardEntry[]) {
const event = new globalThis.ClipboardEvent('paste', { clipboardData: entriesToDataTransfer(items), bubbles: true, cancelable: true, composed: true });
if (target?.dispatchEvent(event))
insertClipboard(target);
}

const clipboard = Object.assign(new globalThis.EventTarget(), {
async writeText(text: string) {
await setStore([{ type: 'text/plain', value: String(text) }]);
},
async readText() {
mirror = await read();
return valueOf(mirror, 'text/plain');
},
async write(clipboardItems: ClipboardItem[]) {
const items: ClipboardEntry[] = [];
for (const item of clipboardItems) {
for (const type of item.types) {
if (ClipboardItemCtor.supports?.(type) === false)
throw new globalThis.DOMException('Type ' + type + ' not supported on write.', 'NotAllowedError');
const blob = await item.getType(type);
if (type.startsWith('text/')) {
items.push({ type, value: await blob.text() });
} else {
const bytes = new Uint8Array(await blob.arrayBuffer());
const binary = Array.from(bytes, b => String.fromCharCode(b)).join('');
items.push({ type, value: globalThis.btoa(binary), base64: true });
}
}
}
await setStore(items);
},
async read() {
mirror = await read();
if (!mirror.length)
return [];
const parts: Record<string, Blob> = {};
for (const item of mirror)
parts[item.type] = new globalThis.Blob([item.base64 ? Uint8Array.from(globalThis.atob(item.value), c => c.charCodeAt(0)) : item.value], { type: item.type });
return [new ClipboardItemCtor(parts)];
},
});
// Only redirect where the browser already exposes it, so feature-detection still works.
if (globalThis.navigator.clipboard)
Object.defineProperty(globalThis.navigator, 'clipboard', { configurable: true, get: () => clipboard });

const originalExecCommand = globalThis.document.execCommand.bind(globalThis.document);
(globalThis.document as any).execCommand = function(commandId: string, showUI?: boolean, value?: string) {
const command = String(commandId).toLowerCase();
if (command === 'copy' || command === 'cut') {
const selection = globalThis.document.getSelection();
const hasSelection = !!activeFormControl() || (!!selection && !selection.isCollapsed);
if (!globalThis.document.queryCommandSupported(command) || !hasSelection)
return false;
dispatchCopy(globalThis.document.activeElement, command === 'cut');
return true;
}
if (command === 'paste') {
const target = globalThis.document.activeElement as HTMLElement | null;
if (!globalThis.document.queryCommandSupported('paste') || !target || (target.tagName !== 'INPUT' && target.tagName !== 'TEXTAREA' && !target.isContentEditable))
return false;
dispatchPaste(target, mirror);
return true;
}
return originalExecCommand(commandId, showUI, value);
};

// Capture-phase listeners; document.open() (page.setContent()) tears them down, so this doesn't survive setContent.
globalThis.addEventListener('copy', e => {
if (!e.isTrusted)
return;
e.preventDefault();
e.stopImmediatePropagation();
dispatchCopy(e.target, false);
}, { capture: true });
globalThis.addEventListener('cut', e => {
if (!e.isTrusted)
return;
e.preventDefault();
e.stopImmediatePropagation();
dispatchCopy(e.target, true);
}, { capture: true });
globalThis.addEventListener('paste', e => {
if (!e.isTrusted)
return;
e.preventDefault();
e.stopImmediatePropagation();
const target = e.target;
void read().then(items => {
mirror = items;
dispatchPaste(target, items);
});
}, { capture: true });
}
5 changes: 5 additions & 0 deletions packages/isomorphic/protocolMetainfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,11 @@ export const methodMetainfo = new Map<string, MethodMetainfo>([
['BrowserContext.clockRunFor', { title: 'Run clock "{ticksNumber|ticksString}"', }],
['BrowserContext.clockSetFixedTime', { title: 'Set fixed time "{timeNumber|timeString}"', }],
['BrowserContext.clockSetSystemTime', { title: 'Set system time "{timeNumber|timeString}"', }],
['BrowserContext.clipboardInstall', { title: 'Install clipboard', }],
['BrowserContext.clipboardReadText', { title: 'Read clipboard text', }],
['BrowserContext.clipboardWriteText', { title: 'Write clipboard text', }],
['BrowserContext.clipboardRead', { title: 'Read clipboard', }],
['BrowserContext.clipboardWrite', { title: 'Write clipboard', }],
['BrowserContext.credentialsInstall', { title: 'Install virtual WebAuthn authenticator', group: 'configuration', }],
['BrowserContext.credentialsCreate', { title: 'Create virtual credential for "{rpId}"', group: 'configuration', }],
['BrowserContext.credentialsGet', { title: 'Get virtual credentials', group: 'configuration', }],
Expand Down
Loading
Loading