Desktop: Text clipboard support (#3461)

* gray background for viewport texture

* cust copy paste support

* connect clipboard read on web

* fix eyedropper bounds

* cleanup

* add missing char events for some named keys like enter
This commit is contained in:
Timon
2025-12-12 00:16:35 +00:00
committed by GitHub
parent d6c06da878
commit 6d852f11af
30 changed files with 602 additions and 115 deletions
+90 -3
View File
@@ -1,10 +1,97 @@
import { type Editor } from "@graphite/editor";
import { TriggerTextCopy } from "@graphite/messages";
import { TriggerClipboardWrite, TriggerSelectionRead, TriggerSelectionWrite } from "@graphite/messages";
export function createClipboardManager(editor: Editor) {
// Subscribe to process backend event
editor.subscriptions.subscribeJsMessage(TriggerTextCopy, (triggerTextCopy) => {
editor.subscriptions.subscribeJsMessage(TriggerClipboardWrite, (triggerTextCopy) => {
// If the Clipboard API is supported in the browser, copy text to the clipboard
navigator.clipboard?.writeText?.(triggerTextCopy.copyText);
navigator.clipboard?.writeText?.(triggerTextCopy.content);
});
editor.subscriptions.subscribeJsMessage(TriggerSelectionRead, async (data) => {
editor.handle.readSelection(readAtCaret(data.cut), data.cut);
});
editor.subscriptions.subscribeJsMessage(TriggerSelectionWrite, async (data) => {
insertAtCaret(data.content);
});
}
function readAtCaret(cut: boolean): string | undefined {
const element = window.document.activeElement;
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
const start = element.selectionStart;
const end = element.selectionEnd;
if ((!start && start !== 0) || (!end && end !== 0) || start === end) {
return undefined;
}
const value = element.value;
const selectedText = value.slice(start, end);
if (cut) {
element.value = value.slice(0, start) + value.slice(end);
element.selectionStart = element.selectionEnd = start;
element.dispatchEvent(new Event("input", { bubbles: true }));
}
return selectedText;
}
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
return undefined;
}
const selectedText = selection.toString();
if (!selectedText) return undefined;
if (cut) {
const range = selection.getRangeAt(0);
range.deleteContents();
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
}
return selectedText;
}
function insertAtCaret(text: string) {
const element = window.document.activeElement;
if (!element) return;
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
const start = element.selectionStart;
const end = element.selectionEnd;
if ((!start && start !== 0) || (!end && end !== 0)) return;
const value = element.value;
element.value = value.slice(0, start) + text + value.slice(end);
const newPos = start + text.length;
element.selectionStart = element.selectionEnd = newPos;
} else if (element instanceof HTMLElement && element.isContentEditable) {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
range.deleteContents();
const textNode = window.document.createTextNode(text);
range.insertNode(textNode);
range.setStartAfter(textNode);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
}
element.dispatchEvent(new Event("input", { bubbles: true }));
}
+9 -19
View File
@@ -1,13 +1,13 @@
import { get } from "svelte/store";
import { type Editor } from "@graphite/editor";
import { TriggerPaste } from "@graphite/messages";
import { TriggerClipboardRead } from "@graphite/messages";
import { type DialogState } from "@graphite/state-providers/dialog";
import { type DocumentState } from "@graphite/state-providers/document";
import { type FullscreenState } from "@graphite/state-providers/fullscreen";
import { type PortfolioState } from "@graphite/state-providers/portfolio";
import { makeKeyboardModifiersBitfield, textInputCleanup, getLocalizedScanCode } from "@graphite/utility-functions/keyboard-entry";
import { operatingSystem } from "@graphite/utility-functions/platform";
import { isDesktop, operatingSystem } from "@graphite/utility-functions/platform";
import { extractPixelData } from "@graphite/utility-functions/rasterization";
import { stripIndents } from "@graphite/utility-functions/strip-indents";
@@ -82,10 +82,13 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli
// TODO: Switch to a system where everything is sent to the backend, then the input preprocessor makes decisions and kicks some inputs back to the frontend
const accelKey = operatingSystem() === "Mac" ? e.metaKey : e.ctrlKey;
// Cut, copy, and paste is handled in the backend on desktop
if (isDesktop() && accelKey && ["KeyX", "KeyC", "KeyV"].includes(key)) return true;
// Don't redirect user input from text entry into HTML elements
if (targetIsTextField(e.target || undefined) && key !== "Escape" && !(accelKey && ["Enter", "NumpadEnter"].includes(key))) return false;
// Don't redirect paste
// Don't redirect paste in web
if (key === "KeyV" && accelKey) return false;
// Don't redirect a fullscreen request
@@ -306,20 +309,10 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli
if (!dataTransfer || targetIsTextField(e.target || undefined)) return;
e.preventDefault();
const LAYER_DATA = "graphite/layer: ";
const NODES_DATA = "graphite/nodes: ";
const VECTOR_DATA = "graphite/vector: ";
Array.from(dataTransfer.items).forEach(async (item) => {
if (item.type === "text/plain") {
item.getAsString((text) => {
if (text.startsWith(LAYER_DATA)) {
editor.handle.pasteSerializedData(text.substring(LAYER_DATA.length, text.length));
} else if (text.startsWith(NODES_DATA)) {
editor.handle.pasteSerializedNodes(text.substring(NODES_DATA.length, text.length));
} else if (text.startsWith(VECTOR_DATA)) {
editor.handle.pasteSerializedVector(text.substring(VECTOR_DATA.length, text.length));
}
editor.handle.pasteText(text);
});
}
@@ -413,7 +406,7 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli
// Frontend message subscriptions
editor.subscriptions.subscribeJsMessage(TriggerPaste, async () => {
editor.subscriptions.subscribeJsMessage(TriggerClipboardRead, async () => {
// In the try block, attempt to read from the Clipboard API, which may not have permission and may not be supported in all browsers
// In the catch block, explain to the user why the paste failed and how to fix or work around the problem
try {
@@ -437,10 +430,7 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli
const reader = new FileReader();
reader.onload = () => {
const text = reader.result as string;
if (text.startsWith("graphite/layer: ")) {
editor.handle.pasteSerializedData(text.substring(16, text.length));
}
editor.handle.pasteText(text);
};
reader.readAsText(blob);
return true;
+15 -5
View File
@@ -725,7 +725,7 @@ export class TriggerOpenDocument extends JsMessage {}
export class TriggerImport extends JsMessage {}
export class TriggerPaste extends JsMessage {}
export class TriggerClipboardRead extends JsMessage {}
export class TriggerSaveDocument extends JsMessage {
readonly documentId!: bigint;
@@ -868,8 +868,16 @@ export class TriggerVisitLink extends JsMessage {
export class TriggerTextCommit extends JsMessage {}
export class TriggerTextCopy extends JsMessage {
readonly copyText!: string;
export class TriggerClipboardWrite extends JsMessage {
readonly content!: string;
}
export class TriggerSelectionRead extends JsMessage {
readonly cut!: boolean;
}
export class TriggerSelectionWrite extends JsMessage {
readonly content!: string;
}
export class TriggerAboutGraphiteLocalizedCommitDate extends JsMessage {
@@ -1695,7 +1703,6 @@ export const messageMakers: Record<string, MessageMaker> = {
TriggerLoadRestAutoSaveDocuments,
TriggerOpenDocument,
TriggerOpenLaunchDocuments,
TriggerPaste,
TriggerPersistenceRemoveDocument,
TriggerPersistenceWriteDocument,
TriggerSaveActiveDocument,
@@ -1703,7 +1710,10 @@ export const messageMakers: Record<string, MessageMaker> = {
TriggerSaveFile,
TriggerSavePreferences,
TriggerTextCommit,
TriggerTextCopy,
TriggerClipboardRead,
TriggerClipboardWrite,
TriggerSelectionRead,
TriggerSelectionWrite,
TriggerVisitLink,
UpdateActiveDocument,
UpdateBox,