Restructure project directories (#333)

`/client/web` -> `/frontend`
`/client/cli` -> *delete for now*
`/client/native` -> *delete for now*
`/core/editor` -> `/editor`
`/core/document` -> `/graphene`
`/core/renderer` -> `/charcoal`
`/core/proc-macro` -> `/proc-macros` *(now plural)*
This commit is contained in:
Keavon Chambers
2021-08-07 05:17:18 -07:00
parent 434695d578
commit 53ad105f57
239 changed files with 197 additions and 224 deletions
@@ -0,0 +1,287 @@
<template>
<div class="color-picker">
<div class="saturation-picker" ref="saturationPicker" data-picker-action="MoveSaturation" @pointerdown="onPointerDown">
<div ref="saturationCursor" class="selection-circle"></div>
</div>
<div class="hue-picker" ref="huePicker" data-picker-action="MoveHue" @pointerdown="onPointerDown">
<div ref="hueCursor" class="selection-pincers"></div>
</div>
<div class="opacity-picker" ref="opacityPicker" data-picker-action="MoveOpacity" @pointerdown="onPointerDown">
<div ref="opacityCursor" class="selection-pincers"></div>
</div>
</div>
</template>
<style lang="scss">
.color-picker {
--saturation-picker-hue: #ff0000;
--opacity-picker-color: #ff0000;
display: flex;
.saturation-picker {
width: 256px;
background-blend-mode: multiply;
background: linear-gradient(to bottom, #ffffff, #000000), linear-gradient(to right, #ffffff, var(--saturation-picker-hue));
position: relative;
}
.saturation-picker,
.hue-picker,
.opacity-picker {
height: 256px;
position: relative;
overflow: hidden;
}
.hue-picker,
.opacity-picker {
width: 24px;
margin-left: 8px;
position: relative;
}
.hue-picker {
background-blend-mode: screen;
background: linear-gradient(to top, #ff0000ff 16.666%, #ff000000 33.333%, #ff000000 66.666%, #ff0000ff 83.333%),
linear-gradient(to top, #00ff0000 0%, #00ff00ff 16.666%, #00ff00ff 50%, #00ff0000 66.666%), linear-gradient(to top, #0000ff00 33.333%, #0000ffff 50%, #0000ffff 83.333%, #0000ff00 100%);
}
.opacity-picker {
background: linear-gradient(to bottom, var(--opacity-picker-color), transparent);
&::before {
content: "";
display: block;
width: 100%;
height: 100%;
background: linear-gradient(45deg, #cccccc 25%, transparent 25%, transparent 75%, #cccccc 75%), linear-gradient(45deg, #cccccc 25%, transparent 25%, transparent 75%, #cccccc 75%),
linear-gradient(#ffffff, #ffffff);
background-size: 16px 16px;
background-position: 0 0, 8px 8px;
position: relative;
z-index: -1;
}
}
.selection-circle {
position: absolute;
left: 0%;
top: 0%;
width: 0;
height: 0;
pointer-events: none;
&::after {
content: "";
display: block;
position: relative;
left: -6px;
top: -6px;
width: 12px;
height: 12px;
border-radius: 50%;
border: 2px solid white;
box-sizing: border-box;
mix-blend-mode: difference;
}
}
.selection-pincers {
position: absolute;
top: 0%;
width: 100%;
height: 0;
pointer-events: none;
&::before {
content: "";
position: absolute;
top: -4px;
left: 0;
border-style: solid;
border-width: 4px 0 4px 4px;
border-color: transparent transparent transparent #000000;
}
&::after {
content: "";
position: absolute;
top: -4px;
right: 0;
border-style: solid;
border-width: 4px 4px 4px 0;
border-color: transparent #000000 transparent transparent;
}
}
}
</style>
<script lang="ts">
import { defineComponent } from "vue";
import { hsvToRgb, rgbToHsv, isRGB } from "@/utilities/color";
import { clamp } from "@/utilities/math";
const enum ColorPickerState {
Idle = "Idle",
MoveHue = "MoveHue",
MoveOpacity = "MoveOpacity",
MoveSaturation = "MoveSaturation",
}
export default defineComponent({
components: {},
props: {
color: { type: Object, required: true },
},
data() {
return {
state: ColorPickerState.Idle,
// Disable proxy on this object
// https://v3.vuejs.org/api/options-data.html#data-2
// eslint-disable-next-line vue/no-reserved-keys
_: {
colorPicker: {
color: { h: 0, s: 0, v: 0, a: 1 },
hue: {
rect: { width: 0, height: 0, top: 0, left: 0 },
},
opacity: {
rect: { width: 0, height: 0, top: 0, left: 0 },
},
saturation: {
rect: { width: 0, height: 0, top: 0, left: 0 },
},
},
},
};
},
mounted() {
this.$watch("color", this.updateColor, { immediate: true });
},
unmounted() {
this.removeEvents();
},
methods: {
addEvents() {
document.addEventListener("pointermove", this.onPointerMove);
document.addEventListener("pointerup", this.onPointerUp);
},
removeEvents() {
document.removeEventListener("pointermove", this.onPointerMove);
document.removeEventListener("pointerup", this.onPointerUp);
},
getRef<T>(name: string) {
return this.$refs[name] as T;
},
onPointerDown(e: PointerEvent) {
if (!(e.currentTarget instanceof Element)) return;
const picker = e.currentTarget.getAttribute("data-picker-action");
this.state = (() => {
switch (picker) {
case "MoveHue":
return ColorPickerState.MoveHue;
case "MoveOpacity":
return ColorPickerState.MoveOpacity;
case "MoveSaturation":
return ColorPickerState.MoveSaturation;
default:
return ColorPickerState.Idle;
}
})();
if (this.state !== ColorPickerState.Idle) {
this.addEvents();
this.updateRects();
this.onPointerMove(e);
}
},
onPointerMove(e: PointerEvent) {
const { colorPicker } = this.$data._;
if (this.state === ColorPickerState.MoveHue) {
this.setHuePosition(e.clientY - colorPicker.hue.rect.top);
} else if (this.state === ColorPickerState.MoveOpacity) {
this.setOpacityPosition(e.clientY - colorPicker.opacity.rect.top);
} else if (this.state === ColorPickerState.MoveSaturation) {
this.setSaturationPosition(e.clientX - colorPicker.saturation.rect.left, e.clientY - colorPicker.saturation.rect.top);
}
if (this.state !== ColorPickerState.Idle) {
this.updateHue();
this.$emit("update:color", hsvToRgb(colorPicker.color));
}
},
onPointerUp() {
if (this.state !== ColorPickerState.Idle) {
this.state = ColorPickerState.Idle;
this.removeEvents();
}
},
updateRects() {
const { colorPicker } = this.$data._;
const saturationPicker = this.getRef<HTMLDivElement>("saturationPicker");
const saturation = saturationPicker.getBoundingClientRect();
colorPicker.saturation.rect.width = saturation.width;
colorPicker.saturation.rect.height = saturation.height;
colorPicker.saturation.rect.left = saturation.left;
colorPicker.saturation.rect.top = saturation.top;
const huePicker = this.getRef<HTMLDivElement>("huePicker");
const hue = huePicker.getBoundingClientRect();
colorPicker.hue.rect.width = hue.width;
colorPicker.hue.rect.height = hue.height;
colorPicker.hue.rect.left = hue.left;
colorPicker.hue.rect.top = hue.top;
const opacityPicker = this.getRef<HTMLDivElement>("opacityPicker");
const opacity = opacityPicker.getBoundingClientRect();
colorPicker.opacity.rect.width = opacity.width;
colorPicker.opacity.rect.height = opacity.height;
colorPicker.opacity.rect.left = opacity.left;
colorPicker.opacity.rect.top = opacity.top;
},
setSaturationPosition(x: number, y: number) {
const { colorPicker } = this.$data._;
const saturationCursor = this.getRef<HTMLDivElement>("saturationCursor");
const saturationPosition = [clamp(x, 0, colorPicker.saturation.rect.width), clamp(y, 0, colorPicker.saturation.rect.height)];
saturationCursor.style.transform = `translate(${saturationPosition[0]}px, ${saturationPosition[1]}px)`;
colorPicker.color.s = saturationPosition[0] / colorPicker.saturation.rect.width;
colorPicker.color.v = (1 - saturationPosition[1] / colorPicker.saturation.rect.height) * 255;
},
setHuePosition(y: number) {
const { colorPicker } = this.$data._;
const hueCursor = this.getRef<HTMLDivElement>("hueCursor");
const huePosition = clamp(y, 0, colorPicker.hue.rect.height);
hueCursor.style.transform = `translateY(${huePosition}px)`;
colorPicker.color.h = clamp(1 - huePosition / colorPicker.hue.rect.height);
},
setOpacityPosition(y: number) {
const { colorPicker } = this.$data._;
const opacityCursor = this.getRef<HTMLDivElement>("opacityCursor");
const opacityPosition = clamp(y, 0, colorPicker.opacity.rect.height);
opacityCursor.style.transform = `translateY(${opacityPosition}px)`;
colorPicker.color.a = clamp(1 - opacityPosition / colorPicker.opacity.rect.height);
},
updateHue() {
const { colorPicker } = this.$data._;
let color = hsvToRgb({ h: colorPicker.color.h, s: 1, v: 255, a: 1 });
this.$el.style.setProperty("--saturation-picker-hue", `rgb(${color.r}, ${color.g}, ${color.b})`);
color = hsvToRgb(colorPicker.color);
this.$el.style.setProperty("--opacity-picker-color", `rgb(${color.r}, ${color.g}, ${color.b})`);
},
updateColor() {
if (this.state !== ColorPickerState.Idle) return;
const { color } = this;
if (!isRGB(color)) return;
const { colorPicker } = this.$data._;
colorPicker.color = rgbToHsv(color);
this.updateRects();
this.setSaturationPosition(colorPicker.color.s * colorPicker.saturation.rect.width, (1 - colorPicker.color.v / 255) * colorPicker.saturation.rect.height);
this.setOpacityPosition((1 - colorPicker.color.a) * colorPicker.opacity.rect.height);
this.setHuePosition((1 - colorPicker.color.h) * colorPicker.hue.rect.height);
this.updateHue();
},
},
});
</script>
@@ -0,0 +1,109 @@
<template>
<div class="dialog-modal">
<FloatingMenu :type="MenuType.Dialog" :direction="MenuDirection.Center">
<LayoutRow>
<LayoutCol :class="'icon-column'">
<!-- `dialog.icon` class exists to provide special sizing in CSS to specific icons -->
<IconLabel :icon="dialog.icon" :class="dialog.icon.toLowerCase()" />
</LayoutCol>
<LayoutCol :class="'main-column'">
<TextLabel :bold="true" :class="'heading'">{{ dialog.heading }}</TextLabel>
<TextLabel :class="'details'">{{ dialog.details }}</TextLabel>
<LayoutRow :class="'buttons-row'">
<TextButton v-for="(button, index) in dialog.buttons" :key="index" :title="button.tooltip" :action="button.callback" v-bind="button.props" />
</LayoutRow>
</LayoutCol>
</LayoutRow>
</FloatingMenu>
</div>
</template>
<style lang="scss">
.dialog-modal {
position: absolute;
pointer-events: none;
width: 100%;
height: 100%;
.dialog {
width: 100%;
height: 100%;
}
.floating-menu-container .floating-menu-content {
pointer-events: auto;
padding: 24px;
}
.icon-column {
margin-right: 24px;
.icon-label {
width: 80px;
height: 80px;
&.file,
&.copy {
width: 60px;
svg {
width: 80px;
height: 80px;
margin: 0 -10px;
}
}
}
}
.main-column {
.heading {
white-space: pre;
margin-bottom: 4px;
}
.details {
white-space: pre;
}
.buttons-row {
margin-top: 16px;
}
}
}
</style>
<script lang="ts">
import { defineComponent } from "vue";
import { dismissDialog } from "@/utilities/dialog";
import LayoutRow from "@/components/layout/LayoutRow.vue";
import LayoutCol from "@/components/layout/LayoutCol.vue";
import FloatingMenu, { MenuDirection, MenuType } from "@/components/widgets/floating-menus/FloatingMenu.vue";
import IconLabel from "@/components/widgets/labels/IconLabel.vue";
import TextLabel from "@/components/widgets/labels/TextLabel.vue";
import TextButton from "@/components/widgets/buttons/TextButton.vue";
export default defineComponent({
inject: ["dialog"],
components: {
LayoutRow,
LayoutCol,
FloatingMenu,
IconLabel,
TextLabel,
TextButton,
},
methods: {
dismiss() {
dismissDialog();
},
},
data() {
return {
MenuDirection,
MenuType,
};
},
});
</script>
@@ -0,0 +1,373 @@
<template>
<div class="floating-menu" :class="[direction.toLowerCase(), type.toLowerCase()]" v-if="open || type === MenuType.Dialog" ref="floatingMenu">
<div class="tail" v-if="type === MenuType.Popover"></div>
<div class="floating-menu-container" ref="floatingMenuContainer">
<div class="floating-menu-content" :class="{ 'scrollable-y': scrollable }" ref="floatingMenuContent" :style="floatingMenuContentStyle">
<slot></slot>
</div>
</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: var(--floating-menu-opacity-color-2-mildblack);
box-shadow: var(--floating-menu-shadow) 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: var(--floating-menu-opacity-color-2-mildblack) transparent transparent transparent;
margin-left: -6px;
margin-bottom: 2px;
}
&.bottom .tail {
border-width: 0 6px 8px 6px;
border-color: transparent transparent var(--floating-menu-opacity-color-2-mildblack) transparent;
margin-left: -6px;
margin-top: 2px;
}
&.left .tail {
border-width: 6px 0 6px 8px;
border-color: transparent transparent transparent var(--floating-menu-opacity-color-2-mildblack);
margin-top: -6px;
margin-right: 2px;
}
&.right .tail {
border-width: 6px 8px 6px 0;
border-color: transparent var(--floating-menu-opacity-color-2-mildblack) 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 } from "vue";
export enum MenuDirection {
Top = "Top",
Bottom = "Bottom",
Left = "Left",
Right = "Right",
TopLeft = "TopLeft",
TopRight = "TopRight",
BottomLeft = "BottomLeft",
BottomRight = "BottomRight",
Center = "Center",
}
export enum MenuType {
Popover = "Popover",
Dropdown = "Dropdown",
Dialog = "Dialog",
}
export default defineComponent({
components: {},
props: {
direction: { type: String, default: MenuDirection.Bottom },
type: { type: String, required: true },
windowEdgeMargin: { type: Number, default: 8 },
minWidth: { type: Number, default: 0 },
scrollable: { type: Boolean, default: false },
},
data() {
return {
open: false,
mouseStillDown: false,
MenuDirection,
MenuType,
};
},
updated() {
const floatingMenuContainer = this.$refs.floatingMenuContainer as HTMLElement;
const floatingMenuContent = this.$refs.floatingMenuContent as HTMLElement;
const workspace = document.querySelector(".workspace-row");
if (floatingMenuContent && workspace) {
const workspaceBounds = workspace.getBoundingClientRect();
const floatingMenuBounds = floatingMenuContent.getBoundingClientRect();
if (this.direction === MenuDirection.Left || this.direction === MenuDirection.Right) {
const topOffset = floatingMenuBounds.top - workspaceBounds.top - this.windowEdgeMargin;
if (topOffset < 0) floatingMenuContainer.style.transform = `translate(0, ${-topOffset}px)`;
const bottomOffset = workspaceBounds.bottom - floatingMenuBounds.bottom - this.windowEdgeMargin;
if (bottomOffset < 0) floatingMenuContainer.style.transform = `translate(0, ${bottomOffset}px)`;
}
if (this.direction === MenuDirection.Top || this.direction === MenuDirection.Bottom) {
const leftOffset = floatingMenuBounds.left - workspaceBounds.left - this.windowEdgeMargin;
if (leftOffset < 0) floatingMenuContainer.style.transform = `translate(${-leftOffset}px, 0)`;
const rightOffset = workspaceBounds.right - floatingMenuBounds.right - this.windowEdgeMargin;
if (rightOffset < 0) floatingMenuContainer.style.transform = `translate(${rightOffset}px, 0)`;
}
}
},
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 HTMLElement;
const width = floatingMenuContent.clientWidth;
callback(width);
});
},
disableMinWidth(callback: (minWidth: string) => void) {
this.$nextTick(() => {
const floatingMenuContent = this.$refs.floatingMenuContent as HTMLElement;
const initialMinWidth = floatingMenuContent.style.minWidth;
floatingMenuContent.style.minWidth = "0";
callback(initialMinWidth);
});
},
enableMinWidth(minWidth: string) {
const floatingMenuContent = this.$refs.floatingMenuContent as HTMLElement;
floatingMenuContent.style.minWidth = minWidth;
},
mouseMoveHandler(e: MouseEvent) {
const MOUSE_STRAY_DISTANCE = 100;
const target = e.target as HTMLElement;
const mouseOverFloatingMenuKeepOpen = target && (target.closest("[data-hover-menu-keep-open]") as HTMLElement);
const mouseOverFloatingMenuSpawner = target && (target.closest("[data-hover-menu-spawner]") as HTMLElement);
// TODO: Simplify the following expression when optional chaining is supported by the build system
const mouseOverOwnFloatingMenuSpawner =
mouseOverFloatingMenuSpawner && mouseOverFloatingMenuSpawner.parentElement && mouseOverFloatingMenuSpawner.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 (mouseOverFloatingMenuSpawner && !mouseOverOwnFloatingMenuSpawner) {
this.setClosed();
mouseOverFloatingMenuSpawner.click();
}
// Close the floating menu if the mouse has strayed far enough from its bounds
if (this.isMouseEventOutsideFloatingMenu(e, MOUSE_STRAY_DISTANCE) && !mouseOverOwnFloatingMenuSpawner && !mouseOverFloatingMenuKeepOpen) {
// 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();
}
// eslint-disable-next-line no-bitwise
const eventIncludesLmb = Boolean(e.buttons & 1);
// Clean up any messes from lost mouseup events
if (!this.open && !eventIncludesLmb) {
this.mouseStillDown = false;
window.removeEventListener("mouseup", this.mouseUpHandler);
}
},
mouseDownHandler(e: MouseEvent) {
// Close the floating menu if the mouse clicked outside the floating menu (but within stray distance)
if (this.isMouseEventOutsideFloatingMenu(e)) {
this.setClosed();
// Track if the left mouse button is now down so its later click event can be canceled
const eventIsForLmb = e.button === 0;
if (eventIsForLmb) this.mouseStillDown = true;
}
},
mouseUpHandler(e: MouseEvent) {
const eventIsForLmb = e.button === 0;
if (this.mouseStillDown && eventIsForLmb) {
// Clean up self
this.mouseStillDown = false;
window.removeEventListener("mouseup", this.mouseUpHandler);
// Prevent the click event from firing, which would normally occur right after this mouseup 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);
},
isMouseEventOutsideFloatingMenu(e: MouseEvent, extraDistanceAllowed = 0): boolean {
const floatingMenuContent = this.$refs.floatingMenuContent as HTMLElement;
if (!floatingMenuContent) return true;
const floatingMenuBounds = floatingMenuContent.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) {
if (newState && !oldState) {
// Close floating menu if mouse strays far enough away
window.addEventListener("mousemove", this.mouseMoveHandler);
// Close floating menu if mouse is outside (but within stray distance)
window.addEventListener("mousedown", this.mouseDownHandler);
// 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("mouseup", this.mouseUpHandler);
}
if (!newState && oldState) {
window.removeEventListener("mousemove", this.mouseMoveHandler);
window.removeEventListener("mousedown", this.mouseDownHandler);
}
},
},
computed: {
floatingMenuContentStyle(): Partial<CSSStyleDeclaration> {
return {
minWidth: this.minWidth > 0 ? `${this.minWidth}px` : "",
};
},
},
});
</script>
@@ -0,0 +1,285 @@
<template>
<FloatingMenu :class="'menu-list'" :direction="direction" :type="MenuType.Dropdown" ref="floatingMenu" :windowEdgeMargin="0" :scrollable="scrollable" data-hover-menu-keep-open>
<template v-for="(section, sectionIndex) in menuEntries" :key="sectionIndex">
<Separator :type="SeparatorType.List" :direction="SeparatorDirection.Vertical" v-if="sectionIndex > 0" />
<div
v-for="(entry, entryIndex) in section"
:key="entryIndex"
class="row"
:class="{ open: isMenuEntryOpen(entry), active: entry === activeEntry }"
@click="handleEntryClick(entry)"
@mouseenter="handleEntryMouseEnter(entry)"
@mouseleave="handleEntryMouseLeave(entry)"
:data-hover-menu-spawner-extend="entry.children && []"
>
<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'" />
<div v-else-if="drawIcon" class="no-icon" />
<span class="entry-label">{{ entry.label }}</span>
<IconLabel v-if="entry.shortcutRequiresLock && !fullscreen.keyboardLocked" :icon="'Info'" :title="keyboardLockInfoMessage" />
<UserInputLabel v-else-if="entry.shortcut && entry.shortcut.length" :inputKeys="[entry.shortcut]" />
<div class="submenu-arrow" v-if="entry.children && entry.children.length"></div>
<div class="no-submenu-arrow" v-else></div>
<MenuList
v-if="entry.children"
:direction="MenuDirection.TopRight"
:menuEntries="entry.children"
v-bind="{ defaultAction, minWidth, drawIcon, scrollable }"
:ref="(ref) => setEntryRefs(entry, ref)"
/>
</div>
</template>
</FloatingMenu>
</template>
<style lang="scss">
.menu-list {
.floating-menu-container .floating-menu-content {
padding: 4px 0;
position: absolute;
min-width: 100%;
.row {
height: 20px;
display: flex;
align-items: center;
white-space: nowrap;
position: relative;
flex: 0 0 auto;
& > * {
flex: 0 0 auto;
}
.entry-icon svg {
fill: var(--color-e-nearwhite);
}
.no-icon {
width: 16px;
}
.entry-label {
flex: 1 1 100%;
margin-left: 8px;
}
.entry-checkbox,
.entry-icon,
.no-icon {
margin: 0 4px;
& + .entry-label {
margin-left: 0;
}
}
.user-input-label {
margin: 0;
margin-left: 4px;
}
.submenu-arrow {
width: 0;
height: 0;
border-style: solid;
border-width: 3px 0 3px 6px;
border-color: transparent transparent transparent var(--color-e-nearwhite);
}
.no-submenu-arrow {
width: 6px;
}
.submenu-arrow,
.no-submenu-arrow {
margin-left: 6px;
margin-right: 4px;
}
&:hover,
&.open,
&.active {
background: var(--color-6-lowergray);
&.active {
background: var(--color-accent);
}
svg {
fill: var(--color-f-white);
}
span {
color: var(--color-f-white);
}
}
&:hover .entry-checkbox label .checkbox-box {
border: 1px solid var(--color-f-white);
svg {
fill: var(--color-f-white);
}
}
}
}
}
</style>
<script lang="ts">
import { defineComponent, PropType } from "vue";
import { keyboardLockApiSupported } from "@/utilities/fullscreen";
import { SeparatorDirection, SeparatorType } from "@/components/widgets/widgets";
import FloatingMenu, { MenuDirection, MenuType } from "@/components/widgets/floating-menus/FloatingMenu.vue";
import Separator from "@/components/widgets/separators/Separator.vue";
import IconLabel from "@/components/widgets/labels/IconLabel.vue";
import CheckboxInput from "@/components/widgets/inputs/CheckboxInput.vue";
import UserInputLabel from "@/components/widgets/labels/UserInputLabel.vue";
export type MenuListEntries = Array<MenuListEntry>;
export type SectionsOfMenuListEntries = Array<MenuListEntries>;
interface MenuListEntryData {
value?: string;
label?: string;
icon?: string;
checkbox?: boolean;
shortcut?: Array<string>;
shortcutRequiresLock?: boolean;
action?: Function;
children?: SectionsOfMenuListEntries;
}
export type MenuListEntry = MenuListEntryData & { ref?: typeof FloatingMenu | typeof MenuList; checked?: boolean };
const KEYBOARD_LOCK_USE_FULLSCREEN = "This hotkey is reserved by the browser, but becomes available in fullscreen mode";
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({
inject: ["fullscreen"],
props: {
direction: { type: String as PropType<MenuDirection>, default: MenuDirection.Bottom },
menuEntries: { type: Array as PropType<SectionsOfMenuListEntries>, required: true },
activeEntry: { type: Object as PropType<MenuListEntry>, required: false },
defaultAction: { type: Function as PropType<Function | undefined>, required: false },
minWidth: { type: Number, default: 0 },
drawIcon: { type: Boolean, default: false },
scrollable: { type: Boolean, default: false },
},
methods: {
setEntryRefs(menuEntry: MenuListEntry, ref: typeof FloatingMenu) {
if (ref) menuEntry.ref = ref;
},
handleEntryClick(menuEntry: MenuListEntry) {
(this.$refs.floatingMenu as typeof FloatingMenu).setClosed();
if (menuEntry.checkbox) menuEntry.checked = !menuEntry.checked;
if (menuEntry.action) menuEntry.action();
else if (this.defaultAction) this.defaultAction();
this.$emit("update:activeEntry", menuEntry);
},
handleEntryMouseEnter(menuEntry: MenuListEntry) {
if (!menuEntry.children || !menuEntry.children.length) return;
if (menuEntry.ref) menuEntry.ref.setOpen();
else throw new Error("The menu bar floating menu has no associated ref");
},
handleEntryMouseLeave(menuEntry: MenuListEntry) {
if (!menuEntry.children || !menuEntry.children.length) return;
if (menuEntry.ref) menuEntry.ref.setClosed();
else throw new Error("The menu bar floating menu has no associated ref");
},
isMenuEntryOpen(menuEntry: MenuListEntry): boolean {
if (!menuEntry.children || !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 && floatingMenu.isOpen());
},
measureAndReportWidth() {
// API is experimental but supported in all browsers - https://developer.mozilla.org/en-US/docs/Web/API/FontFaceSet
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(document as any).fonts.ready.then(() => {
const floatingMenu = this.$refs.floatingMenu as typeof FloatingMenu;
// 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("width-changed", width);
});
});
});
},
},
computed: {
menuEntriesWithoutRefs(): Array<Array<MenuListEntryData>> {
const { menuEntries } = this;
return menuEntries.map((entries) =>
entries.map((entry) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { ref, ...entryWithoutRef } = entry;
return entryWithoutRef;
})
);
},
},
mounted() {
this.measureAndReportWidth();
},
updated() {
this.measureAndReportWidth();
},
watch: {
menuEntriesWithoutRefs: {
handler() {
this.measureAndReportWidth();
},
deep: true,
},
},
data() {
return {
keyboardLockInfoMessage: keyboardLockApiSupported() ? KEYBOARD_LOCK_USE_FULLSCREEN : KEYBOARD_LOCK_SWITCH_BROWSER,
SeparatorDirection,
SeparatorType,
MenuDirection,
MenuType,
};
},
components: {
FloatingMenu,
Separator,
IconLabel,
CheckboxInput,
UserInputLabel,
},
});
export default MenuList;
</script>