Replace the Vue frontend with Svelte

This commit is contained in:
Keavon Chambers
2023-03-10 03:54:39 -08:00
parent e539e43483
commit 6e20ea538b
83 changed files with 10013 additions and 19687 deletions
+1 -1
View File
@@ -95,7 +95,7 @@ module.exports = {
"newlines-between": "always-and-inside-groups", "newlines-between": "always-and-inside-groups",
pathGroups: [ pathGroups: [
{ {
pattern: "**/*.vue", pattern: "**/*.svelte",
group: "unknown", group: "unknown",
position: "after", position: "after",
}, },
+2 -3
View File
@@ -1,4 +1,3 @@
node_modules/ node_modules/
dist/** wasm/pkg/
!dist/.git-keep-directory public/build/
pkg/
+16 -9
View File
@@ -1,34 +1,41 @@
# Overview of `/frontend/` # 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 [Vue](https://vuejs.org/) framework. The backend is written in Rust and compiled to WebAssembly (WASM) to be run in the browser alongside the JS code.
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.studio/) (probably). 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.studio/) (probably).
## Bundled assets: `assets/` ## Bundled assets: `assets/`
Icons and images that are used in components and embedded into the application bundle by the build system using [loaders](https://webpack.js.org/loaders/). Icons and images that are used in components and embedded into the application bundle by the build system using [loaders](https://webpack.js.org/loaders/).
## Public assets: `public/` ## Public assets: `public/`
Static content like favicons that are copied directly into the root of the build output by the build system. Static content like favicons that are copied directly into the root of the build output by the build system.
## Vue/TypeScript source: `src/` ## Svelte/TypeScript source: `src/`
Source code for the web app in the form of Vue components and [TypeScript](https://www.typescriptlang.org/) files.
Source code for the web app in the form of Svelte components and [TypeScript](https://www.typescriptlang.org/) files.
## WebAssembly wrapper: `wasm/` ## 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 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.js`
[ESLint](https://eslint.org/) is the tool which enforces style rules on the JS, TS, and Vue 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 Vue support, [Airbnb](https://github.com/airbnb/javascript)'s popular catalog of sane defaults, and [Prettier](https://prettier.io/)'s role as a code formatter.
[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, [Airbnb](https://github.com/airbnb/javascript)'s popular catalog of sane defaults, and [Prettier](https://prettier.io/)'s role as a code formatter.
## npm ecosystem packages: `package.json` ## 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 and then run `cd frontend && npm install` to install these packages on your system. 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, Vue CLI, 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 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 and then run `cd frontend && npm install` to install these packages on your system. 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, Webpack, ESLint, Prettier, wasm-pack, and [Sass](https://sass-lang.com/)) that run in your console during the build process.
## npm package installed versions: `package-lock.json` ## npm package installed versions: `package-lock.json`
Specifies the exact versions of packages installed in the npm dependency tree. While `package.json` specifies which packages to install and their minimum/maximum acceptable version numbers, `package-lock.json` represents the exact versions of each dependency and sub-dependency. Running `npm install` will grab these exact versions to ensure you are using the same packages as everyone else working on Graphite. `npm update` will modify `package-lock.json` to specify newer versions of any updated (sub-)dependencies and download those, as long as they don't exceed the maximum version allowed in `package.json`. To check for newer versions that exceed the max version, run `npm outdated` to see a list. Unless you know why you are doing it, try to avoid committing updates to `package-lock.json` by mistake if your code changes don't pertain to package updates. And never manually modify the file. Specifies the exact versions of packages installed in the npm dependency tree. While `package.json` specifies which packages to install and their minimum/maximum acceptable version numbers, `package-lock.json` represents the exact versions of each dependency and sub-dependency. Running `npm install` will grab these exact versions to ensure you are using the same packages as everyone else working on Graphite. `npm update` will modify `package-lock.json` to specify newer versions of any updated (sub-)dependencies and download those, as long as they don't exceed the maximum version allowed in `package.json`. To check for newer versions that exceed the max version, run `npm outdated` to see a list. Unless you know why you are doing it, try to avoid committing updates to `package-lock.json` by mistake if your code changes don't pertain to package updates. And never manually modify the file.
## TypeScript configurations: `tsconfig.json` ## TypeScript configurations: `tsconfig.json`
Basic configuration options for the TypeScript build tool to do its job in our repository. Basic configuration options for the TypeScript build tool to do its job in our repository.
## vue-svg-loader.js ## Webpack configurations: `webpack.config.js`
An extremely simple Webpack loader that allows us to `import` SVG files into our JS to be used like they are Vue components. They end up as inline SVG elements in the web page like `<svg ...>...</svg>`, rather than being `<img src="..." />` elements, which provides some benefits like being able to apply CSS styles to them. These get embedded into the bundle (they live somewhere all together in a big, messy JS file) rather than being separate static SVG files that would have to be served individually.
## Vue CLI/Webpack configurations: `vue.config.js` We use the [Webpack](https://webpack.js.org/) bundler/build system. This file is where we configure Webpack to set up plugins (like wasm-pack and license-checker) and loaders (like for Svelte and SVG files). 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.
[Vue CLI](https://cli.vuejs.org/) is a command line tool built around the [Webpack](https://webpack.js.org/) bundler/build system. This file is where we configure Webpack to set up plugins (like wasm-pack and license-checker) and loaders (like for Vue and SVG files). 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
+1693 -11433
View File
File diff suppressed because it is too large Load Diff
+35 -30
View File
@@ -5,13 +5,15 @@
"author": "Graphite Authors <contact@graphite.rs>", "author": "Graphite Authors <contact@graphite.rs>",
"scripts": { "scripts": {
"start": "npm run serve", "start": "npm run serve",
"serve": "vue-cli-service serve || (npm run print-building-help && exit 1)", "serve": "webpack serve || (npm run print-building-help && exit 1)",
"build": "vue-cli-service build || (npm run print-building-help && exit 1)", "build-dev": "webpack build || (npm run print-building-help && exit 1)",
"lint": "vue-cli-service lint || (npm run print-linting-help && exit 1)", "build-prod-unix": "NODE_ENV=production webpack build || (npm run print-building-help && exit 1)",
"lint-no-fix": "vue-cli-service lint --no-fix || (npm run print-linting-help && exit 1)", "build-prod-windows": "set NODE_ENV=production && webpack build || (npm run print-building-help && exit 1)",
"tauri:build": "npm run tauri:build-wasm && vue-cli-service tauri:build", "check": "svelte-check",
"tauri:build-wasm": "wasm-pack build wasm --release -- --features tauri", "lint": "webpack lint || (npm run print-linting-help && exit 1)",
"tauri:serve": "echo 'Make sure you build the wasm binary for tauri using `npm run tauri:build-wasm`' && npm run serve", "lint-no-fix": "webpack lint --no-fix || (npm run print-linting-help && exit 1)",
"tauri:build": "webpack tauri:build",
"tauri:serve": "webpack tauri:serve",
"print-building-help": "echo 'Graphite project failed to build. Did you remember to `npm install` the dependencies in `/frontend`?'", "print-building-help": "echo 'Graphite project failed to build. Did you remember to `npm install` the dependencies in `/frontend`?'",
"print-linting-help": "echo 'Graphite project had lint errors, or may have otherwise failed. In the latter case, did you remember to `npm install` the dependencies in `/frontend`?'" "print-linting-help": "echo 'Graphite project had lint errors, or may have otherwise failed. In the latter case, did you remember to `npm install` the dependencies in `/frontend`?'"
}, },
@@ -19,39 +21,42 @@
"@tauri-apps/api": "^1.2.0", "@tauri-apps/api": "^1.2.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"idb-keyval": "^6.2.0", "idb-keyval": "^6.2.0",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13"
"vue": "^3.2.26"
}, },
"devDependencies": { "devDependencies": {
"@typescript-eslint/eslint-plugin": "^5.43.0", "@types/license-checker-webpack-plugin": "^0.2.1",
"@typescript-eslint/parser": "^5.43.0", "@types/node": "^18.11.18",
"@vue/cli-plugin-eslint": "^5.0.8", "@types/webpack": "^5.28.0",
"@vue/cli-plugin-typescript": "^5.0.8", "@types/webpack-dev-server": "^4.7.2",
"@vue/cli-service": "^5.0.8", "@typescript-eslint/eslint-plugin": "^5.48.0",
"@vue/compiler-sfc": "^3.2.31", "@typescript-eslint/parser": "^5.48.0",
"@vue/eslint-config-airbnb": "^6.0.0",
"@vue/eslint-config-typescript": "^11.0.2",
"@wasm-tool/wasm-pack-plugin": "^1.6.0", "@wasm-tool/wasm-pack-plugin": "^1.6.0",
"eslint": "^8.27.0", "css-loader": "^6.7.3",
"eslint-config-prettier": "^8.5.0", "eslint": "^8.31.0",
"eslint-config-prettier": "^8.6.0",
"eslint-plugin-import": "^2.26.0", "eslint-plugin-import": "^2.26.0",
"eslint-plugin-prettier-vue": "^4.2.0",
"eslint-plugin-vue": "^9.7.0",
"license-checker-webpack-plugin": "^0.2.1", "license-checker-webpack-plugin": "^0.2.1",
"prettier": "^2.7.1", "prettier": "^2.8.2",
"sass": "^1.56.1", "sass": "^1.57.1",
"sass-loader": "^13.2.0", "sass-loader": "^13.2.0",
"typescript": "^4.9.3", "svelte": "^3.55.0",
"vue-cli-plugin-tauri": "~1.0.0", "svelte-check": "^3.0.1",
"vue-loader": "^17.0.1", "svelte-check-plugin": "^1.0.4",
"vue-template-compiler": "^2.7.14" "svelte-loader": "^3.1.4",
"svelte-preprocess": "^5.0.0",
"ts-loader": "^9.4.2",
"ts-node": "^10.9.1",
"typescript": "^4.9.4",
"webpack": "^5.75.0",
"webpack-cli": "^5.0.1",
"webpack-dev-server": "^4.11.1"
},
"optionalDependencies": {
"wasm-pack": "^0.10.3"
}, },
"//": "Notes about dependency issues and incompatibilities should be added here when needed.", "//": "Notes about dependency issues and incompatibilities should be added here when needed.",
"homepage": "https://graphite.rs", "homepage": "https://graphite.rs",
"license": "Apache-2.0", "license": "Apache-2.0",
"optionalDependencies": {
"wasm-pack": "^0.10.3"
},
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/GraphiteEditor/Graphite.git" "url": "git+https://github.com/GraphiteEditor/Graphite.git"
+46 -30
View File
@@ -1,6 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=0.5, maximum-scale=0.5, minimum-scale=0.5"> <meta name="viewport" content="width=device-width, initial-scale=0.5, maximum-scale=0.5, minimum-scale=0.5">
@@ -16,46 +15,63 @@
<meta name="msapplication-TileColor" content="#ffffff"> <meta name="msapplication-TileColor" content="#ffffff">
<meta name="theme-color" content="#ffffff"> <meta name="theme-color" content="#ffffff">
</head> </head>
<body> <body>
<noscript>JavaScript is required</noscript> <noscript>JavaScript is required</noscript>
<div data-app id="app" tabindex="0"></div> <style>
body {
background: #222;
height: 100%;
margin: 0;
}
body::after {
content: "";
display: block;
position: absolute;
left: 50%;
top: 50%;
width: 60px;
height: 60px;
border-radius: 50%;
border: 4px solid #eee;
border-color: #eee transparent #eee transparent;
animation: spinning-loading-indicator 1s linear infinite;
}
@keyframes spinning-loading-indicator {
0% {
transform: translate(-30px, -30px) rotate(0deg);
}
100% {
transform: translate(-30px, -30px) rotate(360deg);
}
}
</style>
<script> <script>
// Confirm the browser is compatible before initializing the application // Confirm the browser is compatible before initializing the application
// Display an error if the browser is incompatible with a required API // Display an error if the browser is incompatible with a required API
// This is run outside the JS code bundle in case unsupported features cause a syntax error when parsing the bundle, preventing the any bundle code from running // This is run outside the JS code bundle in case unsupported features cause a syntax error when parsing the bundle, preventing the any bundle code from running
let error; let incompatibility;
if (!("BigUint64Array" in window)) { if (!("BigUint64Array" in window)) {
error = ` incompatibility = `
<style> <style>
h2, p, a { text-align: center; color: white; } body::after { content: none; }
#app { display: none; } h2, p, a { text-align: center; color: #eee; font-family: "Source Sans Pro", Arial, sans-serif; }
</style> </style>
<h2>This browser is too old to run Graphite</h2> <h2>This browser is too old to run Graphite</h2>
<p>Please upgrade to a modern web browser such as the latest Firefox, Chrome, Edge, or Safari version 15 or newer.</p> <p>Please upgrade to a modern web browser such as the latest Firefox, Chrome, Edge, or Safari version 15 or newer.</p>
<p>(The <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/BigInt64Array#browser_compatibility" target="_blank"><code>BigInt64Array</code></a> <p>(The <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/BigInt64Array#browser_compatibility" target="_blank"><code>BigInt64Array</code></a>
JavaScript API must be supported by the browser for Graphite to function.)</p> JavaScript API must be supported by the browser for Graphite to function.)</p>
`.trim(); `.trim();
} }
if (error) {
document.body.innerHTML = error + document.body.innerHTML;
delete window.graphiteAppInit;
} else {
const retry = () => {
if (window.graphiteAppInit) {
const init = window.graphiteAppInit;
delete window.graphiteAppInit;
init();
}
else {
setTimeout(retry, 0);
}
};
retry();
}
// Load the editor application or display the incompatibility message
if (!incompatibility) {
import("/build/bundle.js");
} else {
document.body.innerHTML += incompatibility;
}
</script> </script>
</body> </body>
</html> </html>
+13 -324
View File
@@ -1,335 +1,24 @@
<script lang="ts"> <script lang="ts">
import { defineComponent } from "vue"; import { onMount, onDestroy } from "svelte";
import { createClipboardManager } from "@/io-managers/clipboard"; import { initWasm, createEditor } from "@/wasm-communication/editor";
import { createDragManager } from "@/io-managers/drag";
import { createHyperlinkManager } from "@/io-managers/hyperlinks";
import { createInputManager } from "@/io-managers/input";
import { createLocalizationManager } from "@/io-managers/localization";
import { createPanicManager } from "@/io-managers/panic";
import { createPersistenceManager } from "@/io-managers/persistence";
import { createDialogState, type DialogState } from "@/state-providers/dialog";
import { createDocumentState, type DocumentState } from "@/state-providers/document";
import { createFontsState, type FontsState } from "@/state-providers/fonts";
import { createFullscreenState, type FullscreenState } from "@/state-providers/fullscreen";
import { createNodeGraphState, type NodeGraphState } from "@/state-providers/node-graph";
import { createPanelsState, type PanelsState } from "@/state-providers/panels";
import { createPortfolioState, type PortfolioState } from "@/state-providers/portfolio";
import { createWorkspaceState, type WorkspaceState } from "@/state-providers/workspace";
import { operatingSystem } from "@/utility-functions/platform";
import { createEditor, type Editor } from "@/wasm-communication/editor";
import MainWindow from "@/components/window/MainWindow.vue"; import Editor from "@/components/Editor.svelte";
const managerDestructors: { let editor: ReturnType<typeof createEditor> | undefined = undefined;
createClipboardManager?: () => void;
createDragManager?: () => void;
createHyperlinkManager?: () => void;
createInputManager?: () => void;
createLocalizationManager?: () => void;
createPanicManager?: () => void;
createPersistenceManager?: () => void;
} = {};
// Vue injects don't play well with TypeScript (all injects will show up as `any`) but we can define these types as a solution onMount(async () => {
declare module "@vue/runtime-core" { await initWasm();
// Systems `provide`d by the root App to be `inject`ed into descendant components and used for reactive bindings
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
interface ComponentCustomProperties {
// Graphite WASM editor instance
editor: Editor;
// State provider systems editor = createEditor();
dialog: DialogState;
fonts: FontsState;
fullscreen: FullscreenState;
panels: PanelsState;
portfolio: PortfolioState;
workspace: WorkspaceState;
nodeGraph: NodeGraphState;
document: DocumentState;
}
}
export default defineComponent({
provide() {
return { ...this.$data };
},
data() {
const editor = createEditor();
return {
// Graphite WASM editor instance
editor,
// State provider systems
dialog: createDialogState(editor),
fonts: createFontsState(editor),
fullscreen: createFullscreenState(editor),
panels: createPanelsState(editor),
portfolio: createPortfolioState(editor),
workspace: createWorkspaceState(editor),
nodeGraph: createNodeGraphState(editor),
document: createDocumentState(editor),
};
},
mounted() {
// Initialize managers, which are isolated systems that subscribe to backend messages to link them to browser API functionality (like JS events, IndexedDB, etc.)
Object.assign(managerDestructors, {
createClipboardManager: createClipboardManager(this.editor),
createDragManager: createDragManager(),
createHyperlinkManager: createHyperlinkManager(this.editor),
createInputManager: createInputManager(this.editor, this.$el.parentElement, this.dialog, this.portfolio, this.fullscreen),
createLocalizationManager: createLocalizationManager(this.editor),
createPanicManager: createPanicManager(this.editor, this.dialog),
createPersistenceManager: createPersistenceManager(this.editor, this.portfolio),
}); });
// Initialize certain setup tasks required by the editor backend to be ready for the user now that the frontend is ready onDestroy(() => {
const platform = operatingSystem();
this.editor.instance.initAfterFrontendReady(platform);
},
beforeUnmount() {
// Call the destructor for each manager
Object.values(managerDestructors).forEach((destructor) => destructor?.());
// Destroy the WASM editor instance // Destroy the WASM editor instance
this.editor.instance.free(); editor?.instance.free();
}, });
components: { MainWindow },
});
</script> </script>
<template> {#if editor !== undefined}
<MainWindow /> <Editor {editor} />
</template> {/if}
<style lang="scss">
:root {
// Replace usage of `-rgb` variants with CSS color() function to calculate alpha when browsers support it
// See https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/color() and https://caniuse.com/css-color-function
--color-0-black: #000;
--color-0-black-rgb: 0, 0, 0;
--color-1-nearblack: #111;
--color-1-nearblack-rgb: 17, 17, 17;
--color-2-mildblack: #222;
--color-2-mildblack-rgb: 34, 34, 34;
--color-3-darkgray: #333;
--color-3-darkgray-rgb: 51, 51, 51;
--color-4-dimgray: #444;
--color-4-dimgray-rgb: 68, 68, 68;
--color-5-dullgray: #555;
--color-5-dullgray-rgb: 85, 85, 85;
--color-6-lowergray: #666;
--color-6-lowergray-rgb: 102, 102, 102;
--color-7-middlegray: #777;
--color-7-middlegray-rgb: 109, 109, 109;
--color-8-uppergray: #888;
--color-8-uppergray-rgb: 136, 136, 136;
--color-9-palegray: #999;
--color-9-palegray-rgb: 153, 153, 153;
--color-a-softgray: #aaa;
--color-a-softgray-rgb: 170, 170, 170;
--color-b-lightgray: #bbb;
--color-b-lightgray-rgb: 187, 187, 187;
--color-c-brightgray: #ccc;
--color-c-brightgray-rgb: 204, 204, 204;
--color-d-mildwhite: #ddd;
--color-d-mildwhite-rgb: 221, 221, 221;
--color-e-nearwhite: #eee;
--color-e-nearwhite-rgb: 238, 238, 238;
--color-f-white: #fff;
--color-f-white-rgb: 255, 255, 255;
--color-data-general: #c5c5c5;
--color-data-general-dim: #767676;
--color-data-vector: #65bbe5;
--color-data-vector-dim: #4b778c;
--color-data-raster: #e4bb72;
--color-data-raster-dim: #8b7752;
--color-data-mask: #8d85c7;
--color-data-number: #d6536e;
--color-data-number-dim: #803242;
--color-data-vec2: #cc00ff;
--color-data-vec2-dim: #71008d;
--color-data-color: #70a898;
--color-data-color-dim: #43645b;
--color-none: white;
--color-none-repeat: no-repeat;
--color-none-position: center center;
// 24px tall, 48px wide
--color-none-size-24px: 60px 24px;
--color-none-image-24px: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 24"><line stroke="red" stroke-width="4px" x1="0" y1="27" x2="60" y2="-3" /></svg>');
// 32px tall, 64px wide
--color-none-size-32px: 80px 32px;
--color-none-image-32px: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 32"><line stroke="red" stroke-width="4px" x1="0" y1="36" x2="80" y2="-4" /></svg>');
--color-transparent-checkered-background: linear-gradient(45deg, #cccccc 25%, transparent 25%, transparent 75%, #cccccc 75%),
linear-gradient(45deg, #cccccc 25%, transparent 25%, transparent 75%, #cccccc 75%), linear-gradient(#ffffff, #ffffff);
--color-transparent-checkered-background-size: 16px 16px;
--color-transparent-checkered-background-position: 0 0, 8px 8px;
--icon-expand-collapse-arrow: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8"><polygon fill="%23eee" points="3,0 1,0 5,4 1,8 3,8 7,4" /></svg>');
--icon-expand-collapse-arrow-hover: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8"><polygon fill="%23fff" points="3,0 1,0 5,4 1,8 3,8 7,4" /></svg>');
}
html,
body,
#app {
margin: 0;
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
user-select: 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
* {
min-width: 0;
min-height: 0;
}
html,
body,
input,
textarea,
button {
font-family: "Source Sans Pro", Arial, sans-serif;
font-weight: 400;
font-size: 14px;
line-height: 1;
color: var(--color-e-nearwhite);
}
svg,
img {
display: block;
}
.sharp-right-corners.sharp-right-corners.sharp-right-corners.sharp-right-corners {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.layout-row,
.layout-col {
.scrollable-x,
.scrollable-y {
// Firefox (standardized in CSS, but less capable)
scrollbar-width: thin;
scrollbar-width: 6px;
scrollbar-gutter: 6px;
scrollbar-color: var(--color-5-dullgray) transparent;
&:not(:hover) {
scrollbar-width: none;
}
// WebKit (only in Chromium/Safari but more capable)
&::-webkit-scrollbar {
width: calc(2px + 6px + 2px);
height: calc(2px + 6px + 2px);
}
&:not(:hover)::-webkit-scrollbar {
width: 0;
height: 0;
}
&::-webkit-scrollbar-track {
box-shadow: inset 0 0 0 1px var(--color-5-dullgray);
border: 2px solid transparent;
border-radius: 10px;
&:hover {
box-shadow: inset 0 0 0 1px var(--color-6-lowergray);
}
}
&::-webkit-scrollbar-thumb {
background-clip: padding-box;
background-color: var(--color-5-dullgray);
border: 2px solid transparent;
border-radius: 10px;
margin: 2px;
&:hover {
background-color: var(--color-6-lowergray);
}
}
&::-webkit-scrollbar-corner {
background: none;
}
}
.scrollable-x.scrollable-y {
// Standard
overflow: auto;
// WebKit
overflow: overlay;
}
.scrollable-x:not(.scrollable-y) {
// Standard
overflow: auto hidden;
// WebKit
overflow-x: overlay;
}
.scrollable-y:not(.scrollable-x) {
// Standard
overflow: hidden auto;
// WebKit
overflow-y: overlay;
}
}
// List of all elements that should show an outline when focused by tabbing or by clicking the element
.dropdown-input .dropdown-box,
.font-input .dropdown-box {
&:focus {
outline: 1px dashed var(--color-e-nearwhite);
outline-offset: -1px;
}
}
// List of all elements that should show an outline when focused by tabbing, but not by clicking the element
.icon-button,
.text-button,
.popover-button,
.color-input > button,
.color-picker .preset-color,
.swatch-pair .swatch > button,
.radio-input button,
.menu-list,
.menu-bar-input .entry,
.layer-tree .expand-arrow,
.widget-section .header {
&:focus-visible {
outline: 1px dashed var(--color-e-nearwhite);
outline-offset: -1px;
}
// Variant: dark outline over light colors
&.preset-color.white,
&.text-button.emphasized {
&:focus-visible {
outline: 1px dashed var(--color-2-mildblack);
}
}
}
// Checkbox needs to apply the focus outline to its sibling label
.checkbox-input input:focus-visible + label {
outline: 1px dashed var(--color-e-nearwhite);
outline-offset: -1px;
// Variant: dark outline over light colors
&.checked {
outline: 1px dashed var(--color-2-mildblack);
}
}
</style>
+16 -12
View File
@@ -1,26 +1,26 @@
# Overview of `/frontend/src/` # Overview of `/frontend/src/`
## Vue components: `components/` ## Svelte components: `components/`
Vue components that build the Graphite editor GUI, which are mounted in `App.vue`. These are Vue SFCs (single-file components) which each contain a Vue-templated HTML section, an SCSS (Stylus CSS) section, and a script section. The aim is to avoid implementing much editor business logic here, just enough to make things interactive and communicate to the backend where the real business logic should occur. Svelte components that build the Graphite editor GUI, which are mounted in `App.svelte`. These each contain a Svelte-templated HTML section, an SCSS (Stylus CSS) section, and a script section. The aim is to avoid implementing much editor business logic here, just enough to make things interactive and communicate to the backend where the real business logic should occur.
## I/O managers: `io-managers/` ## I/O managers: `io-managers/`
TypeScript files which manage the input/output of browser APIs and link this functionality with the editor backend. These files subscribe to backend events to execute JS APIs, and in response to these APIs or user interactions, they may call functions into the backend (defined in `/frontend/wasm/editor_api.rs`). TypeScript files which manage the input/output of browser APIs and link this functionality with the editor backend. These files subscribe to backend events to execute JS APIs, and in response to these APIs or user interactions, they may call functions into the backend (defined in `/frontend/wasm/editor_api.rs`).
Each I/O manager is a self-contained module where one instance is created in `App.vue` when it's mounted to the DOM at app startup. Each I/O manager is a self-contained module where one instance is created in `Editor.svelte` when it's mounted to the DOM at app startup.
During development when HMR (hot-module replacement) occurs, these are also unmounted to clean up after themselves, so they can be mounted again with the updated code. Therefore, any side-effects that these managers cause (e.g. adding event listeners to the page) need a destructor function that cleans them up. The destructor function, when applicable, is returned by the module and automatically called in `App.vue` on unmount. During development when HMR (hot-module replacement) occurs, these are also unmounted to clean up after themselves, so they can be mounted again with the updated code. Therefore, any side-effects that these managers cause (e.g. adding event listeners to the page) need a destructor function that cleans them up. The destructor function, when applicable, is returned by the module and automatically called in `Editor.svelte` on unmount.
## State providers: `state-providers/` ## State providers: `state-providers/`
TypeScript files which provide reactive state and importable functions to Vue components. Each module defines a Vue reactive state object `const state = reactive({ ... });` and exports this from the module in the returned object as the key-value pair `state: readonly(state) as typeof state,` using Vue's `readonly()` wrapper. Other functions may also be defined in the module and exported after `state`, which provide a way for Vue components to call functions to manipulate the state. TypeScript files which provide reactive state and importable functions to Svelte components. Each module defines a Svelte writable store `const { subscribe, update } = writable({ .. });` and exports the `subscribe` method from the module in the returned object. Other functions may also be defined in the module and exported after `subscribe`, which provide a way for Svelte components to call functions to manipulate the state.
In `App.vue`, an instance of each of these are given to Vue's [`provide()`](https://vuejs.org/api/application.html#app-provide) function. This allows any component to access the state provider instance by specifying it in its `inject: [...]` array. The state is accessed in a component with `this.stateProviderName.state.someReactiveVariable` and any exposed functions are accessed with `this.stateProviderName.state.someExposedVariable()`. They can also be used in the Vue HTML template (sans the `this.` prefix). In `Editor.svelte`, an instance of each of these are given to Svelte's [`setContext()`](https://svelte.dev/docs#run-time-svelte-setcontext) function. This allows any component to access the state provider instance using `const exampleStateProvider = getContext<ExampleStateProvider>("exampleStateProvider");`.
## _I/O managers vs. state providers_ ## _I/O managers vs. state providers_
_Some state providers, similarly to I/O managers, may subscribe to backend events, call functions from `editor_api.rs` into the backend, and interact with browser APIs and user input. The difference is that state providers are meant to be `inject`ed by components to use them for reactive state, while I/O managers are meant to be self-contained systems that operate for the lifetime of the application and aren't touched by Vue components._ _Some state providers, similarly to I/O managers, may subscribe to backend events, call functions from `editor_api.rs` into the backend, and interact with browser APIs and user input. The difference is that state providers are meant to be made available to components via `getContext()` to use them for reactive state, while I/O managers are meant to be self-contained systems that operate for the lifetime of the application and aren't touched by Svelte components._
## Utility functions: `utility-functions/` ## Utility functions: `utility-functions/`
@@ -34,7 +34,7 @@ TypeScript files which serve as the JS interface to the WASM bindings for the ed
Instantiates the WASM and editor backend instances. The function `initWasm()` asynchronously constructs and initializes an instance of the WASM bindings JS module provided by wasm-bindgen/wasm-pack. The function `createEditor()` constructs an instance of the editor backend. In theory there could be multiple editor instances sharing the same WASM module instance. The function returns an object where `raw` is the WASM module, `instance` is the editor, and `subscriptions` is the subscription router (described below). Instantiates the WASM and editor backend instances. The function `initWasm()` asynchronously constructs and initializes an instance of the WASM bindings JS module provided by wasm-bindgen/wasm-pack. The function `createEditor()` constructs an instance of the editor backend. In theory there could be multiple editor instances sharing the same WASM module instance. The function returns an object where `raw` is the WASM module, `instance` is the editor, and `subscriptions` is the subscription router (described below).
`initWasm()` occurs in `main.ts` right before the Vue application exists, then `createEditor()` is run in `App.vue` during the Vue app's creation. Similarly to the state providers described above, the editor is `provide`d so other components can `inject` it and call functions on `this.editor.raw`, `this.editor.instance`, or `this.editor.subscriptions`. `initWasm()` occurs in `main.ts` right before the Svelte application exists, then `createEditor()` is run in `Editor.svelte` during the Svelte app's creation. Similarly to the state providers described above, the editor is given via `setContext()` so other components can get it via `getContext` and call functions on `editor.raw`, `editor.instance`, or `editor.subscriptions`.
### Message definitions: `messages.ts` ### Message definitions: `messages.ts`
@@ -44,10 +44,14 @@ Defines the message formats and data types received from the backend. Since Rust
Associates messages from the backend with subscribers in the frontend, and routes messages to subscriber callbacks. This module provides a `subscribeJsMessage(messageType, callback)` function which JS code throughout the frontend can call to be registered as the exclusive handler for a chosen message type. This file's other exported function, `handleJsMessage(messageType, messageData, wasm, instance)`, is called in `editor.ts` by the associated editor instance when the backend sends a `FrontendMessage`. When this occurs, the subscription router delivers the message to the subscriber for given `messageType` by executing its registered `callback` function. As an argument to the function, it provides the `messageData` payload transformed into its TypeScript-friendly format defined in `messages.ts`. Associates messages from the backend with subscribers in the frontend, and routes messages to subscriber callbacks. This module provides a `subscribeJsMessage(messageType, callback)` function which JS code throughout the frontend can call to be registered as the exclusive handler for a chosen message type. This file's other exported function, `handleJsMessage(messageType, messageData, wasm, instance)`, is called in `editor.ts` by the associated editor instance when the backend sends a `FrontendMessage`. When this occurs, the subscription router delivers the message to the subscriber for given `messageType` by executing its registered `callback` function. As an argument to the function, it provides the `messageData` payload transformed into its TypeScript-friendly format defined in `messages.ts`.
## Vue app: `App.vue` ## Svelte app entry point: `App.svelte`
The entry point for the Vue application. This is where we define global CSS style rules, create/destroy the editor instance, construct/destruct the I/O managers, and construct and provide the state providers. The entry point for the Svelte application.
## Entry point: `main.ts` ## Editor base instance: `Editor.svelte`
The entry point for the entire project's code bundle. Here we simply initialize the WASM module with `await initWasm();` then initialize the Vue application with `createApp(App).mount("#app");`. This is where we define global CSS style rules, create/destroy the editor instance, construct/destruct the I/O managers, and construct and `setContext()` the state providers.
## JS bundle entry point: `main.ts`
The entry point for the entire project's code bundle. Here we simply initialize the Svelte application with `export default new App({ target: document.body });`.
+300
View File
@@ -0,0 +1,300 @@
<script lang="ts">
import { onMount, onDestroy, setContext } from "svelte";
import { type createEditor } from "@/wasm-communication/editor";
import { operatingSystem } from "@/utility-functions/platform";
import { createClipboardManager } from "@/io-managers/clipboard";
import { createDragManager } from "@/io-managers/drag";
import { createHyperlinkManager } from "@/io-managers/hyperlinks";
import { createInputManager } from "@/io-managers/input";
import { createLocalizationManager } from "@/io-managers/localization";
import { createPanicManager } from "@/io-managers/panic";
import { createPersistenceManager } from "@/io-managers/persistence";
import { createDialogState } from "@/state-providers/dialog";
import { createDocumentState } from "@/state-providers/document";
import { createFontsState } from "@/state-providers/fonts";
import { createFullscreenState } from "@/state-providers/fullscreen";
import { createNodeGraphState } from "@/state-providers/node-graph";
import { createPortfolioState } from "@/state-providers/portfolio";
import { createWorkspaceState } from "@/state-providers/workspace";
import MainWindow from "@/components/window/MainWindow.svelte";
// Graphite WASM editor instance
export let editor: ReturnType<typeof createEditor>;
setContext("editor", editor);
// State provider systems
let dialog = createDialogState(editor);
setContext("dialog", dialog);
let document = createDocumentState(editor);
setContext("document", document);
let fonts = createFontsState(editor);
setContext("fonts", fonts);
let fullscreen = createFullscreenState(editor);
setContext("fullscreen", fullscreen);
let nodeGraph = createNodeGraphState(editor);
setContext("nodeGraph", nodeGraph);
let portfolio = createPortfolioState(editor);
setContext("portfolio", portfolio);
let workspace = createWorkspaceState(editor);
setContext("workspace", workspace);
// 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);
createHyperlinkManager(editor);
createLocalizationManager(editor);
createPanicManager(editor, dialog);
createPersistenceManager(editor, portfolio);
let dragManagerDestructor = createDragManager();
let inputManagerDestructor = createInputManager(editor, dialog, portfolio, fullscreen);
onMount(() => {
// Initialize certain setup tasks required by the editor backend to be ready for the user now that the frontend is ready
editor.instance.initAfterFrontendReady(operatingSystem());
});
onDestroy(() => {
// Call the destructor for each manager
dragManagerDestructor();
inputManagerDestructor();
});
</script>
<MainWindow />
<style lang="scss" global>
// Disable the spinning loading indicator
body::after {
content: none !important;
}
:root {
// Replace usage of `-rgb` variants with CSS color() function to calculate alpha when browsers support it
// See https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/color() and https://caniuse.com/css-color-function
--color-0-black: #000;
--color-0-black-rgb: 0, 0, 0;
--color-1-nearblack: #111;
--color-1-nearblack-rgb: 17, 17, 17;
--color-2-mildblack: #222;
--color-2-mildblack-rgb: 34, 34, 34;
--color-3-darkgray: #333;
--color-3-darkgray-rgb: 51, 51, 51;
--color-4-dimgray: #444;
--color-4-dimgray-rgb: 68, 68, 68;
--color-5-dullgray: #555;
--color-5-dullgray-rgb: 85, 85, 85;
--color-6-lowergray: #666;
--color-6-lowergray-rgb: 102, 102, 102;
--color-7-middlegray: #777;
--color-7-middlegray-rgb: 109, 109, 109;
--color-8-uppergray: #888;
--color-8-uppergray-rgb: 136, 136, 136;
--color-9-palegray: #999;
--color-9-palegray-rgb: 153, 153, 153;
--color-a-softgray: #aaa;
--color-a-softgray-rgb: 170, 170, 170;
--color-b-lightgray: #bbb;
--color-b-lightgray-rgb: 187, 187, 187;
--color-c-brightgray: #ccc;
--color-c-brightgray-rgb: 204, 204, 204;
--color-d-mildwhite: #ddd;
--color-d-mildwhite-rgb: 221, 221, 221;
--color-e-nearwhite: #eee;
--color-e-nearwhite-rgb: 238, 238, 238;
--color-f-white: #fff;
--color-f-white-rgb: 255, 255, 255;
--color-data-general: #c5c5c5;
--color-data-general-dim: #767676;
--color-data-vector: #65bbe5;
--color-data-vector-dim: #4b778c;
--color-data-raster: #e4bb72;
--color-data-raster-dim: #8b7752;
--color-data-mask: #8d85c7;
--color-data-number: #d6536e;
--color-data-number-dim: #803242;
--color-data-vec2: #cc00ff;
--color-data-vec2-dim: #71008d;
--color-data-color: #70a898;
--color-data-color-dim: #43645b;
--color-none: white;
--color-none-repeat: no-repeat;
--color-none-position: center center;
// 24px tall, 48px wide
--color-none-size-24px: 60px 24px;
--color-none-image-24px: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 24"><line stroke="red" stroke-width="4px" x1="0" y1="27" x2="60" y2="-3" /></svg>');
// 32px tall, 64px wide
--color-none-size-32px: 80px 32px;
--color-none-image-32px: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 32"><line stroke="red" stroke-width="4px" x1="0" y1="36" x2="80" y2="-4" /></svg>');
--color-transparent-checkered-background: linear-gradient(45deg, #cccccc 25%, transparent 25%, transparent 75%, #cccccc 75%),
linear-gradient(45deg, #cccccc 25%, transparent 25%, transparent 75%, #cccccc 75%), linear-gradient(#ffffff, #ffffff);
--color-transparent-checkered-background-size: 16px 16px;
--color-transparent-checkered-background-position: 0 0, 8px 8px;
--icon-expand-collapse-arrow: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8"><polygon fill="%23eee" points="3,0 1,0 5,4 1,8 3,8 7,4" /></svg>');
--icon-expand-collapse-arrow-hover: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8"><polygon fill="%23fff" points="3,0 1,0 5,4 1,8 3,8 7,4" /></svg>');
}
html,
body {
margin: 0;
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
user-select: 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
* {
min-width: 0;
min-height: 0;
}
html,
body,
input,
textarea,
button {
font-family: "Source Sans Pro", Arial, sans-serif;
font-weight: 400;
font-size: 14px;
line-height: 1;
color: var(--color-e-nearwhite);
}
svg,
img {
display: block;
}
.sharp-right-corners.sharp-right-corners.sharp-right-corners.sharp-right-corners {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.layout-row,
.layout-col {
.scrollable-x,
.scrollable-y {
// Firefox (standardized in CSS, but less capable)
scrollbar-width: thin;
scrollbar-width: 6px;
scrollbar-gutter: 6px;
scrollbar-color: var(--color-5-dullgray) transparent;
&:not(:hover) {
scrollbar-width: none;
}
// WebKit (only in Chromium/Safari but more capable)
&::-webkit-scrollbar {
width: calc(2px + 6px + 2px);
height: calc(2px + 6px + 2px);
}
&:not(:hover)::-webkit-scrollbar {
width: 0;
height: 0;
}
&::-webkit-scrollbar-track {
box-shadow: inset 0 0 0 1px var(--color-5-dullgray);
border: 2px solid transparent;
border-radius: 10px;
&:hover {
box-shadow: inset 0 0 0 1px var(--color-6-lowergray);
}
}
&::-webkit-scrollbar-thumb {
background-clip: padding-box;
background-color: var(--color-5-dullgray);
border: 2px solid transparent;
border-radius: 10px;
margin: 2px;
&:hover {
background-color: var(--color-6-lowergray);
}
}
&::-webkit-scrollbar-corner {
background: none;
}
}
.scrollable-x.scrollable-y {
// Standard
overflow: auto;
// WebKit
overflow: overlay;
}
.scrollable-x:not(.scrollable-y) {
// Standard
overflow: auto hidden;
// WebKit
overflow-x: overlay;
}
.scrollable-y:not(.scrollable-x) {
// Standard
overflow: hidden auto;
// WebKit
overflow-y: overlay;
}
}
// List of all elements that should show an outline when focused by tabbing or by clicking the element
.dropdown-input .dropdown-box,
.font-input .dropdown-box {
&:focus {
outline: 1px dashed var(--color-e-nearwhite);
outline-offset: -1px;
}
}
// List of all elements that should show an outline when focused by tabbing, but not by clicking the element
.icon-button,
.text-button,
.popover-button,
.color-input > button,
.color-picker .preset-color,
.swatch-pair .swatch > button,
.radio-input button,
.menu-list,
.menu-bar-input .entry,
.layer-tree .expand-arrow,
.widget-section .header {
&:focus-visible {
outline: 1px dashed var(--color-e-nearwhite);
outline-offset: -1px;
}
// Variant: dark outline over light colors
&.preset-color.white,
&.text-button.emphasized {
&:focus-visible {
outline: 1px dashed var(--color-2-mildblack);
}
}
}
// Checkbox needs to apply the focus outline to its sibling label
.optional-input input:focus-visible + label,
.checkbox-input input:focus-visible + label {
outline: 1px dashed var(--color-e-nearwhite);
outline-offset: -1px;
}
// Variant: dark outline over light colors (when the checkbox is checked)
:not(.optional-input) > .checkbox-input input:focus-visible + label.checked {
outline: 1px dashed var(--color-2-mildblack);
}
</style>
+35 -22
View File
@@ -1,47 +1,60 @@
# Overview of `/frontend/src/components/` # Overview of `/frontend-svelte/src/components/`
Each component represents a (usually reusable) part of the Graphite Editor GUI. These all get mounted within the Vue entry point, `App.vue`, in the `/src` directory above this one.
Each component represents a (usually reusable) part of the Graphite Editor GUI. These all get mounted in `Editor.svelte` (in the `/src` directory above this one).
## Floating Menus: `floating-menus/` ## Floating Menus: `floating-menus/`
The temporary UI areas with dark backgrounds which hover over the top of the editor window content. Examples include popovers, dropdown menu selectors, and dialog modals. The temporary UI areas with dark backgrounds which hover over the top of the editor window content. Examples include popovers, dropdown menu selectors, and dialog modals.
## Layout: `layout/` ## Layout: `layout/`
Useful containers that control the flow of content held within. Useful containers that control the flow of content held within.
## Panels: `panels/` ## Panels: `panels/`
The dockable tabbed regions like the Document, Properties, Layer Tree, and Node Graph panels. The dockable tabbed regions like the Document, Properties, Layer Tree, and Node Graph panels.
## Widgets: `widgets/` ## Widgets: `widgets/`
The interactive input items used to display information and provide user control. The interactive input items used to display information and provide user control.
## Window: `window/` ## Window: `window/`
The building blocks for the Title Bar, Workspace, and Status Bar within an editor application window. The building blocks for the Title Bar, Workspace, and Status Bar within an editor application window.
# Vue tips and tricks # Vue tips and tricks
This section contains a growing list of quick reference information for helpful Vue solutions and best practices. Feel free to add to this to help contributors learn things, or yourself remember tricks you'll likely forget in a few months. This section contains a growing list of quick reference information for helpful Vue solutions and best practices. Feel free to add to this to help contributors learn things, or yourself remember tricks you'll likely forget in a few months.
## Bi-directional props ## Bi-directional props
The component declares this:
```ts
export default defineComponent({
emits: ["update:theBidirectionalProperty"],
props: {
theBidirectionalProperty: { type: Number as PropType<number>, required: false },
},
watch: {
// Called only when `theBidirectionalProperty` is changed from outside this component (with v-model)
theBidirectionalProperty(newSelectedIndex: number | undefined) {
}, The component declares this:
},
methods: { ```ts
doSomething() { // The dispatcher that sends the changed value as a custom event to the parent
this.$emit("update:theBidirectionalProperty", SOME_NEW_VALUE); const dispatch = createEventDispatcher<{ theBidirectionalProperty: number }>();
},
}, // The prop
}); export let theBidirectionalProperty: number;
// Called only when `theBidirectionalProperty` is changed from outside this component via its props
$: console.log(theBidirectionalProperty);
// Example of a method that would update the value
function doSomething() {
dispatch("theBidirectionalProperty", SOME_NEW_VALUE);
},
``` ```
Users of the component do this for `theCorrespondingDataEntry` to be a two-way binding: Users of the component do this for `theCorrespondingDataEntry` to be a two-way binding:
```html
<DropdownInput v-model:theBidirectionalProperty="theCorrespondingDataEntry" /> ```ts
let theCorrespondingDataEntry = 42;
```
```svelte
<DropdownInput
theBidirectionalProperty={theCorrespondingDataEntry}
on:theBidirectionalProperty={({ detail }) => { theCorrespondingDataEntry = detail; }}
/>
``` ```
@@ -1,23 +1,24 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, type PropType } from "vue"; import { onDestroy, createEventDispatcher, getContext } from "svelte";
import { clamp } from "@/utility-functions/math"; import { clamp } from "@/utility-functions/math";
import type { HSV, RGB } from "@/wasm-communication/messages"; import { type HSV, type RGB } from "@/wasm-communication/messages";
import { Color } from "@/wasm-communication/messages"; import { Color } from "@/wasm-communication/messages";
import FloatingMenu, { type MenuDirection } from "@/components/layout/FloatingMenu.vue"; import FloatingMenu, { type MenuDirection } from "@/components/layout/FloatingMenu.svelte";
import LayoutCol from "@/components/layout/LayoutCol.vue"; import LayoutCol from "@/components/layout/LayoutCol.svelte";
import LayoutRow from "@/components/layout/LayoutRow.vue"; import LayoutRow from "@/components/layout/LayoutRow.svelte";
import IconButton from "@/components/widgets/buttons/IconButton.vue"; import IconButton from "@/components/widgets/buttons/IconButton.svelte";
import DropdownInput from "@/components/widgets/inputs/DropdownInput.vue"; import DropdownInput from "@/components/widgets/inputs/DropdownInput.svelte";
import NumberInput from "@/components/widgets/inputs/NumberInput.vue"; import NumberInput from "@/components/widgets/inputs/NumberInput.svelte";
import TextInput from "@/components/widgets/inputs/TextInput.vue"; import TextInput from "@/components/widgets/inputs/TextInput.svelte";
import Separator from "@/components/widgets/labels/Separator.vue"; import Separator from "@/components/widgets/labels/Separator.svelte";
import TextLabel from "@/components/widgets/labels/TextLabel.vue"; import TextLabel from "@/components/widgets/labels/TextLabel.svelte";
import { type Editor } from "@/wasm-communication/editor";
type PresetColors = "none" | "black" | "white" | "red" | "yellow" | "green" | "cyan" | "blue" | "magenta"; type PresetColors = "none" | "black" | "white" | "red" | "yellow" | "green" | "cyan" | "blue" | "magenta";
const PURE_COLORS: Record<PresetColors, [number, number, number]> = { const PURE_COLORS: Record<PresetColors, [number, number, number]> = {
none: [0, 0, 0], none: [0, 0, 0],
black: [0, 0, 0], black: [0, 0, 0],
white: [1, 1, 1], white: [1, 1, 1],
@@ -27,379 +28,388 @@ const PURE_COLORS: Record<PresetColors, [number, number, number]> = {
cyan: [0, 1, 1], cyan: [0, 1, 1],
blue: [0, 0, 1], blue: [0, 0, 1],
magenta: [1, 0, 1], magenta: [1, 0, 1],
}; };
const COLOR_SPACE_CHOICES = [[{ label: "sRGB" }]];
const COLOR_SPACE_CHOICES = [[{ label: "sRGB" }]]; const editor = getContext<Editor>("editor");
export default defineComponent({ // emits: ["update:color", "update:open"],
inject: ["editor"], const dispatch = createEventDispatcher<{ color: Color }>();
emits: ["update:color", "update:open"],
props: { export let color: Color;
color: { type: Object as PropType<Color>, required: true }, export let allowNone = false;
allowNone: { type: Boolean as PropType<boolean>, default: false }, // export let allowTransparency = false; // TODO: Implement this
allowTransparency: { type: Boolean as PropType<boolean>, default: false }, // TODO: Implement this export let direction: MenuDirection = "Bottom";
direction: { type: String as PropType<MenuDirection>, default: "Bottom" }, // TODO: See if this should be made to follow the pattern of DropdownInput.svelte so this could be removed
// TODO: See if this should be made to follow the pattern of DropdownInput.vue so this could be removed export let open: boolean;
open: { type: Boolean as PropType<boolean>, required: true },
}, const hsvaOrNone = color.toHSVA();
data() {
const hsvaOrNone = this.color.toHSVA();
const hsva = hsvaOrNone || { h: 0, s: 0, v: 0, a: 1 }; const hsva = hsvaOrNone || { h: 0, s: 0, v: 0, a: 1 };
return { // New color components
hue: hsva.h, let hue = hsva.h;
saturation: hsva.s, let saturation = hsva.s;
value: hsva.v, let value = hsva.v;
alpha: hsva.a, let alpha = hsva.a;
isNone: hsvaOrNone === undefined, let isNone = hsvaOrNone === undefined;
initialHue: hsva.h, // Initial color components
initialSaturation: hsva.s, let initialHue = hsva.h;
initialValue: hsva.v, let initialSaturation = hsva.s;
initialAlpha: hsva.a, let initialValue = hsva.v;
initialIsNone: hsvaOrNone === undefined, let initialAlpha = hsva.a;
draggingPickerTrack: undefined as HTMLDivElement | undefined, let initialIsNone = hsvaOrNone === undefined;
colorSpaceChoices: COLOR_SPACE_CHOICES, // Transient state
strayCloses: true, let draggingPickerTrack: HTMLDivElement | undefined = undefined;
}; let strayCloses = true;
},
computed: { $: rgbChannels = Object.entries(newColor.toRgb255() || { r: undefined, g: undefined, b: undefined }) as [keyof RGB, number | undefined][];
opaqueHueColor(): Color { $: hsvChannels = Object.entries(!isNone ? { h: hue * 360, s: saturation * 100, v: value * 100 } : { h: undefined, s: undefined, v: undefined }) as [keyof HSV, number | undefined][];
return new Color({ h: this.hue, s: 1, v: 1, a: 1 }); $: opaqueHueColor = new Color({ h: hue, s: 1, v: 1, a: 1 });
}, $: newColor = isNone ? new Color("none") : new Color({ h: hue, s: saturation, v: value, a: alpha });
newColor(): Color { $: initialColor = updateInitialColor(initialHue, initialSaturation, initialValue, initialAlpha, initialIsNone, open);
if (this.isNone) return new Color("none");
return new Color({ h: this.hue, s: this.saturation, v: this.value, a: this.alpha }); $: watchOpen(open);
}, $: watchColor(color);
initialColor(): Color {
if (this.initialIsNone) return new Color("none"); // Taking `_open` is necessary to make Svelte order the reactive processing queue so this works as required, see:
return new Color({ h: this.initialHue, s: this.initialSaturation, v: this.initialValue, a: this.initialAlpha }); // https://stackoverflow.com/questions/63934543/svelte-reactivity-not-triggering-when-variable-changed-in-a-function
}, function updateInitialColor(h: number, s: number, v: number, a: number, initialIsNone: boolean, _open: boolean) {
black(): Color { if (initialIsNone) return new Color("none");
return new Color(0, 0, 0, 1); return new Color({ h, s, v, a });
}, }
},
watch: { function watchOpen(open: boolean) {
// Called only when `open` is changed from outside this component (with v-model) if (!open) setInitialHSVA(hue, saturation, value, alpha, isNone);
open(isOpen: boolean) { }
if (isOpen) this.setInitialHSVA(this.hue, this.saturation, this.value, this.alpha, this.isNone);
}, function watchColor(color: Color) {
// Called only when `color` is changed from outside this component (with v-model)
color(color: Color) {
const hsva = color.toHSVA(); const hsva = color.toHSVA();
if (hsva !== undefined) { if (hsva === undefined) {
setNewHSVA(0, 0, 0, 1, true);
return;
}
// Update the hue, but only if it is necessary so we don't: // Update the hue, but only if it is necessary so we don't:
// - ...jump the user's hue from 360° (top) to the equivalent 0° (bottom) // - ...jump the user's hue from 360° (top) to the equivalent 0° (bottom)
// - ...reset the hue to 0° if the color is fully desaturated, where all hues are equivalent // - ...reset the hue to 0° if the color is fully desaturated, where all hues are equivalent
// - ...reset the hue to 0° if the color's value is black, where all hues are equivalent // - ...reset the hue to 0° if the color's value is black, where all hues are equivalent
if (!(hsva.h === 0 && this.hue === 1) && hsva.s > 0 && hsva.v > 0) this.hue = hsva.h; if (!(hsva.h === 0 && hue === 1) && hsva.s > 0 && hsva.v > 0) hue = hsva.h;
// Update the saturation, but only if it is necessary so we don't: // Update the saturation, but only if it is necessary so we don't:
// - ...reset the saturation to the left is the color's value is black along the bottom edge, where all saturations are equivalent // - ...reset the saturation to the left is the color's value is black along the bottom edge, where all saturations are equivalent
if (hsva.v !== 0) this.saturation = hsva.s; if (hsva.v !== 0) saturation = hsva.s;
// Update the value // Update the value
this.value = hsva.v; value = hsva.v;
// Update the alpha // Update the alpha
this.alpha = hsva.a; alpha = hsva.a;
// Update the status of this not being a color // Update the status of this not being a color
this.isNone = false; isNone = false;
} else {
this.setNewHSVA(0, 0, 0, 1, true);
} }
},
}, function onPointerDown(e: PointerEvent) {
methods: {
onPointerDown(e: PointerEvent) {
const target = (e.target || undefined) as HTMLElement | undefined; const target = (e.target || undefined) as HTMLElement | undefined;
this.draggingPickerTrack = target?.closest("[data-saturation-value-picker], [data-hue-picker], [data-alpha-picker]") || undefined; draggingPickerTrack = target?.closest("[data-saturation-value-picker], [data-hue-picker], [data-alpha-picker]") || undefined;
this.addEvents(); addEvents();
this.onPointerMove(e); onPointerMove(e);
},
onPointerMove(e: PointerEvent) {
// Just in case the mouseup event is lost
if (e.buttons === 0) this.removeEvents();
if (this.draggingPickerTrack?.hasAttribute("data-saturation-value-picker")) {
const rectangle = this.draggingPickerTrack.getBoundingClientRect();
this.saturation = clamp((e.clientX - rectangle.left) / rectangle.width, 0, 1);
this.value = clamp(1 - (e.clientY - rectangle.top) / rectangle.height, 0, 1);
this.strayCloses = false;
} else if (this.draggingPickerTrack?.hasAttribute("data-hue-picker")) {
const rectangle = this.draggingPickerTrack.getBoundingClientRect();
this.hue = clamp(1 - (e.clientY - rectangle.top) / rectangle.height, 0, 1);
this.strayCloses = false;
} else if (this.draggingPickerTrack?.hasAttribute("data-alpha-picker")) {
const rectangle = this.draggingPickerTrack.getBoundingClientRect();
this.alpha = clamp(1 - (e.clientY - rectangle.top) / rectangle.height, 0, 1);
this.strayCloses = false;
} }
const color = new Color({ h: this.hue, s: this.saturation, v: this.value, a: this.alpha }); function onPointerMove(e: PointerEvent) {
this.setColor(color); // Just in case the mouseup event is lost
}, if (e.buttons === 0) removeEvents();
onPointerUp() {
this.removeEvents();
},
addEvents() {
document.addEventListener("pointermove", this.onPointerMove);
document.addEventListener("pointerup", this.onPointerUp);
},
removeEvents() {
this.draggingPickerTrack = undefined;
this.strayCloses = true;
document.removeEventListener("pointermove", this.onPointerMove); if (draggingPickerTrack?.hasAttribute("data-saturation-value-picker")) {
document.removeEventListener("pointerup", this.onPointerUp); const rectangle = draggingPickerTrack.getBoundingClientRect();
},
emitOpenState(isOpen: boolean) {
this.$emit("update:open", isOpen);
},
setColor(color?: Color) {
const colorToEmit = color || new Color({ h: this.hue, s: this.saturation, v: this.value, a: this.alpha });
this.$emit("update:color", colorToEmit);
},
swapNewWithInitial() {
const initial = this.initialColor;
const tempHue = this.hue; saturation = clamp((e.clientX - rectangle.left) / rectangle.width, 0, 1);
const tempSaturation = this.saturation; value = clamp(1 - (e.clientY - rectangle.top) / rectangle.height, 0, 1);
const tempValue = this.value; strayCloses = false;
const tempAlpha = this.alpha; } else if (draggingPickerTrack?.hasAttribute("data-hue-picker")) {
const tempIsNone = this.isNone; const rectangle = draggingPickerTrack.getBoundingClientRect();
this.setNewHSVA(this.initialHue, this.initialSaturation, this.initialValue, this.initialAlpha, this.initialIsNone); hue = clamp(1 - (e.clientY - rectangle.top) / rectangle.height, 0, 1);
this.setInitialHSVA(tempHue, tempSaturation, tempValue, tempAlpha, tempIsNone); strayCloses = false;
} else if (draggingPickerTrack?.hasAttribute("data-alpha-picker")) {
const rectangle = draggingPickerTrack.getBoundingClientRect();
this.setColor(initial); alpha = clamp(1 - (e.clientY - rectangle.top) / rectangle.height, 0, 1);
}, strayCloses = false;
setColorCode(colorCode: string) { }
const color = new Color({ h: hue, s: saturation, v: value, a: alpha });
setColor(color);
}
function onPointerUp() {
removeEvents();
}
function addEvents() {
document.addEventListener("pointermove", onPointerMove);
document.addEventListener("pointerup", onPointerUp);
}
function removeEvents() {
draggingPickerTrack = undefined;
strayCloses = true;
document.removeEventListener("pointermove", onPointerMove);
document.removeEventListener("pointerup", onPointerUp);
}
function setColor(color?: Color) {
const colorToEmit = color || new Color({ h: hue, s: saturation, v: value, a: alpha });
dispatch("color", colorToEmit);
}
function swapNewWithInitial() {
const initial = initialColor;
const tempHue = hue;
const tempSaturation = saturation;
const tempValue = value;
const tempAlpha = alpha;
const tempIsNone = isNone;
setNewHSVA(initialHue, initialSaturation, initialValue, initialAlpha, initialIsNone);
setInitialHSVA(tempHue, tempSaturation, tempValue, tempAlpha, tempIsNone);
setColor(initial);
}
function setColorCode(colorCode: string) {
const color = Color.fromCSS(colorCode); const color = Color.fromCSS(colorCode);
if (color) this.setColor(color); if (color) setColor(color);
}, }
setColorRGB(channel: keyof RGB, strength: number) {
if (channel === "r") this.setColor(new Color(strength / 255, this.newColor.green, this.newColor.blue, this.newColor.alpha));
else if (channel === "g") this.setColor(new Color(this.newColor.red, strength / 255, this.newColor.blue, this.newColor.alpha));
else if (channel === "b") this.setColor(new Color(this.newColor.red, this.newColor.green, strength / 255, this.newColor.alpha));
},
setColorHSV(channel: keyof HSV, strength: number) {
if (channel === "h") this.hue = strength / 360;
else if (channel === "s") this.saturation = strength / 100;
else if (channel === "v") this.value = strength / 100;
this.setColor(); function setColorRGB(channel: keyof RGB, strength: number | undefined) {
}, // Do nothing if the given value is undefined
setColorAlphaPercent(alpha: number) { if (strength === undefined) undefined;
this.alpha = alpha / 100; // Set the specified channel to the given value
this.setColor(); else if (channel === "r") setColor(new Color(strength / 255, newColor.green, newColor.blue, newColor.alpha));
}, else if (channel === "g") setColor(new Color(newColor.red, strength / 255, newColor.blue, newColor.alpha));
setColorPresetSubtile(e: MouseEvent) { else if (channel === "b") setColor(new Color(newColor.red, newColor.green, strength / 255, newColor.alpha));
}
function setColorHSV(channel: keyof HSV, strength: number | undefined) {
// Do nothing if the given value is undefined
if (strength === undefined) undefined;
// Set the specified channel to the given value
else if (channel === "h") hue = strength / 360;
else if (channel === "s") saturation = strength / 100;
else if (channel === "v") value = strength / 100;
setColor();
}
function setColorAlphaPercent(strength: number | undefined) {
if (strength !== undefined) alpha = strength / 100;
setColor();
}
function setColorPresetSubtile(e: MouseEvent) {
const clickedTile = e.target as HTMLDivElement | undefined; const clickedTile = e.target as HTMLDivElement | undefined;
const tileColor = clickedTile?.getAttribute("data-pure-tile") || undefined; const tileColor = clickedTile?.getAttribute("data-pure-tile") || undefined;
if (tileColor) this.setColorPreset(tileColor as PresetColors); if (tileColor) setColorPreset(tileColor as PresetColors);
}, }
setColorPreset(preset: PresetColors) {
function setColorPreset(preset: PresetColors) {
if (preset === "none") { if (preset === "none") {
this.setNewHSVA(0, 0, 0, 1, true); setNewHSVA(0, 0, 0, 1, true);
this.setColor(new Color("none")); setColor(new Color("none"));
return; return;
} }
const presetColor = new Color(...PURE_COLORS[preset], 1); const presetColor = new Color(...PURE_COLORS[preset], 1);
const hsva = presetColor.toHSVA() || { h: 0, s: 0, v: 0, a: 0 }; const hsva = presetColor.toHSVA() || { h: 0, s: 0, v: 0, a: 0 };
this.setNewHSVA(hsva.h, hsva.s, hsva.v, hsva.a, false); setNewHSVA(hsva.h, hsva.s, hsva.v, hsva.a, false);
this.setColor(presetColor); setColor(presetColor);
}, }
setNewHSVA(hue: number, saturation: number, value: number, alpha: number, isNone: boolean) {
this.hue = hue; function setNewHSVA(h: number, s: number, v: number, a: number, none: boolean) {
this.saturation = saturation; hue = h;
this.value = value; saturation = s;
this.alpha = alpha; value = v;
this.isNone = isNone; alpha = a;
}, isNone = none;
setInitialHSVA(hue: number, saturation: number, value: number, alpha: number, isNone: boolean) { }
this.initialHue = hue;
this.initialSaturation = saturation; function setInitialHSVA(h: number, s: number, v: number, a: number, none: boolean) {
this.initialValue = value; initialHue = h;
this.initialAlpha = alpha; initialSaturation = s;
this.initialIsNone = isNone; initialValue = v;
}, initialAlpha = a;
async activateEyedropperSample() { initialIsNone = none;
}
async function activateEyedropperSample() {
// TODO: Replace this temporary solution that only works in Chromium-based browsers with the custom color sampler used by the Eyedropper tool // TODO: Replace this temporary solution that only works in Chromium-based browsers with the custom color sampler used by the Eyedropper tool
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
if (!(window as any).EyeDropper) { if (!(window as any).EyeDropper) {
this.editor.instance.eyedropperSampleForColorPicker(); editor.instance.eyedropperSampleForColorPicker();
return; return;
} }
try { try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await new (window as any).EyeDropper().open(); const result = await new (window as any).EyeDropper().open();
this.setColorCode(result.sRGBHex); setColorCode(result.sRGBHex);
} catch { } catch {
// Do nothing // Do nothing
} }
}, }
},
unmounted() { onDestroy(() => {
this.removeEvents(); removeEvents();
}, });
components: {
DropdownInput,
FloatingMenu,
IconButton,
LayoutCol,
LayoutRow,
NumberInput,
Separator,
TextInput,
TextLabel,
},
});
</script> </script>
<template> <FloatingMenu class="color-picker" {open} on:open {strayCloses} {direction} type="Popover">
<FloatingMenu class="color-picker" :open="open" @update:open="(isOpen) => emitOpenState(isOpen)" :strayCloses="strayCloses" :direction="direction" :type="'Popover'">
<LayoutRow <LayoutRow
:style="{ styles={{
'--new-color': newColor.toHexOptionalAlpha(), "--new-color": newColor.toHexOptionalAlpha(),
'--new-color-contrasting': newColor.contrastingColor(), "--new-color-contrasting": newColor.contrastingColor(),
'--initial-color': initialColor.toHexOptionalAlpha(), "--initial-color": initialColor.toHexOptionalAlpha(),
'--initial-color-contrasting': initialColor.contrastingColor(), "--initial-color-contrasting": initialColor.contrastingColor(),
'--hue-color': opaqueHueColor.toRgbCSS(), "--hue-color": opaqueHueColor.toRgbCSS(),
'--hue-color-contrasting': opaqueHueColor.contrastingColor(), "--hue-color-contrasting": opaqueHueColor.contrastingColor(),
'--opaque-color': (newColor.opaque() || black).toHexNoAlpha(), "--opaque-color": (newColor.opaque() || new Color(0, 0, 0, 1)).toHexNoAlpha(),
'--opaque-color-contrasting': (newColor.opaque() || black).contrastingColor(), "--opaque-color-contrasting": (newColor.opaque() || new Color(0, 0, 0, 1)).contrastingColor(),
}" }}
> >
<LayoutCol class="saturation-value-picker" @pointerdown="(e: PointerEvent) => onPointerDown(e)" data-saturation-value-picker> <LayoutCol class="saturation-value-picker" on:pointerdown={onPointerDown} data-saturation-value-picker>
<div class="selection-circle" :style="{ top: `${(1 - value) * 100}%`, left: `${saturation * 100}%` }" v-if="!isNone"></div> {#if !isNone}
<div class="selection-circle" style:top={`${(1 - value) * 100}%`} style:left={`${saturation * 100}%`} />
{/if}
</LayoutCol> </LayoutCol>
<LayoutCol class="hue-picker" @pointerdown="(e: PointerEvent) => onPointerDown(e)" data-hue-picker> <LayoutCol class="hue-picker" on:pointerdown={onPointerDown} data-hue-picker>
<div class="selection-pincers" :style="{ top: `${(1 - hue) * 100}%` }" v-if="!isNone"></div> {#if !isNone}
<div class="selection-pincers" style:top={`${(1 - hue) * 100}%`} />
{/if}
</LayoutCol> </LayoutCol>
<LayoutCol class="alpha-picker" @pointerdown="(e: PointerEvent) => onPointerDown(e)" data-alpha-picker> <LayoutCol class="alpha-picker" on:pointerdown={onPointerDown} data-alpha-picker>
<div class="selection-pincers" :style="{ top: `${(1 - alpha) * 100}%` }" v-if="!isNone"></div> {#if !isNone}
<div class="selection-pincers" style:top={`${(1 - alpha) * 100}%`} />
{/if}
</LayoutCol> </LayoutCol>
<LayoutCol class="details"> <LayoutCol class="details">
<LayoutRow <LayoutRow class="choice-preview" on:click={swapNewWithInitial} tooltip="Comparison views of the present color choice (left) and the color before any change (right). Click to swap sides.">
class="choice-preview" <LayoutCol class="new-color" classes={{ none: isNone }}>
@click="() => swapNewWithInitial()"
:tooltip="'Comparison views of the present color choice (left) and the color before any change (right). Click to swap sides.'"
>
<LayoutCol class="new-color" :class="{ none: isNone }">
<TextLabel>New</TextLabel> <TextLabel>New</TextLabel>
</LayoutCol> </LayoutCol>
<LayoutCol class="initial-color" :class="{ none: initialIsNone }"> <LayoutCol class="initial-color" classes={{ none: initialIsNone }}>
<TextLabel>Initial</TextLabel> <TextLabel>Initial</TextLabel>
</LayoutCol> </LayoutCol>
</LayoutRow> </LayoutRow>
<DropdownInput :entries="colorSpaceChoices" :selectedIndex="0" :disabled="true" :tooltip="'Color Space and HDR (coming soon)'" /> <DropdownInput entries={COLOR_SPACE_CHOICES} selectedIndex={0} disabled={true} tooltip="Color Space and HDR (coming soon)" />
<LayoutRow> <LayoutRow>
<TextLabel :tooltip="'Color code in hexadecimal format'">Hex</TextLabel> <TextLabel tooltip="Color code in hexadecimal format">Hex</TextLabel>
<Separator /> <Separator />
<LayoutRow> <LayoutRow>
<TextInput <TextInput
:value="newColor.toHexOptionalAlpha() || '-'" value={newColor.toHexOptionalAlpha() || "-"}
@commitText="(value: string) => setColorCode(value)" on:commitText={({ detail }) => setColorCode(detail)}
:centered="true" centered={true}
:tooltip="'Color code in hexadecimal format. 6 digits if opaque, 8 with alpha.\nAccepts input of CSS color values including named colors.'" tooltip={"Color code in hexadecimal format. 6 digits if opaque, 8 with alpha.\nAccepts input of CSS color values including named colors."}
/> />
</LayoutRow> </LayoutRow>
</LayoutRow> </LayoutRow>
<LayoutRow> <LayoutRow>
<TextLabel :tooltip="'Red/Green/Blue channels of the color, integers 0–255'">RGB</TextLabel> <TextLabel tooltip="Red/Green/Blue channels of the color, integers 0–255">RGB</TextLabel>
<Separator /> <Separator />
<LayoutRow> <LayoutRow>
<template v-for="([channel, strength], index) in Object.entries(newColor.toRgb255() || { r: undefined, g: undefined, b: undefined })" :key="channel"> {#each rgbChannels as [channel, strength], index (channel)}
<Separator :type="'Related'" v-if="index > 0" /> {#if index > 0}
<Separator type="Related" />
{/if}
<NumberInput <NumberInput
:value="strength" value={strength}
@update:value="(value: number) => setColorRGB(channel as keyof RGB, value)" on:value={({ detail }) => {
:min="0" strength = detail;
:max="255" setColorRGB(channel, detail);
:centered="true" }}
:minWidth="56" min={0}
:tooltip="`${{ r: 'Red', g: 'Green', b: 'Blue' }[channel]} channel, integers 0–255`" max={255}
minWidth={56}
tooltip={`${{ r: "Red", g: "Green", b: "Blue" }[channel]} channel, integers 0–255`}
/> />
</template> {/each}
</LayoutRow> </LayoutRow>
</LayoutRow> </LayoutRow>
<LayoutRow> <LayoutRow>
<TextLabel :tooltip="'Hue/Saturation/Value, also known as Hue/Saturation/Brightness (HSB).\nNot to be confused with Hue/Saturation/Lightness (HSL), a different color model.'" <TextLabel tooltip={"Hue/Saturation/Value, also known as Hue/Saturation/Brightness (HSB).\nNot to be confused with Hue/Saturation/Lightness (HSL), a different color model."}
>HSV</TextLabel >HSV</TextLabel
> >
<Separator /> <Separator />
<LayoutRow> <LayoutRow>
<template {#each hsvChannels as [channel, strength], index (channel)}
v-for="([channel, strength], index) in !isNone {#if index > 0}
? Object.entries({ h: hue * 360, s: saturation * 100, v: value * 100 }) <Separator type="Related" />
: Object.entries({ h: undefined, s: undefined, v: undefined })" {/if}
:key="channel"
>
<Separator :type="'Related'" v-if="index > 0" />
<NumberInput <NumberInput
:value="strength" value={strength}
@update:value="(value: number) => setColorHSV(channel as keyof HSV, value)" on:value={({ detail }) => {
:min="0" strength = detail;
:max="channel === 'h' ? 360 : 100" setColorHSV(channel, detail);
:unit="channel === 'h' ? '°' : '%'" }}
:centered="true" min={0}
:minWidth="56" max={channel === "h" ? 360 : 100}
:tooltip=" unit={channel === "h" ? "°" : "%"}
{ minWidth={56}
h: 'Hue component, the &quot;color&quot; along the rainbow', tooltip={{
s: 'Saturation component, the &quot;colorfulness&quot; from gray to vivid', h: `Hue component, the "color" along the rainbow`,
v: 'Value (or Brightness), the distance away from being darkened to black', s: `Saturation component, the "colorfulness" from gray to vivid`,
}[channel] v: "Value (or Brightness), the distance away from being darkened to black",
" }[channel]}
/> />
</template> {/each}
</LayoutRow> </LayoutRow>
</LayoutRow> </LayoutRow>
<NumberInput <NumberInput
:label="'Alpha'" label="Alpha"
:value="!isNone ? alpha * 100 : undefined" value={!isNone ? alpha * 100 : undefined}
@update:value="(value: number) => setColorAlphaPercent(value)" on:value={({ detail }) => {
:min="0" if (detail !== undefined) alpha = detail / 100;
:max="100" setColorAlphaPercent(detail);
:rangeMin="0" }}
:rangeMax="100" min={0}
:unit="'%'" max={100}
:mode="'Range'" rangeMin={0}
:tooltip="`Scale from transparent (0%) to opaque (100%) for the color's alpha channel`" rangeMax={100}
unit="%"
mode="Range"
tooltip={`Scale from transparent (0%) to opaque (100%) for the color's alpha channel`}
/> />
<LayoutRow class="leftover-space"></LayoutRow> <LayoutRow class="leftover-space" />
<LayoutRow> <LayoutRow>
<button class="preset-color none" @click="() => setColorPreset('none')" v-if="allowNone" title="Set none" tabindex="0"></button> {#if allowNone}
<Separator :type="'Related'" v-if="allowNone" /> <button class="preset-color none" on:click={() => setColorPreset("none")} title="Set none" tabindex="0" />
<button class="preset-color black" @click="() => setColorPreset('black')" title="Set black" tabindex="0"></button> <Separator type="Related" />
<Separator :type="'Related'" /> {/if}
<button class="preset-color white" @click="() => setColorPreset('white')" title="Set white" tabindex="0"></button> <button class="preset-color black" on:click={() => setColorPreset("black")} title="Set black" tabindex="0" />
<Separator :type="'Related'" /> <Separator type="Related" />
<button class="preset-color pure" @click="(e: MouseEvent) => setColorPresetSubtile(e)" tabindex="-1"> <button class="preset-color white" on:click={() => setColorPreset("white")} title="Set white" tabindex="0" />
<div data-pure-tile="red" style="--pure-color: #ff0000; --pure-color-gray: #4c4c4c" title="Set red"></div> <Separator type="Related" />
<div data-pure-tile="yellow" style="--pure-color: #ffff00; --pure-color-gray: #e3e3e3" title="Set yellow"></div> <button class="preset-color pure" on:click={setColorPresetSubtile} tabindex="-1">
<div data-pure-tile="green" style="--pure-color: #00ff00; --pure-color-gray: #969696" title="Set green"></div> <div data-pure-tile="red" style="--pure-color: #ff0000; --pure-color-gray: #4c4c4c" title="Set red" />
<div data-pure-tile="cyan" style="--pure-color: #00ffff; --pure-color-gray: #b2b2b2" title="Set cyan"></div> <div data-pure-tile="yellow" style="--pure-color: #ffff00; --pure-color-gray: #e3e3e3" title="Set yellow" />
<div data-pure-tile="blue" style="--pure-color: #0000ff; --pure-color-gray: #1c1c1c" title="Set blue"></div> <div data-pure-tile="green" style="--pure-color: #00ff00; --pure-color-gray: #969696" title="Set green" />
<div data-pure-tile="magenta" style="--pure-color: #ff00ff; --pure-color-gray: #696969" title="Set magenta"></div> <div data-pure-tile="cyan" style="--pure-color: #00ffff; --pure-color-gray: #b2b2b2" title="Set cyan" />
<div data-pure-tile="blue" style="--pure-color: #0000ff; --pure-color-gray: #1c1c1c" title="Set blue" />
<div data-pure-tile="magenta" style="--pure-color: #ff00ff; --pure-color-gray: #696969" title="Set magenta" />
</button> </button>
<Separator :type="'Related'" /> <Separator type="Related" />
<IconButton :icon="'Eyedropper'" :size="24" :action="() => activateEyedropperSample()" :tooltip="'Sample a pixel color from the document'" /> <IconButton icon="Eyedropper" size={24} action={activateEyedropperSample} tooltip="Sample a pixel color from the document" />
</LayoutRow> </LayoutRow>
</LayoutCol> </LayoutCol>
</LayoutRow> </LayoutRow>
</FloatingMenu> </FloatingMenu>
</template>
<style lang="scss"> <style lang="scss" global>
.color-picker { .color-picker {
.saturation-value-picker { .saturation-value-picker {
width: 256px; width: 256px;
background-blend-mode: multiply; background-blend-mode: multiply;
@@ -617,5 +627,5 @@ export default defineComponent({
} }
} }
} }
} }
</style> </style>
@@ -1,56 +1,52 @@
<script lang="ts"> <script lang="ts">
import { defineComponent } from "vue"; import { getContext, onMount } from "svelte";
import FloatingMenu from "@/components/layout/FloatingMenu.vue"; import FloatingMenu from "@/components/layout/FloatingMenu.svelte";
import LayoutCol from "@/components/layout/LayoutCol.vue"; import LayoutCol from "@/components/layout/LayoutCol.svelte";
import LayoutRow from "@/components/layout/LayoutRow.vue"; import LayoutRow from "@/components/layout/LayoutRow.svelte";
import TextButton from "@/components/widgets/buttons/TextButton.vue"; import TextButton from "@/components/widgets/buttons/TextButton.svelte";
import IconLabel from "@/components/widgets/labels/IconLabel.vue"; import IconLabel from "@/components/widgets/labels/IconLabel.svelte";
import WidgetLayout from "@/components/widgets/WidgetLayout.vue"; import WidgetLayout from "@/components/widgets/WidgetLayout.svelte";
import { type DialogState } from "@/state-providers/dialog";
export default defineComponent({ const dialog = getContext<DialogState>("dialog");
inject: ["dialog"],
methods: { let self: FloatingMenu;
dismiss() {
this.dialog.dismissDialog(); export function dismiss() {
}, dialog.dismissDialog();
}, }
mounted() {
onMount(() => {
// Focus the first button in the popup // Focus the first button in the popup
const dialogModal: HTMLDivElement | undefined = this.$el; const emphasizedOrFirstButton = (self.div().querySelector("[data-emphasized]") || self.div().querySelector("[data-text-button]") || undefined) as HTMLButtonElement | undefined;
const emphasizedOrFirstButton = (dialogModal?.querySelector("[data-emphasized]") || dialogModal?.querySelector("[data-text-button]") || undefined) as HTMLButtonElement | undefined;
emphasizedOrFirstButton?.focus(); emphasizedOrFirstButton?.focus();
}, });
components: {
FloatingMenu,
IconLabel,
LayoutCol,
LayoutRow,
TextButton,
WidgetLayout,
},
});
</script> </script>
<template> <FloatingMenu open={true} class="dialog-modal" type="Dialog" direction="Center" bind:this={self} data-dialog-modal>
<FloatingMenu :open="true" class="dialog-modal" :type="'Dialog'" :direction="'Center'" data-dialog-modal>
<LayoutRow> <LayoutRow>
<LayoutCol class="icon-column"> <LayoutCol class="icon-column">
<!-- `dialog.state.icon` class exists to provide special sizing in CSS to specific icons --> <!-- `$dialog.icon` class exists to provide special sizing in CSS to specific icons -->
<IconLabel :icon="dialog.state.icon" :class="dialog.state.icon.toLowerCase()" /> <IconLabel icon={$dialog.icon} class={$dialog.icon.toLowerCase()} />
</LayoutCol> </LayoutCol>
<LayoutCol class="main-column"> <LayoutCol class="main-column">
<WidgetLayout v-if="dialog.state.widgets.layout.length > 0" :layout="dialog.state.widgets" class="details" /> {#if $dialog.widgets.layout.length > 0}
<LayoutRow v-if="(dialog.state.jsCallbackBasedButtons?.length || NaN) > 0" class="panic-buttons-row"> <WidgetLayout layout={$dialog.widgets} class="details" />
<TextButton v-for="(button, index) in dialog.state.jsCallbackBasedButtons" :key="index" :action="() => button.callback?.()" v-bind="button.props" /> {/if}
{#if ($dialog.jsCallbackBasedButtons?.length || NaN) > 0}
<LayoutRow class="panic-buttons-row">
{#each $dialog.jsCallbackBasedButtons || [] as button, index (index)}
<TextButton action={() => button.callback?.()} {...button.props} />
{/each}
</LayoutRow> </LayoutRow>
{/if}
</LayoutCol> </LayoutCol>
</LayoutRow> </LayoutRow>
</FloatingMenu> </FloatingMenu>
</template>
<style lang="scss"> <style lang="scss" global>
.dialog-modal { .dialog-modal {
position: absolute; position: absolute;
pointer-events: none; pointer-events: none;
width: 100%; width: 100%;
@@ -97,5 +93,5 @@ export default defineComponent({
align-items: center; align-items: center;
} }
} }
} }
</style> </style>
@@ -1,40 +1,38 @@
<script lang="ts" context="module">
// Should be equal to the width and height of the canvas in the CSS
const ZOOM_WINDOW_DIMENSIONS_EXPANDED = 110;
// Should be equal to the width and height of the `.pixel-outline` div in the CSS, and should be evenly divisible into the number above
const UPSCALE_FACTOR = 10;
export const ZOOM_WINDOW_DIMENSIONS = ZOOM_WINDOW_DIMENSIONS_EXPANDED / UPSCALE_FACTOR;
</script>
<script lang="ts"> <script lang="ts">
import { defineComponent, type PropType } from "vue"; import { onMount } from "svelte";
import FloatingMenu from "@/components/layout/FloatingMenu.vue"; import FloatingMenu from "@/components/layout/FloatingMenu.svelte";
// Should be equal to the width and height of the canvas in the CSS above const temporaryCanvas = document.createElement("canvas");
const ZOOM_WINDOW_DIMENSIONS_EXPANDED = 110;
// SHould be equal to the width and height of the `.pixel-outline` div in the CSS above, and should be evenly divisible into the number above
const UPSCALE_FACTOR = 10;
export const ZOOM_WINDOW_DIMENSIONS = ZOOM_WINDOW_DIMENSIONS_EXPANDED / UPSCALE_FACTOR; let zoomPreviewCanvas: HTMLCanvasElement;
const temporaryCanvas = document.createElement("canvas"); export let imageData: ImageData | undefined = undefined;
export let colorChoice: string;
export let primaryColor: string;
export let secondaryColor: string;
export let x: number;
export let y: number;
export default defineComponent({ $: watchImageData(imageData);
props: {
imageData: { type: Object as PropType<ImageData> },
colorChoice: { type: String as PropType<string>, required: true },
primaryColor: { type: String as PropType<string>, required: true },
secondaryColor: { type: String as PropType<string>, required: true },
},
mounted() {
this.displayImageDataPreview(this.imageData);
},
watch: {
imageData(imageData: ImageData | undefined) {
this.displayImageDataPreview(imageData);
},
},
methods: {
displayImageDataPreview(imageData: ImageData | undefined) {
const canvas = this.$refs.zoomPreviewCanvas as HTMLCanvasElement | undefined;
if (!canvas) return;
canvas.width = ZOOM_WINDOW_DIMENSIONS; function watchImageData(imageData: ImageData | undefined) {
canvas.height = ZOOM_WINDOW_DIMENSIONS; displayImageDataPreview(imageData);
const context = canvas.getContext("2d"); }
function displayImageDataPreview(imageData: ImageData | undefined) {
zoomPreviewCanvas.width = ZOOM_WINDOW_DIMENSIONS;
zoomPreviewCanvas.height = ZOOM_WINDOW_DIMENSIONS;
const context = zoomPreviewCanvas.getContext("2d");
temporaryCanvas.width = ZOOM_WINDOW_DIMENSIONS; temporaryCanvas.width = ZOOM_WINDOW_DIMENSIONS;
temporaryCanvas.height = ZOOM_WINDOW_DIMENSIONS; temporaryCanvas.height = ZOOM_WINDOW_DIMENSIONS;
@@ -48,32 +46,29 @@ export default defineComponent({
context.fillRect(0, 0, ZOOM_WINDOW_DIMENSIONS, ZOOM_WINDOW_DIMENSIONS); context.fillRect(0, 0, ZOOM_WINDOW_DIMENSIONS, ZOOM_WINDOW_DIMENSIONS);
context.drawImage(temporaryCanvas, 0, 0); context.drawImage(temporaryCanvas, 0, 0);
}, }
},
components: { onMount(() => {
FloatingMenu, displayImageDataPreview(imageData);
}, });
});
</script> </script>
<template> <FloatingMenu
<FloatingMenu open={true}
:open="true"
class="eyedropper-preview" class="eyedropper-preview"
:type="'Cursor'" type="Cursor"
:style="{ '--ring-color-primary': primaryColor, '--ring-color-secondary': secondaryColor, '--ring-color-choice': colorChoice }" styles={{ "--ring-color-primary": primaryColor, "--ring-color-secondary": secondaryColor, "--ring-color-choice": colorChoice, left: x + "px", top: y + "px" }}
> >
<div class="ring"> <div class="ring">
<div class="canvas-container"> <div class="canvas-container">
<canvas ref="zoomPreviewCanvas"></canvas> <canvas bind:this={zoomPreviewCanvas} />
<div class="pixel-outline"></div> <div class="pixel-outline" />
</div> </div>
</div> </div>
</FloatingMenu> </FloatingMenu>
</template>
<style lang="scss"> <style lang="scss" global>
.eyedropper-preview { .eyedropper-preview {
pointer-events: none; pointer-events: none;
.ring { .ring {
@@ -135,5 +130,5 @@ export default defineComponent({
} }
} }
} }
} }
</style> </style>
@@ -1,102 +1,104 @@
<svelte:options accessors={true} />
<script lang="ts"> <script lang="ts">
import { defineComponent, type PropType } from "vue"; import { createEventDispatcher } from "svelte";
import { type MenuListEntry } from "@/wasm-communication/messages"; import { type MenuListEntry } from "@/wasm-communication/messages";
import FloatingMenu, { type MenuDirection } from "@/components/layout/FloatingMenu.vue"; import FloatingMenu, { type MenuDirection } from "@/components/layout/FloatingMenu.svelte";
import LayoutCol from "@/components/layout/LayoutCol.vue"; import LayoutCol from "@/components/layout/LayoutCol.svelte";
import LayoutRow from "@/components/layout/LayoutRow.vue"; import LayoutRow from "@/components/layout/LayoutRow.svelte";
import IconLabel from "@/components/widgets/labels/IconLabel.vue"; import IconLabel from "@/components/widgets/labels/IconLabel.svelte";
import Separator from "@/components/widgets/labels/Separator.vue"; import Separator from "@/components/widgets/labels/Separator.svelte";
import TextLabel from "@/components/widgets/labels/TextLabel.vue"; import TextLabel from "@/components/widgets/labels/TextLabel.svelte";
import UserInputLabel from "@/components/widgets/labels/UserInputLabel.vue"; import UserInputLabel from "@/components/widgets/labels/UserInputLabel.svelte";
// eslint-disable-next-line @typescript-eslint/no-unused-vars let self: FloatingMenu;
type MenuListInstance = InstanceType<typeof MenuList>; let scroller: LayoutCol;
const MenuList = defineComponent({ // emits: ["update:open", "update:activeEntry", "naturalWidth"],
emits: ["update:open", "update:activeEntry", "naturalWidth"], const dispatch = createEventDispatcher<{ open: boolean; activeEntry: MenuListEntry }>();
props: {
entries: { type: Array as PropType<MenuListEntry[][]>, required: true }, export let entries: MenuListEntry[][];
activeEntry: { type: Object as PropType<MenuListEntry>, required: false }, export let activeEntry: MenuListEntry | undefined = undefined;
open: { type: Boolean as PropType<boolean>, required: true }, export let open: boolean;
direction: { type: String as PropType<MenuDirection>, default: "Bottom" }, export let direction: MenuDirection = "Bottom";
minWidth: { type: Number as PropType<number>, default: 0 }, export let minWidth = 0;
drawIcon: { type: Boolean as PropType<boolean>, default: false }, export let drawIcon = false;
interactive: { type: Boolean as PropType<boolean>, default: false }, export let interactive = false;
scrollableY: { type: Boolean as PropType<boolean>, default: false }, export let scrollableY = false;
virtualScrollingEntryHeight: { type: Number as PropType<number>, default: 0 }, export let virtualScrollingEntryHeight = 0;
tooltip: { type: String as PropType<string | undefined>, required: false }, export let tooltip: string | undefined = undefined;
},
data() { let highlighted = activeEntry as MenuListEntry | undefined;
return { let virtualScrollingEntriesStart = 0;
isOpen: this.open,
highlighted: this.activeEntry as MenuListEntry | undefined, // Called only when `open` is changed from outside this component
virtualScrollingEntriesStart: 0, $: watchOpen(open);
}; $: watchRemeasureWidth(entries, drawIcon);
}, $: virtualScrollingTotalHeight = entries.length === 0 ? 0 : entries[0].length * virtualScrollingEntryHeight;
watch: { $: virtualScrollingStartIndex = Math.floor(virtualScrollingEntriesStart / virtualScrollingEntryHeight) || 0;
// Called only when `open` is changed from outside this component (with v-model) $: virtualScrollingEndIndex = entries.length === 0 ? 0 : Math.min(entries[0].length, virtualScrollingStartIndex + 1 + 400 / virtualScrollingEntryHeight);
open(newOpen: boolean) {
this.isOpen = newOpen; function watchOpen(open: boolean) {
this.highlighted = this.activeEntry; highlighted = activeEntry;
}, dispatch("open", open);
isOpen(newIsOpen: boolean) { }
this.$emit("update:open", newIsOpen);
}, function watchRemeasureWidth(_: MenuListEntry[][], __: boolean) {
entries() { self?.measureAndEmitNaturalWidth();
(this.$refs.floatingMenu as typeof FloatingMenu | undefined)?.measureAndEmitNaturalWidth(); }
},
drawIcon() { function onScroll(e: Event) {
(this.$refs.floatingMenu as typeof FloatingMenu | undefined)?.measureAndEmitNaturalWidth(); if (!virtualScrollingEntryHeight) return;
}, virtualScrollingEntriesStart = (e.target as HTMLElement)?.scrollTop || 0;
}, }
methods: {
scrollViewTo(distanceDown: number): void { function onEntryClick(menuListEntry: MenuListEntry): void {
const scroller: HTMLDivElement | undefined = (this.$refs.scroller as typeof LayoutCol | undefined)?.$el;
scroller?.scrollTo(0, distanceDown);
},
onEntryClick(menuListEntry: MenuListEntry): void {
// Call the action if available // Call the action if available
if (menuListEntry.action) menuListEntry.action(); if (menuListEntry.action) menuListEntry.action();
// Emit the clicked entry as the new active entry // Notify the parent about the clicked entry as the new active entry
this.$emit("update:activeEntry", menuListEntry); dispatch("activeEntry", menuListEntry);
// Close the containing menu // Close the containing menu
if (menuListEntry.ref) menuListEntry.ref.isOpen = false; if (menuListEntry.ref) menuListEntry.ref.open = false;
this.$emit("update:open", false); dispatch("open", false);
this.isOpen = false; // TODO: This is a hack for MenuBarInput submenus, remove it when we get rid of using `ref` open = false;
}, }
onEntryPointerEnter(menuListEntry: MenuListEntry): void {
function onEntryPointerEnter(menuListEntry: MenuListEntry): void {
if (!menuListEntry.children?.length) return; if (!menuListEntry.children?.length) return;
if (menuListEntry.ref) menuListEntry.ref.isOpen = true; if (menuListEntry.ref) menuListEntry.ref.open = true;
else this.$emit("update:open", true); else dispatch("open", true);
}, }
onEntryPointerLeave(menuListEntry: MenuListEntry): void {
function onEntryPointerLeave(menuListEntry: MenuListEntry): void {
if (!menuListEntry.children?.length) return; if (!menuListEntry.children?.length) return;
if (menuListEntry.ref) menuListEntry.ref.isOpen = false; if (menuListEntry.ref) menuListEntry.ref.open = false;
else this.$emit("update:open", false); else dispatch("open", false);
}, }
isEntryOpen(menuListEntry: MenuListEntry): boolean {
function isEntryOpen(menuListEntry: MenuListEntry): boolean {
if (!menuListEntry.children?.length) return false; if (!menuListEntry.children?.length) return false;
return this.open; return menuListEntry.ref?.open || false;
}, }
/// Handles keyboard navigation for the menu. Returns if the entire menu stack should be dismissed
keydown(e: KeyboardEvent, submenu: boolean): boolean {
// Interactive menus should keep the active entry the same as the highlighted one
if (this.interactive) this.highlighted = this.activeEntry;
const menuOpen = this.isOpen; /// Handles keyboard navigation for the menu. Returns if the entire menu stack should be dismissed
const flatEntries = this.entries.flat().filter((entry) => !entry.disabled); export function keydown(e: KeyboardEvent, submenu: boolean): boolean {
const openChild = flatEntries.findIndex((entry) => entry.children?.length && entry.ref?.isOpen); // Interactive menus should keep the active entry the same as the highlighted one
if (interactive) highlighted = activeEntry;
const menuOpen = open;
const flatEntries = entries.flat().filter((entry) => !entry.disabled);
const openChild = flatEntries.findIndex((entry) => entry.children?.length && entry.ref?.open);
const openSubmenu = (highlighted: MenuListEntry): void => { const openSubmenu = (highlighted: MenuListEntry): void => {
if (highlighted.ref && highlighted.children?.length) { if (highlighted.ref && highlighted.children?.length) {
highlighted.ref.isOpen = true; highlighted.ref.open = true;
// Highlight first item // Highlight first item
highlighted.ref.setHighlighted(highlighted.children[0][0]); highlighted.ref.setHighlighted(highlighted.children[0][0]);
@@ -105,158 +107,148 @@ const MenuList = defineComponent({
if (!menuOpen && (e.key === " " || e.key === "Enter")) { if (!menuOpen && (e.key === " " || e.key === "Enter")) {
// Allow opening menu with space or enter // Allow opening menu with space or enter
this.isOpen = true; open = true;
this.highlighted = this.activeEntry; highlighted = activeEntry;
} else if (menuOpen && openChild >= 0) { } else if (menuOpen && openChild >= 0) {
// Redirect the keyboard navigation to a submenu if one is open // Redirect the keyboard navigation to a submenu if one is open
const shouldCloseStack = flatEntries[openChild].ref?.keydown(e, true); const shouldCloseStack = flatEntries[openChild].ref?.keydown(e, true);
// Highlight the menu item in the parent list that corresponds with the open submenu // Highlight the menu item in the parent list that corresponds with the open submenu
if (e.key !== "Escape" && this.highlighted) this.setHighlighted(flatEntries[openChild]); if (e.key !== "Escape" && highlighted) setHighlighted(flatEntries[openChild]);
// Handle the child closing the entire menu stack // Handle the child closing the entire menu stack
if (shouldCloseStack) { if (shouldCloseStack) {
this.isOpen = false; open = false;
return true; return true;
} }
} else if ((menuOpen || this.interactive) && (e.key === "ArrowUp" || e.key === "ArrowDown")) { } else if ((menuOpen || interactive) && (e.key === "ArrowUp" || e.key === "ArrowDown")) {
// Navigate to the next and previous entries with arrow keys // Navigate to the next and previous entries with arrow keys
let newIndex = e.key === "ArrowUp" ? flatEntries.length - 1 : 0; let newIndex = e.key === "ArrowUp" ? flatEntries.length - 1 : 0;
if (this.highlighted) { if (highlighted) {
const index = this.highlighted ? flatEntries.map((entry) => entry.label).indexOf(this.highlighted.label) : 0; const index = highlighted ? flatEntries.map((entry) => entry.label).indexOf(highlighted.label) : 0;
newIndex = index + (e.key === "ArrowUp" ? -1 : 1); newIndex = index + (e.key === "ArrowUp" ? -1 : 1);
// Interactive dropdowns should lock at the end whereas other dropdowns should loop // Interactive dropdowns should lock at the end whereas other dropdowns should loop
if (this.interactive) newIndex = Math.min(flatEntries.length - 1, Math.max(0, newIndex)); if (interactive) newIndex = Math.min(flatEntries.length - 1, Math.max(0, newIndex));
else newIndex = (newIndex + flatEntries.length) % flatEntries.length; else newIndex = (newIndex + flatEntries.length) % flatEntries.length;
} }
const newEntry = flatEntries[newIndex]; const newEntry = flatEntries[newIndex];
this.setHighlighted(newEntry); setHighlighted(newEntry);
} else if (menuOpen && e.key === "Escape") { } else if (menuOpen && e.key === "Escape") {
// Close menu with escape key // Close menu with escape key
this.isOpen = false; open = false;
// Reset active to before open // Reset active to before open
this.setHighlighted(this.activeEntry); setHighlighted(activeEntry);
} else if (menuOpen && this.highlighted && e.key === "Enter") { } else if (menuOpen && highlighted && e.key === "Enter") {
// Handle clicking on an option if enter is pressed // Handle clicking on an option if enter is pressed
if (!this.highlighted.children?.length) this.onEntryClick(this.highlighted); if (!highlighted.children?.length) onEntryClick(highlighted);
else openSubmenu(this.highlighted); else openSubmenu(highlighted);
// Stop the event from triggering a press on a new dialog // Stop the event from triggering a press on a new dialog
e.preventDefault(); e.preventDefault();
// Enter should close the entire menu stack // Enter should close the entire menu stack
return true; return true;
} else if (menuOpen && this.highlighted && e.key === "ArrowRight") { } else if (menuOpen && highlighted && e.key === "ArrowRight") {
// Right arrow opens a submenu // Right arrow opens a submenu
openSubmenu(this.highlighted); openSubmenu(highlighted);
} else if (menuOpen && e.key === "ArrowLeft") { } else if (menuOpen && e.key === "ArrowLeft") {
// Left arrow closes a submenu // Left arrow closes a submenu
if (submenu) this.isOpen = false; if (submenu) open = false;
} }
// By default, keep the menu stack open // By default, keep the menu stack open
return false; return false;
}, }
setHighlighted(newHighlight: MenuListEntry | undefined) {
this.highlighted = newHighlight; export function setHighlighted(newHighlight: MenuListEntry | undefined) {
highlighted = newHighlight;
// Interactive menus should keep the active entry the same as the highlighted one // Interactive menus should keep the active entry the same as the highlighted one
if (this.interactive && newHighlight?.value !== this.activeEntry?.value) this.$emit("update:activeEntry", newHighlight); if (interactive && newHighlight?.value !== activeEntry?.value && newHighlight) dispatch("activeEntry", newHighlight);
}, }
onScroll(e: Event) {
if (!this.virtualScrollingEntryHeight) return; export function scrollViewTo(distanceDown: number): void {
this.virtualScrollingEntriesStart = (e.target as HTMLElement)?.scrollTop || 0; scroller.div().scrollTo(0, distanceDown);
}, }
},
computed: {
virtualScrollingTotalHeight() {
return this.entries[0].length * this.virtualScrollingEntryHeight;
},
virtualScrollingStartIndex() {
return Math.floor(this.virtualScrollingEntriesStart / this.virtualScrollingEntryHeight);
},
virtualScrollingEndIndex() {
return Math.min(this.entries[0].length, this.virtualScrollingStartIndex + 1 + 400 / this.virtualScrollingEntryHeight);
},
},
components: {
FloatingMenu,
IconLabel,
LayoutCol,
LayoutRow,
Separator,
TextLabel,
UserInputLabel,
},
});
export default MenuList;
</script> </script>
<template> <FloatingMenu
<FloatingMenu
class="menu-list" class="menu-list"
v-model:open="isOpen" {open}
@naturalWidth="(newNaturalWidth: number) => $emit('naturalWidth', newNaturalWidth)" on:open={({ detail }) => (open = detail)}
:type="'Dropdown'" on:naturalWidth
:windowEdgeMargin="0" type="Dropdown"
:escapeCloses="false" windowEdgeMargin={0}
v-bind="{ direction, scrollableY: scrollableY && virtualScrollingEntryHeight === 0, minWidth }" escapeCloses={false}
ref="floatingMenu" {direction}
> {minWidth}
scrollableY={scrollableY && virtualScrollingEntryHeight === 0}
bind:this={self}
>
<!-- If we put the scrollableY on the layoutcol for non-font dropdowns then for some reason it always creates a tiny scrollbar. <!-- If we put the scrollableY on the layoutcol for non-font dropdowns then for some reason it always creates a tiny scrollbar.
However when we are using the virtual scrolling then we need the layoutcol to be scrolling so we can bind the events without using $refs. --> However when we are using the virtual scrolling then we need the layoutcol to be scrolling so we can bind the events without using $refs. -->
<LayoutCol ref="scroller" :scrollableY="scrollableY && virtualScrollingEntryHeight !== 0" @scroll="onScroll" :style="{ minWidth: virtualScrollingEntryHeight ? `${minWidth}px` : `inherit` }"> <LayoutCol
<LayoutRow v-if="virtualScrollingEntryHeight" class="scroll-spacer" :style="{ height: `${virtualScrollingStartIndex * virtualScrollingEntryHeight}px` }"></LayoutRow> bind:this={scroller}
<template v-for="(section, sectionIndex) in entries" :key="sectionIndex"> scrollableY={scrollableY && virtualScrollingEntryHeight !== 0}
<Separator :type="'List'" :direction="'Vertical'" v-if="sectionIndex > 0" /> on:scroll={onScroll}
<LayoutRow styles={{ "min-width": virtualScrollingEntryHeight ? `${minWidth}px` : `inherit` }}
v-for="(entry, entryIndex) in virtualScrollingEntryHeight ? section.slice(virtualScrollingStartIndex, virtualScrollingEndIndex) : section"
:key="entryIndex + (virtualScrollingEntryHeight ? virtualScrollingStartIndex : 0)"
class="row"
:class="{ open: isEntryOpen(entry), active: entry.label === highlighted?.label, disabled: entry.disabled }"
:style="{ height: virtualScrollingEntryHeight || '20px' }"
:title="tooltip"
@click="() => !entry.disabled && onEntryClick(entry)"
@pointerenter="() => !entry.disabled && onEntryPointerEnter(entry)"
@pointerleave="() => !entry.disabled && onEntryPointerLeave(entry)"
> >
<IconLabel v-if="entry.icon && drawIcon" :icon="entry.icon" class="entry-icon" /> {#if virtualScrollingEntryHeight}
<div v-else-if="drawIcon" class="no-icon"></div> <LayoutRow class="scroll-spacer" styles={{ height: `${virtualScrollingStartIndex * virtualScrollingEntryHeight}px` }} />
{/if}
<link v-if="entry.font" rel="stylesheet" :href="entry.font?.toString()" /> {#each entries as section, sectionIndex (sectionIndex)}
{#if sectionIndex > 0}
<TextLabel class="entry-label" :style="{ fontFamily: `${!entry.font ? 'inherit' : entry.value}` }">{{ entry.label }}</TextLabel> <Separator type="List" direction="Vertical" />
{/if}
<UserInputLabel v-if="entry.shortcut?.keys.length" :keysWithLabelsGroups="[entry.shortcut.keys]" :requiresLock="entry.shortcutRequiresLock" /> {#each virtualScrollingEntryHeight ? section.slice(virtualScrollingStartIndex, virtualScrollingEndIndex) : section as entry, entryIndex (entryIndex + (virtualScrollingEntryHeight ? virtualScrollingStartIndex : 0))}
<div class="submenu-arrow" v-if="entry.children?.length"></div>
<div class="no-submenu-arrow" v-else></div>
<MenuList
v-if="entry.children"
@naturalWidth="(newNaturalWidth: number) => $emit('naturalWidth', newNaturalWidth)"
:open="entry.ref?.open || false"
:direction="'TopRight'"
:entries="entry.children"
v-bind="{ minWidth, drawIcon, scrollableY }"
:ref="(ref: MenuListInstance): void => (ref && (entry.ref = ref), undefined)"
/>
</LayoutRow>
</template>
<LayoutRow <LayoutRow
v-if="virtualScrollingEntryHeight" class="row"
class="scroll-spacer" classes={{ open: isEntryOpen(entry), active: entry.label === highlighted?.label, disabled: Boolean(entry.disabled) }}
:style="{ height: `${virtualScrollingTotalHeight - virtualScrollingEndIndex * virtualScrollingEntryHeight}px` }" styles={{ height: virtualScrollingEntryHeight || "20px" }}
></LayoutRow> {tooltip}
</LayoutCol> on:click={() => !entry.disabled && onEntryClick(entry)}
</FloatingMenu> on:pointerenter={() => !entry.disabled && onEntryPointerEnter(entry)}
</template> on:pointerleave={() => !entry.disabled && onEntryPointerLeave(entry)}
>
{#if entry.icon && drawIcon}
<IconLabel icon={entry.icon} class="entry-icon" />
{:else if drawIcon}
<div class="no-icon" />
{/if}
<style lang="scss"> {#if entry.font}
.menu-list { <link rel="stylesheet" href={entry.font?.toString()} />
{/if}
<TextLabel class="entry-label" styles={{ "font-family": `${!entry.font ? "inherit" : entry.value}` }}>{entry.label}</TextLabel>
{#if entry.shortcut?.keys.length}
<UserInputLabel keysWithLabelsGroups={[entry.shortcut.keys]} requiresLock={entry.shortcutRequiresLock} />
{/if}
{#if entry.children?.length}
<div class="submenu-arrow" />
{:else}
<div class="no-submenu-arrow" />
{/if}
{#if entry.children}
<svelte:self on:naturalWidth open={entry.ref?.open || false} direction="TopRight" entries={entry.children} {minWidth} {drawIcon} {scrollableY} bind:this={entry.ref} />
{/if}
</LayoutRow>
{/each}
{/each}
{#if virtualScrollingEntryHeight}
<LayoutRow class="scroll-spacer" styles={{ height: `${virtualScrollingTotalHeight - virtualScrollingEndIndex * virtualScrollingEntryHeight}px` }} />
{/if}
</LayoutCol>
</FloatingMenu>
<style lang="scss" global>
.menu-list {
.floating-menu-container .floating-menu-content { .floating-menu-container .floating-menu-content {
padding: 4px 0; padding: 4px 0;
@@ -355,5 +347,5 @@ export default MenuList;
} }
} }
} }
} }
</style> </style>
+227 -211
View File
@@ -1,190 +1,237 @@
<script lang="ts" context="module">
export type MenuDirection = "Top" | "Bottom" | "Left" | "Right" | "TopLeft" | "TopRight" | "BottomLeft" | "BottomRight" | "Center";
export type MenuType = "Popover" | "Dropdown" | "Dialog" | "Cursor";
</script>
<script lang="ts"> <script lang="ts">
import { defineComponent, nextTick, type PropType } from "vue"; import { afterUpdate, createEventDispatcher, tick } from "svelte";
import LayoutCol from "@/components/layout/LayoutCol.vue"; import LayoutCol from "@/components/layout/LayoutCol.svelte";
export type MenuDirection = "Top" | "Bottom" | "Left" | "Right" | "TopLeft" | "TopRight" | "BottomLeft" | "BottomRight" | "Center"; const POINTER_STRAY_DISTANCE = 100;
export type MenuType = "Popover" | "Dropdown" | "Dialog" | "Cursor";
const POINTER_STRAY_DISTANCE = 100; // emits: ["update:open", "naturalWidth"],
const dispatch = createEventDispatcher<{ open: boolean; naturalWidth: number }>();
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> = {};
export let open: boolean;
export let type: MenuType;
export let direction: MenuDirection = "Bottom";
export let windowEdgeMargin = 6;
export let scrollableY = false;
export let minWidth = 0;
export let escapeCloses = true;
export let strayCloses = true;
let tail: HTMLDivElement;
let self: HTMLDivElement;
let floatingMenuContainer: HTMLDivElement;
let floatingMenuContent: LayoutCol;
export default defineComponent({
emits: ["update:open", "naturalWidth"],
props: {
open: { type: Boolean as PropType<boolean>, required: true },
type: { type: String as PropType<MenuType>, required: true },
direction: { type: String as PropType<MenuDirection>, default: "Bottom" },
windowEdgeMargin: { type: Number as PropType<number>, default: 6 },
scrollableY: { type: Boolean as PropType<boolean>, default: false },
minWidth: { type: Number as PropType<number>, default: 0 },
escapeCloses: { type: Boolean as PropType<boolean>, default: true },
strayCloses: { type: Boolean as PropType<boolean>, default: true },
},
data() {
// The resize observer is attached to the floating menu container, which is the zero-height div of the width of the parent element's floating menu spawner. // The resize observer is attached to the floating menu container, which is the zero-height div of the width of the parent element's floating menu spawner.
// Since CSS doesn't let us make the floating menu (with `position: fixed`) have a 100% width of this container, we need to use JS to observe its size and // Since CSS doesn't let us make the floating menu (with `position: fixed`) have a 100% width of this container, we need to use JS to observe its size and
// tell the floating menu content to use it as a min-width so the floating menu is at least the width of the parent element's floating menu spawner. // tell the floating menu content to use it as a min-width so the floating menu is at least the width of the parent element's floating menu spawner.
// This is the opposite concern of the natural width measurement system, which gets the natural width of the floating menu content in order for the // This is the opposite concern of the natural width measurement system, which gets the natural width of the floating menu content in order for the
// spawner widget to optionally set its min-size to the floating menu's natural width. // spawner widget to optionally set its min-size to the floating menu's natural width.
const containerResizeObserver = new ResizeObserver((entries: ResizeObserverEntry[]) => { let containerResizeObserver = new ResizeObserver((entries: ResizeObserverEntry[]) => {
this.resizeObserverCallback(entries); resizeObserverCallback(entries);
});
let wasOpen = open;
let measuringOngoing = false;
let measuringOngoingGuard = false;
let minWidthParentWidth = 0;
let pointerStillDown = false;
let workspaceBounds = new DOMRect();
let floatingMenuBounds = new DOMRect();
let floatingMenuContentBounds = new DOMRect();
$: minWidthStyleValue = measuringOngoing ? "0" : `${Math.max(minWidth, minWidthParentWidth)}px`;
$: displayTail = open && type === "Popover";
$: displayContainer = open || measuringOngoing;
$: extraClasses = Object.entries(classes)
.flatMap((classAndState) => (classAndState[1] ? [classAndState[0]] : []))
.join(" ");
$: extraStyles = Object.entries(styles)
.flatMap((styleAndValue) => (styleAndValue[1] !== undefined ? [`${styleAndValue[0]}: ${styleAndValue[1]};`] : []))
.join(" ");
$: watchOpenChange(open);
// Called only when `open` is changed from outside this component
async function watchOpenChange(isOpen: boolean) {
// Switching from closed to open
if (isOpen && !wasOpen) {
// TODO: Close any other floating menus that may already be open, which can happen using tab navigation and Enter/Space Bar to open
// Close floating menu if pointer strays far enough away
window.addEventListener("pointermove", pointerMoveHandler);
// Close floating menu if esc is pressed
window.addEventListener("keydown", keyDownHandler);
// Close floating menu if pointer is outside (but within stray distance)
window.addEventListener("pointerdown", pointerDownHandler);
// Cancel the subsequent click event to prevent the floating menu from reopening if the floating menu's button is the click event target
window.addEventListener("pointerup", pointerUpHandler);
// Floating menu min-width resize observer
await tick();
// Start a new observation of the now-open floating menu
containerResizeObserver.disconnect();
containerResizeObserver.observe(floatingMenuContainer);
}
// Switching from open to closed
if (!isOpen && wasOpen) {
// Clean up observation of the now-closed floating menu
containerResizeObserver.disconnect();
window.removeEventListener("pointermove", pointerMoveHandler);
window.removeEventListener("keydown", keyDownHandler);
window.removeEventListener("pointerdown", pointerDownHandler);
// The `pointerup` event is removed in `pointerMoveHandler()` and `pointerDownHandler()`
}
// Now that we're done reading the old state, update it to the current state for next time
wasOpen = isOpen;
}
// Gets the client bounds of the elements and apply relevant styles to them
// TODO: Use DOM attribute bindings more whilst not causing recursive updates
afterUpdate(() => {
// Turning measuring on and off both causes the component to change, which causes the `afterUpdate()` Svelte event to fire extraneous times (hurting performance and sometimes causing an infinite loop)
if (!measuringOngoingGuard) positionAndStyleFloatingMenu();
}); });
return { function resizeObserverCallback(entries: ResizeObserverEntry[]) {
measuringOngoing: false, minWidthParentWidth = entries[0].contentRect.width;
measuringOngoingGuard: false, }
minWidthParentWidth: 0,
containerResizeObserver,
pointerStillDown: false,
workspaceBounds: new DOMRect(),
floatingMenuBounds: new DOMRect(),
floatingMenuContentBounds: new DOMRect(),
};
},
computed: {
minWidthStyleValue() {
if (this.measuringOngoing) return "0";
return `${Math.max(this.minWidth, this.minWidthParentWidth)}px`;
},
displayTail() {
return this.open && this.type === "Popover";
},
displayContainer() {
return this.open || this.measuringOngoing;
},
},
// Gets the client bounds of the elements and apply relevant styles to them
// TODO: Use the Vue :style attribute more whilst not causing recursive updates
async updated() {
// Turning measuring on and off both cause the component to change, which causes the `updated()` Vue event to fire extraneous times (hurting performance and sometimes causing an infinite loop)
if (this.measuringOngoingGuard) return;
this.positionAndStyleFloatingMenu(); function positionAndStyleFloatingMenu() {
}, if (type === "Cursor") return;
methods: {
resizeObserverCallback(entries: ResizeObserverEntry[]) {
this.minWidthParentWidth = entries[0].contentRect.width;
},
positionAndStyleFloatingMenu() {
if (this.type === "Cursor") return;
const workspace = document.querySelector("[data-workspace]"); const workspace = document.querySelector("[data-workspace]");
const floatingMenu: HTMLDivElement | undefined = this.$el;
const floatingMenuContainer = this.$refs.floatingMenuContainer as HTMLDivElement | undefined;
const floatingMenuContent: HTMLDivElement | undefined = (this.$refs.floatingMenuContent as typeof LayoutCol | undefined)?.$el;
if (!workspace || !floatingMenu || !floatingMenuContainer || !floatingMenuContent) return; if (!workspace || !self || !floatingMenuContainer || !floatingMenuContent) return;
this.workspaceBounds = workspace.getBoundingClientRect(); workspaceBounds = workspace.getBoundingClientRect();
this.floatingMenuBounds = floatingMenu.getBoundingClientRect(); floatingMenuBounds = self.getBoundingClientRect();
const floatingMenuContainerBounds = floatingMenuContainer.getBoundingClientRect(); const floatingMenuContainerBounds = floatingMenuContainer.getBoundingClientRect();
this.floatingMenuContentBounds = floatingMenuContent.getBoundingClientRect(); floatingMenuContentBounds = floatingMenuContent.div().getBoundingClientRect();
const inParentFloatingMenu = Boolean(floatingMenuContainer.closest("[data-floating-menu-content]")); const inParentFloatingMenu = Boolean(floatingMenuContainer.closest("[data-floating-menu-content]"));
if (!inParentFloatingMenu) { if (!inParentFloatingMenu) {
// Required to correctly position content when scrolled (it has a `position: fixed` to prevent clipping) // Required to correctly position content when scrolled (it has a `position: fixed` to prevent clipping)
// We use `.style` on a ref (instead of a `:style` Vue binding) because the binding causes the `updated()` hook to call the function we're in recursively forever // We use `.style` on a div (instead of a style DOM attribute binding) because the binding causes the `afterUpdate()` hook to call the function we're in recursively forever
const tailOffset = this.type === "Popover" ? 10 : 0; const tailOffset = type === "Popover" ? 10 : 0;
if (this.direction === "Bottom") floatingMenuContent.style.top = `${tailOffset + this.floatingMenuBounds.top}px`; if (direction === "Bottom") floatingMenuContent.div().style.top = `${tailOffset + floatingMenuBounds.top}px`;
if (this.direction === "Top") floatingMenuContent.style.bottom = `${tailOffset + this.floatingMenuBounds.bottom}px`; if (direction === "Top") floatingMenuContent.div().style.bottom = `${tailOffset + floatingMenuBounds.bottom}px`;
if (this.direction === "Right") floatingMenuContent.style.left = `${tailOffset + this.floatingMenuBounds.left}px`; if (direction === "Right") floatingMenuContent.div().style.left = `${tailOffset + floatingMenuBounds.left}px`;
if (this.direction === "Left") floatingMenuContent.style.right = `${tailOffset + this.floatingMenuBounds.right}px`; if (direction === "Left") floatingMenuContent.div().style.right = `${tailOffset + floatingMenuBounds.right}px`;
// Required to correctly position tail when scrolled (it has a `position: fixed` to prevent clipping) // Required to correctly position tail when scrolled (it has a `position: fixed` to prevent clipping)
// We use `.style` on a ref (instead of a `:style` Vue binding) because the binding causes the `updated()` hook to call the function we're in recursively forever // We use `.style` on a div (instead of a style DOM attribute binding) because the binding causes the `afterUpdate()` hook to call the function we're in recursively forever
const tail = this.$refs.tail as HTMLElement; if (tail && direction === "Bottom") tail.style.top = `${floatingMenuBounds.top}px`;
if (tail && this.direction === "Bottom") tail.style.top = `${this.floatingMenuBounds.top}px`; if (tail && direction === "Top") tail.style.bottom = `${floatingMenuBounds.bottom}px`;
if (tail && this.direction === "Top") tail.style.bottom = `${this.floatingMenuBounds.bottom}px`; if (tail && direction === "Right") tail.style.left = `${floatingMenuBounds.left}px`;
if (tail && this.direction === "Right") tail.style.left = `${this.floatingMenuBounds.left}px`; if (tail && direction === "Left") tail.style.right = `${floatingMenuBounds.right}px`;
if (tail && this.direction === "Left") tail.style.right = `${this.floatingMenuBounds.right}px`;
} }
type Edge = "Top" | "Bottom" | "Left" | "Right"; type Edge = "Top" | "Bottom" | "Left" | "Right";
let zeroedBorderVertical: Edge | undefined; let zeroedBorderVertical: Edge | undefined;
let zeroedBorderHorizontal: Edge | undefined; let zeroedBorderHorizontal: Edge | undefined;
if (this.direction === "Top" || this.direction === "Bottom") { if (direction === "Top" || direction === "Bottom") {
zeroedBorderVertical = this.direction === "Top" ? "Bottom" : "Top"; zeroedBorderVertical = direction === "Top" ? "Bottom" : "Top";
// We use `.style` on a ref (instead of a `:style` Vue binding) because the binding causes the `updated()` hook to call the function we're in recursively forever // We use `.style` on a div (instead of a style DOM attribute binding) because the binding causes the `afterUpdate()` hook to call the function we're in recursively forever
if (this.floatingMenuContentBounds.left - this.windowEdgeMargin <= this.workspaceBounds.left) { if (floatingMenuContentBounds.left - windowEdgeMargin <= workspaceBounds.left) {
floatingMenuContent.style.left = `${this.windowEdgeMargin}px`; floatingMenuContent.div().style.left = `${windowEdgeMargin}px`;
if (this.workspaceBounds.left + floatingMenuContainerBounds.left === 12) zeroedBorderHorizontal = "Left"; if (workspaceBounds.left + floatingMenuContainerBounds.left === 12) zeroedBorderHorizontal = "Left";
} }
if (this.floatingMenuContentBounds.right + this.windowEdgeMargin >= this.workspaceBounds.right) { if (floatingMenuContentBounds.right + windowEdgeMargin >= workspaceBounds.right) {
floatingMenuContent.style.right = `${this.windowEdgeMargin}px`; floatingMenuContent.div().style.right = `${windowEdgeMargin}px`;
if (this.workspaceBounds.right - floatingMenuContainerBounds.right === 12) zeroedBorderHorizontal = "Right"; if (workspaceBounds.right - floatingMenuContainerBounds.right === 12) zeroedBorderHorizontal = "Right";
} }
} }
if (this.direction === "Left" || this.direction === "Right") { if (direction === "Left" || direction === "Right") {
zeroedBorderHorizontal = this.direction === "Left" ? "Right" : "Left"; zeroedBorderHorizontal = direction === "Left" ? "Right" : "Left";
// We use `.style` on a ref (instead of a `:style` Vue binding) because the binding causes the `updated()` hook to call the function we're in recursively forever // We use `.style` on a div (instead of a style DOM attribute binding) because the binding causes the `afterUpdate()` hook to call the function we're in recursively forever
if (this.floatingMenuContentBounds.top - this.windowEdgeMargin <= this.workspaceBounds.top) { if (floatingMenuContentBounds.top - windowEdgeMargin <= workspaceBounds.top) {
floatingMenuContent.style.top = `${this.windowEdgeMargin}px`; floatingMenuContent.div().style.top = `${windowEdgeMargin}px`;
if (this.workspaceBounds.top + floatingMenuContainerBounds.top === 12) zeroedBorderVertical = "Top"; if (workspaceBounds.top + floatingMenuContainerBounds.top === 12) zeroedBorderVertical = "Top";
} }
if (this.floatingMenuContentBounds.bottom + this.windowEdgeMargin >= this.workspaceBounds.bottom) { if (floatingMenuContentBounds.bottom + windowEdgeMargin >= workspaceBounds.bottom) {
floatingMenuContent.style.bottom = `${this.windowEdgeMargin}px`; floatingMenuContent.div().style.bottom = `${windowEdgeMargin}px`;
if (this.workspaceBounds.bottom - floatingMenuContainerBounds.bottom === 12) zeroedBorderVertical = "Bottom"; if (workspaceBounds.bottom - floatingMenuContainerBounds.bottom === 12) zeroedBorderVertical = "Bottom";
} }
} }
// Remove the rounded corner from the content where the tail perfectly meets the corner // Remove the rounded corner from the content where the tail perfectly meets the corner
if (this.type === "Popover" && this.windowEdgeMargin === 6 && zeroedBorderVertical && zeroedBorderHorizontal) { if (type === "Popover" && windowEdgeMargin === 6 && zeroedBorderVertical && zeroedBorderHorizontal) {
// We use `.style` on a ref (instead of a `:style` Vue binding) because the binding causes the `updated()` hook to call the function we're in recursively forever // We use `.style` on a div (instead of a style DOM attribute binding) because the binding causes the `afterUpdate()` hook to call the function we're in recursively forever
switch (`${zeroedBorderVertical}${zeroedBorderHorizontal}`) { switch (`${zeroedBorderVertical}${zeroedBorderHorizontal}`) {
case "TopLeft": case "TopLeft":
floatingMenuContent.style.borderTopLeftRadius = "0"; floatingMenuContent.div().style.borderTopLeftRadius = "0";
break; break;
case "TopRight": case "TopRight":
floatingMenuContent.style.borderTopRightRadius = "0"; floatingMenuContent.div().style.borderTopRightRadius = "0";
break; break;
case "BottomLeft": case "BottomLeft":
floatingMenuContent.style.borderBottomLeftRadius = "0"; floatingMenuContent.div().style.borderBottomLeftRadius = "0";
break; break;
case "BottomRight": case "BottomRight":
floatingMenuContent.style.borderBottomRightRadius = "0"; floatingMenuContent.div().style.borderBottomRightRadius = "0";
break; break;
default: default:
break; break;
} }
} }
}, }
export function div(): HTMLDivElement {
return self;
}
// To be called by the parent component. Measures the actual width of the floating menu content element and returns it in a promise. // To be called by the parent component. Measures the actual width of the floating menu content element and returns it in a promise.
async measureAndEmitNaturalWidth(): Promise<void> { export async function measureAndEmitNaturalWidth(): Promise<void> {
// Wait for the changed content which fired the `updated()` Vue event to be put into the DOM if (!measuringOngoingGuard) return;
await nextTick();
// Wait for the changed content which fired the `afterUpdate()` Svelte event to be put into the DOM
await tick();
// Wait until all fonts have been loaded and rendered so measurements of content involving text are accurate // Wait until all fonts have been loaded and rendered so measurements of content involving text are accurate
// API is experimental but supported in all browsers - https://developer.mozilla.org/en-US/docs/Web/API/FontFaceSet/ready await document.fonts.ready;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (document as any).fonts.ready;
// Make the component show itself with 0 min-width so it can be measured, and wait until the values have been updated to the DOM // Make the component show itself with 0 min-width so it can be measured, and wait until the values have been updated to the DOM
this.measuringOngoing = true; measuringOngoing = true;
this.measuringOngoingGuard = true; measuringOngoingGuard = true;
await nextTick(); await tick();
// Measure the width of the floating menu content element, if it's currently visible // Measure the width of the floating menu content element, if it's currently visible
// The result will be `undefined` if the menu is invisible, perhaps because an ancestor component is hidden with a falsy `v-if` condition // The result will be `undefined` if the menu is invisible, perhaps because an ancestor component is hidden with a falsy Svelte template if condition
const floatingMenuContent: HTMLDivElement | undefined = (this.$refs.floatingMenuContent as typeof LayoutCol | undefined)?.$el; const naturalWidth: number | undefined = floatingMenuContent?.div().clientWidth;
const naturalWidth = floatingMenuContent?.clientWidth;
// Turn off measuring mode for the component, which triggers another call to the `updated()` Vue event, so we can turn off the protection after that has happened // Turn off measuring mode for the component, which triggers another call to the `afterUpdate()` Svelte event, so we can turn off the protection after that has happened
this.measuringOngoing = false; measuringOngoing = false;
await nextTick(); await tick();
this.measuringOngoingGuard = false; measuringOngoingGuard = false;
// Emit the measured natural width to the parent // Notify the parent about the measured natural width
if (naturalWidth !== undefined && naturalWidth >= 0) { if (naturalWidth !== undefined && naturalWidth >= 0) {
this.$emit("naturalWidth", naturalWidth); dispatch("naturalWidth", naturalWidth);
} }
}, }
pointerMoveHandler(e: PointerEvent) {
function pointerMoveHandler(e: PointerEvent) {
// This element and the element being hovered over // This element and the element being hovered over
const self = this.$el as HTMLDivElement | undefined;
const target = e.target as HTMLElement | undefined; const target = e.target as HTMLElement | undefined;
// Get the spawner element (that which is clicked to spawn this floating menu) // Get the spawner element (that which is clicked to spawn this floating menu)
@@ -195,25 +242,26 @@ export default defineComponent({
// HOVER TRANSFER // HOVER TRANSFER
// Transfer from this open floating menu to a sibling floating menu if the pointer hovers to a valid neighboring floating menu spawner // Transfer from this open floating menu to a sibling floating menu if the pointer hovers to a valid neighboring floating menu spawner
this.hoverTransfer(self, ownSpawner, targetSpawner); hoverTransfer(self, ownSpawner, targetSpawner);
// POINTER STRAY // POINTER STRAY
// Close the floating menu if the pointer has strayed far enough from its bounds (and it's not hovering over its own spawner) // Close the floating menu if the pointer has strayed far enough from its bounds (and it's not hovering over its own spawner)
const notHoveringOverOwnSpawner = ownSpawner !== targetSpawner; const notHoveringOverOwnSpawner = ownSpawner !== targetSpawner;
if (this.strayCloses && notHoveringOverOwnSpawner && this.isPointerEventOutsideFloatingMenu(e, POINTER_STRAY_DISTANCE)) { if (strayCloses && notHoveringOverOwnSpawner && isPointerEventOutsideFloatingMenu(e, POINTER_STRAY_DISTANCE)) {
// TODO: Extend this rectangle bounds check to all submenu bounds up the DOM tree since currently submenus disappear // TODO: Extend this rectangle bounds check to all submenu bounds up the DOM tree since currently submenus disappear
// TODO: with zero stray distance if the cursor is further than the stray distance from only the top-level menu // TODO: with zero stray distance if the cursor is further than the stray distance from only the top-level menu
this.$emit("update:open", false); dispatch("open", false);
} }
// Clean up any messes from lost pointerup events // Clean up any messes from lost pointerup events
const eventIncludesLmb = Boolean(e.buttons & 1); const eventIncludesLmb = Boolean(e.buttons & 1);
if (!this.open && !eventIncludesLmb) { if (!open && !eventIncludesLmb) {
this.pointerStillDown = false; pointerStillDown = false;
window.removeEventListener("pointerup", this.pointerUpHandler); window.removeEventListener("pointerup", pointerUpHandler);
} }
}, }
hoverTransfer(self: HTMLDivElement | undefined, ownSpawner: HTMLElement | undefined, targetSpawner: HTMLElement | undefined): void {
function hoverTransfer(self: HTMLDivElement | undefined, ownSpawner: HTMLElement | undefined, targetSpawner: HTMLElement | undefined): void {
// Algorithm pseudo-code to detect and transfer to hover-transferrable floating menu spawners // Algorithm pseudo-code to detect and transfer to hover-transferrable floating menu spawners
// Accompanying diagram: <https://files.keavon.com/-/SpringgreenKnownXantus/capture.png> // Accompanying diagram: <https://files.keavon.com/-/SpringgreenKnownXantus/capture.png>
// //
@@ -270,7 +318,7 @@ export default defineComponent({
const foundTarget = filteredListOfDescendantSpawners.find((item: Element): boolean => item === targetSpawner); const foundTarget = filteredListOfDescendantSpawners.find((item: Element): boolean => item === targetSpawner);
// If the currently hovered spawner is one of the found valid hover-transferrable spawners, swap to it by clicking on it // If the currently hovered spawner is one of the found valid hover-transferrable spawners, swap to it by clicking on it
if (foundTarget) { if (foundTarget) {
this.$emit("update:open", false); dispatch("open", false);
(foundTarget as HTMLElement).click(); (foundTarget as HTMLElement).click();
} }
@@ -278,47 +326,51 @@ export default defineComponent({
break; break;
} }
} }
},
keyDownHandler(e: KeyboardEvent) {
if (this.escapeCloses && e.key.toLowerCase() === "escape") {
this.$emit("update:open", false);
} }
},
pointerDownHandler(e: PointerEvent) { function keyDownHandler(e: KeyboardEvent) {
if (escapeCloses && e.key.toLowerCase() === "escape") {
dispatch("open", false);
}
}
function pointerDownHandler(e: PointerEvent) {
// Close the floating menu if the pointer clicked outside the floating menu (but within stray distance) // Close the floating menu if the pointer clicked outside the floating menu (but within stray distance)
if (this.isPointerEventOutsideFloatingMenu(e)) { if (isPointerEventOutsideFloatingMenu(e)) {
this.$emit("update:open", false); dispatch("open", false);
// Track if the left pointer button is now down so its later click event can be canceled // Track if the left pointer button is now down so its later click event can be canceled
const eventIsForLmb = e.button === 0; const eventIsForLmb = e.button === 0;
if (eventIsForLmb) this.pointerStillDown = true; if (eventIsForLmb) pointerStillDown = true;
} }
}, }
pointerUpHandler(e: PointerEvent) {
function pointerUpHandler(e: PointerEvent) {
const eventIsForLmb = e.button === 0; const eventIsForLmb = e.button === 0;
if (this.pointerStillDown && eventIsForLmb) { if (pointerStillDown && eventIsForLmb) {
// Clean up self // Clean up self
this.pointerStillDown = false; pointerStillDown = false;
window.removeEventListener("pointerup", this.pointerUpHandler); window.removeEventListener("pointerup", pointerUpHandler);
// Prevent the click event from firing, which would normally occur right after this pointerup event // Prevent the click event from firing, which would normally occur right after this pointerup event
window.addEventListener("click", this.clickHandlerCapture, true); window.addEventListener("click", clickHandlerCapture, true);
} }
}, }
clickHandlerCapture(e: MouseEvent) {
function clickHandlerCapture(e: MouseEvent) {
// Stop the click event from reopening this floating menu if the click event targets the floating menu's button // Stop the click event from reopening this floating menu if the click event targets the floating menu's button
e.stopPropagation(); e.stopPropagation();
// Clean up self // Clean up self
window.removeEventListener("click", this.clickHandlerCapture, true); window.removeEventListener("click", clickHandlerCapture, true);
}, }
isPointerEventOutsideFloatingMenu(e: PointerEvent, extraDistanceAllowed = 0): boolean {
// Consider all child menus as well as the top-level one
const floatingMenu: HTMLDivElement | undefined = this.$el;
if (!floatingMenu) return true;
const allContainedFloatingMenus = [...floatingMenu.querySelectorAll("[data-floating-menu-content]")];
return !allContainedFloatingMenus.find((element) => !this.isPointerEventOutsideMenuElement(e, element, extraDistanceAllowed)); function isPointerEventOutsideFloatingMenu(e: PointerEvent, extraDistanceAllowed = 0): boolean {
}, // Consider all child menus as well as the top-level one
isPointerEventOutsideMenuElement(e: PointerEvent, element: Element, extraDistanceAllowed = 0): boolean { const allContainedFloatingMenus = [...self.querySelectorAll("[data-floating-menu-content]")];
return !allContainedFloatingMenus.find((element) => !isPointerEventOutsideMenuElement(e, element, extraDistanceAllowed));
}
function isPointerEventOutsideMenuElement(e: PointerEvent, element: Element, extraDistanceAllowed = 0): boolean {
const floatingMenuBounds = element.getBoundingClientRect(); const floatingMenuBounds = element.getBoundingClientRect();
if (floatingMenuBounds.left - e.clientX >= extraDistanceAllowed) return true; if (floatingMenuBounds.left - e.clientX >= extraDistanceAllowed) return true;
@@ -327,65 +379,29 @@ export default defineComponent({
if (e.clientY - floatingMenuBounds.bottom >= extraDistanceAllowed) return true; if (e.clientY - floatingMenuBounds.bottom >= extraDistanceAllowed) return true;
return false; return false;
},
},
watch: {
// Called only when `open` is changed from outside this component (with v-model)
async open(newState: boolean, oldState: boolean) {
// Switching from closed to open
if (newState && !oldState) {
// TODO: Close any other floating menus that may already be open, which can happen using tab navigation and Enter/Space Bar to open
// Close floating menu if pointer strays far enough away
window.addEventListener("pointermove", this.pointerMoveHandler);
// Close floating menu if esc is pressed
window.addEventListener("keydown", this.keyDownHandler);
// Close floating menu if pointer is outside (but within stray distance)
window.addEventListener("pointerdown", this.pointerDownHandler);
// Cancel the subsequent click event to prevent the floating menu from reopening if the floating menu's button is the click event target
window.addEventListener("pointerup", this.pointerUpHandler);
// Floating menu min-width resize observer
await nextTick();
const floatingMenuContainer = this.$refs.floatingMenuContainer as HTMLDivElement | undefined;
if (!floatingMenuContainer) return;
// Start a new observation of the now-open floating menu
this.containerResizeObserver.disconnect();
this.containerResizeObserver.observe(floatingMenuContainer);
} }
// Switching from open to closed
if (!newState && oldState) {
// Clean up observation of the now-closed floating menu
this.containerResizeObserver.disconnect();
window.removeEventListener("pointermove", this.pointerMoveHandler);
window.removeEventListener("keydown", this.keyDownHandler);
window.removeEventListener("pointerdown", this.pointerDownHandler);
// The `pointerup` event is removed in `pointerMoveHandler()` and `pointerDownHandler()`
}
},
},
components: { LayoutCol },
});
</script> </script>
<template> <div
<div class="floating-menu" :class="[direction.toLowerCase(), type.toLowerCase()]"> class={`floating-menu ${direction.toLowerCase()} ${type.toLowerCase()} ${className} ${extraClasses}`.trim()}
<div class="tail" v-if="displayTail" ref="tail"></div> style={`${styleName} ${extraStyles}`.trim() || undefined}
<div class="floating-menu-container" v-if="displayContainer" ref="floatingMenuContainer"> bind:this={self}
<LayoutCol class="floating-menu-content" :style="{ minWidth: minWidthStyleValue }" :scrollableY="scrollableY" ref="floatingMenuContent" data-floating-menu-content> {...$$restProps}
<slot></slot> >
{#if displayTail}
<div class="tail" bind:this={tail} />
{/if}
{#if displayContainer}
<div class="floating-menu-container" bind:this={floatingMenuContainer}>
<LayoutCol class="floating-menu-content" styles={{ "min-width": minWidthStyleValue }} {scrollableY} bind:this={floatingMenuContent} data-floating-menu-content>
<slot />
</LayoutCol> </LayoutCol>
</div> </div>
</div> {/if}
</template> </div>
<style lang="scss"> <style lang="scss" global>
.floating-menu { .floating-menu {
position: absolute; position: absolute;
width: 0; width: 0;
height: 0; height: 0;
@@ -553,5 +569,5 @@ export default defineComponent({
align-items: center; align-items: center;
margin-left: var(--floating-menu-content-offset); margin-left: var(--floating-menu-content-offset);
} }
} }
</style> </style>
+79 -25
View File
@@ -1,35 +1,89 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, type PropType } from "vue"; 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> = {};
export let tooltip: string | undefined = undefined;
export let scrollableX: boolean = false;
export let scrollableY: boolean = false;
export default defineComponent({ let self: HTMLDivElement;
props: {
scrollableX: { type: Boolean as PropType<boolean>, default: false }, $: extraClasses = Object.entries(classes)
scrollableY: { type: Boolean as PropType<boolean>, default: false }, .flatMap((classAndState) => (classAndState[1] ? [classAndState[0]] : []))
tooltip: { type: String as PropType<string | undefined>, required: false }, .join(" ");
}, $: extraStyles = Object.entries(styles)
}); .flatMap((styleAndValue) => (styleAndValue[1] !== undefined ? [`${styleAndValue[0]}: ${styleAndValue[1]};`] : []))
.join(" ");
export function div(): HTMLDivElement {
return self;
}
</script> </script>
<template> <div
<div class={`layout-col ${className} ${extraClasses}`.trim()}
class="layout-col" class:scrollable-x={scrollableX}
:class="{ 'scrollable-x': scrollableX, 'scrollable-y': scrollableY }" class:scrollable-y={scrollableY}
:data-scrollable-x="scrollableX || undefined" style={`${styleName} ${extraStyles}`.trim() || undefined}
:data-scrollable-y="scrollableY || undefined" title={tooltip}
:title="tooltip" bind:this={self}
> on:focus
<slot></slot> on:blur
</div> on:fullscreenchange
</template> on:fullscreenerror
on:scroll
on:cut
on:copy
on:paste
on:keydown
on:keypress
on:keyup
on:auxclick
on:click
on:contextmenu
on:dblclick
on:mousedown
on:mouseenter
on:mouseleave
on:mousemove
on:mouseover
on:mouseout
on:mouseup
on:select
on:wheel
on:drag
on:dragend
on:dragenter
on:dragstart
on:dragleave
on:dragover
on:drop
on:touchcancel
on:touchend
on:touchmove
on:touchstart
on:pointerover
on:pointerenter
on:pointerdown
on:pointermove
on:pointerup
on:pointercancel
on:pointerout
on:pointerleave
on:gotpointercapture
on:lostpointercapture
{...$$restProps}
>
<slot />
</div>
<style lang="scss"> <style lang="scss" global>
.layout-col { .layout-col {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex-grow: 1; flex-grow: 1;
.spacer {
flex: 1 1 100%;
} }
}
</style> </style>
+79 -25
View File
@@ -1,35 +1,89 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, type PropType } from "vue"; 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> = {};
export let tooltip: string | undefined = undefined;
export let scrollableX: boolean = false;
export let scrollableY: boolean = false;
export default defineComponent({ let self: HTMLDivElement;
props: {
scrollableX: { type: Boolean as PropType<boolean>, default: false }, $: extraClasses = Object.entries(classes)
scrollableY: { type: Boolean as PropType<boolean>, default: false }, .flatMap((classAndState) => (classAndState[1] ? [classAndState[0]] : []))
tooltip: { type: String as PropType<string | undefined>, required: false }, .join(" ");
}, $: extraStyles = Object.entries(styles)
}); .flatMap((styleAndValue) => (styleAndValue[1] !== undefined ? [`${styleAndValue[0]}: ${styleAndValue[1]};`] : []))
.join(" ");
export function div(): HTMLDivElement {
return self;
}
</script> </script>
<template> <div
<div class={`layout-row ${className} ${extraClasses}`.trim()}
class="layout-row" class:scrollable-x={scrollableX}
:class="{ 'scrollable-x': scrollableX, 'scrollable-y': scrollableY }" class:scrollable-y={scrollableY}
:data-scrollable-x="scrollableX || undefined" style={`${styleName} ${extraStyles}`.trim() || undefined}
:data-scrollable-y="scrollableY || undefined" title={tooltip}
:title="tooltip" bind:this={self}
> on:focus
<slot></slot> on:blur
</div> on:fullscreenchange
</template> on:fullscreenerror
on:scroll
on:cut
on:copy
on:paste
on:keydown
on:keypress
on:keyup
on:auxclick
on:click
on:contextmenu
on:dblclick
on:mousedown
on:mouseenter
on:mouseleave
on:mousemove
on:mouseover
on:mouseout
on:mouseup
on:select
on:wheel
on:drag
on:dragend
on:dragenter
on:dragstart
on:dragleave
on:dragover
on:drop
on:touchcancel
on:touchend
on:touchmove
on:touchstart
on:pointerover
on:pointerenter
on:pointerdown
on:pointermove
on:pointerup
on:pointercancel
on:pointerout
on:pointerleave
on:gotpointercapture
on:lostpointercapture
{...$$restProps}
>
<slot />
</div>
<style lang="scss"> <style lang="scss" global>
.layout-row { .layout-row {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
flex-grow: 1; flex-grow: 1;
.spacer {
flex: 1 1 100%;
} }
}
</style> </style>
+307 -229
View File
@@ -1,72 +1,80 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, nextTick } from "vue"; import { getContext, onMount, tick } from "svelte";
import { textInputCleanup } from "@/utility-functions/keyboard-entry"; import { textInputCleanup } from "@/utility-functions/keyboard-entry";
import { extractPixelData, rasterizeSVGCanvas } from "@/utility-functions/rasterization"; import { extractPixelData, rasterizeSVGCanvas } from "@/utility-functions/rasterization";
import { type DisplayEditableTextbox, type MouseCursorIcon, type XY } from "@/wasm-communication/messages"; import {
type MouseCursorIcon,
type XY,
DisplayEditableTextbox,
DisplayRemoveEditableTextbox,
TriggerTextCommit,
TriggerViewportResize,
UpdateDocumentArtboards,
UpdateDocumentArtwork,
UpdateDocumentOverlays,
UpdateDocumentRulers,
UpdateDocumentScrollbars,
UpdateEyedropperSamplingState,
UpdateMouseCursor,
} from "@/wasm-communication/messages";
import EyedropperPreview, { ZOOM_WINDOW_DIMENSIONS } from "@/components/floating-menus/EyedropperPreview.vue"; import EyedropperPreview, { ZOOM_WINDOW_DIMENSIONS } from "@/components/floating-menus/EyedropperPreview.svelte";
import LayoutCol from "@/components/layout/LayoutCol.vue"; import LayoutCol from "@/components/layout/LayoutCol.svelte";
import LayoutRow from "@/components/layout/LayoutRow.vue"; import LayoutRow from "@/components/layout/LayoutRow.svelte";
import CanvasRuler from "@/components/widgets/metrics/CanvasRuler.vue"; import CanvasRuler from "@/components/widgets/metrics/CanvasRuler.svelte";
import PersistentScrollbar from "@/components/widgets/metrics/PersistentScrollbar.vue"; import PersistentScrollbar from "@/components/widgets/metrics/PersistentScrollbar.svelte";
import WidgetLayout from "@/components/widgets/WidgetLayout.vue"; import WidgetLayout from "@/components/widgets/WidgetLayout.svelte";
import { type Editor } from "@/wasm-communication/editor";
import { type DocumentState } from "@/state-providers/document";
export default defineComponent({ let rulerHorizontal: CanvasRuler;
inject: ["editor", "panels", "document"], let rulerVertical: CanvasRuler;
data() { let canvasContainer: HTMLDivElement;
const scrollbarPos: XY = { x: 0.5, y: 0.5 };
const scrollbarSize: XY = { x: 0.5, y: 0.5 };
const scrollbarMultiplier: XY = { x: 0, y: 0 };
const rulerOrigin: XY = { x: 0, y: 0 }; const editor = getContext<Editor>("editor");
const document = getContext<DocumentState>("document");
return {
// Interactive text editing // Interactive text editing
textInput: undefined as undefined | HTMLDivElement, let textInput: undefined | HTMLDivElement = undefined;
// CSS properties // CSS properties
canvasSvgWidth: undefined as number | undefined, let canvasSvgWidth: number | undefined = undefined;
canvasSvgHeight: undefined as number | undefined, let canvasSvgHeight: number | undefined = undefined;
canvasCursor: "default", let canvasCursor = "default";
// Scrollbars // Scrollbars
scrollbarPos, let scrollbarPos: XY = { x: 0.5, y: 0.5 };
scrollbarSize, let scrollbarSize: XY = { x: 0.5, y: 0.5 };
scrollbarMultiplier, let scrollbarMultiplier: XY = { x: 0, y: 0 };
// Rulers // Rulers
rulerOrigin, let rulerOrigin: XY = { x: 0, y: 0 };
rulerSpacing: 100 as number, let rulerSpacing: number = 100;
rulerInterval: 100 as number, let rulerInterval: number = 100;
// Rendered SVG viewport data // Rendered SVG viewport data
artworkSvg: "" as string, let artworkSvg: string = "";
artboardSvg: "" as string, let artboardSvg: string = "";
overlaysSvg: "" as string, let overlaysSvg: string = "";
// Rasterized SVG viewport data, or none if it's not up-to-date // Rasterized SVG viewport data, or none if it's not up-to-date
rasterizedCanvas: undefined as HTMLCanvasElement | undefined, let rasterizedCanvas: HTMLCanvasElement | undefined = undefined;
rasterizedContext: undefined as CanvasRenderingContext2D | undefined, let rasterizedContext: CanvasRenderingContext2D | undefined = undefined;
// Cursor position for cursor floating menus like the Eyedropper tool zoom // Cursor position for cursor floating menus like the Eyedropper tool zoom
cursorLeft: 0, let cursorLeft = 0;
cursorTop: 0, let cursorTop = 0;
cursorEyedropper: false, let cursorEyedropper = false;
cursorEyedropperPreviewImageData: undefined as ImageData | undefined, let cursorEyedropperPreviewImageData: ImageData | undefined = undefined;
cursorEyedropperPreviewColorChoice: "", let cursorEyedropperPreviewColorChoice = "";
cursorEyedropperPreviewColorPrimary: "", let cursorEyedropperPreviewColorPrimary = "";
cursorEyedropperPreviewColorSecondary: "", let cursorEyedropperPreviewColorSecondary = "";
};
},
mounted() {
this.panels.registerPanel("Document", this);
// Once this component is mounted, we want to resend the document bounds to the backend via the resize event handler which does that $: canvasWidthCSS = canvasDimensionCSS(canvasSvgWidth);
window.dispatchEvent(new Event("resize")); $: canvasHeightCSS = canvasDimensionCSS(canvasSvgHeight);
},
methods: { function pasteFile(e: DragEvent) {
pasteFile(e: DragEvent) {
const { dataTransfer } = e; const { dataTransfer } = e;
if (!dataTransfer) return; if (!dataTransfer) return;
e.preventDefault(); e.preventDefault();
@@ -76,55 +84,58 @@ export default defineComponent({
if (file?.type.startsWith("image")) { if (file?.type.startsWith("image")) {
const imageData = await extractPixelData(file); const imageData = await extractPixelData(file);
this.editor.instance.pasteImage(new Uint8Array(imageData.data), imageData.width, imageData.height, e.clientX, e.clientY); editor.instance.pasteImage(new Uint8Array(imageData.data), imageData.width, imageData.height, e.clientX, e.clientY);
} }
}); });
}, }
translateCanvasX(newValue: number) {
const delta = newValue - this.scrollbarPos.x; function translateCanvasX(newValue: number) {
this.scrollbarPos.x = newValue; const delta = newValue - scrollbarPos.x;
this.editor.instance.translateCanvas(-delta * this.scrollbarMultiplier.x, 0); scrollbarPos.x = newValue;
}, editor.instance.translateCanvas(-delta * scrollbarMultiplier.x, 0);
translateCanvasY(newValue: number) { }
const delta = newValue - this.scrollbarPos.y;
this.scrollbarPos.y = newValue; function translateCanvasY(newValue: number) {
this.editor.instance.translateCanvas(0, -delta * this.scrollbarMultiplier.y); const delta = newValue - scrollbarPos.y;
}, scrollbarPos.y = newValue;
pageX(delta: number) { editor.instance.translateCanvas(0, -delta * scrollbarMultiplier.y);
}
function pageX(delta: number) {
const move = delta < 0 ? 1 : -1; const move = delta < 0 ? 1 : -1;
this.editor.instance.translateCanvasByFraction(move, 0); editor.instance.translateCanvasByFraction(move, 0);
}, }
pageY(delta: number) {
function pageY(delta: number) {
const move = delta < 0 ? 1 : -1; const move = delta < 0 ? 1 : -1;
this.editor.instance.translateCanvasByFraction(0, move); editor.instance.translateCanvasByFraction(0, move);
}, }
canvasPointerDown(e: PointerEvent) {
function canvasPointerDown(e: PointerEvent) {
const onEditbox = e.target instanceof HTMLDivElement && e.target.contentEditable; const onEditbox = e.target instanceof HTMLDivElement && e.target.contentEditable;
if (!onEditbox) (this.$refs.canvasDiv as HTMLDivElement | undefined)?.setPointerCapture(e.pointerId); if (!onEditbox) canvasContainer?.setPointerCapture(e.pointerId);
}, }
// Update rendered SVGs // Update rendered SVGs
async updateDocumentArtwork(svg: string) { export async function updateDocumentArtwork(svg: string) {
this.artworkSvg = svg; artworkSvg = svg;
this.rasterizedCanvas = undefined; rasterizedCanvas = undefined;
await nextTick(); await tick();
if (this.textInput) { if (textInput) {
const canvasDiv = this.$refs.canvasDiv as HTMLDivElement | undefined; const foreignObject = canvasContainer.getElementsByTagName("foreignObject")[0] as SVGForeignObjectElement;
if (!canvasDiv) return;
const foreignObject = canvasDiv.getElementsByTagName("foreignObject")[0] as SVGForeignObjectElement;
if (foreignObject.children.length > 0) return; if (foreignObject.children.length > 0) return;
const addedInput = foreignObject.appendChild(this.textInput); const addedInput = foreignObject.appendChild(textInput);
window.dispatchEvent(new CustomEvent("modifyinputfield", { detail: addedInput })); window.dispatchEvent(new CustomEvent("modifyinputfield", { detail: addedInput }));
await nextTick(); await tick();
// Necessary to select contenteditable: https://stackoverflow.com/questions/6139107/programmatically-select-text-in-a-contenteditable-html-element/6150060#6150060 // Necessary to select contenteditable: https://stackoverflow.com/questions/6139107/programmatically-select-text-in-a-contenteditable-html-element/6150060#6150060
const range = document.createRange(); const range = window.document.createRange();
range.selectNodeContents(addedInput); range.selectNodeContents(addedInput);
const selection = window.getSelection(); const selection = window.getSelection();
@@ -136,77 +147,83 @@ export default defineComponent({
addedInput.focus(); addedInput.focus();
addedInput.click(); addedInput.click();
} }
}, }
updateDocumentOverlays(svg: string) {
this.overlaysSvg = svg; export function updateDocumentOverlays(svg: string) {
}, overlaysSvg = svg;
updateDocumentArtboards(svg: string) { }
this.artboardSvg = svg;
this.rasterizedCanvas = undefined; export function updateDocumentArtboards(svg: string) {
}, artboardSvg = svg;
async updateEyedropperSamplingState(mousePosition: XY | undefined, colorPrimary: string, colorSecondary: string): Promise<[number, number, number] | undefined> { rasterizedCanvas = undefined;
}
export async function updateEyedropperSamplingState(mousePosition: XY | undefined, colorPrimary: string, colorSecondary: string): Promise<[number, number, number] | undefined> {
if (mousePosition === undefined) { if (mousePosition === undefined) {
this.cursorEyedropper = false; cursorEyedropper = false;
return undefined; return undefined;
} }
this.cursorEyedropper = true; cursorEyedropper = true;
if (this.canvasSvgWidth === undefined || this.canvasSvgHeight === undefined) return undefined; if (canvasSvgWidth === undefined || canvasSvgHeight === undefined) return undefined;
this.cursorLeft = mousePosition.x; cursorLeft = mousePosition.x;
this.cursorTop = mousePosition.y; cursorTop = mousePosition.y;
// This works nearly perfectly, but sometimes at odd DPI scale factors like 1.25, the anti-aliasing color can yield slightly incorrect colors (potential room for future improvement) // This works nearly perfectly, but sometimes at odd DPI scale factors like 1.25, the anti-aliasing color can yield slightly incorrect colors (potential room for future improvement)
const dpiFactor = window.devicePixelRatio; const dpiFactor = window.devicePixelRatio;
const [width, height] = [this.canvasSvgWidth, this.canvasSvgHeight]; const [width, height] = [canvasSvgWidth, canvasSvgHeight];
const outsideArtboardsColor = getComputedStyle(document.documentElement).getPropertyValue("--color-2-mildblack"); const outsideArtboardsColor = getComputedStyle(window.document.documentElement).getPropertyValue("--color-2-mildblack");
const outsideArtboards = `<rect x="0" y="0" width="100%" height="100%" fill="${outsideArtboardsColor}" />`; const outsideArtboards = `<rect x="0" y="0" width="100%" height="100%" fill="${outsideArtboardsColor}" />`;
const artboards = this.artboardSvg; const artboards = artboardSvg;
const artwork = this.artworkSvg; const artwork = artworkSvg;
const svg = ` const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">${outsideArtboards}${artboards}${artwork}</svg> <svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">${outsideArtboards}${artboards}${artwork}</svg>
`.trim(); `.trim();
if (!this.rasterizedCanvas) { if (!rasterizedCanvas) {
this.rasterizedCanvas = await rasterizeSVGCanvas(svg, width * dpiFactor, height * dpiFactor, "image/png"); rasterizedCanvas = await rasterizeSVGCanvas(svg, width * dpiFactor, height * dpiFactor, "image/png");
this.rasterizedContext = this.rasterizedCanvas.getContext("2d") || undefined; rasterizedContext = rasterizedCanvas.getContext("2d") || undefined;
} }
if (!this.rasterizedContext) return undefined; if (!rasterizedContext) return undefined;
const rgbToHex = (r: number, g: number, b: number): string => `#${[r, g, b].map((x) => x.toString(16).padStart(2, "0")).join("")}`; const rgbToHex = (r: number, g: number, b: number): string => `#${[r, g, b].map((x) => x.toString(16).padStart(2, "0")).join("")}`;
const pixel = this.rasterizedContext.getImageData(mousePosition.x * dpiFactor, mousePosition.y * dpiFactor, 1, 1).data; const pixel = rasterizedContext.getImageData(mousePosition.x * dpiFactor, mousePosition.y * dpiFactor, 1, 1).data;
const hex = rgbToHex(pixel[0], pixel[1], pixel[2]); const hex = rgbToHex(pixel[0], pixel[1], pixel[2]);
const rgb: [number, number, number] = [pixel[0] / 255, pixel[1] / 255, pixel[2] / 255]; const rgb: [number, number, number] = [pixel[0] / 255, pixel[1] / 255, pixel[2] / 255];
this.cursorEyedropperPreviewColorChoice = hex; cursorEyedropperPreviewColorChoice = hex;
this.cursorEyedropperPreviewColorPrimary = colorPrimary; cursorEyedropperPreviewColorPrimary = colorPrimary;
this.cursorEyedropperPreviewColorSecondary = colorSecondary; cursorEyedropperPreviewColorSecondary = colorSecondary;
const previewRegion = this.rasterizedContext.getImageData( const previewRegion = rasterizedContext.getImageData(
mousePosition.x * dpiFactor - (ZOOM_WINDOW_DIMENSIONS - 1) / 2, mousePosition.x * dpiFactor - (ZOOM_WINDOW_DIMENSIONS - 1) / 2,
mousePosition.y * dpiFactor - (ZOOM_WINDOW_DIMENSIONS - 1) / 2, mousePosition.y * dpiFactor - (ZOOM_WINDOW_DIMENSIONS - 1) / 2,
ZOOM_WINDOW_DIMENSIONS, ZOOM_WINDOW_DIMENSIONS,
ZOOM_WINDOW_DIMENSIONS ZOOM_WINDOW_DIMENSIONS
); );
this.cursorEyedropperPreviewImageData = previewRegion; cursorEyedropperPreviewImageData = previewRegion;
return rgb; return rgb;
}, }
// Update scrollbars and rulers // Update scrollbars and rulers
updateDocumentScrollbars(position: XY, size: XY, multiplier: XY) { export function updateDocumentScrollbars(position: XY, size: XY, multiplier: XY) {
this.scrollbarPos = position; scrollbarPos = position;
this.scrollbarSize = size; scrollbarSize = size;
this.scrollbarMultiplier = multiplier; scrollbarMultiplier = multiplier;
}, }
updateDocumentRulers(origin: XY, spacing: number, interval: number) {
this.rulerOrigin = origin; export function updateDocumentRulers(origin: XY, spacing: number, interval: number) {
this.rulerSpacing = spacing; rulerOrigin = origin;
this.rulerInterval = interval; rulerSpacing = spacing;
}, rulerInterval = interval;
}
// Update mouse cursor icon // Update mouse cursor icon
updateMouseCursor(cursor: MouseCursorIcon) { export function updateMouseCursor(cursor: MouseCursorIcon) {
let cursorString: string = cursor; let cursorString: string = cursor;
// This isn't very clean but it's good enough for now until we need more icons, then we can build something more robust (consider blob URLs) // This isn't very clean but it's good enough for now until we need more icons, then we can build something more robust (consider blob URLs)
@@ -230,155 +247,216 @@ export default defineComponent({
cursorString = `url('data:image/svg+xml;utf8,${svg}') 8 8, alias`; cursorString = `url('data:image/svg+xml;utf8,${svg}') 8 8, alias`;
} }
this.canvasCursor = cursorString; canvasCursor = cursorString;
}, }
// Text entry // Text entry
triggerTextCommit() { export function triggerTextCommit() {
if (!this.textInput) return; if (!textInput) return;
const textCleaned = textInputCleanup(this.textInput.innerText); const textCleaned = textInputCleanup(textInput.innerText);
this.editor.instance.onChangeText(textCleaned); editor.instance.onChangeText(textCleaned);
}, }
displayEditableTextbox(displayEditableTextbox: DisplayEditableTextbox) {
this.textInput = document.createElement("div") as HTMLDivElement;
if (displayEditableTextbox.text === "") this.textInput.textContent = ""; export function displayEditableTextbox(displayEditableTextbox: DisplayEditableTextbox) {
else this.textInput.textContent = `${displayEditableTextbox.text}\n`; textInput = window.document.createElement("div") as HTMLDivElement;
this.textInput.contentEditable = "true"; if (displayEditableTextbox.text === "") textInput.textContent = "";
this.textInput.style.width = displayEditableTextbox.lineWidth ? `${displayEditableTextbox.lineWidth}px` : "max-content"; else textInput.textContent = `${displayEditableTextbox.text}\n`;
this.textInput.style.height = "auto";
this.textInput.style.fontSize = `${displayEditableTextbox.fontSize}px`;
this.textInput.style.color = displayEditableTextbox.color.toHexOptionalAlpha() || "transparent";
this.textInput.oninput = (): void => { textInput.contentEditable = "true";
if (!this.textInput) return; textInput.style.width = displayEditableTextbox.lineWidth ? `${displayEditableTextbox.lineWidth}px` : "max-content";
this.editor.instance.updateBounds(textInputCleanup(this.textInput.innerText)); textInput.style.height = "auto";
textInput.style.fontSize = `${displayEditableTextbox.fontSize}px`;
textInput.style.color = displayEditableTextbox.color.toHexOptionalAlpha() || "transparent";
textInput.oninput = (): void => {
if (!textInput) return;
editor.instance.updateBounds(textInputCleanup(textInput.innerText));
}; };
}, }
displayRemoveEditableTextbox() {
this.textInput = undefined;
window.dispatchEvent(new CustomEvent("modifyinputfield", { detail: undefined }));
},
// Resize elements to render the new viewport size
viewportResize() {
// Resize the canvas
const canvasDiv = this.$refs.canvasDiv as HTMLDivElement | undefined;
if (!canvasDiv) return;
this.canvasSvgWidth = Math.ceil(parseFloat(getComputedStyle(canvasDiv).width)); export function displayRemoveEditableTextbox() {
this.canvasSvgHeight = Math.ceil(parseFloat(getComputedStyle(canvasDiv).height)); textInput = undefined;
window.dispatchEvent(new CustomEvent("modifyinputfield", { detail: undefined }));
}
// Resize elements to render the new viewport size
export function viewportResize() {
// Resize the canvas
canvasSvgWidth = Math.ceil(parseFloat(getComputedStyle(canvasContainer).width));
canvasSvgHeight = Math.ceil(parseFloat(getComputedStyle(canvasContainer).height));
// Resize the rulers // Resize the rulers
(this.$refs.rulerHorizontal as typeof CanvasRuler | undefined)?.resize(); rulerHorizontal?.resize();
(this.$refs.rulerVertical as typeof CanvasRuler | undefined)?.resize(); rulerVertical?.resize();
}, }
canvasDimensionCSS(dimension: number | undefined): string {
function canvasDimensionCSS(dimension: number | undefined): string {
// Temporary placeholder until the first actual value is populated // Temporary placeholder until the first actual value is populated
// This at least gets close to the correct value but an actual number is required to prevent CSS from causing non-integer sizing making the SVG render with anti-aliasing // This at least gets close to the correct value but an actual number is required to prevent CSS from causing non-integer sizing making the SVG render with anti-aliasing
if (dimension === undefined) return "100%"; if (dimension === undefined) return "100%";
// Dimension is rounded up to the nearest even number because resizing is centered, and dividing an odd number by 2 for centering causes antialiasing // Dimension is rounded up to the nearest even number because resizing is centered, and dividing an odd number by 2 for centering causes antialiasing
return `${dimension % 2 === 1 ? dimension + 1 : dimension}px`; return `${dimension % 2 === 1 ? dimension + 1 : dimension}px`;
}, }
},
computed: { onMount(() => {
canvasWidthCSS(): string { // Update rendered SVGs
return this.canvasDimensionCSS(this.canvasSvgWidth); editor.subscriptions.subscribeJsMessage(UpdateDocumentArtwork, async (data) => {
}, await tick();
canvasHeightCSS(): string {
return this.canvasDimensionCSS(this.canvasSvgHeight); updateDocumentArtwork(data.svg);
}, });
}, editor.subscriptions.subscribeJsMessage(UpdateDocumentOverlays, async (data) => {
components: { await tick();
CanvasRuler,
LayoutCol, updateDocumentOverlays(data.svg);
LayoutRow, });
PersistentScrollbar, editor.subscriptions.subscribeJsMessage(UpdateDocumentArtboards, async (data) => {
WidgetLayout, await tick();
EyedropperPreview,
}, updateDocumentArtboards(data.svg);
}); });
editor.subscriptions.subscribeJsMessage(UpdateEyedropperSamplingState, async (data) => {
await tick();
const { mousePosition, primaryColor, secondaryColor, setColorChoice } = data;
const rgb = await updateEyedropperSamplingState(mousePosition, primaryColor, secondaryColor);
if (setColorChoice && rgb) {
if (setColorChoice === "Primary") editor.instance.updatePrimaryColor(...rgb, 1);
if (setColorChoice === "Secondary") editor.instance.updateSecondaryColor(...rgb, 1);
}
});
// Update scrollbars and rulers
editor.subscriptions.subscribeJsMessage(UpdateDocumentScrollbars, async (data) => {
await tick();
const { position, size, multiplier } = data;
updateDocumentScrollbars(position, size, multiplier);
});
editor.subscriptions.subscribeJsMessage(UpdateDocumentRulers, async (data) => {
await tick();
const { origin, spacing, interval } = data;
updateDocumentRulers(origin, spacing, interval);
});
// Update mouse cursor icon
editor.subscriptions.subscribeJsMessage(UpdateMouseCursor, async (data) => {
await tick();
const { cursor } = data;
updateMouseCursor(cursor);
});
// Text entry
editor.subscriptions.subscribeJsMessage(TriggerTextCommit, async () => {
await tick();
triggerTextCommit();
});
editor.subscriptions.subscribeJsMessage(DisplayEditableTextbox, async (data) => {
await tick();
displayEditableTextbox(data);
});
editor.subscriptions.subscribeJsMessage(DisplayRemoveEditableTextbox, async () => {
await tick();
displayRemoveEditableTextbox();
});
// Resize elements to render the new viewport size
editor.subscriptions.subscribeJsMessage(TriggerViewportResize, async () => {
await tick();
viewportResize();
});
// Once this component is mounted, we want to resend the document bounds to the backend via the resize event handler which does that
window.dispatchEvent(new Event("resize"));
});
</script> </script>
<template> <LayoutCol class="document">
<LayoutCol class="document"> <LayoutRow class="options-bar" scrollableX={true}>
<LayoutRow class="options-bar" :scrollableX="true"> <WidgetLayout layout={$document.documentModeLayout} />
<WidgetLayout :layout="document.state.documentModeLayout" /> <WidgetLayout layout={$document.toolOptionsLayout} />
<WidgetLayout :layout="document.state.toolOptionsLayout" />
<LayoutRow class="spacer"></LayoutRow> <LayoutRow class="spacer" />
<WidgetLayout :layout="document.state.documentBarLayout" /> <WidgetLayout layout={$document.documentBarLayout} />
</LayoutRow> </LayoutRow>
<LayoutRow class="shelf-and-viewport"> <LayoutRow class="shelf-and-viewport">
<LayoutCol class="shelf"> <LayoutCol class="shelf">
<LayoutCol class="tools" :scrollableY="true"> <LayoutCol class="tools" scrollableY={true}>
<WidgetLayout :layout="document.state.toolShelfLayout" /> <WidgetLayout layout={$document.toolShelfLayout} />
</LayoutCol> </LayoutCol>
<LayoutCol class="spacer"></LayoutCol> <LayoutCol class="spacer" />
<LayoutCol class="working-colors"> <LayoutCol class="working-colors">
<WidgetLayout :layout="document.state.workingColorsLayout" /> <WidgetLayout layout={$document.workingColorsLayout} />
</LayoutCol> </LayoutCol>
</LayoutCol> </LayoutCol>
<LayoutCol class="viewport"> <LayoutCol class="viewport">
<LayoutRow class="bar-area"> <LayoutRow class="bar-area top-ruler">
<CanvasRuler :origin="rulerOrigin.x" :majorMarkSpacing="rulerSpacing" :numberInterval="rulerInterval" :direction="'Horizontal'" class="top-ruler" ref="rulerHorizontal" /> <CanvasRuler origin={rulerOrigin.x} majorMarkSpacing={rulerSpacing} numberInterval={rulerInterval} direction="Horizontal" bind:this={rulerHorizontal} />
</LayoutRow> </LayoutRow>
<LayoutRow class="canvas-area"> <LayoutRow class="canvas-area">
<LayoutCol class="bar-area"> <LayoutCol class="bar-area">
<CanvasRuler :origin="rulerOrigin.y" :majorMarkSpacing="rulerSpacing" :numberInterval="rulerInterval" :direction="'Vertical'" ref="rulerVertical" /> <CanvasRuler origin={rulerOrigin.y} majorMarkSpacing={rulerSpacing} numberInterval={rulerInterval} direction="Vertical" bind:this={rulerVertical} />
</LayoutCol> </LayoutCol>
<LayoutCol class="canvas-area" :style="{ cursor: canvasCursor }"> <LayoutCol class="canvas-area" styles={{ cursor: canvasCursor }}>
{#if cursorEyedropper}
<EyedropperPreview <EyedropperPreview
v-if="cursorEyedropper" colorChoice={cursorEyedropperPreviewColorChoice}
:colorChoice="cursorEyedropperPreviewColorChoice" primaryColor={cursorEyedropperPreviewColorPrimary}
:primaryColor="cursorEyedropperPreviewColorPrimary" secondaryColor={cursorEyedropperPreviewColorSecondary}
:secondaryColor="cursorEyedropperPreviewColorSecondary" imageData={cursorEyedropperPreviewImageData}
:imageData="cursorEyedropperPreviewImageData" x={cursorLeft}
:style="{ left: cursorLeft + 'px', top: cursorTop + 'px' }" y={cursorTop}
/> />
<div class="canvas" @pointerdown="(e: PointerEvent) => canvasPointerDown(e)" @dragover="(e) => e.preventDefault()" @drop="(e) => pasteFile(e)" ref="canvasDiv" data-canvas> {/if}
<svg class="artboards" v-html="artboardSvg" :style="{ width: canvasWidthCSS, height: canvasHeightCSS }"></svg> <div class="canvas" on:pointerdown={(e) => canvasPointerDown(e)} on:dragover={(e) => e.preventDefault()} on:drop={(e) => pasteFile(e)} bind:this={canvasContainer} data-canvas>
<svg <svg class="artboards" style:width={canvasWidthCSS} style:height={canvasHeightCSS}>
class="artwork" {@html artboardSvg}
xmlns="http://www.w3.org/2000/svg" </svg>
xmlns:xlink="http://www.w3.org/1999/xlink" <svg class="artwork" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style:width={canvasWidthCSS} style:height={canvasHeightCSS}>
v-html="artworkSvg" {@html artworkSvg}
:style="{ width: canvasWidthCSS, height: canvasHeightCSS }" </svg>
></svg> <svg class="overlays" style:width={canvasWidthCSS} style:height={canvasHeightCSS}>
<svg class="overlays" v-html="overlaysSvg" :style="{ width: canvasWidthCSS, height: canvasHeightCSS }"></svg> {@html overlaysSvg}
</svg>
</div> </div>
</LayoutCol> </LayoutCol>
<LayoutCol class="bar-area"> <LayoutCol class="bar-area right-scrollbar">
<PersistentScrollbar <PersistentScrollbar
:direction="'Vertical'" direction="Vertical"
:handlePosition="scrollbarPos.y" handleLength={scrollbarSize.y}
@update:handlePosition="(newValue: number) => translateCanvasY(newValue)" handlePosition={scrollbarPos.y}
v-model:handleLength="scrollbarSize.y" on:handlePosition={({ detail }) => translateCanvasY(detail)}
@pressTrack="(delta: number) => pageY(delta)" on:pressTrack={({ detail }) => pageY(detail)}
class="right-scrollbar"
/> />
</LayoutCol> </LayoutCol>
</LayoutRow> </LayoutRow>
<LayoutRow class="bar-area"> <LayoutRow class="bar-area bottom-scrollbar">
<PersistentScrollbar <PersistentScrollbar
:direction="'Horizontal'" direction="Horizontal"
:handlePosition="scrollbarPos.x" handleLength={scrollbarSize.x}
@update:handlePosition="(newValue: number) => translateCanvasX(newValue)" handlePosition={scrollbarPos.x}
v-model:handleLength="scrollbarSize.x" on:handlePosition={({ detail }) => translateCanvasX(detail)}
@pressTrack="(delta: number) => pageX(delta)" on:pressTrack={({ detail }) => pageX(detail)}
class="bottom-scrollbar"
/> />
</LayoutRow> </LayoutRow>
</LayoutCol> </LayoutCol>
</LayoutRow> </LayoutRow>
</LayoutCol> </LayoutCol>
</template>
<style lang="scss"> <style lang="scss" global>
.document { .document {
height: 100%; height: 100%;
.options-bar { .options-bar {
@@ -460,16 +538,16 @@ export default defineComponent({
flex: 0 0 auto; flex: 0 0 auto;
} }
.top-ruler { .top-ruler .canvas-ruler {
padding-left: 16px; padding-left: 16px;
margin-right: 16px; margin-right: 16px;
} }
.right-scrollbar { .right-scrollbar .persistent-scrollbar {
margin-top: -16px; margin-top: -16px;
} }
.bottom-scrollbar { .bottom-scrollbar .persistent-scrollbar {
margin-right: 16px; margin-right: 16px;
} }
@@ -523,5 +601,5 @@ export default defineComponent({
} }
} }
} }
} }
</style> </style>
+214 -188
View File
@@ -1,9 +1,9 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, nextTick } from "vue"; import { getContext, onMount, tick } from "svelte";
import { beginDraggingElement } from "@/io-managers/drag"; import { beginDraggingElement } from "@/io-managers/drag";
import { platformIsMac } from "@/utility-functions/platform"; import { platformIsMac } from "@/utility-functions/platform";
import { import {
type LayerType, type LayerType,
type LayerTypeData, type LayerTypeData,
type LayerPanelEntry, type LayerPanelEntry,
@@ -13,106 +13,148 @@ import {
UpdateDocumentLayerTreeStructureJs, UpdateDocumentLayerTreeStructureJs,
UpdateLayerTreeOptionsLayout, UpdateLayerTreeOptionsLayout,
layerTypeData, layerTypeData,
} from "@/wasm-communication/messages"; } from "@/wasm-communication/messages";
import LayoutCol from "@/components/layout/LayoutCol.vue"; import LayoutCol from "@/components/layout/LayoutCol.svelte";
import LayoutRow from "@/components/layout/LayoutRow.vue"; import LayoutRow from "@/components/layout/LayoutRow.svelte";
import IconButton from "@/components/widgets/buttons/IconButton.vue"; import IconButton from "@/components/widgets/buttons/IconButton.svelte";
import IconLabel from "@/components/widgets/labels/IconLabel.vue"; import IconLabel from "@/components/widgets/labels/IconLabel.svelte";
import WidgetLayout from "@/components/widgets/WidgetLayout.vue"; import WidgetLayout from "@/components/widgets/WidgetLayout.svelte";
import { type Editor } from "@/wasm-communication/editor";
type LayerListingInfo = { folderIndex: number; bottomLayer: boolean; editingName: boolean; entry: LayerPanelEntry }; type LayerListingInfo = {
folderIndex: number;
bottomLayer: boolean;
editingName: boolean;
entry: LayerPanelEntry;
};
const RANGE_TO_INSERT_WITHIN_BOTTOM_FOLDER_NOT_ROOT = 20; let list: LayoutCol;
const LAYER_INDENT = 16;
const INSERT_MARK_MARGIN_LEFT = 4 + 32 + LAYER_INDENT;
const INSERT_MARK_OFFSET = 2;
type DraggingData = { select?: () => void; insertFolder: BigUint64Array; insertIndex: number; highlightFolder: boolean; markerHeight: number }; const RANGE_TO_INSERT_WITHIN_BOTTOM_FOLDER_NOT_ROOT = 20;
const LAYER_INDENT = 16;
const INSERT_MARK_MARGIN_LEFT = 4 + 32 + LAYER_INDENT;
const INSERT_MARK_OFFSET = 2;
type DraggingData = {
select?: () => void;
insertFolder: BigUint64Array;
insertIndex: number;
highlightFolder: boolean;
markerHeight: number;
};
const editor = getContext<Editor>("editor");
export default defineComponent({
inject: ["editor"],
data() {
return {
// Layer data // Layer data
layerCache: new Map() as Map<string, LayerPanelEntry>, // TODO: replace with BigUint64Array as index let layerCache: Map<string, LayerPanelEntry> = new Map(); // TODO: replace with BigUint64Array as index
layers: [] as LayerListingInfo[], let layers: LayerListingInfo[] = [];
// Interactive dragging // Interactive dragging
draggable: true, let draggable = true;
draggingData: undefined as undefined | DraggingData, let draggingData: undefined | DraggingData = undefined;
fakeHighlight: undefined as undefined | BigUint64Array[], let fakeHighlight: undefined | BigUint64Array[] = undefined;
dragInPanel: false, let dragInPanel = false;
// Layouts // Layouts
layerTreeOptionsLayout: defaultWidgetLayout(), let layerTreeOptionsLayout = defaultWidgetLayout();
};
}, onMount(() => {
methods: { editor.subscriptions.subscribeJsMessage(UpdateLayerTreeOptionsLayout, (updateLayerTreeOptionsLayout) => {
layerIndent(layer: LayerPanelEntry): string { patchWidgetLayout(layerTreeOptionsLayout, updateLayerTreeOptionsLayout);
layerTreeOptionsLayout = layerTreeOptionsLayout;
});
editor.subscriptions.subscribeJsMessage(UpdateDocumentLayerTreeStructureJs, (updateDocumentLayerTreeStructure) => {
rebuildLayerTree(updateDocumentLayerTreeStructure);
});
editor.subscriptions.subscribeJsMessage(UpdateDocumentLayerDetails, (updateDocumentLayerDetails) => {
const targetLayer = updateDocumentLayerDetails.data;
const targetPath = targetLayer.path;
updateLayerInTree(targetPath, targetLayer);
});
});
function layerIndent(layer: LayerPanelEntry): string {
return `${layer.path.length * LAYER_INDENT}px`; return `${layer.path.length * LAYER_INDENT}px`;
}, }
markIndent(path: BigUint64Array): string {
function markIndent(path: BigUint64Array): string {
return `${INSERT_MARK_MARGIN_LEFT + path.length * LAYER_INDENT}px`; return `${INSERT_MARK_MARGIN_LEFT + path.length * LAYER_INDENT}px`;
}, }
markTopOffset(height: number): string {
function markTopOffset(height: number): string {
return `${height}px`; return `${height}px`;
}, }
toggleLayerVisibility(path: BigUint64Array) {
this.editor.instance.toggleLayerVisibility(path); function toggleLayerVisibility(path: BigUint64Array) {
}, editor.instance.toggleLayerVisibility(path);
handleExpandArrowClick(path: BigUint64Array) { }
this.editor.instance.toggleLayerExpansion(path);
}, function handleExpandArrowClick(path: BigUint64Array) {
async onEditLayerName(listing: LayerListingInfo) { editor.instance.toggleLayerExpansion(path);
}
async function onEditLayerName(listing: LayerListingInfo) {
if (listing.editingName) return; if (listing.editingName) return;
listing.editingName = true; listing.editingName = true;
this.draggable = false; draggable = false;
await nextTick(); await tick();
const tree: HTMLDivElement | undefined = (this.$refs.list as typeof LayoutCol | undefined)?.$el; const textInput = (list?.div().querySelector("[data-text-input]:not([disabled])") || undefined) as HTMLInputElement | undefined;
const textInput: HTMLInputElement | undefined = tree?.querySelector("[data-text-input]:not([disabled])") || undefined;
textInput?.select(); textInput?.select();
}, }
onEditLayerNameChange(listing: LayerListingInfo, e: Event) {
function onEditLayerNameChange(listing: LayerListingInfo, e: Event) {
// Eliminate duplicate events // Eliminate duplicate events
if (!listing.editingName) return; if (!listing.editingName) return;
this.draggable = true; draggable = true;
const name = (e.target as HTMLInputElement | undefined)?.value; const name = (e.target as HTMLInputElement | undefined)?.value;
listing.editingName = false; listing.editingName = false;
if (name) this.editor.instance.setLayerName(listing.entry.path, name); if (name) editor.instance.setLayerName(listing.entry.path, name);
}, }
async onEditLayerNameDeselect(listing: LayerListingInfo) {
this.draggable = true; async function onEditLayerNameDeselect(listing: LayerListingInfo) {
draggable = true;
listing.editingName = false; listing.editingName = false;
await nextTick(); await tick();
window.getSelection()?.removeAllRanges(); window.getSelection()?.removeAllRanges();
}, }
async selectLayer(ctrl: boolean, cmd: boolean, shift: boolean, listing: LayerListingInfo, event: Event) {
function selectLayerWithModifiers(e: MouseEvent, listing: LayerListingInfo) {
// Get the pressed state of the modifier keys
const [ctrl, meta, shift, alt] = [e.ctrlKey, e.metaKey, e.shiftKey, e.altKey];
// Get the state of the platform's accel key and its opposite platform's accel key
const [accel, oppositeAccel] = platformIsMac() ? [meta, ctrl] : [ctrl, meta];
// Select the layer only if the accel and/or shift keys are pressed
if (!oppositeAccel && !alt) selectLayer(accel, shift, listing);
e.stopPropagation();
}
function selectLayer(accel: boolean, shift: boolean, listing: LayerListingInfo) {
// Don't select while we are entering text to rename the layer
if (listing.editingName) return; if (listing.editingName) return;
const ctrlOrCmd = platformIsMac() ? cmd : ctrl; editor.instance.selectLayer(listing.entry.path, accel, shift);
// Pressing the Ctrl key on a Mac, or the Cmd key on another platform, is a violation of the `.exact` qualifier so we filter it out here }
const opposite = platformIsMac() ? ctrl : cmd;
if (!opposite) this.editor.instance.selectLayer(listing.entry.path, ctrlOrCmd, shift); async function deselectAllLayers() {
editor.instance.deselectAllLayers();
}
// We always want to stop propagation so the click event doesn't pass through the layer and cause a deselection by clicking the layer panel background function calculateDragIndex(tree: LayoutCol, clientY: number, select?: () => void): DraggingData {
// This is also why we cover the remaining cases not considered by the `.exact` qualifier, in the last two bindings on the layer element, with a `stopPropagation()` call const treeChildren = tree.div().children;
event.stopPropagation(); const treeOffset = tree.div().getBoundingClientRect().top;
},
async deselectAllLayers() {
this.editor.instance.deselectAllLayers();
},
calculateDragIndex(tree: HTMLDivElement, clientY: number, select?: () => void): DraggingData {
const treeChildren = tree.children;
const treeOffset = tree.getBoundingClientRect().top;
// Closest distance to the middle of the row along the Y axis // Closest distance to the middle of the row along the Y axis
let closest = Infinity; let closest = Infinity;
@@ -136,7 +178,7 @@ export default defineComponent({
const indexAttribute = child.getAttribute("data-index"); const indexAttribute = child.getAttribute("data-index");
if (!indexAttribute) return; if (!indexAttribute) return;
const { folderIndex, entry: layer } = this.layers[parseInt(indexAttribute, 10)]; const { folderIndex, entry: layer } = layers[parseInt(indexAttribute, 10)];
const rect = child.getBoundingClientRect(); const rect = child.getBoundingClientRect();
const position = rect.top + rect.height / 2; const position = rect.top + rect.height / 2;
@@ -169,16 +211,23 @@ export default defineComponent({
markerHeight -= treeOffset; markerHeight -= treeOffset;
return { select, insertFolder, insertIndex, highlightFolder, markerHeight }; return {
}, select,
async dragStart(event: DragEvent, listing: LayerListingInfo) { insertFolder,
insertIndex,
highlightFolder,
markerHeight,
};
}
async function dragStart(event: DragEvent, listing: LayerListingInfo) {
const layer = listing.entry; const layer = listing.entry;
this.dragInPanel = true; dragInPanel = true;
if (!layer.layerMetadata.selected) { if (!layer.layerMetadata.selected) {
this.fakeHighlight = [layer.path]; fakeHighlight = [layer.path];
} }
const select = (): void => { const select = (): void => {
if (!layer.layerMetadata.selected) this.selectLayer(false, false, false, listing, event); if (!layer.layerMetadata.selected) selectLayer(false, false, listing);
}; };
const target = (event.target || undefined) as HTMLElement | undefined; const target = (event.target || undefined) as HTMLElement | undefined;
@@ -191,42 +240,46 @@ export default defineComponent({
event.dataTransfer.effectAllowed = "move"; event.dataTransfer.effectAllowed = "move";
} }
const tree: HTMLDivElement | undefined = (this.$refs.list as typeof LayoutCol | undefined)?.$el; if (list) draggingData = calculateDragIndex(list, event.clientY, select);
if (tree) this.draggingData = this.calculateDragIndex(tree, event.clientY, select); }
},
updateInsertLine(event: DragEvent) { function updateInsertLine(event: DragEvent) {
// Stop the drag from being shown as cancelled // Stop the drag from being shown as cancelled
event.preventDefault(); event.preventDefault();
this.dragInPanel = true; dragInPanel = true;
const tree: HTMLDivElement | undefined = (this.$refs.list as typeof LayoutCol | undefined)?.$el; if (list) draggingData = calculateDragIndex(list, event.clientY, draggingData?.select);
if (tree) this.draggingData = this.calculateDragIndex(tree, event.clientY, this.draggingData?.select); }
},
async drop() { async function drop() {
if (this.draggingData && this.dragInPanel) { if (draggingData && dragInPanel) {
const { select, insertFolder, insertIndex } = this.draggingData; const { select, insertFolder, insertIndex } = draggingData;
select?.(); select?.();
this.editor.instance.moveLayerInTree(insertFolder, insertIndex); editor.instance.moveLayerInTree(insertFolder, insertIndex);
} }
this.draggingData = undefined; draggingData = undefined;
this.fakeHighlight = undefined; fakeHighlight = undefined;
this.dragInPanel = false; dragInPanel = false;
}, }
rebuildLayerTree(updateDocumentLayerTreeStructure: UpdateDocumentLayerTreeStructureJs) {
const layerWithNameBeingEdited = this.layers.find((layer: LayerListingInfo) => layer.editingName); function rebuildLayerTree(updateDocumentLayerTreeStructure: UpdateDocumentLayerTreeStructureJs) {
const layerWithNameBeingEdited = layers.find((layer: LayerListingInfo) => layer.editingName);
const layerPathWithNameBeingEdited = layerWithNameBeingEdited?.entry.path; const layerPathWithNameBeingEdited = layerWithNameBeingEdited?.entry.path;
const layerIdWithNameBeingEdited = layerPathWithNameBeingEdited?.slice(-1)[0]; const layerIdWithNameBeingEdited = layerPathWithNameBeingEdited?.slice(-1)[0];
const path = [] as bigint[]; const path: bigint[] = [];
this.layers = [] as LayerListingInfo[];
const recurse = (folder: UpdateDocumentLayerTreeStructureJs, layers: LayerListingInfo[], cache: Map<string, LayerPanelEntry>): void => { // Clear the layer tree before rebuilding it
layers = [];
// Build the new layer tree
const recurse = (folder: UpdateDocumentLayerTreeStructureJs): void => {
folder.children.forEach((item, index) => { folder.children.forEach((item, index) => {
// TODO: fix toString // TODO: fix toString
const layerId = BigInt(item.layerId.toString()); const layerId = BigInt(item.layerId.toString());
path.push(layerId); path.push(layerId);
const mapping = cache.get(path.toString()); const mapping = layerCache.get(path.toString());
if (mapping) { if (mapping) {
layers.push({ layers.push({
folderIndex: index, folderIndex: index,
@@ -237,128 +290,101 @@ export default defineComponent({
} }
// Call self recursively if there are any children // Call self recursively if there are any children
if (item.children.length >= 1) recurse(item, layers, cache); if (item.children.length >= 1) recurse(item);
path.pop(); path.pop();
}); });
}; };
recurse(updateDocumentLayerTreeStructure);
layers = layers;
}
recurse(updateDocumentLayerTreeStructure, this.layers, this.layerCache); function updateLayerInTree(targetPath: BigUint64Array, targetLayer: LayerPanelEntry) {
}, const path = targetPath.toString();
layerTypeData(layerType: LayerType): LayerTypeData { layerCache.set(path, targetLayer);
return layerTypeData(layerType) || { name: "Error", icon: "Info" };
},
},
mounted() {
this.editor.subscriptions.subscribeJsMessage(UpdateDocumentLayerTreeStructureJs, (updateDocumentLayerTreeStructure) => {
this.rebuildLayerTree(updateDocumentLayerTreeStructure);
});
this.editor.subscriptions.subscribeJsMessage(UpdateLayerTreeOptionsLayout, (updateLayerTreeOptionsLayout) => { const layer = layers.find((layer: LayerListingInfo) => layer.entry.path.toString() === path);
patchWidgetLayout(this.layerTreeOptionsLayout, updateLayerTreeOptionsLayout); if (layer) {
}); layer.entry = targetLayer;
layers = layers;
}
}
this.editor.subscriptions.subscribeJsMessage(UpdateDocumentLayerDetails, (updateDocumentLayerDetails) => { function getLayerTypeData(layerType: LayerType): LayerTypeData {
const targetPath = updateDocumentLayerDetails.data.path; return layerTypeData(layerType) || { name: "Error", icon: "Info" };
const targetLayer = updateDocumentLayerDetails.data;
const layer = this.layerCache.get(targetPath.toString());
if (layer) {
Object.assign(layer, targetLayer);
} else {
this.layerCache.set(targetPath.toString(), targetLayer);
} }
});
},
components: {
IconButton,
IconLabel,
LayoutCol,
LayoutRow,
WidgetLayout,
},
});
</script> </script>
<template> <LayoutCol class="layer-tree" on:dragleave={() => (dragInPanel = false)}>
<LayoutCol class="layer-tree" @dragleave="dragInPanel = false"> <LayoutRow class="options-bar" scrollableX={true}>
<LayoutRow class="options-bar" :scrollableX="true"> <WidgetLayout layout={layerTreeOptionsLayout} />
<WidgetLayout :layout="layerTreeOptionsLayout" />
</LayoutRow> </LayoutRow>
<LayoutRow class="layer-tree-rows" :scrollableY="true"> <LayoutRow class="layer-tree-rows" scrollableY={true}>
<LayoutCol class="list" ref="list" @click="() => deselectAllLayers()" @dragover="(e: DragEvent) => draggable && updateInsertLine(e)" @dragend="() => draggable && drop()"> <LayoutCol class="list" bind:this={list} on:click={() => deselectAllLayers()} on:dragover={(e) => draggable && updateInsertLine(e)} on:dragend={() => draggable && drop()}>
{#each layers as listing, index (String(listing.entry.path.slice(-1)))}
<LayoutRow <LayoutRow
class="layer-row" class="layer-row"
v-for="(listing, index) in layers" classes={{
:key="String(listing.entry.path.slice(-1))" "insert-folder": (draggingData?.highlightFolder || false) && draggingData?.insertFolder === listing.entry.path,
:class="{ 'insert-folder': draggingData?.highlightFolder && draggingData?.insertFolder === listing.entry.path }" }}
> >
<LayoutRow class="visibility"> <LayoutRow class="visibility">
<IconButton <IconButton
:action="(e?: MouseEvent) => (toggleLayerVisibility(listing.entry.path), e?.stopPropagation())" action={(e) => (toggleLayerVisibility(listing.entry.path), e?.stopPropagation())}
:size="24" size={24}
:icon="listing.entry.visible ? 'EyeVisible' : 'EyeHidden'" icon={listing.entry.visible ? "EyeVisible" : "EyeHidden"}
:title="listing.entry.visible ? 'Visible' : 'Hidden'" tooltip={listing.entry.visible ? "Visible" : "Hidden"}
/> />
</LayoutRow> </LayoutRow>
<div class="indent" :style="{ marginLeft: layerIndent(listing.entry) }"></div> <div class="indent" style:margin-left={layerIndent(listing.entry)} />
<button {#if listing.entry.layerType === "Folder"}
v-if="listing.entry.layerType === 'Folder'" <button class="expand-arrow" class:expanded={listing.entry.layerMetadata.expanded} on:click|stopPropagation={() => handleExpandArrowClick(listing.entry.path)} tabindex="0" />
class="expand-arrow" {/if}
:class="{ expanded: listing.entry.layerMetadata.expanded }"
@click.stop="handleExpandArrowClick(listing.entry.path)"
tabindex="0"
></button>
<LayoutRow <LayoutRow
class="layer" class="layer"
:class="{ selected: fakeHighlight ? fakeHighlight.includes(listing.entry.path) : listing.entry.layerMetadata.selected }" classes={{
:data-layer="String(listing.entry.path)" selected: fakeHighlight ? fakeHighlight.includes(listing.entry.path) : listing.entry.layerMetadata.selected,
:data-index="index" }}
:title="listing.entry.tooltip" data-layer={String(listing.entry.path)}
:draggable="draggable" data-index={index}
@dragstart="(e: DragEvent) => draggable && dragStart(e, listing)" tooltip={listing.entry.tooltip}
@click.exact="(e: MouseEvent) => selectLayer(false, false, false, listing, e)" {draggable}
@click.shift.exact="(e: MouseEvent) => selectLayer(false, false, true, listing, e)" on:dragstart={(e) => draggable && dragStart(e, listing)}
@click.ctrl.exact="(e: MouseEvent) => selectLayer(true, false, false, listing, e)" on:click={(e) => selectLayerWithModifiers(e, listing)}
@click.ctrl.shift.exact="(e: MouseEvent) => selectLayer(true, false, true, listing, e)"
@click.meta.exact="(e: MouseEvent) => selectLayer(false, true, false, listing, e)"
@click.meta.shift.exact="(e: MouseEvent) => selectLayer(false, true, true, listing, e)"
@click.ctrl.meta="(e: MouseEvent) => e.stopPropagation()"
@click.alt="(e: MouseEvent) => e.stopPropagation()"
> >
<LayoutRow class="layer-type-icon"> <LayoutRow class="layer-type-icon">
<IconLabel :icon="layerTypeData(listing.entry.layerType).icon" :title="layerTypeData(listing.entry.layerType).name" /> <IconLabel icon={getLayerTypeData(listing.entry.layerType).icon} tooltip={getLayerTypeData(listing.entry.layerType).name} />
</LayoutRow> </LayoutRow>
<LayoutRow class="layer-name" @dblclick="() => onEditLayerName(listing)"> <LayoutRow class="layer-name" on:dblclick={() => onEditLayerName(listing)}>
<input <input
data-text-input data-text-input
type="text" type="text"
:value="listing.entry.name" value={listing.entry.name}
:placeholder="layerTypeData(listing.entry.layerType).name" placeholder={getLayerTypeData(listing.entry.layerType).name}
:disabled="!listing.editingName" disabled={!listing.editingName}
@blur="() => onEditLayerNameDeselect(listing)" on:blur={() => onEditLayerNameDeselect(listing)}
@keydown.esc="onEditLayerNameDeselect(listing)" on:keydown={(e) => e.key === "Escape" && onEditLayerNameDeselect(listing)}
@keydown.enter="(e) => onEditLayerNameChange(listing, e)" on:keydown={(e) => e.key === "Enter" && onEditLayerNameChange(listing, e)}
@change="(e) => onEditLayerNameChange(listing, e)" on:change={(e) => onEditLayerNameChange(listing, e)}
/> />
</LayoutRow> </LayoutRow>
<div class="thumbnail" v-html="listing.entry.thumbnail"></div> <div class="thumbnail">
{@html listing.entry.thumbnail}
</div>
</LayoutRow> </LayoutRow>
</LayoutRow> </LayoutRow>
{/each}
</LayoutCol> </LayoutCol>
<div {#if draggingData && !draggingData.highlightFolder && dragInPanel}
class="insert-mark" <div class="insert-mark" style:left={markIndent(draggingData.insertFolder)} style:top={markTopOffset(draggingData.markerHeight)} />
v-if="draggingData && !draggingData.highlightFolder && dragInPanel" {/if}
:style="{ left: markIndent(draggingData.insertFolder), top: markTopOffset(draggingData.markerHeight) }"
></div>
</LayoutRow> </LayoutRow>
</LayoutCol> </LayoutCol>
</template>
<style lang="scss"> <style lang="scss" global>
.layer-tree { .layer-tree {
// Options bar // Options bar
.options-bar { .options-bar {
height: 32px; height: 32px;
@@ -546,5 +572,5 @@ export default defineComponent({
pointer-events: none; pointer-events: none;
} }
} }
} }
</style> </style>
+296 -309
View File
@@ -1,42 +1,55 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, nextTick } from "vue"; import { getContext, onMount, tick } from "svelte";
import type { IconName } from "@/utility-functions/icons"; import type { IconName } from "@/utility-functions/icons";
import { UpdateNodeGraphSelection, type FrontendNodeLink } from "@/wasm-communication/messages"; import { UpdateNodeGraphSelection, type FrontendNodeLink, type FrontendNodeType, type FrontendNode } from "@/wasm-communication/messages";
import LayoutCol from "@/components/layout/LayoutCol.vue"; import LayoutCol from "@/components/layout/LayoutCol.svelte";
import LayoutRow from "@/components/layout/LayoutRow.vue"; import LayoutRow from "@/components/layout/LayoutRow.svelte";
import TextButton from "@/components/widgets/buttons/TextButton.vue"; import TextButton from "@/components/widgets/buttons/TextButton.svelte";
import TextInput from "@/components/widgets/inputs/TextInput.vue"; import TextInput from "@/components/widgets/inputs/TextInput.svelte";
import IconLabel from "@/components/widgets/labels/IconLabel.vue"; import IconLabel from "@/components/widgets/labels/IconLabel.svelte";
import TextLabel from "@/components/widgets/labels/TextLabel.vue"; import TextLabel from "@/components/widgets/labels/TextLabel.svelte";
import WidgetLayout from "@/components/widgets/WidgetLayout.vue"; import WidgetLayout from "@/components/widgets/WidgetLayout.svelte";
import { type Editor } from "@/wasm-communication/editor";
import { type NodeGraphState } from "@/state-providers/node-graph";
const WHEEL_RATE = (1 / 600) * 3; const WHEEL_RATE = (1 / 600) * 3;
const GRID_COLLAPSE_SPACING = 10; const GRID_COLLAPSE_SPACING = 10;
const GRID_SIZE = 24; const GRID_SIZE = 24;
export default defineComponent({ const editor = getContext<Editor>("editor");
inject: ["nodeGraph", "editor"], const nodeGraph = getContext<NodeGraphState>("nodeGraph");
data() {
return { let graph: LayoutRow;
transform: { scale: 1, x: 0, y: 0 }, let nodesContainer: HTMLDivElement;
panning: false, let nodeSearchInput: TextInput;
selected: [] as bigint[], let transform = { scale: 1, x: 0, y: 0 };
draggingNodes: undefined as { startX: number; startY: number; roundX: number; roundY: number } | undefined, let panning = false;
selectIfNotDragged: undefined as undefined | bigint, let selected: bigint[] = [];
linkInProgressFromConnector: undefined as HTMLDivElement | undefined, let draggingNodes: { startX: number; startY: number; roundX: number; roundY: number } | undefined = undefined;
linkInProgressToConnector: undefined as HTMLDivElement | DOMRect | undefined, let selectIfNotDragged: undefined | bigint = undefined;
disconnecting: undefined as { nodeId: bigint; inputIndex: number; linkIndex: number } | undefined, let linkInProgressFromConnector: HTMLDivElement | undefined = undefined;
nodeLinkPaths: [] as [string, string][], let linkInProgressToConnector: HTMLDivElement | DOMRect | undefined = undefined;
searchTerm: "", let disconnecting: { nodeId: bigint; inputIndex: number; linkIndex: number } | undefined = undefined;
nodeListLocation: undefined as { x: number; y: number } | undefined, let nodeLinkPaths: [string, string][] = [];
}; let searchTerm = "";
}, let nodeListLocation: { x: number; y: number } | undefined = undefined;
computed: {
gridSpacing(): number { $: gridSpacing = calculateGridSpacing(transform.scale);
const dense = this.transform.scale * GRID_SIZE; $: dotRadius = 1 + Math.floor(transform.scale - 0.5 + 0.001) / 2;
$: nodeGraphBarLayout = $nodeGraph.nodeGraphBarLayout;
$: nodeCategories = buildNodeCategories($nodeGraph.nodeTypes, searchTerm);
$: nodeListX = ((nodeListLocation?.x || 0) * GRID_SIZE + transform.x) * transform.scale;
$: nodeListY = ((nodeListLocation?.y || 0) * GRID_SIZE + transform.y) * transform.scale;
$: linkPathInProgress = createLinkPathInProgress(linkInProgressFromConnector, linkInProgressToConnector);
$: linkPaths = createLinkPaths(linkPathInProgress, nodeLinkPaths);
$: watchNodes($nodeGraph.nodes);
function calculateGridSpacing(scale: number): number {
const dense = scale * GRID_SIZE;
let sparse = dense; let sparse = dense;
while (sparse > 0 && sparse < GRID_COLLAPSE_SPACING) { while (sparse > 0 && sparse < GRID_COLLAPSE_SPACING) {
@@ -44,84 +57,67 @@ export default defineComponent({
} }
return sparse; return sparse;
}, }
dotRadius(): number {
return 1 + Math.floor(this.transform.scale - 0.5 + 0.001) / 2; function buildNodeCategories(nodeTypes: FrontendNodeType[], searchTerm: string): [string, FrontendNodeType[]][] {
},
nodes() {
return this.nodeGraph.state.nodes;
},
nodeGraphBarLayout() {
return this.nodeGraph.state.nodeGraphBarLayout;
},
nodeCategories() {
const categories = new Map(); const categories = new Map();
this.nodeGraph.state.nodeTypes.forEach((node) => { nodeTypes.forEach((node) => {
if (this.searchTerm.length && !node.name.toLowerCase().includes(this.searchTerm.toLowerCase()) && !node.category.toLowerCase().includes(this.searchTerm.toLowerCase())) return; if (searchTerm.length > 0 && !node.name.toLowerCase().includes(searchTerm.toLowerCase()) && !node.category.toLowerCase().includes(searchTerm.toLowerCase())) {
return;
}
const category = categories.get(node.category); const category = categories.get(node.category);
if (category) category.push(node); if (category) category.push(node);
else categories.set(node.category, [node]); else categories.set(node.category, [node]);
}); });
const result = Array.from(categories); return Array.from(categories);
return result; }
},
nodeListX() { function createLinkPathInProgress(linkInProgressFromConnector?: HTMLDivElement, linkInProgressToConnector?: HTMLDivElement | DOMRect): [string, string] | undefined {
return ((this.nodeListLocation?.x || 0) * GRID_SIZE + this.transform.x) * this.transform.scale; if (linkInProgressFromConnector && linkInProgressToConnector) {
}, return createWirePath(linkInProgressFromConnector, linkInProgressToConnector, false, false);
nodeListY() {
return ((this.nodeListLocation?.y || 0) * GRID_SIZE + this.transform.y) * this.transform.scale;
},
linkPathInProgress(): [string, string] | undefined {
if (this.linkInProgressFromConnector && this.linkInProgressToConnector) {
return this.createWirePath(this.linkInProgressFromConnector, this.linkInProgressToConnector, false, false);
} }
return undefined; return undefined;
}, }
linkPaths(): [string, string][] {
const linkPathInProgress = this.linkPathInProgress ? [this.linkPathInProgress] : []; function createLinkPaths(linkPathInProgress: [string, string] | undefined, nodeLinkPaths: [string, string][]): [string, string][] {
return [...linkPathInProgress, ...this.nodeLinkPaths]; const optionalTuple = linkPathInProgress ? [linkPathInProgress] : [];
}, return [...optionalTuple, ...nodeLinkPaths];
}, }
watch: {
nodes: { async function watchNodes(nodes: FrontendNode[]) {
immediate: true, selected = selected.filter((id) => nodes.find((node) => node.id === id));
async handler() { await refreshLinks();
this.selected = this.selected.filter((id) => this.nodeGraph.state.nodes.find((node) => node.id === id)); }
await this.refreshLinks();
}, function resolveLink(link: FrontendNodeLink, containerBounds: HTMLDivElement): { nodePrimaryOutput: HTMLDivElement | undefined; nodePrimaryInput: HTMLDivElement | undefined } {
},
},
methods: {
resolveLink(link: FrontendNodeLink, containerBounds: HTMLDivElement): { nodePrimaryOutput: HTMLDivElement | undefined; nodePrimaryInput: HTMLDivElement | undefined } {
const outputIndex = Number(link.linkStartOutputIndex); const outputIndex = Number(link.linkStartOutputIndex);
const inputIndex = Number(link.linkEndInputIndex); const inputIndex = Number(link.linkEndInputIndex);
const nodeOutputConnectors = containerBounds.querySelectorAll(`[data-node="${String(link.linkStart)}"] [data-port="output"]`) || undefined; const nodeOutputConnectors = containerBounds.querySelectorAll(`[data-node="${String(link.linkStart)}"] [data-port="output"]`) || undefined;
const nodeInputConnectors = containerBounds.querySelectorAll(`[data-node="${String(link.linkEnd)}"] [data-port="input"]`) || undefined; const nodeInputConnectors = containerBounds.querySelectorAll(`[data-node="${String(link.linkEnd)}"] [data-port="input"]`) || undefined;
const nodePrimaryOutput = nodeOutputConnectors?.[outputIndex] as HTMLDivElement | undefined; const nodePrimaryOutput = nodeOutputConnectors?.[outputIndex] as HTMLDivElement | undefined;
const nodePrimaryInput = nodeInputConnectors?.[inputIndex] as HTMLDivElement | undefined; const nodePrimaryInput = nodeInputConnectors?.[inputIndex] as HTMLDivElement | undefined;
return { nodePrimaryOutput, nodePrimaryInput }; return { nodePrimaryOutput, nodePrimaryInput };
}, }
async refreshLinks(): Promise<void> {
await nextTick();
const containerBounds = this.$refs.nodesContainer as HTMLDivElement | undefined; async function refreshLinks(): Promise<void> {
if (!containerBounds) return; await tick();
const links = this.nodeGraph.state.links; const links = $nodeGraph.links;
this.nodeLinkPaths = links.flatMap((link, index) => { nodeLinkPaths = links.flatMap((link, index) => {
const { nodePrimaryInput, nodePrimaryOutput } = this.resolveLink(link, containerBounds); const { nodePrimaryInput, nodePrimaryOutput } = resolveLink(link, nodesContainer);
if (!nodePrimaryInput || !nodePrimaryOutput) return []; if (!nodePrimaryInput || !nodePrimaryOutput) return [];
if (this.disconnecting?.linkIndex === index) return []; if (disconnecting?.linkIndex === index) return [];
return [this.createWirePath(nodePrimaryOutput, nodePrimaryInput.getBoundingClientRect(), false, false)]; return [createWirePath(nodePrimaryOutput, nodePrimaryInput.getBoundingClientRect(), false, false)];
}); });
}, }
nodeIcon(nodeName: string): IconName {
function nodeIcon(nodeName: string): IconName {
const iconMap: Record<string, IconName> = { const iconMap: Record<string, IconName> = {
Output: "NodeOutput", Output: "NodeOutput",
Imaginate: "NodeImaginate", Imaginate: "NodeImaginate",
@@ -130,20 +126,20 @@ export default defineComponent({
"Grayscale Image": "NodeColorCorrection", "Grayscale Image": "NodeColorCorrection",
}; };
return iconMap[nodeName] || "NodeNodes"; return iconMap[nodeName] || "NodeNodes";
}, }
buildWirePathLocations(outputBounds: DOMRect, inputBounds: DOMRect, verticalOut: boolean, verticalIn: boolean): { x: number; y: number }[] {
const containerBounds = (this.$refs.nodesContainer as HTMLDivElement | undefined)?.getBoundingClientRect(); function buildWirePathLocations(outputBounds: DOMRect, inputBounds: DOMRect, verticalOut: boolean, verticalIn: boolean): { x: number; y: number }[] {
if (!containerBounds) return []; const containerBounds = nodesContainer.getBoundingClientRect();
const outX = verticalOut ? outputBounds.x + outputBounds.width / 2 : outputBounds.x + outputBounds.width - 1; const outX = verticalOut ? outputBounds.x + outputBounds.width / 2 : outputBounds.x + outputBounds.width - 1;
const outY = verticalOut ? outputBounds.y + 1 : outputBounds.y + outputBounds.height / 2; const outY = verticalOut ? outputBounds.y + 1 : outputBounds.y + outputBounds.height / 2;
const outConnectorX = (outX - containerBounds.x) / this.transform.scale; const outConnectorX = (outX - containerBounds.x) / transform.scale;
const outConnectorY = (outY - containerBounds.y) / this.transform.scale; const outConnectorY = (outY - containerBounds.y) / transform.scale;
const inX = verticalIn ? inputBounds.x + inputBounds.width / 2 : inputBounds.x + 1; const inX = verticalIn ? inputBounds.x + inputBounds.width / 2 : inputBounds.x + 1;
const inY = verticalIn ? inputBounds.y + inputBounds.height - 1 : inputBounds.y + inputBounds.height / 2; const inY = verticalIn ? inputBounds.y + inputBounds.height - 1 : inputBounds.y + inputBounds.height / 2;
const inConnectorX = (inX - containerBounds.x) / this.transform.scale; const inConnectorX = (inX - containerBounds.x) / transform.scale;
const inConnectorY = (inY - containerBounds.y) / this.transform.scale; const inConnectorY = (inY - containerBounds.y) / transform.scale;
const horizontalGap = Math.abs(outConnectorX - inConnectorX); const horizontalGap = Math.abs(outConnectorX - inConnectorX);
const verticalGap = Math.abs(outConnectorY - inConnectorY); const verticalGap = Math.abs(outConnectorY - inConnectorY);
@@ -161,25 +157,28 @@ export default defineComponent({
{ x: verticalIn ? inConnectorX : inConnectorX - horizontalCurve, y: verticalIn ? inConnectorY + verticalCurve : inConnectorY }, { x: verticalIn ? inConnectorX : inConnectorX - horizontalCurve, y: verticalIn ? inConnectorY + verticalCurve : inConnectorY },
{ x: inConnectorX, y: inConnectorY }, { x: inConnectorX, y: inConnectorY },
]; ];
}, }
buildWirePathString(outputBounds: DOMRect, inputBounds: DOMRect, verticalOut: boolean, verticalIn: boolean): string {
const locations = this.buildWirePathLocations(outputBounds, inputBounds, verticalOut, verticalIn); function buildWirePathString(outputBounds: DOMRect, inputBounds: DOMRect, verticalOut: boolean, verticalIn: boolean): string {
const locations = buildWirePathLocations(outputBounds, inputBounds, verticalOut, verticalIn);
if (locations.length === 0) return "[error]"; if (locations.length === 0) return "[error]";
return `M${locations[0].x},${locations[0].y} C${locations[1].x},${locations[1].y} ${locations[2].x},${locations[2].y} ${locations[3].x},${locations[3].y}`; return `M${locations[0].x},${locations[0].y} C${locations[1].x},${locations[1].y} ${locations[2].x},${locations[2].y} ${locations[3].x},${locations[3].y}`;
}, }
createWirePath(outputPort: HTMLDivElement, inputPort: HTMLDivElement | DOMRect, verticalOut: boolean, verticalIn: boolean): [string, string] {
function createWirePath(outputPort: HTMLDivElement, inputPort: HTMLDivElement | DOMRect, verticalOut: boolean, verticalIn: boolean): [string, string] {
const inputPortRect = inputPort instanceof HTMLDivElement ? inputPort.getBoundingClientRect() : inputPort; const inputPortRect = inputPort instanceof HTMLDivElement ? inputPort.getBoundingClientRect() : inputPort;
const pathString = this.buildWirePathString(outputPort.getBoundingClientRect(), inputPortRect, verticalOut, verticalIn); const pathString = buildWirePathString(outputPort.getBoundingClientRect(), inputPortRect, verticalOut, verticalIn);
const dataType = outputPort.getAttribute("data-datatype") || "general"; const dataType = outputPort.getAttribute("data-datatype") || "general";
return [pathString, dataType]; return [pathString, dataType];
}, }
scroll(e: WheelEvent) {
function scroll(e: WheelEvent) {
const [scrollX, scrollY] = [e.deltaX, e.deltaY]; const [scrollX, scrollY] = [e.deltaX, e.deltaY];
// If zoom with scroll is enabled: horizontal pan with Ctrl, vertical pan with Shift // If zoom with scroll is enabled: horizontal pan with Ctrl, vertical pan with Shift
const zoomWithScroll = this.nodeGraph.state.zoomWithScroll; const zoomWithScroll = $nodeGraph.zoomWithScroll;
const zoom = zoomWithScroll ? !e.ctrlKey && !e.shiftKey : e.ctrlKey; const zoom = zoomWithScroll ? !e.ctrlKey && !e.shiftKey : e.ctrlKey;
const horizontalPan = zoomWithScroll ? e.ctrlKey : !e.ctrlKey && e.shiftKey; const horizontalPan = zoomWithScroll ? e.ctrlKey : !e.ctrlKey && e.shiftKey;
@@ -187,18 +186,16 @@ export default defineComponent({
if (e.ctrlKey) e.preventDefault(); if (e.ctrlKey) e.preventDefault();
// Always pan horizontally in response to a horizontal scroll wheel movement // Always pan horizontally in response to a horizontal scroll wheel movement
this.transform.x -= scrollX / this.transform.scale; transform.x -= scrollX / transform.scale;
// Zoom // Zoom
if (zoom) { if (zoom) {
let zoomFactor = 1 + Math.abs(scrollY) * WHEEL_RATE; let zoomFactor = 1 + Math.abs(scrollY) * WHEEL_RATE;
if (scrollY > 0) zoomFactor = 1 / zoomFactor; if (scrollY > 0) zoomFactor = 1 / zoomFactor;
const graphDiv: HTMLDivElement | undefined = (this.$refs.graph as typeof LayoutCol | undefined)?.$el; const { x, y, width, height } = graph.div().getBoundingClientRect();
if (!graphDiv) return;
const { x, y, width, height } = graphDiv.getBoundingClientRect();
this.transform.scale *= zoomFactor; transform.scale *= zoomFactor;
const newViewportX = width / zoomFactor; const newViewportX = width / zoomFactor;
const newViewportY = height / zoomFactor; const newViewportY = height / zoomFactor;
@@ -209,46 +206,48 @@ export default defineComponent({
const deltaX = deltaSizeX * ((e.x - x) / width); const deltaX = deltaSizeX * ((e.x - x) / width);
const deltaY = deltaSizeY * ((e.y - y) / height); const deltaY = deltaSizeY * ((e.y - y) / height);
this.transform.x -= (deltaX / this.transform.scale) * zoomFactor; transform.x -= (deltaX / transform.scale) * zoomFactor;
this.transform.y -= (deltaY / this.transform.scale) * zoomFactor; transform.y -= (deltaY / transform.scale) * zoomFactor;
return; return;
} }
// Pan // Pan
if (horizontalPan) { if (horizontalPan) {
this.transform.x -= scrollY / this.transform.scale; transform.x -= scrollY / transform.scale;
} else { } else {
this.transform.y -= scrollY / this.transform.scale; transform.y -= scrollY / transform.scale;
} }
}, }
keydown(e: KeyboardEvent): void {
function keydown(e: KeyboardEvent): void {
if (e.key.toLowerCase() === "escape") { if (e.key.toLowerCase() === "escape") {
this.nodeListLocation = undefined; nodeListLocation = undefined;
document.removeEventListener("keydown", this.keydown); document.removeEventListener("keydown", keydown);
} }
}, }
// TODO: Move the event listener from the graph to the window so dragging outside the graph area (or even the whole browser window) works // TODO: Move the event listener from the graph to the window so dragging outside the graph area (or even the whole browser window) works
pointerDown(e: PointerEvent) { function pointerDown(e: PointerEvent) {
const [lmb, rmb] = [e.button === 0, e.button === 2]; const [lmb, rmb] = [e.button === 0, e.button === 2];
const port = (e.target as HTMLDivElement).closest("[data-port]") as HTMLDivElement; const port = (e.target as HTMLDivElement).closest("[data-port]") as HTMLDivElement;
const node = (e.target as HTMLElement).closest("[data-node]") as HTMLElement | undefined; const node = (e.target as HTMLElement).closest("[data-node]") as HTMLElement | undefined;
const nodeId = node?.getAttribute("data-node") || undefined; const nodeId = node?.getAttribute("data-node") || undefined;
const nodeList = (e.target as HTMLElement).closest("[data-node-list]") as HTMLElement | undefined; const nodeList = (e.target as HTMLElement).closest("[data-node-list]") as HTMLElement | undefined;
const containerBounds = this.$refs.nodesContainer as HTMLDivElement | undefined;
if (!containerBounds) return;
// Create the add node popup on right click, then exit // Create the add node popup on right click, then exit
if (rmb) { if (rmb) {
const graphDiv: HTMLDivElement | undefined = (this.$refs.graph as typeof LayoutCol | undefined)?.$el; const graphBounds = graph.div().getBoundingClientRect();
const graph = graphDiv?.getBoundingClientRect() || new DOMRect(); nodeListLocation = {
this.nodeListLocation = { x: Math.round(((e.clientX - graphBounds.x) / transform.scale - transform.x) / GRID_SIZE),
x: Math.round(((e.clientX - graph.x) / this.transform.scale - this.transform.x) / GRID_SIZE), y: Math.round(((e.clientY - graphBounds.y) / transform.scale - transform.y) / GRID_SIZE),
y: Math.round(((e.clientY - graph.y) / this.transform.scale - this.transform.y) / GRID_SIZE),
}; };
document.addEventListener("keydown", this.keydown); // Find actual relevant child and focus it (setTimeout is required to actually focus the input element)
setTimeout(() => nodeSearchInput.focus(), 0);
document.addEventListener("keydown", keydown);
return; return;
} }
@@ -256,18 +255,18 @@ export default defineComponent({
if (lmb && nodeList) return; if (lmb && nodeList) return;
// Since the user is clicking elsewhere in the graph, ensure the add nodes list is closed // Since the user is clicking elsewhere in the graph, ensure the add nodes list is closed
if (lmb) this.nodeListLocation = undefined; if (lmb) nodeListLocation = undefined;
// Alt-click sets the clicked node as previewed // Alt-click sets the clicked node as previewed
if (lmb && e.altKey && nodeId) { if (lmb && e.altKey && nodeId) {
this.editor.instance.togglePreview(BigInt(nodeId)); editor.instance.togglePreview(BigInt(nodeId));
} }
// Clicked on a port dot // Clicked on a port dot
if (lmb && port && node) { if (lmb && port && node) {
const isOutput = Boolean(port.getAttribute("data-port") === "output"); const isOutput = Boolean(port.getAttribute("data-port") === "output");
if (isOutput) this.linkInProgressFromConnector = port; if (isOutput) linkInProgressFromConnector = port;
else { else {
const inputNodeInPorts = Array.from(node.querySelectorAll(`[data-port="input"]`)); const inputNodeInPorts = Array.from(node.querySelectorAll(`[data-port="input"]`));
const inputNodeConnectionIndexSearch = inputNodeInPorts.indexOf(port); const inputNodeConnectionIndexSearch = inputNodeInPorts.indexOf(port);
@@ -276,14 +275,14 @@ export default defineComponent({
if (inputIndex !== undefined && nodeId) { if (inputIndex !== undefined && nodeId) {
const nodeIdInt = BigInt(nodeId); const nodeIdInt = BigInt(nodeId);
const inputIndexInt = BigInt(inputIndex); const inputIndexInt = BigInt(inputIndex);
const links = this.nodeGraph.state.links; const links = $nodeGraph.links;
const linkIndex = links.findIndex((value) => value.linkEnd === nodeIdInt && value.linkEndInputIndex === inputIndexInt); const linkIndex = links.findIndex((value) => value.linkEnd === nodeIdInt && value.linkEndInputIndex === inputIndexInt);
const nodeOutputConnectors = containerBounds.querySelectorAll(`[data-node="${String(links[linkIndex].linkStart)}"] [data-port="output"]`) || undefined; const nodeOutputConnectors = nodesContainer.querySelectorAll(`[data-node="${String(links[linkIndex].linkStart)}"] [data-port="output"]`) || undefined;
this.linkInProgressFromConnector = nodeOutputConnectors?.[Number(links[linkIndex].linkEndInputIndex)] as HTMLDivElement | undefined; linkInProgressFromConnector = nodeOutputConnectors?.[Number(links[linkIndex].linkEndInputIndex)] as HTMLDivElement | undefined;
const nodeInputConnectors = containerBounds.querySelectorAll(`[data-node="${String(links[linkIndex].linkEnd)}"] [data-port="input"]`) || undefined; const nodeInputConnectors = nodesContainer.querySelectorAll(`[data-node="${String(links[linkIndex].linkEnd)}"] [data-port="input"]`) || undefined;
this.linkInProgressToConnector = nodeInputConnectors?.[Number(links[linkIndex].linkEndInputIndex)] as HTMLDivElement | undefined; linkInProgressToConnector = nodeInputConnectors?.[Number(links[linkIndex].linkEndInputIndex)] as HTMLDivElement | undefined;
this.disconnecting = { nodeId: nodeIdInt, inputIndex, linkIndex }; disconnecting = { nodeId: nodeIdInt, inputIndex, linkIndex };
this.refreshLinks(); refreshLinks();
} }
} }
@@ -298,77 +297,78 @@ export default defineComponent({
if (e.shiftKey || e.ctrlKey) { if (e.shiftKey || e.ctrlKey) {
modifiedSelected = true; modifiedSelected = true;
if (this.selected.includes(id)) this.selected.splice(this.selected.lastIndexOf(id), 1); if (selected.includes(id)) selected.splice(selected.lastIndexOf(id), 1);
else this.selected.push(id); else selected.push(id);
} else if (!this.selected.includes(id)) { } else if (!selected.includes(id)) {
modifiedSelected = true; modifiedSelected = true;
this.selected = [id]; selected = [id];
} else { } else {
this.selectIfNotDragged = id; selectIfNotDragged = id;
} }
if (this.selected.includes(id)) { if (selected.includes(id)) {
this.draggingNodes = { startX: e.x, startY: e.y, roundX: 0, roundY: 0 }; draggingNodes = { startX: e.x, startY: e.y, roundX: 0, roundY: 0 };
} }
if (modifiedSelected) this.editor.instance.selectNodes(new BigUint64Array(this.selected)); if (modifiedSelected) editor.instance.selectNodes(new BigUint64Array(selected));
return; return;
} }
// Clicked on the graph background // Clicked on the graph background
if (lmb && this.selected.length !== 0) { if (lmb && selected.length !== 0) {
this.selected = []; selected = [];
this.editor.instance.selectNodes(new BigUint64Array([])); editor.instance.selectNodes(new BigUint64Array([]));
} }
// LMB clicked on the graph background or MMB clicked anywhere // LMB clicked on the graph background or MMB clicked anywhere
this.panning = true; panning = true;
}, }
doubleClick(e: MouseEvent) {
function doubleClick(e: MouseEvent) {
const node = (e.target as HTMLElement).closest("[data-node]") as HTMLElement | undefined; const node = (e.target as HTMLElement).closest("[data-node]") as HTMLElement | undefined;
const nodeId = node?.getAttribute("data-node") || undefined; const nodeId = node?.getAttribute("data-node") || undefined;
if (nodeId) { if (nodeId) {
const id = BigInt(nodeId); const id = BigInt(nodeId);
this.editor.instance.doubleClickNode(id); editor.instance.doubleClickNode(id);
} }
}, }
pointerMove(e: PointerEvent) {
if (this.panning) { function pointerMove(e: PointerEvent) {
this.transform.x += e.movementX / this.transform.scale; if (panning) {
this.transform.y += e.movementY / this.transform.scale; transform.x += e.movementX / transform.scale;
} else if (this.linkInProgressFromConnector) { transform.y += e.movementY / transform.scale;
} else if (linkInProgressFromConnector) {
const target = e.target as Element | undefined; const target = e.target as Element | undefined;
const dot = (target?.closest(`[data-port="input"]`) || undefined) as HTMLDivElement | undefined; const dot = (target?.closest(`[data-port="input"]`) || undefined) as HTMLDivElement | undefined;
if (dot) { if (dot) {
this.linkInProgressToConnector = dot; linkInProgressToConnector = dot;
} else { } else {
this.linkInProgressToConnector = new DOMRect(e.x, e.y); linkInProgressToConnector = new DOMRect(e.x, e.y);
}
} else if (draggingNodes) {
const deltaX = Math.round((e.x - draggingNodes.startX) / transform.scale / GRID_SIZE);
const deltaY = Math.round((e.y - draggingNodes.startY) / transform.scale / GRID_SIZE);
if (draggingNodes.roundX !== deltaX || draggingNodes.roundY !== deltaY) {
draggingNodes.roundX = deltaX;
draggingNodes.roundY = deltaY;
refreshLinks();
} }
} else if (this.draggingNodes) {
const deltaX = Math.round((e.x - this.draggingNodes.startX) / this.transform.scale / GRID_SIZE);
const deltaY = Math.round((e.y - this.draggingNodes.startY) / this.transform.scale / GRID_SIZE);
if (this.draggingNodes.roundX !== deltaX || this.draggingNodes.roundY !== deltaY) {
this.draggingNodes.roundX = deltaX;
this.draggingNodes.roundY = deltaY;
this.refreshLinks();
} }
} }
},
pointerUp(e: PointerEvent) {
const containerBounds = this.$refs.nodesContainer as HTMLDivElement | undefined;
if (!containerBounds) return;
this.panning = false;
if (this.disconnecting) { function pointerUp(e: PointerEvent) {
this.editor.instance.disconnectNodes(BigInt(this.disconnecting.nodeId), this.disconnecting.inputIndex); panning = false;
}
this.disconnecting = undefined;
if (this.linkInProgressToConnector instanceof HTMLDivElement && this.linkInProgressFromConnector) { if (disconnecting) {
const outputNode = this.linkInProgressFromConnector.closest("[data-node]"); editor.instance.disconnectNodes(BigInt(disconnecting.nodeId), disconnecting.inputIndex);
const inputNode = this.linkInProgressToConnector.closest("[data-node]"); }
disconnecting = undefined;
if (linkInProgressToConnector instanceof HTMLDivElement && linkInProgressFromConnector) {
const outputNode = linkInProgressFromConnector.closest("[data-node]");
const inputNode = linkInProgressToConnector.closest("[data-node]");
const outputConnectedNodeID = outputNode?.getAttribute("data-node") ?? undefined; const outputConnectedNodeID = outputNode?.getAttribute("data-node") ?? undefined;
const inputConnectedNodeID = inputNode?.getAttribute("data-node") ?? undefined; const inputConnectedNodeID = inputNode?.getAttribute("data-node") ?? undefined;
@@ -377,51 +377,49 @@ export default defineComponent({
const inputNodeInPorts = Array.from(inputNode.querySelectorAll(`[data-port="input"]`)); const inputNodeInPorts = Array.from(inputNode.querySelectorAll(`[data-port="input"]`));
const outputNodeInPorts = Array.from(outputNode.querySelectorAll(`[data-port="output"]`)); const outputNodeInPorts = Array.from(outputNode.querySelectorAll(`[data-port="output"]`));
const inputNodeConnectionIndexSearch = inputNodeInPorts.indexOf(this.linkInProgressToConnector); const inputNodeConnectionIndexSearch = inputNodeInPorts.indexOf(linkInProgressToConnector);
const outputNodeConnectionIndexSearch = outputNodeInPorts.indexOf(this.linkInProgressFromConnector); const outputNodeConnectionIndexSearch = outputNodeInPorts.indexOf(linkInProgressFromConnector);
const inputNodeConnectionIndex = inputNodeConnectionIndexSearch > -1 ? inputNodeConnectionIndexSearch : undefined; const inputNodeConnectionIndex = inputNodeConnectionIndexSearch > -1 ? inputNodeConnectionIndexSearch : undefined;
const outputNodeConnectionIndex = outputNodeConnectionIndexSearch > -1 ? outputNodeConnectionIndexSearch : undefined; const outputNodeConnectionIndex = outputNodeConnectionIndexSearch > -1 ? outputNodeConnectionIndexSearch : undefined;
if (inputNodeConnectionIndex !== undefined && outputNodeConnectionIndex !== undefined) { if (inputNodeConnectionIndex !== undefined && outputNodeConnectionIndex !== undefined) {
this.editor.instance.connectNodesByLink(BigInt(outputConnectedNodeID), outputNodeConnectionIndex, BigInt(inputConnectedNodeID), inputNodeConnectionIndex); editor.instance.connectNodesByLink(BigInt(outputConnectedNodeID), outputNodeConnectionIndex, BigInt(inputConnectedNodeID), inputNodeConnectionIndex);
} }
} }
} else if (this.draggingNodes) { } else if (draggingNodes) {
if (this.draggingNodes.startX === e.x || this.draggingNodes.startY === e.y) { if (draggingNodes.startX === e.x || draggingNodes.startY === e.y) {
if (this.selectIfNotDragged !== undefined && (this.selected.length !== 1 || this.selected[0] !== this.selectIfNotDragged)) { if (selectIfNotDragged !== undefined && (selected.length !== 1 || selected[0] !== selectIfNotDragged)) {
this.selected = [this.selectIfNotDragged]; selected = [selectIfNotDragged];
this.editor.instance.selectNodes(new BigUint64Array(this.selected)); editor.instance.selectNodes(new BigUint64Array(selected));
} }
} }
if (this.selected.length > 0 && (this.draggingNodes.roundX !== 0 || this.draggingNodes.roundY !== 0)) if (selected.length > 0 && (draggingNodes.roundX !== 0 || draggingNodes.roundY !== 0)) editor.instance.moveSelectedNodes(draggingNodes.roundX, draggingNodes.roundY);
this.editor.instance.moveSelectedNodes(this.draggingNodes.roundX, this.draggingNodes.roundY);
// Check if this node should be inserted between two other nodes // Check if this node should be inserted between two other nodes
if (this.selected.length === 1) { if (selected.length === 1) {
const selectedNodeId = this.selected[0]; const selectedNodeId = selected[0];
const selectedNode = containerBounds.querySelector(`[data-node="${String(selectedNodeId)}"]`); const selectedNode = nodesContainer.querySelector(`[data-node="${String(selectedNodeId)}"]`);
// Check that neither the input or output of the selected node are already connected. // Check that neither the input or output of the selected node are already connected.
const notConnected = const notConnected = $nodeGraph.links.findIndex((link) => link.linkStart === selectedNodeId || (link.linkEnd === selectedNodeId && link.linkEndInputIndex === BigInt(0))) === -1;
this.nodeGraph.state.links.findIndex((link) => link.linkStart === selectedNodeId || (link.linkEnd === selectedNodeId && link.linkEndInputIndex === BigInt(0))) === -1;
const input = selectedNode?.querySelector(`[data-port="input"]`); const input = selectedNode?.querySelector(`[data-port="input"]`);
const output = selectedNode?.querySelector(`[data-port="output"]`); const output = selectedNode?.querySelector(`[data-port="output"]`);
// TODO: Make sure inputs are correctly typed // TODO: Make sure inputs are correctly typed
if (selectedNode && notConnected && input && output) { if (selectedNode && notConnected && input && output) {
// Find the link that the node has been dragged on top of // Find the link that the node has been dragged on top of
const link = this.nodeGraph.state.links.find((link): boolean => { const link = $nodeGraph.links.find((link): boolean => {
const { nodePrimaryInput, nodePrimaryOutput } = this.resolveLink(link, containerBounds); const { nodePrimaryInput, nodePrimaryOutput } = resolveLink(link, nodesContainer);
if (!nodePrimaryInput || !nodePrimaryOutput) return false; if (!nodePrimaryInput || !nodePrimaryOutput) return false;
const wireCurveLocations = this.buildWirePathLocations(nodePrimaryOutput.getBoundingClientRect(), nodePrimaryInput.getBoundingClientRect(), false, false); const wireCurveLocations = buildWirePathLocations(nodePrimaryOutput.getBoundingClientRect(), nodePrimaryInput.getBoundingClientRect(), false, false);
const selectedNodeBounds = selectedNode.getBoundingClientRect(); const selectedNodeBounds = selectedNode.getBoundingClientRect();
const containerBoundsBounds = containerBounds.getBoundingClientRect(); const containerBoundsBounds = nodesContainer.getBoundingClientRect();
return this.editor.instance.rectangleIntersects( return editor.instance.rectangleIntersects(
new Float64Array(wireCurveLocations.map((loc) => loc.x)), new Float64Array(wireCurveLocations.map((loc) => loc.x)),
new Float64Array(wireCurveLocations.map((loc) => loc.y)), new Float64Array(wireCurveLocations.map((loc) => loc.y)),
selectedNodeBounds.top - containerBoundsBounds.y, selectedNodeBounds.top - containerBoundsBounds.y,
@@ -432,173 +430,162 @@ export default defineComponent({
}); });
// If the node has been dragged on top of the link then connect it into the middle. // If the node has been dragged on top of the link then connect it into the middle.
if (link) { if (link) {
this.editor.instance.connectNodesByLink(link.linkStart, 0, selectedNodeId, 0); editor.instance.connectNodesByLink(link.linkStart, 0, selectedNodeId, 0);
this.editor.instance.connectNodesByLink(selectedNodeId, 0, link.linkEnd, Number(link.linkEndInputIndex)); editor.instance.connectNodesByLink(selectedNodeId, 0, link.linkEnd, Number(link.linkEndInputIndex));
this.editor.instance.shiftNode(selectedNodeId); editor.instance.shiftNode(selectedNodeId);
} }
} }
} }
this.draggingNodes = undefined; draggingNodes = undefined;
this.selectIfNotDragged = undefined; selectIfNotDragged = undefined;
} }
this.linkInProgressFromConnector = undefined; linkInProgressFromConnector = undefined;
this.linkInProgressToConnector = undefined; linkInProgressToConnector = undefined;
}, }
createNode(nodeType: string): void {
if (!this.nodeListLocation) return;
this.editor.instance.createNode(nodeType, this.nodeListLocation.x, this.nodeListLocation.y); function createNode(nodeType: string): void {
this.nodeListLocation = undefined; if (!nodeListLocation) return;
},
}, editor.instance.createNode(nodeType, nodeListLocation.x, nodeListLocation.y);
mounted() { nodeListLocation = undefined;
}
onMount(() => {
const outputPort1 = document.querySelectorAll(`[data-port="output"]`)[4] as HTMLDivElement | undefined; const outputPort1 = document.querySelectorAll(`[data-port="output"]`)[4] as HTMLDivElement | undefined;
const inputPort1 = document.querySelectorAll(`[data-port="input"]`)[1] as HTMLDivElement | undefined; const inputPort1 = document.querySelectorAll(`[data-port="input"]`)[1] as HTMLDivElement | undefined;
if (outputPort1 && inputPort1) this.createWirePath(outputPort1, inputPort1.getBoundingClientRect(), true, true); if (outputPort1 && inputPort1) createWirePath(outputPort1, inputPort1.getBoundingClientRect(), true, true);
const outputPort2 = document.querySelectorAll(`[data-port="output"]`)[6] as HTMLDivElement | undefined; const outputPort2 = document.querySelectorAll(`[data-port="output"]`)[6] as HTMLDivElement | undefined;
const inputPort2 = document.querySelectorAll(`[data-port="input"]`)[3] as HTMLDivElement | undefined; const inputPort2 = document.querySelectorAll(`[data-port="input"]`)[3] as HTMLDivElement | undefined;
if (outputPort2 && inputPort2) this.createWirePath(outputPort2, inputPort2.getBoundingClientRect(), true, false); if (outputPort2 && inputPort2) createWirePath(outputPort2, inputPort2.getBoundingClientRect(), true, false);
this.editor.subscriptions.subscribeJsMessage(UpdateNodeGraphSelection, (updateNodeGraphSelection) => { editor.subscriptions.subscribeJsMessage(UpdateNodeGraphSelection, (updateNodeGraphSelection) => {
this.selected = updateNodeGraphSelection.selected; selected = updateNodeGraphSelection.selected;
});
}); });
},
components: {
IconLabel,
LayoutCol,
LayoutRow,
TextLabel,
TextButton,
TextInput,
WidgetLayout,
},
});
</script> </script>
<template> <LayoutCol class="node-graph">
<LayoutCol class="node-graph"> <LayoutRow class="options-bar"><WidgetLayout layout={nodeGraphBarLayout} /></LayoutRow>
<LayoutRow class="options-bar"><WidgetLayout :layout="nodeGraphBarLayout" /></LayoutRow>
<LayoutRow <LayoutRow
class="graph" class="graph"
ref="graph" bind:this={graph}
@wheel="(e: WheelEvent) => scroll(e)" on:wheel={scroll}
@pointerdown="(e: PointerEvent) => pointerDown(e)" on:pointerdown={pointerDown}
@pointermove="(e: PointerEvent) => pointerMove(e)" on:pointermove={pointerMove}
@pointerup="(e: PointerEvent) => pointerUp(e)" on:pointerup={pointerUp}
@dblclick="(e: MouseEvent) => doubleClick(e)" on:dblclick={doubleClick}
:style="{ styles={{
'--grid-spacing': `${gridSpacing}px`, "--grid-spacing": `${gridSpacing}px`,
'--grid-offset-x': `${transform.x * transform.scale}px`, "--grid-offset-x": `${transform.x * transform.scale}px`,
'--grid-offset-y': `${transform.y * transform.scale}px`, "--grid-offset-y": `${transform.y * transform.scale}px`,
'--dot-radius': `${dotRadius}px`, "--dot-radius": `${dotRadius}px`,
}" }}
> >
<LayoutCol class="node-list" data-node-list v-if="nodeListLocation" :style="{ marginLeft: `${nodeListX}px`, marginTop: `${nodeListY}px` }"> {#if nodeListLocation}
<TextInput placeholder="Search Nodes..." :value="searchTerm" @update:value="(val) => (searchTerm = val)" v-focus /> <LayoutCol class="node-list" data-node-list styles={{ "margin-left": `${nodeListX}px`, "margin-top": `${nodeListY}px` }}>
<LayoutCol v-for="nodeCategory in nodeCategories" :key="nodeCategory[0]"> <TextInput placeholder="Search Nodes..." value={searchTerm} on:value={({ detail }) => (searchTerm = detail)} bind:this={nodeSearchInput} />
<TextLabel>{{ nodeCategory[0] }}</TextLabel> {#each nodeCategories as nodeCategory}
<TextButton v-for="nodeType in nodeCategory[1]" v-bind:key="String(nodeType)" :label="nodeType.name" :action="() => createNode(nodeType.name)" /> <LayoutCol>
<TextLabel>{nodeCategory[0]}</TextLabel>
{#each nodeCategory[1] as nodeType}
<TextButton label={nodeType.name} action={() => createNode(nodeType.name)} />
{/each}
</LayoutCol> </LayoutCol>
<TextLabel v-if="nodeCategories.length === 0">No search results :(</TextLabel> {:else}
<TextLabel>No search results</TextLabel>
{/each}
</LayoutCol> </LayoutCol>
{/if}
<div class="nodes" style:transform={`scale(${transform.scale}) translate(${transform.x}px, ${transform.y}px)`} style:transform-origin={`0 0`} bind:this={nodesContainer}>
{#each $nodeGraph.nodes as node (String(node.id))}
{@const exposedInputsOutputs = [...node.exposedInputs, ...node.outputs.slice(1)]}
<div <div
class="nodes"
ref="nodesContainer"
:style="{
transform: `scale(${transform.scale}) translate(${transform.x}px, ${transform.y}px)`,
transformOrigin: `0 0`,
}"
>
<div
v-for="node in nodes"
:key="String(node.id)"
class="node" class="node"
:class="{ selected: selected.includes(node.id), previewed: node.previewed, disabled: node.disabled }" class:selected={selected.includes(node.id)}
:style="{ class:previewed={node.previewed}
'--offset-left': (node.position?.x || 0) + (selected.includes(node.id) ? draggingNodes?.roundX || 0 : 0), class:disabled={node.disabled}
'--offset-top': (node.position?.y || 0) + (selected.includes(node.id) ? draggingNodes?.roundY || 0 : 0), style:--offset-left={(node.position?.x || 0) + (selected.includes(node.id) ? draggingNodes?.roundX || 0 : 0)}
}" style:--offset-top={(node.position?.y || 0) + (selected.includes(node.id) ? draggingNodes?.roundY || 0 : 0)}
:data-node="node.id" data-node={node.id}
> >
<div class="primary"> <div class="primary">
<div class="ports"> <div class="ports">
{#if node.primaryInput}
<div <div
v-if="node.primaryInput"
class="input port" class="input port"
data-port="input" data-port="input"
:data-datatype="node.primaryInput" data-datatype={node.primaryInput}
:style="{ '--data-color': `var(--color-data-${node.primaryInput})`, '--data-color-dim': `var(--color-data-${node.primaryInput}-dim)` }" style:--data-color={`var(--color-data-${node.primaryInput})`}
style:--data-color-dim={`var(--color-data-${node.primaryInput}-dim)`}
> >
<div></div> <div />
</div> </div>
{/if}
{#if node.outputs.length > 0}
<div <div
v-if="node.outputs.length > 0"
class="output port" class="output port"
data-port="output" data-port="output"
:data-datatype="node.outputs[0].dataType" data-datatype={node.outputs[0].dataType}
:style="{ '--data-color': `var(--color-data-${node.outputs[0].dataType})`, '--data-color-dim': `var(--color-data-${node.outputs[0].dataType}-dim)` }" style:--data-color={`var(--color-data-${node.outputs[0].dataType})`}
style:--data-color-dim={`var(--color-data-${node.outputs[0].dataType}-dim)`}
> >
<div></div> <div />
</div> </div>
{/if}
</div> </div>
<IconLabel :icon="nodeIcon(node.displayName)" /> <IconLabel icon={nodeIcon(node.displayName)} />
<TextLabel>{{ node.displayName }}</TextLabel> <TextLabel>{node.displayName}</TextLabel>
</div> </div>
<div v-if="[...node.exposedInputs, ...node.outputs.slice(1)].length > 0" class="parameters"> {#if exposedInputsOutputs.length > 0}
<div v-for="(parameter, index) in [...node.exposedInputs, ...node.outputs.slice(1)]" :key="index" class="parameter"> <div class="parameters">
{#each exposedInputsOutputs as parameter, index (index)}
<div class="parameter">
<div class="ports"> <div class="ports">
{#if index < node.exposedInputs.length}
<div <div
v-if="index < node.exposedInputs.length"
class="input port" class="input port"
data-port="input" data-port="input"
:data-datatype="parameter.dataType" data-datatype={parameter.dataType}
:style="{ style:--data-color={`var(--color-data-${parameter.dataType})`}
'--data-color': `var(--color-data-${parameter.dataType})`, style:--data-color-dim={`var(--color-data-${parameter.dataType}-dim)`}
'--data-color-dim': `var(--color-data-${parameter.dataType}-dim)`,
}"
> >
<div></div> <div />
</div> </div>
{:else}
<div <div
v-else
class="output port" class="output port"
data-port="output" data-port="output"
:data-datatype="parameter.dataType" data-datatype={parameter.dataType}
:style="{ '--data-color': `var(--color-data-${parameter.dataType})`, '--data-color-dim': `var(--color-data-${parameter.dataType}-dim)` }" style:--data-color={`var(--color-data-${parameter.dataType})`}
style:--data-color-dim={`var(--color-data-${parameter.dataType}-dim)`}
> >
<div></div> <div />
</div> </div>
{/if}
</div> </div>
<TextLabel :class="index < node.exposedInputs.length ? 'name' : 'output'">{{ parameter.name }}</TextLabel> <TextLabel class={index < node.exposedInputs.length ? "name" : "output"}>{parameter.name}</TextLabel>
</div> </div>
{/each}
</div> </div>
{/if}
</div> </div>
{/each}
</div> </div>
<div <div class="wires" style:transform={`scale(${transform.scale}) translate(${transform.x}px, ${transform.y}px)`} style:transform-origin={`0 0`}>
class="wires"
:style="{
transform: `scale(${transform.scale}) translate(${transform.x}px, ${transform.y}px)`,
transformOrigin: `0 0`,
}"
>
<svg> <svg>
<path {#each linkPaths as [pathString, dataType], index (index)}
v-for="([pathString, dataType], index) in linkPaths" <path d={pathString} style:--data-color={`var(--color-data-${dataType})`} style:--data-color-dim={`var(--color-data-${dataType}-dim)`} />
:key="index" {/each}
:d="pathString"
:style="{ '--data-color': `var(--color-data-${dataType})`, '--data-color-dim': `var(--color-data-${dataType}-dim)` }"
/>
</svg> </svg>
</div> </div>
</LayoutRow> </LayoutRow>
</LayoutCol> </LayoutCol>
</template>
<style lang="scss"> <style lang="scss" global>
.node-graph { .node-graph {
height: 100%; height: 100%;
position: relative; position: relative;
@@ -810,5 +797,5 @@ export default defineComponent({
} }
} }
} }
} }
</style> </style>
@@ -1,50 +1,42 @@
<script lang="ts"> <script lang="ts">
import { defineComponent } from "vue"; import { getContext, onMount } from "svelte";
import { defaultWidgetLayout, patchWidgetLayout, UpdatePropertyPanelOptionsLayout, UpdatePropertyPanelSectionsLayout } from "@/wasm-communication/messages"; import { defaultWidgetLayout, patchWidgetLayout, UpdatePropertyPanelOptionsLayout, UpdatePropertyPanelSectionsLayout } from "@/wasm-communication/messages";
import LayoutCol from "@/components/layout/LayoutCol.vue"; import LayoutCol from "@/components/layout/LayoutCol.svelte";
import LayoutRow from "@/components/layout/LayoutRow.vue"; import LayoutRow from "@/components/layout/LayoutRow.svelte";
import WidgetLayout from "@/components/widgets/WidgetLayout.vue"; import WidgetLayout from "@/components/widgets/WidgetLayout.svelte";
import { type Editor } from "@/wasm-communication/editor";
export default defineComponent({ const editor = getContext<Editor>("editor");
inject: ["editor", "dialog"],
data() { let propertiesOptionsLayout = defaultWidgetLayout();
return { let propertiesSectionsLayout = defaultWidgetLayout();
propertiesOptionsLayout: defaultWidgetLayout(),
propertiesSectionsLayout: defaultWidgetLayout(), onMount(() => {
}; editor.subscriptions.subscribeJsMessage(UpdatePropertyPanelOptionsLayout, (updatePropertyPanelOptionsLayout) => {
}, patchWidgetLayout(propertiesOptionsLayout, updatePropertyPanelOptionsLayout);
mounted() { propertiesOptionsLayout = propertiesOptionsLayout;
this.editor.subscriptions.subscribeJsMessage(UpdatePropertyPanelOptionsLayout, (updatePropertyPanelOptionsLayout) => {
patchWidgetLayout(this.propertiesOptionsLayout, updatePropertyPanelOptionsLayout);
}); });
this.editor.subscriptions.subscribeJsMessage(UpdatePropertyPanelSectionsLayout, (updatePropertyPanelSectionsLayout) => { editor.subscriptions.subscribeJsMessage(UpdatePropertyPanelSectionsLayout, (updatePropertyPanelSectionsLayout) => {
patchWidgetLayout(this.propertiesSectionsLayout, updatePropertyPanelSectionsLayout); patchWidgetLayout(propertiesSectionsLayout, updatePropertyPanelSectionsLayout);
propertiesSectionsLayout = propertiesSectionsLayout;
});
}); });
},
components: {
LayoutCol,
LayoutRow,
WidgetLayout,
},
});
</script> </script>
<template> <LayoutCol class="properties">
<LayoutCol class="properties">
<LayoutRow class="options-bar"> <LayoutRow class="options-bar">
<WidgetLayout :layout="propertiesOptionsLayout" /> <WidgetLayout layout={propertiesOptionsLayout} />
</LayoutRow> </LayoutRow>
<LayoutRow class="sections" :scrollableY="true"> <LayoutRow class="sections" scrollableY={true}>
<WidgetLayout :layout="propertiesSectionsLayout" /> <WidgetLayout layout={propertiesSectionsLayout} />
</LayoutRow> </LayoutRow>
</LayoutCol> </LayoutCol>
</template>
<style lang="scss"> <style lang="scss" global>
.properties { .properties {
height: 100%; height: 100%;
.widget-layout { .widget-layout {
@@ -64,5 +56,5 @@ export default defineComponent({
.text-button { .text-button {
flex-basis: 0; flex-basis: 0;
} }
} }
</style> </style>
@@ -1,44 +1,37 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, type PropType } from "vue"; import { isWidgetColumn, isWidgetRow, isWidgetSection, type WidgetLayout } from "@/wasm-communication/messages";
import { isWidgetColumn, isWidgetRow, isWidgetSection, type LayoutGroup, type WidgetLayout } from "@/wasm-communication/messages"; import WidgetSection from "@/components/widgets/groups/WidgetSection.svelte";
import WidgetRow from "@/components/widgets/WidgetRow.svelte";
import WidgetSection from "@/components/widgets/groups/WidgetSection.vue"; export let layout: WidgetLayout;
import WidgetRow from "@/components/widgets/WidgetRow.vue"; let className = "";
export { className as class };
export let classes: Record<string, boolean> = {};
export default defineComponent({ $: extraClasses = Object.entries(classes)
props: { .flatMap((classAndState) => (classAndState[1] ? [classAndState[0]] : []))
layout: { type: Object as PropType<WidgetLayout>, required: true }, .join(" ");
},
methods: {
layoutGroupType(layoutRow: LayoutGroup): unknown {
if (isWidgetColumn(layoutRow)) return WidgetRow;
if (isWidgetRow(layoutRow)) return WidgetRow;
if (isWidgetSection(layoutRow)) return WidgetSection;
throw new Error("Layout row type does not exist");
},
},
components: {
WidgetRow,
WidgetSection,
},
});
</script> </script>
<!-- TODO: Refactor this component (together with `WidgetRow.vue`) to be more logically consistent with our layout definition goals, in terms of naming and capabilities --> <!-- TODO: Refactor this component (together with `WidgetRow.svelte`) to be more logically consistent with our layout definition goals, in terms of naming and capabilities -->
<div class={`widget-layout ${className} ${extraClasses}`.trim()}>
{#each layout.layout as layoutGroup, index (index)}
{#if isWidgetColumn(layoutGroup) || isWidgetRow(layoutGroup)}
<WidgetRow widgetData={layoutGroup} layoutTarget={layout.layoutTarget} />
{:else if isWidgetSection(layoutGroup)}
<WidgetSection widgetData={layoutGroup} layoutTarget={layout.layoutTarget} />
{:else}
<span style="color: #d6536e">Error: The widget row that belongs here has an invalid layout group type</span>
{/if}
{/each}
</div>
<template> <style lang="scss" global>
<div class="widget-layout"> .widget-layout {
<component :is="layoutGroupType(layoutRow)" :widgetData="layoutRow" :layoutTarget="layout.layoutTarget" v-for="(layoutRow, index) in layout.layout" :key="index" />
</div>
</template>
<style lang="scss">
.widget-layout {
height: 100%; height: 100%;
flex: 0 0 auto; flex: 0 0 auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
</style> </style>
+162 -140
View File
@@ -1,168 +1,190 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, type PropType } from "vue"; import { debouncer } from "@/utility-functions/debounce";
import { narrowWidgetProps, Widget } from "@/wasm-communication/messages";
import { isWidgetColumn, isWidgetRow, type WidgetColumn, type WidgetRow } from "@/wasm-communication/messages";
import { debouncer } from "@/components/widgets/debounce"; import PivotAssist from "@/components/widgets/assists/PivotAssist.svelte";
import { isWidgetColumn, isWidgetRow, type WidgetColumn, type WidgetRow, type Widget } from "@/wasm-communication/messages"; import BreadcrumbTrailButtons from "@/components/widgets/buttons/BreadcrumbTrailButtons.svelte";
import IconButton from "@/components/widgets/buttons/IconButton.svelte";
import ParameterExposeButton from "@/components/widgets/buttons/ParameterExposeButton.svelte";
import PopoverButton from "@/components/widgets/buttons/PopoverButton.svelte";
import TextButton from "@/components/widgets/buttons/TextButton.svelte";
import CheckboxInput from "@/components/widgets/inputs/CheckboxInput.svelte";
import ColorInput from "@/components/widgets/inputs/ColorInput.svelte";
import DropdownInput from "@/components/widgets/inputs/DropdownInput.svelte";
import FontInput from "@/components/widgets/inputs/FontInput.svelte";
import LayerReferenceInput from "@/components/widgets/inputs/LayerReferenceInput.svelte";
import NumberInput from "@/components/widgets/inputs/NumberInput.svelte";
import OptionalInput from "@/components/widgets/inputs/OptionalInput.svelte";
import RadioInput from "@/components/widgets/inputs/RadioInput.svelte";
import SwatchPairInput from "@/components/widgets/inputs/SwatchPairInput.svelte";
import TextAreaInput from "@/components/widgets/inputs/TextAreaInput.svelte";
import TextInput from "@/components/widgets/inputs/TextInput.svelte";
import IconLabel from "@/components/widgets/labels/IconLabel.svelte";
import Separator from "@/components/widgets/labels/Separator.svelte";
import TextLabel from "@/components/widgets/labels/TextLabel.svelte";
import { getContext } from "svelte";
import { type Editor } from "@/wasm-communication/editor";
import PivotAssist from "@/components/widgets/assists/PivotAssist.vue"; const SUFFIX_WIDGETS = ["PopoverButton"];
import BreadcrumbTrailButtons from "@/components/widgets/buttons/BreadcrumbTrailButtons.vue";
import IconButton from "@/components/widgets/buttons/IconButton.vue";
import ParameterExposeButton from "@/components/widgets/buttons/ParameterExposeButton.vue";
import PopoverButton from "@/components/widgets/buttons/PopoverButton.vue";
import TextButton from "@/components/widgets/buttons/TextButton.vue";
import CheckboxInput from "@/components/widgets/inputs/CheckboxInput.vue";
import ColorInput from "@/components/widgets/inputs/ColorInput.vue";
import DropdownInput from "@/components/widgets/inputs/DropdownInput.vue";
import FontInput from "@/components/widgets/inputs/FontInput.vue";
import LayerReferenceInput from "@/components/widgets/inputs/LayerReferenceInput.vue";
import NumberInput from "@/components/widgets/inputs/NumberInput.vue";
import OptionalInput from "@/components/widgets/inputs/OptionalInput.vue";
import RadioInput from "@/components/widgets/inputs/RadioInput.vue";
import SwatchPairInput from "@/components/widgets/inputs/SwatchPairInput.vue";
import TextAreaInput from "@/components/widgets/inputs/TextAreaInput.vue";
import TextInput from "@/components/widgets/inputs/TextInput.vue";
import IconLabel from "@/components/widgets/labels/IconLabel.vue";
import Separator from "@/components/widgets/labels/Separator.vue";
import TextLabel from "@/components/widgets/labels/TextLabel.vue";
const SUFFIX_WIDGETS = ["PopoverButton"]; const editor = getContext<Editor>("editor");
export default defineComponent({ export let widgetData: WidgetColumn | WidgetRow;
inject: ["editor"], export let layoutTarget: any;
props: {
widgetData: { type: Object as PropType<WidgetColumn | WidgetRow>, required: true }, $: direction = watchDirection(widgetData);
layoutTarget: { required: true }, $: widgets = watchWidgets(widgetData);
}, $: widgetsAndNextSiblingIsSuffix = watchWidgetsAndNextSiblingIsSuffix(widgets);
data() {
return { function watchDirection(widgetData: WidgetRow | WidgetColumn): "row" | "column" | "ERROR" {
open: false, if (isWidgetRow(widgetData)) return "row";
}; if (isWidgetColumn(widgetData)) return "column";
},
computed: {
direction(): "column" | "row" | "ERROR" {
if (isWidgetColumn(this.widgetData)) return "column";
if (isWidgetRow(this.widgetData)) return "row";
return "ERROR"; return "ERROR";
}, }
widgets() {
function watchWidgets(widgetData: WidgetRow | WidgetColumn): Widget[] {
let widgets: Widget[] = []; let widgets: Widget[] = [];
if (isWidgetColumn(this.widgetData)) widgets = this.widgetData.columnWidgets; if (isWidgetRow(widgetData)) widgets = widgetData.rowWidgets;
if (isWidgetRow(this.widgetData)) widgets = this.widgetData.rowWidgets; else if (isWidgetColumn(widgetData)) widgets = widgetData.columnWidgets;
return widgets; return widgets;
}, }
widgetsAndNextSiblingIsSuffix(): [Widget, boolean][] {
return this.widgets.map((widget, index): [Widget, boolean] => { function watchWidgetsAndNextSiblingIsSuffix(widgets: Widget[]): [Widget, boolean][] {
return widgets.map((widget, index): [Widget, boolean] => {
// A suffix widget is one that joins up with this widget at the end with only a 1px gap. // A suffix widget is one that joins up with this widget at the end with only a 1px gap.
// It uses the CSS sibling selector to give its own left edge corners zero radius. // It uses the CSS sibling selector to give its own left edge corners zero radius.
// But this JS is needed to set its preceding sibling widget's right edge corners to zero radius. // But this JS is needed to set its preceding sibling widget's right edge corners to zero radius.
const nextSiblingIsSuffix = SUFFIX_WIDGETS.includes(this.widgets[index + 1]?.props.kind); const nextSiblingIsSuffix = SUFFIX_WIDGETS.includes(widgets[index + 1]?.props.kind);
return [widget, nextSiblingIsSuffix]; return [widget, nextSiblingIsSuffix];
}); });
}, }
},
methods: { function updateLayout(index: number, value: unknown) {
updateLayout(index: number, value: unknown) { editor.instance.updateLayout(layoutTarget, widgets[index].widgetId, value);
this.editor.instance.updateLayout(this.layoutTarget, this.widgets[index].widgetId, value); }
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
withoutValue(props: Record<string, any>): Record<string, unknown> { // function exclude<T extends Record<string, any>>(props: T, additional?: (keyof T)[]): Pick<T, Exclude<keyof T, "kind" | (typeof additional extends Array<infer K> ? K : never)>> {
const { value: _, ...rest } = props; // const exclusions = ["kind", ...(additional || [])];
return rest;
}, // return Object.fromEntries(Object.entries(props).filter((entry) => !exclusions.includes(entry[0]))) as any;
debouncer, // }
},
components: { // TODO: This seems to work, but verify the correctness and terseness of this, it's adapted from https://stackoverflow.com/a/67434028/775283
BreadcrumbTrailButtons, function exclude<T extends object>(props: T, additional?: (keyof T)[]): Omit<T, typeof additional extends Array<infer K> ? K : never> {
CheckboxInput, const exclusions = ["kind", ...(additional || [])];
ColorInput,
DropdownInput, return Object.fromEntries(Object.entries(props).filter((entry) => !exclusions.includes(entry[0]))) as any;
FontInput, }
IconButton,
IconLabel,
LayerReferenceInput,
NumberInput,
OptionalInput,
ParameterExposeButton,
PivotAssist,
PopoverButton,
RadioInput,
Separator,
SwatchPairInput,
TextAreaInput,
TextButton,
TextInput,
TextLabel,
},
});
</script> </script>
<!-- TODO: Refactor this component to use `<component :is="" v-bind="attributesObject"></component>` to avoid all the separate components with `v-if` --> <!-- TODO: Refactor this component to use `<svelte:component this={attributesObject} />` to avoid all the separate conditional components -->
<!-- TODO: Also rename this component, and probably move the `widget-${direction}` wrapper to be part of `WidgetLayout.vue` as part of its refactor --> <!-- TODO: Also rename this component, and probably move the `widget-${direction}` wrapper to be part of `WidgetLayout.svelte` as part of its refactor -->
<template> <div class={`widget-${direction}`}>
<div :class="`widget-${direction}`"> {#each widgetsAndNextSiblingIsSuffix as [component, nextIsSuffix], index (index)}
<template v-for="([component, nextIsSuffix], index) in widgetsAndNextSiblingIsSuffix" :key="index"> {@const checkboxInput = narrowWidgetProps(component.props, "CheckboxInput")}
<CheckboxInput v-if="component.props.kind === 'CheckboxInput'" v-bind="component.props" @update:checked="(value: boolean) => updateLayout(index, value)" /> {#if checkboxInput}
<ColorInput <CheckboxInput {...exclude(checkboxInput)} on:checked={({ detail }) => updateLayout(index, detail)} />
v-if="component.props.kind === 'ColorInput'" {/if}
v-bind="component.props" {@const colorInput = narrowWidgetProps(component.props, "ColorInput")}
v-model:open="open" {#if colorInput}
@update:value="(value: unknown) => updateLayout(index, value)" <ColorInput {...exclude(colorInput)} on:value={({ detail }) => updateLayout(index, detail)} sharpRightCorners={nextIsSuffix} />
:sharpRightCorners="nextIsSuffix" {/if}
/> {@const dropdownInput = narrowWidgetProps(component.props, "DropdownInput")}
<DropdownInput {#if dropdownInput}
v-if="component.props.kind === 'DropdownInput'" <DropdownInput {...exclude(dropdownInput)} on:selectedIndex={({ detail }) => updateLayout(index, detail)} sharpRightCorners={nextIsSuffix} />
v-bind="component.props" {/if}
v-model:open="open" {@const fontInput = narrowWidgetProps(component.props, "FontInput")}
@update:selectedIndex="(value: number) => updateLayout(index, value)" {#if fontInput}
:sharpRightCorners="nextIsSuffix" <FontInput {...exclude(fontInput)} on:changeFont={({ detail }) => updateLayout(index, detail)} sharpRightCorners={nextIsSuffix} />
/> {/if}
<FontInput {@const parameterExposeButton = narrowWidgetProps(component.props, "ParameterExposeButton")}
v-if="component.props.kind === 'FontInput'" {#if parameterExposeButton}
v-bind="component.props" <ParameterExposeButton {...exclude(parameterExposeButton)} action={() => updateLayout(index, undefined)} />
v-model:open="open" {/if}
@changeFont="(value: unknown) => updateLayout(index, value)" {@const iconButton = narrowWidgetProps(component.props, "IconButton")}
:sharpRightCorners="nextIsSuffix" {#if iconButton}
/> <IconButton {...exclude(iconButton)} action={() => updateLayout(index, undefined)} sharpRightCorners={nextIsSuffix} />
<ParameterExposeButton v-if="component.props.kind === 'ParameterExposeButton'" v-bind="component.props" :action="() => updateLayout(index, undefined)" /> {/if}
<IconButton v-if="component.props.kind === 'IconButton'" v-bind="component.props" :action="() => updateLayout(index, undefined)" :sharpRightCorners="nextIsSuffix" /> {@const iconLabel = narrowWidgetProps(component.props, "IconLabel")}
<IconLabel v-if="component.props.kind === 'IconLabel'" v-bind="component.props" /> {#if iconLabel}
<LayerReferenceInput v-if="component.props.kind === 'LayerReferenceInput'" v-bind="component.props" @update:value="(value: BigUint64Array) => updateLayout(index, value)" /> <IconLabel {...exclude(iconLabel)} />
{/if}
{@const layerReferenceInput = narrowWidgetProps(component.props, "LayerReferenceInput")}
{#if layerReferenceInput}
<LayerReferenceInput {...exclude(layerReferenceInput)} on:value={({ detail }) => updateLayout(index, detail)} />
{/if}
{@const numberInput = narrowWidgetProps(component.props, "NumberInput")}
{#if numberInput}
<NumberInput <NumberInput
v-if="component.props.kind === 'NumberInput'" {...exclude(numberInput)}
v-bind="component.props" on:value={({ detail }) => debouncer((value) => updateLayout(index, value)).updateValue(detail)}
@update:value="debouncer((value: number) => updateLayout(index, value)).updateValue" incrementCallbackIncrease={() => updateLayout(index, "Increment")}
:incrementCallbackIncrease="() => updateLayout(index, 'Increment')" incrementCallbackDecrease={() => updateLayout(index, "Decrement")}
:incrementCallbackDecrease="() => updateLayout(index, 'Decrement')" sharpRightCorners={nextIsSuffix}
:sharpRightCorners="nextIsSuffix"
/> />
<OptionalInput v-if="component.props.kind === 'OptionalInput'" v-bind="component.props" @update:checked="(value: boolean) => updateLayout(index, value)" /> {/if}
<PivotAssist v-if="component.props.kind === 'PivotAssist'" v-bind="component.props" @update:position="(value: string) => updateLayout(index, value)" /> {@const optionalInput = narrowWidgetProps(component.props, "OptionalInput")}
<PopoverButton v-if="component.props.kind === 'PopoverButton'" v-bind="component.props"> {#if optionalInput}
<TextLabel :bold="true">{{ (component.props as any).header }}</TextLabel> <OptionalInput {...exclude(optionalInput)} on:checked={({ detail }) => updateLayout(index, detail)} />
<TextLabel :multiline="true">{{ (component.props as any).text }}</TextLabel> {/if}
{@const pivotAssist = narrowWidgetProps(component.props, "PivotAssist")}
{#if pivotAssist}
<PivotAssist {...exclude(pivotAssist)} on:position={({ detail }) => updateLayout(index, detail)} />
{/if}
{@const popoverButton = narrowWidgetProps(component.props, "PopoverButton")}
{#if popoverButton}
<PopoverButton {...exclude(popoverButton, ["header", "text"])}>
<TextLabel bold={true}>{popoverButton.header}</TextLabel>
<TextLabel multiline={true}>{popoverButton.text}</TextLabel>
</PopoverButton> </PopoverButton>
<RadioInput v-if="component.props.kind === 'RadioInput'" v-bind="component.props" @update:selectedIndex="(value: number) => updateLayout(index, value)" :sharpRightCorners="nextIsSuffix" /> {/if}
<Separator v-if="component.props.kind === 'Separator'" v-bind="component.props" /> {@const radioInput = narrowWidgetProps(component.props, "RadioInput")}
<SwatchPairInput v-if="component.props.kind === 'SwatchPairInput'" v-bind="component.props" /> {#if radioInput}
<TextAreaInput v-if="component.props.kind === 'TextAreaInput'" v-bind="component.props" @commitText="(value: string) => updateLayout(index, value)" /> <RadioInput {...exclude(radioInput)} on:selectedIndex={({ detail }) => updateLayout(index, detail)} sharpRightCorners={nextIsSuffix} />
<TextButton v-if="component.props.kind === 'TextButton'" v-bind="component.props" :action="() => updateLayout(index, undefined)" :sharpRightCorners="nextIsSuffix" /> {/if}
<BreadcrumbTrailButtons v-if="component.props.kind === 'BreadcrumbTrailButtons'" v-bind="component.props" :action="(index: number) => updateLayout(index, index)" /> {@const separator = narrowWidgetProps(component.props, "Separator")}
<TextInput v-if="component.props.kind === 'TextInput'" v-bind="component.props" @commitText="(value: string) => updateLayout(index, value)" :sharpRightCorners="nextIsSuffix" /> {#if separator}
<TextLabel v-if="component.props.kind === 'TextLabel'" v-bind="withoutValue(component.props)">{{ (component.props as any).value }}</TextLabel> <Separator {...exclude(separator)} />
</template> {/if}
</div> {@const swatchPairInput = narrowWidgetProps(component.props, "SwatchPairInput")}
</template> {#if swatchPairInput}
<SwatchPairInput {...exclude(swatchPairInput)} />
{/if}
{@const textAreaInput = narrowWidgetProps(component.props, "TextAreaInput")}
{#if textAreaInput}
<TextAreaInput {...exclude(textAreaInput)} on:commitText={({ detail }) => updateLayout(index, detail)} />
{/if}
{@const textButton = narrowWidgetProps(component.props, "TextButton")}
{#if textButton}
<TextButton {...exclude(textButton)} action={() => updateLayout(index, undefined)} sharpRightCorners={nextIsSuffix} />
{/if}
{@const breadcrumbTrailButtons = narrowWidgetProps(component.props, "BreadcrumbTrailButtons")}
{#if breadcrumbTrailButtons}
<BreadcrumbTrailButtons {...exclude(breadcrumbTrailButtons)} action={(index) => updateLayout(index, index)} />
{/if}
{@const textInput = narrowWidgetProps(component.props, "TextInput")}
{#if textInput}
<TextInput {...exclude(textInput)} on:commitText={({ detail }) => updateLayout(index, detail)} sharpRightCorners={nextIsSuffix} />
{/if}
{@const textLabel = narrowWidgetProps(component.props, "TextLabel")}
{#if textLabel}
<TextLabel {...exclude(textLabel, ["value"])}>{textLabel.value}</TextLabel>
{/if}
{/each}
</div>
<style lang="scss"> <style lang="scss" global>
.widget-column { .widget-column {
flex: 0 0 auto; flex: 0 0 auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.widget-row { .widget-row {
flex: 0 0 auto; flex: 0 0 auto;
display: flex; display: flex;
min-height: 32px; min-height: 32px;
@@ -209,5 +231,5 @@ export default defineComponent({
bottom: calc(-4px - 16px); bottom: calc(-4px - 16px);
} }
} }
} }
</style> </style>
@@ -1,38 +1,33 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, type PropType } from "vue"; import { createEventDispatcher } from "svelte";
import { type PivotPosition } from "@/wasm-communication/messages"; import { type PivotPosition } from "@/wasm-communication/messages";
export default defineComponent({ // emits: ["update:position"],
emits: ["update:position"], const dispatch = createEventDispatcher<{ position: PivotPosition }>();
props: {
position: { type: String as PropType<string>, required: true }, export let position: string;
disabled: { type: Boolean as PropType<boolean>, default: false }, export let disabled = false;
},
methods: { function setPosition(newPosition: PivotPosition) {
setPosition(newPosition: PivotPosition) { dispatch("position", newPosition);
this.$emit("update:position", newPosition); }
},
},
});
</script> </script>
<template> <div class="pivot-assist" class:disabled>
<div class="pivot-assist" :class="{ disabled }"> <button on:click={() => setPosition("TopLeft")} class="row-1 col-1" class:active={position === "TopLeft"} tabindex="-1" {disabled}><div /></button>
<button @click="setPosition('TopLeft')" class="row-1 col-1" :class="{ active: position === 'TopLeft' }" tabindex="-1" :disabled="disabled"><div></div></button> <button on:click={() => setPosition("TopCenter")} class="row-1 col-2" class:active={position === "TopCenter"} tabindex="-1" {disabled}><div /></button>
<button @click="setPosition('TopCenter')" class="row-1 col-2" :class="{ active: position === 'TopCenter' }" tabindex="-1" :disabled="disabled"><div></div></button> <button on:click={() => setPosition("TopRight")} class="row-1 col-3" class:active={position === "TopRight"} tabindex="-1" {disabled}><div /></button>
<button @click="setPosition('TopRight')" class="row-1 col-3" :class="{ active: position === 'TopRight' }" tabindex="-1" :disabled="disabled"><div></div></button> <button on:click={() => setPosition("CenterLeft")} class="row-2 col-1" class:active={position === "CenterLeft"} tabindex="-1" {disabled}><div /></button>
<button @click="setPosition('CenterLeft')" class="row-2 col-1" :class="{ active: position === 'CenterLeft' }" tabindex="-1" :disabled="disabled"><div></div></button> <button on:click={() => setPosition("Center")} class="row-2 col-2" class:active={position === "Center"} tabindex="-1" {disabled}><div /></button>
<button @click="setPosition('Center')" class="row-2 col-2" :class="{ active: position === 'Center' }" tabindex="-1" :disabled="disabled"><div></div></button> <button on:click={() => setPosition("CenterRight")} class="row-2 col-3" class:active={position === "CenterRight"} tabindex="-1" {disabled}><div /></button>
<button @click="setPosition('CenterRight')" class="row-2 col-3" :class="{ active: position === 'CenterRight' }" tabindex="-1" :disabled="disabled"><div></div></button> <button on:click={() => setPosition("BottomLeft")} class="row-3 col-1" class:active={position === "BottomLeft"} tabindex="-1" {disabled}><div /></button>
<button @click="setPosition('BottomLeft')" class="row-3 col-1" :class="{ active: position === 'BottomLeft' }" tabindex="-1" :disabled="disabled"><div></div></button> <button on:click={() => setPosition("BottomCenter")} class="row-3 col-2" class:active={position === "BottomCenter"} tabindex="-1" {disabled}><div /></button>
<button @click="setPosition('BottomCenter')" class="row-3 col-2" :class="{ active: position === 'BottomCenter' }" tabindex="-1" :disabled="disabled"><div></div></button> <button on:click={() => setPosition("BottomRight")} class="row-3 col-3" class:active={position === "BottomRight"} tabindex="-1" {disabled}><div /></button>
<button @click="setPosition('BottomRight')" class="row-3 col-3" :class="{ active: position === 'BottomRight' }" tabindex="-1" :disabled="disabled"><div></div></button> </div>
</div>
</template>
<style lang="scss"> <style lang="scss" global>
.pivot-assist { .pivot-assist {
position: relative; position: relative;
flex: 0 0 auto; flex: 0 0 auto;
width: 24px; width: 24px;
@@ -117,6 +112,5 @@ export default defineComponent({
--pivot-border-color: var(--color-4-dimgray); --pivot-border-color: var(--color-4-dimgray);
--pivot-fill-active: var(--color-8-uppergray); --pivot-fill-active: var(--color-8-uppergray);
} }
} }
</style> </style>
@@ -1,40 +1,22 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, type PropType } from "vue"; import LayoutRow from "@/components/layout/LayoutRow.svelte";
import TextButton from "@/components/widgets/buttons/TextButton.svelte";
import LayoutRow from "@/components/layout/LayoutRow.vue";
import TextButton from "@/components/widgets/buttons/TextButton.vue";
export default defineComponent({
props: {
labels: { type: Array as PropType<string[]>, required: true },
disabled: { type: Boolean as PropType<boolean>, default: false },
tooltip: { type: String as PropType<string | undefined>, required: false },
export let labels: string[];
export let disabled = false;
export let tooltip: string | undefined = undefined;
// Callbacks // Callbacks
action: { type: Function as PropType<(index: number) => void>, required: true }, export let action: (index: number) => void;
},
components: {
LayoutRow,
TextButton,
},
});
</script> </script>
<template> <LayoutRow class="breadcrumb-trail-buttons" {tooltip}>
<LayoutRow class="breadcrumb-trail-buttons" :title="tooltip"> {#each labels as label, index (index)}
<TextButton <TextButton {label} emphasized={index === labels.length - 1} {disabled} action={() => !disabled && index !== labels.length - 1 && action(index)} />
v-for="(label, index) in labels" {/each}
:key="index" </LayoutRow>
:label="label"
:emphasized="index === labels.length - 1"
:disabled="disabled"
:action="() => !disabled && index !== labels.length - 1 && action(index)"
/>
</LayoutRow>
</template>
<style lang="scss"> <style lang="scss" global>
.breadcrumb-trail-buttons { .breadcrumb-trail-buttons {
.text-button { .text-button {
position: relative; position: relative;
@@ -77,5 +59,5 @@ export default defineComponent({
pointer-events: none; pointer-events: none;
} }
} }
} }
</style> </style>
@@ -1,41 +1,42 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, type PropType } from "vue"; import { type IconName, type IconSize } from "@/utility-functions/icons";
import { type IconName, type IconSize } from "@/utility-functions/icons"; import IconLabel from "@/components/widgets/labels/IconLabel.svelte";
import IconLabel from "@/components/widgets/labels/IconLabel.vue";
export default defineComponent({
props: {
icon: { type: String as PropType<IconName>, required: true },
size: { type: Number as PropType<IconSize>, required: true },
disabled: { type: Boolean as PropType<boolean>, default: false },
active: { type: Boolean as PropType<boolean>, default: false },
tooltip: { type: String as PropType<string | undefined>, required: false },
sharpRightCorners: { type: Boolean as PropType<boolean>, default: false },
export let icon: IconName;
export let size: IconSize;
export let disabled = false;
export let active = false;
export let tooltip: string | undefined = undefined;
export let sharpRightCorners = false;
// Callbacks // Callbacks
action: { type: Function as PropType<(e?: MouseEvent) => void>, required: true }, export let action: (e?: MouseEvent) => void;
},
components: { IconLabel }, let className = "";
}); export { className as class };
export let classes: Record<string, boolean> = {};
$: extraClasses = Object.entries(classes)
.flatMap((classAndState) => (classAndState[1] ? [classAndState[0]] : []))
.join(" ");
</script> </script>
<template> <button
<button class={`icon-button size-${size} ${className} ${extraClasses}`.trim()}
class="icon-button" class:disabled
:class="[`size-${size}`, { disabled, active, 'sharp-right-corners': sharpRightCorners }]" class:active
@click="(e: MouseEvent) => action(e)" class:sharp-right-corners={sharpRightCorners}
:disabled="disabled" on:click={action}
:title="tooltip" {disabled}
:tabindex="active ? -1 : 0" title={tooltip}
> tabindex={active ? -1 : 0}
<IconLabel :icon="icon" /> {...$$restProps}
</button> >
</template> <IconLabel {icon} />
</button>
<style lang="scss"> <style lang="scss" global>
.icon-button { .icon-button {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
@@ -99,5 +100,5 @@ export default defineComponent({
width: 32px; width: 32px;
height: 32px; height: 32px;
} }
} }
</style> </style>
@@ -1,29 +1,19 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, type PropType } from "vue"; import LayoutRow from "@/components/layout/LayoutRow.svelte";
import LayoutRow from "@/components/layout/LayoutRow.vue";
export default defineComponent({
props: {
exposed: { type: Boolean as PropType<boolean>, required: true },
dataType: { type: String as PropType<string>, required: true },
tooltip: { type: String as PropType<string | undefined>, required: false },
export let exposed: boolean;
export let dataType: string;
export let tooltip: string | undefined = undefined;
// Callbacks // Callbacks
action: { type: Function as PropType<(e?: MouseEvent) => void>, required: true }, export let action: (e?: MouseEvent) => void;
},
components: { LayoutRow },
});
</script> </script>
<template> <LayoutRow class="parameter-expose-button">
<LayoutRow class="parameter-expose-button"> <button class:exposed style:--data-type-color={`var(--color-data-${dataType})`} on:click={action} title={tooltip} tabindex="0" />
<button :class="{ exposed }" :style="{ '--data-type-color': `var(--color-data-${dataType})` }" @click="(e: MouseEvent) => action(e)" :title="tooltip" :tabindex="0"></button> </LayoutRow>
</LayoutRow>
</template>
<style lang="scss"> <style lang="scss" global>
.parameter-expose-button { .parameter-expose-button {
display: flex; display: flex;
align-items: center; align-items: center;
flex: 0 0 auto; flex: 0 0 auto;
@@ -55,5 +45,5 @@ export default defineComponent({
} }
} }
} }
} }
</style> </style>
@@ -1,52 +1,33 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, type PropType } from "vue"; import { type IconName } from "@/utility-functions/icons";
import { type IconName } from "@/utility-functions/icons"; import FloatingMenu from "@/components/layout/FloatingMenu.svelte";
import LayoutRow from "@/components/layout/LayoutRow.svelte";
import FloatingMenu from "@/components/layout/FloatingMenu.vue"; import IconButton from "@/components/widgets/buttons/IconButton.svelte";
import LayoutRow from "@/components/layout/LayoutRow.vue";
import IconButton from "@/components/widgets/buttons/IconButton.vue";
export default defineComponent({
props: {
icon: { type: String as PropType<IconName>, default: "DropdownArrow" },
tooltip: { type: String as PropType<string | undefined>, required: false },
disabled: { type: Boolean as PropType<boolean>, default: false },
export let icon: IconName = "DropdownArrow";
export let tooltip: string | undefined = undefined;
export let disabled = false;
// Callbacks // Callbacks
action: { type: Function as PropType<() => void>, required: false }, export let action: (() => void) | undefined = undefined;
},
data() {
return {
open: false,
};
},
methods: {
onClick() {
this.open = true;
this.action?.(); let open = false;
},
}, function onClick() {
components: { open = true;
FloatingMenu, action?.();
IconButton, }
LayoutRow,
},
});
</script> </script>
<template> <LayoutRow class="popover-button">
<LayoutRow class="popover-button"> <IconButton classes={{ open }} {disabled} action={() => onClick()} icon={icon || "DropdownArrow"} size={16} {tooltip} data-floating-menu-spawner />
<IconButton :class="{ open }" :disabled="disabled" :action="() => onClick()" :icon="icon" :size="16" data-floating-menu-spawner :tooltip="tooltip" /> <FloatingMenu {open} on:open={({ detail }) => (open = detail)} type="Popover" direction="Bottom">
<FloatingMenu v-model:open="open" :type="'Popover'" :direction="'Bottom'"> <slot />
<slot></slot>
</FloatingMenu> </FloatingMenu>
</LayoutRow> </LayoutRow>
</template>
<style lang="scss"> <style lang="scss" global>
.popover-button { .popover-button {
position: relative; position: relative;
width: 16px; width: 16px;
height: 24px; height: 24px;
@@ -57,7 +38,7 @@ export default defineComponent({
bottom: 0; bottom: 0;
} }
.icon-button { .icon-button.icon-button {
width: 100%; width: 100%;
height: 100%; height: 100%;
padding: 0; padding: 0;
@@ -86,5 +67,5 @@ export default defineComponent({
border-radius: 0 2px 2px 0; border-radius: 0 2px 2px 0;
} }
} }
} }
</style> </style>
@@ -1,50 +1,43 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, type PropType } from "vue"; import { type IconName } from "@/utility-functions/icons";
import { type IconName } from "@/utility-functions/icons"; import IconLabel from "@/components/widgets/labels/IconLabel.svelte";
import TextLabel from "@/components/widgets/labels/TextLabel.svelte";
import IconLabel from "@/components/widgets/labels/IconLabel.vue"; export let label: string;
import TextLabel from "@/components/widgets/labels/TextLabel.vue"; export let icon: IconName | undefined = undefined;
export let emphasized: boolean = false;
export default defineComponent({ export let minWidth: number = 0;
props: { export let disabled: boolean = false;
label: { type: String as PropType<string>, required: true }, export let tooltip: string | undefined = undefined;
icon: { type: String as PropType<IconName | undefined>, required: false }, export let sharpRightCorners: boolean = false;
emphasized: { type: Boolean as PropType<boolean>, default: false },
minWidth: { type: Number as PropType<number>, default: 0 },
disabled: { type: Boolean as PropType<boolean>, default: false },
tooltip: { type: String as PropType<string | undefined>, required: false },
sharpRightCorners: { type: Boolean as PropType<boolean>, default: false },
// Callbacks // Callbacks
action: { type: Function as PropType<(e: MouseEvent) => void>, required: true }, // TODO: Replace this with an event binding (and on other components that do this)
}, export let action: (e: MouseEvent) => void;
components: {
IconLabel,
TextLabel,
},
});
</script> </script>
<template> <button
<button
class="text-button" class="text-button"
:class="{ emphasized, disabled, 'sharp-right-corners': sharpRightCorners }" class:emphasized
:data-emphasized="emphasized || undefined" class:disabled
:data-disabled="disabled || undefined" class:sharp-right-corners={sharpRightCorners}
style:min-width={minWidth > 0 ? `${minWidth}px` : undefined}
title={tooltip}
data-emphasized={emphasized || undefined}
data-disabled={disabled || undefined}
data-text-button data-text-button
:title="tooltip" tabindex={disabled ? -1 : 0}
:style="{ 'min-width': minWidth > 0 ? `${minWidth}px` : undefined }" on:click={action}
@click="(e: MouseEvent) => action(e)" >
:tabindex="disabled ? -1 : 0" {#if icon}
> <IconLabel {icon} />
<IconLabel v-if="icon" :icon="icon" /> {/if}
<TextLabel>{{ label }}</TextLabel> <TextLabel>{label}</TextLabel>
</button> </button>
</template>
<style lang="scss"> <style lang="scss" global>
.text-button { .text-button {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
@@ -95,5 +88,5 @@ export default defineComponent({
.text-label { .text-label {
overflow: hidden; overflow: hidden;
} }
} }
</style> </style>
@@ -1,61 +1,43 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, type PropType } from "vue"; import { isWidgetRow, isWidgetSection, type LayoutGroup, type WidgetSection as WidgetSectionFromJsMessages } from "@/wasm-communication/messages";
import { isWidgetRow, isWidgetSection, type LayoutGroup, type WidgetSection as WidgetSectionFromJsMessages } from "@/wasm-communication/messages"; import LayoutCol from "@/components/layout/LayoutCol.svelte";
import TextLabel from "@/components/widgets/labels/TextLabel.svelte";
import WidgetRow from "@/components/widgets/WidgetRow.svelte";
import { getContext } from "svelte";
import { type Editor } from "@/wasm-communication/editor";
import LayoutCol from "@/components/layout/LayoutCol.vue"; const editor = getContext<Editor>("editor");
import LayoutRow from "@/components/layout/LayoutRow.vue";
import TextLabel from "@/components/widgets/labels/TextLabel.vue";
import WidgetRow from "@/components/widgets/WidgetRow.vue";
const WidgetSection = defineComponent({ export let widgetData: WidgetSectionFromJsMessages;
name: "WidgetSection", export let layoutTarget: any; // TODO: Give type
inject: ["editor"],
props: {
widgetData: { type: Object as PropType<WidgetSectionFromJsMessages>, required: true },
layoutTarget: { required: true },
},
data: () => ({
isWidgetRow,
isWidgetSection,
expanded: true,
}),
methods: {
updateLayout(widgetId: bigint, value: unknown) {
this.editor.instance.updateLayout(this.layoutTarget, widgetId, value);
},
layoutGroupType(layoutGroup: LayoutGroup): unknown {
if (isWidgetRow(layoutGroup)) return WidgetRow;
if (isWidgetSection(layoutGroup)) return WidgetSection;
throw new Error("Layout row type does not exist"); let expanded = true;
},
},
components: {
LayoutCol,
LayoutRow,
TextLabel,
WidgetRow,
},
});
export default WidgetSection;
</script> </script>
<!-- TODO: Implement collapsable sections with properties system --> <!-- TODO: Implement collapsable sections with properties system -->
<template> <LayoutCol class="widget-section">
<LayoutCol class="widget-section"> <button class="header" class:expanded on:click|stopPropagation={() => (expanded = !expanded)} tabindex="0">
<button class="header" :class="{ expanded }" @click.stop="() => (expanded = !expanded)" tabindex="0"> <div class="expand-arrow" />
<div class="expand-arrow"></div> <TextLabel bold={true}>{widgetData.name}</TextLabel>
<TextLabel :bold="true">{{ widgetData.name }}</TextLabel>
</button> </button>
<LayoutCol class="body" v-if="expanded"> {#if expanded}
<component :is="layoutGroupType(layoutRow)" :widgetData="layoutRow" :layoutTarget="layoutTarget" v-for="(layoutRow, index) in widgetData.layout" :key="index"></component> <LayoutCol class="body">
{#each widgetData.layout as layoutGroup, index (index)}
{#if isWidgetRow(layoutGroup)}
<WidgetRow widgetData={layoutGroup} {layoutTarget} />
{:else if isWidgetSection(layoutGroup)}
<svelte:self widgetData={layoutGroup} {layoutTarget} />
{:else}
<span style="color: #d6536e">Error: The widget that belongs here has an invalid layout group type</span>
{/if}
{/each}
</LayoutCol> </LayoutCol>
</LayoutCol> {/if}
</template> </LayoutCol>
<style lang="scss"> <style lang="scss" global>
.widget-section { .widget-section {
flex: 0 0 auto; flex: 0 0 auto;
.header { .header {
@@ -161,5 +143,5 @@ export default WidgetSection;
} }
} }
} }
} }
</style> </style>
@@ -1,68 +1,50 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, type PropType } from "vue"; import { createEventDispatcher } from "svelte";
import { type IconName } from "@/utility-functions/icons"; import { type IconName } from "@/utility-functions/icons";
import LayoutRow from "@/components/layout/LayoutRow.vue"; import LayoutRow from "@/components/layout/LayoutRow.svelte";
import IconLabel from "@/components/widgets/labels/IconLabel.vue"; import IconLabel from "@/components/widgets/labels/IconLabel.svelte";
export default defineComponent({ // emits: ["update:checked"],
emits: ["update:checked"], const dispatch = createEventDispatcher<{ checked: boolean }>();
props: {
checked: { type: Boolean as PropType<boolean>, default: false },
disabled: { type: Boolean as PropType<boolean>, default: false },
icon: { type: String as PropType<IconName>, default: "Checkmark" },
tooltip: { type: String as PropType<string | undefined>, required: false },
},
data() {
return {
id: `${Math.random()}`.substring(2),
};
},
computed: {
displayIcon(): IconName {
if (!this.checked && this.icon === "Checkmark") return "Empty12px";
return this.icon; export let checked = false;
}, export let disabled = false;
}, export let icon: IconName = "Checkmark";
methods: { export let tooltip: string | undefined = undefined;
isChecked() {
return this.checked; let inputElement: HTMLInputElement;
}, let id = `${Math.random()}`.substring(2);
toggleCheckboxFromLabel(e: KeyboardEvent) {
$: displayIcon = (!checked && icon === "Checkmark" ? "Empty12px" : icon) as IconName;
export function isChecked() {
return checked;
}
export function input(): HTMLInputElement {
return inputElement;
}
function toggleCheckboxFromLabel(e: KeyboardEvent) {
const target = (e.target || undefined) as HTMLLabelElement | undefined; const target = (e.target || undefined) as HTMLLabelElement | undefined;
const previousSibling = (target?.previousSibling || undefined) as HTMLInputElement | undefined; const previousSibling = (target?.previousSibling || undefined) as HTMLInputElement | undefined;
previousSibling?.click(); previousSibling?.click();
}, }
},
components: {
IconLabel,
LayoutRow,
},
});
</script> </script>
<template> <LayoutRow class="checkbox-input">
<LayoutRow class="checkbox-input"> <input type="checkbox" id={`checkbox-input-${id}`} {checked} on:change={(e) => dispatch("checked", inputElement.checked)} {disabled} tabindex={disabled ? -1 : 0} bind:this={inputElement} />
<input <label class:disabled class:checked for={`checkbox-input-${id}`} on:keydown={(e) => e.key === "Enter" && toggleCheckboxFromLabel(e)} title={tooltip}>
type="checkbox"
:id="`checkbox-input-${id}`"
:checked="checked"
@change="(e) => $emit('update:checked', (e.target as HTMLInputElement).checked)"
:disabled="disabled"
:tabindex="disabled ? -1 : 0"
/>
<label :class="{ disabled, checked }" :for="`checkbox-input-${id}`" @keydown.enter="(e) => toggleCheckboxFromLabel(e)" :title="tooltip">
<LayoutRow class="checkbox-box"> <LayoutRow class="checkbox-box">
<IconLabel :icon="displayIcon" /> <IconLabel icon={displayIcon} />
</LayoutRow> </LayoutRow>
</label> </label>
</LayoutRow> </LayoutRow>
</template>
<style lang="scss"> <style lang="scss" global>
.checkbox-input { .checkbox-input {
flex: 0 0 auto; flex: 0 0 auto;
align-items: center; align-items: center;
@@ -123,5 +105,5 @@ export default defineComponent({
background: var(--color-8-uppergray); background: var(--color-8-uppergray);
} }
} }
} }
</style> </style>
@@ -1,74 +1,54 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, type PropType } from "vue"; import { createEventDispatcher } from "svelte";
import { Color } from "@/wasm-communication/messages"; import { Color } from "@/wasm-communication/messages";
import ColorPicker from "@/components/floating-menus/ColorPicker.vue"; import ColorPicker from "@/components/floating-menus/ColorPicker.svelte";
import LayoutRow from "@/components/layout/LayoutRow.vue"; import LayoutRow from "@/components/layout/LayoutRow.svelte";
import TextLabel from "@/components/widgets/labels/TextLabel.vue"; import TextLabel from "@/components/widgets/labels/TextLabel.svelte";
export default defineComponent({ // emits: ["update:value"],
emits: ["update:value", "update:open"], const dispatch = createEventDispatcher<{ value: Color }>();
props: {
value: { type: Color as PropType<Color>, required: true },
noTransparency: { type: Boolean as PropType<boolean>, default: false }, // TODO: Rename to allowTransparency, also implement allowNone
disabled: { type: Boolean as PropType<boolean>, default: false }, // TODO: Design and implement
tooltip: { type: String as PropType<string | undefined>, required: false },
sharpRightCorners: { type: Boolean as PropType<boolean>, default: false },
// Bound through `v-model` let open = false;
// TODO: See if this should be made to follow the pattern of DropdownInput.vue so this could be removed
open: { type: Boolean as PropType<boolean>, required: true }, export let value: Color;
}, export let noTransparency = false; // TODO: Rename to allowTransparency, also implement allowNone
data() { export let disabled = false; // TODO: Design and implement
return { export let tooltip: string | undefined = undefined;
isOpen: false, export let sharpRightCorners = false;
};
}, // TODO: Implement
watch: { $: chip = undefined;
// Called only when `open` is changed from outside this component (with v-model)
open(newOpen: boolean) {
this.isOpen = newOpen;
},
isOpen(newIsOpen: boolean) {
this.$emit("update:open", newIsOpen);
},
},
methods: {
colorPickerUpdated(color: Color) {
this.$emit("update:value", color);
},
},
computed: {
chip() {
return undefined;
},
},
components: {
ColorPicker,
LayoutRow,
TextLabel,
},
});
</script> </script>
<template> <LayoutRow class="color-input" classes={{ "sharp-right-corners": sharpRightCorners }} {tooltip}>
<LayoutRow class="color-input" :class="{ 'sharp-right-corners': sharpRightCorners }" :title="tooltip">
<button <button
:class="{ none: value.none, 'sharp-right-corners': sharpRightCorners }" class:none={value.none}
:style="{ '--chosen-color': value.toHexOptionalAlpha() }" class:sharp-right-corners={sharpRightCorners}
@click="() => $emit('update:open', true)" style:--chosen-color={value.toHexOptionalAlpha()}
on:click={() => (open = true)}
tabindex="0" tabindex="0"
data-floating-menu-spawner data-floating-menu-spawner
> >
<TextLabel :bold="true" class="chip" v-if="chip">{{ chip }}</TextLabel> {#if chip}
<TextLabel class="chip" bold={true}>{chip}</TextLabel>
{/if}
</button> </button>
<ColorPicker v-model:open="isOpen" :color="value" @update:color="(color: Color) => colorPickerUpdated(color)" :allowNone="true" /> <ColorPicker
</LayoutRow> {open}
</template> on:open={({ detail }) => (open = detail)}
color={value}
on:color={({ detail }) => {
value = detail;
dispatch("value", detail);
}}
allowNone={true}
/>
</LayoutRow>
<style lang="scss"> <style lang="scss" global>
.color-input { .color-input {
box-sizing: border-box; box-sizing: border-box;
position: relative; position: relative;
border: 1px solid var(--color-5-dullgray); border: 1px solid var(--color-5-dullgray);
@@ -129,5 +109,5 @@ export default defineComponent({
left: 50%; left: 50%;
bottom: 0; bottom: 0;
} }
} }
</style> </style>
@@ -1,113 +1,102 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, type PropType, toRaw } from "vue"; import { createEventDispatcher } from "svelte";
import { type MenuListEntry } from "@/wasm-communication/messages"; import { type MenuListEntry } from "@/wasm-communication/messages";
import MenuList from "@/components/floating-menus/MenuList.vue"; import MenuList from "@/components/floating-menus/MenuList.svelte";
import LayoutRow from "@/components/layout/LayoutRow.vue"; import LayoutRow from "@/components/layout/LayoutRow.svelte";
import IconLabel from "@/components/widgets/labels/IconLabel.vue"; import IconLabel from "@/components/widgets/labels/IconLabel.svelte";
import TextLabel from "@/components/widgets/labels/TextLabel.vue"; import TextLabel from "@/components/widgets/labels/TextLabel.svelte";
const DASH_ENTRY = { label: "-" }; const DASH_ENTRY = { label: "-" };
export default defineComponent({ // emits: ["update:selectedIndex"],
emits: ["update:selectedIndex"], const dispatch = createEventDispatcher<{ selectedIndex: number }>();
props: {
entries: { type: Array as PropType<MenuListEntry[][]>, required: true }, let menuList: MenuList;
selectedIndex: { type: Number as PropType<number>, required: false }, // When not provided, a dash is displayed let self: LayoutRow;
drawIcon: { type: Boolean as PropType<boolean>, default: false },
interactive: { type: Boolean as PropType<boolean>, default: true }, export let entries: MenuListEntry[][];
disabled: { type: Boolean as PropType<boolean>, default: false }, export let selectedIndex: number | undefined = undefined; // When not provided, a dash is displayed
tooltip: { type: String as PropType<string | undefined>, required: false }, export let drawIcon = false;
sharpRightCorners: { type: Boolean as PropType<boolean>, default: false }, export let interactive = true;
}, export let disabled = false;
data() { export let tooltip: string | undefined = undefined;
return { export let sharpRightCorners = false;
activeEntry: this.makeActiveEntry(this.selectedIndex),
activeEntrySkipWatcher: false, let activeEntry = makeActiveEntry();
open: false, let activeEntrySkipWatcher = false;
minWidth: 0, let open = false;
}; let minWidth = 0;
},
watch: { $: selectedIndex, watchSelectedIndex();
// Called only when `selectedIndex` is changed from outside this component (with v-model) $: watchActiveEntry(activeEntry);
selectedIndex() {
this.activeEntrySkipWatcher = true; // Called only when `selectedIndex` is changed from outside this component
this.activeEntry = this.makeActiveEntry(); function watchSelectedIndex() {
}, activeEntrySkipWatcher = true;
// Called when `activeEntry` is changed by the `v-model` on this component's MenuList component, or by the `selectedIndex()` watcher above (but we want to skip that case) activeEntry = makeActiveEntry();
activeEntry(newActiveEntry: MenuListEntry) {
if (this.activeEntrySkipWatcher) {
this.activeEntrySkipWatcher = false;
return;
} }
// `toRaw()` pulls it out of the Vue proxy // Called when the `activeEntry` two-way binding on this component's MenuList component is changed, or by the `selectedIndex()` watcher above (but we want to skip that case)
if (toRaw(newActiveEntry) === DASH_ENTRY) return; function watchActiveEntry(activeEntry: MenuListEntry) {
if (activeEntrySkipWatcher) {
activeEntrySkipWatcher = false;
} else if (activeEntry !== DASH_ENTRY) {
dispatch("selectedIndex", entries.flat().indexOf(activeEntry));
}
}
this.$emit("update:selectedIndex", this.entries.flat().indexOf(newActiveEntry)); function makeActiveEntry(): MenuListEntry {
}, const allEntries = entries.flat();
},
methods: {
makeActiveEntry(): MenuListEntry {
const entries = this.entries.flat();
if (this.selectedIndex !== undefined && this.selectedIndex >= 0 && this.selectedIndex < entries.length) { if (selectedIndex !== undefined && selectedIndex >= 0 && selectedIndex < allEntries.length) {
return entries[this.selectedIndex]; return allEntries[selectedIndex];
} }
return DASH_ENTRY; return DASH_ENTRY;
}, }
keydown(e: KeyboardEvent) {
(this.$refs.menuList as typeof MenuList | undefined)?.keydown(e, false); function unFocusDropdownBox(e: FocusEvent) {
},
unFocusDropdownBox(e: FocusEvent) {
const blurTarget = (e.target as HTMLDivElement | undefined)?.closest("[data-dropdown-input]"); const blurTarget = (e.target as HTMLDivElement | undefined)?.closest("[data-dropdown-input]");
const self: HTMLDivElement | undefined = this.$el; if (blurTarget !== self.div()) open = false;
if (blurTarget !== self) this.open = false; }
},
},
components: {
IconLabel,
LayoutRow,
MenuList,
TextLabel,
},
});
</script> </script>
<template> <LayoutRow class="dropdown-input" bind:this={self} data-dropdown-input>
<LayoutRow class="dropdown-input" data-dropdown-input>
<LayoutRow <LayoutRow
class="dropdown-box" class="dropdown-box"
:class="{ disabled, open, 'sharp-right-corners': sharpRightCorners }" classes={{ disabled, open, "sharp-right-corners": sharpRightCorners }}
:style="{ minWidth: `${minWidth}px` }" styles={{ minWidth: `${minWidth}px` }}
:title="tooltip" {tooltip}
@click="() => !disabled && (open = true)" on:click={() => !disabled && (open = true)}
@blur="(e: FocusEvent) => unFocusDropdownBox(e)" on:blur={unFocusDropdownBox}
@keydown="(e: KeyboardEvent) => keydown(e)" on:keydown={(e) => menuList.keydown(e, false)}
:tabindex="disabled ? -1 : 0" tabindex={disabled ? -1 : 0}
data-floating-menu-spawner data-floating-menu-spawner
> >
<IconLabel class="dropdown-icon" :icon="activeEntry.icon" v-if="activeEntry.icon" /> {#if activeEntry.icon}
<TextLabel class="dropdown-label">{{ activeEntry.label }}</TextLabel> <IconLabel class="dropdown-icon" icon={activeEntry.icon} />
<IconLabel class="dropdown-arrow" :icon="'DropdownArrow'" /> {/if}
<TextLabel class="dropdown-label">{activeEntry.label}</TextLabel>
<IconLabel class="dropdown-arrow" icon="DropdownArrow" />
</LayoutRow> </LayoutRow>
<MenuList <MenuList
v-model:activeEntry="activeEntry" on:naturalWidth={({ detail }) => (minWidth = detail)}
v-model:open="open" {activeEntry}
@naturalWidth="(newNaturalWidth: number) => (minWidth = newNaturalWidth)" on:activeEntry={({ detail }) => (activeEntry = detail)}
:entries="entries" {open}
:drawIcon="drawIcon" on:open={({ detail }) => (open = detail)}
:interactive="interactive" {entries}
:direction="'Bottom'" {drawIcon}
:scrollableY="true" {interactive}
ref="menuList" direction="Bottom"
scrollableY={true}
bind:this={menuList}
/> />
</LayoutRow> </LayoutRow>
</template>
<style lang="scss"> <style lang="scss" global>
.dropdown-input { .dropdown-input {
position: relative; position: relative;
.dropdown-box { .dropdown-box {
@@ -170,5 +159,5 @@ export default defineComponent({
.menu-list .floating-menu-container .floating-menu-content { .menu-list .floating-menu-container .floating-menu-content {
max-height: 400px; max-height: 400px;
} }
} }
</style> </style>
@@ -1,108 +1,112 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, type PropType } from "vue"; import { createEventDispatcher } from "svelte";
import { platformIsMac } from "@/utility-functions/platform"; import { platformIsMac } from "@/utility-functions/platform";
import LayoutRow from "@/components/layout/LayoutRow.vue"; import LayoutRow from "@/components/layout/LayoutRow.svelte";
// emits: ["update:value", "textFocused", "textChanged", "cancelTextChange"],
const dispatch = createEventDispatcher<{
value: string;
textFocused: undefined;
textChanged: undefined;
cancelTextChange: 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> = {};
export let value: string;
export let label: string | undefined = undefined;
export let spellcheck = false;
export let disabled = false;
export let textarea = false;
export let tooltip: string | undefined = undefined;
export let sharpRightCorners = false;
export let placeholder: string | undefined = undefined;
let inputOrTextarea: HTMLInputElement | HTMLTextAreaElement;
let id = `${Math.random()}`.substring(2);
let macKeyboardLayout = platformIsMac();
$: inputValue = value;
$: dispatch("value", inputValue);
export default defineComponent({
emits: ["update:value", "textFocused", "textChanged", "cancelTextChange"],
props: {
value: { type: String as PropType<string>, required: true },
label: { type: String as PropType<string>, required: false },
spellcheck: { type: Boolean as PropType<boolean>, default: false },
disabled: { type: Boolean as PropType<boolean>, default: false },
textarea: { type: Boolean as PropType<boolean>, default: false },
tooltip: { type: String as PropType<string | undefined>, required: false },
sharpRightCorners: { type: Boolean as PropType<boolean>, default: false },
placeholder: { type: String as PropType<string>, required: false },
},
data() {
return {
id: `${Math.random()}`.substring(2),
macKeyboardLayout: platformIsMac(),
};
},
methods: {
// Select (highlight) all the text. For technical reasons, it is necessary to pass the current text. // Select (highlight) all the text. For technical reasons, it is necessary to pass the current text.
selectAllText(currentText: string) { export function selectAllText(currentText: string) {
const inputElement = this.$refs.input as HTMLInputElement | HTMLTextAreaElement | undefined; // Setting the value directly is required to make the following `select()` call work
if (!inputElement) return; inputOrTextarea.value = currentText;
inputOrTextarea.select();
}
// Setting the value directly is required to make `inputElement.select()` work export function focus() {
inputElement.value = currentText; inputOrTextarea.focus();
}
inputElement.select(); export function unFocus() {
}, inputOrTextarea.blur();
unFocus() { }
(this.$refs.input as HTMLInputElement | HTMLTextAreaElement | undefined)?.blur();
}, export function getValue(): string {
getInputElementValue(): string | undefined { return inputOrTextarea.value;
return (this.$refs.input as HTMLInputElement | HTMLTextAreaElement | undefined)?.value; }
},
setInputElementValue(value: string) { export function setInputElementValue(value: string) {
const inputElement = this.$refs.input as HTMLInputElement | HTMLTextAreaElement | undefined; inputOrTextarea.value = value;
if (inputElement) inputElement.value = value; }
},
}, export function element(): HTMLInputElement | HTMLTextAreaElement {
computed: { return inputOrTextarea;
inputValue: { }
get() {
return this.value;
},
set(value: string) {
this.$emit("update:value", value);
},
},
},
components: { LayoutRow },
});
</script> </script>
<!-- This is a base component, extended by others like NumberInput and TextInput. It should not be used directly. --> <!-- This is a base component, extended by others like NumberInput and TextInput. It should not be used directly. -->
<template> <LayoutRow class={`field-input ${className}`} classes={{ disabled, "sharp-right-corners": sharpRightCorners, ...classes }} style={styleName} {styles} {tooltip}>
<LayoutRow class="field-input" :class="{ disabled, 'sharp-right-corners': sharpRightCorners }" :title="tooltip"> {#if !textarea}
<input <input
type="text" type="text"
v-if="!textarea" class:has-label={label}
:class="{ 'has-label': label }" id={`field-input-${id}`}
:id="`field-input-${id}`" {spellcheck}
ref="input" {disabled}
v-model="inputValue" {placeholder}
:spellcheck="spellcheck" bind:value={inputValue}
:disabled="disabled" bind:this={inputOrTextarea}
:placeholder="placeholder" on:focus={() => dispatch("textFocused")}
@focus="() => $emit('textFocused')" on:blur={() => dispatch("textChanged")}
@blur="() => $emit('textChanged')" on:change={() => dispatch("textChanged")}
@change="() => $emit('textChanged')" on:keydown={(e) => e.key === "Enter" && dispatch("textChanged")}
@keydown.enter="() => $emit('textChanged')" on:keydown={(e) => e.key === "Escape" && dispatch("cancelTextChange")}
@keydown.esc="() => $emit('cancelTextChange')"
data-input-element data-input-element
/> />
{:else}
<textarea <textarea
v-else class:has-label={label}
:class="{ 'has-label': label }" id={`field-input-${id}`}
:id="`field-input-${id}`"
class="scrollable-y" class="scrollable-y"
data-scrollable-y data-scrollable-y
ref="input" {spellcheck}
v-model="inputValue" {disabled}
:spellcheck="spellcheck" bind:value={inputValue}
:disabled="disabled" bind:this={inputOrTextarea}
@focus="() => $emit('textFocused')" on:focus={() => dispatch("textFocused")}
@blur="() => $emit('textChanged')" on:blur={() => dispatch("textChanged")}
@change="() => $emit('textChanged')" on:change={() => dispatch("textChanged")}
@keydown.ctrl.enter="() => !macKeyboardLayout && $emit('textChanged')" on:keydown={(e) => (macKeyboardLayout ? e.metaKey : e.ctrlKey) && e.key === "Enter" && dispatch("textChanged")}
@keydown.meta.enter="() => macKeyboardLayout && $emit('textChanged')" on:keydown={(e) => e.key === "Escape" && dispatch("cancelTextChange")}
@keydown.esc="() => $emit('cancelTextChange')" />
></textarea> {/if}
<label v-if="label" :for="`field-input-${id}`">{{ label }}</label> {#if label}
<slot></slot> <label for={`field-input-${id}`}>{label}</label>
</LayoutRow> {/if}
</template> <slot />
</LayoutRow>
<style lang="scss"> <style lang="scss" global>
.field-input { .field-input {
min-width: 80px; min-width: 80px;
height: auto; height: auto;
position: relative; position: relative;
@@ -156,7 +160,7 @@ export default defineComponent({
} }
input { input {
text-align: center; // text-align: center;
&:not(:focus).has-label { &:not(:focus).has-label {
text-align: right; text-align: right;
@@ -190,5 +194,5 @@ export default defineComponent({
color: var(--color-8-uppergray); color: var(--color-8-uppergray);
} }
} }
} }
</style> </style>
@@ -1,144 +1,140 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, nextTick, type PropType } from "vue"; import { createEventDispatcher, getContext, onMount, tick } from "svelte";
import { type MenuListEntry } from "@/wasm-communication/messages"; import { type MenuListEntry } from "@/wasm-communication/messages";
import MenuList from "@/components/floating-menus/MenuList.vue"; import MenuList from "@/components/floating-menus/MenuList.svelte";
import LayoutRow from "@/components/layout/LayoutRow.vue"; import LayoutRow from "@/components/layout/LayoutRow.svelte";
import IconLabel from "@/components/widgets/labels/IconLabel.vue"; import IconLabel from "@/components/widgets/labels/IconLabel.svelte";
import TextLabel from "@/components/widgets/labels/TextLabel.vue"; import TextLabel from "@/components/widgets/labels/TextLabel.svelte";
import { type FontsState } from "@/state-providers/fonts";
export default defineComponent({ const fonts = getContext<FontsState>("fonts");
inject: ["fonts"],
emits: ["update:fontFamily", "update:fontStyle", "changeFont"],
props: {
fontFamily: { type: String as PropType<string>, required: true },
fontStyle: { type: String as PropType<string>, required: true },
isStyle: { type: Boolean as PropType<boolean>, default: false },
disabled: { type: Boolean as PropType<boolean>, default: false },
tooltip: { type: String as PropType<string | undefined>, required: false },
sharpRightCorners: { type: Boolean as PropType<boolean>, default: false },
},
data() {
return {
open: false,
entries: [] as MenuListEntry[],
activeEntry: undefined as MenuListEntry | undefined,
entriesStart: 0,
minWidth: this.isStyle ? 0 : 300,
};
},
async mounted() {
this.entries = await this.getEntries();
this.activeEntry = this.getActiveEntry(this.entries);
},
methods: {
async setOpen(): Promise<void> {
this.open = true;
// Scroll to the active entry (the scroller div does not yet exist so we must wait for Vue to render) // emits: ["update:fontFamily", "update:fontStyle", "changeFont"],
await nextTick(); const dispatch = createEventDispatcher<{
fontFamily: string;
fontStyle: string;
changeFont: { fontFamily: string; fontStyle: string; fontFileUrl: string | undefined };
}>();
if (this.activeEntry) { let menuList: MenuList;
const index = this.entries.indexOf(this.activeEntry);
(this.$refs.menuList as typeof MenuList | undefined)?.scrollViewTo(0, Math.max(0, index * 20 - 190)); export let fontFamily: string;
export let fontStyle: string;
export let isStyle = false;
export let disabled = false;
export let tooltip: string | undefined = undefined;
export let sharpRightCorners = false;
let open = false;
let entries: MenuListEntry[] = [];
let activeEntry: MenuListEntry | undefined = undefined;
let minWidth = isStyle ? 0 : 300;
$: fontFamily, fontStyle, watchFont();
async function watchFont(): Promise<void> {
// We set this function's result to a local variable to avoid reading from `entries` which causes Svelte to trigger an update that results in an infinite loop
const newEntries = await getEntries();
entries = newEntries;
activeEntry = getActiveEntry(newEntries);
} }
},
toggleOpen(): void {
if (!this.disabled) {
this.open = !this.open;
if (this.open) this.setOpen(); async function setOpen(): Promise<void> {
open = true;
// Scroll to the active entry (the scroller div does not yet exist so we must wait for the component to render)
await tick();
if (activeEntry) {
const index = entries.indexOf(activeEntry);
menuList.scrollViewTo(Math.max(0, index * 20 - 190));
}
} }
},
keydown(e: KeyboardEvent): void {
(this.$refs.menuList as typeof MenuList | undefined)?.keydown(e, false);
},
async selectFont(newName: string): Promise<void> {
let fontFamily;
let fontStyle;
if (this.isStyle) { function toggleOpen(): void {
this.$emit("update:fontStyle", newName); if (!disabled) {
open = !open;
fontFamily = this.fontFamily; if (open) setOpen();
fontStyle = newName; }
}
async function selectFont(newName: string): Promise<void> {
let family;
let style;
if (isStyle) {
dispatch("fontStyle", newName);
family = fontFamily;
style = newName;
} else { } else {
this.$emit("update:fontFamily", newName); dispatch("fontFamily", newName);
fontFamily = newName; family = newName;
fontStyle = "Normal (400)"; style = "Normal (400)";
} }
const fontFileUrl = await this.fonts.getFontFileUrl(fontFamily, fontStyle); const fontFileUrl = await fonts.getFontFileUrl(family, style);
this.$emit("changeFont", { fontFamily, fontStyle, fontFileUrl }); dispatch("changeFont", { fontFamily: family, fontStyle: style, fontFileUrl });
}, }
async getEntries(): Promise<MenuListEntry[]> {
const x = this.isStyle ? this.fonts.getFontStyles(this.fontFamily) : this.fonts.fontNames(); async function getEntries(): Promise<MenuListEntry[]> {
const x = isStyle ? fonts.getFontStyles(fontFamily) : fonts.fontNames();
return (await x).map((entry: { name: string; url: URL | undefined }) => ({ return (await x).map((entry: { name: string; url: URL | undefined }) => ({
label: entry.name, label: entry.name,
value: entry.name, value: entry.name,
font: entry.url, font: entry.url,
action: () => this.selectFont(entry.name), action: () => selectFont(entry.name),
})); }));
}, }
getActiveEntry(entries: MenuListEntry[]): MenuListEntry {
const selectedChoice = this.isStyle ? this.fontStyle : this.fontFamily; function getActiveEntry(entries: MenuListEntry[]): MenuListEntry {
const selectedChoice = isStyle ? fontStyle : fontFamily;
return entries.find((entry) => entry.value === selectedChoice) as MenuListEntry; return entries.find((entry) => entry.value === selectedChoice) as MenuListEntry;
}, }
},
watch: { onMount(async () => {
async fontFamily() { entries = await getEntries();
this.entries = await this.getEntries();
this.activeEntry = this.getActiveEntry(this.entries); activeEntry = getActiveEntry(entries);
}, });
async fontStyle() {
this.entries = await this.getEntries();
this.activeEntry = this.getActiveEntry(this.entries);
},
},
components: {
IconLabel,
LayoutRow,
MenuList,
TextLabel,
},
});
</script> </script>
<!-- TODO: Combine this widget into the DropdownInput widget --> <!-- TODO: Combine this widget into the DropdownInput widget -->
<LayoutRow class="font-input">
<template>
<LayoutRow class="font-input">
<LayoutRow <LayoutRow
class="dropdown-box" class="dropdown-box"
:class="{ disabled, 'sharp-right-corners': sharpRightCorners }" classes={{ disabled, "sharp-right-corners": sharpRightCorners }}
:style="{ minWidth: `${minWidth}px` }" styles={{ minWidth: `${minWidth}px` }}
:title="tooltip" {tooltip}
:tabindex="disabled ? -1 : 0" tabindex={disabled ? -1 : 0}
@click="toggleOpen" on:click={toggleOpen}
@keydown="keydown" on:keydown={(e) => menuList.keydown(e, false)}
data-floating-menu-spawner data-floating-menu-spawner
> >
<TextLabel class="dropdown-label">{{ activeEntry?.value || "" }}</TextLabel> <TextLabel class="dropdown-label">{activeEntry?.value || ""}</TextLabel>
<IconLabel class="dropdown-arrow" :icon="'DropdownArrow'" /> <IconLabel class="dropdown-arrow" icon="DropdownArrow" />
</LayoutRow> </LayoutRow>
<MenuList <MenuList
v-model:activeEntry="activeEntry" on:naturalWidth={({ detail }) => isStyle && (minWidth = detail)}
v-model:open="open" {activeEntry}
:entries="[entries]" on:activeEntry={({ detail }) => (activeEntry = detail)}
:minWidth="isStyle ? 0 : minWidth" {open}
:virtualScrollingEntryHeight="isStyle ? 0 : 20" on:open={({ detail }) => (open = detail)}
:scrollableY="true" entries={[entries]}
@naturalWidth="(newNaturalWidth: number) => (isStyle && (minWidth = newNaturalWidth))" minWidth={isStyle ? 0 : minWidth}
ref="menuList" virtualScrollingEntryHeight={isStyle ? 0 : 20}
></MenuList> scrollableY={true}
</LayoutRow> bind:this={menuList}
</template> />
</LayoutRow>
<style lang="scss"> <style lang="scss" global>
.font-input { .font-input {
position: relative; position: relative;
.dropdown-box { .dropdown-box {
@@ -185,5 +181,5 @@ export default defineComponent({
max-height: 400px; max-height: 400px;
padding: 4px 0; padding: 4px 0;
} }
} }
</style> </style>
@@ -1,47 +1,38 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, type PropType } from "vue"; import { createEventDispatcher } from "svelte";
import { currentDraggingElement } from "@/io-managers/drag"; import { currentDraggingElement } from "@/io-managers/drag";
import type { LayerType, LayerTypeData } from "@/wasm-communication/messages"; import type { LayerType, LayerTypeData } from "@/wasm-communication/messages";
import { layerTypeData } from "@/wasm-communication/messages"; import { layerTypeData } from "@/wasm-communication/messages";
import LayoutRow from "@/components/layout/LayoutRow.vue"; import LayoutRow from "@/components/layout/LayoutRow.svelte";
import IconButton from "@/components/widgets/buttons/IconButton.vue"; import IconButton from "@/components/widgets/buttons/IconButton.svelte";
import IconLabel from "@/components/widgets/labels/IconLabel.vue"; import IconLabel from "@/components/widgets/labels/IconLabel.svelte";
import TextLabel from "@/components/widgets/labels/TextLabel.vue"; import TextLabel from "@/components/widgets/labels/TextLabel.svelte";
export default defineComponent({ // emits: ["update:value"],
emits: ["update:value"], const dispatch = createEventDispatcher<{ value: string | undefined }>();
props: {
value: { type: String as PropType<string | undefined>, required: false }, export let value: string | undefined = undefined;
layerName: { type: String as PropType<string | undefined>, required: false }, export let layerName: string | undefined = undefined;
layerType: { type: String as PropType<LayerType | undefined>, required: false }, export let layerType: LayerType | undefined = undefined;
disabled: { type: Boolean as PropType<boolean>, default: false }, export let disabled = false;
tooltip: { type: String as PropType<string | undefined>, required: false }, export let tooltip: string | undefined = undefined;
sharpRightCorners: { type: Boolean as PropType<boolean>, default: false }, export let sharpRightCorners = false;
},
data() { let hoveringDrop = false;
return {
hoveringDrop: false, $: droppable = hoveringDrop && Boolean(currentDraggingElement());
};
}, function dragOver(e: DragEvent): void {
computed: { hoveringDrop = true;
droppable() {
return this.hoveringDrop && currentDraggingElement();
},
},
methods: {
dragOver(e: DragEvent): void {
this.hoveringDrop = true;
e.preventDefault(); e.preventDefault();
}, }
dragLeave(): void {
this.hoveringDrop = false; function drop(e: DragEvent): void {
}, hoveringDrop = false;
drop(e: DragEvent): void {
this.hoveringDrop = false;
const element = currentDraggingElement(); const element = currentDraggingElement();
const layerPath = element?.getAttribute("data-layer") || undefined; const layerPath = element?.getAttribute("data-layer") || undefined;
@@ -49,49 +40,39 @@ export default defineComponent({
if (layerPath) { if (layerPath) {
e.preventDefault(); e.preventDefault();
this.$emit("update:value", layerPath); dispatch("value", layerPath);
} }
}, }
clearLayer(): void {
this.$emit("update:value", undefined); function getLayerTypeData(layerType: LayerType): LayerTypeData {
},
layerTypeData(layerType: LayerType): LayerTypeData {
return layerTypeData(layerType) || { name: "Error", icon: "Info" }; return layerTypeData(layerType) || { name: "Error", icon: "Info" };
}, }
},
components: {
IconButton,
IconLabel,
LayoutRow,
TextLabel,
},
});
</script> </script>
<template> <LayoutRow
<LayoutRow
class="layer-reference-input" class="layer-reference-input"
:class="{ disabled, droppable, 'sharp-right-corners': sharpRightCorners }" classes={{ disabled, droppable, "sharp-right-corners": sharpRightCorners }}
:title="tooltip" {tooltip}
@dragover="(e: DragEvent) => !disabled && dragOver(e)" on:dragover={(e) => !disabled && dragOver(e)}
@dragleave="() => !disabled && dragLeave()" on:dragleave={() => !disabled && (hoveringDrop = false)}
@drop="(e: DragEvent) => !disabled && drop(e)" on:drop={(e) => !disabled && drop(e)}
> >
<template v-if="value === undefined || droppable"> {#if value === undefined || droppable}
<LayoutRow class="drop-zone"></LayoutRow> <LayoutRow class="drop-zone" />
<TextLabel :italic="true">{{ droppable ? "Drop" : "Drag" }} Layer Here</TextLabel> <TextLabel italic={true}>{droppable ? "Drop" : "Drag"} Layer Here</TextLabel>
</template> {:else}
<template v-if="value !== undefined && !droppable"> {#if layerName !== undefined && layerType}
<IconLabel v-if="layerName !== undefined && layerType" :icon="layerTypeData(layerType).icon" class="layer-icon" /> <IconLabel icon={getLayerTypeData(layerType).icon} class="layer-icon" />
<TextLabel v-if="layerName !== undefined && layerType" :italic="layerName === ''" class="layer-name">{{ layerName || layerTypeData(layerType).name }}</TextLabel> <TextLabel italic={layerName === ""} class="layer-name">{layerName || getLayerTypeData(layerType).name}</TextLabel>
<TextLabel :bold="true" :italic="true" v-else class="missing">Layer Missing</TextLabel> {:else}
</template> <TextLabel bold={true} italic={true} class="missing">Layer Missing</TextLabel>
<IconButton v-if="value !== undefined && !droppable" :icon="'CloseX'" :size="16" :disabled="disabled" :action="() => clearLayer()" /> {/if}
</LayoutRow> <IconButton icon="CloseX" size={16} {disabled} action={() => dispatch("value", undefined)} />
</template> {/if}
</LayoutRow>
<style lang="scss"> <style lang="scss" global>
.layer-reference-input { .layer-reference-input {
position: relative; position: relative;
flex: 1 0 auto; flex: 1 0 auto;
height: 24px; height: 24px;
@@ -128,7 +109,7 @@ export default defineComponent({
text-align: center; text-align: center;
&.missing { &.missing {
// TODO: Define this as a permanent color palette choice // TODO: Define this as a permanent color palette choice (search the project for all uses of this hex code)
color: #d6536e; color: #d6536e;
} }
@@ -157,5 +138,5 @@ export default defineComponent({
fill: var(--color-8-uppergray); fill: var(--color-8-uppergray);
} }
} }
} }
</style> </style>
@@ -1,30 +1,49 @@
<script lang="ts"> <script lang="ts">
import { defineComponent } from "vue"; import { getContext, onMount } from "svelte";
import { platformIsMac } from "@/utility-functions/platform"; import { platformIsMac } from "@/utility-functions/platform";
import { type KeyRaw, type LayoutKeysGroup, type MenuBarEntry, type MenuListEntry, UpdateMenuBarLayout } from "@/wasm-communication/messages"; import { type KeyRaw, type LayoutKeysGroup, type MenuBarEntry, type MenuListEntry, UpdateMenuBarLayout } from "@/wasm-communication/messages";
import MenuList from "@/components/floating-menus/MenuList.vue"; import MenuList from "@/components/floating-menus/MenuList.svelte";
import IconLabel from "@/components/widgets/labels/IconLabel.vue"; import IconLabel from "@/components/widgets/labels/IconLabel.svelte";
import TextLabel from "@/components/widgets/labels/TextLabel.vue"; import TextLabel from "@/components/widgets/labels/TextLabel.svelte";
import { type Editor } from "@/wasm-communication/editor";
// eslint-disable-next-line @typescript-eslint/no-unused-vars // TODO: Apparently, Safari does not support the Keyboard.lock() API but does relax its authority over certain keyboard shortcuts in fullscreen mode, which we should take advantage of
type MenuListInstance = InstanceType<typeof MenuList>; const accelKey = platformIsMac() ? "Command" : "Control";
const LOCK_REQUIRING_SHORTCUTS: KeyRaw[][] = [
// TODO: Apparently, Safari does not support the Keyboard.lock() API but does relax its authority over certain keyboard shortcuts in fullscreen mode, which we should take advantage of
const accelKey = platformIsMac() ? "Command" : "Control";
const LOCK_REQUIRING_SHORTCUTS: KeyRaw[][] = [
[accelKey, "KeyW"], [accelKey, "KeyW"],
[accelKey, "KeyN"], [accelKey, "KeyN"],
[accelKey, "Shift", "KeyN"], [accelKey, "Shift", "KeyN"],
[accelKey, "KeyT"], [accelKey, "KeyT"],
[accelKey, "Shift", "KeyT"], [accelKey, "Shift", "KeyT"],
]; ];
export default defineComponent({ const editor = getContext<Editor>("editor");
inject: ["editor"],
mounted() { let entries: MenuListEntry[] = [];
this.editor.subscriptions.subscribeJsMessage(UpdateMenuBarLayout, (updateMenuBarLayout) => {
function clickEntry(menuListEntry: MenuListEntry, e: MouseEvent) {
// If there's no menu to open, trigger the action but don't try to open its non-existant children
if (!menuListEntry.children || menuListEntry.children.length === 0) {
if (menuListEntry.action && !menuListEntry.disabled) menuListEntry.action();
return;
}
// Focus the target so that keyboard inputs are sent to the dropdown
(e.target as HTMLElement | undefined)?.focus();
if (menuListEntry.ref) {
menuListEntry.ref.open = true;
entries = entries;
} else {
throw new Error("The menu bar floating menu has no associated ref");
}
}
onMount(() => {
editor.subscriptions.subscribeJsMessage(UpdateMenuBarLayout, (updateMenuBarLayout) => {
const arraysEqual = (a: KeyRaw[], b: KeyRaw[]): boolean => a.length === b.length && a.every((aValue, i) => aValue === b[i]); const arraysEqual = (a: KeyRaw[], b: KeyRaw[]): boolean => a.length === b.length && a.every((aValue, i) => aValue === b[i]);
const shortcutRequiresLock = (shortcut: LayoutKeysGroup): boolean => { const shortcutRequiresLock = (shortcut: LayoutKeysGroup): boolean => {
const shortcutKeys = shortcut.map((keyWithLabel) => keyWithLabel.key); const shortcutKeys = shortcut.map((keyWithLabel) => keyWithLabel.key);
@@ -38,7 +57,7 @@ export default defineComponent({
...entry, ...entry,
// Shared names with fields that need to be converted from the type used in `MenuBarEntry` to that of `MenuListEntry` // Shared names with fields that need to be converted from the type used in `MenuBarEntry` to that of `MenuListEntry`
action: (): void => this.editor.instance.updateLayout(updateMenuBarLayout.layoutTarget, entry.action.widgetId, undefined), action: (): void => editor.instance.updateLayout(updateMenuBarLayout.layoutTarget, entry.action.widgetId, undefined),
children: entry.children ? entry.children.map((entries) => entries.map((entry) => menuBarEntryToMenuListEntry(entry))) : undefined, children: entry.children ? entry.children.map((entries) => entries.map((entry) => menuBarEntryToMenuListEntry(entry))) : undefined,
// New fields in `MenuListEntry` // New fields in `MenuListEntry`
@@ -49,74 +68,49 @@ export default defineComponent({
ref: undefined, ref: undefined,
}); });
this.entries = updateMenuBarLayout.layout.map(menuBarEntryToMenuListEntry); entries = updateMenuBarLayout.layout.map(menuBarEntryToMenuListEntry);
});
}); });
},
methods: {
clickEntry(menuListEntry: MenuListEntry, e: MouseEvent) {
// If there's no menu to open, trigger the action but don't try to open its non-existant children
if (!menuListEntry.children || menuListEntry.children.length === 0) {
if (menuListEntry.action && !menuListEntry.disabled) menuListEntry.action();
return;
}
// Focus the target so that keyboard inputs are sent to the dropdown
(e.target as HTMLElement | undefined)?.focus();
if (menuListEntry.ref) menuListEntry.ref.isOpen = true;
else throw new Error("The menu bar floating menu has no associated ref");
},
unFocusEntry(menuListEntry: MenuListEntry, e: FocusEvent) {
const blurTarget = (e.target as HTMLElement | undefined)?.closest("[data-menu-bar-input]");
const self: HTMLDivElement | undefined = this.$el;
if (blurTarget !== self && menuListEntry.ref) menuListEntry.ref.isOpen = false;
},
},
data() {
return {
entries: [] as MenuListEntry[],
open: false,
};
},
components: {
IconLabel,
MenuList,
TextLabel,
},
});
</script> </script>
<template> <div class="menu-bar-input" data-menu-bar-input>
<div class="menu-bar-input" data-menu-bar-input> {#each entries as entry, index (index)}
<div class="entry-container" v-for="(entry, index) in entries" :key="index"> <div class="entry-container">
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<div <div
@click="(e: MouseEvent) => clickEntry(entry, e)" on:click={(e) => clickEntry(entry, e)}
@blur="(e: FocusEvent) => unFocusEntry(entry, e)" on:keydown={(e) => entry.ref?.keydown(e, false)}
@keydown="(e: KeyboardEvent) => entry.ref?.keydown(e, false)"
class="entry" class="entry"
:class="{ open: entry.ref?.isOpen }" class:open={entry.ref?.open}
tabindex="0" tabindex="0"
:data-floating-menu-spawner="entry.children && entry.children.length > 0 ? '' : 'no-hover-transfer'" data-floating-menu-spawner={entry.children && entry.children.length > 0 ? "" : "no-hover-transfer"}
> >
<IconLabel v-if="entry.icon" :icon="entry.icon" /> {#if entry.icon}
<TextLabel v-if="entry.label">{{ entry.label }}</TextLabel> <IconLabel icon={entry.icon} />
{/if}
{#if entry.label}
<TextLabel>{entry.label}</TextLabel>
{/if}
</div> </div>
{#if entry.children && entry.children.length > 0}
<MenuList <MenuList
v-if="entry.children && entry.children.length > 0" on:open={({ detail }) => {
:open="entry.ref?.open || false" if (entry.ref) entry.ref.open = detail;
:entries="entry.children || []" }}
:direction="'Bottom'" open={entry.ref?.open || false}
:minWidth="240" entries={entry.children || []}
:drawIcon="true" direction="Bottom"
:ref="(ref: MenuListInstance): void => (ref && (entry.ref = ref), undefined)" minWidth={240}
drawIcon={true}
bind:this={entry.ref}
/> />
{/if}
</div> </div>
</div> {/each}
</template> </div>
<style lang="scss"> <style lang="scss" global>
.menu-bar-input { .menu-bar-input {
display: flex; display: flex;
.entry-container { .entry-container {
@@ -150,5 +144,5 @@ export default defineComponent({
} }
} }
} }
} }
</style> </style>
@@ -1,195 +1,219 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, type PropType } from "vue"; import { createEventDispatcher } from "svelte";
import { type NumberInputMode, type NumberInputIncrementBehavior } from "@/wasm-communication/messages"; import { type NumberInputMode, type NumberInputIncrementBehavior } from "@/wasm-communication/messages";
import FieldInput from "@/components/widgets/inputs/FieldInput.vue"; import FieldInput from "@/components/widgets/inputs/FieldInput.svelte";
// emits: ["update:value"],
const dispatch = createEventDispatcher<{ value: number | undefined }>();
export default defineComponent({
emits: ["update:value"],
props: {
// Label // Label
label: { type: String as PropType<string>, required: false }, export let label: string | undefined = undefined;
tooltip: { type: String as PropType<string | undefined>, required: false }, export let tooltip: string | undefined = undefined;
// Disabled // Disabled
disabled: { type: Boolean as PropType<boolean>, default: false }, export let disabled = false;
// Value // Value
value: { type: Number as PropType<number>, required: false }, // When not provided, a dash is displayed export let value: number | undefined = undefined; // When not provided, a dash is displayed
min: { type: Number as PropType<number>, required: false }, export let min: number | undefined = undefined;
max: { type: Number as PropType<number>, required: false }, export let max: number | undefined = undefined;
isInteger: { type: Boolean as PropType<boolean>, default: false }, export let isInteger = false;
// Number presentation // Number presentation
displayDecimalPlaces: { type: Number as PropType<number>, default: 3 }, export let displayDecimalPlaces = 3;
unit: { type: String as PropType<string>, default: "" }, export let unit = "";
unitIsHiddenWhenEditing: { type: Boolean as PropType<boolean>, default: true }, export let unitIsHiddenWhenEditing = true;
// Mode behavior // Mode behavior
// "Increment" shows arrows and allows dragging left/right to change the value. // "Increment" shows arrows and allows dragging left/right to change the value.
// "Range" shows a range slider between some minimum and maximum value. // "Range" shows a range slider between some minimum and maximum value.
mode: { type: String as PropType<NumberInputMode>, default: "Increment" }, export let mode: NumberInputMode = "Increment";
// When `mode` is "Increment", `step` is the multiplier or addend used with `incrementBehavior`. // When `mode` is "Increment", `step` is the multiplier or addend used with `incrementBehavior`.
// When `mode` is "Range", `step` is the range slider's snapping increment if `isInteger` is `true`. // When `mode` is "Range", `step` is the range slider's snapping increment if `isInteger` is `true`.
step: { type: Number as PropType<number>, default: 1 }, export let step = 1;
// `incrementBehavior` is only applicable with a `mode` of "Increment". // `incrementBehavior` is only applicable with a `mode` of "Increment".
// "Add"/"Multiply": The value is added or multiplied by `step`. // "Add"/"Multiply": The value is added or multiplied by `step`.
// "None": the increment arrows are not shown. // "None": the increment arrows are not shown.
// "Callback": the functions `incrementCallbackIncrease` and `incrementCallbackDecrease` call custom behavior. // "Callback": the functions `incrementCallbackIncrease` and `incrementCallbackDecrease` call custom behavior.
incrementBehavior: { type: String as PropType<NumberInputIncrementBehavior>, default: "Add" }, export let incrementBehavior: NumberInputIncrementBehavior = "Add";
// `rangeMin` and `rangeMax` are only applicable with a `mode` of "Range". // `rangeMin` and `rangeMax` are only applicable with a `mode` of "Range".
// They set the lower and upper values of the slider to drag between. // They set the lower and upper values of the slider to drag between.
rangeMin: { type: Number as PropType<number>, default: 0 }, export let rangeMin = 0;
rangeMax: { type: Number as PropType<number>, default: 1 }, export let rangeMax = 1;
// Styling // Styling
minWidth: { type: Number as PropType<number>, default: 0 }, export let minWidth = 0;
sharpRightCorners: { type: Boolean as PropType<boolean>, default: false }, export let sharpRightCorners = false;
// Callbacks // Callbacks
incrementCallbackIncrease: { type: Function as PropType<() => void>, required: false }, export let incrementCallbackIncrease: (() => void) | undefined = undefined;
incrementCallbackDecrease: { type: Function as PropType<() => void>, required: false }, export let incrementCallbackDecrease: (() => void) | undefined = undefined;
},
data() { let self: FieldInput;
return { let text = displayText(value, displayDecimalPlaces, unit);
text: this.displayText(this.value), let editing = false;
editing: false,
// Stays in sync with a binding to the actual input range slider element. // Stays in sync with a binding to the actual input range slider element.
rangeSliderValue: this.value !== undefined ? this.value : 0, let rangeSliderValue = value !== undefined ? value : 0;
// Value used to render the position of the fake slider when applicable, and length of the progress colored region to the slider's left. // Value used to render the position of the fake slider when applicable, and length of the progress colored region to the slider's left.
// This is the same as `rangeSliderValue` except in the "mousedown" state, when it has the previous location before the user's mousedown. // This is the same as `rangeSliderValue` except in the "mousedown" state, when it has the previous location before the user's mousedown.
rangeSliderValueAsRendered: this.value !== undefined ? this.value : 0, let rangeSliderValueAsRendered = value !== undefined ? value : 0;
// "default": no interaction is happening. // "default": no interaction is happening.
// "mousedown": the user has pressed down the mouse and might next decide to either drag left/right or release without dragging. // "mousedown": the user has pressed down the mouse and might next decide to either drag left/right or release without dragging.
// "dragging": the user is dragging the slider left/right. // "dragging": the user is dragging the slider left/right.
rangeSliderClickDragState: "default" as "default" | "mousedown" | "dragging", let rangeSliderClickDragState: "default" | "mousedown" | "dragging" = "default";
};
}, $: sliderStepValue = isInteger ? (step === undefined ? 1 : step) : "any";
computed: { $: watchValue(value);
sliderStepValue() {
const step = this.step === undefined ? 1 : this.step; // Called only when `value` is changed from outside this component
return this.isInteger ? step : "any"; function watchValue(value: number | undefined) {
}, // Don't update if the slider is currently being dragged (we don't want the backend fighting with the user's drag)
}, if (rangeSliderClickDragState === "dragging") return;
methods: {
sliderInput() { // Draw a dash if the value is undefined
if (value === undefined) {
text = "-";
return;
}
// Update the range slider with the new value
rangeSliderValue = value;
rangeSliderValueAsRendered = value;
// The simple `clamp()` function can't be used here since `undefined` values need to be boundless
let sanitized = value;
if (typeof min === "number") sanitized = Math.max(sanitized, min);
if (typeof max === "number") sanitized = Math.min(sanitized, max);
text = displayText(sanitized, displayDecimalPlaces, unit);
}
function onSliderInput() {
// Keep only 4 digits after the decimal point // Keep only 4 digits after the decimal point
const ROUNDING_EXPONENT = 4; const ROUNDING_EXPONENT = 4;
const ROUNDING_MAGNITUDE = 10 ** ROUNDING_EXPONENT; const ROUNDING_MAGNITUDE = 10 ** ROUNDING_EXPONENT;
const roundedValue = Math.round(this.rangeSliderValue * ROUNDING_MAGNITUDE) / ROUNDING_MAGNITUDE; const roundedValue = Math.round(rangeSliderValue * ROUNDING_MAGNITUDE) / ROUNDING_MAGNITUDE;
// Exit if this is an extraneous event invocation that occurred after mouseup, which happens in Firefox // Exit if this is an extraneous event invocation that occurred after mouseup, which happens in Firefox
if (this.value !== undefined && Math.abs(this.value - roundedValue) < 1 / ROUNDING_MAGNITUDE) { if (value !== undefined && Math.abs(value - roundedValue) < 1 / ROUNDING_MAGNITUDE) {
return; return;
} }
// The first event upon mousedown means we transition to a "mousedown" state // The first event upon mousedown means we transition to a "mousedown" state
if (this.rangeSliderClickDragState === "default") { if (rangeSliderClickDragState === "default") {
this.rangeSliderClickDragState = "mousedown"; rangeSliderClickDragState = "mousedown";
// Exit early because we don't want to use the value set by where on the track the user pressed // Exit early because we don't want to use the value set by where on the track the user pressed
return; return;
} }
// The second event upon mousedown that occurs by moving left or right means the user has committed to dragging the slider // The second event upon mousedown that occurs by moving left or right means the user has committed to dragging the slider
if (this.rangeSliderClickDragState === "mousedown") { if (rangeSliderClickDragState === "mousedown") {
this.rangeSliderClickDragState = "dragging"; rangeSliderClickDragState = "dragging";
} }
// If we're in a dragging state, we want to use the new slider value // If we're in a dragging state, we want to use the new slider value
this.rangeSliderValueAsRendered = roundedValue; rangeSliderValueAsRendered = roundedValue;
this.updateValue(roundedValue); updateValue(roundedValue, min, max, displayDecimalPlaces, unit);
}, }
sliderPointerDown() {
function onSliderPointerDown() {
// We want to render the fake slider thumb at the old position, which is still the number held by `value` // We want to render the fake slider thumb at the old position, which is still the number held by `value`
this.rangeSliderValueAsRendered = this.value || 0; rangeSliderValueAsRendered = value || 0;
// Because an `input` event is fired right before or after this (depending on browser), that first // Because an `input` event is fired right before or after this (depending on browser), that first
// invocation will transition the state machine to `mousedown`. That's why we don't do it here. // invocation will transition the state machine to `mousedown`. That's why we don't do it here.
}, }
sliderPointerUp() {
function onSliderPointerUp() {
// User clicked but didn't drag, so we focus the text input element // User clicked but didn't drag, so we focus the text input element
if (this.rangeSliderClickDragState === "mousedown") { if (rangeSliderClickDragState === "mousedown") {
const fieldInput = this.$refs.fieldInput as typeof FieldInput | undefined; const inputElement = self.element().querySelector("[data-input-element]") as HTMLInputElement | undefined;
const inputElement = fieldInput?.$el.querySelector("[data-input-element]") as HTMLInputElement | undefined;
if (!inputElement) return; if (!inputElement) return;
// Set the slider position back to the original position to undo the user moving it // Set the slider position back to the original position to undo the user moving it
this.rangeSliderValue = this.rangeSliderValueAsRendered; rangeSliderValue = rangeSliderValueAsRendered;
// Begin editing the number text field // Begin editing the number text field
inputElement.focus(); inputElement.focus();
} }
// Releasing the mouse means we can reset the state machine // Releasing the mouse means we can reset the state machine
this.rangeSliderClickDragState = "default"; rangeSliderClickDragState = "default";
}, }
onTextFocused() {
if (this.value === undefined) this.text = "";
else if (this.unitIsHiddenWhenEditing) this.text = `${this.value}`;
else this.text = `${this.value}${unPluralize(this.unit, this.value)}`;
this.editing = true; function onTextFocused() {
if (value === undefined) text = "";
else if (unitIsHiddenWhenEditing) text = `${value}`;
else text = `${value}${unPluralize(unit, value)}`;
editing = true;
self.selectAllText(text);
}
(this.$refs.fieldInput as typeof FieldInput | undefined)?.selectAllText(this.text);
},
// Called only when `value` is changed from the <input> element via user input and committed, either with the // Called only when `value` is changed from the <input> element via user input and committed, either with the
// enter key (via the `change` event) or when the <input> element is unfocused (with the `blur` event binding) // enter key (via the `change` event) or when the <input> element is unfocused (with the `blur` event binding)
onTextChanged() { function onTextChanged() {
// The `unFocus()` call at the bottom of this function and in `onCancelTextChange()` causes this function to be run again, so this check skips a second run // The `unFocus()` call at the bottom of this function and in `onCancelTextChange()` causes this function to be run again, so this check skips a second run
if (!this.editing) return; if (!editing) return;
const parsed = parseFloat(this.text); const parsed = parseFloat(text);
const newValue = Number.isNaN(parsed) ? undefined : parsed; const newValue = Number.isNaN(parsed) ? undefined : parsed;
this.updateValue(newValue); updateValue(newValue, min, max, displayDecimalPlaces, unit);
this.editing = false; editing = false;
(this.$refs.fieldInput as typeof FieldInput | undefined)?.unFocus(); self.unFocus();
}, }
onCancelTextChange() {
this.updateValue(undefined);
this.editing = false; function onCancelTextChange() {
updateValue(undefined, min, max, displayDecimalPlaces, unit);
(this.$refs.fieldInput as typeof FieldInput | undefined)?.unFocus(); editing = false;
},
onIncrement(direction: "Decrease" | "Increase") {
if (this.value === undefined) return;
const actions = { self.unFocus();
Add: (): void => { }
const directionAddend = direction === "Increase" ? this.step : -this.step;
this.updateValue(this.value !== undefined ? this.value + directionAddend : undefined); function onIncrement(direction: "Decrease" | "Increase") {
if (value === undefined) return;
const actions: Record<NumberInputIncrementBehavior, () => void> = {
Add: () => {
const directionAddend = direction === "Increase" ? step : -step;
updateValue(value !== undefined ? value + directionAddend : undefined, min, max, displayDecimalPlaces, unit);
}, },
Multiply: (): void => { Multiply: () => {
const directionMultiplier = direction === "Increase" ? this.step : 1 / this.step; const directionMultiplier = direction === "Increase" ? step : 1 / step;
this.updateValue(this.value !== undefined ? this.value * directionMultiplier : undefined); updateValue(value !== undefined ? value * directionMultiplier : undefined, min, max, displayDecimalPlaces, unit);
}, },
Callback: (): void => { Callback: () => {
if (direction === "Increase") this.incrementCallbackIncrease?.(); if (direction === "Increase") incrementCallbackIncrease?.();
if (direction === "Decrease") this.incrementCallbackDecrease?.(); if (direction === "Decrease") incrementCallbackDecrease?.();
}, },
None: (): void => undefined, None: () => {},
}; };
const action = actions[this.incrementBehavior]; const action = actions[incrementBehavior];
action(); action();
}, }
updateValue(newValue: number | undefined) {
const nowValid = this.value !== undefined && this.isInteger ? Math.round(this.value) : this.value; function updateValue(newValue: number | undefined, min: number | undefined, max: number | undefined, displayDecimalPlaces: number, unit: string) {
// Check if the new value is valid, otherwise we use the old value (rounded if it's an integer)
const nowValid = value !== undefined && isInteger ? Math.round(value) : value;
let cleaned = newValue !== undefined ? newValue : nowValid; let cleaned = newValue !== undefined ? newValue : nowValid;
if (typeof this.min === "number" && !Number.isNaN(this.min) && cleaned !== undefined) cleaned = Math.max(cleaned, this.min); if (typeof min === "number" && !Number.isNaN(min) && cleaned !== undefined) cleaned = Math.max(cleaned, min);
if (typeof this.max === "number" && !Number.isNaN(this.max) && cleaned !== undefined) cleaned = Math.min(cleaned, this.max); if (typeof max === "number" && !Number.isNaN(max) && cleaned !== undefined) cleaned = Math.min(cleaned, max);
// Required as the call to update:value can, not change the value text = displayText(cleaned, displayDecimalPlaces, unit);
this.text = this.displayText(this.value);
if (newValue !== undefined) this.$emit("update:value", cleaned); if (newValue !== undefined) dispatch("value", cleaned);
}, }
displayText(value: number | undefined): string {
function displayText(value: number | undefined, displayDecimalPlaces: number, unit: string): string {
if (value === undefined) return "-"; if (value === undefined) return "-";
// Find the amount of digits on the left side of the decimal // Find the amount of digits on the left side of the decimal
@@ -198,83 +222,68 @@ export default defineComponent({
// 0.23 == 0 (Reason for the slightly more complicated code) // 0.23 == 0 (Reason for the slightly more complicated code)
const absValueInt = Math.floor(Math.abs(value)); const absValueInt = Math.floor(Math.abs(value));
const leftSideDigits = absValueInt === 0 ? 0 : absValueInt.toString().length; const leftSideDigits = absValueInt === 0 ? 0 : absValueInt.toString().length;
const roundingPower = 10 ** Math.max(this.displayDecimalPlaces - leftSideDigits, 0); const roundingPower = 10 ** Math.max(displayDecimalPlaces - leftSideDigits, 0);
const displayValue = Math.round(value * roundingPower) / roundingPower; const displayValue = Math.round(value * roundingPower) / roundingPower;
return `${displayValue}${unPluralize(this.unit, value)}`; return `${displayValue}${unPluralize(unit, value)}`;
},
},
watch: {
// Called only when `value` is changed from outside this component (with v-model)
value(newValue: number | undefined) {
// Draw a dash if the value is undefined
if (newValue === undefined) {
this.text = "-";
return;
} }
// Update the range slider with the new value function unPluralize(unit: string, value: number): string {
this.rangeSliderValue = newValue;
this.rangeSliderValueAsRendered = newValue;
// The simple `clamp()` function can't be used here since `undefined` values need to be boundless
let sanitized = newValue;
if (typeof this.min === "number") sanitized = Math.max(sanitized, this.min);
if (typeof this.max === "number") sanitized = Math.min(sanitized, this.max);
this.text = this.displayText(sanitized);
},
},
components: { FieldInput },
});
function unPluralize(unit: string, value: number): string {
if (value === 1 && unit.endsWith("s")) return unit.slice(0, -1); if (value === 1 && unit.endsWith("s")) return unit.slice(0, -1);
return unit; return unit;
} }
</script> </script>
<template> <FieldInput
<FieldInput class={`number-input ${mode.toLocaleLowerCase()}`}
class="number-input" value={text}
:class="mode.toLocaleLowerCase()" on:value={({ detail }) => (text = detail)}
v-model:value="text" on:textFocused={onTextFocused}
:label="label" on:textChanged={onTextChanged}
:spellcheck="false" on:cancelTextChange={onCancelTextChange}
:disabled="disabled" {label}
:style="{ 'min-width': minWidth > 0 ? `${minWidth}px` : undefined, '--progress-factor': (rangeSliderValueAsRendered - rangeMin) / (rangeMax - rangeMin) }" {disabled}
:tooltip="tooltip" {tooltip}
:sharpRightCorners="sharpRightCorners" {sharpRightCorners}
@textFocused="() => onTextFocused()" spellcheck={false}
@textChanged="() => onTextChanged()" styles={{ "min-width": minWidth > 0 ? `${minWidth}px` : undefined, "--progress-factor": (rangeSliderValueAsRendered - rangeMin) / (rangeMax - rangeMin) }}
@cancelTextChange="() => onCancelTextChange()" bind:this={self}
ref="fieldInput" >
> {#if value !== undefined && mode === "Increment" && incrementBehavior !== "None"}
<button v-if="value !== undefined && mode === 'Increment' && incrementBehavior !== 'None'" class="arrow left" @click="() => onIncrement('Decrease')" tabindex="-1"></button> <button class="arrow left" on:click={() => onIncrement("Decrease")} tabindex="-1" />
<button v-if="value !== undefined && mode === 'Increment' && incrementBehavior !== 'None'" class="arrow right" @click="() => onIncrement('Increase')" tabindex="-1"></button> <button class="arrow right" on:click={() => onIncrement("Increase")} tabindex="-1" />
{/if}
{#if mode === "Range" && value !== undefined}
<input <input
type="range" type="range"
class="slider" class="slider"
:class="{ hidden: rangeSliderClickDragState === 'mousedown' }" class:hidden={rangeSliderClickDragState === "mousedown"}
v-if="mode === 'Range' && value !== undefined" bind:value={rangeSliderValue}
v-model="rangeSliderValue" min={rangeMin}
:min="rangeMin" max={rangeMax}
:max="rangeMax" step={sliderStepValue}
:step="sliderStepValue" {disabled}
:disabled="disabled" on:input={onSliderInput}
@input="() => sliderInput()" on:pointerdown={onSliderPointerDown}
@pointerdown="() => sliderPointerDown()" on:pointerup={onSliderPointerUp}
@pointerup="() => sliderPointerUp()"
tabindex="-1" tabindex="-1"
/> />
<div v-if="value !== undefined && rangeSliderClickDragState === 'mousedown'" class="fake-slider-thumb"></div> {/if}
<div v-if="value !== undefined" class="slider-progress"></div> {#if value !== undefined}
</FieldInput> {#if value !== undefined && rangeSliderClickDragState === "mousedown"}
</template> <div class="fake-slider-thumb" />
{/if}
<div class="slider-progress" />
{/if}
</FieldInput>
<style lang="scss" global>
.number-input {
input {
text-align: center;
}
<style lang="scss">
.number-input {
&.increment { &.increment {
// Widen the label and input margins from the edges by an extra 8px to make room for the increment arrows // Widen the label and input margins from the edges by an extra 8px to make room for the increment arrows
label { label {
@@ -475,5 +484,5 @@ function unPluralize(unit: string, value: number): string {
} }
} }
} }
} }
</style> </style>
@@ -1,37 +1,24 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, type PropType } from "vue"; import { type IconName } from "@/utility-functions/icons";
import { type IconName } from "@/utility-functions/icons"; import LayoutRow from "@/components/layout/LayoutRow.svelte";
import CheckboxInput from "@/components/widgets/inputs/CheckboxInput.svelte";
import LayoutRow from "@/components/layout/LayoutRow.vue"; export let checked: boolean;
import CheckboxInput from "@/components/widgets/inputs/CheckboxInput.vue"; export let disabled = false;
export let icon: IconName = "Checkmark";
export default defineComponent({ export let tooltip: string | undefined = undefined;
emits: ["update:checked"],
props: {
checked: { type: Boolean as PropType<boolean>, required: true },
disabled: { type: Boolean as PropType<boolean>, default: false },
icon: { type: String as PropType<IconName>, default: "Checkmark" },
tooltip: { type: String as PropType<string | undefined>, required: false },
},
components: {
CheckboxInput,
LayoutRow,
},
});
</script> </script>
<template> <LayoutRow class="optional-input" classes={{ disabled }}>
<LayoutRow class="optional-input" :class="disabled"> <CheckboxInput {checked} on:checked {disabled} {icon} {tooltip} />
<CheckboxInput :checked="checked" :disabled="disabled" @input="(e: Event) => $emit('update:checked', (e.target as HTMLInputElement).checked)" :icon="icon" :tooltip="tooltip" /> </LayoutRow>
</LayoutRow>
</template>
<style lang="scss"> <style lang="scss" global>
.optional-input { .optional-input {
flex-grow: 0; flex-grow: 0;
label { .checkbox-input label {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
white-space: nowrap; white-space: nowrap;
@@ -42,8 +29,8 @@ export default defineComponent({
box-sizing: border-box; box-sizing: border-box;
} }
&.disabled label { &.disabled .checkbox-input label {
border: 1px solid var(--color-4-dimgray); border: 1px solid var(--color-4-dimgray);
} }
} }
</style> </style>
@@ -1,55 +1,51 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, type PropType } from "vue"; import { createEventDispatcher } from "svelte";
import { type RadioEntries, type RadioEntryData } from "@/wasm-communication/messages"; import { type RadioEntries, type RadioEntryData } from "@/wasm-communication/messages";
import LayoutRow from "@/components/layout/LayoutRow.vue"; import LayoutRow from "@/components/layout/LayoutRow.svelte";
import IconLabel from "@/components/widgets/labels/IconLabel.vue"; import IconLabel from "@/components/widgets/labels/IconLabel.svelte";
import TextLabel from "@/components/widgets/labels/TextLabel.vue"; import TextLabel from "@/components/widgets/labels/TextLabel.svelte";
export default defineComponent({ // emits: ["update:selectedIndex"],
emits: ["update:selectedIndex"], const dispatch = createEventDispatcher<{ selectedIndex: number }>();
props: {
entries: { type: Array as PropType<RadioEntries>, required: true }, export let entries: RadioEntries;
disabled: { type: Boolean as PropType<boolean>, default: false }, export let selectedIndex: number;
selectedIndex: { type: Number as PropType<number>, required: true }, export let disabled = false;
sharpRightCorners: { type: Boolean as PropType<boolean>, default: false }, export let sharpRightCorners = false;
},
methods: { function handleEntryClick(radioEntryData: RadioEntryData) {
handleEntryClick(radioEntryData: RadioEntryData) { const index = entries.indexOf(radioEntryData);
const index = this.entries.indexOf(radioEntryData); dispatch("selectedIndex", index);
this.$emit("update:selectedIndex", index);
radioEntryData.action?.(); radioEntryData.action?.();
}, }
},
components: {
IconLabel,
LayoutRow,
TextLabel,
},
});
</script> </script>
<template> <LayoutRow class="radio-input" classes={{ disabled }}>
<LayoutRow class="radio-input" :class="{ disabled }"> {#each entries as entry, index (index)}
<button <button
:class="{ active: index === selectedIndex, disabled, 'sharp-right-corners': index === entries.length - 1 && sharpRightCorners }" class:active={index === selectedIndex}
v-for="(entry, index) in entries" class:disabled
:key="index" class:sharp-right-corners={index === entries.length - 1 && sharpRightCorners}
@click="() => handleEntryClick(entry)" on:click={() => handleEntryClick(entry)}
:title="entry.tooltip" title={entry.tooltip}
:tabindex="index === selectedIndex ? -1 : 0" tabindex={index === selectedIndex ? -1 : 0}
:disabled="disabled" {disabled}
> >
<IconLabel v-if="entry.icon" :icon="entry.icon" /> {#if entry.icon}
<TextLabel v-if="entry.label">{{ entry.label }}</TextLabel> <IconLabel icon={entry.icon} />
{/if}
{#if entry.label}
<TextLabel>{entry.label}</TextLabel>
{/if}
</button> </button>
</LayoutRow> {/each}
</template> </LayoutRow>
<style lang="scss"> <style lang="scss" global>
.radio-input { .radio-input {
button { button {
background: var(--color-5-dullgray); background: var(--color-5-dullgray);
fill: var(--color-e-nearwhite); fill: var(--color-e-nearwhite);
@@ -119,5 +115,5 @@ export default defineComponent({
&.combined-after button:last-of-type { &.combined-after button:last-of-type {
border-radius: 0; border-radius: 0;
} }
} }
</style> </style>
@@ -1,63 +1,71 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, type PropType } from "vue"; import { getContext } from "svelte";
import { type Color } from "@/wasm-communication/messages"; import { type Color } from "@/wasm-communication/messages";
import ColorPicker from "@/components/floating-menus/ColorPicker.vue"; import ColorPicker from "@/components/floating-menus/ColorPicker.svelte";
import LayoutCol from "@/components/layout/LayoutCol.vue"; import LayoutCol from "@/components/layout/LayoutCol.svelte";
import LayoutRow from "@/components/layout/LayoutRow.vue"; import LayoutRow from "@/components/layout/LayoutRow.svelte";
import { Editor } from "@/wasm-communication/editor";
export default defineComponent({ const editor = getContext<Editor>("editor");
inject: ["editor"],
props: { export let primary: Color;
primary: { type: Object as PropType<Color>, required: true }, export let secondary: Color;
secondary: { type: Object as PropType<Color>, required: true },
}, let primaryOpen = false;
data() { let secondaryOpen = false;
return {
primaryOpen: false, function clickPrimarySwatch() {
secondaryOpen: false, primaryOpen = true;
}; secondaryOpen = false;
}, }
methods: {
clickPrimarySwatch() { function clickSecondarySwatch() {
this.primaryOpen = true; primaryOpen = false;
this.secondaryOpen = false; secondaryOpen = true;
}, }
clickSecondarySwatch() {
this.primaryOpen = false; function primaryColorChanged(color: Color) {
this.secondaryOpen = true; editor.instance.updatePrimaryColor(color.red, color.green, color.blue, color.alpha);
}, }
primaryColorChanged(color: Color) {
this.editor.instance.updatePrimaryColor(color.red, color.green, color.blue, color.alpha); function secondaryColorChanged(color: Color) {
}, editor.instance.updateSecondaryColor(color.red, color.green, color.blue, color.alpha);
secondaryColorChanged(color: Color) { }
this.editor.instance.updateSecondaryColor(color.red, color.green, color.blue, color.alpha);
},
},
components: {
ColorPicker,
LayoutCol,
LayoutRow,
},
});
</script> </script>
<template> <LayoutCol class="swatch-pair">
<LayoutCol class="swatch-pair">
<LayoutRow class="primary swatch"> <LayoutRow class="primary swatch">
<button @click="() => clickPrimarySwatch()" :style="{ '--swatch-color': primary.toRgbaCSS() }" data-floating-menu-spawner="no-hover-transfer" tabindex="0"></button> <button on:click={clickPrimarySwatch} style:--swatch-color={primary.toRgbaCSS()} data-floating-menu-spawner="no-hover-transfer" tabindex="0" />
<ColorPicker v-model:open="primaryOpen" :color="primary" @update:color="(color: Color) => primaryColorChanged(color)" :direction="'Right'" /> <ColorPicker
open={primaryOpen}
on:open={({ detail }) => (primaryOpen = detail)}
color={primary}
on:color={({ detail }) => {
primary = detail;
primaryColorChanged(detail);
}}
direction="Right"
/>
</LayoutRow> </LayoutRow>
<LayoutRow class="secondary swatch"> <LayoutRow class="secondary swatch">
<button @click="() => clickSecondarySwatch()" :style="{ '--swatch-color': secondary.toRgbaCSS() }" data-floating-menu-spawner="no-hover-transfer" tabindex="0"></button> <button on:click={clickSecondarySwatch} style:--swatch-color={secondary.toRgbaCSS()} data-floating-menu-spawner="no-hover-transfer" tabindex="0" />
<ColorPicker v-model:open="secondaryOpen" :color="secondary" @update:color="(color: Color) => secondaryColorChanged(color)" :direction="'Right'" /> <ColorPicker
open={secondaryOpen}
on:open={({ detail }) => (secondaryOpen = detail)}
color={secondary}
on:color={({ detail }) => {
secondary = detail;
secondaryColorChanged(detail);
}}
direction="Right"
/>
</LayoutRow> </LayoutRow>
</LayoutCol> </LayoutCol>
</template>
<style lang="scss"> <style lang="scss" global>
.swatch-pair { .swatch-pair {
flex: 0 0 auto; flex: 0 0 auto;
.swatch { .swatch {
@@ -92,5 +100,5 @@ export default defineComponent({
z-index: 1; z-index: 1;
} }
} }
} }
</style> </style>
@@ -1,76 +1,64 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, type PropType } from "vue"; import { createEventDispatcher } from "svelte";
import FieldInput from "@/components/widgets/inputs/FieldInput.vue"; import FieldInput from "@/components/widgets/inputs/FieldInput.svelte";
// emits: ["update:value", "commitText"],
const dispatch = createEventDispatcher<{ commitText: string }>();
export let value: string;
export let label: string | undefined = undefined;
export let tooltip: string | undefined = undefined;
export let disabled = false;
let self: FieldInput;
let editing = false;
function onTextFocused() {
editing = true;
}
export default defineComponent({
emits: ["update:value", "commitText"],
props: {
value: { type: String as PropType<string>, required: true },
label: { type: String as PropType<string>, required: false },
disabled: { type: Boolean as PropType<boolean>, default: false },
tooltip: { type: String as PropType<string | undefined>, required: false },
},
data() {
return {
editing: false,
};
},
computed: {
inputValue: {
get() {
return this.value;
},
set(value: string) {
this.$emit("update:value", value);
},
},
},
methods: {
onTextFocused() {
this.editing = true;
},
// Called only when `value` is changed from the <textarea> element via user input and committed, either // Called only when `value` is changed from the <textarea> element via user input and committed, either
// via the `change` event or when the <input> element is unfocused (with the `blur` event binding) // via the `change` event or when the <input> element is unfocused (with the `blur` event binding)
onTextChanged() { function onTextChanged() {
// The `unFocus()` call in `onCancelTextChange()` causes itself to be run again, so this if statement skips a second run // The `unFocus()` call in `onCancelTextChange()` causes itself to be run again, so this if statement skips a second run
if (!this.editing) return; if (!editing) return;
this.onCancelTextChange(); onCancelTextChange();
// TODO: Find a less hacky way to do this // TODO: Find a less hacky way to do this
const inputElement = this.$refs.fieldInput as typeof FieldInput | undefined; dispatch("commitText", self.getValue());
if (!inputElement) return;
this.$emit("commitText", inputElement.getInputElementValue());
// Required if value is not changed by the parent component upon update:value event // Required if value is not changed by the parent component upon update:value event
inputElement.setInputElementValue(this.value); self.setInputElementValue(value);
}, }
onCancelTextChange() {
this.editing = false;
(this.$refs.fieldInput as typeof FieldInput | undefined)?.unFocus(); function onCancelTextChange() {
}, editing = false;
},
components: { FieldInput }, self.unFocus();
}); }
export function focus() {
self.focus();
}
</script> </script>
<template> <FieldInput
<FieldInput
:textarea="true"
class="text-area-input" class="text-area-input"
:class="{ 'has-label': label }" classes={{ "has-label": Boolean(label) }}
:label="label" {value}
:spellcheck="true" on:value
:disabled="disabled" on:textFocused={onTextFocused}
:tooltip="tooltip" on:textChanged={onTextChanged}
v-model:value="inputValue" on:cancelTextChange={onCancelTextChange}
@textFocused="() => onTextFocused()" textarea={true}
@textChanged="() => onTextChanged()" spellcheck={true}
@cancelTextChange="() => onCancelTextChange()" {label}
ref="fieldInput" {disabled}
></FieldInput> {tooltip}
</template> bind:this={self}
/>
<style lang="scss"></style> <style lang="scss" global>
</style>
@@ -1,95 +1,79 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, type PropType } from "vue"; import { createEventDispatcher } from "svelte";
import FieldInput from "@/components/widgets/inputs/FieldInput.vue"; import FieldInput from "@/components/widgets/inputs/FieldInput.svelte";
// emits: ["update:value", "commitText"],
const dispatch = createEventDispatcher<{ commitText: string }>();
export default defineComponent({
emits: ["update:value", "commitText"],
props: {
// Label // Label
label: { type: String as PropType<string>, required: false }, export let label: string | undefined = undefined;
tooltip: { type: String as PropType<string | undefined>, required: false }, export let tooltip: string | undefined = undefined;
placeholder: { type: String as PropType<string>, required: false }, export let placeholder: string | undefined = undefined;
// Disabled // Disabled
disabled: { type: Boolean as PropType<boolean>, default: false }, export let disabled = false;
// Value // Value
value: { type: String as PropType<string>, required: true }, export let value: string;
// Styling // Styling
centered: { type: Boolean as PropType<boolean>, default: false }, export let centered = false;
minWidth: { type: Number as PropType<number>, default: 0 }, export let minWidth = 0;
sharpRightCorners: { type: Boolean as PropType<boolean>, default: false }, export let sharpRightCorners = false;
},
data() { let self: FieldInput;
return { let editing = false;
editing: false,
}; function onTextFocused() {
}, editing = true;
computed: {
text: { self.selectAllText(value);
get() { }
return this.value;
},
set(value: string) {
this.$emit("update:value", value);
},
},
},
methods: {
onTextFocused() {
this.editing = true;
(this.$refs.fieldInput as typeof FieldInput | undefined)?.selectAllText(this.text);
},
// Called only when `value` is changed from the <input> element via user input and committed, either with the // Called only when `value` is changed from the <input> element via user input and committed, either with the
// enter key (via the `change` event) or when the <input> element is unfocused (with the `blur` event binding) // enter key (via the `change` event) or when the <input> element is unfocused (with the `blur` event binding)
onTextChanged() { function onTextChanged() {
// The `unFocus()` call in `onCancelTextChange()` causes itself to be run again, so this if statement skips a second run // The `unFocus()` call in `onCancelTextChange()` causes itself to be run again, so this if statement skips a second run
if (!this.editing) return; if (!editing) return;
this.onCancelTextChange(); onCancelTextChange();
// TODO: Find a less hacky way to do this // TODO: Find a less hacky way to do this
const inputElement = this.$refs.fieldInput as typeof FieldInput | undefined; dispatch("commitText", self.getValue());
if (!inputElement) return;
this.$emit("commitText", inputElement.getInputElementValue());
// Required if value is not changed by the parent component upon update:value event // Required if value is not changed by the parent component upon update:value event
inputElement.setInputElementValue(this.value); self.setInputElementValue(value);
}, }
onCancelTextChange() {
this.editing = false;
(this.$refs.fieldInput as typeof FieldInput | undefined)?.unFocus(); function onCancelTextChange() {
}, editing = false;
},
components: { FieldInput }, self.unFocus();
}); }
export function focus() {
self.focus();
}
</script> </script>
<template> <FieldInput
<FieldInput
class="text-input" class="text-input"
:class="{ centered }" classes={{ centered }}
v-model:value="text" styles={{ "min-width": minWidth > 0 ? `${minWidth}px` : undefined }}
:label="label" {value}
:spellcheck="true" on:value
:disabled="disabled" on:textFocused={onTextFocused}
:tooltip="tooltip" on:textChanged={onTextChanged}
:placeholder="placeholder" on:cancelTextChange={onCancelTextChange}
:style="{ 'min-width': minWidth > 0 ? `${minWidth}px` : undefined }" spellcheck={true}
:sharpRightCorners="sharpRightCorners" {label}
@textFocused="() => onTextFocused()" {disabled}
@textChanged="() => onTextChanged()" {tooltip}
@cancelTextChange="() => onCancelTextChange()" {placeholder}
ref="fieldInput" {sharpRightCorners}
></FieldInput> bind:this={self}
</template> />
<style lang="scss"> <style lang="scss" global>
.text-input { .text-input {
input { input {
text-align: left; text-align: left;
} }
@@ -99,5 +83,5 @@ export default defineComponent({
text-align: center; text-align: center;
} }
} }
} }
</style> </style>
@@ -1,36 +1,29 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, type PropType } from "vue"; import { type IconName, ICONS, ICON_SVG_STRINGS } from "@/utility-functions/icons";
import { type IconName, ICONS, ICON_COMPONENTS } from "@/utility-functions/icons"; import LayoutRow from "@/components/layout/LayoutRow.svelte";
import LayoutRow from "@/components/layout/LayoutRow.vue"; let className = "";
export { className as class };
export let classes: Record<string, boolean> = {};
export let icon: IconName;
export let disabled = false;
export let tooltip: string | undefined = undefined;
export default defineComponent({ $: iconSizeClass = ((icon: IconName) => {
props: { return `size-${ICONS[icon].size}`;
icon: { type: String as PropType<IconName>, required: true }, })(icon);
disabled: { type: Boolean as PropType<boolean>, default: false }, $: extraClasses = Object.entries(classes)
tooltip: { type: String as PropType<string | undefined>, required: false }, .flatMap((classAndState) => (classAndState[1] ? [classAndState[0]] : []))
}, .join(" ");
computed: {
iconSizeClass(): string {
return `size-${ICONS[this.icon].size}`;
},
},
components: {
LayoutRow,
...ICON_COMPONENTS,
},
});
</script> </script>
<template> <LayoutRow class={`icon-label ${iconSizeClass} ${className} ${extraClasses}`.trim()} classes={{ disabled }} {tooltip}>
<LayoutRow :class="['icon-label', iconSizeClass, { disabled }]" :title="tooltip"> {@html ICON_SVG_STRINGS[icon]}
<component :is="icon" /> </LayoutRow>
</LayoutRow>
</template>
<style lang="scss"> <style lang="scss" global>
.icon-label { .icon-label {
flex: 0 0 auto; flex: 0 0 auto;
fill: var(--color-e-nearwhite); fill: var(--color-e-nearwhite);
@@ -52,5 +45,5 @@ export default defineComponent({
width: 24px; width: 24px;
height: 24px; height: 24px;
} }
} }
</style> </style>
@@ -1,24 +1,18 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, type PropType } from "vue"; import { type SeparatorDirection, type SeparatorType } from "@/wasm-communication/messages";
import { type SeparatorDirection, type SeparatorType } from "@/wasm-communication/messages"; export let direction: SeparatorDirection = "Horizontal";
export let type: SeparatorType = "Unrelated";
export default defineComponent({
props: {
direction: { type: String as PropType<SeparatorDirection>, default: "Horizontal" },
type: { type: String as PropType<SeparatorType>, default: "Unrelated" },
},
});
</script> </script>
<template> <div class={`separator ${direction.toLowerCase()} ${type.toLowerCase()}`}>
<div class="separator" :class="[direction.toLowerCase(), type.toLowerCase()]"> {#if ["Section", "List"].includes(type)}
<div v-if="['Section', 'List'].includes(type)"></div> <div />
</div> {/if}
</template> </div>
<style lang="scss"> <style lang="scss" global>
.separator { .separator {
&.vertical { &.vertical {
flex: 0 0 auto; flex: 0 0 auto;
@@ -82,5 +76,5 @@ export default defineComponent({
margin: 0 4px; margin: 0 4px;
} }
} }
} }
</style> </style>
@@ -1,27 +1,42 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, type PropType } from "vue"; 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> = {};
export let disabled = false;
export let bold = false;
export let italic = false;
export let tableAlign = false;
export let minWidth = 0;
export let multiline = false;
export let tooltip: string | undefined = undefined;
export default defineComponent({ $: extraClasses = Object.entries(classes)
props: { .flatMap((classAndState) => (classAndState[1] ? [classAndState[0]] : []))
disabled: { type: Boolean as PropType<boolean>, default: false }, .join(" ");
bold: { type: Boolean as PropType<boolean>, default: false }, $: extraStyles = Object.entries(styles)
italic: { type: Boolean as PropType<boolean>, default: false }, .flatMap((styleAndValue) => (styleAndValue[1] !== undefined ? [`${styleAndValue[0]}: ${styleAndValue[1]};`] : []))
tableAlign: { type: Boolean as PropType<boolean>, default: false }, .join(" ");
minWidth: { type: Number as PropType<number>, default: 0 },
multiline: { type: Boolean as PropType<boolean>, default: false },
tooltip: { type: String as PropType<string | undefined>, required: false },
},
});
</script> </script>
<template> <span
<span class="text-label" :class="{ disabled, bold, italic, multiline, 'table-align': tableAlign }" :style="{ 'min-width': minWidth > 0 ? `${minWidth}px` : undefined }" :title="tooltip"> class={`text-label ${className} ${extraClasses}`.trim()}
<slot></slot> class:disabled
</span> class:bold
</template> class:italic
class:multiline
class:table-align={tableAlign}
style:min-width={minWidth > 0 ? `${minWidth}px` : undefined}
style={`${styleName} ${extraStyles}`.trim() || undefined}
title={tooltip}
>
<slot />
</span>
<style lang="scss"> <style lang="scss" global>
.text-label { .text-label {
line-height: 18px; line-height: 18px;
white-space: nowrap; white-space: nowrap;
// Force Safari to not draw a text cursor, even though this element has `user-select: none` // Force Safari to not draw a text cursor, even though this element has `user-select: none`
@@ -48,5 +63,5 @@ export default defineComponent({
flex: 0 0 30%; flex: 0 0 30%;
text-align: right; text-align: right;
} }
} }
</style> </style>
@@ -1,25 +1,25 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, type PropType } from "vue"; import { type IconName } from "@/utility-functions/icons";
import { platformIsMac } from "@/utility-functions/platform";
import { type KeyRaw, type LayoutKeysGroup, type Key, type MouseMotion } from "@/wasm-communication/messages";
import { type IconName } from "@/utility-functions/icons"; import LayoutRow from "@/components/layout/LayoutRow.svelte";
import { platformIsMac } from "@/utility-functions/platform"; import IconLabel from "@/components/widgets/labels/IconLabel.svelte";
import { type KeyRaw, type LayoutKeysGroup, type Key, type MouseMotion } from "@/wasm-communication/messages"; import Separator from "@/components/widgets/labels/Separator.svelte";
import TextLabel from "@/components/widgets/labels/TextLabel.svelte";
import { getContext } from "svelte";
import { type FullscreenState } from "@/state-providers/fullscreen";
import LayoutRow from "@/components/layout/LayoutRow.vue"; type LabelData = { label?: string; icon?: IconName; width: string };
import IconLabel from "@/components/widgets/labels/IconLabel.vue";
import Separator from "@/components/widgets/labels/Separator.vue";
import TextLabel from "@/components/widgets/labels/TextLabel.vue";
type LabelData = { label?: string; icon?: IconName; width: string }; // Keys that become icons if they are listed here with their units of width
const ICON_WIDTHS_MAC = {
// Keys that become icons if they are listed here with their units of width
const ICON_WIDTHS_MAC = {
Shift: 2, Shift: 2,
Control: 2, Control: 2,
Option: 2, Option: 2,
Command: 2, Command: 2,
}; };
const ICON_WIDTHS = { const ICON_WIDTHS = {
ArrowUp: 1, ArrowUp: 1,
ArrowRight: 1, ArrowRight: 1,
ArrowDown: 1, ArrowDown: 1,
@@ -29,39 +29,34 @@ const ICON_WIDTHS = {
Tab: 2, Tab: 2,
Space: 3, Space: 3,
...(platformIsMac() ? ICON_WIDTHS_MAC : {}), ...(platformIsMac() ? ICON_WIDTHS_MAC : {}),
}; };
export default defineComponent({ const fullscreen = getContext<FullscreenState>("fullscreen");
inject: ["fullscreen"],
props: { export let keysWithLabelsGroups: LayoutKeysGroup[] = [];
keysWithLabelsGroups: { type: Array as PropType<LayoutKeysGroup[]>, default: () => [] }, export let mouseMotion: MouseMotion | undefined = undefined;
mouseMotion: { type: String as PropType<MouseMotion | undefined>, required: false }, export let requiresLock = false;
requiresLock: { type: Boolean as PropType<boolean>, default: false },
}, $: keyboardLockInfoMessage = watchKeyboardLockInfoMessage(fullscreen.keyboardLockApiSupported);
computed: { $: displayKeyboardLockNotice = requiresLock && !$fullscreen.keyboardLocked;
hasSlotContent(): boolean {
return Boolean(this.$slots.default); function watchKeyboardLockInfoMessage(keyboardLockApiSupported: boolean): string {
},
keyboardLockInfoMessage(): string {
const RESERVED = "This hotkey is reserved by the browser. "; const RESERVED = "This hotkey is reserved by the browser. ";
const USE_FULLSCREEN = "It is made available in fullscreen mode."; const USE_FULLSCREEN = "It is made available in fullscreen mode.";
const USE_SECURE_CTX = "It is made available in fullscreen mode when this website is served from a secure context (https or localhost)."; const USE_SECURE_CTX = "It is made available in fullscreen mode when this website is served from a secure context (https or localhost).";
const SWITCH_BROWSER = "Use a Chromium-based browser (like Chrome or Edge) in fullscreen mode to directly use the shortcut."; const SWITCH_BROWSER = "Use a Chromium-based browser (like Chrome or Edge) in fullscreen mode to directly use the shortcut.";
if (this.fullscreen.keyboardLockApiSupported) return `${RESERVED} ${USE_FULLSCREEN}`; if (keyboardLockApiSupported) return `${RESERVED} ${USE_FULLSCREEN}`;
if (!("chrome" in window)) return `${RESERVED} ${SWITCH_BROWSER}`; if (!("chrome" in window)) return `${RESERVED} ${SWITCH_BROWSER}`;
if (!window.isSecureContext) return `${RESERVED} ${USE_SECURE_CTX}`; if (!window.isSecureContext) return `${RESERVED} ${USE_SECURE_CTX}`;
return RESERVED; return RESERVED;
}, }
displayKeyboardLockNotice(): boolean {
return this.requiresLock && !this.fullscreen.state.keyboardLocked; function keyTextOrIconList(keyGroup: LayoutKeysGroup): LabelData[] {
}, return keyGroup.map((key) => keyTextOrIcon(key));
}, }
methods: {
keyTextOrIconList(keyGroup: LayoutKeysGroup): LabelData[] { function keyTextOrIcon(keyWithLabel: Key): LabelData {
return keyGroup.map((key) => this.keyTextOrIcon(key));
},
keyTextOrIcon(keyWithLabel: Key): LabelData {
// `key` is the name of the `Key` enum in Rust, while `label` is the localized string to display (if it doesn't become an icon) // `key` is the name of the `Key` enum in Rust, while `label` is the localized string to display (if it doesn't become an icon)
let key = keyWithLabel.key; let key = keyWithLabel.key;
const label = keyWithLabel.label; const label = keyWithLabel.label;
@@ -75,16 +70,18 @@ export default defineComponent({
// Either display an icon... // Either display an icon...
// @ts-expect-error We want undefined if it isn't in the object // @ts-expect-error We want undefined if it isn't in the object
const iconWidth: number | undefined = ICON_WIDTHS[key]; const iconWidth: number | undefined = ICON_WIDTHS[key];
const icon = iconWidth !== undefined && iconWidth > 0 && (this.keyboardHintIcon(key) || false); const icon = iconWidth !== undefined && iconWidth > 0 && (keyboardHintIcon(key) || false);
if (icon) return { icon, width: `width-${iconWidth}` }; if (icon) return { icon, width: `width-${iconWidth}` };
// ...or display text // ...or display text
return { label, width: `width-${label.length}` }; return { label, width: `width-${label.length}` };
}, }
mouseHintIcon(input?: MouseMotion): IconName {
function mouseHintIcon(input?: MouseMotion): IconName {
return `MouseHint${input}` as IconName; return `MouseHint${input}` as IconName;
}, }
keyboardHintIcon(input: KeyRaw): IconName | undefined {
function keyboardHintIcon(input: KeyRaw): IconName | undefined {
switch (input) { switch (input) {
case "ArrowDown": case "ArrowDown":
return "KeyboardArrowDown"; return "KeyboardArrowDown";
@@ -113,40 +110,42 @@ export default defineComponent({
default: default:
return undefined; return undefined;
} }
}, }
},
components: {
IconLabel,
LayoutRow,
Separator,
TextLabel,
},
});
</script> </script>
<template> {#if displayKeyboardLockNotice}
<IconLabel class="user-input-label keyboard-lock-notice" v-if="displayKeyboardLockNotice" :icon="'Info'" :title="keyboardLockInfoMessage" /> <IconLabel class="user-input-label keyboard-lock-notice" icon="Info" tooltip={keyboardLockInfoMessage} />
<LayoutRow class="user-input-label" v-else> {:else}
<template v-for="(keysWithLabels, i) in keysWithLabelsGroups" :key="i"> <LayoutRow class="user-input-label">
<Separator :type="'Related'" v-if="i > 0"></Separator> {#each keysWithLabelsGroups as keysWithLabels, groupIndex (groupIndex)}
<template v-for="(keyInfo, j) in keyTextOrIconList(keysWithLabels)" :key="j"> {#if groupIndex > 0}
<div class="input-key" :class="keyInfo.width"> <Separator type="Related" />
<IconLabel v-if="keyInfo.icon" :icon="keyInfo.icon" /> {/if}
<TextLabel v-else-if="keyInfo.label !== undefined">{{ keyInfo.label }}</TextLabel> {#each keyTextOrIconList(keysWithLabels) as keyInfo, keyIndex (keyIndex)}
<div class={`input-key ${keyInfo.width}`}>
{#if keyInfo.icon}
<IconLabel icon={keyInfo.icon} />
{:else if keyInfo.label !== undefined}
<TextLabel>{keyInfo.label}</TextLabel>
{/if}
</div> </div>
</template> {/each}
</template> {/each}
<div class="input-mouse" v-if="mouseMotion"> {#if mouseMotion}
<IconLabel :icon="mouseHintIcon(mouseMotion)" /> <div class="input-mouse">
<IconLabel icon={mouseHintIcon(mouseMotion)} />
</div> </div>
<div class="hint-text" v-if="hasSlotContent"> {/if}
<slot></slot> {#if $$slots.default}
<div class="hint-text">
<slot />
</div> </div>
{/if}
</LayoutRow> </LayoutRow>
</template> {/if}
<style lang="scss"> <style lang="scss" global>
.user-input-label { .user-input-label {
flex: 0 0 auto; flex: 0 0 auto;
height: 100%; height: 100%;
align-items: center; align-items: center;
@@ -245,5 +244,5 @@ export default defineComponent({
fill: var(--color-7-middlegray); fill: var(--color-7-middlegray);
} }
} }
} }
</style> </style>
@@ -1,45 +1,43 @@
<script lang="ts" context="module">
export type RulerDirection = "Horizontal" | "Vertical";
</script>
<script lang="ts"> <script lang="ts">
import { defineComponent, type PropType } from "vue"; const RULER_THICKNESS = 16;
const MAJOR_MARK_THICKNESS = 16;
const MEDIUM_MARK_THICKNESS = 6;
const MINOR_MARK_THICKNESS = 3;
const RULER_THICKNESS = 16; export let direction: RulerDirection = "Vertical";
const MAJOR_MARK_THICKNESS = 16; export let origin: number;
const MEDIUM_MARK_THICKNESS = 6; export let numberInterval: number;
const MINOR_MARK_THICKNESS = 3; export let majorMarkSpacing: number;
export let mediumDivisions: number = 5;
export let minorDivisions: number = 2;
export type RulerDirection = "Horizontal" | "Vertical"; let canvasRuler: HTMLDivElement;
let rulerLength = 0;
let svgBounds = { width: "0px", height: "0px" };
// Modulo function that works for negative numbers, unlike the JS % operator $: svgPath = computeSvgPath(direction, origin, majorMarkSpacing, mediumDivisions, minorDivisions, rulerLength);
const mod = (n: number, m: number): number => { $: svgTexts = computeSvgTexts(direction, origin, majorMarkSpacing, numberInterval, rulerLength);
const remainder = n % m;
return Math.floor(remainder >= 0 ? remainder : remainder + m);
};
export default defineComponent({ function computeSvgPath(direction: RulerDirection, origin: number, majorMarkSpacing: number, mediumDivisions: number, minorDivisions: number, rulerLength: number): string {
props: { const isVertical = direction === "Vertical";
direction: { type: String as PropType<RulerDirection>, default: "Vertical" },
origin: { type: Number as PropType<number>, required: true },
numberInterval: { type: Number as PropType<number>, required: true },
majorMarkSpacing: { type: Number as PropType<number>, required: true },
mediumDivisions: { type: Number as PropType<number>, default: 5 },
minorDivisions: { type: Number as PropType<number>, default: 2 },
},
computed: {
svgPath(): string {
const isVertical = this.direction === "Vertical";
const lineDirection = isVertical ? "H" : "V"; const lineDirection = isVertical ? "H" : "V";
const offsetStart = mod(this.origin, this.majorMarkSpacing); const offsetStart = mod(origin, majorMarkSpacing);
const shiftedOffsetStart = offsetStart - this.majorMarkSpacing; const shiftedOffsetStart = offsetStart - majorMarkSpacing;
const divisions = this.majorMarkSpacing / this.mediumDivisions / this.minorDivisions; const divisions = majorMarkSpacing / mediumDivisions / minorDivisions;
const majorMarksFrequency = this.mediumDivisions * this.minorDivisions; const majorMarksFrequency = mediumDivisions * minorDivisions;
let dPathAttribute = ""; let dPathAttribute = "";
let i = 0; let i = 0;
for (let location = shiftedOffsetStart; location < this.rulerLength; location += divisions) { for (let location = shiftedOffsetStart; location < rulerLength; location += divisions) {
let length; let length;
if (i % majorMarksFrequency === 0) length = MAJOR_MARK_THICKNESS; if (i % majorMarksFrequency === 0) length = MAJOR_MARK_THICKNESS;
else if (i % this.minorDivisions === 0) length = MEDIUM_MARK_THICKNESS; else if (i % minorDivisions === 0) length = MEDIUM_MARK_THICKNESS;
else length = MINOR_MARK_THICKNESS; else length = MINOR_MARK_THICKNESS;
i += 1; i += 1;
@@ -49,18 +47,19 @@ export default defineComponent({
} }
return dPathAttribute; return dPathAttribute;
}, }
svgTexts(): { transform: string; text: number }[] {
const isVertical = this.direction === "Vertical";
const offsetStart = mod(this.origin, this.majorMarkSpacing); function computeSvgTexts(direction: RulerDirection, origin: number, majorMarkSpacing: number, numberInterval: number, rulerLength: number): { transform: string; text: number }[] {
const shiftedOffsetStart = offsetStart - this.majorMarkSpacing; const isVertical = direction === "Vertical";
const offsetStart = mod(origin, majorMarkSpacing);
const shiftedOffsetStart = offsetStart - majorMarkSpacing;
const svgTextCoordinates = []; const svgTextCoordinates = [];
let text = (Math.ceil(-this.origin / this.majorMarkSpacing) - 1) * this.numberInterval; let text = (Math.ceil(-origin / majorMarkSpacing) - 1) * numberInterval;
for (let location = shiftedOffsetStart; location < this.rulerLength; location += this.majorMarkSpacing) { for (let location = shiftedOffsetStart; location < rulerLength; location += majorMarkSpacing) {
const destination = Math.round(location); const destination = Math.round(location);
const x = isVertical ? 9 : destination + 2; const x = isVertical ? 9 : destination + 2;
const y = isVertical ? destination + 1 : 9; const y = isVertical ? destination + 1 : 9;
@@ -70,50 +69,44 @@ export default defineComponent({
svgTextCoordinates.push({ transform, text }); svgTextCoordinates.push({ transform, text });
text += this.numberInterval; text += numberInterval;
} }
return svgTextCoordinates; return svgTextCoordinates;
}, }
},
methods: {
resize() {
const canvasRuler = this.$refs.canvasRuler as HTMLDivElement | undefined;
if (!canvasRuler) return;
const isVertical = this.direction === "Vertical"; export function resize() {
const isVertical = direction === "Vertical";
const newLength = isVertical ? canvasRuler.clientHeight : canvasRuler.clientWidth; const newLength = isVertical ? canvasRuler.clientHeight : canvasRuler.clientWidth;
const roundedUp = (Math.floor(newLength / this.majorMarkSpacing) + 1) * this.majorMarkSpacing; const roundedUp = (Math.floor(newLength / majorMarkSpacing) + 1) * majorMarkSpacing;
if (roundedUp !== this.rulerLength) { if (roundedUp !== rulerLength) {
this.rulerLength = roundedUp; rulerLength = roundedUp;
const thickness = `${RULER_THICKNESS}px`; const thickness = `${RULER_THICKNESS}px`;
const length = `${roundedUp}px`; const length = `${roundedUp}px`;
this.svgBounds = isVertical ? { width: thickness, height: length } : { width: length, height: thickness }; svgBounds = isVertical ? { width: thickness, height: length } : { width: length, height: thickness };
}
}
// Modulo function that works for negative numbers, unlike the JS `%` operator
function mod(n: number, m: number): number {
const remainder = n % m;
return Math.floor(remainder >= 0 ? remainder : remainder + m);
} }
},
},
data() {
return {
rulerLength: 0,
svgBounds: { width: "0px", height: "0px" },
};
},
});
</script> </script>
<template> <div class={`canvas-ruler ${direction.toLowerCase()}`} bind:this={canvasRuler}>
<div class="canvas-ruler" :class="direction.toLowerCase()" ref="canvasRuler"> <svg style:width={svgBounds.width} style:height={svgBounds.height}>
<svg :style="svgBounds"> <path d={svgPath} />
<path :d="svgPath" /> {#each svgTexts as svgText, index (index)}
<text v-for="(svgText, index) in svgTexts" :key="index" :transform="svgText.transform">{{ svgText.text }}</text> <text transform={svgText.transform}>{svgText.text}</text>
{/each}
</svg> </svg>
</div> </div>
</template>
<style lang="scss"> <style lang="scss" global>
.canvas-ruler { .canvas-ruler {
flex: 1 1 100%; flex: 1 1 100%;
background: var(--color-4-dimgray); background: var(--color-4-dimgray);
overflow: hidden; overflow: hidden;
@@ -144,5 +137,5 @@ export default defineComponent({
fill: var(--color-8-uppergray); fill: var(--color-8-uppergray);
} }
} }
} }
</style> </style>
@@ -1,127 +1,116 @@
<script lang="ts"> <script lang="ts" context="module">
import { defineComponent, type PropType } from "vue"; export type ScrollbarDirection = "Horizontal" | "Vertical";
export type ScrollbarDirection = "Horizontal" | "Vertical";
// Linear Interpolation
const lerp = (x: number, y: number, a: number): number => x * (1 - a) + y * a;
// Convert the position of the handle (0-1) to the position on the track (0-1).
// This includes the 1/2 handle length gap of the possible handle positionson each side so the end of the handle doesn't go off the track.
const handleToTrack = (handleLen: number, handlePos: number): number => lerp(handleLen / 2, 1 - handleLen / 2, handlePos);
const pointerPosition = (direction: ScrollbarDirection, e: PointerEvent): number => (direction === "Vertical" ? e.clientY : e.clientX);
export default defineComponent({
emits: {
"update:handlePosition": null,
pressTrack: (pointerOffset: number) => typeof pointerOffset === "number",
},
props: {
direction: { type: String as PropType<ScrollbarDirection>, default: "Vertical" },
handlePosition: { type: Number as PropType<number>, default: 0.5 },
handleLength: { type: Number as PropType<number>, default: 0.5 },
},
computed: {
thumbStart(): { left: string } | { top: string } {
const start = handleToTrack(this.handleLength, this.handlePosition) - this.handleLength / 2;
return this.direction === "Vertical" ? { top: `${start * 100}%` } : { left: `${start * 100}%` };
},
thumbEnd(): { right: string } | { bottom: string } {
const end = 1 - handleToTrack(this.handleLength, this.handlePosition) - this.handleLength / 2;
return this.direction === "Vertical" ? { bottom: `${end * 100}%` } : { right: `${end * 100}%` };
},
sides(): { left: string; right: string } | { top: string; bottom: string } {
return this.direction === "Vertical" ? { left: "0%", right: "0%" } : { top: "0%", bottom: "0%" };
},
},
data() {
return {
dragging: false,
pointerPos: 0,
};
},
mounted() {
window.addEventListener("pointerup", this.pointerUp);
window.addEventListener("pointermove", this.pointerMove);
},
unmounted() {
window.removeEventListener("pointerup", this.pointerUp);
window.removeEventListener("pointermove", this.pointerMove);
},
methods: {
trackLength(): number | undefined {
const track = this.$refs.scrollTrack as HTMLDivElement | undefined;
if (track) return this.direction === "Vertical" ? track.clientHeight - this.handleLength : track.clientWidth;
return undefined;
},
trackOffset(): number | undefined {
const track = this.$refs.scrollTrack as HTMLDivElement | undefined;
if (track) return this.direction === "Vertical" ? track.getBoundingClientRect().top : track.getBoundingClientRect().left;
return undefined;
},
clampHandlePosition(newPos: number) {
const clampedPosition = Math.min(Math.max(newPos, 0), 1);
this.$emit("update:handlePosition", clampedPosition);
},
updateHandlePosition(e: PointerEvent) {
const trackLength = this.trackLength();
if (trackLength === undefined) return;
const position = pointerPosition(this.direction, e);
this.clampHandlePosition(this.handlePosition + (position - this.pointerPos) / (trackLength * (1 - this.handleLength)));
this.pointerPos = position;
},
grabHandle(e: PointerEvent) {
if (!this.dragging) {
this.dragging = true;
this.pointerPos = pointerPosition(this.direction, e);
}
},
grabArea(e: PointerEvent) {
if (!this.dragging) {
const trackLength = this.trackLength();
const trackOffset = this.trackOffset();
if (trackLength === undefined || trackOffset === undefined) return;
const oldPointer = handleToTrack(this.handleLength, this.handlePosition) * trackLength + trackOffset;
const pointerPos = pointerPosition(this.direction, e);
this.$emit("pressTrack", pointerPos - oldPointer);
}
},
pointerUp() {
this.dragging = false;
},
pointerMove(e: PointerEvent) {
if (this.dragging) this.updateHandlePosition(e);
},
changePosition(difference: number) {
const trackLength = this.trackLength();
if (trackLength === undefined) return;
this.clampHandlePosition(this.handlePosition + difference / trackLength);
},
},
});
</script> </script>
<template> <script lang="ts">
<div class="persistent-scrollbar" :class="direction.toLowerCase()"> import { createEventDispatcher, onMount, onDestroy } from "svelte";
<button class="arrow decrease" @pointerdown="() => changePosition(-50)" tabindex="-1"></button>
<div class="scroll-track" ref="scrollTrack" @pointerdown="(e) => grabArea(e)">
<div class="scroll-thumb" @pointerdown="(e) => grabHandle(e)" :class="{ dragging }" :style="[thumbStart, thumbEnd, sides]"></div>
</div>
<button class="arrow increase" @click="() => changePosition(50)" tabindex="-1"></button>
</div>
</template>
<style lang="scss"> // Linear Interpolation
.persistent-scrollbar { const lerp = (x: number, y: number, a: number): number => x * (1 - a) + y * a;
// Convert the position of the handle (0-1) to the position on the track (0-1).
// This includes the 1/2 handle length gap of the possible handle positionson each side so the end of the handle doesn't go off the track.
const handleToTrack = (handleLen: number, handlePos: number): number => lerp(handleLen / 2, 1 - handleLen / 2, handlePos);
const pointerPosition = (direction: ScrollbarDirection, e: PointerEvent): number => (direction === "Vertical" ? e.clientY : e.clientX);
// emits: { "update:handlePosition": null, pressTrack: (pointerOffset: number) => typeof pointerOffset === "number" }
const dispatch = createEventDispatcher<{ handlePosition: number; pressTrack: number }>();
export let direction: ScrollbarDirection = "Vertical";
export let handlePosition: number = 0.5;
export let handleLength: number = 0.5;
let scrollTrack: HTMLDivElement;
let dragging = false;
let pointerPos = 0;
let thumbTop: string | undefined = undefined;
let thumbBottom: string | undefined = undefined;
let thumbLeft: string | undefined = undefined;
let thumbRight: string | undefined = undefined;
$: start = handleToTrack(handleLength, handlePosition) - handleLength / 2;
$: end = 1 - handleToTrack(handleLength, handlePosition) - handleLength / 2;
$: [thumbTop, thumbBottom, thumbLeft, thumbRight] = direction === "Vertical" ? [`${start * 100}%`, `${end * 100}%`, "0%", "0%"] : ["0%", "0%", `${start * 100}%`, `${end * 100}%`];
function trackLength(): number | undefined {
return direction === "Vertical" ? scrollTrack.clientHeight - handleLength : scrollTrack.clientWidth;
}
function trackOffset(): number | undefined {
return direction === "Vertical" ? scrollTrack.getBoundingClientRect().top : scrollTrack.getBoundingClientRect().left;
}
function clampHandlePosition(newPos: number) {
const clampedPosition = Math.min(Math.max(newPos, 0), 1);
dispatch("handlePosition", clampedPosition);
}
function updateHandlePosition(e: PointerEvent) {
const length = trackLength();
if (length === undefined) return;
const position = pointerPosition(direction, e);
clampHandlePosition(handlePosition + (position - pointerPos) / (length * (1 - handleLength)));
pointerPos = position;
}
function grabHandle(e: PointerEvent) {
if (!dragging) {
dragging = true;
pointerPos = pointerPosition(direction, e);
}
}
function grabArea(e: PointerEvent) {
if (!dragging) {
const length = trackLength();
const offset = trackOffset();
if (length === undefined || offset === undefined) return;
const oldPointer = handleToTrack(handleLength, handlePosition) * length + offset;
const pointerPos = pointerPosition(direction, e);
dispatch("pressTrack", pointerPos - oldPointer);
}
}
function pointerUp() {
dragging = false;
}
function pointerMove(e: PointerEvent) {
if (dragging) updateHandlePosition(e);
}
function changePosition(difference: number) {
const length = trackLength();
if (length === undefined) return;
clampHandlePosition(handlePosition + difference / length);
}
onMount(() => {
window.addEventListener("pointerup", pointerUp);
window.addEventListener("pointermove", pointerMove);
});
onDestroy(() => {
window.removeEventListener("pointerup", pointerUp);
window.removeEventListener("pointermove", pointerMove);
});
</script>
<div class={`persistent-scrollbar ${direction.toLowerCase()}`}>
<button class="arrow decrease" on:pointerdown={() => changePosition(-50)} tabindex="-1" />
<div class="scroll-track" bind:this={scrollTrack} on:pointerdown={grabArea}>
<div class="scroll-thumb" on:pointerdown={grabHandle} class:dragging style:top={thumbTop} style:bottom={thumbBottom} style:left={thumbLeft} style:right={thumbRight} />
</div>
<button class="arrow increase" on:click={() => changePosition(50)} tabindex="-1" />
</div>
<style lang="scss" global>
.persistent-scrollbar {
display: flex; display: flex;
flex: 1 1 100%; flex: 1 1 100%;
@@ -215,5 +204,5 @@ export default defineComponent({
} }
} }
} }
} }
</style> </style>
@@ -1,43 +1,29 @@
<script lang="ts"> <script lang="ts" context="module">
import { defineComponent } from "vue"; export type ApplicationPlatform = "Windows" | "Mac" | "Linux" | "Web";
import LayoutCol from "@/components/layout/LayoutCol.vue";
import StatusBar from "@/components/window/status-bar/StatusBar.vue";
import TitleBar from "@/components/window/title-bar/TitleBar.vue";
import Workspace from "@/components/window/workspace/Workspace.vue";
export type ApplicationPlatform = "Windows" | "Mac" | "Linux" | "Web";
export default defineComponent({
data() {
return {
platform: "Web" as ApplicationPlatform,
maximized: true,
};
},
components: {
LayoutCol,
StatusBar,
TitleBar,
Workspace,
},
});
</script> </script>
<template> <script lang="ts">
<LayoutCol class="main-window"> import LayoutCol from "@/components/layout/LayoutCol.svelte";
<TitleBar :platform="platform" :maximized="maximized" /> import StatusBar from "@/components/window/status-bar/StatusBar.svelte";
import TitleBar from "@/components/window/title-bar/TitleBar.svelte";
import Workspace from "@/components/window/workspace/Workspace.svelte";
let platform: ApplicationPlatform = "Web";
let maximized: true;
</script>
<LayoutCol class="main-window">
<TitleBar {platform} {maximized} />
<Workspace /> <Workspace />
<StatusBar /> <StatusBar />
</LayoutCol> </LayoutCol>
</template>
<style lang="scss"> <style lang="scss" global>
.main-window { .main-window {
height: 100%; height: 100%;
overflow: auto; overflow: auto;
touch-action: none; touch-action: none;
} }
</style> </style>
@@ -1,55 +1,48 @@
<script lang="ts"> <script lang="ts">
import { defineComponent } from "vue"; import { getContext, onMount } from "svelte";
import { platformIsMac } from "@/utility-functions/platform"; import { platformIsMac } from "@/utility-functions/platform";
import { type HintData, type HintInfo, type LayoutKeysGroup, UpdateInputHints } from "@/wasm-communication/messages"; import { type HintData, type HintInfo, type LayoutKeysGroup, UpdateInputHints } from "@/wasm-communication/messages";
import LayoutRow from "@/components/layout/LayoutRow.vue"; import LayoutRow from "@/components/layout/LayoutRow.svelte";
import Separator from "@/components/widgets/labels/Separator.vue"; import Separator from "@/components/widgets/labels/Separator.svelte";
import UserInputLabel from "@/components/widgets/labels/UserInputLabel.vue"; import UserInputLabel from "@/components/widgets/labels/UserInputLabel.svelte";
import { type Editor } from "@/wasm-communication/editor";
export default defineComponent({ const editor = getContext<Editor>("editor");
inject: ["editor"],
data() { let hintData: HintData = [];
return {
hintData: [] as HintData, function inputKeysForPlatform(hint: HintInfo): LayoutKeysGroup[] {
};
},
methods: {
inputKeysForPlatform(hint: HintInfo): LayoutKeysGroup[] {
if (platformIsMac() && hint.keyGroupsMac) return hint.keyGroupsMac; if (platformIsMac() && hint.keyGroupsMac) return hint.keyGroupsMac;
return hint.keyGroups; return hint.keyGroups;
}, }
},
mounted() { onMount(() => {
this.editor.subscriptions.subscribeJsMessage(UpdateInputHints, (updateInputHints) => { editor.subscriptions.subscribeJsMessage(UpdateInputHints, (data) => {
this.hintData = updateInputHints.hintData; hintData = data.hintData;
});
}); });
},
components: {
LayoutRow,
Separator,
UserInputLabel,
},
});
</script> </script>
<template> <LayoutRow class="status-bar">
<LayoutRow class="status-bar">
<LayoutRow class="hint-groups"> <LayoutRow class="hint-groups">
<template v-for="(hintGroup, index) in hintData" :key="hintGroup"> {#each hintData as hintGroup, index (hintGroup)}
<Separator :type="'Section'" v-if="index !== 0" /> {#if index !== 0}
<template v-for="hint in hintGroup" :key="hint"> <Separator type={"Section"} />
<LayoutRow v-if="hint.plus" class="plus">+</LayoutRow> {/if}
<UserInputLabel :mouseMotion="hint.mouse" :keysWithLabelsGroups="inputKeysForPlatform(hint)">{{ hint.label }}</UserInputLabel> {#each hintGroup as hint (hint)}
</template> {#if hint.plus}
</template> <LayoutRow class="plus">+</LayoutRow>
{/if}
<UserInputLabel mouseMotion={hint.mouse} keysWithLabelsGroups={inputKeysForPlatform(hint)}>{hint.label}</UserInputLabel>
{/each}
{/each}
</LayoutRow> </LayoutRow>
</LayoutRow> </LayoutRow>
</template>
<style lang="scss"> <style lang="scss" global>
.status-bar { .status-bar {
height: 24px; height: 24px;
width: 100%; width: 100%;
flex: 0 0 auto; flex: 0 0 auto;
@@ -78,5 +71,5 @@ export default defineComponent({
} }
} }
} }
} }
</style> </style>
@@ -1,58 +1,49 @@
<script lang="ts"> <script lang="ts" context="module">
import { defineComponent, type PropType } from "vue"; export type Platform = "Windows" | "Mac" | "Linux" | "Web";
import LayoutRow from "@/components/layout/LayoutRow.vue";
import MenuBarInput from "@/components/widgets/inputs/MenuBarInput.vue";
import WindowButtonsMac from "@/components/window/title-bar/WindowButtonsMac.vue";
import WindowButtonsWeb from "@/components/window/title-bar/WindowButtonsWeb.vue";
import WindowButtonsWindows from "@/components/window/title-bar/WindowButtonsWindows.vue";
import WindowTitle from "@/components/window/title-bar/WindowTitle.vue";
export type Platform = "Windows" | "Mac" | "Linux" | "Web";
export default defineComponent({
inject: ["portfolio"],
props: {
platform: { type: String as PropType<Platform>, required: true },
maximized: { type: Boolean as PropType<boolean>, required: true },
},
computed: {
windowTitle(): string {
const activeDocumentIndex = this.portfolio.state.activeDocumentIndex;
const activeDocumentDisplayName = this.portfolio.state.documents[activeDocumentIndex]?.displayName || "";
return `${activeDocumentDisplayName}${activeDocumentDisplayName && " - "}Graphite`;
},
},
components: {
LayoutRow,
MenuBarInput,
WindowButtonsMac,
WindowButtonsWeb,
WindowButtonsWindows,
WindowTitle,
},
});
</script> </script>
<template> <script lang="ts">
<LayoutRow class="title-bar"> import LayoutRow from "@/components/layout/LayoutRow.svelte";
<LayoutRow class="header-part"> import MenuBarInput from "@/components/widgets/inputs/MenuBarInput.svelte";
<WindowButtonsMac :maximized="maximized" v-if="platform === 'Mac'" /> import WindowButtonsMac from "@/components/window/title-bar/WindowButtonsMac.svelte";
<MenuBarInput v-if="platform !== 'Mac'" /> import WindowButtonsWeb from "@/components/window/title-bar/WindowButtonsWeb.svelte";
</LayoutRow> import WindowButtonsWindows from "@/components/window/title-bar/WindowButtonsWindows.svelte";
<LayoutRow class="header-part"> import WindowTitle from "@/components/window/title-bar/WindowTitle.svelte";
<WindowTitle :text="windowTitle" /> import { type PortfolioState } from "@/state-providers/portfolio";
</LayoutRow> import { getContext } from "svelte";
<LayoutRow class="header-part">
<WindowButtonsWindows :maximized="maximized" v-if="platform === 'Windows' || platform === 'Linux'" />
<WindowButtonsWeb :maximized="maximized" v-if="platform === 'Web'" />
</LayoutRow>
</LayoutRow>
</template>
<style lang="scss"> export let platform: Platform;
.title-bar { export let maximized: boolean;
const portfolio = getContext<PortfolioState>("portfolio");
$: docIndex = $portfolio.activeDocumentIndex;
$: displayName = $portfolio.documents[docIndex]?.displayName || "";
$: windowTitle = `${displayName}${displayName && " - "}Graphite`;
</script>
<LayoutRow class="title-bar">
<LayoutRow class="header-part">
{#if platform === "Mac"}
<WindowButtonsMac {maximized} />
{:else}
<MenuBarInput />
{/if}
</LayoutRow>
<LayoutRow class="header-part">
<WindowTitle text={windowTitle} />
</LayoutRow>
<LayoutRow class="header-part">
{#if platform === "Windows" || platform === "Linux"}
<WindowButtonsWindows {maximized} />
{:else if platform === "Web"}
<WindowButtonsWeb />
{/if}
</LayoutRow>
</LayoutRow>
<style lang="scss" global>
.title-bar {
height: 28px; height: 28px;
flex: 0 0 auto; flex: 0 0 auto;
@@ -71,5 +62,5 @@ export default defineComponent({
justify-content: flex-end; justify-content: flex-end;
} }
} }
} }
</style> </style>
@@ -1,26 +1,17 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, type PropType } from "vue"; import LayoutRow from "@/components/layout/LayoutRow.svelte";
import LayoutRow from "@/components/layout/LayoutRow.vue"; export let maximized = false;
export default defineComponent({
props: {
maximized: { type: Boolean as PropType<boolean>, default: false },
},
components: { LayoutRow },
});
</script> </script>
<template> <LayoutRow class="window-buttons-mac">
<LayoutRow class="window-buttons mac"> <div class="close" title="Close" />
<div class="close" title="Close"></div> <div class="minimize" title="Minimize" />
<div class="minimize" title="Minimize"></div> <div class="zoom" title="Zoom" />
<div class="zoom" title="Zoom"></div> </LayoutRow>
</LayoutRow>
</template>
<style lang="scss"> <style lang="scss" global>
.window-buttons.mac { .window-buttons-mac {
flex: 0 0 auto; flex: 0 0 auto;
align-items: center; align-items: center;
margin: 0 8px; margin: 0 8px;
@@ -48,5 +39,5 @@ export default defineComponent({
background: #54c22b; background: #54c22b;
} }
} }
} }
</style> </style>
@@ -1,40 +1,31 @@
<script lang="ts"> <script lang="ts">
import { defineComponent } from "vue"; import { getContext } from "svelte";
import LayoutRow from "@/components/layout/LayoutRow.vue"; import LayoutRow from "@/components/layout/LayoutRow.svelte";
import IconLabel from "@/components/widgets/labels/IconLabel.vue"; import IconLabel from "@/components/widgets/labels/IconLabel.svelte";
import TextLabel from "@/components/widgets/labels/TextLabel.vue"; import TextLabel from "@/components/widgets/labels/TextLabel.svelte";
import { FullscreenState } from "@/state-providers/fullscreen";
export default defineComponent({ const fullscreen = getContext<FullscreenState>("fullscreen");
inject: ["fullscreen"],
methods: { $: windowFullscreen = $fullscreen.windowFullscreen;
async handleClick() { $: requestFullscreenHotkeys = fullscreen.keyboardLockApiSupported && !$fullscreen.keyboardLocked;
if (this.fullscreen.state.windowFullscreen) this.fullscreen.exitFullscreen();
else this.fullscreen.enterFullscreen(); async function handleClick() {
}, if (windowFullscreen) fullscreen.exitFullscreen();
}, else fullscreen.enterFullscreen();
computed: { }
requestFullscreenHotkeys() {
return this.fullscreen.keyboardLockApiSupported && !this.fullscreen.state.keyboardLocked;
},
},
components: {
IconLabel,
LayoutRow,
TextLabel,
},
});
</script> </script>
<template> <LayoutRow class="window-buttons-web" on:click={() => handleClick()} tooltip={(windowFullscreen ? "Exit" : "Enter") + " Fullscreen (F11)"}>
<LayoutRow class="window-buttons-web" @click="() => handleClick()" :title="(fullscreen.state.windowFullscreen ? 'Exit' : 'Enter') + ' Fullscreen (F11)'"> {#if requestFullscreenHotkeys}
<TextLabel v-if="requestFullscreenHotkeys" :italic="true">Go fullscreen to access all hotkeys</TextLabel> <TextLabel italic={true}>Go fullscreen to access all hotkeys</TextLabel>
<IconLabel :icon="fullscreen.state.windowFullscreen ? 'FullscreenExit' : 'FullscreenEnter'" /> {/if}
</LayoutRow> <IconLabel icon={windowFullscreen ? "FullscreenExit" : "FullscreenEnter"} />
</template> </LayoutRow>
<style lang="scss"> <style lang="scss" global>
.window-buttons-web { .window-buttons-web {
flex: 0 0 auto; flex: 0 0 auto;
align-items: center; align-items: center;
padding: 0 8px; padding: 0 8px;
@@ -55,5 +46,5 @@ export default defineComponent({
fill: var(--color-f-white); fill: var(--color-f-white);
} }
} }
} }
</style> </style>
@@ -1,37 +1,28 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, type PropType } from "vue"; import LayoutRow from "@/components/layout/LayoutRow.svelte";
import IconLabel from "@/components/widgets/labels/IconLabel.svelte";
import LayoutRow from "@/components/layout/LayoutRow.vue"; export let maximized = false;
import IconLabel from "@/components/widgets/labels/IconLabel.vue";
export default defineComponent({
props: {
maximized: { type: Boolean as PropType<boolean>, default: false },
},
components: {
IconLabel,
LayoutRow,
},
});
</script> </script>
<template> <LayoutRow class="window-button windows minimize" tooltip="Minimize">
<LayoutRow class="window-button windows minimize" title="Minimize"> <IconLabel icon={"WindowButtonWinMinimize"} />
<IconLabel :icon="'WindowButtonWinMinimize'" /> </LayoutRow>
{#if !maximized}
<LayoutRow class="window-button windows maximize" tooltip="Maximize">
<IconLabel icon={"WindowButtonWinMaximize"} />
</LayoutRow> </LayoutRow>
<LayoutRow class="window-button windows maximize" title="Maximize" v-if="!maximized"> {:else}
<IconLabel :icon="'WindowButtonWinMaximize'" /> <LayoutRow class="window-button windows restore-down" tooltip="Restore Down">
<IconLabel icon={"WindowButtonWinRestoreDown"} />
</LayoutRow> </LayoutRow>
<LayoutRow class="window-button windows restore-down" title="Restore Down" v-if="maximized"> {/if}
<IconLabel :icon="'WindowButtonWinRestoreDown'" /> <LayoutRow class="window-button windows close" tooltip="Close">
</LayoutRow> <IconLabel icon={"WindowButtonWinClose"} />
<LayoutRow class="window-button windows close" title="Close"> </LayoutRow>
<IconLabel :icon="'WindowButtonWinClose'" />
</LayoutRow>
</template>
<style lang="scss"> <style lang="scss" global>
.window-button.windows { .window-button.windows {
flex: 0 0 auto; flex: 0 0 auto;
align-items: center; align-items: center;
padding: 0 17px; padding: 0 17px;
@@ -51,5 +42,5 @@ export default defineComponent({
&.close:hover { &.close:hover {
background: #e81123; background: #e81123;
} }
} }
</style> </style>
@@ -1,31 +1,19 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, type PropType } from "vue"; import LayoutRow from "@/components/layout/LayoutRow.svelte";
import TextLabel from "@/components/widgets/labels/TextLabel.svelte";
import LayoutRow from "@/components/layout/LayoutRow.vue"; export let text: string;
import TextLabel from "@/components/widgets/labels/TextLabel.vue";
export default defineComponent({
props: {
text: { type: String as PropType<string>, required: true },
},
components: {
LayoutRow,
TextLabel,
},
});
</script> </script>
<template> <LayoutRow class="window-title">
<LayoutRow class="window-title"> <TextLabel>{text}</TextLabel>
<TextLabel>{{ text }}</TextLabel> </LayoutRow>
</LayoutRow>
</template>
<style lang="scss"> <style lang="scss" global>
.window-title { .window-title {
flex: 0 0 auto; flex: 0 0 auto;
align-items: center; align-items: center;
white-space: nowrap; white-space: nowrap;
padding: 0 8px; padding: 0 8px;
} }
</style> </style>
@@ -1,53 +1,56 @@
<script lang="ts"> <script lang="ts" context="module">
import { defineComponent, nextTick, type PropType } from "vue"; import Document from "@/components/panels/Document.svelte";
import IconButton from "@/components/widgets/buttons/IconButton.svelte";
import LayerTree from "@/components/panels/LayerTree.svelte";
import NodeGraph from "@/components/panels/NodeGraph.svelte";
import PopoverButton from "@/components/widgets/buttons/PopoverButton.svelte";
import Properties from "@/components/panels/Properties.svelte";
import TextButton from "@/components/widgets/buttons/TextButton.svelte";
import { platformIsMac } from "@/utility-functions/platform"; const PANEL_COMPONENTS = {
import { type LayoutKeysGroup, type Key } from "@/wasm-communication/messages";
import LayoutCol from "@/components/layout/LayoutCol.vue";
import LayoutRow from "@/components/layout/LayoutRow.vue";
import Document from "@/components/panels/Document.vue";
import LayerTree from "@/components/panels/LayerTree.vue";
import NodeGraph from "@/components/panels/NodeGraph.vue";
import Properties from "@/components/panels/Properties.vue";
import IconButton from "@/components/widgets/buttons/IconButton.vue";
import PopoverButton from "@/components/widgets/buttons/PopoverButton.vue";
import TextButton from "@/components/widgets/buttons/TextButton.vue";
import IconLabel from "@/components/widgets/labels/IconLabel.vue";
import TextLabel from "@/components/widgets/labels/TextLabel.vue";
import UserInputLabel from "@/components/widgets/labels/UserInputLabel.vue";
const PANEL_COMPONENTS = {
Document, Document,
IconButton,
LayerTree, LayerTree,
NodeGraph, NodeGraph,
PopoverButton,
Properties, Properties,
TextButton, };
}; type PanelTypes = keyof typeof PANEL_COMPONENTS;
type PanelTypes = keyof typeof PANEL_COMPONENTS; </script>
export default defineComponent({ <script lang="ts">
inject: ["editor"], import { getContext, tick } from "svelte";
props: {
tabMinWidths: { type: Boolean as PropType<boolean>, default: false }, import { platformIsMac, isEventSupported } from "@/utility-functions/platform";
tabCloseButtons: { type: Boolean as PropType<boolean>, default: false },
tabLabels: { type: Array as PropType<{ name: string; tooltip?: string }[]>, required: true }, import { type LayoutKeysGroup, type Key } from "@/wasm-communication/messages";
tabActiveIndex: { type: Number as PropType<number>, required: true },
panelType: { type: String as PropType<PanelTypes>, required: false }, import LayoutCol from "@/components/layout/LayoutCol.svelte";
clickAction: { type: Function as PropType<(index: number) => void>, required: false }, import LayoutRow from "@/components/layout/LayoutRow.svelte";
closeAction: { type: Function as PropType<(index: number) => void>, required: false }, import IconLabel from "@/components/widgets/labels/IconLabel.svelte";
}, import TextLabel from "@/components/widgets/labels/TextLabel.svelte";
methods: { import UserInputLabel from "@/components/widgets/labels/UserInputLabel.svelte";
newDocument() { import { type Editor } from "@/wasm-communication/editor";
this.editor.instance.newDocumentDialog();
}, const editor = getContext<Editor>("editor");
openDocument() {
this.editor.instance.documentOpen(); export let tabMinWidths = false;
}, export let tabCloseButtons = false;
platformModifiers(reservedKey: boolean): LayoutKeysGroup { export let tabLabels: { name: string; tooltip?: string }[];
export let tabActiveIndex: number;
export let panelType: PanelTypes | undefined = undefined;
export let clickAction: ((index: number) => void) | undefined = undefined;
export let closeAction: ((index: number) => void) | undefined = undefined;
let tabElements: LayoutRow[] = [];
function newDocument() {
editor.instance.newDocumentDialog();
}
function openDocument() {
editor.instance.documentOpen();
}
function platformModifiers(reservedKey: boolean): LayoutKeysGroup {
// TODO: Remove this by properly feeding these keys from a layout provided by the backend // TODO: Remove this by properly feeding these keys from a layout provided by the backend
const ALT: Key = { key: "Alt", label: "Alt" }; const ALT: Key = { key: "Alt", label: "Alt" };
@@ -56,86 +59,101 @@ export default defineComponent({
if (platformIsMac()) return reservedKey ? [ALT, COMMAND] : [COMMAND]; if (platformIsMac()) return reservedKey ? [ALT, COMMAND] : [COMMAND];
return reservedKey ? [CONTROL, ALT] : [CONTROL]; return reservedKey ? [CONTROL, ALT] : [CONTROL];
}, }
async scrollTabIntoView(newIndex: number) {
await nextTick();
const panel: HTMLDivElement | undefined = this.$el; export async function scrollTabIntoView(newIndex: number) {
if (!panel) return; await tick();
tabElements[newIndex].div().scrollIntoView();
const newActiveTab = panel.querySelectorAll("[data-tab]")[newIndex] as HTMLDivElement | undefined; }
newActiveTab?.scrollIntoView();
},
},
components: {
IconLabel,
LayoutCol,
LayoutRow,
TextLabel,
UserInputLabel,
...PANEL_COMPONENTS,
},
});
</script> </script>
<template> <LayoutCol class="panel">
<LayoutCol class="panel"> <LayoutRow class="tab-bar" classes={{ "min-widths": tabMinWidths }}>
<LayoutRow class="tab-bar" :class="{ 'min-widths': tabMinWidths }"> <LayoutRow class="tab-group" scrollableX={true}>
<LayoutRow class="tab-group" :scrollableX="true"> {#each tabLabels as tabLabel, tabIndex (tabIndex)}
<LayoutRow <LayoutRow
v-for="(tabLabel, tabIndex) in tabLabels"
:key="tabIndex"
class="tab" class="tab"
:class="{ active: tabIndex === tabActiveIndex }" classes={{ active: tabIndex === tabActiveIndex }}
:title="tabLabel.tooltip || null" tooltip={tabLabel.tooltip || undefined}
@click="(e: MouseEvent) => (e?.stopPropagation(), clickAction?.(tabIndex))" on:click={(e) => {
@click.middle="(e: MouseEvent) => (e?.stopPropagation(), closeAction?.(tabIndex))" e.stopPropagation();
data-tab clickAction?.(tabIndex);
}}
on:auxclick={(e) => {
// Middle mouse button click
if (e.button === 1) {
e.stopPropagation();
closeAction?.(tabIndex);
}
}}
on:mouseup={(e) => {
// Fallback for Safari:
// https://developer.mozilla.org/en-US/docs/Web/API/Element/auxclick_event#browser_compatibility
// The downside of using mouseup is that the mousedown didn't have to originate in the same element.
// A possible future improvement could save the target element during mousedown and check if it's the same here.
if (!isEventSupported("auxclick") && e.button === 1) {
e.stopPropagation();
closeAction?.(tabIndex);
}
}}
bind:this={tabElements[tabIndex]}
> >
<TextLabel>{{ tabLabel.name }}</TextLabel> <TextLabel>{tabLabel.name}</TextLabel>
<IconButton :action="(e?: MouseEvent) => (e?.stopPropagation(), closeAction?.(tabIndex))" :icon="'CloseX'" :size="16" v-if="tabCloseButtons" /> {#if tabCloseButtons}
<IconButton
action={(e) => {
e?.stopPropagation();
closeAction?.(tabIndex);
}}
icon="CloseX"
size={16}
/>
{/if}
</LayoutRow> </LayoutRow>
{/each}
</LayoutRow> </LayoutRow>
<PopoverButton :icon="'VerticalEllipsis'"> <PopoverButton icon="VerticalEllipsis">
<TextLabel :bold="true">Panel Options</TextLabel> <TextLabel bold={true}>Panel Options</TextLabel>
<TextLabel :multiline="true">Coming soon</TextLabel> <TextLabel multiline={true}>Coming soon</TextLabel>
</PopoverButton> </PopoverButton>
</LayoutRow> </LayoutRow>
<LayoutCol class="panel-body"> <LayoutCol class="panel-body">
<component :is="panelType" v-if="panelType" /> {#if panelType}
<LayoutCol class="empty-panel" v-else> <svelte:component this={PANEL_COMPONENTS[panelType]} />
{:else}
<LayoutCol class="empty-panel">
<LayoutCol class="content"> <LayoutCol class="content">
<LayoutRow class="logotype"> <LayoutRow class="logotype">
<IconLabel :icon="'GraphiteLogotypeSolid'" /> <IconLabel icon="GraphiteLogotypeSolid" />
</LayoutRow> </LayoutRow>
<LayoutRow class="actions"> <LayoutRow class="actions">
<table> <table>
<tr> <tr>
<td> <td>
<TextButton :label="'New Document'" :icon="'File'" :action="() => newDocument()" /> <TextButton label="New Document" icon="File" action={() => newDocument()} />
</td> </td>
<td> <td>
<UserInputLabel :keysWithLabelsGroups="[[...platformModifiers(true), { key: 'KeyN', label: 'N' }]]" /> <UserInputLabel keysWithLabelsGroups={[[...platformModifiers(true), { key: "KeyN", label: "N" }]]} />
</td> </td>
</tr> </tr>
<tr> <tr>
<td> <td>
<TextButton :label="'Open Document'" :icon="'Folder'" :action="() => openDocument()" /> <TextButton label="Open Document" icon="Folder" action={() => openDocument()} />
</td> </td>
<td> <td>
<UserInputLabel :keysWithLabelsGroups="[[...platformModifiers(false), { key: 'KeyO', label: 'O' }]]" /> <UserInputLabel keysWithLabelsGroups={[[...platformModifiers(false), { key: "KeyO", label: "O" }]]} />
</td> </td>
</tr> </tr>
</table> </table>
</LayoutRow> </LayoutRow>
</LayoutCol> </LayoutCol>
</LayoutCol> </LayoutCol>
{/if}
</LayoutCol> </LayoutCol>
</LayoutCol> </LayoutCol>
</template>
<style lang="scss"> <style lang="scss" global>
.panel { .panel {
background: var(--color-1-nearblack); background: var(--color-1-nearblack);
border-radius: 6px; border-radius: 6px;
overflow: hidden; overflow: hidden;
@@ -285,5 +303,5 @@ export default defineComponent({
} }
} }
} }
} }
</style> </style>
@@ -1,13 +1,17 @@
<script lang="ts"> <script lang="ts">
import { defineComponent } from "vue"; import DialogModal from "@/components/floating-menus/DialogModal.svelte";
import LayoutCol from "@/components/layout/LayoutCol.svelte";
import LayoutRow from "@/components/layout/LayoutRow.svelte";
import Panel from "@/components/window/workspace/Panel.svelte";
import { getContext } from "svelte";
import { type Editor } from "@/wasm-communication/editor";
import { type WorkspaceState } from "@/state-providers/workspace";
import { type PortfolioState } from "@/state-providers/portfolio";
import { type DialogState } from "@/state-providers/dialog";
import { FrontendDocumentDetails } from "@/wasm-communication/messages";
import DialogModal from "@/components/floating-menus/DialogModal.vue"; const MIN_PANEL_SIZE = 100;
import LayoutCol from "@/components/layout/LayoutCol.vue"; const PANEL_SIZES = {
import LayoutRow from "@/components/layout/LayoutRow.vue";
import Panel from "@/components/window/workspace/Panel.vue";
const MIN_PANEL_SIZE = 100;
const PANEL_SIZES = {
/**/ root: 100, /**/ root: 100,
/* ├── */ content: 80, /* ├── */ content: 80,
/* │ ├── */ document: 60, /* │ ├── */ document: 60,
@@ -15,36 +19,35 @@ const PANEL_SIZES = {
/* └── */ details: 20, /* └── */ details: 20,
/* ├── */ properties: 45, /* ├── */ properties: 45,
/* └── */ layers: 55, /* └── */ layers: 55,
};
export default defineComponent({
inject: ["workspace", "portfolio", "dialog", "editor"],
data() {
return {
panelSizes: PANEL_SIZES,
}; };
},
computed: { let panelSizes = PANEL_SIZES;
activeDocumentIndex() { let documentPanel: Panel;
return this.portfolio.state.activeDocumentIndex;
}, $: activeDocumentIndex = $portfolio.activeDocumentIndex;
nodeGraphVisible() { $: nodeGraphVisible = $workspace.nodeGraphVisible;
return this.workspace.state.nodeGraphVisible; $: documentTabLabels = $portfolio.documents.map((doc: FrontendDocumentDetails) => {
},
documentTabLabels() {
return this.portfolio.state.documents.map((doc) => {
const name = doc.displayName; const name = doc.displayName;
if (!this.editor.instance.inDevelopmentMode()) return { name }; if (!editor.instance.inDevelopmentMode()) return { name };
const tooltip = `Document ID ${doc.id}`; const tooltip = `Document ID ${doc.id}`;
return { name, tooltip }; return { name, tooltip };
}); });
}, $: {
}, scrollIntoView(activeDocumentIndex);
methods: { }
resizePanel(event: PointerEvent) { function scrollIntoView(activeDocumentIndex: number) {
const gutter = (event.target || undefined) as HTMLDivElement | undefined; documentPanel?.scrollTabIntoView(activeDocumentIndex);
}
const editor = getContext<Editor>("editor");
const workspace = getContext<WorkspaceState>("workspace");
const portfolio = getContext<PortfolioState>("portfolio");
const dialog = getContext<DialogState>("dialog");
function resizePanel(e: PointerEvent) {
const gutter = (e.target || undefined) as HTMLDivElement | undefined;
const nextSibling = (gutter?.nextElementSibling || undefined) as HTMLDivElement | undefined; const nextSibling = (gutter?.nextElementSibling || undefined) as HTMLDivElement | undefined;
const prevSibling = (gutter?.previousElementSibling || undefined) as HTMLDivElement | undefined; const prevSibling = (gutter?.previousElementSibling || undefined) as HTMLDivElement | undefined;
const parentElement = (gutter?.parentElement || undefined) as HTMLDivElement | undefined; const parentElement = (gutter?.parentElement || undefined) as HTMLDivElement | undefined;
@@ -68,25 +71,25 @@ export default defineComponent({
const proportionBeingResized = totalResizingSpaceOccupied / parentElementSize; const proportionBeingResized = totalResizingSpaceOccupied / parentElementSize;
// Prevent cursor flicker as mouse temporarily leaves the gutter // Prevent cursor flicker as mouse temporarily leaves the gutter
gutter.setPointerCapture(event.pointerId); gutter.setPointerCapture(e.pointerId);
const mouseStart = isHorizontal ? event.clientX : event.clientY; const mouseStart = isHorizontal ? e.clientX : e.clientY;
const updatePosition = (event: PointerEvent): void => { const updatePosition = (e: PointerEvent): void => {
const mouseCurrent = isHorizontal ? event.clientX : event.clientY; const mouseCurrent = isHorizontal ? e.clientX : e.clientY;
let mouseDelta = mouseStart - mouseCurrent; let mouseDelta = mouseStart - mouseCurrent;
mouseDelta = Math.max(nextSiblingSize + mouseDelta, MIN_PANEL_SIZE) - nextSiblingSize; mouseDelta = Math.max(nextSiblingSize + mouseDelta, MIN_PANEL_SIZE) - nextSiblingSize;
mouseDelta = prevSiblingSize - Math.max(prevSiblingSize - mouseDelta, MIN_PANEL_SIZE); mouseDelta = prevSiblingSize - Math.max(prevSiblingSize - mouseDelta, MIN_PANEL_SIZE);
this.panelSizes[nextSiblingName] = ((nextSiblingSize + mouseDelta) / totalResizingSpaceOccupied) * proportionBeingResized * 100; panelSizes[nextSiblingName] = ((nextSiblingSize + mouseDelta) / totalResizingSpaceOccupied) * proportionBeingResized * 100;
this.panelSizes[prevSiblingName] = ((prevSiblingSize - mouseDelta) / totalResizingSpaceOccupied) * proportionBeingResized * 100; panelSizes[prevSiblingName] = ((prevSiblingSize - mouseDelta) / totalResizingSpaceOccupied) * proportionBeingResized * 100;
window.dispatchEvent(new CustomEvent("resize")); window.dispatchEvent(new CustomEvent("resize"));
}; };
const cleanup = (event: PointerEvent): void => { const cleanup = (e: PointerEvent): void => {
gutter.releasePointerCapture(event.pointerId); gutter.releasePointerCapture(e.pointerId);
document.removeEventListener("pointermove", updatePosition); document.removeEventListener("pointermove", updatePosition);
document.removeEventListener("pointerleave", cleanup); document.removeEventListener("pointerleave", cleanup);
@@ -96,60 +99,49 @@ export default defineComponent({
document.addEventListener("pointermove", updatePosition); document.addEventListener("pointermove", updatePosition);
document.addEventListener("pointerleave", cleanup); document.addEventListener("pointerleave", cleanup);
document.addEventListener("pointerup", cleanup); document.addEventListener("pointerup", cleanup);
}, }
},
watch: {
async activeDocumentIndex(newIndex: number) {
(this.$refs.documentPanel as typeof Panel | undefined)?.scrollTabIntoView(newIndex);
},
},
components: {
DialogModal,
LayoutCol,
LayoutRow,
Panel,
},
});
</script> </script>
<template> <LayoutRow class="workspace" data-workspace>
<LayoutRow class="workspace" data-workspace> <LayoutRow class="workspace-grid-subdivision" styles={{ "flex-grow": panelSizes["root"] }} data-subdivision-name="root">
<LayoutRow class="workspace-grid-subdivision" :style="{ 'flex-grow': panelSizes['root'] }" data-subdivision-name="root"> <LayoutCol class="workspace-grid-subdivision" styles={{ "flex-grow": panelSizes["content"] }} data-subdivision-name="content">
<LayoutCol class="workspace-grid-subdivision" :style="{ 'flex-grow': panelSizes['content'] }" data-subdivision-name="content"> <LayoutRow class="workspace-grid-subdivision" styles={{ "flex-grow": panelSizes["document"] }} data-subdivision-name="document">
<LayoutRow class="workspace-grid-subdivision" :style="{ 'flex-grow': panelSizes['document'] }" data-subdivision-name="document">
<Panel <Panel
:panelType="portfolio.state.documents.length > 0 ? 'Document' : undefined" panelType={$portfolio.documents.length > 0 ? "Document" : undefined}
:tabCloseButtons="true" tabCloseButtons={true}
:tabMinWidths="true" tabMinWidths={true}
:tabLabels="documentTabLabels" tabLabels={documentTabLabels}
:clickAction="(tabIndex: number) => editor.instance.selectDocument(portfolio.state.documents[tabIndex].id)" clickAction={(tabIndex) => editor.instance.selectDocument($portfolio.documents[tabIndex].id)}
:closeAction="(tabIndex: number) => editor.instance.closeDocumentWithConfirmation(portfolio.state.documents[tabIndex].id)" closeAction={(tabIndex) => editor.instance.closeDocumentWithConfirmation($portfolio.documents[tabIndex].id)}
:tabActiveIndex="portfolio.state.activeDocumentIndex" tabActiveIndex={$portfolio.activeDocumentIndex}
ref="documentPanel" bind:this={documentPanel}
/> />
</LayoutRow> </LayoutRow>
<LayoutRow class="workspace-grid-resize-gutter" data-gutter-vertical @pointerdown="(e: PointerEvent) => resizePanel(e)" v-if="nodeGraphVisible"></LayoutRow> {#if nodeGraphVisible}
<LayoutRow class="workspace-grid-subdivision" v-if="nodeGraphVisible" :style="{ 'flex-grow': panelSizes['graph'] }" data-subdivision-name="graph"> <LayoutRow class="workspace-grid-resize-gutter" data-gutter-vertical on:pointerdown={resizePanel} />
<Panel :panelType="'NodeGraph'" :tabLabels="[{ name: 'Node Graph' }]" :tabActiveIndex="0" /> <LayoutRow class="workspace-grid-subdivision" styles={{ "flex-grow": panelSizes["graph"] }} data-subdivision-name="graph">
<Panel panelType="NodeGraph" tabLabels={[{ name: "Node Graph" }]} tabActiveIndex={0} />
</LayoutRow> </LayoutRow>
{/if}
</LayoutCol> </LayoutCol>
<LayoutCol class="workspace-grid-resize-gutter" data-gutter-horizontal @pointerdown="(e: PointerEvent) => resizePanel(e)"></LayoutCol> <LayoutCol class="workspace-grid-resize-gutter" data-gutter-horizontal on:pointerdown={(e) => resizePanel(e)} />
<LayoutCol class="workspace-grid-subdivision" :style="{ 'flex-grow': panelSizes['details'] }" data-subdivision-name="details"> <LayoutCol class="workspace-grid-subdivision" styles={{ "flex-grow": panelSizes["details"] }} data-subdivision-name="details">
<LayoutRow class="workspace-grid-subdivision" :style="{ 'flex-grow': panelSizes['properties'] }" data-subdivision-name="properties"> <LayoutRow class="workspace-grid-subdivision" styles={{ "flex-grow": panelSizes["properties"] }} data-subdivision-name="properties">
<Panel :panelType="'Properties'" :tabLabels="[{ name: 'Properties' }]" :tabActiveIndex="0" /> <Panel panelType="Properties" tabLabels={[{ name: "Properties" }]} tabActiveIndex={0} />
</LayoutRow> </LayoutRow>
<LayoutRow class="workspace-grid-resize-gutter" data-gutter-vertical @pointerdown="(e: PointerEvent) => resizePanel(e)"></LayoutRow> <LayoutRow class="workspace-grid-resize-gutter" data-gutter-vertical on:pointerdown={(e) => resizePanel(e)} />
<LayoutRow class="workspace-grid-subdivision" :style="{ 'flex-grow': panelSizes['layers'] }" data-subdivision-name="layers"> <LayoutRow class="workspace-grid-subdivision" styles={{ "flex-grow": panelSizes["layers"] }} data-subdivision-name="layers">
<Panel :panelType="'LayerTree'" :tabLabels="[{ name: 'Layer Tree' }]" :tabActiveIndex="0" /> <Panel panelType="LayerTree" tabLabels={[{ name: "Layer Tree" }]} tabActiveIndex={0} />
</LayoutRow> </LayoutRow>
</LayoutCol> </LayoutCol>
</LayoutRow> </LayoutRow>
<DialogModal v-if="dialog.state.visible" /> {#if $dialog.visible}
</LayoutRow> <DialogModal />
</template> {/if}
</LayoutRow>
<style lang="scss"> <style lang="scss" global>
.workspace { .workspace {
position: relative; position: relative;
flex: 1 1 100%; flex: 1 1 100%;
@@ -174,5 +166,5 @@ export default defineComponent({
cursor: ew-resize; cursor: ew-resize;
} }
} }
} }
</style> </style>
+7
View File
@@ -0,0 +1,7 @@
// Allow `import` statements to work with SVG files in the eyes of the TypeScript compiler.
// This prevents red underlines from showing and lets it know the types of imported variables are strings.
// The actual import is performed by Webpack when building, as configured in the module rules in `webpack.config.ts`.
declare module "*.svg" {
const content: string;
export default content;
}
+27 -29
View File
@@ -1,3 +1,5 @@
import { get } from "svelte/store";
import { type DialogState } from "@/state-providers/dialog"; import { type DialogState } from "@/state-providers/dialog";
import { type FullscreenState } from "@/state-providers/fullscreen"; import { type FullscreenState } from "@/state-providers/fullscreen";
import { type PortfolioState } from "@/state-providers/portfolio"; import { type PortfolioState } from "@/state-providers/portfolio";
@@ -14,8 +16,8 @@ type EventListenerTarget = {
removeEventListener: typeof window.removeEventListener; removeEventListener: typeof window.removeEventListener;
}; };
export function createInputManager(editor: Editor, container: HTMLElement, dialog: DialogState, document: PortfolioState, fullscreen: FullscreenState): () => void { export function createInputManager(editor: Editor, dialog: DialogState, document: PortfolioState, fullscreen: FullscreenState): () => void {
const app = window.document.querySelector("[data-app]") as HTMLElement | undefined; const app = window.document.querySelector("[data-app-container]") as HTMLElement | undefined;
app?.focus(); app?.focus();
let viewportPointerInteractionOngoing = false; let viewportPointerInteractionOngoing = false;
@@ -30,21 +32,20 @@ export function createInputManager(editor: Editor, container: HTMLElement, dialo
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const listeners: { target: EventListenerTarget; eventName: EventName; action: (event: any) => void; options?: boolean | AddEventListenerOptions }[] = [ const listeners: { target: EventListenerTarget; eventName: EventName; action: (event: any) => void; options?: boolean | AddEventListenerOptions }[] = [
{ target: window, eventName: "resize", action: (): void => onWindowResize(container) }, { target: window, eventName: "resize", action: () => onWindowResize(window.document.body) },
{ target: window, eventName: "beforeunload", action: (e: BeforeUnloadEvent): Promise<void> => onBeforeUnload(e) }, { target: window, eventName: "beforeunload", action: (e: BeforeUnloadEvent) => onBeforeUnload(e) },
{ target: window.document, eventName: "contextmenu", action: (e: MouseEvent): void => e.preventDefault() }, { target: window, eventName: "keyup", action: (e: KeyboardEvent) => onKeyUp(e) },
{ target: window.document, eventName: "fullscreenchange", action: (): void => fullscreen.fullscreenModeChanged() }, { target: window, eventName: "keydown", action: (e: KeyboardEvent) => onKeyDown(e) },
{ target: window, eventName: "keyup", action: (e: KeyboardEvent): Promise<void> => onKeyUp(e) }, { target: window, eventName: "pointermove", action: (e: PointerEvent) => onPointerMove(e) },
{ target: window, eventName: "keydown", action: (e: KeyboardEvent): Promise<void> => onKeyDown(e) }, { target: window, eventName: "pointerdown", action: (e: PointerEvent) => onPointerDown(e) },
{ target: window, eventName: "pointermove", action: (e: PointerEvent): void => onPointerMove(e) }, { target: window, eventName: "pointerup", action: (e: PointerEvent) => onPointerUp(e) },
{ target: window, eventName: "pointerdown", action: (e: PointerEvent): void => onPointerDown(e) }, { target: window, eventName: "dblclick", action: (e: PointerEvent) => onDoubleClick(e) },
{ target: window, eventName: "pointerup", action: (e: PointerEvent): void => onPointerUp(e) }, { target: window, eventName: "wheel", action: (e: WheelEvent) => onWheelScroll(e), options: { passive: false } },
{ target: window, eventName: "dblclick", action: (e: PointerEvent): void => onDoubleClick(e) }, { target: window, eventName: "modifyinputfield", action: (e: CustomEvent) => onModifyInputField(e) },
{ target: window, eventName: "mousedown", action: (e: MouseEvent): void => onMouseDown(e) }, { target: window, eventName: "focusout", action: () => (canvasFocused = false) },
{ target: window, eventName: "wheel", action: (e: WheelEvent): void => onWheelScroll(e), options: { passive: false } }, { target: window.document, eventName: "contextmenu", action: (e: MouseEvent) => e.preventDefault() },
{ target: window, eventName: "modifyinputfield", action: (e: CustomEvent): void => onModifyInputField(e) }, { target: window.document, eventName: "fullscreenchange", action: () => fullscreen.fullscreenModeChanged() },
{ target: window.document.body, eventName: "paste", action: (e: ClipboardEvent): void => onPaste(e) }, { target: window.document.body, eventName: "paste", action: (e: ClipboardEvent) => onPaste(e) },
{ target: app as EventListenerTarget, eventName: "blur", action: (): void => blurApp() },
]; ];
// Event bindings // Event bindings
@@ -62,7 +63,7 @@ export function createInputManager(editor: Editor, container: HTMLElement, dialo
async function shouldRedirectKeyboardEventToBackend(e: KeyboardEvent): Promise<boolean> { async function shouldRedirectKeyboardEventToBackend(e: KeyboardEvent): Promise<boolean> {
// Don't redirect when a modal is covering the workspace // Don't redirect when a modal is covering the workspace
if (dialog.dialogIsVisible()) return false; if (get(dialog).visible) return false;
const key = await getLocalizedScanCode(e); const key = await getLocalizedScanCode(e);
@@ -110,7 +111,7 @@ export function createInputManager(editor: Editor, container: HTMLElement, dialo
return; return;
} }
if (dialog.dialogIsVisible() && key === "Escape") { if (get(dialog).visible && key === "Escape") {
dialog.dismissDialog(); dialog.dismissDialog();
} }
} }
@@ -155,7 +156,7 @@ export function createInputManager(editor: Editor, container: HTMLElement, dialo
const inDialog = target instanceof Element && target.closest("[data-dialog-modal] [data-floating-menu-content]"); const inDialog = target instanceof Element && target.closest("[data-dialog-modal] [data-floating-menu-content]");
const inTextInput = target === textInput; const inTextInput = target === textInput;
if (dialog.dialogIsVisible() && !inDialog) { if (get(dialog).visible && !inDialog) {
dialog.dismissDialog(); dialog.dismissDialog();
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@@ -170,6 +171,9 @@ export function createInputManager(editor: Editor, container: HTMLElement, dialo
const modifiers = makeKeyboardModifiersBitfield(e); const modifiers = makeKeyboardModifiersBitfield(e);
editor.instance.onMouseDown(e.clientX, e.clientY, e.buttons, modifiers); editor.instance.onMouseDown(e.clientX, e.clientY, e.buttons, modifiers);
} }
// Block middle mouse button auto-scroll mode (the circlar widget that appears and allows quick scrolling by moving the cursor above or below it)
if (e.button === 1) e.preventDefault();
} }
function onPointerUp(e: PointerEvent): void { function onPointerUp(e: PointerEvent): void {
@@ -192,12 +196,6 @@ export function createInputManager(editor: Editor, container: HTMLElement, dialo
// Mouse events // Mouse events
function onMouseDown(e: MouseEvent): void {
// Block middle mouse button auto-scroll mode (the circlar widget that appears and allows quick scrolling by moving the cursor above or below it)
// This has to be in `mousedown`, not `pointerdown`, to avoid blocking Vue's middle click detection on HTML elements
if (e.button === 1) e.preventDefault();
}
function onWheelScroll(e: WheelEvent): void { function onWheelScroll(e: WheelEvent): void {
const { target } = e; const { target } = e;
const isTargetingCanvas = target instanceof Element && target.closest("[data-canvas]"); const isTargetingCanvas = target instanceof Element && target.closest("[data-canvas]");
@@ -237,7 +235,7 @@ export function createInputManager(editor: Editor, container: HTMLElement, dialo
} }
async function onBeforeUnload(e: BeforeUnloadEvent): Promise<void> { async function onBeforeUnload(e: BeforeUnloadEvent): Promise<void> {
const activeDocument = document.state.documents[document.state.activeDocumentIndex]; const activeDocument = get(document).documents[get(document).activeDocumentIndex];
if (activeDocument && !activeDocument.isAutoSaved) editor.instance.triggerAutoSave(activeDocument.id); if (activeDocument && !activeDocument.isAutoSaved) editor.instance.triggerAutoSave(activeDocument.id);
// Skip the message if the editor crashed, since work is already lost // Skip the message if the editor crashed, since work is already lost
@@ -246,7 +244,7 @@ export function createInputManager(editor: Editor, container: HTMLElement, dialo
// Skip the message during development, since it's annoying when testing // Skip the message during development, since it's annoying when testing
if (await editor.instance.inDevelopmentMode()) return; if (await editor.instance.inDevelopmentMode()) return;
const allDocumentsSaved = document.state.documents.reduce((acc, doc) => acc && doc.isSaved, true); const allDocumentsSaved = get(document).documents.reduce((acc, doc) => acc && doc.isSaved, true);
if (!allDocumentsSaved) { if (!allDocumentsSaved) {
e.returnValue = "Unsaved work will be lost if the web browser tab is closed. Close anyway?"; e.returnValue = "Unsaved work will be lost if the web browser tab is closed. Close anyway?";
e.preventDefault(); e.preventDefault();
@@ -353,7 +351,7 @@ export function createInputManager(editor: Editor, container: HTMLElement, dialo
// Bind the event listeners // Bind the event listeners
bindListeners(); bindListeners();
// Resize on creation // Resize on creation
onWindowResize(container); onWindowResize(window.document.body);
// Return the destructor // Return the destructor
return unbindListeners; return unbindListeners;
+2 -1
View File
@@ -4,7 +4,8 @@ import { type IconName } from "@/utility-functions/icons";
import { browserVersion, operatingSystem } from "@/utility-functions/platform"; import { browserVersion, operatingSystem } from "@/utility-functions/platform";
import { stripIndents } from "@/utility-functions/strip-indents"; import { stripIndents } from "@/utility-functions/strip-indents";
import { type Editor } from "@/wasm-communication/editor"; import { type Editor } from "@/wasm-communication/editor";
import { type TextButtonWidget, type TextLabel, type WidgetLayout, Widget, DisplayDialogPanic } from "@/wasm-communication/messages"; import type { TextLabel } from "@/wasm-communication/messages";
import { type TextButtonWidget, type WidgetLayout, Widget, DisplayDialogPanic } from "@/wasm-communication/messages";
export function createPanicManager(editor: Editor, dialogState: DialogState): void { export function createPanicManager(editor: Editor, dialogState: DialogState): void {
// Code panic dialog and console error // Code panic dialog and console error
+2 -1
View File
@@ -1,4 +1,5 @@
import { createStore, del, get, set, update } from "idb-keyval"; import { createStore, del, get, set, update } from "idb-keyval";
import { get as getFromStore } from "svelte/store";
import { type PortfolioState } from "@/state-providers/portfolio"; import { type PortfolioState } from "@/state-providers/portfolio";
import { type Editor } from "@/wasm-communication/editor"; import { type Editor } from "@/wasm-communication/editor";
@@ -10,7 +11,7 @@ export function createPersistenceManager(editor: Editor, portfolio: PortfolioSta
// DOCUMENTS // DOCUMENTS
async function storeDocumentOrder(): Promise<void> { async function storeDocumentOrder(): Promise<void> {
const documentOrder = portfolio.state.documents.map((doc) => String(doc.id)); const documentOrder = getFromStore(portfolio).documents.map((doc) => String(doc.id));
await set("documents_tab_order", documentOrder, graphiteStore); await set("documents_tab_order", documentOrder, graphiteStore);
} }
+3 -27
View File
@@ -4,33 +4,9 @@
// It is needed for class-transformer to work and is imported as a side effect. // It is needed for class-transformer to work and is imported as a side effect.
// The library replaces the Reflect API on the window to support more features. // The library replaces the Reflect API on the window to support more features.
import "reflect-metadata"; import "reflect-metadata";
import { createApp } from "vue";
import { initWasm } from "@/wasm-communication/editor"; import App from "@/App.svelte";
import App from "@/App.vue"; document.body.setAttribute("data-app-container", "");
// This exported function is called in `index.html` after confirming that the browser supports all required JS standards export default new App({ target: document.body });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).graphiteAppInit = async (): Promise<void> => {
// Initialize the WASM module for the editor backend
await initWasm();
// Initialize the Vue application
createApp(App)
.directive("focus", {
// When the bound element is inserted into the DOM
mounted(el) {
let focus = el;
// Find actual relevant child
while (focus.children.length) focus = focus.children[0];
// Random timeout needed?
setTimeout(() => {
focus.focus(); // Focus the element
}, 0);
},
})
.mount("#app");
};
+16 -8
View File
@@ -1,4 +1,4 @@
import { reactive, readonly } from "vue"; import {writable} from "svelte/store";
import { type IconName } from "@/utility-functions/icons"; import { type IconName } from "@/utility-functions/icons";
import { type Editor } from "@/wasm-communication/editor"; import { type Editor } from "@/wasm-communication/editor";
@@ -6,7 +6,7 @@ import { type TextButtonWidget, type WidgetLayout, defaultWidgetLayout, DisplayD
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export function createDialogState(editor: Editor) { export function createDialogState(editor: Editor) {
const state = reactive({ const { subscribe, update } = writable({
visible: false, visible: false,
icon: "" as IconName, icon: "" as IconName,
widgets: defaultWidgetLayout(), widgets: defaultWidgetLayout(),
@@ -15,37 +15,45 @@ export function createDialogState(editor: Editor) {
}); });
function dismissDialog(): void { function dismissDialog(): void {
update((state) => {
state.visible = false; state.visible = false;
} return state;
});
function dialogIsVisible(): boolean {
return state.visible;
} }
// Creates a panic dialog from JS. // Creates a panic dialog from JS.
// Normal dialogs are created in the Rust backend, but for the crash dialog, the editor instance has panicked so it cannot respond to widget callbacks. // Normal dialogs are created in the Rust backend, but for the crash dialog, the editor instance has panicked so it cannot respond to widget callbacks.
function createPanicDialog(icon: IconName, widgets: WidgetLayout, jsCallbackBasedButtons: TextButtonWidget[]): void { function createPanicDialog(icon: IconName, widgets: WidgetLayout, jsCallbackBasedButtons: TextButtonWidget[]): void {
update((state) => {
state.visible = true; state.visible = true;
state.icon = icon; state.icon = icon;
state.widgets = widgets; state.widgets = widgets;
state.jsCallbackBasedButtons = jsCallbackBasedButtons; state.jsCallbackBasedButtons = jsCallbackBasedButtons;
return state;
});
} }
// Subscribe to process backend events // Subscribe to process backend events
editor.subscriptions.subscribeJsMessage(DisplayDialog, (displayDialog) => { editor.subscriptions.subscribeJsMessage(DisplayDialog, (displayDialog) => {
update((state) => {
state.visible = true; state.visible = true;
state.icon = displayDialog.icon; state.icon = displayDialog.icon;
return state;
});
}); });
editor.subscriptions.subscribeJsMessage(UpdateDialogDetails, (updateDialogDetails) => { editor.subscriptions.subscribeJsMessage(UpdateDialogDetails, (updateDialogDetails) => {
update((state) => {
patchWidgetLayout(state.widgets, updateDialogDetails); patchWidgetLayout(state.widgets, updateDialogDetails);
state.jsCallbackBasedButtons = undefined; state.jsCallbackBasedButtons = undefined;
return state;
});
}); });
editor.subscriptions.subscribeJsMessage(DisplayDialogDismiss, dismissDialog); editor.subscriptions.subscribeJsMessage(DisplayDialogDismiss, dismissDialog);
return { return {
state: readonly(state) as typeof state, subscribe,
dismissDialog, dismissDialog,
dialogIsVisible,
createPanicDialog, createPanicDialog,
}; };
} }
+45 -8
View File
@@ -1,9 +1,11 @@
import { nextTick, reactive, readonly } from "vue"; import {tick} from "svelte";
import {writable} from "svelte/store";
import { type Editor } from "@/wasm-communication/editor"; import { type Editor } from "@/wasm-communication/editor";
import { import {
defaultWidgetLayout, defaultWidgetLayout,
patchWidgetLayout, patchWidgetLayout,
TriggerRefreshBoundsOfViewports,
UpdateDocumentBarLayout, UpdateDocumentBarLayout,
UpdateDocumentModeLayout, UpdateDocumentModeLayout,
UpdateToolOptionsLayout, UpdateToolOptionsLayout,
@@ -13,7 +15,7 @@ import {
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export function createDocumentState(editor: Editor) { export function createDocumentState(editor: Editor) {
const state = reactive({ const state = writable({
// Layouts // Layouts
documentModeLayout: defaultWidgetLayout(), documentModeLayout: defaultWidgetLayout(),
toolOptionsLayout: defaultWidgetLayout(), toolOptionsLayout: defaultWidgetLayout(),
@@ -21,31 +23,66 @@ export function createDocumentState(editor: Editor) {
toolShelfLayout: defaultWidgetLayout(), toolShelfLayout: defaultWidgetLayout(),
workingColorsLayout: defaultWidgetLayout(), workingColorsLayout: defaultWidgetLayout(),
}); });
const { subscribe, update } = state;
// Update layouts // Update layouts
editor.subscriptions.subscribeJsMessage(UpdateDocumentModeLayout, async (updateDocumentModeLayout) => { editor.subscriptions.subscribeJsMessage(UpdateDocumentModeLayout, async (updateDocumentModeLayout) => {
await nextTick(); await tick();
update((state) => {
// `state.documentModeLayout` is mutated in the function
patchWidgetLayout(state.documentModeLayout, updateDocumentModeLayout); patchWidgetLayout(state.documentModeLayout, updateDocumentModeLayout);
return state;
});
}); });
editor.subscriptions.subscribeJsMessage(UpdateToolOptionsLayout, async (updateToolOptionsLayout) => { editor.subscriptions.subscribeJsMessage(UpdateToolOptionsLayout, async (updateToolOptionsLayout) => {
await nextTick(); await tick();
update((state) => {
// `state.documentModeLayout` is mutated in the function
patchWidgetLayout(state.toolOptionsLayout, updateToolOptionsLayout); patchWidgetLayout(state.toolOptionsLayout, updateToolOptionsLayout);
return state;
});
}); });
editor.subscriptions.subscribeJsMessage(UpdateDocumentBarLayout, async (updateDocumentBarLayout) => { editor.subscriptions.subscribeJsMessage(UpdateDocumentBarLayout, async (updateDocumentBarLayout) => {
await nextTick(); await tick();
update((state) => {
// `state.documentModeLayout` is mutated in the function
patchWidgetLayout(state.documentBarLayout, updateDocumentBarLayout); patchWidgetLayout(state.documentBarLayout, updateDocumentBarLayout);
return state;
});
}); });
editor.subscriptions.subscribeJsMessage(UpdateToolShelfLayout, async (updateToolShelfLayout) => { editor.subscriptions.subscribeJsMessage(UpdateToolShelfLayout, async (updateToolShelfLayout) => {
await nextTick(); await tick();
update((state) => {
// `state.documentModeLayout` is mutated in the function
patchWidgetLayout(state.toolShelfLayout, updateToolShelfLayout); patchWidgetLayout(state.toolShelfLayout, updateToolShelfLayout);
return state;
});
}); });
editor.subscriptions.subscribeJsMessage(UpdateWorkingColorsLayout, async (updateWorkingColorsLayout) => { editor.subscriptions.subscribeJsMessage(UpdateWorkingColorsLayout, async (updateWorkingColorsLayout) => {
await nextTick(); await tick();
update((state) => {
// `state.documentModeLayout` is mutated in the function
patchWidgetLayout(state.workingColorsLayout, updateWorkingColorsLayout); patchWidgetLayout(state.workingColorsLayout, updateWorkingColorsLayout);
return state;
});
});
editor.subscriptions.subscribeJsMessage(TriggerRefreshBoundsOfViewports, async () => {
// Wait to display the unpopulated document panel (missing: tools, options bar content, scrollbar positioning, and canvas)
await tick();
// Wait to display the populated document panel
await tick();
// Request a resize event so the viewport gets measured now that the canvas is populated and positioned correctly
window.dispatchEvent(new CustomEvent("resize"));
}); });
return { return {
state: readonly(state) as typeof state, subscribe,
}; };
} }
export type DocumentState = ReturnType<typeof createDocumentState>; export type DocumentState = ReturnType<typeof createDocumentState>;
+9 -3
View File
@@ -1,11 +1,12 @@
import { reactive } from "vue"; import { writable } from "svelte/store";
import { type Editor } from "@/wasm-communication/editor"; import { type Editor } from "@/wasm-communication/editor";
import { TriggerFontLoad } from "@/wasm-communication/messages"; import { TriggerFontLoad } from "@/wasm-communication/messages";
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export function createFontsState(editor: Editor) { export function createFontsState(editor: Editor) {
const state = reactive({}); // TODO: Do some code cleanup to remove the need for this empty store
const { subscribe } = writable({});
function createURL(font: string): URL { function createURL(font: string): URL {
const url = new URL("https://fonts.googleapis.com/css2"); const url = new URL("https://fonts.googleapis.com/css2");
@@ -73,7 +74,12 @@ export function createFontsState(editor: Editor) {
}); });
}); });
return { state, fontNames, getFontStyles, getFontFileUrl }; return {
subscribe,
fontNames,
getFontStyles,
getFontFileUrl,
};
} }
export type FontsState = ReturnType<typeof createFontsState>; export type FontsState = ReturnType<typeof createFontsState>;
+18 -5
View File
@@ -1,17 +1,20 @@
import { reactive, readonly } from "vue"; import {writable} from "svelte/store";
import { type Editor } from "@/wasm-communication/editor"; import { type Editor } from "@/wasm-communication/editor";
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export function createFullscreenState(_: Editor) { export function createFullscreenState(_: Editor) {
const state = reactive({ const { subscribe, update } = writable({
windowFullscreen: false, windowFullscreen: false,
keyboardLocked: false, keyboardLocked: false,
}); });
function fullscreenModeChanged(): void { function fullscreenModeChanged(): void {
update((state) => {
state.windowFullscreen = Boolean(document.fullscreenElement); state.windowFullscreen = Boolean(document.fullscreenElement);
if (!state.windowFullscreen) state.keyboardLocked = false; if (!state.windowFullscreen) state.keyboardLocked = false;
return state;
});
} }
async function enterFullscreen(): Promise<void> { async function enterFullscreen(): Promise<void> {
@@ -20,7 +23,11 @@ export function createFullscreenState(_: Editor) {
if (keyboardLockApiSupported) { if (keyboardLockApiSupported) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
await (navigator as any).keyboard.lock(["ControlLeft", "ControlRight"]); await (navigator as any).keyboard.lock(["ControlLeft", "ControlRight"]);
update((state) => {
state.keyboardLocked = true; state.keyboardLocked = true;
return state;
});
} }
} }
@@ -29,8 +36,14 @@ export function createFullscreenState(_: Editor) {
} }
async function toggleFullscreen(): Promise<void> { async function toggleFullscreen(): Promise<void> {
if (state.windowFullscreen) await exitFullscreen(); return new Promise((resolve, reject) => {
else await enterFullscreen(); update((state) => {
if (state.windowFullscreen) exitFullscreen().then(resolve).catch(reject);
else enterFullscreen().then(resolve).catch(reject);
return state;
});
});
} }
// Experimental Keyboard API: https://developer.mozilla.org/en-US/docs/Web/API/Navigator/keyboard // Experimental Keyboard API: https://developer.mozilla.org/en-US/docs/Web/API/Navigator/keyboard
@@ -38,7 +51,7 @@ export function createFullscreenState(_: Editor) {
const keyboardLockApiSupported: Readonly<boolean> = "keyboard" in navigator && (navigator as any).keyboard && "lock" in (navigator as any).keyboard; const keyboardLockApiSupported: Readonly<boolean> = "keyboard" in navigator && (navigator as any).keyboard && "lock" in (navigator as any).keyboard;
return { return {
state: readonly(state) as typeof state, subscribe,
fullscreenModeChanged, fullscreenModeChanged,
enterFullscreen, enterFullscreen,
exitFullscreen, exitFullscreen,
+16 -3
View File
@@ -1,4 +1,5 @@
import { reactive, readonly } from "vue"; import {tick} from "svelte";
import {writable} from "svelte/store";
import { type Editor } from "@/wasm-communication/editor"; import { type Editor } from "@/wasm-communication/editor";
import { import {
@@ -15,7 +16,7 @@ import {
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export function createNodeGraphState(editor: Editor) { export function createNodeGraphState(editor: Editor) {
const state = reactive({ const { subscribe, update } = writable({
nodes: [] as FrontendNode[], nodes: [] as FrontendNode[],
links: [] as FrontendNodeLink[], links: [] as FrontendNodeLink[],
nodeTypes: [] as FrontendNodeType[], nodeTypes: [] as FrontendNodeType[],
@@ -25,21 +26,33 @@ export function createNodeGraphState(editor: Editor) {
// Set up message subscriptions on creation // Set up message subscriptions on creation
editor.subscriptions.subscribeJsMessage(UpdateNodeGraph, (updateNodeGraph) => { editor.subscriptions.subscribeJsMessage(UpdateNodeGraph, (updateNodeGraph) => {
update((state) => {
state.nodes = updateNodeGraph.nodes; state.nodes = updateNodeGraph.nodes;
state.links = updateNodeGraph.links; state.links = updateNodeGraph.links;
return state;
});
}); });
editor.subscriptions.subscribeJsMessage(UpdateNodeTypes, (updateNodeTypes) => { editor.subscriptions.subscribeJsMessage(UpdateNodeTypes, (updateNodeTypes) => {
update((state) => {
state.nodeTypes = updateNodeTypes.nodeTypes; state.nodeTypes = updateNodeTypes.nodeTypes;
return state;
});
}); });
editor.subscriptions.subscribeJsMessage(UpdateNodeGraphBarLayout, (updateNodeGraphBarLayout) => { editor.subscriptions.subscribeJsMessage(UpdateNodeGraphBarLayout, (updateNodeGraphBarLayout) => {
update((state) => {
patchWidgetLayout(state.nodeGraphBarLayout, updateNodeGraphBarLayout); patchWidgetLayout(state.nodeGraphBarLayout, updateNodeGraphBarLayout);
return state;
});
}); });
editor.subscriptions.subscribeJsMessage(UpdateZoomWithScroll, (updateZoomWithScroll) => { editor.subscriptions.subscribeJsMessage(UpdateZoomWithScroll, (updateZoomWithScroll) => {
update((state) => {
state.zoomWithScroll = updateZoomWithScroll.zoomWithScroll; state.zoomWithScroll = updateZoomWithScroll.zoomWithScroll;
return state;
});
}); });
return { return {
state: readonly(state) as typeof state, subscribe,
}; };
} }
export type NodeGraphState = ReturnType<typeof createNodeGraphState>; export type NodeGraphState = ReturnType<typeof createNodeGraphState>;
-114
View File
@@ -1,114 +0,0 @@
import { nextTick, reactive, readonly } from "vue";
import { type Editor } from "@/wasm-communication/editor";
import {
DisplayEditableTextbox,
DisplayRemoveEditableTextbox,
TriggerRefreshBoundsOfViewports,
TriggerTextCommit,
TriggerViewportResize,
UpdateDocumentArtboards,
UpdateDocumentArtwork,
UpdateDocumentOverlays,
UpdateDocumentRulers,
UpdateDocumentScrollbars,
UpdateEyedropperSamplingState,
UpdateMouseCursor,
} from "@/wasm-communication/messages";
import DocumentComponent from "@/components/panels/Document.vue";
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export function createPanelsState(editor: Editor) {
const state = reactive({
documentPanel: DocumentComponent,
});
// We use `any` instead of `typeof DocumentComponent` as a workaround for the fact that calling this function with the `this` argument from within `Document.vue` isn't a compatible type
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function registerPanel(type: string, panelComponent: any): void {
state.documentPanel = panelComponent;
}
function subscribeDocumentPanel(): void {
// Update rendered SVGs
editor.subscriptions.subscribeJsMessage(UpdateDocumentArtwork, async (updateDocumentArtwork) => {
await nextTick();
state.documentPanel.updateDocumentArtwork(updateDocumentArtwork.svg);
});
editor.subscriptions.subscribeJsMessage(UpdateDocumentOverlays, async (updateDocumentOverlays) => {
await nextTick();
state.documentPanel.updateDocumentOverlays(updateDocumentOverlays.svg);
});
editor.subscriptions.subscribeJsMessage(UpdateDocumentArtboards, async (updateDocumentArtboards) => {
await nextTick();
state.documentPanel.updateDocumentArtboards(updateDocumentArtboards.svg);
});
editor.subscriptions.subscribeJsMessage(UpdateEyedropperSamplingState, async (updateEyedropperSamplingState) => {
await nextTick();
const { mousePosition, primaryColor, secondaryColor, setColorChoice } = updateEyedropperSamplingState;
const rgb = (await state.documentPanel.updateEyedropperSamplingState(mousePosition, primaryColor, secondaryColor)) as [number, number, number] | undefined;
if (setColorChoice && rgb) {
if (setColorChoice === "Primary") editor.instance.updatePrimaryColor(...rgb, 1);
if (setColorChoice === "Secondary") editor.instance.updateSecondaryColor(...rgb, 1);
}
});
// Update scrollbars and rulers
editor.subscriptions.subscribeJsMessage(UpdateDocumentScrollbars, async (updateDocumentScrollbars) => {
await nextTick();
const { position, size, multiplier } = updateDocumentScrollbars;
state.documentPanel.updateDocumentScrollbars(position, size, multiplier);
});
editor.subscriptions.subscribeJsMessage(UpdateDocumentRulers, async (updateDocumentRulers) => {
await nextTick();
const { origin, spacing, interval } = updateDocumentRulers;
state.documentPanel.updateDocumentRulers(origin, spacing, interval);
});
// Update mouse cursor icon
editor.subscriptions.subscribeJsMessage(UpdateMouseCursor, async (updateMouseCursor) => {
await nextTick();
const { cursor } = updateMouseCursor;
state.documentPanel.updateMouseCursor(cursor);
});
// Text entry
editor.subscriptions.subscribeJsMessage(TriggerTextCommit, async () => {
await nextTick();
state.documentPanel.triggerTextCommit();
});
editor.subscriptions.subscribeJsMessage(DisplayEditableTextbox, async (displayEditableTextbox) => {
await nextTick();
state.documentPanel.displayEditableTextbox(displayEditableTextbox);
});
editor.subscriptions.subscribeJsMessage(DisplayRemoveEditableTextbox, async () => {
await nextTick();
state.documentPanel.displayRemoveEditableTextbox();
});
// Resize elements to render the new viewport size
editor.subscriptions.subscribeJsMessage(TriggerViewportResize, async () => {
await nextTick();
state.documentPanel.viewportResize();
});
editor.subscriptions.subscribeJsMessage(TriggerRefreshBoundsOfViewports, async () => {
// Wait to display the unpopulated document panel (missing: tools, options bar content, scrollbar positioning, and canvas)
await nextTick();
// Wait to display the populated document panel
await nextTick();
// Request a resize event so the viewport gets measured now that the canvas is populated and positioned correctly
window.dispatchEvent(new CustomEvent("resize"));
});
}
subscribeDocumentPanel();
return {
state: readonly(state) as typeof state,
registerPanel,
};
}
export type PanelsState = ReturnType<typeof createPanelsState>;
+10 -3
View File
@@ -1,5 +1,6 @@
/* eslint-disable max-classes-per-file */ /* eslint-disable max-classes-per-file */
import { reactive, readonly } from "vue";
import {writable} from "svelte/store";
import { downloadFileText, downloadFileBlob, upload } from "@/utility-functions/files"; import { downloadFileText, downloadFileBlob, upload } from "@/utility-functions/files";
import { imaginateGenerate, imaginateCheckConnection, imaginateTerminate, updateBackendImage } from "@/utility-functions/imaginate"; import { imaginateGenerate, imaginateCheckConnection, imaginateTerminate, updateBackendImage } from "@/utility-functions/imaginate";
@@ -23,7 +24,7 @@ import {
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export function createPortfolioState(editor: Editor) { export function createPortfolioState(editor: Editor) {
const state = reactive({ const { subscribe, update } = writable({
unsaved: false, unsaved: false,
documents: [] as FrontendDocumentDetails[], documents: [] as FrontendDocumentDetails[],
activeDocumentIndex: 0, activeDocumentIndex: 0,
@@ -31,12 +32,18 @@ export function createPortfolioState(editor: Editor) {
// Set up message subscriptions on creation // Set up message subscriptions on creation
editor.subscriptions.subscribeJsMessage(UpdateOpenDocumentsList, (updateOpenDocumentList) => { editor.subscriptions.subscribeJsMessage(UpdateOpenDocumentsList, (updateOpenDocumentList) => {
update((state) => {
state.documents = updateOpenDocumentList.openDocuments; state.documents = updateOpenDocumentList.openDocuments;
return state;
})
}); });
editor.subscriptions.subscribeJsMessage(UpdateActiveDocument, (updateActiveDocument) => { editor.subscriptions.subscribeJsMessage(UpdateActiveDocument, (updateActiveDocument) => {
update((state) => {
// Assume we receive a correct document id // Assume we receive a correct document id
const activeId = state.documents.findIndex((doc) => doc.id === updateActiveDocument.documentId); const activeId = state.documents.findIndex((doc) => doc.id === updateActiveDocument.documentId);
state.activeDocumentIndex = activeId; state.activeDocumentIndex = activeId;
return state;
})
}); });
editor.subscriptions.subscribeJsMessage(TriggerOpenDocument, async () => { editor.subscriptions.subscribeJsMessage(TriggerOpenDocument, async () => {
const extension = editor.instance.fileSaveSuffix(); const extension = editor.instance.fileSaveSuffix();
@@ -122,7 +129,7 @@ export function createPortfolioState(editor: Editor) {
}); });
return { return {
state: readonly(state) as typeof state, subscribe,
}; };
} }
export type PortfolioState = ReturnType<typeof createPortfolioState>; export type PortfolioState = ReturnType<typeof createPortfolioState>;
+10 -4
View File
@@ -1,25 +1,31 @@
/* eslint-disable max-classes-per-file */ /* eslint-disable max-classes-per-file */
import { nextTick, reactive, readonly } from "vue";
import {tick} from "svelte";
import {writable} from "svelte/store";
import { type Editor } from "@/wasm-communication/editor"; import { type Editor } from "@/wasm-communication/editor";
import { UpdateNodeGraphVisibility } from "@/wasm-communication/messages"; import { UpdateNodeGraphVisibility } from "@/wasm-communication/messages";
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export function createWorkspaceState(editor: Editor) { export function createWorkspaceState(editor: Editor) {
const state = reactive({ const { subscribe, update } = writable({
nodeGraphVisible: false, nodeGraphVisible: false,
}); });
// Set up message subscriptions on creation // Set up message subscriptions on creation
editor.subscriptions.subscribeJsMessage(UpdateNodeGraphVisibility, async (updateNodeGraphVisibility) => { editor.subscriptions.subscribeJsMessage(UpdateNodeGraphVisibility, async (updateNodeGraphVisibility) => {
update((state) => {
state.nodeGraphVisible = updateNodeGraphVisibility.visible; state.nodeGraphVisible = updateNodeGraphVisibility.visible;
return state;
});
// Update the viewport bounds // Update the viewport bounds
await nextTick(); await tick();
window.dispatchEvent(new Event("resize")); window.dispatchEvent(new Event("resize"));
}); });
return { return {
state: readonly(state) as typeof state, subscribe,
}; };
} }
export type WorkspaceState = ReturnType<typeof createWorkspaceState>; export type WorkspaceState = ReturnType<typeof createWorkspaceState>;
-11
View File
@@ -1,11 +0,0 @@
// Allow JS import statements to work with .vue files
declare module "*.vue" {
const component: DefineComponent;
export default component;
}
// Allow JS import statements to work with .svg files
declare module "*.svg" {
const component: DefineComponent;
export default component;
}
+126 -128
View File
@@ -4,7 +4,7 @@
import GraphiteLogotypeSolid from "@/../assets/graphics/graphite-logotype-solid.svg"; import GraphiteLogotypeSolid from "@/../assets/graphics/graphite-logotype-solid.svg";
const GRAPHICS = { const GRAPHICS = {
GraphiteLogotypeSolid: { component: GraphiteLogotypeSolid, size: undefined }, GraphiteLogotypeSolid: { svg: GraphiteLogotypeSolid, size: undefined },
} as const; } as const;
// 12px Solid // 12px Solid
@@ -44,40 +44,40 @@ import WindowButtonWinMinimize from "@/../assets/icon-12px-solid/window-button-w
import WindowButtonWinRestoreDown from "@/../assets/icon-12px-solid/window-button-win-restore-down.svg"; import WindowButtonWinRestoreDown from "@/../assets/icon-12px-solid/window-button-win-restore-down.svg";
const SOLID_12PX = { const SOLID_12PX = {
Add: { component: Add, size: 12 }, Add: { svg: Add, size: 12 },
Checkmark: { component: Checkmark, size: 12 }, Checkmark: { svg: Checkmark, size: 12 },
CloseX: { component: CloseX, size: 12 }, CloseX: { svg: CloseX, size: 12 },
DropdownArrow: { component: DropdownArrow, size: 12 }, DropdownArrow: { svg: DropdownArrow, size: 12 },
Edit: { component: Edit, size: 12 }, Edit: { svg: Edit, size: 12 },
Empty12px: { component: Empty12px, size: 12 }, Empty12px: { svg: Empty12px, size: 12 },
FullscreenEnter: { component: FullscreenEnter, size: 12 }, FullscreenEnter: { svg: FullscreenEnter, size: 12 },
FullscreenExit: { component: FullscreenExit, size: 12 }, FullscreenExit: { svg: FullscreenExit, size: 12 },
Grid: { component: Grid, size: 12 }, Grid: { svg: Grid, size: 12 },
Info: { component: Info, size: 12 }, Info: { svg: Info, size: 12 },
KeyboardArrowDown: { component: KeyboardArrowDown, size: 12 }, KeyboardArrowDown: { svg: KeyboardArrowDown, size: 12 },
KeyboardArrowLeft: { component: KeyboardArrowLeft, size: 12 }, KeyboardArrowLeft: { svg: KeyboardArrowLeft, size: 12 },
KeyboardArrowRight: { component: KeyboardArrowRight, size: 12 }, KeyboardArrowRight: { svg: KeyboardArrowRight, size: 12 },
KeyboardArrowUp: { component: KeyboardArrowUp, size: 12 }, KeyboardArrowUp: { svg: KeyboardArrowUp, size: 12 },
KeyboardBackspace: { component: KeyboardBackspace, size: 12 }, KeyboardBackspace: { svg: KeyboardBackspace, size: 12 },
KeyboardCommand: { component: KeyboardCommand, size: 12 }, KeyboardCommand: { svg: KeyboardCommand, size: 12 },
KeyboardControl: { component: KeyboardControl, size: 12 }, KeyboardControl: { svg: KeyboardControl, size: 12 },
KeyboardEnter: { component: KeyboardEnter, size: 12 }, KeyboardEnter: { svg: KeyboardEnter, size: 12 },
KeyboardOption: { component: KeyboardOption, size: 12 }, KeyboardOption: { svg: KeyboardOption, size: 12 },
KeyboardShift: { component: KeyboardShift, size: 12 }, KeyboardShift: { svg: KeyboardShift, size: 12 },
KeyboardSpace: { component: KeyboardSpace, size: 12 }, KeyboardSpace: { svg: KeyboardSpace, size: 12 },
KeyboardTab: { component: KeyboardTab, size: 12 }, KeyboardTab: { svg: KeyboardTab, size: 12 },
Link: { component: Link, size: 12 }, Link: { svg: Link, size: 12 },
Overlays: { component: Overlays, size: 12 }, Overlays: { svg: Overlays, size: 12 },
Remove: { component: Remove, size: 12 }, Remove: { svg: Remove, size: 12 },
ResetColors: { component: ResetColors, size: 12 }, ResetColors: { svg: ResetColors, size: 12 },
Snapping: { component: Snapping, size: 12 }, Snapping: { svg: Snapping, size: 12 },
Swap: { component: Swap, size: 12 }, Swap: { svg: Swap, size: 12 },
VerticalEllipsis: { component: VerticalEllipsis, size: 12 }, VerticalEllipsis: { svg: VerticalEllipsis, size: 12 },
Warning: { component: Warning, size: 12 }, Warning: { svg: Warning, size: 12 },
WindowButtonWinClose: { component: WindowButtonWinClose, size: 12 }, WindowButtonWinClose: { svg: WindowButtonWinClose, size: 12 },
WindowButtonWinMaximize: { component: WindowButtonWinMaximize, size: 12 }, WindowButtonWinMaximize: { svg: WindowButtonWinMaximize, size: 12 },
WindowButtonWinMinimize: { component: WindowButtonWinMinimize, size: 12 }, WindowButtonWinMinimize: { svg: WindowButtonWinMinimize, size: 12 },
WindowButtonWinRestoreDown: { component: WindowButtonWinRestoreDown, size: 12 }, WindowButtonWinRestoreDown: { svg: WindowButtonWinRestoreDown, size: 12 },
} as const; } as const;
// 16px Solid // 16px Solid
@@ -138,61 +138,61 @@ import ZoomOut from "@/../assets/icon-16px-solid/zoom-out.svg";
import ZoomReset from "@/../assets/icon-16px-solid/zoom-reset.svg"; import ZoomReset from "@/../assets/icon-16px-solid/zoom-reset.svg";
const SOLID_16PX = { const SOLID_16PX = {
AlignBottom: { component: AlignBottom, size: 16 }, AlignBottom: { svg: AlignBottom, size: 16 },
AlignHorizontalCenter: { component: AlignHorizontalCenter, size: 16 }, AlignHorizontalCenter: { svg: AlignHorizontalCenter, size: 16 },
AlignLeft: { component: AlignLeft, size: 16 }, AlignLeft: { svg: AlignLeft, size: 16 },
AlignRight: { component: AlignRight, size: 16 }, AlignRight: { svg: AlignRight, size: 16 },
AlignTop: { component: AlignTop, size: 16 }, AlignTop: { svg: AlignTop, size: 16 },
AlignVerticalCenter: { component: AlignVerticalCenter, size: 16 }, AlignVerticalCenter: { svg: AlignVerticalCenter, size: 16 },
BooleanDifference: { component: BooleanDifference, size: 16 }, BooleanDifference: { svg: BooleanDifference, size: 16 },
BooleanIntersect: { component: BooleanIntersect, size: 16 }, BooleanIntersect: { svg: BooleanIntersect, size: 16 },
BooleanSubtractBack: { component: BooleanSubtractBack, size: 16 }, BooleanSubtractBack: { svg: BooleanSubtractBack, size: 16 },
BooleanSubtractFront: { component: BooleanSubtractFront, size: 16 }, BooleanSubtractFront: { svg: BooleanSubtractFront, size: 16 },
BooleanUnion: { component: BooleanUnion, size: 16 }, BooleanUnion: { svg: BooleanUnion, size: 16 },
CheckboxChecked: { component: CheckboxChecked, size: 16 }, CheckboxChecked: { svg: CheckboxChecked, size: 16 },
CheckboxUnchecked: { component: CheckboxUnchecked, size: 16 }, CheckboxUnchecked: { svg: CheckboxUnchecked, size: 16 },
Copy: { component: Copy, size: 16 }, Copy: { svg: Copy, size: 16 },
Eyedropper: { component: Eyedropper, size: 16 }, Eyedropper: { svg: Eyedropper, size: 16 },
EyeHidden: { component: EyeHidden, size: 16 }, EyeHidden: { svg: EyeHidden, size: 16 },
EyeVisible: { component: EyeVisible, size: 16 }, EyeVisible: { svg: EyeVisible, size: 16 },
File: { component: File, size: 16 }, File: { svg: File, size: 16 },
FlipHorizontal: { component: FlipHorizontal, size: 16 }, FlipHorizontal: { svg: FlipHorizontal, size: 16 },
FlipVertical: { component: FlipVertical, size: 16 }, FlipVertical: { svg: FlipVertical, size: 16 },
Folder: { component: Folder, size: 16 }, Folder: { svg: Folder, size: 16 },
GraphiteLogo: { component: GraphiteLogo, size: 16 }, GraphiteLogo: { svg: GraphiteLogo, size: 16 },
NodeArtboard: { component: NodeArtboard, size: 16 }, NodeArtboard: { svg: NodeArtboard, size: 16 },
NodeBlur: { component: NodeBlur, size: 16 }, NodeBlur: { svg: NodeBlur, size: 16 },
NodeBrushwork: { component: NodeBrushwork, size: 16 }, NodeBrushwork: { svg: NodeBrushwork, size: 16 },
NodeColorCorrection: { component: NodeColorCorrection, size: 16 }, NodeColorCorrection: { svg: NodeColorCorrection, size: 16 },
NodeFolder: { component: NodeFolder, size: 16 }, NodeFolder: { svg: NodeFolder, size: 16 },
NodeGradient: { component: NodeGradient, size: 16 }, NodeGradient: { svg: NodeGradient, size: 16 },
NodeImage: { component: NodeImage, size: 16 }, NodeImage: { svg: NodeImage, size: 16 },
NodeImaginate: { component: NodeImaginate, size: 16 }, NodeImaginate: { svg: NodeImaginate, size: 16 },
NodeMagicWand: { component: NodeMagicWand, size: 16 }, NodeMagicWand: { svg: NodeMagicWand, size: 16 },
NodeMask: { component: NodeMask, size: 16 }, NodeMask: { svg: NodeMask, size: 16 },
NodeMotionBlur: { component: NodeMotionBlur, size: 16 }, NodeMotionBlur: { svg: NodeMotionBlur, size: 16 },
NodeNodes: { component: NodeNodes, size: 16 }, NodeNodes: { svg: NodeNodes, size: 16 },
NodeOutput: { component: NodeOutput, size: 16 }, NodeOutput: { svg: NodeOutput, size: 16 },
NodeShape: { component: NodeShape, size: 16 }, NodeShape: { svg: NodeShape, size: 16 },
NodeText: { component: NodeText, size: 16 }, NodeText: { svg: NodeText, size: 16 },
NodeTransform: { component: NodeTransform, size: 16 }, NodeTransform: { svg: NodeTransform, size: 16 },
Paste: { component: Paste, size: 16 }, Paste: { svg: Paste, size: 16 },
Random: { component: Random, size: 16 }, Random: { svg: Random, size: 16 },
Regenerate: { component: Regenerate, size: 16 }, Regenerate: { svg: Regenerate, size: 16 },
Reload: { component: Reload, size: 16 }, Reload: { svg: Reload, size: 16 },
Rescale: { component: Rescale, size: 16 }, Rescale: { svg: Rescale, size: 16 },
Reset: { component: Reset, size: 16 }, Reset: { svg: Reset, size: 16 },
Settings: { component: Settings, size: 16 }, Settings: { svg: Settings, size: 16 },
Trash: { component: Trash, size: 16 }, Trash: { svg: Trash, size: 16 },
ViewModeNormal: { component: ViewModeNormal, size: 16 }, ViewModeNormal: { svg: ViewModeNormal, size: 16 },
ViewModeOutline: { component: ViewModeOutline, size: 16 }, ViewModeOutline: { svg: ViewModeOutline, size: 16 },
ViewModePixels: { component: ViewModePixels, size: 16 }, ViewModePixels: { svg: ViewModePixels, size: 16 },
ViewportDesignMode: { component: ViewportDesignMode, size: 16 }, ViewportDesignMode: { svg: ViewportDesignMode, size: 16 },
ViewportGuideMode: { component: ViewportGuideMode, size: 16 }, ViewportGuideMode: { svg: ViewportGuideMode, size: 16 },
ViewportSelectMode: { component: ViewportSelectMode, size: 16 }, ViewportSelectMode: { svg: ViewportSelectMode, size: 16 },
ZoomIn: { component: ZoomIn, size: 16 }, ZoomIn: { svg: ZoomIn, size: 16 },
ZoomOut: { component: ZoomOut, size: 16 }, ZoomOut: { svg: ZoomOut, size: 16 },
ZoomReset: { component: ZoomReset, size: 16 }, ZoomReset: { svg: ZoomReset, size: 16 },
} as const; } as const;
// 16px Two-Tone // 16px Two-Tone
@@ -203,25 +203,23 @@ import MouseHintLmb from "@/../assets/icon-16px-two-tone/mouse-hint-lmb.svg";
import MouseHintMmbDrag from "@/../assets/icon-16px-two-tone/mouse-hint-mmb-drag.svg"; import MouseHintMmbDrag from "@/../assets/icon-16px-two-tone/mouse-hint-mmb-drag.svg";
import MouseHintMmb from "@/../assets/icon-16px-two-tone/mouse-hint-mmb.svg"; import MouseHintMmb from "@/../assets/icon-16px-two-tone/mouse-hint-mmb.svg";
import MouseHintNone from "@/../assets/icon-16px-two-tone/mouse-hint-none.svg"; import MouseHintNone from "@/../assets/icon-16px-two-tone/mouse-hint-none.svg";
import MouseHintRmbDouble from "@/../assets/icon-16px-two-tone/mouse-hint-rmb-double.svg";
import MouseHintRmbDrag from "@/../assets/icon-16px-two-tone/mouse-hint-rmb-drag.svg"; import MouseHintRmbDrag from "@/../assets/icon-16px-two-tone/mouse-hint-rmb-drag.svg";
import MouseHintRmb from "@/../assets/icon-16px-two-tone/mouse-hint-rmb.svg"; import MouseHintRmb from "@/../assets/icon-16px-two-tone/mouse-hint-rmb.svg";
import MouseHintScrollDown from "@/../assets/icon-16px-two-tone/mouse-hint-scroll-down.svg"; import MouseHintScrollDown from "@/../assets/icon-16px-two-tone/mouse-hint-scroll-down.svg";
import MouseHintScrollUp from "@/../assets/icon-16px-two-tone/mouse-hint-scroll-up.svg"; import MouseHintScrollUp from "@/../assets/icon-16px-two-tone/mouse-hint-scroll-up.svg";
const TWO_TONE_16PX = { const TWO_TONE_16PX = {
MouseHintDrag: { component: MouseHintDrag, size: 16 }, MouseHintDrag: { svg: MouseHintDrag, size: 16 },
MouseHintLmb: { component: MouseHintLmb, size: 16 }, MouseHintLmb: { svg: MouseHintLmb, size: 16 },
MouseHintLmbDouble: { component: MouseHintLmbDouble, size: 16 }, MouseHintLmbDouble: { svg: MouseHintLmbDouble, size: 16 },
MouseHintLmbDrag: { component: MouseHintLmbDrag, size: 16 }, MouseHintLmbDrag: { svg: MouseHintLmbDrag, size: 16 },
MouseHintMmb: { component: MouseHintMmb, size: 16 }, MouseHintMmb: { svg: MouseHintMmb, size: 16 },
MouseHintMmbDrag: { component: MouseHintMmbDrag, size: 16 }, MouseHintMmbDrag: { svg: MouseHintMmbDrag, size: 16 },
MouseHintNone: { component: MouseHintNone, size: 16 }, MouseHintNone: { svg: MouseHintNone, size: 16 },
MouseHintRmb: { component: MouseHintRmb, size: 16 }, MouseHintRmb: { svg: MouseHintRmb, size: 16 },
MouseHintRmbDouble: { component: MouseHintRmbDouble, size: 16 }, MouseHintRmbDrag: { svg: MouseHintRmbDrag, size: 16 },
MouseHintRmbDrag: { component: MouseHintRmbDrag, size: 16 }, MouseHintScrollDown: { svg: MouseHintScrollDown, size: 16 },
MouseHintScrollDown: { component: MouseHintScrollDown, size: 16 }, MouseHintScrollUp: { svg: MouseHintScrollUp, size: 16 },
MouseHintScrollUp: { component: MouseHintScrollUp, size: 16 },
} as const; } as const;
// 24px Two-Tone // 24px Two-Tone
@@ -250,29 +248,29 @@ import VectorSplineTool from "@/../assets/icon-24px-two-tone/vector-spline-tool.
import VectorTextTool from "@/../assets/icon-24px-two-tone/vector-text-tool.svg"; import VectorTextTool from "@/../assets/icon-24px-two-tone/vector-text-tool.svg";
const TWO_TONE_24PX = { const TWO_TONE_24PX = {
GeneralArtboardTool: { component: GeneralArtboardTool, size: 24 }, GeneralArtboardTool: { svg: GeneralArtboardTool, size: 24 },
GeneralEyedropperTool: { component: GeneralEyedropperTool, size: 24 }, GeneralEyedropperTool: { svg: GeneralEyedropperTool, size: 24 },
GeneralFillTool: { component: GeneralFillTool, size: 24 }, GeneralFillTool: { svg: GeneralFillTool, size: 24 },
GeneralGradientTool: { component: GeneralGradientTool, size: 24 }, GeneralGradientTool: { svg: GeneralGradientTool, size: 24 },
GeneralNavigateTool: { component: GeneralNavigateTool, size: 24 }, GeneralNavigateTool: { svg: GeneralNavigateTool, size: 24 },
GeneralSelectTool: { component: GeneralSelectTool, size: 24 }, GeneralSelectTool: { svg: GeneralSelectTool, size: 24 },
RasterImaginateTool: { component: RasterImaginateTool, size: 24 }, RasterImaginateTool: { svg: RasterImaginateTool, size: 24 },
RasterNodesTool: { component: RasterNodesTool, size: 24 }, RasterNodesTool: { svg: RasterNodesTool, size: 24 },
RasterBrushTool: { component: RasterBrushTool, size: 24 }, RasterBrushTool: { svg: RasterBrushTool, size: 24 },
RasterCloneTool: { component: RasterCloneTool, size: 24 }, RasterCloneTool: { svg: RasterCloneTool, size: 24 },
RasterDetailTool: { component: RasterDetailTool, size: 24 }, RasterDetailTool: { svg: RasterDetailTool, size: 24 },
RasterHealTool: { component: RasterHealTool, size: 24 }, RasterHealTool: { svg: RasterHealTool, size: 24 },
RasterPatchTool: { component: RasterPatchTool, size: 24 }, RasterPatchTool: { svg: RasterPatchTool, size: 24 },
RasterRelightTool: { component: RasterRelightTool, size: 24 }, RasterRelightTool: { svg: RasterRelightTool, size: 24 },
VectorEllipseTool: { component: VectorEllipseTool, size: 24 }, VectorEllipseTool: { svg: VectorEllipseTool, size: 24 },
VectorFreehandTool: { component: VectorFreehandTool, size: 24 }, VectorFreehandTool: { svg: VectorFreehandTool, size: 24 },
VectorLineTool: { component: VectorLineTool, size: 24 }, VectorLineTool: { svg: VectorLineTool, size: 24 },
VectorPathTool: { component: VectorPathTool, size: 24 }, VectorPathTool: { svg: VectorPathTool, size: 24 },
VectorPenTool: { component: VectorPenTool, size: 24 }, VectorPenTool: { svg: VectorPenTool, size: 24 },
VectorRectangleTool: { component: VectorRectangleTool, size: 24 }, VectorRectangleTool: { svg: VectorRectangleTool, size: 24 },
VectorShapeTool: { component: VectorShapeTool, size: 24 }, VectorShapeTool: { svg: VectorShapeTool, size: 24 },
VectorSplineTool: { component: VectorSplineTool, size: 24 }, VectorSplineTool: { svg: VectorSplineTool, size: 24 },
VectorTextTool: { component: VectorTextTool, size: 24 }, VectorTextTool: { svg: VectorTextTool, size: 24 },
} as const; } as const;
// All icons // All icons
@@ -286,7 +284,7 @@ const ICON_LIST = {
// Exported icons and types // Exported icons and types
export const ICONS: IconDefinitionType<typeof ICON_LIST> = ICON_LIST; export const ICONS: IconDefinitionType<typeof ICON_LIST> = ICON_LIST;
export const ICON_COMPONENTS = Object.fromEntries(Object.entries(ICONS).map(([name, data]) => [name, data.component])); export const ICON_SVG_STRINGS = Object.fromEntries(Object.entries(ICONS).map(([name, data]) => [name, data.svg]));
export type IconName = keyof typeof ICONS; export type IconName = keyof typeof ICONS;
export type IconSize = undefined | 12 | 16 | 24 | 32; export type IconSize = undefined | 12 | 16 | 24 | 32;
@@ -299,6 +297,6 @@ export type IconSize = undefined | 12 | 16 | 24 | 32;
// map of all its individual entries. Having the full list of entries lets us automatically set the `IconName` type to the union of strings that is the full // map of all its individual entries. Having the full list of entries lets us automatically set the `IconName` type to the union of strings that is the full
// list of keys. The result is that we don't have to maintain a separate list of icon names since this scheme infers it from the keys of the icon definitions. // list of keys. The result is that we don't have to maintain a separate list of icon names since this scheme infers it from the keys of the icon definitions.
// Based on https://stackoverflow.com/a/64119715/775283 // Based on https://stackoverflow.com/a/64119715/775283
type IconDefinition = { component: string; size: IconSize }; type IconDefinition = { svg: string; size: IconSize };
type EvaluateType<T> = T extends infer O ? { [K in keyof O]: O[K] } : never; type EvaluateType<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;
type IconDefinitionType<T extends Record<string, IconDefinition>> = EvaluateType<{ [key in keyof T]: IconDefinition }>; type IconDefinitionType<T extends Record<string, IconDefinition>> = EvaluateType<{ [key in keyof T]: IconDefinition }>;
+2 -1
View File
@@ -4,7 +4,8 @@
import { blobToBase64 } from "@/utility-functions/files"; import { blobToBase64 } from "@/utility-functions/files";
import { type RequestResult, requestWithUploadDownloadProgress } from "@/utility-functions/network"; import { type RequestResult, requestWithUploadDownloadProgress } from "@/utility-functions/network";
import { type Editor } from "@/wasm-communication/editor"; import { type Editor } from "@/wasm-communication/editor";
import { type ImaginateGenerationParameters, type XY } from "@/wasm-communication/messages"; import type { XY } from "@/wasm-communication/messages";
import { type ImaginateGenerationParameters } from "@/wasm-communication/messages";
const MAX_POLLING_RETRIES = 4; const MAX_POLLING_RETRIES = 4;
const SERVER_STATUS_CHECK_TIMEOUT = 5000; const SERVER_STATUS_CHECK_TIMEOUT = 5000;
@@ -52,3 +52,19 @@ export function operatingSystem(detailed = false): string {
export function platformIsMac(): boolean { export function platformIsMac(): boolean {
return operatingSystem() === "Mac"; return operatingSystem() === "Mac";
} }
export function isEventSupported(eventName: string) {
const onEventName = `on${eventName}`;
let tag = "div";
if (["select", "change"].includes(eventName)) tag = "select";
if (["submit", "reset"].includes(eventName)) tag = "form";
if (["error", "load", "abort"].includes(eventName)) tag = "img";
const element = document.createElement(tag);
if (onEventName in element) return true;
// Check if "return;" gets converted into a function, meaning the event is supported
element.setAttribute(eventName, "return;");
return typeof (element as Record<string, any>)[onEventName] === "function";
}
+10 -9
View File
@@ -1,5 +1,3 @@
import { invoke } from "@tauri-apps/api";
import type WasmBindgenPackage from "@/../wasm/pkg"; import type WasmBindgenPackage from "@/../wasm/pkg";
import { panicProxy } from "@/utility-functions/panic-proxy"; import { panicProxy } from "@/utility-functions/panic-proxy";
import { type JsMessageType } from "@/wasm-communication/messages"; import { type JsMessageType } from "@/wasm-communication/messages";
@@ -40,10 +38,12 @@ export async function fetchImage(path: BigUint64Array, mime: string, documentId:
editorInstance?.setImageBlobURL(documentId, path, blobURL, image.naturalWidth, image.naturalHeight); editorInstance?.setImageBlobURL(documentId, path, blobURL, image.naturalWidth, image.naturalHeight);
} }
// export async function dispatchTauri(message: string): Promise<string> { const tauri = "__TAURI_METADATA__" in window && import("@tauri-apps/api");
export async function dispatchTauri(message: unknown): Promise<void> { export async function dispatchTauri(message: unknown): Promise<void> {
if (!tauri) return;
try { try {
const response = await invoke("handle_message", { message }); const response = await (await tauri).invoke("handle_message", { message });
editorInstance?.tauriResponse(response); editorInstance?.tauriResponse(response);
} catch { } catch {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
@@ -64,11 +64,12 @@ export async function initWasm(): Promise<void> {
const randomSeedFloat = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER); const randomSeedFloat = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);
const randomSeed = BigInt(randomSeedFloat); const randomSeed = BigInt(randomSeedFloat);
wasmImport?.setRandomSeed(randomSeed); wasmImport?.setRandomSeed(randomSeed);
try { // TODO: Tauri: reenable this
await invoke("set_random_seed", { seed: randomSeedFloat }); // try {
} catch { // await invoke("set_random_seed", { seed: randomSeedFloat });
// Ignore errors // } catch {
} // // Ignore errors
// }
} }
// Should be called after running `initWasm()` and its promise resolving // Should be called after running `initWasm()` and its promise resolving
+23 -13
View File
@@ -5,7 +5,7 @@ import { Transform, Type, plainToClass } from "class-transformer";
import { type IconName, type IconSize } from "@/utility-functions/icons"; import { type IconName, type IconSize } from "@/utility-functions/icons";
import { type WasmEditorInstance, type WasmRawInstance } from "@/wasm-communication/editor"; import { type WasmEditorInstance, type WasmRawInstance } from "@/wasm-communication/editor";
import type MenuList from "@/components/floating-menus/MenuList.vue"; import type MenuList from "@/components/floating-menus/MenuList.svelte";
export class JsMessage { export class JsMessage {
// The marker provides a way to check if an object is a sub-class constructor for a jsMessage. // The marker provides a way to check if an object is a sub-class constructor for a jsMessage.
@@ -496,7 +496,7 @@ const mouseCursorIconCSSNames = {
Rotate: "custom-rotate", Rotate: "custom-rotate",
} as const; } as const;
export type MouseCursor = keyof typeof mouseCursorIconCSSNames; export type MouseCursor = keyof typeof mouseCursorIconCSSNames;
export type MouseCursorIcon = (typeof mouseCursorIconCSSNames)[MouseCursor]; export type MouseCursorIcon = typeof mouseCursorIconCSSNames[MouseCursor];
export class UpdateMouseCursor extends JsMessage { export class UpdateMouseCursor extends JsMessage {
@Transform(({ value }: { value: MouseCursor }) => mouseCursorIconCSSNames[value] || "alias") @Transform(({ value }: { value: MouseCursor }) => mouseCursorIconCSSNames[value] || "alias")
@@ -810,7 +810,7 @@ export class TriggerViewportResize extends JsMessage {}
// WIDGET PROPS // WIDGET PROPS
export abstract class WidgetProps { export abstract class WidgetProps {
kind!: string; kind!: WidgetPropsNames;
} }
export class CheckboxInput extends WidgetProps { export class CheckboxInput extends WidgetProps {
@@ -860,7 +860,7 @@ export type MenuListEntry = MenuEntryCommon & {
disabled?: boolean; disabled?: boolean;
tooltip?: string; tooltip?: string;
font?: URL; font?: URL;
ref?: InstanceType<typeof MenuList>; ref?: MenuList;
}; };
export class DropdownInput extends WidgetProps { export class DropdownInput extends WidgetProps {
@@ -993,7 +993,7 @@ export class OptionalInput extends WidgetProps {
} }
export class PopoverButton extends WidgetProps { export class PopoverButton extends WidgetProps {
icon!: string | undefined; icon!: IconName | undefined;
disabled!: boolean; disabled!: boolean;
@@ -1065,7 +1065,7 @@ export class ParameterExposeButton extends WidgetProps {
export class TextButton extends WidgetProps { export class TextButton extends WidgetProps {
label!: string; label!: string;
icon!: string | undefined; icon!: IconName | undefined;
emphasized!: boolean; emphasized!: boolean;
@@ -1096,7 +1096,7 @@ export type TextButtonWidget = {
}; };
export class BreadcrumbTrailButtons extends WidgetProps { export class BreadcrumbTrailButtons extends WidgetProps {
labels!: string; labels!: string[];
disabled!: boolean; disabled!: boolean;
@@ -1149,6 +1149,7 @@ export class PivotAssist extends WidgetProps {
// WIDGET // WIDGET
const widgetSubTypes = [ const widgetSubTypes = [
{ value: BreadcrumbTrailButtons, name: "BreadcrumbTrailButtons"},
{ value: CheckboxInput, name: "CheckboxInput" }, { value: CheckboxInput, name: "CheckboxInput" },
{ value: ColorInput, name: "ColorInput" }, { value: ColorInput, name: "ColorInput" },
{ value: DropdownInput, name: "DropdownInput" }, { value: DropdownInput, name: "DropdownInput" },
@@ -1158,18 +1159,27 @@ const widgetSubTypes = [
{ value: LayerReferenceInput, name: "LayerReferenceInput" }, { value: LayerReferenceInput, name: "LayerReferenceInput" },
{ value: NumberInput, name: "NumberInput" }, { value: NumberInput, name: "NumberInput" },
{ value: OptionalInput, name: "OptionalInput" }, { value: OptionalInput, name: "OptionalInput" },
{ value: ParameterExposeButton, name: "ParameterExposeButton" },
{ value: PivotAssist, name: "PivotAssist" },
{ value: PopoverButton, name: "PopoverButton" }, { value: PopoverButton, name: "PopoverButton" },
{ value: RadioInput, name: "RadioInput" }, { value: RadioInput, name: "RadioInput" },
{ value: Separator, name: "Separator" }, { value: Separator, name: "Separator" },
{ value: SwatchPairInput, name: "SwatchPairInput" }, { value: SwatchPairInput, name: "SwatchPairInput" },
{ value: TextAreaInput, name: "TextAreaInput" }, { value: TextAreaInput, name: "TextAreaInput" },
{ value: TextButton, name: "TextButton" }, { value: TextButton, name: "TextButton" },
{ value: ParameterExposeButton, name: "ParameterExposeButton" },
{ value: TextInput, name: "TextInput" }, { value: TextInput, name: "TextInput" },
{ value: TextLabel, name: "TextLabel" }, { value: TextLabel, name: "TextLabel" },
{ value: PivotAssist, name: "PivotAssist" }, ] as const;
];
export type WidgetPropsSet = InstanceType<(typeof widgetSubTypes)[number]["value"]>; type WidgetSubTypes = typeof widgetSubTypes[number];
type WidgetKindMap = { [T in WidgetSubTypes as T["name"]]: InstanceType<T["value"]> };
export type WidgetPropsNames = keyof WidgetKindMap;
export type WidgetPropsSet = WidgetKindMap[WidgetPropsNames];
export function narrowWidgetProps<K extends WidgetPropsNames>(props: WidgetPropsSet, kind: K) {
if (props.kind === kind) return props as WidgetKindMap[K]
else return undefined;
}
export class Widget { export class Widget {
constructor(props: WidgetPropsSet, widgetId: bigint) { constructor(props: WidgetPropsSet, widgetId: bigint) {
@@ -1177,7 +1187,7 @@ export class Widget {
this.widgetId = widgetId; this.widgetId = widgetId;
} }
@Type(() => WidgetProps, { discriminator: { property: "kind", subTypes: widgetSubTypes }, keepDiscriminatorProperty: true }) @Type(() => WidgetProps, { discriminator: { property: "kind", subTypes: [...widgetSubTypes] }, keepDiscriminatorProperty: true })
props!: WidgetPropsSet; props!: WidgetPropsSet;
widgetId!: bigint; widgetId!: bigint;
@@ -1226,7 +1236,7 @@ export function defaultWidgetLayout(): WidgetLayout {
} }
// Updates a widget layout based on a list of updates, returning the new layout // Updates a widget layout based on a list of updates, returning the new layout
export function patchWidgetLayout(layout: WidgetLayout, updates: WidgetDiffUpdate): void { export function patchWidgetLayout(/* mut */ layout: WidgetLayout, updates: WidgetDiffUpdate): void {
layout.layoutTarget = updates.layoutTarget; layout.layoutTarget = updates.layoutTarget;
updates.diff.forEach((update) => { updates.diff.forEach((update) => {
+12 -21
View File
@@ -12,28 +12,19 @@
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"sourceMap": true, "sourceMap": true,
"baseUrl": ".", "baseUrl": ".",
"types": [ "types": ["node", "svelte", "svelte/store", "svelte/motion", "svelte/transition", "svelte/animate", "svelte/easing"],
"node"
],
"paths": { "paths": {
"@/*": [ "@/*": ["src/*"]
"src/*"
]
}, },
"lib": [ "lib": ["esnext", "dom", "dom.iterable", "scripthost"]
"esnext",
"dom",
"dom.iterable",
"scripthost"
]
}, },
"include": [ "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.svelte", "tests/**/*.ts"],
"src/**/*.ts", "exclude": ["node_modules"],
"src/**/*.d.ts", "ts-node": {
"src/**/*.vue", "compilerOptions": {
"tests/**/*.ts", "module": "commonjs",
], "useDefineForClassFields": false,
"exclude": [ "noImplicitOverride": true
"node_modules" }
] }
} }
-4
View File
@@ -1,4 +0,0 @@
module.exports = function VueSvgLoader(svg) {
this.cacheable();
return `<template>${svg}</template>`;
};
+2 -2
View File
@@ -1,5 +1,5 @@
[package] [package]
name = "graphite-wasm" name = "graphite-wasm-svelte"
publish = false publish = false
version = "0.0.0" version = "0.0.0"
rust-version = "1.66.0" rust-version = "1.66.0"
@@ -22,8 +22,8 @@ crate-type = ["cdylib", "rlib"]
editor = { path = "../../editor", package = "graphite-editor" } editor = { path = "../../editor", package = "graphite-editor" }
document-legacy = { path = "../../document-legacy", package = "graphite-document-legacy" } document-legacy = { path = "../../document-legacy", package = "graphite-document-legacy" }
graph-craft = { path = "../../node-graph/graph-craft" } graph-craft = { path = "../../node-graph/graph-craft" }
graphene-core = { path = "../../node-graph/gcore", features = ["async", "std", "alloc"] }
log = "0.4" log = "0.4"
graphene-core = { path = "../../node-graph/gcore", features = ["async", "std", "alloc"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
wasm-bindgen = { version = "0.2.73" } wasm-bindgen = { version = "0.2.73" }
serde-wasm-bindgen = "0.4.1" serde-wasm-bindgen = "0.4.1"
@@ -1,81 +1,192 @@
/* eslint-disable @typescript-eslint/no-var-requires, no-console */ import * as path from "path";
const { spawnSync } = require("child_process"); import fs from "fs";
const path = require("path"); import { spawnSync } from "child_process";
import WasmPackPlugin from "@wasm-tool/wasm-pack-plugin";
const WasmPackPlugin = require("@wasm-tool/wasm-pack-plugin"); import SvelteCheckPlugin from "svelte-check-plugin";
import SveltePreprocess from 'svelte-preprocess';
import * as webpack from "webpack";
import 'webpack-dev-server';
const LicenseCheckerWebpackPlugin = require("license-checker-webpack-plugin"); const LicenseCheckerWebpackPlugin = require("license-checker-webpack-plugin");
module.exports = { const mode = process.env.NODE_ENV === "production" ? "production" : "development";
lintOnSave: "warning",
// https://cli.vuejs.org/guide/webpack.html const config: webpack.Configuration = {
chainWebpack: (config) => { mode,
entry: {
bundle: ["./src/main.ts"]
},
resolve: {
alias: {
// Note: Later in this config file, we'll automatically add paths from `tsconfig.compilerOptions.paths`
svelte: path.resolve('node_modules', 'svelte')
},
extensions: ['.ts', '.js', '.svelte'],
mainFields: ['svelte', 'browser', 'module', 'main']
},
output: {
path: path.resolve(__dirname, 'public/build'),
publicPath: '/build/',
filename: '[name].js',
chunkFilename: '[name].[id].js'
},
module: {
rules: [
// Rule: Svelte
{
test: /\.svelte$/,
use: {
loader: 'svelte-loader',
options: {
compilerOptions: {
// Dev mode must be enabled for HMR to work!
dev: mode === "development"
},
emitCss: mode === "production",
hotReload: mode === "development",
hotOptions: {
// List of options and defaults: https://www.npmjs.com/package/svelte-loader-hot#usage
noPreserveState: false,
optimistic: true,
},
preprocess: SveltePreprocess({
scss: true,
sass: true,
}),
onwarn(warning: { code: string; }, handler: (warn: any) => any) {
const suppress = [
"css-unused-selector",
"unused-export-let",
"a11y-no-noninteractive-tabindex",
];
if (suppress.includes(warning.code)) return;
handler(warning);
},
}
},
},
// Required to prevent errors from Svelte on Webpack 5+
// https://github.com/sveltejs/svelte-loader#usage
{
test: /node_modules\/svelte\/.*\.mjs$/,
resolve: {
fullySpecified: false
}
},
// Rule: SASS
{
test: /\.(scss|sass)$/,
use: [
'css-loader',
'sass-loader'
]
},
// Rule: CSS
{
test: /\.css$/,
use: [
'css-loader',
]
},
// Rule: TypeScript
{
test: /\.ts$/,
use: 'ts-loader',
exclude: /node_modules/
},
// Rule: SVG
{
test: /\.svg$/,
type: "asset/source",
},
]
},
devServer: {
hot: true,
},
plugins: [
// WASM Pack Plugin integrates compiled Rust code (.wasm) and generated wasm-bindgen code (.js) with the webpack bundle // WASM Pack Plugin integrates compiled Rust code (.wasm) and generated wasm-bindgen code (.js) with the webpack bundle
// Use this JS to import the bundled Rust entry points: const wasm = import("@/../wasm/pkg").then(panicProxy); // Use this JS to import the bundled Rust entry points: const wasm = import("@/../wasm/pkg").then(panicProxy);
// Then call WASM functions with: (await wasm).functionName() // Then call WASM functions with: (await wasm).functionName()
// https://github.com/wasm-tool/wasm-pack-plugin // https://github.com/wasm-tool/wasm-pack-plugin
config new WasmPackPlugin({
// https://cli.vuejs.org/guide/webpack.html#modifying-options-of-a-plugin
.plugin("wasm-pack")
.use(WasmPackPlugin)
.init(
(Plugin) =>
new Plugin({
crateDirectory: path.resolve(__dirname, "wasm"), crateDirectory: path.resolve(__dirname, "wasm"),
// Remove when this issue is resolved: https://github.com/wasm-tool/wasm-pack-plugin/issues/93 // Remove when this issue is resolved: https://github.com/wasm-tool/wasm-pack-plugin/issues/93
outDir: path.resolve(__dirname, "wasm/pkg"), outDir: path.resolve(__dirname, "wasm/pkg"),
watchDirectories: ["../editor", "../document-legacy", "../proc-macros", "../node-graph"].map((folder) => path.resolve(__dirname, folder)), watchDirectories: ["../editor", "../document-legacy", "../proc-macros", "../node-graph"].map((folder) => path.resolve(__dirname, folder)),
}) }),
)
.end();
// License Checker Webpack Plugin validates the license compatibility of all dependencies which are compiled into the webpack bundle // License Checker Webpack Plugin validates the license compatibility of all dependencies which are compiled into the webpack bundle
// It also writes the third-party license notices to a file which is displayed in the application // It also writes the third-party license notices to a file which is displayed in the application
// https://github.com/microsoft/license-checker-webpack-plugin // https://github.com/microsoft/license-checker-webpack-plugin
config new LicenseCheckerWebpackPlugin({
.plugin("license-checker")
.use(LicenseCheckerWebpackPlugin)
.init(
(Plugin) =>
new Plugin({
allow: "(Apache-2.0 OR BSD-2-Clause OR BSD-3-Clause OR MIT OR 0BSD)", allow: "(Apache-2.0 OR BSD-2-Clause OR BSD-3-Clause OR MIT OR 0BSD)",
emitError: true, emitError: true,
outputFilename: "third-party-licenses.txt", outputFilename: "third-party-licenses.txt",
outputWriter: formatThirdPartyLicenses, outputWriter: formatThirdPartyLicenses,
// Workaround for failure caused in WebPack 5: https://github.com/microsoft/license-checker-webpack-plugin/issues/25#issuecomment-833325799 // Workaround for failure caused in WebPack 5: https://github.com/microsoft/license-checker-webpack-plugin/issues/25#issuecomment-833325799
filter: /(^.*[/\\]node_modules[/\\]((?:@[^/\\]+[/\\])?(?:[^@/\\][^/\\]*)))/, filter: /(^.*[/\\]node_modules[/\\]((?:@[^/\\]+[/\\])?(?:[^@/\\][^/\\]*)))/,
}) }),
);
// Change the loaders used by the Vue compilation process // new SvelteCheckPlugin(),
config.module ],
// Replace Vue's existing base loader by first clearing it devtool: mode === 'development' ? 'source-map' : false,
// https://cli.vuejs.org/guide/webpack.html#replacing-loaders-of-a-rule
.rule("svg")
.uses.clear()
.end()
// Required (since upgrading vue-cli to v5) to stop the default import behavior, as documented in:
// https://webpack.js.org/configuration/module/#ruletype
.type("javascript/auto")
// Add vue-loader as a loader for Vue single-file components
// https://www.npmjs.com/package/vue-loader
.use("vue-loader")
.loader("vue-loader")
.end()
// Add vue-svg-loader as a loader for importing .svg files into Vue single-file components
// Located in ./vue-svg-loader.js
.use("./vue-svg-loader")
.loader("./vue-svg-loader")
.end();
},
configureWebpack: {
experiments: { experiments: {
asyncWebAssembly: true, asyncWebAssembly: true,
}, },
},
}; };
function formatThirdPartyLicenses(jsLicenses) { // Load path aliases from the tsconfig.json file
let rustLicenses; const tsconfigPath = path.resolve(__dirname, 'tsconfig.json');
const tsconfig = fs.existsSync(tsconfigPath) ? require(tsconfigPath) : {};
if ('compilerOptions' in tsconfig && 'paths' in tsconfig.compilerOptions) {
const aliases = tsconfig.compilerOptions.paths;
for (const alias in aliases) {
const paths = aliases[alias].map((p: string) => path.resolve(__dirname, p));
// Our tsconfig uses glob path formats, whereas webpack just wants directories
// We'll need to transform the glob format into a format acceptable to webpack
const wpAlias = alias.replace(/(\\|\/)\*$/, '');
const wpPaths = paths.map((p: string) => p.replace(/(\\|\/)\*$/, ''));
if (config.resolve && config.resolve.alias) {
if (!(wpAlias in config.resolve.alias) && wpPaths.length) {
(config.resolve.alias as any)[wpAlias] = wpPaths.length > 1 ? wpPaths : wpPaths[0];
}
}
}
}
module.exports = config;
interface LicenseInfo {
licenseName: string;
licenseText: string;
packages: PackageInfo[]
}
interface PackageInfo {
name: string;
version: string;
author: string;
repository: string;
}
interface Dependency extends PackageInfo {
licenseName: string;
licenseText?: string;
}
function formatThirdPartyLicenses(jsLicenses: {dependencies: Dependency[]}): string {
let rustLicenses: LicenseInfo[] | undefined;
if (process.env.NODE_ENV === "production" && process.env.SKIP_CARGO_ABOUT === undefined) { if (process.env.NODE_ENV === "production" && process.env.SKIP_CARGO_ABOUT === undefined) {
try { try {
rustLicenses = generateRustLicenses(); rustLicenses = generateRustLicenses();
@@ -101,10 +212,10 @@ function formatThirdPartyLicenses(jsLicenses) {
} }
// Remove the HTML character encoding caused by Handlebars // Remove the HTML character encoding caused by Handlebars
let licenses = (rustLicenses || []).map((rustLicense) => ({ let licenses = (rustLicenses || []).map((rustLicense): LicenseInfo => ({
licenseName: htmlDecode(rustLicense.licenseName), licenseName: htmlDecode(rustLicense.licenseName),
licenseText: trimBlankLines(htmlDecode(rustLicense.licenseText)), licenseText: trimBlankLines(htmlDecode(rustLicense.licenseText)),
packages: rustLicense.packages.map((packageInfo) => ({ packages: rustLicense.packages.map((packageInfo): PackageInfo => ({
name: htmlDecode(packageInfo.name), name: htmlDecode(packageInfo.name),
version: htmlDecode(packageInfo.version), version: htmlDecode(packageInfo.version),
author: htmlDecode(packageInfo.author).replace(/\[(.*), \]/, "$1"), author: htmlDecode(packageInfo.author).replace(/\[(.*), \]/, "$1"),
@@ -139,7 +250,7 @@ function formatThirdPartyLicenses(jsLicenses) {
const matchedLicense = licenses.find((license) => trimBlankLines(license.licenseText) === licenseText); const matchedLicense = licenses.find((license) => trimBlankLines(license.licenseText) === licenseText);
const packages = { name, version, author, repository: repo }; const packages: PackageInfo = { name, version, author, repository: repo };
if (matchedLicense) matchedLicense.packages.push(packages); if (matchedLicense) matchedLicense.packages.push(packages);
else licenses.push({ licenseName, licenseText, packages: [packages] }); else licenses.push({ licenseName, licenseText, packages: [packages] });
}); });
@@ -178,8 +289,9 @@ ${license.licenseText}
return formattedLicenseNotice; return formattedLicenseNotice;
} }
function generateRustLicenses() { function generateRustLicenses(): LicenseInfo[] | undefined {
console.info("Generating license information for Rust code"); console.info("Generating license information for Rust code");
// This `about.hbs` file is written so it generates a valid JavaScript array expression which we evaluate below
const { stdout, stderr, status } = spawnSync("cargo", ["about", "generate", "about.hbs"], { const { stdout, stderr, status } = spawnSync("cargo", ["about", "generate", "about.hbs"], {
cwd: path.join(__dirname, ".."), cwd: path.join(__dirname, ".."),
encoding: "utf8", encoding: "utf8",
@@ -196,7 +308,7 @@ function generateRustLicenses() {
return undefined; return undefined;
} }
// Make sure the output starts as expected, we don't want to eval an error message. // Make sure the output starts with this expected label, we don't want to eval an error message.
if (!stdout.trim().startsWith("GENERATED_BY_CARGO_ABOUT:")) { if (!stdout.trim().startsWith("GENERATED_BY_CARGO_ABOUT:")) {
console.error("Unexpected output from cargo-about", stdout); console.error("Unexpected output from cargo-about", stdout);
return undefined; return undefined;
@@ -204,10 +316,10 @@ function generateRustLicenses() {
// Security-wise, eval() isn't any worse than require(), but it doesn't need a temporary file. // Security-wise, eval() isn't any worse than require(), but it doesn't need a temporary file.
// eslint-disable-next-line no-eval // eslint-disable-next-line no-eval
return eval(stdout); return eval(stdout) as LicenseInfo[];
} }
function htmlDecode(input) { function htmlDecode(input: string): string {
if (!input) return input; if (!input) return input;
const htmlEntities = { const htmlEntities = {
@@ -218,15 +330,15 @@ function htmlDecode(input) {
gt: ">", gt: ">",
amp: "&", amp: "&",
apos: "'", apos: "'",
// eslint-disable-next-line quotes
quot: '"', quot: '"',
}; };
return input.replace(/&([^;]+);/g, (entity, entityCode) => { return input.replace(/&([^;]+);/g, (entity, entityCode: string) => {
let match; let match;
if (entityCode in htmlEntities) { const maybeEntity = Object.entries(htmlEntities).find((entry) => entry[1] === entityCode);
return htmlEntities[entityCode]; if (maybeEntity) {
return maybeEntity[1];
} }
// eslint-disable-next-line no-cond-assign // eslint-disable-next-line no-cond-assign
if ((match = entityCode.match(/^#x([\da-fA-F]+)$/))) { if ((match = entityCode.match(/^#x([\da-fA-F]+)$/))) {
@@ -240,7 +352,7 @@ function htmlDecode(input) {
}); });
} }
function trimBlankLines(input) { function trimBlankLines(input: string): string {
let result = input.replace(/\r/g, ""); let result = input.replace(/\r/g, "");
while (result.charAt(0) === "\r" || result.charAt(0) === "\n") { while (result.charAt(0) === "\r" || result.charAt(0) === "\n") {