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
@@ -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: {
@@ -0,0 +1,28 @@
import { createContext, type Dispatch, type SetStateAction } from 'react';
import type { ExplorerPreference } from './types';
export type DocExplorerContextType = ExplorerPreference & {
view: 'list' | 'grid' | 'masonry';
groups: Array<{ key: string; items: string[] }>;
collapsed: string[];
selectMode?: boolean;
selectedDocIds: string[];
prevCheckAnchorId?: string | null;
onToggleCollapse: (groupId: string) => void;
onToggleSelect: (docId: string) => void;
onSelect: Dispatch<SetStateAction<string[]>>;
setPrevCheckAnchorId: Dispatch<SetStateAction<string | null>>;
};
export const DocExplorerContext = createContext<DocExplorerContextType>({
view: 'list',
groups: [],
collapsed: [],
selectedDocIds: [],
prevCheckAnchorId: null,
onToggleSelect: () => {},
onToggleCollapse: () => {},
onSelect: () => {},
setPrevCheckAnchorId: () => {},
});
@@ -1,4 +1,4 @@
import { Button, Menu, MenuSub } from '@affine/component';
import { Button, Divider, Menu, MenuSub } from '@affine/component';
import type {
GroupByParams,
OrderByParams,
@@ -11,6 +11,8 @@ import { useCallback } from 'react';
import type { ExplorerPreference } from '../types';
import { GroupByList, GroupByName } from './group';
import { OrderByList, OrderByName } from './order';
import { DisplayProperties } from './properties';
import { QuickActionsConfig } from './quick-actions';
import * as styles from './styles.css';
const ExplorerDisplayMenu = ({
@@ -78,6 +80,10 @@ const ExplorerDisplayMenu = ({
</span>
</div>
</MenuSub>
<Divider size="thinner" />
<DisplayProperties preference={preference} onChange={onChange} />
<Divider size="thinner" />
<QuickActionsConfig preference={preference} onChange={onChange} />
</div>
);
};
@@ -0,0 +1,31 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const root = style({
display: 'flex',
flexDirection: 'column',
gap: 4,
});
export const properties = style({
padding: '4px 0px 8px 0px',
display: 'flex',
gap: 4,
flexWrap: 'wrap',
});
export const sectionLabel = style({
fontSize: 14,
lineHeight: '22px',
color: cssVarV2.text.primary,
padding: 4,
});
export const property = style({
selectors: {
'&[data-show="false"]': {
backgroundColor: cssVarV2.button.emptyIconBackground,
color: cssVarV2.icon.disable,
},
},
});
@@ -0,0 +1,144 @@
import { Button, Divider } from '@affine/component';
import {
WorkspacePropertyService,
type WorkspacePropertyType,
} from '@affine/core/modules/workspace-property';
import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useMemo } from 'react';
import { WorkspacePropertyName } from '../../properties';
import { WorkspacePropertyTypes } from '../../workspace-property-types';
import type { ExplorerPreference } from '../types';
import * as styles from './properties.css';
export const filterDisplayProperties = <
T extends { type: WorkspacePropertyType },
>(
propertyList: T[],
showInDocList: 'inline' | 'stack'
) => {
return propertyList
.filter(
property =>
WorkspacePropertyTypes[property.type].showInDocList === showInDocList
)
.map(property => ({
property,
config: WorkspacePropertyTypes[property.type],
}));
};
export const DisplayProperties = ({
preference,
onChange,
}: {
preference: ExplorerPreference;
onChange?: (preference: ExplorerPreference) => void;
}) => {
const t = useI18n();
const workspacePropertyService = useService(WorkspacePropertyService);
const propertyList = useLiveData(workspacePropertyService.properties$);
const displayProperties = preference.displayProperties;
const showIcon = preference.showDocIcon;
const showBody = preference.showDocPreview;
const propertiesGroups = useMemo(
() => [
{
type: 'inline',
properties: filterDisplayProperties(propertyList, 'inline'),
},
{
type: 'stack',
properties: filterDisplayProperties(propertyList, 'stack'),
},
],
[propertyList]
);
const handleDisplayPropertiesChange = useCallback(
(displayProperties: string[]) => {
onChange?.({
...preference,
displayProperties,
});
},
[onChange, preference]
);
const handlePropertyClick = useCallback(
(propertyId: string) => {
handleDisplayPropertiesChange(
displayProperties && displayProperties.includes(propertyId)
? displayProperties.filter(id => id !== propertyId)
: [...(displayProperties || []), propertyId]
);
},
[displayProperties, handleDisplayPropertiesChange]
);
const toggleIcon = useCallback(() => {
onChange?.({
...preference,
showDocIcon: !showIcon,
});
}, [onChange, preference, showIcon]);
const toggleBody = useCallback(() => {
onChange?.({
...preference,
showDocPreview: !showBody,
});
}, [onChange, preference, showBody]);
return (
<div className={styles.root}>
<section className={styles.sectionLabel}>
{t['com.affine.all-docs.display.properties']()}
</section>
{propertiesGroups.map(list => {
return (
<div className={styles.properties} key={list.type}>
{list.properties.map(({ property }) => {
return (
<Button
key={property.id}
data-show={
displayProperties && displayProperties.includes(property.id)
}
onClick={() => handlePropertyClick(property.id)}
className={styles.property}
data-property-id={property.id}
>
<WorkspacePropertyName propertyInfo={property} />
</Button>
);
})}
</div>
);
})}
<Divider size="thinner" />
<section className={styles.sectionLabel}>
{t['com.affine.all-docs.display.list-view']()}
</section>
<div className={styles.properties}>
<Button
className={styles.property}
data-show={showIcon}
onClick={toggleIcon}
>
{t['com.affine.all-docs.display.list-view.icon']()}
</Button>
<Button
className={styles.property}
data-show={showBody}
onClick={toggleBody}
>
{t['com.affine.all-docs.display.list-view.body']()}
</Button>
</div>
</div>
);
};
@@ -0,0 +1,50 @@
import { Checkbox, MenuItem, MenuSub } from '@affine/component';
import { useI18n } from '@affine/i18n';
import { useCallback } from 'react';
import { type QuickActionKey, quickActions } from '../quick-actions.constants';
import type { ExplorerPreference } from '../types';
export const QuickActionsConfig = ({
preference,
onChange,
}: {
preference: ExplorerPreference;
onChange?: (preference: ExplorerPreference) => void;
}) => {
const t = useI18n();
const toggleAction = useCallback(
(key: QuickActionKey) => {
onChange?.({
...preference,
[key]: !preference[key],
});
},
[preference, onChange]
);
return (
<MenuSub
items={quickActions.map(action => {
if (action.disabled) return null;
return (
<MenuItem
key={action.key}
onClick={e => {
// do not close sub menu
e.preventDefault();
toggleAction(action.key);
}}
prefixIcon={<Checkbox checked={!!preference[action.key]} />}
>
{t.t(action.name)}
</MenuItem>
);
})}
>
{t['com.affine.all-docs.quick-actions']()}
</MenuSub>
);
};
@@ -0,0 +1,223 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const root = style({
width: '100%',
height: '100%',
});
export const listViewRoot = style({
padding: '0px 4px',
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: 8,
borderRadius: 4,
overflow: 'hidden',
containerName: 'list-view-root',
containerType: 'size',
selectors: {
'&:hover': {
backgroundColor: cssVarV2.layer.background.hoverOverlay,
},
},
});
export const dragHandle = style({
position: 'absolute',
padding: '5px 2px',
color: cssVarV2.icon.secondary,
});
export const listDragHandle = style([
dragHandle,
{
left: -4,
top: '50%',
transform: 'translateY(-50%) translateX(-100%)',
opacity: 0,
selectors: {
[`${listViewRoot}:hover &`]: {
opacity: 1,
},
},
},
]);
export const listSelect = style({
width: 0,
height: 24,
fontSize: 20,
padding: 2,
// to make sure won't take place when hidden
// 12 = gap + padding * 2
marginLeft: -12,
flexShrink: 0,
display: 'flex',
color: cssVarV2.icon.primary,
overflow: 'hidden',
alignItems: 'center',
justifyContent: 'end',
transition: 'width 0.25s ease, margin-left 0.25s ease',
// when select mode is on, the whole item can be clicked,
// the selection will be handled by the parent, the checkbox here just for the visual effect
pointerEvents: 'none',
selectors: {
'&[data-select-mode="true"]': {
width: 24,
marginLeft: 0,
},
},
});
export const listIcon = style({
width: 24,
height: 24,
fontSize: 24,
color: cssVarV2.icon.primary,
});
export const listContent = style({
width: 0,
height: '100%',
flex: 1,
display: 'flex',
alignItems: 'center',
gap: 8,
justifyContent: 'space-between',
});
export const listBrief = style({
height: '100%',
flexShrink: 10,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
marginLeft: 4,
minWidth: 200,
});
export const listSpace = style({
width: 0,
flex: 1,
});
// export const listDetails = style({
// display: 'flex',
// gap: 8,
// alignItems: 'center',
// flexShrink: 1,
// minWidth: 0,
// justifyContent: 'flex-end',
// });
const ellipsis = style({
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
});
export const listTitle = style([
ellipsis,
{
fontSize: 14,
lineHeight: '22px',
fontWeight: 500,
color: cssVarV2.text.primary,
},
]);
export const listPreview = style([
ellipsis,
{
fontSize: 12,
lineHeight: '20px',
fontWeight: 400,
color: cssVarV2.text.secondary,
},
]);
export const listQuickActions = style({
display: 'flex',
gap: 8,
flexShrink: 0,
});
export const listHide750 = style({
'@container': {
'list-view-root (width <= 750px)': {
display: 'none',
},
},
});
export const listHide560 = style({
'@container': {
'list-view-root (width <= 560px)': {
display: 'none',
},
},
});
// --- card view ---
export const cardViewRoot = style({
vars: {
'--ring-color': 'transparent',
},
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
gap: 8,
padding: 16,
borderRadius: 12,
backgroundColor: cssVarV2.layer.background.mobile.secondary,
border: `0.5px solid ${cssVarV2.layer.insideBorder.border}`,
// TODO: use variable
boxShadow: '0px 0px 0px 1px var(--ring-color),0px 0px 3px rgba(0,0,0,.05)',
overflow: 'hidden',
selectors: {
[`${root}[data-selected="true"] &`]: {
vars: {
'--ring-color': cssVarV2.layer.insideBorder.primaryBorder,
},
},
},
});
export const cardViewHeader = style({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
});
export const cardViewIcon = style({
fontSize: 24,
color: cssVarV2.icon.primary,
lineHeight: 0,
});
export const cardViewTitle = style({
fontSize: 18,
lineHeight: '26px',
fontWeight: 600,
color: cssVarV2.text.primary,
letterSpacing: '-0.24px',
width: 0,
flexGrow: 1,
flexShrink: 1,
textOverflow: 'ellipsis',
overflow: 'hidden',
whiteSpace: 'nowrap',
});
export const cardPreviewContainer = style({
width: '100%',
fontSize: 12,
lineHeight: '20px',
fontWeight: 400,
color: cssVarV2.text.primary,
minHeight: 20,
flexGrow: 1,
flexShrink: 1,
overflow: 'hidden',
});
export const cardViewCheckbox = style({
width: 20,
height: 20,
fontSize: 16,
padding: 2,
color: cssVarV2.icon.primary,
pointerEvents: 'none',
});
@@ -0,0 +1,400 @@
import {
Checkbox,
DragHandle as DragHandleIcon,
Skeleton,
useDraggable,
} from '@affine/component';
import { DocsService } from '@affine/core/modules/doc';
import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta';
import { WorkbenchLink } from '@affine/core/modules/workbench';
import type { AffineDNDData } from '@affine/core/types/dnd';
import { useI18n } from '@affine/i18n';
import {
AutoTidyUpIcon,
PropertyIcon,
ResizeTidyUpIcon,
} from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import {
type HTMLProps,
memo,
type ReactNode,
type SVGProps,
useCallback,
useContext,
useState,
} from 'react';
import { PagePreview } from '../../page-list/page-content-preview';
import { DocExplorerContext } from '../context';
import { quickActions } from '../quick-actions.constants';
import * as styles from './doc-list-item.css';
import { MoreMenuButton } from './more-menu';
import { CardViewProperties, ListViewProperties } from './properties';
export type DocListItemView = 'list' | 'grid' | 'masonry';
export const DocListViewIcon = ({
view,
...props
}: { view: DocListItemView } & SVGProps<SVGSVGElement>) => {
const Component = {
list: PropertyIcon,
grid: ResizeTidyUpIcon,
masonry: AutoTidyUpIcon,
}[view];
return <Component {...props} />;
};
export interface DocListItemProps {
docId: string;
groupId: string;
}
class MixId {
static connector = '||';
static create(groupId: string, docId: string) {
return `${groupId}${this.connector}${docId}`;
}
static parse(mixId: string) {
if (!mixId) {
return { groupId: null, docId: null };
}
const [groupId, docId] = mixId.split(this.connector);
return { groupId, docId };
}
}
export const DocListItem = ({ ...props }: DocListItemProps) => {
const {
view,
groups,
selectMode,
selectedDocIds,
prevCheckAnchorId,
onSelect,
onToggleSelect,
setPrevCheckAnchorId,
} = useContext(DocExplorerContext);
const handleMultiSelect = useCallback(
(prevCursor: string, currCursor: string) => {
const flattenList = groups.flatMap(group =>
group.items.map(docId => MixId.create(group.key, docId))
);
const prevIndex = flattenList.indexOf(prevCursor);
const currIndex = flattenList.indexOf(currCursor);
onSelect(prev => {
const lowerIndex = Math.min(prevIndex, currIndex);
const upperIndex = Math.max(prevIndex, currIndex);
const resSet = new Set(prev);
const handledSet = new Set<string>();
for (let i = lowerIndex; i <= upperIndex; i++) {
const mixId = flattenList[i];
const { groupId, docId } = MixId.parse(mixId);
if (groupId === null || docId === null) {
continue;
}
if (handledSet.has(docId) || mixId === prevCursor) {
continue;
}
if (resSet.has(docId)) {
resSet.delete(docId);
} else {
resSet.add(docId);
}
handledSet.add(docId);
}
return [...resSet];
});
setPrevCheckAnchorId(currCursor);
},
[groups, onSelect, setPrevCheckAnchorId]
);
const handleClick = useCallback(
(e: React.MouseEvent<Element>) => {
const { docId, groupId } = props;
const currCursor = MixId.create(groupId, docId);
if (selectMode || e.shiftKey) {
e.preventDefault();
}
if (selectMode) {
if (e.shiftKey && prevCheckAnchorId) {
// do multi select
handleMultiSelect(prevCheckAnchorId, currCursor);
} else {
onToggleSelect(docId);
setPrevCheckAnchorId(currCursor);
}
} else {
if (e.shiftKey) {
onToggleSelect(docId);
setPrevCheckAnchorId(currCursor);
return;
} else {
// as link
return;
}
}
},
[
handleMultiSelect,
onToggleSelect,
prevCheckAnchorId,
props,
selectMode,
setPrevCheckAnchorId,
]
);
return (
<WorkbenchLink
draggable={false}
to={`/${props.docId}`}
onClick={handleClick}
data-selected={selectedDocIds.includes(props.docId)}
className={styles.root}
>
{view === 'list' ? (
<ListViewDoc {...props} />
) : (
<CardViewDoc {...props} />
)}
</WorkbenchLink>
);
};
const RawDocIcon = memo(function RawDocIcon({
id,
...props
}: HTMLProps<SVGSVGElement>) {
const docDisplayMetaService = useService(DocDisplayMetaService);
const Icon = useLiveData(id ? docDisplayMetaService.icon$(id) : null);
return <Icon {...props} />;
});
const RawDocTitle = memo(function RawDocTitle({ id }: { id: string }) {
const i18n = useI18n();
const docDisplayMetaService = useService(DocDisplayMetaService);
const title = useLiveData(docDisplayMetaService.title$(id));
return i18n.t(title);
});
const RawDocPreview = memo(function RawDocPreview({
id,
loading,
}: {
id: string;
loading?: ReactNode;
}) {
return <PagePreview pageId={id} fallback={loading} />;
});
const DragHandle = memo(function DragHandle({
id,
preview,
...props
}: HTMLProps<HTMLDivElement> & { preview?: ReactNode }) {
const { selectMode } = useContext(DocExplorerContext);
const { dragRef, CustomDragPreview } = useDraggable<AffineDNDData>(
() => ({
canDrag: true,
data: {
entity: {
type: 'doc',
id: id as string,
},
from: {
at: 'all-docs:list',
},
},
}),
[id]
);
if (selectMode || !id) {
return null;
}
return (
<>
<div ref={dragRef} {...props}>
<DragHandleIcon />
</div>
<CustomDragPreview>
{preview ?? (
<>
<RawDocIcon id={id} />
<RawDocTitle id={id} />
</>
)}
</CustomDragPreview>
</>
);
});
const Select = memo(function Select({
id,
...props
}: HTMLProps<HTMLDivElement>) {
const { selectMode, selectedDocIds, onToggleSelect } =
useContext(DocExplorerContext);
const handleSelectChange = useCallback(() => {
id && onToggleSelect(id);
}, [id, onToggleSelect]);
if (!id) {
return null;
}
return (
<div data-select-mode={selectMode} {...props}>
<Checkbox
checked={selectedDocIds.includes(id)}
onChange={handleSelectChange}
/>
</div>
);
});
// Different with RawDocIcon, refer to `ExplorerPreference.showDocIcon`
const DocIcon = memo(function DocIcon({
id,
...props
}: HTMLProps<HTMLDivElement>) {
const { showDocIcon } = useContext(DocExplorerContext);
if (!showDocIcon) {
return null;
}
return (
<div {...props}>
<RawDocIcon id={id} />
</div>
);
});
const DocTitle = memo(function DocTitle({
id,
...props
}: HTMLProps<HTMLDivElement>) {
if (!id) return null;
return (
<div {...props}>
<RawDocTitle id={id} />
</div>
);
});
const DocPreview = memo(function DocPreview({
id,
loading,
...props
}: HTMLProps<HTMLDivElement> & { loading?: ReactNode }) {
const { showDocPreview } = useContext(DocExplorerContext);
if (!id || !showDocPreview) return null;
return (
<div {...props}>
<RawDocPreview id={id} loading={loading} />
</div>
);
});
const listMoreMenuContentOptions = {
side: 'bottom',
align: 'end',
sideOffset: 12,
alignOffset: -4,
} as const;
export const ListViewDoc = ({ docId }: DocListItemProps) => {
const docsService = useService(DocsService);
const doc = useLiveData(docsService.list.doc$(docId));
const [previewSkeletonWidth] = useState(
Math.round(100 + Math.random() * 100)
);
if (!doc) {
return null;
}
return (
<li className={styles.listViewRoot}>
<DragHandle id={docId} className={styles.listDragHandle} />
<Select id={docId} className={styles.listSelect} />
<DocIcon id={docId} className={styles.listIcon} />
<div className={styles.listBrief}>
<DocTitle id={docId} className={styles.listTitle} />
<DocPreview
id={docId}
className={styles.listPreview}
loading={<Skeleton height={20} width={previewSkeletonWidth} />}
/>
</div>
<div className={styles.listSpace} />
<ListViewProperties docId={docId} />
{quickActions.map(action => {
return <action.Component key={action.key} doc={doc} />;
})}
<MoreMenuButton
docId={docId}
contentOptions={listMoreMenuContentOptions}
/>
</li>
);
};
const cardMoreMenuContentOptions = {
side: 'bottom',
align: 'end',
sideOffset: 12,
alignOffset: -4,
} as const;
const randomPreviewSkeleton = () => {
return Array.from({ length: Math.floor(Math.random() * 2) + 1 }, (_, i) => ({
id: i,
width: Math.round(30 + Math.random() * 70),
}));
};
export const CardViewDoc = ({ docId }: DocListItemProps) => {
const { selectMode } = useContext(DocExplorerContext);
const docsService = useService(DocsService);
const doc = useLiveData(docsService.list.doc$(docId));
const [previewSkeleton] = useState(randomPreviewSkeleton);
if (!doc) {
return null;
}
return (
<li className={styles.cardViewRoot}>
<header className={styles.cardViewHeader}>
<DocIcon id={docId} className={styles.cardViewIcon} />
<DocTitle id={docId} className={styles.cardViewTitle} />
{quickActions.map(action => {
return <action.Component size="16" key={action.key} doc={doc} />;
})}
{selectMode ? (
<Select id={docId} className={styles.cardViewCheckbox} />
) : (
<MoreMenuButton
docId={docId}
contentOptions={cardMoreMenuContentOptions}
iconProps={{ size: '16' }}
/>
)}
</header>
<DocPreview
id={docId}
className={styles.cardPreviewContainer}
loading={
<div>
{previewSkeleton.map(({ id, width }) => (
<Skeleton key={id} height={20} width={`${width}%`} />
))}
</div>
}
/>
<CardViewProperties docId={docId} />
</li>
);
};
@@ -0,0 +1,99 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const groupHeader = style({
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
position: 'relative',
padding: '0px 4px',
borderRadius: 4,
':hover': {
background: cssVarV2.layer.background.hoverOverlay,
},
});
export const space = style({
width: 0,
flex: 1,
});
export const plainTextGroupHeaderIcon = style({
width: 24,
height: 24,
fontSize: 20,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
});
export const plainTextGroupHeader = style({
gap: 4,
display: 'flex',
alignItems: 'center',
paddingLeft: 4,
selectors: {
[`&:has(${plainTextGroupHeaderIcon})`]: {
paddingLeft: 0,
},
},
});
const showOnHover = style({
opacity: 0,
selectors: {
[`${groupHeader}:hover &`]: {
opacity: 1,
},
},
});
export const collapseButton = style([
showOnHover,
{
width: 20,
height: 20,
marginLeft: 2,
fontSize: 16,
flexShrink: 0,
color: cssVarV2.icon.primary,
},
]);
export const collapseButtonIcon = style({
vars: {
'--rotate': '90deg',
},
transition: 'transform 0.23s cubic-bezier(.56,.15,.37,.97)',
transform: 'rotate(var(--rotate))',
selectors: {
[`${groupHeader}[data-collapsed="true"] &`]: {
vars: {
'--rotate': '0deg',
},
},
},
});
export const selectInfo = style({
fontSize: 14,
lineHeight: '22px',
color: cssVarV2.text.tertiary,
marginLeft: 12,
});
export const content = style({
flexShrink: 0,
fontSize: 15,
lineHeight: '24px',
color: cssVarV2.text.secondary,
});
export const selectAllButton = style([
showOnHover,
{
padding: '0px 4px',
fontSize: 12,
lineHeight: '20px',
color: cssVarV2.text.secondary,
borderRadius: 4,
},
]);
@@ -0,0 +1,120 @@
import { Button, IconButton } from '@affine/component';
import { useI18n } from '@affine/i18n';
import { ToggleRightIcon } from '@blocksuite/icons/rc';
import clsx from 'clsx';
import {
type HTMLAttributes,
type ReactNode,
useCallback,
useContext,
} from 'react';
import { DocExplorerContext } from '../context';
import * as styles from './group-header.css';
export const DocGroupHeader = ({
className,
groupId,
...props
}: HTMLAttributes<HTMLDivElement> & {
groupId: string;
}) => {
const t = useI18n();
const {
selectMode,
collapsed,
groups,
selectedDocIds,
onToggleCollapse,
onSelect,
} = useContext(DocExplorerContext);
const group = groups.find(g => g.key === groupId);
const isGroupAllSelected = group?.items.every(id =>
selectedDocIds.includes(id)
);
const handleToggleCollapse = useCallback(() => {
onToggleCollapse(groupId);
}, [groupId, onToggleCollapse]);
const handleSelectAll = useCallback(() => {
if (isGroupAllSelected) {
onSelect(prev => prev.filter(id => !group?.items.includes(id)));
} else {
onSelect(prev => {
const newSelected = [...prev];
group?.items.forEach(id => {
if (!newSelected.includes(id)) {
newSelected.push(id);
}
});
return newSelected;
});
}
}, [group?.items, isGroupAllSelected, onSelect]);
const selectedCount = group?.items.filter(id =>
selectedDocIds.includes(id)
).length;
return (
<div
className={styles.groupHeader}
data-collapsed={collapsed.includes(groupId)}
>
<div className={clsx(styles.content, className)} {...props} />
{selectMode ? (
<div className={styles.selectInfo}>
{selectedCount}/{group?.items.length}
</div>
) : null}
<IconButton
className={styles.collapseButton}
icon={<ToggleRightIcon className={styles.collapseButtonIcon} />}
onClick={handleToggleCollapse}
/>
<div className={styles.space} />
<Button
size="custom"
className={styles.selectAllButton}
variant="plain"
onClick={handleSelectAll}
>
{t[
isGroupAllSelected
? 'com.affine.page.group-header.clear'
: 'com.affine.page.group-header.select-all'
]()}
</Button>
</div>
);
};
export const PlainTextDocGroupHeader = ({
groupId,
docCount,
className,
children,
icon,
...props
}: HTMLAttributes<HTMLDivElement> & {
groupId: string;
docCount: number;
icon?: ReactNode;
}) => {
return (
<DocGroupHeader
className={clsx(styles.plainTextGroupHeader, className)}
groupId={groupId}
{...props}
>
{icon ? (
<div className={styles.plainTextGroupHeaderIcon}>{icon}</div>
) : null}
<div>{children ?? groupId}</div>
<div>·</div>
<div>{docCount}</div>
</DocGroupHeader>
);
};
@@ -0,0 +1,211 @@
import {
IconButton,
type IconButtonProps,
Menu,
MenuItem,
type MenuProps,
useConfirmModal,
} from '@affine/component';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { DocsService } from '@affine/core/modules/doc';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { useI18n } from '@affine/i18n';
import track from '@affine/track';
import {
DeleteIcon,
DuplicateIcon,
InformationIcon,
MoreVerticalIcon,
OpenInNewIcon,
SplitViewIcon,
} from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback } from 'react';
import { useBlockSuiteMetaHelper } from '../../hooks/affine/use-block-suite-meta-helper';
import { IsFavoriteIcon } from '../../pure/icons';
interface DocOperationProps {
docId: string;
}
/**
* Favorite Operation
*/
const ToggleFavorite = ({ docId }: DocOperationProps) => {
const t = useI18n();
const favAdapter = useService(CompatibleFavoriteItemsAdapter);
const favourite = useLiveData(favAdapter.isFavorite$(docId, 'doc'));
const toggleFavorite = useCallback(() => {
favAdapter.toggle(docId, 'doc');
}, [docId, favAdapter]);
return (
<MenuItem
prefixIcon={<IsFavoriteIcon favorite={favourite} />}
onClick={toggleFavorite}
>
{favourite
? t['com.affine.favoritePageOperation.remove']()
: t['com.affine.favoritePageOperation.add']()}
</MenuItem>
);
};
/**
* Doc Info Operation
*/
const DocInfo = ({ docId }: DocOperationProps) => {
const t = useI18n();
const workspaceDialogService = useService(WorkspaceDialogService);
const onOpenInfoModal = useCallback(() => {
if (docId) {
track.$.docInfoPanel.$.open();
workspaceDialogService.open('doc-info', { docId });
}
}, [docId, workspaceDialogService]);
return (
<MenuItem onClick={onOpenInfoModal} prefixIcon={<InformationIcon />}>
{t['com.affine.page-properties.page-info.view']()}
</MenuItem>
);
};
/**
* Open in New Tab Operation
*/
const NewTab = ({ docId }: DocOperationProps) => {
const t = useI18n();
const workbench = useService(WorkbenchService).workbench;
const onOpenInNewTab = useCallback(() => {
workbench.openDoc(docId, { at: 'new-tab' });
}, [docId, workbench]);
return (
<MenuItem onClick={onOpenInNewTab} prefixIcon={<OpenInNewIcon />}>
{t['com.affine.workbench.tab.page-menu-open']()}
</MenuItem>
);
};
/**
* Open in Split View Operation
*/
const SplitView = ({ docId }: DocOperationProps) => {
const t = useI18n();
const workbench = useService(WorkbenchService).workbench;
const onOpenInSplitView = useCallback(() => {
track.allDocs.list.docMenu.openInSplitView();
workbench.openDoc(docId, { at: 'tail' });
}, [docId, workbench]);
return (
<MenuItem onClick={onOpenInSplitView} prefixIcon={<SplitViewIcon />}>
{t['com.affine.workbench.tab.page-menu-open']()}
</MenuItem>
);
};
/**
* Duplicate Operation
*/
const Duplicate = ({ docId }: DocOperationProps) => {
const { duplicate } = useBlockSuiteMetaHelper();
const t = useI18n();
const onDuplicate = useCallback(() => {
duplicate(docId, false);
track.allDocs.list.docMenu.createDoc({
control: 'duplicate',
});
}, [docId, duplicate]);
return (
<MenuItem prefixIcon={<DuplicateIcon />} onSelect={onDuplicate}>
{t['com.affine.header.option.duplicate']()}
</MenuItem>
);
};
/**
* Move to Trash Operation
*/
const MoveToTrash = ({ docId }: DocOperationProps) => {
const t = useI18n();
const docsService = useService(DocsService);
const { openConfirmModal } = useConfirmModal();
const doc = useLiveData(docsService.list.doc$(docId));
const onMoveToTrash = useCallback(() => {
if (!doc) {
return;
}
track.allDocs.list.docMenu.deleteDoc();
openConfirmModal({
title: t['com.affine.moveToTrash.confirmModal.title'](),
description: t['com.affine.moveToTrash.confirmModal.description']({
title: doc.title$.value || t['Untitled'](),
}),
cancelText: t['com.affine.confirmModal.button.cancel'](),
confirmText: t.Delete(),
confirmButtonOptions: {
variant: 'error',
},
onConfirm: () => {
doc.moveToTrash();
},
});
}, [doc, openConfirmModal, t]);
return (
<MenuItem prefixIcon={<DeleteIcon />} onClick={onMoveToTrash}>
{t['com.affine.moveToTrash.title']()}
</MenuItem>
);
};
export const MoreMenuContent = (props: DocOperationProps) => {
return (
<>
<ToggleFavorite {...props} />
<DocInfo {...props} />
<NewTab {...props} />
{BUILD_CONFIG.isElectron ? <SplitView {...props} /> : null}
<Duplicate {...props} />
<MoveToTrash {...props} />
</>
);
};
export const MoreMenu = ({
docId,
children,
...menuProps
}: Omit<MenuProps, 'items'> & { docId: string }) => {
return (
<Menu items={<MoreMenuContent docId={docId} />} {...menuProps}>
{children}
</Menu>
);
};
export const MoreMenuButton = ({
docId,
iconProps,
...menuProps
}: Omit<MenuProps, 'items' | 'children'> & {
docId: string;
iconProps?: IconButtonProps;
}) => {
return (
<MoreMenu docId={docId} {...menuProps}>
<IconButton icon={<MoreVerticalIcon />} {...iconProps} />
</MoreMenu>
);
};
@@ -0,0 +1,103 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
// export const root = style({
// display: 'flex',
// gap: 8,
// justifyContent: 'flex-end',
// minWidth: 0,
// flexGrow: 1,
// flexShrink: 0,
// });
export const stackContainer = style({
display: 'flex',
gap: 8,
flexShrink: 1.5,
minWidth: 0,
justifyContent: 'flex-end',
});
export const stackProperties = style({
display: 'flex',
justifyContent: 'flex-end',
gap: 10,
minWidth: 0,
flexGrow: 1,
flexShrink: 5,
transition: 'flex-shrink 0.23s cubic-bezier(.56,.15,.37,.97)',
selectors: {
'&:empty': {
display: 'none',
},
'&:not(:empty)': {
minWidth: 40,
},
'&:hover': {
flexShrink: 0.2,
},
},
});
export const stackItem = style({
display: 'flex',
alignItems: 'center',
minWidth: 0,
maxWidth: 128,
selectors: {
'&:last-child': {
minWidth: 'fit-content',
},
},
});
export const stackItemContent = style({
height: 24,
borderRadius: 12,
borderWidth: 1,
borderStyle: 'solid',
borderColor: cssVarV2.layer.insideBorder.blackBorder,
padding: '0px 8px 0px 6px',
display: 'flex',
alignItems: 'center',
gap: 4,
maxWidth: 'min(128px, 300%)',
minWidth: 48,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
backgroundColor: cssVarV2.layer.background.primary,
flexShrink: 0,
});
export const stackItemIcon = style({
width: 16,
height: 16,
fontSize: 16,
color: cssVarV2.icon.primary,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
});
export const stackItemLabel = style({
fontSize: 12,
lineHeight: '20px',
color: cssVarV2.text.primary,
textOverflow: 'ellipsis',
overflow: 'hidden',
whiteSpace: 'nowrap',
});
export const inlineProperty = style({
flexShrink: 0,
selectors: {
'&:empty': {
display: 'none',
},
},
});
export const cardProperties = style({
display: 'flex',
flexWrap: 'wrap',
gap: 8,
});
@@ -0,0 +1,214 @@
import type { DocCustomPropertyInfo } from '@affine/core/modules/db';
import { type DocRecord, DocsService } from '@affine/core/modules/doc';
import {
WorkspacePropertyService,
type WorkspacePropertyType,
} from '@affine/core/modules/workspace-property';
import { useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx';
import { useContext, useMemo } from 'react';
import type { WorkspacePropertyTypes } from '../../workspace-property-types';
import { DocExplorerContext } from '../context';
import { filterDisplayProperties } from '../display-menu/properties';
import { listHide560, listHide750 } from './doc-list-item.css';
import * as styles from './properties.css';
const listInlinePropertyOrder: WorkspacePropertyType[] = [
'createdAt',
'updatedAt',
'createdBy',
'updatedBy',
];
const cardInlinePropertyOrder: WorkspacePropertyType[] = [
'createdBy',
'updatedBy',
'createdAt',
'updatedAt',
];
const useProperties = (docId: string, view: 'list' | 'card') => {
const { displayProperties } = useContext(DocExplorerContext);
const docsService = useService(DocsService);
const workspacePropertyService = useService(WorkspacePropertyService);
const doc = useLiveData(docsService.list.doc$(docId));
const properties = useLiveData(doc?.properties$);
const propertyList = useLiveData(workspacePropertyService.properties$);
const stackProperties = useMemo(
() => (properties ? filterDisplayProperties(propertyList, 'stack') : []),
[properties, propertyList]
);
const inlineProperties = useMemo(
() =>
properties
? filterDisplayProperties(propertyList, 'inline')
.filter(p => p.property.type !== 'tags')
.sort((a, b) => {
const orderList =
view === 'list'
? listInlinePropertyOrder
: cardInlinePropertyOrder;
const aIndex = orderList.indexOf(a.property.type);
const bIndex = orderList.indexOf(b.property.type);
// Push un-recognised types to the tail instead of the head
return (
(aIndex === -1 ? Number.MAX_SAFE_INTEGER : aIndex) -
(bIndex === -1 ? Number.MAX_SAFE_INTEGER : bIndex)
);
})
: [],
[properties, propertyList, view]
);
const tagsProperty = useMemo(() => {
return propertyList
? filterDisplayProperties(propertyList, 'inline').find(
prop => prop.property.type === 'tags'
)
: undefined;
}, [propertyList]);
return useMemo(
() => ({
doc,
displayProperties,
stackProperties,
inlineProperties,
tagsProperty,
}),
[doc, displayProperties, stackProperties, inlineProperties, tagsProperty]
);
};
export const ListViewProperties = ({ docId }: { docId: string }) => {
const {
doc,
displayProperties,
stackProperties,
inlineProperties,
tagsProperty,
} = useProperties(docId, 'list');
if (!doc) {
return null;
}
return (
<>
{/* stack properties */}
<div className={clsx(styles.stackContainer, listHide750)}>
<div className={styles.stackProperties}>
{stackProperties.map(({ property, config }) => {
if (!displayProperties?.includes(property.id)) {
return null;
}
return (
<PropertyRenderer
key={property.id}
doc={doc}
property={property}
config={config}
/>
);
})}
</div>
{tagsProperty &&
displayProperties?.includes(tagsProperty.property.id) ? (
<div className={styles.stackProperties}>
<PropertyRenderer
doc={doc}
property={tagsProperty.property}
config={tagsProperty.config}
/>
</div>
) : null}
</div>
{/* inline properties */}
{inlineProperties.map(({ property, config }) => {
if (!displayProperties?.includes(property.id)) {
return null;
}
return (
<div
key={property.id}
className={clsx(styles.inlineProperty, listHide560)}
>
<PropertyRenderer doc={doc} property={property} config={config} />
</div>
);
})}
</>
);
};
export const CardViewProperties = ({ docId }: { docId: string }) => {
const {
doc,
displayProperties,
stackProperties,
inlineProperties,
tagsProperty,
} = useProperties(docId, 'card');
if (!doc) {
return null;
}
return (
<div className={styles.cardProperties}>
{inlineProperties.map(({ property, config }) => {
if (!displayProperties?.includes(property.id)) {
return null;
}
return (
<div key={property.id} className={styles.inlineProperty}>
<PropertyRenderer doc={doc} property={property} config={config} />
</div>
);
})}
{tagsProperty && displayProperties?.includes(tagsProperty.property.id) ? (
<PropertyRenderer
doc={doc}
property={tagsProperty.property}
config={tagsProperty.config}
/>
) : null}
{stackProperties.map(({ property, config }) => {
if (!displayProperties?.includes(property.id)) {
return null;
}
return (
<PropertyRenderer
key={property.id}
doc={doc}
property={property}
config={config}
/>
);
})}
</div>
);
};
const PropertyRenderer = ({
property,
doc,
config,
}: {
property: DocCustomPropertyInfo;
doc: DocRecord;
config: (typeof WorkspacePropertyTypes)[keyof typeof WorkspacePropertyTypes];
}) => {
const customPropertyValue = useLiveData(doc.customProperty$(property.id));
if (!config.docListProperty) {
return null;
}
return (
<config.docListProperty
value={customPropertyValue}
doc={doc}
propertyInfo={property}
/>
);
};
@@ -0,0 +1,190 @@
import {
Checkbox,
IconButton,
type IconButtonProps,
useConfirmModal,
} from '@affine/component';
import type { DocRecord } from '@affine/core/modules/doc';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { useI18n } from '@affine/i18n';
import track from '@affine/track';
import { DeleteIcon, OpenInNewIcon, SplitViewIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { memo, useCallback, useContext } from 'react';
import { IsFavoriteIcon } from '../../pure/icons';
import { DocExplorerContext } from '../context';
export interface QuickActionProps extends IconButtonProps {
doc: DocRecord;
}
export const QuickFavorite = memo(function QuickFavorite({
doc,
...iconButtonProps
}: QuickActionProps) {
const { quickFavorite } = useContext(DocExplorerContext);
const favAdapter = useService(CompatibleFavoriteItemsAdapter);
const favourite = useLiveData(favAdapter.isFavorite$(doc.id, 'doc'));
const toggleFavorite = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
favAdapter.toggle(doc.id, 'doc');
},
[doc.id, favAdapter]
);
if (!quickFavorite) {
return null;
}
return (
<IconButton
icon={<IsFavoriteIcon favorite={favourite} />}
onClick={toggleFavorite}
{...iconButtonProps}
/>
);
});
export const QuickTab = memo(function QuickTab({
doc,
...iconButtonProps
}: QuickActionProps) {
const { quickTab } = useContext(DocExplorerContext);
const workbench = useService(WorkbenchService).workbench;
const onOpenInNewTab = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
workbench.openDoc(doc.id, { at: 'new-tab' });
},
[doc.id, workbench]
);
if (!quickTab) {
return null;
}
return (
<IconButton
onClick={onOpenInNewTab}
icon={<OpenInNewIcon />}
{...iconButtonProps}
/>
);
});
export const QuickSplit = memo(function QuickSplit({
doc,
...iconButtonProps
}: QuickActionProps) {
const { quickSplit } = useContext(DocExplorerContext);
const workbench = useService(WorkbenchService).workbench;
const onOpenInSplitView = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
track.allDocs.list.docMenu.openInSplitView();
workbench.openDoc(doc.id, { at: 'tail' });
},
[doc.id, workbench]
);
if (!quickSplit) {
return null;
}
return (
<IconButton
onClick={onOpenInSplitView}
icon={<SplitViewIcon />}
{...iconButtonProps}
/>
);
});
export const QuickDelete = memo(function QuickDelete({
doc,
...iconButtonProps
}: QuickActionProps) {
const t = useI18n();
const { openConfirmModal } = useConfirmModal();
const { quickTrash } = useContext(DocExplorerContext);
const onMoveToTrash = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
if (!doc) {
return;
}
track.allDocs.list.docMenu.deleteDoc();
openConfirmModal({
title: t['com.affine.moveToTrash.confirmModal.title'](),
description: t['com.affine.moveToTrash.confirmModal.description']({
title: doc.title$.value || t['Untitled'](),
}),
cancelText: t['com.affine.confirmModal.button.cancel'](),
confirmText: t.Delete(),
confirmButtonOptions: {
variant: 'error',
},
onConfirm: () => {
doc.moveToTrash();
},
});
},
[doc, openConfirmModal, t]
);
if (!quickTrash) {
return null;
}
return (
<IconButton
onClick={onMoveToTrash}
icon={<DeleteIcon />}
variant="danger"
{...iconButtonProps}
/>
);
});
export const QuickSelect = memo(function QuickSelect({
doc,
...iconButtonProps
}: QuickActionProps) {
const { quickSelect, selectedDocIds, onToggleSelect } =
useContext(DocExplorerContext);
const selected = selectedDocIds.includes(doc.id);
const onChange = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
onToggleSelect(doc.id);
},
[doc.id, onToggleSelect]
);
if (!quickSelect) {
return null;
}
return (
<IconButton
onClick={onChange}
icon={<Checkbox checked={selected} style={{ pointerEvents: 'none' }} />}
{...iconButtonProps}
/>
);
});
@@ -0,0 +1,18 @@
import * as styles from './properties.css';
export const StackProperty = ({
icon,
children,
}: {
icon: React.ReactNode;
children: React.ReactNode;
}) => {
return (
<div className={styles.stackItem}>
<div className={styles.stackItemContent}>
<div className={styles.stackItemIcon}>{icon}</div>
<div className={styles.stackItemLabel}>{children}</div>
</div>
</div>
);
};
@@ -0,0 +1,52 @@
import type { I18nString } from '@affine/i18n';
import {
type QuickActionProps,
QuickDelete,
QuickFavorite,
QuickSelect,
QuickSplit,
QuickTab,
} from './docs-view/quick-actions';
import type { ExplorerPreference } from './types';
export interface QuickAction {
name: I18nString;
Component: React.FC<QuickActionProps>;
disabled?: boolean;
}
type ExtractPrefixKeys<Obj extends object, Prefix extends string> = {
[Key in keyof Obj]-?: Key extends `${Prefix}${string}` ? Key : never;
}[keyof Obj];
export type QuickActionKey = ExtractPrefixKeys<ExplorerPreference, 'quick'>;
const QUICK_ACTION_MAP: Record<QuickActionKey, QuickAction> = {
quickFavorite: {
name: 'com.affine.all-docs.quick-action.favorite',
Component: QuickFavorite,
},
quickTrash: {
name: 'com.affine.all-docs.quick-action.trash',
Component: QuickDelete,
},
quickSplit: {
name: 'com.affine.all-docs.quick-action.split',
Component: QuickSplit,
disabled: !BUILD_CONFIG.isElectron,
},
quickTab: {
name: 'com.affine.all-docs.quick-action.tab',
Component: QuickTab,
},
quickSelect: {
name: 'com.affine.all-docs.quick-action.select',
Component: QuickSelect,
},
};
export const quickActions = Object.entries(QUICK_ACTION_MAP).map(
([key, config]) => {
return { key: key as QuickActionKey, ...config };
}
);
@@ -3,9 +3,31 @@ import type {
GroupByParams,
OrderByParams,
} from '@affine/core/modules/collection-rules/types';
import type { DocCustomPropertyInfo } from '@affine/core/modules/db';
import type { DocRecord } from '@affine/core/modules/doc';
export interface ExplorerPreference {
filters?: FilterParams[];
groupBy?: GroupByParams;
orderBy?: OrderByParams;
displayProperties?: string[];
showDocIcon?: boolean;
showDocPreview?: boolean;
quickFavorite?: boolean;
quickTrash?: boolean;
quickSplit?: boolean;
quickTab?: boolean;
quickSelect?: boolean;
}
export interface DocListPropertyProps {
value: any;
doc: DocRecord;
propertyInfo: DocCustomPropertyInfo;
}
export interface GroupHeaderProps {
groupId: string;
docCount: number;
collapsed: boolean;
}
@@ -1,7 +1,12 @@
import { Checkbox, Menu, MenuItem, PropertyValue } from '@affine/component';
import type { FilterParams } from '@affine/core/modules/collection-rules';
import { useI18n } from '@affine/i18n';
import { CheckBoxCheckLinearIcon } from '@blocksuite/icons/rc';
import { useCallback } from 'react';
import { PlainTextDocGroupHeader } from '../explorer/docs-view/group-header';
import { StackProperty } from '../explorer/docs-view/stack-property';
import type { DocListPropertyProps, GroupHeaderProps } from '../explorer/types';
import type { PropertyValueProps } from '../properties/types';
import * as styles from './checkbox.css';
@@ -73,3 +78,33 @@ export const CheckboxFilterValue = ({
</Menu>
);
};
export const CheckboxDocListProperty = ({
value,
propertyInfo,
}: DocListPropertyProps) => {
const t = useI18n();
if (!value) return null;
return (
<StackProperty icon={<CheckBoxCheckLinearIcon />}>
{/*
Has circular dependency issue (WorkspacePropertyName -> WorkspacePropertyTypes -> Checkbox)
<WorkspacePropertyName propertyInfo={propertyInfo} />
*/}
{propertyInfo.name || t['unnamed']()}
</StackProperty>
);
};
export const CheckboxGroupHeader = ({
groupId,
docCount,
}: GroupHeaderProps) => {
const text = groupId === 'true' ? 'Checked' : 'Unchecked';
return (
<PlainTextDocGroupHeader docCount={docCount} groupId={groupId}>
{text}
</PlainTextDocGroupHeader>
);
};
@@ -3,3 +3,9 @@ export const userWrapper = style({
display: 'flex',
gap: '8px',
});
export const userLabelContainer = style({
height: '100%',
display: 'flex',
alignItems: 'center',
});
@@ -1,34 +1,47 @@
import { PropertyValue } from '@affine/component';
import { PublicUserLabel } from '@affine/core/modules/cloud/views/public-user';
import type { FilterParams } from '@affine/core/modules/collection-rules';
import { DocService } from '@affine/core/modules/doc';
import { type DocRecord, DocService } from '@affine/core/modules/doc';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import { cssVarV2 } from '@toeverything/theme/v2';
import { useCallback, useMemo } from 'react';
import { type ReactNode, useCallback, useMemo } from 'react';
import { PlainTextDocGroupHeader } from '../explorer/docs-view/group-header';
import type { DocListPropertyProps, GroupHeaderProps } from '../explorer/types';
import { MemberSelectorInline } from '../member-selector';
import { userWrapper } from './created-updated-by.css';
import * as styles from './created-updated-by.css';
const CreatedByUpdatedByAvatar = (props: {
type: 'CreatedBy' | 'UpdatedBy';
doc: DocRecord;
size?: number;
showName?: boolean;
emptyFallback?: ReactNode;
}) => {
const docService = useService(DocService);
const doc = props.doc;
const userId = useLiveData(
props.type === 'CreatedBy'
? docService.doc.createdBy$
: docService.doc.updatedBy$
props.type === 'CreatedBy' ? doc?.createdBy$ : doc?.updatedBy$
);
if (userId) {
return (
<div className={userWrapper}>
<PublicUserLabel id={userId} />
<div className={styles.userWrapper}>
<PublicUserLabel
id={userId}
size={props.size}
showName={props.showName}
/>
</div>
);
}
return <NoRecordValue />;
return props.emptyFallback === undefined ? (
<NoRecordValue />
) : (
props.emptyFallback
);
};
const NoRecordValue = () => {
@@ -46,6 +59,7 @@ const LocalUserValue = () => {
};
export const CreatedByValue = () => {
const doc = useService(DocService).doc.record;
const workspaceService = useService(WorkspaceService);
const isCloud = workspaceService.workspace.flavour !== 'local';
@@ -59,12 +73,13 @@ export const CreatedByValue = () => {
return (
<PropertyValue readonly>
<CreatedByUpdatedByAvatar type="CreatedBy" />
<CreatedByUpdatedByAvatar type="CreatedBy" doc={doc} />
</PropertyValue>
);
};
export const UpdatedByValue = () => {
const doc = useService(DocService).doc.record;
const workspaceService = useService(WorkspaceService);
const isCloud = workspaceService.workspace.flavour !== 'local';
@@ -78,7 +93,7 @@ export const UpdatedByValue = () => {
return (
<PropertyValue readonly>
<CreatedByUpdatedByAvatar type="UpdatedBy" />
<CreatedByUpdatedByAvatar type="UpdatedBy" doc={doc} />
</PropertyValue>
);
};
@@ -119,3 +134,45 @@ export const CreatedByUpdatedByFilterValue = ({
/>
);
};
export const CreatedByDocListInlineProperty = ({
doc,
}: DocListPropertyProps) => {
return (
<CreatedByUpdatedByAvatar
doc={doc}
type="CreatedBy"
size={20}
emptyFallback={null}
/>
);
};
export const UpdatedByDocListInlineProperty = ({
doc,
}: DocListPropertyProps) => {
return (
<CreatedByUpdatedByAvatar
type="UpdatedBy"
doc={doc}
showName={false}
size={20}
emptyFallback={null}
/>
);
};
export const ModifiedByGroupHeader = ({
groupId,
docCount,
}: GroupHeaderProps) => {
const userId = groupId;
return (
<PlainTextDocGroupHeader groupId={groupId} docCount={docCount}>
<div className={styles.userLabelContainer}>
<PublicUserLabel id={userId} size={20} />
</div>
</PlainTextDocGroupHeader>
);
};
@@ -1,6 +1,28 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const empty = style({
color: cssVar('placeholderColor'),
});
export const dateDocListInlineProperty = style({
width: 60,
textAlign: 'center',
fontSize: 12,
lineHeight: '20px',
color: cssVarV2.text.secondary,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
flexShrink: 0,
});
export const tooltip = style({
display: 'inline-block',
selectors: {
'&::first-letter': {
textTransform: 'uppercase',
},
},
});
@@ -2,10 +2,14 @@ import { DatePicker, Menu, PropertyValue, Tooltip } from '@affine/component';
import type { FilterParams } from '@affine/core/modules/collection-rules';
import { DocService } from '@affine/core/modules/doc';
import { i18nTime, useI18n } from '@affine/i18n';
import { DateTimeIcon } from '@blocksuite/icons/rc';
import { useLiveData, useServices } from '@toeverything/infra';
import { cssVarV2 } from '@toeverything/theme/v2';
import { useCallback } from 'react';
import { PlainTextDocGroupHeader } from '../explorer/docs-view/group-header';
import { StackProperty } from '../explorer/docs-view/stack-property';
import type { DocListPropertyProps, GroupHeaderProps } from '../explorer/types';
import type { PropertyValueProps } from '../properties/types';
import * as styles from './date.css';
@@ -184,3 +188,71 @@ export const DateFilterValue = ({
</>
) : undefined;
};
export const DateDocListProperty = ({ value }: DocListPropertyProps) => {
if (!value) return null;
return (
<StackProperty icon={<DateTimeIcon />}>
{i18nTime(value, { absolute: { accuracy: 'day' } })}
</StackProperty>
);
};
export const CreateDateDocListProperty = ({ doc }: DocListPropertyProps) => {
const t = useI18n();
const docMeta = useLiveData(doc.meta$);
const createDate = docMeta?.createDate;
if (!createDate) return null;
return (
<Tooltip
content={
<span className={styles.tooltip}>
{t.t('created at', { time: i18nTime(createDate) })}
</span>
}
>
<div className={styles.dateDocListInlineProperty}>
{i18nTime(createDate, { relative: true })}
</div>
</Tooltip>
);
};
export const UpdatedDateDocListProperty = ({ doc }: DocListPropertyProps) => {
const t = useI18n();
const docMeta = useLiveData(doc.meta$);
const updatedDate = docMeta?.updatedDate;
if (!updatedDate) return null;
return (
<Tooltip
content={
<span className={styles.tooltip}>
{t.t('updated at', { time: i18nTime(updatedDate) })}
</span>
}
>
<div className={styles.dateDocListInlineProperty}>
{i18nTime(updatedDate, { relative: true })}
</div>
</Tooltip>
);
};
export const DateGroupHeader = ({ groupId, docCount }: GroupHeaderProps) => {
const date = groupId || 'No Date';
return (
<PlainTextDocGroupHeader groupId={groupId} docCount={docCount}>
{date}
</PlainTextDocGroupHeader>
);
};
export const CreatedGroupHeader = (props: GroupHeaderProps) => {
return <PlainTextDocGroupHeader {...props} />;
};
@@ -9,9 +9,13 @@ import type { FilterParams } from '@affine/core/modules/collection-rules';
import { DocService } from '@affine/core/modules/doc';
import { useI18n } from '@affine/i18n';
import type { DocMode } from '@blocksuite/affine/model';
import { EdgelessIcon, PageIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useMemo } from 'react';
import { PlainTextDocGroupHeader } from '../explorer/docs-view/group-header';
import { StackProperty } from '../explorer/docs-view/stack-property';
import type { DocListPropertyProps, GroupHeaderProps } from '../explorer/types';
import type { PropertyValueProps } from '../properties/types';
import { PropertyRadioGroup } from '../properties/widgets/radio-group';
import * as styles from './doc-primary-mode.css';
@@ -114,3 +118,37 @@ export const DocPrimaryModeFilterValue = ({
</Menu>
);
};
export const DocPrimaryModeDocListProperty = ({
doc,
}: DocListPropertyProps) => {
const t = useI18n();
const primaryMode = useLiveData(doc.primaryMode$);
return (
<StackProperty
icon={primaryMode === 'edgeless' ? <EdgelessIcon /> : <PageIcon />}
>
{primaryMode === 'edgeless' ? t['Edgeless']() : t['Page']()}
</StackProperty>
);
};
export const DocPrimaryModeGroupHeader = ({
groupId,
docCount,
}: GroupHeaderProps) => {
const t = useI18n();
const text =
groupId === 'edgeless'
? t['com.affine.edgelessMode']()
: groupId === 'page'
? t['com.affine.pageMode']()
: 'Default';
return (
<PlainTextDocGroupHeader groupId={groupId} docCount={docCount}>
{text}
</PlainTextDocGroupHeader>
);
};
@@ -1,9 +1,12 @@
import { PropertyValue, type RadioItem } from '@affine/component';
import { DocService } from '@affine/core/modules/doc';
import { useI18n } from '@affine/i18n';
import { EdgelessIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useMemo } from 'react';
import { StackProperty } from '../explorer/docs-view/stack-property';
import type { DocListPropertyProps } from '../explorer/types';
import type { PropertyValueProps } from '../properties/types';
import { PropertyRadioGroup } from '../properties/widgets/radio-group';
import * as styles from './edgeless-theme.css';
@@ -56,3 +59,20 @@ export const EdgelessThemeValue = ({
</PropertyValue>
);
};
export const EdgelessThemeDocListProperty = ({ doc }: DocListPropertyProps) => {
const t = useI18n();
const edgelessTheme = useLiveData(
doc.properties$.selector(p => p.edgelessColorTheme)
);
return (
<StackProperty icon={<EdgelessIcon />}>
{edgelessTheme === 'system' || !edgelessTheme
? t['com.affine.themeSettings.auto']()
: edgelessTheme === 'light'
? t['com.affine.themeSettings.light']()
: t['com.affine.themeSettings.dark']()}
</StackProperty>
);
};
@@ -20,30 +20,64 @@ import {
TodayIcon,
} from '@blocksuite/icons/rc';
import type { DocListPropertyProps, GroupHeaderProps } from '../explorer/types';
import type { PropertyValueProps } from '../properties/types';
import { CheckboxFilterValue, CheckboxValue } from './checkbox';
import {
CheckboxDocListProperty,
CheckboxFilterValue,
CheckboxGroupHeader,
CheckboxValue,
} from './checkbox';
import {
CreatedByDocListInlineProperty,
CreatedByUpdatedByFilterValue,
CreatedByValue,
ModifiedByGroupHeader,
UpdatedByDocListInlineProperty,
UpdatedByValue,
} from './created-updated-by';
import {
CreateDateDocListProperty,
CreateDateValue,
CreatedGroupHeader,
DateDocListProperty,
DateFilterValue,
DateGroupHeader,
DateValue,
UpdatedDateDocListProperty,
UpdatedDateValue,
} from './date';
import {
DocPrimaryModeDocListProperty,
DocPrimaryModeFilterValue,
DocPrimaryModeGroupHeader,
DocPrimaryModeValue,
} from './doc-primary-mode';
import { EdgelessThemeValue } from './edgeless-theme';
import { JournalFilterValue, JournalValue } from './journal';
import { NumberValue } from './number';
import { PageWidthValue } from './page-width';
import { TagsFilterValue, TagsValue } from './tags';
import { TemplateValue } from './template';
import { TextFilterValue, TextValue } from './text';
import {
EdgelessThemeDocListProperty,
EdgelessThemeValue,
} from './edgeless-theme';
import {
JournalDocListProperty,
JournalFilterValue,
JournalGroupHeader,
JournalValue,
} from './journal';
import { NumberDocListProperty, NumberValue } from './number';
import { PageWidthDocListProperty, PageWidthValue } from './page-width';
import {
TagsDocListProperty,
TagsFilterValue,
TagsGroupHeader,
TagsValue,
} from './tags';
import { TemplateDocListProperty, TemplateValue } from './template';
import {
TextDocListProperty,
TextFilterValue,
TextGroupHeader,
TextValue,
} from './text';
const DateFilterMethod = {
after: 'com.affine.filter.after',
@@ -76,6 +110,9 @@ export const WorkspacePropertyTypes = {
allowInOrderBy: true,
defaultFilter: { method: 'is-not-empty' },
filterValue: TagsFilterValue,
showInDocList: 'inline',
docListProperty: TagsDocListProperty,
groupHeader: TagsGroupHeader,
},
text: {
icon: TextIcon,
@@ -92,12 +129,17 @@ export const WorkspacePropertyTypes = {
allowInOrderBy: true,
filterValue: TextFilterValue,
defaultFilter: { method: 'is-not-empty' },
showInDocList: 'stack',
docListProperty: TextDocListProperty,
groupHeader: TextGroupHeader,
},
number: {
icon: NumberIcon,
value: NumberValue,
name: 'com.affine.page-properties.property.number',
description: 'com.affine.page-properties.property.number.tooltips',
showInDocList: 'stack',
docListProperty: NumberDocListProperty,
},
checkbox: {
icon: CheckBoxCheckLinearIcon,
@@ -112,6 +154,9 @@ export const WorkspacePropertyTypes = {
allowInOrderBy: true,
filterValue: CheckboxFilterValue,
defaultFilter: { method: 'is', value: 'true' },
showInDocList: 'stack',
docListProperty: CheckboxDocListProperty,
groupHeader: CheckboxGroupHeader,
},
date: {
icon: DateTimeIcon,
@@ -127,6 +172,9 @@ export const WorkspacePropertyTypes = {
allowInOrderBy: true,
filterValue: DateFilterValue,
defaultFilter: { method: 'is-not-empty' },
showInDocList: 'stack',
docListProperty: DateDocListProperty,
groupHeader: DateGroupHeader,
},
createdBy: {
icon: MemberIcon,
@@ -140,6 +188,9 @@ export const WorkspacePropertyTypes = {
},
filterValue: CreatedByUpdatedByFilterValue,
defaultFilter: { method: 'include', value: '' },
showInDocList: 'inline',
docListProperty: CreatedByDocListInlineProperty,
groupHeader: ModifiedByGroupHeader,
},
updatedBy: {
icon: MemberIcon,
@@ -153,6 +204,9 @@ export const WorkspacePropertyTypes = {
},
filterValue: CreatedByUpdatedByFilterValue,
defaultFilter: { method: 'include', value: '' },
showInDocList: 'inline',
docListProperty: UpdatedByDocListInlineProperty,
groupHeader: ModifiedByGroupHeader,
},
updatedAt: {
icon: DateTimeIcon,
@@ -167,6 +221,8 @@ export const WorkspacePropertyTypes = {
},
filterValue: DateFilterValue,
defaultFilter: { method: 'this-week' },
showInDocList: 'inline',
docListProperty: UpdatedDateDocListProperty,
},
createdAt: {
icon: HistoryIcon,
@@ -181,6 +237,9 @@ export const WorkspacePropertyTypes = {
},
filterValue: DateFilterValue,
defaultFilter: { method: 'this-week' },
showInDocList: 'inline',
docListProperty: CreateDateDocListProperty,
groupHeader: CreatedGroupHeader,
},
docPrimaryMode: {
icon: FileIcon,
@@ -195,6 +254,9 @@ export const WorkspacePropertyTypes = {
},
filterValue: DocPrimaryModeFilterValue,
defaultFilter: { method: 'is', value: 'page' },
showInDocList: 'stack',
docListProperty: DocPrimaryModeDocListProperty,
groupHeader: DocPrimaryModeGroupHeader,
},
journal: {
icon: TodayIcon,
@@ -209,18 +271,25 @@ export const WorkspacePropertyTypes = {
},
filterValue: JournalFilterValue,
defaultFilter: { method: 'is', value: 'true' },
showInDocList: 'stack',
docListProperty: JournalDocListProperty,
groupHeader: JournalGroupHeader,
},
edgelessTheme: {
icon: EdgelessIcon,
value: EdgelessThemeValue,
name: 'com.affine.page-properties.property.edgelessTheme',
description: 'com.affine.page-properties.property.edgelessTheme.tooltips',
showInDocList: 'stack',
docListProperty: EdgelessThemeDocListProperty,
},
pageWidth: {
icon: LongerIcon,
value: PageWidthValue,
name: 'com.affine.page-properties.property.pageWidth',
description: 'com.affine.page-properties.property.pageWidth.tooltips',
showInDocList: 'stack',
docListProperty: PageWidthDocListProperty,
},
template: {
icon: TemplateIcon,
@@ -228,6 +297,8 @@ export const WorkspacePropertyTypes = {
name: 'com.affine.page-properties.property.template',
renameable: true,
description: 'com.affine.page-properties.property.template.tooltips',
showInDocList: 'stack',
docListProperty: TemplateDocListProperty,
},
unknown: {
icon: PropertyIcon,
@@ -254,6 +325,14 @@ export const WorkspacePropertyTypes = {
name: I18nString;
renameable?: boolean;
description?: I18nString;
/**
* Whether to show the property in the doc list,
* - `inline`: show the property in the doc list inline
* - `stack`: show as tags
*/
showInDocList?: 'inline' | 'stack';
docListProperty?: React.FC<DocListPropertyProps>;
groupHeader?: React.FC<GroupHeaderProps>;
};
};
@@ -12,6 +12,7 @@ import { JournalService } from '@affine/core/modules/journal';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { ViewService } from '@affine/core/modules/workbench/services/view';
import { i18nTime, useI18n } from '@affine/i18n';
import { TodayIcon } from '@blocksuite/icons/rc';
import {
useLiveData,
useService,
@@ -20,6 +21,9 @@ import {
import dayjs from 'dayjs';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { PlainTextDocGroupHeader } from '../explorer/docs-view/group-header';
import { StackProperty } from '../explorer/docs-view/stack-property';
import type { DocListPropertyProps, GroupHeaderProps } from '../explorer/types';
import type { PropertyValueProps } from '../properties/types';
import * as styles from './journal.css';
@@ -216,3 +220,27 @@ export const JournalFilterValue = ({
</Menu>
);
};
export const JournalDocListProperty = ({ doc }: DocListPropertyProps) => {
const journalService = useService(JournalService);
const journalDate = useLiveData(journalService.journalDate$(doc.id));
if (!journalDate) {
return null;
}
return (
<StackProperty icon={<TodayIcon />}>
{i18nTime(journalDate, { absolute: { accuracy: 'day' } })}
</StackProperty>
);
};
export const JournalGroupHeader = ({ groupId, docCount }: GroupHeaderProps) => {
const text = groupId === 'true' ? 'Journal' : 'Not Journal';
return (
<PlainTextDocGroupHeader groupId={groupId} docCount={docCount}>
{text}
</PlainTextDocGroupHeader>
);
};
@@ -1,5 +1,6 @@
import { PropertyValue } from '@affine/component';
import { useI18n } from '@affine/i18n';
import { NumberIcon } from '@blocksuite/icons/rc';
import {
type ChangeEventHandler,
useCallback,
@@ -7,6 +8,7 @@ import {
useState,
} from 'react';
import { StackProperty } from '../explorer/docs-view/stack-property';
import type { PropertyValueProps } from '../properties/types';
import * as styles from './number.css';
@@ -55,3 +57,11 @@ export const NumberValue = ({
</PropertyValue>
);
};
export const NumberDocListProperty = ({ value }: { value: number }) => {
if (value !== 0 && !value) {
return null;
}
return <StackProperty icon={<NumberIcon />}>{value}</StackProperty>;
};
@@ -2,9 +2,12 @@ import { PropertyValue, type RadioItem } from '@affine/component';
import { DocService } from '@affine/core/modules/doc';
import { EditorSettingService } from '@affine/core/modules/editor-setting';
import { useI18n } from '@affine/i18n';
import { LongerIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useMemo } from 'react';
import { StackProperty } from '../explorer/docs-view/stack-property';
import type { DocListPropertyProps } from '../explorer/types';
import type { PropertyValueProps } from '../properties/types';
import { PropertyRadioGroup } from '../properties/widgets/radio-group';
import { container } from './page-width.css';
@@ -58,3 +61,20 @@ export const PageWidthValue = ({ readonly }: PropertyValueProps) => {
</PropertyValue>
);
};
export const PageWidthDocListProperty = ({ doc }: DocListPropertyProps) => {
const t = useI18n();
const pageWidth = useLiveData(doc.properties$.selector(p => p.pageWidth));
return (
<StackProperty icon={<LongerIcon />}>
{pageWidth === 'standard' || !pageWidth
? t[
'com.affine.settings.editorSettings.page.default-page-width.standard'
]()
: t[
'com.affine.settings.editorSettings.page.default-page-width.full-width'
]()}
</StackProperty>
);
};
@@ -5,3 +5,22 @@ export const tagInlineEditor = style({
});
export const container = style({});
export const groupHeader = style({
display: 'flex',
alignItems: 'center',
gap: 8,
});
export const groupHeaderIcon = style({
width: 24,
height: 24,
fontSize: 20,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
});
export const groupHeaderLabel = style({
display: 'flex',
alignItems: 'center',
gap: 4,
});
@@ -1,7 +1,7 @@
import { PropertyValue } from '@affine/component';
import type { FilterParams } from '@affine/core/modules/collection-rules';
import { DocService } from '@affine/core/modules/doc';
import { TagService } from '@affine/core/modules/tag';
import { type Tag, TagService } from '@affine/core/modules/tag';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { useI18n } from '@affine/i18n';
import { TagsIcon } from '@blocksuite/icons/rc';
@@ -9,6 +9,9 @@ import { useLiveData, useService } from '@toeverything/infra';
import { cssVarV2 } from '@toeverything/theme/v2';
import { useCallback, useMemo } from 'react';
import { PlainTextDocGroupHeader } from '../explorer/docs-view/group-header';
import { StackProperty } from '../explorer/docs-view/stack-property';
import type { DocListPropertyProps, GroupHeaderProps } from '../explorer/types';
import { useNavigateHelper } from '../hooks/use-navigate-helper';
import type { PropertyValueProps } from '../properties/types';
import {
@@ -164,3 +167,73 @@ const TagsInlineEditor = ({
/>
);
};
const TagName = ({ tag }: { tag: Tag }) => {
const name = useLiveData(tag.value$);
return name;
};
const TagIcon = ({ tag, size = 8 }: { tag: Tag; size?: number }) => {
const color = useLiveData(tag.color$);
return (
<div
style={{
backgroundColor: color,
width: size,
height: size,
borderRadius: '50%',
}}
/>
);
};
export const TagsDocListProperty = ({ doc }: DocListPropertyProps) => {
const tagList = useService(TagService).tagList;
const tags = useLiveData(tagList.tagsByPageId$(doc.id));
return (
<>
{tags.map(tag => {
return (
<StackProperty icon={<TagIcon tag={tag} />} key={tag.id}>
<TagName tag={tag} />
</StackProperty>
);
})}
</>
);
};
export const TagsGroupHeader = ({ groupId, docCount }: GroupHeaderProps) => {
const t = useI18n();
const tagService = useService(TagService);
const tag = useLiveData(tagService.tagList.tagByTagId$(groupId));
if (!tag) {
return (
<PlainTextDocGroupHeader
groupId={groupId}
docCount={docCount}
icon={
<div
style={{
backgroundColor: cssVarV2.icon.secondary,
width: 8,
height: 8,
borderRadius: '50%',
}}
/>
}
>
{t['com.affine.page.display.grouping.group-by-tag.untagged']()}
</PlainTextDocGroupHeader>
);
}
return (
<PlainTextDocGroupHeader
groupId={groupId}
docCount={docCount}
icon={<TagIcon tag={tag} />}
>
<TagName tag={tag} />
</PlainTextDocGroupHeader>
);
};
@@ -1,8 +1,12 @@
import { Checkbox, PropertyValue } from '@affine/component';
import { DocService } from '@affine/core/modules/doc';
import { useI18n } from '@affine/i18n';
import { TemplateIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { type ChangeEvent, useCallback } from 'react';
import { StackProperty } from '../explorer/docs-view/stack-property';
import type { DocListPropertyProps } from '../explorer/types';
import type { PropertyValueProps } from '../properties/types';
import * as styles from './template.css';
@@ -39,3 +43,16 @@ export const TemplateValue = ({ readonly }: PropertyValueProps) => {
</PropertyValue>
);
};
export const TemplateDocListProperty = ({ doc }: DocListPropertyProps) => {
const t = useI18n();
const isTemplate = useLiveData(doc.properties$.selector(p => p.isTemplate));
if (!isTemplate) {
return null;
}
return (
<StackProperty icon={<TemplateIcon />}>{t['Template']()}</StackProperty>
);
};
@@ -1,7 +1,7 @@
import { Input, Menu, PropertyValue } from '@affine/component';
import type { FilterParams } from '@affine/core/modules/collection-rules';
import { useI18n } from '@affine/i18n';
import { TextIcon } from '@blocksuite/icons/rc';
import { TextIcon, TextTypeIcon } from '@blocksuite/icons/rc';
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import {
@@ -12,6 +12,9 @@ import {
useState,
} from 'react';
import { PlainTextDocGroupHeader } from '../explorer/docs-view/group-header';
import { StackProperty } from '../explorer/docs-view/stack-property';
import type { GroupHeaderProps } from '../explorer/types';
import { ConfigModal } from '../mobile';
import type { PropertyValueProps } from '../properties/types';
import * as styles from './text.css';
@@ -248,3 +251,20 @@ export const TextFilterValue = ({
</Menu>
) : null;
};
export const TextDocListProperty = ({ value }: { value: string }) => {
if (!value) {
return null;
}
return <StackProperty icon={<TextTypeIcon />}>{value}</StackProperty>;
};
export const TextGroupHeader = ({ groupId, docCount }: GroupHeaderProps) => {
const text = groupId || 'No Text';
return (
<PlainTextDocGroupHeader groupId={groupId} docCount={docCount}>
{text}
</PlainTextDocGroupHeader>
);
};
@@ -1,3 +1,4 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const scrollContainer = style({
flex: 1,
@@ -27,3 +28,17 @@ export const body = style({
height: '100%',
width: '100%',
});
export const scrollArea = style({
height: 0,
flex: 1,
});
// group
export const groupHeader = style({
background: cssVarV2.layer.background.primary,
});
export const docItem = style({
transition: 'width 0.2s ease-in-out',
});
@@ -1,11 +1,37 @@
import {
Masonry,
type MasonryGroup,
RadioGroup,
useConfirmModal,
} from '@affine/component';
import {
DocExplorerContext,
type DocExplorerContextType,
} from '@affine/core/components/explorer/context';
import { ExplorerDisplayMenuButton } from '@affine/core/components/explorer/display-menu';
import {
DocListItem,
type DocListItemView,
} from '@affine/core/components/explorer/docs-view/doc-list-item';
import type { ExplorerPreference } from '@affine/core/components/explorer/types';
import { Filters } from '@affine/core/components/filter';
import { ListFloatingToolbar } from '@affine/core/components/page-list/components/list-floating-toolbar';
import { WorkspacePropertyTypes } from '@affine/core/components/workspace-property-types';
import { CollectionRulesService } from '@affine/core/modules/collection-rules';
import type { FilterParams } from '@affine/core/modules/collection-rules/types';
import { useI18n } from '@affine/i18n';
import { useService } from '@toeverything/infra';
import { useCallback, useEffect, useState } from 'react';
import { DocsService } from '@affine/core/modules/doc';
import { WorkspacePropertyService } from '@affine/core/modules/workspace-property';
import { Trans, useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import { cssVarV2 } from '@toeverything/theme/v2';
import {
memo,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import {
ViewBody,
@@ -16,14 +42,123 @@ import {
import { AllDocSidebarTabs } from '../layouts/all-doc-sidebar-tabs';
import * as styles from './all-page.css';
import { MigrationAllDocsDataNotification } from './migration-data';
const GroupHeader = memo(function GroupHeader({
groupId,
collapsed,
itemCount,
}: {
groupId: string;
collapsed?: boolean;
itemCount: number;
}) {
const { groupBy } = useContext(DocExplorerContext);
const propertyService = useService(WorkspacePropertyService);
const allProperties = useLiveData(propertyService.sortedProperties$);
const groupType = groupBy?.type;
const groupKey = groupBy?.key;
const header = useMemo(() => {
if (groupType === 'property') {
const property = allProperties.find(p => p.id === groupKey);
if (!property) return null;
const config = WorkspacePropertyTypes[property.type];
if (!config?.groupHeader) return null;
return (
<config.groupHeader
groupId={groupId}
docCount={itemCount}
collapsed={!!collapsed}
/>
);
} else {
return '// TODO: ' + groupType;
}
}, [allProperties, collapsed, groupId, groupKey, groupType, itemCount]);
if (!groupType) {
return null;
}
return header;
});
const calcCardHeightById = (id: string) => {
const max = 5;
const min = 1;
const code = id.charCodeAt(0);
const value = Math.floor((code % (max - min)) + min);
return 250 + value * 10;
};
const DocListItemComponent = memo(function DocListItemComponent({
itemId,
groupId,
}: {
groupId: string;
itemId: string;
}) {
return <DocListItem docId={itemId} groupId={groupId} />;
});
export const AllPage = () => {
const t = useI18n();
const docsService = useService(DocsService);
const [view, setView] = useState<DocListItemView>('masonry');
const [collapsedGroups, setCollapsedGroups] = useState<string[]>([]);
const [selectMode, setSelectMode] = useState(false);
const [selectedDocIds, setSelectedDocIds] = useState<string[]>([]);
const [prevCheckAnchorId, setPrevCheckAnchorId] = useState<string | null>(
null
);
const [explorerPreference, setExplorerPreference] =
useState<ExplorerPreference>({});
useState<ExplorerPreference>({
filters: [
{
type: 'system',
key: 'trash',
value: 'false',
method: 'is',
},
],
displayProperties: [],
showDocIcon: true,
showDocPreview: true,
});
const [groups, setGroups] = useState<any>([]);
const { openConfirmModal } = useConfirmModal();
const masonryItems = useMemo(() => {
const items = groups.map((group: any) => {
return {
id: group.key,
Component: groups.length > 1 ? GroupHeader : undefined,
height: groups.length > 1 ? 24 : 0,
className: styles.groupHeader,
items: group.items.map((docId: string) => {
return {
id: docId,
Component: DocListItemComponent,
height:
view === 'list'
? 42
: view === 'grid'
? 280
: calcCardHeightById(docId),
'data-view': view,
className: styles.docItem,
};
}),
} satisfies MasonryGroup;
});
return items;
}, [groups, view]);
const collectionRulesService = useService(CollectionRulesService);
useEffect(() => {
const subscription = collectionRulesService
@@ -43,7 +178,26 @@ export const AllPage = () => {
return () => {
subscription.unsubscribe();
};
}, [collectionRulesService, explorerPreference]);
}, [
collectionRulesService,
explorerPreference.filters,
explorerPreference.groupBy,
explorerPreference.orderBy,
]);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setSelectMode(false);
setSelectedDocIds([]);
setPrevCheckAnchorId(null);
}
};
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('keydown', onKeyDown);
};
}, []);
const handleFilterChange = useCallback((filters: FilterParams[]) => {
setExplorerPreference(prev => ({
@@ -51,8 +205,92 @@ export const AllPage = () => {
filters,
}));
}, []);
const toggleGroupCollapse = useCallback((groupId: string) => {
setCollapsedGroups(prev => {
return prev.includes(groupId)
? prev.filter(id => id !== groupId)
: [...prev, groupId];
});
}, []);
const toggleDocSelect = useCallback((docId: string) => {
setSelectMode(true);
setSelectedDocIds(prev => {
return prev.includes(docId)
? prev.filter(id => id !== docId)
: [...prev, docId];
});
}, []);
const onSelect = useCallback(
(...args: Parameters<typeof setSelectedDocIds>) => {
setSelectMode(true);
setSelectedDocIds(...args);
},
[]
);
const handleCloseFloatingToolbar = useCallback(() => {
setSelectMode(false);
setSelectedDocIds([]);
}, []);
const handleMultiDelete = useCallback(() => {
if (selectedDocIds.length === 0) {
return;
}
openConfirmModal({
title: t['com.affine.moveToTrash.confirmModal.title.multiple']({
number: selectedDocIds.length.toString(),
}),
description: t[
'com.affine.moveToTrash.confirmModal.description.multiple'
]({
number: selectedDocIds.length.toString(),
}),
cancelText: t['com.affine.confirmModal.button.cancel'](),
confirmText: t.Delete(),
confirmButtonOptions: {
variant: 'error',
},
onConfirm: () => {
for (const docId of selectedDocIds) {
const doc = docsService.list.doc$(docId).value;
doc?.moveToTrash();
}
},
});
}, [docsService.list, openConfirmModal, selectedDocIds, t]);
const explorerContextValue = useMemo(
() =>
({
...explorerPreference,
view,
groups,
collapsed: collapsedGroups,
selectedDocIds,
selectMode,
prevCheckAnchorId,
setPrevCheckAnchorId,
onToggleCollapse: toggleGroupCollapse,
onToggleSelect: toggleDocSelect,
onSelect,
}) satisfies DocExplorerContextType,
[
collapsedGroups,
explorerPreference,
groups,
onSelect,
prevCheckAnchorId,
selectMode,
selectedDocIds,
toggleDocSelect,
toggleGroupCollapse,
view,
]
);
return (
<>
<DocExplorerContext.Provider value={explorerContextValue}>
<ViewTitle title={t['All pages']()} />
<ViewIcon icon="allDocs" />
<ViewHeader></ViewHeader>
@@ -60,6 +298,12 @@ export const AllPage = () => {
<div className={styles.body}>
<MigrationAllDocsDataNotification />
<div>
<RadioGroup
items={['masonry', 'grid', 'list']}
value={view}
onChange={setView}
width={240}
/>
<Filters
filters={explorerPreference.filters ?? []}
onChange={handleFilterChange}
@@ -69,12 +313,45 @@ export const AllPage = () => {
onChange={setExplorerPreference}
/>
</div>
<pre>{JSON.stringify(explorerPreference, null, 2)}</pre>
<pre>{JSON.stringify(groups, null, 2)}</pre>
<div className={styles.scrollArea}>
<Masonry
items={masonryItems}
gapY={12}
gapX={12}
groupsGap={12}
groupHeaderGapWithItems={12}
columns={view === 'list' ? 1 : undefined}
itemWidthMin={220}
preloadHeight={100}
itemWidth={'stretch'}
virtualScroll
collapsedGroups={collapsedGroups}
paddingX={useCallback(
(w: number) => (w > 500 ? 24 : w > 393 ? 20 : 16),
[]
)}
/>
</div>
</div>
<ListFloatingToolbar
open={selectMode}
onDelete={handleMultiDelete}
onClose={handleCloseFloatingToolbar}
content={
<Trans
i18nKey="com.affine.page.toolbar.selected"
count={selectedDocIds.length}
>
<div style={{ color: cssVarV2.text.secondary }}>
{{ count: selectedDocIds.length } as any}
</div>
selected
</Trans>
}
/>
</ViewBody>
<AllDocSidebarTabs />
</>
</DocExplorerContext.Provider>
);
};
@@ -38,8 +38,6 @@ export type { ServerConfig } from './types';
// eslint-disable-next-line simple-import-sort/imports
import { type Framework } from '@toeverything/infra';
import { DocScope } from '../doc/scopes/doc';
import { DocService } from '../doc/services/doc';
import { GlobalCache, GlobalState } from '../storage/providers/global';
import { GlobalStateService } from '../storage/services/global';
import { UrlService } from '../url';
@@ -101,7 +99,7 @@ import { DocCreatedByService } from './services/doc-created-by';
import { DocUpdatedByService } from './services/doc-updated-by';
import { DocCreatedByUpdatedBySyncService } from './services/doc-created-by-updated-by-sync';
import { WorkspacePermissionService } from '../permissions';
import { DocsService } from '../doc';
import { DocScope, DocService, DocsService } from '../doc';
import { DocCreatedByUpdatedBySyncStore } from './stores/doc-created-by-updated-by-sync';
export function configureCloudModule(framework: Framework) {
@@ -7,7 +7,15 @@ import { useLayoutEffect, useMemo } from 'react';
import { PublicUserService } from '../services/public-user';
import * as styles from './public-user.css';
export const PublicUserLabel = ({ id }: { id: string }) => {
export const PublicUserLabel = ({
id,
size = 20,
showName = true,
}: {
id: string;
size?: number;
showName?: boolean;
}) => {
const serverService = useCurrentServerService();
const publicUser = useMemo(() => {
return serverService?.scope.get(PublicUserService);
@@ -28,10 +36,16 @@ export const PublicUserLabel = ({ id }: { id: string }) => {
}
if (user?.removed) {
return (
return showName ? (
<span className={styles.publicUserLabelRemoved}>
{t['Unknown User']()}
</span>
) : (
<Avatar
size={size}
name={t['Unknown User']()}
className={styles.publicUserLabelAvatar}
/>
);
}
@@ -40,10 +54,10 @@ export const PublicUserLabel = ({ id }: { id: string }) => {
<Avatar
url={user?.avatar}
name={user?.name ?? ''}
size={20}
size={size}
className={styles.publicUserLabelAvatar}
/>
{user?.name}
{showName && user?.name}
</span>
);
};
@@ -25,7 +25,7 @@ export class PropertyFilterProvider extends Service implements FilterProvider {
FilterProvider('property:' + type)
);
if (!provider) {
throw new Error('Unsupported property type');
throw new Error(`Unsupported property type: ${type}`);
}
return provider.filter$(params);
})
@@ -0,0 +1,21 @@
import type { DocsService } from '@affine/core/modules/doc';
import { Service } from '@toeverything/infra';
import { map, type Observable } from 'rxjs';
import type { FilterProvider } from '../../provider';
import type { FilterParams } from '../../types';
export class TrashFilterProvider extends Service implements FilterProvider {
constructor(private readonly docsService: DocsService) {
super();
}
filter$(params: FilterParams): Observable<Set<string>> {
if (params.value === 'true') {
return this.docsService.allTrashDocIds$().pipe(map(ids => new Set(ids)));
} else {
return this.docsService
.allNonTrashDocIds$()
.pipe(map(ids => new Set(ids)));
}
}
}
@@ -14,6 +14,7 @@ import { PropertyFilterProvider } from './impls/filters/property';
import { SystemFilterProvider } from './impls/filters/system';
import { TagsFilterProvider } from './impls/filters/tags';
import { TextPropertyFilterProvider } from './impls/filters/text';
import { TrashFilterProvider } from './impls/filters/trash';
import { UpdatedAtFilterProvider } from './impls/filters/updated-at';
import { UpdatedByFilterProvider } from './impls/filters/updated-by';
import { CheckboxPropertyGroupByProvider } from './impls/group-by/checkbox';
@@ -79,6 +80,7 @@ export function configureCollectionRulesModule(framework: Framework) {
DocPrimaryModeFilterProvider,
[DocsService]
)
.impl(FilterProvider('system:trash'), TrashFilterProvider, [DocsService])
.impl(FilterProvider('property:date'), DatePropertyFilterProvider, [
DocsService,
])
@@ -69,6 +69,14 @@ export class DocsService extends Service {
return this.store.watchAllDocTagIds();
}
allNonTrashDocIds$() {
return this.store.watchNonTrashDocIds();
}
allTrashDocIds$() {
return this.store.watchTrashDocIds();
}
constructor(
private readonly store: DocsStore,
private readonly docPropertiesStore: DocPropertiesStore,
@@ -1,26 +1,26 @@
{
"ar": 97,
"ar": 96,
"ca": 4,
"da": 4,
"de": 97,
"el-GR": 97,
"de": 96,
"el-GR": 96,
"en": 100,
"es-AR": 97,
"es-AR": 96,
"es-CL": 98,
"es": 97,
"fa": 97,
"fr": 97,
"es": 96,
"fa": 96,
"fr": 96,
"hi": 2,
"it-IT": 97,
"it-IT": 96,
"it": 1,
"ja": 97,
"ja": 96,
"ko": 56,
"pl": 97,
"pt-BR": 97,
"ru": 97,
"sv-SE": 97,
"uk": 97,
"pl": 96,
"pt-BR": 96,
"ru": 96,
"sv-SE": 96,
"uk": 96,
"ur": 2,
"zh-Hans": 97,
"zh-Hant": 97
"zh-Hans": 96,
"zh-Hant": 96
}
+52
View File
@@ -593,6 +593,18 @@ export function useAFFiNEI18N(): {
* `current`
*/
current(): string;
/**
* `created at {{time}}`
*/
["created at"](options: {
readonly time: string;
}): string;
/**
* `last updated at {{time}}`
*/
["updated at"](options: {
readonly time: string;
}): string;
/**
* `Automatically check for new updates periodically.`
*/
@@ -6908,6 +6920,46 @@ export function useAFFiNEI18N(): {
* `Inactive workspace`
*/
["com.affine.inactive-workspace"](): string;
/**
* `Display Properties`
*/
["com.affine.all-docs.display.properties"](): string;
/**
* `List view options`
*/
["com.affine.all-docs.display.list-view"](): string;
/**
* `Icon`
*/
["com.affine.all-docs.display.list-view.icon"](): string;
/**
* `Body`
*/
["com.affine.all-docs.display.list-view.body"](): string;
/**
* `Quick actions`
*/
["com.affine.all-docs.quick-actions"](): string;
/**
* `Favorite`
*/
["com.affine.all-docs.quick-action.favorite"](): string;
/**
* `Move to trash`
*/
["com.affine.all-docs.quick-action.trash"](): string;
/**
* `Open in split view`
*/
["com.affine.all-docs.quick-action.split"](): string;
/**
* `Open in new tab`
*/
["com.affine.all-docs.quick-action.tab"](): string;
/**
* `Select checkbox`
*/
["com.affine.all-docs.quick-action.select"](): string;
/**
* `core`
*/
@@ -137,6 +137,8 @@
"Deleted User": "Deleted User",
"all": "all",
"current": "current",
"created at": "created at {{time}}",
"updated at": "last updated at {{time}}",
"com.affine.aboutAFFiNE.autoCheckUpdate.description": "Automatically check for new updates periodically.",
"com.affine.aboutAFFiNE.autoCheckUpdate.title": "Check for updates automatically",
"com.affine.aboutAFFiNE.autoDownloadUpdate.description": "Automatically download updates (to this device).",
@@ -1725,6 +1727,16 @@
"com.affine.inactive": "Inactive",
"com.affine.inactive-member": "Inactive member",
"com.affine.inactive-workspace": "Inactive workspace",
"com.affine.all-docs.display.properties": "Display Properties",
"com.affine.all-docs.display.list-view": "List view options",
"com.affine.all-docs.display.list-view.icon": "Icon",
"com.affine.all-docs.display.list-view.body": "Body",
"com.affine.all-docs.quick-actions": "Quick actions",
"com.affine.all-docs.quick-action.favorite": "Favorite",
"com.affine.all-docs.quick-action.trash": "Move to trash",
"com.affine.all-docs.quick-action.split": "Open in split view",
"com.affine.all-docs.quick-action.tab": "Open in new tab",
"com.affine.all-docs.quick-action.select": "Select checkbox",
"core": "core",
"dark": "Dark",
"invited you to join": "invited you to join",