Files
webstudio/apps/builder/app/shared/copy-paste/copy-paste.ts
d292502037 fix: Paste in Firefox navigator (#6329)
## Summary

- Read every clipboard mime type before the first await in both paste
handlers.
- Call the paste handler registered by the active mode from `emitPaste`
instead of dispatching a synthetic clipboard event.
- Add regression coverage for both paths: a clipboard event that
disconnects after dispatch, and a Firefox-shaped `ClipboardEvent`.

## Root cause

Two independent Firefox behaviors, one per entry point.

Keyboard paste: the plugin loop re-read `event.clipboardData` on every
iteration. Firefox disconnects a paste event's `DataTransfer` as soon as
the handler yields, so once `pageText` — first in the list, and async —
awaited, every later plugin read an empty string and `instanceText`
never received the payload. Chrome keeps the data readable across
microtasks, which is why this only surfaced in Firefox. Introduced in
#5798, which put `pageText` at the head of the plugin list.

Context menu paste: `emitPaste` dispatched `new ClipboardEvent("paste",
{ clipboardData })`. Gecko's `ClipboardEventInit` has no `clipboardData`
member, so the event arrived with a non-null but empty `clipboardData`.
`validateClipboardEvent` only rejects a null one, so the paste silently
did nothing — no toast, no error.

Measured in Firefox 134 against Chrome and Edge 152: `getData` after one
microtask returns the payload in Chromium and an empty string in
Firefox; a synthetic event's `getData` likewise returns the payload in
Chromium and an empty string in Firefox.

No behavior change in Chromium. Every plugin returns `pasteIgnored`
before its first await, so reading all mime types up front yields the
same input the loop received before.

## Verification

- `pnpm --filter @webstudio-is/builder exec vitest run
app/shared/copy-paste app/builder/shared/asset-manager
app/shared/commands-emitter.test.ts` — 335 tests passed.
- `pnpm --filter @webstudio-is/builder typecheck` — passed.
- `pnpm exec oxlint --deny-warnings apps/builder/app/shared/copy-paste/`
— passed.
- `pnpm exec oxfmt --check
apps/builder/app/shared/copy-paste/copy-paste.ts
apps/builder/app/shared/copy-paste/copy-paste.test.ts` — passed.
- Reverted each fix in turn against the new tests: 13 failures for the
keyboard path, 2 for the context menu, all passing again once restored.
- e2e runs Chromium only, and the previous test `DataTransfer` never
disconnected, so neither path was covered before.

Closes #6288

---------

Co-authored-by: Anmol <anmol@fathershops.com>
Co-authored-by: Oleg Isonen <oleg008@gmail.com>
2026-09-04 15:36:20 +02:00

407 lines
11 KiB
TypeScript

import { toastError } from "../error/toast-error";
import {
$authTokenPermissions,
$textEditingInstanceSelector,
} from "../nano-states";
import { instanceText, instanceJson } from "./plugin-instance";
import {
pageText,
copyFolderData,
copyPageData,
copyTemplateData,
handlePastePage,
} from "./plugin-page";
import { html } from "./plugin-html";
import { jsx } from "./plugin-jsx";
import { markdown } from "./plugin-markdown";
import { webflow } from "./plugin-webflow/plugin-webflow";
import { designTokens } from "./plugin-design-tokens";
import { builderApi } from "../builder-api";
import { readClipboardText } from "../clipboard";
const isTextEditing = (event: ClipboardEvent) => {
// Text is edited on the Canvas using the default Canvas text editor settings.
if ($textEditingInstanceSelector.get() != null) {
return true;
}
// Text is edited inside an input, textarea, or contenteditable (i.e. codemirror editor) field.
if (
event.target instanceof HTMLInputElement ||
event.target instanceof HTMLTextAreaElement ||
(event.target instanceof HTMLElement &&
event.target.closest("[contenteditable]"))
) {
return true;
}
return false;
};
/**
*
* # Note:
* validateClipboardEvent determines when to use default copy/paste behavior or if a WebStudio instance will be copied, pasted, or cut.
* # How to test:
* 1. Focus on an input in the style panel (width, style source) or name input in settings. Select the text, then copy and paste any instance in the Navigator tree or on the Canvas.
* 2. Edit text in a paragraph on the Canvas. Select text (single or multiple lines), then press Cmd+X. The paragraph should not be deleted.
* 3. Select CSS preview text, then select an instance in the Navigator. Press Cmd+C, then Cmd+V.
**/
const validateClipboardEvent = (event: ClipboardEvent) => {
if (event.clipboardData === null) {
return false;
}
if (isTextEditing(event)) {
return false;
}
// Allows native selection of text in the Builder panels, such as CSS preview.
if (event.type === "copy") {
const isInBuilderContext = window.self === window.top;
const selection = window.getSelection();
if (isInBuilderContext && selection && selection.isCollapsed === false) {
return false;
}
}
return validateCopyPermission();
};
const validateCopyPermission = () => {
if ($authTokenPermissions.get().canCopy === false) {
toastError("Copying has been disabled by the project owner");
return false;
}
return true;
};
export type Plugin = {
name: string;
mimeType: string;
onCopy?: () => undefined | string | Promise<undefined | string>;
onCut?: () => undefined | string;
onPaste?: (data: string) => PasteResult | Promise<PasteResult>;
};
export type PasteResult =
| { success: true; data: { handled: false } }
| { success: true; data: { handled: true } }
| { success: false; error: string };
export const pasteIgnored: PasteResult = {
success: true,
data: { handled: false },
};
export const pasteHandled: PasteResult = {
success: true,
data: { handled: true },
};
const isPasteHandled = (result: PasteResult) =>
result.success === false || result.data.handled;
const reportPasteResult = (result: PasteResult) => {
if (result.success === false) {
builderApi.toast.error(result.error);
}
};
const textMimeType = "text/plain";
const readClipboardData = (
mimeTypes: Array<string>,
clipboardData: null | DataTransfer
) => {
const dataByMimeType = new Map<string, string>();
for (const mimeType of mimeTypes) {
if (dataByMimeType.has(mimeType) === false) {
dataByMimeType.set(
mimeType,
clipboardData?.getData(mimeType).trim() ?? ""
);
}
}
return dataByMimeType;
};
let pasteClipboardText: undefined | ((text: string) => Promise<void>);
const registerPasteClipboardText = (
handler: (text: string) => Promise<void>,
signal: AbortSignal
) => {
pasteClipboardText = handler;
signal.addEventListener("abort", () => {
if (pasteClipboardText === handler) {
pasteClipboardText = undefined;
}
});
};
const validatePasteCommand = () => {
if ($textEditingInstanceSelector.get() != null) {
return false;
}
return validateCopyPermission();
};
const initPlugins = ({
plugins,
signal,
}: {
plugins: Array<Plugin>;
signal: AbortSignal;
}) => {
const handleCopy = (event: ClipboardEvent) => {
if (validateClipboardEvent(event) === false) {
return;
}
for (const { mimeType, onCopy } of plugins) {
const data = onCopy?.();
if (data instanceof Promise) {
event.preventDefault();
void data
.then((value) => writeClipboardText(value))
.catch(() =>
builderApi.toast.error(
"Could not prepare the selected content for copying."
)
);
break;
}
if (data) {
// must prevent default, otherwise setData() will not work
event.preventDefault();
event.clipboardData?.setData(mimeType, data);
break;
}
}
};
const handleCut = (event: ClipboardEvent) => {
if (validateClipboardEvent(event) === false) {
return;
}
for (const { mimeType, onCut } of plugins) {
const data = onCut?.();
if (data) {
// must prevent default, otherwise setData() will not work
event.preventDefault();
event.clipboardData?.setData(mimeType, data);
break;
}
}
};
const pasteData = async (dataByMimeType: Map<string, string>) => {
for (const { mimeType, onPaste } of plugins) {
if (onPaste === undefined) {
continue;
}
const data = dataByMimeType.get(mimeType);
if (data === undefined || data === "") {
continue;
}
const result = await onPaste(data);
if (isPasteHandled(result)) {
reportPasteResult(result);
break;
}
}
};
const handlePaste = async (event: ClipboardEvent) => {
if (validateClipboardEvent(event) === false) {
return;
}
event.preventDefault();
await pasteData(
readClipboardData(
plugins.map((plugin) => plugin.mimeType),
event.clipboardData
)
);
};
const handlePasteText = async (text: string) => {
if (validatePasteCommand() === false) {
return;
}
await pasteData(new Map([[textMimeType, text.trim()]]));
};
document.addEventListener("copy", handleCopy, { signal });
document.addEventListener("cut", handleCut, { signal });
// Capture is required so we get the element before content-editable removes it
// This way we can detect when we are inside content-editable and ignore the event
document.addEventListener("paste", handlePaste, { capture: true, signal });
registerPasteClipboardText(handlePasteText, signal);
};
export const initCopyPaste = ({ signal }: { signal: AbortSignal }) => {
initPlugins({
plugins: [
pageText,
instanceJson,
instanceText,
designTokens,
jsx,
html,
markdown,
webflow,
],
signal,
});
};
export const initCopyPasteForContentEditMode = ({
signal,
}: {
signal: AbortSignal;
}) => {
const showUnsupportedCopyMessage = () => {
builderApi.toast.info("This selection cannot be copied here.");
};
const showUnsupportedPasteMessage = () => {
builderApi.toast.info("This clipboard data cannot be pasted here.");
};
const showCutDesignModeOnlyMessage = () => {
builderApi.toast.info("Cutting is allowed in design mode only.");
};
const handleCopy = (event: ClipboardEvent) => {
if (validateClipboardEvent(event) === false) {
return;
}
const data = instanceText.onCopy?.();
if (data) {
event.preventDefault();
event.clipboardData?.setData(instanceText.mimeType, data);
return;
}
showUnsupportedCopyMessage();
};
const handlePaste = async (event: ClipboardEvent) => {
if (validateClipboardEvent(event) === false) {
return;
}
const dataByMimeType = readClipboardData(
[instanceJson.mimeType, instanceText.mimeType],
event.clipboardData
);
const jsonData = dataByMimeType.get(instanceJson.mimeType);
if (jsonData) {
event.preventDefault();
const result = await instanceJson.onPaste(jsonData);
if (isPasteHandled(result)) {
reportPasteResult(result);
return;
}
showUnsupportedPasteMessage();
return;
}
const textData = dataByMimeType.get(instanceText.mimeType);
if (textData) {
event.preventDefault();
const result = await instanceText.onPaste(textData);
if (isPasteHandled(result)) {
reportPasteResult(result);
return;
}
showUnsupportedPasteMessage();
return;
}
showUnsupportedPasteMessage();
};
const handlePasteText = async (text: string) => {
if (validatePasteCommand() === false) {
return;
}
const textData = text.trim();
if (textData === "") {
showUnsupportedPasteMessage();
return;
}
const result = await instanceText.onPaste(textData);
if (isPasteHandled(result)) {
reportPasteResult(result);
return;
}
showUnsupportedPasteMessage();
};
const handleCut = (event: ClipboardEvent) => {
if (validateClipboardEvent(event) === false) {
return;
}
showCutDesignModeOnlyMessage();
};
document.addEventListener("copy", handleCopy, { signal });
document.addEventListener("cut", handleCut, { signal });
// Capture is required so we get the element before content-editable removes it
// This way we can detect when we are inside content-editable and ignore the event
document.addEventListener("paste", handlePaste, {
capture: true,
signal,
});
registerPasteClipboardText(handlePasteText, signal);
};
const writeClipboardText = async (
data: string | undefined | Promise<string | undefined>
) => {
data = await data;
if (data && validateCopyPermission()) {
await navigator.clipboard.writeText(data);
}
};
// Public API for programmatic copy/paste/cut operations
export const copyInstance = () => {
return writeClipboardText(instanceText.onCopy?.());
};
export const copyPage = (pageId: string) => {
return writeClipboardText(copyPageData(pageId));
};
export const copyFolder = (folderId: string) => {
return writeClipboardText(copyFolderData(folderId));
};
export const copyTemplate = (templateId: string) => {
return writeClipboardText(copyTemplateData(templateId));
};
export const pastePage = async (targetFolderId?: string) => {
const text = await readClipboardText();
if (text === undefined) {
return false;
}
const result = await handlePastePage(text, targetFolderId);
reportPasteResult(result);
return isPasteHandled(result);
};
export const emitPaste = async () => {
const text = await readClipboardText();
if (text === undefined) {
return;
}
await pasteClipboardText?.(text);
};
export const cutInstance = () => {
return writeClipboardText(instanceText.onCut?.());
};