<template>
	<div class="floating-menu" :class="[direction.toLowerCase(), type.toLowerCase()]" v-if="open || type === 'Dialog'" ref="floatingMenu">
		<div class="tail" v-if="type === 'Popover'"></div>
		<div class="floating-menu-container" ref="floatingMenuContainer">
			<LayoutCol class="floating-menu-content" :scrollableY="scrollableY" ref="floatingMenuContent" :style="floatingMenuContentStyle">
				<slot></slot>
			</LayoutCol>
		</div>
	</div>
</template>

<style lang="scss">
.floating-menu {
	position: absolute;
	width: 0;
	height: 0;
	display: flex;
	// Floating menus begin at a z-index of 1000
	z-index: 1000;
	--floating-menu-content-offset: 0;
	--floating-menu-content-border-radius: 4px;

	&.bottom {
		--floating-menu-content-border-radius: 0 0 4px 4px;
	}

	.tail {
		width: 0;
		height: 0;
		border-style: solid;
		// Put the tail above the floating menu's shadow
		z-index: 10;
		// Draw over the application without being clipped by the containing panel's `overflow: hidden`
		position: fixed;
	}

	.floating-menu-container {
		display: flex;

		.floating-menu-content {
			background: rgba(var(--color-2-mildblack-rgb), 0.95);
			box-shadow: rgba(var(--color-0-black-rgb), 50%) 0 2px 4px;
			border-radius: var(--floating-menu-content-border-radius);
			color: var(--color-e-nearwhite);
			font-size: inherit;
			padding: 8px;
			z-index: 0;
			display: flex;
			flex-direction: column;
			// Draw over the application without being clipped by the containing panel's `overflow: hidden`
			position: fixed;
		}
	}

	&.dropdown {
		&.top {
			width: 100%;
			left: 0;
			top: 0;
		}

		&.bottom {
			width: 100%;
			left: 0;
			bottom: 0;
		}

		&.left {
			height: 100%;
			top: 0;
			left: 0;
		}

		&.right {
			height: 100%;
			top: 0;
			right: 0;
		}

		&.topleft {
			top: 0;
			left: 0;
			margin-top: -4px;
		}

		&.topright {
			top: 0;
			right: 0;
			margin-top: -4px;
		}

		&.topleft {
			bottom: 0;
			left: 0;
			margin-bottom: -4px;
		}

		&.topright {
			bottom: 0;
			right: 0;
			margin-bottom: -4px;
		}
	}

	&.top.dropdown .floating-menu-container,
	&.bottom.dropdown .floating-menu-container {
		justify-content: left;
	}

	&.popover {
		--floating-menu-content-offset: 10px;
		--floating-menu-content-border-radius: 4px;
	}

	&.center {
		justify-content: center;
		align-items: center;

		.floating-menu-content {
			transform: translate(-50%, -50%);
		}
	}

	&.top,
	&.bottom {
		flex-direction: column;
	}

	&.top .tail {
		border-width: 8px 6px 0 6px;
		border-color: rgba(var(--color-2-mildblack-rgb), 0.95) transparent transparent transparent;
		margin-left: -6px;
		margin-bottom: 2px;
	}

	&.bottom .tail {
		border-width: 0 6px 8px 6px;
		border-color: transparent transparent rgba(var(--color-2-mildblack-rgb), 0.95) transparent;
		margin-left: -6px;
		margin-top: 2px;
	}

	&.left .tail {
		border-width: 6px 0 6px 8px;
		border-color: transparent transparent transparent rgba(var(--color-2-mildblack-rgb), 0.95);
		margin-top: -6px;
		margin-right: 2px;
	}

	&.right .tail {
		border-width: 6px 8px 6px 0;
		border-color: transparent rgba(var(--color-2-mildblack-rgb), 0.95) transparent transparent;
		margin-top: -6px;
		margin-left: 2px;
	}

	&.top .floating-menu-container {
		justify-content: center;
		margin-bottom: var(--floating-menu-content-offset);
	}

	&.bottom .floating-menu-container {
		justify-content: center;
		margin-top: var(--floating-menu-content-offset);
	}

	&.left .floating-menu-container {
		align-items: center;
		margin-right: var(--floating-menu-content-offset);
	}

	&.right .floating-menu-container {
		align-items: center;
		margin-left: var(--floating-menu-content-offset);
	}
}
</style>

<script lang="ts">
import { defineComponent, PropType, StyleValue } from "vue";

import LayoutCol from "@/components/layout/LayoutCol.vue";

export type MenuDirection = "Top" | "Bottom" | "Left" | "Right" | "TopLeft" | "TopRight" | "BottomLeft" | "BottomRight" | "Center";
export type MenuType = "Popover" | "Dropdown" | "Dialog";

const POINTER_STRAY_DISTANCE = 100;

export default defineComponent({
	props: {
		direction: { type: String as PropType<MenuDirection>, default: "Bottom" },
		type: { type: String as PropType<MenuType>, required: true },
		windowEdgeMargin: { type: Number as PropType<number>, default: 6 },
		minWidth: { type: Number as PropType<number>, default: 0 },
		scrollableY: { type: Boolean as PropType<boolean>, default: false },
	},
	data() {
		const containerResizeObserver = new ResizeObserver((entries) => {
			const content = entries[0].target.querySelector(".floating-menu-content") as HTMLElement;
			content.style.minWidth = `${entries[0].contentRect.width}px`;
		});
		return {
			open: false,
			pointerStillDown: false,
			containerResizeObserver,
		};
	},
	updated() {
		const floatingMenuContainer = this.$refs.floatingMenuContainer as HTMLElement;
		const floatingMenuContentComponent = this.$refs.floatingMenuContent as typeof LayoutCol;
		const floatingMenuContent = floatingMenuContentComponent && (floatingMenuContentComponent.$el as HTMLElement);
		const workspace = document.querySelector(".workspace-row");

		if (!floatingMenuContainer || !floatingMenuContentComponent || !floatingMenuContent || !workspace) return;

		const workspaceBounds = workspace.getBoundingClientRect();
		const floatingMenuBounds = floatingMenuContent.getBoundingClientRect();

		type Edge = "Top" | "Bottom" | "Left" | "Right";
		let zeroedBorderDirection1: Edge | undefined;
		let zeroedBorderDirection2: Edge | undefined;

		if (this.direction === "Top" || this.direction === "Bottom") {
			zeroedBorderDirection1 = this.direction === "Top" ? "Bottom" : "Top";

			if (floatingMenuBounds.left - this.windowEdgeMargin <= workspaceBounds.left) {
				floatingMenuContent.style.left = `${this.windowEdgeMargin}px`;
				if (workspaceBounds.left + floatingMenuContainer.getBoundingClientRect().left === 12) zeroedBorderDirection2 = "Left";
			}
			if (floatingMenuBounds.right + this.windowEdgeMargin >= workspaceBounds.right) {
				floatingMenuContent.style.right = `${this.windowEdgeMargin}px`;
				if (workspaceBounds.right - floatingMenuContainer.getBoundingClientRect().right === 12) zeroedBorderDirection2 = "Right";
			}
		}
		if (this.direction === "Left" || this.direction === "Right") {
			zeroedBorderDirection2 = this.direction === "Left" ? "Right" : "Left";

			if (floatingMenuBounds.top - this.windowEdgeMargin <= workspaceBounds.top) {
				floatingMenuContent.style.top = `${this.windowEdgeMargin}px`;
				if (workspaceBounds.top + floatingMenuContainer.getBoundingClientRect().top === 12) zeroedBorderDirection1 = "Top";
			}
			if (floatingMenuBounds.bottom + this.windowEdgeMargin >= workspaceBounds.bottom) {
				floatingMenuContent.style.bottom = `${this.windowEdgeMargin}px`;
				if (workspaceBounds.bottom - floatingMenuContainer.getBoundingClientRect().bottom === 12) zeroedBorderDirection1 = "Bottom";
			}
		}

		// Remove the rounded corner from where the tail perfectly meets the corner
		if (this.type === "Popover" && this.windowEdgeMargin === 6 && zeroedBorderDirection1 && zeroedBorderDirection2) {
			switch (`${zeroedBorderDirection1}${zeroedBorderDirection2}`) {
				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;
			}
		}
	},
	methods: {
		setOpen() {
			this.open = true;
		},
		setClosed() {
			this.open = false;
		},
		isOpen(): boolean {
			return this.open;
		},
		getWidth(callback: (width: number) => void) {
			this.$nextTick(() => {
				const floatingMenuContent = (this.$refs.floatingMenuContent as typeof LayoutCol).$el as HTMLElement;
				const width = floatingMenuContent.clientWidth;
				callback(width);
			});
		},
		disableMinWidth(callback: (minWidth: string) => void) {
			this.$nextTick(() => {
				const floatingMenuContent = (this.$refs.floatingMenuContent as typeof LayoutCol).$el as HTMLElement;
				const initialMinWidth = floatingMenuContent.style.minWidth;
				floatingMenuContent.style.minWidth = "0";
				callback(initialMinWidth);
			});
		},
		enableMinWidth(minWidth: string) {
			const floatingMenuContent = (this.$refs.floatingMenuContent as typeof LayoutCol).$el as HTMLElement;
			floatingMenuContent.style.minWidth = minWidth;
		},
		pointerMoveHandler(e: PointerEvent) {
			const target = e.target as HTMLElement;
			const pointerOverFloatingMenuKeepOpen = target && (target.closest("[data-hover-menu-keep-open]") as HTMLElement);
			const pointerOverFloatingMenuSpawner = target && (target.closest("[data-hover-menu-spawner]") as HTMLElement);
			// TODO: Simplify the following expression when optional chaining is supported by the build system
			const pointerOverOwnFloatingMenuSpawner =
				pointerOverFloatingMenuSpawner && pointerOverFloatingMenuSpawner.parentElement && 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();
				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();
			}
			const eventIncludesLmb = Boolean(e.buttons & 1);
			// Clean up any messes from lost pointerup events
			if (!this.open && !eventIncludesLmb) {
				this.pointerStillDown = false;
				window.removeEventListener("pointerup", this.pointerUpHandler);
			}
		},
		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();
				// 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;
			}
		},
		pointerUpHandler(e: PointerEvent) {
			const eventIsForLmb = e.button === 0;
			if (this.pointerStillDown && eventIsForLmb) {
				// Clean up self
				this.pointerStillDown = false;
				window.removeEventListener("pointerup", this.pointerUpHandler);
				// Prevent the click event from firing, which would normally occur right after this pointerup event
				window.addEventListener("click", this.clickHandlerCapture, true);
			}
		},
		clickHandlerCapture(e: MouseEvent) {
			// Stop the click event from reopening this floating menu if the click event targets the floating menu's button
			e.stopPropagation();
			// Clean up self
			window.removeEventListener("click", this.clickHandlerCapture, true);
		},
		isPointerEventOutsideFloatingMenu(e: PointerEvent, extraDistanceAllowed = 0): boolean {
			// Considers all child menus as well as the top-level one.
			const allContainedFloatingMenus = [...this.$el.querySelectorAll(".floating-menu-content")];
			return !allContainedFloatingMenus.find((element) => !this.isPointerEventOutsideMenuElement(e, element, extraDistanceAllowed));
		},
		isPointerEventOutsideMenuElement(e: PointerEvent, element: HTMLElement, extraDistanceAllowed = 0): boolean {
			const floatingMenuBounds = element.getBoundingClientRect();
			if (floatingMenuBounds.left - e.clientX >= extraDistanceAllowed) return true;
			if (e.clientX - floatingMenuBounds.right >= extraDistanceAllowed) return true;
			if (floatingMenuBounds.top - e.clientY >= extraDistanceAllowed) return true;
			if (e.clientY - floatingMenuBounds.bottom >= extraDistanceAllowed) return true;
			return false;
		},
	},
	watch: {
		open(newState: boolean, oldState: boolean) {
			// Switching from closed to open
			if (newState && !oldState) {
				// Close floating menu if pointer strays far enough away
				window.addEventListener("pointermove", this.pointerMoveHandler);
				// Close floating menu if pointer is outside (but within stray distance)
				window.addEventListener("pointerdown", this.pointerDownHandler);
				// Cancel the subsequent click event to prevent the floating menu from reopening if the floating menu's button is the click event target
				window.addEventListener("pointerup", this.pointerUpHandler);
				// Floating menu min-width resize observer
				this.$nextTick(() => {
					const floatingMenuContainer = this.$refs.floatingMenuContainer as HTMLElement;
					if (floatingMenuContainer) {
						this.containerResizeObserver.disconnect();
						this.containerResizeObserver.observe(floatingMenuContainer);
					}
				});
			}
			// Switching from open to closed
			if (!newState && oldState) {
				window.removeEventListener("pointermove", this.pointerMoveHandler);
				window.removeEventListener("pointerdown", this.pointerDownHandler);
				this.containerResizeObserver.disconnect();
			}
		},
	},
	computed: {
		floatingMenuContentStyle(): StyleValue {
			return {
				minWidth: this.minWidth > 0 ? `${this.minWidth}px` : "",
			};
		},
	},
	components: { LayoutCol },
});
</script>
