Skip to content

Commit 3a30cdb

Browse files
0HyperCubeKeavon
andcommitted
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 <[email protected]>
1 parent a26a0dd commit 3a30cdb

File tree

4 files changed

+151
-103
lines changed

4 files changed

+151
-103
lines changed

editor/src/document/document_message_handler.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -681,6 +681,7 @@ impl DocumentMessageHandler {
681681
]],
682682
selected_index: Some(self.document_mode as u32),
683683
draw_icon: true,
684+
interactive: false, // TODO: set to true when dialogs are not spawned
684685
..Default::default()
685686
})),
686687
WidgetHolder::new(Widget::Separator(Separator {

frontend/src/components/floating-menus/MenuList.vue

Lines changed: 69 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -6,44 +6,57 @@
66
:type="'Dropdown'"
77
:windowEdgeMargin="0"
88
:escapeCloses="false"
9-
v-bind="{ direction, scrollableY, minWidth }"
9+
v-bind="{ direction, scrollableY: scrollableY && virtualScrollingEntryHeight === 0, minWidth }"
1010
ref="floatingMenu"
1111
data-hover-menu-keep-open
1212
>
13-
<template v-for="(section, sectionIndex) in entries" :key="sectionIndex">
14-
<Separator :type="'List'" :direction="'Vertical'" v-if="sectionIndex > 0" />
13+
<!-- If we put the scrollableY on the layoutcol for non-font dropdowns then for some reason it always creates a tiny scrollbar.
14+
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. -->
15+
<LayoutCol ref="scroller" :scrollableY="scrollableY && virtualScrollingEntryHeight !== 0" @scroll="onScroll" :style="{ minWidth: virtualScrollingEntryHeight ? `${minWidth}px` : `inherit` }">
16+
<LayoutRow v-if="virtualScrollingEntryHeight" class="scroll-spacer" :style="{ height: `${virtualScrollingStartIndex * virtualScrollingEntryHeight}px` }"></LayoutRow>
17+
<template v-for="(section, sectionIndex) in entries" :key="sectionIndex">
18+
<Separator :type="'List'" :direction="'Vertical'" v-if="sectionIndex > 0" />
19+
<LayoutRow
20+
v-for="(entry, entryIndex) in virtualScrollingEntryHeight ? section.slice(virtualScrollingStartIndex, virtualScrollingEndIndex) : section"
21+
:key="entryIndex + (virtualScrollingEntryHeight ? virtualScrollingStartIndex : 0)"
22+
class="row"
23+
:class="{ open: isEntryOpen(entry), active: entry.label === highlighted?.label }"
24+
:style="{ height: virtualScrollingEntryHeight || '20px' }"
25+
@click="() => onEntryClick(entry)"
26+
@pointerenter="() => onEntryPointerEnter(entry)"
27+
@pointerleave="() => onEntryPointerLeave(entry)"
28+
>
29+
<CheckboxInput v-if="entry.checkbox" v-model:checked="entry.checked" :outlineStyle="true" :disableTabIndex="true" class="entry-checkbox" />
30+
<IconLabel v-else-if="entry.icon && drawIcon" :icon="entry.icon" class="entry-icon" />
31+
<div v-else-if="drawIcon" class="no-icon"></div>
32+
33+
<link v-if="entry.font" rel="stylesheet" :href="entry.font?.toString()" />
34+
35+
<span class="entry-label" :style="{ fontFamily: `${!entry.font ? 'inherit' : entry.value}` }">{{ entry.label }}</span>
36+
37+
<IconLabel v-if="entry.shortcutRequiresLock && !fullscreen.state.keyboardLocked" :icon="'Info'" :title="keyboardLockInfoMessage" />
38+
<UserInputLabel v-else-if="entry.shortcut?.length" :inputKeys="[entry.shortcut]" />
39+
40+
<div class="submenu-arrow" v-if="entry.children?.length"></div>
41+
<div class="no-submenu-arrow" v-else></div>
42+
43+
<MenuList
44+
v-if="entry.children"
45+
@naturalWidth="(newNaturalWidth: number) => $emit('naturalWidth', newNaturalWidth)"
46+
:open="entry.ref?.open || false"
47+
:direction="'TopRight'"
48+
:entries="entry.children"
49+
v-bind="{ defaultAction, minWidth, drawIcon, scrollableY }"
50+
:ref="(ref: typeof FloatingMenu) => ref && (entry.ref = ref)"
51+
/>
52+
</LayoutRow>
53+
</template>
1554
<LayoutRow
16-
v-for="(entry, entryIndex) in section"
17-
:key="entryIndex"
18-
class="row"
19-
:class="{ open: isEntryOpen(entry), active: entry.label === highlighted?.label }"
20-
@click="() => onEntryClick(entry)"
21-
@pointerenter="() => onEntryPointerEnter(entry)"
22-
@pointerleave="() => onEntryPointerLeave(entry)"
23-
>
24-
<CheckboxInput v-if="entry.checkbox" v-model:checked="entry.checked" :outlineStyle="true" :disableTabIndex="true" class="entry-checkbox" />
25-
<IconLabel v-else-if="entry.icon && drawIcon" :icon="entry.icon" class="entry-icon" />
26-
<div v-else-if="drawIcon" class="no-icon"></div>
27-
28-
<span class="entry-label">{{ entry.label }}</span>
29-
30-
<IconLabel v-if="entry.shortcutRequiresLock && !fullscreen.state.keyboardLocked" :icon="'Info'" :title="keyboardLockInfoMessage" />
31-
<UserInputLabel v-else-if="entry.shortcut?.length" :inputKeys="[entry.shortcut]" />
32-
33-
<div class="submenu-arrow" v-if="entry.children?.length"></div>
34-
<div class="no-submenu-arrow" v-else></div>
35-
36-
<MenuList
37-
v-if="entry.children"
38-
@naturalWidth="(newNaturalWidth: number) => $emit('naturalWidth', newNaturalWidth)"
39-
:open="entry.ref?.open || false"
40-
:direction="'TopRight'"
41-
:entries="entry.children"
42-
v-bind="{ defaultAction, minWidth, drawIcon, scrollableY }"
43-
:ref="(ref: typeof FloatingMenu) => ref && (entry.ref = ref)"
44-
/>
45-
</LayoutRow>
46-
</template>
55+
v-if="virtualScrollingEntryHeight"
56+
class="scroll-spacer"
57+
:style="{ height: `${virtualScrollingTotalHeight - virtualScrollingEndIndex * virtualScrollingEntryHeight}px` }"
58+
></LayoutRow>
59+
</LayoutCol>
4760
</FloatingMenu>
4861
</template>
4962

@@ -52,6 +65,10 @@
5265
.floating-menu-container .floating-menu-content {
5366
padding: 4px 0;
5467
68+
.scroll-spacer {
69+
flex: 0 0 auto;
70+
}
71+
5572
.row {
5673
height: 20px;
5774
align-items: center;
@@ -145,6 +162,7 @@ import { defineComponent, PropType } from "vue";
145162
import { IconName } from "@/utility-functions/icons";
146163
147164
import FloatingMenu, { MenuDirection } from "@/components/floating-menus/FloatingMenu.vue";
165+
import LayoutCol from "@/components/layout/LayoutCol.vue";
148166
import LayoutRow from "@/components/layout/LayoutRow.vue";
149167
import CheckboxInput from "@/components/widgets/inputs/CheckboxInput.vue";
150168
import IconLabel from "@/components/widgets/labels/IconLabel.vue";
@@ -158,6 +176,7 @@ interface MenuListEntryData<Value = string> {
158176
value?: Value;
159177
label?: string;
160178
icon?: IconName;
179+
font?: URL;
161180
checkbox?: boolean;
162181
shortcut?: string[];
163182
shortcutRequiresLock?: boolean;
@@ -182,13 +201,15 @@ const MenuList = defineComponent({
182201
drawIcon: { type: Boolean as PropType<boolean>, default: false },
183202
interactive: { type: Boolean as PropType<boolean>, default: false },
184203
scrollableY: { type: Boolean as PropType<boolean>, default: false },
204+
virtualScrollingEntryHeight: { type: Number as PropType<number>, default: 0 },
185205
defaultAction: { type: Function as PropType<() => void>, required: false },
186206
},
187207
data() {
188208
return {
189209
isOpen: this.open,
190210
keyboardLockInfoMessage: this.fullscreen.keyboardLockApiSupported ? KEYBOARD_LOCK_USE_FULLSCREEN : KEYBOARD_LOCK_SWITCH_BROWSER,
191211
highlighted: this.activeEntry as MenuListEntry | undefined,
212+
virtualScrollingEntriesStart: 0,
192213
};
193214
},
194215
watch: {
@@ -326,6 +347,10 @@ const MenuList = defineComponent({
326347
// Interactive menus should keep the active entry the same as the highlighted one
327348
if (this.interactive && newHighlight?.value !== this.activeEntry?.value) this.$emit("update:activeEntry", newHighlight);
328349
},
350+
onScroll(e: Event) {
351+
if (!this.virtualScrollingEntryHeight) return;
352+
this.virtualScrollingEntriesStart = (e.target as HTMLElement)?.scrollTop || 0;
353+
},
329354
},
330355
computed: {
331356
entriesWithoutRefs(): MenuListEntryData[][] {
@@ -336,6 +361,15 @@ const MenuList = defineComponent({
336361
})
337362
);
338363
},
364+
virtualScrollingTotalHeight() {
365+
return this.entries[0].length * this.virtualScrollingEntryHeight;
366+
},
367+
virtualScrollingStartIndex() {
368+
return Math.floor(this.virtualScrollingEntriesStart / this.virtualScrollingEntryHeight);
369+
},
370+
virtualScrollingEndIndex() {
371+
return Math.min(this.entries[0].length, this.virtualScrollingStartIndex + 1 + 400 / this.virtualScrollingEntryHeight);
372+
},
339373
},
340374
components: {
341375
FloatingMenu,
@@ -344,6 +378,7 @@ const MenuList = defineComponent({
344378
CheckboxInput,
345379
UserInputLabel,
346380
LayoutRow,
381+
LayoutCol,
347382
},
348383
});
349384
export default MenuList;
Lines changed: 64 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
<template>
22
<LayoutRow class="font-input">
3-
<LayoutRow class="dropdown-box" :class="{ disabled }" :style="{ minWidth: `${minWidth}px` }" @click="() => !disabled && (open = true)" data-hover-menu-spawner>
4-
<span>{{ activeEntry?.label || "" }}</span>
3+
<LayoutRow class="dropdown-box" :class="{ disabled }" :style="{ minWidth: `${minWidth}px` }" tabindex="0" @click="toggleOpen" @keydown="keydown" data-hover-menu-spawner>
4+
<span>{{ activeEntry?.value || "" }}</span>
55
<IconLabel class="dropdown-arrow" :icon="'DropdownArrow'" />
66
</LayoutRow>
77
<MenuList
8+
ref="menulist"
89
v-model:activeEntry="activeEntry"
910
v-model:open="open"
10-
@naturalWidth="(newNaturalWidth: number) => (minWidth = newNaturalWidth)"
11-
:entries="entries"
12-
:direction="'Bottom'"
11+
:entries="[entries]"
12+
:minWidth="isStyle ? 0 : minWidth"
13+
:virtualScrollingEntryHeight="isStyle ? 0 : 20"
1314
:scrollableY="true"
14-
/>
15+
@naturalWidth="(newNaturalWidth: number) => (isStyle && (minWidth = newNaturalWidth))"
16+
></MenuList>
1517
</LayoutRow>
1618
</template>
1719

@@ -26,21 +28,12 @@
2628
height: 24px;
2729
border-radius: 2px;
2830
29-
.dropdown-icon {
30-
margin: 4px;
31-
flex: 0 0 auto;
32-
}
33-
3431
span {
3532
margin: 0;
3633
margin-left: 8px;
3734
flex: 1 1 100%;
3835
}
3936
40-
.dropdown-icon + span {
41-
margin-left: 0;
42-
}
43-
4437
.dropdown-arrow {
4538
margin: 6px 2px;
4639
flex: 0 0 auto;
@@ -53,10 +46,6 @@
5346
span {
5447
color: var(--color-f-white);
5548
}
56-
57-
svg {
58-
fill: var(--color-f-white);
59-
}
6049
}
6150
6251
&.open {
@@ -69,23 +58,23 @@
6958
span {
7059
color: var(--color-8-uppergray);
7160
}
72-
73-
svg {
74-
fill: var(--color-8-uppergray);
75-
}
7661
}
7762
}
7863
7964
.menu-list .floating-menu-container .floating-menu-content {
8065
max-height: 400px;
66+
padding: 4px 0;
8167
}
8268
}
8369
</style>
8470

8571
<script lang="ts">
86-
import { defineComponent, PropType } from "vue";
72+
import { defineComponent, nextTick, PropType } from "vue";
8773
88-
import MenuList, { MenuListEntry, SectionsOfMenuListEntries } from "@/components/floating-menus/MenuList.vue";
74+
import FloatingMenu from "@/components/floating-menus/FloatingMenu.vue";
75+
import MenuList, { MenuListEntry } from "@/components/floating-menus/MenuList.vue";
76+
77+
import LayoutCol from "@/components/layout/LayoutCol.vue";
8978
import LayoutRow from "@/components/layout/LayoutRow.vue";
9079
import IconLabel from "@/components/widgets/labels/IconLabel.vue";
9180
@@ -101,17 +90,42 @@ export default defineComponent({
10190
data() {
10291
return {
10392
open: false,
104-
minWidth: 0,
105-
entries: [] as SectionsOfMenuListEntries,
106-
activeEntry: undefined as undefined | MenuListEntry,
93+
entries: [] as MenuListEntry[],
94+
activeEntry: undefined as MenuListEntry | undefined,
95+
highlighted: undefined as MenuListEntry | undefined,
96+
entriesStart: 0,
97+
minWidth: this.isStyle ? 0 : 300,
10798
};
10899
},
109100
async mounted() {
110-
const { entries, activeEntry } = await this.updateEntries();
111-
this.entries = entries;
112-
this.activeEntry = activeEntry;
101+
this.entries = await this.getEntries();
102+
this.activeEntry = this.getActiveEntry(this.entries);
103+
this.highlighted = this.activeEntry;
113104
},
114105
methods: {
106+
floatingMenu() {
107+
return this.$refs.floatingMenu as typeof FloatingMenu;
108+
},
109+
scroller() {
110+
return ((this.$refs.menulist as typeof MenuList).$refs.scroller as typeof LayoutCol)?.$el as HTMLElement;
111+
},
112+
async setOpen() {
113+
this.open = true;
114+
// Scroll to the active entry (the scroller div does not yet exist so we must wait for vue to render)
115+
await nextTick();
116+
if (this.activeEntry) {
117+
const index = this.entries.indexOf(this.activeEntry);
118+
this.scroller()?.scrollTo(0, Math.max(0, index * 20 - 190));
119+
}
120+
},
121+
toggleOpen() {
122+
if (this.disabled) return;
123+
this.open = !this.open;
124+
if (this.open) this.setOpen();
125+
},
126+
keydown(e: KeyboardEvent) {
127+
(this.$refs.menulist as typeof MenuList).keydown(e, false);
128+
},
115129
async selectFont(newName: string): Promise<void> {
116130
let fontFamily;
117131
let fontStyle;
@@ -125,50 +139,43 @@ export default defineComponent({
125139
this.$emit("update:fontFamily", newName);
126140
127141
fontFamily = newName;
128-
fontStyle = (await this.fonts.getFontStyles(newName))[0];
142+
fontStyle = "Normal (400)";
129143
}
130144
131145
const fontFileUrl = await this.fonts.getFontFileUrl(fontFamily, fontStyle);
132146
this.$emit("changeFont", { fontFamily, fontStyle, fontFileUrl });
133147
},
134-
async updateEntries(): Promise<{ entries: SectionsOfMenuListEntries; activeEntry: MenuListEntry }> {
135-
const choices = this.isStyle ? await this.fonts.getFontStyles(this.fontFamily) : this.fonts.state.fontNames;
148+
async getEntries(): Promise<MenuListEntry[]> {
149+
const x = this.isStyle ? this.fonts.getFontStyles(this.fontFamily) : this.fonts.fontNames();
150+
return (await x).map((entry: { name: string; url: URL | undefined }) => ({
151+
label: entry.name,
152+
value: entry.name,
153+
font: entry.url,
154+
action: () => this.selectFont(entry.name),
155+
}));
156+
},
157+
getActiveEntry(entries: MenuListEntry[]): MenuListEntry {
136158
const selectedChoice = this.isStyle ? this.fontStyle : this.fontFamily;
137159
138-
let selectedEntry: MenuListEntry | undefined;
139-
const menuListEntries = choices.map((name) => {
140-
const result: MenuListEntry = {
141-
label: name,
142-
action: async (): Promise<void> => this.selectFont(name),
143-
};
144-
145-
if (name === selectedChoice) selectedEntry = result;
146-
147-
return result;
148-
});
149-
150-
const entries: SectionsOfMenuListEntries = [menuListEntries];
151-
const activeEntry = selectedEntry || { label: "-" };
152-
153-
return { entries, activeEntry };
160+
return entries.find((entry) => entry.value === selectedChoice) as MenuListEntry;
154161
},
155162
},
156163
watch: {
157164
async fontFamily() {
158-
const { entries, activeEntry } = await this.updateEntries();
159-
this.entries = entries;
160-
this.activeEntry = activeEntry;
165+
this.entries = await this.getEntries();
166+
this.activeEntry = this.getActiveEntry(this.entries);
167+
this.highlighted = this.activeEntry;
161168
},
162169
async fontStyle() {
163-
const { entries, activeEntry } = await this.updateEntries();
164-
this.entries = entries;
165-
this.activeEntry = activeEntry;
170+
this.entries = await this.getEntries();
171+
this.activeEntry = this.getActiveEntry(this.entries);
172+
this.highlighted = this.activeEntry;
166173
},
167174
},
168175
components: {
176+
LayoutRow,
169177
IconLabel,
170178
MenuList,
171-
LayoutRow,
172179
},
173180
});
174181
</script>

0 commit comments

Comments
 (0)