feat: hoverable translation design, fixes #268

Signed-off-by: Innei <i@innei.in>
This commit is contained in:
Innei 2024-09-04 17:39:16 +08:00
parent 64f40ee614
commit 3ff11dce35
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
9 changed files with 310 additions and 59 deletions

View File

@ -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

View File

@ -103,7 +103,7 @@ const Viewport = React.forwardRef<
ref={ref}
className={cn(
"block size-full",
shouldAddMask && styles["scroller"],
shouldAddMask && styles["mask-scroller"],
className,
)}
/>

View File

@ -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;
}
}

View File

@ -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

View File

@ -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"

View File

@ -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])

View File

@ -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}

View File

@ -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>
)
}

View File

@ -35,7 +35,7 @@ import { FeedForm } from "../discover/feed-form"
const itemVariantAtom = atomWithStorage(
getStorageNS("item-variant"),
"loose",
"loose" as ItemVariant,
undefined,
{
getOnInit: true,