mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2025-07-23 15:45:05 +00:00
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:
parent
4c3c925c2c
commit
fc2d983bd7
73 changed files with 1572 additions and 1462 deletions
|
@ -73,7 +73,7 @@ pub enum DocumentMessage {
|
|||
affected_folder_path: Vec<LayerId>,
|
||||
},
|
||||
FontLoaded {
|
||||
font: String,
|
||||
font_file_url: String,
|
||||
data: Vec<u8>,
|
||||
is_default: bool,
|
||||
},
|
||||
|
@ -82,7 +82,7 @@ pub enum DocumentMessage {
|
|||
affected_layer_path: Vec<LayerId>,
|
||||
},
|
||||
LoadFont {
|
||||
font: String,
|
||||
font_file_url: String,
|
||||
},
|
||||
MoveSelectedLayersTo {
|
||||
folder_path: Vec<LayerId>,
|
||||
|
|
|
@ -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>) {
|
||||
if !self.graphene_document.font_cache.has_default() {
|
||||
responses.push_back(FrontendMessage::TriggerFontLoadDefault.into())
|
||||
|
@ -669,30 +670,36 @@ impl DocumentMessageHandler {
|
|||
}]);
|
||||
|
||||
let document_mode_layout = WidgetLayout::new(vec![LayoutRow::Row {
|
||||
widgets: vec![WidgetHolder::new(Widget::DropdownInput(DropdownInput {
|
||||
entries: vec![vec![
|
||||
DropdownEntryData {
|
||||
label: DocumentMode::DesignMode.to_string(),
|
||||
icon: DocumentMode::DesignMode.icon_name(),
|
||||
..DropdownEntryData::default()
|
||||
},
|
||||
DropdownEntryData {
|
||||
label: DocumentMode::SelectMode.to_string(),
|
||||
icon: DocumentMode::SelectMode.icon_name(),
|
||||
on_update: WidgetCallback::new(|_| DialogMessage::RequestComingSoonDialog { issue: Some(330) }.into()),
|
||||
..DropdownEntryData::default()
|
||||
},
|
||||
DropdownEntryData {
|
||||
label: DocumentMode::GuideMode.to_string(),
|
||||
icon: DocumentMode::GuideMode.icon_name(),
|
||||
on_update: WidgetCallback::new(|_| DialogMessage::RequestComingSoonDialog { issue: Some(331) }.into()),
|
||||
..DropdownEntryData::default()
|
||||
},
|
||||
]],
|
||||
selected_index: Some(self.document_mode as u32),
|
||||
draw_icon: true,
|
||||
..Default::default()
|
||||
}))],
|
||||
widgets: vec![
|
||||
WidgetHolder::new(Widget::DropdownInput(DropdownInput {
|
||||
entries: vec![vec![
|
||||
DropdownEntryData {
|
||||
label: DocumentMode::DesignMode.to_string(),
|
||||
icon: DocumentMode::DesignMode.icon_name(),
|
||||
..DropdownEntryData::default()
|
||||
},
|
||||
DropdownEntryData {
|
||||
label: DocumentMode::SelectMode.to_string(),
|
||||
icon: DocumentMode::SelectMode.icon_name(),
|
||||
on_update: WidgetCallback::new(|_| DialogMessage::RequestComingSoonDialog { issue: Some(330) }.into()),
|
||||
..DropdownEntryData::default()
|
||||
},
|
||||
DropdownEntryData {
|
||||
label: DocumentMode::GuideMode.to_string(),
|
||||
icon: DocumentMode::GuideMode.icon_name(),
|
||||
on_update: WidgetCallback::new(|_| DialogMessage::RequestComingSoonDialog { issue: Some(331) }.into()),
|
||||
..DropdownEntryData::default()
|
||||
},
|
||||
]],
|
||||
selected_index: Some(self.document_mode as u32),
|
||||
draw_icon: true,
|
||||
..Default::default()
|
||||
})),
|
||||
WidgetHolder::new(Widget::Separator(Separator {
|
||||
separator_type: SeparatorType::Section,
|
||||
direction: SeparatorDirection::Horizontal,
|
||||
})),
|
||||
],
|
||||
}]);
|
||||
|
||||
responses.push_back(
|
||||
|
@ -1107,8 +1114,8 @@ impl MessageHandler<DocumentMessage, &InputPreprocessorMessageHandler> for Docum
|
|||
let affected_layer_path = affected_folder_path;
|
||||
responses.extend([LayerChanged { affected_layer_path }.into(), DocumentStructureChanged.into()]);
|
||||
}
|
||||
FontLoaded { font, data, is_default } => {
|
||||
self.graphene_document.font_cache.insert(font, data, is_default);
|
||||
FontLoaded { font_file_url, data, is_default } => {
|
||||
self.graphene_document.font_cache.insert(font_file_url, data, is_default);
|
||||
responses.push_back(DocumentMessage::DirtyRenderDocument.into());
|
||||
}
|
||||
GroupSelectedLayers => {
|
||||
|
@ -1147,9 +1154,9 @@ impl MessageHandler<DocumentMessage, &InputPreprocessorMessageHandler> for Docum
|
|||
responses.push_back(PropertiesPanelMessage::CheckSelectedWasUpdated { path: affected_layer_path }.into());
|
||||
self.update_layer_tree_options_bar_widgets(responses);
|
||||
}
|
||||
LoadFont { font } => {
|
||||
if !self.graphene_document.font_cache.loaded_font(&font) {
|
||||
responses.push_front(FrontendMessage::TriggerFontLoad { font }.into());
|
||||
LoadFont { font_file_url } => {
|
||||
if !self.graphene_document.font_cache.loaded_font(&font_file_url) {
|
||||
responses.push_front(FrontendMessage::TriggerFontLoad { font_file_url }.into());
|
||||
}
|
||||
}
|
||||
MoveSelectedLayersTo {
|
||||
|
|
|
@ -79,6 +79,7 @@ impl PortfolioMessageHandler {
|
|||
new_document.update_layer_tree_options_bar_widgets(responses);
|
||||
|
||||
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);
|
||||
|
||||
self.documents.insert(document_id, new_document);
|
||||
|
|
|
@ -714,12 +714,12 @@ fn node_section_font(layer: &TextLayer) -> LayoutRow {
|
|||
is_style_picker: false,
|
||||
font_family: layer.font_family.clone(),
|
||||
font_style: layer.font_style.clone(),
|
||||
font_file: String::new(),
|
||||
font_file_url: String::new(),
|
||||
on_update: WidgetCallback::new(move |font_input: &FontInput| {
|
||||
PropertiesPanelMessage::ModifyFont {
|
||||
font_family: font_input.font_family.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,
|
||||
}
|
||||
.into()
|
||||
|
@ -741,12 +741,12 @@ fn node_section_font(layer: &TextLayer) -> LayoutRow {
|
|||
is_style_picker: true,
|
||||
font_family: layer.font_family.clone(),
|
||||
font_style: layer.font_style.clone(),
|
||||
font_file: String::new(),
|
||||
font_file_url: String::new(),
|
||||
on_update: WidgetCallback::new(move |font_input: &FontInput| {
|
||||
PropertiesPanelMessage::ModifyFont {
|
||||
font_family: font_input.font_family.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,
|
||||
}
|
||||
.into()
|
||||
|
|
|
@ -22,7 +22,7 @@ pub enum FrontendMessage {
|
|||
// Trigger prefix: cause a browser API to do something
|
||||
TriggerFileDownload { document: String, name: String },
|
||||
TriggerFileUpload,
|
||||
TriggerFontLoad { font: String },
|
||||
TriggerFontLoad { font_file_url: String },
|
||||
TriggerFontLoadDefault,
|
||||
TriggerIndexedDbRemoveDocument { document_id: u64 },
|
||||
TriggerIndexedDbWriteDocument { document: String, details: FrontendDocumentDetails, version: String },
|
||||
|
|
|
@ -95,17 +95,17 @@ impl MessageHandler<LayoutMessage, ()> for LayoutMessageHandler {
|
|||
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_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_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_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);
|
||||
responses.push_back(callback_message);
|
||||
}
|
||||
|
|
|
@ -49,6 +49,7 @@ impl WidgetLayout {
|
|||
|
||||
pub type SubLayout = Vec<LayoutRow>;
|
||||
|
||||
// TODO: Rename LayoutRow to something more generic
|
||||
#[remain::sorted]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum LayoutRow {
|
||||
|
@ -254,8 +255,8 @@ pub struct FontInput {
|
|||
pub font_family: String,
|
||||
#[serde(rename = "fontStyle")]
|
||||
pub font_style: String,
|
||||
#[serde(rename = "fontFile")]
|
||||
pub font_file: String,
|
||||
#[serde(rename = "fontFileUrl")]
|
||||
pub font_file_url: String,
|
||||
#[serde(skip)]
|
||||
#[derivative(Debug = "ignore", PartialEq = "ignore")]
|
||||
pub on_update: WidgetCallback<FontInput>,
|
||||
|
|
|
@ -84,7 +84,7 @@ impl PropertyHolder for TextTool {
|
|||
TextMessage::UpdateOptions(TextOptionsUpdate::Font {
|
||||
family: font_input.font_family.clone(),
|
||||
style: font_input.font_style.clone(),
|
||||
file: font_input.font_file.clone(),
|
||||
file: font_input.font_file_url.clone(),
|
||||
})
|
||||
.into()
|
||||
}),
|
||||
|
@ -102,7 +102,7 @@ impl PropertyHolder for TextTool {
|
|||
TextMessage::UpdateOptions(TextOptionsUpdate::Font {
|
||||
family: font_input.font_family.clone(),
|
||||
style: font_input.font_style.clone(),
|
||||
file: font_input.font_file.clone(),
|
||||
file: font_input.font_file_url.clone(),
|
||||
})
|
||||
.into()
|
||||
}),
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<MainWindow />
|
||||
|
||||
<div class="unsupported-modal-backdrop" v-if="showUnsupportedModal">
|
||||
<div class="unsupported-modal-backdrop" v-if="apiUnsupported" ref="unsupported">
|
||||
<LayoutCol class="unsupported-modal">
|
||||
<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>
|
||||
|
@ -11,7 +11,7 @@
|
|||
API which is required for using the editor. However, you can still explore the user interface.
|
||||
</p>
|
||||
<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>
|
||||
</LayoutCol>
|
||||
</div>
|
||||
|
@ -258,78 +258,96 @@ img {
|
|||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
|
||||
import { createAutoSaveManager } from "@/lifetime/auto-save";
|
||||
import { initErrorHandling } from "@/lifetime/errors";
|
||||
import { createInputManager, InputManager } from "@/lifetime/input";
|
||||
import { createDialogState, DialogState } from "@/state/dialog";
|
||||
import { createFullscreenState, FullscreenState } from "@/state/fullscreen";
|
||||
import { createPortfolioState, PortfolioState } from "@/state/portfolio";
|
||||
import { createEditorState, EditorState } from "@/state/wasm-loader";
|
||||
import { createWorkspaceState, WorkspaceState } from "@/state/workspace";
|
||||
import { createBuildMetadataManager } from "@/io-managers/build-metadata";
|
||||
import { createClipboardManager } from "@/io-managers/clipboard";
|
||||
import { createHyperlinkManager } from "@/io-managers/hyperlinks";
|
||||
import { createInputManager } from "@/io-managers/input";
|
||||
import { createPanicManager } from "@/io-managers/panic";
|
||||
import { createPersistenceManager } from "@/io-managers/persistence";
|
||||
import { createDialogState, DialogState } from "@/state-providers/dialog";
|
||||
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 LayoutRow from "@/components/layout/LayoutRow.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" {
|
||||
// Systems `provide`d by the root App to be `inject`ed into descendant components and used for reactive bindings
|
||||
interface ComponentCustomProperties {
|
||||
// Graphite WASM editor instance
|
||||
editor: Editor;
|
||||
|
||||
// State provider systems
|
||||
dialog: DialogState;
|
||||
fonts: FontsState;
|
||||
fullscreen: FullscreenState;
|
||||
portfolio: PortfolioState;
|
||||
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({
|
||||
provide() {
|
||||
return {
|
||||
editor: this.editor,
|
||||
dialog: this.dialog,
|
||||
portfolio: this.portfolio,
|
||||
workspace: this.workspace,
|
||||
fullscreen: this.fullscreen,
|
||||
inputManager: this.inputManager,
|
||||
};
|
||||
return { ...this.$data };
|
||||
},
|
||||
data() {
|
||||
// Initialize the Graphite WASM editor instance
|
||||
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);
|
||||
|
||||
const editor = createEditor();
|
||||
return {
|
||||
// Graphite WASM editor instance
|
||||
editor,
|
||||
dialog,
|
||||
portfolio,
|
||||
workspace,
|
||||
fullscreen,
|
||||
showUnsupportedModal: !("BigInt64Array" in window),
|
||||
inputManager: undefined as undefined | InputManager,
|
||||
|
||||
// State provider systems
|
||||
dialog: createDialogState(editor),
|
||||
fonts: createFontsState(editor),
|
||||
fullscreen: createFullscreenState(),
|
||||
portfolio: createPortfolioState(editor),
|
||||
workspace: createWorkspaceState(editor),
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
closeModal() {
|
||||
this.showUnsupportedModal = false;
|
||||
computed: {
|
||||
apiUnsupported() {
|
||||
return !("BigInt64Array" in window);
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.inputManager = createInputManager(this.editor, this.$el.parentElement, this.dialog, this.portfolio, this.fullscreen);
|
||||
methods: {
|
||||
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();
|
||||
},
|
||||
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();
|
||||
},
|
||||
components: {
|
||||
|
|
53
frontend/src/README.md
Normal file
53
frontend/src/README.md
Normal 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
frontend/src/components/README.md
Normal file
33
frontend/src/components/README.md
Normal 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" />
|
||||
```
|
|
@ -2,7 +2,6 @@
|
|||
<LayoutCol class="document">
|
||||
<LayoutRow class="options-bar" :scrollableX="true">
|
||||
<WidgetLayout :layout="documentModeLayout" />
|
||||
<Separator :type="'Section'" />
|
||||
<WidgetLayout :layout="toolOptionsLayout" />
|
||||
|
||||
<LayoutRow class="spacer"></LayoutRow>
|
||||
|
@ -37,12 +36,12 @@
|
|||
<LayoutCol class="canvas-area">
|
||||
<div
|
||||
class="canvas"
|
||||
data-canvas
|
||||
ref="canvas"
|
||||
:style="{ cursor: canvasCursor }"
|
||||
@pointerdown="(e: PointerEvent) => canvasPointerDown(e)"
|
||||
@dragover="(e) => e.preventDefault()"
|
||||
@drop="(e) => pasteFile(e)"
|
||||
ref="canvas"
|
||||
data-canvas
|
||||
>
|
||||
<svg class="artboards" v-html="artboardSvg" :style="{ width: canvasSvgWidth, height: canvasSvgHeight }"></svg>
|
||||
<svg
|
||||
|
@ -221,6 +220,7 @@
|
|||
<script lang="ts">
|
||||
import { defineComponent, nextTick } from "vue";
|
||||
|
||||
import { textInputCleanup } from "@/utility-functions/keyboard-entry";
|
||||
import {
|
||||
UpdateDocumentArtwork,
|
||||
UpdateDocumentOverlays,
|
||||
|
@ -235,18 +235,10 @@ import {
|
|||
UpdateDocumentBarLayout,
|
||||
UpdateImageData,
|
||||
TriggerTextCommit,
|
||||
TriggerTextCopy,
|
||||
TriggerViewportResize,
|
||||
DisplayRemoveEditableTextbox,
|
||||
DisplayEditableTextbox,
|
||||
TriggerFontLoad,
|
||||
TriggerFontLoadDefault,
|
||||
TriggerVisitLink,
|
||||
} from "@/dispatcher/js-messages";
|
||||
|
||||
import { textInputCleanup } from "@/lifetime/input";
|
||||
|
||||
import { loadDefaultFont, setLoadDefaultFontCallback } from "@/utilities/fonts";
|
||||
} from "@/wasm-communication/messages";
|
||||
|
||||
import LayoutCol from "@/components/layout/LayoutCol.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 CanvasRuler from "@/components/widgets/rulers/CanvasRuler.vue";
|
||||
import PersistentScrollbar from "@/components/widgets/scrollbars/PersistentScrollbar.vue";
|
||||
import Separator from "@/components/widgets/separators/Separator.vue";
|
||||
import WidgetLayout from "@/components/widgets/WidgetLayout.vue";
|
||||
|
||||
export default defineComponent({
|
||||
inject: ["editor", "dialog"],
|
||||
inject: ["editor"],
|
||||
methods: {
|
||||
viewportResize() {
|
||||
// Resize the canvas
|
||||
|
@ -275,11 +266,10 @@ export default defineComponent({
|
|||
this.canvasSvgHeight = `${height}px`;
|
||||
|
||||
// Resize the rulers
|
||||
|
||||
const rulerHorizontal = this.$refs.rulerHorizontal as typeof CanvasRuler;
|
||||
const rulerVertical = this.$refs.rulerVertical as typeof CanvasRuler;
|
||||
rulerHorizontal?.handleResize();
|
||||
rulerVertical?.handleResize();
|
||||
rulerHorizontal?.resize();
|
||||
rulerVertical?.resize();
|
||||
},
|
||||
pasteFile(e: DragEvent) {
|
||||
const { dataTransfer } = e;
|
||||
|
@ -329,7 +319,7 @@ export default defineComponent({
|
|||
},
|
||||
},
|
||||
mounted() {
|
||||
this.editor.dispatcher.subscribeJsMessage(UpdateDocumentArtwork, (UpdateDocumentArtwork) => {
|
||||
this.editor.subscriptions.subscribeJsMessage(UpdateDocumentArtwork, (UpdateDocumentArtwork) => {
|
||||
this.artworkSvg = UpdateDocumentArtwork.svg;
|
||||
|
||||
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.editor.dispatcher.subscribeJsMessage(UpdateDocumentArtboards, (updateDocumentArtboards) => {
|
||||
this.editor.subscriptions.subscribeJsMessage(UpdateDocumentArtboards, (updateDocumentArtboards) => {
|
||||
this.artboardSvg = updateDocumentArtboards.svg;
|
||||
});
|
||||
|
||||
this.editor.dispatcher.subscribeJsMessage(UpdateDocumentScrollbars, (updateDocumentScrollbars) => {
|
||||
this.editor.subscriptions.subscribeJsMessage(UpdateDocumentScrollbars, (updateDocumentScrollbars) => {
|
||||
this.scrollbarPos = updateDocumentScrollbars.position;
|
||||
this.scrollbarSize = updateDocumentScrollbars.size;
|
||||
this.scrollbarMultiplier = updateDocumentScrollbars.multiplier;
|
||||
});
|
||||
|
||||
this.editor.dispatcher.subscribeJsMessage(UpdateDocumentRulers, (updateDocumentRulers) => {
|
||||
this.editor.subscriptions.subscribeJsMessage(UpdateDocumentRulers, (updateDocumentRulers) => {
|
||||
this.rulerOrigin = updateDocumentRulers.origin;
|
||||
this.rulerSpacing = updateDocumentRulers.spacing;
|
||||
this.rulerInterval = updateDocumentRulers.interval;
|
||||
});
|
||||
|
||||
this.editor.dispatcher.subscribeJsMessage(UpdateMouseCursor, (updateMouseCursor) => {
|
||||
this.editor.subscriptions.subscribeJsMessage(UpdateMouseCursor, (updateMouseCursor) => {
|
||||
this.canvasCursor = updateMouseCursor.cursor;
|
||||
});
|
||||
this.editor.dispatcher.subscribeJsMessage(TriggerTextCommit, () => {
|
||||
|
||||
this.editor.subscriptions.subscribeJsMessage(TriggerTextCommit, () => {
|
||||
if (this.textInput) {
|
||||
const textCleaned = textInputCleanup(this.textInput.innerText);
|
||||
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;
|
||||
|
||||
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;
|
||||
window.dispatchEvent(
|
||||
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.editor.dispatcher.subscribeJsMessage(UpdateToolOptionsLayout, (updateToolOptionsLayout) => {
|
||||
this.editor.subscriptions.subscribeJsMessage(UpdateToolOptionsLayout, (updateToolOptionsLayout) => {
|
||||
this.toolOptionsLayout = updateToolOptionsLayout;
|
||||
});
|
||||
|
||||
this.editor.dispatcher.subscribeJsMessage(UpdateDocumentBarLayout, (updateDocumentBarLayout) => {
|
||||
this.editor.subscriptions.subscribeJsMessage(UpdateDocumentBarLayout, (updateDocumentBarLayout) => {
|
||||
this.documentBarLayout = updateDocumentBarLayout;
|
||||
});
|
||||
|
||||
this.editor.dispatcher.subscribeJsMessage(UpdateToolShelfLayout, (updateToolShelfLayout) => {
|
||||
this.editor.subscriptions.subscribeJsMessage(UpdateToolShelfLayout, (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) => {
|
||||
// Using updateImageData.image_data.buffer returns undefined for some reason?
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
// 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() {
|
||||
return {
|
||||
|
@ -525,7 +479,6 @@ export default defineComponent({
|
|||
LayoutRow,
|
||||
LayoutCol,
|
||||
SwatchPairInput,
|
||||
Separator,
|
||||
PersistentScrollbar,
|
||||
CanvasRuler,
|
||||
IconButton,
|
||||
|
|
|
@ -263,7 +263,7 @@
|
|||
<script lang="ts">
|
||||
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 LayoutRow from "@/components/layout/LayoutRow.vue";
|
||||
|
@ -471,15 +471,15 @@ export default defineComponent({
|
|||
},
|
||||
},
|
||||
mounted() {
|
||||
this.editor.dispatcher.subscribeJsMessage(UpdateDocumentLayerTreeStructure, (updateDocumentLayerTreeStructure) => {
|
||||
this.editor.subscriptions.subscribeJsMessage(UpdateDocumentLayerTreeStructure, (updateDocumentLayerTreeStructure) => {
|
||||
this.rebuildLayerTree(updateDocumentLayerTreeStructure);
|
||||
});
|
||||
|
||||
this.editor.dispatcher.subscribeJsMessage(UpdateLayerTreeOptionsLayout, (updateLayerTreeOptionsLayout) => {
|
||||
this.editor.subscriptions.subscribeJsMessage(UpdateLayerTreeOptionsLayout, (updateLayerTreeOptionsLayout) => {
|
||||
this.layerTreeOptionsLayout = updateLayerTreeOptionsLayout;
|
||||
});
|
||||
|
||||
this.editor.dispatcher.subscribeJsMessage(UpdateDocumentLayerDetails, (updateDocumentLayerDetails) => {
|
||||
this.editor.subscriptions.subscribeJsMessage(UpdateDocumentLayerDetails, (updateDocumentLayerDetails) => {
|
||||
const targetPath = updateDocumentLayerDetails.data.path;
|
||||
const targetLayer = updateDocumentLayerDetails.data;
|
||||
|
||||
|
|
|
@ -350,7 +350,6 @@ const GRID_COLLAPSE_SPACING = 10;
|
|||
const GRID_SIZE = 24;
|
||||
|
||||
export default defineComponent({
|
||||
inject: ["editor"],
|
||||
data() {
|
||||
return {
|
||||
transform: { scale: 1, x: 0, y: 0 },
|
||||
|
@ -467,16 +466,13 @@ export default defineComponent({
|
|||
},
|
||||
},
|
||||
mounted() {
|
||||
{
|
||||
const outputPort = document.querySelectorAll(".output.port")[4] as HTMLElement;
|
||||
const inputPort = document.querySelectorAll(".input.port")[1] as HTMLElement;
|
||||
this.createWirePath(outputPort, inputPort, true, true);
|
||||
}
|
||||
{
|
||||
const outputPort = document.querySelectorAll(".output.port")[6] as HTMLElement;
|
||||
const inputPort = document.querySelectorAll(".input.port")[3] as HTMLElement;
|
||||
this.createWirePath(outputPort, inputPort, true, false);
|
||||
}
|
||||
const outputPort1 = document.querySelectorAll(".output.port")[4] as HTMLElement;
|
||||
const inputPort1 = document.querySelectorAll(".input.port")[1] as HTMLElement;
|
||||
this.createWirePath(outputPort1, inputPort1, true, true);
|
||||
|
||||
const outputPort2 = document.querySelectorAll(".output.port")[6] as HTMLElement;
|
||||
const inputPort2 = document.querySelectorAll(".input.port")[3] as HTMLElement;
|
||||
this.createWirePath(outputPort2, inputPort2, true, false);
|
||||
},
|
||||
components: {
|
||||
LayoutRow,
|
||||
|
|
|
@ -42,7 +42,7 @@
|
|||
<script lang="ts">
|
||||
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 LayoutRow from "@/components/layout/LayoutRow.vue";
|
||||
|
@ -58,10 +58,11 @@ export default defineComponent({
|
|||
};
|
||||
},
|
||||
mounted() {
|
||||
this.editor.dispatcher.subscribeJsMessage(UpdatePropertyPanelOptionsLayout, (updatePropertyPanelOptionsLayout) => {
|
||||
this.editor.subscriptions.subscribeJsMessage(UpdatePropertyPanelOptionsLayout, (updatePropertyPanelOptionsLayout) => {
|
||||
this.propertiesOptionsLayout = updatePropertyPanelOptionsLayout;
|
||||
});
|
||||
this.editor.dispatcher.subscribeJsMessage(UpdatePropertyPanelSectionsLayout, (updatePropertyPanelSectionsLayout) => {
|
||||
|
||||
this.editor.subscriptions.subscribeJsMessage(UpdatePropertyPanelSectionsLayout, (updatePropertyPanelSectionsLayout) => {
|
||||
this.propertiesSectionsLayout = updatePropertyPanelSectionsLayout;
|
||||
});
|
||||
},
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
<script lang="ts">
|
||||
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 WidgetSection from "@/components/widgets/WidgetSection.vue";
|
||||
|
|
|
@ -3,11 +3,12 @@
|
|||
<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` -->
|
||||
<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)" />
|
||||
<DropdownInput v-if="component.kind === 'DropdownInput'" v-bind="component.props" @update:selectedIndex="(value: number) => 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" v-model:open="open" @update:selectedIndex="(value: number) => updateLayout(component.widget_id, value)" />
|
||||
<FontInput
|
||||
v-if="component.kind === 'FontInput'"
|
||||
v-bind="component.props"
|
||||
v-model:open="open"
|
||||
@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)" />
|
||||
|
@ -69,7 +70,7 @@
|
|||
<script lang="ts">
|
||||
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 PopoverButton from "@/components/widgets/buttons/PopoverButton.vue";
|
||||
|
@ -93,6 +94,11 @@ export default defineComponent({
|
|||
widgetData: { type: Object as PropType<WidgetColumn | WidgetRow>, required: true },
|
||||
layoutTarget: { required: true },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
open: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
direction() {
|
||||
if (isWidgetColumn(this.widgetData)) return "column";
|
||||
|
|
|
@ -69,7 +69,7 @@
|
|||
<script lang="ts">
|
||||
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 LayoutRow from "@/components/layout/LayoutRow.vue";
|
||||
|
|
|
@ -63,7 +63,7 @@
|
|||
<script lang="ts">
|
||||
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";
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<LayoutRow class="popover-button">
|
||||
<IconButton :action="handleClick" :icon="icon" :size="16" data-hover-menu-spawner />
|
||||
<FloatingMenu :type="'Popover'" :direction="'Bottom'" ref="floatingMenu">
|
||||
<IconButton :action="() => onClick()" :icon="icon" :size="16" data-hover-menu-spawner />
|
||||
<FloatingMenu v-model:open="open" :type="'Popover'" :direction="'Bottom'">
|
||||
<slot></slot>
|
||||
</FloatingMenu>
|
||||
</LayoutRow>
|
||||
|
@ -49,7 +49,7 @@
|
|||
<script lang="ts">
|
||||
import { defineComponent, PropType } from "vue";
|
||||
|
||||
import { PopoverButtonIcon } from "@/utilities/widgets";
|
||||
import { IconName } from "@/utility-functions/icons";
|
||||
|
||||
import LayoutRow from "@/components/layout/LayoutRow.vue";
|
||||
import IconButton from "@/components/widgets/buttons/IconButton.vue";
|
||||
|
@ -63,11 +63,16 @@ export default defineComponent({
|
|||
},
|
||||
props: {
|
||||
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: {
|
||||
handleClick() {
|
||||
(this.$refs.floatingMenu as typeof FloatingMenu).setOpen();
|
||||
onClick() {
|
||||
this.open = true;
|
||||
|
||||
this.action?.();
|
||||
},
|
||||
|
|
16
frontend/src/components/widgets/buttons/TextButton.ts
Normal file
16
frontend/src/components/widgets/buttons/TextButton.ts
Normal file
|
@ -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">
|
||||
import { defineComponent, PropType } from "vue";
|
||||
|
||||
import { RGBA } from "@/dispatcher/js-messages";
|
||||
import { hsvaToRgba, rgbaToHsva } from "@/utilities/color";
|
||||
import { clamp } from "@/utilities/math";
|
||||
import { hsvaToRgba, rgbaToHsva } from "@/utility-functions/color";
|
||||
import { clamp } from "@/utility-functions/math";
|
||||
import { RGBA } from "@/wasm-communication/messages";
|
||||
|
||||
import LayoutCol from "@/components/layout/LayoutCol.vue";
|
||||
import LayoutRow from "@/components/layout/LayoutRow.vue";
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<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">
|
||||
<LayoutCol class="icon-column">
|
||||
<!-- `dialog.state.icon` class exists to provide special sizing in CSS to specific icons -->
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
<template>
|
||||
<div class="floating-menu" :class="[direction.toLowerCase(), type.toLowerCase()]" v-if="open || type === 'Dialog'" ref="floatingMenu">
|
||||
<div class="tail" v-if="type === 'Popover'" ref="tail"></div>
|
||||
<div class="floating-menu-container" ref="floatingMenuContainer">
|
||||
<LayoutCol class="floating-menu-content" data-floating-menu-content :scrollableY="scrollableY" ref="floatingMenuContent" :style="floatingMenuContentStyle">
|
||||
<div class="floating-menu" :class="[direction.toLowerCase(), type.toLowerCase()]" ref="floatingMenu">
|
||||
<div class="tail" v-if="open && type === 'Popover'" ref="tail"></div>
|
||||
<div class="floating-menu-container" v-if="open || measuringOngoing" ref="floatingMenuContainer">
|
||||
<LayoutCol class="floating-menu-content" :style="{ minWidth: minWidthStyleValue }" :scrollableY="scrollableY" ref="floatingMenuContent" data-floating-menu-content>
|
||||
<slot></slot>
|
||||
</LayoutCol>
|
||||
</div>
|
||||
|
@ -175,7 +175,7 @@
|
|||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType, StyleValue } from "vue";
|
||||
import { defineComponent, PropType } from "vue";
|
||||
|
||||
import LayoutCol from "@/components/layout/LayoutCol.vue";
|
||||
|
||||
|
@ -185,158 +185,190 @@ export type MenuType = "Popover" | "Dropdown" | "Dialog";
|
|||
const POINTER_STRAY_DISTANCE = 100;
|
||||
|
||||
export default defineComponent({
|
||||
emits: ["update:open", "naturalWidth"],
|
||||
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 },
|
||||
direction: { type: String as PropType<MenuDirection>, default: "Bottom" },
|
||||
windowEdgeMargin: { type: Number as PropType<number>, default: 6 },
|
||||
minWidth: { type: Number as PropType<number>, default: 0 },
|
||||
scrollableY: { type: Boolean as PropType<boolean>, default: false },
|
||||
minWidth: { type: Number as PropType<number>, default: 0 },
|
||||
},
|
||||
data() {
|
||||
const containerResizeObserver = new ResizeObserver((entries) => {
|
||||
const content = entries[0].target.querySelector("[data-floating-menu-content]") as HTMLElement;
|
||||
content.style.minWidth = `${entries[0].contentRect.width}px`;
|
||||
// The resize observer is attached to the floating menu container, which is the zero-height div of the width of the parent element's floating menu spawner.
|
||||
// Since CSS doesn't let us make the floating menu (with `position: fixed`) have a 100% width of this container, we need to use JS to observe its size and
|
||||
// tell the floating menu content to use it as a min-width so the floating menu is at least the width of the parent element's floating menu spawner.
|
||||
// This is the opposite concern of the natural width measurement system, which gets the natural width of the floating menu content in order for the
|
||||
// spawner widget to optionally set its min-size to the floating menu's natural width.
|
||||
const containerResizeObserver = new ResizeObserver((entries: ResizeObserverEntry[]) => {
|
||||
this.resizeObserverCallback(entries);
|
||||
});
|
||||
|
||||
return {
|
||||
open: false,
|
||||
pointerStillDown: false,
|
||||
measuringOngoing: false,
|
||||
measuringOngoingGuard: false,
|
||||
minWidthParentWidth: 0,
|
||||
containerResizeObserver,
|
||||
pointerStillDown: false,
|
||||
workspaceBounds: new DOMRect(),
|
||||
floatingMenuBounds: new DOMRect(),
|
||||
floatingMenuContentBounds: new DOMRect(),
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
minWidthStyleValue() {
|
||||
if (this.measuringOngoing) return "0";
|
||||
return `${Math.max(this.minWidth, this.minWidthParentWidth)}px`;
|
||||
},
|
||||
},
|
||||
// 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
|
||||
updated() {
|
||||
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;
|
||||
async updated() {
|
||||
// Turning measuring on and off both cause the component to change, which causes the `updated()` Vue event to fire extraneous times (hurting performance and sometimes causing an infinite loop)
|
||||
if (this.measuringOngoingGuard) return;
|
||||
|
||||
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 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;
|
||||
}
|
||||
}
|
||||
this.positionAndStyleFloatingMenu();
|
||||
},
|
||||
methods: {
|
||||
setOpen() {
|
||||
this.open = true;
|
||||
resizeObserverCallback(entries: ResizeObserverEntry[]) {
|
||||
this.minWidthParentWidth = entries[0].contentRect.width;
|
||||
},
|
||||
setClosed() {
|
||||
this.open = false;
|
||||
positionAndStyleFloatingMenu() {
|
||||
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 {
|
||||
return this.open;
|
||||
},
|
||||
getWidth(callback: (width: number) => void) {
|
||||
this.$nextTick(() => {
|
||||
// To be called by the parent component. Measures the actual width of the floating menu content element and returns it in a promise.
|
||||
async measureAndEmitNaturalWidth(): Promise<void> {
|
||||
// Wait for the changed content which fired the `updated()` Vue event to be put into the DOM
|
||||
await 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 width = floatingMenuContent.clientWidth;
|
||||
callback(width);
|
||||
});
|
||||
},
|
||||
disableMinWidth(callback: (minWidth: string) => void) {
|
||||
this.$nextTick(() => {
|
||||
const floatingMenuContent: HTMLElement = (this.$refs.floatingMenuContent as typeof LayoutCol).$el;
|
||||
const initialMinWidth = floatingMenuContent.style.minWidth;
|
||||
floatingMenuContent.style.minWidth = "0";
|
||||
callback(initialMinWidth);
|
||||
});
|
||||
},
|
||||
enableMinWidth(minWidth: string) {
|
||||
const floatingMenuContent: HTMLElement = (this.$refs.floatingMenuContent as typeof LayoutCol).$el;
|
||||
floatingMenuContent.style.minWidth = minWidth;
|
||||
naturalWidth = floatingMenuContent?.clientWidth;
|
||||
}
|
||||
|
||||
// Turn off measuring mode for the component, which triggers another call to the `updated()` Vue event, so we can turn off the protection after that has happened
|
||||
this.measuringOngoing = false;
|
||||
await this.$nextTick();
|
||||
this.measuringOngoingGuard = false;
|
||||
|
||||
// Emit the measured natural width to the parent
|
||||
if (naturalWidth !== undefined && naturalWidth >= 0) {
|
||||
this.$emit("naturalWidth", naturalWidth);
|
||||
}
|
||||
},
|
||||
pointerMoveHandler(e: PointerEvent) {
|
||||
const target = e.target 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 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
|
||||
if (pointerOverFloatingMenuSpawner && !pointerOverOwnFloatingMenuSpawner) {
|
||||
this.setClosed();
|
||||
this.$emit("update:open", false);
|
||||
pointerOverFloatingMenuSpawner.click();
|
||||
}
|
||||
|
||||
// Close the floating menu if the pointer has strayed far enough from its bounds
|
||||
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
|
||||
// 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
|
||||
const eventIncludesLmb = Boolean(e.buttons & 1);
|
||||
if (!this.open && !eventIncludesLmb) {
|
||||
this.pointerStillDown = false;
|
||||
window.removeEventListener("pointerup", this.pointerUpHandler);
|
||||
|
@ -345,7 +377,8 @@ export default defineComponent({
|
|||
pointerDownHandler(e: PointerEvent) {
|
||||
// Close the floating menu if the pointer clicked outside the floating menu (but within stray distance)
|
||||
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
|
||||
const eventIsForLmb = e.button === 0;
|
||||
if (eventIsForLmb) this.pointerStillDown = true;
|
||||
|
@ -384,6 +417,7 @@ export default defineComponent({
|
|||
},
|
||||
},
|
||||
watch: {
|
||||
// Called only when `open` is changed from outside this component (with v-model)
|
||||
open(newState: boolean, oldState: boolean) {
|
||||
// Switching from closed to open
|
||||
if (newState && !oldState) {
|
||||
|
@ -396,28 +430,25 @@ export default defineComponent({
|
|||
// Floating menu min-width resize observer
|
||||
this.$nextTick(() => {
|
||||
const floatingMenuContainer = this.$refs.floatingMenuContainer as HTMLElement;
|
||||
if (floatingMenuContainer) {
|
||||
this.containerResizeObserver.disconnect();
|
||||
this.containerResizeObserver.observe(floatingMenuContainer);
|
||||
}
|
||||
if (!floatingMenuContainer) return;
|
||||
|
||||
// Start a new observation of the now-open floating menu
|
||||
this.containerResizeObserver.disconnect();
|
||||
this.containerResizeObserver.observe(floatingMenuContainer);
|
||||
});
|
||||
}
|
||||
|
||||
// Switching from open to closed
|
||||
if (!newState && oldState) {
|
||||
// Clean up observation of the now-closed floating menu
|
||||
this.containerResizeObserver.disconnect();
|
||||
|
||||
window.removeEventListener("pointermove", this.pointerMoveHandler);
|
||||
window.removeEventListener("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 },
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -1,16 +1,24 @@
|
|||
<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">
|
||||
<Separator :type="'List'" :direction="'Vertical'" v-if="sectionIndex > 0" />
|
||||
<LayoutRow
|
||||
v-for="(entry, entryIndex) in section"
|
||||
:key="entryIndex"
|
||||
class="row"
|
||||
:class="{ open: isMenuEntryOpen(entry), active: entry === activeEntry }"
|
||||
@click="() => handleEntryClick(entry)"
|
||||
@pointerenter="() => handleEntryPointerEnter(entry)"
|
||||
@pointerleave="() => handleEntryPointerLeave(entry)"
|
||||
:data-hover-menu-spawner-extend="entry.children && []"
|
||||
:class="{ open: isEntryOpen(entry), active: entry.label === activeEntry?.label }"
|
||||
@click="() => onEntryClick(entry)"
|
||||
@pointerenter="() => onEntryPointerEnter(entry)"
|
||||
@pointerleave="() => onEntryPointerLeave(entry)"
|
||||
>
|
||||
<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" />
|
||||
|
@ -26,10 +34,12 @@
|
|||
|
||||
<MenuList
|
||||
v-if="entry.children"
|
||||
@naturalWidth="(newNaturalWidth: number) => $emit('naturalWidth', newNaturalWidth)"
|
||||
:open="entry.ref?.open || false"
|
||||
:direction="'TopRight'"
|
||||
:entries="entry.children"
|
||||
v-bind="{ defaultAction, minWidth, drawIcon, scrollableY }"
|
||||
:ref="(ref: any) => setEntryRefs(entry, ref)"
|
||||
:ref="(ref: typeof FloatingMenu) => ref && (entry.ref = ref)"
|
||||
/>
|
||||
</LayoutRow>
|
||||
</template>
|
||||
|
@ -131,7 +141,7 @@
|
|||
<script lang="ts">
|
||||
import { defineComponent, PropType } from "vue";
|
||||
|
||||
import { IconName } from "@/utilities/icons";
|
||||
import { IconName } from "@/utility-functions/icons";
|
||||
|
||||
import LayoutRow from "@/components/layout/LayoutRow.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 MenuList = defineComponent({
|
||||
emits: {
|
||||
"update:activeEntry": null,
|
||||
widthChanged: (width: number) => typeof width === "number",
|
||||
},
|
||||
inject: ["fullscreen"],
|
||||
emits: ["update:open", "update:activeEntry", "naturalWidth"],
|
||||
props: {
|
||||
direction: { type: String as PropType<MenuDirection>, default: "Bottom" },
|
||||
entries: { type: Array as PropType<SectionsOfMenuListEntries>, required: true },
|
||||
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 },
|
||||
drawIcon: { 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: {
|
||||
setEntryRefs(menuEntry: MenuListEntry, ref: typeof FloatingMenu): void {
|
||||
if (ref) menuEntry.ref = ref;
|
||||
},
|
||||
handleEntryClick(menuEntry: MenuListEntry): void {
|
||||
(this.$refs.floatingMenu as typeof FloatingMenu).setClosed();
|
||||
|
||||
onEntryClick(menuEntry: MenuListEntry): void {
|
||||
// Toggle checkbox
|
||||
// TODO: This is broken at the moment, fix it when we get rid of using `ref`
|
||||
if (menuEntry.checkbox) menuEntry.checked = !menuEntry.checked;
|
||||
|
||||
// Call the action, or a default, if either are provided
|
||||
if (menuEntry.action) menuEntry.action();
|
||||
else if (this.defaultAction) this.defaultAction();
|
||||
|
||||
// Emit the clicked entry as the new active entry
|
||||
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.ref) menuEntry.ref.setOpen();
|
||||
else throw new Error("The menu bar floating menu has no associated ref");
|
||||
if (menuEntry.ref) menuEntry.ref.isOpen = true;
|
||||
else this.$emit("update:open", true);
|
||||
},
|
||||
handleEntryPointerLeave(menuEntry: MenuListEntry): void {
|
||||
onEntryPointerLeave(menuEntry: MenuListEntry): void {
|
||||
if (!menuEntry.children?.length) return;
|
||||
|
||||
if (menuEntry.ref) menuEntry.ref.setClosed();
|
||||
else throw new Error("The menu bar floating menu has no associated ref");
|
||||
if (menuEntry.ref) menuEntry.ref.isOpen = false;
|
||||
else this.$emit("update:open", false);
|
||||
},
|
||||
isMenuEntryOpen(menuEntry: MenuListEntry): boolean {
|
||||
isEntryOpen(menuEntry: MenuListEntry): boolean {
|
||||
if (!menuEntry.children?.length) return false;
|
||||
|
||||
if (menuEntry.ref) return menuEntry.ref.isOpen();
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
return this.open;
|
||||
},
|
||||
},
|
||||
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: {
|
||||
FloatingMenu,
|
||||
Separator,
|
||||
|
|
|
@ -84,7 +84,7 @@
|
|||
<script lang="ts">
|
||||
import { defineComponent, PropType } from "vue";
|
||||
|
||||
import { IconName } from "@/utilities/icons";
|
||||
import { IconName } from "@/utility-functions/icons";
|
||||
|
||||
import LayoutRow from "@/components/layout/LayoutRow.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" />
|
||||
<Separator :type="'Related'" />
|
||||
<LayoutRow class="swatch">
|
||||
<button class="swatch-button" :class="{ 'disabled-swatch': !value }" :style="`--swatch-color: #${value}`" @click="() => menuOpen()"></button>
|
||||
<FloatingMenu :type="'Popover'" :direction="'Bottom'" horizontal ref="colorFloatingMenu">
|
||||
<button class="swatch-button" :class="{ 'disabled-swatch': !value }" :style="`--swatch-color: #${value}`" @click="() => $emit('update:open', true)"></button>
|
||||
<FloatingMenu v-model:open="isOpen" :type="'Popover'" :direction="'Bottom'">
|
||||
<ColorPicker @update:color="(color) => colorPickerUpdated(color)" :color="color" />
|
||||
</FloatingMenu>
|
||||
</LayoutRow>
|
||||
|
@ -70,7 +70,7 @@
|
|||
<script lang="ts">
|
||||
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 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";
|
||||
|
||||
export default defineComponent({
|
||||
emits: ["update:value"],
|
||||
emits: ["update:value", "update:open"],
|
||||
props: {
|
||||
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 },
|
||||
canSetTransparent: { type: Boolean as PropType<boolean>, required: false, default: true },
|
||||
disabled: { type: Boolean as PropType<boolean>, default: false },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isOpen: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
color() {
|
||||
if (!this.value) return { r: 0, g: 0, b: 0, a: 1 };
|
||||
|
@ -105,6 +111,15 @@ export default defineComponent({
|
|||
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: {
|
||||
colorPickerUpdated(color: RGBA) {
|
||||
const twoDigitHex = (value: number): string => value.toString(16).padStart(2, "0");
|
||||
|
@ -134,9 +149,6 @@ export default defineComponent({
|
|||
|
||||
this.$emit("update:value", sanitized);
|
||||
},
|
||||
menuOpen() {
|
||||
(this.$refs.colorFloatingMenu as typeof FloatingMenu).setOpen();
|
||||
},
|
||||
updateEnabled(value: boolean) {
|
||||
if (value) this.$emit("update:value", "000000");
|
||||
else this.$emit("update:value", undefined);
|
||||
|
|
|
@ -1,19 +1,18 @@
|
|||
<template>
|
||||
<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" />
|
||||
<span>{{ activeEntry.label }}</span>
|
||||
<IconLabel class="dropdown-arrow" :icon="'DropdownArrow'" />
|
||||
</LayoutRow>
|
||||
<MenuList
|
||||
v-model:activeEntry="activeEntry"
|
||||
@update:activeEntry="(newActiveEntry: typeof MENU_LIST_ENTRY) => activeEntryChanged(newActiveEntry)"
|
||||
@widthChanged="(newWidth: number) => onWidthChanged(newWidth)"
|
||||
v-model:open="open"
|
||||
@naturalWidth="(newNaturalWidth: number) => (minWidth = newNaturalWidth)"
|
||||
:entries="entries"
|
||||
:direction="'Bottom'"
|
||||
:drawIcon="drawIcon"
|
||||
:direction="'Bottom'"
|
||||
:scrollableY="true"
|
||||
ref="menuList"
|
||||
/>
|
||||
</LayoutRow>
|
||||
</template>
|
||||
|
@ -86,16 +85,13 @@
|
|||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType } from "vue";
|
||||
import { defineComponent, PropType, toRaw } from "vue";
|
||||
|
||||
import LayoutRow from "@/components/layout/LayoutRow.vue";
|
||||
import MenuList, { MenuListEntry, SectionsOfMenuListEntries } from "@/components/widgets/floating-menus/MenuList.vue";
|
||||
import IconLabel from "@/components/widgets/labels/IconLabel.vue";
|
||||
|
||||
// Satisfies Volar (https://github.com/johnsoncodehk/volar/issues/596)
|
||||
declare global {
|
||||
const MENU_LIST_ENTRY: MenuListEntry;
|
||||
}
|
||||
const DASH_ENTRY = { label: "-" };
|
||||
|
||||
export default defineComponent({
|
||||
emits: ["update:selectedIndex"],
|
||||
|
@ -107,32 +103,31 @@ export default defineComponent({
|
|||
},
|
||||
data() {
|
||||
return {
|
||||
activeEntry: this.selectedIndex !== undefined ? this.entries.flat()[this.selectedIndex] : { label: "-" },
|
||||
activeEntry: this.makeActiveEntry(this.selectedIndex),
|
||||
open: false,
|
||||
minWidth: 0,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
// Called only when `selectedIndex` is changed from outside this component (with v-model)
|
||||
selectedIndex(newSelectedIndex: number | undefined) {
|
||||
const entries = this.entries.flat();
|
||||
selectedIndex() {
|
||||
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.activeEntry = entries[newSelectedIndex];
|
||||
} else {
|
||||
this.activeEntry = { label: "-" };
|
||||
}
|
||||
this.$emit("update:selectedIndex", this.entries.flat().indexOf(newActiveEntry));
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
// Called only when `activeEntry` is changed from the child MenuList component via user input
|
||||
activeEntryChanged(newActiveEntry: MenuListEntry) {
|
||||
this.$emit("update:selectedIndex", this.entries.flat().indexOf(newActiveEntry));
|
||||
},
|
||||
clickDropdownBox() {
|
||||
if (!this.disabled) (this.$refs.menuList as typeof MenuList).setOpen();
|
||||
},
|
||||
onWidthChanged(newWidth: number) {
|
||||
this.minWidth = newWidth;
|
||||
makeActiveEntry(): MenuListEntry {
|
||||
const entries = this.entries.flat();
|
||||
|
||||
if (this.selectedIndex !== undefined && this.selectedIndex >= 0 && this.selectedIndex < entries.length) {
|
||||
return entries[this.selectedIndex];
|
||||
}
|
||||
return DASH_ENTRY;
|
||||
},
|
||||
},
|
||||
components: {
|
||||
|
|
|
@ -1,10 +1,17 @@
|
|||
<template>
|
||||
<LayoutRow class="font-input">
|
||||
<LayoutRow class="dropdown-box" :class="{ disabled }" :style="{ minWidth: `${minWidth}px` }" @click="() => clickDropdownBox()" data-hover-menu-spawner>
|
||||
<span>{{ activeEntry.label }}</span>
|
||||
<LayoutRow class="dropdown-box" :class="{ disabled }" :style="{ minWidth: `${minWidth}px` }" @click="() => !disabled && (open = true)" data-hover-menu-spawner>
|
||||
<span>{{ activeEntry?.label || "" }}</span>
|
||||
<IconLabel class="dropdown-arrow" :icon="'DropdownArrow'" />
|
||||
</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>
|
||||
</template>
|
||||
|
||||
|
@ -78,13 +85,12 @@
|
|||
<script lang="ts">
|
||||
import { defineComponent, PropType } from "vue";
|
||||
|
||||
import { fontNames, getFontFile, getFontStyles } from "@/utilities/fonts";
|
||||
|
||||
import LayoutRow from "@/components/layout/LayoutRow.vue";
|
||||
import MenuList, { MenuListEntry, SectionsOfMenuListEntries } from "@/components/widgets/floating-menus/MenuList.vue";
|
||||
import IconLabel from "@/components/widgets/labels/IconLabel.vue";
|
||||
|
||||
export default defineComponent({
|
||||
inject: ["fonts"],
|
||||
emits: ["update:fontFamily", "update:fontStyle", "changeFont"],
|
||||
props: {
|
||||
fontFamily: { type: String as PropType<string>, required: true },
|
||||
|
@ -93,18 +99,20 @@ export default defineComponent({
|
|||
isStyle: { type: Boolean as PropType<boolean>, default: false },
|
||||
},
|
||||
data() {
|
||||
const { entries, activeEntry } = this.updateEntries();
|
||||
return {
|
||||
entries,
|
||||
activeEntry,
|
||||
open: false,
|
||||
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: {
|
||||
clickDropdownBox() {
|
||||
if (!this.disabled) (this.$refs.menuList as typeof MenuList).setOpen();
|
||||
},
|
||||
selectFont(newName: string) {
|
||||
async selectFont(newName: string): Promise<void> {
|
||||
let fontFamily;
|
||||
let fontStyle;
|
||||
|
||||
|
@ -117,24 +125,21 @@ export default defineComponent({
|
|||
this.$emit("update:fontFamily", newName);
|
||||
|
||||
fontFamily = newName;
|
||||
fontStyle = getFontStyles(newName)[0];
|
||||
fontStyle = (await this.fonts.getFontStyles(newName))[0];
|
||||
}
|
||||
|
||||
const fontFile = getFontFile(fontFamily, fontStyle);
|
||||
this.$emit("changeFont", { fontFamily, fontStyle, fontFile });
|
||||
const fontFileUrl = await this.fonts.getFontFileUrl(fontFamily, fontStyle);
|
||||
this.$emit("changeFont", { fontFamily, fontStyle, fontFileUrl });
|
||||
},
|
||||
onWidthChanged(newWidth: number) {
|
||||
this.minWidth = newWidth;
|
||||
},
|
||||
updateEntries(): { entries: SectionsOfMenuListEntries; activeEntry: MenuListEntry } {
|
||||
const choices = this.isStyle ? getFontStyles(this.fontFamily) : fontNames();
|
||||
async updateEntries(): Promise<{ entries: SectionsOfMenuListEntries; activeEntry: MenuListEntry }> {
|
||||
const choices = this.isStyle ? await this.fonts.getFontStyles(this.fontFamily) : this.fonts.state.fontNames;
|
||||
const selectedChoice = this.isStyle ? this.fontStyle : this.fontFamily;
|
||||
|
||||
let selectedEntry: MenuListEntry | undefined;
|
||||
const menuListEntries = choices.map((name) => {
|
||||
const result: MenuListEntry = {
|
||||
label: name,
|
||||
action: (): void => this.selectFont(name),
|
||||
action: async (): Promise<void> => this.selectFont(name),
|
||||
};
|
||||
|
||||
if (name === selectedChoice) selectedEntry = result;
|
||||
|
@ -149,13 +154,13 @@ export default defineComponent({
|
|||
},
|
||||
},
|
||||
watch: {
|
||||
fontFamily() {
|
||||
const { entries, activeEntry } = this.updateEntries();
|
||||
async fontFamily() {
|
||||
const { entries, activeEntry } = await this.updateEntries();
|
||||
this.entries = entries;
|
||||
this.activeEntry = activeEntry;
|
||||
},
|
||||
fontStyle() {
|
||||
const { entries, activeEntry } = this.updateEntries();
|
||||
async fontStyle() {
|
||||
const { entries, activeEntry } = await this.updateEntries();
|
||||
this.entries = entries;
|
||||
this.activeEntry = activeEntry;
|
||||
},
|
||||
|
|
|
@ -6,11 +6,19 @@
|
|||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<IconLabel :icon="entry.icon" v-if="entry.icon" />
|
||||
<div @click="() => onClick(entry)" class="entry" :class="{ open: entry.ref?.open }" data-hover-menu-spawner>
|
||||
<IconLabel v-if="entry.icon" :icon="entry.icon" />
|
||||
<span v-if="entry.label">{{ entry.label }}</span>
|
||||
</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>
|
||||
</template>
|
||||
|
@ -53,12 +61,12 @@
|
|||
<script lang="ts">
|
||||
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 IconLabel from "@/components/widgets/labels/IconLabel.vue";
|
||||
|
||||
function makeEntries(editor: EditorState): MenuListEntries {
|
||||
function makeEntries(editor: Editor): MenuListEntries {
|
||||
return [
|
||||
{
|
||||
label: "File",
|
||||
|
@ -131,14 +139,14 @@ function makeEntries(editor: EditorState): MenuListEntries {
|
|||
{
|
||||
label: "Raise To Front",
|
||||
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: "Lower", shortcut: ["KeyControl", "KeyLeftBracket"], action: async (): Promise<void> => editor.instance.reorder_selected_layers(-1) },
|
||||
{
|
||||
label: "Lower to Back",
|
||||
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({
|
||||
inject: ["workspace", "editor", "dialog"],
|
||||
inject: ["editor"],
|
||||
methods: {
|
||||
setEntryRefs(menuEntry: MenuListEntry, ref: typeof MenuList) {
|
||||
if (ref) menuEntry.ref = ref;
|
||||
},
|
||||
handleEntryClick(menuEntry: MenuListEntry) {
|
||||
if (menuEntry.ref) menuEntry.ref.setOpen();
|
||||
onClick(menuEntry: MenuListEntry) {
|
||||
if (menuEntry.ref) menuEntry.ref.isOpen = true;
|
||||
else throw new Error("The menu bar floating menu has no associated ref");
|
||||
},
|
||||
// TODO: Move to backend
|
||||
visitWebsite(url: string) {
|
||||
// This method is required because `window` isn't accessible from the Vue component HTML
|
||||
window.open(url, "_blank");
|
||||
|
@ -214,7 +220,7 @@ export default defineComponent({
|
|||
data() {
|
||||
return {
|
||||
entries: makeEntries(this.editor),
|
||||
comingSoon: (): void => this.dialog.comingSoon(),
|
||||
open: false,
|
||||
};
|
||||
},
|
||||
components: {
|
||||
|
|
|
@ -87,10 +87,11 @@
|
|||
<script lang="ts">
|
||||
import { defineComponent, PropType } from "vue";
|
||||
|
||||
import { IncrementBehavior, IncrementDirection } from "@/utilities/widgets";
|
||||
|
||||
import FieldInput from "@/components/widgets/inputs/FieldInput.vue";
|
||||
|
||||
type IncrementBehavior = "Add" | "Multiply" | "Callback" | "None";
|
||||
type IncrementDirection = "Decrease" | "Increase";
|
||||
|
||||
export default defineComponent({
|
||||
emits: ["update:value"],
|
||||
props: {
|
||||
|
|
|
@ -37,7 +37,7 @@
|
|||
<script lang="ts">
|
||||
import { defineComponent, PropType } from "vue";
|
||||
|
||||
import { IconName } from "@/utilities/icons";
|
||||
import { IconName } from "@/utility-functions/icons";
|
||||
|
||||
import LayoutRow from "@/components/layout/LayoutRow.vue";
|
||||
import CheckboxInput from "@/components/widgets/inputs/CheckboxInput.vue";
|
||||
|
|
|
@ -64,7 +64,7 @@
|
|||
<script lang="ts">
|
||||
import { defineComponent, PropType } from "vue";
|
||||
|
||||
import { IconName } from "@/utilities/icons";
|
||||
import { IconName } from "@/utility-functions/icons";
|
||||
|
||||
import LayoutRow from "@/components/layout/LayoutRow.vue";
|
||||
import IconLabel from "@/components/widgets/labels/IconLabel.vue";
|
||||
|
|
|
@ -2,14 +2,14 @@
|
|||
<LayoutCol class="swatch-pair">
|
||||
<LayoutRow class="secondary swatch">
|
||||
<button @click="() => clickSecondarySwatch()" ref="secondaryButton" data-hover-menu-spawner></button>
|
||||
<FloatingMenu :type="'Popover'" :direction="'Right'" horizontal ref="secondarySwatchFloatingMenu">
|
||||
<ColorPicker @update:color="(color: RGBA_) => secondaryColorChanged(color)" :color="secondaryColor" />
|
||||
<FloatingMenu :type="'Popover'" :direction="'Right'" v-model:open="secondaryOpen">
|
||||
<ColorPicker @update:color="(color: RGBA) => secondaryColorChanged(color)" :color="secondaryColor" />
|
||||
</FloatingMenu>
|
||||
</LayoutRow>
|
||||
<LayoutRow class="primary swatch">
|
||||
<button @click="() => clickPrimarySwatch()" ref="primaryButton" data-hover-menu-spawner></button>
|
||||
<FloatingMenu :type="'Popover'" :direction="'Right'" horizontal ref="primarySwatchFloatingMenu">
|
||||
<ColorPicker @update:color="(color: RGBA_) => primaryColorChanged(color)" :color="primaryColor" />
|
||||
<FloatingMenu :type="'Popover'" :direction="'Right'" v-model:open="primaryOpen">
|
||||
<ColorPicker @update:color="(color: RGBA) => primaryColorChanged(color)" :color="primaryColor" />
|
||||
</FloatingMenu>
|
||||
</LayoutRow>
|
||||
</LayoutCol>
|
||||
|
@ -68,19 +68,14 @@
|
|||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
|
||||
import { type RGBA, UpdateWorkingColors } from "@/dispatcher/js-messages";
|
||||
import { rgbaToDecimalRgba } from "@/utilities/color";
|
||||
import { rgbaToDecimalRgba } from "@/utility-functions/color";
|
||||
import { type RGBA, UpdateWorkingColors } from "@/wasm-communication/messages";
|
||||
|
||||
import LayoutCol from "@/components/layout/LayoutCol.vue";
|
||||
import LayoutRow from "@/components/layout/LayoutRow.vue";
|
||||
import ColorPicker from "@/components/widgets/floating-menus/ColorPicker.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({
|
||||
inject: ["editor"],
|
||||
components: {
|
||||
|
@ -89,14 +84,22 @@ export default defineComponent({
|
|||
LayoutRow,
|
||||
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: {
|
||||
clickPrimarySwatch() {
|
||||
(this.$refs.primarySwatchFloatingMenu as typeof FloatingMenu).setOpen();
|
||||
(this.$refs.secondarySwatchFloatingMenu as typeof FloatingMenu).setClosed();
|
||||
this.primaryOpen = true;
|
||||
this.secondaryOpen = false;
|
||||
},
|
||||
clickSecondarySwatch() {
|
||||
(this.$refs.secondarySwatchFloatingMenu as typeof FloatingMenu).setOpen();
|
||||
(this.$refs.primarySwatchFloatingMenu as typeof FloatingMenu).setClosed();
|
||||
this.primaryOpen = false;
|
||||
this.secondaryOpen = true;
|
||||
},
|
||||
primaryColorChanged(color: RGBA) {
|
||||
this.primaryColor = color;
|
||||
|
@ -123,14 +126,8 @@ export default defineComponent({
|
|||
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() {
|
||||
this.editor.dispatcher.subscribeJsMessage(UpdateWorkingColors, (updateWorkingColors) => {
|
||||
this.editor.subscriptions.subscribeJsMessage(UpdateWorkingColors, (updateWorkingColors) => {
|
||||
this.primaryColor = updateWorkingColors.primary.toRgba();
|
||||
this.secondaryColor = updateWorkingColors.secondary.toRgba();
|
||||
|
||||
|
|
|
@ -35,7 +35,7 @@
|
|||
<script lang="ts">
|
||||
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";
|
||||
|
||||
|
|
|
@ -99,9 +99,8 @@
|
|||
<script lang="ts">
|
||||
import { defineComponent, PropType } from "vue";
|
||||
|
||||
import { HintInfo, KeysGroup } from "@/dispatcher/js-messages";
|
||||
|
||||
import { IconName } from "@/utilities/icons";
|
||||
import { IconName } from "@/utility-functions/icons";
|
||||
import { HintInfo, KeysGroup } from "@/wasm-communication/messages";
|
||||
|
||||
import LayoutRow from "@/components/layout/LayoutRow.vue";
|
||||
import IconLabel from "@/components/widgets/labels/IconLabel.vue";
|
||||
|
|
|
@ -121,7 +121,7 @@ export default defineComponent({
|
|||
},
|
||||
},
|
||||
methods: {
|
||||
handleResize() {
|
||||
resize() {
|
||||
if (!this.$refs.rulerRef) return;
|
||||
|
||||
const rulerElement = this.$refs.rulerRef as HTMLElement;
|
||||
|
|
|
@ -155,6 +155,10 @@ export default defineComponent({
|
|||
window.addEventListener("pointerup", this.pointerUp);
|
||||
window.addEventListener("pointermove", this.pointerMove);
|
||||
},
|
||||
unmounted() {
|
||||
window.removeEventListener("pointerup", this.pointerUp);
|
||||
window.removeEventListener("pointermove", this.pointerMove);
|
||||
},
|
||||
methods: {
|
||||
trackLength(): number {
|
||||
const track = this.$refs.scrollTrack as HTMLElement;
|
||||
|
|
|
@ -75,7 +75,8 @@
|
|||
<script lang="ts">
|
||||
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({
|
||||
props: {
|
||||
|
|
|
@ -44,7 +44,7 @@
|
|||
<script lang="ts">
|
||||
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 UserInputLabel from "@/components/widgets/labels/UserInputLabel.vue";
|
||||
|
@ -58,7 +58,7 @@ export default defineComponent({
|
|||
};
|
||||
},
|
||||
mounted() {
|
||||
this.editor.dispatcher.subscribeJsMessage(UpdateInputHints, (updateInputHints) => {
|
||||
this.editor.subscriptions.subscribeJsMessage(UpdateInputHints, (updateInputHints) => {
|
||||
this.hintData = updateInputHints.hint_data;
|
||||
});
|
||||
},
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
<MenuBarInput v-if="platform !== 'Mac'" />
|
||||
</LayoutRow>
|
||||
<LayoutRow class="header-part">
|
||||
<WindowTitle :text="`${activeDocumentDisplayName} - Graphite`" />
|
||||
<WindowTitle :text="windowTitle" />
|
||||
</LayoutRow>
|
||||
<LayoutRow class="header-part">
|
||||
<WindowButtonsWindows :maximized="maximized" v-if="platform === 'Windows' || platform === 'Linux'" />
|
||||
|
@ -56,8 +56,11 @@ export default defineComponent({
|
|||
maximized: { type: Boolean as PropType<boolean>, required: true },
|
||||
},
|
||||
computed: {
|
||||
activeDocumentDisplayName() {
|
||||
return this.portfolio.state.documents[this.portfolio.state.activeDocumentIndex].displayName;
|
||||
windowTitle(): string {
|
||||
const activeDocumentIndex = this.portfolio.state.activeDocumentIndex;
|
||||
const activeDocumentDisplayName = this.portfolio.state.documents[activeDocumentIndex]?.displayName || "";
|
||||
|
||||
return `${activeDocumentDisplayName}${activeDocumentDisplayName && " - "}Graphite`;
|
||||
},
|
||||
},
|
||||
components: {
|
||||
|
|
|
@ -168,7 +168,6 @@ const panelComponents = {
|
|||
type PanelTypes = keyof typeof panelComponents;
|
||||
|
||||
export default defineComponent({
|
||||
inject: ["portfolio"],
|
||||
props: {
|
||||
tabMinWidths: { type: Boolean as PropType<boolean>, default: false },
|
||||
tabCloseButtons: { type: Boolean as PropType<boolean>, default: false },
|
||||
|
|
26
frontend/src/io-managers/build-metadata.ts
Normal file
26
frontend/src/io-managers/build-metadata.ts
Normal file
|
@ -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
frontend/src/io-managers/clipboard.ts
Normal file
10
frontend/src/io-managers/clipboard.ts
Normal 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
frontend/src/io-managers/hyperlinks.ts
Normal file
9
frontend/src/io-managers/hyperlinks.ts
Normal 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 { FullscreenState } from "@/state/fullscreen";
|
||||
import { PortfolioState } from "@/state/portfolio";
|
||||
import { EditorState } from "@/state/wasm-loader";
|
||||
import { DialogState } from "@/state-providers/dialog";
|
||||
import { FullscreenState } from "@/state-providers/fullscreen";
|
||||
import { PortfolioState } from "@/state-providers/portfolio";
|
||||
import { makeKeyboardModifiersBitfield, textInputCleanup, getLatinKey } from "@/utility-functions/keyboard-entry";
|
||||
import { Editor } from "@/wasm-communication/editor";
|
||||
|
||||
type EventName = keyof HTMLElementEventMap | keyof WindowEventHandlersEventMap | "modifyinputfield";
|
||||
interface EventListenerTarget {
|
||||
type EventListenerTarget = {
|
||||
addEventListener: typeof window.addEventListener;
|
||||
removeEventListener: typeof window.removeEventListener;
|
||||
}
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
||||
export function createInputManager(editor: EditorState, container: HTMLElement, dialog: DialogState, document: PortfolioState, fullscreen: FullscreenState) {
|
||||
export function createInputManager(editor: Editor, container: HTMLElement, dialog: DialogState, document: PortfolioState, fullscreen: FullscreenState): () => void {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const listeners: { target: EventListenerTarget; eventName: EventName; action: (event: any) => void; options?: boolean | AddEventListenerOptions }[] = [
|
||||
{ target: window, eventName: "resize", action: (): void => onWindowResize(container) },
|
||||
|
@ -34,7 +34,7 @@ export function createInputManager(editor: EditorState, container: HTMLElement,
|
|||
|
||||
// Keyboard events
|
||||
|
||||
const shouldRedirectKeyboardEventToBackend = (e: KeyboardEvent): boolean => {
|
||||
function shouldRedirectKeyboardEventToBackend(e: KeyboardEvent): boolean {
|
||||
// Don't redirect when a modal is covering the workspace
|
||||
if (dialog.dialogIsVisible()) return false;
|
||||
|
||||
|
@ -76,15 +76,15 @@ export function createInputManager(editor: EditorState, container: HTMLElement,
|
|||
|
||||
// Redirect to the backend
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent): void => {
|
||||
function onKeyDown(e: KeyboardEvent): void {
|
||||
const key = getLatinKey(e);
|
||||
if (!key) return;
|
||||
|
||||
if (shouldRedirectKeyboardEventToBackend(e)) {
|
||||
e.preventDefault();
|
||||
const modifiers = makeModifiersBitfield(e);
|
||||
const modifiers = makeKeyboardModifiersBitfield(e);
|
||||
editor.instance.on_key_down(key, modifiers);
|
||||
return;
|
||||
}
|
||||
|
@ -92,23 +92,23 @@ export function createInputManager(editor: EditorState, container: HTMLElement,
|
|||
if (dialog.dialogIsVisible()) {
|
||||
if (key === "escape") dialog.dismissDialog();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const onKeyUp = (e: KeyboardEvent): void => {
|
||||
function onKeyUp(e: KeyboardEvent): void {
|
||||
const key = getLatinKey(e);
|
||||
if (!key) return;
|
||||
|
||||
if (shouldRedirectKeyboardEventToBackend(e)) {
|
||||
e.preventDefault();
|
||||
const modifiers = makeModifiersBitfield(e);
|
||||
const modifiers = makeKeyboardModifiersBitfield(e);
|
||||
editor.instance.on_key_up(key, modifiers);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Pointer events
|
||||
|
||||
// While any pointer button is already down, additional button down events are not reported, but they are sent as `pointermove` events and these are handled in the backend
|
||||
const onPointerMove = (e: PointerEvent): void => {
|
||||
function onPointerMove(e: PointerEvent): void {
|
||||
if (!e.buttons) viewportPointerInteractionOngoing = false;
|
||||
|
||||
// Don't redirect pointer movement to the backend if there's no ongoing interaction and it's over a floating menu on top of the canvas
|
||||
|
@ -118,11 +118,11 @@ export function createInputManager(editor: EditorState, container: HTMLElement,
|
|||
const inFloatingMenu = e.target instanceof Element && e.target.closest("[data-floating-menu-content]");
|
||||
if (!viewportPointerInteractionOngoing && inFloatingMenu) return;
|
||||
|
||||
const modifiers = makeModifiersBitfield(e);
|
||||
const modifiers = makeKeyboardModifiersBitfield(e);
|
||||
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 inCanvas = target instanceof Element && target.closest("[data-canvas]");
|
||||
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) {
|
||||
const modifiers = makeModifiersBitfield(e);
|
||||
const modifiers = makeKeyboardModifiersBitfield(e);
|
||||
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 (!textInput) {
|
||||
const modifiers = makeModifiersBitfield(e);
|
||||
const modifiers = makeKeyboardModifiersBitfield(e);
|
||||
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 (!textInput) {
|
||||
const modifiers = makeModifiersBitfield(e);
|
||||
const modifiers = makeKeyboardModifiersBitfield(e);
|
||||
editor.instance.on_double_click(e.clientX, e.clientY, e.buttons, modifiers);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 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)
|
||||
// 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();
|
||||
};
|
||||
}
|
||||
|
||||
const onMouseScroll = (e: WheelEvent): void => {
|
||||
function onMouseScroll(e: WheelEvent): void {
|
||||
const { target } = e;
|
||||
const inCanvas = target instanceof Element && target.closest("[data-canvas]");
|
||||
|
||||
|
@ -185,18 +185,18 @@ export function createInputManager(editor: EditorState, container: HTMLElement,
|
|||
|
||||
if (inCanvas) {
|
||||
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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const onModifyInputField = (e: CustomEvent): void => {
|
||||
function onModifyInputField(e: CustomEvent): void {
|
||||
textInput = e.detail;
|
||||
};
|
||||
}
|
||||
|
||||
// Window events
|
||||
|
||||
const onWindowResize = (container: HTMLElement): void => {
|
||||
function onWindowResize(container: HTMLElement): void {
|
||||
const viewports = Array.from(container.querySelectorAll("[data-canvas]"));
|
||||
const boundsOfViewports = viewports.map((canvas) => {
|
||||
const bounds = canvas.getBoundingClientRect();
|
||||
|
@ -207,9 +207,9 @@ export function createInputManager(editor: EditorState, container: HTMLElement,
|
|||
const data = Float64Array.from(flattened);
|
||||
|
||||
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];
|
||||
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.preventDefault();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const onPaste = (e: ClipboardEvent): void => {
|
||||
function onPaste(e: ClipboardEvent): void {
|
||||
const dataTransfer = e.clipboardData;
|
||||
if (!dataTransfer) return;
|
||||
e.preventDefault();
|
||||
|
@ -249,394 +249,26 @@ export function createInputManager(editor: EditorState, container: HTMLElement,
|
|||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// 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));
|
||||
};
|
||||
}
|
||||
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 => {
|
||||
listeners.forEach(({ target, eventName, action }) => target.removeEventListener(eventName, action));
|
||||
};
|
||||
// Initialization
|
||||
|
||||
// Run on creation
|
||||
addListeners();
|
||||
// Bind the event listeners
|
||||
bindListeners();
|
||||
// Resize on creation
|
||||
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 { DialogState } from "@/state/dialog";
|
||||
import { EditorState } from "@/state/wasm-loader";
|
||||
import { stripIndents } from "@/utilities/strip-indents";
|
||||
import { TextButtonWidget } from "@/utilities/widgets";
|
||||
import { TextButtonWidget } from "@/components/widgets/buttons/TextButton";
|
||||
import { DialogState } from "@/state-providers/dialog";
|
||||
import { IconName } from "@/utility-functions/icons";
|
||||
import { stripIndents } from "@/utility-functions/strip-indents";
|
||||
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
|
||||
editor.dispatcher.subscribeJsMessage(DisplayDialogPanic, (displayDialogPanic) => {
|
||||
editor.subscriptions.subscribeJsMessage(DisplayDialogPanic, (displayDialogPanic) => {
|
||||
// `Error.stackTraceLimit` is only available in V8/Chromium
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(Error as any).stackTraceLimit = Infinity;
|
||||
|
@ -16,11 +17,12 @@ export function initErrorHandling(editor: EditorState, dialogState: DialogState)
|
|||
// eslint-disable-next-line no-console
|
||||
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 = {
|
||||
layout: [
|
||||
{
|
||||
|
@ -65,7 +67,7 @@ function preparePanicDialog(dialogState: DialogState, title: string, details: st
|
|||
};
|
||||
const jsCallbackBasedButtons = [reloadButton, copyErrorLogButton, reportOnGithubButton];
|
||||
|
||||
dialogState.createPanicDialog(widgets, jsCallbackBasedButtons);
|
||||
return ["Warning", widgets, jsCallbackBasedButtons];
|
||||
}
|
||||
|
||||
function githubUrl(panicDetails: string): string {
|
91
frontend/src/io-managers/persistence.ts
Normal file
91
frontend/src/io-managers/persistence.ts
Normal 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;
|
||||
}
|
|
@ -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 };
|
||||
}
|
|
@ -4,13 +4,12 @@
|
|||
import "reflect-metadata";
|
||||
import { createApp } from "vue";
|
||||
|
||||
import "@/lifetime/errors";
|
||||
import { initWasm } from "@/state/wasm-loader";
|
||||
import { initWasm } from "@/wasm-communication/editor";
|
||||
|
||||
import App from "@/App.vue";
|
||||
|
||||
(async (): Promise<void> => {
|
||||
// Initialize the WASM editor backend
|
||||
// Initialize the WASM module for the editor backend
|
||||
await initWasm();
|
||||
|
||||
// Initialize the Vue application
|
||||
|
|
53
frontend/src/state-providers/dialog.ts
Normal file
53
frontend/src/state-providers/dialog.ts
Normal 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
frontend/src/state-providers/fonts.ts
Normal file
95
frontend/src/state-providers/fonts.ts
Normal 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,
|
||||
});
|
||||
|
||||
const fullscreenModeChanged = (): void => {
|
||||
function fullscreenModeChanged(): void {
|
||||
state.windowFullscreen = Boolean(document.fullscreenElement);
|
||||
if (!state.windowFullscreen) state.keyboardLocked = false;
|
||||
};
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
const enterFullscreen = async (): Promise<void> => {
|
||||
async function enterFullscreen(): Promise<void> {
|
||||
await document.documentElement.requestFullscreen();
|
||||
|
||||
if (keyboardLockApiSupported) {
|
||||
|
@ -24,25 +20,28 @@ export function createFullscreenState() {
|
|||
await (navigator as any).keyboard.lock(["ControlLeft", "ControlRight"]);
|
||||
state.keyboardLocked = true;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
const exitFullscreen = async (): Promise<void> => {
|
||||
async function exitFullscreen(): Promise<void> {
|
||||
await document.exitFullscreen();
|
||||
};
|
||||
}
|
||||
|
||||
const toggleFullscreen = async (): Promise<void> => {
|
||||
async function toggleFullscreen(): Promise<void> {
|
||||
if (state.windowFullscreen) await exitFullscreen();
|
||||
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 {
|
||||
state: readonly(state),
|
||||
keyboardLockApiSupported,
|
||||
state: readonly(state) as typeof state,
|
||||
fullscreenModeChanged,
|
||||
enterFullscreen,
|
||||
exitFullscreen,
|
||||
toggleFullscreen,
|
||||
fullscreenModeChanged,
|
||||
keyboardLockApiSupported,
|
||||
};
|
||||
}
|
||||
export type FullscreenState = ReturnType<typeof createFullscreenState>;
|
|
@ -1,12 +1,12 @@
|
|||
/* eslint-disable max-classes-per-file */
|
||||
import { reactive, readonly } from "vue";
|
||||
|
||||
import { TriggerFileDownload, TriggerRasterDownload, FrontendDocumentDetails, TriggerFileUpload, UpdateActiveDocument, UpdateOpenDocumentsList } from "@/dispatcher/js-messages";
|
||||
import { EditorState } from "@/state/wasm-loader";
|
||||
import { download, downloadBlob, upload } from "@/utilities/files";
|
||||
import { download, downloadBlob, upload } from "@/utility-functions/files";
|
||||
import { Editor } from "@/wasm-communication/editor";
|
||||
import { TriggerFileDownload, TriggerRasterDownload, FrontendDocumentDetails, TriggerFileUpload, UpdateActiveDocument, UpdateOpenDocumentsList } from "@/wasm-communication/messages";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
||||
export function createPortfolioState(editor: EditorState) {
|
||||
export function createPortfolioState(editor: Editor) {
|
||||
const state = reactive({
|
||||
unsaved: false,
|
||||
documents: [] as FrontendDocumentDetails[],
|
||||
|
@ -14,39 +14,35 @@ export function createPortfolioState(editor: EditorState) {
|
|||
});
|
||||
|
||||
// Set up message subscriptions on creation
|
||||
editor.dispatcher.subscribeJsMessage(UpdateOpenDocumentsList, (updateOpenDocumentList) => {
|
||||
editor.subscriptions.subscribeJsMessage(UpdateOpenDocumentsList, (updateOpenDocumentList) => {
|
||||
state.documents = updateOpenDocumentList.open_documents;
|
||||
});
|
||||
|
||||
editor.dispatcher.subscribeJsMessage(UpdateActiveDocument, (updateActiveDocument) => {
|
||||
editor.subscriptions.subscribeJsMessage(UpdateActiveDocument, (updateActiveDocument) => {
|
||||
// Assume we receive a correct document id
|
||||
const activeId = state.documents.findIndex((doc) => doc.id === updateActiveDocument.document_id);
|
||||
state.activeDocumentIndex = activeId;
|
||||
});
|
||||
|
||||
editor.dispatcher.subscribeJsMessage(TriggerFileUpload, async () => {
|
||||
const extension = editor.rawWasm.file_save_suffix();
|
||||
editor.subscriptions.subscribeJsMessage(TriggerFileUpload, async () => {
|
||||
const extension = editor.raw.file_save_suffix();
|
||||
const data = await upload(extension);
|
||||
editor.instance.open_document_file(data.filename, data.content);
|
||||
});
|
||||
|
||||
editor.dispatcher.subscribeJsMessage(TriggerFileDownload, (triggerFileDownload) => {
|
||||
editor.subscriptions.subscribeJsMessage(TriggerFileDownload, (triggerFileDownload) => {
|
||||
download(triggerFileDownload.name, triggerFileDownload.document);
|
||||
});
|
||||
|
||||
editor.dispatcher.subscribeJsMessage(TriggerRasterDownload, (triggerRasterDownload) => {
|
||||
editor.subscriptions.subscribeJsMessage(TriggerRasterDownload, (triggerRasterDownload) => {
|
||||
// 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
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = triggerRasterDownload.size.x;
|
||||
canvas.height = triggerRasterDownload.size.y;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return;
|
||||
|
||||
// Fill the canvas with white if jpeg (does not support transparency and defaults to black)
|
||||
if (triggerRasterDownload.mime.endsWith("jpg")) {
|
||||
ctx.fillStyle = "white";
|
||||
ctx.fillRect(0, 0, triggerRasterDownload.size.x, triggerRasterDownload.size.y);
|
||||
context.fillStyle = "white";
|
||||
context.fillRect(0, 0, triggerRasterDownload.size.x, triggerRasterDownload.size.y);
|
||||
}
|
||||
|
||||
// Create a blob url for our svg
|
||||
|
@ -55,7 +51,7 @@ export function createPortfolioState(editor: EditorState) {
|
|||
const url = URL.createObjectURL(svgBlob);
|
||||
img.onload = (): void => {
|
||||
// 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
|
||||
const imgURI = canvas.toDataURL(triggerRasterDownload.mime);
|
||||
|
@ -68,12 +64,8 @@ export function createPortfolioState(editor: EditorState) {
|
|||
img.src = url;
|
||||
});
|
||||
|
||||
// TODO(mfish33): Replace with initialization system Issue:#524
|
||||
// Get the initial documents
|
||||
editor.instance.get_open_documents_list();
|
||||
|
||||
return {
|
||||
state: readonly(state),
|
||||
state: readonly(state) as typeof state,
|
||||
};
|
||||
}
|
||||
export type PortfolioState = ReturnType<typeof createPortfolioState>;
|
|
@ -1,22 +1,22 @@
|
|||
/* eslint-disable max-classes-per-file */
|
||||
import { reactive, readonly } from "vue";
|
||||
|
||||
import { UpdateNodeGraphVisibility } from "@/dispatcher/js-messages";
|
||||
import { EditorState } from "@/state/wasm-loader";
|
||||
import { Editor } from "@/wasm-communication/editor";
|
||||
import { UpdateNodeGraphVisibility } from "@/wasm-communication/messages";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
||||
export function createWorkspaceState(editor: EditorState) {
|
||||
export function createWorkspaceState(editor: Editor) {
|
||||
const state = reactive({
|
||||
nodeGraphVisible: false,
|
||||
});
|
||||
|
||||
// Set up message subscriptions on creation
|
||||
editor.dispatcher.subscribeJsMessage(UpdateNodeGraphVisibility, (updateNodeGraphVisibility) => {
|
||||
editor.subscriptions.subscribeJsMessage(UpdateNodeGraphVisibility, (updateNodeGraphVisibility) => {
|
||||
state.nodeGraphVisible = updateNodeGraphVisibility.visible;
|
||||
});
|
||||
|
||||
return {
|
||||
state: readonly(state),
|
||||
state: readonly(state) as typeof state,
|
||||
};
|
||||
}
|
||||
export type WorkspaceState = ReturnType<typeof createWorkspaceState>;
|
|
@ -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>;
|
|
@ -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>>;
|
|
@ -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://");
|
||||
}
|
|
@ -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 {
|
||||
const { h, s, v, a } = hsva;
|
|
@ -40,5 +40,7 @@ export async function upload(acceptedEextensions: string): Promise<{ filename: s
|
|||
);
|
||||
|
||||
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" | "";
|
||||
|
||||
// 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`.
|
||||
// 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
|
367
frontend/src/utility-functions/keyboard-entry.ts
Normal file
367
frontend/src/utility-functions/keyboard-entry.ts
Normal file
|
@ -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",
|
||||
]);
|
40
frontend/src/utility-functions/panic-proxy.ts
Normal file
40
frontend/src/utility-functions/panic-proxy.ts
Normal file
|
@ -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
frontend/src/volar.d.ts
vendored
Normal file
23
frontend/src/volar.d.ts
vendored
Normal 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
frontend/src/wasm-communication/editor.ts
Normal file
50
frontend/src/wasm-communication/editor.ts
Normal 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 type { RustEditorInstance, WasmInstance } from "@/state/wasm-loader";
|
||||
import { IconName } from "@/utilities/icons";
|
||||
import { IconName } from "@/utility-functions/icons";
|
||||
import type { WasmEditorInstance, WasmRawInstance } from "@/wasm-communication/editor";
|
||||
|
||||
export class 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.
|
||||
//
|
||||
|
@ -21,9 +21,9 @@ export class JsMessage {
|
|||
// ============================================================================
|
||||
|
||||
// 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
|
||||
// subclasses to change the type of class variables in subclasses. It is an abstract
|
||||
// class to point out that it should not be instantiated directly.
|
||||
// IndexedDb does not allow for BigInts as primary keys.
|
||||
// TypeScript does not allow subclasses to change the type of class variables in subclasses.
|
||||
// It is an abstract class to point out that it should not be instantiated directly.
|
||||
export abstract class DocumentDetails {
|
||||
readonly name!: string;
|
||||
|
||||
|
@ -222,7 +222,7 @@ interface DataBuffer {
|
|||
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 lengthNum = Number(input.data_buffer.length);
|
||||
|
||||
|
@ -335,8 +335,6 @@ export class IndexedDbDocumentDetails extends DocumentDetails {
|
|||
id!: string;
|
||||
}
|
||||
|
||||
export class TriggerFontLoadDefault extends JsMessage {}
|
||||
|
||||
export class DisplayDialogDismiss extends JsMessage {}
|
||||
|
||||
export class TriggerIndexedDbWriteDocument extends JsMessage {
|
||||
|
@ -355,9 +353,11 @@ export class TriggerIndexedDbRemoveDocument extends JsMessage {
|
|||
}
|
||||
|
||||
export class TriggerFontLoad extends JsMessage {
|
||||
font!: string;
|
||||
font_file_url!: string;
|
||||
}
|
||||
|
||||
export class TriggerFontLoadDefault extends JsMessage {}
|
||||
|
||||
export class TriggerVisitLink extends JsMessage {
|
||||
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
|
||||
// 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;
|
||||
|
||||
export const messageMakers: Record<string, MessageMaker> = {
|
||||
|
@ -536,12 +536,12 @@ export const messageMakers: Record<string, MessageMaker> = {
|
|||
DisplayEditableTextbox,
|
||||
UpdateImageData,
|
||||
DisplayRemoveEditableTextbox,
|
||||
TriggerFontLoadDefault,
|
||||
DisplayDialogDismiss,
|
||||
TriggerFileDownload,
|
||||
TriggerFileUpload,
|
||||
TriggerIndexedDbRemoveDocument,
|
||||
TriggerFontLoad,
|
||||
TriggerFontLoadDefault,
|
||||
TriggerIndexedDbWriteDocument,
|
||||
TriggerRasterDownload,
|
||||
TriggerTextCommit,
|
|
@ -1,7 +1,7 @@
|
|||
import { plainToInstance } from "class-transformer";
|
||||
|
||||
import { JsMessageType, messageMakers, JsMessage } from "@/dispatcher/js-messages";
|
||||
import type { RustEditorInstance, WasmInstance } from "@/state/wasm-loader";
|
||||
import type { WasmEditorInstance, WasmRawInstance } from "@/wasm-communication/editor";
|
||||
import { JsMessageType, messageMakers, JsMessage } from "@/wasm-communication/messages";
|
||||
|
||||
type JsMessageCallback<T extends JsMessage> = (messageData: T) => void;
|
||||
type JsMessageCallbackMap = {
|
||||
|
@ -12,21 +12,21 @@ type JsMessageCallbackMap = {
|
|||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
||||
export function createJsDispatcher() {
|
||||
export function createSubscriptionRouter() {
|
||||
const subscriptions: JsMessageCallbackMap = {};
|
||||
|
||||
const subscribeJsMessage = <T extends JsMessage, Args extends unknown[]>(messageType: new (...args: Args) => T, callback: JsMessageCallback<T>): void => {
|
||||
subscriptions[messageType.name] = callback;
|
||||
};
|
||||
|
||||
const handleJsMessage = (messageType: JsMessageType, messageData: Record<string, unknown>, wasm: 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
|
||||
const messageMaker = messageMakers[messageType];
|
||||
if (!messageMaker) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`Received a frontend message of type "${messageType}" but was not able to parse the data. ` +
|
||||
"(Perhaps this message parser isn't exported in `messageMakers` at the bottom of `js-messages.ts`.)"
|
||||
"(Perhaps this message parser isn't exported in `messageMakers` at the bottom of `messages.ts`.)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
@ -63,4 +63,4 @@ export function createJsDispatcher() {
|
|||
handleJsMessage,
|
||||
};
|
||||
}
|
||||
export type JsDispatcher = ReturnType<typeof createJsDispatcher>;
|
||||
export type SubscriptionRouter = ReturnType<typeof createSubscriptionRouter>;
|
|
@ -1,6 +1,6 @@
|
|||
// 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
|
||||
// on the dispatcher messaging system and more complex Rust data types.
|
||||
//! 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
|
||||
//! on the dispatcher messaging system and more complex Rust data types.
|
||||
|
||||
use crate::helpers::{translate_key, Error};
|
||||
use crate::{EDITOR_HAS_CRASHED, EDITOR_INSTANCES, JS_EDITOR_HANDLES};
|
||||
|
@ -84,10 +84,34 @@ impl JsEditorHandle {
|
|||
// 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 {
|
||||
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) {
|
||||
self.dispatch(WorkspaceMessage::NodeGraphToggleVisibility);
|
||||
}
|
||||
|
@ -109,11 +133,6 @@ impl JsEditorHandle {
|
|||
self.dispatch(message);
|
||||
}
|
||||
|
||||
pub fn get_open_documents_list(&self) {
|
||||
let message = PortfolioMessage::UpdateOpenDocumentsList;
|
||||
self.dispatch(message);
|
||||
}
|
||||
|
||||
pub fn request_new_document_dialog(&self) {
|
||||
let message = DialogMessage::RequestNewDocumentDialog;
|
||||
self.dispatch(message);
|
||||
|
@ -301,8 +320,8 @@ impl JsEditorHandle {
|
|||
}
|
||||
|
||||
/// A font has been downloaded
|
||||
pub fn on_font_load(&self, font: String, data: Vec<u8>, is_default: bool) -> Result<(), JsValue> {
|
||||
let message = DocumentMessage::FontLoaded { font, data, is_default };
|
||||
pub fn on_font_load(&self, font_file_url: String, data: Vec<u8>, is_default: bool) -> Result<(), JsValue> {
|
||||
let message = DocumentMessage::FontLoaded { font_file_url, data, is_default };
|
||||
self.dispatch(message);
|
||||
|
||||
Ok(())
|
||||
|
@ -467,19 +486,10 @@ impl JsEditorHandle {
|
|||
let message = DocumentMessage::ToggleLayerExpansion { layer_path };
|
||||
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
|
||||
// understand reason but has to do with #[wasm_bindgen] procedural macro.
|
||||
// Needed to make JsEditorHandle functions pub to Rust.
|
||||
// The reason is not fully clear but it has to do with the #[wasm_bindgen] procedural macro.
|
||||
impl JsEditorHandle {
|
||||
pub fn handle_response_rust_proxy(&self, message: FrontendMessage) {
|
||||
self.handle_response(message);
|
||||
|
@ -499,12 +509,6 @@ pub fn set_random_seed(seed: u64) {
|
|||
editor::communication::set_uuid_seed(seed)
|
||||
}
|
||||
|
||||
/// Intentionally panic for debugging purposes
|
||||
#[wasm_bindgen]
|
||||
pub fn intentional_panic() {
|
||||
panic!();
|
||||
}
|
||||
|
||||
/// Access a handle to WASM memory
|
||||
#[wasm_bindgen]
|
||||
pub fn wasm_memory() -> JsValue {
|
||||
|
|
|
@ -19,8 +19,10 @@ thread_local! {
|
|||
/// Initialize the backend
|
||||
#[wasm_bindgen(start)]
|
||||
pub fn init() {
|
||||
// Set up the 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_max_level(log::LevelFilter::Debug);
|
||||
}
|
||||
|
|
|
@ -563,7 +563,7 @@ impl Document {
|
|||
Some(vec![DocumentChanged])
|
||||
}
|
||||
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 (layer_path, id) = split_path(&path)?;
|
||||
|
@ -730,7 +730,7 @@ impl Document {
|
|||
font_file,
|
||||
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 (folder_path, id) = split_path(&path)?;
|
||||
for id in folder_path {
|
||||
|
@ -797,7 +797,7 @@ impl Document {
|
|||
self.set_transform_relative_to_viewport(&path, transform)?;
|
||||
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 (folder_path, id) = split_path(&path)?;
|
||||
for id in folder_path {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue