mirror of
https://github.com/th-ch/youtube-music.git
synced 2026-01-11 10:31:47 +00:00
336 lines
9.8 KiB
TypeScript
336 lines
9.8 KiB
TypeScript
import { createSignal, Match, Show, Switch } from 'solid-js';
|
|
import { JSX } from 'solid-js/jsx-runtime';
|
|
import { css } from 'solid-styled-components';
|
|
import { Portal } from 'solid-js/web';
|
|
|
|
import { Transition } from 'solid-transition-group';
|
|
import { useFloating } from 'solid-floating-ui';
|
|
import { autoUpdate, offset, size } from '@floating-ui/dom';
|
|
|
|
import { Panel } from './Panel';
|
|
import { cache } from '@/providers/decorators';
|
|
|
|
const itemStyle = cache(() => css`
|
|
position: relative;
|
|
|
|
-webkit-app-region: none;
|
|
min-height: 32px;
|
|
height: 32px;
|
|
|
|
display: grid;
|
|
grid-template-columns: 32px 1fr auto minmax(32px, auto);
|
|
justify-content: flex-start;
|
|
align-items: center;
|
|
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
box-sizing: border-box;
|
|
user-select: none;
|
|
-webkit-user-drag: none;
|
|
|
|
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
|
|
|
&:hover {
|
|
background-color: rgba(255, 255, 255, 0.1);
|
|
}
|
|
|
|
&:active {
|
|
background-color: rgba(255, 255, 255, 0.2);
|
|
}
|
|
|
|
&[data-selected="true"] {
|
|
background-color: rgba(255, 255, 255, 0.2);
|
|
}
|
|
|
|
& * {
|
|
box-sizing: border-box;
|
|
}
|
|
`);
|
|
|
|
const itemIconStyle = cache(() => css`
|
|
height: 32px;
|
|
padding: 4px;
|
|
color: white;
|
|
`);
|
|
|
|
const itemLabelStyle = cache(() => css`
|
|
font-size: 12px;
|
|
color: white;
|
|
`);
|
|
|
|
const itemChipStyle = cache(() => css`
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
|
|
min-width: 16px;
|
|
height: 16px;
|
|
padding: 0 4px;
|
|
margin-left: 8px;
|
|
|
|
border-radius: 4px;
|
|
background-color: rgba(255, 255, 255, 0.2);
|
|
color: #f1f1f1;
|
|
font-size: 10px;
|
|
font-weight: 500;
|
|
line-height: 1;
|
|
`);
|
|
|
|
const toolTipStyle = cache(() => css`
|
|
min-width: 32px;
|
|
width: 100%;
|
|
height: 100%;
|
|
|
|
padding: 4px;
|
|
|
|
max-width: calc(var(--max-width, 100%) - 8px);
|
|
max-height: calc(var(--max-height, 100%) - 8px);
|
|
|
|
border-radius: 4px;
|
|
background-color: rgba(25, 25, 25, 0.8);
|
|
color: #f1f1f1;
|
|
font-size: 10px;
|
|
`);
|
|
|
|
const popupStyle = cache(() => css`
|
|
position: fixed;
|
|
top: var(--offset-y, 0);
|
|
left: var(--offset-x, 0);
|
|
|
|
max-width: var(--max-width, 100%);
|
|
max-height: var(--max-height, 100%);
|
|
|
|
z-index: 100000000;
|
|
pointer-events: none;
|
|
|
|
`);
|
|
|
|
const animationStyle = cache(() => ({
|
|
enter: css`
|
|
opacity: 0;
|
|
transform: scale(0.9);
|
|
`,
|
|
enterActive: css`
|
|
transition: opacity 0.225s cubic-bezier(0.33, 1, 0.68, 1), transform 0.225s cubic-bezier(0.33, 1, 0.68, 1);
|
|
`,
|
|
exitTo: css`
|
|
opacity: 0;
|
|
transform: scale(0.9);
|
|
`,
|
|
exitActive: css`
|
|
transition: opacity 0.225s cubic-bezier(0.32, 0, 0.67, 0), transform 0.225s cubic-bezier(0.32, 0, 0.67, 0);
|
|
`,
|
|
}));
|
|
|
|
const getParents = (element: Element | null): (HTMLElement | null)[] => {
|
|
const parents: (HTMLElement | null)[] = [];
|
|
let now = element;
|
|
|
|
while (now) {
|
|
parents.push(now as HTMLElement | null);
|
|
now = now.parentElement;
|
|
}
|
|
|
|
return parents;
|
|
};
|
|
|
|
type BasePanelItemProps = {
|
|
name: string;
|
|
label?: string;
|
|
chip?: string;
|
|
toolTip?: string;
|
|
commandId?: number;
|
|
};
|
|
type NormalPanelItemProps = BasePanelItemProps & {
|
|
type: 'normal';
|
|
onClick?: () => void;
|
|
};
|
|
type SubmenuItemProps = BasePanelItemProps & {
|
|
type: 'submenu';
|
|
level: number[];
|
|
children: JSX.Element;
|
|
};
|
|
type RadioPanelItemProps = BasePanelItemProps & {
|
|
type: 'radio';
|
|
checked: boolean;
|
|
onChange?: (checked: boolean) => void;
|
|
};
|
|
type CheckboxPanelItemProps = BasePanelItemProps & {
|
|
type: 'checkbox';
|
|
checked: boolean;
|
|
onChange?: (checked: boolean) => void;
|
|
};
|
|
export type PanelItemProps = NormalPanelItemProps | SubmenuItemProps | RadioPanelItemProps | CheckboxPanelItemProps;
|
|
export const PanelItem = (props: PanelItemProps) => {
|
|
const [open, setOpen] = createSignal(false);
|
|
const [toolTipOpen, setToolTipOpen] = createSignal(false);
|
|
const [toolTip, setToolTip] = createSignal<HTMLElement | null>(null);
|
|
const [anchor, setAnchor] = createSignal<HTMLElement | null>(null);
|
|
const [child, setChild] = createSignal<HTMLElement | null>(null);
|
|
|
|
const position = useFloating(anchor, toolTip, {
|
|
whileElementsMounted: autoUpdate,
|
|
placement: 'bottom-start',
|
|
strategy: 'fixed',
|
|
middleware: [
|
|
offset({ mainAxis: 8 }),
|
|
size({
|
|
apply({ rects, elements }) {
|
|
elements.floating.style.setProperty('--max-width', `${rects.reference.width}px`);
|
|
}
|
|
}),
|
|
],
|
|
});
|
|
|
|
const handleHover = (event: MouseEvent) => {
|
|
setToolTipOpen(true);
|
|
event.target?.addEventListener('mouseleave', () => {
|
|
setToolTipOpen(false);
|
|
}, { once: true });
|
|
|
|
if (props.type === 'submenu') {
|
|
const timer = setTimeout(() => {
|
|
setOpen(true);
|
|
|
|
let mouseX = event.clientX;
|
|
let mouseY = event.clientY;
|
|
const onMouseMove = (event: MouseEvent) => {
|
|
mouseX = event.clientX;
|
|
mouseY = event.clientY;
|
|
};
|
|
document.addEventListener('mousemove', onMouseMove);
|
|
|
|
event.target?.addEventListener('mouseleave', () => {
|
|
setTimeout(() => {
|
|
document.removeEventListener('mousemove', onMouseMove);
|
|
const parents = getParents(document.elementFromPoint(mouseX, mouseY));
|
|
|
|
if (!parents.includes(child())) {
|
|
setOpen(false);
|
|
} else {
|
|
const onOtherHover = (event: MouseEvent) => {
|
|
const parents = getParents(event.target as HTMLElement);
|
|
const closestLevel = parents.find((it) => it?.dataset?.level)?.dataset.level ?? '';
|
|
const path = event.composedPath();
|
|
|
|
const isOtherItem = path.some((it) => it instanceof HTMLElement && it.classList.contains(itemStyle()));
|
|
const isChild = closestLevel.startsWith(props.level.join('/'));
|
|
|
|
if (isOtherItem && !isChild) {
|
|
setOpen(false);
|
|
document.removeEventListener('mousemove', onOtherHover);
|
|
}
|
|
};
|
|
document.addEventListener('mousemove', onOtherHover);
|
|
}
|
|
}, 225);
|
|
}, { once: true });
|
|
}, 225);
|
|
|
|
event.target?.addEventListener('mouseleave', () => {
|
|
clearTimeout(timer);
|
|
}, { once: true });
|
|
}
|
|
};
|
|
|
|
const handleClick = async () => {
|
|
await window.ipcRenderer.invoke('ytmd:menu-event', props.commandId);
|
|
if (props.type === 'radio') {
|
|
props.onChange?.(!props.checked);
|
|
} else if (props.type === 'checkbox') {
|
|
props.onChange?.(!props.checked);
|
|
} else if (props.type === 'normal') {
|
|
props.onClick?.();
|
|
}
|
|
};
|
|
|
|
|
|
return (
|
|
<li
|
|
ref={setAnchor}
|
|
class={itemStyle()}
|
|
onMouseEnter={handleHover}
|
|
onClick={handleClick}
|
|
data-selected={open()}
|
|
>
|
|
<Switch fallback={<div class={itemIconStyle()}/>}>
|
|
<Match when={props.type === 'checkbox' && props.checked}>
|
|
<svg class={itemIconStyle()} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5"
|
|
stroke="currentColor" fill="none"
|
|
stroke-linecap="round" stroke-linejoin="round">
|
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
|
<path d="M5 12l5 5l10 -10"/>
|
|
</svg>
|
|
</Match>
|
|
<Match when={props.type === 'radio' && props.checked}>
|
|
<svg class={itemIconStyle()} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"
|
|
style={{ padding: '6px' }}>
|
|
<path fill="currentColor"
|
|
d="M10,5 C7.2,5 5,7.2 5,10 C5,12.8 7.2,15 10,15 C12.8,15 15,12.8 15,10 C15,7.2 12.8,5 10,5 L10,5 Z M10,0 C4.5,0 0,4.5 0,10 C0,15.5 4.5,20 10,20 C15.5,20 20,15.5 20,10 C20,4.5 15.5,0 10,0 L10,0 Z M10,18 C5.6,18 2,14.4 2,10 C2,5.6 5.6,2 10,2 C14.4,2 18,5.6 18,10 C18,14.4 14.4,18 10,18 L10,18 Z"/>
|
|
</svg>
|
|
</Match>
|
|
<Match when={props.type === 'radio' && !props.checked}>
|
|
<svg class={itemIconStyle()} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"
|
|
style={{ padding: '6px' }}>
|
|
<path fill="currentColor"
|
|
d="M10,0 C4.5,0 0,4.5 0,10 C0,15.5 4.5,20 10,20 C15.5,20 20,15.5 20,10 C20,4.5 15.5,0 10,0 L10,0 Z M10,18 C5.6,18 2,14.4 2,10 C2,5.6 5.6,2 10,2 C14.4,2 18,5.6 18,10 C18,14.4 14.4,18 10,18 L10,18 Z"/>
|
|
</svg>
|
|
</Match>
|
|
</Switch>
|
|
<span class={itemLabelStyle()}>
|
|
{props.name}
|
|
</span>
|
|
<Show when={props.chip} fallback={<div/>}>
|
|
<span class={itemChipStyle()}>
|
|
{props.chip}
|
|
</span>
|
|
</Show>
|
|
<Show when={props.type === 'submenu'}>
|
|
<svg class={itemIconStyle()} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5"
|
|
stroke="currentColor"
|
|
fill="none"
|
|
stroke-linecap="round" stroke-linejoin="round">
|
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
|
<polyline points="9 6 15 12 9 18"/>
|
|
</svg>
|
|
<Panel
|
|
ref={setChild}
|
|
open={open()}
|
|
anchor={anchor()}
|
|
placement={'right-start'}
|
|
data-level={props.type === 'submenu' && props.level.join('/')}
|
|
offset={{ mainAxis: 8 }}
|
|
>
|
|
{props.type === 'submenu' && props.children}
|
|
</Panel>
|
|
</Show>
|
|
<Show when={props.toolTip}>
|
|
<Portal>
|
|
<div
|
|
ref={setToolTip}
|
|
class={popupStyle()}
|
|
style={{
|
|
'--offset-x': `${position.x}px`,
|
|
'--offset-y': `${position.y}px`,
|
|
}}
|
|
>
|
|
<Transition
|
|
appear
|
|
enterClass={animationStyle().enter}
|
|
enterActiveClass={animationStyle().enterActive}
|
|
exitToClass={animationStyle().exitTo}
|
|
exitActiveClass={animationStyle().exitActive}
|
|
>
|
|
<Show when={toolTipOpen()}>
|
|
<div class={toolTipStyle()}>
|
|
{props.toolTip}
|
|
</div>
|
|
</Show>
|
|
</Transition>
|
|
</div>
|
|
</Portal>
|
|
</Show>
|
|
</li>
|
|
);
|
|
};
|