Break out helper functions from the frontend's managers and stores (#3920)
* Move destructor call to each manager/store constructor for safety * Break out utility functions
This commit is contained in:
parent
0c7b5cd534
commit
64fd12a1a0
|
|
@ -1,9 +1,9 @@
|
|||
<script lang="ts">
|
||||
import { getContext, onMount } from "svelte";
|
||||
|
||||
import { wipeDocuments } from "@graphite/managers/persistence";
|
||||
import type { DialogStore } from "@graphite/stores/dialog";
|
||||
import { crashReportUrl } from "@graphite/utility-functions/crash-report";
|
||||
import { wipeDocuments } from "@graphite/utility-functions/persistence";
|
||||
|
||||
import FloatingMenu from "@graphite/components/layout/FloatingMenu.svelte";
|
||||
import LayoutCol from "@graphite/components/layout/LayoutCol.svelte";
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import type { Editor } from "@graphite/editor";
|
||||
import { insertAtCaret, readAtCaret } from "@graphite/utility-functions/clipboard";
|
||||
|
||||
let editorRef: Editor | undefined = undefined;
|
||||
|
||||
export function createClipboardManager(editor: Editor) {
|
||||
destroyClipboardManager();
|
||||
|
||||
editorRef = editor;
|
||||
|
||||
editor.subscriptions.subscribeFrontendMessage("TriggerClipboardWrite", (data) => {
|
||||
|
|
@ -28,89 +31,7 @@ export function destroyClipboardManager() {
|
|||
editor.subscriptions.unsubscribeFrontendMessage("TriggerSelectionWrite");
|
||||
}
|
||||
|
||||
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 = String(selection);
|
||||
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 }));
|
||||
}
|
||||
|
||||
// Self-accepting HMR: tear down the old instance and re-create with the new module's code
|
||||
import.meta.hot?.accept((newModule) => {
|
||||
destroyClipboardManager();
|
||||
if (editorRef) newModule?.createClipboardManager(editorRef);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ let editorRef: Editor | undefined = undefined;
|
|||
let abortController: AbortController | undefined = undefined;
|
||||
|
||||
export function createFontsManager(editor: Editor) {
|
||||
destroyFontsManager();
|
||||
|
||||
editorRef = editor;
|
||||
abortController = new AbortController();
|
||||
|
||||
|
|
@ -66,6 +68,5 @@ export function destroyFontsManager() {
|
|||
|
||||
// Self-accepting HMR: tear down the old instance and re-create with the new module's code
|
||||
import.meta.hot?.accept((newModule) => {
|
||||
destroyFontsManager();
|
||||
if (editorRef) newModule?.createFontsManager(editorRef);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import type { Editor } from "@graphite/editor";
|
|||
let editorRef: Editor | undefined = undefined;
|
||||
|
||||
export function createHyperlinkManager(editor: Editor) {
|
||||
destroyHyperlinkManager();
|
||||
|
||||
editorRef = editor;
|
||||
|
||||
editor.subscriptions.subscribeFrontendMessage("TriggerVisitLink", async (data) => {
|
||||
|
|
@ -19,6 +21,5 @@ export function destroyHyperlinkManager() {
|
|||
|
||||
// Self-accepting HMR: tear down the old instance and re-create with the new module's code
|
||||
import.meta.hot?.accept((newModule) => {
|
||||
destroyHyperlinkManager();
|
||||
if (editorRef) newModule?.createHyperlinkManager(editorRef);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,72 +1,70 @@
|
|||
import { get } from "svelte/store";
|
||||
|
||||
import { isPlatformNative } from "@graphite/../wasm/pkg/graphite_wasm";
|
||||
import type { Editor } from "@graphite/editor";
|
||||
import type { DialogStore } from "@graphite/stores/dialog";
|
||||
import type { DocumentStore } from "@graphite/stores/document";
|
||||
import { fullscreenModeChanged, toggleFullscreen } from "@graphite/stores/fullscreen";
|
||||
import { fullscreenModeChanged } from "@graphite/stores/fullscreen";
|
||||
import type { PortfolioStore } from "@graphite/stores/portfolio";
|
||||
import { pasteFile } from "@graphite/utility-functions/files";
|
||||
import { makeKeyboardModifiersBitfield, textInputCleanup, getLocalizedScanCode } from "@graphite/utility-functions/keyboard-entry";
|
||||
import { operatingSystem } from "@graphite/utility-functions/platform";
|
||||
import { extractPixelData } from "@graphite/utility-functions/rasterization";
|
||||
import { stripIndents } from "@graphite/utility-functions/strip-indents";
|
||||
|
||||
const BUTTON_LEFT = 0;
|
||||
const BUTTON_MIDDLE = 1;
|
||||
const BUTTON_RIGHT = 2;
|
||||
const BUTTON_BACK = 3;
|
||||
const BUTTON_FORWARD = 4;
|
||||
|
||||
export const PRESS_REPEAT_DELAY_MS = 400;
|
||||
export const PRESS_REPEAT_INTERVAL_MS = 72;
|
||||
export const PRESS_REPEAT_INTERVAL_RAPID_MS = 10;
|
||||
import { triggerClipboardRead } from "@graphite/utility-functions/clipboard";
|
||||
import {
|
||||
onBeforeUnload,
|
||||
onKeyUp,
|
||||
onKeyDown,
|
||||
onPointerMove,
|
||||
onPointerDown,
|
||||
onPointerUp,
|
||||
onMouseDown,
|
||||
onPotentialDoubleClick,
|
||||
onWheelScroll,
|
||||
onModifyInputField,
|
||||
onFocusOut,
|
||||
onContextMenu,
|
||||
onPaste,
|
||||
onPointerLockChange,
|
||||
} from "@graphite/utility-functions/input";
|
||||
|
||||
type EventName = keyof HTMLElementEventMap | keyof WindowEventHandlersEventMap | "modifyinputfield" | "pointerlockchange" | "pointerlockerror";
|
||||
type EventListenerTarget = {
|
||||
addEventListener: typeof window.addEventListener;
|
||||
removeEventListener: typeof window.removeEventListener;
|
||||
};
|
||||
type Listener = { target: EventListenerTarget; eventName: EventName; action(event: Event): void; options?: AddEventListenerOptions };
|
||||
|
||||
export const PRESS_REPEAT_DELAY_MS = 400;
|
||||
export const PRESS_REPEAT_INTERVAL_MS = 72;
|
||||
export const PRESS_REPEAT_INTERVAL_RAPID_MS = 10;
|
||||
const listeners: Listener[] = [
|
||||
{ target: window, eventName: "beforeunload", action: (e: BeforeUnloadEvent) => editorRef && portfolioStore && onBeforeUnload(e, editorRef, portfolioStore) },
|
||||
{ target: window, eventName: "keyup", action: (e: KeyboardEvent) => editorRef && dialogStore && onKeyUp(e, editorRef, dialogStore) },
|
||||
{ target: window, eventName: "keydown", action: (e: KeyboardEvent) => editorRef && dialogStore && onKeyDown(e, editorRef, dialogStore) },
|
||||
{ target: window, eventName: "pointermove", action: (e: PointerEvent) => editorRef && documentStore && onPointerMove(e, editorRef, documentStore) },
|
||||
{ target: window, eventName: "pointerdown", action: (e: PointerEvent) => editorRef && dialogStore && onPointerDown(e, editorRef, dialogStore) },
|
||||
{ target: window, eventName: "pointerup", action: (e: PointerEvent) => editorRef && onPointerUp(e, editorRef) },
|
||||
{ target: window, eventName: "mousedown", action: (e: MouseEvent) => onMouseDown(e) },
|
||||
{ target: window, eventName: "mouseup", action: (e: MouseEvent) => editorRef && onPotentialDoubleClick(e, editorRef) },
|
||||
{ target: window, eventName: "wheel", action: (e: WheelEvent) => editorRef && onWheelScroll(e, editorRef), options: { passive: false } },
|
||||
{ target: window, eventName: "modifyinputfield", action: (e: CustomEvent) => onModifyInputField(e) },
|
||||
{ target: window, eventName: "focusout", action: () => onFocusOut() },
|
||||
{ target: window.document, eventName: "contextmenu", action: (e: MouseEvent) => onContextMenu(e) },
|
||||
{ target: window.document, eventName: "fullscreenchange", action: () => fullscreenModeChanged() },
|
||||
{ target: window.document.body, eventName: "paste", action: (e: ClipboardEvent) => editorRef && onPaste(e, editorRef) },
|
||||
{ target: window.document, eventName: "pointerlockchange", action: onPointerLockChange },
|
||||
{ target: window.document, eventName: "pointerlockerror", action: onPointerLockChange },
|
||||
];
|
||||
|
||||
let editorRef: Editor | undefined = undefined;
|
||||
let dialogStore: DialogStore | undefined = undefined;
|
||||
let portfolioStore: PortfolioStore | undefined = undefined;
|
||||
let documentStore: DocumentStore | undefined = undefined;
|
||||
|
||||
let viewportPointerInteractionOngoing = false;
|
||||
let textToolInteractiveInputElement: HTMLDivElement | undefined = undefined;
|
||||
let canvasFocused = true;
|
||||
let inPointerLock = false;
|
||||
let lastShakeTime = 0;
|
||||
const shakeSamples: { x: number; y: number; time: number }[] = [];
|
||||
|
||||
const listeners: { target: EventListenerTarget; eventName: EventName; action(event: Event): void; options?: AddEventListenerOptions }[] = [
|
||||
{ target: window, eventName: "beforeunload", action: (e: BeforeUnloadEvent) => onBeforeUnload(e) },
|
||||
{ target: window, eventName: "keyup", action: (e: KeyboardEvent) => onKeyUp(e) },
|
||||
{ target: window, eventName: "keydown", action: (e: KeyboardEvent) => onKeyDown(e) },
|
||||
{ target: window, eventName: "pointermove", action: (e: PointerEvent) => onPointerMove(e) },
|
||||
{ target: window, eventName: "pointerdown", action: (e: PointerEvent) => onPointerDown(e) },
|
||||
{ target: window, eventName: "pointerup", action: (e: PointerEvent) => onPointerUp(e) },
|
||||
{ target: window, eventName: "mousedown", action: (e: MouseEvent) => onMouseDown(e) },
|
||||
{ target: window, eventName: "mouseup", action: (e: MouseEvent) => onPotentialDoubleClick(e) },
|
||||
{ target: window, eventName: "wheel", action: (e: WheelEvent) => onWheelScroll(e), options: { passive: false } },
|
||||
{ target: window, eventName: "modifyinputfield", action: (e: CustomEvent) => onModifyInputField(e) },
|
||||
{ target: window, eventName: "focusout", action: () => (canvasFocused = false) },
|
||||
{ target: window.document, eventName: "contextmenu", action: (e: MouseEvent) => onContextMenu(e) },
|
||||
{ target: window.document, eventName: "fullscreenchange", action: () => fullscreenModeChanged() },
|
||||
{ target: window.document.body, eventName: "paste", action: (e: ClipboardEvent) => onPaste(e) },
|
||||
{ target: window.document, eventName: "pointerlockchange", action: onPointerLockChange },
|
||||
{ target: window.document, eventName: "pointerlockerror", action: onPointerLockChange },
|
||||
];
|
||||
|
||||
export function createInputManager(editor: Editor, dialog: DialogStore, portfolio: PortfolioStore, doc: DocumentStore) {
|
||||
destroyInputManager();
|
||||
|
||||
editorRef = editor;
|
||||
dialogStore = dialog;
|
||||
portfolioStore = portfolio;
|
||||
documentStore = doc;
|
||||
|
||||
editor.subscriptions.subscribeFrontendMessage("TriggerClipboardRead", () => {
|
||||
triggerClipboardRead();
|
||||
triggerClipboardRead(editor);
|
||||
});
|
||||
|
||||
editor.subscriptions.subscribeFrontendMessage("WindowPointerLockMove", (data) => {
|
||||
|
|
@ -75,14 +73,12 @@ export function createInputManager(editor: Editor, dialog: DialogStore, portfoli
|
|||
window.dispatchEvent(event);
|
||||
});
|
||||
|
||||
// INITIALIZATION
|
||||
// Add event bindings for the lifetime of the application
|
||||
listeners.forEach(({ target, eventName, action, options }) => target.addEventListener(eventName, action, options));
|
||||
|
||||
// Focus the app container
|
||||
const app = window.document.querySelector("[data-app-container]");
|
||||
if (app instanceof HTMLElement) app.focus();
|
||||
|
||||
// Bind the event listeners
|
||||
bindListeners();
|
||||
}
|
||||
|
||||
// Return the destructor
|
||||
|
|
@ -90,480 +86,14 @@ export function destroyInputManager() {
|
|||
const editor = editorRef;
|
||||
if (!editor) return;
|
||||
|
||||
unbindListeners();
|
||||
editor.subscriptions.unsubscribeFrontendMessage("TriggerClipboardRead");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("WindowPointerLockMove");
|
||||
}
|
||||
|
||||
async function triggerClipboardRead() {
|
||||
const editor = editorRef;
|
||||
if (!editor) return;
|
||||
|
||||
// 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 attempting 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 permission = await navigator.permissions?.query({ name: "clipboard-read" });
|
||||
if (permission?.state === "denied") throw new Error("Permission denied");
|
||||
|
||||
// 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
|
||||
const success = await Promise.any(
|
||||
Array.from(clipboardItems).map(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 = () => {
|
||||
if (typeof reader.result === "string") editor.handle.pasteText(reader.result);
|
||||
};
|
||||
reader.readAsText(blob);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Read an image from the clipboard and pass it to the editor to be loaded
|
||||
const imageType = item.types.find((type) => type.startsWith("image/"));
|
||||
|
||||
// Import the actual SVG content if it's an SVG
|
||||
if (imageType?.includes("svg")) {
|
||||
const blob = await item.getType("text/plain");
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result === "string") editor.handle.pasteSvg(undefined, reader.result);
|
||||
};
|
||||
reader.readAsText(blob);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Import the bitmap image if it's an image
|
||||
if (imageType) {
|
||||
const blob = await item.getType(imageType);
|
||||
const reader = new FileReader();
|
||||
reader.onload = async () => {
|
||||
if (reader.result instanceof ArrayBuffer) {
|
||||
const imageData = await extractPixelData(new Blob([reader.result], { type: imageType }));
|
||||
editor.handle.pasteImage(undefined, new Uint8Array(imageData.data), imageData.width, imageData.height);
|
||||
}
|
||||
};
|
||||
reader.readAsArrayBuffer(blob);
|
||||
return true;
|
||||
}
|
||||
|
||||
// The API limits what kinds of data we can access, so we can get copied images and our text encodings of copied nodes, but not files (like
|
||||
// .graphite or even image files). However, the user can paste those with Ctrl+V, which we recommend they in the error message that's shown to them.
|
||||
return false;
|
||||
}),
|
||||
);
|
||||
|
||||
if (!success) throw new Error("No valid clipboard data");
|
||||
} catch (err) {
|
||||
const unsupported = stripIndents`
|
||||
This browser does not support reading from the clipboard.
|
||||
Use the standard 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 bar) to allow this permission.
|
||||
`;
|
||||
const nothing = stripIndents`
|
||||
No valid clipboard data was found. You may have better
|
||||
success pasting with the standard keyboard shortcut instead.
|
||||
`;
|
||||
|
||||
const matchMessage = {
|
||||
"clipboard-read": unsupported,
|
||||
"Clipboard API unsupported": unsupported,
|
||||
"Permission denied": denied,
|
||||
"No valid clipboard data": nothing,
|
||||
};
|
||||
const message = Object.entries(matchMessage).find(([key]) => String(err).includes(key))?.[1] || String(err);
|
||||
|
||||
editor.handle.errorDialog("Cannot access clipboard", message);
|
||||
}
|
||||
}
|
||||
|
||||
// Event bindings
|
||||
|
||||
function bindListeners() {
|
||||
// Add event bindings for the lifetime of the application
|
||||
listeners.forEach(({ target, eventName, action, options }) => target.addEventListener(eventName, action, options));
|
||||
}
|
||||
|
||||
function unbindListeners() {
|
||||
// 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
|
||||
|
||||
async function shouldRedirectKeyboardEventToBackend(e: KeyboardEvent): Promise<boolean> {
|
||||
if (!dialogStore) return false;
|
||||
|
||||
// Don't redirect when a dialog is covering the workspace
|
||||
if (get(dialogStore).visible) return false;
|
||||
|
||||
const key = await getLocalizedScanCode(e);
|
||||
|
||||
// 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 (isPlatformNative() && accelKey && ["KeyX", "KeyC", "KeyV"].includes(key)) return true;
|
||||
// But on web, we want to not redirect paste
|
||||
if (!isPlatformNative() && key === "KeyV" && accelKey) return false;
|
||||
|
||||
// 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 tab or enter if not in canvas (to allow navigating elements)
|
||||
potentiallyRestoreCanvasFocus(e);
|
||||
if (
|
||||
!canvasFocused &&
|
||||
!targetIsTextField(e.target || undefined) &&
|
||||
["Tab", "Enter", "NumpadEnter", "Space", "ArrowDown", "ArrowLeft", "ArrowRight", "ArrowUp"].includes(key) &&
|
||||
!(e.ctrlKey || e.metaKey || e.altKey)
|
||||
)
|
||||
return false;
|
||||
|
||||
// Don't redirect if a MenuList is open
|
||||
if (window.document.querySelector("[data-floating-menu-content]")) return false;
|
||||
|
||||
// Web-only keyboard shortcuts
|
||||
if (!isPlatformNative()) {
|
||||
// Don't redirect a fullscreen request, but process it immediately instead
|
||||
if (((operatingSystem() !== "Mac" && key === "F11") || (operatingSystem() === "Mac" && e.ctrlKey && e.metaKey && key === "KeyF")) && e.type === "keydown" && !e.repeat) {
|
||||
e.preventDefault();
|
||||
toggleFullscreen();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Don't redirect a reload request
|
||||
if (key === "F5") return false;
|
||||
if (key === "KeyR" && accelKey) return false;
|
||||
|
||||
// Don't redirect debugging tools
|
||||
if (["F12", "F8"].includes(key)) return false;
|
||||
if (["KeyC", "KeyI", "KeyJ"].includes(key) && accelKey && e.shiftKey) return false;
|
||||
}
|
||||
|
||||
// Redirect to the backend
|
||||
return true;
|
||||
}
|
||||
|
||||
async function onKeyDown(e: KeyboardEvent) {
|
||||
const editor = editorRef;
|
||||
if (!editor || !dialogStore) return;
|
||||
|
||||
const key = await getLocalizedScanCode(e);
|
||||
|
||||
const NO_KEY_REPEAT_MODIFIER_KEYS = ["ControlLeft", "ControlRight", "ShiftLeft", "ShiftRight", "MetaLeft", "MetaRight", "AltLeft", "AltRight", "AltGraph", "CapsLock", "Fn", "FnLock"];
|
||||
if (e.repeat && NO_KEY_REPEAT_MODIFIER_KEYS.includes(key)) return;
|
||||
|
||||
if (await shouldRedirectKeyboardEventToBackend(e)) {
|
||||
e.preventDefault();
|
||||
const modifiers = makeKeyboardModifiersBitfield(e);
|
||||
editor.handle.onKeyDown(key, modifiers, e.repeat);
|
||||
return;
|
||||
}
|
||||
|
||||
if (get(dialogStore).visible && key === "Escape") {
|
||||
editor.handle.onDialogDismiss();
|
||||
}
|
||||
}
|
||||
|
||||
async function onKeyUp(e: KeyboardEvent) {
|
||||
const editor = editorRef;
|
||||
if (!editor) return;
|
||||
|
||||
const key = await getLocalizedScanCode(e);
|
||||
|
||||
if (await shouldRedirectKeyboardEventToBackend(e)) {
|
||||
e.preventDefault();
|
||||
const modifiers = makeKeyboardModifiersBitfield(e);
|
||||
editor.handle.onKeyUp(key, modifiers, e.repeat);
|
||||
}
|
||||
}
|
||||
|
||||
// Pointer events
|
||||
|
||||
// While any pointer button is already down, additional button down events are not reported, but they are sent as `pointermove` events and these are handled in the backend
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
const editor = editorRef;
|
||||
if (!editor || !documentStore) return;
|
||||
|
||||
potentiallyRestoreCanvasFocus(e);
|
||||
|
||||
if (!e.buttons) viewportPointerInteractionOngoing = false;
|
||||
|
||||
// Don't redirect pointer movement to the backend if there's no ongoing interaction and it's over a floating menu, or the graph overlay, on top of the canvas
|
||||
// TODO: A better approach is to pass along a boolean to the backend's input preprocessor so it can know if it's being occluded by the GUI.
|
||||
// TODO: This would allow it to properly decide to act on removing hover focus from something that was hovered in the canvas before moving over the GUI.
|
||||
// TODO: Further explanation: https://github.com/GraphiteEditor/Graphite/pull/623#discussion_r866436197
|
||||
const inFloatingMenu = e.target instanceof Element && e.target.closest("[data-floating-menu-content]");
|
||||
const inGraphOverlay = get(documentStore).graphViewOverlayOpen;
|
||||
if (!viewportPointerInteractionOngoing && (inFloatingMenu || inGraphOverlay)) return;
|
||||
|
||||
const modifiers = makeKeyboardModifiersBitfield(e);
|
||||
if (detectShake(e)) editor.handle.onMouseShake(e.clientX, e.clientY, e.buttons, modifiers);
|
||||
editor.handle.onMouseMove(e.clientX, e.clientY, e.buttons, modifiers);
|
||||
}
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
const editor = editorRef;
|
||||
if (!editor || !dialogStore) return;
|
||||
|
||||
potentiallyRestoreCanvasFocus(e);
|
||||
|
||||
const inFloatingMenu = e.target instanceof Element && e.target.closest("[data-floating-menu-content]");
|
||||
const isTargetingCanvas = !inFloatingMenu && e.target instanceof Element && e.target.closest("[data-viewport], [data-viewport-container], [data-node-graph]");
|
||||
const inDialog = e.target instanceof Element && e.target.closest("[data-dialog] [data-floating-menu-content]");
|
||||
const inContextMenu = e.target instanceof Element && e.target.closest("[data-context-menu]");
|
||||
const inTextInput = e.target === textToolInteractiveInputElement;
|
||||
|
||||
if (get(dialogStore).visible && !inDialog) {
|
||||
editor.handle.onDialogDismiss();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
if (!inTextInput && !inContextMenu) {
|
||||
if (textToolInteractiveInputElement) {
|
||||
const isLeftOrRightClick = e.button === BUTTON_RIGHT || e.button === BUTTON_LEFT;
|
||||
editor.handle.onChangeText(textInputCleanup(textToolInteractiveInputElement.innerText), isLeftOrRightClick);
|
||||
} else {
|
||||
viewportPointerInteractionOngoing = isTargetingCanvas instanceof Element;
|
||||
}
|
||||
}
|
||||
|
||||
if (viewportPointerInteractionOngoing && isTargetingCanvas instanceof Element) {
|
||||
const modifiers = makeKeyboardModifiersBitfield(e);
|
||||
editor.handle.onMouseDown(e.clientX, e.clientY, e.buttons, modifiers);
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerUp(e: PointerEvent) {
|
||||
const editor = editorRef;
|
||||
if (!editor) return;
|
||||
|
||||
potentiallyRestoreCanvasFocus(e);
|
||||
|
||||
// Don't let the browser navigate back or forward when using the buttons on some mice
|
||||
// TODO: This works in Chrome but not in Firefox
|
||||
// TODO: Possible workaround: use the browser's history API to block navigation:
|
||||
// TODO: <https://stackoverflow.com/questions/57102502/preventing-mouse-fourth-and-fifth-buttons-from-navigating-back-forward-in-browse>
|
||||
if (e.button === BUTTON_BACK || e.button === BUTTON_FORWARD) e.preventDefault();
|
||||
|
||||
if (!e.buttons) viewportPointerInteractionOngoing = false;
|
||||
|
||||
if (textToolInteractiveInputElement) return;
|
||||
|
||||
const modifiers = makeKeyboardModifiersBitfield(e);
|
||||
editor.handle.onMouseUp(e.clientX, e.clientY, e.buttons, modifiers);
|
||||
}
|
||||
|
||||
// Mouse events
|
||||
|
||||
function onPotentialDoubleClick(e: MouseEvent) {
|
||||
const editor = editorRef;
|
||||
if (!editor) return;
|
||||
|
||||
if (textToolInteractiveInputElement || inPointerLock) return;
|
||||
|
||||
// Allow only events within the viewport or node graph boundaries
|
||||
const isTargetingCanvas = e.target instanceof Element && e.target.closest("[data-viewport], [data-viewport-container], [data-node-graph]");
|
||||
if (!(isTargetingCanvas instanceof Element)) return;
|
||||
|
||||
// Allow only repeated increments of double-clicks (not 1, 3, 5, etc.)
|
||||
if (e.detail % 2 == 1) return;
|
||||
|
||||
// `e.buttons` is always 0 in the `mouseup` event, so we have to convert from `e.button` instead
|
||||
let buttons = 1;
|
||||
if (e.button === BUTTON_LEFT) buttons = 1; // Left
|
||||
if (e.button === BUTTON_RIGHT) buttons = 2; // Right
|
||||
if (e.button === BUTTON_MIDDLE) buttons = 4; // Middle
|
||||
if (e.button === BUTTON_BACK) buttons = 8; // Back
|
||||
if (e.button === BUTTON_FORWARD) buttons = 16; // Forward
|
||||
|
||||
const modifiers = makeKeyboardModifiersBitfield(e);
|
||||
editor.handle.onDoubleClick(e.clientX, e.clientY, buttons, modifiers);
|
||||
}
|
||||
|
||||
function onMouseDown(e: MouseEvent) {
|
||||
// Block middle mouse button auto-scroll mode (the circular gizmo that appears and allows quick scrolling by moving the cursor above or below it)
|
||||
if (e.button === BUTTON_MIDDLE) e.preventDefault();
|
||||
}
|
||||
|
||||
function onContextMenu(e: MouseEvent) {
|
||||
if (!targetIsTextField(e.target || undefined) && e.target !== textToolInteractiveInputElement) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerLockChange() {
|
||||
inPointerLock = Boolean(window.document.pointerLockElement);
|
||||
}
|
||||
|
||||
// Wheel events
|
||||
|
||||
function onWheelScroll(e: WheelEvent) {
|
||||
const editor = editorRef;
|
||||
if (!editor) return;
|
||||
|
||||
const isTargetingCanvas = e.target instanceof Element && e.target.closest("[data-viewport], [data-viewport-container], [data-node-graph]");
|
||||
|
||||
// Prevent zooming the entire page when using Ctrl + scroll wheel outside of the viewport
|
||||
if (e.ctrlKey && !isTargetingCanvas) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
// Redirect vertical scroll wheel movement into a horizontal scroll on a horizontally scrollable element
|
||||
// There seems to be no possible way to properly employ the browser's smooth scrolling interpolation
|
||||
const horizontalScrollableElement = e.target instanceof Element && e.target.closest("[data-scrollable-x]");
|
||||
if (horizontalScrollableElement && e.deltaY !== 0) {
|
||||
horizontalScrollableElement.scrollTo(horizontalScrollableElement.scrollLeft + e.deltaY, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTargetingCanvas) {
|
||||
e.preventDefault();
|
||||
const modifiers = makeKeyboardModifiersBitfield(e);
|
||||
editor.handle.onWheelScroll(e.clientX, e.clientY, e.buttons, e.deltaX, e.deltaY, e.deltaZ, modifiers);
|
||||
}
|
||||
}
|
||||
|
||||
// Receives a custom event dispatched when the user begins interactively editing with the text tool.
|
||||
// We keep a copy of the text input element to check against when it's active for text entry.
|
||||
function onModifyInputField(e: CustomEvent) {
|
||||
textToolInteractiveInputElement = e.detail;
|
||||
}
|
||||
|
||||
// Window events
|
||||
|
||||
async function onBeforeUnload(e: BeforeUnloadEvent) {
|
||||
const editor = editorRef;
|
||||
if (!editor || !portfolioStore) return;
|
||||
|
||||
const activeDocument = get(portfolioStore).documents[get(portfolioStore).activeDocumentIndex];
|
||||
if (activeDocument && !activeDocument.details.isAutoSaved) editor.handle.triggerAutoSave(activeDocument.id);
|
||||
|
||||
// Skip the message if the editor crashed, since work is already lost
|
||||
if (await editor.handle.hasCrashed()) return;
|
||||
|
||||
// Skip the message during development, since it's annoying when testing
|
||||
if (await editor.handle.inDevelopmentMode()) return;
|
||||
|
||||
const allDocumentsSaved = get(portfolioStore).documents.reduce((acc, doc) => acc && doc.details.isSaved, true);
|
||||
if (!allDocumentsSaved) {
|
||||
e.returnValue = "Unsaved work will be lost if the web browser tab is closed. Close anyway?";
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
function onPaste(e: ClipboardEvent) {
|
||||
const editor = editorRef;
|
||||
if (!editor) return;
|
||||
|
||||
const dataTransfer = e.clipboardData;
|
||||
if (!dataTransfer || targetIsTextField(e.target || undefined)) return;
|
||||
e.preventDefault();
|
||||
|
||||
Array.from(dataTransfer.items).forEach(async (item) => {
|
||||
if (item.type === "text/plain") item.getAsString((text) => editor.handle.pasteText(text));
|
||||
await pasteFile(item, editor);
|
||||
});
|
||||
}
|
||||
|
||||
function detectShake(e: PointerEvent | MouseEvent): boolean {
|
||||
const SENSITIVITY_DIRECTION_CHANGES = 3;
|
||||
const SENSITIVITY_DISTANCE_TO_DISPLACEMENT_RATIO = 0.1;
|
||||
const DETECTION_WINDOW_MS = 500;
|
||||
const DEBOUNCE_MS = 1000;
|
||||
|
||||
// Add the current mouse position and time to our list of samples
|
||||
const now = Date.now();
|
||||
shakeSamples.push({ x: e.clientX, y: e.clientY, time: now });
|
||||
|
||||
// Remove samples that are older than our time window
|
||||
while (shakeSamples.length > 0 && now - shakeSamples[0].time > DETECTION_WINDOW_MS) {
|
||||
shakeSamples.shift();
|
||||
}
|
||||
|
||||
// We can't be shaking if it's too early in terms of samples or debounce time
|
||||
if (shakeSamples.length <= 3 || now - lastShakeTime <= DEBOUNCE_MS) return false;
|
||||
|
||||
// Calculate the total distance traveled
|
||||
let totalDistanceSquared = 0;
|
||||
for (let i = 1; i < shakeSamples.length; i += 1) {
|
||||
const p1 = shakeSamples[i - 1];
|
||||
const p2 = shakeSamples[i];
|
||||
totalDistanceSquared += (p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2;
|
||||
}
|
||||
|
||||
// Count the number of times the mouse changes direction significantly, and the average position of the mouse
|
||||
let directionChanges = 0;
|
||||
const averagePoint = { x: 0, y: 0 };
|
||||
let averagePointCount = 0;
|
||||
for (let i = 0; i < shakeSamples.length - 2; i += 1) {
|
||||
const p1 = shakeSamples[i];
|
||||
const p2 = shakeSamples[i + 1];
|
||||
const p3 = shakeSamples[i + 2];
|
||||
|
||||
const vector1 = { x: p2.x - p1.x, y: p2.y - p1.y };
|
||||
const vector2 = { x: p3.x - p2.x, y: p3.y - p2.y };
|
||||
|
||||
// Check if the dot product is negative, which indicates the angle between vectors is > 90 degrees
|
||||
if (vector1.x * vector2.x + vector1.y * vector2.y < 0) directionChanges += 1;
|
||||
|
||||
averagePoint.x += p2.x;
|
||||
averagePoint.y += p2.y;
|
||||
averagePointCount += 1;
|
||||
}
|
||||
if (averagePointCount > 0) {
|
||||
averagePoint.x /= averagePointCount;
|
||||
averagePoint.y /= averagePointCount;
|
||||
}
|
||||
|
||||
// Calculate the displacement (the distance between the first and last mouse positions)
|
||||
const lastPoint = shakeSamples[shakeSamples.length - 1];
|
||||
const displacementSquared = (lastPoint.x - averagePoint.x) ** 2 + (lastPoint.y - averagePoint.y) ** 2;
|
||||
|
||||
// A shake is detected if the mouse has traveled a lot but not moved far, and has changed direction enough times
|
||||
if (SENSITIVITY_DISTANCE_TO_DISPLACEMENT_RATIO * totalDistanceSquared >= displacementSquared && directionChanges >= SENSITIVITY_DIRECTION_CHANGES) {
|
||||
lastShakeTime = now;
|
||||
shakeSamples.length = 0;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function targetIsTextField(target: EventTarget | HTMLElement | undefined): boolean {
|
||||
return target instanceof HTMLElement && (target.nodeName === "INPUT" || target.nodeName === "TEXTAREA" || target.isContentEditable);
|
||||
}
|
||||
|
||||
function potentiallyRestoreCanvasFocus(e: Event) {
|
||||
const appElement = window.document.querySelector("[data-app-container]");
|
||||
const app = appElement instanceof HTMLElement ? appElement : null;
|
||||
|
||||
const newInCanvasArea =
|
||||
(e.target instanceof Element && e.target.closest("[data-viewport], [data-viewport-container], [data-graph]")) instanceof Element &&
|
||||
!targetIsTextField(window.document.activeElement || undefined);
|
||||
if (!canvasFocused && newInCanvasArea) {
|
||||
canvasFocused = true;
|
||||
app?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// Self-accepting HMR: tear down the old instance and re-create with the new module's code
|
||||
import.meta.hot?.accept((newModule) => {
|
||||
destroyInputManager();
|
||||
if (editorRef && dialogStore && portfolioStore && documentStore) newModule?.createInputManager(editorRef, dialogStore, portfolioStore, documentStore);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import type { Editor } from "@graphite/editor";
|
||||
import { localizeTimestamp } from "@graphite/utility-functions/time";
|
||||
|
||||
let editorRef: Editor | undefined = undefined;
|
||||
|
||||
export function createLocalizationManager(editor: Editor) {
|
||||
destroyLocalizationManager();
|
||||
|
||||
editorRef = editor;
|
||||
|
||||
editor.subscriptions.subscribeFrontendMessage("TriggerAboutGraphiteLocalizedCommitDate", (data) => {
|
||||
|
|
@ -18,23 +21,7 @@ export function destroyLocalizationManager() {
|
|||
editor.subscriptions.unsubscribeFrontendMessage("TriggerAboutGraphiteLocalizedCommitDate");
|
||||
}
|
||||
|
||||
function localizeTimestamp(utc: string): { timestamp: string; year: string } {
|
||||
// Timestamp
|
||||
const date = new Date(utc);
|
||||
if (Number.isNaN(date.getTime())) return { timestamp: utc, year: `${new Date().getFullYear()}` };
|
||||
|
||||
const timezoneName = Intl.DateTimeFormat(undefined, { timeZoneName: "longGeneric" })
|
||||
.formatToParts(new Date())
|
||||
.find((part) => part.type === "timeZoneName");
|
||||
|
||||
const dateString = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||||
const timeString = `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
|
||||
const timezoneNameString = timezoneName?.value;
|
||||
return { timestamp: `${dateString} ${timeString} ${timezoneNameString}`, year: String(date.getFullYear()) };
|
||||
}
|
||||
|
||||
// Self-accepting HMR: tear down the old instance and re-create with the new module's code
|
||||
import.meta.hot?.accept((newModule) => {
|
||||
destroyLocalizationManager();
|
||||
if (editorRef) newModule?.createLocalizationManager(editorRef);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { createCrashDialog } from "@graphite/stores/dialog";
|
|||
let editorRef: Editor | undefined = undefined;
|
||||
|
||||
export function createPanicManager(editor: Editor) {
|
||||
destroyPanicManager();
|
||||
|
||||
editorRef = editor;
|
||||
|
||||
editor.subscriptions.subscribeFrontendMessage("DisplayDialogPanic", (data) => {
|
||||
|
|
@ -31,6 +33,5 @@ export function destroyPanicManager() {
|
|||
|
||||
// Self-accepting HMR: tear down the old instance and re-create with the new module's code
|
||||
import.meta.hot?.accept((newModule) => {
|
||||
destroyPanicManager();
|
||||
if (editorRef) newModule?.createPanicManager(editorRef);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,14 +1,13 @@
|
|||
import * as idb from "idb-keyval";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
import type { Editor } from "@graphite/editor";
|
||||
import type { PortfolioStore } from "@graphite/stores/portfolio";
|
||||
import type { MessageBody } from "@graphite/subscription-router";
|
||||
import { saveEditorPreferences, loadEditorPreferences, storeDocument, removeDocument, loadFirstDocument, loadRestDocuments, saveActiveDocument } from "@graphite/utility-functions/persistence";
|
||||
|
||||
let editorRef: Editor | undefined = undefined;
|
||||
let portfolioStore: PortfolioStore | undefined = undefined;
|
||||
|
||||
export function createPersistenceManager(editor: Editor, portfolio: PortfolioStore) {
|
||||
destroyPersistenceManager();
|
||||
|
||||
editorRef = editor;
|
||||
portfolioStore = portfolio;
|
||||
|
||||
|
|
@ -41,24 +40,7 @@ export function createPersistenceManager(editor: Editor, portfolio: PortfolioSto
|
|||
});
|
||||
|
||||
editor.subscriptions.subscribeFrontendMessage("TriggerSaveActiveDocument", async (data) => {
|
||||
const indexedDbStorage = idb.createStore("graphite", "store");
|
||||
|
||||
const previouslySavedDocuments = await idb.get<Record<string, MessageBody<"TriggerPersistenceWriteDocument">>>("documents", indexedDbStorage);
|
||||
|
||||
const documentId = String(data.documentId);
|
||||
|
||||
// TODO: Eventually remove this document upgrade code
|
||||
// Migrate TriggerPersistenceWriteDocument.documentId from string to bigint if needed
|
||||
if (previouslySavedDocuments) {
|
||||
Object.values(previouslySavedDocuments).forEach((doc) => {
|
||||
if (typeof doc.documentId === "string") doc.documentId = BigInt(doc.documentId);
|
||||
});
|
||||
}
|
||||
|
||||
if (!previouslySavedDocuments) return;
|
||||
if (documentId in previouslySavedDocuments) {
|
||||
await storeCurrentDocumentId(documentId);
|
||||
}
|
||||
await saveActiveDocument(data.documentId);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -76,174 +58,7 @@ export function destroyPersistenceManager() {
|
|||
editor.subscriptions.unsubscribeFrontendMessage("TriggerSaveActiveDocument");
|
||||
}
|
||||
|
||||
export async function storeCurrentDocumentId(documentId: string) {
|
||||
const indexedDbStorage = idb.createStore("graphite", "store");
|
||||
|
||||
await idb.set("current_document_id", String(documentId), indexedDbStorage);
|
||||
}
|
||||
|
||||
export async function storeDocument(autoSaveDocument: MessageBody<"TriggerPersistenceWriteDocument">, portfolio: PortfolioStore) {
|
||||
const indexedDbStorage = idb.createStore("graphite", "store");
|
||||
|
||||
await idb.update<Record<string, MessageBody<"TriggerPersistenceWriteDocument">>>(
|
||||
"documents",
|
||||
(old) => {
|
||||
const documents = old || {};
|
||||
documents[String(autoSaveDocument.documentId)] = autoSaveDocument;
|
||||
return documents;
|
||||
},
|
||||
indexedDbStorage,
|
||||
);
|
||||
|
||||
const documentOrder = get(portfolio).documents.map((doc) => String(doc.id));
|
||||
await idb.set("documents_tab_order", documentOrder, indexedDbStorage);
|
||||
await storeCurrentDocumentId(String(autoSaveDocument.documentId));
|
||||
}
|
||||
|
||||
export async function removeDocument(id: string, portfolio: PortfolioStore) {
|
||||
const indexedDbStorage = idb.createStore("graphite", "store");
|
||||
|
||||
await idb.update<Record<string, MessageBody<"TriggerPersistenceWriteDocument">>>(
|
||||
"documents",
|
||||
(old) => {
|
||||
const documents = old || {};
|
||||
delete documents[id];
|
||||
return documents;
|
||||
},
|
||||
indexedDbStorage,
|
||||
);
|
||||
|
||||
await idb.update<string[]>(
|
||||
"documents_tab_order",
|
||||
(old) => {
|
||||
const order = old || [];
|
||||
return order.filter((docId) => docId !== id);
|
||||
},
|
||||
indexedDbStorage,
|
||||
);
|
||||
|
||||
const documentCount = get(portfolio).documents.length;
|
||||
if (documentCount > 0) {
|
||||
const documentIndex = get(portfolio).activeDocumentIndex;
|
||||
const documentId = String(get(portfolio).documents[documentIndex].id);
|
||||
|
||||
const tabOrder = (await idb.get<string[]>("documents_tab_order", indexedDbStorage)) || [];
|
||||
if (tabOrder.includes(documentId)) {
|
||||
await storeCurrentDocumentId(documentId);
|
||||
}
|
||||
} else {
|
||||
await idb.del("current_document_id", indexedDbStorage);
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadFirstDocument(editor: Editor) {
|
||||
const indexedDbStorage = idb.createStore("graphite", "store");
|
||||
|
||||
const previouslySavedDocuments = await idb.get<Record<string, MessageBody<"TriggerPersistenceWriteDocument">>>("documents", indexedDbStorage);
|
||||
|
||||
// TODO: Eventually remove this document upgrade code
|
||||
// Migrate TriggerPersistenceWriteDocument.documentId from string to bigint if the browser is storing the old format as strings
|
||||
if (previouslySavedDocuments) {
|
||||
Object.values(previouslySavedDocuments).forEach((doc) => {
|
||||
if (typeof doc.documentId === "string") doc.documentId = BigInt(doc.documentId);
|
||||
});
|
||||
}
|
||||
|
||||
const documentOrder = await idb.get<string[]>("documents_tab_order", indexedDbStorage);
|
||||
const currentDocumentIdString = await idb.get<string>("current_document_id", indexedDbStorage);
|
||||
const currentDocumentId = currentDocumentIdString ? BigInt(currentDocumentIdString) : undefined;
|
||||
if (!previouslySavedDocuments || !documentOrder) return;
|
||||
|
||||
const orderedSavedDocuments = documentOrder.flatMap((id) => (previouslySavedDocuments[id] ? [previouslySavedDocuments[id]] : []));
|
||||
|
||||
if (currentDocumentId !== undefined && String(currentDocumentId) in previouslySavedDocuments) {
|
||||
const doc = previouslySavedDocuments[String(currentDocumentId)];
|
||||
editor.handle.openAutoSavedDocument(doc.documentId, doc.details.name, doc.details.isSaved, doc.document, false);
|
||||
editor.handle.selectDocument(currentDocumentId);
|
||||
} else {
|
||||
const len = orderedSavedDocuments.length;
|
||||
if (len > 0) {
|
||||
const doc = orderedSavedDocuments[len - 1];
|
||||
editor.handle.openAutoSavedDocument(doc.documentId, doc.details.name, doc.details.isSaved, doc.document, false);
|
||||
editor.handle.selectDocument(doc.documentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadRestDocuments(editor: Editor) {
|
||||
const indexedDbStorage = idb.createStore("graphite", "store");
|
||||
|
||||
const previouslySavedDocuments = await idb.get<Record<string, MessageBody<"TriggerPersistenceWriteDocument">>>("documents", indexedDbStorage);
|
||||
|
||||
// TODO: Eventually remove this document upgrade code
|
||||
// Migrate TriggerPersistenceWriteDocument.documentId from string to bigint if needed
|
||||
if (previouslySavedDocuments) {
|
||||
Object.values(previouslySavedDocuments).forEach((doc) => {
|
||||
if (typeof doc.documentId === "string") doc.documentId = BigInt(doc.documentId);
|
||||
});
|
||||
}
|
||||
|
||||
const documentOrder = await idb.get<string[]>("documents_tab_order", indexedDbStorage);
|
||||
const currentDocumentIdString = await idb.get<string>("current_document_id", indexedDbStorage);
|
||||
const currentDocumentId = currentDocumentIdString ? BigInt(currentDocumentIdString) : undefined;
|
||||
if (!previouslySavedDocuments || !documentOrder) return;
|
||||
|
||||
const orderedSavedDocuments = documentOrder.flatMap((id) => (previouslySavedDocuments[id] ? [previouslySavedDocuments[id]] : []));
|
||||
|
||||
const currentIndex = currentDocumentId !== undefined ? orderedSavedDocuments.findIndex((doc) => doc.documentId === currentDocumentId) : -1;
|
||||
|
||||
// Open documents in order around the current document, placing earlier ones before it and later ones after
|
||||
if (currentIndex !== -1 && currentDocumentId !== undefined) {
|
||||
for (let i = currentIndex - 1; i >= 0; i--) {
|
||||
const { documentId, document, details } = orderedSavedDocuments[i];
|
||||
const { name, isSaved } = details;
|
||||
editor.handle.openAutoSavedDocument(documentId, name, isSaved, document, true);
|
||||
}
|
||||
for (let i = currentIndex + 1; i < orderedSavedDocuments.length; i++) {
|
||||
const { documentId, document, details } = orderedSavedDocuments[i];
|
||||
const { name, isSaved } = details;
|
||||
editor.handle.openAutoSavedDocument(documentId, name, isSaved, document, false);
|
||||
}
|
||||
|
||||
editor.handle.selectDocument(currentDocumentId);
|
||||
}
|
||||
// No valid current document: open all remaining documents and select the last one
|
||||
else {
|
||||
const length = orderedSavedDocuments.length;
|
||||
|
||||
for (let i = length - 2; i >= 0; i--) {
|
||||
const { documentId, document, details } = orderedSavedDocuments[i];
|
||||
const { name, isSaved } = details;
|
||||
editor.handle.openAutoSavedDocument(documentId, name, isSaved, document, true);
|
||||
}
|
||||
|
||||
if (length > 0) editor.handle.selectDocument(orderedSavedDocuments[length - 1].documentId);
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveEditorPreferences(preferences: unknown) {
|
||||
const indexedDbStorage = idb.createStore("graphite", "store");
|
||||
|
||||
await idb.set("preferences", preferences, indexedDbStorage);
|
||||
}
|
||||
|
||||
export async function loadEditorPreferences(editor: Editor) {
|
||||
const indexedDbStorage = idb.createStore("graphite", "store");
|
||||
|
||||
const preferences = await idb.get<Record<string, unknown>>("preferences", indexedDbStorage);
|
||||
editor.handle.loadPreferences(preferences ? JSON.stringify(preferences) : undefined);
|
||||
}
|
||||
|
||||
export async function wipeDocuments() {
|
||||
const indexedDbStorage = idb.createStore("graphite", "store");
|
||||
|
||||
await idb.del("documents_tab_order", indexedDbStorage);
|
||||
await idb.del("current_document_id", indexedDbStorage);
|
||||
await idb.del("documents", indexedDbStorage);
|
||||
}
|
||||
|
||||
// Self-accepting HMR: tear down the old instance and re-create with the new module's code
|
||||
import.meta.hot?.accept((newModule) => {
|
||||
destroyPersistenceManager();
|
||||
if (editorRef && portfolioStore) newModule?.createPersistenceManager(editorRef, portfolioStore);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ if (import.meta.hot) import.meta.hot.data.store = store;
|
|||
const { subscribe, update } = store;
|
||||
|
||||
export function createAppWindowStore(editor: Editor) {
|
||||
destroyAppWindowStore();
|
||||
|
||||
editorRef = editor;
|
||||
|
||||
editor.subscriptions.subscribeFrontendMessage("UpdatePlatform", (data) => {
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ if (import.meta.hot) import.meta.hot.data.store = store;
|
|||
const { subscribe, update } = store;
|
||||
|
||||
export function createDialogStore(editor: Editor) {
|
||||
destroyDialogStore();
|
||||
|
||||
editorRef = editor;
|
||||
|
||||
editor.subscriptions.subscribeFrontendMessage("DisplayDialog", (data) => {
|
||||
|
|
@ -49,6 +51,7 @@ export function createDialogStore(editor: Editor) {
|
|||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
editor.subscriptions.subscribeLayoutUpdate("DialogButtons", async (data) => {
|
||||
await tick();
|
||||
|
||||
|
|
@ -58,6 +61,7 @@ export function createDialogStore(editor: Editor) {
|
|||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
editor.subscriptions.subscribeLayoutUpdate("DialogColumn1", async (data) => {
|
||||
await tick();
|
||||
|
||||
|
|
@ -67,6 +71,7 @@ export function createDialogStore(editor: Editor) {
|
|||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
editor.subscriptions.subscribeLayoutUpdate("DialogColumn2", async (data) => {
|
||||
await tick();
|
||||
|
||||
|
|
|
|||
|
|
@ -35,15 +35,17 @@ if (import.meta.hot) import.meta.hot.data.store = store;
|
|||
const { subscribe, update } = store;
|
||||
|
||||
export function createDocumentStore(editor: Editor) {
|
||||
destroyDocumentStore();
|
||||
|
||||
editorRef = editor;
|
||||
|
||||
// Update layouts
|
||||
editor.subscriptions.subscribeFrontendMessage("UpdateGraphFadeArtwork", (data) => {
|
||||
update((state) => {
|
||||
state.fadeArtwork = data.percentage;
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
editor.subscriptions.subscribeLayoutUpdate("ToolOptions", async (data) => {
|
||||
await tick();
|
||||
|
||||
|
|
@ -52,6 +54,7 @@ export function createDocumentStore(editor: Editor) {
|
|||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
editor.subscriptions.subscribeLayoutUpdate("DocumentBar", async (data) => {
|
||||
await tick();
|
||||
|
||||
|
|
@ -60,6 +63,7 @@ export function createDocumentStore(editor: Editor) {
|
|||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
editor.subscriptions.subscribeLayoutUpdate("ToolShelf", async (data) => {
|
||||
await tick();
|
||||
|
||||
|
|
@ -68,6 +72,7 @@ export function createDocumentStore(editor: Editor) {
|
|||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
editor.subscriptions.subscribeLayoutUpdate("WorkingColors", async (data) => {
|
||||
await tick();
|
||||
|
||||
|
|
@ -76,6 +81,7 @@ export function createDocumentStore(editor: Editor) {
|
|||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
editor.subscriptions.subscribeLayoutUpdate("NodeGraphControlBar", async (data) => {
|
||||
await tick();
|
||||
|
||||
|
|
@ -85,7 +91,6 @@ export function createDocumentStore(editor: Editor) {
|
|||
});
|
||||
});
|
||||
|
||||
// Show or hide the graph view overlay
|
||||
editor.subscriptions.subscribeFrontendMessage("UpdateGraphViewOverlay", (data) => {
|
||||
update((state) => {
|
||||
state.graphViewOverlayOpen = data.open;
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ if (import.meta.hot) import.meta.hot.data.store = store;
|
|||
const { subscribe, update } = store;
|
||||
|
||||
export function createFullscreenStore(editor: Editor) {
|
||||
destroyFullscreenStore();
|
||||
|
||||
editorRef = editor;
|
||||
|
||||
editor.subscriptions.subscribeFrontendMessage("WindowFullscreen", () => {
|
||||
|
|
|
|||
|
|
@ -61,9 +61,10 @@ if (import.meta.hot) import.meta.hot.data.store = store;
|
|||
const { subscribe, update } = store;
|
||||
|
||||
export function createNodeGraphStore(editor: Editor) {
|
||||
destroyNodeGraphStore();
|
||||
|
||||
editorRef = editor;
|
||||
|
||||
// Set up message subscriptions on creation
|
||||
editor.subscriptions.subscribeFrontendMessage("SendUIMetadata", (data) => {
|
||||
update((state) => {
|
||||
state.nodeDescriptions = new Map(data.nodeDescriptions);
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ if (import.meta.hot) import.meta.hot.data.store = store;
|
|||
const { subscribe, update } = store;
|
||||
|
||||
export function createPortfolioStore(editor: Editor) {
|
||||
destroyPortfolioStore();
|
||||
|
||||
editorRef = editor;
|
||||
|
||||
editor.subscriptions.subscribeFrontendMessage("UpdateOpenDocumentsList", (data) => {
|
||||
|
|
|
|||
|
|
@ -9,8 +9,6 @@ export type TooltipStore = ReturnType<typeof createTooltipStore>;
|
|||
|
||||
const SHOW_TOOLTIP_DELAY_MS = 500;
|
||||
|
||||
let tooltipTimeout: ReturnType<typeof setTimeout> | undefined = undefined;
|
||||
|
||||
type TooltipStoreState = {
|
||||
visible: boolean;
|
||||
element: Element | undefined;
|
||||
|
|
@ -28,7 +26,18 @@ const initialState: TooltipStoreState = {
|
|||
fullscreenShortcut: undefined,
|
||||
};
|
||||
|
||||
type Listener = { eventName: keyof DocumentEventMap; action(event: Event): void };
|
||||
const tooltipEventListeners: Listener[] = [
|
||||
{ eventName: "mouseover", action: onMouseOver },
|
||||
{ eventName: "mousemove", action: onMouseMove },
|
||||
{ eventName: "mouseleave", action: onMouseLeave },
|
||||
{ eventName: "mousedown", action: closeTooltip },
|
||||
{ eventName: "keydown", action: closeTooltip },
|
||||
{ eventName: "wheel", action: closeTooltip },
|
||||
];
|
||||
|
||||
let editorRef: Editor | undefined = undefined;
|
||||
let tooltipTimeout: ReturnType<typeof setTimeout> | undefined = undefined;
|
||||
|
||||
// Store state persisted across HMR to maintain reactive subscriptions in the component tree
|
||||
const store: Writable<TooltipStoreState> = import.meta.hot?.data?.store || writable<TooltipStoreState>(initialState);
|
||||
|
|
@ -36,14 +45,9 @@ if (import.meta.hot) import.meta.hot.data.store = store;
|
|||
const { subscribe, update } = store;
|
||||
|
||||
export function createTooltipStore(editor: Editor) {
|
||||
editorRef = editor;
|
||||
destroyTooltipStore();
|
||||
|
||||
document.addEventListener("mouseover", onMouseOver);
|
||||
document.addEventListener("mousemove", onMouseMove);
|
||||
document.addEventListener("mouseleave", onMouseLeave);
|
||||
document.addEventListener("mousedown", closeTooltip);
|
||||
document.addEventListener("keydown", closeTooltip);
|
||||
document.addEventListener("wheel", closeTooltip);
|
||||
editorRef = editor;
|
||||
|
||||
editor.subscriptions.subscribeFrontendMessage("SendShortcutShiftClick", async (data) => {
|
||||
update((state) => {
|
||||
|
|
@ -51,12 +55,14 @@ export function createTooltipStore(editor: Editor) {
|
|||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
editor.subscriptions.subscribeFrontendMessage("SendShortcutAltClick", async (data) => {
|
||||
update((state) => {
|
||||
state.altClickShortcut = data.shortcut;
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
editor.subscriptions.subscribeFrontendMessage("SendShortcutFullscreen", async (data) => {
|
||||
update((state) => {
|
||||
state.fullscreenShortcut = operatingSystem() === "Mac" ? data.shortcutMac : data.shortcut;
|
||||
|
|
@ -64,6 +70,8 @@ export function createTooltipStore(editor: Editor) {
|
|||
});
|
||||
});
|
||||
|
||||
tooltipEventListeners.forEach(({ eventName, action }) => document.addEventListener(eventName, action));
|
||||
|
||||
return { subscribe };
|
||||
}
|
||||
|
||||
|
|
@ -73,16 +81,11 @@ export function destroyTooltipStore() {
|
|||
|
||||
if (tooltipTimeout) clearTimeout(tooltipTimeout);
|
||||
|
||||
document.removeEventListener("mouseover", onMouseOver);
|
||||
document.removeEventListener("mousemove", onMouseMove);
|
||||
document.removeEventListener("mouseleave", onMouseLeave);
|
||||
document.removeEventListener("mousedown", closeTooltip);
|
||||
document.removeEventListener("keydown", closeTooltip);
|
||||
document.removeEventListener("wheel", closeTooltip);
|
||||
|
||||
editor.subscriptions.unsubscribeFrontendMessage("SendShortcutShiftClick");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("SendShortcutAltClick");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("SendShortcutFullscreen");
|
||||
|
||||
tooltipEventListeners.forEach(({ eventName, action }) => document.removeEventListener(eventName, action));
|
||||
}
|
||||
|
||||
// Listen for mouse movements onto tooltip-bearing HTML elements to track the future target of a tooltip
|
||||
|
|
|
|||
|
|
@ -0,0 +1,175 @@
|
|||
import type { Editor } from "@graphite/editor";
|
||||
import { extractPixelData } from "@graphite/utility-functions/rasterization";
|
||||
import { stripIndents } from "@graphite/utility-functions/strip-indents";
|
||||
|
||||
export 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 = String(selection);
|
||||
if (!selectedText) return undefined;
|
||||
|
||||
if (cut) {
|
||||
const range = selection.getRangeAt(0);
|
||||
range.deleteContents();
|
||||
|
||||
range.collapse(true);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
|
||||
return selectedText;
|
||||
}
|
||||
|
||||
export 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 }));
|
||||
}
|
||||
|
||||
export async function triggerClipboardRead(editor: Editor) {
|
||||
// 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 attempting 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 permission = await navigator.permissions?.query({ name: "clipboard-read" });
|
||||
if (permission?.state === "denied") throw new Error("Permission denied");
|
||||
|
||||
// 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
|
||||
const success = await Promise.any(
|
||||
Array.from(clipboardItems).map(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 = () => {
|
||||
if (typeof reader.result === "string") editor.handle.pasteText(reader.result);
|
||||
};
|
||||
reader.readAsText(blob);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Read an image from the clipboard and pass it to the editor to be loaded
|
||||
const imageType = item.types.find((type) => type.startsWith("image/"));
|
||||
|
||||
// Import the actual SVG content if it's an SVG
|
||||
if (imageType?.includes("svg")) {
|
||||
const blob = await item.getType("text/plain");
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result === "string") editor.handle.pasteSvg(undefined, reader.result);
|
||||
};
|
||||
reader.readAsText(blob);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Import the bitmap image if it's an image
|
||||
if (imageType) {
|
||||
const blob = await item.getType(imageType);
|
||||
const reader = new FileReader();
|
||||
reader.onload = async () => {
|
||||
if (reader.result instanceof ArrayBuffer) {
|
||||
const imageData = await extractPixelData(new Blob([reader.result], { type: imageType }));
|
||||
editor.handle.pasteImage(undefined, new Uint8Array(imageData.data), imageData.width, imageData.height);
|
||||
}
|
||||
};
|
||||
reader.readAsArrayBuffer(blob);
|
||||
return true;
|
||||
}
|
||||
|
||||
// The API limits what kinds of data we can access, so we can get copied images and our text encodings of copied nodes, but not files (like
|
||||
// .graphite or even image files). However, the user can paste those with Ctrl+V, which we recommend they in the error message that's shown to them.
|
||||
return false;
|
||||
}),
|
||||
);
|
||||
|
||||
if (!success) throw new Error("No valid clipboard data");
|
||||
} catch (err) {
|
||||
const unsupported = stripIndents`
|
||||
This browser does not support reading from the clipboard.
|
||||
Use the standard 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 bar) to allow this permission.
|
||||
`;
|
||||
const nothing = stripIndents`
|
||||
No valid clipboard data was found. You may have better
|
||||
success pasting with the standard keyboard shortcut instead.
|
||||
`;
|
||||
|
||||
const matchMessage = {
|
||||
"clipboard-read": unsupported,
|
||||
"Clipboard API unsupported": unsupported,
|
||||
"Permission denied": denied,
|
||||
"No valid clipboard data": nothing,
|
||||
};
|
||||
const message = Object.entries(matchMessage).find(([key]) => String(err).includes(key))?.[1] || String(err);
|
||||
|
||||
editor.handle.errorDialog("Cannot access clipboard", message);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,362 @@
|
|||
import { get } from "svelte/store";
|
||||
|
||||
import { isPlatformNative } from "@graphite/../wasm/pkg/graphite_wasm";
|
||||
import type { Editor } from "@graphite/editor";
|
||||
import type { DialogStore } from "@graphite/stores/dialog";
|
||||
import type { DocumentStore } from "@graphite/stores/document";
|
||||
import { toggleFullscreen } from "@graphite/stores/fullscreen";
|
||||
import type { PortfolioStore } from "@graphite/stores/portfolio";
|
||||
import { pasteFile } from "@graphite/utility-functions/files";
|
||||
import { makeKeyboardModifiersBitfield, textInputCleanup, getLocalizedScanCode } from "@graphite/utility-functions/keyboard-entry";
|
||||
import { operatingSystem } from "@graphite/utility-functions/platform";
|
||||
|
||||
const BUTTON_LEFT = 0;
|
||||
const BUTTON_MIDDLE = 1;
|
||||
const BUTTON_RIGHT = 2;
|
||||
const BUTTON_BACK = 3;
|
||||
const BUTTON_FORWARD = 4;
|
||||
|
||||
let viewportPointerInteractionOngoing = false;
|
||||
let textToolInteractiveInputElement: HTMLDivElement | undefined = undefined;
|
||||
let canvasFocused = true;
|
||||
let inPointerLock = false;
|
||||
let lastShakeTime = 0;
|
||||
const shakeSamples: { x: number; y: number; time: number }[] = [];
|
||||
|
||||
// Keyboard events
|
||||
|
||||
export async function shouldRedirectKeyboardEventToBackend(e: KeyboardEvent, dialogStore: DialogStore): Promise<boolean> {
|
||||
if (!dialogStore) return false;
|
||||
|
||||
// Don't redirect when a dialog is covering the workspace
|
||||
if (get(dialogStore).visible) return false;
|
||||
|
||||
const key = await getLocalizedScanCode(e);
|
||||
|
||||
// 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 (isPlatformNative() && accelKey && ["KeyX", "KeyC", "KeyV"].includes(key)) return true;
|
||||
// But on web, we want to not redirect paste
|
||||
if (!isPlatformNative() && key === "KeyV" && accelKey) return false;
|
||||
|
||||
// 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 tab or enter if not in canvas (to allow navigating elements)
|
||||
potentiallyRestoreCanvasFocus(e);
|
||||
if (
|
||||
!canvasFocused &&
|
||||
!targetIsTextField(e.target || undefined) &&
|
||||
["Tab", "Enter", "NumpadEnter", "Space", "ArrowDown", "ArrowLeft", "ArrowRight", "ArrowUp"].includes(key) &&
|
||||
!(e.ctrlKey || e.metaKey || e.altKey)
|
||||
)
|
||||
return false;
|
||||
|
||||
// Don't redirect if a MenuList is open
|
||||
if (window.document.querySelector("[data-floating-menu-content]")) return false;
|
||||
|
||||
// Web-only keyboard shortcuts
|
||||
if (!isPlatformNative()) {
|
||||
// Don't redirect a fullscreen request, but process it immediately instead
|
||||
if (((operatingSystem() !== "Mac" && key === "F11") || (operatingSystem() === "Mac" && e.ctrlKey && e.metaKey && key === "KeyF")) && e.type === "keydown" && !e.repeat) {
|
||||
e.preventDefault();
|
||||
toggleFullscreen();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Don't redirect a reload request
|
||||
if (key === "F5") return false;
|
||||
if (key === "KeyR" && accelKey) return false;
|
||||
|
||||
// Don't redirect debugging tools
|
||||
if (["F12", "F8"].includes(key)) return false;
|
||||
if (["KeyC", "KeyI", "KeyJ"].includes(key) && accelKey && e.shiftKey) return false;
|
||||
}
|
||||
|
||||
// Redirect to the backend
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function onKeyDown(e: KeyboardEvent, editor: Editor, dialogStore: DialogStore) {
|
||||
const key = await getLocalizedScanCode(e);
|
||||
|
||||
const NO_KEY_REPEAT_MODIFIER_KEYS = ["ControlLeft", "ControlRight", "ShiftLeft", "ShiftRight", "MetaLeft", "MetaRight", "AltLeft", "AltRight", "AltGraph", "CapsLock", "Fn", "FnLock"];
|
||||
if (e.repeat && NO_KEY_REPEAT_MODIFIER_KEYS.includes(key)) return;
|
||||
|
||||
if (await shouldRedirectKeyboardEventToBackend(e, dialogStore)) {
|
||||
e.preventDefault();
|
||||
const modifiers = makeKeyboardModifiersBitfield(e);
|
||||
editor.handle.onKeyDown(key, modifiers, e.repeat);
|
||||
return;
|
||||
}
|
||||
|
||||
if (get(dialogStore).visible && key === "Escape") {
|
||||
editor.handle.onDialogDismiss();
|
||||
}
|
||||
}
|
||||
|
||||
export async function onKeyUp(e: KeyboardEvent, editor: Editor, dialogStore: DialogStore) {
|
||||
const key = await getLocalizedScanCode(e);
|
||||
|
||||
if (await shouldRedirectKeyboardEventToBackend(e, dialogStore)) {
|
||||
e.preventDefault();
|
||||
const modifiers = makeKeyboardModifiersBitfield(e);
|
||||
editor.handle.onKeyUp(key, modifiers, e.repeat);
|
||||
}
|
||||
}
|
||||
|
||||
// Pointer events
|
||||
|
||||
// While any pointer button is already down, additional button down events are not reported, but they are sent as `pointermove` events and these are handled in the backend
|
||||
export function onPointerMove(e: PointerEvent, editor: Editor, documentStore: DocumentStore) {
|
||||
potentiallyRestoreCanvasFocus(e);
|
||||
|
||||
if (!e.buttons) viewportPointerInteractionOngoing = false;
|
||||
|
||||
// Don't redirect pointer movement to the backend if there's no ongoing interaction and it's over a floating menu, or the graph overlay, on top of the canvas
|
||||
// TODO: A better approach is to pass along a boolean to the backend's input preprocessor so it can know if it's being occluded by the GUI.
|
||||
// TODO: This would allow it to properly decide to act on removing hover focus from something that was hovered in the canvas before moving over the GUI.
|
||||
// TODO: Further explanation: https://github.com/GraphiteEditor/Graphite/pull/623#discussion_r866436197
|
||||
const inFloatingMenu = e.target instanceof Element && e.target.closest("[data-floating-menu-content]");
|
||||
const inGraphOverlay = get(documentStore).graphViewOverlayOpen;
|
||||
if (!viewportPointerInteractionOngoing && (inFloatingMenu || inGraphOverlay)) return;
|
||||
|
||||
const modifiers = makeKeyboardModifiersBitfield(e);
|
||||
if (detectShake(e)) editor.handle.onMouseShake(e.clientX, e.clientY, e.buttons, modifiers);
|
||||
editor.handle.onMouseMove(e.clientX, e.clientY, e.buttons, modifiers);
|
||||
}
|
||||
|
||||
export function onPointerDown(e: PointerEvent, editor: Editor, dialogStore: DialogStore) {
|
||||
potentiallyRestoreCanvasFocus(e);
|
||||
|
||||
const inFloatingMenu = e.target instanceof Element && e.target.closest("[data-floating-menu-content]");
|
||||
const isTargetingCanvas = !inFloatingMenu && e.target instanceof Element && e.target.closest("[data-viewport], [data-viewport-container], [data-node-graph]");
|
||||
const inDialog = e.target instanceof Element && e.target.closest("[data-dialog] [data-floating-menu-content]");
|
||||
const inContextMenu = e.target instanceof Element && e.target.closest("[data-context-menu]");
|
||||
const inTextInput = e.target === textToolInteractiveInputElement;
|
||||
|
||||
if (get(dialogStore).visible && !inDialog) {
|
||||
editor.handle.onDialogDismiss();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
if (!inTextInput && !inContextMenu) {
|
||||
if (textToolInteractiveInputElement) {
|
||||
const isLeftOrRightClick = e.button === BUTTON_RIGHT || e.button === BUTTON_LEFT;
|
||||
editor.handle.onChangeText(textInputCleanup(textToolInteractiveInputElement.innerText), isLeftOrRightClick);
|
||||
} else {
|
||||
viewportPointerInteractionOngoing = isTargetingCanvas instanceof Element;
|
||||
}
|
||||
}
|
||||
|
||||
if (viewportPointerInteractionOngoing && isTargetingCanvas instanceof Element) {
|
||||
const modifiers = makeKeyboardModifiersBitfield(e);
|
||||
editor.handle.onMouseDown(e.clientX, e.clientY, e.buttons, modifiers);
|
||||
}
|
||||
}
|
||||
|
||||
export function onPointerUp(e: PointerEvent, editor: Editor) {
|
||||
potentiallyRestoreCanvasFocus(e);
|
||||
|
||||
// Don't let the browser navigate back or forward when using the buttons on some mice
|
||||
// TODO: This works in Chrome but not in Firefox
|
||||
// TODO: Possible workaround: use the browser's history API to block navigation:
|
||||
// TODO: <https://stackoverflow.com/questions/57102502/preventing-mouse-fourth-and-fifth-buttons-from-navigating-back-forward-in-browse>
|
||||
if (e.button === BUTTON_BACK || e.button === BUTTON_FORWARD) e.preventDefault();
|
||||
|
||||
if (!e.buttons) viewportPointerInteractionOngoing = false;
|
||||
|
||||
if (textToolInteractiveInputElement) return;
|
||||
|
||||
const modifiers = makeKeyboardModifiersBitfield(e);
|
||||
editor.handle.onMouseUp(e.clientX, e.clientY, e.buttons, modifiers);
|
||||
}
|
||||
|
||||
// Mouse events
|
||||
|
||||
export function onPotentialDoubleClick(e: MouseEvent, editor: Editor) {
|
||||
if (textToolInteractiveInputElement || inPointerLock) return;
|
||||
|
||||
// Allow only events within the viewport or node graph boundaries
|
||||
const isTargetingCanvas = e.target instanceof Element && e.target.closest("[data-viewport], [data-viewport-container], [data-node-graph]");
|
||||
if (!(isTargetingCanvas instanceof Element)) return;
|
||||
|
||||
// Allow only repeated increments of double-clicks (not 1, 3, 5, etc.)
|
||||
if (e.detail % 2 == 1) return;
|
||||
|
||||
// `e.buttons` is always 0 in the `mouseup` event, so we have to convert from `e.button` instead
|
||||
let buttons = 1;
|
||||
if (e.button === BUTTON_LEFT) buttons = 1; // Left
|
||||
if (e.button === BUTTON_RIGHT) buttons = 2; // Right
|
||||
if (e.button === BUTTON_MIDDLE) buttons = 4; // Middle
|
||||
if (e.button === BUTTON_BACK) buttons = 8; // Back
|
||||
if (e.button === BUTTON_FORWARD) buttons = 16; // Forward
|
||||
|
||||
const modifiers = makeKeyboardModifiersBitfield(e);
|
||||
editor.handle.onDoubleClick(e.clientX, e.clientY, buttons, modifiers);
|
||||
}
|
||||
|
||||
export function onMouseDown(e: MouseEvent) {
|
||||
// Block middle mouse button auto-scroll mode (the circular gizmo that appears and allows quick scrolling by moving the cursor above or below it)
|
||||
if (e.button === BUTTON_MIDDLE) e.preventDefault();
|
||||
}
|
||||
|
||||
export function onContextMenu(e: MouseEvent) {
|
||||
if (!targetIsTextField(e.target || undefined) && e.target !== textToolInteractiveInputElement) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
export function onPointerLockChange() {
|
||||
inPointerLock = Boolean(window.document.pointerLockElement);
|
||||
}
|
||||
|
||||
// Wheel events
|
||||
|
||||
export function onWheelScroll(e: WheelEvent, editor: Editor) {
|
||||
const isTargetingCanvas = e.target instanceof Element && e.target.closest("[data-viewport], [data-viewport-container], [data-node-graph]");
|
||||
|
||||
// Prevent zooming the entire page when using Ctrl + scroll wheel outside of the viewport
|
||||
if (e.ctrlKey && !isTargetingCanvas) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
// Redirect vertical scroll wheel movement into a horizontal scroll on a horizontally scrollable element
|
||||
// There seems to be no possible way to properly employ the browser's smooth scrolling interpolation
|
||||
const horizontalScrollableElement = e.target instanceof Element && e.target.closest("[data-scrollable-x]");
|
||||
if (horizontalScrollableElement && e.deltaY !== 0) {
|
||||
horizontalScrollableElement.scrollTo(horizontalScrollableElement.scrollLeft + e.deltaY, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTargetingCanvas) {
|
||||
e.preventDefault();
|
||||
const modifiers = makeKeyboardModifiersBitfield(e);
|
||||
editor.handle.onWheelScroll(e.clientX, e.clientY, e.buttons, e.deltaX, e.deltaY, e.deltaZ, modifiers);
|
||||
}
|
||||
}
|
||||
|
||||
// Receives a custom event dispatched when the user begins interactively editing with the text tool.
|
||||
// We keep a copy of the text input element to check against when it's active for text entry.
|
||||
export function onModifyInputField(e: CustomEvent) {
|
||||
textToolInteractiveInputElement = e.detail;
|
||||
}
|
||||
|
||||
// Window events
|
||||
|
||||
export async function onBeforeUnload(e: BeforeUnloadEvent, editor: Editor, portfolioStore: PortfolioStore) {
|
||||
const activeDocument = get(portfolioStore).documents[get(portfolioStore).activeDocumentIndex];
|
||||
if (activeDocument && !activeDocument.details.isAutoSaved) editor.handle.triggerAutoSave(activeDocument.id);
|
||||
|
||||
// Skip the message if the editor crashed, since work is already lost
|
||||
if (await editor.handle.hasCrashed()) return;
|
||||
|
||||
// Skip the message during development, since it's annoying when testing
|
||||
if (await editor.handle.inDevelopmentMode()) return;
|
||||
|
||||
const allDocumentsSaved = get(portfolioStore).documents.reduce((acc, doc) => acc && doc.details.isSaved, true);
|
||||
if (!allDocumentsSaved) {
|
||||
e.returnValue = "Unsaved work will be lost if the web browser tab is closed. Close anyway?";
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
export function onPaste(e: ClipboardEvent, editor: Editor) {
|
||||
const dataTransfer = e.clipboardData;
|
||||
if (!dataTransfer || targetIsTextField(e.target || undefined)) return;
|
||||
e.preventDefault();
|
||||
|
||||
Array.from(dataTransfer.items).forEach(async (item) => {
|
||||
if (item.type === "text/plain") item.getAsString((text) => editor.handle.pasteText(text));
|
||||
await pasteFile(item, editor);
|
||||
});
|
||||
}
|
||||
|
||||
export function onFocusOut() {
|
||||
canvasFocused = false;
|
||||
}
|
||||
|
||||
function detectShake(e: PointerEvent | MouseEvent): boolean {
|
||||
const SENSITIVITY_DIRECTION_CHANGES = 3;
|
||||
const SENSITIVITY_DISTANCE_TO_DISPLACEMENT_RATIO = 0.1;
|
||||
const DETECTION_WINDOW_MS = 500;
|
||||
const DEBOUNCE_MS = 1000;
|
||||
|
||||
// Add the current mouse position and time to our list of samples
|
||||
const now = Date.now();
|
||||
shakeSamples.push({ x: e.clientX, y: e.clientY, time: now });
|
||||
|
||||
// Remove samples that are older than our time window
|
||||
while (shakeSamples.length > 0 && now - shakeSamples[0].time > DETECTION_WINDOW_MS) {
|
||||
shakeSamples.shift();
|
||||
}
|
||||
|
||||
// We can't be shaking if it's too early in terms of samples or debounce time
|
||||
if (shakeSamples.length <= 3 || now - lastShakeTime <= DEBOUNCE_MS) return false;
|
||||
|
||||
// Calculate the total distance traveled
|
||||
let totalDistanceSquared = 0;
|
||||
for (let i = 1; i < shakeSamples.length; i += 1) {
|
||||
const p1 = shakeSamples[i - 1];
|
||||
const p2 = shakeSamples[i];
|
||||
totalDistanceSquared += (p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2;
|
||||
}
|
||||
|
||||
// Count the number of times the mouse changes direction significantly, and the average position of the mouse
|
||||
let directionChanges = 0;
|
||||
const averagePoint = { x: 0, y: 0 };
|
||||
let averagePointCount = 0;
|
||||
for (let i = 0; i < shakeSamples.length - 2; i += 1) {
|
||||
const p1 = shakeSamples[i];
|
||||
const p2 = shakeSamples[i + 1];
|
||||
const p3 = shakeSamples[i + 2];
|
||||
|
||||
const vector1 = { x: p2.x - p1.x, y: p2.y - p1.y };
|
||||
const vector2 = { x: p3.x - p2.x, y: p3.y - p2.y };
|
||||
|
||||
// Check if the dot product is negative, which indicates the angle between vectors is > 90 degrees
|
||||
if (vector1.x * vector2.x + vector1.y * vector2.y < 0) directionChanges += 1;
|
||||
|
||||
averagePoint.x += p2.x;
|
||||
averagePoint.y += p2.y;
|
||||
averagePointCount += 1;
|
||||
}
|
||||
if (averagePointCount > 0) {
|
||||
averagePoint.x /= averagePointCount;
|
||||
averagePoint.y /= averagePointCount;
|
||||
}
|
||||
|
||||
// Calculate the displacement (the distance between the first and last mouse positions)
|
||||
const lastPoint = shakeSamples[shakeSamples.length - 1];
|
||||
const displacementSquared = (lastPoint.x - averagePoint.x) ** 2 + (lastPoint.y - averagePoint.y) ** 2;
|
||||
|
||||
// A shake is detected if the mouse has traveled a lot but not moved far, and has changed direction enough times
|
||||
if (SENSITIVITY_DISTANCE_TO_DISPLACEMENT_RATIO * totalDistanceSquared >= displacementSquared && directionChanges >= SENSITIVITY_DIRECTION_CHANGES) {
|
||||
lastShakeTime = now;
|
||||
shakeSamples.length = 0;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function targetIsTextField(target: EventTarget | HTMLElement | undefined): boolean {
|
||||
return target instanceof HTMLElement && (target.nodeName === "INPUT" || target.nodeName === "TEXTAREA" || target.isContentEditable);
|
||||
}
|
||||
|
||||
function potentiallyRestoreCanvasFocus(e: Event) {
|
||||
const appElement = window.document.querySelector("[data-app-container]");
|
||||
const app = appElement instanceof HTMLElement ? appElement : null;
|
||||
|
||||
const newInCanvasArea =
|
||||
(e.target instanceof Element && e.target.closest("[data-viewport], [data-viewport-container], [data-graph]")) instanceof Element &&
|
||||
!targetIsTextField(window.document.activeElement || undefined);
|
||||
if (!canvasFocused && newInCanvasArea) {
|
||||
canvasFocused = true;
|
||||
app?.focus();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,193 @@
|
|||
import * as idb from "idb-keyval";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
import type { Editor } from "@graphite/editor";
|
||||
import type { PortfolioStore } from "@graphite/stores/portfolio";
|
||||
import type { MessageBody } from "@graphite/subscription-router";
|
||||
|
||||
export async function storeCurrentDocumentId(documentId: string) {
|
||||
const indexedDbStorage = idb.createStore("graphite", "store");
|
||||
|
||||
await idb.set("current_document_id", String(documentId), indexedDbStorage);
|
||||
}
|
||||
|
||||
export async function storeDocument(autoSaveDocument: MessageBody<"TriggerPersistenceWriteDocument">, portfolio: PortfolioStore) {
|
||||
const indexedDbStorage = idb.createStore("graphite", "store");
|
||||
|
||||
await idb.update<Record<string, MessageBody<"TriggerPersistenceWriteDocument">>>(
|
||||
"documents",
|
||||
(old) => {
|
||||
const documents = old || {};
|
||||
documents[String(autoSaveDocument.documentId)] = autoSaveDocument;
|
||||
return documents;
|
||||
},
|
||||
indexedDbStorage,
|
||||
);
|
||||
|
||||
const documentOrder = get(portfolio).documents.map((doc) => String(doc.id));
|
||||
await idb.set("documents_tab_order", documentOrder, indexedDbStorage);
|
||||
await storeCurrentDocumentId(String(autoSaveDocument.documentId));
|
||||
}
|
||||
|
||||
export async function removeDocument(id: string, portfolio: PortfolioStore) {
|
||||
const indexedDbStorage = idb.createStore("graphite", "store");
|
||||
|
||||
await idb.update<Record<string, MessageBody<"TriggerPersistenceWriteDocument">>>(
|
||||
"documents",
|
||||
(old) => {
|
||||
const documents = old || {};
|
||||
delete documents[id];
|
||||
return documents;
|
||||
},
|
||||
indexedDbStorage,
|
||||
);
|
||||
|
||||
await idb.update<string[]>(
|
||||
"documents_tab_order",
|
||||
(old) => {
|
||||
const order = old || [];
|
||||
return order.filter((docId) => docId !== id);
|
||||
},
|
||||
indexedDbStorage,
|
||||
);
|
||||
|
||||
const documentCount = get(portfolio).documents.length;
|
||||
if (documentCount > 0) {
|
||||
const documentIndex = get(portfolio).activeDocumentIndex;
|
||||
const documentId = String(get(portfolio).documents[documentIndex].id);
|
||||
|
||||
const tabOrder = (await idb.get<string[]>("documents_tab_order", indexedDbStorage)) || [];
|
||||
if (tabOrder.includes(documentId)) {
|
||||
await storeCurrentDocumentId(documentId);
|
||||
}
|
||||
} else {
|
||||
await idb.del("current_document_id", indexedDbStorage);
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadFirstDocument(editor: Editor) {
|
||||
const indexedDbStorage = idb.createStore("graphite", "store");
|
||||
|
||||
const previouslySavedDocuments = await idb.get<Record<string, MessageBody<"TriggerPersistenceWriteDocument">>>("documents", indexedDbStorage);
|
||||
|
||||
// TODO: Eventually remove this document upgrade code
|
||||
// Migrate TriggerPersistenceWriteDocument.documentId from string to bigint if the browser is storing the old format as strings
|
||||
if (previouslySavedDocuments) {
|
||||
Object.values(previouslySavedDocuments).forEach((doc) => {
|
||||
if (typeof doc.documentId === "string") doc.documentId = BigInt(doc.documentId);
|
||||
});
|
||||
}
|
||||
|
||||
const documentOrder = await idb.get<string[]>("documents_tab_order", indexedDbStorage);
|
||||
const currentDocumentIdString = await idb.get<string>("current_document_id", indexedDbStorage);
|
||||
const currentDocumentId = currentDocumentIdString ? BigInt(currentDocumentIdString) : undefined;
|
||||
if (!previouslySavedDocuments || !documentOrder) return;
|
||||
|
||||
const orderedSavedDocuments = documentOrder.flatMap((id) => (previouslySavedDocuments[id] ? [previouslySavedDocuments[id]] : []));
|
||||
|
||||
if (currentDocumentId !== undefined && String(currentDocumentId) in previouslySavedDocuments) {
|
||||
const doc = previouslySavedDocuments[String(currentDocumentId)];
|
||||
editor.handle.openAutoSavedDocument(doc.documentId, doc.details.name, doc.details.isSaved, doc.document, false);
|
||||
editor.handle.selectDocument(currentDocumentId);
|
||||
} else {
|
||||
const len = orderedSavedDocuments.length;
|
||||
if (len > 0) {
|
||||
const doc = orderedSavedDocuments[len - 1];
|
||||
editor.handle.openAutoSavedDocument(doc.documentId, doc.details.name, doc.details.isSaved, doc.document, false);
|
||||
editor.handle.selectDocument(doc.documentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadRestDocuments(editor: Editor) {
|
||||
const indexedDbStorage = idb.createStore("graphite", "store");
|
||||
|
||||
const previouslySavedDocuments = await idb.get<Record<string, MessageBody<"TriggerPersistenceWriteDocument">>>("documents", indexedDbStorage);
|
||||
|
||||
// TODO: Eventually remove this document upgrade code
|
||||
// Migrate TriggerPersistenceWriteDocument.documentId from string to bigint if needed
|
||||
if (previouslySavedDocuments) {
|
||||
Object.values(previouslySavedDocuments).forEach((doc) => {
|
||||
if (typeof doc.documentId === "string") doc.documentId = BigInt(doc.documentId);
|
||||
});
|
||||
}
|
||||
|
||||
const documentOrder = await idb.get<string[]>("documents_tab_order", indexedDbStorage);
|
||||
const currentDocumentIdString = await idb.get<string>("current_document_id", indexedDbStorage);
|
||||
const currentDocumentId = currentDocumentIdString ? BigInt(currentDocumentIdString) : undefined;
|
||||
if (!previouslySavedDocuments || !documentOrder) return;
|
||||
|
||||
const orderedSavedDocuments = documentOrder.flatMap((id) => (previouslySavedDocuments[id] ? [previouslySavedDocuments[id]] : []));
|
||||
|
||||
const currentIndex = currentDocumentId !== undefined ? orderedSavedDocuments.findIndex((doc) => doc.documentId === currentDocumentId) : -1;
|
||||
|
||||
// Open documents in order around the current document, placing earlier ones before it and later ones after
|
||||
if (currentIndex !== -1 && currentDocumentId !== undefined) {
|
||||
for (let i = currentIndex - 1; i >= 0; i--) {
|
||||
const { documentId, document, details } = orderedSavedDocuments[i];
|
||||
const { name, isSaved } = details;
|
||||
editor.handle.openAutoSavedDocument(documentId, name, isSaved, document, true);
|
||||
}
|
||||
for (let i = currentIndex + 1; i < orderedSavedDocuments.length; i++) {
|
||||
const { documentId, document, details } = orderedSavedDocuments[i];
|
||||
const { name, isSaved } = details;
|
||||
editor.handle.openAutoSavedDocument(documentId, name, isSaved, document, false);
|
||||
}
|
||||
|
||||
editor.handle.selectDocument(currentDocumentId);
|
||||
}
|
||||
// No valid current document: open all remaining documents and select the last one
|
||||
else {
|
||||
const length = orderedSavedDocuments.length;
|
||||
|
||||
for (let i = length - 2; i >= 0; i--) {
|
||||
const { documentId, document, details } = orderedSavedDocuments[i];
|
||||
const { name, isSaved } = details;
|
||||
editor.handle.openAutoSavedDocument(documentId, name, isSaved, document, true);
|
||||
}
|
||||
|
||||
if (length > 0) editor.handle.selectDocument(orderedSavedDocuments[length - 1].documentId);
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveActiveDocument(documentId: bigint) {
|
||||
const indexedDbStorage = idb.createStore("graphite", "store");
|
||||
|
||||
const previouslySavedDocuments = await idb.get<Record<string, MessageBody<"TriggerPersistenceWriteDocument">>>("documents", indexedDbStorage);
|
||||
|
||||
const documentIdString = String(documentId);
|
||||
|
||||
// TODO: Eventually remove this document upgrade code
|
||||
// Migrate TriggerPersistenceWriteDocument.documentId from string to bigint if needed
|
||||
if (previouslySavedDocuments) {
|
||||
Object.values(previouslySavedDocuments).forEach((doc) => {
|
||||
if (typeof doc.documentId === "string") doc.documentId = BigInt(doc.documentId);
|
||||
});
|
||||
}
|
||||
|
||||
if (!previouslySavedDocuments) return;
|
||||
if (documentIdString in previouslySavedDocuments) {
|
||||
await storeCurrentDocumentId(documentIdString);
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveEditorPreferences(preferences: unknown) {
|
||||
const indexedDbStorage = idb.createStore("graphite", "store");
|
||||
|
||||
await idb.set("preferences", preferences, indexedDbStorage);
|
||||
}
|
||||
|
||||
export async function loadEditorPreferences(editor: Editor) {
|
||||
const indexedDbStorage = idb.createStore("graphite", "store");
|
||||
|
||||
const preferences = await idb.get<Record<string, unknown>>("preferences", indexedDbStorage);
|
||||
editor.handle.loadPreferences(preferences ? JSON.stringify(preferences) : undefined);
|
||||
}
|
||||
|
||||
export async function wipeDocuments() {
|
||||
const indexedDbStorage = idb.createStore("graphite", "store");
|
||||
|
||||
await idb.del("documents_tab_order", indexedDbStorage);
|
||||
await idb.del("current_document_id", indexedDbStorage);
|
||||
await idb.del("documents", indexedDbStorage);
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
export function localizeTimestamp(utc: string): { timestamp: string; year: string } {
|
||||
// Timestamp
|
||||
const date = new Date(utc);
|
||||
if (Number.isNaN(date.getTime())) return { timestamp: utc, year: `${new Date().getFullYear()}` };
|
||||
|
||||
const timezoneName = Intl.DateTimeFormat(undefined, { timeZoneName: "longGeneric" })
|
||||
.formatToParts(new Date())
|
||||
.find((part) => part.type === "timeZoneName");
|
||||
|
||||
const dateString = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||||
const timeString = `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
|
||||
const timezoneNameString = timezoneName?.value;
|
||||
return { timestamp: `${dateString} ${timeString} ${timezoneNameString}`, year: String(date.getFullYear()) };
|
||||
}
|
||||
Loading…
Reference in New Issue