feat: Enhance scrolling behavior and styles across components; add centralized scroll utilities
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -7,6 +7,7 @@ export const Image = style([
|
|||||||
display: 'block',
|
display: 'block',
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
maxHeight: '100%',
|
maxHeight: '100%',
|
||||||
|
borderRadius: 'inherit',
|
||||||
imageRendering: 'auto',
|
imageRendering: 'auto',
|
||||||
backfaceVisibility: 'hidden',
|
backfaceVisibility: 'hidden',
|
||||||
transform: 'translateZ(0)',
|
transform: 'translateZ(0)',
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ export function MImage({ content, renderImageContent, outlined }: MImageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Attachment outlined={outlined}>
|
<Attachment outlined={outlined} transparent>
|
||||||
<AttachmentBox>
|
<AttachmentBox>
|
||||||
{renderImageContent({
|
{renderImageContent({
|
||||||
body: content.body || 'Image',
|
body: content.body || 'Image',
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export const RelativeBase = style([
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
maxHeight: '100%',
|
maxHeight: '100%',
|
||||||
|
borderRadius: '7px',
|
||||||
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -19,6 +21,8 @@ export const AbsoluteContainer = style([
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
maxHeight: '100%',
|
maxHeight: '100%',
|
||||||
|
borderRadius: 'inherit',
|
||||||
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../
|
|||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator';
|
import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator';
|
||||||
import { useAlive } from '../../hooks/useAlive';
|
import { useAlive } from '../../hooks/useAlive';
|
||||||
import { editableActiveElement, scrollToBottom } from '../../utils/dom';
|
import { autoScrollToBottom, editableActiveElement } from '../../utils/dom';
|
||||||
import {
|
import {
|
||||||
DefaultPlaceholder,
|
DefaultPlaceholder,
|
||||||
CompactPlaceholder,
|
CompactPlaceholder,
|
||||||
@@ -514,6 +514,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
const space = useSpaceOptionally();
|
const space = useSpaceOptionally();
|
||||||
const markAsRead = useMarkAsRead(mx);
|
const markAsRead = useMarkAsRead(mx);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const scrollToLatest = (location.state as { scrollToLatest?: boolean } | null)?.scrollToLatest;
|
||||||
|
|
||||||
const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents);
|
const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents);
|
||||||
|
|
||||||
@@ -530,16 +531,18 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
const atBottomRef = useRef(atBottom);
|
const atBottomRef = useRef(atBottom);
|
||||||
atBottomRef.current = atBottom;
|
atBottomRef.current = atBottom;
|
||||||
|
|
||||||
const shouldAutoScrollRef = useRef<boolean>(true);
|
|
||||||
const [latestUnreadMessage, setLatestUnreadMessage] = useState<{
|
const [latestUnreadMessage, setLatestUnreadMessage] = useState<{
|
||||||
sender: string;
|
sender: string;
|
||||||
content: string;
|
content: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const timelineContentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const timelineContentHeightRef = useRef(0);
|
||||||
const scrollToBottomRef = useRef({
|
const scrollToBottomRef = useRef({
|
||||||
count: 0,
|
count: 0,
|
||||||
smooth: true,
|
smooth: true,
|
||||||
|
force: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [focusItem, setFocusItem] = useState<
|
const [focusItem, setFocusItem] = useState<
|
||||||
@@ -639,6 +642,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
setTimeline(getInitialTimeline(room));
|
setTimeline(getInitialTimeline(room));
|
||||||
scrollToBottomRef.current.count += 1;
|
scrollToBottomRef.current.count += 1;
|
||||||
scrollToBottomRef.current.smooth = false;
|
scrollToBottomRef.current.smooth = false;
|
||||||
|
scrollToBottomRef.current.force = true;
|
||||||
}, [alive, room])
|
}, [alive, room])
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -647,52 +651,49 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
useCallback(
|
useCallback(
|
||||||
(mEvt: MatrixEvent) => {
|
(mEvt: MatrixEvent) => {
|
||||||
const isOwnMessage = mEvt.getSender() === mx.getUserId();
|
const isOwnMessage = mEvt.getSender() === mx.getUserId();
|
||||||
const shouldScroll = atBottomRef.current || (shouldAutoScrollRef.current && isOwnMessage);
|
const shouldStickToBottom = atBottomRef.current || isOwnMessage;
|
||||||
|
|
||||||
if (shouldScroll) {
|
// Always update timeline with new message
|
||||||
if (isOwnMessage) {
|
setTimeline((ct) => ({
|
||||||
shouldAutoScrollRef.current = true;
|
...ct,
|
||||||
}
|
range: {
|
||||||
|
start: ct.range.start + 1,
|
||||||
if (document.hasFocus() && (!unreadInfo || isOwnMessage)) {
|
end: ct.range.end + 1,
|
||||||
requestAnimationFrame(() => markAsRead(mEvt.getRoomId()!, hideActivity));
|
},
|
||||||
}
|
}));
|
||||||
|
|
||||||
if (!document.hasFocus() && !unreadInfo) {
|
|
||||||
setUnreadInfo(getRoomUnreadInfo(room));
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Handle own messages and focus
|
||||||
|
if (isOwnMessage) {
|
||||||
setLatestUnreadMessage(null);
|
setLatestUnreadMessage(null);
|
||||||
scrollToBottomRef.current.count += 1;
|
if (document.hasFocus() && (!unreadInfo)) {
|
||||||
scrollToBottomRef.current.smooth = true;
|
const roomId = mEvt.getRoomId();
|
||||||
|
if (roomId) {
|
||||||
setTimeline((ct) => ({
|
requestAnimationFrame(() => markAsRead(roomId, hideActivity));
|
||||||
...ct,
|
}
|
||||||
range: {
|
}
|
||||||
start: ct.range.start + 1,
|
} else if (!document.hasFocus() && !unreadInfo) {
|
||||||
end: ct.range.end + 1,
|
// Show unread notification for other users' messages when unfocused
|
||||||
},
|
setUnreadInfo(getRoomUnreadInfo(room));
|
||||||
}));
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isOwnMessage) {
|
// Preserve the pre-update bottom state so a newly added message keeps the view pinned.
|
||||||
shouldAutoScrollRef.current = false;
|
scrollToBottomRef.current.count += 1;
|
||||||
const senderName = getMemberDisplayName(room, mEvt.getSender() ?? '')
|
scrollToBottomRef.current.smooth = !isOwnMessage;
|
||||||
?? getMxIdLocalPart(mEvt.getSender() ?? '')
|
scrollToBottomRef.current.force = shouldStickToBottom;
|
||||||
?? 'Unknown';
|
|
||||||
|
// Show unread message preview for incoming messages when not at bottom
|
||||||
|
if (!isOwnMessage && !atBottomRef.current) {
|
||||||
|
const senderName =
|
||||||
|
getMemberDisplayName(room, mEvt.getSender() ?? '') ??
|
||||||
|
getMxIdLocalPart(mEvt.getSender() ?? '') ??
|
||||||
|
'Unknown';
|
||||||
const content = mEvt.getContent();
|
const content = mEvt.getContent();
|
||||||
const messageText = content.body ?? content.msgtype ?? 'New message';
|
const messageText = content.body ?? content.msgtype ?? 'New message';
|
||||||
setLatestUnreadMessage({
|
setLatestUnreadMessage({
|
||||||
sender: senderName,
|
sender: senderName,
|
||||||
content: messageText.length > 100 ? messageText.substring(0, 100) + '...' : messageText,
|
content: messageText.length > 100 ? `${messageText.substring(0, 100)}...` : messageText,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeline((ct) => ({ ...ct }));
|
|
||||||
if (!unreadInfo) {
|
|
||||||
setUnreadInfo(getRoomUnreadInfo(room));
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[mx, room, unreadInfo, hideActivity, markAsRead]
|
[mx, room, unreadInfo, hideActivity, markAsRead]
|
||||||
)
|
)
|
||||||
@@ -753,13 +754,35 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
if (!editorBaseEntry || !scrollElement) return;
|
if (!editorBaseEntry || !scrollElement) return;
|
||||||
|
|
||||||
if (atBottomRef.current) {
|
if (atBottomRef.current) {
|
||||||
scrollToBottom(scrollElement);
|
autoScrollToBottom(scrollElement, false, true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [getScrollElement, roomInputRef]),
|
}, [getScrollElement, roomInputRef]),
|
||||||
useCallback(() => roomInputRef.current, [roomInputRef])
|
useCallback(() => roomInputRef.current, [roomInputRef])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useResizeObserver(
|
||||||
|
useCallback((entries) => {
|
||||||
|
const timelineContent = timelineContentRef.current;
|
||||||
|
const scrollElement = getScrollElement();
|
||||||
|
if (!timelineContent || !scrollElement) return;
|
||||||
|
|
||||||
|
const timelineEntry = getResizeObserverEntry(timelineContent, entries);
|
||||||
|
if (!timelineEntry) return;
|
||||||
|
|
||||||
|
const nextHeight = timelineEntry.contentRect.height;
|
||||||
|
const previousHeight = timelineContentHeightRef.current;
|
||||||
|
timelineContentHeightRef.current = nextHeight;
|
||||||
|
|
||||||
|
if (previousHeight === 0 || nextHeight <= previousHeight || !atBottomRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
autoScrollToBottom(scrollElement, false, true);
|
||||||
|
}, [getScrollElement]),
|
||||||
|
useCallback(() => timelineContentRef.current, [])
|
||||||
|
);
|
||||||
|
|
||||||
const tryAutoMarkAsRead = useCallback(() => {
|
const tryAutoMarkAsRead = useCallback(() => {
|
||||||
const readUptoEventId = readUptoEventIdRef.current;
|
const readUptoEventId = readUptoEventIdRef.current;
|
||||||
if (!readUptoEventId) {
|
if (!readUptoEventId) {
|
||||||
@@ -777,7 +800,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
useCallback((entry: IntersectionObserverEntry) => {
|
useCallback((entry: IntersectionObserverEntry) => {
|
||||||
if (!entry.isIntersecting) {
|
if (!entry.isIntersecting) {
|
||||||
setAtBottom(false);
|
setAtBottom(false);
|
||||||
shouldAutoScrollRef.current = false;
|
|
||||||
}
|
}
|
||||||
}, []),
|
}, []),
|
||||||
{ wait: 1000 }
|
{ wait: 1000 }
|
||||||
@@ -791,7 +813,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
if (targetEntry) debounceSetAtBottom(targetEntry);
|
if (targetEntry) debounceSetAtBottom(targetEntry);
|
||||||
if (targetEntry?.isIntersecting && atLiveEndRef.current) {
|
if (targetEntry?.isIntersecting && atLiveEndRef.current) {
|
||||||
setAtBottom(true);
|
setAtBottom(true);
|
||||||
shouldAutoScrollRef.current = true;
|
|
||||||
setLatestUnreadMessage(null);
|
setLatestUnreadMessage(null);
|
||||||
if (document.hasFocus()) {
|
if (document.hasFocus()) {
|
||||||
tryAutoMarkAsRead();
|
tryAutoMarkAsRead();
|
||||||
@@ -864,22 +885,21 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
|
|
||||||
// Scroll to latest when clicking on already-selected room
|
// Scroll to latest when clicking on already-selected room
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const scrollToLatest = (location.state as any)?.scrollToLatest;
|
|
||||||
if (scrollToLatest) {
|
if (scrollToLatest) {
|
||||||
shouldAutoScrollRef.current = true;
|
|
||||||
setLatestUnreadMessage(null);
|
setLatestUnreadMessage(null);
|
||||||
setUnreadInfo(undefined);
|
setUnreadInfo(undefined);
|
||||||
setTimeline(getInitialTimeline(room));
|
setTimeline(getInitialTimeline(room));
|
||||||
scrollToBottomRef.current.count += 1;
|
scrollToBottomRef.current.count += 1;
|
||||||
scrollToBottomRef.current.smooth = false;
|
scrollToBottomRef.current.smooth = false;
|
||||||
|
scrollToBottomRef.current.force = true;
|
||||||
}
|
}
|
||||||
}, [(location.state as any)?.scrollToLatest, room]);
|
}, [scrollToLatest, room]);
|
||||||
|
|
||||||
// Scroll to bottom on initial timeline load
|
// Scroll to bottom on initial timeline load
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const scrollEl = scrollRef.current;
|
const scrollEl = scrollRef.current;
|
||||||
if (scrollEl) {
|
if (scrollEl) {
|
||||||
scrollToBottom(scrollEl);
|
autoScrollToBottom(scrollEl, false, true);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -926,8 +946,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (scrollToBottomCount > 0) {
|
if (scrollToBottomCount > 0) {
|
||||||
const scrollEl = scrollRef.current;
|
const scrollEl = scrollRef.current;
|
||||||
if (scrollEl)
|
if (scrollEl) {
|
||||||
scrollToBottom(scrollEl, scrollToBottomRef.current.smooth ? 'smooth' : 'instant');
|
autoScrollToBottom(
|
||||||
|
scrollEl,
|
||||||
|
scrollToBottomRef.current.smooth,
|
||||||
|
scrollToBottomRef.current.force
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [scrollToBottomCount]);
|
}, [scrollToBottomCount]);
|
||||||
|
|
||||||
@@ -980,11 +1005,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
if (eventId) {
|
if (eventId) {
|
||||||
navigateRoom(room.roomId, undefined, { replace: true });
|
navigateRoom(room.roomId, undefined, { replace: true });
|
||||||
}
|
}
|
||||||
shouldAutoScrollRef.current = true;
|
|
||||||
setLatestUnreadMessage(null);
|
setLatestUnreadMessage(null);
|
||||||
setTimeline(getInitialTimeline(room));
|
setTimeline(getInitialTimeline(room));
|
||||||
scrollToBottomRef.current.count += 1;
|
scrollToBottomRef.current.count += 1;
|
||||||
scrollToBottomRef.current.smooth = false;
|
scrollToBottomRef.current.smooth = false;
|
||||||
|
scrollToBottomRef.current.force = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleJumpToUnread = () => {
|
const handleJumpToUnread = () => {
|
||||||
@@ -1995,6 +2020,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
)}
|
)}
|
||||||
<Scroll ref={scrollRef} visibility="Hover">
|
<Scroll ref={scrollRef} visibility="Hover">
|
||||||
<Box
|
<Box
|
||||||
|
ref={timelineContentRef}
|
||||||
direction="Column"
|
direction="Column"
|
||||||
justifyContent="End"
|
justifyContent="End"
|
||||||
style={{ minHeight: '100%', padding: `${config.space.S600} 0` }}
|
style={{ minHeight: '100%', padding: `${config.space.S600} 0` }}
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ import {
|
|||||||
} from '../../utils/matrix';
|
} from '../../utils/matrix';
|
||||||
import { GetContentCallback, MessageEvent } from '../../../types/matrix/room';
|
import { GetContentCallback, MessageEvent } from '../../../types/matrix/room';
|
||||||
import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time';
|
import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time';
|
||||||
import { scrollToBottom } from '../../utils/dom';
|
import { autoScrollToBottom, scrollElementIntoView } from '../../utils/dom';
|
||||||
|
|
||||||
type ThreadViewProps = {
|
type ThreadViewProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
@@ -406,7 +406,7 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) {
|
|||||||
// Scroll to bottom on mount and when new events arrive.
|
// Scroll to bottom on mount and when new events arrive.
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
if (el) scrollToBottom(el);
|
if (el) autoScrollToBottom(el);
|
||||||
}, [events.length]);
|
}, [events.length]);
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
@@ -481,7 +481,7 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) {
|
|||||||
const el = scrollRef.current?.querySelector(
|
const el = scrollRef.current?.querySelector(
|
||||||
`[data-message-id="${targetId}"]`
|
`[data-message-id="${targetId}"]`
|
||||||
) as HTMLElement | null;
|
) as HTMLElement | null;
|
||||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
if (el) scrollElementIntoView(el, { behavior: 'smooth', block: 'center' });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleReactionToggle = useCallback(
|
const handleReactionToggle = useCallback(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
getScrollInfo,
|
getScrollInfo,
|
||||||
isInScrollView,
|
isInScrollView,
|
||||||
isIntersectingScrollView,
|
isIntersectingScrollView,
|
||||||
|
scrollToPosition,
|
||||||
} from '../utils/dom';
|
} from '../utils/dom';
|
||||||
|
|
||||||
const PAGINATOR_ANCHOR_ATTR = 'data-paginator-anchor';
|
const PAGINATOR_ANCHOR_ATTR = 'data-paginator-anchor';
|
||||||
@@ -218,7 +219,7 @@ export const useVirtualPaginator = <TScrollElement extends HTMLElement>(
|
|||||||
scrollTo = element.offsetTop - Math.round(scrollInfo.viewHeight) + element.clientHeight;
|
scrollTo = element.offsetTop - Math.round(scrollInfo.viewHeight) + element.clientHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
scrollElement.scrollTo({
|
scrollToPosition(scrollElement, {
|
||||||
top: scrollTo - (opts?.offset ?? 0),
|
top: scrollTo - (opts?.offset ?? 0),
|
||||||
behavior: opts?.behavior,
|
behavior: opts?.behavior,
|
||||||
});
|
});
|
||||||
@@ -253,10 +254,12 @@ export const useVirtualPaginator = <TScrollElement extends HTMLElement>(
|
|||||||
|
|
||||||
if (!itemElement) {
|
if (!itemElement) {
|
||||||
const scrollElement = getScrollElement();
|
const scrollElement = getScrollElement();
|
||||||
scrollElement?.scrollTo({
|
if (scrollElement) {
|
||||||
top: opts?.offset ?? 0,
|
scrollToPosition(scrollElement, {
|
||||||
behavior: opts?.behavior,
|
top: opts?.offset ?? 0,
|
||||||
});
|
behavior: opts?.behavior,
|
||||||
|
});
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return scrollToElement(itemElement, opts);
|
return scrollToElement(itemElement, opts);
|
||||||
@@ -364,7 +367,7 @@ export const useVirtualPaginator = <TScrollElement extends HTMLElement>(
|
|||||||
const offsetAddition = offsetTop - oldOffsetTop;
|
const offsetAddition = offsetTop - oldOffsetTop;
|
||||||
const restoreTop = oldScrollTop + offsetAddition;
|
const restoreTop = oldScrollTop + offsetAddition;
|
||||||
|
|
||||||
scrollEl.scrollTo({
|
scrollToPosition(scrollEl, {
|
||||||
top: restoreTop,
|
top: restoreTop,
|
||||||
behavior: 'instant',
|
behavior: 'instant',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -172,6 +172,32 @@ export type ScrollInfo = {
|
|||||||
viewHeight: number;
|
viewHeight: number;
|
||||||
scrollable: boolean;
|
scrollable: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ScrollBehavior = 'auto' | 'instant' | 'smooth';
|
||||||
|
|
||||||
|
export type ScrollPosition = {
|
||||||
|
top?: number;
|
||||||
|
left?: number;
|
||||||
|
behavior?: ScrollBehavior;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centralized wrapper for element scrolling.
|
||||||
|
*/
|
||||||
|
export const scrollToPosition = (scrollEl: HTMLElement, position: ScrollPosition): void => {
|
||||||
|
scrollEl.scrollTo(position);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centralized wrapper for scrolling a target into the current viewport.
|
||||||
|
*/
|
||||||
|
export const scrollElementIntoView = (
|
||||||
|
element: HTMLElement,
|
||||||
|
options?: ScrollIntoViewOptions
|
||||||
|
): void => {
|
||||||
|
element.scrollIntoView(options);
|
||||||
|
};
|
||||||
|
|
||||||
export const getScrollInfo = (target: HTMLElement): ScrollInfo => ({
|
export const getScrollInfo = (target: HTMLElement): ScrollInfo => ({
|
||||||
offsetTop: Math.round(target.offsetTop),
|
offsetTop: Math.round(target.offsetTop),
|
||||||
top: Math.round(target.scrollTop),
|
top: Math.round(target.scrollTop),
|
||||||
@@ -180,17 +206,44 @@ export const getScrollInfo = (target: HTMLElement): ScrollInfo => ({
|
|||||||
scrollable: target.scrollHeight > target.offsetHeight,
|
scrollable: target.scrollHeight > target.offsetHeight,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const scrollToBottom = (scrollEl: HTMLElement, behavior?: 'auto' | 'instant' | 'smooth') => {
|
export const scrollToBottom = (scrollEl: HTMLElement, behavior?: ScrollBehavior) => {
|
||||||
scrollEl.scrollTo({
|
scrollToPosition(scrollEl, {
|
||||||
top: Math.round(scrollEl.scrollHeight - scrollEl.offsetHeight),
|
top: Math.round(scrollEl.scrollHeight - scrollEl.offsetHeight),
|
||||||
behavior,
|
behavior,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if scroll element is at or near the bottom (within threshold).
|
||||||
|
* Useful for "sticky to bottom" behavior in chat-like interfaces.
|
||||||
|
* @param scrollEl - The scrollable element
|
||||||
|
* @param threshold - Distance from bottom in pixels (default: 50px)
|
||||||
|
* @returns true if scrolled to bottom within threshold
|
||||||
|
*/
|
||||||
|
export const isScrolledToBottom = (scrollEl: HTMLElement, threshold = 50): boolean => {
|
||||||
|
const { scrollTop, offsetHeight, scrollHeight } = scrollEl;
|
||||||
|
const scrollPosition = scrollTop + offsetHeight;
|
||||||
|
return scrollHeight - scrollPosition <= threshold;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared auto-scroll behavior for chat-like timelines that should stay pinned to bottom.
|
||||||
|
* Only scrolls if already near the bottom unless a call site explicitly forces it.
|
||||||
|
*/
|
||||||
|
export const autoScrollToBottom = (
|
||||||
|
scrollEl: HTMLElement,
|
||||||
|
smooth = false,
|
||||||
|
force = false
|
||||||
|
): void => {
|
||||||
|
if (force || isScrolledToBottom(scrollEl)) {
|
||||||
|
scrollToBottom(scrollEl, smooth ? 'smooth' : 'instant');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const copyToClipboard = (text: string) => {
|
export const copyToClipboard = (text: string) => {
|
||||||
// Try Electron clipboard first (if available)
|
// Try Electron clipboard first (if available)
|
||||||
if ('electron' in window && (window as any).electron?.clipboard?.writeText) {
|
if ('electron' in window && window.electron?.clipboard?.writeText) {
|
||||||
(window as any).electron.clipboard.writeText(text);
|
window.electron.clipboard.writeText(text);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
3
src/ext.d.ts
vendored
3
src/ext.d.ts
vendored
@@ -53,6 +53,9 @@ interface PluginAPI {
|
|||||||
interface ElectronAPI {
|
interface ElectronAPI {
|
||||||
platform?: string;
|
platform?: string;
|
||||||
arch?: string;
|
arch?: string;
|
||||||
|
clipboard?: {
|
||||||
|
writeText: (text: string) => void;
|
||||||
|
};
|
||||||
plugins?: PluginAPI;
|
plugins?: PluginAPI;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user