Implement the Edit > Paste menu entry (#683)

Closes #682
This commit is contained in:
Keavon Chambers 2022-06-19 00:53:33 -07:00
parent 5d6d2b22bc
commit fe4a76a395
6 changed files with 105 additions and 23 deletions

View File

@ -1204,7 +1204,7 @@ impl MessageHandler<DocumentMessage, (&InputPreprocessorMessageHandler, &FontCac
.into(),
);
let mouse = mouse.map_or(ipp.mouse.position, |pos| pos.into());
let mouse = mouse.map_or(ipp.viewport_bounds.center(), |pos| pos.into());
let transform = DAffine2::from_translation(mouse - ipp.viewport_bounds.top_left).to_cols_array();
responses.push_back(DocumentOperation::SetLayerTransformInViewport { path, transform }.into());
}

View File

@ -176,8 +176,13 @@ impl PropertyHolder for MenuBarMessageHandler {
action: MenuEntry::create_action(|_| PortfolioMessage::Copy { clipboard: Clipboard::Device }.into()),
..MenuEntry::default()
},
// TODO: Fix this
// { label: "Paste", icon: "Paste", shortcut: ["KeyControl", "KeyV"], action: async (): Promise<void> => editor.instance.paste() },
MenuEntry {
label: "Paste".into(),
icon: Some("Paste".into()),
shortcut: Some(vec![Key::KeyControl, Key::KeyV]),
action: MenuEntry::create_action(|_| FrontendMessage::TriggerPaste.into()),
..MenuEntry::default()
},
],
],
},

View File

@ -28,6 +28,7 @@ pub enum FrontendMessage {
TriggerFontLoad { font: Font, is_default: bool },
TriggerIndexedDbRemoveDocument { document_id: u64 },
TriggerIndexedDbWriteDocument { document: String, details: FrontendDocumentDetails, version: String },
TriggerPaste,
TriggerRasterDownload { document: String, name: String, mime: String, size: (f64, f64) },
TriggerTextCommit,
TriggerTextCopy { copy_text: String },

View File

@ -3,10 +3,10 @@
<div class="unsupported-modal-backdrop" v-if="apiUnsupported" ref="unsupported">
<LayoutCol class="unsupported-modal">
<h2>Your browser currently doesn't support Graphite</h2>
<h2>This browser currently doesn't support Graphite</h2>
<p>Unfortunately, some features won't work properly. Please upgrade to a modern browser such as Firefox, Chrome, Edge, or Safari version 15 or later.</p>
<p>
Your browser is missing support for the
This browser is missing support for the
<a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/BigInt64Array#browser_compatibility" target="_blank"><code>BigInt64Array</code></a> JavaScript
API which is required for using the editor. However, you can still explore the user interface.
</p>

View File

@ -2,7 +2,9 @@ import { DialogState } from "@/state-providers/dialog";
import { FullscreenState } from "@/state-providers/fullscreen";
import { PortfolioState } from "@/state-providers/portfolio";
import { makeKeyboardModifiersBitfield, textInputCleanup, getLatinKey } from "@/utility-functions/keyboard-entry";
import { stripIndents } from "@/utility-functions/strip-indents";
import { Editor } from "@/wasm-communication/editor";
import { TriggerPaste } from "@/wasm-communication/messages";
type EventName = keyof HTMLElementEventMap | keyof WindowEventHandlersEventMap | "modifyinputfield";
type EventListenerTarget = {
@ -14,6 +16,16 @@ export function createInputManager(editor: Editor, container: HTMLElement, dialo
const app = window.document.querySelector("[data-app]") as HTMLElement | undefined;
app?.focus();
let viewportPointerInteractionOngoing = false;
let textInput = undefined as undefined | HTMLDivElement;
let canvasFocused = true;
function blurApp(): void {
canvasFocused = false;
}
// Event listeners
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const listeners: { target: EventListenerTarget; eventName: EventName; action: (event: any) => void; options?: boolean | AddEventListenerOptions }[] = [
{ target: window, eventName: "resize", action: (): void => onWindowResize(container) },
@ -37,12 +49,15 @@ export function createInputManager(editor: Editor, container: HTMLElement, dialo
},
];
let viewportPointerInteractionOngoing = false;
let textInput = undefined as undefined | HTMLDivElement;
let canvasFocused = true;
// Event bindings
function blurApp(): void {
canvasFocused = false;
function bindListeners(): void {
// Add event bindings for the lifetime of the application
listeners.forEach(({ target, eventName, action, options }) => target.addEventListener(eventName, action, options));
}
function unbindListeners(): void {
// Remove event bindings after the lifetime of the application (or on hot-module replacement during development)
listeners.forEach(({ target, eventName, action, options }) => target.removeEventListener(eventName, action, options));
}
// Keyboard events
@ -259,26 +274,80 @@ export function createInputManager(editor: Editor, container: HTMLElement, dialo
file.arrayBuffer().then((buffer): void => {
const u8Array = new Uint8Array(buffer);
editor.instance.paste_image(file.type, u8Array, undefined, undefined);
editor.instance.paste_image(file.type, u8Array);
});
}
});
}
function targetIsTextField(target: EventTarget | HTMLElement | null): boolean {
return target instanceof HTMLElement && (target.nodeName === "INPUT" || target.nodeName === "TEXTAREA" || target.isContentEditable);
}
// Frontend message subscriptions
// Event bindings
editor.subscriptions.subscribeJsMessage(TriggerPaste, 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 {
// Attempt to check if the clipboard permission is denied, and throw an error if that is the case
// In Firefox, the `clipboard-read` permission isn't supported, so attemping to query it throws an error
// In Safari, the entire Permissions API isn't supported, so the query never occurs and this block is skipped without an error and we assume we might have permission
const clipboardRead = "clipboard-read" as PermissionName;
const permission = await navigator.permissions?.query({ name: clipboardRead });
if (permission?.state === "denied") throw new Error("Permission denied");
function bindListeners(): void {
// Add event bindings for the lifetime of the application
listeners.forEach(({ target, eventName, action, options }) => target.addEventListener(eventName, action, options));
}
function unbindListeners(): void {
// Remove event bindings after the lifetime of the application (or on hot-module replacement during development)
listeners.forEach(({ target, eventName, action, options }) => target.removeEventListener(eventName, action, options));
}
// Read the clipboard contents if the Clipboard API is available
const clipboardItems = await navigator.clipboard.read();
if (!clipboardItems) throw new Error("Clipboard API unsupported");
// Read any layer data or images from the clipboard
Array.from(clipboardItems).forEach(async (item) => {
// Read plain text and, if it is a layer, pass it to the editor
if (item.types.includes("text/plain")) {
const blob = await item.getType("text/plain");
const reader = new FileReader();
reader.onload = (): void => {
const text = reader.result as string;
if (text.startsWith("graphite/layer: ")) {
editor.instance.paste_serialized_data(text.substring(16, text.length));
}
};
reader.readAsText(blob);
}
// Read an image from the clipboard and pass it to the editor to be loaded
const imageType = item.types.find((type) => type.startsWith("image/"));
if (imageType) {
const blob = await item.getType(imageType);
const reader = new FileReader();
reader.onload = (): void => {
const u8Array = new Uint8Array(reader.result as ArrayBuffer);
editor.instance.paste_image(imageType, u8Array);
};
reader.readAsArrayBuffer(blob);
}
});
} catch (err) {
const unsupported = stripIndents`
This browser does not support reading from the clipboard.
Use the keyboard shortcut to paste instead.
`;
const denied = stripIndents`
The browser's clipboard permission has been denied.
Open the browser's website settings (usually accessible
just left of the URL) to allow this permission.
`;
const matchMessage = {
"clipboard-read": unsupported,
"Clipboard API unsupported": unsupported,
"Permission denied": denied,
};
const message = Object.entries(matchMessage).find(([key]) => String(err).includes(key))?.[1] || String(err);
editor.instance.error_dialog("Cannot access clipboard", message);
}
});
// Initialization
@ -290,3 +359,7 @@ export function createInputManager(editor: Editor, container: HTMLElement, dialo
// Return the destructor
return unbindListeners;
}
function targetIsTextField(target: EventTarget | HTMLElement | null): boolean {
return target instanceof HTMLElement && (target.nodeName === "INPUT" || target.nodeName === "TEXTAREA" || target.isContentEditable);
}

View File

@ -198,6 +198,8 @@ export class TriggerFileDownload extends JsMessage {
export class TriggerFileUpload extends JsMessage {}
export class TriggerPaste extends JsMessage {}
export class TriggerRasterDownload extends JsMessage {
readonly document!: string;
@ -584,6 +586,7 @@ export const messageMakers: Record<string, MessageMaker> = {
TriggerIndexedDbRemoveDocument,
TriggerFontLoad,
TriggerIndexedDbWriteDocument,
TriggerPaste,
TriggerRasterDownload,
TriggerTextCommit,
TriggerTextCopy,