mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-19 10:58:04 +08:00
* Fix dropdown select if options have identical labels * update other uses of label in favor of value * simplify null checks
678 lines
22 KiB
Svelte
678 lines
22 KiB
Svelte
<svelte:options accessors={true} />
|
|
|
|
<script lang="ts">
|
|
import { createEventDispatcher, tick, onDestroy, onMount } from "svelte";
|
|
import MenuList from "/src/components/floating-menus/MenuList.svelte";
|
|
import FloatingMenu from "/src/components/layout/FloatingMenu.svelte";
|
|
import LayoutCol from "/src/components/layout/LayoutCol.svelte";
|
|
import LayoutRow from "/src/components/layout/LayoutRow.svelte";
|
|
import TextInput from "/src/components/widgets/inputs/TextInput.svelte";
|
|
import IconLabel from "/src/components/widgets/labels/IconLabel.svelte";
|
|
import Separator from "/src/components/widgets/labels/Separator.svelte";
|
|
import ShortcutLabel from "/src/components/widgets/labels/ShortcutLabel.svelte";
|
|
import TextLabel from "/src/components/widgets/labels/TextLabel.svelte";
|
|
import type { MenuListEntry, MenuDirection } from "/wrapper/pkg/graphite_wasm_wrapper";
|
|
|
|
let self: FloatingMenu | undefined;
|
|
let scroller: LayoutCol | undefined;
|
|
let searchTextInput: TextInput | undefined;
|
|
|
|
const dispatch = createEventDispatcher<{
|
|
open: boolean;
|
|
activeEntry: MenuListEntry;
|
|
selectedEntryValuePath: string[];
|
|
hoverInEntry: MenuListEntry;
|
|
hoverOutEntry: undefined;
|
|
naturalWidth: number;
|
|
}>();
|
|
|
|
export let parentsValuePath: string[] = [];
|
|
export let entries: MenuListEntry[][];
|
|
export let entriesHash: bigint;
|
|
export let activeEntry: MenuListEntry | undefined = undefined;
|
|
export let open: boolean;
|
|
export let direction: MenuDirection = "Bottom";
|
|
export let minWidth = 0;
|
|
export let drawIcon = false;
|
|
export let interactive = false;
|
|
export let scrollableY = false;
|
|
export let virtualScrolling = false;
|
|
|
|
// Keep the child references outside of the entries array so as to avoid infinite recursion.
|
|
let childReferences: MenuList[][] = [];
|
|
let openChildValue: string | undefined = undefined;
|
|
let search = "";
|
|
let reactiveEntries = entries;
|
|
let highlighted: MenuListEntry | undefined = activeEntry;
|
|
let virtualScrollingEntriesStart = 0;
|
|
let keydownListenerAdded = false;
|
|
let destroyed = false;
|
|
let maxMenuWidth = 0;
|
|
let resizeObserver: ResizeObserver | undefined = undefined;
|
|
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- `loadedFonts` reactivity is driven by `loadedFontsGeneration`, not the Set itself
|
|
let loadedFonts = new Set<string>();
|
|
let loadedFontsGeneration = 0;
|
|
|
|
// `watchOpen` is called only when `open` is changed from outside this component
|
|
$: watchOpen(open);
|
|
$: watchEntries(entries);
|
|
$: watchEntriesHash(entriesHash);
|
|
$: watchRemeasureWidth(filteredEntries, drawIcon);
|
|
$: watchHighlightedWithSearch(filteredEntries, open);
|
|
|
|
$: virtualScrollingEntryHeight = virtualScrolling ? 20 : 0;
|
|
$: filteredEntries = reactiveEntries.map((section) => section.filter((entry) => inSearch(search, entry)));
|
|
$: startIndex = virtualScrollingEntryHeight ? virtualScrollingStartIndex : 0;
|
|
// Virtual scrolling calculations
|
|
$: virtualScrollingTotalHeight = filteredEntries.length === 0 ? 0 : filteredEntries[0].length * virtualScrollingEntryHeight;
|
|
$: virtualScrollingStartIndex = filteredEntries.length === 0 ? 0 : Math.floor(virtualScrollingEntriesStart / virtualScrollingEntryHeight) || 0;
|
|
$: virtualScrollingEndIndex = filteredEntries.length === 0 ? 0 : Math.min(filteredEntries[0].length, virtualScrollingStartIndex + 1 + 400 / virtualScrollingEntryHeight);
|
|
|
|
// 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 (!destroyed && open && !inNestedMenuList() && !keydownListenerAdded) {
|
|
addEventListener("keydown", keydown);
|
|
keydownListenerAdded = true;
|
|
}
|
|
});
|
|
onDestroy(() => {
|
|
removeEventListener("keydown", keydown);
|
|
resizeObserver?.disconnect();
|
|
// Set the destroyed status in the closure kept by the awaited `tick()` in `onMount` in case that delayed run occurs after the component is destroyed
|
|
destroyed = true;
|
|
});
|
|
|
|
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 = flattened.map((entry) => entry.value).includes(highlighted.value);
|
|
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) {
|
|
if (open && !inNestedMenuList() && !keydownListenerAdded) {
|
|
addEventListener("keydown", keydown);
|
|
keydownListenerAdded = true;
|
|
} else if (!open && !inNestedMenuList() && keydownListenerAdded) {
|
|
removeEventListener("keydown", keydown);
|
|
keydownListenerAdded = false;
|
|
}
|
|
|
|
// For virtual scrolling menus, observe width changes so the menu only grows and never shrinks while open
|
|
if (open && virtualScrolling) {
|
|
startMenuWidthObserver();
|
|
} else if (resizeObserver) {
|
|
resizeObserver.disconnect();
|
|
resizeObserver = undefined;
|
|
maxMenuWidth = 0;
|
|
}
|
|
|
|
highlighted = activeEntry;
|
|
dispatch("open", open);
|
|
|
|
search = "";
|
|
}
|
|
|
|
function watchEntries(entries: MenuListEntry[][]) {
|
|
entries.forEach((_, index) => {
|
|
if (!childReferences[index]) childReferences[index] = [];
|
|
});
|
|
}
|
|
|
|
function watchEntriesHash(_: bigint) {
|
|
reactiveEntries = entries;
|
|
}
|
|
|
|
function watchRemeasureWidth(_: MenuListEntry[][], __: boolean) {
|
|
// Skip re-measurement for virtual scrolling menus since ResizeObserver handles their width
|
|
if (virtualScrolling) return;
|
|
|
|
self?.measureAndEmitNaturalWidth();
|
|
}
|
|
|
|
async function startMenuWidthObserver() {
|
|
await tick();
|
|
// Guard against the menu having closed during the tick
|
|
if (!open) return;
|
|
|
|
const floatingMenuContentDiv = self?.div()?.querySelector("[data-floating-menu-content]");
|
|
if (!(floatingMenuContentDiv instanceof HTMLElement)) return;
|
|
|
|
maxMenuWidth = 0;
|
|
|
|
resizeObserver?.disconnect();
|
|
resizeObserver = new ResizeObserver(() => {
|
|
const width = floatingMenuContentDiv.scrollWidth;
|
|
if (width > maxMenuWidth) {
|
|
maxMenuWidth = width;
|
|
floatingMenuContentDiv.style.minWidth = `${maxMenuWidth}px`;
|
|
}
|
|
});
|
|
resizeObserver.observe(floatingMenuContentDiv);
|
|
}
|
|
|
|
function onScroll(e: Event) {
|
|
if (!virtualScrollingEntryHeight) return;
|
|
virtualScrollingEntriesStart = e.target instanceof HTMLElement ? e.target.scrollTop : 0;
|
|
}
|
|
|
|
function getChildReference(menuListEntry: MenuListEntry): MenuList | undefined {
|
|
const index = filteredEntries.flat().findIndex((entry) => entry.value === menuListEntry.value);
|
|
|
|
if (index !== -1) {
|
|
return childReferences.flat().filter((x) => x)[index];
|
|
} else {
|
|
// eslint-disable-next-line no-console
|
|
console.error("MenuListEntry not found in filteredEntries:", menuListEntry);
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function onEntryClick(menuListEntry: MenuListEntry) {
|
|
// Close the containing menu
|
|
let childReference = getChildReference(menuListEntry);
|
|
if (childReference) {
|
|
openChildValue = undefined;
|
|
reactiveEntries = reactiveEntries;
|
|
}
|
|
dispatch("open", false);
|
|
open = false;
|
|
reactiveEntries = reactiveEntries;
|
|
|
|
// Notify the parent about the clicked entry as the new active entry
|
|
dispatch("activeEntry", menuListEntry);
|
|
dispatch("selectedEntryValuePath", [...parentsValuePath, menuListEntry.value]);
|
|
}
|
|
|
|
function onEntryPointerEnter(menuListEntry: MenuListEntry) {
|
|
if (!menuListEntry.children?.length) {
|
|
dispatch("hoverInEntry", menuListEntry);
|
|
return;
|
|
}
|
|
|
|
let childReference = getChildReference(menuListEntry);
|
|
if (childReference) {
|
|
openChildValue = menuListEntry.value;
|
|
reactiveEntries = reactiveEntries;
|
|
} else {
|
|
dispatch("open", true);
|
|
}
|
|
}
|
|
|
|
function onEntryPointerLeave(menuListEntry: MenuListEntry) {
|
|
if (!menuListEntry.children?.length) {
|
|
dispatch("hoverOutEntry");
|
|
return;
|
|
}
|
|
|
|
let childReference = getChildReference(menuListEntry);
|
|
if (childReference) {
|
|
openChildValue = undefined;
|
|
reactiveEntries = reactiveEntries;
|
|
} else {
|
|
dispatch("open", false);
|
|
}
|
|
}
|
|
|
|
function includeSeparator(entries: MenuListEntry[][], section: MenuListEntry[], sectionIndex: number, search: string): 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;
|
|
|
|
openChildValue = highlightedEntry.value;
|
|
// 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 `childReference.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 {
|
|
const menuOpen = open;
|
|
const flatEntries = filteredEntries.flat().filter((entry) => !entry.disabled);
|
|
const openChild = (openChildValue !== undefined && flatEntries.findIndex((entry) => entry.value === openChildValue)) || -1;
|
|
|
|
// Allow opening menu with space or enter
|
|
if (!menuOpen && (e.key === " " || e.key === "Enter")) {
|
|
open = true;
|
|
highlighted = activeEntry;
|
|
|
|
// 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
|
|
const shouldCloseStack = childMenu?.keydown(e, true) || false;
|
|
|
|
// Highlight the menu item in the parent list that corresponds with the open submenu
|
|
if (highlighted && e.key !== "Escape") setHighlighted(childMenuListEntry);
|
|
|
|
// Handle the child closing the entire menu stack
|
|
if (shouldCloseStack) open = false;
|
|
|
|
// 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;
|
|
if (highlighted) {
|
|
const index = flatEntries.map((entry) => entry.value).indexOf(highlighted.value);
|
|
newIndex = index + (e.key === "ArrowUp" ? -1 : 1);
|
|
|
|
// Interactive dropdowns should lock at the end whereas other dropdowns should loop
|
|
if (interactive) newIndex = Math.min(flatEntries.length - 1, Math.max(0, newIndex));
|
|
else newIndex = (newIndex + flatEntries.length) % flatEntries.length;
|
|
}
|
|
|
|
const newEntry = flatEntries[newIndex];
|
|
setHighlighted(newEntry);
|
|
|
|
e.preventDefault();
|
|
|
|
// Keep the menu stack open
|
|
return false;
|
|
}
|
|
|
|
// Close menu with escape key
|
|
if (menuOpen && e.key === "Escape") {
|
|
open = false;
|
|
|
|
// Reset active to before open
|
|
setHighlighted(activeEntry);
|
|
|
|
// 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
|
|
if (!highlighted.children?.length) onEntryClick(highlighted);
|
|
else openSubmenu(highlighted);
|
|
|
|
// Stop the event from triggering a press on a new dialog
|
|
e.preventDefault();
|
|
|
|
// Enter should close the entire menu stack
|
|
return true;
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
export function setHighlighted(newHighlight: MenuListEntry | undefined) {
|
|
highlighted = newHighlight;
|
|
|
|
// Interactive menus should keep the active entry the same as the highlighted one
|
|
// 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.value === highlighted?.value);
|
|
|
|
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);
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<FloatingMenu
|
|
class="menu-list"
|
|
{open}
|
|
on:open={({ detail }) => (open = detail)}
|
|
on:naturalWidth
|
|
type="Dropdown"
|
|
windowEdgeMargin={0}
|
|
escapeCloses={false}
|
|
{direction}
|
|
{minWidth}
|
|
scrollableY={scrollableY && virtualScrollingEntryHeight === 0}
|
|
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.
|
|
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
|
|
bind:this={scroller}
|
|
scrollableY={scrollableY && virtualScrollingEntryHeight !== 0}
|
|
on:scroll={onScroll}
|
|
styles={{ "min-width": virtualScrollingEntryHeight ? `${minWidth}px` : `inherit` }}
|
|
>
|
|
{#if virtualScrollingEntryHeight}
|
|
<LayoutRow class="scroll-spacer" styles={{ height: `${virtualScrollingStartIndex * virtualScrollingEntryHeight}px` }} />
|
|
{/if}
|
|
{#each reactiveEntries as section, sectionIndex (sectionIndex)}
|
|
{#if includeSeparator(reactiveEntries, section, sectionIndex, search)}
|
|
<Separator style="Section" direction="Vertical" />
|
|
{/if}
|
|
{#each currentEntries(section, virtualScrollingEntryHeight, virtualScrollingStartIndex, virtualScrollingEndIndex, search) as entry, entryIndex (entryIndex + startIndex)}
|
|
<LayoutRow
|
|
class="row"
|
|
classes={{ open: openChildValue === entry.value, active: entry.value === highlighted?.value, disabled: Boolean(entry.disabled) }}
|
|
styles={{ height: virtualScrollingEntryHeight || "20px" }}
|
|
tooltipLabel={entry.tooltipLabel}
|
|
tooltipDescription={entry.tooltipDescription}
|
|
tooltipShortcut={entry.tooltipShortcut}
|
|
on:click={() => !entry.disabled && onEntryClick(entry)}
|
|
on:pointerenter={() => !entry.disabled && onEntryPointerEnter(entry)}
|
|
on:pointerleave={() => !entry.disabled && onEntryPointerLeave(entry)}
|
|
>
|
|
{#if entry.icon && drawIcon}
|
|
<IconLabel icon={entry.icon} iconSizeOverride={16} class="entry-icon" />
|
|
{:else if drawIcon}
|
|
<div class="no-icon"></div>
|
|
{/if}
|
|
|
|
{#if entry.font}
|
|
<link
|
|
rel="stylesheet"
|
|
href={entry.font}
|
|
onload={() => {
|
|
document.fonts.load(`16px "${entry.value}"`).then(() => {
|
|
loadedFonts.add(entry.value);
|
|
loadedFontsGeneration += 1; // Modify the dirty trigger
|
|
});
|
|
}}
|
|
/>
|
|
{/if}
|
|
|
|
<TextLabel
|
|
class="entry-label"
|
|
classes={{
|
|
"font-preview": Boolean(entry.font),
|
|
"font-loaded": loadedFontsGeneration >= 0 && loadedFonts.has(entry.value),
|
|
}}
|
|
styles={entry.font ? { "font-family": `"${entry.value}", "Source Sans Pro"` } : {}}
|
|
>
|
|
{entry.label}
|
|
</TextLabel>
|
|
|
|
{#if entry.tooltipShortcut?.shortcut.length}
|
|
<ShortcutLabel shortcut={entry.tooltipShortcut} />
|
|
{/if}
|
|
|
|
{#if entry.children?.length}
|
|
<IconLabel class="submenu-arrow" icon="DropdownArrow" />
|
|
{:else}
|
|
<div class="no-submenu-arrow"></div>
|
|
{/if}
|
|
|
|
{#if entry.children}
|
|
<MenuList
|
|
on:naturalWidth={({ detail }) => {
|
|
// 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", detail);
|
|
}}
|
|
on:selectedEntryValuePath={({ detail }) => dispatch("selectedEntryValuePath", detail)}
|
|
parentsValuePath={[...parentsValuePath, entry.value]}
|
|
open={openChildValue === entry.value}
|
|
direction="TopRight"
|
|
entries={entry.children}
|
|
entriesHash={entry.childrenHash || 0n}
|
|
{minWidth}
|
|
{drawIcon}
|
|
{scrollableY}
|
|
bind:this={childReferences[sectionIndex][entryIndex + startIndex]}
|
|
/>
|
|
{/if}
|
|
</LayoutRow>
|
|
{/each}
|
|
{/each}
|
|
{#if virtualScrollingEntryHeight}
|
|
<LayoutRow class="scroll-spacer" styles={{ height: `${virtualScrollingTotalHeight - virtualScrollingEndIndex * virtualScrollingEntryHeight}px` }} />
|
|
{/if}
|
|
</LayoutCol>
|
|
</FloatingMenu>
|
|
|
|
<style lang="scss">
|
|
.menu-list {
|
|
.search {
|
|
margin: 4px;
|
|
margin-top: 0;
|
|
}
|
|
|
|
.floating-menu-container .floating-menu-content.floating-menu-content {
|
|
padding: 4px 0;
|
|
|
|
.separator {
|
|
margin: 4px 0;
|
|
|
|
div {
|
|
background: var(--color-3-darkgray);
|
|
}
|
|
}
|
|
|
|
.scroll-spacer {
|
|
flex: 0 0 auto;
|
|
}
|
|
|
|
.row {
|
|
height: 20px;
|
|
align-items: center;
|
|
white-space: nowrap;
|
|
position: relative;
|
|
flex: 0 0 auto;
|
|
border-radius: 2px;
|
|
margin: 0 4px;
|
|
|
|
> * {
|
|
flex: 0 0 auto;
|
|
}
|
|
|
|
.no-icon {
|
|
width: 16px;
|
|
height: 16px;
|
|
}
|
|
|
|
.entry-label {
|
|
flex: 1 1 100%;
|
|
margin: 0 4px;
|
|
}
|
|
|
|
.font-preview:not(.font-loaded) {
|
|
opacity: 0.5;
|
|
}
|
|
|
|
.entry-icon,
|
|
.no-icon {
|
|
margin: 0 4px;
|
|
}
|
|
|
|
.shortcut-label {
|
|
margin-left: 12px;
|
|
}
|
|
|
|
.submenu-arrow {
|
|
transform: rotate(270deg);
|
|
}
|
|
|
|
.no-submenu-arrow {
|
|
width: 12px;
|
|
height: 12px;
|
|
}
|
|
|
|
// Extend the submenu to the right by the width of the margin outside the row, since we want the submenu to line up with the edge of the menu
|
|
&.open {
|
|
// Offset by the margin distance
|
|
> .menu-list {
|
|
margin-right: -4px;
|
|
}
|
|
|
|
// Extend the click target by the margin distance so the user can hover to the right of the row, within the margin area, and still have the submenu open
|
|
&::after {
|
|
content: "";
|
|
position: absolute;
|
|
top: 0;
|
|
right: -4px;
|
|
width: 4px;
|
|
height: 100%;
|
|
}
|
|
}
|
|
|
|
&:hover,
|
|
&.open {
|
|
background: var(--color-4-dimgray);
|
|
}
|
|
|
|
&.active {
|
|
background: var(--color-e-nearwhite);
|
|
color: var(--color-2-mildblack);
|
|
|
|
> .icon-label {
|
|
fill: var(--color-2-mildblack);
|
|
}
|
|
}
|
|
|
|
&.disabled {
|
|
color: var(--color-8-uppergray);
|
|
|
|
&:hover {
|
|
background: none;
|
|
}
|
|
|
|
svg {
|
|
fill: var(--color-8-uppergray);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// paddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpadding
|
|
</style>
|