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 @@