feat: hoverable translation design, fixes #268
Signed-off-by: Innei <i@innei.in>
This commit is contained in:
parent
64f40ee614
commit
3ff11dce35
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path fill="#fff" fill-opacity=".01" d="M24 0v24H0V0z"/><path stroke="#10161F" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 20c.9-3.068 2.116-6.272 3.689-9.118a.92.92 0 0 1 1.622 0C17.884 13.728 19.1 16.932 20 20m-7.65-3h6.3M4 6h5m0 0h3M9 6V4m3 2h2m-2 0c0 2.39-1.071 4.78-3 6.744m0 0C7.7 14.066 6.012 15.195 4 16m5-3.256C7.887 11.61 7.06 10.335 6.559 9M9 12.744c1 1.018 2.23 1.92 3.662 2.65"/></svg>
|
||||
|
After Width: | Height: | Size: 500 B |
|
|
@ -103,7 +103,7 @@ const Viewport = React.forwardRef<
|
|||
ref={ref}
|
||||
className={cn(
|
||||
"block size-full",
|
||||
shouldAddMask && styles["scroller"],
|
||||
shouldAddMask && styles["mask-scroller"],
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
|
|
@ -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}
|
||||
>
|
||||
<m.div
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ export * from "./useBizQuery"
|
|||
export * from "./useDark"
|
||||
export * from "./useInputComposition"
|
||||
export * from "./useIsOnline"
|
||||
export * from "./useMeasure"
|
||||
export * from "./usePageVisibility"
|
||||
export * from "./usePrevious"
|
||||
export * from "./useRefValue"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,232 @@
|
|||
// @copy https://github.com/pmndrs/react-use-measure/blob/master/src/web/index.ts
|
||||
|
||||
import { debounce } from "lodash-es"
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
|
||||
const createDebounce = debounce
|
||||
declare type ResizeObserverCallback = (
|
||||
entries: any[],
|
||||
observer: ResizeObserver
|
||||
) => 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<RectReadOnly>({
|
||||
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<State>({
|
||||
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])
|
||||
|
|
@ -99,12 +99,16 @@ export function ListItem({
|
|||
>
|
||||
{entry.entries.title ? (
|
||||
<EntryTranslation
|
||||
useOverlay
|
||||
side="top"
|
||||
className={envIsSafari ? "line-clamp-2 break-all" : undefined}
|
||||
source={entry.entries.title}
|
||||
target={translation?.title}
|
||||
/>
|
||||
) : (
|
||||
<EntryTranslation
|
||||
useOverlay
|
||||
side="top"
|
||||
source={entry.entries.description}
|
||||
target={translation?.description}
|
||||
/>
|
||||
|
|
@ -121,6 +125,8 @@ export function ListItem({
|
|||
)}
|
||||
>
|
||||
<EntryTranslation
|
||||
useOverlay
|
||||
side="top"
|
||||
className={envIsSafari ? "line-clamp-2 break-all" : undefined}
|
||||
source={entry.entries.description}
|
||||
target={translation?.description}
|
||||
|
|
|
|||
|
|
@ -1,36 +1,62 @@
|
|||
import { useHover } from "@use-gesture/react"
|
||||
import { useRef, useState } from "react"
|
||||
import * as HoverCard from "@radix-ui/react-hover-card"
|
||||
import { ScrollArea } from "@renderer/components/ui/scroll-area"
|
||||
import { tooltipStyle } from "@renderer/components/ui/tooltip"
|
||||
import { useMeasure } from "@renderer/hooks/common"
|
||||
import { cn } from "@renderer/lib/utils"
|
||||
|
||||
export const EntryTranslation: Component<{
|
||||
source?: string | null
|
||||
target?: string
|
||||
}> = ({ source, target, className }) => {
|
||||
|
||||
side?: "top" | "bottom"
|
||||
|
||||
useOverlay?: boolean
|
||||
}> = ({ source, target, className, side, useOverlay }) => {
|
||||
let nextTarget = target
|
||||
if (source === target) {
|
||||
nextTarget = undefined
|
||||
}
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [hovered, setHovered] = useState(false)
|
||||
useHover(
|
||||
(event) => {
|
||||
setHovered(event.active)
|
||||
},
|
||||
{
|
||||
target: ref,
|
||||
},
|
||||
)
|
||||
const [ref, bounds] = useMeasure({ debounce: 60 })
|
||||
|
||||
if (!nextTarget && source) {
|
||||
return <div className={className}>{source}</div>
|
||||
}
|
||||
return (
|
||||
<div ref={ref} className={className}>
|
||||
{nextTarget && !hovered ? (
|
||||
<>
|
||||
<i className="i-mgc-magic-2-cute-re mr-1 align-middle" />
|
||||
<span className="align-middle">{nextTarget}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="align-middle">{source}</span>
|
||||
)}
|
||||
</div>
|
||||
<HoverCard.Root>
|
||||
<HoverCard.Trigger className={className} ref={ref}>
|
||||
<i className="i-mgc-translate-2-cute-re mr-1 align-middle" />
|
||||
<span className="align-middle">{nextTarget}</span>
|
||||
</HoverCard.Trigger>
|
||||
<HoverCard.Portal>
|
||||
<HoverCard.Content
|
||||
className={cn(
|
||||
"group relative text-sm",
|
||||
"animate-in fade-in-0 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
)}
|
||||
style={useOverlay ? { width: bounds.width } : undefined}
|
||||
side={side}
|
||||
sideOffset={useOverlay ? 0 : 12}
|
||||
>
|
||||
{useOverlay ? (
|
||||
<div className="absolute inset-x-0">
|
||||
<ScrollArea.ScrollArea
|
||||
mask
|
||||
rootClassName={cn(
|
||||
"shadow-modal rounded-xl border bg-background p-2",
|
||||
"group-data-[side=top]:top-0",
|
||||
"group-data-[side=bottom]:top-full",
|
||||
)}
|
||||
viewportClassName="max-h-[12ch]"
|
||||
>
|
||||
<span className="align-middle">{source}</span>
|
||||
</ScrollArea.ScrollArea>
|
||||
</div>
|
||||
) : (
|
||||
<span className={cn(tooltipStyle.content)}>{source}</span>
|
||||
)}
|
||||
</HoverCard.Content>
|
||||
</HoverCard.Portal>
|
||||
</HoverCard.Root>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ import { FeedForm } from "../discover/feed-form"
|
|||
|
||||
const itemVariantAtom = atomWithStorage(
|
||||
getStorageNS("item-variant"),
|
||||
"loose",
|
||||
"loose" as ItemVariant,
|
||||
undefined,
|
||||
{
|
||||
getOnInit: true,
|
||||
|
|
|
|||
Loading…
Reference in New Issue