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

* Clean up Vue initialization-related code

* Rename folder: dispatcher -> interop

* Rename folder: state -> providers

* Comments and clarification

* Rename JS dispatcher to subscription router

* Assorted cleanup and renaming

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

* Comments

* Remove unused Vue component injects

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

* Further cleanup

* Dangerous changes

* Simplify App.vue code

* Move more disparate init code from components into managers

* Rename folder: providers -> state-providers

* Other

* Move Document panel options bar separator to backend

* Add destructors to managers to fix HMR

* Comments and code style

* Rename variable: font -> font_file_url

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

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

* Add Vue bidirectional props documentation

* More folder renaming for better terminology; add some documentation
This commit is contained in:
Keavon Chambers 2022-05-21 19:46:15 -07:00
parent 4c3c925c2c
commit fc2d983bd7
73 changed files with 1572 additions and 1462 deletions

View file

@ -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>,

View file

@ -523,6 +523,7 @@ impl DocumentMessageHandler {
}
}
// TODO: Loading the default font should happen on a per-application basis, not a per-document basis
pub fn load_default_font(&self, responses: &mut VecDeque<Message>) {
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 {

View file

@ -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);

View file

@ -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()

View file

@ -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 },

View file

@ -95,17 +95,17 @@ impl MessageHandler<LayoutMessage, ()> for LayoutMessageHandler {
let update_value = value.as_object().expect("FontInput update was not of type: object");
let 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);
}

View file

@ -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>,

View file

@ -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()
}),

View file

@ -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
View file

@ -0,0 +1,53 @@
# Overview of `/frontend/src/`
## Vue components: `components/`
Vue components that build the Graphite editor GUI, which are mounted in `App.vue`. These are Vue SFCs (single-file components) which each contain a Vue-templated HTML section, an SCSS (Stylus CSS) section, and a script section. The aim is to avoid implementing much editor business logic here, just enough to make things interactive and communicate to the backend where the real business logic should occur.
## I/O managers: `io-managers/`
TypeScript files which manage the input/output of browser APIs and link this functionality with the editor backend. These files subscribe to backend events to execute JS APIs, and in response to these APIs or user interactions, they may call functions into the backend (defined in `/frontend/wasm/api.rs`).
Each I/O manager is a self-contained module where one instance is created in `App.vue` when it's mounted to the DOM at app startup.
During development when HMR (hot-module replacement) occurs, these are also unmounted to clean up after themselves, so they can be mounted again with the updated code. Therefore, any side-effects that these managers cause (e.g. adding event listeners to the page) need a destructor function that cleans them up. The destructor function, when applicable, is returned by the module and automatically called in `App.vue` on unmount.
## State providers: `state-providers/`
TypeScript files which provide reactive state and importable functions to Vue components. Each module defines a Vue reactive state object `const state = reactive({ ... });` and exports this from the module in the returned object as the key-value pair `state: readonly(state) as typeof state,` using Vue's `readonly()` wrapper. Other functions may also be defined in the module and exported after `state`, which provide a way for Vue components to call functions to manipulate the state.
In `App.vue`, an instance of each of these are given to Vue's [`provide()`](https://vuejs.org/api/application.html#app-provide) function. This allows any component to access the state provider instance by specifying it in its `inject: [...]` array. The state is accessed in a component with `this.stateProviderName.state.someReactiveVariable` and any exposed functions are accessed with `this.stateProviderName.state.someExposedVariable()`. They can also be used in the Vue HTML template (sans the `this.` prefix).
## *I/O managers vs. state providers*
*Some state providers, similarly to I/O managers, may subscribe to backend events, call functions from `api.rs` into the backend, and interact with browser APIs and user input. The difference is that state providers are meant to `inject`ed by components to use them for reactive state, while I/O managers are meant to be self-contained systems that operate for the lifetime of the application and aren't touched by Vue components.*
## Utility functions: `utility-functions/`
TypeScript files which define and `export` individual helper functions for use elsewhere in the codebase. These files should not persist state outside each function.
## WASM communication: `wasm-communication/`
TypeScript files which serve as the JS interface to the WASM bindings for the editor backend.
### WASM editor: `editor.ts`
Instantiates the WASM and editor backend instances. The function `initWasm()` asynchronously constructs and initializes an instance of the WASM bindings module provided by wasm-bindgen/wasm-pack. It is stored in a local variable and can be retrieved with the `getWasmInstance()` function. The function `createEditor()` constructs an instance of the editor backend. In theory there could be multiple editor instances sharing the same WASM module instance. The function returns an object where `raw` is the WASM module, `instance` is the editor, and `subscriptions` is the subscription router (described below).
`initWasm()` occurs in `main.ts` right before the Vue application exists, then `createEditor()` is run in `App.vue` during the Vue app's creation. Similarly to the state providers described above, the editor is `provide`d so other components can `inject` it and call functions on `this.editor.raw`, `this.editor.instance`, or `this.editor.subscriptions`.
### Message definitions: `messages.ts`
Defines the message formats and data types received from the backend. Since Rust and JS support different styles of data representation, this bridges the gap from Rust into JS land. Messages (and the data contained within) are serialized in Rust by `serde` into JSON, and these definitions are manually kept up-to-date to parallel the message structs and their data types. (However, directives like `#[serde(skip)]` or `#[serde(rename = "someOtherName")]` may cause the TypeScript format to look slightly different from the Rust structs.) These definitions are basically just for the sake of TypeScript to understand the format, although in some cases we may perform data conversion here using translation functions that we can provide.
### Subscription router: `subscription-router.ts`
Associates messages from the backend with subscribers in the frontend, and routes messages to subscriber callbacks. This module provides a `subscribeJsMessage(messageType, callback)` function which JS code throughout the frontend can call to be registered as the exclusive handler for a chosen message type. This file's other exported function, `handleJsMessage(messageType, messageData, wasm, instance)`, is called in `editor.ts` by the associated editor instance when the backend sends a `FrontendMessage`. When this occurs, the subscription router delivers the message to the subscriber for given `messageType` by executing its registered `callback` function. As an argument to the function, it provides the `messageData` payload transformed into its TypeScript-friendly format defined in `messages.ts`.
## Vue app: `App.vue`
The entry point for the Vue application. This is where we define global CSS style rules, construct the editor,construct/destruct the editor and I/O managers, and construct/provide state providers.
## Entry point: `main.ts`
The entry point for the entire project. Here we simply initialize the WASM module with `await initWasm();` then initialize the Vue application with `createApp(App).mount("#app");`.

View file

@ -0,0 +1,33 @@
# Vue components
Each component is a layout or widget in the GUI.
This document is a growing list of quick reference information for helpful Vue solutions and best practices. Feel free to add to this to help contributors learn things, or yourself remember tricks you'll likely forget in a few months.
## Bi-directional props
The component declares this:
```ts
export default defineComponent({
emits: ["update:theBidirectionalProperty"],
props: {
theBidirectionalProperty: { type: Number as PropType<number>, required: false },
},
watch: {
// Called only when `theBidirectionalProperty` is changed from outside this component (with v-model)
theBidirectionalProperty(newSelectedIndex: number | undefined) {
},
},
methods: {
doSomething() {
this.$emit("update:theBidirectionalProperty", SOME_NEW_VALUE);
},
},
});
```
Users of the component do this for `theCorrespondingDataEntry` to be a two-way binding:
```html
<DropdownInput v-model:theBidirectionalProperty="theCorrespondingDataEntry" />
```

View file

@ -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,

View file

@ -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;

View file

@ -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,

View file

@ -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;
});
},

View file

@ -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";

View file

@ -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";

View file

@ -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";

View file

@ -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";

View file

@ -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?.();
},

View 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;
};
}

View file

@ -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";

View file

@ -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 -->

View file

@ -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>

View file

@ -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,

View file

@ -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";

View file

@ -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);

View file

@ -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: {

View file

@ -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;
},

View file

@ -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: {

View file

@ -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: {

View file

@ -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";

View file

@ -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";

View file

@ -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();

View file

@ -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";

View file

@ -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";

View file

@ -121,7 +121,7 @@ export default defineComponent({
},
},
methods: {
handleResize() {
resize() {
if (!this.$refs.rulerRef) return;
const rulerElement = this.$refs.rulerRef as HTMLElement;

View file

@ -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;

View file

@ -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: {

View file

@ -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;
});
},

View file

@ -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: {

View file

@ -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 },

View 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 || "");
}

View file

@ -0,0 +1,10 @@
import { Editor } from "@/wasm-communication/editor";
import { TriggerTextCopy } from "@/wasm-communication/messages";
export function createClipboardManager(editor: Editor): void {
// Subscribe to process backend event
editor.subscriptions.subscribeJsMessage(TriggerTextCopy, (triggerTextCopy) => {
// If the Clipboard API is supported in the browser, copy text to the clipboard
navigator.clipboard?.writeText?.(triggerTextCopy.copy_text);
});
}

View file

@ -0,0 +1,9 @@
import { Editor } from "@/wasm-communication/editor";
import { TriggerVisitLink } from "@/wasm-communication/messages";
export function createHyperlinkManager(editor: Editor): void {
// Subscribe to process backend event
editor.subscriptions.subscribeJsMessage(TriggerVisitLink, async (triggerOpenLink) => {
window.open(triggerOpenLink.url, "_blank");
});
}

View file

@ -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",
]);

View file

@ -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 {

View file

@ -0,0 +1,91 @@
import { PortfolioState } from "@/state-providers/portfolio";
import { Editor, getWasmInstance } from "@/wasm-communication/editor";
import { TriggerIndexedDbWriteDocument, TriggerIndexedDbRemoveDocument } from "@/wasm-communication/messages";
const GRAPHITE_INDEXED_DB_VERSION = 2;
const GRAPHITE_INDEXED_DB_NAME = "graphite-indexed-db";
const GRAPHITE_AUTO_SAVE_STORE = "auto-save-documents";
const GRAPHITE_AUTO_SAVE_ORDER_KEY = "auto-save-documents-order";
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export async function createPersistenceManager(editor: Editor, portfolio: PortfolioState): Promise<() => void> {
function storeDocumentOrder(): void {
// Make sure to store as string since JSON does not play nice with BigInt
const documentOrder = portfolio.state.documents.map((doc) => doc.id.toString());
window.localStorage.setItem(GRAPHITE_AUTO_SAVE_ORDER_KEY, JSON.stringify(documentOrder));
}
async function removeDocument(id: string): Promise<void> {
const db = await databaseConnection;
const transaction = db.transaction(GRAPHITE_AUTO_SAVE_STORE, "readwrite");
transaction.objectStore(GRAPHITE_AUTO_SAVE_STORE).delete(id);
storeDocumentOrder();
}
async function closeDatabaseConnection(): Promise<void> {
const db = await databaseConnection;
db.close();
}
// Subscribe to process backend events
editor.subscriptions.subscribeJsMessage(TriggerIndexedDbWriteDocument, async (autoSaveDocument) => {
const db = await databaseConnection;
const transaction = db.transaction(GRAPHITE_AUTO_SAVE_STORE, "readwrite");
transaction.objectStore(GRAPHITE_AUTO_SAVE_STORE).put(autoSaveDocument);
storeDocumentOrder();
});
editor.subscriptions.subscribeJsMessage(TriggerIndexedDbRemoveDocument, async (removeAutoSaveDocument) => {
removeDocument(removeAutoSaveDocument.document_id);
});
// Open the IndexedDB database connection and save it to this variable, which is a promise that resolves once the connection is open
const databaseConnection: Promise<IDBDatabase> = new Promise((resolve) => {
const dbOpenRequest = indexedDB.open(GRAPHITE_INDEXED_DB_NAME, GRAPHITE_INDEXED_DB_VERSION);
dbOpenRequest.onupgradeneeded = (): void => {
const db = dbOpenRequest.result;
// Wipes out all auto-save data on upgrade
if (db.objectStoreNames.contains(GRAPHITE_AUTO_SAVE_STORE)) {
db.deleteObjectStore(GRAPHITE_AUTO_SAVE_STORE);
}
db.createObjectStore(GRAPHITE_AUTO_SAVE_STORE, { keyPath: "details.id" });
};
dbOpenRequest.onerror = (): void => {
// eslint-disable-next-line no-console
console.error("Graphite IndexedDb error:", dbOpenRequest.error);
};
dbOpenRequest.onsuccess = (): void => {
resolve(dbOpenRequest.result);
};
});
// Open auto-save documents
const db = await databaseConnection;
const transaction = db.transaction(GRAPHITE_AUTO_SAVE_STORE, "readonly");
const request = transaction.objectStore(GRAPHITE_AUTO_SAVE_STORE).getAll();
await new Promise((resolve): void => {
request.onsuccess = (): void => {
const previouslySavedDocuments: TriggerIndexedDbWriteDocument[] = request.result;
const documentOrder: string[] = JSON.parse(window.localStorage.getItem(GRAPHITE_AUTO_SAVE_ORDER_KEY) || "[]");
const orderedSavedDocuments = documentOrder
.map((id) => previouslySavedDocuments.find((autoSave) => autoSave.details.id === id))
.filter((x) => x !== undefined) as TriggerIndexedDbWriteDocument[];
const currentDocumentVersion = getWasmInstance().graphite_version();
orderedSavedDocuments.forEach((doc: TriggerIndexedDbWriteDocument) => {
if (doc.version === currentDocumentVersion) {
editor.instance.open_auto_saved_document(BigInt(doc.details.id), doc.details.name, doc.details.is_saved, doc.document);
} else {
removeDocument(doc.details.id);
}
});
resolve(undefined);
};
});
return closeDatabaseConnection;
}

View file

@ -1,90 +0,0 @@
import { TriggerIndexedDbWriteDocument, TriggerIndexedDbRemoveDocument } from "@/dispatcher/js-messages";
import { PortfolioState } from "@/state/portfolio";
import { EditorState, getWasmInstance } from "@/state/wasm-loader";
const GRAPHITE_INDEXED_DB_VERSION = 2;
const GRAPHITE_INDEXED_DB_NAME = "graphite-indexed-db";
const GRAPHITE_AUTO_SAVE_STORE = "auto-save-documents";
const GRAPHITE_AUTO_SAVE_ORDER_KEY = "auto-save-documents-order";
const databaseConnection: Promise<IDBDatabase> = new Promise((resolve) => {
const dbOpenRequest = indexedDB.open(GRAPHITE_INDEXED_DB_NAME, GRAPHITE_INDEXED_DB_VERSION);
dbOpenRequest.onupgradeneeded = (): void => {
const db = dbOpenRequest.result;
// Wipes out all auto-save data on upgrade
if (db.objectStoreNames.contains(GRAPHITE_AUTO_SAVE_STORE)) {
db.deleteObjectStore(GRAPHITE_AUTO_SAVE_STORE);
}
db.createObjectStore(GRAPHITE_AUTO_SAVE_STORE, { keyPath: "details.id" });
};
dbOpenRequest.onerror = (): void => {
// eslint-disable-next-line no-console
console.error("Graphite IndexedDb error:", dbOpenRequest.error);
};
dbOpenRequest.onsuccess = (): void => {
resolve(dbOpenRequest.result);
};
});
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export function createAutoSaveManager(editor: EditorState, portfolio: PortfolioState) {
const openAutoSavedDocuments = async (): Promise<void> => {
const db = await databaseConnection;
const transaction = db.transaction(GRAPHITE_AUTO_SAVE_STORE, "readonly");
const request = transaction.objectStore(GRAPHITE_AUTO_SAVE_STORE).getAll();
return new Promise((resolve) => {
request.onsuccess = (): void => {
const previouslySavedDocuments: TriggerIndexedDbWriteDocument[] = request.result;
const documentOrder: string[] = JSON.parse(window.localStorage.getItem(GRAPHITE_AUTO_SAVE_ORDER_KEY) || "[]");
const orderedSavedDocuments = documentOrder
.map((id) => previouslySavedDocuments.find((autoSave) => autoSave.details.id === id))
.filter((x) => x !== undefined) as TriggerIndexedDbWriteDocument[];
const currentDocumentVersion = getWasmInstance().graphite_version();
orderedSavedDocuments.forEach((doc: TriggerIndexedDbWriteDocument) => {
if (doc.version === currentDocumentVersion) {
editor.instance.open_auto_saved_document(BigInt(doc.details.id), doc.details.name, doc.details.is_saved, doc.document);
} else {
removeDocument(doc.details.id);
}
});
resolve(undefined);
};
});
};
const storeDocumentOrder = (): void => {
// Make sure to store as string since JSON does not play nice with BigInt
const documentOrder = portfolio.state.documents.map((doc) => doc.id.toString());
window.localStorage.setItem(GRAPHITE_AUTO_SAVE_ORDER_KEY, JSON.stringify(documentOrder));
};
const removeDocument = async (id: string): Promise<void> => {
const db = await databaseConnection;
const transaction = db.transaction(GRAPHITE_AUTO_SAVE_STORE, "readwrite");
transaction.objectStore(GRAPHITE_AUTO_SAVE_STORE).delete(id);
storeDocumentOrder();
};
editor.dispatcher.subscribeJsMessage(TriggerIndexedDbWriteDocument, async (autoSaveDocument) => {
const db = await databaseConnection;
const transaction = db.transaction(GRAPHITE_AUTO_SAVE_STORE, "readwrite");
transaction.objectStore(GRAPHITE_AUTO_SAVE_STORE).put(autoSaveDocument);
storeDocumentOrder();
});
editor.dispatcher.subscribeJsMessage(TriggerIndexedDbRemoveDocument, async (removeAutoSaveDocument) => {
removeDocument(removeAutoSaveDocument.document_id);
});
// On creation
openAutoSavedDocuments();
return { openAutoSavedDocuments };
}

View file

@ -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

View file

@ -0,0 +1,53 @@
import { reactive, readonly } from "vue";
import { TextButtonWidget } from "@/components/widgets/buttons/TextButton";
import { IconName } from "@/utility-functions/icons";
import { Editor } from "@/wasm-communication/editor";
import { defaultWidgetLayout, DisplayDialog, DisplayDialogDismiss, UpdateDialogDetails, WidgetLayout } from "@/wasm-communication/messages";
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export function createDialogState(editor: Editor) {
const state = reactive({
visible: false,
icon: "" as IconName,
widgets: defaultWidgetLayout(),
// Special case for the crash dialog because we cannot handle button widget callbacks from Rust once the editor instance has panicked
jsCallbackBasedButtons: undefined as undefined | TextButtonWidget[],
});
function dismissDialog(): void {
state.visible = false;
}
function dialogIsVisible(): boolean {
return state.visible;
}
// Creates a panic dialog from JS.
// Normal dialogs are created in the Rust backend, but for the crash dialog, the editor instance has panicked so it cannot respond to widget callbacks.
function createPanicDialog(icon: IconName, widgets: WidgetLayout, jsCallbackBasedButtons: TextButtonWidget[]): void {
state.visible = true;
state.icon = icon;
state.widgets = widgets;
state.jsCallbackBasedButtons = jsCallbackBasedButtons;
}
// Subscribe to process backend events
editor.subscriptions.subscribeJsMessage(DisplayDialog, (displayDialog) => {
state.visible = true;
state.icon = displayDialog.icon;
});
editor.subscriptions.subscribeJsMessage(UpdateDialogDetails, (updateDialogDetails) => {
state.widgets = updateDialogDetails;
state.jsCallbackBasedButtons = undefined;
});
editor.subscriptions.subscribeJsMessage(DisplayDialogDismiss, dismissDialog);
return {
state: readonly(state) as typeof state,
dismissDialog,
dialogIsVisible,
createPanicDialog,
};
}
export type DialogState = ReturnType<typeof createDialogState>;

View file

@ -0,0 +1,95 @@
import { reactive, readonly } from "vue";
import { Editor } from "@/wasm-communication/editor";
import { TriggerFontLoad, TriggerFontLoadDefault } from "@/wasm-communication/messages";
const DEFAULT_FONT = "Merriweather";
const DEFAULT_FONT_STYLE = "Normal (400)";
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export function createFontsState(editor: Editor) {
const state = reactive({
fontNames: [] as string[],
});
async function getFontStyles(fontFamily: string): Promise<string[]> {
const font = (await fontList).find((value) => value.family === fontFamily);
return font?.variants || [];
}
async function getFontFileUrl(fontFamily: string, fontStyle: string): Promise<string | undefined> {
const font = (await fontList).find((value) => value.family === fontFamily);
const fontFileUrl = font?.files.get(fontStyle);
return fontFileUrl?.replace("http://", "https://");
}
function formatFontStyleName(fontStyle: string): string {
const isItalic = fontStyle.endsWith("italic");
const weight = fontStyle === "regular" || fontStyle === "italic" ? 400 : parseInt(fontStyle, 10);
let weightName = "";
let bestWeight = Infinity;
weightNameMapping.forEach((nameChecking, weightChecking) => {
if (Math.abs(weightChecking - weight) < bestWeight) {
bestWeight = Math.abs(weightChecking - weight);
weightName = nameChecking;
}
});
return `${weightName}${isItalic ? " Italic" : ""} (${weight})`;
}
// Subscribe to process backend events
editor.subscriptions.subscribeJsMessage(TriggerFontLoadDefault, async (): Promise<void> => {
const fontFileUrl = await getFontFileUrl(DEFAULT_FONT, DEFAULT_FONT_STYLE);
if (!fontFileUrl) return;
const response = await fetch(fontFileUrl);
const responseBuffer = await response.arrayBuffer();
editor.instance.on_font_load(fontFileUrl, new Uint8Array(responseBuffer), true);
});
editor.subscriptions.subscribeJsMessage(TriggerFontLoad, async (triggerFontLoad) => {
const response = await (await fetch(triggerFontLoad.font_file_url)).arrayBuffer();
editor.instance.on_font_load(triggerFontLoad.font_file_url, new Uint8Array(response), false);
});
const fontList: Promise<{ family: string; variants: string[]; files: Map<string, string> }[]> = new Promise((resolve) => {
fetch(fontListAPI)
.then((response) => response.json())
.then((fontListResponse) => {
const fontListData = fontListResponse.items as { family: string; variants: string[]; files: { [name: string]: string } }[];
const result = fontListData.map((font) => {
const { family } = font;
const variants = font.variants.map(formatFontStyleName);
const files = new Map(font.variants.map((x) => [formatFontStyleName(x), font.files[x]]));
return { family, variants, files };
});
state.fontNames = result.map((value) => value.family);
resolve(result);
});
});
return {
state: readonly(state) as typeof state,
getFontStyles,
getFontFileUrl,
};
}
export type FontsState = ReturnType<typeof createFontsState>;
const fontListAPI = "https://api.graphite.rs/font-list";
// From https://developer.mozilla.org/en-US/docs/Web/CSS/font-weight#common_weight_name_mapping
const weightNameMapping = new Map([
[100, "Thin"],
[200, "Extra Light"],
[300, "Light"],
[400, "Normal"],
[500, "Medium"],
[600, "Semi Bold"],
[700, "Bold"],
[800, "Extra Bold"],
[900, "Black"],
[950, "Extra Black"],
]);

View file

@ -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>;

View file

@ -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>;

View file

@ -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>;

View file

@ -1,58 +0,0 @@
import { reactive, readonly } from "vue";
import { defaultWidgetLayout, DisplayDialog, DisplayDialogDismiss, UpdateDialogDetails, WidgetLayout } from "@/dispatcher/js-messages";
import { EditorState } from "@/state/wasm-loader";
import { IconName } from "@/utilities/icons";
import { TextButtonWidget } from "@/utilities/widgets";
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export function createDialogState(editor: EditorState) {
const state = reactive({
visible: false,
icon: "" as IconName,
widgets: defaultWidgetLayout(),
// Special case for the crash dialog because we cannot handle button widget callbacks from Rust once the editor instance has panicked
jsCallbackBasedButtons: undefined as undefined | TextButtonWidget[],
});
// Creates a panic dialog from JS.
// Normal dialogs are created in the Rust backend, however for the crash dialog, the editor instance has panicked so it cannot respond to widget callbacks.
const createPanicDialog = (widgets: WidgetLayout, jsCallbackBasedButtons: TextButtonWidget[]): void => {
state.visible = true;
state.icon = "Warning";
state.widgets = widgets;
state.jsCallbackBasedButtons = jsCallbackBasedButtons;
};
const dismissDialog = (): void => {
state.visible = false;
};
const dialogIsVisible = (): boolean => state.visible;
const comingSoon = (issueNumber?: number): void => {
editor.instance.request_coming_soon_dialog(issueNumber);
};
// Run on creation
editor.dispatcher.subscribeJsMessage(DisplayDialog, (displayDialog) => {
state.visible = true;
state.icon = displayDialog.icon;
});
editor.dispatcher.subscribeJsMessage(DisplayDialogDismiss, dismissDialog);
editor.dispatcher.subscribeJsMessage(UpdateDialogDetails, (updateDialogDetails) => {
state.widgets = updateDialogDetails;
state.jsCallbackBasedButtons = undefined;
});
return {
state: readonly(state),
createPanicDialog,
dismissDialog,
dialogIsVisible,
comingSoon,
};
}
export type DialogState = ReturnType<typeof createDialogState>;

View file

@ -1,89 +0,0 @@
/* eslint-disable func-names */
import { createJsDispatcher } from "@/dispatcher/js-dispatcher";
import { JsMessageType } from "@/dispatcher/js-messages";
export type WasmInstance = typeof import("@/../wasm/pkg");
export type RustEditorInstance = InstanceType<WasmInstance["JsEditorHandle"]>;
// `wasmImport` starts uninitialized until `initWasm()` is called in `main.ts` before the Vue app is created
let wasmImport: WasmInstance | null = null;
export async function initWasm(): Promise<void> {
// Skip if the wasm module is already initialized
if (wasmImport !== null) return;
// Separating in two lines satisfies TypeScript
const importedWasm = await import("@/../wasm/pkg").then(panicProxy);
wasmImport = importedWasm;
// Provide a random starter seed which must occur after initializing the wasm module, since wasm can't generate is own random numbers
const randomSeed = BigInt(Math.floor(Math.random() * Number.MAX_SAFE_INTEGER));
importedWasm.set_random_seed(randomSeed);
}
// This works by proxying every function call and wrapping a try-catch block to filter out redundant and confusing
// `RuntimeError: unreachable` exceptions that would normally be printed in the browser's JS console upon a panic.
function panicProxy<T extends object>(module: T): T {
const proxyHandler = {
get(target: T, propKey: string | symbol, receiver: unknown): unknown {
const targetValue = Reflect.get(target, propKey, receiver);
// Keep the original value being accessed if it isn't a function
const isFunction = typeof targetValue === "function";
if (!isFunction) return targetValue;
// Special handling to wrap the return of a constructor in the proxy
const isClass = isFunction && /^\s*class\s+/.test(targetValue.toString());
if (isClass) {
return function (...args: unknown[]): unknown {
// eslint-disable-next-line new-cap
const result = new targetValue(...args);
return panicProxy(result);
};
}
// Replace the original function with a wrapper function that runs the original in a try-catch block
return function (...args: unknown[]): unknown {
let result;
try {
// @ts-expect-error TypeScript does not know what `this` is, since it should be able to be anything
result = targetValue.apply(this, args);
} catch (err) {
// Suppress `unreachable` WebAssembly.RuntimeError exceptions
if (!`${err}`.startsWith("RuntimeError: unreachable")) throw err;
}
return result;
};
},
};
return new Proxy<T>(module, proxyHandler);
}
export function getWasmInstance(): WasmInstance {
if (wasmImport) return wasmImport;
throw new Error("Editor WASM backend was not initialized at application startup");
}
type CreateEditorStateType = {
// Allows subscribing to messages from the WASM backend
rawWasm: WasmInstance;
// Bindings to WASM wrapper declarations (generated by wasm-bindgen)
dispatcher: ReturnType<typeof createJsDispatcher>;
// WASM wrapper's exported functions (generated by wasm-bindgen)
instance: RustEditorInstance;
};
export function createEditorState(): CreateEditorStateType {
const rawWasm = getWasmInstance();
const dispatcher = createJsDispatcher();
const instance = new rawWasm.JsEditorHandle((messageType: JsMessageType, data: Record<string, unknown>): void => {
dispatcher.handleJsMessage(messageType, data, rawWasm, instance);
});
return {
rawWasm,
dispatcher,
instance,
};
}
export type EditorState = Readonly<ReturnType<typeof createEditorState>>;

View file

@ -1,80 +0,0 @@
const fontListAPI = "https://api.graphite.rs/font-list";
// Taken from https://developer.mozilla.org/en-US/docs/Web/CSS/font-weight#common_weight_name_mapping
const weightNameMapping = new Map([
[100, "Thin"],
[200, "Extra Light"],
[300, "Light"],
[400, "Normal"],
[500, "Medium"],
[600, "Semi Bold"],
[700, "Bold"],
[800, "Extra Bold"],
[900, "Black"],
[950, "Extra Black"],
]);
type fontCallbackType = (font: string, data: Uint8Array) => void;
let fontList = [] as { family: string; variants: string[]; files: Map<string, string> }[];
let loadDefaultFontCallback = undefined as fontCallbackType | undefined;
fetch(fontListAPI)
.then((response) => response.json())
.then((json) => {
const loadedFonts = json.items as { family: string; variants: string[]; files: { [name: string]: string } }[];
fontList = loadedFonts.map((font) => {
const { family } = font;
const variants = font.variants.map(formatFontStyleName);
const files = new Map(font.variants.map((x) => [formatFontStyleName(x), font.files[x]]));
return { family, variants, files };
});
loadDefaultFont();
});
function formatFontStyleName(fontStyle: string): string {
const isItalic = fontStyle.endsWith("italic");
const weight = fontStyle === "regular" || fontStyle === "italic" ? 400 : parseInt(fontStyle, 10);
let weightName = "";
let bestWeight = Infinity;
weightNameMapping.forEach((nameChecking, weightChecking) => {
if (Math.abs(weightChecking - weight) < bestWeight) {
bestWeight = Math.abs(weightChecking - weight);
weightName = nameChecking;
}
});
return `${weightName}${isItalic ? " Italic" : ""} (${weight})`;
}
export async function loadDefaultFont(): Promise<void> {
const font = getFontFile("Merriweather", "Normal (400)");
if (!font) return;
const response = await fetch(font);
const responseBuffer = await response.arrayBuffer();
loadDefaultFontCallback?.(font, new Uint8Array(responseBuffer));
}
export function setLoadDefaultFontCallback(callback: fontCallbackType): void {
loadDefaultFontCallback = callback;
loadDefaultFont();
}
export function fontNames(): string[] {
return fontList.map((value) => value.family);
}
export function getFontStyles(fontFamily: string): string[] {
const font = fontList.find((value) => value.family === fontFamily);
return font?.variants || [];
}
export function getFontFile(fontFamily: string, fontStyle: string): string | undefined {
const font = fontList.find((value) => value.family === fontFamily);
const fontFile = font?.files.get(fontStyle);
return fontFile?.replace("http://", "https://");
}

View file

@ -1,92 +0,0 @@
import { IconName, IconSize } from "@/utilities/icons";
// Text Button
export interface TextButtonWidget {
kind: "TextButton";
tooltip?: string;
message?: string | object;
callback?: () => void;
props: TextButtonProps;
}
export interface TextButtonProps {
// `action` is used via `IconButtonWidget.callback`
label: string;
emphasized?: boolean;
disabled?: boolean;
minWidth?: number;
gapAfter?: boolean;
}
// Icon Button
export interface IconButtonWidget {
kind: "IconButton";
tooltip?: string;
message?: string | object;
callback?: () => void;
props: IconButtonProps;
}
export interface IconButtonProps {
// `action` is used via `IconButtonWidget.callback`
icon: IconName;
size: IconSize;
gapAfter?: boolean;
}
// Popover Button
export interface PopoverButtonWidget {
kind: "PopoverButton";
tooltip?: string;
callback?: () => void;
// popover: WidgetLayout;
popover: { title: string; text: string }; // TODO: Replace this with a `WidgetLayout` like above for arbitrary layouts
props: PopoverButtonProps;
}
export interface PopoverButtonProps {
// `action` is used via `PopoverButtonWidget.callback`
icon?: PopoverButtonIcon;
}
type Extends<T, U extends T> = U;
export type PopoverButtonIcon = Extends<IconName, "DropdownArrow" | "VerticalEllipsis">;
// Number Input
export interface NumberInputWidget {
kind: "NumberInput";
tooltip?: string;
optionPath: string[];
props: Omit<NumberInputProps, "value">;
}
export interface NumberInputProps {
value: number;
min?: number;
max?: number;
incrementBehavior?: IncrementBehavior;
incrementFactor?: number;
isInteger?: boolean;
unit?: string;
unitIsHiddenWhenEditing?: boolean;
displayDecimalPlaces?: number;
label?: string;
disabled?: boolean;
}
export type IncrementBehavior = "Add" | "Multiply" | "Callback" | "None";
export type IncrementDirection = "Decrease" | "Increase";
// Separator
export type SeparatorDirection = "Horizontal" | "Vertical";
export type SeparatorType = "Related" | "Unrelated" | "Section" | "List";
export interface SeparatorWidget {
kind: "Separator";
props: SeparatorProps;
}
export interface SeparatorProps {
direction?: SeparatorDirection;
type?: SeparatorType;
}

View file

@ -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;

View file

@ -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
});
}

View file

@ -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

View 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",
]);

View 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
View file

@ -0,0 +1,23 @@
import { type RGBA as RGBA_ } from "@/wasm-communication/messages";
import FloatingMenu from "@/components/widgets/floating-menus/FloatingMenu.vue";
import MenuList from "@/components/widgets/floating-menus/MenuList.vue";
// TODO: When a Volar bug is fixed (likely in v0.34.16):
// TODO: - Uncomment this block
// TODO: - Remove the `MenuList` and `FloatingMenu` lines from the `declare global` section below
// TODO: - And possibly add the empty export line of code `export {};` to the bottom of this file, for some reason
// declare module "vue" {
// interface ComponentCustomProperties {
// const MenuList: MenuList;
// const FloatingMenu: FloatingMenu;
// }
// }
// Satisfies Volar
// TODO: Move this back into `DropdownInput.vue` and `SwatchPairInput.vue` after https://github.com/johnsoncodehk/volar/issues/1321 is fixed
declare global {
const MenuList: MenuList;
const FloatingMenu: FloatingMenu;
type RGBA = RGBA_;
}

View file

@ -0,0 +1,50 @@
import { panicProxy } from "@/utility-functions/panic-proxy";
import { JsMessageType } from "@/wasm-communication/messages";
import { createSubscriptionRouter, SubscriptionRouter } from "@/wasm-communication/subscription-router";
export type WasmRawInstance = typeof import("@/../wasm/pkg");
export type WasmEditorInstance = InstanceType<WasmRawInstance["JsEditorHandle"]>;
export type Editor = Readonly<ReturnType<typeof createEditor>>;
// `wasmImport` starts uninitialized because its initialization needs to occur asynchronously, and thus needs to occur by manually calling and awaiting `initWasm()`
let wasmImport: WasmRawInstance | null = null;
// Should be called asynchronously before `createEditor()`
export async function initWasm(): Promise<void> {
// Skip if the WASM module is already initialized
if (wasmImport !== null) return;
// Import the WASM module JS bindings and wrap them in the panic proxy
wasmImport = await import("@/../wasm/pkg").then(panicProxy);
// Provide a random starter seed which must occur after initializing the WASM module, since WASM can't generate its own random numbers
const randomSeed = BigInt(Math.floor(Math.random() * Number.MAX_SAFE_INTEGER));
wasmImport?.set_random_seed(randomSeed);
}
// Should be called after running `initWasm()` and its promise resolving
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export function createEditor() {
// Functions from `api.rs` defined directly on the WASM module, not the editor instance (generated by wasm-bindgen)
const raw: WasmRawInstance = getWasmInstance();
// Functions from `api.rs` that are part of the editor instance (generated by wasm-bindgen)
const instance: WasmEditorInstance = new raw.JsEditorHandle(invokeJsMessageSubscription);
function invokeJsMessageSubscription(messageType: JsMessageType, data: Record<string, unknown>): void {
subscriptions.handleJsMessage(messageType, data, raw, instance);
}
// Allows subscribing to messages in JS that are sent from the WASM backend
const subscriptions: SubscriptionRouter = createSubscriptionRouter();
return {
raw,
instance,
subscriptions,
};
}
export function getWasmInstance(): WasmRawInstance {
if (wasmImport) return wasmImport;
throw new Error("Editor WASM backend was not initialized at application startup");
}

View file

@ -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,

View file

@ -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>;

View file

@ -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 {

View file

@ -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);
}

View file

@ -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 {