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:
2026-06-29 16:28:35 +01:00
parent d81624573d
commit 9144be2518
4 changed files with 81 additions and 68 deletions
+6 -6
View File
@@ -56,7 +56,7 @@ export function ColorFilter() {
}, [open]); }, [open]);
return ( 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 {/* Trigger — a single palette icon; shows the active color as a dot when a
filter is applied so the collapsed state still communicates it. */} filter is applied so the collapsed state still communicates it. */}
<Tooltip label={isActive ? "Color filter active" : "Filter by color"} delay={400}> <Tooltip label={isActive ? "Color filter active" : "Filter by color"} delay={400}>
@@ -99,9 +99,9 @@ export function ColorFilter() {
{SWATCHES.map((swatch) => { {SWATCHES.map((swatch) => {
const active = rgbEquals(colorFilter, swatch.rgb); const active = rgbEquals(colorFilter, swatch.rgb);
return ( return (
<Tooltip label= {swatch.name} followCursor>
<button <button
key={swatch.name} key={swatch.name}
title={swatch.name}
aria-label={`Filter by ${swatch.name}`} aria-label={`Filter by ${swatch.name}`}
className={`h-5 w-5 shrink-0 rounded-full border transition-transform ${ 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" 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) }} style={{ backgroundColor: toHex(swatch.rgb) }}
onClick={() => setColorFilter(active ? null : swatch.rgb)} onClick={() => setColorFilter(active ? null : swatch.rgb)}
/> />
</Tooltip>
); );
})} })}
<Tooltip label= "Custom Colour" followCursor>
{/* Custom color picker — rainbow until a custom color is chosen. */} {/* Custom color picker — rainbow until a custom color is chosen. */}
<label <label
title="Custom color"
className={`relative h-5 w-5 shrink-0 cursor-pointer overflow-hidden rounded-full border ${ 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" 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"} value={colorFilter ? toHex(colorFilter) : "#3b7dd8"}
onChange={(event) => setColorFilter(fromHex(event.target.value))} onChange={(event) => setColorFilter(fromHex(event.target.value))}
/> />
</label> </label></Tooltip>
</div> </div>
{isActive || (colorBackfill && colorBackfill.total > 0) ? ( {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 ? ( {colorBackfill && colorBackfill.total > 0 ? (
<span <span
className="text-[10px] text-gray-600" className="text-[10px] text-gray-600"
+19 -18
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
import { useVirtualizer } from "@tanstack/react-virtual"; import { useVirtualizer } from "@tanstack/react-virtual";
import { ImageRecord, tileSizeForZoom, useGalleryStore } from "../store"; import { ImageRecord, tileSizeForZoom, useGalleryStore } from "../store";
import { ContextMenu, ImageTile } from "./Gallery"; import { ContextMenu, ImageTile } from "./Gallery";
import { Tooltip } from "./Tooltip";
const GAP = 6; const GAP = 6;
const HEADER_HEIGHT = 52; const HEADER_HEIGHT = 52;
@@ -374,16 +375,16 @@ function ScrubberYearBlock({ yearEntry, activeGroupIndex, onScrollTo }: Scrubber
return ( return (
<div className="w-full flex flex-col items-center"> <div className="w-full flex flex-col items-center">
<button <Tooltip label={yearEntry.year} anchorToCursor>
className={`w-full text-center py-0.5 text-[10px] font-semibold tracking-wide transition-colors ${ <button
isYearActive ? "text-white/80" : "text-white/30 hover:text-white/55" 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} onClick={() => onScrollTo(yearEntry.firstGroupIndex)}
> >
{yearEntry.year} {yearEntry.year}
</button> </button>
</Tooltip>
<div <div
className="grid gap-[3px] pb-1.5" className="grid gap-[3px] pb-1.5"
style={{ gridTemplateColumns: "repeat(3, 10px)" }} 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 <span key={monthNum} className="h-[10px] w-[10px]" />;
} }
return ( return (
<button <Tooltip key={monthNum} label={`${monthEntry.label} ${yearEntry.year}`} anchorToCursor>
key={monthNum} <button
title={`${monthEntry.label} ${yearEntry.year}`} onClick={() => onScrollTo(monthEntry.groupIndex)}
onClick={() => onScrollTo(monthEntry.groupIndex)} className={`h-[10px] w-[10px] rounded-full transition-colors ${
className={`h-[10px] w-[10px] rounded-full transition-colors ${ isActive ? "bg-white/70" : "bg-white/15 hover:bg-white/40"
isActive ? "bg-white/70" : "bg-white/15 hover:bg-white/40" }`}
}`} />
/> </Tooltip>
); );
})} })}
</div> </div>
+23 -22
View File
@@ -3,6 +3,7 @@ import { invoke } from "@tauri-apps/api/core";
import { tileSizeForZoom, useGalleryStore, SortOrder, MediaFilter, SearchCommand, parseSearchValue, searchModeLabel, ExploreTagEntry } from "../store"; import { tileSizeForZoom, useGalleryStore, SortOrder, MediaFilter, SearchCommand, parseSearchValue, searchModeLabel, ExploreTagEntry } from "../store";
import { FolderScopeDropdown } from "./FolderScopeDropdown"; import { FolderScopeDropdown } from "./FolderScopeDropdown";
import { ColorFilter } from "./ColorFilter"; import { ColorFilter } from "./ColorFilter";
import { Tooltip } from "./Tooltip";
const BASE_SORT_OPTIONS: { value: SortOrder; label: string }[] = [ const BASE_SORT_OPTIONS: { value: SortOrder; label: string }[] = [
{ value: "date_desc", label: "Newest first" }, { value: "date_desc", label: "Newest first" },
@@ -230,7 +231,7 @@ export function Toolbar() {
const results = await invoke<ExploreTagEntry[]>("search_tags_autocomplete", { const results = await invoke<ExploreTagEntry[]>("search_tags_autocomplete", {
params: { query: searchQuery.trim(), folder_id: selectedFolderId ?? null, limit: 10 }, params: { query: searchQuery.trim(), folder_id: selectedFolderId ?? null, limit: 10 },
}); });
setTagSuggestions(results); setTagSuggestions(Array.isArray(results) ? results : []);
} catch { } catch {
setTagSuggestions([]); setTagSuggestions([]);
} }
@@ -266,7 +267,7 @@ export function Toolbar() {
const showCommandHints = !searchCommand && searchPanelOpen; const showCommandHints = !searchCommand && searchPanelOpen;
return ( 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 */} {/* Primary row */}
<div className="flex items-center gap-2 px-3 h-12 lg:gap-3 lg:px-5"> <div className="flex items-center gap-2 px-3 h-12 lg:gap-3 lg:px-5">
{/* Title + count */} {/* Title + count */}
@@ -357,7 +358,7 @@ export function Toolbar() {
{/* Tag autocomplete suggestions */} {/* Tag autocomplete suggestions */}
{showTagSuggestions && tagSuggestions.length > 0 ? ( {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) => ( {tagSuggestions.map((entry) => (
<button <button
key={entry.tag} key={entry.tag}
@@ -381,25 +382,25 @@ export function Toolbar() {
{/* Tag mode with no suggestions yet — show a brief hint */} {/* Tag mode with no suggestions yet — show a brief hint */}
{showTagSuggestions && tagSuggestions.length === 0 && searchQuery.trim().length > 0 ? ( {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> <p className="text-xs text-white/25">No matching tags</p>
</div> </div>
) : null} ) : null}
{/* Semantic mode hint */} {/* Semantic mode hint */}
{searchCommand === "semantic" && searchPanelOpen ? ( {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> <p className="text-xs text-white/40">Search by meaning and visual concepts</p>
</div> </div>
) : null} ) : null}
{/* Command hints — only shown when no command is active */} {/* Command hints — only shown when no command is active */}
{showCommandHints ? ( {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: "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" }, { command: "semantic" as SearchCommand, prefix: "/s", label: "Semantic", description: "Search by meaning, object, mood" },
] as const ] as const
).map((option) => ( ).map((option) => (
<button <button
@@ -434,20 +435,20 @@ export function Toolbar() {
{/* Zoom */} {/* Zoom */}
<div className="flex items-center rounded-lg border border-white/8 overflow-hidden light-theme:border-gray-700/40"> <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) => ( {(["compact", "comfortable", "detail"] as const).map((preset, i) => (
<button <Tooltip key={preset} label={`${tileSize}px tiles`} followCursor>
key={preset} <button
className={`px-2.5 py-1.5 text-xs font-medium transition-colors ${ className={`px-2.5 py-1.5 text-xs font-medium transition-colors ${
i > 0 ? "border-l border-white/8" : "" i > 0 ? "border-l border-white/8" : ""
} ${ } ${
zoomPreset === preset zoomPreset === preset
? "bg-white/10 text-white light-theme:bg-gray-900 light-theme:text-white" ? "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" : "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)}
onClick={() => setZoomPreset(preset)} >
> {preset === "compact" ? "S" : preset === "comfortable" ? "M" : "L"}
{preset === "compact" ? "S" : preset === "comfortable" ? "M" : "L"} </button>
</button> </Tooltip>
))} ))}
</div> </div>
</div> </div>
+33 -22
View File
@@ -1,4 +1,5 @@
import { MouseEvent, ReactNode, useEffect, useRef, useState } from "react"; import { MouseEvent, ReactNode, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
type TooltipSide = "top" | "bottom" | "left" | "right"; 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 * 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 * 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 * anchors to a `side` of the wrapper. `anchorToCursor` shows at the pointer's
* (fixed-positioned, so it escapes scroll-container clipping). * entry position; `followCursor` keeps tracking the pointer.
*/ */
export function Tooltip({ export function Tooltip({
label, label,
@@ -29,6 +30,7 @@ export function Tooltip({
side = "bottom", side = "bottom",
align = "center", align = "center",
block = false, block = false,
anchorToCursor = false,
followCursor = false, followCursor = false,
className = "", className = "",
children, children,
@@ -41,6 +43,8 @@ export function Tooltip({
align?: TooltipAlign; align?: TooltipAlign;
/** Full-width block wrapper (e.g. wrapping a grid cell) instead of inline. */ /** Full-width block wrapper (e.g. wrapping a grid cell) instead of inline. */
block?: boolean; 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. */ /** Track the cursor (fixed position) instead of anchoring to a side. */
followCursor?: boolean; followCursor?: boolean;
/** Extra classes for the wrapper (e.g. layout). */ /** Extra classes for the wrapper (e.g. layout). */
@@ -49,6 +53,7 @@ export function Tooltip({
}) { }) {
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const [coords, setCoords] = useState({ x: 0, y: 0 }); const [coords, setCoords] = useState({ x: 0, y: 0 });
const [mounted, setMounted] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined); const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const frame = useRef<number | undefined>(undefined); const frame = useRef<number | undefined>(undefined);
const tooltipRef = useRef<HTMLSpanElement>(null); const tooltipRef = useRef<HTMLSpanElement>(null);
@@ -79,7 +84,7 @@ export function Tooltip({
}; };
const show = (event: MouseEvent<HTMLElement>) => { const show = (event: MouseEvent<HTMLElement>) => {
clear(); clear();
if (followCursor) place(event.clientX, event.clientY); if (anchorToCursor || followCursor) place(event.clientX, event.clientY);
if (delay <= 0) { if (delay <= 0) {
setVisible(true); setVisible(true);
return; return;
@@ -100,9 +105,31 @@ export function Tooltip({
}); });
}; };
useEffect(() => clear, []); useEffect(() => {
setMounted(true);
return clear;
}, []);
const Wrapper = block ? "div" : "span"; 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 ( return (
<Wrapper <Wrapper
@@ -113,24 +140,8 @@ export function Tooltip({
onPointerDown={hide} onPointerDown={hide}
> >
{children} {children}
{followCursor ? ( {anchorToCursor || followCursor ? (
<motion.span mounted ? createPortal(cursorTooltip, document.body) : null
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>
) : ( ) : (
<span <span
role="tooltip" role="tooltip"