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