From 8b8d3f4aadd8f57690f1bd7a040176a326317d58 Mon Sep 17 00:00:00 2001
From: 0hypercube <0hypercube@gmail.com>
Date: Sun, 10 Dec 2023 18:19:58 +0000
Subject: [PATCH 1/7] Searchable font list
---
.../components/floating-menus/MenuList.svelte | 144 +++++++++++++++---
.../widgets/inputs/TextInput.svelte | 5 +
frontend/src/wasm-communication/messages.ts | 3 -
3 files changed, 129 insertions(+), 23 deletions(-)
diff --git a/frontend/src/components/floating-menus/MenuList.svelte b/frontend/src/components/floating-menus/MenuList.svelte
index f7cc172fc43..fa4b12c3dd0 100644
--- a/frontend/src/components/floating-menus/MenuList.svelte
+++ b/frontend/src/components/floating-menus/MenuList.svelte
@@ -1,13 +1,14 @@
.text-input {
+ flex-shrink: 0;
input {
text-align: left;
}
diff --git a/frontend/src/wasm-communication/messages.ts b/frontend/src/wasm-communication/messages.ts
index f5801b4f8bb..c9eade2d95f 100644
--- a/frontend/src/wasm-communication/messages.ts
+++ b/frontend/src/wasm-communication/messages.ts
@@ -6,8 +6,6 @@ import { Transform, Type, plainToClass } from "class-transformer";
import { type PopoverButtonStyle, type IconName, type IconSize } from "@graphite/utility-functions/icons";
import { type WasmEditorInstance, type WasmRawInstance } from "@graphite/wasm-communication/editor";
-import type MenuList from "@graphite/components/floating-menus/MenuList.svelte";
-
export class JsMessage {
// The marker provides a way to check if an object is a sub-class constructor for a jsMessage.
static readonly jsMessageMarker = true;
@@ -718,7 +716,6 @@ export type MenuListEntry = MenuEntryCommon & {
disabled?: boolean;
tooltip?: string;
font?: URL;
- ref?: MenuList;
};
export class CurveManipulatorGroup {
From da8681eee5ba64cf0e3d52bfd3dad08f08370ae2 Mon Sep 17 00:00:00 2001
From: Keavon Chambers
Date: Sun, 10 Dec 2023 20:45:49 -0800
Subject: [PATCH 2/7] Bug fixes and UX polish for edge cases
---
.../components/floating-menus/Dialog.svelte | 2 +-
.../components/floating-menus/MenuList.svelte | 114 ++++++++++--------
.../src/components/layout/FloatingMenu.svelte | 8 +-
frontend/src/components/panels/Layers.svelte | 2 +-
.../widgets/inputs/DropdownInput.svelte | 2 +-
.../widgets/inputs/TextInput.svelte | 1 +
.../components/window/workspace/Panel.svelte | 2 +-
7 files changed, 74 insertions(+), 57 deletions(-)
diff --git a/frontend/src/components/floating-menus/Dialog.svelte b/frontend/src/components/floating-menus/Dialog.svelte
index 1beecbbba6f..f8040a95c06 100644
--- a/frontend/src/components/floating-menus/Dialog.svelte
+++ b/frontend/src/components/floating-menus/Dialog.svelte
@@ -20,7 +20,7 @@
onMount(() => {
// Focus the button which is marked as emphasized, or otherwise the first button, in the popup
- const emphasizedOrFirstButton = (self?.div()?.querySelector("[data-emphasized]") || self?.div()?.querySelector("[data-text-button]") || undefined) as HTMLButtonElement | undefined;
+ const emphasizedOrFirstButton = (self?.div?.()?.querySelector("[data-emphasized]") || self?.div?.()?.querySelector("[data-text-button]") || undefined) as HTMLButtonElement | undefined;
emphasizedOrFirstButton?.focus();
});
diff --git a/frontend/src/components/floating-menus/MenuList.svelte b/frontend/src/components/floating-menus/MenuList.svelte
index fa4b12c3dd0..01bf597153e 100644
--- a/frontend/src/components/floating-menus/MenuList.svelte
+++ b/frontend/src/components/floating-menus/MenuList.svelte
@@ -16,6 +16,7 @@
let self: FloatingMenu | undefined;
let scroller: LayoutCol | undefined;
+ let searchTextInput: TextInput | undefined;
const dispatch = createEventDispatcher<{ open: boolean; activeEntry: MenuListEntry }>();
@@ -33,49 +34,63 @@
// Keep the child references outside of the entries array so as to avoid infinite recursion.
let childReferences: (typeof self)[][] = [];
let search: string | undefined;
- let focus: () => void | undefined;
- let searchElement: () => HTMLInputElement | HTMLTextAreaElement | undefined;
let highlighted = activeEntry as MenuListEntry | undefined;
let virtualScrollingEntriesStart = 0;
// Called only when `open` is changed from outside this component
$: watchOpen(open);
- $: filteredEntries = entries.map((section) => section.filter(inSearch(search)));
$: watchRemeasureWidth(filteredEntries, drawIcon);
+ $: entries.forEach((_, index) => {
+ if (!childReferences[index]) childReferences[index] = [];
+ });
+ $: watchHighlightedWithSearch(filteredEntries);
+ $: filteredEntries = entries.map((section) => section.filter(inSearch(search)));
$: virtualScrollingTotalHeight = filteredEntries.length === 0 ? 0 : filteredEntries[0].length * virtualScrollingEntryHeight;
$: virtualScrollingStartIndex = Math.floor(virtualScrollingEntriesStart / virtualScrollingEntryHeight) || 0;
$: virtualScrollingEndIndex = filteredEntries.length === 0 ? 0 : Math.min(filteredEntries[0].length, virtualScrollingStartIndex + 1 + 400 / virtualScrollingEntryHeight);
$: startIndex = virtualScrollingEntryHeight ? virtualScrollingStartIndex : 0;
- function expandChildReferences(entries: MenuListEntry[][]) {
- entries.forEach((_, index) => {
- if (!childReferences[index]) childReferences[index] = [];
- });
+ // Required to keep the highlighted item centered and to find a new highlighted item if necessary
+ async function watchHighlightedWithSearch(filteredEntries: MenuListEntry[][]) {
+ if (highlighted) {
+ // Allows the scrollable area to expand if necessary
+ await tick();
+
+ const flattened = filteredEntries.flat();
+ setHighlighted(flattened.includes(highlighted) ? highlighted : flattened[0]);
+ }
}
- $: expandChildReferences(entries);
+ // Detect when the user types, which creates a search box
async function startSearch(event: KeyboardEvent) {
if (search !== undefined || event.key.length !== 1) return;
+
// Stop shortcuts being activated
event.stopPropagation();
event.preventDefault();
- // Open the sarch bar
+
+ // Open the search bar
search = "";
- // Must wait until the dom elements have been created before focus
+
+ // Must wait until the DOM elements have been created before focusing the search box
await tick();
- focus();
- // Forward the input
+ searchTextInput?.focus();
+
+ // Forward the input's first character to the search box, which after that point the user will continue typing into directly
search = event.key;
+ // Get the search box element
+ let searchElement = searchTextInput?.element();
+ if (!searchElement) return;
+
// Allow arrow key navigation whilst in the search box
- let element = searchElement();
- if (element) {
- element.onkeydown = (event) => {
- if (["Enter", "ArrowUp", "ArrowDown"].includes(event.key)) keydown(event, false);
- };
- }
+ searchElement.onkeydown = (event) => {
+ if (["Enter", "Escape", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(event.key)) {
+ keydown(event, false);
+ }
+ };
}
function inSearch(search: string | undefined): (entry: MenuListEntry) => boolean {
@@ -89,16 +104,6 @@
search = undefined;
}
- $: updateHighlightedWithSearch(filteredEntries);
- // Required to keep the highlighted item centred and to find a new highlighted item if necessary
- async function updateHighlightedWithSearch(filteredEntries: MenuListEntry[][]) {
- if (highlighted) {
- // Allows the scrollable area to expand if necessary
- await tick();
- setHighlighted(filteredEntries.flat().includes(highlighted) ? highlighted : filteredEntries.flat()[0]);
- }
- }
-
function watchRemeasureWidth(_: MenuListEntry[][], __: boolean) {
self?.measureAndEmitNaturalWidth();
}
@@ -109,7 +114,7 @@
}
function getChildReference(menuListEntry: MenuListEntry): typeof self | undefined {
- return childReferences.flat()[filteredEntries.flat().indexOf(menuListEntry)];
+ return childReferences.flat().filter((x) => x)[filteredEntries.flat().indexOf(menuListEntry)];
}
function onEntryClick(menuListEntry: MenuListEntry) {
@@ -164,17 +169,21 @@
const flatEntries = filteredEntries.flat().filter((entry) => !entry.disabled);
const openChild = flatEntries.findIndex((entry) => (entry.children?.length ?? 0) > 0 && getChildReference(entry)?.open);
- const openSubmenu = (highlightedEntry: MenuListEntry) => {
+ const openSubmenu = (highlightedEntry: MenuListEntry): boolean => {
let childReference = getChildReference(highlightedEntry);
- if (childReference && highlightedEntry.children?.length) {
- childReference.open = true;
- // The reason we bother taking `highlightdEntry` as an argument is because, when this function is called, it can ensure `highlightedEntry` is not undefined.
- // But here we still have to set `highlighted` to itself so Svelte knows to reactively update it after we set its `.ref.open` property.
- highlighted = highlighted;
-
- // Highlight first item
- childReference.setHighlighted(highlightedEntry.children[0][0]);
- }
+ // No submenu to open
+ if (!childReference || !highlightedEntry.children?.length) return false;
+
+ childReference.open = true;
+ // The reason we bother taking `highlightdEntry` as an argument is because, when this function is called, it can ensure `highlightedEntry` is not undefined.
+ // But here we still have to set `highlighted` to itself so Svelte knows to reactively update it after we set its `.ref.open` property.
+ highlighted = highlighted;
+
+ // Highlight first item
+ childReference.setHighlighted(highlightedEntry.children[0][0]);
+
+ // Submenu was opened
+ return true;
};
if (!menuOpen && (e.key === " " || e.key === "Enter")) {
@@ -208,6 +217,8 @@
const newEntry = flatEntries[newIndex];
setHighlighted(newEntry);
+
+ e.preventDefault();
} else if (menuOpen && e.key === "Escape") {
// Close menu with escape key
open = false;
@@ -224,17 +235,22 @@
// Enter should close the entire menu stack
return true;
- } else if (menuOpen && highlighted && e.key === "ArrowRight") {
+ } else if (menuOpen && highlighted && (e.key === "ArrowRight" || e.key === " " || e.key === "Enter")) {
// Right arrow opens a submenu
- openSubmenu(highlighted);
+ const openable = openSubmenu(highlighted);
+
+ // Prevent the right arrow from moving the search text cursor if we are opening a submenu
+ if (openable) e.preventDefault();
} else if (menuOpen && e.key === "ArrowLeft") {
// Left arrow closes a submenu
- if (submenu) open = false;
- }
+ if (submenu) {
+ open = false;
- startSearch(e);
- e.stopPropagation();
- e.preventDefault();
+ e.preventDefault();
+ }
+ } else if (menuOpen && e.key !== " ") {
+ startSearch(e);
+ }
// By default, keep the menu stack open
return false;
@@ -246,7 +262,7 @@
if (interactive && newHighlight?.value !== activeEntry?.value && newHighlight) dispatch("activeEntry", newHighlight);
// Scroll into view
- let container = scroller?.div();
+ let container = scroller?.div?.();
if (!container || !highlighted) return;
let containerBoundingRect = container.getBoundingClientRect();
let highlightedIndex = filteredEntries.flat().findIndex((entry) => entry === highlighted);
@@ -272,7 +288,7 @@
}
export function scrollViewTo(distanceDown: number) {
- scroller?.div()?.scrollTo(0, distanceDown);
+ scroller?.div?.()?.scrollTo(0, distanceDown);
}
@@ -290,7 +306,7 @@
bind:this={self}
>
{#if search !== undefined}
- (search = value.detail)} bind:focus bind:element={searchElement}>
+ (search = value.detail)} bind:this={searchTextInput}>
{/if}
diff --git a/frontend/src/components/layout/FloatingMenu.svelte b/frontend/src/components/layout/FloatingMenu.svelte
index 71d868f6de2..dc4cb4a1a80 100644
--- a/frontend/src/components/layout/FloatingMenu.svelte
+++ b/frontend/src/components/layout/FloatingMenu.svelte
@@ -118,7 +118,7 @@
onMount(() => {
// Measure the content and round up its width and height to the nearest even integer.
// This solves antialiasing issues when the content isn't cleanly divisible by 2 and gets translated by (-50%, -50%) causing all its content to be blurry.
- const floatingMenuContentDiv = floatingMenuContent?.div();
+ const floatingMenuContentDiv = floatingMenuContent?.div?.();
if (type === "Dialog" && floatingMenuContentDiv) {
// TODO: Also use https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver to detect any changes which may affect the size of the content.
// TODO: The current method only notices when the dialog size increases but can't detect when it decreases.
@@ -142,7 +142,7 @@
afterUpdate(() => {
// Remove the size constraint after the content updates so the resize observer can measure the content and reapply a newly calculated one
- const floatingMenuContentDiv = floatingMenuContent?.div();
+ const floatingMenuContentDiv = floatingMenuContent?.div?.();
if (type === "Dialog" && floatingMenuContentDiv) {
// We have to set the style properties directly because attempting to do it through a Svelte bound property results in `afterUpdate()` being triggered
floatingMenuContentDiv.style.setProperty("min-width", "unset");
@@ -164,7 +164,7 @@
const workspace = document.querySelector("[data-workspace]");
- const floatingMenuContentDiv = floatingMenuContent?.div();
+ const floatingMenuContentDiv = floatingMenuContent?.div?.();
if (!workspace || !self || !floatingMenuContainer || !floatingMenuContent || !floatingMenuContentDiv) return;
workspaceBounds = workspace.getBoundingClientRect();
@@ -265,7 +265,7 @@
// Measure the width of the floating menu content element, if it's currently visible
// The result will be `undefined` if the menu is invisible, perhaps because an ancestor component is hidden with a falsy Svelte template if condition
- const naturalWidth: number | undefined = floatingMenuContent?.div()?.clientWidth;
+ const naturalWidth: number | undefined = floatingMenuContent?.div?.()?.clientWidth;
// Turn off measuring mode for the component, which triggers another call to the `afterUpdate()` Svelte event, so we can turn off the protection after that has happened
measuringOngoing = false;
diff --git a/frontend/src/components/panels/Layers.svelte b/frontend/src/components/panels/Layers.svelte
index 373cacade5a..df5bc5a729b 100644
--- a/frontend/src/components/panels/Layers.svelte
+++ b/frontend/src/components/panels/Layers.svelte
@@ -146,7 +146,7 @@
await tick();
- const query = list?.div()?.querySelector("[data-text-input]:not([disabled])");
+ const query = list?.div?.()?.querySelector("[data-text-input]:not([disabled])");
const textInput = (query instanceof HTMLInputElement && query) || undefined;
textInput?.select();
}
diff --git a/frontend/src/components/widgets/inputs/DropdownInput.svelte b/frontend/src/components/widgets/inputs/DropdownInput.svelte
index 338da687ff3..64569fa51ec 100644
--- a/frontend/src/components/widgets/inputs/DropdownInput.svelte
+++ b/frontend/src/components/widgets/inputs/DropdownInput.svelte
@@ -56,7 +56,7 @@
function unFocusDropdownBox(e: FocusEvent) {
const blurTarget = (e.target as HTMLDivElement | undefined)?.closest("[data-dropdown-input]") || undefined;
- if (blurTarget !== self?.div()) open = false;
+ if (blurTarget !== self?.div?.()) open = false;
}
diff --git a/frontend/src/components/widgets/inputs/TextInput.svelte b/frontend/src/components/widgets/inputs/TextInput.svelte
index 5b222e98a08..31fb66231fc 100644
--- a/frontend/src/components/widgets/inputs/TextInput.svelte
+++ b/frontend/src/components/widgets/inputs/TextInput.svelte
@@ -76,6 +76,7 @@