diff --git a/icons/mgc/translate_2_cute_re.svg b/icons/mgc/translate_2_cute_re.svg new file mode 100644 index 000000000..364240100 --- /dev/null +++ b/icons/mgc/translate_2_cute_re.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/renderer/src/components/ui/scroll-area/ScrollArea.tsx b/src/renderer/src/components/ui/scroll-area/ScrollArea.tsx index b1320e42b..8e1c5f939 100644 --- a/src/renderer/src/components/ui/scroll-area/ScrollArea.tsx +++ b/src/renderer/src/components/ui/scroll-area/ScrollArea.tsx @@ -103,7 +103,7 @@ const Viewport = React.forwardRef< ref={ref} className={cn( "block size-full", - shouldAddMask && styles["scroller"], + shouldAddMask && styles["mask-scroller"], className, )} /> diff --git a/src/renderer/src/components/ui/scroll-area/index.module.css b/src/renderer/src/components/ui/scroll-area/index.module.css index 644e1f850..26a1e4df1 100644 --- a/src/renderer/src/components/ui/scroll-area/index.module.css +++ b/src/renderer/src/components/ui/scroll-area/index.module.css @@ -1,45 +1,30 @@ -.scroller { - animation: mask-up; - animation-timeline: scroll(self); - animation-range: 0 1rem; - mask-composite: exclude; -} -@​keyframes mask-up { - to { - mask-size: 100% 120px, 100% 100%; - } -} - -.scroller { - --mask-size: 48px; - padding: 0; - background: transparent; +.mask-scroller { mask: linear-gradient(white, transparent) 50% 0 / 100% 0 no-repeat, linear-gradient(white, white) 50% 50% / 100% 100% no-repeat, - linear-gradient(transparent, white) 50% 100% / 100% 100px no-repeat; + linear-gradient(transparent, white) 50% 100% / 100% 30px no-repeat; mask-composite: exclude; - mask-size: 100% calc((var(--scroll-progress-top) / 100) * 100px), 100% 100%, + mask-size: 100% calc((var(--scroll-progress-top) / 100) * 30px), 100% 100%, 100% calc((100 - (100 * (var(--scroll-progress-bottom) / 100))) * 1px); } @supports (animation-timeline: scroll()) { - .scroller { + .mask-scroller { mask: linear-gradient(white, transparent) 50% 0 / 100% 0 no-repeat, linear-gradient(white, white) 50% 50% / 100% 100% no-repeat, - linear-gradient(transparent, white) 50% 100% / 100% 100px no-repeat; + linear-gradient(transparent, white) 50% 100% / 100% 30px no-repeat; mask-composite: exclude; animation: mask-up both linear, mask-down both linear; animation-timeline: scroll(self); - animation-range: 0 2rem, calc(100% - 2rem) 100%; + animation-range: 0 50px, calc(100% - 50px) 100%; } } @keyframes mask-up { 100% { - mask-size: 100% 100px, 100% 100%, 100% 100px; + mask-size: 100% 30px, 100% 100%, 100% 30px; } } @keyframes mask-down { 100% { - mask-size: 100% 100px, 100% 100%, 100% 0; + mask-size: 100% 30px, 100% 100%, 100% 0; } } diff --git a/src/renderer/src/components/ui/tooltip.tsx b/src/renderer/src/components/ui/tooltip.tsx index 08e92de64..51356604f 100644 --- a/src/renderer/src/components/ui/tooltip.tsx +++ b/src/renderer/src/components/ui/tooltip.tsx @@ -13,6 +13,16 @@ const Tooltip: typeof TooltipProvider = ({ children, ...props }) => ( const TooltipTrigger = TooltipPrimitive.Trigger +export const tooltipStyle = { + content: [ + "relative z-[101] border border-accent/10 bg-white px-2 py-1 text-foreground dark:bg-neutral-950", + "data-[state=closed]:animate-out data-[state=closed]:fade-out-0", + "rounded-lg text-sm", + "max-w-[75ch] select-text", + "drop-shadow data-[side=top]:shadow-tooltip-bottom data-[side=bottom]:shadow-tooltip-top dark:border-border", + ], +} + const TooltipContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef @@ -21,17 +31,7 @@ const TooltipContent = React.forwardRef< ref={ref} asChild sideOffset={sideOffset} - className={cn( - "relative z-[101] border border-accent/10 bg-white px-2 py-1 text-foreground dark:bg-neutral-950", - // "animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2", - "data-[state=closed]:animate-out data-[state=closed]:fade-out-0", - "rounded-lg text-sm", - "max-w-[75ch] select-text", - - "drop-shadow data-[side=top]:shadow-tooltip-bottom data-[side=bottom]:shadow-tooltip-top dark:border-border", - - className, - )} + className={cn(tooltipStyle, className)} {...props} > void +declare class ResizeObserver { + constructor(callback: ResizeObserverCallback) + observe(target: Element, options?: any): void + unobserve(target: Element): void + disconnect(): void + static toString(): string +} + +export interface RectReadOnly { + readonly x: number + readonly y: number + readonly width: number + readonly height: number + readonly top: number + readonly right: number + readonly bottom: number + readonly left: number + [key: string]: number +} + +type HTMLOrSVGElement = HTMLElement | SVGElement + +type Result = [ + (element: HTMLOrSVGElement | null) => void, + RectReadOnly, + () => void, +] + +type State = { + element: HTMLOrSVGElement | null + scrollContainers: HTMLOrSVGElement[] | null + resizeObserver: ResizeObserver | null + lastBounds: RectReadOnly +} + +export type Options = { + debounce?: number | { scroll: number, resize: number } + scroll?: boolean + offsetSize?: boolean +} + +const defaultOptions: Options = { + debounce: 0, + scroll: false, + offsetSize: false, +} +export function useMeasure({ + debounce, + scroll, + offsetSize, +}: Options = defaultOptions): Result { + const [bounds, set] = useState({ + left: 0, + top: 0, + width: 0, + height: 0, + bottom: 0, + right: 0, + x: 0, + y: 0, + }) + + // keep all state in a ref + const state = useRef({ + element: null, + scrollContainers: null, + resizeObserver: null, + lastBounds: bounds, + }) + + // set actual debounce values early, so effects know if they should react accordingly + const scrollDebounce = debounce ? + typeof debounce === "number" ? + debounce : + debounce.scroll : + null + const resizeDebounce = debounce ? + typeof debounce === "number" ? + debounce : + debounce.resize : + null + + // make sure to update state only as long as the component is truly mounted + const mounted = useRef(false) + useEffect(() => { + mounted.current = true + return () => void (mounted.current = false) + }) + + // memoize handlers, so event-listeners know when they should update + const [forceRefresh, resizeChange, scrollChange] = useMemo(() => { + const callback = () => { + if (!state.current.element) return + const { left, top, width, height, bottom, right, x, y } = + state.current.element.getBoundingClientRect() as unknown as RectReadOnly + + const size = { + left, + top, + width, + height, + bottom, + right, + x, + y, + } + + if (state.current.element instanceof HTMLElement && offsetSize) { + size.height = state.current.element.offsetHeight + size.width = state.current.element.offsetWidth + } + + Object.freeze(size) + if (mounted.current && !areBoundsEqual(state.current.lastBounds, size)) { set((state.current.lastBounds = size)) } + } + return [ + callback, + resizeDebounce ? createDebounce(callback, resizeDebounce) : callback, + scrollDebounce ? createDebounce(callback, scrollDebounce) : callback, + ] + }, [set, offsetSize, scrollDebounce, resizeDebounce]) + + // cleanup current scroll-listeners / observers + function removeListeners() { + if (state.current.scrollContainers) { + state.current.scrollContainers.forEach((element) => + element.removeEventListener("scroll", scrollChange, true), + ) + state.current.scrollContainers = null + } + + if (state.current.resizeObserver) { + state.current.resizeObserver.disconnect() + state.current.resizeObserver = null + } + } + + // add scroll-listeners / observers + function addListeners() { + if (!state.current.element) return + state.current.resizeObserver = new ResizeObserver(scrollChange) + state.current.resizeObserver!.observe(state.current.element) + if (scroll && state.current.scrollContainers) { + state.current.scrollContainers.forEach((scrollContainer) => + scrollContainer.addEventListener("scroll", scrollChange, { + capture: true, + passive: true, + }), + ) + } + } + + // the ref we expose to the user + const ref = (node: HTMLOrSVGElement | null) => { + if (!node || node === state.current.element) return + removeListeners() + state.current.element = node + state.current.scrollContainers = findScrollContainers(node) + addListeners() + } + + // add general event listeners + useOnWindowScroll(scrollChange, Boolean(scroll)) + useOnWindowResize(resizeChange) + + // respond to changes that are relevant for the listeners + useEffect(() => { + removeListeners() + addListeners() + }, [scroll, scrollChange, resizeChange]) + + // remove all listeners when the components unmounts + useEffect(() => removeListeners, []) + return [ref, bounds, forceRefresh] +} + +// Adds native resize listener to window +function useOnWindowResize(onWindowResize: (event: Event) => void) { + useEffect(() => { + const cb = onWindowResize + window.addEventListener("resize", cb) + return () => void window.removeEventListener("resize", cb) + }, [onWindowResize]) +} +function useOnWindowScroll(onScroll: () => void, enabled: boolean) { + useEffect(() => { + if (enabled) { + const cb = onScroll + window.addEventListener("scroll", cb, { capture: true, passive: true }) + return () => void window.removeEventListener("scroll", cb, true) + } + }, [onScroll, enabled]) +} + +// Returns a list of scroll offsets +function findScrollContainers( + element: HTMLOrSVGElement | null, +): HTMLOrSVGElement[] { + const result: HTMLOrSVGElement[] = [] + if (!element || element === document.body) return result + const { overflow, overflowX, overflowY } = window.getComputedStyle(element) + if ( + [overflow, overflowX, overflowY].some( + (prop) => prop === "auto" || prop === "scroll", + ) + ) { result.push(element) } + return [...result, ...findScrollContainers(element.parentElement)] +} + +// Checks if element boundaries are equal +const keys: (keyof RectReadOnly)[] = [ + "x", + "y", + "top", + "bottom", + "left", + "right", + "width", + "height", +] +const areBoundsEqual = (a: RectReadOnly, b: RectReadOnly): boolean => + keys.every((key) => a[key] === b[key]) diff --git a/src/renderer/src/modules/entry-column/templates/list-item-template.tsx b/src/renderer/src/modules/entry-column/templates/list-item-template.tsx index 6c5bc56e1..d8e732ff4 100644 --- a/src/renderer/src/modules/entry-column/templates/list-item-template.tsx +++ b/src/renderer/src/modules/entry-column/templates/list-item-template.tsx @@ -99,12 +99,16 @@ export function ListItem({ > {entry.entries.title ? ( ) : ( @@ -121,6 +125,8 @@ export function ListItem({ )} > = ({ source, target, className }) => { + + side?: "top" | "bottom" + + useOverlay?: boolean +}> = ({ source, target, className, side, useOverlay }) => { let nextTarget = target if (source === target) { nextTarget = undefined } - const ref = useRef(null) - const [hovered, setHovered] = useState(false) - useHover( - (event) => { - setHovered(event.active) - }, - { - target: ref, - }, - ) + const [ref, bounds] = useMeasure({ debounce: 60 }) + if (!nextTarget && source) { + return
{source}
+ } return ( -
- {nextTarget && !hovered ? ( - <> - - {nextTarget} - - ) : ( - {source} - )} -
+ + + + {nextTarget} + + + + {useOverlay ? ( +
+ + {source} + +
+ ) : ( + {source} + )} +
+
+
) } diff --git a/src/renderer/src/modules/profile/user-profile-modal.tsx b/src/renderer/src/modules/profile/user-profile-modal.tsx index f88985f07..0bd7b1c8b 100644 --- a/src/renderer/src/modules/profile/user-profile-modal.tsx +++ b/src/renderer/src/modules/profile/user-profile-modal.tsx @@ -35,7 +35,7 @@ import { FeedForm } from "../discover/feed-form" const itemVariantAtom = atomWithStorage( getStorageNS("item-variant"), - "loose", + "loose" as ItemVariant, undefined, { getOnInit: true,