Refactor the old menu bar plumbing to use standard TextButtons (#3444)

* Refactor the old menu bar plumbing to use standard TextButtons

* WIP: Fix Mac native menu bar

* WIP: fix desktop menu bar mac

* Refactor menu bar definitions to use the builder pattern

* WIP: fixup desktop

* cleanup

* fix linux

* Remove dead code that was failing to lint

---------

Co-authored-by: Timon Schelling <me@timon.zip>
This commit is contained in:
Keavon Chambers
2025-12-03 12:41:54 +00:00
committed by GitHub
co-authored by Timon Schelling
parent 3fd0460d03
commit 600fb5c28f
34 changed files with 1222 additions and 1355 deletions
@@ -19,8 +19,16 @@
let scroller: LayoutCol | undefined;
let searchTextInput: TextInput | undefined;
const dispatch = createEventDispatcher<{ open: boolean; activeEntry: MenuListEntry; hoverInEntry: MenuListEntry; hoverOutEntry: undefined; naturalWidth: number }>();
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 activeEntry: MenuListEntry | undefined = undefined;
export let open: boolean;
@@ -30,9 +38,6 @@
export let interactive = false;
export let scrollableY = false;
export let virtualScrollingEntryHeight = 0;
export let tooltipLabel: string | undefined = undefined;
export let tooltipDescription: string | undefined = undefined;
export let tooltipShortcut: string | undefined = undefined;
// Keep the child references outside of the entries array so as to avoid infinite recursion.
let childReferences: MenuList[][] = [];
@@ -149,11 +154,9 @@
}
function onEntryClick(menuListEntry: MenuListEntry) {
// Call the action if available
if (menuListEntry.action) menuListEntry.action();
// Notify the parent about the clicked entry as the new active entry
dispatch("activeEntry", menuListEntry);
dispatch("selectedEntryValuePath", [...parentsValuePath, menuListEntry.value]);
// Close the containing menu
let childReference = getChildReference(menuListEntry);
@@ -425,9 +428,9 @@
class="row"
classes={{ open: isEntryOpen(entry), active: entry.label === highlighted?.label, disabled: Boolean(entry.disabled) }}
styles={{ height: virtualScrollingEntryHeight || "20px" }}
{tooltipLabel}
{tooltipDescription}
{tooltipShortcut}
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)}
@@ -444,8 +447,8 @@
<TextLabel class="entry-label" styles={{ "font-family": `${!entry.font ? "inherit" : entry.value}` }}>{entry.label}</TextLabel>
{#if entry.shortcut?.keys.length}
<UserInputLabel keysWithLabelsGroups={[entry.shortcut.keys]} requiresLock={entry.shortcutRequiresLock} textOnly={true} />
{#if entry.shortcutKeys?.keys.length}
<UserInputLabel keysWithLabelsGroups={[entry.shortcutKeys.keys]} requiresLock={entry.shortcutRequiresLock} textOnly={true} />
{/if}
{#if entry.children?.length}
@@ -462,6 +465,8 @@
// 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={getChildReference(entry)?.open || false}
direction="TopRight"
entries={entry.children}
@@ -4,6 +4,7 @@
import type { FrontendNodeType } from "@graphite/messages";
import type { NodeGraphState } from "@graphite/state-providers/node-graph";
import LayoutCol from "@graphite/components/layout/LayoutCol.svelte";
import TextButton from "@graphite/components/widgets/buttons/TextButton.svelte";
import TextInput from "@graphite/components/widgets/inputs/TextInput.svelte";
import TextLabel from "@graphite/components/widgets/labels/TextLabel.svelte";
@@ -109,8 +110,8 @@
});
</script>
<div class="node-catalog">
<TextInput placeholder="Search Nodes..." value={searchTerm} on:value={({ detail }) => (searchTerm = detail)} bind:this={nodeSearchInput} />
<LayoutCol class="node-catalog">
<TextInput placeholder="Search Nodes" value={searchTerm} on:value={({ detail }) => (searchTerm = detail)} bind:this={nodeSearchInput} />
<div class="list-results" on:wheel|passive|stopPropagation>
{#each nodeCategories as nodeCategory}
<details open={nodeCategory[1].open}>
@@ -131,15 +132,12 @@
<TextLabel>No search results</TextLabel>
{/each}
</div>
</div>
</LayoutCol>
<style lang="scss" global>
.node-catalog {
max-height: 30vh;
min-width: 250px;
display: flex;
flex-direction: column;
align-items: stretch;
.text-input {
flex: 0 0 auto;
@@ -149,13 +147,10 @@
.list-results {
overflow-y: auto;
flex: 1 1 auto;
// Together with the `margin-right: 4px;` on `details` below, this keeps a gap between the listings and the scrollbar
margin-right: -4px;
details {
cursor: pointer;
position: relative;
// Together with the `margin-right: -4px;` on `.list-results` above, this keeps a gap between the listings and the scrollbar
margin-right: 4px;
&[open] summary .text-label::before {
@@ -164,8 +159,6 @@
summary {
display: flex;
align-items: center;
gap: 2px;
.text-label {
padding-left: 16px;
@@ -189,6 +182,11 @@
.text-button {
width: 100%;
margin: 4px 0;
text-align: left;
}
&:last-child .text-button {
margin-bottom: 0;
}
}
}
@@ -495,13 +495,22 @@
--floating-menu-content-offset: 0;
.tail {
width: 0;
height: 0;
border-style: solid;
// Put the tail above the floating menu's shadow
z-index: 10;
// Draw over the application without being clipped by the containing panel's `overflow: hidden`
position: fixed;
&,
&::before {
width: 0;
height: 0;
border-style: solid;
}
&::before {
content: "";
position: absolute;
}
}
.floating-menu-container {
@@ -510,6 +519,7 @@
.floating-menu-content {
background: var(--color-2-mildblack);
box-shadow: rgba(var(--color-0-black-rgb), 0.5) 0 2px 4px;
border: 1px solid var(--color-4-dimgray);
border-radius: 4px;
color: var(--color-e-nearwhite);
font-size: inherit;
@@ -517,6 +527,8 @@
z-index: 0;
// Draw over the application without being clipped by the containing panel's `overflow: hidden`
position: fixed;
// Counteract the rightward shift caused by the border
margin-left: -1px;
}
}
@@ -603,33 +615,69 @@
&.top .tail,
&.topleft .tail,
&.topright .tail {
border-width: 8px 6px 0 6px;
border-color: var(--color-2-mildblack) transparent transparent transparent;
margin-left: -6px;
margin-bottom: 2px;
border-color: var(--color-4-dimgray) transparent transparent transparent;
&::before {
border-color: var(--color-2-mildblack) transparent transparent transparent;
bottom: 0;
}
&,
&::before {
border-width: 8px 6px 0 6px;
margin-left: -6px;
margin-bottom: 2px;
}
}
&.bottom .tail,
&.bottomleft .tail,
&.bottomright .tail {
border-width: 0 6px 8px 6px;
border-color: transparent transparent var(--color-2-mildblack) transparent;
margin-left: -6px;
margin-top: 2px;
border-color: transparent transparent var(--color-4-dimgray) transparent;
&::before {
border-color: transparent transparent var(--color-2-mildblack) transparent;
top: 0;
}
&,
&::before {
border-width: 0 6px 8px 6px;
margin-left: -6px;
margin-top: 2px;
}
}
&.left .tail {
border-width: 6px 0 6px 8px;
border-color: transparent transparent transparent var(--color-2-mildblack);
margin-top: -6px;
margin-right: 2px;
border-color: transparent transparent transparent var(--color-4-dimgray);
&::before {
border-color: transparent transparent transparent var(--color-2-mildblack);
right: 0;
}
&,
&::before {
border-width: 6px 0 6px 8px;
margin-top: -6px;
margin-right: 2px;
}
}
&.right .tail {
border-width: 6px 8px 6px 0;
border-color: transparent var(--color-2-mildblack) transparent transparent;
margin-top: -6px;
margin-left: 2px;
border-color: transparent var(--color-4-dimgray) transparent transparent;
&::before {
border-color: transparent var(--color-2-mildblack) transparent transparent;
left: 0;
}
&,
&::before {
border-width: 6px 8px 6px 0;
margin-top: -6px;
margin-left: 2px;
}
}
&.top .floating-menu-container {
@@ -776,7 +776,7 @@
margin-right: 16px;
}
.right-scrollbar .scrollbar-input {
&:has(.top-ruler) .right-scrollbar .scrollbar-input {
margin-top: -16px;
}
+42 -44
View File
@@ -9,13 +9,11 @@
import type { NodeGraphState } from "@graphite/state-providers/node-graph";
import NodeCatalog from "@graphite/components/floating-menus/NodeCatalog.svelte";
import FloatingMenu from "@graphite/components/layout/FloatingMenu.svelte";
import LayoutCol from "@graphite/components/layout/LayoutCol.svelte";
import LayoutRow from "@graphite/components/layout/LayoutRow.svelte";
import IconButton from "@graphite/components/widgets/buttons/IconButton.svelte";
import TextButton from "@graphite/components/widgets/buttons/TextButton.svelte";
import RadioInput from "@graphite/components/widgets/inputs/RadioInput.svelte";
import IconLabel from "@graphite/components/widgets/labels/IconLabel.svelte";
import Separator from "@graphite/components/widgets/labels/Separator.svelte";
import TextLabel from "@graphite/components/widgets/labels/TextLabel.svelte";
const GRID_COLLAPSE_SPACING = 10;
@@ -202,46 +200,44 @@
>
<!-- Right click menu for adding nodes -->
{#if $nodeGraph.contextMenuInformation}
<LayoutCol
<FloatingMenu
class="context-menu"
data-context-menu
styles={{
left: `${$nodeGraph.contextMenuInformation.contextMenuCoordinates.x * $nodeGraph.transform.scale + $nodeGraph.transform.x}px`,
top: `${$nodeGraph.contextMenuInformation.contextMenuCoordinates.y * $nodeGraph.transform.scale + $nodeGraph.transform.y}px`,
}}
open={true}
type="Popover"
direction="BottomLeft"
>
{#if $nodeGraph.contextMenuInformation.contextMenuData.type === "CreateNode"}
<NodeCatalog initialSearchTerm={$nodeGraph.contextMenuInformation.contextMenuData.data.compatibleType || ""} on:selectNodeType={(e) => createNode(e.detail)} />
{:else if $nodeGraph.contextMenuInformation.contextMenuData.type === "ModifyNode"}
<LayoutRow class="toggle-layer-or-node">
<TextLabel>Display as</TextLabel>
<RadioInput
selectedIndex={$nodeGraph.contextMenuInformation.contextMenuData.data.currentlyIsNode ? 0 : 1}
entries={[
{
value: "node",
label: "Node",
action: () =>
$nodeGraph.contextMenuInformation?.contextMenuData.type === "ModifyNode" &&
editor.handle.setToNodeOrLayer($nodeGraph.contextMenuInformation.contextMenuData.data.nodeId, false),
},
{
value: "layer",
label: "Layer",
action: () =>
$nodeGraph.contextMenuInformation?.contextMenuData.type === "ModifyNode" &&
editor.handle.setToNodeOrLayer($nodeGraph.contextMenuInformation.contextMenuData.data.nodeId, true),
},
]}
disabled={!$nodeGraph.contextMenuInformation.contextMenuData.data.canBeLayer}
<LayoutCol class="modify-node-menu">
<TextButton
label="Merge Selected Nodes"
action={() => {
editor.handle.mergeSelectedNodes();
nodeGraph.closeContextMenu();
}}
flush={true}
/>
</LayoutRow>
<Separator type="Section" direction="Vertical" />
<LayoutRow class="merge-selected-nodes">
<TextButton label="Merge Selected Nodes" action={() => editor.handle.mergeSelectedNodes()} />
</LayoutRow>
{@const currentlyIsNode = $nodeGraph.contextMenuInformation.contextMenuData.data.currentlyIsNode}
<TextButton
label={currentlyIsNode ? "Display as Layer" : "Display as Node"}
action={() => {
if ($nodeGraph.contextMenuInformation?.contextMenuData.type === "ModifyNode") {
editor.handle.setToNodeOrLayer($nodeGraph.contextMenuInformation.contextMenuData.data.nodeId, currentlyIsNode);
}
nodeGraph.closeContextMenu();
}}
disabled={!$nodeGraph.contextMenuInformation.contextMenuData.data.canBeLayer}
flush={true}
/>
</LayoutCol>
{/if}
</LayoutCol>
</FloatingMenu>
{/if}
{#if $nodeGraph.error}
@@ -822,20 +818,17 @@
.context-menu {
width: max-content;
position: absolute;
box-sizing: border-box;
padding: 5px;
z-index: 3;
background-color: var(--color-3-darkgray);
border-radius: 4px;
.toggle-layer-or-node .text-label {
line-height: 24px;
margin-right: 8px;
.modify-node-menu {
margin: -4px;
.text-button {
justify-content: left;
}
}
.merge-selected-nodes {
justify-content: center;
.tail {
display: none;
}
}
@@ -968,11 +961,16 @@
.imports-and-exports {
width: 100%;
height: 100%;
pointer-events: none;
position: absolute;
pointer-events: none;
// Keeps the connectors above the wires
z-index: 1;
// Zero specificity with `:where()` to allow other rules to override `pointer-events`
:where(.graph-view.open & > *) {
pointer-events: auto;
}
.connector {
position: absolute;
width: 8px;
@@ -1071,7 +1069,7 @@
height: 100%;
// Zero specificity with `:where()` to allow other rules to override `pointer-events`
:where(& > *) {
:where(.graph-view.open & > *) {
pointer-events: auto;
}
}
@@ -177,7 +177,7 @@
{/if}
{@const textButton = narrowWidgetProps(component.props, "TextButton")}
{#if textButton}
<TextButton {...exclude(textButton)} action={() => widgetValueCommitAndUpdate(index, undefined)} />
<TextButton {...exclude(textButton)} action={() => widgetValueCommitAndUpdate(index, [])} on:selectedEntryValuePath={({ detail }) => widgetValueCommitAndUpdate(index, detail)} />
{/if}
{@const breadcrumbTrailButtons = narrowWidgetProps(component.props, "BreadcrumbTrailButtons")}
{#if breadcrumbTrailButtons}
@@ -1,6 +1,7 @@
<script lang="ts">
import type { IconName } from "@graphite/icons";
import { createEventDispatcher } from "svelte";
import type { IconName } from "@graphite/icons";
import type { MenuListEntry } from "@graphite/messages";
import MenuList from "@graphite/components/floating-menus/MenuList.svelte";
@@ -8,6 +9,8 @@
import IconLabel from "@graphite/components/widgets/labels/IconLabel.svelte";
import TextLabel from "@graphite/components/widgets/labels/TextLabel.svelte";
const dispatch = createEventDispatcher<{ selectedEntryValuePath: string[] }>();
let self: MenuList;
// Note: IconButton should be used if only an icon, but no label, is desired.
@@ -36,7 +39,7 @@
// If there's no menu to open, trigger the action
if ((menuListChildren?.length ?? 0) === 0) {
// Call the action
if (action && !disabled) action();
if (!disabled) action?.();
// Exit early so we don't continue on and try to open the menu
return;
@@ -84,6 +87,7 @@
{#if menuListChildrenExists}
<MenuList
on:open={({ detail }) => self && (self.open = detail)}
on:selectedEntryValuePath={({ detail }) => dispatch("selectedEntryValuePath", detail)}
open={self?.open || false}
entries={menuListChildren || []}
direction="Bottom"
@@ -165,6 +169,11 @@
&.open {
--button-background-color: var(--color-5-dullgray);
}
&.disabled {
--button-text-color: var(--color-8-uppergray);
--button-background-color: none;
}
}
.icon-label {
@@ -84,8 +84,8 @@
async function getEntries(): Promise<MenuListEntry[]> {
const x = isStyle ? fonts.getFontStyles(fontFamily) : fonts.fontNames();
return (await x).map((entry: { name: string; url: URL | undefined }) => ({
label: entry.name,
value: entry.name,
label: entry.name,
font: entry.url,
action: () => selectFont(entry.name),
}));
@@ -110,6 +110,7 @@
removeEventListener("keydown", trackCtrl);
removeEventListener("keyup", trackCtrl);
removeEventListener("mousemove", trackCtrl);
clearTimeout(repeatTimeout);
});
// ===============================
@@ -20,8 +20,6 @@
function handleEntryClick(radioEntryData: RadioEntryData) {
const index = entries.indexOf(radioEntryData);
dispatch("selectedIndex", index);
radioEntryData.action?.();
}
</script>
@@ -203,6 +203,7 @@
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("mousedown", onMouseDown);
window.removeEventListener("keydown", onKeyDown);
clearTimeout(repeatTimeout);
}
</script>
@@ -231,7 +231,7 @@
.input-mouse {
.bright {
fill: var(--color-e-nearwhite);
fill: var(--color-b-lightgray);
}
.dim {
@@ -2,12 +2,11 @@
import { getContext, onMount } from "svelte";
import type { Editor } from "@graphite/editor";
import { type KeyRaw, type LayoutKeysGroup, type MenuBarEntry, type MenuListEntry, UpdateMenuBarLayout } from "@graphite/messages";
import { defaultWidgetLayout, patchWidgetLayout, UpdateMenuBarLayout } from "@graphite/messages";
import type { AppWindowState } from "@graphite/state-providers/app-window";
import { operatingSystem } from "@graphite/utility-functions/platform";
import LayoutRow from "@graphite/components/layout/LayoutRow.svelte";
import TextButton from "@graphite/components/widgets/buttons/TextButton.svelte";
import WidgetLayout from "@graphite/components/widgets/WidgetLayout.svelte";
import WindowButtonsLinux from "@graphite/components/window/title-bar/WindowButtonsLinux.svelte";
import WindowButtonsWeb from "@graphite/components/window/title-bar/WindowButtonsWeb.svelte";
import WindowButtonsWindows from "@graphite/components/window/title-bar/WindowButtonsWindows.svelte";
@@ -15,44 +14,12 @@
const appWindow = getContext<AppWindowState>("appWindow");
const editor = getContext<Editor>("editor");
// TODO: Apparently, Safari does not support the Keyboard.lock() API but does relax its authority over certain keyboard shortcuts in fullscreen mode, which we should take advantage of
const ACCEL_KEY = operatingSystem() === "Mac" ? "Command" : "Control";
const LOCK_REQUIRING_SHORTCUTS: KeyRaw[][] = [
[ACCEL_KEY, "KeyW"],
[ACCEL_KEY, "KeyN"],
[ACCEL_KEY, "Shift", "KeyN"],
[ACCEL_KEY, "KeyT"],
[ACCEL_KEY, "Shift", "KeyT"],
];
let entries: MenuListEntry[] = [];
let menuBarLayout = defaultWidgetLayout();
onMount(() => {
const arraysEqual = (a: KeyRaw[], b: KeyRaw[]): boolean => a.length === b.length && a.every((aValue, i) => aValue === b[i]);
const shortcutRequiresLock = (shortcut: LayoutKeysGroup): boolean => {
const shortcutKeys = shortcut.map((keyWithLabel) => keyWithLabel.key);
// If this shortcut matches any of the browser-reserved shortcuts
return LOCK_REQUIRING_SHORTCUTS.some((lockKeyCombo) => arraysEqual(shortcutKeys, lockKeyCombo));
};
editor.subscriptions.subscribeJsMessage(UpdateMenuBarLayout, (updateMenuBarLayout) => {
const menuBarEntryToMenuListEntry = (entry: MenuBarEntry): MenuListEntry => ({
// From `MenuEntryCommon`
...entry,
// Shared names with fields that need to be converted from the type used in `MenuBarEntry` to that of `MenuListEntry`
action: () => editor.handle.widgetValueCommitAndUpdate(updateMenuBarLayout.layoutTarget, entry.action.widgetId, undefined),
children: entry.children ? entry.children.map((entries) => entries.map((entry) => menuBarEntryToMenuListEntry(entry))) : undefined,
// New fields in `MenuListEntry`
shortcutRequiresLock: entry.shortcut ? shortcutRequiresLock(entry.shortcut.keys) : undefined,
value: "",
disabled: entry.disabled ?? undefined,
font: undefined,
});
entries = updateMenuBarLayout.layout.map(menuBarEntryToMenuListEntry);
patchWidgetLayout(menuBarLayout, updateMenuBarLayout);
menuBarLayout = menuBarLayout;
});
});
</script>
@@ -61,9 +28,7 @@
<!-- Menu bar -->
<LayoutRow>
{#if $appWindow.platform !== "Mac"}
{#each entries as entry}
<TextButton label={entry.label} icon={entry.icon} menuListChildren={entry.children} action={entry.action} flush={true} />
{/each}
<WidgetLayout layout={menuBarLayout} />
{/if}
</LayoutRow>
<!-- Spacer -->
@@ -88,6 +53,14 @@
> .layout-row {
flex: 0 0 auto;
> .widget-span {
--row-height: 28px;
> * {
--widget-height: 28px;
}
}
&.spacer {
flex: 1 1 100%;
}