Skip to content

Commit

Permalink
feat: hoverable translation design, fixes #268
Browse files Browse the repository at this point in the history
Signed-off-by: Innei <i@innei.in>
  • Loading branch information
Innei committed Sep 4, 2024
1 parent 64f40ee commit 3ff11dc
Show file tree
Hide file tree
Showing 9 changed files with 310 additions and 59 deletions.
1 change: 1 addition & 0 deletions icons/mgc/translate_2_cute_re.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion src/renderer/src/components/ui/scroll-area/ScrollArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ const Viewport = React.forwardRef<
ref={ref}
className={cn(
"block size-full",
shouldAddMask && styles["scroller"],
shouldAddMask && styles["mask-scroller"],
className,
)}
/>
Expand Down
31 changes: 8 additions & 23 deletions src/renderer/src/components/ui/scroll-area/index.module.css
Original file line number Diff line number Diff line change
@@ -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;
}
}
22 changes: 11 additions & 11 deletions src/renderer/src/components/ui/tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/renderer/src/hooks/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
232 changes: 232 additions & 0 deletions src/renderer/src/hooks/common/useMeasure.ts
Original file line number Diff line number Diff line change
@@ -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(() => {

Check failure on line 196 in src/renderer/src/hooks/common/useMeasure.ts

View workflow job for this annotation

GitHub Actions / release (macos-latest)

Not all code paths return a value.

Check failure on line 196 in src/renderer/src/hooks/common/useMeasure.ts

View workflow job for this annotation

GitHub Actions / release (ubuntu-latest)

Not all code paths return a value.

Check failure on line 196 in src/renderer/src/hooks/common/useMeasure.ts

View workflow job for this annotation

GitHub Actions / release (windows-latest)

Not all code paths return a value.
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])
Original file line number Diff line number Diff line change
Expand Up @@ -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}
/>
Expand All @@ -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}
Expand Down
Loading

0 comments on commit 3ff11dc

Please sign in to comment.