Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions demo/src/stories/experiments/sticky-toolbar/Editor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import {memo, useRef} from 'react';

import {MarkdownEditorView, useMarkdownEditor} from '@gravity-ui/markdown-editor';
import {Text} from '@gravity-ui/uikit';

import {PlaygroundLayout} from '../../../components/PlaygroundLayout';

const initialMarkup = `# Sticky toolbar in a scroll container

This example shows how the toolbar can stick to the top of a custom scroll container
instead of the browser window.

Try scrolling down inside the editor area — the toolbar will stay fixed at the top
of the scrollable box below, not at the top of the page.

## Why this matters

By default \`useSticky\` listens to \`scroll\` events on \`window\`. When the editor lives
inside a fixed-height scrollable div, the window never scrolls, so the sticky logic
never triggers. Passing \`scrollContainerRef\` routes the \`scroll\` listener onto the
right element.

## How to use it

\`\`\`tsx
const scrollRef = useRef<HTMLDivElement>(null);

<div ref={scrollRef} style={{overflowY: 'auto', height: 400}}>
<MarkdownEditorView
editor={editor}
stickyToolbar
scrollContainerRef={scrollRef}
/>
</div>
\`\`\`

## More content to make the area scrollable

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor
incididunt ut labore et dolore magna aliqua.

- Item one
- Item two
- Item three

### Heading level 3

Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip
ex ea commodo consequat.

1. First ordered item
2. Second ordered item
3. Third ordered item

### Another section

Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu
fugiat nulla pariatur.

> Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia
> deserunt mollit anim id est laborum.

### Yet another section

Curabitur pretium tincidunt lacus. Nulla gravida orci a odio. Nullam varius,
turpis molestie pretium placerat, arcu purus aliquam erat.

- Nested list item 1
- Child item A
- Child item B
- Nested list item 2

### Final section

Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac
turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget.
`;

export type EditorWithStickyInContainerProps = {};

export const EditorWithStickyInContainer = memo<EditorWithStickyInContainerProps>(
function EditorWithStickyInContainer() {
const scrollContainerRef = useRef<HTMLDivElement>(null);

const editor = useMarkdownEditor({
initial: {markup: initialMarkup, mode: 'wysiwyg'},
});

return (
<PlaygroundLayout
title="Sticky toolbar inside a scroll container"
editor={editor}
view={() => (
<div
ref={scrollContainerRef}
style={{
height: 600,
overflowY: 'auto',
border: '1px solid var(--g-color-line-generic)',
borderRadius: 4,
padding: 16,
}}
>
<Text variant="display-4" style={{paddingBottom: 16, display: 'block'}}>
Scroll container
</Text>
<MarkdownEditorView
className="g-md-editor-mode"
autofocus
stickyToolbar
settingsVisible
editor={editor}
scrollContainerRef={scrollContainerRef}
/>
</div>
)}
/>
);
},
);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please, move this story to demo/src/stories/examples

Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type {StoryObj} from '@storybook/react';

import {EditorWithStickyInContainer as component} from './Editor';

export const Story: StoryObj<typeof component> = {
args: {},
};
Story.storyName = 'Sticky Toolbar in Scroll Container';

export default {
title: 'Experiments / Sticky Toolbar in Scroll Container',
component,
};
4 changes: 4 additions & 0 deletions packages/editor/src/bundle/MarkdownEditorView.scss
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@
}
}

&_withScrollContainer {
height: auto;
}

&__editor {
flex-grow: 1;
gap: 2px;
Expand Down
20 changes: 18 additions & 2 deletions packages/editor/src/bundle/MarkdownEditorView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ const EditorWrapper = forwardRef<HTMLDivElement, EditorWrapperProps>(
markupHiddenActionsConfig: initialMarkupHiddenActionsConfig,
markupToolbarConfig: initialMarkupToolbarConfig,
qa,
scrollContainerRef,
settingsVisible: settingsVisibleProp,
stickyToolbar,
toolbarsPreset,
Expand Down Expand Up @@ -148,6 +149,7 @@ const EditorWrapper = forwardRef<HTMLDivElement, EditorWrapperProps>(
onSplitModeChange,
onToolbarVisibilityChange,
renderPreviewButton: canRenderPreview,
scrollContainerRef,
showPreview,
splitMode: editor.splitMode,
splitModeEnabled: editor.splitModeEnabled,
Expand Down Expand Up @@ -195,6 +197,7 @@ const EditorWrapper = forwardRef<HTMLDivElement, EditorWrapperProps>(
toolbarClassName={b('toolbar')}
stickyToolbar={stickyToolbar}
toolbarDisplay={toolbarDisplay}
scrollContainerRef={scrollContainerRef}
>
<Settings
{...settingsProps}
Expand All @@ -216,6 +219,7 @@ const EditorWrapper = forwardRef<HTMLDivElement, EditorWrapperProps>(
toolbarClassName={b('toolbar')}
stickyToolbar={stickyToolbar}
toolbarDisplay={toolbarDisplay}
scrollContainerRef={scrollContainerRef}
>
<Settings
{...settingsProps}
Expand Down Expand Up @@ -247,6 +251,7 @@ type ViewProps = {
stickyToolbar: boolean;
enableSubmitInPreview?: boolean;
hidePreviewAfterSubmit?: boolean;
scrollContainerRef?: React.RefObject<HTMLElement>;
};

export type MarkdownEditorViewProps = ClassNameProps & ToolbarConfigs & ViewProps & QAProps & {};
Expand Down Expand Up @@ -276,6 +281,7 @@ export const MarkdownEditorView = forwardRef<HTMLDivElement, MarkdownEditorViewP
markupHiddenActionsConfig,
markupToolbarConfig,
qa,
scrollContainerRef,
settingsVisible = true,
stickyToolbar,
toolbarsPreset,
Expand Down Expand Up @@ -340,6 +346,7 @@ export const MarkdownEditorView = forwardRef<HTMLDivElement, MarkdownEditorViewP
{
settings: areSettingsVisible,
split: markupSplitMode && editor.splitMode,
withScrollContainer: !!scrollContainerRef,
},
[className],
)}
Expand All @@ -357,6 +364,7 @@ export const MarkdownEditorView = forwardRef<HTMLDivElement, MarkdownEditorViewP
markupToolbarConfig={markupToolbarConfig}
qa="g-md-editor-mode"
ref={editorWrapperRef}
scrollContainerRef={scrollContainerRef}
settingsVisible={settingsVisible}
stickyToolbar={stickyToolbar}
toolbarsPreset={toolbarsPreset}
Expand Down Expand Up @@ -393,9 +401,17 @@ const MarkupSearchAnchor: React.FC<MarkupSearchAnchorProps> = ({mode}) => (
<div className={`g-md-search-${mode}-anchor`}></div>
);

function Settings(props: EditorSettingsProps & {stickyToolbar: boolean}) {
function Settings(
props: EditorSettingsProps & {
stickyToolbar: boolean;
scrollContainerRef?: React.RefObject<HTMLElement>;
},
) {
const wrapperRef = useRef<HTMLDivElement>(null);
const isSticky = useSticky(wrapperRef) && props.toolbarVisibility && props.stickyToolbar;
const isSticky =
useSticky(wrapperRef, props.scrollContainerRef) &&
props.toolbarVisibility &&
props.stickyToolbar;

return (
<>
Expand Down
3 changes: 3 additions & 0 deletions packages/editor/src/bundle/MarkupEditorView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export type MarkupEditorViewProps = ClassNameProps &
hiddenActionsConfig?: MToolbarItemData[];
children?: React.ReactNode;
toolbarDisplay?: ToolbarDisplay;
scrollContainerRef?: React.RefObject<HTMLElement>;
};

export const MarkupEditorView = memo<MarkupEditorViewProps>((props) => {
Expand All @@ -48,6 +49,7 @@ export const MarkupEditorView = memo<MarkupEditorViewProps>((props) => {
children,
stickyToolbar = true,
toolbarDisplay,
scrollContainerRef,
} = props;
useRenderTime((time) => {
globalLogger.metrics({
Expand Down Expand Up @@ -83,6 +85,7 @@ export const MarkupEditorView = memo<MarkupEditorViewProps>((props) => {
settingsVisible={settingsVisible}
className={b('toolbar', [toolbarClassName])}
toolbarDisplay={toolbarDisplay}
scrollContainerRef={scrollContainerRef}
>
{children}
</ToolbarView>
Expand Down
4 changes: 3 additions & 1 deletion packages/editor/src/bundle/ToolbarView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export type ToolbarViewProps<T> = ClassNameProps &
children?: React.ReactNode;
stickyToolbar: boolean;
toolbarDisplay?: ToolbarDisplay;
scrollContainerRef?: React.RefObject<HTMLElement>;
};

export function ToolbarView<T>({
Expand All @@ -50,10 +51,11 @@ export function ToolbarView<T>({
className,
children,
stickyToolbar,
scrollContainerRef,
qa,
}: ToolbarViewProps<T>) {
const wrapperRef = useRef<HTMLDivElement>(null);
const isStickyActive = useSticky(wrapperRef) && stickyToolbar;
const isStickyActive = useSticky(wrapperRef, scrollContainerRef) && stickyToolbar;

const mobile = editor.mobile;

Expand Down
3 changes: 3 additions & 0 deletions packages/editor/src/bundle/WysiwygEditorView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export type WysiwygEditorViewProps = ClassNameProps &
hiddenActionsConfig?: WToolbarItemData[];
children?: React.ReactNode;
toolbarDisplay?: ToolbarDisplay;
scrollContainerRef?: React.RefObject<HTMLElement>;
};

export const WysiwygEditorView: React.FC<WysiwygEditorViewProps> = (props) => {
Expand All @@ -42,6 +43,7 @@ export const WysiwygEditorView: React.FC<WysiwygEditorViewProps> = (props) => {
children,
stickyToolbar = true,
toolbarDisplay,
scrollContainerRef,
} = props;

useRenderTime((time) => {
Expand Down Expand Up @@ -71,6 +73,7 @@ export const WysiwygEditorView: React.FC<WysiwygEditorViewProps> = (props) => {
settingsVisible={settingsVisible}
className={b('toolbar', [toolbarClassName])}
toolbarDisplay={toolbarDisplay}
scrollContainerRef={scrollContainerRef}
>
{children}
</ToolbarView>
Expand Down
37 changes: 32 additions & 5 deletions packages/editor/src/react-utils/useSticky.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,46 @@ import {useEffectOnce, useLatest} from 'react-use';

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

export function useSticky<T extends HTMLElement>(elemRef: React.RefObject<T>) {
const CONTAINER_EVENTS = new Set<keyof WindowEventMap>([
'scroll',
'touchstart',
'touchmove',
'touchend',
]);

export function useSticky<T extends HTMLElement>(
elemRef: React.RefObject<T>,
scrollContainerRef?: React.RefObject<HTMLElement>,
) {
const [sticky, setSticky] = useState(false);
const stickyRef = useLatest(sticky);

useEffectOnce(() => {
let rafId: number | null = null;
const scrollContainer = scrollContainerRef?.current ?? null;

const naturalTopFromContainer =
scrollContainer && elemRef.current
? elemRef.current.getBoundingClientRect().top -
scrollContainer.getBoundingClientRect().top -
scrollContainer.clientTop
: null;
Comment on lines +25 to +30

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's better to move this to the observe()


const getTarget = (eventName: keyof WindowEventMap): EventTarget =>
CONTAINER_EVENTS.has(eventName) && scrollContainer ? scrollContainer : window;

observe();

for (const eventName of REFLOW_EVENTS) {
window.addEventListener(eventName, scheduleObserve, true);
getTarget(eventName).addEventListener(eventName, scheduleObserve, true);
}

return () => {
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
for (const eventName of REFLOW_EVENTS) {
window.removeEventListener(eventName, scheduleObserve, true);
getTarget(eventName).removeEventListener(eventName, scheduleObserve, true);
}
};

Expand All @@ -36,9 +57,15 @@ export function useSticky<T extends HTMLElement>(elemRef: React.RefObject<T>) {
function observe() {
rafId = null;
if (!elemRef.current) return;
const refPageOffset = elemRef.current.getBoundingClientRect().top;
const stickyOffset = parseInt(getComputedStyle(elemRef.current).top, 10);
const stickyActive = refPageOffset <= stickyOffset;

let stickyActive: boolean;
if (scrollContainer !== null && naturalTopFromContainer !== null) {
stickyActive = scrollContainer.scrollTop >= naturalTopFromContainer - stickyOffset;
} else {
const refPageOffset = elemRef.current.getBoundingClientRect().top;
stickyActive = refPageOffset <= stickyOffset;
}

if (stickyActive && !stickyRef.current) setSticky(true);
else if (!stickyActive && stickyRef.current) setSticky(false);
Expand Down
Loading