From 216dea1a36d1c05108ac5cfcec84a7808ecf1d6e Mon Sep 17 00:00:00 2001 From: Kristóf Marussy Date: Mon, 31 Oct 2022 19:15:21 -0400 Subject: feat(frontend): overlay scrollbars for editor --- subprojects/frontend/src/editor/EditorArea.tsx | 22 +-- .../frontend/src/editor/EditorAreaDecorations.tsx | 148 ----------------- subprojects/frontend/src/editor/EditorTheme.ts | 99 ++++++++++-- .../frontend/src/editor/createEditorState.ts | 2 + .../frontend/src/editor/scrollbarViewPlugin.ts | 177 +++++++++++++++++++++ 5 files changed, 272 insertions(+), 176 deletions(-) delete mode 100644 subprojects/frontend/src/editor/EditorAreaDecorations.tsx create mode 100644 subprojects/frontend/src/editor/scrollbarViewPlugin.ts diff --git a/subprojects/frontend/src/editor/EditorArea.tsx b/subprojects/frontend/src/editor/EditorArea.tsx index e6227672..95f0f92e 100644 --- a/subprojects/frontend/src/editor/EditorArea.tsx +++ b/subprojects/frontend/src/editor/EditorArea.tsx @@ -1,9 +1,8 @@ import Box from '@mui/material/Box'; import { useTheme } from '@mui/material/styles'; import { observer } from 'mobx-react-lite'; -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect } from 'react'; -import EditorAreaDecorations from './EditorAreaDecorations'; import type EditorStore from './EditorStore'; import EditorTheme from './EditorTheme'; @@ -15,41 +14,26 @@ export default observer(function EditorArea({ const { palette: { mode: paletteMode }, } = useTheme(); - const [parent, setParent] = useState(); - const [scroller, setScroller] = useState(); useEffect( () => editorStore.setDarkMode(paletteMode === 'dark'), [editorStore, paletteMode], ); - const parentRef = useCallback( - (value: HTMLElement | null) => setParent(value ?? undefined), - [], - ); - const editorParentRef = useCallback( (editorParent: HTMLDivElement | null) => { editorStore.setEditorParent(editorParent ?? undefined); - setScroller(editorStore.view?.scrollDOM); }, - [editorStore, setScroller], + [editorStore], ); return ( - + - ); }); diff --git a/subprojects/frontend/src/editor/EditorAreaDecorations.tsx b/subprojects/frontend/src/editor/EditorAreaDecorations.tsx deleted file mode 100644 index e00cc290..00000000 --- a/subprojects/frontend/src/editor/EditorAreaDecorations.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import { type CSSObject, type Theme, styled } from '@mui/material/styles'; -import React, { memo, useEffect, useState } from 'react'; - -const SHADOW_SIZE = 10; - -const EditorAreaDecoration = styled('div')({ - position: 'absolute', - pointerEvents: 'none', -}); - -function shadowTheme( - origin: string, - scaleX: boolean, - scaleY: boolean, -): CSSObject { - function radialGradient(opacity: number, scale: string): string { - return `radial-gradient( - farthest-side at ${origin}, - rgba(0, 0, 0, ${opacity}), - rgba(0, 0, 0, 0) - ) - ${origin} / - ${scaleX ? scale : '100%'} - ${scaleY ? scale : '100%'} - no-repeat`; - } - - return { - background: ` - ${radialGradient(0.2, '40%')}, - ${radialGradient(0.14, '50%')}, - ${radialGradient(0.12, '100%')} - `, - }; -} - -function animateSize( - theme: Theme, - direction: 'height' | 'width', - opacity: number, -): CSSObject { - return { - [direction]: opacity * SHADOW_SIZE, - transition: theme.transitions.create(direction, { - duration: theme.transitions.duration.shortest, - }), - }; -} - -const TopDecoration = memo( - styled(EditorAreaDecoration, { - shouldForwardProp: (prop) => prop !== 'visible' && prop !== 'opacity', - })<{ - visible: boolean; - opacity: number; - }>(({ theme, visible, opacity }) => ({ - display: visible ? 'block' : 'none', - top: 0, - left: 0, - right: 0, - ...shadowTheme('50% 0', false, true), - ...animateSize(theme, 'height', opacity), - })), -); - -const GutterDecoration = memo( - styled(EditorAreaDecoration, { - shouldForwardProp: (prop) => - prop !== 'top' && - prop !== 'bottom' && - prop !== 'guttersWidth' && - prop !== 'opacity', - })<{ - top: number; - bottom: number; - guttersWidth: number; - opacity: number; - }>(({ theme, top, bottom, guttersWidth, opacity }) => ({ - top, - left: guttersWidth, - bottom, - ...shadowTheme('0 50%', true, false), - ...animateSize(theme, 'width', opacity), - })), -); - -function convertToOpacity(scroll: number): number { - return Math.max(0, Math.min(1, scroll / SHADOW_SIZE)); -} - -export default function EditorAreaDecorations({ - parent, - scroller, -}: { - parent: HTMLElement | undefined; - scroller: HTMLElement | undefined; -}): JSX.Element { - const [top, setTop] = useState(0); - const [bottom, setBottom] = useState(0); - const [guttersWidth, setGuttersWidth] = useState(0); - const [topOpacity, setTopOpacity] = useState(0); - const [gutterOpacity, setGutterOpacity] = useState(0); - - useEffect(() => { - if (parent === undefined || scroller === undefined) { - return () => {}; - } - const gutters = scroller.querySelector('.cm-gutters'); - - const updateBounds = () => { - const parentRect = parent.getBoundingClientRect(); - const rect = scroller.getBoundingClientRect(); - setTop(rect.top - parentRect.top); - setBottom(parentRect.bottom - rect.bottom); - setGuttersWidth(gutters?.clientWidth ?? 0); - }; - updateBounds(); - const resizeObserver = new ResizeObserver(updateBounds); - resizeObserver.observe(scroller); - if (gutters !== null) { - resizeObserver.observe(gutters); - } - - const updateScroll = () => { - setTopOpacity(convertToOpacity(scroller.scrollTop)); - setGutterOpacity(convertToOpacity(scroller.scrollLeft)); - }; - updateScroll(); - scroller.addEventListener('scroll', updateScroll); - - return () => { - resizeObserver.disconnect(); - scroller.removeEventListener('scroll', updateScroll); - }; - }, [parent, scroller, setTop]); - - return ( - <> - - - - ); -} diff --git a/subprojects/frontend/src/editor/EditorTheme.ts b/subprojects/frontend/src/editor/EditorTheme.ts index 325f8d18..829b709f 100644 --- a/subprojects/frontend/src/editor/EditorTheme.ts +++ b/subprojects/frontend/src/editor/EditorTheme.ts @@ -4,15 +4,36 @@ import infoSVG from '@material-icons/svg/svg/info/baseline.svg?raw'; import warningSVG from '@material-icons/svg/svg/warning/baseline.svg?raw'; import { alpha, styled, type CSSObject } from '@mui/material/styles'; -import { - INDENTATION_MARKER_ACTIVE_CLASS, - INDENTATION_MARKER_CLASS, -} from './indentationMarkerViewPlugin'; - function svgURL(svg: string): string { return `url('data:image/svg+xml;utf8,${svg}')`; } +function radialShadowTheme( + origin: string, + scaleX: boolean, + scaleY: boolean, +): CSSObject { + function radialGradient(opacity: number, scale: string): string { + return `radial-gradient( + farthest-side at ${origin}, + rgba(0, 0, 0, ${opacity}), + rgba(0, 0, 0, 0) + ) + ${origin} / + ${scaleX ? scale : '100%'} + ${scaleY ? scale : '100%'} + no-repeat`; + } + + return { + background: ` + ${radialGradient(0.2, '40%')}, + ${radialGradient(0.14, '50%')}, + ${radialGradient(0.12, '100%')} + `, + }; +} + export default styled('div', { name: 'EditorTheme', shouldForwardProp: (propName) => @@ -36,8 +57,68 @@ export default styled('div', { '&, .cm-editor': { height: '100%', }, + '.cm-scroller-holder': { + display: 'flex', + position: 'relative', + flexDirection: 'column', + overflow: 'hidden', + flex: '1 1', + }, '.cm-scroller': { color: theme.palette.text.secondary, + scrollbarWidth: 'none', + MsOverflowStyle: 'none', + '&::-webkit-scrollbar': { + width: 0, + height: 0, + background: 'transparent', + }, + }, + '.cm-scroller-thumb': { + position: 'absolute', + background: theme.palette.text.secondary, + opacity: theme.palette.mode === 'dark' ? 0.16 : 0.28, + transition: theme.transitions.create('opacity', { + duration: theme.transitions.duration.shortest, + }), + '&:hover': { + opacity: 0.75, + }, + '&.active': { + opacity: 1, + pointerEvents: 'none', + userSelect: 'none', + }, + }, + '.cm-scroller-thumb-y': { + top: 0, + right: 0, + }, + '.cm-scroller-thumb-x': { + left: 0, + bottom: 0, + }, + '.cm-scroller-gutter-decoration': { + position: 'absolute', + top: 0, + bottom: 0, + left: 0, + width: 0, + transition: theme.transitions.create('width', { + duration: theme.transitions.duration.shortest, + }), + ...radialShadowTheme('0 50%', true, false), + }, + '.cm-scroller-top-decoration': { + position: 'absolute', + top: 0, + left: 0, + right: 0, + height: 0, + transition: theme.transitions.create('height', { + duration: theme.transitions.duration.shortest, + }), + ...radialShadowTheme('50% 0', false, true), }, '.cm-gutters': { background: theme.palette.background.default, @@ -157,12 +238,12 @@ export default styled('div', { '.cm-searchMatch-selected': { background: theme.palette.highlight.search.selected, }, - [`.${INDENTATION_MARKER_CLASS}`]: { + '.cm-indentation-marker': { display: 'inline-block', boxShadow: `1px 0 0 ${theme.palette.highlight.lineNumber} inset`, - }, - [`.${INDENTATION_MARKER_CLASS}.${INDENTATION_MARKER_ACTIVE_CLASS}`]: { - boxShadow: `1px 0 0 ${theme.palette.text.primary} inset`, + '&.active': { + boxShadow: `1px 0 0 ${theme.palette.text.primary} inset`, + }, }, }; diff --git a/subprojects/frontend/src/editor/createEditorState.ts b/subprojects/frontend/src/editor/createEditorState.ts index 079e8a47..05028fcc 100644 --- a/subprojects/frontend/src/editor/createEditorState.ts +++ b/subprojects/frontend/src/editor/createEditorState.ts @@ -38,6 +38,7 @@ import type EditorStore from './EditorStore'; import SearchPanel from './SearchPanel'; import findOccurrences from './findOccurrences'; import indentationMarkerViewPlugin from './indentationMarkerViewPlugin'; +import scrollbarViewPlugin from './scrollbarViewPlugin'; import semanticHighlighting from './semanticHighlighting'; export default function createEditorState( @@ -120,6 +121,7 @@ export default function createEditorState( ...defaultKeymap, ]), problemLanguageSupport(), + scrollbarViewPlugin(store), ], }); } diff --git a/subprojects/frontend/src/editor/scrollbarViewPlugin.ts b/subprojects/frontend/src/editor/scrollbarViewPlugin.ts new file mode 100644 index 00000000..2882f02e --- /dev/null +++ b/subprojects/frontend/src/editor/scrollbarViewPlugin.ts @@ -0,0 +1,177 @@ +import { type PluginValue, ViewPlugin } from '@codemirror/view'; +import { reaction } from 'mobx'; + +import type EditorStore from './EditorStore'; + +export const HOLDER_CLASS = 'cm-scroller-holder'; +export const THUMB_CLASS = 'cm-scroller-thumb'; +export const THUMB_Y_CLASS = 'cm-scroller-thumb-y'; +export const THUMB_X_CLASS = 'cm-scroller-thumb-x'; +export const THUMB_ACTIVE_CLASS = 'active'; +export const GUTTER_DECORATION_CLASS = 'cm-scroller-gutter-decoration'; +export const TOP_DECORATION_CLASS = 'cm-scroller-top-decoration'; +export const SHADOW_WIDTH = 10; +export const SCROLLBAR_WIDTH = 12; + +export default function scrollbarViewPlugin( + editorStore: EditorStore, +): ViewPlugin { + return ViewPlugin.define((view) => { + const { scrollDOM } = view; + const { ownerDocument, parentElement: parentDOM } = scrollDOM; + if (parentDOM === null) { + return {}; + } + + const holder = ownerDocument.createElement('div'); + holder.className = HOLDER_CLASS; + parentDOM.replaceChild(holder, scrollDOM); + holder.appendChild(scrollDOM); + + let factorY = 1; + let factorX = 1; + + const thumbY = ownerDocument.createElement('div'); + thumbY.className = `${THUMB_CLASS} ${THUMB_Y_CLASS}`; + const scrollY = (event: MouseEvent) => { + scrollDOM.scrollBy({ top: event.movementY / factorY }); + event.preventDefault(); + }; + const stopScrollY = () => { + thumbY.classList.remove(THUMB_ACTIVE_CLASS); + window.removeEventListener('mousemove', scrollY); + window.removeEventListener('mouseup', stopScrollY); + }; + thumbY.addEventListener( + 'mousedown', + () => { + thumbY.classList.add(THUMB_ACTIVE_CLASS); + window.addEventListener('mousemove', scrollY); + window.addEventListener('mouseup', stopScrollY, { passive: true }); + }, + { passive: true }, + ); + holder.appendChild(thumbY); + + const thumbX = ownerDocument.createElement('div'); + thumbX.className = `${THUMB_CLASS} ${THUMB_X_CLASS}`; + const scrollX = (event: MouseEvent) => { + scrollDOM.scrollBy({ left: event.movementX / factorX }); + }; + const stopScrollX = () => { + thumbX.classList.remove(THUMB_ACTIVE_CLASS); + window.removeEventListener('mousemove', scrollX); + window.removeEventListener('mouseup', stopScrollX); + }; + thumbX.addEventListener( + 'mousedown', + () => { + thumbX.classList.add(THUMB_ACTIVE_CLASS); + window.addEventListener('mousemove', scrollX); + window.addEventListener('mouseup', stopScrollX, { passive: true }); + }, + { passive: true }, + ); + holder.appendChild(thumbX); + + const gutterDecoration = ownerDocument.createElement('div'); + gutterDecoration.className = GUTTER_DECORATION_CLASS; + holder.appendChild(gutterDecoration); + + const topDecoration = ownerDocument.createElement('div'); + topDecoration.className = TOP_DECORATION_CLASS; + holder.appendChild(topDecoration); + + const disposePanelReaction = reaction( + () => editorStore.searchPanel.state, + (panelOpen) => { + topDecoration.style.display = panelOpen ? 'none' : 'block'; + }, + { fireImmediately: true }, + ); + + let observer: ResizeObserver | undefined; + let gutters: Element | undefined; + + let requested = false; + + function update() { + requested = false; + + if (gutters === undefined) { + gutters = scrollDOM.querySelector('.cm-gutters') ?? undefined; + if (gutters !== undefined && observer !== undefined) { + observer.observe(gutters); + } + } + + const { height: scrollerHeight, width: scrollerWidth } = + scrollDOM.getBoundingClientRect(); + const { scrollTop, scrollHeight, scrollLeft, scrollWidth } = scrollDOM; + const gutterWidth = gutters?.clientWidth ?? 0; + let trackYHeight = scrollerHeight; + + if (scrollWidth > scrollerWidth) { + // Leave space for horizontal scrollbar. + trackYHeight -= SCROLLBAR_WIDTH; + // Alwalys leave space for annotation in the vertical scrollbar. + const trackXWidth = scrollerWidth - gutterWidth - SCROLLBAR_WIDTH; + const thumbWidth = trackXWidth * (scrollerWidth / scrollWidth); + factorX = (trackXWidth - thumbWidth) / (scrollWidth - scrollerWidth); + thumbX.style.display = 'block'; + thumbX.style.height = `${SCROLLBAR_WIDTH}px`; + thumbX.style.width = `${thumbWidth}px`; + thumbX.style.left = `${gutterWidth + scrollLeft * factorX}px`; + } else { + thumbX.style.display = 'none'; + } + + if (scrollHeight > scrollerHeight) { + const thumbHeight = trackYHeight * (scrollerHeight / scrollHeight); + factorY = + (trackYHeight - thumbHeight) / (scrollHeight - scrollerHeight); + thumbY.style.display = 'block'; + thumbY.style.height = `${thumbHeight}px`; + thumbY.style.width = `${SCROLLBAR_WIDTH}px`; + thumbY.style.top = `${scrollTop * factorY}px`; + } else { + thumbY.style.display = 'none'; + } + + gutterDecoration.style.left = `${gutterWidth}px`; + gutterDecoration.style.width = `${Math.max( + 0, + Math.min(scrollLeft, SHADOW_WIDTH), + )}px`; + + topDecoration.style.height = `${Math.max( + 0, + Math.min(scrollTop, SHADOW_WIDTH), + )}px`; + } + + function requestUpdate() { + if (!requested) { + requested = true; + view.requestMeasure({ read: update }); + } + } + + observer = new ResizeObserver(requestUpdate); + observer.observe(scrollDOM); + + scrollDOM.addEventListener('scroll', requestUpdate); + + requestUpdate(); + + return { + update: requestUpdate, + destroy() { + disposePanelReaction(); + observer?.disconnect(); + scrollDOM.removeEventListener('scroll', requestUpdate); + parentDOM.replaceChild(scrollDOM, holder); + }, + }; + }); +} -- cgit v1.2.3-70-g09d2