Merge branch 'master' into fix-range
@@ -1,8 +1,6 @@
|
||||
# Overview of `/frontend/`
|
||||
|
||||
The Graphite frontend is a web app that provides the presentation for the editor. It displays the GUI based on state from the backend and provides users with interactive widgets that send updates to the backend, which is the source of truth for state information. The frontend is built out of reactive components using the [Svelte](https://svelte.dev/) framework. The backend is written in Rust and compiled to WebAssembly (WASM) to be run in the browser alongside the JS code.
|
||||
|
||||
For lack of other options, the frontend is currently written as a web app. Maintaining web compatibility will always be a requirement, but the long-term plan is to port this code to a Rust-based native GUI framework, either written by the Rust community or created by our project if necessary. As a medium-term compromise, we may wrap the web-based frontend in a desktop webview windowing solution like Electron (probably not) or [Tauri](https://tauri.app/) (probably).
|
||||
The Graphite frontend is a web app that provides the presentation for the editor. It displays the GUI based on state from the backend and provides users with interactive widgets that send updates to the backend, which is the source of truth for state information. The frontend is built out of reactive components using the [Svelte](https://svelte.dev/) framework. The backend is written in Rust and compiled to WebAssembly (Wasm) to be run in the browser alongside the JS code.
|
||||
|
||||
## Bundled assets: `assets/`
|
||||
|
||||
@@ -18,15 +16,15 @@ Source code for the web app in the form of Svelte components and [TypeScript](ht
|
||||
|
||||
## WebAssembly wrapper: `wasm/`
|
||||
|
||||
Wraps the editor backend codebase (`/editor`) and provides a JS-centric API for the web app to use unburdened by Rust's complex data types that are incompatible with JS data types. Bindings (JS functions that call into the WASM module) are provided by [wasm-bindgen](https://rustwasm.github.io/docs/wasm-bindgen/) in concert with [wasm-pack](https://github.com/rustwasm/wasm-pack).
|
||||
Wraps the editor backend codebase (`/editor`) and provides a JS-centric API for the web app to use as an entry point, unburdened by Rust's complex data types that are incompatible with JS data types. Bindings (JS functions that call into the Wasm module) are provided by [wasm-bindgen](https://rustwasm.github.io/docs/wasm-bindgen/) in concert with [wasm-pack](https://github.com/rustwasm/wasm-pack).
|
||||
|
||||
## ESLint configurations: `.eslintrc.js`
|
||||
## ESLint configurations: `.eslintrc.cjs`
|
||||
|
||||
[ESLint](https://eslint.org/) is the tool which enforces style rules on the JS, TS, and Svelte files in our frontend codebase. As it is set up in this config file, ESLint will complain about bad practices and often help reformat code automatically when (in VS Code) the file is saved or `npm run lint` is executed. (If you don't use VS Code, remember to run this command before committing!) This config file for ESLint sets our style preferences and configures our usage of extensions/plugins for Svelte support and [Prettier](https://prettier.io/)'s role as a code formatter.
|
||||
|
||||
## npm ecosystem packages: `package.json`
|
||||
|
||||
While we don't use Node.js as a JS-based server, we do have to rely on its wide ecosystem of packages for our build system toolchain. If you're just getting started, make sure to install the latest LTS copy of Node.js. Our project's philosophy on third-party packages is to keep our dependency tree as light as possible, so adding anything new to our `package.json` should have overwhelming justification. Most of the packages are just development tooling (TypeScript, Vite, ESLint, Prettier, wasm-pack, and [Sass](https://sass-lang.com/)) that run in your console during the build process.
|
||||
While we don't use Node.js as a JS-based server, we do rely on its ecosystem of packages for our build system toolchain. If you're just getting started, make sure to install the latest LTS copy of [Node.js](https://nodejs.org/en/download). Our project's philosophy on third-party packages is to keep our dependency tree as light as possible, so adding anything new to our `package.json` should have overwhelming justification. Most of the packages are just development tooling (TypeScript, Vite, ESLint, Prettier, and [Sass](https://sass-lang.com/)) that run in your terminal during the build process.
|
||||
|
||||
## npm package installed versions: `package-lock.json`
|
||||
|
||||
@@ -36,6 +34,6 @@ Specifies the exact versions of packages installed in the npm dependency tree. W
|
||||
|
||||
Basic configuration options for the TypeScript build tool to do its job in our repository.
|
||||
|
||||
## Vite configurations: `vite.config.js`
|
||||
## Vite configurations: `vite.config.ts`
|
||||
|
||||
We use the [Vite](https://vitejs.dev/) bundler/build system. This file is where we configure Vite to set up plugins (like the third-party license checker/generator). Part of the license checker plugin setup includes some functions to format web package licenses, as well as Rust package licenses provided by [cargo-about](https://github.com/EmbarkStudios/cargo-about), into a text file that's distributed with the application to provide license notices for third-party code.
|
||||
|
||||
@@ -21,11 +21,17 @@
|
||||
<noscript>JavaScript is required</noscript>
|
||||
<style>
|
||||
body {
|
||||
background: #222;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: #222;
|
||||
}
|
||||
|
||||
body::after {
|
||||
content: "";
|
||||
display: block;
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"production": "npm run setup && npm run wasm:build-production && concurrently -k -n \"VITE,RUST\" \"vite\" \"npm run wasm:watch-production\"",
|
||||
"---------- BUILDS ----------": "",
|
||||
"build-dev": "npm run wasm:build-dev && vite build",
|
||||
"build-native": "npm run native:build-dev && vite build",
|
||||
"build-profiling": "npm run wasm:build-profiling && vite build",
|
||||
"build": "npm run wasm:build-production && vite build",
|
||||
"---------- UTILITIES ----------": "",
|
||||
@@ -19,8 +20,7 @@
|
||||
"lint-fix": "eslint . --fix && tsc --noEmit",
|
||||
"---------- INTERNAL ----------": "",
|
||||
"setup": "node package-installer.js",
|
||||
"tauri:dev": "vite",
|
||||
"tauri:build": "wasm-pack build ./wasm --target=web --features=tauri",
|
||||
"native:build-dev": "wasm-pack build ./wasm --dev --target=web --features native",
|
||||
"wasm:build-dev": "wasm-pack build ./wasm --dev --target=web",
|
||||
"wasm:build-profiling": "wasm-pack build ./wasm --profiling --target=web",
|
||||
"wasm:build-production": "wasm-pack build ./wasm --release --target=web",
|
||||
|
||||
5
frontend/src-tauri/.gitignore
vendored
@@ -1,5 +0,0 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
# Generated by tauri
|
||||
gen/
|
||||
@@ -1,45 +0,0 @@
|
||||
[package]
|
||||
name = "graphite-desktop"
|
||||
version = "0.1.0"
|
||||
description = "Graphite Desktop"
|
||||
authors = ["Graphite Authors <contact@graphite.rs>"]
|
||||
license = "Apache-2.0"
|
||||
repository = ""
|
||||
default-run = "graphite-desktop"
|
||||
edition = "2021"
|
||||
rust-version = "1.79"
|
||||
|
||||
[features]
|
||||
# By default Tauri runs in production mode when `tauri dev` runs it is executed with `cargo run --no-default-features` if `devPath` is an URL
|
||||
default = ["custom-protocol", "gpu"]
|
||||
# This feature is used for production builds where `devPath` points to the filesystem
|
||||
# DO NOT remove this
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
gpu = ["graphite-editor/gpu"]
|
||||
|
||||
[dependencies]
|
||||
# Local dependencies
|
||||
graphite-editor = { path = "../../editor", features = [
|
||||
"gpu",
|
||||
"ron",
|
||||
"vello",
|
||||
"decouple-execution",
|
||||
] }
|
||||
|
||||
# Workspace dependencies
|
||||
axum = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
ron = { workspace = true }
|
||||
log = { workspace = true }
|
||||
fern = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
|
||||
# Required dependencies
|
||||
tauri = { version = "2", features = ["devtools", "wry"] }
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-http = "2"
|
||||
|
||||
[build-dependencies]
|
||||
# Required dependencies
|
||||
tauri-build = { version = "2", features = [] }
|
||||
@@ -1,9 +0,0 @@
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
// Directory required for compilation, but not tracked by git if empty.
|
||||
let dist_dir: PathBuf = ["..", "dist"].iter().collect();
|
||||
fs::create_dir_all(dist_dir).unwrap();
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"identifier": "desktop-capability",
|
||||
"platforms": ["macOS", "windows", "linux"],
|
||||
"windows": ["main"],
|
||||
"permissions": ["http:default"]
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
{
|
||||
"identifier": "migrated",
|
||||
"description": "permissions that were migrated from v1",
|
||||
"local": true,
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-create",
|
||||
"core:window:allow-center",
|
||||
"core:window:allow-request-user-attention",
|
||||
"core:window:allow-set-resizable",
|
||||
"core:window:allow-set-maximizable",
|
||||
"core:window:allow-set-minimizable",
|
||||
"core:window:allow-set-closable",
|
||||
"core:window:allow-set-title",
|
||||
"core:window:allow-maximize",
|
||||
"core:window:allow-unmaximize",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-unminimize",
|
||||
"core:window:allow-show",
|
||||
"core:window:allow-hide",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-set-decorations",
|
||||
"core:window:allow-set-always-on-top",
|
||||
"core:window:allow-set-content-protected",
|
||||
"core:window:allow-set-size",
|
||||
"core:window:allow-set-min-size",
|
||||
"core:window:allow-set-max-size",
|
||||
"core:window:allow-set-position",
|
||||
"core:window:allow-set-fullscreen",
|
||||
"core:window:allow-set-focus",
|
||||
"core:window:allow-set-icon",
|
||||
"core:window:allow-set-skip-taskbar",
|
||||
"core:window:allow-set-cursor-grab",
|
||||
"core:window:allow-set-cursor-visible",
|
||||
"core:window:allow-set-cursor-icon",
|
||||
"core:window:allow-set-cursor-position",
|
||||
"core:window:allow-set-ignore-cursor-events",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:webview:allow-print",
|
||||
"shell:allow-execute",
|
||||
"shell:allow-open",
|
||||
"http:default",
|
||||
"core:app:allow-app-show",
|
||||
"core:app:allow-app-hide",
|
||||
"shell:default",
|
||||
"http:default"
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 34 KiB |
@@ -1,84 +0,0 @@
|
||||
#![cfg_attr(all(not(debug_assertions), target_os = "windows"), windows_subsystem = "windows")]
|
||||
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
use fern::colors::{Color, ColoredLevelConfig};
|
||||
use graphite_editor::node_graph_executor::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
static NODE_RUNTIME_IO: Mutex<Option<NodeRuntimeIO>> = const { Mutex::new(None) };
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
println!("Starting server...");
|
||||
|
||||
let colors = ColoredLevelConfig::new().debug(Color::Magenta).info(Color::Green).error(Color::Red);
|
||||
|
||||
fern::Dispatch::new()
|
||||
.chain(std::io::stdout())
|
||||
.level(log::LevelFilter::Trace)
|
||||
.level_for("naga", log::LevelFilter::Error)
|
||||
.level_for("wgpu-hal", log::LevelFilter::Error)
|
||||
.level_for("wgpu_hal", log::LevelFilter::Error)
|
||||
.level_for("wgpu_core", log::LevelFilter::Error)
|
||||
.format(move |out, message, record| {
|
||||
out.finish(format_args!(
|
||||
"[{}]{} {} {}",
|
||||
// This will color the log level only, not the whole line. Just a touch.
|
||||
colors.color(record.level()),
|
||||
chrono::Utc::now().format("[%Y-%m-%d %H:%M:%S]"),
|
||||
message,
|
||||
record.module_path().unwrap_or("")
|
||||
))
|
||||
})
|
||||
.apply()
|
||||
.unwrap();
|
||||
|
||||
std::thread::spawn(|| loop {
|
||||
futures::executor::block_on(graphite_editor::node_graph_executor::run_node_graph());
|
||||
std::thread::sleep(std::time::Duration::from_millis(16))
|
||||
});
|
||||
graphite_editor::application::set_uuid_seed(0);
|
||||
|
||||
let mut runtime_lock = NODE_RUNTIME_IO.lock().unwrap();
|
||||
*runtime_lock = Some(NodeRuntimeIO::new());
|
||||
drop(runtime_lock);
|
||||
|
||||
let app = Router::new().route("/", get(|| async { "Hello, World!" }));
|
||||
|
||||
// Run it with hyper on localhost:3000
|
||||
tauri::async_runtime::spawn(async {
|
||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_http::init())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.invoke_handler(tauri::generate_handler![poll_node_graph, runtime_message])
|
||||
.setup(|_app| {
|
||||
use tauri::Manager;
|
||||
_app.get_webview_window("main").unwrap().open_devtools();
|
||||
Ok(())
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
#[tauri::command]
|
||||
fn poll_node_graph() -> String {
|
||||
let vec: Vec<_> = NODE_RUNTIME_IO.lock().as_mut().unwrap().as_mut().unwrap().receive().collect();
|
||||
ron::to_string(&vec).unwrap()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn runtime_message(message: String) -> Result<(), String> {
|
||||
let message = match ron::from_str(&message) {
|
||||
Ok(message) => message,
|
||||
Err(e) => {
|
||||
log::error!("Failed to deserialize message: {}\nwith error: {}", message, e);
|
||||
return Err("Failed to deserialize message".into());
|
||||
}
|
||||
};
|
||||
let response = NODE_RUNTIME_IO.lock().as_ref().unwrap().as_ref().unwrap().send(message);
|
||||
response
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
|
||||
"build": {
|
||||
"beforeBuildCommand": "npm run tauri:build",
|
||||
"beforeDevCommand": "npm run tauri:dev",
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://127.0.0.1:8080/"
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"category": "DeveloperTool",
|
||||
"copyright": "",
|
||||
"targets": "all",
|
||||
"externalBin": [],
|
||||
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"],
|
||||
"windows": {
|
||||
"certificateThumbprint": null,
|
||||
"digestAlgorithm": "sha256",
|
||||
"timestampUrl": ""
|
||||
},
|
||||
"longDescription": "",
|
||||
"macOS": {
|
||||
"entitlements": null,
|
||||
"exceptionDomain": "",
|
||||
"frameworks": [],
|
||||
"providerShortName": null,
|
||||
"signingIdentity": null
|
||||
},
|
||||
"resources": [],
|
||||
"shortDescription": "",
|
||||
"linux": {
|
||||
"deb": {
|
||||
"depends": ["librustc_codegen_spirv"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"productName": "Graphite",
|
||||
"mainBinaryName": "Graphite",
|
||||
"version": "0.1.0",
|
||||
"identifier": "rs.graphite.editor",
|
||||
"plugins": {},
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
{
|
||||
"decorations": false,
|
||||
"fullscreen": false,
|
||||
"height": 1080,
|
||||
"resizable": true,
|
||||
"title": "Graphite",
|
||||
"width": 1920,
|
||||
"useHttpsScheme": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
import { createLocalizationManager } from "@graphite/io-managers/localization";
|
||||
import { createPanicManager } from "@graphite/io-managers/panic";
|
||||
import { createPersistenceManager } from "@graphite/io-managers/persistence";
|
||||
import { createAppWindowState } from "@graphite/state-providers/app-window";
|
||||
import { createDialogState } from "@graphite/state-providers/dialog";
|
||||
import { createDocumentState } from "@graphite/state-providers/document";
|
||||
import { createFontsState } from "@graphite/state-providers/fonts";
|
||||
@@ -36,6 +37,8 @@
|
||||
setContext("nodeGraph", nodeGraph);
|
||||
let portfolio = createPortfolioState(editor);
|
||||
setContext("portfolio", portfolio);
|
||||
let appWindow = createAppWindowState(editor);
|
||||
setContext("appWindow", appWindow);
|
||||
|
||||
// Initialize managers, which are isolated systems that subscribe to backend messages to link them to browser API functionality (like JS events, IndexedDB, etc.)
|
||||
createClipboardManager(editor);
|
||||
@@ -58,10 +61,11 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<MainWindow />
|
||||
<MainWindow platform={$appWindow.platform} maximized={$appWindow.maximized} viewportHolePunch={$appWindow.viewportHolePunch} />
|
||||
|
||||
<style lang="scss" global>
|
||||
// Disable the spinning loading indicator
|
||||
body::before,
|
||||
body::after {
|
||||
content: none !important;
|
||||
}
|
||||
@@ -206,10 +210,16 @@
|
||||
height: 100%;
|
||||
background: var(--color-2-mildblack);
|
||||
overscroll-behavior: none;
|
||||
-webkit-user-select: none; // Required as of Safari 15.0 (Graphite's minimum version) through the latest release
|
||||
-webkit-user-select: none; // Still required by Safari as of 2025
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
// Needed for the viewport hole punch on desktop
|
||||
html:has(body > .viewport-hole-punch),
|
||||
body:has(> .viewport-hole-punch) {
|
||||
background: none;
|
||||
}
|
||||
|
||||
// The default value of `auto` from the CSS spec is a footgun with flexbox layouts:
|
||||
// https://stackoverflow.com/questions/36247140/why-dont-flex-items-shrink-past-content-size
|
||||
* {
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
UpdateMouseCursor,
|
||||
isWidgetSpanRow,
|
||||
} from "@graphite/messages";
|
||||
import type { AppWindowState } from "@graphite/state-providers/app-window";
|
||||
import type { DocumentState } from "@graphite/state-providers/document";
|
||||
import { textInputCleanup } from "@graphite/utility-functions/keyboard-entry";
|
||||
import { extractPixelData, rasterizeSVGCanvas } from "@graphite/utility-functions/rasterization";
|
||||
@@ -34,6 +35,7 @@
|
||||
let viewport: HTMLDivElement | undefined;
|
||||
|
||||
const editor = getContext<Editor>("editor");
|
||||
const appWindow = getContext<AppWindowState>("appWindow");
|
||||
const document = getContext<DocumentState>("document");
|
||||
|
||||
// Interactive text editing
|
||||
@@ -192,12 +194,25 @@
|
||||
|
||||
const placeholders = window.document.querySelectorAll("[data-viewport] [data-canvas-placeholder]");
|
||||
// Replace the placeholders with the actual canvas elements
|
||||
placeholders.forEach((placeholder) => {
|
||||
Array.from(placeholders).forEach((placeholder) => {
|
||||
const canvasName = placeholder.getAttribute("data-canvas-placeholder");
|
||||
if (!canvasName) return;
|
||||
// Get the canvas element from the global storage
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const canvas = (window as any).imageCanvases[canvasName];
|
||||
let canvas = (window as any).imageCanvases[canvasName];
|
||||
|
||||
if (canvasName !== "0" && canvas.parentElement) {
|
||||
var newCanvas = window.document.createElement("canvas");
|
||||
var context = newCanvas.getContext("2d");
|
||||
|
||||
newCanvas.width = canvas.width;
|
||||
newCanvas.height = canvas.height;
|
||||
|
||||
context?.drawImage(canvas, 0, 0);
|
||||
|
||||
canvas = newCanvas;
|
||||
}
|
||||
|
||||
placeholder.replaceWith(canvas);
|
||||
});
|
||||
}
|
||||
@@ -226,8 +241,8 @@
|
||||
`.trim();
|
||||
|
||||
if (!rasterizedCanvas) {
|
||||
rasterizedCanvas = await rasterizeSVGCanvas(svg, width * dpiFactor, height * dpiFactor, "image/png");
|
||||
rasterizedContext = rasterizedCanvas.getContext("2d") || undefined;
|
||||
rasterizedCanvas = await rasterizeSVGCanvas(svg, width * dpiFactor, height * dpiFactor);
|
||||
rasterizedContext = rasterizedCanvas.getContext("2d", { willReadFrequently: true }) || undefined;
|
||||
}
|
||||
if (!rasterizedContext) return undefined;
|
||||
|
||||
@@ -330,6 +345,7 @@
|
||||
textInput.style.lineHeight = `${displayEditableTextbox.lineHeightRatio}`;
|
||||
textInput.style.fontSize = `${displayEditableTextbox.fontSize}px`;
|
||||
textInput.style.color = displayEditableTextbox.color.toHexOptionalAlpha() || "transparent";
|
||||
textInput.style.textAlign = displayEditableTextbox.align;
|
||||
|
||||
textInput.oninput = () => {
|
||||
if (!textInput) return;
|
||||
@@ -500,13 +516,13 @@
|
||||
<RulerInput origin={rulerOrigin.x} majorMarkSpacing={rulerSpacing} numberInterval={rulerInterval} direction="Horizontal" bind:this={rulerHorizontal} />
|
||||
</LayoutRow>
|
||||
{/if}
|
||||
<LayoutRow class="viewport-container-inner">
|
||||
<LayoutRow class="viewport-container-inner-1">
|
||||
{#if rulersVisible}
|
||||
<LayoutCol class="ruler-or-scrollbar">
|
||||
<RulerInput origin={rulerOrigin.y} majorMarkSpacing={rulerSpacing} numberInterval={rulerInterval} direction="Vertical" bind:this={rulerVertical} />
|
||||
</LayoutCol>
|
||||
{/if}
|
||||
<LayoutCol class="viewport-container-inner" styles={{ cursor: canvasCursor }}>
|
||||
<LayoutCol class="viewport-container-inner-2" styles={{ cursor: canvasCursor }} data-viewport-container>
|
||||
{#if cursorEyedropper}
|
||||
<EyedropperPreview
|
||||
colorChoice={cursorEyedropperPreviewColorChoice}
|
||||
@@ -517,25 +533,27 @@
|
||||
y={cursorTop}
|
||||
/>
|
||||
{/if}
|
||||
<div class="viewport" on:pointerdown={(e) => canvasPointerDown(e)} bind:this={viewport} data-viewport>
|
||||
<svg class="artboards" style:width={canvasWidthCSS} style:height={canvasHeightCSS}>
|
||||
{@html artworkSvg}
|
||||
</svg>
|
||||
<div class="text-input" style:width={canvasWidthCSS} style:height={canvasHeightCSS} style:pointer-events={showTextInput ? "auto" : ""}>
|
||||
{#if showTextInput}
|
||||
<div bind:this={textInput} style:transform="matrix({textInputMatrix})" on:scroll={preventTextEditingScroll} />
|
||||
{/if}
|
||||
{#if !$appWindow.viewportHolePunch}
|
||||
<div class="viewport" on:pointerdown={(e) => canvasPointerDown(e)} bind:this={viewport} data-viewport>
|
||||
<svg class="artboards" style:width={canvasWidthCSS} style:height={canvasHeightCSS}>
|
||||
{@html artworkSvg}
|
||||
</svg>
|
||||
<div class="text-input" style:width={canvasWidthCSS} style:height={canvasHeightCSS} style:pointer-events={showTextInput ? "auto" : ""}>
|
||||
{#if showTextInput}
|
||||
<div bind:this={textInput} style:transform="matrix({textInputMatrix})" on:scroll={preventTextEditingScroll} />
|
||||
{/if}
|
||||
</div>
|
||||
<canvas
|
||||
class="overlays"
|
||||
width={canvasWidthScaledRoundedToEven}
|
||||
height={canvasHeightScaledRoundedToEven}
|
||||
style:width={canvasWidthCSS}
|
||||
style:height={canvasHeightCSS}
|
||||
data-overlays-canvas
|
||||
>
|
||||
</canvas>
|
||||
</div>
|
||||
<canvas
|
||||
class="overlays"
|
||||
width={canvasWidthScaledRoundedToEven}
|
||||
height={canvasHeightScaledRoundedToEven}
|
||||
style:width={canvasWidthCSS}
|
||||
style:height={canvasHeightCSS}
|
||||
data-overlays-canvas
|
||||
>
|
||||
</canvas>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="graph-view" class:open={$document.graphViewOverlayOpen} style:--fade-artwork={`${$document.fadeArtwork}%`} data-graph>
|
||||
<Graph />
|
||||
</div>
|
||||
@@ -579,7 +597,8 @@
|
||||
.control-bar {
|
||||
height: 32px;
|
||||
flex: 0 0 auto;
|
||||
margin: 0 4px;
|
||||
padding: 0 4px; // Padding (instead of margin) is needed for the viewport hole punch on desktop
|
||||
background: var(--color-3-darkgray); // Needed for the viewport hole punch on desktop
|
||||
|
||||
.spacer {
|
||||
min-width: 40px;
|
||||
@@ -618,6 +637,7 @@
|
||||
.tool-shelf {
|
||||
flex: 0 0 auto;
|
||||
justify-content: space-between;
|
||||
background: var(--color-3-darkgray); // Needed for the viewport hole punch on desktop
|
||||
|
||||
.tools {
|
||||
flex: 0 1 auto;
|
||||
@@ -699,6 +719,7 @@
|
||||
|
||||
.ruler-or-scrollbar {
|
||||
flex: 0 0 auto;
|
||||
background: var(--color-3-darkgray); // Needed for the viewport hole punch on desktop
|
||||
}
|
||||
|
||||
.ruler-corner {
|
||||
@@ -729,7 +750,8 @@
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.viewport-container-inner {
|
||||
.viewport-container-inner-1,
|
||||
.viewport-container-inner-2 {
|
||||
flex: 1 1 100%;
|
||||
position: relative;
|
||||
|
||||
@@ -761,7 +783,6 @@
|
||||
.text-input {
|
||||
word-break: break-all;
|
||||
unicode-bidi: plaintext;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.text-input div {
|
||||
@@ -776,7 +797,6 @@
|
||||
white-space: pre-wrap;
|
||||
word-break: normal;
|
||||
unicode-bidi: plaintext;
|
||||
text-align: left;
|
||||
display: inline-block;
|
||||
// Workaround to force Chrome to display the flashing text entry cursor when text is empty
|
||||
padding-left: 1px;
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
<script lang="ts" context="module">
|
||||
export type ApplicationPlatform = "Windows" | "Mac" | "Linux" | "Web";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { AppWindowPlatform } from "@graphite/messages";
|
||||
|
||||
import LayoutCol from "@graphite/components/layout/LayoutCol.svelte";
|
||||
import StatusBar from "@graphite/components/window/status-bar/StatusBar.svelte";
|
||||
import TitleBar from "@graphite/components/window/title-bar/TitleBar.svelte";
|
||||
import Workspace from "@graphite/components/window/workspace/Workspace.svelte";
|
||||
|
||||
let platform: ApplicationPlatform = "Web";
|
||||
let maximized: true;
|
||||
export let platform: AppWindowPlatform;
|
||||
export let maximized: boolean;
|
||||
export let viewportHolePunch: boolean;
|
||||
</script>
|
||||
|
||||
<LayoutCol class="main-window">
|
||||
<LayoutCol class="main-window" classes={{ "viewport-hole-punch": viewportHolePunch }}>
|
||||
<TitleBar {platform} {maximized} />
|
||||
|
||||
<Workspace />
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
<script lang="ts" context="module">
|
||||
export type Platform = "Windows" | "Mac" | "Linux" | "Web";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { getContext, onMount } from "svelte";
|
||||
|
||||
import type { Editor } from "@graphite/editor";
|
||||
import { type KeyRaw, type LayoutKeysGroup, type MenuBarEntry, type MenuListEntry, UpdateMenuBarLayout } from "@graphite/messages";
|
||||
import { type KeyRaw, type LayoutKeysGroup, type MenuBarEntry, type MenuListEntry, type AppWindowPlatform, UpdateMenuBarLayout } from "@graphite/messages";
|
||||
import type { PortfolioState } from "@graphite/state-providers/portfolio";
|
||||
import { platformIsMac } from "@graphite/utility-functions/platform";
|
||||
|
||||
@@ -17,7 +13,7 @@
|
||||
import WindowButtonsWindows from "@graphite/components/window/title-bar/WindowButtonsWindows.svelte";
|
||||
import WindowTitle from "@graphite/components/window/title-bar/WindowTitle.svelte";
|
||||
|
||||
export let platform: Platform;
|
||||
export let platform: AppWindowPlatform;
|
||||
export let maximized: boolean;
|
||||
|
||||
const editor = getContext<Editor>("editor");
|
||||
@@ -73,7 +69,7 @@
|
||||
<!-- Menu bar (or on Mac: window buttons) -->
|
||||
<LayoutRow class="left">
|
||||
{#if platform === "Mac"}
|
||||
<WindowButtonsMac {maximized} />
|
||||
<WindowButtonsMac />
|
||||
{:else}
|
||||
{#each entries as entry}
|
||||
<TextButton label={entry.label} icon={entry.icon} menuListChildren={entry.children} action={entry.action} flush={true} />
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
|
||||
import type { Editor } from "@graphite/editor";
|
||||
|
||||
import LayoutRow from "@graphite/components/layout/LayoutRow.svelte";
|
||||
|
||||
export let maximized = false;
|
||||
const editor = getContext<Editor>("editor");
|
||||
</script>
|
||||
|
||||
<LayoutRow class="window-buttons-mac">
|
||||
<div class="close" title="Close" />
|
||||
<div class="minimize" title={maximized ? "Minimize" : "Maximize"} />
|
||||
<div class="zoom" title="Zoom" />
|
||||
<div class="close" on:click={() => editor.handle.appWindowClose()} />
|
||||
<div class="minimize" on:click={() => editor.handle.appWindowMinimize()} />
|
||||
<div class="zoom" on:click={() => editor.handle.appWindowMaximize()} />
|
||||
</LayoutRow>
|
||||
|
||||
<style lang="scss" global>
|
||||
|
||||
@@ -1,23 +1,32 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
|
||||
import type { Editor } from "@graphite/editor";
|
||||
import type { FullscreenState } from "@graphite/state-providers/fullscreen";
|
||||
|
||||
import LayoutRow from "@graphite/components/layout/LayoutRow.svelte";
|
||||
import IconLabel from "@graphite/components/widgets/labels/IconLabel.svelte";
|
||||
import TextLabel from "@graphite/components/widgets/labels/TextLabel.svelte";
|
||||
|
||||
const editor = getContext<Editor>("editor");
|
||||
const fullscreen = getContext<FullscreenState>("fullscreen");
|
||||
|
||||
$: requestFullscreenHotkeys = fullscreen.keyboardLockApiSupported && !$fullscreen.keyboardLocked;
|
||||
|
||||
async function handleClick() {
|
||||
async function handleClick(e: MouseEvent) {
|
||||
// TODO: Remove this debugging option to switch from web to desktop window buttons
|
||||
if (e.ctrlKey && e.shiftKey && e.altKey) {
|
||||
editor.handle.appWindowMinimize();
|
||||
editor.handle.appWindowMinimize();
|
||||
return;
|
||||
}
|
||||
|
||||
if ($fullscreen.windowFullscreen) fullscreen.exitFullscreen();
|
||||
else fullscreen.enterFullscreen();
|
||||
}
|
||||
</script>
|
||||
|
||||
<LayoutRow class="window-buttons-web" on:click={() => handleClick()} tooltip={($fullscreen.windowFullscreen ? "Exit" : "Enter") + " Fullscreen (F11)"}>
|
||||
<LayoutRow class="window-buttons-web" on:click={handleClick} tooltip={$fullscreen.windowFullscreen ? "Exit Fullscreen (F11)" : "Enter Fullscreen (F11)"}>
|
||||
{#if requestFullscreenHotkeys}
|
||||
<TextLabel italic={true}>Go fullscreen to access all hotkeys</TextLabel>
|
||||
{/if}
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
|
||||
import type { Editor } from "@graphite/editor";
|
||||
|
||||
import LayoutRow from "@graphite/components/layout/LayoutRow.svelte";
|
||||
import IconLabel from "@graphite/components/widgets/labels/IconLabel.svelte";
|
||||
|
||||
export let maximized = false;
|
||||
export let maximized;
|
||||
|
||||
const editor = getContext<Editor>("editor");
|
||||
</script>
|
||||
|
||||
<LayoutRow class="window-button windows minimize" tooltip="Minimize">
|
||||
<LayoutRow class="window-button windows" tooltip="Minimize" on:click={() => editor.handle.appWindowMinimize()}>
|
||||
<IconLabel icon={"WindowButtonWinMinimize"} />
|
||||
</LayoutRow>
|
||||
{#if !maximized}
|
||||
<LayoutRow class="window-button windows maximize" tooltip="Maximize">
|
||||
<IconLabel icon={"WindowButtonWinMaximize"} />
|
||||
</LayoutRow>
|
||||
{:else}
|
||||
<LayoutRow class="window-button windows restore-down" tooltip="Restore Down">
|
||||
<IconLabel icon={"WindowButtonWinRestoreDown"} />
|
||||
</LayoutRow>
|
||||
{/if}
|
||||
<LayoutRow class="window-button windows close" tooltip="Close">
|
||||
<LayoutRow class="window-button windows" tooltip={maximized ? "Restore Down" : "Maximize"} on:click={() => editor.handle.appWindowMaximize()}>
|
||||
<IconLabel icon={maximized ? "WindowButtonWinRestoreDown" : "WindowButtonWinMaximize"} />
|
||||
</LayoutRow>
|
||||
<LayoutRow class="window-button windows" tooltip="Close" on:click={() => editor.handle.appWindowClose()}>
|
||||
<IconLabel icon={"WindowButtonWinClose"} />
|
||||
</LayoutRow>
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.close:hover {
|
||||
&:last-of-type:hover {
|
||||
background: #e81123;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,13 @@
|
||||
export let clickAction: ((index: number) => void) | undefined = undefined;
|
||||
export let closeAction: ((index: number) => void) | undefined = undefined;
|
||||
|
||||
let className = "";
|
||||
export { className as class };
|
||||
export let classes: Record<string, boolean> = {};
|
||||
let styleName = "";
|
||||
export { styleName as style };
|
||||
export let styles: Record<string, string | number | undefined> = {};
|
||||
|
||||
let tabElements: (LayoutRow | undefined)[] = [];
|
||||
|
||||
function platformModifiers(reservedKey: boolean): LayoutKeysGroup {
|
||||
@@ -90,7 +97,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<LayoutCol class="panel" on:pointerdown={() => panelType && editor.handle.setActivePanel(panelType)}>
|
||||
<LayoutCol on:pointerdown={() => panelType && editor.handle.setActivePanel(panelType)} class={`panel ${className}`.trim()} {classes} style={styleName} {styles}>
|
||||
<LayoutRow class="tab-bar" classes={{ "min-widths": tabMinWidths }}>
|
||||
<LayoutRow class="tab-group" scrollableX={true}>
|
||||
{#each tabLabels as tabLabel, tabIndex}
|
||||
@@ -194,6 +201,7 @@
|
||||
.tab-bar {
|
||||
height: 28px;
|
||||
min-height: auto;
|
||||
background: var(--color-1-nearblack); // Needed for the viewport hole punch on desktop
|
||||
|
||||
&.min-widths .tab-group .tab {
|
||||
min-width: 120px;
|
||||
@@ -336,5 +344,11 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Needed for the viewport hole punch on desktop
|
||||
.viewport-hole-punch &.document-panel,
|
||||
.viewport-hole-punch &.document-panel .panel-body:not(:has(.empty-panel)) {
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -137,6 +137,7 @@
|
||||
<LayoutCol class="workspace-grid-subdivision" styles={{ "flex-grow": panelSizes["content"] }} data-subdivision-name="content">
|
||||
<LayoutRow class="workspace-grid-subdivision" styles={{ "flex-grow": panelSizes["document"] }} data-subdivision-name="document">
|
||||
<Panel
|
||||
class="document-panel"
|
||||
panelType={$portfolio.documents.length > 0 ? "Document" : undefined}
|
||||
tabCloseButtons={true}
|
||||
tabMinWidths={true}
|
||||
@@ -176,8 +177,9 @@
|
||||
flex: 1 1 100%;
|
||||
|
||||
.workspace-grid-subdivision {
|
||||
min-height: 28px;
|
||||
position: relative;
|
||||
flex: 1 1 0;
|
||||
min-height: 28px;
|
||||
|
||||
&.folded {
|
||||
flex-grow: 0;
|
||||
@@ -196,5 +198,15 @@
|
||||
cursor: ew-resize;
|
||||
}
|
||||
}
|
||||
|
||||
// Needed for the viewport hole punch on desktop
|
||||
.viewport-hole-punch & .workspace-grid-subdivision:has(.panel.document-panel)::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 6px;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 0 0 calc(100vw + 100vh) var(--color-2-mildblack);
|
||||
z-index: -1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// import { panicProxy } from "@graphite/utility-functions/panic-proxy";
|
||||
import { type JsMessageType } from "@graphite/messages";
|
||||
import { createSubscriptionRouter, type SubscriptionRouter } from "@graphite/subscription-router";
|
||||
import init, { setRandomSeed, wasmMemory, EditorHandle } from "@graphite-frontend/wasm/pkg/graphite_wasm.js";
|
||||
import init, { setRandomSeed, wasmMemory, EditorHandle, receiveNativeMessage } from "@graphite-frontend/wasm/pkg/graphite_wasm.js";
|
||||
|
||||
export type Editor = {
|
||||
raw: WebAssembly.Memory;
|
||||
@@ -27,6 +27,8 @@ export async function initWasm() {
|
||||
wasmImport = await wasmMemory();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(window as any).imageCanvases = {};
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(window as any).receiveNativeMessage = receiveNativeMessage;
|
||||
|
||||
// Provide a random starter seed which must occur after initializing the WASM module, since WASM can't generate its own random numbers
|
||||
const randomSeedFloat = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);
|
||||
|
||||
@@ -36,6 +36,8 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli
|
||||
let textToolInteractiveInputElement = undefined as undefined | HTMLDivElement;
|
||||
let canvasFocused = true;
|
||||
let inPointerLock = false;
|
||||
const shakeSamples: { x: number; y: number; time: number }[] = [];
|
||||
let lastShakeTime = 0;
|
||||
|
||||
// Event listeners
|
||||
|
||||
@@ -159,6 +161,7 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -166,7 +169,7 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli
|
||||
potentiallyRestoreCanvasFocus(e);
|
||||
|
||||
const { target } = e;
|
||||
const isTargetingCanvas = target instanceof Element && target.closest("[data-viewport], [data-node-graph]");
|
||||
const isTargetingCanvas = target instanceof Element && target.closest("[data-viewport], [data-viewport-container], [data-node-graph]");
|
||||
const inDialog = target instanceof Element && target.closest("[data-dialog] [data-floating-menu-content]");
|
||||
const inContextMenu = target instanceof Element && target.closest("[data-context-menu]");
|
||||
const inTextInput = target === textToolInteractiveInputElement;
|
||||
@@ -216,7 +219,7 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli
|
||||
|
||||
// Allow only events within the viewport or node graph boundaries
|
||||
const { target } = e;
|
||||
const isTargetingCanvas = target instanceof Element && target.closest("[data-viewport], [data-node-graph]");
|
||||
const isTargetingCanvas = target instanceof Element && 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.)
|
||||
@@ -253,7 +256,7 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli
|
||||
|
||||
function onWheelScroll(e: WheelEvent) {
|
||||
const { target } = e;
|
||||
const isTargetingCanvas = target instanceof Element && target.closest("[data-viewport], [data-node-graph]");
|
||||
const isTargetingCanvas = target instanceof Element && target.closest("[data-viewport], [data-viewport-container], [data-node-graph]");
|
||||
|
||||
// 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
|
||||
@@ -331,6 +334,71 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Frontend message subscriptions
|
||||
|
||||
editor.subscriptions.subscribeJsMessage(TriggerPaste, async () => {
|
||||
@@ -434,7 +502,9 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli
|
||||
|
||||
function potentiallyRestoreCanvasFocus(e: Event) {
|
||||
const { target } = e;
|
||||
const newInCanvasArea = (target instanceof Element && target.closest("[data-viewport], [data-graph]")) instanceof Element && !targetIsTextField(window.document.activeElement || undefined);
|
||||
const newInCanvasArea =
|
||||
(target instanceof Element && target.closest("[data-viewport], [data-viewport-container], [data-graph]")) instanceof Element &&
|
||||
!targetIsTextField(window.document.activeElement || undefined);
|
||||
if (!canvasFocused && newInCanvasArea) {
|
||||
canvasFocused = true;
|
||||
app?.focus();
|
||||
|
||||
@@ -349,6 +349,21 @@ export class TriggerIndexedDbRemoveDocument extends JsMessage {
|
||||
documentId!: string;
|
||||
}
|
||||
|
||||
export type AppWindowPlatform = "Web" | "Windows" | "Mac" | "Linux";
|
||||
|
||||
export class UpdatePlatform extends JsMessage {
|
||||
@Transform(({ value }: { value: AppWindowPlatform }) => value)
|
||||
readonly platform!: AppWindowPlatform;
|
||||
}
|
||||
|
||||
export class UpdateMaximized extends JsMessage {
|
||||
readonly maximized!: boolean;
|
||||
}
|
||||
|
||||
export class UpdateViewportHolePunch extends JsMessage {
|
||||
readonly active!: boolean;
|
||||
}
|
||||
|
||||
export class UpdateInputHints extends JsMessage {
|
||||
@Type(() => HintInfo)
|
||||
readonly hintData!: HintData;
|
||||
@@ -501,7 +516,7 @@ export class Color {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 1;
|
||||
canvas.height = 1;
|
||||
const context = canvas.getContext("2d");
|
||||
const context = canvas.getContext("2d", { willReadFrequently: true });
|
||||
if (!context) return undefined;
|
||||
|
||||
context.clearRect(0, 0, 1, 1);
|
||||
@@ -776,8 +791,6 @@ export class TriggerImport extends JsMessage {}
|
||||
|
||||
export class TriggerPaste extends JsMessage {}
|
||||
|
||||
export class TriggerDelayedZoomCanvasToFitAll extends JsMessage {}
|
||||
|
||||
export class TriggerDownloadImage extends JsMessage {
|
||||
readonly svg!: string;
|
||||
|
||||
@@ -814,6 +827,8 @@ export class UpdateDocumentLayerStructureJs extends JsMessage {
|
||||
readonly dataBuffer!: DataBuffer;
|
||||
}
|
||||
|
||||
export type TextAlign = "Left" | "Center" | "Right" | "JustifyLeft";
|
||||
|
||||
export class DisplayEditableTextbox extends JsMessage {
|
||||
readonly text!: string;
|
||||
|
||||
@@ -831,6 +846,8 @@ export class DisplayEditableTextbox extends JsMessage {
|
||||
readonly maxWidth!: undefined | number;
|
||||
|
||||
readonly maxHeight!: undefined | number;
|
||||
|
||||
readonly align!: TextAlign;
|
||||
}
|
||||
|
||||
export class DisplayEditableTextboxTransform extends JsMessage {
|
||||
@@ -1630,7 +1647,6 @@ export const messageMakers: Record<string, MessageMaker> = {
|
||||
DisplayRemoveEditableTextbox,
|
||||
SendUIMetadata,
|
||||
TriggerAboutGraphiteLocalizedCommitDate,
|
||||
TriggerDelayedZoomCanvasToFitAll,
|
||||
TriggerDownloadImage,
|
||||
TriggerDownloadTextFile,
|
||||
TriggerFetchAndOpenDocument,
|
||||
@@ -1666,29 +1682,32 @@ export const messageMakers: Record<string, MessageMaker> = {
|
||||
UpdateEyedropperSamplingState,
|
||||
UpdateGraphFadeArtwork,
|
||||
UpdateGraphViewOverlay,
|
||||
UpdateSpreadsheetState,
|
||||
UpdateImportReorderIndex,
|
||||
UpdateImportsExports,
|
||||
UpdateInputHints,
|
||||
UpdateInSelectedNetwork,
|
||||
UpdateLayersPanelBottomBarLayout,
|
||||
UpdateLayersPanelControlBarLeftLayout,
|
||||
UpdateLayersPanelControlBarRightLayout,
|
||||
UpdateLayersPanelBottomBarLayout,
|
||||
UpdateLayerWidths,
|
||||
UpdateMaximized,
|
||||
UpdateMenuBarLayout,
|
||||
UpdateMouseCursor,
|
||||
UpdateNodeGraphNodes,
|
||||
UpdateVisibleNodes,
|
||||
UpdateNodeGraphWires,
|
||||
UpdateNodeGraphTransform,
|
||||
UpdateNodeGraphControlBarLayout,
|
||||
UpdateNodeGraphNodes,
|
||||
UpdateNodeGraphSelection,
|
||||
UpdateNodeGraphTransform,
|
||||
UpdateNodeGraphWires,
|
||||
UpdateNodeThumbnail,
|
||||
UpdateOpenDocumentsList,
|
||||
UpdatePlatform,
|
||||
UpdatePropertyPanelSectionsLayout,
|
||||
UpdateSpreadsheetLayout,
|
||||
UpdateSpreadsheetState,
|
||||
UpdateToolOptionsLayout,
|
||||
UpdateToolShelfLayout,
|
||||
UpdateViewportHolePunch,
|
||||
UpdateVisibleNodes,
|
||||
UpdateWirePathInProgress,
|
||||
UpdateWorkingColorsLayout,
|
||||
} as const;
|
||||
|
||||
40
frontend/src/state-providers/app-window.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/* eslint-disable max-classes-per-file */
|
||||
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
import { type Editor } from "@graphite/editor";
|
||||
import { type AppWindowPlatform, UpdatePlatform, UpdateMaximized, UpdateViewportHolePunch } from "@graphite/messages";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
||||
export function createAppWindowState(editor: Editor) {
|
||||
const { subscribe, update } = writable({
|
||||
platform: "Web" as AppWindowPlatform,
|
||||
maximized: false,
|
||||
viewportHolePunch: false,
|
||||
});
|
||||
|
||||
// Set up message subscriptions on creation
|
||||
editor.subscriptions.subscribeJsMessage(UpdatePlatform, (updatePlatform) => {
|
||||
update((state) => {
|
||||
state.platform = updatePlatform.platform;
|
||||
return state;
|
||||
});
|
||||
});
|
||||
editor.subscriptions.subscribeJsMessage(UpdateMaximized, (maximized) => {
|
||||
update((state) => {
|
||||
state.maximized = maximized.maximized;
|
||||
return state;
|
||||
});
|
||||
});
|
||||
editor.subscriptions.subscribeJsMessage(UpdateViewportHolePunch, (viewportHolePunch) => {
|
||||
update((state) => {
|
||||
state.viewportHolePunch = viewportHolePunch.active;
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
};
|
||||
}
|
||||
export type AppWindowState = ReturnType<typeof createAppWindowState>;
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
UpdateWorkingColorsLayout,
|
||||
UpdateNodeGraphControlBarLayout,
|
||||
UpdateGraphViewOverlay,
|
||||
TriggerDelayedZoomCanvasToFitAll,
|
||||
UpdateGraphFadeArtwork,
|
||||
} from "@graphite/messages";
|
||||
|
||||
@@ -94,12 +93,6 @@ export function createDocumentState(editor: Editor) {
|
||||
return state;
|
||||
});
|
||||
});
|
||||
editor.subscriptions.subscribeJsMessage(TriggerDelayedZoomCanvasToFitAll, () => {
|
||||
// TODO: This is horribly hacky
|
||||
[0, 1, 10, 50, 100, 200, 300, 400, 500].forEach((delay) => {
|
||||
setTimeout(() => editor.handle.zoomCanvasToFitAll(), delay);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
@@ -103,7 +103,6 @@ export function createPortfolioState(editor: Editor) {
|
||||
// Fail silently if there's an error rasterizing the SVG, such as a zero-sized image
|
||||
}
|
||||
});
|
||||
|
||||
editor.subscriptions.subscribeJsMessage(UpdateSpreadsheetState, async (updateSpreadsheetState) => {
|
||||
update((state) => {
|
||||
state.spreadsheetOpen = updateSpreadsheetState.open;
|
||||
@@ -111,7 +110,6 @@ export function createPortfolioState(editor: Editor) {
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
editor.subscriptions.subscribeJsMessage(UpdateSpreadsheetLayout, (updateSpreadsheetLayout) => {
|
||||
update((state) => {
|
||||
patchWidgetLayout(state.spreadsheetWidgets, updateSpreadsheetLayout);
|
||||
|
||||
@@ -32,7 +32,7 @@ export function textInputCleanup(text: string): string {
|
||||
// <https://github.com/WICG/keyboard-map/issues/26>
|
||||
// In the desktop version of VS Code, this is achieved with this Electron plugin:
|
||||
// <https://github.com/Microsoft/node-native-keymap>
|
||||
// We may be able to port that (it's a relatively small codebase) to Rust for use with Tauri.
|
||||
// We may be able to port that (it's a relatively small codebase) to Rust for use with our desktop application.
|
||||
// But on the web, just like VS Code, we're limited by the shortcomings of the spec.
|
||||
// A collection of further insights:
|
||||
// <https://docs.google.com/document/d/1p17IBbYGsZivLIMhKZOaCJFAJFokbPfKrkB37fOPXSM/edit>
|
||||
|
||||
@@ -93,7 +93,7 @@ export async function imageToCanvasContext(imageData: ImageBitmapSource): Promis
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
const context = canvas.getContext("2d");
|
||||
const context = canvas.getContext("2d", { willReadFrequently: true });
|
||||
if (!context) throw new Error("Could not create canvas context");
|
||||
context.drawImage(image, 0, 0, image.width, image.height, 0, 0, width, height);
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
name = "graphite-wasm"
|
||||
publish = false
|
||||
version = "0.0.0"
|
||||
rust-version = "1.85"
|
||||
rust-version = "1.88"
|
||||
authors = ["Graphite Authors <contact@graphite.rs>"]
|
||||
edition = "2024"
|
||||
readme = "../../README.md"
|
||||
@@ -13,7 +13,7 @@ license = "Apache-2.0"
|
||||
[features]
|
||||
default = ["gpu"]
|
||||
gpu = ["editor/gpu"]
|
||||
tauri = [ "editor/tauri"]
|
||||
native = []
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
@@ -38,6 +38,7 @@ wasm-bindgen-futures = { workspace = true }
|
||||
math-parser = { workspace = true }
|
||||
wgpu = { workspace = true }
|
||||
web-sys = { workspace = true }
|
||||
ron = { workspace = true }
|
||||
|
||||
[package.metadata.wasm-pack.profile.dev]
|
||||
wasm-opt = false
|
||||
|
||||
@@ -16,13 +16,27 @@ use editor::messages::portfolio::utility_types::Platform;
|
||||
use editor::messages::prelude::*;
|
||||
use editor::messages::tool::tool_messages::tool_prelude::WidgetId;
|
||||
use graph_craft::document::NodeId;
|
||||
use graphene_std::raster::Image;
|
||||
use graphene_std::raster::color::Color;
|
||||
use js_sys::{Object, Reflect};
|
||||
use serde::Serialize;
|
||||
use serde_wasm_bindgen::{self, from_value};
|
||||
use std::cell::RefCell;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement, ImageData, window};
|
||||
|
||||
static IMAGE_DATA_HASH: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
fn calculate_hash<T: std::hash::Hash>(t: &T) -> u64 {
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::Hasher;
|
||||
let mut hasher = DefaultHasher::new();
|
||||
t.hash(&mut hasher);
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
/// Set the random seed used by the editor by calling this from JS upon initialization.
|
||||
/// This is necessary because WASM doesn't have a random number generator.
|
||||
@@ -37,6 +51,75 @@ pub fn wasm_memory() -> JsValue {
|
||||
wasm_bindgen::memory()
|
||||
}
|
||||
|
||||
fn render_image_data_to_canvases(image_data: &[(u64, Image<Color>)]) {
|
||||
let window = match window() {
|
||||
Some(window) => window,
|
||||
None => {
|
||||
error!("Cannot render canvas: window object not found");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let document = window.document().expect("window should have a document");
|
||||
let window_obj = Object::from(window);
|
||||
let image_canvases_key = JsValue::from_str("imageCanvases");
|
||||
|
||||
let canvases_obj = match Reflect::get(&window_obj, &image_canvases_key) {
|
||||
Ok(obj) if !obj.is_undefined() && !obj.is_null() => obj,
|
||||
_ => {
|
||||
let new_obj = Object::new();
|
||||
if Reflect::set(&window_obj, &image_canvases_key, &new_obj).is_err() {
|
||||
error!("Failed to create and set imageCanvases object on window");
|
||||
return;
|
||||
}
|
||||
new_obj.into()
|
||||
}
|
||||
};
|
||||
let canvases_obj = Object::from(canvases_obj);
|
||||
|
||||
for (placeholder_id, image) in image_data.iter() {
|
||||
let canvas_name = placeholder_id.to_string();
|
||||
let js_key = JsValue::from_str(&canvas_name);
|
||||
|
||||
if Reflect::has(&canvases_obj, &js_key).unwrap_or(false) || image.width == 0 || image.height == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let canvas: HtmlCanvasElement = document
|
||||
.create_element("canvas")
|
||||
.expect("Failed to create canvas element")
|
||||
.dyn_into::<HtmlCanvasElement>()
|
||||
.expect("Failed to cast element to HtmlCanvasElement");
|
||||
|
||||
canvas.set_width(image.width);
|
||||
canvas.set_height(image.height);
|
||||
|
||||
let context: CanvasRenderingContext2d = canvas
|
||||
.get_context("2d")
|
||||
.expect("Failed to get 2d context")
|
||||
.expect("2d context was not found")
|
||||
.dyn_into::<CanvasRenderingContext2d>()
|
||||
.expect("Failed to cast context to CanvasRenderingContext2d");
|
||||
let u8_data: Vec<u8> = image.data.iter().flat_map(|color| color.to_rgba8_srgb()).collect();
|
||||
let clamped_u8_data = wasm_bindgen::Clamped(&u8_data[..]);
|
||||
match ImageData::new_with_u8_clamped_array_and_sh(clamped_u8_data, image.width, image.height) {
|
||||
Ok(image_data_obj) => {
|
||||
if context.put_image_data(&image_data_obj, 0., 0.).is_err() {
|
||||
error!("Failed to put image data on canvas for id: {placeholder_id}");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to create ImageData for id: {placeholder_id}: {e:?}");
|
||||
}
|
||||
}
|
||||
|
||||
let js_value = JsValue::from(canvas);
|
||||
|
||||
if Reflect::set(&canvases_obj, &js_key, &js_value).is_err() {
|
||||
error!("Failed to set canvas '{canvas_name}' on imageCanvases object");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
/// This struct is, via wasm-bindgen, used by JS to interact with the editor backend. It does this by calling functions, which are `impl`ed
|
||||
@@ -71,6 +154,7 @@ impl EditorHandle {
|
||||
}
|
||||
|
||||
// Sends a message to the dispatcher in the Editor Backend
|
||||
#[cfg(not(feature = "native"))]
|
||||
fn dispatch<T: Into<Message>>(&self, message: T) {
|
||||
// Process no further messages after a crash to avoid spamming the console
|
||||
if EDITOR_HAS_CRASHED.load(Ordering::SeqCst) {
|
||||
@@ -86,8 +170,29 @@ impl EditorHandle {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "native")]
|
||||
fn dispatch<T: Into<Message>>(&self, message: T) {
|
||||
let message: Message = message.into();
|
||||
let Ok(serialized_message) = ron::to_string(&message) else {
|
||||
log::error!("Failed to serialize message");
|
||||
return;
|
||||
};
|
||||
crate::native_communcation::send_message_to_cef(serialized_message)
|
||||
}
|
||||
|
||||
// Sends a FrontendMessage to JavaScript
|
||||
fn send_frontend_message_to_js(&self, mut message: FrontendMessage) {
|
||||
if let FrontendMessage::UpdateImageData { ref image_data } = message {
|
||||
let new_hash = calculate_hash(image_data);
|
||||
let prev_hash = IMAGE_DATA_HASH.load(Ordering::Relaxed);
|
||||
|
||||
if new_hash != prev_hash {
|
||||
render_image_data_to_canvases(image_data.as_slice());
|
||||
IMAGE_DATA_HASH.store(new_hash, Ordering::Relaxed);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if let FrontendMessage::UpdateDocumentLayerStructure { data_buffer } = message {
|
||||
message = FrontendMessage::UpdateDocumentLayerStructureJs { data_buffer: data_buffer.into() };
|
||||
}
|
||||
@@ -123,7 +228,7 @@ impl EditorHandle {
|
||||
_ => Platform::Unknown,
|
||||
};
|
||||
self.dispatch(GlobalsMessage::SetPlatform { platform });
|
||||
self.dispatch(Message::Init);
|
||||
self.dispatch(PortfolioMessage::Init);
|
||||
|
||||
// Poll node graph evaluation on `requestAnimationFrame`
|
||||
{
|
||||
@@ -134,22 +239,15 @@ impl EditorHandle {
|
||||
wasm_bindgen_futures::spawn_local(poll_node_graph_evaluation());
|
||||
|
||||
if !EDITOR_HAS_CRASHED.load(Ordering::SeqCst) {
|
||||
editor_and_handle(|editor, handle| {
|
||||
for message in editor.handle_message(InputPreprocessorMessage::CurrentTime {
|
||||
editor_and_handle(|_, handle| {
|
||||
handle.dispatch(InputPreprocessorMessage::CurrentTime {
|
||||
timestamp: js_sys::Date::now() as u64,
|
||||
}) {
|
||||
handle.send_frontend_message_to_js(message);
|
||||
}
|
||||
|
||||
for message in editor.handle_message(AnimationMessage::IncrementFrameCounter) {
|
||||
handle.send_frontend_message_to_js(message);
|
||||
}
|
||||
});
|
||||
handle.dispatch(AnimationMessage::IncrementFrameCounter);
|
||||
|
||||
// Used by auto-panning, but this could possibly be refactored in the future, see:
|
||||
// <https://github.com/GraphiteEditor/Graphite/pull/2562#discussion_r2041102786>
|
||||
for message in editor.handle_message(BroadcastMessage::TriggerEvent(BroadcastEvent::AnimationFrame)) {
|
||||
handle.send_frontend_message_to_js(message);
|
||||
}
|
||||
handle.dispatch(BroadcastMessage::TriggerEvent(BroadcastEvent::AnimationFrame));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -176,6 +274,27 @@ impl EditorHandle {
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimizes the application window to the taskbar or dock
|
||||
#[wasm_bindgen(js_name = appWindowMinimize)]
|
||||
pub fn app_window_minimize(&self) {
|
||||
let message = AppWindowMessage::AppWindowMinimize;
|
||||
self.dispatch(message);
|
||||
}
|
||||
|
||||
/// Toggles minimizing or restoring down the application window
|
||||
#[wasm_bindgen(js_name = appWindowMaximize)]
|
||||
pub fn app_window_maximize(&self) {
|
||||
let message = AppWindowMessage::AppWindowMaximize;
|
||||
self.dispatch(message);
|
||||
}
|
||||
|
||||
/// Closes the application window
|
||||
#[wasm_bindgen(js_name = appWindowClose)]
|
||||
pub fn app_window_close(&self) {
|
||||
let message = AppWindowMessage::AppWindowClose;
|
||||
self.dispatch(message);
|
||||
}
|
||||
|
||||
/// Displays a dialog with an error message
|
||||
#[wasm_bindgen(js_name = errorDialog)]
|
||||
pub fn error_dialog(&self, title: String, description: String) {
|
||||
@@ -384,6 +503,17 @@ impl EditorHandle {
|
||||
self.dispatch(message);
|
||||
}
|
||||
|
||||
/// Mouse shaken
|
||||
#[wasm_bindgen(js_name = onMouseShake)]
|
||||
pub fn on_mouse_shake(&self, x: f64, y: f64, mouse_keys: u8, modifiers: u8) {
|
||||
let editor_mouse_state = EditorMouseState::from_keys_and_editor_position(mouse_keys, (x, y).into());
|
||||
|
||||
let modifier_keys = ModifierKeys::from_bits(modifiers).expect("Invalid modifier keys");
|
||||
|
||||
let message = InputPreprocessorMessage::PointerShake { editor_mouse_state, modifier_keys };
|
||||
self.dispatch(message);
|
||||
}
|
||||
|
||||
/// Mouse double clicked
|
||||
#[wasm_bindgen(js_name = onDoubleClick)]
|
||||
pub fn on_double_click(&self, x: f64, y: f64, mouse_keys: u8, modifiers: u8) {
|
||||
@@ -784,7 +914,7 @@ fn editor<T: Default>(callback: impl FnOnce(&mut editor::application::Editor) ->
|
||||
}
|
||||
|
||||
/// Provides access to the `Editor` and its `EditorHandle` by calling the given closure with them as arguments.
|
||||
pub(crate) fn editor_and_handle(mut callback: impl FnMut(&mut Editor, &mut EditorHandle)) {
|
||||
pub(crate) fn editor_and_handle(callback: impl FnOnce(&mut Editor, &mut EditorHandle)) {
|
||||
EDITOR_HANDLE.with(|editor_handle| {
|
||||
editor(|editor| {
|
||||
let mut guard = editor_handle.try_lock();
|
||||
@@ -805,7 +935,7 @@ async fn poll_node_graph_evaluation() {
|
||||
return;
|
||||
}
|
||||
|
||||
if !editor::node_graph_executor::run_node_graph().await {
|
||||
if !editor::node_graph_executor::run_node_graph().await.0 {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -838,9 +968,7 @@ fn auto_save_all_documents() {
|
||||
return;
|
||||
}
|
||||
|
||||
editor_and_handle(|editor, handle| {
|
||||
for message in editor.handle_message(PortfolioMessage::AutoSaveAllDocuments) {
|
||||
handle.send_frontend_message_to_js(message);
|
||||
}
|
||||
editor_and_handle(|_, handle| {
|
||||
handle.dispatch(PortfolioMessage::AutoSaveAllDocuments);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ extern crate log;
|
||||
|
||||
pub mod editor_api;
|
||||
pub mod helpers;
|
||||
pub mod native_communcation;
|
||||
|
||||
use editor::messages::prelude::*;
|
||||
use std::panic;
|
||||
|
||||
35
frontend/wasm/src/native_communcation.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
use editor::{application::Editor, messages::prelude::FrontendMessage};
|
||||
use js_sys::{ArrayBuffer, Uint8Array};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::editor_api::{self, EditorHandle};
|
||||
|
||||
#[wasm_bindgen(js_name = "receiveNativeMessage")]
|
||||
pub fn receive_native_message(buffer: ArrayBuffer) {
|
||||
let buffer = Uint8Array::new(buffer.as_ref()).to_vec();
|
||||
match ron::from_str::<Vec<FrontendMessage>>(str::from_utf8(buffer.as_slice()).unwrap()) {
|
||||
Ok(messages) => {
|
||||
let callback = move |_: &mut Editor, handle: &mut EditorHandle| {
|
||||
for message in messages {
|
||||
handle.send_frontend_message_to_js_rust_proxy(message);
|
||||
}
|
||||
};
|
||||
editor_api::editor_and_handle(callback);
|
||||
}
|
||||
Err(e) => log::error!("Failed to deserialize frontend messages: {e:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn send_message_to_cef(message: String) {
|
||||
let global = js_sys::global();
|
||||
|
||||
// Get the function by name
|
||||
let func = js_sys::Reflect::get(&global, &JsValue::from_str("sendNativeMessage")).expect("Function not found");
|
||||
|
||||
let func = func.dyn_into::<js_sys::Function>().expect("Not a function");
|
||||
let array = Uint8Array::from(message.as_bytes());
|
||||
let buffer = array.buffer();
|
||||
|
||||
// Call it with argument
|
||||
func.call1(&JsValue::NULL, &JsValue::from(buffer)).expect("Function call failed");
|
||||
}
|
||||