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

* Clean up Vue initialization-related code

* Rename folder: dispatcher -> interop

* Rename folder: state -> providers

* Comments and clarification

* Rename JS dispatcher to subscription router

* Assorted cleanup and renaming

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

* Comments

* Remove unused Vue component injects

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

* Further cleanup

* Dangerous changes

* Simplify App.vue code

* Move more disparate init code from components into managers

* Rename folder: providers -> state-providers

* Other

* Move Document panel options bar separator to backend

* Add destructors to managers to fix HMR

* Comments and code style

* Rename variable: font -> font_file_url

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

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

* Add Vue bidirectional props documentation

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

View File

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

View File

@@ -2,7 +2,6 @@
<LayoutCol class="document">
<LayoutRow class="options-bar" :scrollableX="true">
<WidgetLayout :layout="documentModeLayout" />
<Separator :type="'Section'" />
<WidgetLayout :layout="toolOptionsLayout" />
<LayoutRow class="spacer"></LayoutRow>
@@ -37,12 +36,12 @@
<LayoutCol class="canvas-area">
<div
class="canvas"
data-canvas
ref="canvas"
:style="{ cursor: canvasCursor }"
@pointerdown="(e: PointerEvent) => canvasPointerDown(e)"
@dragover="(e) => e.preventDefault()"
@drop="(e) => pasteFile(e)"
ref="canvas"
data-canvas
>
<svg class="artboards" v-html="artboardSvg" :style="{ width: canvasSvgWidth, height: canvasSvgHeight }"></svg>
<svg
@@ -221,6 +220,7 @@
<script lang="ts">
import { defineComponent, nextTick } from "vue";
import { textInputCleanup } from "@/utility-functions/keyboard-entry";
import {
UpdateDocumentArtwork,
UpdateDocumentOverlays,
@@ -235,18 +235,10 @@ import {
UpdateDocumentBarLayout,
UpdateImageData,
TriggerTextCommit,
TriggerTextCopy,
TriggerViewportResize,
DisplayRemoveEditableTextbox,
DisplayEditableTextbox,
TriggerFontLoad,
TriggerFontLoadDefault,
TriggerVisitLink,
} from "@/dispatcher/js-messages";
import { textInputCleanup } from "@/lifetime/input";
import { loadDefaultFont, setLoadDefaultFontCallback } from "@/utilities/fonts";
} from "@/wasm-communication/messages";
import LayoutCol from "@/components/layout/LayoutCol.vue";
import LayoutRow from "@/components/layout/LayoutRow.vue";
@@ -254,11 +246,10 @@ import IconButton from "@/components/widgets/buttons/IconButton.vue";
import SwatchPairInput from "@/components/widgets/inputs/SwatchPairInput.vue";
import CanvasRuler from "@/components/widgets/rulers/CanvasRuler.vue";
import PersistentScrollbar from "@/components/widgets/scrollbars/PersistentScrollbar.vue";
import Separator from "@/components/widgets/separators/Separator.vue";
import WidgetLayout from "@/components/widgets/WidgetLayout.vue";
export default defineComponent({
inject: ["editor", "dialog"],
inject: ["editor"],
methods: {
viewportResize() {
// Resize the canvas
@@ -275,11 +266,10 @@ export default defineComponent({
this.canvasSvgHeight = `${height}px`;
// Resize the rulers
const rulerHorizontal = this.$refs.rulerHorizontal as typeof CanvasRuler;
const rulerVertical = this.$refs.rulerVertical as typeof CanvasRuler;
rulerHorizontal?.handleResize();
rulerVertical?.handleResize();
rulerHorizontal?.resize();
rulerVertical?.resize();
},
pasteFile(e: DragEvent) {
const { dataTransfer } = e;
@@ -329,7 +319,7 @@ export default defineComponent({
},
},
mounted() {
this.editor.dispatcher.subscribeJsMessage(UpdateDocumentArtwork, (UpdateDocumentArtwork) => {
this.editor.subscriptions.subscribeJsMessage(UpdateDocumentArtwork, (UpdateDocumentArtwork) => {
this.artworkSvg = UpdateDocumentArtwork.svg;
nextTick((): void => {
@@ -365,50 +355,38 @@ export default defineComponent({
});
});
this.editor.dispatcher.subscribeJsMessage(UpdateDocumentOverlays, (updateDocumentOverlays) => {
this.editor.subscriptions.subscribeJsMessage(UpdateDocumentOverlays, (updateDocumentOverlays) => {
this.overlaysSvg = updateDocumentOverlays.svg;
});
this.editor.dispatcher.subscribeJsMessage(UpdateDocumentArtboards, (updateDocumentArtboards) => {
this.editor.subscriptions.subscribeJsMessage(UpdateDocumentArtboards, (updateDocumentArtboards) => {
this.artboardSvg = updateDocumentArtboards.svg;
});
this.editor.dispatcher.subscribeJsMessage(UpdateDocumentScrollbars, (updateDocumentScrollbars) => {
this.editor.subscriptions.subscribeJsMessage(UpdateDocumentScrollbars, (updateDocumentScrollbars) => {
this.scrollbarPos = updateDocumentScrollbars.position;
this.scrollbarSize = updateDocumentScrollbars.size;
this.scrollbarMultiplier = updateDocumentScrollbars.multiplier;
});
this.editor.dispatcher.subscribeJsMessage(UpdateDocumentRulers, (updateDocumentRulers) => {
this.editor.subscriptions.subscribeJsMessage(UpdateDocumentRulers, (updateDocumentRulers) => {
this.rulerOrigin = updateDocumentRulers.origin;
this.rulerSpacing = updateDocumentRulers.spacing;
this.rulerInterval = updateDocumentRulers.interval;
});
this.editor.dispatcher.subscribeJsMessage(UpdateMouseCursor, (updateMouseCursor) => {
this.editor.subscriptions.subscribeJsMessage(UpdateMouseCursor, (updateMouseCursor) => {
this.canvasCursor = updateMouseCursor.cursor;
});
this.editor.dispatcher.subscribeJsMessage(TriggerTextCommit, () => {
this.editor.subscriptions.subscribeJsMessage(TriggerTextCommit, () => {
if (this.textInput) {
const textCleaned = textInputCleanup(this.textInput.innerText);
this.editor.instance.on_change_text(textCleaned);
}
});
this.editor.dispatcher.subscribeJsMessage(TriggerFontLoad, async (triggerFontLoad) => {
const response = await fetch(triggerFontLoad.font);
const responseBuffer = await response.arrayBuffer();
this.editor.instance.on_font_load(triggerFontLoad.font, new Uint8Array(responseBuffer), false);
});
this.editor.dispatcher.subscribeJsMessage(TriggerFontLoadDefault, loadDefaultFont);
this.editor.dispatcher.subscribeJsMessage(TriggerVisitLink, async (triggerOpenLink) => {
window.open(triggerOpenLink.url, "_blank");
});
this.editor.dispatcher.subscribeJsMessage(TriggerTextCopy, (triggerTextCopy) => {
// If the Clipboard API is supported in the browser, copy text to the clipboard
navigator.clipboard?.writeText?.(triggerTextCopy.copy_text);
});
this.editor.dispatcher.subscribeJsMessage(DisplayEditableTextbox, (displayEditableTextbox) => {
this.editor.subscriptions.subscribeJsMessage(DisplayEditableTextbox, (displayEditableTextbox) => {
this.textInput = document.createElement("DIV") as HTMLDivElement;
if (displayEditableTextbox.text === "") this.textInput.textContent = "";
@@ -425,7 +403,7 @@ export default defineComponent({
};
});
this.editor.dispatcher.subscribeJsMessage(DisplayRemoveEditableTextbox, () => {
this.editor.subscriptions.subscribeJsMessage(DisplayRemoveEditableTextbox, () => {
this.textInput = undefined;
window.dispatchEvent(
new CustomEvent("modifyinputfield", {
@@ -434,25 +412,25 @@ export default defineComponent({
);
});
this.editor.dispatcher.subscribeJsMessage(UpdateDocumentModeLayout, (updateDocumentModeLayout) => {
this.editor.subscriptions.subscribeJsMessage(UpdateDocumentModeLayout, (updateDocumentModeLayout) => {
this.documentModeLayout = updateDocumentModeLayout;
});
this.editor.dispatcher.subscribeJsMessage(UpdateToolOptionsLayout, (updateToolOptionsLayout) => {
this.editor.subscriptions.subscribeJsMessage(UpdateToolOptionsLayout, (updateToolOptionsLayout) => {
this.toolOptionsLayout = updateToolOptionsLayout;
});
this.editor.dispatcher.subscribeJsMessage(UpdateDocumentBarLayout, (updateDocumentBarLayout) => {
this.editor.subscriptions.subscribeJsMessage(UpdateDocumentBarLayout, (updateDocumentBarLayout) => {
this.documentBarLayout = updateDocumentBarLayout;
});
this.editor.dispatcher.subscribeJsMessage(UpdateToolShelfLayout, (updateToolShelfLayout) => {
this.editor.subscriptions.subscribeJsMessage(UpdateToolShelfLayout, (updateToolShelfLayout) => {
this.toolShelfLayout = updateToolShelfLayout;
});
this.editor.dispatcher.subscribeJsMessage(TriggerViewportResize, this.viewportResize);
this.editor.subscriptions.subscribeJsMessage(TriggerViewportResize, this.viewportResize);
this.editor.dispatcher.subscribeJsMessage(UpdateImageData, (updateImageData) => {
this.editor.subscriptions.subscribeJsMessage(UpdateImageData, (updateImageData) => {
updateImageData.image_data.forEach(async (element) => {
// Using updateImageData.image_data.buffer returns undefined for some reason?
const blob = new Blob([new Uint8Array(element.image_data.values()).buffer], { type: element.mime });
@@ -464,30 +442,6 @@ export default defineComponent({
this.editor.instance.set_image_blob_url(element.path, url, image.width, image.height);
});
});
// Gets metadata populated in `frontend/vue.config.js`. We could potentially move this functionality in a build.rs file.
const loadBuildMetadata = (): void => {
const release = process.env.VUE_APP_RELEASE_SERIES;
let timestamp = "";
const hash = (process.env.VUE_APP_COMMIT_HASH || "").substring(0, 8);
const branch = process.env.VUE_APP_COMMIT_BRANCH;
{
const date = new Date(process.env.VUE_APP_COMMIT_DATE || "");
const dateString = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
const timeString = `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
const timezoneName = Intl.DateTimeFormat(undefined, { timeZoneName: "long" })
.formatToParts(new Date())
.find((part) => part.type === "timeZoneName");
const timezoneNameString = timezoneName?.value;
timestamp = `${dateString} ${timeString} ${timezoneNameString}`;
}
this.editor.instance.populate_build_metadata(release || "", timestamp, hash, branch || "");
};
setLoadDefaultFontCallback((font: string, data: Uint8Array) => this.editor.instance.on_font_load(font, data, true));
loadBuildMetadata();
},
data() {
return {
@@ -525,7 +479,6 @@ export default defineComponent({
LayoutRow,
LayoutCol,
SwatchPairInput,
Separator,
PersistentScrollbar,
CanvasRuler,
IconButton,

View File

@@ -263,7 +263,7 @@
<script lang="ts">
import { defineComponent } from "vue";
import { defaultWidgetLayout, UpdateDocumentLayerTreeStructure, UpdateDocumentLayerDetails, UpdateLayerTreeOptionsLayout, LayerPanelEntry } from "@/dispatcher/js-messages";
import { defaultWidgetLayout, UpdateDocumentLayerTreeStructure, UpdateDocumentLayerDetails, UpdateLayerTreeOptionsLayout, LayerPanelEntry } from "@/wasm-communication/messages";
import LayoutCol from "@/components/layout/LayoutCol.vue";
import LayoutRow from "@/components/layout/LayoutRow.vue";
@@ -471,15 +471,15 @@ export default defineComponent({
},
},
mounted() {
this.editor.dispatcher.subscribeJsMessage(UpdateDocumentLayerTreeStructure, (updateDocumentLayerTreeStructure) => {
this.editor.subscriptions.subscribeJsMessage(UpdateDocumentLayerTreeStructure, (updateDocumentLayerTreeStructure) => {
this.rebuildLayerTree(updateDocumentLayerTreeStructure);
});
this.editor.dispatcher.subscribeJsMessage(UpdateLayerTreeOptionsLayout, (updateLayerTreeOptionsLayout) => {
this.editor.subscriptions.subscribeJsMessage(UpdateLayerTreeOptionsLayout, (updateLayerTreeOptionsLayout) => {
this.layerTreeOptionsLayout = updateLayerTreeOptionsLayout;
});
this.editor.dispatcher.subscribeJsMessage(UpdateDocumentLayerDetails, (updateDocumentLayerDetails) => {
this.editor.subscriptions.subscribeJsMessage(UpdateDocumentLayerDetails, (updateDocumentLayerDetails) => {
const targetPath = updateDocumentLayerDetails.data.path;
const targetLayer = updateDocumentLayerDetails.data;

View File

@@ -350,7 +350,6 @@ const GRID_COLLAPSE_SPACING = 10;
const GRID_SIZE = 24;
export default defineComponent({
inject: ["editor"],
data() {
return {
transform: { scale: 1, x: 0, y: 0 },
@@ -467,16 +466,13 @@ export default defineComponent({
},
},
mounted() {
{
const outputPort = document.querySelectorAll(".output.port")[4] as HTMLElement;
const inputPort = document.querySelectorAll(".input.port")[1] as HTMLElement;
this.createWirePath(outputPort, inputPort, true, true);
}
{
const outputPort = document.querySelectorAll(".output.port")[6] as HTMLElement;
const inputPort = document.querySelectorAll(".input.port")[3] as HTMLElement;
this.createWirePath(outputPort, inputPort, true, false);
}
const outputPort1 = document.querySelectorAll(".output.port")[4] as HTMLElement;
const inputPort1 = document.querySelectorAll(".input.port")[1] as HTMLElement;
this.createWirePath(outputPort1, inputPort1, true, true);
const outputPort2 = document.querySelectorAll(".output.port")[6] as HTMLElement;
const inputPort2 = document.querySelectorAll(".input.port")[3] as HTMLElement;
this.createWirePath(outputPort2, inputPort2, true, false);
},
components: {
LayoutRow,

View File

@@ -42,7 +42,7 @@
<script lang="ts">
import { defineComponent } from "vue";
import { defaultWidgetLayout, UpdatePropertyPanelOptionsLayout, UpdatePropertyPanelSectionsLayout } from "@/dispatcher/js-messages";
import { defaultWidgetLayout, UpdatePropertyPanelOptionsLayout, UpdatePropertyPanelSectionsLayout } from "@/wasm-communication/messages";
import LayoutCol from "@/components/layout/LayoutCol.vue";
import LayoutRow from "@/components/layout/LayoutRow.vue";
@@ -58,10 +58,11 @@ export default defineComponent({
};
},
mounted() {
this.editor.dispatcher.subscribeJsMessage(UpdatePropertyPanelOptionsLayout, (updatePropertyPanelOptionsLayout) => {
this.editor.subscriptions.subscribeJsMessage(UpdatePropertyPanelOptionsLayout, (updatePropertyPanelOptionsLayout) => {
this.propertiesOptionsLayout = updatePropertyPanelOptionsLayout;
});
this.editor.dispatcher.subscribeJsMessage(UpdatePropertyPanelSectionsLayout, (updatePropertyPanelSectionsLayout) => {
this.editor.subscriptions.subscribeJsMessage(UpdatePropertyPanelSectionsLayout, (updatePropertyPanelSectionsLayout) => {
this.propertiesSectionsLayout = updatePropertyPanelSectionsLayout;
});
},

View File

@@ -16,7 +16,7 @@
<script lang="ts">
import { defineComponent, PropType } from "vue";
import { isWidgetColumn, isWidgetRow, isWidgetSection, LayoutRow, WidgetLayout } from "@/dispatcher/js-messages";
import { isWidgetColumn, isWidgetRow, isWidgetSection, LayoutRow, WidgetLayout } from "@/wasm-communication/messages";
import WidgetRow from "@/components/widgets/WidgetRow.vue";
import WidgetSection from "@/components/widgets/WidgetSection.vue";

View File

@@ -3,11 +3,12 @@
<template v-for="(component, index) in widgets" :key="index">
<!-- TODO: Use `<component :is="" v-bind="attributesObject"></component>` to avoid all the separate components with `v-if` -->
<CheckboxInput v-if="component.kind === 'CheckboxInput'" v-bind="component.props" @update:checked="(value: boolean) => updateLayout(component.widget_id, value)" />
<ColorInput v-if="component.kind === 'ColorInput'" v-bind="component.props" @update:value="(value: string) => updateLayout(component.widget_id, value)" />
<DropdownInput v-if="component.kind === 'DropdownInput'" v-bind="component.props" @update:selectedIndex="(value: number) => updateLayout(component.widget_id, value)" />
<ColorInput v-if="component.kind === 'ColorInput'" v-bind="component.props" v-model:open="open" @update:value="(value: string) => updateLayout(component.widget_id, value)" />
<DropdownInput v-if="component.kind === 'DropdownInput'" v-bind="component.props" v-model:open="open" @update:selectedIndex="(value: number) => updateLayout(component.widget_id, value)" />
<FontInput
v-if="component.kind === 'FontInput'"
v-bind="component.props"
v-model:open="open"
@changeFont="(value: { name: string, style: string, file: string }) => updateLayout(component.widget_id, value)"
/>
<IconButton v-if="component.kind === 'IconButton'" v-bind="component.props" :action="() => updateLayout(component.widget_id, null)" />
@@ -69,7 +70,7 @@
<script lang="ts">
import { defineComponent, PropType } from "vue";
import { WidgetColumn, WidgetRow, isWidgetColumn, isWidgetRow } from "@/dispatcher/js-messages";
import { WidgetColumn, WidgetRow, isWidgetColumn, isWidgetRow } from "@/wasm-communication/messages";
import IconButton from "@/components/widgets/buttons/IconButton.vue";
import PopoverButton from "@/components/widgets/buttons/PopoverButton.vue";
@@ -93,6 +94,11 @@ export default defineComponent({
widgetData: { type: Object as PropType<WidgetColumn | WidgetRow>, required: true },
layoutTarget: { required: true },
},
data() {
return {
open: false,
};
},
computed: {
direction() {
if (isWidgetColumn(this.widgetData)) return "column";

View File

@@ -69,7 +69,7 @@
<script lang="ts">
import { defineComponent, PropType } from "vue";
import { isWidgetRow, isWidgetSection, LayoutRow as LayoutSystemRow, WidgetSection as WidgetSectionFromJsMessages } from "@/dispatcher/js-messages";
import { isWidgetRow, isWidgetSection, LayoutRow as LayoutSystemRow, WidgetSection as WidgetSectionFromJsMessages } from "@/wasm-communication/messages";
import LayoutCol from "@/components/layout/LayoutCol.vue";
import LayoutRow from "@/components/layout/LayoutRow.vue";

View File

@@ -63,7 +63,7 @@
<script lang="ts">
import { defineComponent, PropType } from "vue";
import { IconName, IconSize } from "@/utilities/icons";
import { IconName, IconSize } from "@/utility-functions/icons";
import IconLabel from "@/components/widgets/labels/IconLabel.vue";

View File

@@ -1,7 +1,7 @@
<template>
<LayoutRow class="popover-button">
<IconButton :action="handleClick" :icon="icon" :size="16" data-hover-menu-spawner />
<FloatingMenu :type="'Popover'" :direction="'Bottom'" ref="floatingMenu">
<IconButton :action="() => onClick()" :icon="icon" :size="16" data-hover-menu-spawner />
<FloatingMenu v-model:open="open" :type="'Popover'" :direction="'Bottom'">
<slot></slot>
</FloatingMenu>
</LayoutRow>
@@ -49,7 +49,7 @@
<script lang="ts">
import { defineComponent, PropType } from "vue";
import { PopoverButtonIcon } from "@/utilities/widgets";
import { IconName } from "@/utility-functions/icons";
import LayoutRow from "@/components/layout/LayoutRow.vue";
import IconButton from "@/components/widgets/buttons/IconButton.vue";
@@ -63,11 +63,16 @@ export default defineComponent({
},
props: {
action: { type: Function as PropType<() => void>, required: false },
icon: { type: String as PropType<PopoverButtonIcon>, default: "DropdownArrow" },
icon: { type: String as PropType<IconName>, default: "DropdownArrow" },
},
data() {
return {
open: false,
};
},
methods: {
handleClick() {
(this.$refs.floatingMenu as typeof FloatingMenu).setOpen();
onClick() {
this.open = true;
this.action?.();
},

View File

@@ -0,0 +1,16 @@
// TODO: Try and get rid of the need for this file
export interface TextButtonWidget {
kind: "TextButton";
tooltip?: string;
message?: string | object;
callback?: () => void;
props: {
// `action` is used via `IconButtonWidget.callback`
label: string;
emphasized?: boolean;
disabled?: boolean;
minWidth?: number;
gapAfter?: boolean;
};
}

View File

@@ -118,9 +118,9 @@
<script lang="ts">
import { defineComponent, PropType } from "vue";
import { RGBA } from "@/dispatcher/js-messages";
import { hsvaToRgba, rgbaToHsva } from "@/utilities/color";
import { clamp } from "@/utilities/math";
import { hsvaToRgba, rgbaToHsva } from "@/utility-functions/color";
import { clamp } from "@/utility-functions/math";
import { RGBA } from "@/wasm-communication/messages";
import LayoutCol from "@/components/layout/LayoutCol.vue";
import LayoutRow from "@/components/layout/LayoutRow.vue";

View File

@@ -1,5 +1,5 @@
<template>
<FloatingMenu class="dialog-modal" :type="'Dialog'" :direction="'Center'" data-dialog-modal>
<FloatingMenu :open="true" class="dialog-modal" :type="'Dialog'" :direction="'Center'" data-dialog-modal>
<LayoutRow ref="main">
<LayoutCol class="icon-column">
<!-- `dialog.state.icon` class exists to provide special sizing in CSS to specific icons -->

View File

@@ -1,8 +1,8 @@
<template>
<div class="floating-menu" :class="[direction.toLowerCase(), type.toLowerCase()]" v-if="open || type === 'Dialog'" ref="floatingMenu">
<div class="tail" v-if="type === 'Popover'" ref="tail"></div>
<div class="floating-menu-container" ref="floatingMenuContainer">
<LayoutCol class="floating-menu-content" data-floating-menu-content :scrollableY="scrollableY" ref="floatingMenuContent" :style="floatingMenuContentStyle">
<div class="floating-menu" :class="[direction.toLowerCase(), type.toLowerCase()]" ref="floatingMenu">
<div class="tail" v-if="open && type === 'Popover'" ref="tail"></div>
<div class="floating-menu-container" v-if="open || measuringOngoing" ref="floatingMenuContainer">
<LayoutCol class="floating-menu-content" :style="{ minWidth: minWidthStyleValue }" :scrollableY="scrollableY" ref="floatingMenuContent" data-floating-menu-content>
<slot></slot>
</LayoutCol>
</div>
@@ -175,7 +175,7 @@
</style>
<script lang="ts">
import { defineComponent, PropType, StyleValue } from "vue";
import { defineComponent, PropType } from "vue";
import LayoutCol from "@/components/layout/LayoutCol.vue";
@@ -185,158 +185,190 @@ export type MenuType = "Popover" | "Dropdown" | "Dialog";
const POINTER_STRAY_DISTANCE = 100;
export default defineComponent({
emits: ["update:open", "naturalWidth"],
props: {
direction: { type: String as PropType<MenuDirection>, default: "Bottom" },
open: { type: Boolean as PropType<boolean>, required: true },
type: { type: String as PropType<MenuType>, required: true },
direction: { type: String as PropType<MenuDirection>, default: "Bottom" },
windowEdgeMargin: { type: Number as PropType<number>, default: 6 },
minWidth: { type: Number as PropType<number>, default: 0 },
scrollableY: { type: Boolean as PropType<boolean>, default: false },
minWidth: { type: Number as PropType<number>, default: 0 },
},
data() {
const containerResizeObserver = new ResizeObserver((entries) => {
const content = entries[0].target.querySelector("[data-floating-menu-content]") as HTMLElement;
content.style.minWidth = `${entries[0].contentRect.width}px`;
// The resize observer is attached to the floating menu container, which is the zero-height div of the width of the parent element's floating menu spawner.
// Since CSS doesn't let us make the floating menu (with `position: fixed`) have a 100% width of this container, we need to use JS to observe its size and
// tell the floating menu content to use it as a min-width so the floating menu is at least the width of the parent element's floating menu spawner.
// This is the opposite concern of the natural width measurement system, which gets the natural width of the floating menu content in order for the
// spawner widget to optionally set its min-size to the floating menu's natural width.
const containerResizeObserver = new ResizeObserver((entries: ResizeObserverEntry[]) => {
this.resizeObserverCallback(entries);
});
return {
open: false,
pointerStillDown: false,
measuringOngoing: false,
measuringOngoingGuard: false,
minWidthParentWidth: 0,
containerResizeObserver,
pointerStillDown: false,
workspaceBounds: new DOMRect(),
floatingMenuBounds: new DOMRect(),
floatingMenuContentBounds: new DOMRect(),
};
},
computed: {
minWidthStyleValue() {
if (this.measuringOngoing) return "0";
return `${Math.max(this.minWidth, this.minWidthParentWidth)}px`;
},
},
// Gets the client bounds of the elements and apply relevant styles to them
// TODO: Use the Vue :style attribute more whilst not causing recursive updates
updated() {
const workspace = document.querySelector("[data-workspace]");
const floatingMenuContainer = this.$refs.floatingMenuContainer as HTMLElement;
const floatingMenuContentComponent = this.$refs.floatingMenuContent as typeof LayoutCol;
const floatingMenuContent: HTMLElement | undefined = floatingMenuContentComponent?.$el;
const floatingMenu = this.$refs.floatingMenu as HTMLElement;
async updated() {
// Turning measuring on and off both cause the component to change, which causes the `updated()` Vue event to fire extraneous times (hurting performance and sometimes causing an infinite loop)
if (this.measuringOngoingGuard) return;
if (!workspace || !floatingMenuContainer || !floatingMenuContentComponent || !floatingMenuContent || !floatingMenu) return;
this.workspaceBounds = workspace.getBoundingClientRect();
this.floatingMenuBounds = floatingMenu.getBoundingClientRect();
this.floatingMenuContentBounds = floatingMenuContent.getBoundingClientRect();
const inParentFloatingMenu = Boolean(floatingMenuContainer.closest("[data-floating-menu-content]"));
if (!inParentFloatingMenu) {
// Required to correctly position content when scrolled (it has a `position: fixed` to prevent clipping)
const tailOffset = this.type === "Popover" ? 10 : 0;
if (this.direction === "Bottom") floatingMenuContent.style.top = `${tailOffset + this.floatingMenuBounds.top}px`;
if (this.direction === "Top") floatingMenuContent.style.bottom = `${tailOffset + this.floatingMenuBounds.bottom}px`;
if (this.direction === "Right") floatingMenuContent.style.left = `${tailOffset + this.floatingMenuBounds.left}px`;
if (this.direction === "Left") floatingMenuContent.style.right = `${tailOffset + this.floatingMenuBounds.right}px`;
// Required to correctly position content when scrolled (it has a `position: fixed` to prevent clipping)
const tail = this.$refs.tail as HTMLElement;
if (tail) {
if (this.direction === "Bottom") tail.style.top = `${this.floatingMenuBounds.top}px`;
if (this.direction === "Top") tail.style.bottom = `${this.floatingMenuBounds.bottom}px`;
if (this.direction === "Right") tail.style.left = `${this.floatingMenuBounds.left}px`;
if (this.direction === "Left") tail.style.right = `${this.floatingMenuBounds.right}px`;
}
}
type Edge = "Top" | "Bottom" | "Left" | "Right";
let zeroedBorderVertical: Edge | undefined;
let zeroedBorderHorizontal: Edge | undefined;
if (this.direction === "Top" || this.direction === "Bottom") {
zeroedBorderVertical = this.direction === "Top" ? "Bottom" : "Top";
if (this.floatingMenuContentBounds.left - this.windowEdgeMargin <= this.workspaceBounds.left) {
floatingMenuContent.style.left = `${this.windowEdgeMargin}px`;
if (this.workspaceBounds.left + floatingMenuContainer.getBoundingClientRect().left === 12) zeroedBorderHorizontal = "Left";
}
if (this.floatingMenuContentBounds.right + this.windowEdgeMargin >= this.workspaceBounds.right) {
floatingMenuContent.style.right = `${this.windowEdgeMargin}px`;
if (this.workspaceBounds.right - floatingMenuContainer.getBoundingClientRect().right === 12) zeroedBorderHorizontal = "Right";
}
}
if (this.direction === "Left" || this.direction === "Right") {
zeroedBorderHorizontal = this.direction === "Left" ? "Right" : "Left";
if (this.floatingMenuContentBounds.top - this.windowEdgeMargin <= this.workspaceBounds.top) {
floatingMenuContent.style.top = `${this.windowEdgeMargin}px`;
if (this.workspaceBounds.top + floatingMenuContainer.getBoundingClientRect().top === 12) zeroedBorderVertical = "Top";
}
if (this.floatingMenuContentBounds.bottom + this.windowEdgeMargin >= this.workspaceBounds.bottom) {
floatingMenuContent.style.bottom = `${this.windowEdgeMargin}px`;
if (this.workspaceBounds.bottom - floatingMenuContainer.getBoundingClientRect().bottom === 12) zeroedBorderVertical = "Bottom";
}
}
// Remove the rounded corner from the content where the tail perfectly meets the corner
if (this.type === "Popover" && this.windowEdgeMargin === 6 && zeroedBorderVertical && zeroedBorderHorizontal) {
switch (`${zeroedBorderVertical}${zeroedBorderHorizontal}`) {
case "TopLeft":
floatingMenuContent.style.borderTopLeftRadius = "0";
break;
case "TopRight":
floatingMenuContent.style.borderTopRightRadius = "0";
break;
case "BottomLeft":
floatingMenuContent.style.borderBottomLeftRadius = "0";
break;
case "BottomRight":
floatingMenuContent.style.borderBottomRightRadius = "0";
break;
default:
break;
}
}
this.positionAndStyleFloatingMenu();
},
methods: {
setOpen() {
this.open = true;
resizeObserverCallback(entries: ResizeObserverEntry[]) {
this.minWidthParentWidth = entries[0].contentRect.width;
},
setClosed() {
this.open = false;
positionAndStyleFloatingMenu() {
const workspace = document.querySelector("[data-workspace]");
const floatingMenuContainer = this.$refs.floatingMenuContainer as HTMLElement;
const floatingMenuContentComponent = this.$refs.floatingMenuContent as typeof LayoutCol;
const floatingMenuContent: HTMLElement | undefined = floatingMenuContentComponent?.$el;
const floatingMenu = this.$refs.floatingMenu as HTMLElement;
if (!workspace || !floatingMenuContainer || !floatingMenuContentComponent || !floatingMenuContent || !floatingMenu) return;
this.workspaceBounds = workspace.getBoundingClientRect();
this.floatingMenuBounds = floatingMenu.getBoundingClientRect();
this.floatingMenuContentBounds = floatingMenuContent.getBoundingClientRect();
const inParentFloatingMenu = Boolean(floatingMenuContainer.closest("[data-floating-menu-content]"));
if (!inParentFloatingMenu) {
// Required to correctly position content when scrolled (it has a `position: fixed` to prevent clipping)
const tailOffset = this.type === "Popover" ? 10 : 0;
if (this.direction === "Bottom") floatingMenuContent.style.top = `${tailOffset + this.floatingMenuBounds.top}px`;
if (this.direction === "Top") floatingMenuContent.style.bottom = `${tailOffset + this.floatingMenuBounds.bottom}px`;
if (this.direction === "Right") floatingMenuContent.style.left = `${tailOffset + this.floatingMenuBounds.left}px`;
if (this.direction === "Left") floatingMenuContent.style.right = `${tailOffset + this.floatingMenuBounds.right}px`;
// Required to correctly position tail when scrolled (it has a `position: fixed` to prevent clipping)
const tail = this.$refs.tail as HTMLElement;
if (tail) {
if (this.direction === "Bottom") tail.style.top = `${this.floatingMenuBounds.top}px`;
if (this.direction === "Top") tail.style.bottom = `${this.floatingMenuBounds.bottom}px`;
if (this.direction === "Right") tail.style.left = `${this.floatingMenuBounds.left}px`;
if (this.direction === "Left") tail.style.right = `${this.floatingMenuBounds.right}px`;
}
}
type Edge = "Top" | "Bottom" | "Left" | "Right";
let zeroedBorderVertical: Edge | undefined;
let zeroedBorderHorizontal: Edge | undefined;
if (this.direction === "Top" || this.direction === "Bottom") {
zeroedBorderVertical = this.direction === "Top" ? "Bottom" : "Top";
if (this.floatingMenuContentBounds.left - this.windowEdgeMargin <= this.workspaceBounds.left) {
floatingMenuContent.style.left = `${this.windowEdgeMargin}px`;
if (this.workspaceBounds.left + floatingMenuContainer.getBoundingClientRect().left === 12) zeroedBorderHorizontal = "Left";
}
if (this.floatingMenuContentBounds.right + this.windowEdgeMargin >= this.workspaceBounds.right) {
floatingMenuContent.style.right = `${this.windowEdgeMargin}px`;
if (this.workspaceBounds.right - floatingMenuContainer.getBoundingClientRect().right === 12) zeroedBorderHorizontal = "Right";
}
}
if (this.direction === "Left" || this.direction === "Right") {
zeroedBorderHorizontal = this.direction === "Left" ? "Right" : "Left";
if (this.floatingMenuContentBounds.top - this.windowEdgeMargin <= this.workspaceBounds.top) {
floatingMenuContent.style.top = `${this.windowEdgeMargin}px`;
if (this.workspaceBounds.top + floatingMenuContainer.getBoundingClientRect().top === 12) zeroedBorderVertical = "Top";
}
if (this.floatingMenuContentBounds.bottom + this.windowEdgeMargin >= this.workspaceBounds.bottom) {
floatingMenuContent.style.bottom = `${this.windowEdgeMargin}px`;
if (this.workspaceBounds.bottom - floatingMenuContainer.getBoundingClientRect().bottom === 12) zeroedBorderVertical = "Bottom";
}
}
// Remove the rounded corner from the content where the tail perfectly meets the corner
if (this.type === "Popover" && this.windowEdgeMargin === 6 && zeroedBorderVertical && zeroedBorderHorizontal) {
switch (`${zeroedBorderVertical}${zeroedBorderHorizontal}`) {
case "TopLeft":
floatingMenuContent.style.borderTopLeftRadius = "0";
break;
case "TopRight":
floatingMenuContent.style.borderTopRightRadius = "0";
break;
case "BottomLeft":
floatingMenuContent.style.borderBottomLeftRadius = "0";
break;
case "BottomRight":
floatingMenuContent.style.borderBottomRightRadius = "0";
break;
default:
break;
}
}
},
isOpen(): boolean {
return this.open;
},
getWidth(callback: (width: number) => void) {
this.$nextTick(() => {
// To be called by the parent component. Measures the actual width of the floating menu content element and returns it in a promise.
async measureAndEmitNaturalWidth(): Promise<void> {
// Wait for the changed content which fired the `updated()` Vue event to be put into the DOM
await this.$nextTick();
// Wait until all fonts have been loaded and rendered so measurements of content involving text are accurate
// API is experimental but supported in all browsers - https://developer.mozilla.org/en-US/docs/Web/API/FontFaceSet/ready
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (document as any).fonts.ready;
// Make the component show itself with 0 min-width so it can be measured, and wait until the values have been updated to the DOM
this.measuringOngoing = true;
this.measuringOngoingGuard = true;
await this.$nextTick();
// Only measure if the menu is visible, perhaps because a parent component with a `v-if` condition is false
let naturalWidth;
if (this.$refs.floatingMenuContent) {
// Measure the width of the floating menu content element
const floatingMenuContent: HTMLElement = (this.$refs.floatingMenuContent as typeof LayoutCol).$el;
const width = floatingMenuContent.clientWidth;
callback(width);
});
},
disableMinWidth(callback: (minWidth: string) => void) {
this.$nextTick(() => {
const floatingMenuContent: HTMLElement = (this.$refs.floatingMenuContent as typeof LayoutCol).$el;
const initialMinWidth = floatingMenuContent.style.minWidth;
floatingMenuContent.style.minWidth = "0";
callback(initialMinWidth);
});
},
enableMinWidth(minWidth: string) {
const floatingMenuContent: HTMLElement = (this.$refs.floatingMenuContent as typeof LayoutCol).$el;
floatingMenuContent.style.minWidth = minWidth;
naturalWidth = floatingMenuContent?.clientWidth;
}
// Turn off measuring mode for the component, which triggers another call to the `updated()` Vue event, so we can turn off the protection after that has happened
this.measuringOngoing = false;
await this.$nextTick();
this.measuringOngoingGuard = false;
// Emit the measured natural width to the parent
if (naturalWidth !== undefined && naturalWidth >= 0) {
this.$emit("naturalWidth", naturalWidth);
}
},
pointerMoveHandler(e: PointerEvent) {
const target = e.target as HTMLElement | undefined;
const pointerOverFloatingMenuKeepOpen = target?.closest("[data-hover-menu-keep-open]") as HTMLElement | undefined;
const pointerOverFloatingMenuSpawner = target?.closest("[data-hover-menu-spawner]") as HTMLElement | undefined;
const pointerOverOwnFloatingMenuSpawner = pointerOverFloatingMenuSpawner?.parentElement?.contains(this.$refs.floatingMenu as HTMLElement);
// Swap this open floating menu with the one created by the floating menu spawner being hovered over
if (pointerOverFloatingMenuSpawner && !pointerOverOwnFloatingMenuSpawner) {
this.setClosed();
this.$emit("update:open", false);
pointerOverFloatingMenuSpawner.click();
}
// Close the floating menu if the pointer has strayed far enough from its bounds
if (this.isPointerEventOutsideFloatingMenu(e, POINTER_STRAY_DISTANCE) && !pointerOverOwnFloatingMenuSpawner && !pointerOverFloatingMenuKeepOpen) {
// TODO: Extend this rectangle bounds check to all `data-hover-menu-keep-open` element bounds up the DOM tree since currently
// submenus disappear with zero stray distance if the cursor is further than the stray distance from only the top-level menu
this.setClosed();
this.$emit("update:open", false);
}
const eventIncludesLmb = Boolean(e.buttons & 1);
// Clean up any messes from lost pointerup events
const eventIncludesLmb = Boolean(e.buttons & 1);
if (!this.open && !eventIncludesLmb) {
this.pointerStillDown = false;
window.removeEventListener("pointerup", this.pointerUpHandler);
@@ -345,7 +377,8 @@ export default defineComponent({
pointerDownHandler(e: PointerEvent) {
// Close the floating menu if the pointer clicked outside the floating menu (but within stray distance)
if (this.isPointerEventOutsideFloatingMenu(e)) {
this.setClosed();
this.$emit("update:open", false);
// Track if the left pointer button is now down so its later click event can be canceled
const eventIsForLmb = e.button === 0;
if (eventIsForLmb) this.pointerStillDown = true;
@@ -384,6 +417,7 @@ export default defineComponent({
},
},
watch: {
// Called only when `open` is changed from outside this component (with v-model)
open(newState: boolean, oldState: boolean) {
// Switching from closed to open
if (newState && !oldState) {
@@ -396,28 +430,25 @@ export default defineComponent({
// Floating menu min-width resize observer
this.$nextTick(() => {
const floatingMenuContainer = this.$refs.floatingMenuContainer as HTMLElement;
if (floatingMenuContainer) {
this.containerResizeObserver.disconnect();
this.containerResizeObserver.observe(floatingMenuContainer);
}
if (!floatingMenuContainer) return;
// Start a new observation of the now-open floating menu
this.containerResizeObserver.disconnect();
this.containerResizeObserver.observe(floatingMenuContainer);
});
}
// Switching from open to closed
if (!newState && oldState) {
// Clean up observation of the now-closed floating menu
this.containerResizeObserver.disconnect();
window.removeEventListener("pointermove", this.pointerMoveHandler);
window.removeEventListener("pointerdown", this.pointerDownHandler);
this.containerResizeObserver.disconnect();
// The `pointerup` event is removed in `pointerMoveHandler()` and `pointerDownHandler()`
}
},
},
computed: {
floatingMenuContentStyle(): StyleValue {
return {
minWidth: this.minWidth > 0 ? `${this.minWidth}px` : "",
};
},
},
components: { LayoutCol },
});
</script>

View File

@@ -1,16 +1,24 @@
<template>
<FloatingMenu class="menu-list" :direction="direction" :type="'Dropdown'" ref="floatingMenu" :windowEdgeMargin="0" :scrollableY="scrollableY" data-hover-menu-keep-open>
<FloatingMenu
class="menu-list"
v-model:open="isOpen"
@naturalWidth="(newNaturalWidth: number) => $emit('naturalWidth', newNaturalWidth)"
:type="'Dropdown'"
:windowEdgeMargin="0"
v-bind="{ direction, scrollableY, minWidth }"
ref="floatingMenu"
data-hover-menu-keep-open
>
<template v-for="(section, sectionIndex) in entries" :key="sectionIndex">
<Separator :type="'List'" :direction="'Vertical'" v-if="sectionIndex > 0" />
<LayoutRow
v-for="(entry, entryIndex) in section"
:key="entryIndex"
class="row"
:class="{ open: isMenuEntryOpen(entry), active: entry === activeEntry }"
@click="() => handleEntryClick(entry)"
@pointerenter="() => handleEntryPointerEnter(entry)"
@pointerleave="() => handleEntryPointerLeave(entry)"
:data-hover-menu-spawner-extend="entry.children && []"
:class="{ open: isEntryOpen(entry), active: entry.label === activeEntry?.label }"
@click="() => onEntryClick(entry)"
@pointerenter="() => onEntryPointerEnter(entry)"
@pointerleave="() => onEntryPointerLeave(entry)"
>
<CheckboxInput v-if="entry.checkbox" v-model:checked="entry.checked" :outlineStyle="true" class="entry-checkbox" />
<IconLabel v-else-if="entry.icon && drawIcon" :icon="entry.icon" class="entry-icon" />
@@ -26,10 +34,12 @@
<MenuList
v-if="entry.children"
@naturalWidth="(newNaturalWidth: number) => $emit('naturalWidth', newNaturalWidth)"
:open="entry.ref?.open || false"
:direction="'TopRight'"
:entries="entry.children"
v-bind="{ defaultAction, minWidth, drawIcon, scrollableY }"
:ref="(ref: any) => setEntryRefs(entry, ref)"
:ref="(ref: typeof FloatingMenu) => ref && (entry.ref = ref)"
/>
</LayoutRow>
</template>
@@ -131,7 +141,7 @@
<script lang="ts">
import { defineComponent, PropType } from "vue";
import { IconName } from "@/utilities/icons";
import { IconName } from "@/utility-functions/icons";
import LayoutRow from "@/components/layout/LayoutRow.vue";
import FloatingMenu, { MenuDirection } from "@/components/widgets/floating-menus/FloatingMenu.vue";
@@ -160,86 +170,75 @@ const KEYBOARD_LOCK_USE_FULLSCREEN = "This hotkey is reserved by the browser, bu
const KEYBOARD_LOCK_SWITCH_BROWSER = "This hotkey is reserved by the browser, but becomes available in Chrome, Edge, and Opera which support the Keyboard.lock() API";
const MenuList = defineComponent({
emits: {
"update:activeEntry": null,
widthChanged: (width: number) => typeof width === "number",
},
inject: ["fullscreen"],
emits: ["update:open", "update:activeEntry", "naturalWidth"],
props: {
direction: { type: String as PropType<MenuDirection>, default: "Bottom" },
entries: { type: Array as PropType<SectionsOfMenuListEntries>, required: true },
activeEntry: { type: Object as PropType<MenuListEntry>, required: false },
defaultAction: { type: Function as PropType<() => void>, required: false },
open: { type: Boolean as PropType<boolean>, required: true },
direction: { type: String as PropType<MenuDirection>, default: "Bottom" },
minWidth: { type: Number as PropType<number>, default: 0 },
drawIcon: { type: Boolean as PropType<boolean>, default: false },
scrollableY: { type: Boolean as PropType<boolean>, default: false },
defaultAction: { type: Function as PropType<() => void>, required: false },
},
data() {
return {
isOpen: this.open,
keyboardLockInfoMessage: this.fullscreen.keyboardLockApiSupported ? KEYBOARD_LOCK_USE_FULLSCREEN : KEYBOARD_LOCK_SWITCH_BROWSER,
};
},
watch: {
// Called only when `open` is changed from outside this component (with v-model)
open(newOpen: boolean) {
this.isOpen = newOpen;
},
isOpen(newIsOpen: boolean) {
this.$emit("update:open", newIsOpen);
},
entries() {
const floatingMenu = this.$refs.floatingMenu as typeof FloatingMenu;
floatingMenu.measureAndEmitNaturalWidth();
},
drawIcon() {
const floatingMenu = this.$refs.floatingMenu as typeof FloatingMenu;
floatingMenu.measureAndEmitNaturalWidth();
},
},
methods: {
setEntryRefs(menuEntry: MenuListEntry, ref: typeof FloatingMenu): void {
if (ref) menuEntry.ref = ref;
},
handleEntryClick(menuEntry: MenuListEntry): void {
(this.$refs.floatingMenu as typeof FloatingMenu).setClosed();
onEntryClick(menuEntry: MenuListEntry): void {
// Toggle checkbox
// TODO: This is broken at the moment, fix it when we get rid of using `ref`
if (menuEntry.checkbox) menuEntry.checked = !menuEntry.checked;
// Call the action, or a default, if either are provided
if (menuEntry.action) menuEntry.action();
else if (this.defaultAction) this.defaultAction();
// Emit the clicked entry as the new active entry
this.$emit("update:activeEntry", menuEntry);
// Close the containing menu
if (menuEntry.ref) menuEntry.ref.isOpen = false;
this.$emit("update:open", false);
this.isOpen = false; // TODO: This is a hack for MenuBarInput submenus, remove it when we get rid of using `ref`
},
handleEntryPointerEnter(menuEntry: MenuListEntry): void {
onEntryPointerEnter(menuEntry: MenuListEntry): void {
if (!menuEntry.children?.length) return;
if (menuEntry.ref) menuEntry.ref.setOpen();
else throw new Error("The menu bar floating menu has no associated ref");
if (menuEntry.ref) menuEntry.ref.isOpen = true;
else this.$emit("update:open", true);
},
handleEntryPointerLeave(menuEntry: MenuListEntry): void {
onEntryPointerLeave(menuEntry: MenuListEntry): void {
if (!menuEntry.children?.length) return;
if (menuEntry.ref) menuEntry.ref.setClosed();
else throw new Error("The menu bar floating menu has no associated ref");
if (menuEntry.ref) menuEntry.ref.isOpen = false;
else this.$emit("update:open", false);
},
isMenuEntryOpen(menuEntry: MenuListEntry): boolean {
isEntryOpen(menuEntry: MenuListEntry): boolean {
if (!menuEntry.children?.length) return false;
if (menuEntry.ref) return menuEntry.ref.isOpen();
return false;
},
setOpen() {
(this.$refs.floatingMenu as typeof FloatingMenu).setOpen();
},
setClosed() {
(this.$refs.floatingMenu as typeof FloatingMenu).setClosed();
},
isOpen(): boolean {
const floatingMenu = this.$refs.floatingMenu as typeof FloatingMenu;
return Boolean(floatingMenu?.isOpen());
},
async measureAndReportWidth() {
// API is experimental but supported in all browsers - https://developer.mozilla.org/en-US/docs/Web/API/FontFaceSet/ready
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (document as any).fonts.ready;
const floatingMenu = this.$refs.floatingMenu as typeof FloatingMenu;
if (!floatingMenu) return;
// Save open/closed state before forcing open, if necessary, for measurement
const initiallyOpen = floatingMenu.isOpen();
if (!initiallyOpen) floatingMenu.setOpen();
floatingMenu.disableMinWidth((initialMinWidth: string) => {
floatingMenu.getWidth((width: number) => {
floatingMenu.enableMinWidth(initialMinWidth);
// Restore open/closed state if it was forced open for measurement
if (!initiallyOpen) floatingMenu.setClosed();
this.$emit("widthChanged", width);
});
});
return this.open;
},
},
computed: {
@@ -252,25 +251,6 @@ const MenuList = defineComponent({
);
},
},
mounted() {
this.measureAndReportWidth();
},
updated() {
this.measureAndReportWidth();
},
watch: {
entriesWithoutRefs: {
handler() {
this.measureAndReportWidth();
},
deep: true,
},
},
data() {
return {
keyboardLockInfoMessage: this.fullscreen.keyboardLockApiSupported ? KEYBOARD_LOCK_USE_FULLSCREEN : KEYBOARD_LOCK_SWITCH_BROWSER,
};
},
components: {
FloatingMenu,
Separator,

View File

@@ -84,7 +84,7 @@
<script lang="ts">
import { defineComponent, PropType } from "vue";
import { IconName } from "@/utilities/icons";
import { IconName } from "@/utility-functions/icons";
import LayoutRow from "@/components/layout/LayoutRow.vue";
import IconLabel from "@/components/widgets/labels/IconLabel.vue";

View File

@@ -4,8 +4,8 @@
<TextInput :value="displayValue" :label="label" :disabled="disabled || !value" @commitText="(value: string) => textInputUpdated(value)" :center="true" />
<Separator :type="'Related'" />
<LayoutRow class="swatch">
<button class="swatch-button" :class="{ 'disabled-swatch': !value }" :style="`--swatch-color: #${value}`" @click="() => menuOpen()"></button>
<FloatingMenu :type="'Popover'" :direction="'Bottom'" horizontal ref="colorFloatingMenu">
<button class="swatch-button" :class="{ 'disabled-swatch': !value }" :style="`--swatch-color: #${value}`" @click="() => $emit('update:open', true)"></button>
<FloatingMenu v-model:open="isOpen" :type="'Popover'" :direction="'Bottom'">
<ColorPicker @update:color="(color) => colorPickerUpdated(color)" :color="color" />
</FloatingMenu>
</LayoutRow>
@@ -70,7 +70,7 @@
<script lang="ts">
import { defineComponent, PropType } from "vue";
import { RGBA } from "@/dispatcher/js-messages";
import { RGBA } from "@/wasm-communication/messages";
import LayoutRow from "@/components/layout/LayoutRow.vue";
import ColorPicker from "@/components/widgets/floating-menus/ColorPicker.vue";
@@ -80,13 +80,19 @@ import TextInput from "@/components/widgets/inputs/TextInput.vue";
import Separator from "@/components/widgets/separators/Separator.vue";
export default defineComponent({
emits: ["update:value"],
emits: ["update:value", "update:open"],
props: {
value: { type: String as PropType<string | undefined>, required: true },
open: { type: Boolean as PropType<boolean>, required: true },
label: { type: String as PropType<string>, required: false },
canSetTransparent: { type: Boolean as PropType<boolean>, required: false, default: true },
disabled: { type: Boolean as PropType<boolean>, default: false },
},
data() {
return {
isOpen: false,
};
},
computed: {
color() {
if (!this.value) return { r: 0, g: 0, b: 0, a: 1 };
@@ -105,6 +111,15 @@ export default defineComponent({
return `#${shortenedIfOpaque}`;
},
},
watch: {
// Called only when `open` is changed from outside this component (with v-model)
open(newOpen: boolean) {
this.isOpen = newOpen;
},
isOpen(newIsOpen: boolean) {
this.$emit("update:open", newIsOpen);
},
},
methods: {
colorPickerUpdated(color: RGBA) {
const twoDigitHex = (value: number): string => value.toString(16).padStart(2, "0");
@@ -134,9 +149,6 @@ export default defineComponent({
this.$emit("update:value", sanitized);
},
menuOpen() {
(this.$refs.colorFloatingMenu as typeof FloatingMenu).setOpen();
},
updateEnabled(value: boolean) {
if (value) this.$emit("update:value", "000000");
else this.$emit("update:value", undefined);

View File

@@ -1,19 +1,18 @@
<template>
<LayoutRow class="dropdown-input">
<LayoutRow class="dropdown-box" :class="{ disabled }" :style="{ minWidth: `${minWidth}px` }" @click="() => clickDropdownBox()" data-hover-menu-spawner>
<LayoutRow class="dropdown-box" :class="{ disabled }" :style="{ minWidth: `${minWidth}px` }" @click="() => !disabled && (open = true)" ref="dropdownBox" data-hover-menu-spawner>
<IconLabel class="dropdown-icon" :icon="activeEntry.icon" v-if="activeEntry.icon" />
<span>{{ activeEntry.label }}</span>
<IconLabel class="dropdown-arrow" :icon="'DropdownArrow'" />
</LayoutRow>
<MenuList
v-model:activeEntry="activeEntry"
@update:activeEntry="(newActiveEntry: typeof MENU_LIST_ENTRY) => activeEntryChanged(newActiveEntry)"
@widthChanged="(newWidth: number) => onWidthChanged(newWidth)"
v-model:open="open"
@naturalWidth="(newNaturalWidth: number) => (minWidth = newNaturalWidth)"
:entries="entries"
:direction="'Bottom'"
:drawIcon="drawIcon"
:direction="'Bottom'"
:scrollableY="true"
ref="menuList"
/>
</LayoutRow>
</template>
@@ -86,16 +85,13 @@
</style>
<script lang="ts">
import { defineComponent, PropType } from "vue";
import { defineComponent, PropType, toRaw } from "vue";
import LayoutRow from "@/components/layout/LayoutRow.vue";
import MenuList, { MenuListEntry, SectionsOfMenuListEntries } from "@/components/widgets/floating-menus/MenuList.vue";
import IconLabel from "@/components/widgets/labels/IconLabel.vue";
// Satisfies Volar (https://github.com/johnsoncodehk/volar/issues/596)
declare global {
const MENU_LIST_ENTRY: MenuListEntry;
}
const DASH_ENTRY = { label: "-" };
export default defineComponent({
emits: ["update:selectedIndex"],
@@ -107,32 +103,31 @@ export default defineComponent({
},
data() {
return {
activeEntry: this.selectedIndex !== undefined ? this.entries.flat()[this.selectedIndex] : { label: "-" },
activeEntry: this.makeActiveEntry(this.selectedIndex),
open: false,
minWidth: 0,
};
},
watch: {
// Called only when `selectedIndex` is changed from outside this component (with v-model)
selectedIndex(newSelectedIndex: number | undefined) {
const entries = this.entries.flat();
selectedIndex() {
this.activeEntry = this.makeActiveEntry();
},
activeEntry(newActiveEntry: MenuListEntry) {
// `toRaw()` pulls it out of the Vue proxy
if (toRaw(newActiveEntry) === DASH_ENTRY) return;
if (newSelectedIndex !== undefined && newSelectedIndex >= 0 && newSelectedIndex < entries.length) {
this.activeEntry = entries[newSelectedIndex];
} else {
this.activeEntry = { label: "-" };
}
this.$emit("update:selectedIndex", this.entries.flat().indexOf(newActiveEntry));
},
},
methods: {
// Called only when `activeEntry` is changed from the child MenuList component via user input
activeEntryChanged(newActiveEntry: MenuListEntry) {
this.$emit("update:selectedIndex", this.entries.flat().indexOf(newActiveEntry));
},
clickDropdownBox() {
if (!this.disabled) (this.$refs.menuList as typeof MenuList).setOpen();
},
onWidthChanged(newWidth: number) {
this.minWidth = newWidth;
makeActiveEntry(): MenuListEntry {
const entries = this.entries.flat();
if (this.selectedIndex !== undefined && this.selectedIndex >= 0 && this.selectedIndex < entries.length) {
return entries[this.selectedIndex];
}
return DASH_ENTRY;
},
},
components: {

View File

@@ -1,10 +1,17 @@
<template>
<LayoutRow class="font-input">
<LayoutRow class="dropdown-box" :class="{ disabled }" :style="{ minWidth: `${minWidth}px` }" @click="() => clickDropdownBox()" data-hover-menu-spawner>
<span>{{ activeEntry.label }}</span>
<LayoutRow class="dropdown-box" :class="{ disabled }" :style="{ minWidth: `${minWidth}px` }" @click="() => !disabled && (open = true)" data-hover-menu-spawner>
<span>{{ activeEntry?.label || "" }}</span>
<IconLabel class="dropdown-arrow" :icon="'DropdownArrow'" />
</LayoutRow>
<MenuList v-model:activeEntry="activeEntry" @widthChanged="(newWidth: number) => onWidthChanged(newWidth)" :entries="entries" :direction="'Bottom'" :scrollableY="true" ref="menuList" />
<MenuList
v-model:activeEntry="activeEntry"
v-model:open="open"
@naturalWidth="(newNaturalWidth: number) => (minWidth = newNaturalWidth)"
:entries="entries"
:direction="'Bottom'"
:scrollableY="true"
/>
</LayoutRow>
</template>
@@ -78,13 +85,12 @@
<script lang="ts">
import { defineComponent, PropType } from "vue";
import { fontNames, getFontFile, getFontStyles } from "@/utilities/fonts";
import LayoutRow from "@/components/layout/LayoutRow.vue";
import MenuList, { MenuListEntry, SectionsOfMenuListEntries } from "@/components/widgets/floating-menus/MenuList.vue";
import IconLabel from "@/components/widgets/labels/IconLabel.vue";
export default defineComponent({
inject: ["fonts"],
emits: ["update:fontFamily", "update:fontStyle", "changeFont"],
props: {
fontFamily: { type: String as PropType<string>, required: true },
@@ -93,18 +99,20 @@ export default defineComponent({
isStyle: { type: Boolean as PropType<boolean>, default: false },
},
data() {
const { entries, activeEntry } = this.updateEntries();
return {
entries,
activeEntry,
open: false,
minWidth: 0,
entries: [] as SectionsOfMenuListEntries,
activeEntry: undefined as undefined | MenuListEntry,
};
},
async mounted() {
const { entries, activeEntry } = await this.updateEntries();
this.entries = entries;
this.activeEntry = activeEntry;
},
methods: {
clickDropdownBox() {
if (!this.disabled) (this.$refs.menuList as typeof MenuList).setOpen();
},
selectFont(newName: string) {
async selectFont(newName: string): Promise<void> {
let fontFamily;
let fontStyle;
@@ -117,24 +125,21 @@ export default defineComponent({
this.$emit("update:fontFamily", newName);
fontFamily = newName;
fontStyle = getFontStyles(newName)[0];
fontStyle = (await this.fonts.getFontStyles(newName))[0];
}
const fontFile = getFontFile(fontFamily, fontStyle);
this.$emit("changeFont", { fontFamily, fontStyle, fontFile });
const fontFileUrl = await this.fonts.getFontFileUrl(fontFamily, fontStyle);
this.$emit("changeFont", { fontFamily, fontStyle, fontFileUrl });
},
onWidthChanged(newWidth: number) {
this.minWidth = newWidth;
},
updateEntries(): { entries: SectionsOfMenuListEntries; activeEntry: MenuListEntry } {
const choices = this.isStyle ? getFontStyles(this.fontFamily) : fontNames();
async updateEntries(): Promise<{ entries: SectionsOfMenuListEntries; activeEntry: MenuListEntry }> {
const choices = this.isStyle ? await this.fonts.getFontStyles(this.fontFamily) : this.fonts.state.fontNames;
const selectedChoice = this.isStyle ? this.fontStyle : this.fontFamily;
let selectedEntry: MenuListEntry | undefined;
const menuListEntries = choices.map((name) => {
const result: MenuListEntry = {
label: name,
action: (): void => this.selectFont(name),
action: async (): Promise<void> => this.selectFont(name),
};
if (name === selectedChoice) selectedEntry = result;
@@ -149,13 +154,13 @@ export default defineComponent({
},
},
watch: {
fontFamily() {
const { entries, activeEntry } = this.updateEntries();
async fontFamily() {
const { entries, activeEntry } = await this.updateEntries();
this.entries = entries;
this.activeEntry = activeEntry;
},
fontStyle() {
const { entries, activeEntry } = this.updateEntries();
async fontStyle() {
const { entries, activeEntry } = await this.updateEntries();
this.entries = entries;
this.activeEntry = activeEntry;
},

View File

@@ -6,11 +6,19 @@
</div>
</div>
<div class="entry-container" v-for="(entry, index) in entries" :key="index">
<div @click="() => handleEntryClick(entry)" class="entry" :class="{ open: entry.ref?.isOpen() }" data-hover-menu-spawner>
<IconLabel :icon="entry.icon" v-if="entry.icon" />
<div @click="() => onClick(entry)" class="entry" :class="{ open: entry.ref?.open }" data-hover-menu-spawner>
<IconLabel v-if="entry.icon" :icon="entry.icon" />
<span v-if="entry.label">{{ entry.label }}</span>
</div>
<MenuList :entries="entry.children || []" :direction="'Bottom'" :minWidth="240" :drawIcon="true" :defaultAction="comingSoon" :ref="(ref: any) => setEntryRefs(entry, ref)" />
<MenuList
:open="entry.ref?.open || false"
:entries="entry.children || []"
:direction="'Bottom'"
:minWidth="240"
:drawIcon="true"
:defaultAction="() => editor.instance.request_coming_soon_dialog()"
:ref="(ref: typeof MenuList) => ref && (entry.ref = ref)"
/>
</div>
</div>
</template>
@@ -53,12 +61,12 @@
<script lang="ts">
import { defineComponent } from "vue";
import { EditorState } from "@/state/wasm-loader";
import { Editor } from "@/wasm-communication/editor";
import MenuList, { MenuListEntry, MenuListEntries } from "@/components/widgets/floating-menus/MenuList.vue";
import IconLabel from "@/components/widgets/labels/IconLabel.vue";
function makeEntries(editor: EditorState): MenuListEntries {
function makeEntries(editor: Editor): MenuListEntries {
return [
{
label: "File",
@@ -131,14 +139,14 @@ function makeEntries(editor: EditorState): MenuListEntries {
{
label: "Raise To Front",
shortcut: ["KeyControl", "KeyShift", "KeyLeftBracket"],
action: async (): Promise<void> => editor.instance.reorder_selected_layers(editor.rawWasm.i32_max()),
action: async (): Promise<void> => editor.instance.reorder_selected_layers(editor.raw.i32_max()),
},
{ label: "Raise", shortcut: ["KeyControl", "KeyRightBracket"], action: async (): Promise<void> => editor.instance.reorder_selected_layers(1) },
{ label: "Lower", shortcut: ["KeyControl", "KeyLeftBracket"], action: async (): Promise<void> => editor.instance.reorder_selected_layers(-1) },
{
label: "Lower to Back",
shortcut: ["KeyControl", "KeyShift", "KeyRightBracket"],
action: async (): Promise<void> => editor.instance.reorder_selected_layers(editor.rawWasm.i32_min()),
action: async (): Promise<void> => editor.instance.reorder_selected_layers(editor.raw.i32_min()),
},
],
],
@@ -189,7 +197,7 @@ function makeEntries(editor: EditorState): MenuListEntries {
],
],
},
{ label: "Debug: Panic (DANGER)", action: async (): Promise<void> => editor.rawWasm.intentional_panic() },
{ label: "Debug: Panic (DANGER)", action: async (): Promise<void> => editor.instance.intentional_panic() },
],
],
},
@@ -197,15 +205,13 @@ function makeEntries(editor: EditorState): MenuListEntries {
}
export default defineComponent({
inject: ["workspace", "editor", "dialog"],
inject: ["editor"],
methods: {
setEntryRefs(menuEntry: MenuListEntry, ref: typeof MenuList) {
if (ref) menuEntry.ref = ref;
},
handleEntryClick(menuEntry: MenuListEntry) {
if (menuEntry.ref) menuEntry.ref.setOpen();
onClick(menuEntry: MenuListEntry) {
if (menuEntry.ref) menuEntry.ref.isOpen = true;
else throw new Error("The menu bar floating menu has no associated ref");
},
// TODO: Move to backend
visitWebsite(url: string) {
// This method is required because `window` isn't accessible from the Vue component HTML
window.open(url, "_blank");
@@ -214,7 +220,7 @@ export default defineComponent({
data() {
return {
entries: makeEntries(this.editor),
comingSoon: (): void => this.dialog.comingSoon(),
open: false,
};
},
components: {

View File

@@ -87,10 +87,11 @@
<script lang="ts">
import { defineComponent, PropType } from "vue";
import { IncrementBehavior, IncrementDirection } from "@/utilities/widgets";
import FieldInput from "@/components/widgets/inputs/FieldInput.vue";
type IncrementBehavior = "Add" | "Multiply" | "Callback" | "None";
type IncrementDirection = "Decrease" | "Increase";
export default defineComponent({
emits: ["update:value"],
props: {

View File

@@ -37,7 +37,7 @@
<script lang="ts">
import { defineComponent, PropType } from "vue";
import { IconName } from "@/utilities/icons";
import { IconName } from "@/utility-functions/icons";
import LayoutRow from "@/components/layout/LayoutRow.vue";
import CheckboxInput from "@/components/widgets/inputs/CheckboxInput.vue";

View File

@@ -64,7 +64,7 @@
<script lang="ts">
import { defineComponent, PropType } from "vue";
import { IconName } from "@/utilities/icons";
import { IconName } from "@/utility-functions/icons";
import LayoutRow from "@/components/layout/LayoutRow.vue";
import IconLabel from "@/components/widgets/labels/IconLabel.vue";

View File

@@ -2,14 +2,14 @@
<LayoutCol class="swatch-pair">
<LayoutRow class="secondary swatch">
<button @click="() => clickSecondarySwatch()" ref="secondaryButton" data-hover-menu-spawner></button>
<FloatingMenu :type="'Popover'" :direction="'Right'" horizontal ref="secondarySwatchFloatingMenu">
<ColorPicker @update:color="(color: RGBA_) => secondaryColorChanged(color)" :color="secondaryColor" />
<FloatingMenu :type="'Popover'" :direction="'Right'" v-model:open="secondaryOpen">
<ColorPicker @update:color="(color: RGBA) => secondaryColorChanged(color)" :color="secondaryColor" />
</FloatingMenu>
</LayoutRow>
<LayoutRow class="primary swatch">
<button @click="() => clickPrimarySwatch()" ref="primaryButton" data-hover-menu-spawner></button>
<FloatingMenu :type="'Popover'" :direction="'Right'" horizontal ref="primarySwatchFloatingMenu">
<ColorPicker @update:color="(color: RGBA_) => primaryColorChanged(color)" :color="primaryColor" />
<FloatingMenu :type="'Popover'" :direction="'Right'" v-model:open="primaryOpen">
<ColorPicker @update:color="(color: RGBA) => primaryColorChanged(color)" :color="primaryColor" />
</FloatingMenu>
</LayoutRow>
</LayoutCol>
@@ -68,19 +68,14 @@
<script lang="ts">
import { defineComponent } from "vue";
import { type RGBA, UpdateWorkingColors } from "@/dispatcher/js-messages";
import { rgbaToDecimalRgba } from "@/utilities/color";
import { rgbaToDecimalRgba } from "@/utility-functions/color";
import { type RGBA, UpdateWorkingColors } from "@/wasm-communication/messages";
import LayoutCol from "@/components/layout/LayoutCol.vue";
import LayoutRow from "@/components/layout/LayoutRow.vue";
import ColorPicker from "@/components/widgets/floating-menus/ColorPicker.vue";
import FloatingMenu from "@/components/widgets/floating-menus/FloatingMenu.vue";
// Satisfies Volar (https://github.com/johnsoncodehk/volar/issues/596)
declare global {
type RGBA_ = RGBA;
}
export default defineComponent({
inject: ["editor"],
components: {
@@ -89,14 +84,22 @@ export default defineComponent({
LayoutRow,
LayoutCol,
},
data() {
return {
primaryOpen: false,
secondaryOpen: false,
primaryColor: { r: 0, g: 0, b: 0, a: 1 } as RGBA,
secondaryColor: { r: 255, g: 255, b: 255, a: 1 } as RGBA,
};
},
methods: {
clickPrimarySwatch() {
(this.$refs.primarySwatchFloatingMenu as typeof FloatingMenu).setOpen();
(this.$refs.secondarySwatchFloatingMenu as typeof FloatingMenu).setClosed();
this.primaryOpen = true;
this.secondaryOpen = false;
},
clickSecondarySwatch() {
(this.$refs.secondarySwatchFloatingMenu as typeof FloatingMenu).setOpen();
(this.$refs.primarySwatchFloatingMenu as typeof FloatingMenu).setClosed();
this.primaryOpen = false;
this.secondaryOpen = true;
},
primaryColorChanged(color: RGBA) {
this.primaryColor = color;
@@ -123,14 +126,8 @@ export default defineComponent({
this.editor.instance.update_secondary_color(color.r, color.g, color.b, color.a);
},
},
data() {
return {
primaryColor: { r: 0, g: 0, b: 0, a: 1 } as RGBA,
secondaryColor: { r: 255, g: 255, b: 255, a: 1 } as RGBA,
};
},
mounted() {
this.editor.dispatcher.subscribeJsMessage(UpdateWorkingColors, (updateWorkingColors) => {
this.editor.subscriptions.subscribeJsMessage(UpdateWorkingColors, (updateWorkingColors) => {
this.primaryColor = updateWorkingColors.primary.toRgba();
this.secondaryColor = updateWorkingColors.secondary.toRgba();

View File

@@ -35,7 +35,7 @@
<script lang="ts">
import { defineComponent, PropType } from "vue";
import { IconName, IconStyle, icons, iconComponents } from "@/utilities/icons";
import { IconName, IconStyle, icons, iconComponents } from "@/utility-functions/icons";
import LayoutRow from "@/components/layout/LayoutRow.vue";

View File

@@ -99,9 +99,8 @@
<script lang="ts">
import { defineComponent, PropType } from "vue";
import { HintInfo, KeysGroup } from "@/dispatcher/js-messages";
import { IconName } from "@/utilities/icons";
import { IconName } from "@/utility-functions/icons";
import { HintInfo, KeysGroup } from "@/wasm-communication/messages";
import LayoutRow from "@/components/layout/LayoutRow.vue";
import IconLabel from "@/components/widgets/labels/IconLabel.vue";

View File

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

View File

@@ -155,6 +155,10 @@ export default defineComponent({
window.addEventListener("pointerup", this.pointerUp);
window.addEventListener("pointermove", this.pointerMove);
},
unmounted() {
window.removeEventListener("pointerup", this.pointerUp);
window.removeEventListener("pointermove", this.pointerMove);
},
methods: {
trackLength(): number {
const track = this.$refs.scrollTrack as HTMLElement;

View File

@@ -75,7 +75,8 @@
<script lang="ts">
import { defineComponent, PropType } from "vue";
import { SeparatorDirection, SeparatorType } from "@/utilities/widgets";
export type SeparatorDirection = "Horizontal" | "Vertical";
export type SeparatorType = "Related" | "Unrelated" | "Section" | "List";
export default defineComponent({
props: {

View File

@@ -44,7 +44,7 @@
<script lang="ts">
import { defineComponent } from "vue";
import { HintData, UpdateInputHints } from "@/dispatcher/js-messages";
import { HintData, UpdateInputHints } from "@/wasm-communication/messages";
import LayoutRow from "@/components/layout/LayoutRow.vue";
import UserInputLabel from "@/components/widgets/labels/UserInputLabel.vue";
@@ -58,7 +58,7 @@ export default defineComponent({
};
},
mounted() {
this.editor.dispatcher.subscribeJsMessage(UpdateInputHints, (updateInputHints) => {
this.editor.subscriptions.subscribeJsMessage(UpdateInputHints, (updateInputHints) => {
this.hintData = updateInputHints.hint_data;
});
},

View File

@@ -5,7 +5,7 @@
<MenuBarInput v-if="platform !== 'Mac'" />
</LayoutRow>
<LayoutRow class="header-part">
<WindowTitle :text="`${activeDocumentDisplayName} - Graphite`" />
<WindowTitle :text="windowTitle" />
</LayoutRow>
<LayoutRow class="header-part">
<WindowButtonsWindows :maximized="maximized" v-if="platform === 'Windows' || platform === 'Linux'" />
@@ -56,8 +56,11 @@ export default defineComponent({
maximized: { type: Boolean as PropType<boolean>, required: true },
},
computed: {
activeDocumentDisplayName() {
return this.portfolio.state.documents[this.portfolio.state.activeDocumentIndex].displayName;
windowTitle(): string {
const activeDocumentIndex = this.portfolio.state.activeDocumentIndex;
const activeDocumentDisplayName = this.portfolio.state.documents[activeDocumentIndex]?.displayName || "";
return `${activeDocumentDisplayName}${activeDocumentDisplayName && " - "}Graphite`;
},
},
components: {

View File

@@ -168,7 +168,6 @@ const panelComponents = {
type PanelTypes = keyof typeof panelComponents;
export default defineComponent({
inject: ["portfolio"],
props: {
tabMinWidths: { type: Boolean as PropType<boolean>, default: false },
tabCloseButtons: { type: Boolean as PropType<boolean>, default: false },