Vue initialization and FloatingMenu codebase refactoring and cleanup (#649)

* Clean up Vue initialization-related code

* Rename folder: dispatcher -> interop

* Rename folder: state -> providers

* Comments and clarification

* Rename JS dispatcher to subscription router

* Assorted cleanup and renaming

* Rename: js-messages.ts -> messages.ts

* Comments

* Remove unused Vue component injects

* Clean up coming soon and add warning about freezing the app

* Further cleanup

* Dangerous changes

* Simplify App.vue code

* Move more disparate init code from components into managers

* Rename folder: providers -> state-providers

* Other

* Move Document panel options bar separator to backend

* Add destructors to managers to fix HMR

* Comments and code style

* Rename variable: font -> font_file_url

* Fix async font loading; refactor janky floating menu openness and min-width measurement; fix Vetur errors

* Fix misaligned canvas in viewport until panning on page (re)load

* Add Vue bidirectional props documentation

* More folder renaming for better terminology; add some documentation
This commit is contained in:
Keavon Chambers
2022-05-21 19:46:15 -07:00
parent 4c3c925c2c
commit fc2d983bd7
73 changed files with 1572 additions and 1462 deletions
+2 -2
View File
@@ -73,7 +73,7 @@ pub enum DocumentMessage {
affected_folder_path: Vec<LayerId>, affected_folder_path: Vec<LayerId>,
}, },
FontLoaded { FontLoaded {
font: String, font_file_url: String,
data: Vec<u8>, data: Vec<u8>,
is_default: bool, is_default: bool,
}, },
@@ -82,7 +82,7 @@ pub enum DocumentMessage {
affected_layer_path: Vec<LayerId>, affected_layer_path: Vec<LayerId>,
}, },
LoadFont { LoadFont {
font: String, font_file_url: String,
}, },
MoveSelectedLayersTo { MoveSelectedLayersTo {
folder_path: Vec<LayerId>, folder_path: Vec<LayerId>,
+36 -29
View File
@@ -523,6 +523,7 @@ impl DocumentMessageHandler {
} }
} }
// TODO: Loading the default font should happen on a per-application basis, not a per-document basis
pub fn load_default_font(&self, responses: &mut VecDeque<Message>) { pub fn load_default_font(&self, responses: &mut VecDeque<Message>) {
if !self.graphene_document.font_cache.has_default() { if !self.graphene_document.font_cache.has_default() {
responses.push_back(FrontendMessage::TriggerFontLoadDefault.into()) responses.push_back(FrontendMessage::TriggerFontLoadDefault.into())
@@ -669,30 +670,36 @@ impl DocumentMessageHandler {
}]); }]);
let document_mode_layout = WidgetLayout::new(vec![LayoutRow::Row { let document_mode_layout = WidgetLayout::new(vec![LayoutRow::Row {
widgets: vec![WidgetHolder::new(Widget::DropdownInput(DropdownInput { widgets: vec![
entries: vec![vec![ WidgetHolder::new(Widget::DropdownInput(DropdownInput {
DropdownEntryData { entries: vec![vec![
label: DocumentMode::DesignMode.to_string(), DropdownEntryData {
icon: DocumentMode::DesignMode.icon_name(), label: DocumentMode::DesignMode.to_string(),
..DropdownEntryData::default() icon: DocumentMode::DesignMode.icon_name(),
}, ..DropdownEntryData::default()
DropdownEntryData { },
label: DocumentMode::SelectMode.to_string(), DropdownEntryData {
icon: DocumentMode::SelectMode.icon_name(), label: DocumentMode::SelectMode.to_string(),
on_update: WidgetCallback::new(|_| DialogMessage::RequestComingSoonDialog { issue: Some(330) }.into()), icon: DocumentMode::SelectMode.icon_name(),
..DropdownEntryData::default() on_update: WidgetCallback::new(|_| DialogMessage::RequestComingSoonDialog { issue: Some(330) }.into()),
}, ..DropdownEntryData::default()
DropdownEntryData { },
label: DocumentMode::GuideMode.to_string(), DropdownEntryData {
icon: DocumentMode::GuideMode.icon_name(), label: DocumentMode::GuideMode.to_string(),
on_update: WidgetCallback::new(|_| DialogMessage::RequestComingSoonDialog { issue: Some(331) }.into()), icon: DocumentMode::GuideMode.icon_name(),
..DropdownEntryData::default() on_update: WidgetCallback::new(|_| DialogMessage::RequestComingSoonDialog { issue: Some(331) }.into()),
}, ..DropdownEntryData::default()
]], },
selected_index: Some(self.document_mode as u32), ]],
draw_icon: true, selected_index: Some(self.document_mode as u32),
..Default::default() draw_icon: true,
}))], ..Default::default()
})),
WidgetHolder::new(Widget::Separator(Separator {
separator_type: SeparatorType::Section,
direction: SeparatorDirection::Horizontal,
})),
],
}]); }]);
responses.push_back( responses.push_back(
@@ -1107,8 +1114,8 @@ impl MessageHandler<DocumentMessage, &InputPreprocessorMessageHandler> for Docum
let affected_layer_path = affected_folder_path; let affected_layer_path = affected_folder_path;
responses.extend([LayerChanged { affected_layer_path }.into(), DocumentStructureChanged.into()]); responses.extend([LayerChanged { affected_layer_path }.into(), DocumentStructureChanged.into()]);
} }
FontLoaded { font, data, is_default } => { FontLoaded { font_file_url, data, is_default } => {
self.graphene_document.font_cache.insert(font, data, is_default); self.graphene_document.font_cache.insert(font_file_url, data, is_default);
responses.push_back(DocumentMessage::DirtyRenderDocument.into()); responses.push_back(DocumentMessage::DirtyRenderDocument.into());
} }
GroupSelectedLayers => { GroupSelectedLayers => {
@@ -1147,9 +1154,9 @@ impl MessageHandler<DocumentMessage, &InputPreprocessorMessageHandler> for Docum
responses.push_back(PropertiesPanelMessage::CheckSelectedWasUpdated { path: affected_layer_path }.into()); responses.push_back(PropertiesPanelMessage::CheckSelectedWasUpdated { path: affected_layer_path }.into());
self.update_layer_tree_options_bar_widgets(responses); self.update_layer_tree_options_bar_widgets(responses);
} }
LoadFont { font } => { LoadFont { font_file_url } => {
if !self.graphene_document.font_cache.loaded_font(&font) { if !self.graphene_document.font_cache.loaded_font(&font_file_url) {
responses.push_front(FrontendMessage::TriggerFontLoad { font }.into()); responses.push_front(FrontendMessage::TriggerFontLoad { font_file_url }.into());
} }
} }
MoveSelectedLayersTo { MoveSelectedLayersTo {
@@ -79,6 +79,7 @@ impl PortfolioMessageHandler {
new_document.update_layer_tree_options_bar_widgets(responses); new_document.update_layer_tree_options_bar_widgets(responses);
new_document.load_image_data(responses, &new_document.graphene_document.root.data, Vec::new()); new_document.load_image_data(responses, &new_document.graphene_document.root.data, Vec::new());
// TODO: Loading the default font should happen on a per-application basis, not a per-document basis
new_document.load_default_font(responses); new_document.load_default_font(responses);
self.documents.insert(document_id, new_document); self.documents.insert(document_id, new_document);
@@ -714,12 +714,12 @@ fn node_section_font(layer: &TextLayer) -> LayoutRow {
is_style_picker: false, is_style_picker: false,
font_family: layer.font_family.clone(), font_family: layer.font_family.clone(),
font_style: layer.font_style.clone(), font_style: layer.font_style.clone(),
font_file: String::new(), font_file_url: String::new(),
on_update: WidgetCallback::new(move |font_input: &FontInput| { on_update: WidgetCallback::new(move |font_input: &FontInput| {
PropertiesPanelMessage::ModifyFont { PropertiesPanelMessage::ModifyFont {
font_family: font_input.font_family.clone(), font_family: font_input.font_family.clone(),
font_style: font_input.font_style.clone(), font_style: font_input.font_style.clone(),
font_file: Some(font_input.font_file.clone()), font_file: Some(font_input.font_file_url.clone()),
size, size,
} }
.into() .into()
@@ -741,12 +741,12 @@ fn node_section_font(layer: &TextLayer) -> LayoutRow {
is_style_picker: true, is_style_picker: true,
font_family: layer.font_family.clone(), font_family: layer.font_family.clone(),
font_style: layer.font_style.clone(), font_style: layer.font_style.clone(),
font_file: String::new(), font_file_url: String::new(),
on_update: WidgetCallback::new(move |font_input: &FontInput| { on_update: WidgetCallback::new(move |font_input: &FontInput| {
PropertiesPanelMessage::ModifyFont { PropertiesPanelMessage::ModifyFont {
font_family: font_input.font_family.clone(), font_family: font_input.font_family.clone(),
font_style: font_input.font_style.clone(), font_style: font_input.font_style.clone(),
font_file: Some(font_input.font_file.clone()), font_file: Some(font_input.font_file_url.clone()),
size, size,
} }
.into() .into()
+1 -1
View File
@@ -22,7 +22,7 @@ pub enum FrontendMessage {
// Trigger prefix: cause a browser API to do something // Trigger prefix: cause a browser API to do something
TriggerFileDownload { document: String, name: String }, TriggerFileDownload { document: String, name: String },
TriggerFileUpload, TriggerFileUpload,
TriggerFontLoad { font: String }, TriggerFontLoad { font_file_url: String },
TriggerFontLoadDefault, TriggerFontLoadDefault,
TriggerIndexedDbRemoveDocument { document_id: u64 }, TriggerIndexedDbRemoveDocument { document_id: u64 },
TriggerIndexedDbWriteDocument { document: String, details: FrontendDocumentDetails, version: String }, TriggerIndexedDbWriteDocument { document: String, details: FrontendDocumentDetails, version: String },
+4 -4
View File
@@ -95,17 +95,17 @@ impl MessageHandler<LayoutMessage, ()> for LayoutMessageHandler {
let update_value = value.as_object().expect("FontInput update was not of type: object"); let update_value = value.as_object().expect("FontInput update was not of type: object");
let font_family_value = update_value.get("fontFamily").expect("FontInput update does not have a fontFamily"); let font_family_value = update_value.get("fontFamily").expect("FontInput update does not have a fontFamily");
let font_style_value = update_value.get("fontStyle").expect("FontInput update does not have a fontStyle"); let font_style_value = update_value.get("fontStyle").expect("FontInput update does not have a fontStyle");
let font_file_value = update_value.get("fontFile").expect("FontInput update does not have a fontFile"); let font_file_url_value = update_value.get("fontFileUrl").expect("FontInput update does not have a fontFileUrl");
let font_family = font_family_value.as_str().expect("FontInput update fontFamily was not of type: string"); let font_family = font_family_value.as_str().expect("FontInput update fontFamily was not of type: string");
let font_style = font_style_value.as_str().expect("FontInput update fontStyle was not of type: string"); let font_style = font_style_value.as_str().expect("FontInput update fontStyle was not of type: string");
let font_file = font_file_value.as_str().expect("FontInput update fontFile was not of type: string"); let font_file_url = font_file_url_value.as_str().expect("FontInput update fontFileUrl was not of type: string");
font_input.font_family = font_family.into(); font_input.font_family = font_family.into();
font_input.font_style = font_style.into(); font_input.font_style = font_style.into();
font_input.font_file = font_file.into(); font_input.font_file_url = font_file_url.into();
responses.push_back(DocumentMessage::LoadFont { font: font_file.into() }.into()); responses.push_back(DocumentMessage::LoadFont { font_file_url: font_file_url.into() }.into());
let callback_message = (font_input.on_update.callback)(font_input); let callback_message = (font_input.on_update.callback)(font_input);
responses.push_back(callback_message); responses.push_back(callback_message);
} }
+3 -2
View File
@@ -49,6 +49,7 @@ impl WidgetLayout {
pub type SubLayout = Vec<LayoutRow>; pub type SubLayout = Vec<LayoutRow>;
// TODO: Rename LayoutRow to something more generic
#[remain::sorted] #[remain::sorted]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum LayoutRow { pub enum LayoutRow {
@@ -254,8 +255,8 @@ pub struct FontInput {
pub font_family: String, pub font_family: String,
#[serde(rename = "fontStyle")] #[serde(rename = "fontStyle")]
pub font_style: String, pub font_style: String,
#[serde(rename = "fontFile")] #[serde(rename = "fontFileUrl")]
pub font_file: String, pub font_file_url: String,
#[serde(skip)] #[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")] #[derivative(Debug = "ignore", PartialEq = "ignore")]
pub on_update: WidgetCallback<FontInput>, pub on_update: WidgetCallback<FontInput>,
+2 -2
View File
@@ -84,7 +84,7 @@ impl PropertyHolder for TextTool {
TextMessage::UpdateOptions(TextOptionsUpdate::Font { TextMessage::UpdateOptions(TextOptionsUpdate::Font {
family: font_input.font_family.clone(), family: font_input.font_family.clone(),
style: font_input.font_style.clone(), style: font_input.font_style.clone(),
file: font_input.font_file.clone(), file: font_input.font_file_url.clone(),
}) })
.into() .into()
}), }),
@@ -102,7 +102,7 @@ impl PropertyHolder for TextTool {
TextMessage::UpdateOptions(TextOptionsUpdate::Font { TextMessage::UpdateOptions(TextOptionsUpdate::Font {
family: font_input.font_family.clone(), family: font_input.font_family.clone(),
style: font_input.font_style.clone(), style: font_input.font_style.clone(),
file: font_input.font_file.clone(), file: font_input.font_file_url.clone(),
}) })
.into() .into()
}), }),
+65 -47
View File
@@ -1,7 +1,7 @@
<template> <template>
<MainWindow /> <MainWindow />
<div class="unsupported-modal-backdrop" v-if="showUnsupportedModal"> <div class="unsupported-modal-backdrop" v-if="apiUnsupported" ref="unsupported">
<LayoutCol class="unsupported-modal"> <LayoutCol class="unsupported-modal">
<h2>Your browser currently doesn't support Graphite</h2> <h2>Your browser currently doesn't support Graphite</h2>
<p>Unfortunately, some features won't work properly. Please upgrade to a modern browser such as Firefox, Chrome, Edge, or Safari version 15 or later.</p> <p>Unfortunately, some features won't work properly. Please upgrade to a modern browser such as Firefox, Chrome, Edge, or Safari version 15 or later.</p>
@@ -11,7 +11,7 @@
API which is required for using the editor. However, you can still explore the user interface. API which is required for using the editor. However, you can still explore the user interface.
</p> </p>
<LayoutRow> <LayoutRow>
<button class="unsupported-modal-button" @click="() => closeModal()">I understand, let's just see the interface</button> <button class="unsupported-modal-button" @click="() => closeUnsupportedWarning()">I understand, let's just see the interface</button>
</LayoutRow> </LayoutRow>
</LayoutCol> </LayoutCol>
</div> </div>
@@ -258,78 +258,96 @@ img {
<script lang="ts"> <script lang="ts">
import { defineComponent } from "vue"; import { defineComponent } from "vue";
import { createAutoSaveManager } from "@/lifetime/auto-save"; import { createBuildMetadataManager } from "@/io-managers/build-metadata";
import { initErrorHandling } from "@/lifetime/errors"; import { createClipboardManager } from "@/io-managers/clipboard";
import { createInputManager, InputManager } from "@/lifetime/input"; import { createHyperlinkManager } from "@/io-managers/hyperlinks";
import { createDialogState, DialogState } from "@/state/dialog"; import { createInputManager } from "@/io-managers/input";
import { createFullscreenState, FullscreenState } from "@/state/fullscreen"; import { createPanicManager } from "@/io-managers/panic";
import { createPortfolioState, PortfolioState } from "@/state/portfolio"; import { createPersistenceManager } from "@/io-managers/persistence";
import { createEditorState, EditorState } from "@/state/wasm-loader"; import { createDialogState, DialogState } from "@/state-providers/dialog";
import { createWorkspaceState, WorkspaceState } from "@/state/workspace"; import { createFontsState, FontsState } from "@/state-providers/fonts";
import { createFullscreenState, FullscreenState } from "@/state-providers/fullscreen";
import { createPortfolioState, PortfolioState } from "@/state-providers/portfolio";
import { createWorkspaceState, WorkspaceState } from "@/state-providers/workspace";
import { createEditor, Editor } from "@/wasm-communication/editor";
import LayoutCol from "@/components/layout/LayoutCol.vue"; import LayoutCol from "@/components/layout/LayoutCol.vue";
import LayoutRow from "@/components/layout/LayoutRow.vue"; import LayoutRow from "@/components/layout/LayoutRow.vue";
import MainWindow from "@/components/window/MainWindow.vue"; import MainWindow from "@/components/window/MainWindow.vue";
// Vue injects don't play well with TypeScript, and all injects will show up as `any`. As a workaround, we can define these types. const managerDestructors: {
createBuildMetadataManager?: () => void;
createClipboardManager?: () => void;
createHyperlinkManager?: () => void;
createInputManager?: () => void;
createPanicManager?: () => void;
createPersistenceManager?: () => void;
} = {};
// Vue injects don't play well with TypeScript (all injects will show up as `any`) but we can define these types as a solution
declare module "@vue/runtime-core" { declare module "@vue/runtime-core" {
// Systems `provide`d by the root App to be `inject`ed into descendant components and used for reactive bindings
interface ComponentCustomProperties { interface ComponentCustomProperties {
// Graphite WASM editor instance
editor: Editor;
// State provider systems
dialog: DialogState; dialog: DialogState;
fonts: FontsState;
fullscreen: FullscreenState;
portfolio: PortfolioState; portfolio: PortfolioState;
workspace: WorkspaceState; workspace: WorkspaceState;
fullscreen: FullscreenState;
editor: EditorState;
// This must be set to optional because there is a time in the lifecycle of the component where inputManager is undefined.
// That's because we initialize inputManager in `mounted()` rather than `data()` since the div hasn't been created yet.
inputManager?: InputManager;
} }
} }
export default defineComponent({ export default defineComponent({
provide() { provide() {
return { return { ...this.$data };
editor: this.editor,
dialog: this.dialog,
portfolio: this.portfolio,
workspace: this.workspace,
fullscreen: this.fullscreen,
inputManager: this.inputManager,
};
}, },
data() { data() {
// Initialize the Graphite WASM editor instance const editor = createEditor();
const editor = createEditorState();
// Initialize other stateful Vue systems
const dialog = createDialogState(editor);
const portfolio = createPortfolioState(editor);
const workspace = createWorkspaceState(editor);
const fullscreen = createFullscreenState();
initErrorHandling(editor, dialog);
createAutoSaveManager(editor, portfolio);
return { return {
// Graphite WASM editor instance
editor, editor,
dialog,
portfolio, // State provider systems
workspace, dialog: createDialogState(editor),
fullscreen, fonts: createFontsState(editor),
showUnsupportedModal: !("BigInt64Array" in window), fullscreen: createFullscreenState(),
inputManager: undefined as undefined | InputManager, portfolio: createPortfolioState(editor),
workspace: createWorkspaceState(editor),
}; };
}, },
methods: { computed: {
closeModal() { apiUnsupported() {
this.showUnsupportedModal = false; return !("BigInt64Array" in window);
}, },
}, },
mounted() { methods: {
this.inputManager = createInputManager(this.editor, this.$el.parentElement, this.dialog, this.portfolio, this.fullscreen); closeUnsupportedWarning() {
const element = this.$refs.unsupported as HTMLElement;
element.parentElement?.removeChild(element);
},
},
async mounted() {
// Initialize managers, which are isolated systems that subscribe to backend messages to link them to browser API functionality (like JS events, IndexedDB, etc.)
Object.assign(managerDestructors, {
createBuildMetadataManager: createBuildMetadataManager(this.editor),
createClipboardManager: createClipboardManager(this.editor),
createHyperlinkManager: createHyperlinkManager(this.editor),
createInputManager: createInputManager(this.editor, this.$el.parentElement, this.dialog, this.portfolio, this.fullscreen),
createPanicManager: createPanicManager(this.editor, this.dialog),
createPersistenceManager: await createPersistenceManager(this.editor, this.portfolio),
});
// Initialize certain setup tasks required by the editor backend to be ready for the user now that the frontend is ready
this.editor.instance.init_app(); this.editor.instance.init_app();
}, },
beforeUnmount() { beforeUnmount() {
this.inputManager?.removeListeners(); // Call the destructor for each manager
Object.values(managerDestructors).forEach((destructor) => destructor?.());
// Destroy the WASM editor instance
this.editor.instance.free(); this.editor.instance.free();
}, },
components: { components: {
+53
View File
@@ -0,0 +1,53 @@
# Overview of `/frontend/src/`
## Vue components: `components/`
Vue components that build the Graphite editor GUI, which are mounted in `App.vue`. These are Vue SFCs (single-file components) which each contain a Vue-templated HTML section, an SCSS (Stylus CSS) section, and a script section. The aim is to avoid implementing much editor business logic here, just enough to make things interactive and communicate to the backend where the real business logic should occur.
## 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/api.rs`).
Each I/O manager is a self-contained module where one instance is created in `App.vue` when it's mounted to the DOM at app startup.
During development when HMR (hot-module replacement) occurs, these are also unmounted to clean up after themselves, so they can be mounted again with the updated code. Therefore, any side-effects that these managers cause (e.g. adding event listeners to the page) need a destructor function that cleans them up. The destructor function, when applicable, is returned by the module and automatically called in `App.vue` on unmount.
## 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.vue`, an instance of each of these are given to Vue's [`provide()`](https://vuejs.org/api/application.html#app-provide) function. This allows any component to access the state provider instance by specifying it in its `inject: [...]` array. The state is accessed in a component with `this.stateProviderName.state.someReactiveVariable` and any exposed functions are accessed with `this.stateProviderName.state.someExposedVariable()`. They can also be used in the Vue HTML template (sans the `this.` prefix).
## *I/O managers vs. state providers*
*Some state providers, similarly to I/O managers, may subscribe to backend events, call functions from `api.rs` into the backend, and interact with browser APIs and user input. The difference is that state providers are meant to `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 module provided by wasm-bindgen/wasm-pack. It is stored in a local variable and can be retrieved with the `getWasmInstance()` function. The function `createEditor()` constructs an instance of the editor backend. In theory there could be multiple editor instances sharing the same WASM module instance. The function returns an object where `raw` is the WASM module, `instance` is the editor, and `subscriptions` is the subscription router (described below).
`initWasm()` occurs in `main.ts` right before the Vue application exists, then `createEditor()` is run in `App.vue` during the Vue app's creation. Similarly to the state providers described above, the editor is `provide`d so other components can `inject` it and call functions on `this.editor.raw`, `this.editor.instance`, or `this.editor.subscriptions`.
### 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.vue`
The entry point for the Vue application. This is where we define global CSS style rules, construct the editor,construct/destruct the editor and I/O managers, and construct/provide state providers.
## Entry point: `main.ts`
The entry point for the entire project. Here we simply initialize the WASM module with `await initWasm();` then initialize the Vue application with `createApp(App).mount("#app");`.
+33
View File
@@ -0,0 +1,33 @@
# Vue components
Each component is a layout or widget in the GUI.
This document is 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" />
```
+23 -70
View File
@@ -2,7 +2,6 @@
<LayoutCol class="document"> <LayoutCol class="document">
<LayoutRow class="options-bar" :scrollableX="true"> <LayoutRow class="options-bar" :scrollableX="true">
<WidgetLayout :layout="documentModeLayout" /> <WidgetLayout :layout="documentModeLayout" />
<Separator :type="'Section'" />
<WidgetLayout :layout="toolOptionsLayout" /> <WidgetLayout :layout="toolOptionsLayout" />
<LayoutRow class="spacer"></LayoutRow> <LayoutRow class="spacer"></LayoutRow>
@@ -37,12 +36,12 @@
<LayoutCol class="canvas-area"> <LayoutCol class="canvas-area">
<div <div
class="canvas" class="canvas"
data-canvas
ref="canvas"
:style="{ cursor: canvasCursor }" :style="{ cursor: canvasCursor }"
@pointerdown="(e: PointerEvent) => canvasPointerDown(e)" @pointerdown="(e: PointerEvent) => canvasPointerDown(e)"
@dragover="(e) => e.preventDefault()" @dragover="(e) => e.preventDefault()"
@drop="(e) => pasteFile(e)" @drop="(e) => pasteFile(e)"
ref="canvas"
data-canvas
> >
<svg class="artboards" v-html="artboardSvg" :style="{ width: canvasSvgWidth, height: canvasSvgHeight }"></svg> <svg class="artboards" v-html="artboardSvg" :style="{ width: canvasSvgWidth, height: canvasSvgHeight }"></svg>
<svg <svg
@@ -221,6 +220,7 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, nextTick } from "vue"; import { defineComponent, nextTick } from "vue";
import { textInputCleanup } from "@/utility-functions/keyboard-entry";
import { import {
UpdateDocumentArtwork, UpdateDocumentArtwork,
UpdateDocumentOverlays, UpdateDocumentOverlays,
@@ -235,18 +235,10 @@ import {
UpdateDocumentBarLayout, UpdateDocumentBarLayout,
UpdateImageData, UpdateImageData,
TriggerTextCommit, TriggerTextCommit,
TriggerTextCopy,
TriggerViewportResize, TriggerViewportResize,
DisplayRemoveEditableTextbox, DisplayRemoveEditableTextbox,
DisplayEditableTextbox, DisplayEditableTextbox,
TriggerFontLoad, } from "@/wasm-communication/messages";
TriggerFontLoadDefault,
TriggerVisitLink,
} from "@/dispatcher/js-messages";
import { textInputCleanup } from "@/lifetime/input";
import { loadDefaultFont, setLoadDefaultFontCallback } from "@/utilities/fonts";
import LayoutCol from "@/components/layout/LayoutCol.vue"; import LayoutCol from "@/components/layout/LayoutCol.vue";
import LayoutRow from "@/components/layout/LayoutRow.vue"; import LayoutRow from "@/components/layout/LayoutRow.vue";
@@ -254,11 +246,10 @@ import IconButton from "@/components/widgets/buttons/IconButton.vue";
import SwatchPairInput from "@/components/widgets/inputs/SwatchPairInput.vue"; import SwatchPairInput from "@/components/widgets/inputs/SwatchPairInput.vue";
import CanvasRuler from "@/components/widgets/rulers/CanvasRuler.vue"; import CanvasRuler from "@/components/widgets/rulers/CanvasRuler.vue";
import PersistentScrollbar from "@/components/widgets/scrollbars/PersistentScrollbar.vue"; import PersistentScrollbar from "@/components/widgets/scrollbars/PersistentScrollbar.vue";
import Separator from "@/components/widgets/separators/Separator.vue";
import WidgetLayout from "@/components/widgets/WidgetLayout.vue"; import WidgetLayout from "@/components/widgets/WidgetLayout.vue";
export default defineComponent({ export default defineComponent({
inject: ["editor", "dialog"], inject: ["editor"],
methods: { methods: {
viewportResize() { viewportResize() {
// Resize the canvas // Resize the canvas
@@ -275,11 +266,10 @@ export default defineComponent({
this.canvasSvgHeight = `${height}px`; this.canvasSvgHeight = `${height}px`;
// Resize the rulers // Resize the rulers
const rulerHorizontal = this.$refs.rulerHorizontal as typeof CanvasRuler; const rulerHorizontal = this.$refs.rulerHorizontal as typeof CanvasRuler;
const rulerVertical = this.$refs.rulerVertical as typeof CanvasRuler; const rulerVertical = this.$refs.rulerVertical as typeof CanvasRuler;
rulerHorizontal?.handleResize(); rulerHorizontal?.resize();
rulerVertical?.handleResize(); rulerVertical?.resize();
}, },
pasteFile(e: DragEvent) { pasteFile(e: DragEvent) {
const { dataTransfer } = e; const { dataTransfer } = e;
@@ -329,7 +319,7 @@ export default defineComponent({
}, },
}, },
mounted() { mounted() {
this.editor.dispatcher.subscribeJsMessage(UpdateDocumentArtwork, (UpdateDocumentArtwork) => { this.editor.subscriptions.subscribeJsMessage(UpdateDocumentArtwork, (UpdateDocumentArtwork) => {
this.artworkSvg = UpdateDocumentArtwork.svg; this.artworkSvg = UpdateDocumentArtwork.svg;
nextTick((): void => { nextTick((): void => {
@@ -365,50 +355,38 @@ export default defineComponent({
}); });
}); });
this.editor.dispatcher.subscribeJsMessage(UpdateDocumentOverlays, (updateDocumentOverlays) => { this.editor.subscriptions.subscribeJsMessage(UpdateDocumentOverlays, (updateDocumentOverlays) => {
this.overlaysSvg = updateDocumentOverlays.svg; this.overlaysSvg = updateDocumentOverlays.svg;
}); });
this.editor.dispatcher.subscribeJsMessage(UpdateDocumentArtboards, (updateDocumentArtboards) => { this.editor.subscriptions.subscribeJsMessage(UpdateDocumentArtboards, (updateDocumentArtboards) => {
this.artboardSvg = updateDocumentArtboards.svg; this.artboardSvg = updateDocumentArtboards.svg;
}); });
this.editor.dispatcher.subscribeJsMessage(UpdateDocumentScrollbars, (updateDocumentScrollbars) => { this.editor.subscriptions.subscribeJsMessage(UpdateDocumentScrollbars, (updateDocumentScrollbars) => {
this.scrollbarPos = updateDocumentScrollbars.position; this.scrollbarPos = updateDocumentScrollbars.position;
this.scrollbarSize = updateDocumentScrollbars.size; this.scrollbarSize = updateDocumentScrollbars.size;
this.scrollbarMultiplier = updateDocumentScrollbars.multiplier; this.scrollbarMultiplier = updateDocumentScrollbars.multiplier;
}); });
this.editor.dispatcher.subscribeJsMessage(UpdateDocumentRulers, (updateDocumentRulers) => { this.editor.subscriptions.subscribeJsMessage(UpdateDocumentRulers, (updateDocumentRulers) => {
this.rulerOrigin = updateDocumentRulers.origin; this.rulerOrigin = updateDocumentRulers.origin;
this.rulerSpacing = updateDocumentRulers.spacing; this.rulerSpacing = updateDocumentRulers.spacing;
this.rulerInterval = updateDocumentRulers.interval; this.rulerInterval = updateDocumentRulers.interval;
}); });
this.editor.dispatcher.subscribeJsMessage(UpdateMouseCursor, (updateMouseCursor) => { this.editor.subscriptions.subscribeJsMessage(UpdateMouseCursor, (updateMouseCursor) => {
this.canvasCursor = updateMouseCursor.cursor; this.canvasCursor = updateMouseCursor.cursor;
}); });
this.editor.dispatcher.subscribeJsMessage(TriggerTextCommit, () => {
this.editor.subscriptions.subscribeJsMessage(TriggerTextCommit, () => {
if (this.textInput) { if (this.textInput) {
const textCleaned = textInputCleanup(this.textInput.innerText); const textCleaned = textInputCleanup(this.textInput.innerText);
this.editor.instance.on_change_text(textCleaned); this.editor.instance.on_change_text(textCleaned);
} }
}); });
this.editor.dispatcher.subscribeJsMessage(TriggerFontLoad, async (triggerFontLoad) => {
const response = await fetch(triggerFontLoad.font);
const responseBuffer = await response.arrayBuffer();
this.editor.instance.on_font_load(triggerFontLoad.font, new Uint8Array(responseBuffer), false);
});
this.editor.dispatcher.subscribeJsMessage(TriggerFontLoadDefault, loadDefaultFont);
this.editor.dispatcher.subscribeJsMessage(TriggerVisitLink, async (triggerOpenLink) => {
window.open(triggerOpenLink.url, "_blank");
});
this.editor.dispatcher.subscribeJsMessage(TriggerTextCopy, (triggerTextCopy) => {
// If the Clipboard API is supported in the browser, copy text to the clipboard
navigator.clipboard?.writeText?.(triggerTextCopy.copy_text);
});
this.editor.dispatcher.subscribeJsMessage(DisplayEditableTextbox, (displayEditableTextbox) => { this.editor.subscriptions.subscribeJsMessage(DisplayEditableTextbox, (displayEditableTextbox) => {
this.textInput = document.createElement("DIV") as HTMLDivElement; this.textInput = document.createElement("DIV") as HTMLDivElement;
if (displayEditableTextbox.text === "") this.textInput.textContent = ""; if (displayEditableTextbox.text === "") this.textInput.textContent = "";
@@ -425,7 +403,7 @@ export default defineComponent({
}; };
}); });
this.editor.dispatcher.subscribeJsMessage(DisplayRemoveEditableTextbox, () => { this.editor.subscriptions.subscribeJsMessage(DisplayRemoveEditableTextbox, () => {
this.textInput = undefined; this.textInput = undefined;
window.dispatchEvent( window.dispatchEvent(
new CustomEvent("modifyinputfield", { new CustomEvent("modifyinputfield", {
@@ -434,25 +412,25 @@ export default defineComponent({
); );
}); });
this.editor.dispatcher.subscribeJsMessage(UpdateDocumentModeLayout, (updateDocumentModeLayout) => { this.editor.subscriptions.subscribeJsMessage(UpdateDocumentModeLayout, (updateDocumentModeLayout) => {
this.documentModeLayout = updateDocumentModeLayout; this.documentModeLayout = updateDocumentModeLayout;
}); });
this.editor.dispatcher.subscribeJsMessage(UpdateToolOptionsLayout, (updateToolOptionsLayout) => { this.editor.subscriptions.subscribeJsMessage(UpdateToolOptionsLayout, (updateToolOptionsLayout) => {
this.toolOptionsLayout = updateToolOptionsLayout; this.toolOptionsLayout = updateToolOptionsLayout;
}); });
this.editor.dispatcher.subscribeJsMessage(UpdateDocumentBarLayout, (updateDocumentBarLayout) => { this.editor.subscriptions.subscribeJsMessage(UpdateDocumentBarLayout, (updateDocumentBarLayout) => {
this.documentBarLayout = updateDocumentBarLayout; this.documentBarLayout = updateDocumentBarLayout;
}); });
this.editor.dispatcher.subscribeJsMessage(UpdateToolShelfLayout, (updateToolShelfLayout) => { this.editor.subscriptions.subscribeJsMessage(UpdateToolShelfLayout, (updateToolShelfLayout) => {
this.toolShelfLayout = updateToolShelfLayout; this.toolShelfLayout = updateToolShelfLayout;
}); });
this.editor.dispatcher.subscribeJsMessage(TriggerViewportResize, this.viewportResize); this.editor.subscriptions.subscribeJsMessage(TriggerViewportResize, this.viewportResize);
this.editor.dispatcher.subscribeJsMessage(UpdateImageData, (updateImageData) => { this.editor.subscriptions.subscribeJsMessage(UpdateImageData, (updateImageData) => {
updateImageData.image_data.forEach(async (element) => { updateImageData.image_data.forEach(async (element) => {
// Using updateImageData.image_data.buffer returns undefined for some reason? // Using updateImageData.image_data.buffer returns undefined for some reason?
const blob = new Blob([new Uint8Array(element.image_data.values()).buffer], { type: element.mime }); const blob = new Blob([new Uint8Array(element.image_data.values()).buffer], { type: element.mime });
@@ -464,30 +442,6 @@ export default defineComponent({
this.editor.instance.set_image_blob_url(element.path, url, image.width, image.height); this.editor.instance.set_image_blob_url(element.path, url, image.width, image.height);
}); });
}); });
// Gets metadata populated in `frontend/vue.config.js`. We could potentially move this functionality in a build.rs file.
const loadBuildMetadata = (): void => {
const release = process.env.VUE_APP_RELEASE_SERIES;
let timestamp = "";
const hash = (process.env.VUE_APP_COMMIT_HASH || "").substring(0, 8);
const branch = process.env.VUE_APP_COMMIT_BRANCH;
{
const date = new Date(process.env.VUE_APP_COMMIT_DATE || "");
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 timezoneName = Intl.DateTimeFormat(undefined, { timeZoneName: "long" })
.formatToParts(new Date())
.find((part) => part.type === "timeZoneName");
const timezoneNameString = timezoneName?.value;
timestamp = `${dateString} ${timeString} ${timezoneNameString}`;
}
this.editor.instance.populate_build_metadata(release || "", timestamp, hash, branch || "");
};
setLoadDefaultFontCallback((font: string, data: Uint8Array) => this.editor.instance.on_font_load(font, data, true));
loadBuildMetadata();
}, },
data() { data() {
return { return {
@@ -525,7 +479,6 @@ export default defineComponent({
LayoutRow, LayoutRow,
LayoutCol, LayoutCol,
SwatchPairInput, SwatchPairInput,
Separator,
PersistentScrollbar, PersistentScrollbar,
CanvasRuler, CanvasRuler,
IconButton, IconButton,
+4 -4
View File
@@ -263,7 +263,7 @@
<script lang="ts"> <script lang="ts">
import { defineComponent } from "vue"; import { defineComponent } from "vue";
import { defaultWidgetLayout, UpdateDocumentLayerTreeStructure, UpdateDocumentLayerDetails, UpdateLayerTreeOptionsLayout, LayerPanelEntry } from "@/dispatcher/js-messages"; import { defaultWidgetLayout, UpdateDocumentLayerTreeStructure, UpdateDocumentLayerDetails, UpdateLayerTreeOptionsLayout, LayerPanelEntry } from "@/wasm-communication/messages";
import LayoutCol from "@/components/layout/LayoutCol.vue"; import LayoutCol from "@/components/layout/LayoutCol.vue";
import LayoutRow from "@/components/layout/LayoutRow.vue"; import LayoutRow from "@/components/layout/LayoutRow.vue";
@@ -471,15 +471,15 @@ export default defineComponent({
}, },
}, },
mounted() { mounted() {
this.editor.dispatcher.subscribeJsMessage(UpdateDocumentLayerTreeStructure, (updateDocumentLayerTreeStructure) => { this.editor.subscriptions.subscribeJsMessage(UpdateDocumentLayerTreeStructure, (updateDocumentLayerTreeStructure) => {
this.rebuildLayerTree(updateDocumentLayerTreeStructure); this.rebuildLayerTree(updateDocumentLayerTreeStructure);
}); });
this.editor.dispatcher.subscribeJsMessage(UpdateLayerTreeOptionsLayout, (updateLayerTreeOptionsLayout) => { this.editor.subscriptions.subscribeJsMessage(UpdateLayerTreeOptionsLayout, (updateLayerTreeOptionsLayout) => {
this.layerTreeOptionsLayout = updateLayerTreeOptionsLayout; this.layerTreeOptionsLayout = updateLayerTreeOptionsLayout;
}); });
this.editor.dispatcher.subscribeJsMessage(UpdateDocumentLayerDetails, (updateDocumentLayerDetails) => { this.editor.subscriptions.subscribeJsMessage(UpdateDocumentLayerDetails, (updateDocumentLayerDetails) => {
const targetPath = updateDocumentLayerDetails.data.path; const targetPath = updateDocumentLayerDetails.data.path;
const targetLayer = updateDocumentLayerDetails.data; const targetLayer = updateDocumentLayerDetails.data;
+7 -11
View File
@@ -350,7 +350,6 @@ const GRID_COLLAPSE_SPACING = 10;
const GRID_SIZE = 24; const GRID_SIZE = 24;
export default defineComponent({ export default defineComponent({
inject: ["editor"],
data() { data() {
return { return {
transform: { scale: 1, x: 0, y: 0 }, transform: { scale: 1, x: 0, y: 0 },
@@ -467,16 +466,13 @@ export default defineComponent({
}, },
}, },
mounted() { mounted() {
{ const outputPort1 = document.querySelectorAll(".output.port")[4] as HTMLElement;
const outputPort = document.querySelectorAll(".output.port")[4] as HTMLElement; const inputPort1 = document.querySelectorAll(".input.port")[1] as HTMLElement;
const inputPort = document.querySelectorAll(".input.port")[1] as HTMLElement; this.createWirePath(outputPort1, inputPort1, true, true);
this.createWirePath(outputPort, inputPort, true, true);
} const outputPort2 = document.querySelectorAll(".output.port")[6] as HTMLElement;
{ const inputPort2 = document.querySelectorAll(".input.port")[3] as HTMLElement;
const outputPort = document.querySelectorAll(".output.port")[6] as HTMLElement; this.createWirePath(outputPort2, inputPort2, true, false);
const inputPort = document.querySelectorAll(".input.port")[3] as HTMLElement;
this.createWirePath(outputPort, inputPort, true, false);
}
}, },
components: { components: {
LayoutRow, LayoutRow,
@@ -42,7 +42,7 @@
<script lang="ts"> <script lang="ts">
import { defineComponent } from "vue"; import { defineComponent } from "vue";
import { defaultWidgetLayout, UpdatePropertyPanelOptionsLayout, UpdatePropertyPanelSectionsLayout } from "@/dispatcher/js-messages"; import { defaultWidgetLayout, UpdatePropertyPanelOptionsLayout, UpdatePropertyPanelSectionsLayout } from "@/wasm-communication/messages";
import LayoutCol from "@/components/layout/LayoutCol.vue"; import LayoutCol from "@/components/layout/LayoutCol.vue";
import LayoutRow from "@/components/layout/LayoutRow.vue"; import LayoutRow from "@/components/layout/LayoutRow.vue";
@@ -58,10 +58,11 @@ export default defineComponent({
}; };
}, },
mounted() { mounted() {
this.editor.dispatcher.subscribeJsMessage(UpdatePropertyPanelOptionsLayout, (updatePropertyPanelOptionsLayout) => { this.editor.subscriptions.subscribeJsMessage(UpdatePropertyPanelOptionsLayout, (updatePropertyPanelOptionsLayout) => {
this.propertiesOptionsLayout = updatePropertyPanelOptionsLayout; this.propertiesOptionsLayout = updatePropertyPanelOptionsLayout;
}); });
this.editor.dispatcher.subscribeJsMessage(UpdatePropertyPanelSectionsLayout, (updatePropertyPanelSectionsLayout) => {
this.editor.subscriptions.subscribeJsMessage(UpdatePropertyPanelSectionsLayout, (updatePropertyPanelSectionsLayout) => {
this.propertiesSectionsLayout = updatePropertyPanelSectionsLayout; this.propertiesSectionsLayout = updatePropertyPanelSectionsLayout;
}); });
}, },
@@ -16,7 +16,7 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, PropType } from "vue"; import { defineComponent, PropType } from "vue";
import { isWidgetColumn, isWidgetRow, isWidgetSection, LayoutRow, WidgetLayout } from "@/dispatcher/js-messages"; import { isWidgetColumn, isWidgetRow, isWidgetSection, LayoutRow, WidgetLayout } from "@/wasm-communication/messages";
import WidgetRow from "@/components/widgets/WidgetRow.vue"; import WidgetRow from "@/components/widgets/WidgetRow.vue";
import WidgetSection from "@/components/widgets/WidgetSection.vue"; import WidgetSection from "@/components/widgets/WidgetSection.vue";
@@ -3,11 +3,12 @@
<template v-for="(component, index) in widgets" :key="index"> <template v-for="(component, index) in widgets" :key="index">
<!-- TODO: Use `<component :is="" v-bind="attributesObject"></component>` to avoid all the separate components with `v-if` --> <!-- TODO: Use `<component :is="" v-bind="attributesObject"></component>` to avoid all the separate components with `v-if` -->
<CheckboxInput v-if="component.kind === 'CheckboxInput'" v-bind="component.props" @update:checked="(value: boolean) => updateLayout(component.widget_id, value)" /> <CheckboxInput v-if="component.kind === 'CheckboxInput'" v-bind="component.props" @update:checked="(value: boolean) => updateLayout(component.widget_id, value)" />
<ColorInput v-if="component.kind === 'ColorInput'" v-bind="component.props" @update:value="(value: string) => updateLayout(component.widget_id, value)" /> <ColorInput v-if="component.kind === 'ColorInput'" v-bind="component.props" v-model:open="open" @update:value="(value: string) => updateLayout(component.widget_id, value)" />
<DropdownInput v-if="component.kind === 'DropdownInput'" v-bind="component.props" @update:selectedIndex="(value: number) => updateLayout(component.widget_id, value)" /> <DropdownInput v-if="component.kind === 'DropdownInput'" v-bind="component.props" v-model:open="open" @update:selectedIndex="(value: number) => updateLayout(component.widget_id, value)" />
<FontInput <FontInput
v-if="component.kind === 'FontInput'" v-if="component.kind === 'FontInput'"
v-bind="component.props" v-bind="component.props"
v-model:open="open"
@changeFont="(value: { name: string, style: string, file: string }) => updateLayout(component.widget_id, value)" @changeFont="(value: { name: string, style: string, file: string }) => updateLayout(component.widget_id, value)"
/> />
<IconButton v-if="component.kind === 'IconButton'" v-bind="component.props" :action="() => updateLayout(component.widget_id, null)" /> <IconButton v-if="component.kind === 'IconButton'" v-bind="component.props" :action="() => updateLayout(component.widget_id, null)" />
@@ -69,7 +70,7 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, PropType } from "vue"; import { defineComponent, PropType } from "vue";
import { WidgetColumn, WidgetRow, isWidgetColumn, isWidgetRow } from "@/dispatcher/js-messages"; import { WidgetColumn, WidgetRow, isWidgetColumn, isWidgetRow } from "@/wasm-communication/messages";
import IconButton from "@/components/widgets/buttons/IconButton.vue"; import IconButton from "@/components/widgets/buttons/IconButton.vue";
import PopoverButton from "@/components/widgets/buttons/PopoverButton.vue"; import PopoverButton from "@/components/widgets/buttons/PopoverButton.vue";
@@ -93,6 +94,11 @@ export default defineComponent({
widgetData: { type: Object as PropType<WidgetColumn | WidgetRow>, required: true }, widgetData: { type: Object as PropType<WidgetColumn | WidgetRow>, required: true },
layoutTarget: { required: true }, layoutTarget: { required: true },
}, },
data() {
return {
open: false,
};
},
computed: { computed: {
direction() { direction() {
if (isWidgetColumn(this.widgetData)) return "column"; if (isWidgetColumn(this.widgetData)) return "column";
@@ -69,7 +69,7 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, PropType } from "vue"; import { defineComponent, PropType } from "vue";
import { isWidgetRow, isWidgetSection, LayoutRow as LayoutSystemRow, WidgetSection as WidgetSectionFromJsMessages } from "@/dispatcher/js-messages"; import { isWidgetRow, isWidgetSection, LayoutRow as LayoutSystemRow, WidgetSection as WidgetSectionFromJsMessages } from "@/wasm-communication/messages";
import LayoutCol from "@/components/layout/LayoutCol.vue"; import LayoutCol from "@/components/layout/LayoutCol.vue";
import LayoutRow from "@/components/layout/LayoutRow.vue"; import LayoutRow from "@/components/layout/LayoutRow.vue";
@@ -63,7 +63,7 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, PropType } from "vue"; import { defineComponent, PropType } from "vue";
import { IconName, IconSize } from "@/utilities/icons"; import { IconName, IconSize } from "@/utility-functions/icons";
import IconLabel from "@/components/widgets/labels/IconLabel.vue"; import IconLabel from "@/components/widgets/labels/IconLabel.vue";
@@ -1,7 +1,7 @@
<template> <template>
<LayoutRow class="popover-button"> <LayoutRow class="popover-button">
<IconButton :action="handleClick" :icon="icon" :size="16" data-hover-menu-spawner /> <IconButton :action="() => onClick()" :icon="icon" :size="16" data-hover-menu-spawner />
<FloatingMenu :type="'Popover'" :direction="'Bottom'" ref="floatingMenu"> <FloatingMenu v-model:open="open" :type="'Popover'" :direction="'Bottom'">
<slot></slot> <slot></slot>
</FloatingMenu> </FloatingMenu>
</LayoutRow> </LayoutRow>
@@ -49,7 +49,7 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, PropType } from "vue"; import { defineComponent, PropType } from "vue";
import { PopoverButtonIcon } from "@/utilities/widgets"; import { IconName } from "@/utility-functions/icons";
import LayoutRow from "@/components/layout/LayoutRow.vue"; import LayoutRow from "@/components/layout/LayoutRow.vue";
import IconButton from "@/components/widgets/buttons/IconButton.vue"; import IconButton from "@/components/widgets/buttons/IconButton.vue";
@@ -63,11 +63,16 @@ export default defineComponent({
}, },
props: { props: {
action: { type: Function as PropType<() => void>, required: false }, action: { type: Function as PropType<() => void>, required: false },
icon: { type: String as PropType<PopoverButtonIcon>, default: "DropdownArrow" }, icon: { type: String as PropType<IconName>, default: "DropdownArrow" },
},
data() {
return {
open: false,
};
}, },
methods: { methods: {
handleClick() { onClick() {
(this.$refs.floatingMenu as typeof FloatingMenu).setOpen(); this.open = true;
this.action?.(); this.action?.();
}, },
@@ -0,0 +1,16 @@
// TODO: Try and get rid of the need for this file
export interface TextButtonWidget {
kind: "TextButton";
tooltip?: string;
message?: string | object;
callback?: () => void;
props: {
// `action` is used via `IconButtonWidget.callback`
label: string;
emphasized?: boolean;
disabled?: boolean;
minWidth?: number;
gapAfter?: boolean;
};
}
@@ -118,9 +118,9 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, PropType } from "vue"; import { defineComponent, PropType } from "vue";
import { RGBA } from "@/dispatcher/js-messages"; import { hsvaToRgba, rgbaToHsva } from "@/utility-functions/color";
import { hsvaToRgba, rgbaToHsva } from "@/utilities/color"; import { clamp } from "@/utility-functions/math";
import { clamp } from "@/utilities/math"; import { RGBA } from "@/wasm-communication/messages";
import LayoutCol from "@/components/layout/LayoutCol.vue"; import LayoutCol from "@/components/layout/LayoutCol.vue";
import LayoutRow from "@/components/layout/LayoutRow.vue"; import LayoutRow from "@/components/layout/LayoutRow.vue";
@@ -1,5 +1,5 @@
<template> <template>
<FloatingMenu class="dialog-modal" :type="'Dialog'" :direction="'Center'" data-dialog-modal> <FloatingMenu :open="true" class="dialog-modal" :type="'Dialog'" :direction="'Center'" data-dialog-modal>
<LayoutRow ref="main"> <LayoutRow ref="main">
<LayoutCol class="icon-column"> <LayoutCol class="icon-column">
<!-- `dialog.state.icon` class exists to provide special sizing in CSS to specific icons --> <!-- `dialog.state.icon` class exists to provide special sizing in CSS to specific icons -->
@@ -1,8 +1,8 @@
<template> <template>
<div class="floating-menu" :class="[direction.toLowerCase(), type.toLowerCase()]" v-if="open || type === 'Dialog'" ref="floatingMenu"> <div class="floating-menu" :class="[direction.toLowerCase(), type.toLowerCase()]" ref="floatingMenu">
<div class="tail" v-if="type === 'Popover'" ref="tail"></div> <div class="tail" v-if="open && type === 'Popover'" ref="tail"></div>
<div class="floating-menu-container" ref="floatingMenuContainer"> <div class="floating-menu-container" v-if="open || measuringOngoing" ref="floatingMenuContainer">
<LayoutCol class="floating-menu-content" data-floating-menu-content :scrollableY="scrollableY" ref="floatingMenuContent" :style="floatingMenuContentStyle"> <LayoutCol class="floating-menu-content" :style="{ minWidth: minWidthStyleValue }" :scrollableY="scrollableY" ref="floatingMenuContent" data-floating-menu-content>
<slot></slot> <slot></slot>
</LayoutCol> </LayoutCol>
</div> </div>
@@ -175,7 +175,7 @@
</style> </style>
<script lang="ts"> <script lang="ts">
import { defineComponent, PropType, StyleValue } from "vue"; import { defineComponent, PropType } from "vue";
import LayoutCol from "@/components/layout/LayoutCol.vue"; import LayoutCol from "@/components/layout/LayoutCol.vue";
@@ -185,158 +185,190 @@ export type MenuType = "Popover" | "Dropdown" | "Dialog";
const POINTER_STRAY_DISTANCE = 100; const POINTER_STRAY_DISTANCE = 100;
export default defineComponent({ export default defineComponent({
emits: ["update:open", "naturalWidth"],
props: { props: {
direction: { type: String as PropType<MenuDirection>, default: "Bottom" }, open: { type: Boolean as PropType<boolean>, required: true },
type: { type: String as PropType<MenuType>, required: true }, type: { type: String as PropType<MenuType>, required: true },
direction: { type: String as PropType<MenuDirection>, default: "Bottom" },
windowEdgeMargin: { type: Number as PropType<number>, default: 6 }, windowEdgeMargin: { type: Number as PropType<number>, default: 6 },
minWidth: { type: Number as PropType<number>, default: 0 },
scrollableY: { type: Boolean as PropType<boolean>, default: false }, scrollableY: { type: Boolean as PropType<boolean>, default: false },
minWidth: { type: Number as PropType<number>, default: 0 },
}, },
data() { data() {
const containerResizeObserver = new ResizeObserver((entries) => { // 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.
const content = entries[0].target.querySelector("[data-floating-menu-content]") as HTMLElement; // 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
content.style.minWidth = `${entries[0].contentRect.width}px`; // 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.
const containerResizeObserver = new ResizeObserver((entries: ResizeObserverEntry[]) => {
this.resizeObserverCallback(entries);
}); });
return { return {
open: false, measuringOngoing: false,
pointerStillDown: false, measuringOngoingGuard: false,
minWidthParentWidth: 0,
containerResizeObserver, containerResizeObserver,
pointerStillDown: false,
workspaceBounds: new DOMRect(), workspaceBounds: new DOMRect(),
floatingMenuBounds: new DOMRect(), floatingMenuBounds: new DOMRect(),
floatingMenuContentBounds: new DOMRect(), floatingMenuContentBounds: new DOMRect(),
}; };
}, },
computed: {
minWidthStyleValue() {
if (this.measuringOngoing) return "0";
return `${Math.max(this.minWidth, this.minWidthParentWidth)}px`;
},
},
// Gets the client bounds of the elements and apply relevant styles to them // 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 the Vue :style attribute more whilst not causing recursive updates
updated() { async updated() {
const workspace = document.querySelector("[data-workspace]"); // 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)
const floatingMenuContainer = this.$refs.floatingMenuContainer as HTMLElement; if (this.measuringOngoingGuard) return;
const floatingMenuContentComponent = this.$refs.floatingMenuContent as typeof LayoutCol;
const floatingMenuContent: HTMLElement | undefined = floatingMenuContentComponent?.$el;
const floatingMenu = this.$refs.floatingMenu as HTMLElement;
if (!workspace || !floatingMenuContainer || !floatingMenuContentComponent || !floatingMenuContent || !floatingMenu) return; this.positionAndStyleFloatingMenu();
this.workspaceBounds = workspace.getBoundingClientRect();
this.floatingMenuBounds = floatingMenu.getBoundingClientRect();
this.floatingMenuContentBounds = floatingMenuContent.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)
const tailOffset = this.type === "Popover" ? 10 : 0;
if (this.direction === "Bottom") floatingMenuContent.style.top = `${tailOffset + this.floatingMenuBounds.top}px`;
if (this.direction === "Top") floatingMenuContent.style.bottom = `${tailOffset + this.floatingMenuBounds.bottom}px`;
if (this.direction === "Right") floatingMenuContent.style.left = `${tailOffset + this.floatingMenuBounds.left}px`;
if (this.direction === "Left") floatingMenuContent.style.right = `${tailOffset + this.floatingMenuBounds.right}px`;
// Required to correctly position content when scrolled (it has a `position: fixed` to prevent clipping)
const tail = this.$refs.tail as HTMLElement;
if (tail) {
if (this.direction === "Bottom") tail.style.top = `${this.floatingMenuBounds.top}px`;
if (this.direction === "Top") tail.style.bottom = `${this.floatingMenuBounds.bottom}px`;
if (this.direction === "Right") tail.style.left = `${this.floatingMenuBounds.left}px`;
if (this.direction === "Left") tail.style.right = `${this.floatingMenuBounds.right}px`;
}
}
type Edge = "Top" | "Bottom" | "Left" | "Right";
let zeroedBorderVertical: Edge | undefined;
let zeroedBorderHorizontal: Edge | undefined;
if (this.direction === "Top" || this.direction === "Bottom") {
zeroedBorderVertical = this.direction === "Top" ? "Bottom" : "Top";
if (this.floatingMenuContentBounds.left - this.windowEdgeMargin <= this.workspaceBounds.left) {
floatingMenuContent.style.left = `${this.windowEdgeMargin}px`;
if (this.workspaceBounds.left + floatingMenuContainer.getBoundingClientRect().left === 12) zeroedBorderHorizontal = "Left";
}
if (this.floatingMenuContentBounds.right + this.windowEdgeMargin >= this.workspaceBounds.right) {
floatingMenuContent.style.right = `${this.windowEdgeMargin}px`;
if (this.workspaceBounds.right - floatingMenuContainer.getBoundingClientRect().right === 12) zeroedBorderHorizontal = "Right";
}
}
if (this.direction === "Left" || this.direction === "Right") {
zeroedBorderHorizontal = this.direction === "Left" ? "Right" : "Left";
if (this.floatingMenuContentBounds.top - this.windowEdgeMargin <= this.workspaceBounds.top) {
floatingMenuContent.style.top = `${this.windowEdgeMargin}px`;
if (this.workspaceBounds.top + floatingMenuContainer.getBoundingClientRect().top === 12) zeroedBorderVertical = "Top";
}
if (this.floatingMenuContentBounds.bottom + this.windowEdgeMargin >= this.workspaceBounds.bottom) {
floatingMenuContent.style.bottom = `${this.windowEdgeMargin}px`;
if (this.workspaceBounds.bottom - floatingMenuContainer.getBoundingClientRect().bottom === 12) zeroedBorderVertical = "Bottom";
}
}
// Remove the rounded corner from the content where the tail perfectly meets the corner
if (this.type === "Popover" && this.windowEdgeMargin === 6 && zeroedBorderVertical && zeroedBorderHorizontal) {
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;
}
}
}, },
methods: { methods: {
setOpen() { resizeObserverCallback(entries: ResizeObserverEntry[]) {
this.open = true; this.minWidthParentWidth = entries[0].contentRect.width;
}, },
setClosed() { positionAndStyleFloatingMenu() {
this.open = false; const workspace = document.querySelector("[data-workspace]");
const floatingMenuContainer = this.$refs.floatingMenuContainer as HTMLElement;
const floatingMenuContentComponent = this.$refs.floatingMenuContent as typeof LayoutCol;
const floatingMenuContent: HTMLElement | undefined = floatingMenuContentComponent?.$el;
const floatingMenu = this.$refs.floatingMenu as HTMLElement;
if (!workspace || !floatingMenuContainer || !floatingMenuContentComponent || !floatingMenuContent || !floatingMenu) return;
this.workspaceBounds = workspace.getBoundingClientRect();
this.floatingMenuBounds = floatingMenu.getBoundingClientRect();
this.floatingMenuContentBounds = floatingMenuContent.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)
const tailOffset = this.type === "Popover" ? 10 : 0;
if (this.direction === "Bottom") floatingMenuContent.style.top = `${tailOffset + this.floatingMenuBounds.top}px`;
if (this.direction === "Top") floatingMenuContent.style.bottom = `${tailOffset + this.floatingMenuBounds.bottom}px`;
if (this.direction === "Right") floatingMenuContent.style.left = `${tailOffset + this.floatingMenuBounds.left}px`;
if (this.direction === "Left") floatingMenuContent.style.right = `${tailOffset + this.floatingMenuBounds.right}px`;
// Required to correctly position tail when scrolled (it has a `position: fixed` to prevent clipping)
const tail = this.$refs.tail as HTMLElement;
if (tail) {
if (this.direction === "Bottom") tail.style.top = `${this.floatingMenuBounds.top}px`;
if (this.direction === "Top") tail.style.bottom = `${this.floatingMenuBounds.bottom}px`;
if (this.direction === "Right") tail.style.left = `${this.floatingMenuBounds.left}px`;
if (this.direction === "Left") tail.style.right = `${this.floatingMenuBounds.right}px`;
}
}
type Edge = "Top" | "Bottom" | "Left" | "Right";
let zeroedBorderVertical: Edge | undefined;
let zeroedBorderHorizontal: Edge | undefined;
if (this.direction === "Top" || this.direction === "Bottom") {
zeroedBorderVertical = this.direction === "Top" ? "Bottom" : "Top";
if (this.floatingMenuContentBounds.left - this.windowEdgeMargin <= this.workspaceBounds.left) {
floatingMenuContent.style.left = `${this.windowEdgeMargin}px`;
if (this.workspaceBounds.left + floatingMenuContainer.getBoundingClientRect().left === 12) zeroedBorderHorizontal = "Left";
}
if (this.floatingMenuContentBounds.right + this.windowEdgeMargin >= this.workspaceBounds.right) {
floatingMenuContent.style.right = `${this.windowEdgeMargin}px`;
if (this.workspaceBounds.right - floatingMenuContainer.getBoundingClientRect().right === 12) zeroedBorderHorizontal = "Right";
}
}
if (this.direction === "Left" || this.direction === "Right") {
zeroedBorderHorizontal = this.direction === "Left" ? "Right" : "Left";
if (this.floatingMenuContentBounds.top - this.windowEdgeMargin <= this.workspaceBounds.top) {
floatingMenuContent.style.top = `${this.windowEdgeMargin}px`;
if (this.workspaceBounds.top + floatingMenuContainer.getBoundingClientRect().top === 12) zeroedBorderVertical = "Top";
}
if (this.floatingMenuContentBounds.bottom + this.windowEdgeMargin >= this.workspaceBounds.bottom) {
floatingMenuContent.style.bottom = `${this.windowEdgeMargin}px`;
if (this.workspaceBounds.bottom - floatingMenuContainer.getBoundingClientRect().bottom === 12) zeroedBorderVertical = "Bottom";
}
}
// Remove the rounded corner from the content where the tail perfectly meets the corner
if (this.type === "Popover" && this.windowEdgeMargin === 6 && zeroedBorderVertical && zeroedBorderHorizontal) {
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;
}
}
}, },
isOpen(): boolean { // To be called by the parent component. Measures the actual width of the floating menu content element and returns it in a promise.
return this.open; async measureAndEmitNaturalWidth(): Promise<void> {
}, // Wait for the changed content which fired the `updated()` Vue event to be put into the DOM
getWidth(callback: (width: number) => void) { await this.$nextTick();
this.$nextTick(() => {
// 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
this.measuringOngoing = true;
this.measuringOngoingGuard = true;
await this.$nextTick();
// Only measure if the menu is visible, perhaps because a parent component with a `v-if` condition is false
let naturalWidth;
if (this.$refs.floatingMenuContent) {
// Measure the width of the floating menu content element
const floatingMenuContent: HTMLElement = (this.$refs.floatingMenuContent as typeof LayoutCol).$el; const floatingMenuContent: HTMLElement = (this.$refs.floatingMenuContent as typeof LayoutCol).$el;
const width = floatingMenuContent.clientWidth; naturalWidth = floatingMenuContent?.clientWidth;
callback(width); }
});
}, // 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
disableMinWidth(callback: (minWidth: string) => void) { this.measuringOngoing = false;
this.$nextTick(() => { await this.$nextTick();
const floatingMenuContent: HTMLElement = (this.$refs.floatingMenuContent as typeof LayoutCol).$el; this.measuringOngoingGuard = false;
const initialMinWidth = floatingMenuContent.style.minWidth;
floatingMenuContent.style.minWidth = "0"; // Emit the measured natural width to the parent
callback(initialMinWidth); if (naturalWidth !== undefined && naturalWidth >= 0) {
}); this.$emit("naturalWidth", naturalWidth);
}, }
enableMinWidth(minWidth: string) {
const floatingMenuContent: HTMLElement = (this.$refs.floatingMenuContent as typeof LayoutCol).$el;
floatingMenuContent.style.minWidth = minWidth;
}, },
pointerMoveHandler(e: PointerEvent) { pointerMoveHandler(e: PointerEvent) {
const target = e.target as HTMLElement | undefined; const target = e.target as HTMLElement | undefined;
const pointerOverFloatingMenuKeepOpen = target?.closest("[data-hover-menu-keep-open]") as HTMLElement | undefined; const pointerOverFloatingMenuKeepOpen = target?.closest("[data-hover-menu-keep-open]") as HTMLElement | undefined;
const pointerOverFloatingMenuSpawner = target?.closest("[data-hover-menu-spawner]") as HTMLElement | undefined; const pointerOverFloatingMenuSpawner = target?.closest("[data-hover-menu-spawner]") as HTMLElement | undefined;
const pointerOverOwnFloatingMenuSpawner = pointerOverFloatingMenuSpawner?.parentElement?.contains(this.$refs.floatingMenu as HTMLElement); const pointerOverOwnFloatingMenuSpawner = pointerOverFloatingMenuSpawner?.parentElement?.contains(this.$refs.floatingMenu as HTMLElement);
// Swap this open floating menu with the one created by the floating menu spawner being hovered over // Swap this open floating menu with the one created by the floating menu spawner being hovered over
if (pointerOverFloatingMenuSpawner && !pointerOverOwnFloatingMenuSpawner) { if (pointerOverFloatingMenuSpawner && !pointerOverOwnFloatingMenuSpawner) {
this.setClosed(); this.$emit("update:open", false);
pointerOverFloatingMenuSpawner.click(); pointerOverFloatingMenuSpawner.click();
} }
// Close the floating menu if the pointer has strayed far enough from its bounds // Close the floating menu if the pointer has strayed far enough from its bounds
if (this.isPointerEventOutsideFloatingMenu(e, POINTER_STRAY_DISTANCE) && !pointerOverOwnFloatingMenuSpawner && !pointerOverFloatingMenuKeepOpen) { if (this.isPointerEventOutsideFloatingMenu(e, POINTER_STRAY_DISTANCE) && !pointerOverOwnFloatingMenuSpawner && !pointerOverFloatingMenuKeepOpen) {
// TODO: Extend this rectangle bounds check to all `data-hover-menu-keep-open` element bounds up the DOM tree since currently // TODO: Extend this rectangle bounds check to all `data-hover-menu-keep-open` element bounds up the DOM tree since currently
// submenus disappear with zero stray distance if the cursor is further than the stray distance from only the top-level menu // submenus disappear with zero stray distance if the cursor is further than the stray distance from only the top-level menu
this.setClosed(); this.$emit("update:open", false);
} }
const eventIncludesLmb = Boolean(e.buttons & 1);
// Clean up any messes from lost pointerup events // Clean up any messes from lost pointerup events
const eventIncludesLmb = Boolean(e.buttons & 1);
if (!this.open && !eventIncludesLmb) { if (!this.open && !eventIncludesLmb) {
this.pointerStillDown = false; this.pointerStillDown = false;
window.removeEventListener("pointerup", this.pointerUpHandler); window.removeEventListener("pointerup", this.pointerUpHandler);
@@ -345,7 +377,8 @@ export default defineComponent({
pointerDownHandler(e: PointerEvent) { pointerDownHandler(e: PointerEvent) {
// Close the floating menu if the pointer clicked outside the floating menu (but within stray distance) // Close the floating menu if the pointer clicked outside the floating menu (but within stray distance)
if (this.isPointerEventOutsideFloatingMenu(e)) { if (this.isPointerEventOutsideFloatingMenu(e)) {
this.setClosed(); this.$emit("update:open", false);
// Track if the left pointer button is now down so its later click event can be canceled // Track if the left pointer button is now down so its later click event can be canceled
const eventIsForLmb = e.button === 0; const eventIsForLmb = e.button === 0;
if (eventIsForLmb) this.pointerStillDown = true; if (eventIsForLmb) this.pointerStillDown = true;
@@ -384,6 +417,7 @@ export default defineComponent({
}, },
}, },
watch: { watch: {
// Called only when `open` is changed from outside this component (with v-model)
open(newState: boolean, oldState: boolean) { open(newState: boolean, oldState: boolean) {
// Switching from closed to open // Switching from closed to open
if (newState && !oldState) { if (newState && !oldState) {
@@ -396,28 +430,25 @@ export default defineComponent({
// Floating menu min-width resize observer // Floating menu min-width resize observer
this.$nextTick(() => { this.$nextTick(() => {
const floatingMenuContainer = this.$refs.floatingMenuContainer as HTMLElement; const floatingMenuContainer = this.$refs.floatingMenuContainer as HTMLElement;
if (floatingMenuContainer) { if (!floatingMenuContainer) return;
this.containerResizeObserver.disconnect();
this.containerResizeObserver.observe(floatingMenuContainer); // Start a new observation of the now-open floating menu
} this.containerResizeObserver.disconnect();
this.containerResizeObserver.observe(floatingMenuContainer);
}); });
} }
// Switching from open to closed // Switching from open to closed
if (!newState && oldState) { if (!newState && oldState) {
// Clean up observation of the now-closed floating menu
this.containerResizeObserver.disconnect();
window.removeEventListener("pointermove", this.pointerMoveHandler); window.removeEventListener("pointermove", this.pointerMoveHandler);
window.removeEventListener("pointerdown", this.pointerDownHandler); window.removeEventListener("pointerdown", this.pointerDownHandler);
this.containerResizeObserver.disconnect(); // The `pointerup` event is removed in `pointerMoveHandler()` and `pointerDownHandler()`
} }
}, },
}, },
computed: {
floatingMenuContentStyle(): StyleValue {
return {
minWidth: this.minWidth > 0 ? `${this.minWidth}px` : "",
};
},
},
components: { LayoutCol }, components: { LayoutCol },
}); });
</script> </script>
@@ -1,16 +1,24 @@
<template> <template>
<FloatingMenu class="menu-list" :direction="direction" :type="'Dropdown'" ref="floatingMenu" :windowEdgeMargin="0" :scrollableY="scrollableY" data-hover-menu-keep-open> <FloatingMenu
class="menu-list"
v-model:open="isOpen"
@naturalWidth="(newNaturalWidth: number) => $emit('naturalWidth', newNaturalWidth)"
:type="'Dropdown'"
:windowEdgeMargin="0"
v-bind="{ direction, scrollableY, minWidth }"
ref="floatingMenu"
data-hover-menu-keep-open
>
<template v-for="(section, sectionIndex) in entries" :key="sectionIndex"> <template v-for="(section, sectionIndex) in entries" :key="sectionIndex">
<Separator :type="'List'" :direction="'Vertical'" v-if="sectionIndex > 0" /> <Separator :type="'List'" :direction="'Vertical'" v-if="sectionIndex > 0" />
<LayoutRow <LayoutRow
v-for="(entry, entryIndex) in section" v-for="(entry, entryIndex) in section"
:key="entryIndex" :key="entryIndex"
class="row" class="row"
:class="{ open: isMenuEntryOpen(entry), active: entry === activeEntry }" :class="{ open: isEntryOpen(entry), active: entry.label === activeEntry?.label }"
@click="() => handleEntryClick(entry)" @click="() => onEntryClick(entry)"
@pointerenter="() => handleEntryPointerEnter(entry)" @pointerenter="() => onEntryPointerEnter(entry)"
@pointerleave="() => handleEntryPointerLeave(entry)" @pointerleave="() => onEntryPointerLeave(entry)"
:data-hover-menu-spawner-extend="entry.children && []"
> >
<CheckboxInput v-if="entry.checkbox" v-model:checked="entry.checked" :outlineStyle="true" class="entry-checkbox" /> <CheckboxInput v-if="entry.checkbox" v-model:checked="entry.checked" :outlineStyle="true" class="entry-checkbox" />
<IconLabel v-else-if="entry.icon && drawIcon" :icon="entry.icon" class="entry-icon" /> <IconLabel v-else-if="entry.icon && drawIcon" :icon="entry.icon" class="entry-icon" />
@@ -26,10 +34,12 @@
<MenuList <MenuList
v-if="entry.children" v-if="entry.children"
@naturalWidth="(newNaturalWidth: number) => $emit('naturalWidth', newNaturalWidth)"
:open="entry.ref?.open || false"
:direction="'TopRight'" :direction="'TopRight'"
:entries="entry.children" :entries="entry.children"
v-bind="{ defaultAction, minWidth, drawIcon, scrollableY }" v-bind="{ defaultAction, minWidth, drawIcon, scrollableY }"
:ref="(ref: any) => setEntryRefs(entry, ref)" :ref="(ref: typeof FloatingMenu) => ref && (entry.ref = ref)"
/> />
</LayoutRow> </LayoutRow>
</template> </template>
@@ -131,7 +141,7 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, PropType } from "vue"; import { defineComponent, PropType } from "vue";
import { IconName } from "@/utilities/icons"; import { IconName } from "@/utility-functions/icons";
import LayoutRow from "@/components/layout/LayoutRow.vue"; import LayoutRow from "@/components/layout/LayoutRow.vue";
import FloatingMenu, { MenuDirection } from "@/components/widgets/floating-menus/FloatingMenu.vue"; import FloatingMenu, { MenuDirection } from "@/components/widgets/floating-menus/FloatingMenu.vue";
@@ -160,86 +170,75 @@ const KEYBOARD_LOCK_USE_FULLSCREEN = "This hotkey is reserved by the browser, bu
const KEYBOARD_LOCK_SWITCH_BROWSER = "This hotkey is reserved by the browser, but becomes available in Chrome, Edge, and Opera which support the Keyboard.lock() API"; const KEYBOARD_LOCK_SWITCH_BROWSER = "This hotkey is reserved by the browser, but becomes available in Chrome, Edge, and Opera which support the Keyboard.lock() API";
const MenuList = defineComponent({ const MenuList = defineComponent({
emits: {
"update:activeEntry": null,
widthChanged: (width: number) => typeof width === "number",
},
inject: ["fullscreen"], inject: ["fullscreen"],
emits: ["update:open", "update:activeEntry", "naturalWidth"],
props: { props: {
direction: { type: String as PropType<MenuDirection>, default: "Bottom" },
entries: { type: Array as PropType<SectionsOfMenuListEntries>, required: true }, entries: { type: Array as PropType<SectionsOfMenuListEntries>, required: true },
activeEntry: { type: Object as PropType<MenuListEntry>, required: false }, activeEntry: { type: Object as PropType<MenuListEntry>, required: false },
defaultAction: { type: Function as PropType<() => void>, required: false }, open: { type: Boolean as PropType<boolean>, required: true },
direction: { type: String as PropType<MenuDirection>, default: "Bottom" },
minWidth: { type: Number as PropType<number>, default: 0 }, minWidth: { type: Number as PropType<number>, default: 0 },
drawIcon: { type: Boolean as PropType<boolean>, default: false }, drawIcon: { type: Boolean as PropType<boolean>, default: false },
scrollableY: { type: Boolean as PropType<boolean>, default: false }, scrollableY: { type: Boolean as PropType<boolean>, default: false },
defaultAction: { type: Function as PropType<() => void>, required: false },
},
data() {
return {
isOpen: this.open,
keyboardLockInfoMessage: this.fullscreen.keyboardLockApiSupported ? KEYBOARD_LOCK_USE_FULLSCREEN : KEYBOARD_LOCK_SWITCH_BROWSER,
};
},
watch: {
// Called only when `open` is changed from outside this component (with v-model)
open(newOpen: boolean) {
this.isOpen = newOpen;
},
isOpen(newIsOpen: boolean) {
this.$emit("update:open", newIsOpen);
},
entries() {
const floatingMenu = this.$refs.floatingMenu as typeof FloatingMenu;
floatingMenu.measureAndEmitNaturalWidth();
},
drawIcon() {
const floatingMenu = this.$refs.floatingMenu as typeof FloatingMenu;
floatingMenu.measureAndEmitNaturalWidth();
},
}, },
methods: { methods: {
setEntryRefs(menuEntry: MenuListEntry, ref: typeof FloatingMenu): void { onEntryClick(menuEntry: MenuListEntry): void {
if (ref) menuEntry.ref = ref; // Toggle checkbox
}, // TODO: This is broken at the moment, fix it when we get rid of using `ref`
handleEntryClick(menuEntry: MenuListEntry): void {
(this.$refs.floatingMenu as typeof FloatingMenu).setClosed();
if (menuEntry.checkbox) menuEntry.checked = !menuEntry.checked; if (menuEntry.checkbox) menuEntry.checked = !menuEntry.checked;
// Call the action, or a default, if either are provided
if (menuEntry.action) menuEntry.action(); if (menuEntry.action) menuEntry.action();
else if (this.defaultAction) this.defaultAction(); else if (this.defaultAction) this.defaultAction();
// Emit the clicked entry as the new active entry
this.$emit("update:activeEntry", menuEntry); this.$emit("update:activeEntry", menuEntry);
// Close the containing menu
if (menuEntry.ref) menuEntry.ref.isOpen = false;
this.$emit("update:open", false);
this.isOpen = false; // TODO: This is a hack for MenuBarInput submenus, remove it when we get rid of using `ref`
}, },
handleEntryPointerEnter(menuEntry: MenuListEntry): void { onEntryPointerEnter(menuEntry: MenuListEntry): void {
if (!menuEntry.children?.length) return; if (!menuEntry.children?.length) return;
if (menuEntry.ref) menuEntry.ref.setOpen(); if (menuEntry.ref) menuEntry.ref.isOpen = true;
else throw new Error("The menu bar floating menu has no associated ref"); else this.$emit("update:open", true);
}, },
handleEntryPointerLeave(menuEntry: MenuListEntry): void { onEntryPointerLeave(menuEntry: MenuListEntry): void {
if (!menuEntry.children?.length) return; if (!menuEntry.children?.length) return;
if (menuEntry.ref) menuEntry.ref.setClosed(); if (menuEntry.ref) menuEntry.ref.isOpen = false;
else throw new Error("The menu bar floating menu has no associated ref"); else this.$emit("update:open", false);
}, },
isMenuEntryOpen(menuEntry: MenuListEntry): boolean { isEntryOpen(menuEntry: MenuListEntry): boolean {
if (!menuEntry.children?.length) return false; if (!menuEntry.children?.length) return false;
if (menuEntry.ref) return menuEntry.ref.isOpen(); return this.open;
return false;
},
setOpen() {
(this.$refs.floatingMenu as typeof FloatingMenu).setOpen();
},
setClosed() {
(this.$refs.floatingMenu as typeof FloatingMenu).setClosed();
},
isOpen(): boolean {
const floatingMenu = this.$refs.floatingMenu as typeof FloatingMenu;
return Boolean(floatingMenu?.isOpen());
},
async measureAndReportWidth() {
// 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;
const floatingMenu = this.$refs.floatingMenu as typeof FloatingMenu;
if (!floatingMenu) return;
// Save open/closed state before forcing open, if necessary, for measurement
const initiallyOpen = floatingMenu.isOpen();
if (!initiallyOpen) floatingMenu.setOpen();
floatingMenu.disableMinWidth((initialMinWidth: string) => {
floatingMenu.getWidth((width: number) => {
floatingMenu.enableMinWidth(initialMinWidth);
// Restore open/closed state if it was forced open for measurement
if (!initiallyOpen) floatingMenu.setClosed();
this.$emit("widthChanged", width);
});
});
}, },
}, },
computed: { computed: {
@@ -252,25 +251,6 @@ const MenuList = defineComponent({
); );
}, },
}, },
mounted() {
this.measureAndReportWidth();
},
updated() {
this.measureAndReportWidth();
},
watch: {
entriesWithoutRefs: {
handler() {
this.measureAndReportWidth();
},
deep: true,
},
},
data() {
return {
keyboardLockInfoMessage: this.fullscreen.keyboardLockApiSupported ? KEYBOARD_LOCK_USE_FULLSCREEN : KEYBOARD_LOCK_SWITCH_BROWSER,
};
},
components: { components: {
FloatingMenu, FloatingMenu,
Separator, Separator,
@@ -84,7 +84,7 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, PropType } from "vue"; import { defineComponent, PropType } from "vue";
import { IconName } from "@/utilities/icons"; import { IconName } from "@/utility-functions/icons";
import LayoutRow from "@/components/layout/LayoutRow.vue"; import LayoutRow from "@/components/layout/LayoutRow.vue";
import IconLabel from "@/components/widgets/labels/IconLabel.vue"; import IconLabel from "@/components/widgets/labels/IconLabel.vue";
@@ -4,8 +4,8 @@
<TextInput :value="displayValue" :label="label" :disabled="disabled || !value" @commitText="(value: string) => textInputUpdated(value)" :center="true" /> <TextInput :value="displayValue" :label="label" :disabled="disabled || !value" @commitText="(value: string) => textInputUpdated(value)" :center="true" />
<Separator :type="'Related'" /> <Separator :type="'Related'" />
<LayoutRow class="swatch"> <LayoutRow class="swatch">
<button class="swatch-button" :class="{ 'disabled-swatch': !value }" :style="`--swatch-color: #${value}`" @click="() => menuOpen()"></button> <button class="swatch-button" :class="{ 'disabled-swatch': !value }" :style="`--swatch-color: #${value}`" @click="() => $emit('update:open', true)"></button>
<FloatingMenu :type="'Popover'" :direction="'Bottom'" horizontal ref="colorFloatingMenu"> <FloatingMenu v-model:open="isOpen" :type="'Popover'" :direction="'Bottom'">
<ColorPicker @update:color="(color) => colorPickerUpdated(color)" :color="color" /> <ColorPicker @update:color="(color) => colorPickerUpdated(color)" :color="color" />
</FloatingMenu> </FloatingMenu>
</LayoutRow> </LayoutRow>
@@ -70,7 +70,7 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, PropType } from "vue"; import { defineComponent, PropType } from "vue";
import { RGBA } from "@/dispatcher/js-messages"; import { RGBA } from "@/wasm-communication/messages";
import LayoutRow from "@/components/layout/LayoutRow.vue"; import LayoutRow from "@/components/layout/LayoutRow.vue";
import ColorPicker from "@/components/widgets/floating-menus/ColorPicker.vue"; import ColorPicker from "@/components/widgets/floating-menus/ColorPicker.vue";
@@ -80,13 +80,19 @@ import TextInput from "@/components/widgets/inputs/TextInput.vue";
import Separator from "@/components/widgets/separators/Separator.vue"; import Separator from "@/components/widgets/separators/Separator.vue";
export default defineComponent({ export default defineComponent({
emits: ["update:value"], emits: ["update:value", "update:open"],
props: { props: {
value: { type: String as PropType<string | undefined>, required: true }, value: { type: String as PropType<string | undefined>, required: true },
open: { type: Boolean as PropType<boolean>, required: true },
label: { type: String as PropType<string>, required: false }, label: { type: String as PropType<string>, required: false },
canSetTransparent: { type: Boolean as PropType<boolean>, required: false, default: true }, canSetTransparent: { type: Boolean as PropType<boolean>, required: false, default: true },
disabled: { type: Boolean as PropType<boolean>, default: false }, disabled: { type: Boolean as PropType<boolean>, default: false },
}, },
data() {
return {
isOpen: false,
};
},
computed: { computed: {
color() { color() {
if (!this.value) return { r: 0, g: 0, b: 0, a: 1 }; if (!this.value) return { r: 0, g: 0, b: 0, a: 1 };
@@ -105,6 +111,15 @@ export default defineComponent({
return `#${shortenedIfOpaque}`; return `#${shortenedIfOpaque}`;
}, },
}, },
watch: {
// Called only when `open` is changed from outside this component (with v-model)
open(newOpen: boolean) {
this.isOpen = newOpen;
},
isOpen(newIsOpen: boolean) {
this.$emit("update:open", newIsOpen);
},
},
methods: { methods: {
colorPickerUpdated(color: RGBA) { colorPickerUpdated(color: RGBA) {
const twoDigitHex = (value: number): string => value.toString(16).padStart(2, "0"); const twoDigitHex = (value: number): string => value.toString(16).padStart(2, "0");
@@ -134,9 +149,6 @@ export default defineComponent({
this.$emit("update:value", sanitized); this.$emit("update:value", sanitized);
}, },
menuOpen() {
(this.$refs.colorFloatingMenu as typeof FloatingMenu).setOpen();
},
updateEnabled(value: boolean) { updateEnabled(value: boolean) {
if (value) this.$emit("update:value", "000000"); if (value) this.$emit("update:value", "000000");
else this.$emit("update:value", undefined); else this.$emit("update:value", undefined);
@@ -1,19 +1,18 @@
<template> <template>
<LayoutRow class="dropdown-input"> <LayoutRow class="dropdown-input">
<LayoutRow class="dropdown-box" :class="{ disabled }" :style="{ minWidth: `${minWidth}px` }" @click="() => clickDropdownBox()" data-hover-menu-spawner> <LayoutRow class="dropdown-box" :class="{ disabled }" :style="{ minWidth: `${minWidth}px` }" @click="() => !disabled && (open = true)" ref="dropdownBox" data-hover-menu-spawner>
<IconLabel class="dropdown-icon" :icon="activeEntry.icon" v-if="activeEntry.icon" /> <IconLabel class="dropdown-icon" :icon="activeEntry.icon" v-if="activeEntry.icon" />
<span>{{ activeEntry.label }}</span> <span>{{ activeEntry.label }}</span>
<IconLabel class="dropdown-arrow" :icon="'DropdownArrow'" /> <IconLabel class="dropdown-arrow" :icon="'DropdownArrow'" />
</LayoutRow> </LayoutRow>
<MenuList <MenuList
v-model:activeEntry="activeEntry" v-model:activeEntry="activeEntry"
@update:activeEntry="(newActiveEntry: typeof MENU_LIST_ENTRY) => activeEntryChanged(newActiveEntry)" v-model:open="open"
@widthChanged="(newWidth: number) => onWidthChanged(newWidth)" @naturalWidth="(newNaturalWidth: number) => (minWidth = newNaturalWidth)"
:entries="entries" :entries="entries"
:direction="'Bottom'"
:drawIcon="drawIcon" :drawIcon="drawIcon"
:direction="'Bottom'"
:scrollableY="true" :scrollableY="true"
ref="menuList"
/> />
</LayoutRow> </LayoutRow>
</template> </template>
@@ -86,16 +85,13 @@
</style> </style>
<script lang="ts"> <script lang="ts">
import { defineComponent, PropType } from "vue"; import { defineComponent, PropType, toRaw } from "vue";
import LayoutRow from "@/components/layout/LayoutRow.vue"; import LayoutRow from "@/components/layout/LayoutRow.vue";
import MenuList, { MenuListEntry, SectionsOfMenuListEntries } from "@/components/widgets/floating-menus/MenuList.vue"; import MenuList, { MenuListEntry, SectionsOfMenuListEntries } from "@/components/widgets/floating-menus/MenuList.vue";
import IconLabel from "@/components/widgets/labels/IconLabel.vue"; import IconLabel from "@/components/widgets/labels/IconLabel.vue";
// Satisfies Volar (https://github.com/johnsoncodehk/volar/issues/596) const DASH_ENTRY = { label: "-" };
declare global {
const MENU_LIST_ENTRY: MenuListEntry;
}
export default defineComponent({ export default defineComponent({
emits: ["update:selectedIndex"], emits: ["update:selectedIndex"],
@@ -107,32 +103,31 @@ export default defineComponent({
}, },
data() { data() {
return { return {
activeEntry: this.selectedIndex !== undefined ? this.entries.flat()[this.selectedIndex] : { label: "-" }, activeEntry: this.makeActiveEntry(this.selectedIndex),
open: false,
minWidth: 0, minWidth: 0,
}; };
}, },
watch: { watch: {
// Called only when `selectedIndex` is changed from outside this component (with v-model) // Called only when `selectedIndex` is changed from outside this component (with v-model)
selectedIndex(newSelectedIndex: number | undefined) { selectedIndex() {
const entries = this.entries.flat(); this.activeEntry = this.makeActiveEntry();
},
activeEntry(newActiveEntry: MenuListEntry) {
// `toRaw()` pulls it out of the Vue proxy
if (toRaw(newActiveEntry) === DASH_ENTRY) return;
if (newSelectedIndex !== undefined && newSelectedIndex >= 0 && newSelectedIndex < entries.length) { this.$emit("update:selectedIndex", this.entries.flat().indexOf(newActiveEntry));
this.activeEntry = entries[newSelectedIndex];
} else {
this.activeEntry = { label: "-" };
}
}, },
}, },
methods: { methods: {
// Called only when `activeEntry` is changed from the child MenuList component via user input makeActiveEntry(): MenuListEntry {
activeEntryChanged(newActiveEntry: MenuListEntry) { const entries = this.entries.flat();
this.$emit("update:selectedIndex", this.entries.flat().indexOf(newActiveEntry));
}, if (this.selectedIndex !== undefined && this.selectedIndex >= 0 && this.selectedIndex < entries.length) {
clickDropdownBox() { return entries[this.selectedIndex];
if (!this.disabled) (this.$refs.menuList as typeof MenuList).setOpen(); }
}, return DASH_ENTRY;
onWidthChanged(newWidth: number) {
this.minWidth = newWidth;
}, },
}, },
components: { components: {
@@ -1,10 +1,17 @@
<template> <template>
<LayoutRow class="font-input"> <LayoutRow class="font-input">
<LayoutRow class="dropdown-box" :class="{ disabled }" :style="{ minWidth: `${minWidth}px` }" @click="() => clickDropdownBox()" data-hover-menu-spawner> <LayoutRow class="dropdown-box" :class="{ disabled }" :style="{ minWidth: `${minWidth}px` }" @click="() => !disabled && (open = true)" data-hover-menu-spawner>
<span>{{ activeEntry.label }}</span> <span>{{ activeEntry?.label || "" }}</span>
<IconLabel class="dropdown-arrow" :icon="'DropdownArrow'" /> <IconLabel class="dropdown-arrow" :icon="'DropdownArrow'" />
</LayoutRow> </LayoutRow>
<MenuList v-model:activeEntry="activeEntry" @widthChanged="(newWidth: number) => onWidthChanged(newWidth)" :entries="entries" :direction="'Bottom'" :scrollableY="true" ref="menuList" /> <MenuList
v-model:activeEntry="activeEntry"
v-model:open="open"
@naturalWidth="(newNaturalWidth: number) => (minWidth = newNaturalWidth)"
:entries="entries"
:direction="'Bottom'"
:scrollableY="true"
/>
</LayoutRow> </LayoutRow>
</template> </template>
@@ -78,13 +85,12 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, PropType } from "vue"; import { defineComponent, PropType } from "vue";
import { fontNames, getFontFile, getFontStyles } from "@/utilities/fonts";
import LayoutRow from "@/components/layout/LayoutRow.vue"; import LayoutRow from "@/components/layout/LayoutRow.vue";
import MenuList, { MenuListEntry, SectionsOfMenuListEntries } from "@/components/widgets/floating-menus/MenuList.vue"; import MenuList, { MenuListEntry, SectionsOfMenuListEntries } from "@/components/widgets/floating-menus/MenuList.vue";
import IconLabel from "@/components/widgets/labels/IconLabel.vue"; import IconLabel from "@/components/widgets/labels/IconLabel.vue";
export default defineComponent({ export default defineComponent({
inject: ["fonts"],
emits: ["update:fontFamily", "update:fontStyle", "changeFont"], emits: ["update:fontFamily", "update:fontStyle", "changeFont"],
props: { props: {
fontFamily: { type: String as PropType<string>, required: true }, fontFamily: { type: String as PropType<string>, required: true },
@@ -93,18 +99,20 @@ export default defineComponent({
isStyle: { type: Boolean as PropType<boolean>, default: false }, isStyle: { type: Boolean as PropType<boolean>, default: false },
}, },
data() { data() {
const { entries, activeEntry } = this.updateEntries();
return { return {
entries, open: false,
activeEntry,
minWidth: 0, minWidth: 0,
entries: [] as SectionsOfMenuListEntries,
activeEntry: undefined as undefined | MenuListEntry,
}; };
}, },
async mounted() {
const { entries, activeEntry } = await this.updateEntries();
this.entries = entries;
this.activeEntry = activeEntry;
},
methods: { methods: {
clickDropdownBox() { async selectFont(newName: string): Promise<void> {
if (!this.disabled) (this.$refs.menuList as typeof MenuList).setOpen();
},
selectFont(newName: string) {
let fontFamily; let fontFamily;
let fontStyle; let fontStyle;
@@ -117,24 +125,21 @@ export default defineComponent({
this.$emit("update:fontFamily", newName); this.$emit("update:fontFamily", newName);
fontFamily = newName; fontFamily = newName;
fontStyle = getFontStyles(newName)[0]; fontStyle = (await this.fonts.getFontStyles(newName))[0];
} }
const fontFile = getFontFile(fontFamily, fontStyle); const fontFileUrl = await this.fonts.getFontFileUrl(fontFamily, fontStyle);
this.$emit("changeFont", { fontFamily, fontStyle, fontFile }); this.$emit("changeFont", { fontFamily, fontStyle, fontFileUrl });
}, },
onWidthChanged(newWidth: number) { async updateEntries(): Promise<{ entries: SectionsOfMenuListEntries; activeEntry: MenuListEntry }> {
this.minWidth = newWidth; const choices = this.isStyle ? await this.fonts.getFontStyles(this.fontFamily) : this.fonts.state.fontNames;
},
updateEntries(): { entries: SectionsOfMenuListEntries; activeEntry: MenuListEntry } {
const choices = this.isStyle ? getFontStyles(this.fontFamily) : fontNames();
const selectedChoice = this.isStyle ? this.fontStyle : this.fontFamily; const selectedChoice = this.isStyle ? this.fontStyle : this.fontFamily;
let selectedEntry: MenuListEntry | undefined; let selectedEntry: MenuListEntry | undefined;
const menuListEntries = choices.map((name) => { const menuListEntries = choices.map((name) => {
const result: MenuListEntry = { const result: MenuListEntry = {
label: name, label: name,
action: (): void => this.selectFont(name), action: async (): Promise<void> => this.selectFont(name),
}; };
if (name === selectedChoice) selectedEntry = result; if (name === selectedChoice) selectedEntry = result;
@@ -149,13 +154,13 @@ export default defineComponent({
}, },
}, },
watch: { watch: {
fontFamily() { async fontFamily() {
const { entries, activeEntry } = this.updateEntries(); const { entries, activeEntry } = await this.updateEntries();
this.entries = entries; this.entries = entries;
this.activeEntry = activeEntry; this.activeEntry = activeEntry;
}, },
fontStyle() { async fontStyle() {
const { entries, activeEntry } = this.updateEntries(); const { entries, activeEntry } = await this.updateEntries();
this.entries = entries; this.entries = entries;
this.activeEntry = activeEntry; this.activeEntry = activeEntry;
}, },
@@ -6,11 +6,19 @@
</div> </div>
</div> </div>
<div class="entry-container" v-for="(entry, index) in entries" :key="index"> <div class="entry-container" v-for="(entry, index) in entries" :key="index">
<div @click="() => handleEntryClick(entry)" class="entry" :class="{ open: entry.ref?.isOpen() }" data-hover-menu-spawner> <div @click="() => onClick(entry)" class="entry" :class="{ open: entry.ref?.open }" data-hover-menu-spawner>
<IconLabel :icon="entry.icon" v-if="entry.icon" /> <IconLabel v-if="entry.icon" :icon="entry.icon" />
<span v-if="entry.label">{{ entry.label }}</span> <span v-if="entry.label">{{ entry.label }}</span>
</div> </div>
<MenuList :entries="entry.children || []" :direction="'Bottom'" :minWidth="240" :drawIcon="true" :defaultAction="comingSoon" :ref="(ref: any) => setEntryRefs(entry, ref)" /> <MenuList
:open="entry.ref?.open || false"
:entries="entry.children || []"
:direction="'Bottom'"
:minWidth="240"
:drawIcon="true"
:defaultAction="() => editor.instance.request_coming_soon_dialog()"
:ref="(ref: typeof MenuList) => ref && (entry.ref = ref)"
/>
</div> </div>
</div> </div>
</template> </template>
@@ -53,12 +61,12 @@
<script lang="ts"> <script lang="ts">
import { defineComponent } from "vue"; import { defineComponent } from "vue";
import { EditorState } from "@/state/wasm-loader"; import { Editor } from "@/wasm-communication/editor";
import MenuList, { MenuListEntry, MenuListEntries } from "@/components/widgets/floating-menus/MenuList.vue"; import MenuList, { MenuListEntry, MenuListEntries } from "@/components/widgets/floating-menus/MenuList.vue";
import IconLabel from "@/components/widgets/labels/IconLabel.vue"; import IconLabel from "@/components/widgets/labels/IconLabel.vue";
function makeEntries(editor: EditorState): MenuListEntries { function makeEntries(editor: Editor): MenuListEntries {
return [ return [
{ {
label: "File", label: "File",
@@ -131,14 +139,14 @@ function makeEntries(editor: EditorState): MenuListEntries {
{ {
label: "Raise To Front", label: "Raise To Front",
shortcut: ["KeyControl", "KeyShift", "KeyLeftBracket"], shortcut: ["KeyControl", "KeyShift", "KeyLeftBracket"],
action: async (): Promise<void> => editor.instance.reorder_selected_layers(editor.rawWasm.i32_max()), action: async (): Promise<void> => editor.instance.reorder_selected_layers(editor.raw.i32_max()),
}, },
{ label: "Raise", shortcut: ["KeyControl", "KeyRightBracket"], action: async (): Promise<void> => editor.instance.reorder_selected_layers(1) }, { label: "Raise", shortcut: ["KeyControl", "KeyRightBracket"], action: async (): Promise<void> => editor.instance.reorder_selected_layers(1) },
{ label: "Lower", shortcut: ["KeyControl", "KeyLeftBracket"], action: async (): Promise<void> => editor.instance.reorder_selected_layers(-1) }, { label: "Lower", shortcut: ["KeyControl", "KeyLeftBracket"], action: async (): Promise<void> => editor.instance.reorder_selected_layers(-1) },
{ {
label: "Lower to Back", label: "Lower to Back",
shortcut: ["KeyControl", "KeyShift", "KeyRightBracket"], shortcut: ["KeyControl", "KeyShift", "KeyRightBracket"],
action: async (): Promise<void> => editor.instance.reorder_selected_layers(editor.rawWasm.i32_min()), action: async (): Promise<void> => editor.instance.reorder_selected_layers(editor.raw.i32_min()),
}, },
], ],
], ],
@@ -189,7 +197,7 @@ function makeEntries(editor: EditorState): MenuListEntries {
], ],
], ],
}, },
{ label: "Debug: Panic (DANGER)", action: async (): Promise<void> => editor.rawWasm.intentional_panic() }, { label: "Debug: Panic (DANGER)", action: async (): Promise<void> => editor.instance.intentional_panic() },
], ],
], ],
}, },
@@ -197,15 +205,13 @@ function makeEntries(editor: EditorState): MenuListEntries {
} }
export default defineComponent({ export default defineComponent({
inject: ["workspace", "editor", "dialog"], inject: ["editor"],
methods: { methods: {
setEntryRefs(menuEntry: MenuListEntry, ref: typeof MenuList) { onClick(menuEntry: MenuListEntry) {
if (ref) menuEntry.ref = ref; if (menuEntry.ref) menuEntry.ref.isOpen = true;
},
handleEntryClick(menuEntry: MenuListEntry) {
if (menuEntry.ref) menuEntry.ref.setOpen();
else throw new Error("The menu bar floating menu has no associated ref"); else throw new Error("The menu bar floating menu has no associated ref");
}, },
// TODO: Move to backend
visitWebsite(url: string) { visitWebsite(url: string) {
// This method is required because `window` isn't accessible from the Vue component HTML // This method is required because `window` isn't accessible from the Vue component HTML
window.open(url, "_blank"); window.open(url, "_blank");
@@ -214,7 +220,7 @@ export default defineComponent({
data() { data() {
return { return {
entries: makeEntries(this.editor), entries: makeEntries(this.editor),
comingSoon: (): void => this.dialog.comingSoon(), open: false,
}; };
}, },
components: { components: {
@@ -87,10 +87,11 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, PropType } from "vue"; import { defineComponent, PropType } from "vue";
import { IncrementBehavior, IncrementDirection } from "@/utilities/widgets";
import FieldInput from "@/components/widgets/inputs/FieldInput.vue"; import FieldInput from "@/components/widgets/inputs/FieldInput.vue";
type IncrementBehavior = "Add" | "Multiply" | "Callback" | "None";
type IncrementDirection = "Decrease" | "Increase";
export default defineComponent({ export default defineComponent({
emits: ["update:value"], emits: ["update:value"],
props: { props: {
@@ -37,7 +37,7 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, PropType } from "vue"; import { defineComponent, PropType } from "vue";
import { IconName } from "@/utilities/icons"; import { IconName } from "@/utility-functions/icons";
import LayoutRow from "@/components/layout/LayoutRow.vue"; import LayoutRow from "@/components/layout/LayoutRow.vue";
import CheckboxInput from "@/components/widgets/inputs/CheckboxInput.vue"; import CheckboxInput from "@/components/widgets/inputs/CheckboxInput.vue";
@@ -64,7 +64,7 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, PropType } from "vue"; import { defineComponent, PropType } from "vue";
import { IconName } from "@/utilities/icons"; import { IconName } from "@/utility-functions/icons";
import LayoutRow from "@/components/layout/LayoutRow.vue"; import LayoutRow from "@/components/layout/LayoutRow.vue";
import IconLabel from "@/components/widgets/labels/IconLabel.vue"; import IconLabel from "@/components/widgets/labels/IconLabel.vue";
@@ -2,14 +2,14 @@
<LayoutCol class="swatch-pair"> <LayoutCol class="swatch-pair">
<LayoutRow class="secondary swatch"> <LayoutRow class="secondary swatch">
<button @click="() => clickSecondarySwatch()" ref="secondaryButton" data-hover-menu-spawner></button> <button @click="() => clickSecondarySwatch()" ref="secondaryButton" data-hover-menu-spawner></button>
<FloatingMenu :type="'Popover'" :direction="'Right'" horizontal ref="secondarySwatchFloatingMenu"> <FloatingMenu :type="'Popover'" :direction="'Right'" v-model:open="secondaryOpen">
<ColorPicker @update:color="(color: RGBA_) => secondaryColorChanged(color)" :color="secondaryColor" /> <ColorPicker @update:color="(color: RGBA) => secondaryColorChanged(color)" :color="secondaryColor" />
</FloatingMenu> </FloatingMenu>
</LayoutRow> </LayoutRow>
<LayoutRow class="primary swatch"> <LayoutRow class="primary swatch">
<button @click="() => clickPrimarySwatch()" ref="primaryButton" data-hover-menu-spawner></button> <button @click="() => clickPrimarySwatch()" ref="primaryButton" data-hover-menu-spawner></button>
<FloatingMenu :type="'Popover'" :direction="'Right'" horizontal ref="primarySwatchFloatingMenu"> <FloatingMenu :type="'Popover'" :direction="'Right'" v-model:open="primaryOpen">
<ColorPicker @update:color="(color: RGBA_) => primaryColorChanged(color)" :color="primaryColor" /> <ColorPicker @update:color="(color: RGBA) => primaryColorChanged(color)" :color="primaryColor" />
</FloatingMenu> </FloatingMenu>
</LayoutRow> </LayoutRow>
</LayoutCol> </LayoutCol>
@@ -68,19 +68,14 @@
<script lang="ts"> <script lang="ts">
import { defineComponent } from "vue"; import { defineComponent } from "vue";
import { type RGBA, UpdateWorkingColors } from "@/dispatcher/js-messages"; import { rgbaToDecimalRgba } from "@/utility-functions/color";
import { rgbaToDecimalRgba } from "@/utilities/color"; import { type RGBA, UpdateWorkingColors } from "@/wasm-communication/messages";
import LayoutCol from "@/components/layout/LayoutCol.vue"; import LayoutCol from "@/components/layout/LayoutCol.vue";
import LayoutRow from "@/components/layout/LayoutRow.vue"; import LayoutRow from "@/components/layout/LayoutRow.vue";
import ColorPicker from "@/components/widgets/floating-menus/ColorPicker.vue"; import ColorPicker from "@/components/widgets/floating-menus/ColorPicker.vue";
import FloatingMenu from "@/components/widgets/floating-menus/FloatingMenu.vue"; import FloatingMenu from "@/components/widgets/floating-menus/FloatingMenu.vue";
// Satisfies Volar (https://github.com/johnsoncodehk/volar/issues/596)
declare global {
type RGBA_ = RGBA;
}
export default defineComponent({ export default defineComponent({
inject: ["editor"], inject: ["editor"],
components: { components: {
@@ -89,14 +84,22 @@ export default defineComponent({
LayoutRow, LayoutRow,
LayoutCol, LayoutCol,
}, },
data() {
return {
primaryOpen: false,
secondaryOpen: false,
primaryColor: { r: 0, g: 0, b: 0, a: 1 } as RGBA,
secondaryColor: { r: 255, g: 255, b: 255, a: 1 } as RGBA,
};
},
methods: { methods: {
clickPrimarySwatch() { clickPrimarySwatch() {
(this.$refs.primarySwatchFloatingMenu as typeof FloatingMenu).setOpen(); this.primaryOpen = true;
(this.$refs.secondarySwatchFloatingMenu as typeof FloatingMenu).setClosed(); this.secondaryOpen = false;
}, },
clickSecondarySwatch() { clickSecondarySwatch() {
(this.$refs.secondarySwatchFloatingMenu as typeof FloatingMenu).setOpen(); this.primaryOpen = false;
(this.$refs.primarySwatchFloatingMenu as typeof FloatingMenu).setClosed(); this.secondaryOpen = true;
}, },
primaryColorChanged(color: RGBA) { primaryColorChanged(color: RGBA) {
this.primaryColor = color; this.primaryColor = color;
@@ -123,14 +126,8 @@ export default defineComponent({
this.editor.instance.update_secondary_color(color.r, color.g, color.b, color.a); this.editor.instance.update_secondary_color(color.r, color.g, color.b, color.a);
}, },
}, },
data() {
return {
primaryColor: { r: 0, g: 0, b: 0, a: 1 } as RGBA,
secondaryColor: { r: 255, g: 255, b: 255, a: 1 } as RGBA,
};
},
mounted() { mounted() {
this.editor.dispatcher.subscribeJsMessage(UpdateWorkingColors, (updateWorkingColors) => { this.editor.subscriptions.subscribeJsMessage(UpdateWorkingColors, (updateWorkingColors) => {
this.primaryColor = updateWorkingColors.primary.toRgba(); this.primaryColor = updateWorkingColors.primary.toRgba();
this.secondaryColor = updateWorkingColors.secondary.toRgba(); this.secondaryColor = updateWorkingColors.secondary.toRgba();
@@ -35,7 +35,7 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, PropType } from "vue"; import { defineComponent, PropType } from "vue";
import { IconName, IconStyle, icons, iconComponents } from "@/utilities/icons"; import { IconName, IconStyle, icons, iconComponents } from "@/utility-functions/icons";
import LayoutRow from "@/components/layout/LayoutRow.vue"; import LayoutRow from "@/components/layout/LayoutRow.vue";
@@ -99,9 +99,8 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, PropType } from "vue"; import { defineComponent, PropType } from "vue";
import { HintInfo, KeysGroup } from "@/dispatcher/js-messages"; import { IconName } from "@/utility-functions/icons";
import { HintInfo, KeysGroup } from "@/wasm-communication/messages";
import { IconName } from "@/utilities/icons";
import LayoutRow from "@/components/layout/LayoutRow.vue"; import LayoutRow from "@/components/layout/LayoutRow.vue";
import IconLabel from "@/components/widgets/labels/IconLabel.vue"; import IconLabel from "@/components/widgets/labels/IconLabel.vue";
@@ -121,7 +121,7 @@ export default defineComponent({
}, },
}, },
methods: { methods: {
handleResize() { resize() {
if (!this.$refs.rulerRef) return; if (!this.$refs.rulerRef) return;
const rulerElement = this.$refs.rulerRef as HTMLElement; const rulerElement = this.$refs.rulerRef as HTMLElement;
@@ -155,6 +155,10 @@ export default defineComponent({
window.addEventListener("pointerup", this.pointerUp); window.addEventListener("pointerup", this.pointerUp);
window.addEventListener("pointermove", this.pointerMove); window.addEventListener("pointermove", this.pointerMove);
}, },
unmounted() {
window.removeEventListener("pointerup", this.pointerUp);
window.removeEventListener("pointermove", this.pointerMove);
},
methods: { methods: {
trackLength(): number { trackLength(): number {
const track = this.$refs.scrollTrack as HTMLElement; const track = this.$refs.scrollTrack as HTMLElement;
@@ -75,7 +75,8 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, PropType } from "vue"; import { defineComponent, PropType } from "vue";
import { SeparatorDirection, SeparatorType } from "@/utilities/widgets"; export type SeparatorDirection = "Horizontal" | "Vertical";
export type SeparatorType = "Related" | "Unrelated" | "Section" | "List";
export default defineComponent({ export default defineComponent({
props: { props: {
@@ -44,7 +44,7 @@
<script lang="ts"> <script lang="ts">
import { defineComponent } from "vue"; import { defineComponent } from "vue";
import { HintData, UpdateInputHints } from "@/dispatcher/js-messages"; import { HintData, UpdateInputHints } from "@/wasm-communication/messages";
import LayoutRow from "@/components/layout/LayoutRow.vue"; import LayoutRow from "@/components/layout/LayoutRow.vue";
import UserInputLabel from "@/components/widgets/labels/UserInputLabel.vue"; import UserInputLabel from "@/components/widgets/labels/UserInputLabel.vue";
@@ -58,7 +58,7 @@ export default defineComponent({
}; };
}, },
mounted() { mounted() {
this.editor.dispatcher.subscribeJsMessage(UpdateInputHints, (updateInputHints) => { this.editor.subscriptions.subscribeJsMessage(UpdateInputHints, (updateInputHints) => {
this.hintData = updateInputHints.hint_data; this.hintData = updateInputHints.hint_data;
}); });
}, },
@@ -5,7 +5,7 @@
<MenuBarInput v-if="platform !== 'Mac'" /> <MenuBarInput v-if="platform !== 'Mac'" />
</LayoutRow> </LayoutRow>
<LayoutRow class="header-part"> <LayoutRow class="header-part">
<WindowTitle :text="`${activeDocumentDisplayName} - Graphite`" /> <WindowTitle :text="windowTitle" />
</LayoutRow> </LayoutRow>
<LayoutRow class="header-part"> <LayoutRow class="header-part">
<WindowButtonsWindows :maximized="maximized" v-if="platform === 'Windows' || platform === 'Linux'" /> <WindowButtonsWindows :maximized="maximized" v-if="platform === 'Windows' || platform === 'Linux'" />
@@ -56,8 +56,11 @@ export default defineComponent({
maximized: { type: Boolean as PropType<boolean>, required: true }, maximized: { type: Boolean as PropType<boolean>, required: true },
}, },
computed: { computed: {
activeDocumentDisplayName() { windowTitle(): string {
return this.portfolio.state.documents[this.portfolio.state.activeDocumentIndex].displayName; const activeDocumentIndex = this.portfolio.state.activeDocumentIndex;
const activeDocumentDisplayName = this.portfolio.state.documents[activeDocumentIndex]?.displayName || "";
return `${activeDocumentDisplayName}${activeDocumentDisplayName && " - "}Graphite`;
}, },
}, },
components: { components: {
@@ -168,7 +168,6 @@ const panelComponents = {
type PanelTypes = keyof typeof panelComponents; type PanelTypes = keyof typeof panelComponents;
export default defineComponent({ export default defineComponent({
inject: ["portfolio"],
props: { props: {
tabMinWidths: { type: Boolean as PropType<boolean>, default: false }, tabMinWidths: { type: Boolean as PropType<boolean>, default: false },
tabCloseButtons: { type: Boolean as PropType<boolean>, default: false }, tabCloseButtons: { type: Boolean as PropType<boolean>, default: false },
@@ -0,0 +1,26 @@
import { Editor } from "@/wasm-communication/editor";
// Gets metadata populated in the `process.env` namespace by code in `frontend/vue.config.js`.
// TODO: Move that functionality to a build.rs file so our web build system is more lightweight.
export function createBuildMetadataManager(editor: Editor): void {
// Release
const release = process.env.VUE_APP_RELEASE_SERIES;
// Timestamp
const date = new Date(process.env.VUE_APP_COMMIT_DATE || "");
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;
const timestamp = `${dateString} ${timeString} ${timezoneNameString}`;
// Hash
const hash = (process.env.VUE_APP_COMMIT_HASH || "").substring(0, 8);
// Branch
const branch = process.env.VUE_APP_COMMIT_BRANCH;
editor.instance.populate_build_metadata(release || "", timestamp, hash, branch || "");
}
+10
View File
@@ -0,0 +1,10 @@
import { 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.copy_text);
});
}
+9
View File
@@ -0,0 +1,9 @@
import { 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");
});
}
@@ -1,16 +1,16 @@
import { DialogState } from "@/state/dialog"; import { DialogState } from "@/state-providers/dialog";
import { FullscreenState } from "@/state/fullscreen"; import { FullscreenState } from "@/state-providers/fullscreen";
import { PortfolioState } from "@/state/portfolio"; import { PortfolioState } from "@/state-providers/portfolio";
import { EditorState } from "@/state/wasm-loader"; import { makeKeyboardModifiersBitfield, textInputCleanup, getLatinKey } from "@/utility-functions/keyboard-entry";
import { Editor } from "@/wasm-communication/editor";
type EventName = keyof HTMLElementEventMap | keyof WindowEventHandlersEventMap | "modifyinputfield"; type EventName = keyof HTMLElementEventMap | keyof WindowEventHandlersEventMap | "modifyinputfield";
interface EventListenerTarget { type EventListenerTarget = {
addEventListener: typeof window.addEventListener; addEventListener: typeof window.addEventListener;
removeEventListener: typeof window.removeEventListener; removeEventListener: typeof window.removeEventListener;
} };
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type export function createInputManager(editor: Editor, container: HTMLElement, dialog: DialogState, document: PortfolioState, fullscreen: FullscreenState): () => void {
export function createInputManager(editor: EditorState, container: HTMLElement, dialog: DialogState, document: PortfolioState, fullscreen: FullscreenState) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const listeners: { target: EventListenerTarget; eventName: EventName; action: (event: any) => void; options?: boolean | AddEventListenerOptions }[] = [ const listeners: { target: EventListenerTarget; eventName: EventName; action: (event: any) => void; options?: boolean | AddEventListenerOptions }[] = [
{ target: window, eventName: "resize", action: (): void => onWindowResize(container) }, { target: window, eventName: "resize", action: (): void => onWindowResize(container) },
@@ -34,7 +34,7 @@ export function createInputManager(editor: EditorState, container: HTMLElement,
// Keyboard events // Keyboard events
const shouldRedirectKeyboardEventToBackend = (e: KeyboardEvent): boolean => { function shouldRedirectKeyboardEventToBackend(e: KeyboardEvent): boolean {
// Don't redirect when a modal is covering the workspace // Don't redirect when a modal is covering the workspace
if (dialog.dialogIsVisible()) return false; if (dialog.dialogIsVisible()) return false;
@@ -76,15 +76,15 @@ export function createInputManager(editor: EditorState, container: HTMLElement,
// Redirect to the backend // Redirect to the backend
return true; return true;
}; }
const onKeyDown = (e: KeyboardEvent): void => { function onKeyDown(e: KeyboardEvent): void {
const key = getLatinKey(e); const key = getLatinKey(e);
if (!key) return; if (!key) return;
if (shouldRedirectKeyboardEventToBackend(e)) { if (shouldRedirectKeyboardEventToBackend(e)) {
e.preventDefault(); e.preventDefault();
const modifiers = makeModifiersBitfield(e); const modifiers = makeKeyboardModifiersBitfield(e);
editor.instance.on_key_down(key, modifiers); editor.instance.on_key_down(key, modifiers);
return; return;
} }
@@ -92,23 +92,23 @@ export function createInputManager(editor: EditorState, container: HTMLElement,
if (dialog.dialogIsVisible()) { if (dialog.dialogIsVisible()) {
if (key === "escape") dialog.dismissDialog(); if (key === "escape") dialog.dismissDialog();
} }
}; }
const onKeyUp = (e: KeyboardEvent): void => { function onKeyUp(e: KeyboardEvent): void {
const key = getLatinKey(e); const key = getLatinKey(e);
if (!key) return; if (!key) return;
if (shouldRedirectKeyboardEventToBackend(e)) { if (shouldRedirectKeyboardEventToBackend(e)) {
e.preventDefault(); e.preventDefault();
const modifiers = makeModifiersBitfield(e); const modifiers = makeKeyboardModifiersBitfield(e);
editor.instance.on_key_up(key, modifiers); editor.instance.on_key_up(key, modifiers);
} }
}; }
// Pointer events // 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 // 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
const onPointerMove = (e: PointerEvent): void => { function onPointerMove(e: PointerEvent): void {
if (!e.buttons) viewportPointerInteractionOngoing = false; 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 // 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
@@ -118,11 +118,11 @@ export function createInputManager(editor: EditorState, container: HTMLElement,
const inFloatingMenu = e.target instanceof Element && e.target.closest("[data-floating-menu-content]"); const inFloatingMenu = e.target instanceof Element && e.target.closest("[data-floating-menu-content]");
if (!viewportPointerInteractionOngoing && inFloatingMenu) return; if (!viewportPointerInteractionOngoing && inFloatingMenu) return;
const modifiers = makeModifiersBitfield(e); const modifiers = makeKeyboardModifiersBitfield(e);
editor.instance.on_mouse_move(e.clientX, e.clientY, e.buttons, modifiers); editor.instance.on_mouse_move(e.clientX, e.clientY, e.buttons, modifiers);
}; }
const onPointerDown = (e: PointerEvent): void => { function onPointerDown(e: PointerEvent): void {
const { target } = e; const { target } = e;
const inCanvas = target instanceof Element && target.closest("[data-canvas]"); const inCanvas = target instanceof Element && target.closest("[data-canvas]");
const inDialog = target instanceof Element && target.closest("[data-dialog-modal] [data-floating-menu-content]"); const inDialog = target instanceof Element && target.closest("[data-dialog-modal] [data-floating-menu-content]");
@@ -140,38 +140,38 @@ export function createInputManager(editor: EditorState, container: HTMLElement,
} }
if (viewportPointerInteractionOngoing) { if (viewportPointerInteractionOngoing) {
const modifiers = makeModifiersBitfield(e); const modifiers = makeKeyboardModifiersBitfield(e);
editor.instance.on_mouse_down(e.clientX, e.clientY, e.buttons, modifiers); editor.instance.on_mouse_down(e.clientX, e.clientY, e.buttons, modifiers);
} }
}; }
const onPointerUp = (e: PointerEvent): void => { function onPointerUp(e: PointerEvent): void {
if (!e.buttons) viewportPointerInteractionOngoing = false; if (!e.buttons) viewportPointerInteractionOngoing = false;
if (!textInput) { if (!textInput) {
const modifiers = makeModifiersBitfield(e); const modifiers = makeKeyboardModifiersBitfield(e);
editor.instance.on_mouse_up(e.clientX, e.clientY, e.buttons, modifiers); editor.instance.on_mouse_up(e.clientX, e.clientY, e.buttons, modifiers);
} }
}; }
const onDoubleClick = (e: PointerEvent): void => { function onDoubleClick(e: PointerEvent): void {
if (!e.buttons) viewportPointerInteractionOngoing = false; if (!e.buttons) viewportPointerInteractionOngoing = false;
if (!textInput) { if (!textInput) {
const modifiers = makeModifiersBitfield(e); const modifiers = makeKeyboardModifiersBitfield(e);
editor.instance.on_double_click(e.clientX, e.clientY, e.buttons, modifiers); editor.instance.on_double_click(e.clientX, e.clientY, e.buttons, modifiers);
} }
}; }
// Mouse events // Mouse events
const onMouseDown = (e: MouseEvent): void => { 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) // 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 // 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(); if (e.button === 1) e.preventDefault();
}; }
const onMouseScroll = (e: WheelEvent): void => { function onMouseScroll(e: WheelEvent): void {
const { target } = e; const { target } = e;
const inCanvas = target instanceof Element && target.closest("[data-canvas]"); const inCanvas = target instanceof Element && target.closest("[data-canvas]");
@@ -185,18 +185,18 @@ export function createInputManager(editor: EditorState, container: HTMLElement,
if (inCanvas) { if (inCanvas) {
e.preventDefault(); e.preventDefault();
const modifiers = makeModifiersBitfield(e); const modifiers = makeKeyboardModifiersBitfield(e);
editor.instance.on_mouse_scroll(e.clientX, e.clientY, e.buttons, e.deltaX, e.deltaY, e.deltaZ, modifiers); editor.instance.on_mouse_scroll(e.clientX, e.clientY, e.buttons, e.deltaX, e.deltaY, e.deltaZ, modifiers);
} }
}; }
const onModifyInputField = (e: CustomEvent): void => { function onModifyInputField(e: CustomEvent): void {
textInput = e.detail; textInput = e.detail;
}; }
// Window events // Window events
const onWindowResize = (container: HTMLElement): void => { function onWindowResize(container: HTMLElement): void {
const viewports = Array.from(container.querySelectorAll("[data-canvas]")); const viewports = Array.from(container.querySelectorAll("[data-canvas]"));
const boundsOfViewports = viewports.map((canvas) => { const boundsOfViewports = viewports.map((canvas) => {
const bounds = canvas.getBoundingClientRect(); const bounds = canvas.getBoundingClientRect();
@@ -207,9 +207,9 @@ export function createInputManager(editor: EditorState, container: HTMLElement,
const data = Float64Array.from(flattened); const data = Float64Array.from(flattened);
if (boundsOfViewports.length > 0) editor.instance.bounds_of_viewports(data); if (boundsOfViewports.length > 0) editor.instance.bounds_of_viewports(data);
}; }
const onBeforeUnload = (e: BeforeUnloadEvent): void => { function onBeforeUnload(e: BeforeUnloadEvent): void {
const activeDocument = document.state.documents[document.state.activeDocumentIndex]; const activeDocument = document.state.documents[document.state.activeDocumentIndex];
if (!activeDocument.is_saved) editor.instance.trigger_auto_save(activeDocument.id); if (!activeDocument.is_saved) editor.instance.trigger_auto_save(activeDocument.id);
@@ -224,9 +224,9 @@ export function createInputManager(editor: EditorState, container: HTMLElement,
e.returnValue = "Unsaved work will be lost if the web browser tab is closed. Close anyway?"; e.returnValue = "Unsaved work will be lost if the web browser tab is closed. Close anyway?";
e.preventDefault(); e.preventDefault();
} }
}; }
const onPaste = (e: ClipboardEvent): void => { function onPaste(e: ClipboardEvent): void {
const dataTransfer = e.clipboardData; const dataTransfer = e.clipboardData;
if (!dataTransfer) return; if (!dataTransfer) return;
e.preventDefault(); e.preventDefault();
@@ -249,394 +249,26 @@ export function createInputManager(editor: EditorState, container: HTMLElement,
}); });
} }
}); });
}; }
// Event bindings // Event bindings
const addListeners = (): void => { function bindListeners(): void {
// Add event bindings for the lifetime of the application
listeners.forEach(({ target, eventName, action, options }) => target.addEventListener(eventName, action, options)); 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));
}
const removeListeners = (): void => { // Initialization
listeners.forEach(({ target, eventName, action }) => target.removeEventListener(eventName, action));
};
// Run on creation // Bind the event listeners
addListeners(); bindListeners();
// Resize on creation
onWindowResize(container); onWindowResize(container);
return { removeListeners }; // Return the destructor
return unbindListeners;
} }
export type InputManager = ReturnType<typeof createInputManager>;
export function makeModifiersBitfield(e: WheelEvent | PointerEvent | KeyboardEvent): number {
return Number(e.ctrlKey) | (Number(e.shiftKey) << 1) | (Number(e.altKey) << 2);
}
// 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 is a naive, temporary solution to allow non-Latin keyboards to fall back on the physical QWERTY layout
function getLatinKey(e: KeyboardEvent): string | null {
const key = e.key.toLowerCase();
const isPrintable = isKeyPrintable(e.key);
// Control (non-printable) characters are handled normally
if (!isPrintable) return key;
// These non-Latin characters should fall back to the Latin equivalent at the key location
const LAST_LATIN_UNICODE_CHAR = 0x024f;
if (key.length > 1 || key.charCodeAt(0) > LAST_LATIN_UNICODE_CHAR) return keyCodeToKey(e.code);
// Otherwise, ths is a printable Latin character
return e.key.toLowerCase();
}
function keyCodeToKey(code: string): string | null {
// Letters
if (code.match(/^Key[A-Z]$/)) return code.replace("Key", "").toLowerCase();
// Numbers
if (code.match(/^Digit[0-9]$/)) return code.replace("Digit", "");
if (code.match(/^Numpad[0-9]$/)) return code.replace("Numpad", "");
// Function keys
if (code.match(/^F[1-9]|F1[0-9]|F20$/)) return code.replace("F", "").toLowerCase();
// Other characters
if (MAPPING[code]) return MAPPING[code];
return null;
}
const MAPPING: Record<string, string> = {
BracketLeft: "[",
BracketRight: "]",
Backslash: "\\",
Slash: "/",
Period: ".",
Comma: ",",
Equal: "=",
Minus: "-",
Quote: "'",
Semicolon: ";",
NumpadEqual: "=",
NumpadDivide: "/",
NumpadMultiply: "*",
NumpadSubtract: "-",
NumpadAdd: "+",
NumpadDecimal: ".",
};
function isKeyPrintable(key: string): boolean {
return !ALL_PRINTABLE_KEYS.has(key);
}
const ALL_PRINTABLE_KEYS = new Set([
// Modifier
"Alt",
"AltGraph",
"CapsLock",
"Control",
"Fn",
"FnLock",
"Meta",
"NumLock",
"ScrollLock",
"Shift",
"Symbol",
"SymbolLock",
// Legacy modifier
"Hyper",
"Super",
// White space
"Enter",
"Tab",
// Navigation
"ArrowDown",
"ArrowLeft",
"ArrowRight",
"ArrowUp",
"End",
"Home",
"PageDown",
"PageUp",
// Editing
"Backspace",
"Clear",
"Copy",
"CrSel",
"Cut",
"Delete",
"EraseEof",
"ExSel",
"Insert",
"Paste",
"Redo",
"Undo",
// UI
"Accept",
"Again",
"Attn",
"Cancel",
"ContextMenu",
"Escape",
"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",
"Soft1",
"Soft2",
"Soft3",
"Soft4",
// Multimedia
"ChannelDown",
"ChannelUp",
"Close",
"MailForward",
"MailReply",
"MailSend",
"MediaClose",
"MediaFastForward",
"MediaPause",
"MediaPlay",
"MediaPlayPause",
"MediaRecord",
"MediaRewind",
"MediaStop",
"MediaTrackNext",
"MediaTrackPrevious",
"New",
"Open",
"Print",
"Save",
"SpellCheck",
// Multimedia numpad
"Key11",
"Key12",
// 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",
]);
@@ -1,12 +1,13 @@
import { DisplayDialogPanic, WidgetLayout } from "@/dispatcher/js-messages"; import { TextButtonWidget } from "@/components/widgets/buttons/TextButton";
import { DialogState } from "@/state/dialog"; import { DialogState } from "@/state-providers/dialog";
import { EditorState } from "@/state/wasm-loader"; import { IconName } from "@/utility-functions/icons";
import { stripIndents } from "@/utilities/strip-indents"; import { stripIndents } from "@/utility-functions/strip-indents";
import { TextButtonWidget } from "@/utilities/widgets"; import { Editor } from "@/wasm-communication/editor";
import { DisplayDialogPanic, WidgetLayout } from "@/wasm-communication/messages";
export function initErrorHandling(editor: EditorState, dialogState: DialogState): void { export function createPanicManager(editor: Editor, dialogState: DialogState): void {
// Code panic dialog and console error // Code panic dialog and console error
editor.dispatcher.subscribeJsMessage(DisplayDialogPanic, (displayDialogPanic) => { editor.subscriptions.subscribeJsMessage(DisplayDialogPanic, (displayDialogPanic) => {
// `Error.stackTraceLimit` is only available in V8/Chromium // `Error.stackTraceLimit` is only available in V8/Chromium
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
(Error as any).stackTraceLimit = Infinity; (Error as any).stackTraceLimit = Infinity;
@@ -16,11 +17,12 @@ export function initErrorHandling(editor: EditorState, dialogState: DialogState)
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error(panicDetails); console.error(panicDetails);
preparePanicDialog(dialogState, displayDialogPanic.title, displayDialogPanic.description, panicDetails); const panicDialog = preparePanicDialog(displayDialogPanic.title, displayDialogPanic.description, panicDetails);
dialogState.createPanicDialog(...panicDialog);
}); });
} }
function preparePanicDialog(dialogState: DialogState, title: string, details: string, panicDetails: string): void { function preparePanicDialog(title: string, details: string, panicDetails: string): [IconName, WidgetLayout, TextButtonWidget[]] {
const widgets: WidgetLayout = { const widgets: WidgetLayout = {
layout: [ layout: [
{ {
@@ -65,7 +67,7 @@ function preparePanicDialog(dialogState: DialogState, title: string, details: st
}; };
const jsCallbackBasedButtons = [reloadButton, copyErrorLogButton, reportOnGithubButton]; const jsCallbackBasedButtons = [reloadButton, copyErrorLogButton, reportOnGithubButton];
dialogState.createPanicDialog(widgets, jsCallbackBasedButtons); return ["Warning", widgets, jsCallbackBasedButtons];
} }
function githubUrl(panicDetails: string): string { function githubUrl(panicDetails: string): string {
+91
View File
@@ -0,0 +1,91 @@
import { PortfolioState } from "@/state-providers/portfolio";
import { Editor, getWasmInstance } from "@/wasm-communication/editor";
import { TriggerIndexedDbWriteDocument, TriggerIndexedDbRemoveDocument } from "@/wasm-communication/messages";
const GRAPHITE_INDEXED_DB_VERSION = 2;
const GRAPHITE_INDEXED_DB_NAME = "graphite-indexed-db";
const GRAPHITE_AUTO_SAVE_STORE = "auto-save-documents";
const GRAPHITE_AUTO_SAVE_ORDER_KEY = "auto-save-documents-order";
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export async function createPersistenceManager(editor: Editor, portfolio: PortfolioState): Promise<() => void> {
function storeDocumentOrder(): void {
// Make sure to store as string since JSON does not play nice with BigInt
const documentOrder = portfolio.state.documents.map((doc) => doc.id.toString());
window.localStorage.setItem(GRAPHITE_AUTO_SAVE_ORDER_KEY, JSON.stringify(documentOrder));
}
async function removeDocument(id: string): Promise<void> {
const db = await databaseConnection;
const transaction = db.transaction(GRAPHITE_AUTO_SAVE_STORE, "readwrite");
transaction.objectStore(GRAPHITE_AUTO_SAVE_STORE).delete(id);
storeDocumentOrder();
}
async function closeDatabaseConnection(): Promise<void> {
const db = await databaseConnection;
db.close();
}
// Subscribe to process backend events
editor.subscriptions.subscribeJsMessage(TriggerIndexedDbWriteDocument, async (autoSaveDocument) => {
const db = await databaseConnection;
const transaction = db.transaction(GRAPHITE_AUTO_SAVE_STORE, "readwrite");
transaction.objectStore(GRAPHITE_AUTO_SAVE_STORE).put(autoSaveDocument);
storeDocumentOrder();
});
editor.subscriptions.subscribeJsMessage(TriggerIndexedDbRemoveDocument, async (removeAutoSaveDocument) => {
removeDocument(removeAutoSaveDocument.document_id);
});
// Open the IndexedDB database connection and save it to this variable, which is a promise that resolves once the connection is open
const databaseConnection: Promise<IDBDatabase> = new Promise((resolve) => {
const dbOpenRequest = indexedDB.open(GRAPHITE_INDEXED_DB_NAME, GRAPHITE_INDEXED_DB_VERSION);
dbOpenRequest.onupgradeneeded = (): void => {
const db = dbOpenRequest.result;
// Wipes out all auto-save data on upgrade
if (db.objectStoreNames.contains(GRAPHITE_AUTO_SAVE_STORE)) {
db.deleteObjectStore(GRAPHITE_AUTO_SAVE_STORE);
}
db.createObjectStore(GRAPHITE_AUTO_SAVE_STORE, { keyPath: "details.id" });
};
dbOpenRequest.onerror = (): void => {
// eslint-disable-next-line no-console
console.error("Graphite IndexedDb error:", dbOpenRequest.error);
};
dbOpenRequest.onsuccess = (): void => {
resolve(dbOpenRequest.result);
};
});
// Open auto-save documents
const db = await databaseConnection;
const transaction = db.transaction(GRAPHITE_AUTO_SAVE_STORE, "readonly");
const request = transaction.objectStore(GRAPHITE_AUTO_SAVE_STORE).getAll();
await new Promise((resolve): void => {
request.onsuccess = (): void => {
const previouslySavedDocuments: TriggerIndexedDbWriteDocument[] = request.result;
const documentOrder: string[] = JSON.parse(window.localStorage.getItem(GRAPHITE_AUTO_SAVE_ORDER_KEY) || "[]");
const orderedSavedDocuments = documentOrder
.map((id) => previouslySavedDocuments.find((autoSave) => autoSave.details.id === id))
.filter((x) => x !== undefined) as TriggerIndexedDbWriteDocument[];
const currentDocumentVersion = getWasmInstance().graphite_version();
orderedSavedDocuments.forEach((doc: TriggerIndexedDbWriteDocument) => {
if (doc.version === currentDocumentVersion) {
editor.instance.open_auto_saved_document(BigInt(doc.details.id), doc.details.name, doc.details.is_saved, doc.document);
} else {
removeDocument(doc.details.id);
}
});
resolve(undefined);
};
});
return closeDatabaseConnection;
}
-90
View File
@@ -1,90 +0,0 @@
import { TriggerIndexedDbWriteDocument, TriggerIndexedDbRemoveDocument } from "@/dispatcher/js-messages";
import { PortfolioState } from "@/state/portfolio";
import { EditorState, getWasmInstance } from "@/state/wasm-loader";
const GRAPHITE_INDEXED_DB_VERSION = 2;
const GRAPHITE_INDEXED_DB_NAME = "graphite-indexed-db";
const GRAPHITE_AUTO_SAVE_STORE = "auto-save-documents";
const GRAPHITE_AUTO_SAVE_ORDER_KEY = "auto-save-documents-order";
const databaseConnection: Promise<IDBDatabase> = new Promise((resolve) => {
const dbOpenRequest = indexedDB.open(GRAPHITE_INDEXED_DB_NAME, GRAPHITE_INDEXED_DB_VERSION);
dbOpenRequest.onupgradeneeded = (): void => {
const db = dbOpenRequest.result;
// Wipes out all auto-save data on upgrade
if (db.objectStoreNames.contains(GRAPHITE_AUTO_SAVE_STORE)) {
db.deleteObjectStore(GRAPHITE_AUTO_SAVE_STORE);
}
db.createObjectStore(GRAPHITE_AUTO_SAVE_STORE, { keyPath: "details.id" });
};
dbOpenRequest.onerror = (): void => {
// eslint-disable-next-line no-console
console.error("Graphite IndexedDb error:", dbOpenRequest.error);
};
dbOpenRequest.onsuccess = (): void => {
resolve(dbOpenRequest.result);
};
});
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export function createAutoSaveManager(editor: EditorState, portfolio: PortfolioState) {
const openAutoSavedDocuments = async (): Promise<void> => {
const db = await databaseConnection;
const transaction = db.transaction(GRAPHITE_AUTO_SAVE_STORE, "readonly");
const request = transaction.objectStore(GRAPHITE_AUTO_SAVE_STORE).getAll();
return new Promise((resolve) => {
request.onsuccess = (): void => {
const previouslySavedDocuments: TriggerIndexedDbWriteDocument[] = request.result;
const documentOrder: string[] = JSON.parse(window.localStorage.getItem(GRAPHITE_AUTO_SAVE_ORDER_KEY) || "[]");
const orderedSavedDocuments = documentOrder
.map((id) => previouslySavedDocuments.find((autoSave) => autoSave.details.id === id))
.filter((x) => x !== undefined) as TriggerIndexedDbWriteDocument[];
const currentDocumentVersion = getWasmInstance().graphite_version();
orderedSavedDocuments.forEach((doc: TriggerIndexedDbWriteDocument) => {
if (doc.version === currentDocumentVersion) {
editor.instance.open_auto_saved_document(BigInt(doc.details.id), doc.details.name, doc.details.is_saved, doc.document);
} else {
removeDocument(doc.details.id);
}
});
resolve(undefined);
};
});
};
const storeDocumentOrder = (): void => {
// Make sure to store as string since JSON does not play nice with BigInt
const documentOrder = portfolio.state.documents.map((doc) => doc.id.toString());
window.localStorage.setItem(GRAPHITE_AUTO_SAVE_ORDER_KEY, JSON.stringify(documentOrder));
};
const removeDocument = async (id: string): Promise<void> => {
const db = await databaseConnection;
const transaction = db.transaction(GRAPHITE_AUTO_SAVE_STORE, "readwrite");
transaction.objectStore(GRAPHITE_AUTO_SAVE_STORE).delete(id);
storeDocumentOrder();
};
editor.dispatcher.subscribeJsMessage(TriggerIndexedDbWriteDocument, async (autoSaveDocument) => {
const db = await databaseConnection;
const transaction = db.transaction(GRAPHITE_AUTO_SAVE_STORE, "readwrite");
transaction.objectStore(GRAPHITE_AUTO_SAVE_STORE).put(autoSaveDocument);
storeDocumentOrder();
});
editor.dispatcher.subscribeJsMessage(TriggerIndexedDbRemoveDocument, async (removeAutoSaveDocument) => {
removeDocument(removeAutoSaveDocument.document_id);
});
// On creation
openAutoSavedDocuments();
return { openAutoSavedDocuments };
}
+2 -3
View File
@@ -4,13 +4,12 @@
import "reflect-metadata"; import "reflect-metadata";
import { createApp } from "vue"; import { createApp } from "vue";
import "@/lifetime/errors"; import { initWasm } from "@/wasm-communication/editor";
import { initWasm } from "@/state/wasm-loader";
import App from "@/App.vue"; import App from "@/App.vue";
(async (): Promise<void> => { (async (): Promise<void> => {
// Initialize the WASM editor backend // Initialize the WASM module for the editor backend
await initWasm(); await initWasm();
// Initialize the Vue application // Initialize the Vue application
+53
View File
@@ -0,0 +1,53 @@
import { reactive, readonly } from "vue";
import { TextButtonWidget } from "@/components/widgets/buttons/TextButton";
import { IconName } from "@/utility-functions/icons";
import { Editor } from "@/wasm-communication/editor";
import { defaultWidgetLayout, DisplayDialog, DisplayDialogDismiss, UpdateDialogDetails, WidgetLayout } from "@/wasm-communication/messages";
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export function createDialogState(editor: Editor) {
const state = reactive({
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 {
state.visible = false;
}
function dialogIsVisible(): boolean {
return state.visible;
}
// 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 {
state.visible = true;
state.icon = icon;
state.widgets = widgets;
state.jsCallbackBasedButtons = jsCallbackBasedButtons;
}
// Subscribe to process backend events
editor.subscriptions.subscribeJsMessage(DisplayDialog, (displayDialog) => {
state.visible = true;
state.icon = displayDialog.icon;
});
editor.subscriptions.subscribeJsMessage(UpdateDialogDetails, (updateDialogDetails) => {
state.widgets = updateDialogDetails;
state.jsCallbackBasedButtons = undefined;
});
editor.subscriptions.subscribeJsMessage(DisplayDialogDismiss, dismissDialog);
return {
state: readonly(state) as typeof state,
dismissDialog,
dialogIsVisible,
createPanicDialog,
};
}
export type DialogState = ReturnType<typeof createDialogState>;
+95
View File
@@ -0,0 +1,95 @@
import { reactive, readonly } from "vue";
import { Editor } from "@/wasm-communication/editor";
import { TriggerFontLoad, TriggerFontLoadDefault } from "@/wasm-communication/messages";
const DEFAULT_FONT = "Merriweather";
const DEFAULT_FONT_STYLE = "Normal (400)";
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export function createFontsState(editor: Editor) {
const state = reactive({
fontNames: [] as string[],
});
async function getFontStyles(fontFamily: string): Promise<string[]> {
const font = (await fontList).find((value) => value.family === fontFamily);
return font?.variants || [];
}
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(TriggerFontLoadDefault, async (): Promise<void> => {
const fontFileUrl = await getFontFileUrl(DEFAULT_FONT, DEFAULT_FONT_STYLE);
if (!fontFileUrl) return;
const response = await fetch(fontFileUrl);
const responseBuffer = await response.arrayBuffer();
editor.instance.on_font_load(fontFileUrl, new Uint8Array(responseBuffer), true);
});
editor.subscriptions.subscribeJsMessage(TriggerFontLoad, async (triggerFontLoad) => {
const response = await (await fetch(triggerFontLoad.font_file_url)).arrayBuffer();
editor.instance.on_font_load(triggerFontLoad.font_file_url, new Uint8Array(response), false);
});
const fontList: Promise<{ family: string; variants: string[]; files: Map<string, string> }[]> = new Promise((resolve) => {
fetch(fontListAPI)
.then((response) => response.json())
.then((fontListResponse) => {
const fontListData = fontListResponse.items as { family: string; variants: string[]; files: { [name: 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 };
});
state.fontNames = result.map((value) => value.family);
resolve(result);
});
});
return {
state: readonly(state) as typeof state,
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"],
]);
@@ -7,16 +7,12 @@ export function createFullscreenState() {
keyboardLocked: false, keyboardLocked: false,
}); });
const fullscreenModeChanged = (): void => { function fullscreenModeChanged(): void {
state.windowFullscreen = Boolean(document.fullscreenElement); state.windowFullscreen = Boolean(document.fullscreenElement);
if (!state.windowFullscreen) state.keyboardLocked = false; if (!state.windowFullscreen) state.keyboardLocked = false;
}; }
// Experimental Keyboard API: https://developer.mozilla.org/en-US/docs/Web/API/Navigator/keyboard async function enterFullscreen(): Promise<void> {
// 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;
const enterFullscreen = async (): Promise<void> => {
await document.documentElement.requestFullscreen(); await document.documentElement.requestFullscreen();
if (keyboardLockApiSupported) { if (keyboardLockApiSupported) {
@@ -24,25 +20,28 @@ export function createFullscreenState() {
await (navigator as any).keyboard.lock(["ControlLeft", "ControlRight"]); await (navigator as any).keyboard.lock(["ControlLeft", "ControlRight"]);
state.keyboardLocked = true; state.keyboardLocked = true;
} }
}; }
// eslint-disable-next-line class-methods-use-this async function exitFullscreen(): Promise<void> {
const exitFullscreen = async (): Promise<void> => {
await document.exitFullscreen(); await document.exitFullscreen();
}; }
const toggleFullscreen = async (): Promise<void> => { async function toggleFullscreen(): Promise<void> {
if (state.windowFullscreen) await exitFullscreen(); if (state.windowFullscreen) await exitFullscreen();
else await enterFullscreen(); else await enterFullscreen();
}; }
// 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 { return {
state: readonly(state), state: readonly(state) as typeof state,
keyboardLockApiSupported, fullscreenModeChanged,
enterFullscreen, enterFullscreen,
exitFullscreen, exitFullscreen,
toggleFullscreen, toggleFullscreen,
fullscreenModeChanged, keyboardLockApiSupported,
}; };
} }
export type FullscreenState = ReturnType<typeof createFullscreenState>; export type FullscreenState = ReturnType<typeof createFullscreenState>;
@@ -1,12 +1,12 @@
/* eslint-disable max-classes-per-file */ /* eslint-disable max-classes-per-file */
import { reactive, readonly } from "vue"; import { reactive, readonly } from "vue";
import { TriggerFileDownload, TriggerRasterDownload, FrontendDocumentDetails, TriggerFileUpload, UpdateActiveDocument, UpdateOpenDocumentsList } from "@/dispatcher/js-messages"; import { download, downloadBlob, upload } from "@/utility-functions/files";
import { EditorState } from "@/state/wasm-loader"; import { Editor } from "@/wasm-communication/editor";
import { download, downloadBlob, upload } from "@/utilities/files"; import { TriggerFileDownload, TriggerRasterDownload, FrontendDocumentDetails, TriggerFileUpload, UpdateActiveDocument, UpdateOpenDocumentsList } from "@/wasm-communication/messages";
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export function createPortfolioState(editor: EditorState) { export function createPortfolioState(editor: Editor) {
const state = reactive({ const state = reactive({
unsaved: false, unsaved: false,
documents: [] as FrontendDocumentDetails[], documents: [] as FrontendDocumentDetails[],
@@ -14,39 +14,35 @@ export function createPortfolioState(editor: EditorState) {
}); });
// Set up message subscriptions on creation // Set up message subscriptions on creation
editor.dispatcher.subscribeJsMessage(UpdateOpenDocumentsList, (updateOpenDocumentList) => { editor.subscriptions.subscribeJsMessage(UpdateOpenDocumentsList, (updateOpenDocumentList) => {
state.documents = updateOpenDocumentList.open_documents; state.documents = updateOpenDocumentList.open_documents;
}); });
editor.subscriptions.subscribeJsMessage(UpdateActiveDocument, (updateActiveDocument) => {
editor.dispatcher.subscribeJsMessage(UpdateActiveDocument, (updateActiveDocument) => {
// Assume we receive a correct document id // Assume we receive a correct document id
const activeId = state.documents.findIndex((doc) => doc.id === updateActiveDocument.document_id); const activeId = state.documents.findIndex((doc) => doc.id === updateActiveDocument.document_id);
state.activeDocumentIndex = activeId; state.activeDocumentIndex = activeId;
}); });
editor.subscriptions.subscribeJsMessage(TriggerFileUpload, async () => {
editor.dispatcher.subscribeJsMessage(TriggerFileUpload, async () => { const extension = editor.raw.file_save_suffix();
const extension = editor.rawWasm.file_save_suffix();
const data = await upload(extension); const data = await upload(extension);
editor.instance.open_document_file(data.filename, data.content); editor.instance.open_document_file(data.filename, data.content);
}); });
editor.subscriptions.subscribeJsMessage(TriggerFileDownload, (triggerFileDownload) => {
editor.dispatcher.subscribeJsMessage(TriggerFileDownload, (triggerFileDownload) => {
download(triggerFileDownload.name, triggerFileDownload.document); download(triggerFileDownload.name, triggerFileDownload.document);
}); });
editor.subscriptions.subscribeJsMessage(TriggerRasterDownload, (triggerRasterDownload) => {
editor.dispatcher.subscribeJsMessage(TriggerRasterDownload, (triggerRasterDownload) => {
// A canvas to render our svg to in order to get a raster image // A canvas to render our svg to in order to get a raster image
// https://stackoverflow.com/questions/3975499/convert-svg-to-image-jpeg-png-etc-in-the-browser // https://stackoverflow.com/questions/3975499/convert-svg-to-image-jpeg-png-etc-in-the-browser
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
canvas.width = triggerRasterDownload.size.x; canvas.width = triggerRasterDownload.size.x;
canvas.height = triggerRasterDownload.size.y; canvas.height = triggerRasterDownload.size.y;
const ctx = canvas.getContext("2d"); const context = canvas.getContext("2d");
if (!ctx) return; if (!context) return;
// Fill the canvas with white if jpeg (does not support transparency and defaults to black) // Fill the canvas with white if jpeg (does not support transparency and defaults to black)
if (triggerRasterDownload.mime.endsWith("jpg")) { if (triggerRasterDownload.mime.endsWith("jpg")) {
ctx.fillStyle = "white"; context.fillStyle = "white";
ctx.fillRect(0, 0, triggerRasterDownload.size.x, triggerRasterDownload.size.y); context.fillRect(0, 0, triggerRasterDownload.size.x, triggerRasterDownload.size.y);
} }
// Create a blob url for our svg // Create a blob url for our svg
@@ -55,7 +51,7 @@ export function createPortfolioState(editor: EditorState) {
const url = URL.createObjectURL(svgBlob); const url = URL.createObjectURL(svgBlob);
img.onload = (): void => { img.onload = (): void => {
// Draw our svg to the canvas // Draw our svg to the canvas
ctx?.drawImage(img, 0, 0, triggerRasterDownload.size.x, triggerRasterDownload.size.y); context?.drawImage(img, 0, 0, triggerRasterDownload.size.x, triggerRasterDownload.size.y);
// Convert the canvas to an image of the correct mime // Convert the canvas to an image of the correct mime
const imgURI = canvas.toDataURL(triggerRasterDownload.mime); const imgURI = canvas.toDataURL(triggerRasterDownload.mime);
@@ -68,12 +64,8 @@ export function createPortfolioState(editor: EditorState) {
img.src = url; img.src = url;
}); });
// TODO(mfish33): Replace with initialization system Issue:#524
// Get the initial documents
editor.instance.get_open_documents_list();
return { return {
state: readonly(state), state: readonly(state) as typeof state,
}; };
} }
export type PortfolioState = ReturnType<typeof createPortfolioState>; export type PortfolioState = ReturnType<typeof createPortfolioState>;
@@ -1,22 +1,22 @@
/* eslint-disable max-classes-per-file */ /* eslint-disable max-classes-per-file */
import { reactive, readonly } from "vue"; import { reactive, readonly } from "vue";
import { UpdateNodeGraphVisibility } from "@/dispatcher/js-messages"; import { Editor } from "@/wasm-communication/editor";
import { EditorState } from "@/state/wasm-loader"; import { UpdateNodeGraphVisibility } from "@/wasm-communication/messages";
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export function createWorkspaceState(editor: EditorState) { export function createWorkspaceState(editor: Editor) {
const state = reactive({ const state = reactive({
nodeGraphVisible: false, nodeGraphVisible: false,
}); });
// Set up message subscriptions on creation // Set up message subscriptions on creation
editor.dispatcher.subscribeJsMessage(UpdateNodeGraphVisibility, (updateNodeGraphVisibility) => { editor.subscriptions.subscribeJsMessage(UpdateNodeGraphVisibility, (updateNodeGraphVisibility) => {
state.nodeGraphVisible = updateNodeGraphVisibility.visible; state.nodeGraphVisible = updateNodeGraphVisibility.visible;
}); });
return { return {
state: readonly(state), state: readonly(state) as typeof state,
}; };
} }
export type WorkspaceState = ReturnType<typeof createWorkspaceState>; export type WorkspaceState = ReturnType<typeof createWorkspaceState>;
-58
View File
@@ -1,58 +0,0 @@
import { reactive, readonly } from "vue";
import { defaultWidgetLayout, DisplayDialog, DisplayDialogDismiss, UpdateDialogDetails, WidgetLayout } from "@/dispatcher/js-messages";
import { EditorState } from "@/state/wasm-loader";
import { IconName } from "@/utilities/icons";
import { TextButtonWidget } from "@/utilities/widgets";
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export function createDialogState(editor: EditorState) {
const state = reactive({
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[],
});
// Creates a panic dialog from JS.
// Normal dialogs are created in the Rust backend, however for the crash dialog, the editor instance has panicked so it cannot respond to widget callbacks.
const createPanicDialog = (widgets: WidgetLayout, jsCallbackBasedButtons: TextButtonWidget[]): void => {
state.visible = true;
state.icon = "Warning";
state.widgets = widgets;
state.jsCallbackBasedButtons = jsCallbackBasedButtons;
};
const dismissDialog = (): void => {
state.visible = false;
};
const dialogIsVisible = (): boolean => state.visible;
const comingSoon = (issueNumber?: number): void => {
editor.instance.request_coming_soon_dialog(issueNumber);
};
// Run on creation
editor.dispatcher.subscribeJsMessage(DisplayDialog, (displayDialog) => {
state.visible = true;
state.icon = displayDialog.icon;
});
editor.dispatcher.subscribeJsMessage(DisplayDialogDismiss, dismissDialog);
editor.dispatcher.subscribeJsMessage(UpdateDialogDetails, (updateDialogDetails) => {
state.widgets = updateDialogDetails;
state.jsCallbackBasedButtons = undefined;
});
return {
state: readonly(state),
createPanicDialog,
dismissDialog,
dialogIsVisible,
comingSoon,
};
}
export type DialogState = ReturnType<typeof createDialogState>;
-89
View File
@@ -1,89 +0,0 @@
/* eslint-disable func-names */
import { createJsDispatcher } from "@/dispatcher/js-dispatcher";
import { JsMessageType } from "@/dispatcher/js-messages";
export type WasmInstance = typeof import("@/../wasm/pkg");
export type RustEditorInstance = InstanceType<WasmInstance["JsEditorHandle"]>;
// `wasmImport` starts uninitialized until `initWasm()` is called in `main.ts` before the Vue app is created
let wasmImport: WasmInstance | null = null;
export async function initWasm(): Promise<void> {
// Skip if the wasm module is already initialized
if (wasmImport !== null) return;
// Separating in two lines satisfies TypeScript
const importedWasm = await import("@/../wasm/pkg").then(panicProxy);
wasmImport = importedWasm;
// Provide a random starter seed which must occur after initializing the wasm module, since wasm can't generate is own random numbers
const randomSeed = BigInt(Math.floor(Math.random() * Number.MAX_SAFE_INTEGER));
importedWasm.set_random_seed(randomSeed);
}
// 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.
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) {
return function (...args: unknown[]): unknown {
// 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
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);
}
export function getWasmInstance(): WasmInstance {
if (wasmImport) return wasmImport;
throw new Error("Editor WASM backend was not initialized at application startup");
}
type CreateEditorStateType = {
// Allows subscribing to messages from the WASM backend
rawWasm: WasmInstance;
// Bindings to WASM wrapper declarations (generated by wasm-bindgen)
dispatcher: ReturnType<typeof createJsDispatcher>;
// WASM wrapper's exported functions (generated by wasm-bindgen)
instance: RustEditorInstance;
};
export function createEditorState(): CreateEditorStateType {
const rawWasm = getWasmInstance();
const dispatcher = createJsDispatcher();
const instance = new rawWasm.JsEditorHandle((messageType: JsMessageType, data: Record<string, unknown>): void => {
dispatcher.handleJsMessage(messageType, data, rawWasm, instance);
});
return {
rawWasm,
dispatcher,
instance,
};
}
export type EditorState = Readonly<ReturnType<typeof createEditorState>>;
-80
View File
@@ -1,80 +0,0 @@
const fontListAPI = "https://api.graphite.rs/font-list";
// Taken 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"],
]);
type fontCallbackType = (font: string, data: Uint8Array) => void;
let fontList = [] as { family: string; variants: string[]; files: Map<string, string> }[];
let loadDefaultFontCallback = undefined as fontCallbackType | undefined;
fetch(fontListAPI)
.then((response) => response.json())
.then((json) => {
const loadedFonts = json.items as { family: string; variants: string[]; files: { [name: string]: string } }[];
fontList = loadedFonts.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 };
});
loadDefaultFont();
});
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})`;
}
export async function loadDefaultFont(): Promise<void> {
const font = getFontFile("Merriweather", "Normal (400)");
if (!font) return;
const response = await fetch(font);
const responseBuffer = await response.arrayBuffer();
loadDefaultFontCallback?.(font, new Uint8Array(responseBuffer));
}
export function setLoadDefaultFontCallback(callback: fontCallbackType): void {
loadDefaultFontCallback = callback;
loadDefaultFont();
}
export function fontNames(): string[] {
return fontList.map((value) => value.family);
}
export function getFontStyles(fontFamily: string): string[] {
const font = fontList.find((value) => value.family === fontFamily);
return font?.variants || [];
}
export function getFontFile(fontFamily: string, fontStyle: string): string | undefined {
const font = fontList.find((value) => value.family === fontFamily);
const fontFile = font?.files.get(fontStyle);
return fontFile?.replace("http://", "https://");
}
-92
View File
@@ -1,92 +0,0 @@
import { IconName, IconSize } from "@/utilities/icons";
// Text Button
export interface TextButtonWidget {
kind: "TextButton";
tooltip?: string;
message?: string | object;
callback?: () => void;
props: TextButtonProps;
}
export interface TextButtonProps {
// `action` is used via `IconButtonWidget.callback`
label: string;
emphasized?: boolean;
disabled?: boolean;
minWidth?: number;
gapAfter?: boolean;
}
// Icon Button
export interface IconButtonWidget {
kind: "IconButton";
tooltip?: string;
message?: string | object;
callback?: () => void;
props: IconButtonProps;
}
export interface IconButtonProps {
// `action` is used via `IconButtonWidget.callback`
icon: IconName;
size: IconSize;
gapAfter?: boolean;
}
// Popover Button
export interface PopoverButtonWidget {
kind: "PopoverButton";
tooltip?: string;
callback?: () => void;
// popover: WidgetLayout;
popover: { title: string; text: string }; // TODO: Replace this with a `WidgetLayout` like above for arbitrary layouts
props: PopoverButtonProps;
}
export interface PopoverButtonProps {
// `action` is used via `PopoverButtonWidget.callback`
icon?: PopoverButtonIcon;
}
type Extends<T, U extends T> = U;
export type PopoverButtonIcon = Extends<IconName, "DropdownArrow" | "VerticalEllipsis">;
// Number Input
export interface NumberInputWidget {
kind: "NumberInput";
tooltip?: string;
optionPath: string[];
props: Omit<NumberInputProps, "value">;
}
export interface NumberInputProps {
value: number;
min?: number;
max?: number;
incrementBehavior?: IncrementBehavior;
incrementFactor?: number;
isInteger?: boolean;
unit?: string;
unitIsHiddenWhenEditing?: boolean;
displayDecimalPlaces?: number;
label?: string;
disabled?: boolean;
}
export type IncrementBehavior = "Add" | "Multiply" | "Callback" | "None";
export type IncrementDirection = "Decrease" | "Increase";
// Separator
export type SeparatorDirection = "Horizontal" | "Vertical";
export type SeparatorType = "Related" | "Unrelated" | "Section" | "List";
export interface SeparatorWidget {
kind: "Separator";
props: SeparatorProps;
}
export interface SeparatorProps {
direction?: SeparatorDirection;
type?: SeparatorType;
}
@@ -1,4 +1,4 @@
import { HSVA, RGBA } from "@/dispatcher/js-messages"; import { HSVA, RGBA } from "@/wasm-communication/messages";
export function hsvaToRgba(hsva: HSVA): RGBA { export function hsvaToRgba(hsva: HSVA): RGBA {
const { h, s, v, a } = hsva; const { h, s, v, a } = hsva;
@@ -40,5 +40,7 @@ export async function upload(acceptedEextensions: string): Promise<{ filename: s
); );
element.click(); 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
}); });
} }
@@ -243,7 +243,7 @@ export type IconSize = 12 | 16 | 24 | 32;
export type IconStyle = "node" | ""; export type IconStyle = "node" | "";
// The following helper type declarations allow us to avoid manually maintaining the `IconName` type declaration as a string union paralleling the keys of the // 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 icons key-value pair by constraining its values, but inferring its keys. // 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`. // 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 // 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 // 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
@@ -0,0 +1,367 @@
export function makeKeyboardModifiersBitfield(e: WheelEvent | PointerEvent | KeyboardEvent): number {
return Number(e.ctrlKey) | (Number(e.shiftKey) << 1) | (Number(e.altKey) << 2);
}
// 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 is a naive, temporary solution to allow non-Latin keyboards to fall back on the physical QWERTY layout
export function getLatinKey(e: KeyboardEvent): string | null {
const key = e.key.toLowerCase();
const isPrintable = !ALL_PRINTABLE_KEYS.has(e.key);
// Control (non-printable) characters are handled normally
if (!isPrintable) return key;
// These non-Latin characters should fall back to the Latin equivalent at the key location
const LAST_LATIN_UNICODE_CHAR = 0x024f;
if (key.length > 1 || key.charCodeAt(0) > LAST_LATIN_UNICODE_CHAR) return keyCodeToKey(e.code);
// Otherwise, ths is a printable Latin character
return e.key.toLowerCase();
}
export function keyCodeToKey(code: string): string | null {
// Letters
if (code.match(/^Key[A-Z]$/)) return code.replace("Key", "").toLowerCase();
// Numbers
if (code.match(/^Digit[0-9]$/)) return code.replace("Digit", "");
if (code.match(/^Numpad[0-9]$/)) return code.replace("Numpad", "");
// Function keys
if (code.match(/^F[1-9]|F1[0-9]|F20$/)) return code.replace("F", "").toLowerCase();
// Other characters
if (SPECIAL_CHARACTERS[code]) return SPECIAL_CHARACTERS[code];
return null;
}
const SPECIAL_CHARACTERS: Record<string, string> = {
BracketLeft: "[",
BracketRight: "]",
Backslash: "\\",
Slash: "/",
Period: ".",
Comma: ",",
Equal: "=",
Minus: "-",
Quote: "'",
Semicolon: ";",
NumpadEqual: "=",
NumpadDivide: "/",
NumpadMultiply: "*",
NumpadSubtract: "-",
NumpadAdd: "+",
NumpadDecimal: ".",
} as const;
const ALL_PRINTABLE_KEYS = new Set([
// Modifier
"Alt",
"AltGraph",
"CapsLock",
"Control",
"Fn",
"FnLock",
"Meta",
"NumLock",
"ScrollLock",
"Shift",
"Symbol",
"SymbolLock",
// Legacy modifier
"Hyper",
"Super",
// White space
"Enter",
"Tab",
// Navigation
"ArrowDown",
"ArrowLeft",
"ArrowRight",
"ArrowUp",
"End",
"Home",
"PageDown",
"PageUp",
// Editing
"Backspace",
"Clear",
"Copy",
"CrSel",
"Cut",
"Delete",
"EraseEof",
"ExSel",
"Insert",
"Paste",
"Redo",
"Undo",
// UI
"Accept",
"Again",
"Attn",
"Cancel",
"ContextMenu",
"Escape",
"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",
"Soft1",
"Soft2",
"Soft3",
"Soft4",
// Multimedia
"ChannelDown",
"ChannelUp",
"Close",
"MailForward",
"MailReply",
"MailSend",
"MediaClose",
"MediaFastForward",
"MediaPause",
"MediaPlay",
"MediaPlayPause",
"MediaRecord",
"MediaRewind",
"MediaStop",
"MediaTrackNext",
"MediaTrackPrevious",
"New",
"Open",
"Print",
"Save",
"SpellCheck",
// Multimedia numpad
"Key11",
"Key12",
// 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",
]);
@@ -0,0 +1,40 @@
// 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 {
// 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);
}
+23
View File
@@ -0,0 +1,23 @@
import { type RGBA as RGBA_ } from "@/wasm-communication/messages";
import FloatingMenu from "@/components/widgets/floating-menus/FloatingMenu.vue";
import MenuList from "@/components/widgets/floating-menus/MenuList.vue";
// TODO: When a Volar bug is fixed (likely in v0.34.16):
// TODO: - Uncomment this block
// TODO: - Remove the `MenuList` and `FloatingMenu` lines from the `declare global` section below
// TODO: - And possibly add the empty export line of code `export {};` to the bottom of this file, for some reason
// declare module "vue" {
// interface ComponentCustomProperties {
// const MenuList: MenuList;
// const FloatingMenu: FloatingMenu;
// }
// }
// Satisfies Volar
// TODO: Move this back into `DropdownInput.vue` and `SwatchPairInput.vue` after https://github.com/johnsoncodehk/volar/issues/1321 is fixed
declare global {
const MenuList: MenuList;
const FloatingMenu: FloatingMenu;
type RGBA = RGBA_;
}
+50
View File
@@ -0,0 +1,50 @@
import { panicProxy } from "@/utility-functions/panic-proxy";
import { JsMessageType } from "@/wasm-communication/messages";
import { createSubscriptionRouter, SubscriptionRouter } from "@/wasm-communication/subscription-router";
export type WasmRawInstance = typeof import("@/../wasm/pkg");
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 | null = null;
// Should be called asynchronously before `createEditor()`
export async function initWasm(): Promise<void> {
// Skip if the WASM module is already initialized
if (wasmImport !== null) return;
// Import the WASM module JS bindings and wrap them in the panic proxy
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 randomSeed = BigInt(Math.floor(Math.random() * Number.MAX_SAFE_INTEGER));
wasmImport?.set_random_seed(randomSeed);
}
// Should be called after running `initWasm()` and its promise resolving
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export function createEditor() {
// Functions from `api.rs` defined directly on the WASM module, not the editor instance (generated by wasm-bindgen)
const raw: WasmRawInstance = getWasmInstance();
// Functions from `api.rs` that are part of the editor instance (generated by wasm-bindgen)
const instance: WasmEditorInstance = new raw.JsEditorHandle(invokeJsMessageSubscription);
function invokeJsMessageSubscription(messageType: JsMessageType, data: Record<string, unknown>): void {
subscriptions.handleJsMessage(messageType, data, raw, instance);
}
// Allows subscribing to messages in JS that are sent from the WASM backend
const subscriptions: SubscriptionRouter = createSubscriptionRouter();
return {
raw,
instance,
subscriptions,
};
}
export function getWasmInstance(): WasmRawInstance {
if (wasmImport) return wasmImport;
throw new Error("Editor WASM backend was not initialized at application startup");
}
@@ -3,8 +3,8 @@
import { Transform, Type } from "class-transformer"; import { Transform, Type } from "class-transformer";
import type { RustEditorInstance, WasmInstance } from "@/state/wasm-loader"; import { IconName } from "@/utility-functions/icons";
import { IconName } from "@/utilities/icons"; import type { WasmEditorInstance, WasmRawInstance } from "@/wasm-communication/editor";
export class JsMessage { export class JsMessage {
// The marker provides a way to check if an object is a sub-class constructor for a jsMessage. // The marker provides a way to check if an object is a sub-class constructor for a jsMessage.
@@ -12,7 +12,7 @@ export class JsMessage {
} }
// ============================================================================ // ============================================================================
// Add additional classes to replicate Rust's FrontendMessages and data structures below. // Add additional classes to replicate Rust's `FrontendMessage`s and data structures below.
// //
// Remember to add each message to the `messageConstructors` export at the bottom of the file. // Remember to add each message to the `messageConstructors` export at the bottom of the file.
// //
@@ -21,9 +21,9 @@ export class JsMessage {
// ============================================================================ // ============================================================================
// Allows the auto save system to use a string for the id rather than a BigInt. // Allows the auto save system to use a string for the id rather than a BigInt.
// IndexedDb does not allow for BigInts as primary keys. TypeScript does not allow // IndexedDb does not allow for BigInts as primary keys.
// subclasses to change the type of class variables in subclasses. It is an abstract // TypeScript does not allow subclasses to change the type of class variables in subclasses.
// class to point out that it should not be instantiated directly. // It is an abstract class to point out that it should not be instantiated directly.
export abstract class DocumentDetails { export abstract class DocumentDetails {
readonly name!: string; readonly name!: string;
@@ -222,7 +222,7 @@ interface DataBuffer {
length: BigInt; length: BigInt;
} }
export function newUpdateDocumentLayerTreeStructure(input: { data_buffer: DataBuffer }, wasm: WasmInstance): UpdateDocumentLayerTreeStructure { export function newUpdateDocumentLayerTreeStructure(input: { data_buffer: DataBuffer }, wasm: WasmRawInstance): UpdateDocumentLayerTreeStructure {
const pointerNum = Number(input.data_buffer.pointer); const pointerNum = Number(input.data_buffer.pointer);
const lengthNum = Number(input.data_buffer.length); const lengthNum = Number(input.data_buffer.length);
@@ -335,8 +335,6 @@ export class IndexedDbDocumentDetails extends DocumentDetails {
id!: string; id!: string;
} }
export class TriggerFontLoadDefault extends JsMessage {}
export class DisplayDialogDismiss extends JsMessage {} export class DisplayDialogDismiss extends JsMessage {}
export class TriggerIndexedDbWriteDocument extends JsMessage { export class TriggerIndexedDbWriteDocument extends JsMessage {
@@ -355,9 +353,11 @@ export class TriggerIndexedDbRemoveDocument extends JsMessage {
} }
export class TriggerFontLoad extends JsMessage { export class TriggerFontLoad extends JsMessage {
font!: string; font_file_url!: string;
} }
export class TriggerFontLoadDefault extends JsMessage {}
export class TriggerVisitLink extends JsMessage { export class TriggerVisitLink extends JsMessage {
url!: string; url!: string;
} }
@@ -526,7 +526,7 @@ export class TriggerViewportResize extends JsMessage {}
// `any` is used since the type of the object should be known from the Rust side // `any` is used since the type of the object should be known from the Rust side
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
type JSMessageFactory = (data: any, wasm: WasmInstance, instance: RustEditorInstance) => JsMessage; type JSMessageFactory = (data: any, wasm: WasmRawInstance, instance: WasmEditorInstance) => JsMessage;
type MessageMaker = typeof JsMessage | JSMessageFactory; type MessageMaker = typeof JsMessage | JSMessageFactory;
export const messageMakers: Record<string, MessageMaker> = { export const messageMakers: Record<string, MessageMaker> = {
@@ -536,12 +536,12 @@ export const messageMakers: Record<string, MessageMaker> = {
DisplayEditableTextbox, DisplayEditableTextbox,
UpdateImageData, UpdateImageData,
DisplayRemoveEditableTextbox, DisplayRemoveEditableTextbox,
TriggerFontLoadDefault,
DisplayDialogDismiss, DisplayDialogDismiss,
TriggerFileDownload, TriggerFileDownload,
TriggerFileUpload, TriggerFileUpload,
TriggerIndexedDbRemoveDocument, TriggerIndexedDbRemoveDocument,
TriggerFontLoad, TriggerFontLoad,
TriggerFontLoadDefault,
TriggerIndexedDbWriteDocument, TriggerIndexedDbWriteDocument,
TriggerRasterDownload, TriggerRasterDownload,
TriggerTextCommit, TriggerTextCommit,
@@ -1,7 +1,7 @@
import { plainToInstance } from "class-transformer"; import { plainToInstance } from "class-transformer";
import { JsMessageType, messageMakers, JsMessage } from "@/dispatcher/js-messages"; import type { WasmEditorInstance, WasmRawInstance } from "@/wasm-communication/editor";
import type { RustEditorInstance, WasmInstance } from "@/state/wasm-loader"; import { JsMessageType, messageMakers, JsMessage } from "@/wasm-communication/messages";
type JsMessageCallback<T extends JsMessage> = (messageData: T) => void; type JsMessageCallback<T extends JsMessage> = (messageData: T) => void;
type JsMessageCallbackMap = { type JsMessageCallbackMap = {
@@ -12,21 +12,21 @@ type JsMessageCallbackMap = {
}; };
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export function createJsDispatcher() { export function createSubscriptionRouter() {
const subscriptions: JsMessageCallbackMap = {}; const subscriptions: JsMessageCallbackMap = {};
const subscribeJsMessage = <T extends JsMessage, Args extends unknown[]>(messageType: new (...args: Args) => T, callback: JsMessageCallback<T>): void => { const subscribeJsMessage = <T extends JsMessage, Args extends unknown[]>(messageType: new (...args: Args) => T, callback: JsMessageCallback<T>): void => {
subscriptions[messageType.name] = callback; subscriptions[messageType.name] = callback;
}; };
const handleJsMessage = (messageType: JsMessageType, messageData: Record<string, unknown>, wasm: WasmInstance, instance: RustEditorInstance): void => { 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 // 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]; const messageMaker = messageMakers[messageType];
if (!messageMaker) { if (!messageMaker) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error( console.error(
`Received a frontend message of type "${messageType}" but was not able to parse the data. ` + `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 `js-messages.ts`.)" "(Perhaps this message parser isn't exported in `messageMakers` at the bottom of `messages.ts`.)"
); );
return; return;
} }
@@ -63,4 +63,4 @@ export function createJsDispatcher() {
handleJsMessage, handleJsMessage,
}; };
} }
export type JsDispatcher = ReturnType<typeof createJsDispatcher>; export type SubscriptionRouter = ReturnType<typeof createSubscriptionRouter>;
+31 -27
View File
@@ -1,6 +1,6 @@
// This file is where functions are defined to be called directly from JS. //! This file is where functions are defined to be called directly from JS.
// It serves as a thin wrapper over the editor backend API that relies //! It serves as a thin wrapper over the editor backend API that relies
// on the dispatcher messaging system and more complex Rust data types. //! on the dispatcher messaging system and more complex Rust data types.
use crate::helpers::{translate_key, Error}; use crate::helpers::{translate_key, Error};
use crate::{EDITOR_HAS_CRASHED, EDITOR_INSTANCES, JS_EDITOR_HANDLES}; use crate::{EDITOR_HAS_CRASHED, EDITOR_INSTANCES, JS_EDITOR_HANDLES};
@@ -84,10 +84,34 @@ impl JsEditorHandle {
// the backend from the web frontend. // the backend from the web frontend.
// ======================================================================== // ========================================================================
pub fn init_app(&self) {
let message = PortfolioMessage::UpdateOpenDocumentsList;
self.dispatch(message);
let message = PortfolioMessage::UpdateDocumentWidgets;
self.dispatch(message);
let message = ToolMessage::InitTools;
self.dispatch(message);
let message = FrontendMessage::TriggerFontLoadDefault;
self.dispatch(message);
let message = MovementMessage::TranslateCanvas { delta: (0., 0.).into() };
self.dispatch(message);
}
/// Intentionally panic for debugging purposes
pub fn intentional_panic(&self) {
panic!();
}
/// Answer whether or not the editor has crashed
pub fn has_crashed(&self) -> bool { pub fn has_crashed(&self) -> bool {
EDITOR_HAS_CRASHED.load(Ordering::SeqCst) EDITOR_HAS_CRASHED.load(Ordering::SeqCst)
} }
/// Request that the Node Graph panel be shown or hidden by toggling the visibility state
pub fn toggle_node_graph_visibility(&self) { pub fn toggle_node_graph_visibility(&self) {
self.dispatch(WorkspaceMessage::NodeGraphToggleVisibility); self.dispatch(WorkspaceMessage::NodeGraphToggleVisibility);
} }
@@ -109,11 +133,6 @@ impl JsEditorHandle {
self.dispatch(message); self.dispatch(message);
} }
pub fn get_open_documents_list(&self) {
let message = PortfolioMessage::UpdateOpenDocumentsList;
self.dispatch(message);
}
pub fn request_new_document_dialog(&self) { pub fn request_new_document_dialog(&self) {
let message = DialogMessage::RequestNewDocumentDialog; let message = DialogMessage::RequestNewDocumentDialog;
self.dispatch(message); self.dispatch(message);
@@ -301,8 +320,8 @@ impl JsEditorHandle {
} }
/// A font has been downloaded /// A font has been downloaded
pub fn on_font_load(&self, font: String, data: Vec<u8>, is_default: bool) -> Result<(), JsValue> { pub fn on_font_load(&self, font_file_url: String, data: Vec<u8>, is_default: bool) -> Result<(), JsValue> {
let message = DocumentMessage::FontLoaded { font, data, is_default }; let message = DocumentMessage::FontLoaded { font_file_url, data, is_default };
self.dispatch(message); self.dispatch(message);
Ok(()) Ok(())
@@ -467,19 +486,10 @@ impl JsEditorHandle {
let message = DocumentMessage::ToggleLayerExpansion { layer_path }; let message = DocumentMessage::ToggleLayerExpansion { layer_path };
self.dispatch(message); self.dispatch(message);
} }
// TODO: Replace with initialization system, issue #524
pub fn init_app(&self) {
let message = PortfolioMessage::UpdateDocumentWidgets;
self.dispatch(message);
let message = ToolMessage::InitTools;
self.dispatch(message);
}
} }
// Needed to make JsEditorHandle functions pub to rust. Do not fully // Needed to make JsEditorHandle functions pub to Rust.
// understand reason but has to do with #[wasm_bindgen] procedural macro. // The reason is not fully clear but it has to do with the #[wasm_bindgen] procedural macro.
impl JsEditorHandle { impl JsEditorHandle {
pub fn handle_response_rust_proxy(&self, message: FrontendMessage) { pub fn handle_response_rust_proxy(&self, message: FrontendMessage) {
self.handle_response(message); self.handle_response(message);
@@ -499,12 +509,6 @@ pub fn set_random_seed(seed: u64) {
editor::communication::set_uuid_seed(seed) editor::communication::set_uuid_seed(seed)
} }
/// Intentionally panic for debugging purposes
#[wasm_bindgen]
pub fn intentional_panic() {
panic!();
}
/// Access a handle to WASM memory /// Access a handle to WASM memory
#[wasm_bindgen] #[wasm_bindgen]
pub fn wasm_memory() -> JsValue { pub fn wasm_memory() -> JsValue {
+2
View File
@@ -19,8 +19,10 @@ thread_local! {
/// Initialize the backend /// Initialize the backend
#[wasm_bindgen(start)] #[wasm_bindgen(start)]
pub fn init() { pub fn init() {
// Set up the panic hook
panic::set_hook(Box::new(panic_hook)); panic::set_hook(Box::new(panic_hook));
// Set up the logger with a default level of debug
log::set_logger(&LOGGER).expect("Failed to set logger"); log::set_logger(&LOGGER).expect("Failed to set logger");
log::set_max_level(log::LevelFilter::Debug); log::set_max_level(log::LevelFilter::Debug);
} }
+3 -3
View File
@@ -563,7 +563,7 @@ impl Document {
Some(vec![DocumentChanged]) Some(vec![DocumentChanged])
} }
Operation::SetTextContent { path, new_text } => { Operation::SetTextContent { path, new_text } => {
// Not using Document::layer_mut is necessary because we alson need to borrow the font cache // Not using Document::layer_mut is necessary because we also need to borrow the font cache
let mut current_folder = &mut self.root; let mut current_folder = &mut self.root;
let (layer_path, id) = split_path(&path)?; let (layer_path, id) = split_path(&path)?;
@@ -730,7 +730,7 @@ impl Document {
font_file, font_file,
size, size,
} => { } => {
// Not using Document::layer_mut is necessary because we alson need to borrow the font cache // Not using Document::layer_mut is necessary because we also need to borrow the font cache
let mut current_folder = &mut self.root; let mut current_folder = &mut self.root;
let (folder_path, id) = split_path(&path)?; let (folder_path, id) = split_path(&path)?;
for id in folder_path { for id in folder_path {
@@ -797,7 +797,7 @@ impl Document {
self.set_transform_relative_to_viewport(&path, transform)?; self.set_transform_relative_to_viewport(&path, transform)?;
self.mark_as_dirty(&path)?; self.mark_as_dirty(&path)?;
// Not using Document::layer_mut is necessary because we alson need to borrow the font cache // Not using Document::layer_mut is necessary because we also need to borrow the font cache
let mut current_folder = &mut self.root; let mut current_folder = &mut self.root;
let (folder_path, id) = split_path(&path)?; let (folder_path, id) = split_path(&path)?;
for id in folder_path { for id in folder_path {