mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-27 02:42:25 +08:00
feat(core): new all docs list ui (#12102)
### New all docs list ui close AF-2531, AF-2585, AF-2586, AF-2580 ### What changed - a new `display-menu` component - properties visibility - quick actions visibility - extend DocPropertyType definition - `showInDocList`: configure whether to show in doc and how to show (stack | inline) - `docListProperty`: define how to render property in doc - `groupHeader`: define how to render group header when grouped - implement all properties's `docListProperty` renderer and `groupHeader` renderer - new `docs-view` component - render doc in `card` | `list` view - split doc card into minimal components for reuse in list and card views, as well as visibility control - implement docs-list with `<Masonry />` and multi-view support - for list view, make masonry column count always `1` <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Redesigned document explorer with multiple view modes (list, grid, masonry), grouping, selection, and multi-delete. - Added customizable display properties and quick actions (favorite, trash, split view, new tab, select) for documents. - Introduced new group header and document item components with improved styling and interaction. - Enabled dynamic rendering of document properties including tags, dates, users, templates, and themes. - Added filtering support for trash status and enhanced localization for UI elements. - Introduced drag handle size customization and expanded masonry layout configurability. - Added contextual "More" menu with document operations like favorite, info, duplicate, and trash. - Implemented shared context for explorer state management and multi-selection logic. - **Enhancements** - Improved virtual scrolling with active item tracking and configurable preload and debounce settings. - Responsive, theme-aware styling applied across explorer and property components. - Configurable UI elements and quick actions via user preferences. - Enhanced error messages for unsupported property types in filters. - Refined padding and layout calculations in masonry component for better visual consistency. - Avatar and date components refactored for explicit prop-driven rendering and customization. - **Bug Fixes** - Improved error messages for unsupported property types in filters. - **Documentation** - Added new localization keys and updated language completeness for new features. - **Chores** - Modularized workspace property types with list and group header display components. - Consolidated imports and enhanced code maintainability. - Added new CSS styling modules for explorer components and workspace property types. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -8,8 +8,10 @@ export const DragHandle = forwardRef<
|
||||
{
|
||||
className?: string;
|
||||
dragging?: boolean;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
>(({ className, dragging, ...props }, ref) => {
|
||||
>(({ className, dragging, width = 10, height = 22, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
@@ -18,8 +20,8 @@ export const DragHandle = forwardRef<
|
||||
className={clsx(styles.root, className)}
|
||||
>
|
||||
<svg
|
||||
width="10"
|
||||
height="22"
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox="0 0 10 22"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from './masonry';
|
||||
export type { MasonryGroup, MasonryItem } from './type';
|
||||
|
||||
@@ -14,15 +14,26 @@ import {
|
||||
import { observeResize } from '../../utils';
|
||||
import { Scrollable } from '../scrollbar';
|
||||
import * as styles from './styles.css';
|
||||
import type { MasonryGroup, MasonryItem, MasonryItemXYWH } from './type';
|
||||
import { calcColumns, calcLayout, calcSleep, calcSticky } from './utils';
|
||||
import type {
|
||||
MasonryGroup,
|
||||
MasonryItem,
|
||||
MasonryItemXYWH,
|
||||
MasonryPX,
|
||||
} from './type';
|
||||
import {
|
||||
calcActive,
|
||||
calcColumns,
|
||||
calcLayout,
|
||||
calcPX,
|
||||
calcSticky,
|
||||
} from './utils';
|
||||
|
||||
export interface MasonryProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
items: MasonryItem[] | MasonryGroup[];
|
||||
|
||||
gapX?: number;
|
||||
gapY?: number;
|
||||
paddingX?: number;
|
||||
paddingX?: MasonryPX;
|
||||
paddingY?: number;
|
||||
|
||||
groupsGap?: number;
|
||||
@@ -48,6 +59,8 @@ export interface MasonryProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
* Specify the number of columns, will override the calculated
|
||||
*/
|
||||
columns?: number;
|
||||
resizeDebounce?: number;
|
||||
preloadHeight?: number;
|
||||
}
|
||||
|
||||
export const Masonry = ({
|
||||
@@ -66,6 +79,8 @@ export const Masonry = ({
|
||||
stickyGroupHeader = true,
|
||||
collapsedGroups,
|
||||
columns,
|
||||
preloadHeight = 50,
|
||||
resizeDebounce = 20,
|
||||
onGroupCollapse,
|
||||
...props
|
||||
}: MasonryProps) => {
|
||||
@@ -74,13 +89,16 @@ export const Masonry = ({
|
||||
const [layoutMap, setLayoutMap] = useState<
|
||||
Map<MasonryItem['id'], MasonryItemXYWH>
|
||||
>(new Map());
|
||||
const [sleepMap, setSleepMap] = useState<Map<
|
||||
MasonryItem['id'],
|
||||
boolean
|
||||
> | null>(null);
|
||||
/**
|
||||
* Record active items, to ensure all items won't be rendered when initialized.
|
||||
*/
|
||||
const [activeMap, setActiveMap] = useState<Map<MasonryItem['id'], boolean>>(
|
||||
new Map()
|
||||
);
|
||||
const [stickyGroupId, setStickyGroupId] = useState<string | undefined>(
|
||||
undefined
|
||||
);
|
||||
const [totalWidth, setTotalWidth] = useState(0);
|
||||
const stickyGroupCollapsed = !!(
|
||||
collapsedGroups &&
|
||||
stickyGroupId &&
|
||||
@@ -91,7 +109,7 @@ export const Masonry = ({
|
||||
if (items.length === 0) {
|
||||
return [];
|
||||
}
|
||||
if ('items' in items[0]) return items as MasonryGroup[];
|
||||
if (items[0] && 'items' in items[0]) return items as MasonryGroup[];
|
||||
return [{ id: '', height: 0, items: items as MasonryItem[] }];
|
||||
}, [items]);
|
||||
|
||||
@@ -100,7 +118,7 @@ export const Masonry = ({
|
||||
return groups.find(group => group.id === stickyGroupId);
|
||||
}, [groups, stickyGroupId]);
|
||||
|
||||
const updateSleepMap = useCallback(
|
||||
const updateActiveMap = useCallback(
|
||||
(layoutMap: Map<MasonryItem['id'], MasonryItemXYWH>, _scrollY?: number) => {
|
||||
if (!virtualScroll) return;
|
||||
|
||||
@@ -109,16 +127,16 @@ export const Masonry = ({
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const scrollY = _scrollY ?? rootEl.scrollTop;
|
||||
const sleepMap = calcSleep({
|
||||
const activeMap = calcActive({
|
||||
viewportHeight: rootEl.clientHeight,
|
||||
scrollY,
|
||||
layoutMap,
|
||||
preloadHeight: 50,
|
||||
preloadHeight,
|
||||
});
|
||||
setSleepMap(sleepMap);
|
||||
setActiveMap(activeMap);
|
||||
});
|
||||
},
|
||||
[virtualScroll]
|
||||
[preloadHeight, virtualScroll]
|
||||
);
|
||||
|
||||
const calculateLayout = useCallback(() => {
|
||||
@@ -149,7 +167,8 @@ export const Masonry = ({
|
||||
});
|
||||
setLayoutMap(layout);
|
||||
setHeight(height);
|
||||
updateSleepMap(layout);
|
||||
setTotalWidth(totalWidth);
|
||||
updateActiveMap(layout);
|
||||
if (stickyGroupHeader && rootRef.current) {
|
||||
setStickyGroupId(
|
||||
calcSticky({ scrollY: rootRef.current.scrollTop, layoutMap: layout })
|
||||
@@ -168,17 +187,20 @@ export const Masonry = ({
|
||||
paddingX,
|
||||
paddingY,
|
||||
stickyGroupHeader,
|
||||
updateSleepMap,
|
||||
updateActiveMap,
|
||||
]);
|
||||
|
||||
// handle resize
|
||||
useEffect(() => {
|
||||
calculateLayout();
|
||||
if (rootRef.current) {
|
||||
return observeResize(rootRef.current, debounce(calculateLayout, 50));
|
||||
return observeResize(
|
||||
rootRef.current,
|
||||
debounce(calculateLayout, resizeDebounce)
|
||||
);
|
||||
}
|
||||
return;
|
||||
}, [calculateLayout]);
|
||||
}, [calculateLayout, resizeDebounce]);
|
||||
|
||||
// handle scroll
|
||||
useEffect(() => {
|
||||
@@ -188,7 +210,7 @@ export const Masonry = ({
|
||||
if (virtualScroll) {
|
||||
const handler = throttle((e: Event) => {
|
||||
const scrollY = (e.target as HTMLElement).scrollTop;
|
||||
updateSleepMap(layoutMap, scrollY);
|
||||
updateActiveMap(layoutMap, scrollY);
|
||||
if (stickyGroupHeader) {
|
||||
setStickyGroupId(calcSticky({ scrollY, layoutMap }));
|
||||
}
|
||||
@@ -199,7 +221,7 @@ export const Masonry = ({
|
||||
};
|
||||
}
|
||||
return;
|
||||
}, [layoutMap, stickyGroupHeader, updateSleepMap, virtualScroll]);
|
||||
}, [layoutMap, stickyGroupHeader, updateActiveMap, virtualScroll]);
|
||||
|
||||
return (
|
||||
<Scrollable.Root>
|
||||
@@ -211,11 +233,9 @@ export const Masonry = ({
|
||||
>
|
||||
{groups.map(group => {
|
||||
// sleep is not calculated, do not render
|
||||
if (virtualScroll && !sleepMap) return null;
|
||||
const {
|
||||
id: groupId,
|
||||
items,
|
||||
children,
|
||||
className,
|
||||
Component,
|
||||
...groupProps
|
||||
@@ -223,14 +243,11 @@ export const Masonry = ({
|
||||
const collapsed =
|
||||
collapsedGroups && collapsedGroups.includes(groupId);
|
||||
|
||||
const sleep =
|
||||
(virtualScroll && sleepMap && sleepMap.get(groupId)) ?? false;
|
||||
|
||||
return (
|
||||
<Fragment key={groupId}>
|
||||
{/* group header */}
|
||||
{sleep ? null : (
|
||||
<MasonryItem
|
||||
{virtualScroll && !activeMap.get(group.id) ? null : (
|
||||
<MasonryGroupHeader
|
||||
className={clsx(styles.groupHeader, className)}
|
||||
key={`header-${groupId}`}
|
||||
id={groupId}
|
||||
@@ -238,41 +255,30 @@ export const Masonry = ({
|
||||
xywh={layoutMap.get(groupId)}
|
||||
{...groupProps}
|
||||
onClick={() => onGroupCollapse?.(groupId, !collapsed)}
|
||||
>
|
||||
{Component ? (
|
||||
<Component
|
||||
itemCount={items.length}
|
||||
groupId={groupId}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</MasonryItem>
|
||||
Component={Component}
|
||||
itemCount={items.length}
|
||||
collapsed={!!collapsed}
|
||||
groupId={groupId}
|
||||
paddingX={calcPX(paddingX, totalWidth)}
|
||||
/>
|
||||
)}
|
||||
{/* group items */}
|
||||
{collapsed
|
||||
? null
|
||||
: items.map(({ id: itemId, Component, ...item }) => {
|
||||
const mixId = groupId ? `${groupId}:${itemId}` : itemId;
|
||||
const sleep =
|
||||
(virtualScroll && sleepMap && sleepMap.get(mixId)) ??
|
||||
false;
|
||||
if (sleep) return null;
|
||||
if (virtualScroll && !activeMap.get(mixId)) return null;
|
||||
return (
|
||||
<MasonryItem
|
||||
<MasonryGroupItem
|
||||
key={mixId}
|
||||
id={mixId}
|
||||
{...item}
|
||||
locateMode={locateMode}
|
||||
xywh={layoutMap.get(mixId)}
|
||||
>
|
||||
{Component ? (
|
||||
<Component groupId={groupId} itemId={itemId} />
|
||||
) : (
|
||||
item.children
|
||||
)}
|
||||
</MasonryItem>
|
||||
groupId={groupId}
|
||||
itemId={itemId}
|
||||
Component={Component}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Fragment>
|
||||
@@ -283,10 +289,11 @@ export const Masonry = ({
|
||||
<Scrollable.Scrollbar />
|
||||
{stickyGroup ? (
|
||||
<div
|
||||
className={styles.stickyGroupHeader}
|
||||
className={clsx(styles.stickyGroupHeader, stickyGroup.className)}
|
||||
style={{
|
||||
padding: `0 ${paddingX}px`,
|
||||
padding: `0 ${calcPX(paddingX, totalWidth)}px`,
|
||||
height: stickyGroup.height,
|
||||
...stickyGroup.style,
|
||||
}}
|
||||
onClick={() =>
|
||||
onGroupCollapse?.(stickyGroup.id, !stickyGroupCollapsed)
|
||||
@@ -314,11 +321,87 @@ interface MasonryItemProps
|
||||
xywh?: MasonryItemXYWH;
|
||||
}
|
||||
|
||||
const MasonryGroupHeader = memo(function MasonryGroupHeader({
|
||||
id,
|
||||
children,
|
||||
style,
|
||||
className,
|
||||
Component,
|
||||
groupId,
|
||||
itemCount,
|
||||
collapsed,
|
||||
height,
|
||||
paddingX,
|
||||
...props
|
||||
}: Omit<MasonryItemProps, 'Component'> & {
|
||||
Component?: MasonryGroup['Component'];
|
||||
groupId: string;
|
||||
itemCount: number;
|
||||
collapsed: boolean;
|
||||
paddingX?: number;
|
||||
}) {
|
||||
const content = useMemo(() => {
|
||||
if (Component) {
|
||||
return (
|
||||
<Component
|
||||
groupId={groupId}
|
||||
itemCount={itemCount}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return children;
|
||||
}, [Component, children, collapsed, groupId, itemCount]);
|
||||
|
||||
return (
|
||||
<MasonryItem
|
||||
id={id}
|
||||
height={height}
|
||||
style={{
|
||||
padding: `0 ${paddingX}px`,
|
||||
height: '100%',
|
||||
...style,
|
||||
}}
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</MasonryItem>
|
||||
);
|
||||
});
|
||||
|
||||
const MasonryGroupItem = memo(function MasonryGroupItem({
|
||||
id,
|
||||
children,
|
||||
className,
|
||||
Component,
|
||||
groupId,
|
||||
itemId,
|
||||
...props
|
||||
}: MasonryItemProps & {
|
||||
groupId: string;
|
||||
itemId: string;
|
||||
}) {
|
||||
const content = useMemo(() => {
|
||||
if (Component) {
|
||||
return <Component groupId={groupId} itemId={itemId} />;
|
||||
}
|
||||
return children;
|
||||
}, [Component, children, groupId, itemId]);
|
||||
|
||||
return (
|
||||
<MasonryItem id={id} className={className} {...props}>
|
||||
{content}
|
||||
</MasonryItem>
|
||||
);
|
||||
});
|
||||
|
||||
const MasonryItem = memo(function MasonryItem({
|
||||
id,
|
||||
xywh,
|
||||
locateMode = 'leftTop',
|
||||
children,
|
||||
className,
|
||||
style: styleProp,
|
||||
...props
|
||||
}: MasonryItemProps) {
|
||||
@@ -341,14 +424,19 @@ const MasonryItem = memo(function MasonryItem({
|
||||
...posStyle,
|
||||
width: `${w}px`,
|
||||
height: `${h}px`,
|
||||
position: 'absolute' as const,
|
||||
};
|
||||
}, [locateMode, styleProp, xywh]);
|
||||
|
||||
if (!xywh) return null;
|
||||
|
||||
return (
|
||||
<div data-masonry-item data-masonry-item-id={id} style={style} {...props}>
|
||||
<div
|
||||
data-masonry-item
|
||||
data-masonry-item-id={id}
|
||||
style={style}
|
||||
className={clsx(styles.item, className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -20,3 +20,7 @@ export const stickyGroupHeader = style({
|
||||
top: 0,
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
export const item = style({
|
||||
position: 'absolute',
|
||||
});
|
||||
|
||||
@@ -22,3 +22,5 @@ export interface MasonryItemXYWH {
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export type MasonryPX = number | ((totalWidth: number) => number);
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import type { MasonryGroup, MasonryItem, MasonryItemXYWH } from './type';
|
||||
import type {
|
||||
MasonryGroup,
|
||||
MasonryItem,
|
||||
MasonryItemXYWH,
|
||||
MasonryPX,
|
||||
} from './type';
|
||||
|
||||
export const calcPX = (px: MasonryPX, totalWidth: number) =>
|
||||
typeof px === 'number' ? px : px(totalWidth);
|
||||
|
||||
export const calcColumns = (
|
||||
totalWidth: number,
|
||||
itemWidth: number | 'stretch',
|
||||
itemWidthMin: number,
|
||||
gapX: number,
|
||||
paddingX: number,
|
||||
_paddingX: MasonryPX,
|
||||
columns?: number
|
||||
) => {
|
||||
const paddingX = calcPX(_paddingX, totalWidth);
|
||||
const availableWidth = totalWidth - paddingX * 2;
|
||||
|
||||
if (columns) {
|
||||
@@ -47,7 +56,7 @@ export const calcLayout = (
|
||||
width: number;
|
||||
gapX: number;
|
||||
gapY: number;
|
||||
paddingX: number;
|
||||
paddingX: MasonryPX;
|
||||
paddingY: number;
|
||||
groupsGap: number;
|
||||
groupHeaderGapWithItems: number;
|
||||
@@ -60,12 +69,13 @@ export const calcLayout = (
|
||||
width,
|
||||
gapX,
|
||||
gapY,
|
||||
paddingX,
|
||||
paddingX: _paddingX,
|
||||
paddingY,
|
||||
groupsGap,
|
||||
groupHeaderGapWithItems,
|
||||
collapsedGroups,
|
||||
} = options;
|
||||
const paddingX = calcPX(_paddingX, totalWidth);
|
||||
|
||||
const layout = new Map<string, MasonryItemXYWH>();
|
||||
let finalHeight = paddingY;
|
||||
@@ -79,9 +89,9 @@ export const calcLayout = (
|
||||
// calculate group header
|
||||
const groupHeaderLayout: MasonryItemXYWH = {
|
||||
type: 'group',
|
||||
x: paddingX,
|
||||
x: 0,
|
||||
y: finalHeight,
|
||||
w: totalWidth - paddingX * 2,
|
||||
w: totalWidth,
|
||||
h: group.height,
|
||||
};
|
||||
layout.set(group.id, groupHeaderLayout);
|
||||
@@ -97,10 +107,11 @@ export const calcLayout = (
|
||||
const itemId = group.id ? `${group.id}:${item.id}` : item.id;
|
||||
const minHeight = Math.min(...heightStack);
|
||||
const minHeightIndex = heightStack.indexOf(minHeight);
|
||||
const hasGap = heightStack[minHeightIndex] ? gapY : 0;
|
||||
const x = minHeightIndex * (width + gapX) + paddingX;
|
||||
const y = minHeight + finalHeight;
|
||||
const y = finalHeight + minHeight + hasGap;
|
||||
|
||||
heightStack[minHeightIndex] += item.height + gapY;
|
||||
heightStack[minHeightIndex] += item.height + hasGap;
|
||||
layout.set(itemId, {
|
||||
type: 'item',
|
||||
x,
|
||||
@@ -117,7 +128,7 @@ export const calcLayout = (
|
||||
return { layout, height: finalHeight };
|
||||
};
|
||||
|
||||
export const calcSleep = (options: {
|
||||
export const calcActive = (options: {
|
||||
viewportHeight: number;
|
||||
scrollY: number;
|
||||
layoutMap: Map<MasonryItem['id'], MasonryItemXYWH>;
|
||||
@@ -125,7 +136,7 @@ export const calcSleep = (options: {
|
||||
}) => {
|
||||
const { viewportHeight, scrollY, layoutMap, preloadHeight } = options;
|
||||
|
||||
const sleepMap = new Map<MasonryItem['id'], boolean>();
|
||||
const activeMap = new Map<MasonryItem['id'], boolean>();
|
||||
|
||||
layoutMap.forEach((layout, id) => {
|
||||
const { y, h } = layout;
|
||||
@@ -134,10 +145,12 @@ export const calcSleep = (options: {
|
||||
y + h + preloadHeight > scrollY &&
|
||||
y - preloadHeight < scrollY + viewportHeight;
|
||||
|
||||
sleepMap.set(id, !isInView);
|
||||
if (isInView) {
|
||||
activeMap.set(id, true);
|
||||
}
|
||||
});
|
||||
|
||||
return sleepMap;
|
||||
return activeMap;
|
||||
};
|
||||
|
||||
export const calcSticky = (options: {
|
||||
|
||||
Reference in New Issue
Block a user