Add font menu previews and virtual scrolling (#650)

* Keyboard menu navigation

* Fix dropdown keyboard navigation

* Fix merge error

* Some code review

* Interactive dropdowns

* Query by data attr not class name

* Add locking behaviour

* Add font prieviews

* Remove blank line in css

* Use default for interactive in struct

* Use menulist for fontinput

* Polish

* Rename state -> manager

* Code review

* Cleanup fontinput

* More cleanup

* Make fonts.ts an empty state

* Fix regression

Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
0HyperCube 2022-06-10 23:21:10 +01:00 committed by Keavon Chambers
parent a26a0ddfcf
commit 3a30cdbb70
4 changed files with 151 additions and 103 deletions

View File

@ -681,6 +681,7 @@ impl DocumentMessageHandler {
]], ]],
selected_index: Some(self.document_mode as u32), selected_index: Some(self.document_mode as u32),
draw_icon: true, draw_icon: true,
interactive: false, // TODO: set to true when dialogs are not spawned
..Default::default() ..Default::default()
})), })),
WidgetHolder::new(Widget::Separator(Separator { WidgetHolder::new(Widget::Separator(Separator {

View File

@ -6,44 +6,57 @@
:type="'Dropdown'" :type="'Dropdown'"
:windowEdgeMargin="0" :windowEdgeMargin="0"
:escapeCloses="false" :escapeCloses="false"
v-bind="{ direction, scrollableY, minWidth }" v-bind="{ direction, scrollableY: scrollableY && virtualScrollingEntryHeight === 0, minWidth }"
ref="floatingMenu" ref="floatingMenu"
data-hover-menu-keep-open data-hover-menu-keep-open
> >
<template v-for="(section, sectionIndex) in entries" :key="sectionIndex"> <!-- If we put the scrollableY on the layoutcol for non-font dropdowns then for some reason it always creates a tiny scrollbar.
<Separator :type="'List'" :direction="'Vertical'" v-if="sectionIndex > 0" /> However when we are using the virtual scrolling then we need the layoutcol to be scrolling so we can bind the events without using $refs. -->
<LayoutCol ref="scroller" :scrollableY="scrollableY && virtualScrollingEntryHeight !== 0" @scroll="onScroll" :style="{ minWidth: virtualScrollingEntryHeight ? `${minWidth}px` : `inherit` }">
<LayoutRow v-if="virtualScrollingEntryHeight" class="scroll-spacer" :style="{ height: `${virtualScrollingStartIndex * virtualScrollingEntryHeight}px` }"></LayoutRow>
<template v-for="(section, sectionIndex) in entries" :key="sectionIndex">
<Separator :type="'List'" :direction="'Vertical'" v-if="sectionIndex > 0" />
<LayoutRow
v-for="(entry, entryIndex) in virtualScrollingEntryHeight ? section.slice(virtualScrollingStartIndex, virtualScrollingEndIndex) : section"
:key="entryIndex + (virtualScrollingEntryHeight ? virtualScrollingStartIndex : 0)"
class="row"
:class="{ open: isEntryOpen(entry), active: entry.label === highlighted?.label }"
:style="{ height: virtualScrollingEntryHeight || '20px' }"
@click="() => onEntryClick(entry)"
@pointerenter="() => onEntryPointerEnter(entry)"
@pointerleave="() => onEntryPointerLeave(entry)"
>
<CheckboxInput v-if="entry.checkbox" v-model:checked="entry.checked" :outlineStyle="true" :disableTabIndex="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>
<link v-if="entry.font" rel="stylesheet" :href="entry.font?.toString()" />
<span class="entry-label" :style="{ fontFamily: `${!entry.font ? 'inherit' : entry.value}` }">{{ 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>
<LayoutRow <LayoutRow
v-for="(entry, entryIndex) in section" v-if="virtualScrollingEntryHeight"
:key="entryIndex" class="scroll-spacer"
class="row" :style="{ height: `${virtualScrollingTotalHeight - virtualScrollingEndIndex * virtualScrollingEntryHeight}px` }"
:class="{ open: isEntryOpen(entry), active: entry.label === highlighted?.label }" ></LayoutRow>
@click="() => onEntryClick(entry)" </LayoutCol>
@pointerenter="() => onEntryPointerEnter(entry)"
@pointerleave="() => onEntryPointerLeave(entry)"
>
<CheckboxInput v-if="entry.checkbox" v-model:checked="entry.checked" :outlineStyle="true" :disableTabIndex="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> </FloatingMenu>
</template> </template>
@ -52,6 +65,10 @@
.floating-menu-container .floating-menu-content { .floating-menu-container .floating-menu-content {
padding: 4px 0; padding: 4px 0;
.scroll-spacer {
flex: 0 0 auto;
}
.row { .row {
height: 20px; height: 20px;
align-items: center; align-items: center;
@ -145,6 +162,7 @@ import { defineComponent, PropType } from "vue";
import { IconName } from "@/utility-functions/icons"; import { IconName } from "@/utility-functions/icons";
import FloatingMenu, { MenuDirection } from "@/components/floating-menus/FloatingMenu.vue"; import FloatingMenu, { MenuDirection } from "@/components/floating-menus/FloatingMenu.vue";
import LayoutCol from "@/components/layout/LayoutCol.vue";
import LayoutRow from "@/components/layout/LayoutRow.vue"; import LayoutRow from "@/components/layout/LayoutRow.vue";
import CheckboxInput from "@/components/widgets/inputs/CheckboxInput.vue"; import CheckboxInput from "@/components/widgets/inputs/CheckboxInput.vue";
import IconLabel from "@/components/widgets/labels/IconLabel.vue"; import IconLabel from "@/components/widgets/labels/IconLabel.vue";
@ -158,6 +176,7 @@ interface MenuListEntryData<Value = string> {
value?: Value; value?: Value;
label?: string; label?: string;
icon?: IconName; icon?: IconName;
font?: URL;
checkbox?: boolean; checkbox?: boolean;
shortcut?: string[]; shortcut?: string[];
shortcutRequiresLock?: boolean; shortcutRequiresLock?: boolean;
@ -182,6 +201,7 @@ const MenuList = defineComponent({
drawIcon: { type: Boolean as PropType<boolean>, default: false }, drawIcon: { type: Boolean as PropType<boolean>, default: false },
interactive: { type: Boolean as PropType<boolean>, default: false }, interactive: { type: Boolean as PropType<boolean>, default: false },
scrollableY: { type: Boolean as PropType<boolean>, default: false }, scrollableY: { type: Boolean as PropType<boolean>, default: false },
virtualScrollingEntryHeight: { type: Number as PropType<number>, default: 0 },
defaultAction: { type: Function as PropType<() => void>, required: false }, defaultAction: { type: Function as PropType<() => void>, required: false },
}, },
data() { data() {
@ -189,6 +209,7 @@ const MenuList = defineComponent({
isOpen: this.open, isOpen: this.open,
keyboardLockInfoMessage: this.fullscreen.keyboardLockApiSupported ? KEYBOARD_LOCK_USE_FULLSCREEN : KEYBOARD_LOCK_SWITCH_BROWSER, keyboardLockInfoMessage: this.fullscreen.keyboardLockApiSupported ? KEYBOARD_LOCK_USE_FULLSCREEN : KEYBOARD_LOCK_SWITCH_BROWSER,
highlighted: this.activeEntry as MenuListEntry | undefined, highlighted: this.activeEntry as MenuListEntry | undefined,
virtualScrollingEntriesStart: 0,
}; };
}, },
watch: { watch: {
@ -326,6 +347,10 @@ const MenuList = defineComponent({
// Interactive menus should keep the active entry the same as the highlighted one // Interactive menus should keep the active entry the same as the highlighted one
if (this.interactive && newHighlight?.value !== this.activeEntry?.value) this.$emit("update:activeEntry", newHighlight); if (this.interactive && newHighlight?.value !== this.activeEntry?.value) this.$emit("update:activeEntry", newHighlight);
}, },
onScroll(e: Event) {
if (!this.virtualScrollingEntryHeight) return;
this.virtualScrollingEntriesStart = (e.target as HTMLElement)?.scrollTop || 0;
},
}, },
computed: { computed: {
entriesWithoutRefs(): MenuListEntryData[][] { entriesWithoutRefs(): MenuListEntryData[][] {
@ -336,6 +361,15 @@ const MenuList = defineComponent({
}) })
); );
}, },
virtualScrollingTotalHeight() {
return this.entries[0].length * this.virtualScrollingEntryHeight;
},
virtualScrollingStartIndex() {
return Math.floor(this.virtualScrollingEntriesStart / this.virtualScrollingEntryHeight);
},
virtualScrollingEndIndex() {
return Math.min(this.entries[0].length, this.virtualScrollingStartIndex + 1 + 400 / this.virtualScrollingEntryHeight);
},
}, },
components: { components: {
FloatingMenu, FloatingMenu,
@ -344,6 +378,7 @@ const MenuList = defineComponent({
CheckboxInput, CheckboxInput,
UserInputLabel, UserInputLabel,
LayoutRow, LayoutRow,
LayoutCol,
}, },
}); });
export default MenuList; export default MenuList;

View File

@ -1,17 +1,19 @@
<template> <template>
<LayoutRow class="font-input"> <LayoutRow class="font-input">
<LayoutRow class="dropdown-box" :class="{ disabled }" :style="{ minWidth: `${minWidth}px` }" @click="() => !disabled && (open = true)" data-hover-menu-spawner> <LayoutRow class="dropdown-box" :class="{ disabled }" :style="{ minWidth: `${minWidth}px` }" tabindex="0" @click="toggleOpen" @keydown="keydown" data-hover-menu-spawner>
<span>{{ activeEntry?.label || "" }}</span> <span>{{ activeEntry?.value || "" }}</span>
<IconLabel class="dropdown-arrow" :icon="'DropdownArrow'" /> <IconLabel class="dropdown-arrow" :icon="'DropdownArrow'" />
</LayoutRow> </LayoutRow>
<MenuList <MenuList
ref="menulist"
v-model:activeEntry="activeEntry" v-model:activeEntry="activeEntry"
v-model:open="open" v-model:open="open"
@naturalWidth="(newNaturalWidth: number) => (minWidth = newNaturalWidth)" :entries="[entries]"
:entries="entries" :minWidth="isStyle ? 0 : minWidth"
:direction="'Bottom'" :virtualScrollingEntryHeight="isStyle ? 0 : 20"
:scrollableY="true" :scrollableY="true"
/> @naturalWidth="(newNaturalWidth: number) => (isStyle && (minWidth = newNaturalWidth))"
></MenuList>
</LayoutRow> </LayoutRow>
</template> </template>
@ -26,21 +28,12 @@
height: 24px; height: 24px;
border-radius: 2px; border-radius: 2px;
.dropdown-icon {
margin: 4px;
flex: 0 0 auto;
}
span { span {
margin: 0; margin: 0;
margin-left: 8px; margin-left: 8px;
flex: 1 1 100%; flex: 1 1 100%;
} }
.dropdown-icon + span {
margin-left: 0;
}
.dropdown-arrow { .dropdown-arrow {
margin: 6px 2px; margin: 6px 2px;
flex: 0 0 auto; flex: 0 0 auto;
@ -53,10 +46,6 @@
span { span {
color: var(--color-f-white); color: var(--color-f-white);
} }
svg {
fill: var(--color-f-white);
}
} }
&.open { &.open {
@ -69,23 +58,23 @@
span { span {
color: var(--color-8-uppergray); color: var(--color-8-uppergray);
} }
svg {
fill: var(--color-8-uppergray);
}
} }
} }
.menu-list .floating-menu-container .floating-menu-content { .menu-list .floating-menu-container .floating-menu-content {
max-height: 400px; max-height: 400px;
padding: 4px 0;
} }
} }
</style> </style>
<script lang="ts"> <script lang="ts">
import { defineComponent, PropType } from "vue"; import { defineComponent, nextTick, PropType } from "vue";
import MenuList, { MenuListEntry, SectionsOfMenuListEntries } from "@/components/floating-menus/MenuList.vue"; import FloatingMenu from "@/components/floating-menus/FloatingMenu.vue";
import MenuList, { MenuListEntry } from "@/components/floating-menus/MenuList.vue";
import LayoutCol from "@/components/layout/LayoutCol.vue";
import LayoutRow from "@/components/layout/LayoutRow.vue"; import LayoutRow from "@/components/layout/LayoutRow.vue";
import IconLabel from "@/components/widgets/labels/IconLabel.vue"; import IconLabel from "@/components/widgets/labels/IconLabel.vue";
@ -101,17 +90,42 @@ export default defineComponent({
data() { data() {
return { return {
open: false, open: false,
minWidth: 0, entries: [] as MenuListEntry[],
entries: [] as SectionsOfMenuListEntries, activeEntry: undefined as MenuListEntry | undefined,
activeEntry: undefined as undefined | MenuListEntry, highlighted: undefined as MenuListEntry | undefined,
entriesStart: 0,
minWidth: this.isStyle ? 0 : 300,
}; };
}, },
async mounted() { async mounted() {
const { entries, activeEntry } = await this.updateEntries(); this.entries = await this.getEntries();
this.entries = entries; this.activeEntry = this.getActiveEntry(this.entries);
this.activeEntry = activeEntry; this.highlighted = this.activeEntry;
}, },
methods: { methods: {
floatingMenu() {
return this.$refs.floatingMenu as typeof FloatingMenu;
},
scroller() {
return ((this.$refs.menulist as typeof MenuList).$refs.scroller as typeof LayoutCol)?.$el as HTMLElement;
},
async setOpen() {
this.open = true;
// Scroll to the active entry (the scroller div does not yet exist so we must wait for vue to render)
await nextTick();
if (this.activeEntry) {
const index = this.entries.indexOf(this.activeEntry);
this.scroller()?.scrollTo(0, Math.max(0, index * 20 - 190));
}
},
toggleOpen() {
if (this.disabled) return;
this.open = !this.open;
if (this.open) this.setOpen();
},
keydown(e: KeyboardEvent) {
(this.$refs.menulist as typeof MenuList).keydown(e, false);
},
async selectFont(newName: string): Promise<void> { async selectFont(newName: string): Promise<void> {
let fontFamily; let fontFamily;
let fontStyle; let fontStyle;
@ -125,50 +139,43 @@ export default defineComponent({
this.$emit("update:fontFamily", newName); this.$emit("update:fontFamily", newName);
fontFamily = newName; fontFamily = newName;
fontStyle = (await this.fonts.getFontStyles(newName))[0]; fontStyle = "Normal (400)";
} }
const fontFileUrl = await this.fonts.getFontFileUrl(fontFamily, fontStyle); const fontFileUrl = await this.fonts.getFontFileUrl(fontFamily, fontStyle);
this.$emit("changeFont", { fontFamily, fontStyle, fontFileUrl }); this.$emit("changeFont", { fontFamily, fontStyle, fontFileUrl });
}, },
async updateEntries(): Promise<{ entries: SectionsOfMenuListEntries; activeEntry: MenuListEntry }> { async getEntries(): Promise<MenuListEntry[]> {
const choices = this.isStyle ? await this.fonts.getFontStyles(this.fontFamily) : this.fonts.state.fontNames; const x = this.isStyle ? this.fonts.getFontStyles(this.fontFamily) : this.fonts.fontNames();
return (await x).map((entry: { name: string; url: URL | undefined }) => ({
label: entry.name,
value: entry.name,
font: entry.url,
action: () => this.selectFont(entry.name),
}));
},
getActiveEntry(entries: MenuListEntry[]): MenuListEntry {
const selectedChoice = this.isStyle ? this.fontStyle : this.fontFamily; const selectedChoice = this.isStyle ? this.fontStyle : this.fontFamily;
let selectedEntry: MenuListEntry | undefined; return entries.find((entry) => entry.value === selectedChoice) as MenuListEntry;
const menuListEntries = choices.map((name) => {
const result: MenuListEntry = {
label: name,
action: async (): Promise<void> => this.selectFont(name),
};
if (name === selectedChoice) selectedEntry = result;
return result;
});
const entries: SectionsOfMenuListEntries = [menuListEntries];
const activeEntry = selectedEntry || { label: "-" };
return { entries, activeEntry };
}, },
}, },
watch: { watch: {
async fontFamily() { async fontFamily() {
const { entries, activeEntry } = await this.updateEntries(); this.entries = await this.getEntries();
this.entries = entries; this.activeEntry = this.getActiveEntry(this.entries);
this.activeEntry = activeEntry; this.highlighted = this.activeEntry;
}, },
async fontStyle() { async fontStyle() {
const { entries, activeEntry } = await this.updateEntries(); this.entries = await this.getEntries();
this.entries = entries; this.activeEntry = this.getActiveEntry(this.entries);
this.activeEntry = activeEntry; this.highlighted = this.activeEntry;
}, },
}, },
components: { components: {
LayoutRow,
IconLabel, IconLabel,
MenuList, MenuList,
LayoutRow,
}, },
}); });
</script> </script>

View File

@ -1,17 +1,27 @@
import { reactive, readonly } from "vue"; import { reactive } from "vue";
import { Editor } from "@/wasm-communication/editor"; import { Editor } from "@/wasm-communication/editor";
import { TriggerFontLoad } from "@/wasm-communication/messages"; import { TriggerFontLoad } from "@/wasm-communication/messages";
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export function createFontsState(editor: Editor) { export function createFontsState(editor: Editor) {
const state = reactive({ const state = reactive({});
fontNames: [] as string[],
});
async function getFontStyles(fontFamily: string): Promise<string[]> { function createURL(font: string): URL {
const url = new URL("https://fonts.googleapis.com/css2");
url.searchParams.set("display", "swap");
url.searchParams.set("family", font);
url.searchParams.set("text", font);
return url;
}
async function fontNames(): Promise<{ name: string; url: URL | undefined }[]> {
return (await fontList).map((font) => ({ name: font.family, url: createURL(font.family) }));
}
async function getFontStyles(fontFamily: string): Promise<{ name: string; url: URL | undefined }[]> {
const font = (await fontList).find((value) => value.family === fontFamily); const font = (await fontList).find((value) => value.family === fontFamily);
return font?.variants || []; return font?.variants.map((variant) => ({ name: variant, url: undefined })) || [];
} }
async function getFontFileUrl(fontFamily: string, fontStyle: string): Promise<string | undefined> { async function getFontFileUrl(fontFamily: string, fontStyle: string): Promise<string | undefined> {
@ -58,17 +68,12 @@ export function createFontsState(editor: Editor) {
const files = new Map(font.variants.map((x) => [formatFontStyleName(x), font.files[x]])); const files = new Map(font.variants.map((x) => [formatFontStyleName(x), font.files[x]]));
return { family, variants, files }; return { family, variants, files };
}); });
state.fontNames = result.map((value) => value.family);
resolve(result); resolve(result);
}); });
}); });
return { return { state, fontNames, getFontStyles, getFontFileUrl };
state: readonly(state) as typeof state,
getFontStyles,
getFontFileUrl,
};
} }
export type FontsState = ReturnType<typeof createFontsState>; export type FontsState = ReturnType<typeof createFontsState>;