mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-23 18:28:11 +08:00
Port frontend from Vue to Svelte (WIP in separate folder, many bugs) (#964)
* Make `tauri build` just work * Move folder: frontend/wasm -> wasm * Create SvelteKit project with 'npx create-svelte' * Move wasm-communication into seperate npm package * Use wasm-pack directly and pack package.json * Got it to work * Add primitive build script for wasm npm module * Fix wasm build script (python) * Clean up glue code * Rewrite wasm build script in node.js * Add serde-reflection to trace types * Add traced types * Generate typescript (.d.ts) from Rust types * Update .d.ts * Finalize TS types * Add script to update .d.ts * Add watch command to build wasm-bindgen * Make wasm work again * Add sass; fix build script for windows * Describe requirement for wasm-pack * Add license * Copy and reorganize vue components * translate LayoutCol.vue * Split app.scss into pieces * Translate LayoutRow.svelte * Rename scss files * Fix compile issues on Windows * WIP port TitleBar * Support classes for LayoutCol/Row * Restructure based on Vue codebase * Port all components in window folder * Port FloatingMenu * Port Document panel component * Update readme after folder move * Update typegen: print discriminant by default * Update typegen: Merge from branch 'tailwind' 4f14fedb Fixes bigint & bytes * Made Vue/webpack/eslint to accept wasm package at new location This is quite a hack. Those two packages are both named the same. Yes, it's an npm package inside another npm package. - frontend/src/wasm-communication/ - frontend-svelte/glue/ 'wasm/pkg/index.js' imports the correct one registered when linking. * Port LayerTree * Port NodeGraph * Port Properties * Port components in /floating-menus * Finish porting all Vue -> Svelte components * Change import prefix * Revert type generation * Revert moved wasm folder * Revert all of @locriacyber's work on this branch - Remove Vite and restore Webpack - Remove SvelteKit - Remove everything except the components I ported to Svelte - Restore all frontend files from Vue code, now altered for Svelte * Convert Vue's 'reactive' and 'provide' to Svelte's stores and contexts * Fix event emitting and bi-di data flow * Undo removal of 'update:' events * Fix 'update:' event dispatching * Fix usage in parent of bi-di component props * Fix component typing, more progress towards no errors * The page builds and opens! * Add loading spinner and remove postcss dependency * Make the basics of document editing work * Fix rebase history Co-authored-by: Locria Cyber <74560659+locriacyber@users.noreply.github.com>
This commit is contained in:
co-authored by
Locria Cyber
parent
2f7c45771e
commit
dcc3eadf44
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
|
||||
import { initWasm, createEditor } from "@/wasm-communication/editor";
|
||||
|
||||
import Editor from "@/components/Editor.svelte";
|
||||
|
||||
let editor: ReturnType<typeof createEditor> | undefined = undefined;
|
||||
|
||||
onMount(async () => {
|
||||
await initWasm();
|
||||
|
||||
editor = createEditor();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
// Destroy the WASM editor instance
|
||||
editor?.instance.free();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if editor !== undefined}
|
||||
<Editor {editor} />
|
||||
{/if}
|
||||
@@ -0,0 +1,53 @@
|
||||
# Overview of `/frontend/src/`
|
||||
|
||||
## Vue components: `components/`
|
||||
|
||||
Vue components that build the Graphite editor GUI, which are mounted in `App.svelte`. 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.
|
||||
|
||||
## 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`).
|
||||
|
||||
Each I/O manager is a self-contained module where one instance is created in `App.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.svelte` on unmount.
|
||||
|
||||
## 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.
|
||||
|
||||
In `App.svelte`, 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).
|
||||
|
||||
## _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._
|
||||
|
||||
## Utility functions: `utility-functions/`
|
||||
|
||||
TypeScript files which define and `export` individual helper functions for use elsewhere in the codebase. These files should not persist state outside each function.
|
||||
|
||||
## WASM communication: `wasm-communication/`
|
||||
|
||||
TypeScript files which serve as the JS interface to the WASM bindings for the editor backend.
|
||||
|
||||
### WASM editor: `editor.ts`
|
||||
|
||||
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.svelte` 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`.
|
||||
|
||||
### Message definitions: `messages.ts`
|
||||
|
||||
Defines the message formats and data types received from the backend. Since Rust and JS support different styles of data representation, this bridges the gap from Rust into JS land. Messages (and the data contained within) are serialized in Rust by `serde` into JSON, and these definitions are manually kept up-to-date to parallel the message structs and their data types. (However, directives like `#[serde(skip)]` or `#[serde(rename = "someOtherName")]` may cause the TypeScript format to look slightly different from the Rust structs.) These definitions are basically just for the sake of TypeScript to understand the format, although in some cases we may perform data conversion here using translation functions that we can provide.
|
||||
|
||||
### Subscription router: `subscription-router.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.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.
|
||||
|
||||
## Entry point: `main.ts`
|
||||
|
||||
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");`.
|
||||
@@ -0,0 +1,299 @@
|
||||
<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
|
||||
.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>
|
||||
@@ -0,0 +1,58 @@
|
||||
# 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.svelte`, in the `/src` directory above this one.
|
||||
|
||||
## 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.
|
||||
|
||||
## Layout: `layout/`
|
||||
|
||||
Useful containers that control the flow of content held within.
|
||||
|
||||
## Panels: `panels/`
|
||||
|
||||
The dockable tabbed regions like the Document, Properties, Layer Tree, and Node Graph panels.
|
||||
|
||||
## Widgets: `widgets/`
|
||||
|
||||
The interactive input items used to display information and provide user control.
|
||||
|
||||
## Window: `window/`
|
||||
|
||||
The building blocks for the Title Bar, Workspace, and Status Bar within an editor application window.
|
||||
|
||||
# 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.
|
||||
|
||||
## 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) {},
|
||||
},
|
||||
methods: {
|
||||
doSomething() {
|
||||
this.$emit("update:theBidirectionalProperty", SOME_NEW_VALUE);
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Users of the component do this for `theCorrespondingDataEntry` to be a two-way binding:
|
||||
|
||||
```html
|
||||
<DropdownInput v-model:theBidirectionalProperty="theCorrespondingDataEntry" />
|
||||
```
|
||||
@@ -0,0 +1,615 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, createEventDispatcher, getContext } from "svelte";
|
||||
|
||||
import { clamp } from "@/utility-functions/math";
|
||||
import { type HSV, type RGB } from "@/wasm-communication/messages";
|
||||
import { Color } from "@/wasm-communication/messages";
|
||||
|
||||
import FloatingMenu, { type MenuDirection } from "@/components/layout/FloatingMenu.svelte";
|
||||
import LayoutCol from "@/components/layout/LayoutCol.svelte";
|
||||
import LayoutRow from "@/components/layout/LayoutRow.svelte";
|
||||
import IconButton from "@/components/widgets/buttons/IconButton.svelte";
|
||||
import DropdownInput from "@/components/widgets/inputs/DropdownInput.svelte";
|
||||
import NumberInput from "@/components/widgets/inputs/NumberInput.svelte";
|
||||
import TextInput from "@/components/widgets/inputs/TextInput.svelte";
|
||||
import Separator from "@/components/widgets/labels/Separator.svelte";
|
||||
import TextLabel from "@/components/widgets/labels/TextLabel.svelte";
|
||||
import { type Editor } from "@/wasm-communication/editor";
|
||||
|
||||
type PresetColors = "none" | "black" | "white" | "red" | "yellow" | "green" | "cyan" | "blue" | "magenta";
|
||||
|
||||
const PURE_COLORS: Record<PresetColors, [number, number, number]> = {
|
||||
none: [0, 0, 0],
|
||||
black: [0, 0, 0],
|
||||
white: [1, 1, 1],
|
||||
red: [1, 0, 0],
|
||||
yellow: [1, 1, 0],
|
||||
green: [0, 1, 0],
|
||||
cyan: [0, 1, 1],
|
||||
blue: [0, 0, 1],
|
||||
magenta: [1, 0, 1],
|
||||
};
|
||||
const COLOR_SPACE_CHOICES = [[{ label: "sRGB" }]];
|
||||
|
||||
const editor = getContext<Editor>("editor");
|
||||
|
||||
// emits: ["update:color", "update:open"],
|
||||
const dispatch = createEventDispatcher<{ color: Color }>();
|
||||
|
||||
export let color: Color;
|
||||
export let allowNone = false;
|
||||
// export let allowTransparency = false; // TODO: Implement this
|
||||
export let direction: MenuDirection = "Bottom";
|
||||
// TODO: See if this should be made to follow the pattern of DropdownInput.svelte so this could be removed
|
||||
export let open: boolean;
|
||||
|
||||
const hsvaOrNone = color.toHSVA();
|
||||
const hsva = hsvaOrNone || { h: 0, s: 0, v: 0, a: 1 };
|
||||
|
||||
let hue = hsva.h;
|
||||
let saturation = hsva.s;
|
||||
let value = hsva.v;
|
||||
let alpha = hsva.a;
|
||||
let isNone = hsvaOrNone === undefined;
|
||||
let initialHue = hsva.h;
|
||||
let initialSaturation = hsva.s;
|
||||
let initialValue = hsva.v;
|
||||
let initialAlpha = hsva.a;
|
||||
let initialIsNone = hsvaOrNone === undefined;
|
||||
let draggingPickerTrack: HTMLDivElement | undefined = undefined;
|
||||
let colorSpaceChoices = COLOR_SPACE_CHOICES;
|
||||
let strayCloses = true;
|
||||
|
||||
$: rgbChannels = Object.entries(newColor.toRgb255() || { r: undefined, g: undefined, b: undefined }) as [keyof RGB, number | undefined][];
|
||||
$: hsvChannels = Object.entries(!isNone ? { h: hue * 360, s: saturation * 100, v: value * 100 } : { h: undefined, s: undefined, v: undefined }) as [keyof HSV, number | undefined][];
|
||||
$: opaqueHueColor = new Color({ h: hue, s: 1, v: 1, a: 1 });
|
||||
$: newColor = isNone ? new Color("none") : new Color({ h: hue, s: saturation, v: value, a: alpha });
|
||||
$: initialColor = initialIsNone ? new Color("none") : new Color({ h: initialHue, s: initialSaturation, v: initialValue, a: initialAlpha });
|
||||
|
||||
$: watchOpen(open);
|
||||
$: watchColor(color);
|
||||
|
||||
// Called only when `open` is changed from outside this component (with v-model)
|
||||
function watchOpen(open: boolean) {
|
||||
if (open) setInitialHSVA(hue, saturation, value, alpha, isNone);
|
||||
}
|
||||
|
||||
// Called only when `color` is changed from outside this component (with v-model)
|
||||
function watchColor(color: Color) {
|
||||
const hsva = color.toHSVA();
|
||||
|
||||
if (hsva === undefined) {
|
||||
setNewHSVA(0, 0, 0, 1, true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the hue, but only if it is necessary so we don't:
|
||||
// - ...jump the user's hue from 360° (top) to the equivalent 0° (bottom)
|
||||
// - ...reset the hue to 0° if the color is fully desaturated, where all hues are equivalent
|
||||
// - ...reset the hue to 0° if the color's value is black, where all hues are equivalent
|
||||
if (!(hsva.h === 0 && hue === 1) && hsva.s > 0 && hsva.v > 0) hue = hsva.h;
|
||||
// Update the saturation, but only if it is necessary so we don't:
|
||||
// - ...reset the saturation to the left is the color's value is black along the bottom edge, where all saturations are equivalent
|
||||
if (hsva.v !== 0) saturation = hsva.s;
|
||||
// Update the value
|
||||
value = hsva.v;
|
||||
// Update the alpha
|
||||
alpha = hsva.a;
|
||||
// Update the status of this not being a color
|
||||
isNone = false;
|
||||
}
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
const target = (e.target || undefined) as HTMLElement | undefined;
|
||||
draggingPickerTrack = target?.closest("[data-saturation-value-picker], [data-hue-picker], [data-alpha-picker]") || undefined;
|
||||
|
||||
addEvents();
|
||||
|
||||
onPointerMove(e);
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
// Just in case the mouseup event is lost
|
||||
if (e.buttons === 0) removeEvents();
|
||||
|
||||
if (draggingPickerTrack?.hasAttribute("data-saturation-value-picker")) {
|
||||
const rectangle = draggingPickerTrack.getBoundingClientRect();
|
||||
|
||||
saturation = clamp((e.clientX - rectangle.left) / rectangle.width, 0, 1);
|
||||
value = clamp(1 - (e.clientY - rectangle.top) / rectangle.height, 0, 1);
|
||||
strayCloses = false;
|
||||
} else if (draggingPickerTrack?.hasAttribute("data-hue-picker")) {
|
||||
const rectangle = draggingPickerTrack.getBoundingClientRect();
|
||||
|
||||
hue = clamp(1 - (e.clientY - rectangle.top) / rectangle.height, 0, 1);
|
||||
strayCloses = false;
|
||||
} else if (draggingPickerTrack?.hasAttribute("data-alpha-picker")) {
|
||||
const rectangle = draggingPickerTrack.getBoundingClientRect();
|
||||
|
||||
alpha = clamp(1 - (e.clientY - rectangle.top) / rectangle.height, 0, 1);
|
||||
strayCloses = false;
|
||||
}
|
||||
|
||||
const color = new Color({ h: hue, s: saturation, v: value, a: alpha });
|
||||
setColor(color);
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
removeEvents();
|
||||
}
|
||||
|
||||
function addEvents() {
|
||||
document.addEventListener("pointermove", onPointerMove);
|
||||
document.addEventListener("pointerup", onPointerUp);
|
||||
}
|
||||
|
||||
function removeEvents() {
|
||||
draggingPickerTrack = undefined;
|
||||
strayCloses = true;
|
||||
|
||||
document.removeEventListener("pointermove", onPointerMove);
|
||||
document.removeEventListener("pointerup", onPointerUp);
|
||||
}
|
||||
|
||||
function setColor(color?: Color) {
|
||||
const colorToEmit = color || new Color({ h: hue, s: saturation, v: value, a: alpha });
|
||||
dispatch("color", colorToEmit);
|
||||
}
|
||||
|
||||
function swapNewWithInitial() {
|
||||
const initial = initialColor;
|
||||
|
||||
const tempHue = hue;
|
||||
const tempSaturation = saturation;
|
||||
const tempValue = value;
|
||||
const tempAlpha = alpha;
|
||||
const tempIsNone = isNone;
|
||||
|
||||
setNewHSVA(initialHue, initialSaturation, initialValue, initialAlpha, initialIsNone);
|
||||
setInitialHSVA(tempHue, tempSaturation, tempValue, tempAlpha, tempIsNone);
|
||||
|
||||
setColor(initial);
|
||||
}
|
||||
|
||||
function setColorCode(colorCode: string) {
|
||||
const color = Color.fromCSS(colorCode);
|
||||
if (color) setColor(color);
|
||||
}
|
||||
|
||||
function setColorRGB(channel: keyof RGB, strength: number | undefined) {
|
||||
// Do nothing if the given value is undefined
|
||||
if (strength === undefined) undefined;
|
||||
// Set the specified channel to the given value
|
||||
else if (channel === "r") setColor(new Color(strength / 255, newColor.green, newColor.blue, newColor.alpha));
|
||||
else if (channel === "g") setColor(new Color(newColor.red, strength / 255, newColor.blue, newColor.alpha));
|
||||
else if (channel === "b") setColor(new Color(newColor.red, newColor.green, strength / 255, newColor.alpha));
|
||||
}
|
||||
|
||||
function setColorHSV(channel: keyof HSV, strength: number | undefined) {
|
||||
// Do nothing if the given value is undefined
|
||||
if (strength === undefined) undefined;
|
||||
// Set the specified channel to the given value
|
||||
else if (channel === "h") hue = strength / 360;
|
||||
else if (channel === "s") saturation = strength / 100;
|
||||
else if (channel === "v") value = strength / 100;
|
||||
|
||||
setColor();
|
||||
}
|
||||
|
||||
function setColorAlphaPercent(strength: number | undefined) {
|
||||
if (strength !== undefined) alpha = strength / 100;
|
||||
setColor();
|
||||
}
|
||||
|
||||
function setColorPresetSubtile(e: MouseEvent) {
|
||||
const clickedTile = e.target as HTMLDivElement | undefined;
|
||||
const tileColor = clickedTile?.getAttribute("data-pure-tile") || undefined;
|
||||
|
||||
if (tileColor) setColorPreset(tileColor as PresetColors);
|
||||
}
|
||||
|
||||
function setColorPreset(preset: PresetColors) {
|
||||
if (preset === "none") {
|
||||
setNewHSVA(0, 0, 0, 1, true);
|
||||
setColor(new Color("none"));
|
||||
return;
|
||||
}
|
||||
|
||||
const presetColor = new Color(...PURE_COLORS[preset], 1);
|
||||
const hsva = presetColor.toHSVA() || { h: 0, s: 0, v: 0, a: 0 };
|
||||
|
||||
setNewHSVA(hsva.h, hsva.s, hsva.v, hsva.a, false);
|
||||
setColor(presetColor);
|
||||
}
|
||||
|
||||
function setNewHSVA(hue: number, saturation: number, value: number, alpha: number, isNone: boolean) {
|
||||
hue = hue;
|
||||
saturation = saturation;
|
||||
value = value;
|
||||
alpha = alpha;
|
||||
isNone = isNone;
|
||||
}
|
||||
|
||||
function setInitialHSVA(hue: number, saturation: number, value: number, alpha: number, isNone: boolean) {
|
||||
initialHue = hue;
|
||||
initialSaturation = saturation;
|
||||
initialValue = value;
|
||||
initialAlpha = alpha;
|
||||
initialIsNone = isNone;
|
||||
}
|
||||
|
||||
async function activateEyedropperSample() {
|
||||
// TODO: Replace this temporary solution that only works in Chromium-based browsers with the custom color sampler used by the Eyedropper tool
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if (!(window as any).EyeDropper) {
|
||||
editor.instance.eyedropperSampleForColorPicker();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await new (window as any).EyeDropper().open();
|
||||
setColorCode(result.sRGBHex);
|
||||
} catch {
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
removeEvents();
|
||||
});
|
||||
</script>
|
||||
|
||||
<FloatingMenu class="color-picker" {open} on:open {strayCloses} {direction} type="Popover">
|
||||
<LayoutRow
|
||||
styles={{
|
||||
"--new-color": newColor.toHexOptionalAlpha(),
|
||||
"--new-color-contrasting": newColor.contrastingColor(),
|
||||
"--initial-color": initialColor.toHexOptionalAlpha(),
|
||||
"--initial-color-contrasting": initialColor.contrastingColor(),
|
||||
"--hue-color": opaqueHueColor.toRgbCSS(),
|
||||
"--hue-color-contrasting": opaqueHueColor.contrastingColor(),
|
||||
"--opaque-color": (newColor.opaque() || new Color(0, 0, 0, 1)).toHexNoAlpha(),
|
||||
"--opaque-color-contrasting": (newColor.opaque() || new Color(0, 0, 0, 1)).contrastingColor(),
|
||||
}}
|
||||
>
|
||||
<LayoutCol class="saturation-value-picker" on:pointerdown={onPointerDown} data-saturation-value-picker>
|
||||
{#if !isNone}
|
||||
<div class="selection-circle" style:top={`${(1 - value) * 100}%`} style:left={`${saturation * 100}%`} />
|
||||
{/if}
|
||||
</LayoutCol>
|
||||
<LayoutCol class="hue-picker" on:pointerdown={onPointerDown} data-hue-picker>
|
||||
{#if !isNone}
|
||||
<div class="selection-pincers" style:top={`${(1 - hue) * 100}%`} />
|
||||
{/if}
|
||||
</LayoutCol>
|
||||
<LayoutCol class="alpha-picker" on:pointerdown={onPointerDown} data-alpha-picker>
|
||||
{#if !isNone}
|
||||
<div class="selection-pincers" style:top={`${(1 - alpha) * 100}%`} />
|
||||
{/if}
|
||||
</LayoutCol>
|
||||
<LayoutCol class="details">
|
||||
<LayoutRow class="choice-preview" on:click={swapNewWithInitial} tooltip="Comparison views of the present color choice (left) and the color before any change (right). Click to swap sides.">
|
||||
<LayoutCol class="new-color" classes={{ none: isNone }}>
|
||||
<TextLabel>New</TextLabel>
|
||||
</LayoutCol>
|
||||
<LayoutCol class="initial-color" classes={{ none: initialIsNone }}>
|
||||
<TextLabel>Initial</TextLabel>
|
||||
</LayoutCol>
|
||||
</LayoutRow>
|
||||
<DropdownInput entries={colorSpaceChoices} selectedIndex={0} disabled={true} tooltip="Color Space and HDR (coming soon)" />
|
||||
<LayoutRow>
|
||||
<TextLabel tooltip="Color code in hexadecimal format">Hex</TextLabel>
|
||||
<Separator />
|
||||
<LayoutRow>
|
||||
<TextInput
|
||||
value={newColor.toHexOptionalAlpha() || "-"}
|
||||
on:commitText={({ detail }) => setColorCode(detail)}
|
||||
centered={true}
|
||||
tooltip="Color code in hexadecimal format. 6 digits if opaque, 8 with alpha.\nAccepts input of CSS color values including named colors."
|
||||
/>
|
||||
</LayoutRow>
|
||||
</LayoutRow>
|
||||
<LayoutRow>
|
||||
<TextLabel tooltip="Red/Green/Blue channels of the color, integers 0–255">RGB</TextLabel>
|
||||
<Separator />
|
||||
<LayoutRow>
|
||||
{#each rgbChannels as [channel, strength], index (channel)}
|
||||
{#if index > 0}
|
||||
<Separator type="Related" />
|
||||
{/if}
|
||||
<NumberInput
|
||||
value={strength}
|
||||
on:value={({ detail }) => setColorRGB(channel, detail)}
|
||||
min={0}
|
||||
max={255}
|
||||
minWidth={56}
|
||||
tooltip={`${{ r: "Red", g: "Green", b: "Blue" }[channel]} channel, integers 0–255`}
|
||||
/>
|
||||
{/each}
|
||||
</LayoutRow>
|
||||
</LayoutRow>
|
||||
<LayoutRow>
|
||||
<TextLabel tooltip="Hue/Saturation/Value, also known as Hue/Saturation/Brightness (HSB).\nNot to be confused with Hue/Saturation/Lightness (HSL), a different color model."
|
||||
>HSV</TextLabel
|
||||
>
|
||||
<Separator />
|
||||
<LayoutRow>
|
||||
{#each hsvChannels as [channel, strength], index (channel)}
|
||||
{#if index > 0}
|
||||
<Separator type="Related" />
|
||||
{/if}
|
||||
<NumberInput
|
||||
value={strength}
|
||||
on:value={({ detail }) => setColorHSV(channel, detail)}
|
||||
min={0}
|
||||
max={channel === "h" ? 360 : 100}
|
||||
unit={channel === "h" ? "°" : "%"}
|
||||
minWidth={56}
|
||||
tooltip={{
|
||||
h: "Hue component, the "color" along the rainbow",
|
||||
s: "Saturation component, the "colorfulness" from gray to vivid",
|
||||
v: "Value (or Brightness), the distance away from being darkened to black",
|
||||
}[channel]}
|
||||
/>
|
||||
{/each}
|
||||
</LayoutRow>
|
||||
</LayoutRow>
|
||||
<NumberInput
|
||||
label="Alpha"
|
||||
value={!isNone ? alpha * 100 : undefined}
|
||||
on:value={({ detail }) => setColorAlphaPercent(detail)}
|
||||
min={0}
|
||||
max={100}
|
||||
rangeMin={0}
|
||||
rangeMax={100}
|
||||
unit="%"
|
||||
mode="Range"
|
||||
tooltip={`Scale from transparent (0%) to opaque (100%) for the color's alpha channel`}
|
||||
/>
|
||||
<LayoutRow class="leftover-space" />
|
||||
<LayoutRow>
|
||||
{#if allowNone}
|
||||
<button class="preset-color none" on:click={() => setColorPreset("none")} title="Set none" tabindex="0" />
|
||||
<Separator type="Related" />
|
||||
{/if}
|
||||
<button class="preset-color black" on:click={() => setColorPreset("black")} title="Set black" tabindex="0" />
|
||||
<Separator type="Related" />
|
||||
<button class="preset-color white" on:click={() => setColorPreset("white")} title="Set white" tabindex="0" />
|
||||
<Separator type="Related" />
|
||||
<button class="preset-color pure" on:click={setColorPresetSubtile} tabindex="-1">
|
||||
<div data-pure-tile="red" style="--pure-color: #ff0000; --pure-color-gray: #4c4c4c" title="Set red" />
|
||||
<div data-pure-tile="yellow" style="--pure-color: #ffff00; --pure-color-gray: #e3e3e3" title="Set yellow" />
|
||||
<div data-pure-tile="green" style="--pure-color: #00ff00; --pure-color-gray: #969696" title="Set green" />
|
||||
<div data-pure-tile="cyan" style="--pure-color: #00ffff; --pure-color-gray: #b2b2b2" title="Set cyan" />
|
||||
<div data-pure-tile="blue" style="--pure-color: #0000ff; --pure-color-gray: #1c1c1c" title="Set blue" />
|
||||
<div data-pure-tile="magenta" style="--pure-color: #ff00ff; --pure-color-gray: #696969" title="Set magenta" />
|
||||
</button>
|
||||
<Separator type="Related" />
|
||||
<IconButton icon="Eyedropper" size={24} action={activateEyedropperSample} tooltip="Sample a pixel color from the document" />
|
||||
</LayoutRow>
|
||||
</LayoutCol>
|
||||
</LayoutRow>
|
||||
</FloatingMenu>
|
||||
|
||||
<style lang="scss" global>
|
||||
.color-picker {
|
||||
.saturation-value-picker {
|
||||
width: 256px;
|
||||
background-blend-mode: multiply;
|
||||
background: linear-gradient(to bottom, #ffffff, #000000), linear-gradient(to right, #ffffff, var(--hue-color));
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.saturation-value-picker,
|
||||
.hue-picker,
|
||||
.alpha-picker {
|
||||
height: 256px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hue-picker,
|
||||
.alpha-picker {
|
||||
width: 24px;
|
||||
margin-left: 8px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hue-picker {
|
||||
background-blend-mode: screen;
|
||||
background: linear-gradient(to top, #ff0000ff 16.666%, #ff000000 33.333%, #ff000000 66.666%, #ff0000ff 83.333%),
|
||||
linear-gradient(to top, #00ff0000 0%, #00ff00ff 16.666%, #00ff00ff 50%, #00ff0000 66.666%), linear-gradient(to top, #0000ff00 33.333%, #0000ffff 50%, #0000ffff 83.333%, #0000ff00 100%);
|
||||
--selection-pincers-color: var(--hue-color-contrasting);
|
||||
}
|
||||
|
||||
.alpha-picker {
|
||||
background: linear-gradient(to bottom, var(--opaque-color), transparent);
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: -1;
|
||||
position: relative;
|
||||
background: var(--color-transparent-checkered-background);
|
||||
background-size: var(--color-transparent-checkered-background-size);
|
||||
background-position: var(--color-transparent-checkered-background-position);
|
||||
}
|
||||
--selection-pincers-color: var(--new-color-contrasting);
|
||||
}
|
||||
|
||||
.selection-circle {
|
||||
position: absolute;
|
||||
left: 0%;
|
||||
top: 0%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: relative;
|
||||
left: -6px;
|
||||
top: -6px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--opaque-color-contrasting);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
.selection-pincers {
|
||||
position: absolute;
|
||||
top: 0%;
|
||||
width: 100%;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
left: 0;
|
||||
border-style: solid;
|
||||
border-width: 4px 0 4px 4px;
|
||||
border-color: transparent transparent transparent var(--selection-pincers-color);
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: 0;
|
||||
border-style: solid;
|
||||
border-width: 4px 4px 4px 0;
|
||||
border-color: transparent var(--selection-pincers-color) transparent transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.details {
|
||||
margin-left: 16px;
|
||||
width: 208px;
|
||||
gap: 8px;
|
||||
|
||||
> .layout-row {
|
||||
height: 24px;
|
||||
flex: 0 0 auto;
|
||||
|
||||
> .text-label {
|
||||
width: 24px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
&.leftover-space {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.choice-preview {
|
||||
flex: 0 0 auto;
|
||||
width: 208px;
|
||||
height: 32px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--color-0-black);
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
|
||||
.new-color {
|
||||
background: linear-gradient(var(--new-color), var(--new-color)), var(--color-transparent-checkered-background);
|
||||
|
||||
.text-label {
|
||||
text-align: left;
|
||||
margin: 2px 8px;
|
||||
color: var(--new-color-contrasting);
|
||||
}
|
||||
}
|
||||
|
||||
.initial-color {
|
||||
background: linear-gradient(var(--initial-color), var(--initial-color)), var(--color-transparent-checkered-background);
|
||||
|
||||
.text-label {
|
||||
text-align: right;
|
||||
margin: 2px 8px;
|
||||
color: var(--initial-color-contrasting);
|
||||
}
|
||||
}
|
||||
|
||||
.new-color,
|
||||
.initial-color {
|
||||
width: 50%;
|
||||
height: 100%;
|
||||
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-32px);
|
||||
background-image: var(--color-none-image-32px);
|
||||
|
||||
.text-label {
|
||||
// Many stacked white shadows helps to increase the opacity and approximate shadow spread which does not exist for text shadows
|
||||
text-shadow: 0 0 4px white, 0 0 4px white, 0 0 4px white, 0 0 4px white, 0 0 4px white, 0 0 4px white, 0 0 4px white, 0 0 4px white, 0 0 4px white, 0 0 4px white;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.preset-color {
|
||||
border: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border-radius: 2px;
|
||||
width: calc(48px + (48px + 4px) / 2);
|
||||
height: 24px;
|
||||
|
||||
&.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);
|
||||
|
||||
&,
|
||||
& ~ .black,
|
||||
& ~ .white {
|
||||
width: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
&.black {
|
||||
background: black;
|
||||
}
|
||||
|
||||
&.white {
|
||||
background: white;
|
||||
}
|
||||
|
||||
&.pure {
|
||||
width: 24px;
|
||||
font-size: 0;
|
||||
overflow: hidden;
|
||||
transition: background-color 0.5s ease;
|
||||
|
||||
div {
|
||||
display: inline-block;
|
||||
width: calc(100% / 3);
|
||||
height: 50%;
|
||||
// For the least jarring luminance conversion, these colors are derived by placing a black layer with the "desaturate" blend mode over the colors.
|
||||
// We don't use the CSS `filter: grayscale(1);` property because it produces overly dark tones for bright colors with a noticeable jump on hover.
|
||||
background: var(--pure-color-gray);
|
||||
}
|
||||
|
||||
&:hover div,
|
||||
&:focus div {
|
||||
background: var(--pure-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,97 @@
|
||||
<script lang="ts">
|
||||
import { getContext, onMount } from "svelte";
|
||||
|
||||
import FloatingMenu from "@/components/layout/FloatingMenu.svelte";
|
||||
import LayoutCol from "@/components/layout/LayoutCol.svelte";
|
||||
import LayoutRow from "@/components/layout/LayoutRow.svelte";
|
||||
import TextButton from "@/components/widgets/buttons/TextButton.svelte";
|
||||
import IconLabel from "@/components/widgets/labels/IconLabel.svelte";
|
||||
import WidgetLayout from "@/components/widgets/WidgetLayout.svelte";
|
||||
import { type DialogState } from "@/state-providers/dialog";
|
||||
|
||||
const dialog = getContext<DialogState>("dialog");
|
||||
|
||||
let dialogModal: FloatingMenu;
|
||||
|
||||
export function dismiss() {
|
||||
dialog.dismissDialog();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Focus the first button in the popup
|
||||
const emphasizedOrFirstButton = (dialogModal.div().querySelector("[data-emphasized]") || dialogModal.div().querySelector("[data-text-button]") || undefined) as HTMLButtonElement | undefined;
|
||||
emphasizedOrFirstButton?.focus();
|
||||
});
|
||||
</script>
|
||||
|
||||
<FloatingMenu open={true} class="dialog-modal" type="Dialog" direction="Center" bind:this={dialogModal} data-dialog-modal>
|
||||
<LayoutRow>
|
||||
<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>
|
||||
|
||||
<style lang="scss" global>
|
||||
.dialog-modal {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
> .floating-menu-container > .floating-menu-content {
|
||||
pointer-events: auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.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 {
|
||||
margin: -4px 0;
|
||||
|
||||
.details.text-label {
|
||||
-webkit-user-select: text; // Required as of Safari 15.0 (Graphite's minimum version) through the latest release
|
||||
user-select: text;
|
||||
white-space: pre-wrap;
|
||||
max-width: 400px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.panic-buttons-row {
|
||||
height: 32px;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,134 @@
|
||||
<script lang="ts" context="module">
|
||||
// Should be equal to the width and height of the canvas in the CSS
|
||||
const ZOOM_WINDOW_DIMENSIONS_EXPANDED = 110;
|
||||
// Should be equal to the width and height of the `.pixel-outline` div in the CSS, and should be evenly divisible into the number above
|
||||
const UPSCALE_FACTOR = 10;
|
||||
|
||||
export const ZOOM_WINDOW_DIMENSIONS = ZOOM_WINDOW_DIMENSIONS_EXPANDED / UPSCALE_FACTOR;
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
|
||||
import FloatingMenu from "@/components/layout/FloatingMenu.svelte";
|
||||
|
||||
const temporaryCanvas = document.createElement("canvas");
|
||||
|
||||
let zoomPreviewCanvas: HTMLCanvasElement;
|
||||
|
||||
export let imageData: ImageData | undefined = undefined;
|
||||
export let colorChoice: string;
|
||||
export let primaryColor: string;
|
||||
export let secondaryColor: string;
|
||||
export let x: number;
|
||||
export let y: number;
|
||||
|
||||
$: 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>
|
||||
|
||||
<style lang="scss" global>
|
||||
.eyedropper-preview {
|
||||
pointer-events: none;
|
||||
|
||||
.ring {
|
||||
transform: translate(0, -50%) rotate(45deg);
|
||||
position: relative;
|
||||
background: var(--ring-color-choice);
|
||||
padding: 16px;
|
||||
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%;
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.5), 0 0 8px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.canvas-container {
|
||||
transform: rotate(-45deg);
|
||||
|
||||
canvas {
|
||||
display: block;
|
||||
width: 110px;
|
||||
height: 110px;
|
||||
border-radius: 50%;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
&::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>
|
||||
@@ -0,0 +1,363 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
import { type MenuListEntry } from "@/wasm-communication/messages";
|
||||
|
||||
import FloatingMenu, { type MenuDirection } from "@/components/layout/FloatingMenu.svelte";
|
||||
import LayoutCol from "@/components/layout/LayoutCol.svelte";
|
||||
import LayoutRow from "@/components/layout/LayoutRow.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 UserInputLabel from "@/components/widgets/labels/UserInputLabel.svelte";
|
||||
|
||||
let floatingMenu: FloatingMenu;
|
||||
let scroller: LayoutCol;
|
||||
|
||||
// emits: ["update:open", "update:activeEntry", "naturalWidth"],
|
||||
const dispatch = createEventDispatcher<{ open: boolean; activeEntry: MenuListEntry }>();
|
||||
|
||||
export let entries: MenuListEntry[][];
|
||||
export let activeEntry: MenuListEntry | undefined = undefined;
|
||||
export let open: boolean;
|
||||
export let direction: MenuDirection = "Bottom";
|
||||
export let minWidth = 0;
|
||||
export let drawIcon = false;
|
||||
export let interactive = false;
|
||||
export let scrollableY = false;
|
||||
export let virtualScrollingEntryHeight = 0;
|
||||
export let tooltip: string | undefined = undefined;
|
||||
|
||||
let isOpen = open;
|
||||
let highlighted = activeEntry as MenuListEntry | undefined;
|
||||
let virtualScrollingEntriesStart = 0;
|
||||
|
||||
// Called only when `open` is changed from outside this component (with v-model)
|
||||
$: watchOpen(open);
|
||||
$: dispatch("open", isOpen);
|
||||
$: watchEntries(entries, floatingMenu);
|
||||
$: watchDrawIcon(drawIcon, floatingMenu);
|
||||
$: 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) {
|
||||
isOpen = open;
|
||||
highlighted = activeEntry;
|
||||
}
|
||||
|
||||
// TODO: Svelte: fix infinite loop and reenable
|
||||
function watchEntries(_: MenuListEntry[][], floatingMenu: FloatingMenu) {
|
||||
// floatingMenu?.measureAndEmitNaturalWidth();
|
||||
}
|
||||
|
||||
// TODO: Svelte: fix infinite loop and reenable
|
||||
function watchDrawIcon(_: boolean, floatingMenu: FloatingMenu) {
|
||||
// floatingMenu?.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();
|
||||
|
||||
// Emit the clicked entry as the new active entry
|
||||
dispatch("activeEntry", menuListEntry);
|
||||
|
||||
// Close the containing menu
|
||||
if (menuListEntry.ref) menuListEntry.ref.isOpen = false;
|
||||
dispatch("open", false);
|
||||
isOpen = false; // TODO: This is a hack for MenuBarInput submenus, remove it when we get rid of using `ref`
|
||||
}
|
||||
|
||||
function onEntryPointerEnter(menuListEntry: MenuListEntry): void {
|
||||
if (!menuListEntry.children?.length) return;
|
||||
|
||||
if (menuListEntry.ref) menuListEntry.ref.isOpen = true;
|
||||
else dispatch("open", true);
|
||||
}
|
||||
|
||||
function onEntryPointerLeave(menuListEntry: MenuListEntry): void {
|
||||
if (!menuListEntry.children?.length) return;
|
||||
|
||||
if (menuListEntry.ref) menuListEntry.ref.isOpen = false;
|
||||
else dispatch("open", false);
|
||||
}
|
||||
|
||||
function isEntryOpen(menuListEntry: MenuListEntry): boolean {
|
||||
if (!menuListEntry.children?.length) return false;
|
||||
|
||||
return open;
|
||||
}
|
||||
|
||||
/// 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 = isOpen;
|
||||
const flatEntries = 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
|
||||
isOpen = 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);
|
||||
|
||||
// Highlight the menu item in the parent list that corresponds with the open submenu
|
||||
if (e.key !== "Escape" && highlighted) setHighlighted(flatEntries[openChild]);
|
||||
|
||||
// Handle the child closing the entire menu stack
|
||||
if (shouldCloseStack) {
|
||||
isOpen = false;
|
||||
return true;
|
||||
}
|
||||
} else if ((menuOpen || 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 (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;
|
||||
}
|
||||
|
||||
const newEntry = flatEntries[newIndex];
|
||||
setHighlighted(newEntry);
|
||||
} else if (menuOpen && e.key === "Escape") {
|
||||
// Close menu with escape key
|
||||
isOpen = false;
|
||||
|
||||
// Reset active to before open
|
||||
setHighlighted(activeEntry);
|
||||
} else if (menuOpen && highlighted && e.key === "Enter") {
|
||||
// Handle clicking on an option if enter is pressed
|
||||
if (!highlighted.children?.length) onEntryClick(highlighted);
|
||||
else openSubmenu(highlighted);
|
||||
|
||||
// Stop the event from triggering a press on a new dialog
|
||||
e.preventDefault();
|
||||
|
||||
// Enter should close the entire menu stack
|
||||
return true;
|
||||
} else if (menuOpen && highlighted && e.key === "ArrowRight") {
|
||||
// Right arrow opens a submenu
|
||||
openSubmenu(highlighted);
|
||||
} else if (menuOpen && e.key === "ArrowLeft") {
|
||||
// Left arrow closes a submenu
|
||||
if (submenu) isOpen = false;
|
||||
}
|
||||
|
||||
// By default, keep the menu stack open
|
||||
return false;
|
||||
}
|
||||
|
||||
export function setHighlighted(newHighlight: MenuListEntry | undefined) {
|
||||
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);
|
||||
}
|
||||
|
||||
// TODO: Svelte: Re-enable the `export` prefix
|
||||
export function scrollViewTo(distanceDown: number): void {
|
||||
scroller.div().scrollTo(0, distanceDown);
|
||||
}
|
||||
|
||||
export function menuIsOpen(): boolean {
|
||||
return open;
|
||||
}
|
||||
</script>
|
||||
|
||||
<FloatingMenu
|
||||
class="menu-list"
|
||||
open={isOpen}
|
||||
on:open={({ detail }) => (isOpen = detail)}
|
||||
on:naturalWidth
|
||||
type="Dropdown"
|
||||
windowEdgeMargin={0}
|
||||
escapeCloses={false}
|
||||
{direction}
|
||||
{minWidth}
|
||||
scrollableY={scrollableY && virtualScrollingEntryHeight === 0}
|
||||
bind:this={floatingMenu}
|
||||
>
|
||||
<!-- 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 virtualScrollingEntryHeight}
|
||||
<LayoutRow class="scroll-spacer" styles={{ height: `${virtualScrollingStartIndex * virtualScrollingEntryHeight}px` }} />
|
||||
{/if}
|
||||
{#each entries as section, sectionIndex (sectionIndex)}
|
||||
{#if sectionIndex > 0}
|
||||
<Separator type="List" direction="Vertical" />
|
||||
{/if}
|
||||
{#each virtualScrollingEntryHeight ? section.slice(virtualScrollingStartIndex, virtualScrollingEndIndex) : section as entry, entryIndex (entryIndex + (virtualScrollingEntryHeight ? virtualScrollingStartIndex : 0))}
|
||||
<LayoutRow
|
||||
class="row"
|
||||
classes={{ open: isEntryOpen(entry), active: entry.label === highlighted?.label, disabled: Boolean(entry.disabled) }}
|
||||
styles={{ height: virtualScrollingEntryHeight || "20px" }}
|
||||
{tooltip}
|
||||
on:click={() => !entry.disabled && onEntryClick(entry)}
|
||||
on:pointerenter={() => !entry.disabled && onEntryPointerEnter(entry)}
|
||||
on:pointerleave={() => !entry.disabled && onEntryPointerLeave(entry)}
|
||||
>
|
||||
{#if entry.icon && drawIcon}
|
||||
<IconLabel icon={entry.icon} class="entry-icon" />
|
||||
{:else if drawIcon}
|
||||
<div class="no-icon" />
|
||||
{/if}
|
||||
|
||||
{#if entry.font}
|
||||
<link rel="stylesheet" href={entry.font?.toString()} />
|
||||
{/if}
|
||||
|
||||
<TextLabel class="entry-label" styles={{ "font-family": `${!entry.font ? "inherit" : entry.value}` }}>{entry.label}</TextLabel>
|
||||
|
||||
{#if entry.shortcut?.keys.length}
|
||||
<UserInputLabel keysWithLabelsGroups={[entry.shortcut.keys]} requiresLock={entry.shortcutRequiresLock} />
|
||||
{/if}
|
||||
|
||||
{#if entry.children?.length}
|
||||
<div class="submenu-arrow" />
|
||||
{:else}
|
||||
<div class="no-submenu-arrow" />
|
||||
{/if}
|
||||
|
||||
{#if entry.children}
|
||||
<svelte:self on:naturalWidth open={entry.ref?.menuIsOpen() || false} direction="TopRight" entries={entry.children} {minWidth} {drawIcon} {scrollableY} bind:this={entry.ref} />
|
||||
{/if}
|
||||
</LayoutRow>
|
||||
{/each}
|
||||
{/each}
|
||||
{#if virtualScrollingEntryHeight}
|
||||
<LayoutRow class="scroll-spacer" styles={{ height: `${virtualScrollingTotalHeight - virtualScrollingEndIndex * virtualScrollingEntryHeight}px` }} />
|
||||
{/if}
|
||||
</LayoutCol>
|
||||
</FloatingMenu>
|
||||
|
||||
<style lang="scss" global>
|
||||
.menu-list {
|
||||
.floating-menu-container .floating-menu-content {
|
||||
padding: 4px 0;
|
||||
|
||||
.separator div {
|
||||
background: var(--color-4-dimgray);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.entry-icon svg {
|
||||
fill: var(--color-e-nearwhite);
|
||||
}
|
||||
|
||||
.no-icon {
|
||||
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>
|
||||
@@ -0,0 +1,575 @@
|
||||
<script lang="ts" context="module">
|
||||
export type MenuDirection = "Top" | "Bottom" | "Left" | "Right" | "TopLeft" | "TopRight" | "BottomLeft" | "BottomRight" | "Center";
|
||||
export type MenuType = "Popover" | "Dropdown" | "Dialog" | "Cursor";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { afterUpdate, createEventDispatcher, tick } from "svelte";
|
||||
|
||||
import LayoutCol from "@/components/layout/LayoutCol.svelte";
|
||||
|
||||
const POINTER_STRAY_DISTANCE = 100;
|
||||
|
||||
// emits: ["update:open", "naturalWidth"],
|
||||
const dispatch = createEventDispatcher<{ open: boolean; naturalWidth: number }>();
|
||||
|
||||
let className = "";
|
||||
export { className as class };
|
||||
export let classes: Record<string, boolean> = {};
|
||||
let styleName = "";
|
||||
export { styleName as style };
|
||||
export let styles: Record<string, string | number | undefined> = {};
|
||||
export let open: boolean;
|
||||
export let type: MenuType;
|
||||
export let direction: MenuDirection = "Bottom";
|
||||
export let windowEdgeMargin = 6;
|
||||
export let scrollableY = false;
|
||||
export let minWidth = 0;
|
||||
export let escapeCloses = true;
|
||||
export let strayCloses = true;
|
||||
|
||||
let tail: HTMLDivElement;
|
||||
let self: HTMLDivElement;
|
||||
let floatingMenuContainer: HTMLDivElement;
|
||||
let floatingMenuContent: LayoutCol;
|
||||
|
||||
// The resize observer is attached to the floating menu container, which is the zero-height div of the width of the parent element's floating menu spawner.
|
||||
// Since CSS doesn't let us make the floating menu (with `position: fixed`) have a 100% width of this container, we need to use JS to observe its size and
|
||||
// tell the floating menu content to use it as a min-width so the floating menu is at least the width of the parent element's floating menu spawner.
|
||||
// This is the opposite concern of the natural width measurement system, which gets the natural width of the floating menu content in order for the
|
||||
// spawner widget to optionally set its min-size to the floating menu's natural width.
|
||||
let containerResizeObserver = new ResizeObserver((entries: ResizeObserverEntry[]) => {
|
||||
resizeObserverCallback(entries);
|
||||
});
|
||||
let wasOpen = open;
|
||||
let measuringOngoing = false;
|
||||
let measuringOngoingGuard = false;
|
||||
let minWidthParentWidth = 0;
|
||||
let pointerStillDown = false;
|
||||
let workspaceBounds = new DOMRect();
|
||||
let floatingMenuBounds = new DOMRect();
|
||||
let floatingMenuContentBounds = new DOMRect();
|
||||
|
||||
$: minWidthStyleValue = measuringOngoing ? "0" : `${Math.max(minWidth, minWidthParentWidth)}px`;
|
||||
$: displayTail = open && type === "Popover";
|
||||
$: displayContainer = open || measuringOngoing;
|
||||
$: extraClasses = Object.entries(classes)
|
||||
.flatMap((classAndState) => (classAndState[1] ? [classAndState[0]] : []))
|
||||
.join(" ");
|
||||
$: extraStyles = Object.entries(styles)
|
||||
.flatMap((styleAndValue) => (styleAndValue[1] !== undefined ? [`${styleAndValue[0]}: ${styleAndValue[1]};`] : []))
|
||||
.join(" ");
|
||||
|
||||
$: watchOpenChange(open);
|
||||
|
||||
// Called only when `open` is changed from outside this component (with `v-model`)
|
||||
async function watchOpenChange(isOpen: boolean) {
|
||||
// Switching from closed to open
|
||||
if (isOpen && !wasOpen) {
|
||||
// TODO: Close any other floating menus that may already be open, which can happen using tab navigation and Enter/Space Bar to open
|
||||
|
||||
// Close floating menu if pointer strays far enough away
|
||||
window.addEventListener("pointermove", pointerMoveHandler);
|
||||
// Close floating menu if esc is pressed
|
||||
window.addEventListener("keydown", keyDownHandler);
|
||||
// Close floating menu if pointer is outside (but within stray distance)
|
||||
window.addEventListener("pointerdown", pointerDownHandler);
|
||||
// Cancel the subsequent click event to prevent the floating menu from reopening if the floating menu's button is the click event target
|
||||
window.addEventListener("pointerup", pointerUpHandler);
|
||||
|
||||
// Floating menu min-width resize observer
|
||||
|
||||
await tick();
|
||||
|
||||
// Start a new observation of the now-open floating menu
|
||||
containerResizeObserver.disconnect();
|
||||
containerResizeObserver.observe(floatingMenuContainer);
|
||||
}
|
||||
|
||||
// Switching from open to closed
|
||||
if (!isOpen && wasOpen) {
|
||||
// Clean up observation of the now-closed floating menu
|
||||
containerResizeObserver.disconnect();
|
||||
|
||||
window.removeEventListener("pointermove", pointerMoveHandler);
|
||||
window.removeEventListener("keydown", keyDownHandler);
|
||||
window.removeEventListener("pointerdown", pointerDownHandler);
|
||||
// The `pointerup` event is removed in `pointerMoveHandler()` and `pointerDownHandler()`
|
||||
}
|
||||
|
||||
// Now that we're done reading the old state, update it to the current state for next time
|
||||
wasOpen = isOpen;
|
||||
}
|
||||
|
||||
// Gets the client bounds of the elements and apply relevant styles to them
|
||||
// TODO: Use the Vue :style attribute more whilst not causing recursive updates
|
||||
afterUpdate(() => {
|
||||
// Turning measuring on and off both cause the component to change, which causes the `updated()` Vue event to fire extraneous times (hurting performance and sometimes causing an infinite loop)
|
||||
if (measuringOngoingGuard) return;
|
||||
|
||||
positionAndStyleFloatingMenu();
|
||||
});
|
||||
|
||||
function resizeObserverCallback(entries: ResizeObserverEntry[]) {
|
||||
minWidthParentWidth = entries[0].contentRect.width;
|
||||
}
|
||||
|
||||
function positionAndStyleFloatingMenu() {
|
||||
if (type === "Cursor") return;
|
||||
|
||||
const workspace = document.querySelector("[data-workspace]");
|
||||
|
||||
if (!workspace || !self || !floatingMenuContainer || !floatingMenuContent) return;
|
||||
|
||||
workspaceBounds = workspace.getBoundingClientRect();
|
||||
floatingMenuBounds = self.getBoundingClientRect();
|
||||
const floatingMenuContainerBounds = floatingMenuContainer.getBoundingClientRect();
|
||||
floatingMenuContentBounds = floatingMenuContent.div().getBoundingClientRect();
|
||||
|
||||
const inParentFloatingMenu = Boolean(floatingMenuContainer.closest("[data-floating-menu-content]"));
|
||||
|
||||
if (!inParentFloatingMenu) {
|
||||
// Required to correctly position content when scrolled (it has a `position: fixed` to prevent clipping)
|
||||
// We use `.style` on a ref (instead of a `:style` Vue binding) because the binding causes the `updated()` hook to call the function we're in recursively forever
|
||||
const tailOffset = type === "Popover" ? 10 : 0;
|
||||
if (direction === "Bottom") floatingMenuContent.style.top = `${tailOffset + floatingMenuBounds.top}px`;
|
||||
if (direction === "Top") floatingMenuContent.style.bottom = `${tailOffset + floatingMenuBounds.bottom}px`;
|
||||
if (direction === "Right") floatingMenuContent.style.left = `${tailOffset + floatingMenuBounds.left}px`;
|
||||
if (direction === "Left") floatingMenuContent.style.right = `${tailOffset + floatingMenuBounds.right}px`;
|
||||
|
||||
// Required to correctly position tail when scrolled (it has a `position: fixed` to prevent clipping)
|
||||
// We use `.style` on a ref (instead of a `:style` Vue binding) because the binding causes the `updated()` hook to call the function we're in recursively forever
|
||||
if (tail && direction === "Bottom") tail.style.top = `${floatingMenuBounds.top}px`;
|
||||
if (tail && direction === "Top") tail.style.bottom = `${floatingMenuBounds.bottom}px`;
|
||||
if (tail && direction === "Right") tail.style.left = `${floatingMenuBounds.left}px`;
|
||||
if (tail && direction === "Left") tail.style.right = `${floatingMenuBounds.right}px`;
|
||||
}
|
||||
|
||||
type Edge = "Top" | "Bottom" | "Left" | "Right";
|
||||
let zeroedBorderVertical: Edge | undefined;
|
||||
let zeroedBorderHorizontal: Edge | undefined;
|
||||
|
||||
if (direction === "Top" || direction === "Bottom") {
|
||||
zeroedBorderVertical = direction === "Top" ? "Bottom" : "Top";
|
||||
|
||||
// We use `.style` on a ref (instead of a `:style` Vue binding) because the binding causes the `updated()` hook to call the function we're in recursively forever
|
||||
if (floatingMenuContentBounds.left - windowEdgeMargin <= workspaceBounds.left) {
|
||||
floatingMenuContent.style.left = `${windowEdgeMargin}px`;
|
||||
if (workspaceBounds.left + floatingMenuContainerBounds.left === 12) zeroedBorderHorizontal = "Left";
|
||||
}
|
||||
if (floatingMenuContentBounds.right + windowEdgeMargin >= workspaceBounds.right) {
|
||||
floatingMenuContent.style.right = `${windowEdgeMargin}px`;
|
||||
if (workspaceBounds.right - floatingMenuContainerBounds.right === 12) zeroedBorderHorizontal = "Right";
|
||||
}
|
||||
}
|
||||
if (direction === "Left" || direction === "Right") {
|
||||
zeroedBorderHorizontal = direction === "Left" ? "Right" : "Left";
|
||||
|
||||
// We use `.style` on a ref (instead of a `:style` Vue binding) because the binding causes the `updated()` hook to call the function we're in recursively forever
|
||||
if (floatingMenuContentBounds.top - windowEdgeMargin <= workspaceBounds.top) {
|
||||
floatingMenuContent.style.top = `${windowEdgeMargin}px`;
|
||||
if (workspaceBounds.top + floatingMenuContainerBounds.top === 12) zeroedBorderVertical = "Top";
|
||||
}
|
||||
if (floatingMenuContentBounds.bottom + windowEdgeMargin >= workspaceBounds.bottom) {
|
||||
floatingMenuContent.style.bottom = `${windowEdgeMargin}px`;
|
||||
if (workspaceBounds.bottom - floatingMenuContainerBounds.bottom === 12) zeroedBorderVertical = "Bottom";
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the rounded corner from the content where the tail perfectly meets the corner
|
||||
if (type === "Popover" && windowEdgeMargin === 6 && zeroedBorderVertical && zeroedBorderHorizontal) {
|
||||
// We use `.style` on a ref (instead of a `:style` Vue binding) because the binding causes the `updated()` hook to call the function we're in recursively forever
|
||||
switch (`${zeroedBorderVertical}${zeroedBorderHorizontal}`) {
|
||||
case "TopLeft":
|
||||
floatingMenuContent.style.borderTopLeftRadius = "0";
|
||||
break;
|
||||
case "TopRight":
|
||||
floatingMenuContent.style.borderTopRightRadius = "0";
|
||||
break;
|
||||
case "BottomLeft":
|
||||
floatingMenuContent.style.borderBottomLeftRadius = "0";
|
||||
break;
|
||||
case "BottomRight":
|
||||
floatingMenuContent.style.borderBottomRightRadius = "0";
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function div(): HTMLDivElement {
|
||||
return self;
|
||||
}
|
||||
|
||||
// To be called by the parent component. Measures the actual width of the floating menu content element and returns it in a promise.
|
||||
export async function measureAndEmitNaturalWidth(): Promise<void> {
|
||||
// Wait for the changed content which fired the `updated()` Vue event to be put into the DOM
|
||||
await tick();
|
||||
|
||||
// Wait until all fonts have been loaded and rendered so measurements of content involving text are accurate
|
||||
// API is experimental but supported in all browsers - https://developer.mozilla.org/en-US/docs/Web/API/FontFaceSet/ready
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (document as any).fonts.ready;
|
||||
|
||||
// Make the component show itself with 0 min-width so it can be measured, and wait until the values have been updated to the DOM
|
||||
measuringOngoing = true;
|
||||
measuringOngoingGuard = true;
|
||||
await tick();
|
||||
|
||||
// Measure the width of the floating menu content element, if it's currently visible
|
||||
// The result will be `undefined` if the menu is invisible, perhaps because an ancestor component is hidden with a falsy `v-if` condition
|
||||
const naturalWidth: number | undefined = floatingMenuContent?.clientWidth;
|
||||
|
||||
// Turn off measuring mode for the component, which triggers another call to the `updated()` Vue event, so we can turn off the protection after that has happened
|
||||
measuringOngoing = false;
|
||||
await tick();
|
||||
measuringOngoingGuard = false;
|
||||
|
||||
// Emit the measured natural width to the parent
|
||||
if (naturalWidth !== undefined && naturalWidth >= 0) {
|
||||
dispatch("naturalWidth", naturalWidth);
|
||||
}
|
||||
}
|
||||
|
||||
function pointerMoveHandler(e: PointerEvent) {
|
||||
// This element and the element being hovered over
|
||||
const target = e.target as HTMLElement | undefined;
|
||||
|
||||
// Get the spawner element (that which is clicked to spawn this floating menu)
|
||||
// Assumes the spawner is a sibling of this FloatingMenu component
|
||||
const ownSpawner: HTMLElement | undefined = self?.parentElement?.querySelector(":scope > [data-floating-menu-spawner]") || undefined;
|
||||
// Get the spawner element containing whatever element the user is hovering over now, if there is one
|
||||
const targetSpawner: HTMLElement | undefined = target?.closest("[data-floating-menu-spawner]") || undefined;
|
||||
|
||||
// HOVER TRANSFER
|
||||
// Transfer from this open floating menu to a sibling floating menu if the pointer hovers to a valid neighboring floating menu spawner
|
||||
hoverTransfer(self, ownSpawner, targetSpawner);
|
||||
|
||||
// POINTER STRAY
|
||||
// Close the floating menu if the pointer has strayed far enough from its bounds (and it's not hovering over its own spawner)
|
||||
const notHoveringOverOwnSpawner = ownSpawner !== targetSpawner;
|
||||
if (strayCloses && notHoveringOverOwnSpawner && isPointerEventOutsideFloatingMenu(e, POINTER_STRAY_DISTANCE)) {
|
||||
// TODO: Extend this rectangle bounds check to all submenu bounds up the DOM tree since currently submenus disappear
|
||||
// TODO: with zero stray distance if the cursor is further than the stray distance from only the top-level menu
|
||||
dispatch("open", false);
|
||||
}
|
||||
|
||||
// Clean up any messes from lost pointerup events
|
||||
const eventIncludesLmb = Boolean(e.buttons & 1);
|
||||
if (!open && !eventIncludesLmb) {
|
||||
pointerStillDown = false;
|
||||
window.removeEventListener("pointerup", pointerUpHandler);
|
||||
}
|
||||
}
|
||||
|
||||
function hoverTransfer(self: HTMLDivElement | undefined, ownSpawner: HTMLElement | undefined, targetSpawner: HTMLElement | undefined): void {
|
||||
// Algorithm pseudo-code to detect and transfer to hover-transferrable floating menu spawners
|
||||
// Accompanying diagram: <https://files.keavon.com/-/SpringgreenKnownXantus/capture.png>
|
||||
//
|
||||
// Check our own parent for descendant spawners
|
||||
// Filter out ourself and our children
|
||||
// Filter out all with a different distance than our own distance from the currently-being-checked parent
|
||||
// How many left?
|
||||
// None -> go up a level and repeat
|
||||
// Some -> is one of them the target?
|
||||
// Yes -> click it and terminate
|
||||
// No -> do nothing and terminate
|
||||
|
||||
// Helper function that gets used below
|
||||
const getDepthFromAncestor = (item: Element, ancestor: Element): number | undefined => {
|
||||
let depth = 1;
|
||||
|
||||
let parent = item.parentElement || undefined;
|
||||
while (parent) {
|
||||
if (parent === ancestor) return depth;
|
||||
|
||||
parent = parent.parentElement || undefined;
|
||||
depth += 1;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// A list of all the descendant spawners: the spawner for this floating menu plus any spawners belonging to widgets inside this floating menu
|
||||
const ownDescendantMenuSpawners = Array.from(self?.parentElement?.querySelectorAll("[data-floating-menu-spawner]") || []);
|
||||
|
||||
// Start with the parent of the spawner for this floating menu and keep widening the search for any other valid spawners that are hover-transferrable
|
||||
let currentAncestor = (targetSpawner && ownSpawner?.parentElement) || undefined;
|
||||
while (currentAncestor) {
|
||||
const ownSpawnerDepthFromCurrentAncestor = ownSpawner && getDepthFromAncestor(ownSpawner, currentAncestor);
|
||||
const currentAncestor2 = currentAncestor; // This duplicate variable avoids an ESLint warning
|
||||
|
||||
// Get the list of descendant spawners and filter out invalid possibilities for spawners that are hover-transferrable
|
||||
const listOfDescendantSpawners = Array.from(currentAncestor?.querySelectorAll("[data-floating-menu-spawner]") || []);
|
||||
const filteredListOfDescendantSpawners = listOfDescendantSpawners.filter((item: Element): boolean => {
|
||||
// Filter away ourself and our descendants
|
||||
const notOurself = !ownDescendantMenuSpawners.includes(item);
|
||||
// And filter away unequal depths from the current ancestor
|
||||
const notUnequalDepths = notOurself && getDepthFromAncestor(item, currentAncestor2) === ownSpawnerDepthFromCurrentAncestor;
|
||||
// And filter away elements that explicitly disable hover transfer
|
||||
return notUnequalDepths && !(item as HTMLElement).getAttribute?.("data-floating-menu-spawner")?.includes("no-hover-transfer");
|
||||
});
|
||||
|
||||
// If none were found, widen the search by a level and keep trying (or stop looping if the root was reached)
|
||||
if (filteredListOfDescendantSpawners.length === 0) {
|
||||
currentAncestor = currentAncestor?.parentElement || undefined;
|
||||
}
|
||||
// Stop after the first non-empty set was found
|
||||
else {
|
||||
const foundTarget = filteredListOfDescendantSpawners.find((item: Element): boolean => item === targetSpawner);
|
||||
// If the currently hovered spawner is one of the found valid hover-transferrable spawners, swap to it by clicking on it
|
||||
if (foundTarget) {
|
||||
dispatch("open", false);
|
||||
(foundTarget as HTMLElement).click();
|
||||
}
|
||||
|
||||
// In either case, we are done searching
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function keyDownHandler(e: KeyboardEvent) {
|
||||
if (escapeCloses && e.key.toLowerCase() === "escape") {
|
||||
dispatch("open", false);
|
||||
}
|
||||
}
|
||||
|
||||
function pointerDownHandler(e: PointerEvent) {
|
||||
// Close the floating menu if the pointer clicked outside the floating menu (but within stray distance)
|
||||
if (isPointerEventOutsideFloatingMenu(e)) {
|
||||
dispatch("open", false);
|
||||
|
||||
// Track if the left pointer button is now down so its later click event can be canceled
|
||||
const eventIsForLmb = e.button === 0;
|
||||
if (eventIsForLmb) pointerStillDown = true;
|
||||
}
|
||||
}
|
||||
|
||||
function pointerUpHandler(e: PointerEvent) {
|
||||
const eventIsForLmb = e.button === 0;
|
||||
if (pointerStillDown && eventIsForLmb) {
|
||||
// Clean up self
|
||||
pointerStillDown = false;
|
||||
window.removeEventListener("pointerup", pointerUpHandler);
|
||||
// Prevent the click event from firing, which would normally occur right after this pointerup event
|
||||
window.addEventListener("click", clickHandlerCapture, true);
|
||||
}
|
||||
}
|
||||
|
||||
function clickHandlerCapture(e: MouseEvent) {
|
||||
// Stop the click event from reopening this floating menu if the click event targets the floating menu's button
|
||||
e.stopPropagation();
|
||||
// Clean up self
|
||||
window.removeEventListener("click", clickHandlerCapture, true);
|
||||
}
|
||||
|
||||
function isPointerEventOutsideFloatingMenu(e: PointerEvent, extraDistanceAllowed = 0): boolean {
|
||||
// Consider all child menus as well as the top-level one
|
||||
const allContainedFloatingMenus = [...self.querySelectorAll("[data-floating-menu-content]")];
|
||||
|
||||
return !allContainedFloatingMenus.find((element) => !isPointerEventOutsideMenuElement(e, element, extraDistanceAllowed));
|
||||
}
|
||||
|
||||
function isPointerEventOutsideMenuElement(e: PointerEvent, element: Element, extraDistanceAllowed = 0): boolean {
|
||||
const floatingMenuBounds = element.getBoundingClientRect();
|
||||
|
||||
if (floatingMenuBounds.left - e.clientX >= extraDistanceAllowed) return true;
|
||||
if (e.clientX - floatingMenuBounds.right >= extraDistanceAllowed) return true;
|
||||
if (floatingMenuBounds.top - e.clientY >= extraDistanceAllowed) return true;
|
||||
if (e.clientY - floatingMenuBounds.bottom >= extraDistanceAllowed) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={`floating-menu ${direction.toLowerCase()} ${type.toLowerCase()} ${className} ${extraClasses}`.trim()}
|
||||
style={`${styleName} ${extraStyles}`.trim() || undefined}
|
||||
bind:this={self}
|
||||
{...$$restProps}
|
||||
>
|
||||
{#if displayTail}
|
||||
<div class="tail" bind:this={tail} />
|
||||
{/if}
|
||||
{#if displayContainer}
|
||||
<div class="floating-menu-container" bind:this={floatingMenuContainer}>
|
||||
<LayoutCol class="floating-menu-content" styles={{ "min-width": minWidthStyleValue }} {scrollableY} bind:this={floatingMenuContent} data-floating-menu-content>
|
||||
<slot />
|
||||
</LayoutCol>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss" global>
|
||||
.floating-menu {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
display: flex;
|
||||
// Floating menus begin at a z-index of 1000
|
||||
z-index: 1000;
|
||||
--floating-menu-content-offset: 0;
|
||||
--floating-menu-content-border-radius: 4px;
|
||||
|
||||
&.bottom {
|
||||
--floating-menu-content-border-radius: 0 0 4px 4px;
|
||||
}
|
||||
|
||||
.tail {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
// Put the tail above the floating menu's shadow
|
||||
z-index: 10;
|
||||
// Draw over the application without being clipped by the containing panel's `overflow: hidden`
|
||||
position: fixed;
|
||||
}
|
||||
|
||||
.floating-menu-container {
|
||||
display: flex;
|
||||
|
||||
.floating-menu-content {
|
||||
background: rgba(var(--color-2-mildblack-rgb), 0.95);
|
||||
box-shadow: rgba(var(--color-0-black-rgb), 50%) 0 2px 4px;
|
||||
border-radius: var(--floating-menu-content-border-radius);
|
||||
color: var(--color-e-nearwhite);
|
||||
font-size: inherit;
|
||||
padding: 8px;
|
||||
z-index: 0;
|
||||
// Draw over the application without being clipped by the containing panel's `overflow: hidden`
|
||||
position: fixed;
|
||||
}
|
||||
}
|
||||
|
||||
&.dropdown {
|
||||
&.top {
|
||||
width: 100%;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
&.bottom {
|
||||
width: 100%;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
&.left {
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
&.right {
|
||||
height: 100%;
|
||||
top: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
&.topleft {
|
||||
top: 0;
|
||||
left: 0;
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
&.topright {
|
||||
top: 0;
|
||||
right: 0;
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
&.topleft {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
margin-bottom: -4px;
|
||||
}
|
||||
|
||||
&.topright {
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
margin-bottom: -4px;
|
||||
}
|
||||
}
|
||||
|
||||
&.top.dropdown .floating-menu-container,
|
||||
&.bottom.dropdown .floating-menu-container {
|
||||
justify-content: left;
|
||||
}
|
||||
|
||||
&.popover {
|
||||
--floating-menu-content-offset: 10px;
|
||||
--floating-menu-content-border-radius: 4px;
|
||||
}
|
||||
|
||||
&.cursor .floating-menu-container .floating-menu-content {
|
||||
background: none;
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&.center {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
> .floating-menu-container > .floating-menu-content {
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
|
||||
&.top,
|
||||
&.bottom {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&.top .tail {
|
||||
border-width: 8px 6px 0 6px;
|
||||
border-color: rgba(var(--color-2-mildblack-rgb), 0.95) transparent transparent transparent;
|
||||
margin-left: -6px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
&.bottom .tail {
|
||||
border-width: 0 6px 8px 6px;
|
||||
border-color: transparent transparent rgba(var(--color-2-mildblack-rgb), 0.95) transparent;
|
||||
margin-left: -6px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
&.left .tail {
|
||||
border-width: 6px 0 6px 8px;
|
||||
border-color: transparent transparent transparent rgba(var(--color-2-mildblack-rgb), 0.95);
|
||||
margin-top: -6px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
&.right .tail {
|
||||
border-width: 6px 8px 6px 0;
|
||||
border-color: transparent rgba(var(--color-2-mildblack-rgb), 0.95) transparent transparent;
|
||||
margin-top: -6px;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
&.top .floating-menu-container {
|
||||
justify-content: center;
|
||||
margin-bottom: var(--floating-menu-content-offset);
|
||||
}
|
||||
|
||||
&.bottom .floating-menu-container {
|
||||
justify-content: center;
|
||||
margin-top: var(--floating-menu-content-offset);
|
||||
}
|
||||
|
||||
&.left .floating-menu-container {
|
||||
align-items: center;
|
||||
margin-right: var(--floating-menu-content-offset);
|
||||
}
|
||||
|
||||
&.right .floating-menu-container {
|
||||
align-items: center;
|
||||
margin-left: var(--floating-menu-content-offset);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
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;
|
||||
|
||||
let divElement: HTMLDivElement;
|
||||
|
||||
$: extraClasses = Object.entries(classes)
|
||||
.flatMap((classAndState) => (classAndState[1] ? [classAndState[0]] : []))
|
||||
.join(" ");
|
||||
$: extraStyles = Object.entries(styles)
|
||||
.flatMap((styleAndValue) => (styleAndValue[1] !== undefined ? [`${styleAndValue[0]}: ${styleAndValue[1]};`] : []))
|
||||
.join(" ");
|
||||
|
||||
export function div(): HTMLDivElement {
|
||||
return divElement;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={`layout-col ${className} ${extraClasses}`.trim()}
|
||||
class:scrollable-x={scrollableX}
|
||||
class:scrollable-y={scrollableY}
|
||||
style={`${styleName} ${extraStyles}`.trim() || undefined}
|
||||
title={tooltip}
|
||||
bind:this={divElement}
|
||||
on:click
|
||||
on:dblclick
|
||||
on:pointerdown
|
||||
on:pointermove
|
||||
on:pointerup
|
||||
on:dragleave
|
||||
on:dragover
|
||||
on:dragstart
|
||||
on:dragend
|
||||
on:drop
|
||||
on:wheel
|
||||
on:scroll
|
||||
on:focus
|
||||
on:blur
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<style lang="scss" global>
|
||||
.layout-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
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;
|
||||
|
||||
let divElement: HTMLDivElement;
|
||||
|
||||
$: extraClasses = Object.entries(classes)
|
||||
.flatMap((classAndState) => (classAndState[1] ? [classAndState[0]] : []))
|
||||
.join(" ");
|
||||
$: extraStyles = Object.entries(styles)
|
||||
.flatMap((styleAndValue) => (styleAndValue[1] !== undefined ? [`${styleAndValue[0]}: ${styleAndValue[1]};`] : []))
|
||||
.join(" ");
|
||||
|
||||
export function div(): HTMLDivElement {
|
||||
return divElement;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={`layout-row ${className} ${extraClasses}`.trim()}
|
||||
class:scrollable-x={scrollableX}
|
||||
class:scrollable-y={scrollableY}
|
||||
style={`${styleName} ${extraStyles}`.trim() || undefined}
|
||||
title={tooltip}
|
||||
bind:this={divElement}
|
||||
on:click
|
||||
on:dblclick
|
||||
on:pointerdown
|
||||
on:pointermove
|
||||
on:pointerup
|
||||
on:dragleave
|
||||
on:dragover
|
||||
on:dragstart
|
||||
on:dragend
|
||||
on:drop
|
||||
on:wheel
|
||||
on:scroll
|
||||
on:focus
|
||||
on:blur
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<style lang="scss" global>
|
||||
.layout-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-grow: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,607 @@
|
||||
<script lang="ts">
|
||||
import { getContext, onMount, tick } from "svelte";
|
||||
|
||||
import { textInputCleanup } from "@/utility-functions/keyboard-entry";
|
||||
import { rasterizeSVGCanvas } from "@/utility-functions/rasterization";
|
||||
import {
|
||||
type MouseCursorIcon,
|
||||
type XY,
|
||||
DisplayEditableTextbox,
|
||||
DisplayRemoveEditableTextbox,
|
||||
TriggerTextCommit,
|
||||
TriggerViewportResize,
|
||||
UpdateDocumentArtboards,
|
||||
UpdateDocumentArtwork,
|
||||
UpdateDocumentOverlays,
|
||||
UpdateDocumentRulers,
|
||||
UpdateDocumentScrollbars,
|
||||
UpdateEyedropperSamplingState,
|
||||
UpdateMouseCursor,
|
||||
} from "@/wasm-communication/messages";
|
||||
|
||||
import EyedropperPreview, { ZOOM_WINDOW_DIMENSIONS } from "@/components/floating-menus/EyedropperPreview.svelte";
|
||||
import LayoutCol from "@/components/layout/LayoutCol.svelte";
|
||||
import LayoutRow from "@/components/layout/LayoutRow.svelte";
|
||||
import CanvasRuler from "@/components/widgets/metrics/CanvasRuler.svelte";
|
||||
import PersistentScrollbar from "@/components/widgets/metrics/PersistentScrollbar.svelte";
|
||||
import WidgetLayout from "@/components/widgets/WidgetLayout.svelte";
|
||||
import { type Editor } from "@/wasm-communication/editor";
|
||||
import { type DocumentState } from "@/state-providers/document";
|
||||
|
||||
let self: LayoutCol;
|
||||
let rulerHorizontal: CanvasRuler;
|
||||
let rulerVertical: CanvasRuler;
|
||||
let canvasDiv: HTMLDivElement;
|
||||
|
||||
const editor = getContext<Editor>("editor");
|
||||
const document = getContext<DocumentState>("document");
|
||||
|
||||
// Interactive text editing
|
||||
let textInput: undefined | HTMLDivElement = undefined;
|
||||
|
||||
// CSS properties
|
||||
let canvasSvgWidth: number | undefined = undefined;
|
||||
let canvasSvgHeight: number | undefined = undefined;
|
||||
let canvasCursor = "default";
|
||||
|
||||
// Scrollbars
|
||||
let scrollbarPos: XY = { x: 0.5, y: 0.5 };
|
||||
let scrollbarSize: XY = { x: 0.5, y: 0.5 };
|
||||
let scrollbarMultiplier: XY = { x: 0, y: 0 };
|
||||
|
||||
// Rulers
|
||||
let rulerOrigin: XY = { x: 0, y: 0 };
|
||||
let rulerSpacing: number = 100;
|
||||
let rulerInterval: number = 100;
|
||||
|
||||
// Rendered SVG viewport data
|
||||
let artworkSvg: string = "";
|
||||
let artboardSvg: string = "";
|
||||
let overlaysSvg: string = "";
|
||||
|
||||
// Rasterized SVG viewport data, or none if it's not up-to-date
|
||||
let rasterizedCanvas: HTMLCanvasElement | undefined = undefined;
|
||||
let rasterizedContext: CanvasRenderingContext2D | undefined = undefined;
|
||||
|
||||
// Cursor position for cursor floating menus like the Eyedropper tool zoom
|
||||
let cursorLeft = 0;
|
||||
let cursorTop = 0;
|
||||
let cursorEyedropper = false;
|
||||
let cursorEyedropperPreviewImageData: ImageData | undefined = undefined;
|
||||
let cursorEyedropperPreviewColorChoice = "";
|
||||
let cursorEyedropperPreviewColorPrimary = "";
|
||||
let cursorEyedropperPreviewColorSecondary = "";
|
||||
|
||||
$: canvasWidthCSS = canvasDimensionCSS(canvasSvgWidth);
|
||||
$: canvasHeightCSS = canvasDimensionCSS(canvasSvgHeight);
|
||||
|
||||
function pasteFile(e: DragEvent) {
|
||||
const { dataTransfer } = e;
|
||||
if (!dataTransfer) return;
|
||||
e.preventDefault();
|
||||
|
||||
Array.from(dataTransfer.items).forEach(async (item) => {
|
||||
const file = item.getAsFile();
|
||||
if (file?.type.startsWith("image")) {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const u8Array = new Uint8Array(buffer);
|
||||
|
||||
editor.instance.pasteImage(file.type, u8Array, e.clientX, e.clientY);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function translateCanvasX(newValue: number) {
|
||||
const delta = newValue - scrollbarPos.x;
|
||||
scrollbarPos.x = newValue;
|
||||
editor.instance.translateCanvas(-delta * scrollbarMultiplier.x, 0);
|
||||
}
|
||||
|
||||
function translateCanvasY(newValue: number) {
|
||||
const delta = newValue - scrollbarPos.y;
|
||||
scrollbarPos.y = newValue;
|
||||
editor.instance.translateCanvas(0, -delta * scrollbarMultiplier.y);
|
||||
}
|
||||
|
||||
function pageX(delta: number) {
|
||||
const move = delta < 0 ? 1 : -1;
|
||||
editor.instance.translateCanvasByFraction(move, 0);
|
||||
}
|
||||
|
||||
function pageY(delta: number) {
|
||||
const move = delta < 0 ? 1 : -1;
|
||||
editor.instance.translateCanvasByFraction(0, move);
|
||||
}
|
||||
|
||||
function canvasPointerDown(e: PointerEvent) {
|
||||
const onEditbox = e.target instanceof HTMLDivElement && e.target.contentEditable;
|
||||
|
||||
if (!onEditbox) canvasDiv?.setPointerCapture(e.pointerId);
|
||||
}
|
||||
|
||||
// Update rendered SVGs
|
||||
export async function updateDocumentArtwork(svg: string) {
|
||||
artworkSvg = svg;
|
||||
rasterizedCanvas = undefined;
|
||||
|
||||
await tick();
|
||||
|
||||
if (textInput) {
|
||||
const foreignObject = canvasDiv.getElementsByTagName("foreignObject")[0] as SVGForeignObjectElement;
|
||||
if (foreignObject.children.length > 0) return;
|
||||
|
||||
const addedInput = foreignObject.appendChild(textInput);
|
||||
window.dispatchEvent(new CustomEvent("modifyinputfield", { detail: addedInput }));
|
||||
|
||||
await tick();
|
||||
|
||||
// Necessary to select contenteditable: https://stackoverflow.com/questions/6139107/programmatically-select-text-in-a-contenteditable-html-element/6150060#6150060
|
||||
|
||||
const range = window.document.createRange();
|
||||
range.selectNodeContents(addedInput);
|
||||
|
||||
const selection = window.getSelection();
|
||||
if (selection) {
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
|
||||
addedInput.focus();
|
||||
addedInput.click();
|
||||
}
|
||||
}
|
||||
|
||||
export function updateDocumentOverlays(svg: string) {
|
||||
overlaysSvg = svg;
|
||||
}
|
||||
|
||||
export function updateDocumentArtboards(svg: string) {
|
||||
artboardSvg = svg;
|
||||
rasterizedCanvas = undefined;
|
||||
}
|
||||
|
||||
export async function updateEyedropperSamplingState(mousePosition: XY | undefined, colorPrimary: string, colorSecondary: string): Promise<[number, number, number] | undefined> {
|
||||
if (mousePosition === undefined) {
|
||||
cursorEyedropper = false;
|
||||
return undefined;
|
||||
}
|
||||
cursorEyedropper = true;
|
||||
|
||||
if (canvasSvgWidth === undefined || canvasSvgHeight === undefined) return undefined;
|
||||
|
||||
cursorLeft = mousePosition.x;
|
||||
cursorTop = mousePosition.y;
|
||||
|
||||
// This works nearly perfectly, but sometimes at odd DPI scale factors like 1.25, the anti-aliasing color can yield slightly incorrect colors (potential room for future improvement)
|
||||
const dpiFactor = window.devicePixelRatio;
|
||||
const [width, height] = [canvasSvgWidth, canvasSvgHeight];
|
||||
|
||||
const outsideArtboardsColor = getComputedStyle(window.document.documentElement).getPropertyValue("--color-2-mildblack");
|
||||
const outsideArtboards = `<rect x="0" y="0" width="100%" height="100%" fill="${outsideArtboardsColor}" />`;
|
||||
const artboards = artboardSvg;
|
||||
const artwork = artworkSvg;
|
||||
const svg = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">${outsideArtboards}${artboards}${artwork}</svg>
|
||||
`.trim();
|
||||
|
||||
if (!rasterizedCanvas) {
|
||||
rasterizedCanvas = await rasterizeSVGCanvas(svg, width * dpiFactor, height * dpiFactor, "image/png");
|
||||
rasterizedContext = rasterizedCanvas.getContext("2d") || undefined;
|
||||
}
|
||||
if (!rasterizedContext) return undefined;
|
||||
|
||||
const rgbToHex = (r: number, g: number, b: number): string => `#${[r, g, b].map((x) => x.toString(16).padStart(2, "0")).join("")}`;
|
||||
|
||||
const pixel = rasterizedContext.getImageData(mousePosition.x * dpiFactor, mousePosition.y * dpiFactor, 1, 1).data;
|
||||
const hex = rgbToHex(pixel[0], pixel[1], pixel[2]);
|
||||
const rgb: [number, number, number] = [pixel[0] / 255, pixel[1] / 255, pixel[2] / 255];
|
||||
|
||||
cursorEyedropperPreviewColorChoice = hex;
|
||||
cursorEyedropperPreviewColorPrimary = colorPrimary;
|
||||
cursorEyedropperPreviewColorSecondary = colorSecondary;
|
||||
|
||||
const previewRegion = rasterizedContext.getImageData(
|
||||
mousePosition.x * dpiFactor - (ZOOM_WINDOW_DIMENSIONS - 1) / 2,
|
||||
mousePosition.y * dpiFactor - (ZOOM_WINDOW_DIMENSIONS - 1) / 2,
|
||||
ZOOM_WINDOW_DIMENSIONS,
|
||||
ZOOM_WINDOW_DIMENSIONS
|
||||
);
|
||||
cursorEyedropperPreviewImageData = previewRegion;
|
||||
|
||||
return rgb;
|
||||
}
|
||||
|
||||
// Update scrollbars and rulers
|
||||
export function updateDocumentScrollbars(position: XY, size: XY, multiplier: XY) {
|
||||
scrollbarPos = position;
|
||||
scrollbarSize = size;
|
||||
scrollbarMultiplier = multiplier;
|
||||
}
|
||||
|
||||
export function updateDocumentRulers(origin: XY, spacing: number, interval: number) {
|
||||
rulerOrigin = origin;
|
||||
rulerSpacing = spacing;
|
||||
rulerInterval = interval;
|
||||
}
|
||||
|
||||
// Update mouse cursor icon
|
||||
export function updateMouseCursor(cursor: MouseCursorIcon) {
|
||||
let cursorString: string = cursor;
|
||||
|
||||
// This isn't very clean but it's good enough for now until we need more icons, then we can build something more robust (consider blob URLs)
|
||||
if (cursor === "custom-rotate") {
|
||||
const svg = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" width="20" height="20">
|
||||
<path transform="translate(2 2)" fill="black" stroke="black" stroke-width="2px" d="
|
||||
M8,15.2C4,15.2,0.8,12,0.8,8C0.8,4,4,0.8,8,0.8c2,0,3.9,0.8,5.3,2.3l-1,1C11.2,2.9,9.6,2.2,8,2.2C4.8,2.2,2.2,4.8,2.2,8s2.6,5.8,5.8,5.8s5.8-2.6,5.8-5.8h1.4C15.2,12,12,15.2,8,15.2z
|
||||
" />
|
||||
<polygon transform="translate(2 2)" fill="black" stroke="black" stroke-width="2px" points="12.6,0 15.5,5 9.7,5" />
|
||||
<path transform="translate(2 2)" fill="white" d="
|
||||
M8,15.2C4,15.2,0.8,12,0.8,8C0.8,4,4,0.8,8,0.8c2,0,3.9,0.8,5.3,2.3l-1,1C11.2,2.9,9.6,2.2,8,2.2C4.8,2.2,2.2,4.8,2.2,8s2.6,5.8,5.8,5.8s5.8-2.6,5.8-5.8h1.4C15.2,12,12,15.2,8,15.2z
|
||||
" />
|
||||
<polygon transform="translate(2 2)" fill="white" points="12.6,0 15.5,5 9.7,5" />
|
||||
</svg>
|
||||
`
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.join("");
|
||||
|
||||
cursorString = `url('data:image/svg+xml;utf8,${svg}') 8 8, alias`;
|
||||
}
|
||||
|
||||
canvasCursor = cursorString;
|
||||
}
|
||||
|
||||
// Text entry
|
||||
export function triggerTextCommit() {
|
||||
if (!textInput) return;
|
||||
const textCleaned = textInputCleanup(textInput.innerText);
|
||||
editor.instance.onChangeText(textCleaned);
|
||||
}
|
||||
|
||||
export function displayEditableTextbox(displayEditableTextbox: DisplayEditableTextbox) {
|
||||
textInput = window.document.createElement("div") as HTMLDivElement;
|
||||
|
||||
if (displayEditableTextbox.text === "") textInput.textContent = "";
|
||||
else textInput.textContent = `${displayEditableTextbox.text}\n`;
|
||||
|
||||
textInput.contentEditable = "true";
|
||||
textInput.style.width = displayEditableTextbox.lineWidth ? `${displayEditableTextbox.lineWidth}px` : "max-content";
|
||||
textInput.style.height = "auto";
|
||||
textInput.style.fontSize = `${displayEditableTextbox.fontSize}px`;
|
||||
textInput.style.color = displayEditableTextbox.color.toHexOptionalAlpha() || "transparent";
|
||||
|
||||
textInput.oninput = (): void => {
|
||||
if (!textInput) return;
|
||||
editor.instance.updateBounds(textInputCleanup(textInput.innerText));
|
||||
};
|
||||
}
|
||||
|
||||
export function displayRemoveEditableTextbox() {
|
||||
textInput = undefined;
|
||||
window.dispatchEvent(new CustomEvent("modifyinputfield", { detail: undefined }));
|
||||
}
|
||||
|
||||
// Resize elements to render the new viewport size
|
||||
export function viewportResize() {
|
||||
// Resize the canvas
|
||||
canvasSvgWidth = Math.ceil(parseFloat(getComputedStyle(canvasDiv).width));
|
||||
canvasSvgHeight = Math.ceil(parseFloat(getComputedStyle(canvasDiv).height));
|
||||
|
||||
// Resize the rulers
|
||||
rulerHorizontal?.resize();
|
||||
rulerVertical?.resize();
|
||||
}
|
||||
|
||||
function canvasDimensionCSS(dimension: number | undefined): string {
|
||||
// Temporary placeholder until the first actual value is populated
|
||||
// This at least gets close to the correct value but an actual number is required to prevent CSS from causing non-integer sizing making the SVG render with anti-aliasing
|
||||
if (dimension === undefined) return "100%";
|
||||
|
||||
// Dimension is rounded up to the nearest even number because resizing is centered, and dividing an odd number by 2 for centering causes antialiasing
|
||||
return `${dimension % 2 === 1 ? dimension + 1 : dimension}px`;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Update rendered SVGs
|
||||
editor.subscriptions.subscribeJsMessage(UpdateDocumentArtwork, async (data) => {
|
||||
await tick();
|
||||
|
||||
updateDocumentArtwork(data.svg);
|
||||
});
|
||||
editor.subscriptions.subscribeJsMessage(UpdateDocumentOverlays, async (data) => {
|
||||
await tick();
|
||||
|
||||
updateDocumentOverlays(data.svg);
|
||||
});
|
||||
editor.subscriptions.subscribeJsMessage(UpdateDocumentArtboards, async (data) => {
|
||||
await tick();
|
||||
|
||||
updateDocumentArtboards(data.svg);
|
||||
});
|
||||
editor.subscriptions.subscribeJsMessage(UpdateEyedropperSamplingState, async (data) => {
|
||||
await tick();
|
||||
|
||||
const { mousePosition, primaryColor, secondaryColor, setColorChoice } = data;
|
||||
const rgb = await updateEyedropperSamplingState(mousePosition, primaryColor, secondaryColor);
|
||||
|
||||
if (setColorChoice && rgb) {
|
||||
if (setColorChoice === "Primary") editor.instance.updatePrimaryColor(...rgb, 1);
|
||||
if (setColorChoice === "Secondary") editor.instance.updateSecondaryColor(...rgb, 1);
|
||||
}
|
||||
});
|
||||
|
||||
// Update scrollbars and rulers
|
||||
editor.subscriptions.subscribeJsMessage(UpdateDocumentScrollbars, async (data) => {
|
||||
await tick();
|
||||
|
||||
const { position, size, multiplier } = data;
|
||||
updateDocumentScrollbars(position, size, multiplier);
|
||||
});
|
||||
editor.subscriptions.subscribeJsMessage(UpdateDocumentRulers, async (data) => {
|
||||
await tick();
|
||||
|
||||
const { origin, spacing, interval } = data;
|
||||
updateDocumentRulers(origin, spacing, interval);
|
||||
});
|
||||
|
||||
// Update mouse cursor icon
|
||||
editor.subscriptions.subscribeJsMessage(UpdateMouseCursor, async (data) => {
|
||||
await tick();
|
||||
|
||||
const { cursor } = data;
|
||||
updateMouseCursor(cursor);
|
||||
});
|
||||
|
||||
// Text entry
|
||||
editor.subscriptions.subscribeJsMessage(TriggerTextCommit, async () => {
|
||||
await tick();
|
||||
|
||||
triggerTextCommit();
|
||||
});
|
||||
editor.subscriptions.subscribeJsMessage(DisplayEditableTextbox, async (data) => {
|
||||
await tick();
|
||||
|
||||
displayEditableTextbox(data);
|
||||
});
|
||||
editor.subscriptions.subscribeJsMessage(DisplayRemoveEditableTextbox, async () => {
|
||||
await tick();
|
||||
|
||||
displayRemoveEditableTextbox();
|
||||
});
|
||||
|
||||
// Resize elements to render the new viewport size
|
||||
editor.subscriptions.subscribeJsMessage(TriggerViewportResize, async () => {
|
||||
await tick();
|
||||
|
||||
viewportResize();
|
||||
});
|
||||
|
||||
// Once this component is mounted, we want to resend the document bounds to the backend via the resize event handler which does that
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
});
|
||||
</script>
|
||||
|
||||
<LayoutCol class="document" bind:this={self}>
|
||||
<LayoutRow class="options-bar" scrollableX={true}>
|
||||
<WidgetLayout layout={$document.documentModeLayout} />
|
||||
<WidgetLayout layout={$document.toolOptionsLayout} />
|
||||
|
||||
<LayoutRow class="spacer" />
|
||||
|
||||
<WidgetLayout layout={$document.documentBarLayout} />
|
||||
</LayoutRow>
|
||||
<LayoutRow class="shelf-and-viewport">
|
||||
<LayoutCol class="shelf">
|
||||
<LayoutCol class="tools" scrollableY={true}>
|
||||
<WidgetLayout layout={$document.toolShelfLayout} />
|
||||
</LayoutCol>
|
||||
|
||||
<LayoutCol class="spacer" />
|
||||
|
||||
<LayoutCol class="working-colors">
|
||||
<WidgetLayout layout={$document.workingColorsLayout} />
|
||||
</LayoutCol>
|
||||
</LayoutCol>
|
||||
<LayoutCol class="viewport">
|
||||
<LayoutRow class="bar-area top-ruler">
|
||||
<CanvasRuler origin={rulerOrigin.x} majorMarkSpacing={rulerSpacing} numberInterval={rulerInterval} direction="Horizontal" bind:this={rulerHorizontal} />
|
||||
</LayoutRow>
|
||||
<LayoutRow class="canvas-area">
|
||||
<LayoutCol class="bar-area">
|
||||
<CanvasRuler origin={rulerOrigin.y} majorMarkSpacing={rulerSpacing} numberInterval={rulerInterval} direction="Vertical" bind:this={rulerVertical} />
|
||||
</LayoutCol>
|
||||
<LayoutCol class="canvas-area" styles={{ cursor: canvasCursor }}>
|
||||
{#if cursorEyedropper}
|
||||
<EyedropperPreview
|
||||
colorChoice={cursorEyedropperPreviewColorChoice}
|
||||
primaryColor={cursorEyedropperPreviewColorPrimary}
|
||||
secondaryColor={cursorEyedropperPreviewColorSecondary}
|
||||
imageData={cursorEyedropperPreviewImageData}
|
||||
x={cursorLeft}
|
||||
y={cursorTop}
|
||||
/>
|
||||
{/if}
|
||||
<div class="canvas" on:pointerdown={(e) => canvasPointerDown(e)} on:dragover={(e) => e.preventDefault()} on:drop={(e) => pasteFile(e)} bind:this={canvasDiv} data-canvas>
|
||||
<svg class="artboards" style:width={canvasWidthCSS} style:height={canvasHeightCSS}>
|
||||
{@html artboardSvg}
|
||||
</svg>
|
||||
<svg class="artwork" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style:width={canvasWidthCSS} style:height={canvasHeightCSS}>
|
||||
{@html artworkSvg}
|
||||
</svg>
|
||||
<svg class="overlays" style:width={canvasWidthCSS} style:height={canvasHeightCSS}>
|
||||
{@html overlaysSvg}
|
||||
</svg>
|
||||
</div>
|
||||
</LayoutCol>
|
||||
<LayoutCol class="bar-area right-scrollbar">
|
||||
<PersistentScrollbar
|
||||
direction="Vertical"
|
||||
handleLength={scrollbarSize.y}
|
||||
handlePosition={scrollbarPos.y}
|
||||
on:handlePosition={({ detail }) => translateCanvasY(detail)}
|
||||
on:pressTrack={({ detail }) => pageY(detail)}
|
||||
/>
|
||||
</LayoutCol>
|
||||
</LayoutRow>
|
||||
<LayoutRow class="bar-area bottom-scrollbar">
|
||||
<PersistentScrollbar
|
||||
direction="Horizontal"
|
||||
handleLength={scrollbarSize.x}
|
||||
handlePosition={scrollbarPos.x}
|
||||
on:handlePosition={({ detail }) => translateCanvasX(detail)}
|
||||
on:pressTrack={({ detail }) => pageX(detail)}
|
||||
/>
|
||||
</LayoutRow>
|
||||
</LayoutCol>
|
||||
</LayoutRow>
|
||||
</LayoutCol>
|
||||
|
||||
<style lang="scss" global>
|
||||
.document {
|
||||
height: 100%;
|
||||
|
||||
.options-bar {
|
||||
height: 32px;
|
||||
flex: 0 0 auto;
|
||||
margin: 0 4px;
|
||||
|
||||
.spacer {
|
||||
min-width: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.shelf-and-viewport {
|
||||
.shelf {
|
||||
flex: 0 0 auto;
|
||||
|
||||
.tools {
|
||||
flex: 0 1 auto;
|
||||
|
||||
.icon-button[title^="Coming Soon"] {
|
||||
opacity: 0.25;
|
||||
transition: opacity 0.25s;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-button:not(.active) {
|
||||
.color-solid {
|
||||
fill: var(--color-f-white);
|
||||
}
|
||||
|
||||
.color-general {
|
||||
fill: var(--color-data-general);
|
||||
}
|
||||
|
||||
.color-vector {
|
||||
fill: var(--color-data-vector);
|
||||
}
|
||||
|
||||
.color-raster {
|
||||
fill: var(--color-data-raster);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1 0 auto;
|
||||
min-height: 8px;
|
||||
}
|
||||
|
||||
.working-colors {
|
||||
flex: 0 0 auto;
|
||||
|
||||
.widget-row {
|
||||
min-height: 0;
|
||||
|
||||
.swatch-pair {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
--widget-height: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.viewport {
|
||||
flex: 1 1 100%;
|
||||
|
||||
.canvas-area {
|
||||
flex: 1 1 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.bar-area {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.top-ruler .canvas-ruler {
|
||||
padding-left: 16px;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.right-scrollbar .persistent-scrollbar {
|
||||
margin-top: -16px;
|
||||
}
|
||||
|
||||
.bottom-scrollbar .persistent-scrollbar {
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.canvas {
|
||||
background: var(--color-2-mildblack);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
// Allows the SVG to be placed at explicit integer values of width and height to prevent non-pixel-perfect SVG scaling
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
svg {
|
||||
position: absolute;
|
||||
// Fallback values if JS hasn't set these to integers yet
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
// Allows dev tools to select the artwork without being blocked by the SVG containers
|
||||
pointer-events: none;
|
||||
|
||||
// Prevent inheritance from reaching the child elements
|
||||
> * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
foreignObject {
|
||||
width: 10000px;
|
||||
height: 10000px;
|
||||
overflow: visible;
|
||||
|
||||
div {
|
||||
cursor: text;
|
||||
background: none;
|
||||
border: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: visible;
|
||||
white-space: pre-wrap;
|
||||
display: inline-block;
|
||||
// Workaround to force Chrome to display the flashing text entry cursor when text is empty
|
||||
padding-left: 1px;
|
||||
margin-left: -1px;
|
||||
|
||||
&:focus {
|
||||
border: none;
|
||||
outline: none; // Ok for contenteditable element
|
||||
margin: -1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,577 @@
|
||||
<script lang="ts">
|
||||
import { getContext, onMount, tick } from "svelte";
|
||||
|
||||
import { beginDraggingElement } from "@/io-managers/drag";
|
||||
import { platformIsMac } from "@/utility-functions/platform";
|
||||
import {
|
||||
type LayerType,
|
||||
type LayerTypeData,
|
||||
type LayerPanelEntry,
|
||||
defaultWidgetLayout,
|
||||
patchWidgetLayout,
|
||||
UpdateDocumentLayerDetails,
|
||||
UpdateDocumentLayerTreeStructureJs,
|
||||
UpdateLayerTreeOptionsLayout,
|
||||
layerTypeData,
|
||||
} from "@/wasm-communication/messages";
|
||||
|
||||
import LayoutCol from "@/components/layout/LayoutCol.svelte";
|
||||
import LayoutRow from "@/components/layout/LayoutRow.svelte";
|
||||
import IconButton from "@/components/widgets/buttons/IconButton.svelte";
|
||||
import IconLabel from "@/components/widgets/labels/IconLabel.svelte";
|
||||
import WidgetLayout from "@/components/widgets/WidgetLayout.svelte";
|
||||
import { type Editor } from "@/wasm-communication/editor";
|
||||
|
||||
type LayerListingInfo = {
|
||||
folderIndex: number;
|
||||
bottomLayer: boolean;
|
||||
editingName: boolean;
|
||||
entry: LayerPanelEntry;
|
||||
};
|
||||
|
||||
let list: LayoutCol;
|
||||
|
||||
const RANGE_TO_INSERT_WITHIN_BOTTOM_FOLDER_NOT_ROOT = 20;
|
||||
const LAYER_INDENT = 16;
|
||||
const INSERT_MARK_MARGIN_LEFT = 4 + 32 + LAYER_INDENT;
|
||||
const INSERT_MARK_OFFSET = 2;
|
||||
|
||||
type DraggingData = {
|
||||
select?: () => void;
|
||||
insertFolder: BigUint64Array;
|
||||
insertIndex: number;
|
||||
highlightFolder: boolean;
|
||||
markerHeight: number;
|
||||
};
|
||||
|
||||
const editor = getContext<Editor>("editor");
|
||||
|
||||
// Layer data
|
||||
let layerCache: Map<string, LayerPanelEntry> = new Map(); // TODO: replace with BigUint64Array as index
|
||||
let layers: LayerListingInfo[] = [];
|
||||
|
||||
// Interactive dragging
|
||||
let draggable = true;
|
||||
let draggingData: undefined | DraggingData = undefined;
|
||||
let fakeHighlight: undefined | BigUint64Array[] = undefined;
|
||||
let dragInPanel = false;
|
||||
|
||||
// Layouts
|
||||
let layerTreeOptionsLayout = defaultWidgetLayout();
|
||||
|
||||
onMount(() => {
|
||||
editor.subscriptions.subscribeJsMessage(UpdateDocumentLayerTreeStructureJs, (updateDocumentLayerTreeStructure) => {
|
||||
rebuildLayerTree(updateDocumentLayerTreeStructure);
|
||||
});
|
||||
|
||||
editor.subscriptions.subscribeJsMessage(UpdateLayerTreeOptionsLayout, (updateLayerTreeOptionsLayout) => {
|
||||
patchWidgetLayout(layerTreeOptionsLayout, updateLayerTreeOptionsLayout);
|
||||
});
|
||||
|
||||
editor.subscriptions.subscribeJsMessage(UpdateDocumentLayerDetails, (updateDocumentLayerDetails) => {
|
||||
const targetPath = updateDocumentLayerDetails.data.path;
|
||||
const targetLayer = updateDocumentLayerDetails.data;
|
||||
|
||||
const layer = layerCache.get(targetPath.toString());
|
||||
if (layer) {
|
||||
Object.assign(layer, targetLayer);
|
||||
} else {
|
||||
layerCache.set(targetPath.toString(), targetLayer);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function layerIndent(layer: LayerPanelEntry): string {
|
||||
return `${layer.path.length * LAYER_INDENT}px`;
|
||||
}
|
||||
|
||||
function markIndent(path: BigUint64Array): string {
|
||||
return `${INSERT_MARK_MARGIN_LEFT + path.length * LAYER_INDENT}px`;
|
||||
}
|
||||
|
||||
function markTopOffset(height: number): string {
|
||||
return `${height}px`;
|
||||
}
|
||||
|
||||
function toggleLayerVisibility(path: BigUint64Array) {
|
||||
editor.instance.toggleLayerVisibility(path);
|
||||
}
|
||||
|
||||
function handleExpandArrowClick(path: BigUint64Array) {
|
||||
editor.instance.toggleLayerExpansion(path);
|
||||
}
|
||||
|
||||
async function onEditLayerName(listing: LayerListingInfo) {
|
||||
if (listing.editingName) return;
|
||||
|
||||
listing.editingName = true;
|
||||
draggable = false;
|
||||
|
||||
await tick();
|
||||
|
||||
const textInput: HTMLInputElement | undefined = list?.querySelector("[data-text-input]:not([disabled])") || undefined;
|
||||
textInput?.select();
|
||||
}
|
||||
|
||||
function onEditLayerNameChange(listing: LayerListingInfo, e: Event) {
|
||||
// Eliminate duplicate events
|
||||
if (!listing.editingName) return;
|
||||
|
||||
draggable = true;
|
||||
|
||||
const name = (e.target as HTMLInputElement | undefined)?.value;
|
||||
listing.editingName = false;
|
||||
if (name) editor.instance.setLayerName(listing.entry.path, name);
|
||||
}
|
||||
|
||||
async function onEditLayerNameDeselect(listing: LayerListingInfo) {
|
||||
draggable = true;
|
||||
|
||||
listing.editingName = false;
|
||||
|
||||
await tick();
|
||||
window.getSelection()?.removeAllRanges();
|
||||
}
|
||||
|
||||
// TODO: Svelte: test this works
|
||||
function selectLayerWithModifiers(e: MouseEvent, listing: LayerListingInfo) {
|
||||
const ctrl = e.ctrlKey;
|
||||
const meta = e.metaKey;
|
||||
const shift = e.shiftKey;
|
||||
const alt = e.altKey;
|
||||
|
||||
if (!ctrl && !meta && !shift && !alt) selectLayer(false, false, false, listing, e);
|
||||
else if (!ctrl && !meta && shift && !alt) selectLayer(false, false, true, listing, e);
|
||||
else if (ctrl && !meta && !shift && !alt) selectLayer(true, false, false, listing, e);
|
||||
else if (ctrl && !meta && shift && !alt) selectLayer(true, false, true, listing, e);
|
||||
else if (!ctrl && meta && !shift && !alt) selectLayer(false, true, false, listing, e);
|
||||
else if (!ctrl && meta && shift && !alt) selectLayer(false, true, true, listing, e);
|
||||
else if ((ctrl && meta) || alt) e.stopPropagation();
|
||||
}
|
||||
|
||||
async function selectLayer(ctrl: boolean, cmd: boolean, shift: boolean, listing: LayerListingInfo, event: Event) {
|
||||
if (listing.editingName) return;
|
||||
|
||||
const ctrlOrCmd = platformIsMac() ? cmd : ctrl;
|
||||
// Pressing the Ctrl key on a Mac, or the Cmd key on another platform, is a violation of the `.exact` qualifier so we filter it out here
|
||||
const opposite = platformIsMac() ? ctrl : cmd;
|
||||
|
||||
if (!opposite) editor.instance.selectLayer(listing.entry.path, ctrlOrCmd, shift);
|
||||
|
||||
// We always want to stop propagation so the click event doesn't pass through the layer and cause a deselection by clicking the layer panel background
|
||||
// This is also why we cover the remaining cases not considered by the `.exact` qualifier, in the last two bindings on the layer element, with a `stopPropagation()` call
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
async function deselectAllLayers() {
|
||||
editor.instance.deselectAllLayers();
|
||||
}
|
||||
|
||||
function calculateDragIndex(tree: LayoutCol, clientY: number, select?: () => void): DraggingData {
|
||||
const treeChildren = tree.div().children;
|
||||
const treeOffset = tree.getBoundingClientRect().top;
|
||||
|
||||
// Closest distance to the middle of the row along the Y axis
|
||||
let closest = Infinity;
|
||||
|
||||
// Folder to insert into
|
||||
let insertFolder = new BigUint64Array();
|
||||
|
||||
// Insert index
|
||||
let insertIndex = -1;
|
||||
|
||||
// Whether you are inserting into a folder and should show the folder outline
|
||||
let highlightFolder = false;
|
||||
|
||||
let markerHeight = 0;
|
||||
let previousHeight = undefined as undefined | number;
|
||||
|
||||
Array.from(treeChildren).forEach((treeChild, index) => {
|
||||
const layerComponents = treeChild.getElementsByClassName("layer");
|
||||
if (layerComponents.length !== 1) return;
|
||||
const child = layerComponents[0];
|
||||
|
||||
const indexAttribute = child.getAttribute("data-index");
|
||||
if (!indexAttribute) return;
|
||||
const { folderIndex, entry: layer } = layers[parseInt(indexAttribute, 10)];
|
||||
|
||||
const rect = child.getBoundingClientRect();
|
||||
const position = rect.top + rect.height / 2;
|
||||
const distance = position - clientY;
|
||||
|
||||
// Inserting above current row
|
||||
if (distance > 0 && distance < closest) {
|
||||
insertFolder = layer.path.slice(0, layer.path.length - 1);
|
||||
insertIndex = folderIndex;
|
||||
highlightFolder = false;
|
||||
closest = distance;
|
||||
markerHeight = previousHeight || treeOffset + INSERT_MARK_OFFSET;
|
||||
}
|
||||
// Inserting below current row
|
||||
else if (distance > -closest && distance > -RANGE_TO_INSERT_WITHIN_BOTTOM_FOLDER_NOT_ROOT && distance < 0) {
|
||||
insertFolder = layer.layerType === "Folder" ? layer.path : layer.path.slice(0, layer.path.length - 1);
|
||||
insertIndex = layer.layerType === "Folder" ? 0 : folderIndex + 1;
|
||||
highlightFolder = layer.layerType === "Folder";
|
||||
closest = -distance;
|
||||
markerHeight = index === treeChildren.length - 1 ? rect.bottom - INSERT_MARK_OFFSET : rect.bottom;
|
||||
}
|
||||
// Inserting with no nesting at the end of the panel
|
||||
else if (closest === Infinity) {
|
||||
if (layer.path.length === 1) insertIndex = folderIndex + 1;
|
||||
|
||||
markerHeight = rect.bottom - INSERT_MARK_OFFSET;
|
||||
}
|
||||
previousHeight = rect.bottom;
|
||||
});
|
||||
|
||||
markerHeight -= treeOffset;
|
||||
|
||||
return {
|
||||
select,
|
||||
insertFolder,
|
||||
insertIndex,
|
||||
highlightFolder,
|
||||
markerHeight,
|
||||
};
|
||||
}
|
||||
|
||||
async function dragStart(event: DragEvent, listing: LayerListingInfo) {
|
||||
const layer = listing.entry;
|
||||
dragInPanel = true;
|
||||
if (!layer.layerMetadata.selected) {
|
||||
fakeHighlight = [layer.path];
|
||||
}
|
||||
const select = (): void => {
|
||||
if (!layer.layerMetadata.selected) selectLayer(false, false, false, listing, event);
|
||||
};
|
||||
|
||||
const target = (event.target || undefined) as HTMLElement | undefined;
|
||||
const draggingELement = (target?.closest("[data-layer]") || undefined) as HTMLElement | undefined;
|
||||
if (draggingELement) beginDraggingElement(draggingELement);
|
||||
|
||||
// Set style of cursor for drag
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
}
|
||||
|
||||
if (list) draggingData = calculateDragIndex(list, event.clientY, select);
|
||||
}
|
||||
|
||||
function updateInsertLine(event: DragEvent) {
|
||||
// Stop the drag from being shown as cancelled
|
||||
event.preventDefault();
|
||||
dragInPanel = true;
|
||||
|
||||
if (list) draggingData = calculateDragIndex(list, event.clientY, draggingData?.select);
|
||||
}
|
||||
|
||||
async function drop() {
|
||||
if (draggingData && dragInPanel) {
|
||||
const { select, insertFolder, insertIndex } = draggingData;
|
||||
|
||||
select?.();
|
||||
editor.instance.moveLayerInTree(insertFolder, insertIndex);
|
||||
}
|
||||
draggingData = undefined;
|
||||
fakeHighlight = undefined;
|
||||
dragInPanel = false;
|
||||
}
|
||||
|
||||
function rebuildLayerTree(updateDocumentLayerTreeStructure: UpdateDocumentLayerTreeStructureJs) {
|
||||
const layerWithNameBeingEdited = layers.find((layer: LayerListingInfo) => layer.editingName);
|
||||
const layerPathWithNameBeingEdited = layerWithNameBeingEdited?.entry.path;
|
||||
const layerIdWithNameBeingEdited = layerPathWithNameBeingEdited?.slice(-1)[0];
|
||||
const path = [] as bigint[];
|
||||
layers = [] as LayerListingInfo[];
|
||||
|
||||
const recurse = (folder: UpdateDocumentLayerTreeStructureJs, layers: LayerListingInfo[], cache: Map<string, LayerPanelEntry>): void => {
|
||||
folder.children.forEach((item, index) => {
|
||||
// TODO: fix toString
|
||||
const layerId = BigInt(item.layerId.toString());
|
||||
path.push(layerId);
|
||||
|
||||
const mapping = cache.get(path.toString());
|
||||
if (mapping) {
|
||||
layers.push({
|
||||
folderIndex: index,
|
||||
bottomLayer: index === folder.children.length - 1,
|
||||
entry: mapping,
|
||||
editingName: layerIdWithNameBeingEdited === layerId,
|
||||
});
|
||||
}
|
||||
|
||||
// Call self recursively if there are any children
|
||||
if (item.children.length >= 1) recurse(item, layers, cache);
|
||||
|
||||
path.pop();
|
||||
});
|
||||
};
|
||||
|
||||
recurse(updateDocumentLayerTreeStructure, layers, layerCache);
|
||||
}
|
||||
|
||||
function getLayerTypeData(layerType: LayerType): LayerTypeData {
|
||||
return layerTypeData(layerType) || { name: "Error", icon: "Info" };
|
||||
}
|
||||
</script>
|
||||
|
||||
<LayoutCol class="layer-tree" on:dragleave={() => (dragInPanel = false)}>
|
||||
<LayoutRow class="options-bar" scrollableX={true}>
|
||||
<WidgetLayout layout={layerTreeOptionsLayout} />
|
||||
</LayoutRow>
|
||||
<LayoutRow class="layer-tree-rows" scrollableY={true}>
|
||||
<LayoutCol class="list" bind:this={list} on:click={() => deselectAllLayers()} on:dragover={(e) => draggable && updateInsertLine(e)} on:dragend={() => draggable && drop()}>
|
||||
{#each layers as listing, index (String(listing.entry.path.slice(-1)))}
|
||||
<LayoutRow
|
||||
class="layer-row"
|
||||
classes={{
|
||||
"insert-folder": (draggingData?.highlightFolder || false) && draggingData?.insertFolder === listing.entry.path,
|
||||
}}
|
||||
>
|
||||
<LayoutRow class="visibility">
|
||||
<IconButton
|
||||
action={(e) => (toggleLayerVisibility(listing.entry.path), e?.stopPropagation())}
|
||||
size={24}
|
||||
icon={listing.entry.visible ? "EyeVisible" : "EyeHidden"}
|
||||
tooltip={listing.entry.visible ? "Visible" : "Hidden"}
|
||||
/>
|
||||
</LayoutRow>
|
||||
|
||||
<div class="indent" style:margin-left={layerIndent(listing.entry)} />
|
||||
|
||||
{#if listing.entry.layerType === "Folder"}
|
||||
<button class="expand-arrow" class:expanded={listing.entry.layerMetadata.expanded} on:click|stopPropagation={() => handleExpandArrowClick(listing.entry.path)} tabindex="0" />
|
||||
{/if}
|
||||
<LayoutRow
|
||||
class="layer"
|
||||
classes={{
|
||||
selected: fakeHighlight ? fakeHighlight.includes(listing.entry.path) : listing.entry.layerMetadata.selected,
|
||||
}}
|
||||
data-layer={String(listing.entry.path)}
|
||||
data-index={index}
|
||||
tooltip={listing.entry.tooltip}
|
||||
{draggable}
|
||||
on:dragstart={(e) => draggable && dragStart(e, listing)}
|
||||
on:click={(e) => selectLayerWithModifiers(e, listing)}
|
||||
>
|
||||
<LayoutRow class="layer-type-icon">
|
||||
<IconLabel icon={getLayerTypeData(listing.entry.layerType).icon} tooltip={getLayerTypeData(listing.entry.layerType).name} />
|
||||
</LayoutRow>
|
||||
<LayoutRow class="layer-name" on:dblclick={() => onEditLayerName(listing)}>
|
||||
<input
|
||||
data-text-input
|
||||
type="text"
|
||||
value={listing.entry.name}
|
||||
placeholder={getLayerTypeData(listing.entry.layerType).name}
|
||||
disabled={!listing.editingName}
|
||||
on:blur={() => onEditLayerNameDeselect(listing)}
|
||||
on:keydown={(e) => e.key === "Escape" && onEditLayerNameDeselect(listing)}
|
||||
on:keydown={(e) => e.key === "Enter" && onEditLayerNameChange(listing, e)}
|
||||
on:change={(e) => onEditLayerNameChange(listing, e)}
|
||||
/>
|
||||
</LayoutRow>
|
||||
<div class="thumbnail">
|
||||
{@html listing.entry.thumbnail}
|
||||
</div>
|
||||
</LayoutRow>
|
||||
</LayoutRow>
|
||||
{/each}
|
||||
</LayoutCol>
|
||||
{#if draggingData && !draggingData.highlightFolder && dragInPanel}
|
||||
<div class="insert-mark" style:left={markIndent(draggingData.insertFolder)} style:top={markTopOffset(draggingData.markerHeight)} />
|
||||
{/if}
|
||||
</LayoutRow>
|
||||
</LayoutCol>
|
||||
|
||||
<style lang="scss" global>
|
||||
.layer-tree {
|
||||
// Options bar
|
||||
.options-bar {
|
||||
height: 32px;
|
||||
flex: 0 0 auto;
|
||||
margin: 0 4px;
|
||||
align-items: center;
|
||||
|
||||
.widget-layout {
|
||||
width: 100%;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
// Blend mode selector
|
||||
.dropdown-input {
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
// Blend mode selector and opacity slider
|
||||
.dropdown-input,
|
||||
.number-input {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
|
||||
// Layer tree
|
||||
.layer-tree-rows {
|
||||
margin-top: 4px;
|
||||
// Crop away the 1px border below the bottom layer entry when it uses the full space of this panel
|
||||
margin-bottom: -1px;
|
||||
position: relative;
|
||||
|
||||
.layer-row {
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
height: 32px;
|
||||
margin: 0 4px;
|
||||
border-bottom: 1px solid var(--color-4-dimgray);
|
||||
|
||||
.visibility {
|
||||
flex: 0 0 auto;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
|
||||
.icon-button {
|
||||
height: 100%;
|
||||
width: calc(24px + 2 * 4px);
|
||||
}
|
||||
}
|
||||
|
||||
.expand-arrow {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
margin-left: -16px;
|
||||
width: 16px;
|
||||
height: 100%;
|
||||
border: none;
|
||||
position: relative;
|
||||
background: none;
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 2px;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-6-lowergray);
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
border-width: 3px 0 3px 6px;
|
||||
border-color: transparent transparent transparent var(--color-e-nearwhite);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-f-white);
|
||||
}
|
||||
}
|
||||
|
||||
&.expanded::after {
|
||||
border-width: 6px 3px 0 3px;
|
||||
border-color: var(--color-e-nearwhite) transparent transparent transparent;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-f-white);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.layer {
|
||||
align-items: center;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0 4px;
|
||||
border-radius: 2px;
|
||||
margin-right: 8px;
|
||||
|
||||
&.selected {
|
||||
background: var(--color-5-dullgray);
|
||||
color: var(--color-f-white);
|
||||
}
|
||||
|
||||
.layer-type-icon {
|
||||
flex: 0 0 auto;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.layer-name {
|
||||
flex: 1 1 100%;
|
||||
margin: 0 4px;
|
||||
|
||||
input {
|
||||
color: inherit;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none; // Ok for input element
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
border-radius: 2px;
|
||||
height: 24px;
|
||||
width: 100%;
|
||||
|
||||
&:disabled {
|
||||
-webkit-user-select: none; // Required as of Safari 15.0 (Graphite's minimum version) through the latest release
|
||||
user-select: none;
|
||||
// Workaround for `user-select: none` not working on <input> elements
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: inherit;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
background: var(--color-1-nearblack);
|
||||
padding: 0 4px;
|
||||
|
||||
&::placeholder {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
width: 36px;
|
||||
height: 24px;
|
||||
margin: 2px 0;
|
||||
margin-left: 4px;
|
||||
background: white;
|
||||
border-radius: 2px;
|
||||
flex: 0 0 auto;
|
||||
|
||||
svg {
|
||||
width: calc(100% - 4px);
|
||||
height: calc(100% - 4px);
|
||||
margin: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.insert-folder .layer {
|
||||
outline: 3px solid var(--color-e-nearwhite);
|
||||
outline-offset: -3px;
|
||||
}
|
||||
}
|
||||
|
||||
.insert-mark {
|
||||
position: absolute;
|
||||
// `left` is applied dynamically
|
||||
right: 0;
|
||||
background: var(--color-e-nearwhite);
|
||||
margin-top: -2px;
|
||||
height: 5px;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,761 @@
|
||||
<script lang="ts">
|
||||
import { getContext, onMount, tick } from "svelte";
|
||||
|
||||
import type { IconName } from "@/utility-functions/icons";
|
||||
|
||||
import { UpdateNodeGraphSelection, type FrontendNodeLink, type FrontendNodeType, type FrontendNode } from "@/wasm-communication/messages";
|
||||
|
||||
import LayoutCol from "@/components/layout/LayoutCol.svelte";
|
||||
import LayoutRow from "@/components/layout/LayoutRow.svelte";
|
||||
import TextButton from "@/components/widgets/buttons/TextButton.svelte";
|
||||
import TextInput from "@/components/widgets/inputs/TextInput.svelte";
|
||||
import IconLabel from "@/components/widgets/labels/IconLabel.svelte";
|
||||
import TextLabel from "@/components/widgets/labels/TextLabel.svelte";
|
||||
import WidgetLayout from "@/components/widgets/WidgetLayout.svelte";
|
||||
import { type Editor } from "@/wasm-communication/editor";
|
||||
import { type NodeGraphState } from "@/state-providers/node-graph";
|
||||
|
||||
const WHEEL_RATE = (1 / 600) * 3;
|
||||
const GRID_COLLAPSE_SPACING = 10;
|
||||
const GRID_SIZE = 24;
|
||||
|
||||
const editor = getContext<Editor>("editor");
|
||||
const nodeGraph = getContext<NodeGraphState>("nodeGraph");
|
||||
|
||||
let graph: LayoutRow;
|
||||
let nodesContainer: HTMLDivElement;
|
||||
let nodeSearchInput: TextInput;
|
||||
let transform = { scale: 1, x: 0, y: 0 };
|
||||
let panning = false;
|
||||
let selected: bigint[] = [];
|
||||
let draggingNodes: { startX: number; startY: number; roundX: number; roundY: number } | undefined = undefined;
|
||||
let selectIfNotDragged: undefined | bigint = undefined;
|
||||
let linkInProgressFromConnector: HTMLDivElement | undefined = undefined;
|
||||
let linkInProgressToConnector: HTMLDivElement | DOMRect | undefined = undefined;
|
||||
let disconnecting: { nodeId: bigint; inputIndex: number; linkIndex: number } | undefined = undefined;
|
||||
let nodeLinkPaths: [string, string][] = [];
|
||||
let searchTerm = "";
|
||||
let nodeListLocation: { x: number; y: number } | undefined = undefined;
|
||||
|
||||
$: gridSpacing = calculateGridSpacing(transform.scale);
|
||||
$: dotRadius = 1 + Math.floor(transform.scale - 0.5 + 0.001) / 2;
|
||||
$: nodeGraphBarLayout = $nodeGraph.nodeGraphBarLayout;
|
||||
$: nodeCategories = buildNodeCategories($nodeGraph.nodeTypes, searchTerm);
|
||||
$: nodeListX = ((nodeListLocation?.x || 0) * GRID_SIZE + transform.x) * transform.scale;
|
||||
$: nodeListY = ((nodeListLocation?.y || 0) * GRID_SIZE + transform.y) * transform.scale;
|
||||
$: linkPathInProgress = createLinkPathInProgress(linkInProgressFromConnector, linkInProgressToConnector);
|
||||
$: linkPaths = createLinkPaths(linkPathInProgress, nodeLinkPaths);
|
||||
|
||||
$: watchNodes($nodeGraph.nodes);
|
||||
|
||||
function calculateGridSpacing(scale: number): number {
|
||||
const dense = scale * GRID_SIZE;
|
||||
let sparse = dense;
|
||||
|
||||
while (sparse > 0 && sparse < GRID_COLLAPSE_SPACING) {
|
||||
sparse *= 2;
|
||||
}
|
||||
|
||||
return sparse;
|
||||
}
|
||||
|
||||
function buildNodeCategories(nodeTypes: FrontendNodeType[], searchTerm: string) {
|
||||
const categories = new Map();
|
||||
nodeTypes.forEach((node) => {
|
||||
if (searchTerm.length > 0 && !node.name.toLowerCase().includes(searchTerm.toLowerCase()) && !node.category.toLowerCase().includes(searchTerm.toLowerCase())) {
|
||||
return;
|
||||
}
|
||||
|
||||
const category = categories.get(node.category);
|
||||
if (category) category.push(node);
|
||||
else categories.set(node.category, [node]);
|
||||
});
|
||||
|
||||
return Array.from(categories);
|
||||
}
|
||||
|
||||
function createLinkPathInProgress(linkInProgressFromConnector?: HTMLDivElement, linkInProgressToConnector?: HTMLDivElement | DOMRect): [string, string] | undefined {
|
||||
if (linkInProgressFromConnector && linkInProgressToConnector) {
|
||||
return createWirePath(linkInProgressFromConnector, linkInProgressToConnector, false, false);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function createLinkPaths(linkPathInProgress: [string, string] | undefined, nodeLinkPaths: [string, string][]): [string, string][] {
|
||||
const optionalTuple = linkPathInProgress ? [linkPathInProgress] : [];
|
||||
return [...optionalTuple, ...nodeLinkPaths];
|
||||
}
|
||||
|
||||
async function watchNodes(nodes: FrontendNode[]) {
|
||||
selected = selected.filter((id) => nodes.find((node) => node.id === id));
|
||||
await refreshLinks();
|
||||
}
|
||||
|
||||
function resolveLink(link: FrontendNodeLink, containerBounds: HTMLDivElement): { nodePrimaryOutput: HTMLDivElement | undefined; nodePrimaryInput: HTMLDivElement | undefined } {
|
||||
const connectorIndex = Number(link.linkEndInputIndex);
|
||||
|
||||
const nodePrimaryOutput = (containerBounds.querySelector(`[data-node="${String(link.linkStart)}"] [data-port="output"]`) || undefined) as HTMLDivElement | undefined;
|
||||
|
||||
const nodeInputConnectors = containerBounds.querySelectorAll(`[data-node="${String(link.linkEnd)}"] [data-port="input"]`) || undefined;
|
||||
const nodePrimaryInput = nodeInputConnectors?.[connectorIndex] as HTMLDivElement | undefined;
|
||||
return { nodePrimaryOutput, nodePrimaryInput };
|
||||
}
|
||||
|
||||
async function refreshLinks(): Promise<void> {
|
||||
await tick();
|
||||
|
||||
const links = $nodeGraph.links;
|
||||
nodeLinkPaths = links.flatMap((link, index) => {
|
||||
const { nodePrimaryInput, nodePrimaryOutput } = resolveLink(link, nodesContainer);
|
||||
if (!nodePrimaryInput || !nodePrimaryOutput) return [];
|
||||
if (disconnecting?.linkIndex === index) return [];
|
||||
|
||||
return [createWirePath(nodePrimaryOutput, nodePrimaryInput.getBoundingClientRect(), false, false)];
|
||||
});
|
||||
}
|
||||
|
||||
function nodeIcon(nodeName: string): IconName {
|
||||
const iconMap: Record<string, IconName> = {
|
||||
Output: "NodeOutput",
|
||||
Imaginate: "NodeImaginate",
|
||||
"Hue Shift Image": "NodeColorCorrection",
|
||||
"Brighten Image": "NodeColorCorrection",
|
||||
"Grayscale Image": "NodeColorCorrection",
|
||||
};
|
||||
return iconMap[nodeName] || "NodeNodes";
|
||||
}
|
||||
|
||||
function buildWirePathLocations(outputBounds: DOMRect, inputBounds: DOMRect, verticalOut: boolean, verticalIn: boolean): { x: number; y: number }[] {
|
||||
const containerBounds = nodesContainer.getBoundingClientRect();
|
||||
|
||||
const outX = verticalOut ? outputBounds.x + outputBounds.width / 2 : outputBounds.x + outputBounds.width - 1;
|
||||
const outY = verticalOut ? outputBounds.y + 1 : outputBounds.y + outputBounds.height / 2;
|
||||
const outConnectorX = (outX - containerBounds.x) / transform.scale;
|
||||
const outConnectorY = (outY - containerBounds.y) / transform.scale;
|
||||
|
||||
const inX = verticalIn ? inputBounds.x + inputBounds.width / 2 : inputBounds.x + 1;
|
||||
const inY = verticalIn ? inputBounds.y + inputBounds.height - 1 : inputBounds.y + inputBounds.height / 2;
|
||||
const inConnectorX = (inX - containerBounds.x) / transform.scale;
|
||||
const inConnectorY = (inY - containerBounds.y) / transform.scale;
|
||||
const horizontalGap = Math.abs(outConnectorX - inConnectorX);
|
||||
const verticalGap = Math.abs(outConnectorY - inConnectorY);
|
||||
|
||||
const curveLength = 200;
|
||||
const curveFalloffRate = curveLength * Math.PI * 2;
|
||||
|
||||
const horizontalCurveAmount = -(2 ** ((-10 * horizontalGap) / curveFalloffRate)) + 1;
|
||||
const verticalCurveAmount = -(2 ** ((-10 * verticalGap) / curveFalloffRate)) + 1;
|
||||
const horizontalCurve = horizontalCurveAmount * curveLength;
|
||||
const verticalCurve = verticalCurveAmount * curveLength;
|
||||
|
||||
return [
|
||||
{ x: outConnectorX, y: outConnectorY },
|
||||
{ x: verticalOut ? outConnectorX : outConnectorX + horizontalCurve, y: verticalOut ? outConnectorY - verticalCurve : outConnectorY },
|
||||
{ x: verticalIn ? inConnectorX : inConnectorX - horizontalCurve, y: verticalIn ? inConnectorY + verticalCurve : inConnectorY },
|
||||
{ x: inConnectorX, y: inConnectorY },
|
||||
];
|
||||
}
|
||||
|
||||
function buildWirePathString(outputBounds: DOMRect, inputBounds: DOMRect, verticalOut: boolean, verticalIn: boolean): string {
|
||||
const locations = buildWirePathLocations(outputBounds, inputBounds, verticalOut, verticalIn);
|
||||
if (locations.length === 0) return "[error]";
|
||||
return `M${locations[0].x},${locations[0].y} C${locations[1].x},${locations[1].y} ${locations[2].x},${locations[2].y} ${locations[3].x},${locations[3].y}`;
|
||||
}
|
||||
|
||||
function createWirePath(outputPort: HTMLDivElement, inputPort: HTMLDivElement | DOMRect, verticalOut: boolean, verticalIn: boolean): [string, string] {
|
||||
const inputPortRect = inputPort instanceof HTMLDivElement ? inputPort.getBoundingClientRect() : inputPort;
|
||||
|
||||
const pathString = buildWirePathString(outputPort.getBoundingClientRect(), inputPortRect, verticalOut, verticalIn);
|
||||
const dataType = outputPort.getAttribute("data-datatype") || "general";
|
||||
|
||||
return [pathString, dataType];
|
||||
}
|
||||
|
||||
function scroll(e: WheelEvent) {
|
||||
const scrollX = e.deltaX;
|
||||
const scrollY = e.deltaY;
|
||||
|
||||
// Zoom
|
||||
if (e.ctrlKey) {
|
||||
let zoomFactor = 1 + Math.abs(scrollY) * WHEEL_RATE;
|
||||
if (scrollY > 0) zoomFactor = 1 / zoomFactor;
|
||||
|
||||
const { x, y, width, height } = graph.getBoundingClientRect();
|
||||
|
||||
transform.scale *= zoomFactor;
|
||||
|
||||
const newViewportX = width / zoomFactor;
|
||||
const newViewportY = height / zoomFactor;
|
||||
|
||||
const deltaSizeX = width - newViewportX;
|
||||
const deltaSizeY = height - newViewportY;
|
||||
|
||||
const deltaX = deltaSizeX * ((e.x - x) / width);
|
||||
const deltaY = deltaSizeY * ((e.y - y) / height);
|
||||
|
||||
transform.x -= (deltaX / transform.scale) * zoomFactor;
|
||||
transform.y -= (deltaY / transform.scale) * zoomFactor;
|
||||
|
||||
// Prevent actually zooming into the page when pinch-zooming on laptop trackpads
|
||||
e.preventDefault();
|
||||
}
|
||||
// Pan
|
||||
else if (!e.shiftKey) {
|
||||
transform.x -= scrollX / transform.scale;
|
||||
transform.y -= scrollY / transform.scale;
|
||||
} else {
|
||||
transform.x -= scrollY / transform.scale;
|
||||
}
|
||||
}
|
||||
|
||||
function keydown(e: KeyboardEvent): void {
|
||||
if (e.key.toLowerCase() === "escape") {
|
||||
nodeListLocation = undefined;
|
||||
document.removeEventListener("keydown", keydown);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Move the event listener from the graph to the window so dragging outside the graph area (or even the browser window) works
|
||||
function pointerDown(e: PointerEvent) {
|
||||
// Exit the add node popup by clicking elsewhere in the graph
|
||||
if (nodeListLocation && !(e.target as HTMLElement).closest("[data-node-list]")) nodeListLocation = undefined;
|
||||
|
||||
// Handle the add node popup on right click
|
||||
if (e.button === 2) {
|
||||
const graphBounds = graph.getBoundingClientRect();
|
||||
nodeListLocation = {
|
||||
x: Math.round(((e.clientX - graphBounds.x) / transform.scale - transform.x) / GRID_SIZE),
|
||||
y: Math.round(((e.clientY - graphBounds.y) / transform.scale - transform.y) / GRID_SIZE),
|
||||
};
|
||||
|
||||
// Find actual relevant child and focus it
|
||||
// TODO: Svelte: check if this works and if `setTimeout` can be removed
|
||||
setTimeout(() => nodeSearchInput.focus(), 0);
|
||||
|
||||
document.addEventListener("keydown", keydown);
|
||||
return;
|
||||
}
|
||||
|
||||
const port = (e.target as HTMLDivElement).closest("[data-port]") as HTMLDivElement;
|
||||
const node = (e.target as HTMLElement).closest("[data-node]") as HTMLElement | undefined;
|
||||
const nodeId = node?.getAttribute("data-node") || undefined;
|
||||
const nodeList = (e.target as HTMLElement).closest("[data-node-list]") as HTMLElement | undefined;
|
||||
|
||||
// If the user is clicking on the add nodes list, exit here
|
||||
if (nodeList) return;
|
||||
|
||||
if (e.altKey && nodeId) {
|
||||
editor.instance.togglePreview(BigInt(nodeId));
|
||||
}
|
||||
|
||||
// Clicked on a port dot
|
||||
if (port && node) {
|
||||
const isOutput = Boolean(port.getAttribute("data-port") === "output");
|
||||
|
||||
if (isOutput) linkInProgressFromConnector = port;
|
||||
else {
|
||||
const inputNodeInPorts = Array.from(node.querySelectorAll(`[data-port="input"]`));
|
||||
const inputNodeConnectionIndexSearch = inputNodeInPorts.indexOf(port);
|
||||
const inputIndex = inputNodeConnectionIndexSearch > -1 ? inputNodeConnectionIndexSearch : undefined;
|
||||
// Set the link to draw from the input that a previous link was on
|
||||
if (inputIndex !== undefined && nodeId) {
|
||||
const nodeIdInt = BigInt(nodeId);
|
||||
const inputIndexInt = BigInt(inputIndex);
|
||||
const links = $nodeGraph.links;
|
||||
const linkIndex = links.findIndex((value) => value.linkEnd === nodeIdInt && value.linkEndInputIndex === inputIndexInt);
|
||||
const queryString = `[data-node="${String(links[linkIndex].linkStart)}"] [data-port="output"]`;
|
||||
linkInProgressFromConnector = (nodesContainer.querySelector(queryString) || undefined) as HTMLDivElement | undefined;
|
||||
const nodeInputConnectors = nodesContainer.querySelectorAll(`[data-node="${String(links[linkIndex].linkEnd)}"] [data-port="input"]`) || undefined;
|
||||
linkInProgressToConnector = nodeInputConnectors?.[Number(links[linkIndex].linkEndInputIndex)] as HTMLDivElement | undefined;
|
||||
disconnecting = { nodeId: nodeIdInt, inputIndex, linkIndex };
|
||||
refreshLinks();
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Clicked on a node
|
||||
if (nodeId) {
|
||||
let modifiedSelected = false;
|
||||
|
||||
const id = BigInt(nodeId);
|
||||
if (e.shiftKey || e.ctrlKey) {
|
||||
modifiedSelected = true;
|
||||
|
||||
if (selected.includes(id)) selected.splice(selected.lastIndexOf(id), 1);
|
||||
else selected.push(id);
|
||||
} else if (!selected.includes(id)) {
|
||||
modifiedSelected = true;
|
||||
|
||||
selected = [id];
|
||||
} else {
|
||||
selectIfNotDragged = id;
|
||||
}
|
||||
|
||||
if (selected.includes(id)) {
|
||||
draggingNodes = { startX: e.x, startY: e.y, roundX: 0, roundY: 0 };
|
||||
}
|
||||
|
||||
if (modifiedSelected) editor.instance.selectNodes(new BigUint64Array(selected));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Clicked on the graph background
|
||||
panning = true;
|
||||
if (selected.length !== 0) {
|
||||
selected = [];
|
||||
editor.instance.selectNodes(new BigUint64Array(selected));
|
||||
}
|
||||
}
|
||||
|
||||
function doubleClick(e: MouseEvent) {
|
||||
const node = (e.target as HTMLElement).closest("[data-node]") as HTMLElement | undefined;
|
||||
const nodeId = node?.getAttribute("data-node") || undefined;
|
||||
if (nodeId) {
|
||||
const id = BigInt(nodeId);
|
||||
editor.instance.doubleClickNode(id);
|
||||
}
|
||||
}
|
||||
|
||||
function pointerMove(e: PointerEvent) {
|
||||
if (panning) {
|
||||
transform.x += e.movementX / transform.scale;
|
||||
transform.y += e.movementY / transform.scale;
|
||||
} else if (linkInProgressFromConnector) {
|
||||
const target = e.target as Element | undefined;
|
||||
const dot = (target?.closest(`[data-port="input"]`) || undefined) as HTMLDivElement | undefined;
|
||||
if (dot) {
|
||||
linkInProgressToConnector = dot;
|
||||
} else {
|
||||
linkInProgressToConnector = new DOMRect(e.x, e.y);
|
||||
}
|
||||
} else if (draggingNodes) {
|
||||
const deltaX = Math.round((e.x - draggingNodes.startX) / transform.scale / GRID_SIZE);
|
||||
const deltaY = Math.round((e.y - draggingNodes.startY) / transform.scale / GRID_SIZE);
|
||||
if (draggingNodes.roundX !== deltaX || draggingNodes.roundY !== deltaY) {
|
||||
draggingNodes.roundX = deltaX;
|
||||
draggingNodes.roundY = deltaY;
|
||||
refreshLinks();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pointerUp(e: PointerEvent) {
|
||||
panning = false;
|
||||
|
||||
if (disconnecting) {
|
||||
editor.instance.disconnectNodes(BigInt(disconnecting.nodeId), disconnecting.inputIndex);
|
||||
}
|
||||
disconnecting = undefined;
|
||||
|
||||
if (linkInProgressToConnector instanceof HTMLDivElement && linkInProgressFromConnector) {
|
||||
const outputNode = linkInProgressFromConnector.closest("[data-node]");
|
||||
const inputNode = linkInProgressToConnector.closest("[data-node]");
|
||||
|
||||
const outputConnectedNodeID = outputNode?.getAttribute("data-node") ?? undefined;
|
||||
const inputConnectedNodeID = inputNode?.getAttribute("data-node") ?? undefined;
|
||||
|
||||
if (outputNode && inputNode && outputConnectedNodeID && inputConnectedNodeID) {
|
||||
const inputNodeInPorts = Array.from(inputNode.querySelectorAll(`[data-port="input"]`));
|
||||
const inputNodeConnectionIndexSearch = inputNodeInPorts.indexOf(linkInProgressToConnector);
|
||||
const inputNodeConnectionIndex = inputNodeConnectionIndexSearch > -1 ? inputNodeConnectionIndexSearch : undefined;
|
||||
|
||||
if (inputNodeConnectionIndex !== undefined) {
|
||||
// const oneBasedIndex = inputNodeConnectionIndex + 1;
|
||||
|
||||
editor.instance.connectNodesByLink(BigInt(outputConnectedNodeID), BigInt(inputConnectedNodeID), inputNodeConnectionIndex);
|
||||
}
|
||||
}
|
||||
} else if (draggingNodes) {
|
||||
if (draggingNodes.startX === e.x || draggingNodes.startY === e.y) {
|
||||
if (selectIfNotDragged !== undefined && (selected.length !== 1 || selected[0] !== selectIfNotDragged)) {
|
||||
selected = [selectIfNotDragged];
|
||||
editor.instance.selectNodes(new BigUint64Array(selected));
|
||||
}
|
||||
}
|
||||
|
||||
if (selected.length > 0 && (draggingNodes.roundX !== 0 || draggingNodes.roundY !== 0)) editor.instance.moveSelectedNodes(draggingNodes.roundX, draggingNodes.roundY);
|
||||
|
||||
// Check if this node should be inserted between two other nodes
|
||||
if (selected.length === 1) {
|
||||
const selectedNodeId = selected[0];
|
||||
const selectedNode = nodesContainer.querySelector(`[data-node="${String(selectedNodeId)}"]`);
|
||||
|
||||
// Check that neither the input or output of the selected node are already connected.
|
||||
const notConnected = $nodeGraph.links.findIndex((link) => link.linkStart === selectedNodeId || (link.linkEnd === selectedNodeId && link.linkEndInputIndex === BigInt(0))) === -1;
|
||||
const input = selectedNode?.querySelector(`[data-port="input"]`);
|
||||
const output = selectedNode?.querySelector(`[data-port="output"]`);
|
||||
|
||||
// TODO: Make sure inputs are correctly typed
|
||||
if (selectedNode && notConnected && input && output) {
|
||||
// Find the link that the node has been dragged on top of
|
||||
const link = $nodeGraph.links.find((link): boolean => {
|
||||
const { nodePrimaryInput, nodePrimaryOutput } = resolveLink(link, nodesContainer);
|
||||
if (!nodePrimaryInput || !nodePrimaryOutput) return false;
|
||||
|
||||
const wireCurveLocations = buildWirePathLocations(nodePrimaryOutput.getBoundingClientRect(), nodePrimaryInput.getBoundingClientRect(), false, false);
|
||||
|
||||
const selectedNodeBounds = selectedNode.getBoundingClientRect();
|
||||
const containerBoundsBounds = nodesContainer.getBoundingClientRect();
|
||||
|
||||
return editor.instance.rectangleIntersects(
|
||||
new Float64Array(wireCurveLocations.map((loc) => loc.x)),
|
||||
new Float64Array(wireCurveLocations.map((loc) => loc.y)),
|
||||
selectedNodeBounds.top - containerBoundsBounds.y,
|
||||
selectedNodeBounds.left - containerBoundsBounds.x,
|
||||
selectedNodeBounds.bottom - containerBoundsBounds.y,
|
||||
selectedNodeBounds.right - containerBoundsBounds.x
|
||||
);
|
||||
});
|
||||
// If the node has been dragged on top of the link then connect it into the middle.
|
||||
if (link) {
|
||||
editor.instance.connectNodesByLink(link.linkStart, selectedNodeId, 0);
|
||||
editor.instance.connectNodesByLink(selectedNodeId, link.linkEnd, Number(link.linkEndInputIndex));
|
||||
editor.instance.shiftNode(selectedNodeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
draggingNodes = undefined;
|
||||
selectIfNotDragged = undefined;
|
||||
}
|
||||
|
||||
linkInProgressFromConnector = undefined;
|
||||
linkInProgressToConnector = undefined;
|
||||
}
|
||||
|
||||
function createNode(nodeType: string): void {
|
||||
if (!nodeListLocation) return;
|
||||
|
||||
editor.instance.createNode(nodeType, nodeListLocation.x, nodeListLocation.y);
|
||||
nodeListLocation = undefined;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const outputPort1 = document.querySelectorAll(`[data-port="output"]`)[4] as HTMLDivElement | undefined;
|
||||
const inputPort1 = document.querySelectorAll(`[data-port="input"]`)[1] as HTMLDivElement | undefined;
|
||||
if (outputPort1 && inputPort1) createWirePath(outputPort1, inputPort1.getBoundingClientRect(), true, true);
|
||||
|
||||
const outputPort2 = document.querySelectorAll(`[data-port="output"]`)[6] as HTMLDivElement | undefined;
|
||||
const inputPort2 = document.querySelectorAll(`[data-port="input"]`)[3] as HTMLDivElement | undefined;
|
||||
if (outputPort2 && inputPort2) createWirePath(outputPort2, inputPort2.getBoundingClientRect(), true, false);
|
||||
|
||||
editor.subscriptions.subscribeJsMessage(UpdateNodeGraphSelection, (updateNodeGraphSelection) => {
|
||||
selected = updateNodeGraphSelection.selected;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<LayoutCol class="node-graph">
|
||||
<LayoutRow class="options-bar"><WidgetLayout layout={nodeGraphBarLayout} /></LayoutRow>
|
||||
<LayoutRow
|
||||
class="graph"
|
||||
bind:this={graph}
|
||||
on:wheel={scroll}
|
||||
on:pointerdown={pointerDown}
|
||||
on:pointermove={pointerMove}
|
||||
on:pointerup={pointerUp}
|
||||
on:dblclick={doubleClick}
|
||||
styles={{
|
||||
"--grid-spacing": `${gridSpacing}px`,
|
||||
"--grid-offset-x": `${transform.x * transform.scale}px`,
|
||||
"--grid-offset-y": `${transform.y * transform.scale}px`,
|
||||
"--dot-radius": `${dotRadius}px`,
|
||||
}}
|
||||
>
|
||||
{#if nodeListLocation}
|
||||
<LayoutCol class="node-list" data-node-list styles={{ "margin-left": `${nodeListX}px`, "margin-top": `${nodeListY}px` }}>
|
||||
<TextInput placeholder="Search Nodes..." value={searchTerm} on:value={({ detail }) => (searchTerm = detail)} bind:this={nodeSearchInput} />
|
||||
{#each nodeCategories as nodeCategory (nodeCategory[0])}
|
||||
<LayoutCol>
|
||||
<TextLabel>{nodeCategory[0]}</TextLabel>
|
||||
{#each nodeCategory[1] as nodeType (String(nodeType))}
|
||||
<TextButton label={nodeType.name} action={() => createNode(nodeType.name)} />
|
||||
{/each}
|
||||
</LayoutCol>
|
||||
{:else}
|
||||
<TextLabel>No search results</TextLabel>
|
||||
{/each}
|
||||
</LayoutCol>
|
||||
{/if}
|
||||
<div class="nodes" style:transform={`scale(${transform.scale}) translate(${transform.x}px, ${transform.y}px)`} style:transform-origin={`0 0`} bind:this={nodesContainer}>
|
||||
{#each $nodeGraph.nodes as node (String(node.id))}
|
||||
<div
|
||||
class="node"
|
||||
class:selected={selected.includes(node.id)}
|
||||
class:output={node.output}
|
||||
class:disabled={node.disabled}
|
||||
style:--offset-left={(node.position?.x || 0) + (selected.includes(node.id) ? draggingNodes?.roundX || 0 : 0)}
|
||||
style:--offset-top={(node.position?.y || 0) + (selected.includes(node.id) ? draggingNodes?.roundY || 0 : 0)}
|
||||
data-node={node.id}
|
||||
>
|
||||
<div class="primary">
|
||||
<div class="ports">
|
||||
{#if node.primaryInput}
|
||||
<div
|
||||
class="input port"
|
||||
data-port="input"
|
||||
data-datatype={node.primaryInput}
|
||||
style:--data-color={`var(--color-data-${node.primaryInput})`}
|
||||
style:--data-color-dim={`var(--color-data-${node.primaryInput}-dim)`}
|
||||
>
|
||||
<div />
|
||||
</div>
|
||||
{/if}
|
||||
{#if node.outputs.length > 0}
|
||||
<div
|
||||
class="output port"
|
||||
data-port="output"
|
||||
data-datatype={node.outputs[0]}
|
||||
style:--data-color={`var(--color-data-${node.outputs[0]})`}
|
||||
style:--data-color-dim={`var(--color-data-${node.outputs[0]}-dim)`}
|
||||
>
|
||||
<div />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<IconLabel icon={nodeIcon(node.displayName)} />
|
||||
<TextLabel>{node.displayName}</TextLabel>
|
||||
</div>
|
||||
{#if node.exposedInputs.length > 0}
|
||||
<div class="arguments">
|
||||
{#each node.exposedInputs as argument, index (index)}
|
||||
<div class="argument">
|
||||
<div class="ports">
|
||||
<div
|
||||
class="input port"
|
||||
data-port="input"
|
||||
data-datatype={argument.dataType}
|
||||
style:--data-color={`var(--color-data-${argument.dataType})`}
|
||||
style:--data-color-dim={`var(--color-data-${argument.dataType}-dim)`}
|
||||
>
|
||||
<div />
|
||||
</div>
|
||||
</div>
|
||||
<TextLabel>{argument.name}</TextLabel>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="wires" style:transform={`scale(${transform.scale}) translate(${transform.x}px, ${transform.y}px)`} style:transform-origin={`0 0`}>
|
||||
<svg>
|
||||
{#each linkPaths as [pathString, dataType], index (index)}
|
||||
<path d={pathString} style:--data-color={`var(--color-data-${dataType})`} style:--data-color-dim={`var(--color-data-${dataType}-dim)`} />
|
||||
{/each}
|
||||
</svg>
|
||||
</div>
|
||||
</LayoutRow>
|
||||
</LayoutCol>
|
||||
|
||||
<style lang="scss" global>
|
||||
.node-graph {
|
||||
height: 100%;
|
||||
position: relative;
|
||||
|
||||
.node-list {
|
||||
width: max-content;
|
||||
position: fixed;
|
||||
padding: 5px;
|
||||
z-index: 3;
|
||||
background-color: var(--color-3-darkgray);
|
||||
|
||||
.text-button + .text-button {
|
||||
margin-left: 0;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.options-bar {
|
||||
height: 32px;
|
||||
margin: 0 4px;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
|
||||
.widget-layout {
|
||||
flex-direction: row;
|
||||
flex-grow: 1;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
.graph {
|
||||
position: relative;
|
||||
background: var(--color-2-mildblack);
|
||||
width: calc(100% - 8px);
|
||||
margin-left: 4px;
|
||||
margin-bottom: 4px;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
|
||||
// We're displaying the dotted grid in a pseudo-element because `image-rendering` is an inherited property and we don't want it to apply to child elements
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-size: var(--grid-spacing) var(--grid-spacing);
|
||||
background-position: calc(var(--grid-offset-x) - var(--dot-radius)) calc(var(--grid-offset-y) - var(--dot-radius));
|
||||
background-image: radial-gradient(circle at var(--dot-radius) var(--dot-radius), var(--color-3-darkgray) var(--dot-radius), transparent 0);
|
||||
image-rendering: pixelated;
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
}
|
||||
|
||||
.nodes,
|
||||
.wires {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
&.wires {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
|
||||
path {
|
||||
fill: none;
|
||||
// stroke: var(--color-data-raster-dim);
|
||||
stroke: var(--data-color-dim);
|
||||
stroke-width: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.nodes {
|
||||
.node {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 120px;
|
||||
border-radius: 4px;
|
||||
background: var(--color-4-dimgray);
|
||||
left: calc((var(--offset-left) + 0.5) * 24px);
|
||||
top: calc((var(--offset-top) - 0.5) * 24px);
|
||||
|
||||
&.selected {
|
||||
border: 1px solid var(--color-e-nearwhite);
|
||||
margin: -1px;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
background: var(--color-3-darkgray);
|
||||
color: var(--color-a-softgray);
|
||||
|
||||
.icon-label {
|
||||
fill: var(--color-a-softgray);
|
||||
}
|
||||
}
|
||||
|
||||
&.output {
|
||||
outline: 3px solid var(--color-data-vector);
|
||||
}
|
||||
|
||||
.primary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
height: 24px;
|
||||
background: var(--color-5-dullgray);
|
||||
border-radius: 4px;
|
||||
|
||||
.icon-label {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.text-label {
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.arguments {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
|
||||
.argument {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
width: 100%;
|
||||
margin-left: 24px;
|
||||
margin-right: 24px;
|
||||
}
|
||||
|
||||
// Squares to cover up the rounded corners of the primary area and make them have a straight edge
|
||||
&::before,
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
background: var(--color-5-dullgray);
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
top: -4px;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
&::after {
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.ports {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.port {
|
||||
position: absolute;
|
||||
margin: auto 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--data-color-dim);
|
||||
// background: var(--color-data-raster-dim);
|
||||
|
||||
div {
|
||||
background: var(--data-color);
|
||||
// background: var(--color-data-raster);
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
&.input {
|
||||
left: calc(-12px - 6px);
|
||||
}
|
||||
|
||||
&.output {
|
||||
right: calc(-12px - 6px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
import { getContext, onMount } from "svelte";
|
||||
|
||||
import { defaultWidgetLayout, patchWidgetLayout, UpdatePropertyPanelOptionsLayout, UpdatePropertyPanelSectionsLayout } from "@/wasm-communication/messages";
|
||||
|
||||
import LayoutCol from "@/components/layout/LayoutCol.svelte";
|
||||
import LayoutRow from "@/components/layout/LayoutRow.svelte";
|
||||
import WidgetLayout from "@/components/widgets/WidgetLayout.svelte";
|
||||
import { type Editor } from "@/wasm-communication/editor";
|
||||
|
||||
const editor = getContext<Editor>("editor");
|
||||
|
||||
let propertiesOptionsLayout = defaultWidgetLayout();
|
||||
let propertiesSectionsLayout = defaultWidgetLayout();
|
||||
|
||||
onMount(() => {
|
||||
editor.subscriptions.subscribeJsMessage(UpdatePropertyPanelOptionsLayout, (updatePropertyPanelOptionsLayout) => {
|
||||
patchWidgetLayout(propertiesOptionsLayout, updatePropertyPanelOptionsLayout);
|
||||
});
|
||||
|
||||
editor.subscriptions.subscribeJsMessage(UpdatePropertyPanelSectionsLayout, (updatePropertyPanelSectionsLayout) => {
|
||||
patchWidgetLayout(propertiesSectionsLayout, updatePropertyPanelSectionsLayout);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<LayoutCol class="properties">
|
||||
<LayoutRow class="options-bar">
|
||||
<WidgetLayout layout={propertiesOptionsLayout} />
|
||||
</LayoutRow>
|
||||
<LayoutRow class="sections" scrollableY={true}>
|
||||
<WidgetLayout layout={propertiesSectionsLayout} />
|
||||
</LayoutRow>
|
||||
</LayoutCol>
|
||||
|
||||
<style lang="scss" global>
|
||||
.properties {
|
||||
height: 100%;
|
||||
|
||||
.widget-layout {
|
||||
flex: 1 1 100%;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.options-bar {
|
||||
height: 32px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.sections {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { isWidgetColumn, isWidgetRow, isWidgetSection, type WidgetLayout } from "@/wasm-communication/messages";
|
||||
|
||||
import WidgetSection from "@/components/widgets/groups/WidgetSection.svelte";
|
||||
import WidgetRow from "@/components/widgets/WidgetRow.svelte";
|
||||
|
||||
export let layout: WidgetLayout;
|
||||
let className = "";
|
||||
export { className as class };
|
||||
export let classes: Record<string, boolean> = {};
|
||||
|
||||
$: extraClasses = Object.entries(classes)
|
||||
.flatMap((classAndState) => (classAndState[1] ? [classAndState[0]] : []))
|
||||
.join(" ");
|
||||
</script>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<style lang="scss" global>
|
||||
.widget-layout {
|
||||
height: 100%;
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,235 @@
|
||||
<script lang="ts">
|
||||
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 PivotAssist from "@/components/widgets/assists/PivotAssist.svelte";
|
||||
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";
|
||||
|
||||
const SUFFIX_WIDGETS = ["PopoverButton"];
|
||||
|
||||
const editor = getContext<Editor>("editor");
|
||||
|
||||
export let widgetData: WidgetColumn | WidgetRow;
|
||||
export let layoutTarget: any;
|
||||
|
||||
$: direction = watchDirection(widgetData);
|
||||
$: widgets = watchWidgets(widgetData);
|
||||
$: widgetsAndNextSiblingIsSuffix = watchWidgetsAndNextSiblingIsSuffix(widgets);
|
||||
|
||||
function watchDirection(widgetData: WidgetRow | WidgetColumn): "row" | "column" | "ERROR" {
|
||||
if (isWidgetRow(widgetData)) return "row";
|
||||
if (isWidgetColumn(widgetData)) return "column";
|
||||
return "ERROR";
|
||||
}
|
||||
|
||||
function watchWidgets(widgetData: WidgetRow | WidgetColumn): Widget[] {
|
||||
let widgets: Widget[] = [];
|
||||
if (isWidgetRow(widgetData)) widgets = widgetData.rowWidgets;
|
||||
else if (isWidgetColumn(widgetData)) widgets = widgetData.columnWidgets;
|
||||
return widgets;
|
||||
}
|
||||
|
||||
function watchWidgetsAndNextSiblingIsSuffix(widgets: Widget[]): [Widget, boolean][] {
|
||||
return widgets.map((widget, index): [Widget, boolean] => {
|
||||
// A suffix widget is one that joins up with this widget at the end with only a 1px gap.
|
||||
// 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(widgets[index + 1]?.props.kind);
|
||||
|
||||
return [widget, nextSiblingIsSuffix];
|
||||
});
|
||||
}
|
||||
|
||||
function updateLayout(index: number, value: unknown) {
|
||||
editor.instance.updateLayout(layoutTarget, widgets[index].widgetId, value);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
// function exclude<T extends Record<string, any>>(props: T, additional?: (keyof T)[]): Pick<T, Exclude<keyof T, "kind" | (typeof additional extends Array<infer K> ? K : never)>> {
|
||||
// const exclusions = ["kind", ...(additional || [])];
|
||||
|
||||
// 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>
|
||||
|
||||
<!-- TODO: Refactor this component to use `<component :is="" v-bind="attributesObject"></component>` to avoid all the separate components with `v-if` -->
|
||||
<!-- TODO: Also rename this component, and probably move the `widget-${direction}` wrapper to be part of `WidgetLayout.svelte` as part of its refactor -->
|
||||
|
||||
<div class={`widget-${direction}`}>
|
||||
{#each widgetsAndNextSiblingIsSuffix as [component, nextIsSuffix], index (index)}
|
||||
{@const checkboxInput = narrowWidgetProps(component.props, "CheckboxInput")}
|
||||
{#if checkboxInput}
|
||||
<CheckboxInput {...exclude(checkboxInput)} on:checked={({ detail }) => updateLayout(index, detail)} />
|
||||
{/if}
|
||||
{@const colorInput = narrowWidgetProps(component.props, "ColorInput")}
|
||||
{#if colorInput}
|
||||
<ColorInput {...exclude(colorInput)} on:value={({ detail }) => updateLayout(index, detail)} sharpRightCorners={nextIsSuffix} />
|
||||
{/if}
|
||||
{@const dropdownInput = narrowWidgetProps(component.props, "DropdownInput")}
|
||||
{#if dropdownInput}
|
||||
<DropdownInput {...exclude(dropdownInput)} on:selectedIndex={({ detail }) => updateLayout(index, detail)} sharpRightCorners={nextIsSuffix} />
|
||||
{/if}
|
||||
{@const fontInput = narrowWidgetProps(component.props, "FontInput")}
|
||||
{#if fontInput}
|
||||
<FontInput {...exclude(fontInput)} on:changeFont={({ detail }) => updateLayout(index, detail)} sharpRightCorners={nextIsSuffix} />
|
||||
{/if}
|
||||
{@const parameterExposeButton = narrowWidgetProps(component.props, "ParameterExposeButton")}
|
||||
{#if parameterExposeButton}
|
||||
<ParameterExposeButton {...exclude(parameterExposeButton)} action={() => updateLayout(index, undefined)} />
|
||||
{/if}
|
||||
{@const iconButton = narrowWidgetProps(component.props, "IconButton")}
|
||||
{#if iconButton}
|
||||
<IconButton {...exclude(iconButton)} action={() => updateLayout(index, undefined)} sharpRightCorners={nextIsSuffix} />
|
||||
{/if}
|
||||
{@const iconLabel = narrowWidgetProps(component.props, "IconLabel")}
|
||||
{#if iconLabel}
|
||||
<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
|
||||
{...exclude(numberInput)}
|
||||
on:value={({ detail }) => debouncer(() => updateLayout(index, detail))}
|
||||
incrementCallbackIncrease={() => updateLayout(index, "Increment")}
|
||||
incrementCallbackDecrease={() => updateLayout(index, "Decrement")}
|
||||
sharpRightCorners={nextIsSuffix}
|
||||
/>
|
||||
{/if}
|
||||
{@const optionalInput = narrowWidgetProps(component.props, "OptionalInput")}
|
||||
{#if optionalInput}
|
||||
<OptionalInput {...exclude(optionalInput)} on:checked={({ detail }) => updateLayout(index, detail)} />
|
||||
{/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>
|
||||
{/if}
|
||||
{@const radioInput = narrowWidgetProps(component.props, "RadioInput")}
|
||||
{#if radioInput}
|
||||
<RadioInput {...exclude(radioInput)} on:selectedIndex={({ detail }) => updateLayout(index, detail)} sharpRightCorners={nextIsSuffix} />
|
||||
{/if}
|
||||
{@const separator = narrowWidgetProps(component.props, "Separator")}
|
||||
{#if separator}
|
||||
<Separator {...exclude(separator)} />
|
||||
{/if}
|
||||
{@const swatchPairInput = narrowWidgetProps(component.props, "SwatchPairInput")}
|
||||
{#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" global>
|
||||
.widget-column {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
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
|
||||
.checkbox-input [title="Preserve Aspect Ratio"] {
|
||||
margin-bottom: -32px;
|
||||
position: relative;
|
||||
|
||||
&::before,
|
||||
&::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>
|
||||
@@ -0,0 +1,116 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
import { type PivotPosition } from "@/wasm-communication/messages";
|
||||
|
||||
// emits: ["update:position"],
|
||||
const dispatch = createEventDispatcher<{ position: PivotPosition }>();
|
||||
|
||||
export let position: string;
|
||||
export let disabled = false;
|
||||
|
||||
function setPosition(newPosition: PivotPosition) {
|
||||
dispatch("position", newPosition);
|
||||
}
|
||||
</script>
|
||||
|
||||
<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 on:click={() => setPosition("TopCenter")} class="row-1 col-2" class:active={position === "TopCenter"} tabindex="-1" {disabled}><div /></button>
|
||||
<button on:click={() => setPosition("TopRight")} class="row-1 col-3" class:active={position === "TopRight"} tabindex="-1" {disabled}><div /></button>
|
||||
<button on:click={() => setPosition("CenterLeft")} class="row-2 col-1" class:active={position === "CenterLeft"} tabindex="-1" {disabled}><div /></button>
|
||||
<button on:click={() => setPosition("Center")} class="row-2 col-2" class:active={position === "Center"} tabindex="-1" {disabled}><div /></button>
|
||||
<button on:click={() => setPosition("CenterRight")} class="row-2 col-3" class:active={position === "CenterRight"} tabindex="-1" {disabled}><div /></button>
|
||||
<button on:click={() => setPosition("BottomLeft")} class="row-3 col-1" class:active={position === "BottomLeft"} tabindex="-1" {disabled}><div /></button>
|
||||
<button on:click={() => setPosition("BottomCenter")} class="row-3 col-2" class:active={position === "BottomCenter"} tabindex="-1" {disabled}><div /></button>
|
||||
<button on:click={() => setPosition("BottomRight")} class="row-3 col-3" class:active={position === "BottomRight"} tabindex="-1" {disabled}><div /></button>
|
||||
</div>
|
||||
|
||||
<style lang="scss" global>
|
||||
.pivot-assist {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
--pivot-border-color: var(--color-5-dullgray);
|
||||
--pivot-fill-active: var(--color-e-nearwhite);
|
||||
|
||||
button {
|
||||
position: absolute;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--color-1-nearblack);
|
||||
border: 1px solid var(--pivot-border-color);
|
||||
|
||||
&.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;
|
||||
background: var(--color-6-lowergray);
|
||||
}
|
||||
|
||||
&.disabled button {
|
||||
--pivot-border-color: var(--color-4-dimgray);
|
||||
--pivot-fill-active: var(--color-8-uppergray);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script lang="ts">
|
||||
import LayoutRow from "@/components/layout/LayoutRow.svelte";
|
||||
import TextButton from "@/components/widgets/buttons/TextButton.svelte";
|
||||
|
||||
export let labels: string[];
|
||||
export let disabled = false;
|
||||
export let tooltip: string | undefined = undefined;
|
||||
// Callbacks
|
||||
export let action: (index: number) => void;
|
||||
</script>
|
||||
|
||||
<LayoutRow class="breadcrumb-trail-buttons" {tooltip}>
|
||||
{#each labels as label, index (index)}
|
||||
<TextButton {label} emphasized={index === labels.length - 1} {disabled} action={() => !disabled && index !== labels.length - 1 && action(index)} />
|
||||
{/each}
|
||||
</LayoutRow>
|
||||
|
||||
<style lang="scss" global>
|
||||
.breadcrumb-trail-buttons {
|
||||
.text-button {
|
||||
position: relative;
|
||||
|
||||
&:not(:first-of-type) {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -4px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
border-width: 12px 0 12px 4px;
|
||||
border-color: var(--button-background-color) var(--button-background-color) var(--button-background-color) transparent;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:last-of-type) {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -4px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
border-width: 12px 0 12px 4px;
|
||||
border-color: transparent transparent transparent var(--button-background-color);
|
||||
}
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
// Make this non-functional button not change color on hover
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,104 @@
|
||||
<script lang="ts">
|
||||
import { type IconName, type IconSize } from "@/utility-functions/icons";
|
||||
|
||||
import IconLabel from "@/components/widgets/labels/IconLabel.svelte";
|
||||
|
||||
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;
|
||||
|
||||
let className = "";
|
||||
export { className as class };
|
||||
export let classes: Record<string, boolean> = {};
|
||||
|
||||
$: extraClasses = Object.entries(classes)
|
||||
.flatMap((classAndState) => (classAndState[1] ? [classAndState[0]] : []))
|
||||
.join(" ");
|
||||
</script>
|
||||
|
||||
<button
|
||||
class={`icon-button size-${size} ${className} ${extraClasses}`.trim()}
|
||||
class:disabled
|
||||
class:active
|
||||
class:sharp-right-corners={sharpRightCorners}
|
||||
on:click={action}
|
||||
{disabled}
|
||||
title={tooltip}
|
||||
tabindex={active ? -1 : 0}
|
||||
{...$$restProps}
|
||||
>
|
||||
<IconLabel {icon} />
|
||||
</button>
|
||||
|
||||
<style lang="scss" global>
|
||||
.icon-button {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
import LayoutRow from "@/components/layout/LayoutRow.svelte";
|
||||
|
||||
export let exposed: boolean;
|
||||
export let dataType: string;
|
||||
export let tooltip: string | undefined = undefined;
|
||||
// Callbacks
|
||||
export let action: (e?: MouseEvent) => void;
|
||||
</script>
|
||||
|
||||
<LayoutRow class="parameter-expose-button">
|
||||
<button class:exposed style:--data-type-color={`var(--color-data-${dataType})`} on:click={action} title={tooltip} tabindex="0" />
|
||||
</LayoutRow>
|
||||
|
||||
<style lang="scss" global>
|
||||
.parameter-expose-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
max-height: 24px;
|
||||
|
||||
button {
|
||||
flex: 0 0 auto;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
|
||||
&:not(.exposed) {
|
||||
background: none;
|
||||
border: 1px solid var(--data-type-color);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-6-lowergray);
|
||||
}
|
||||
}
|
||||
|
||||
&.exposed {
|
||||
background: var(--data-type-color);
|
||||
|
||||
&:hover {
|
||||
border: 1px solid var(--color-f-white);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,71 @@
|
||||
<script lang="ts">
|
||||
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";
|
||||
|
||||
export let icon: IconName = "DropdownArrow";
|
||||
export let tooltip: string | undefined = undefined;
|
||||
export let disabled = false;
|
||||
// Callbacks
|
||||
export let action: (() => void) | undefined = undefined;
|
||||
|
||||
let open = false;
|
||||
|
||||
function onClick() {
|
||||
open = true;
|
||||
action?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<LayoutRow class="popover-button">
|
||||
<IconButton classes={{ open }} {disabled} action={() => onClick()} icon={icon || "DropdownArrow"} size={16} {tooltip} data-floating-menu-spawner />
|
||||
<FloatingMenu {open} on:open={({ detail }) => (open = detail)} type="Popover" direction="Bottom">
|
||||
<slot />
|
||||
</FloatingMenu>
|
||||
</LayoutRow>
|
||||
|
||||
<style lang="scss" global>
|
||||
.popover-button {
|
||||
position: relative;
|
||||
width: 16px;
|
||||
height: 24px;
|
||||
flex: 0 0 auto;
|
||||
|
||||
.floating-menu {
|
||||
left: 50%;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.icon-button.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 {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,88 @@
|
||||
<script lang="ts">
|
||||
import { type IconName } from "@/utility-functions/icons";
|
||||
|
||||
import IconLabel from "@/components/widgets/labels/IconLabel.svelte";
|
||||
import TextLabel from "@/components/widgets/labels/TextLabel.svelte";
|
||||
|
||||
export let label: string;
|
||||
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;
|
||||
|
||||
// Callbacks
|
||||
// TODO: Replace this with an event binding (and on other components that do this)
|
||||
export let action: (e: MouseEvent) => void;
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="text-button"
|
||||
class:emphasized
|
||||
class:disabled
|
||||
class:sharp-right-corners={sharpRightCorners}
|
||||
style:min-width={minWidth > 0 ? `${minWidth}px` : undefined}
|
||||
title={tooltip}
|
||||
data-emphasized={emphasized || undefined}
|
||||
data-disabled={disabled || undefined}
|
||||
data-text-button
|
||||
tabindex={disabled ? -1 : 0}
|
||||
on:click={action}
|
||||
>
|
||||
{#if icon}
|
||||
<IconLabel {icon} />
|
||||
{/if}
|
||||
<TextLabel>{label}</TextLabel>
|
||||
</button>
|
||||
|
||||
<style lang="scss" global>
|
||||
.text-button {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
height: 24px;
|
||||
margin: 0;
|
||||
padding: 0 8px;
|
||||
box-sizing: border-box;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
background: var(--button-background-color);
|
||||
color: var(--button-text-color);
|
||||
--button-background-color: var(--color-5-dullgray);
|
||||
--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 {
|
||||
--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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,147 @@
|
||||
<script lang="ts">
|
||||
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";
|
||||
|
||||
const editor = getContext<Editor>("editor");
|
||||
|
||||
export let widgetData: WidgetSectionFromJsMessages;
|
||||
export let layoutTarget: any; // TODO: Give type
|
||||
|
||||
let expanded = true;
|
||||
</script>
|
||||
|
||||
<!-- TODO: Implement collapsable sections with properties system -->
|
||||
<LayoutCol class="widget-section">
|
||||
<button class="header" class:expanded on:click|stopPropagation={() => (expanded = !expanded)} tabindex="0">
|
||||
<div class="expand-arrow" />
|
||||
<TextLabel bold={true}>{widgetData.name}</TextLabel>
|
||||
</button>
|
||||
{#if expanded}
|
||||
<LayoutCol class="body">
|
||||
{#each widgetData.layout as layoutGroup, index (index)}
|
||||
{#if isWidgetRow(layoutGroup)}
|
||||
<WidgetRow widgetData={layoutGroup} {layoutTarget} />
|
||||
{:else if isWidgetSection(layoutGroup)}
|
||||
<svelte:self widgetData={layoutGroup} {layoutTarget} />
|
||||
{:else}
|
||||
<span style="color: #d6536e">Error: The widget that belongs here has an invalid layout group type</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</LayoutCol>
|
||||
{/if}
|
||||
</LayoutCol>
|
||||
|
||||
<style lang="scss" global>
|
||||
.widget-section {
|
||||
flex: 0 0 auto;
|
||||
|
||||
.header {
|
||||
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;
|
||||
justify-content: center;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: var(--icon-expand-collapse-arrow);
|
||||
}
|
||||
}
|
||||
|
||||
&.expanded {
|
||||
border-radius: 4px 4px 0 0;
|
||||
margin-bottom: 0;
|
||||
|
||||
.expand-arrow::after {
|
||||
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 {
|
||||
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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,109 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
import { type IconName } from "@/utility-functions/icons";
|
||||
|
||||
import LayoutRow from "@/components/layout/LayoutRow.svelte";
|
||||
import IconLabel from "@/components/widgets/labels/IconLabel.svelte";
|
||||
|
||||
// emits: ["update:checked"],
|
||||
const dispatch = createEventDispatcher<{ checked: boolean }>();
|
||||
|
||||
export let checked = false;
|
||||
export let disabled = false;
|
||||
export let icon: IconName = "Checkmark";
|
||||
export let tooltip: string | undefined = undefined;
|
||||
|
||||
let inputElement: HTMLInputElement;
|
||||
let id = `${Math.random()}`.substring(2);
|
||||
|
||||
$: displayIcon = (!checked && icon === "Checkmark" ? "Empty12px" : icon) as IconName;
|
||||
|
||||
export function isChecked() {
|
||||
return checked;
|
||||
}
|
||||
|
||||
export function input(): HTMLInputElement {
|
||||
return inputElement;
|
||||
}
|
||||
|
||||
function toggleCheckboxFromLabel(e: KeyboardEvent) {
|
||||
const target = (e.target || undefined) as HTMLLabelElement | undefined;
|
||||
const previousSibling = (target?.previousSibling || undefined) as HTMLInputElement | undefined;
|
||||
previousSibling?.click();
|
||||
}
|
||||
</script>
|
||||
|
||||
<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} />
|
||||
<label class:disabled class:checked for={`checkbox-input-${id}`} on:keydown={(e) => e.key === "Enter" && toggleCheckboxFromLabel(e)} title={tooltip}>
|
||||
<LayoutRow class="checkbox-box">
|
||||
<IconLabel icon={displayIcon} />
|
||||
</LayoutRow>
|
||||
</label>
|
||||
</LayoutRow>
|
||||
|
||||
<style lang="scss" global>
|
||||
.checkbox-input {
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
|
||||
input {
|
||||
// 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;
|
||||
height: 0;
|
||||
margin: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
// Unchecked
|
||||
label {
|
||||
display: flex;
|
||||
height: 16px;
|
||||
// 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;
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
// Checked
|
||||
input:checked + label {
|
||||
.checkbox-box {
|
||||
background: var(--color-e-nearwhite);
|
||||
|
||||
.icon-label {
|
||||
fill: var(--color-2-mildblack);
|
||||
}
|
||||
}
|
||||
|
||||
// Hovered
|
||||
&:hover .checkbox-box {
|
||||
background: var(--color-f-white);
|
||||
}
|
||||
|
||||
// Hovered
|
||||
&.disabled .checkbox-box {
|
||||
background: var(--color-8-uppergray);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,104 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
import { Color } from "@/wasm-communication/messages";
|
||||
|
||||
import ColorPicker from "@/components/floating-menus/ColorPicker.svelte";
|
||||
import LayoutRow from "@/components/layout/LayoutRow.svelte";
|
||||
import TextLabel from "@/components/widgets/labels/TextLabel.svelte";
|
||||
|
||||
// emits: ["update:value"],
|
||||
const dispatch = createEventDispatcher<{ value: Color }>();
|
||||
|
||||
let open = false;
|
||||
|
||||
export let value: Color;
|
||||
export let noTransparency = false; // TODO: Rename to allowTransparency, also implement allowNone
|
||||
export let disabled = false; // TODO: Design and implement
|
||||
export let tooltip: string | undefined = undefined;
|
||||
export let sharpRightCorners = false;
|
||||
|
||||
// TODO: Implement
|
||||
$: chip = undefined;
|
||||
</script>
|
||||
|
||||
<LayoutRow class="color-input" classes={{ "sharp-right-corners": sharpRightCorners }} {tooltip}>
|
||||
<button
|
||||
class:none={value.none}
|
||||
class:sharp-right-corners={sharpRightCorners}
|
||||
style:--chosen-color={value.toHexOptionalAlpha()}
|
||||
on:click={() => (open = true)}
|
||||
tabindex="0"
|
||||
data-floating-menu-spawner
|
||||
>
|
||||
{#if chip}
|
||||
<TextLabel class="chip" bold={true}>{chip}</TextLabel>
|
||||
{/if}
|
||||
</button>
|
||||
<ColorPicker {open} on:open={({ detail }) => (open = detail)} color={value} on:color={({ detail }) => dispatch("value", detail)} allowNone={true} />
|
||||
</LayoutRow>
|
||||
|
||||
<style lang="scss" global>
|
||||
.color-input {
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
border: 1px solid var(--color-5-dullgray);
|
||||
border-radius: 2px;
|
||||
padding: 1px;
|
||||
|
||||
> button {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 1px;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
&.color-input.color-input > button {
|
||||
outline-offset: 0;
|
||||
}
|
||||
|
||||
> .floating-menu {
|
||||
left: 50%;
|
||||
bottom: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,163 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
import { type MenuListEntry } from "@/wasm-communication/messages";
|
||||
|
||||
import MenuList from "@/components/floating-menus/MenuList.svelte";
|
||||
import LayoutRow from "@/components/layout/LayoutRow.svelte";
|
||||
import IconLabel from "@/components/widgets/labels/IconLabel.svelte";
|
||||
import TextLabel from "@/components/widgets/labels/TextLabel.svelte";
|
||||
|
||||
const DASH_ENTRY = { label: "-" };
|
||||
|
||||
// emits: ["update:selectedIndex"],
|
||||
const dispatch = createEventDispatcher<{ selectedIndex: number }>();
|
||||
|
||||
let menuList: MenuList;
|
||||
let self: LayoutRow;
|
||||
|
||||
export let entries: MenuListEntry[][];
|
||||
export let selectedIndex: number | undefined = undefined; // When not provided, a dash is displayed
|
||||
export let drawIcon = false;
|
||||
export let interactive = true;
|
||||
export let disabled = false;
|
||||
export let tooltip: string | undefined = undefined;
|
||||
export let sharpRightCorners = false;
|
||||
|
||||
let activeEntry = makeActiveEntry();
|
||||
let activeEntrySkipWatcher = false;
|
||||
let open = false;
|
||||
let minWidth = 0;
|
||||
|
||||
$: selectedIndex, watchSelectedIndex();
|
||||
$: watchActiveEntry(activeEntry);
|
||||
|
||||
// Called only when `selectedIndex` is changed from outside this component (with v-model)
|
||||
function watchSelectedIndex() {
|
||||
activeEntrySkipWatcher = true;
|
||||
activeEntry = 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)
|
||||
function watchActiveEntry(activeEntry: MenuListEntry) {
|
||||
if (activeEntrySkipWatcher) {
|
||||
activeEntrySkipWatcher = false;
|
||||
} 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>
|
||||
|
||||
<LayoutRow class="dropdown-input" bind:this={self} data-dropdown-input>
|
||||
<LayoutRow
|
||||
class="dropdown-box"
|
||||
classes={{ disabled, open, "sharp-right-corners": sharpRightCorners }}
|
||||
styles={{ minWidth: `${minWidth}px` }}
|
||||
{tooltip}
|
||||
on:click={() => !disabled && (open = true)}
|
||||
on:blur={unFocusDropdownBox}
|
||||
on:keydown={(e) => menuList.keydown(e, false)}
|
||||
tabindex={disabled ? -1 : 0}
|
||||
data-floating-menu-spawner
|
||||
>
|
||||
{#if activeEntry.icon}
|
||||
<IconLabel class="dropdown-icon" icon={activeEntry.icon} />
|
||||
{/if}
|
||||
<TextLabel class="dropdown-label">{activeEntry.label}</TextLabel>
|
||||
<IconLabel class="dropdown-arrow" icon="DropdownArrow" />
|
||||
</LayoutRow>
|
||||
<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" global>
|
||||
.dropdown-input {
|
||||
position: relative;
|
||||
|
||||
.dropdown-box {
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
background: var(--color-1-nearblack);
|
||||
height: 24px;
|
||||
border-radius: 2px;
|
||||
|
||||
.dropdown-label {
|
||||
margin: 0;
|
||||
margin-left: 8px;
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.dropdown-icon {
|
||||
margin: 4px;
|
||||
flex: 0 0 auto;
|
||||
|
||||
& + .dropdown-label {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.menu-list .floating-menu-container .floating-menu-content {
|
||||
max-height: 400px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,192 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
import { platformIsMac } from "@/utility-functions/platform";
|
||||
|
||||
import LayoutRow from "@/components/layout/LayoutRow.svelte";
|
||||
|
||||
// emits: ["update:value", "textFocused", "textChanged", "cancelTextChange"],
|
||||
const dispatch = createEventDispatcher<{
|
||||
value: string;
|
||||
textFocused: undefined;
|
||||
textChanged: undefined;
|
||||
cancelTextChange: undefined;
|
||||
}>();
|
||||
|
||||
let className = "";
|
||||
export { className as class };
|
||||
export let classes: Record<string, boolean> = {};
|
||||
let styleName = "";
|
||||
export { styleName as style };
|
||||
export let styles: Record<string, string | number | undefined> = {};
|
||||
export let value: string;
|
||||
export let label: string | undefined = undefined;
|
||||
export let spellcheck = false;
|
||||
export let disabled = false;
|
||||
export let textarea = false;
|
||||
export let tooltip: string | undefined = undefined;
|
||||
export let sharpRightCorners = false;
|
||||
export let placeholder: string | undefined = undefined;
|
||||
|
||||
let input: HTMLInputElement | HTMLTextAreaElement;
|
||||
let id = `${Math.random()}`.substring(2);
|
||||
let macKeyboardLayout = platformIsMac();
|
||||
let inputValue = value;
|
||||
|
||||
$: dispatch("value", inputValue);
|
||||
|
||||
// Select (highlight) all the text. For technical reasons, it is necessary to pass the current text.
|
||||
// TODO: Svelte: Test if the above message is still true
|
||||
export function selectAllText(currentText: string) {
|
||||
// Setting the value directly is required to make `input.select()` work
|
||||
// TODO: Svelte: Test if the above message is still true
|
||||
input.value = currentText;
|
||||
input.select();
|
||||
}
|
||||
|
||||
export function unFocus() {
|
||||
input.blur();
|
||||
}
|
||||
|
||||
export function getInputElementValue(): string {
|
||||
return input.value;
|
||||
}
|
||||
|
||||
export function setInputElementValue(value: string) {
|
||||
input.value = value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- This is a base component, extended by others like NumberInput and TextInput. It should not be used directly. -->
|
||||
<LayoutRow class={`field-input ${className}`} classes={{ disabled, "sharp-right-corners": sharpRightCorners, ...classes }} style={styleName} {styles} {tooltip}>
|
||||
{#if !textarea}
|
||||
<input
|
||||
type="text"
|
||||
class:has-label={label}
|
||||
id={`field-input-${id}`}
|
||||
{spellcheck}
|
||||
{disabled}
|
||||
{placeholder}
|
||||
bind:value={inputValue}
|
||||
bind:this={input}
|
||||
on:focus={() => dispatch("textFocused")}
|
||||
on:blur={() => dispatch("textChanged")}
|
||||
on:change={() => dispatch("textChanged")}
|
||||
on:keydown={(e) => e.key === "Enter" && dispatch("textChanged")}
|
||||
on:keydown={(e) => e.key === "Escape" && dispatch("cancelTextChange")}
|
||||
data-input-element
|
||||
/>
|
||||
{:else}
|
||||
<textarea
|
||||
class:has-label={label}
|
||||
id={`field-input-${id}`}
|
||||
class="scrollable-y"
|
||||
data-scrollable-y
|
||||
{spellcheck}
|
||||
{disabled}
|
||||
bind:value={inputValue}
|
||||
bind:this={input}
|
||||
on:focus={() => dispatch("textFocused")}
|
||||
on:blur={() => dispatch("textChanged")}
|
||||
on:change={() => dispatch("textChanged")}
|
||||
on:keydown={(e) => (macKeyboardLayout ? e.metaKey : e.ctrlKey) && e.key === "Enter" && dispatch("textChanged")}
|
||||
on:keydown={(e) => e.key === "Escape" && dispatch("cancelTextChange")}
|
||||
/>
|
||||
{/if}
|
||||
{#if label}
|
||||
<label for={`field-input-${id}`}>{label}</label>
|
||||
{/if}
|
||||
<slot />
|
||||
</LayoutRow>
|
||||
|
||||
<style lang="scss" global>
|
||||
.field-input {
|
||||
min-width: 80px;
|
||||
height: auto;
|
||||
position: relative;
|
||||
border-radius: 2px;
|
||||
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;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&:not(.disabled) label {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
input,
|
||||
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);
|
||||
|
||||
&::selection {
|
||||
background-color: var(--color-5-dullgray);
|
||||
|
||||
// Target only Safari
|
||||
@supports (background: -webkit-named-image(i)) {
|
||||
& {
|
||||
// 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 {
|
||||
// text-align: center;
|
||||
|
||||
&:not(:focus).has-label {
|
||||
text-align: right;
|
||||
margin-left: 0;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
text-align: left;
|
||||
|
||||
& + label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -0,0 +1,191 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, getContext, onMount, tick } from "svelte";
|
||||
|
||||
import { type MenuListEntry } from "@/wasm-communication/messages";
|
||||
|
||||
import MenuList from "@/components/floating-menus/MenuList.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 { type FontsState } from "@/state-providers/fonts";
|
||||
|
||||
const fonts = getContext<FontsState>("fonts");
|
||||
|
||||
// emits: ["update:fontFamily", "update:fontStyle", "changeFont"],
|
||||
const dispatch = createEventDispatcher<{
|
||||
fontFamily: string;
|
||||
fontStyle: string;
|
||||
changeFont: { fontFamily: string; fontStyle: string; fontFileUrl: string | undefined };
|
||||
}>();
|
||||
|
||||
let menuList: MenuList;
|
||||
|
||||
export let fontFamily: string;
|
||||
export let fontStyle: string;
|
||||
export let isStyle = false;
|
||||
export let disabled = false;
|
||||
export let tooltip: string | undefined = undefined;
|
||||
export let sharpRightCorners = false;
|
||||
|
||||
let open = false;
|
||||
let entries: MenuListEntry[] = [];
|
||||
let activeEntry: MenuListEntry | undefined = undefined;
|
||||
let highlighted: MenuListEntry | undefined = undefined;
|
||||
let minWidth = isStyle ? 0 : 300;
|
||||
|
||||
$: fontFamily,
|
||||
(async () => {
|
||||
entries = await getEntries();
|
||||
activeEntry = getActiveEntry(entries);
|
||||
highlighted = activeEntry;
|
||||
})();
|
||||
$: fontStyle,
|
||||
async () => {
|
||||
entries = await getEntries();
|
||||
activeEntry = getActiveEntry(entries);
|
||||
highlighted = activeEntry;
|
||||
};
|
||||
|
||||
async function setOpen(): Promise<void> {
|
||||
open = true;
|
||||
|
||||
// Scroll to the active entry (the scroller div does not yet exist so we must wait for the component to render)
|
||||
await tick();
|
||||
|
||||
if (activeEntry) {
|
||||
const index = entries.indexOf(activeEntry);
|
||||
menuList.scrollViewTo(Math.max(0, index * 20 - 190));
|
||||
}
|
||||
}
|
||||
|
||||
function toggleOpen(): void {
|
||||
if (!disabled) {
|
||||
open = !open;
|
||||
|
||||
if (open) setOpen();
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
highlighted = activeEntry;
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- TODO: Combine this widget into the DropdownInput widget -->
|
||||
<LayoutRow class="font-input">
|
||||
<LayoutRow
|
||||
class="dropdown-box"
|
||||
classes={{ disabled, "sharp-right-corners": sharpRightCorners }}
|
||||
styles={{ minWidth: `${minWidth}px` }}
|
||||
{tooltip}
|
||||
tabindex={disabled ? -1 : 0}
|
||||
on:click={toggleOpen}
|
||||
on:keydown={(e) => menuList.keydown(e, false)}
|
||||
data-floating-menu-spawner
|
||||
>
|
||||
<TextLabel class="dropdown-label">{activeEntry?.value || ""}</TextLabel>
|
||||
<IconLabel class="dropdown-arrow" icon="DropdownArrow" />
|
||||
</LayoutRow>
|
||||
<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" global>
|
||||
.font-input {
|
||||
position: relative;
|
||||
|
||||
.dropdown-box {
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
background: var(--color-1-nearblack);
|
||||
height: 24px;
|
||||
border-radius: 2px;
|
||||
|
||||
.dropdown-label {
|
||||
margin: 0;
|
||||
margin-left: 8px;
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.dropdown-arrow {
|
||||
margin: 6px 2px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&.open {
|
||||
background: var(--color-6-lowergray);
|
||||
|
||||
span {
|
||||
color: var(--color-f-white);
|
||||
}
|
||||
}
|
||||
|
||||
&.open {
|
||||
border-radius: 2px 2px 0 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>
|
||||
@@ -0,0 +1,142 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
import { currentDraggingElement } from "@/io-managers/drag";
|
||||
|
||||
import type { LayerType, LayerTypeData } from "@/wasm-communication/messages";
|
||||
import { layerTypeData } from "@/wasm-communication/messages";
|
||||
|
||||
import LayoutRow from "@/components/layout/LayoutRow.svelte";
|
||||
import IconButton from "@/components/widgets/buttons/IconButton.svelte";
|
||||
import IconLabel from "@/components/widgets/labels/IconLabel.svelte";
|
||||
import TextLabel from "@/components/widgets/labels/TextLabel.svelte";
|
||||
|
||||
// emits: ["update:value"],
|
||||
const dispatch = createEventDispatcher<{ value: string | undefined }>();
|
||||
|
||||
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();
|
||||
|
||||
dispatch("value", layerPath);
|
||||
}
|
||||
}
|
||||
|
||||
function getLayerTypeData(layerType: LayerType): LayerTypeData {
|
||||
return layerTypeData(layerType) || { name: "Error", icon: "Info" };
|
||||
}
|
||||
</script>
|
||||
|
||||
<LayoutRow
|
||||
class="layer-reference-input"
|
||||
classes={{ disabled, droppable, "sharp-right-corners": sharpRightCorners }}
|
||||
{tooltip}
|
||||
on:dragover={(e) => !disabled && dragOver(e)}
|
||||
on:dragleave={() => !disabled && (hoveringDrop = false)}
|
||||
on:drop={(e) => !disabled && drop(e)}
|
||||
>
|
||||
{#if value === undefined || droppable}
|
||||
<LayoutRow class="drop-zone" />
|
||||
<TextLabel italic={true}>{droppable ? "Drop" : "Drag"} Layer Here</TextLabel>
|
||||
{:else}
|
||||
{#if layerName !== undefined && layerType}
|
||||
<IconLabel icon={getLayerTypeData(layerType).icon} class="layer-icon" />
|
||||
<TextLabel italic={layerName === ""} class="layer-name">{layerName || getLayerTypeData(layerType).name}</TextLabel>
|
||||
{:else}
|
||||
<TextLabel bold={true} italic={true} class="missing">Layer Missing</TextLabel>
|
||||
{/if}
|
||||
<IconButton icon="CloseX" size={16} {disabled} action={() => dispatch("value", undefined)} />
|
||||
{/if}
|
||||
</LayoutRow>
|
||||
|
||||
<style lang="scss" global>
|
||||
.layer-reference-input {
|
||||
position: relative;
|
||||
flex: 1 0 auto;
|
||||
height: 24px;
|
||||
border-radius: 2px;
|
||||
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 (search the project for all uses of this hex code)
|
||||
color: #d6536e;
|
||||
}
|
||||
|
||||
&.layer-name {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
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>
|
||||
@@ -0,0 +1,140 @@
|
||||
<script lang="ts">
|
||||
import { getContext, onMount } from "svelte";
|
||||
|
||||
import { platformIsMac } from "@/utility-functions/platform";
|
||||
import { type KeyRaw, type LayoutKeysGroup, type MenuBarEntry, type MenuListEntry, UpdateMenuBarLayout } from "@/wasm-communication/messages";
|
||||
|
||||
import MenuList from "@/components/floating-menus/MenuList.svelte";
|
||||
import IconLabel from "@/components/widgets/labels/IconLabel.svelte";
|
||||
import TextLabel from "@/components/widgets/labels/TextLabel.svelte";
|
||||
import { type Editor } from "@/wasm-communication/editor";
|
||||
|
||||
// TODO: Apparently, Safari does not support the Keyboard.lock() API but does relax its authority over certain keyboard shortcuts in fullscreen mode, which we should take advantage of
|
||||
const accelKey = platformIsMac() ? "Command" : "Control";
|
||||
const LOCK_REQUIRING_SHORTCUTS: KeyRaw[][] = [
|
||||
[accelKey, "KeyW"],
|
||||
[accelKey, "KeyN"],
|
||||
[accelKey, "Shift", "KeyN"],
|
||||
[accelKey, "KeyT"],
|
||||
[accelKey, "Shift", "KeyT"],
|
||||
];
|
||||
|
||||
const editor = getContext<Editor>("editor");
|
||||
|
||||
let self: HTMLDivElement;
|
||||
let entries: MenuListEntry[] = [];
|
||||
|
||||
function clickEntry(menuListEntry: MenuListEntry, e: MouseEvent) {
|
||||
// If there's no menu to open, trigger the action but don't try to open its non-existant children
|
||||
if (!menuListEntry.children || menuListEntry.children.length === 0) {
|
||||
if (menuListEntry.action && !menuListEntry.disabled) menuListEntry.action();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Focus the target so that keyboard inputs are sent to the dropdown
|
||||
(e.target as HTMLElement | undefined)?.focus();
|
||||
|
||||
if (menuListEntry.ref) menuListEntry.ref.isOpen = true;
|
||||
else throw new Error("The menu bar floating menu has no associated ref");
|
||||
}
|
||||
|
||||
function unFocusEntry(menuListEntry: MenuListEntry, e: FocusEvent) {
|
||||
const blurTarget = (e.target as HTMLElement | undefined)?.closest("[data-menu-bar-input]");
|
||||
if (blurTarget !== self && menuListEntry.ref) menuListEntry.ref.isOpen = false;
|
||||
}
|
||||
|
||||
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 shortcutRequiresLock = (shortcut: LayoutKeysGroup): boolean => {
|
||||
const shortcutKeys = shortcut.map((keyWithLabel) => keyWithLabel.key);
|
||||
|
||||
// If this shortcut matches any of the browser-reserved shortcuts
|
||||
return LOCK_REQUIRING_SHORTCUTS.some((lockKeyCombo) => arraysEqual(shortcutKeys, lockKeyCombo));
|
||||
};
|
||||
|
||||
const menuBarEntryToMenuListEntry = (entry: MenuBarEntry): MenuListEntry => ({
|
||||
// From `MenuEntryCommon`
|
||||
...entry,
|
||||
|
||||
// Shared names with fields that need to be converted from the type used in `MenuBarEntry` to that of `MenuListEntry`
|
||||
action: (): void => editor.instance.updateLayout(updateMenuBarLayout.layoutTarget, entry.action.widgetId, undefined),
|
||||
children: entry.children ? entry.children.map((entries) => entries.map((entry) => menuBarEntryToMenuListEntry(entry))) : undefined,
|
||||
|
||||
// New fields in `MenuListEntry`
|
||||
shortcutRequiresLock: entry.shortcut ? shortcutRequiresLock(entry.shortcut.keys) : undefined,
|
||||
value: undefined,
|
||||
disabled: undefined,
|
||||
font: undefined,
|
||||
ref: undefined,
|
||||
});
|
||||
|
||||
entries = updateMenuBarLayout.layout.map(menuBarEntryToMenuListEntry);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="menu-bar-input" bind:this={self} data-menu-bar-input>
|
||||
{#each entries as entry, index (index)}
|
||||
<div class="entry-container">
|
||||
<div
|
||||
on:click={(e) => clickEntry(entry, e)}
|
||||
on:blur={(e) => unFocusEntry(entry, e)}
|
||||
on:keydown={(e) => entry.ref?.keydown(e, false)}
|
||||
class="entry"
|
||||
class:open={entry.ref?.isOpen}
|
||||
tabindex="0"
|
||||
data-floating-menu-spawner={entry.children && entry.children.length > 0 ? "" : "no-hover-transfer"}
|
||||
>
|
||||
{#if entry.icon}
|
||||
<IconLabel icon={entry.icon} />
|
||||
{/if}
|
||||
{#if entry.label}
|
||||
<TextLabel>{entry.label}</TextLabel>
|
||||
{/if}
|
||||
</div>
|
||||
{#if entry.children && entry.children.length > 0}
|
||||
<MenuList open={entry.ref?.menuIsOpen() || false} entries={entry.children || []} direction="Bottom" minWidth={240} drawIcon={true} bind:this={entry.ref} />
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style lang="scss" global>
|
||||
.menu-bar-input {
|
||||
display: flex;
|
||||
|
||||
.entry-container {
|
||||
display: flex;
|
||||
position: relative;
|
||||
|
||||
.entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
padding: 0 8px;
|
||||
background: none;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
|
||||
svg {
|
||||
fill: var(--color-e-nearwhite);
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&.open {
|
||||
background: var(--color-6-lowergray);
|
||||
|
||||
svg {
|
||||
fill: var(--color-f-white);
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--color-f-white);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,485 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
import { type NumberInputMode, type NumberInputIncrementBehavior } from "@/wasm-communication/messages";
|
||||
|
||||
import FieldInput from "@/components/widgets/inputs/FieldInput.svelte";
|
||||
|
||||
// emits: ["update:value"],
|
||||
const dispatch = createEventDispatcher<{ value: number | undefined }>();
|
||||
|
||||
// Label
|
||||
export let label: string | undefined = undefined;
|
||||
export let tooltip: string | undefined = undefined;
|
||||
|
||||
// Disabled
|
||||
export let disabled = false;
|
||||
|
||||
// Value
|
||||
export let value: number | undefined = undefined; // When not provided, a dash is displayed
|
||||
export let min: number | undefined = undefined;
|
||||
export let max: number | undefined = undefined;
|
||||
export let isInteger = false;
|
||||
|
||||
// Number presentation
|
||||
export let displayDecimalPlaces = 3;
|
||||
export let unit = "";
|
||||
export let unitIsHiddenWhenEditing = true;
|
||||
|
||||
// Mode behavior
|
||||
// "Increment" shows arrows and allows dragging left/right to change the value.
|
||||
// "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;
|
||||
|
||||
// Styling
|
||||
export let minWidth = 0;
|
||||
export let sharpRightCorners = false;
|
||||
|
||||
// Callbacks
|
||||
export let incrementCallbackIncrease: (() => void) | undefined = undefined;
|
||||
export let incrementCallbackDecrease: (() => void) | undefined = undefined;
|
||||
|
||||
let fieldInput: FieldInput;
|
||||
let text = displayText(value);
|
||||
let editing = false;
|
||||
// Stays in sync with a binding to the actual input range slider element.
|
||||
let rangeSliderValue = value !== undefined ? value : 0;
|
||||
// Value used to render the position of the fake slider when applicable, and length of the progress colored region to the slider's left.
|
||||
// This is the same as `rangeSliderValue` except in the "mousedown" state, when it has the previous location before the user's mousedown.
|
||||
let rangeSliderValueAsRendered = value !== undefined ? value : 0;
|
||||
// "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 (with v-model)
|
||||
function watchValue(value: number | undefined) {
|
||||
// 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);
|
||||
}
|
||||
|
||||
function sliderInput() {
|
||||
// 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);
|
||||
}
|
||||
|
||||
function sliderPointerDown() {
|
||||
// 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 sliderPointerUp() {
|
||||
// User clicked but didn't drag, so we focus the text input element
|
||||
if (rangeSliderClickDragState === "mousedown") {
|
||||
const inputElement = fieldInput.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;
|
||||
|
||||
fieldInput.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);
|
||||
|
||||
editing = false;
|
||||
|
||||
fieldInput.unFocus();
|
||||
}
|
||||
|
||||
function onCancelTextChange() {
|
||||
updateValue(undefined);
|
||||
|
||||
editing = false;
|
||||
|
||||
fieldInput.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);
|
||||
},
|
||||
Multiply: () => {
|
||||
const directionMultiplier = direction === "Increase" ? step : 1 / step;
|
||||
updateValue(value !== undefined ? value * directionMultiplier : undefined);
|
||||
},
|
||||
Callback: () => {
|
||||
if (direction === "Increase") incrementCallbackIncrease?.();
|
||||
if (direction === "Decrease") incrementCallbackDecrease?.();
|
||||
},
|
||||
None: () => {},
|
||||
};
|
||||
const action = actions[incrementBehavior];
|
||||
action();
|
||||
}
|
||||
|
||||
function updateValue(newValue: number | undefined) {
|
||||
const nowValid = value !== undefined && isInteger ? Math.round(value) : value;
|
||||
let cleaned = newValue !== undefined ? newValue : nowValid;
|
||||
|
||||
if (typeof min === "number" && !Number.isNaN(min) && cleaned !== undefined) cleaned = Math.max(cleaned, min);
|
||||
if (typeof max === "number" && !Number.isNaN(max) && cleaned !== undefined) cleaned = Math.min(cleaned, max);
|
||||
|
||||
// Required as the call to update:value can, not change the value
|
||||
text = displayText(value);
|
||||
|
||||
if (newValue !== undefined) dispatch("value", cleaned);
|
||||
}
|
||||
|
||||
function 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(displayDecimalPlaces - leftSideDigits, 0);
|
||||
|
||||
const displayValue = Math.round(value * roundingPower) / roundingPower;
|
||||
|
||||
return `${displayValue}${unPluralize(unit, value)}`;
|
||||
}
|
||||
|
||||
function unPluralize(unit: string, value: number): string {
|
||||
if (value === 1 && unit.endsWith("s")) return unit.slice(0, -1);
|
||||
return unit;
|
||||
}
|
||||
</script>
|
||||
|
||||
<FieldInput
|
||||
class={`number-input ${mode.toLocaleLowerCase()}`}
|
||||
value={text}
|
||||
on:value={({ detail }) => (text = detail)}
|
||||
on:textFocused={onTextFocused}
|
||||
on:textChanged={onTextChanged}
|
||||
on:cancelTextChange={onCancelTextChange}
|
||||
{label}
|
||||
{disabled}
|
||||
{tooltip}
|
||||
{sharpRightCorners}
|
||||
spellcheck={false}
|
||||
styles={{ "min-width": minWidth > 0 ? `${minWidth}px` : undefined, "--progress-factor": (rangeSliderValueAsRendered - rangeMin) / (rangeMax - rangeMin) }}
|
||||
bind:this={fieldInput}
|
||||
>
|
||||
{#if value !== undefined && mode === "Increment" && incrementBehavior !== "None"}
|
||||
<button class="arrow left" on:click={() => onIncrement("Decrease")} tabindex="-1" />
|
||||
<button class="arrow right" on:click={() => onIncrement("Increase")} tabindex="-1" />
|
||||
{/if}
|
||||
{#if mode === "Range" && value !== undefined}
|
||||
<input
|
||||
type="range"
|
||||
class="slider"
|
||||
class:hidden={rangeSliderClickDragState === "mousedown"}
|
||||
bind:value={rangeSliderValue}
|
||||
min={rangeMin}
|
||||
max={rangeMax}
|
||||
step={sliderStepValue}
|
||||
{disabled}
|
||||
on:input={sliderInput}
|
||||
on:pointerdown={sliderPointerDown}
|
||||
on:pointerup={sliderPointerUp}
|
||||
tabindex="-1"
|
||||
/>
|
||||
{/if}
|
||||
{#if value !== undefined}
|
||||
{#if value !== undefined && rangeSliderClickDragState === "mousedown"}
|
||||
<div class="fake-slider-thumb" />
|
||||
{/if}
|
||||
<div class="slider-progress" />
|
||||
{/if}
|
||||
</FieldInput>
|
||||
|
||||
<style lang="scss" global>
|
||||
.number-input {
|
||||
input {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&.increment {
|
||||
// Widen the label and input margins from the edges by an extra 8px to make room for the increment arrows
|
||||
label {
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
input[type="text"]:not(:focus).has-label {
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
// Hide the increment arrows when entering text, disabled, or not hovered
|
||||
input[type="text"]:focus ~ .arrow,
|
||||
&.disabled .arrow,
|
||||
&:not(:hover) .arrow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
// Style the increment arrows
|
||||
.arrow {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
margin: 0;
|
||||
padding: 9px 0;
|
||||
border: none;
|
||||
background: rgba(var(--color-1-nearblack-rgb), 0.75);
|
||||
|
||||
&:hover {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
&.right {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
import { type IconName } from "@/utility-functions/icons";
|
||||
|
||||
import LayoutRow from "@/components/layout/LayoutRow.svelte";
|
||||
import CheckboxInput from "@/components/widgets/inputs/CheckboxInput.svelte";
|
||||
|
||||
// emits: ["update:checked"],
|
||||
const dispatch = createEventDispatcher<{ checked: boolean }>();
|
||||
|
||||
export let checked: boolean;
|
||||
export let disabled = false;
|
||||
export let icon: IconName = "Checkmark";
|
||||
export let tooltip: string | undefined = undefined;
|
||||
|
||||
let checkboxInput: CheckboxInput;
|
||||
</script>
|
||||
|
||||
<LayoutRow class="optional-input" classes={{ disabled }}>
|
||||
<CheckboxInput {checked} on:checked {disabled} {icon} {tooltip} bind:this={checkboxInput} />
|
||||
</LayoutRow>
|
||||
|
||||
<style lang="scss" global>
|
||||
.optional-input {
|
||||
flex-grow: 0;
|
||||
|
||||
label {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
white-space: nowrap;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 1px solid var(--color-5-dullgray);
|
||||
border-radius: 2px 0 0 2px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
&.disabled label {
|
||||
border: 1px solid var(--color-4-dimgray);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,118 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
import { type RadioEntries, type RadioEntryData } from "@/wasm-communication/messages";
|
||||
|
||||
import LayoutRow from "@/components/layout/LayoutRow.svelte";
|
||||
import IconLabel from "@/components/widgets/labels/IconLabel.svelte";
|
||||
import TextLabel from "@/components/widgets/labels/TextLabel.svelte";
|
||||
|
||||
// emits: ["update:selectedIndex"],
|
||||
const dispatch = createEventDispatcher<{ selectedIndex: number }>();
|
||||
|
||||
export let entries: RadioEntries;
|
||||
export let selectedIndex: number;
|
||||
export let disabled = false;
|
||||
export let sharpRightCorners = false;
|
||||
|
||||
function handleEntryClick(radioEntryData: RadioEntryData) {
|
||||
const index = entries.indexOf(radioEntryData);
|
||||
dispatch("selectedIndex", index);
|
||||
|
||||
radioEntryData.action?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<LayoutRow class="radio-input" classes={{ disabled }}>
|
||||
{#each entries as entry, index (index)}
|
||||
<button
|
||||
class:active={index === selectedIndex}
|
||||
class:disabled
|
||||
class:sharp-right-corners={index === entries.length - 1 && sharpRightCorners}
|
||||
on:click={() => handleEntryClick(entry)}
|
||||
title={entry.tooltip}
|
||||
tabindex={index === selectedIndex ? -1 : 0}
|
||||
{disabled}
|
||||
>
|
||||
{#if entry.icon}
|
||||
<IconLabel icon={entry.icon} />
|
||||
{/if}
|
||||
{#if entry.label}
|
||||
<TextLabel>{entry.label}</TextLabel>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</LayoutRow>
|
||||
|
||||
<style lang="scss" global>
|
||||
.radio-input {
|
||||
button {
|
||||
background: var(--color-5-dullgray);
|
||||
fill: var(--color-e-nearwhite);
|
||||
height: 24px;
|
||||
margin: 0;
|
||||
padding: 0 4px;
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-6-lowergray);
|
||||
color: var(--color-f-white);
|
||||
|
||||
svg {
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
.text-label {
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
&.combined-before button:first-of-type,
|
||||
&.combined-after button:last-of-type {
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,86 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
|
||||
import { type Color } from "@/wasm-communication/messages";
|
||||
|
||||
import ColorPicker from "@/components/floating-menus/ColorPicker.svelte";
|
||||
import LayoutCol from "@/components/layout/LayoutCol.svelte";
|
||||
import LayoutRow from "@/components/layout/LayoutRow.svelte";
|
||||
import { Editor } from "@/wasm-communication/editor";
|
||||
|
||||
const editor = getContext<Editor>("editor");
|
||||
|
||||
export let primary: Color;
|
||||
export let secondary: Color;
|
||||
|
||||
let primaryOpen = false;
|
||||
let secondaryOpen = false;
|
||||
|
||||
function clickPrimarySwatch() {
|
||||
primaryOpen = true;
|
||||
secondaryOpen = false;
|
||||
}
|
||||
|
||||
function clickSecondarySwatch() {
|
||||
primaryOpen = false;
|
||||
secondaryOpen = true;
|
||||
}
|
||||
|
||||
function primaryColorChanged(color: Color) {
|
||||
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);
|
||||
}
|
||||
</script>
|
||||
|
||||
<LayoutCol class="swatch-pair">
|
||||
<LayoutRow class="primary swatch">
|
||||
<button on:click={clickPrimarySwatch} style:--swatch-color={primary.toRgbaCSS()} data-floating-menu-spawner="no-hover-transfer" tabindex="0" />
|
||||
<ColorPicker open={primaryOpen} on:open={({ detail }) => (primaryOpen = detail)} color={primary} on:color={({ detail }) => primaryColorChanged(detail)} 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 }) => secondaryColorChanged(detail)} direction="Right" />
|
||||
</LayoutRow>
|
||||
</LayoutCol>
|
||||
|
||||
<style lang="scss" global>
|
||||
.swatch-pair {
|
||||
flex: 0 0 auto;
|
||||
|
||||
.swatch {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin: 0 2px;
|
||||
position: relative;
|
||||
|
||||
> button {
|
||||
--swatch-color: #ffffff;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
border: 2px var(--color-5-dullgray) solid;
|
||||
box-shadow: 0 0 0 2px var(--color-3-darkgray);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
background: linear-gradient(var(--swatch-color), var(--swatch-color)), var(--color-transparent-checkered-background);
|
||||
background-size: var(--color-transparent-checkered-background-size);
|
||||
background-position: var(--color-transparent-checkered-background-position);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.floating-menu {
|
||||
top: 50%;
|
||||
right: -2px;
|
||||
}
|
||||
|
||||
&.primary {
|
||||
margin-bottom: -8px;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
import FieldInput from "@/components/widgets/inputs/FieldInput.svelte";
|
||||
|
||||
// emits: ["update:value", "commitText"],
|
||||
const dispatch = createEventDispatcher<{ value: string; commitText: string }>();
|
||||
|
||||
export let value: string;
|
||||
export let label: string | undefined = undefined;
|
||||
export let tooltip: string | undefined = undefined;
|
||||
export let disabled = false;
|
||||
|
||||
let fieldInput: FieldInput;
|
||||
let editing = false;
|
||||
let inputValue = value;
|
||||
|
||||
$: dispatch("value", inputValue);
|
||||
|
||||
function onTextFocused() {
|
||||
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)
|
||||
function onTextChanged() {
|
||||
// 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", fieldInput.getInputElementValue());
|
||||
|
||||
// Required if value is not changed by the parent component upon update:value event
|
||||
fieldInput.setInputElementValue(value);
|
||||
}
|
||||
|
||||
function onCancelTextChange() {
|
||||
editing = false;
|
||||
|
||||
fieldInput.unFocus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<FieldInput
|
||||
class="text-area-input"
|
||||
classes={{
|
||||
// TODO: Svelte: check if this should be based on `Boolean(label)` or `label !== ""`
|
||||
"has-label": Boolean(label),
|
||||
}}
|
||||
on:value={({ detail }) => (inputValue = detail)}
|
||||
on:textFocused={onTextFocused}
|
||||
on:textChanged={onTextChanged}
|
||||
on:cancelTextChange={onCancelTextChange}
|
||||
textarea={true}
|
||||
spellcheck={true}
|
||||
{label}
|
||||
{disabled}
|
||||
{tooltip}
|
||||
value={inputValue}
|
||||
bind:this={fieldInput}
|
||||
/>
|
||||
|
||||
<style lang="scss" global>
|
||||
</style>
|
||||
@@ -0,0 +1,86 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
import FieldInput from "@/components/widgets/inputs/FieldInput.svelte";
|
||||
|
||||
// emits: ["update:value", "commitText"],
|
||||
const dispatch = createEventDispatcher<{ value: string; commitText: string }>();
|
||||
|
||||
// Label
|
||||
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;
|
||||
|
||||
let fieldInput: FieldInput;
|
||||
let editing = false;
|
||||
let text = value;
|
||||
|
||||
$: dispatch("value", text);
|
||||
|
||||
function onTextFocused() {
|
||||
editing = true;
|
||||
|
||||
fieldInput.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 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", fieldInput.getInputElementValue());
|
||||
|
||||
// Required if value is not changed by the parent component upon update:value event
|
||||
fieldInput.setInputElementValue(value);
|
||||
}
|
||||
|
||||
function onCancelTextChange() {
|
||||
editing = false;
|
||||
|
||||
fieldInput.unFocus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<FieldInput
|
||||
class="text-input"
|
||||
classes={{ centered }}
|
||||
styles={{ "min-width": minWidth > 0 ? `${minWidth}px` : undefined }}
|
||||
value={text}
|
||||
on:value={({ detail }) => (text = detail)}
|
||||
on:textFocused={onTextFocused}
|
||||
on:textChanged={onTextChanged}
|
||||
on:cancelTextChange={onCancelTextChange}
|
||||
spellcheck={true}
|
||||
{label}
|
||||
{disabled}
|
||||
{tooltip}
|
||||
{placeholder}
|
||||
{sharpRightCorners}
|
||||
bind:this={fieldInput}
|
||||
/>
|
||||
|
||||
<style lang="scss" global>
|
||||
.text-input {
|
||||
input {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
&.centered {
|
||||
input:not(:focus) {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
import { type IconName, ICONS, ICON_SVG_STRINGS } from "@/utility-functions/icons";
|
||||
|
||||
import LayoutRow from "@/components/layout/LayoutRow.svelte";
|
||||
|
||||
// TODO: Svelte: fix icon imports
|
||||
|
||||
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;
|
||||
|
||||
$: iconSizeClass = ((icon: IconName) => {
|
||||
return `size-${ICONS[icon].size}`;
|
||||
})(icon);
|
||||
$: extraClasses = Object.entries(classes)
|
||||
.flatMap((classAndState) => (classAndState[1] ? [classAndState[0]] : []))
|
||||
.join(" ");
|
||||
</script>
|
||||
|
||||
<LayoutRow class={`icon-label ${iconSizeClass} ${className} ${extraClasses}`.trim()} classes={{ disabled }} {tooltip}>
|
||||
{@html ICON_SVG_STRINGS[icon]}
|
||||
</LayoutRow>
|
||||
|
||||
<style lang="scss" global>
|
||||
.icon-label {
|
||||
flex: 0 0 auto;
|
||||
fill: var(--color-e-nearwhite);
|
||||
|
||||
&.disabled {
|
||||
fill: var(--color-8-uppergray);
|
||||
}
|
||||
|
||||
&.size-12 {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
&.size-16 {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
&.size-24 {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
import { type SeparatorDirection, type SeparatorType } from "@/wasm-communication/messages";
|
||||
|
||||
export let direction: SeparatorDirection = "Horizontal";
|
||||
export let type: SeparatorType = "Unrelated";
|
||||
</script>
|
||||
|
||||
<div class={`separator ${direction.toLowerCase()} ${type.toLowerCase()}`}>
|
||||
{#if ["Section", "List"].includes(type)}
|
||||
<div />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss" global>
|
||||
.separator {
|
||||
&.vertical {
|
||||
flex: 0 0 auto;
|
||||
|
||||
&.related {
|
||||
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 {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
&.list {
|
||||
margin: 4px 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.horizontal {
|
||||
flex: 0 0 auto;
|
||||
|
||||
&.related {
|
||||
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>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script lang="ts">
|
||||
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;
|
||||
|
||||
$: extraClasses = Object.entries(classes)
|
||||
.flatMap((classAndState) => (classAndState[1] ? [classAndState[0]] : []))
|
||||
.join(" ");
|
||||
$: extraStyles = Object.entries(styles)
|
||||
.flatMap((styleAndValue) => (styleAndValue[1] !== undefined ? [`${styleAndValue[0]}: ${styleAndValue[1]};`] : []))
|
||||
.join(" ");
|
||||
</script>
|
||||
|
||||
<span
|
||||
class={`text-label ${className} ${extraClasses}`.trim()}
|
||||
class:disabled
|
||||
class:bold
|
||||
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" global>
|
||||
.text-label {
|
||||
line-height: 18px;
|
||||
white-space: nowrap;
|
||||
// Force Safari to not draw a text cursor, even though this element has `user-select: none`
|
||||
cursor: default;
|
||||
|
||||
&.disabled {
|
||||
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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,248 @@
|
||||
<script lang="ts">
|
||||
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 LayoutRow from "@/components/layout/LayoutRow.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 FullscreenState } from "@/state-providers/fullscreen";
|
||||
|
||||
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 : {}),
|
||||
};
|
||||
|
||||
const fullscreen = getContext<FullscreenState>("fullscreen");
|
||||
|
||||
export let keysWithLabelsGroups: LayoutKeysGroup[] = [];
|
||||
export let mouseMotion: MouseMotion | undefined = undefined;
|
||||
export let requiresLock = false;
|
||||
|
||||
$: keyboardLockInfoMessage = watchKeyboardLockInfoMessage(fullscreen.keyboardLockApiSupported);
|
||||
$: displayKeyboardLockNotice = requiresLock && !$fullscreen.keyboardLocked;
|
||||
|
||||
function watchKeyboardLockInfoMessage(keyboardLockApiSupported: boolean): 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 (keyboardLockApiSupported) return `${RESERVED} ${USE_FULLSCREEN}`;
|
||||
if (!("chrome" in window)) return `${RESERVED} ${SWITCH_BROWSER}`;
|
||||
if (!window.isSecureContext) return `${RESERVED} ${USE_SECURE_CTX}`;
|
||||
return RESERVED;
|
||||
}
|
||||
|
||||
function keyTextOrIconList(keyGroup: LayoutKeysGroup): LabelData[] {
|
||||
return keyGroup.map((key) => keyTextOrIcon(key));
|
||||
}
|
||||
|
||||
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)
|
||||
let key = keyWithLabel.key;
|
||||
const label = keyWithLabel.label;
|
||||
|
||||
// Replace Alt and Accel keys with their Mac-specific equivalents
|
||||
if (platformIsMac()) {
|
||||
if (key === "Alt") key = "Option";
|
||||
if (key === "Accel") key = "Command";
|
||||
}
|
||||
|
||||
// Either display an icon...
|
||||
// @ts-expect-error We want undefined if it isn't in the object
|
||||
const iconWidth: number | undefined = ICON_WIDTHS[key];
|
||||
const icon = iconWidth !== undefined && iconWidth > 0 && (keyboardHintIcon(key) || false);
|
||||
if (icon) return { icon, width: `width-${iconWidth}` };
|
||||
|
||||
// ...or display text
|
||||
return { label, width: `width-${label.length}` };
|
||||
}
|
||||
|
||||
function mouseHintIcon(input?: MouseMotion): IconName {
|
||||
return `MouseHint${input}` as IconName;
|
||||
}
|
||||
|
||||
function keyboardHintIcon(input: KeyRaw): IconName | undefined {
|
||||
switch (input) {
|
||||
case "ArrowDown":
|
||||
return "KeyboardArrowDown";
|
||||
case "ArrowLeft":
|
||||
return "KeyboardArrowLeft";
|
||||
case "ArrowRight":
|
||||
return "KeyboardArrowRight";
|
||||
case "ArrowUp":
|
||||
return "KeyboardArrowUp";
|
||||
case "Backspace":
|
||||
return "KeyboardBackspace";
|
||||
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>
|
||||
|
||||
{#if displayKeyboardLockNotice}
|
||||
<IconLabel class="user-input-label keyboard-lock-notice" icon="Info" tooltip={keyboardLockInfoMessage} />
|
||||
{:else}
|
||||
<LayoutRow class="user-input-label">
|
||||
{#each keysWithLabelsGroups as keysWithLabels, groupIndex (groupIndex)}
|
||||
{#if groupIndex > 0}
|
||||
<Separator type="Related" />
|
||||
{/if}
|
||||
{#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>
|
||||
{/each}
|
||||
{/each}
|
||||
{#if mouseMotion}
|
||||
<div class="input-mouse">
|
||||
<IconLabel icon={mouseHintIcon(mouseMotion)} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if $$slots.default}
|
||||
<div class="hint-text">
|
||||
<slot />
|
||||
</div>
|
||||
{/if}
|
||||
</LayoutRow>
|
||||
{/if}
|
||||
|
||||
<style lang="scss" global>
|
||||
.user-input-label {
|
||||
flex: 0 0 auto;
|
||||
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;
|
||||
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-mouse {
|
||||
.bright {
|
||||
fill: var(--color-e-nearwhite);
|
||||
}
|
||||
|
||||
.dim {
|
||||
fill: var(--color-8-uppergray);
|
||||
}
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,141 @@
|
||||
<script lang="ts" context="module">
|
||||
export type RulerDirection = "Horizontal" | "Vertical";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
const RULER_THICKNESS = 16;
|
||||
const MAJOR_MARK_THICKNESS = 16;
|
||||
const MEDIUM_MARK_THICKNESS = 6;
|
||||
const MINOR_MARK_THICKNESS = 3;
|
||||
|
||||
export let direction: RulerDirection = "Vertical";
|
||||
export let origin: number;
|
||||
export let numberInterval: number;
|
||||
export let majorMarkSpacing: number;
|
||||
export let mediumDivisions: number = 5;
|
||||
export let minorDivisions: number = 2;
|
||||
|
||||
let canvasRuler: HTMLDivElement;
|
||||
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;
|
||||
}
|
||||
|
||||
function computeSvgTexts(direction: RulerDirection, origin: number, majorMarkSpacing: number, numberInterval: number, rulerLength: number): { transform: string; text: number }[] {
|
||||
const isVertical = direction === "Vertical";
|
||||
|
||||
const offsetStart = mod(origin, majorMarkSpacing);
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
// Modulo function that works for negative numbers, unlike the JS `%` operator
|
||||
function mod(n: number, m: number): number {
|
||||
const remainder = n % m;
|
||||
return Math.floor(remainder >= 0 ? remainder : remainder + m);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class={`canvas-ruler ${direction.toLowerCase()}`} bind:this={canvasRuler}>
|
||||
<svg style:width={svgBounds.width} style:height={svgBounds.height}>
|
||||
<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;
|
||||
}
|
||||
|
||||
&.vertical {
|
||||
width: 16px;
|
||||
|
||||
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>
|
||||
@@ -0,0 +1,208 @@
|
||||
<script lang="ts" context="module">
|
||||
export type ScrollbarDirection = "Horizontal" | "Vertical";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, onMount, onDestroy } from "svelte";
|
||||
|
||||
// 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);
|
||||
|
||||
// emits: { "update:handlePosition": null, pressTrack: (pointerOffset: number) => typeof pointerOffset === "number" }
|
||||
const dispatch = createEventDispatcher<{ handlePosition: number; pressTrack: number }>();
|
||||
|
||||
export let direction: ScrollbarDirection = "Vertical";
|
||||
export let handlePosition: number = 0.5;
|
||||
export let handleLength: number = 0.5;
|
||||
|
||||
let scrollTrack: HTMLDivElement;
|
||||
let dragging = false;
|
||||
let pointerPos = 0;
|
||||
let thumbTop: string | undefined = undefined;
|
||||
let thumbBottom: string | undefined = undefined;
|
||||
let thumbLeft: string | undefined = undefined;
|
||||
let thumbRight: string | undefined = undefined;
|
||||
|
||||
$: start = handleToTrack(handleLength, handlePosition) - handleLength / 2;
|
||||
$: end = 1 - handleToTrack(handleLength, handlePosition) - handleLength / 2;
|
||||
$: [thumbTop, thumbBottom, thumbLeft, thumbRight] = direction === "Vertical" ? [`${start * 100}%`, `${end * 100}%`, "0%", "0%"] : ["0%", "0%", `${start * 100}%`, `${end * 100}%`];
|
||||
|
||||
function trackLength(): number | undefined {
|
||||
return direction === "Vertical" ? scrollTrack.clientHeight - handleLength : scrollTrack.clientWidth;
|
||||
}
|
||||
|
||||
function trackOffset(): number | undefined {
|
||||
return direction === "Vertical" ? scrollTrack.getBoundingClientRect().top : scrollTrack.getBoundingClientRect().left;
|
||||
}
|
||||
|
||||
function clampHandlePosition(newPos: number) {
|
||||
const clampedPosition = Math.min(Math.max(newPos, 0), 1);
|
||||
dispatch("handlePosition", clampedPosition);
|
||||
}
|
||||
|
||||
function updateHandlePosition(e: PointerEvent) {
|
||||
const length = trackLength();
|
||||
if (length === undefined) return;
|
||||
|
||||
const position = pointerPosition(direction, e);
|
||||
|
||||
clampHandlePosition(handlePosition + (position - pointerPos) / (length * (1 - handleLength)));
|
||||
pointerPos = position;
|
||||
}
|
||||
|
||||
function grabHandle(e: PointerEvent) {
|
||||
if (!dragging) {
|
||||
dragging = true;
|
||||
pointerPos = pointerPosition(direction, e);
|
||||
}
|
||||
}
|
||||
|
||||
function grabArea(e: PointerEvent) {
|
||||
if (!dragging) {
|
||||
const length = trackLength();
|
||||
const offset = trackOffset();
|
||||
if (length === undefined || offset === undefined) return;
|
||||
|
||||
const oldPointer = handleToTrack(handleLength, handlePosition) * length + offset;
|
||||
const pointerPos = pointerPosition(direction, e);
|
||||
dispatch("pressTrack", pointerPos - oldPointer);
|
||||
}
|
||||
}
|
||||
|
||||
function pointerUp() {
|
||||
dragging = false;
|
||||
}
|
||||
|
||||
function pointerMove(e: PointerEvent) {
|
||||
if (dragging) updateHandlePosition(e);
|
||||
}
|
||||
|
||||
function changePosition(difference: number) {
|
||||
const length = trackLength();
|
||||
if (length === undefined) return;
|
||||
|
||||
clampHandlePosition(handlePosition + difference / length);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener("pointerup", pointerUp);
|
||||
window.addEventListener("pointermove", pointerMove);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
window.removeEventListener("pointerup", pointerUp);
|
||||
window.removeEventListener("pointermove", pointerMove);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class={`persistent-scrollbar ${direction.toLowerCase()}`}>
|
||||
<button class="arrow decrease" on:pointerdown={() => changePosition(-50)} tabindex="-1" />
|
||||
<div class="scroll-track" bind:this={scrollTrack} on:pointerdown={grabArea}>
|
||||
<div class="scroll-thumb" on:pointerdown={grabHandle} class:dragging style:top={thumbTop} style:bottom={thumbBottom} style:left={thumbLeft} style:right={thumbRight} />
|
||||
</div>
|
||||
<button class="arrow increase" on:click={() => changePosition(50)} tabindex="-1" />
|
||||
</div>
|
||||
|
||||
<style lang="scss" global>
|
||||
.persistent-scrollbar {
|
||||
display: flex;
|
||||
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%;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
&.vertical {
|
||||
flex-direction: column;
|
||||
|
||||
.arrow.decrease {
|
||||
margin: 4px 3px;
|
||||
border-width: 0 5px 8px 5px;
|
||||
border-color: transparent transparent var(--color-5-dullgray) 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.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>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts" context="module">
|
||||
export type ApplicationPlatform = "Windows" | "Mac" | "Linux" | "Web";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import LayoutCol from "@/components/layout/LayoutCol.svelte";
|
||||
import StatusBar from "@/components/window/status-bar/StatusBar.svelte";
|
||||
import TitleBar from "@/components/window/title-bar/TitleBar.svelte";
|
||||
import Workspace from "@/components/window/workspace/Workspace.svelte";
|
||||
|
||||
let platform: ApplicationPlatform = "Web";
|
||||
let maximized: true;
|
||||
</script>
|
||||
|
||||
<LayoutCol class="main-window">
|
||||
<TitleBar {platform} {maximized} />
|
||||
|
||||
<Workspace />
|
||||
|
||||
<StatusBar />
|
||||
</LayoutCol>
|
||||
|
||||
<style lang="scss" global>
|
||||
.main-window {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
touch-action: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,75 @@
|
||||
<script lang="ts">
|
||||
import { getContext, onMount } from "svelte";
|
||||
|
||||
import { platformIsMac } from "@/utility-functions/platform";
|
||||
import { type HintData, type HintInfo, type LayoutKeysGroup, UpdateInputHints } from "@/wasm-communication/messages";
|
||||
|
||||
import LayoutRow from "@/components/layout/LayoutRow.svelte";
|
||||
import Separator from "@/components/widgets/labels/Separator.svelte";
|
||||
import UserInputLabel from "@/components/widgets/labels/UserInputLabel.svelte";
|
||||
import { type Editor } from "@/wasm-communication/editor";
|
||||
|
||||
const editor = getContext<Editor>("editor");
|
||||
|
||||
let hintData: HintData = [];
|
||||
|
||||
function inputKeysForPlatform(hint: HintInfo): LayoutKeysGroup[] {
|
||||
if (platformIsMac() && hint.keyGroupsMac) return hint.keyGroupsMac;
|
||||
return hint.keyGroups;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
editor.subscriptions.subscribeJsMessage(UpdateInputHints, (data) => {
|
||||
hintData = data.hintData;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<LayoutRow class="status-bar">
|
||||
<LayoutRow class="hint-groups">
|
||||
{#each hintData as hintGroup, index (hintGroup)}
|
||||
{#if index !== 0}
|
||||
<Separator type={"Section"} />
|
||||
{/if}
|
||||
{#each hintGroup as hint (hint)}
|
||||
{#if hint.plus}
|
||||
<LayoutRow class="plus">+</LayoutRow>
|
||||
{/if}
|
||||
<UserInputLabel mouseMotion={hint.mouse} keysWithLabelsGroups={inputKeysForPlatform(hint)}>{hint.label}</UserInputLabel>
|
||||
{/each}
|
||||
{/each}
|
||||
</LayoutRow>
|
||||
</LayoutRow>
|
||||
|
||||
<style lang="scss" global>
|
||||
.status-bar {
|
||||
height: 24px;
|
||||
width: 100%;
|
||||
flex: 0 0 auto;
|
||||
|
||||
.hint-groups {
|
||||
flex: 0 0 auto;
|
||||
max-width: 100%;
|
||||
margin: 0 -4px;
|
||||
overflow: hidden;
|
||||
|
||||
.separator.section {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.plus {
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.user-input-label {
|
||||
margin: 0 8px;
|
||||
|
||||
& + .user-input-label {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script lang="ts" context="module">
|
||||
export type Platform = "Windows" | "Mac" | "Linux" | "Web";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import LayoutRow from "@/components/layout/LayoutRow.svelte";
|
||||
import MenuBarInput from "@/components/widgets/inputs/MenuBarInput.svelte";
|
||||
import WindowButtonsMac from "@/components/window/title-bar/WindowButtonsMac.svelte";
|
||||
import WindowButtonsWeb from "@/components/window/title-bar/WindowButtonsWeb.svelte";
|
||||
import WindowButtonsWindows from "@/components/window/title-bar/WindowButtonsWindows.svelte";
|
||||
import WindowTitle from "@/components/window/title-bar/WindowTitle.svelte";
|
||||
import { type PortfolioState } from "@/state-providers/portfolio";
|
||||
import { getContext } from "svelte";
|
||||
|
||||
export let platform: Platform;
|
||||
export let maximized: boolean;
|
||||
|
||||
const portfolio = getContext<PortfolioState>("portfolio");
|
||||
|
||||
$: docIndex = $portfolio.activeDocumentIndex;
|
||||
$: displayName = $portfolio.documents[docIndex]?.displayName || "";
|
||||
$: windowTitle = `${displayName}${displayName && " - "}Graphite`;
|
||||
</script>
|
||||
|
||||
<LayoutRow class="title-bar">
|
||||
<LayoutRow class="header-part">
|
||||
{#if platform === "Mac"}
|
||||
<WindowButtonsMac {maximized} />
|
||||
{:else}
|
||||
<MenuBarInput />
|
||||
{/if}
|
||||
</LayoutRow>
|
||||
<LayoutRow class="header-part">
|
||||
<WindowTitle text={windowTitle} />
|
||||
</LayoutRow>
|
||||
<LayoutRow class="header-part">
|
||||
{#if platform === "Windows" || platform === "Linux"}
|
||||
<WindowButtonsWindows {maximized} />
|
||||
{:else if platform === "Web"}
|
||||
<WindowButtonsWeb />
|
||||
{/if}
|
||||
</LayoutRow>
|
||||
</LayoutRow>
|
||||
|
||||
<style lang="scss" global>
|
||||
.title-bar {
|
||||
height: 28px;
|
||||
flex: 0 0 auto;
|
||||
|
||||
.header-part {
|
||||
flex: 1 1 100%;
|
||||
|
||||
&:nth-child(1) {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import LayoutRow from "@/components/layout/LayoutRow.svelte";
|
||||
|
||||
export let maximized = false;
|
||||
</script>
|
||||
|
||||
<LayoutRow class="window-buttons-mac">
|
||||
<div class="close" title="Close" />
|
||||
<div class="minimize" title="Minimize" />
|
||||
<div class="zoom" title="Zoom" />
|
||||
</LayoutRow>
|
||||
|
||||
<style lang="scss" global>
|
||||
.window-buttons-mac {
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
margin: 0 8px;
|
||||
|
||||
div {
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 50%;
|
||||
|
||||
& + div {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
&.close {
|
||||
background: #ff5a52;
|
||||
}
|
||||
|
||||
&.minimize {
|
||||
background: #e6c029;
|
||||
}
|
||||
|
||||
&.zoom {
|
||||
background: #54c22b;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from "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 { FullscreenState } from "@/state-providers/fullscreen";
|
||||
|
||||
const fullscreen = getContext<FullscreenState>("fullscreen");
|
||||
|
||||
$: windowFullscreen = $fullscreen.windowFullscreen;
|
||||
$: requestFullscreenHotkeys = fullscreen.keyboardLockApiSupported && !$fullscreen.keyboardLocked;
|
||||
|
||||
async function handleClick() {
|
||||
if (windowFullscreen) fullscreen.exitFullscreen();
|
||||
else fullscreen.enterFullscreen();
|
||||
}
|
||||
</script>
|
||||
|
||||
<LayoutRow class="window-buttons-web" on:click={() => handleClick()} tooltip={(windowFullscreen ? "Exit" : "Enter") + " Fullscreen (F11)"}>
|
||||
{#if requestFullscreenHotkeys}
|
||||
<TextLabel italic={true}>Go fullscreen to access all hotkeys</TextLabel>
|
||||
{/if}
|
||||
<IconLabel icon={windowFullscreen ? "FullscreenExit" : "FullscreenEnter"} />
|
||||
</LayoutRow>
|
||||
|
||||
<style lang="scss" global>
|
||||
.window-buttons-web {
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
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 {
|
||||
fill: var(--color-f-white);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import LayoutRow from "@/components/layout/LayoutRow.svelte";
|
||||
import IconLabel from "@/components/widgets/labels/IconLabel.svelte";
|
||||
|
||||
export let maximized = false;
|
||||
</script>
|
||||
|
||||
<LayoutRow class="window-button windows minimize" tooltip="Minimize">
|
||||
<IconLabel icon={"WindowButtonWinMinimize"} />
|
||||
</LayoutRow>
|
||||
{#if !maximized}
|
||||
<LayoutRow class="window-button windows maximize" tooltip="Maximize">
|
||||
<IconLabel icon={"WindowButtonWinMaximize"} />
|
||||
</LayoutRow>
|
||||
{:else}
|
||||
<LayoutRow class="window-button windows restore-down" tooltip="Restore Down">
|
||||
<IconLabel icon={"WindowButtonWinRestoreDown"} />
|
||||
</LayoutRow>
|
||||
{/if}
|
||||
<LayoutRow class="window-button windows close" tooltip="Close">
|
||||
<IconLabel icon={"WindowButtonWinClose"} />
|
||||
</LayoutRow>
|
||||
|
||||
<style lang="scss" global>
|
||||
.window-button.windows {
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
padding: 0 17px;
|
||||
|
||||
svg {
|
||||
fill: var(--color-e-nearwhite);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--color-6-lowergray);
|
||||
|
||||
svg {
|
||||
fill: var(--color-f-white);
|
||||
}
|
||||
}
|
||||
|
||||
&.close:hover {
|
||||
background: #e81123;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import LayoutRow from "@/components/layout/LayoutRow.svelte";
|
||||
import TextLabel from "@/components/widgets/labels/TextLabel.svelte";
|
||||
|
||||
export let text: string;
|
||||
</script>
|
||||
|
||||
<LayoutRow class="window-title">
|
||||
<TextLabel>{text}</TextLabel>
|
||||
</LayoutRow>
|
||||
|
||||
<style lang="scss" global>
|
||||
.window-title {
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
padding: 0 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,292 @@
|
||||
<script lang="ts" context="module">
|
||||
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";
|
||||
|
||||
const PANEL_COMPONENTS = {
|
||||
Document,
|
||||
LayerTree,
|
||||
NodeGraph,
|
||||
Properties,
|
||||
};
|
||||
type PanelTypes = keyof typeof PANEL_COMPONENTS;
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { getContext, tick } from "svelte";
|
||||
|
||||
import { platformIsMac } 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];
|
||||
}
|
||||
|
||||
// TODO: Svelte: test this
|
||||
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
|
||||
class="tab"
|
||||
classes={{ active: tabIndex === tabActiveIndex }}
|
||||
tooltip={tabLabel.tooltip || undefined}
|
||||
on:click={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.button === 0) clickAction?.(tabIndex);
|
||||
if (e.button === 1) closeAction?.(tabIndex);
|
||||
}}
|
||||
bind:this={tabElements[tabIndex]}
|
||||
>
|
||||
<TextLabel>{tabLabel.name}</TextLabel>
|
||||
{#if tabCloseButtons}
|
||||
<IconButton
|
||||
action={(e) => {
|
||||
e?.stopPropagation();
|
||||
closeAction?.(tabIndex);
|
||||
}}
|
||||
icon="CloseX"
|
||||
size={16}
|
||||
/>
|
||||
{/if}
|
||||
</LayoutRow>
|
||||
{/each}
|
||||
</LayoutRow>
|
||||
<PopoverButton icon="VerticalEllipsis">
|
||||
<TextLabel bold={true}>Panel Options</TextLabel>
|
||||
<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">
|
||||
<LayoutRow class="logotype">
|
||||
<IconLabel icon="GraphiteLogotypeSolid" />
|
||||
</LayoutRow>
|
||||
<LayoutRow class="actions">
|
||||
<table>
|
||||
<tr>
|
||||
<td>
|
||||
<TextButton label="New Document" icon="File" action={() => newDocument()} />
|
||||
</td>
|
||||
<td>
|
||||
<UserInputLabel keysWithLabelsGroups={[[...platformModifiers(true), { key: "KeyN", label: "N" }]]} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<TextButton label="Open Document" icon="Folder" action={() => openDocument()} />
|
||||
</td>
|
||||
<td>
|
||||
<UserInputLabel keysWithLabelsGroups={[[...platformModifiers(false), { key: "KeyO", label: "O" }]]} />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</LayoutRow>
|
||||
</LayoutCol>
|
||||
</LayoutCol>
|
||||
{/if}
|
||||
</LayoutCol>
|
||||
</LayoutCol>
|
||||
|
||||
<style lang="scss" global>
|
||||
.panel {
|
||||
background: var(--color-1-nearblack);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
|
||||
.tab-bar {
|
||||
height: 28px;
|
||||
min-height: auto;
|
||||
|
||||
&.min-widths .tab-group .tab {
|
||||
min-width: 120px;
|
||||
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 {
|
||||
flex: 0 1 auto;
|
||||
height: 100%;
|
||||
padding: 0 8px;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
|
||||
&.active {
|
||||
background: var(--color-3-darkgray);
|
||||
border-radius: 6px 6px 0 0;
|
||||
position: relative;
|
||||
|
||||
&:not(:first-child)::before,
|
||||
&::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);
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
flex: 1 1 100%;
|
||||
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 {
|
||||
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: "";
|
||||
position: absolute;
|
||||
right: -1px;
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: var(--color-4-dimgray);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.popover-button {
|
||||
margin: 2px 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
background: var(--color-3-darkgray);
|
||||
flex: 1 1 100%;
|
||||
flex-direction: column;
|
||||
|
||||
.empty-panel {
|
||||
background: var(--color-2-mildblack);
|
||||
margin: 4px;
|
||||
border-radius: 2px;
|
||||
justify-content: center;
|
||||
|
||||
.content {
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
|
||||
.logotype {
|
||||
margin-bottom: 40px;
|
||||
|
||||
svg {
|
||||
width: auto;
|
||||
height: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
table {
|
||||
border-spacing: 8px;
|
||||
margin: -8px;
|
||||
|
||||
td {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.text-button:not(:hover) {
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,170 @@
|
||||
<script lang="ts">
|
||||
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";
|
||||
|
||||
const MIN_PANEL_SIZE = 100;
|
||||
const PANEL_SIZES = {
|
||||
/**/ root: 100,
|
||||
/* ├── */ content: 80,
|
||||
/* │ ├── */ document: 60,
|
||||
/* │ └── */ graph: 40,
|
||||
/* └── */ details: 20,
|
||||
/* ├── */ properties: 45,
|
||||
/* └── */ layers: 55,
|
||||
};
|
||||
|
||||
let panelSizes = PANEL_SIZES;
|
||||
let documentPanel: Panel;
|
||||
|
||||
$: activeDocumentIndex = $portfolio.activeDocumentIndex;
|
||||
$: nodeGraphVisible = $workspace.nodeGraphVisible;
|
||||
$: documentTabLabels = $portfolio.documents.map((doc: FrontendDocumentDetails) => {
|
||||
const name = doc.displayName;
|
||||
|
||||
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"));
|
||||
};
|
||||
|
||||
const cleanup = (e: PointerEvent): void => {
|
||||
gutter.releasePointerCapture(e.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);
|
||||
}
|
||||
</script>
|
||||
|
||||
<LayoutRow class="workspace" data-workspace>
|
||||
<LayoutRow class="workspace-grid-subdivision" styles={{ "flex-grow": panelSizes["root"] }} data-subdivision-name="root">
|
||||
<LayoutCol class="workspace-grid-subdivision" styles={{ "flex-grow": panelSizes["content"] }} data-subdivision-name="content">
|
||||
<LayoutRow class="workspace-grid-subdivision" styles={{ "flex-grow": panelSizes["document"] }} data-subdivision-name="document">
|
||||
<Panel
|
||||
panelType={$portfolio.documents.length > 0 ? "Document" : undefined}
|
||||
tabCloseButtons={true}
|
||||
tabMinWidths={true}
|
||||
tabLabels={documentTabLabels}
|
||||
clickAction={(tabIndex) => editor.instance.selectDocument($portfolio.documents[tabIndex].id)}
|
||||
closeAction={(tabIndex) => editor.instance.closeDocumentWithConfirmation($portfolio.documents[tabIndex].id)}
|
||||
tabActiveIndex={$portfolio.activeDocumentIndex}
|
||||
bind:this={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>
|
||||
{/if}
|
||||
</LayoutCol>
|
||||
<LayoutCol class="workspace-grid-resize-gutter" data-gutter-horizontal on:pointerdown={(e) => resizePanel(e)} />
|
||||
<LayoutCol class="workspace-grid-subdivision" styles={{ "flex-grow": panelSizes["details"] }} data-subdivision-name="details">
|
||||
<LayoutRow class="workspace-grid-subdivision" styles={{ "flex-grow": panelSizes["properties"] }} data-subdivision-name="properties">
|
||||
<Panel panelType="Properties" tabLabels={[{ name: "Properties" }]} tabActiveIndex={0} />
|
||||
</LayoutRow>
|
||||
<LayoutRow class="workspace-grid-resize-gutter" data-gutter-vertical on:pointerdown={(e) => resizePanel(e)} />
|
||||
<LayoutRow class="workspace-grid-subdivision" styles={{ "flex-grow": panelSizes["layers"] }} data-subdivision-name="layers">
|
||||
<Panel panelType="LayerTree" tabLabels={[{ name: "Layer Tree" }]} tabActiveIndex={0} />
|
||||
</LayoutRow>
|
||||
</LayoutCol>
|
||||
</LayoutRow>
|
||||
{#if $dialog.visible}
|
||||
<DialogModal />
|
||||
{/if}
|
||||
</LayoutRow>
|
||||
|
||||
<style lang="scss" global>
|
||||
.workspace {
|
||||
position: relative;
|
||||
flex: 1 1 100%;
|
||||
|
||||
.workspace-grid-subdivision {
|
||||
min-height: 28px;
|
||||
flex: 1 1 0;
|
||||
|
||||
&.folded {
|
||||
flex-grow: 0;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.workspace-grid-resize-gutter {
|
||||
flex: 0 0 4px;
|
||||
|
||||
&.layout-row {
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
&.layout-col {
|
||||
cursor: ew-resize;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Vendored
+7
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { type Editor } from "@/wasm-communication/editor";
|
||||
import { TriggerTextCopy } from "@/wasm-communication/messages";
|
||||
|
||||
export function createClipboardManager(editor: Editor): void {
|
||||
// Subscribe to process backend event
|
||||
editor.subscriptions.subscribeJsMessage(TriggerTextCopy, (triggerTextCopy) => {
|
||||
// If the Clipboard API is supported in the browser, copy text to the clipboard
|
||||
navigator.clipboard?.writeText?.(triggerTextCopy.copyText);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
let draggingElement: HTMLElement | undefined;
|
||||
|
||||
export function createDragManager(): () => void {
|
||||
const clearDraggingElement = (): void => {
|
||||
draggingElement = undefined;
|
||||
};
|
||||
|
||||
// Add the event listener
|
||||
document.addEventListener("drop", clearDraggingElement);
|
||||
|
||||
// Return the destructor
|
||||
return () => {
|
||||
// We use setTimeout to sequence this drop after any potential users in the current call stack progression, since this will begin in an entirely new call stack later
|
||||
setTimeout(() => {
|
||||
document.removeEventListener("drop", clearDraggingElement);
|
||||
}, 0);
|
||||
};
|
||||
}
|
||||
|
||||
export function beginDraggingElement(element: HTMLElement): void {
|
||||
draggingElement = element;
|
||||
}
|
||||
|
||||
export function currentDraggingElement(): HTMLElement | undefined {
|
||||
return draggingElement;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { type Editor } from "@/wasm-communication/editor";
|
||||
import { TriggerVisitLink } from "@/wasm-communication/messages";
|
||||
|
||||
export function createHyperlinkManager(editor: Editor): void {
|
||||
// Subscribe to process backend event
|
||||
editor.subscriptions.subscribeJsMessage(TriggerVisitLink, async (triggerOpenLink) => {
|
||||
window.open(triggerOpenLink.url, "_blank");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
import { get } from "svelte/store";
|
||||
|
||||
import { type DialogState } from "@/state-providers/dialog";
|
||||
import { type FullscreenState } from "@/state-providers/fullscreen";
|
||||
import { type PortfolioState } from "@/state-providers/portfolio";
|
||||
import { makeKeyboardModifiersBitfield, textInputCleanup, getLocalizedScanCode } from "@/utility-functions/keyboard-entry";
|
||||
import { platformIsMac } from "@/utility-functions/platform";
|
||||
import { stripIndents } from "@/utility-functions/strip-indents";
|
||||
import { type Editor } from "@/wasm-communication/editor";
|
||||
import { TriggerPaste } from "@/wasm-communication/messages";
|
||||
|
||||
type EventName = keyof HTMLElementEventMap | keyof WindowEventHandlersEventMap | "modifyinputfield";
|
||||
type EventListenerTarget = {
|
||||
addEventListener: typeof window.addEventListener;
|
||||
removeEventListener: typeof window.removeEventListener;
|
||||
};
|
||||
|
||||
export function createInputManager(editor: Editor, dialog: DialogState, document: PortfolioState, fullscreen: FullscreenState): () => void {
|
||||
window.document.body.focus();
|
||||
|
||||
let viewportPointerInteractionOngoing = false;
|
||||
let textInput = undefined as undefined | HTMLDivElement;
|
||||
let canvasFocused = true;
|
||||
|
||||
function blurApp(): void {
|
||||
canvasFocused = false;
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const listeners: { target: EventListenerTarget; eventName: EventName; action: (event: any) => void; options?: boolean | AddEventListenerOptions }[] = [
|
||||
{ target: window, eventName: "resize", action: (): void => onWindowResize(window.document.body) },
|
||||
{ target: window, eventName: "beforeunload", action: (e: BeforeUnloadEvent): Promise<void> => onBeforeUnload(e) },
|
||||
{ target: window.document, eventName: "contextmenu", action: (e: MouseEvent): void => e.preventDefault() },
|
||||
{ target: window.document, eventName: "fullscreenchange", action: (): void => fullscreen.fullscreenModeChanged() },
|
||||
{ target: window, eventName: "keyup", action: (e: KeyboardEvent): Promise<void> => onKeyUp(e) },
|
||||
{ target: window, eventName: "keydown", action: (e: KeyboardEvent): Promise<void> => onKeyDown(e) },
|
||||
{ target: window, eventName: "pointermove", action: (e: PointerEvent): void => onPointerMove(e) },
|
||||
{ target: window, eventName: "pointerdown", action: (e: PointerEvent): void => onPointerDown(e) },
|
||||
{ target: window, eventName: "pointerup", action: (e: PointerEvent): void => onPointerUp(e) },
|
||||
{ target: window, eventName: "dblclick", action: (e: PointerEvent): void => onDoubleClick(e) },
|
||||
{ target: window, eventName: "mousedown", action: (e: MouseEvent): void => onMouseDown(e) },
|
||||
{ target: window, eventName: "wheel", action: (e: WheelEvent): void => onWheelScroll(e), options: { passive: false } },
|
||||
{ target: window, eventName: "modifyinputfield", action: (e: CustomEvent): void => onModifyInputField(e) },
|
||||
{ target: window.document.body, eventName: "paste", action: (e: ClipboardEvent): void => onPaste(e) },
|
||||
{ target: window.document.body, eventName: "blur", action: (): void => blurApp() }, // TODO: Svelte: check if this works with the new target of `body`
|
||||
];
|
||||
|
||||
// Event bindings
|
||||
|
||||
function bindListeners(): void {
|
||||
// Add event bindings for the lifetime of the application
|
||||
listeners.forEach(({ target, eventName, action, options }) => target.addEventListener(eventName, action, options));
|
||||
}
|
||||
function unbindListeners(): void {
|
||||
// Remove event bindings after the lifetime of the application (or on hot-module replacement during development)
|
||||
listeners.forEach(({ target, eventName, action, options }) => target.removeEventListener(eventName, action, options));
|
||||
}
|
||||
|
||||
// Keyboard events
|
||||
|
||||
async function shouldRedirectKeyboardEventToBackend(e: KeyboardEvent): Promise<boolean> {
|
||||
// Don't redirect when a modal is covering the workspace
|
||||
if (get(dialog).visible) return false;
|
||||
|
||||
const key = await getLocalizedScanCode(e);
|
||||
|
||||
// TODO: Switch to a system where everything is sent to the backend, then the input preprocessor makes decisions and kicks some inputs back to the frontend
|
||||
const accelKey = platformIsMac() ? e.metaKey : e.ctrlKey;
|
||||
|
||||
// Don't redirect user input from text entry into HTML elements
|
||||
if (targetIsTextField(e.target || undefined) && key !== "Escape" && !(key === "Enter" && accelKey)) return false;
|
||||
|
||||
// Don't redirect paste
|
||||
if (key === "KeyV" && accelKey) return false;
|
||||
|
||||
// Don't redirect a fullscreen request
|
||||
if (key === "F11" && e.type === "keydown" && !e.repeat) {
|
||||
e.preventDefault();
|
||||
fullscreen.toggleFullscreen();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Don't redirect a reload request
|
||||
if (key === "F5") return false;
|
||||
if (key === "KeyR" && accelKey) return false;
|
||||
|
||||
// Don't redirect debugging tools
|
||||
if (["F12", "F8"].includes(key)) return false;
|
||||
if (["KeyC", "KeyI", "KeyJ"].includes(key) && accelKey && e.shiftKey) return false;
|
||||
|
||||
// Don't redirect tab or enter if not in canvas (to allow navigating elements)
|
||||
if (!canvasFocused && !targetIsTextField(e.target || undefined) && ["Tab", "Enter", "Space", "ArrowDown", "ArrowLeft", "ArrowRight", "ArrowUp"].includes(key)) return false;
|
||||
|
||||
// Redirect to the backend
|
||||
return true;
|
||||
}
|
||||
|
||||
async function onKeyDown(e: KeyboardEvent): Promise<void> {
|
||||
const key = await getLocalizedScanCode(e);
|
||||
|
||||
const NO_KEY_REPEAT_MODIFIER_KEYS = ["ControlLeft", "ControlRight", "ShiftLeft", "ShiftRight", "MetaLeft", "MetaRight", "AltLeft", "AltRight", "AltGraph", "CapsLock", "Fn", "FnLock"];
|
||||
if (e.repeat && NO_KEY_REPEAT_MODIFIER_KEYS.includes(key)) return;
|
||||
|
||||
if (await shouldRedirectKeyboardEventToBackend(e)) {
|
||||
e.preventDefault();
|
||||
const modifiers = makeKeyboardModifiersBitfield(e);
|
||||
editor.instance.onKeyDown(key, modifiers);
|
||||
return;
|
||||
}
|
||||
|
||||
if (get(dialog).visible && key === "Escape") {
|
||||
dialog.dismissDialog();
|
||||
}
|
||||
}
|
||||
|
||||
async function onKeyUp(e: KeyboardEvent): Promise<void> {
|
||||
const key = await getLocalizedScanCode(e);
|
||||
|
||||
if (await shouldRedirectKeyboardEventToBackend(e)) {
|
||||
e.preventDefault();
|
||||
const modifiers = makeKeyboardModifiersBitfield(e);
|
||||
editor.instance.onKeyUp(key, modifiers);
|
||||
}
|
||||
}
|
||||
|
||||
// Pointer events
|
||||
|
||||
// While any pointer button is already down, additional button down events are not reported, but they are sent as `pointermove` events and these are handled in the backend
|
||||
function onPointerMove(e: PointerEvent): void {
|
||||
if (!e.buttons) viewportPointerInteractionOngoing = false;
|
||||
|
||||
// Don't redirect pointer movement to the backend if there's no ongoing interaction and it's over a floating menu on top of the canvas
|
||||
// TODO: A better approach is to pass along a boolean to the backend's input preprocessor so it can know if it's being occluded by the GUI.
|
||||
// TODO: This would allow it to properly decide to act on removing hover focus from something that was hovered in the canvas before moving over the GUI.
|
||||
// TODO: Further explanation: https://github.com/GraphiteEditor/Graphite/pull/623#discussion_r866436197
|
||||
const inFloatingMenu = e.target instanceof Element && e.target.closest("[data-floating-menu-content]");
|
||||
if (!viewportPointerInteractionOngoing && inFloatingMenu) return;
|
||||
|
||||
const { target } = e;
|
||||
const newInCanvas = (target instanceof Element && target.closest("[data-canvas]")) instanceof Element && !targetIsTextField(window.document.activeElement || undefined);
|
||||
if (newInCanvas && !canvasFocused) {
|
||||
canvasFocused = true;
|
||||
window.document.body.focus();
|
||||
}
|
||||
|
||||
const modifiers = makeKeyboardModifiersBitfield(e);
|
||||
editor.instance.onMouseMove(e.clientX, e.clientY, e.buttons, modifiers);
|
||||
}
|
||||
|
||||
function onPointerDown(e: PointerEvent): void {
|
||||
const { target } = e;
|
||||
const isTargetingCanvas = target instanceof Element && target.closest("[data-canvas]");
|
||||
const inDialog = target instanceof Element && target.closest("[data-dialog-modal] [data-floating-menu-content]");
|
||||
const inTextInput = target === textInput;
|
||||
|
||||
if (get(dialog).visible && !inDialog) {
|
||||
dialog.dismissDialog();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
if (!inTextInput) {
|
||||
if (textInput) editor.instance.onChangeText(textInputCleanup(textInput.innerText));
|
||||
else viewportPointerInteractionOngoing = isTargetingCanvas instanceof Element;
|
||||
}
|
||||
|
||||
if (viewportPointerInteractionOngoing) {
|
||||
const modifiers = makeKeyboardModifiersBitfield(e);
|
||||
editor.instance.onMouseDown(e.clientX, e.clientY, e.buttons, modifiers);
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerUp(e: PointerEvent): void {
|
||||
if (!e.buttons) viewportPointerInteractionOngoing = false;
|
||||
|
||||
if (!textInput) {
|
||||
const modifiers = makeKeyboardModifiersBitfield(e);
|
||||
editor.instance.onMouseUp(e.clientX, e.clientY, e.buttons, modifiers);
|
||||
}
|
||||
}
|
||||
|
||||
function onDoubleClick(e: PointerEvent): void {
|
||||
if (!e.buttons) viewportPointerInteractionOngoing = false;
|
||||
|
||||
if (!textInput) {
|
||||
const modifiers = makeKeyboardModifiersBitfield(e);
|
||||
editor.instance.onDoubleClick(e.clientX, e.clientY, e.buttons, modifiers);
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
const { target } = e;
|
||||
const isTargetingCanvas = target instanceof Element && target.closest("[data-canvas]");
|
||||
|
||||
// Redirect vertical scroll wheel movement into a horizontal scroll on a horizontally scrollable element
|
||||
// There seems to be no possible way to properly employ the browser's smooth scrolling interpolation
|
||||
const horizontalScrollableElement = target instanceof Element && target.closest("[data-scrollable-x]");
|
||||
if (horizontalScrollableElement && e.deltaY !== 0) {
|
||||
horizontalScrollableElement.scrollTo(horizontalScrollableElement.scrollLeft + e.deltaY, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTargetingCanvas) {
|
||||
e.preventDefault();
|
||||
const modifiers = makeKeyboardModifiersBitfield(e);
|
||||
editor.instance.onWheelScroll(e.clientX, e.clientY, e.buttons, e.deltaX, e.deltaY, e.deltaZ, modifiers);
|
||||
}
|
||||
}
|
||||
|
||||
function onModifyInputField(e: CustomEvent): void {
|
||||
textInput = e.detail;
|
||||
}
|
||||
|
||||
// Window events
|
||||
|
||||
function onWindowResize(container: HTMLElement): void {
|
||||
const viewports = Array.from(container.querySelectorAll("[data-canvas]"));
|
||||
const boundsOfViewports = viewports.map((canvas) => {
|
||||
const bounds = canvas.getBoundingClientRect();
|
||||
return [bounds.left, bounds.top, bounds.right, bounds.bottom];
|
||||
});
|
||||
|
||||
const flattened = boundsOfViewports.flat();
|
||||
const data = Float64Array.from(flattened);
|
||||
|
||||
if (boundsOfViewports.length > 0) editor.instance.boundsOfViewports(data);
|
||||
}
|
||||
|
||||
async function onBeforeUnload(e: BeforeUnloadEvent): Promise<void> {
|
||||
const activeDocument = get(document).documents[get(document).activeDocumentIndex];
|
||||
if (activeDocument && !activeDocument.isAutoSaved) editor.instance.triggerAutoSave(activeDocument.id);
|
||||
|
||||
// Skip the message if the editor crashed, since work is already lost
|
||||
if (await editor.instance.hasCrashed()) return;
|
||||
|
||||
// Skip the message during development, since it's annoying when testing
|
||||
if (await editor.instance.inDevelopmentMode()) return;
|
||||
|
||||
const allDocumentsSaved = get(document).documents.reduce((acc, doc) => acc && doc.isSaved, true);
|
||||
if (!allDocumentsSaved) {
|
||||
e.returnValue = "Unsaved work will be lost if the web browser tab is closed. Close anyway?";
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
function onPaste(e: ClipboardEvent): void {
|
||||
const dataTransfer = e.clipboardData;
|
||||
if (!dataTransfer || targetIsTextField(e.target || undefined)) return;
|
||||
e.preventDefault();
|
||||
|
||||
Array.from(dataTransfer.items).forEach((item) => {
|
||||
if (item.type === "text/plain") {
|
||||
item.getAsString((text) => {
|
||||
if (text.startsWith("graphite/layer: ")) {
|
||||
editor.instance.pasteSerializedData(text.substring(16, text.length));
|
||||
} else if (text.startsWith("graphite/nodes: ")) {
|
||||
editor.instance.pasteSerializedNodes(text.substring(16, text.length));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const file = item.getAsFile();
|
||||
if (file?.type.startsWith("image")) {
|
||||
file.arrayBuffer().then((buffer): void => {
|
||||
const u8Array = new Uint8Array(buffer);
|
||||
|
||||
editor.instance.pasteImage(file.type, u8Array);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Frontend message subscriptions
|
||||
|
||||
editor.subscriptions.subscribeJsMessage(TriggerPaste, async () => {
|
||||
// In the try block, attempt to read from the Clipboard API, which may not have permission and may not be supported in all browsers
|
||||
// In the catch block, explain to the user why the paste failed and how to fix or work around the problem
|
||||
try {
|
||||
// Attempt to check if the clipboard permission is denied, and throw an error if that is the case
|
||||
// In Firefox, the `clipboard-read` permission isn't supported, so attempting to query it throws an error
|
||||
// In Safari, the entire Permissions API isn't supported, so the query never occurs and this block is skipped without an error and we assume we might have permission
|
||||
const clipboardRead = "clipboard-read" as PermissionName;
|
||||
const permission = await navigator.permissions?.query({ name: clipboardRead });
|
||||
if (permission?.state === "denied") throw new Error("Permission denied");
|
||||
|
||||
// Read the clipboard contents if the Clipboard API is available
|
||||
const clipboardItems = await navigator.clipboard.read();
|
||||
if (!clipboardItems) throw new Error("Clipboard API unsupported");
|
||||
|
||||
// Read any layer data or images from the clipboard
|
||||
Array.from(clipboardItems).forEach(async (item) => {
|
||||
// Read plain text and, if it is a layer, pass it to the editor
|
||||
if (item.types.includes("text/plain")) {
|
||||
const blob = await item.getType("text/plain");
|
||||
const reader = new FileReader();
|
||||
reader.onload = (): void => {
|
||||
const text = reader.result as string;
|
||||
|
||||
if (text.startsWith("graphite/layer: ")) {
|
||||
editor.instance.pasteSerializedData(text.substring(16, text.length));
|
||||
}
|
||||
};
|
||||
reader.readAsText(blob);
|
||||
}
|
||||
|
||||
// Read an image from the clipboard and pass it to the editor to be loaded
|
||||
const imageType = item.types.find((type) => type.startsWith("image/"));
|
||||
if (imageType) {
|
||||
const blob = await item.getType(imageType);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (): void => {
|
||||
const u8Array = new Uint8Array(reader.result as ArrayBuffer);
|
||||
|
||||
editor.instance.pasteImage(imageType, u8Array);
|
||||
};
|
||||
reader.readAsArrayBuffer(blob);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
const unsupported = stripIndents`
|
||||
This browser does not support reading from the clipboard.
|
||||
Use the keyboard shortcut to paste instead.
|
||||
`;
|
||||
const denied = stripIndents`
|
||||
The browser's clipboard permission has been denied.
|
||||
|
||||
Open the browser's website settings (usually accessible
|
||||
just left of the URL) to allow this permission.
|
||||
`;
|
||||
|
||||
const matchMessage = {
|
||||
"clipboard-read": unsupported,
|
||||
"Clipboard API unsupported": unsupported,
|
||||
"Permission denied": denied,
|
||||
};
|
||||
const message = Object.entries(matchMessage).find(([key]) => String(err).includes(key))?.[1] || String(err);
|
||||
|
||||
editor.instance.errorDialog("Cannot access clipboard", message);
|
||||
}
|
||||
});
|
||||
|
||||
// Initialization
|
||||
|
||||
// Bind the event listeners
|
||||
bindListeners();
|
||||
// Resize on creation
|
||||
onWindowResize(window.document.body);
|
||||
|
||||
// Return the destructor
|
||||
return unbindListeners;
|
||||
}
|
||||
|
||||
function targetIsTextField(target: EventTarget | HTMLElement | undefined): boolean {
|
||||
return target instanceof HTMLElement && (target.nodeName === "INPUT" || target.nodeName === "TEXTAREA" || target.isContentEditable);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { type Editor } from "@/wasm-communication/editor";
|
||||
import { TriggerAboutGraphiteLocalizedCommitDate } from "@/wasm-communication/messages";
|
||||
|
||||
export function createLocalizationManager(editor: Editor): void {
|
||||
function localizeTimestamp(utc: string): string {
|
||||
// Timestamp
|
||||
const date = new Date(utc);
|
||||
if (Number.isNaN(date.getTime())) return utc;
|
||||
|
||||
const timezoneName = Intl.DateTimeFormat(undefined, { timeZoneName: "long" })
|
||||
.formatToParts(new Date())
|
||||
.find((part) => part.type === "timeZoneName");
|
||||
|
||||
const dateString = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||||
const timeString = `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
|
||||
const timezoneNameString = timezoneName?.value;
|
||||
return `${dateString} ${timeString} ${timezoneNameString}`;
|
||||
}
|
||||
|
||||
// Subscribe to process backend event
|
||||
editor.subscriptions.subscribeJsMessage(TriggerAboutGraphiteLocalizedCommitDate, (triggerAboutGraphiteLocalizedCommitDate) => {
|
||||
const localized = localizeTimestamp(triggerAboutGraphiteLocalizedCommitDate.commitDate);
|
||||
editor.instance.requestAboutGraphiteDialogWithLocalizedCommitDate(localized);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { wipeDocuments } from "@/io-managers/persistence";
|
||||
import { type DialogState } from "@/state-providers/dialog";
|
||||
import { type IconName } from "@/utility-functions/icons";
|
||||
import { browserVersion, operatingSystem } from "@/utility-functions/platform";
|
||||
import { stripIndents } from "@/utility-functions/strip-indents";
|
||||
import { type Editor } from "@/wasm-communication/editor";
|
||||
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 {
|
||||
// Code panic dialog and console error
|
||||
editor.subscriptions.subscribeJsMessage(DisplayDialogPanic, (displayDialogPanic) => {
|
||||
// `Error.stackTraceLimit` is only available in V8/Chromium
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(Error as any).stackTraceLimit = Infinity;
|
||||
const stackTrace = new Error().stack || "";
|
||||
const panicDetails = `${displayDialogPanic.panicInfo}${stackTrace ? `\n\n${stackTrace}` : ""}`;
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(panicDetails);
|
||||
|
||||
const panicDialog = preparePanicDialog(displayDialogPanic.header, displayDialogPanic.description, panicDetails);
|
||||
dialogState.createPanicDialog(...panicDialog);
|
||||
});
|
||||
}
|
||||
|
||||
function preparePanicDialog(header: string, details: string, panicDetails: string): [IconName, WidgetLayout, TextButtonWidget[]] {
|
||||
const headerLabel: TextLabel = { kind: "TextLabel", value: header, disabled: false, bold: true, italic: false, tableAlign: false, minWidth: 0, multiline: false, tooltip: "" };
|
||||
const detailsLabel: TextLabel = { kind: "TextLabel", value: details, disabled: false, bold: false, italic: false, tableAlign: false, minWidth: 0, multiline: true, tooltip: "" };
|
||||
|
||||
const widgets: WidgetLayout = {
|
||||
layout: [{ rowWidgets: [new Widget(headerLabel, 0n)] }, { rowWidgets: [new Widget(detailsLabel, 1n)] }],
|
||||
layoutTarget: undefined,
|
||||
};
|
||||
|
||||
const reloadButton: TextButtonWidget = {
|
||||
callback: async () => window.location.reload(),
|
||||
props: { kind: "TextButton", label: "Reload", emphasized: true, minWidth: 96 },
|
||||
};
|
||||
const copyErrorLogButton: TextButtonWidget = {
|
||||
callback: async () => navigator.clipboard.writeText(panicDetails),
|
||||
props: { kind: "TextButton", label: "Copy Error Log", emphasized: false, minWidth: 96 },
|
||||
};
|
||||
const reportOnGithubButton: TextButtonWidget = {
|
||||
callback: async () => window.open(githubUrl(panicDetails), "_blank"),
|
||||
props: { kind: "TextButton", label: "Report Bug", emphasized: false, minWidth: 96 },
|
||||
};
|
||||
const clearPersistedDataButton: TextButtonWidget = {
|
||||
callback: async () => {
|
||||
await wipeDocuments();
|
||||
window.location.reload();
|
||||
},
|
||||
props: { kind: "TextButton", label: "Clear Saved Data", emphasized: false, minWidth: 96 },
|
||||
};
|
||||
const jsCallbackBasedButtons = [reloadButton, copyErrorLogButton, reportOnGithubButton, clearPersistedDataButton];
|
||||
|
||||
return ["Warning", widgets, jsCallbackBasedButtons];
|
||||
}
|
||||
|
||||
function githubUrl(panicDetails: string): string {
|
||||
const url = new URL("https://github.com/GraphiteEditor/Graphite/issues/new");
|
||||
|
||||
let body = stripIndents`
|
||||
**Describe the Crash**
|
||||
Explain clearly what you were doing when the crash occurred.
|
||||
|
||||
**Steps To Reproduce**
|
||||
Describe precisely how the crash occurred, step by step, starting with a new editor window.
|
||||
1. Open the Graphite Editor at https://editor.graphite.rs
|
||||
2.
|
||||
3.
|
||||
4.
|
||||
5.
|
||||
|
||||
**Additional Details**
|
||||
Provide any further information or context that you think would be helpful in fixing the issue. Screenshots or video can be linked or attached to this issue.
|
||||
|
||||
**Browser and OS**
|
||||
${browserVersion()}, ${operatingSystem(true).replace("Unknown", "YOUR OPERATING SYSTEM")}
|
||||
|
||||
**Stack Trace**
|
||||
Copied from the crash dialog in the Graphite Editor:
|
||||
`;
|
||||
|
||||
body += "\n\n```\n";
|
||||
body += panicDetails.trimEnd();
|
||||
body += "\n```";
|
||||
|
||||
const fields = {
|
||||
title: "[Crash Report] ",
|
||||
body,
|
||||
labels: ["Crash"].join(","),
|
||||
projects: [].join(","),
|
||||
milestone: "",
|
||||
assignee: "",
|
||||
template: "",
|
||||
};
|
||||
|
||||
Object.entries(fields).forEach(([field, value]) => {
|
||||
if (value) url.searchParams.set(field, value);
|
||||
});
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
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 Editor } from "@/wasm-communication/editor";
|
||||
import { TriggerIndexedDbWriteDocument, TriggerIndexedDbRemoveDocument, TriggerSavePreferences, TriggerLoadAutoSaveDocuments, TriggerLoadPreferences } from "@/wasm-communication/messages";
|
||||
|
||||
const graphiteStore = createStore("graphite", "store");
|
||||
|
||||
export function createPersistenceManager(editor: Editor, portfolio: PortfolioState): void {
|
||||
// DOCUMENTS
|
||||
|
||||
async function storeDocumentOrder(): Promise<void> {
|
||||
const documentOrder = getFromStore(portfolio).documents.map((doc) => String(doc.id));
|
||||
|
||||
await set("documents_tab_order", documentOrder, graphiteStore);
|
||||
}
|
||||
|
||||
async function storeDocument(autoSaveDocument: TriggerIndexedDbWriteDocument): Promise<void> {
|
||||
await update<Record<string, TriggerIndexedDbWriteDocument>>(
|
||||
"documents",
|
||||
(old) => {
|
||||
const documents = old || {};
|
||||
documents[autoSaveDocument.details.id] = autoSaveDocument;
|
||||
return documents;
|
||||
},
|
||||
graphiteStore
|
||||
);
|
||||
|
||||
await storeDocumentOrder();
|
||||
}
|
||||
|
||||
async function removeDocument(id: string): Promise<void> {
|
||||
await update<Record<string, TriggerIndexedDbWriteDocument>>(
|
||||
"documents",
|
||||
(old) => {
|
||||
const documents = old || {};
|
||||
delete documents[id];
|
||||
return documents;
|
||||
},
|
||||
graphiteStore
|
||||
);
|
||||
|
||||
await storeDocumentOrder();
|
||||
}
|
||||
|
||||
async function loadDocuments(): Promise<void> {
|
||||
const previouslySavedDocuments = await get<Record<string, TriggerIndexedDbWriteDocument>>("documents", graphiteStore);
|
||||
const documentOrder = await get<string[]>("documents_tab_order", graphiteStore);
|
||||
if (!previouslySavedDocuments || !documentOrder) return;
|
||||
|
||||
const orderedSavedDocuments = documentOrder.flatMap((id) => (previouslySavedDocuments[id] ? [previouslySavedDocuments[id]] : []));
|
||||
|
||||
const currentDocumentVersion = editor.instance.graphiteDocumentVersion();
|
||||
orderedSavedDocuments?.forEach(async (doc: TriggerIndexedDbWriteDocument) => {
|
||||
if (doc.version !== currentDocumentVersion) {
|
||||
await removeDocument(doc.details.id);
|
||||
return;
|
||||
}
|
||||
|
||||
editor.instance.openAutoSavedDocument(BigInt(doc.details.id), doc.details.name, doc.details.isSaved, doc.document);
|
||||
});
|
||||
}
|
||||
|
||||
// PREFERENCES
|
||||
|
||||
async function savePreferences(preferences: TriggerSavePreferences["preferences"]): Promise<void> {
|
||||
await set("preferences", preferences, graphiteStore);
|
||||
}
|
||||
|
||||
async function loadPreferences(): Promise<void> {
|
||||
const preferences = await get<Record<string, unknown>>("preferences", graphiteStore);
|
||||
if (!preferences) return;
|
||||
|
||||
editor.instance.loadPreferences(JSON.stringify(preferences));
|
||||
}
|
||||
|
||||
// FRONTEND MESSAGE SUBSCRIPTIONS
|
||||
|
||||
// Subscribe to process backend events
|
||||
editor.subscriptions.subscribeJsMessage(TriggerSavePreferences, async (preferences) => {
|
||||
await savePreferences(preferences.preferences);
|
||||
});
|
||||
editor.subscriptions.subscribeJsMessage(TriggerLoadPreferences, async () => {
|
||||
await loadPreferences();
|
||||
});
|
||||
editor.subscriptions.subscribeJsMessage(TriggerIndexedDbWriteDocument, async (autoSaveDocument) => {
|
||||
await storeDocument(autoSaveDocument);
|
||||
});
|
||||
editor.subscriptions.subscribeJsMessage(TriggerIndexedDbRemoveDocument, async (removeAutoSaveDocument) => {
|
||||
await removeDocument(removeAutoSaveDocument.documentId);
|
||||
});
|
||||
editor.subscriptions.subscribeJsMessage(TriggerLoadAutoSaveDocuments, async () => {
|
||||
await loadDocuments();
|
||||
});
|
||||
}
|
||||
|
||||
export async function wipeDocuments(): Promise<void> {
|
||||
await del("documents_tab_order", graphiteStore);
|
||||
await del("documents", graphiteStore);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// This file is the browser's entry point for the JS bundle
|
||||
|
||||
// reflect-metadata allows for runtime reflection of types in JavaScript.
|
||||
// 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.
|
||||
import "reflect-metadata";
|
||||
|
||||
import App from "@/App.svelte";
|
||||
|
||||
export default new App({ target: document.body });
|
||||
@@ -0,0 +1,60 @@
|
||||
import {writable} from "svelte/store";
|
||||
|
||||
import { type IconName } from "@/utility-functions/icons";
|
||||
import { type Editor } from "@/wasm-communication/editor";
|
||||
import { type TextButtonWidget, type WidgetLayout, defaultWidgetLayout, DisplayDialog, DisplayDialogDismiss, UpdateDialogDetails, patchWidgetLayout } from "@/wasm-communication/messages";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
||||
export function createDialogState(editor: Editor) {
|
||||
const { subscribe, update } = writable({
|
||||
visible: false,
|
||||
icon: "" as IconName,
|
||||
widgets: defaultWidgetLayout(),
|
||||
// Special case for the crash dialog because we cannot handle button widget callbacks from Rust once the editor instance has panicked
|
||||
jsCallbackBasedButtons: undefined as undefined | TextButtonWidget[],
|
||||
});
|
||||
|
||||
function dismissDialog(): void {
|
||||
update((state) => {
|
||||
state.visible = false;
|
||||
return state;
|
||||
});
|
||||
}
|
||||
|
||||
// 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.
|
||||
function createPanicDialog(icon: IconName, widgets: WidgetLayout, jsCallbackBasedButtons: TextButtonWidget[]): void {
|
||||
update((state) => {
|
||||
state.visible = true;
|
||||
state.icon = icon;
|
||||
state.widgets = widgets;
|
||||
state.jsCallbackBasedButtons = jsCallbackBasedButtons;
|
||||
return state;
|
||||
});
|
||||
}
|
||||
|
||||
// Subscribe to process backend events
|
||||
editor.subscriptions.subscribeJsMessage(DisplayDialog, (displayDialog) => {
|
||||
update((state) => {
|
||||
state.visible = true;
|
||||
state.icon = displayDialog.icon;
|
||||
return state;
|
||||
});
|
||||
});
|
||||
editor.subscriptions.subscribeJsMessage(UpdateDialogDetails, (updateDialogDetails) => {
|
||||
update((state) => {
|
||||
patchWidgetLayout(state.widgets, updateDialogDetails);
|
||||
|
||||
state.jsCallbackBasedButtons = undefined;
|
||||
return state;
|
||||
});
|
||||
});
|
||||
editor.subscriptions.subscribeJsMessage(DisplayDialogDismiss, dismissDialog);
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
dismissDialog,
|
||||
createPanicDialog,
|
||||
};
|
||||
}
|
||||
export type DialogState = ReturnType<typeof createDialogState>;
|
||||
@@ -0,0 +1,88 @@
|
||||
import {tick} from "svelte";
|
||||
import {writable} from "svelte/store";
|
||||
|
||||
import { type Editor } from "@/wasm-communication/editor";
|
||||
import {
|
||||
defaultWidgetLayout,
|
||||
patchWidgetLayout,
|
||||
TriggerRefreshBoundsOfViewports,
|
||||
UpdateDocumentBarLayout,
|
||||
UpdateDocumentModeLayout,
|
||||
UpdateToolOptionsLayout,
|
||||
UpdateToolShelfLayout,
|
||||
UpdateWorkingColorsLayout,
|
||||
} from "@/wasm-communication/messages";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
||||
export function createDocumentState(editor: Editor) {
|
||||
const state = writable({
|
||||
// Layouts
|
||||
documentModeLayout: defaultWidgetLayout(),
|
||||
toolOptionsLayout: defaultWidgetLayout(),
|
||||
documentBarLayout: defaultWidgetLayout(),
|
||||
toolShelfLayout: defaultWidgetLayout(),
|
||||
workingColorsLayout: defaultWidgetLayout(),
|
||||
});
|
||||
const { subscribe, update } = state;
|
||||
|
||||
// Update layouts
|
||||
editor.subscriptions.subscribeJsMessage(UpdateDocumentModeLayout, async (updateDocumentModeLayout) => {
|
||||
await tick();
|
||||
|
||||
update((state) => {
|
||||
// `state.documentModeLayout` is mutated in the function
|
||||
patchWidgetLayout(state.documentModeLayout, updateDocumentModeLayout);
|
||||
return state;
|
||||
});
|
||||
});
|
||||
editor.subscriptions.subscribeJsMessage(UpdateToolOptionsLayout, async (updateToolOptionsLayout) => {
|
||||
await tick();
|
||||
|
||||
update((state) => {
|
||||
// `state.documentModeLayout` is mutated in the function
|
||||
patchWidgetLayout(state.toolOptionsLayout, updateToolOptionsLayout);
|
||||
return state;
|
||||
});
|
||||
});
|
||||
editor.subscriptions.subscribeJsMessage(UpdateDocumentBarLayout, async (updateDocumentBarLayout) => {
|
||||
await tick();
|
||||
|
||||
update((state) => {
|
||||
// `state.documentModeLayout` is mutated in the function
|
||||
patchWidgetLayout(state.documentBarLayout, updateDocumentBarLayout);
|
||||
return state;
|
||||
});
|
||||
});
|
||||
editor.subscriptions.subscribeJsMessage(UpdateToolShelfLayout, async (updateToolShelfLayout) => {
|
||||
await tick();
|
||||
|
||||
update((state) => {
|
||||
// `state.documentModeLayout` is mutated in the function
|
||||
patchWidgetLayout(state.toolShelfLayout, updateToolShelfLayout);
|
||||
return state;
|
||||
});
|
||||
});
|
||||
editor.subscriptions.subscribeJsMessage(UpdateWorkingColorsLayout, async (updateWorkingColorsLayout) => {
|
||||
await tick();
|
||||
|
||||
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 {
|
||||
subscribe,
|
||||
};
|
||||
}
|
||||
export type DocumentState = ReturnType<typeof createDocumentState>;
|
||||
@@ -0,0 +1,100 @@
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
import { type Editor } from "@/wasm-communication/editor";
|
||||
import { TriggerFontLoad } from "@/wasm-communication/messages";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
||||
export function createFontsState(editor: Editor) {
|
||||
// TODO: Svelte: refactor to remove the need for this empty store
|
||||
const { subscribe } = writable({});
|
||||
|
||||
function createURL(font: string): URL {
|
||||
const url = new URL("https://fonts.googleapis.com/css2");
|
||||
url.searchParams.set("display", "swap");
|
||||
url.searchParams.set("family", font);
|
||||
url.searchParams.set("text", font);
|
||||
return url;
|
||||
}
|
||||
|
||||
async function fontNames(): Promise<{ name: string; url: URL | undefined }[]> {
|
||||
return (await fontList).map((font) => ({ name: font.family, url: createURL(font.family) }));
|
||||
}
|
||||
|
||||
async function getFontStyles(fontFamily: string): Promise<{ name: string; url: URL | undefined }[]> {
|
||||
const font = (await fontList).find((value) => value.family === fontFamily);
|
||||
return font?.variants.map((variant) => ({ name: variant, url: undefined })) || [];
|
||||
}
|
||||
|
||||
async function getFontFileUrl(fontFamily: string, fontStyle: string): Promise<string | undefined> {
|
||||
const font = (await fontList).find((value) => value.family === fontFamily);
|
||||
const fontFileUrl = font?.files.get(fontStyle);
|
||||
return fontFileUrl?.replace("http://", "https://");
|
||||
}
|
||||
|
||||
function formatFontStyleName(fontStyle: string): string {
|
||||
const isItalic = fontStyle.endsWith("italic");
|
||||
const weight = fontStyle === "regular" || fontStyle === "italic" ? 400 : parseInt(fontStyle, 10);
|
||||
let weightName = "";
|
||||
|
||||
let bestWeight = Infinity;
|
||||
weightNameMapping.forEach((nameChecking, weightChecking) => {
|
||||
if (Math.abs(weightChecking - weight) < bestWeight) {
|
||||
bestWeight = Math.abs(weightChecking - weight);
|
||||
weightName = nameChecking;
|
||||
}
|
||||
});
|
||||
|
||||
return `${weightName}${isItalic ? " Italic" : ""} (${weight})`;
|
||||
}
|
||||
|
||||
// Subscribe to process backend events
|
||||
editor.subscriptions.subscribeJsMessage(TriggerFontLoad, async (triggerFontLoad) => {
|
||||
const url = await getFontFileUrl(triggerFontLoad.font.fontFamily, triggerFontLoad.font.fontStyle);
|
||||
if (url) {
|
||||
const response = await (await fetch(url)).arrayBuffer();
|
||||
editor.instance.onFontLoad(triggerFontLoad.font.fontFamily, triggerFontLoad.font.fontStyle, url, new Uint8Array(response), triggerFontLoad.isDefault);
|
||||
} else {
|
||||
editor.instance.errorDialog("Failed to load font", `The font ${triggerFontLoad.font.fontFamily} with style ${triggerFontLoad.font.fontStyle} does not exist`);
|
||||
}
|
||||
});
|
||||
|
||||
const fontList = new Promise<{ family: string; variants: string[]; files: Map<string, string> }[]>((resolve) => {
|
||||
fetch(fontListAPI)
|
||||
.then((response) => response.json())
|
||||
.then((fontListResponse) => {
|
||||
const fontListData = fontListResponse.items as { family: string; variants: string[]; files: Record<string, string> }[];
|
||||
const result = fontListData.map((font) => {
|
||||
const { family } = font;
|
||||
const variants = font.variants.map(formatFontStyleName);
|
||||
const files = new Map(font.variants.map((x) => [formatFontStyleName(x), font.files[x]]));
|
||||
return { family, variants, files };
|
||||
});
|
||||
|
||||
resolve(result);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
fontNames,
|
||||
getFontStyles,
|
||||
getFontFileUrl,
|
||||
};
|
||||
}
|
||||
export type FontsState = ReturnType<typeof createFontsState>;
|
||||
|
||||
const fontListAPI = "https://api.graphite.rs/font-list";
|
||||
|
||||
// From https://developer.mozilla.org/en-US/docs/Web/CSS/font-weight#common_weight_name_mapping
|
||||
const weightNameMapping = new Map([
|
||||
[100, "Thin"],
|
||||
[200, "Extra Light"],
|
||||
[300, "Light"],
|
||||
[400, "Normal"],
|
||||
[500, "Medium"],
|
||||
[600, "Semi Bold"],
|
||||
[700, "Bold"],
|
||||
[800, "Extra Bold"],
|
||||
[900, "Black"],
|
||||
[950, "Extra Black"],
|
||||
]);
|
||||
@@ -0,0 +1,62 @@
|
||||
import {writable} from "svelte/store";
|
||||
|
||||
import { type Editor } from "@/wasm-communication/editor";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
||||
export function createFullscreenState(_: Editor) {
|
||||
const { subscribe, update } = writable({
|
||||
windowFullscreen: false,
|
||||
keyboardLocked: false,
|
||||
});
|
||||
|
||||
function fullscreenModeChanged(): void {
|
||||
update((state) => {
|
||||
state.windowFullscreen = Boolean(document.fullscreenElement);
|
||||
if (!state.windowFullscreen) state.keyboardLocked = false;
|
||||
return state;
|
||||
});
|
||||
}
|
||||
|
||||
async function enterFullscreen(): Promise<void> {
|
||||
await document.documentElement.requestFullscreen();
|
||||
|
||||
if (keyboardLockApiSupported) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (navigator as any).keyboard.lock(["ControlLeft", "ControlRight"]);
|
||||
|
||||
update((state) => {
|
||||
state.keyboardLocked = true;
|
||||
return state;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function exitFullscreen(): Promise<void> {
|
||||
await document.exitFullscreen();
|
||||
}
|
||||
|
||||
async function toggleFullscreen(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
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
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const keyboardLockApiSupported: Readonly<boolean> = "keyboard" in navigator && (navigator as any).keyboard && "lock" in (navigator as any).keyboard;
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
fullscreenModeChanged,
|
||||
enterFullscreen,
|
||||
exitFullscreen,
|
||||
toggleFullscreen,
|
||||
keyboardLockApiSupported,
|
||||
};
|
||||
}
|
||||
export type FullscreenState = ReturnType<typeof createFullscreenState>;
|
||||
@@ -0,0 +1,50 @@
|
||||
import {tick} from "svelte";
|
||||
import {writable} from "svelte/store";
|
||||
|
||||
import { type Editor } from "@/wasm-communication/editor";
|
||||
import {
|
||||
type FrontendNode,
|
||||
type FrontendNodeLink,
|
||||
type FrontendNodeType,
|
||||
UpdateNodeGraph,
|
||||
UpdateNodeTypes,
|
||||
UpdateNodeGraphBarLayout,
|
||||
defaultWidgetLayout,
|
||||
patchWidgetLayout,
|
||||
} from "@/wasm-communication/messages";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
||||
export function createNodeGraphState(editor: Editor) {
|
||||
const { subscribe, update } = writable({
|
||||
nodes: [] as FrontendNode[],
|
||||
links: [] as FrontendNodeLink[],
|
||||
nodeTypes: [] as FrontendNodeType[],
|
||||
nodeGraphBarLayout: defaultWidgetLayout(),
|
||||
});
|
||||
|
||||
// Set up message subscriptions on creation
|
||||
editor.subscriptions.subscribeJsMessage(UpdateNodeGraph, (updateNodeGraph) => {
|
||||
update((state) => {
|
||||
state.nodes = updateNodeGraph.nodes;
|
||||
state.links = updateNodeGraph.links;
|
||||
return state;
|
||||
});
|
||||
});
|
||||
editor.subscriptions.subscribeJsMessage(UpdateNodeTypes, (updateNodeTypes) => {
|
||||
update((state) => {
|
||||
state.nodeTypes = updateNodeTypes.nodeTypes;
|
||||
return state;
|
||||
});
|
||||
});
|
||||
editor.subscriptions.subscribeJsMessage(UpdateNodeGraphBarLayout, (updateNodeGraphBarLayout) => {
|
||||
update((state) => {
|
||||
patchWidgetLayout(state.nodeGraphBarLayout, updateNodeGraphBarLayout);
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
};
|
||||
}
|
||||
export type NodeGraphState = ReturnType<typeof createNodeGraphState>;
|
||||
@@ -0,0 +1,134 @@
|
||||
/* eslint-disable max-classes-per-file */
|
||||
|
||||
import {writable} from "svelte/store";
|
||||
|
||||
import { downloadFileText, downloadFileBlob, upload } from "@/utility-functions/files";
|
||||
import { imaginateGenerate, imaginateCheckConnection, imaginateTerminate, updateBackendImage } from "@/utility-functions/imaginate";
|
||||
import { rasterizeSVG, rasterizeSVGCanvas } from "@/utility-functions/rasterization";
|
||||
import { type Editor } from "@/wasm-communication/editor";
|
||||
import {
|
||||
type FrontendDocumentDetails,
|
||||
TriggerFileDownload,
|
||||
TriggerImport,
|
||||
TriggerOpenDocument,
|
||||
TriggerRasterDownload,
|
||||
TriggerImaginateGenerate,
|
||||
TriggerImaginateTerminate,
|
||||
TriggerImaginateCheckServerStatus,
|
||||
TriggerNodeGraphFrameGenerate,
|
||||
UpdateActiveDocument,
|
||||
UpdateOpenDocumentsList,
|
||||
UpdateImageData,
|
||||
TriggerRevokeBlobUrl,
|
||||
} from "@/wasm-communication/messages";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
||||
export function createPortfolioState(editor: Editor) {
|
||||
const { subscribe, update } = writable({
|
||||
unsaved: false,
|
||||
documents: [] as FrontendDocumentDetails[],
|
||||
activeDocumentIndex: 0,
|
||||
});
|
||||
|
||||
// Set up message subscriptions on creation
|
||||
editor.subscriptions.subscribeJsMessage(UpdateOpenDocumentsList, (updateOpenDocumentList) => {
|
||||
update((state) => {
|
||||
state.documents = updateOpenDocumentList.openDocuments;
|
||||
return state;
|
||||
})
|
||||
});
|
||||
editor.subscriptions.subscribeJsMessage(UpdateActiveDocument, (updateActiveDocument) => {
|
||||
update((state) => {
|
||||
// Assume we receive a correct document id
|
||||
const activeId = state.documents.findIndex((doc) => doc.id === updateActiveDocument.documentId);
|
||||
state.activeDocumentIndex = activeId;
|
||||
return state;
|
||||
})
|
||||
});
|
||||
editor.subscriptions.subscribeJsMessage(TriggerOpenDocument, async () => {
|
||||
const extension = editor.instance.fileSaveSuffix();
|
||||
const data = await upload(extension, "text");
|
||||
editor.instance.openDocumentFile(data.filename, data.content);
|
||||
});
|
||||
editor.subscriptions.subscribeJsMessage(TriggerImport, async () => {
|
||||
const data = await upload("image/*", "data");
|
||||
editor.instance.pasteImage(data.type, Uint8Array.from(data.content));
|
||||
});
|
||||
editor.subscriptions.subscribeJsMessage(TriggerFileDownload, (triggerFileDownload) => {
|
||||
downloadFileText(triggerFileDownload.name, triggerFileDownload.document);
|
||||
});
|
||||
editor.subscriptions.subscribeJsMessage(TriggerRasterDownload, async (triggerRasterDownload) => {
|
||||
const { svg, name, mime, size } = triggerRasterDownload;
|
||||
|
||||
// Fill the canvas with white if it'll be a JPEG (which does not support transparency and defaults to black)
|
||||
const backgroundColor = mime.endsWith("jpeg") ? "white" : undefined;
|
||||
|
||||
// Rasterize the SVG to an image file
|
||||
const blob = await rasterizeSVG(svg, size.x, size.y, mime, backgroundColor);
|
||||
|
||||
// Have the browser download the file to the user's disk
|
||||
downloadFileBlob(name, blob);
|
||||
});
|
||||
editor.subscriptions.subscribeJsMessage(TriggerImaginateCheckServerStatus, async (triggerImaginateCheckServerStatus) => {
|
||||
const { hostname } = triggerImaginateCheckServerStatus;
|
||||
|
||||
imaginateCheckConnection(hostname, editor);
|
||||
});
|
||||
editor.subscriptions.subscribeJsMessage(TriggerImaginateGenerate, async (triggerImaginateGenerate) => {
|
||||
const { documentId, layerPath, nodePath, hostname, refreshFrequency, baseImage, maskImage, maskPaintMode, maskBlurPx, maskFillContent, parameters } = triggerImaginateGenerate;
|
||||
|
||||
// Handle img2img mode
|
||||
let image: Blob | undefined;
|
||||
if (parameters.denoisingStrength !== undefined && baseImage !== undefined) {
|
||||
const buffer = new Uint8Array(baseImage.imageData.values()).buffer;
|
||||
|
||||
image = new Blob([buffer], { type: baseImage.mime });
|
||||
updateBackendImage(editor, image, documentId, layerPath, nodePath);
|
||||
}
|
||||
|
||||
// Handle layer mask
|
||||
let mask: Blob | undefined;
|
||||
if (maskImage !== undefined) {
|
||||
// Rasterize the SVG to an image file
|
||||
mask = await rasterizeSVG(maskImage.svg, maskImage.size[0], maskImage.size[1], "image/png");
|
||||
}
|
||||
|
||||
imaginateGenerate(parameters, image, mask, maskPaintMode, maskBlurPx, maskFillContent, hostname, refreshFrequency, documentId, layerPath, nodePath, editor);
|
||||
});
|
||||
editor.subscriptions.subscribeJsMessage(TriggerImaginateTerminate, async (triggerImaginateTerminate) => {
|
||||
const { documentId, layerPath, nodePath, hostname } = triggerImaginateTerminate;
|
||||
|
||||
imaginateTerminate(hostname, documentId, layerPath, nodePath, editor);
|
||||
});
|
||||
editor.subscriptions.subscribeJsMessage(UpdateImageData, (updateImageData) => {
|
||||
updateImageData.imageData.forEach(async (element) => {
|
||||
const buffer = new Uint8Array(element.imageData.values()).buffer;
|
||||
const blob = new Blob([buffer], { type: element.mime });
|
||||
|
||||
const blobURL = URL.createObjectURL(blob);
|
||||
|
||||
// Pre-decode the image so it is ready to be drawn instantly once it's placed into the viewport SVG
|
||||
const image = new Image();
|
||||
image.src = blobURL;
|
||||
await image.decode();
|
||||
|
||||
editor.instance.setImageBlobURL(updateImageData.documentId, element.path, blobURL, image.naturalWidth, image.naturalHeight);
|
||||
});
|
||||
});
|
||||
editor.subscriptions.subscribeJsMessage(TriggerNodeGraphFrameGenerate, async (triggerNodeGraphFrameGenerate) => {
|
||||
const { documentId, layerPath, svg, size, imaginateNode } = triggerNodeGraphFrameGenerate;
|
||||
|
||||
// Rasterize the SVG to an image file
|
||||
const imageData = (await rasterizeSVGCanvas(svg, size[0], size[1])).getContext("2d")?.getImageData(0, 0, size[0], size[1]);
|
||||
|
||||
if (imageData) editor.instance.processNodeGraphFrame(documentId, layerPath, new Uint8Array(imageData.data), imageData.width, imageData.height, imaginateNode);
|
||||
});
|
||||
editor.subscriptions.subscribeJsMessage(TriggerRevokeBlobUrl, async (triggerRevokeBlobUrl) => {
|
||||
URL.revokeObjectURL(triggerRevokeBlobUrl.url);
|
||||
});
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
};
|
||||
}
|
||||
export type PortfolioState = ReturnType<typeof createPortfolioState>;
|
||||
@@ -0,0 +1,31 @@
|
||||
/* eslint-disable max-classes-per-file */
|
||||
|
||||
import {tick} from "svelte";
|
||||
import {writable} from "svelte/store";
|
||||
|
||||
import { type Editor } from "@/wasm-communication/editor";
|
||||
import { UpdateNodeGraphVisibility } from "@/wasm-communication/messages";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
||||
export function createWorkspaceState(editor: Editor) {
|
||||
const { subscribe, update } = writable({
|
||||
nodeGraphVisible: false,
|
||||
});
|
||||
|
||||
// Set up message subscriptions on creation
|
||||
editor.subscriptions.subscribeJsMessage(UpdateNodeGraphVisibility, async (updateNodeGraphVisibility) => {
|
||||
update((state) => {
|
||||
state.nodeGraphVisible = updateNodeGraphVisibility.visible;
|
||||
return state;
|
||||
});
|
||||
|
||||
// Update the viewport bounds
|
||||
await tick();
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
});
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
};
|
||||
}
|
||||
export type WorkspaceState = ReturnType<typeof createWorkspaceState>;
|
||||
@@ -0,0 +1,31 @@
|
||||
export type Debouncer = ReturnType<typeof debouncer>;
|
||||
|
||||
export type DebouncerOptions = {
|
||||
debounceTime: number;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
||||
export function debouncer<T>(callFn: (value: T) => unknown, { debounceTime = 60 }: Partial<DebouncerOptions> = {}) {
|
||||
let currentValue: T | undefined;
|
||||
|
||||
const emitValue = (): void => {
|
||||
if (currentValue === undefined) {
|
||||
throw new Error("Tried to emit undefined value from debouncer. This should never be possible");
|
||||
}
|
||||
const emittingValue = currentValue;
|
||||
currentValue = undefined;
|
||||
callFn(emittingValue);
|
||||
};
|
||||
|
||||
const updateValue = (newValue: T): void => {
|
||||
if (currentValue !== undefined) {
|
||||
currentValue = newValue;
|
||||
return;
|
||||
}
|
||||
|
||||
currentValue = newValue;
|
||||
setTimeout(emitValue, debounceTime);
|
||||
};
|
||||
|
||||
return { updateValue };
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/* eslint-disable no-useless-escape */
|
||||
/* eslint-disable quotes */
|
||||
|
||||
export function escapeJSON(str: string): string {
|
||||
return str
|
||||
.replace(/[\\]/g, "\\\\")
|
||||
.replace(/[\"]/g, '\\"')
|
||||
.replace(/[\/]/g, "\\/")
|
||||
.replace(/[\b]/g, "\\b")
|
||||
.replace(/[\f]/g, "\\f")
|
||||
.replace(/[\n]/g, "\\n")
|
||||
.replace(/[\r]/g, "\\r")
|
||||
.replace(/[\t]/g, "\\t");
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
export function downloadFileURL(filename: string, url: string): void {
|
||||
const element = document.createElement("a");
|
||||
|
||||
element.href = url;
|
||||
element.setAttribute("download", filename);
|
||||
|
||||
element.click();
|
||||
}
|
||||
|
||||
export function downloadFileBlob(filename: string, blob: Blob): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
downloadFileURL(filename, url);
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function downloadFileText(filename: string, text: string): void {
|
||||
const type = filename.endsWith(".svg") ? "image/svg+xml;charset=utf-8" : "text/plain;charset=utf-8";
|
||||
|
||||
const blob = new Blob([text], { type });
|
||||
downloadFileBlob(filename, blob);
|
||||
}
|
||||
|
||||
export async function upload<T extends "text" | "data">(acceptedExtensions: string, textOrData: T): Promise<UploadResult<T>> {
|
||||
return new Promise<UploadResult<T>>((resolve, _) => {
|
||||
const element = document.createElement("input");
|
||||
element.type = "file";
|
||||
element.accept = acceptedExtensions;
|
||||
|
||||
element.addEventListener(
|
||||
"change",
|
||||
async () => {
|
||||
if (element.files?.length) {
|
||||
const file = element.files[0];
|
||||
|
||||
const filename = file.name;
|
||||
const type = file.type;
|
||||
const content = (textOrData === "text" ? await file.text() : new Uint8Array(await file.arrayBuffer())) as UploadResultType<T>;
|
||||
|
||||
resolve({ filename, type, content });
|
||||
}
|
||||
},
|
||||
{ capture: false, once: true }
|
||||
);
|
||||
|
||||
element.click();
|
||||
|
||||
// Once `element` goes out of scope, it has no references so it gets garbage collected along with its event listener, so `removeEventListener` is not needed
|
||||
});
|
||||
}
|
||||
export type UploadResult<T> = { filename: string; type: string; content: UploadResultType<T> };
|
||||
type UploadResultType<T> = T extends "text" ? string : T extends "data" ? Uint8Array : never;
|
||||
|
||||
export function blobToBase64(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = (): void => resolve(typeof reader.result === "string" ? reader.result : "");
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
export async function replaceBlobURLsWithBase64(svg: string): Promise<string> {
|
||||
const splitByBlobs = svg.split(/("blob:.*?")/);
|
||||
const onlyBlobs = splitByBlobs.filter((_, i) => i % 2 === 1);
|
||||
|
||||
const onlyBlobsConverted = onlyBlobs.map(async (blobURL) => {
|
||||
const urlWithoutQuotes = blobURL.slice(1, -1);
|
||||
const data = await fetch(urlWithoutQuotes);
|
||||
const dataBlob = await data.blob();
|
||||
return blobToBase64(dataBlob);
|
||||
});
|
||||
const base64Images = await Promise.all(onlyBlobsConverted);
|
||||
|
||||
const substituted = splitByBlobs.map((segment, i) => {
|
||||
if (i % 2 === 0) return segment;
|
||||
|
||||
const blobsIndex = Math.floor(i / 2);
|
||||
return `"${base64Images[blobsIndex]}"`;
|
||||
});
|
||||
return substituted.join("");
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
/* eslint-disable import/first */
|
||||
|
||||
// Graphics
|
||||
import GraphiteLogotypeSolid from "@/../assets/graphics/graphite-logotype-solid.svg";
|
||||
|
||||
const GRAPHICS = {
|
||||
GraphiteLogotypeSolid: { svg: GraphiteLogotypeSolid, size: undefined },
|
||||
} as const;
|
||||
|
||||
// 12px Solid
|
||||
import Add from "@/../assets/icon-12px-solid/add.svg";
|
||||
import Checkmark from "@/../assets/icon-12px-solid/checkmark.svg";
|
||||
import CloseX from "@/../assets/icon-12px-solid/close-x.svg";
|
||||
import DropdownArrow from "@/../assets/icon-12px-solid/dropdown-arrow.svg";
|
||||
import Edit from "@/../assets/icon-12px-solid/edit.svg";
|
||||
import Empty12px from "@/../assets/icon-12px-solid/empty-12px.svg";
|
||||
import FullscreenEnter from "@/../assets/icon-12px-solid/fullscreen-enter.svg";
|
||||
import FullscreenExit from "@/../assets/icon-12px-solid/fullscreen-exit.svg";
|
||||
import Grid from "@/../assets/icon-12px-solid/grid.svg";
|
||||
import Info from "@/../assets/icon-12px-solid/info.svg";
|
||||
import KeyboardArrowDown from "@/../assets/icon-12px-solid/keyboard-arrow-down.svg";
|
||||
import KeyboardArrowLeft from "@/../assets/icon-12px-solid/keyboard-arrow-left.svg";
|
||||
import KeyboardArrowRight from "@/../assets/icon-12px-solid/keyboard-arrow-right.svg";
|
||||
import KeyboardArrowUp from "@/../assets/icon-12px-solid/keyboard-arrow-up.svg";
|
||||
import KeyboardBackspace from "@/../assets/icon-12px-solid/keyboard-backspace.svg";
|
||||
import KeyboardCommand from "@/../assets/icon-12px-solid/keyboard-command.svg";
|
||||
import KeyboardControl from "@/../assets/icon-12px-solid/keyboard-control.svg";
|
||||
import KeyboardEnter from "@/../assets/icon-12px-solid/keyboard-enter.svg";
|
||||
import KeyboardOption from "@/../assets/icon-12px-solid/keyboard-option.svg";
|
||||
import KeyboardShift from "@/../assets/icon-12px-solid/keyboard-shift.svg";
|
||||
import KeyboardSpace from "@/../assets/icon-12px-solid/keyboard-space.svg";
|
||||
import KeyboardTab from "@/../assets/icon-12px-solid/keyboard-tab.svg";
|
||||
import Link from "@/../assets/icon-12px-solid/link.svg";
|
||||
import Overlays from "@/../assets/icon-12px-solid/overlays.svg";
|
||||
import Remove from "@/../assets/icon-12px-solid/remove.svg";
|
||||
import ResetColors from "@/../assets/icon-12px-solid/reset-colors.svg";
|
||||
import Snapping from "@/../assets/icon-12px-solid/snapping.svg";
|
||||
import Swap from "@/../assets/icon-12px-solid/swap.svg";
|
||||
import VerticalEllipsis from "@/../assets/icon-12px-solid/vertical-ellipsis.svg";
|
||||
import Warning from "@/../assets/icon-12px-solid/warning.svg";
|
||||
import WindowButtonWinClose from "@/../assets/icon-12px-solid/window-button-win-close.svg";
|
||||
import WindowButtonWinMaximize from "@/../assets/icon-12px-solid/window-button-win-maximize.svg";
|
||||
import WindowButtonWinMinimize from "@/../assets/icon-12px-solid/window-button-win-minimize.svg";
|
||||
import WindowButtonWinRestoreDown from "@/../assets/icon-12px-solid/window-button-win-restore-down.svg";
|
||||
|
||||
const SOLID_12PX = {
|
||||
Add: { svg: Add, size: 12 },
|
||||
Checkmark: { svg: Checkmark, size: 12 },
|
||||
CloseX: { svg: CloseX, size: 12 },
|
||||
DropdownArrow: { svg: DropdownArrow, size: 12 },
|
||||
Edit: { svg: Edit, size: 12 },
|
||||
Empty12px: { svg: Empty12px, size: 12 },
|
||||
FullscreenEnter: { svg: FullscreenEnter, size: 12 },
|
||||
FullscreenExit: { svg: FullscreenExit, size: 12 },
|
||||
Grid: { svg: Grid, size: 12 },
|
||||
Info: { svg: Info, size: 12 },
|
||||
KeyboardArrowDown: { svg: KeyboardArrowDown, size: 12 },
|
||||
KeyboardArrowLeft: { svg: KeyboardArrowLeft, size: 12 },
|
||||
KeyboardArrowRight: { svg: KeyboardArrowRight, size: 12 },
|
||||
KeyboardArrowUp: { svg: KeyboardArrowUp, size: 12 },
|
||||
KeyboardBackspace: { svg: KeyboardBackspace, size: 12 },
|
||||
KeyboardCommand: { svg: KeyboardCommand, size: 12 },
|
||||
KeyboardControl: { svg: KeyboardControl, size: 12 },
|
||||
KeyboardEnter: { svg: KeyboardEnter, size: 12 },
|
||||
KeyboardOption: { svg: KeyboardOption, size: 12 },
|
||||
KeyboardShift: { svg: KeyboardShift, size: 12 },
|
||||
KeyboardSpace: { svg: KeyboardSpace, size: 12 },
|
||||
KeyboardTab: { svg: KeyboardTab, size: 12 },
|
||||
Link: { svg: Link, size: 12 },
|
||||
Overlays: { svg: Overlays, size: 12 },
|
||||
Remove: { svg: Remove, size: 12 },
|
||||
ResetColors: { svg: ResetColors, size: 12 },
|
||||
Snapping: { svg: Snapping, size: 12 },
|
||||
Swap: { svg: Swap, size: 12 },
|
||||
VerticalEllipsis: { svg: VerticalEllipsis, size: 12 },
|
||||
Warning: { svg: Warning, size: 12 },
|
||||
WindowButtonWinClose: { svg: WindowButtonWinClose, size: 12 },
|
||||
WindowButtonWinMaximize: { svg: WindowButtonWinMaximize, size: 12 },
|
||||
WindowButtonWinMinimize: { svg: WindowButtonWinMinimize, size: 12 },
|
||||
WindowButtonWinRestoreDown: { svg: WindowButtonWinRestoreDown, size: 12 },
|
||||
} as const;
|
||||
|
||||
// 16px Solid
|
||||
import AlignBottom from "@/../assets/icon-16px-solid/align-bottom.svg";
|
||||
import AlignHorizontalCenter from "@/../assets/icon-16px-solid/align-horizontal-center.svg";
|
||||
import AlignLeft from "@/../assets/icon-16px-solid/align-left.svg";
|
||||
import AlignRight from "@/../assets/icon-16px-solid/align-right.svg";
|
||||
import AlignTop from "@/../assets/icon-16px-solid/align-top.svg";
|
||||
import AlignVerticalCenter from "@/../assets/icon-16px-solid/align-vertical-center.svg";
|
||||
import BooleanDifference from "@/../assets/icon-16px-solid/boolean-difference.svg";
|
||||
import BooleanIntersect from "@/../assets/icon-16px-solid/boolean-intersect.svg";
|
||||
import BooleanSubtractBack from "@/../assets/icon-16px-solid/boolean-subtract-back.svg";
|
||||
import BooleanSubtractFront from "@/../assets/icon-16px-solid/boolean-subtract-front.svg";
|
||||
import BooleanUnion from "@/../assets/icon-16px-solid/boolean-union.svg";
|
||||
import CheckboxChecked from "@/../assets/icon-16px-solid/checkbox-checked.svg";
|
||||
import CheckboxUnchecked from "@/../assets/icon-16px-solid/checkbox-unchecked.svg";
|
||||
import Copy from "@/../assets/icon-16px-solid/copy.svg";
|
||||
import EyeHidden from "@/../assets/icon-16px-solid/eye-hidden.svg";
|
||||
import EyeVisible from "@/../assets/icon-16px-solid/eye-visible.svg";
|
||||
import Eyedropper from "@/../assets/icon-16px-solid/eyedropper.svg";
|
||||
import File from "@/../assets/icon-16px-solid/file.svg";
|
||||
import FlipHorizontal from "@/../assets/icon-16px-solid/flip-horizontal.svg";
|
||||
import FlipVertical from "@/../assets/icon-16px-solid/flip-vertical.svg";
|
||||
import Folder from "@/../assets/icon-16px-solid/folder.svg";
|
||||
import GraphiteLogo from "@/../assets/icon-16px-solid/graphite-logo.svg";
|
||||
import NodeArtboard from "@/../assets/icon-16px-solid/node-artboard.svg";
|
||||
import NodeBlur from "@/../assets/icon-16px-solid/node-blur.svg";
|
||||
import NodeBrushwork from "@/../assets/icon-16px-solid/node-brushwork.svg";
|
||||
import NodeColorCorrection from "@/../assets/icon-16px-solid/node-color-correction.svg";
|
||||
import NodeFolder from "@/../assets/icon-16px-solid/node-folder.svg";
|
||||
import NodeGradient from "@/../assets/icon-16px-solid/node-gradient.svg";
|
||||
import NodeImage from "@/../assets/icon-16px-solid/node-image.svg";
|
||||
import NodeImaginate from "@/../assets/icon-16px-solid/node-imaginate.svg";
|
||||
import NodeMagicWand from "@/../assets/icon-16px-solid/node-magic-wand.svg";
|
||||
import NodeMask from "@/../assets/icon-16px-solid/node-mask.svg";
|
||||
import NodeMotionBlur from "@/../assets/icon-16px-solid/node-motion-blur.svg";
|
||||
import NodeNodes from "@/../assets/icon-16px-solid/node-nodes.svg";
|
||||
import NodeOutput from "@/../assets/icon-16px-solid/node-output.svg";
|
||||
import NodeShape from "@/../assets/icon-16px-solid/node-shape.svg";
|
||||
import NodeText from "@/../assets/icon-16px-solid/node-text.svg";
|
||||
import NodeTransform from "@/../assets/icon-16px-solid/node-transform.svg";
|
||||
import Paste from "@/../assets/icon-16px-solid/paste.svg";
|
||||
import Random from "@/../assets/icon-16px-solid/random.svg";
|
||||
import Regenerate from "@/../assets/icon-16px-solid/regenerate.svg";
|
||||
import Reload from "@/../assets/icon-16px-solid/reload.svg";
|
||||
import Rescale from "@/../assets/icon-16px-solid/rescale.svg";
|
||||
import Reset from "@/../assets/icon-16px-solid/reset.svg";
|
||||
import Settings from "@/../assets/icon-16px-solid/settings.svg";
|
||||
import Trash from "@/../assets/icon-16px-solid/trash.svg";
|
||||
import ViewModeNormal from "@/../assets/icon-16px-solid/view-mode-normal.svg";
|
||||
import ViewModeOutline from "@/../assets/icon-16px-solid/view-mode-outline.svg";
|
||||
import ViewModePixels from "@/../assets/icon-16px-solid/view-mode-pixels.svg";
|
||||
import ViewportDesignMode from "@/../assets/icon-16px-solid/viewport-design-mode.svg";
|
||||
import ViewportGuideMode from "@/../assets/icon-16px-solid/viewport-guide-mode.svg";
|
||||
import ViewportSelectMode from "@/../assets/icon-16px-solid/viewport-select-mode.svg";
|
||||
import ZoomIn from "@/../assets/icon-16px-solid/zoom-in.svg";
|
||||
import ZoomOut from "@/../assets/icon-16px-solid/zoom-out.svg";
|
||||
import ZoomReset from "@/../assets/icon-16px-solid/zoom-reset.svg";
|
||||
|
||||
const SOLID_16PX = {
|
||||
AlignBottom: { svg: AlignBottom, size: 16 },
|
||||
AlignHorizontalCenter: { svg: AlignHorizontalCenter, size: 16 },
|
||||
AlignLeft: { svg: AlignLeft, size: 16 },
|
||||
AlignRight: { svg: AlignRight, size: 16 },
|
||||
AlignTop: { svg: AlignTop, size: 16 },
|
||||
AlignVerticalCenter: { svg: AlignVerticalCenter, size: 16 },
|
||||
BooleanDifference: { svg: BooleanDifference, size: 16 },
|
||||
BooleanIntersect: { svg: BooleanIntersect, size: 16 },
|
||||
BooleanSubtractBack: { svg: BooleanSubtractBack, size: 16 },
|
||||
BooleanSubtractFront: { svg: BooleanSubtractFront, size: 16 },
|
||||
BooleanUnion: { svg: BooleanUnion, size: 16 },
|
||||
CheckboxChecked: { svg: CheckboxChecked, size: 16 },
|
||||
CheckboxUnchecked: { svg: CheckboxUnchecked, size: 16 },
|
||||
Copy: { svg: Copy, size: 16 },
|
||||
Eyedropper: { svg: Eyedropper, size: 16 },
|
||||
EyeHidden: { svg: EyeHidden, size: 16 },
|
||||
EyeVisible: { svg: EyeVisible, size: 16 },
|
||||
File: { svg: File, size: 16 },
|
||||
FlipHorizontal: { svg: FlipHorizontal, size: 16 },
|
||||
FlipVertical: { svg: FlipVertical, size: 16 },
|
||||
Folder: { svg: Folder, size: 16 },
|
||||
GraphiteLogo: { svg: GraphiteLogo, size: 16 },
|
||||
NodeArtboard: { svg: NodeArtboard, size: 16 },
|
||||
NodeBlur: { svg: NodeBlur, size: 16 },
|
||||
NodeBrushwork: { svg: NodeBrushwork, size: 16 },
|
||||
NodeColorCorrection: { svg: NodeColorCorrection, size: 16 },
|
||||
NodeFolder: { svg: NodeFolder, size: 16 },
|
||||
NodeGradient: { svg: NodeGradient, size: 16 },
|
||||
NodeImage: { svg: NodeImage, size: 16 },
|
||||
NodeImaginate: { svg: NodeImaginate, size: 16 },
|
||||
NodeMagicWand: { svg: NodeMagicWand, size: 16 },
|
||||
NodeMask: { svg: NodeMask, size: 16 },
|
||||
NodeMotionBlur: { svg: NodeMotionBlur, size: 16 },
|
||||
NodeNodes: { svg: NodeNodes, size: 16 },
|
||||
NodeOutput: { svg: NodeOutput, size: 16 },
|
||||
NodeShape: { svg: NodeShape, size: 16 },
|
||||
NodeText: { svg: NodeText, size: 16 },
|
||||
NodeTransform: { svg: NodeTransform, size: 16 },
|
||||
Paste: { svg: Paste, size: 16 },
|
||||
Random: { svg: Random, size: 16 },
|
||||
Regenerate: { svg: Regenerate, size: 16 },
|
||||
Reload: { svg: Reload, size: 16 },
|
||||
Rescale: { svg: Rescale, size: 16 },
|
||||
Reset: { svg: Reset, size: 16 },
|
||||
Settings: { svg: Settings, size: 16 },
|
||||
Trash: { svg: Trash, size: 16 },
|
||||
ViewModeNormal: { svg: ViewModeNormal, size: 16 },
|
||||
ViewModeOutline: { svg: ViewModeOutline, size: 16 },
|
||||
ViewModePixels: { svg: ViewModePixels, size: 16 },
|
||||
ViewportDesignMode: { svg: ViewportDesignMode, size: 16 },
|
||||
ViewportGuideMode: { svg: ViewportGuideMode, size: 16 },
|
||||
ViewportSelectMode: { svg: ViewportSelectMode, size: 16 },
|
||||
ZoomIn: { svg: ZoomIn, size: 16 },
|
||||
ZoomOut: { svg: ZoomOut, size: 16 },
|
||||
ZoomReset: { svg: ZoomReset, size: 16 },
|
||||
} as const;
|
||||
|
||||
// 16px Two-Tone
|
||||
import MouseHintDrag from "@/../assets/icon-16px-two-tone/mouse-hint-drag.svg";
|
||||
import MouseHintLmbDrag from "@/../assets/icon-16px-two-tone/mouse-hint-lmb-drag.svg";
|
||||
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 MouseHintMmb from "@/../assets/icon-16px-two-tone/mouse-hint-mmb.svg";
|
||||
import MouseHintNone from "@/../assets/icon-16px-two-tone/mouse-hint-none.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 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";
|
||||
|
||||
const TWO_TONE_16PX = {
|
||||
MouseHintDrag: { svg: MouseHintDrag, size: 16 },
|
||||
MouseHintLmb: { svg: MouseHintLmb, size: 16 },
|
||||
MouseHintLmbDrag: { svg: MouseHintLmbDrag, size: 16 },
|
||||
MouseHintMmb: { svg: MouseHintMmb, size: 16 },
|
||||
MouseHintMmbDrag: { svg: MouseHintMmbDrag, size: 16 },
|
||||
MouseHintNone: { svg: MouseHintNone, size: 16 },
|
||||
MouseHintRmb: { svg: MouseHintRmb, size: 16 },
|
||||
MouseHintRmbDrag: { svg: MouseHintRmbDrag, size: 16 },
|
||||
MouseHintScrollDown: { svg: MouseHintScrollDown, size: 16 },
|
||||
MouseHintScrollUp: { svg: MouseHintScrollUp, size: 16 },
|
||||
} as const;
|
||||
|
||||
// 24px Two-Tone
|
||||
import GeneralArtboardTool from "@/../assets/icon-24px-two-tone/general-artboard-tool.svg";
|
||||
import GeneralEyedropperTool from "@/../assets/icon-24px-two-tone/general-eyedropper-tool.svg";
|
||||
import GeneralFillTool from "@/../assets/icon-24px-two-tone/general-fill-tool.svg";
|
||||
import GeneralGradientTool from "@/../assets/icon-24px-two-tone/general-gradient-tool.svg";
|
||||
import GeneralNavigateTool from "@/../assets/icon-24px-two-tone/general-navigate-tool.svg";
|
||||
import GeneralSelectTool from "@/../assets/icon-24px-two-tone/general-select-tool.svg";
|
||||
import RasterBrushTool from "@/../assets/icon-24px-two-tone/raster-brush-tool.svg";
|
||||
import RasterCloneTool from "@/../assets/icon-24px-two-tone/raster-clone-tool.svg";
|
||||
import RasterDetailTool from "@/../assets/icon-24px-two-tone/raster-detail-tool.svg";
|
||||
import RasterHealTool from "@/../assets/icon-24px-two-tone/raster-heal-tool.svg";
|
||||
import RasterImaginateTool from "@/../assets/icon-24px-two-tone/raster-imaginate-tool.svg";
|
||||
import RasterNodesTool from "@/../assets/icon-24px-two-tone/raster-nodes-tool.svg";
|
||||
import RasterPatchTool from "@/../assets/icon-24px-two-tone/raster-patch-tool.svg";
|
||||
import RasterRelightTool from "@/../assets/icon-24px-two-tone/raster-relight-tool.svg";
|
||||
import VectorEllipseTool from "@/../assets/icon-24px-two-tone/vector-ellipse-tool.svg";
|
||||
import VectorFreehandTool from "@/../assets/icon-24px-two-tone/vector-freehand-tool.svg";
|
||||
import VectorLineTool from "@/../assets/icon-24px-two-tone/vector-line-tool.svg";
|
||||
import VectorPathTool from "@/../assets/icon-24px-two-tone/vector-path-tool.svg";
|
||||
import VectorPenTool from "@/../assets/icon-24px-two-tone/vector-pen-tool.svg";
|
||||
import VectorRectangleTool from "@/../assets/icon-24px-two-tone/vector-rectangle-tool.svg";
|
||||
import VectorShapeTool from "@/../assets/icon-24px-two-tone/vector-shape-tool.svg";
|
||||
import VectorSplineTool from "@/../assets/icon-24px-two-tone/vector-spline-tool.svg";
|
||||
import VectorTextTool from "@/../assets/icon-24px-two-tone/vector-text-tool.svg";
|
||||
|
||||
const TWO_TONE_24PX = {
|
||||
GeneralArtboardTool: { svg: GeneralArtboardTool, size: 24 },
|
||||
GeneralEyedropperTool: { svg: GeneralEyedropperTool, size: 24 },
|
||||
GeneralFillTool: { svg: GeneralFillTool, size: 24 },
|
||||
GeneralGradientTool: { svg: GeneralGradientTool, size: 24 },
|
||||
GeneralNavigateTool: { svg: GeneralNavigateTool, size: 24 },
|
||||
GeneralSelectTool: { svg: GeneralSelectTool, size: 24 },
|
||||
RasterImaginateTool: { svg: RasterImaginateTool, size: 24 },
|
||||
RasterNodesTool: { svg: RasterNodesTool, size: 24 },
|
||||
RasterBrushTool: { svg: RasterBrushTool, size: 24 },
|
||||
RasterCloneTool: { svg: RasterCloneTool, size: 24 },
|
||||
RasterDetailTool: { svg: RasterDetailTool, size: 24 },
|
||||
RasterHealTool: { svg: RasterHealTool, size: 24 },
|
||||
RasterPatchTool: { svg: RasterPatchTool, size: 24 },
|
||||
RasterRelightTool: { svg: RasterRelightTool, size: 24 },
|
||||
VectorEllipseTool: { svg: VectorEllipseTool, size: 24 },
|
||||
VectorFreehandTool: { svg: VectorFreehandTool, size: 24 },
|
||||
VectorLineTool: { svg: VectorLineTool, size: 24 },
|
||||
VectorPathTool: { svg: VectorPathTool, size: 24 },
|
||||
VectorPenTool: { svg: VectorPenTool, size: 24 },
|
||||
VectorRectangleTool: { svg: VectorRectangleTool, size: 24 },
|
||||
VectorShapeTool: { svg: VectorShapeTool, size: 24 },
|
||||
VectorSplineTool: { svg: VectorSplineTool, size: 24 },
|
||||
VectorTextTool: { svg: VectorTextTool, size: 24 },
|
||||
} as const;
|
||||
|
||||
// All icons
|
||||
const ICON_LIST = {
|
||||
...GRAPHICS,
|
||||
...SOLID_12PX,
|
||||
...SOLID_16PX,
|
||||
...TWO_TONE_16PX,
|
||||
...TWO_TONE_24PX,
|
||||
} as const;
|
||||
|
||||
// Exported icons and types
|
||||
export const ICONS: IconDefinitionType<typeof ICON_LIST> = ICON_LIST;
|
||||
export const ICON_SVG_STRINGS = Object.fromEntries(Object.entries(ICONS).map(([name, data]) => [name, data.svg]));
|
||||
|
||||
export type IconName = keyof typeof ICONS;
|
||||
export type IconSize = undefined | 12 | 16 | 24 | 32;
|
||||
|
||||
// The following helper type declarations allow us to avoid manually maintaining the `IconName` type declaration as a string union paralleling the keys of the
|
||||
// icon definitions. It lets TypeScript do that for us. Our goal is to define the big key-value pair of icons by constraining its values, but inferring its keys.
|
||||
// Constraining its values means that TypeScript can make sure each icon definition has a valid size number from the union of numbers that is `IconSize`.
|
||||
// Inferring its keys means we don't have to specify a supertype like `string` or `any` for the key-value pair's keys, which would prevent us from accessing
|
||||
// the individual keys with `keyof typeof`. Absent a specified type for the keys, TypeScript falls back to inferring that the key-value pair's type is the
|
||||
// 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.
|
||||
// Based on https://stackoverflow.com/a/64119715/775283
|
||||
type IconDefinition = { svg: string; size: IconSize };
|
||||
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 }>;
|
||||
@@ -0,0 +1,367 @@
|
||||
/* eslint-disable camelcase */
|
||||
|
||||
// import { escapeJSON } from "@/utility-functions/escape";
|
||||
import { blobToBase64 } from "@/utility-functions/files";
|
||||
import { type RequestResult, requestWithUploadDownloadProgress } from "@/utility-functions/network";
|
||||
import { type Editor } from "@/wasm-communication/editor";
|
||||
import type { XY } from "@/wasm-communication/messages";
|
||||
import { type ImaginateGenerationParameters } from "@/wasm-communication/messages";
|
||||
|
||||
const MAX_POLLING_RETRIES = 4;
|
||||
const SERVER_STATUS_CHECK_TIMEOUT = 5000;
|
||||
const PROGRESS_EVERY_N_STEPS = 5;
|
||||
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
let terminated = false;
|
||||
|
||||
let generatingAbortRequest: XMLHttpRequest | undefined;
|
||||
let pollingAbortController = new AbortController();
|
||||
let statusAbortController = new AbortController();
|
||||
|
||||
// PUBLICLY CALLABLE FUNCTIONS
|
||||
|
||||
export async function imaginateGenerate(
|
||||
parameters: ImaginateGenerationParameters,
|
||||
image: Blob | undefined,
|
||||
mask: Blob | undefined,
|
||||
maskPaintMode: string,
|
||||
maskBlurPx: number,
|
||||
maskFillContent: string,
|
||||
hostname: string,
|
||||
refreshFrequency: number,
|
||||
documentId: bigint,
|
||||
layerPath: BigUint64Array,
|
||||
nodePath: BigUint64Array,
|
||||
editor: Editor
|
||||
): Promise<void> {
|
||||
// Ignore a request to generate a new image while another is already being generated
|
||||
if (generatingAbortRequest !== undefined) return;
|
||||
|
||||
terminated = false;
|
||||
|
||||
// Immediately set the progress to 0% so the backend knows to update its layout
|
||||
editor.instance.setImaginateGeneratingStatus(documentId, layerPath, nodePath, 0, "Beginning");
|
||||
|
||||
// Initiate a request to the computation server
|
||||
const discloseUploadingProgress = (progress: number): void => {
|
||||
editor.instance.setImaginateGeneratingStatus(documentId, layerPath, nodePath, progress * 100, "Uploading");
|
||||
};
|
||||
const { uploaded, result, xhr } = await generate(discloseUploadingProgress, hostname, image, mask, maskPaintMode, maskBlurPx, maskFillContent, parameters);
|
||||
generatingAbortRequest = xhr;
|
||||
|
||||
try {
|
||||
// Wait until the request is fully uploaded, which could be slow if the img2img source is large and the user is on a slow connection
|
||||
await uploaded;
|
||||
editor.instance.setImaginateGeneratingStatus(documentId, layerPath, nodePath, 0, "Generating");
|
||||
|
||||
// Begin polling for updates to the in-progress image generation at the specified interval
|
||||
// Don't poll if the chosen interval is 0, or if the chosen sampling method does not support polling
|
||||
if (refreshFrequency > 0) {
|
||||
const interval = Math.max(refreshFrequency * 1000, 500);
|
||||
scheduleNextPollingUpdate(interval, Date.now(), 0, editor, hostname, documentId, layerPath, nodePath, parameters.resolution);
|
||||
}
|
||||
|
||||
// Wait for the final image to be returned by the initial request containing either the full image or the last frame if it was terminated by the user
|
||||
const { body, status } = await result;
|
||||
if (status < 200 || status > 299) {
|
||||
throw new Error(`Request to server failed to return a 200-level status code (${status})`);
|
||||
}
|
||||
|
||||
// Extract the final image from the response and convert it to a data blob
|
||||
const base64Data = JSON.parse(body)?.images?.[0] as string | undefined;
|
||||
const base64 = typeof base64Data === "string" && base64Data.length > 0 ? `data:image/png;base64,${base64Data}` : undefined;
|
||||
if (!base64) throw new Error("Could not read final image result from server response");
|
||||
const blob = await (await fetch(base64)).blob();
|
||||
|
||||
// Send the backend an updated status
|
||||
const percent = terminated ? undefined : 100;
|
||||
const newStatus = terminated ? "Terminated" : "Idle";
|
||||
editor.instance.setImaginateGeneratingStatus(documentId, layerPath, nodePath, percent, newStatus);
|
||||
|
||||
// Send the backend a blob URL for the final image
|
||||
updateBackendImage(editor, blob, documentId, layerPath, nodePath);
|
||||
} catch {
|
||||
editor.instance.setImaginateGeneratingStatus(documentId, layerPath, nodePath, undefined, "Terminated");
|
||||
|
||||
await imaginateCheckConnection(hostname, editor);
|
||||
}
|
||||
|
||||
abortAndResetGenerating();
|
||||
abortAndResetPolling();
|
||||
}
|
||||
|
||||
export async function imaginateTerminate(hostname: string, documentId: bigint, layerPath: BigUint64Array, nodePath: BigUint64Array, editor: Editor): Promise<void> {
|
||||
terminated = true;
|
||||
abortAndResetPolling();
|
||||
|
||||
try {
|
||||
await terminate(hostname);
|
||||
|
||||
editor.instance.setImaginateGeneratingStatus(documentId, layerPath, nodePath, undefined, "Terminating");
|
||||
} catch {
|
||||
abortAndResetGenerating();
|
||||
abortAndResetPolling();
|
||||
|
||||
editor.instance.setImaginateGeneratingStatus(documentId, layerPath, nodePath, undefined, "Terminated");
|
||||
|
||||
await imaginateCheckConnection(hostname, editor);
|
||||
}
|
||||
}
|
||||
|
||||
export async function imaginateCheckConnection(hostname: string, editor: Editor): Promise<void> {
|
||||
const serverReached = await checkConnection(hostname);
|
||||
editor.instance.setImaginateServerStatus(serverReached);
|
||||
}
|
||||
|
||||
// Converts the blob image into a list of pixels using an invisible canvas.
|
||||
export async function updateBackendImage(editor: Editor, blob: Blob, documentId: bigint, layerPath: BigUint64Array, nodePath: BigUint64Array): Promise<void> {
|
||||
const image = await createImageBitmap(blob);
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = image.width;
|
||||
canvas.height = image.height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("Could not create canvas context");
|
||||
ctx.drawImage(image, 0, 0);
|
||||
|
||||
// Send the backend the blob data to be stored persistently in the layer
|
||||
const imageData = ctx.getImageData(0, 0, image.width, image.height);
|
||||
const u8Array = new Uint8Array(imageData.data);
|
||||
|
||||
editor.instance.setImaginateImageData(documentId, layerPath, nodePath, u8Array, imageData.width, imageData.height);
|
||||
}
|
||||
|
||||
// ABORTING AND RESETTING HELPERS
|
||||
|
||||
function abortAndResetGenerating(): void {
|
||||
generatingAbortRequest?.abort();
|
||||
generatingAbortRequest = undefined;
|
||||
}
|
||||
|
||||
function abortAndResetPolling(): void {
|
||||
pollingAbortController.abort();
|
||||
pollingAbortController = new AbortController();
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
// POLLING IMPLEMENTATION DETAILS
|
||||
|
||||
function scheduleNextPollingUpdate(
|
||||
interval: number,
|
||||
timeoutBegan: number,
|
||||
pollingRetries: number,
|
||||
editor: Editor,
|
||||
hostname: string,
|
||||
documentId: bigint,
|
||||
layerPath: BigUint64Array,
|
||||
nodePath: BigUint64Array,
|
||||
resolution: XY
|
||||
): void {
|
||||
// Pick a future time that keeps to the user-requested interval if possible, but on slower connections will go as fast as possible without overlapping itself
|
||||
const nextPollTimeGoal = timeoutBegan + interval;
|
||||
const timeFromNow = Math.max(0, nextPollTimeGoal - Date.now());
|
||||
|
||||
timer = setTimeout(async () => {
|
||||
const nextTimeoutBegan = Date.now();
|
||||
|
||||
try {
|
||||
const [blob, percentComplete] = await pollImage(hostname);
|
||||
|
||||
// After waiting for the polling result back from the server, if during that intervening time the user has terminated the generation, exit so we don't overwrite that terminated status
|
||||
if (terminated) return;
|
||||
|
||||
if (blob) updateBackendImage(editor, blob, documentId, layerPath, nodePath);
|
||||
editor.instance.setImaginateGeneratingStatus(documentId, layerPath, nodePath, percentComplete, "Generating");
|
||||
|
||||
scheduleNextPollingUpdate(interval, nextTimeoutBegan, 0, editor, hostname, documentId, layerPath, nodePath, resolution);
|
||||
} catch {
|
||||
if (generatingAbortRequest === undefined) return;
|
||||
|
||||
if (pollingRetries + 1 > MAX_POLLING_RETRIES) {
|
||||
abortAndResetGenerating();
|
||||
abortAndResetPolling();
|
||||
|
||||
await imaginateCheckConnection(hostname, editor);
|
||||
} else {
|
||||
scheduleNextPollingUpdate(interval, nextTimeoutBegan, pollingRetries + 1, editor, hostname, documentId, layerPath, nodePath, resolution);
|
||||
}
|
||||
}
|
||||
}, timeFromNow);
|
||||
}
|
||||
|
||||
// API COMMUNICATION FUNCTIONS
|
||||
|
||||
async function pollImage(hostname: string): Promise<[Blob | undefined, number]> {
|
||||
// Fetch the percent progress and in-progress image from the API
|
||||
const result = await fetch(`${hostname}sdapi/v1/progress`, { signal: pollingAbortController.signal, method: "GET" });
|
||||
const { current_image, progress } = await result.json();
|
||||
|
||||
// Convert to a usable format
|
||||
const progressPercent = progress * 100;
|
||||
const base64 = typeof current_image === "string" && current_image.length > 0 ? `data:image/png;base64,${current_image}` : undefined;
|
||||
|
||||
// Deal with a missing image
|
||||
if (!base64) {
|
||||
// The image is not ready yet (because it's only had a few samples since generation began), but we do have a progress percentage
|
||||
if (!Number.isNaN(progressPercent) && progressPercent >= 0 && progressPercent <= 100) {
|
||||
return [undefined, progressPercent];
|
||||
}
|
||||
|
||||
// Something else is wrong and the image wasn't provided as expected
|
||||
return Promise.reject();
|
||||
}
|
||||
|
||||
// The image was provided so we turn it into a data blob
|
||||
const blob = await (await fetch(base64)).blob();
|
||||
return [blob, progressPercent];
|
||||
}
|
||||
|
||||
async function generate(
|
||||
discloseUploadingProgress: (progress: number) => void,
|
||||
hostname: string,
|
||||
image: Blob | undefined,
|
||||
mask: Blob | undefined,
|
||||
maskPaintMode: string,
|
||||
maskBlurPx: number,
|
||||
maskFillContent: string,
|
||||
parameters: ImaginateGenerationParameters
|
||||
): Promise<{
|
||||
uploaded: Promise<void>;
|
||||
result: Promise<RequestResult>;
|
||||
xhr?: XMLHttpRequest;
|
||||
}> {
|
||||
let body;
|
||||
let endpoint;
|
||||
if (image === undefined || parameters.denoisingStrength === undefined) {
|
||||
endpoint = `${hostname}sdapi/v1/txt2img`;
|
||||
|
||||
body = {
|
||||
// enable_hr: false,
|
||||
// denoising_strength: 0,
|
||||
// firstphase_width: 0,
|
||||
// firstphase_height: 0,
|
||||
prompt: parameters.prompt,
|
||||
// styles: [],
|
||||
seed: Number(parameters.seed),
|
||||
// subseed: -1,
|
||||
// subseed_strength: 0,
|
||||
// seed_resize_from_h: -1,
|
||||
// seed_resize_from_w: -1,
|
||||
// batch_size: 1,
|
||||
// n_iter: 1,
|
||||
steps: parameters.samples,
|
||||
cfg_scale: parameters.cfgScale,
|
||||
width: parameters.resolution.x,
|
||||
height: parameters.resolution.y,
|
||||
restore_faces: parameters.restoreFaces,
|
||||
tiling: parameters.tiling,
|
||||
negative_prompt: parameters.negativePrompt,
|
||||
// eta: 0,
|
||||
// s_churn: 0,
|
||||
// s_tmax: 0,
|
||||
// s_tmin: 0,
|
||||
// s_noise: 1,
|
||||
override_settings: {
|
||||
show_progress_every_n_steps: PROGRESS_EVERY_N_STEPS,
|
||||
},
|
||||
sampler_index: parameters.samplingMethod,
|
||||
};
|
||||
} else {
|
||||
const sourceImageBase64 = await blobToBase64(image);
|
||||
const maskImageBase64 = mask ? await blobToBase64(mask) : "";
|
||||
|
||||
const maskFillContentIndexes = ["Fill", "Original", "LatentNoise", "LatentNothing"];
|
||||
const maskFillContentIndexFound = maskFillContentIndexes.indexOf(maskFillContent);
|
||||
const maskFillContentIndex = maskFillContentIndexFound === -1 ? undefined : maskFillContentIndexFound;
|
||||
|
||||
const maskInvert = maskPaintMode === "Inpaint" ? 1 : 0;
|
||||
|
||||
endpoint = `${hostname}sdapi/v1/img2img`;
|
||||
|
||||
body = {
|
||||
init_images: [sourceImageBase64],
|
||||
// resize_mode: 0,
|
||||
denoising_strength: parameters.denoisingStrength,
|
||||
mask: mask && maskImageBase64,
|
||||
mask_blur: mask && maskBlurPx,
|
||||
inpainting_fill: mask && maskFillContentIndex,
|
||||
inpaint_full_res: mask && false,
|
||||
// inpaint_full_res_padding: 0,
|
||||
inpainting_mask_invert: mask && maskInvert,
|
||||
prompt: parameters.prompt,
|
||||
// styles: [],
|
||||
seed: Number(parameters.seed),
|
||||
// subseed: -1,
|
||||
// subseed_strength: 0,
|
||||
// seed_resize_from_h: -1,
|
||||
// seed_resize_from_w: -1,
|
||||
// batch_size: 1,
|
||||
// n_iter: 1,
|
||||
steps: parameters.samples,
|
||||
cfg_scale: parameters.cfgScale,
|
||||
width: parameters.resolution.x,
|
||||
height: parameters.resolution.y,
|
||||
restore_faces: parameters.restoreFaces,
|
||||
tiling: parameters.tiling,
|
||||
negative_prompt: parameters.negativePrompt,
|
||||
// eta: 0,
|
||||
// s_churn: 0,
|
||||
// s_tmax: 0,
|
||||
// s_tmin: 0,
|
||||
// s_noise: 1,
|
||||
override_settings: {
|
||||
show_progress_every_n_steps: PROGRESS_EVERY_N_STEPS,
|
||||
img2img_fix_steps: true,
|
||||
},
|
||||
sampler_index: parameters.samplingMethod,
|
||||
// include_init_images: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Prepare a promise that will resolve after the outbound request upload is complete
|
||||
let uploadedResolve: () => void;
|
||||
let uploadedReject: () => void;
|
||||
const uploaded = new Promise<void>((resolve, reject): void => {
|
||||
uploadedResolve = resolve;
|
||||
uploadedReject = reject;
|
||||
});
|
||||
|
||||
// Fire off the request and, once the outbound request upload is complete, resolve the promise we defined above
|
||||
const uploadProgress = (progress: number): void => {
|
||||
if (progress < 1) {
|
||||
discloseUploadingProgress(progress);
|
||||
} else {
|
||||
uploadedResolve();
|
||||
}
|
||||
};
|
||||
const [result, xhr] = requestWithUploadDownloadProgress(endpoint, "POST", JSON.stringify(body), uploadProgress, abortAndResetPolling);
|
||||
result.catch(() => uploadedReject());
|
||||
|
||||
// Return the promise that resolves when the request upload is complete, the promise that resolves when the response download is complete, and the XHR so it can be aborted
|
||||
return { uploaded, result, xhr };
|
||||
}
|
||||
|
||||
async function terminate(hostname: string): Promise<void> {
|
||||
await fetch(`${hostname}sdapi/v1/interrupt`, { method: "POST" });
|
||||
}
|
||||
|
||||
async function checkConnection(hostname: string): Promise<boolean> {
|
||||
statusAbortController.abort();
|
||||
statusAbortController = new AbortController();
|
||||
|
||||
const timeout = setTimeout(() => statusAbortController.abort(), SERVER_STATUS_CHECK_TIMEOUT);
|
||||
|
||||
try {
|
||||
// Intentionally misuse this API endpoint by using it just to check for a code 200 response, regardless of what the result is
|
||||
const { status } = await fetch(`${hostname}sdapi/v1/progress?skip_current_image=true`, { signal: statusAbortController.signal, method: "GET" });
|
||||
|
||||
// This code means the server has indeed responded and the endpoint exists (otherwise it would be 404)
|
||||
if (status === 200) {
|
||||
clearTimeout(timeout);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Do nothing here
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,741 @@
|
||||
export function makeKeyboardModifiersBitfield(e: WheelEvent | PointerEvent | KeyboardEvent): number {
|
||||
return (
|
||||
// Shift (all platforms)
|
||||
(Number(e.shiftKey) << 0) |
|
||||
// Alt (all platforms, also called Option on Mac)
|
||||
(Number(e.altKey) << 1) |
|
||||
// Control (all platforms)
|
||||
(Number(e.ctrlKey) << 2) |
|
||||
// Meta (Windows/Linux) or Command (Mac)
|
||||
(Number(e.metaKey) << 3)
|
||||
);
|
||||
}
|
||||
|
||||
// Necessary because innerText puts an extra newline character at the end when the text is more than one line.
|
||||
export function textInputCleanup(text: string): string {
|
||||
if (text[text.length - 1] === "\n") return text.slice(0, -1);
|
||||
return text;
|
||||
}
|
||||
|
||||
// This function tries to find what scan code the user pressed, even if using a non-US keyboard.
|
||||
// Directly using `KeyboardEvent.code` scan code only works on a US QWERTY layout, because alternate layouts like
|
||||
// QWERTZ (German) or AZERTY (French) will end up reporting the wrong keys.
|
||||
// Directly using `KeyboardEvent.key` doesn't work because the results are often garbage, as the printed character
|
||||
// varies when the Shift key is pressed, or worse, when the Option (Alt) key on a Mac is pressed.
|
||||
// This function does its best to try and sort through both of those sources of information to determine the localized scan code.
|
||||
//
|
||||
// This function is an imperfect stopgap solution to allow non-US keyboards to be handled on a best-effort basis.
|
||||
// Eventually we will need a more robust system based on a giant database of keyboard layouts from all around the world.
|
||||
// We'd provide the user a choice of layout, and aim to detect a default based on the `key` and `code` values entered by the user
|
||||
// combined with `Keyboard.getLayoutMap()` where supported in Chromium-based browsers and perhaps the browser's language and IP address.
|
||||
// We are also limited by browser APIs, since the spec doesn't support what we need it to:
|
||||
// <https://github.com/WICG/keyboard-map/issues/26>
|
||||
// In the desktop version of VS Code, this is achieved with this Electron plugin:
|
||||
// <https://github.com/Microsoft/node-native-keymap>
|
||||
// We may be able to port that (it's a relatively small codebase) to Rust for use with Tauri.
|
||||
// But on the web, just like VS Code, we're limited by the shortcomings of the spec.
|
||||
// A collection of further insights:
|
||||
// <https://docs.google.com/document/d/1p17IBbYGsZivLIMhKZOaCJFAJFokbPfKrkB37fOPXSM/edit>
|
||||
// And it's a really good idea to read the explainer on keyboard layout variations and the whole spec (it's quite digestible):
|
||||
// <https://www.w3.org/TR/uievents-code/#key-alphanumeric-writing-system>
|
||||
export async function getLocalizedScanCode(e: KeyboardEvent): Promise<string> {
|
||||
const keyText = e.key;
|
||||
const scanCode = e.code;
|
||||
|
||||
// Use the key code directly if it isn't one that changes per locale (i.e. isn't a writing system key or one of the other few exceptions)
|
||||
const scanCodeNotLocaleSpecific = !LOCALE_SPECIFIC_KEY_CODES.includes(scanCode);
|
||||
if (scanCodeNotLocaleSpecific) {
|
||||
return scanCode;
|
||||
}
|
||||
|
||||
// Use the key directly if it's one of the exceptions that usually don't change, but sometimes do in a predictable way
|
||||
if (SCAN_CODES_FOR_NON_WRITING_KEYS_THAT_VARY_PER_LOCALE.includes(scanCode)) {
|
||||
// Numpad comma and period which swap in some locales as decimal and thousands separator symbols
|
||||
if (NUMPAD_DECIMAL_AND_THOUSANDS_SEPARATORS.includes(scanCode)) {
|
||||
switch (scanCode) {
|
||||
case ".":
|
||||
return "NumpadDecimal";
|
||||
case ",":
|
||||
return "NumpadComma";
|
||||
default:
|
||||
return scanCode;
|
||||
}
|
||||
}
|
||||
|
||||
// The AltRight key changes from a key value of "Alt" to "AltGraph" on keyboards with an AltGraph key
|
||||
if (scanCode === "AltRight") {
|
||||
return keyText === "Alt" ? "AltRight" : "AltGraph";
|
||||
}
|
||||
}
|
||||
|
||||
// Use good-enough-for-now heuristics on the writing system keys, which are commonly subject to change by locale
|
||||
|
||||
// Number scan codes
|
||||
if (/^Digit[0-9]$/.test(scanCode)) {
|
||||
// For now it's good enough to treat every digit key, regardless of locale, as just its digit from the standard US layout.
|
||||
// Even on a keyboard like the French AZERTY layout, where numbers are shifted, users still refer to those keys by their numbers.
|
||||
// This unfortunately means that any special symbols under these keys are overridden by their number, making it impossible to access some shortcuts that rely on those special symbols.
|
||||
// We'll have to deal with that for now, and find a way to upgrade or properly replace this system, or assign alternate keymaps based on locale, when people complain.
|
||||
return scanCode;
|
||||
}
|
||||
|
||||
// Letter scan codes
|
||||
if (/^Key([A-Z])$/.test(scanCode)) {
|
||||
// Get the uppercase letter, with any accents or discritics removed if possible
|
||||
const rawLetter = keyText
|
||||
.normalize("NFD")
|
||||
.replace(/\p{Diacritic}/gu, "")
|
||||
.toUpperCase();
|
||||
|
||||
// If the key letter is in the A-Z range, use the key code for that letter
|
||||
if (/^[A-Z]$/.test(rawLetter)) return `Key${rawLetter}`;
|
||||
|
||||
// If the key text isn't one of the named attribute values, that means it must be the literal unicode value which we use directly
|
||||
// It is likely a weird symbol that isn't in the A-Z range even with accents removed.
|
||||
// It might be a symbol from an Option key combination on a Mac. Or it might be from a non-Latin alphabet like Cyrillic.
|
||||
if (!KEY_ATTRIBUTE_VALUES.has(keyText)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if (navigator && "keyboard" in navigator && "getLayoutMap" in (navigator as any).keyboard) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const layout = await (navigator as any).keyboard.getLayoutMap();
|
||||
|
||||
type KeyCode = string;
|
||||
type KeySymbol = string;
|
||||
// Get all the keyboard mappings and transform the key symbols to uppercase
|
||||
const keyboardLayoutMap: [KeyCode, KeySymbol][] = [...layout.entries()].map(([keyCode, keySymbol]) => [keyCode, keySymbol.toUpperCase()]);
|
||||
|
||||
// If we match the uppercase version of the pressed key character, use the scan code that produces it
|
||||
const matchedEntry = keyboardLayoutMap.find(([_, keySymbol]) => keySymbol === keyText.toUpperCase());
|
||||
if (matchedEntry) return matchedEntry[0];
|
||||
}
|
||||
|
||||
// If the keyboard layout API is unavailable, or it didn't match anything, just return the scan code that the user typed
|
||||
// This isn't perfect because alternate keyboard layouts may end up having the US QWERTY key,
|
||||
// but it's all we can do without a giant database of keyboard layouts and Mac Option key combinations
|
||||
return scanCode;
|
||||
}
|
||||
|
||||
// If the key's named attribute value shares a name with a scan code, use that scan code
|
||||
if (KEY_CODE_NAMES.includes(keyText)) {
|
||||
return scanCodeFromKeyText(keyText);
|
||||
}
|
||||
if (KEY_ATTRIBUTE_VALUES_INVOLVING_HANDEDNESS.includes(keyText)) {
|
||||
// Since for some reason we're in a situation where we are using the key instead of the scan code to
|
||||
// match one of the modifier keys which have both a Left and Right variant as part of the scan code names,
|
||||
// but no handedness as part of the key's named attribute values, we default to the left side as it's more common.
|
||||
return `${keyText}Left`;
|
||||
}
|
||||
|
||||
// All reasonable attempts to figure out what this key is has now failed, so we fall back on the US QWERTY layout scan code
|
||||
return scanCode;
|
||||
}
|
||||
|
||||
// If the key text is the unicode character for one of the standard symbols on the US keyboard, use the symbol even though it's not located on the same scan code as on a US keyboard
|
||||
if (WRITING_SYSTEM_SPECIAL_CHARS.includes(keyText)) {
|
||||
return scanCodeFromKeyText(keyText);
|
||||
}
|
||||
|
||||
// If the key is otherwise totally unrecognized, we ignore it
|
||||
if (keyText === "Unidentified" || scanCode === "Unidentified") return "Unidentified";
|
||||
|
||||
// As a last resort, we just use the scan code
|
||||
return scanCode;
|
||||
}
|
||||
|
||||
function scanCodeFromKeyText(keyText: string): string {
|
||||
// There are many possible unicode symbols as well as named attribute values, but we only care about finding the equivalent scan code (based on the US keyboard) without regard for modifiers
|
||||
|
||||
// Match any handed modifier keys by claiming it's the left-handed modifier
|
||||
if (KEY_ATTRIBUTE_VALUES_INVOLVING_HANDEDNESS.includes(keyText)) {
|
||||
return `${keyText}Left`;
|
||||
}
|
||||
|
||||
// Match any named attribute keys to an identical key name
|
||||
const identicalName = KEY_CODE_NAMES.find((code) => code === keyText);
|
||||
if (identicalName) return keyText;
|
||||
|
||||
// Match the space character
|
||||
// Order matters because the next step assumes it can safely ignore the space character
|
||||
if (keyText === " ") return "Space";
|
||||
|
||||
// Match individual characters by the scan code which produces that symbol on a US keyboard, either shifted or not
|
||||
// This also includes the `Digit*` and `Key*` codes
|
||||
const matchedScanCode = KEY_CODES.find((info) => info.keys?.us?.includes(keyText));
|
||||
if (matchedScanCode) return matchedScanCode.code;
|
||||
|
||||
return "Unidentified";
|
||||
}
|
||||
|
||||
type KeyCategories = "writing-system" | "functional" | "functional-jp-kr" | "control-pad" | "arrow-pad" | "numpad" | "function" | "media" | "unidentified";
|
||||
type KeyboardLocale = "us";
|
||||
type ScanCodeInfo = { code: string; category: KeyCategories; keys?: Record<KeyboardLocale, string | undefined> };
|
||||
const KEY_CODES: ScanCodeInfo[] = [
|
||||
// Writing system keys
|
||||
// Codes produce different printed characters depending on locale
|
||||
// https://www.w3.org/TR/uievents-code/#key-alphanumeric-writing-system
|
||||
|
||||
{ code: "Digit0", category: "writing-system", keys: { us: "0 )" } },
|
||||
{ code: "Digit1", category: "writing-system", keys: { us: "1 !" } },
|
||||
{ code: "Digit2", category: "writing-system", keys: { us: "2 @" } },
|
||||
{ code: "Digit3", category: "writing-system", keys: { us: "3 #" } },
|
||||
{ code: "Digit4", category: "writing-system", keys: { us: "4 $" } },
|
||||
{ code: "Digit5", category: "writing-system", keys: { us: "5 %" } },
|
||||
{ code: "Digit6", category: "writing-system", keys: { us: "6 ^" } },
|
||||
{ code: "Digit7", category: "writing-system", keys: { us: "7 &" } },
|
||||
{ code: "Digit8", category: "writing-system", keys: { us: "8 *" } },
|
||||
{ code: "Digit9", category: "writing-system", keys: { us: "9 (" } },
|
||||
|
||||
{ code: "KeyA", category: "writing-system", keys: { us: "a A" } },
|
||||
{ code: "KeyB", category: "writing-system", keys: { us: "b B" } },
|
||||
{ code: "KeyC", category: "writing-system", keys: { us: "c C" } },
|
||||
{ code: "KeyD", category: "writing-system", keys: { us: "d D" } },
|
||||
{ code: "KeyE", category: "writing-system", keys: { us: "e E" } },
|
||||
{ code: "KeyF", category: "writing-system", keys: { us: "f F" } },
|
||||
{ code: "KeyG", category: "writing-system", keys: { us: "g G" } },
|
||||
{ code: "KeyH", category: "writing-system", keys: { us: "h H" } },
|
||||
{ code: "KeyI", category: "writing-system", keys: { us: "i I" } },
|
||||
{ code: "KeyJ", category: "writing-system", keys: { us: "j J" } },
|
||||
{ code: "KeyK", category: "writing-system", keys: { us: "k K" } },
|
||||
{ code: "KeyL", category: "writing-system", keys: { us: "l L" } },
|
||||
{ code: "KeyM", category: "writing-system", keys: { us: "m M" } },
|
||||
{ code: "KeyN", category: "writing-system", keys: { us: "n N" } },
|
||||
{ code: "KeyO", category: "writing-system", keys: { us: "o O" } },
|
||||
{ code: "KeyP", category: "writing-system", keys: { us: "p P" } },
|
||||
{ code: "KeyQ", category: "writing-system", keys: { us: "q Q" } },
|
||||
{ code: "KeyR", category: "writing-system", keys: { us: "r R" } },
|
||||
{ code: "KeyS", category: "writing-system", keys: { us: "s S" } },
|
||||
{ code: "KeyT", category: "writing-system", keys: { us: "t T" } },
|
||||
{ code: "KeyU", category: "writing-system", keys: { us: "u U" } },
|
||||
{ code: "KeyV", category: "writing-system", keys: { us: "v V" } },
|
||||
{ code: "KeyW", category: "writing-system", keys: { us: "w W" } },
|
||||
{ code: "KeyX", category: "writing-system", keys: { us: "x X" } },
|
||||
{ code: "KeyY", category: "writing-system", keys: { us: "y Y" } },
|
||||
{ code: "KeyZ", category: "writing-system", keys: { us: "z Z" } },
|
||||
|
||||
{ code: "Backquote", category: "writing-system", keys: { us: "` ~" } },
|
||||
{ code: "Backslash", category: "writing-system", keys: { us: "\\ |" } },
|
||||
{ code: "BracketLeft", category: "writing-system", keys: { us: "[ {" } },
|
||||
{ code: "BracketRight", category: "writing-system", keys: { us: "] }" } },
|
||||
{ code: "Comma", category: "writing-system", keys: { us: ", <" } },
|
||||
{ code: "Equal", category: "writing-system", keys: { us: "= +" } },
|
||||
{ code: "Minus", category: "writing-system", keys: { us: "- _" } },
|
||||
{ code: "Period", category: "writing-system", keys: { us: ". >" } },
|
||||
{ code: "Quote", category: "writing-system", keys: { us: "' \"" } },
|
||||
{ code: "Semicolon", category: "writing-system", keys: { us: "; :" } },
|
||||
{ code: "Slash", category: "writing-system", keys: { us: "/ ?" } },
|
||||
|
||||
{ code: "IntlBackslash", category: "writing-system", keys: { us: undefined } },
|
||||
{ code: "IntlRo", category: "writing-system", keys: { us: undefined } },
|
||||
{ code: "IntlYen", category: "writing-system", keys: { us: undefined } },
|
||||
|
||||
// Functional keys
|
||||
// https://www.w3.org/TR/uievents-code/#key-alphanumeric-functional
|
||||
// Codes have the same meaning regardless of locale, except for "AltRight"
|
||||
{ code: "AltLeft", category: "functional" },
|
||||
{ code: "AltRight", category: "functional" }, // Exception: `key` value is either "Alt" or "AltGraph" depending on locale (e.g. US vs. French, respectively)
|
||||
// The W3C table includes this in the Writing System Keys table instead of the Functional Keys table, but its diagrams
|
||||
// and text describe it as a functional key, so it has been moved here under the assumption that the table is incorrect
|
||||
// https://github.com/w3c/uievents-code/issues/34
|
||||
{ code: "Backspace", category: "writing-system" }, // Shares a name with a key attribute
|
||||
{ code: "CapsLock", category: "functional" }, // Shares a name with a key attribute
|
||||
{ code: "ContextMenu", category: "functional" }, // Shares a name with a key attribute
|
||||
{ code: "ControlLeft", category: "functional" }, // Shares a name with a key attribute as "Control"
|
||||
{ code: "ControlRight", category: "functional" }, // Shares a name with a key attribute as "Control"
|
||||
{ code: "Enter", category: "functional" }, // Shares a name with a key attribute
|
||||
{ code: "MetaLeft", category: "functional" }, // Shares a name with a key attribute as "Meta"
|
||||
{ code: "MetaRight", category: "functional" }, // Shares a name with a key attribute as "Meta"
|
||||
{ code: "ShiftLeft", category: "functional" }, // Shares a name with a key attribute as "Shift"
|
||||
{ code: "ShiftRight", category: "functional" }, // Shares a name with a key attribute as "Shift"
|
||||
{ code: "Space", category: "functional" },
|
||||
{ code: "Tab", category: "functional" }, // Shares a name with a key attribute
|
||||
|
||||
// Functional Japanese/Korean keys
|
||||
{ code: "Convert", category: "functional-jp-kr" }, // Shares a name with a key attribute
|
||||
{ code: "KanaMode", category: "functional-jp-kr" }, // Shares a name with a key attribute
|
||||
{ code: "Lang1", category: "functional-jp-kr" },
|
||||
{ code: "Lang2", category: "functional-jp-kr" },
|
||||
{ code: "Lang3", category: "functional-jp-kr" },
|
||||
{ code: "Lang4", category: "functional-jp-kr" },
|
||||
{ code: "Lang5", category: "functional-jp-kr" },
|
||||
{ code: "NonConvert", category: "functional-jp-kr" }, // Shares a name with a key attribute
|
||||
|
||||
// Control pad keys
|
||||
{ code: "Delete", category: "control-pad" }, // Shares a name with a key attribute
|
||||
{ code: "End", category: "control-pad" }, // Shares a name with a key attribute
|
||||
{ code: "Help", category: "control-pad" }, // Shares a name with a key attribute
|
||||
{ code: "Home", category: "control-pad" }, // Shares a name with a key attribute
|
||||
{ code: "Insert", category: "control-pad" }, // Shares a name with a key attribute
|
||||
{ code: "PageDown", category: "control-pad" }, // Shares a name with a key attribute
|
||||
{ code: "PageUp", category: "control-pad" }, // Shares a name with a key attribute
|
||||
|
||||
// Arrow pad keys
|
||||
{ code: "ArrowDown", category: "arrow-pad" }, // Shares a name with a key attribute
|
||||
{ code: "ArrowLeft", category: "arrow-pad" }, // Shares a name with a key attribute
|
||||
{ code: "ArrowRight", category: "arrow-pad" }, // Shares a name with a key attribute
|
||||
{ code: "ArrowUp", category: "arrow-pad" }, // Shares a name with a key attribute
|
||||
|
||||
// Numpad keys
|
||||
{ code: "Numpad0", category: "numpad" },
|
||||
{ code: "Numpad1", category: "numpad" },
|
||||
{ code: "Numpad2", category: "numpad" },
|
||||
{ code: "Numpad3", category: "numpad" },
|
||||
{ code: "Numpad4", category: "numpad" },
|
||||
{ code: "Numpad5", category: "numpad" },
|
||||
{ code: "Numpad6", category: "numpad" },
|
||||
{ code: "Numpad7", category: "numpad" },
|
||||
{ code: "Numpad8", category: "numpad" },
|
||||
{ code: "Numpad9", category: "numpad" },
|
||||
{ code: "NumLock", category: "numpad" }, // Shares a name with a key attribute
|
||||
{ code: "NumpadAdd", category: "numpad" },
|
||||
{ code: "NumpadBackspace", category: "numpad" },
|
||||
{ code: "NumpadClear", category: "numpad" },
|
||||
{ code: "NumpadClearEntry", category: "numpad" },
|
||||
{ code: "NumpadComma", category: "numpad" }, // Exception: Produces either a comma (,) or period (.) depending on locale (e.g. comma in US vs. period in Brazil)
|
||||
{ code: "NumpadDecimal", category: "numpad" }, // Exception: Produces either a comma (,) or period (.) depending on locale (e.g. period in US vs. decimal in Brazil)
|
||||
{ code: "NumpadDivide", category: "numpad" },
|
||||
{ code: "NumpadEnter", category: "numpad" },
|
||||
{ code: "NumpadEqual", category: "numpad" },
|
||||
{ code: "NumpadHash", category: "numpad" },
|
||||
{ code: "NumpadMemoryAdd", category: "numpad" },
|
||||
{ code: "NumpadMemoryClear", category: "numpad" },
|
||||
{ code: "NumpadMemoryRecall", category: "numpad" },
|
||||
{ code: "NumpadMemoryStore", category: "numpad" },
|
||||
{ code: "NumpadMemorySubtract", category: "numpad" },
|
||||
{ code: "NumpadMultiply", category: "numpad" },
|
||||
{ code: "NumpadParenLeft", category: "numpad" },
|
||||
{ code: "NumpadParenRight", category: "numpad" },
|
||||
{ code: "NumpadStar", category: "numpad" },
|
||||
{ code: "NumpadSubtract", category: "numpad" },
|
||||
|
||||
// Function keys
|
||||
{ code: "Escape", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "F1", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "F2", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "F3", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "F4", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "F5", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "F6", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "F7", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "F8", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "F9", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "F10", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "F11", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "F12", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "F13", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "F14", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "F15", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "F16", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "F17", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "F18", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "F19", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "F20", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "F21", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "F22", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "F23", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "F24", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "Fn", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "FnLock", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "PrintScreen", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "ScrollLock", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "Pause", category: "function" }, // Shares a name with a key attribute
|
||||
|
||||
// Media keys
|
||||
{ code: "BrowserBack", category: "media" }, // Shares a name with a key attribute
|
||||
{ code: "BrowserFavorites", category: "media" }, // Shares a name with a key attribute
|
||||
{ code: "BrowserForward", category: "media" }, // Shares a name with a key attribute
|
||||
{ code: "BrowserHome", category: "media" }, // Shares a name with a key attribute
|
||||
{ code: "BrowserRefresh", category: "media" }, // Shares a name with a key attribute
|
||||
{ code: "BrowserSearch", category: "media" }, // Shares a name with a key attribute
|
||||
{ code: "BrowserStop", category: "media" }, // Shares a name with a key attribute
|
||||
{ code: "Eject", category: "media" }, // Shares a name with a key attribute
|
||||
{ code: "LaunchApp1", category: "media" },
|
||||
{ code: "LaunchApp2", category: "media" },
|
||||
{ code: "LaunchMail", category: "media" }, // Shares a name with a key attribute
|
||||
{ code: "MediaPlayPause", category: "media" }, // Shares a name with a key attribute
|
||||
{ code: "MediaSelect", category: "media" },
|
||||
{ code: "MediaStop", category: "media" }, // Shares a name with a key attribute
|
||||
{ code: "MediaTrackNext", category: "media" }, // Shares a name with a key attribute
|
||||
{ code: "MediaTrackPrevious", category: "media" }, // Shares a name with a key attribute
|
||||
{ code: "Power", category: "media" }, // Shares a name with a key attribute
|
||||
{ code: "Sleep", category: "media" },
|
||||
{ code: "AudioVolumeDown", category: "media" }, // Shares a name with a key attribute
|
||||
{ code: "AudioVolumeMute", category: "media" }, // Shares a name with a key attribute
|
||||
{ code: "AudioVolumeUp", category: "media" }, // Shares a name with a key attribute
|
||||
{ code: "WakeUp", category: "media" }, // Shares a name with a key attribute
|
||||
|
||||
// Unidentified keys
|
||||
{ code: "Unidentified", category: "unidentified" }, // Shares a name with a key attribute
|
||||
];
|
||||
const KEY_CODE_NAMES = Object.values(KEY_CODES).map((info) => info.code);
|
||||
// const KEY_CODE_NAMES_WITHOUT_HANDEDNESS = KEY_CODE_NAMES.filter((code) => !(code.endsWith("Right") && HANDED_KEY_ATTRIBUTE_VALUES.some((modifier) => code === `${modifier}Right`))).map((code) =>
|
||||
// code.endsWith("Left") && HANDED_KEY_ATTRIBUTE_VALUES.some((modifier) => code === `${modifier}Left`) ? code.replace("Left", "") : code
|
||||
// );
|
||||
const NUMPAD_DECIMAL_AND_THOUSANDS_SEPARATORS = ["NumpadComma", "NumpadDecimal"];
|
||||
const SCAN_CODES_FOR_NON_WRITING_KEYS_THAT_VARY_PER_LOCALE = ["AltRight", ...NUMPAD_DECIMAL_AND_THOUSANDS_SEPARATORS];
|
||||
const LOCALE_SPECIFIC_KEY_CODES_INFO = KEY_CODES.filter((key) => key.category === "writing-system" || SCAN_CODES_FOR_NON_WRITING_KEYS_THAT_VARY_PER_LOCALE.includes(key.code));
|
||||
const LOCALE_SPECIFIC_KEY_CODES = LOCALE_SPECIFIC_KEY_CODES_INFO.map((info) => info.code);
|
||||
const WRITING_SYSTEM_SPECIAL_CHARS = Object.values(KEY_CODES)
|
||||
.filter((info) => info.category === "writing-system")
|
||||
.flatMap((info) => info.keys?.us?.split(" "))
|
||||
.filter((character) => character && !/[a-zA-Z0-9]/.test(character)) as string[];
|
||||
|
||||
const KEY_ATTRIBUTE_VALUES_INVOLVING_HANDEDNESS = ["Control", "Meta", "Shift"];
|
||||
const KEY_ATTRIBUTE_VALUES = new Set([
|
||||
// Modifier
|
||||
"Alt", // Glyph modifier key
|
||||
"AltGraph", // Glyph modifier key
|
||||
"CapsLock", // Glyph modifier key
|
||||
"Control",
|
||||
"Fn",
|
||||
"FnLock",
|
||||
"Meta",
|
||||
"NumLock",
|
||||
"ScrollLock",
|
||||
"Shift",
|
||||
"Symbol",
|
||||
"SymbolLock",
|
||||
|
||||
// Legacy modifier
|
||||
"Hyper",
|
||||
"Super",
|
||||
|
||||
// White space
|
||||
"Enter", // Control character
|
||||
"Tab", // Control character
|
||||
|
||||
// Navigation
|
||||
"ArrowDown",
|
||||
"ArrowLeft",
|
||||
"ArrowRight",
|
||||
"ArrowUp",
|
||||
"End",
|
||||
"Home",
|
||||
"PageDown",
|
||||
"PageUp",
|
||||
|
||||
// Editing
|
||||
"Backspace", // Control character
|
||||
"Clear",
|
||||
"Copy",
|
||||
"CrSel",
|
||||
"Cut",
|
||||
"Delete", // Control character
|
||||
"EraseEof",
|
||||
"ExSel",
|
||||
"Insert",
|
||||
"Paste",
|
||||
"Redo",
|
||||
"Undo",
|
||||
|
||||
// UI
|
||||
"Accept",
|
||||
"Again",
|
||||
"Attn",
|
||||
"Cancel",
|
||||
"ContextMenu",
|
||||
"Escape", // Control character
|
||||
"Execute",
|
||||
"Find",
|
||||
"Help",
|
||||
"Pause",
|
||||
"Play",
|
||||
"Props",
|
||||
"Select",
|
||||
"ZoomIn",
|
||||
"ZoomOut",
|
||||
|
||||
// Device
|
||||
"BrightnessDown",
|
||||
"BrightnessUp",
|
||||
"Eject",
|
||||
"LogOff",
|
||||
"Power",
|
||||
"PowerOff",
|
||||
"PrintScreen",
|
||||
"Hibernate",
|
||||
"Standby",
|
||||
"WakeUp",
|
||||
|
||||
// IME composition keys
|
||||
"AllCandidates",
|
||||
"Alphanumeric",
|
||||
"CodeInput",
|
||||
"Compose",
|
||||
"Convert",
|
||||
"Dead",
|
||||
"FinalMode",
|
||||
"GroupFirst",
|
||||
"GroupLast",
|
||||
"GroupNext",
|
||||
"GroupPrevious",
|
||||
"ModeChange",
|
||||
"NextCandidate",
|
||||
"NonConvert",
|
||||
"PreviousCandidate",
|
||||
"Process",
|
||||
"SingleCandidate",
|
||||
|
||||
// Korean-specific
|
||||
"HangulMode",
|
||||
"HanjaMode",
|
||||
"JunjaMode",
|
||||
|
||||
// Japanese-specific
|
||||
"Eisu",
|
||||
"Hankaku",
|
||||
"Hiragana",
|
||||
"HiraganaKatakana",
|
||||
"KanaMode",
|
||||
"KanjiMode",
|
||||
"Katakana",
|
||||
"Romaji",
|
||||
"Zenkaku",
|
||||
"ZenkakuHankaku",
|
||||
|
||||
// Common function
|
||||
"F1",
|
||||
"F2",
|
||||
"F3",
|
||||
"F4",
|
||||
"F5",
|
||||
"F6",
|
||||
"F7",
|
||||
"F8",
|
||||
"F9",
|
||||
"F10",
|
||||
"F11",
|
||||
"F12",
|
||||
"F13",
|
||||
"F14",
|
||||
"F15",
|
||||
"F16",
|
||||
"F17",
|
||||
"F18",
|
||||
"F19",
|
||||
"F20",
|
||||
"F21",
|
||||
"F22",
|
||||
"F23",
|
||||
"F24",
|
||||
"Soft1",
|
||||
"Soft2",
|
||||
"Soft3",
|
||||
"Soft4",
|
||||
"Soft5",
|
||||
"Soft6",
|
||||
"Soft7",
|
||||
"Soft8",
|
||||
"Soft9",
|
||||
"Soft10",
|
||||
"Soft11",
|
||||
"Soft12",
|
||||
"Soft13",
|
||||
"Soft14",
|
||||
"Soft15",
|
||||
"Soft16",
|
||||
"Soft17",
|
||||
"Soft18",
|
||||
"Soft19",
|
||||
"Soft20",
|
||||
"Soft21",
|
||||
"Soft22",
|
||||
"Soft23",
|
||||
"Soft24",
|
||||
|
||||
// Multimedia
|
||||
"ChannelDown",
|
||||
"ChannelUp",
|
||||
"Close",
|
||||
"MailForward",
|
||||
"MailReply",
|
||||
"MailSend",
|
||||
"MediaClose",
|
||||
"MediaFastForward",
|
||||
"MediaPause",
|
||||
"MediaPlay",
|
||||
"MediaPlayPause",
|
||||
"MediaRecord",
|
||||
"MediaRewind",
|
||||
"MediaStop",
|
||||
"MediaTrackNext",
|
||||
"MediaTrackPrevious",
|
||||
"New",
|
||||
"Open",
|
||||
"Print",
|
||||
"Save",
|
||||
"SpellCheck",
|
||||
|
||||
// Multimedia numpad
|
||||
"Digit11",
|
||||
"Digit12",
|
||||
|
||||
// Audio
|
||||
"AudioBalanceLeft",
|
||||
"AudioBalanceRight",
|
||||
"AudioBassBoostDown",
|
||||
"AudioBassBoostToggle",
|
||||
"AudioBassBoostUp",
|
||||
"AudioFaderFront",
|
||||
"AudioFaderRear",
|
||||
"AudioSurroundModeNext",
|
||||
"AudioTrebleDown",
|
||||
"AudioTrebleUp",
|
||||
"AudioVolumeDown",
|
||||
"AudioVolumeUp",
|
||||
"AudioVolumeMute",
|
||||
"MicrophoneToggle",
|
||||
"MicrophoneVolumeDown",
|
||||
"MicrophoneVolumeUp",
|
||||
"MicrophoneVolumeMute",
|
||||
|
||||
// Speech
|
||||
"SpeechCorrectionList",
|
||||
"SpeechInputToggle",
|
||||
|
||||
// Application
|
||||
"LaunchApplication1",
|
||||
"LaunchApplication2",
|
||||
"LaunchCalendar",
|
||||
"LaunchContacts",
|
||||
"LaunchMail",
|
||||
"LaunchMediaPlayer",
|
||||
"LaunchMusicPlayer",
|
||||
"LaunchPhone",
|
||||
"LaunchScreenSaver",
|
||||
"LaunchSpreadsheet",
|
||||
"LaunchWebBrowser",
|
||||
"LaunchWebCam",
|
||||
"LaunchWordProcessor",
|
||||
|
||||
// Browser
|
||||
"BrowserBack",
|
||||
"BrowserFavorites",
|
||||
"BrowserForward",
|
||||
"BrowserHome",
|
||||
"BrowserRefresh",
|
||||
"BrowserSearch",
|
||||
"BrowserStop",
|
||||
|
||||
// Mobile phone
|
||||
"AppSwitch",
|
||||
"Call",
|
||||
"Camera",
|
||||
"CameraFocus",
|
||||
"EndCall",
|
||||
"GoBack",
|
||||
"GoHome",
|
||||
"HeadsetHook",
|
||||
"LastNumberRedial",
|
||||
"Notification",
|
||||
"MannerMode",
|
||||
"VoiceDial",
|
||||
|
||||
// TV
|
||||
"TV",
|
||||
"TV3DMode",
|
||||
"TVAntennaCable",
|
||||
"TVAudioDescription",
|
||||
"TVAudioDescriptionMixDown",
|
||||
"TVAudioDescriptionMixUp",
|
||||
"TVContentsMenu",
|
||||
"TVDataService",
|
||||
"TVInput",
|
||||
"TVInputComponent1",
|
||||
"TVInputComponent2",
|
||||
"TVInputComposite1",
|
||||
"TVInputComposite2",
|
||||
"TVInputHDMI1",
|
||||
"TVInputHDMI2",
|
||||
"TVInputHDMI3",
|
||||
"TVInputHDMI4",
|
||||
"TVInputVGA1",
|
||||
"TVMediaContext",
|
||||
"TVNetwork",
|
||||
"TVNumberEntry",
|
||||
"TVPower",
|
||||
"TVRadioService",
|
||||
"TVSatellite",
|
||||
"TVSatelliteBS",
|
||||
"TVSatelliteCS",
|
||||
"TVSatelliteToggle",
|
||||
"TVTerrestrialAnalog",
|
||||
"TVTerrestrialDigital",
|
||||
"TVTimer",
|
||||
|
||||
// Media controls
|
||||
"AVRInput",
|
||||
"AVRPower",
|
||||
"ColorF0Red",
|
||||
"ColorF1Green",
|
||||
"ColorF2Yellow",
|
||||
"ColorF3Blue",
|
||||
"ColorF4Grey",
|
||||
"ColorF5Brown",
|
||||
"ClosedCaptionToggle",
|
||||
"Dimmer",
|
||||
"DisplaySwap",
|
||||
"DVR",
|
||||
"Exit",
|
||||
"FavoriteClear0",
|
||||
"FavoriteClear1",
|
||||
"FavoriteClear2",
|
||||
"FavoriteClear3",
|
||||
"FavoriteRecall0",
|
||||
"FavoriteRecall1",
|
||||
"FavoriteRecall2",
|
||||
"FavoriteRecall3",
|
||||
"FavoriteStore0",
|
||||
"FavoriteStore1",
|
||||
"FavoriteStore2",
|
||||
"FavoriteStore3",
|
||||
"Guide",
|
||||
"GuideNextDay",
|
||||
"GuidePreviousDay",
|
||||
"Info",
|
||||
"InstantReplay",
|
||||
"Link",
|
||||
"ListProgram",
|
||||
"LiveContent",
|
||||
"Lock",
|
||||
"MediaApps",
|
||||
"MediaAudioTrack",
|
||||
"MediaLast",
|
||||
"MediaSkipBackward",
|
||||
"MediaSkipForward",
|
||||
"MediaStepBackward",
|
||||
"MediaStepForward",
|
||||
"MediaTopMenu",
|
||||
"NavigateIn",
|
||||
"NavigateNext",
|
||||
"NavigateOut",
|
||||
"NavigatePrevious",
|
||||
"NextFavoriteChannel",
|
||||
"NextUserProfile",
|
||||
"OnDemand",
|
||||
"Pairing",
|
||||
"PinPDown",
|
||||
"PinPMove",
|
||||
"PinPToggle",
|
||||
"PinPUp",
|
||||
"PlaySpeedDown",
|
||||
"PlaySpeedReset",
|
||||
"PlaySpeedUp",
|
||||
"RandomToggle",
|
||||
"RcLowBattery",
|
||||
"RecordSpeedNext",
|
||||
"RfBypass",
|
||||
"ScanChannelsToggle",
|
||||
"ScreenModeNext",
|
||||
"Settings",
|
||||
"SplitScreenToggle",
|
||||
"STBInput",
|
||||
"STBPower",
|
||||
"Subtitle",
|
||||
"Teletext",
|
||||
"VideoModeNext",
|
||||
"Wink",
|
||||
"ZoomToggle",
|
||||
|
||||
// Unidentified
|
||||
"Unidentified",
|
||||
]);
|
||||
@@ -0,0 +1,3 @@
|
||||
export function clamp(value: number, min = 0, max = 1): number {
|
||||
return Math.max(min, Math.min(value, max));
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export type RequestResult = { body: string; status: number };
|
||||
|
||||
// Special implementation using the legacy XMLHttpRequest API that provides callbacks to get:
|
||||
// - Calls with the percent progress uploading the request to the server
|
||||
// - Calls when downloading the result from the server, after the server has begun streaming back the response data
|
||||
// It returns a tuple of the promise as well as the XHR which can be used to call the `.abort()` method on it.
|
||||
export function requestWithUploadDownloadProgress(
|
||||
url: string,
|
||||
method: "GET" | "HEAD" | "POST" | "PUT" | "DELETE" | "CONNECT" | "OPTIONS" | "TRACE" | "PATCH",
|
||||
body: string,
|
||||
uploadProgress: (progress: number) => void,
|
||||
downloadOccurring: () => void
|
||||
): [Promise<RequestResult>, XMLHttpRequest | undefined] {
|
||||
let xhrValue: XMLHttpRequest | undefined;
|
||||
const promise = new Promise<RequestResult>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.upload.addEventListener("progress", (e) => uploadProgress(e.loaded / e.total));
|
||||
xhr.addEventListener("progress", () => downloadOccurring());
|
||||
xhr.addEventListener("load", () => resolve({ status: xhr.status, body: xhr.responseText }));
|
||||
xhr.addEventListener("abort", () => resolve({ status: xhr.status, body: xhr.responseText }));
|
||||
xhr.addEventListener("error", () => reject(new Error("Request error")));
|
||||
xhr.open(method, url, true);
|
||||
xhr.setRequestHeader("accept", "*/*");
|
||||
xhr.setRequestHeader("accept-language", "en-US,en;q=0.9");
|
||||
xhr.setRequestHeader("content-type", "application/json");
|
||||
|
||||
xhrValue = xhr;
|
||||
|
||||
xhr.send(body);
|
||||
});
|
||||
|
||||
return [promise, xhrValue];
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// This works by proxying every function call and wrapping a try-catch block to filter out redundant and confusing
|
||||
// `RuntimeError: unreachable` exceptions that would normally be printed in the browser's JS console upon a panic.
|
||||
export function panicProxy<T extends object>(module: T): T {
|
||||
const proxyHandler = {
|
||||
get(target: T, propKey: string | symbol, receiver: unknown): unknown {
|
||||
const targetValue = Reflect.get(target, propKey, receiver);
|
||||
|
||||
// Keep the original value being accessed if it isn't a function
|
||||
const isFunction = typeof targetValue === "function";
|
||||
if (!isFunction) return targetValue;
|
||||
|
||||
// Special handling to wrap the return of a constructor in the proxy
|
||||
const isClass = isFunction && /^\s*class\s+/.test(targetValue.toString());
|
||||
if (isClass) {
|
||||
// eslint-disable-next-line func-names
|
||||
return function (...args: unknown[]): unknown {
|
||||
// All three of these comment lines are necessary to suppress errors at both compile time and while editing this file (@ts-expect-error doesn't work here while editing the file)
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
// eslint-disable-next-line new-cap
|
||||
const result = new targetValue(...args);
|
||||
|
||||
return panicProxy(result);
|
||||
};
|
||||
}
|
||||
|
||||
// Replace the original function with a wrapper function that runs the original in a try-catch block
|
||||
// eslint-disable-next-line func-names
|
||||
return function (...args: unknown[]): unknown {
|
||||
let result;
|
||||
try {
|
||||
// @ts-expect-error TypeScript does not know what `this` is, since it should be able to be anything
|
||||
result = targetValue.apply(this, args);
|
||||
} catch (err) {
|
||||
// Suppress `unreachable` WebAssembly.RuntimeError exceptions
|
||||
if (!`${err}`.startsWith("RuntimeError: unreachable")) throw err;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
return new Proxy<T>(module, proxyHandler);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
export function browserVersion(): string {
|
||||
const agent = window.navigator.userAgent;
|
||||
let match = agent.match(/(opera|chrome|safari|firefox|msie|trident(?=\/))\/?\s*(\d+)/i) || [];
|
||||
|
||||
if (/trident/i.test(match[1])) {
|
||||
const browser = /\brv[ :]+(\d+)/g.exec(agent) || [];
|
||||
return `IE ${browser[1] || ""}`.trim();
|
||||
}
|
||||
|
||||
if (match[1] === "Chrome") {
|
||||
let browser = agent.match(/\bEdg\/(\d+)/) || undefined;
|
||||
if (browser !== undefined) return `Edge (Chromium) ${browser[1]}`;
|
||||
|
||||
browser = agent.match(/\bOPR\/(\d+)/) || undefined;
|
||||
if (browser !== undefined) return `Opera ${browser[1]}`;
|
||||
}
|
||||
|
||||
match = match[2] ? [match[1], match[2]] : [navigator.appName, navigator.appVersion, "-?"];
|
||||
|
||||
const browser = agent.match(/version\/(\d+)/i) || undefined;
|
||||
if (browser !== undefined) match.splice(1, 1, browser[1]);
|
||||
|
||||
return `${match[0]} ${match[1]}`;
|
||||
}
|
||||
|
||||
export function operatingSystem(detailed = false): string {
|
||||
const osTableDetailed: Record<string, string> = {
|
||||
"Windows NT 10": "Windows 10 or 11",
|
||||
"Windows NT 6.3": "Windows 8.1",
|
||||
"Windows NT 6.2": "Windows 8",
|
||||
"Windows NT 6.1": "Windows 7",
|
||||
"Windows NT 6.0": "Windows Vista",
|
||||
"Windows NT 5.1": "Windows XP",
|
||||
"Windows NT 5.0": "Windows 2000",
|
||||
Mac: "Mac",
|
||||
X11: "Unix",
|
||||
Linux: "Linux",
|
||||
Unknown: "Unknown",
|
||||
};
|
||||
const osTableSimple: Record<string, string> = {
|
||||
Windows: "Windows",
|
||||
Mac: "Mac",
|
||||
Linux: "Linux",
|
||||
Unknown: "Unknown",
|
||||
};
|
||||
const osTable = detailed ? osTableDetailed : osTableSimple;
|
||||
|
||||
const userAgentOS = Object.keys(osTable).find((key) => window.navigator.userAgent.includes(key));
|
||||
return osTable[userAgentOS || "Unknown"];
|
||||
}
|
||||
|
||||
export function platformIsMac(): boolean {
|
||||
return operatingSystem() === "Mac";
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { replaceBlobURLsWithBase64 } from "@/utility-functions/files";
|
||||
|
||||
// Rasterize the string of an SVG document at a given width and height and turn it into the blob data of an image file matching the given MIME type
|
||||
export async function rasterizeSVGCanvas(svg: string, width: number, height: number, backgroundColor?: string): Promise<HTMLCanvasElement> {
|
||||
// A canvas to render our SVG to in order to get a raster image
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const context = canvas.getContext("2d", { willReadFrequently: true });
|
||||
if (!context) throw new Error("Can't create 2D context from canvas during SVG rasterization");
|
||||
|
||||
// Apply a background fill color if one is given
|
||||
if (backgroundColor) {
|
||||
context.fillStyle = backgroundColor;
|
||||
context.fillRect(0, 0, width, height);
|
||||
}
|
||||
|
||||
// This SVG rasterization scheme has the limitation that it cannot access blob URLs, so they must be inlined to base64 URLs
|
||||
const svgWithBase64Images = await replaceBlobURLsWithBase64(svg);
|
||||
|
||||
// Create a blob URL for our SVG
|
||||
const svgBlob = new Blob([svgWithBase64Images], { type: "image/svg+xml;charset=utf-8" });
|
||||
const url = URL.createObjectURL(svgBlob);
|
||||
|
||||
const image = new Image();
|
||||
image.src = url;
|
||||
await new Promise<void>((resolve) => {
|
||||
image.onload = (): void => resolve();
|
||||
});
|
||||
|
||||
// Draw our SVG to the canvas
|
||||
context?.drawImage(image, 0, 0, width, height);
|
||||
|
||||
// Clean up the SVG blob URL (once the URL is revoked, the SVG blob data itself is garbage collected after `svgBlob` goes out of scope)
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
return canvas;
|
||||
}
|
||||
|
||||
export async function rasterizeSVG(svg: string, width: number, height: number, mime: string, backgroundColor?: string): Promise<Blob> {
|
||||
const canvas = await rasterizeSVGCanvas(svg, width, height, backgroundColor);
|
||||
|
||||
// Convert the canvas to an image of the correct MIME type
|
||||
const blob = await new Promise<Blob | undefined>((resolve) => {
|
||||
canvas.toBlob((blob) => {
|
||||
resolve(blob || undefined);
|
||||
}, mime);
|
||||
});
|
||||
|
||||
if (!blob) throw new Error("Converting canvas to blob data failed in rasterizeSVG()");
|
||||
|
||||
return blob;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export function stripIndents(stringPieces: TemplateStringsArray, ...substitutions: unknown[]): string {
|
||||
const interleavedSubstitutions = stringPieces.flatMap((stringPiece, index) => [stringPiece, substitutions[index] !== undefined ? substitutions[index] : ""]);
|
||||
const stringLines = interleavedSubstitutions.join("").split("\n");
|
||||
|
||||
const visibleLineTabPrefixLengths = stringLines.map((line) => (/\S/.test(line) ? (line.match(/^(\t*)/) || [])[1].length : Infinity));
|
||||
const commonTabPrefixLength = Math.min(...visibleLineTabPrefixLengths);
|
||||
|
||||
const linesWithoutCommonTabPrefix = stringLines.map((line) => line.substring(commonTabPrefixLength));
|
||||
const multiLineString = linesWithoutCommonTabPrefix.join("\n");
|
||||
|
||||
return multiLineString.trim();
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// import { invoke } from "@tauri-apps/api";
|
||||
|
||||
import type WasmBindgenPackage from "@/../wasm/pkg";
|
||||
import { panicProxy } from "@/utility-functions/panic-proxy";
|
||||
import { type JsMessageType } from "@/wasm-communication/messages";
|
||||
import { createSubscriptionRouter, type SubscriptionRouter } from "@/wasm-communication/subscription-router";
|
||||
|
||||
export type WasmRawInstance = typeof WasmBindgenPackage;
|
||||
export type WasmEditorInstance = InstanceType<WasmRawInstance["JsEditorHandle"]>;
|
||||
export type Editor = Readonly<ReturnType<typeof createEditor>>;
|
||||
|
||||
// `wasmImport` starts uninitialized because its initialization needs to occur asynchronously, and thus needs to occur by manually calling and awaiting `initWasm()`
|
||||
let wasmImport: WasmRawInstance | undefined;
|
||||
let editorInstance: WasmEditorInstance | undefined;
|
||||
|
||||
export async function updateImage(path: BigUint64Array, mime: string, imageData: Uint8Array, documentId: bigint): Promise<void> {
|
||||
const blob = new Blob([imageData], { type: mime });
|
||||
|
||||
const blobURL = URL.createObjectURL(blob);
|
||||
|
||||
// Pre-decode the image so it is ready to be drawn instantly once it's placed into the viewport SVG
|
||||
const image = new Image();
|
||||
image.src = blobURL;
|
||||
await image.decode();
|
||||
|
||||
editorInstance?.setImageBlobURL(documentId, path, blobURL, image.naturalWidth, image.naturalHeight);
|
||||
}
|
||||
|
||||
export async function fetchImage(path: BigUint64Array, mime: string, documentId: bigint, url: string): Promise<void> {
|
||||
const data = await fetch(url);
|
||||
const blob = await data.blob();
|
||||
|
||||
const blobURL = URL.createObjectURL(blob);
|
||||
|
||||
// Pre-decode the image so it is ready to be drawn instantly once it's placed into the viewport SVG
|
||||
const image = new Image();
|
||||
image.src = blobURL;
|
||||
await image.decode();
|
||||
|
||||
editorInstance?.setImageBlobURL(documentId, path, blobURL, image.naturalWidth, image.naturalHeight);
|
||||
}
|
||||
|
||||
// TODO: Svelte: reenable this
|
||||
// // export async function dispatchTauri(message: string): Promise<string> {
|
||||
// export async function dispatchTauri(message: unknown): Promise<void> {
|
||||
// try {
|
||||
// const response = await invoke("handle_message", { message });
|
||||
// editorInstance?.tauriResponse(response);
|
||||
// } catch {
|
||||
// // eslint-disable-next-line no-console
|
||||
// console.error("Failed to dispatch Tauri message");
|
||||
// }
|
||||
// }
|
||||
|
||||
// Should be called asynchronously before `createEditor()`
|
||||
export async function initWasm(): Promise<void> {
|
||||
// Skip if the WASM module is already initialized
|
||||
if (wasmImport !== undefined) return;
|
||||
|
||||
// Import the WASM module JS bindings and wrap them in the panic proxy
|
||||
// eslint-disable-next-line import/no-cycle
|
||||
wasmImport = await import("@/../wasm/pkg").then(panicProxy);
|
||||
|
||||
// Provide a random starter seed which must occur after initializing the WASM module, since WASM can't generate its own random numbers
|
||||
const randomSeedFloat = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);
|
||||
const randomSeed = BigInt(randomSeedFloat);
|
||||
wasmImport?.setRandomSeed(randomSeed);
|
||||
// TODO: Tauri: reenable this
|
||||
// try {
|
||||
// await invoke("set_random_seed", { seed: randomSeedFloat });
|
||||
// } catch {
|
||||
// // Ignore errors
|
||||
// }
|
||||
}
|
||||
|
||||
// Should be called after running `initWasm()` and its promise resolving
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
||||
export function createEditor() {
|
||||
// Raw: Object containing several callable functions from `editor_api.rs` defined directly on the WASM module, not the editor instance (generated by wasm-bindgen)
|
||||
if (!wasmImport) throw new Error("Editor WASM backend was not initialized at application startup");
|
||||
const raw: WasmRawInstance = wasmImport;
|
||||
|
||||
// Instance: Object containing many functions from `editor_api.rs` that are part of the editor instance (generated by wasm-bindgen)
|
||||
const instance: WasmEditorInstance = new raw.JsEditorHandle((messageType: JsMessageType, messageData: Record<string, unknown>): void => {
|
||||
// This callback is called by WASM when a FrontendMessage is received from the WASM wrapper editor instance
|
||||
// We pass along the first two arguments then add our own `raw` and `instance` context for the last two arguments
|
||||
subscriptions.handleJsMessage(messageType, messageData, raw, instance);
|
||||
});
|
||||
editorInstance = instance;
|
||||
|
||||
// Subscriptions: Allows subscribing to messages in JS that are sent from the WASM backend
|
||||
const subscriptions: SubscriptionRouter = createSubscriptionRouter();
|
||||
|
||||
return {
|
||||
raw,
|
||||
instance,
|
||||
subscriptions,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,64 @@
|
||||
import { plainToInstance } from "class-transformer";
|
||||
|
||||
import { type WasmEditorInstance, type WasmRawInstance } from "@/wasm-communication/editor";
|
||||
import { type JsMessageType, messageMakers, type JsMessage } from "@/wasm-communication/messages";
|
||||
|
||||
type JsMessageCallback<T extends JsMessage> = (messageData: T) => void;
|
||||
// Don't know a better way of typing this since it can be any subclass of JsMessage
|
||||
// The functions interacting with this map are strongly typed though around JsMessage
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type JsMessageCallbackMap = Record<string, JsMessageCallback<any> | undefined>;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
||||
export function createSubscriptionRouter() {
|
||||
const subscriptions: JsMessageCallbackMap = {};
|
||||
|
||||
const subscribeJsMessage = <T extends JsMessage, Args extends unknown[]>(messageType: new (...args: Args) => T, callback: JsMessageCallback<T>): void => {
|
||||
subscriptions[messageType.name] = callback;
|
||||
};
|
||||
|
||||
const handleJsMessage = (messageType: JsMessageType, messageData: Record<string, unknown>, wasm: WasmRawInstance, instance: WasmEditorInstance): void => {
|
||||
// Find the message maker for the message type, which can either be a JS class constructor or a function that returns an instance of the JS class
|
||||
const messageMaker = messageMakers[messageType];
|
||||
if (!messageMaker) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`Received a frontend message of type "${messageType}" but was not able to parse the data. ` +
|
||||
"(Perhaps this message parser isn't exported in `messageMakers` at the bottom of `messages.ts`.)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Checks if the provided `messageMaker` is a class extending `JsMessage`. All classes inheriting from `JsMessage` will have a static readonly `jsMessageMarker` which is `true`.
|
||||
const isJsMessageMaker = (fn: typeof messageMaker): fn is typeof JsMessage => "jsMessageMarker" in fn;
|
||||
const messageIsClass = isJsMessageMaker(messageMaker);
|
||||
|
||||
// Messages with non-empty data are provided by wasm-bindgen as an object with one key as the message name, like: { NameOfThisMessage: { ... } }
|
||||
// Messages with empty data are provided by wasm-bindgen as a string with the message name, like: "NameOfThisMessage"
|
||||
// Here we extract the payload object or use an empty object depending on the situation.
|
||||
const unwrappedMessageData = messageData[messageType] || {};
|
||||
|
||||
// Converts to a `JsMessage` object by turning the JSON message data into an instance of the message class, either automatically or by calling the function that builds it.
|
||||
// If the `messageMaker` is a `JsMessage` class then we use the class-transformer library's `plainToInstance` function in order to convert the JSON data into the destination class.
|
||||
// If it is not a `JsMessage` then it should be a custom function that creates a JsMessage from a JSON, so we call the function itself with the raw JSON as an argument.
|
||||
// The resulting `message` is an instance of a class that extends `JsMessage`.
|
||||
const message = messageIsClass ? plainToInstance(messageMaker, unwrappedMessageData) : messageMaker(unwrappedMessageData, wasm, instance);
|
||||
|
||||
// It is ok to use constructor.name even with minification since it is used consistently with registerHandler
|
||||
const callback = subscriptions[message.constructor.name];
|
||||
|
||||
// If we have constructed a valid message, then we try and execute the callback that the frontend has associated with this message.
|
||||
// The frontend should always have a callback for all messages, and so we display an error if one was not found.
|
||||
if (message) {
|
||||
if (callback) callback(message);
|
||||
// eslint-disable-next-line no-console
|
||||
else console.error(`Received a frontend message of type "${messageType}" but no handler was registered for it from the client.`);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
subscribeJsMessage,
|
||||
handleJsMessage,
|
||||
};
|
||||
}
|
||||
export type SubscriptionRouter = ReturnType<typeof createSubscriptionRouter>;
|
||||
Reference in New Issue
Block a user