From 6d1f78af96e4c0d1ad76c5791b93b0c2d83810e1 Mon Sep 17 00:00:00 2001 From: Kristóf Marussy Date: Thu, 8 Sep 2022 02:51:52 +0200 Subject: feat(frontend): editor area scroll shadow styling --- subprojects/frontend/package.json | 4 +- subprojects/frontend/src/editor/EditorArea.tsx | 31 ++++- .../frontend/src/editor/EditorAreaDecorations.tsx | 149 +++++++++++++++++++++ subprojects/frontend/src/editor/EditorPane.tsx | 2 +- subprojects/frontend/src/editor/EditorTheme.ts | 11 +- 5 files changed, 181 insertions(+), 16 deletions(-) create mode 100644 subprojects/frontend/src/editor/EditorAreaDecorations.tsx (limited to 'subprojects') diff --git a/subprojects/frontend/package.json b/subprojects/frontend/package.json index af345777..0de7ce47 100644 --- a/subprojects/frontend/package.json +++ b/subprojects/frontend/package.json @@ -23,13 +23,13 @@ }, "homepage": "https://refinery.tools", "dependencies": { - "@codemirror/autocomplete": "^6.1.0", + "@codemirror/autocomplete": "^6.1.1", "@codemirror/commands": "^6.1.0", "@codemirror/language": "^6.2.1", "@codemirror/lint": "^6.0.0", "@codemirror/search": "^6.2.0", "@codemirror/state": "^6.1.1", - "@codemirror/view": "^6.2.2", + "@codemirror/view": "^6.2.3", "@emotion/react": "^11.10.4", "@emotion/styled": "^11.10.4", "@fontsource/inter": "^4.5.12", diff --git a/subprojects/frontend/src/editor/EditorArea.tsx b/subprojects/frontend/src/editor/EditorArea.tsx index 7c5ac5fb..174b1205 100644 --- a/subprojects/frontend/src/editor/EditorArea.tsx +++ b/subprojects/frontend/src/editor/EditorArea.tsx @@ -1,7 +1,9 @@ +import Box from '@mui/material/Box'; import { useTheme } from '@mui/material/styles'; import { observer } from 'mobx-react-lite'; -import React, { useCallback, useEffect } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; +import EditorAreaDecorations from './EditorAreaDecorations'; import type EditorStore from './EditorStore'; import EditorTheme from './EditorTheme'; @@ -13,23 +15,40 @@ 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], + [editorStore, setScroller], ); return ( - + + + + ); }); diff --git a/subprojects/frontend/src/editor/EditorAreaDecorations.tsx b/subprojects/frontend/src/editor/EditorAreaDecorations.tsx new file mode 100644 index 00000000..f33bf25f --- /dev/null +++ b/subprojects/frontend/src/editor/EditorAreaDecorations.tsx @@ -0,0 +1,149 @@ +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, + easing: theme.transitions.easing.sharp, + }), + }; +} + +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/EditorPane.tsx b/subprojects/frontend/src/editor/EditorPane.tsx index e433e714..bcd324e5 100644 --- a/subprojects/frontend/src/editor/EditorPane.tsx +++ b/subprojects/frontend/src/editor/EditorPane.tsx @@ -43,7 +43,7 @@ export default observer(function EditorPane(): JSX.Element { {showGenerateButton && } - + {editorStore === undefined ? ( ) : ( diff --git a/subprojects/frontend/src/editor/EditorTheme.ts b/subprojects/frontend/src/editor/EditorTheme.ts index dfeeb547..db051d0e 100644 --- a/subprojects/frontend/src/editor/EditorTheme.ts +++ b/subprojects/frontend/src/editor/EditorTheme.ts @@ -31,12 +31,7 @@ export default styled('div', { color: theme.palette.text.secondary, }, '.cm-gutters': { - background: `linear-gradient( - to right, - ${theme.palette.background.default} 0%, - ${theme.palette.background.default} calc(100% - 12px), - transparent 100% - )`, + background: theme.palette.background.default, border: 'none', }, '.cm-content': { @@ -170,7 +165,9 @@ export default styled('div', { '.cm-panels-top': { color: theme.palette.text.primary, borderBottom: `1px solid ${theme.palette.outer.border}`, - marginBottom: theme.spacing(1), + }, + '.cm-panels-top + div + .cm-scroller': { + paddingTop: theme.spacing(0.5), }, '.cm-panel': { color: theme.palette.text.primary, -- cgit v1.2.3-54-g00ecf