mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-24 14:58:12 +08:00
Rework wasm initialization and reduce global state (#379)
* wasm: do the async initialization only once This allows the rest of the app to access wasm synchronously. This allows removing of a global. * provide the wasm via vue provide/inject. There's still code directly accessing the wasm. That will be changed later. * MenuBarInput: use injected wasm instead of the global instance * Let the App handle event listeners * move stateful modules into state/ * state/fullscreen: create per instance * App: load the initial document list on mount. This got lost a few commits ago. Now it's back. * state/dialog: create per instance * util/input: remove dependency on global dialog instance * state/documents: create per instance * reponse-handler: move into EditorWasm * comingSoon: move into dialog * wasm: allow instantiating multiple editors * input handlers: do not look at canvases outside the mounted App * input: listen on the container instead of the window when possible * - removed proxy from wasm-loader - integrated with js-dispatcher - state functions to classes - integrated some upstream changes * fix errors caused by merge * Getting closer: - added global state to track all instances - fix fullscreen close trigger - wasm-loader is statefull - panic across instanes * - fix outline while using editor - removed circular import rule - added editorInstance to js message constructor * - changed input handler to a class - still need a better way of handeling it in App.vue * - fixed single instance of inputManager to weakmap * - fix no-explicit-any in a few places - removed global state from input.ts * simplified two long lines * removed global state * removed $data from App * add mut self to functions in api.rs * Update Workspace.vue remove outdated import * fixed missing import * Changes throughout code review; note this causes some bugs to be fixed in a later commit * PR review round 1 * - fix coming soon bugs - changed folder structure * moved declaration to .d.ts * - changed from classes to functions - moved decs back to app.vue * removed need to export js function to rust * changed folder structure * fixed indentation breaking multiline strings * Fix eslint rule to whitelist @/../ * Simplify strip-indents implementation * replace type assertions with better annotations or proper runtime checks * Small tweaks and code rearranging improvements after second code review pass * maybe fix mouse events * Add back preventDefault for mouse scroll * code review round 2 * Comment improvements * -removed runtime checks - fixed layers not showing * - extened proxy to cover classes - stopped multiple panics from logging - Stop wasm-bindgen from mut ref counting our struct * cleaned up messageConstructors exports * Fix input and fullscreen regressions Co-authored-by: Max Fisher <maxmfishernj@gmail.com> Co-authored-by: mfish33 <32677537+mfish33@users.noreply.github.com> Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
co-authored by
Max Fisher
mfish33
Keavon Chambers
parent
37be856884
commit
448e72fe02
Generated
+1
@@ -126,6 +126,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"graphite-editor",
|
"graphite-editor",
|
||||||
"graphite-graphene",
|
"graphite-graphene",
|
||||||
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
"serde",
|
"serde",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
|
|||||||
@@ -141,8 +141,8 @@ impl Serialize for RawBuffer {
|
|||||||
S: serde::Serializer,
|
S: serde::Serializer,
|
||||||
{
|
{
|
||||||
let mut buffer = serializer.serialize_struct("Buffer", 2)?;
|
let mut buffer = serializer.serialize_struct("Buffer", 2)?;
|
||||||
buffer.serialize_field("ptr", &(self.0.as_ptr() as usize))?;
|
buffer.serialize_field("pointer", &(self.0.as_ptr() as usize))?;
|
||||||
buffer.serialize_field("len", &(self.0.len()))?;
|
buffer.serialize_field("length", &(self.0.len()))?;
|
||||||
buffer.end()
|
buffer.end()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ module.exports = {
|
|||||||
"no-bitwise": "off",
|
"no-bitwise": "off",
|
||||||
"no-shadow": "off",
|
"no-shadow": "off",
|
||||||
"no-use-before-define": "off",
|
"no-use-before-define": "off",
|
||||||
|
"no-restricted-imports": ["error", { patterns: [".*", "!@/*"] }],
|
||||||
|
|
||||||
// TypeScript plugin config
|
// TypeScript plugin config
|
||||||
"@typescript-eslint/indent": ["error", "tab", { SwitchCase: 1 }],
|
"@typescript-eslint/indent": ["error", "tab", { SwitchCase: 1 }],
|
||||||
|
|||||||
@@ -17,7 +17,8 @@
|
|||||||
|
|
||||||
<body>
|
<body>
|
||||||
<noscript>JavaScript is required</noscript>
|
<noscript>JavaScript is required</noscript>
|
||||||
<div id="app"></div>
|
<!-- tabindex is used to allow the app to have focus while inside of it -->
|
||||||
|
<div id="app" tabindex="0"></div>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+48
-8
@@ -71,6 +71,7 @@ body,
|
|||||||
background: var(--color-2-mildblack);
|
background: var(--color-2-mildblack);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
overscroll-behavior: none;
|
overscroll-behavior: none;
|
||||||
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
@@ -220,23 +221,52 @@ img {
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent } from "vue";
|
import { defineComponent } from "vue";
|
||||||
|
|
||||||
// State providers
|
import { DialogState, createDialogState } from "@/state/dialog";
|
||||||
import dialog from "@/utilities/dialog";
|
import { createDocumentsState, DocumentsState } from "@/state/documents";
|
||||||
import documents from "@/utilities/documents";
|
import { createFullscreenState, FullscreenState } from "@/state/fullscreen";
|
||||||
import fullscreen from "@/utilities/fullscreen";
|
|
||||||
|
|
||||||
import MainWindow from "@/components/window/MainWindow.vue";
|
import MainWindow from "@/components/window/MainWindow.vue";
|
||||||
import LayoutRow from "@/components/layout/LayoutRow.vue";
|
import LayoutRow from "@/components/layout/LayoutRow.vue";
|
||||||
|
import { createEditorState, EditorState } from "@/state/wasm-loader";
|
||||||
|
import { createInputManager, InputManager } from "@/lifetime/input";
|
||||||
|
import { initErrorHandling } from "@/lifetime/errors";
|
||||||
|
|
||||||
|
// Vue injects don't play well with TypeScript, and all injects will show up as `any`. As a workaround, we can define these types.
|
||||||
|
declare module "@vue/runtime-core" {
|
||||||
|
interface ComponentCustomProperties {
|
||||||
|
dialog: DialogState;
|
||||||
|
documents: DocumentsState;
|
||||||
|
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.
|
||||||
|
inputManger?: InputManager;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
provide: {
|
provide() {
|
||||||
dialog,
|
return {
|
||||||
documents,
|
editor: this.editor,
|
||||||
fullscreen,
|
dialog: this.dialog,
|
||||||
|
documents: this.documents,
|
||||||
|
fullscreen: this.fullscreen,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
|
const editor = createEditorState();
|
||||||
|
const dialog = createDialogState(editor);
|
||||||
|
const documents = createDocumentsState(editor, dialog);
|
||||||
|
const fullscreen = createFullscreenState();
|
||||||
|
initErrorHandling(editor, dialog);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
editor,
|
||||||
|
dialog,
|
||||||
|
documents,
|
||||||
|
fullscreen,
|
||||||
showUnsupportedModal: !("BigInt64Array" in window),
|
showUnsupportedModal: !("BigInt64Array" in window),
|
||||||
|
inputManager: undefined as undefined | InputManager,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -244,6 +274,16 @@ export default defineComponent({
|
|||||||
this.showUnsupportedModal = false;
|
this.showUnsupportedModal = false;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
mounted() {
|
||||||
|
this.inputManager = createInputManager(this.editor, this.$el.parentElement, this.dialog, this.documents, this.fullscreen);
|
||||||
|
},
|
||||||
|
beforeUnmount() {
|
||||||
|
const { inputManager } = this;
|
||||||
|
if (inputManager) inputManager.removeListeners();
|
||||||
|
|
||||||
|
const { editor } = this;
|
||||||
|
editor.instance.free();
|
||||||
|
},
|
||||||
components: { MainWindow, LayoutRow },
|
components: { MainWindow, LayoutRow },
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<template>
|
||||||
|
<div style="display: flex">
|
||||||
|
<div tabindex="0" style="width: 50%; outline: none">
|
||||||
|
<App />
|
||||||
|
</div>
|
||||||
|
<div tabindex="0" style="width: 50%; outline: none">
|
||||||
|
<App />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent } from "vue";
|
||||||
|
|
||||||
|
import App from "@/App.vue";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
components: { App },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
<Separator :type="SeparatorType.Unrelated" />
|
<Separator :type="SeparatorType.Unrelated" />
|
||||||
|
|
||||||
<OptionalInput v-model:checked="gridEnabled" @update:checked="comingSoon(318)" :icon="'Grid'" title="Grid" />
|
<OptionalInput v-model:checked="gridEnabled" @update:checked="dialog.comingSoon(318)" :icon="'Grid'" title="Grid" />
|
||||||
<PopoverButton>
|
<PopoverButton>
|
||||||
<h3>Grid</h3>
|
<h3>Grid</h3>
|
||||||
<p>The contents of this popover menu are coming soon</p>
|
<p>The contents of this popover menu are coming soon</p>
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
|
|
||||||
<Separator :type="SeparatorType.Unrelated" />
|
<Separator :type="SeparatorType.Unrelated" />
|
||||||
|
|
||||||
<OptionalInput v-model:checked="overlaysEnabled" @update:checked="comingSoon(99)" :icon="'Overlays'" title="Overlays" />
|
<OptionalInput v-model:checked="overlaysEnabled" @update:checked="dialog.comingSoon(99)" :icon="'Overlays'" title="Overlays" />
|
||||||
<PopoverButton>
|
<PopoverButton>
|
||||||
<h3>Overlays</h3>
|
<h3>Overlays</h3>
|
||||||
<p>The contents of this popover menu are coming soon</p>
|
<p>The contents of this popover menu are coming soon</p>
|
||||||
@@ -70,31 +70,31 @@
|
|||||||
<LayoutCol :class="'shelf'">
|
<LayoutCol :class="'shelf'">
|
||||||
<div class="tools scrollable-y">
|
<div class="tools scrollable-y">
|
||||||
<ShelfItemInput icon="LayoutSelectTool" title="Select Tool (V)" :active="activeTool === 'Select'" :action="() => selectTool('Select')" />
|
<ShelfItemInput icon="LayoutSelectTool" title="Select Tool (V)" :active="activeTool === 'Select'" :action="() => selectTool('Select')" />
|
||||||
<ShelfItemInput icon="LayoutCropTool" title="Crop Tool" :active="activeTool === 'Crop'" :action="() => comingSoon(289) && selectTool('Crop')" />
|
<ShelfItemInput icon="LayoutCropTool" title="Crop Tool" :active="activeTool === 'Crop'" :action="() => dialog.comingSoon(289) && selectTool('Crop')" />
|
||||||
<ShelfItemInput icon="LayoutNavigateTool" title="Navigate Tool (Z)" :active="activeTool === 'Navigate'" :action="() => comingSoon(155) && selectTool('Navigate')" />
|
<ShelfItemInput icon="LayoutNavigateTool" title="Navigate Tool (Z)" :active="activeTool === 'Navigate'" :action="() => dialog.comingSoon(155) && selectTool('Navigate')" />
|
||||||
<ShelfItemInput icon="LayoutEyedropperTool" title="Eyedropper Tool (I)" :active="activeTool === 'Eyedropper'" :action="() => selectTool('Eyedropper')" />
|
<ShelfItemInput icon="LayoutEyedropperTool" title="Eyedropper Tool (I)" :active="activeTool === 'Eyedropper'" :action="() => selectTool('Eyedropper')" />
|
||||||
|
|
||||||
<Separator :type="SeparatorType.Section" :direction="SeparatorDirection.Vertical" />
|
<Separator :type="SeparatorType.Section" :direction="SeparatorDirection.Vertical" />
|
||||||
|
|
||||||
<ShelfItemInput icon="ParametricTextTool" title="Text Tool (T)" :active="activeTool === 'Text'" :action="() => comingSoon(153) && selectTool('Text')" />
|
<ShelfItemInput icon="ParametricTextTool" title="Text Tool (T)" :active="activeTool === 'Text'" :action="() => dialog.comingSoon(153) && selectTool('Text')" />
|
||||||
<ShelfItemInput icon="ParametricFillTool" title="Fill Tool (F)" :active="activeTool === 'Fill'" :action="() => selectTool('Fill')" />
|
<ShelfItemInput icon="ParametricFillTool" title="Fill Tool (F)" :active="activeTool === 'Fill'" :action="() => selectTool('Fill')" />
|
||||||
<ShelfItemInput icon="ParametricGradientTool" title="Gradient Tool (H)" :active="activeTool === 'Gradient'" :action="() => comingSoon() && selectTool('Gradient')" />
|
<ShelfItemInput icon="ParametricGradientTool" title="Gradient Tool (H)" :active="activeTool === 'Gradient'" :action="() => dialog.comingSoon() && selectTool('Gradient')" />
|
||||||
|
|
||||||
<Separator :type="SeparatorType.Section" :direction="SeparatorDirection.Vertical" />
|
<Separator :type="SeparatorType.Section" :direction="SeparatorDirection.Vertical" />
|
||||||
|
|
||||||
<ShelfItemInput icon="RasterBrushTool" title="Brush Tool (B)" :active="activeTool === 'Brush'" :action="() => comingSoon() && selectTool('Brush')" />
|
<ShelfItemInput icon="RasterBrushTool" title="Brush Tool (B)" :active="activeTool === 'Brush'" :action="() => dialog.comingSoon() && selectTool('Brush')" />
|
||||||
<ShelfItemInput icon="RasterHealTool" title="Heal Tool (J)" :active="activeTool === 'Heal'" :action="() => comingSoon() && selectTool('Heal')" />
|
<ShelfItemInput icon="RasterHealTool" title="Heal Tool (J)" :active="activeTool === 'Heal'" :action="() => dialog.comingSoon() && selectTool('Heal')" />
|
||||||
<ShelfItemInput icon="RasterCloneTool" title="Clone Tool (C)" :active="activeTool === 'Clone'" :action="() => comingSoon() && selectTool('Clone')" />
|
<ShelfItemInput icon="RasterCloneTool" title="Clone Tool (C)" :active="activeTool === 'Clone'" :action="() => dialog.comingSoon() && selectTool('Clone')" />
|
||||||
<ShelfItemInput icon="RasterPatchTool" title="Patch Tool" :active="activeTool === 'Patch'" :action="() => comingSoon() && selectTool('Patch')" />
|
<ShelfItemInput icon="RasterPatchTool" title="Patch Tool" :active="activeTool === 'Patch'" :action="() => dialog.comingSoon() && selectTool('Patch')" />
|
||||||
<ShelfItemInput icon="RasterBlurSharpenTool" title="Detail Tool (D)" :active="activeTool === 'BlurSharpen'" :action="() => comingSoon() && selectTool('BlurSharpen')" />
|
<ShelfItemInput icon="RasterBlurSharpenTool" title="Detail Tool (D)" :active="activeTool === 'BlurSharpen'" :action="() => dialog.comingSoon() && selectTool('BlurSharpen')" />
|
||||||
<ShelfItemInput icon="RasterRelightTool" title="Relight Tool (O)" :active="activeTool === 'Relight'" :action="() => comingSoon() && selectTool('Relight')" />
|
<ShelfItemInput icon="RasterRelightTool" title="Relight Tool (O)" :active="activeTool === 'Relight'" :action="() => dialog.comingSoon() && selectTool('Relight')" />
|
||||||
|
|
||||||
<Separator :type="SeparatorType.Section" :direction="SeparatorDirection.Vertical" />
|
<Separator :type="SeparatorType.Section" :direction="SeparatorDirection.Vertical" />
|
||||||
|
|
||||||
<ShelfItemInput icon="VectorPathTool" title="Path Tool (A)" :active="activeTool === 'Path'" :action="() => selectTool('Path')" />
|
<ShelfItemInput icon="VectorPathTool" title="Path Tool (A)" :active="activeTool === 'Path'" :action="() => selectTool('Path')" />
|
||||||
<ShelfItemInput icon="VectorPenTool" title="Pen Tool (P)" :active="activeTool === 'Pen'" :action="() => selectTool('Pen')" />
|
<ShelfItemInput icon="VectorPenTool" title="Pen Tool (P)" :active="activeTool === 'Pen'" :action="() => selectTool('Pen')" />
|
||||||
<ShelfItemInput icon="VectorFreehandTool" title="Freehand Tool (N)" :active="activeTool === 'Freehand'" :action="() => comingSoon() && selectTool('Freehand')" />
|
<ShelfItemInput icon="VectorFreehandTool" title="Freehand Tool (N)" :active="activeTool === 'Freehand'" :action="() => dialog.comingSoon() && selectTool('Freehand')" />
|
||||||
<ShelfItemInput icon="VectorSplineTool" title="Spline Tool" :active="activeTool === 'Spline'" :action="() => comingSoon() && selectTool('Spline')" />
|
<ShelfItemInput icon="VectorSplineTool" title="Spline Tool" :active="activeTool === 'Spline'" :action="() => dialog.comingSoon() && selectTool('Spline')" />
|
||||||
<ShelfItemInput icon="VectorLineTool" title="Line Tool (L)" :active="activeTool === 'Line'" :action="() => selectTool('Line')" />
|
<ShelfItemInput icon="VectorLineTool" title="Line Tool (L)" :active="activeTool === 'Line'" :action="() => selectTool('Line')" />
|
||||||
<ShelfItemInput icon="VectorRectangleTool" title="Rectangle Tool (M)" :active="activeTool === 'Rectangle'" :action="() => selectTool('Rectangle')" />
|
<ShelfItemInput icon="VectorRectangleTool" title="Rectangle Tool (M)" :active="activeTool === 'Rectangle'" :action="() => selectTool('Rectangle')" />
|
||||||
<ShelfItemInput icon="VectorEllipseTool" title="Ellipse Tool (E)" :active="activeTool === 'Ellipse'" :action="() => selectTool('Ellipse')" />
|
<ShelfItemInput icon="VectorEllipseTool" title="Ellipse Tool (E)" :active="activeTool === 'Ellipse'" :action="() => selectTool('Ellipse')" />
|
||||||
@@ -238,11 +238,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent } from "vue";
|
import { defineComponent } from "vue";
|
||||||
|
|
||||||
import { subscribeJsMessage } from "@/utilities/js-message-dispatcher";
|
import { UpdateCanvas, UpdateScrollbars, UpdateRulers, SetActiveTool, SetCanvasZoom, SetCanvasRotation } from "@/dispatcher/js-messages";
|
||||||
import { UpdateCanvas, UpdateScrollbars, UpdateRulers, SetActiveTool, SetCanvasZoom, SetCanvasRotation } from "@/utilities/js-messages";
|
|
||||||
import { SeparatorDirection, SeparatorType } from "@/components/widgets/widgets";
|
import { SeparatorDirection, SeparatorType } from "@/components/widgets/widgets";
|
||||||
import { comingSoon } from "@/utilities/errors";
|
|
||||||
import { panicProxy } from "@/utilities/panic-proxy";
|
|
||||||
|
|
||||||
import LayoutRow from "@/components/layout/LayoutRow.vue";
|
import LayoutRow from "@/components/layout/LayoutRow.vue";
|
||||||
import LayoutCol from "@/components/layout/LayoutCol.vue";
|
import LayoutCol from "@/components/layout/LayoutCol.vue";
|
||||||
@@ -261,27 +258,13 @@ import OptionalInput from "@/components/widgets/inputs/OptionalInput.vue";
|
|||||||
import ToolOptions from "@/components/widgets/options/ToolOptions.vue";
|
import ToolOptions from "@/components/widgets/options/ToolOptions.vue";
|
||||||
import { SectionsOfMenuListEntries } from "@/components/widgets/floating-menus/MenuList.vue";
|
import { SectionsOfMenuListEntries } from "@/components/widgets/floating-menus/MenuList.vue";
|
||||||
|
|
||||||
const wasm = import("@/../wasm/pkg").then(panicProxy);
|
|
||||||
|
|
||||||
const documentModeEntries: SectionsOfMenuListEntries = [
|
|
||||||
[
|
|
||||||
{ label: "Design Mode", icon: "ViewportDesignMode" },
|
|
||||||
{ label: "Select Mode", icon: "ViewportSelectMode", action: () => comingSoon(330) },
|
|
||||||
{ label: "Guide Mode", icon: "ViewportGuideMode", action: () => comingSoon(331) },
|
|
||||||
],
|
|
||||||
];
|
|
||||||
const viewModeEntries: RadioEntries = [
|
|
||||||
{ value: "normal", icon: "ViewModeNormal", tooltip: "View Mode: Normal" },
|
|
||||||
{ value: "outline", icon: "ViewModeOutline", tooltip: "View Mode: Outline", action: () => comingSoon(319) },
|
|
||||||
{ value: "pixels", icon: "ViewModePixels", tooltip: "View Mode: Pixels", action: () => comingSoon(320) },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
|
inject: ["editor", "dialog"],
|
||||||
methods: {
|
methods: {
|
||||||
async setSnap(newStatus: boolean) {
|
setSnap(newStatus: boolean) {
|
||||||
(await wasm).set_snapping(newStatus);
|
this.editor.instance.set_snapping(newStatus);
|
||||||
},
|
},
|
||||||
async viewportResize() {
|
viewportResize() {
|
||||||
const canvas = this.$refs.canvas as HTMLElement;
|
const canvas = this.$refs.canvas as HTMLElement;
|
||||||
// Get the width and height rounded up to the nearest even number because resizing is centered and dividing an odd number by 2 for centering causes antialiasing
|
// Get the width and height rounded up to the nearest even number because resizing is centered and dividing an odd number by 2 for centering causes antialiasing
|
||||||
let width = Math.ceil(parseFloat(getComputedStyle(canvas).width));
|
let width = Math.ceil(parseFloat(getComputedStyle(canvas).width));
|
||||||
@@ -292,73 +275,73 @@ export default defineComponent({
|
|||||||
this.canvasSvgWidth = `${width}px`;
|
this.canvasSvgWidth = `${width}px`;
|
||||||
this.canvasSvgHeight = `${height}px`;
|
this.canvasSvgHeight = `${height}px`;
|
||||||
},
|
},
|
||||||
async setCanvasZoom(newZoom: number) {
|
setCanvasZoom(newZoom: number) {
|
||||||
(await wasm).set_canvas_zoom(newZoom / 100);
|
this.editor.instance.set_canvas_zoom(newZoom / 100);
|
||||||
},
|
},
|
||||||
async increaseCanvasZoom() {
|
increaseCanvasZoom() {
|
||||||
(await wasm).increase_canvas_zoom();
|
this.editor.instance.increase_canvas_zoom();
|
||||||
},
|
},
|
||||||
async decreaseCanvasZoom() {
|
decreaseCanvasZoom() {
|
||||||
(await wasm).decrease_canvas_zoom();
|
this.editor.instance.decrease_canvas_zoom();
|
||||||
},
|
},
|
||||||
async setRotation(newRotation: number) {
|
setRotation(newRotation: number) {
|
||||||
(await wasm).set_rotation(newRotation * (Math.PI / 180));
|
this.editor.instance.set_rotation(newRotation * (Math.PI / 180));
|
||||||
},
|
},
|
||||||
async translateCanvasX(newValue: number) {
|
translateCanvasX(newValue: number) {
|
||||||
const delta = newValue - this.scrollbarPos.x;
|
const delta = newValue - this.scrollbarPos.x;
|
||||||
this.scrollbarPos.x = newValue;
|
this.scrollbarPos.x = newValue;
|
||||||
(await wasm).translate_canvas(-delta * this.scrollbarMultiplier.x, 0);
|
this.editor.instance.translate_canvas(-delta * this.scrollbarMultiplier.x, 0);
|
||||||
},
|
},
|
||||||
async translateCanvasY(newValue: number) {
|
translateCanvasY(newValue: number) {
|
||||||
const delta = newValue - this.scrollbarPos.y;
|
const delta = newValue - this.scrollbarPos.y;
|
||||||
this.scrollbarPos.y = newValue;
|
this.scrollbarPos.y = newValue;
|
||||||
(await wasm).translate_canvas(0, -delta * this.scrollbarMultiplier.y);
|
this.editor.instance.translate_canvas(0, -delta * this.scrollbarMultiplier.y);
|
||||||
},
|
},
|
||||||
async pageX(delta: number) {
|
pageX(delta: number) {
|
||||||
const move = delta < 0 ? 1 : -1;
|
const move = delta < 0 ? 1 : -1;
|
||||||
(await wasm).translate_canvas_by_fraction(move, 0);
|
this.editor.instance.translate_canvas_by_fraction(move, 0);
|
||||||
},
|
},
|
||||||
async pageY(delta: number) {
|
pageY(delta: number) {
|
||||||
const move = delta < 0 ? 1 : -1;
|
const move = delta < 0 ? 1 : -1;
|
||||||
(await wasm).translate_canvas_by_fraction(0, move);
|
this.editor.instance.translate_canvas_by_fraction(0, move);
|
||||||
},
|
},
|
||||||
async selectTool(toolName: string) {
|
selectTool(toolName: string) {
|
||||||
(await wasm).select_tool(toolName);
|
this.editor.instance.select_tool(toolName);
|
||||||
},
|
},
|
||||||
async swapWorkingColors() {
|
swapWorkingColors() {
|
||||||
(await wasm).swap_colors();
|
this.editor.instance.swap_colors();
|
||||||
},
|
},
|
||||||
async resetWorkingColors() {
|
resetWorkingColors() {
|
||||||
(await wasm).reset_colors();
|
this.editor.instance.reset_colors();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
subscribeJsMessage(UpdateCanvas, (updateCanvas) => {
|
this.editor.dispatcher.subscribeJsMessage(UpdateCanvas, (updateCanvas) => {
|
||||||
this.viewportSvg = updateCanvas.document;
|
this.viewportSvg = updateCanvas.document;
|
||||||
});
|
});
|
||||||
|
|
||||||
subscribeJsMessage(UpdateScrollbars, (updateScrollbars) => {
|
this.editor.dispatcher.subscribeJsMessage(UpdateScrollbars, (updateScrollbars) => {
|
||||||
this.scrollbarPos = updateScrollbars.position;
|
this.scrollbarPos = updateScrollbars.position;
|
||||||
this.scrollbarSize = updateScrollbars.size;
|
this.scrollbarSize = updateScrollbars.size;
|
||||||
this.scrollbarMultiplier = updateScrollbars.multiplier;
|
this.scrollbarMultiplier = updateScrollbars.multiplier;
|
||||||
});
|
});
|
||||||
|
|
||||||
subscribeJsMessage(UpdateRulers, (updateRulers) => {
|
this.editor.dispatcher.subscribeJsMessage(UpdateRulers, (updateRulers) => {
|
||||||
this.rulerOrigin = updateRulers.origin;
|
this.rulerOrigin = updateRulers.origin;
|
||||||
this.rulerSpacing = updateRulers.spacing;
|
this.rulerSpacing = updateRulers.spacing;
|
||||||
this.rulerInterval = updateRulers.interval;
|
this.rulerInterval = updateRulers.interval;
|
||||||
});
|
});
|
||||||
|
|
||||||
subscribeJsMessage(SetActiveTool, (setActiveTool) => {
|
this.editor.dispatcher.subscribeJsMessage(SetActiveTool, (setActiveTool) => {
|
||||||
this.activeTool = setActiveTool.tool_name;
|
this.activeTool = setActiveTool.tool_name;
|
||||||
this.activeToolOptions = setActiveTool.tool_options;
|
this.activeToolOptions = setActiveTool.tool_options;
|
||||||
});
|
});
|
||||||
|
|
||||||
subscribeJsMessage(SetCanvasZoom, (setCanvasZoom) => {
|
this.editor.dispatcher.subscribeJsMessage(SetCanvasZoom, (setCanvasZoom) => {
|
||||||
this.documentZoom = setCanvasZoom.new_zoom * 100;
|
this.documentZoom = setCanvasZoom.new_zoom * 100;
|
||||||
});
|
});
|
||||||
|
|
||||||
subscribeJsMessage(SetCanvasRotation, (setCanvasRotation) => {
|
this.editor.dispatcher.subscribeJsMessage(SetCanvasRotation, (setCanvasRotation) => {
|
||||||
const newRotation = setCanvasRotation.new_radians * (180 / Math.PI);
|
const newRotation = setCanvasRotation.new_radians * (180 / Math.PI);
|
||||||
this.documentRotation = (360 + (newRotation % 360)) % 360;
|
this.documentRotation = (360 + (newRotation % 360)) % 360;
|
||||||
});
|
});
|
||||||
@@ -367,6 +350,19 @@ export default defineComponent({
|
|||||||
window.addEventListener("DOMContentLoaded", this.viewportResize);
|
window.addEventListener("DOMContentLoaded", this.viewportResize);
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
|
const documentModeEntries: SectionsOfMenuListEntries = [
|
||||||
|
[
|
||||||
|
{ label: "Design Mode", icon: "ViewportDesignMode" },
|
||||||
|
{ label: "Select Mode", icon: "ViewportSelectMode", action: () => this.dialog.comingSoon(330) },
|
||||||
|
{ label: "Guide Mode", icon: "ViewportGuideMode", action: () => this.dialog.comingSoon(331) },
|
||||||
|
],
|
||||||
|
];
|
||||||
|
const viewModeEntries: RadioEntries = [
|
||||||
|
{ value: "normal", icon: "ViewModeNormal", tooltip: "View Mode: Normal" },
|
||||||
|
{ value: "outline", icon: "ViewModeOutline", tooltip: "View Mode: Outline", action: () => this.dialog.comingSoon(319) },
|
||||||
|
{ value: "pixels", icon: "ViewModePixels", tooltip: "View Mode: Pixels", action: () => this.dialog.comingSoon(320) },
|
||||||
|
];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
viewportSvg: "",
|
viewportSvg: "",
|
||||||
canvasSvgWidth: "100%",
|
canvasSvgWidth: "100%",
|
||||||
@@ -395,7 +391,6 @@ export default defineComponent({
|
|||||||
ScrollbarDirection,
|
ScrollbarDirection,
|
||||||
RulerDirection,
|
RulerDirection,
|
||||||
SeparatorType,
|
SeparatorType,
|
||||||
comingSoon,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
|
|||||||
@@ -197,9 +197,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent } from "vue";
|
import { defineComponent } from "vue";
|
||||||
|
|
||||||
import { subscribeJsMessage } from "@/utilities/js-message-dispatcher";
|
import { BlendMode, DisplayFolderTreeStructure, UpdateLayer, LayerPanelEntry, LayerTypeOptions } from "@/dispatcher/js-messages";
|
||||||
import { BlendMode, DisplayFolderTreeStructure, UpdateLayer, LayerPanelEntry, LayerTypeOptions } from "@/utilities/js-messages";
|
|
||||||
import { panicProxy } from "@/utilities/panic-proxy";
|
|
||||||
import { SeparatorType } from "@/components/widgets/widgets";
|
import { SeparatorType } from "@/components/widgets/widgets";
|
||||||
|
|
||||||
import LayoutRow from "@/components/layout/LayoutRow.vue";
|
import LayoutRow from "@/components/layout/LayoutRow.vue";
|
||||||
@@ -213,8 +211,6 @@ import IconLabel from "@/components/widgets/labels/IconLabel.vue";
|
|||||||
import DropdownInput from "@/components/widgets/inputs/DropdownInput.vue";
|
import DropdownInput from "@/components/widgets/inputs/DropdownInput.vue";
|
||||||
import { SectionsOfMenuListEntries } from "@/components/widgets/floating-menus/MenuList.vue";
|
import { SectionsOfMenuListEntries } from "@/components/widgets/floating-menus/MenuList.vue";
|
||||||
|
|
||||||
const wasm = import("@/../wasm/pkg").then(panicProxy);
|
|
||||||
|
|
||||||
const blendModeEntries: SectionsOfMenuListEntries<BlendMode> = [
|
const blendModeEntries: SectionsOfMenuListEntries<BlendMode> = [
|
||||||
[{ label: "Normal", value: "Normal" }],
|
[{ label: "Normal", value: "Normal" }],
|
||||||
[
|
[
|
||||||
@@ -255,6 +251,7 @@ const blendModeEntries: SectionsOfMenuListEntries<BlendMode> = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
|
inject: ["editor"],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
blendModeEntries,
|
blendModeEntries,
|
||||||
@@ -278,19 +275,19 @@ export default defineComponent({
|
|||||||
return `${(layer.path.length - 1) * 16}px`;
|
return `${(layer.path.length - 1) * 16}px`;
|
||||||
},
|
},
|
||||||
async toggleLayerVisibility(path: BigUint64Array) {
|
async toggleLayerVisibility(path: BigUint64Array) {
|
||||||
(await wasm).toggle_layer_visibility(path);
|
this.editor.instance.toggle_layer_visibility(path);
|
||||||
},
|
},
|
||||||
async handleNodeConnectorClick(path: BigUint64Array) {
|
async handleNodeConnectorClick(path: BigUint64Array) {
|
||||||
(await wasm).toggle_layer_expansion(path);
|
this.editor.instance.toggle_layer_expansion(path);
|
||||||
},
|
},
|
||||||
async setLayerBlendMode() {
|
async setLayerBlendMode() {
|
||||||
const blendMode = this.blendModeEntries.flat()[this.blendModeSelectedIndex].value;
|
const blendMode = this.blendModeEntries.flat()[this.blendModeSelectedIndex].value;
|
||||||
if (blendMode) {
|
if (blendMode) {
|
||||||
(await wasm).set_blend_mode_for_selected_layers(blendMode);
|
this.editor.instance.set_blend_mode_for_selected_layers(blendMode);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async setLayerOpacity() {
|
async setLayerOpacity() {
|
||||||
(await wasm).set_opacity_for_selected_layers(this.opacity);
|
this.editor.instance.set_opacity_for_selected_layers(this.opacity);
|
||||||
},
|
},
|
||||||
async handleControlClick(clickedLayer: LayerPanelEntry) {
|
async handleControlClick(clickedLayer: LayerPanelEntry) {
|
||||||
const index = this.layers.indexOf(clickedLayer);
|
const index = this.layers.indexOf(clickedLayer);
|
||||||
@@ -330,7 +327,7 @@ export default defineComponent({
|
|||||||
this.selectionRangeStartLayer = undefined;
|
this.selectionRangeStartLayer = undefined;
|
||||||
this.selectionRangeEndLayer = undefined;
|
this.selectionRangeEndLayer = undefined;
|
||||||
|
|
||||||
(await wasm).deselect_all_layers();
|
this.editor.instance.deselect_all_layers();
|
||||||
},
|
},
|
||||||
async fillSelectionRange(start: LayerPanelEntry, end: LayerPanelEntry, selected = true) {
|
async fillSelectionRange(start: LayerPanelEntry, end: LayerPanelEntry, selected = true) {
|
||||||
const startIndex = this.layers.findIndex((layer) => layer.path.join() === start.path.join());
|
const startIndex = this.layers.findIndex((layer) => layer.path.join() === start.path.join());
|
||||||
@@ -363,7 +360,7 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
i += 1;
|
i += 1;
|
||||||
});
|
});
|
||||||
(await wasm).select_layers(output);
|
this.editor.instance.select_layers(output);
|
||||||
},
|
},
|
||||||
setBlendModeForSelectedLayers() {
|
setBlendModeForSelectedLayers() {
|
||||||
const selected = this.layers.filter((layer) => layer.layer_data.selected);
|
const selected = this.layers.filter((layer) => layer.layer_data.selected);
|
||||||
@@ -407,7 +404,7 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
subscribeJsMessage(DisplayFolderTreeStructure, (displayFolderTreeStructure) => {
|
this.editor.dispatcher.subscribeJsMessage(DisplayFolderTreeStructure, (displayFolderTreeStructure) => {
|
||||||
const path = [] as bigint[];
|
const path = [] as bigint[];
|
||||||
this.layers = [] as LayerPanelEntry[];
|
this.layers = [] as LayerPanelEntry[];
|
||||||
function recurse(folder: DisplayFolderTreeStructure, layers: LayerPanelEntry[], cache: Map<string, LayerPanelEntry>) {
|
function recurse(folder: DisplayFolderTreeStructure, layers: LayerPanelEntry[], cache: Map<string, LayerPanelEntry>) {
|
||||||
@@ -423,7 +420,7 @@ export default defineComponent({
|
|||||||
recurse(displayFolderTreeStructure, this.layers, this.layerCache);
|
recurse(displayFolderTreeStructure, this.layers, this.layerCache);
|
||||||
});
|
});
|
||||||
|
|
||||||
subscribeJsMessage(UpdateLayer, (updateLayer) => {
|
this.editor.dispatcher.subscribeJsMessage(UpdateLayer, (updateLayer) => {
|
||||||
const targetPath = updateLayer.data.path;
|
const targetPath = updateLayer.data.path;
|
||||||
const targetLayer = updateLayer.data;
|
const targetLayer = updateLayer.data;
|
||||||
|
|
||||||
|
|||||||
@@ -3,14 +3,14 @@
|
|||||||
<FloatingMenu :type="MenuType.Dialog" :direction="MenuDirection.Center">
|
<FloatingMenu :type="MenuType.Dialog" :direction="MenuDirection.Center">
|
||||||
<LayoutRow>
|
<LayoutRow>
|
||||||
<LayoutCol :class="'icon-column'">
|
<LayoutCol :class="'icon-column'">
|
||||||
<!-- `dialog.icon` class exists to provide special sizing in CSS to specific icons -->
|
<!-- `dialog.state.icon` class exists to provide special sizing in CSS to specific icons -->
|
||||||
<IconLabel :icon="dialog.icon" :class="dialog.icon.toLowerCase()" />
|
<IconLabel :icon="dialog.state.icon" :class="dialog.state.icon.toLowerCase()" />
|
||||||
</LayoutCol>
|
</LayoutCol>
|
||||||
<LayoutCol :class="'main-column'">
|
<LayoutCol :class="'main-column'">
|
||||||
<TextLabel :bold="true" :class="'heading'">{{ dialog.heading }}</TextLabel>
|
<TextLabel :bold="true" :class="'heading'">{{ dialog.state.heading }}</TextLabel>
|
||||||
<TextLabel :class="'details'">{{ dialog.details }}</TextLabel>
|
<TextLabel :class="'details'">{{ dialog.state.details }}</TextLabel>
|
||||||
<LayoutRow :class="'buttons-row'" v-if="dialog.buttons.length > 0">
|
<LayoutRow :class="'buttons-row'" v-if="dialog.state.buttons.length > 0">
|
||||||
<TextButton v-for="(button, index) in dialog.buttons" :key="index" :title="button.tooltip" :action="button.callback" v-bind="button.props" />
|
<TextButton v-for="(button, index) in dialog.state.buttons" :key="index" :title="button.tooltip" :action="button.callback" v-bind="button.props" />
|
||||||
</LayoutRow>
|
</LayoutRow>
|
||||||
</LayoutCol>
|
</LayoutCol>
|
||||||
</LayoutRow>
|
</LayoutRow>
|
||||||
@@ -79,8 +79,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent } from "vue";
|
import { defineComponent } from "vue";
|
||||||
|
|
||||||
import { dismissDialog } from "@/utilities/dialog";
|
|
||||||
|
|
||||||
import LayoutRow from "@/components/layout/LayoutRow.vue";
|
import LayoutRow from "@/components/layout/LayoutRow.vue";
|
||||||
import LayoutCol from "@/components/layout/LayoutCol.vue";
|
import LayoutCol from "@/components/layout/LayoutCol.vue";
|
||||||
import FloatingMenu, { MenuDirection, MenuType } from "@/components/widgets/floating-menus/FloatingMenu.vue";
|
import FloatingMenu, { MenuDirection, MenuType } from "@/components/widgets/floating-menus/FloatingMenu.vue";
|
||||||
@@ -100,7 +98,7 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
dismiss() {
|
dismiss() {
|
||||||
dismissDialog();
|
this.dialog.dismissDialog();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
<span class="entry-label">{{ entry.label }}</span>
|
<span class="entry-label">{{ entry.label }}</span>
|
||||||
|
|
||||||
<IconLabel v-if="entry.shortcutRequiresLock && !fullscreen.keyboardLocked" :icon="'Info'" :title="keyboardLockInfoMessage" />
|
<IconLabel v-if="entry.shortcutRequiresLock && !fullscreen.state.keyboardLocked" :icon="'Info'" :title="keyboardLockInfoMessage" />
|
||||||
<UserInputLabel v-else-if="entry.shortcut && entry.shortcut.length" :inputKeys="[entry.shortcut]" />
|
<UserInputLabel v-else-if="entry.shortcut && entry.shortcut.length" :inputKeys="[entry.shortcut]" />
|
||||||
|
|
||||||
<div class="submenu-arrow" v-if="entry.children && entry.children.length"></div>
|
<div class="submenu-arrow" v-if="entry.children && entry.children.length"></div>
|
||||||
@@ -132,7 +132,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, PropType } from "vue";
|
import { defineComponent, PropType } from "vue";
|
||||||
|
|
||||||
import { keyboardLockApiSupported } from "@/utilities/fullscreen";
|
|
||||||
import { SeparatorDirection, SeparatorType } from "@/components/widgets/widgets";
|
import { SeparatorDirection, SeparatorType } from "@/components/widgets/widgets";
|
||||||
|
|
||||||
import FloatingMenu, { MenuDirection, MenuType } from "@/components/widgets/floating-menus/FloatingMenu.vue";
|
import FloatingMenu, { MenuDirection, MenuType } from "@/components/widgets/floating-menus/FloatingMenu.vue";
|
||||||
@@ -265,7 +264,7 @@ const MenuList = defineComponent({
|
|||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
keyboardLockInfoMessage: keyboardLockApiSupported() ? KEYBOARD_LOCK_USE_FULLSCREEN : KEYBOARD_LOCK_SWITCH_BROWSER,
|
keyboardLockInfoMessage: this.fullscreen.keyboardLockApiSupported ? KEYBOARD_LOCK_USE_FULLSCREEN : KEYBOARD_LOCK_SWITCH_BROWSER,
|
||||||
SeparatorDirection,
|
SeparatorDirection,
|
||||||
SeparatorType,
|
SeparatorType,
|
||||||
MenuDirection,
|
MenuDirection,
|
||||||
|
|||||||
@@ -53,117 +53,116 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent } from "vue";
|
import { defineComponent } from "vue";
|
||||||
|
|
||||||
import { comingSoon } from "@/utilities/errors";
|
|
||||||
import { panicProxy } from "@/utilities/panic-proxy";
|
|
||||||
|
|
||||||
import IconLabel from "@/components/widgets/labels/IconLabel.vue";
|
import IconLabel from "@/components/widgets/labels/IconLabel.vue";
|
||||||
import { ApplicationPlatform } from "@/components/window/MainWindow.vue";
|
import { ApplicationPlatform } from "@/components/window/MainWindow.vue";
|
||||||
import MenuList, { MenuListEntry, MenuListEntries } from "@/components/widgets/floating-menus/MenuList.vue";
|
import MenuList, { MenuListEntry, MenuListEntries } from "@/components/widgets/floating-menus/MenuList.vue";
|
||||||
import { MenuDirection } from "@/components/widgets/floating-menus/FloatingMenu.vue";
|
import { MenuDirection } from "@/components/widgets/floating-menus/FloatingMenu.vue";
|
||||||
|
import { EditorState } from "@/state/wasm-loader";
|
||||||
|
|
||||||
const wasm = import("@/../wasm/pkg").then(panicProxy);
|
function makeMenuEntries(editor: EditorState): MenuListEntries {
|
||||||
|
return [
|
||||||
const menuEntries: MenuListEntries = [
|
{
|
||||||
{
|
label: "File",
|
||||||
label: "File",
|
ref: undefined,
|
||||||
ref: undefined,
|
children: [
|
||||||
children: [
|
[
|
||||||
[
|
{ label: "New", icon: "File", shortcut: ["Ctrl", "N"], shortcutRequiresLock: true, action: async () => editor.instance.new_document() },
|
||||||
{ label: "New", icon: "File", shortcut: ["Ctrl", "N"], shortcutRequiresLock: true, action: async () => (await wasm).new_document() },
|
{ label: "Open…", shortcut: ["Ctrl", "O"], action: async () => editor.instance.open_document() },
|
||||||
{ label: "Open…", shortcut: ["Ctrl", "O"], action: async () => (await wasm).open_document() },
|
{
|
||||||
{
|
label: "Open Recent",
|
||||||
label: "Open Recent",
|
shortcut: ["Ctrl", "⇧", "O"],
|
||||||
shortcut: ["Ctrl", "⇧", "O"],
|
children: [
|
||||||
children: [
|
[{ label: "Reopen Last Closed", shortcut: ["Ctrl", "⇧", "T"], shortcutRequiresLock: true }, { label: "Clear Recently Opened" }],
|
||||||
[{ label: "Reopen Last Closed", shortcut: ["Ctrl", "⇧", "T"], shortcutRequiresLock: true }, { label: "Clear Recently Opened" }],
|
[
|
||||||
[
|
{ label: "Some Recent File.gdd" },
|
||||||
{ label: "Some Recent File.gdd" },
|
{ label: "Another Recent File.gdd" },
|
||||||
{ label: "Another Recent File.gdd" },
|
{ label: "An Older File.gdd" },
|
||||||
{ label: "An Older File.gdd" },
|
{ label: "Some Other Older File.gdd" },
|
||||||
{ label: "Some Other Older File.gdd" },
|
{ label: "Yet Another Older File.gdd" },
|
||||||
{ label: "Yet Another Older File.gdd" },
|
],
|
||||||
],
|
],
|
||||||
],
|
},
|
||||||
},
|
],
|
||||||
|
[
|
||||||
|
{ label: "Close", shortcut: ["Ctrl", "W"], shortcutRequiresLock: true, action: async () => editor.instance.close_active_document_with_confirmation() },
|
||||||
|
{ label: "Close All", shortcut: ["Ctrl", "Alt", "W"], action: async () => editor.instance.close_all_documents_with_confirmation() },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ label: "Save", shortcut: ["Ctrl", "S"], action: async () => editor.instance.save_document() },
|
||||||
|
{ label: "Save As…", shortcut: ["Ctrl", "⇧", "S"], action: async () => editor.instance.save_document() },
|
||||||
|
{ label: "Save All", shortcut: ["Ctrl", "Alt", "S"] },
|
||||||
|
{ label: "Auto-Save", checkbox: true, checked: true },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ label: "Import…", shortcut: ["Ctrl", "I"] },
|
||||||
|
{ label: "Export…", shortcut: ["Ctrl", "E"], action: async () => editor.instance.export_document() },
|
||||||
|
],
|
||||||
|
[{ label: "Quit", shortcut: ["Ctrl", "Q"] }],
|
||||||
],
|
],
|
||||||
[
|
},
|
||||||
{ label: "Close", shortcut: ["Ctrl", "W"], shortcutRequiresLock: true, action: async () => (await wasm).close_active_document_with_confirmation() },
|
{
|
||||||
{ label: "Close All", shortcut: ["Ctrl", "Alt", "W"], action: async () => (await wasm).close_all_documents_with_confirmation() },
|
label: "Edit",
|
||||||
|
ref: undefined,
|
||||||
|
children: [
|
||||||
|
[
|
||||||
|
{ label: "Undo", shortcut: ["Ctrl", "Z"], action: async () => editor.instance.undo() },
|
||||||
|
{ label: "Redo", shortcut: ["Ctrl", "⇧", "Z"], action: async () => editor.instance.redo() },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ label: "Cut", shortcut: ["Ctrl", "X"] },
|
||||||
|
{ label: "Copy", icon: "Copy", shortcut: ["Ctrl", "C"] },
|
||||||
|
{ label: "Paste", icon: "Paste", shortcut: ["Ctrl", "V"] },
|
||||||
|
],
|
||||||
],
|
],
|
||||||
[
|
},
|
||||||
{ label: "Save", shortcut: ["Ctrl", "S"], action: async () => (await wasm).save_document() },
|
{
|
||||||
{ label: "Save As…", shortcut: ["Ctrl", "⇧", "S"], action: async () => (await wasm).save_document() },
|
label: "Layer",
|
||||||
{ label: "Save All", shortcut: ["Ctrl", "Alt", "S"] },
|
ref: undefined,
|
||||||
{ label: "Auto-Save", checkbox: true, checked: true },
|
children: [
|
||||||
],
|
[
|
||||||
[
|
{ label: "Select All", shortcut: ["Ctrl", "A"], action: async () => editor.instance.select_all_layers() },
|
||||||
{ label: "Import…", shortcut: ["Ctrl", "I"] },
|
{ label: "Deselect All", shortcut: ["Ctrl", "Alt", "A"], action: async () => editor.instance.deselect_all_layers() },
|
||||||
{ label: "Export…", shortcut: ["Ctrl", "E"], action: async () => (await wasm).export_document() },
|
{
|
||||||
],
|
label: "Order",
|
||||||
[{ label: "Quit", shortcut: ["Ctrl", "Q"] }],
|
children: [
|
||||||
],
|
[
|
||||||
},
|
{ label: "Raise To Front", shortcut: ["Ctrl", "Shift", "]"], action: async () => editor.instance.reorder_selected_layers(editor.rawWasm.i32_max()) },
|
||||||
{
|
{ label: "Raise", shortcut: ["Ctrl", "]"], action: async () => editor.instance.reorder_selected_layers(1) },
|
||||||
label: "Edit",
|
{ label: "Lower", shortcut: ["Ctrl", "["], action: async () => editor.instance.reorder_selected_layers(-1) },
|
||||||
ref: undefined,
|
{ label: "Lower to Back", shortcut: ["Ctrl", "Shift", "["], action: async () => editor.instance.reorder_selected_layers(editor.rawWasm.i32_min()) },
|
||||||
children: [
|
],
|
||||||
[
|
|
||||||
{ label: "Undo", shortcut: ["Ctrl", "Z"], action: async () => (await wasm).undo() },
|
|
||||||
{ label: "Redo", shortcut: ["Ctrl", "⇧", "Z"], action: async () => (await wasm).redo() },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{ label: "Cut", shortcut: ["Ctrl", "X"] },
|
|
||||||
{ label: "Copy", icon: "Copy", shortcut: ["Ctrl", "C"] },
|
|
||||||
{ label: "Paste", icon: "Paste", shortcut: ["Ctrl", "V"] },
|
|
||||||
],
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Layer",
|
|
||||||
ref: undefined,
|
|
||||||
children: [
|
|
||||||
[
|
|
||||||
{ label: "Select All", shortcut: ["Ctrl", "A"], action: async () => (await wasm).select_all_layers() },
|
|
||||||
{ label: "Deselect All", shortcut: ["Ctrl", "Alt", "A"], action: async () => (await wasm).deselect_all_layers() },
|
|
||||||
{
|
|
||||||
label: "Order",
|
|
||||||
children: [
|
|
||||||
[
|
|
||||||
{ label: "Raise To Front", shortcut: ["Ctrl", "Shift", "]"], action: async () => (await wasm).reorder_selected_layers((await wasm).i32_max()) },
|
|
||||||
{ label: "Raise", shortcut: ["Ctrl", "]"], action: async () => (await wasm).reorder_selected_layers(1) },
|
|
||||||
{ label: "Lower", shortcut: ["Ctrl", "["], action: async () => (await wasm).reorder_selected_layers(-1) },
|
|
||||||
{ label: "Lower to Back", shortcut: ["Ctrl", "Shift", "["], action: async () => (await wasm).reorder_selected_layers((await wasm).i32_min()) },
|
|
||||||
],
|
],
|
||||||
],
|
},
|
||||||
},
|
],
|
||||||
],
|
],
|
||||||
],
|
},
|
||||||
},
|
{
|
||||||
{
|
label: "Document",
|
||||||
label: "Document",
|
ref: undefined,
|
||||||
ref: undefined,
|
children: [[{ label: "Menu entries coming soon" }]],
|
||||||
children: [[{ label: "Menu entries coming soon" }]],
|
},
|
||||||
},
|
{
|
||||||
{
|
label: "View",
|
||||||
label: "View",
|
ref: undefined,
|
||||||
ref: undefined,
|
children: [[{ label: "Menu entries coming soon" }]],
|
||||||
children: [[{ label: "Menu entries coming soon" }]],
|
},
|
||||||
},
|
{
|
||||||
{
|
label: "Help",
|
||||||
label: "Help",
|
ref: undefined,
|
||||||
ref: undefined,
|
children: [
|
||||||
children: [
|
[{ label: "About Graphite", action: async () => editor.instance.request_about_graphite_dialog() }],
|
||||||
[{ label: "About Graphite", action: async () => (await wasm).request_about_graphite_dialog() }],
|
[
|
||||||
[
|
{ label: "Report a Bug", action: () => window.open("https://github.com/GraphiteEditor/Graphite/issues/new", "_blank") },
|
||||||
{ label: "Report a Bug", action: () => window.open("https://github.com/GraphiteEditor/Graphite/issues/new", "_blank") },
|
{ label: "Visit on GitHub", action: () => window.open("https://github.com/GraphiteEditor/Graphite", "_blank") },
|
||||||
{ label: "Visit on GitHub", action: () => window.open("https://github.com/GraphiteEditor/Graphite", "_blank") },
|
],
|
||||||
|
[{ label: "Debug: Panic (DANGER)", action: async () => editor.rawWasm.intentional_panic() }],
|
||||||
],
|
],
|
||||||
[{ label: "Debug: Panic (DANGER)", action: async () => (await wasm).intentional_panic() }],
|
},
|
||||||
],
|
];
|
||||||
},
|
}
|
||||||
];
|
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
|
inject: ["editor", "dialog"],
|
||||||
methods: {
|
methods: {
|
||||||
setEntryRefs(menuEntry: MenuListEntry, ref: typeof MenuList) {
|
setEntryRefs(menuEntry: MenuListEntry, ref: typeof MenuList) {
|
||||||
if (ref) menuEntry.ref = ref;
|
if (ref) menuEntry.ref = ref;
|
||||||
@@ -180,9 +179,9 @@ export default defineComponent({
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
ApplicationPlatform,
|
ApplicationPlatform,
|
||||||
menuEntries,
|
menuEntries: makeMenuEntries(this.editor),
|
||||||
MenuDirection,
|
MenuDirection,
|
||||||
comingSoon,
|
comingSoon: () => this.dialog.comingSoon(),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
|
|||||||
@@ -69,16 +69,13 @@
|
|||||||
import { defineComponent } from "vue";
|
import { defineComponent } from "vue";
|
||||||
|
|
||||||
import { rgbToDecimalRgb, RGB } from "@/utilities/color";
|
import { rgbToDecimalRgb, RGB } from "@/utilities/color";
|
||||||
import { panicProxy } from "@/utilities/panic-proxy";
|
|
||||||
import { subscribeJsMessage } from "@/utilities/js-message-dispatcher";
|
|
||||||
import { UpdateWorkingColors } from "@/utilities/js-messages";
|
|
||||||
|
|
||||||
import ColorPicker from "@/components/widgets/floating-menus/ColorPicker.vue";
|
import ColorPicker from "@/components/widgets/floating-menus/ColorPicker.vue";
|
||||||
import FloatingMenu, { MenuDirection, MenuType } from "@/components/widgets/floating-menus/FloatingMenu.vue";
|
import FloatingMenu, { MenuDirection, MenuType } from "@/components/widgets/floating-menus/FloatingMenu.vue";
|
||||||
|
import { UpdateWorkingColors } from "@/dispatcher/js-messages";
|
||||||
const wasm = import("@/../wasm/pkg").then(panicProxy);
|
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
|
inject: ["editor"],
|
||||||
components: {
|
components: {
|
||||||
FloatingMenu,
|
FloatingMenu,
|
||||||
ColorPicker,
|
ColorPicker,
|
||||||
@@ -115,7 +112,7 @@ export default defineComponent({
|
|||||||
button.style.setProperty("--swatch-color", `rgba(${color.r}, ${color.g}, ${color.b}, ${color.a})`);
|
button.style.setProperty("--swatch-color", `rgba(${color.r}, ${color.g}, ${color.b}, ${color.a})`);
|
||||||
|
|
||||||
color = rgbToDecimalRgb(this.primaryColor);
|
color = rgbToDecimalRgb(this.primaryColor);
|
||||||
(await wasm).update_primary_color(color.r, color.g, color.b, color.a);
|
this.editor.instance.update_primary_color(color.r, color.g, color.b, color.a);
|
||||||
},
|
},
|
||||||
|
|
||||||
async updateSecondaryColor() {
|
async updateSecondaryColor() {
|
||||||
@@ -124,7 +121,7 @@ export default defineComponent({
|
|||||||
button.style.setProperty("--swatch-color", `rgba(${color.r}, ${color.g}, ${color.b}, ${color.a})`);
|
button.style.setProperty("--swatch-color", `rgba(${color.r}, ${color.g}, ${color.b}, ${color.a})`);
|
||||||
|
|
||||||
color = rgbToDecimalRgb(this.secondaryColor);
|
color = rgbToDecimalRgb(this.secondaryColor);
|
||||||
(await wasm).update_secondary_color(color.r, color.g, color.b, color.a);
|
this.editor.instance.update_secondary_color(color.r, color.g, color.b, color.a);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
@@ -136,7 +133,7 @@ export default defineComponent({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
subscribeJsMessage(UpdateWorkingColors, (updateWorkingColors) => {
|
this.editor.dispatcher.subscribeJsMessage(UpdateWorkingColors, (updateWorkingColors) => {
|
||||||
const { primary, secondary } = updateWorkingColors;
|
const { primary, secondary } = updateWorkingColors;
|
||||||
|
|
||||||
this.primaryColor = primary.toRgba();
|
this.primaryColor = primary.toRgba();
|
||||||
|
|||||||
@@ -31,8 +31,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, PropType } from "vue";
|
import { defineComponent, PropType } from "vue";
|
||||||
|
|
||||||
import { comingSoon } from "@/utilities/errors";
|
|
||||||
import { panicProxy } from "@/utilities/panic-proxy";
|
|
||||||
import { WidgetRow, SeparatorType, IconButtonWidget } from "@/components/widgets/widgets";
|
import { WidgetRow, SeparatorType, IconButtonWidget } from "@/components/widgets/widgets";
|
||||||
|
|
||||||
import Separator from "@/components/widgets/separators/Separator.vue";
|
import Separator from "@/components/widgets/separators/Separator.vue";
|
||||||
@@ -40,9 +38,8 @@ import IconButton from "@/components/widgets/buttons/IconButton.vue";
|
|||||||
import PopoverButton from "@/components/widgets/buttons/PopoverButton.vue";
|
import PopoverButton from "@/components/widgets/buttons/PopoverButton.vue";
|
||||||
import NumberInput from "@/components/widgets/inputs/NumberInput.vue";
|
import NumberInput from "@/components/widgets/inputs/NumberInput.vue";
|
||||||
|
|
||||||
const wasm = import("@/../wasm/pkg").then(panicProxy);
|
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
|
inject: ["editor", "dialog"],
|
||||||
props: {
|
props: {
|
||||||
activeTool: { type: String },
|
activeTool: { type: String },
|
||||||
activeToolOptions: { type: Object as PropType<Record<string, object>> },
|
activeToolOptions: { type: Object as PropType<Record<string, object>> },
|
||||||
@@ -50,10 +47,10 @@ export default defineComponent({
|
|||||||
methods: {
|
methods: {
|
||||||
async updateToolOptions(path: string[], newValue: number) {
|
async updateToolOptions(path: string[], newValue: number) {
|
||||||
this.setToolOption(path, newValue);
|
this.setToolOption(path, newValue);
|
||||||
(await wasm).set_tool_options(this.activeTool || "", this.activeToolOptions);
|
this.editor.instance.set_tool_options(this.activeTool || "", this.activeToolOptions);
|
||||||
},
|
},
|
||||||
async sendToolMessage(message: string | object) {
|
async sendToolMessage(message: string | object) {
|
||||||
(await wasm).send_tool_message(this.activeTool || "", message);
|
this.editor.instance.send_tool_message(this.activeTool || "", message);
|
||||||
},
|
},
|
||||||
// Traverses the given path and returns the direct parent of the option
|
// Traverses the given path and returns the direct parent of the option
|
||||||
getRecordContainingOption(optionPath: string[]): Record<string, number> {
|
getRecordContainingOption(optionPath: string[]): Record<string, number> {
|
||||||
@@ -87,7 +84,7 @@ export default defineComponent({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
comingSoon();
|
this.dialog.comingSoon();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
@@ -132,11 +129,11 @@ export default defineComponent({
|
|||||||
|
|
||||||
{ kind: "Separator", props: { type: SeparatorType.Section } },
|
{ kind: "Separator", props: { type: SeparatorType.Section } },
|
||||||
|
|
||||||
{ kind: "IconButton", tooltip: "Boolean Union", callback: () => comingSoon(197), props: { icon: "BooleanUnion", size: 24 } },
|
{ kind: "IconButton", tooltip: "Boolean Union", callback: () => this.dialog.comingSoon(197), props: { icon: "BooleanUnion", size: 24 } },
|
||||||
{ kind: "IconButton", tooltip: "Boolean Subtract Front", callback: () => comingSoon(197), props: { icon: "BooleanSubtractFront", size: 24 } },
|
{ kind: "IconButton", tooltip: "Boolean Subtract Front", callback: () => this.dialog.comingSoon(197), props: { icon: "BooleanSubtractFront", size: 24 } },
|
||||||
{ kind: "IconButton", tooltip: "Boolean Subtract Back", callback: () => comingSoon(197), props: { icon: "BooleanSubtractBack", size: 24 } },
|
{ kind: "IconButton", tooltip: "Boolean Subtract Back", callback: () => this.dialog.comingSoon(197), props: { icon: "BooleanSubtractBack", size: 24 } },
|
||||||
{ kind: "IconButton", tooltip: "Boolean Intersect", callback: () => comingSoon(197), props: { icon: "BooleanIntersect", size: 24 } },
|
{ kind: "IconButton", tooltip: "Boolean Intersect", callback: () => this.dialog.comingSoon(197), props: { icon: "BooleanIntersect", size: 24 } },
|
||||||
{ kind: "IconButton", tooltip: "Boolean Difference", callback: () => comingSoon(197), props: { icon: "BooleanDifference", size: 24 } },
|
{ kind: "IconButton", tooltip: "Boolean Difference", callback: () => this.dialog.comingSoon(197), props: { icon: "BooleanDifference", size: 24 } },
|
||||||
|
|
||||||
{ kind: "Separator", props: { type: SeparatorType.Related } },
|
{ kind: "Separator", props: { type: SeparatorType.Related } },
|
||||||
|
|
||||||
@@ -157,7 +154,6 @@ export default defineComponent({
|
|||||||
return {
|
return {
|
||||||
toolOptionsWidgets,
|
toolOptionsWidgets,
|
||||||
SeparatorType,
|
SeparatorType,
|
||||||
comingSoon,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<MenuBarInput v-if="platform !== ApplicationPlatform.Mac" />
|
<MenuBarInput v-if="platform !== ApplicationPlatform.Mac" />
|
||||||
</div>
|
</div>
|
||||||
<div class="header-third">
|
<div class="header-third">
|
||||||
<WindowTitle :title="`${documents.documents[documents.activeDocumentIndex].displayName} - Graphite`" />
|
<WindowTitle :title="`${activeDocumentDisplayName} - Graphite`" />
|
||||||
</div>
|
</div>
|
||||||
<div class="header-third">
|
<div class="header-third">
|
||||||
<WindowButtonsWindows :maximized="maximized" v-if="platform === ApplicationPlatform.Windows || platform === ApplicationPlatform.Linux" />
|
<WindowButtonsWindows :maximized="maximized" v-if="platform === ApplicationPlatform.Windows || platform === ApplicationPlatform.Linux" />
|
||||||
@@ -52,6 +52,11 @@ export default defineComponent({
|
|||||||
ApplicationPlatform,
|
ApplicationPlatform,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
computed: {
|
||||||
|
activeDocumentDisplayName() {
|
||||||
|
return this.documents.state.documents[this.documents.state.activeDocumentIndex].displayName;
|
||||||
|
},
|
||||||
|
},
|
||||||
components: {
|
components: {
|
||||||
MenuBarInput,
|
MenuBarInput,
|
||||||
WindowTitle,
|
WindowTitle,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="window-buttons-web" @click="handleClick" :title="fullscreen.windowFullscreen ? 'Exit Fullscreen (F11)' : 'Enter Fullscreen (F11)'">
|
<div class="window-buttons-web" @click="handleClick" :title="fullscreen.state.windowFullscreen ? 'Exit Fullscreen (F11)' : 'Enter Fullscreen (F11)'">
|
||||||
<TextLabel v-if="requestFullscreenHotkeys" :italic="true">Go fullscreen to access all hotkeys</TextLabel>
|
<TextLabel v-if="requestFullscreenHotkeys" :italic="true">Go fullscreen to access all hotkeys</TextLabel>
|
||||||
<IconLabel :icon="fullscreen.windowFullscreen ? 'FullscreenExit' : 'FullscreenEnter'" />
|
<IconLabel :icon="fullscreen.state.windowFullscreen ? 'FullscreenExit' : 'FullscreenEnter'" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -33,24 +33,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent } from "vue";
|
import { defineComponent } from "vue";
|
||||||
|
|
||||||
import fullscreen, { keyboardLockApiSupported, enterFullscreen, exitFullscreen } from "@/utilities/fullscreen";
|
|
||||||
|
|
||||||
import IconLabel from "@/components/widgets/labels/IconLabel.vue";
|
import IconLabel from "@/components/widgets/labels/IconLabel.vue";
|
||||||
import TextLabel from "@/components/widgets/labels/TextLabel.vue";
|
import TextLabel from "@/components/widgets/labels/TextLabel.vue";
|
||||||
|
|
||||||
const canUseKeyboardLock = keyboardLockApiSupported();
|
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
inject: ["fullscreen"],
|
inject: ["fullscreen"],
|
||||||
methods: {
|
methods: {
|
||||||
async handleClick() {
|
async handleClick() {
|
||||||
if (fullscreen.windowFullscreen) exitFullscreen();
|
if (this.fullscreen.state.windowFullscreen) this.fullscreen.exitFullscreen();
|
||||||
else enterFullscreen();
|
else this.fullscreen.enterFullscreen();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
requestFullscreenHotkeys() {
|
requestFullscreenHotkeys() {
|
||||||
return canUseKeyboardLock && !fullscreen.keyboardLocked;
|
return this.fullscreen.keyboardLockApiSupported && !this.fullscreen.state.keyboardLocked;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
|
|||||||
@@ -10,17 +10,17 @@
|
|||||||
@click.middle="
|
@click.middle="
|
||||||
(e) => {
|
(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
closeDocumentWithConfirmation(tabIndex);
|
documents.closeDocumentWithConfirmation(tabIndex);
|
||||||
}
|
}
|
||||||
"
|
"
|
||||||
@click="panelType === 'Document' && selectDocument(tabIndex)"
|
@click="panelType === 'Document' && documents.selectDocument(tabIndex)"
|
||||||
>
|
>
|
||||||
<span>{{ tabLabel }}</span>
|
<span>{{ tabLabel }}</span>
|
||||||
<IconButton
|
<IconButton
|
||||||
:action="
|
:action="
|
||||||
(e) => {
|
(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
closeDocumentWithConfirmation(tabIndex);
|
documents.closeDocumentWithConfirmation(tabIndex);
|
||||||
}
|
}
|
||||||
"
|
"
|
||||||
:icon="'CloseX'"
|
:icon="'CloseX'"
|
||||||
@@ -169,8 +169,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, PropType } from "vue";
|
import { defineComponent, PropType } from "vue";
|
||||||
|
|
||||||
import { selectDocument, closeDocumentWithConfirmation } from "@/utilities/documents";
|
|
||||||
|
|
||||||
import Document from "@/components/panels/Document.vue";
|
import Document from "@/components/panels/Document.vue";
|
||||||
import Properties from "@/components/panels/Properties.vue";
|
import Properties from "@/components/panels/Properties.vue";
|
||||||
import LayerTree from "@/components/panels/LayerTree.vue";
|
import LayerTree from "@/components/panels/LayerTree.vue";
|
||||||
@@ -180,6 +178,7 @@ import PopoverButton, { PopoverButtonIcon } from "@/components/widgets/buttons/P
|
|||||||
import { MenuDirection } from "@/components/widgets/floating-menus/FloatingMenu.vue";
|
import { MenuDirection } from "@/components/widgets/floating-menus/FloatingMenu.vue";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
|
inject: ["documents"],
|
||||||
components: {
|
components: {
|
||||||
Document,
|
Document,
|
||||||
Properties,
|
Properties,
|
||||||
@@ -197,8 +196,6 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
selectDocument,
|
|
||||||
closeDocumentWithConfirmation,
|
|
||||||
PopoverButtonIcon,
|
PopoverButtonIcon,
|
||||||
MenuDirection,
|
MenuDirection,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
:panelType="'Document'"
|
:panelType="'Document'"
|
||||||
:tabCloseButtons="true"
|
:tabCloseButtons="true"
|
||||||
:tabMinWidths="true"
|
:tabMinWidths="true"
|
||||||
:tabLabels="documents.documents.map((doc) => doc.displayName)"
|
:tabLabels="documents.state.documents.map((doc) => doc.displayName)"
|
||||||
:tabActiveIndex="documents.activeDocumentIndex"
|
:tabActiveIndex="documents.state.activeDocumentIndex"
|
||||||
ref="documentsPanel"
|
ref="documentsPanel"
|
||||||
/>
|
/>
|
||||||
</LayoutCol>
|
</LayoutCol>
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
</LayoutRow> -->
|
</LayoutRow> -->
|
||||||
</LayoutCol>
|
</LayoutCol>
|
||||||
</LayoutRow>
|
</LayoutRow>
|
||||||
<DialogModal v-if="dialog.visible" />
|
<DialogModal v-if="dialog.state.visible" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
@@ -55,8 +55,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent } from "vue";
|
import { defineComponent } from "vue";
|
||||||
|
|
||||||
import "@/utilities/dialogs";
|
|
||||||
|
|
||||||
import Panel from "@/components/workspace/Panel.vue";
|
import Panel from "@/components/workspace/Panel.vue";
|
||||||
import LayoutRow from "@/components/layout/LayoutRow.vue";
|
import LayoutRow from "@/components/layout/LayoutRow.vue";
|
||||||
import LayoutCol from "@/components/layout/LayoutCol.vue";
|
import LayoutCol from "@/components/layout/LayoutCol.vue";
|
||||||
@@ -75,8 +73,7 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
activeDocumentIndex() {
|
activeDocumentIndex() {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
return this.documents.state.activeDocumentIndex;
|
||||||
return (this as any).documents.activeDocumentIndex;
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { plainToInstance } from "class-transformer";
|
||||||
|
import { JsMessageType, messageConstructors, JsMessage } from "@/dispatcher/js-messages";
|
||||||
|
import type { RustEditorInstance, WasmInstance } from "@/state/wasm-loader";
|
||||||
|
|
||||||
|
type JsMessageCallback<T extends JsMessage> = (messageData: T) => void;
|
||||||
|
type JsMessageCallbackMap = {
|
||||||
|
// Don't know a better way of typing this since it can be any subclass of JsMessage
|
||||||
|
// The functions interacting with this map are strongly typed though around JsMessage
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
[message: string]: JsMessageCallback<any> | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createJsDispatcher() {
|
||||||
|
const subscriptions: JsMessageCallbackMap = {};
|
||||||
|
|
||||||
|
const subscribeJsMessage = <T extends JsMessage, Args extends unknown[]>(messageType: new (...args: Args) => T, callback: JsMessageCallback<T>) => {
|
||||||
|
subscriptions[messageType.name] = callback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleJsMessage = (messageType: JsMessageType, messageData: Record<string, unknown>, wasm: WasmInstance, instance: RustEditorInstance) => {
|
||||||
|
const messageConstructor = messageConstructors[messageType];
|
||||||
|
if (!messageConstructor) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(`Received a frontend message of type "${messageType}" but but was not able to parse the data.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Messages with non-empty data are provided by wasm-bindgen as an object with one key as the message name, like: { NameOfThisMessage: { ... } }
|
||||||
|
// Messages with empty data are provided by wasm-bindgen as a string with the message name, like: "NameOfThisMessage"
|
||||||
|
const unwrappedMessageData = messageData[messageType] || {};
|
||||||
|
|
||||||
|
const isJsMessageConstructor = (fn: typeof messageConstructor): fn is typeof JsMessage => {
|
||||||
|
return "jsMessageMarker" in fn;
|
||||||
|
};
|
||||||
|
let message: JsMessage;
|
||||||
|
if (isJsMessageConstructor(messageConstructor)) {
|
||||||
|
message = plainToInstance(messageConstructor, unwrappedMessageData);
|
||||||
|
} else {
|
||||||
|
message = messageConstructor(unwrappedMessageData, wasm, instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
// It is ok to use constructor.name even with minification since it is used consistently with registerHandler
|
||||||
|
const callback = subscriptions[message.constructor.name];
|
||||||
|
|
||||||
|
if (callback && message) {
|
||||||
|
callback(message);
|
||||||
|
} else if (message) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(`Received a frontend message of type "${messageType}" but no handler was registered for it from the client.`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribeJsMessage,
|
||||||
|
handleJsMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export type JsDispatcher = ReturnType<typeof createJsDispatcher>;
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
||||||
/* eslint-disable camelcase */
|
/* eslint-disable camelcase */
|
||||||
/* eslint-disable max-classes-per-file */
|
/* eslint-disable max-classes-per-file */
|
||||||
|
|
||||||
import { Transform, Type } from "class-transformer";
|
import { Transform, Type } from "class-transformer";
|
||||||
|
|
||||||
|
import type { RustEditorInstance, WasmInstance } from "@/state/wasm-loader";
|
||||||
|
|
||||||
export class JsMessage {
|
export class JsMessage {
|
||||||
// The marker provides a way to check if an object is a sub-class constructor for a jsMessage.
|
// The marker provides a way to check if an object is a sub-class constructor for a jsMessage.
|
||||||
static readonly jsMessageMarker = true;
|
static readonly jsMessageMarker = true;
|
||||||
@@ -124,19 +125,25 @@ export class DisplayFolderTreeStructure extends JsMessage {
|
|||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export function newDisplayFolderTreeStructure(input: any): DisplayFolderTreeStructure {
|
|
||||||
const { ptr, len } = input.data_buffer;
|
interface DataBuffer {
|
||||||
const wasmMemoryBuffer = (window as any).wasmMemory().buffer;
|
pointer: number;
|
||||||
|
length: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function newDisplayFolderTreeStructure(input: { data_buffer: DataBuffer }, wasm: WasmInstance): DisplayFolderTreeStructure {
|
||||||
|
const { pointer, length } = input.data_buffer;
|
||||||
|
const wasmMemoryBuffer = wasm.wasm_memory().buffer;
|
||||||
|
|
||||||
// Decode the folder structure encoding
|
// Decode the folder structure encoding
|
||||||
const encoding = new DataView(wasmMemoryBuffer, ptr, len);
|
const encoding = new DataView(wasmMemoryBuffer, pointer, length);
|
||||||
|
|
||||||
// The structure section indicates how to read through the upcoming layer list and assign depths to each layer
|
// The structure section indicates how to read through the upcoming layer list and assign depths to each layer
|
||||||
const structureSectionLength = Number(encoding.getBigUint64(0, true));
|
const structureSectionLength = Number(encoding.getBigUint64(0, true));
|
||||||
const structureSectionMsbSigned = new DataView(wasmMemoryBuffer, ptr + 8, structureSectionLength * 8);
|
const structureSectionMsbSigned = new DataView(wasmMemoryBuffer, pointer + 8, structureSectionLength * 8);
|
||||||
|
|
||||||
// The layer IDs section lists each layer ID sequentially in the tree, as it will show up in the panel
|
// The layer IDs section lists each layer ID sequentially in the tree, as it will show up in the panel
|
||||||
const layerIdsSection = new DataView(wasmMemoryBuffer, ptr + 8 + structureSectionLength * 8);
|
const layerIdsSection = new DataView(wasmMemoryBuffer, pointer + 8 + structureSectionLength * 8);
|
||||||
|
|
||||||
let layersEncountered = 0;
|
let layersEncountered = 0;
|
||||||
let currentFolder = new DisplayFolderTreeStructure(BigInt(-1), []);
|
let currentFolder = new DisplayFolderTreeStructure(BigInt(-1), []);
|
||||||
@@ -189,7 +196,7 @@ export class SetCanvasRotation extends JsMessage {
|
|||||||
readonly new_radians!: number;
|
readonly new_radians!: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function newPath(input: any): BigUint64Array {
|
function newPath(input: number[][]): BigUint64Array {
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
const u32CombinedPairs = input.map((n: number[]) => BigInt((BigInt(n[0]) << BigInt(32)) | BigInt(n[1])));
|
const u32CombinedPairs = input.map((n: number[]) => BigInt((BigInt(n[0]) << BigInt(32)) | BigInt(n[1])));
|
||||||
return new BigUint64Array(u32CombinedPairs);
|
return new BigUint64Array(u32CombinedPairs);
|
||||||
@@ -252,3 +259,31 @@ export const LayerTypeOptions = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type LayerType = typeof LayerTypeOptions[keyof typeof LayerTypeOptions];
|
export type LayerType = typeof LayerTypeOptions[keyof typeof LayerTypeOptions];
|
||||||
|
|
||||||
|
// 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 MessageMaker = typeof JsMessage | JSMessageFactory;
|
||||||
|
|
||||||
|
export const messageConstructors: Record<string, MessageMaker> = {
|
||||||
|
UpdateCanvas,
|
||||||
|
UpdateScrollbars,
|
||||||
|
UpdateRulers,
|
||||||
|
ExportDocument,
|
||||||
|
SaveDocument,
|
||||||
|
OpenDocumentBrowse,
|
||||||
|
DisplayFolderTreeStructure: newDisplayFolderTreeStructure,
|
||||||
|
UpdateLayer,
|
||||||
|
SetActiveTool,
|
||||||
|
SetActiveDocument,
|
||||||
|
UpdateOpenDocumentsList,
|
||||||
|
UpdateWorkingColors,
|
||||||
|
SetCanvasZoom,
|
||||||
|
SetCanvasRotation,
|
||||||
|
DisplayError,
|
||||||
|
DisplayPanic,
|
||||||
|
DisplayConfirmationToCloseDocument,
|
||||||
|
DisplayConfirmationToCloseAllDocuments,
|
||||||
|
DisplayAboutGraphiteDialog,
|
||||||
|
} as const;
|
||||||
|
export type JsMessageType = keyof typeof messageConstructors;
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { DialogState } from "@/state/dialog";
|
||||||
|
import { TextButtonWidget } from "@/components/widgets/widgets";
|
||||||
|
import { DisplayError, DisplayPanic } from "@/dispatcher/js-messages";
|
||||||
|
import { EditorState } from "@/state/wasm-loader";
|
||||||
|
import { stripIndents } from "@/utilities/strip-indents";
|
||||||
|
|
||||||
|
export function initErrorHandling(editor: EditorState, dialogState: DialogState) {
|
||||||
|
// Graphite error dialog
|
||||||
|
editor.dispatcher.subscribeJsMessage(DisplayError, (displayError) => {
|
||||||
|
const okButton: TextButtonWidget = {
|
||||||
|
kind: "TextButton",
|
||||||
|
callback: async () => dialogState.dismissDialog(),
|
||||||
|
props: { label: "OK", emphasized: true, minWidth: 96 },
|
||||||
|
};
|
||||||
|
const buttons = [okButton];
|
||||||
|
|
||||||
|
dialogState.createDialog("Warning", displayError.title, displayError.description, buttons);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Code panic dialog and console error
|
||||||
|
editor.dispatcher.subscribeJsMessage(DisplayPanic, (displayPanic) => {
|
||||||
|
// `Error.stackTraceLimit` is only available in V8/Chromium
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(Error as any).stackTraceLimit = Infinity;
|
||||||
|
const stackTrace = new Error().stack || "";
|
||||||
|
const panicDetails = `${displayPanic.panic_info}\n\n${stackTrace}`;
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(panicDetails);
|
||||||
|
|
||||||
|
const reloadButton: TextButtonWidget = {
|
||||||
|
kind: "TextButton",
|
||||||
|
callback: async () => window.location.reload(),
|
||||||
|
props: { label: "Reload", emphasized: true, minWidth: 96 },
|
||||||
|
};
|
||||||
|
const copyErrorLogButton: TextButtonWidget = {
|
||||||
|
kind: "TextButton",
|
||||||
|
callback: async () => navigator.clipboard.writeText(panicDetails),
|
||||||
|
props: { label: "Copy Error Log", emphasized: false, minWidth: 96 },
|
||||||
|
};
|
||||||
|
const reportOnGithubButton: TextButtonWidget = {
|
||||||
|
kind: "TextButton",
|
||||||
|
callback: async () => window.open(githubUrl(panicDetails), "_blank"),
|
||||||
|
props: { label: "Report Bug", emphasized: false, minWidth: 96 },
|
||||||
|
};
|
||||||
|
const buttons = [reloadButton, copyErrorLogButton, reportOnGithubButton];
|
||||||
|
|
||||||
|
dialogState.createDialog("Warning", displayPanic.title, displayPanic.description, buttons);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function githubUrl(panicDetails: string) {
|
||||||
|
const url = new URL("https://github.com/GraphiteEditor/Graphite/issues/new");
|
||||||
|
|
||||||
|
const body = stripIndents`
|
||||||
|
**Describe the Crash**
|
||||||
|
Explain clearly what you were doing when the crash occurred.
|
||||||
|
|
||||||
|
**Steps To Reproduce**
|
||||||
|
Describe precisely how the crash occurred, step by step, starting with a new editor window.
|
||||||
|
1. Open the Graphite Editor at https://editor.graphite.design
|
||||||
|
2.
|
||||||
|
3.
|
||||||
|
4.
|
||||||
|
5.
|
||||||
|
|
||||||
|
**Additional Details**
|
||||||
|
Provide any further information or context that you think would be helpful in fixing the issue. Screenshots or video can be linked or attached to this issue.
|
||||||
|
|
||||||
|
**Browser and OS**
|
||||||
|
${browserVersion()}, ${operatingSystem()}
|
||||||
|
|
||||||
|
**Stack Trace**
|
||||||
|
Copied from the crash dialog in the Graphite Editor:
|
||||||
|
|
||||||
|
\`\`\`
|
||||||
|
${panicDetails}
|
||||||
|
\`\`\`
|
||||||
|
`;
|
||||||
|
|
||||||
|
const fields = {
|
||||||
|
title: "[Crash Report] ",
|
||||||
|
body,
|
||||||
|
labels: ["Crash"].join(","),
|
||||||
|
projects: [].join(","),
|
||||||
|
milestone: "",
|
||||||
|
assignee: "",
|
||||||
|
template: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.entries(fields).forEach(([field, value]) => {
|
||||||
|
if (value) url.searchParams.set(field, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function browserVersion(): string {
|
||||||
|
const agent = window.navigator.userAgent;
|
||||||
|
let match = agent.match(/(opera|chrome|safari|firefox|msie|trident(?=\/))\/?\s*(\d+)/i) || [];
|
||||||
|
|
||||||
|
if (/trident/i.test(match[1])) {
|
||||||
|
const browser = /\brv[ :]+(\d+)/g.exec(agent) || [];
|
||||||
|
return `IE ${browser[1] || ""}`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match[1] === "Chrome") {
|
||||||
|
let browser = agent.match(/\bEdg\/(\d+)/);
|
||||||
|
if (browser !== null) return `Edge (Chromium) ${browser[1]}`;
|
||||||
|
|
||||||
|
browser = agent.match(/\bOPR\/(\d+)/);
|
||||||
|
if (browser !== null) return `Opera ${browser[1]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
match = match[2] ? [match[1], match[2]] : [navigator.appName, navigator.appVersion, "-?"];
|
||||||
|
|
||||||
|
const browser = agent.match(/version\/(\d+)/i);
|
||||||
|
if (browser !== null) match.splice(1, 1, browser[1]);
|
||||||
|
|
||||||
|
return `${match[0]} ${match[1]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function operatingSystem(): string {
|
||||||
|
const osTable: Record<string, string> = {
|
||||||
|
"Windows NT 10": "Windows 10 or 11",
|
||||||
|
"Windows NT 6.3": "Windows 8.1",
|
||||||
|
"Windows NT 6.2": "Windows 8",
|
||||||
|
"Windows NT 6.1": "Windows 7",
|
||||||
|
"Windows NT 6.0": "Windows Vista",
|
||||||
|
"Windows NT 5.1": "Windows XP",
|
||||||
|
"Windows NT 5.0": "Windows 2000",
|
||||||
|
Mac: "Mac",
|
||||||
|
X11: "Unix",
|
||||||
|
Linux: "Linux",
|
||||||
|
Unknown: "YOUR OPERATING SYSTEM",
|
||||||
|
};
|
||||||
|
|
||||||
|
const userAgentOS = Object.keys(osTable).find((key) => window.navigator.userAgent.includes(key));
|
||||||
|
return osTable[userAgentOS || "Unknown"];
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { DialogState } from "@/state/dialog";
|
||||||
|
import { FullscreenState } from "@/state/fullscreen";
|
||||||
|
import { DocumentsState } from "@/state/documents";
|
||||||
|
import { EditorState } from "@/state/wasm-loader";
|
||||||
|
|
||||||
|
type EventName = keyof HTMLElementEventMap | keyof WindowEventHandlersEventMap;
|
||||||
|
interface EventListenerTarget {
|
||||||
|
addEventListener: typeof window.addEventListener;
|
||||||
|
removeEventListener: typeof window.removeEventListener;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createInputManager(editor: EditorState, container: HTMLElement, dialog: DialogState, document: DocumentsState, fullscreen: FullscreenState) {
|
||||||
|
// 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: () => onWindowResize(container) },
|
||||||
|
{ target: window, eventName: "beforeunload", action: (e) => onBeforeUnload(e) },
|
||||||
|
{ target: window.document, eventName: "contextmenu", action: (e) => e.preventDefault() },
|
||||||
|
{ target: window.document, eventName: "fullscreenchange", action: () => fullscreen.fullscreenModeChanged() },
|
||||||
|
{ target: window, eventName: "keyup", action: (e) => onKeyUp(e) },
|
||||||
|
{ target: window, eventName: "keydown", action: (e) => onKeyDown(e) },
|
||||||
|
{ target: window, eventName: "mousemove", action: (e) => onMouseMove(e) },
|
||||||
|
{ target: window, eventName: "mousedown", action: (e) => onMouseDown(e) },
|
||||||
|
{ target: window, eventName: "mouseup", action: (e) => onMouseUp(e) },
|
||||||
|
{ target: window, eventName: "wheel", action: (e) => onMouseScroll(e), options: { passive: false } },
|
||||||
|
];
|
||||||
|
|
||||||
|
let viewportMouseInteractionOngoing = false;
|
||||||
|
|
||||||
|
const shouldRedirectKeyboardEventToBackend = (e: KeyboardEvent): boolean => {
|
||||||
|
// Don't redirect user input from text entry into HTML elements
|
||||||
|
const { target } = e;
|
||||||
|
if (target instanceof HTMLElement && (target.nodeName === "INPUT" || target.nodeName === "TEXTAREA" || target.isContentEditable)) return false;
|
||||||
|
|
||||||
|
// Don't redirect when a modal is covering the workspace
|
||||||
|
if (dialog.dialogIsVisible()) return false;
|
||||||
|
|
||||||
|
// Don't redirect a fullscreen request
|
||||||
|
if (e.key.toLowerCase() === "f11" && e.type === "keydown" && !e.repeat) {
|
||||||
|
e.preventDefault();
|
||||||
|
fullscreen.toggleFullscreen();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't redirect a reload request
|
||||||
|
if (e.key.toLowerCase() === "f5") return false;
|
||||||
|
|
||||||
|
// Don't redirect debugging tools
|
||||||
|
if (e.key.toLowerCase() === "f12") return false;
|
||||||
|
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === "c") return false;
|
||||||
|
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === "i") return false;
|
||||||
|
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === "j") return false;
|
||||||
|
|
||||||
|
// Redirect to the backend
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (shouldRedirectKeyboardEventToBackend(e)) {
|
||||||
|
e.preventDefault();
|
||||||
|
const modifiers = makeModifiersBitfield(e);
|
||||||
|
editor.instance.on_key_down(e.key, modifiers);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dialog.dialogIsVisible()) {
|
||||||
|
if (e.key === "Escape") dialog.dismissDialog();
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
dialog.submitDialog();
|
||||||
|
|
||||||
|
// Prevent the Enter key from acting like a click on the last clicked button, which might reopen the dialog
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeyUp = (e: KeyboardEvent) => {
|
||||||
|
if (shouldRedirectKeyboardEventToBackend(e)) {
|
||||||
|
e.preventDefault();
|
||||||
|
const modifiers = makeModifiersBitfield(e);
|
||||||
|
editor.instance.on_key_up(e.key, modifiers);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMouseMove = (e: MouseEvent) => {
|
||||||
|
if (!e.buttons) viewportMouseInteractionOngoing = false;
|
||||||
|
|
||||||
|
const modifiers = makeModifiersBitfield(e);
|
||||||
|
editor.instance.on_mouse_move(e.clientX, e.clientY, e.buttons, modifiers);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMouseDown = (e: MouseEvent) => {
|
||||||
|
const { target } = e;
|
||||||
|
const inCanvas = target instanceof Element && target.closest(".canvas");
|
||||||
|
const inDialog = target instanceof Element && target.closest(".dialog-modal .floating-menu-content");
|
||||||
|
|
||||||
|
// Block middle mouse button auto-scroll mode
|
||||||
|
if (e.button === 1) e.preventDefault();
|
||||||
|
|
||||||
|
if (dialog.dialogIsVisible() && !inDialog) {
|
||||||
|
dialog.dismissDialog();
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inCanvas) viewportMouseInteractionOngoing = true;
|
||||||
|
|
||||||
|
if (viewportMouseInteractionOngoing) {
|
||||||
|
const modifiers = makeModifiersBitfield(e);
|
||||||
|
editor.instance.on_mouse_down(e.clientX, e.clientY, e.buttons, modifiers);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMouseUp = (e: MouseEvent) => {
|
||||||
|
if (!e.buttons) viewportMouseInteractionOngoing = false;
|
||||||
|
|
||||||
|
const modifiers = makeModifiersBitfield(e);
|
||||||
|
editor.instance.on_mouse_up(e.clientX, e.clientY, e.buttons, modifiers);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMouseScroll = (e: WheelEvent) => {
|
||||||
|
const { target } = e;
|
||||||
|
const inCanvas = target instanceof Element && target.closest(".canvas");
|
||||||
|
|
||||||
|
const horizontalScrollableElement = target instanceof Element && target.closest(".scrollable-x");
|
||||||
|
if (horizontalScrollableElement && e.deltaY !== 0) {
|
||||||
|
horizontalScrollableElement.scrollTo(horizontalScrollableElement.scrollLeft + e.deltaY, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inCanvas) {
|
||||||
|
e.preventDefault();
|
||||||
|
const modifiers = makeModifiersBitfield(e);
|
||||||
|
editor.instance.on_mouse_scroll(e.clientX, e.clientY, e.buttons, e.deltaX, e.deltaY, e.deltaZ, modifiers);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onWindowResize = (container: HTMLElement) => {
|
||||||
|
const viewports = Array.from(container.querySelectorAll(".canvas"));
|
||||||
|
const boundsOfViewports = viewports.map((canvas) => {
|
||||||
|
const bounds = canvas.getBoundingClientRect();
|
||||||
|
return [bounds.left, bounds.top, bounds.right, bounds.bottom];
|
||||||
|
});
|
||||||
|
|
||||||
|
const flattened = boundsOfViewports.flat();
|
||||||
|
const data = Float64Array.from(flattened);
|
||||||
|
|
||||||
|
if (boundsOfViewports.length > 0) editor.instance.bounds_of_viewports(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||||
|
const allDocumentsSaved = document.state.documents.reduce((acc, doc) => acc && doc.isSaved, true);
|
||||||
|
if (!allDocumentsSaved) {
|
||||||
|
e.returnValue = "Unsaved work will be lost if the web browser tab is closed. Close anyway?";
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addListeners = () => {
|
||||||
|
listeners.forEach(({ target, eventName, action, options }) => target.addEventListener(eventName, action, options));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeListeners = () => {
|
||||||
|
listeners.forEach(({ target, eventName, action }) => target.removeEventListener(eventName, action));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Run on creation
|
||||||
|
addListeners();
|
||||||
|
onWindowResize(container);
|
||||||
|
|
||||||
|
return {
|
||||||
|
removeListeners,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export type InputManager = ReturnType<typeof createInputManager>;
|
||||||
|
|
||||||
|
export function makeModifiersBitfield(e: MouseEvent | KeyboardEvent): number {
|
||||||
|
return Number(e.ctrlKey) | (Number(e.shiftKey) << 1) | (Number(e.altKey) << 2);
|
||||||
|
}
|
||||||
+5
-28
@@ -4,38 +4,15 @@
|
|||||||
import "reflect-metadata";
|
import "reflect-metadata";
|
||||||
import { createApp } from "vue";
|
import { createApp } from "vue";
|
||||||
|
|
||||||
import { fullscreenModeChanged } from "@/utilities/fullscreen";
|
import "@/lifetime/errors";
|
||||||
import { onKeyUp, onKeyDown, onMouseMove, onMouseDown, onMouseUp, onMouseScroll, onWindowResize, onBeforeUnload } from "@/utilities/input";
|
import { initWasm } from "@/state/wasm-loader";
|
||||||
import "@/utilities/errors";
|
|
||||||
import App from "@/App.vue";
|
|
||||||
import { panicProxy } from "@/utilities/panic-proxy";
|
|
||||||
|
|
||||||
const wasm = import("@/../wasm/pkg").then(panicProxy);
|
import App from "@/App.vue";
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
(window as any).wasmMemory = undefined;
|
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// Initialize the WASM editor backend
|
||||||
(window as any).wasmMemory = (await wasm).wasm_memory;
|
await initWasm();
|
||||||
|
|
||||||
// Initialize the Vue application
|
// Initialize the Vue application
|
||||||
createApp(App).mount("#app");
|
createApp(App).mount("#app");
|
||||||
|
|
||||||
// Bind global browser events
|
|
||||||
window.addEventListener("resize", onWindowResize);
|
|
||||||
onWindowResize();
|
|
||||||
|
|
||||||
window.addEventListener("beforeunload", onBeforeUnload);
|
|
||||||
|
|
||||||
document.addEventListener("contextmenu", (e) => e.preventDefault());
|
|
||||||
document.addEventListener("fullscreenchange", () => fullscreenModeChanged());
|
|
||||||
|
|
||||||
window.addEventListener("keyup", onKeyUp);
|
|
||||||
window.addEventListener("keydown", onKeyDown);
|
|
||||||
|
|
||||||
window.addEventListener("mousemove", onMouseMove);
|
|
||||||
window.addEventListener("mousedown", onMouseDown);
|
|
||||||
window.addEventListener("mouseup", onMouseUp);
|
|
||||||
|
|
||||||
window.addEventListener("wheel", onMouseScroll, { passive: false });
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { reactive, readonly } from "vue";
|
||||||
|
|
||||||
|
import { TextButtonWidget } from "@/components/widgets/widgets";
|
||||||
|
import { EditorState } from "@/state/wasm-loader";
|
||||||
|
import { DisplayAboutGraphiteDialog } from "@/dispatcher/js-messages";
|
||||||
|
import { stripIndents } from "@/utilities/strip-indents";
|
||||||
|
|
||||||
|
export function createDialogState(editor: EditorState) {
|
||||||
|
const state = reactive({
|
||||||
|
visible: false,
|
||||||
|
icon: "",
|
||||||
|
heading: "",
|
||||||
|
details: "",
|
||||||
|
buttons: [] as TextButtonWidget[],
|
||||||
|
});
|
||||||
|
|
||||||
|
const createDialog = (icon: string, heading: string, details: string, buttons: TextButtonWidget[]) => {
|
||||||
|
state.visible = true;
|
||||||
|
state.icon = icon;
|
||||||
|
state.heading = heading;
|
||||||
|
state.details = details;
|
||||||
|
state.buttons = buttons;
|
||||||
|
};
|
||||||
|
|
||||||
|
const dismissDialog = () => {
|
||||||
|
state.visible = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitDialog = () => {
|
||||||
|
const firstEmphasizedButton = state.buttons.find((button) => button.props.emphasized && button.callback);
|
||||||
|
if (firstEmphasizedButton) {
|
||||||
|
// If statement satisfies TypeScript
|
||||||
|
if (firstEmphasizedButton.callback) firstEmphasizedButton.callback();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const dialogIsVisible = (): boolean => {
|
||||||
|
return state.visible;
|
||||||
|
};
|
||||||
|
|
||||||
|
const comingSoon = (issueNumber?: number) => {
|
||||||
|
const bugMessage = `— but you can help add it!\nSee issue #${issueNumber} on GitHub.`;
|
||||||
|
const details = `This feature is not implemented yet${issueNumber ? bugMessage : ""}`;
|
||||||
|
|
||||||
|
const okButton: TextButtonWidget = {
|
||||||
|
kind: "TextButton",
|
||||||
|
callback: async () => dismissDialog(),
|
||||||
|
props: { label: "OK", emphasized: true, minWidth: 96 },
|
||||||
|
};
|
||||||
|
const issueButton: TextButtonWidget = {
|
||||||
|
kind: "TextButton",
|
||||||
|
callback: async () => window.open(`https://github.com/GraphiteEditor/Graphite/issues/${issueNumber}`, "_blank"),
|
||||||
|
props: { label: `Issue #${issueNumber}`, minWidth: 96 },
|
||||||
|
};
|
||||||
|
const buttons = [okButton];
|
||||||
|
if (issueNumber) buttons.push(issueButton);
|
||||||
|
|
||||||
|
createDialog("Warning", "Coming soon", details, buttons);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onAboutHandler = () => {
|
||||||
|
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 && timezoneName.value;
|
||||||
|
|
||||||
|
const hash = (process.env.VUE_APP_COMMIT_HASH || "").substring(0, 12);
|
||||||
|
|
||||||
|
const details = stripIndents`
|
||||||
|
Release Series: ${process.env.VUE_APP_RELEASE_SERIES}
|
||||||
|
|
||||||
|
Date: ${dateString} ${timeString} ${timezoneNameString}
|
||||||
|
Hash: ${hash}
|
||||||
|
Branch: ${process.env.VUE_APP_COMMIT_BRANCH}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const buttons: TextButtonWidget[] = [
|
||||||
|
{
|
||||||
|
kind: "TextButton",
|
||||||
|
callback: () => window.open("https://www.graphite.design", "_blank"),
|
||||||
|
props: { label: "Website", emphasized: false, minWidth: 0 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "TextButton",
|
||||||
|
callback: () => window.open("https://github.com/GraphiteEditor/Graphite/graphs/contributors", "_blank"),
|
||||||
|
props: { label: "Credits", emphasized: false, minWidth: 0 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "TextButton",
|
||||||
|
callback: () => window.open("https://raw.githubusercontent.com/GraphiteEditor/Graphite/master/LICENSE.txt", "_blank"),
|
||||||
|
props: { label: "License", emphasized: false, minWidth: 0 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "TextButton",
|
||||||
|
callback: () => window.open("/third-party-licenses.txt", "_blank"),
|
||||||
|
props: { label: "Third-Party Licenses", emphasized: false, minWidth: 0 },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
createDialog("GraphiteLogo", "Graphite", details, buttons);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Run on creation
|
||||||
|
editor.dispatcher.subscribeJsMessage(DisplayAboutGraphiteDialog, () => onAboutHandler());
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: readonly(state),
|
||||||
|
createDialog,
|
||||||
|
dismissDialog,
|
||||||
|
submitDialog,
|
||||||
|
dialogIsVisible,
|
||||||
|
comingSoon,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export type DialogState = ReturnType<typeof createDialogState>;
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
/* eslint-disable max-classes-per-file */
|
||||||
|
import { reactive, readonly } from "vue";
|
||||||
|
|
||||||
|
import { DialogState } from "@/state/dialog";
|
||||||
|
import { download, upload } from "@/utilities/files";
|
||||||
|
import { EditorState } from "@/state/wasm-loader";
|
||||||
|
import {
|
||||||
|
DisplayConfirmationToCloseAllDocuments,
|
||||||
|
DisplayConfirmationToCloseDocument,
|
||||||
|
ExportDocument,
|
||||||
|
OpenDocumentBrowse,
|
||||||
|
SaveDocument,
|
||||||
|
SetActiveDocument,
|
||||||
|
UpdateOpenDocumentsList,
|
||||||
|
} from "@/dispatcher/js-messages";
|
||||||
|
|
||||||
|
class DocumentSaveState {
|
||||||
|
readonly displayName: string;
|
||||||
|
|
||||||
|
constructor(readonly name: string, readonly isSaved: boolean) {
|
||||||
|
this.displayName = `${name}${isSaved ? "" : "*"}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDocumentsState(editor: EditorState, dialogState: DialogState) {
|
||||||
|
const state = reactive({
|
||||||
|
unsaved: false,
|
||||||
|
documents: [] as DocumentSaveState[],
|
||||||
|
activeDocumentIndex: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectDocument = (tabIndex: number) => {
|
||||||
|
editor.instance.select_document(tabIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDocumentWithConfirmation = (tabIndex: number) => {
|
||||||
|
// Close automatically if it's already saved, no confirmation is needed
|
||||||
|
const targetDocument = state.documents[tabIndex];
|
||||||
|
if (targetDocument.isSaved) {
|
||||||
|
editor.instance.close_document(tabIndex);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switch to the document that's being prompted to close
|
||||||
|
selectDocument(tabIndex);
|
||||||
|
|
||||||
|
// Show the close confirmation prompt
|
||||||
|
dialogState.createDialog("File", "Save changes before closing?", targetDocument.displayName, [
|
||||||
|
{
|
||||||
|
kind: "TextButton",
|
||||||
|
callback: () => {
|
||||||
|
editor.instance.save_document();
|
||||||
|
dialogState.dismissDialog();
|
||||||
|
},
|
||||||
|
props: { label: "Save", emphasized: true, minWidth: 96 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "TextButton",
|
||||||
|
callback: () => {
|
||||||
|
editor.instance.close_document(tabIndex);
|
||||||
|
dialogState.dismissDialog();
|
||||||
|
},
|
||||||
|
props: { label: "Discard", minWidth: 96 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "TextButton",
|
||||||
|
callback: () => {
|
||||||
|
dialogState.dismissDialog();
|
||||||
|
},
|
||||||
|
props: { label: "Cancel", minWidth: 96 },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeAllDocumentsWithConfirmation = () => {
|
||||||
|
dialogState.createDialog("Copy", "Close all documents?", "Unsaved work will be lost!", [
|
||||||
|
{
|
||||||
|
kind: "TextButton",
|
||||||
|
callback: () => {
|
||||||
|
editor.instance.close_all_documents();
|
||||||
|
dialogState.dismissDialog();
|
||||||
|
},
|
||||||
|
props: { label: "Discard All", minWidth: 96 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "TextButton",
|
||||||
|
callback: () => {
|
||||||
|
dialogState.dismissDialog();
|
||||||
|
},
|
||||||
|
props: { label: "Cancel", minWidth: 96 },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set up message subscriptions on creation
|
||||||
|
editor.dispatcher.subscribeJsMessage(UpdateOpenDocumentsList, (updateOpenDocumentList) => {
|
||||||
|
state.documents = updateOpenDocumentList.open_documents.map(({ name, isSaved }) => new DocumentSaveState(name, isSaved));
|
||||||
|
});
|
||||||
|
|
||||||
|
editor.dispatcher.subscribeJsMessage(SetActiveDocument, (setActiveDocument) => {
|
||||||
|
state.activeDocumentIndex = setActiveDocument.document_index;
|
||||||
|
});
|
||||||
|
|
||||||
|
editor.dispatcher.subscribeJsMessage(DisplayConfirmationToCloseDocument, (displayConfirmationToCloseDocument) => {
|
||||||
|
closeDocumentWithConfirmation(displayConfirmationToCloseDocument.document_index);
|
||||||
|
});
|
||||||
|
|
||||||
|
editor.dispatcher.subscribeJsMessage(DisplayConfirmationToCloseAllDocuments, () => {
|
||||||
|
closeAllDocumentsWithConfirmation();
|
||||||
|
});
|
||||||
|
|
||||||
|
editor.dispatcher.subscribeJsMessage(OpenDocumentBrowse, async () => {
|
||||||
|
const extension = editor.rawWasm.file_save_suffix();
|
||||||
|
const data = await upload(extension);
|
||||||
|
editor.instance.open_document_file(data.filename, data.content);
|
||||||
|
});
|
||||||
|
|
||||||
|
editor.dispatcher.subscribeJsMessage(ExportDocument, (exportDocument) => {
|
||||||
|
download(exportDocument.name, exportDocument.document);
|
||||||
|
});
|
||||||
|
|
||||||
|
editor.dispatcher.subscribeJsMessage(SaveDocument, (saveDocument) => {
|
||||||
|
download(saveDocument.name, saveDocument.document);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get the initial documents
|
||||||
|
editor.instance.get_open_documents_list();
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: readonly(state),
|
||||||
|
selectDocument,
|
||||||
|
closeDocumentWithConfirmation,
|
||||||
|
closeAllDocumentsWithConfirmation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export type DocumentsState = ReturnType<typeof createDocumentsState>;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { reactive, readonly } from "vue";
|
||||||
|
|
||||||
|
export function createFullscreenState() {
|
||||||
|
const state = reactive({
|
||||||
|
windowFullscreen: false,
|
||||||
|
keyboardLocked: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fullscreenModeChanged = () => {
|
||||||
|
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 && "lock" in (navigator as any).keyboard;
|
||||||
|
|
||||||
|
const enterFullscreen = async () => {
|
||||||
|
await document.documentElement.requestFullscreen();
|
||||||
|
|
||||||
|
if (keyboardLockApiSupported) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
await (navigator as any).keyboard.lock(["ControlLeft", "ControlRight"]);
|
||||||
|
state.keyboardLocked = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line class-methods-use-this
|
||||||
|
const exitFullscreen = async () => {
|
||||||
|
await document.exitFullscreen();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleFullscreen = async () => {
|
||||||
|
if (state.windowFullscreen) await exitFullscreen();
|
||||||
|
else await enterFullscreen();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: readonly(state),
|
||||||
|
keyboardLockApiSupported,
|
||||||
|
enterFullscreen,
|
||||||
|
exitFullscreen,
|
||||||
|
toggleFullscreen,
|
||||||
|
fullscreenModeChanged,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export type FullscreenState = ReturnType<typeof createFullscreenState>;
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/* 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["Editor"]>;
|
||||||
|
|
||||||
|
let wasmImport: WasmInstance | null = null;
|
||||||
|
export async function initWasm() {
|
||||||
|
if (wasmImport !== null) return;
|
||||||
|
|
||||||
|
wasmImport = await import("@/../wasm/pkg").then(panicProxy);
|
||||||
|
}
|
||||||
|
|
||||||
|
// This works by proxying every function call wrapping a try-catch block to filter out redundant and confusing
|
||||||
|
// `RuntimeError: unreachable` exceptions sent to the console
|
||||||
|
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[]) {
|
||||||
|
// 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[]) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWasmInstance() {
|
||||||
|
if (wasmImport) return wasmImport;
|
||||||
|
throw new Error("Editor WASM backend was not initialized at application startup");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEditorState() {
|
||||||
|
const dispatcher = createJsDispatcher();
|
||||||
|
const rawWasm = getWasmInstance();
|
||||||
|
|
||||||
|
const rustCallback = (messageType: JsMessageType, data: Record<string, unknown>) => {
|
||||||
|
dispatcher.handleJsMessage(messageType, data, rawWasm, instance);
|
||||||
|
};
|
||||||
|
|
||||||
|
const instance = new rawWasm.Editor(rustCallback);
|
||||||
|
|
||||||
|
return {
|
||||||
|
dispatcher,
|
||||||
|
rawWasm,
|
||||||
|
instance,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export type EditorState = Readonly<ReturnType<typeof createEditorState>>;
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import { reactive, readonly } from "vue";
|
|
||||||
|
|
||||||
import { TextButtonWidget } from "@/components/widgets/widgets";
|
|
||||||
|
|
||||||
const state = reactive({
|
|
||||||
visible: false,
|
|
||||||
icon: "",
|
|
||||||
heading: "",
|
|
||||||
details: "",
|
|
||||||
buttons: [] as TextButtonWidget[],
|
|
||||||
});
|
|
||||||
|
|
||||||
export function createDialog(icon: string, heading: string, details: string, buttons: TextButtonWidget[]) {
|
|
||||||
state.visible = true;
|
|
||||||
state.icon = icon;
|
|
||||||
state.heading = heading;
|
|
||||||
state.details = details;
|
|
||||||
state.buttons = buttons;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function dismissDialog() {
|
|
||||||
state.visible = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function submitDialog() {
|
|
||||||
const firstEmphasizedButton = state.buttons.find((button) => button.props.emphasized && button.callback);
|
|
||||||
if (firstEmphasizedButton) {
|
|
||||||
// If statement satisfies TypeScript
|
|
||||||
if (firstEmphasizedButton.callback) firstEmphasizedButton.callback();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function dialogIsVisible(): boolean {
|
|
||||||
return state.visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default readonly(state);
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { subscribeJsMessage } from "@/utilities/js-message-dispatcher";
|
|
||||||
import { DisplayAboutGraphiteDialog } from "@/utilities/js-messages";
|
|
||||||
import { createDialog } from "@/utilities/dialog";
|
|
||||||
import { TextButtonWidget } from "@/components/widgets/widgets";
|
|
||||||
|
|
||||||
subscribeJsMessage(DisplayAboutGraphiteDialog, () => {
|
|
||||||
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 && timezoneName.value;
|
|
||||||
|
|
||||||
const hash = (process.env.VUE_APP_COMMIT_HASH || "").substring(0, 12);
|
|
||||||
|
|
||||||
const details = `
|
|
||||||
Release Series: ${process.env.VUE_APP_RELEASE_SERIES}
|
|
||||||
|
|
||||||
Date: ${dateString} ${timeString} ${timezoneNameString}
|
|
||||||
Hash: ${hash}
|
|
||||||
Branch: ${process.env.VUE_APP_COMMIT_BRANCH}
|
|
||||||
`.trim();
|
|
||||||
|
|
||||||
const buttons: TextButtonWidget[] = [
|
|
||||||
{
|
|
||||||
kind: "TextButton",
|
|
||||||
callback: () => window.open("https://www.graphite.design", "_blank"),
|
|
||||||
props: { label: "Website", emphasized: false, minWidth: 0 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "TextButton",
|
|
||||||
callback: () => window.open("https://github.com/GraphiteEditor/Graphite/graphs/contributors", "_blank"),
|
|
||||||
props: { label: "Credits", emphasized: false, minWidth: 0 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "TextButton",
|
|
||||||
callback: () => window.open("https://raw.githubusercontent.com/GraphiteEditor/Graphite/master/LICENSE.txt", "_blank"),
|
|
||||||
props: { label: "License", emphasized: false, minWidth: 0 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "TextButton",
|
|
||||||
callback: () => window.open("/third-party-licenses.txt", "_blank"),
|
|
||||||
props: { label: "Third-Party Licenses", emphasized: false, minWidth: 0 },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
createDialog("GraphiteLogo", "Graphite", details, buttons);
|
|
||||||
});
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
import { reactive, readonly } from "vue";
|
|
||||||
|
|
||||||
import { createDialog, dismissDialog } from "@/utilities/dialog";
|
|
||||||
import { subscribeJsMessage } from "@/utilities/js-message-dispatcher";
|
|
||||||
import {
|
|
||||||
DisplayConfirmationToCloseAllDocuments,
|
|
||||||
SetActiveDocument,
|
|
||||||
UpdateOpenDocumentsList,
|
|
||||||
DisplayConfirmationToCloseDocument,
|
|
||||||
ExportDocument,
|
|
||||||
SaveDocument,
|
|
||||||
OpenDocumentBrowse,
|
|
||||||
} from "@/utilities/js-messages";
|
|
||||||
import { download, upload } from "@/utilities/files";
|
|
||||||
import { panicProxy } from "@/utilities/panic-proxy";
|
|
||||||
|
|
||||||
const wasm = import("@/../wasm/pkg").then(panicProxy);
|
|
||||||
|
|
||||||
class DocumentState {
|
|
||||||
readonly displayName: string;
|
|
||||||
|
|
||||||
constructor(readonly name: string, readonly isSaved: boolean) {
|
|
||||||
this.displayName = `${name}${isSaved ? "" : "*"}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const state = reactive({
|
|
||||||
documents: [] as DocumentState[],
|
|
||||||
activeDocumentIndex: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function selectDocument(tabIndex: number) {
|
|
||||||
(await wasm).select_document(tabIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function closeDocumentWithConfirmation(tabIndex: number) {
|
|
||||||
const targetDocument = state.documents[tabIndex];
|
|
||||||
if (targetDocument.isSaved) {
|
|
||||||
(await wasm).close_document(tabIndex);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show the document is being prompted to close
|
|
||||||
await selectDocument(tabIndex);
|
|
||||||
|
|
||||||
const tabLabel = targetDocument.displayName;
|
|
||||||
|
|
||||||
createDialog("File", "Save changes before closing?", tabLabel, [
|
|
||||||
{
|
|
||||||
kind: "TextButton",
|
|
||||||
callback: async () => {
|
|
||||||
(await wasm).save_document();
|
|
||||||
dismissDialog();
|
|
||||||
},
|
|
||||||
props: { label: "Save", emphasized: true, minWidth: 96 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "TextButton",
|
|
||||||
callback: async () => {
|
|
||||||
(await wasm).close_document(tabIndex);
|
|
||||||
dismissDialog();
|
|
||||||
},
|
|
||||||
props: { label: "Discard", minWidth: 96 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "TextButton",
|
|
||||||
callback: async () => {
|
|
||||||
dismissDialog();
|
|
||||||
},
|
|
||||||
props: { label: "Cancel", minWidth: 96 },
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function closeAllDocumentsWithConfirmation() {
|
|
||||||
createDialog("Copy", "Close all documents?", "Unsaved work will be lost!", [
|
|
||||||
{
|
|
||||||
kind: "TextButton",
|
|
||||||
callback: async () => {
|
|
||||||
(await wasm).close_all_documents();
|
|
||||||
dismissDialog();
|
|
||||||
},
|
|
||||||
props: { label: "Discard All", minWidth: 96 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "TextButton",
|
|
||||||
callback: async () => {
|
|
||||||
dismissDialog();
|
|
||||||
},
|
|
||||||
props: { label: "Cancel", minWidth: 96 },
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default readonly(state);
|
|
||||||
|
|
||||||
subscribeJsMessage(UpdateOpenDocumentsList, (updateOpenDocumentList) => {
|
|
||||||
state.documents = updateOpenDocumentList.open_documents.map(({ name, isSaved }) => new DocumentState(name, isSaved));
|
|
||||||
});
|
|
||||||
|
|
||||||
subscribeJsMessage(SetActiveDocument, (setActiveDocument) => {
|
|
||||||
state.activeDocumentIndex = setActiveDocument.document_index;
|
|
||||||
});
|
|
||||||
|
|
||||||
subscribeJsMessage(DisplayConfirmationToCloseDocument, (displayConfirmationToCloseDocument) => {
|
|
||||||
closeDocumentWithConfirmation(displayConfirmationToCloseDocument.document_index);
|
|
||||||
});
|
|
||||||
|
|
||||||
subscribeJsMessage(DisplayConfirmationToCloseAllDocuments, () => {
|
|
||||||
closeAllDocumentsWithConfirmation();
|
|
||||||
});
|
|
||||||
|
|
||||||
subscribeJsMessage(OpenDocumentBrowse, async () => {
|
|
||||||
const extension = (await wasm).file_save_suffix();
|
|
||||||
const data = await upload(extension);
|
|
||||||
(await wasm).open_document_file(data.filename, data.content);
|
|
||||||
});
|
|
||||||
|
|
||||||
subscribeJsMessage(ExportDocument, (exportDocument) => {
|
|
||||||
download(exportDocument.name, exportDocument.document);
|
|
||||||
});
|
|
||||||
|
|
||||||
subscribeJsMessage(SaveDocument, (saveDocument) => {
|
|
||||||
download(saveDocument.name, saveDocument.document);
|
|
||||||
});
|
|
||||||
|
|
||||||
(async () => (await wasm).get_open_documents_list())();
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
import { createDialog, dismissDialog } from "@/utilities/dialog";
|
|
||||||
import { TextButtonWidget } from "@/components/widgets/widgets";
|
|
||||||
import { subscribeJsMessage } from "@/utilities/js-message-dispatcher";
|
|
||||||
import { DisplayError, DisplayPanic } from "@/utilities/js-messages";
|
|
||||||
|
|
||||||
// Coming soon dialog
|
|
||||||
export function comingSoon(issueNumber?: number) {
|
|
||||||
const bugMessage = `— but you can help add it!\nSee issue #${issueNumber} on GitHub.`;
|
|
||||||
const details = `This feature is not implemented yet${issueNumber ? bugMessage : ""}`;
|
|
||||||
|
|
||||||
const okButton: TextButtonWidget = {
|
|
||||||
kind: "TextButton",
|
|
||||||
callback: async () => dismissDialog(),
|
|
||||||
props: { label: "OK", emphasized: true, minWidth: 96 },
|
|
||||||
};
|
|
||||||
const issueButton: TextButtonWidget = {
|
|
||||||
kind: "TextButton",
|
|
||||||
callback: async () => window.open(`https://github.com/GraphiteEditor/Graphite/issues/${issueNumber}`, "_blank"),
|
|
||||||
props: { label: `Issue #${issueNumber}`, minWidth: 96 },
|
|
||||||
};
|
|
||||||
const buttons = [okButton];
|
|
||||||
if (issueNumber) buttons.push(issueButton);
|
|
||||||
|
|
||||||
createDialog("Warning", "Coming soon", details, buttons);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Graphite error dialog
|
|
||||||
subscribeJsMessage(DisplayError, (displayError) => {
|
|
||||||
const okButton: TextButtonWidget = {
|
|
||||||
kind: "TextButton",
|
|
||||||
callback: async () => dismissDialog(),
|
|
||||||
props: { label: "OK", emphasized: true, minWidth: 96 },
|
|
||||||
};
|
|
||||||
const buttons = [okButton];
|
|
||||||
|
|
||||||
createDialog("Warning", displayError.title, displayError.description, buttons);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Code panic dialog and console error
|
|
||||||
subscribeJsMessage(DisplayPanic, (displayPanic) => {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
(Error as any).stackTraceLimit = Infinity;
|
|
||||||
const stackTrace = new Error().stack || "";
|
|
||||||
const panicDetails = `${displayPanic.panic_info}\n\n${stackTrace}`;
|
|
||||||
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.error(panicDetails);
|
|
||||||
|
|
||||||
const reloadButton: TextButtonWidget = {
|
|
||||||
kind: "TextButton",
|
|
||||||
callback: async () => window.location.reload(),
|
|
||||||
props: { label: "Reload", emphasized: true, minWidth: 96 },
|
|
||||||
};
|
|
||||||
const copyErrorLogButton: TextButtonWidget = {
|
|
||||||
kind: "TextButton",
|
|
||||||
callback: async () => navigator.clipboard.writeText(panicDetails),
|
|
||||||
props: { label: "Copy Error Log", emphasized: false, minWidth: 96 },
|
|
||||||
};
|
|
||||||
const reportOnGithubButton: TextButtonWidget = {
|
|
||||||
kind: "TextButton",
|
|
||||||
callback: async () => window.open(githubUrl(panicDetails), "_blank"),
|
|
||||||
props: { label: "Report Bug", emphasized: false, minWidth: 96 },
|
|
||||||
};
|
|
||||||
const buttons = [reloadButton, copyErrorLogButton, reportOnGithubButton];
|
|
||||||
|
|
||||||
createDialog("Warning", displayPanic.title, displayPanic.description, buttons);
|
|
||||||
});
|
|
||||||
|
|
||||||
function githubUrl(panicDetails: string) {
|
|
||||||
const url = new URL("https://github.com/GraphiteEditor/Graphite/issues/new");
|
|
||||||
|
|
||||||
const body = `
|
|
||||||
**Describe the Crash**
|
|
||||||
Explain clearly what you were doing when the crash occurred.
|
|
||||||
|
|
||||||
**Steps To Reproduce**
|
|
||||||
Describe precisely how the crash occurred, step by step, starting with a new editor window.
|
|
||||||
1. Open the Graphite Editor at https://editor.graphite.design
|
|
||||||
2.
|
|
||||||
3.
|
|
||||||
4.
|
|
||||||
5.
|
|
||||||
|
|
||||||
**Additional Details**
|
|
||||||
Provide any further information or context that you think would be helpful in fixing the issue. Screenshots or video can be linked or attached to this issue.
|
|
||||||
|
|
||||||
**Browser and OS**
|
|
||||||
${browserVersion()}, ${operatingSystem()}
|
|
||||||
|
|
||||||
**Stack Trace**
|
|
||||||
Copied from the crash dialog in the Graphite Editor:
|
|
||||||
|
|
||||||
\`\`\`
|
|
||||||
${panicDetails}
|
|
||||||
\`\`\`
|
|
||||||
`.trim();
|
|
||||||
|
|
||||||
const fields = {
|
|
||||||
title: "[Crash Report] ",
|
|
||||||
body,
|
|
||||||
labels: ["Crash"].join(","),
|
|
||||||
projects: [].join(","),
|
|
||||||
milestone: "",
|
|
||||||
assignee: "",
|
|
||||||
template: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
Object.entries(fields).forEach(([field, value]) => {
|
|
||||||
if (value) url.searchParams.set(field, value);
|
|
||||||
});
|
|
||||||
|
|
||||||
return url.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function browserVersion(): string {
|
|
||||||
const agent = window.navigator.userAgent;
|
|
||||||
let match = agent.match(/(opera|chrome|safari|firefox|msie|trident(?=\/))\/?\s*(\d+)/i) || [];
|
|
||||||
|
|
||||||
if (/trident/i.test(match[1])) {
|
|
||||||
const browser = /\brv[ :]+(\d+)/g.exec(agent) || [];
|
|
||||||
return `IE ${browser[1] || ""}`.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (match[1] === "Chrome") {
|
|
||||||
let browser = agent.match(/\bEdg\/(\d+)/);
|
|
||||||
if (browser !== null) return `Edge (Chromium) ${browser[1]}`;
|
|
||||||
|
|
||||||
browser = agent.match(/\bOPR\/(\d+)/);
|
|
||||||
if (browser !== null) return `Opera ${browser[1]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
match = match[2] ? [match[1], match[2]] : [navigator.appName, navigator.appVersion, "-?"];
|
|
||||||
|
|
||||||
const browser = agent.match(/version\/(\d+)/i);
|
|
||||||
if (browser !== null) match.splice(1, 1, browser[1]);
|
|
||||||
|
|
||||||
return `${match[0]} ${match[1]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function operatingSystem(): string {
|
|
||||||
const osTable: Record<string, string> = {
|
|
||||||
"Windows NT 10": "Windows 10 or 11",
|
|
||||||
"Windows NT 6.3": "Windows 8.1",
|
|
||||||
"Windows NT 6.2": "Windows 8",
|
|
||||||
"Windows NT 6.1": "Windows 7",
|
|
||||||
"Windows NT 6.0": "Windows Vista",
|
|
||||||
"Windows NT 5.1": "Windows XP",
|
|
||||||
"Windows NT 5.0": "Windows 2000",
|
|
||||||
Mac: "Mac",
|
|
||||||
X11: "Unix",
|
|
||||||
Linux: "Linux",
|
|
||||||
Unknown: "YOUR OPERATING SYSTEM",
|
|
||||||
};
|
|
||||||
|
|
||||||
const userAgentOS = Object.keys(osTable).find((key) => window.navigator.userAgent.includes(key));
|
|
||||||
return osTable[userAgentOS || "Unknown"];
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import { reactive, readonly } from "vue";
|
|
||||||
|
|
||||||
const state = reactive({
|
|
||||||
windowFullscreen: false,
|
|
||||||
keyboardLocked: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
export function fullscreenModeChanged() {
|
|
||||||
state.windowFullscreen = Boolean(document.fullscreenElement);
|
|
||||||
if (!state.windowFullscreen) state.keyboardLocked = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function keyboardLockApiSupported(): boolean {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
return "keyboard" in navigator && "lock" in (navigator as any).keyboard;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function enterFullscreen() {
|
|
||||||
await document.documentElement.requestFullscreen();
|
|
||||||
|
|
||||||
if (keyboardLockApiSupported()) {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
await (navigator as any).keyboard.lock(["ControlLeft", "ControlRight"]);
|
|
||||||
state.keyboardLocked = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function exitFullscreen() {
|
|
||||||
await document.exitFullscreen();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function toggleFullscreen() {
|
|
||||||
if (state.windowFullscreen) await exitFullscreen();
|
|
||||||
else await enterFullscreen();
|
|
||||||
}
|
|
||||||
|
|
||||||
export default readonly(state);
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
import { toggleFullscreen } from "@/utilities/fullscreen";
|
|
||||||
import { dialogIsVisible, dismissDialog, submitDialog } from "@/utilities/dialog";
|
|
||||||
import { panicProxy } from "@/utilities/panic-proxy";
|
|
||||||
import documents from "@/utilities/documents";
|
|
||||||
|
|
||||||
const wasm = import("@/../wasm/pkg").then(panicProxy);
|
|
||||||
|
|
||||||
let viewportMouseInteractionOngoing = false;
|
|
||||||
|
|
||||||
// Keyboard events
|
|
||||||
|
|
||||||
function shouldRedirectKeyboardEventToBackend(e: KeyboardEvent): boolean {
|
|
||||||
// Don't redirect user input from text entry into HTML elements
|
|
||||||
const target = e.target as HTMLElement;
|
|
||||||
if (target.nodeName === "INPUT" || target.nodeName === "TEXTAREA" || target.isContentEditable) return false;
|
|
||||||
|
|
||||||
// Don't redirect when a modal is covering the workspace
|
|
||||||
if (dialogIsVisible()) return false;
|
|
||||||
|
|
||||||
// Don't redirect a fullscreen request
|
|
||||||
if (e.key.toLowerCase() === "f11" && e.type === "keydown" && !e.repeat) {
|
|
||||||
e.preventDefault();
|
|
||||||
toggleFullscreen();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't redirect a reload request
|
|
||||||
if (e.key.toLowerCase() === "f5") return false;
|
|
||||||
|
|
||||||
// Don't redirect debugging tools
|
|
||||||
if (e.key.toLowerCase() === "f12") return false;
|
|
||||||
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === "c") return false;
|
|
||||||
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === "i") return false;
|
|
||||||
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === "j") return false;
|
|
||||||
|
|
||||||
// Redirect to the backend
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function onKeyDown(e: KeyboardEvent) {
|
|
||||||
if (shouldRedirectKeyboardEventToBackend(e)) {
|
|
||||||
e.preventDefault();
|
|
||||||
const modifiers = makeModifiersBitfield(e);
|
|
||||||
(await wasm).on_key_down(e.key, modifiers);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dialogIsVisible()) {
|
|
||||||
if (e.key === "Escape") dismissDialog();
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
submitDialog();
|
|
||||||
|
|
||||||
// Prevent the Enter key from acting like a click on the last clicked button, which might reopen the dialog
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function onKeyUp(e: KeyboardEvent) {
|
|
||||||
if (shouldRedirectKeyboardEventToBackend(e)) {
|
|
||||||
e.preventDefault();
|
|
||||||
const modifiers = makeModifiersBitfield(e);
|
|
||||||
(await wasm).on_key_up(e.key, modifiers);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mouse events
|
|
||||||
|
|
||||||
export async function onMouseMove(e: MouseEvent) {
|
|
||||||
if (!e.buttons) viewportMouseInteractionOngoing = false;
|
|
||||||
|
|
||||||
const modifiers = makeModifiersBitfield(e);
|
|
||||||
(await wasm).on_mouse_move(e.clientX, e.clientY, e.buttons, modifiers);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function onMouseDown(e: MouseEvent) {
|
|
||||||
const target = e.target && (e.target as HTMLElement);
|
|
||||||
const inCanvas = target && target.closest(".canvas");
|
|
||||||
const inDialog = target && target.closest(".dialog-modal .floating-menu-content");
|
|
||||||
|
|
||||||
// Block middle mouse button auto-scroll mode
|
|
||||||
if (e.button === 1) e.preventDefault();
|
|
||||||
|
|
||||||
if (dialogIsVisible() && !inDialog) {
|
|
||||||
dismissDialog();
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (inCanvas) viewportMouseInteractionOngoing = true;
|
|
||||||
|
|
||||||
if (viewportMouseInteractionOngoing) {
|
|
||||||
const modifiers = makeModifiersBitfield(e);
|
|
||||||
(await wasm).on_mouse_down(e.clientX, e.clientY, e.buttons, modifiers);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function onMouseUp(e: MouseEvent) {
|
|
||||||
if (!e.buttons) viewportMouseInteractionOngoing = false;
|
|
||||||
|
|
||||||
const modifiers = makeModifiersBitfield(e);
|
|
||||||
(await wasm).on_mouse_up(e.clientX, e.clientY, e.buttons, modifiers);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function onMouseScroll(e: WheelEvent) {
|
|
||||||
const target = e.target && (e.target as HTMLElement);
|
|
||||||
const inCanvas = target && target.closest(".canvas");
|
|
||||||
|
|
||||||
const horizontalScrollableElement = e.target instanceof Element && e.target.closest(".scrollable-x");
|
|
||||||
if (horizontalScrollableElement && e.deltaY !== 0) {
|
|
||||||
horizontalScrollableElement.scrollTo(horizontalScrollableElement.scrollLeft + e.deltaY, 0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (inCanvas) {
|
|
||||||
e.preventDefault();
|
|
||||||
const modifiers = makeModifiersBitfield(e);
|
|
||||||
(await wasm).on_mouse_scroll(e.clientX, e.clientY, e.buttons, e.deltaX, e.deltaY, e.deltaZ, modifiers);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function onWindowResize() {
|
|
||||||
const viewports = Array.from(document.querySelectorAll(".canvas"));
|
|
||||||
const boundsOfViewports = viewports.map((canvas) => {
|
|
||||||
const bounds = canvas.getBoundingClientRect();
|
|
||||||
return [bounds.left, bounds.top, bounds.right, bounds.bottom];
|
|
||||||
});
|
|
||||||
|
|
||||||
const flattened = boundsOfViewports.flat();
|
|
||||||
const data = Float64Array.from(flattened);
|
|
||||||
|
|
||||||
if (boundsOfViewports.length > 0) (await wasm).bounds_of_viewports(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function onBeforeUnload(event: BeforeUnloadEvent) {
|
|
||||||
const allDocumentsSaved = documents.documents.reduce((acc, doc) => doc.isSaved && acc, true);
|
|
||||||
if (!allDocumentsSaved) {
|
|
||||||
event.returnValue = "Unsaved work will be lost if the web browser tab is closed. Close anyway?";
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function makeModifiersBitfield(e: MouseEvent | KeyboardEvent): number {
|
|
||||||
return Number(e.ctrlKey) | (Number(e.shiftKey) << 1) | (Number(e.altKey) << 2);
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
// This file is instantiated by wasm-bindgen in `/frontend/wasm/src/lib.rs` and re-exports the `handleJsMessage` function to
|
|
||||||
// provide access to the global copy of `js-message-dispatcher.ts` with its shared state, not an isolated duplicate with empty state
|
|
||||||
|
|
||||||
export { handleJsMessage } from "@/utilities/js-message-dispatcher";
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
||||||
|
|
||||||
import { plainToInstance } from "class-transformer";
|
|
||||||
import {
|
|
||||||
JsMessage,
|
|
||||||
DisplayConfirmationToCloseAllDocuments,
|
|
||||||
DisplayConfirmationToCloseDocument,
|
|
||||||
DisplayError,
|
|
||||||
DisplayPanic,
|
|
||||||
ExportDocument,
|
|
||||||
newDisplayFolderTreeStructure as DisplayFolderTreeStructure,
|
|
||||||
OpenDocumentBrowse,
|
|
||||||
SaveDocument,
|
|
||||||
SetActiveDocument,
|
|
||||||
SetActiveTool,
|
|
||||||
SetCanvasRotation,
|
|
||||||
SetCanvasZoom,
|
|
||||||
UpdateCanvas,
|
|
||||||
UpdateOpenDocumentsList,
|
|
||||||
UpdateRulers,
|
|
||||||
UpdateScrollbars,
|
|
||||||
UpdateWorkingColors,
|
|
||||||
UpdateLayer,
|
|
||||||
DisplayAboutGraphiteDialog,
|
|
||||||
} from "@/utilities/js-messages";
|
|
||||||
|
|
||||||
const messageConstructors = {
|
|
||||||
UpdateCanvas,
|
|
||||||
UpdateScrollbars,
|
|
||||||
UpdateRulers,
|
|
||||||
ExportDocument,
|
|
||||||
SaveDocument,
|
|
||||||
OpenDocumentBrowse,
|
|
||||||
DisplayFolderTreeStructure,
|
|
||||||
UpdateLayer,
|
|
||||||
SetActiveTool,
|
|
||||||
SetActiveDocument,
|
|
||||||
UpdateOpenDocumentsList,
|
|
||||||
UpdateWorkingColors,
|
|
||||||
SetCanvasZoom,
|
|
||||||
SetCanvasRotation,
|
|
||||||
DisplayError,
|
|
||||||
DisplayPanic,
|
|
||||||
DisplayConfirmationToCloseDocument,
|
|
||||||
DisplayConfirmationToCloseAllDocuments,
|
|
||||||
DisplayAboutGraphiteDialog,
|
|
||||||
} as const;
|
|
||||||
type JsMessageType = keyof typeof messageConstructors;
|
|
||||||
|
|
||||||
type JsMessageCallback<T extends JsMessage> = (messageData: T) => void;
|
|
||||||
type JsMessageCallbackMap = {
|
|
||||||
[message: string]: JsMessageCallback<any> | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Constructs<T> = new (...args: any[]) => T;
|
|
||||||
type ConstructsJsMessage = Constructs<JsMessage> & typeof JsMessage;
|
|
||||||
|
|
||||||
const subscriptions = {} as JsMessageCallbackMap;
|
|
||||||
|
|
||||||
export function subscribeJsMessage<T extends JsMessage>(messageType: Constructs<T>, callback: JsMessageCallback<T>) {
|
|
||||||
subscriptions[messageType.name] = callback;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function handleJsMessage(messageType: JsMessageType, messageData: any) {
|
|
||||||
const messageConstructor = messageConstructors[messageType];
|
|
||||||
if (!messageConstructor) {
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.error(`Received a frontend message of type "${messageType}" but but was not able to parse the data.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Messages with non-empty data are provided by wasm-bindgen as an object with one key as the message name, like: { NameOfThisMessage: { ... } }
|
|
||||||
// Messages with empty data are provided by wasm-bindgen as a string with the message name, like: "NameOfThisMessage"
|
|
||||||
const unwrappedMessageData = messageData[messageType] || {};
|
|
||||||
|
|
||||||
const isJsMessageConstructor = (fn: ConstructsJsMessage | ((data: any) => JsMessage)): fn is ConstructsJsMessage => {
|
|
||||||
return (fn as ConstructsJsMessage).jsMessageMarker !== undefined;
|
|
||||||
};
|
|
||||||
let message: JsMessage;
|
|
||||||
if (isJsMessageConstructor(messageConstructor)) {
|
|
||||||
message = plainToInstance(messageConstructor, unwrappedMessageData);
|
|
||||||
} else {
|
|
||||||
message = messageConstructor(unwrappedMessageData);
|
|
||||||
}
|
|
||||||
|
|
||||||
// It is ok to use constructor.name even with minification since it is used consistently with registerHandler
|
|
||||||
const callback = subscriptions[message.constructor.name];
|
|
||||||
|
|
||||||
if (callback && message) {
|
|
||||||
callback(message);
|
|
||||||
} else if (message) {
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.error(`Received a frontend message of type "${messageType}" but no handler was registered for it from the client.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any, func-names */
|
|
||||||
|
|
||||||
// Import this function and chain it on all `wasm` imports like: const wasm = import("@/../wasm/pkg").then(panicProxy);
|
|
||||||
// This works by proxying every function call wrapping a try-catch block to filter out redundant and confusing `RuntimeError: unreachable` exceptions sent to the console
|
|
||||||
export function panicProxy<T extends object>(module: T): T {
|
|
||||||
const proxyHandler = {
|
|
||||||
get(target: T, propKey: string | symbol, receiver: any): any {
|
|
||||||
const targetValue = Reflect.get(target, propKey, receiver);
|
|
||||||
|
|
||||||
// Keep the original value being accessed if it isn't a function or it is a class
|
|
||||||
// TODO: Figure out how to also wrap class constructor functions instead of skipping them for now
|
|
||||||
const isFunction = typeof targetValue === "function";
|
|
||||||
const isClass = isFunction && /^\s*class\s+/.test(targetValue.toString());
|
|
||||||
if (!isFunction || isClass) return targetValue;
|
|
||||||
|
|
||||||
// Replace the original function with a wrapper function that runs the original in a try-catch block
|
|
||||||
return function (...args: any) {
|
|
||||||
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: any) {
|
|
||||||
// Suppress `unreachable` WebAssembly.RuntimeError exceptions
|
|
||||||
if (!`${err}`.startsWith("RuntimeError: unreachable")) throw err;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return new Proxy<T>(module, proxyHandler);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export function stripIndents(stringPieces: TemplateStringsArray, ...substitutions: unknown[]) {
|
||||||
|
const interleavedSubstitutions = stringPieces.flatMap((stringPiece, index) => [stringPiece, substitutions[index] !== undefined ? substitutions[index] : ""]);
|
||||||
|
const stringLines = interleavedSubstitutions.join("").split("\n");
|
||||||
|
|
||||||
|
const visibleLineTabPrefixLengths = stringLines.map((line) => (line.match(/\S/) ? (line.match(/^(\t*)/) || [])[1].length : Infinity));
|
||||||
|
const commonTabPrefixLength = Math.min(...visibleLineTabPrefixLengths);
|
||||||
|
|
||||||
|
const linesWithoutCommonTabPrefix = stringLines.map((line) => line.substring(commonTabPrefixLength));
|
||||||
|
const multiLineString = linesWithoutCommonTabPrefix.join("\n");
|
||||||
|
|
||||||
|
return multiLineString.trim();
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ graphene = { path = "../../graphene", package = "graphite-graphene" }
|
|||||||
log = "0.4"
|
log = "0.4"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
wasm-bindgen = { version = "0.2.73", features = ["serde-serialize"] }
|
wasm-bindgen = { version = "0.2.73", features = ["serde-serialize"] }
|
||||||
|
js-sys = "0.3.55"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
wasm-bindgen-test = "0.3.22"
|
wasm-bindgen-test = "0.3.22"
|
||||||
|
|||||||
+421
-402
@@ -2,427 +2,446 @@
|
|||||||
// It serves as a thin wrapper over the editor backend API that relies
|
// It serves as a thin wrapper over the editor backend API that relies
|
||||||
// on the dispatcher messaging system and more complex Rust data types.
|
// on the dispatcher messaging system and more complex Rust data types.
|
||||||
|
|
||||||
use crate::dispatch;
|
use std::cell::{Cell, UnsafeCell};
|
||||||
|
|
||||||
use crate::helpers::Error;
|
use crate::helpers::Error;
|
||||||
use crate::type_translators::{translate_blend_mode, translate_key, translate_tool_type};
|
use crate::type_translators::{translate_blend_mode, translate_key, translate_tool_type};
|
||||||
|
use crate::EDITOR_HAS_CRASHED;
|
||||||
use editor::consts::FILE_SAVE_SUFFIX;
|
use editor::consts::FILE_SAVE_SUFFIX;
|
||||||
use editor::input::input_preprocessor::ModifierKeys;
|
use editor::input::input_preprocessor::ModifierKeys;
|
||||||
use editor::input::mouse::{EditorMouseState, ScrollDelta, ViewportBounds};
|
use editor::input::mouse::{EditorMouseState, ScrollDelta, ViewportBounds};
|
||||||
|
use editor::message_prelude::*;
|
||||||
use editor::misc::EditorError;
|
use editor::misc::EditorError;
|
||||||
use editor::tool::{tool_options::ToolOptions, tools, ToolType};
|
use editor::tool::{tool_options::ToolOptions, tools, ToolType};
|
||||||
|
use editor::Color;
|
||||||
use editor::LayerId;
|
use editor::LayerId;
|
||||||
use editor::{message_prelude::*, Color};
|
|
||||||
use wasm_bindgen::prelude::*;
|
use wasm_bindgen::prelude::*;
|
||||||
|
|
||||||
/// Intentionally panic for testing purposes
|
// To avoid wasm-bindgen from checking mutable reference issues using WasmRefCell
|
||||||
|
// we must make all methods take a non mutable reference to self. Not doing this creates
|
||||||
|
// an issue when rust calls into JS which calls back to rust in the same call stack.
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
pub fn intentional_panic() {
|
pub struct Editor {
|
||||||
panic!();
|
editor: UnsafeCell<editor::Editor>,
|
||||||
|
instance_received_crashed: Cell<bool>,
|
||||||
|
handle_response: js_sys::Function,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
impl Editor {
|
||||||
|
#[wasm_bindgen(constructor)]
|
||||||
|
pub fn new(handle_response: js_sys::Function) -> Editor {
|
||||||
|
Editor {
|
||||||
|
editor: UnsafeCell::new(editor::Editor::new()),
|
||||||
|
instance_received_crashed: Cell::new(false),
|
||||||
|
handle_response,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sends a message to the dispatcher in the Editor Backend
|
||||||
|
fn dispatch<T: Into<Message>>(&self, message: T) {
|
||||||
|
// Process no further messages after a crash to avoid spamming the console
|
||||||
|
let has_crashed = EDITOR_HAS_CRASHED.with(|crash_state| crash_state.borrow().clone());
|
||||||
|
if let Some(message) = has_crashed {
|
||||||
|
if !self.instance_received_crashed.get() {
|
||||||
|
self.handle_response(message);
|
||||||
|
self.instance_received_crashed.set(true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let editor = unsafe { self.editor.get().as_mut().unwrap() };
|
||||||
|
// Dispatch the message and receive a vector of FrontendMessage responses
|
||||||
|
let responses = editor.handle_message(message.into());
|
||||||
|
for response in responses.into_iter() {
|
||||||
|
// Send each FrontendMessage to the JavaScript frontend
|
||||||
|
self.handle_response(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sends a FrontendMessage to JavaScript
|
||||||
|
fn handle_response(&self, message: FrontendMessage) {
|
||||||
|
let message_type = message.to_discriminant().local_name();
|
||||||
|
let message_data = JsValue::from_serde(&message).expect("Failed to serialize FrontendMessage");
|
||||||
|
|
||||||
|
let js_return_value = self.handle_response.call2(&JsValue::null(), &JsValue::from(message_type), &message_data);
|
||||||
|
|
||||||
|
if let Err(error) = js_return_value {
|
||||||
|
log::error!(
|
||||||
|
"While handling FrontendMessage \"{:?}\", JavaScript threw an error: {:?}",
|
||||||
|
message.to_discriminant().local_name(),
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Modify the currently selected tool in the document state store
|
||||||
|
pub fn select_tool(&self, tool: String) -> Result<(), JsValue> {
|
||||||
|
match translate_tool_type(&tool) {
|
||||||
|
Some(tool) => {
|
||||||
|
let message = ToolMessage::ActivateTool(tool);
|
||||||
|
self.dispatch(message);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
None => Err(Error::new(&format!("Couldn't select {} because it was not recognized as a valid tool", tool)).into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update the options for a given tool
|
||||||
|
pub fn set_tool_options(&self, tool: String, options: &JsValue) -> Result<(), JsValue> {
|
||||||
|
match options.into_serde::<ToolOptions>() {
|
||||||
|
Ok(options) => match translate_tool_type(&tool) {
|
||||||
|
Some(tool) => {
|
||||||
|
let message = ToolMessage::SetToolOptions(tool, options);
|
||||||
|
self.dispatch(message);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
None => Err(Error::new(&format!("Couldn't set options for {} because it was not recognized as a valid tool", tool)).into()),
|
||||||
|
},
|
||||||
|
Err(err) => Err(Error::new(&format!("Invalid JSON for ToolOptions: {}", err)).into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a message to a given tool
|
||||||
|
pub fn send_tool_message(&self, tool: String, message: &JsValue) -> Result<(), JsValue> {
|
||||||
|
let tool_message = match translate_tool_type(&tool) {
|
||||||
|
Some(tool) => match tool {
|
||||||
|
ToolType::Select => match message.into_serde::<tools::select::SelectMessage>() {
|
||||||
|
Ok(select_message) => Ok(ToolMessage::Select(select_message)),
|
||||||
|
Err(err) => Err(Error::new(&format!("Invalid message for {}: {}", tool, err)).into()),
|
||||||
|
},
|
||||||
|
_ => Err(Error::new(&format!("Tool message sending not implemented for {}", tool)).into()),
|
||||||
|
},
|
||||||
|
None => Err(Error::new(&format!("Couldn't send message for {} because it was not recognized as a valid tool", tool)).into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
match tool_message {
|
||||||
|
Ok(message) => {
|
||||||
|
self.dispatch(message);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(err) => Err(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select_document(&self, document: usize) {
|
||||||
|
let message = DocumentsMessage::SelectDocument(document);
|
||||||
|
self.dispatch(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_open_documents_list(&self) {
|
||||||
|
let message = DocumentsMessage::UpdateOpenDocumentsList;
|
||||||
|
self.dispatch(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_document(&self) {
|
||||||
|
let message = DocumentsMessage::NewDocument;
|
||||||
|
self.dispatch(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn open_document(&self) {
|
||||||
|
let message = DocumentsMessage::OpenDocument;
|
||||||
|
self.dispatch(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn open_document_file(&self, name: String, content: String) {
|
||||||
|
let message = DocumentsMessage::OpenDocumentFile(name, content);
|
||||||
|
self.dispatch(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_document(&self) {
|
||||||
|
let message = DocumentMessage::SaveDocument;
|
||||||
|
self.dispatch(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn close_document(&self, document: usize) {
|
||||||
|
let message = DocumentsMessage::CloseDocument(document);
|
||||||
|
self.dispatch(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn close_all_documents(&self) {
|
||||||
|
let message = DocumentsMessage::CloseAllDocuments;
|
||||||
|
self.dispatch(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn close_active_document_with_confirmation(&self) {
|
||||||
|
let message = DocumentsMessage::CloseActiveDocumentWithConfirmation;
|
||||||
|
self.dispatch(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn close_all_documents_with_confirmation(&self) {
|
||||||
|
let message = DocumentsMessage::CloseAllDocumentsWithConfirmation;
|
||||||
|
self.dispatch(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn request_about_graphite_dialog(&self) {
|
||||||
|
let message = DocumentsMessage::RequestAboutGraphiteDialog;
|
||||||
|
self.dispatch(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send new bounds when document panel viewports get resized or moved within the editor
|
||||||
|
/// [left, top, right, bottom]...
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn bounds_of_viewports(&self, bounds_of_viewports: &[f64]) {
|
||||||
|
let chunked: Vec<_> = bounds_of_viewports.chunks(4).map(ViewportBounds::from_slice).collect();
|
||||||
|
|
||||||
|
let message = InputPreprocessorMessage::BoundsOfViewports(chunked);
|
||||||
|
self.dispatch(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mouse movement within the screenspace bounds of the viewport
|
||||||
|
pub fn on_mouse_move(&self, x: f64, y: f64, mouse_keys: u8, modifiers: u8) {
|
||||||
|
let editor_mouse_state = EditorMouseState::from_keys_and_editor_position(mouse_keys, (x, y).into());
|
||||||
|
|
||||||
|
let modifier_keys = ModifierKeys::from_bits(modifiers).expect("Invalid modifier keys");
|
||||||
|
|
||||||
|
let message = InputPreprocessorMessage::MouseMove(editor_mouse_state, modifier_keys);
|
||||||
|
self.dispatch(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mouse scrolling within the screenspace bounds of the viewport
|
||||||
|
pub fn on_mouse_scroll(&self, x: f64, y: f64, mouse_keys: u8, wheel_delta_x: i32, wheel_delta_y: i32, wheel_delta_z: i32, modifiers: u8) {
|
||||||
|
let mut editor_mouse_state = EditorMouseState::from_keys_and_editor_position(mouse_keys, (x, y).into());
|
||||||
|
editor_mouse_state.scroll_delta = ScrollDelta::new(wheel_delta_x, wheel_delta_y, wheel_delta_z);
|
||||||
|
|
||||||
|
let modifier_keys = ModifierKeys::from_bits(modifiers).expect("Invalid modifier keys");
|
||||||
|
|
||||||
|
let message = InputPreprocessorMessage::MouseScroll(editor_mouse_state, modifier_keys);
|
||||||
|
self.dispatch(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A mouse button depressed within screenspace the bounds of the viewport
|
||||||
|
pub fn on_mouse_down(&self, x: f64, y: f64, mouse_keys: u8, modifiers: u8) {
|
||||||
|
let editor_mouse_state = EditorMouseState::from_keys_and_editor_position(mouse_keys, (x, y).into());
|
||||||
|
|
||||||
|
let modifier_keys = ModifierKeys::from_bits(modifiers).expect("Invalid modifier keys");
|
||||||
|
|
||||||
|
let message = InputPreprocessorMessage::MouseDown(editor_mouse_state, modifier_keys);
|
||||||
|
self.dispatch(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A mouse button released
|
||||||
|
pub fn on_mouse_up(&self, x: f64, y: f64, mouse_keys: u8, modifiers: u8) {
|
||||||
|
let editor_mouse_state = EditorMouseState::from_keys_and_editor_position(mouse_keys, (x, y).into());
|
||||||
|
|
||||||
|
let modifier_keys = ModifierKeys::from_bits(modifiers).expect("Invalid modifier keys");
|
||||||
|
|
||||||
|
let message = InputPreprocessorMessage::MouseUp(editor_mouse_state, modifier_keys);
|
||||||
|
self.dispatch(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A keyboard button depressed within screenspace the bounds of the viewport
|
||||||
|
pub fn on_key_down(&self, name: String, modifiers: u8) {
|
||||||
|
let key = translate_key(&name);
|
||||||
|
let modifiers = ModifierKeys::from_bits(modifiers).expect("Invalid modifier keys");
|
||||||
|
|
||||||
|
log::trace!("Key down {:?}, name: {}, modifiers: {:?}", key, name, modifiers);
|
||||||
|
|
||||||
|
let message = InputPreprocessorMessage::KeyDown(key, modifiers);
|
||||||
|
self.dispatch(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A keyboard button released
|
||||||
|
pub fn on_key_up(&self, name: String, modifiers: u8) {
|
||||||
|
let key = translate_key(&name);
|
||||||
|
let modifiers = ModifierKeys::from_bits(modifiers).expect("Invalid modifier keys");
|
||||||
|
|
||||||
|
log::trace!("Key up {:?}, name: {}, modifiers: {:?}", key, name, modifiers);
|
||||||
|
|
||||||
|
let message = InputPreprocessorMessage::KeyUp(key, modifiers);
|
||||||
|
self.dispatch(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update primary color
|
||||||
|
pub fn update_primary_color(&self, red: f32, green: f32, blue: f32, alpha: f32) -> Result<(), JsValue> {
|
||||||
|
let primary_color = match Color::from_rgbaf32(red, green, blue, alpha) {
|
||||||
|
Some(color) => color,
|
||||||
|
None => return Err(Error::new("Invalid color").into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let message = ToolMessage::SelectPrimaryColor(primary_color);
|
||||||
|
self.dispatch(message);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update secondary color
|
||||||
|
pub fn update_secondary_color(&self, red: f32, green: f32, blue: f32, alpha: f32) -> Result<(), JsValue> {
|
||||||
|
let secondary_color = match Color::from_rgbaf32(red, green, blue, alpha) {
|
||||||
|
Some(color) => color,
|
||||||
|
None => return Err(Error::new("Invalid color").into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let message = ToolMessage::SelectSecondaryColor(secondary_color);
|
||||||
|
self.dispatch(message);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Swap primary and secondary color
|
||||||
|
pub fn swap_colors(&self) {
|
||||||
|
let message = ToolMessage::SwapColors;
|
||||||
|
self.dispatch(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset primary and secondary colors to their defaults
|
||||||
|
pub fn reset_colors(&self) {
|
||||||
|
let message = ToolMessage::ResetColors;
|
||||||
|
self.dispatch(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Undo history one step
|
||||||
|
pub fn undo(&self) {
|
||||||
|
let message = DocumentMessage::Undo;
|
||||||
|
self.dispatch(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Redo history one step
|
||||||
|
pub fn redo(&self) {
|
||||||
|
let message = DocumentMessage::Redo;
|
||||||
|
self.dispatch(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Select all layers
|
||||||
|
pub fn select_all_layers(&self) {
|
||||||
|
let message = DocumentMessage::SelectAllLayers;
|
||||||
|
self.dispatch(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deselect all layers
|
||||||
|
pub fn deselect_all_layers(&self) {
|
||||||
|
let message = DocumentMessage::DeselectAllLayers;
|
||||||
|
self.dispatch(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reorder selected layer
|
||||||
|
pub fn reorder_selected_layers(&self, delta: i32) {
|
||||||
|
let message = DocumentMessage::ReorderSelectedLayers(delta);
|
||||||
|
self.dispatch(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the blend mode for the selected layers
|
||||||
|
pub fn set_blend_mode_for_selected_layers(&self, blend_mode_svg_style_name: String) -> Result<(), JsValue> {
|
||||||
|
let blend_mode = translate_blend_mode(blend_mode_svg_style_name.as_str());
|
||||||
|
|
||||||
|
match blend_mode {
|
||||||
|
Some(mode) => {
|
||||||
|
let message = DocumentMessage::SetBlendModeForSelectedLayers(mode);
|
||||||
|
self.dispatch(message);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
None => Err(Error::new(&EditorError::Misc("UnknownBlendMode".to_string()).to_string()).into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the opacity for the selected layers
|
||||||
|
pub fn set_opacity_for_selected_layers(&self, opacity_percent: f64) {
|
||||||
|
let message = DocumentMessage::SetOpacityForSelectedLayers(opacity_percent / 100.);
|
||||||
|
self.dispatch(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Export the document
|
||||||
|
pub fn export_document(&self) {
|
||||||
|
let message = DocumentMessage::ExportDocument;
|
||||||
|
self.dispatch(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set snapping disabled / enabled
|
||||||
|
pub fn set_snapping(&self, new_status: bool) {
|
||||||
|
let message = DocumentMessage::SetSnapping(new_status);
|
||||||
|
self.dispatch(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the zoom to the value
|
||||||
|
pub fn set_canvas_zoom(&self, new_zoom: f64) {
|
||||||
|
let message = MovementMessage::SetCanvasZoom(new_zoom);
|
||||||
|
self.dispatch(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zoom in to the next step
|
||||||
|
pub fn increase_canvas_zoom(&self) {
|
||||||
|
let message = MovementMessage::IncreaseCanvasZoom;
|
||||||
|
self.dispatch(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zoom out to the next step
|
||||||
|
pub fn decrease_canvas_zoom(&self) {
|
||||||
|
let message = MovementMessage::DecreaseCanvasZoom;
|
||||||
|
self.dispatch(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the rotation to the new value (in radians)
|
||||||
|
pub fn set_rotation(&self, new_radians: f64) {
|
||||||
|
let message = MovementMessage::SetCanvasRotation(new_radians);
|
||||||
|
self.dispatch(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Translates document (in viewport coords)
|
||||||
|
pub fn translate_canvas(&self, delta_x: f64, delta_y: f64) {
|
||||||
|
let message = MovementMessage::TranslateCanvas((delta_x, delta_y).into());
|
||||||
|
self.dispatch(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Translates document (in viewport coords)
|
||||||
|
pub fn translate_canvas_by_fraction(&self, delta_x: f64, delta_y: f64) {
|
||||||
|
let message = MovementMessage::TranslateCanvasByViewportFraction((delta_x, delta_y).into());
|
||||||
|
self.dispatch(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update the list of selected layers. The layer paths have to be stored in one array and are separated by LayerId::MAX
|
||||||
|
pub fn select_layers(&self, paths: Vec<LayerId>) {
|
||||||
|
let paths = paths.split(|id| *id == LayerId::MAX).map(|path| path.to_vec()).collect();
|
||||||
|
|
||||||
|
let message = DocumentMessage::SetSelectedLayers(paths);
|
||||||
|
self.dispatch(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle visibility of a layer from the layer list
|
||||||
|
pub fn toggle_layer_visibility(&self, path: Vec<LayerId>) {
|
||||||
|
let message = DocumentMessage::ToggleLayerVisibility(path);
|
||||||
|
self.dispatch(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle expansions state of a layer from the layer list
|
||||||
|
pub fn toggle_layer_expansion(&self, path: Vec<LayerId>) {
|
||||||
|
let message = DocumentMessage::ToggleLayerExpansion(path);
|
||||||
|
self.dispatch(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renames a layer from the layer list
|
||||||
|
pub fn rename_layer(&self, path: Vec<LayerId>, new_name: String) {
|
||||||
|
let message = DocumentMessage::RenameLayer(path, new_name);
|
||||||
|
self.dispatch(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes a layer from the layer list
|
||||||
|
pub fn delete_layer(&self, path: Vec<LayerId>) {
|
||||||
|
let message = DocumentMessage::DeleteLayer(path);
|
||||||
|
self.dispatch(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Requests the backend to add a layer to the layer list
|
||||||
|
pub fn add_folder(&self, path: Vec<LayerId>) {
|
||||||
|
let message = DocumentMessage::CreateFolder(path);
|
||||||
|
self.dispatch(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Access a handle to WASM memory
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
pub fn wasm_memory() -> JsValue {
|
pub fn wasm_memory() -> JsValue {
|
||||||
wasm_bindgen::memory()
|
wasm_bindgen::memory()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Modify the currently selected tool in the document state store
|
/// Intentionally panic for debugging purposes
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
pub fn select_tool(tool: String) -> Result<(), JsValue> {
|
pub fn intentional_panic() {
|
||||||
match translate_tool_type(&tool) {
|
panic!();
|
||||||
Some(tool) => {
|
|
||||||
let message = ToolMessage::ActivateTool(tool);
|
|
||||||
dispatch(message);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
None => Err(Error::new(&format!("Couldn't select {} because it was not recognized as a valid tool", tool)).into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update the options for a given tool
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn set_tool_options(tool: String, options: &JsValue) -> Result<(), JsValue> {
|
|
||||||
match options.into_serde::<ToolOptions>() {
|
|
||||||
Ok(options) => match translate_tool_type(&tool) {
|
|
||||||
Some(tool) => {
|
|
||||||
let message = ToolMessage::SetToolOptions(tool, options);
|
|
||||||
dispatch(message);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
None => Err(Error::new(&format!("Couldn't set options for {} because it was not recognized as a valid tool", tool)).into()),
|
|
||||||
},
|
|
||||||
Err(err) => Err(Error::new(&format!("Invalid JSON for ToolOptions: {}", err)).into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send a message to a given tool
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn send_tool_message(tool: String, message: &JsValue) -> Result<(), JsValue> {
|
|
||||||
let tool_message = match translate_tool_type(&tool) {
|
|
||||||
Some(tool) => match tool {
|
|
||||||
ToolType::Select => match message.into_serde::<tools::select::SelectMessage>() {
|
|
||||||
Ok(select_message) => Ok(ToolMessage::Select(select_message)),
|
|
||||||
Err(err) => Err(Error::new(&format!("Invalid message for {}: {}", tool, err)).into()),
|
|
||||||
},
|
|
||||||
_ => Err(Error::new(&format!("Tool message sending not implemented for {}", tool)).into()),
|
|
||||||
},
|
|
||||||
None => Err(Error::new(&format!("Couldn't send message for {} because it was not recognized as a valid tool", tool)).into()),
|
|
||||||
};
|
|
||||||
|
|
||||||
match tool_message {
|
|
||||||
Ok(message) => {
|
|
||||||
dispatch(message);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Err(err) => Err(err),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn select_document(document: usize) {
|
|
||||||
let message = DocumentsMessage::SelectDocument(document);
|
|
||||||
dispatch(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn get_open_documents_list() {
|
|
||||||
let message = DocumentsMessage::UpdateOpenDocumentsList;
|
|
||||||
dispatch(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn new_document() {
|
|
||||||
let message = DocumentsMessage::NewDocument;
|
|
||||||
dispatch(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn open_document() {
|
|
||||||
let message = DocumentsMessage::OpenDocument;
|
|
||||||
dispatch(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn open_document_file(name: String, content: String) {
|
|
||||||
let message = DocumentsMessage::OpenDocumentFile(name, content);
|
|
||||||
dispatch(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn save_document() {
|
|
||||||
let message = DocumentMessage::SaveDocument;
|
|
||||||
dispatch(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn close_document(document: usize) {
|
|
||||||
let message = DocumentsMessage::CloseDocument(document);
|
|
||||||
dispatch(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn close_all_documents() {
|
|
||||||
let message = DocumentsMessage::CloseAllDocuments;
|
|
||||||
dispatch(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn close_active_document_with_confirmation() {
|
|
||||||
let message = DocumentsMessage::CloseActiveDocumentWithConfirmation;
|
|
||||||
dispatch(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn close_all_documents_with_confirmation() {
|
|
||||||
let message = DocumentsMessage::CloseAllDocumentsWithConfirmation;
|
|
||||||
dispatch(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn request_about_graphite_dialog() {
|
|
||||||
let message = DocumentsMessage::RequestAboutGraphiteDialog;
|
|
||||||
dispatch(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send new bounds when document panel viewports get resized or moved within the editor
|
|
||||||
/// [left, top, right, bottom]...
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn bounds_of_viewports(bounds_of_viewports: &[f64]) {
|
|
||||||
let chunked: Vec<_> = bounds_of_viewports.chunks(4).map(ViewportBounds::from_slice).collect();
|
|
||||||
|
|
||||||
let message = InputPreprocessorMessage::BoundsOfViewports(chunked);
|
|
||||||
dispatch(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Mouse movement within the screenspace bounds of the viewport
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn on_mouse_move(x: f64, y: f64, mouse_keys: u8, modifiers: u8) {
|
|
||||||
let editor_mouse_state = EditorMouseState::from_keys_and_editor_position(mouse_keys, (x, y).into());
|
|
||||||
|
|
||||||
let modifier_keys = ModifierKeys::from_bits(modifiers).expect("Invalid modifier keys");
|
|
||||||
|
|
||||||
let message = InputPreprocessorMessage::MouseMove(editor_mouse_state, modifier_keys);
|
|
||||||
dispatch(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Mouse scrolling within the screenspace bounds of the viewport
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn on_mouse_scroll(x: f64, y: f64, mouse_keys: u8, wheel_delta_x: i32, wheel_delta_y: i32, wheel_delta_z: i32, modifiers: u8) {
|
|
||||||
let mut editor_mouse_state = EditorMouseState::from_keys_and_editor_position(mouse_keys, (x, y).into());
|
|
||||||
editor_mouse_state.scroll_delta = ScrollDelta::new(wheel_delta_x, wheel_delta_y, wheel_delta_z);
|
|
||||||
|
|
||||||
let modifier_keys = ModifierKeys::from_bits(modifiers).expect("Invalid modifier keys");
|
|
||||||
|
|
||||||
let message = InputPreprocessorMessage::MouseScroll(editor_mouse_state, modifier_keys);
|
|
||||||
dispatch(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A mouse button depressed within screenspace the bounds of the viewport
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn on_mouse_down(x: f64, y: f64, mouse_keys: u8, modifiers: u8) {
|
|
||||||
let editor_mouse_state = EditorMouseState::from_keys_and_editor_position(mouse_keys, (x, y).into());
|
|
||||||
|
|
||||||
let modifier_keys = ModifierKeys::from_bits(modifiers).expect("Invalid modifier keys");
|
|
||||||
|
|
||||||
let message = InputPreprocessorMessage::MouseDown(editor_mouse_state, modifier_keys);
|
|
||||||
dispatch(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A mouse button released
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn on_mouse_up(x: f64, y: f64, mouse_keys: u8, modifiers: u8) {
|
|
||||||
let editor_mouse_state = EditorMouseState::from_keys_and_editor_position(mouse_keys, (x, y).into());
|
|
||||||
|
|
||||||
let modifier_keys = ModifierKeys::from_bits(modifiers).expect("Invalid modifier keys");
|
|
||||||
|
|
||||||
let message = InputPreprocessorMessage::MouseUp(editor_mouse_state, modifier_keys);
|
|
||||||
dispatch(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A keyboard button depressed within screenspace the bounds of the viewport
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn on_key_down(name: String, modifiers: u8) {
|
|
||||||
let key = translate_key(&name);
|
|
||||||
let modifiers = ModifierKeys::from_bits(modifiers).expect("Invalid modifier keys");
|
|
||||||
|
|
||||||
log::trace!("Key down {:?}, name: {}, modifiers: {:?}", key, name, modifiers);
|
|
||||||
|
|
||||||
let message = InputPreprocessorMessage::KeyDown(key, modifiers);
|
|
||||||
dispatch(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A keyboard button released
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn on_key_up(name: String, modifiers: u8) {
|
|
||||||
let key = translate_key(&name);
|
|
||||||
let modifiers = ModifierKeys::from_bits(modifiers).expect("Invalid modifier keys");
|
|
||||||
|
|
||||||
log::trace!("Key up {:?}, name: {}, modifiers: {:?}", key, name, modifiers);
|
|
||||||
|
|
||||||
let message = InputPreprocessorMessage::KeyUp(key, modifiers);
|
|
||||||
dispatch(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update primary color
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn update_primary_color(red: f32, green: f32, blue: f32, alpha: f32) -> Result<(), JsValue> {
|
|
||||||
let primary_color = match Color::from_rgbaf32(red, green, blue, alpha) {
|
|
||||||
Some(color) => color,
|
|
||||||
None => return Err(Error::new("Invalid color").into()),
|
|
||||||
};
|
|
||||||
|
|
||||||
let message = ToolMessage::SelectPrimaryColor(primary_color);
|
|
||||||
dispatch(message);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update secondary color
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn update_secondary_color(red: f32, green: f32, blue: f32, alpha: f32) -> Result<(), JsValue> {
|
|
||||||
let secondary_color = match Color::from_rgbaf32(red, green, blue, alpha) {
|
|
||||||
Some(color) => color,
|
|
||||||
None => return Err(Error::new("Invalid color").into()),
|
|
||||||
};
|
|
||||||
|
|
||||||
let message = ToolMessage::SelectSecondaryColor(secondary_color);
|
|
||||||
dispatch(message);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Swap primary and secondary color
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn swap_colors() {
|
|
||||||
let message = ToolMessage::SwapColors;
|
|
||||||
dispatch(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reset primary and secondary colors to their defaults
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn reset_colors() {
|
|
||||||
let message = ToolMessage::ResetColors;
|
|
||||||
dispatch(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Undo history one step
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn undo() {
|
|
||||||
let message = DocumentMessage::Undo;
|
|
||||||
dispatch(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Redo history one step
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn redo() {
|
|
||||||
let message = DocumentMessage::Redo;
|
|
||||||
dispatch(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Select all layers
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn select_all_layers() {
|
|
||||||
let message = DocumentMessage::SelectAllLayers;
|
|
||||||
dispatch(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Deselect all layers
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn deselect_all_layers() {
|
|
||||||
let message = DocumentMessage::DeselectAllLayers;
|
|
||||||
dispatch(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reorder selected layer
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn reorder_selected_layers(delta: i32) {
|
|
||||||
let message = DocumentMessage::ReorderSelectedLayers(delta);
|
|
||||||
dispatch(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the blend mode for the selected layers
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn set_blend_mode_for_selected_layers(blend_mode_svg_style_name: String) -> Result<(), JsValue> {
|
|
||||||
let blend_mode = translate_blend_mode(blend_mode_svg_style_name.as_str());
|
|
||||||
|
|
||||||
match blend_mode {
|
|
||||||
Some(mode) => {
|
|
||||||
let message = DocumentMessage::SetBlendModeForSelectedLayers(mode);
|
|
||||||
dispatch(message);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
None => Err(Error::new(&EditorError::Misc("UnknownBlendMode".to_string()).to_string()).into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the opacity for the selected layers
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn set_opacity_for_selected_layers(opacity_percent: f64) {
|
|
||||||
let message = DocumentMessage::SetOpacityForSelectedLayers(opacity_percent / 100.);
|
|
||||||
dispatch(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Export the document
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn export_document() {
|
|
||||||
let message = DocumentMessage::ExportDocument;
|
|
||||||
dispatch(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set snapping disabled / enabled
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn set_snapping(new_status: bool) {
|
|
||||||
let message = DocumentMessage::SetSnapping(new_status);
|
|
||||||
dispatch(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets the zoom to the value
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn set_canvas_zoom(new_zoom: f64) {
|
|
||||||
let message = MovementMessage::SetCanvasZoom(new_zoom);
|
|
||||||
dispatch(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Zoom in to the next step
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn increase_canvas_zoom() {
|
|
||||||
let message = MovementMessage::IncreaseCanvasZoom;
|
|
||||||
dispatch(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Zoom out to the next step
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn decrease_canvas_zoom() {
|
|
||||||
let message = MovementMessage::DecreaseCanvasZoom;
|
|
||||||
dispatch(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets the rotation to the new value (in radians)
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn set_rotation(new_radians: f64) {
|
|
||||||
let message = MovementMessage::SetCanvasRotation(new_radians);
|
|
||||||
dispatch(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Translates document (in viewport coords)
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn translate_canvas(delta_x: f64, delta_y: f64) {
|
|
||||||
let message = MovementMessage::TranslateCanvas((delta_x, delta_y).into());
|
|
||||||
dispatch(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Translates document (in viewport coords)
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn translate_canvas_by_fraction(delta_x: f64, delta_y: f64) {
|
|
||||||
let message = MovementMessage::TranslateCanvasByViewportFraction((delta_x, delta_y).into());
|
|
||||||
dispatch(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update the list of selected layers. The layer paths have to be stored in one array and are separated by LayerId::MAX
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn select_layers(paths: Vec<LayerId>) {
|
|
||||||
let paths = paths.split(|id| *id == LayerId::MAX).map(|path| path.to_vec()).collect();
|
|
||||||
|
|
||||||
let message = DocumentMessage::SetSelectedLayers(paths);
|
|
||||||
dispatch(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Toggle visibility of a layer from the layer list
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn toggle_layer_visibility(path: Vec<LayerId>) {
|
|
||||||
let message = DocumentMessage::ToggleLayerVisibility(path);
|
|
||||||
dispatch(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Toggle expansions state of a layer from the layer list
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn toggle_layer_expansion(path: Vec<LayerId>) {
|
|
||||||
let message = DocumentMessage::ToggleLayerExpansion(path);
|
|
||||||
dispatch(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Renames a layer from the layer list
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn rename_layer(path: Vec<LayerId>, new_name: String) {
|
|
||||||
let message = DocumentMessage::RenameLayer(path, new_name);
|
|
||||||
dispatch(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Deletes a layer from the layer list
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn delete_layer(path: Vec<LayerId>) {
|
|
||||||
let message = DocumentMessage::DeleteLayer(path);
|
|
||||||
dispatch(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Requests the backend to add a layer to the layer list
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn add_folder(path: Vec<LayerId>) {
|
|
||||||
let message = DocumentMessage::CreateFolder(path);
|
|
||||||
dispatch(message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the constant FILE_SAVE_SUFFIX
|
/// Get the constant FILE_SAVE_SUFFIX
|
||||||
|
|||||||
@@ -3,17 +3,15 @@ mod helpers;
|
|||||||
pub mod logging;
|
pub mod logging;
|
||||||
pub mod type_translators;
|
pub mod type_translators;
|
||||||
|
|
||||||
use editor::{message_prelude::*, Editor};
|
use editor::message_prelude::FrontendMessage;
|
||||||
use logging::WasmLog;
|
use logging::WasmLog;
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::panic;
|
use std::panic;
|
||||||
use std::sync::atomic::AtomicBool;
|
|
||||||
use wasm_bindgen::prelude::*;
|
use wasm_bindgen::prelude::*;
|
||||||
|
|
||||||
// Set up the persistent editor backend state (the thread_local macro provides a way to initialize static variables with non-constant functions)
|
// Set up the persistent editor backend state
|
||||||
thread_local! { pub static EDITOR_STATE: RefCell<Editor> = RefCell::new(Editor::new()); }
|
|
||||||
static LOGGER: WasmLog = WasmLog;
|
static LOGGER: WasmLog = WasmLog;
|
||||||
static EDITOR_HAS_CRASHED: AtomicBool = AtomicBool::new(false);
|
thread_local! { pub static EDITOR_HAS_CRASHED: RefCell<Option<FrontendMessage>> = RefCell::new(None); }
|
||||||
|
|
||||||
// Initialize the backend
|
// Initialize the backend
|
||||||
#[wasm_bindgen(start)]
|
#[wasm_bindgen(start)]
|
||||||
@@ -30,44 +28,5 @@ fn panic_hook(info: &panic::PanicInfo) {
|
|||||||
let title = "The editor crashed — sorry about that".to_string();
|
let title = "The editor crashed — sorry about that".to_string();
|
||||||
let description = "An internal error occurred. Reload the editor to continue. Please report this by filing an issue on GitHub.".to_string();
|
let description = "An internal error occurred. Reload the editor to continue. Please report this by filing an issue on GitHub.".to_string();
|
||||||
|
|
||||||
handle_response(FrontendMessage::DisplayPanic { panic_info, title, description });
|
EDITOR_HAS_CRASHED.with(|crash_status| crash_status.borrow_mut().replace(FrontendMessage::DisplayPanic { panic_info, title, description }));
|
||||||
|
|
||||||
EDITOR_HAS_CRASHED.store(true, std::sync::atomic::Ordering::SeqCst);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sends a message to the dispatcher in the Editor Backend
|
|
||||||
fn dispatch<T: Into<Message>>(message: T) {
|
|
||||||
// Process no further messages after a crash to avoid spamming the console
|
|
||||||
if EDITOR_HAS_CRASHED.load(std::sync::atomic::Ordering::SeqCst) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dispatch the message and receive a vector of FrontendMessage responses
|
|
||||||
let responses = EDITOR_STATE.with(|state| state.try_borrow_mut().ok().map(|mut state| state.handle_message(message.into())));
|
|
||||||
for response in responses.unwrap_or_default().into_iter() {
|
|
||||||
// Send each FrontendMessage to the JavaScript frontend
|
|
||||||
handle_response(response);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sends a FrontendMessage to JavaScript
|
|
||||||
fn handle_response(message: FrontendMessage) {
|
|
||||||
let message_type = message.to_discriminant().local_name();
|
|
||||||
let message_data = JsValue::from_serde(&message).expect("Failed to serialize FrontendMessage");
|
|
||||||
|
|
||||||
let js_return_value = handleJsMessage(message_type, message_data);
|
|
||||||
if let Err(error) = js_return_value {
|
|
||||||
log::error!(
|
|
||||||
"While handling FrontendMessage \"{:?}\", JavaScript threw an error: {:?}",
|
|
||||||
message.to_discriminant().local_name(),
|
|
||||||
error,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The JavaScript function to call into with each FrontendMessage
|
|
||||||
#[wasm_bindgen(module = "/../src/utilities/js-message-dispatcher-binding.ts")]
|
|
||||||
extern "C" {
|
|
||||||
#[wasm_bindgen(catch)]
|
|
||||||
fn handleJsMessage(responseType: String, responseData: JsValue) -> Result<(), JsValue>;
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user