Skip to content

Commit 3ff11dc

Browse files
committed
feat: hoverable translation design, fixes #268
Signed-off-by: Innei <i@innei.in>
1 parent 64f40ee commit 3ff11dc

File tree

9 files changed

+310
-59
lines changed

9 files changed

+310
-59
lines changed

icons/mgc/translate_2_cute_re.svg

Lines changed: 1 addition & 0 deletions
Loading

src/renderer/src/components/ui/scroll-area/ScrollArea.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ const Viewport = React.forwardRef<
103103
ref={ref}
104104
className={cn(
105105
"block size-full",
106-
shouldAddMask && styles["scroller"],
106+
shouldAddMask && styles["mask-scroller"],
107107
className,
108108
)}
109109
/>
Lines changed: 8 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,30 @@
1-
.scroller {
2-
animation: mask-up;
3-
animation-timeline: scroll(self);
4-
animation-range: 0 1rem;
5-
mask-composite: exclude;
6-
}
7-
@​keyframes mask-up {
8-
to {
9-
mask-size: 100% 120px, 100% 100%;
10-
}
11-
}
12-
13-
.scroller {
14-
--mask-size: 48px;
15-
padding: 0;
16-
background: transparent;
1+
.mask-scroller {
172
mask: linear-gradient(white, transparent) 50% 0 / 100% 0 no-repeat,
183
linear-gradient(white, white) 50% 50% / 100% 100% no-repeat,
19-
linear-gradient(transparent, white) 50% 100% / 100% 100px no-repeat;
4+
linear-gradient(transparent, white) 50% 100% / 100% 30px no-repeat;
205
mask-composite: exclude;
21-
mask-size: 100% calc((var(--scroll-progress-top) / 100) * 100px), 100% 100%,
6+
mask-size: 100% calc((var(--scroll-progress-top) / 100) * 30px), 100% 100%,
227
100% calc((100 - (100 * (var(--scroll-progress-bottom) / 100))) * 1px);
238
}
249

2510
@supports (animation-timeline: scroll()) {
26-
.scroller {
11+
.mask-scroller {
2712
mask: linear-gradient(white, transparent) 50% 0 / 100% 0 no-repeat,
2813
linear-gradient(white, white) 50% 50% / 100% 100% no-repeat,
29-
linear-gradient(transparent, white) 50% 100% / 100% 100px no-repeat;
14+
linear-gradient(transparent, white) 50% 100% / 100% 30px no-repeat;
3015
mask-composite: exclude;
3116
animation: mask-up both linear, mask-down both linear;
3217
animation-timeline: scroll(self);
33-
animation-range: 0 2rem, calc(100% - 2rem) 100%;
18+
animation-range: 0 50px, calc(100% - 50px) 100%;
3419
}
3520
}
3621
@keyframes mask-up {
3722
100% {
38-
mask-size: 100% 100px, 100% 100%, 100% 100px;
23+
mask-size: 100% 30px, 100% 100%, 100% 30px;
3924
}
4025
}
4126
@keyframes mask-down {
4227
100% {
43-
mask-size: 100% 100px, 100% 100%, 100% 0;
28+
mask-size: 100% 30px, 100% 100%, 100% 0;
4429
}
4530
}

src/renderer/src/components/ui/tooltip.tsx

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,16 @@ const Tooltip: typeof TooltipProvider = ({ children, ...props }) => (
1313

1414
const TooltipTrigger = TooltipPrimitive.Trigger
1515

16+
export const tooltipStyle = {
17+
content: [
18+
"relative z-[101] border border-accent/10 bg-white px-2 py-1 text-foreground dark:bg-neutral-950",
19+
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
20+
"rounded-lg text-sm",
21+
"max-w-[75ch] select-text",
22+
"drop-shadow data-[side=top]:shadow-tooltip-bottom data-[side=bottom]:shadow-tooltip-top dark:border-border",
23+
],
24+
}
25+
1626
const TooltipContent = React.forwardRef<
1727
React.ElementRef<typeof TooltipPrimitive.Content>,
1828
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
@@ -21,17 +31,7 @@ const TooltipContent = React.forwardRef<
2131
ref={ref}
2232
asChild
2333
sideOffset={sideOffset}
24-
className={cn(
25-
"relative z-[101] border border-accent/10 bg-white px-2 py-1 text-foreground dark:bg-neutral-950",
26-
// "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",
27-
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
28-
"rounded-lg text-sm",
29-
"max-w-[75ch] select-text",
30-
31-
"drop-shadow data-[side=top]:shadow-tooltip-bottom data-[side=bottom]:shadow-tooltip-top dark:border-border",
32-
33-
className,
34-
)}
34+
className={cn(tooltipStyle, className)}
3535
{...props}
3636
>
3737
<m.div

src/renderer/src/hooks/common/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export * from "./useBizQuery"
22
export * from "./useDark"
33
export * from "./useInputComposition"
44
export * from "./useIsOnline"
5+
export * from "./useMeasure"
56
export * from "./usePageVisibility"
67
export * from "./usePrevious"
78
export * from "./useRefValue"
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
// @copy https://github.com/pmndrs/react-use-measure/blob/master/src/web/index.ts
2+
3+
import { debounce } from "lodash-es"
4+
import { useEffect, useMemo, useRef, useState } from "react"
5+
6+
const createDebounce = debounce
7+
declare type ResizeObserverCallback = (
8+
entries: any[],
9+
observer: ResizeObserver
10+
) => void
11+
declare class ResizeObserver {
12+
constructor(callback: ResizeObserverCallback)
13+
observe(target: Element, options?: any): void
14+
unobserve(target: Element): void
15+
disconnect(): void
16+
static toString(): string
17+
}
18+
19+
export interface RectReadOnly {
20+
readonly x: number
21+
readonly y: number
22+
readonly width: number
23+
readonly height: number
24+
readonly top: number
25+
readonly right: number
26+
readonly bottom: number
27+
readonly left: number
28+
[key: string]: number
29+
}
30+
31+
type HTMLOrSVGElement = HTMLElement | SVGElement
32+
33+
type Result = [
34+
(element: HTMLOrSVGElement | null) => void,
35+
RectReadOnly,
36+
() => void,
37+
]
38+
39+
type State = {
40+
element: HTMLOrSVGElement | null
41+
scrollContainers: HTMLOrSVGElement[] | null
42+
resizeObserver: ResizeObserver | null
43+
lastBounds: RectReadOnly
44+
}
45+
46+
export type Options = {
47+
debounce?: number | { scroll: number, resize: number }
48+
scroll?: boolean
49+
offsetSize?: boolean
50+
}
51+
52+
const defaultOptions: Options = {
53+
debounce: 0,
54+
scroll: false,
55+
offsetSize: false,
56+
}
57+
export function useMeasure({
58+
debounce,
59+
scroll,
60+
offsetSize,
61+
}: Options = defaultOptions): Result {
62+
const [bounds, set] = useState<RectReadOnly>({
63+
left: 0,
64+
top: 0,
65+
width: 0,
66+
height: 0,
67+
bottom: 0,
68+
right: 0,
69+
x: 0,
70+
y: 0,
71+
})
72+
73+
// keep all state in a ref
74+
const state = useRef<State>({
75+
element: null,
76+
scrollContainers: null,
77+
resizeObserver: null,
78+
lastBounds: bounds,
79+
})
80+
81+
// set actual debounce values early, so effects know if they should react accordingly
82+
const scrollDebounce = debounce ?
83+
typeof debounce === "number" ?
84+
debounce :
85+
debounce.scroll :
86+
null
87+
const resizeDebounce = debounce ?
88+
typeof debounce === "number" ?
89+
debounce :
90+
debounce.resize :
91+
null
92+
93+
// make sure to update state only as long as the component is truly mounted
94+
const mounted = useRef(false)
95+
useEffect(() => {
96+
mounted.current = true
97+
return () => void (mounted.current = false)
98+
})
99+
100+
// memoize handlers, so event-listeners know when they should update
101+
const [forceRefresh, resizeChange, scrollChange] = useMemo(() => {
102+
const callback = () => {
103+
if (!state.current.element) return
104+
const { left, top, width, height, bottom, right, x, y } =
105+
state.current.element.getBoundingClientRect() as unknown as RectReadOnly
106+
107+
const size = {
108+
left,
109+
top,
110+
width,
111+
height,
112+
bottom,
113+
right,
114+
x,
115+
y,
116+
}
117+
118+
if (state.current.element instanceof HTMLElement && offsetSize) {
119+
size.height = state.current.element.offsetHeight
120+
size.width = state.current.element.offsetWidth
121+
}
122+
123+
Object.freeze(size)
124+
if (mounted.current && !areBoundsEqual(state.current.lastBounds, size)) { set((state.current.lastBounds = size)) }
125+
}
126+
return [
127+
callback,
128+
resizeDebounce ? createDebounce(callback, resizeDebounce) : callback,
129+
scrollDebounce ? createDebounce(callback, scrollDebounce) : callback,
130+
]
131+
}, [set, offsetSize, scrollDebounce, resizeDebounce])
132+
133+
// cleanup current scroll-listeners / observers
134+
function removeListeners() {
135+
if (state.current.scrollContainers) {
136+
state.current.scrollContainers.forEach((element) =>
137+
element.removeEventListener("scroll", scrollChange, true),
138+
)
139+
state.current.scrollContainers = null
140+
}
141+
142+
if (state.current.resizeObserver) {
143+
state.current.resizeObserver.disconnect()
144+
state.current.resizeObserver = null
145+
}
146+
}
147+
148+
// add scroll-listeners / observers
149+
function addListeners() {
150+
if (!state.current.element) return
151+
state.current.resizeObserver = new ResizeObserver(scrollChange)
152+
state.current.resizeObserver!.observe(state.current.element)
153+
if (scroll && state.current.scrollContainers) {
154+
state.current.scrollContainers.forEach((scrollContainer) =>
155+
scrollContainer.addEventListener("scroll", scrollChange, {
156+
capture: true,
157+
passive: true,
158+
}),
159+
)
160+
}
161+
}
162+
163+
// the ref we expose to the user
164+
const ref = (node: HTMLOrSVGElement | null) => {
165+
if (!node || node === state.current.element) return
166+
removeListeners()
167+
state.current.element = node
168+
state.current.scrollContainers = findScrollContainers(node)
169+
addListeners()
170+
}
171+
172+
// add general event listeners
173+
useOnWindowScroll(scrollChange, Boolean(scroll))
174+
useOnWindowResize(resizeChange)
175+
176+
// respond to changes that are relevant for the listeners
177+
useEffect(() => {
178+
removeListeners()
179+
addListeners()
180+
}, [scroll, scrollChange, resizeChange])
181+
182+
// remove all listeners when the components unmounts
183+
useEffect(() => removeListeners, [])
184+
return [ref, bounds, forceRefresh]
185+
}
186+
187+
// Adds native resize listener to window
188+
function useOnWindowResize(onWindowResize: (event: Event) => void) {
189+
useEffect(() => {
190+
const cb = onWindowResize
191+
window.addEventListener("resize", cb)
192+
return () => void window.removeEventListener("resize", cb)
193+
}, [onWindowResize])
194+
}
195+
function useOnWindowScroll(onScroll: () => void, enabled: boolean) {
196+
useEffect(() => {
197+
if (enabled) {
198+
const cb = onScroll
199+
window.addEventListener("scroll", cb, { capture: true, passive: true })
200+
return () => void window.removeEventListener("scroll", cb, true)
201+
}
202+
}, [onScroll, enabled])
203+
}
204+
205+
// Returns a list of scroll offsets
206+
function findScrollContainers(
207+
element: HTMLOrSVGElement | null,
208+
): HTMLOrSVGElement[] {
209+
const result: HTMLOrSVGElement[] = []
210+
if (!element || element === document.body) return result
211+
const { overflow, overflowX, overflowY } = window.getComputedStyle(element)
212+
if (
213+
[overflow, overflowX, overflowY].some(
214+
(prop) => prop === "auto" || prop === "scroll",
215+
)
216+
) { result.push(element) }
217+
return [...result, ...findScrollContainers(element.parentElement)]
218+
}
219+
220+
// Checks if element boundaries are equal
221+
const keys: (keyof RectReadOnly)[] = [
222+
"x",
223+
"y",
224+
"top",
225+
"bottom",
226+
"left",
227+
"right",
228+
"width",
229+
"height",
230+
]
231+
const areBoundsEqual = (a: RectReadOnly, b: RectReadOnly): boolean =>
232+
keys.every((key) => a[key] === b[key])

src/renderer/src/modules/entry-column/templates/list-item-template.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,12 +99,16 @@ export function ListItem({
9999
>
100100
{entry.entries.title ? (
101101
<EntryTranslation
102+
useOverlay
103+
side="top"
102104
className={envIsSafari ? "line-clamp-2 break-all" : undefined}
103105
source={entry.entries.title}
104106
target={translation?.title}
105107
/>
106108
) : (
107109
<EntryTranslation
110+
useOverlay
111+
side="top"
108112
source={entry.entries.description}
109113
target={translation?.description}
110114
/>
@@ -121,6 +125,8 @@ export function ListItem({
121125
)}
122126
>
123127
<EntryTranslation
128+
useOverlay
129+
side="top"
124130
className={envIsSafari ? "line-clamp-2 break-all" : undefined}
125131
source={entry.entries.description}
126132
target={translation?.description}

0 commit comments

Comments
 (0)