Compare commits

...

2 Commits

Author SHA1 Message Date
EYHN
68b57cdee7 feat(core): move collapsed groups state to masonry 2025-05-15 15:36:42 +09:00
EYHN
afa108d517 feat(core): edit and delete pinned collections in all docs 2025-05-15 13:28:35 +09:00
22 changed files with 419 additions and 156 deletions

View File

@@ -31,6 +31,23 @@ import {
export interface MasonryProps extends React.HTMLAttributes<HTMLDivElement> { export interface MasonryProps extends React.HTMLAttributes<HTMLDivElement> {
items: MasonryItem[] | MasonryGroup[]; items: MasonryItem[] | MasonryGroup[];
itemComponent: React.ComponentType<{
groupId: string;
itemId: string;
}>;
groupComponent?: React.ComponentType<{
groupId: string;
itemCount: number;
collapsed: boolean;
onCollapse: (collapsed: boolean) => void;
}>;
groupHeight?: number | ((group: MasonryGroup) => number);
itemHeight: number | ((item: MasonryItem) => number);
groupClassName?: string;
itemClassName?: string;
gapX?: number; gapX?: number;
gapY?: number; gapY?: number;
paddingX?: MasonryPX; paddingX?: MasonryPX;
@@ -39,8 +56,7 @@ export interface MasonryProps extends React.HTMLAttributes<HTMLDivElement> {
groupsGap?: number; groupsGap?: number;
groupHeaderGapWithItems?: number; groupHeaderGapWithItems?: number;
stickyGroupHeader?: boolean; stickyGroupHeader?: boolean;
collapsedGroups?: string[];
onGroupCollapse?: (groupId: string, collapsed: boolean) => void;
/** /**
* Specify the width of the item. * Specify the width of the item.
* - `number`: The width of the item in pixels. * - `number`: The width of the item in pixels.
@@ -61,6 +77,9 @@ export interface MasonryProps extends React.HTMLAttributes<HTMLDivElement> {
columns?: number; columns?: number;
resizeDebounce?: number; resizeDebounce?: number;
preloadHeight?: number; preloadHeight?: number;
itemSelected?: string[];
onItemSelectedChanged?: (selected: string[]) => void;
} }
export const Masonry = ({ export const Masonry = ({
@@ -77,11 +96,15 @@ export const Masonry = ({
groupsGap = 0, groupsGap = 0,
groupHeaderGapWithItems = 0, groupHeaderGapWithItems = 0,
stickyGroupHeader = true, stickyGroupHeader = true,
collapsedGroups,
columns, columns,
preloadHeight = 50, preloadHeight = 50,
resizeDebounce = 20, resizeDebounce = 20,
onGroupCollapse, groupComponent: GroupComponent,
itemComponent: ItemComponent,
groupClassName,
itemClassName,
itemHeight,
groupHeight,
...props ...props
}: MasonryProps) => { }: MasonryProps) => {
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
@@ -99,9 +122,17 @@ export const Masonry = ({
undefined undefined
); );
const [totalWidth, setTotalWidth] = useState(0); const [totalWidth, setTotalWidth] = useState(0);
const [collapsedGroups, setCollapsedGroups] = useState<string[]>([]);
const onGroupCollapse = useCallback((groupId: string, collapsed: boolean) => {
setCollapsedGroups(prev =>
collapsed ? [...prev, groupId] : prev.filter(id => id !== groupId)
);
}, []);
const stickyGroupCollapsed = !!( const stickyGroupCollapsed = !!(
collapsedGroups && collapsedGroups &&
stickyGroupId && stickyGroupId !== undefined &&
collapsedGroups.includes(stickyGroupId) collapsedGroups.includes(stickyGroupId)
); );
@@ -114,7 +145,7 @@ export const Masonry = ({
}, [items]); }, [items]);
const stickyGroup = useMemo(() => { const stickyGroup = useMemo(() => {
if (!stickyGroupId) return undefined; if (stickyGroupId === undefined) return undefined;
return groups.find(group => group.id === stickyGroupId); return groups.find(group => group.id === stickyGroupId);
}, [groups, stickyGroupId]); }, [groups, stickyGroupId]);
@@ -164,6 +195,8 @@ export const Masonry = ({
groupsGap, groupsGap,
groupHeaderGapWithItems, groupHeaderGapWithItems,
collapsedGroups: collapsedGroups ?? [], collapsedGroups: collapsedGroups ?? [],
groupHeight: groupHeight ?? 0,
itemHeight,
}); });
setLayoutMap(layout); setLayoutMap(layout);
setHeight(height); setHeight(height);
@@ -180,8 +213,10 @@ export const Masonry = ({
gapX, gapX,
gapY, gapY,
groupHeaderGapWithItems, groupHeaderGapWithItems,
groupHeight,
groups, groups,
groupsGap, groupsGap,
itemHeight,
itemWidth, itemWidth,
itemWidthMin, itemWidthMin,
paddingX, paddingX,
@@ -233,13 +268,7 @@ export const Masonry = ({
> >
{groups.map(group => { {groups.map(group => {
// sleep is not calculated, do not render // sleep is not calculated, do not render
const { const { id: groupId, items, ...groupProps } = group;
id: groupId,
items,
className,
Component,
...groupProps
} = group;
const collapsed = const collapsed =
collapsedGroups && collapsedGroups.includes(groupId); collapsedGroups && collapsedGroups.includes(groupId);
@@ -248,14 +277,16 @@ export const Masonry = ({
{/* group header */} {/* group header */}
{virtualScroll && !activeMap.get(group.id) ? null : ( {virtualScroll && !activeMap.get(group.id) ? null : (
<MasonryGroupHeader <MasonryGroupHeader
className={clsx(styles.groupHeader, className)} className={clsx(styles.groupHeader, groupClassName)}
key={`header-${groupId}`} key={`header-${groupId}`}
id={groupId} id={groupId}
locateMode={locateMode} locateMode={locateMode}
xywh={layoutMap.get(groupId)} xywh={layoutMap.get(groupId)}
{...groupProps} {...groupProps}
onClick={() => onGroupCollapse?.(groupId, !collapsed)} onCollapse={collapsed =>
Component={Component} onGroupCollapse?.(groupId, collapsed)
}
Component={GroupComponent}
itemCount={items.length} itemCount={items.length}
collapsed={!!collapsed} collapsed={!!collapsed}
groupId={groupId} groupId={groupId}
@@ -265,19 +296,20 @@ export const Masonry = ({
{/* group items */} {/* group items */}
{collapsed {collapsed
? null ? null
: items.map(({ id: itemId, Component, ...item }) => { : items.map(item => {
const itemId = item.id;
const mixId = groupId ? `${groupId}:${itemId}` : itemId; const mixId = groupId ? `${groupId}:${itemId}` : itemId;
if (virtualScroll && !activeMap.get(mixId)) return null; if (virtualScroll && !activeMap.get(mixId)) return null;
return ( return (
<MasonryGroupItem <MasonryGroupItem
key={mixId} key={mixId}
id={mixId} id={mixId}
{...item}
locateMode={locateMode} locateMode={locateMode}
xywh={layoutMap.get(mixId)} xywh={layoutMap.get(mixId)}
groupId={groupId} groupId={groupId}
itemId={itemId} itemId={itemId}
Component={Component} className={itemClassName}
Component={ItemComponent}
/> />
); );
})} })}
@@ -289,24 +321,24 @@ export const Masonry = ({
<Scrollable.Scrollbar /> <Scrollable.Scrollbar />
{stickyGroup ? ( {stickyGroup ? (
<div <div
className={clsx(styles.stickyGroupHeader, stickyGroup.className)} className={clsx(styles.stickyGroupHeader, groupClassName)}
style={{ style={{
padding: `0 ${calcPX(paddingX, totalWidth)}px`, padding: `0 ${calcPX(paddingX, totalWidth)}px`,
height: stickyGroup.height, height:
...stickyGroup.style, typeof groupHeight === 'function'
? groupHeight(stickyGroup)
: groupHeight,
}} }}
onClick={() =>
onGroupCollapse?.(stickyGroup.id, !stickyGroupCollapsed)
}
> >
{stickyGroup.Component ? ( {GroupComponent && (
<stickyGroup.Component <GroupComponent
groupId={stickyGroup.id} groupId={stickyGroup.id}
itemCount={stickyGroup.items.length} itemCount={stickyGroup.items.length}
collapsed={stickyGroupCollapsed} collapsed={stickyGroupCollapsed}
onCollapse={collapsed => {
onGroupCollapse(stickyGroup.id, collapsed);
}}
/> />
) : (
stickyGroup.children
)} )}
</div> </div>
) : null} ) : null}
@@ -330,11 +362,12 @@ const MasonryGroupHeader = memo(function MasonryGroupHeader({
groupId, groupId,
itemCount, itemCount,
collapsed, collapsed,
height,
paddingX, paddingX,
onCollapse,
...props ...props
}: Omit<MasonryItemProps, 'Component'> & { }: Omit<MasonryItemProps, 'Component'> & {
Component?: MasonryGroup['Component']; Component?: MasonryProps['groupComponent'];
onCollapse: (collapsed: boolean) => void;
groupId: string; groupId: string;
itemCount: number; itemCount: number;
collapsed: boolean; collapsed: boolean;
@@ -347,16 +380,16 @@ const MasonryGroupHeader = memo(function MasonryGroupHeader({
groupId={groupId} groupId={groupId}
itemCount={itemCount} itemCount={itemCount}
collapsed={collapsed} collapsed={collapsed}
onCollapse={onCollapse}
/> />
); );
} }
return children; return children;
}, [Component, children, collapsed, groupId, itemCount]); }, [Component, children, collapsed, groupId, itemCount, onCollapse]);
return ( return (
<MasonryItem <MasonryItem
id={id} id={id}
height={height}
style={{ style={{
padding: `0 ${paddingX}px`, padding: `0 ${paddingX}px`,
height: '100%', height: '100%',
@@ -381,6 +414,7 @@ const MasonryGroupItem = memo(function MasonryGroupItem({
}: MasonryItemProps & { }: MasonryItemProps & {
groupId: string; groupId: string;
itemId: string; itemId: string;
Component?: MasonryProps['itemComponent'];
}) { }) {
const content = useMemo(() => { const content = useMemo(() => {
if (Component) { if (Component) {

View File

@@ -1,18 +1,10 @@
export interface MasonryItem extends React.HTMLAttributes<HTMLDivElement> { export interface MasonryItem {
id: string; id: string;
height: number;
Component?: React.ComponentType<{ groupId: string; itemId: string }>;
} }
export interface MasonryGroup extends React.HTMLAttributes<HTMLDivElement> { export interface MasonryGroup {
id: string; id: string;
height: number;
items: MasonryItem[]; items: MasonryItem[];
Component?: React.ComponentType<{
groupId: string;
collapsed?: boolean;
itemCount: number;
}>;
} }
export interface MasonryItemXYWH { export interface MasonryItemXYWH {

View File

@@ -61,6 +61,8 @@ export const calcLayout = (
groupsGap: number; groupsGap: number;
groupHeaderGapWithItems: number; groupHeaderGapWithItems: number;
collapsedGroups: string[]; collapsedGroups: string[];
groupHeight: number | ((group: MasonryGroup) => number);
itemHeight: number | ((item: MasonryItem) => number);
} }
) => { ) => {
const { const {
@@ -74,6 +76,8 @@ export const calcLayout = (
groupsGap, groupsGap,
groupHeaderGapWithItems, groupHeaderGapWithItems,
collapsedGroups, collapsedGroups,
groupHeight,
itemHeight,
} = options; } = options;
const paddingX = calcPX(_paddingX, totalWidth); const paddingX = calcPX(_paddingX, totalWidth);
@@ -92,7 +96,7 @@ export const calcLayout = (
x: 0, x: 0,
y: finalHeight, y: finalHeight,
w: totalWidth, w: totalWidth,
h: group.height, h: typeof groupHeight === 'function' ? groupHeight(group) : groupHeight,
}; };
layout.set(group.id, groupHeaderLayout); layout.set(group.id, groupHeaderLayout);
@@ -110,19 +114,21 @@ export const calcLayout = (
const hasGap = heightStack[minHeightIndex] ? gapY : 0; const hasGap = heightStack[minHeightIndex] ? gapY : 0;
const x = minHeightIndex * (width + gapX) + paddingX; const x = minHeightIndex * (width + gapX) + paddingX;
const y = finalHeight + minHeight + hasGap; const y = finalHeight + minHeight + hasGap;
const height =
typeof itemHeight === 'function' ? itemHeight(item) : itemHeight;
heightStack[minHeightIndex] += item.height + hasGap; heightStack[minHeightIndex] += height + hasGap;
layout.set(itemId, { layout.set(itemId, {
type: 'item', type: 'item',
x, x,
y, y,
w: width, w: width,
h: item.height, h: height,
}); });
}); });
const groupHeight = Math.max(...heightStack) + paddingY; const height = Math.max(...heightStack) + paddingY;
finalHeight += groupHeight; finalHeight += height;
}); });
return { layout, height: finalHeight }; return { layout, height: finalHeight };
@@ -167,9 +173,9 @@ export const calcSticky = (options: {
return xywh.y < scrollY && (!next || next[1].y > scrollY); return xywh.y < scrollY && (!next || next[1].y > scrollY);
}); });
return stickyGroupEntry return stickyGroupEntry !== undefined
? stickyGroupEntry[0] ? stickyGroupEntry[0]
: groupEntries.length > 0 : groupEntries.length > 0
? groupEntries[0][0] ? groupEntries[0][0]
: ''; : undefined;
}; };

View File

@@ -7,7 +7,6 @@ import type { ExplorerPreference } from './types';
export type DocExplorerContextType = { export type DocExplorerContextType = {
view$: LiveData<DocListItemView>; view$: LiveData<DocListItemView>;
groups$: LiveData<Array<{ key: string; items: string[] }>>; groups$: LiveData<Array<{ key: string; items: string[] }>>;
collapsedGroups$: LiveData<string[]>;
selectMode$?: LiveData<boolean>; selectMode$?: LiveData<boolean>;
selectedDocIds$: LiveData<string[]>; selectedDocIds$: LiveData<string[]>;
prevCheckAnchorId$?: LiveData<string | null>; prevCheckAnchorId$?: LiveData<string | null>;
@@ -25,7 +24,6 @@ export const createDocExplorerContext = () =>
({ ({
view$: new LiveData<DocListItemView>('list'), view$: new LiveData<DocListItemView>('list'),
groups$: new LiveData<Array<{ key: string; items: string[] }>>([]), groups$: new LiveData<Array<{ key: string; items: string[] }>>([]),
collapsedGroups$: new LiveData<string[]>([]),
selectMode$: new LiveData<boolean>(false), selectMode$: new LiveData<boolean>(false),
selectedDocIds$: new LiveData<string[]>([]), selectedDocIds$: new LiveData<string[]>([]),
prevCheckAnchorId$: new LiveData<string | null>(null), prevCheckAnchorId$: new LiveData<string | null>(null),

View File

@@ -16,16 +16,19 @@ import * as styles from './group-header.css';
export const DocGroupHeader = ({ export const DocGroupHeader = ({
className, className,
groupId, groupId,
collapsed,
onCollapse,
...props ...props
}: HTMLAttributes<HTMLDivElement> & { }: HTMLAttributes<HTMLDivElement> & {
groupId: string; groupId: string;
collapsed: boolean;
onCollapse: (collapsed: boolean) => void;
}) => { }) => {
const t = useI18n(); const t = useI18n();
const contextValue = useContext(DocExplorerContext); const contextValue = useContext(DocExplorerContext);
const groups = useLiveData(contextValue.groups$); const groups = useLiveData(contextValue.groups$);
const selectedDocIds = useLiveData(contextValue.selectedDocIds$); const selectedDocIds = useLiveData(contextValue.selectedDocIds$);
const collapsedGroups = useLiveData(contextValue.collapsedGroups$);
const selectMode = useLiveData(contextValue.selectMode$); const selectMode = useLiveData(contextValue.selectMode$);
const group = groups.find(g => g.key === groupId); const group = groups.find(g => g.key === groupId);
@@ -34,13 +37,8 @@ export const DocGroupHeader = ({
); );
const handleToggleCollapse = useCallback(() => { const handleToggleCollapse = useCallback(() => {
const prev = contextValue.collapsedGroups$.value; onCollapse(!collapsed);
contextValue.collapsedGroups$.next( }, [collapsed, onCollapse]);
prev.includes(groupId)
? prev.filter(id => id !== groupId)
: [...prev, groupId]
);
}, [groupId, contextValue]);
const handleSelectAll = useCallback(() => { const handleSelectAll = useCallback(() => {
const prev = contextValue.selectedDocIds$.value; const prev = contextValue.selectedDocIds$.value;
@@ -64,10 +62,7 @@ export const DocGroupHeader = ({
).length; ).length;
return ( return (
<div <div className={styles.groupHeader} data-collapsed={collapsed}>
className={styles.groupHeader}
data-collapsed={collapsedGroups.includes(groupId)}
>
<div className={clsx(styles.content, className)} {...props} /> <div className={clsx(styles.content, className)} {...props} />
{selectMode ? ( {selectMode ? (
<div className={styles.selectInfo}> <div className={styles.selectInfo}>
@@ -105,6 +100,8 @@ export const PlainTextDocGroupHeader = ({
...props ...props
}: HTMLAttributes<HTMLDivElement> & { }: HTMLAttributes<HTMLDivElement> & {
groupId: string; groupId: string;
collapsed: boolean;
onCollapse: (collapsed: boolean) => void;
docCount: number; docCount: number;
icon?: ReactNode; icon?: ReactNode;
}) => { }) => {

View File

@@ -30,4 +30,5 @@ export interface GroupHeaderProps {
groupId: string; groupId: string;
docCount: number; docCount: number;
collapsed: boolean; collapsed: boolean;
onCollapse: (collapsed: boolean) => void;
} }

View File

@@ -100,10 +100,17 @@ export const CheckboxDocListProperty = ({
export const CheckboxGroupHeader = ({ export const CheckboxGroupHeader = ({
groupId, groupId,
docCount, docCount,
collapsed,
onCollapse,
}: GroupHeaderProps) => { }: GroupHeaderProps) => {
const text = groupId === 'true' ? 'Checked' : 'Unchecked'; const text = groupId === 'true' ? 'Checked' : 'Unchecked';
return ( return (
<PlainTextDocGroupHeader docCount={docCount} groupId={groupId}> <PlainTextDocGroupHeader
docCount={docCount}
groupId={groupId}
collapsed={collapsed}
onCollapse={onCollapse}
>
{text} {text}
</PlainTextDocGroupHeader> </PlainTextDocGroupHeader>
); );

View File

@@ -166,11 +166,18 @@ export const UpdatedByDocListInlineProperty = ({
export const ModifiedByGroupHeader = ({ export const ModifiedByGroupHeader = ({
groupId, groupId,
docCount, docCount,
collapsed,
onCollapse,
}: GroupHeaderProps) => { }: GroupHeaderProps) => {
const userId = groupId; const userId = groupId;
return ( return (
<PlainTextDocGroupHeader groupId={groupId} docCount={docCount}> <PlainTextDocGroupHeader
groupId={groupId}
docCount={docCount}
collapsed={collapsed}
onCollapse={onCollapse}
>
<div className={styles.userLabelContainer}> <div className={styles.userLabelContainer}>
<PublicUserLabel id={userId} size={20} showName={false} /> <PublicUserLabel id={userId} size={20} showName={false} />
</div> </div>

View File

@@ -243,11 +243,21 @@ export const UpdatedDateDocListProperty = ({ doc }: DocListPropertyProps) => {
); );
}; };
export const DateGroupHeader = ({ groupId, docCount }: GroupHeaderProps) => { export const DateGroupHeader = ({
groupId,
docCount,
collapsed,
onCollapse,
}: GroupHeaderProps) => {
const date = groupId || 'No Date'; const date = groupId || 'No Date';
return ( return (
<PlainTextDocGroupHeader groupId={groupId} docCount={docCount}> <PlainTextDocGroupHeader
groupId={groupId}
docCount={docCount}
collapsed={collapsed}
onCollapse={onCollapse}
>
{date} {date}
</PlainTextDocGroupHeader> </PlainTextDocGroupHeader>
); );

View File

@@ -137,6 +137,8 @@ export const DocPrimaryModeDocListProperty = ({
export const DocPrimaryModeGroupHeader = ({ export const DocPrimaryModeGroupHeader = ({
groupId, groupId,
docCount, docCount,
collapsed,
onCollapse,
}: GroupHeaderProps) => { }: GroupHeaderProps) => {
const t = useI18n(); const t = useI18n();
const text = const text =
@@ -147,7 +149,12 @@ export const DocPrimaryModeGroupHeader = ({
: 'Default'; : 'Default';
return ( return (
<PlainTextDocGroupHeader groupId={groupId} docCount={docCount}> <PlainTextDocGroupHeader
groupId={groupId}
docCount={docCount}
collapsed={collapsed}
onCollapse={onCollapse}
>
{text} {text}
</PlainTextDocGroupHeader> </PlainTextDocGroupHeader>
); );

View File

@@ -236,10 +236,20 @@ export const JournalDocListProperty = ({ doc }: DocListPropertyProps) => {
); );
}; };
export const JournalGroupHeader = ({ groupId, docCount }: GroupHeaderProps) => { export const JournalGroupHeader = ({
groupId,
docCount,
collapsed,
onCollapse,
}: GroupHeaderProps) => {
const text = groupId === 'true' ? 'Journal' : 'Not Journal'; const text = groupId === 'true' ? 'Journal' : 'Not Journal';
return ( return (
<PlainTextDocGroupHeader groupId={groupId} docCount={docCount}> <PlainTextDocGroupHeader
groupId={groupId}
docCount={docCount}
collapsed={collapsed}
onCollapse={onCollapse}
>
{text} {text}
</PlainTextDocGroupHeader> </PlainTextDocGroupHeader>
); );

View File

@@ -202,7 +202,12 @@ export const TagsDocListProperty = ({ doc }: DocListPropertyProps) => {
); );
}; };
export const TagsGroupHeader = ({ groupId, docCount }: GroupHeaderProps) => { export const TagsGroupHeader = ({
groupId,
docCount,
collapsed,
onCollapse,
}: GroupHeaderProps) => {
const t = useI18n(); const t = useI18n();
const tagService = useService(TagService); const tagService = useService(TagService);
const tag = useLiveData(tagService.tagList.tagByTagId$(groupId)); const tag = useLiveData(tagService.tagList.tagByTagId$(groupId));
@@ -212,6 +217,8 @@ export const TagsGroupHeader = ({ groupId, docCount }: GroupHeaderProps) => {
<PlainTextDocGroupHeader <PlainTextDocGroupHeader
groupId={groupId} groupId={groupId}
docCount={docCount} docCount={docCount}
collapsed={collapsed}
onCollapse={onCollapse}
icon={ icon={
<div <div
style={{ style={{
@@ -231,6 +238,8 @@ export const TagsGroupHeader = ({ groupId, docCount }: GroupHeaderProps) => {
<PlainTextDocGroupHeader <PlainTextDocGroupHeader
groupId={groupId} groupId={groupId}
docCount={docCount} docCount={docCount}
collapsed={collapsed}
onCollapse={onCollapse}
icon={<TagIcon tag={tag} />} icon={<TagIcon tag={tag} />}
> >
<TagName tag={tag} /> <TagName tag={tag} />

View File

@@ -260,10 +260,20 @@ export const TextDocListProperty = ({ value }: { value: string }) => {
return <StackProperty icon={<TextTypeIcon />}>{value}</StackProperty>; return <StackProperty icon={<TextTypeIcon />}>{value}</StackProperty>;
}; };
export const TextGroupHeader = ({ groupId, docCount }: GroupHeaderProps) => { export const TextGroupHeader = ({
groupId,
docCount,
collapsed,
onCollapse,
}: GroupHeaderProps) => {
const text = groupId || 'No Text'; const text = groupId || 'No Text';
return ( return (
<PlainTextDocGroupHeader groupId={groupId} docCount={docCount}> <PlainTextDocGroupHeader
groupId={groupId}
docCount={docCount}
collapsed={collapsed}
onCollapse={onCollapse}
>
{text} {text}
</PlainTextDocGroupHeader> </PlainTextDocGroupHeader>
); );

View File

@@ -50,10 +50,12 @@ import { PinnedCollections } from './pinned-collections';
const GroupHeader = memo(function GroupHeader({ const GroupHeader = memo(function GroupHeader({
groupId, groupId,
collapsed, collapsed,
onCollapse,
itemCount, itemCount,
}: { }: {
groupId: string; groupId: string;
collapsed?: boolean; collapsed?: boolean;
onCollapse: (collapsed: boolean) => void;
itemCount: number; itemCount: number;
}) { }) {
const contextValue = useContext(DocExplorerContext); const contextValue = useContext(DocExplorerContext);
@@ -76,12 +78,21 @@ const GroupHeader = memo(function GroupHeader({
groupId={groupId} groupId={groupId}
docCount={itemCount} docCount={itemCount}
collapsed={!!collapsed} collapsed={!!collapsed}
onCollapse={onCollapse}
/> />
); );
} else { } else {
return '// TODO: ' + groupType; return '// TODO: ' + groupType;
} }
}, [allProperties, collapsed, groupId, groupKey, groupType, itemCount]); }, [
allProperties,
collapsed,
groupId,
groupKey,
groupType,
itemCount,
onCollapse,
]);
if (!groupType) { if (!groupType) {
return null; return null;
@@ -114,6 +125,18 @@ export const AllPage = () => {
const collectionService = useService(CollectionService); const collectionService = useService(CollectionService);
const pinnedCollectionService = useService(PinnedCollectionService); const pinnedCollectionService = useService(PinnedCollectionService);
const isCollectionDataReady = useLiveData(
collectionService.collectionDataReady$
);
const isPinnedCollectionDataReady = useLiveData(
pinnedCollectionService.pinnedCollectionDataReady$
);
const pinnedCollections = useLiveData(
pinnedCollectionService.pinnedCollections$
);
const [selectedCollectionId, setSelectedCollectionId] = useState< const [selectedCollectionId, setSelectedCollectionId] = useState<
string | null string | null
>(null); >(null);
@@ -124,17 +147,28 @@ export const AllPage = () => {
); );
useEffect(() => { useEffect(() => {
// if selected collection is not found, set selected collection id to null // if selected collection is not in pinned collections, set selected collection id to null
if (!selectedCollection && selectedCollectionId) { if (
isPinnedCollectionDataReady &&
selectedCollectionId &&
!pinnedCollections.some(c => c.collectionId === selectedCollectionId)
) {
setSelectedCollectionId(null); setSelectedCollectionId(null);
} }
}, [selectedCollection, selectedCollectionId]); }, [isPinnedCollectionDataReady, pinnedCollections, selectedCollectionId]);
useEffect(() => {
// if selected collection is not found, set selected collection id to null
if (!selectedCollection && selectedCollectionId && isCollectionDataReady) {
setSelectedCollectionId(null);
}
}, [isCollectionDataReady, selectedCollection, selectedCollectionId]);
const selectedCollectionInfo = useLiveData( const selectedCollectionInfo = useLiveData(
selectedCollection ? selectedCollection.info$ : null selectedCollection ? selectedCollection.info$ : null
); );
const [tempFilters, setTempFilters] = useState<FilterParams[]>([]); const [tempFilters, setTempFilters] = useState<FilterParams[] | null>(null);
const [explorerContextValue] = useState(createDocExplorerContext); const [explorerContextValue] = useState(createDocExplorerContext);
@@ -143,7 +177,6 @@ export const AllPage = () => {
const orderBy = useLiveData(explorerContextValue.orderBy$); const orderBy = useLiveData(explorerContextValue.orderBy$);
const groups = useLiveData(explorerContextValue.groups$); const groups = useLiveData(explorerContextValue.groups$);
const selectedDocIds = useLiveData(explorerContextValue.selectedDocIds$); const selectedDocIds = useLiveData(explorerContextValue.selectedDocIds$);
const collapsedGroups = useLiveData(explorerContextValue.collapsedGroups$);
const selectMode = useLiveData(explorerContextValue.selectMode$); const selectMode = useLiveData(explorerContextValue.selectMode$);
const { openPromptModal } = usePromptModal(); const { openPromptModal } = usePromptModal();
@@ -152,36 +185,23 @@ export const AllPage = () => {
const items = groups.map((group: any) => { const items = groups.map((group: any) => {
return { return {
id: group.key, id: group.key,
Component: groups.length > 1 ? GroupHeader : undefined,
height: groups.length > 1 ? 24 : 0,
className: styles.groupHeader,
items: group.items.map((docId: string) => { items: group.items.map((docId: string) => {
return { return {
id: docId, id: docId,
Component: DocListItemComponent,
height:
view === 'list'
? 42
: view === 'grid'
? 280
: calcCardHeightById(docId),
'data-view': view,
className: styles.docItem,
}; };
}), }),
} satisfies MasonryGroup; } satisfies MasonryGroup;
}); });
return items; return items;
}, [groups, view]); }, [groups]);
const collectionRulesService = useService(CollectionRulesService); const collectionRulesService = useService(CollectionRulesService);
useEffect(() => { useEffect(() => {
const subscription = collectionRulesService const subscription = collectionRulesService
.watch( .watch(
// collection filters and temp filters can't exist at the same time
selectedCollectionInfo selectedCollectionInfo
? { ? {
filters: selectedCollectionInfo.rules.filters, filters: tempFilters ?? selectedCollectionInfo.rules.filters,
groupBy, groupBy,
orderBy, orderBy,
extraAllowList: selectedCollectionInfo.allowList, extraAllowList: selectedCollectionInfo.allowList,
@@ -303,42 +323,74 @@ export const AllPage = () => {
}); });
}, [docsService.list, openConfirmModal, selectedDocIds, t]); }, [docsService.list, openConfirmModal, selectedDocIds, t]);
const handleSelectCollection = useCallback((collectionId: string) => {
setSelectedCollectionId(collectionId);
setTempFilters(null);
}, []);
const handleEditCollection = useCallback(
(collectionId: string) => {
const collection = collectionService.collection$(collectionId).value;
if (!collection) {
return;
}
setSelectedCollectionId(collectionId);
setTempFilters(collection.info$.value.rules.filters);
},
[collectionService]
);
const handleSaveFilters = useCallback(() => { const handleSaveFilters = useCallback(() => {
openPromptModal({ if (selectedCollectionId) {
title: t['com.affine.editCollection.saveCollection'](), collectionService.updateCollection(selectedCollectionId, {
label: t['com.affine.editCollectionName.name'](), rules: {
inputOptions: { filters: tempFilters ?? [],
placeholder: t['com.affine.editCollectionName.name.placeholder'](), },
}, });
children: t['com.affine.editCollectionName.createTips'](), setTempFilters(null);
confirmText: t['com.affine.editCollection.save'](), } else {
cancelText: t['com.affine.editCollection.button.cancel'](), openPromptModal({
confirmButtonOptions: { title: t['com.affine.editCollection.saveCollection'](),
variant: 'primary', label: t['com.affine.editCollectionName.name'](),
}, inputOptions: {
onConfirm(name) { placeholder: t['com.affine.editCollectionName.name.placeholder'](),
const id = collectionService.createCollection({ },
name, children: t['com.affine.editCollectionName.createTips'](),
rules: { confirmText: t['com.affine.editCollection.save'](),
filters: tempFilters, cancelText: t['com.affine.editCollection.button.cancel'](),
}, confirmButtonOptions: {
}); variant: 'primary',
pinnedCollectionService.addPinnedCollection({ },
collectionId: id, onConfirm(name) {
index: pinnedCollectionService.indexAt('after'), const id = collectionService.createCollection({
}); name,
setTempFilters([]); rules: {
setSelectedCollectionId(id); filters: tempFilters ?? [],
}, },
}); });
pinnedCollectionService.addPinnedCollection({
collectionId: id,
index: pinnedCollectionService.indexAt('after'),
});
setTempFilters(null);
setSelectedCollectionId(id);
},
});
}
}, [ }, [
collectionService, collectionService,
openPromptModal, openPromptModal,
pinnedCollectionService, pinnedCollectionService,
selectedCollectionId,
t, t,
tempFilters, tempFilters,
]); ]);
const handleNewTempFilter = useCallback((params: FilterParams) => {
setSelectedCollectionId(null);
setTempFilters([params]);
}, []);
return ( return (
<DocExplorerContext.Provider value={explorerContextValue}> <DocExplorerContext.Provider value={explorerContextValue}>
<ViewTitle title={t['All pages']()} /> <ViewTitle title={t['All pages']()} />
@@ -352,29 +404,24 @@ export const AllPage = () => {
<div className={styles.pinnedCollection}> <div className={styles.pinnedCollection}>
<PinnedCollections <PinnedCollections
activeCollectionId={selectedCollectionId} activeCollectionId={selectedCollectionId}
onClickAll={() => setSelectedCollectionId(null)} onActiveAll={() => setSelectedCollectionId(null)}
onClickCollection={collectionId => { onActiveCollection={handleSelectCollection}
setSelectedCollectionId(collectionId); onAddFilter={handleNewTempFilter}
setTempFilters([]); onEditCollection={handleEditCollection}
}} hiddenAdd={tempFilters !== null}
onAddFilter={params => {
setSelectedCollectionId(null);
setTempFilters([...(tempFilters ?? []), params]);
}}
hiddenAdd={tempFilters.length > 0}
/> />
</div> </div>
{tempFilters.length > 0 && ( {tempFilters !== null && (
<div className={styles.filterArea}> <div className={styles.filterArea}>
<Filters <Filters
className={styles.filters} className={styles.filters}
filters={tempFilters ?? []} filters={tempFilters}
onChange={handleFilterChange} onChange={handleFilterChange}
/> />
<Button <Button
variant="plain" variant="plain"
onClick={() => { onClick={() => {
setTempFilters([]); setTempFilters(null);
}} }}
> >
{t['Cancel']()} {t['Cancel']()}
@@ -394,11 +441,24 @@ export const AllPage = () => {
preloadHeight={100} preloadHeight={100}
itemWidth={'stretch'} itemWidth={'stretch'}
virtualScroll virtualScroll
collapsedGroups={collapsedGroups} groupComponent={GroupHeader}
itemComponent={DocListItemComponent}
groupClassName={styles.groupHeader}
itemClassName={styles.docItem}
paddingX={useCallback( paddingX={useCallback(
(w: number) => (w > 500 ? 24 : w > 393 ? 20 : 16), (w: number) => (w > 500 ? 24 : w > 393 ? 20 : 16),
[] []
)} )}
groupHeight={groupBy ? 24 : 0}
itemHeight={useMemo(
() =>
view === 'list'
? 42
: view === 'grid'
? 280
: item => calcCardHeightById(item.id),
[view]
)}
/> />
</div> </div>
</div> </div>

View File

@@ -27,6 +27,38 @@ export const item = style({
}, },
}); });
export const itemContent = style({
display: 'inline-block',
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
textAlign: 'center',
maxWidth: '128px',
minWidth: '32px',
selectors: {
[`${item}:hover > &`]: {
mask:
'linear-gradient(#fff) left / calc(100% - 32px) no-repeat,' +
'linear-gradient(90deg,#fff 0%,transparent 50%,transparent 100%) right / 32px no-repeat',
},
},
});
export const editIconButton = style({
opacity: 0,
marginLeft: -16,
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
selectors: {
[`${item}:hover > &`]: {
opacity: 1,
},
},
});
export const closeButton = style({});
export const container = style({ export const container = style({
display: 'flex', display: 'flex',
flexDirection: 'row', flexDirection: 'row',

View File

@@ -7,7 +7,13 @@ import {
} from '@affine/core/modules/collection'; } from '@affine/core/modules/collection';
import type { FilterParams } from '@affine/core/modules/collection-rules'; import type { FilterParams } from '@affine/core/modules/collection-rules';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import { CollectionsIcon, FilterIcon, PlusIcon } from '@blocksuite/icons/rc'; import {
CloseIcon,
CollectionsIcon,
EditIcon,
FilterIcon,
PlusIcon,
} from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra'; import { useLiveData, useService } from '@toeverything/infra';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
@@ -17,10 +23,14 @@ export const PinnedCollectionItem = ({
record, record,
isActive, isActive,
onClick, onClick,
onClickRemove,
onClickEdit,
}: { }: {
record: PinnedCollectionRecord; record: PinnedCollectionRecord;
onClickRemove: () => void;
isActive: boolean; isActive: boolean;
onClick: () => void; onClick: () => void;
onClickEdit: () => void;
}) => { }) => {
const t = useI18n(); const t = useI18n();
const collectionService = useService(CollectionService); const collectionService = useService(CollectionService);
@@ -38,22 +48,46 @@ export const PinnedCollectionItem = ({
data-active={isActive ? 'true' : undefined} data-active={isActive ? 'true' : undefined}
onClick={onClick} onClick={onClick}
> >
{name ?? t['Untitled']()} <span className={styles.itemContent}>{name ?? t['Untitled']()}</span>
<IconButton
size="16"
className={styles.editIconButton}
onClick={e => {
e.stopPropagation();
onClickEdit();
}}
>
<EditIcon />
</IconButton>
{isActive && (
<IconButton
className={styles.closeButton}
size="16"
onClick={e => {
e.stopPropagation();
onClickRemove();
}}
>
<CloseIcon />
</IconButton>
)}
</div> </div>
); );
}; };
export const PinnedCollections = ({ export const PinnedCollections = ({
activeCollectionId, activeCollectionId,
onClickAll, onActiveAll,
onClickCollection, onActiveCollection,
onAddFilter, onAddFilter,
onEditCollection,
hiddenAdd, hiddenAdd,
}: { }: {
activeCollectionId: string | null; activeCollectionId: string | null;
onClickAll: () => void; onActiveAll: () => void;
onClickCollection: (collectionId: string) => void; onActiveCollection: (collectionId: string) => void;
onAddFilter: (params: FilterParams) => void; onAddFilter: (params: FilterParams) => void;
onEditCollection: (collectionId: string) => void;
hiddenAdd?: boolean; hiddenAdd?: boolean;
}) => { }) => {
const t = useI18n(); const t = useI18n();
@@ -74,22 +108,32 @@ export const PinnedCollections = ({
<div <div
className={styles.item} className={styles.item}
data-active={activeCollectionId === null ? 'true' : undefined} data-active={activeCollectionId === null ? 'true' : undefined}
onClick={onClickAll} onClick={onActiveAll}
role="button" role="button"
> >
{t['com.affine.all-docs.pinned-collection.all']()} {t['com.affine.all-docs.pinned-collection.all']()}
</div> </div>
{pinnedCollections.map(record => ( {pinnedCollections.map((record, index) => (
<PinnedCollectionItem <PinnedCollectionItem
key={record.collectionId} key={record.collectionId}
record={record} record={record}
isActive={activeCollectionId === record.collectionId} isActive={activeCollectionId === record.collectionId}
onClick={() => onClickCollection(record.collectionId)} onClick={() => onActiveCollection(record.collectionId)}
onClickEdit={() => onEditCollection(record.collectionId)}
onClickRemove={() => {
const nextCollectionId = pinnedCollections[index - 1]?.collectionId;
if (nextCollectionId) {
onActiveCollection(nextCollectionId);
} else {
onActiveAll();
}
pinnedCollectionService.removePinnedCollection(record.collectionId);
}}
/> />
))} ))}
{!hiddenAdd && ( {!hiddenAdd && (
<AddPinnedCollection <AddPinnedCollection
onAddPinnedCollection={handleAddPinnedCollection} onPinCollection={handleAddPinnedCollection}
onAddFilter={onAddFilter} onAddFilter={onAddFilter}
/> />
)} )}
@@ -98,17 +142,17 @@ export const PinnedCollections = ({
}; };
export const AddPinnedCollection = ({ export const AddPinnedCollection = ({
onAddPinnedCollection, onPinCollection,
onAddFilter, onAddFilter,
}: { }: {
onAddPinnedCollection: (collectionId: string) => void; onPinCollection: (collectionId: string) => void;
onAddFilter: (params: FilterParams) => void; onAddFilter: (params: FilterParams) => void;
}) => { }) => {
return ( return (
<Menu <Menu
items={ items={
<AddPinnedCollectionMenuContent <AddPinnedCollectionMenuContent
onAddPinnedCollection={onAddPinnedCollection} onPinCollection={onPinCollection}
onAddFilter={onAddFilter} onAddFilter={onAddFilter}
/> />
} }
@@ -121,10 +165,10 @@ export const AddPinnedCollection = ({
}; };
export const AddPinnedCollectionMenuContent = ({ export const AddPinnedCollectionMenuContent = ({
onAddPinnedCollection, onPinCollection,
onAddFilter, onAddFilter,
}: { }: {
onAddPinnedCollection: (collectionId: string) => void; onPinCollection: (collectionId: string) => void;
onAddFilter: (params: FilterParams) => void; onAddFilter: (params: FilterParams) => void;
}) => { }) => {
const [addingFilter, setAddingFilter] = useState<boolean>(false); const [addingFilter, setAddingFilter] = useState<boolean>(false);
@@ -167,7 +211,7 @@ export const AddPinnedCollectionMenuContent = ({
prefixIcon={<CollectionsIcon />} prefixIcon={<CollectionsIcon />}
suffixIcon={<PlusIcon />} suffixIcon={<PlusIcon />}
onClick={() => { onClick={() => {
onAddPinnedCollection(meta.id); onPinCollection(meta.id);
}} }}
> >
{meta.name ?? t['Untitled']()} {meta.name ?? t['Untitled']()}

View File

@@ -136,7 +136,8 @@ export class DocCreatedByUpdatedBySyncService extends Service {
workspaceRootDocSynced && workspaceRootDocSynced &&
isOwnerOrAdmin && isOwnerOrAdmin &&
missingCreatedBy && missingCreatedBy &&
!markedSynced !markedSynced &&
this.workspaceService.workspace.flavour !== 'local'
) )
), ),
false false

View File

@@ -19,6 +19,11 @@ export class CollectionService extends Service {
}, },
}); });
readonly collectionDataReady$ = LiveData.from(
this.store.watchCollectionDataReady(),
false
);
// collection metas used in collection list, only include `id` and `name`, without `rules` and `allowList` // collection metas used in collection list, only include `id` and `name`, without `rules` and `allowList`
readonly collectionMetas$ = LiveData.from( readonly collectionMetas$ = LiveData.from(
this.store.watchCollectionMetas(), this.store.watchCollectionMetas(),

View File

@@ -14,6 +14,11 @@ export class PinnedCollectionService extends Service {
super(); super();
} }
pinnedCollectionDataReady$ = LiveData.from(
this.pinnedCollectionStore.watchPinnedCollectionDataReady(),
false
);
pinnedCollections$ = LiveData.from<PinnedCollectionRecord[]>( pinnedCollections$ = LiveData.from<PinnedCollectionRecord[]>(
this.pinnedCollectionStore.watchPinnedCollections(), this.pinnedCollectionStore.watchPinnedCollections(),
[] []

View File

@@ -7,7 +7,7 @@ import {
} from '@toeverything/infra'; } from '@toeverything/infra';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { map, type Observable, switchMap } from 'rxjs'; import { distinctUntilChanged, map, type Observable, switchMap } from 'rxjs';
import { Array as YArray } from 'yjs'; import { Array as YArray } from 'yjs';
import type { FilterParams } from '../../collection-rules'; import type { FilterParams } from '../../collection-rules';
@@ -35,6 +35,17 @@ export class CollectionStore extends Store {
return this.rootYDoc.getMap('setting'); return this.rootYDoc.getMap('setting');
} }
watchCollectionDataReady() {
return this.workspaceService.workspace.engine.doc
.docState$(this.workspaceService.workspace.id)
.pipe(
map(docState => {
return docState.ready;
}),
distinctUntilChanged()
);
}
watchCollectionMetas() { watchCollectionMetas() {
return yjsGetPath(this.workspaceSettingYMap, 'collections').pipe( return yjsGetPath(this.workspaceSettingYMap, 'collections').pipe(
switchMap(yjsObserveDeep), switchMap(yjsObserveDeep),

View File

@@ -13,6 +13,10 @@ export class PinnedCollectionStore extends Store {
super(); super();
} }
watchPinnedCollectionDataReady() {
return this.workspaceDBService.db.pinnedCollections.isReady$;
}
watchPinnedCollections(): Observable<PinnedCollectionRecord[]> { watchPinnedCollections(): Observable<PinnedCollectionRecord[]> {
return this.workspaceDBService.db.pinnedCollections.find$(); return this.workspaceDBService.db.pinnedCollections.find$();
} }

View File

@@ -3,7 +3,7 @@ import type {
TableSchemaBuilder, TableSchemaBuilder,
} from '@toeverything/infra'; } from '@toeverything/infra';
import { Entity, LiveData } from '@toeverything/infra'; import { Entity, LiveData } from '@toeverything/infra';
import { map } from 'rxjs'; import { distinctUntilChanged, map } from 'rxjs';
import type { WorkspaceService } from '../../workspace'; import type { WorkspaceService } from '../../workspace';
@@ -19,10 +19,23 @@ export class WorkspaceDBTable<
super(); super();
} }
isReady$ = LiveData.from(
this.workspaceService.workspace.engine.doc
.docState$(this.props.storageDocId)
.pipe(
map(docState => docState.ready),
distinctUntilChanged()
),
false
);
isSyncing$ = LiveData.from( isSyncing$ = LiveData.from(
this.workspaceService.workspace.engine.doc this.workspaceService.workspace.engine.doc
.docState$(this.props.storageDocId) .docState$(this.props.storageDocId)
.pipe(map(docState => docState.syncing)), .pipe(
map(docState => docState.syncing),
distinctUntilChanged()
),
false false
); );