mirror of
https://github.com/th-ch/youtube-music.git
synced 2026-01-14 03:41:46 +00:00
refactor(in-app-menu): refactor in-app-menu plugin (#1710)
Co-authored-by: JellyBrick <shlee1503@naver.com>
This commit is contained in:
342
src/plugins/in-app-menu/renderer/TitleBar.tsx
Normal file
342
src/plugins/in-app-menu/renderer/TitleBar.tsx
Normal file
@ -0,0 +1,342 @@
|
||||
import { Menu, MenuItem } from 'electron';
|
||||
import { createEffect, createResource, createSignal, Index, Match, onMount, Show, Switch } from 'solid-js';
|
||||
import { css } from 'solid-styled-components';
|
||||
import { TransitionGroup } from 'solid-transition-group';
|
||||
|
||||
import { MenuButton } from './MenuButton';
|
||||
import { Panel } from './Panel';
|
||||
import { PanelItem } from './PanelItem';
|
||||
import { IconButton } from './IconButton';
|
||||
import { WindowController } from './WindowController';
|
||||
|
||||
import type { RendererContext } from '@/types/contexts';
|
||||
import type { InAppMenuConfig } from '../constants';
|
||||
|
||||
const titleStyle = css`
|
||||
-webkit-app-region: drag;
|
||||
box-sizing: border-box;
|
||||
|
||||
position: fixed;
|
||||
top: 0;
|
||||
z-index: 10000000;
|
||||
|
||||
width: 100%;
|
||||
height: var(--menu-bar-height, 32px);
|
||||
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
color: #f1f1f1;
|
||||
font-size: 12px;
|
||||
padding: 4px 4px 4px var(--offset-left, 4px);
|
||||
background-color: var(--titlebar-background-color, #030303);
|
||||
user-select: none;
|
||||
|
||||
transition: opacity 200ms ease 0s,
|
||||
background-color 300ms cubic-bezier(0.2, 0, 0.6, 1) 0s;
|
||||
|
||||
&[data-macos="true"] {
|
||||
padding: 4px 4px 4px 74px;
|
||||
}
|
||||
`;
|
||||
|
||||
const separatorStyle = css`
|
||||
min-height: 1px;
|
||||
height: 1px;
|
||||
margin: 4px 0;
|
||||
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
`;
|
||||
|
||||
const animationStyle = {
|
||||
enter: css`
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) scale(0.8);
|
||||
`,
|
||||
enterActive: css`
|
||||
transition: opacity 0.1s cubic-bezier(0.33, 1, 0.68, 1), transform 0.1s cubic-bezier(0.33, 1, 0.68, 1);
|
||||
`,
|
||||
exitTo: css`
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) scale(0.8);
|
||||
`,
|
||||
exitActive: css`
|
||||
transition: opacity 0.1s cubic-bezier(0.32, 0, 0.67, 0), transform 0.1s cubic-bezier(0.32, 0, 0.67, 0);
|
||||
`,
|
||||
move: css`
|
||||
transition: all 0.1s cubic-bezier(0.65, 0, 0.35, 1);
|
||||
`,
|
||||
fakeTarget: css`
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
`,
|
||||
fake: css`
|
||||
transition: all 0.00000000001s;
|
||||
`,
|
||||
};
|
||||
|
||||
export type PanelRendererProps = {
|
||||
items: Electron.Menu['items'];
|
||||
level?: number[];
|
||||
onClick?: (commandId: number, radioGroup?: MenuItem[]) => void;
|
||||
}
|
||||
const PanelRenderer = (props: PanelRendererProps) => {
|
||||
const radioGroup = () => props.items.filter((it) => it.type === 'radio');
|
||||
|
||||
return (
|
||||
<Index each={props.items}>
|
||||
{(subItem) => (
|
||||
<Show when={subItem().visible}>
|
||||
<Switch>
|
||||
<Match when={subItem().type === 'normal'}>
|
||||
<PanelItem
|
||||
type={'normal'}
|
||||
name={subItem().label}
|
||||
chip={subItem().sublabel}
|
||||
toolTip={subItem().toolTip}
|
||||
commandId={subItem().commandId}
|
||||
onClick={() => props.onClick?.(subItem().commandId)}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={subItem().type === 'submenu'}>
|
||||
<PanelItem
|
||||
type={'submenu'}
|
||||
name={subItem().label}
|
||||
chip={subItem().sublabel}
|
||||
toolTip={subItem().toolTip}
|
||||
level={[...props.level ?? [], subItem().commandId]}
|
||||
commandId={subItem().commandId}
|
||||
>
|
||||
<PanelRenderer
|
||||
items={subItem().submenu?.items ?? []}
|
||||
level={[...props.level ?? [], subItem().commandId]}
|
||||
onClick={props.onClick}
|
||||
/>
|
||||
</PanelItem>
|
||||
</Match>
|
||||
<Match when={subItem().type === 'checkbox'}>
|
||||
<PanelItem
|
||||
type={'checkbox'}
|
||||
name={subItem().label}
|
||||
checked={subItem().checked}
|
||||
chip={subItem().sublabel}
|
||||
toolTip={subItem().toolTip}
|
||||
commandId={subItem().commandId}
|
||||
onChange={() => props.onClick?.(subItem().commandId)}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={subItem().type === 'radio'}>
|
||||
<PanelItem
|
||||
type={'radio'}
|
||||
name={subItem().label}
|
||||
checked={subItem().checked}
|
||||
chip={subItem().sublabel}
|
||||
toolTip={subItem().toolTip}
|
||||
commandId={subItem().commandId}
|
||||
onChange={() => props.onClick?.(subItem().commandId, radioGroup())}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={subItem().type === 'separator'}>
|
||||
<hr class={separatorStyle}/>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Show>
|
||||
)}
|
||||
</Index>
|
||||
);
|
||||
};
|
||||
|
||||
export type TitleBarProps = {
|
||||
ipc: RendererContext<InAppMenuConfig>['ipc'];
|
||||
isMacOS?: boolean;
|
||||
enableController?: boolean;
|
||||
initialCollapsed?: boolean;
|
||||
};
|
||||
export const TitleBar = (props: TitleBarProps) => {
|
||||
const [collapsed, setCollapsed] = createSignal(props.initialCollapsed);
|
||||
const [ignoreTransition, setIgnoreTransition] = createSignal(false);
|
||||
const [openTarget, setOpenTarget] = createSignal<HTMLElement | null>(null);
|
||||
const [menu, setMenu] = createSignal<Menu | null>(null);
|
||||
|
||||
const [data, { refetch }] = createResource(async () => await props.ipc.invoke('get-menu') as Promise<Menu | null>);
|
||||
const [isMaximized, { refetch: refetchMaximize }] = createResource(async () => await props.ipc.invoke('window-is-maximized') as Promise<boolean>);
|
||||
|
||||
const handleToggleMaximize = async () => {
|
||||
if (isMaximized()) {
|
||||
await props.ipc.invoke('window-unmaximize');
|
||||
} else {
|
||||
await props.ipc.invoke('window-maximize');
|
||||
}
|
||||
await refetchMaximize();
|
||||
};
|
||||
const handleMinimize = async () => {
|
||||
await props.ipc.invoke('window-minimize');
|
||||
};
|
||||
const handleClose = async () => {
|
||||
await props.ipc.invoke('window-close');
|
||||
};
|
||||
|
||||
const refreshMenuItem = async (originalMenu: Menu, commandId: number) => {
|
||||
const menuItem = (await window.ipcRenderer.invoke(
|
||||
'get-menu-by-id',
|
||||
commandId,
|
||||
)) as MenuItem | null;
|
||||
|
||||
const newMenu = structuredClone(originalMenu);
|
||||
const stack = [...newMenu?.items ?? []];
|
||||
let now: MenuItem | undefined = stack.pop();
|
||||
while (now) {
|
||||
const index = now?.submenu?.items?.findIndex((it) => it.commandId === commandId) ?? -1;
|
||||
|
||||
if (index >= 0) {
|
||||
if (menuItem) now?.submenu?.items?.splice(index, 1, menuItem);
|
||||
else now?.submenu?.items?.splice(index, 1);
|
||||
}
|
||||
if (now?.submenu) {
|
||||
stack.push(...now.submenu.items);
|
||||
}
|
||||
|
||||
now = stack.pop();
|
||||
}
|
||||
|
||||
return newMenu;
|
||||
};
|
||||
|
||||
const handleItemClick = async (commandId: number, radioGroup?: MenuItem[]) => {
|
||||
const menuData = menu();
|
||||
if (!menuData) return;
|
||||
|
||||
if (Array.isArray(radioGroup)) {
|
||||
let newMenu = menuData;
|
||||
for await (const item of radioGroup) {
|
||||
newMenu = await refreshMenuItem(newMenu, item.commandId);
|
||||
}
|
||||
|
||||
setMenu(newMenu);
|
||||
return;
|
||||
}
|
||||
|
||||
setMenu(await refreshMenuItem(menuData, commandId));
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
props.ipc.on('close-all-in-app-menu-panel', async () => {
|
||||
setIgnoreTransition(true);
|
||||
setMenu(null);
|
||||
await refetch();
|
||||
setMenu(data() ?? null);
|
||||
setIgnoreTransition(false);
|
||||
});
|
||||
props.ipc.on('refresh-in-app-menu', async () => {
|
||||
setIgnoreTransition(true);
|
||||
await refetch();
|
||||
setMenu(data() ?? null);
|
||||
setIgnoreTransition(false);
|
||||
});
|
||||
props.ipc.on('toggle-in-app-menu', () => {
|
||||
setCollapsed(!collapsed());
|
||||
});
|
||||
|
||||
props.ipc.on('window-maximize', refetchMaximize);
|
||||
props.ipc.on('window-unmaximize', refetchMaximize);
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (!menu() && data()) {
|
||||
setMenu(data() ?? null);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<nav class={titleStyle} data-macos={props.isMacOS}>
|
||||
<IconButton
|
||||
onClick={() => setCollapsed(!collapsed())}
|
||||
style={{
|
||||
'border-top-left-radius': '4px',
|
||||
}}
|
||||
>
|
||||
<svg width={16} height={16} viewBox={'0 0 24 24'}>
|
||||
<path
|
||||
d="M3 17h12a1 1 0 0 1 .117 1.993L15 19H3a1 1 0 0 1-.117-1.993L3 17h12H3Zm0-6h18a1 1 0 0 1 .117 1.993L21 13H3a1 1 0 0 1-.117-1.993L3 11h18H3Zm0-6h15a1 1 0 0 1 .117 1.993L18 7H3a1 1 0 0 1-.117-1.993L3 5h15H3Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</IconButton>
|
||||
<TransitionGroup
|
||||
enterClass={ignoreTransition() ? animationStyle.fakeTarget : animationStyle.enter}
|
||||
enterActiveClass={ignoreTransition() ? animationStyle.fake : animationStyle.enterActive}
|
||||
exitToClass={ignoreTransition() ? animationStyle.fakeTarget : animationStyle.exitTo}
|
||||
exitActiveClass={ignoreTransition() ? animationStyle.fake : animationStyle.exitActive}
|
||||
onBeforeEnter={(element) => {
|
||||
if (ignoreTransition()) return;
|
||||
const index = Number(element.getAttribute('data-index') ?? 0);
|
||||
|
||||
(element as HTMLElement).style.setProperty('transition-delay', `${(index * 0.025)}s`);
|
||||
}}
|
||||
onAfterEnter={(element) => {
|
||||
(element as HTMLElement).style.removeProperty('transition-delay');
|
||||
}}
|
||||
onBeforeExit={(element) => {
|
||||
if (ignoreTransition()) return;
|
||||
const index = Number(element.getAttribute('data-index') ?? 0);
|
||||
const length = Number(element.getAttribute('data-length') ?? 1);
|
||||
|
||||
(element as HTMLElement).style.setProperty('transition-delay', `${(length * 0.025) - (index * 0.025)}s`);
|
||||
}}
|
||||
>
|
||||
<Show when={!collapsed()}>
|
||||
<Index each={menu()?.items}>
|
||||
{(item, index) => {
|
||||
const [anchor, setAnchor] = createSignal<HTMLElement | null>(null);
|
||||
|
||||
const handleClick = () => {
|
||||
if (openTarget() === anchor()) {
|
||||
setOpenTarget(null);
|
||||
} else {
|
||||
setOpenTarget(anchor());
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuButton
|
||||
ref={setAnchor}
|
||||
text={item().label}
|
||||
onClick={handleClick}
|
||||
selected={openTarget() === anchor()}
|
||||
data-index={index}
|
||||
data-length={data()?.items.length}
|
||||
/>
|
||||
<Panel
|
||||
open={openTarget() === anchor()}
|
||||
anchor={anchor()}
|
||||
placement={'bottom-start'}
|
||||
offset={{ mainAxis: 8 }}
|
||||
>
|
||||
<PanelRenderer
|
||||
items={item().submenu?.items ?? []}
|
||||
onClick={handleItemClick}
|
||||
/>
|
||||
</Panel>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Index>
|
||||
</Show>
|
||||
</TransitionGroup>
|
||||
<Show when={props.enableController}>
|
||||
<div style={{ flex: 1 }}/>
|
||||
<WindowController
|
||||
isMaximize={isMaximized()}
|
||||
onToggleMaximize={handleToggleMaximize}
|
||||
onMinimize={handleMinimize}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
</Show>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user