From c11d5248ffdcf1b2cc74c133c82608cfa5f09419 Mon Sep 17 00:00:00 2001 From: Morten Olsen Date: Fri, 16 Jun 2023 14:08:38 +0200 Subject: [PATCH] feat: masonry grid --- packages/ui/package.json | 6 +- packages/ui/src/base/index.ts | 1 + packages/ui/src/base/list/index.tsx | 11 +- packages/ui/src/base/masonry/index.tsx | 115 +++++++++++++++++++++ packages/ui/src/base/masonry/item.tsx | 29 ++++++ packages/ui/src/interface/board/index.tsx | 38 +++---- packages/ui/src/interface/widget/index.tsx | 10 +- packages/ui/src/typography/index.tsx | 1 + pnpm-lock.yaml | 23 ++++- 9 files changed, 207 insertions(+), 27 deletions(-) create mode 100644 packages/ui/src/base/masonry/index.tsx create mode 100644 packages/ui/src/base/masonry/item.tsx diff --git a/packages/ui/package.json b/packages/ui/package.json index 3e44ac5..c9ebc58 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -42,12 +42,14 @@ }, "types": "./dist/cjs/types/index.d.ts", "dependencies": { - "@refocus/sdk": "workspace:^", "@radix-ui/react-dialog": "^1.0.4", "@radix-ui/react-dropdown-menu": "^2.0.5", "@radix-ui/react-tabs": "^1.0.4", + "@refocus/sdk": "workspace:^", + "framer-motion": "^10.12.16", "react-icons": "^4.9.0", "react-markdown": "^6.0.3", - "styled-components": "6.0.0-rc.3" + "styled-components": "6.0.0-rc.3", + "usehooks-ts": "^2.9.1" } } diff --git a/packages/ui/src/base/index.ts b/packages/ui/src/base/index.ts index d9e9b53..08be80a 100644 --- a/packages/ui/src/base/index.ts +++ b/packages/ui/src/base/index.ts @@ -7,3 +7,4 @@ export * from './dialog'; export * from './list'; export * from './dropdown'; export * from './button'; +export * from './masonry'; diff --git a/packages/ui/src/base/list/index.tsx b/packages/ui/src/base/list/index.tsx index d98c9f7..a66c528 100644 --- a/packages/ui/src/base/list/index.tsx +++ b/packages/ui/src/base/list/index.tsx @@ -1,3 +1,4 @@ +import { AnimatePresence, motion } from 'framer-motion'; import { View } from '../view'; type ListProps = { @@ -7,7 +8,15 @@ type ListProps = { const List = ({ children }: ListProps) => { return ( - {children} + + + {children} + + ); }; diff --git a/packages/ui/src/base/masonry/index.tsx b/packages/ui/src/base/masonry/index.tsx new file mode 100644 index 0000000..a6b476b --- /dev/null +++ b/packages/ui/src/base/masonry/index.tsx @@ -0,0 +1,115 @@ +import { + Children, + useCallback, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { motion } from 'framer-motion'; +import { MasonryItem } from './item'; +import { styled } from 'styled-components'; +import { useDebounce } from 'usehooks-ts'; + +type Props = { + children: React.ReactNode; +}; + +const maxColumnWidth = 400; +const gutter = 16; + +const ItemWrapper = styled(motion.div)<{}>` + position: absolute; +`; + +const Masonry = ({ children }: Props) => { + const ref = useRef(null); + const [columns, setColumns] = useState(1); + const [columnWidth, setColumnWidth] = useState(0); + const [heights, setHeights] = useState([]); + const setHeight = useCallback((index: number, height: number) => { + setHeights((current) => { + if (current[index] === height) { + return current; + } + const next = [...current]; + next[index] = height; + return next; + }); + }, []); + const elements = useMemo(() => { + return Children.toArray(children).map((child, index) => { + const setItemHeight = (height: number) => { + setHeight(index, height); + }; + return ( + + {child} + + ); + }); + }, [children, setHeight]); + + const layout = useMemo(() => { + const columnHeights = Array.from({ length: columns }).map(() => 0); + return heights.map((height) => { + const lowestColumn = columnHeights.indexOf(Math.min(...columnHeights)); + const currentHeight = columnHeights[lowestColumn]; + columnHeights[lowestColumn] += height + gutter; + const y = currentHeight; + const margin = gutter / 2; + const marginLeft = lowestColumn === 0 ? 0 : margin; + const marginRight = lowestColumn === columns - 1 ? 0 : margin; + const x = lowestColumn * columnWidth + marginLeft; + const width = columnWidth - marginLeft - marginRight; + return { x, y, width }; + }); + }, [heights, columns, columnWidth]); + + const updateSize = useCallback(() => { + if (!ref.current) { + return; + } + const nextWidth = ref.current.getBoundingClientRect().width; + const nextColumns = Math.max(Math.floor(nextWidth / maxColumnWidth), 1); + const nextColumnWidth = nextWidth / nextColumns; + setColumns(nextColumns); + setColumnWidth(nextColumnWidth); + }, []); + + useLayoutEffect(() => { + if (!ref.current) { + return; + } + const observer = new ResizeObserver(() => { + updateSize(); + }); + observer.observe(ref.current); + return () => { + observer.disconnect(); + }; + }, [updateSize]); + + const debouncedLayout = useDebounce(layout, 10); + + return ( +
+ {elements.map((element, index) => ( + + {element} + + ))} +
+ ); +}; + +export { Masonry }; diff --git a/packages/ui/src/base/masonry/item.tsx b/packages/ui/src/base/masonry/item.tsx new file mode 100644 index 0000000..45a622b --- /dev/null +++ b/packages/ui/src/base/masonry/item.tsx @@ -0,0 +1,29 @@ +import { useLayoutEffect, useRef } from 'react'; + +type Props = { + children: React.ReactNode; + setHeight: (height: number) => void; +}; + +const MasonryItem: React.FC = ({ children, setHeight }) => { + const ref = useRef(null); + + useLayoutEffect(() => { + if (!ref.current) { + return; + } + const height = ref.current.getBoundingClientRect().height; + setHeight(height); + const observer = new ResizeObserver((entries) => { + setHeight(entries[0].contentRect.height); + }); + observer.observe(ref.current); + return () => { + observer.disconnect(); + }; + }, [setHeight]); + + return
{children}
; +}; + +export { MasonryItem }; diff --git a/packages/ui/src/interface/board/index.tsx b/packages/ui/src/interface/board/index.tsx index dfbb6a5..b8f4919 100644 --- a/packages/ui/src/interface/board/index.tsx +++ b/packages/ui/src/interface/board/index.tsx @@ -5,7 +5,7 @@ import { useUpdateWidget, } from '@refocus/sdk'; import { IoAddCircleOutline } from 'react-icons/io5'; -import { View } from '../../base'; +import { Masonry, View } from '../../base'; import { Widget } from '../widget'; import { AddWidgetFromUrl } from '../add-from-url'; import { styled } from 'styled-components'; @@ -15,14 +15,12 @@ type BoardProps = { id: string; }; -const Wrapper = styled(View)` - flex-wrap: wrap; -`; - const ItemWrapper = styled(View)` - max-width: 400px; overflow-y: auto; max-height: 500px; + max-width: 100%; + box-shadow: 0 0 4px 0px ${({ theme }) => theme.colors.bg.highlight}; + border-radius: ${({ theme }) => theme.radii.md}px; `; const Board: React.FC = ({ board, id }) => { @@ -42,19 +40,21 @@ const Board: React.FC = ({ board, id }) => { - - {Object.entries(board.widgets).map(([widgetId, widget]) => ( - - setWidgetData(id, widgetId, data)} - onRemove={() => removeWidget(id, widgetId)} - /> - - ))} - + + + {Object.entries(board.widgets).map(([widgetId, widget]) => ( + + setWidgetData(id, widgetId, data)} + onRemove={() => removeWidget(id, widgetId)} + /> + + ))} + + ); }; diff --git a/packages/ui/src/interface/widget/index.tsx b/packages/ui/src/interface/widget/index.tsx index 2024afe..e49d84d 100644 --- a/packages/ui/src/interface/widget/index.tsx +++ b/packages/ui/src/interface/widget/index.tsx @@ -23,6 +23,12 @@ const Wrapper = styled(View)` background: ${({ theme }) => theme.colors.bg.base}; `; +const WidgetWrapper = styled(View)` + flex-grow: 0; + overflow: hidden; + flex: 1; +`; + const Widget: React.FC = ({ id, data, @@ -47,9 +53,9 @@ const Widget: React.FC = ({ return ( - + - + {hasMenu && ( diff --git a/packages/ui/src/typography/index.tsx b/packages/ui/src/typography/index.tsx index 015dcd5..4fa732b 100644 --- a/packages/ui/src/typography/index.tsx +++ b/packages/ui/src/typography/index.tsx @@ -33,6 +33,7 @@ const styles = { font-size: 10px; font-weight: bold; text-transform: uppercase; + break-word: break-all; `, } satisfies Record>; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 69c34d0..835da99 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -147,9 +147,6 @@ importers: packages/ui: dependencies: - '@refocus/sdk': - specifier: workspace:^ - version: link:../sdk '@radix-ui/react-dialog': specifier: ^1.0.4 version: 1.0.4(@types/react@18.0.37)(react-dom@18.2.0)(react@18.2.0) @@ -159,6 +156,12 @@ importers: '@radix-ui/react-tabs': specifier: ^1.0.4 version: 1.0.4(@types/react@18.0.37)(react-dom@18.2.0)(react@18.2.0) + '@refocus/sdk': + specifier: workspace:^ + version: link:../sdk + framer-motion: + specifier: ^10.12.16 + version: 10.12.16(react-dom@18.2.0)(react@18.2.0) react-icons: specifier: ^4.9.0 version: 4.9.0(react@18.2.0) @@ -168,6 +171,9 @@ importers: styled-components: specifier: 6.0.0-rc.3 version: 6.0.0-rc.3(react-dom@18.2.0)(react@18.2.0) + usehooks-ts: + specifier: ^2.9.1 + version: 2.9.1(react-dom@18.2.0)(react@18.2.0) devDependencies: '@refocus/config': specifier: workspace:^ @@ -10524,6 +10530,17 @@ packages: tslib: 2.5.3 dev: false + /usehooks-ts@2.9.1(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-2FAuSIGHlY+apM9FVlj8/oNhd+1y+Uwv5QNkMQz1oSfdHk4PXo1qoCw9I5M7j0vpH8CSWFJwXbVPeYDjLCx9PA==} + engines: {node: '>=16.15.0', npm: '>=8'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} dev: true