From 0e1c1dd469dba031cf631ef439653f44729e6ce4 Mon Sep 17 00:00:00 2001 From: Hosmel Quintana Date: Wed, 4 Nov 2026 14:44:46 -0600 Subject: [PATCH 1/2] fix: light up the commit under a still pointer after scrolling Lane dimming only followed pointer moves, so after a wheel or trackpad scroll the commit under a still pointer stayed unlit until the mouse moved. Once such a scroll settles, the commit under the pointer now lights up, while keyboard and programmatic scrolls leave the log alone. The highlight now lives in a small store each commit subscribes to, so moving between commits re-renders the two commits that change instead of the whole log. The hover background and the graph lines follow the same state and the same 150ms ease-out timing as the dimming. The lit commit switches off by where the mouse actually is, so late or missing leave events no longer keep it lit or switch it off. --- resources/js/components/commit-card.tsx | 20 +- resources/js/components/commit-feed.tsx | 325 ++++++++++++++++++----- resources/js/components/graph-canvas.tsx | 2 +- resources/js/components/graph-rail.tsx | 6 +- resources/js/lib/feed-hover.ts | 49 ++++ tests/Unit/FeedHover.test.ts | 51 +++- 6 files changed, 372 insertions(+), 81 deletions(-) diff --git a/resources/js/components/commit-card.tsx b/resources/js/components/commit-card.tsx index edd740d31f..640e7feb82 100644 --- a/resources/js/components/commit-card.tsx +++ b/resources/js/components/commit-card.tsx @@ -31,6 +31,7 @@ import { RailAvatar, RailDot, RailSegment, + dimmable, railDotRing, } from '@/components/graph-rail'; import { HiddenLinkPreviewContext } from '@/components/hidden-link-preview-context'; @@ -70,6 +71,7 @@ export function CommitCard({ previousLane, isDimmed = false, isPreviousDimmed = false, + isHovered = false, isHighlighted = false, isGroupedWithPrevious = false, onAuthorHover, @@ -96,6 +98,7 @@ export function CommitCard({ graphAfter?: string; isDimmed?: boolean; isPreviousDimmed?: boolean; + isHovered?: boolean; isHighlighted?: boolean; isGroupedWithPrevious?: boolean; onAuthorHover?: (isHovering: boolean) => void; @@ -257,9 +260,11 @@ export function CommitCard({
diff --git a/resources/js/components/commit-feed.tsx b/resources/js/components/commit-feed.tsx index 32a573ab0a..909e99b055 100644 --- a/resources/js/components/commit-feed.tsx +++ b/resources/js/components/commit-feed.tsx @@ -1,7 +1,14 @@ import { InfiniteScroll, router, usePage } from '@inertiajs/react'; import { Pin } from 'lucide-react'; import type { PointerEvent, ReactNode } from 'react'; -import { Fragment, useEffect, useRef, useState } from 'react'; +import { + Fragment, + useEffect, + useEffectEvent, + useRef, + useState, + useSyncExternalStore, +} from 'react'; import { CommitCard } from '@/components/commit-card'; import { GraphCanvas } from '@/components/graph-canvas'; import { GraphRail, RailSegment } from '@/components/graph-rail'; @@ -10,12 +17,25 @@ import { restoreFeedCommit, updateQuotedCommits, } from '@/lib/commit-mutation-options'; -import { isDeliberateHover } from '@/lib/feed-hover'; +import type { FeedHighlightStore } from '@/lib/feed-hover'; +import { + createFeedHighlight, + isDeliberateHover, + isDimmedBy, +} from '@/lib/feed-hover'; import { authorLaneColor, cn, formatDayLabel, localDayKey } from '@/lib/utils'; import type { Commit, CursorPaginated } from '@/types'; const interludeAfter = 5; +function commitCardAt(point: { x: number; y: number } | null) { + return point + ? document + .elementFromPoint(point.x, point.y) + ?.closest('[data-commit]') + : null; +} + export function CommitFeed({ commits, empty, @@ -48,7 +68,6 @@ export function CommitFeed({ }) { const now = useNow(); const page = usePage(); - const { dimsLanes } = page.props; const isModeration = page.component === 'moderation/show'; const isStarred = page.component === 'starred/index'; const profileUserId = @@ -62,48 +81,158 @@ export function CommitFeed({ page.component === 'organization/show' ? (page.props.organization as { id: string }).id : null; - const [highlightedAuthor, setHighlightedAuthor] = useState( - null, - ); - const [highlightedCommit, setHighlightedCommit] = useState( - null, - ); + const [highlight] = useState(createFeedHighlight); const unhighlightTimer = useRef>(undefined); const isScrolling = useRef(false); const authorUnderPointer = useRef(null); + const pointer = useRef<{ x: number; y: number } | null>(null); + const litCard = useRef(null); + const isPointerOnCommit = useRef(false); + const commitMove = useRef(null); + const listedIds = useRef(new Set()); + + // Crossing a day marker between two commits shouldn't flash the whole feed back on. A commit that scrolled away + // can report leaving after the one under the still pointer lit up, so only a pointer off the lit commit clears it. + const unhighlightSoon = () => { + clearTimeout(unhighlightTimer.current); + unhighlightTimer.current = setTimeout(() => { + if (commitCardAt(pointer.current) !== litCard.current) { + highlight.set({ commitId: null }); + } + }, 150); + }; + + // A menu closing under the pointer, or a deleted commit, never reports a leave, so a move that reaches no commit + // counts as leaving them. + const leaveCommits = useEffectEvent(() => { + if (isPointerOnCommit.current) { + isPointerOnCommit.current = false; + unhighlightSoon(); + } + }); useEffect(() => () => clearTimeout(unhighlightTimer.current), []); useEffect(() => { let settleTimer: ReturnType | undefined; + let settleFrame = 0; + let lastWheelAt = -Infinity; + let isWheelScroll = false; + + // Once a wheel scroll stops, the commit resting under the still pointer lights up as if the pointer had moved + // onto it. Keyboard scrolling leaves the feed alone, since the pointer isn't what the reader is following. + const highlightUnderPointer = () => { + const card = commitCardAt(pointer.current); + const commitId = card?.dataset.commit; + + if (card && commitId && listedIds.current.has(commitId)) { + litCard.current = card; + isPointerOnCommit.current = true; + highlight.set({ commitId, authorId: null }); + } + }; const unhighlight = () => { + const now = performance.now(); + + // A smooth wheel or a fling keeps scrolling after its last wheel event, and a long render can split it in + // two, so a scroll that starts within a second of wheel scrolling continues it. + if (!isScrolling.current) { + isWheelScroll = now - lastWheelAt < 1000; + } + + if (isWheelScroll) { + lastWheelAt = now; + } + isScrolling.current = true; clearTimeout(unhighlightTimer.current); - setHighlightedCommit(null); - setHighlightedAuthor(null); + highlight.set({ commitId: null, authorId: null }); clearTimeout(settleTimer); + cancelAnimationFrame(settleFrame); + // A long render can hold back the scroll events of a frame past the delay, and a frame dispatches them before + // its animation callbacks, so a scroll still under way cancels the settle. settleTimer = setTimeout(() => { - isScrolling.current = false; + settleFrame = requestAnimationFrame(() => { + isScrolling.current = false; + + if (isWheelScroll) { + highlightUnderPointer(); + } + }); }, 300); }; + const trackWheel = (event: WheelEvent) => { + if (event.ctrlKey || event.deltaY === 0) { + return; + } + + lastWheelAt = performance.now(); + isWheelScroll ||= isScrolling.current; + }; + + // A key or a press can start a scroll of its own, such as a shortcut or a button that scrolls to the top. + const forgetWheel = ( + event: KeyboardEvent | globalThis.PointerEvent, + ) => { + if ( + 'key' in event && + ['Alt', 'Control', 'Meta', 'Shift'].includes(event.key) + ) { + return; + } + + lastWheelAt = -Infinity; + isWheelScroll = false; + }; + + const trackPointer = (event: globalThis.PointerEvent) => { + if (event.pointerType !== 'mouse') { + return; + } + + pointer.current = { x: event.clientX, y: event.clientY }; + + if (event !== commitMove.current) { + leaveCommits(); + } + }; + + const forgetPointer = (event: globalThis.PointerEvent) => { + if (event.pointerType === 'mouse' && event.relatedTarget === null) { + pointer.current = null; + leaveCommits(); + } + }; + window.addEventListener('scroll', unhighlight, { passive: true }); + window.addEventListener('wheel', trackWheel, { passive: true }); + window.addEventListener('keydown', forgetWheel); + window.addEventListener('pointerdown', forgetWheel); + window.addEventListener('pointermove', trackPointer, { passive: true }); + document.addEventListener('pointerout', forgetPointer); return () => { window.removeEventListener('scroll', unhighlight); + window.removeEventListener('wheel', trackWheel); + window.removeEventListener('keydown', forgetWheel); + window.removeEventListener('pointerdown', forgetWheel); + window.removeEventListener('pointermove', trackPointer); + document.removeEventListener('pointerout', forgetPointer); clearTimeout(settleTimer); + cancelAnimationFrame(settleFrame); }; - }, []); + }, [highlight]); const highlightAuthor = (userId: string, isHovering: boolean) => { authorUnderPointer.current = isHovering ? userId : null; if (!isHovering) { - setHighlightedAuthor(null); + highlight.set({ authorId: null }); } else if (!isScrolling.current) { - setHighlightedAuthor(userId); + highlight.set({ authorId: userId }); } }; @@ -113,23 +242,28 @@ export function CommitFeed({ event: PointerEvent, ) => { if (isHovering) { + commitMove.current = event.nativeEvent; + isPointerOnCommit.current = true; + if (isDeliberateHover(event, isScrolling.current)) { clearTimeout(unhighlightTimer.current); - setHighlightedCommit(commitId); + litCard.current = event.currentTarget; // Author targets only report entering, so one that slid under the pointer while scrolling lights up on the next move. - setHighlightedAuthor(authorUnderPointer.current); + highlight.set({ + commitId, + authorId: authorUnderPointer.current, + }); } return; } - clearTimeout(unhighlightTimer.current); + if (event.pointerType !== 'mouse') { + return; + } - // Crossing a day marker between two commits shouldn't flash the whole feed back on. - unhighlightTimer.current = setTimeout( - () => setHighlightedCommit(null), - 150, - ); + isPointerOnCommit.current = false; + unhighlightSoon(); }; useEffect( @@ -200,41 +334,55 @@ export function CommitFeed({ return true; }); + useEffect(() => { + listedIds.current = new Set( + [...listed, ...(pinnedCommit ? [pinnedCommit] : [])].map( + (commit) => commit.id, + ), + ); + + const { commitId } = highlight.get(); + + if (commitId !== null && !listedIds.current.has(commitId)) { + authorUnderPointer.current = null; + highlight.set({ commitId: null, authorId: null }); + } + }); + if (listed.length === 0 && pinnedCommit === null) { return
{empty}
; } - const isDimmed = (commit: Commit) => - dimsLanes && - (highlightedAuthor !== null - ? commit.author.id !== highlightedAuthor - : highlightedCommit !== null && commit.id !== highlightedCommit); - const lastCommit = listed.at(-1) ?? pinnedCommit; const interludeIndex = Math.min(interludeAfter, listed.length) - 1; // Feed commits are unrelated to each other, so each one stands on its own with its author's avatar, even when the // same author committed several in a row. const card = (commit: Commit) => ( - - highlightAuthor(commit.author.id, isHovering) - } - onHover={(isHovering, event) => - highlightCommit(commit.id, isHovering, event) - } - /> + + {({ isDimmed, isHovered }) => ( + + highlightAuthor(commit.author.id, isHovering) + } + onHover={(isHovering, event) => + highlightCommit(commit.id, isHovering, event) + } + /> + )} + ); return ( @@ -248,21 +396,30 @@ export function CommitFeed({ Pinned

- - highlightCommit(pinnedCommit.id, isHovering, event) - } - isHighlighted={ - highlightedPeriod !== null && - pinnedCommit.created_at.startsWith( - highlightedPeriod, - ) - } - threadActions={threadActions?.(pinnedCommit)} - /> + + {({ isDimmed, isHovered }) => ( + + highlightCommit( + pinnedCommit.id, + isHovering, + event, + ) + } + isHighlighted={ + highlightedPeriod !== null && + pinnedCommit.created_at.startsWith( + highlightedPeriod, + ) + } + threadActions={threadActions?.(pinnedCommit)} + /> + )} + )} )} {card(commit)} - {index === interludeIndex && - interlude?.( - authorLaneColor(commit.author), - isDimmed(commit), - )} + {index === interludeIndex && interlude && ( + + {({ isDimmed }) => + interlude( + authorLaneColor(commit.author), + isDimmed, + ) + } + + )} ); })} @@ -382,6 +547,30 @@ function LastVisitMarker({ stagedLane }: { stagedLane?: string }) { ); } +function Highlighted({ + highlight, + commit, + children, +}: { + highlight: FeedHighlightStore; + commit: Commit; + children: (state: { isDimmed: boolean; isHovered: boolean }) => ReactNode; +}) { + const { dimsLanes } = usePage().props; + const isDimmed = useSyncExternalStore( + highlight.subscribe, + () => dimsLanes && isDimmedBy(highlight.get(), commit), + () => false, + ); + const isHovered = useSyncExternalStore( + highlight.subscribe, + () => highlight.get().commitId === commit.id, + () => false, + ); + + return children({ isDimmed, isHovered }); +} + export function CommitSkeleton() { return (
diff --git a/resources/js/components/graph-canvas.tsx b/resources/js/components/graph-canvas.tsx index 0dcd83cc8a..e6b573ea70 100644 --- a/resources/js/components/graph-canvas.tsx +++ b/resources/js/components/graph-canvas.tsx @@ -815,7 +815,7 @@ export function GraphCanvas({ children }: { children: ReactNode }) { (left?.opacity ?? 1) } clipPath={clipOf(left?.clip ?? -1)} - className="transition-opacity duration-200" + className="transition-opacity duration-150 ease-out" strokeWidth={strokeWidth} strokeDasharray={ edge.dashed ? dash : undefined diff --git a/resources/js/components/graph-rail.tsx b/resources/js/components/graph-rail.tsx index 0953fd444f..55bce0e90e 100644 --- a/resources/js/components/graph-rail.tsx +++ b/resources/js/components/graph-rail.tsx @@ -43,7 +43,7 @@ type Props = { }; }; -const dimmable = 'transition-opacity duration-200'; +export const dimmable = 'transition-opacity duration-150 ease-out'; const stagedDash = 4; @@ -380,7 +380,7 @@ export function RailDot({ ; + +/** + * Holds what the pointer highlights outside React state, so each commit re-renders only when its own look changes + * instead of the whole feed re-rendering on every move between commits. + */ +export function createFeedHighlight() { + let highlight: FeedHighlight = { commitId: null, authorId: null }; + const listeners = new Set<() => void>(); + + return { + get: (): FeedHighlight => highlight, + set: (next: Partial) => { + const updated = { ...highlight, ...next }; + + if ( + updated.commitId === highlight.commitId && + updated.authorId === highlight.authorId + ) { + return; + } + + highlight = updated; + listeners.forEach((listener) => listener()); + }, + subscribe: (listener: () => void) => { + listeners.add(listener); + + return () => { + listeners.delete(listener); + }; + }, + }; +} diff --git a/tests/Unit/FeedHover.test.ts b/tests/Unit/FeedHover.test.ts index ddc8b359a6..2c101a4c52 100644 --- a/tests/Unit/FeedHover.test.ts +++ b/tests/Unit/FeedHover.test.ts @@ -1,6 +1,10 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { isDeliberateHover } from '../../resources/js/lib/feed-hover.ts'; +import { + createFeedHighlight, + isDeliberateHover, + isDimmedBy, +} from '../../resources/js/lib/feed-hover.ts'; await test('moving the pointer over a commit highlights it', () => { assert.equal( @@ -26,3 +30,48 @@ await test('nudging the pointer while the feed scrolls does not highlight', () = false, ); }); + +await test('pointing at an author dims everyone else, even the commit under the pointer', () => { + const commit = { id: 'a', author: { id: 'nuno' } }; + const other = { id: 'b', author: { id: 'taylor' } }; + const highlight = { commitId: 'b', authorId: 'nuno' }; + + assert.equal(isDimmedBy(highlight, commit), false); + assert.equal(isDimmedBy(highlight, other), true); +}); + +await test('pointing at a commit dims the rest of the feed', () => { + const highlight = { commitId: 'a', authorId: null }; + + assert.equal( + isDimmedBy(highlight, { id: 'a', author: { id: 'x' } }), + false, + ); + assert.equal(isDimmedBy(highlight, { id: 'b', author: { id: 'x' } }), true); + assert.equal( + isDimmedBy( + { commitId: null, authorId: null }, + { id: 'b', author: { id: 'x' } }, + ), + false, + ); +}); + +await test('commits are only told about highlights that change something', () => { + const highlight = createFeedHighlight(); + let notified = 0; + const unsubscribe = highlight.subscribe(() => notified++); + + highlight.set({ commitId: 'a' }); + highlight.set({ commitId: 'a' }); + highlight.set({ authorId: null }); + assert.equal(notified, 1); + + highlight.set({ commitId: 'b', authorId: 'nuno' }); + assert.deepEqual(highlight.get(), { commitId: 'b', authorId: 'nuno' }); + assert.equal(notified, 2); + + unsubscribe(); + highlight.set({ commitId: null }); + assert.equal(notified, 2); +}); From 84a49d6de9644fe2a5c44bd321340406087aadc8 Mon Sep 17 00:00:00 2001 From: Hosmel Quintana Date: Wed, 4 Nov 2026 17:48:30 -0600 Subject: [PATCH 2/2] revert: drop the post-scroll highlight, keep only the performance part The log waits for a pointer move again before it dims after a scroll, as the maintainers want, so the commit under a still pointer no longer lights up and the wheel, key and pointer tracking it needed is gone. The hover background, the dimming and the graph lines go back to their production timing as well. The highlight store stays, so moving between commits still re-renders only the commits whose dimming changes instead of the whole log. --- resources/js/components/commit-card.tsx | 20 ++- resources/js/components/commit-feed.tsx | 174 ++--------------------- resources/js/components/graph-canvas.tsx | 2 +- resources/js/components/graph-rail.tsx | 6 +- resources/js/lib/feed-hover.ts | 2 +- 5 files changed, 25 insertions(+), 179 deletions(-) diff --git a/resources/js/components/commit-card.tsx b/resources/js/components/commit-card.tsx index 640e7feb82..edd740d31f 100644 --- a/resources/js/components/commit-card.tsx +++ b/resources/js/components/commit-card.tsx @@ -31,7 +31,6 @@ import { RailAvatar, RailDot, RailSegment, - dimmable, railDotRing, } from '@/components/graph-rail'; import { HiddenLinkPreviewContext } from '@/components/hidden-link-preview-context'; @@ -71,7 +70,6 @@ export function CommitCard({ previousLane, isDimmed = false, isPreviousDimmed = false, - isHovered = false, isHighlighted = false, isGroupedWithPrevious = false, onAuthorHover, @@ -98,7 +96,6 @@ export function CommitCard({ graphAfter?: string; isDimmed?: boolean; isPreviousDimmed?: boolean; - isHovered?: boolean; isHighlighted?: boolean; isGroupedWithPrevious?: boolean; onAuthorHover?: (isHovering: boolean) => void; @@ -260,11 +257,9 @@ export function CommitCard({
diff --git a/resources/js/components/commit-feed.tsx b/resources/js/components/commit-feed.tsx index 909e99b055..1e99d6a118 100644 --- a/resources/js/components/commit-feed.tsx +++ b/resources/js/components/commit-feed.tsx @@ -4,7 +4,6 @@ import type { PointerEvent, ReactNode } from 'react'; import { Fragment, useEffect, - useEffectEvent, useRef, useState, useSyncExternalStore, @@ -28,14 +27,6 @@ import type { Commit, CursorPaginated } from '@/types'; const interludeAfter = 5; -function commitCardAt(point: { x: number; y: number } | null) { - return point - ? document - .elementFromPoint(point.x, point.y) - ?.closest('[data-commit]') - : null; -} - export function CommitFeed({ commits, empty, @@ -85,144 +76,28 @@ export function CommitFeed({ const unhighlightTimer = useRef>(undefined); const isScrolling = useRef(false); const authorUnderPointer = useRef(null); - const pointer = useRef<{ x: number; y: number } | null>(null); - const litCard = useRef(null); - const isPointerOnCommit = useRef(false); - const commitMove = useRef(null); - const listedIds = useRef(new Set()); - - // Crossing a day marker between two commits shouldn't flash the whole feed back on. A commit that scrolled away - // can report leaving after the one under the still pointer lit up, so only a pointer off the lit commit clears it. - const unhighlightSoon = () => { - clearTimeout(unhighlightTimer.current); - unhighlightTimer.current = setTimeout(() => { - if (commitCardAt(pointer.current) !== litCard.current) { - highlight.set({ commitId: null }); - } - }, 150); - }; - - // A menu closing under the pointer, or a deleted commit, never reports a leave, so a move that reaches no commit - // counts as leaving them. - const leaveCommits = useEffectEvent(() => { - if (isPointerOnCommit.current) { - isPointerOnCommit.current = false; - unhighlightSoon(); - } - }); useEffect(() => () => clearTimeout(unhighlightTimer.current), []); useEffect(() => { let settleTimer: ReturnType | undefined; - let settleFrame = 0; - let lastWheelAt = -Infinity; - let isWheelScroll = false; - - // Once a wheel scroll stops, the commit resting under the still pointer lights up as if the pointer had moved - // onto it. Keyboard scrolling leaves the feed alone, since the pointer isn't what the reader is following. - const highlightUnderPointer = () => { - const card = commitCardAt(pointer.current); - const commitId = card?.dataset.commit; - - if (card && commitId && listedIds.current.has(commitId)) { - litCard.current = card; - isPointerOnCommit.current = true; - highlight.set({ commitId, authorId: null }); - } - }; const unhighlight = () => { - const now = performance.now(); - - // A smooth wheel or a fling keeps scrolling after its last wheel event, and a long render can split it in - // two, so a scroll that starts within a second of wheel scrolling continues it. - if (!isScrolling.current) { - isWheelScroll = now - lastWheelAt < 1000; - } - - if (isWheelScroll) { - lastWheelAt = now; - } - isScrolling.current = true; clearTimeout(unhighlightTimer.current); highlight.set({ commitId: null, authorId: null }); clearTimeout(settleTimer); - cancelAnimationFrame(settleFrame); - // A long render can hold back the scroll events of a frame past the delay, and a frame dispatches them before - // its animation callbacks, so a scroll still under way cancels the settle. settleTimer = setTimeout(() => { - settleFrame = requestAnimationFrame(() => { - isScrolling.current = false; - - if (isWheelScroll) { - highlightUnderPointer(); - } - }); + isScrolling.current = false; }, 300); }; - const trackWheel = (event: WheelEvent) => { - if (event.ctrlKey || event.deltaY === 0) { - return; - } - - lastWheelAt = performance.now(); - isWheelScroll ||= isScrolling.current; - }; - - // A key or a press can start a scroll of its own, such as a shortcut or a button that scrolls to the top. - const forgetWheel = ( - event: KeyboardEvent | globalThis.PointerEvent, - ) => { - if ( - 'key' in event && - ['Alt', 'Control', 'Meta', 'Shift'].includes(event.key) - ) { - return; - } - - lastWheelAt = -Infinity; - isWheelScroll = false; - }; - - const trackPointer = (event: globalThis.PointerEvent) => { - if (event.pointerType !== 'mouse') { - return; - } - - pointer.current = { x: event.clientX, y: event.clientY }; - - if (event !== commitMove.current) { - leaveCommits(); - } - }; - - const forgetPointer = (event: globalThis.PointerEvent) => { - if (event.pointerType === 'mouse' && event.relatedTarget === null) { - pointer.current = null; - leaveCommits(); - } - }; - window.addEventListener('scroll', unhighlight, { passive: true }); - window.addEventListener('wheel', trackWheel, { passive: true }); - window.addEventListener('keydown', forgetWheel); - window.addEventListener('pointerdown', forgetWheel); - window.addEventListener('pointermove', trackPointer, { passive: true }); - document.addEventListener('pointerout', forgetPointer); return () => { window.removeEventListener('scroll', unhighlight); - window.removeEventListener('wheel', trackWheel); - window.removeEventListener('keydown', forgetWheel); - window.removeEventListener('pointerdown', forgetWheel); - window.removeEventListener('pointermove', trackPointer); - document.removeEventListener('pointerout', forgetPointer); clearTimeout(settleTimer); - cancelAnimationFrame(settleFrame); }; }, [highlight]); @@ -242,12 +117,8 @@ export function CommitFeed({ event: PointerEvent, ) => { if (isHovering) { - commitMove.current = event.nativeEvent; - isPointerOnCommit.current = true; - if (isDeliberateHover(event, isScrolling.current)) { clearTimeout(unhighlightTimer.current); - litCard.current = event.currentTarget; // Author targets only report entering, so one that slid under the pointer while scrolling lights up on the next move. highlight.set({ commitId, @@ -258,12 +129,13 @@ export function CommitFeed({ return; } - if (event.pointerType !== 'mouse') { - return; - } + clearTimeout(unhighlightTimer.current); - isPointerOnCommit.current = false; - unhighlightSoon(); + // Crossing a day marker between two commits shouldn't flash the whole feed back on. + unhighlightTimer.current = setTimeout( + () => highlight.set({ commitId: null }), + 150, + ); }; useEffect( @@ -334,21 +206,6 @@ export function CommitFeed({ return true; }); - useEffect(() => { - listedIds.current = new Set( - [...listed, ...(pinnedCommit ? [pinnedCommit] : [])].map( - (commit) => commit.id, - ), - ); - - const { commitId } = highlight.get(); - - if (commitId !== null && !listedIds.current.has(commitId)) { - authorUnderPointer.current = null; - highlight.set({ commitId: null, authorId: null }); - } - }); - if (listed.length === 0 && pinnedCommit === null) { return
{empty}
; } @@ -360,12 +217,11 @@ export function CommitFeed({ // same author committed several in a row. const card = (commit: Commit) => ( - {({ isDimmed, isHovered }) => ( + {(isDimmed) => (
- {({ isDimmed, isHovered }) => ( + {(isDimmed) => ( highlightCommit( pinnedCommit.id, @@ -465,7 +320,7 @@ export function CommitFeed({ highlight={highlight} commit={commit} > - {({ isDimmed }) => + {(isDimmed) => interlude( authorLaneColor(commit.author), isDimmed, @@ -554,7 +409,7 @@ function Highlighted({ }: { highlight: FeedHighlightStore; commit: Commit; - children: (state: { isDimmed: boolean; isHovered: boolean }) => ReactNode; + children: (isDimmed: boolean) => ReactNode; }) { const { dimsLanes } = usePage().props; const isDimmed = useSyncExternalStore( @@ -562,13 +417,8 @@ function Highlighted({ () => dimsLanes && isDimmedBy(highlight.get(), commit), () => false, ); - const isHovered = useSyncExternalStore( - highlight.subscribe, - () => highlight.get().commitId === commit.id, - () => false, - ); - return children({ isDimmed, isHovered }); + return children(isDimmed); } export function CommitSkeleton() { diff --git a/resources/js/components/graph-canvas.tsx b/resources/js/components/graph-canvas.tsx index e6b573ea70..0dcd83cc8a 100644 --- a/resources/js/components/graph-canvas.tsx +++ b/resources/js/components/graph-canvas.tsx @@ -815,7 +815,7 @@ export function GraphCanvas({ children }: { children: ReactNode }) { (left?.opacity ?? 1) } clipPath={clipOf(left?.clip ?? -1)} - className="transition-opacity duration-150 ease-out" + className="transition-opacity duration-200" strokeWidth={strokeWidth} strokeDasharray={ edge.dashed ? dash : undefined diff --git a/resources/js/components/graph-rail.tsx b/resources/js/components/graph-rail.tsx index 55bce0e90e..0953fd444f 100644 --- a/resources/js/components/graph-rail.tsx +++ b/resources/js/components/graph-rail.tsx @@ -43,7 +43,7 @@ type Props = { }; }; -export const dimmable = 'transition-opacity duration-150 ease-out'; +const dimmable = 'transition-opacity duration-200'; const stagedDash = 4; @@ -380,7 +380,7 @@ export function RailDot({ ; /** - * Holds what the pointer highlights outside React state, so each commit re-renders only when its own look changes + * Holds what the pointer highlights outside React state, so each commit re-renders only when its dimming changes * instead of the whole feed re-rendering on every move between commits. */ export function createFeedHighlight() {