feat: Tooltip portal with anchorToCursor mode
Tooltip now portals cursor-anchored variants into document.body via a `mounted` guard, preventing transformed parents and scroll-container overflow from distorting coordinates. New `anchorToCursor` prop locks position at hover entry without tracking; `followCursor` retains the spring-animated tracking behaviour. Color swatches (ColorFilter), timeline scrubber dots/labels (Timeline), and toolbar dropdowns (Toolbar) are updated to use the appropriate cursor mode. Toolbar z-index bumped z-20→z-40 (dropdowns z-30→z-50) to layer above portaled content; tag autocomplete result guard added (Array.isArray).
This commit is contained in:
@@ -56,7 +56,7 @@ export function ColorFilter() {
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative ml-1 flex shrink-0 items-center border-l border-white/[0.06] pl-2">
|
||||
<div ref={ref} className="relative ml-1 flex shrink-0 items-center border-l border-white/6 pl-2">
|
||||
{/* Trigger — a single palette icon; shows the active color as a dot when a
|
||||
filter is applied so the collapsed state still communicates it. */}
|
||||
<Tooltip label={isActive ? "Color filter active" : "Filter by color"} delay={400}>
|
||||
@@ -99,9 +99,9 @@ export function ColorFilter() {
|
||||
{SWATCHES.map((swatch) => {
|
||||
const active = rgbEquals(colorFilter, swatch.rgb);
|
||||
return (
|
||||
<Tooltip label= {swatch.name} followCursor>
|
||||
<button
|
||||
key={swatch.name}
|
||||
title={swatch.name}
|
||||
aria-label={`Filter by ${swatch.name}`}
|
||||
className={`h-5 w-5 shrink-0 rounded-full border transition-transform ${
|
||||
active ? "scale-110 border-white/40 ring-2 ring-white/70" : "border-white/15 hover:scale-110"
|
||||
@@ -109,12 +109,12 @@ export function ColorFilter() {
|
||||
style={{ backgroundColor: toHex(swatch.rgb) }}
|
||||
onClick={() => setColorFilter(active ? null : swatch.rgb)}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
|
||||
<Tooltip label= "Custom Colour" followCursor>
|
||||
{/* Custom color picker — rainbow until a custom color is chosen. */}
|
||||
<label
|
||||
title="Custom color"
|
||||
className={`relative h-5 w-5 shrink-0 cursor-pointer overflow-hidden rounded-full border ${
|
||||
isCustom ? "border-white/40 ring-2 ring-white/70" : "border-white/15 hover:scale-110"
|
||||
}`}
|
||||
@@ -130,11 +130,11 @@ export function ColorFilter() {
|
||||
value={colorFilter ? toHex(colorFilter) : "#3b7dd8"}
|
||||
onChange={(event) => setColorFilter(fromHex(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
</label></Tooltip>
|
||||
</div>
|
||||
|
||||
{isActive || (colorBackfill && colorBackfill.total > 0) ? (
|
||||
<div className="mt-2 flex items-center justify-between gap-3 border-t border-white/[0.06] pt-2 light-theme:border-gray-700/40">
|
||||
<div className="mt-2 flex items-center justify-between gap-3 border-t border-white/6 pt-2 light-theme:border-gray-700/40">
|
||||
{colorBackfill && colorBackfill.total > 0 ? (
|
||||
<span
|
||||
className="text-[10px] text-gray-600"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { ImageRecord, tileSizeForZoom, useGalleryStore } from "../store";
|
||||
import { ContextMenu, ImageTile } from "./Gallery";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
|
||||
const GAP = 6;
|
||||
const HEADER_HEIGHT = 52;
|
||||
@@ -374,16 +375,16 @@ function ScrubberYearBlock({ yearEntry, activeGroupIndex, onScrollTo }: Scrubber
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col items-center">
|
||||
<Tooltip label={yearEntry.year} anchorToCursor>
|
||||
<button
|
||||
className={`w-full text-center py-0.5 text-[10px] font-semibold tracking-wide transition-colors ${
|
||||
isYearActive ? "text-white/80" : "text-white/30 hover:text-white/55"
|
||||
}`}
|
||||
onClick={() => onScrollTo(yearEntry.firstGroupIndex)}
|
||||
title={yearEntry.year}
|
||||
>
|
||||
{yearEntry.year}
|
||||
</button>
|
||||
|
||||
</Tooltip>
|
||||
<div
|
||||
className="grid gap-[3px] pb-1.5"
|
||||
style={{ gridTemplateColumns: "repeat(3, 10px)" }}
|
||||
@@ -396,14 +397,14 @@ function ScrubberYearBlock({ yearEntry, activeGroupIndex, onScrollTo }: Scrubber
|
||||
return <span key={monthNum} className="h-[10px] w-[10px]" />;
|
||||
}
|
||||
return (
|
||||
<Tooltip key={monthNum} label={`${monthEntry.label} ${yearEntry.year}`} anchorToCursor>
|
||||
<button
|
||||
key={monthNum}
|
||||
title={`${monthEntry.label} ${yearEntry.year}`}
|
||||
onClick={() => onScrollTo(monthEntry.groupIndex)}
|
||||
className={`h-[10px] w-[10px] rounded-full transition-colors ${
|
||||
isActive ? "bg-white/70" : "bg-white/15 hover:bg-white/40"
|
||||
}`}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
+11
-10
@@ -3,6 +3,7 @@ import { invoke } from "@tauri-apps/api/core";
|
||||
import { tileSizeForZoom, useGalleryStore, SortOrder, MediaFilter, SearchCommand, parseSearchValue, searchModeLabel, ExploreTagEntry } from "../store";
|
||||
import { FolderScopeDropdown } from "./FolderScopeDropdown";
|
||||
import { ColorFilter } from "./ColorFilter";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
|
||||
const BASE_SORT_OPTIONS: { value: SortOrder; label: string }[] = [
|
||||
{ value: "date_desc", label: "Newest first" },
|
||||
@@ -230,7 +231,7 @@ export function Toolbar() {
|
||||
const results = await invoke<ExploreTagEntry[]>("search_tags_autocomplete", {
|
||||
params: { query: searchQuery.trim(), folder_id: selectedFolderId ?? null, limit: 10 },
|
||||
});
|
||||
setTagSuggestions(results);
|
||||
setTagSuggestions(Array.isArray(results) ? results : []);
|
||||
} catch {
|
||||
setTagSuggestions([]);
|
||||
}
|
||||
@@ -266,7 +267,7 @@ export function Toolbar() {
|
||||
const showCommandHints = !searchCommand && searchPanelOpen;
|
||||
|
||||
return (
|
||||
<div className="relative z-20 shrink-0 border-b border-white/[0.06] bg-gray-950/80 backdrop-blur-xl">
|
||||
<div className="relative z-40 shrink-0 border-b border-white/[0.06] bg-gray-950/80 backdrop-blur-xl">
|
||||
{/* Primary row */}
|
||||
<div className="flex items-center gap-2 px-3 h-12 lg:gap-3 lg:px-5">
|
||||
{/* Title + count */}
|
||||
@@ -357,7 +358,7 @@ export function Toolbar() {
|
||||
|
||||
{/* Tag autocomplete suggestions */}
|
||||
{showTagSuggestions && tagSuggestions.length > 0 ? (
|
||||
<div className="absolute left-0 top-full z-30 mt-1.5 w-72 rounded-xl border border-white/10 bg-gray-950/98 py-1 shadow-2xl backdrop-blur">
|
||||
<div className="absolute left-0 top-full z-50 mt-1.5 w-72 rounded-xl border border-white/10 bg-gray-950/98 py-1 shadow-2xl backdrop-blur">
|
||||
{tagSuggestions.map((entry) => (
|
||||
<button
|
||||
key={entry.tag}
|
||||
@@ -381,25 +382,25 @@ export function Toolbar() {
|
||||
|
||||
{/* Tag mode with no suggestions yet — show a brief hint */}
|
||||
{showTagSuggestions && tagSuggestions.length === 0 && searchQuery.trim().length > 0 ? (
|
||||
<div className="absolute left-0 top-full z-30 mt-1.5 w-72 rounded-xl border border-white/10 bg-gray-950/98 px-3 py-2.5 shadow-2xl backdrop-blur">
|
||||
<div className="absolute left-0 top-full z-50 mt-1.5 w-72 rounded-xl border border-white/10 bg-gray-950/98 px-3 py-2.5 shadow-2xl backdrop-blur">
|
||||
<p className="text-xs text-white/25">No matching tags</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Semantic mode hint */}
|
||||
{searchCommand === "semantic" && searchPanelOpen ? (
|
||||
<div className="absolute left-0 top-full z-30 mt-1.5 w-72 rounded-xl border border-white/10 bg-gray-950/98 px-3 py-2.5 shadow-2xl backdrop-blur">
|
||||
<div className="absolute left-0 top-full z-50 mt-1.5 w-72 rounded-xl border border-white/10 bg-gray-950/98 px-3 py-2.5 shadow-2xl backdrop-blur">
|
||||
<p className="text-xs text-white/40">Search by meaning and visual concepts</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Command hints — only shown when no command is active */}
|
||||
{showCommandHints ? (
|
||||
<div className="absolute left-0 top-full z-30 mt-1.5 w-64 rounded-xl border border-white/10 bg-gray-950/98 py-1 shadow-2xl backdrop-blur">
|
||||
<div className="absolute left-0 top-full z-50 mt-1.5 w-64 rounded-xl border border-white/10 bg-gray-950/98 py-1 shadow-2xl backdrop-blur">
|
||||
{(
|
||||
[
|
||||
{ command: "tag" as SearchCommand, prefix: "/t", label: "Tags", description: "Search AI and user tags" },
|
||||
{ command: "semantic" as SearchCommand, prefix: "/s", label: "Semantic", description: "Search by meaning" },
|
||||
{ command: "tag" as SearchCommand, prefix: "/t", label: "Tags", description: "Search user and AI tags" },
|
||||
{ command: "semantic" as SearchCommand, prefix: "/s", label: "Semantic", description: "Search by meaning, object, mood" },
|
||||
] as const
|
||||
).map((option) => (
|
||||
<button
|
||||
@@ -434,8 +435,8 @@ export function Toolbar() {
|
||||
{/* Zoom */}
|
||||
<div className="flex items-center rounded-lg border border-white/8 overflow-hidden light-theme:border-gray-700/40">
|
||||
{(["compact", "comfortable", "detail"] as const).map((preset, i) => (
|
||||
<Tooltip key={preset} label={`${tileSize}px tiles`} followCursor>
|
||||
<button
|
||||
key={preset}
|
||||
className={`px-2.5 py-1.5 text-xs font-medium transition-colors ${
|
||||
i > 0 ? "border-l border-white/8" : ""
|
||||
} ${
|
||||
@@ -443,11 +444,11 @@ export function Toolbar() {
|
||||
? "bg-white/10 text-white light-theme:bg-gray-900 light-theme:text-white"
|
||||
: "text-gray-500 hover:text-gray-200 hover:bg-white/5 light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
|
||||
}`}
|
||||
title={`${tileSize}px tiles`}
|
||||
onClick={() => setZoomPreset(preset)}
|
||||
>
|
||||
{preset === "compact" ? "S" : preset === "comfortable" ? "M" : "L"}
|
||||
</button>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+33
-22
@@ -1,4 +1,5 @@
|
||||
import { MouseEvent, ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
type TooltipSide = "top" | "bottom" | "left" | "right";
|
||||
@@ -20,8 +21,8 @@ const STATIC_CLASSES = `${BASE_CLASSES} transition-opacity duration-100`;
|
||||
/**
|
||||
* Lightweight custom tooltip — fades in (faster than the native one) after a
|
||||
* tunable hover `delay`, and hides instantly on leave or click. By default it
|
||||
* anchors to a `side` of the wrapper; with `followCursor` it tracks the pointer
|
||||
* (fixed-positioned, so it escapes scroll-container clipping).
|
||||
* anchors to a `side` of the wrapper. `anchorToCursor` shows at the pointer's
|
||||
* entry position; `followCursor` keeps tracking the pointer.
|
||||
*/
|
||||
export function Tooltip({
|
||||
label,
|
||||
@@ -29,6 +30,7 @@ export function Tooltip({
|
||||
side = "bottom",
|
||||
align = "center",
|
||||
block = false,
|
||||
anchorToCursor = false,
|
||||
followCursor = false,
|
||||
className = "",
|
||||
children,
|
||||
@@ -41,6 +43,8 @@ export function Tooltip({
|
||||
align?: TooltipAlign;
|
||||
/** Full-width block wrapper (e.g. wrapping a grid cell) instead of inline. */
|
||||
block?: boolean;
|
||||
/** Position at the cursor when shown, without following subsequent movement. */
|
||||
anchorToCursor?: boolean;
|
||||
/** Track the cursor (fixed position) instead of anchoring to a side. */
|
||||
followCursor?: boolean;
|
||||
/** Extra classes for the wrapper (e.g. layout). */
|
||||
@@ -49,6 +53,7 @@ export function Tooltip({
|
||||
}) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [coords, setCoords] = useState({ x: 0, y: 0 });
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const frame = useRef<number | undefined>(undefined);
|
||||
const tooltipRef = useRef<HTMLSpanElement>(null);
|
||||
@@ -79,7 +84,7 @@ export function Tooltip({
|
||||
};
|
||||
const show = (event: MouseEvent<HTMLElement>) => {
|
||||
clear();
|
||||
if (followCursor) place(event.clientX, event.clientY);
|
||||
if (anchorToCursor || followCursor) place(event.clientX, event.clientY);
|
||||
if (delay <= 0) {
|
||||
setVisible(true);
|
||||
return;
|
||||
@@ -100,9 +105,31 @@ export function Tooltip({
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => clear, []);
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
return clear;
|
||||
}, []);
|
||||
|
||||
const Wrapper = block ? "div" : "span";
|
||||
const cursorTooltip = (
|
||||
<motion.span
|
||||
ref={tooltipRef}
|
||||
role="tooltip"
|
||||
aria-hidden={!visible}
|
||||
// Portaled + fixed so transformed parents and scroll containers cannot
|
||||
// distort cursor-anchored tooltip coordinates.
|
||||
className={`fixed ${BASE_CLASSES}`}
|
||||
initial={false}
|
||||
animate={{ left: coords.x, top: coords.y, opacity: visible ? 1 : 0 }}
|
||||
transition={{
|
||||
left: followCursor ? { type: "spring", stiffness: 700, damping: 45, mass: 0.35 } : { duration: 0 },
|
||||
top: followCursor ? { type: "spring", stiffness: 520, damping: 34, mass: 0.45 } : { duration: 0 },
|
||||
opacity: { duration: visible ? 0.1 : 0.05 },
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</motion.span>
|
||||
);
|
||||
|
||||
return (
|
||||
<Wrapper
|
||||
@@ -113,24 +140,8 @@ export function Tooltip({
|
||||
onPointerDown={hide}
|
||||
>
|
||||
{children}
|
||||
{followCursor ? (
|
||||
<motion.span
|
||||
ref={tooltipRef}
|
||||
role="tooltip"
|
||||
aria-hidden={!visible}
|
||||
// Fixed (so the scroll container's overflow doesn't clip it) and
|
||||
// positioned by `place()` near the cursor with viewport-edge flipping.
|
||||
className={`fixed ${BASE_CLASSES}`}
|
||||
initial={false}
|
||||
animate={{ left: coords.x, top: coords.y, opacity: visible ? 1 : 0 }}
|
||||
transition={{
|
||||
left: { type: "spring", stiffness: 700, damping: 45, mass: 0.35 },
|
||||
top: { type: "spring", stiffness: 520, damping: 34, mass: 0.45 },
|
||||
opacity: { duration: visible ? 0.1 : 0.05 },
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</motion.span>
|
||||
{anchorToCursor || followCursor ? (
|
||||
mounted ? createPortal(cursorTooltip, document.body) : null
|
||||
) : (
|
||||
<span
|
||||
role="tooltip"
|
||||
|
||||
Reference in New Issue
Block a user