mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-20 03:18:06 +08:00
Reorganize Vue components and add documentation explaining each folder
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
<!-- TODO: Refactor this component (together with `WidgetRow.vue`) to be more logically consistent with our layout definition goals, in terms of naming and capabilities -->
|
||||
|
||||
<template>
|
||||
<div class="widget-layout">
|
||||
<component :is="layoutRowType(layoutRow)" :widgetData="layoutRow" :layoutTarget="layout.layout_target" v-for="(layoutRow, index) in layout.layout" :key="index" />
|
||||
@@ -18,8 +20,8 @@ import { defineComponent, PropType } from "vue";
|
||||
|
||||
import { isWidgetColumn, isWidgetRow, isWidgetSection, LayoutRow, WidgetLayout } from "@/wasm-communication/messages";
|
||||
|
||||
import WidgetSection from "@/components/widgets/groups/WidgetSection.vue";
|
||||
import WidgetRow from "@/components/widgets/WidgetRow.vue";
|
||||
import WidgetSection from "@/components/widgets/WidgetSection.vue";
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<!-- TODO: Refactor this component to use `<component :is="" v-bind="attributesObject"></component>` to avoid all the separate components with `v-if` -->
|
||||
<!-- TODO: Also rename this component, and probably move the `widget-${direction}` wrapper to be part of `WidgetLayout.vue` as part of its refactor -->
|
||||
|
||||
<template>
|
||||
<div :class="`widget-${direction}`">
|
||||
<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" 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)" />
|
||||
@@ -85,8 +87,8 @@ import RadioInput from "@/components/widgets/inputs/RadioInput.vue";
|
||||
import TextAreaInput from "@/components/widgets/inputs/TextAreaInput.vue";
|
||||
import TextInput from "@/components/widgets/inputs/TextInput.vue";
|
||||
import IconLabel from "@/components/widgets/labels/IconLabel.vue";
|
||||
import Separator from "@/components/widgets/labels/Separator.vue";
|
||||
import TextLabel from "@/components/widgets/labels/TextLabel.vue";
|
||||
import Separator from "@/components/widgets/separators/Separator.vue";
|
||||
|
||||
export default defineComponent({
|
||||
inject: ["editor"],
|
||||
|
||||
@@ -51,9 +51,9 @@ import { defineComponent, PropType } from "vue";
|
||||
|
||||
import { IconName } from "@/utility-functions/icons";
|
||||
|
||||
import FloatingMenu from "@/components/floating-menus/FloatingMenu.vue";
|
||||
import LayoutRow from "@/components/layout/LayoutRow.vue";
|
||||
import IconButton from "@/components/widgets/buttons/IconButton.vue";
|
||||
import FloatingMenu from "@/components/widgets/floating-menus/FloatingMenu.vue";
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
|
||||
@@ -1,305 +0,0 @@
|
||||
<template>
|
||||
<LayoutRow class="color-picker">
|
||||
<LayoutCol class="saturation-picker" ref="saturationPicker" @pointerdown="(e: PointerEvent) => onPointerDown(e)">
|
||||
<div ref="saturationCursor" class="selection-circle"></div>
|
||||
</LayoutCol>
|
||||
<LayoutCol class="hue-picker" ref="huePicker" @pointerdown="(e: PointerEvent) => onPointerDown(e)">
|
||||
<div ref="hueCursor" class="selection-pincers"></div>
|
||||
</LayoutCol>
|
||||
<LayoutCol class="opacity-picker" ref="opacityPicker" @pointerdown="(e: PointerEvent) => onPointerDown(e)">
|
||||
<div ref="opacityCursor" class="selection-pincers"></div>
|
||||
</LayoutCol>
|
||||
</LayoutRow>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.color-picker {
|
||||
--saturation-picker-hue: #ff0000;
|
||||
--opacity-picker-color: #ff0000;
|
||||
|
||||
.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: "";
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: -1;
|
||||
position: relative;
|
||||
// Checkered transparent pattern
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
.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, PropType } from "vue";
|
||||
|
||||
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";
|
||||
|
||||
type ColorPickerState = "Idle" | "MoveHue" | "MoveOpacity" | "MoveSaturation";
|
||||
|
||||
// TODO: Clean up the fundamental code design in this file to simplify it and use better practices.
|
||||
// TODO: Such as removing the `picker*` data variables and reducing the number of functions which call each other in weird, non-obvious ways.
|
||||
|
||||
export default defineComponent({
|
||||
emits: ["update:color"],
|
||||
props: {
|
||||
color: { type: Object as PropType<RGBA>, required: true },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
state: "Idle" as ColorPickerState,
|
||||
pickerHSVA: { h: 0, s: 0, v: 0, a: 1 },
|
||||
pickerHueRect: { width: 0, height: 0, top: 0, left: 0 },
|
||||
pickerOpacityRect: { width: 0, height: 0, top: 0, left: 0 },
|
||||
pickerSaturationRect: { 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);
|
||||
},
|
||||
onPointerDown(e: PointerEvent) {
|
||||
const saturationPicker = this.$refs.saturationPicker as typeof LayoutCol;
|
||||
const saturationPickerElement = saturationPicker?.$el as HTMLElement | undefined;
|
||||
|
||||
const huePicker = this.$refs.huePicker as typeof LayoutCol;
|
||||
const huePickerElement = huePicker?.$el as HTMLElement | undefined;
|
||||
|
||||
const opacityPicker = this.$refs.opacityPicker as typeof LayoutCol;
|
||||
const opacityPickerElement = opacityPicker?.$el as HTMLElement | undefined;
|
||||
|
||||
if (!(e.currentTarget instanceof HTMLElement) || !saturationPickerElement || !huePickerElement || !opacityPickerElement) return;
|
||||
|
||||
if (saturationPickerElement.contains(e.currentTarget)) {
|
||||
this.state = "MoveSaturation";
|
||||
} else if (huePickerElement.contains(e.currentTarget)) {
|
||||
this.state = "MoveHue";
|
||||
} else if (opacityPickerElement.contains(e.currentTarget)) {
|
||||
this.state = "MoveOpacity";
|
||||
} else {
|
||||
this.state = "Idle";
|
||||
}
|
||||
|
||||
if (this.state === "Idle") return;
|
||||
|
||||
this.addEvents();
|
||||
this.updateRects();
|
||||
this.onPointerMove(e);
|
||||
},
|
||||
onPointerMove(e: PointerEvent) {
|
||||
switch (this.state) {
|
||||
case "MoveHue":
|
||||
this.setHueCursorPosition(e.clientY - this.pickerHueRect.top);
|
||||
break;
|
||||
case "MoveOpacity":
|
||||
this.setOpacityCursorPosition(e.clientY - this.pickerOpacityRect.top);
|
||||
break;
|
||||
case "MoveSaturation":
|
||||
this.setSaturationCursorPosition(e.clientX - this.pickerSaturationRect.left, e.clientY - this.pickerSaturationRect.top);
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateHue();
|
||||
|
||||
// The `color` prop's watcher calls `this.updateColor()`
|
||||
this.$emit("update:color", hsvaToRgba(this.pickerHSVA));
|
||||
},
|
||||
onPointerUp() {
|
||||
if (this.state === "Idle") return;
|
||||
|
||||
this.state = "Idle";
|
||||
|
||||
this.removeEvents();
|
||||
},
|
||||
updateRects() {
|
||||
const saturationPicker = this.$refs.saturationPicker as typeof LayoutCol;
|
||||
const saturationPickerElement = saturationPicker?.$el as HTMLElement | undefined;
|
||||
|
||||
const huePicker = this.$refs.huePicker as typeof LayoutCol;
|
||||
const huePickerElement = huePicker?.$el as HTMLElement | undefined;
|
||||
|
||||
const opacityPicker = this.$refs.opacityPicker as typeof LayoutCol;
|
||||
const opacityPickerElement = opacityPicker?.$el as HTMLElement | undefined;
|
||||
|
||||
if (!saturationPickerElement || !huePickerElement || !opacityPickerElement) return;
|
||||
|
||||
// Saturation
|
||||
const saturation = saturationPickerElement.getBoundingClientRect();
|
||||
|
||||
this.pickerSaturationRect.width = saturation.width;
|
||||
this.pickerSaturationRect.height = saturation.height;
|
||||
this.pickerSaturationRect.left = saturation.left;
|
||||
this.pickerSaturationRect.top = saturation.top;
|
||||
|
||||
// Hue
|
||||
const hue = huePickerElement.getBoundingClientRect();
|
||||
|
||||
this.pickerHueRect.width = hue.width;
|
||||
this.pickerHueRect.height = hue.height;
|
||||
this.pickerHueRect.left = hue.left;
|
||||
this.pickerHueRect.top = hue.top;
|
||||
|
||||
// Opacity
|
||||
const opacity = opacityPickerElement.getBoundingClientRect();
|
||||
|
||||
this.pickerOpacityRect.width = opacity.width;
|
||||
this.pickerOpacityRect.height = opacity.height;
|
||||
this.pickerOpacityRect.left = opacity.left;
|
||||
this.pickerOpacityRect.top = opacity.top;
|
||||
},
|
||||
setSaturationCursorPosition(x: number, y: number) {
|
||||
const saturationPositionX = clamp(x, 0, this.pickerSaturationRect.width);
|
||||
const saturationPositionY = clamp(y, 0, this.pickerSaturationRect.height);
|
||||
|
||||
const saturationCursor = this.$refs.saturationCursor as HTMLElement;
|
||||
saturationCursor.style.transform = `translate(${saturationPositionX}px, ${saturationPositionY}px)`;
|
||||
|
||||
this.pickerHSVA.s = saturationPositionX / this.pickerSaturationRect.width;
|
||||
this.pickerHSVA.v = (1 - saturationPositionY / this.pickerSaturationRect.height) * 255;
|
||||
},
|
||||
setHueCursorPosition(y: number) {
|
||||
const huePosition = clamp(y, 0, this.pickerHueRect.height);
|
||||
|
||||
const hueCursor = this.$refs.hueCursor as HTMLElement;
|
||||
hueCursor.style.transform = `translateY(${huePosition}px)`;
|
||||
|
||||
this.pickerHSVA.h = clamp(1 - huePosition / this.pickerHueRect.height);
|
||||
},
|
||||
setOpacityCursorPosition(y: number) {
|
||||
const opacityPosition = clamp(y, 0, this.pickerOpacityRect.height);
|
||||
|
||||
const opacityCursor = this.$refs.opacityCursor as HTMLElement;
|
||||
opacityCursor.style.transform = `translateY(${opacityPosition}px)`;
|
||||
|
||||
this.pickerHSVA.a = clamp(1 - opacityPosition / this.pickerOpacityRect.height);
|
||||
},
|
||||
updateHue() {
|
||||
const hsva = hsvaToRgba({ h: this.pickerHSVA.h, s: 1, v: 255, a: 1 });
|
||||
const rgba = hsvaToRgba(this.pickerHSVA);
|
||||
|
||||
this.$el.style.setProperty("--saturation-picker-hue", `rgb(${hsva.r}, ${hsva.g}, ${hsva.b})`);
|
||||
this.$el.style.setProperty("--opacity-picker-color", `rgb(${rgba.r}, ${rgba.g}, ${rgba.b})`);
|
||||
},
|
||||
updateColor() {
|
||||
if (this.state !== "Idle") return;
|
||||
|
||||
this.pickerHSVA = rgbaToHsva(this.color);
|
||||
|
||||
this.updateRects();
|
||||
|
||||
this.setSaturationCursorPosition(this.pickerHSVA.s * this.pickerSaturationRect.width, (1 - this.pickerHSVA.v / 255) * this.pickerSaturationRect.height);
|
||||
this.setOpacityCursorPosition((1 - this.pickerHSVA.a) * this.pickerOpacityRect.height);
|
||||
this.setHueCursorPosition((1 - this.pickerHSVA.h) * this.pickerHueRect.height);
|
||||
|
||||
this.updateHue();
|
||||
},
|
||||
},
|
||||
components: {
|
||||
LayoutRow,
|
||||
LayoutCol,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -1,94 +0,0 @@
|
||||
<template>
|
||||
<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 -->
|
||||
<IconLabel :icon="dialog.state.icon" :class="dialog.state.icon.toLowerCase()" />
|
||||
</LayoutCol>
|
||||
<LayoutCol class="main-column">
|
||||
<WidgetLayout v-if="dialog.state.widgets.layout.length > 0" :layout="dialog.state.widgets" class="details" />
|
||||
<LayoutRow v-if="(dialog.state.jsCallbackBasedButtons?.length || NaN) > 0" class="panic-buttons-row">
|
||||
<TextButton v-for="(button, index) in dialog.state.jsCallbackBasedButtons" :key="index" :action="() => button.callback?.()" v-bind="button.props" />
|
||||
</LayoutRow>
|
||||
</LayoutCol>
|
||||
</LayoutRow>
|
||||
</FloatingMenu>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.dialog-modal {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
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 {
|
||||
margin: -4px 0;
|
||||
|
||||
.details.text-label {
|
||||
user-select: text;
|
||||
white-space: pre-wrap;
|
||||
max-width: 400px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.panic-buttons-row {
|
||||
height: 32px;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
|
||||
import LayoutCol from "@/components/layout/LayoutCol.vue";
|
||||
import LayoutRow from "@/components/layout/LayoutRow.vue";
|
||||
import TextButton from "@/components/widgets/buttons/TextButton.vue";
|
||||
import FloatingMenu from "@/components/widgets/floating-menus/FloatingMenu.vue";
|
||||
import IconLabel from "@/components/widgets/labels/IconLabel.vue";
|
||||
import WidgetLayout from "@/components/widgets/WidgetLayout.vue";
|
||||
|
||||
export default defineComponent({
|
||||
inject: ["dialog"],
|
||||
components: {
|
||||
LayoutRow,
|
||||
LayoutCol,
|
||||
FloatingMenu,
|
||||
IconLabel,
|
||||
TextButton,
|
||||
WidgetLayout,
|
||||
},
|
||||
methods: {
|
||||
dismiss() {
|
||||
this.dialog.dismissDialog();
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -1,454 +0,0 @@
|
||||
<template>
|
||||
<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>
|
||||
</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;
|
||||
// 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-container > .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 } 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({
|
||||
emits: ["update:open", "naturalWidth"],
|
||||
props: {
|
||||
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 },
|
||||
scrollableY: { type: Boolean as PropType<boolean>, default: false },
|
||||
minWidth: { type: Number as PropType<number>, default: 0 },
|
||||
},
|
||||
data() {
|
||||
// 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 {
|
||||
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
|
||||
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;
|
||||
|
||||
this.positionAndStyleFloatingMenu();
|
||||
},
|
||||
methods: {
|
||||
resizeObserverCallback(entries: ResizeObserverEntry[]) {
|
||||
this.minWidthParentWidth = entries[0].contentRect.width;
|
||||
},
|
||||
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;
|
||||
}
|
||||
}
|
||||
},
|
||||
// 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;
|
||||
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.$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.$emit("update:open", false);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
},
|
||||
pointerDownHandler(e: PointerEvent) {
|
||||
// Close the floating menu if the pointer clicked outside the floating menu (but within stray distance)
|
||||
if (this.isPointerEventOutsideFloatingMenu(e)) {
|
||||
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;
|
||||
}
|
||||
},
|
||||
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("[data-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: {
|
||||
// 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) {
|
||||
// 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) 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);
|
||||
// The `pointerup` event is removed in `pointerMoveHandler()` and `pointerDownHandler()`
|
||||
}
|
||||
},
|
||||
},
|
||||
components: { LayoutCol },
|
||||
});
|
||||
</script>
|
||||
@@ -1,264 +0,0 @@
|
||||
<template>
|
||||
<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: 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" />
|
||||
<div v-else-if="drawIcon" class="no-icon"></div>
|
||||
|
||||
<span class="entry-label">{{ entry.label }}</span>
|
||||
|
||||
<IconLabel v-if="entry.shortcutRequiresLock && !fullscreen.state.keyboardLocked" :icon="'Info'" :title="keyboardLockInfoMessage" />
|
||||
<UserInputLabel v-else-if="entry.shortcut?.length" :inputKeys="[entry.shortcut]" />
|
||||
|
||||
<div class="submenu-arrow" v-if="entry.children?.length"></div>
|
||||
<div class="no-submenu-arrow" v-else></div>
|
||||
|
||||
<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: typeof FloatingMenu) => ref && (entry.ref = ref)"
|
||||
/>
|
||||
</LayoutRow>
|
||||
</template>
|
||||
</FloatingMenu>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.menu-list {
|
||||
.floating-menu-container .floating-menu-content {
|
||||
padding: 4px 0;
|
||||
|
||||
.row {
|
||||
height: 20px;
|
||||
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: 16px;
|
||||
}
|
||||
|
||||
.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 { IconName } from "@/utility-functions/icons";
|
||||
|
||||
import LayoutRow from "@/components/layout/LayoutRow.vue";
|
||||
import FloatingMenu, { MenuDirection } from "@/components/widgets/floating-menus/FloatingMenu.vue";
|
||||
import CheckboxInput from "@/components/widgets/inputs/CheckboxInput.vue";
|
||||
import IconLabel from "@/components/widgets/labels/IconLabel.vue";
|
||||
import UserInputLabel from "@/components/widgets/labels/UserInputLabel.vue";
|
||||
import Separator from "@/components/widgets/separators/Separator.vue";
|
||||
|
||||
export type MenuListEntries<Value = string> = MenuListEntry<Value>[];
|
||||
export type SectionsOfMenuListEntries<Value = string> = MenuListEntries<Value>[];
|
||||
|
||||
interface MenuListEntryData<Value = string> {
|
||||
value?: Value;
|
||||
label?: string;
|
||||
icon?: IconName;
|
||||
checkbox?: boolean;
|
||||
shortcut?: string[];
|
||||
shortcutRequiresLock?: boolean;
|
||||
action?: () => void;
|
||||
children?: SectionsOfMenuListEntries;
|
||||
}
|
||||
|
||||
export type MenuListEntry<Value = string> = MenuListEntryData<Value> & { 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"],
|
||||
emits: ["update:open", "update:activeEntry", "naturalWidth"],
|
||||
props: {
|
||||
entries: { type: Array as PropType<SectionsOfMenuListEntries>, required: true },
|
||||
activeEntry: { type: Object as PropType<MenuListEntry>, 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: {
|
||||
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`
|
||||
},
|
||||
onEntryPointerEnter(menuEntry: MenuListEntry): void {
|
||||
if (!menuEntry.children?.length) return;
|
||||
|
||||
if (menuEntry.ref) menuEntry.ref.isOpen = true;
|
||||
else this.$emit("update:open", true);
|
||||
},
|
||||
onEntryPointerLeave(menuEntry: MenuListEntry): void {
|
||||
if (!menuEntry.children?.length) return;
|
||||
|
||||
if (menuEntry.ref) menuEntry.ref.isOpen = false;
|
||||
else this.$emit("update:open", false);
|
||||
},
|
||||
isEntryOpen(menuEntry: MenuListEntry): boolean {
|
||||
if (!menuEntry.children?.length) return false;
|
||||
|
||||
return this.open;
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
entriesWithoutRefs(): MenuListEntryData[][] {
|
||||
return this.entries.map((menuListEntries) =>
|
||||
menuListEntries.map((entry) => {
|
||||
const { ref, ...entryWithoutRef } = entry;
|
||||
return entryWithoutRef;
|
||||
})
|
||||
);
|
||||
},
|
||||
},
|
||||
components: {
|
||||
FloatingMenu,
|
||||
Separator,
|
||||
IconLabel,
|
||||
CheckboxInput,
|
||||
UserInputLabel,
|
||||
LayoutRow,
|
||||
},
|
||||
});
|
||||
export default MenuList;
|
||||
</script>
|
||||
@@ -73,8 +73,8 @@ import { isWidgetRow, isWidgetSection, LayoutRow as LayoutSystemRow, WidgetSecti
|
||||
|
||||
import LayoutCol from "@/components/layout/LayoutCol.vue";
|
||||
import LayoutRow from "@/components/layout/LayoutRow.vue";
|
||||
import Separator from "@/components/widgets/labels/Separator.vue";
|
||||
import TextLabel from "@/components/widgets/labels/TextLabel.vue";
|
||||
import Separator from "@/components/widgets/separators/Separator.vue";
|
||||
import WidgetRow from "@/components/widgets/WidgetRow.vue";
|
||||
|
||||
const WidgetSection = defineComponent({
|
||||
@@ -72,12 +72,12 @@ import { defineComponent, PropType } from "vue";
|
||||
|
||||
import { RGBA } from "@/wasm-communication/messages";
|
||||
|
||||
import ColorPicker from "@/components/floating-menus/ColorPicker.vue";
|
||||
import FloatingMenu from "@/components/floating-menus/FloatingMenu.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";
|
||||
import OptionalInput from "@/components/widgets/inputs/OptionalInput.vue";
|
||||
import TextInput from "@/components/widgets/inputs/TextInput.vue";
|
||||
import Separator from "@/components/widgets/separators/Separator.vue";
|
||||
import Separator from "@/components/widgets/labels/Separator.vue";
|
||||
|
||||
export default defineComponent({
|
||||
emits: ["update:value", "update:open"],
|
||||
|
||||
@@ -87,8 +87,8 @@
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType, toRaw } from "vue";
|
||||
|
||||
import MenuList, { MenuListEntry, SectionsOfMenuListEntries } from "@/components/floating-menus/MenuList.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";
|
||||
|
||||
const DASH_ENTRY = { label: "-" };
|
||||
|
||||
@@ -85,8 +85,8 @@
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType } from "vue";
|
||||
|
||||
import MenuList, { MenuListEntry, SectionsOfMenuListEntries } from "@/components/floating-menus/MenuList.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";
|
||||
|
||||
export default defineComponent({
|
||||
|
||||
@@ -63,7 +63,7 @@ import { defineComponent } from "vue";
|
||||
|
||||
import { Editor } from "@/wasm-communication/editor";
|
||||
|
||||
import MenuList, { MenuListEntry, MenuListEntries } from "@/components/widgets/floating-menus/MenuList.vue";
|
||||
import MenuList, { MenuListEntry, MenuListEntries } from "@/components/floating-menus/MenuList.vue";
|
||||
import IconLabel from "@/components/widgets/labels/IconLabel.vue";
|
||||
|
||||
function makeEntries(editor: Editor): MenuListEntries {
|
||||
|
||||
@@ -71,10 +71,10 @@ import { defineComponent } from "vue";
|
||||
import { rgbaToDecimalRgba } from "@/utility-functions/color";
|
||||
import { type RGBA, UpdateWorkingColors } from "@/wasm-communication/messages";
|
||||
|
||||
import ColorPicker from "@/components/floating-menus/ColorPicker.vue";
|
||||
import FloatingMenu from "@/components/floating-menus/FloatingMenu.vue";
|
||||
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";
|
||||
|
||||
export default defineComponent({
|
||||
inject: ["editor"],
|
||||
|
||||
Reference in New Issue
Block a user