Merge branch 'master' into fix-range

This commit is contained in:
mTvare
2025-08-02 17:12:36 +05:30
committed by GitHub
306 changed files with 13655 additions and 7691 deletions

View File

@@ -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.

View File

@@ -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;

View File

@@ -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",

View File

@@ -1,5 +0,0 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by tauri
gen/

View File

@@ -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 = [] }

View File

@@ -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()
}

View File

@@ -1,6 +0,0 @@
{
"identifier": "desktop-capability",
"platforms": ["macOS", "windows", "linux"],
"windows": ["main"],
"permissions": ["http:default"]
}

View File

@@ -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"
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -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
}

View File

@@ -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
}
}
}

View File

@@ -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
* {

View File

@@ -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;

View File

@@ -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 />

View File

@@ -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} />

View File

@@ -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>

View File

@@ -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}

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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);

View File

@@ -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();

View File

@@ -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;

View 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>;

View File

@@ -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,

View File

@@ -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);

View File

@@ -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>

View File

@@ -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);

View File

@@ -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

View File

@@ -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);
});
}

View File

@@ -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;

View 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");
}