mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-15 14:18:04 +08:00
Replace the Vue frontend with Svelte
This commit is contained in:
@@ -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",
|
||||||
},
|
},
|
||||||
|
|||||||
5
frontend/.gitignore
vendored
5
frontend/.gitignore
vendored
@@ -1,4 +1,3 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/**
|
wasm/pkg/
|
||||||
!dist/.git-keep-directory
|
public/build/
|
||||||
pkg/
|
|
||||||
|
|||||||
@@ -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.
|
|
||||||
|
|||||||
0
frontend/dist/.git-keep-directory
vendored
0
frontend/dist/.git-keep-directory
vendored
13128
frontend/package-lock.json
generated
13128
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
|
||||||
const platform = operatingSystem();
|
|
||||||
this.editor.instance.initAfterFrontendReady(platform);
|
|
||||||
},
|
|
||||||
beforeUnmount() {
|
|
||||||
// Call the destructor for each manager
|
|
||||||
Object.values(managerDestructors).forEach((destructor) => destructor?.());
|
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
// 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>
|
|
||||||
|
|||||||
@@ -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
frontend/src/components/Editor.svelte
Normal file
300
frontend/src/components/Editor.svelte
Normal 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>
|
||||||
@@ -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; }}
|
||||||
|
/>
|
||||||
```
|
```
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,101 +1,97 @@
|
|||||||
<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: {
|
|
||||||
dismiss() {
|
|
||||||
this.dialog.dismissDialog();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
// Focus the first button in the popup
|
|
||||||
const dialogModal: HTMLDivElement | undefined = this.$el;
|
|
||||||
const emphasizedOrFirstButton = (dialogModal?.querySelector("[data-emphasized]") || dialogModal?.querySelector("[data-text-button]") || undefined) as HTMLButtonElement | undefined;
|
|
||||||
emphasizedOrFirstButton?.focus();
|
|
||||||
},
|
|
||||||
components: {
|
|
||||||
FloatingMenu,
|
|
||||||
IconLabel,
|
|
||||||
LayoutCol,
|
|
||||||
LayoutRow,
|
|
||||||
TextButton,
|
|
||||||
WidgetLayout,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
let self: FloatingMenu;
|
||||||
<FloatingMenu :open="true" class="dialog-modal" :type="'Dialog'" :direction="'Center'" data-dialog-modal>
|
|
||||||
<LayoutRow>
|
|
||||||
<LayoutCol class="icon-column">
|
|
||||||
<!-- `dialog.state.icon` class exists to provide special sizing in CSS to specific icons -->
|
|
||||||
<IconLabel :icon="dialog.state.icon" :class="dialog.state.icon.toLowerCase()" />
|
|
||||||
</LayoutCol>
|
|
||||||
<LayoutCol class="main-column">
|
|
||||||
<WidgetLayout v-if="dialog.state.widgets.layout.length > 0" :layout="dialog.state.widgets" class="details" />
|
|
||||||
<LayoutRow v-if="(dialog.state.jsCallbackBasedButtons?.length || NaN) > 0" class="panic-buttons-row">
|
|
||||||
<TextButton v-for="(button, index) in dialog.state.jsCallbackBasedButtons" :key="index" :action="() => button.callback?.()" v-bind="button.props" />
|
|
||||||
</LayoutRow>
|
|
||||||
</LayoutCol>
|
|
||||||
</LayoutRow>
|
|
||||||
</FloatingMenu>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style lang="scss">
|
export function dismiss() {
|
||||||
.dialog-modal {
|
dialog.dismissDialog();
|
||||||
position: absolute;
|
|
||||||
pointer-events: none;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
|
|
||||||
> .floating-menu-container > .floating-menu-content {
|
|
||||||
pointer-events: auto;
|
|
||||||
padding: 24px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-column {
|
onMount(() => {
|
||||||
margin-right: 24px;
|
// Focus the first button in the popup
|
||||||
|
const emphasizedOrFirstButton = (self.div().querySelector("[data-emphasized]") || self.div().querySelector("[data-text-button]") || undefined) as HTMLButtonElement | undefined;
|
||||||
|
emphasizedOrFirstButton?.focus();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
.icon-label {
|
<FloatingMenu open={true} class="dialog-modal" type="Dialog" direction="Center" bind:this={self} data-dialog-modal>
|
||||||
width: 80px;
|
<LayoutRow>
|
||||||
height: 80px;
|
<LayoutCol class="icon-column">
|
||||||
|
<!-- `$dialog.icon` class exists to provide special sizing in CSS to specific icons -->
|
||||||
|
<IconLabel icon={$dialog.icon} class={$dialog.icon.toLowerCase()} />
|
||||||
|
</LayoutCol>
|
||||||
|
<LayoutCol class="main-column">
|
||||||
|
{#if $dialog.widgets.layout.length > 0}
|
||||||
|
<WidgetLayout layout={$dialog.widgets} class="details" />
|
||||||
|
{/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>
|
||||||
|
{/if}
|
||||||
|
</LayoutCol>
|
||||||
|
</LayoutRow>
|
||||||
|
</FloatingMenu>
|
||||||
|
|
||||||
&.file,
|
<style lang="scss" global>
|
||||||
&.copy {
|
.dialog-modal {
|
||||||
width: 60px;
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
svg {
|
> .floating-menu-container > .floating-menu-content {
|
||||||
width: 80px;
|
pointer-events: auto;
|
||||||
height: 80px;
|
padding: 24px;
|
||||||
margin: 0 -10px;
|
}
|
||||||
|
|
||||||
|
.icon-column {
|
||||||
|
margin-right: 24px;
|
||||||
|
|
||||||
|
.icon-label {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
|
||||||
|
&.file,
|
||||||
|
&.copy {
|
||||||
|
width: 60px;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
margin: 0 -10px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.main-column {
|
.main-column {
|
||||||
margin: -4px 0;
|
margin: -4px 0;
|
||||||
|
|
||||||
.details.text-label {
|
.details.text-label {
|
||||||
-webkit-user-select: text; // Required as of Safari 15.0 (Graphite's minimum version) through the latest release
|
-webkit-user-select: text; // Required as of Safari 15.0 (Graphite's minimum version) through the latest release
|
||||||
user-select: text;
|
user-select: text;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panic-buttons-row {
|
.panic-buttons-row {
|
||||||
height: 32px;
|
height: 32px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,139 +1,134 @@
|
|||||||
<script lang="ts">
|
<script lang="ts" context="module">
|
||||||
import { defineComponent, type PropType } from "vue";
|
// 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;
|
||||||
|
|
||||||
import FloatingMenu from "@/components/layout/FloatingMenu.vue";
|
export const ZOOM_WINDOW_DIMENSIONS = ZOOM_WINDOW_DIMENSIONS_EXPANDED / UPSCALE_FACTOR;
|
||||||
|
|
||||||
// Should be equal to the width and height of the canvas in the CSS above
|
|
||||||
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;
|
|
||||||
|
|
||||||
const temporaryCanvas = document.createElement("canvas");
|
|
||||||
|
|
||||||
export default defineComponent({
|
|
||||||
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;
|
|
||||||
canvas.height = ZOOM_WINDOW_DIMENSIONS;
|
|
||||||
const context = canvas.getContext("2d");
|
|
||||||
|
|
||||||
temporaryCanvas.width = ZOOM_WINDOW_DIMENSIONS;
|
|
||||||
temporaryCanvas.height = ZOOM_WINDOW_DIMENSIONS;
|
|
||||||
const temporaryContext = temporaryCanvas.getContext("2d");
|
|
||||||
|
|
||||||
if (!imageData || !context || !temporaryContext) return;
|
|
||||||
|
|
||||||
temporaryContext.putImageData(imageData, 0, 0, 0, 0, ZOOM_WINDOW_DIMENSIONS, ZOOM_WINDOW_DIMENSIONS);
|
|
||||||
|
|
||||||
context.fillStyle = "black";
|
|
||||||
context.fillRect(0, 0, ZOOM_WINDOW_DIMENSIONS, ZOOM_WINDOW_DIMENSIONS);
|
|
||||||
|
|
||||||
context.drawImage(temporaryCanvas, 0, 0);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
components: {
|
|
||||||
FloatingMenu,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<script lang="ts">
|
||||||
<FloatingMenu
|
import { onMount } from "svelte";
|
||||||
:open="true"
|
|
||||||
class="eyedropper-preview"
|
import FloatingMenu from "@/components/layout/FloatingMenu.svelte";
|
||||||
:type="'Cursor'"
|
|
||||||
:style="{ '--ring-color-primary': primaryColor, '--ring-color-secondary': secondaryColor, '--ring-color-choice': colorChoice }"
|
const temporaryCanvas = document.createElement("canvas");
|
||||||
>
|
|
||||||
<div class="ring">
|
let zoomPreviewCanvas: HTMLCanvasElement;
|
||||||
<div class="canvas-container">
|
|
||||||
<canvas ref="zoomPreviewCanvas"></canvas>
|
export let imageData: ImageData | undefined = undefined;
|
||||||
<div class="pixel-outline"></div>
|
export let colorChoice: string;
|
||||||
</div>
|
export let primaryColor: string;
|
||||||
|
export let secondaryColor: string;
|
||||||
|
export let x: number;
|
||||||
|
export let y: number;
|
||||||
|
|
||||||
|
$: watchImageData(imageData);
|
||||||
|
|
||||||
|
function watchImageData(imageData: ImageData | undefined) {
|
||||||
|
displayImageDataPreview(imageData);
|
||||||
|
}
|
||||||
|
|
||||||
|
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.height = ZOOM_WINDOW_DIMENSIONS;
|
||||||
|
const temporaryContext = temporaryCanvas.getContext("2d");
|
||||||
|
|
||||||
|
if (!imageData || !context || !temporaryContext) return;
|
||||||
|
|
||||||
|
temporaryContext.putImageData(imageData, 0, 0, 0, 0, ZOOM_WINDOW_DIMENSIONS, ZOOM_WINDOW_DIMENSIONS);
|
||||||
|
|
||||||
|
context.fillStyle = "black";
|
||||||
|
context.fillRect(0, 0, ZOOM_WINDOW_DIMENSIONS, ZOOM_WINDOW_DIMENSIONS);
|
||||||
|
|
||||||
|
context.drawImage(temporaryCanvas, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
displayImageDataPreview(imageData);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<FloatingMenu
|
||||||
|
open={true}
|
||||||
|
class="eyedropper-preview"
|
||||||
|
type="Cursor"
|
||||||
|
styles={{ "--ring-color-primary": primaryColor, "--ring-color-secondary": secondaryColor, "--ring-color-choice": colorChoice, left: x + "px", top: y + "px" }}
|
||||||
|
>
|
||||||
|
<div class="ring">
|
||||||
|
<div class="canvas-container">
|
||||||
|
<canvas bind:this={zoomPreviewCanvas} />
|
||||||
|
<div class="pixel-outline" />
|
||||||
</div>
|
</div>
|
||||||
</FloatingMenu>
|
</div>
|
||||||
</template>
|
</FloatingMenu>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss" global>
|
||||||
.eyedropper-preview {
|
.eyedropper-preview {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
|
||||||
.ring {
|
.ring {
|
||||||
transform: translate(0, -50%) rotate(45deg);
|
transform: translate(0, -50%) rotate(45deg);
|
||||||
position: relative;
|
position: relative;
|
||||||
background: var(--ring-color-choice);
|
background: var(--ring-color-choice);
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
border: 8px solid;
|
border: 8px solid;
|
||||||
border-radius: 50%;
|
|
||||||
border-top-color: var(--ring-color-primary);
|
|
||||||
border-left-color: var(--ring-color-primary);
|
|
||||||
border-bottom-color: var(--ring-color-secondary);
|
|
||||||
border-right-color: var(--ring-color-secondary);
|
|
||||||
|
|
||||||
&::after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
top: -8px;
|
|
||||||
left: -8px;
|
|
||||||
padding: 8px;
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.5), 0 0 8px rgba(0, 0, 0, 0.25);
|
border-top-color: var(--ring-color-primary);
|
||||||
}
|
border-left-color: var(--ring-color-primary);
|
||||||
|
border-bottom-color: var(--ring-color-secondary);
|
||||||
.canvas-container {
|
border-right-color: var(--ring-color-secondary);
|
||||||
transform: rotate(-45deg);
|
|
||||||
|
|
||||||
canvas {
|
|
||||||
display: block;
|
|
||||||
width: 110px;
|
|
||||||
height: 110px;
|
|
||||||
border-radius: 50%;
|
|
||||||
image-rendering: pixelated;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
top: -8px;
|
||||||
|
left: -8px;
|
||||||
|
padding: 8px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.5), inset 0 0 8px rgba(0, 0, 0, 0.25);
|
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.5), 0 0 8px rgba(0, 0, 0, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pixel-outline {
|
.canvas-container {
|
||||||
position: absolute;
|
transform: rotate(-45deg);
|
||||||
left: 50%;
|
|
||||||
top: 50%;
|
canvas {
|
||||||
transform: translate(-50%, -50%);
|
display: block;
|
||||||
--outline-width: 2;
|
width: 110px;
|
||||||
margin-top: calc(-1px * (var(--outline-width) / 2));
|
height: 110px;
|
||||||
width: calc(10px - (var(--outline-width) * 1px));
|
border-radius: 50%;
|
||||||
height: calc(10px - var(--outline-width) * 1px);
|
image-rendering: pixelated;
|
||||||
border: calc(var(--outline-width) * 1px) solid var(--color-0-black);
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.5), inset 0 0 8px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pixel-outline {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
--outline-width: 2;
|
||||||
|
margin-top: calc(-1px * (var(--outline-width) / 2));
|
||||||
|
width: calc(10px - (var(--outline-width) * 1px));
|
||||||
|
height: calc(10px - var(--outline-width) * 1px);
|
||||||
|
border: calc(var(--outline-width) * 1px) solid var(--color-0-black);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,359 +1,351 @@
|
|||||||
|
<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;
|
||||||
|
$: virtualScrollingStartIndex = Math.floor(virtualScrollingEntriesStart / virtualScrollingEntryHeight) || 0;
|
||||||
|
$: virtualScrollingEndIndex = entries.length === 0 ? 0 : Math.min(entries[0].length, virtualScrollingStartIndex + 1 + 400 / virtualScrollingEntryHeight);
|
||||||
|
|
||||||
|
function watchOpen(open: boolean) {
|
||||||
|
highlighted = activeEntry;
|
||||||
|
dispatch("open", open);
|
||||||
|
}
|
||||||
|
|
||||||
|
function watchRemeasureWidth(_: MenuListEntry[][], __: boolean) {
|
||||||
|
self?.measureAndEmitNaturalWidth();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onScroll(e: Event) {
|
||||||
|
if (!virtualScrollingEntryHeight) return;
|
||||||
|
virtualScrollingEntriesStart = (e.target as HTMLElement)?.scrollTop || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEntryClick(menuListEntry: MenuListEntry): void {
|
||||||
|
// Call the action if available
|
||||||
|
if (menuListEntry.action) menuListEntry.action();
|
||||||
|
|
||||||
|
// Notify the parent about the clicked entry as the new active entry
|
||||||
|
dispatch("activeEntry", menuListEntry);
|
||||||
|
|
||||||
|
// Close the containing menu
|
||||||
|
if (menuListEntry.ref) menuListEntry.ref.open = false;
|
||||||
|
dispatch("open", false);
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEntryPointerEnter(menuListEntry: MenuListEntry): void {
|
||||||
|
if (!menuListEntry.children?.length) return;
|
||||||
|
|
||||||
|
if (menuListEntry.ref) menuListEntry.ref.open = true;
|
||||||
|
else dispatch("open", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEntryPointerLeave(menuListEntry: MenuListEntry): void {
|
||||||
|
if (!menuListEntry.children?.length) return;
|
||||||
|
|
||||||
|
if (menuListEntry.ref) menuListEntry.ref.open = false;
|
||||||
|
else dispatch("open", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEntryOpen(menuListEntry: MenuListEntry): boolean {
|
||||||
|
if (!menuListEntry.children?.length) return false;
|
||||||
|
|
||||||
|
return menuListEntry.ref?.open || false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles keyboard navigation for the menu. Returns if the entire menu stack should be dismissed
|
||||||
|
export function keydown(e: KeyboardEvent, submenu: boolean): boolean {
|
||||||
|
// 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 => {
|
||||||
|
if (highlighted.ref && highlighted.children?.length) {
|
||||||
|
highlighted.ref.open = true;
|
||||||
|
|
||||||
|
// Highlight first item
|
||||||
|
highlighted.ref.setHighlighted(highlighted.children[0][0]);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
// Called only when `open` is changed from outside this component (with v-model)
|
|
||||||
open(newOpen: boolean) {
|
|
||||||
this.isOpen = newOpen;
|
|
||||||
this.highlighted = this.activeEntry;
|
|
||||||
},
|
|
||||||
isOpen(newIsOpen: boolean) {
|
|
||||||
this.$emit("update:open", newIsOpen);
|
|
||||||
},
|
|
||||||
entries() {
|
|
||||||
(this.$refs.floatingMenu as typeof FloatingMenu | undefined)?.measureAndEmitNaturalWidth();
|
|
||||||
},
|
|
||||||
drawIcon() {
|
|
||||||
(this.$refs.floatingMenu as typeof FloatingMenu | undefined)?.measureAndEmitNaturalWidth();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
scrollViewTo(distanceDown: number): 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
|
|
||||||
if (menuListEntry.action) menuListEntry.action();
|
|
||||||
|
|
||||||
// Emit the clicked entry as the new active entry
|
if (!menuOpen && (e.key === " " || e.key === "Enter")) {
|
||||||
this.$emit("update:activeEntry", menuListEntry);
|
// Allow opening menu with space or enter
|
||||||
|
open = true;
|
||||||
|
highlighted = activeEntry;
|
||||||
|
} else if (menuOpen && openChild >= 0) {
|
||||||
|
// Redirect the keyboard navigation to a submenu if one is open
|
||||||
|
const shouldCloseStack = flatEntries[openChild].ref?.keydown(e, true);
|
||||||
|
|
||||||
// Close the containing menu
|
// Highlight the menu item in the parent list that corresponds with the open submenu
|
||||||
if (menuListEntry.ref) menuListEntry.ref.isOpen = false;
|
if (e.key !== "Escape" && highlighted) setHighlighted(flatEntries[openChild]);
|
||||||
this.$emit("update:open", false);
|
|
||||||
this.isOpen = false; // TODO: This is a hack for MenuBarInput submenus, remove it when we get rid of using `ref`
|
|
||||||
},
|
|
||||||
onEntryPointerEnter(menuListEntry: MenuListEntry): void {
|
|
||||||
if (!menuListEntry.children?.length) return;
|
|
||||||
|
|
||||||
if (menuListEntry.ref) menuListEntry.ref.isOpen = true;
|
// Handle the child closing the entire menu stack
|
||||||
else this.$emit("update:open", true);
|
if (shouldCloseStack) {
|
||||||
},
|
open = false;
|
||||||
onEntryPointerLeave(menuListEntry: MenuListEntry): void {
|
|
||||||
if (!menuListEntry.children?.length) return;
|
|
||||||
|
|
||||||
if (menuListEntry.ref) menuListEntry.ref.isOpen = false;
|
|
||||||
else this.$emit("update:open", false);
|
|
||||||
},
|
|
||||||
isEntryOpen(menuListEntry: MenuListEntry): boolean {
|
|
||||||
if (!menuListEntry.children?.length) return false;
|
|
||||||
|
|
||||||
return this.open;
|
|
||||||
},
|
|
||||||
/// 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;
|
|
||||||
const flatEntries = this.entries.flat().filter((entry) => !entry.disabled);
|
|
||||||
const openChild = flatEntries.findIndex((entry) => entry.children?.length && entry.ref?.isOpen);
|
|
||||||
|
|
||||||
const openSubmenu = (highlighted: MenuListEntry): void => {
|
|
||||||
if (highlighted.ref && highlighted.children?.length) {
|
|
||||||
highlighted.ref.isOpen = true;
|
|
||||||
|
|
||||||
// Highlight first item
|
|
||||||
highlighted.ref.setHighlighted(highlighted.children[0][0]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!menuOpen && (e.key === " " || e.key === "Enter")) {
|
|
||||||
// Allow opening menu with space or enter
|
|
||||||
this.isOpen = true;
|
|
||||||
this.highlighted = this.activeEntry;
|
|
||||||
} else if (menuOpen && openChild >= 0) {
|
|
||||||
// Redirect the keyboard navigation to a submenu if one is open
|
|
||||||
const shouldCloseStack = flatEntries[openChild].ref?.keydown(e, true);
|
|
||||||
|
|
||||||
// Highlight the menu item in the parent list that corresponds with the open submenu
|
|
||||||
if (e.key !== "Escape" && this.highlighted) this.setHighlighted(flatEntries[openChild]);
|
|
||||||
|
|
||||||
// Handle the child closing the entire menu stack
|
|
||||||
if (shouldCloseStack) {
|
|
||||||
this.isOpen = false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} else if ((menuOpen || this.interactive) && (e.key === "ArrowUp" || e.key === "ArrowDown")) {
|
|
||||||
// Navigate to the next and previous entries with arrow keys
|
|
||||||
|
|
||||||
let newIndex = e.key === "ArrowUp" ? flatEntries.length - 1 : 0;
|
|
||||||
if (this.highlighted) {
|
|
||||||
const index = this.highlighted ? flatEntries.map((entry) => entry.label).indexOf(this.highlighted.label) : 0;
|
|
||||||
newIndex = index + (e.key === "ArrowUp" ? -1 : 1);
|
|
||||||
|
|
||||||
// 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));
|
|
||||||
else newIndex = (newIndex + flatEntries.length) % flatEntries.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newEntry = flatEntries[newIndex];
|
|
||||||
this.setHighlighted(newEntry);
|
|
||||||
} else if (menuOpen && e.key === "Escape") {
|
|
||||||
// Close menu with escape key
|
|
||||||
this.isOpen = false;
|
|
||||||
|
|
||||||
// Reset active to before open
|
|
||||||
this.setHighlighted(this.activeEntry);
|
|
||||||
} else if (menuOpen && this.highlighted && e.key === "Enter") {
|
|
||||||
// Handle clicking on an option if enter is pressed
|
|
||||||
if (!this.highlighted.children?.length) this.onEntryClick(this.highlighted);
|
|
||||||
else openSubmenu(this.highlighted);
|
|
||||||
|
|
||||||
// Stop the event from triggering a press on a new dialog
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
// Enter should close the entire menu stack
|
|
||||||
return true;
|
return true;
|
||||||
} else if (menuOpen && this.highlighted && e.key === "ArrowRight") {
|
}
|
||||||
// Right arrow opens a submenu
|
} else if ((menuOpen || interactive) && (e.key === "ArrowUp" || e.key === "ArrowDown")) {
|
||||||
openSubmenu(this.highlighted);
|
// Navigate to the next and previous entries with arrow keys
|
||||||
} else if (menuOpen && e.key === "ArrowLeft") {
|
|
||||||
// Left arrow closes a submenu
|
let newIndex = e.key === "ArrowUp" ? flatEntries.length - 1 : 0;
|
||||||
if (submenu) this.isOpen = false;
|
if (highlighted) {
|
||||||
|
const index = highlighted ? flatEntries.map((entry) => entry.label).indexOf(highlighted.label) : 0;
|
||||||
|
newIndex = index + (e.key === "ArrowUp" ? -1 : 1);
|
||||||
|
|
||||||
|
// Interactive dropdowns should lock at the end whereas other dropdowns should loop
|
||||||
|
if (interactive) newIndex = Math.min(flatEntries.length - 1, Math.max(0, newIndex));
|
||||||
|
else newIndex = (newIndex + flatEntries.length) % flatEntries.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// By default, keep the menu stack open
|
const newEntry = flatEntries[newIndex];
|
||||||
return false;
|
setHighlighted(newEntry);
|
||||||
},
|
} else if (menuOpen && e.key === "Escape") {
|
||||||
setHighlighted(newHighlight: MenuListEntry | undefined) {
|
// Close menu with escape key
|
||||||
this.highlighted = newHighlight;
|
open = false;
|
||||||
// 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);
|
// Reset active to before open
|
||||||
},
|
setHighlighted(activeEntry);
|
||||||
onScroll(e: Event) {
|
} else if (menuOpen && highlighted && e.key === "Enter") {
|
||||||
if (!this.virtualScrollingEntryHeight) return;
|
// Handle clicking on an option if enter is pressed
|
||||||
this.virtualScrollingEntriesStart = (e.target as HTMLElement)?.scrollTop || 0;
|
if (!highlighted.children?.length) onEntryClick(highlighted);
|
||||||
},
|
else openSubmenu(highlighted);
|
||||||
},
|
|
||||||
computed: {
|
// Stop the event from triggering a press on a new dialog
|
||||||
virtualScrollingTotalHeight() {
|
e.preventDefault();
|
||||||
return this.entries[0].length * this.virtualScrollingEntryHeight;
|
|
||||||
},
|
// Enter should close the entire menu stack
|
||||||
virtualScrollingStartIndex() {
|
return true;
|
||||||
return Math.floor(this.virtualScrollingEntriesStart / this.virtualScrollingEntryHeight);
|
} else if (menuOpen && highlighted && e.key === "ArrowRight") {
|
||||||
},
|
// Right arrow opens a submenu
|
||||||
virtualScrollingEndIndex() {
|
openSubmenu(highlighted);
|
||||||
return Math.min(this.entries[0].length, this.virtualScrollingStartIndex + 1 + 400 / this.virtualScrollingEntryHeight);
|
} else if (menuOpen && e.key === "ArrowLeft") {
|
||||||
},
|
// Left arrow closes a submenu
|
||||||
},
|
if (submenu) open = false;
|
||||||
components: {
|
}
|
||||||
FloatingMenu,
|
|
||||||
IconLabel,
|
// By default, keep the menu stack open
|
||||||
LayoutCol,
|
return false;
|
||||||
LayoutRow,
|
}
|
||||||
Separator,
|
|
||||||
TextLabel,
|
export function setHighlighted(newHighlight: MenuListEntry | undefined) {
|
||||||
UserInputLabel,
|
highlighted = newHighlight;
|
||||||
},
|
// Interactive menus should keep the active entry the same as the highlighted one
|
||||||
});
|
if (interactive && newHighlight?.value !== activeEntry?.value && newHighlight) dispatch("activeEntry", newHighlight);
|
||||||
export default MenuList;
|
}
|
||||||
|
|
||||||
|
export function scrollViewTo(distanceDown: number): void {
|
||||||
|
scroller.div().scrollTo(0, distanceDown);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<FloatingMenu
|
||||||
<FloatingMenu
|
class="menu-list"
|
||||||
class="menu-list"
|
{open}
|
||||||
v-model:open="isOpen"
|
on:open={({ detail }) => (open = detail)}
|
||||||
@naturalWidth="(newNaturalWidth: number) => $emit('naturalWidth', newNaturalWidth)"
|
on:naturalWidth
|
||||||
:type="'Dropdown'"
|
type="Dropdown"
|
||||||
:windowEdgeMargin="0"
|
windowEdgeMargin={0}
|
||||||
:escapeCloses="false"
|
escapeCloses={false}
|
||||||
v-bind="{ direction, scrollableY: scrollableY && virtualScrollingEntryHeight === 0, minWidth }"
|
{direction}
|
||||||
ref="floatingMenu"
|
{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.
|
||||||
|
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
|
||||||
|
bind:this={scroller}
|
||||||
|
scrollableY={scrollableY && virtualScrollingEntryHeight !== 0}
|
||||||
|
on:scroll={onScroll}
|
||||||
|
styles={{ "min-width": virtualScrollingEntryHeight ? `${minWidth}px` : `inherit` }}
|
||||||
>
|
>
|
||||||
<!-- If we put the scrollableY on the layoutcol for non-font dropdowns then for some reason it always creates a tiny scrollbar.
|
{#if virtualScrollingEntryHeight}
|
||||||
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. -->
|
<LayoutRow class="scroll-spacer" styles={{ height: `${virtualScrollingStartIndex * virtualScrollingEntryHeight}px` }} />
|
||||||
<LayoutCol ref="scroller" :scrollableY="scrollableY && virtualScrollingEntryHeight !== 0" @scroll="onScroll" :style="{ minWidth: virtualScrollingEntryHeight ? `${minWidth}px` : `inherit` }">
|
{/if}
|
||||||
<LayoutRow v-if="virtualScrollingEntryHeight" class="scroll-spacer" :style="{ height: `${virtualScrollingStartIndex * virtualScrollingEntryHeight}px` }"></LayoutRow>
|
{#each entries as section, sectionIndex (sectionIndex)}
|
||||||
<template v-for="(section, sectionIndex) in entries" :key="sectionIndex">
|
{#if sectionIndex > 0}
|
||||||
<Separator :type="'List'" :direction="'Vertical'" v-if="sectionIndex > 0" />
|
<Separator type="List" direction="Vertical" />
|
||||||
|
{/if}
|
||||||
|
{#each virtualScrollingEntryHeight ? section.slice(virtualScrollingStartIndex, virtualScrollingEndIndex) : section as entry, entryIndex (entryIndex + (virtualScrollingEntryHeight ? virtualScrollingStartIndex : 0))}
|
||||||
<LayoutRow
|
<LayoutRow
|
||||||
v-for="(entry, entryIndex) in virtualScrollingEntryHeight ? section.slice(virtualScrollingStartIndex, virtualScrollingEndIndex) : section"
|
|
||||||
:key="entryIndex + (virtualScrollingEntryHeight ? virtualScrollingStartIndex : 0)"
|
|
||||||
class="row"
|
class="row"
|
||||||
:class="{ open: isEntryOpen(entry), active: entry.label === highlighted?.label, disabled: entry.disabled }"
|
classes={{ open: isEntryOpen(entry), active: entry.label === highlighted?.label, disabled: Boolean(entry.disabled) }}
|
||||||
:style="{ height: virtualScrollingEntryHeight || '20px' }"
|
styles={{ height: virtualScrollingEntryHeight || "20px" }}
|
||||||
:title="tooltip"
|
{tooltip}
|
||||||
@click="() => !entry.disabled && onEntryClick(entry)"
|
on:click={() => !entry.disabled && onEntryClick(entry)}
|
||||||
@pointerenter="() => !entry.disabled && onEntryPointerEnter(entry)"
|
on:pointerenter={() => !entry.disabled && onEntryPointerEnter(entry)}
|
||||||
@pointerleave="() => !entry.disabled && onEntryPointerLeave(entry)"
|
on:pointerleave={() => !entry.disabled && onEntryPointerLeave(entry)}
|
||||||
>
|
>
|
||||||
<IconLabel v-if="entry.icon && drawIcon" :icon="entry.icon" class="entry-icon" />
|
{#if entry.icon && drawIcon}
|
||||||
<div v-else-if="drawIcon" class="no-icon"></div>
|
<IconLabel icon={entry.icon} class="entry-icon" />
|
||||||
|
{:else if drawIcon}
|
||||||
|
<div class="no-icon" />
|
||||||
|
{/if}
|
||||||
|
|
||||||
<link v-if="entry.font" rel="stylesheet" :href="entry.font?.toString()" />
|
{#if entry.font}
|
||||||
|
<link rel="stylesheet" href={entry.font?.toString()} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
<TextLabel class="entry-label" :style="{ fontFamily: `${!entry.font ? 'inherit' : entry.value}` }">{{ entry.label }}</TextLabel>
|
<TextLabel class="entry-label" styles={{ "font-family": `${!entry.font ? "inherit" : entry.value}` }}>{entry.label}</TextLabel>
|
||||||
|
|
||||||
<UserInputLabel v-if="entry.shortcut?.keys.length" :keysWithLabelsGroups="[entry.shortcut.keys]" :requiresLock="entry.shortcutRequiresLock" />
|
{#if entry.shortcut?.keys.length}
|
||||||
|
<UserInputLabel keysWithLabelsGroups={[entry.shortcut.keys]} requiresLock={entry.shortcutRequiresLock} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="submenu-arrow" v-if="entry.children?.length"></div>
|
{#if entry.children?.length}
|
||||||
<div class="no-submenu-arrow" v-else></div>
|
<div class="submenu-arrow" />
|
||||||
|
{:else}
|
||||||
|
<div class="no-submenu-arrow" />
|
||||||
|
{/if}
|
||||||
|
|
||||||
<MenuList
|
{#if entry.children}
|
||||||
v-if="entry.children"
|
<svelte:self on:naturalWidth open={entry.ref?.open || false} direction="TopRight" entries={entry.children} {minWidth} {drawIcon} {scrollableY} bind:this={entry.ref} />
|
||||||
@naturalWidth="(newNaturalWidth: number) => $emit('naturalWidth', newNaturalWidth)"
|
{/if}
|
||||||
: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>
|
</LayoutRow>
|
||||||
</template>
|
{/each}
|
||||||
<LayoutRow
|
{/each}
|
||||||
v-if="virtualScrollingEntryHeight"
|
{#if virtualScrollingEntryHeight}
|
||||||
class="scroll-spacer"
|
<LayoutRow class="scroll-spacer" styles={{ height: `${virtualScrollingTotalHeight - virtualScrollingEndIndex * virtualScrollingEntryHeight}px` }} />
|
||||||
:style="{ height: `${virtualScrollingTotalHeight - virtualScrollingEndIndex * virtualScrollingEntryHeight}px` }"
|
{/if}
|
||||||
></LayoutRow>
|
</LayoutCol>
|
||||||
</LayoutCol>
|
</FloatingMenu>
|
||||||
</FloatingMenu>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss" global>
|
||||||
.menu-list {
|
.menu-list {
|
||||||
.floating-menu-container .floating-menu-content {
|
.floating-menu-container .floating-menu-content {
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
|
|
||||||
.separator div {
|
.separator div {
|
||||||
background: var(--color-4-dimgray);
|
background: var(--color-4-dimgray);
|
||||||
}
|
}
|
||||||
|
|
||||||
.scroll-spacer {
|
.scroll-spacer {
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row {
|
|
||||||
height: 20px;
|
|
||||||
align-items: center;
|
|
||||||
white-space: nowrap;
|
|
||||||
position: relative;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
|
|
||||||
& > * {
|
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.entry-icon svg {
|
.row {
|
||||||
fill: var(--color-e-nearwhite);
|
height: 20px;
|
||||||
}
|
align-items: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
position: relative;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
|
||||||
.no-icon {
|
& > * {
|
||||||
width: 16px;
|
flex: 0 0 auto;
|
||||||
}
|
|
||||||
|
|
||||||
.entry-label {
|
|
||||||
flex: 1 1 100%;
|
|
||||||
margin-left: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.entry-icon,
|
|
||||||
.no-icon {
|
|
||||||
margin: 0 4px;
|
|
||||||
|
|
||||||
& + .entry-label {
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.user-input-label {
|
|
||||||
margin-left: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.submenu-arrow {
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
border-style: solid;
|
|
||||||
border-width: 3px 0 3px 6px;
|
|
||||||
border-color: transparent transparent transparent var(--color-e-nearwhite);
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-submenu-arrow {
|
|
||||||
width: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.submenu-arrow,
|
|
||||||
.no-submenu-arrow {
|
|
||||||
margin-left: 6px;
|
|
||||||
margin-right: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover,
|
|
||||||
&.open {
|
|
||||||
background: var(--color-6-lowergray);
|
|
||||||
color: var(--color-f-white);
|
|
||||||
|
|
||||||
.entry-icon svg {
|
.entry-icon svg {
|
||||||
fill: var(--color-f-white);
|
fill: var(--color-e-nearwhite);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
background: var(--color-e-nearwhite);
|
|
||||||
color: var(--color-2-mildblack);
|
|
||||||
|
|
||||||
.entry-icon svg {
|
|
||||||
fill: var(--color-2-mildblack);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.disabled {
|
|
||||||
color: var(--color-8-uppergray);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
svg {
|
.no-icon {
|
||||||
fill: var(--color-8-uppergray);
|
width: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-label {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-icon,
|
||||||
|
.no-icon {
|
||||||
|
margin: 0 4px;
|
||||||
|
|
||||||
|
& + .entry-label {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-input-label {
|
||||||
|
margin-left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu-arrow {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-style: solid;
|
||||||
|
border-width: 3px 0 3px 6px;
|
||||||
|
border-color: transparent transparent transparent var(--color-e-nearwhite);
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-submenu-arrow {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu-arrow,
|
||||||
|
.no-submenu-arrow {
|
||||||
|
margin-left: 6px;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&.open {
|
||||||
|
background: var(--color-6-lowergray);
|
||||||
|
color: var(--color-f-white);
|
||||||
|
|
||||||
|
.entry-icon svg {
|
||||||
|
fill: var(--color-f-white);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: var(--color-e-nearwhite);
|
||||||
|
color: var(--color-2-mildblack);
|
||||||
|
|
||||||
|
.entry-icon svg {
|
||||||
|
fill: var(--color-2-mildblack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
color: var(--color-8-uppergray);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
fill: var(--color-8-uppergray);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,68 +1,60 @@
|
|||||||
<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 {
|
||||||
flex: 1 1 100%;
|
flex: 1 1 100%;
|
||||||
margin: 0 4px;
|
margin: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.options-bar {
|
||||||
|
height: 32px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sections {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-button {
|
||||||
|
flex-basis: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.options-bar {
|
|
||||||
height: 32px;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sections {
|
|
||||||
flex: 1 1 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-button {
|
|
||||||
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" />
|
height: 100%;
|
||||||
</div>
|
flex: 0 0 auto;
|
||||||
</template>
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
<style lang="scss">
|
}
|
||||||
.widget-layout {
|
|
||||||
height: 100%;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,213 +1,235 @@
|
|||||||
<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 },
|
|
||||||
layoutTarget: { required: true },
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
open: false,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
direction(): "column" | "row" | "ERROR" {
|
|
||||||
if (isWidgetColumn(this.widgetData)) return "column";
|
|
||||||
if (isWidgetRow(this.widgetData)) return "row";
|
|
||||||
return "ERROR";
|
|
||||||
},
|
|
||||||
widgets() {
|
|
||||||
let widgets: Widget[] = [];
|
|
||||||
if (isWidgetColumn(this.widgetData)) widgets = this.widgetData.columnWidgets;
|
|
||||||
if (isWidgetRow(this.widgetData)) widgets = this.widgetData.rowWidgets;
|
|
||||||
return widgets;
|
|
||||||
},
|
|
||||||
widgetsAndNextSiblingIsSuffix(): [Widget, boolean][] {
|
|
||||||
return this.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.
|
|
||||||
// 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.
|
|
||||||
const nextSiblingIsSuffix = SUFFIX_WIDGETS.includes(this.widgets[index + 1]?.props.kind);
|
|
||||||
|
|
||||||
return [widget, nextSiblingIsSuffix];
|
$: direction = watchDirection(widgetData);
|
||||||
});
|
$: widgets = watchWidgets(widgetData);
|
||||||
},
|
$: widgetsAndNextSiblingIsSuffix = watchWidgetsAndNextSiblingIsSuffix(widgets);
|
||||||
},
|
|
||||||
methods: {
|
function watchDirection(widgetData: WidgetRow | WidgetColumn): "row" | "column" | "ERROR" {
|
||||||
updateLayout(index: number, value: unknown) {
|
if (isWidgetRow(widgetData)) return "row";
|
||||||
this.editor.instance.updateLayout(this.layoutTarget, this.widgets[index].widgetId, value);
|
if (isWidgetColumn(widgetData)) return "column";
|
||||||
},
|
return "ERROR";
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
}
|
||||||
withoutValue(props: Record<string, any>): Record<string, unknown> {
|
|
||||||
const { value: _, ...rest } = props;
|
function watchWidgets(widgetData: WidgetRow | WidgetColumn): Widget[] {
|
||||||
return rest;
|
let widgets: Widget[] = [];
|
||||||
},
|
if (isWidgetRow(widgetData)) widgets = widgetData.rowWidgets;
|
||||||
debouncer,
|
else if (isWidgetColumn(widgetData)) widgets = widgetData.columnWidgets;
|
||||||
},
|
return widgets;
|
||||||
components: {
|
}
|
||||||
BreadcrumbTrailButtons,
|
|
||||||
CheckboxInput,
|
function watchWidgetsAndNextSiblingIsSuffix(widgets: Widget[]): [Widget, boolean][] {
|
||||||
ColorInput,
|
return widgets.map((widget, index): [Widget, boolean] => {
|
||||||
DropdownInput,
|
// A suffix widget is one that joins up with this widget at the end with only a 1px gap.
|
||||||
FontInput,
|
// It uses the CSS sibling selector to give its own left edge corners zero radius.
|
||||||
IconButton,
|
// But this JS is needed to set its preceding sibling widget's right edge corners to zero radius.
|
||||||
IconLabel,
|
const nextSiblingIsSuffix = SUFFIX_WIDGETS.includes(widgets[index + 1]?.props.kind);
|
||||||
LayerReferenceInput,
|
|
||||||
NumberInput,
|
return [widget, nextSiblingIsSuffix];
|
||||||
OptionalInput,
|
});
|
||||||
ParameterExposeButton,
|
}
|
||||||
PivotAssist,
|
|
||||||
PopoverButton,
|
function updateLayout(index: number, value: unknown) {
|
||||||
RadioInput,
|
editor.instance.updateLayout(layoutTarget, widgets[index].widgetId, value);
|
||||||
Separator,
|
}
|
||||||
SwatchPairInput,
|
|
||||||
TextAreaInput,
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
TextButton,
|
// 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)>> {
|
||||||
TextInput,
|
// const exclusions = ["kind", ...(additional || [])];
|
||||||
TextLabel,
|
|
||||||
},
|
// return Object.fromEntries(Object.entries(props).filter((entry) => !exclusions.includes(entry[0]))) as any;
|
||||||
});
|
// }
|
||||||
|
|
||||||
|
// TODO: This seems to work, but verify the correctness and terseness of this, it's adapted from https://stackoverflow.com/a/67434028/775283
|
||||||
|
function exclude<T extends object>(props: T, additional?: (keyof T)[]): Omit<T, typeof additional extends Array<infer K> ? K : never> {
|
||||||
|
const exclusions = ["kind", ...(additional || [])];
|
||||||
|
|
||||||
|
return Object.fromEntries(Object.entries(props).filter((entry) => !exclusions.includes(entry[0]))) as any;
|
||||||
|
}
|
||||||
</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 {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
display: flex;
|
|
||||||
min-height: 32px;
|
|
||||||
|
|
||||||
> * {
|
|
||||||
--widget-height: 24px;
|
|
||||||
margin: calc((24px - var(--widget-height)) / 2 + 4px) 0;
|
|
||||||
min-height: var(--widget-height);
|
|
||||||
|
|
||||||
&:not(.multiline) {
|
|
||||||
line-height: var(--widget-height);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.icon-label.size-12 {
|
|
||||||
--widget-height: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.icon-label.size-16 {
|
|
||||||
--widget-height: 16px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Target this in a better way than using the tooltip, which will break if changed, or when localized/translated
|
.widget-row {
|
||||||
.checkbox-input [title="Preserve Aspect Ratio"] {
|
flex: 0 0 auto;
|
||||||
margin-bottom: -32px;
|
display: flex;
|
||||||
position: relative;
|
min-height: 32px;
|
||||||
|
|
||||||
&::before,
|
> * {
|
||||||
&::after {
|
--widget-height: 24px;
|
||||||
content: "";
|
margin: calc((24px - var(--widget-height)) / 2 + 4px) 0;
|
||||||
pointer-events: none;
|
min-height: var(--widget-height);
|
||||||
position: absolute;
|
|
||||||
left: 8px;
|
&:not(.multiline) {
|
||||||
width: 1px;
|
line-height: var(--widget-height);
|
||||||
height: 16px;
|
}
|
||||||
background: var(--color-7-middlegray);
|
|
||||||
|
&.icon-label.size-12 {
|
||||||
|
--widget-height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.icon-label.size-16 {
|
||||||
|
--widget-height: 16px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&::before {
|
// TODO: Target this in a better way than using the tooltip, which will break if changed, or when localized/translated
|
||||||
top: calc(-4px - 16px);
|
.checkbox-input [title="Preserve Aspect Ratio"] {
|
||||||
}
|
margin-bottom: -32px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
&::after {
|
&::before,
|
||||||
bottom: calc(-4px - 16px);
|
&::after {
|
||||||
|
content: "";
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
left: 8px;
|
||||||
|
width: 1px;
|
||||||
|
height: 16px;
|
||||||
|
background: var(--color-7-middlegray);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
top: calc(-4px - 16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
bottom: calc(-4px - 16px);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,122 +1,116 @@
|
|||||||
<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;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
--pivot-border-color: var(--color-5-dullgray);
|
--pivot-border-color: var(--color-5-dullgray);
|
||||||
--pivot-fill-active: var(--color-e-nearwhite);
|
--pivot-fill-active: var(--color-e-nearwhite);
|
||||||
|
|
||||||
button {
|
button {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 5px;
|
width: 5px;
|
||||||
height: 5px;
|
height: 5px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: var(--color-1-nearblack);
|
background: var(--color-1-nearblack);
|
||||||
border: 1px solid var(--pivot-border-color);
|
border: 1px solid var(--pivot-border-color);
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
|
border-color: transparent;
|
||||||
|
background: var(--pivot-fill-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.col-1::before,
|
||||||
|
&.col-2::before {
|
||||||
|
content: "";
|
||||||
|
pointer-events: none;
|
||||||
|
width: 2px;
|
||||||
|
height: 0;
|
||||||
|
border-top: 1px solid var(--pivot-border-color);
|
||||||
|
position: absolute;
|
||||||
|
top: 1px;
|
||||||
|
right: -3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.row-1::after,
|
||||||
|
&.row-2::after {
|
||||||
|
content: "";
|
||||||
|
pointer-events: none;
|
||||||
|
width: 0;
|
||||||
|
height: 2px;
|
||||||
|
border-left: 1px solid var(--pivot-border-color);
|
||||||
|
position: absolute;
|
||||||
|
bottom: -3px;
|
||||||
|
right: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.row-1 {
|
||||||
|
top: 3px;
|
||||||
|
}
|
||||||
|
&.col-1 {
|
||||||
|
left: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.row-2 {
|
||||||
|
top: 10px;
|
||||||
|
}
|
||||||
|
&.col-2 {
|
||||||
|
left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.row-3 {
|
||||||
|
top: 17px;
|
||||||
|
}
|
||||||
|
&.col-3 {
|
||||||
|
left: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click targets that extend 1px beyond the borders of each square
|
||||||
|
div {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 2px;
|
||||||
|
margin: -2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(.disabled) button:not(.active):hover {
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
background: var(--pivot-fill-active);
|
background: var(--color-6-lowergray);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.col-1::before,
|
&.disabled button {
|
||||||
&.col-2::before {
|
--pivot-border-color: var(--color-4-dimgray);
|
||||||
content: "";
|
--pivot-fill-active: var(--color-8-uppergray);
|
||||||
pointer-events: none;
|
|
||||||
width: 2px;
|
|
||||||
height: 0;
|
|
||||||
border-top: 1px solid var(--pivot-border-color);
|
|
||||||
position: absolute;
|
|
||||||
top: 1px;
|
|
||||||
right: -3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.row-1::after,
|
|
||||||
&.row-2::after {
|
|
||||||
content: "";
|
|
||||||
pointer-events: none;
|
|
||||||
width: 0;
|
|
||||||
height: 2px;
|
|
||||||
border-left: 1px solid var(--pivot-border-color);
|
|
||||||
position: absolute;
|
|
||||||
bottom: -3px;
|
|
||||||
right: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.row-1 {
|
|
||||||
top: 3px;
|
|
||||||
}
|
|
||||||
&.col-1 {
|
|
||||||
left: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.row-2 {
|
|
||||||
top: 10px;
|
|
||||||
}
|
|
||||||
&.col-2 {
|
|
||||||
left: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.row-3 {
|
|
||||||
top: 17px;
|
|
||||||
}
|
|
||||||
&.col-3 {
|
|
||||||
left: 17px;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Click targets that extend 1px beyond the borders of each square
|
|
||||||
div {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
padding: 2px;
|
|
||||||
margin: -2px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&:not(.disabled) button:not(.active):hover {
|
|
||||||
border-color: transparent;
|
|
||||||
background: var(--color-6-lowergray);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.disabled button {
|
|
||||||
--pivot-border-color: var(--color-4-dimgray);
|
|
||||||
--pivot-fill-active: var(--color-8-uppergray);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -1,81 +1,63 @@
|
|||||||
<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";
|
export let labels: string[];
|
||||||
import TextButton from "@/components/widgets/buttons/TextButton.vue";
|
export let disabled = false;
|
||||||
|
export let tooltip: string | undefined = undefined;
|
||||||
export default defineComponent({
|
// Callbacks
|
||||||
props: {
|
export let action: (index: number) => void;
|
||||||
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 },
|
|
||||||
|
|
||||||
// Callbacks
|
|
||||||
action: { type: Function as PropType<(index: number) => void>, required: true },
|
|
||||||
},
|
|
||||||
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;
|
||||||
|
|
||||||
&:not(:first-of-type) {
|
&:not(:first-of-type) {
|
||||||
border-top-left-radius: 0;
|
border-top-left-radius: 0;
|
||||||
border-bottom-left-radius: 0;
|
border-bottom-left-radius: 0;
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: -4px;
|
left: -4px;
|
||||||
width: 0;
|
width: 0;
|
||||||
height: 0;
|
height: 0;
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
border-width: 12px 0 12px 4px;
|
border-width: 12px 0 12px 4px;
|
||||||
border-color: var(--button-background-color) var(--button-background-color) var(--button-background-color) transparent;
|
border-color: var(--button-background-color) var(--button-background-color) var(--button-background-color) transparent;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
&:not(:last-of-type) {
|
&:not(:last-of-type) {
|
||||||
border-top-right-radius: 0;
|
border-top-right-radius: 0;
|
||||||
border-bottom-right-radius: 0;
|
border-bottom-right-radius: 0;
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: -4px;
|
right: -4px;
|
||||||
width: 0;
|
width: 0;
|
||||||
height: 0;
|
height: 0;
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
border-width: 12px 0 12px 4px;
|
border-width: 12px 0 12px 4px;
|
||||||
border-color: transparent transparent transparent var(--button-background-color);
|
border-color: transparent transparent transparent var(--button-background-color);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
&:last-of-type {
|
&:last-of-type {
|
||||||
// Make this non-functional button not change color on hover
|
// Make this non-functional button not change color on hover
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,103 +1,104 @@
|
|||||||
<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 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
|
||||||
|
export let action: (e?: MouseEvent) => void;
|
||||||
|
|
||||||
export default defineComponent({
|
let className = "";
|
||||||
props: {
|
export { className as class };
|
||||||
icon: { type: String as PropType<IconName>, required: true },
|
export let classes: Record<string, boolean> = {};
|
||||||
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 },
|
|
||||||
|
|
||||||
// Callbacks
|
$: extraClasses = Object.entries(classes)
|
||||||
action: { type: Function as PropType<(e?: MouseEvent) => void>, required: true },
|
.flatMap((classAndState) => (classAndState[1] ? [classAndState[0]] : []))
|
||||||
},
|
.join(" ");
|
||||||
components: { IconLabel },
|
|
||||||
});
|
|
||||||
</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;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
background: none;
|
|
||||||
|
|
||||||
svg {
|
|
||||||
fill: var(--color-e-nearwhite);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The `where` pseudo-class does not contribtue to specificity
|
|
||||||
& + :where(.icon-button) {
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--color-6-lowergray);
|
|
||||||
color: var(--color-f-white);
|
|
||||||
|
|
||||||
svg {
|
|
||||||
fill: var(--color-f-white);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.disabled {
|
|
||||||
background: none;
|
background: none;
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
fill: var(--color-8-uppergray);
|
fill: var(--color-e-nearwhite);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The `where` pseudo-class does not contribtue to specificity
|
||||||
|
& + :where(.icon-button) {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-6-lowergray);
|
||||||
|
color: var(--color-f-white);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
fill: var(--color-f-white);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
background: none;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
fill: var(--color-8-uppergray);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: var(--color-e-nearwhite);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
fill: var(--color-2-mildblack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.size-12 {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.size-16 {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.size-24 {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.size-32 {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.active {
|
|
||||||
background: var(--color-e-nearwhite);
|
|
||||||
|
|
||||||
svg {
|
|
||||||
fill: var(--color-2-mildblack);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.size-12 {
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.size-16 {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.size-24 {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.size-32 {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,59 +1,49 @@
|
|||||||
<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 exposed: boolean;
|
||||||
|
export let dataType: string;
|
||||||
export default defineComponent({
|
export let tooltip: string | undefined = undefined;
|
||||||
props: {
|
// Callbacks
|
||||||
exposed: { type: Boolean as PropType<boolean>, required: true },
|
export let action: (e?: MouseEvent) => void;
|
||||||
dataType: { type: String as PropType<string>, required: true },
|
|
||||||
tooltip: { type: String as PropType<string | undefined>, required: false },
|
|
||||||
|
|
||||||
// Callbacks
|
|
||||||
action: { type: Function as PropType<(e?: MouseEvent) => void>, required: true },
|
|
||||||
},
|
|
||||||
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;
|
|
||||||
max-height: 24px;
|
|
||||||
|
|
||||||
button {
|
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
width: 8px;
|
max-height: 24px;
|
||||||
height: 8px;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
border: none;
|
|
||||||
border-radius: 50%;
|
|
||||||
|
|
||||||
&:not(.exposed) {
|
button {
|
||||||
background: none;
|
flex: 0 0 auto;
|
||||||
border: 1px solid var(--data-type-color);
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
|
||||||
&:hover {
|
&:not(.exposed) {
|
||||||
background: var(--color-6-lowergray);
|
background: none;
|
||||||
|
border: 1px solid var(--data-type-color);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-6-lowergray);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
&.exposed {
|
&.exposed {
|
||||||
background: var(--data-type-color);
|
background: var(--data-type-color);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border: 1px solid var(--color-f-white);
|
border: 1px solid var(--color-f-white);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,90 +1,71 @@
|
|||||||
<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 IconButton from "@/components/widgets/buttons/IconButton.svelte";
|
||||||
|
|
||||||
import FloatingMenu from "@/components/layout/FloatingMenu.vue";
|
export let icon: IconName = "DropdownArrow";
|
||||||
import LayoutRow from "@/components/layout/LayoutRow.vue";
|
export let tooltip: string | undefined = undefined;
|
||||||
import IconButton from "@/components/widgets/buttons/IconButton.vue";
|
export let disabled = false;
|
||||||
|
// Callbacks
|
||||||
|
export let action: (() => void) | undefined = undefined;
|
||||||
|
|
||||||
export default defineComponent({
|
let open = false;
|
||||||
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 },
|
|
||||||
|
|
||||||
// Callbacks
|
function onClick() {
|
||||||
action: { type: Function as PropType<() => void>, required: false },
|
open = true;
|
||||||
},
|
action?.();
|
||||||
data() {
|
}
|
||||||
return {
|
|
||||||
open: false,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
onClick() {
|
|
||||||
this.open = true;
|
|
||||||
|
|
||||||
this.action?.();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
components: {
|
|
||||||
FloatingMenu,
|
|
||||||
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;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
|
||||||
.floating-menu {
|
.floating-menu {
|
||||||
left: 50%;
|
left: 50%;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
}
|
|
||||||
|
|
||||||
.icon-button {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
padding: 0;
|
|
||||||
border: none;
|
|
||||||
border-radius: 2px;
|
|
||||||
background: var(--color-1-nearblack);
|
|
||||||
fill: var(--color-e-nearwhite);
|
|
||||||
|
|
||||||
&:hover,
|
|
||||||
&.open {
|
|
||||||
background: var(--color-6-lowergray);
|
|
||||||
fill: var(--color-f-white);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&.disabled {
|
.icon-button.icon-button {
|
||||||
background: var(--color-2-mildblack);
|
width: 100%;
|
||||||
fill: var(--color-8-uppergray);
|
height: 100%;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--color-1-nearblack);
|
||||||
|
fill: var(--color-e-nearwhite);
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&.open {
|
||||||
|
background: var(--color-6-lowergray);
|
||||||
|
fill: var(--color-f-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
background: var(--color-2-mildblack);
|
||||||
|
fill: var(--color-8-uppergray);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Refactor this and other complicated cases dealing with joined widget margins and border-radius by adding a single standard set of classes: joined-first, joined-inner, and joined-last
|
||||||
|
div[class*="-input"] + & {
|
||||||
|
margin-left: 1px;
|
||||||
|
|
||||||
|
.icon-button {
|
||||||
|
border-radius: 0 2px 2px 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Refactor this and other complicated cases dealing with joined widget margins and border-radius by adding a single standard set of classes: joined-first, joined-inner, and joined-last
|
|
||||||
div[class*="-input"] + & {
|
|
||||||
margin-left: 1px;
|
|
||||||
|
|
||||||
.icon-button {
|
|
||||||
border-radius: 0 2px 2px 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,99 +1,92 @@
|
|||||||
<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 let minWidth: number = 0;
|
||||||
|
export let disabled: boolean = false;
|
||||||
|
export let tooltip: string | undefined = undefined;
|
||||||
|
export let sharpRightCorners: boolean = false;
|
||||||
|
|
||||||
export default defineComponent({
|
// Callbacks
|
||||||
props: {
|
// TODO: Replace this with an event binding (and on other components that do this)
|
||||||
label: { type: String as PropType<string>, required: true },
|
export let action: (e: MouseEvent) => void;
|
||||||
icon: { type: String as PropType<IconName | undefined>, required: 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
|
|
||||||
action: { type: Function as PropType<(e: MouseEvent) => void>, required: true },
|
|
||||||
},
|
|
||||||
components: {
|
|
||||||
IconLabel,
|
|
||||||
TextLabel,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<button
|
||||||
<button
|
class="text-button"
|
||||||
class="text-button"
|
class:emphasized
|
||||||
:class="{ emphasized, disabled, 'sharp-right-corners': sharpRightCorners }"
|
class:disabled
|
||||||
:data-emphasized="emphasized || undefined"
|
class:sharp-right-corners={sharpRightCorners}
|
||||||
:data-disabled="disabled || undefined"
|
style:min-width={minWidth > 0 ? `${minWidth}px` : undefined}
|
||||||
data-text-button
|
title={tooltip}
|
||||||
:title="tooltip"
|
data-emphasized={emphasized || undefined}
|
||||||
:style="{ 'min-width': minWidth > 0 ? `${minWidth}px` : undefined }"
|
data-disabled={disabled || undefined}
|
||||||
@click="(e: MouseEvent) => action(e)"
|
data-text-button
|
||||||
:tabindex="disabled ? -1 : 0"
|
tabindex={disabled ? -1 : 0}
|
||||||
>
|
on:click={action}
|
||||||
<IconLabel v-if="icon" :icon="icon" />
|
>
|
||||||
<TextLabel>{{ label }}</TextLabel>
|
{#if icon}
|
||||||
</button>
|
<IconLabel {icon} />
|
||||||
</template>
|
{/if}
|
||||||
|
<TextLabel>{label}</TextLabel>
|
||||||
|
</button>
|
||||||
|
|
||||||
<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;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
background: var(--button-background-color);
|
background: var(--button-background-color);
|
||||||
color: var(--button-text-color);
|
color: var(--button-text-color);
|
||||||
--button-background-color: var(--color-5-dullgray);
|
--button-background-color: var(--color-5-dullgray);
|
||||||
--button-text-color: var(--color-e-nearwhite);
|
--button-text-color: var(--color-e-nearwhite);
|
||||||
|
|
||||||
&:hover {
|
|
||||||
--button-background-color: var(--color-6-lowergray);
|
|
||||||
--button-text-color: var(--color-f-white);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.disabled {
|
|
||||||
--button-background-color: var(--color-4-dimgray);
|
|
||||||
--button-text-color: var(--color-8-uppergray);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.emphasized {
|
|
||||||
--button-background-color: var(--color-e-nearwhite);
|
|
||||||
--button-text-color: var(--color-2-mildblack);
|
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
--button-background-color: var(--color-f-white);
|
--button-background-color: var(--color-6-lowergray);
|
||||||
|
--button-text-color: var(--color-f-white);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.disabled {
|
&.disabled {
|
||||||
--button-background-color: var(--color-8-uppergray);
|
--button-background-color: var(--color-4-dimgray);
|
||||||
|
--button-text-color: var(--color-8-uppergray);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.emphasized {
|
||||||
|
--button-background-color: var(--color-e-nearwhite);
|
||||||
|
--button-text-color: var(--color-2-mildblack);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
--button-background-color: var(--color-f-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
--button-background-color: var(--color-8-uppergray);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& + .text-button {
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-label {
|
||||||
|
position: relative;
|
||||||
|
left: -4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-label {
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
& + .text-button {
|
|
||||||
margin-left: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-label {
|
|
||||||
position: relative;
|
|
||||||
left: -4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-label {
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,165 +1,147 @@
|
|||||||
<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>
|
{#if expanded}
|
||||||
<LayoutCol class="body" v-if="expanded">
|
<LayoutCol class="body">
|
||||||
<component :is="layoutGroupType(layoutRow)" :widgetData="layoutRow" :layoutTarget="layoutTarget" v-for="(layoutRow, index) in widgetData.layout" :key="index"></component>
|
{#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 {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
align-items: center;
|
|
||||||
display: flex;
|
|
||||||
flex: 0 0 24px;
|
|
||||||
padding: 0 8px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: var(--color-5-dullgray);
|
|
||||||
|
|
||||||
.expand-arrow {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
position: relative;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
display: flex;
|
||||||
|
flex: 0 0 24px;
|
||||||
|
padding: 0 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--color-5-dullgray);
|
||||||
|
|
||||||
&::after {
|
.expand-arrow {
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
background: var(--icon-expand-collapse-arrow);
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
position: relative;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background: var(--icon-expand-collapse-arrow);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
&.expanded {
|
&.expanded {
|
||||||
border-radius: 4px 4px 0 0;
|
border-radius: 4px 4px 0 0;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
|
|
||||||
.expand-arrow::after {
|
.expand-arrow::after {
|
||||||
transform: rotate(90deg);
|
transform: rotate(90deg);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.text-label {
|
|
||||||
height: 18px;
|
|
||||||
margin-left: 8px;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--color-6-lowergray);
|
|
||||||
|
|
||||||
.expand-arrow::after {
|
|
||||||
background: var(--icon-expand-collapse-arrow-hover);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-label {
|
.text-label {
|
||||||
color: var(--color-f-white);
|
height: 18px;
|
||||||
|
margin-left: 8px;
|
||||||
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
+ .body {
|
&:hover {
|
||||||
border: 1px solid var(--color-6-lowergray);
|
background: var(--color-6-lowergray);
|
||||||
|
|
||||||
|
.expand-arrow::after {
|
||||||
|
background: var(--icon-expand-collapse-arrow-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-label {
|
||||||
|
color: var(--color-f-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
+ .body {
|
||||||
|
border: 1px solid var(--color-6-lowergray);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
padding: 0 7px;
|
||||||
|
padding-top: 1px;
|
||||||
|
margin-top: -1px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
border: 1px solid var(--color-5-dullgray);
|
||||||
|
border-radius: 0 0 4px 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.widget-row {
|
||||||
|
&:first-child {
|
||||||
|
margin-top: calc(4px - 1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: calc(4px - 1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
> .text-button:first-child {
|
||||||
|
margin-left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
> .text-label:first-of-type {
|
||||||
|
flex: 0 0 25%;
|
||||||
|
margin-left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
> .parameter-expose-button ~ .text-label:first-of-type {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
> .text-button {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
> .radio-input button {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.body {
|
|
||||||
padding: 0 7px;
|
|
||||||
padding-top: 1px;
|
|
||||||
margin-top: -1px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
border: 1px solid var(--color-5-dullgray);
|
|
||||||
border-radius: 0 0 4px 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
.widget-row {
|
|
||||||
&:first-child {
|
|
||||||
margin-top: calc(4px - 1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
margin-bottom: calc(4px - 1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
> .text-button:first-child {
|
|
||||||
margin-left: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
> .text-label:first-of-type {
|
|
||||||
flex: 0 0 25%;
|
|
||||||
margin-left: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
> .parameter-expose-button ~ .text-label:first-of-type {
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
> .text-button {
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
> .radio-input button {
|
|
||||||
flex: 1 1 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,127 +1,109 @@
|
|||||||
<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) {
|
|
||||||
const target = (e.target || undefined) as HTMLLabelElement | undefined;
|
$: displayIcon = (!checked && icon === "Checkmark" ? "Empty12px" : icon) as IconName;
|
||||||
const previousSibling = (target?.previousSibling || undefined) as HTMLInputElement | undefined;
|
|
||||||
previousSibling?.click();
|
export function isChecked() {
|
||||||
},
|
return checked;
|
||||||
},
|
}
|
||||||
components: {
|
|
||||||
IconLabel,
|
export function input(): HTMLInputElement {
|
||||||
LayoutRow,
|
return inputElement;
|
||||||
},
|
}
|
||||||
});
|
|
||||||
|
function toggleCheckboxFromLabel(e: KeyboardEvent) {
|
||||||
|
const target = (e.target || undefined) as HTMLLabelElement | undefined;
|
||||||
|
const previousSibling = (target?.previousSibling || undefined) as HTMLInputElement | undefined;
|
||||||
|
previousSibling?.click();
|
||||||
|
}
|
||||||
</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"
|
<LayoutRow class="checkbox-box">
|
||||||
:id="`checkbox-input-${id}`"
|
<IconLabel icon={displayIcon} />
|
||||||
:checked="checked"
|
</LayoutRow>
|
||||||
@change="(e) => $emit('update:checked', (e.target as HTMLInputElement).checked)"
|
</label>
|
||||||
:disabled="disabled"
|
</LayoutRow>
|
||||||
:tabindex="disabled ? -1 : 0"
|
|
||||||
/>
|
|
||||||
<label :class="{ disabled, checked }" :for="`checkbox-input-${id}`" @keydown.enter="(e) => toggleCheckboxFromLabel(e)" :title="tooltip">
|
|
||||||
<LayoutRow class="checkbox-box">
|
|
||||||
<IconLabel :icon="displayIcon" />
|
|
||||||
</LayoutRow>
|
|
||||||
</label>
|
|
||||||
</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;
|
||||||
|
|
||||||
input {
|
input {
|
||||||
// We can't use `display: none` because it must be visible to work as a tabbale input that accepts a space bar actuation
|
// We can't use `display: none` because it must be visible to work as a tabbale input that accepts a space bar actuation
|
||||||
width: 0;
|
width: 0;
|
||||||
height: 0;
|
height: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unchecked
|
// Unchecked
|
||||||
label {
|
label {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
// Provides rounded corners for the :focus outline
|
// Provides rounded corners for the :focus outline
|
||||||
border-radius: 2px;
|
|
||||||
|
|
||||||
.checkbox-box {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
background: var(--color-5-dullgray);
|
|
||||||
padding: 2px;
|
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
|
|
||||||
.icon-label {
|
.checkbox-box {
|
||||||
fill: var(--color-8-uppergray);
|
flex: 0 0 auto;
|
||||||
|
background: var(--color-5-dullgray);
|
||||||
|
padding: 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
|
||||||
|
.icon-label {
|
||||||
|
fill: var(--color-8-uppergray);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hovered
|
||||||
|
&:hover .checkbox-box {
|
||||||
|
background: var(--color-6-lowergray);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disabled
|
||||||
|
&.disabled .checkbox-box {
|
||||||
|
background: var(--color-4-dimgray);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hovered
|
// Checked
|
||||||
&:hover .checkbox-box {
|
input:checked + label {
|
||||||
background: var(--color-6-lowergray);
|
.checkbox-box {
|
||||||
}
|
background: var(--color-e-nearwhite);
|
||||||
|
|
||||||
// Disabled
|
.icon-label {
|
||||||
&.disabled .checkbox-box {
|
fill: var(--color-2-mildblack);
|
||||||
background: var(--color-4-dimgray);
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Checked
|
// Hovered
|
||||||
input:checked + label {
|
&:hover .checkbox-box {
|
||||||
.checkbox-box {
|
background: var(--color-f-white);
|
||||||
background: var(--color-e-nearwhite);
|
}
|
||||||
|
|
||||||
.icon-label {
|
// Hovered
|
||||||
fill: var(--color-2-mildblack);
|
&.disabled .checkbox-box {
|
||||||
|
background: var(--color-8-uppergray);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hovered
|
|
||||||
&:hover .checkbox-box {
|
|
||||||
background: var(--color-f-white);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hovered
|
|
||||||
&.disabled .checkbox-box {
|
|
||||||
background: var(--color-8-uppergray);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,133 +1,113 @@
|
|||||||
<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}
|
||||||
:class="{ none: value.none, 'sharp-right-corners': sharpRightCorners }"
|
class:sharp-right-corners={sharpRightCorners}
|
||||||
:style="{ '--chosen-color': value.toHexOptionalAlpha() }"
|
style:--chosen-color={value.toHexOptionalAlpha()}
|
||||||
@click="() => $emit('update:open', true)"
|
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}
|
||||||
</button>
|
<TextLabel class="chip" bold={true}>{chip}</TextLabel>
|
||||||
<ColorPicker v-model:open="isOpen" :color="value" @update:color="(color: Color) => colorPickerUpdated(color)" :allowNone="true" />
|
{/if}
|
||||||
</LayoutRow>
|
</button>
|
||||||
</template>
|
<ColorPicker
|
||||||
|
{open}
|
||||||
|
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;
|
|
||||||
border: 1px solid var(--color-5-dullgray);
|
|
||||||
border-radius: 2px;
|
|
||||||
padding: 1px;
|
|
||||||
|
|
||||||
> button {
|
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
border: 1px solid var(--color-5-dullgray);
|
||||||
border: none;
|
border-radius: 2px;
|
||||||
margin: 0;
|
padding: 1px;
|
||||||
padding: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
border-radius: 1px;
|
|
||||||
|
|
||||||
&::before {
|
> button {
|
||||||
content: "";
|
position: relative;
|
||||||
position: absolute;
|
overflow: hidden;
|
||||||
|
border: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding: 2px;
|
border-radius: 1px;
|
||||||
top: -2px;
|
|
||||||
left: -2px;
|
&::before {
|
||||||
background: linear-gradient(var(--chosen-color), var(--chosen-color)), var(--color-transparent-checkered-background);
|
content: "";
|
||||||
background-size: var(--color-transparent-checkered-background-size);
|
position: absolute;
|
||||||
background-position: var(--color-transparent-checkered-background-position);
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 2px;
|
||||||
|
top: -2px;
|
||||||
|
left: -2px;
|
||||||
|
background: linear-gradient(var(--chosen-color), var(--chosen-color)), var(--color-transparent-checkered-background);
|
||||||
|
background-size: var(--color-transparent-checkered-background-size);
|
||||||
|
background-position: var(--color-transparent-checkered-background-position);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.none {
|
||||||
|
background: var(--color-none);
|
||||||
|
background-repeat: var(--color-none-repeat);
|
||||||
|
background-position: var(--color-none-position);
|
||||||
|
background-size: var(--color-none-size-24px);
|
||||||
|
background-image: var(--color-none-image-24px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
position: absolute;
|
||||||
|
bottom: -1px;
|
||||||
|
right: 0;
|
||||||
|
height: 13px;
|
||||||
|
line-height: 13px;
|
||||||
|
background: var(--color-f-white);
|
||||||
|
color: var(--color-2-mildblack);
|
||||||
|
border-radius: 4px 0 0 0;
|
||||||
|
padding: 0 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
box-shadow: 0 0 2px var(--color-3-darkgray);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.none {
|
&.color-input.color-input > button {
|
||||||
background: var(--color-none);
|
outline-offset: 0;
|
||||||
background-repeat: var(--color-none-repeat);
|
|
||||||
background-position: var(--color-none-position);
|
|
||||||
background-size: var(--color-none-size-24px);
|
|
||||||
background-image: var(--color-none-image-24px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chip {
|
> .floating-menu {
|
||||||
position: absolute;
|
left: 50%;
|
||||||
bottom: -1px;
|
bottom: 0;
|
||||||
right: 0;
|
|
||||||
height: 13px;
|
|
||||||
line-height: 13px;
|
|
||||||
background: var(--color-f-white);
|
|
||||||
color: var(--color-2-mildblack);
|
|
||||||
border-radius: 4px 0 0 0;
|
|
||||||
padding: 0 4px;
|
|
||||||
font-size: 10px;
|
|
||||||
box-shadow: 0 0 2px var(--color-3-darkgray);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.color-input.color-input > button {
|
|
||||||
outline-offset: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
> .floating-menu {
|
|
||||||
left: 50%;
|
|
||||||
bottom: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,174 +1,163 @@
|
|||||||
<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 },
|
|
||||||
selectedIndex: { type: Number as PropType<number>, required: false }, // When not provided, a dash is displayed
|
|
||||||
drawIcon: { type: Boolean as PropType<boolean>, default: false },
|
|
||||||
interactive: { type: Boolean as PropType<boolean>, default: true },
|
|
||||||
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 {
|
|
||||||
activeEntry: this.makeActiveEntry(this.selectedIndex),
|
|
||||||
activeEntrySkipWatcher: false,
|
|
||||||
open: false,
|
|
||||||
minWidth: 0,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
// Called only when `selectedIndex` is changed from outside this component (with v-model)
|
|
||||||
selectedIndex() {
|
|
||||||
this.activeEntrySkipWatcher = true;
|
|
||||||
this.activeEntry = this.makeActiveEntry();
|
|
||||||
},
|
|
||||||
// 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(newActiveEntry: MenuListEntry) {
|
|
||||||
if (this.activeEntrySkipWatcher) {
|
|
||||||
this.activeEntrySkipWatcher = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// `toRaw()` pulls it out of the Vue proxy
|
let menuList: MenuList;
|
||||||
if (toRaw(newActiveEntry) === DASH_ENTRY) return;
|
let self: LayoutRow;
|
||||||
|
|
||||||
this.$emit("update:selectedIndex", this.entries.flat().indexOf(newActiveEntry));
|
export let entries: MenuListEntry[][];
|
||||||
},
|
export let selectedIndex: number | undefined = undefined; // When not provided, a dash is displayed
|
||||||
},
|
export let drawIcon = false;
|
||||||
methods: {
|
export let interactive = true;
|
||||||
makeActiveEntry(): MenuListEntry {
|
export let disabled = false;
|
||||||
const entries = this.entries.flat();
|
export let tooltip: string | undefined = undefined;
|
||||||
|
export let sharpRightCorners = false;
|
||||||
|
|
||||||
if (this.selectedIndex !== undefined && this.selectedIndex >= 0 && this.selectedIndex < entries.length) {
|
let activeEntry = makeActiveEntry();
|
||||||
return entries[this.selectedIndex];
|
let activeEntrySkipWatcher = false;
|
||||||
}
|
let open = false;
|
||||||
return DASH_ENTRY;
|
let minWidth = 0;
|
||||||
},
|
|
||||||
keydown(e: KeyboardEvent) {
|
$: selectedIndex, watchSelectedIndex();
|
||||||
(this.$refs.menuList as typeof MenuList | undefined)?.keydown(e, false);
|
$: watchActiveEntry(activeEntry);
|
||||||
},
|
|
||||||
unFocusDropdownBox(e: FocusEvent) {
|
// Called only when `selectedIndex` is changed from outside this component
|
||||||
const blurTarget = (e.target as HTMLDivElement | undefined)?.closest("[data-dropdown-input]");
|
function watchSelectedIndex() {
|
||||||
const self: HTMLDivElement | undefined = this.$el;
|
activeEntrySkipWatcher = true;
|
||||||
if (blurTarget !== self) this.open = false;
|
activeEntry = makeActiveEntry();
|
||||||
},
|
}
|
||||||
},
|
|
||||||
components: {
|
// 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)
|
||||||
IconLabel,
|
function watchActiveEntry(activeEntry: MenuListEntry) {
|
||||||
LayoutRow,
|
if (activeEntrySkipWatcher) {
|
||||||
MenuList,
|
activeEntrySkipWatcher = false;
|
||||||
TextLabel,
|
} else if (activeEntry !== DASH_ENTRY) {
|
||||||
},
|
dispatch("selectedIndex", entries.flat().indexOf(activeEntry));
|
||||||
});
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeActiveEntry(): MenuListEntry {
|
||||||
|
const allEntries = entries.flat();
|
||||||
|
|
||||||
|
if (selectedIndex !== undefined && selectedIndex >= 0 && selectedIndex < allEntries.length) {
|
||||||
|
return allEntries[selectedIndex];
|
||||||
|
}
|
||||||
|
return DASH_ENTRY;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unFocusDropdownBox(e: FocusEvent) {
|
||||||
|
const blurTarget = (e.target as HTMLDivElement | undefined)?.closest("[data-dropdown-input]");
|
||||||
|
if (blurTarget !== self.div()) open = false;
|
||||||
|
}
|
||||||
</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"
|
classes={{ disabled, open, "sharp-right-corners": sharpRightCorners }}
|
||||||
:class="{ disabled, open, 'sharp-right-corners': sharpRightCorners }"
|
styles={{ minWidth: `${minWidth}px` }}
|
||||||
:style="{ minWidth: `${minWidth}px` }"
|
{tooltip}
|
||||||
:title="tooltip"
|
on:click={() => !disabled && (open = true)}
|
||||||
@click="() => !disabled && (open = true)"
|
on:blur={unFocusDropdownBox}
|
||||||
@blur="(e: FocusEvent) => unFocusDropdownBox(e)"
|
on:keydown={(e) => menuList.keydown(e, false)}
|
||||||
@keydown="(e: KeyboardEvent) => keydown(e)"
|
tabindex={disabled ? -1 : 0}
|
||||||
:tabindex="disabled ? -1 : 0"
|
data-floating-menu-spawner
|
||||||
data-floating-menu-spawner
|
>
|
||||||
>
|
{#if activeEntry.icon}
|
||||||
<IconLabel class="dropdown-icon" :icon="activeEntry.icon" v-if="activeEntry.icon" />
|
<IconLabel class="dropdown-icon" icon={activeEntry.icon} />
|
||||||
<TextLabel class="dropdown-label">{{ activeEntry.label }}</TextLabel>
|
{/if}
|
||||||
<IconLabel class="dropdown-arrow" :icon="'DropdownArrow'" />
|
<TextLabel class="dropdown-label">{activeEntry.label}</TextLabel>
|
||||||
</LayoutRow>
|
<IconLabel class="dropdown-arrow" icon="DropdownArrow" />
|
||||||
<MenuList
|
|
||||||
v-model:activeEntry="activeEntry"
|
|
||||||
v-model:open="open"
|
|
||||||
@naturalWidth="(newNaturalWidth: number) => (minWidth = newNaturalWidth)"
|
|
||||||
:entries="entries"
|
|
||||||
:drawIcon="drawIcon"
|
|
||||||
:interactive="interactive"
|
|
||||||
:direction="'Bottom'"
|
|
||||||
:scrollableY="true"
|
|
||||||
ref="menuList"
|
|
||||||
/>
|
|
||||||
</LayoutRow>
|
</LayoutRow>
|
||||||
</template>
|
<MenuList
|
||||||
|
on:naturalWidth={({ detail }) => (minWidth = detail)}
|
||||||
|
{activeEntry}
|
||||||
|
on:activeEntry={({ detail }) => (activeEntry = detail)}
|
||||||
|
{open}
|
||||||
|
on:open={({ detail }) => (open = detail)}
|
||||||
|
{entries}
|
||||||
|
{drawIcon}
|
||||||
|
{interactive}
|
||||||
|
direction="Bottom"
|
||||||
|
scrollableY={true}
|
||||||
|
bind:this={menuList}
|
||||||
|
/>
|
||||||
|
</LayoutRow>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss" global>
|
||||||
.dropdown-input {
|
.dropdown-input {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
.dropdown-box {
|
.dropdown-box {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
background: var(--color-1-nearblack);
|
background: var(--color-1-nearblack);
|
||||||
height: 24px;
|
height: 24px;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
|
|
||||||
.dropdown-label {
|
.dropdown-label {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
flex: 1 1 100%;
|
flex: 1 1 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-icon {
|
.dropdown-icon {
|
||||||
margin: 4px;
|
margin: 4px;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
|
||||||
& + .dropdown-label {
|
& + .dropdown-label {
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-arrow {
|
||||||
|
margin: 6px 2px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&.open {
|
||||||
|
background: var(--color-6-lowergray);
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: var(--color-f-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
fill: var(--color-f-white);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.open {
|
||||||
|
border-radius: 2px 2px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
background: var(--color-2-mildblack);
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: var(--color-8-uppergray);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
fill: var(--color-8-uppergray);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-arrow {
|
.menu-list .floating-menu-container .floating-menu-content {
|
||||||
margin: 6px 2px;
|
max-height: 400px;
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover,
|
|
||||||
&.open {
|
|
||||||
background: var(--color-6-lowergray);
|
|
||||||
|
|
||||||
span {
|
|
||||||
color: var(--color-f-white);
|
|
||||||
}
|
|
||||||
|
|
||||||
svg {
|
|
||||||
fill: var(--color-f-white);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.open {
|
|
||||||
border-radius: 2px 2px 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.disabled {
|
|
||||||
background: var(--color-2-mildblack);
|
|
||||||
|
|
||||||
span {
|
|
||||||
color: var(--color-8-uppergray);
|
|
||||||
}
|
|
||||||
|
|
||||||
svg {
|
|
||||||
fill: var(--color-8-uppergray);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-list .floating-menu-container .floating-menu-content {
|
|
||||||
max-height: 400px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,194 +1,198 @@
|
|||||||
<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";
|
||||||
|
|
||||||
export default defineComponent({
|
// emits: ["update:value", "textFocused", "textChanged", "cancelTextChange"],
|
||||||
emits: ["update:value", "textFocused", "textChanged", "cancelTextChange"],
|
const dispatch = createEventDispatcher<{
|
||||||
props: {
|
value: string;
|
||||||
value: { type: String as PropType<string>, required: true },
|
textFocused: undefined;
|
||||||
label: { type: String as PropType<string>, required: false },
|
textChanged: undefined;
|
||||||
spellcheck: { type: Boolean as PropType<boolean>, default: false },
|
cancelTextChange: undefined;
|
||||||
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.
|
|
||||||
selectAllText(currentText: string) {
|
|
||||||
const inputElement = this.$refs.input as HTMLInputElement | HTMLTextAreaElement | undefined;
|
|
||||||
if (!inputElement) return;
|
|
||||||
|
|
||||||
// Setting the value directly is required to make `inputElement.select()` work
|
let className = "";
|
||||||
inputElement.value = currentText;
|
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;
|
||||||
|
|
||||||
inputElement.select();
|
let inputOrTextarea: HTMLInputElement | HTMLTextAreaElement;
|
||||||
},
|
let id = `${Math.random()}`.substring(2);
|
||||||
unFocus() {
|
let macKeyboardLayout = platformIsMac();
|
||||||
(this.$refs.input as HTMLInputElement | HTMLTextAreaElement | undefined)?.blur();
|
|
||||||
},
|
$: inputValue = value;
|
||||||
getInputElementValue(): string | undefined {
|
$: dispatch("value", inputValue);
|
||||||
return (this.$refs.input as HTMLInputElement | HTMLTextAreaElement | undefined)?.value;
|
|
||||||
},
|
// Select (highlight) all the text. For technical reasons, it is necessary to pass the current text.
|
||||||
setInputElementValue(value: 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) inputElement.value = value;
|
inputOrTextarea.value = currentText;
|
||||||
},
|
inputOrTextarea.select();
|
||||||
},
|
}
|
||||||
computed: {
|
|
||||||
inputValue: {
|
export function focus() {
|
||||||
get() {
|
inputOrTextarea.focus();
|
||||||
return this.value;
|
}
|
||||||
},
|
|
||||||
set(value: string) {
|
export function unFocus() {
|
||||||
this.$emit("update:value", value);
|
inputOrTextarea.blur();
|
||||||
},
|
}
|
||||||
},
|
|
||||||
},
|
export function getValue(): string {
|
||||||
components: { LayoutRow },
|
return inputOrTextarea.value;
|
||||||
});
|
}
|
||||||
|
|
||||||
|
export function setInputElementValue(value: string) {
|
||||||
|
inputOrTextarea.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function element(): HTMLInputElement | HTMLTextAreaElement {
|
||||||
|
return inputOrTextarea;
|
||||||
|
}
|
||||||
</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;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
background: var(--color-1-nearblack);
|
background: var(--color-1-nearblack);
|
||||||
overflow: hidden;
|
|
||||||
flex-direction: row-reverse;
|
|
||||||
|
|
||||||
label {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
line-height: 18px;
|
|
||||||
padding: 3px 0;
|
|
||||||
padding-right: 4px;
|
|
||||||
margin-left: 8px;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
flex-direction: row-reverse;
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:not(.disabled) label {
|
label {
|
||||||
cursor: text;
|
flex: 0 0 auto;
|
||||||
}
|
line-height: 18px;
|
||||||
|
padding: 3px 0;
|
||||||
|
padding-right: 4px;
|
||||||
|
margin-left: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
input,
|
&:not(.disabled) label {
|
||||||
textarea {
|
cursor: text;
|
||||||
flex: 1 1 100%;
|
}
|
||||||
width: 0;
|
|
||||||
min-width: 30px;
|
|
||||||
height: 18px;
|
|
||||||
line-height: 18px;
|
|
||||||
margin: 0 8px;
|
|
||||||
padding: 3px 0;
|
|
||||||
outline: none; // Ok for input/textarea element
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
color: var(--color-e-nearwhite);
|
|
||||||
caret-color: var(--color-e-nearwhite);
|
|
||||||
|
|
||||||
&::selection {
|
input,
|
||||||
background-color: var(--color-5-dullgray);
|
textarea {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
width: 0;
|
||||||
|
min-width: 30px;
|
||||||
|
height: 18px;
|
||||||
|
line-height: 18px;
|
||||||
|
margin: 0 8px;
|
||||||
|
padding: 3px 0;
|
||||||
|
outline: none; // Ok for input/textarea element
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--color-e-nearwhite);
|
||||||
|
caret-color: var(--color-e-nearwhite);
|
||||||
|
|
||||||
// Target only Safari
|
&::selection {
|
||||||
@supports (background: -webkit-named-image(i)) {
|
background-color: var(--color-5-dullgray);
|
||||||
& {
|
|
||||||
// Setting an alpha value opts out of Safari's "fancy" (but not visible on dark backgrounds) selection highlight rendering
|
// Target only Safari
|
||||||
// https://stackoverflow.com/a/71753552/775283
|
@supports (background: -webkit-named-image(i)) {
|
||||||
background-color: rgba(var(--color-5-dullgray-rgb), calc(254 / 255));
|
& {
|
||||||
|
// Setting an alpha value opts out of Safari's "fancy" (but not visible on dark backgrounds) selection highlight rendering
|
||||||
|
// https://stackoverflow.com/a/71753552/775283
|
||||||
|
background-color: rgba(var(--color-5-dullgray-rgb), calc(254 / 255));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
input {
|
input {
|
||||||
text-align: center;
|
// text-align: center;
|
||||||
|
|
||||||
&:not(:focus).has-label {
|
&:not(:focus).has-label {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
& + label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus {
|
textarea {
|
||||||
text-align: left;
|
min-height: calc(18px * 3);
|
||||||
|
margin: 3px;
|
||||||
|
padding: 0 5px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
& + label {
|
&.disabled {
|
||||||
display: none;
|
background: var(--color-2-mildblack);
|
||||||
|
|
||||||
|
label,
|
||||||
|
input,
|
||||||
|
textarea {
|
||||||
|
color: var(--color-8-uppergray);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
textarea {
|
|
||||||
min-height: calc(18px * 3);
|
|
||||||
margin: 3px;
|
|
||||||
padding: 0 5px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
resize: vertical;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.disabled {
|
|
||||||
background: var(--color-2-mildblack);
|
|
||||||
|
|
||||||
label,
|
|
||||||
input,
|
|
||||||
textarea {
|
|
||||||
color: var(--color-8-uppergray);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,189 +1,185 @@
|
|||||||
<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));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
toggleOpen(): void {
|
|
||||||
if (!this.disabled) {
|
|
||||||
this.open = !this.open;
|
|
||||||
|
|
||||||
if (this.open) this.setOpen();
|
export let fontFamily: string;
|
||||||
}
|
export let fontStyle: string;
|
||||||
},
|
export let isStyle = false;
|
||||||
keydown(e: KeyboardEvent): void {
|
export let disabled = false;
|
||||||
(this.$refs.menuList as typeof MenuList | undefined)?.keydown(e, false);
|
export let tooltip: string | undefined = undefined;
|
||||||
},
|
export let sharpRightCorners = false;
|
||||||
async selectFont(newName: string): Promise<void> {
|
|
||||||
let fontFamily;
|
|
||||||
let fontStyle;
|
|
||||||
|
|
||||||
if (this.isStyle) {
|
let open = false;
|
||||||
this.$emit("update:fontStyle", newName);
|
let entries: MenuListEntry[] = [];
|
||||||
|
let activeEntry: MenuListEntry | undefined = undefined;
|
||||||
|
let minWidth = isStyle ? 0 : 300;
|
||||||
|
|
||||||
fontFamily = this.fontFamily;
|
$: fontFamily, fontStyle, watchFont();
|
||||||
fontStyle = newName;
|
|
||||||
} else {
|
|
||||||
this.$emit("update:fontFamily", newName);
|
|
||||||
|
|
||||||
fontFamily = newName;
|
async function watchFont(): Promise<void> {
|
||||||
fontStyle = "Normal (400)";
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
const fontFileUrl = await this.fonts.getFontFileUrl(fontFamily, fontStyle);
|
async function setOpen(): Promise<void> {
|
||||||
this.$emit("changeFont", { fontFamily, fontStyle, fontFileUrl });
|
open = true;
|
||||||
},
|
|
||||||
async getEntries(): Promise<MenuListEntry[]> {
|
|
||||||
const x = this.isStyle ? this.fonts.getFontStyles(this.fontFamily) : this.fonts.fontNames();
|
|
||||||
return (await x).map((entry: { name: string; url: URL | undefined }) => ({
|
|
||||||
label: entry.name,
|
|
||||||
value: entry.name,
|
|
||||||
font: entry.url,
|
|
||||||
action: () => this.selectFont(entry.name),
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
getActiveEntry(entries: MenuListEntry[]): MenuListEntry {
|
|
||||||
const selectedChoice = this.isStyle ? this.fontStyle : this.fontFamily;
|
|
||||||
|
|
||||||
return entries.find((entry) => entry.value === selectedChoice) as MenuListEntry;
|
// Scroll to the active entry (the scroller div does not yet exist so we must wait for the component to render)
|
||||||
},
|
await tick();
|
||||||
},
|
|
||||||
watch: {
|
if (activeEntry) {
|
||||||
async fontFamily() {
|
const index = entries.indexOf(activeEntry);
|
||||||
this.entries = await this.getEntries();
|
menuList.scrollViewTo(Math.max(0, index * 20 - 190));
|
||||||
this.activeEntry = this.getActiveEntry(this.entries);
|
}
|
||||||
},
|
}
|
||||||
async fontStyle() {
|
|
||||||
this.entries = await this.getEntries();
|
function toggleOpen(): void {
|
||||||
this.activeEntry = this.getActiveEntry(this.entries);
|
if (!disabled) {
|
||||||
},
|
open = !open;
|
||||||
},
|
|
||||||
components: {
|
if (open) setOpen();
|
||||||
IconLabel,
|
}
|
||||||
LayoutRow,
|
}
|
||||||
MenuList,
|
|
||||||
TextLabel,
|
async function selectFont(newName: string): Promise<void> {
|
||||||
},
|
let family;
|
||||||
});
|
let style;
|
||||||
|
|
||||||
|
if (isStyle) {
|
||||||
|
dispatch("fontStyle", newName);
|
||||||
|
|
||||||
|
family = fontFamily;
|
||||||
|
style = newName;
|
||||||
|
} else {
|
||||||
|
dispatch("fontFamily", newName);
|
||||||
|
|
||||||
|
family = newName;
|
||||||
|
style = "Normal (400)";
|
||||||
|
}
|
||||||
|
|
||||||
|
const fontFileUrl = await fonts.getFontFileUrl(family, style);
|
||||||
|
dispatch("changeFont", { fontFamily: family, fontStyle: style, fontFileUrl });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getEntries(): Promise<MenuListEntry[]> {
|
||||||
|
const x = isStyle ? fonts.getFontStyles(fontFamily) : fonts.fontNames();
|
||||||
|
return (await x).map((entry: { name: string; url: URL | undefined }) => ({
|
||||||
|
label: entry.name,
|
||||||
|
value: entry.name,
|
||||||
|
font: entry.url,
|
||||||
|
action: () => selectFont(entry.name),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveEntry(entries: MenuListEntry[]): MenuListEntry {
|
||||||
|
const selectedChoice = isStyle ? fontStyle : fontFamily;
|
||||||
|
|
||||||
|
return entries.find((entry) => entry.value === selectedChoice) as MenuListEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
entries = await getEntries();
|
||||||
|
|
||||||
|
activeEntry = getActiveEntry(entries);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- TODO: Combine this widget into the DropdownInput widget -->
|
<!-- TODO: Combine this widget into the DropdownInput widget -->
|
||||||
|
<LayoutRow class="font-input">
|
||||||
<template>
|
<LayoutRow
|
||||||
<LayoutRow class="font-input">
|
class="dropdown-box"
|
||||||
<LayoutRow
|
classes={{ disabled, "sharp-right-corners": sharpRightCorners }}
|
||||||
class="dropdown-box"
|
styles={{ minWidth: `${minWidth}px` }}
|
||||||
:class="{ disabled, 'sharp-right-corners': sharpRightCorners }"
|
{tooltip}
|
||||||
:style="{ minWidth: `${minWidth}px` }"
|
tabindex={disabled ? -1 : 0}
|
||||||
:title="tooltip"
|
on:click={toggleOpen}
|
||||||
:tabindex="disabled ? -1 : 0"
|
on:keydown={(e) => menuList.keydown(e, false)}
|
||||||
@click="toggleOpen"
|
data-floating-menu-spawner
|
||||||
@keydown="keydown"
|
>
|
||||||
data-floating-menu-spawner
|
<TextLabel class="dropdown-label">{activeEntry?.value || ""}</TextLabel>
|
||||||
>
|
<IconLabel class="dropdown-arrow" icon="DropdownArrow" />
|
||||||
<TextLabel class="dropdown-label">{{ activeEntry?.value || "" }}</TextLabel>
|
|
||||||
<IconLabel class="dropdown-arrow" :icon="'DropdownArrow'" />
|
|
||||||
</LayoutRow>
|
|
||||||
<MenuList
|
|
||||||
v-model:activeEntry="activeEntry"
|
|
||||||
v-model:open="open"
|
|
||||||
:entries="[entries]"
|
|
||||||
:minWidth="isStyle ? 0 : minWidth"
|
|
||||||
:virtualScrollingEntryHeight="isStyle ? 0 : 20"
|
|
||||||
:scrollableY="true"
|
|
||||||
@naturalWidth="(newNaturalWidth: number) => (isStyle && (minWidth = newNaturalWidth))"
|
|
||||||
ref="menuList"
|
|
||||||
></MenuList>
|
|
||||||
</LayoutRow>
|
</LayoutRow>
|
||||||
</template>
|
<MenuList
|
||||||
|
on:naturalWidth={({ detail }) => isStyle && (minWidth = detail)}
|
||||||
|
{activeEntry}
|
||||||
|
on:activeEntry={({ detail }) => (activeEntry = detail)}
|
||||||
|
{open}
|
||||||
|
on:open={({ detail }) => (open = detail)}
|
||||||
|
entries={[entries]}
|
||||||
|
minWidth={isStyle ? 0 : minWidth}
|
||||||
|
virtualScrollingEntryHeight={isStyle ? 0 : 20}
|
||||||
|
scrollableY={true}
|
||||||
|
bind:this={menuList}
|
||||||
|
/>
|
||||||
|
</LayoutRow>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss" global>
|
||||||
.font-input {
|
.font-input {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
.dropdown-box {
|
.dropdown-box {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
background: var(--color-1-nearblack);
|
background: var(--color-1-nearblack);
|
||||||
height: 24px;
|
height: 24px;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
|
|
||||||
.dropdown-label {
|
.dropdown-label {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
flex: 1 1 100%;
|
flex: 1 1 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-arrow {
|
.dropdown-arrow {
|
||||||
margin: 6px 2px;
|
margin: 6px 2px;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover,
|
&:hover,
|
||||||
&.open {
|
&.open {
|
||||||
background: var(--color-6-lowergray);
|
background: var(--color-6-lowergray);
|
||||||
|
|
||||||
span {
|
span {
|
||||||
color: var(--color-f-white);
|
color: var(--color-f-white);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.open {
|
||||||
|
border-radius: 2px 2px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
background: var(--color-2-mildblack);
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: var(--color-8-uppergray);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.open {
|
.menu-list .floating-menu-container .floating-menu-content {
|
||||||
border-radius: 2px 2px 0 0;
|
max-height: 400px;
|
||||||
}
|
padding: 4px 0;
|
||||||
|
|
||||||
&.disabled {
|
|
||||||
background: var(--color-2-mildblack);
|
|
||||||
|
|
||||||
span {
|
|
||||||
color: var(--color-8-uppergray);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-list .floating-menu-container .floating-menu-content {
|
|
||||||
max-height: 400px;
|
|
||||||
padding: 4px 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,161 +1,142 @@
|
|||||||
<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 },
|
|
||||||
layerName: { type: String as PropType<string | undefined>, required: false },
|
|
||||||
layerType: { type: String as PropType<LayerType | undefined>, required: 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 {
|
|
||||||
hoveringDrop: false,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
droppable() {
|
|
||||||
return this.hoveringDrop && currentDraggingElement();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
dragOver(e: DragEvent): void {
|
|
||||||
this.hoveringDrop = true;
|
|
||||||
|
|
||||||
|
export let value: string | undefined = undefined;
|
||||||
|
export let layerName: string | undefined = undefined;
|
||||||
|
export let layerType: LayerType | undefined = undefined;
|
||||||
|
export let disabled = false;
|
||||||
|
export let tooltip: string | undefined = undefined;
|
||||||
|
export let sharpRightCorners = false;
|
||||||
|
|
||||||
|
let hoveringDrop = false;
|
||||||
|
|
||||||
|
$: droppable = hoveringDrop && Boolean(currentDraggingElement());
|
||||||
|
|
||||||
|
function dragOver(e: DragEvent): void {
|
||||||
|
hoveringDrop = true;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drop(e: DragEvent): void {
|
||||||
|
hoveringDrop = false;
|
||||||
|
|
||||||
|
const element = currentDraggingElement();
|
||||||
|
const layerPath = element?.getAttribute("data-layer") || undefined;
|
||||||
|
|
||||||
|
if (layerPath) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
},
|
|
||||||
dragLeave(): void {
|
|
||||||
this.hoveringDrop = false;
|
|
||||||
},
|
|
||||||
drop(e: DragEvent): void {
|
|
||||||
this.hoveringDrop = false;
|
|
||||||
|
|
||||||
const element = currentDraggingElement();
|
dispatch("value", layerPath);
|
||||||
const layerPath = element?.getAttribute("data-layer") || undefined;
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (layerPath) {
|
function getLayerTypeData(layerType: LayerType): LayerTypeData {
|
||||||
e.preventDefault();
|
return layerTypeData(layerType) || { name: "Error", icon: "Info" };
|
||||||
|
}
|
||||||
this.$emit("update:value", layerPath);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
clearLayer(): void {
|
|
||||||
this.$emit("update:value", undefined);
|
|
||||||
},
|
|
||||||
layerTypeData(layerType: LayerType): LayerTypeData {
|
|
||||||
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"
|
classes={{ disabled, droppable, "sharp-right-corners": sharpRightCorners }}
|
||||||
:class="{ disabled, droppable, 'sharp-right-corners': sharpRightCorners }"
|
{tooltip}
|
||||||
:title="tooltip"
|
on:dragover={(e) => !disabled && dragOver(e)}
|
||||||
@dragover="(e: DragEvent) => !disabled && dragOver(e)"
|
on:dragleave={() => !disabled && (hoveringDrop = false)}
|
||||||
@dragleave="() => !disabled && dragLeave()"
|
on:drop={(e) => !disabled && drop(e)}
|
||||||
@drop="(e: DragEvent) => !disabled && drop(e)"
|
>
|
||||||
>
|
{#if value === undefined || droppable}
|
||||||
<template v-if="value === undefined || droppable">
|
<LayoutRow class="drop-zone" />
|
||||||
<LayoutRow class="drop-zone"></LayoutRow>
|
<TextLabel italic={true}>{droppable ? "Drop" : "Drag"} Layer Here</TextLabel>
|
||||||
<TextLabel :italic="true">{{ droppable ? "Drop" : "Drag" }} Layer Here</TextLabel>
|
{:else}
|
||||||
</template>
|
{#if layerName !== undefined && layerType}
|
||||||
<template v-if="value !== undefined && !droppable">
|
<IconLabel icon={getLayerTypeData(layerType).icon} class="layer-icon" />
|
||||||
<IconLabel v-if="layerName !== undefined && layerType" :icon="layerTypeData(layerType).icon" class="layer-icon" />
|
<TextLabel italic={layerName === ""} class="layer-name">{layerName || getLayerTypeData(layerType).name}</TextLabel>
|
||||||
<TextLabel v-if="layerName !== undefined && layerType" :italic="layerName === ''" class="layer-name">{{ layerName || layerTypeData(layerType).name }}</TextLabel>
|
{:else}
|
||||||
<TextLabel :bold="true" :italic="true" v-else class="missing">Layer Missing</TextLabel>
|
<TextLabel bold={true} italic={true} class="missing">Layer Missing</TextLabel>
|
||||||
</template>
|
{/if}
|
||||||
<IconButton v-if="value !== undefined && !droppable" :icon="'CloseX'" :size="16" :disabled="disabled" :action="() => clearLayer()" />
|
<IconButton icon="CloseX" size={16} {disabled} action={() => dispatch("value", undefined)} />
|
||||||
</LayoutRow>
|
{/if}
|
||||||
</template>
|
</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;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
background: var(--color-1-nearblack);
|
background: var(--color-1-nearblack);
|
||||||
|
|
||||||
.drop-zone {
|
|
||||||
pointer-events: none;
|
|
||||||
border: 1px dashed var(--color-5-dullgray);
|
|
||||||
border-radius: 1px;
|
|
||||||
position: absolute;
|
|
||||||
top: 2px;
|
|
||||||
bottom: 2px;
|
|
||||||
left: 2px;
|
|
||||||
right: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.droppable .drop-zone {
|
|
||||||
border: 1px dashed var(--color-e-nearwhite);
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-icon {
|
|
||||||
margin: 4px 8px;
|
|
||||||
|
|
||||||
+ .text-label {
|
|
||||||
padding-left: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-label {
|
|
||||||
line-height: 18px;
|
|
||||||
padding: 3px calc(8px + 2px);
|
|
||||||
width: 100%;
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
&.missing {
|
|
||||||
// TODO: Define this as a permanent color palette choice
|
|
||||||
color: #d6536e;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.layer-name {
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-button {
|
|
||||||
margin: 4px;
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.disabled {
|
|
||||||
background: var(--color-2-mildblack);
|
|
||||||
|
|
||||||
.drop-zone {
|
.drop-zone {
|
||||||
border: 1px dashed var(--color-4-dimgray);
|
pointer-events: none;
|
||||||
|
border: 1px dashed var(--color-5-dullgray);
|
||||||
|
border-radius: 1px;
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
bottom: 2px;
|
||||||
|
left: 2px;
|
||||||
|
right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.droppable .drop-zone {
|
||||||
|
border: 1px dashed var(--color-e-nearwhite);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-icon {
|
||||||
|
margin: 4px 8px;
|
||||||
|
|
||||||
|
+ .text-label {
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-label {
|
.text-label {
|
||||||
color: var(--color-8-uppergray);
|
line-height: 18px;
|
||||||
|
padding: 3px calc(8px + 2px);
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
&.missing {
|
||||||
|
// TODO: Define this as a permanent color palette choice (search the project for all uses of this hex code)
|
||||||
|
color: #d6536e;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.layer-name {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-label svg {
|
.icon-button {
|
||||||
fill: var(--color-8-uppergray);
|
margin: 4px;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
background: var(--color-2-mildblack);
|
||||||
|
|
||||||
|
.drop-zone {
|
||||||
|
border: 1px dashed var(--color-4-dimgray);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-label {
|
||||||
|
color: var(--color-8-uppergray);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-label svg {
|
||||||
|
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[][] = [
|
||||||
|
[accelKey, "KeyW"],
|
||||||
|
[accelKey, "KeyN"],
|
||||||
|
[accelKey, "Shift", "KeyN"],
|
||||||
|
[accelKey, "KeyT"],
|
||||||
|
[accelKey, "Shift", "KeyT"],
|
||||||
|
];
|
||||||
|
|
||||||
// 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 editor = getContext<Editor>("editor");
|
||||||
const accelKey = platformIsMac() ? "Command" : "Control";
|
|
||||||
const LOCK_REQUIRING_SHORTCUTS: KeyRaw[][] = [
|
|
||||||
[accelKey, "KeyW"],
|
|
||||||
[accelKey, "KeyN"],
|
|
||||||
[accelKey, "Shift", "KeyN"],
|
|
||||||
[accelKey, "KeyT"],
|
|
||||||
[accelKey, "Shift", "KeyT"],
|
|
||||||
];
|
|
||||||
|
|
||||||
export default defineComponent({
|
let entries: MenuListEntry[] = [];
|
||||||
inject: ["editor"],
|
|
||||||
mounted() {
|
function clickEntry(menuListEntry: MenuListEntry, e: MouseEvent) {
|
||||||
this.editor.subscriptions.subscribeJsMessage(UpdateMenuBarLayout, (updateMenuBarLayout) => {
|
// 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,106 +68,81 @@ 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>
|
||||||
<MenuList
|
{#if entry.children && entry.children.length > 0}
|
||||||
v-if="entry.children && entry.children.length > 0"
|
<MenuList
|
||||||
:open="entry.ref?.open || false"
|
on:open={({ detail }) => {
|
||||||
:entries="entry.children || []"
|
if (entry.ref) entry.ref.open = detail;
|
||||||
:direction="'Bottom'"
|
}}
|
||||||
:minWidth="240"
|
open={entry.ref?.open || false}
|
||||||
:drawIcon="true"
|
entries={entry.children || []}
|
||||||
:ref="(ref: MenuListInstance): void => (ref && (entry.ref = ref), undefined)"
|
direction="Bottom"
|
||||||
/>
|
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;
|
|
||||||
|
|
||||||
.entry-container {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
position: relative;
|
|
||||||
|
|
||||||
.entry {
|
.entry-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
position: relative;
|
||||||
white-space: nowrap;
|
|
||||||
padding: 0 8px;
|
|
||||||
background: none;
|
|
||||||
border: 0;
|
|
||||||
margin: 0;
|
|
||||||
|
|
||||||
svg {
|
.entry {
|
||||||
fill: var(--color-e-nearwhite);
|
display: flex;
|
||||||
}
|
align-items: center;
|
||||||
|
white-space: nowrap;
|
||||||
&:hover,
|
padding: 0 8px;
|
||||||
&.open {
|
background: none;
|
||||||
background: var(--color-6-lowergray);
|
border: 0;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
fill: var(--color-f-white);
|
fill: var(--color-e-nearwhite);
|
||||||
}
|
}
|
||||||
|
|
||||||
span {
|
&:hover,
|
||||||
color: var(--color-f-white);
|
&.open {
|
||||||
|
background: var(--color-6-lowergray);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
fill: var(--color-f-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: var(--color-f-white);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,479 +1,488 @@
|
|||||||
<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";
|
||||||
|
|
||||||
export default defineComponent({
|
// emits: ["update:value"],
|
||||||
emits: ["update:value"],
|
const dispatch = createEventDispatcher<{ value: number | undefined }>();
|
||||||
props: {
|
|
||||||
// Label
|
|
||||||
label: { type: String as PropType<string>, required: false },
|
|
||||||
tooltip: { type: String as PropType<string | undefined>, required: false },
|
|
||||||
|
|
||||||
// Disabled
|
// Label
|
||||||
disabled: { type: Boolean as PropType<boolean>, default: false },
|
export let label: string | undefined = undefined;
|
||||||
|
export let tooltip: string | undefined = undefined;
|
||||||
|
|
||||||
// Value
|
// Disabled
|
||||||
value: { type: Number as PropType<number>, required: false }, // When not provided, a dash is displayed
|
export let disabled = false;
|
||||||
min: { type: Number as PropType<number>, required: false },
|
|
||||||
max: { type: Number as PropType<number>, required: false },
|
|
||||||
isInteger: { type: Boolean as PropType<boolean>, default: false },
|
|
||||||
|
|
||||||
// Number presentation
|
// Value
|
||||||
displayDecimalPlaces: { type: Number as PropType<number>, default: 3 },
|
export let value: number | undefined = undefined; // When not provided, a dash is displayed
|
||||||
unit: { type: String as PropType<string>, default: "" },
|
export let min: number | undefined = undefined;
|
||||||
unitIsHiddenWhenEditing: { type: Boolean as PropType<boolean>, default: true },
|
export let max: number | undefined = undefined;
|
||||||
|
export let isInteger = false;
|
||||||
|
|
||||||
// Mode behavior
|
// Number presentation
|
||||||
// "Increment" shows arrows and allows dragging left/right to change the value.
|
export let displayDecimalPlaces = 3;
|
||||||
// "Range" shows a range slider between some minimum and maximum value.
|
export let unit = "";
|
||||||
mode: { type: String as PropType<NumberInputMode>, default: "Increment" },
|
export let unitIsHiddenWhenEditing = true;
|
||||||
// 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`.
|
|
||||||
step: { type: Number as PropType<number>, default: 1 },
|
|
||||||
// `incrementBehavior` is only applicable with a `mode` of "Increment".
|
|
||||||
// "Add"/"Multiply": The value is added or multiplied by `step`.
|
|
||||||
// "None": the increment arrows are not shown.
|
|
||||||
// "Callback": the functions `incrementCallbackIncrease` and `incrementCallbackDecrease` call custom behavior.
|
|
||||||
incrementBehavior: { type: String as PropType<NumberInputIncrementBehavior>, default: "Add" },
|
|
||||||
// `rangeMin` and `rangeMax` are only applicable with a `mode` of "Range".
|
|
||||||
// They set the lower and upper values of the slider to drag between.
|
|
||||||
rangeMin: { type: Number as PropType<number>, default: 0 },
|
|
||||||
rangeMax: { type: Number as PropType<number>, default: 1 },
|
|
||||||
|
|
||||||
// Styling
|
// Mode behavior
|
||||||
minWidth: { type: Number as PropType<number>, default: 0 },
|
// "Increment" shows arrows and allows dragging left/right to change the value.
|
||||||
sharpRightCorners: { type: Boolean as PropType<boolean>, default: false },
|
// "Range" shows a range slider between some minimum and maximum value.
|
||||||
|
export let mode: NumberInputMode = "Increment";
|
||||||
|
// 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`.
|
||||||
|
export let step = 1;
|
||||||
|
// `incrementBehavior` is only applicable with a `mode` of "Increment".
|
||||||
|
// "Add"/"Multiply": The value is added or multiplied by `step`.
|
||||||
|
// "None": the increment arrows are not shown.
|
||||||
|
// "Callback": the functions `incrementCallbackIncrease` and `incrementCallbackDecrease` call custom behavior.
|
||||||
|
export let incrementBehavior: NumberInputIncrementBehavior = "Add";
|
||||||
|
// `rangeMin` and `rangeMax` are only applicable with a `mode` of "Range".
|
||||||
|
// They set the lower and upper values of the slider to drag between.
|
||||||
|
export let rangeMin = 0;
|
||||||
|
export let rangeMax = 1;
|
||||||
|
|
||||||
// Callbacks
|
// Styling
|
||||||
incrementCallbackIncrease: { type: Function as PropType<() => void>, required: false },
|
export let minWidth = 0;
|
||||||
incrementCallbackDecrease: { type: Function as PropType<() => void>, required: false },
|
export let sharpRightCorners = false;
|
||||||
},
|
|
||||||
data() {
|
// Callbacks
|
||||||
return {
|
export let incrementCallbackIncrease: (() => void) | undefined = undefined;
|
||||||
text: this.displayText(this.value),
|
export let incrementCallbackDecrease: (() => void) | undefined = undefined;
|
||||||
editing: false,
|
|
||||||
// Stays in sync with a binding to the actual input range slider element.
|
let self: FieldInput;
|
||||||
rangeSliderValue: this.value !== undefined ? this.value : 0,
|
let text = displayText(value, displayDecimalPlaces, unit);
|
||||||
// Value used to render the position of the fake slider when applicable, and length of the progress colored region to the slider's left.
|
let editing = false;
|
||||||
// This is the same as `rangeSliderValue` except in the "mousedown" state, when it has the previous location before the user's mousedown.
|
// Stays in sync with a binding to the actual input range slider element.
|
||||||
rangeSliderValueAsRendered: this.value !== undefined ? this.value : 0,
|
let rangeSliderValue = value !== undefined ? value : 0;
|
||||||
// "default": no interaction is happening.
|
// Value used to render the position of the fake slider when applicable, and length of the progress colored region to the slider's left.
|
||||||
// "mousedown": the user has pressed down the mouse and might next decide to either drag left/right or release without dragging.
|
// This is the same as `rangeSliderValue` except in the "mousedown" state, when it has the previous location before the user's mousedown.
|
||||||
// "dragging": the user is dragging the slider left/right.
|
let rangeSliderValueAsRendered = value !== undefined ? value : 0;
|
||||||
rangeSliderClickDragState: "default" as "default" | "mousedown" | "dragging",
|
// "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.
|
||||||
|
// "dragging": the user is dragging the slider left/right.
|
||||||
|
let rangeSliderClickDragState: "default" | "mousedown" | "dragging" = "default";
|
||||||
|
|
||||||
|
$: sliderStepValue = isInteger ? (step === undefined ? 1 : step) : "any";
|
||||||
|
$: watchValue(value);
|
||||||
|
|
||||||
|
// Called only when `value` is changed from outside this component
|
||||||
|
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;
|
||||||
|
|
||||||
|
// 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
|
||||||
|
const ROUNDING_EXPONENT = 4;
|
||||||
|
const ROUNDING_MAGNITUDE = 10 ** ROUNDING_EXPONENT;
|
||||||
|
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
|
||||||
|
if (value !== undefined && Math.abs(value - roundedValue) < 1 / ROUNDING_MAGNITUDE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The first event upon mousedown means we transition to a "mousedown" state
|
||||||
|
if (rangeSliderClickDragState === "default") {
|
||||||
|
rangeSliderClickDragState = "mousedown";
|
||||||
|
|
||||||
|
// Exit early because we don't want to use the value set by where on the track the user pressed
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The second event upon mousedown that occurs by moving left or right means the user has committed to dragging the slider
|
||||||
|
if (rangeSliderClickDragState === "mousedown") {
|
||||||
|
rangeSliderClickDragState = "dragging";
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we're in a dragging state, we want to use the new slider value
|
||||||
|
rangeSliderValueAsRendered = roundedValue;
|
||||||
|
updateValue(roundedValue, min, max, displayDecimalPlaces, unit);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSliderPointerDown() {
|
||||||
|
// We want to render the fake slider thumb at the old position, which is still the number held by `value`
|
||||||
|
rangeSliderValueAsRendered = value || 0;
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSliderPointerUp() {
|
||||||
|
// User clicked but didn't drag, so we focus the text input element
|
||||||
|
if (rangeSliderClickDragState === "mousedown") {
|
||||||
|
const inputElement = self.element().querySelector("[data-input-element]") as HTMLInputElement | undefined;
|
||||||
|
if (!inputElement) return;
|
||||||
|
|
||||||
|
// Set the slider position back to the original position to undo the user moving it
|
||||||
|
rangeSliderValue = rangeSliderValueAsRendered;
|
||||||
|
|
||||||
|
// Begin editing the number text field
|
||||||
|
inputElement.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Releasing the mouse means we can reset the state machine
|
||||||
|
rangeSliderClickDragState = "default";
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTextFocused() {
|
||||||
|
if (value === undefined) text = "";
|
||||||
|
else if (unitIsHiddenWhenEditing) text = `${value}`;
|
||||||
|
else text = `${value}${unPluralize(unit, value)}`;
|
||||||
|
|
||||||
|
editing = true;
|
||||||
|
|
||||||
|
self.selectAllText(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
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
|
||||||
|
if (!editing) return;
|
||||||
|
|
||||||
|
const parsed = parseFloat(text);
|
||||||
|
const newValue = Number.isNaN(parsed) ? undefined : parsed;
|
||||||
|
|
||||||
|
updateValue(newValue, min, max, displayDecimalPlaces, unit);
|
||||||
|
|
||||||
|
editing = false;
|
||||||
|
|
||||||
|
self.unFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCancelTextChange() {
|
||||||
|
updateValue(undefined, min, max, displayDecimalPlaces, unit);
|
||||||
|
|
||||||
|
editing = false;
|
||||||
|
|
||||||
|
self.unFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
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: () => {
|
||||||
|
const directionMultiplier = direction === "Increase" ? step : 1 / step;
|
||||||
|
updateValue(value !== undefined ? value * directionMultiplier : undefined, min, max, displayDecimalPlaces, unit);
|
||||||
|
},
|
||||||
|
Callback: () => {
|
||||||
|
if (direction === "Increase") incrementCallbackIncrease?.();
|
||||||
|
if (direction === "Decrease") incrementCallbackDecrease?.();
|
||||||
|
},
|
||||||
|
None: () => {},
|
||||||
};
|
};
|
||||||
},
|
const action = actions[incrementBehavior];
|
||||||
computed: {
|
action();
|
||||||
sliderStepValue() {
|
}
|
||||||
const step = this.step === undefined ? 1 : this.step;
|
|
||||||
return this.isInteger ? step : "any";
|
|
||||||
},
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
sliderInput() {
|
|
||||||
// Keep only 4 digits after the decimal point
|
|
||||||
const ROUNDING_EXPONENT = 4;
|
|
||||||
const ROUNDING_MAGNITUDE = 10 ** ROUNDING_EXPONENT;
|
|
||||||
const roundedValue = Math.round(this.rangeSliderValue * ROUNDING_MAGNITUDE) / ROUNDING_MAGNITUDE;
|
|
||||||
|
|
||||||
// Exit if this is an extraneous event invocation that occurred after mouseup, which happens in Firefox
|
function updateValue(newValue: number | undefined, min: number | undefined, max: number | undefined, displayDecimalPlaces: number, unit: string) {
|
||||||
if (this.value !== undefined && Math.abs(this.value - roundedValue) < 1 / ROUNDING_MAGNITUDE) {
|
// Check if the new value is valid, otherwise we use the old value (rounded if it's an integer)
|
||||||
return;
|
const nowValid = value !== undefined && isInteger ? Math.round(value) : value;
|
||||||
}
|
let cleaned = newValue !== undefined ? newValue : nowValid;
|
||||||
|
|
||||||
// The first event upon mousedown means we transition to a "mousedown" state
|
if (typeof min === "number" && !Number.isNaN(min) && cleaned !== undefined) cleaned = Math.max(cleaned, min);
|
||||||
if (this.rangeSliderClickDragState === "default") {
|
if (typeof max === "number" && !Number.isNaN(max) && cleaned !== undefined) cleaned = Math.min(cleaned, max);
|
||||||
this.rangeSliderClickDragState = "mousedown";
|
|
||||||
|
|
||||||
// Exit early because we don't want to use the value set by where on the track the user pressed
|
text = displayText(cleaned, displayDecimalPlaces, unit);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The second event upon mousedown that occurs by moving left or right means the user has committed to dragging the slider
|
if (newValue !== undefined) dispatch("value", cleaned);
|
||||||
if (this.rangeSliderClickDragState === "mousedown") {
|
}
|
||||||
this.rangeSliderClickDragState = "dragging";
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we're in a dragging state, we want to use the new slider value
|
function displayText(value: number | undefined, displayDecimalPlaces: number, unit: string): string {
|
||||||
this.rangeSliderValueAsRendered = roundedValue;
|
if (value === undefined) return "-";
|
||||||
this.updateValue(roundedValue);
|
|
||||||
},
|
|
||||||
sliderPointerDown() {
|
|
||||||
// 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;
|
|
||||||
|
|
||||||
// Because an `input` event is fired right before or after this (depending on browser), that first
|
// Find the amount of digits on the left side of the decimal
|
||||||
// invocation will transition the state machine to `mousedown`. That's why we don't do it here.
|
// 10.25 == 2
|
||||||
},
|
// 1.23 == 1
|
||||||
sliderPointerUp() {
|
// 0.23 == 0 (Reason for the slightly more complicated code)
|
||||||
// User clicked but didn't drag, so we focus the text input element
|
const absValueInt = Math.floor(Math.abs(value));
|
||||||
if (this.rangeSliderClickDragState === "mousedown") {
|
const leftSideDigits = absValueInt === 0 ? 0 : absValueInt.toString().length;
|
||||||
const fieldInput = this.$refs.fieldInput as typeof FieldInput | undefined;
|
const roundingPower = 10 ** Math.max(displayDecimalPlaces - leftSideDigits, 0);
|
||||||
const inputElement = fieldInput?.$el.querySelector("[data-input-element]") as HTMLInputElement | undefined;
|
|
||||||
if (!inputElement) return;
|
|
||||||
|
|
||||||
// Set the slider position back to the original position to undo the user moving it
|
const displayValue = Math.round(value * roundingPower) / roundingPower;
|
||||||
this.rangeSliderValue = this.rangeSliderValueAsRendered;
|
|
||||||
|
|
||||||
// Begin editing the number text field
|
return `${displayValue}${unPluralize(unit, value)}`;
|
||||||
inputElement.focus();
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Releasing the mouse means we can reset the state machine
|
function unPluralize(unit: string, value: number): string {
|
||||||
this.rangeSliderClickDragState = "default";
|
if (value === 1 && unit.endsWith("s")) return unit.slice(0, -1);
|
||||||
},
|
return unit;
|
||||||
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;
|
|
||||||
|
|
||||||
(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
|
|
||||||
// enter key (via the `change` event) or when the <input> element is unfocused (with the `blur` event binding)
|
|
||||||
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
|
|
||||||
if (!this.editing) return;
|
|
||||||
|
|
||||||
const parsed = parseFloat(this.text);
|
|
||||||
const newValue = Number.isNaN(parsed) ? undefined : parsed;
|
|
||||||
|
|
||||||
this.updateValue(newValue);
|
|
||||||
|
|
||||||
this.editing = false;
|
|
||||||
|
|
||||||
(this.$refs.fieldInput as typeof FieldInput | undefined)?.unFocus();
|
|
||||||
},
|
|
||||||
onCancelTextChange() {
|
|
||||||
this.updateValue(undefined);
|
|
||||||
|
|
||||||
this.editing = false;
|
|
||||||
|
|
||||||
(this.$refs.fieldInput as typeof FieldInput | undefined)?.unFocus();
|
|
||||||
},
|
|
||||||
onIncrement(direction: "Decrease" | "Increase") {
|
|
||||||
if (this.value === undefined) return;
|
|
||||||
|
|
||||||
const actions = {
|
|
||||||
Add: (): void => {
|
|
||||||
const directionAddend = direction === "Increase" ? this.step : -this.step;
|
|
||||||
this.updateValue(this.value !== undefined ? this.value + directionAddend : undefined);
|
|
||||||
},
|
|
||||||
Multiply: (): void => {
|
|
||||||
const directionMultiplier = direction === "Increase" ? this.step : 1 / this.step;
|
|
||||||
this.updateValue(this.value !== undefined ? this.value * directionMultiplier : undefined);
|
|
||||||
},
|
|
||||||
Callback: (): void => {
|
|
||||||
if (direction === "Increase") this.incrementCallbackIncrease?.();
|
|
||||||
if (direction === "Decrease") this.incrementCallbackDecrease?.();
|
|
||||||
},
|
|
||||||
None: (): void => undefined,
|
|
||||||
};
|
|
||||||
const action = actions[this.incrementBehavior];
|
|
||||||
action();
|
|
||||||
},
|
|
||||||
updateValue(newValue: number | undefined) {
|
|
||||||
const nowValid = this.value !== undefined && this.isInteger ? Math.round(this.value) : this.value;
|
|
||||||
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 this.max === "number" && !Number.isNaN(this.max) && cleaned !== undefined) cleaned = Math.min(cleaned, this.max);
|
|
||||||
|
|
||||||
// Required as the call to update:value can, not change the value
|
|
||||||
this.text = this.displayText(this.value);
|
|
||||||
|
|
||||||
if (newValue !== undefined) this.$emit("update:value", cleaned);
|
|
||||||
},
|
|
||||||
displayText(value: number | undefined): string {
|
|
||||||
if (value === undefined) return "-";
|
|
||||||
|
|
||||||
// Find the amount of digits on the left side of the decimal
|
|
||||||
// 10.25 == 2
|
|
||||||
// 1.23 == 1
|
|
||||||
// 0.23 == 0 (Reason for the slightly more complicated code)
|
|
||||||
const absValueInt = Math.floor(Math.abs(value));
|
|
||||||
const leftSideDigits = absValueInt === 0 ? 0 : absValueInt.toString().length;
|
|
||||||
const roundingPower = 10 ** Math.max(this.displayDecimalPlaces - leftSideDigits, 0);
|
|
||||||
|
|
||||||
const displayValue = Math.round(value * roundingPower) / roundingPower;
|
|
||||||
|
|
||||||
return `${displayValue}${unPluralize(this.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
|
|
||||||
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);
|
|
||||||
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">
|
<style lang="scss" global>
|
||||||
.number-input {
|
.number-input {
|
||||||
&.increment {
|
input {
|
||||||
// Widen the label and input margins from the edges by an extra 8px to make room for the increment arrows
|
text-align: center;
|
||||||
label {
|
|
||||||
margin-left: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="text"]:not(:focus).has-label {
|
&.increment {
|
||||||
margin-right: 16px;
|
// Widen the label and input margins from the edges by an extra 8px to make room for the increment arrows
|
||||||
}
|
label {
|
||||||
|
margin-left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
// Hide the increment arrows when entering text, disabled, or not hovered
|
input[type="text"]:not(:focus).has-label {
|
||||||
input[type="text"]:focus ~ .arrow,
|
margin-right: 16px;
|
||||||
&.disabled .arrow,
|
}
|
||||||
&:not(:hover) .arrow {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Style the increment arrows
|
// Hide the increment arrows when entering text, disabled, or not hovered
|
||||||
.arrow {
|
input[type="text"]:focus ~ .arrow,
|
||||||
position: absolute;
|
&.disabled .arrow,
|
||||||
top: 0;
|
&:not(:hover) .arrow {
|
||||||
margin: 0;
|
display: none;
|
||||||
padding: 9px 0;
|
}
|
||||||
border: none;
|
|
||||||
background: rgba(var(--color-1-nearblack-rgb), 0.75);
|
|
||||||
|
|
||||||
&:hover {
|
// Style the increment arrows
|
||||||
background: var(--color-6-lowergray);
|
.arrow {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 9px 0;
|
||||||
|
border: none;
|
||||||
|
background: rgba(var(--color-1-nearblack-rgb), 0.75);
|
||||||
|
|
||||||
&.right::before {
|
&:hover {
|
||||||
border-color: transparent transparent transparent var(--color-f-white);
|
background: var(--color-6-lowergray);
|
||||||
|
|
||||||
|
&.right::before {
|
||||||
|
border-color: transparent transparent transparent var(--color-f-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.left::after {
|
||||||
|
border-color: transparent var(--color-f-white) transparent transparent;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.left::after {
|
&.right {
|
||||||
border-color: transparent var(--color-f-white) transparent transparent;
|
right: 0;
|
||||||
|
padding-left: 7px;
|
||||||
|
padding-right: 6px;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-style: solid;
|
||||||
|
border-width: 3px 0 3px 3px;
|
||||||
|
border-color: transparent transparent transparent var(--color-e-nearwhite);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.left {
|
||||||
|
left: 0;
|
||||||
|
padding-left: 6px;
|
||||||
|
padding-right: 7px;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-style: solid;
|
||||||
|
border-width: 3px 3px 3px 0;
|
||||||
|
border-color: transparent var(--color-e-nearwhite) transparent transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.range {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
label {
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"]:focus ~ .slider,
|
||||||
|
input[type="text"]:focus ~ .fake-slider-thumb,
|
||||||
|
input[type="text"]:focus ~ .slider-progress {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
-webkit-appearance: none; // Required until Safari 15.4 (Graphite supports 15.0+)
|
||||||
|
appearance: none;
|
||||||
|
background: none;
|
||||||
|
cursor: default;
|
||||||
|
// Except when disabled, the range slider goes above the label and input so it's interactable.
|
||||||
|
// Then we use the blend mode to make it appear behind which works since the text is almost white and background almost black.
|
||||||
|
// When disabled, the blend mode trick doesn't work with the grayer colors. But we don't need it to be interactable, so it can actually go behind properly.
|
||||||
|
z-index: 2;
|
||||||
|
mix-blend-mode: screen;
|
||||||
|
|
||||||
|
&.hidden {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chromium and Safari
|
||||||
|
&::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none; // Required until Safari 15.4 (Graphite supports 15.0+)
|
||||||
|
appearance: none;
|
||||||
|
border-radius: 2px;
|
||||||
|
width: 4px;
|
||||||
|
height: 24px;
|
||||||
|
background: #494949; // Becomes var(--color-5-dullgray) with screen blend mode over var(--color-1-nearblack) background
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover::-webkit-slider-thumb {
|
||||||
|
background: #5b5b5b; // Becomes var(--color-6-lowergray) with screen blend mode over var(--color-1-nearblack) background
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
mix-blend-mode: normal;
|
||||||
|
z-index: 0;
|
||||||
|
|
||||||
|
&::-webkit-slider-thumb {
|
||||||
|
background: var(--color-4-dimgray);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Firefox
|
||||||
|
&::-moz-range-thumb {
|
||||||
|
border: none;
|
||||||
|
border-radius: 2px;
|
||||||
|
width: 4px;
|
||||||
|
height: 24px;
|
||||||
|
background: #494949; // Becomes var(--color-5-dullgray) with screen blend mode over var(--color-1-nearblack) background
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover::-moz-range-thumb {
|
||||||
|
background: #5b5b5b; // Becomes var(--color-6-lowergray) with screen blend mode over var(--color-1-nearblack) background
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover ~ .slider-progress::before {
|
||||||
|
background: var(--color-3-darkgray);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-moz-range-track {
|
||||||
|
height: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.right {
|
// This fake slider thumb stays in the location of the real thumb while we have to hide the real slider between mousedown and mouseup or mousemove.
|
||||||
right: 0;
|
// That's because the range input element moves to the pressed location immediately upon mousedown, but we don't want to show that yet.
|
||||||
padding-left: 7px;
|
// Instead, we want to wait until the user does something:
|
||||||
padding-right: 6px;
|
// Releasing the mouse means we reset the slider to its previous location, thus canceling the slider move. In that case, we focus the text entry.
|
||||||
|
// Moving the mouse left/right means we have begun dragging, so then we hide this fake one and continue showing the actual drag of the real slider.
|
||||||
|
.fake-slider-thumb {
|
||||||
|
position: absolute;
|
||||||
|
left: 2px;
|
||||||
|
right: 2px;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 2;
|
||||||
|
mix-blend-mode: screen;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
content: "";
|
content: "";
|
||||||
display: block;
|
position: absolute;
|
||||||
width: 0;
|
border-radius: 2px;
|
||||||
height: 0;
|
margin-left: -2px;
|
||||||
border-style: solid;
|
left: calc(var(--progress-factor) * 100%);
|
||||||
border-width: 3px 0 3px 3px;
|
width: 4px;
|
||||||
border-color: transparent transparent transparent var(--color-e-nearwhite);
|
height: 24px;
|
||||||
|
background: #5b5b5b; // Becomes var(--color-6-lowergray) with screen blend mode over var(--color-1-nearblack) background
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.left {
|
.slider-progress {
|
||||||
left: 0;
|
position: absolute;
|
||||||
padding-left: 6px;
|
top: 2px;
|
||||||
padding-right: 7px;
|
bottom: 2px;
|
||||||
|
left: 2px;
|
||||||
|
right: 2px;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
&::after {
|
&::before {
|
||||||
content: "";
|
content: "";
|
||||||
display: block;
|
position: absolute;
|
||||||
width: 0;
|
top: 0;
|
||||||
height: 0;
|
left: 0;
|
||||||
border-style: solid;
|
width: calc(var(--progress-factor) * 100% - 2px);
|
||||||
border-width: 3px 3px 3px 0;
|
height: 100%;
|
||||||
border-color: transparent var(--color-e-nearwhite) transparent transparent;
|
background: var(--color-2-mildblack);
|
||||||
|
border-radius: 1px 0 0 1px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.range {
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
input[type="text"],
|
|
||||||
label {
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="text"]:focus ~ .slider,
|
|
||||||
input[type="text"]:focus ~ .fake-slider-thumb,
|
|
||||||
input[type="text"]:focus ~ .slider-progress {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider {
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
-webkit-appearance: none; // Required until Safari 15.4 (Graphite supports 15.0+)
|
|
||||||
appearance: none;
|
|
||||||
background: none;
|
|
||||||
cursor: default;
|
|
||||||
// Except when disabled, the range slider goes above the label and input so it's interactable.
|
|
||||||
// Then we use the blend mode to make it appear behind which works since the text is almost white and background almost black.
|
|
||||||
// When disabled, the blend mode trick doesn't work with the grayer colors. But we don't need it to be interactable, so it can actually go behind properly.
|
|
||||||
z-index: 2;
|
|
||||||
mix-blend-mode: screen;
|
|
||||||
|
|
||||||
&.hidden {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chromium and Safari
|
|
||||||
&::-webkit-slider-thumb {
|
|
||||||
-webkit-appearance: none; // Required until Safari 15.4 (Graphite supports 15.0+)
|
|
||||||
appearance: none;
|
|
||||||
border-radius: 2px;
|
|
||||||
width: 4px;
|
|
||||||
height: 24px;
|
|
||||||
background: #494949; // Becomes var(--color-5-dullgray) with screen blend mode over var(--color-1-nearblack) background
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover::-webkit-slider-thumb {
|
|
||||||
background: #5b5b5b; // Becomes var(--color-6-lowergray) with screen blend mode over var(--color-1-nearblack) background
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
mix-blend-mode: normal;
|
|
||||||
z-index: 0;
|
|
||||||
|
|
||||||
&::-webkit-slider-thumb {
|
|
||||||
background: var(--color-4-dimgray);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Firefox
|
|
||||||
&::-moz-range-thumb {
|
|
||||||
border: none;
|
|
||||||
border-radius: 2px;
|
|
||||||
width: 4px;
|
|
||||||
height: 24px;
|
|
||||||
background: #494949; // Becomes var(--color-5-dullgray) with screen blend mode over var(--color-1-nearblack) background
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover::-moz-range-thumb {
|
|
||||||
background: #5b5b5b; // Becomes var(--color-6-lowergray) with screen blend mode over var(--color-1-nearblack) background
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover ~ .slider-progress::before {
|
|
||||||
background: var(--color-3-darkgray);
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-moz-range-track {
|
|
||||||
height: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// This fake slider thumb stays in the location of the real thumb while we have to hide the real slider between mousedown and mouseup or mousemove.
|
|
||||||
// That's because the range input element moves to the pressed location immediately upon mousedown, but we don't want to show that yet.
|
|
||||||
// Instead, we want to wait until the user does something:
|
|
||||||
// Releasing the mouse means we reset the slider to its previous location, thus canceling the slider move. In that case, we focus the text entry.
|
|
||||||
// Moving the mouse left/right means we have begun dragging, so then we hide this fake one and continue showing the actual drag of the real slider.
|
|
||||||
.fake-slider-thumb {
|
|
||||||
position: absolute;
|
|
||||||
left: 2px;
|
|
||||||
right: 2px;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
z-index: 2;
|
|
||||||
mix-blend-mode: screen;
|
|
||||||
pointer-events: none;
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
border-radius: 2px;
|
|
||||||
margin-left: -2px;
|
|
||||||
left: calc(var(--progress-factor) * 100%);
|
|
||||||
width: 4px;
|
|
||||||
height: 24px;
|
|
||||||
background: #5b5b5b; // Becomes var(--color-6-lowergray) with screen blend mode over var(--color-1-nearblack) background
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider-progress {
|
|
||||||
position: absolute;
|
|
||||||
top: 2px;
|
|
||||||
bottom: 2px;
|
|
||||||
left: 2px;
|
|
||||||
right: 2px;
|
|
||||||
pointer-events: none;
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: calc(var(--progress-factor) * 100% - 2px);
|
|
||||||
height: 100%;
|
|
||||||
background: var(--color-2-mildblack);
|
|
||||||
border-radius: 1px 0 0 1px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,49 +1,36 @@
|
|||||||
<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;
|
||||||
width: 24px;
|
width: 24px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
border: 1px solid var(--color-5-dullgray);
|
border: 1px solid var(--color-5-dullgray);
|
||||||
border-radius: 2px 0 0 2px;
|
border-radius: 2px 0 0 2px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled .checkbox-input label {
|
||||||
|
border: 1px solid var(--color-4-dimgray);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.disabled label {
|
|
||||||
border: 1px solid var(--color-4-dimgray);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,123 +1,119 @@
|
|||||||
<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 },
|
|
||||||
disabled: { type: Boolean as PropType<boolean>, default: false },
|
|
||||||
selectedIndex: { type: Number as PropType<number>, required: true },
|
|
||||||
sharpRightCorners: { type: Boolean as PropType<boolean>, default: false },
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
handleEntryClick(radioEntryData: RadioEntryData) {
|
|
||||||
const index = this.entries.indexOf(radioEntryData);
|
|
||||||
this.$emit("update:selectedIndex", index);
|
|
||||||
|
|
||||||
radioEntryData.action?.();
|
export let entries: RadioEntries;
|
||||||
},
|
export let selectedIndex: number;
|
||||||
},
|
export let disabled = false;
|
||||||
components: {
|
export let sharpRightCorners = false;
|
||||||
IconLabel,
|
|
||||||
LayoutRow,
|
function handleEntryClick(radioEntryData: RadioEntryData) {
|
||||||
TextLabel,
|
const index = entries.indexOf(radioEntryData);
|
||||||
},
|
dispatch("selectedIndex", index);
|
||||||
});
|
|
||||||
|
radioEntryData.action?.();
|
||||||
|
}
|
||||||
</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);
|
||||||
height: 24px;
|
height: 24px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0 4px;
|
padding: 0 4px;
|
||||||
border: none;
|
border: none;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--color-6-lowergray);
|
background: var(--color-6-lowergray);
|
||||||
color: var(--color-f-white);
|
color: var(--color-f-white);
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
fill: var(--color-f-white);
|
fill: var(--color-f-white);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
background: var(--color-e-nearwhite);
|
|
||||||
color: var(--color-2-mildblack);
|
|
||||||
|
|
||||||
svg {
|
|
||||||
fill: var(--color-2-mildblack);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.disabled {
|
|
||||||
background: var(--color-4-dimgray);
|
|
||||||
color: var(--color-8-uppergray);
|
|
||||||
|
|
||||||
svg {
|
|
||||||
fill: var(--color-8-uppergray);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
background: var(--color-8-uppergray);
|
background: var(--color-e-nearwhite);
|
||||||
color: var(--color-2-mildblack);
|
color: var(--color-2-mildblack);
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
fill: var(--color-2-mildblack);
|
fill: var(--color-2-mildblack);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
background: var(--color-4-dimgray);
|
||||||
|
color: var(--color-8-uppergray);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
fill: var(--color-8-uppergray);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: var(--color-8-uppergray);
|
||||||
|
color: var(--color-2-mildblack);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
fill: var(--color-2-mildblack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& + button {
|
||||||
|
margin-left: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:first-of-type {
|
||||||
|
border-radius: 2px 0 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-of-type {
|
||||||
|
border-radius: 0 2px 2px 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
& + button {
|
.text-label {
|
||||||
margin-left: 1px;
|
margin: 0 4px;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:first-of-type {
|
&.combined-before button:first-of-type,
|
||||||
border-radius: 2px 0 0 2px;
|
&.combined-after button:last-of-type {
|
||||||
}
|
border-radius: 0;
|
||||||
|
|
||||||
&:last-of-type {
|
|
||||||
border-radius: 0 2px 2px 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-label {
|
|
||||||
margin: 0 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.combined-before button:first-of-type,
|
|
||||||
&.combined-after button:last-of-type {
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,96 +1,104 @@
|
|||||||
<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 on:click={clickPrimarySwatch} style:--swatch-color={primary.toRgbaCSS()} data-floating-menu-spawner="no-hover-transfer" tabindex="0" />
|
||||||
<button @click="() => clickPrimarySwatch()" :style="{ '--swatch-color': primary.toRgbaCSS() }" data-floating-menu-spawner="no-hover-transfer" tabindex="0"></button>
|
<ColorPicker
|
||||||
<ColorPicker v-model:open="primaryOpen" :color="primary" @update:color="(color: Color) => primaryColorChanged(color)" :direction="'Right'" />
|
open={primaryOpen}
|
||||||
</LayoutRow>
|
on:open={({ detail }) => (primaryOpen = detail)}
|
||||||
<LayoutRow class="secondary swatch">
|
color={primary}
|
||||||
<button @click="() => clickSecondarySwatch()" :style="{ '--swatch-color': secondary.toRgbaCSS() }" data-floating-menu-spawner="no-hover-transfer" tabindex="0"></button>
|
on:color={({ detail }) => {
|
||||||
<ColorPicker v-model:open="secondaryOpen" :color="secondary" @update:color="(color: Color) => secondaryColorChanged(color)" :direction="'Right'" />
|
primary = detail;
|
||||||
</LayoutRow>
|
primaryColorChanged(detail);
|
||||||
</LayoutCol>
|
}}
|
||||||
</template>
|
direction="Right"
|
||||||
|
/>
|
||||||
|
</LayoutRow>
|
||||||
|
<LayoutRow class="secondary swatch">
|
||||||
|
<button on:click={clickSecondarySwatch} style:--swatch-color={secondary.toRgbaCSS()} data-floating-menu-spawner="no-hover-transfer" tabindex="0" />
|
||||||
|
<ColorPicker
|
||||||
|
open={secondaryOpen}
|
||||||
|
on:open={({ detail }) => (secondaryOpen = detail)}
|
||||||
|
color={secondary}
|
||||||
|
on:color={({ detail }) => {
|
||||||
|
secondary = detail;
|
||||||
|
secondaryColorChanged(detail);
|
||||||
|
}}
|
||||||
|
direction="Right"
|
||||||
|
/>
|
||||||
|
</LayoutRow>
|
||||||
|
</LayoutCol>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss" global>
|
||||||
.swatch-pair {
|
.swatch-pair {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
|
||||||
.swatch {
|
.swatch {
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
margin: 0 2px;
|
margin: 0 2px;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
> button {
|
> button {
|
||||||
--swatch-color: #ffffff;
|
--swatch-color: #ffffff;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 2px var(--color-5-dullgray) solid;
|
border: 2px var(--color-5-dullgray) solid;
|
||||||
box-shadow: 0 0 0 2px var(--color-3-darkgray);
|
box-shadow: 0 0 0 2px var(--color-3-darkgray);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
background: linear-gradient(var(--swatch-color), var(--swatch-color)), var(--color-transparent-checkered-background);
|
background: linear-gradient(var(--swatch-color), var(--swatch-color)), var(--color-transparent-checkered-background);
|
||||||
background-size: var(--color-transparent-checkered-background-size);
|
background-size: var(--color-transparent-checkered-background-size);
|
||||||
background-position: var(--color-transparent-checkered-background-position);
|
background-position: var(--color-transparent-checkered-background-position);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.floating-menu {
|
.floating-menu {
|
||||||
top: 50%;
|
top: 50%;
|
||||||
right: -2px;
|
right: -2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.primary {
|
&.primary {
|
||||||
margin-bottom: -8px;
|
margin-bottom: -8px;
|
||||||
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";
|
||||||
|
|
||||||
export default defineComponent({
|
// emits: ["update:value", "commitText"],
|
||||||
emits: ["update:value", "commitText"],
|
const dispatch = createEventDispatcher<{ commitText: string }>();
|
||||||
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
|
|
||||||
// via the `change` event or when the <input> element is unfocused (with the `blur` event binding)
|
|
||||||
onTextChanged() {
|
|
||||||
// The `unFocus()` call in `onCancelTextChange()` causes itself to be run again, so this if statement skips a second run
|
|
||||||
if (!this.editing) return;
|
|
||||||
|
|
||||||
this.onCancelTextChange();
|
export let value: string;
|
||||||
|
export let label: string | undefined = undefined;
|
||||||
|
export let tooltip: string | undefined = undefined;
|
||||||
|
export let disabled = false;
|
||||||
|
|
||||||
// TODO: Find a less hacky way to do this
|
let self: FieldInput;
|
||||||
const inputElement = this.$refs.fieldInput as typeof FieldInput | undefined;
|
let editing = false;
|
||||||
if (!inputElement) return;
|
|
||||||
this.$emit("commitText", inputElement.getInputElementValue());
|
|
||||||
|
|
||||||
// Required if value is not changed by the parent component upon update:value event
|
function onTextFocused() {
|
||||||
inputElement.setInputElementValue(this.value);
|
editing = true;
|
||||||
},
|
}
|
||||||
onCancelTextChange() {
|
|
||||||
this.editing = false;
|
|
||||||
|
|
||||||
(this.$refs.fieldInput as typeof FieldInput | undefined)?.unFocus();
|
// 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)
|
||||||
},
|
function onTextChanged() {
|
||||||
components: { FieldInput },
|
// The `unFocus()` call in `onCancelTextChange()` causes itself to be run again, so this if statement skips a second run
|
||||||
});
|
if (!editing) return;
|
||||||
|
|
||||||
|
onCancelTextChange();
|
||||||
|
|
||||||
|
// TODO: Find a less hacky way to do this
|
||||||
|
dispatch("commitText", self.getValue());
|
||||||
|
|
||||||
|
// Required if value is not changed by the parent component upon update:value event
|
||||||
|
self.setInputElementValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCancelTextChange() {
|
||||||
|
editing = false;
|
||||||
|
|
||||||
|
self.unFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function focus() {
|
||||||
|
self.focus();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<FieldInput
|
||||||
<FieldInput
|
class="text-area-input"
|
||||||
:textarea="true"
|
classes={{ "has-label": Boolean(label) }}
|
||||||
class="text-area-input"
|
{value}
|
||||||
:class="{ 'has-label': label }"
|
on:value
|
||||||
:label="label"
|
on:textFocused={onTextFocused}
|
||||||
:spellcheck="true"
|
on:textChanged={onTextChanged}
|
||||||
:disabled="disabled"
|
on:cancelTextChange={onCancelTextChange}
|
||||||
:tooltip="tooltip"
|
textarea={true}
|
||||||
v-model:value="inputValue"
|
spellcheck={true}
|
||||||
@textFocused="() => onTextFocused()"
|
{label}
|
||||||
@textChanged="() => onTextChanged()"
|
{disabled}
|
||||||
@cancelTextChange="() => onCancelTextChange()"
|
{tooltip}
|
||||||
ref="fieldInput"
|
bind:this={self}
|
||||||
></FieldInput>
|
/>
|
||||||
</template>
|
|
||||||
|
|
||||||
<style lang="scss"></style>
|
<style lang="scss" global>
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,103 +1,87 @@
|
|||||||
<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";
|
||||||
|
|
||||||
export default defineComponent({
|
// emits: ["update:value", "commitText"],
|
||||||
emits: ["update:value", "commitText"],
|
const dispatch = createEventDispatcher<{ commitText: string }>();
|
||||||
props: {
|
|
||||||
// Label
|
|
||||||
label: { type: String as PropType<string>, required: false },
|
|
||||||
tooltip: { type: String as PropType<string | undefined>, required: false },
|
|
||||||
placeholder: { type: String as PropType<string>, required: false },
|
|
||||||
|
|
||||||
// Disabled
|
// Label
|
||||||
disabled: { type: Boolean as PropType<boolean>, default: false },
|
export let label: string | undefined = undefined;
|
||||||
|
export let tooltip: string | undefined = undefined;
|
||||||
|
export let placeholder: string | undefined = undefined;
|
||||||
|
// Disabled
|
||||||
|
export let disabled = false;
|
||||||
|
// Value
|
||||||
|
export let value: string;
|
||||||
|
// Styling
|
||||||
|
export let centered = false;
|
||||||
|
export let minWidth = 0;
|
||||||
|
export let sharpRightCorners = false;
|
||||||
|
|
||||||
// Value
|
let self: FieldInput;
|
||||||
value: { type: String as PropType<string>, required: true },
|
let editing = false;
|
||||||
|
|
||||||
// Styling
|
function onTextFocused() {
|
||||||
centered: { type: Boolean as PropType<boolean>, default: false },
|
editing = true;
|
||||||
minWidth: { type: Number as PropType<number>, default: 0 },
|
|
||||||
sharpRightCorners: { type: Boolean as PropType<boolean>, default: false },
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
editing: false,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
text: {
|
|
||||||
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);
|
self.selectAllText(value);
|
||||||
},
|
}
|
||||||
// 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)
|
|
||||||
onTextChanged() {
|
|
||||||
// The `unFocus()` call in `onCancelTextChange()` causes itself to be run again, so this if statement skips a second run
|
|
||||||
if (!this.editing) return;
|
|
||||||
|
|
||||||
this.onCancelTextChange();
|
// 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)
|
||||||
|
function onTextChanged() {
|
||||||
|
// The `unFocus()` call in `onCancelTextChange()` causes itself to be run again, so this if statement skips a second run
|
||||||
|
if (!editing) return;
|
||||||
|
|
||||||
// TODO: Find a less hacky way to do this
|
onCancelTextChange();
|
||||||
const inputElement = this.$refs.fieldInput as typeof FieldInput | undefined;
|
|
||||||
if (!inputElement) return;
|
|
||||||
this.$emit("commitText", inputElement.getInputElementValue());
|
|
||||||
|
|
||||||
// Required if value is not changed by the parent component upon update:value event
|
// TODO: Find a less hacky way to do this
|
||||||
inputElement.setInputElementValue(this.value);
|
dispatch("commitText", self.getValue());
|
||||||
},
|
|
||||||
onCancelTextChange() {
|
|
||||||
this.editing = false;
|
|
||||||
|
|
||||||
(this.$refs.fieldInput as typeof FieldInput | undefined)?.unFocus();
|
// Required if value is not changed by the parent component upon update:value event
|
||||||
},
|
self.setInputElementValue(value);
|
||||||
},
|
}
|
||||||
components: { FieldInput },
|
|
||||||
});
|
function onCancelTextChange() {
|
||||||
|
editing = false;
|
||||||
|
|
||||||
|
self.unFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function focus() {
|
||||||
|
self.focus();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<FieldInput
|
||||||
<FieldInput
|
class="text-input"
|
||||||
class="text-input"
|
classes={{ centered }}
|
||||||
:class="{ centered }"
|
styles={{ "min-width": minWidth > 0 ? `${minWidth}px` : undefined }}
|
||||||
v-model:value="text"
|
{value}
|
||||||
:label="label"
|
on:value
|
||||||
:spellcheck="true"
|
on:textFocused={onTextFocused}
|
||||||
:disabled="disabled"
|
on:textChanged={onTextChanged}
|
||||||
:tooltip="tooltip"
|
on:cancelTextChange={onCancelTextChange}
|
||||||
:placeholder="placeholder"
|
spellcheck={true}
|
||||||
:style="{ 'min-width': minWidth > 0 ? `${minWidth}px` : undefined }"
|
{label}
|
||||||
:sharpRightCorners="sharpRightCorners"
|
{disabled}
|
||||||
@textFocused="() => onTextFocused()"
|
{tooltip}
|
||||||
@textChanged="() => onTextChanged()"
|
{placeholder}
|
||||||
@cancelTextChange="() => onCancelTextChange()"
|
{sharpRightCorners}
|
||||||
ref="fieldInput"
|
bind:this={self}
|
||||||
></FieldInput>
|
/>
|
||||||
</template>
|
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss" global>
|
||||||
.text-input {
|
.text-input {
|
||||||
input {
|
input {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.centered {
|
&.centered {
|
||||||
input:not(:focus) {
|
input:not(:focus) {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,56 +1,49 @@
|
|||||||
<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);
|
||||||
|
|
||||||
&.disabled {
|
&.disabled {
|
||||||
fill: var(--color-8-uppergray);
|
fill: var(--color-8-uppergray);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.size-12 {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.size-16 {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.size-24 {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.size-12 {
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.size-16 {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.size-24 {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,86 +1,80 @@
|
|||||||
<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;
|
||||||
|
|
||||||
&.related {
|
&.related {
|
||||||
height: 4px;
|
height: 4px;
|
||||||
}
|
|
||||||
|
|
||||||
&.unrelated {
|
|
||||||
height: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.section,
|
|
||||||
&.list {
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
div {
|
|
||||||
height: 1px;
|
|
||||||
width: calc(100% - 8px);
|
|
||||||
margin: 0 4px;
|
|
||||||
background: var(--color-7-middlegray);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
&.section {
|
&.unrelated {
|
||||||
margin: 8px 0;
|
height: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.list {
|
&.section,
|
||||||
margin: 4px 0;
|
&.list {
|
||||||
}
|
width: 100%;
|
||||||
}
|
|
||||||
|
|
||||||
&.horizontal {
|
div {
|
||||||
flex: 0 0 auto;
|
height: 1px;
|
||||||
|
width: calc(100% - 8px);
|
||||||
|
margin: 0 4px;
|
||||||
|
background: var(--color-7-middlegray);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&.related {
|
&.section {
|
||||||
width: 4px;
|
margin: 8px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.unrelated {
|
&.list {
|
||||||
width: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.section,
|
|
||||||
&.list {
|
|
||||||
height: 100%;
|
|
||||||
|
|
||||||
div {
|
|
||||||
height: calc(100% - 8px);
|
|
||||||
width: 1px;
|
|
||||||
margin: 4px 0;
|
margin: 4px 0;
|
||||||
background: var(--color-7-middlegray);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.section {
|
&.horizontal {
|
||||||
margin: 0 8px;
|
flex: 0 0 auto;
|
||||||
}
|
|
||||||
|
|
||||||
&.list {
|
&.related {
|
||||||
margin: 0 4px;
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.unrelated {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.section,
|
||||||
|
&.list {
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
div {
|
||||||
|
height: calc(100% - 8px);
|
||||||
|
width: 1px;
|
||||||
|
margin: 4px 0;
|
||||||
|
background: var(--color-7-middlegray);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.section {
|
||||||
|
margin: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.list {
|
||||||
|
margin: 0 4px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,52 +1,67 @@
|
|||||||
<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`
|
||||||
cursor: default;
|
cursor: default;
|
||||||
|
|
||||||
&.disabled {
|
&.disabled {
|
||||||
color: var(--color-8-uppergray);
|
color: var(--color-8-uppergray);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bold {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.italic {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.multiline {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.table-align {
|
||||||
|
flex: 0 0 30%;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.bold {
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.italic {
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.multiline {
|
|
||||||
white-space: pre-wrap;
|
|
||||||
margin: 4px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.table-align {
|
|
||||||
flex: 0 0 30%;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,249 +1,248 @@
|
|||||||
<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 = {
|
||||||
|
Shift: 2,
|
||||||
|
Control: 2,
|
||||||
|
Option: 2,
|
||||||
|
Command: 2,
|
||||||
|
};
|
||||||
|
const ICON_WIDTHS = {
|
||||||
|
ArrowUp: 1,
|
||||||
|
ArrowRight: 1,
|
||||||
|
ArrowDown: 1,
|
||||||
|
ArrowLeft: 1,
|
||||||
|
Backspace: 2,
|
||||||
|
Enter: 2,
|
||||||
|
Tab: 2,
|
||||||
|
Space: 3,
|
||||||
|
...(platformIsMac() ? ICON_WIDTHS_MAC : {}),
|
||||||
|
};
|
||||||
|
|
||||||
// Keys that become icons if they are listed here with their units of width
|
const fullscreen = getContext<FullscreenState>("fullscreen");
|
||||||
const ICON_WIDTHS_MAC = {
|
|
||||||
Shift: 2,
|
|
||||||
Control: 2,
|
|
||||||
Option: 2,
|
|
||||||
Command: 2,
|
|
||||||
};
|
|
||||||
const ICON_WIDTHS = {
|
|
||||||
ArrowUp: 1,
|
|
||||||
ArrowRight: 1,
|
|
||||||
ArrowDown: 1,
|
|
||||||
ArrowLeft: 1,
|
|
||||||
Backspace: 2,
|
|
||||||
Enter: 2,
|
|
||||||
Tab: 2,
|
|
||||||
Space: 3,
|
|
||||||
...(platformIsMac() ? ICON_WIDTHS_MAC : {}),
|
|
||||||
};
|
|
||||||
|
|
||||||
export default defineComponent({
|
export let keysWithLabelsGroups: LayoutKeysGroup[] = [];
|
||||||
inject: ["fullscreen"],
|
export let mouseMotion: MouseMotion | undefined = undefined;
|
||||||
props: {
|
export let requiresLock = false;
|
||||||
keysWithLabelsGroups: { type: Array as PropType<LayoutKeysGroup[]>, default: () => [] },
|
|
||||||
mouseMotion: { type: String as PropType<MouseMotion | undefined>, required: false },
|
|
||||||
requiresLock: { type: Boolean as PropType<boolean>, default: false },
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
hasSlotContent(): boolean {
|
|
||||||
return Boolean(this.$slots.default);
|
|
||||||
},
|
|
||||||
keyboardLockInfoMessage(): string {
|
|
||||||
const RESERVED = "This hotkey is reserved by the browser. ";
|
|
||||||
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 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}`;
|
$: keyboardLockInfoMessage = watchKeyboardLockInfoMessage(fullscreen.keyboardLockApiSupported);
|
||||||
if (!("chrome" in window)) return `${RESERVED} ${SWITCH_BROWSER}`;
|
$: displayKeyboardLockNotice = requiresLock && !$fullscreen.keyboardLocked;
|
||||||
if (!window.isSecureContext) return `${RESERVED} ${USE_SECURE_CTX}`;
|
|
||||||
return RESERVED;
|
|
||||||
},
|
|
||||||
displayKeyboardLockNotice(): boolean {
|
|
||||||
return this.requiresLock && !this.fullscreen.state.keyboardLocked;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
keyTextOrIconList(keyGroup: LayoutKeysGroup): 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)
|
|
||||||
let key = keyWithLabel.key;
|
|
||||||
const label = keyWithLabel.label;
|
|
||||||
|
|
||||||
// Replace Alt and Accel keys with their Mac-specific equivalents
|
function watchKeyboardLockInfoMessage(keyboardLockApiSupported: boolean): string {
|
||||||
if (platformIsMac()) {
|
const RESERVED = "This hotkey is reserved by the browser. ";
|
||||||
if (key === "Alt") key = "Option";
|
const USE_FULLSCREEN = "It is made available in fullscreen mode.";
|
||||||
if (key === "Accel") key = "Command";
|
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.";
|
||||||
|
|
||||||
// Either display an icon...
|
if (keyboardLockApiSupported) return `${RESERVED} ${USE_FULLSCREEN}`;
|
||||||
// @ts-expect-error We want undefined if it isn't in the object
|
if (!("chrome" in window)) return `${RESERVED} ${SWITCH_BROWSER}`;
|
||||||
const iconWidth: number | undefined = ICON_WIDTHS[key];
|
if (!window.isSecureContext) return `${RESERVED} ${USE_SECURE_CTX}`;
|
||||||
const icon = iconWidth !== undefined && iconWidth > 0 && (this.keyboardHintIcon(key) || false);
|
return RESERVED;
|
||||||
if (icon) return { icon, width: `width-${iconWidth}` };
|
}
|
||||||
|
|
||||||
// ...or display text
|
function keyTextOrIconList(keyGroup: LayoutKeysGroup): LabelData[] {
|
||||||
return { label, width: `width-${label.length}` };
|
return keyGroup.map((key) => keyTextOrIcon(key));
|
||||||
},
|
}
|
||||||
mouseHintIcon(input?: MouseMotion): IconName {
|
|
||||||
return `MouseHint${input}` as IconName;
|
function 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)
|
||||||
keyboardHintIcon(input: KeyRaw): IconName | undefined {
|
let key = keyWithLabel.key;
|
||||||
switch (input) {
|
const label = keyWithLabel.label;
|
||||||
case "ArrowDown":
|
|
||||||
return "KeyboardArrowDown";
|
// Replace Alt and Accel keys with their Mac-specific equivalents
|
||||||
case "ArrowLeft":
|
if (platformIsMac()) {
|
||||||
return "KeyboardArrowLeft";
|
if (key === "Alt") key = "Option";
|
||||||
case "ArrowRight":
|
if (key === "Accel") key = "Command";
|
||||||
return "KeyboardArrowRight";
|
}
|
||||||
case "ArrowUp":
|
|
||||||
return "KeyboardArrowUp";
|
// Either display an icon...
|
||||||
case "Backspace":
|
// @ts-expect-error We want undefined if it isn't in the object
|
||||||
return "KeyboardBackspace";
|
const iconWidth: number | undefined = ICON_WIDTHS[key];
|
||||||
case "Command":
|
const icon = iconWidth !== undefined && iconWidth > 0 && (keyboardHintIcon(key) || false);
|
||||||
return "KeyboardCommand";
|
if (icon) return { icon, width: `width-${iconWidth}` };
|
||||||
case "Control":
|
|
||||||
return "KeyboardControl";
|
// ...or display text
|
||||||
case "Enter":
|
return { label, width: `width-${label.length}` };
|
||||||
return "KeyboardEnter";
|
}
|
||||||
case "Option":
|
|
||||||
return "KeyboardOption";
|
function mouseHintIcon(input?: MouseMotion): IconName {
|
||||||
case "Shift":
|
return `MouseHint${input}` as IconName;
|
||||||
return "KeyboardShift";
|
}
|
||||||
case "Space":
|
|
||||||
return "KeyboardSpace";
|
function keyboardHintIcon(input: KeyRaw): IconName | undefined {
|
||||||
case "Tab":
|
switch (input) {
|
||||||
return "KeyboardTab";
|
case "ArrowDown":
|
||||||
default:
|
return "KeyboardArrowDown";
|
||||||
return undefined;
|
case "ArrowLeft":
|
||||||
}
|
return "KeyboardArrowLeft";
|
||||||
},
|
case "ArrowRight":
|
||||||
},
|
return "KeyboardArrowRight";
|
||||||
components: {
|
case "ArrowUp":
|
||||||
IconLabel,
|
return "KeyboardArrowUp";
|
||||||
LayoutRow,
|
case "Backspace":
|
||||||
Separator,
|
return "KeyboardBackspace";
|
||||||
TextLabel,
|
case "Command":
|
||||||
},
|
return "KeyboardCommand";
|
||||||
});
|
case "Control":
|
||||||
|
return "KeyboardControl";
|
||||||
|
case "Enter":
|
||||||
|
return "KeyboardEnter";
|
||||||
|
case "Option":
|
||||||
|
return "KeyboardOption";
|
||||||
|
case "Shift":
|
||||||
|
return "KeyboardShift";
|
||||||
|
case "Space":
|
||||||
|
return "KeyboardSpace";
|
||||||
|
case "Tab":
|
||||||
|
return "KeyboardTab";
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
</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">
|
||||||
</div>
|
<IconLabel icon={mouseHintIcon(mouseMotion)} />
|
||||||
<div class="hint-text" v-if="hasSlotContent">
|
</div>
|
||||||
<slot></slot>
|
{/if}
|
||||||
</div>
|
{#if $$slots.default}
|
||||||
|
<div class="hint-text">
|
||||||
|
<slot />
|
||||||
|
</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;
|
|
||||||
white-space: nowrap;
|
|
||||||
|
|
||||||
.input-key,
|
|
||||||
.input-mouse {
|
|
||||||
& + .input-key,
|
|
||||||
& + .input-mouse {
|
|
||||||
margin-left: 2px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-key {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-family: "Inconsolata", monospace;
|
white-space: nowrap;
|
||||||
font-weight: 400;
|
|
||||||
text-align: center;
|
|
||||||
height: 16px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
border: 1px solid;
|
|
||||||
border-radius: 4px;
|
|
||||||
border-color: var(--color-5-dullgray);
|
|
||||||
color: var(--color-e-nearwhite);
|
|
||||||
|
|
||||||
.text-label {
|
.input-key,
|
||||||
// Firefox renders the text 1px lower than Chrome (tested on Windows) with 16px line-height,
|
.input-mouse {
|
||||||
// so moving it up 1 pixel by using 15px makes them agree.
|
& + .input-key,
|
||||||
line-height: 15px;
|
& + .input-mouse {
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.width-1 {
|
|
||||||
width: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.width-2 {
|
|
||||||
width: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.width-3 {
|
|
||||||
width: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.width-4 {
|
|
||||||
width: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.width-5 {
|
|
||||||
width: 48px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-label {
|
|
||||||
margin: 1px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-mouse {
|
|
||||||
.bright {
|
|
||||||
fill: var(--color-e-nearwhite);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dim {
|
|
||||||
fill: var(--color-8-uppergray);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.hint-text {
|
|
||||||
margin-left: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.floating-menu-content .row > & {
|
|
||||||
.input-key {
|
.input-key {
|
||||||
border-color: var(--color-3-darkgray);
|
display: flex;
|
||||||
color: var(--color-8-uppergray);
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
font-family: "Inconsolata", monospace;
|
||||||
|
font-weight: 400;
|
||||||
|
text-align: center;
|
||||||
|
height: 16px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid;
|
||||||
|
border-radius: 4px;
|
||||||
|
border-color: var(--color-5-dullgray);
|
||||||
|
color: var(--color-e-nearwhite);
|
||||||
|
|
||||||
|
.text-label {
|
||||||
|
// Firefox renders the text 1px lower than Chrome (tested on Windows) with 16px line-height,
|
||||||
|
// so moving it up 1 pixel by using 15px makes them agree.
|
||||||
|
line-height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.width-1 {
|
||||||
|
width: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.width-2 {
|
||||||
|
width: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.width-3 {
|
||||||
|
width: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.width-4 {
|
||||||
|
width: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.width-5 {
|
||||||
|
width: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-label {
|
||||||
|
margin: 1px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-key .icon-label svg,
|
.input-mouse {
|
||||||
&.keyboard-lock-notice.keyboard-lock-notice svg,
|
.bright {
|
||||||
.input-mouse .bright {
|
fill: var(--color-e-nearwhite);
|
||||||
fill: var(--color-8-uppergray);
|
}
|
||||||
|
|
||||||
|
.dim {
|
||||||
|
fill: var(--color-8-uppergray);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-mouse .dim {
|
.hint-text {
|
||||||
fill: var(--color-3-darkgray);
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-menu-content .row > & {
|
||||||
|
.input-key {
|
||||||
|
border-color: var(--color-3-darkgray);
|
||||||
|
color: var(--color-8-uppergray);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-key .icon-label svg,
|
||||||
|
&.keyboard-lock-notice.keyboard-lock-notice svg,
|
||||||
|
.input-mouse .bright {
|
||||||
|
fill: var(--color-8-uppergray);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-mouse .dim {
|
||||||
|
fill: var(--color-3-darkgray);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-menu-content .row:hover > & {
|
||||||
|
.input-key {
|
||||||
|
border-color: var(--color-7-middlegray);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-mouse .dim {
|
||||||
|
fill: var(--color-7-middlegray);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.floating-menu-content .row:hover > & {
|
|
||||||
.input-key {
|
|
||||||
border-color: var(--color-7-middlegray);
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-mouse .dim {
|
|
||||||
fill: var(--color-7-middlegray);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,148 +1,141 @@
|
|||||||
<script lang="ts">
|
<script lang="ts" context="module">
|
||||||
import { defineComponent, type PropType } from "vue";
|
export type RulerDirection = "Horizontal" | "Vertical";
|
||||||
|
|
||||||
const RULER_THICKNESS = 16;
|
|
||||||
const MAJOR_MARK_THICKNESS = 16;
|
|
||||||
const MEDIUM_MARK_THICKNESS = 6;
|
|
||||||
const MINOR_MARK_THICKNESS = 3;
|
|
||||||
|
|
||||||
export type RulerDirection = "Horizontal" | "Vertical";
|
|
||||||
|
|
||||||
// Modulo function that works for negative numbers, unlike the JS % operator
|
|
||||||
const mod = (n: number, m: number): number => {
|
|
||||||
const remainder = n % m;
|
|
||||||
return Math.floor(remainder >= 0 ? remainder : remainder + m);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default defineComponent({
|
|
||||||
props: {
|
|
||||||
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 offsetStart = mod(this.origin, this.majorMarkSpacing);
|
|
||||||
const shiftedOffsetStart = offsetStart - this.majorMarkSpacing;
|
|
||||||
|
|
||||||
const divisions = this.majorMarkSpacing / this.mediumDivisions / this.minorDivisions;
|
|
||||||
const majorMarksFrequency = this.mediumDivisions * this.minorDivisions;
|
|
||||||
|
|
||||||
let dPathAttribute = "";
|
|
||||||
let i = 0;
|
|
||||||
for (let location = shiftedOffsetStart; location < this.rulerLength; location += divisions) {
|
|
||||||
let length;
|
|
||||||
if (i % majorMarksFrequency === 0) length = MAJOR_MARK_THICKNESS;
|
|
||||||
else if (i % this.minorDivisions === 0) length = MEDIUM_MARK_THICKNESS;
|
|
||||||
else length = MINOR_MARK_THICKNESS;
|
|
||||||
i += 1;
|
|
||||||
|
|
||||||
const destination = Math.round(location) + 0.5;
|
|
||||||
const startPoint = isVertical ? `${RULER_THICKNESS - length},${destination}` : `${destination},${RULER_THICKNESS - length}`;
|
|
||||||
dPathAttribute += `M${startPoint}${lineDirection}${RULER_THICKNESS} `;
|
|
||||||
}
|
|
||||||
|
|
||||||
return dPathAttribute;
|
|
||||||
},
|
|
||||||
svgTexts(): { transform: string; text: number }[] {
|
|
||||||
const isVertical = this.direction === "Vertical";
|
|
||||||
|
|
||||||
const offsetStart = mod(this.origin, this.majorMarkSpacing);
|
|
||||||
const shiftedOffsetStart = offsetStart - this.majorMarkSpacing;
|
|
||||||
|
|
||||||
const svgTextCoordinates = [];
|
|
||||||
|
|
||||||
let text = (Math.ceil(-this.origin / this.majorMarkSpacing) - 1) * this.numberInterval;
|
|
||||||
|
|
||||||
for (let location = shiftedOffsetStart; location < this.rulerLength; location += this.majorMarkSpacing) {
|
|
||||||
const destination = Math.round(location);
|
|
||||||
const x = isVertical ? 9 : destination + 2;
|
|
||||||
const y = isVertical ? destination + 1 : 9;
|
|
||||||
|
|
||||||
let transform = `translate(${x} ${y})`;
|
|
||||||
if (isVertical) transform += " rotate(270)";
|
|
||||||
|
|
||||||
svgTextCoordinates.push({ transform, text });
|
|
||||||
|
|
||||||
text += this.numberInterval;
|
|
||||||
}
|
|
||||||
|
|
||||||
return svgTextCoordinates;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
resize() {
|
|
||||||
const canvasRuler = this.$refs.canvasRuler as HTMLDivElement | undefined;
|
|
||||||
if (!canvasRuler) return;
|
|
||||||
|
|
||||||
const isVertical = this.direction === "Vertical";
|
|
||||||
|
|
||||||
const newLength = isVertical ? canvasRuler.clientHeight : canvasRuler.clientWidth;
|
|
||||||
const roundedUp = (Math.floor(newLength / this.majorMarkSpacing) + 1) * this.majorMarkSpacing;
|
|
||||||
|
|
||||||
if (roundedUp !== this.rulerLength) {
|
|
||||||
this.rulerLength = roundedUp;
|
|
||||||
const thickness = `${RULER_THICKNESS}px`;
|
|
||||||
const length = `${roundedUp}px`;
|
|
||||||
this.svgBounds = isVertical ? { width: thickness, height: length } : { width: length, height: thickness };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
rulerLength: 0,
|
|
||||||
svgBounds: { width: "0px", height: "0px" },
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<script lang="ts">
|
||||||
<div class="canvas-ruler" :class="direction.toLowerCase()" ref="canvasRuler">
|
const RULER_THICKNESS = 16;
|
||||||
<svg :style="svgBounds">
|
const MAJOR_MARK_THICKNESS = 16;
|
||||||
<path :d="svgPath" />
|
const MEDIUM_MARK_THICKNESS = 6;
|
||||||
<text v-for="(svgText, index) in svgTexts" :key="index" :transform="svgText.transform">{{ svgText.text }}</text>
|
const MINOR_MARK_THICKNESS = 3;
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style lang="scss">
|
export let direction: RulerDirection = "Vertical";
|
||||||
.canvas-ruler {
|
export let origin: number;
|
||||||
flex: 1 1 100%;
|
export let numberInterval: number;
|
||||||
background: var(--color-4-dimgray);
|
export let majorMarkSpacing: number;
|
||||||
overflow: hidden;
|
export let mediumDivisions: number = 5;
|
||||||
position: relative;
|
export let minorDivisions: number = 2;
|
||||||
|
|
||||||
&.horizontal {
|
let canvasRuler: HTMLDivElement;
|
||||||
height: 16px;
|
let rulerLength = 0;
|
||||||
|
let svgBounds = { width: "0px", height: "0px" };
|
||||||
|
|
||||||
|
$: svgPath = computeSvgPath(direction, origin, majorMarkSpacing, mediumDivisions, minorDivisions, rulerLength);
|
||||||
|
$: svgTexts = computeSvgTexts(direction, origin, majorMarkSpacing, numberInterval, rulerLength);
|
||||||
|
|
||||||
|
function computeSvgPath(direction: RulerDirection, origin: number, majorMarkSpacing: number, mediumDivisions: number, minorDivisions: number, rulerLength: number): string {
|
||||||
|
const isVertical = direction === "Vertical";
|
||||||
|
const lineDirection = isVertical ? "H" : "V";
|
||||||
|
|
||||||
|
const offsetStart = mod(origin, majorMarkSpacing);
|
||||||
|
const shiftedOffsetStart = offsetStart - majorMarkSpacing;
|
||||||
|
|
||||||
|
const divisions = majorMarkSpacing / mediumDivisions / minorDivisions;
|
||||||
|
const majorMarksFrequency = mediumDivisions * minorDivisions;
|
||||||
|
|
||||||
|
let dPathAttribute = "";
|
||||||
|
let i = 0;
|
||||||
|
for (let location = shiftedOffsetStart; location < rulerLength; location += divisions) {
|
||||||
|
let length;
|
||||||
|
if (i % majorMarksFrequency === 0) length = MAJOR_MARK_THICKNESS;
|
||||||
|
else if (i % minorDivisions === 0) length = MEDIUM_MARK_THICKNESS;
|
||||||
|
else length = MINOR_MARK_THICKNESS;
|
||||||
|
i += 1;
|
||||||
|
|
||||||
|
const destination = Math.round(location) + 0.5;
|
||||||
|
const startPoint = isVertical ? `${RULER_THICKNESS - length},${destination}` : `${destination},${RULER_THICKNESS - length}`;
|
||||||
|
dPathAttribute += `M${startPoint}${lineDirection}${RULER_THICKNESS} `;
|
||||||
|
}
|
||||||
|
|
||||||
|
return dPathAttribute;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.vertical {
|
function computeSvgTexts(direction: RulerDirection, origin: number, majorMarkSpacing: number, numberInterval: number, rulerLength: number): { transform: string; text: number }[] {
|
||||||
width: 16px;
|
const isVertical = direction === "Vertical";
|
||||||
|
|
||||||
svg text {
|
const offsetStart = mod(origin, majorMarkSpacing);
|
||||||
text-anchor: end;
|
const shiftedOffsetStart = offsetStart - majorMarkSpacing;
|
||||||
|
|
||||||
|
const svgTextCoordinates = [];
|
||||||
|
|
||||||
|
let text = (Math.ceil(-origin / majorMarkSpacing) - 1) * numberInterval;
|
||||||
|
|
||||||
|
for (let location = shiftedOffsetStart; location < rulerLength; location += majorMarkSpacing) {
|
||||||
|
const destination = Math.round(location);
|
||||||
|
const x = isVertical ? 9 : destination + 2;
|
||||||
|
const y = isVertical ? destination + 1 : 9;
|
||||||
|
|
||||||
|
let transform = `translate(${x} ${y})`;
|
||||||
|
if (isVertical) transform += " rotate(270)";
|
||||||
|
|
||||||
|
svgTextCoordinates.push({ transform, text });
|
||||||
|
|
||||||
|
text += numberInterval;
|
||||||
|
}
|
||||||
|
|
||||||
|
return svgTextCoordinates;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resize() {
|
||||||
|
const isVertical = direction === "Vertical";
|
||||||
|
|
||||||
|
const newLength = isVertical ? canvasRuler.clientHeight : canvasRuler.clientWidth;
|
||||||
|
const roundedUp = (Math.floor(newLength / majorMarkSpacing) + 1) * majorMarkSpacing;
|
||||||
|
|
||||||
|
if (roundedUp !== rulerLength) {
|
||||||
|
rulerLength = roundedUp;
|
||||||
|
const thickness = `${RULER_THICKNESS}px`;
|
||||||
|
const length = `${roundedUp}px`;
|
||||||
|
svgBounds = isVertical ? { width: thickness, height: length } : { width: length, height: thickness };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
svg {
|
// Modulo function that works for negative numbers, unlike the JS `%` operator
|
||||||
position: absolute;
|
function mod(n: number, m: number): number {
|
||||||
|
const remainder = n % m;
|
||||||
|
return Math.floor(remainder >= 0 ? remainder : remainder + m);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
path {
|
<div class={`canvas-ruler ${direction.toLowerCase()}`} bind:this={canvasRuler}>
|
||||||
stroke-width: 1px;
|
<svg style:width={svgBounds.width} style:height={svgBounds.height}>
|
||||||
stroke: var(--color-7-middlegray);
|
<path d={svgPath} />
|
||||||
|
{#each svgTexts as svgText, index (index)}
|
||||||
|
<text transform={svgText.transform}>{svgText.text}</text>
|
||||||
|
{/each}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss" global>
|
||||||
|
.canvas-ruler {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
background: var(--color-4-dimgray);
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&.horizontal {
|
||||||
|
height: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
text {
|
&.vertical {
|
||||||
font-size: 12px;
|
width: 16px;
|
||||||
fill: var(--color-8-uppergray);
|
|
||||||
|
svg text {
|
||||||
|
text-anchor: end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
path {
|
||||||
|
stroke-width: 1px;
|
||||||
|
stroke: var(--color-7-middlegray);
|
||||||
|
}
|
||||||
|
|
||||||
|
text {
|
||||||
|
font-size: 12px;
|
||||||
|
fill: var(--color-8-uppergray);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,219 +1,208 @@
|
|||||||
<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)">
|
// Linear Interpolation
|
||||||
<div class="scroll-thumb" @pointerdown="(e) => grabHandle(e)" :class="{ dragging }" :style="[thumbStart, thumbEnd, sides]"></div>
|
const lerp = (x: number, y: number, a: number): number => x * (1 - a) + y * a;
|
||||||
</div>
|
|
||||||
<button class="arrow increase" @click="() => changePosition(50)" tabindex="-1"></button>
|
// 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>
|
</div>
|
||||||
</template>
|
<button class="arrow increase" on:click={() => changePosition(50)} tabindex="-1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss" global>
|
||||||
.persistent-scrollbar {
|
.persistent-scrollbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1 1 100%;
|
|
||||||
|
|
||||||
.arrow {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
border-style: solid;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scroll-track {
|
|
||||||
flex: 1 1 100%;
|
flex: 1 1 100%;
|
||||||
position: relative;
|
|
||||||
|
|
||||||
.scroll-thumb {
|
.arrow {
|
||||||
position: absolute;
|
flex: 0 0 auto;
|
||||||
border-radius: 4px;
|
background: none;
|
||||||
background: var(--color-5-dullgray);
|
border: none;
|
||||||
|
border-style: solid;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
&:hover,
|
.scroll-track {
|
||||||
&.dragging {
|
flex: 1 1 100%;
|
||||||
background: var(--color-6-lowergray);
|
position: relative;
|
||||||
|
|
||||||
|
.scroll-thumb {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--color-5-dullgray);
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&.dragging {
|
||||||
|
background: var(--color-6-lowergray);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-click-area {
|
||||||
|
position: absolute;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.scroll-click-area {
|
&.vertical {
|
||||||
position: absolute;
|
flex-direction: column;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.vertical {
|
.arrow.decrease {
|
||||||
flex-direction: column;
|
margin: 4px 3px;
|
||||||
|
border-width: 0 5px 8px 5px;
|
||||||
|
border-color: transparent transparent var(--color-5-dullgray) transparent;
|
||||||
|
|
||||||
.arrow.decrease {
|
&:hover {
|
||||||
margin: 4px 3px;
|
border-color: transparent transparent var(--color-6-lowergray) transparent;
|
||||||
border-width: 0 5px 8px 5px;
|
}
|
||||||
border-color: transparent transparent var(--color-5-dullgray) transparent;
|
&:active {
|
||||||
|
border-color: transparent transparent var(--color-c-brightgray) transparent;
|
||||||
&:hover {
|
}
|
||||||
border-color: transparent transparent var(--color-6-lowergray) transparent;
|
|
||||||
}
|
}
|
||||||
&:active {
|
|
||||||
border-color: transparent transparent var(--color-c-brightgray) transparent;
|
.arrow.increase {
|
||||||
|
margin: 4px 3px;
|
||||||
|
border-width: 8px 5px 0 5px;
|
||||||
|
border-color: var(--color-5-dullgray) transparent transparent transparent;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--color-6-lowergray) transparent transparent transparent;
|
||||||
|
}
|
||||||
|
&:active {
|
||||||
|
border-color: var(--color-c-brightgray) transparent transparent transparent;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.arrow.increase {
|
&.horizontal {
|
||||||
margin: 4px 3px;
|
flex-direction: row;
|
||||||
border-width: 8px 5px 0 5px;
|
|
||||||
border-color: var(--color-5-dullgray) transparent transparent transparent;
|
|
||||||
|
|
||||||
&:hover {
|
.arrow.decrease {
|
||||||
border-color: var(--color-6-lowergray) transparent transparent transparent;
|
margin: 3px 4px;
|
||||||
|
border-width: 5px 8px 5px 0;
|
||||||
|
border-color: transparent var(--color-5-dullgray) transparent transparent;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: transparent var(--color-6-lowergray) transparent transparent;
|
||||||
|
}
|
||||||
|
&:active {
|
||||||
|
border-color: transparent var(--color-c-brightgray) transparent transparent;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
&:active {
|
|
||||||
border-color: var(--color-c-brightgray) transparent transparent transparent;
|
.arrow.increase {
|
||||||
|
margin: 3px 4px;
|
||||||
|
border-width: 5px 0 5px 8px;
|
||||||
|
border-color: transparent transparent transparent var(--color-5-dullgray);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: transparent transparent transparent var(--color-6-lowergray);
|
||||||
|
}
|
||||||
|
&:active {
|
||||||
|
border-color: transparent transparent transparent var(--color-c-brightgray);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.horizontal {
|
|
||||||
flex-direction: row;
|
|
||||||
|
|
||||||
.arrow.decrease {
|
|
||||||
margin: 3px 4px;
|
|
||||||
border-width: 5px 8px 5px 0;
|
|
||||||
border-color: transparent var(--color-5-dullgray) transparent transparent;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: transparent var(--color-6-lowergray) transparent transparent;
|
|
||||||
}
|
|
||||||
&:active {
|
|
||||||
border-color: transparent var(--color-c-brightgray) transparent transparent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.arrow.increase {
|
|
||||||
margin: 3px 4px;
|
|
||||||
border-width: 5px 0 5px 8px;
|
|
||||||
border-color: transparent transparent transparent var(--color-5-dullgray);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: transparent transparent transparent var(--color-6-lowergray);
|
|
||||||
}
|
|
||||||
&:active {
|
|
||||||
border-color: transparent transparent transparent var(--color-c-brightgray);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</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";
|
||||||
|
|
||||||
<Workspace />
|
let platform: ApplicationPlatform = "Web";
|
||||||
|
let maximized: true;
|
||||||
|
</script>
|
||||||
|
|
||||||
<StatusBar />
|
<LayoutCol class="main-window">
|
||||||
</LayoutCol>
|
<TitleBar {platform} {maximized} />
|
||||||
</template>
|
|
||||||
|
|
||||||
<style lang="scss">
|
<Workspace />
|
||||||
.main-window {
|
|
||||||
height: 100%;
|
<StatusBar />
|
||||||
overflow: auto;
|
</LayoutCol>
|
||||||
touch-action: none;
|
|
||||||
}
|
<style lang="scss" global>
|
||||||
|
.main-window {
|
||||||
|
height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,82 +1,75 @@
|
|||||||
<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[] {
|
||||||
};
|
if (platformIsMac() && hint.keyGroupsMac) return hint.keyGroupsMac;
|
||||||
},
|
return hint.keyGroups;
|
||||||
methods: {
|
}
|
||||||
inputKeysForPlatform(hint: HintInfo): LayoutKeysGroup[] {
|
|
||||||
if (platformIsMac() && hint.keyGroupsMac) return hint.keyGroupsMac;
|
onMount(() => {
|
||||||
return hint.keyGroups;
|
editor.subscriptions.subscribeJsMessage(UpdateInputHints, (data) => {
|
||||||
},
|
hintData = data.hintData;
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
this.editor.subscriptions.subscribeJsMessage(UpdateInputHints, (updateInputHints) => {
|
|
||||||
this.hintData = updateInputHints.hintData;
|
|
||||||
});
|
});
|
||||||
},
|
});
|
||||||
components: {
|
|
||||||
LayoutRow,
|
|
||||||
Separator,
|
|
||||||
UserInputLabel,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<LayoutRow class="status-bar">
|
||||||
<LayoutRow class="status-bar">
|
<LayoutRow class="hint-groups">
|
||||||
<LayoutRow class="hint-groups">
|
{#each hintData as hintGroup, index (hintGroup)}
|
||||||
<template v-for="(hintGroup, index) in hintData" :key="hintGroup">
|
{#if index !== 0}
|
||||||
<Separator :type="'Section'" v-if="index !== 0" />
|
<Separator type={"Section"} />
|
||||||
<template v-for="hint in hintGroup" :key="hint">
|
{/if}
|
||||||
<LayoutRow v-if="hint.plus" class="plus">+</LayoutRow>
|
{#each hintGroup as hint (hint)}
|
||||||
<UserInputLabel :mouseMotion="hint.mouse" :keysWithLabelsGroups="inputKeysForPlatform(hint)">{{ hint.label }}</UserInputLabel>
|
{#if hint.plus}
|
||||||
</template>
|
<LayoutRow class="plus">+</LayoutRow>
|
||||||
</template>
|
{/if}
|
||||||
</LayoutRow>
|
<UserInputLabel mouseMotion={hint.mouse} keysWithLabelsGroups={inputKeysForPlatform(hint)}>{hint.label}</UserInputLabel>
|
||||||
|
{/each}
|
||||||
|
{/each}
|
||||||
</LayoutRow>
|
</LayoutRow>
|
||||||
</template>
|
</LayoutRow>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss" global>
|
||||||
.status-bar {
|
.status-bar {
|
||||||
height: 24px;
|
height: 24px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
flex: 0 0 auto;
|
|
||||||
|
|
||||||
.hint-groups {
|
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
max-width: 100%;
|
|
||||||
margin: 0 -4px;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
.separator.section {
|
.hint-groups {
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plus {
|
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
align-items: center;
|
max-width: 100%;
|
||||||
font-weight: 700;
|
margin: 0 -4px;
|
||||||
}
|
overflow: hidden;
|
||||||
|
|
||||||
.user-input-label {
|
.separator.section {
|
||||||
margin: 0 8px;
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
& + .user-input-label {
|
.plus {
|
||||||
margin-left: 0;
|
flex: 0 0 auto;
|
||||||
|
align-items: center;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-input-label {
|
||||||
|
margin: 0 8px;
|
||||||
|
|
||||||
|
& + .user-input-label {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,75 +1,66 @@
|
|||||||
<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'" />
|
export let platform: Platform;
|
||||||
<WindowButtonsWeb :maximized="maximized" v-if="platform === 'Web'" />
|
export let maximized: boolean;
|
||||||
</LayoutRow>
|
|
||||||
|
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>
|
||||||
</template>
|
<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">
|
<style lang="scss" global>
|
||||||
.title-bar {
|
.title-bar {
|
||||||
height: 28px;
|
height: 28px;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
|
||||||
.header-part {
|
.header-part {
|
||||||
flex: 1 1 100%;
|
flex: 1 1 100%;
|
||||||
|
|
||||||
&:nth-child(1) {
|
&:nth-child(1) {
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:nth-child(2) {
|
&:nth-child(2) {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:nth-child(3) {
|
&:nth-child(3) {
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,52 +1,43 @@
|
|||||||
<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;
|
|
||||||
align-items: center;
|
|
||||||
margin: 0 8px;
|
|
||||||
|
|
||||||
div {
|
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 11px;
|
margin: 0 8px;
|
||||||
height: 11px;
|
|
||||||
border-radius: 50%;
|
|
||||||
|
|
||||||
& + div {
|
div {
|
||||||
margin-left: 8px;
|
flex: 0 0 auto;
|
||||||
}
|
align-items: center;
|
||||||
|
width: 11px;
|
||||||
|
height: 11px;
|
||||||
|
border-radius: 50%;
|
||||||
|
|
||||||
&.close {
|
& + div {
|
||||||
background: #ff5a52;
|
margin-left: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.minimize {
|
&.close {
|
||||||
background: #e6c029;
|
background: #ff5a52;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.zoom {
|
&.minimize {
|
||||||
background: #54c22b;
|
background: #e6c029;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.zoom {
|
||||||
|
background: #54c22b;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,59 +1,50 @@
|
|||||||
<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;
|
||||||
|
|
||||||
svg {
|
|
||||||
fill: var(--color-e-nearwhite);
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-label {
|
|
||||||
margin-right: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--color-6-lowergray);
|
|
||||||
color: var(--color-f-white);
|
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
fill: var(--color-f-white);
|
fill: var(--color-e-nearwhite);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-label {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-6-lowergray);
|
||||||
|
color: var(--color-f-white);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
fill: var(--color-f-white);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,55 +1,46 @@
|
|||||||
<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;
|
||||||
|
|
||||||
svg {
|
|
||||||
fill: var(--color-e-nearwhite);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--color-6-lowergray);
|
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
fill: var(--color-f-white);
|
fill: var(--color-e-nearwhite);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-6-lowergray);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
fill: var(--color-f-white);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.close:hover {
|
||||||
|
background: #e81123;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.close:hover {
|
|
||||||
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,289 +1,307 @@
|
|||||||
<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 = {
|
||||||
|
Document,
|
||||||
import { type LayoutKeysGroup, type Key } from "@/wasm-communication/messages";
|
LayerTree,
|
||||||
|
NodeGraph,
|
||||||
import LayoutCol from "@/components/layout/LayoutCol.vue";
|
Properties,
|
||||||
import LayoutRow from "@/components/layout/LayoutRow.vue";
|
};
|
||||||
import Document from "@/components/panels/Document.vue";
|
type PanelTypes = keyof typeof PANEL_COMPONENTS;
|
||||||
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,
|
|
||||||
IconButton,
|
|
||||||
LayerTree,
|
|
||||||
NodeGraph,
|
|
||||||
PopoverButton,
|
|
||||||
Properties,
|
|
||||||
TextButton,
|
|
||||||
};
|
|
||||||
type PanelTypes = keyof typeof PANEL_COMPONENTS;
|
|
||||||
|
|
||||||
export default defineComponent({
|
|
||||||
inject: ["editor"],
|
|
||||||
props: {
|
|
||||||
tabMinWidths: { type: Boolean as PropType<boolean>, default: false },
|
|
||||||
tabCloseButtons: { type: Boolean as PropType<boolean>, default: false },
|
|
||||||
tabLabels: { type: Array as PropType<{ name: string; tooltip?: string }[]>, required: true },
|
|
||||||
tabActiveIndex: { type: Number as PropType<number>, required: true },
|
|
||||||
panelType: { type: String as PropType<PanelTypes>, required: false },
|
|
||||||
clickAction: { type: Function as PropType<(index: number) => void>, required: false },
|
|
||||||
closeAction: { type: Function as PropType<(index: number) => void>, required: false },
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
newDocument() {
|
|
||||||
this.editor.instance.newDocumentDialog();
|
|
||||||
},
|
|
||||||
openDocument() {
|
|
||||||
this.editor.instance.documentOpen();
|
|
||||||
},
|
|
||||||
platformModifiers(reservedKey: boolean): LayoutKeysGroup {
|
|
||||||
// TODO: Remove this by properly feeding these keys from a layout provided by the backend
|
|
||||||
|
|
||||||
const ALT: Key = { key: "Alt", label: "Alt" };
|
|
||||||
const COMMAND: Key = { key: "Command", label: "Command" };
|
|
||||||
const CONTROL: Key = { key: "Control", label: "Ctrl" };
|
|
||||||
|
|
||||||
if (platformIsMac()) return reservedKey ? [ALT, COMMAND] : [COMMAND];
|
|
||||||
return reservedKey ? [CONTROL, ALT] : [CONTROL];
|
|
||||||
},
|
|
||||||
async scrollTabIntoView(newIndex: number) {
|
|
||||||
await nextTick();
|
|
||||||
|
|
||||||
const panel: HTMLDivElement | undefined = this.$el;
|
|
||||||
if (!panel) return;
|
|
||||||
|
|
||||||
const newActiveTab = panel.querySelectorAll("[data-tab]")[newIndex] as HTMLDivElement | undefined;
|
|
||||||
newActiveTab?.scrollIntoView();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
components: {
|
|
||||||
IconLabel,
|
|
||||||
LayoutCol,
|
|
||||||
LayoutRow,
|
|
||||||
TextLabel,
|
|
||||||
UserInputLabel,
|
|
||||||
...PANEL_COMPONENTS,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<script lang="ts">
|
||||||
<LayoutCol class="panel">
|
import { getContext, tick } from "svelte";
|
||||||
<LayoutRow class="tab-bar" :class="{ 'min-widths': tabMinWidths }">
|
|
||||||
<LayoutRow class="tab-group" :scrollableX="true">
|
import { platformIsMac, isEventSupported } from "@/utility-functions/platform";
|
||||||
|
|
||||||
|
import { type LayoutKeysGroup, type Key } from "@/wasm-communication/messages";
|
||||||
|
|
||||||
|
import LayoutCol from "@/components/layout/LayoutCol.svelte";
|
||||||
|
import LayoutRow from "@/components/layout/LayoutRow.svelte";
|
||||||
|
import IconLabel from "@/components/widgets/labels/IconLabel.svelte";
|
||||||
|
import TextLabel from "@/components/widgets/labels/TextLabel.svelte";
|
||||||
|
import UserInputLabel from "@/components/widgets/labels/UserInputLabel.svelte";
|
||||||
|
import { type Editor } from "@/wasm-communication/editor";
|
||||||
|
|
||||||
|
const editor = getContext<Editor>("editor");
|
||||||
|
|
||||||
|
export let tabMinWidths = false;
|
||||||
|
export let tabCloseButtons = false;
|
||||||
|
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
|
||||||
|
|
||||||
|
const ALT: Key = { key: "Alt", label: "Alt" };
|
||||||
|
const COMMAND: Key = { key: "Command", label: "Command" };
|
||||||
|
const CONTROL: Key = { key: "Control", label: "Ctrl" };
|
||||||
|
|
||||||
|
if (platformIsMac()) return reservedKey ? [ALT, COMMAND] : [COMMAND];
|
||||||
|
return reservedKey ? [CONTROL, ALT] : [CONTROL];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function scrollTabIntoView(newIndex: number) {
|
||||||
|
await tick();
|
||||||
|
tabElements[newIndex].div().scrollIntoView();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<LayoutCol class="panel">
|
||||||
|
<LayoutRow class="tab-bar" classes={{ "min-widths": tabMinWidths }}>
|
||||||
|
<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>
|
||||||
</LayoutRow>
|
{/each}
|
||||||
<PopoverButton :icon="'VerticalEllipsis'">
|
|
||||||
<TextLabel :bold="true">Panel Options</TextLabel>
|
|
||||||
<TextLabel :multiline="true">Coming soon</TextLabel>
|
|
||||||
</PopoverButton>
|
|
||||||
</LayoutRow>
|
</LayoutRow>
|
||||||
<LayoutCol class="panel-body">
|
<PopoverButton icon="VerticalEllipsis">
|
||||||
<component :is="panelType" v-if="panelType" />
|
<TextLabel bold={true}>Panel Options</TextLabel>
|
||||||
<LayoutCol class="empty-panel" v-else>
|
<TextLabel multiline={true}>Coming soon</TextLabel>
|
||||||
|
</PopoverButton>
|
||||||
|
</LayoutRow>
|
||||||
|
<LayoutCol class="panel-body">
|
||||||
|
{#if panelType}
|
||||||
|
<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>
|
||||||
</LayoutCol>
|
{/if}
|
||||||
</LayoutCol>
|
</LayoutCol>
|
||||||
</template>
|
</LayoutCol>
|
||||||
|
|
||||||
<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;
|
||||||
|
|
||||||
.tab-bar {
|
.tab-bar {
|
||||||
height: 28px;
|
height: 28px;
|
||||||
min-height: auto;
|
min-height: auto;
|
||||||
|
|
||||||
&.min-widths .tab-group .tab {
|
&.min-widths .tab-group .tab {
|
||||||
min-width: 120px;
|
min-width: 120px;
|
||||||
max-width: 360px;
|
max-width: 360px;
|
||||||
}
|
|
||||||
|
|
||||||
.tab-group {
|
|
||||||
flex: 1 1 100%;
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
// This always hangs out at the end of the last tab, providing 16px (15px plus the 1px reserved for the separator line) to the right of the tabs.
|
|
||||||
// When the last tab is selected, its bottom rounded fillet adds 16px to the width, which stretches the scrollbar width allocation in only that situation.
|
|
||||||
// This pseudo-element ensures we always reserve that space to prevent the scrollbar from jumping when the last tab is selected.
|
|
||||||
// There is unfortunately no apparent way to remove that 16px gap from the end of the scroll container, since negative margin does not reduce the scrollbar allocation.
|
|
||||||
&::after {
|
|
||||||
content: "";
|
|
||||||
width: 15px;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab {
|
.tab-group {
|
||||||
flex: 0 1 auto;
|
flex: 1 1 100%;
|
||||||
height: 100%;
|
|
||||||
padding: 0 8px;
|
|
||||||
align-items: center;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
&.active {
|
// This always hangs out at the end of the last tab, providing 16px (15px plus the 1px reserved for the separator line) to the right of the tabs.
|
||||||
background: var(--color-3-darkgray);
|
// When the last tab is selected, its bottom rounded fillet adds 16px to the width, which stretches the scrollbar width allocation in only that situation.
|
||||||
border-radius: 6px 6px 0 0;
|
// This pseudo-element ensures we always reserve that space to prevent the scrollbar from jumping when the last tab is selected.
|
||||||
|
// There is unfortunately no apparent way to remove that 16px gap from the end of the scroll container, since negative margin does not reduce the scrollbar allocation.
|
||||||
|
&::after {
|
||||||
|
content: "";
|
||||||
|
width: 15px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0 8px;
|
||||||
|
align-items: center;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
&:not(:first-child)::before,
|
&.active {
|
||||||
&::after {
|
background: var(--color-3-darkgray);
|
||||||
content: "";
|
border-radius: 6px 6px 0 0;
|
||||||
width: 16px;
|
position: relative;
|
||||||
height: 8px;
|
|
||||||
position: absolute;
|
&:not(:first-child)::before,
|
||||||
bottom: 0;
|
&::after {
|
||||||
|
content: "";
|
||||||
|
width: 16px;
|
||||||
|
height: 8px;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(:first-child)::before {
|
||||||
|
left: -16px;
|
||||||
|
border-bottom-right-radius: 8px;
|
||||||
|
box-shadow: 8px 0 0 0 var(--color-3-darkgray);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
right: -16px;
|
||||||
|
border-bottom-left-radius: 8px;
|
||||||
|
box-shadow: -8px 0 0 0 var(--color-3-darkgray);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&:not(:first-child)::before {
|
span {
|
||||||
left: -16px;
|
flex: 1 1 100%;
|
||||||
border-bottom-right-radius: 8px;
|
overflow-x: hidden;
|
||||||
box-shadow: 8px 0 0 0 var(--color-3-darkgray);
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
// Height and line-height required because https://stackoverflow.com/a/21611191/775283
|
||||||
|
height: 100%;
|
||||||
|
line-height: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&::after {
|
.icon-button {
|
||||||
right: -16px;
|
margin-left: 8px;
|
||||||
border-bottom-left-radius: 8px;
|
|
||||||
box-shadow: -8px 0 0 0 var(--color-3-darkgray);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
span {
|
& + .tab {
|
||||||
flex: 1 1 100%;
|
margin-left: 1px;
|
||||||
overflow-x: hidden;
|
}
|
||||||
white-space: nowrap;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
// Height and line-height required because https://stackoverflow.com/a/21611191/775283
|
|
||||||
height: 100%;
|
|
||||||
line-height: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-button {
|
&:not(.active) + .tab:not(.active)::before {
|
||||||
margin-left: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
& + .tab {
|
|
||||||
margin-left: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:not(.active) + .tab:not(.active)::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
left: -1px;
|
|
||||||
width: 1px;
|
|
||||||
height: 16px;
|
|
||||||
background: var(--color-4-dimgray);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:last-of-type {
|
|
||||||
margin-right: 1px;
|
|
||||||
|
|
||||||
&:not(.active)::after {
|
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: -1px;
|
left: -1px;
|
||||||
width: 1px;
|
width: 1px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
background: var(--color-4-dimgray);
|
background: var(--color-4-dimgray);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:last-of-type {
|
||||||
|
margin-right: 1px;
|
||||||
|
|
||||||
|
&:not(.active)::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
right: -1px;
|
||||||
|
width: 1px;
|
||||||
|
height: 16px;
|
||||||
|
background: var(--color-4-dimgray);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.popover-button {
|
||||||
|
margin: 2px 4px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.popover-button {
|
.panel-body {
|
||||||
margin: 2px 4px;
|
background: var(--color-3-darkgray);
|
||||||
}
|
flex: 1 1 100%;
|
||||||
}
|
flex-direction: column;
|
||||||
|
|
||||||
.panel-body {
|
.empty-panel {
|
||||||
background: var(--color-3-darkgray);
|
background: var(--color-2-mildblack);
|
||||||
flex: 1 1 100%;
|
margin: 4px;
|
||||||
flex-direction: column;
|
border-radius: 2px;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
.empty-panel {
|
.content {
|
||||||
background: var(--color-2-mildblack);
|
flex: 0 0 auto;
|
||||||
margin: 4px;
|
align-items: center;
|
||||||
border-radius: 2px;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
.content {
|
.logotype {
|
||||||
flex: 0 0 auto;
|
margin-bottom: 40px;
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
.logotype {
|
svg {
|
||||||
margin-bottom: 40px;
|
width: auto;
|
||||||
|
height: 120px;
|
||||||
svg {
|
|
||||||
width: auto;
|
|
||||||
height: 120px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions {
|
|
||||||
table {
|
|
||||||
border-spacing: 8px;
|
|
||||||
margin: -8px;
|
|
||||||
|
|
||||||
td {
|
|
||||||
padding: 0;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.text-button:not(:hover) {
|
.actions {
|
||||||
background: none;
|
table {
|
||||||
|
border-spacing: 8px;
|
||||||
|
margin: -8px;
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-button:not(:hover) {
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,178 +1,170 @@
|
|||||||
<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";
|
/**/ root: 100,
|
||||||
import Panel from "@/components/window/workspace/Panel.vue";
|
/* ├── */ content: 80,
|
||||||
|
/* │ ├── */ document: 60,
|
||||||
|
/* │ └── */ graph: 40,
|
||||||
|
/* └── */ details: 20,
|
||||||
|
/* ├── */ properties: 45,
|
||||||
|
/* └── */ layers: 55,
|
||||||
|
};
|
||||||
|
|
||||||
const MIN_PANEL_SIZE = 100;
|
let panelSizes = PANEL_SIZES;
|
||||||
const PANEL_SIZES = {
|
let documentPanel: Panel;
|
||||||
/**/ root: 100,
|
|
||||||
/* ├── */ content: 80,
|
|
||||||
/* │ ├── */ document: 60,
|
|
||||||
/* │ └── */ graph: 40,
|
|
||||||
/* └── */ details: 20,
|
|
||||||
/* ├── */ properties: 45,
|
|
||||||
/* └── */ layers: 55,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default defineComponent({
|
$: activeDocumentIndex = $portfolio.activeDocumentIndex;
|
||||||
inject: ["workspace", "portfolio", "dialog", "editor"],
|
$: nodeGraphVisible = $workspace.nodeGraphVisible;
|
||||||
data() {
|
$: documentTabLabels = $portfolio.documents.map((doc: FrontendDocumentDetails) => {
|
||||||
return {
|
const name = doc.displayName;
|
||||||
panelSizes: PANEL_SIZES,
|
|
||||||
|
if (!editor.instance.inDevelopmentMode()) return { name };
|
||||||
|
|
||||||
|
const tooltip = `Document ID ${doc.id}`;
|
||||||
|
return { name, tooltip };
|
||||||
|
});
|
||||||
|
$: {
|
||||||
|
scrollIntoView(activeDocumentIndex);
|
||||||
|
}
|
||||||
|
function scrollIntoView(activeDocumentIndex: number) {
|
||||||
|
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 prevSibling = (gutter?.previousElementSibling || undefined) as HTMLDivElement | undefined;
|
||||||
|
const parentElement = (gutter?.parentElement || undefined) as HTMLDivElement | undefined;
|
||||||
|
|
||||||
|
const nextSiblingName = (nextSibling?.getAttribute("data-subdivision-name") || undefined) as keyof typeof PANEL_SIZES;
|
||||||
|
const prevSiblingName = (prevSibling?.getAttribute("data-subdivision-name") || undefined) as keyof typeof PANEL_SIZES;
|
||||||
|
|
||||||
|
if (!gutter || !nextSibling || !prevSibling || !parentElement || !nextSiblingName || !prevSiblingName) return;
|
||||||
|
|
||||||
|
// Are we resizing horizontally?
|
||||||
|
const isHorizontal = gutter.getAttribute("data-gutter-horizontal") !== null;
|
||||||
|
|
||||||
|
// Get the current size in px of the panels being resized and the gutter
|
||||||
|
const gutterSize = isHorizontal ? gutter.getBoundingClientRect().width : gutter.getBoundingClientRect().height;
|
||||||
|
const nextSiblingSize = isHorizontal ? nextSibling.getBoundingClientRect().width : nextSibling.getBoundingClientRect().height;
|
||||||
|
const prevSiblingSize = isHorizontal ? prevSibling.getBoundingClientRect().width : prevSibling.getBoundingClientRect().height;
|
||||||
|
const parentElementSize = isHorizontal ? parentElement.getBoundingClientRect().width : parentElement.getBoundingClientRect().height;
|
||||||
|
|
||||||
|
// Measure the resizing panels as a percentage of all sibling panels
|
||||||
|
const totalResizingSpaceOccupied = gutterSize + nextSiblingSize + prevSiblingSize;
|
||||||
|
const proportionBeingResized = totalResizingSpaceOccupied / parentElementSize;
|
||||||
|
|
||||||
|
// Prevent cursor flicker as mouse temporarily leaves the gutter
|
||||||
|
gutter.setPointerCapture(e.pointerId);
|
||||||
|
|
||||||
|
const mouseStart = isHorizontal ? e.clientX : e.clientY;
|
||||||
|
|
||||||
|
const updatePosition = (e: PointerEvent): void => {
|
||||||
|
const mouseCurrent = isHorizontal ? e.clientX : e.clientY;
|
||||||
|
let mouseDelta = mouseStart - mouseCurrent;
|
||||||
|
|
||||||
|
mouseDelta = Math.max(nextSiblingSize + mouseDelta, MIN_PANEL_SIZE) - nextSiblingSize;
|
||||||
|
mouseDelta = prevSiblingSize - Math.max(prevSiblingSize - mouseDelta, MIN_PANEL_SIZE);
|
||||||
|
|
||||||
|
panelSizes[nextSiblingName] = ((nextSiblingSize + mouseDelta) / totalResizingSpaceOccupied) * proportionBeingResized * 100;
|
||||||
|
panelSizes[prevSiblingName] = ((prevSiblingSize - mouseDelta) / totalResizingSpaceOccupied) * proportionBeingResized * 100;
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent("resize"));
|
||||||
};
|
};
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
activeDocumentIndex() {
|
|
||||||
return this.portfolio.state.activeDocumentIndex;
|
|
||||||
},
|
|
||||||
nodeGraphVisible() {
|
|
||||||
return this.workspace.state.nodeGraphVisible;
|
|
||||||
},
|
|
||||||
documentTabLabels() {
|
|
||||||
return this.portfolio.state.documents.map((doc) => {
|
|
||||||
const name = doc.displayName;
|
|
||||||
|
|
||||||
if (!this.editor.instance.inDevelopmentMode()) return { name };
|
const cleanup = (e: PointerEvent): void => {
|
||||||
|
gutter.releasePointerCapture(e.pointerId);
|
||||||
|
|
||||||
const tooltip = `Document ID ${doc.id}`;
|
document.removeEventListener("pointermove", updatePosition);
|
||||||
return { name, tooltip };
|
document.removeEventListener("pointerleave", cleanup);
|
||||||
});
|
document.removeEventListener("pointerup", cleanup);
|
||||||
},
|
};
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
resizePanel(event: PointerEvent) {
|
|
||||||
const gutter = (event.target || undefined) as HTMLDivElement | undefined;
|
|
||||||
const nextSibling = (gutter?.nextElementSibling || undefined) as HTMLDivElement | undefined;
|
|
||||||
const prevSibling = (gutter?.previousElementSibling || undefined) as HTMLDivElement | undefined;
|
|
||||||
const parentElement = (gutter?.parentElement || undefined) as HTMLDivElement | undefined;
|
|
||||||
|
|
||||||
const nextSiblingName = (nextSibling?.getAttribute("data-subdivision-name") || undefined) as keyof typeof PANEL_SIZES;
|
document.addEventListener("pointermove", updatePosition);
|
||||||
const prevSiblingName = (prevSibling?.getAttribute("data-subdivision-name") || undefined) as keyof typeof PANEL_SIZES;
|
document.addEventListener("pointerleave", cleanup);
|
||||||
|
document.addEventListener("pointerup", cleanup);
|
||||||
if (!gutter || !nextSibling || !prevSibling || !parentElement || !nextSiblingName || !prevSiblingName) return;
|
}
|
||||||
|
|
||||||
// Are we resizing horizontally?
|
|
||||||
const isHorizontal = gutter.getAttribute("data-gutter-horizontal") !== null;
|
|
||||||
|
|
||||||
// Get the current size in px of the panels being resized and the gutter
|
|
||||||
const gutterSize = isHorizontal ? gutter.getBoundingClientRect().width : gutter.getBoundingClientRect().height;
|
|
||||||
const nextSiblingSize = isHorizontal ? nextSibling.getBoundingClientRect().width : nextSibling.getBoundingClientRect().height;
|
|
||||||
const prevSiblingSize = isHorizontal ? prevSibling.getBoundingClientRect().width : prevSibling.getBoundingClientRect().height;
|
|
||||||
const parentElementSize = isHorizontal ? parentElement.getBoundingClientRect().width : parentElement.getBoundingClientRect().height;
|
|
||||||
|
|
||||||
// Measure the resizing panels as a percentage of all sibling panels
|
|
||||||
const totalResizingSpaceOccupied = gutterSize + nextSiblingSize + prevSiblingSize;
|
|
||||||
const proportionBeingResized = totalResizingSpaceOccupied / parentElementSize;
|
|
||||||
|
|
||||||
// Prevent cursor flicker as mouse temporarily leaves the gutter
|
|
||||||
gutter.setPointerCapture(event.pointerId);
|
|
||||||
|
|
||||||
const mouseStart = isHorizontal ? event.clientX : event.clientY;
|
|
||||||
|
|
||||||
const updatePosition = (event: PointerEvent): void => {
|
|
||||||
const mouseCurrent = isHorizontal ? event.clientX : event.clientY;
|
|
||||||
let mouseDelta = mouseStart - mouseCurrent;
|
|
||||||
|
|
||||||
mouseDelta = Math.max(nextSiblingSize + mouseDelta, MIN_PANEL_SIZE) - nextSiblingSize;
|
|
||||||
mouseDelta = prevSiblingSize - Math.max(prevSiblingSize - mouseDelta, MIN_PANEL_SIZE);
|
|
||||||
|
|
||||||
this.panelSizes[nextSiblingName] = ((nextSiblingSize + mouseDelta) / totalResizingSpaceOccupied) * proportionBeingResized * 100;
|
|
||||||
this.panelSizes[prevSiblingName] = ((prevSiblingSize - mouseDelta) / totalResizingSpaceOccupied) * proportionBeingResized * 100;
|
|
||||||
|
|
||||||
window.dispatchEvent(new CustomEvent("resize"));
|
|
||||||
};
|
|
||||||
|
|
||||||
const cleanup = (event: PointerEvent): void => {
|
|
||||||
gutter.releasePointerCapture(event.pointerId);
|
|
||||||
|
|
||||||
document.removeEventListener("pointermove", updatePosition);
|
|
||||||
document.removeEventListener("pointerleave", cleanup);
|
|
||||||
document.removeEventListener("pointerup", cleanup);
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener("pointermove", updatePosition);
|
|
||||||
document.addEventListener("pointerleave", 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.documents.length > 0 ? "Document" : undefined}
|
||||||
:panelType="portfolio.state.documents.length > 0 ? 'Document' : undefined"
|
tabCloseButtons={true}
|
||||||
:tabCloseButtons="true"
|
tabMinWidths={true}
|
||||||
:tabMinWidths="true"
|
tabLabels={documentTabLabels}
|
||||||
:tabLabels="documentTabLabels"
|
clickAction={(tabIndex) => editor.instance.selectDocument($portfolio.documents[tabIndex].id)}
|
||||||
:clickAction="(tabIndex: number) => editor.instance.selectDocument(portfolio.state.documents[tabIndex].id)"
|
closeAction={(tabIndex) => editor.instance.closeDocumentWithConfirmation($portfolio.documents[tabIndex].id)}
|
||||||
:closeAction="(tabIndex: number) => editor.instance.closeDocumentWithConfirmation(portfolio.state.documents[tabIndex].id)"
|
tabActiveIndex={$portfolio.activeDocumentIndex}
|
||||||
:tabActiveIndex="portfolio.state.activeDocumentIndex"
|
bind:this={documentPanel}
|
||||||
ref="documentPanel"
|
/>
|
||||||
/>
|
</LayoutRow>
|
||||||
|
{#if nodeGraphVisible}
|
||||||
|
<LayoutRow class="workspace-grid-resize-gutter" data-gutter-vertical on:pointerdown={resizePanel} />
|
||||||
|
<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>
|
||||||
<LayoutRow class="workspace-grid-resize-gutter" data-gutter-vertical @pointerdown="(e: PointerEvent) => resizePanel(e)" v-if="nodeGraphVisible"></LayoutRow>
|
{/if}
|
||||||
<LayoutRow class="workspace-grid-subdivision" v-if="nodeGraphVisible" :style="{ 'flex-grow': panelSizes['graph'] }" data-subdivision-name="graph">
|
</LayoutCol>
|
||||||
<Panel :panelType="'NodeGraph'" :tabLabels="[{ name: 'Node Graph' }]" :tabActiveIndex="0" />
|
<LayoutCol class="workspace-grid-resize-gutter" data-gutter-horizontal on:pointerdown={(e) => resizePanel(e)} />
|
||||||
</LayoutRow>
|
<LayoutCol class="workspace-grid-subdivision" styles={{ "flex-grow": panelSizes["details"] }} data-subdivision-name="details">
|
||||||
</LayoutCol>
|
<LayoutRow class="workspace-grid-subdivision" styles={{ "flex-grow": panelSizes["properties"] }} data-subdivision-name="properties">
|
||||||
<LayoutCol class="workspace-grid-resize-gutter" data-gutter-horizontal @pointerdown="(e: PointerEvent) => resizePanel(e)"></LayoutCol>
|
<Panel panelType="Properties" tabLabels={[{ name: "Properties" }]} tabActiveIndex={0} />
|
||||||
<LayoutCol class="workspace-grid-subdivision" :style="{ 'flex-grow': panelSizes['details'] }" data-subdivision-name="details">
|
</LayoutRow>
|
||||||
<LayoutRow class="workspace-grid-subdivision" :style="{ 'flex-grow': panelSizes['properties'] }" data-subdivision-name="properties">
|
<LayoutRow class="workspace-grid-resize-gutter" data-gutter-vertical on:pointerdown={(e) => resizePanel(e)} />
|
||||||
<Panel :panelType="'Properties'" :tabLabels="[{ name: 'Properties' }]" :tabActiveIndex="0" />
|
<LayoutRow class="workspace-grid-subdivision" styles={{ "flex-grow": panelSizes["layers"] }} data-subdivision-name="layers">
|
||||||
</LayoutRow>
|
<Panel panelType="LayerTree" tabLabels={[{ name: "Layer Tree" }]} tabActiveIndex={0} />
|
||||||
<LayoutRow class="workspace-grid-resize-gutter" data-gutter-vertical @pointerdown="(e: PointerEvent) => resizePanel(e)"></LayoutRow>
|
</LayoutRow>
|
||||||
<LayoutRow class="workspace-grid-subdivision" :style="{ 'flex-grow': panelSizes['layers'] }" data-subdivision-name="layers">
|
</LayoutCol>
|
||||||
<Panel :panelType="'LayerTree'" :tabLabels="[{ name: 'Layer Tree' }]" :tabActiveIndex="0" />
|
|
||||||
</LayoutRow>
|
|
||||||
</LayoutCol>
|
|
||||||
</LayoutRow>
|
|
||||||
<DialogModal v-if="dialog.state.visible" />
|
|
||||||
</LayoutRow>
|
</LayoutRow>
|
||||||
</template>
|
{#if $dialog.visible}
|
||||||
|
<DialogModal />
|
||||||
|
{/if}
|
||||||
|
</LayoutRow>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss" global>
|
||||||
.workspace {
|
.workspace {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 1 1 100%;
|
flex: 1 1 100%;
|
||||||
|
|
||||||
.workspace-grid-subdivision {
|
.workspace-grid-subdivision {
|
||||||
min-height: 28px;
|
min-height: 28px;
|
||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
|
|
||||||
&.folded {
|
&.folded {
|
||||||
flex-grow: 0;
|
flex-grow: 0;
|
||||||
height: 0;
|
height: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-grid-resize-gutter {
|
||||||
|
flex: 0 0 4px;
|
||||||
|
|
||||||
|
&.layout-row {
|
||||||
|
cursor: ns-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.layout-col {
|
||||||
|
cursor: ew-resize;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.workspace-grid-resize-gutter {
|
|
||||||
flex: 0 0 4px;
|
|
||||||
|
|
||||||
&.layout-row {
|
|
||||||
cursor: ns-resize;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.layout-col {
|
|
||||||
cursor: ew-resize;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
7
frontend/src/imports.d.ts
vendored
Normal file
7
frontend/src/imports.d.ts
vendored
Normal 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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
state.visible = false;
|
update((state) => {
|
||||||
}
|
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 {
|
||||||
state.visible = true;
|
update((state) => {
|
||||||
state.icon = icon;
|
state.visible = true;
|
||||||
state.widgets = widgets;
|
state.icon = icon;
|
||||||
state.jsCallbackBasedButtons = jsCallbackBasedButtons;
|
state.widgets = widgets;
|
||||||
|
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) => {
|
||||||
state.visible = true;
|
update((state) => {
|
||||||
state.icon = displayDialog.icon;
|
state.visible = true;
|
||||||
|
state.icon = displayDialog.icon;
|
||||||
|
return state;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
editor.subscriptions.subscribeJsMessage(UpdateDialogDetails, (updateDialogDetails) => {
|
editor.subscriptions.subscribeJsMessage(UpdateDialogDetails, (updateDialogDetails) => {
|
||||||
patchWidgetLayout(state.widgets, updateDialogDetails);
|
update((state) => {
|
||||||
state.jsCallbackBasedButtons = undefined;
|
patchWidgetLayout(state.widgets, updateDialogDetails);
|
||||||
|
|
||||||
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
patchWidgetLayout(state.documentModeLayout, updateDocumentModeLayout);
|
|
||||||
|
update((state) => {
|
||||||
|
// `state.documentModeLayout` is mutated in the function
|
||||||
|
patchWidgetLayout(state.documentModeLayout, updateDocumentModeLayout);
|
||||||
|
return state;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
editor.subscriptions.subscribeJsMessage(UpdateToolOptionsLayout, async (updateToolOptionsLayout) => {
|
editor.subscriptions.subscribeJsMessage(UpdateToolOptionsLayout, async (updateToolOptionsLayout) => {
|
||||||
await nextTick();
|
await tick();
|
||||||
patchWidgetLayout(state.toolOptionsLayout, updateToolOptionsLayout);
|
|
||||||
|
update((state) => {
|
||||||
|
// `state.documentModeLayout` is mutated in the function
|
||||||
|
patchWidgetLayout(state.toolOptionsLayout, updateToolOptionsLayout);
|
||||||
|
return state;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
editor.subscriptions.subscribeJsMessage(UpdateDocumentBarLayout, async (updateDocumentBarLayout) => {
|
editor.subscriptions.subscribeJsMessage(UpdateDocumentBarLayout, async (updateDocumentBarLayout) => {
|
||||||
await nextTick();
|
await tick();
|
||||||
patchWidgetLayout(state.documentBarLayout, updateDocumentBarLayout);
|
|
||||||
|
update((state) => {
|
||||||
|
// `state.documentModeLayout` is mutated in the function
|
||||||
|
patchWidgetLayout(state.documentBarLayout, updateDocumentBarLayout);
|
||||||
|
return state;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
editor.subscriptions.subscribeJsMessage(UpdateToolShelfLayout, async (updateToolShelfLayout) => {
|
editor.subscriptions.subscribeJsMessage(UpdateToolShelfLayout, async (updateToolShelfLayout) => {
|
||||||
await nextTick();
|
await tick();
|
||||||
patchWidgetLayout(state.toolShelfLayout, updateToolShelfLayout);
|
|
||||||
|
update((state) => {
|
||||||
|
// `state.documentModeLayout` is mutated in the function
|
||||||
|
patchWidgetLayout(state.toolShelfLayout, updateToolShelfLayout);
|
||||||
|
return state;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
editor.subscriptions.subscribeJsMessage(UpdateWorkingColorsLayout, async (updateWorkingColorsLayout) => {
|
editor.subscriptions.subscribeJsMessage(UpdateWorkingColorsLayout, async (updateWorkingColorsLayout) => {
|
||||||
await nextTick();
|
await tick();
|
||||||
patchWidgetLayout(state.workingColorsLayout, updateWorkingColorsLayout);
|
|
||||||
|
update((state) => {
|
||||||
|
// `state.documentModeLayout` is mutated in the function
|
||||||
|
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>;
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
state.windowFullscreen = Boolean(document.fullscreenElement);
|
update((state) => {
|
||||||
if (!state.windowFullscreen) state.keyboardLocked = false;
|
state.windowFullscreen = Boolean(document.fullscreenElement);
|
||||||
|
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"]);
|
||||||
state.keyboardLocked = true;
|
|
||||||
|
update((state) => {
|
||||||
|
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,
|
||||||
|
|||||||
@@ -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) => {
|
||||||
state.nodes = updateNodeGraph.nodes;
|
update((state) => {
|
||||||
state.links = updateNodeGraph.links;
|
state.nodes = updateNodeGraph.nodes;
|
||||||
|
state.links = updateNodeGraph.links;
|
||||||
|
return state;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
editor.subscriptions.subscribeJsMessage(UpdateNodeTypes, (updateNodeTypes) => {
|
editor.subscriptions.subscribeJsMessage(UpdateNodeTypes, (updateNodeTypes) => {
|
||||||
state.nodeTypes = updateNodeTypes.nodeTypes;
|
update((state) => {
|
||||||
|
state.nodeTypes = updateNodeTypes.nodeTypes;
|
||||||
|
return state;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
editor.subscriptions.subscribeJsMessage(UpdateNodeGraphBarLayout, (updateNodeGraphBarLayout) => {
|
editor.subscriptions.subscribeJsMessage(UpdateNodeGraphBarLayout, (updateNodeGraphBarLayout) => {
|
||||||
patchWidgetLayout(state.nodeGraphBarLayout, updateNodeGraphBarLayout);
|
update((state) => {
|
||||||
|
patchWidgetLayout(state.nodeGraphBarLayout, updateNodeGraphBarLayout);
|
||||||
|
return state;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
editor.subscriptions.subscribeJsMessage(UpdateZoomWithScroll, (updateZoomWithScroll) => {
|
editor.subscriptions.subscribeJsMessage(UpdateZoomWithScroll, (updateZoomWithScroll) => {
|
||||||
state.zoomWithScroll = updateZoomWithScroll.zoomWithScroll;
|
update((state) => {
|
||||||
|
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>;
|
||||||
|
|||||||
@@ -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>;
|
|
||||||
@@ -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) => {
|
||||||
state.documents = updateOpenDocumentList.openDocuments;
|
update((state) => {
|
||||||
|
state.documents = updateOpenDocumentList.openDocuments;
|
||||||
|
return state;
|
||||||
|
})
|
||||||
});
|
});
|
||||||
editor.subscriptions.subscribeJsMessage(UpdateActiveDocument, (updateActiveDocument) => {
|
editor.subscriptions.subscribeJsMessage(UpdateActiveDocument, (updateActiveDocument) => {
|
||||||
// Assume we receive a correct document id
|
update((state) => {
|
||||||
const activeId = state.documents.findIndex((doc) => doc.id === updateActiveDocument.documentId);
|
// Assume we receive a correct document id
|
||||||
state.activeDocumentIndex = activeId;
|
const activeId = state.documents.findIndex((doc) => doc.id === updateActiveDocument.documentId);
|
||||||
|
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>;
|
||||||
|
|||||||
@@ -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) => {
|
||||||
state.nodeGraphVisible = updateNodeGraphVisibility.visible;
|
update((state) => {
|
||||||
|
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
frontend/src/types.d.ts
vendored
11
frontend/src/types.d.ts
vendored
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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 }>;
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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"
|
}
|
||||||
]
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
module.exports = function VueSvgLoader(svg) {
|
|
||||||
this.cacheable();
|
|
||||||
return `<template>${svg}</template>`;
|
|
||||||
};
|
|
||||||
@@ -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
|
crateDirectory: path.resolve(__dirname, "wasm"),
|
||||||
.plugin("wasm-pack")
|
// Remove when this issue is resolved: https://github.com/wasm-tool/wasm-pack-plugin/issues/93
|
||||||
.use(WasmPackPlugin)
|
outDir: path.resolve(__dirname, "wasm/pkg"),
|
||||||
.init(
|
watchDirectories: ["../editor", "../document-legacy", "../proc-macros", "../node-graph"].map((folder) => path.resolve(__dirname, folder)),
|
||||||
(Plugin) =>
|
}),
|
||||||
new Plugin({
|
|
||||||
crateDirectory: path.resolve(__dirname, "wasm"),
|
|
||||||
// Remove when this issue is resolved: https://github.com/wasm-tool/wasm-pack-plugin/issues/93
|
|
||||||
outDir: path.resolve(__dirname, "wasm/pkg"),
|
|
||||||
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")
|
allow: "(Apache-2.0 OR BSD-2-Clause OR BSD-3-Clause OR MIT OR 0BSD)",
|
||||||
.use(LicenseCheckerWebpackPlugin)
|
emitError: true,
|
||||||
.init(
|
outputFilename: "third-party-licenses.txt",
|
||||||
(Plugin) =>
|
outputWriter: formatThirdPartyLicenses,
|
||||||
new Plugin({
|
// Workaround for failure caused in WebPack 5: https://github.com/microsoft/license-checker-webpack-plugin/issues/25#issuecomment-833325799
|
||||||
allow: "(Apache-2.0 OR BSD-2-Clause OR BSD-3-Clause OR MIT OR 0BSD)",
|
filter: /(^.*[/\\]node_modules[/\\]((?:@[^/\\]+[/\\])?(?:[^@/\\][^/\\]*)))/,
|
||||||
emitError: true,
|
}),
|
||||||
outputFilename: "third-party-licenses.txt",
|
|
||||||
outputWriter: formatThirdPartyLicenses,
|
|
||||||
// Workaround for failure caused in WebPack 5: https://github.com/microsoft/license-checker-webpack-plugin/issues/25#issuecomment-833325799
|
|
||||||
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
|
experiments: {
|
||||||
.rule("svg")
|
asyncWebAssembly: true,
|
||||||
.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: {
|
|
||||||
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") {
|
||||||
Reference in New Issue
Block a user