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:
CatsJuice
2025-05-09 05:31:10 +00:00
parent 93e01b4442
commit 2e3b721603
48 changed files with 3060 additions and 124 deletions

View File

@@ -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"

View File

@@ -1 +1,2 @@
export * from './masonry';
export type { MasonryGroup, MasonryItem } from './type';

View File

@@ -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>
);

View File

@@ -20,3 +20,7 @@ export const stickyGroupHeader = style({
top: 0,
width: '100%',
});
export const item = style({
position: 'absolute',
});

View File

@@ -22,3 +22,5 @@ export interface MasonryItemXYWH {
w: number;
h: number;
}
export type MasonryPX = number | ((totalWidth: number) => number);

View File

@@ -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: {