diff --git a/demo/src/stories/experiments/sticky-toolbar/Editor.tsx b/demo/src/stories/experiments/sticky-toolbar/Editor.tsx new file mode 100644 index 000000000..d61e765b4 --- /dev/null +++ b/demo/src/stories/experiments/sticky-toolbar/Editor.tsx @@ -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(null); + +
+ +
+\`\`\` + +## 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( + function EditorWithStickyInContainer() { + const scrollContainerRef = useRef(null); + + const editor = useMarkdownEditor({ + initial: {markup: initialMarkup, mode: 'wysiwyg'}, + }); + + return ( + ( +
+ + Scroll container + + +
+ )} + /> + ); + }, +); diff --git a/demo/src/stories/experiments/sticky-toolbar/StickyToolbar.stories.tsx b/demo/src/stories/experiments/sticky-toolbar/StickyToolbar.stories.tsx new file mode 100644 index 000000000..d907e7df3 --- /dev/null +++ b/demo/src/stories/experiments/sticky-toolbar/StickyToolbar.stories.tsx @@ -0,0 +1,13 @@ +import type {StoryObj} from '@storybook/react'; + +import {EditorWithStickyInContainer as component} from './Editor'; + +export const Story: StoryObj = { + args: {}, +}; +Story.storyName = 'Sticky Toolbar in Scroll Container'; + +export default { + title: 'Experiments / Sticky Toolbar in Scroll Container', + component, +}; diff --git a/packages/editor/src/bundle/MarkdownEditorView.scss b/packages/editor/src/bundle/MarkdownEditorView.scss index 16f10bf46..0cce2f843 100644 --- a/packages/editor/src/bundle/MarkdownEditorView.scss +++ b/packages/editor/src/bundle/MarkdownEditorView.scss @@ -13,6 +13,10 @@ } } + &_withScrollContainer { + height: auto; + } + &__editor { flex-grow: 1; gap: 2px; diff --git a/packages/editor/src/bundle/MarkdownEditorView.tsx b/packages/editor/src/bundle/MarkdownEditorView.tsx index 6d895e206..3f2b9f548 100644 --- a/packages/editor/src/bundle/MarkdownEditorView.tsx +++ b/packages/editor/src/bundle/MarkdownEditorView.tsx @@ -54,6 +54,7 @@ const EditorWrapper = forwardRef( markupHiddenActionsConfig: initialMarkupHiddenActionsConfig, markupToolbarConfig: initialMarkupToolbarConfig, qa, + scrollContainerRef, settingsVisible: settingsVisibleProp, stickyToolbar, toolbarsPreset, @@ -148,6 +149,7 @@ const EditorWrapper = forwardRef( onSplitModeChange, onToolbarVisibilityChange, renderPreviewButton: canRenderPreview, + scrollContainerRef, showPreview, splitMode: editor.splitMode, splitModeEnabled: editor.splitModeEnabled, @@ -195,6 +197,7 @@ const EditorWrapper = forwardRef( toolbarClassName={b('toolbar')} stickyToolbar={stickyToolbar} toolbarDisplay={toolbarDisplay} + scrollContainerRef={scrollContainerRef} > ( toolbarClassName={b('toolbar')} stickyToolbar={stickyToolbar} toolbarDisplay={toolbarDisplay} + scrollContainerRef={scrollContainerRef} > ; }; export type MarkdownEditorViewProps = ClassNameProps & ToolbarConfigs & ViewProps & QAProps & {}; @@ -276,6 +281,7 @@ export const MarkdownEditorView = forwardRef = ({mode}) => (
); -function Settings(props: EditorSettingsProps & {stickyToolbar: boolean}) { +function Settings( + props: EditorSettingsProps & { + stickyToolbar: boolean; + scrollContainerRef?: React.RefObject; + }, +) { const wrapperRef = useRef(null); - const isSticky = useSticky(wrapperRef) && props.toolbarVisibility && props.stickyToolbar; + const isSticky = + useSticky(wrapperRef, props.scrollContainerRef) && + props.toolbarVisibility && + props.stickyToolbar; return ( <> diff --git a/packages/editor/src/bundle/MarkupEditorView.tsx b/packages/editor/src/bundle/MarkupEditorView.tsx index fc8ae8ff1..1233b5e06 100644 --- a/packages/editor/src/bundle/MarkupEditorView.tsx +++ b/packages/editor/src/bundle/MarkupEditorView.tsx @@ -32,6 +32,7 @@ export type MarkupEditorViewProps = ClassNameProps & hiddenActionsConfig?: MToolbarItemData[]; children?: React.ReactNode; toolbarDisplay?: ToolbarDisplay; + scrollContainerRef?: React.RefObject; }; export const MarkupEditorView = memo((props) => { @@ -48,6 +49,7 @@ export const MarkupEditorView = memo((props) => { children, stickyToolbar = true, toolbarDisplay, + scrollContainerRef, } = props; useRenderTime((time) => { globalLogger.metrics({ @@ -83,6 +85,7 @@ export const MarkupEditorView = memo((props) => { settingsVisible={settingsVisible} className={b('toolbar', [toolbarClassName])} toolbarDisplay={toolbarDisplay} + scrollContainerRef={scrollContainerRef} > {children} diff --git a/packages/editor/src/bundle/ToolbarView.tsx b/packages/editor/src/bundle/ToolbarView.tsx index d03749e7e..8cdbf25cf 100644 --- a/packages/editor/src/bundle/ToolbarView.tsx +++ b/packages/editor/src/bundle/ToolbarView.tsx @@ -37,6 +37,7 @@ export type ToolbarViewProps = ClassNameProps & children?: React.ReactNode; stickyToolbar: boolean; toolbarDisplay?: ToolbarDisplay; + scrollContainerRef?: React.RefObject; }; export function ToolbarView({ @@ -50,10 +51,11 @@ export function ToolbarView({ className, children, stickyToolbar, + scrollContainerRef, qa, }: ToolbarViewProps) { const wrapperRef = useRef(null); - const isStickyActive = useSticky(wrapperRef) && stickyToolbar; + const isStickyActive = useSticky(wrapperRef, scrollContainerRef) && stickyToolbar; const mobile = editor.mobile; diff --git a/packages/editor/src/bundle/WysiwygEditorView.tsx b/packages/editor/src/bundle/WysiwygEditorView.tsx index 098afd1e6..4517537c7 100644 --- a/packages/editor/src/bundle/WysiwygEditorView.tsx +++ b/packages/editor/src/bundle/WysiwygEditorView.tsx @@ -26,6 +26,7 @@ export type WysiwygEditorViewProps = ClassNameProps & hiddenActionsConfig?: WToolbarItemData[]; children?: React.ReactNode; toolbarDisplay?: ToolbarDisplay; + scrollContainerRef?: React.RefObject; }; export const WysiwygEditorView: React.FC = (props) => { @@ -42,6 +43,7 @@ export const WysiwygEditorView: React.FC = (props) => { children, stickyToolbar = true, toolbarDisplay, + scrollContainerRef, } = props; useRenderTime((time) => { @@ -71,6 +73,7 @@ export const WysiwygEditorView: React.FC = (props) => { settingsVisible={settingsVisible} className={b('toolbar', [toolbarClassName])} toolbarDisplay={toolbarDisplay} + scrollContainerRef={scrollContainerRef} > {children} diff --git a/packages/editor/src/react-utils/useSticky.ts b/packages/editor/src/react-utils/useSticky.ts index db0b905c8..b6e73ded2 100644 --- a/packages/editor/src/react-utils/useSticky.ts +++ b/packages/editor/src/react-utils/useSticky.ts @@ -4,17 +4,38 @@ import {useEffectOnce, useLatest} from 'react-use'; import {REFLOW_EVENTS} from 'src/utils/dom'; -export function useSticky(elemRef: React.RefObject) { +const CONTAINER_EVENTS = new Set([ + 'scroll', + 'touchstart', + 'touchmove', + 'touchend', +]); + +export function useSticky( + elemRef: React.RefObject, + scrollContainerRef?: React.RefObject, +) { 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; + + 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 () => { @@ -22,7 +43,7 @@ export function useSticky(elemRef: React.RefObject) { cancelAnimationFrame(rafId); } for (const eventName of REFLOW_EVENTS) { - window.removeEventListener(eventName, scheduleObserve, true); + getTarget(eventName).removeEventListener(eventName, scheduleObserve, true); } }; @@ -36,9 +57,15 @@ export function useSticky(elemRef: React.RefObject) { 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);