Skip to content

Commit ffc6043

Browse files
committed
Searchable font list
1 parent b60736c commit ffc6043

File tree

3 files changed

+129
-23
lines changed

3 files changed

+129
-23
lines changed

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

Lines changed: 124 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
<svelte:options accessors={true} />
22

33
<script lang="ts">
4-
import { createEventDispatcher } from "svelte";
4+
import { createEventDispatcher, tick } from "svelte";
55
66
import type { MenuListEntry } from "@graphite/wasm-communication/messages";
77
88
import FloatingMenu, { type MenuDirection } from "@graphite/components/layout/FloatingMenu.svelte";
99
import LayoutCol from "@graphite/components/layout/LayoutCol.svelte";
1010
import LayoutRow from "@graphite/components/layout/LayoutRow.svelte";
11+
import TextInput from "@graphite/components/widgets/inputs/TextInput.svelte";
1112
import IconLabel from "@graphite/components/widgets/labels/IconLabel.svelte";
1213
import Separator from "@graphite/components/widgets/labels/Separator.svelte";
1314
import TextLabel from "@graphite/components/widgets/labels/TextLabel.svelte";
@@ -29,21 +30,73 @@
2930
export let virtualScrollingEntryHeight = 0;
3031
export let tooltip: string | undefined = undefined;
3132
33+
// Keep the child references outside of the entries array so as to avoid infinite recursion.
34+
let childReferences: (typeof self)[][] = [];
35+
let search: string | undefined;
36+
let focus: () => void | undefined;
37+
let searchElement: () => HTMLInputElement | HTMLTextAreaElement | undefined;
38+
3239
let highlighted = activeEntry as MenuListEntry | undefined;
3340
let virtualScrollingEntriesStart = 0;
3441
3542
// Called only when `open` is changed from outside this component
3643
$: watchOpen(open);
37-
$: watchRemeasureWidth(entries, drawIcon);
44+
$: filteredEntries = entries.map((section) => section.filter(inSearch(search)));
45+
$: watchRemeasureWidth(filteredEntries, drawIcon);
3846
39-
$: virtualScrollingTotalHeight = entries.length === 0 ? 0 : entries[0].length * virtualScrollingEntryHeight;
47+
$: virtualScrollingTotalHeight = filteredEntries.length === 0 ? 0 : filteredEntries[0].length * virtualScrollingEntryHeight;
4048
$: virtualScrollingStartIndex = Math.floor(virtualScrollingEntriesStart / virtualScrollingEntryHeight) || 0;
41-
$: virtualScrollingEndIndex = entries.length === 0 ? 0 : Math.min(entries[0].length, virtualScrollingStartIndex + 1 + 400 / virtualScrollingEntryHeight);
49+
$: virtualScrollingEndIndex = filteredEntries.length === 0 ? 0 : Math.min(filteredEntries[0].length, virtualScrollingStartIndex + 1 + 400 / virtualScrollingEntryHeight);
4250
$: startIndex = virtualScrollingEntryHeight ? virtualScrollingStartIndex : 0;
4351
52+
function expandChildReferences(entries: MenuListEntry[][]) {
53+
entries.forEach((_, index) => {
54+
if (!childReferences[index]) childReferences[index] = [];
55+
});
56+
}
57+
$: expandChildReferences(entries);
58+
59+
async function startSearch(event: KeyboardEvent) {
60+
if (search !== undefined && event.key.length !== 1) return;
61+
// Stop shortcuts being activated
62+
event.stopPropagation();
63+
event.preventDefault();
64+
// Open the sarch bar
65+
search = "";
66+
// Must wait until the dom elements have been created before focus
67+
await tick();
68+
focus();
69+
// Forward the input
70+
search = event.key;
71+
72+
// Allow arrow key navigation whilst in the search box
73+
let element = searchElement();
74+
if (element) {
75+
element.onkeydown = (event) => {
76+
if (["Enter", "ArrowUp", "ArrowDown"].includes(event.key)) keydown(event, false);
77+
};
78+
}
79+
}
80+
81+
function inSearch(search: string | undefined): (entry: MenuListEntry) => boolean {
82+
return (entry: MenuListEntry) => !search || entry.label.toLowerCase().includes(search.toLowerCase());
83+
}
84+
4485
function watchOpen(open: boolean) {
4586
highlighted = activeEntry;
4687
dispatch("open", open);
88+
89+
search = undefined;
90+
}
91+
92+
$: updateHighlightedWithSearch(filteredEntries);
93+
// Required to keep the highlighted item centred and to find a new highlighted item if necessary
94+
async function updateHighlightedWithSearch(filteredEntries: MenuListEntry[][]) {
95+
if (highlighted) {
96+
// Allows the scrollable area to expand if necessary
97+
await tick();
98+
setHighlighted(filteredEntries.flat().includes(highlighted) ? highlighted : filteredEntries.flat()[0]);
99+
}
47100
}
48101
49102
function watchRemeasureWidth(_: MenuListEntry[][], __: boolean) {
@@ -55,6 +108,10 @@
55108
virtualScrollingEntriesStart = (e.target as HTMLElement)?.scrollTop || 0;
56109
}
57110
111+
function getChildReference(menuListEntry: MenuListEntry): typeof self | undefined {
112+
return childReferences.flat()[filteredEntries.flat().indexOf(menuListEntry)];
113+
}
114+
58115
function onEntryClick(menuListEntry: MenuListEntry) {
59116
// Call the action if available
60117
if (menuListEntry.action) menuListEntry.action();
@@ -63,8 +120,9 @@
63120
dispatch("activeEntry", menuListEntry);
64121
65122
// Close the containing menu
66-
if (menuListEntry.ref) {
67-
menuListEntry.ref.open = false;
123+
let childReference = getChildReference(menuListEntry);
124+
if (childReference) {
125+
childReference.open = false;
68126
entries = entries;
69127
}
70128
dispatch("open", false);
@@ -74,25 +132,27 @@
74132
function onEntryPointerEnter(menuListEntry: MenuListEntry) {
75133
if (!menuListEntry.children?.length) return;
76134
77-
if (menuListEntry.ref) {
78-
menuListEntry.ref.open = true;
135+
let childReference = getChildReference(menuListEntry);
136+
if (childReference) {
137+
childReference.open = true;
79138
entries = entries;
80139
} else dispatch("open", true);
81140
}
82141
83142
function onEntryPointerLeave(menuListEntry: MenuListEntry) {
84143
if (!menuListEntry.children?.length) return;
85144
86-
if (menuListEntry.ref) {
87-
menuListEntry.ref.open = false;
145+
let childReference = getChildReference(menuListEntry);
146+
if (childReference) {
147+
childReference.open = false;
88148
entries = entries;
89149
} else dispatch("open", false);
90150
}
91151
92152
function isEntryOpen(menuListEntry: MenuListEntry): boolean {
93153
if (!menuListEntry.children?.length) return false;
94154
95-
return menuListEntry.ref?.open || false;
155+
return getChildReference(menuListEntry)?.open || false;
96156
}
97157
98158
/// Handles keyboard navigation for the menu. Returns if the entire menu stack should be dismissed
@@ -101,18 +161,19 @@
101161
if (interactive) highlighted = activeEntry;
102162
103163
const menuOpen = open;
104-
const flatEntries = entries.flat().filter((entry) => !entry.disabled);
105-
const openChild = flatEntries.findIndex((entry) => (entry.children?.length ?? 0) > 0 && entry.ref?.open);
164+
const flatEntries = filteredEntries.flat().filter((entry) => !entry.disabled);
165+
const openChild = flatEntries.findIndex((entry) => (entry.children?.length ?? 0) > 0 && getChildReference(entry)?.open);
106166
107167
const openSubmenu = (highlightedEntry: MenuListEntry) => {
108-
if (highlightedEntry.ref && highlightedEntry.children?.length) {
109-
highlightedEntry.ref.open = true;
168+
let childReference = getChildReference(highlightedEntry);
169+
if (childReference && highlightedEntry.children?.length) {
170+
childReference.open = true;
110171
// The reason we bother taking `highlightdEntry` as an argument is because, when this function is called, it can ensure `highlightedEntry` is not undefined.
111172
// But here we still have to set `highlighted` to itself so Svelte knows to reactively update it after we set its `.ref.open` property.
112173
highlighted = highlighted;
113174
114175
// Highlight first item
115-
highlightedEntry.ref.setHighlighted(highlightedEntry.children[0][0]);
176+
childReference.setHighlighted(highlightedEntry.children[0][0]);
116177
}
117178
};
118179
@@ -122,7 +183,7 @@
122183
highlighted = activeEntry;
123184
} else if (menuOpen && openChild >= 0) {
124185
// Redirect the keyboard navigation to a submenu if one is open
125-
const shouldCloseStack = flatEntries[openChild].ref?.keydown(e, true);
186+
const shouldCloseStack = getChildReference(flatEntries[openChild])?.keydown(e, true);
126187
127188
// Highlight the menu item in the parent list that corresponds with the open submenu
128189
if (e.key !== "Escape" && highlighted) setHighlighted(flatEntries[openChild]);
@@ -171,6 +232,10 @@
171232
if (submenu) open = false;
172233
}
173234
235+
startSearch(e);
236+
e.stopPropagation();
237+
e.preventDefault();
238+
174239
// By default, keep the menu stack open
175240
return false;
176241
}
@@ -179,6 +244,31 @@
179244
highlighted = newHighlight;
180245
// Interactive menus should keep the active entry the same as the highlighted one
181246
if (interactive && newHighlight?.value !== activeEntry?.value && newHighlight) dispatch("activeEntry", newHighlight);
247+
248+
// Scroll into view
249+
let container = scroller?.div();
250+
if (!container || !highlighted) return;
251+
let containerBoundingRect = container.getBoundingClientRect();
252+
let highlightedIndex = filteredEntries.flat().findIndex((entry) => entry === highlighted);
253+
254+
let selectedBoundingRect = new DOMRect();
255+
if (virtualScrollingEntryHeight) {
256+
// Special case for virtual scrolling
257+
selectedBoundingRect.y = highlightedIndex * virtualScrollingEntryHeight - container.scrollTop + containerBoundingRect.y;
258+
selectedBoundingRect.height = virtualScrollingEntryHeight;
259+
} else {
260+
let entries = Array.from(container.children).filter((element) => element.classList.contains("row"));
261+
let element = entries[highlightedIndex - startIndex];
262+
if (!element) return;
263+
containerBoundingRect = element.getBoundingClientRect();
264+
}
265+
266+
if (containerBoundingRect.y > selectedBoundingRect.y) {
267+
container.scrollBy(0, selectedBoundingRect.y - containerBoundingRect.y);
268+
}
269+
if (containerBoundingRect.y + containerBoundingRect.height < selectedBoundingRect.y + selectedBoundingRect.height) {
270+
container.scrollBy(0, selectedBoundingRect.y - (containerBoundingRect.y + containerBoundingRect.height) + selectedBoundingRect.height);
271+
}
182272
}
183273
184274
export function scrollViewTo(distanceDown: number) {
@@ -199,6 +289,9 @@
199289
scrollableY={scrollableY && virtualScrollingEntryHeight === 0}
200290
bind:this={self}
201291
>
292+
{#if search !== undefined}
293+
<TextInput value={search} on:value={(value) => (search = value.detail)} bind:focus bind:element={searchElement}></TextInput>
294+
{/if}
202295
<!-- If we put the scrollableY on the layoutcol for non-font dropdowns then for some reason it always creates a tiny scrollbar.
203296
However when we are using the virtual scrolling then we need the layoutcol to be scrolling so we can bind the events without using `self`. -->
204297
<LayoutCol
@@ -211,10 +304,12 @@
211304
<LayoutRow class="scroll-spacer" styles={{ height: `${virtualScrollingStartIndex * virtualScrollingEntryHeight}px` }} />
212305
{/if}
213306
{#each entries as section, sectionIndex (sectionIndex)}
214-
{#if sectionIndex > 0}
307+
{#if entries.slice(undefined, sectionIndex).flat().filter(inSearch(search)).length > 0 && section.filter(inSearch(search)).length > 0}
215308
<Separator type="List" direction="Vertical" />
216309
{/if}
217-
{#each virtualScrollingEntryHeight ? section.slice(virtualScrollingStartIndex, virtualScrollingEndIndex) : section as entry, entryIndex (entryIndex + startIndex)}
310+
{#each virtualScrollingEntryHeight ? section
311+
.filter(inSearch(search))
312+
.slice(virtualScrollingStartIndex, virtualScrollingEndIndex) : section.filter(inSearch(search)) as entry, entryIndex (entryIndex + startIndex)}
218313
<LayoutRow
219314
class="row"
220315
classes={{ open: isEntryOpen(entry), active: entry.label === highlighted?.label, disabled: Boolean(entry.disabled) }}
@@ -248,7 +343,16 @@
248343

249344
{#if entry.children}
250345
<!-- TODO: Solve the red underline error on the bind:this below -->
251-
<svelte:self on:naturalWidth open={entry.ref?.open || false} direction="TopRight" entries={entry.children} {minWidth} {drawIcon} {scrollableY} bind:this={entry.ref} />
346+
<svelte:self
347+
on:naturalWidth
348+
open={getChildReference(entry)?.open || false}
349+
direction="TopRight"
350+
entries={entry.children}
351+
{minWidth}
352+
{drawIcon}
353+
{scrollableY}
354+
bind:this={childReferences[sectionIndex][entryIndex + startIndex]}
355+
/>
252356
{/if}
253357
</LayoutRow>
254358
{/each}

frontend/src/components/widgets/inputs/TextInput.svelte

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@
5151
export function focus() {
5252
self?.focus();
5353
}
54+
55+
export function element(): HTMLInputElement | HTMLTextAreaElement | undefined {
56+
return self?.element();
57+
}
5458
</script>
5559

5660
<FieldInput
@@ -73,6 +77,7 @@
7377

7478
<style lang="scss" global>
7579
.text-input {
80+
flex-shrink: 0;
7681
input {
7782
text-align: left;
7883
}

frontend/src/wasm-communication/messages.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@ import { Transform, Type, plainToClass } from "class-transformer";
66
import { type IconName, type IconSize } from "@graphite/utility-functions/icons";
77
import { type WasmEditorInstance, type WasmRawInstance } from "@graphite/wasm-communication/editor";
88

9-
import type MenuList from "@graphite/components/floating-menus/MenuList.svelte";
10-
119
export class JsMessage {
1210
// The marker provides a way to check if an object is a sub-class constructor for a jsMessage.
1311
static readonly jsMessageMarker = true;
@@ -811,7 +809,6 @@ export type MenuListEntry = MenuEntryCommon & {
811809
disabled?: boolean;
812810
tooltip?: string;
813811
font?: URL;
814-
ref?: MenuList;
815812
};
816813

817814
export class CurveManipulatorGroup {

0 commit comments

Comments
 (0)