Skip to content

Commit f3ed58f

Browse files
feat(apollo-react): hijack canvas scroll events when sticky note has overflow
1 parent 237afb0 commit f3ed58f

2 files changed

Lines changed: 56 additions & 1 deletion

File tree

packages/apollo-react/src/canvas/components/StickyNoteNode/StickyNoteNode.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import type { StickyNoteColor, StickyNoteData, TextSelection } from './StickyNot
3434
import { STICKY_NOTE_COLORS, withAlpha } from './StickyNoteNode.types';
3535
import { preserveNewlines } from './StickyNoteNode.utils';
3636
import { useMarkdownShortcuts } from './useMarkdownShortcuts';
37+
import { useScrollCapture } from './useScrollCapture';
3738

3839
export interface StickyNoteNodeProps extends NodeProps {
3940
data: StickyNoteData;
@@ -58,6 +59,7 @@ const StickyNoteNodeComponent = ({
5859
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
5960
const [localContent, setLocalContent] = useState(data.content || '');
6061
const textAreaRef = useRef<HTMLTextAreaElement>(null);
62+
const { ref: markdownRef, scrollCaptureProps } = useScrollCapture();
6163
const colorButtonRef = useRef<HTMLDivElement>(null);
6264
const [activeFormats, setActiveFormats] = useState<ActiveFormats>({
6365
bold: false,
@@ -354,7 +356,7 @@ const StickyNoteNodeComponent = ({
354356
/>
355357
</>
356358
) : (
357-
<StickyNoteMarkdown>
359+
<StickyNoteMarkdown ref={markdownRef} {...scrollCaptureProps}>
358360
{localContent ? (
359361
<ReactMarkdown remarkPlugins={[remarkGfm, remarkBreaks]} components={markdownComponents}>
360362
{preserveNewlines(localContent)}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { useCallback, useEffect, useRef, useState } from 'react';
2+
3+
const EVENT_START_POLL_INTERVAL = 150;
4+
5+
/**
6+
* Captures scroll (wheel) events on an overflowing element without hijacking
7+
* an in-progress canvas zoom gesture.
8+
*
9+
* Returns a ref to attach to the scrollable element and props to spread onto it.
10+
* Adds the `nowheel` class only when the pointer entered while no wheel gesture
11+
* was active and the element has overflow.
12+
*/
13+
export function useScrollCapture<T extends HTMLElement = HTMLDivElement>() {
14+
const ref = useRef<T>(null);
15+
const [captureScroll, setCaptureScroll] = useState(false);
16+
const wheelActiveRef = useRef(false);
17+
const wheelTimeoutRef = useRef<ReturnType<typeof setTimeout>>(null);
18+
19+
// Track global wheel activity so we can distinguish "pointer entered while idle"
20+
// from "pointer drifted over during a canvas zoom gesture".
21+
useEffect(() => {
22+
const onWheel = () => {
23+
wheelActiveRef.current = true;
24+
if (wheelTimeoutRef.current) clearTimeout(wheelTimeoutRef.current);
25+
wheelTimeoutRef.current = setTimeout(() => {
26+
wheelActiveRef.current = false;
27+
}, EVENT_START_POLL_INTERVAL);
28+
};
29+
window.addEventListener('wheel', onWheel, { passive: true });
30+
return () => window.removeEventListener('wheel', onWheel);
31+
}, []);
32+
33+
const onMouseEnter = useCallback(() => {
34+
if (wheelActiveRef.current) return;
35+
const el = ref.current;
36+
if (el && el.scrollHeight > el.clientHeight) {
37+
setCaptureScroll(true);
38+
}
39+
}, []);
40+
41+
const onMouseLeave = useCallback(() => {
42+
setCaptureScroll(false);
43+
}, []);
44+
45+
return {
46+
ref,
47+
scrollCaptureProps: {
48+
className: captureScroll ? 'nowheel' : undefined,
49+
onMouseEnter,
50+
onMouseLeave,
51+
},
52+
};
53+
}

0 commit comments

Comments
 (0)