mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 21:05:19 +00:00
refactor(core): move page list to core (#5556)
This commit is contained in:
@@ -5,6 +5,8 @@ import { type PropsWithChildren, useRef } from 'react';
|
||||
import * as styles from './index.css';
|
||||
import { useHasScrollTop } from './use-has-scroll-top';
|
||||
|
||||
export { useHasScrollTop } from './use-has-scroll-top';
|
||||
|
||||
export function SidebarContainer({ children }: PropsWithChildren) {
|
||||
return <div className={clsx([styles.baseContainer])}>{children}</div>;
|
||||
}
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import 'fake-indexeddb/auto';
|
||||
|
||||
import type {
|
||||
Filter,
|
||||
LiteralValue,
|
||||
PropertiesMeta,
|
||||
Ref,
|
||||
VariableMap,
|
||||
} from '@affine/env/filter';
|
||||
import { createI18n, I18nextProvider } from '@affine/i18n';
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
import { render } from '@testing-library/react';
|
||||
import type { ReactElement } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { Condition } from '../filter/condition';
|
||||
import { tBoolean, tDate } from '../filter/logical/custom-type';
|
||||
import { toLiteral } from '../filter/shared-types';
|
||||
import type { FilterMatcherDataType } from '../filter/vars';
|
||||
import { filterMatcher } from '../filter/vars';
|
||||
import { filterByFilterList } from '../use-collection-manager';
|
||||
const ref = (name: keyof VariableMap): Ref => {
|
||||
return {
|
||||
type: 'ref',
|
||||
name,
|
||||
};
|
||||
};
|
||||
const mockVariableMap = (vars: Partial<VariableMap>): VariableMap => {
|
||||
return {
|
||||
Created: 0,
|
||||
Updated: 0,
|
||||
'Is Favourited': false,
|
||||
Tags: [],
|
||||
...vars,
|
||||
};
|
||||
};
|
||||
const mockPropertiesMeta = (meta: Partial<PropertiesMeta>): PropertiesMeta => {
|
||||
return {
|
||||
tags: {
|
||||
options: [],
|
||||
},
|
||||
...meta,
|
||||
};
|
||||
};
|
||||
const filter = (
|
||||
matcherData: FilterMatcherDataType,
|
||||
left: Ref,
|
||||
args: LiteralValue[]
|
||||
): Filter => {
|
||||
return {
|
||||
type: 'filter',
|
||||
left,
|
||||
funcName: matcherData.name,
|
||||
args: args.map(toLiteral),
|
||||
};
|
||||
};
|
||||
describe('match filter', () => {
|
||||
test('boolean variable will match `is` filter', () => {
|
||||
const is = filterMatcher
|
||||
.allMatchedData(tBoolean.create())
|
||||
.find(v => v.name === 'is');
|
||||
expect(is?.name).toBe('is');
|
||||
});
|
||||
test('Date variable will match `before` filter', () => {
|
||||
const before = filterMatcher
|
||||
.allMatchedData(tDate.create())
|
||||
.find(v => v.name === 'before');
|
||||
expect(before?.name).toBe('before');
|
||||
});
|
||||
});
|
||||
|
||||
describe('eval filter', () => {
|
||||
test('before', async () => {
|
||||
const before = filterMatcher.findData(v => v.name === 'before');
|
||||
assertExists(before);
|
||||
const filter1 = filter(before, ref('Created'), [
|
||||
new Date(2023, 5, 28).getTime(),
|
||||
]);
|
||||
const filter2 = filter(before, ref('Created'), [
|
||||
new Date(2023, 5, 30).getTime(),
|
||||
]);
|
||||
const filter3 = filter(before, ref('Created'), [
|
||||
new Date(2023, 5, 29).getTime(),
|
||||
]);
|
||||
const varMap = mockVariableMap({
|
||||
Created: new Date(2023, 5, 29).getTime(),
|
||||
});
|
||||
expect(filterByFilterList([filter1], varMap)).toBe(false);
|
||||
expect(filterByFilterList([filter2], varMap)).toBe(true);
|
||||
expect(filterByFilterList([filter3], varMap)).toBe(false);
|
||||
});
|
||||
test('after', async () => {
|
||||
const after = filterMatcher.findData(v => v.name === 'after');
|
||||
assertExists(after);
|
||||
const filter1 = filter(after, ref('Created'), [
|
||||
new Date(2023, 5, 28).getTime(),
|
||||
]);
|
||||
const filter2 = filter(after, ref('Created'), [
|
||||
new Date(2023, 5, 30).getTime(),
|
||||
]);
|
||||
const filter3 = filter(after, ref('Created'), [
|
||||
new Date(2023, 5, 29).getTime(),
|
||||
]);
|
||||
const varMap = mockVariableMap({
|
||||
Created: new Date(2023, 5, 29).getTime(),
|
||||
});
|
||||
expect(filterByFilterList([filter1], varMap)).toBe(true);
|
||||
expect(filterByFilterList([filter2], varMap)).toBe(false);
|
||||
expect(filterByFilterList([filter3], varMap)).toBe(false);
|
||||
});
|
||||
test('is', async () => {
|
||||
const is = filterMatcher.findData(v => v.name === 'is');
|
||||
assertExists(is);
|
||||
const filter1 = filter(is, ref('Is Favourited'), [false]);
|
||||
const filter2 = filter(is, ref('Is Favourited'), [true]);
|
||||
const varMap = mockVariableMap({
|
||||
'Is Favourited': true,
|
||||
});
|
||||
expect(filterByFilterList([filter1], varMap)).toBe(false);
|
||||
expect(filterByFilterList([filter2], varMap)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('render filter', () => {
|
||||
test('boolean condition value change', async () => {
|
||||
const i18n = createI18n();
|
||||
const is = filterMatcher.match(tBoolean.create());
|
||||
assertExists(is);
|
||||
const Wrapper = () => {
|
||||
const [value, onChange] = useState(
|
||||
filter(is, ref('Is Favourited'), [true])
|
||||
);
|
||||
|
||||
return (
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<Condition
|
||||
propertiesMeta={mockPropertiesMeta({})}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</I18nextProvider>
|
||||
);
|
||||
};
|
||||
const result = render(<Wrapper />);
|
||||
const dom = await result.findByText('true');
|
||||
dom.click();
|
||||
await result.findByText('false');
|
||||
result.unmount();
|
||||
});
|
||||
|
||||
const WrapperCreator = (fn: FilterMatcherDataType) =>
|
||||
function Wrapper(): ReactElement {
|
||||
const [value, onChange] = useState(
|
||||
filter(fn, ref('Created'), [new Date(2023, 5, 29).getTime()])
|
||||
);
|
||||
return (
|
||||
<Condition
|
||||
propertiesMeta={mockPropertiesMeta({})}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
test('date condition function change', async () => {
|
||||
const dateFunction = filterMatcher.match(tDate.create());
|
||||
assertExists(dateFunction);
|
||||
const Wrapper = WrapperCreator(dateFunction);
|
||||
const result = render(<Wrapper />);
|
||||
const dom = await result.findByTestId('filter-name');
|
||||
dom.click();
|
||||
await result.findByTestId('filter-name');
|
||||
result.unmount();
|
||||
});
|
||||
test('date condition variable change', async () => {
|
||||
const dateFunction = filterMatcher.match(tDate.create());
|
||||
assertExists(dateFunction);
|
||||
const Wrapper = WrapperCreator(dateFunction);
|
||||
const result = render(<Wrapper />);
|
||||
const dom = await result.findByTestId('variable-name');
|
||||
dom.click();
|
||||
await result.findByTestId('variable-name');
|
||||
result.unmount();
|
||||
});
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import 'fake-indexeddb/auto';
|
||||
|
||||
import type { Collection } from '@affine/env/filter';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { atom } from 'jotai';
|
||||
import { atomWithObservable } from 'jotai/utils';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import { createDefaultFilter, vars } from '../filter/vars';
|
||||
import {
|
||||
type CollectionsCRUD,
|
||||
useCollectionManager,
|
||||
} from '../use-collection-manager';
|
||||
|
||||
const defaultMeta = { tags: { options: [] } };
|
||||
const collectionsSubject = new BehaviorSubject<Collection[]>([]);
|
||||
const baseAtom = atomWithObservable<Collection[]>(
|
||||
() => {
|
||||
return collectionsSubject;
|
||||
},
|
||||
{
|
||||
initialValue: [],
|
||||
}
|
||||
);
|
||||
|
||||
const mockAtom = atom(get => {
|
||||
return {
|
||||
collections: get(baseAtom),
|
||||
addCollection: (...collections) => {
|
||||
const prev = collectionsSubject.value;
|
||||
collectionsSubject.next([...collections, ...prev]);
|
||||
},
|
||||
deleteCollection: (...ids) => {
|
||||
const prev = collectionsSubject.value;
|
||||
collectionsSubject.next(prev.filter(v => !ids.includes(v.id)));
|
||||
},
|
||||
updateCollection: (id, updater) => {
|
||||
const prev = collectionsSubject.value;
|
||||
collectionsSubject.next(
|
||||
prev.map(v => {
|
||||
if (v.id === id) {
|
||||
return updater(v);
|
||||
}
|
||||
return v;
|
||||
})
|
||||
);
|
||||
},
|
||||
} satisfies CollectionsCRUD;
|
||||
});
|
||||
|
||||
test('useAllPageSetting', async () => {
|
||||
const settingHook = renderHook(() => useCollectionManager(mockAtom));
|
||||
const prevCollection = settingHook.result.current.currentCollection;
|
||||
expect(settingHook.result.current.savedCollections).toEqual([]);
|
||||
settingHook.result.current.updateCollection({
|
||||
...settingHook.result.current.currentCollection,
|
||||
filterList: [createDefaultFilter(vars[0], defaultMeta)],
|
||||
});
|
||||
settingHook.rerender();
|
||||
const nextCollection = settingHook.result.current.currentCollection;
|
||||
expect(nextCollection).not.toBe(prevCollection);
|
||||
expect(nextCollection.filterList).toEqual([
|
||||
createDefaultFilter(vars[0], defaultMeta),
|
||||
]);
|
||||
settingHook.result.current.createCollection({
|
||||
...settingHook.result.current.currentCollection,
|
||||
id: '1',
|
||||
});
|
||||
settingHook.rerender();
|
||||
expect(settingHook.result.current.savedCollections.length).toBe(1);
|
||||
expect(settingHook.result.current.savedCollections[0].id).toBe('1');
|
||||
});
|
||||
@@ -1,69 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import 'fake-indexeddb/auto';
|
||||
|
||||
import { __unstableSchemas, AffineSchemas } from '@blocksuite/blocks/models';
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
import type { Page } from '@blocksuite/store';
|
||||
import { Schema, Workspace as BlockSuiteWorkspace } from '@blocksuite/store';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { beforeEach } from 'vitest';
|
||||
|
||||
import { useBlockSuitePagePreview } from '../use-block-suite-page-preview';
|
||||
let blockSuiteWorkspace: BlockSuiteWorkspace;
|
||||
|
||||
const schema = new Schema();
|
||||
schema.register(AffineSchemas).register(__unstableSchemas);
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.useFakeTimers({ toFake: ['requestIdleCallback'] });
|
||||
blockSuiteWorkspace = new BlockSuiteWorkspace({ id: 'test', schema });
|
||||
const initPage = async (page: Page) => {
|
||||
await page.waitForLoaded();
|
||||
expect(page).not.toBeNull();
|
||||
assertExists(page);
|
||||
const pageBlockId = page.addBlock('affine:page', {
|
||||
title: new page.Text(''),
|
||||
});
|
||||
const frameId = page.addBlock('affine:note', {}, pageBlockId);
|
||||
page.addBlock('affine:paragraph', {}, frameId);
|
||||
};
|
||||
await initPage(blockSuiteWorkspace.createPage({ id: 'page0' }));
|
||||
});
|
||||
|
||||
describe('useBlockSuitePagePreview', () => {
|
||||
test('basic', async () => {
|
||||
const page = blockSuiteWorkspace.getPage('page0') as Page;
|
||||
const id = page.addBlock(
|
||||
'affine:paragraph',
|
||||
{
|
||||
text: new page.Text('Hello, world!'),
|
||||
},
|
||||
page.getBlockByFlavour('affine:note')[0].id
|
||||
);
|
||||
const hook = renderHook(() => useAtomValue(useBlockSuitePagePreview(page)));
|
||||
expect(hook.result.current).toBe('Hello, world!');
|
||||
page.transact(() => {
|
||||
page.getBlockById(id)!.text!.insert('Test', 0);
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
hook.rerender();
|
||||
expect(hook.result.current).toBe('TestHello, world!');
|
||||
|
||||
// Insert before
|
||||
page.addBlock(
|
||||
'affine:paragraph',
|
||||
{
|
||||
text: new page.Text('First block!'),
|
||||
},
|
||||
page.getBlockByFlavour('affine:note')[0].id,
|
||||
0
|
||||
);
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
hook.rerender();
|
||||
expect(hook.result.current).toBe('First block! TestHello, world!');
|
||||
});
|
||||
});
|
||||
@@ -1,48 +0,0 @@
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { FavoritedIcon, FavoriteIcon } from '@blocksuite/icons';
|
||||
import Lottie from 'lottie-react';
|
||||
import { forwardRef, useCallback, useState } from 'react';
|
||||
|
||||
import { IconButton, type IconButtonProps } from '../../../ui/button';
|
||||
import { Tooltip } from '../../../ui/tooltip';
|
||||
import favoritedAnimation from './favorited-animation/data.json';
|
||||
|
||||
export const FavoriteTag = forwardRef<
|
||||
HTMLButtonElement,
|
||||
{
|
||||
active: boolean;
|
||||
} & Omit<IconButtonProps, 'children'>
|
||||
>(({ active, onClick, ...props }, ref) => {
|
||||
const [playAnimation, setPlayAnimation] = useState(false);
|
||||
const t = useAFFiNEI18N();
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onClick?.(e);
|
||||
setPlayAnimation(!active);
|
||||
},
|
||||
[active, onClick]
|
||||
);
|
||||
return (
|
||||
<Tooltip content={active ? t['Favorited']() : t['Favorite']()} side="top">
|
||||
<IconButton ref={ref} active={active} onClick={handleClick} {...props}>
|
||||
{active ? (
|
||||
playAnimation ? (
|
||||
<Lottie
|
||||
loop={false}
|
||||
animationData={favoritedAnimation}
|
||||
onComplete={() => setPlayAnimation(false)}
|
||||
style={{ width: '20px', height: '20px' }}
|
||||
/>
|
||||
) : (
|
||||
<FavoritedIcon data-testid="favorited-icon" />
|
||||
)
|
||||
) : (
|
||||
<FavoriteIcon />
|
||||
)}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
FavoriteTag.displayName = 'FavoriteTag';
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,94 +0,0 @@
|
||||
import * as Popover from '@radix-ui/react-popover';
|
||||
import * as Toolbar from '@radix-ui/react-toolbar';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
type CSSProperties,
|
||||
type HTMLAttributes,
|
||||
type MouseEventHandler,
|
||||
type PropsWithChildren,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
|
||||
import * as styles from './floating-toolbar.css';
|
||||
|
||||
interface FloatingToolbarProps {
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
open?: boolean;
|
||||
}
|
||||
|
||||
interface FloatingToolbarButtonProps extends HTMLAttributes<HTMLButtonElement> {
|
||||
icon: ReactNode;
|
||||
onClick: MouseEventHandler;
|
||||
type?: 'danger' | 'default';
|
||||
label?: ReactNode;
|
||||
}
|
||||
|
||||
interface FloatingToolbarItemProps {}
|
||||
|
||||
export function FloatingToolbar({
|
||||
children,
|
||||
style,
|
||||
className,
|
||||
open,
|
||||
}: PropsWithChildren<FloatingToolbarProps>) {
|
||||
return (
|
||||
<Popover.Root open={open}>
|
||||
{/* Having Anchor here to let Popover to calculate the position of the place it is being used */}
|
||||
<Popover.Anchor className={className} style={style} />
|
||||
<Popover.Portal>
|
||||
{/* always pop up on top for now */}
|
||||
<Popover.Content side="top" className={styles.popoverContent}>
|
||||
<Toolbar.Root
|
||||
data-testid="floating-toolbar"
|
||||
className={clsx(styles.root)}
|
||||
>
|
||||
{children}
|
||||
</Toolbar.Root>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
);
|
||||
}
|
||||
|
||||
// freestyle item that allows user to do anything
|
||||
export function FloatingToolbarItem({
|
||||
children,
|
||||
}: PropsWithChildren<FloatingToolbarItemProps>) {
|
||||
return <div className={styles.item}>{children}</div>;
|
||||
}
|
||||
|
||||
// a typical button that has icon and label
|
||||
export function FloatingToolbarButton({
|
||||
icon,
|
||||
type,
|
||||
onClick,
|
||||
className,
|
||||
style,
|
||||
label,
|
||||
...props
|
||||
}: FloatingToolbarButtonProps) {
|
||||
return (
|
||||
<Toolbar.Button
|
||||
onClick={onClick}
|
||||
className={clsx(
|
||||
styles.button,
|
||||
type === 'danger' && styles.danger,
|
||||
className
|
||||
)}
|
||||
style={style}
|
||||
{...props}
|
||||
>
|
||||
<div className={styles.buttonIcon}>{icon}</div>
|
||||
{label}
|
||||
</Toolbar.Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function FloatingToolbarSeparator() {
|
||||
return <Toolbar.Separator className={styles.separator} />;
|
||||
}
|
||||
|
||||
FloatingToolbar.Item = FloatingToolbarItem;
|
||||
FloatingToolbar.Separator = FloatingToolbarSeparator;
|
||||
FloatingToolbar.Button = FloatingToolbarButton;
|
||||
@@ -1,93 +0,0 @@
|
||||
import { keyframes, style } from '@vanilla-extract/css';
|
||||
|
||||
const slideDownAndFade = keyframes({
|
||||
'0%': {
|
||||
opacity: 0,
|
||||
transform: 'scale(0.95) translateY(20px)',
|
||||
},
|
||||
'100%': {
|
||||
opacity: 1,
|
||||
transform: 'scale(1) translateY(0)',
|
||||
},
|
||||
});
|
||||
|
||||
const slideUpAndFade = keyframes({
|
||||
'0%': {
|
||||
opacity: 1,
|
||||
transform: 'scale(1) translateY(0)',
|
||||
},
|
||||
'100%': {
|
||||
opacity: 0,
|
||||
transform: 'scale(0.95) translateY(20px)',
|
||||
},
|
||||
});
|
||||
|
||||
export const root = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
borderRadius: '10px',
|
||||
padding: '4px',
|
||||
border: '1px solid var(--affine-border-color)',
|
||||
boxShadow: 'var(--affine-menu-shadow)',
|
||||
gap: 4,
|
||||
minWidth: 'max-content',
|
||||
width: 'fit-content',
|
||||
background: 'var(--affine-background-primary-color)',
|
||||
});
|
||||
|
||||
export const popoverContent = style({
|
||||
willChange: 'transform opacity',
|
||||
selectors: {
|
||||
'&[data-state="open"]': {
|
||||
animation: `${slideDownAndFade} 0.2s ease-in-out`,
|
||||
},
|
||||
'&[data-state="closed"]': {
|
||||
animation: `${slideUpAndFade} 0.2s ease-in-out`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const separator = style({
|
||||
width: '1px',
|
||||
height: '24px',
|
||||
background: 'var(--affine-divider-color)',
|
||||
});
|
||||
|
||||
export const item = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
color: 'inherit',
|
||||
gap: 4,
|
||||
height: '32px',
|
||||
padding: '0 6px',
|
||||
});
|
||||
|
||||
export const button = style([
|
||||
item,
|
||||
{
|
||||
borderRadius: '8px',
|
||||
':hover': {
|
||||
background: 'var(--affine-hover-color)',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
export const danger = style({
|
||||
color: 'inherit',
|
||||
':hover': {
|
||||
background: 'var(--affine-background-error-color)',
|
||||
color: 'var(--affine-error-color)',
|
||||
},
|
||||
});
|
||||
|
||||
export const buttonIcon = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
fontSize: 20,
|
||||
color: 'var(--affine-icon-color)',
|
||||
selectors: {
|
||||
[`${danger}:hover &`]: {
|
||||
color: 'var(--affine-error-color)',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const menuContent = style({
|
||||
backgroundColor: 'var(--affine-background-overlay-panel-color)',
|
||||
});
|
||||
@@ -1,108 +0,0 @@
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { EdgelessIcon, ImportIcon, PageIcon } from '@blocksuite/icons';
|
||||
import { type PropsWithChildren, useCallback, useState } from 'react';
|
||||
|
||||
import { DropdownButton } from '../../../ui/button';
|
||||
import { Menu } from '../../../ui/menu';
|
||||
import { BlockCard } from '../../card/block-card';
|
||||
import { menuContent } from './new-page-button.css';
|
||||
|
||||
type NewPageButtonProps = {
|
||||
createNewPage: () => void;
|
||||
createNewEdgeless: () => void;
|
||||
importFile: () => void;
|
||||
size?: 'small' | 'default';
|
||||
};
|
||||
|
||||
export const CreateNewPagePopup = ({
|
||||
createNewPage,
|
||||
createNewEdgeless,
|
||||
importFile,
|
||||
}: NewPageButtonProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
padding: '8px',
|
||||
}}
|
||||
>
|
||||
<BlockCard
|
||||
title={t['New Page']()}
|
||||
desc={t['com.affine.write_with_a_blank_page']()}
|
||||
right={<PageIcon width={20} height={20} />}
|
||||
onClick={createNewPage}
|
||||
data-testid="new-page-button-in-all-page"
|
||||
/>
|
||||
<BlockCard
|
||||
title={t['com.affine.new_edgeless']()}
|
||||
desc={t['com.affine.draw_with_a_blank_whiteboard']()}
|
||||
right={<EdgelessIcon width={20} height={20} />}
|
||||
onClick={createNewEdgeless}
|
||||
data-testid="new-edgeless-button-in-all-page"
|
||||
/>
|
||||
<BlockCard
|
||||
title={t['com.affine.new_import']()}
|
||||
desc={t['com.affine.import_file']()}
|
||||
right={<ImportIcon width={20} height={20} />}
|
||||
onClick={importFile}
|
||||
data-testid="import-button-in-all-page"
|
||||
/>
|
||||
{/* TODO Import */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const NewPageButton = ({
|
||||
createNewPage,
|
||||
createNewEdgeless,
|
||||
importFile,
|
||||
size,
|
||||
children,
|
||||
}: PropsWithChildren<NewPageButtonProps>) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Menu
|
||||
items={
|
||||
<CreateNewPagePopup
|
||||
createNewPage={useCallback(() => {
|
||||
createNewPage();
|
||||
setOpen(false);
|
||||
}, [createNewPage])}
|
||||
createNewEdgeless={useCallback(() => {
|
||||
createNewEdgeless();
|
||||
setOpen(false);
|
||||
}, [createNewEdgeless])}
|
||||
importFile={useCallback(() => {
|
||||
importFile();
|
||||
setOpen(false);
|
||||
}, [importFile])}
|
||||
/>
|
||||
}
|
||||
rootOptions={{
|
||||
open,
|
||||
}}
|
||||
contentOptions={{
|
||||
className: menuContent,
|
||||
align: 'end',
|
||||
hideWhenDetached: true,
|
||||
onInteractOutside: useCallback(() => {
|
||||
setOpen(false);
|
||||
}, []),
|
||||
}}
|
||||
>
|
||||
<DropdownButton
|
||||
size={size}
|
||||
onClick={useCallback(() => {
|
||||
createNewPage();
|
||||
setOpen(false);
|
||||
}, [createNewPage])}
|
||||
onClickDropDown={useCallback(() => setOpen(open => !open), [])}
|
||||
>
|
||||
{children}
|
||||
</DropdownButton>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
@@ -1,180 +0,0 @@
|
||||
import type { Filter, Literal } from '@affine/env/filter';
|
||||
import type { PropertiesMeta } from '@affine/env/filter';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { Menu, MenuItem } from '../../../ui/menu';
|
||||
import { FilterTag } from './filter-tag-translation';
|
||||
import * as styles from './index.css';
|
||||
import { literalMatcher } from './literal-matcher';
|
||||
import { tBoolean } from './logical/custom-type';
|
||||
import type { TFunction, TType } from './logical/typesystem';
|
||||
import { typesystem } from './logical/typesystem';
|
||||
import { variableDefineMap } from './shared-types';
|
||||
import { filterMatcher, VariableSelect, vars } from './vars';
|
||||
|
||||
export const Condition = ({
|
||||
value,
|
||||
onChange,
|
||||
propertiesMeta,
|
||||
}: {
|
||||
value: Filter;
|
||||
onChange: (filter: Filter) => void;
|
||||
propertiesMeta: PropertiesMeta;
|
||||
}) => {
|
||||
const data = useMemo(() => {
|
||||
const data = filterMatcher.find(v => v.data.name === value.funcName);
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
const instance = typesystem.instance(
|
||||
{},
|
||||
[variableDefineMap[value.left.name].type(propertiesMeta)],
|
||||
tBoolean.create(),
|
||||
data.type
|
||||
);
|
||||
return {
|
||||
render: data.data.render,
|
||||
type: instance,
|
||||
};
|
||||
}, [propertiesMeta, value.funcName, value.left.name]);
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
const render =
|
||||
data.render ??
|
||||
(({ ast }) => {
|
||||
const args = renderArgs(value, onChange, data.type);
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
userSelect: 'none',
|
||||
alignItems: 'center',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Menu
|
||||
items={
|
||||
<VariableSelect
|
||||
propertiesMeta={propertiesMeta}
|
||||
selected={[]}
|
||||
onSelect={onChange}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div data-testid="variable-name" className={styles.filterTypeStyle}>
|
||||
<div className={styles.filterTypeIconStyle}>
|
||||
{variableDefineMap[ast.left.name].icon}
|
||||
</div>
|
||||
<FilterTag name={ast.left.name} />
|
||||
</div>
|
||||
</Menu>
|
||||
<Menu
|
||||
items={
|
||||
<FunctionSelect
|
||||
propertiesMeta={propertiesMeta}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className={styles.switchStyle} data-testid="filter-name">
|
||||
<FilterTag name={ast.funcName} />
|
||||
</div>
|
||||
</Menu>
|
||||
{args}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
return <>{render({ ast: value })}</>;
|
||||
};
|
||||
|
||||
const FunctionSelect = ({
|
||||
value,
|
||||
onChange,
|
||||
propertiesMeta,
|
||||
}: {
|
||||
value: Filter;
|
||||
onChange: (value: Filter) => void;
|
||||
propertiesMeta: PropertiesMeta;
|
||||
}) => {
|
||||
const list = useMemo(() => {
|
||||
const type = vars.find(v => v.name === value.left.name)?.type;
|
||||
if (!type) {
|
||||
return [];
|
||||
}
|
||||
return filterMatcher.allMatchedData(type(propertiesMeta));
|
||||
}, [propertiesMeta, value.left.name]);
|
||||
return (
|
||||
<div data-testid="filter-name-select">
|
||||
{list.map(v => (
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
onChange({
|
||||
...value,
|
||||
funcName: v.name,
|
||||
args: v.defaultArgs().map(v => ({ type: 'literal', value: v })),
|
||||
});
|
||||
}}
|
||||
key={v.name}
|
||||
>
|
||||
<FilterTag name={v.name} />
|
||||
</MenuItem>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Arg = ({
|
||||
type,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
type: TType;
|
||||
value: Literal;
|
||||
onChange: (lit: Literal) => void;
|
||||
}) => {
|
||||
const data = useMemo(() => literalMatcher.match(type), [type]);
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
data-testid="filter-arg"
|
||||
style={{ marginLeft: 4, fontWeight: 600, overflow: 'hidden' }}
|
||||
>
|
||||
{data.render({
|
||||
type,
|
||||
value: value?.value,
|
||||
onChange: v => onChange({ type: 'literal', value: v }),
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export const renderArgs = (
|
||||
filter: Filter,
|
||||
onChange: (value: Filter) => void,
|
||||
type: TFunction
|
||||
): ReactNode => {
|
||||
const rest = type.args.slice(1);
|
||||
return rest.map((argType, i) => {
|
||||
const value = filter.args[i];
|
||||
return (
|
||||
<Arg
|
||||
key={i}
|
||||
type={argType}
|
||||
value={value}
|
||||
onChange={value => {
|
||||
const args = type.args.map((_, index) =>
|
||||
i === index ? value : filter.args[index]
|
||||
);
|
||||
onChange({
|
||||
...filter,
|
||||
args,
|
||||
});
|
||||
}}
|
||||
></Arg>
|
||||
);
|
||||
});
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { Filter, Literal, Ref, VariableMap } from '@affine/env/filter';
|
||||
|
||||
import { filterMatcher } from './vars';
|
||||
|
||||
const evalRef = (ref: Ref, variableMap: VariableMap) => {
|
||||
return variableMap[ref.name];
|
||||
};
|
||||
const evalLiteral = (lit?: Literal) => {
|
||||
return lit?.value;
|
||||
};
|
||||
const evalFilter = (filter: Filter, variableMap: VariableMap): boolean => {
|
||||
const impl = filterMatcher.findData(v => v.name === filter.funcName)?.impl;
|
||||
if (!impl) {
|
||||
throw new Error('No function implementation found');
|
||||
}
|
||||
const leftValue = evalRef(filter.left, variableMap);
|
||||
const args = filter.args.map(evalLiteral);
|
||||
return impl(leftValue, ...args);
|
||||
};
|
||||
export const evalFilterList = (
|
||||
filterList: Filter[],
|
||||
variableMap: VariableMap
|
||||
) => {
|
||||
return filterList.every(filter => evalFilter(filter, variableMap));
|
||||
};
|
||||
@@ -1,80 +0,0 @@
|
||||
import type { Filter } from '@affine/env/filter';
|
||||
import type { PropertiesMeta } from '@affine/env/filter';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { CloseIcon, PlusIcon } from '@blocksuite/icons';
|
||||
|
||||
import { Button } from '../../../ui/button';
|
||||
import { IconButton } from '../../../ui/button';
|
||||
import { Menu } from '../../../ui/menu';
|
||||
import { Condition } from './condition';
|
||||
import * as styles from './index.css';
|
||||
import { CreateFilterMenu } from './vars';
|
||||
|
||||
export const FilterList = ({
|
||||
value,
|
||||
onChange,
|
||||
propertiesMeta,
|
||||
}: {
|
||||
value: Filter[];
|
||||
onChange: (value: Filter[]) => void;
|
||||
propertiesMeta: PropertiesMeta;
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 10,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{value.map((filter, i) => {
|
||||
return (
|
||||
<div className={styles.filterItemStyle} key={i}>
|
||||
<Condition
|
||||
propertiesMeta={propertiesMeta}
|
||||
value={filter}
|
||||
onChange={filter => {
|
||||
onChange(
|
||||
value.map((old, oldIndex) => (oldIndex === i ? filter : old))
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={styles.filterItemCloseStyle}
|
||||
onClick={() => {
|
||||
onChange(value.filter((_, index) => i !== index));
|
||||
}}
|
||||
>
|
||||
<CloseIcon />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<Menu
|
||||
items={
|
||||
<CreateFilterMenu
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
propertiesMeta={propertiesMeta}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{value.length === 0 ? (
|
||||
<Button
|
||||
icon={<PlusIcon style={{ color: 'var(--affine-icon-color)' }} />}
|
||||
iconPosition="end"
|
||||
style={{ fontSize: 'var(--affine-font-xs)', padding: '0 8px' }}
|
||||
>
|
||||
{t['com.affine.filterList.button.add']()}
|
||||
</Button>
|
||||
) : (
|
||||
<IconButton size="small">
|
||||
<PlusIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,51 +0,0 @@
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
|
||||
type FilterTagProps = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
const useFilterTag = ({ name }: FilterTagProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
switch (name) {
|
||||
case 'Created':
|
||||
return t['Created']();
|
||||
case 'Updated':
|
||||
return t['Updated']();
|
||||
case 'Tags':
|
||||
return t['Tags']();
|
||||
case 'Is Favourited':
|
||||
return t['com.affine.filter.is-favourited']();
|
||||
case 'after':
|
||||
return t['com.affine.filter.after']();
|
||||
case 'before':
|
||||
return t['com.affine.filter.before']();
|
||||
case 'last':
|
||||
return t['com.affine.filter.last']();
|
||||
case 'is':
|
||||
return t['com.affine.filter.is']();
|
||||
case 'is not empty':
|
||||
return t['com.affine.filter.is not empty']();
|
||||
case 'is empty':
|
||||
return t['com.affine.filter.is empty']();
|
||||
case 'contains all':
|
||||
return t['com.affine.filter.contains all']();
|
||||
case 'contains one of':
|
||||
return t['com.affine.filter.contains one of']();
|
||||
case 'does not contains all':
|
||||
return t['com.affine.filter.does not contains all']();
|
||||
case 'does not contains one of':
|
||||
return t['com.affine.filter.does not contains one of']();
|
||||
case 'true':
|
||||
return t['com.affine.filter.true']();
|
||||
case 'false':
|
||||
return t['com.affine.filter.false']();
|
||||
default:
|
||||
return name;
|
||||
}
|
||||
};
|
||||
|
||||
export const FilterTag = ({ name }: FilterTagProps) => {
|
||||
const tag = useFilterTag({ name });
|
||||
|
||||
return <span data-testid={`filler-tag-${tag}`}>{tag}</span>;
|
||||
};
|
||||
@@ -1,84 +0,0 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const menuItemStyle = style({
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
});
|
||||
export const variableSelectTitleStyle = style({
|
||||
margin: '7px 16px',
|
||||
fontWeight: 500,
|
||||
lineHeight: '20px',
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
});
|
||||
export const variableSelectDividerStyle = style({
|
||||
marginTop: '2px',
|
||||
marginBottom: '2px',
|
||||
marginLeft: '12px',
|
||||
marginRight: '8px',
|
||||
height: '1px',
|
||||
background: 'var(--affine-border-color)',
|
||||
});
|
||||
export const menuItemTextStyle = style({
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
});
|
||||
export const filterItemStyle = style({
|
||||
display: 'flex',
|
||||
border: '1px solid var(--affine-border-color)',
|
||||
borderRadius: '8px',
|
||||
background: 'var(--affine-white)',
|
||||
padding: '4px 8px',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
export const filterItemCloseStyle = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
marginLeft: '4px',
|
||||
});
|
||||
export const inputStyle = style({
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
padding: '2px 4px',
|
||||
transition: 'all 0.15s ease-in-out',
|
||||
':hover': {
|
||||
cursor: 'pointer',
|
||||
background: 'var(--affine-hover-color)',
|
||||
borderRadius: '4px',
|
||||
},
|
||||
});
|
||||
export const switchStyle = style({
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
padding: '2px 4px',
|
||||
transition: 'all 0.15s ease-in-out',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
':hover': {
|
||||
cursor: 'pointer',
|
||||
background: 'var(--affine-hover-color)',
|
||||
borderRadius: '4px',
|
||||
},
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
export const filterTypeStyle = style({
|
||||
fontSize: 'var(--affine-font-sm)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '2px 4px',
|
||||
transition: 'all 0.15s ease-in-out',
|
||||
marginRight: '6px',
|
||||
':hover': {
|
||||
cursor: 'pointer',
|
||||
background: 'var(--affine-hover-color)',
|
||||
borderRadius: '4px',
|
||||
},
|
||||
});
|
||||
export const filterTypeIconStyle = style({
|
||||
fontSize: 'var(--affine-font-base)',
|
||||
marginRight: '6px',
|
||||
padding: '1px 0',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
color: 'var(--affine-icon-color)',
|
||||
});
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from './eval';
|
||||
export * from './filter-list';
|
||||
export * from './utils';
|
||||
@@ -1,103 +0,0 @@
|
||||
import type { LiteralValue, Tag } from '@affine/env/filter';
|
||||
import dayjs from 'dayjs';
|
||||
import { type ReactNode } from 'react';
|
||||
|
||||
import Input from '../../../ui/input';
|
||||
import { Menu, MenuItem } from '../../../ui/menu';
|
||||
import { AFFiNEDatePicker } from '../../date-picker';
|
||||
import { FilterTag } from './filter-tag-translation';
|
||||
import { inputStyle } from './index.css';
|
||||
import { tBoolean, tDate, tDateRange, tTag } from './logical/custom-type';
|
||||
import { Matcher } from './logical/matcher';
|
||||
import type { TType } from './logical/typesystem';
|
||||
import { tArray, typesystem } from './logical/typesystem';
|
||||
import { MultiSelect } from './multi-select';
|
||||
|
||||
export const literalMatcher = new Matcher<{
|
||||
render: (props: {
|
||||
type: TType;
|
||||
value: LiteralValue;
|
||||
onChange: (lit: LiteralValue) => void;
|
||||
}) => ReactNode;
|
||||
}>((type, target) => {
|
||||
return typesystem.isSubtype(type, target);
|
||||
});
|
||||
|
||||
literalMatcher.register(tDateRange.create(), {
|
||||
render: ({ value, onChange }) => (
|
||||
<Menu
|
||||
items={
|
||||
<div>
|
||||
<Input
|
||||
type="number"
|
||||
// Handle the input change and update the value accordingly
|
||||
onChange={i => (i ? onChange(parseInt(i)) : onChange(0))}
|
||||
/>
|
||||
{[1, 2, 3, 7, 14, 30].map(i => (
|
||||
<MenuItem
|
||||
key={i}
|
||||
onClick={() => {
|
||||
// Handle the menu item click and update the value accordingly
|
||||
onChange(i);
|
||||
}}
|
||||
>
|
||||
{i} {i > 1 ? 'days' : 'day'}
|
||||
</MenuItem>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<span>{value.toString()}</span> {(value as number) > 1 ? 'days' : 'day'}
|
||||
</div>
|
||||
</Menu>
|
||||
),
|
||||
});
|
||||
|
||||
literalMatcher.register(tBoolean.create(), {
|
||||
render: ({ value, onChange }) => (
|
||||
<div
|
||||
className={inputStyle}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
onChange(!value);
|
||||
}}
|
||||
>
|
||||
<FilterTag name={value?.toString()} />
|
||||
</div>
|
||||
),
|
||||
});
|
||||
literalMatcher.register(tDate.create(), {
|
||||
render: ({ value, onChange }) => (
|
||||
<AFFiNEDatePicker
|
||||
value={dayjs(value as number).format('YYYY-MM-DD')}
|
||||
onChange={e => {
|
||||
onChange(dayjs(e, 'YYYY-MM-DD').valueOf());
|
||||
}}
|
||||
/>
|
||||
),
|
||||
});
|
||||
const getTagsOfArrayTag = (type: TType): Tag[] => {
|
||||
if (type.type === 'array') {
|
||||
if (tTag.is(type.ele)) {
|
||||
return type.ele.data?.tags ?? [];
|
||||
}
|
||||
return [];
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
literalMatcher.register(tArray(tTag.create()), {
|
||||
render: ({ type, value, onChange }) => {
|
||||
return (
|
||||
<MultiSelect
|
||||
value={(value ?? []) as string[]}
|
||||
onChange={value => onChange(value)}
|
||||
options={getTagsOfArrayTag(type).map(v => ({
|
||||
label: v.value,
|
||||
value: v.id,
|
||||
}))}
|
||||
></MultiSelect>
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { Tag } from '@affine/env/filter';
|
||||
|
||||
import { DataHelper, typesystem } from './typesystem';
|
||||
|
||||
export const tNumber = typesystem.defineData(
|
||||
DataHelper.create<{ value: number }>('Number')
|
||||
);
|
||||
export const tString = typesystem.defineData(
|
||||
DataHelper.create<{ value: string }>('String')
|
||||
);
|
||||
export const tBoolean = typesystem.defineData(
|
||||
DataHelper.create<{ value: boolean }>('Boolean')
|
||||
);
|
||||
export const tDate = typesystem.defineData(
|
||||
DataHelper.create<{ value: number }>('Date')
|
||||
);
|
||||
|
||||
export const tTag = typesystem.defineData<{ tags: Tag[] }>({
|
||||
name: 'Tag',
|
||||
supers: [],
|
||||
});
|
||||
|
||||
export const tDateRange = typesystem.defineData(
|
||||
DataHelper.create<{ value: number }>('DateRange')
|
||||
);
|
||||
@@ -1,55 +0,0 @@
|
||||
import type { TType } from './typesystem';
|
||||
import { typesystem } from './typesystem';
|
||||
|
||||
type MatcherData<Data, Type extends TType = TType> = { type: Type; data: Data };
|
||||
|
||||
export class Matcher<Data, Type extends TType = TType> {
|
||||
private readonly list: MatcherData<Data, Type>[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly _match?: (type: Type, target: TType) => boolean
|
||||
) {}
|
||||
|
||||
register(type: Type, data: Data) {
|
||||
this.list.push({ type, data });
|
||||
}
|
||||
|
||||
match(type: TType) {
|
||||
const match = this._match ?? typesystem.isSubtype.bind(typesystem);
|
||||
for (const t of this.list) {
|
||||
if (match(t.type, type)) {
|
||||
return t.data;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
allMatched(type: TType): MatcherData<Data>[] {
|
||||
const match = this._match ?? typesystem.isSubtype.bind(typesystem);
|
||||
const result: MatcherData<Data>[] = [];
|
||||
for (const t of this.list) {
|
||||
if (match(t.type, type)) {
|
||||
result.push(t);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
allMatchedData(type: TType): Data[] {
|
||||
return this.allMatched(type).map(v => v.data);
|
||||
}
|
||||
|
||||
findData(f: (data: Data) => boolean): Data | undefined {
|
||||
return this.list.find(data => f(data.data))?.data;
|
||||
}
|
||||
|
||||
find(
|
||||
f: (data: MatcherData<Data, Type>) => boolean
|
||||
): MatcherData<Data, Type> | undefined {
|
||||
return this.list.find(f);
|
||||
}
|
||||
|
||||
all(): MatcherData<Data, Type>[] {
|
||||
return this.list;
|
||||
}
|
||||
}
|
||||
@@ -1,283 +0,0 @@
|
||||
/**
|
||||
* This file will be moved to a separate package soon.
|
||||
*/
|
||||
|
||||
export interface TUnion {
|
||||
type: 'union';
|
||||
title: 'union';
|
||||
list: TType[];
|
||||
}
|
||||
|
||||
export const tUnion = (list: TType[]): TUnion => ({
|
||||
type: 'union',
|
||||
title: 'union',
|
||||
list,
|
||||
});
|
||||
|
||||
// TODO treat as data type
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export interface TArray<Ele extends TType = TType> {
|
||||
type: 'array';
|
||||
ele: Ele;
|
||||
title: 'array';
|
||||
}
|
||||
|
||||
export const tArray = <const T extends TType>(ele: T): TArray<T> => {
|
||||
return {
|
||||
type: 'array',
|
||||
title: 'array',
|
||||
ele,
|
||||
};
|
||||
};
|
||||
export type TTypeVar = {
|
||||
type: 'typeVar';
|
||||
title: 'typeVar';
|
||||
name: string;
|
||||
bound: TType;
|
||||
};
|
||||
export const tTypeVar = (name: string, bound: TType): TTypeVar => {
|
||||
return {
|
||||
type: 'typeVar',
|
||||
title: 'typeVar',
|
||||
name,
|
||||
bound,
|
||||
};
|
||||
};
|
||||
export type TTypeRef = {
|
||||
type: 'typeRef';
|
||||
title: 'typeRef';
|
||||
name: string;
|
||||
};
|
||||
export const tTypeRef = (name: string): TTypeRef => {
|
||||
return {
|
||||
type: 'typeRef',
|
||||
title: 'typeRef',
|
||||
name,
|
||||
};
|
||||
};
|
||||
|
||||
export type TFunction = {
|
||||
type: 'function';
|
||||
title: 'function';
|
||||
typeVars: TTypeVar[];
|
||||
args: TType[];
|
||||
rt: TType;
|
||||
};
|
||||
|
||||
export const tFunction = (fn: {
|
||||
typeVars?: TTypeVar[];
|
||||
args: TType[];
|
||||
rt: TType;
|
||||
}): TFunction => {
|
||||
return {
|
||||
type: 'function',
|
||||
title: 'function',
|
||||
typeVars: fn.typeVars ?? [],
|
||||
args: fn.args,
|
||||
rt: fn.rt,
|
||||
};
|
||||
};
|
||||
|
||||
export type TType = TDataType | TArray | TUnion | TTypeRef | TFunction;
|
||||
|
||||
export type DataTypeShape = Record<string, unknown>;
|
||||
export type TDataType<Data extends DataTypeShape = Record<string, unknown>> = {
|
||||
type: 'data';
|
||||
name: string;
|
||||
data?: Data;
|
||||
};
|
||||
export type ValueOfData<T extends DataDefine> = T extends DataDefine<infer R>
|
||||
? R
|
||||
: never;
|
||||
|
||||
export class DataDefine<Data extends DataTypeShape = Record<string, unknown>> {
|
||||
constructor(
|
||||
private readonly config: DataDefineConfig<Data>,
|
||||
private readonly dataMap: Map<string, DataDefine>
|
||||
) {}
|
||||
|
||||
create(data?: Data): TDataType<Data> {
|
||||
return {
|
||||
type: 'data',
|
||||
name: this.config.name,
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
is(data: TType): data is TDataType<Data> {
|
||||
if (data.type !== 'data') {
|
||||
return false;
|
||||
}
|
||||
return data.name === this.config.name;
|
||||
}
|
||||
|
||||
private isByName(name: string): boolean {
|
||||
return name === this.config.name;
|
||||
}
|
||||
|
||||
isSubOf(superType: TDataType): boolean {
|
||||
if (this.is(superType)) {
|
||||
return true;
|
||||
}
|
||||
return this.config.supers.some(sup => sup.isSubOf(superType));
|
||||
}
|
||||
|
||||
private isSubOfByName(superType: string): boolean {
|
||||
if (this.isByName(superType)) {
|
||||
return true;
|
||||
}
|
||||
return this.config.supers.some(sup => sup.isSubOfByName(superType));
|
||||
}
|
||||
|
||||
isSuperOf(subType: TDataType): boolean {
|
||||
const dataDefine = this.dataMap.get(subType.name);
|
||||
if (!dataDefine) {
|
||||
throw new Error('bug');
|
||||
}
|
||||
return dataDefine.isSubOfByName(this.config.name);
|
||||
}
|
||||
}
|
||||
|
||||
// type DataTypeVar = {};
|
||||
|
||||
// TODO support generic data type
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
interface DataDefineConfig<T extends DataTypeShape> {
|
||||
name: string;
|
||||
supers: DataDefine[];
|
||||
_phantom?: T;
|
||||
}
|
||||
|
||||
interface DataHelper<T extends DataTypeShape> {
|
||||
create<V = Record<string, unknown>>(name: string): DataDefineConfig<T & V>;
|
||||
|
||||
extends<V extends DataTypeShape>(
|
||||
dataDefine: DataDefine<V>
|
||||
): DataHelper<T & V>;
|
||||
}
|
||||
|
||||
const createDataHelper = <T extends DataTypeShape = Record<string, unknown>>(
|
||||
...supers: DataDefine[]
|
||||
): DataHelper<T> => {
|
||||
return {
|
||||
create(name: string) {
|
||||
return {
|
||||
name,
|
||||
supers,
|
||||
};
|
||||
},
|
||||
extends(dataDefine) {
|
||||
return createDataHelper(...supers, dataDefine);
|
||||
},
|
||||
};
|
||||
};
|
||||
export const DataHelper = createDataHelper();
|
||||
|
||||
export class Typesystem {
|
||||
dataMap = new Map<string, DataDefine<any>>();
|
||||
|
||||
defineData<T extends DataTypeShape>(
|
||||
config: DataDefineConfig<T>
|
||||
): DataDefine<T> {
|
||||
const result = new DataDefine(config, this.dataMap);
|
||||
this.dataMap.set(config.name, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
isDataType(t: TType): t is TDataType {
|
||||
return t.type === 'data';
|
||||
}
|
||||
|
||||
isSubtype(
|
||||
superType: TType,
|
||||
sub: TType,
|
||||
context?: Record<string, TType>
|
||||
): boolean {
|
||||
if (superType.type === 'typeRef') {
|
||||
// TODO both are ref
|
||||
if (context && sub.type !== 'typeRef') {
|
||||
context[superType.name] = sub;
|
||||
}
|
||||
// TODO bound
|
||||
return true;
|
||||
}
|
||||
if (sub.type === 'typeRef') {
|
||||
// TODO both are ref
|
||||
if (context) {
|
||||
context[sub.name] = superType;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (tUnknown.is(superType)) {
|
||||
return true;
|
||||
}
|
||||
if (superType.type === 'union') {
|
||||
return superType.list.some(type => this.isSubtype(type, sub, context));
|
||||
}
|
||||
if (sub.type === 'union') {
|
||||
return sub.list.every(type => this.isSubtype(superType, type, context));
|
||||
}
|
||||
|
||||
if (this.isDataType(sub)) {
|
||||
const dataDefine = this.dataMap.get(sub.name);
|
||||
if (!dataDefine) {
|
||||
throw new Error('bug');
|
||||
}
|
||||
if (!this.isDataType(superType)) {
|
||||
return false;
|
||||
}
|
||||
return dataDefine.isSubOf(superType);
|
||||
}
|
||||
|
||||
if (superType.type === 'array' || sub.type === 'array') {
|
||||
if (superType.type !== 'array' || sub.type !== 'array') {
|
||||
return false;
|
||||
}
|
||||
return this.isSubtype(superType.ele, sub.ele, context);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
subst(context: Record<string, TType>, template: TFunction): TFunction {
|
||||
const subst = (type: TType): TType => {
|
||||
if (this.isDataType(type)) {
|
||||
return type;
|
||||
}
|
||||
switch (type.type) {
|
||||
case 'typeRef':
|
||||
return { ...context[type.name] };
|
||||
case 'union':
|
||||
return tUnion(type.list.map(type => subst(type)));
|
||||
case 'array':
|
||||
return tArray(subst(type.ele));
|
||||
case 'function':
|
||||
throw new Error('TODO');
|
||||
}
|
||||
};
|
||||
const result = tFunction({
|
||||
args: template.args.map(type => subst(type)),
|
||||
rt: subst(template.rt),
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
instance(
|
||||
context: Record<string, TType>,
|
||||
realArgs: TType[],
|
||||
realRt: TType,
|
||||
template: TFunction
|
||||
): TFunction {
|
||||
const ctx = { ...context };
|
||||
template.args.forEach((arg, i) => {
|
||||
const realArg = realArgs[i];
|
||||
if (realArg) {
|
||||
this.isSubtype(arg, realArg, ctx);
|
||||
}
|
||||
});
|
||||
this.isSubtype(realRt, template.rt);
|
||||
return this.subst(ctx, template);
|
||||
}
|
||||
}
|
||||
|
||||
export const typesystem = new Typesystem();
|
||||
export const tUnknown = typesystem.defineData(DataHelper.create('Unknown'));
|
||||
@@ -1,51 +0,0 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const content = style({
|
||||
fontSize: 12,
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
borderRadius: 8,
|
||||
padding: '3px 4px',
|
||||
cursor: 'pointer',
|
||||
overflow: 'hidden',
|
||||
':hover': {
|
||||
backgroundColor: 'var(--affine-hover-color)',
|
||||
},
|
||||
});
|
||||
export const text = style({
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: 350,
|
||||
});
|
||||
export const optionList = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
padding: '0 4px',
|
||||
});
|
||||
export const selectOption = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
fontSize: 14,
|
||||
height: 26,
|
||||
borderRadius: 5,
|
||||
maxWidth: 240,
|
||||
minWidth: 100,
|
||||
padding: '0 12px',
|
||||
cursor: 'pointer',
|
||||
':hover': {
|
||||
backgroundColor: 'var(--affine-hover-color)',
|
||||
},
|
||||
});
|
||||
export const optionLabel = style({
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
flex: 1,
|
||||
});
|
||||
export const done = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
color: 'var(--affine-primary-color)',
|
||||
marginLeft: 8,
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
import type { MouseEvent } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { Menu, MenuItem } from '../../../ui/menu';
|
||||
import * as styles from './multi-select.css';
|
||||
|
||||
export const MultiSelect = ({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
value: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
options: {
|
||||
label: string;
|
||||
value: string;
|
||||
}[];
|
||||
}) => {
|
||||
const optionMap = useMemo(
|
||||
() => Object.fromEntries(options.map(v => [v.value, v])),
|
||||
[options]
|
||||
);
|
||||
|
||||
return (
|
||||
<Menu
|
||||
items={
|
||||
<div data-testid="multi-select" className={styles.optionList}>
|
||||
{options.map(option => {
|
||||
const selected = value.includes(option.value);
|
||||
const click = (e: MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (selected) {
|
||||
onChange(value.filter(v => v !== option.value));
|
||||
} else {
|
||||
onChange([...value, option.value]);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<MenuItem
|
||||
data-testid={`multi-select-${option.label}`}
|
||||
checked={selected}
|
||||
onClick={click}
|
||||
key={option.value}
|
||||
>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className={styles.content}>
|
||||
{value.length ? (
|
||||
<div className={styles.text}>
|
||||
{value.map(id => optionMap[id]?.label).join(', ')}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ color: 'var(--affine-text-secondary-color)' }}>
|
||||
Empty
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
@@ -1,62 +0,0 @@
|
||||
import type {
|
||||
Literal,
|
||||
LiteralValue,
|
||||
PropertiesMeta,
|
||||
VariableMap,
|
||||
} from '@affine/env/filter';
|
||||
import {
|
||||
CreatedIcon,
|
||||
FavoriteIcon,
|
||||
TagsIcon,
|
||||
UpdatedIcon,
|
||||
} from '@blocksuite/icons';
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
import { tBoolean, tDate, tTag } from './logical/custom-type';
|
||||
import type { TType } from './logical/typesystem';
|
||||
import { tArray } from './logical/typesystem';
|
||||
|
||||
export const toLiteral = (value: LiteralValue): Literal => ({
|
||||
type: 'literal',
|
||||
value,
|
||||
});
|
||||
|
||||
export type FilterVariable = {
|
||||
name: keyof VariableMap;
|
||||
type: (propertiesMeta: PropertiesMeta) => TType;
|
||||
icon: ReactElement;
|
||||
};
|
||||
|
||||
export const variableDefineMap = {
|
||||
Created: {
|
||||
type: () => tDate.create(),
|
||||
icon: <CreatedIcon />,
|
||||
},
|
||||
Updated: {
|
||||
type: () => tDate.create(),
|
||||
icon: <UpdatedIcon />,
|
||||
},
|
||||
'Is Favourited': {
|
||||
type: () => tBoolean.create(),
|
||||
icon: <FavoriteIcon />,
|
||||
},
|
||||
Tags: {
|
||||
type: meta => tArray(tTag.create({ tags: meta.tags?.options ?? [] })),
|
||||
icon: <TagsIcon />,
|
||||
},
|
||||
// Imported: {
|
||||
// type: tBoolean.create(),
|
||||
// },
|
||||
// 'Daily Note': {
|
||||
// type: tBoolean.create(),
|
||||
// },
|
||||
} satisfies Record<string, Omit<FilterVariable, 'name'>>;
|
||||
|
||||
export type InternalVariableMap = {
|
||||
[K in keyof typeof variableDefineMap]: LiteralValue;
|
||||
};
|
||||
|
||||
declare module '@affine/env/filter' {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||
interface VariableMap extends InternalVariableMap {}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import type { Filter } from '@affine/env/filter';
|
||||
|
||||
export const createTagFilter = (id: string): Filter => {
|
||||
return {
|
||||
type: 'filter',
|
||||
left: { type: 'ref', name: 'Tags' },
|
||||
funcName: 'contains all',
|
||||
args: [{ type: 'literal', value: [id] }],
|
||||
};
|
||||
};
|
||||
@@ -1,305 +0,0 @@
|
||||
import type {
|
||||
Filter,
|
||||
LiteralValue,
|
||||
PropertiesMeta,
|
||||
VariableMap,
|
||||
} from '@affine/env/filter';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import dayjs from 'dayjs';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { MenuIcon, MenuItem, MenuSeparator } from '../../../ui/menu';
|
||||
import { FilterTag } from './filter-tag-translation';
|
||||
import * as styles from './index.css';
|
||||
import { tBoolean, tDate, tDateRange, tTag } from './logical/custom-type';
|
||||
import { Matcher } from './logical/matcher';
|
||||
import type { TFunction } from './logical/typesystem';
|
||||
import {
|
||||
tArray,
|
||||
tFunction,
|
||||
tTypeRef,
|
||||
tTypeVar,
|
||||
typesystem,
|
||||
} from './logical/typesystem';
|
||||
import type { FilterVariable } from './shared-types';
|
||||
import { variableDefineMap } from './shared-types';
|
||||
|
||||
export const vars: FilterVariable[] = Object.entries(variableDefineMap).map(
|
||||
([key, value]) => ({
|
||||
name: key as keyof VariableMap,
|
||||
type: value.type,
|
||||
icon: value.icon,
|
||||
})
|
||||
);
|
||||
|
||||
export const createDefaultFilter = (
|
||||
variable: FilterVariable,
|
||||
propertiesMeta: PropertiesMeta
|
||||
): Filter => {
|
||||
const data = filterMatcher.match(variable.type(propertiesMeta));
|
||||
if (!data) {
|
||||
throw new Error('No matching function found');
|
||||
}
|
||||
return {
|
||||
type: 'filter',
|
||||
left: {
|
||||
type: 'ref',
|
||||
name: variable.name,
|
||||
},
|
||||
funcName: data.name,
|
||||
args: data.defaultArgs().map(value => ({
|
||||
type: 'literal',
|
||||
value,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
export const CreateFilterMenu = ({
|
||||
value,
|
||||
onChange,
|
||||
propertiesMeta,
|
||||
}: {
|
||||
value: Filter[];
|
||||
onChange: (value: Filter[]) => void;
|
||||
propertiesMeta: PropertiesMeta;
|
||||
}) => {
|
||||
return (
|
||||
<VariableSelect
|
||||
propertiesMeta={propertiesMeta}
|
||||
selected={value}
|
||||
onSelect={filter => {
|
||||
onChange([...value, filter]);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
export const VariableSelect = ({
|
||||
onSelect,
|
||||
propertiesMeta,
|
||||
}: {
|
||||
selected: Filter[];
|
||||
onSelect: (value: Filter) => void;
|
||||
propertiesMeta: PropertiesMeta;
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
return (
|
||||
<div data-testid="variable-select">
|
||||
<div className={styles.variableSelectTitleStyle}>
|
||||
{t['com.affine.filter']()}
|
||||
</div>
|
||||
<MenuSeparator />
|
||||
{vars
|
||||
// .filter(v => !selected.find(filter => filter.left.name === v.name))
|
||||
.map(v => (
|
||||
<MenuItem
|
||||
preFix={<MenuIcon>{variableDefineMap[v.name].icon}</MenuIcon>}
|
||||
key={v.name}
|
||||
onClick={() => {
|
||||
onSelect(createDefaultFilter(v, propertiesMeta));
|
||||
}}
|
||||
className={styles.menuItemStyle}
|
||||
>
|
||||
<div
|
||||
data-testid="variable-select-item"
|
||||
className={styles.menuItemTextStyle}
|
||||
>
|
||||
<FilterTag name={v.name} />
|
||||
</div>
|
||||
</MenuItem>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type FilterMatcherDataType = {
|
||||
name: string;
|
||||
defaultArgs: () => LiteralValue[];
|
||||
render?: (props: { ast: Filter }) => ReactNode;
|
||||
impl: (...args: (LiteralValue | undefined)[]) => boolean;
|
||||
};
|
||||
export const filterMatcher = new Matcher<FilterMatcherDataType, TFunction>(
|
||||
(type, target) => {
|
||||
const staticType = typesystem.subst(
|
||||
Object.fromEntries(type.typeVars?.map(v => [v.name, v.bound]) ?? []),
|
||||
type
|
||||
);
|
||||
const firstArg = staticType.args[0];
|
||||
return firstArg && typesystem.isSubtype(firstArg, target);
|
||||
}
|
||||
);
|
||||
|
||||
filterMatcher.register(
|
||||
tFunction({
|
||||
args: [tBoolean.create(), tBoolean.create()],
|
||||
rt: tBoolean.create(),
|
||||
}),
|
||||
{
|
||||
name: 'is',
|
||||
defaultArgs: () => [true],
|
||||
impl: (value, target) => {
|
||||
return value === target;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
filterMatcher.register(
|
||||
tFunction({
|
||||
args: [tDate.create(), tDate.create()],
|
||||
rt: tBoolean.create(),
|
||||
}),
|
||||
{
|
||||
name: 'after',
|
||||
defaultArgs: () => {
|
||||
return [dayjs().subtract(1, 'day').endOf('day').valueOf()];
|
||||
},
|
||||
impl: (date, target) => {
|
||||
if (typeof date !== 'number' || typeof target !== 'number') {
|
||||
throw new Error('argument type error');
|
||||
}
|
||||
return dayjs(date).isAfter(dayjs(target).endOf('day'));
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
filterMatcher.register(
|
||||
tFunction({
|
||||
args: [tDate.create(), tDateRange.create()],
|
||||
rt: tBoolean.create(),
|
||||
}),
|
||||
{
|
||||
name: 'last',
|
||||
defaultArgs: () => [30], // Default to the last 30 days
|
||||
impl: (date, n) => {
|
||||
if (typeof date !== 'number' || typeof n !== 'number') {
|
||||
throw new Error('Argument type error: date and n must be numbers');
|
||||
}
|
||||
const startDate = dayjs().subtract(n, 'day').startOf('day').valueOf();
|
||||
return date > startDate;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
filterMatcher.register(
|
||||
tFunction({
|
||||
args: [tDate.create(), tDate.create()],
|
||||
rt: tBoolean.create(),
|
||||
}),
|
||||
{
|
||||
name: 'before',
|
||||
defaultArgs: () => [dayjs().endOf('day').valueOf()],
|
||||
impl: (date, target) => {
|
||||
if (typeof date !== 'number' || typeof target !== 'number') {
|
||||
throw new Error('argument type error');
|
||||
}
|
||||
return dayjs(date).isBefore(dayjs(target).startOf('day'));
|
||||
},
|
||||
}
|
||||
);
|
||||
const safeArray = (arr: unknown): LiteralValue[] => {
|
||||
return Array.isArray(arr) ? arr : [];
|
||||
};
|
||||
filterMatcher.register(
|
||||
tFunction({
|
||||
args: [tArray(tTag.create())],
|
||||
rt: tBoolean.create(),
|
||||
}),
|
||||
{
|
||||
name: 'is not empty',
|
||||
defaultArgs: () => [],
|
||||
impl: tags => {
|
||||
const safeTags = safeArray(tags);
|
||||
return safeTags.length > 0;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
filterMatcher.register(
|
||||
tFunction({
|
||||
args: [tArray(tTag.create())],
|
||||
rt: tBoolean.create(),
|
||||
}),
|
||||
{
|
||||
name: 'is empty',
|
||||
defaultArgs: () => [],
|
||||
impl: tags => {
|
||||
const safeTags = safeArray(tags);
|
||||
return safeTags.length === 0;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
filterMatcher.register(
|
||||
tFunction({
|
||||
typeVars: [tTypeVar('T', tTag.create())],
|
||||
args: [tArray(tTypeRef('T')), tArray(tTypeRef('T'))],
|
||||
rt: tBoolean.create(),
|
||||
}),
|
||||
{
|
||||
name: 'contains all',
|
||||
defaultArgs: () => [],
|
||||
impl: (tags, target) => {
|
||||
if (!Array.isArray(target)) {
|
||||
return true;
|
||||
}
|
||||
const safeTags = safeArray(tags);
|
||||
return target.every(id => safeTags.includes(id));
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
filterMatcher.register(
|
||||
tFunction({
|
||||
typeVars: [tTypeVar('T', tTag.create())],
|
||||
args: [tArray(tTypeRef('T')), tArray(tTypeRef('T'))],
|
||||
rt: tBoolean.create(),
|
||||
}),
|
||||
{
|
||||
name: 'contains one of',
|
||||
defaultArgs: () => [],
|
||||
impl: (tags, target) => {
|
||||
if (!Array.isArray(target)) {
|
||||
return true;
|
||||
}
|
||||
const safeTags = safeArray(tags);
|
||||
return target.some(id => safeTags.includes(id));
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
filterMatcher.register(
|
||||
tFunction({
|
||||
typeVars: [tTypeVar('T', tTag.create())],
|
||||
args: [tArray(tTypeRef('T')), tArray(tTypeRef('T'))],
|
||||
rt: tBoolean.create(),
|
||||
}),
|
||||
{
|
||||
name: 'does not contains all',
|
||||
defaultArgs: () => [],
|
||||
impl: (tags, target) => {
|
||||
if (!Array.isArray(target)) {
|
||||
return true;
|
||||
}
|
||||
const safeTags = safeArray(tags);
|
||||
return !target.every(id => safeTags.includes(id));
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
filterMatcher.register(
|
||||
tFunction({
|
||||
typeVars: [tTypeVar('T', tTag.create())],
|
||||
args: [tArray(tTypeRef('T')), tArray(tTypeRef('T'))],
|
||||
rt: tBoolean.create(),
|
||||
}),
|
||||
{
|
||||
name: 'does not contains one of',
|
||||
defaultArgs: () => [],
|
||||
impl: (tags, target) => {
|
||||
if (!Array.isArray(target)) {
|
||||
return true;
|
||||
}
|
||||
const safeTags = safeArray(tags);
|
||||
return !target.some(id => safeTags.includes(id));
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -1,14 +0,0 @@
|
||||
export * from './components/favorite-tag';
|
||||
export * from './components/floating-toobar';
|
||||
export * from './components/new-page-buttton';
|
||||
export * from './filter';
|
||||
export * from './operation-cell';
|
||||
export * from './operation-menu-items';
|
||||
export * from './page-list';
|
||||
export * from './page-list-item';
|
||||
export * from './page-tags';
|
||||
export * from './types';
|
||||
export * from './use-collection-manager';
|
||||
export * from './utils';
|
||||
export * from './view';
|
||||
export * from './virtualized-page-list';
|
||||
@@ -1,176 +0,0 @@
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import {
|
||||
DeletePermanentlyIcon,
|
||||
FavoritedIcon,
|
||||
FavoriteIcon,
|
||||
MoreVerticalIcon,
|
||||
OpenInNewIcon,
|
||||
ResetIcon,
|
||||
} from '@blocksuite/icons';
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { IconButton } from '../../ui/button';
|
||||
import { Menu, MenuIcon, MenuItem } from '../../ui/menu';
|
||||
import { ConfirmModal } from '../../ui/modal';
|
||||
import { Tooltip } from '../../ui/tooltip';
|
||||
import { FavoriteTag } from './components/favorite-tag';
|
||||
import { DisablePublicSharing, MoveToTrash } from './operation-menu-items';
|
||||
import * as styles from './page-list.css';
|
||||
import { ColWrapper, stopPropagationWithoutPrevent } from './utils';
|
||||
|
||||
export interface OperationCellProps {
|
||||
favorite: boolean;
|
||||
isPublic: boolean;
|
||||
link: string;
|
||||
onToggleFavoritePage: () => void;
|
||||
onRemoveToTrash: () => void;
|
||||
onDisablePublicSharing: () => void;
|
||||
}
|
||||
|
||||
export const OperationCell = ({
|
||||
favorite,
|
||||
isPublic,
|
||||
link,
|
||||
onToggleFavoritePage,
|
||||
onRemoveToTrash,
|
||||
onDisablePublicSharing,
|
||||
}: OperationCellProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const [openDisableShared, setOpenDisableShared] = useState(false);
|
||||
const OperationMenu = (
|
||||
<>
|
||||
{isPublic && (
|
||||
<DisablePublicSharing
|
||||
data-testid="disable-public-sharing"
|
||||
onSelect={() => {
|
||||
setOpenDisableShared(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<MenuItem
|
||||
onClick={onToggleFavoritePage}
|
||||
preFix={
|
||||
<MenuIcon>
|
||||
{favorite ? (
|
||||
<FavoritedIcon style={{ color: 'var(--affine-primary-color)' }} />
|
||||
) : (
|
||||
<FavoriteIcon />
|
||||
)}
|
||||
</MenuIcon>
|
||||
}
|
||||
>
|
||||
{favorite
|
||||
? t['com.affine.favoritePageOperation.remove']()
|
||||
: t['com.affine.favoritePageOperation.add']()}
|
||||
</MenuItem>
|
||||
{!environment.isDesktop && (
|
||||
<Link
|
||||
className={styles.clearLinkStyle}
|
||||
onClick={stopPropagationWithoutPrevent}
|
||||
to={link}
|
||||
target={'_blank'}
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<MenuItem
|
||||
style={{ marginBottom: 4 }}
|
||||
preFix={
|
||||
<MenuIcon>
|
||||
<OpenInNewIcon />
|
||||
</MenuIcon>
|
||||
}
|
||||
>
|
||||
{t['com.affine.openPageOperation.newTab']()}
|
||||
</MenuItem>
|
||||
</Link>
|
||||
)}
|
||||
<MoveToTrash data-testid="move-to-trash" onSelect={onRemoveToTrash} />
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<ColWrapper
|
||||
hideInSmallContainer
|
||||
data-testid="page-list-item-favorite"
|
||||
data-favorite={favorite ? true : undefined}
|
||||
className={styles.favoriteCell}
|
||||
>
|
||||
<FavoriteTag onClick={onToggleFavoritePage} active={favorite} />
|
||||
</ColWrapper>
|
||||
<ColWrapper alignment="start">
|
||||
<Menu
|
||||
items={OperationMenu}
|
||||
contentOptions={{
|
||||
align: 'end',
|
||||
}}
|
||||
>
|
||||
<IconButton type="plain" data-testid="page-list-operation-button">
|
||||
<MoreVerticalIcon />
|
||||
</IconButton>
|
||||
</Menu>
|
||||
</ColWrapper>
|
||||
<DisablePublicSharing.DisablePublicSharingModal
|
||||
onConfirm={onDisablePublicSharing}
|
||||
open={openDisableShared}
|
||||
onOpenChange={setOpenDisableShared}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export interface TrashOperationCellProps {
|
||||
onPermanentlyDeletePage: () => void;
|
||||
onRestorePage: () => void;
|
||||
}
|
||||
|
||||
export const TrashOperationCell = ({
|
||||
onPermanentlyDeletePage,
|
||||
onRestorePage,
|
||||
}: TrashOperationCellProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<ColWrapper flex={1}>
|
||||
<Tooltip content={t['com.affine.trashOperation.restoreIt']()} side="top">
|
||||
<IconButton
|
||||
data-testid="restore-page-button"
|
||||
style={{ marginRight: '12px' }}
|
||||
onClick={() => {
|
||||
onRestorePage();
|
||||
}}
|
||||
>
|
||||
<ResetIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={t['com.affine.trashOperation.deletePermanently']()}
|
||||
side="top"
|
||||
align="end"
|
||||
>
|
||||
<IconButton
|
||||
data-testid="delete-page-button"
|
||||
onClick={() => {
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
<DeletePermanentlyIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<ConfirmModal
|
||||
title={`${t['com.affine.trashOperation.deletePermanently']()}?`}
|
||||
description={t['com.affine.trashOperation.deleteDescription']()}
|
||||
cancelText={t['com.affine.confirmModal.button.cancel']()}
|
||||
confirmButtonOptions={{
|
||||
type: 'error',
|
||||
children: t['com.affine.trashOperation.delete'](),
|
||||
}}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
onConfirm={() => {
|
||||
onPermanentlyDeletePage();
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
</ColWrapper>
|
||||
);
|
||||
};
|
||||
@@ -1,24 +0,0 @@
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { ShareIcon } from '@blocksuite/icons';
|
||||
|
||||
import { MenuIcon, MenuItem, type MenuItemProps } from '../../../ui/menu';
|
||||
import { PublicLinkDisableModal } from '../../disable-public-link';
|
||||
|
||||
export const DisablePublicSharing = (props: MenuItemProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
return (
|
||||
<MenuItem
|
||||
type="danger"
|
||||
preFix={
|
||||
<MenuIcon>
|
||||
<ShareIcon />
|
||||
</MenuIcon>
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
{t['Disable Public Sharing']()}
|
||||
</MenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
DisablePublicSharing.DisablePublicSharingModal = PublicLinkDisableModal;
|
||||
@@ -1,133 +0,0 @@
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import {
|
||||
ExportIcon,
|
||||
ExportToHtmlIcon,
|
||||
ExportToMarkdownIcon,
|
||||
ExportToPdfIcon,
|
||||
ExportToPngIcon,
|
||||
} from '@blocksuite/icons';
|
||||
import { type ReactNode, useMemo } from 'react';
|
||||
|
||||
import { MenuIcon, MenuItem, MenuSub } from '../../../ui/menu';
|
||||
import { transitionStyle } from './index.css';
|
||||
|
||||
interface ExportMenuItemProps<T> {
|
||||
onSelect: () => void;
|
||||
className?: string;
|
||||
type: T;
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface ExportProps {
|
||||
exportHandler: (type: 'pdf' | 'html' | 'png' | 'markdown') => Promise<void>;
|
||||
pageMode?: 'page' | 'edgeless';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ExportMenuItem<T>({
|
||||
onSelect,
|
||||
className,
|
||||
type,
|
||||
icon,
|
||||
label,
|
||||
}: ExportMenuItemProps<T>) {
|
||||
return (
|
||||
<MenuItem
|
||||
className={className}
|
||||
data-testid={`export-to-${type}`}
|
||||
onSelect={onSelect}
|
||||
block
|
||||
preFix={<MenuIcon>{icon}</MenuIcon>}
|
||||
>
|
||||
{label}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
export const ExportMenuItems = ({
|
||||
exportHandler,
|
||||
className = transitionStyle,
|
||||
pageMode = 'page',
|
||||
}: ExportProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const itemMap = useMemo(
|
||||
() => [
|
||||
{
|
||||
component: ExportMenuItem,
|
||||
props: {
|
||||
onSelect: () => exportHandler('pdf'),
|
||||
className: className,
|
||||
type: 'pdf',
|
||||
icon: <ExportToPdfIcon />,
|
||||
label: t['Export to PDF'](),
|
||||
},
|
||||
},
|
||||
{
|
||||
component: ExportMenuItem,
|
||||
props: {
|
||||
onSelect: () => exportHandler('html'),
|
||||
className: className,
|
||||
type: 'html',
|
||||
icon: <ExportToHtmlIcon />,
|
||||
label: t['Export to HTML'](),
|
||||
},
|
||||
},
|
||||
{
|
||||
component: ExportMenuItem,
|
||||
props: {
|
||||
onSelect: () => exportHandler('png'),
|
||||
className: className,
|
||||
type: 'png',
|
||||
icon: <ExportToPngIcon />,
|
||||
label: t['Export to PNG'](),
|
||||
},
|
||||
},
|
||||
{
|
||||
component: ExportMenuItem,
|
||||
props: {
|
||||
onSelect: () => exportHandler('markdown'),
|
||||
className: className,
|
||||
type: 'markdown',
|
||||
icon: <ExportToMarkdownIcon />,
|
||||
label: t['Export to Markdown'](),
|
||||
},
|
||||
},
|
||||
],
|
||||
[className, exportHandler, t]
|
||||
);
|
||||
const items = itemMap.map(({ component: Component, props }) =>
|
||||
pageMode === 'edgeless' &&
|
||||
(props.type === 'pdf' || props.type === 'png') ? null : (
|
||||
<Component key={props.label} {...props} />
|
||||
)
|
||||
);
|
||||
return items;
|
||||
};
|
||||
|
||||
export const Export = ({ exportHandler, className, pageMode }: ExportProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const items = (
|
||||
<ExportMenuItems
|
||||
exportHandler={exportHandler}
|
||||
className={className}
|
||||
pageMode={pageMode}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<MenuSub
|
||||
items={items}
|
||||
triggerOptions={{
|
||||
className: transitionStyle,
|
||||
preFix: (
|
||||
<MenuIcon>
|
||||
<ExportIcon />
|
||||
</MenuIcon>
|
||||
),
|
||||
['data-testid' as string]: 'export-menu',
|
||||
}}
|
||||
>
|
||||
{t.Export()}
|
||||
</MenuSub>
|
||||
);
|
||||
};
|
||||
@@ -1,18 +0,0 @@
|
||||
import { ContentParser } from '@blocksuite/blocks/content-parser';
|
||||
import type { Page } from '@blocksuite/store';
|
||||
|
||||
const contentParserWeakMap = new WeakMap<Page, ContentParser>();
|
||||
|
||||
export function getContentParser(page: Page) {
|
||||
if (!contentParserWeakMap.has(page)) {
|
||||
contentParserWeakMap.set(
|
||||
page,
|
||||
new ContentParser(page, {
|
||||
imageProxyEndpoint: !environment.isDesktop
|
||||
? runtimeConfig.imageProxyUrl
|
||||
: undefined,
|
||||
})
|
||||
);
|
||||
}
|
||||
return contentParserWeakMap.get(page) as ContentParser;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
|
||||
export const moveToTrashStyle = style({
|
||||
padding: '4px 12px',
|
||||
':hover': {
|
||||
backgroundColor: 'var(--affine-background-error-color)',
|
||||
color: 'var(--affine-error-color)',
|
||||
},
|
||||
});
|
||||
|
||||
globalStyle(`${moveToTrashStyle}:hover svg`, {
|
||||
color: 'var(--affine-error-color)',
|
||||
});
|
||||
|
||||
export const transitionStyle = style({
|
||||
transition: 'all 0.3s',
|
||||
});
|
||||
@@ -1,4 +0,0 @@
|
||||
export * from './disable-public-sharing';
|
||||
export * from './export';
|
||||
// export * from './MoveTo';
|
||||
export * from './move-to-trash';
|
||||
@@ -1,60 +0,0 @@
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { DeleteIcon } from '@blocksuite/icons';
|
||||
|
||||
import { MenuIcon, MenuItem, type MenuItemProps } from '../../../ui/menu';
|
||||
import { ConfirmModal, type ConfirmModalProps } from '../../../ui/modal';
|
||||
|
||||
export const MoveToTrash = (props: MenuItemProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
preFix={
|
||||
<MenuIcon>
|
||||
<DeleteIcon />
|
||||
</MenuIcon>
|
||||
}
|
||||
type="danger"
|
||||
{...props}
|
||||
>
|
||||
{t['com.affine.moveToTrash.title']()}
|
||||
</MenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
const MoveToTrashConfirm = ({
|
||||
titles,
|
||||
...confirmModalProps
|
||||
}: {
|
||||
titles: string[];
|
||||
} & ConfirmModalProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const multiple = titles.length > 1;
|
||||
const title = multiple
|
||||
? t['com.affine.moveToTrash.confirmModal.title.multiple']({
|
||||
number: titles.length.toString(),
|
||||
})
|
||||
: t['com.affine.moveToTrash.confirmModal.title']();
|
||||
const description = multiple
|
||||
? t['com.affine.moveToTrash.confirmModal.description.multiple']({
|
||||
number: titles.length.toString(),
|
||||
})
|
||||
: t['com.affine.moveToTrash.confirmModal.description']({
|
||||
title: titles[0] || t['Untitled'](),
|
||||
});
|
||||
return (
|
||||
<ConfirmModal
|
||||
title={title}
|
||||
description={description}
|
||||
cancelText={t['com.affine.confirmModal.button.cancel']()}
|
||||
confirmButtonOptions={{
|
||||
['data-testid' as string]: 'confirm-delete-page',
|
||||
type: 'error',
|
||||
children: t.Delete(),
|
||||
}}
|
||||
{...confirmModalProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
MoveToTrash.ConfirmModal = MoveToTrashConfirm;
|
||||
@@ -1,8 +0,0 @@
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
export type CommonMenuItemProps<SelectParams = undefined> = {
|
||||
// onItemClick is triggered when the item is clicked, sometimes after item click, it still has some internal logic to run(like popover a new menu), so we need to have a separate callback for that
|
||||
onItemClick?: () => void;
|
||||
// onSelect is triggered when the item is selected, it's the final callback for the item click
|
||||
onSelect?: (params?: SelectParams) => void;
|
||||
} & HTMLAttributes<HTMLButtonElement>;
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { Workspace } from '@blocksuite/store';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
import { useBlockSuitePagePreview } from './use-block-suite-page-preview';
|
||||
import { useBlockSuiteWorkspacePage } from './use-block-suite-workspace-page';
|
||||
|
||||
interface PagePreviewInnerProps {
|
||||
workspace: Workspace;
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
const PagePreviewInner = ({ workspace, pageId }: PagePreviewInnerProps) => {
|
||||
const page = useBlockSuiteWorkspacePage(workspace, pageId);
|
||||
const previewAtom = useBlockSuitePagePreview(page);
|
||||
const preview = useAtomValue(previewAtom);
|
||||
return preview ? preview : null;
|
||||
};
|
||||
|
||||
interface PagePreviewProps {
|
||||
workspace: Workspace;
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
export const PagePreview = ({ workspace, pageId }: PagePreviewProps) => {
|
||||
return (
|
||||
<Suspense>
|
||||
<PagePreviewInner workspace={workspace} pageId={pageId} />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
@@ -1,134 +0,0 @@
|
||||
import { keyframes, style } from '@vanilla-extract/css';
|
||||
|
||||
export const root = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 6,
|
||||
});
|
||||
|
||||
const slideDown = keyframes({
|
||||
'0%': {
|
||||
opacity: 0,
|
||||
height: '0px',
|
||||
},
|
||||
'100%': {
|
||||
opacity: 1,
|
||||
height: 'var(--radix-collapsible-content-height)',
|
||||
},
|
||||
});
|
||||
|
||||
const slideUp = keyframes({
|
||||
'0%': {
|
||||
opacity: 1,
|
||||
height: 'var(--radix-collapsible-content-height)',
|
||||
},
|
||||
'100%': {
|
||||
opacity: 0,
|
||||
height: '0px',
|
||||
},
|
||||
});
|
||||
|
||||
export const collapsibleContent = style({
|
||||
overflow: 'hidden',
|
||||
selectors: {
|
||||
'&[data-state="open"]': {
|
||||
animation: `${slideDown} 0.3s ease-in-out`,
|
||||
},
|
||||
'&[data-state="closed"]': {
|
||||
animation: `${slideUp} 0.3s ease-in-out`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const collapsibleContentInner = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
});
|
||||
|
||||
export const header = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0px 16px 0px 6px',
|
||||
gap: 4,
|
||||
height: '28px',
|
||||
background: 'var(--affine-background-primary-color)',
|
||||
':hover': {
|
||||
background: 'var(--affine-hover-color-filled)',
|
||||
},
|
||||
userSelect: 'none',
|
||||
});
|
||||
|
||||
export const spacer = style({
|
||||
flex: 1,
|
||||
});
|
||||
|
||||
export const headerCollapseIcon = style({
|
||||
cursor: 'pointer',
|
||||
});
|
||||
|
||||
export const headerLabel = style({
|
||||
fontSize: 'var(--affine-font-sm)',
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
});
|
||||
|
||||
export const headerCount = style({
|
||||
fontSize: 'var(--affine-font-sm)',
|
||||
color: 'var(--affine-text-disable-color)',
|
||||
});
|
||||
|
||||
export const selectAllButton = style({
|
||||
display: 'flex',
|
||||
opacity: 0,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
height: '20px',
|
||||
borderRadius: 4,
|
||||
padding: '0 8px',
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
background: 'var(--affine-hover-color)',
|
||||
},
|
||||
[`${header}:hover &`]: {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const collapsedIcon = style({
|
||||
opacity: 0,
|
||||
transition: 'transform 0.2s ease-in-out',
|
||||
selectors: {
|
||||
'&[data-collapsed="false"]': {
|
||||
transform: 'rotate(90deg)',
|
||||
},
|
||||
[`${header}:hover &, &[data-collapsed="true"]`]: {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const collapsedIconContainer = style({
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '2px',
|
||||
transition: 'transform 0.2s',
|
||||
color: 'inherit',
|
||||
selectors: {
|
||||
'&[data-collapsed="true"]': {
|
||||
transform: 'rotate(-90deg)',
|
||||
},
|
||||
'&[data-disabled="true"]': {
|
||||
opacity: 0.3,
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
'&:hover': {
|
||||
background: 'var(--affine-hover-color)',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,270 +0,0 @@
|
||||
import type { Tag } from '@affine/env/filter';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
import { EdgelessIcon, PageIcon, ToggleCollapseIcon } from '@blocksuite/icons';
|
||||
import type { PageMeta, Workspace } from '@blocksuite/store';
|
||||
import * as Collapsible from '@radix-ui/react-collapsible';
|
||||
import clsx from 'clsx';
|
||||
import { selectAtom } from 'jotai/utils';
|
||||
import { type MouseEventHandler, useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { PagePreview } from './page-content-preview';
|
||||
import * as styles from './page-group.css';
|
||||
import { PageListItem } from './page-list-item';
|
||||
import {
|
||||
pageGroupCollapseStateAtom,
|
||||
pageListPropsAtom,
|
||||
selectionStateAtom,
|
||||
useAtom,
|
||||
useAtomValue,
|
||||
} from './scoped-atoms';
|
||||
import type { PageGroupProps, PageListItemProps, PageListProps } from './types';
|
||||
import { shallowEqual } from './utils';
|
||||
|
||||
export const PageGroupHeader = ({ id, items, label }: PageGroupProps) => {
|
||||
const [collapseState, setCollapseState] = useAtom(pageGroupCollapseStateAtom);
|
||||
const collapsed = collapseState[id];
|
||||
const onExpandedClicked: MouseEventHandler = useCallback(
|
||||
e => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setCollapseState(v => ({ ...v, [id]: !v[id] }));
|
||||
},
|
||||
[id, setCollapseState]
|
||||
);
|
||||
|
||||
const [selectionState, setSelectionActive] = useAtom(selectionStateAtom);
|
||||
const selectedItems = useMemo(() => {
|
||||
const selectedPageIds = selectionState.selectedPageIds ?? [];
|
||||
return items.filter(item => selectedPageIds.includes(item.id));
|
||||
}, [items, selectionState.selectedPageIds]);
|
||||
|
||||
const allSelected = selectedItems.length === items.length;
|
||||
|
||||
const onSelectAll = useCallback(() => {
|
||||
// also enable selection active
|
||||
setSelectionActive(true);
|
||||
|
||||
const nonCurrentGroupIds =
|
||||
selectionState.selectedPageIds?.filter(
|
||||
id => !items.map(item => item.id).includes(id)
|
||||
) ?? [];
|
||||
|
||||
const newSelectedPageIds = allSelected
|
||||
? nonCurrentGroupIds
|
||||
: [...nonCurrentGroupIds, ...items.map(item => item.id)];
|
||||
|
||||
selectionState.onSelectedPageIdsChange?.(newSelectedPageIds);
|
||||
}, [setSelectionActive, selectionState, allSelected, items]);
|
||||
|
||||
const t = useAFFiNEI18N();
|
||||
|
||||
return label ? (
|
||||
<div
|
||||
data-testid="page-list-group-header"
|
||||
className={styles.header}
|
||||
data-group-id={id}
|
||||
data-group-items-count={items.length}
|
||||
data-group-selected-items-count={selectedItems.length}
|
||||
>
|
||||
<div
|
||||
role="button"
|
||||
onClick={onExpandedClicked}
|
||||
data-testid="page-list-group-header-collapsed-button"
|
||||
className={styles.collapsedIconContainer}
|
||||
>
|
||||
<ToggleCollapseIcon
|
||||
className={styles.collapsedIcon}
|
||||
data-collapsed={!!collapsed}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.headerLabel}>{label}</div>
|
||||
{selectionState.selectionActive ? (
|
||||
<div className={styles.headerCount}>
|
||||
{selectedItems.length}/{items.length}
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.spacer} />
|
||||
<button className={styles.selectAllButton} onClick={onSelectAll}>
|
||||
{t[
|
||||
allSelected
|
||||
? 'com.affine.page.group-header.clear'
|
||||
: 'com.affine.page.group-header.select-all'
|
||||
]()}
|
||||
</button>
|
||||
</div>
|
||||
) : null;
|
||||
};
|
||||
|
||||
export const PageGroup = ({ id, items, label }: PageGroupProps) => {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const onExpandedClicked: MouseEventHandler = useCallback(e => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setCollapsed(v => !v);
|
||||
}, []);
|
||||
const selectionState = useAtomValue(selectionStateAtom);
|
||||
const selectedItems = useMemo(() => {
|
||||
const selectedPageIds = selectionState.selectedPageIds ?? [];
|
||||
return items.filter(item => selectedPageIds.includes(item.id));
|
||||
}, [items, selectionState.selectedPageIds]);
|
||||
const onSelectAll = useCallback(() => {
|
||||
const nonCurrentGroupIds =
|
||||
selectionState.selectedPageIds?.filter(
|
||||
id => !items.map(item => item.id).includes(id)
|
||||
) ?? [];
|
||||
|
||||
selectionState.onSelectedPageIdsChange?.([
|
||||
...nonCurrentGroupIds,
|
||||
...items.map(item => item.id),
|
||||
]);
|
||||
}, [items, selectionState]);
|
||||
const t = useAFFiNEI18N();
|
||||
return (
|
||||
<Collapsible.Root
|
||||
data-testid="page-list-group"
|
||||
data-group-id={id}
|
||||
open={!collapsed}
|
||||
className={clsx(styles.root)}
|
||||
>
|
||||
{label ? (
|
||||
<div data-testid="page-list-group-header" className={styles.header}>
|
||||
<Collapsible.Trigger
|
||||
role="button"
|
||||
onClick={onExpandedClicked}
|
||||
data-testid="page-list-group-header-collapsed-button"
|
||||
className={styles.collapsedIconContainer}
|
||||
>
|
||||
<ToggleCollapseIcon
|
||||
className={styles.collapsedIcon}
|
||||
data-collapsed={collapsed !== false}
|
||||
/>
|
||||
</Collapsible.Trigger>
|
||||
<div className={styles.headerLabel}>{label}</div>
|
||||
{selectionState.selectionActive ? (
|
||||
<div className={styles.headerCount}>
|
||||
{selectedItems.length}/{items.length}
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.spacer} />
|
||||
{selectionState.selectionActive ? (
|
||||
<button className={styles.selectAllButton} onClick={onSelectAll}>
|
||||
{t['com.affine.page.group-header.select-all']()}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<Collapsible.Content className={styles.collapsibleContent}>
|
||||
<div className={styles.collapsibleContentInner}>
|
||||
{items.map(item => (
|
||||
<PageMetaListItemRenderer key={item.id} {...item} />
|
||||
))}
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
);
|
||||
};
|
||||
|
||||
// todo: optimize how to render page meta list item
|
||||
const requiredPropNames = [
|
||||
'blockSuiteWorkspace',
|
||||
'rowAsLink',
|
||||
'isPreferredEdgeless',
|
||||
'pageOperationsRenderer',
|
||||
'selectedPageIds',
|
||||
'onSelectedPageIdsChange',
|
||||
'draggable',
|
||||
] as const;
|
||||
|
||||
type RequiredProps = Pick<PageListProps, (typeof requiredPropNames)[number]> & {
|
||||
selectable: boolean;
|
||||
};
|
||||
|
||||
const listPropsAtom = selectAtom(
|
||||
pageListPropsAtom,
|
||||
props => {
|
||||
return Object.fromEntries(
|
||||
requiredPropNames.map(name => [name, props[name]])
|
||||
) as RequiredProps;
|
||||
},
|
||||
shallowEqual
|
||||
);
|
||||
|
||||
export const PageMetaListItemRenderer = (pageMeta: PageMeta) => {
|
||||
const props = useAtomValue(listPropsAtom);
|
||||
const { selectionActive } = useAtomValue(selectionStateAtom);
|
||||
return (
|
||||
<PageListItem
|
||||
{...pageMetaToPageItemProp(pageMeta, {
|
||||
...props,
|
||||
selectable: !!selectionActive,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
function tagIdToTagOption(
|
||||
tagId: string,
|
||||
blockSuiteWorkspace: Workspace
|
||||
): Tag | undefined {
|
||||
return blockSuiteWorkspace.meta.properties.tags?.options.find(
|
||||
opt => opt.id === tagId
|
||||
);
|
||||
}
|
||||
|
||||
function pageMetaToPageItemProp(
|
||||
pageMeta: PageMeta,
|
||||
props: RequiredProps
|
||||
): PageListItemProps {
|
||||
const toggleSelection = props.onSelectedPageIdsChange
|
||||
? () => {
|
||||
assertExists(props.selectedPageIds);
|
||||
const prevSelected = props.selectedPageIds.includes(pageMeta.id);
|
||||
const shouldAdd = !prevSelected;
|
||||
const shouldRemove = prevSelected;
|
||||
|
||||
if (shouldAdd) {
|
||||
props.onSelectedPageIdsChange?.([
|
||||
...props.selectedPageIds,
|
||||
pageMeta.id,
|
||||
]);
|
||||
} else if (shouldRemove) {
|
||||
props.onSelectedPageIdsChange?.(
|
||||
props.selectedPageIds.filter(id => id !== pageMeta.id)
|
||||
);
|
||||
}
|
||||
}
|
||||
: undefined;
|
||||
const itemProps: PageListItemProps = {
|
||||
pageId: pageMeta.id,
|
||||
title: pageMeta.title,
|
||||
preview: (
|
||||
<PagePreview workspace={props.blockSuiteWorkspace} pageId={pageMeta.id} />
|
||||
),
|
||||
createDate: new Date(pageMeta.createDate),
|
||||
updatedDate: pageMeta.updatedDate
|
||||
? new Date(pageMeta.updatedDate)
|
||||
: undefined,
|
||||
to:
|
||||
props.rowAsLink && !props.selectable
|
||||
? `/workspace/${props.blockSuiteWorkspace.id}/${pageMeta.id}`
|
||||
: undefined,
|
||||
onClick: props.selectable ? toggleSelection : undefined,
|
||||
icon: props.isPreferredEdgeless?.(pageMeta.id) ? (
|
||||
<EdgelessIcon />
|
||||
) : (
|
||||
<PageIcon />
|
||||
),
|
||||
tags:
|
||||
pageMeta.tags
|
||||
?.map(id => tagIdToTagOption(id, props.blockSuiteWorkspace))
|
||||
.filter((v): v is Tag => v != null) ?? [],
|
||||
operations: props.pageOperationsRenderer?.(pageMeta),
|
||||
selectable: props.selectable,
|
||||
selected: props.selectedPageIds?.includes(pageMeta.id),
|
||||
onSelectedChange: toggleSelection,
|
||||
draggable: props.draggable,
|
||||
isPublicPage: !!pageMeta.isPublic,
|
||||
};
|
||||
return itemProps;
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { MultiSelectIcon, SortDownIcon, SortUpIcon } from '@blocksuite/icons';
|
||||
import type { PageMeta } from '@blocksuite/store';
|
||||
import clsx from 'clsx';
|
||||
import { selectAtom } from 'jotai/utils';
|
||||
import {
|
||||
type MouseEventHandler,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
|
||||
import { Checkbox, type CheckboxProps } from '../../ui/checkbox';
|
||||
import * as styles from './page-list.css';
|
||||
import {
|
||||
pageListHandlersAtom,
|
||||
pageListPropsAtom,
|
||||
pagesAtom,
|
||||
selectionStateAtom,
|
||||
showOperationsAtom,
|
||||
sorterAtom,
|
||||
useAtom,
|
||||
useAtomValue,
|
||||
} from './scoped-atoms';
|
||||
import { ColWrapper, type ColWrapperProps, stopPropagation } from './utils';
|
||||
|
||||
export const PageListHeaderCell = (props: HeaderCellProps) => {
|
||||
const [sorter, setSorter] = useAtom(sorterAtom);
|
||||
const onClick: MouseEventHandler = useCallback(() => {
|
||||
if (props.sortable && props.sortKey) {
|
||||
setSorter({
|
||||
newSortKey: props.sortKey,
|
||||
});
|
||||
}
|
||||
}, [props.sortKey, props.sortable, setSorter]);
|
||||
|
||||
const sorting = sorter.key === props.sortKey;
|
||||
|
||||
return (
|
||||
<ColWrapper
|
||||
flex={props.flex}
|
||||
alignment={props.alignment}
|
||||
onClick={onClick}
|
||||
className={styles.headerCell}
|
||||
data-sortable={props.sortable ? true : undefined}
|
||||
data-sorting={sorting ? true : undefined}
|
||||
style={props.style}
|
||||
role="columnheader"
|
||||
hideInSmallContainer={props.hideInSmallContainer}
|
||||
>
|
||||
{props.children}
|
||||
{sorting ? (
|
||||
<div className={styles.headerCellSortIcon}>
|
||||
{sorter.order === 'asc' ? <SortUpIcon /> : <SortDownIcon />}
|
||||
</div>
|
||||
) : null}
|
||||
</ColWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
type HeaderColDef = {
|
||||
key: string;
|
||||
content: ReactNode;
|
||||
flex: ColWrapperProps['flex'];
|
||||
alignment?: ColWrapperProps['alignment'];
|
||||
sortable?: boolean;
|
||||
hideInSmallContainer?: boolean;
|
||||
};
|
||||
|
||||
type HeaderCellProps = ColWrapperProps & {
|
||||
sortKey: keyof PageMeta;
|
||||
sortable?: boolean;
|
||||
};
|
||||
|
||||
// the checkbox on the header has three states:
|
||||
// when list selectable = true, the checkbox will be presented
|
||||
// when internal selection state is not enabled, it is a clickable <ListIcon /> that enables the selection state
|
||||
// when internal selection state is enabled, it is a checkbox that reflects the selection state
|
||||
const PageListHeaderCheckbox = () => {
|
||||
const [selectionState, setSelectionState] = useAtom(selectionStateAtom);
|
||||
const pages = useAtomValue(pagesAtom);
|
||||
const onActivateSelection: MouseEventHandler = useCallback(
|
||||
e => {
|
||||
stopPropagation(e);
|
||||
setSelectionState(true);
|
||||
},
|
||||
[setSelectionState]
|
||||
);
|
||||
const handlers = useAtomValue(pageListHandlersAtom);
|
||||
const onChange: NonNullable<CheckboxProps['onChange']> = useCallback(
|
||||
(e, checked) => {
|
||||
stopPropagation(e);
|
||||
handlers.onSelectedPageIdsChange?.(checked ? pages.map(p => p.id) : []);
|
||||
},
|
||||
[handlers, pages]
|
||||
);
|
||||
|
||||
if (!selectionState.selectable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="page-list-header-selection-checkbox"
|
||||
className={styles.headerTitleSelectionIconWrapper}
|
||||
onClick={onActivateSelection}
|
||||
>
|
||||
{!selectionState.selectionActive ? (
|
||||
<MultiSelectIcon />
|
||||
) : (
|
||||
<Checkbox
|
||||
checked={selectionState.selectedPageIds?.length === pages.length}
|
||||
indeterminate={
|
||||
selectionState.selectedPageIds &&
|
||||
selectionState.selectedPageIds.length > 0 &&
|
||||
selectionState.selectedPageIds.length < pages.length
|
||||
}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PageListHeaderTitleCell = () => {
|
||||
const t = useAFFiNEI18N();
|
||||
return (
|
||||
<div className={styles.headerTitleCell}>
|
||||
<PageListHeaderCheckbox />
|
||||
{t['Title']()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const hideHeaderAtom = selectAtom(pageListPropsAtom, props => props.hideHeader);
|
||||
|
||||
// the table header for page list
|
||||
export const PageListTableHeader = () => {
|
||||
const t = useAFFiNEI18N();
|
||||
const showOperations = useAtomValue(showOperationsAtom);
|
||||
const hideHeader = useAtomValue(hideHeaderAtom);
|
||||
const selectionState = useAtomValue(selectionStateAtom);
|
||||
const headerCols = useMemo(() => {
|
||||
const cols: (HeaderColDef | boolean)[] = [
|
||||
{
|
||||
key: 'title',
|
||||
content: <PageListHeaderTitleCell />,
|
||||
flex: 6,
|
||||
alignment: 'start',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
key: 'tags',
|
||||
content: t['Tags'](),
|
||||
flex: 3,
|
||||
alignment: 'end',
|
||||
},
|
||||
{
|
||||
key: 'createDate',
|
||||
content: t['Created'](),
|
||||
flex: 1,
|
||||
sortable: true,
|
||||
alignment: 'end',
|
||||
hideInSmallContainer: true,
|
||||
},
|
||||
{
|
||||
key: 'updatedDate',
|
||||
content: t['Updated'](),
|
||||
flex: 1,
|
||||
sortable: true,
|
||||
alignment: 'end',
|
||||
hideInSmallContainer: true,
|
||||
},
|
||||
showOperations && {
|
||||
key: 'actions',
|
||||
content: '',
|
||||
flex: 1,
|
||||
alignment: 'end',
|
||||
},
|
||||
];
|
||||
return cols.filter((def): def is HeaderColDef => !!def);
|
||||
}, [t, showOperations]);
|
||||
|
||||
if (hideHeader) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(styles.tableHeader)}
|
||||
data-selectable={selectionState.selectable}
|
||||
data-selection-active={selectionState.selectionActive}
|
||||
>
|
||||
{headerCols.map(col => {
|
||||
return (
|
||||
<PageListHeaderCell
|
||||
flex={col.flex}
|
||||
alignment={col.alignment}
|
||||
key={col.key}
|
||||
sortKey={col.key as keyof PageMeta}
|
||||
sortable={col.sortable}
|
||||
style={{ overflow: 'visible' }}
|
||||
hideInSmallContainer={col.hideInSmallContainer}
|
||||
>
|
||||
{col.content}
|
||||
</PageListHeaderCell>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,181 +0,0 @@
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
|
||||
export const root = style({
|
||||
display: 'flex',
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
height: '54px', // 42 + 12
|
||||
flexShrink: 0,
|
||||
width: '100%',
|
||||
alignItems: 'stretch',
|
||||
transition: 'background-color 0.2s, opacity 0.2s',
|
||||
':hover': {
|
||||
backgroundColor: 'var(--affine-hover-color)',
|
||||
},
|
||||
overflow: 'hidden',
|
||||
cursor: 'default',
|
||||
willChange: 'opacity',
|
||||
selectors: {
|
||||
'&[data-clickable=true]': {
|
||||
cursor: 'pointer',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const dragOverlay = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
zIndex: 1001,
|
||||
cursor: 'grabbing',
|
||||
maxWidth: '360px',
|
||||
transition: 'transform 0.2s',
|
||||
willChange: 'transform',
|
||||
selectors: {
|
||||
'&[data-over=true]': {
|
||||
transform: 'scale(0.8)',
|
||||
},
|
||||
},
|
||||
});
|
||||
export const dragPageItemOverlay = style({
|
||||
height: '54px',
|
||||
borderRadius: '10px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
background: 'var(--affine-hover-color-filled)',
|
||||
boxShadow: 'var(--affine-menu-shadow)',
|
||||
maxWidth: '360px',
|
||||
minWidth: '260px',
|
||||
});
|
||||
|
||||
export const dndCell = style({
|
||||
position: 'relative',
|
||||
marginLeft: -8,
|
||||
height: '100%',
|
||||
outline: 'none',
|
||||
paddingLeft: 8,
|
||||
});
|
||||
|
||||
globalStyle(`[data-draggable=true] ${dndCell}:before`, {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
left: 0,
|
||||
width: 4,
|
||||
height: 4,
|
||||
transition: 'height 0.2s, opacity 0.2s',
|
||||
backgroundColor: 'var(--affine-placeholder-color)',
|
||||
borderRadius: '2px',
|
||||
opacity: 0,
|
||||
willChange: 'height, opacity',
|
||||
});
|
||||
|
||||
globalStyle(`[data-draggable=true] ${dndCell}:hover:before`, {
|
||||
height: 12,
|
||||
opacity: 1,
|
||||
});
|
||||
|
||||
globalStyle(`[data-draggable=true][data-dragging=true] ${dndCell}`, {
|
||||
opacity: 0.5,
|
||||
});
|
||||
|
||||
globalStyle(`[data-draggable=true][data-dragging=true] ${dndCell}:before`, {
|
||||
height: 32,
|
||||
width: 2,
|
||||
opacity: 1,
|
||||
});
|
||||
|
||||
// todo: remove global style
|
||||
globalStyle(`${root} > :first-child`, {
|
||||
paddingLeft: '16px',
|
||||
});
|
||||
|
||||
globalStyle(`${root} > :last-child`, {
|
||||
paddingRight: '8px',
|
||||
});
|
||||
|
||||
export const titleIconsWrapper = style({
|
||||
padding: '0 5px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
});
|
||||
|
||||
export const selectionCell = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
fontSize: 'var(--affine-font-h-3)',
|
||||
});
|
||||
|
||||
export const titleCell = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
padding: '0 16px',
|
||||
maxWidth: 'calc(100% - 64px)',
|
||||
flex: 1,
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
|
||||
export const titleCellMain = style({
|
||||
overflow: 'hidden',
|
||||
fontSize: 'var(--affine-font-sm)',
|
||||
fontWeight: 600,
|
||||
whiteSpace: 'nowrap',
|
||||
flex: 1,
|
||||
textOverflow: 'ellipsis',
|
||||
alignSelf: 'stretch',
|
||||
});
|
||||
|
||||
export const titleCellPreview = style({
|
||||
overflow: 'hidden',
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
flex: 1,
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis',
|
||||
alignSelf: 'stretch',
|
||||
});
|
||||
|
||||
export const iconCell = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
fontSize: 'var(--affine-font-h-3)',
|
||||
color: 'var(--affine-icon-color)',
|
||||
flexShrink: 0,
|
||||
});
|
||||
|
||||
export const tagsCell = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
padding: '0 8px',
|
||||
height: '60px',
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
export const dateCell = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
flexShrink: 0,
|
||||
flexWrap: 'nowrap',
|
||||
padding: '0 8px',
|
||||
});
|
||||
|
||||
export const actionsCellWrapper = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
flexShrink: 0,
|
||||
});
|
||||
|
||||
export const operationsCell = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
columnGap: '6px',
|
||||
flexShrink: 0,
|
||||
});
|
||||
@@ -1,263 +0,0 @@
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { useDraggable } from '@dnd-kit/core';
|
||||
import { type PropsWithChildren, useCallback, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { Checkbox } from '../../ui/checkbox';
|
||||
import * as styles from './page-list-item.css';
|
||||
import { PageTags } from './page-tags';
|
||||
import type { DraggableTitleCellData, PageListItemProps } from './types';
|
||||
import { ColWrapper, formatDate, stopPropagation } from './utils';
|
||||
|
||||
const PageListTitleCell = ({
|
||||
title,
|
||||
preview,
|
||||
}: Pick<PageListItemProps, 'title' | 'preview'>) => {
|
||||
const t = useAFFiNEI18N();
|
||||
return (
|
||||
<div data-testid="page-list-item-title" className={styles.titleCell}>
|
||||
<div
|
||||
data-testid="page-list-item-title-text"
|
||||
className={styles.titleCellMain}
|
||||
>
|
||||
{title || t['Untitled']()}
|
||||
</div>
|
||||
{preview ? (
|
||||
<div
|
||||
data-testid="page-list-item-preview-text"
|
||||
className={styles.titleCellPreview}
|
||||
>
|
||||
{preview}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PageListIconCell = ({ icon }: Pick<PageListItemProps, 'icon'>) => {
|
||||
return (
|
||||
<div data-testid="page-list-item-icon" className={styles.iconCell}>
|
||||
{icon}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PageSelectionCell = ({
|
||||
selectable,
|
||||
onSelectedChange,
|
||||
selected,
|
||||
}: Pick<PageListItemProps, 'selectable' | 'onSelectedChange' | 'selected'>) => {
|
||||
const onSelectionChange = useCallback(
|
||||
(_event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
return onSelectedChange?.();
|
||||
},
|
||||
[onSelectedChange]
|
||||
);
|
||||
if (!selectable) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className={styles.selectionCell}>
|
||||
<Checkbox
|
||||
onClick={stopPropagation}
|
||||
checked={!!selected}
|
||||
onChange={onSelectionChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const PageTagsCell = ({ tags }: Pick<PageListItemProps, 'tags'>) => {
|
||||
return (
|
||||
<div data-testid="page-list-item-tags" className={styles.tagsCell}>
|
||||
<PageTags
|
||||
tags={tags}
|
||||
hoverExpandDirection="left"
|
||||
widthOnHover="300%"
|
||||
maxItems={5}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PageCreateDateCell = ({
|
||||
createDate,
|
||||
}: Pick<PageListItemProps, 'createDate'>) => {
|
||||
return (
|
||||
<div
|
||||
data-testid="page-list-item-date"
|
||||
data-date-raw={createDate}
|
||||
className={styles.dateCell}
|
||||
>
|
||||
{formatDate(createDate)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PageUpdatedDateCell = ({
|
||||
updatedDate,
|
||||
}: Pick<PageListItemProps, 'updatedDate'>) => {
|
||||
return (
|
||||
<div
|
||||
data-testid="page-list-item-date"
|
||||
data-date-raw={updatedDate}
|
||||
className={styles.dateCell}
|
||||
>
|
||||
{updatedDate ? formatDate(updatedDate) : '-'}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PageListOperationsCell = ({
|
||||
operations,
|
||||
}: Pick<PageListItemProps, 'operations'>) => {
|
||||
return operations ? (
|
||||
<div onClick={stopPropagation} className={styles.operationsCell}>
|
||||
{operations}
|
||||
</div>
|
||||
) : null;
|
||||
};
|
||||
|
||||
export const PageListItem = (props: PageListItemProps) => {
|
||||
const pageTitleElement = useMemo(() => {
|
||||
return (
|
||||
<div className={styles.dragPageItemOverlay}>
|
||||
<div className={styles.titleIconsWrapper}>
|
||||
<PageSelectionCell
|
||||
onSelectedChange={props.onSelectedChange}
|
||||
selectable={props.selectable}
|
||||
selected={props.selected}
|
||||
/>
|
||||
<PageListIconCell icon={props.icon} />
|
||||
</div>
|
||||
<PageListTitleCell title={props.title} preview={props.preview} />
|
||||
</div>
|
||||
);
|
||||
}, [
|
||||
props.icon,
|
||||
props.onSelectedChange,
|
||||
props.preview,
|
||||
props.selectable,
|
||||
props.selected,
|
||||
props.title,
|
||||
]);
|
||||
|
||||
// TODO: use getDropItemId
|
||||
const { setNodeRef, attributes, listeners, isDragging } = useDraggable({
|
||||
id: 'page-list-item-title-' + props.pageId,
|
||||
data: {
|
||||
pageId: props.pageId,
|
||||
pageTitle: pageTitleElement,
|
||||
} satisfies DraggableTitleCellData,
|
||||
disabled: !props.draggable,
|
||||
});
|
||||
|
||||
return (
|
||||
<PageListItemWrapper
|
||||
onClick={props.onClick}
|
||||
to={props.to}
|
||||
pageId={props.pageId}
|
||||
draggable={props.draggable}
|
||||
isDragging={isDragging}
|
||||
>
|
||||
<ColWrapper flex={9}>
|
||||
<ColWrapper
|
||||
className={styles.dndCell}
|
||||
flex={8}
|
||||
ref={setNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<div className={styles.titleIconsWrapper}>
|
||||
<PageSelectionCell
|
||||
onSelectedChange={props.onSelectedChange}
|
||||
selectable={props.selectable}
|
||||
selected={props.selected}
|
||||
/>
|
||||
<PageListIconCell icon={props.icon} />
|
||||
</div>
|
||||
<PageListTitleCell title={props.title} preview={props.preview} />
|
||||
</ColWrapper>
|
||||
<ColWrapper flex={4} alignment="end" style={{ overflow: 'visible' }}>
|
||||
<PageTagsCell tags={props.tags} />
|
||||
</ColWrapper>
|
||||
</ColWrapper>
|
||||
<ColWrapper flex={1} alignment="end" hideInSmallContainer>
|
||||
<PageCreateDateCell createDate={props.createDate} />
|
||||
</ColWrapper>
|
||||
<ColWrapper flex={1} alignment="end" hideInSmallContainer>
|
||||
<PageUpdatedDateCell updatedDate={props.updatedDate} />
|
||||
</ColWrapper>
|
||||
{props.operations ? (
|
||||
<ColWrapper
|
||||
className={styles.actionsCellWrapper}
|
||||
flex={1}
|
||||
alignment="end"
|
||||
>
|
||||
<PageListOperationsCell operations={props.operations} />
|
||||
</ColWrapper>
|
||||
) : null}
|
||||
</PageListItemWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
type PageListWrapperProps = PropsWithChildren<
|
||||
Pick<PageListItemProps, 'to' | 'pageId' | 'onClick' | 'draggable'> & {
|
||||
isDragging: boolean;
|
||||
}
|
||||
>;
|
||||
|
||||
function PageListItemWrapper({
|
||||
to,
|
||||
isDragging,
|
||||
pageId,
|
||||
onClick,
|
||||
children,
|
||||
draggable,
|
||||
}: PageListWrapperProps) {
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (onClick) {
|
||||
stopPropagation(e);
|
||||
onClick();
|
||||
}
|
||||
},
|
||||
[onClick]
|
||||
);
|
||||
|
||||
const commonProps = useMemo(
|
||||
() => ({
|
||||
'data-testid': 'page-list-item',
|
||||
'data-page-id': pageId,
|
||||
'data-draggable': draggable,
|
||||
className: styles.root,
|
||||
'data-clickable': !!onClick || !!to,
|
||||
'data-dragging': isDragging,
|
||||
onClick: handleClick,
|
||||
}),
|
||||
[pageId, draggable, isDragging, onClick, to, handleClick]
|
||||
);
|
||||
|
||||
if (to) {
|
||||
return (
|
||||
<Link {...commonProps} to={to}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
} else {
|
||||
return <div {...commonProps}>{children}</div>;
|
||||
}
|
||||
}
|
||||
|
||||
export const PageListDragOverlay = ({
|
||||
children,
|
||||
over,
|
||||
}: PropsWithChildren<{
|
||||
over?: boolean;
|
||||
}>) => {
|
||||
return (
|
||||
<div data-over={over} className={styles.dragOverlay}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,133 +0,0 @@
|
||||
import { createContainer, globalStyle, style } from '@vanilla-extract/css';
|
||||
|
||||
import * as itemStyles from './page-list-item.css';
|
||||
|
||||
export const listRootContainer = createContainer('list-root-container');
|
||||
|
||||
export const pageListScrollContainer = style({
|
||||
width: '100%',
|
||||
flex: 1,
|
||||
});
|
||||
|
||||
export const root = style({
|
||||
width: '100%',
|
||||
maxWidth: '100%',
|
||||
containerName: listRootContainer,
|
||||
containerType: 'inline-size',
|
||||
background: 'var(--affine-background-primary-color)',
|
||||
});
|
||||
|
||||
export const groupsContainer = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
rowGap: '16px',
|
||||
});
|
||||
|
||||
export const heading = style({});
|
||||
|
||||
export const tableHeader = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '10px 6px 10px 16px',
|
||||
position: 'sticky',
|
||||
overflow: 'hidden',
|
||||
zIndex: 1,
|
||||
top: 0,
|
||||
left: 0,
|
||||
background: 'var(--affine-background-primary-color)',
|
||||
transition: 'box-shadow 0.2s ease-in-out',
|
||||
transform: 'translateY(-0.5px)', // fix sticky look through issue
|
||||
});
|
||||
|
||||
globalStyle(`[data-has-scroll-top=true] ${tableHeader}`, {
|
||||
boxShadow: '0 1px var(--affine-border-color)',
|
||||
});
|
||||
|
||||
export const headerCell = style({
|
||||
padding: '0 8px',
|
||||
userSelect: 'none',
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
selectors: {
|
||||
'&[data-sorting], &:hover': {
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
},
|
||||
'&[data-sortable]': {
|
||||
cursor: 'pointer',
|
||||
},
|
||||
'&:not(:last-child)': {
|
||||
borderRight: '1px solid var(--affine-hover-color-filled)',
|
||||
},
|
||||
},
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
columnGap: '4px',
|
||||
position: 'relative',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
|
||||
export const headerTitleCell = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
});
|
||||
|
||||
export const headerTitleSelectionIconWrapper = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
fontSize: '16px',
|
||||
selectors: {
|
||||
[`${tableHeader}[data-selectable=toggle] &`]: {
|
||||
width: 32,
|
||||
},
|
||||
[`${tableHeader}[data-selection-active=true] &`]: {
|
||||
width: 24,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const headerCellSortIcon = style({
|
||||
display: 'inline-flex',
|
||||
fontSize: 14,
|
||||
color: 'var(--affine-icon-color)',
|
||||
});
|
||||
|
||||
export const colWrapper = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
export const hideInSmallContainer = style({
|
||||
'@container': {
|
||||
[`${listRootContainer} (max-width: 800px)`]: {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const favoriteCell = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
flexShrink: 0,
|
||||
opacity: 0,
|
||||
selectors: {
|
||||
[`&[data-favorite], ${itemStyles.root}:hover &`]: {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const clearLinkStyle = style({
|
||||
color: 'inherit',
|
||||
textDecoration: 'none',
|
||||
':visited': {
|
||||
color: 'inherit',
|
||||
},
|
||||
':active': {
|
||||
color: 'inherit',
|
||||
},
|
||||
});
|
||||
@@ -1,191 +0,0 @@
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
type ForwardedRef,
|
||||
forwardRef,
|
||||
memo,
|
||||
type PropsWithChildren,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
} from 'react';
|
||||
|
||||
import { Scrollable } from '../../ui/scrollbar';
|
||||
import { useHasScrollTop } from '../app-sidebar/sidebar-containers/use-has-scroll-top';
|
||||
import { PageGroup } from './page-group';
|
||||
import { PageListTableHeader } from './page-header';
|
||||
import * as styles from './page-list.css';
|
||||
import {
|
||||
pageGroupsAtom,
|
||||
pageListPropsAtom,
|
||||
PageListProvider,
|
||||
selectionStateAtom,
|
||||
useAtom,
|
||||
useAtomValue,
|
||||
useSetAtom,
|
||||
} from './scoped-atoms';
|
||||
import type { PageListHandle, PageListProps } from './types';
|
||||
|
||||
/**
|
||||
* Given a list of pages, render a list of pages
|
||||
*/
|
||||
export const PageList = forwardRef<PageListHandle, PageListProps>(
|
||||
function PageList(props, ref) {
|
||||
return (
|
||||
// push pageListProps to the atom so that downstream components can consume it
|
||||
// this makes sure pageListPropsAtom is always populated
|
||||
// @ts-expect-error fix type issues later
|
||||
<PageListProvider initialValues={[[pageListPropsAtom, props]]}>
|
||||
<PageListInnerWrapper {...props} handleRef={ref}>
|
||||
<PageListInner {...props} />
|
||||
</PageListInnerWrapper>
|
||||
</PageListProvider>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
// when pressing ESC or double clicking outside of the page list, close the selection mode
|
||||
// todo: use jotai-effect instead but it seems it does not work with jotai-scope?
|
||||
const usePageSelectionStateEffect = () => {
|
||||
const [selectionState, setSelectionActive] = useAtom(selectionStateAtom);
|
||||
useEffect(() => {
|
||||
if (
|
||||
selectionState.selectionActive &&
|
||||
selectionState.selectable === 'toggle'
|
||||
) {
|
||||
const startTime = Date.now();
|
||||
const dblClickHandler = (e: MouseEvent) => {
|
||||
if (Date.now() - startTime < 200) {
|
||||
return;
|
||||
}
|
||||
const target = e.target as HTMLElement;
|
||||
// skip if event target is inside of a button or input
|
||||
// or within a toolbar (like page list floating toolbar)
|
||||
if (
|
||||
target.tagName === 'BUTTON' ||
|
||||
target.tagName === 'INPUT' ||
|
||||
(e.target as HTMLElement).closest('button, input, [role="toolbar"]')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setSelectionActive(false);
|
||||
};
|
||||
|
||||
const escHandler = (e: KeyboardEvent) => {
|
||||
if (Date.now() - startTime < 200) {
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setSelectionActive(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('dblclick', dblClickHandler);
|
||||
document.addEventListener('keydown', escHandler);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('dblclick', dblClickHandler);
|
||||
document.removeEventListener('keydown', escHandler);
|
||||
};
|
||||
}
|
||||
return;
|
||||
}, [
|
||||
selectionState.selectable,
|
||||
selectionState.selectionActive,
|
||||
setSelectionActive,
|
||||
]);
|
||||
};
|
||||
|
||||
export const PageListInnerWrapper = memo(
|
||||
({
|
||||
handleRef,
|
||||
children,
|
||||
onSelectionActiveChange,
|
||||
...props
|
||||
}: PropsWithChildren<
|
||||
PageListProps & { handleRef: ForwardedRef<PageListHandle> }
|
||||
>) => {
|
||||
const setPageListPropsAtom = useSetAtom(pageListPropsAtom);
|
||||
const [selectionState, setPageListSelectionState] =
|
||||
useAtom(selectionStateAtom);
|
||||
usePageSelectionStateEffect();
|
||||
|
||||
useEffect(() => {
|
||||
setPageListPropsAtom(props);
|
||||
}, [props, setPageListPropsAtom]);
|
||||
|
||||
useEffect(() => {
|
||||
onSelectionActiveChange?.(!!selectionState.selectionActive);
|
||||
}, [onSelectionActiveChange, selectionState.selectionActive]);
|
||||
|
||||
useImperativeHandle(
|
||||
handleRef,
|
||||
() => {
|
||||
return {
|
||||
toggleSelectable: () => {
|
||||
setPageListSelectionState(false);
|
||||
},
|
||||
};
|
||||
},
|
||||
[setPageListSelectionState]
|
||||
);
|
||||
return children;
|
||||
}
|
||||
);
|
||||
|
||||
PageListInnerWrapper.displayName = 'PageListInnerWrapper';
|
||||
|
||||
const PageListInner = (props: PageListProps) => {
|
||||
const groups = useAtomValue(pageGroupsAtom);
|
||||
const hideHeader = props.hideHeader;
|
||||
return (
|
||||
<div className={clsx(props.className, styles.root)}>
|
||||
{!hideHeader ? <PageListTableHeader /> : null}
|
||||
<div className={styles.groupsContainer}>
|
||||
{groups.map(group => (
|
||||
<PageGroup key={group.id} {...group} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface PageListScrollContainerProps {
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const PageListScrollContainer = forwardRef<
|
||||
HTMLDivElement,
|
||||
PropsWithChildren<PageListScrollContainerProps>
|
||||
>(({ className, children, style }, ref) => {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const hasScrollTop = useHasScrollTop(containerRef);
|
||||
|
||||
const setNodeRef = useCallback(
|
||||
(r: HTMLDivElement) => {
|
||||
if (ref) {
|
||||
if (typeof ref === 'function') {
|
||||
ref(r);
|
||||
} else {
|
||||
ref.current = r;
|
||||
}
|
||||
}
|
||||
containerRef.current = r;
|
||||
},
|
||||
[ref]
|
||||
);
|
||||
|
||||
return (
|
||||
<Scrollable.Root
|
||||
style={style}
|
||||
data-has-scroll-top={hasScrollTop}
|
||||
className={clsx(styles.pageListScrollContainer, className)}
|
||||
>
|
||||
<Scrollable.Viewport ref={setNodeRef}>{children}</Scrollable.Viewport>
|
||||
<Scrollable.Scrollbar />
|
||||
</Scrollable.Root>
|
||||
);
|
||||
});
|
||||
|
||||
PageListScrollContainer.displayName = 'PageListScrollContainer';
|
||||
@@ -1,133 +0,0 @@
|
||||
import { createVar, style } from '@vanilla-extract/css';
|
||||
|
||||
export const hoverMaxWidth = createVar();
|
||||
|
||||
export const root = style({
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
minHeight: '32px',
|
||||
});
|
||||
|
||||
export const tagsContainer = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
});
|
||||
|
||||
export const tagsScrollContainer = style([
|
||||
tagsContainer,
|
||||
{
|
||||
overflowX: 'hidden',
|
||||
position: 'relative',
|
||||
height: '100%',
|
||||
gap: '8px',
|
||||
},
|
||||
]);
|
||||
|
||||
export const tagsListContainer = style([
|
||||
tagsContainer,
|
||||
{
|
||||
flexWrap: 'wrap',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
gap: '4px',
|
||||
},
|
||||
]);
|
||||
|
||||
export const innerContainer = style({
|
||||
display: 'flex',
|
||||
columnGap: '8px',
|
||||
alignItems: 'center',
|
||||
position: 'absolute',
|
||||
height: '100%',
|
||||
maxWidth: '100%',
|
||||
transition: 'all 0.2s 0.3s ease-in-out',
|
||||
selectors: {
|
||||
[`${root}:hover &`]: {
|
||||
maxWidth: hoverMaxWidth,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// background with linear gradient hack
|
||||
export const innerBackdrop = style({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '100%',
|
||||
opacity: 0,
|
||||
transition: 'all 0.2s',
|
||||
background:
|
||||
'linear-gradient(90deg, transparent 0%, var(--affine-hover-color-filled) 40%)',
|
||||
selectors: {
|
||||
[`${root}:hover &`]: {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const tag = style({
|
||||
height: '20px',
|
||||
display: 'flex',
|
||||
minWidth: 0,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
':last-child': {
|
||||
minWidth: 'max-content',
|
||||
},
|
||||
});
|
||||
|
||||
export const tagInnerWrapper = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '0 8px',
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
});
|
||||
|
||||
export const tagSticky = style([
|
||||
tagInnerWrapper,
|
||||
{
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
borderRadius: '10px',
|
||||
columnGap: '4px',
|
||||
border: '1px solid var(--affine-border-color)',
|
||||
background: 'var(--affine-background-primary-color)',
|
||||
maxWidth: '128px',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
},
|
||||
]);
|
||||
|
||||
export const tagListItem = style([
|
||||
tag,
|
||||
{
|
||||
fontSize: 'var(--affine-font-sm)',
|
||||
padding: '4px 12px',
|
||||
columnGap: '8px',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
height: '30px',
|
||||
},
|
||||
]);
|
||||
|
||||
export const showMoreTag = style({
|
||||
fontSize: 'var(--affine-font-h-5)',
|
||||
right: 0,
|
||||
position: 'sticky',
|
||||
display: 'inline-flex',
|
||||
});
|
||||
|
||||
export const tagIndicator = style({
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
flexShrink: 0,
|
||||
});
|
||||
|
||||
export const tagLabel = style({
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
@@ -1,131 +0,0 @@
|
||||
import type { Tag } from '@affine/env/filter';
|
||||
import { MoreHorizontalIcon } from '@blocksuite/icons';
|
||||
import { assignInlineVars } from '@vanilla-extract/dynamic';
|
||||
import clsx from 'clsx';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { Menu } from '../../ui/menu';
|
||||
import * as styles from './page-tags.css';
|
||||
import { stopPropagation } from './utils';
|
||||
|
||||
export interface PageTagsProps {
|
||||
tags: Tag[];
|
||||
maxItems?: number; // max number to show. if not specified, show all. if specified, show the first n items and add a "..." tag
|
||||
widthOnHover?: number | string; // max width on hover
|
||||
hoverExpandDirection?: 'left' | 'right'; // expansion direction on hover
|
||||
}
|
||||
|
||||
interface TagItemProps {
|
||||
tag: Tag;
|
||||
idx: number;
|
||||
mode: 'sticky' | 'list-item';
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
// hack: map var(--affine-tag-xxx) colors to var(--affine-palette-line-xxx)
|
||||
const tagColorMap = (color: string) => {
|
||||
const mapping: Record<string, string> = {
|
||||
'var(--affine-tag-red)': 'var(--affine-palette-line-red)',
|
||||
'var(--affine-tag-teal)': 'var(--affine-palette-line-green)',
|
||||
'var(--affine-tag-blue)': 'var(--affine-palette-line-blue)',
|
||||
'var(--affine-tag-yellow)': 'var(--affine-palette-line-yellow)',
|
||||
'var(--affine-tag-pink)': 'var(--affine-palette-line-magenta)',
|
||||
'var(--affine-tag-white)': 'var(--affine-palette-line-grey)',
|
||||
'var(--affine-tag-gray)': 'var(--affine-palette-line-grey)',
|
||||
'var(--affine-tag-orange)': 'var(--affine-palette-line-orange)',
|
||||
'var(--affine-tag-purple)': 'var(--affine-palette-line-purple)',
|
||||
'var(--affine-tag-green)': 'var(--affine-palette-line-green)',
|
||||
};
|
||||
return mapping[color] || color;
|
||||
};
|
||||
|
||||
const TagItem = ({ tag, idx, mode, style }: TagItemProps) => {
|
||||
return (
|
||||
<div
|
||||
data-testid="page-tag"
|
||||
className={styles.tag}
|
||||
data-idx={idx}
|
||||
title={tag.value}
|
||||
style={style}
|
||||
>
|
||||
<div
|
||||
className={mode === 'sticky' ? styles.tagSticky : styles.tagListItem}
|
||||
>
|
||||
<div
|
||||
className={styles.tagIndicator}
|
||||
style={{
|
||||
backgroundColor: tagColorMap(tag.color),
|
||||
}}
|
||||
/>
|
||||
<div className={styles.tagLabel}>{tag.value}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const PageTags = ({
|
||||
tags,
|
||||
widthOnHover,
|
||||
maxItems,
|
||||
hoverExpandDirection,
|
||||
}: PageTagsProps) => {
|
||||
const sanitizedWidthOnHover = widthOnHover
|
||||
? typeof widthOnHover === 'string'
|
||||
? widthOnHover
|
||||
: `${widthOnHover}px`
|
||||
: 'auto';
|
||||
|
||||
const tagsInPopover = useMemo(() => {
|
||||
const lastTags = tags.slice(maxItems);
|
||||
return (
|
||||
<div className={styles.tagsListContainer}>
|
||||
{lastTags.map((tag, idx) => (
|
||||
<TagItem key={tag.id} tag={tag} idx={idx} mode="list-item" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}, [maxItems, tags]);
|
||||
|
||||
const tagsNormal = useMemo(() => {
|
||||
const nTags = maxItems ? tags.slice(0, maxItems) : tags;
|
||||
|
||||
// sort tags by length
|
||||
nTags.sort((a, b) => a.value.length - b.value.length);
|
||||
|
||||
return nTags.map((tag, idx) => (
|
||||
<TagItem key={tag.id} tag={tag} idx={idx} mode="sticky" />
|
||||
));
|
||||
}, [maxItems, tags]);
|
||||
return (
|
||||
<div
|
||||
data-testid="page-tags"
|
||||
className={styles.root}
|
||||
style={assignInlineVars({
|
||||
[styles.hoverMaxWidth]: sanitizedWidthOnHover,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
right: hoverExpandDirection === 'left' ? 0 : 'auto',
|
||||
left: hoverExpandDirection === 'right' ? 0 : 'auto',
|
||||
}}
|
||||
className={clsx(styles.innerContainer)}
|
||||
>
|
||||
<div className={styles.innerBackdrop} />
|
||||
<div className={styles.tagsScrollContainer}>{tagsNormal}</div>
|
||||
{maxItems && tags.length > maxItems ? (
|
||||
<Menu
|
||||
items={tagsInPopover}
|
||||
contentOptions={{
|
||||
onClick: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<div className={styles.showMoreTag}>
|
||||
<MoreHorizontalIcon />
|
||||
</div>
|
||||
</Menu>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,85 +0,0 @@
|
||||
import { Trans } from '@affine/i18n';
|
||||
import type { PageMeta } from '@blocksuite/store';
|
||||
|
||||
import type { PageGroupDefinition, PageGroupProps } from './types';
|
||||
import { type DateKey } from './types';
|
||||
import { betweenDaysAgo, withinDaysAgo } from './utils';
|
||||
|
||||
// todo: optimize date matchers
|
||||
const getDateGroupDefinitions = (key: DateKey): PageGroupDefinition[] => [
|
||||
{
|
||||
id: 'today',
|
||||
label: <Trans i18nKey="com.affine.today" />,
|
||||
match: item => withinDaysAgo(new Date(item[key] ?? item.createDate), 1),
|
||||
},
|
||||
{
|
||||
id: 'yesterday',
|
||||
label: <Trans i18nKey="com.affine.yesterday" />,
|
||||
match: item => betweenDaysAgo(new Date(item[key] ?? item.createDate), 1, 2),
|
||||
},
|
||||
{
|
||||
id: 'last7Days',
|
||||
label: <Trans i18nKey="com.affine.last7Days" />,
|
||||
match: item => betweenDaysAgo(new Date(item[key] ?? item.createDate), 2, 7),
|
||||
},
|
||||
{
|
||||
id: 'last30Days',
|
||||
label: <Trans i18nKey="com.affine.last30Days" />,
|
||||
match: item =>
|
||||
betweenDaysAgo(new Date(item[key] ?? item.createDate), 7, 30),
|
||||
},
|
||||
{
|
||||
id: 'moreThan30Days',
|
||||
label: <Trans i18nKey="com.affine.moreThan30Days" />,
|
||||
match: item => !withinDaysAgo(new Date(item[key] ?? item.createDate), 30),
|
||||
},
|
||||
];
|
||||
|
||||
const pageGroupDefinitions = {
|
||||
createDate: getDateGroupDefinitions('createDate'),
|
||||
updatedDate: getDateGroupDefinitions('updatedDate'),
|
||||
// add more here later
|
||||
// todo: some page group definitions maybe dynamic
|
||||
};
|
||||
|
||||
export function pagesToPageGroups(
|
||||
pages: PageMeta[],
|
||||
key?: DateKey
|
||||
): PageGroupProps[] {
|
||||
if (!key) {
|
||||
return [
|
||||
{
|
||||
id: 'all',
|
||||
items: pages,
|
||||
allItems: pages,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// assume pages are already sorted, we will use the page order to determine the group order
|
||||
const groupDefs = pageGroupDefinitions[key];
|
||||
const groups: PageGroupProps[] = [];
|
||||
|
||||
for (const page of pages) {
|
||||
// for a single page, there could be multiple groups that it belongs to
|
||||
const matchedGroups = groupDefs.filter(def => def.match(page));
|
||||
for (const groupDef of matchedGroups) {
|
||||
const group = groups.find(g => g.id === groupDef.id);
|
||||
if (group) {
|
||||
group.items.push(page);
|
||||
} else {
|
||||
const label =
|
||||
typeof groupDef.label === 'function'
|
||||
? groupDef.label()
|
||||
: groupDef.label;
|
||||
groups.push({
|
||||
id: groupDef.id,
|
||||
label: label,
|
||||
items: [page],
|
||||
allItems: pages,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
# <PageListTable />
|
||||
|
||||
A new implementation of the list table component for Page. Replace existing `PageList` component.
|
||||
May rename to `PageList` later.
|
||||
@@ -1,207 +0,0 @@
|
||||
import { DEFAULT_SORT_KEY } from '@affine/env/constant';
|
||||
import type { PageMeta } from '@blocksuite/store';
|
||||
import { atom } from 'jotai';
|
||||
import { selectAtom } from 'jotai/utils';
|
||||
import { createIsolation } from 'jotai-scope';
|
||||
|
||||
import { pagesToPageGroups } from './pages-to-page-group';
|
||||
import type {
|
||||
PageListProps,
|
||||
PageMetaRecord,
|
||||
VirtualizedPageListProps,
|
||||
} from './types';
|
||||
import { shallowEqual } from './utils';
|
||||
|
||||
// for ease of use in the component tree
|
||||
// note: must use selectAtom to access this atom for efficiency
|
||||
// @ts-expect-error the error is expected but we will assume the default value is always there by using useHydrateAtoms
|
||||
export const pageListPropsAtom = atom<
|
||||
PageListProps & Partial<VirtualizedPageListProps>
|
||||
>();
|
||||
|
||||
// whether or not the table is in selection mode (showing selection checkbox & selection floating bar)
|
||||
const selectionActiveAtom = atom(false);
|
||||
|
||||
export const selectionStateAtom = atom(
|
||||
get => {
|
||||
const baseAtom = selectAtom(
|
||||
pageListPropsAtom,
|
||||
props => {
|
||||
const { selectable, selectedPageIds, onSelectedPageIdsChange } = props;
|
||||
return {
|
||||
selectable,
|
||||
selectedPageIds,
|
||||
onSelectedPageIdsChange,
|
||||
};
|
||||
},
|
||||
shallowEqual
|
||||
);
|
||||
const baseState = get(baseAtom);
|
||||
const selectionActive =
|
||||
baseState.selectable === 'toggle'
|
||||
? get(selectionActiveAtom)
|
||||
: baseState.selectable;
|
||||
return {
|
||||
...baseState,
|
||||
selectionActive,
|
||||
};
|
||||
},
|
||||
(_get, set, active: boolean) => {
|
||||
set(selectionActiveAtom, active);
|
||||
}
|
||||
);
|
||||
|
||||
// id -> isCollapsed
|
||||
// maybe reset on page on unmount?
|
||||
export const pageGroupCollapseStateAtom = atom<Record<string, boolean>>({});
|
||||
|
||||
// get handlers from pageListPropsAtom
|
||||
export const pageListHandlersAtom = selectAtom(
|
||||
pageListPropsAtom,
|
||||
props => {
|
||||
const { onSelectedPageIdsChange } = props;
|
||||
return {
|
||||
onSelectedPageIdsChange,
|
||||
};
|
||||
},
|
||||
shallowEqual
|
||||
);
|
||||
|
||||
export const pagesAtom = selectAtom(
|
||||
pageListPropsAtom,
|
||||
props => props.pages,
|
||||
shallowEqual
|
||||
);
|
||||
|
||||
export const showOperationsAtom = selectAtom(
|
||||
pageListPropsAtom,
|
||||
props => !!props.pageOperationsRenderer
|
||||
);
|
||||
|
||||
type SortingContext<T extends string | number | symbol> = {
|
||||
key: T;
|
||||
order: 'asc' | 'desc';
|
||||
fallbackKey?: T;
|
||||
};
|
||||
|
||||
type SorterConfig<T extends Record<string, unknown> = Record<string, unknown>> =
|
||||
{
|
||||
key?: keyof T;
|
||||
order: 'asc' | 'desc';
|
||||
sortingFn: (ctx: SortingContext<keyof T>, a: T, b: T) => number;
|
||||
};
|
||||
|
||||
const defaultSortingFn: SorterConfig<PageMetaRecord>['sortingFn'] = (
|
||||
ctx,
|
||||
a,
|
||||
b
|
||||
) => {
|
||||
const val = (obj: PageMetaRecord) => {
|
||||
let v = obj[ctx.key];
|
||||
if (v === undefined && ctx.fallbackKey) {
|
||||
v = obj[ctx.fallbackKey];
|
||||
}
|
||||
return v;
|
||||
};
|
||||
const valA = val(a);
|
||||
const valB = val(b);
|
||||
const revert = ctx.order === 'desc';
|
||||
const revertSymbol = revert ? -1 : 1;
|
||||
if (typeof valA === 'string' && typeof valB === 'string') {
|
||||
return valA.localeCompare(valB) * revertSymbol;
|
||||
}
|
||||
if (typeof valA === 'number' && typeof valB === 'number') {
|
||||
return (valA - valB) * revertSymbol;
|
||||
}
|
||||
if (valA instanceof Date && valB instanceof Date) {
|
||||
return (valA.getTime() - valB.getTime()) * revertSymbol;
|
||||
}
|
||||
if (!valA) {
|
||||
return -1 * revertSymbol;
|
||||
}
|
||||
if (!valB) {
|
||||
return 1 * revertSymbol;
|
||||
}
|
||||
|
||||
if (Array.isArray(valA) && Array.isArray(valB)) {
|
||||
return (valA.length - valB.length) * revertSymbol;
|
||||
}
|
||||
console.warn(
|
||||
'Unsupported sorting type! Please use custom sorting function.',
|
||||
valA,
|
||||
valB
|
||||
);
|
||||
return 0;
|
||||
};
|
||||
|
||||
const sorterStateAtom = atom<SorterConfig<PageMetaRecord>>({
|
||||
key: DEFAULT_SORT_KEY,
|
||||
order: 'desc',
|
||||
sortingFn: defaultSortingFn,
|
||||
});
|
||||
|
||||
export const sorterAtom = atom(
|
||||
get => {
|
||||
let pages = get(pagesAtom);
|
||||
const sorterState = get(sorterStateAtom);
|
||||
const sortCtx: SortingContext<keyof PageMetaRecord> | null = sorterState.key
|
||||
? {
|
||||
key: sorterState.key,
|
||||
order: sorterState.order,
|
||||
}
|
||||
: null;
|
||||
if (sortCtx) {
|
||||
if (sorterState.key === 'updatedDate') {
|
||||
sortCtx.fallbackKey = 'createDate';
|
||||
}
|
||||
const compareFn = (a: PageMetaRecord, b: PageMetaRecord) =>
|
||||
sorterState.sortingFn(sortCtx, a, b);
|
||||
pages = [...pages].sort(compareFn);
|
||||
}
|
||||
return {
|
||||
pages,
|
||||
...sortCtx,
|
||||
};
|
||||
},
|
||||
(_get, set, { newSortKey }: { newSortKey: keyof PageMeta }) => {
|
||||
set(sorterStateAtom, sorterState => {
|
||||
if (sorterState.key === newSortKey) {
|
||||
return {
|
||||
...sorterState,
|
||||
order: sorterState.order === 'asc' ? 'desc' : 'asc',
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
key: newSortKey,
|
||||
order: 'desc',
|
||||
sortingFn: sorterState.sortingFn,
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export const pageGroupsAtom = atom(get => {
|
||||
let groupBy = get(selectAtom(pageListPropsAtom, props => props.groupBy));
|
||||
const sorter = get(sorterAtom);
|
||||
|
||||
if (groupBy === false) {
|
||||
groupBy = undefined;
|
||||
} else if (groupBy === undefined) {
|
||||
groupBy =
|
||||
sorter.key === 'createDate' || sorter.key === 'updatedDate'
|
||||
? sorter.key
|
||||
: // default sort
|
||||
!sorter.key
|
||||
? DEFAULT_SORT_KEY
|
||||
: undefined;
|
||||
}
|
||||
return pagesToPageGroups(sorter.pages, groupBy);
|
||||
});
|
||||
|
||||
export const {
|
||||
Provider: PageListProvider,
|
||||
useAtom,
|
||||
useAtomValue,
|
||||
useSetAtom,
|
||||
} = createIsolation();
|
||||
@@ -1,91 +0,0 @@
|
||||
import type { Tag } from '@affine/env/filter';
|
||||
import type { PageMeta, Workspace } from '@blocksuite/store';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { To } from 'react-router-dom';
|
||||
|
||||
// TODO: consider reducing the number of props here
|
||||
// using type instead of interface to make it Record compatible
|
||||
export type PageListItemProps = {
|
||||
pageId: string;
|
||||
icon: JSX.Element;
|
||||
title: ReactNode; // using ReactNode to allow for rich content rendering
|
||||
preview?: ReactNode; // using ReactNode to allow for rich content rendering
|
||||
tags: Tag[];
|
||||
createDate: Date;
|
||||
updatedDate?: Date;
|
||||
isPublicPage?: boolean;
|
||||
to?: To; // whether or not to render this item as a Link
|
||||
draggable?: boolean; // whether or not to allow dragging this item
|
||||
selectable?: boolean; // show selection checkbox
|
||||
selected?: boolean;
|
||||
operations?: ReactNode; // operations to show on the right side of the item
|
||||
onClick?: () => void;
|
||||
onSelectedChange?: () => void;
|
||||
};
|
||||
|
||||
export interface PageListHeaderProps {}
|
||||
|
||||
// todo: a temporary solution. may need to be refactored later
|
||||
export type PagesGroupByType = 'createDate' | 'updatedDate'; // todo: can add more later
|
||||
|
||||
// todo: a temporary solution. may need to be refactored later
|
||||
export interface SortBy {
|
||||
key: 'createDate' | 'updatedDate';
|
||||
order: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export type DateKey = 'createDate' | 'updatedDate';
|
||||
|
||||
export interface PageListProps {
|
||||
// required data:
|
||||
pages: PageMeta[];
|
||||
blockSuiteWorkspace: Workspace;
|
||||
className?: string;
|
||||
hideHeader?: boolean; // whether or not to hide the header. default is false (showing header)
|
||||
groupBy?: PagesGroupByType | false;
|
||||
isPreferredEdgeless: (pageId: string) => boolean; // determines the icon used for each row
|
||||
rowAsLink?: boolean;
|
||||
selectable?: 'toggle' | boolean; // show selection checkbox. toggle means showing a toggle selection in header on click; boolean == true means showing a selection checkbox for each item
|
||||
selectedPageIds?: string[]; // selected page ids
|
||||
onSelectedPageIdsChange?: (selected: string[]) => void;
|
||||
onSelectionActiveChange?: (active: boolean) => void;
|
||||
draggable?: boolean; // whether or not to allow dragging this page item
|
||||
// we also need the following to make sure the page list functions properly
|
||||
// maybe we could also give a function to render PageListItem?
|
||||
pageOperationsRenderer?: (page: PageMeta) => ReactNode;
|
||||
}
|
||||
|
||||
export interface VirtualizedPageListProps extends PageListProps {
|
||||
heading?: ReactNode; // the user provided heading part (non sticky, above the original header)
|
||||
atTopThreshold?: number; // the threshold to determine whether or not the user has scrolled to the top. default is 0
|
||||
atTopStateChange?: (atTop: boolean) => void; // called when the user scrolls to the top or not
|
||||
}
|
||||
|
||||
export interface PageListHandle {
|
||||
toggleSelectable: () => void;
|
||||
}
|
||||
|
||||
export interface PageGroupDefinition {
|
||||
id: string;
|
||||
// using a function to render custom group header
|
||||
label: (() => ReactNode) | ReactNode;
|
||||
match: (item: PageMeta) => boolean;
|
||||
}
|
||||
|
||||
export interface PageGroupProps {
|
||||
id: string;
|
||||
label?: ReactNode; // if there is no label, it is a default group (without header)
|
||||
items: PageMeta[];
|
||||
allItems: PageMeta[];
|
||||
}
|
||||
|
||||
type MakeRecord<T> = {
|
||||
[P in keyof T]: T[P];
|
||||
};
|
||||
|
||||
export type PageMetaRecord = MakeRecord<PageMeta>;
|
||||
|
||||
export type DraggableTitleCellData = {
|
||||
pageId: string;
|
||||
pageTitle: ReactNode;
|
||||
};
|
||||
@@ -1,75 +0,0 @@
|
||||
import type { Page } from '@blocksuite/store';
|
||||
import type { Atom } from 'jotai';
|
||||
import { atom } from 'jotai';
|
||||
|
||||
const MAX_PREVIEW_LENGTH = 150;
|
||||
const MAX_SEARCH_BLOCK_COUNT = 30;
|
||||
|
||||
const weakMap = new WeakMap<Page, Atom<string>>();
|
||||
|
||||
export const getPagePreviewText = (page: Page) => {
|
||||
const pageRoot = page.root;
|
||||
if (!pageRoot) {
|
||||
return '';
|
||||
}
|
||||
const preview: string[] = [];
|
||||
// DFS
|
||||
const queue = [pageRoot];
|
||||
let previewLenNeeded = MAX_PREVIEW_LENGTH;
|
||||
let count = MAX_SEARCH_BLOCK_COUNT;
|
||||
while (queue.length && previewLenNeeded > 0 && count-- > 0) {
|
||||
const block = queue.shift();
|
||||
if (!block) {
|
||||
console.error('Unexpected empty block');
|
||||
break;
|
||||
}
|
||||
if (block.children) {
|
||||
queue.unshift(...block.children);
|
||||
}
|
||||
if (block.role !== 'content') {
|
||||
continue;
|
||||
}
|
||||
if (block.text) {
|
||||
const text = block.text.toString();
|
||||
if (!text.length) {
|
||||
continue;
|
||||
}
|
||||
previewLenNeeded -= text.length;
|
||||
preview.push(text);
|
||||
} else {
|
||||
// image/attachment/bookmark
|
||||
const type = block.flavour.split('affine:')[1] ?? null;
|
||||
previewLenNeeded -= type.length + 2;
|
||||
type && preview.push(`[${type}]`);
|
||||
}
|
||||
}
|
||||
return preview.join(' ');
|
||||
};
|
||||
|
||||
const emptyAtom = atom<string>('');
|
||||
|
||||
export function useBlockSuitePagePreview(page: Page | null): Atom<string> {
|
||||
if (page === null) {
|
||||
return emptyAtom;
|
||||
} else if (weakMap.has(page)) {
|
||||
return weakMap.get(page) as Atom<string>;
|
||||
} else {
|
||||
const baseAtom = atom<string>('');
|
||||
baseAtom.onMount = set => {
|
||||
const disposables = [
|
||||
page.slots.ready.on(() => {
|
||||
set(getPagePreviewText(page));
|
||||
}),
|
||||
page.slots.blockUpdated.on(() => {
|
||||
set(getPagePreviewText(page));
|
||||
}),
|
||||
];
|
||||
set(getPagePreviewText(page));
|
||||
return () => {
|
||||
disposables.forEach(disposable => disposable.dispose());
|
||||
};
|
||||
};
|
||||
weakMap.set(page, baseAtom);
|
||||
return baseAtom;
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { DebugLogger } from '@affine/debug';
|
||||
import { DisposableGroup } from '@blocksuite/global/utils';
|
||||
import type { Page, Workspace } from '@blocksuite/store';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const logger = new DebugLogger('use-block-suite-workspace-page');
|
||||
|
||||
export function useBlockSuiteWorkspacePage(
|
||||
blockSuiteWorkspace: Workspace,
|
||||
pageId: string | null
|
||||
): Page | null {
|
||||
const [page, setPage] = useState(
|
||||
pageId ? blockSuiteWorkspace.getPage(pageId) : null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const group = new DisposableGroup();
|
||||
group.add(
|
||||
blockSuiteWorkspace.slots.pageAdded.on(id => {
|
||||
if (pageId === id) {
|
||||
setPage(blockSuiteWorkspace.getPage(id));
|
||||
}
|
||||
})
|
||||
);
|
||||
group.add(
|
||||
blockSuiteWorkspace.slots.pageRemoved.on(id => {
|
||||
if (pageId === id) {
|
||||
setPage(null);
|
||||
}
|
||||
})
|
||||
);
|
||||
return () => {
|
||||
group.dispose();
|
||||
};
|
||||
}, [blockSuiteWorkspace, pageId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (page && !page.loaded) {
|
||||
page.load().catch(err => {
|
||||
logger.error('Failed to load page', err);
|
||||
});
|
||||
}
|
||||
}, [page]);
|
||||
|
||||
return page;
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
import type {
|
||||
Collection,
|
||||
DeleteCollectionInfo,
|
||||
Filter,
|
||||
VariableMap,
|
||||
} from '@affine/env/filter';
|
||||
import type { PageMeta } from '@blocksuite/store';
|
||||
import { type Atom, useAtom, useAtomValue } from 'jotai';
|
||||
import { atomWithReset } from 'jotai/utils';
|
||||
import { useCallback } from 'react';
|
||||
import { NIL } from 'uuid';
|
||||
|
||||
import { evalFilterList } from './filter';
|
||||
|
||||
export const createEmptyCollection = (
|
||||
id: string,
|
||||
data?: Partial<Omit<Collection, 'id'>>
|
||||
): Collection => {
|
||||
return {
|
||||
id,
|
||||
name: '',
|
||||
filterList: [],
|
||||
allowList: [],
|
||||
...data,
|
||||
};
|
||||
};
|
||||
const defaultCollection: Collection = createEmptyCollection(NIL, {
|
||||
name: 'All',
|
||||
});
|
||||
const defaultCollectionAtom = atomWithReset<Collection>(defaultCollection);
|
||||
export const currentCollectionAtom = atomWithReset<string>(NIL);
|
||||
|
||||
export type Updater<T> = (value: T) => T;
|
||||
export type CollectionUpdater = Updater<Collection>;
|
||||
export type CollectionsCRUD = {
|
||||
addCollection: (...collections: Collection[]) => void;
|
||||
collections: Collection[];
|
||||
updateCollection: (id: string, updater: CollectionUpdater) => void;
|
||||
deleteCollection: (info: DeleteCollectionInfo, ...ids: string[]) => void;
|
||||
};
|
||||
export type CollectionsCRUDAtom = Atom<
|
||||
Promise<CollectionsCRUD> | CollectionsCRUD
|
||||
>;
|
||||
|
||||
export const useSavedCollections = (collectionAtom: CollectionsCRUDAtom) => {
|
||||
const [{ collections, addCollection, deleteCollection, updateCollection }] =
|
||||
useAtom(collectionAtom);
|
||||
const addPage = useCallback(
|
||||
(collectionId: string, pageId: string) => {
|
||||
updateCollection(collectionId, old => {
|
||||
return {
|
||||
...old,
|
||||
allowList: [pageId, ...(old.allowList ?? [])],
|
||||
};
|
||||
});
|
||||
},
|
||||
[updateCollection]
|
||||
);
|
||||
return {
|
||||
collections,
|
||||
addCollection,
|
||||
updateCollection,
|
||||
deleteCollection,
|
||||
addPage,
|
||||
};
|
||||
};
|
||||
|
||||
export const useCollectionManager = (collectionsAtom: CollectionsCRUDAtom) => {
|
||||
const {
|
||||
collections,
|
||||
updateCollection,
|
||||
addCollection,
|
||||
deleteCollection,
|
||||
addPage,
|
||||
} = useSavedCollections(collectionsAtom);
|
||||
const currentCollectionId = useAtomValue(currentCollectionAtom);
|
||||
const [defaultCollection, updateDefaultCollection] = useAtom(
|
||||
defaultCollectionAtom
|
||||
);
|
||||
const update = useCallback(
|
||||
(collection: Collection) => {
|
||||
if (collection.id === NIL) {
|
||||
updateDefaultCollection(collection);
|
||||
} else {
|
||||
updateCollection(collection.id, () => collection);
|
||||
}
|
||||
},
|
||||
[updateDefaultCollection, updateCollection]
|
||||
);
|
||||
const setTemporaryFilter = useCallback(
|
||||
(filterList: Filter[]) => {
|
||||
updateDefaultCollection({
|
||||
...defaultCollection,
|
||||
filterList: filterList,
|
||||
});
|
||||
},
|
||||
[updateDefaultCollection, defaultCollection]
|
||||
);
|
||||
const currentCollection =
|
||||
currentCollectionId === NIL
|
||||
? defaultCollection
|
||||
: collections.find(v => v.id === currentCollectionId) ??
|
||||
defaultCollection;
|
||||
|
||||
return {
|
||||
currentCollection: currentCollection,
|
||||
savedCollections: collections,
|
||||
isDefault: currentCollectionId === NIL,
|
||||
|
||||
// actions
|
||||
createCollection: addCollection,
|
||||
updateCollection: update,
|
||||
deleteCollection,
|
||||
addPage,
|
||||
setTemporaryFilter,
|
||||
};
|
||||
};
|
||||
export const filterByFilterList = (filterList: Filter[], varMap: VariableMap) =>
|
||||
evalFilterList(filterList, varMap);
|
||||
|
||||
export const filterPage = (collection: Collection, page: PageMeta) => {
|
||||
if (collection.filterList.length === 0) {
|
||||
return collection.allowList.includes(page.id);
|
||||
}
|
||||
return filterPageByRules(collection.filterList, collection.allowList, page);
|
||||
};
|
||||
export const filterPageByRules = (
|
||||
rules: Filter[],
|
||||
allowList: string[],
|
||||
page: PageMeta
|
||||
) => {
|
||||
if (allowList?.includes(page.id)) {
|
||||
return true;
|
||||
}
|
||||
return filterByFilterList(rules, {
|
||||
'Is Favourited': !!page.favorite,
|
||||
Created: page.createDate,
|
||||
Updated: page.updatedDate ?? page.createDate,
|
||||
Tags: page.tags,
|
||||
});
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
// add dblclick & esc to document when page selection is active
|
||||
//
|
||||
export function usePageSelectionEvents() {}
|
||||
@@ -1,175 +0,0 @@
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
type BaseSyntheticEvent,
|
||||
forwardRef,
|
||||
type PropsWithChildren,
|
||||
} from 'react';
|
||||
|
||||
import * as styles from './page-list.css';
|
||||
|
||||
export function isToday(date: Date): boolean {
|
||||
const today = new Date();
|
||||
return (
|
||||
date.getDate() === today.getDate() &&
|
||||
date.getMonth() === today.getMonth() &&
|
||||
date.getFullYear() === today.getFullYear()
|
||||
);
|
||||
}
|
||||
|
||||
export function isYesterday(date: Date): boolean {
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
return (
|
||||
date.getFullYear() === yesterday.getFullYear() &&
|
||||
date.getMonth() === yesterday.getMonth() &&
|
||||
date.getDate() === yesterday.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
export function isLastWeek(date: Date): boolean {
|
||||
const today = new Date();
|
||||
const lastWeek = new Date(
|
||||
today.getFullYear(),
|
||||
today.getMonth(),
|
||||
today.getDate() - 7
|
||||
);
|
||||
return date >= lastWeek && date < today;
|
||||
}
|
||||
|
||||
export function isLastMonth(date: Date): boolean {
|
||||
const today = new Date();
|
||||
const lastMonth = new Date(
|
||||
today.getFullYear(),
|
||||
today.getMonth() - 1,
|
||||
today.getDate()
|
||||
);
|
||||
return date >= lastMonth && date < today;
|
||||
}
|
||||
|
||||
export function isLastYear(date: Date): boolean {
|
||||
const today = new Date();
|
||||
const lastYear = new Date(
|
||||
today.getFullYear() - 1,
|
||||
today.getMonth(),
|
||||
today.getDate()
|
||||
);
|
||||
return date >= lastYear && date < today;
|
||||
}
|
||||
|
||||
export const formatDate = (date: Date): string => {
|
||||
// yyyy-mm-dd MM-DD HH:mm
|
||||
// const year = date.getFullYear();
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||
const day = date.getDate().toString().padStart(2, '0');
|
||||
const hours = date.getHours().toString().padStart(2, '0');
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||
if (isToday(date)) {
|
||||
// HH:mm
|
||||
return `${hours}:${minutes}`;
|
||||
}
|
||||
// MM-DD HH:mm
|
||||
return `${month}-${day} ${hours}:${minutes}`;
|
||||
};
|
||||
|
||||
export type ColWrapperProps = PropsWithChildren<{
|
||||
flex?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
|
||||
alignment?: 'start' | 'center' | 'end';
|
||||
styles?: React.CSSProperties;
|
||||
hideInSmallContainer?: boolean;
|
||||
}> &
|
||||
React.HTMLAttributes<Element>;
|
||||
|
||||
export const ColWrapper = forwardRef<HTMLDivElement, ColWrapperProps>(
|
||||
function ColWrapper(
|
||||
{
|
||||
flex,
|
||||
alignment,
|
||||
hideInSmallContainer,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
...rest
|
||||
}: ColWrapperProps,
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
{...rest}
|
||||
ref={ref}
|
||||
data-testid="page-list-flex-wrapper"
|
||||
style={{
|
||||
...style,
|
||||
flexGrow: flex,
|
||||
flexBasis: flex ? `${(flex / 12) * 100}%` : 'auto',
|
||||
justifyContent: alignment,
|
||||
}}
|
||||
className={clsx(
|
||||
className,
|
||||
styles.colWrapper,
|
||||
hideInSmallContainer ? styles.hideInSmallContainer : null
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const withinDaysAgo = (date: Date, days: number): boolean => {
|
||||
const startDate = new Date();
|
||||
const day = startDate.getDate();
|
||||
const month = startDate.getMonth();
|
||||
const year = startDate.getFullYear();
|
||||
return new Date(year, month, day - days + 1) <= date;
|
||||
};
|
||||
|
||||
export const betweenDaysAgo = (
|
||||
date: Date,
|
||||
days0: number,
|
||||
days1: number
|
||||
): boolean => {
|
||||
return !withinDaysAgo(date, days0) && withinDaysAgo(date, days1);
|
||||
};
|
||||
|
||||
export function stopPropagation(event: BaseSyntheticEvent) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
export function stopPropagationWithoutPrevent(event: BaseSyntheticEvent) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
// credit: https://github.com/facebook/fbjs/blob/main/packages/fbjs/src/core/shallowEqual.js
|
||||
export function shallowEqual(objA: any, objB: any) {
|
||||
if (Object.is(objA, objB)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof objA !== 'object' ||
|
||||
objA === null ||
|
||||
typeof objB !== 'object' ||
|
||||
objB === null
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const keysA = Object.keys(objA);
|
||||
const keysB = Object.keys(objB);
|
||||
|
||||
if (keysA.length !== keysB.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Test for A's keys different from B.
|
||||
for (const key of keysA) {
|
||||
if (
|
||||
!Object.prototype.hasOwnProperty.call(objB, key) ||
|
||||
!Object.is(objA[key], objB[key])
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import type React from 'react';
|
||||
|
||||
export const AffineShapeIcon = (props: React.SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 800 695"
|
||||
fill="none"
|
||||
width={200}
|
||||
{...props}
|
||||
>
|
||||
<rect
|
||||
x="206.896"
|
||||
y="154.635"
|
||||
width="386.207"
|
||||
height="386.207"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
<path
|
||||
d="M207.336 347.738L400 155.074L592.664 347.738L400 540.402L207.336 347.738Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
<path
|
||||
d="M398.421 153.574C430.649 134.691 468.171 123.866 508.222 123.866C628.347 123.866 725.729 221.247 725.729 341.372C725.729 430.974 671.549 507.921 594.163 541.241"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
<path
|
||||
d="M593.501 346.677C612.385 378.905 623.21 416.427 623.21 456.478C623.21 576.603 525.829 673.984 405.703 673.984C316.101 673.984 239.154 619.805 205.835 542.419"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
<path
|
||||
d="M401.578 541.24C369.35 560.123 331.828 570.948 291.777 570.948C171.651 570.948 74.2704 473.567 74.2704 353.441C74.2704 263.84 128.45 186.892 205.835 153.573"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
<path
|
||||
d="M205.834 349.315C186.951 317.088 176.126 279.565 176.126 239.515C176.126 119.389 273.507 22.0086 393.633 22.0086C483.235 22.0085 560.182 76.188 593.501 153.573"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
<path
|
||||
d="M205.835 153.574L594.164 541.903"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
<path
|
||||
d="M594.164 153.574L205.835 541.903"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
<path d="M399.999 153.574V541.903" stroke="currentColor" strokeWidth="3" />
|
||||
<path
|
||||
d="M594.164 347.738L205.835 347.738"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
<ellipse
|
||||
cx="593.102"
|
||||
cy="154.635"
|
||||
rx="15.9151"
|
||||
ry="15.9151"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<ellipse
|
||||
cx="593.102"
|
||||
cy="540.842"
|
||||
rx="15.9151"
|
||||
ry="15.9151"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<ellipse
|
||||
cx="593.102"
|
||||
cy="347.738"
|
||||
rx="15.9151"
|
||||
ry="15.9151"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<ellipse
|
||||
cx="206.896"
|
||||
cy="154.635"
|
||||
rx="15.9151"
|
||||
ry="15.9151"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<ellipse
|
||||
cx="206.896"
|
||||
cy="540.842"
|
||||
rx="15.9151"
|
||||
ry="15.9151"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<ellipse
|
||||
cx="206.896"
|
||||
cy="347.738"
|
||||
rx="15.9151"
|
||||
ry="15.9151"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<ellipse
|
||||
cx="400"
|
||||
cy="154.635"
|
||||
rx="15.9151"
|
||||
ry="15.9151"
|
||||
transform="rotate(-90 400 154.635)"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<ellipse
|
||||
cx="400"
|
||||
cy="345.616"
|
||||
rx="15.9151"
|
||||
ry="15.9151"
|
||||
transform="rotate(-90 400 345.616)"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<ellipse
|
||||
cx="400"
|
||||
cy="540.842"
|
||||
rx="15.9151"
|
||||
ry="15.9151"
|
||||
transform="rotate(-90 400 540.842)"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
@@ -1,28 +0,0 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const view = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
height: '100%',
|
||||
});
|
||||
|
||||
export const option = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 4,
|
||||
cursor: 'pointer',
|
||||
borderRadius: 4,
|
||||
':hover': {
|
||||
backgroundColor: 'var(--affine-hover-color)',
|
||||
},
|
||||
opacity: 0,
|
||||
selectors: {
|
||||
[`${view}:hover &`]: {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,115 +0,0 @@
|
||||
import type { DeleteCollectionInfo, PropertiesMeta } from '@affine/env/filter';
|
||||
import type { GetPageInfoById } from '@affine/env/page-info';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { ViewLayersIcon } from '@blocksuite/icons';
|
||||
import clsx from 'clsx';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Button } from '../../../ui/button';
|
||||
import { Tooltip } from '../../../ui/tooltip';
|
||||
import {
|
||||
type CollectionsCRUDAtom,
|
||||
useCollectionManager,
|
||||
} from '../use-collection-manager';
|
||||
import * as styles from './collection-bar.css';
|
||||
import {
|
||||
type AllPageListConfig,
|
||||
EditCollectionModal,
|
||||
} from './edit-collection/edit-collection';
|
||||
import { useActions } from './use-action';
|
||||
|
||||
interface CollectionBarProps {
|
||||
getPageInfo: GetPageInfoById;
|
||||
propertiesMeta: PropertiesMeta;
|
||||
collectionsAtom: CollectionsCRUDAtom;
|
||||
backToAll: () => void;
|
||||
allPageListConfig: AllPageListConfig;
|
||||
info: DeleteCollectionInfo;
|
||||
}
|
||||
|
||||
export const CollectionBar = (props: CollectionBarProps) => {
|
||||
const { collectionsAtom } = props;
|
||||
const t = useAFFiNEI18N();
|
||||
const setting = useCollectionManager(collectionsAtom);
|
||||
const collection = setting.currentCollection;
|
||||
const [open, setOpen] = useState(false);
|
||||
const actions = useActions({
|
||||
collection,
|
||||
setting,
|
||||
info: props.info,
|
||||
openEdit: () => setOpen(true),
|
||||
});
|
||||
return !setting.isDefault ? (
|
||||
<div
|
||||
style={{
|
||||
userSelect: 'none',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '12px 20px',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div className={styles.view}>
|
||||
<EditCollectionModal
|
||||
allPageListConfig={props.allPageListConfig}
|
||||
init={collection}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
onConfirm={setting.updateCollection}
|
||||
/>
|
||||
<ViewLayersIcon
|
||||
style={{
|
||||
height: 20,
|
||||
width: 20,
|
||||
}}
|
||||
/>
|
||||
<Tooltip
|
||||
content={setting.currentCollection.name}
|
||||
rootOptions={{
|
||||
delayDuration: 1500,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
marginRight: 10,
|
||||
maxWidth: 200,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{setting.currentCollection.name}
|
||||
</div>
|
||||
</Tooltip>
|
||||
{actions.map(action => {
|
||||
return (
|
||||
<Tooltip key={action.name} content={action.tooltip}>
|
||||
<div
|
||||
data-testid={`collection-bar-option-${action.name}`}
|
||||
onClick={action.click}
|
||||
className={clsx(styles.option, action.className)}
|
||||
>
|
||||
{action.icon}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'end',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
style={{ border: 'none', position: 'static' }}
|
||||
onClick={props.backToAll}
|
||||
>
|
||||
{t['com.affine.collectionBar.backToAll']()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
};
|
||||
@@ -1,41 +0,0 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const menuTitleStyle = style({
|
||||
marginLeft: '12px',
|
||||
marginTop: '10px',
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
});
|
||||
export const menuDividerStyle = style({
|
||||
marginTop: '2px',
|
||||
marginBottom: '2px',
|
||||
marginLeft: '12px',
|
||||
marginRight: '8px',
|
||||
height: '1px',
|
||||
background: 'var(--affine-border-color)',
|
||||
});
|
||||
export const viewMenu = style({});
|
||||
export const viewOption = style({
|
||||
borderRadius: 8,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginLeft: 6,
|
||||
width: 24,
|
||||
height: 24,
|
||||
opacity: 0,
|
||||
':hover': {
|
||||
backgroundColor: 'var(--affine-hover-color)',
|
||||
},
|
||||
selectors: {
|
||||
[`${viewMenu}:hover &`]: {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
export const filterMenuTrigger = style({
|
||||
padding: '6px 8px',
|
||||
':hover': {
|
||||
backgroundColor: 'var(--affine-hover-color)',
|
||||
},
|
||||
});
|
||||
@@ -1,107 +0,0 @@
|
||||
import type {
|
||||
Collection,
|
||||
DeleteCollectionInfo,
|
||||
Filter,
|
||||
} from '@affine/env/filter';
|
||||
import type { PropertiesMeta } from '@affine/env/filter';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { FilterIcon } from '@blocksuite/icons';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { Button } from '../../../ui/button';
|
||||
import { FlexWrapper } from '../../../ui/layout';
|
||||
import { Menu } from '../../../ui/menu';
|
||||
import { CreateFilterMenu } from '../filter/vars';
|
||||
import type { useCollectionManager } from '../use-collection-manager';
|
||||
import * as styles from './collection-list.css';
|
||||
import { CollectionOperations } from './collection-operations';
|
||||
import {
|
||||
type AllPageListConfig,
|
||||
EditCollectionModal,
|
||||
} from './edit-collection/edit-collection';
|
||||
|
||||
export const CollectionList = ({
|
||||
setting,
|
||||
propertiesMeta,
|
||||
allPageListConfig,
|
||||
userInfo,
|
||||
}: {
|
||||
setting: ReturnType<typeof useCollectionManager>;
|
||||
propertiesMeta: PropertiesMeta;
|
||||
allPageListConfig: AllPageListConfig;
|
||||
userInfo: DeleteCollectionInfo;
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const [collection, setCollection] = useState<Collection>();
|
||||
const onChange = useCallback(
|
||||
(filterList: Filter[]) => {
|
||||
setting.updateCollection({
|
||||
...setting.currentCollection,
|
||||
filterList,
|
||||
});
|
||||
},
|
||||
[setting]
|
||||
);
|
||||
const closeUpdateCollectionModal = useCallback((open: boolean) => {
|
||||
if (!open) {
|
||||
setCollection(undefined);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onConfirm = useCallback(
|
||||
(view: Collection) => {
|
||||
setting.updateCollection(view);
|
||||
closeUpdateCollectionModal(false);
|
||||
},
|
||||
[closeUpdateCollectionModal, setting]
|
||||
);
|
||||
return (
|
||||
<FlexWrapper alignItems="center">
|
||||
{setting.isDefault ? (
|
||||
<>
|
||||
<Menu
|
||||
items={
|
||||
<CreateFilterMenu
|
||||
propertiesMeta={propertiesMeta}
|
||||
value={setting.currentCollection.filterList}
|
||||
onChange={onChange}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
className={styles.filterMenuTrigger}
|
||||
type="default"
|
||||
icon={<FilterIcon />}
|
||||
data-testid="create-first-filter"
|
||||
>
|
||||
{t['com.affine.filter']()}
|
||||
</Button>
|
||||
</Menu>
|
||||
<EditCollectionModal
|
||||
allPageListConfig={allPageListConfig}
|
||||
init={collection}
|
||||
open={!!collection}
|
||||
onOpenChange={closeUpdateCollectionModal}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<CollectionOperations
|
||||
info={userInfo}
|
||||
collection={setting.currentCollection}
|
||||
config={allPageListConfig}
|
||||
setting={setting}
|
||||
>
|
||||
<Button
|
||||
className={styles.filterMenuTrigger}
|
||||
type="default"
|
||||
icon={<FilterIcon />}
|
||||
data-testid="create-first-filter"
|
||||
>
|
||||
{t['com.affine.filter']()}
|
||||
</Button>
|
||||
</CollectionOperations>
|
||||
)}
|
||||
</FlexWrapper>
|
||||
);
|
||||
};
|
||||
@@ -1,10 +0,0 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const divider = style({
|
||||
marginTop: '2px',
|
||||
marginBottom: '2px',
|
||||
marginLeft: '12px',
|
||||
marginRight: '8px',
|
||||
height: '1px',
|
||||
background: 'var(--affine-border-color)',
|
||||
});
|
||||
@@ -1,147 +0,0 @@
|
||||
import type { Collection, DeleteCollectionInfo } from '@affine/env/filter';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { DeleteIcon, EditIcon, FilterIcon } from '@blocksuite/icons';
|
||||
import {
|
||||
type PropsWithChildren,
|
||||
type ReactElement,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
|
||||
import { Menu, MenuIcon, MenuItem, type MenuItemProps } from '../../../ui/menu';
|
||||
import type { useCollectionManager } from '../use-collection-manager';
|
||||
import type { AllPageListConfig } from '.';
|
||||
import * as styles from './collection-operations.css';
|
||||
import {
|
||||
useEditCollection,
|
||||
useEditCollectionName,
|
||||
} from './use-edit-collection';
|
||||
|
||||
export const CollectionOperations = ({
|
||||
collection,
|
||||
config,
|
||||
setting,
|
||||
info,
|
||||
openRenameModal,
|
||||
children,
|
||||
}: PropsWithChildren<{
|
||||
info: DeleteCollectionInfo;
|
||||
collection: Collection;
|
||||
config: AllPageListConfig;
|
||||
setting: ReturnType<typeof useCollectionManager>;
|
||||
openRenameModal?: () => void;
|
||||
}>) => {
|
||||
const { open: openEditCollectionModal, node: editModal } =
|
||||
useEditCollection(config);
|
||||
const t = useAFFiNEI18N();
|
||||
const { open: openEditCollectionNameModal, node: editNameModal } =
|
||||
useEditCollectionName({
|
||||
title: t['com.affine.editCollection.renameCollection'](),
|
||||
});
|
||||
|
||||
const showEditName = useCallback(() => {
|
||||
// use openRenameModal if it is in the sidebar collection list
|
||||
if (openRenameModal) {
|
||||
return openRenameModal();
|
||||
}
|
||||
openEditCollectionNameModal(collection.name)
|
||||
.then(name => {
|
||||
return setting.updateCollection({ ...collection, name });
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
});
|
||||
}, [openRenameModal, openEditCollectionNameModal, collection, setting]);
|
||||
|
||||
const showEdit = useCallback(() => {
|
||||
openEditCollectionModal(collection)
|
||||
.then(collection => {
|
||||
return setting.updateCollection(collection);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
});
|
||||
}, [setting, collection, openEditCollectionModal]);
|
||||
|
||||
const actions = useMemo<
|
||||
Array<
|
||||
| {
|
||||
icon: ReactElement;
|
||||
name: string;
|
||||
click: () => void;
|
||||
type?: MenuItemProps['type'];
|
||||
element?: undefined;
|
||||
}
|
||||
| {
|
||||
element: ReactElement;
|
||||
}
|
||||
>
|
||||
>(
|
||||
() => [
|
||||
{
|
||||
icon: (
|
||||
<MenuIcon>
|
||||
<EditIcon />
|
||||
</MenuIcon>
|
||||
),
|
||||
name: t['com.affine.collection.menu.rename'](),
|
||||
click: showEditName,
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<MenuIcon>
|
||||
<FilterIcon />
|
||||
</MenuIcon>
|
||||
),
|
||||
name: t['com.affine.collection.menu.edit'](),
|
||||
click: showEdit,
|
||||
},
|
||||
{
|
||||
element: <div key="divider" className={styles.divider}></div>,
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<MenuIcon>
|
||||
<DeleteIcon />
|
||||
</MenuIcon>
|
||||
),
|
||||
name: t['Delete'](),
|
||||
click: () => {
|
||||
setting.deleteCollection(info, collection.id);
|
||||
},
|
||||
type: 'danger',
|
||||
},
|
||||
],
|
||||
[t, showEditName, showEdit, setting, info, collection.id]
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{editModal}
|
||||
{editNameModal}
|
||||
<Menu
|
||||
items={
|
||||
<div style={{ minWidth: 150 }}>
|
||||
{actions.map(action => {
|
||||
if (action.element) {
|
||||
return action.element;
|
||||
}
|
||||
return (
|
||||
<MenuItem
|
||||
data-testid="collection-option"
|
||||
key={action.name}
|
||||
type={action.type}
|
||||
preFix={action.icon}
|
||||
onClick={action.click}
|
||||
>
|
||||
{action.name}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,28 +0,0 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const footer = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
paddingTop: 20,
|
||||
gap: 20,
|
||||
});
|
||||
|
||||
export const createTips = style({
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
fontSize: 12,
|
||||
lineHeight: '20px',
|
||||
});
|
||||
|
||||
export const label = style({
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
fontSize: 14,
|
||||
lineHeight: '22px',
|
||||
});
|
||||
|
||||
export const content = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
padding: '12px 0px 20px',
|
||||
marginBottom: 8,
|
||||
});
|
||||
@@ -1,117 +0,0 @@
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { Button } from '../../../ui/button';
|
||||
import Input from '../../../ui/input';
|
||||
import { Modal } from '../../../ui/modal';
|
||||
import * as styles from './create-collection.css';
|
||||
|
||||
export interface CreateCollectionModalProps {
|
||||
title?: string;
|
||||
onConfirmText?: string;
|
||||
init: string;
|
||||
onConfirm: (title: string) => void;
|
||||
open: boolean;
|
||||
showTips?: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const CreateCollectionModal = ({
|
||||
init,
|
||||
onConfirm,
|
||||
open,
|
||||
showTips,
|
||||
onOpenChange,
|
||||
title,
|
||||
}: CreateCollectionModalProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const onConfirmTitle = useCallback(
|
||||
(title: string) => {
|
||||
onConfirm(title);
|
||||
onOpenChange(false);
|
||||
},
|
||||
[onConfirm, onOpenChange]
|
||||
);
|
||||
const onCancel = useCallback(() => {
|
||||
onOpenChange(false);
|
||||
}, [onOpenChange]);
|
||||
|
||||
return (
|
||||
<Modal open={open} title={title} onOpenChange={onOpenChange} width={480}>
|
||||
{init != null ? (
|
||||
<CreateCollection
|
||||
showTips={showTips}
|
||||
onConfirmText={t['com.affine.editCollection.save']()}
|
||||
init={init}
|
||||
onConfirm={onConfirmTitle}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
) : null}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export interface CreateCollectionProps {
|
||||
onConfirmText?: string;
|
||||
init: string;
|
||||
showTips?: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: (title: string) => void;
|
||||
}
|
||||
|
||||
export const CreateCollection = ({
|
||||
onConfirmText,
|
||||
init,
|
||||
showTips,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: CreateCollectionProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const [value, onChange] = useState(init);
|
||||
const isNameEmpty = useMemo(() => value.trim().length === 0, [value]);
|
||||
const save = useCallback(() => {
|
||||
if (isNameEmpty) {
|
||||
return;
|
||||
}
|
||||
onConfirm(value);
|
||||
}, [onConfirm, value, isNameEmpty]);
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.label}>
|
||||
{t['com.affine.editCollectionName.name']()}
|
||||
</div>
|
||||
<Input
|
||||
autoFocus
|
||||
value={value}
|
||||
onKeyDown={e => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
data-testid="input-collection-title"
|
||||
placeholder={t['com.affine.editCollectionName.name.placeholder']()}
|
||||
onChange={useCallback((value: string) => onChange(value), [onChange])}
|
||||
onEnter={save}
|
||||
></Input>
|
||||
{showTips ? (
|
||||
<div className={styles.createTips}>
|
||||
{t['com.affine.editCollectionName.createTips']()}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.footer}>
|
||||
<Button size="large" onClick={onCancel}>
|
||||
{t['com.affine.editCollection.button.cancel']()}
|
||||
</Button>
|
||||
<Button
|
||||
size="large"
|
||||
data-testid="save-collection"
|
||||
type="primary"
|
||||
disabled={isNameEmpty}
|
||||
onClick={save}
|
||||
>
|
||||
{onConfirmText ?? t['com.affine.editCollection.button.create']()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,228 +0,0 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const ellipsis = style({
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
|
||||
export const pagesBottomLeft = style({
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
});
|
||||
|
||||
export const pagesBottom = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
padding: '20px 24px',
|
||||
borderTop: '1px solid var(--affine-border-color)',
|
||||
flexWrap: 'wrap',
|
||||
gap: '12px',
|
||||
});
|
||||
|
||||
export const pagesTabContent = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
padding: '16px 16px 8px 16px',
|
||||
});
|
||||
|
||||
export const pagesTab = style({
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
export const pagesList = style({
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
export const bottomLeft = style({
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
});
|
||||
|
||||
export const rulesBottom = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
padding: '20px 24px',
|
||||
borderTop: '1px solid var(--affine-border-color)',
|
||||
flexWrap: 'wrap',
|
||||
gap: '12px',
|
||||
});
|
||||
|
||||
export const includeListTitle = style({
|
||||
fontSize: 14,
|
||||
fontWeight: 400,
|
||||
lineHeight: '22px',
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
padding: '4px 16px',
|
||||
borderTop: '1px solid var(--affine-border-color)',
|
||||
});
|
||||
|
||||
export const rulesContainerRight = style({
|
||||
flex: 2,
|
||||
flexDirection: 'column',
|
||||
borderLeft: '1px solid var(--affine-border-color)',
|
||||
overflowX: 'hidden',
|
||||
overflowY: 'auto',
|
||||
});
|
||||
|
||||
export const includeAddButton = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '4px 8px',
|
||||
fontSize: 14,
|
||||
lineHeight: '22px',
|
||||
width: 'max-content',
|
||||
});
|
||||
|
||||
export const includeItemTitle = style({ overflow: 'hidden', fontWeight: 600 });
|
||||
|
||||
export const includeItemContentIs = style({
|
||||
padding: '0 8px',
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
});
|
||||
|
||||
export const includeItemContent = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
fontSize: 12,
|
||||
lineHeight: '20px',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
export const includeItem = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: 'max-content',
|
||||
backgroundColor: 'var(--affine-background-primary-color)',
|
||||
overflow: 'hidden',
|
||||
gap: 16,
|
||||
whiteSpace: 'nowrap',
|
||||
border: '1px solid var(--affine-border-color)',
|
||||
borderRadius: 8,
|
||||
padding: '4px 8px 4px',
|
||||
});
|
||||
|
||||
export const includeTitle = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
fontSize: 14,
|
||||
lineHeight: '22px',
|
||||
});
|
||||
|
||||
export const rulesContainerLeftContentInclude = style({
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
flexShrink: 0,
|
||||
});
|
||||
|
||||
export const rulesContainerLeftContent = style({
|
||||
padding: '12px 16px 16px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
export const rulesContainerLeftTab = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
padding: '16px 16px 8px 16px',
|
||||
});
|
||||
|
||||
export const rulesContainerLeft = style({
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
export const rulesContainer = style({
|
||||
display: 'flex',
|
||||
overflow: 'hidden',
|
||||
flex: 1,
|
||||
});
|
||||
|
||||
export const collectionEditContainer = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
});
|
||||
|
||||
export const confirmButton = style({
|
||||
marginLeft: 20,
|
||||
});
|
||||
|
||||
export const resultPages = style({
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
export const pageList = style({
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
export const previewCountTipsHighlight = style({
|
||||
color: 'var(--affine-primary-color)',
|
||||
});
|
||||
|
||||
export const previewCountTips = style({
|
||||
fontSize: 12,
|
||||
lineHeight: '20px',
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
});
|
||||
export const selectedCountTips = style({
|
||||
fontSize: 12,
|
||||
lineHeight: '20px',
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
});
|
||||
|
||||
export const rulesTitleHighlight = style({
|
||||
color: 'var(--affine-primary-color)',
|
||||
fontStyle: 'italic',
|
||||
fontWeight: 800,
|
||||
});
|
||||
|
||||
export const tabButton = style({ height: 28 });
|
||||
export const icon = style({
|
||||
color: 'var(--affine-icon-color)',
|
||||
});
|
||||
export const button = style({
|
||||
userSelect: 'none',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
':hover': {
|
||||
backgroundColor: 'var(--affine-hover-color)',
|
||||
},
|
||||
});
|
||||
export const bottomButton = style({
|
||||
padding: '4px 12px',
|
||||
borderRadius: 8,
|
||||
});
|
||||
|
||||
export const previewActive = style({
|
||||
backgroundColor: 'var(--affine-hover-color-filled)',
|
||||
});
|
||||
export const rulesTitle = style({
|
||||
padding: '20px 24px',
|
||||
userSelect: 'none',
|
||||
fontSize: 20,
|
||||
lineHeight: '24px',
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
borderBottom: '1px solid var(--affine-border-color)',
|
||||
});
|
||||
@@ -1,203 +0,0 @@
|
||||
import type { Collection } from '@affine/env/filter';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import type { PageMeta, Workspace } from '@blocksuite/store';
|
||||
import type { DialogContentProps } from '@radix-ui/react-dialog';
|
||||
import { type ReactNode, useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { RadioButton, RadioButtonGroup } from '../../../../index';
|
||||
import { Button } from '../../../../ui/button';
|
||||
import { Modal } from '../../../../ui/modal';
|
||||
import * as styles from './edit-collection.css';
|
||||
import { PagesMode } from './pages-mode';
|
||||
import { RulesMode } from './rules-mode';
|
||||
|
||||
export type EditCollectionMode = 'page' | 'rule';
|
||||
|
||||
export interface EditCollectionModalProps {
|
||||
init?: Collection;
|
||||
title?: string;
|
||||
open: boolean;
|
||||
mode?: EditCollectionMode;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: (view: Collection) => void;
|
||||
allPageListConfig: AllPageListConfig;
|
||||
}
|
||||
|
||||
const contentOptions: DialogContentProps = {
|
||||
onPointerDownOutside: e => {
|
||||
e.preventDefault();
|
||||
},
|
||||
style: {
|
||||
padding: 0,
|
||||
maxWidth: 944,
|
||||
backgroundColor: 'var(--affine-background-primary-color)',
|
||||
},
|
||||
};
|
||||
export const EditCollectionModal = ({
|
||||
init,
|
||||
onConfirm,
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
mode,
|
||||
allPageListConfig,
|
||||
}: EditCollectionModalProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const onConfirmOnCollection = useCallback(
|
||||
(view: Collection) => {
|
||||
onConfirm(view);
|
||||
onOpenChange(false);
|
||||
},
|
||||
[onConfirm, onOpenChange]
|
||||
);
|
||||
const onCancel = useCallback(() => {
|
||||
onOpenChange(false);
|
||||
}, [onOpenChange]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
withoutCloseButton
|
||||
width="calc(100% - 64px)"
|
||||
height="80%"
|
||||
contentOptions={contentOptions}
|
||||
>
|
||||
{init ? (
|
||||
<EditCollection
|
||||
title={title}
|
||||
onConfirmText={t['com.affine.editCollection.save']()}
|
||||
init={init}
|
||||
mode={mode}
|
||||
onCancel={onCancel}
|
||||
onConfirm={onConfirmOnCollection}
|
||||
allPageListConfig={allPageListConfig}
|
||||
/>
|
||||
) : null}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export interface EditCollectionProps {
|
||||
title?: string;
|
||||
onConfirmText?: string;
|
||||
init: Collection;
|
||||
mode?: EditCollectionMode;
|
||||
onCancel: () => void;
|
||||
onConfirm: (collection: Collection) => void;
|
||||
allPageListConfig: AllPageListConfig;
|
||||
}
|
||||
|
||||
export const EditCollection = ({
|
||||
init,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
onConfirmText,
|
||||
mode: initMode,
|
||||
allPageListConfig,
|
||||
}: EditCollectionProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const [value, onChange] = useState<Collection>(init);
|
||||
const [mode, setMode] = useState<'page' | 'rule'>(
|
||||
initMode ?? (init.filterList.length === 0 ? 'page' : 'rule')
|
||||
);
|
||||
const isNameEmpty = useMemo(() => value.name.trim().length === 0, [value]);
|
||||
const onSaveCollection = useCallback(() => {
|
||||
if (!isNameEmpty) {
|
||||
onConfirm(value);
|
||||
}
|
||||
}, [value, isNameEmpty, onConfirm]);
|
||||
const reset = useCallback(() => {
|
||||
onChange({
|
||||
...value,
|
||||
filterList: init.filterList,
|
||||
allowList: init.allowList,
|
||||
});
|
||||
}, [init.allowList, init.filterList, value]);
|
||||
const buttons = useMemo(
|
||||
() => (
|
||||
<>
|
||||
<Button size="large" onClick={onCancel}>
|
||||
{t['com.affine.editCollection.button.cancel']()}
|
||||
</Button>
|
||||
<Button
|
||||
className={styles.confirmButton}
|
||||
size="large"
|
||||
data-testid="save-collection"
|
||||
type="primary"
|
||||
disabled={isNameEmpty}
|
||||
onClick={onSaveCollection}
|
||||
>
|
||||
{onConfirmText ?? t['com.affine.editCollection.button.create']()}
|
||||
</Button>
|
||||
</>
|
||||
),
|
||||
[onCancel, t, isNameEmpty, onSaveCollection, onConfirmText]
|
||||
);
|
||||
const switchMode = useMemo(
|
||||
() => (
|
||||
<RadioButtonGroup
|
||||
width={158}
|
||||
style={{ height: 32 }}
|
||||
value={mode}
|
||||
onValueChange={(mode: 'page' | 'rule') => {
|
||||
setMode(mode);
|
||||
}}
|
||||
>
|
||||
<RadioButton
|
||||
spanStyle={styles.tabButton}
|
||||
value="page"
|
||||
data-testid="edit-collection-pages-button"
|
||||
>
|
||||
{t['com.affine.editCollection.pages']()}
|
||||
</RadioButton>
|
||||
<RadioButton
|
||||
spanStyle={styles.tabButton}
|
||||
value="rule"
|
||||
data-testid="edit-collection-rules-button"
|
||||
>
|
||||
{t['com.affine.editCollection.rules']()}
|
||||
</RadioButton>
|
||||
</RadioButtonGroup>
|
||||
),
|
||||
[mode, t]
|
||||
);
|
||||
return (
|
||||
<div
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Escape') {
|
||||
return;
|
||||
}
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className={styles.collectionEditContainer}
|
||||
>
|
||||
{mode === 'page' ? (
|
||||
<PagesMode
|
||||
collection={value}
|
||||
updateCollection={onChange}
|
||||
switchMode={switchMode}
|
||||
buttons={buttons}
|
||||
allPageListConfig={allPageListConfig}
|
||||
></PagesMode>
|
||||
) : (
|
||||
<RulesMode
|
||||
allPageListConfig={allPageListConfig}
|
||||
collection={value}
|
||||
switchMode={switchMode}
|
||||
reset={reset}
|
||||
updateCollection={onChange}
|
||||
buttons={buttons}
|
||||
></RulesMode>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type AllPageListConfig = {
|
||||
allPages: PageMeta[];
|
||||
workspace: Workspace;
|
||||
isEdgeless: (id: string) => boolean;
|
||||
getPage: (id: string) => PageMeta | undefined;
|
||||
favoriteRender: (page: PageMeta) => ReactNode;
|
||||
};
|
||||
@@ -1,57 +0,0 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { Modal } from '../../../../ui/modal';
|
||||
import type { AllPageListConfig } from './edit-collection';
|
||||
import { SelectPage } from './select-page';
|
||||
export const useSelectPage = ({
|
||||
allPageListConfig,
|
||||
}: {
|
||||
allPageListConfig: AllPageListConfig;
|
||||
}) => {
|
||||
const [value, onChange] = useState<{
|
||||
init: string[];
|
||||
onConfirm: (ids: string[]) => void;
|
||||
}>();
|
||||
const close = useCallback(() => {
|
||||
onChange(undefined);
|
||||
}, []);
|
||||
return {
|
||||
node: (
|
||||
<Modal
|
||||
open={!!value}
|
||||
onOpenChange={close}
|
||||
withoutCloseButton
|
||||
width="calc(100% - 32px)"
|
||||
height="80%"
|
||||
overlayOptions={{ style: { backgroundColor: 'transparent' } }}
|
||||
contentOptions={{
|
||||
style: {
|
||||
padding: 0,
|
||||
transform: 'translateY(16px)',
|
||||
maxWidth: 976,
|
||||
backgroundColor: 'var(--affine-white)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{value ? (
|
||||
<SelectPage
|
||||
allPageListConfig={allPageListConfig}
|
||||
init={value.init}
|
||||
onConfirm={value.onConfirm}
|
||||
onCancel={close}
|
||||
/>
|
||||
) : null}
|
||||
</Modal>
|
||||
),
|
||||
open: (init: string[]): Promise<string[]> =>
|
||||
new Promise<string[]>(res => {
|
||||
onChange({
|
||||
init,
|
||||
onConfirm: list => {
|
||||
close();
|
||||
res(list);
|
||||
},
|
||||
});
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -1,149 +0,0 @@
|
||||
import type { Collection } from '@affine/env/filter';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { FilterIcon } from '@blocksuite/icons';
|
||||
import type { PageMeta } from '@blocksuite/store';
|
||||
import clsx from 'clsx';
|
||||
import { type ReactNode, useCallback } from 'react';
|
||||
|
||||
import { Menu } from '../../../../ui/menu';
|
||||
import { FilterList } from '../../filter/filter-list';
|
||||
import { VariableSelect } from '../../filter/vars';
|
||||
import { VirtualizedPageList } from '../../virtualized-page-list';
|
||||
import type { AllPageListConfig } from './edit-collection';
|
||||
import * as styles from './edit-collection.css';
|
||||
import { EmptyList } from './select-page';
|
||||
import { useFilter } from './use-filter';
|
||||
import { useSearch } from './use-search';
|
||||
|
||||
export const PagesMode = ({
|
||||
switchMode,
|
||||
collection,
|
||||
updateCollection,
|
||||
buttons,
|
||||
allPageListConfig,
|
||||
}: {
|
||||
collection: Collection;
|
||||
updateCollection: (collection: Collection) => void;
|
||||
buttons: ReactNode;
|
||||
switchMode: ReactNode;
|
||||
allPageListConfig: AllPageListConfig;
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const {
|
||||
showFilter,
|
||||
filters,
|
||||
updateFilters,
|
||||
clickFilter,
|
||||
createFilter,
|
||||
filteredList,
|
||||
} = useFilter(allPageListConfig.allPages);
|
||||
const { searchText, updateSearchText, searchedList } =
|
||||
useSearch(filteredList);
|
||||
const clearSelected = useCallback(() => {
|
||||
updateCollection({
|
||||
...collection,
|
||||
allowList: [],
|
||||
});
|
||||
}, [collection, updateCollection]);
|
||||
const pageOperationsRenderer = useCallback(
|
||||
(page: PageMeta) => allPageListConfig.favoriteRender(page),
|
||||
[allPageListConfig]
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
value={searchText}
|
||||
onChange={e => updateSearchText(e.target.value)}
|
||||
className={styles.rulesTitle}
|
||||
style={{
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
}}
|
||||
placeholder={t['com.affine.editCollection.search.placeholder']()}
|
||||
></input>
|
||||
<div className={styles.pagesList}>
|
||||
<div className={styles.pagesTab}>
|
||||
<div className={styles.pagesTabContent}>
|
||||
{switchMode}
|
||||
{!showFilter && filters.length === 0 ? (
|
||||
<Menu
|
||||
items={
|
||||
<VariableSelect
|
||||
propertiesMeta={allPageListConfig.workspace.meta.properties}
|
||||
selected={filters}
|
||||
onSelect={createFilter}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<FilterIcon
|
||||
className={clsx(styles.icon, styles.button)}
|
||||
onClick={clickFilter}
|
||||
width={24}
|
||||
height={24}
|
||||
></FilterIcon>
|
||||
</div>
|
||||
</Menu>
|
||||
) : (
|
||||
<FilterIcon
|
||||
className={clsx(styles.icon, styles.button)}
|
||||
onClick={clickFilter}
|
||||
width={24}
|
||||
height={24}
|
||||
></FilterIcon>
|
||||
)}
|
||||
</div>
|
||||
{showFilter ? (
|
||||
<div style={{ padding: '12px 16px 16px' }}>
|
||||
<FilterList
|
||||
propertiesMeta={allPageListConfig.workspace.meta.properties}
|
||||
value={filters}
|
||||
onChange={updateFilters}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{searchedList.length ? (
|
||||
<VirtualizedPageList
|
||||
className={styles.pageList}
|
||||
pages={searchedList}
|
||||
groupBy={false}
|
||||
blockSuiteWorkspace={allPageListConfig.workspace}
|
||||
selectable
|
||||
onSelectedPageIdsChange={ids => {
|
||||
updateCollection({
|
||||
...collection,
|
||||
allowList: ids,
|
||||
});
|
||||
}}
|
||||
pageOperationsRenderer={pageOperationsRenderer}
|
||||
selectedPageIds={collection.allowList}
|
||||
isPreferredEdgeless={allPageListConfig.isEdgeless}
|
||||
></VirtualizedPageList>
|
||||
) : (
|
||||
<EmptyList search={searchText} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.pagesBottom}>
|
||||
<div className={styles.pagesBottomLeft}>
|
||||
<div className={styles.selectedCountTips}>
|
||||
{t['com.affine.selectPage.selected']()}
|
||||
<span
|
||||
style={{ marginLeft: 7 }}
|
||||
className={styles.previewCountTipsHighlight}
|
||||
>
|
||||
{collection.allowList.length}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={clsx(styles.button, styles.bottomButton)}
|
||||
style={{ fontSize: 12, lineHeight: '20px' }}
|
||||
onClick={clearSelected}
|
||||
>
|
||||
{t['com.affine.editCollection.pages.clear']()}
|
||||
</div>
|
||||
</div>
|
||||
<div>{buttons}</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,370 +0,0 @@
|
||||
import type { Collection } from '@affine/env/filter';
|
||||
import { Trans } from '@affine/i18n';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import {
|
||||
CloseIcon,
|
||||
EdgelessIcon,
|
||||
PageIcon,
|
||||
PlusIcon,
|
||||
ToggleCollapseIcon,
|
||||
} from '@blocksuite/icons';
|
||||
import type { PageMeta } from '@blocksuite/store';
|
||||
import clsx from 'clsx';
|
||||
import { type ReactNode, useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { FilterList } from '../../filter';
|
||||
import { PageList, PageListScrollContainer } from '../../page-list';
|
||||
import { filterPageByRules } from '../../use-collection-manager';
|
||||
import { AffineShapeIcon } from '../affine-shape';
|
||||
import type { AllPageListConfig } from './edit-collection';
|
||||
import * as styles from './edit-collection.css';
|
||||
import { useSelectPage } from './hooks';
|
||||
|
||||
export const RulesMode = ({
|
||||
collection,
|
||||
updateCollection,
|
||||
reset,
|
||||
buttons,
|
||||
switchMode,
|
||||
allPageListConfig,
|
||||
}: {
|
||||
collection: Collection;
|
||||
updateCollection: (collection: Collection) => void;
|
||||
reset: () => void;
|
||||
buttons: ReactNode;
|
||||
switchMode: ReactNode;
|
||||
allPageListConfig: AllPageListConfig;
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const [showPreview, setShowPreview] = useState(true);
|
||||
const allowListPages: PageMeta[] = [];
|
||||
const rulesPages: PageMeta[] = [];
|
||||
const [showTips, setShowTips] = useState(false);
|
||||
useEffect(() => {
|
||||
setShowTips(!localStorage.getItem('hide-rules-mode-include-page-tips'));
|
||||
}, []);
|
||||
const hideTips = useCallback(() => {
|
||||
setShowTips(false);
|
||||
localStorage.setItem('hide-rules-mode-include-page-tips', 'true');
|
||||
}, []);
|
||||
allPageListConfig.allPages.forEach(v => {
|
||||
if (v.trash) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
collection.filterList.length &&
|
||||
filterPageByRules(collection.filterList, [], v)
|
||||
) {
|
||||
rulesPages.push(v);
|
||||
}
|
||||
if (collection.allowList.includes(v.id)) {
|
||||
allowListPages.push(v);
|
||||
}
|
||||
});
|
||||
const { node: selectPageNode, open } = useSelectPage({ allPageListConfig });
|
||||
const openSelectPage = useCallback(() => {
|
||||
open(collection.allowList).then(
|
||||
ids => {
|
||||
updateCollection({
|
||||
...collection,
|
||||
allowList: ids,
|
||||
});
|
||||
},
|
||||
() => {
|
||||
//do nothing
|
||||
}
|
||||
);
|
||||
}, [open, updateCollection, collection]);
|
||||
const [expandInclude, setExpandInclude] = useState(
|
||||
collection.allowList.length > 0
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{/*prevents modal autofocus to the first input*/}
|
||||
<input
|
||||
type="text"
|
||||
style={{ width: 0, height: 0 }}
|
||||
onFocus={e => requestAnimationFrame(() => e.target.blur())}
|
||||
/>
|
||||
<div className={clsx(styles.rulesTitle, styles.ellipsis)}>
|
||||
<Trans
|
||||
i18nKey="com.affine.editCollection.rules.tips"
|
||||
values={{
|
||||
highlight: t['com.affine.editCollection.rules.tips.highlight'](),
|
||||
}}
|
||||
components={{
|
||||
2: <span className={styles.rulesTitleHighlight} />,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.rulesContainer}>
|
||||
<div className={styles.rulesContainerLeft}>
|
||||
<div className={styles.rulesContainerLeftTab}>{switchMode}</div>
|
||||
<div className={styles.rulesContainerLeftContent}>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
<FilterList
|
||||
propertiesMeta={allPageListConfig.workspace.meta.properties}
|
||||
value={collection.filterList}
|
||||
onChange={useCallback(
|
||||
filterList => updateCollection({ ...collection, filterList }),
|
||||
[collection, updateCollection]
|
||||
)}
|
||||
/>
|
||||
<div className={styles.rulesContainerLeftContentInclude}>
|
||||
<div className={styles.includeTitle}>
|
||||
<ToggleCollapseIcon
|
||||
onClick={() => setExpandInclude(!expandInclude)}
|
||||
className={styles.button}
|
||||
width={24}
|
||||
height={24}
|
||||
style={{
|
||||
transform: expandInclude ? 'rotate(90deg)' : undefined,
|
||||
}}
|
||||
></ToggleCollapseIcon>
|
||||
<div style={{ color: 'var(--affine-text-secondary-color)' }}>
|
||||
{t['com.affine.editCollection.rules.include.title']()}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: expandInclude ? 'flex' : 'none',
|
||||
flexWrap: 'wrap',
|
||||
gap: '8px 16px',
|
||||
}}
|
||||
>
|
||||
{collection.allowList.map(id => {
|
||||
const page = allPageListConfig.allPages.find(
|
||||
v => v.id === id
|
||||
);
|
||||
return (
|
||||
<div className={styles.includeItem} key={id}>
|
||||
<div className={styles.includeItemContent}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 6,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{allPageListConfig.isEdgeless(id) ? (
|
||||
<EdgelessIcon style={{ width: 16, height: 16 }} />
|
||||
) : (
|
||||
<PageIcon style={{ width: 16, height: 16 }} />
|
||||
)}
|
||||
{t[
|
||||
'com.affine.editCollection.rules.include.page'
|
||||
]()}
|
||||
</div>
|
||||
<div className={styles.includeItemContentIs}>
|
||||
{t['com.affine.editCollection.rules.include.is']()}
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
styles.includeItemTitle,
|
||||
styles.ellipsis
|
||||
)}
|
||||
>
|
||||
{page?.title || t['Untitled']()}
|
||||
</div>
|
||||
</div>
|
||||
<CloseIcon
|
||||
className={styles.button}
|
||||
onClick={() => {
|
||||
updateCollection({
|
||||
...collection,
|
||||
allowList: collection.allowList.filter(
|
||||
v => v !== id
|
||||
),
|
||||
});
|
||||
}}
|
||||
></CloseIcon>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div
|
||||
onClick={openSelectPage}
|
||||
className={clsx(styles.button, styles.includeAddButton)}
|
||||
>
|
||||
<PlusIcon></PlusIcon>
|
||||
<div
|
||||
style={{ color: 'var(--affine-text-secondary-color)' }}
|
||||
>
|
||||
{t['com.affine.editCollection.rules.include.add']()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{showTips ? (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
borderRadius: 8,
|
||||
backgroundColor:
|
||||
'var(--affine-background-overlay-panel-color)',
|
||||
padding: 10,
|
||||
fontSize: 12,
|
||||
lineHeight: '20px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 14,
|
||||
fontWeight: 600,
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<div>{t['com.affine.collection.helpInfo']()}</div>
|
||||
<CloseIcon
|
||||
color="var(--affine-icon-color)"
|
||||
onClick={hideTips}
|
||||
className={styles.button}
|
||||
style={{ width: 16, height: 16 }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: 10, fontWeight: 600 }}>
|
||||
{t['com.affine.editCollection.rules.include.tipsTitle']()}
|
||||
</div>
|
||||
<div>{t['com.affine.editCollection.rules.include.tips']()}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<PageListScrollContainer
|
||||
className={styles.rulesContainerRight}
|
||||
style={{
|
||||
display: showPreview ? 'flex' : 'none',
|
||||
}}
|
||||
>
|
||||
{rulesPages.length > 0 ? (
|
||||
<PageList
|
||||
hideHeader
|
||||
className={styles.resultPages}
|
||||
pages={rulesPages}
|
||||
groupBy={false}
|
||||
blockSuiteWorkspace={allPageListConfig.workspace}
|
||||
isPreferredEdgeless={allPageListConfig.isEdgeless}
|
||||
pageOperationsRenderer={allPageListConfig.favoriteRender}
|
||||
></PageList>
|
||||
) : (
|
||||
<RulesEmpty
|
||||
noRules={collection.filterList.length === 0}
|
||||
fullHeight={allowListPages.length === 0}
|
||||
/>
|
||||
)}
|
||||
{allowListPages.length > 0 ? (
|
||||
<div>
|
||||
<div className={styles.includeListTitle}>
|
||||
{t['com.affine.editCollection.rules.include.title']()}
|
||||
</div>
|
||||
<PageList
|
||||
hideHeader
|
||||
className={styles.resultPages}
|
||||
pages={allowListPages}
|
||||
groupBy={false}
|
||||
blockSuiteWorkspace={allPageListConfig.workspace}
|
||||
isPreferredEdgeless={allPageListConfig.isEdgeless}
|
||||
pageOperationsRenderer={allPageListConfig.favoriteRender}
|
||||
></PageList>
|
||||
</div>
|
||||
) : null}
|
||||
</PageListScrollContainer>
|
||||
</div>
|
||||
<div className={styles.rulesBottom}>
|
||||
<div className={styles.bottomLeft}>
|
||||
<div
|
||||
className={clsx(
|
||||
styles.button,
|
||||
styles.bottomButton,
|
||||
showPreview && styles.previewActive
|
||||
)}
|
||||
onClick={() => {
|
||||
setShowPreview(!showPreview);
|
||||
}}
|
||||
>
|
||||
{t['com.affine.editCollection.rules.preview']()}
|
||||
</div>
|
||||
<div
|
||||
className={clsx(styles.button, styles.bottomButton)}
|
||||
onClick={reset}
|
||||
>
|
||||
{t['com.affine.editCollection.rules.reset']()}
|
||||
</div>
|
||||
<div className={styles.previewCountTips}>
|
||||
<Trans
|
||||
i18nKey="com.affine.editCollection.rules.countTips"
|
||||
values={{
|
||||
selectedCount: allowListPages.length,
|
||||
filteredCount: rulesPages.length,
|
||||
}}
|
||||
>
|
||||
Selected
|
||||
<span className={styles.previewCountTipsHighlight}>count</span>,
|
||||
filtered
|
||||
<span className={styles.previewCountTipsHighlight}>count</span>
|
||||
</Trans>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>{buttons}</div>
|
||||
</div>
|
||||
{selectPageNode}
|
||||
</>
|
||||
);
|
||||
};
|
||||
const RulesEmpty = ({
|
||||
noRules,
|
||||
fullHeight,
|
||||
}: {
|
||||
noRules: boolean;
|
||||
fullHeight: boolean;
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: fullHeight ? '100%' : '70%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 18,
|
||||
padding: '48px 0',
|
||||
}}
|
||||
>
|
||||
<AffineShapeIcon />
|
||||
<strong style={{ fontSize: 20, lineHeight: '28px' }}>
|
||||
{noRules
|
||||
? t['com.affine.editCollection.rules.empty.noRules']()
|
||||
: t['com.affine.editCollection.rules.empty.noResults']()}
|
||||
</strong>
|
||||
<div
|
||||
style={{
|
||||
width: '389px',
|
||||
textAlign: 'center',
|
||||
fontSize: 15,
|
||||
lineHeight: '24px',
|
||||
}}
|
||||
>
|
||||
{noRules ? (
|
||||
<Trans i18nKey="com.affine.editCollection.rules.empty.noRules.tips">
|
||||
Please <strong>add rules</strong> to save this collection or switch
|
||||
to <strong>Pages</strong>, use manual selection mode
|
||||
</Trans>
|
||||
) : (
|
||||
t['com.affine.editCollection.rules.empty.noResults.tips']()
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,190 +0,0 @@
|
||||
import { Trans } from '@affine/i18n';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { FilterIcon } from '@blocksuite/icons';
|
||||
import clsx from 'clsx';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { Button } from '../../../../ui/button';
|
||||
import { Menu } from '../../../../ui/menu';
|
||||
import { FilterList } from '../../filter';
|
||||
import { VariableSelect } from '../../filter/vars';
|
||||
import { VirtualizedPageList } from '../../virtualized-page-list';
|
||||
import { AffineShapeIcon } from '../affine-shape';
|
||||
import type { AllPageListConfig } from './edit-collection';
|
||||
import * as styles from './edit-collection.css';
|
||||
import { useFilter } from './use-filter';
|
||||
import { useSearch } from './use-search';
|
||||
|
||||
export const SelectPage = ({
|
||||
allPageListConfig,
|
||||
init,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
allPageListConfig: AllPageListConfig;
|
||||
init: string[];
|
||||
onConfirm: (pageIds: string[]) => void;
|
||||
onCancel: () => void;
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const [value, onChange] = useState(init);
|
||||
const confirm = useCallback(() => {
|
||||
onConfirm(value);
|
||||
}, [value, onConfirm]);
|
||||
const clearSelected = useCallback(() => {
|
||||
onChange([]);
|
||||
}, []);
|
||||
const {
|
||||
clickFilter,
|
||||
createFilter,
|
||||
filters,
|
||||
showFilter,
|
||||
updateFilters,
|
||||
filteredList,
|
||||
} = useFilter(allPageListConfig.allPages);
|
||||
const { searchText, updateSearchText, searchedList } =
|
||||
useSearch(filteredList);
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<input
|
||||
className={styles.rulesTitle}
|
||||
value={searchText}
|
||||
onChange={e => updateSearchText(e.target.value)}
|
||||
placeholder={t['com.affine.editCollection.search.placeholder']()}
|
||||
></input>
|
||||
<div className={styles.pagesTab}>
|
||||
<div className={styles.pagesTabContent}>
|
||||
<div style={{ fontSize: 12, lineHeight: '20px', fontWeight: 600 }}>
|
||||
{t['com.affine.selectPage.title']()}
|
||||
</div>
|
||||
{!showFilter && filters.length === 0 ? (
|
||||
<Menu
|
||||
items={
|
||||
<VariableSelect
|
||||
propertiesMeta={allPageListConfig.workspace.meta.properties}
|
||||
selected={filters}
|
||||
onSelect={createFilter}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<FilterIcon
|
||||
className={clsx(styles.icon, styles.button)}
|
||||
onClick={clickFilter}
|
||||
width={24}
|
||||
height={24}
|
||||
></FilterIcon>
|
||||
</div>
|
||||
</Menu>
|
||||
) : (
|
||||
<FilterIcon
|
||||
className={clsx(styles.icon, styles.button)}
|
||||
onClick={clickFilter}
|
||||
width={24}
|
||||
height={24}
|
||||
></FilterIcon>
|
||||
)}
|
||||
</div>
|
||||
{showFilter ? (
|
||||
<div style={{ padding: '12px 16px 16px' }}>
|
||||
<FilterList
|
||||
propertiesMeta={allPageListConfig.workspace.meta.properties}
|
||||
value={filters}
|
||||
onChange={updateFilters}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{searchedList.length ? (
|
||||
<VirtualizedPageList
|
||||
className={styles.pageList}
|
||||
pages={searchedList}
|
||||
blockSuiteWorkspace={allPageListConfig.workspace}
|
||||
selectable
|
||||
groupBy={false}
|
||||
onSelectedPageIdsChange={onChange}
|
||||
selectedPageIds={value}
|
||||
isPreferredEdgeless={allPageListConfig.isEdgeless}
|
||||
pageOperationsRenderer={allPageListConfig.favoriteRender}
|
||||
/>
|
||||
) : (
|
||||
<EmptyList search={searchText} />
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.pagesBottom}>
|
||||
<div className={styles.pagesBottomLeft}>
|
||||
<div className={styles.selectedCountTips}>
|
||||
{t['com.affine.selectPage.selected']()}
|
||||
<span
|
||||
style={{ marginLeft: 7 }}
|
||||
className={styles.previewCountTipsHighlight}
|
||||
>
|
||||
{value.length}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={clsx(styles.button, styles.bottomButton)}
|
||||
style={{ fontSize: 12, lineHeight: '20px' }}
|
||||
onClick={clearSelected}
|
||||
>
|
||||
{t['com.affine.editCollection.pages.clear']()}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Button size="large" onClick={onCancel}>
|
||||
{t['com.affine.editCollection.button.cancel']()}
|
||||
</Button>
|
||||
<Button
|
||||
className={styles.confirmButton}
|
||||
size="large"
|
||||
data-testid="save-collection"
|
||||
type="primary"
|
||||
onClick={confirm}
|
||||
>
|
||||
{t['Confirm']()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export const EmptyList = ({ search }: { search?: string }) => {
|
||||
const t = useAFFiNEI18N();
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'column',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<AffineShapeIcon />
|
||||
<div
|
||||
style={{
|
||||
margin: '18px 0',
|
||||
fontSize: 20,
|
||||
lineHeight: '28px',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{t['com.affine.selectPage.empty']()}
|
||||
</div>
|
||||
{search ? (
|
||||
<div
|
||||
className={styles.ellipsis}
|
||||
style={{ maxWidth: 300, fontSize: 15, lineHeight: '24px' }}
|
||||
>
|
||||
<Trans i18nKey="com.affine.selectPage.empty.tips" values={{ search }}>
|
||||
No page titles contain
|
||||
<span
|
||||
style={{ fontWeight: 600, color: 'var(--affine-primary-color)' }}
|
||||
>
|
||||
search
|
||||
</span>
|
||||
</Trans>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,40 +0,0 @@
|
||||
import type { Filter } from '@affine/env/filter';
|
||||
import type { PageMeta } from '@blocksuite/store';
|
||||
import { type MouseEvent, useCallback, useState } from 'react';
|
||||
|
||||
import { filterPageByRules } from '../../use-collection-manager';
|
||||
|
||||
export const useFilter = (list: PageMeta[]) => {
|
||||
const [filters, changeFilters] = useState<Filter[]>([]);
|
||||
const [showFilter, setShowFilter] = useState(false);
|
||||
const clickFilter = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (showFilter || filters.length !== 0) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setShowFilter(!showFilter);
|
||||
}
|
||||
},
|
||||
[filters.length, showFilter]
|
||||
);
|
||||
const onCreateFilter = useCallback(
|
||||
(filter: Filter) => {
|
||||
changeFilters([...filters, filter]);
|
||||
setShowFilter(true);
|
||||
},
|
||||
[filters]
|
||||
);
|
||||
return {
|
||||
showFilter,
|
||||
filters,
|
||||
updateFilters: changeFilters,
|
||||
clickFilter,
|
||||
createFilter: onCreateFilter,
|
||||
filteredList: list.filter(v => {
|
||||
if (v.trash) {
|
||||
return false;
|
||||
}
|
||||
return filterPageByRules(filters, [], v);
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -1,13 +0,0 @@
|
||||
import type { PageMeta } from '@blocksuite/store';
|
||||
import { useState } from 'react';
|
||||
|
||||
export const useSearch = (list: PageMeta[]) => {
|
||||
const [value, onChange] = useState('');
|
||||
return {
|
||||
searchText: value,
|
||||
updateSearchText: onChange,
|
||||
searchedList: value
|
||||
? list.filter(v => v.title.toLowerCase().includes(value.toLowerCase()))
|
||||
: list,
|
||||
};
|
||||
};
|
||||
@@ -1,8 +0,0 @@
|
||||
export * from './affine-shape';
|
||||
export * from './collection-bar';
|
||||
export * from './collection-list';
|
||||
export * from './collection-operations';
|
||||
export * from './create-collection';
|
||||
export * from './edit-collection/edit-collection';
|
||||
export * from './save-as-collection-button';
|
||||
export * from './use-edit-collection';
|
||||
@@ -1,46 +0,0 @@
|
||||
import type { Collection } from '@affine/env/filter';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { SaveIcon } from '@blocksuite/icons';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { Button } from '../../../ui/button';
|
||||
import { createEmptyCollection } from '../use-collection-manager';
|
||||
import { useEditCollectionName } from './use-edit-collection';
|
||||
|
||||
interface SaveAsCollectionButtonProps {
|
||||
onConfirm: (collection: Collection) => void;
|
||||
}
|
||||
|
||||
export const SaveAsCollectionButton = ({
|
||||
onConfirm,
|
||||
}: SaveAsCollectionButtonProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const { open, node } = useEditCollectionName({
|
||||
title: t['com.affine.editCollection.saveCollection'](),
|
||||
showTips: true,
|
||||
});
|
||||
const handleClick = useCallback(() => {
|
||||
open('')
|
||||
.then(name => {
|
||||
return onConfirm(createEmptyCollection(nanoid(), { name }));
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
});
|
||||
}, [open, onConfirm]);
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={handleClick}
|
||||
data-testid="save-as-collection"
|
||||
icon={<SaveIcon />}
|
||||
size="large"
|
||||
style={{ padding: '7px 8px' }}
|
||||
>
|
||||
{t['com.affine.editCollection.saveCollection']()}
|
||||
</Button>
|
||||
{node}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,48 +0,0 @@
|
||||
import type { Collection, DeleteCollectionInfo } from '@affine/env/filter';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { DeleteIcon, FilterIcon } from '@blocksuite/icons';
|
||||
import { type ReactNode, useMemo } from 'react';
|
||||
|
||||
import type { useCollectionManager } from '../use-collection-manager';
|
||||
|
||||
interface CollectionBarAction {
|
||||
icon: ReactNode;
|
||||
click: () => void;
|
||||
className?: string;
|
||||
name: string;
|
||||
tooltip: string;
|
||||
}
|
||||
|
||||
export const useActions = ({
|
||||
collection,
|
||||
setting,
|
||||
openEdit,
|
||||
info,
|
||||
}: {
|
||||
info: DeleteCollectionInfo;
|
||||
collection: Collection;
|
||||
setting: ReturnType<typeof useCollectionManager>;
|
||||
openEdit: (open: Collection) => void;
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
return useMemo<CollectionBarAction[]>(() => {
|
||||
return [
|
||||
{
|
||||
icon: <FilterIcon />,
|
||||
name: 'edit',
|
||||
tooltip: t['com.affine.collection-bar.action.tooltip.edit'](),
|
||||
click: () => {
|
||||
openEdit(collection);
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: <DeleteIcon style={{ color: 'var(--affine-error-color)' }} />,
|
||||
name: 'delete',
|
||||
tooltip: t['com.affine.collection-bar.action.tooltip.delete'](),
|
||||
click: () => {
|
||||
setting.deleteCollection(info, collection.id);
|
||||
},
|
||||
},
|
||||
];
|
||||
}, [info, collection, t, setting, openEdit]);
|
||||
};
|
||||
@@ -1,80 +0,0 @@
|
||||
import type { Collection } from '@affine/env/filter';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { CreateCollectionModal } from './create-collection';
|
||||
import {
|
||||
type AllPageListConfig,
|
||||
EditCollectionModal,
|
||||
type EditCollectionMode,
|
||||
} from './edit-collection/edit-collection';
|
||||
|
||||
export const useEditCollection = (config: AllPageListConfig) => {
|
||||
const [data, setData] = useState<{
|
||||
collection: Collection;
|
||||
mode?: 'page' | 'rule';
|
||||
onConfirm: (collection: Collection) => void;
|
||||
}>();
|
||||
const close = useCallback(() => setData(undefined), []);
|
||||
|
||||
return {
|
||||
node: data ? (
|
||||
<EditCollectionModal
|
||||
allPageListConfig={config}
|
||||
init={data.collection}
|
||||
open={!!data}
|
||||
mode={data.mode}
|
||||
onOpenChange={close}
|
||||
onConfirm={data.onConfirm}
|
||||
/>
|
||||
) : null,
|
||||
open: (
|
||||
collection: Collection,
|
||||
mode?: EditCollectionMode
|
||||
): Promise<Collection> =>
|
||||
new Promise<Collection>(res => {
|
||||
setData({
|
||||
collection,
|
||||
mode,
|
||||
onConfirm: collection => {
|
||||
res(collection);
|
||||
},
|
||||
});
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
export const useEditCollectionName = ({
|
||||
title,
|
||||
showTips,
|
||||
}: {
|
||||
title: string;
|
||||
showTips?: boolean;
|
||||
}) => {
|
||||
const [data, setData] = useState<{
|
||||
name: string;
|
||||
onConfirm: (name: string) => void;
|
||||
}>();
|
||||
const close = useCallback(() => setData(undefined), []);
|
||||
|
||||
return {
|
||||
node: data ? (
|
||||
<CreateCollectionModal
|
||||
showTips={showTips}
|
||||
title={title}
|
||||
init={data.name}
|
||||
open={!!data}
|
||||
onOpenChange={close}
|
||||
onConfirm={data.onConfirm}
|
||||
/>
|
||||
) : null,
|
||||
open: (name: string): Promise<string> =>
|
||||
new Promise<string>(res => {
|
||||
setData({
|
||||
name,
|
||||
onConfirm: collection => {
|
||||
res(collection);
|
||||
},
|
||||
});
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -1,218 +0,0 @@
|
||||
import type { PageMeta } from '@blocksuite/store';
|
||||
import clsx from 'clsx';
|
||||
import { selectAtom } from 'jotai/utils';
|
||||
import {
|
||||
forwardRef,
|
||||
type HTMLAttributes,
|
||||
type PropsWithChildren,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
|
||||
import { Scrollable } from '../../ui/scrollbar';
|
||||
import { PageGroupHeader, PageMetaListItemRenderer } from './page-group';
|
||||
import { PageListTableHeader } from './page-header';
|
||||
import { PageListInnerWrapper } from './page-list';
|
||||
import * as styles from './page-list.css';
|
||||
import {
|
||||
pageGroupCollapseStateAtom,
|
||||
pageGroupsAtom,
|
||||
pageListPropsAtom,
|
||||
PageListProvider,
|
||||
useAtomValue,
|
||||
} from './scoped-atoms';
|
||||
import type {
|
||||
PageGroupProps,
|
||||
PageListHandle,
|
||||
VirtualizedPageListProps,
|
||||
} from './types';
|
||||
|
||||
// we have three item types for rendering rows in Virtuoso
|
||||
type VirtuosoItemType =
|
||||
| 'sticky-header'
|
||||
| 'page-group-header'
|
||||
| 'page-item'
|
||||
| 'page-item-spacer';
|
||||
|
||||
interface BaseVirtuosoItem {
|
||||
type: VirtuosoItemType;
|
||||
}
|
||||
|
||||
interface VirtuosoItemStickyHeader extends BaseVirtuosoItem {
|
||||
type: 'sticky-header';
|
||||
}
|
||||
|
||||
interface VirtuosoItemPageItem extends BaseVirtuosoItem {
|
||||
type: 'page-item';
|
||||
data: PageMeta;
|
||||
}
|
||||
|
||||
interface VirtuosoItemPageGroupHeader extends BaseVirtuosoItem {
|
||||
type: 'page-group-header';
|
||||
data: PageGroupProps;
|
||||
}
|
||||
|
||||
interface VirtuosoPageItemSpacer extends BaseVirtuosoItem {
|
||||
type: 'page-item-spacer';
|
||||
data: {
|
||||
height: number;
|
||||
};
|
||||
}
|
||||
|
||||
type VirtuosoItem =
|
||||
| VirtuosoItemStickyHeader
|
||||
| VirtuosoItemPageItem
|
||||
| VirtuosoItemPageGroupHeader
|
||||
| VirtuosoPageItemSpacer;
|
||||
|
||||
/**
|
||||
* Given a list of pages, render a list of pages
|
||||
* Similar to normal PageList, but uses react-virtuoso to render the list (virtual rendering)
|
||||
*/
|
||||
export const VirtualizedPageList = forwardRef<
|
||||
PageListHandle,
|
||||
VirtualizedPageListProps
|
||||
>(function VirtualizedPageList(props, ref) {
|
||||
return (
|
||||
// push pageListProps to the atom so that downstream components can consume it
|
||||
// this makes sure pageListPropsAtom is always populated
|
||||
// @ts-expect-error fix type issues later
|
||||
<PageListProvider initialValues={[[pageListPropsAtom, props]]}>
|
||||
<PageListInnerWrapper {...props} handleRef={ref}>
|
||||
<PageListInner {...props} />
|
||||
</PageListInnerWrapper>
|
||||
</PageListProvider>
|
||||
);
|
||||
});
|
||||
|
||||
const headingAtom = selectAtom(pageListPropsAtom, props => props.heading);
|
||||
|
||||
const PageListHeading = () => {
|
||||
const heading = useAtomValue(headingAtom);
|
||||
return <div className={styles.heading}>{heading}</div>;
|
||||
};
|
||||
|
||||
const useVirtuosoItems = () => {
|
||||
const groups = useAtomValue(pageGroupsAtom);
|
||||
const groupCollapsedState = useAtomValue(pageGroupCollapseStateAtom);
|
||||
|
||||
return useMemo(() => {
|
||||
const items: VirtuosoItem[] = [];
|
||||
|
||||
// 1.
|
||||
// always put sticky header at the top
|
||||
// the visibility of sticky header is inside of PageListTableHeader
|
||||
items.push({
|
||||
type: 'sticky-header',
|
||||
});
|
||||
|
||||
// 2.
|
||||
// iterate groups and add page items
|
||||
for (const group of groups) {
|
||||
// skip empty group header since it will cause issue in virtuoso ("Zero-sized element")
|
||||
if (group.label) {
|
||||
items.push({
|
||||
type: 'page-group-header',
|
||||
data: group,
|
||||
});
|
||||
}
|
||||
// do not render items if the group is collapsed
|
||||
if (!groupCollapsedState[group.id]) {
|
||||
for (const item of group.items) {
|
||||
items.push({
|
||||
type: 'page-item',
|
||||
data: item,
|
||||
});
|
||||
// add a spacer between items (4px), unless it's the last item
|
||||
if (item !== group.items[group.items.length - 1]) {
|
||||
items.push({
|
||||
type: 'page-item-spacer',
|
||||
data: {
|
||||
height: 4,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// add a spacer between groups (16px)
|
||||
items.push({
|
||||
type: 'page-item-spacer',
|
||||
data: {
|
||||
height: 16,
|
||||
},
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}, [groupCollapsedState, groups]);
|
||||
};
|
||||
|
||||
const itemContentRenderer = (_index: number, data: VirtuosoItem) => {
|
||||
switch (data.type) {
|
||||
case 'sticky-header':
|
||||
return <PageListTableHeader />;
|
||||
case 'page-group-header':
|
||||
return <PageGroupHeader {...data.data} />;
|
||||
case 'page-item':
|
||||
return <PageMetaListItemRenderer {...data.data} />;
|
||||
case 'page-item-spacer':
|
||||
return <div style={{ height: data.data.height }} />;
|
||||
}
|
||||
};
|
||||
|
||||
const Scroller = forwardRef<
|
||||
HTMLDivElement,
|
||||
PropsWithChildren<HTMLAttributes<HTMLDivElement>>
|
||||
>(({ children, ...props }, ref) => {
|
||||
return (
|
||||
<Scrollable.Root>
|
||||
<Scrollable.Viewport {...props} ref={ref}>
|
||||
{children}
|
||||
</Scrollable.Viewport>
|
||||
<Scrollable.Scrollbar />
|
||||
</Scrollable.Root>
|
||||
);
|
||||
});
|
||||
|
||||
Scroller.displayName = 'Scroller';
|
||||
|
||||
const PageListInner = ({
|
||||
atTopStateChange,
|
||||
atTopThreshold,
|
||||
...props
|
||||
}: VirtualizedPageListProps) => {
|
||||
const virtuosoItems = useVirtuosoItems();
|
||||
const [atTop, setAtTop] = useState(false);
|
||||
const handleAtTopStateChange = useCallback(
|
||||
(atTop: boolean) => {
|
||||
setAtTop(atTop);
|
||||
atTopStateChange?.(atTop);
|
||||
},
|
||||
[atTopStateChange]
|
||||
);
|
||||
const components = useMemo(() => {
|
||||
return {
|
||||
Header: props.heading ? PageListHeading : undefined,
|
||||
Scroller: Scroller,
|
||||
};
|
||||
}, [props.heading]);
|
||||
return (
|
||||
<Virtuoso<VirtuosoItem>
|
||||
data-has-scroll-top={!atTop}
|
||||
atTopThreshold={atTopThreshold ?? 0}
|
||||
atTopStateChange={handleAtTopStateChange}
|
||||
components={components}
|
||||
data={virtuosoItems}
|
||||
data-testid="virtualized-page-list"
|
||||
data-total-count={props.pages.length} // for testing, since we do not know the total count in test
|
||||
topItemCount={1} // sticky header
|
||||
totalCount={virtuosoItems.length}
|
||||
itemContent={itemContentRenderer}
|
||||
className={clsx(props.className, styles.root)}
|
||||
// todo: set a reasonable overscan value to avoid blank space?
|
||||
// overscan={100}
|
||||
/>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user