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