mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-24 08:58:12 +08:00
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:
@@ -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 }));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user