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
@@ -1204,7 +1204,7 @@ impl MessageHandler<DocumentMessage, (&InputPreprocessorMessageHandler, &FontCac
.into(), .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(); let transform = DAffine2::from_translation(mouse - ipp.viewport_bounds.top_left).to_cols_array();
responses.push_back(DocumentOperation::SetLayerTransformInViewport { path, transform }.into()); responses.push_back(DocumentOperation::SetLayerTransformInViewport { path, transform }.into());
} }
@@ -176,8 +176,13 @@ impl PropertyHolder for MenuBarMessageHandler {
action: MenuEntry::create_action(|_| PortfolioMessage::Copy { clipboard: Clipboard::Device }.into()), action: MenuEntry::create_action(|_| PortfolioMessage::Copy { clipboard: Clipboard::Device }.into()),
..MenuEntry::default() ..MenuEntry::default()
}, },
// TODO: Fix this MenuEntry {
// { label: "Paste", icon: "Paste", shortcut: ["KeyControl", "KeyV"], action: async (): Promise<void> => editor.instance.paste() }, label: "Paste".into(),
icon: Some("Paste".into()),
shortcut: Some(vec![Key::KeyControl, Key::KeyV]),
action: MenuEntry::create_action(|_| FrontendMessage::TriggerPaste.into()),
..MenuEntry::default()
},
], ],
], ],
}, },
+1
View File
@@ -28,6 +28,7 @@ pub enum FrontendMessage {
TriggerFontLoad { font: Font, is_default: bool }, TriggerFontLoad { font: Font, is_default: bool },
TriggerIndexedDbRemoveDocument { document_id: u64 }, TriggerIndexedDbRemoveDocument { document_id: u64 },
TriggerIndexedDbWriteDocument { document: String, details: FrontendDocumentDetails, version: String }, TriggerIndexedDbWriteDocument { document: String, details: FrontendDocumentDetails, version: String },
TriggerPaste,
TriggerRasterDownload { document: String, name: String, mime: String, size: (f64, f64) }, TriggerRasterDownload { document: String, name: String, mime: String, size: (f64, f64) },
TriggerTextCommit, TriggerTextCommit,
TriggerTextCopy { copy_text: String }, TriggerTextCopy { copy_text: String },
+2 -2
View File
@@ -3,10 +3,10 @@
<div class="unsupported-modal-backdrop" v-if="apiUnsupported" ref="unsupported"> <div class="unsupported-modal-backdrop" v-if="apiUnsupported" ref="unsupported">
<LayoutCol class="unsupported-modal"> <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>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> <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 <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. API which is required for using the editor. However, you can still explore the user interface.
</p> </p>
+91 -18
View File
@@ -2,7 +2,9 @@ import { DialogState } from "@/state-providers/dialog";
import { FullscreenState } from "@/state-providers/fullscreen"; import { FullscreenState } from "@/state-providers/fullscreen";
import { PortfolioState } from "@/state-providers/portfolio"; import { PortfolioState } from "@/state-providers/portfolio";
import { makeKeyboardModifiersBitfield, textInputCleanup, getLatinKey } from "@/utility-functions/keyboard-entry"; import { makeKeyboardModifiersBitfield, textInputCleanup, getLatinKey } from "@/utility-functions/keyboard-entry";
import { stripIndents } from "@/utility-functions/strip-indents";
import { Editor } from "@/wasm-communication/editor"; import { Editor } from "@/wasm-communication/editor";
import { TriggerPaste } from "@/wasm-communication/messages";
type EventName = keyof HTMLElementEventMap | keyof WindowEventHandlersEventMap | "modifyinputfield"; type EventName = keyof HTMLElementEventMap | keyof WindowEventHandlersEventMap | "modifyinputfield";
type EventListenerTarget = { type EventListenerTarget = {
@@ -14,6 +16,16 @@ export function createInputManager(editor: Editor, container: HTMLElement, dialo
const app = window.document.querySelector("[data-app]") as HTMLElement | undefined; const app = window.document.querySelector("[data-app]") as HTMLElement | undefined;
app?.focus(); 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any
const listeners: { target: EventListenerTarget; eventName: EventName; action: (event: any) => void; options?: boolean | AddEventListenerOptions }[] = [ const listeners: { target: EventListenerTarget; eventName: EventName; action: (event: any) => void; options?: boolean | AddEventListenerOptions }[] = [
{ target: window, eventName: "resize", action: (): void => onWindowResize(container) }, { target: window, eventName: "resize", action: (): void => onWindowResize(container) },
@@ -37,12 +49,15 @@ export function createInputManager(editor: Editor, container: HTMLElement, dialo
}, },
]; ];
let viewportPointerInteractionOngoing = false; // Event bindings
let textInput = undefined as undefined | HTMLDivElement;
let canvasFocused = true;
function blurApp(): void { function bindListeners(): void {
canvasFocused = false; // 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 // Keyboard events
@@ -259,26 +274,80 @@ export function createInputManager(editor: Editor, container: HTMLElement, dialo
file.arrayBuffer().then((buffer): void => { file.arrayBuffer().then((buffer): void => {
const u8Array = new Uint8Array(buffer); 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 { // Frontend message subscriptions
return target instanceof HTMLElement && (target.nodeName === "INPUT" || target.nodeName === "TEXTAREA" || target.isContentEditable);
}
// 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 { // Read the clipboard contents if the Clipboard API is available
// Add event bindings for the lifetime of the application const clipboardItems = await navigator.clipboard.read();
listeners.forEach(({ target, eventName, action, options }) => target.addEventListener(eventName, action, options)); if (!clipboardItems) throw new Error("Clipboard API unsupported");
}
function unbindListeners(): void { // Read any layer data or images from the clipboard
// Remove event bindings after the lifetime of the application (or on hot-module replacement during development) Array.from(clipboardItems).forEach(async (item) => {
listeners.forEach(({ target, eventName, action, options }) => target.removeEventListener(eventName, action, options)); // 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 // Initialization
@@ -290,3 +359,7 @@ export function createInputManager(editor: Editor, container: HTMLElement, dialo
// Return the destructor // Return the destructor
return unbindListeners; return unbindListeners;
} }
function targetIsTextField(target: EventTarget | HTMLElement | null): boolean {
return target instanceof HTMLElement && (target.nodeName === "INPUT" || target.nodeName === "TEXTAREA" || target.isContentEditable);
}
@@ -198,6 +198,8 @@ export class TriggerFileDownload extends JsMessage {
export class TriggerFileUpload extends JsMessage {} export class TriggerFileUpload extends JsMessage {}
export class TriggerPaste extends JsMessage {}
export class TriggerRasterDownload extends JsMessage { export class TriggerRasterDownload extends JsMessage {
readonly document!: string; readonly document!: string;
@@ -584,6 +586,7 @@ export const messageMakers: Record<string, MessageMaker> = {
TriggerIndexedDbRemoveDocument, TriggerIndexedDbRemoveDocument,
TriggerFontLoad, TriggerFontLoad,
TriggerIndexedDbWriteDocument, TriggerIndexedDbWriteDocument,
TriggerPaste,
TriggerRasterDownload, TriggerRasterDownload,
TriggerTextCommit, TriggerTextCommit,
TriggerTextCopy, TriggerTextCopy,