Add the Dropdown Input widget (#168)

Fixes #135.

* Add the Dropdown Input widget

* Fix font loading race condition
This commit is contained in:
Keavon Chambers
2021-06-06 02:17:36 -07:00
committed by GitHub
parent eae2d70e93
commit 91303459a9
10 changed files with 339 additions and 46 deletions

View File

@@ -2,7 +2,7 @@
<LayoutCol :class="'document'">
<LayoutRow :class="'options-bar'">
<div class="left side">
<span class="label">Select</span>
<DropdownInput :menuEntries="modeMenuEntries" :default="modeMenuEntries[0][0]" :drawIcon="true" />
<Separator :type="SeparatorType.Section" />
@@ -132,11 +132,6 @@
display: flex;
align-items: center;
margin: 0 4px;
.label {
white-space: nowrap;
font-weight: bold;
}
}
}
@@ -177,21 +172,19 @@ import IconButton from "../widgets/buttons/IconButton.vue";
import PopoverButton from "../widgets/buttons/PopoverButton.vue";
import RadioInput from "../widgets/inputs/RadioInput.vue";
import NumberInput from "../widgets/inputs/NumberInput.vue";
import DropdownInput from "../widgets/inputs/DropdownInput.vue";
import { SectionsOfMenuListEntries } from "../widgets/floating-menus/MenuList.vue";
const modeMenuEntries: SectionsOfMenuListEntries = [
[
{ label: "Design Mode", icon: "ViewportDesignMode" },
{ label: "Select Mode", icon: "ViewportSelectMode" },
],
];
const wasm = import("../../../wasm/pkg");
export default defineComponent({
components: {
LayoutRow,
LayoutCol,
WorkingColors,
ShelfItem,
Separator,
IconButton,
PopoverButton,
RadioInput,
NumberInput,
},
methods: {
async canvasMouseDown(e: MouseEvent) {
const { on_mouse_down } = await wasm;
@@ -259,7 +252,20 @@ export default defineComponent({
MenuDirection,
SeparatorDirection,
SeparatorType,
modeMenuEntries,
};
},
components: {
LayoutRow,
LayoutCol,
WorkingColors,
ShelfItem,
Separator,
IconButton,
PopoverButton,
RadioInput,
NumberInput,
DropdownInput,
},
});
</script>

View File

@@ -1,6 +1,10 @@
<template>
<LayoutCol :class="'layer-tree-panel'">
<LayoutRow :class="'options-bar'">
<DropdownInput :menuEntries="blendModeMenuEntries" :default="blendModeMenuEntries[0][0]" />
<Separator :type="SeparatorType.Related" />
<NumberInput :value="100" :unit="`%`" />
<Separator :type="SeparatorType.Related" />
@@ -38,6 +42,10 @@
margin: 0 4px;
align-items: center;
.dropdown-input {
flex: 0 0 auto;
}
.number-input {
flex: 1 1 100%;
}
@@ -88,19 +96,21 @@ import PopoverButton from "../widgets/buttons/PopoverButton.vue";
import { MenuDirection } from "../widgets/floating-menus/FloatingMenu.vue";
import IconButton from "../widgets/buttons/IconButton.vue";
import Icon from "../widgets/labels/Icon.vue";
import DropdownInput from "../widgets/inputs/DropdownInput.vue";
import { SectionsOfMenuListEntries } from "../widgets/floating-menus/MenuList.vue";
const wasm = import("../../../wasm/pkg");
const blendModeMenuEntries: SectionsOfMenuListEntries = [
[{ label: "Normal" }],
[{ label: "Multiply" }, { label: "Darken" }, { label: "Color Burn" }, { label: "Linear Burn" }, { label: "Darker Color" }],
[{ label: "Screen" }, { label: "Lighten" }, { label: "Color Dodge" }, { label: "Linear Dodge (Add)" }, { label: "Lighter Color" }],
[{ label: "Overlay" }, { label: "Soft Light" }, { label: "Hard Light" }, { label: "Vivid Light" }, { label: "Linear Light" }, { label: "Pin Light" }, { label: "Hard Mix" }],
[{ label: "Difference" }, { label: "Exclusion" }, { label: "Subtract" }, { label: "Divide" }],
[{ label: "Hue" }, { label: "Saturation" }, { label: "Color" }, { label: "Luminosity" }],
];
export default defineComponent({
components: {
LayoutRow,
LayoutCol,
Separator,
PopoverButton,
NumberInput,
IconButton,
Icon,
},
props: {},
methods: {
async toggleLayerVisibility(path: BigUint64Array) {
@@ -125,10 +135,21 @@ export default defineComponent({
},
data() {
return {
blendModeMenuEntries,
MenuDirection,
SeparatorType,
layers: [] as Array<LayerPanelEntry>,
};
},
components: {
LayoutRow,
LayoutCol,
Separator,
PopoverButton,
NumberInput,
IconButton,
Icon,
DropdownInput,
},
});
</script>

View File

@@ -2,7 +2,7 @@
<div class="floating-menu" :class="[direction.toLowerCase(), type.toLowerCase()]" v-if="open" ref="floatingMenu">
<div class="tail" v-if="type === MenuType.Popover"></div>
<div class="floating-menu-container" ref="floatingMenuContainer">
<div class="floating-menu-content" ref="floatingMenuContent">
<div class="floating-menu-content" ref="floatingMenuContent" :style="{ minWidth: minWidth > 0 ? `${minWidth}px` : undefined }">
<slot></slot>
</div>
</div>
@@ -192,6 +192,7 @@ export default defineComponent({
direction: { type: String, default: MenuDirection.Bottom },
type: { type: String, required: true },
windowEdgeMargin: { type: Number, default: 8 },
minWidth: { type: Number, default: 0 },
},
data() {
return {
@@ -237,6 +238,27 @@ export default defineComponent({
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;
@@ -299,6 +321,7 @@ export default defineComponent({
},
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;

View File

@@ -6,19 +6,28 @@
v-for="(entry, entryIndex) in section"
:key="entryIndex"
class="row"
:class="{ open: isMenuEntryOpen(entry) }"
:class="{ open: isMenuEntryOpen(entry), active: entry === activeEntry }"
@click="handleEntryClick(entry)"
@mouseenter="handleEntryMouseEnter(entry)"
@mouseleave="handleEntryMouseLeave(entry)"
:data-hover-menu-spawner-extend="entry.children && []"
>
<Icon :icon="entry.icon" v-if="entry.icon" />
<div class="no-icon" v-else />
<span class="label">{{ entry.label }}</span>
<Icon :icon="entry.icon" v-if="entry.icon && drawIcon" />
<div class="no-icon" v-else-if="drawIcon" />
<span class="entry-label">{{ entry.label }}</span>
<UserInputLabel v-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" :menuEntries="entry.children" :direction="MenuDirection.TopRight" :ref="(ref) => setEntryRefs(entry, ref)" />
<MenuList
v-if="entry.children"
:direction="MenuDirection.TopRight"
:menuEntries="entry.children"
:activeEntry="activeEntry"
:minWidth="minWidth"
:defaultAction="defaultAction"
:drawIcon="drawIcon"
:ref="(ref) => setEntryRefs(entry, ref)"
/>
</div>
</template>
</FloatingMenu>
@@ -27,8 +36,9 @@
<style lang="scss">
.menu-list {
.floating-menu-container .floating-menu-content {
min-width: 240px;
padding: 4px 0;
position: absolute;
min-width: 100%;
.row {
height: 20px;
@@ -36,6 +46,7 @@
align-items: center;
white-space: nowrap;
position: relative;
flex: 0 0 auto;
& > * {
flex: 0 0 auto;
@@ -49,18 +60,23 @@
width: 16px;
}
.label {
.entry-label {
flex: 1 1 100%;
margin-left: 8px;
}
.icon,
.no-icon,
.label {
.no-icon {
margin: 0 4px;
& + .entry-label {
margin-left: 0;
}
}
.user-input-label {
margin: 0;
margin-left: 4px;
}
.submenu-arrow {
@@ -77,14 +93,19 @@
.submenu-arrow,
.no-submenu-arrow {
margin-left: 4px;
margin-right: 2px;
margin-left: 6px;
margin-right: 4px;
}
&:hover,
&.open {
&.open,
&.active {
background: var(--color-6-lowergray);
&.active {
background: var(--color-accent);
}
svg {
fill: var(--color-f-white);
}
@@ -106,28 +127,38 @@ import Icon from "../labels/Icon.vue";
import UserInputLabel from "../labels/UserInputLabel.vue";
export type MenuListEntries = Array<MenuListEntry>;
export type SectionsOfMenuListEntries = Array<MenuListEntries>;
export interface MenuListEntry {
interface MenuListEntryData {
label?: string;
icon?: string;
// TODO: Add `checkbox` (which overrides any `icon`)
shortcut?: Array<string>;
action?: Function;
children?: Array<Array<MenuListEntry>>;
ref?: typeof FloatingMenu | typeof MenuList;
children?: SectionsOfMenuListEntries;
}
export type MenuListEntry = MenuListEntryData & { ref?: typeof FloatingMenu | typeof MenuList };
const MenuList = defineComponent({
props: {
direction: { type: String as PropType<MenuDirection>, value: MenuDirection.Bottom },
menuEntries: { type: Array as PropType<MenuListEntries>, required: true },
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 },
minWidth: { type: Number, default: 0 },
defaultAction: { type: Function, required: false },
widthChanged: { type: Function, required: false },
drawIcon: { 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.action) menuEntry.action();
else alert("This action is not yet implemented");
else if (this.defaultAction) this.defaultAction(menuEntry);
},
handleEntryMouseEnter(menuEntry: MenuListEntry) {
if (!menuEntry.children || !menuEntry.children.length) return;
@@ -161,6 +192,57 @@ const MenuList = defineComponent({
const floatingMenu = this.$refs.floatingMenu as typeof FloatingMenu;
return Boolean(floatingMenu && floatingMenu.isOpen());
},
measureAndReportWidth() {
const { widthChanged } = this;
if (!widthChanged) return;
// 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();
widthChanged(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 {

View File

@@ -0,0 +1,113 @@
<template>
<div class="dropdown-input">
<div class="dropdown-box" :style="{ minWidth: `${minWidth}px` }" @click="clickDropdownBox" data-hover-menu-spawner>
<Icon :class="'dropdown-icon'" :icon="activeEntry.icon" v-if="activeEntry.icon" />
<span>{{ activeEntry.label }}</span>
<Icon :class="'dropdown-arrow'" :icon="'DropdownArrow'" />
</div>
<MenuList
:menuEntries="menuEntries"
:activeEntry="activeEntry"
:defaultAction="setActiveEntry"
:direction="MenuDirection.Bottom"
:widthChanged="widthChanged"
:drawIcon="drawIcon"
ref="menuList"
/>
</div>
</template>
<style lang="scss">
.dropdown-input {
position: relative;
.dropdown-box {
display: flex;
align-items: center;
white-space: nowrap;
background: var(--color-1-nearblack);
height: 24px;
border-radius: 2px;
.dropdown-icon {
margin: 4px;
flex: 0 0 auto;
}
span {
display: inline-block;
margin: 0;
margin-left: 8px;
flex: 1 1 100%;
}
.dropdown-icon + span {
margin-left: 0;
}
.dropdown-arrow {
margin: 6px 2px;
flex: 0 0 auto;
}
&:hover,
&.open {
background: var(--color-6-lowergray);
span {
color: var(--color-f-white);
}
svg {
fill: var(--color-f-white);
}
}
&.open {
border-radius: 2px 2px 0 0;
}
}
.menu-list .floating-menu-container .floating-menu-content {
max-height: 400px;
overflow-y: auto;
}
}
</style>
<script lang="ts">
import { defineComponent, PropType } from "vue";
import Icon from "../labels/Icon.vue";
import MenuList, { MenuListEntry, SectionsOfMenuListEntries } from "../floating-menus/MenuList.vue";
import { MenuDirection } from "../floating-menus/FloatingMenu.vue";
export default defineComponent({
props: {
menuEntries: { type: Array as PropType<SectionsOfMenuListEntries>, required: true },
default: { type: Object as PropType<MenuListEntry>, required: true },
drawIcon: { type: Boolean, default: false },
},
data() {
return {
activeEntry: this.default,
MenuDirection,
minWidth: 0,
};
},
methods: {
clickDropdownBox() {
(this.$refs.menuList as typeof MenuList).setOpen();
},
setActiveEntry(newActiveEntry: MenuListEntry) {
this.activeEntry = newActiveEntry;
},
widthChanged(newWidth: number) {
this.minWidth = newWidth;
},
},
components: {
Icon,
MenuList,
},
});
</script>

View File

@@ -5,7 +5,15 @@
<Icon :icon="entry.icon" v-if="entry.icon" />
<span v-if="entry.label">{{ entry.label }}</span>
</div>
<MenuList :menuEntries="entry.children" :direction="MenuDirection.Bottom" :ref="(ref) => setEntryRefs(entry, ref)" />
<MenuList
:ourEntry="entry"
:menuEntries="entry.children"
:direction="MenuDirection.Bottom"
:minWidth="240"
:drawIcon="true"
:defaultAction="actionNotImplemented"
:ref="(ref) => setEntryRefs(entry, ref)"
/>
</div>
</div>
</template>
@@ -140,6 +148,9 @@ export default defineComponent({
if (menuEntry.ref) menuEntry.ref.setOpen();
else throw new Error("The menu bar floating menu has no associated ref");
},
actionNotImplemented() {
alert("This action is not yet implemented");
},
},
data() {
return {

View File

@@ -77,6 +77,8 @@ import GraphiteLogo from "../../../../assets/16px-solid/graphite-logo.svg";
import File from "../../../../assets/16px-solid/file.svg";
import Copy from "../../../../assets/16px-solid/copy.svg";
import Paste from "../../../../assets/16px-solid/paste.svg";
import ViewportDesignMode from "../../../../assets/16px-solid/viewport-design-mode.svg";
import ViewportSelectMode from "../../../../assets/16px-solid/viewport-select-mode.svg";
import SwapButton from "../../../../assets/12px-solid/swap.svg";
import ResetColorsButton from "../../../../assets/12px-solid/reset-colors.svg";
@@ -148,6 +150,8 @@ const icons = {
File: { component: File, size: 16 },
Copy: { component: Copy, size: 16 },
Paste: { component: Paste, size: 16 },
ViewportDesignMode: { component: ViewportDesignMode, size: 16 },
ViewportSelectMode: { component: ViewportSelectMode, size: 16 },
SwapButton: { component: SwapButton, size: 12 },
ResetColorsButton: { component: ResetColorsButton, size: 12 },
DropdownArrow: { component: DropdownArrow, size: 12 },

View File

@@ -0,0 +1,21 @@
<template>
<span class="text-label">
<slot></slot>
</span>
</template>
<style lang="scss">
.text-label {
white-space: nowrap;
font-weight: bold;
}
</style>
<script lang="ts">
import { defineComponent } from "vue";
export default defineComponent({
components: {},
props: {},
});
</script>