Svelte bug fixes and code cleanup (#1074)

* Fix middle click tab close

* Fix swapping colors in the ColorPicker

* Cleanup

* Fix field inputs

* Fix tab key-based navigation

* Fix ColorPicker reseting Initial color when opened

* Prevent backend fighting with user dragging NumberInput

* Remaining fixes and comment cleanup
This commit is contained in:
Keavon Chambers 2023-03-10 03:47:06 -08:00
parent 2327524690
commit 9bd319fba4
26 changed files with 221 additions and 180 deletions

View File

@ -95,7 +95,7 @@ module.exports = {
"newlines-between": "always-and-inside-groups",
pathGroups: [
{
pattern: "**/*.vue",
pattern: "**/*.svelte",
group: "unknown",
position: "after",
},

View File

@ -1,34 +1,41 @@
# Overview of `/frontend/`
The Graphite frontend is a web app that provides the presentation for the editor. It displays the GUI based on state from the backend and provides users with interactive widgets that send updates to the backend, which is the source of truth for state information. The frontend is built out of reactive components using the [Vue](https://vuejs.org/) framework. The backend is written in Rust and compiled to WebAssembly (WASM) to be run in the browser alongside the JS code.
The Graphite frontend is a web app that provides the presentation for the editor. It displays the GUI based on state from the backend and provides users with interactive widgets that send updates to the backend, which is the source of truth for state information. The frontend is built out of reactive components using the [Svelte](https://svelte.dev/) framework. The backend is written in Rust and compiled to WebAssembly (WASM) to be run in the browser alongside the JS code.
For lack of other options, the frontend is currently written as a web app. Maintaining web compatibility will always be a requirement, but the long-term plan is to port this code to a Rust-based native GUI framework, either written by the Rust community or created by our project if necessary. As a medium-term compromise, we may wrap the web-based frontend in a desktop webview windowing solution like Electron (probably not) or [Tauri](https://tauri.studio/) (probably).
## Bundled assets: `assets/`
Icons and images that are used in components and embedded into the application bundle by the build system using [loaders](https://webpack.js.org/loaders/).
## Public assets: `public/`
Static content like favicons that are copied directly into the root of the build output by the build system.
## Vue/TypeScript source: `src/`
Source code for the web app in the form of Vue components and [TypeScript](https://www.typescriptlang.org/) files.
## Svelte/TypeScript source: `src/`
Source code for the web app in the form of Svelte components and [TypeScript](https://www.typescriptlang.org/) files.
## WebAssembly wrapper: `wasm/`
Wraps the editor backend codebase (`/editor`) and provides a JS-centric API for the web app to use unburdened by Rust's complex data types that are incompatible with JS data types. Bindings (JS functions that call into the WASM module) are provided by [wasm-bindgen](https://rustwasm.github.io/docs/wasm-bindgen/) in concert with [wasm-pack](https://github.com/rustwasm/wasm-pack).
## ESLint configurations: `.eslintrc.js`
[ESLint](https://eslint.org/) is the tool which enforces style rules on the JS, TS, and Vue files in our frontend codebase. As it is set up in this config file, ESLint will complain about bad practices and often help reformat code automatically when (in VS Code) the file is saved or `npm run lint` is executed. (If you don't use VS Code, remember to run this command before committing!) This config file for ESLint sets our style preferences and configures our usage of extensions/plugins for Vue support, [Airbnb](https://github.com/airbnb/javascript)'s popular catalog of sane defaults, and [Prettier](https://prettier.io/)'s role as a code formatter.
[ESLint](https://eslint.org/) is the tool which enforces style rules on the JS, TS, and Svelte files in our frontend codebase. As it is set up in this config file, ESLint will complain about bad practices and often help reformat code automatically when (in VS Code) the file is saved or `npm run lint` is executed. (If you don't use VS Code, remember to run this command before committing!) This config file for ESLint sets our style preferences and configures our usage of extensions/plugins for Svelte support, [Airbnb](https://github.com/airbnb/javascript)'s popular catalog of sane defaults, and [Prettier](https://prettier.io/)'s role as a code formatter.
## npm ecosystem packages: `package.json`
While we don't use Node.js as a JS-based server, we do have to rely on its wide ecosystem of packages for our build system toolchain. If you're just getting started, make sure to install the latest LTS copy of Node.js and then run `cd frontend && npm install` to install these packages on your system. Our project's philosophy on third-party packages is to keep our dependency tree as light as possible, so adding anything new to our `package.json` should have overwhelming justification. Most of the packages are just development tooling (TypeScript, Vue CLI, ESLint, Prettier, wasm-pack, and [Sass](https://sass-lang.com/)) that run in your console during the build process.
While we don't use Node.js as a JS-based server, we do have to rely on its wide ecosystem of packages for our build system toolchain. If you're just getting started, make sure to install the latest LTS copy of Node.js and then run `cd frontend && npm install` to install these packages on your system. Our project's philosophy on third-party packages is to keep our dependency tree as light as possible, so adding anything new to our `package.json` should have overwhelming justification. Most of the packages are just development tooling (TypeScript, Webpack, ESLint, Prettier, wasm-pack, and [Sass](https://sass-lang.com/)) that run in your console during the build process.
## npm package installed versions: `package-lock.json`
Specifies the exact versions of packages installed in the npm dependency tree. While `package.json` specifies which packages to install and their minimum/maximum acceptable version numbers, `package-lock.json` represents the exact versions of each dependency and sub-dependency. Running `npm install` will grab these exact versions to ensure you are using the same packages as everyone else working on Graphite. `npm update` will modify `package-lock.json` to specify newer versions of any updated (sub-)dependencies and download those, as long as they don't exceed the maximum version allowed in `package.json`. To check for newer versions that exceed the max version, run `npm outdated` to see a list. Unless you know why you are doing it, try to avoid committing updates to `package-lock.json` by mistake if your code changes don't pertain to package updates. And never manually modify the file.
## TypeScript configurations: `tsconfig.json`
Basic configuration options for the TypeScript build tool to do its job in our repository.
## vue-svg-loader.js
An extremely simple Webpack loader that allows us to `import` SVG files into our JS to be used like they are Vue components. They end up as inline SVG elements in the web page like `<svg ...>...</svg>`, rather than being `<img src="..." />` elements, which provides some benefits like being able to apply CSS styles to them. These get embedded into the bundle (they live somewhere all together in a big, messy JS file) rather than being separate static SVG files that would have to be served individually.
## Webpack configurations: `webpack.config.js`
## Vue CLI/Webpack configurations: `vue.config.js`
[Vue CLI](https://cli.vuejs.org/) is a command line tool built around the [Webpack](https://webpack.js.org/) bundler/build system. This file is where we configure Webpack to set up plugins (like wasm-pack and license-checker) and loaders (like for Vue and SVG files). Part of the license-checker plugin setup includes some functions to format web package licenses, as well as Rust package licenses provided by [cargo-about](https://github.com/EmbarkStudios/cargo-about), into a text file that's distributed with the application to provide license notices for third-party code.
We use the [Webpack](https://webpack.js.org/) bundler/build system. This file is where we configure Webpack to set up plugins (like wasm-pack and license-checker) and loaders (like for Svelte and SVG files). Part of the license-checker plugin setup includes some functions to format web package licenses, as well as Rust package licenses provided by [cargo-about](https://github.com/EmbarkStudios/cargo-about), into a text file that's distributed with the application to provide license notices for third-party code.

View File

@ -1,26 +1,26 @@
# Overview of `/frontend/src/`
## Vue components: `components/`
## Svelte 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.
Svelte components that build the Graphite editor GUI, which are mounted in `App.svelte`. These each contain a Svelte-templated HTML section, an SCSS (Stylus CSS) section, and a script section. The aim is to avoid implementing much editor business logic here, just enough to make things interactive and communicate to the backend where the real business logic should occur.
## I/O managers: `io-managers/`
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.
Each I/O manager is a self-contained module where one instance is created in `Editor.svelte` when it's mounted to the DOM at app startup.
During development when HMR (hot-module replacement) occurs, these are also unmounted to clean up after themselves, so they can be mounted again with the updated code. Therefore, any side-effects that these managers cause (e.g. adding event listeners to the page) need a destructor function that cleans them up. The destructor function, when applicable, is returned by the module and automatically called in `App.svelte` on unmount.
During development when HMR (hot-module replacement) occurs, these are also unmounted to clean up after themselves, so they can be mounted again with the updated code. Therefore, any side-effects that these managers cause (e.g. adding event listeners to the page) need a destructor function that cleans them up. The destructor function, when applicable, is returned by the module and automatically called in `Editor.svelte` on unmount.
## State providers: `state-providers/`
TypeScript files which provide reactive state and importable functions to Vue components. Each module defines a Vue reactive state object `const state = reactive({ ... });` and exports this from the module in the returned object as the key-value pair `state: readonly(state) as typeof state,` using Vue's `readonly()` wrapper. Other functions may also be defined in the module and exported after `state`, which provide a way for Vue components to call functions to manipulate the state.
TypeScript files which provide reactive state and importable functions to Svelte components. Each module defines a Svelte writable store `const { subscribe, update } = writable({ .. });` and exports the `subscribe` method from the module in the returned object. Other functions may also be defined in the module and exported after `subscribe`, which provide a way for Svelte components to call functions to manipulate the state.
In `App.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).
In `Editor.svelte`, an instance of each of these are given to Svelte's [`setContext()`](https://svelte.dev/docs#run-time-svelte-setcontext) function. This allows any component to access the state provider instance using `const exampleStateProvider = getContext<ExampleStateProvider>("exampleStateProvider");`.
## _I/O managers vs. state providers_
_Some state providers, similarly to I/O managers, may subscribe to backend events, call functions from `editor_api.rs` into the backend, and interact with browser APIs and user input. The difference is that state providers are meant to be `inject`ed by components to use them for reactive state, while I/O managers are meant to be self-contained systems that operate for the lifetime of the application and aren't touched by Vue components._
_Some state providers, similarly to I/O managers, may subscribe to backend events, call functions from `editor_api.rs` into the backend, and interact with browser APIs and user input. The difference is that state providers are meant to be made available to components via `getContext()` to use them for reactive state, while I/O managers are meant to be self-contained systems that operate for the lifetime of the application and aren't touched by Svelte components._
## Utility functions: `utility-functions/`
@ -34,7 +34,7 @@ TypeScript files which serve as the JS interface to the WASM bindings for the ed
Instantiates the WASM and editor backend instances. The function `initWasm()` asynchronously constructs and initializes an instance of the WASM bindings JS module provided by wasm-bindgen/wasm-pack. The function `createEditor()` constructs an instance of the editor backend. In theory there could be multiple editor instances sharing the same WASM module instance. The function returns an object where `raw` is the WASM module, `instance` is the editor, and `subscriptions` is the subscription router (described below).
`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`.
`initWasm()` occurs in `main.ts` right before the Svelte application exists, then `createEditor()` is run in `Editor.svelte` during the Svelte app's creation. Similarly to the state providers described above, the editor is given via `setContext()` so other components can get it via `getContext` and call functions on `editor.raw`, `editor.instance`, or `editor.subscriptions`.
### Message definitions: `messages.ts`
@ -44,10 +44,14 @@ Defines the message formats and data types received from the backend. Since Rust
Associates messages from the backend with subscribers in the frontend, and routes messages to subscriber callbacks. This module provides a `subscribeJsMessage(messageType, callback)` function which JS code throughout the frontend can call to be registered as the exclusive handler for a chosen message type. This file's other exported function, `handleJsMessage(messageType, messageData, wasm, instance)`, is called in `editor.ts` by the associated editor instance when the backend sends a `FrontendMessage`. When this occurs, the subscription router delivers the message to the subscriber for given `messageType` by executing its registered `callback` function. As an argument to the function, it provides the `messageData` payload transformed into its TypeScript-friendly format defined in `messages.ts`.
## Vue app: `App.svelte`
## Svelte app entry point: `App.svelte`
The entry point for the Vue application. This is where we define global CSS style rules, create/destroy the editor instance, construct/destruct the I/O managers, and construct and provide the state providers.
The entry point for the Svelte application.
## Entry point: `main.ts`
## Editor base instance: `Editor.svelte`
The entry point for the entire project's code bundle. Here we simply initialize the WASM module with `await initWasm();` then initialize the Vue application with `createApp(App).mount("#app");`.
This is where we define global CSS style rules, create/destroy the editor instance, construct/destruct the I/O managers, and construct and `setContext()` the state providers.
## JS bundle entry point: `main.ts`
The entry point for the entire project's code bundle. Here we simply initialize the Svelte application with `export default new App({ target: document.body });`.

View File

@ -287,13 +287,14 @@
}
// Checkbox needs to apply the focus outline to its sibling label
.optional-input input:focus-visible + label,
.checkbox-input input:focus-visible + label {
outline: 1px dashed var(--color-e-nearwhite);
outline-offset: -1px;
}
// Variant: dark outline over light colors
&.checked {
outline: 1px dashed var(--color-2-mildblack);
}
// Variant: dark outline over light colors (when the checkbox is checked)
:not(.optional-input) > .checkbox-input input:focus-visible + label.checked {
outline: 1px dashed var(--color-2-mildblack);
}
</style>

View File

@ -1,6 +1,6 @@
# 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.
Each component represents a (usually reusable) part of the Graphite Editor GUI. These all get mounted in `Editor.svelte` (in the `/src` directory above this one).
## Floating Menus: `floating-menus/`
@ -31,28 +31,30 @@ This section contains a growing list of quick reference information for helpful
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);
},
},
});
// The dispatcher that sends the changed value as a custom event to the parent
const dispatch = createEventDispatcher<{ theBidirectionalProperty: number }>();
// The prop
export let theBidirectionalProperty: number;
// Called only when `theBidirectionalProperty` is changed from outside this component via its props
$: console.log(theBidirectionalProperty);
// Example of a method that would update the value
function doSomething() {
dispatch("theBidirectionalProperty", SOME_NEW_VALUE);
},
```
Users of the component do this for `theCorrespondingDataEntry` to be a two-way binding:
```html
<DropdownInput v-model:theBidirectionalProperty="theCorrespondingDataEntry" />
```ts
let theCorrespondingDataEntry = 42;
```
```svelte
<DropdownInput
theBidirectionalProperty={theCorrespondingDataEntry}
on:theBidirectionalProperty={({ detail }) => { theCorrespondingDataEntry = detail; }}
/>
```

View File

@ -46,35 +46,42 @@
const hsvaOrNone = color.toHSVA();
const hsva = hsvaOrNone || { h: 0, s: 0, v: 0, a: 1 };
// New color components
let hue = hsva.h;
let saturation = hsva.s;
let value = hsva.v;
let alpha = hsva.a;
let isNone = hsvaOrNone === undefined;
// Initial color components
let initialHue = hsva.h;
let initialSaturation = hsva.s;
let initialValue = hsva.v;
let initialAlpha = hsva.a;
let initialIsNone = hsvaOrNone === undefined;
// Transient state
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 });
$: initialColor = updateInitialColor(initialHue, initialSaturation, initialValue, initialAlpha, initialIsNone, open);
$: 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);
// Taking `_open` is necessary to make Svelte order the reactive processing queue so this works as required, see:
// https://stackoverflow.com/questions/63934543/svelte-reactivity-not-triggering-when-variable-changed-in-a-function
function updateInitialColor(h: number, s: number, v: number, a: number, initialIsNone: boolean, _open: boolean) {
if (initialIsNone) return new Color("none");
return new Color({ h, s, v, a });
}
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();
@ -222,20 +229,20 @@
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 setNewHSVA(h: number, s: number, v: number, a: number, none: boolean) {
hue = h;
saturation = s;
value = v;
alpha = a;
isNone = none;
}
function setInitialHSVA(hue: number, saturation: number, value: number, alpha: number, isNone: boolean) {
initialHue = hue;
initialSaturation = saturation;
initialValue = value;
initialAlpha = alpha;
initialIsNone = isNone;
function setInitialHSVA(h: number, s: number, v: number, a: number, none: boolean) {
initialHue = h;
initialSaturation = s;
initialValue = v;
initialAlpha = a;
initialIsNone = none;
}
async function activateEyedropperSample() {
@ -297,7 +304,7 @@
<TextLabel>Initial</TextLabel>
</LayoutCol>
</LayoutRow>
<DropdownInput entries={colorSpaceChoices} selectedIndex={0} disabled={true} tooltip="Color Space and HDR (coming soon)" />
<DropdownInput entries={COLOR_SPACE_CHOICES} selectedIndex={0} disabled={true} tooltip="Color Space and HDR (coming soon)" />
<LayoutRow>
<TextLabel tooltip="Color code in hexadecimal format">Hex</TextLabel>
<Separator />
@ -333,7 +340,7 @@
</LayoutRow>
</LayoutRow>
<LayoutRow>
<TextLabel tooltip="Hue/Saturation/Value, also known as Hue/Saturation/Brightness (HSB).\nNot to be confused with Hue/Saturation/Lightness (HSL), a different color model."
<TextLabel tooltip={"Hue/Saturation/Value, also known as Hue/Saturation/Brightness (HSB).\nNot to be confused with Hue/Saturation/Lightness (HSL), a different color model."}
>HSV</TextLabel
>
<Separator />

View File

@ -33,7 +33,7 @@
let highlighted = activeEntry as MenuListEntry | undefined;
let virtualScrollingEntriesStart = 0;
// Called only when `open` is changed from outside this component (with v-model)
// Called only when `open` is changed from outside this component
$: watchOpen(open);
$: watchRemeasureWidth(entries, drawIcon);
$: virtualScrollingTotalHeight = entries.length === 0 ? 0 : entries[0].length * virtualScrollingEntryHeight;
@ -58,7 +58,7 @@
// Call the action if available
if (menuListEntry.action) menuListEntry.action();
// Emit the clicked entry as the new active entry
// Notify the parent about the clicked entry as the new active entry
dispatch("activeEntry", menuListEntry);
// Close the containing menu

View File

@ -62,7 +62,7 @@
$: watchOpenChange(open);
// Called only when `open` is changed from outside this component (with `v-model`)
// Called only when `open` is changed from outside this component
async function watchOpenChange(isOpen: boolean) {
// Switching from closed to open
if (isOpen && !wasOpen) {
@ -102,9 +102,9 @@
}
// 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
// TODO: Use DOM attribute bindings more whilst not causing recursive updates
afterUpdate(() => {
// Turning measuring on and off both causes the component to change, which causes the `updated()` Vue event to fire extraneous times (hurting performance and sometimes causing an infinite loop)
// Turning measuring on and off both causes the component to change, which causes the `afterUpdate()` Svelte event to fire extraneous times (hurting performance and sometimes causing an infinite loop)
if (!measuringOngoingGuard) positionAndStyleFloatingMenu();
});
@ -128,7 +128,7 @@
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
// We use `.style` on a div (instead of a style DOM attribute binding) because the binding causes the `afterUpdate()` hook to call the function we're in recursively forever
const tailOffset = type === "Popover" ? 10 : 0;
if (direction === "Bottom") floatingMenuContent.div().style.top = `${tailOffset + floatingMenuBounds.top}px`;
if (direction === "Top") floatingMenuContent.div().style.bottom = `${tailOffset + floatingMenuBounds.bottom}px`;
@ -136,7 +136,7 @@
if (direction === "Left") floatingMenuContent.div().style.right = `${tailOffset + floatingMenuBounds.right}px`;
// Required to correctly position tail when scrolled (it has a `position: fixed` to prevent clipping)
// We use `.style` on a ref (instead of a `:style` Vue binding) because the binding causes the `updated()` hook to call the function we're in recursively forever
// We use `.style` on a div (instead of a style DOM attribute binding) because the binding causes the `afterUpdate()` hook to call the function we're in recursively forever
if (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`;
@ -150,7 +150,7 @@
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
// We use `.style` on a div (instead of a style DOM attribute binding) because the binding causes the `afterUpdate()` hook to call the function we're in recursively forever
if (floatingMenuContentBounds.left - windowEdgeMargin <= workspaceBounds.left) {
floatingMenuContent.div().style.left = `${windowEdgeMargin}px`;
if (workspaceBounds.left + floatingMenuContainerBounds.left === 12) zeroedBorderHorizontal = "Left";
@ -163,7 +163,7 @@
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
// We use `.style` on a div (instead of a style DOM attribute binding) because the binding causes the `afterUpdate()` hook to call the function we're in recursively forever
if (floatingMenuContentBounds.top - windowEdgeMargin <= workspaceBounds.top) {
floatingMenuContent.div().style.top = `${windowEdgeMargin}px`;
if (workspaceBounds.top + floatingMenuContainerBounds.top === 12) zeroedBorderVertical = "Top";
@ -176,7 +176,7 @@
// 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
// We use `.style` on a div (instead of a style DOM attribute binding) because the binding causes the `afterUpdate()` hook to call the function we're in recursively forever
switch (`${zeroedBorderVertical}${zeroedBorderHorizontal}`) {
case "TopLeft":
floatingMenuContent.div().style.borderTopLeftRadius = "0";
@ -204,7 +204,7 @@
export async function measureAndEmitNaturalWidth(): Promise<void> {
if (!measuringOngoingGuard) return;
// Wait for the changed content which fired the `updated()` Vue event to be put into the DOM
// Wait for the changed content which fired the `afterUpdate()` Svelte event to be put into the DOM
await tick();
// Wait until all fonts have been loaded and rendered so measurements of content involving text are accurate
@ -216,15 +216,15 @@
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
// The result will be `undefined` if the menu is invisible, perhaps because an ancestor component is hidden with a falsy Svelte template if condition
const naturalWidth: number | undefined = floatingMenuContent?.div().clientWidth;
// Turn off measuring mode for the component, which triggers another call to the `updated()` Vue event, so we can turn off the protection after that has happened
// Turn off measuring mode for the component, which triggers another call to the `afterUpdate()` Svelte event, so we can turn off the protection after that has happened
measuringOngoing = false;
await tick();
measuringOngoingGuard = false;
// Emit the measured natural width to the parent
// Notify the parent about the measured natural width
if (naturalWidth !== undefined && naturalWidth >= 0) {
dispatch("naturalWidth", naturalWidth);
}

View File

@ -244,8 +244,7 @@
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
// Find actual relevant child and focus it (setTimeout is required to actually focus the input element)
setTimeout(() => nodeSearchInput.focus(), 0);
document.addEventListener("keydown", keydown);

View File

@ -80,7 +80,7 @@
}
</script>
<!-- TODO: Refactor this component to use `<component :is="" v-bind="attributesObject"></component>` to avoid all the separate components with `v-if` -->
<!-- TODO: Refactor this component to use `<svelte:component this={attributesObject} />` to avoid all the separate conditional components -->
<!-- TODO: Also rename this component, and probably move the `widget-${direction}` wrapper to be part of `WidgetLayout.svelte` as part of its refactor -->
<div class={`widget-${direction}`}>

View File

@ -35,7 +35,16 @@
<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} />
<ColorPicker
{open}
on:open={({ detail }) => (open = detail)}
color={value}
on:color={({ detail }) => {
value = detail;
dispatch("value", detail);
}}
allowNone={true}
/>
</LayoutRow>
<style lang="scss" global>

View File

@ -32,13 +32,13 @@
$: selectedIndex, watchSelectedIndex();
$: watchActiveEntry(activeEntry);
// Called only when `selectedIndex` is changed from outside this component (with v-model)
// Called only when `selectedIndex` is changed from outside this component
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)
// Called when the `activeEntry` two-way binding on this component's MenuList component is changed, or by the `selectedIndex()` watcher above (but we want to skip that case)
function watchActiveEntry(activeEntry: MenuListEntry) {
if (activeEntrySkipWatcher) {
activeEntrySkipWatcher = false;

View File

@ -36,10 +36,8 @@
$: 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
// Setting the value directly is required to make the following `select()` call work
inputOrTextarea.value = currentText;
inputOrTextarea.select();
}

View File

@ -94,8 +94,8 @@
</div>
{#if entry.children && entry.children.length > 0}
<MenuList
on:open={(e) => {
if (entry.ref) entry.ref.open = e.detail;
on:open={({ detail }) => {
if (entry.ref) entry.ref.open = detail;
}}
open={entry.ref?.open || false}
entries={entry.children || []}

View File

@ -67,8 +67,11 @@
$: sliderStepValue = isInteger ? (step === undefined ? 1 : step) : "any";
$: watchValue(value);
// Called only when `value` is changed from outside this component (with v-model)
// Called only when `value` is changed from outside this component
function watchValue(value: number | undefined) {
// Don't update if the slider is currently being dragged (we don't want the backend fighting with the user's drag)
if (rangeSliderClickDragState === "dragging") return;
// Draw a dash if the value is undefined
if (value === undefined) {
text = "-";

View File

@ -38,11 +38,29 @@
<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" />
<ColorPicker
open={primaryOpen}
on:open={({ detail }) => (primaryOpen = detail)}
color={primary}
on:color={({ detail }) => {
primary = 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" />
<ColorPicker
open={secondaryOpen}
on:open={({ detail }) => (secondaryOpen = detail)}
color={secondary}
on:color={({ detail }) => {
secondary = detail;
secondaryColorChanged(detail);
}}
direction="Right"
/>
</LayoutRow>
</LayoutCol>

View File

@ -4,7 +4,7 @@
import FieldInput from "@/components/widgets/inputs/FieldInput.svelte";
// emits: ["update:value", "commitText"],
const dispatch = createEventDispatcher<{ value: string; commitText: string }>();
const dispatch = createEventDispatcher<{ commitText: string }>();
export let value: string;
export let label: string | undefined = undefined;
@ -13,9 +13,6 @@
let self: FieldInput;
let editing = false;
let inputValue = value;
$: dispatch("value", inputValue);
function onTextFocused() {
editing = true;
@ -49,11 +46,9 @@
<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)}
classes={{ "has-label": Boolean(label) }}
{value}
on:value
on:textFocused={onTextFocused}
on:textChanged={onTextChanged}
on:cancelTextChange={onCancelTextChange}
@ -62,7 +57,6 @@
{label}
{disabled}
{tooltip}
value={inputValue}
bind:this={self}
/>

View File

@ -4,7 +4,7 @@
import FieldInput from "@/components/widgets/inputs/FieldInput.svelte";
// emits: ["update:value", "commitText"],
const dispatch = createEventDispatcher<{ value: string; commitText: string }>();
const dispatch = createEventDispatcher<{ commitText: string }>();
// Label
export let label: string | undefined = undefined;
@ -21,14 +21,11 @@
let self: FieldInput;
let editing = false;
let text = value;
$: dispatch("value", text);
function onTextFocused() {
editing = true;
self.selectAllText(text);
self.selectAllText(value);
}
// Called only when `value` is changed from the <input> element via user input and committed, either with the
@ -61,8 +58,8 @@
class="text-input"
classes={{ centered }}
styles={{ "min-width": minWidth > 0 ? `${minWidth}px` : undefined }}
value={text}
on:value={({ detail }) => (text = detail)}
{value}
on:value
on:textFocused={onTextFocused}
on:textChanged={onTextChanged}
on:cancelTextChange={onCancelTextChange}

View File

@ -3,8 +3,6 @@
import LayoutRow from "@/components/layout/LayoutRow.svelte";
// TODO: Svelte: fix icon imports
let className = "";
export { className as class };
export let classes: Record<string, boolean> = {};

View File

@ -19,7 +19,7 @@
<script lang="ts">
import { getContext, tick } from "svelte";
import { platformIsMac } from "@/utility-functions/platform";
import { platformIsMac, isEventSupported } from "@/utility-functions/platform";
import { type LayoutKeysGroup, type Key } from "@/wasm-communication/messages";
@ -61,7 +61,6 @@
return reservedKey ? [CONTROL, ALT] : [CONTROL];
}
// TODO: Svelte: test this
export async function scrollTabIntoView(newIndex: number) {
await tick();
tabElements[newIndex].div().scrollIntoView();
@ -78,8 +77,24 @@
tooltip={tabLabel.tooltip || undefined}
on:click={(e) => {
e.stopPropagation();
if (e.button === 0) clickAction?.(tabIndex);
if (e.button === 1) closeAction?.(tabIndex);
clickAction?.(tabIndex);
}}
on:auxclick={(e) => {
// Middle mouse button click
if (e.button === 1) {
e.stopPropagation();
closeAction?.(tabIndex);
}
}}
on:mouseup={(e) => {
// Fallback for Safari:
// https://developer.mozilla.org/en-US/docs/Web/API/Element/auxclick_event#browser_compatibility
// The downside of using mouseup is that the mousedown didn't have to originate in the same element.
// A possible future improvement could save the target element during mousedown and check if it's the same here.
if (!isEventSupported("auxclick") && e.button === 1) {
e.stopPropagation();
closeAction?.(tabIndex);
}
}}
bind:this={tabElements[tabIndex]}
>

View File

@ -17,7 +17,8 @@ type EventListenerTarget = {
};
export function createInputManager(editor: Editor, dialog: DialogState, document: PortfolioState, fullscreen: FullscreenState): () => void {
window.document.body.focus();
const app = window.document.querySelector("[data-app-container]") as HTMLElement | undefined;
app?.focus();
let viewportPointerInteractionOngoing = false;
let textInput = undefined as undefined | HTMLDivElement;
@ -31,21 +32,20 @@ export function createInputManager(editor: Editor, dialog: DialogState, document
// 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`
{ target: window, eventName: "resize", action: () => onWindowResize(window.document.body) },
{ target: window, eventName: "beforeunload", action: (e: BeforeUnloadEvent) => onBeforeUnload(e) },
{ target: window, eventName: "keyup", action: (e: KeyboardEvent) => onKeyUp(e) },
{ target: window, eventName: "keydown", action: (e: KeyboardEvent) => onKeyDown(e) },
{ target: window, eventName: "pointermove", action: (e: PointerEvent) => onPointerMove(e) },
{ target: window, eventName: "pointerdown", action: (e: PointerEvent) => onPointerDown(e) },
{ target: window, eventName: "pointerup", action: (e: PointerEvent) => onPointerUp(e) },
{ target: window, eventName: "dblclick", action: (e: PointerEvent) => onDoubleClick(e) },
{ target: window, eventName: "wheel", action: (e: WheelEvent) => onWheelScroll(e), options: { passive: false } },
{ target: window, eventName: "modifyinputfield", action: (e: CustomEvent) => onModifyInputField(e) },
{ target: window, eventName: "focusout", action: () => (canvasFocused = false) },
{ target: window.document, eventName: "contextmenu", action: (e: MouseEvent) => e.preventDefault() },
{ target: window.document, eventName: "fullscreenchange", action: () => fullscreen.fullscreenModeChanged() },
{ target: window.document.body, eventName: "paste", action: (e: ClipboardEvent) => onPaste(e) },
];
// Event bindings
@ -143,7 +143,7 @@ export function createInputManager(editor: Editor, dialog: DialogState, document
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();
app?.focus();
}
const modifiers = makeKeyboardModifiersBitfield(e);
@ -171,6 +171,9 @@ export function createInputManager(editor: Editor, dialog: DialogState, document
const modifiers = makeKeyboardModifiersBitfield(e);
editor.instance.onMouseDown(e.clientX, e.clientY, e.buttons, modifiers);
}
// Block middle mouse button auto-scroll mode (the circlar widget that appears and allows quick scrolling by moving the cursor above or below it)
if (e.button === 1) e.preventDefault();
}
function onPointerUp(e: PointerEvent): void {
@ -193,12 +196,6 @@ export function createInputManager(editor: Editor, dialog: DialogState, document
// 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]");

View File

@ -7,4 +7,6 @@ import "reflect-metadata";
import App from "@/App.svelte";
document.body.setAttribute("data-app-container", "");
export default new App({ target: document.body });

View File

@ -5,7 +5,7 @@ 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
// TODO: Do some code cleanup to remove the need for this empty store
const { subscribe } = writable({});
function createURL(font: string): URL {

View File

@ -52,3 +52,19 @@ export function operatingSystem(detailed = false): string {
export function platformIsMac(): boolean {
return operatingSystem() === "Mac";
}
export function isEventSupported(eventName: string) {
const onEventName = `on${eventName}`;
let tag = "div";
if (["select", "change"].includes(eventName)) tag = "select";
if (["submit", "reset"].includes(eventName)) tag = "form";
if (["error", "load", "abort"].includes(eventName)) tag = "img";
const element = document.createElement(tag);
if (onEventName in element) return true;
// Check if "return;" gets converted into a function, meaning the event is supported
element.setAttribute(eventName, "return;");
return typeof (element as Record<string, any>)[onEventName] === "function";
}

View File

@ -1,5 +1,3 @@
// 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";
@ -40,17 +38,18 @@ export async function fetchImage(path: BigUint64Array, mime: string, documentId:
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");
// }
// }
const tauri = "__TAURI_METADATA__" in window && import("@tauri-apps/api");
export async function dispatchTauri(message: unknown): Promise<void> {
if (!tauri) return;
try {
const response = await (await tauri).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> {

View File

@ -136,29 +136,6 @@ const config: webpack.Configuration = {
// new SvelteCheckPlugin(),
],
devtool: mode === 'development' ? 'source-map' : false,
// // https://cli.vuejs.org/guide/webpack.html
// chainWebpack: (config) => {
// // Change the loaders used by the Vue compilation process
// config.module
// // Replace Vue's existing base loader by first clearing it
// // https://cli.vuejs.org/guide/webpack.html#replacing-loaders-of-a-rule
// .rule("svg")
// .uses.clear()
// .end()
// // Required (since upgrading vue-cli to v5) to stop the default import behavior, as documented in:
// // https://webpack.js.org/configuration/module/#ruletype
// .type("javascript/auto")
// // Add vue-loader as a loader for Vue single-file components
// // https://www.npmjs.com/package/vue-loader
// .use("vue-loader")
// .loader("vue-loader")
// .end()
// // Add vue-svg-loader as a loader for importing .svg files into Vue single-file components
// // Located in ./vue-svg-loader.js
// .use("./vue-svg-loader")
// .loader("./vue-svg-loader")
// .end();
// },
experiments: {
asyncWebAssembly: true,
},
@ -353,8 +330,6 @@ function htmlDecode(input: string): string {
gt: ">",
amp: "&",
apos: "'",
// TODO: Svelte: check if this can be removed
// eslint-disable-next-line quotes
quot: '"',
};