fix: tips follow scroll

This commit is contained in:
zhom committed 2026-10-20 10:28:47 +04:00
1 parent 553c7689d9
commit cae6f9980e
4 files changed
+548 -87

No files matched your search

+8 -2
View File
@@ -346,8 +346,14 @@ flow (what to show when) is `src/hooks/use-tips.ts`. State (`tips_auto_show`,
rail uses `right`). The coach mark follows its anchor through scrolling,
resizing and re-renders, flips to stay on screen, hides while a modal
surface is open, never takes the focus, describes the anchor with its text
(`aria-describedby`), and closes with Escape. Placement is the pure
`placeCoachMark` (tests in `src/lib/coach-mark.test.mjs`).
(`aria-describedby`), and closes with Escape. An anchor counts only while
most of it shows: not scrolled out of any container, under a `.scroll-fade`
sticky header or fade band, or covered. The followed element keeps the mark
while it shows; then the nearest element of the same id, the next anchor,
or nothing (the mark hides until one comes back). Events schedule one
measure per frame (`frame.read`). The card stays inside the anchor's scroll
container when it fits there. Placement and choice are the pure
`placeCoachMark` and `pickAnchor` (tests in `src/lib/coach-mark.test.mjs`).
- One new tip a day, only on the Profiles page with at least one profile,
after a settled launch (onboarding done, terms accepted, never in the
first-run session), once the page has been quiet a few seconds with nothing
+239 -58
View File
@@ -2,6 +2,8 @@
import {
animate,
cancelFrame,
frame,
type MotionValue,
motion,
useMotionValue,
@@ -21,19 +23,29 @@ import {
} from "react";
import { createPortal } from "react-dom";
import {
type AnchorCandidate,
anchorSelector,
type Box,
COACH_MARK_EXIT_MS,
type CoachAnchorId,
clipArea,
type Insets,
mostlyVisible,
pickAnchor,
placeCoachMark,
type Side,
visiblePart,
} from "@/lib/coach-mark";
import { MOTION_DURATION, MOTION_EASE_OUT } from "@/lib/motion";
import { cn } from "@/lib/utils";
/** A modal surface is open; a coach mark waits behind it. */
const MODAL_SELECTOR = '[role="dialog"], [role="alertdialog"]';
/** The mark's own layers, and surfaces that pass over the page for a moment. */
const PASSING_LAYERS =
"[data-coach-layer], [data-radix-popper-content-wrapper], [data-sonner-toaster]";
/** A new anchor closer than this to the ring is the same place: no travel. */
const SAME_PLACE_PX = 2;
/**
* Observers report through the rendering loop, which a throttled webview
* stops; this slow poll still places the card there. It runs only while the
@@ -85,31 +97,145 @@ function toBox(rect: DOMRect): Box {
return { x: rect.left, y: rect.top, width: rect.width, height: rect.height };
}
/** Every box that clips `element`: its scrolling ancestors and the window. */
function clipsOf(element: Element): Box[] {
const clips: Box[] = [
{ x: 0, y: 0, width: window.innerWidth, height: window.innerHeight },
];
const NO_INSETS: Insets = { top: 0, right: 0, bottom: 0, left: 0 };
function clipsContent(style: CSSStyleDeclaration): boolean {
return style.overflowX !== "visible" || style.overflowY !== "visible";
}
/**
* The edges of a `.scroll-fade` container that hide what scrolls under them:
* the sticky header it declares, and each fade band while it shows.
*/
function coveredEdges(node: HTMLElement, style: CSSStyleDeclaration): Insets {
if (!node.classList.contains("scroll-fade")) return NO_INSETS;
const length = (name: string) =>
Number.parseFloat(style.getPropertyValue(name)) || 0;
const band = length("--scroll-fade-size");
return {
top:
length("--scroll-fade-top-offset") +
(node.dataset.fadeTop === "true" ? band : 0),
right: 0,
bottom: node.dataset.fadeBottom === "true" ? band : 0,
left: 0,
};
}
interface Ancestor {
sticky: boolean;
/**
* When the element clips: what it shows of content that scrolls inside
* it, and of sticky content, which its covered edges do not hide.
*/
clip: { scrolling: Box; sticky: Box } | null;
}
/** Nothing of the page lies over the middle of what shows of the anchor. */
function uncovered(element: Element, part: Box): boolean {
const top = document
.elementsFromPoint(part.x + part.width / 2, part.y + part.height / 2)
.find((hit) => !hit.closest(PASSING_LAYERS));
return (
!top || top === element || element.contains(top) || top.contains(element)
);
}
/**
* Reads what shows on screen, for one placement. The buttons of every row
* share their scroll containers, so each element is read once.
*/
function createLens() {
const viewport: Box = {
x: 0,
y: 0,
width: window.innerWidth,
height: window.innerHeight,
};
const read = new Map<Element, Ancestor>();
const ancestor = (node: HTMLElement): Ancestor => {
const known = read.get(node);
if (known) return known;
const style = getComputedStyle(node);
const sticky = style.position === "sticky";
let found: Ancestor = { sticky, clip: null };
if (clipsContent(style)) {
const border = toBox(node.getBoundingClientRect());
const client = {
left: node.clientLeft,
top: node.clientTop,
width: node.clientWidth,
height: node.clientHeight,
};
const whole = clipArea(border, client, NO_INSETS);
const covered = coveredEdges(node, style);
found = {
sticky,
clip: {
scrolling:
covered === NO_INSETS ? whole : clipArea(border, client, covered),
sticky: whole,
},
};
}
read.set(node, found);
return found;
};
/**
* Every box that clips `element`: the window and its clipping ancestors. A
* sticky header is not hidden by the edge it sticks to.
*/
const clipsOf = (element: HTMLElement): Box[] => {
const clips = [viewport];
let pinned = ancestor(element).sticky;
for (
let node = element.parentElement;
node && node !== document.body;
node = node.parentElement
) {
const { sticky, clip } = ancestor(node);
if (clip) {
clips.push(pinned ? clip.sticky : clip.scrolling);
pinned = false;
}
if (sticky) pinned = true;
}
return clips;
};
/** Most of the element shows: not scrolled away, clipped or covered. */
const shows = (element: HTMLElement, box: Box): boolean => {
if (box.width < 1 || box.height < 1) return false;
const clips = clipsOf(element);
if (!mostlyVisible(box, clips)) return false;
if (element.closest("[inert], [aria-hidden='true']")) return false;
const style = getComputedStyle(element);
if (style.visibility === "hidden" || style.display === "none") return false;
const part = visiblePart(box, clips);
return part !== null && uncovered(element, part);
};
/** The part of the page the element's scroll containers show. */
const areaOf = (element: HTMLElement): Box => {
const [first, ...rest] = clipsOf(element);
return visiblePart(first, rest) ?? viewport;
};
return { viewport, shows, areaOf };
}
/** The element's clipping ancestors, to watch them change size. */
function clippingAncestors(element: Element): HTMLElement[] {
const found: HTMLElement[] = [];
for (
let node = element.parentElement;
node && node !== document.body;
node = node.parentElement
) {
const style = getComputedStyle(node);
if (style.overflowX !== "visible" || style.overflowY !== "visible") {
clips.push(toBox(node.getBoundingClientRect()));
}
if (clipsContent(getComputedStyle(node))) found.push(node);
}
return clips;
}
function onScreen(element: Element): boolean {
if (element.closest("[inert], [aria-hidden='true']")) return false;
const style = getComputedStyle(element);
if (style.visibility === "hidden" || style.display === "none") return false;
const rect = element.getBoundingClientRect();
if (rect.width < 1 || rect.height < 1) return false;
return mostlyVisible(toBox(rect), clipsOf(element));
return found;
}
/**
@@ -161,18 +287,32 @@ function watchMoves(element: Element, onMove: () => void): () => void {
return stop;
}
/** The first anchor, by preference, with an element that shows. */
/**
* Where to point now: the first anchor id, by preference, with an element
* that shows. The element followed last keeps the mark while it shows; when
* it scrolls away or unmounts, the nearest one of the same id takes over.
*/
function resolveAnchor(
anchors: readonly CoachAnchorId[],
): { id: CoachAnchorId; element: HTMLElement } | null {
lens: ReturnType<typeof createLens>,
memory: Map<CoachAnchorId, { item: HTMLElement; box: Box }>,
): AnchorCandidate<HTMLElement> | null {
const candidates: AnchorCandidate<HTMLElement>[] = [];
for (const id of anchors) {
for (const element of document.querySelectorAll<HTMLElement>(
for (const item of document.querySelectorAll<HTMLElement>(
anchorSelector(id),
)) {
if (onScreen(element)) return { id, element };
candidates.push({ id, item, box: toBox(item.getBoundingClientRect()) });
}
}
return null;
const found = pickAnchor(
anchors,
candidates,
(candidate) => lens.shows(candidate.item, candidate.box),
(id) => memory.get(id),
);
if (found) memory.set(found.id, { item: found.item, box: found.box });
return found;
}
/**
@@ -297,7 +437,10 @@ export interface CoachMarkProps extends DataAttributes {
* Points at a real element: a ring drawn around it, a beacon on its corner,
* and a card beside it with an arrow. The anchor is found by its
* `data-tip-anchor` id, and the mark follows it through scrolling, resizing
* and re-renders, flipping sides to stay on screen.
* and re-renders, flipping sides to stay on screen. An anchor counts only
* while most of it shows: scrolled out of a container, under a sticky header
* or a fade band, covered, or unmounted, it gives the mark to the next anchor
* that shows, and with none the mark waits hidden until one comes back.
*
* It never takes the focus. The card is a labelled region whose buttons are in
* the tab order, the anchor is described by the card's text while the mark
@@ -340,6 +483,12 @@ export function CoachMark({
const afterMeasureRef = useRef<(() => void) | null>(null);
const travelUntilRef = useRef(0);
const shownRef = useRef(false);
/** The last measure hid the mark: its next appearance does not travel. */
const hiddenRef = useRef(true);
/** The element each anchor id pointed at last, and where it was. */
const memoryRef = useRef(
new Map<CoachAnchorId, { item: HTMLElement; box: Box }>(),
);
const cardX = useMotionValue(0);
const cardY = useMotionValue(0);
@@ -393,20 +542,38 @@ export function CoachMark({
open,
} = latest.current;
const blocked = Boolean(document.querySelector(MODAL_SELECTOR));
const found = blocked ? null : resolveAnchor(anchors);
const lens = createLens();
const found = blocked
? null
: resolveAnchor(anchors, lens, memoryRef.current);
const element = found?.item ?? null;
const hidden = blocked || (!found && !floatWithoutAnchor);
const viewport = { width: window.innerWidth, height: window.innerHeight };
const { viewport } = lens;
const size = { width: card.offsetWidth, height: card.offsetHeight };
const pad = spotlight ? 6 : 4;
const ring = found
? {
x: found.box.x - pad,
y: found.box.y - pad,
width: found.box.width + pad * 2,
height: found.box.height + pad * 2,
}
: null;
const now = performance.now();
if (
ring &&
shownRef.current &&
!hiddenRef.current &&
!hidden &&
!reduceMotion &&
found?.element !== anchorRef.current
element !== anchorRef.current &&
(Math.abs(ringX.get() - ring.x) > SAME_PLACE_PX ||
Math.abs(ringY.get() - ring.y) > SAME_PLACE_PX)
) {
travelUntilRef.current = now + TRAVEL_MS;
}
if (hiddenRef.current) travelUntilRef.current = 0;
const travel = now < travelUntilRef.current;
const move = (value: MotionValue<number>, target: number) => {
if (travel) animate(value, target, TRAVEL_SPRING);
@@ -414,32 +581,27 @@ export function CoachMark({
};
// The anchor is described by the card while the mark points at it.
const previous = anchorRef.current;
if (previous && previous !== found?.element)
describe(previous, bodyId, false);
if (found) describe(found.element, bodyId, open && !hidden);
anchorRef.current = found?.element ?? null;
if (previous && previous !== element) describe(previous, bodyId, false);
if (element) describe(element, bodyId, open && !hidden);
anchorRef.current = element;
let side: Side | null = null;
if (found) {
const rect = found.element.getBoundingClientRect();
const pad = spotlight ? 6 : 4;
if (element && ring) {
const radius =
Number.parseFloat(
getComputedStyle(found.element).borderTopLeftRadius,
) || 6;
const ring = {
x: rect.left - pad,
y: rect.top - pad,
width: rect.width + pad * 2,
height: rect.height + pad * 2,
};
const named = found.element.getAttribute("data-tip-side");
const placed = placeCoachMark(ring, size, viewport, {
Number.parseFloat(getComputedStyle(element).borderTopLeftRadius) || 6;
const named = element.getAttribute("data-tip-side");
const options = {
side: SIDE_VALUES.includes(named as Side)
? (named as Side)
: preferredSide,
gap: ARROW_H + 4,
});
};
// Inside the part of its scroll container that shows, when the card
// fits there; else anywhere in the window.
const inside = placeCoachMark(ring, size, lens.areaOf(element), options);
const placed = inside.fits
? inside
: placeCoachMark(ring, size, viewport, options);
side = placed.side;
const arrow = arrowTransform(placed.side, placed.arrow, size);
move(cardX, placed.x);
@@ -458,6 +620,11 @@ export function CoachMark({
move(cardX, (viewport.width - size.width) / 2);
move(cardY, viewport.height - size.height - 24);
card.style.transformOrigin = "50% 100%";
// The spotlight closes its hole: there is nothing to show through it.
move(ringX, ringX.get() + ringW.get() / 2);
move(ringY, ringY.get() + ringH.get() / 2);
move(ringW, 0);
move(ringH, 0);
}
// The first time the card shows, it settles out from its anchor.
@@ -471,6 +638,7 @@ export function CoachMark({
animate(enterScale, 1, ENTER_SPRING);
}
if (!hidden) shownRef.current = true;
hiddenRef.current = hidden;
setLayout((previous) =>
previous.anchor === (found?.id ?? null) &&
@@ -501,48 +669,56 @@ export function CoachMark({
useEffect(() => setMounted(true), []);
// Placed before the first paint, then kept in place by every event that can
// move the anchor: scrolling, resizing, DOM changes, the anchor moving or
// changing size, a candidate anchor coming into or out of view. Only a slow
// move the anchor: scrolling anywhere (the window or any scroll container),
// resizing, DOM changes, the anchor or its scroll containers moving or
// changing size, a candidate anchor coming into or out of view. Events only
// ask for a measure; the frame loop runs one per frame, before the frame's
// render, so the mark moves in the same frame as the page. Only a slow
// fallback poll runs on a timer.
useLayoutEffect(() => {
if (!mounted) return;
const onChange = () => measure();
const onFrame = () => measure();
const schedule = () => frame.read(onFrame);
let settle = 0;
const onMutation = () => {
window.clearTimeout(settle);
settle = window.setTimeout(measure, MUTATION_SETTLE_MS);
settle = window.setTimeout(schedule, MUTATION_SETTLE_MS);
};
const measureIfVisible = () => {
if (document.visibilityState === "visible") measure();
};
window.addEventListener("resize", onChange);
document.addEventListener("scroll", onChange, {
window.addEventListener("resize", schedule);
document.addEventListener("scroll", schedule, {
capture: true,
passive: true,
});
document.addEventListener("visibilitychange", measureIfVisible);
const resize = new ResizeObserver(onChange);
const resize = new ResizeObserver(schedule);
if (cardRef.current) resize.observe(cardRef.current);
const mutations = new MutationObserver(onMutation);
mutations.observe(document.body, { childList: true, subtree: true });
// Candidates crossing the "mostly visible" line (0.6) change the anchor.
const candidates = new IntersectionObserver(onChange, {
const candidates = new IntersectionObserver(schedule, {
threshold: [0.5, 0.6, 0.7, 1],
});
let watchedCandidates: Element[] = [];
let followed: HTMLElement | null = null;
let containers: HTMLElement[] = [];
let stopMoves: (() => void) | null = null;
afterMeasureRef.current = () => {
const anchor = anchorRef.current;
if (anchor !== followed) {
if (followed) resize.unobserve(followed);
for (const container of containers) resize.unobserve(container);
stopMoves?.();
stopMoves = null;
followed = anchor;
containers = anchor ? clippingAncestors(anchor) : [];
if (anchor) {
resize.observe(anchor);
stopMoves = watchMoves(anchor, onChange);
for (const container of containers) resize.observe(container);
stopMoves = watchMoves(anchor, schedule);
}
}
const selector = latest.current.anchors.map(anchorSelector).join(",");
@@ -562,9 +738,10 @@ export function CoachMark({
const poll = window.setInterval(measureIfVisible, FALLBACK_POLL_MS);
return () => {
window.clearInterval(poll);
cancelFrame(onFrame);
afterMeasureRef.current = null;
window.removeEventListener("resize", onChange);
document.removeEventListener("scroll", onChange, { capture: true });
window.removeEventListener("resize", schedule);
document.removeEventListener("scroll", schedule, { capture: true });
document.removeEventListener("visibilitychange", measureIfVisible);
resize.disconnect();
mutations.disconnect();
@@ -659,6 +836,7 @@ export function CoachMark({
{spotlight && (
<svg
aria-hidden="true"
data-coach-layer=""
data-slot="coach-mark-spotlight"
className="pointer-events-none fixed inset-0 z-[70] h-full w-full"
style={{ visibility }}
@@ -683,6 +861,7 @@ export function CoachMark({
{anchored && (
<svg
aria-hidden="true"
data-coach-layer=""
data-slot="coach-mark-ring"
className="pointer-events-none fixed inset-0 z-[71] h-full w-full overflow-visible"
>
@@ -711,6 +890,7 @@ export function CoachMark({
{anchored && (
<motion.div
aria-hidden="true"
data-coach-layer=""
data-slot="coach-mark-beacon"
className="pointer-events-none fixed top-0 left-0 z-[72] -mt-1.5 -ml-1.5 size-3"
style={{ x: beaconX, y: beaconY }}
@@ -740,6 +920,7 @@ export function CoachMark({
)}
<motion.div
data-coach-layer=""
className="pointer-events-none fixed top-0 left-0 z-[73]"
style={{ x: cardX, y: cardY, visibility }}
>
+175
View File
@@ -3,8 +3,10 @@ import test from "node:test";
import {
anchorSelector,
COACH_ANCHORS,
clipArea,
isRailAnchor,
mostlyVisible,
pickAnchor,
placeCoachMark,
visiblePart,
} from "./coach-mark.ts";
@@ -81,6 +83,179 @@ test("an anchor scrolled mostly out of its container does not count", () => {
);
});
const NONE = { top: 0, right: 0, bottom: 0, left: 0 };
test("a scroll container shows its content inside borders, scrollbars and covered edges", () => {
const border = { x: 100, y: 50, width: 400, height: 300 };
// A 1px border all round and a 12px scrollbar on the right.
const client = { left: 1, top: 1, width: 386, height: 298 };
assert.deepEqual(clipArea(border, client, NONE), {
x: 101,
y: 51,
width: 386,
height: 298,
});
// A 32px sticky header and a 24px fade band at each end.
assert.deepEqual(
clipArea(border, client, { top: 56, right: 0, bottom: 24, left: 0 }),
{ x: 101, y: 107, width: 386, height: 218 },
);
assert.deepEqual(
clipArea(border, { left: 0, top: 0, width: 0, height: 0 }, NONE),
border,
"an inline element clips at its border box",
);
});
test("a row under the sticky header or in the fade band does not count", () => {
const table = clipArea(
{ x: 0, y: 100, width: 600, height: 400 },
{ left: 0, top: 0, width: 600, height: 400 },
{ top: 56, right: 0, bottom: 24, left: 0 },
);
const clips = [{ x: 0, y: 0, width: 1000, height: 700 }, table];
const button = (y) => ({ x: 560, y, width: 28, height: 28 });
assert.equal(mostlyVisible(button(104), clips), false, "under the header");
assert.equal(mostlyVisible(button(140), clips), false, "in the fade band");
assert.equal(mostlyVisible(button(170), clips), true);
assert.equal(mostlyVisible(button(460), clips), false, "in the bottom fade");
assert.equal(mostlyVisible(button(800), clips), false, "off the window");
});
/** One info button per row, 36px apart, from the top of the table. */
function rows(count, top = 0) {
return Array.from({ length: count }, (_, index) => ({
id: "row-inspect",
item: `row-${index}`,
box: { x: 560, y: top + index * 36, width: 28, height: 28 },
}));
}
const RAIL = {
id: "rail-profiles",
item: "rail",
box: { x: 6, y: 60, width: 28, height: 28 },
};
const insideTable = (candidate) =>
candidate.id !== "row-inspect" ||
(candidate.box.y >= 156 && candidate.box.y + candidate.box.height <= 600);
test("the first anchor that shows wins, best first, in page order", () => {
const candidates = [...rows(20, 100), RAIL];
const found = pickAnchor(
["row-inspect", "rail-profiles"],
candidates,
insideTable,
() => undefined,
);
assert.equal(found.item, "row-2", "rows 0 and 1 are under the header");
assert.equal(
pickAnchor(
["row-inspect", "rail-profiles"],
[...rows(20, 1000), RAIL],
insideTable,
() => undefined,
).item,
"rail",
"with every row scrolled away, the rail item takes the mark",
);
assert.equal(
pickAnchor(["column-dns"], candidates, insideTable, () => undefined),
null,
"with no anchor at all there is nowhere to point",
);
});
test("the followed row keeps the mark while it shows, and the nearest row takes over", () => {
const anchors = ["row-inspect", "rail-profiles"];
const at = (item, y) => ({ item, box: { x: 560, y, width: 28, height: 28 } });
// A short scroll: row 8 moved but still shows, so the mark stays on it.
assert.equal(
pickAnchor(anchors, [...rows(20, 0), RAIL], insideTable, () =>
at("row-8", 388),
).item,
"row-8",
);
// Scrolled down: row 8 went under the header, or unmounted. The row
// nearest to where it was takes the mark, not the first row that shows.
const down = [...rows(40, -300), RAIL];
assert.equal(
pickAnchor(anchors, down, insideTable, () => at("row-8", 160)).item,
"row-13",
);
assert.equal(
pickAnchor(
anchors,
down.filter((candidate) => candidate.item !== "row-8"),
insideTable,
() => at("row-8", 160),
).item,
"row-13",
);
// Scrolled up: row 8 left at the bottom, and the row above it takes over.
assert.equal(
pickAnchor(anchors, [...rows(40, 300), RAIL], insideTable, () =>
at("row-8", 570),
).item,
"row-7",
);
});
test("the card stays inside the part of its scroll container that shows", () => {
const area = { x: 200, y: 156, width: 600, height: 420 };
const anchor = { x: 760, y: 170, width: 36, height: 36 };
const placed = placeCoachMark(anchor, CARD, area, { side: "bottom" });
assert.equal(placed.fits, true);
assert.equal(placed.side, "bottom");
assert.ok(
placed.x >= area.x + 8 && placed.x + CARD.width <= area.x + area.width - 8,
);
assert.ok(placed.y + CARD.height <= area.y + area.height - 8);
// Near the bottom of the table it flips above, still inside.
const low = { x: 400, y: 520, width: 36, height: 36 };
const flipped = placeCoachMark(low, CARD, area, { side: "bottom" });
assert.equal(flipped.side, "top");
assert.ok(flipped.y >= area.y + 8);
});
test("a card that fits never covers its anchor", () => {
const area = { x: 200, y: 156, width: 600, height: 420 };
const overlaps = (a, b) =>
a.x < b.x + b.width &&
b.x < a.x + a.width &&
a.y < b.y + b.height &&
b.y < a.y + a.height;
for (let y = 160; y <= 540; y += 20) {
for (let x = 204; x <= 760; x += 37) {
const anchor = { x, y, width: 36, height: 36 };
for (const side of ["top", "right", "bottom", "left"]) {
const placed = placeCoachMark(anchor, CARD, area, { side });
if (!placed.fits) continue;
assert.ok(
!overlaps(anchor, { ...CARD, x: placed.x, y: placed.y }),
`anchor ${x},${y} preferring ${side}`,
);
}
}
}
});
test("a container too small for the card does not count as room", () => {
const strip = { x: 0, y: 0, width: 56, height: 700 };
const rail = { x: 14, y: 300, width: 28, height: 28 };
assert.equal(
placeCoachMark(rail, CARD, strip, { side: "right" }).fits,
false,
"the rail is narrower than the card, so the window places it",
);
const short = { x: 0, y: 0, width: 1000, height: 150 };
const head = { x: 400, y: 10, width: 100, height: 30 };
assert.equal(
placeCoachMark(head, CARD, short, { side: "right" }).fits,
false,
"beside the anchor, the card must also fit the area's height",
);
});
test("anchor ids are unique and map to their attribute", () => {
assert.equal(new Set(COACH_ANCHORS).size, COACH_ANCHORS.length);
assert.equal(anchorSelector("rail-more"), '[data-tip-anchor="rail-more"]');
+126 -27
View File
@@ -96,31 +96,37 @@ function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), Math.max(min, max));
}
/** The room between the anchor and the viewport edge on one side. */
function room(anchor: Box, viewport: Size, side: Side): number {
/** The room between the anchor and the edge of the area on one side. */
function room(anchor: Box, area: Box, side: Side): number {
switch (side) {
case "top":
return anchor.y;
return anchor.y - area.y;
case "bottom":
return viewport.height - (anchor.y + anchor.height);
return area.y + area.height - (anchor.y + anchor.height);
case "left":
return anchor.x;
return anchor.x - area.x;
case "right":
return viewport.width - (anchor.x + anchor.width);
return area.x + area.width - (anchor.x + anchor.width);
}
}
function toArea(area: Box | Size): Box {
return "x" in area ? area : { x: 0, y: 0, ...area };
}
/**
* Puts a card of `card` size next to `anchor`, `gap` away, inside the
* viewport with `margin` to spare. The preferred side wins when the card
* fits there; then the opposite side, then the two others. Along the edge the
* card centers on the anchor and slides to stay on screen, and `arrow` keeps
* pointing at the anchor's middle, never closer than `arrowInset` to a corner.
* Puts a card of `card` size next to `anchor`, `gap` away, inside `bounds`
* (the viewport, or the part of a scroll container that shows) with `margin`
* to spare. The preferred side wins when the card fits there; then the
* opposite side, then the two others. Along the edge the card centers on the
* anchor and slides to stay inside, and `arrow` keeps pointing at the
* anchor's middle, never closer than `arrowInset` to a corner. A card that
* fits never covers its anchor.
*/
export function placeCoachMark(
anchor: Box,
card: Size,
viewport: Size,
bounds: Box | Size,
{
side: preferred = "bottom",
gap = 12,
@@ -133,6 +139,7 @@ export function placeCoachMark(
arrowInset?: number;
} = {},
): Placement {
const area = toArea(bounds);
const order: Side[] = [
preferred,
OPPOSITE[preferred],
@@ -142,43 +149,48 @@ export function placeCoachMark(
(side === "top" || side === "bottom" ? card.height : card.width) +
gap +
margin;
const across = (side: Side) =>
(side === "top" || side === "bottom" ? card.width : card.height) +
margin * 2;
const breadth = (side: Side) =>
side === "top" || side === "bottom" ? area.width : area.height;
const chosen =
order.find((side) => room(anchor, viewport, side) >= needs(side)) ?? null;
order.find(
(side) =>
room(anchor, area, side) >= needs(side) &&
breadth(side) >= across(side),
) ?? null;
const side =
chosen ??
order.reduce((best, side) =>
room(anchor, viewport, side) - needs(side) >
room(anchor, viewport, best) - needs(best)
room(anchor, area, side) - needs(side) >
room(anchor, area, best) - needs(best)
? side
: best,
);
const left = area.x + margin;
const top = area.y + margin;
const right = area.x + area.width - margin;
const bottom = area.y + area.height - margin;
const centerX = anchor.x + anchor.width / 2;
const centerY = anchor.y + anchor.height / 2;
let x: number;
let y: number;
if (side === "top" || side === "bottom") {
x = clamp(
centerX - card.width / 2,
margin,
viewport.width - margin - card.width,
);
x = clamp(centerX - card.width / 2, left, right - card.width);
y =
side === "bottom"
? anchor.y + anchor.height + gap
: anchor.y - gap - card.height;
y = clamp(y, margin, viewport.height - margin - card.height);
y = clamp(y, top, bottom - card.height);
} else {
y = clamp(
centerY - card.height / 2,
margin,
viewport.height - margin - card.height,
);
y = clamp(centerY - card.height / 2, top, bottom - card.height);
x =
side === "right"
? anchor.x + anchor.width + gap
: anchor.x - gap - card.width;
x = clamp(x, margin, viewport.width - margin - card.width);
x = clamp(x, left, right - card.width);
}
const along = side === "top" || side === "bottom";
@@ -219,3 +231,90 @@ export function mostlyVisible(box: Box, clips: readonly Box[]): boolean {
if (!part || box.width <= 0 || box.height <= 0) return false;
return (part.width * part.height) / (box.width * box.height) >= 0.6;
}
export interface Insets {
top: number;
right: number;
bottom: number;
left: number;
}
/**
* The part of a clipping element that shows its content: the box inside its
* borders and scrollbars (`client*`), less the edges where sticky chrome or a
* fade covers what scrolls under them.
*/
export function clipArea(
border: Box,
client: { left: number; top: number; width: number; height: number },
covered: Insets,
): Box {
// An inline element has no client box; its border box is all there is.
const inner =
client.width > 0 || client.height > 0
? {
x: border.x + client.left,
y: border.y + client.top,
width: client.width,
height: client.height,
}
: border;
return {
x: inner.x + covered.left,
y: inner.y + covered.top,
width: Math.max(0, inner.width - covered.left - covered.right),
height: Math.max(0, inner.height - covered.top - covered.bottom),
};
}
export interface AnchorCandidate<T> {
id: CoachAnchorId;
item: T;
box: Box;
}
function distance(a: Box, b: Box): number {
return Math.hypot(
a.x + a.width / 2 - (b.x + b.width / 2),
a.y + a.height / 2 - (b.y + b.height / 2),
);
}
/**
* Picks where a coach mark points. Anchor ids go best first; the first id
* with an element that shows wins. Among the elements of one id (a button on
* every row), the one the mark followed last stays while it shows; when it
* scrolls away or unmounts, the nearest one to where it was takes over, so
* the mark moves a row and does not jump across the table. With nothing of
* that id followed before, the first element in page order wins.
*/
export function pickAnchor<T>(
anchors: readonly CoachAnchorId[],
candidates: readonly AnchorCandidate<T>[],
shows: (candidate: AnchorCandidate<T>) => boolean,
recall: (id: CoachAnchorId) => { item: T; box: Box } | undefined,
): AnchorCandidate<T> | null {
for (const id of anchors) {
const own = candidates.filter((candidate) => candidate.id === id);
const last = recall(id);
if (!last) {
const first = own.find(shows);
if (first) return first;
continue;
}
const same = own.find((candidate) => candidate.item === last.item);
if (same && shows(same)) return same;
const nearest = own
.filter((candidate) => candidate !== same)
.map((candidate, index) => ({
candidate,
index,
away: distance(candidate.box, last.box),
}))
.sort((a, b) => a.away - b.away || a.index - b.index);
for (const { candidate } of nearest) {
if (shows(candidate)) return candidate;
}
}
return null;
}