mirror of
https://github.com/toeverything/AFFiNE.git
synced 2024-12-30 07:51:51 +03:00
feat(component, mobile): masonry layout with virtual scroll support, adapted with all docs
This commit is contained in:
parent
c6603ad182
commit
6d4ec5437d
@ -17,6 +17,7 @@ export * from './ui/loading';
|
||||
export * from './ui/lottie/collections-icon';
|
||||
export * from './ui/lottie/delete-icon';
|
||||
export * from './ui/lottie/folder-icon';
|
||||
export * from './ui/masonry';
|
||||
export * from './ui/menu';
|
||||
export * from './ui/modal';
|
||||
export * from './ui/notification';
|
||||
|
29
packages/frontend/component/src/ui/masonry/context.ts
Normal file
29
packages/frontend/component/src/ui/masonry/context.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
export interface MasonryItem {
|
||||
id: string;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface LayoutInfo {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface MasonryContext {
|
||||
addItem: (item: MasonryItem, index?: number) => void;
|
||||
removeItem: (id: string) => void;
|
||||
items: MasonryItem[];
|
||||
layoutMap: Map<MasonryItem['id'], LayoutInfo>;
|
||||
sleepMap: Map<MasonryItem['id'], boolean>;
|
||||
}
|
||||
|
||||
export const MasonryContext = createContext<MasonryContext>({
|
||||
addItem: () => {},
|
||||
removeItem: () => {},
|
||||
items: [],
|
||||
layoutMap: new Map(),
|
||||
sleepMap: new Map(),
|
||||
});
|
7
packages/frontend/component/src/ui/masonry/index.ts
Normal file
7
packages/frontend/component/src/ui/masonry/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { MasonryItem } from './masonry-item';
|
||||
import { MasonryRoot } from './masonry-root';
|
||||
|
||||
export const Masonry = {
|
||||
Item: MasonryItem,
|
||||
Root: MasonryRoot,
|
||||
};
|
67
packages/frontend/component/src/ui/masonry/masonry-item.tsx
Normal file
67
packages/frontend/component/src/ui/masonry/masonry-item.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import clsx from 'clsx';
|
||||
import { useContext, useEffect, useMemo } from 'react';
|
||||
|
||||
import { MasonryContext, type MasonryItem as MasonryItemType } from './context';
|
||||
import * as styles from './styles.css';
|
||||
export interface MasonryItemProps
|
||||
extends Omit<React.HTMLAttributes<HTMLDivElement>, 'id' | 'height'>,
|
||||
MasonryItemType {
|
||||
locateMode?: 'transform' | 'leftTop' | 'transform3d';
|
||||
}
|
||||
|
||||
export const MasonryItem = ({
|
||||
id,
|
||||
height,
|
||||
className,
|
||||
children,
|
||||
locateMode = 'leftTop',
|
||||
style: styleProp,
|
||||
...props
|
||||
}: MasonryItemProps) => {
|
||||
const { addItem, removeItem, layoutMap, sleepMap } =
|
||||
useContext(MasonryContext);
|
||||
|
||||
useEffect(() => {
|
||||
addItem({ id, height });
|
||||
return () => {
|
||||
removeItem(id);
|
||||
};
|
||||
}, [addItem, height, id, removeItem]);
|
||||
|
||||
const style = useMemo(() => {
|
||||
const info = layoutMap.get(id);
|
||||
if (!info) return { display: 'none' };
|
||||
|
||||
const { x, y, w, h } = info;
|
||||
|
||||
const posStyle =
|
||||
locateMode === 'transform'
|
||||
? { transform: `translate(${x}px, ${y}px)` }
|
||||
: locateMode === 'leftTop'
|
||||
? { left: `${x}px`, top: `${y}px` }
|
||||
: { transform: `translate3d(${x}px, ${y}px, 0)` };
|
||||
|
||||
return {
|
||||
left: 0,
|
||||
top: 0,
|
||||
...styleProp,
|
||||
...posStyle,
|
||||
width: `${w}px`,
|
||||
height: `${h}px`,
|
||||
};
|
||||
}, [id, layoutMap, locateMode, styleProp]);
|
||||
|
||||
if (sleepMap.get(id)) return null;
|
||||
if (!layoutMap.get(id)) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-masonry-item
|
||||
className={clsx(styles.item, className)}
|
||||
style={style}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
179
packages/frontend/component/src/ui/masonry/masonry-root.tsx
Normal file
179
packages/frontend/component/src/ui/masonry/masonry-root.tsx
Normal file
@ -0,0 +1,179 @@
|
||||
import clsx from 'clsx';
|
||||
import { throttle } from 'lodash-es';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { observeResize } from '../../utils';
|
||||
import { Scrollable } from '../scrollbar';
|
||||
import { type LayoutInfo, MasonryContext, type MasonryItem } from './context';
|
||||
import * as styles from './styles.css';
|
||||
import { calcColumns, calcLayout, calcSleep } from './utils';
|
||||
|
||||
export interface MasonryRootProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
gapX: number;
|
||||
gapY: number;
|
||||
paddingX?: number;
|
||||
paddingY?: number;
|
||||
/**
|
||||
* Specify the width of the item.
|
||||
* - `number`: The width of the item in pixels.
|
||||
* - `'stretch'`: The item will stretch to fill the container.
|
||||
* @default 'stretch'
|
||||
*/
|
||||
itemWidth?: number | 'stretch';
|
||||
/**
|
||||
* The minimum width of the item in pixels.
|
||||
* @default 100
|
||||
*/
|
||||
itemWidthMin?: number;
|
||||
virtualScroll?: boolean;
|
||||
}
|
||||
|
||||
export const MasonryRoot = ({
|
||||
gapX,
|
||||
gapY,
|
||||
itemWidth = 'stretch',
|
||||
itemWidthMin = 100,
|
||||
paddingX = 0,
|
||||
paddingY = 0,
|
||||
children,
|
||||
className,
|
||||
virtualScroll = false,
|
||||
...props
|
||||
}: MasonryRootProps) => {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const [items, setItems] = useState<MasonryItem[]>([]);
|
||||
const [height, setHeight] = useState(0);
|
||||
const [layoutMap, setLayoutMap] = useState<
|
||||
Map<MasonryItem['id'], LayoutInfo>
|
||||
>(new Map());
|
||||
const [sleepMap, setSleepMap] = useState<Map<MasonryItem['id'], boolean>>(
|
||||
new Map()
|
||||
);
|
||||
|
||||
const addItem = useCallback((item: MasonryItem, index?: number) => {
|
||||
if (index === undefined) {
|
||||
setItems(prev => [...prev, item]);
|
||||
} else {
|
||||
setItems(prev => {
|
||||
const limitedIndex = Math.max(0, Math.min(index, prev.length));
|
||||
return [
|
||||
...prev.slice(0, limitedIndex),
|
||||
item,
|
||||
...prev.slice(limitedIndex),
|
||||
];
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const removeItem = useCallback((id: string) => {
|
||||
setItems(prev => prev.filter(item => item.id !== id));
|
||||
}, []);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
addItem,
|
||||
removeItem,
|
||||
items,
|
||||
layoutMap,
|
||||
sleepMap,
|
||||
}),
|
||||
[items, addItem, removeItem, layoutMap, sleepMap]
|
||||
);
|
||||
|
||||
const updateSleepMap = useCallback(
|
||||
(layoutMap: Map<MasonryItem['id'], LayoutInfo>, _scrollY?: number) => {
|
||||
if (!virtualScroll) return;
|
||||
|
||||
const rootEl = rootRef.current;
|
||||
if (!rootEl) return;
|
||||
|
||||
const scrollY = _scrollY ?? rootEl.scrollTop;
|
||||
|
||||
const sleepMap = calcSleep({
|
||||
viewportHeight: rootEl.clientHeight,
|
||||
scrollY,
|
||||
layoutMap,
|
||||
preloadHeight: 50,
|
||||
});
|
||||
setSleepMap(sleepMap);
|
||||
},
|
||||
[virtualScroll]
|
||||
);
|
||||
|
||||
const calculateLayout = useCallback(() => {
|
||||
const rootEl = rootRef.current;
|
||||
if (!rootEl) return;
|
||||
|
||||
const totalWidth = rootEl.clientWidth;
|
||||
const { columns, width } = calcColumns(
|
||||
totalWidth,
|
||||
itemWidth,
|
||||
itemWidthMin,
|
||||
gapX,
|
||||
paddingX
|
||||
);
|
||||
|
||||
const { layout, height } = calcLayout(items, {
|
||||
columns,
|
||||
width,
|
||||
gapX,
|
||||
gapY,
|
||||
paddingX,
|
||||
paddingY,
|
||||
});
|
||||
setLayoutMap(layout);
|
||||
setHeight(height);
|
||||
updateSleepMap(layout);
|
||||
}, [
|
||||
gapX,
|
||||
gapY,
|
||||
itemWidth,
|
||||
itemWidthMin,
|
||||
items,
|
||||
paddingX,
|
||||
paddingY,
|
||||
updateSleepMap,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
calculateLayout();
|
||||
if (rootRef.current) {
|
||||
return observeResize(rootRef.current, calculateLayout);
|
||||
}
|
||||
return;
|
||||
}, [calculateLayout]);
|
||||
|
||||
useEffect(() => {
|
||||
const rootEl = rootRef.current;
|
||||
if (!rootEl) return;
|
||||
|
||||
if (virtualScroll) {
|
||||
const handler = throttle((e: Event) => {
|
||||
const scrollY = (e.target as HTMLElement).scrollTop;
|
||||
updateSleepMap(layoutMap, scrollY);
|
||||
}, 50);
|
||||
rootEl.addEventListener('scroll', handler);
|
||||
return () => {
|
||||
rootEl.removeEventListener('scroll', handler);
|
||||
};
|
||||
}
|
||||
return;
|
||||
}, [layoutMap, updateSleepMap, virtualScroll]);
|
||||
|
||||
return (
|
||||
<MasonryContext.Provider value={contextValue}>
|
||||
<Scrollable.Root>
|
||||
<Scrollable.Viewport
|
||||
ref={rootRef}
|
||||
data-masonry-root
|
||||
className={clsx('scrollable', styles.root, className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<div data-masonry-placeholder style={{ height }} />
|
||||
</Scrollable.Viewport>
|
||||
<Scrollable.Scrollbar />
|
||||
</Scrollable.Root>
|
||||
</MasonryContext.Provider>
|
||||
);
|
||||
};
|
@ -0,0 +1,88 @@
|
||||
import { ResizePanel } from '../resize-panel/resize-panel';
|
||||
import { MasonryItem } from './masonry-item';
|
||||
import { MasonryRoot } from './masonry-root';
|
||||
|
||||
export default {
|
||||
title: 'UI/Masonry',
|
||||
};
|
||||
|
||||
const Card = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: 10,
|
||||
border: `1px solid rgba(100, 100, 100, 0.2)`,
|
||||
boxShadow: '0 1px 10px rgba(0, 0, 0, 0.1)',
|
||||
padding: 10,
|
||||
backgroundColor: 'white',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const cards = Array.from({ length: 10000 }, (_, i) => {
|
||||
return {
|
||||
id: 'card-' + i,
|
||||
height: Math.round(100 + Math.random() * 100),
|
||||
};
|
||||
});
|
||||
|
||||
export const BasicVirtualScroll = () => {
|
||||
return (
|
||||
<ResizePanel width={800} height={600}>
|
||||
<MasonryRoot
|
||||
gapX={10}
|
||||
gapY={10}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
paddingX={12}
|
||||
paddingY={12}
|
||||
virtualScroll
|
||||
>
|
||||
{cards.map(card => (
|
||||
<MasonryItem key={card.id} id={card.id} height={card.height}>
|
||||
<Card>
|
||||
<h1>Hello</h1>
|
||||
<p>World</p>
|
||||
{card.id}
|
||||
</Card>
|
||||
</MasonryItem>
|
||||
))}
|
||||
</MasonryRoot>
|
||||
</ResizePanel>
|
||||
);
|
||||
};
|
||||
|
||||
export const CustomTransition = () => {
|
||||
return (
|
||||
<ResizePanel width={800} height={600}>
|
||||
<MasonryRoot
|
||||
gapX={10}
|
||||
gapY={10}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
paddingX={12}
|
||||
paddingY={12}
|
||||
virtualScroll
|
||||
>
|
||||
{cards.map(card => (
|
||||
<MasonryItem
|
||||
locateMode="transform3d"
|
||||
style={{ transition: 'transform 0.2s ease' }}
|
||||
key={card.id}
|
||||
id={card.id}
|
||||
height={card.height}
|
||||
>
|
||||
<Card>
|
||||
<h1>Hello</h1>
|
||||
<p>World</p>
|
||||
{card.id}
|
||||
</Card>
|
||||
</MasonryItem>
|
||||
))}
|
||||
</MasonryRoot>
|
||||
</ResizePanel>
|
||||
);
|
||||
};
|
14
packages/frontend/component/src/ui/masonry/styles.css.ts
Normal file
14
packages/frontend/component/src/ui/masonry/styles.css.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const root = style({
|
||||
position: 'relative',
|
||||
selectors: {
|
||||
'&.scrollable': {
|
||||
overflowY: 'auto',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const item = style({
|
||||
position: 'absolute',
|
||||
});
|
87
packages/frontend/component/src/ui/masonry/utils.ts
Normal file
87
packages/frontend/component/src/ui/masonry/utils.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import type { LayoutInfo, MasonryItem } from './context';
|
||||
|
||||
export const calcColumns = (
|
||||
totalWidth: number,
|
||||
itemWidth: number | 'stretch',
|
||||
itemWidthMin: number,
|
||||
gapX: number,
|
||||
paddingX: number
|
||||
) => {
|
||||
const availableWidth = totalWidth - paddingX * 2;
|
||||
|
||||
if (itemWidth === 'stretch') {
|
||||
let columns = 1;
|
||||
while (columns * itemWidthMin + (columns - 1) * gapX < availableWidth) {
|
||||
columns++;
|
||||
}
|
||||
const finalColumns = columns - 1;
|
||||
const finalWidth =
|
||||
(availableWidth - (finalColumns - 1) * gapX) / finalColumns;
|
||||
return {
|
||||
columns: finalColumns,
|
||||
width: finalWidth,
|
||||
};
|
||||
} else {
|
||||
let columns = 1;
|
||||
while (columns * itemWidth + (columns - 1) * gapX < availableWidth) {
|
||||
columns++;
|
||||
}
|
||||
return {
|
||||
columns: columns - 1,
|
||||
width: itemWidth,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const calcLayout = (
|
||||
items: MasonryItem[],
|
||||
options: {
|
||||
columns: number;
|
||||
width: number;
|
||||
gapX: number;
|
||||
gapY: number;
|
||||
paddingX: number;
|
||||
paddingY: number;
|
||||
}
|
||||
) => {
|
||||
const { columns, width, gapX, gapY, paddingX, paddingY } = options;
|
||||
|
||||
const layoutMap = new Map<MasonryItem['id'], LayoutInfo>();
|
||||
const heightStack = Array.from({ length: columns }, () => paddingY);
|
||||
|
||||
items.forEach(item => {
|
||||
const minHeight = Math.min(...heightStack);
|
||||
const minHeightIndex = heightStack.indexOf(minHeight);
|
||||
const x = minHeightIndex * (width + gapX) + paddingX;
|
||||
const y = minHeight + gapY;
|
||||
heightStack[minHeightIndex] = y + item.height;
|
||||
layoutMap.set(item.id, { x, y, w: width, h: item.height });
|
||||
});
|
||||
|
||||
const finalHeight = Math.max(...heightStack) + paddingY;
|
||||
|
||||
return { layout: layoutMap, height: finalHeight };
|
||||
};
|
||||
|
||||
export const calcSleep = (options: {
|
||||
viewportHeight: number;
|
||||
scrollY: number;
|
||||
layoutMap: Map<MasonryItem['id'], LayoutInfo>;
|
||||
preloadHeight: number;
|
||||
}) => {
|
||||
const { viewportHeight, scrollY, layoutMap, preloadHeight } = options;
|
||||
|
||||
const sleepMap = new Map<MasonryItem['id'], boolean>();
|
||||
|
||||
layoutMap.forEach((layout, id) => {
|
||||
const { y, h } = layout;
|
||||
|
||||
const isInView =
|
||||
y + h + preloadHeight > scrollY &&
|
||||
y - preloadHeight < scrollY + viewportHeight;
|
||||
|
||||
sleepMap.set(id, !isInView);
|
||||
});
|
||||
|
||||
return sleepMap;
|
||||
};
|
@ -10,15 +10,23 @@ import { type AppTabLink, tabs } from './data';
|
||||
import * as styles from './styles.css';
|
||||
import { TabItem } from './tab-item';
|
||||
|
||||
export const AppTabs = ({ background }: { background?: string }) => {
|
||||
export const AppTabs = ({
|
||||
background,
|
||||
fixed = true,
|
||||
}: {
|
||||
background?: string;
|
||||
fixed?: boolean;
|
||||
}) => {
|
||||
const virtualKeyboardService = useService(VirtualKeyboardService);
|
||||
const virtualKeyboardVisible = useLiveData(virtualKeyboardService.show$);
|
||||
|
||||
return createPortal(
|
||||
const tab = (
|
||||
<SafeArea
|
||||
id="app-tabs"
|
||||
bottom
|
||||
className={styles.appTabs}
|
||||
bottomOffset={2}
|
||||
data-fixed={fixed}
|
||||
style={{
|
||||
...assignInlineVars({
|
||||
[styles.appTabsBackground]: background,
|
||||
@ -26,7 +34,7 @@ export const AppTabs = ({ background }: { background?: string }) => {
|
||||
visibility: virtualKeyboardVisible ? 'hidden' : 'visible',
|
||||
}}
|
||||
>
|
||||
<ul className={styles.appTabsInner} id="app-tabs" role="tablist">
|
||||
<ul className={styles.appTabsInner} role="tablist">
|
||||
{tabs.map(tab => {
|
||||
if ('to' in tab) {
|
||||
return <AppTabLink route={tab} key={tab.key} />;
|
||||
@ -39,9 +47,10 @@ export const AppTabs = ({ background }: { background?: string }) => {
|
||||
}
|
||||
})}
|
||||
</ul>
|
||||
</SafeArea>,
|
||||
document.body
|
||||
</SafeArea>
|
||||
);
|
||||
|
||||
return fixed ? createPortal(tab, document.body) : tab;
|
||||
};
|
||||
|
||||
const AppTabLink = ({ route }: { route: AppTabLink }) => {
|
||||
|
@ -14,9 +14,16 @@ export const appTabs = style({
|
||||
|
||||
width: '100dvw',
|
||||
|
||||
position: 'fixed',
|
||||
bottom: -2,
|
||||
zIndex: 1,
|
||||
|
||||
marginBottom: -2,
|
||||
selectors: {
|
||||
'&[data-fixed="true"]': {
|
||||
position: 'fixed',
|
||||
bottom: -2,
|
||||
marginBottom: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
export const appTabsInner = style({
|
||||
display: 'flex',
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { IconButton, observeIntersection, Skeleton } from '@affine/component';
|
||||
import { IconButton, Skeleton } from '@affine/component';
|
||||
import { useCatchEventCallback } from '@affine/core/components/hooks/use-catch-event-hook';
|
||||
import { PagePreview } from '@affine/core/components/page-list/page-content-preview';
|
||||
import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
|
||||
@ -11,14 +11,7 @@ import {
|
||||
import type { DocMeta } from '@blocksuite/affine/store';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
forwardRef,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { forwardRef, type ReactNode, useMemo, useRef } from 'react';
|
||||
|
||||
import * as styles from './styles.css';
|
||||
import { DocCardTags } from './tag';
|
||||
@ -66,20 +59,6 @@ export const DocCard = forwardRef<HTMLAnchorElement, DocCardProps>(
|
||||
return { height: `${rows * 18}px` };
|
||||
}, [autoHeightById, meta.id]);
|
||||
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const dispose = observeIntersection(containerRef.current, entry => {
|
||||
setVisible(entry.isIntersecting);
|
||||
});
|
||||
|
||||
return () => {
|
||||
dispose();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<WorkbenchLink
|
||||
to={`/${meta.id}`}
|
||||
@ -94,38 +73,30 @@ export const DocCard = forwardRef<HTMLAnchorElement, DocCardProps>(
|
||||
className={clsx(styles.card, className)}
|
||||
data-testid="doc-card"
|
||||
data-doc-id={meta.id}
|
||||
data-visible={visible}
|
||||
{...attrs}
|
||||
>
|
||||
{visible && (
|
||||
<>
|
||||
<header className={styles.head} data-testid="doc-card-header">
|
||||
<h3 className={styles.title}>{title}</h3>
|
||||
<IconButton
|
||||
aria-label="favorite"
|
||||
icon={
|
||||
<IsFavoriteIcon
|
||||
onClick={toggleFavorite}
|
||||
favorite={favorited}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</header>
|
||||
<main className={styles.content} style={contentStyle}>
|
||||
<PagePreview
|
||||
fallback={
|
||||
<>
|
||||
<Skeleton />
|
||||
<Skeleton width={'60%'} />
|
||||
</>
|
||||
}
|
||||
pageId={meta.id}
|
||||
emptyFallback={<div className={styles.contentEmpty}>Empty</div>}
|
||||
/>
|
||||
</main>
|
||||
{showTags ? <DocCardTags docId={meta.id} rows={2} /> : null}
|
||||
</>
|
||||
)}
|
||||
<header className={styles.head} data-testid="doc-card-header">
|
||||
<h3 className={styles.title}>{title}</h3>
|
||||
<IconButton
|
||||
aria-label="favorite"
|
||||
icon={
|
||||
<IsFavoriteIcon onClick={toggleFavorite} favorite={favorited} />
|
||||
}
|
||||
/>
|
||||
</header>
|
||||
<main className={styles.content} style={contentStyle}>
|
||||
<PagePreview
|
||||
fallback={
|
||||
<>
|
||||
<Skeleton />
|
||||
<Skeleton width={'60%'} />
|
||||
</>
|
||||
}
|
||||
pageId={meta.id}
|
||||
emptyFallback={<div className={styles.contentEmpty}>Empty</div>}
|
||||
/>
|
||||
</main>
|
||||
{showTags ? <DocCardTags docId={meta.id} rows={2} /> : null}
|
||||
</WorkbenchLink>
|
||||
);
|
||||
}
|
||||
|
31
packages/frontend/core/src/mobile/components/page/index.tsx
Normal file
31
packages/frontend/core/src/mobile/components/page/index.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import { type HTMLAttributes, type ReactNode, useEffect } from 'react';
|
||||
|
||||
import { AppTabs } from '../app-tabs';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
interface PageProps extends HTMLAttributes<HTMLDivElement> {
|
||||
tab?: boolean;
|
||||
header?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* A Page is a full-screen container that will not scroll on document.
|
||||
*/
|
||||
export const Page = ({ children, tab = true, header, ...attrs }: PageProps) => {
|
||||
// disable scroll on body
|
||||
useEffect(() => {
|
||||
const prevOverflowY = document.body.style.overflowY;
|
||||
document.body.style.overflowY = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflowY = prevOverflowY;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className={styles.page} {...attrs} data-tab={tab}>
|
||||
{header}
|
||||
{children}
|
||||
{tab ? <AppTabs fixed={false} /> : null}
|
||||
</main>
|
||||
);
|
||||
};
|
@ -0,0 +1,9 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const page = style({
|
||||
width: '100dvw',
|
||||
height: '100dvh',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
});
|
@ -1,18 +1,14 @@
|
||||
import { SafeArea, useThemeColorV2 } from '@affine/component';
|
||||
import { useThemeColorV2 } from '@affine/component';
|
||||
|
||||
import { AppTabs } from '../../components';
|
||||
import { AllDocList, AllDocsHeader, AllDocsMenu } from '../../views';
|
||||
import { Page } from '../../components/page';
|
||||
import { AllDocList, AllDocsHeader } from '../../views';
|
||||
|
||||
export const Component = () => {
|
||||
useThemeColorV2('layer/background/mobile/primary');
|
||||
|
||||
return (
|
||||
<>
|
||||
<AllDocsHeader operations={<AllDocsMenu />} />
|
||||
<SafeArea bottom>
|
||||
<AllDocList />
|
||||
</SafeArea>
|
||||
<AppTabs />
|
||||
</>
|
||||
<Page header={<AllDocsHeader />} tab>
|
||||
<AllDocList />
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
@ -10,7 +10,6 @@ import {
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { AppTabs } from '../../../components';
|
||||
import { CollectionDetail } from '../../../views';
|
||||
|
||||
export const Component = () => {
|
||||
@ -68,10 +67,5 @@ export const Component = () => {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<CollectionDetail collection={collection} />
|
||||
<AppTabs />
|
||||
</>
|
||||
);
|
||||
return <CollectionDetail collection={collection} />;
|
||||
};
|
||||
|
@ -3,11 +3,13 @@ import { createVar, globalStyle } from '@vanilla-extract/css';
|
||||
|
||||
export const globalVars = {
|
||||
appTabHeight: createVar('appTabHeight'),
|
||||
appTabSafeArea: createVar('appTabSafeArea'),
|
||||
};
|
||||
|
||||
globalStyle(':root', {
|
||||
vars: {
|
||||
[globalVars.appTabHeight]: BUILD_CONFIG.isIOS ? '49px' : '62px',
|
||||
[globalVars.appTabSafeArea]: `calc(${globalVars.appTabHeight} + env(safe-area-inset-bottom))`,
|
||||
},
|
||||
userSelect: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
@ -18,17 +20,17 @@ globalStyle('body', {
|
||||
minHeight: '100dvh',
|
||||
overflowY: 'unset',
|
||||
});
|
||||
globalStyle('body:has(#app-tabs)', {
|
||||
paddingBottom: `calc(${globalVars.appTabHeight} + env(safe-area-inset-bottom))`,
|
||||
globalStyle('body:has(> #app-tabs)', {
|
||||
paddingBottom: globalVars.appTabSafeArea,
|
||||
});
|
||||
globalStyle('body:has(#app-tabs) affine-keyboard-toolbar[data-shrink="true"]', {
|
||||
paddingBottom: `calc(${globalVars.appTabHeight} + env(safe-area-inset-bottom))`,
|
||||
paddingBottom: globalVars.appTabSafeArea,
|
||||
});
|
||||
globalStyle('body:has(#app-tabs) affine-keyboard-tool-panel', {
|
||||
paddingBottom: `calc(${globalVars.appTabHeight} + env(safe-area-inset-bottom) + 8px)`,
|
||||
});
|
||||
globalStyle('body:has(#app-tabs) edgeless-toolbar-widget', {
|
||||
bottom: `calc(${globalVars.appTabHeight} + env(safe-area-inset-bottom))`,
|
||||
bottom: globalVars.appTabSafeArea,
|
||||
});
|
||||
globalStyle('html', {
|
||||
height: '100dvh',
|
||||
|
@ -1,29 +1,16 @@
|
||||
import { IconButton, MobileMenu } from '@affine/component';
|
||||
import { EmptyCollectionDetail } from '@affine/core/components/affine/empty';
|
||||
import { isEmptyCollection } from '@affine/core/desktop/pages/workspace/collection';
|
||||
import { PageHeader } from '@affine/core/mobile/components';
|
||||
import { AppTabs, PageHeader } from '@affine/core/mobile/components';
|
||||
import { Page } from '@affine/core/mobile/components/page';
|
||||
import type { Collection } from '@affine/env/filter';
|
||||
import { MoreHorizontalIcon, ViewLayersIcon } from '@blocksuite/icons/rc';
|
||||
import { ViewLayersIcon } from '@blocksuite/icons/rc';
|
||||
|
||||
import { AllDocList } from '../doc/list';
|
||||
import { AllDocsMenu } from '../doc/menu';
|
||||
import * as styles from './detail.css';
|
||||
|
||||
export const DetailHeader = ({ collection }: { collection: Collection }) => {
|
||||
return (
|
||||
<PageHeader
|
||||
className={styles.header}
|
||||
back
|
||||
suffix={
|
||||
<MobileMenu items={<AllDocsMenu />}>
|
||||
<IconButton
|
||||
size="24"
|
||||
style={{ padding: 10 }}
|
||||
icon={<MoreHorizontalIcon />}
|
||||
/>
|
||||
</MobileMenu>
|
||||
}
|
||||
>
|
||||
<PageHeader className={styles.header} back>
|
||||
<div className={styles.headerContent}>
|
||||
<ViewLayersIcon className={styles.headerIcon} />
|
||||
{collection.name}
|
||||
@ -42,14 +29,14 @@ export const CollectionDetail = ({
|
||||
<>
|
||||
<DetailHeader collection={collection} />
|
||||
<EmptyCollectionDetail collection={collection} absoluteCenter />
|
||||
<AppTabs />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DetailHeader collection={collection} />
|
||||
<Page header={<DetailHeader collection={collection} />}>
|
||||
<AllDocList collection={collection} />
|
||||
</>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
@ -1,13 +1,10 @@
|
||||
import { EmptyDocs } from '@affine/core/components/affine/empty';
|
||||
import { useBlockSuiteDocMeta } from '@affine/core/components/hooks/use-block-suite-page-meta';
|
||||
import {
|
||||
type ItemGroupDefinition,
|
||||
type ItemGroupProps,
|
||||
useAllDocDisplayProperties,
|
||||
useFilteredPageMetas,
|
||||
usePageItemGroupDefinitions,
|
||||
} from '@affine/core/components/page-list';
|
||||
import { itemsToItemGroups } from '@affine/core/components/page-list/items-to-item-group';
|
||||
import type { Tag } from '@affine/core/modules/tag';
|
||||
import type { Collection, Filter } from '@affine/env/filter';
|
||||
import type { DocMeta } from '@blocksuite/affine/store';
|
||||
@ -19,7 +16,7 @@ import { useMemo } from 'react';
|
||||
import * as styles from './list.css';
|
||||
import { MasonryDocs } from './masonry';
|
||||
|
||||
const DocGroup = ({ group }: { group: ItemGroupProps<DocMeta> }) => {
|
||||
export const DocGroup = ({ group }: { group: ItemGroupProps<DocMeta> }) => {
|
||||
const [properties] = useAllDocDisplayProperties();
|
||||
const showTags = properties.displayProperties.tags;
|
||||
|
||||
@ -53,6 +50,7 @@ export const AllDocList = ({
|
||||
tag,
|
||||
filters = [],
|
||||
}: AllDocListProps) => {
|
||||
const [properties] = useAllDocDisplayProperties();
|
||||
const workspace = useService(WorkspaceService).workspace;
|
||||
const allPageMetas = useBlockSuiteDocMeta(workspace.docCollection);
|
||||
|
||||
@ -72,22 +70,29 @@ export const AllDocList = ({
|
||||
return filteredPageMetas;
|
||||
}, [filteredPageMetas, tag, tagPageIds]);
|
||||
|
||||
const groupDefs =
|
||||
usePageItemGroupDefinitions() as ItemGroupDefinition<DocMeta>[];
|
||||
// const groupDefs =
|
||||
// usePageItemGroupDefinitions() as ItemGroupDefinition<DocMeta>[];
|
||||
|
||||
const groups = useMemo(() => {
|
||||
return itemsToItemGroups(finalPageMetas ?? [], groupDefs);
|
||||
}, [finalPageMetas, groupDefs]);
|
||||
// const groups = useMemo(() => {
|
||||
// return itemsToItemGroups(finalPageMetas ?? [], groupDefs);
|
||||
// }, [finalPageMetas, groupDefs]);
|
||||
|
||||
if (!groups.length) {
|
||||
if (!finalPageMetas.length) {
|
||||
return <EmptyDocs absoluteCenter tagId={tag?.id} />;
|
||||
}
|
||||
|
||||
// return (
|
||||
// <div className={styles.groups}>
|
||||
// {groups.map(group => (
|
||||
// <DocGroup key={group.id} group={group} />
|
||||
// ))}
|
||||
// </div>
|
||||
// );
|
||||
|
||||
return (
|
||||
<div className={styles.groups}>
|
||||
{groups.map(group => (
|
||||
<DocGroup key={group.id} group={group} />
|
||||
))}
|
||||
</div>
|
||||
<MasonryDocs
|
||||
items={finalPageMetas}
|
||||
showTags={properties.displayProperties.tags}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -1,31 +1,11 @@
|
||||
import { useGlobalEvent } from '@affine/core/mobile/hooks/use-global-events';
|
||||
import { Masonry } from '@affine/component';
|
||||
import type { DocMeta } from '@blocksuite/affine/store';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { calcRowsById, DocCard } from '../../../components';
|
||||
import * as styles from './masonry.css';
|
||||
|
||||
const calcColumnCount = () => {
|
||||
const maxCardWidth = 220;
|
||||
const windowWidth = window.innerWidth;
|
||||
const newColumnCount = Math.floor(
|
||||
(windowWidth - styles.paddingX * 2 - styles.columnGap) / maxCardWidth
|
||||
);
|
||||
return Math.max(newColumnCount, 2);
|
||||
};
|
||||
|
||||
const calcColumns = (items: DocMeta[], length: number) => {
|
||||
const columns = Array.from({ length }, () => [] as DocMeta[]);
|
||||
const heights = Array.from({ length }, () => 0);
|
||||
|
||||
items.forEach(item => {
|
||||
const itemHeight = calcRowsById(item.id);
|
||||
const minHeightIndex = heights.indexOf(Math.min(...heights));
|
||||
heights[minHeightIndex] += itemHeight;
|
||||
columns[minHeightIndex].push(item);
|
||||
});
|
||||
|
||||
return columns;
|
||||
const fullStyle = {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
};
|
||||
|
||||
export const MasonryDocs = ({
|
||||
@ -35,32 +15,25 @@ export const MasonryDocs = ({
|
||||
items: DocMeta[];
|
||||
showTags?: boolean;
|
||||
}) => {
|
||||
const [columnCount, setColumnCount] = useState(calcColumnCount);
|
||||
|
||||
const updateColumnCount = useCallback(() => {
|
||||
setColumnCount(calcColumnCount());
|
||||
}, []);
|
||||
useGlobalEvent('resize', updateColumnCount);
|
||||
|
||||
const columns = useMemo(
|
||||
() => calcColumns(items, columnCount),
|
||||
[items, columnCount]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.columns}>
|
||||
{columns.map((col, index) => (
|
||||
<div key={`${columnCount}-${index}`} className={styles.column}>
|
||||
{col.map(item => (
|
||||
<DocCard
|
||||
key={item.id}
|
||||
showTags={showTags}
|
||||
meta={item}
|
||||
autoHeightById
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Masonry.Root
|
||||
style={fullStyle}
|
||||
itemWidthMin={160}
|
||||
gapX={17}
|
||||
gapY={10}
|
||||
paddingX={16}
|
||||
paddingY={16}
|
||||
virtualScroll
|
||||
>
|
||||
{items.map(item => (
|
||||
<Masonry.Item
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
height={calcRowsById(item.id) * 18 + 95}
|
||||
>
|
||||
<DocCard style={fullStyle} meta={item} showTags={showTags} />
|
||||
</Masonry.Item>
|
||||
))}
|
||||
</div>
|
||||
</Masonry.Root>
|
||||
);
|
||||
};
|
||||
|
@ -1,29 +1,14 @@
|
||||
import { IconButton, MobileMenu } from '@affine/component';
|
||||
import { PageHeader } from '@affine/core/mobile/components';
|
||||
import type { Tag } from '@affine/core/modules/tag';
|
||||
import { MoreHorizontalIcon } from '@blocksuite/icons/rc';
|
||||
import { useLiveData } from '@toeverything/infra';
|
||||
|
||||
import { AllDocsMenu } from '../doc';
|
||||
import * as styles from './detail.css';
|
||||
|
||||
export const TagDetailHeader = ({ tag }: { tag: Tag }) => {
|
||||
const name = useLiveData(tag.value$);
|
||||
const color = useLiveData(tag.color$);
|
||||
return (
|
||||
<PageHeader
|
||||
className={styles.header}
|
||||
back
|
||||
suffix={
|
||||
<MobileMenu items={<AllDocsMenu />}>
|
||||
<IconButton
|
||||
size="24"
|
||||
style={{ padding: 10 }}
|
||||
icon={<MoreHorizontalIcon />}
|
||||
/>
|
||||
</MobileMenu>
|
||||
}
|
||||
>
|
||||
<PageHeader className={styles.header} back>
|
||||
<div className={styles.headerContent}>
|
||||
<div className={styles.headerIcon} style={{ color }} />
|
||||
{name}
|
||||
|
@ -1,15 +1,13 @@
|
||||
import { Page } from '@affine/core/mobile/components/page';
|
||||
import type { Tag } from '@affine/core/modules/tag';
|
||||
|
||||
import { AppTabs } from '../../../components';
|
||||
import { AllDocList } from '../doc';
|
||||
import { TagDetailHeader } from './detail-header';
|
||||
|
||||
export const TagDetail = ({ tag }: { tag: Tag }) => {
|
||||
return (
|
||||
<>
|
||||
<TagDetailHeader tag={tag} />
|
||||
<Page header={<TagDetailHeader tag={tag} />} tab>
|
||||
<AllDocList tag={tag} />
|
||||
<AppTabs />
|
||||
</>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user