Make menu lists searchable (#1499)
* Searchable font list * Bug fixes and UX polish for edge cases * More work, still more bugs to fix * Don't update highlight when not open * Bug fixes * Additional bug fixes and code review * Fix keyboard input being sent to backend --------- Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
parent
4cbba3d92b
commit
b31e8f7b6d
|
|
@ -1180,7 +1180,7 @@ impl DocumentMessageHandler {
|
||||||
.icon(DocumentMode::SelectMode.icon_name())
|
.icon(DocumentMode::SelectMode.icon_name())
|
||||||
.on_update(|_| DialogMessage::RequestComingSoonDialog { issue: Some(330) }.into()),
|
.on_update(|_| DialogMessage::RequestComingSoonDialog { issue: Some(330) }.into()),
|
||||||
MenuListEntry::new(format!("{:?}", DocumentMode::GuideMode))
|
MenuListEntry::new(format!("{:?}", DocumentMode::GuideMode))
|
||||||
.label(DocumentMode::SelectMode.to_string())
|
.label(DocumentMode::GuideMode.to_string())
|
||||||
.icon(DocumentMode::GuideMode.icon_name())
|
.icon(DocumentMode::GuideMode.icon_name())
|
||||||
.on_update(|_| DialogMessage::RequestComingSoonDialog { issue: Some(331) }.into()),
|
.on_update(|_| DialogMessage::RequestComingSoonDialog { issue: Some(331) }.into()),
|
||||||
]])
|
]])
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// Focus the button which is marked as emphasized, or otherwise the first button, in the popup
|
// 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();
|
emphasizedOrFirstButton?.focus();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
<svelte:options accessors={true} />
|
<svelte:options accessors={true} />
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createEventDispatcher } from "svelte";
|
import { createEventDispatcher, tick, onDestroy, onMount } from "svelte";
|
||||||
|
|
||||||
import type { MenuListEntry } from "@graphite/wasm-communication/messages";
|
import type { MenuListEntry } from "@graphite/wasm-communication/messages";
|
||||||
|
|
||||||
|
import MenuList from "@graphite/components/floating-menus/MenuList.svelte";
|
||||||
import FloatingMenu, { type MenuDirection } from "@graphite/components/layout/FloatingMenu.svelte";
|
import FloatingMenu, { type MenuDirection } from "@graphite/components/layout/FloatingMenu.svelte";
|
||||||
import LayoutCol from "@graphite/components/layout/LayoutCol.svelte";
|
import LayoutCol from "@graphite/components/layout/LayoutCol.svelte";
|
||||||
import LayoutRow from "@graphite/components/layout/LayoutRow.svelte";
|
import LayoutRow from "@graphite/components/layout/LayoutRow.svelte";
|
||||||
|
import TextInput from "@graphite/components/widgets/inputs/TextInput.svelte";
|
||||||
import IconLabel from "@graphite/components/widgets/labels/IconLabel.svelte";
|
import IconLabel from "@graphite/components/widgets/labels/IconLabel.svelte";
|
||||||
import Separator from "@graphite/components/widgets/labels/Separator.svelte";
|
import Separator from "@graphite/components/widgets/labels/Separator.svelte";
|
||||||
import TextLabel from "@graphite/components/widgets/labels/TextLabel.svelte";
|
import TextLabel from "@graphite/components/widgets/labels/TextLabel.svelte";
|
||||||
|
|
@ -15,8 +17,9 @@
|
||||||
|
|
||||||
let self: FloatingMenu | undefined;
|
let self: FloatingMenu | undefined;
|
||||||
let scroller: LayoutCol | undefined;
|
let scroller: LayoutCol | undefined;
|
||||||
|
let searchTextInput: TextInput | undefined;
|
||||||
|
|
||||||
const dispatch = createEventDispatcher<{ open: boolean; activeEntry: MenuListEntry }>();
|
const dispatch = createEventDispatcher<{ open: boolean; activeEntry: MenuListEntry; naturalWidth: number }>();
|
||||||
|
|
||||||
export let entries: MenuListEntry[][];
|
export let entries: MenuListEntry[][];
|
||||||
export let activeEntry: MenuListEntry | undefined = undefined;
|
export let activeEntry: MenuListEntry | undefined = undefined;
|
||||||
|
|
@ -29,21 +32,104 @@
|
||||||
export let virtualScrollingEntryHeight = 0;
|
export let virtualScrollingEntryHeight = 0;
|
||||||
export let tooltip: string | undefined = undefined;
|
export let tooltip: string | undefined = undefined;
|
||||||
|
|
||||||
|
// Keep the child references outside of the entries array so as to avoid infinite recursion.
|
||||||
|
let childReferences: MenuList[][] = [];
|
||||||
|
let search = "";
|
||||||
|
|
||||||
let highlighted = activeEntry as MenuListEntry | undefined;
|
let highlighted = activeEntry as MenuListEntry | undefined;
|
||||||
let virtualScrollingEntriesStart = 0;
|
let virtualScrollingEntriesStart = 0;
|
||||||
|
|
||||||
// Called only when `open` is changed from outside this component
|
// Called only when `open` is changed from outside this component
|
||||||
$: watchOpen(open);
|
$: watchOpen(open);
|
||||||
$: watchRemeasureWidth(entries, drawIcon);
|
$: watchEntries(entries);
|
||||||
|
$: watchRemeasureWidth(filteredEntries, drawIcon);
|
||||||
|
$: watchHighlightedWithSearch(filteredEntries, open);
|
||||||
|
|
||||||
$: virtualScrollingTotalHeight = entries.length === 0 ? 0 : entries[0].length * virtualScrollingEntryHeight;
|
$: filteredEntries = entries.map((section) => section.filter((entry) => inSearch(search, entry)));
|
||||||
|
$: virtualScrollingTotalHeight = filteredEntries.length === 0 ? 0 : filteredEntries[0].length * virtualScrollingEntryHeight;
|
||||||
$: virtualScrollingStartIndex = Math.floor(virtualScrollingEntriesStart / virtualScrollingEntryHeight) || 0;
|
$: virtualScrollingStartIndex = Math.floor(virtualScrollingEntriesStart / virtualScrollingEntryHeight) || 0;
|
||||||
$: virtualScrollingEndIndex = entries.length === 0 ? 0 : Math.min(entries[0].length, virtualScrollingStartIndex + 1 + 400 / virtualScrollingEntryHeight);
|
$: virtualScrollingEndIndex = filteredEntries.length === 0 ? 0 : Math.min(filteredEntries[0].length, virtualScrollingStartIndex + 1 + 400 / virtualScrollingEntryHeight);
|
||||||
$: startIndex = virtualScrollingEntryHeight ? virtualScrollingStartIndex : 0;
|
$: startIndex = virtualScrollingEntryHeight ? virtualScrollingStartIndex : 0;
|
||||||
|
|
||||||
|
// TODO: Move keyboard input handling entirely to the unified system in `input.ts`.
|
||||||
|
// TODO: The current approach is hacky and blocks the allowances for shortcuts like the key to open the browser's dev tools.
|
||||||
|
onMount(async () => {
|
||||||
|
await tick();
|
||||||
|
if (open && !inNestedMenuList()) addEventListener("keydown", keydown);
|
||||||
|
});
|
||||||
|
onDestroy(async () => {
|
||||||
|
await tick();
|
||||||
|
if (!inNestedMenuList()) removeEventListener("keydown", keydown);
|
||||||
|
});
|
||||||
|
|
||||||
|
function inNestedMenuList(): boolean {
|
||||||
|
const div = self?.div();
|
||||||
|
if (!(div instanceof HTMLDivElement)) return false;
|
||||||
|
return Boolean(div.closest("[data-floating-menu-content]"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Required to keep the highlighted item centered and to find a new highlighted item if necessary
|
||||||
|
async function watchHighlightedWithSearch(filteredEntries: MenuListEntry[][], open: boolean) {
|
||||||
|
if (highlighted && open) {
|
||||||
|
// Allows the scrollable area to expand if necessary
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const flattened = filteredEntries.flat();
|
||||||
|
const highlightedFound = highlighted?.label && flattened.map((entry) => entry.label).includes(highlighted.label);
|
||||||
|
const newHighlighted = highlightedFound ? highlighted : flattened[0];
|
||||||
|
setHighlighted(newHighlighted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect when the user types, which creates a search box
|
||||||
|
async function startSearch(e: KeyboardEvent) {
|
||||||
|
// Only accept single-character symbol inputs other than space
|
||||||
|
if (e.key.length !== 1 || e.key === " ") return;
|
||||||
|
|
||||||
|
// Stop shortcuts being activated
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Forward the input's first character to the search box, which after that point the user will continue typing into directly
|
||||||
|
search = e.key;
|
||||||
|
|
||||||
|
// Must wait until the DOM elements have been created (after the if condition becomes true) before the search box exists
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
// Get the search box element
|
||||||
|
const searchElement = searchTextInput?.element();
|
||||||
|
if (!searchTextInput || !searchElement) return;
|
||||||
|
|
||||||
|
// Focus the search box and move the cursor to the end
|
||||||
|
searchTextInput.focus();
|
||||||
|
searchElement.setSelectionRange(search.length, search.length);
|
||||||
|
|
||||||
|
// Continue listening for keyboard navigation even when the search box is focused
|
||||||
|
// searchElement.onkeydown = (e) => {
|
||||||
|
// if (["Enter", "Escape", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key)) {
|
||||||
|
// keydown(e, false);
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
}
|
||||||
|
|
||||||
|
function inSearch(search: string, entry: MenuListEntry): boolean {
|
||||||
|
return !search || entry.label.toLowerCase().includes(search.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
function watchOpen(open: boolean) {
|
function watchOpen(open: boolean) {
|
||||||
|
if (open && !inNestedMenuList()) addEventListener("keydown", keydown);
|
||||||
|
else if (!inNestedMenuList()) removeEventListener("keydown", keydown);
|
||||||
|
|
||||||
highlighted = activeEntry;
|
highlighted = activeEntry;
|
||||||
dispatch("open", open);
|
dispatch("open", open);
|
||||||
|
|
||||||
|
search = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function watchEntries(entries: MenuListEntry[][]) {
|
||||||
|
entries.forEach((_, index) => {
|
||||||
|
if (!childReferences[index]) childReferences[index] = [];
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function watchRemeasureWidth(_: MenuListEntry[][], __: boolean) {
|
function watchRemeasureWidth(_: MenuListEntry[][], __: boolean) {
|
||||||
|
|
@ -55,6 +141,11 @@
|
||||||
virtualScrollingEntriesStart = (e.target as HTMLElement)?.scrollTop || 0;
|
virtualScrollingEntriesStart = (e.target as HTMLElement)?.scrollTop || 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getChildReference(menuListEntry: MenuListEntry): MenuList | undefined {
|
||||||
|
const index = filteredEntries.flat().indexOf(menuListEntry);
|
||||||
|
return childReferences.flat().filter((x) => x)[index];
|
||||||
|
}
|
||||||
|
|
||||||
function onEntryClick(menuListEntry: MenuListEntry) {
|
function onEntryClick(menuListEntry: MenuListEntry) {
|
||||||
// Call the action if available
|
// Call the action if available
|
||||||
if (menuListEntry.action) menuListEntry.action();
|
if (menuListEntry.action) menuListEntry.action();
|
||||||
|
|
@ -63,8 +154,9 @@
|
||||||
dispatch("activeEntry", menuListEntry);
|
dispatch("activeEntry", menuListEntry);
|
||||||
|
|
||||||
// Close the containing menu
|
// Close the containing menu
|
||||||
if (menuListEntry.ref) {
|
let childReference = getChildReference(menuListEntry);
|
||||||
menuListEntry.ref.open = false;
|
if (childReference) {
|
||||||
|
childReference.open = false;
|
||||||
entries = entries;
|
entries = entries;
|
||||||
}
|
}
|
||||||
dispatch("open", false);
|
dispatch("open", false);
|
||||||
|
|
@ -74,8 +166,9 @@
|
||||||
function onEntryPointerEnter(menuListEntry: MenuListEntry) {
|
function onEntryPointerEnter(menuListEntry: MenuListEntry) {
|
||||||
if (!menuListEntry.children?.length) return;
|
if (!menuListEntry.children?.length) return;
|
||||||
|
|
||||||
if (menuListEntry.ref) {
|
let childReference = getChildReference(menuListEntry);
|
||||||
menuListEntry.ref.open = true;
|
if (childReference) {
|
||||||
|
childReference.open = true;
|
||||||
entries = entries;
|
entries = entries;
|
||||||
} else dispatch("open", true);
|
} else dispatch("open", true);
|
||||||
}
|
}
|
||||||
|
|
@ -83,8 +176,9 @@
|
||||||
function onEntryPointerLeave(menuListEntry: MenuListEntry) {
|
function onEntryPointerLeave(menuListEntry: MenuListEntry) {
|
||||||
if (!menuListEntry.children?.length) return;
|
if (!menuListEntry.children?.length) return;
|
||||||
|
|
||||||
if (menuListEntry.ref) {
|
let childReference = getChildReference(menuListEntry);
|
||||||
menuListEntry.ref.open = false;
|
if (childReference) {
|
||||||
|
childReference.open = false;
|
||||||
entries = entries;
|
entries = entries;
|
||||||
} else dispatch("open", false);
|
} else dispatch("open", false);
|
||||||
}
|
}
|
||||||
|
|
@ -92,49 +186,82 @@
|
||||||
function isEntryOpen(menuListEntry: MenuListEntry): boolean {
|
function isEntryOpen(menuListEntry: MenuListEntry): boolean {
|
||||||
if (!menuListEntry.children?.length) return false;
|
if (!menuListEntry.children?.length) return false;
|
||||||
|
|
||||||
return menuListEntry.ref?.open || false;
|
return getChildReference(menuListEntry)?.open || false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handles keyboard navigation for the menu. Returns if the entire menu stack should be dismissed
|
function includeSeparator(entries: MenuListEntry[][], section: MenuListEntry[], sectionIndex: number, search: string): boolean {
|
||||||
export function keydown(e: KeyboardEvent, submenu: boolean): boolean {
|
const elementsBeforeCurrentSection = entries
|
||||||
|
.slice(0, sectionIndex)
|
||||||
|
.flat()
|
||||||
|
.filter((entry) => inSearch(search, entry));
|
||||||
|
const entriesInCurrentSection = section.filter((entry) => inSearch(search, entry));
|
||||||
|
|
||||||
|
return elementsBeforeCurrentSection.length > 0 && entriesInCurrentSection.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentEntries(section: MenuListEntry[], virtualScrollingEntryHeight: number, virtualScrollingStartIndex: number, virtualScrollingEndIndex: number, search: string) {
|
||||||
|
if (!virtualScrollingEntryHeight) {
|
||||||
|
return section.filter((entry) => inSearch(search, entry));
|
||||||
|
}
|
||||||
|
return section.filter((entry) => inSearch(search, entry)).slice(virtualScrollingStartIndex, virtualScrollingEndIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSubmenu(highlightedEntry: MenuListEntry): boolean {
|
||||||
|
let childReference = getChildReference(highlightedEntry);
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles keyboard navigation for the menu.
|
||||||
|
// Returns a boolean indicating whether the entire menu stack should be dismissed.
|
||||||
|
export function keydown(e: KeyboardEvent, submenu = false): boolean {
|
||||||
// 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 (interactive) highlighted = activeEntry;
|
if (interactive) highlighted = activeEntry;
|
||||||
|
|
||||||
const menuOpen = open;
|
const menuOpen = open;
|
||||||
const flatEntries = entries.flat().filter((entry) => !entry.disabled);
|
const flatEntries = filteredEntries.flat().filter((entry) => !entry.disabled);
|
||||||
const openChild = flatEntries.findIndex((entry) => (entry.children?.length ?? 0) > 0 && entry.ref?.open);
|
const openChild = flatEntries.findIndex((entry) => (entry.children?.length ?? 0) > 0 && getChildReference(entry)?.open);
|
||||||
|
|
||||||
const openSubmenu = (highlightedEntry: MenuListEntry) => {
|
|
||||||
if (highlightedEntry.ref && highlightedEntry.children?.length) {
|
|
||||||
highlightedEntry.ref.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
|
|
||||||
highlightedEntry.ref.setHighlighted(highlightedEntry.children[0][0]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
// Allow opening menu with space or enter
|
||||||
if (!menuOpen && (e.key === " " || e.key === "Enter")) {
|
if (!menuOpen && (e.key === " " || e.key === "Enter")) {
|
||||||
// Allow opening menu with space or enter
|
|
||||||
open = true;
|
open = true;
|
||||||
highlighted = activeEntry;
|
highlighted = activeEntry;
|
||||||
} else if (menuOpen && openChild >= 0) {
|
|
||||||
|
// Keep the menu stack open
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If a submenu is open, have it handle this instead
|
||||||
|
if (menuOpen && openChild >= 0) {
|
||||||
|
const childMenuListEntry = flatEntries[openChild];
|
||||||
|
const childMenu = getChildReference(childMenuListEntry);
|
||||||
|
|
||||||
// Redirect the keyboard navigation to a submenu if one is open
|
// Redirect the keyboard navigation to a submenu if one is open
|
||||||
const shouldCloseStack = flatEntries[openChild].ref?.keydown(e, true);
|
const shouldCloseStack = childMenu?.keydown(e, true) || false;
|
||||||
|
|
||||||
// Highlight the menu item in the parent list that corresponds with the open submenu
|
// Highlight the menu item in the parent list that corresponds with the open submenu
|
||||||
if (e.key !== "Escape" && highlighted) setHighlighted(flatEntries[openChild]);
|
if (highlighted && e.key !== "Escape") setHighlighted(childMenuListEntry);
|
||||||
|
|
||||||
// Handle the child closing the entire menu stack
|
// Handle the child closing the entire menu stack
|
||||||
if (shouldCloseStack) {
|
if (shouldCloseStack) open = false;
|
||||||
open = false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} else if ((menuOpen || interactive) && (e.key === "ArrowUp" || e.key === "ArrowDown")) {
|
|
||||||
// Navigate to the next and previous entries with arrow keys
|
|
||||||
|
|
||||||
|
// Keep the menu stack open
|
||||||
|
return shouldCloseStack;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate to the next and previous entries with arrow keys
|
||||||
|
if ((menuOpen || interactive) && (e.key === "ArrowUp" || e.key === "ArrowDown")) {
|
||||||
let newIndex = e.key === "ArrowUp" ? flatEntries.length - 1 : 0;
|
let newIndex = e.key === "ArrowUp" ? flatEntries.length - 1 : 0;
|
||||||
if (highlighted) {
|
if (highlighted) {
|
||||||
const index = highlighted ? flatEntries.map((entry) => entry.label).indexOf(highlighted.label) : 0;
|
const index = highlighted ? flatEntries.map((entry) => entry.label).indexOf(highlighted.label) : 0;
|
||||||
|
|
@ -147,13 +274,26 @@
|
||||||
|
|
||||||
const newEntry = flatEntries[newIndex];
|
const newEntry = flatEntries[newIndex];
|
||||||
setHighlighted(newEntry);
|
setHighlighted(newEntry);
|
||||||
} else if (menuOpen && e.key === "Escape") {
|
|
||||||
// Close menu with escape key
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Keep the menu stack open
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close menu with escape key
|
||||||
|
if (menuOpen && e.key === "Escape") {
|
||||||
open = false;
|
open = false;
|
||||||
|
|
||||||
// Reset active to before open
|
// Reset active to before open
|
||||||
setHighlighted(activeEntry);
|
setHighlighted(activeEntry);
|
||||||
} else if (menuOpen && highlighted && e.key === "Enter") {
|
|
||||||
|
// Keep the menu stack open
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click on a highlighted entry with the enter key
|
||||||
|
if (menuOpen && highlighted && e.key === "Enter") {
|
||||||
// Handle clicking on an option if enter is pressed
|
// Handle clicking on an option if enter is pressed
|
||||||
if (!highlighted.children?.length) onEntryClick(highlighted);
|
if (!highlighted.children?.length) onEntryClick(highlighted);
|
||||||
else openSubmenu(highlighted);
|
else openSubmenu(highlighted);
|
||||||
|
|
@ -163,26 +303,81 @@
|
||||||
|
|
||||||
// Enter should close the entire menu stack
|
// Enter should close the entire menu stack
|
||||||
return true;
|
return true;
|
||||||
} else if (menuOpen && highlighted && e.key === "ArrowRight") {
|
|
||||||
// Right arrow opens a submenu
|
|
||||||
openSubmenu(highlighted);
|
|
||||||
} else if (menuOpen && e.key === "ArrowLeft") {
|
|
||||||
// Left arrow closes a submenu
|
|
||||||
if (submenu) open = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// By default, keep the menu stack open
|
// Open a submenu with the right arrow key, space, or enter
|
||||||
|
if (menuOpen && highlighted && (e.key === "ArrowRight" || e.key === " " || e.key === "Enter")) {
|
||||||
|
// Right arrow opens a submenu
|
||||||
|
const openable = openSubmenu(highlighted);
|
||||||
|
|
||||||
|
// Prevent the right arrow from moving the search text cursor if we are opening a submenu
|
||||||
|
if (openable) e.preventDefault();
|
||||||
|
|
||||||
|
// Keep the menu stack open
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close a submenu with the left arrow key
|
||||||
|
if (menuOpen && e.key === "ArrowLeft") {
|
||||||
|
// Left arrow closes a submenu
|
||||||
|
if (submenu) {
|
||||||
|
open = false;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the menu stack open
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start a search with any other key
|
||||||
|
if (menuOpen && search === "") {
|
||||||
|
startSearch(e);
|
||||||
|
|
||||||
|
// Keep the menu stack open
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If nothing happened, keep the menu stack open
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setHighlighted(newHighlight: MenuListEntry | undefined) {
|
export function setHighlighted(newHighlight: MenuListEntry | undefined) {
|
||||||
highlighted = newHighlight;
|
highlighted = newHighlight;
|
||||||
|
|
||||||
// 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 (interactive && newHighlight?.value !== activeEntry?.value && newHighlight) dispatch("activeEntry", newHighlight);
|
if (interactive && newHighlight?.value !== activeEntry?.value && newHighlight) {
|
||||||
|
dispatch("activeEntry", newHighlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll into view
|
||||||
|
let container = scroller?.div?.();
|
||||||
|
if (!container || !highlighted) return;
|
||||||
|
let containerBoundingRect = container.getBoundingClientRect();
|
||||||
|
let highlightedIndex = filteredEntries.flat().findIndex((entry) => entry === highlighted);
|
||||||
|
|
||||||
|
let selectedBoundingRect = new DOMRect();
|
||||||
|
if (virtualScrollingEntryHeight) {
|
||||||
|
// Special case for virtual scrolling
|
||||||
|
selectedBoundingRect.y = highlightedIndex * virtualScrollingEntryHeight - container.scrollTop + containerBoundingRect.y;
|
||||||
|
selectedBoundingRect.height = virtualScrollingEntryHeight;
|
||||||
|
} else {
|
||||||
|
let entries = Array.from(container.children).filter((element) => element.classList.contains("row"));
|
||||||
|
let element = entries[highlightedIndex - startIndex];
|
||||||
|
if (!element) return;
|
||||||
|
containerBoundingRect = element.getBoundingClientRect();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (containerBoundingRect.y > selectedBoundingRect.y) {
|
||||||
|
container.scrollBy(0, selectedBoundingRect.y - containerBoundingRect.y);
|
||||||
|
}
|
||||||
|
if (containerBoundingRect.y + containerBoundingRect.height < selectedBoundingRect.y + selectedBoundingRect.height) {
|
||||||
|
container.scrollBy(0, selectedBoundingRect.y - (containerBoundingRect.y + containerBoundingRect.height) + selectedBoundingRect.height);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function scrollViewTo(distanceDown: number) {
|
export function scrollViewTo(distanceDown: number) {
|
||||||
scroller?.div()?.scrollTo(0, distanceDown);
|
scroller?.div?.()?.scrollTo(0, distanceDown);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -199,6 +394,9 @@
|
||||||
scrollableY={scrollableY && virtualScrollingEntryHeight === 0}
|
scrollableY={scrollableY && virtualScrollingEntryHeight === 0}
|
||||||
bind:this={self}
|
bind:this={self}
|
||||||
>
|
>
|
||||||
|
{#if search.length > 0}
|
||||||
|
<TextInput class="search" value={search} on:value={({ detail }) => (search = detail)} bind:this={searchTextInput}></TextInput>
|
||||||
|
{/if}
|
||||||
<!-- If we put the scrollableY on the layoutcol for non-font dropdowns then for some reason it always creates a tiny scrollbar.
|
<!-- If we put the scrollableY on the layoutcol for non-font dropdowns then for some reason it always creates a tiny scrollbar.
|
||||||
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`. -->
|
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`. -->
|
||||||
<LayoutCol
|
<LayoutCol
|
||||||
|
|
@ -211,10 +409,10 @@
|
||||||
<LayoutRow class="scroll-spacer" styles={{ height: `${virtualScrollingStartIndex * virtualScrollingEntryHeight}px` }} />
|
<LayoutRow class="scroll-spacer" styles={{ height: `${virtualScrollingStartIndex * virtualScrollingEntryHeight}px` }} />
|
||||||
{/if}
|
{/if}
|
||||||
{#each entries as section, sectionIndex (sectionIndex)}
|
{#each entries as section, sectionIndex (sectionIndex)}
|
||||||
{#if sectionIndex > 0}
|
{#if includeSeparator(entries, section, sectionIndex, search)}
|
||||||
<Separator type="Section" direction="Vertical" />
|
<Separator type="Section" direction="Vertical" />
|
||||||
{/if}
|
{/if}
|
||||||
{#each virtualScrollingEntryHeight ? section.slice(virtualScrollingStartIndex, virtualScrollingEndIndex) : section as entry, entryIndex (entryIndex + startIndex)}
|
{#each currentEntries(section, virtualScrollingEntryHeight, virtualScrollingStartIndex, virtualScrollingEndIndex, search) as entry, entryIndex (entryIndex + startIndex)}
|
||||||
<LayoutRow
|
<LayoutRow
|
||||||
class="row"
|
class="row"
|
||||||
classes={{ open: isEntryOpen(entry), active: entry.label === highlighted?.label, disabled: Boolean(entry.disabled) }}
|
classes={{ open: isEntryOpen(entry), active: entry.label === highlighted?.label, disabled: Boolean(entry.disabled) }}
|
||||||
|
|
@ -247,8 +445,21 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if entry.children}
|
{#if entry.children}
|
||||||
<!-- TODO: Solve the red underline error on the bind:this below -->
|
<MenuList
|
||||||
<svelte:self on:naturalWidth open={entry.ref?.open || false} direction="TopRight" entries={entry.children} {minWidth} {drawIcon} {scrollableY} bind:this={entry.ref} />
|
on:naturalWidth={() => {
|
||||||
|
// We do a manual dispatch here instead of just `on:naturalWidth` as a workaround for the <script> tag
|
||||||
|
// at the top of this file displaying a "'render' implicitly has return type 'any' because..." error.
|
||||||
|
// See explanation at <https://github.com/sveltejs/language-tools/issues/452#issuecomment-723148184>.
|
||||||
|
dispatch("naturalWidth");
|
||||||
|
}}
|
||||||
|
open={getChildReference(entry)?.open || false}
|
||||||
|
direction="TopRight"
|
||||||
|
entries={entry.children}
|
||||||
|
{minWidth}
|
||||||
|
{drawIcon}
|
||||||
|
{scrollableY}
|
||||||
|
bind:this={childReferences[sectionIndex][entryIndex + startIndex]}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</LayoutRow>
|
</LayoutRow>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
@ -261,6 +472,11 @@
|
||||||
|
|
||||||
<style lang="scss" global>
|
<style lang="scss" global>
|
||||||
.menu-list {
|
.menu-list {
|
||||||
|
.search {
|
||||||
|
margin: 4px;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.floating-menu-container .floating-menu-content.floating-menu-content {
|
.floating-menu-container .floating-menu-content.floating-menu-content {
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,7 @@
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// Measure the content and round up its width and height to the nearest even integer.
|
// 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.
|
// 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) {
|
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: 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.
|
// TODO: The current method only notices when the dialog size increases but can't detect when it decreases.
|
||||||
|
|
@ -142,7 +142,7 @@
|
||||||
|
|
||||||
afterUpdate(() => {
|
afterUpdate(() => {
|
||||||
// Remove the size constraint after the content updates so the resize observer can measure the content and reapply a newly calculated one
|
// 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) {
|
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
|
// 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");
|
floatingMenuContentDiv.style.setProperty("min-width", "unset");
|
||||||
|
|
@ -164,7 +164,7 @@
|
||||||
|
|
||||||
const workspace = document.querySelector("[data-workspace]");
|
const workspace = document.querySelector("[data-workspace]");
|
||||||
|
|
||||||
const floatingMenuContentDiv = floatingMenuContent?.div();
|
const floatingMenuContentDiv = floatingMenuContent?.div?.();
|
||||||
if (!workspace || !self || !floatingMenuContainer || !floatingMenuContent || !floatingMenuContentDiv) return;
|
if (!workspace || !self || !floatingMenuContainer || !floatingMenuContent || !floatingMenuContentDiv) return;
|
||||||
|
|
||||||
workspaceBounds = workspace.getBoundingClientRect();
|
workspaceBounds = workspace.getBoundingClientRect();
|
||||||
|
|
@ -265,7 +265,7 @@
|
||||||
|
|
||||||
// Measure the width of the floating menu content element, if it's currently visible
|
// 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
|
// 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
|
// 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;
|
measuringOngoing = false;
|
||||||
|
|
|
||||||
|
|
@ -146,7 +146,7 @@
|
||||||
|
|
||||||
await tick();
|
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;
|
const textInput = (query instanceof HTMLInputElement && query) || undefined;
|
||||||
textInput?.select();
|
textInput?.select();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,6 @@
|
||||||
tabindex={disabled ? -1 : 0}
|
tabindex={disabled ? -1 : 0}
|
||||||
data-floating-menu-spawner={menuListChildrenExists ? "" : "no-hover-transfer"}
|
data-floating-menu-spawner={menuListChildrenExists ? "" : "no-hover-transfer"}
|
||||||
on:click={onClick}
|
on:click={onClick}
|
||||||
on:keydown={(e) => self?.keydown(e, false)}
|
|
||||||
>
|
>
|
||||||
{#if icon}
|
{#if icon}
|
||||||
<IconLabel {icon} />
|
<IconLabel {icon} />
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
import IconLabel from "@graphite/components/widgets/labels/IconLabel.svelte";
|
import IconLabel from "@graphite/components/widgets/labels/IconLabel.svelte";
|
||||||
import TextLabel from "@graphite/components/widgets/labels/TextLabel.svelte";
|
import TextLabel from "@graphite/components/widgets/labels/TextLabel.svelte";
|
||||||
|
|
||||||
const DASH_ENTRY = { label: "-" };
|
const DASH_ENTRY = { value: "", label: "-" };
|
||||||
|
|
||||||
const dispatch = createEventDispatcher<{ selectedIndex: number }>();
|
const dispatch = createEventDispatcher<{ selectedIndex: number }>();
|
||||||
|
|
||||||
|
|
@ -56,7 +56,7 @@
|
||||||
|
|
||||||
function unFocusDropdownBox(e: FocusEvent) {
|
function unFocusDropdownBox(e: FocusEvent) {
|
||||||
const blurTarget = (e.target as HTMLDivElement | undefined)?.closest("[data-dropdown-input]") || undefined;
|
const blurTarget = (e.target as HTMLDivElement | undefined)?.closest("[data-dropdown-input]") || undefined;
|
||||||
if (blurTarget !== self?.div()) open = false;
|
if (blurTarget !== self?.div?.()) open = false;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -68,7 +68,6 @@
|
||||||
{tooltip}
|
{tooltip}
|
||||||
on:click={() => !disabled && (open = true)}
|
on:click={() => !disabled && (open = true)}
|
||||||
on:blur={unFocusDropdownBox}
|
on:blur={unFocusDropdownBox}
|
||||||
on:keydown={(e) => menuList?.keydown(e, false)}
|
|
||||||
tabindex={disabled ? -1 : 0}
|
tabindex={disabled ? -1 : 0}
|
||||||
data-floating-menu-spawner
|
data-floating-menu-spawner
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -104,16 +104,7 @@
|
||||||
|
|
||||||
<!-- TODO: Combine this widget into the DropdownInput widget -->
|
<!-- TODO: Combine this widget into the DropdownInput widget -->
|
||||||
<LayoutRow class="font-input">
|
<LayoutRow class="font-input">
|
||||||
<LayoutRow
|
<LayoutRow class="dropdown-box" classes={{ disabled }} styles={{ "min-width": `${minWidth}px` }} {tooltip} tabindex={disabled ? -1 : 0} on:click={toggleOpen} data-floating-menu-spawner>
|
||||||
class="dropdown-box"
|
|
||||||
classes={{ disabled }}
|
|
||||||
styles={{ "min-width": `${minWidth}px` }}
|
|
||||||
{tooltip}
|
|
||||||
tabindex={disabled ? -1 : 0}
|
|
||||||
on:click={toggleOpen}
|
|
||||||
on:keydown={(e) => menuList?.keydown(e, false)}
|
|
||||||
data-floating-menu-spawner
|
|
||||||
>
|
|
||||||
<TextLabel class="dropdown-label">{activeEntry?.value || ""}</TextLabel>
|
<TextLabel class="dropdown-label">{activeEntry?.value || ""}</TextLabel>
|
||||||
<IconLabel class="dropdown-arrow" icon="DropdownArrow" />
|
<IconLabel class="dropdown-arrow" icon="DropdownArrow" />
|
||||||
</LayoutRow>
|
</LayoutRow>
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,10 @@
|
||||||
export let centered = false;
|
export let centered = false;
|
||||||
export let minWidth = 0;
|
export let minWidth = 0;
|
||||||
|
|
||||||
|
let className = "";
|
||||||
|
export { className as class };
|
||||||
|
export let classes: Record<string, boolean> = {};
|
||||||
|
|
||||||
let self: FieldInput | undefined;
|
let self: FieldInput | undefined;
|
||||||
let editing = false;
|
let editing = false;
|
||||||
|
|
||||||
|
|
@ -50,11 +54,15 @@
|
||||||
export function focus() {
|
export function focus() {
|
||||||
self?.focus();
|
self?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function element(): HTMLInputElement | HTMLTextAreaElement | undefined {
|
||||||
|
return self?.element();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<FieldInput
|
<FieldInput
|
||||||
class="text-input"
|
class={`text-input ${className}`.trim()}
|
||||||
classes={{ centered }}
|
classes={{ centered, ...classes }}
|
||||||
styles={{ "min-width": minWidth > 0 ? `${minWidth}px` : undefined }}
|
styles={{ "min-width": minWidth > 0 ? `${minWidth}px` : undefined }}
|
||||||
{value}
|
{value}
|
||||||
on:value
|
on:value
|
||||||
|
|
@ -71,6 +79,8 @@
|
||||||
|
|
||||||
<style lang="scss" global>
|
<style lang="scss" global>
|
||||||
.text-input {
|
.text-input {
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
input {
|
input {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@
|
||||||
|
|
||||||
export async function scrollTabIntoView(newIndex: number) {
|
export async function scrollTabIntoView(newIndex: number) {
|
||||||
await tick();
|
await tick();
|
||||||
tabElements[newIndex]?.div()?.scrollIntoView();
|
tabElements[newIndex]?.div?.()?.scrollIntoView();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,9 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli
|
||||||
// Don't redirect tab or enter if not in canvas (to allow navigating elements)
|
// Don't redirect tab or enter if not in canvas (to allow navigating elements)
|
||||||
if (!canvasFocused && !targetIsTextField(e.target || undefined) && ["Tab", "Enter", "NumpadEnter", "Space", "ArrowDown", "ArrowLeft", "ArrowRight", "ArrowUp"].includes(key)) return false;
|
if (!canvasFocused && !targetIsTextField(e.target || undefined) && ["Tab", "Enter", "NumpadEnter", "Space", "ArrowDown", "ArrowLeft", "ArrowRight", "ArrowUp"].includes(key)) return false;
|
||||||
|
|
||||||
|
// Don't redirect if a MenuList is open
|
||||||
|
if (window.document.querySelector("[data-floating-menu-content]")) return false;
|
||||||
|
|
||||||
// Redirect to the backend
|
// Redirect to the backend
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 PopoverButtonStyle, type IconName, type IconSize } from "@graphite/utility-functions/icons";
|
||||||
import { type WasmEditorInstance, type WasmRawInstance } from "@graphite/wasm-communication/editor";
|
import { type WasmEditorInstance, type WasmRawInstance } from "@graphite/wasm-communication/editor";
|
||||||
|
|
||||||
import type MenuList from "@graphite/components/floating-menus/MenuList.svelte";
|
|
||||||
|
|
||||||
export class JsMessage {
|
export class JsMessage {
|
||||||
// The marker provides a way to check if an object is a sub-class constructor for a jsMessage.
|
// The marker provides a way to check if an object is a sub-class constructor for a jsMessage.
|
||||||
static readonly jsMessageMarker = true;
|
static readonly jsMessageMarker = true;
|
||||||
|
|
@ -713,12 +711,11 @@ export type MenuListEntry = MenuEntryCommon & {
|
||||||
action?: () => void;
|
action?: () => void;
|
||||||
children?: MenuListEntry[][];
|
children?: MenuListEntry[][];
|
||||||
|
|
||||||
|
value: string;
|
||||||
shortcutRequiresLock?: boolean;
|
shortcutRequiresLock?: boolean;
|
||||||
value?: string;
|
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
tooltip?: string;
|
tooltip?: string;
|
||||||
font?: URL;
|
font?: URL;
|
||||||
ref?: MenuList;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export class CurveManipulatorGroup {
|
export class CurveManipulatorGroup {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue