Skip to content

Commit 03e6fe7

Browse files
committed
feat: add scroll container prop
1 parent 3a5cfd4 commit 03e6fe7

8 files changed

Lines changed: 196 additions & 8 deletions

File tree

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import {memo, useRef} from 'react';
2+
3+
import {MarkdownEditorView, useMarkdownEditor} from '@gravity-ui/markdown-editor';
4+
import {Text} from '@gravity-ui/uikit';
5+
6+
import {PlaygroundLayout} from '../../../components/PlaygroundLayout';
7+
8+
const initialMarkup = `# Sticky toolbar in a scroll container
9+
10+
This example shows how the toolbar can stick to the top of a custom scroll container
11+
instead of the browser window.
12+
13+
Try scrolling down inside the editor area — the toolbar will stay fixed at the top
14+
of the scrollable box below, not at the top of the page.
15+
16+
## Why this matters
17+
18+
By default \`useSticky\` listens to \`scroll\` events on \`window\`. When the editor lives
19+
inside a fixed-height scrollable div, the window never scrolls, so the sticky logic
20+
never triggers. Passing \`scrollContainerRef\` routes the \`scroll\` listener onto the
21+
right element.
22+
23+
## How to use it
24+
25+
\`\`\`tsx
26+
const scrollRef = useRef<HTMLDivElement>(null);
27+
28+
<div ref={scrollRef} style={{overflowY: 'auto', height: 400}}>
29+
<MarkdownEditorView
30+
editor={editor}
31+
stickyToolbar
32+
scrollContainerRef={scrollRef}
33+
/>
34+
</div>
35+
\`\`\`
36+
37+
## More content to make the area scrollable
38+
39+
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor
40+
incididunt ut labore et dolore magna aliqua.
41+
42+
- Item one
43+
- Item two
44+
- Item three
45+
46+
### Heading level 3
47+
48+
Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip
49+
ex ea commodo consequat.
50+
51+
1. First ordered item
52+
2. Second ordered item
53+
3. Third ordered item
54+
55+
### Another section
56+
57+
Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu
58+
fugiat nulla pariatur.
59+
60+
> Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia
61+
> deserunt mollit anim id est laborum.
62+
63+
### Yet another section
64+
65+
Curabitur pretium tincidunt lacus. Nulla gravida orci a odio. Nullam varius,
66+
turpis molestie pretium placerat, arcu purus aliquam erat.
67+
68+
- Nested list item 1
69+
- Child item A
70+
- Child item B
71+
- Nested list item 2
72+
73+
### Final section
74+
75+
Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac
76+
turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget.
77+
`;
78+
79+
export type EditorWithStickyInContainerProps = {};
80+
81+
export const EditorWithStickyInContainer = memo<EditorWithStickyInContainerProps>(
82+
function EditorWithStickyInContainer() {
83+
const scrollContainerRef = useRef<HTMLDivElement>(null);
84+
85+
const editor = useMarkdownEditor({
86+
initial: {markup: initialMarkup, mode: 'wysiwyg'},
87+
});
88+
89+
return (
90+
<PlaygroundLayout
91+
title="Sticky toolbar inside a scroll container"
92+
editor={editor}
93+
view={() => (
94+
<div
95+
ref={scrollContainerRef}
96+
style={{
97+
height: 600,
98+
overflowY: 'auto',
99+
border: '1px solid var(--g-color-line-generic)',
100+
borderRadius: 4,
101+
padding: 16,
102+
}}
103+
>
104+
<Text variant="display-4" style={{paddingBottom: 16, display: 'block'}}>
105+
Scroll container
106+
</Text>
107+
<MarkdownEditorView
108+
className="g-md-editor-mode"
109+
autofocus
110+
stickyToolbar
111+
settingsVisible
112+
editor={editor}
113+
scrollContainerRef={scrollContainerRef}
114+
/>
115+
</div>
116+
)}
117+
/>
118+
);
119+
},
120+
);
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import type {StoryObj} from '@storybook/react';
2+
3+
import {EditorWithStickyInContainer as component} from './Editor';
4+
5+
export const Story: StoryObj<typeof component> = {
6+
args: {},
7+
};
8+
Story.storyName = 'Sticky Toolbar in Scroll Container';
9+
10+
export default {
11+
title: 'Experiments / Sticky Toolbar in Scroll Container',
12+
component,
13+
};

packages/editor/src/bundle/MarkdownEditorView.scss

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313
}
1414
}
1515

16+
&_withScrollContainer {
17+
height: auto;
18+
}
19+
1620
&__editor {
1721
flex-grow: 1;
1822
gap: 2px;

packages/editor/src/bundle/MarkdownEditorView.tsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ const EditorWrapper = forwardRef<HTMLDivElement, EditorWrapperProps>(
5454
markupHiddenActionsConfig: initialMarkupHiddenActionsConfig,
5555
markupToolbarConfig: initialMarkupToolbarConfig,
5656
qa,
57+
scrollContainerRef,
5758
settingsVisible: settingsVisibleProp,
5859
stickyToolbar,
5960
toolbarsPreset,
@@ -148,6 +149,7 @@ const EditorWrapper = forwardRef<HTMLDivElement, EditorWrapperProps>(
148149
onSplitModeChange,
149150
onToolbarVisibilityChange,
150151
renderPreviewButton: canRenderPreview,
152+
scrollContainerRef,
151153
showPreview,
152154
splitMode: editor.splitMode,
153155
splitModeEnabled: editor.splitModeEnabled,
@@ -195,6 +197,7 @@ const EditorWrapper = forwardRef<HTMLDivElement, EditorWrapperProps>(
195197
toolbarClassName={b('toolbar')}
196198
stickyToolbar={stickyToolbar}
197199
toolbarDisplay={toolbarDisplay}
200+
scrollContainerRef={scrollContainerRef}
198201
>
199202
<Settings
200203
{...settingsProps}
@@ -216,6 +219,7 @@ const EditorWrapper = forwardRef<HTMLDivElement, EditorWrapperProps>(
216219
toolbarClassName={b('toolbar')}
217220
stickyToolbar={stickyToolbar}
218221
toolbarDisplay={toolbarDisplay}
222+
scrollContainerRef={scrollContainerRef}
219223
>
220224
<Settings
221225
{...settingsProps}
@@ -247,6 +251,7 @@ type ViewProps = {
247251
stickyToolbar: boolean;
248252
enableSubmitInPreview?: boolean;
249253
hidePreviewAfterSubmit?: boolean;
254+
scrollContainerRef?: React.RefObject<HTMLElement>;
250255
};
251256

252257
export type MarkdownEditorViewProps = ClassNameProps & ToolbarConfigs & ViewProps & QAProps & {};
@@ -276,6 +281,7 @@ export const MarkdownEditorView = forwardRef<HTMLDivElement, MarkdownEditorViewP
276281
markupHiddenActionsConfig,
277282
markupToolbarConfig,
278283
qa,
284+
scrollContainerRef,
279285
settingsVisible = true,
280286
stickyToolbar,
281287
toolbarsPreset,
@@ -340,6 +346,7 @@ export const MarkdownEditorView = forwardRef<HTMLDivElement, MarkdownEditorViewP
340346
{
341347
settings: areSettingsVisible,
342348
split: markupSplitMode && editor.splitMode,
349+
withScrollContainer: !!scrollContainerRef,
343350
},
344351
[className],
345352
)}
@@ -357,6 +364,7 @@ export const MarkdownEditorView = forwardRef<HTMLDivElement, MarkdownEditorViewP
357364
markupToolbarConfig={markupToolbarConfig}
358365
qa="g-md-editor-mode"
359366
ref={editorWrapperRef}
367+
scrollContainerRef={scrollContainerRef}
360368
settingsVisible={settingsVisible}
361369
stickyToolbar={stickyToolbar}
362370
toolbarsPreset={toolbarsPreset}
@@ -393,9 +401,17 @@ const MarkupSearchAnchor: React.FC<MarkupSearchAnchorProps> = ({mode}) => (
393401
<div className={`g-md-search-${mode}-anchor`}></div>
394402
);
395403

396-
function Settings(props: EditorSettingsProps & {stickyToolbar: boolean}) {
404+
function Settings(
405+
props: EditorSettingsProps & {
406+
stickyToolbar: boolean;
407+
scrollContainerRef?: React.RefObject<HTMLElement>;
408+
},
409+
) {
397410
const wrapperRef = useRef<HTMLDivElement>(null);
398-
const isSticky = useSticky(wrapperRef) && props.toolbarVisibility && props.stickyToolbar;
411+
const isSticky =
412+
useSticky(wrapperRef, props.scrollContainerRef) &&
413+
props.toolbarVisibility &&
414+
props.stickyToolbar;
399415

400416
return (
401417
<>

packages/editor/src/bundle/MarkupEditorView.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export type MarkupEditorViewProps = ClassNameProps &
3232
hiddenActionsConfig?: MToolbarItemData[];
3333
children?: React.ReactNode;
3434
toolbarDisplay?: ToolbarDisplay;
35+
scrollContainerRef?: React.RefObject<HTMLElement>;
3536
};
3637

3738
export const MarkupEditorView = memo<MarkupEditorViewProps>((props) => {
@@ -48,6 +49,7 @@ export const MarkupEditorView = memo<MarkupEditorViewProps>((props) => {
4849
children,
4950
stickyToolbar = true,
5051
toolbarDisplay,
52+
scrollContainerRef,
5153
} = props;
5254
useRenderTime((time) => {
5355
globalLogger.metrics({
@@ -83,6 +85,7 @@ export const MarkupEditorView = memo<MarkupEditorViewProps>((props) => {
8385
settingsVisible={settingsVisible}
8486
className={b('toolbar', [toolbarClassName])}
8587
toolbarDisplay={toolbarDisplay}
88+
scrollContainerRef={scrollContainerRef}
8689
>
8790
{children}
8891
</ToolbarView>

packages/editor/src/bundle/ToolbarView.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export type ToolbarViewProps<T> = ClassNameProps &
3737
children?: React.ReactNode;
3838
stickyToolbar: boolean;
3939
toolbarDisplay?: ToolbarDisplay;
40+
scrollContainerRef?: React.RefObject<HTMLElement>;
4041
};
4142

4243
export function ToolbarView<T>({
@@ -50,10 +51,11 @@ export function ToolbarView<T>({
5051
className,
5152
children,
5253
stickyToolbar,
54+
scrollContainerRef,
5355
qa,
5456
}: ToolbarViewProps<T>) {
5557
const wrapperRef = useRef<HTMLDivElement>(null);
56-
const isStickyActive = useSticky(wrapperRef) && stickyToolbar;
58+
const isStickyActive = useSticky(wrapperRef, scrollContainerRef) && stickyToolbar;
5759

5860
const mobile = editor.mobile;
5961

packages/editor/src/bundle/WysiwygEditorView.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export type WysiwygEditorViewProps = ClassNameProps &
2626
hiddenActionsConfig?: WToolbarItemData[];
2727
children?: React.ReactNode;
2828
toolbarDisplay?: ToolbarDisplay;
29+
scrollContainerRef?: React.RefObject<HTMLElement>;
2930
};
3031

3132
export const WysiwygEditorView: React.FC<WysiwygEditorViewProps> = (props) => {
@@ -42,6 +43,7 @@ export const WysiwygEditorView: React.FC<WysiwygEditorViewProps> = (props) => {
4243
children,
4344
stickyToolbar = true,
4445
toolbarDisplay,
46+
scrollContainerRef,
4547
} = props;
4648

4749
useRenderTime((time) => {
@@ -71,6 +73,7 @@ export const WysiwygEditorView: React.FC<WysiwygEditorViewProps> = (props) => {
7173
settingsVisible={settingsVisible}
7274
className={b('toolbar', [toolbarClassName])}
7375
toolbarDisplay={toolbarDisplay}
76+
scrollContainerRef={scrollContainerRef}
7477
>
7578
{children}
7679
</ToolbarView>

packages/editor/src/react-utils/useSticky.ts

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,46 @@ import {useEffectOnce, useLatest} from 'react-use';
44

55
import {REFLOW_EVENTS} from 'src/utils/dom';
66

7-
export function useSticky<T extends HTMLElement>(elemRef: React.RefObject<T>) {
7+
const CONTAINER_EVENTS = new Set<keyof WindowEventMap>([
8+
'scroll',
9+
'touchstart',
10+
'touchmove',
11+
'touchend',
12+
]);
13+
14+
export function useSticky<T extends HTMLElement>(
15+
elemRef: React.RefObject<T>,
16+
scrollContainerRef?: React.RefObject<HTMLElement>,
17+
) {
818
const [sticky, setSticky] = useState(false);
919
const stickyRef = useLatest(sticky);
1020

1121
useEffectOnce(() => {
1222
let rafId: number | null = null;
23+
const scrollContainer = scrollContainerRef?.current ?? null;
24+
25+
const naturalTopFromContainer =
26+
scrollContainer && elemRef.current
27+
? elemRef.current.getBoundingClientRect().top -
28+
scrollContainer.getBoundingClientRect().top -
29+
scrollContainer.clientTop
30+
: null;
31+
32+
const getTarget = (eventName: keyof WindowEventMap): EventTarget =>
33+
CONTAINER_EVENTS.has(eventName) && scrollContainer ? scrollContainer : window;
1334

1435
observe();
1536

1637
for (const eventName of REFLOW_EVENTS) {
17-
window.addEventListener(eventName, scheduleObserve, true);
38+
getTarget(eventName).addEventListener(eventName, scheduleObserve, true);
1839
}
1940

2041
return () => {
2142
if (rafId !== null) {
2243
cancelAnimationFrame(rafId);
2344
}
2445
for (const eventName of REFLOW_EVENTS) {
25-
window.removeEventListener(eventName, scheduleObserve, true);
46+
getTarget(eventName).removeEventListener(eventName, scheduleObserve, true);
2647
}
2748
};
2849

@@ -36,9 +57,15 @@ export function useSticky<T extends HTMLElement>(elemRef: React.RefObject<T>) {
3657
function observe() {
3758
rafId = null;
3859
if (!elemRef.current) return;
39-
const refPageOffset = elemRef.current.getBoundingClientRect().top;
4060
const stickyOffset = parseInt(getComputedStyle(elemRef.current).top, 10);
41-
const stickyActive = refPageOffset <= stickyOffset;
61+
62+
let stickyActive: boolean;
63+
if (scrollContainer !== null && naturalTopFromContainer !== null) {
64+
stickyActive = scrollContainer.scrollTop >= naturalTopFromContainer - stickyOffset;
65+
} else {
66+
const refPageOffset = elemRef.current.getBoundingClientRect().top;
67+
stickyActive = refPageOffset <= stickyOffset;
68+
}
4269

4370
if (stickyActive && !stickyRef.current) setSticky(true);
4471
else if (!stickyActive && stickyRef.current) setSticky(false);

0 commit comments

Comments
 (0)