mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-11 03:48:39 +00:00
Compare commits
6 Commits
v0.14.0-ca
...
v0.14.0-ca
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c648c2425 | ||
|
|
c1eb7b657a | ||
|
|
2576a69eb6 | ||
|
|
c7e10c2283 | ||
|
|
35af526eb2 | ||
|
|
5490944d04 |
@@ -234,7 +234,7 @@ const config = {
|
||||
},
|
||||
},
|
||||
...allPackages.map(pkg => ({
|
||||
files: [`${pkg}/src/**/*.ts`, `${pkg}/src/**/*.tsx`],
|
||||
files: [`${pkg}/src/**/*.ts`, `${pkg}/src/**/*.tsx`, `${pkg}/**/*.mjs`],
|
||||
rules: {
|
||||
'@typescript-eslint/no-restricted-imports': [
|
||||
'error',
|
||||
|
||||
@@ -1,31 +1,155 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { useRef } from 'react';
|
||||
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||
import { useMemo } from 'react';
|
||||
import type { Subscriber } from 'rxjs';
|
||||
import { Observable } from 'rxjs';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { LiveData, useLiveData } from '..';
|
||||
|
||||
describe('livedata', () => {
|
||||
test('react', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
test('react', async () => {
|
||||
const livedata$ = new LiveData(0);
|
||||
let renderCount = 0;
|
||||
const Component = () => {
|
||||
const renderCount = useRef(0);
|
||||
renderCount.current++;
|
||||
renderCount++;
|
||||
const value = useLiveData(livedata$);
|
||||
return (
|
||||
<main>
|
||||
{renderCount.current}:{value}
|
||||
</main>
|
||||
return <main>{value}</main>;
|
||||
};
|
||||
render(<Component />);
|
||||
expect(screen.getByRole('main').innerText).toBe('0');
|
||||
livedata$.next(1);
|
||||
// wait for rerender
|
||||
await waitFor(() => expect(screen.getByRole('main').innerText).toBe('1'));
|
||||
expect(renderCount).toBe(2);
|
||||
});
|
||||
|
||||
test('react livedata.map', async () => {
|
||||
const livedata$ = new LiveData(0);
|
||||
let renderCount = 0;
|
||||
const Component = () => {
|
||||
renderCount++;
|
||||
const value = useLiveData(livedata$.map(v => v + 1));
|
||||
return <main>{value}</main>;
|
||||
};
|
||||
render(<Component />);
|
||||
expect(screen.getByRole('main').innerText).toBe('1');
|
||||
livedata$.next(1);
|
||||
// wait for rerender
|
||||
await waitFor(() => expect(screen.getByRole('main').innerText).toBe('2'));
|
||||
expect(renderCount).toBe(2);
|
||||
});
|
||||
|
||||
test('react livedata.map heavy object copy', async () => {
|
||||
const livedata$ = new LiveData({ hello: 'world' });
|
||||
let renderCount = 0;
|
||||
let objectCopyCount = 0;
|
||||
const Component = () => {
|
||||
renderCount++;
|
||||
|
||||
const value = useLiveData(
|
||||
livedata$.map(v => {
|
||||
objectCopyCount++;
|
||||
return { ...v };
|
||||
})
|
||||
);
|
||||
return <main>{value.hello}</main>;
|
||||
};
|
||||
const { rerender } = render(<Component />);
|
||||
expect(screen.getByRole('main').innerText).toBe('1:0');
|
||||
livedata$.next(1);
|
||||
expect(screen.getByRole('main').innerText).toBe('world');
|
||||
livedata$.next({ hello: 'foobar' });
|
||||
// wait for rerender
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('main').innerText).toBe('foobar')
|
||||
);
|
||||
expect(renderCount).toBe(2);
|
||||
expect(objectCopyCount).toBe(3);
|
||||
|
||||
rerender(<Component />);
|
||||
expect(screen.getByRole('main').innerText).toBe('3:1');
|
||||
expect(renderCount).toBe(3);
|
||||
expect(objectCopyCount).toBe(4);
|
||||
});
|
||||
|
||||
test('react useMemo livedata.map heavy object copy', async () => {
|
||||
const livedata$ = new LiveData({ hello: 'world' });
|
||||
let renderCount = 0;
|
||||
let objectCopyCount = 0;
|
||||
const Component = () => {
|
||||
renderCount++;
|
||||
const value = useLiveData(
|
||||
useMemo(
|
||||
() =>
|
||||
livedata$.map(v => {
|
||||
objectCopyCount++;
|
||||
return { ...v };
|
||||
}),
|
||||
[]
|
||||
)
|
||||
);
|
||||
return <main>{value.hello}</main>;
|
||||
};
|
||||
const { rerender } = render(<Component />);
|
||||
expect(screen.getByRole('main').innerText).toBe('world');
|
||||
livedata$.next({ hello: 'foobar' });
|
||||
// wait for rerender
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('main').innerText).toBe('foobar')
|
||||
);
|
||||
expect(renderCount).toBe(2);
|
||||
expect(objectCopyCount).toBe(2);
|
||||
|
||||
rerender(<Component />);
|
||||
expect(renderCount).toBe(3);
|
||||
expect(objectCopyCount).toBe(2);
|
||||
});
|
||||
|
||||
test('react useLiveData with livedata from observable', async () => {
|
||||
let subscribeCount = 0;
|
||||
let renderCount = 0;
|
||||
|
||||
let innerSubscriber: Subscriber<{
|
||||
value: number;
|
||||
}> = null!;
|
||||
|
||||
const livedata$ = LiveData.from(
|
||||
new Observable<{ value: number }>(subscriber => {
|
||||
subscribeCount++;
|
||||
subscriber.next({ value: 1 });
|
||||
innerSubscriber = subscriber;
|
||||
}),
|
||||
{ value: 0 }
|
||||
);
|
||||
|
||||
const Component = () => {
|
||||
renderCount++;
|
||||
const value = useLiveData(
|
||||
livedata$.map(v => ({
|
||||
value: v.value + 1,
|
||||
}))
|
||||
).value;
|
||||
return <main>{value}</main>;
|
||||
};
|
||||
const { rerender } = render(<Component />);
|
||||
expect(screen.getByRole('main').innerText).toBe('2');
|
||||
|
||||
expect(subscribeCount).toBe(1);
|
||||
expect(renderCount).toBe(1);
|
||||
|
||||
innerSubscriber.next({ value: 2 });
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('main').innerText).toBe('3'));
|
||||
expect(subscribeCount).toBe(1);
|
||||
expect(renderCount).toBe(2);
|
||||
|
||||
rerender(<Component />);
|
||||
expect(subscribeCount).toBe(1);
|
||||
expect(renderCount).toBe(3);
|
||||
});
|
||||
|
||||
test('lifecycle', async () => {
|
||||
@@ -34,7 +158,6 @@ describe('livedata', () => {
|
||||
const observable$ = new Observable<number>(subscriber => {
|
||||
observableSubscribed = true;
|
||||
subscriber.next(1);
|
||||
console.log(1);
|
||||
return () => {
|
||||
observableClosed = true;
|
||||
};
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"@dnd-kit/modifiers": "^7.0.0",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emotion/cache": "^11.11.0",
|
||||
"@emotion/react": "^11.11.3",
|
||||
"@emotion/server": "^11.11.0",
|
||||
|
||||
@@ -5,8 +5,8 @@ import type {
|
||||
PageInfoCustomPropertyMeta,
|
||||
} from '@affine/core/modules/workspace/properties/schema';
|
||||
import { PagePropertyType } from '@affine/core/modules/workspace/properties/schema';
|
||||
import { createFractionalIndexingSortableHelper } from '@affine/core/utils';
|
||||
import { DebugLogger } from '@affine/debug';
|
||||
import { generateKeyBetween } from 'fractional-indexing';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import { getDefaultIconName } from './icons-mapping';
|
||||
@@ -147,6 +147,11 @@ export class PagePropertiesManager {
|
||||
this.ensureRequiredProperties();
|
||||
}
|
||||
|
||||
readonly sorter = createFractionalIndexingSortableHelper<
|
||||
PageInfoCustomProperty,
|
||||
string | number
|
||||
>(this);
|
||||
|
||||
// prevent infinite loop
|
||||
private ensuring = false;
|
||||
ensureRequiredProperties() {
|
||||
@@ -163,6 +168,22 @@ export class PagePropertiesManager {
|
||||
this.ensuring = false;
|
||||
}
|
||||
|
||||
getItems() {
|
||||
return Object.values(this.getCustomProperties());
|
||||
}
|
||||
|
||||
getItemOrder(item: PageInfoCustomProperty): string {
|
||||
return item.order;
|
||||
}
|
||||
|
||||
setItemOrder(item: PageInfoCustomProperty, order: string) {
|
||||
item.order = order;
|
||||
}
|
||||
|
||||
getItemId(item: PageInfoCustomProperty) {
|
||||
return item.id;
|
||||
}
|
||||
|
||||
get workspace() {
|
||||
return this.adapter.workspace;
|
||||
}
|
||||
@@ -204,16 +225,6 @@ export class PagePropertiesManager {
|
||||
: {};
|
||||
}
|
||||
|
||||
getOrderedCustomProperties() {
|
||||
return Object.values(this.getCustomProperties()).sort((a, b) =>
|
||||
a.order > b.order ? 1 : a.order < b.order ? -1 : 0
|
||||
);
|
||||
}
|
||||
|
||||
largestOrder() {
|
||||
return this.getOrderedCustomProperties().at(-1)?.order ?? null;
|
||||
}
|
||||
|
||||
getCustomPropertyMeta(id: string): PageInfoCustomPropertyMeta | undefined {
|
||||
return this.metaManager.customPropertiesSchema[id];
|
||||
}
|
||||
@@ -234,7 +245,7 @@ export class PagePropertiesManager {
|
||||
return;
|
||||
}
|
||||
|
||||
const newOrder = generateKeyBetween(this.largestOrder(), null);
|
||||
const newOrder = this.sorter.getNewItemOrder();
|
||||
if (this.properties!.custom[id]) {
|
||||
logger.warn(`custom property ${id} already exists`);
|
||||
}
|
||||
@@ -247,21 +258,6 @@ export class PagePropertiesManager {
|
||||
};
|
||||
}
|
||||
|
||||
moveCustomProperty(from: number, to: number) {
|
||||
this.ensureRequiredProperties();
|
||||
// move from -> to means change from's order to a new order between to and to -1/+1
|
||||
const properties = this.getOrderedCustomProperties();
|
||||
const fromProperty = properties[from];
|
||||
const toProperty = properties[to];
|
||||
const toNextProperty = properties[from < to ? to + 1 : to - 1];
|
||||
const args: [string?, string?] =
|
||||
from < to
|
||||
? [toProperty.order, toNextProperty?.order ?? null]
|
||||
: [toNextProperty?.order ?? null, toProperty.order];
|
||||
const newOrder = generateKeyBetween(...args);
|
||||
this.properties!.custom[fromProperty.id].order = newOrder;
|
||||
}
|
||||
|
||||
hasCustomProperty(id: string) {
|
||||
return !!this.properties?.custom[id];
|
||||
}
|
||||
|
||||
@@ -101,10 +101,7 @@ interface SortablePropertiesProps {
|
||||
|
||||
const SortableProperties = ({ children }: SortablePropertiesProps) => {
|
||||
const manager = useContext(managerContext);
|
||||
const properties = useMemo(
|
||||
() => manager.getOrderedCustomProperties(),
|
||||
[manager]
|
||||
);
|
||||
const properties = useMemo(() => manager.sorter.getOrderedItems(), [manager]);
|
||||
const editingItem = useAtomValue(editingPropertyAtom);
|
||||
const draggable = !manager.readonly && !editingItem;
|
||||
const sensors = useSensors(
|
||||
@@ -128,15 +125,12 @@ const SortableProperties = ({ children }: SortablePropertiesProps) => {
|
||||
return;
|
||||
}
|
||||
const { active, over } = event;
|
||||
const fromIndex = properties.findIndex(p => p.id === active.id);
|
||||
const toIndex = properties.findIndex(p => p.id === over?.id);
|
||||
|
||||
if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
|
||||
manager.moveCustomProperty(fromIndex, toIndex);
|
||||
setLocalProperties(manager.getOrderedCustomProperties());
|
||||
if (over) {
|
||||
manager.sorter.move(active.id, over.id);
|
||||
}
|
||||
setLocalProperties(manager.sorter.getOrderedItems());
|
||||
},
|
||||
[manager, properties, draggable]
|
||||
[manager, draggable]
|
||||
);
|
||||
|
||||
const filteredProperties = useMemo(
|
||||
@@ -636,7 +630,7 @@ export const PagePropertiesTableHeader = ({
|
||||
onOpenChange(!open);
|
||||
}, [onOpenChange, open]);
|
||||
|
||||
const properties = manager.getOrderedCustomProperties();
|
||||
const properties = manager.sorter.getOrderedItems();
|
||||
|
||||
return (
|
||||
<div className={clsx(styles.tableHeader, className)} style={style}>
|
||||
|
||||
@@ -40,13 +40,12 @@ export const root = style({
|
||||
paddingLeft: '4px',
|
||||
paddingRight: '4px',
|
||||
},
|
||||
'&[data-type="collection-list-item"][data-collapsible="false"][data-active="true"],&[data-type="reference-page"][data-collapsible="false"][data-active="true"], &[data-type="reference-page"][data-collapsible="false"]:hover, &[data-type="collection-list-item"][data-collapsible="false"]:hover':
|
||||
{
|
||||
width: 'calc(100% + 8px)',
|
||||
transform: 'translateX(-8px)',
|
||||
paddingLeft: '20px',
|
||||
paddingRight: '12px',
|
||||
},
|
||||
'&[data-collapsible="false"]:is([data-active="true"], :hover)': {
|
||||
width: 'calc(100% + 8px)',
|
||||
transform: 'translateX(-8px)',
|
||||
paddingLeft: '20px',
|
||||
paddingRight: '12px',
|
||||
},
|
||||
[`${linkItemRoot}:first-of-type &`]: {
|
||||
marginTop: '0px',
|
||||
},
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { FavoriteTag } from '@affine/core/components/page-list';
|
||||
import { useBlockSuiteMetaHelper } from '@affine/core/hooks/affine/use-block-suite-meta-helper';
|
||||
import { useBlockSuiteDocMeta } from '@affine/core/hooks/use-block-suite-page-meta';
|
||||
import { FavoriteItemsAdapter } from '@affine/core/modules/workspace';
|
||||
import { toast } from '@affine/core/utils';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
import { useService, Workspace } from '@toeverything/infra';
|
||||
import { useLiveData, useService, Workspace } from '@toeverything/infra';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
export interface FavoriteButtonProps {
|
||||
@@ -16,24 +15,19 @@ export const useFavorite = (pageId: string) => {
|
||||
const workspace = useService(Workspace);
|
||||
const docCollection = workspace.docCollection;
|
||||
const currentPage = docCollection.getDoc(pageId);
|
||||
const favAdapter = useService(FavoriteItemsAdapter);
|
||||
assertExists(currentPage);
|
||||
|
||||
const pageMeta = useBlockSuiteDocMeta(docCollection).find(
|
||||
meta => meta.id === pageId
|
||||
);
|
||||
const favorite = pageMeta?.favorite ?? false;
|
||||
|
||||
const { toggleFavorite: _toggleFavorite } =
|
||||
useBlockSuiteMetaHelper(docCollection);
|
||||
const favorite = useLiveData(favAdapter.isFavorite$(pageId, 'doc'));
|
||||
|
||||
const toggleFavorite = useCallback(() => {
|
||||
_toggleFavorite(pageId);
|
||||
favAdapter.toggle(pageId, 'doc');
|
||||
toast(
|
||||
favorite
|
||||
? t['com.affine.toastMessage.removedFavorites']()
|
||||
: t['com.affine.toastMessage.addedFavorites']()
|
||||
);
|
||||
}, [favorite, pageId, t, _toggleFavorite]);
|
||||
}, [favorite, pageId, t, favAdapter]);
|
||||
|
||||
return { favorite, toggleFavorite };
|
||||
};
|
||||
@@ -41,5 +35,11 @@ export const useFavorite = (pageId: string) => {
|
||||
export const FavoriteButton = ({ pageId }: FavoriteButtonProps) => {
|
||||
const { favorite, toggleFavorite } = useFavorite(pageId);
|
||||
|
||||
return <FavoriteTag active={!!favorite} onClick={toggleFavorite} />;
|
||||
return (
|
||||
<FavoriteTag
|
||||
data-testid="pin-button"
|
||||
active={!!favorite}
|
||||
onClick={toggleFavorite}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,6 +9,18 @@ export const editor = style({
|
||||
},
|
||||
},
|
||||
},
|
||||
'@media': {
|
||||
'screen and (max-width: 800px)': {
|
||||
selectors: {
|
||||
'&.is-public': {
|
||||
vars: {
|
||||
'--affine-editor-width': '100%',
|
||||
'--affine-editor-side-padding': '24px',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
globalStyle(
|
||||
`${editor} .affine-page-viewport:not(.affine-embed-synced-doc-editor)`,
|
||||
|
||||
@@ -104,7 +104,8 @@ const PageDetailEditorMain = memo(function PageDetailEditorMain({
|
||||
return (
|
||||
<Editor
|
||||
className={clsx(styles.editor, {
|
||||
'full-screen': appSettings.fullWidthLayout,
|
||||
'full-screen': !isPublic && appSettings.fullWidthLayout,
|
||||
'is-public': isPublic,
|
||||
})}
|
||||
style={
|
||||
{
|
||||
|
||||
@@ -21,20 +21,7 @@ export const root = style({
|
||||
},
|
||||
},
|
||||
});
|
||||
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',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Checkbox } from '@affine/component';
|
||||
import { getDNDId } from '@affine/core/hooks/affine/use-global-dnd-helper';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { useDraggable } from '@dnd-kit/core';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
@@ -97,16 +98,22 @@ export const CollectionListItem = (props: CollectionListItemProps) => {
|
||||
/>
|
||||
<ListIconCell icon={props.icon} />
|
||||
</div>
|
||||
<ListTitleCell title={props.title} />
|
||||
</div>
|
||||
);
|
||||
}, [props.icon, props.onSelectedChange, props.selectable, props.selected]);
|
||||
}, [
|
||||
props.icon,
|
||||
props.onSelectedChange,
|
||||
props.selectable,
|
||||
props.selected,
|
||||
props.title,
|
||||
]);
|
||||
|
||||
// TODO: use getDropItemId
|
||||
const { setNodeRef, attributes, listeners, isDragging } = useDraggable({
|
||||
id: 'collection-list-item-title-' + props.collectionId,
|
||||
id: getDNDId('collection-list', 'collection', props.collectionId),
|
||||
data: {
|
||||
pageId: props.collectionId,
|
||||
pageTitle: collectionTitleElement,
|
||||
preview: collectionTitleElement,
|
||||
} satisfies DraggableTitleCellData,
|
||||
disabled: !props.draggable,
|
||||
});
|
||||
|
||||
@@ -106,7 +106,7 @@ export const VirtualizedCollectionList = ({
|
||||
<VirtualizedList
|
||||
ref={listRef}
|
||||
selectable="toggle"
|
||||
draggable={false}
|
||||
draggable
|
||||
atTopThreshold={80}
|
||||
atTopStateChange={setHideHeaderCreateNewCollection}
|
||||
onSelectionActiveChange={setShowFloatingToolbar}
|
||||
|
||||
@@ -21,20 +21,7 @@ export const root = style({
|
||||
},
|
||||
},
|
||||
});
|
||||
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',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Checkbox } from '@affine/component';
|
||||
import { getDNDId } from '@affine/core/hooks/affine/use-global-dnd-helper';
|
||||
import { TagService } from '@affine/core/modules/tag';
|
||||
import { useDraggable } from '@dnd-kit/core';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
@@ -152,10 +153,9 @@ export const PageListItem = (props: PageListItemProps) => {
|
||||
|
||||
// TODO: use getDropItemId
|
||||
const { setNodeRef, attributes, listeners, isDragging } = useDraggable({
|
||||
id: 'page-list-item-title-' + props.pageId,
|
||||
id: getDNDId('doc-list', 'doc', props.pageId),
|
||||
data: {
|
||||
pageId: props.pageId,
|
||||
pageTitle: pageTitleElement,
|
||||
preview: pageTitleElement,
|
||||
} satisfies DraggableTitleCellData,
|
||||
disabled: !props.draggable,
|
||||
});
|
||||
@@ -279,16 +279,3 @@ function PageListItemWrapper({
|
||||
return <div {...commonProps}>{children}</div>;
|
||||
}
|
||||
}
|
||||
|
||||
export const PageListDragOverlay = ({
|
||||
children,
|
||||
over,
|
||||
}: PropsWithChildren<{
|
||||
over?: boolean;
|
||||
}>) => {
|
||||
return (
|
||||
<div data-over={over} className={styles.dragOverlay}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { toast } from '@affine/component';
|
||||
import { useBlockSuiteMetaHelper } from '@affine/core/hooks/affine/use-block-suite-meta-helper';
|
||||
import { useTrashModalHelper } from '@affine/core/hooks/affine/use-trash-modal-helper';
|
||||
import { useBlockSuiteDocMeta } from '@affine/core/hooks/use-block-suite-page-meta';
|
||||
import { CollectionService } from '@affine/core/modules/collection';
|
||||
import type { Tag } from '@affine/core/modules/tag';
|
||||
import { Workbench } from '@affine/core/modules/workbench';
|
||||
import type { Collection, Filter } from '@affine/env/filter';
|
||||
import { Trans } from '@affine/i18n';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
@@ -30,13 +28,7 @@ import {
|
||||
} from './page-list-header';
|
||||
|
||||
const usePageOperationsRenderer = () => {
|
||||
const currentWorkspace = useService(Workspace);
|
||||
const { setTrashModal } = useTrashModalHelper(currentWorkspace.docCollection);
|
||||
const { toggleFavorite, duplicate } = useBlockSuiteMetaHelper(
|
||||
currentWorkspace.docCollection
|
||||
);
|
||||
const t = useAFFiNEI18N();
|
||||
const workbench = useService(Workbench);
|
||||
const collectionService = useService(CollectionService);
|
||||
const removeFromAllowList = useCallback(
|
||||
(id: string) => {
|
||||
@@ -45,57 +37,18 @@ const usePageOperationsRenderer = () => {
|
||||
},
|
||||
[collectionService, t]
|
||||
);
|
||||
|
||||
const pageOperationsRenderer = useCallback(
|
||||
(page: DocMeta, isInAllowList?: boolean) => {
|
||||
const onDisablePublicSharing = () => {
|
||||
toast('Successfully disabled', {
|
||||
portal: document.body,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<PageOperationCell
|
||||
favorite={!!page.favorite}
|
||||
isPublic={!!page.isPublic}
|
||||
page={page}
|
||||
isInAllowList={isInAllowList}
|
||||
onDisablePublicSharing={onDisablePublicSharing}
|
||||
link={`/workspace/${currentWorkspace.id}/${page.id}`}
|
||||
onOpenInSplitView={() => workbench.openPage(page.id, { at: 'tail' })}
|
||||
onDuplicate={() => {
|
||||
duplicate(page.id, false);
|
||||
}}
|
||||
onRemoveToTrash={() =>
|
||||
setTrashModal({
|
||||
open: true,
|
||||
pageIds: [page.id],
|
||||
pageTitles: [page.title],
|
||||
})
|
||||
}
|
||||
onToggleFavoritePage={() => {
|
||||
const status = page.favorite;
|
||||
toggleFavorite(page.id);
|
||||
toast(
|
||||
status
|
||||
? t['com.affine.toastMessage.removedFavorites']()
|
||||
: t['com.affine.toastMessage.addedFavorites']()
|
||||
);
|
||||
}}
|
||||
onRemoveFromAllowList={() => removeFromAllowList(page.id)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[
|
||||
currentWorkspace.id,
|
||||
workbench,
|
||||
duplicate,
|
||||
setTrashModal,
|
||||
toggleFavorite,
|
||||
t,
|
||||
removeFromAllowList,
|
||||
]
|
||||
[removeFromAllowList]
|
||||
);
|
||||
|
||||
return pageOperationsRenderer;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Tag } from '@affine/core/modules/tag';
|
||||
import { TagService } from '@affine/core/modules/tag';
|
||||
import { FavoriteItemsAdapter } from '@affine/core/modules/workspace';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { FavoritedIcon, FavoriteIcon } from '@blocksuite/icons';
|
||||
import type { DocMeta } from '@blocksuite/store';
|
||||
@@ -154,6 +155,8 @@ export const useFavoriteGroupDefinitions = <
|
||||
T extends ListItem,
|
||||
>(): ItemGroupDefinition<T>[] => {
|
||||
const t = useAFFiNEI18N();
|
||||
const favAdapter = useService(FavoriteItemsAdapter);
|
||||
const favourites = useLiveData(favAdapter.favorites$);
|
||||
return useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -166,7 +169,7 @@ export const useFavoriteGroupDefinitions = <
|
||||
icon={<FavoritedIcon className={styles.favouritedIcon} />}
|
||||
/>
|
||||
),
|
||||
match: item => !!(item as DocMeta).favorite,
|
||||
match: item => favourites.some(fav => fav.id === item.id),
|
||||
},
|
||||
{
|
||||
id: 'notFavourited',
|
||||
@@ -178,10 +181,10 @@ export const useFavoriteGroupDefinitions = <
|
||||
icon={<FavoriteIcon className={styles.notFavouritedIcon} />}
|
||||
/>
|
||||
),
|
||||
match: item => !(item as DocMeta).favorite,
|
||||
match: item => !favourites.some(fav => fav.id === item.id),
|
||||
},
|
||||
],
|
||||
[t]
|
||||
[t, favourites]
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -4,9 +4,14 @@ import {
|
||||
Menu,
|
||||
MenuIcon,
|
||||
MenuItem,
|
||||
toast,
|
||||
Tooltip,
|
||||
} from '@affine/component';
|
||||
import { useAppSettingHelper } from '@affine/core/hooks/affine/use-app-setting-helper';
|
||||
import { useBlockSuiteMetaHelper } from '@affine/core/hooks/affine/use-block-suite-meta-helper';
|
||||
import { useTrashModalHelper } from '@affine/core/hooks/affine/use-trash-modal-helper';
|
||||
import { Workbench } from '@affine/core/modules/workbench';
|
||||
import { FavoriteItemsAdapter } from '@affine/core/modules/workspace';
|
||||
import type { Collection, DeleteCollectionInfo } from '@affine/env/filter';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import {
|
||||
@@ -23,6 +28,8 @@ import {
|
||||
ResetIcon,
|
||||
SplitViewIcon,
|
||||
} from '@blocksuite/icons';
|
||||
import type { DocMeta } from '@blocksuite/store';
|
||||
import { useLiveData, useService, Workspace } from '@toeverything/infra';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
@@ -37,37 +44,61 @@ import type { AllPageListConfig } from './view';
|
||||
import { useEditCollection, useEditCollectionName } from './view';
|
||||
|
||||
export interface PageOperationCellProps {
|
||||
favorite: boolean;
|
||||
isPublic: boolean;
|
||||
link: string;
|
||||
page: DocMeta;
|
||||
isInAllowList?: boolean;
|
||||
onToggleFavoritePage: () => void;
|
||||
onRemoveToTrash: () => void;
|
||||
onDuplicate: () => void;
|
||||
onDisablePublicSharing: () => void;
|
||||
onOpenInSplitView: () => void;
|
||||
onRemoveFromAllowList?: () => void;
|
||||
}
|
||||
|
||||
export const PageOperationCell = ({
|
||||
favorite,
|
||||
isPublic,
|
||||
isInAllowList,
|
||||
link,
|
||||
onToggleFavoritePage,
|
||||
onRemoveToTrash,
|
||||
onDuplicate,
|
||||
onDisablePublicSharing,
|
||||
onOpenInSplitView,
|
||||
page,
|
||||
onRemoveFromAllowList,
|
||||
}: PageOperationCellProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const currentWorkspace = useService(Workspace);
|
||||
const { appSettings } = useAppSettingHelper();
|
||||
const { setTrashModal } = useTrashModalHelper(currentWorkspace.docCollection);
|
||||
const [openDisableShared, setOpenDisableShared] = useState(false);
|
||||
const favAdapter = useService(FavoriteItemsAdapter);
|
||||
const favourite = useLiveData(favAdapter.isFavorite$(page.id, 'doc'));
|
||||
const workbench = useService(Workbench);
|
||||
const { duplicate } = useBlockSuiteMetaHelper(currentWorkspace.docCollection);
|
||||
|
||||
const onDisablePublicSharing = useCallback(() => {
|
||||
toast('Successfully disabled', {
|
||||
portal: document.body,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onRemoveToTrash = useCallback(() => {
|
||||
setTrashModal({
|
||||
open: true,
|
||||
pageIds: [page.id],
|
||||
pageTitles: [page.title],
|
||||
});
|
||||
}, [page.id, page.title, setTrashModal]);
|
||||
|
||||
const onOpenInSplitView = useCallback(() => {
|
||||
workbench.openPage(page.id, { at: 'tail' });
|
||||
}, [page.id, workbench]);
|
||||
|
||||
const onToggleFavoritePage = useCallback(() => {
|
||||
const status = favAdapter.isFavorite(page.id, 'doc');
|
||||
favAdapter.toggle(page.id, 'doc');
|
||||
toast(
|
||||
status
|
||||
? t['com.affine.toastMessage.removedFavorites']()
|
||||
: t['com.affine.toastMessage.addedFavorites']()
|
||||
);
|
||||
}, [page.id, favAdapter, t]);
|
||||
|
||||
const onDuplicate = useCallback(() => {
|
||||
duplicate(page.id, false);
|
||||
}, [duplicate, page.id]);
|
||||
|
||||
const OperationMenu = (
|
||||
<>
|
||||
{isPublic && (
|
||||
{page.isPublic && (
|
||||
<DisablePublicSharing
|
||||
data-testid="disable-public-sharing"
|
||||
onSelect={() => {
|
||||
@@ -91,7 +122,7 @@ export const PageOperationCell = ({
|
||||
onClick={onToggleFavoritePage}
|
||||
preFix={
|
||||
<MenuIcon>
|
||||
{favorite ? (
|
||||
{favourite ? (
|
||||
<FavoritedIcon style={{ color: 'var(--affine-primary-color)' }} />
|
||||
) : (
|
||||
<FavoriteIcon />
|
||||
@@ -99,7 +130,7 @@ export const PageOperationCell = ({
|
||||
</MenuIcon>
|
||||
}
|
||||
>
|
||||
{favorite
|
||||
{favourite
|
||||
? t['com.affine.favoritePageOperation.remove']()
|
||||
: t['com.affine.favoritePageOperation.add']()}
|
||||
</MenuItem>
|
||||
@@ -121,7 +152,7 @@ export const PageOperationCell = ({
|
||||
<Link
|
||||
className={styles.clearLinkStyle}
|
||||
onClick={stopPropagationWithoutPrevent}
|
||||
to={link}
|
||||
to={`/workspace/${currentWorkspace.id}/${page.id}`}
|
||||
target={'_blank'}
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
@@ -157,10 +188,10 @@ export const PageOperationCell = ({
|
||||
<ColWrapper
|
||||
hideInSmallContainer
|
||||
data-testid="page-list-item-favorite"
|
||||
data-favorite={favorite ? true : undefined}
|
||||
data-favorite={favourite ? true : undefined}
|
||||
className={styles.favoriteCell}
|
||||
>
|
||||
<FavoriteTag onClick={onToggleFavoritePage} active={favorite} />
|
||||
<FavoriteTag onClick={onToggleFavoritePage} active={favourite} />
|
||||
</ColWrapper>
|
||||
<ColWrapper alignment="start">
|
||||
<Menu
|
||||
|
||||
@@ -21,20 +21,6 @@ export const root = style({
|
||||
},
|
||||
},
|
||||
});
|
||||
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',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Checkbox } from '@affine/component';
|
||||
import { getDNDId } from '@affine/core/hooks/affine/use-global-dnd-helper';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { useDraggable } from '@dnd-kit/core';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
@@ -99,10 +100,9 @@ export const TagListItem = (props: TagListItemProps) => {
|
||||
|
||||
// TODO: use getDropItemId
|
||||
const { setNodeRef, attributes, listeners, isDragging } = useDraggable({
|
||||
id: 'tag-list-item-title-' + props.tagId,
|
||||
id: getDNDId('tag-list', 'tag', props.tagId),
|
||||
data: {
|
||||
pageId: props.tagId,
|
||||
pageTitle: tagTitleElement,
|
||||
preview: tagTitleElement,
|
||||
} satisfies DraggableTitleCellData,
|
||||
disabled: !props.draggable,
|
||||
});
|
||||
|
||||
@@ -141,8 +141,7 @@ type MakeRecord<T> = {
|
||||
export type MetaRecord<T> = MakeRecord<T>;
|
||||
|
||||
export type DraggableTitleCellData = {
|
||||
pageId: string;
|
||||
pageTitle: ReactNode;
|
||||
preview: ReactNode;
|
||||
};
|
||||
|
||||
export type HeaderColDef = {
|
||||
|
||||
@@ -21,6 +21,7 @@ export const filterByFilterList = (filterList: Filter[], varMap: VariableMap) =>
|
||||
|
||||
export type PageDataForFilter = {
|
||||
meta: DocMeta;
|
||||
favorite: boolean;
|
||||
publicMode: undefined | 'page' | 'edgeless';
|
||||
};
|
||||
|
||||
@@ -33,13 +34,13 @@ export const filterPage = (collection: Collection, page: PageDataForFilter) => {
|
||||
export const filterPageByRules = (
|
||||
rules: Filter[],
|
||||
allowList: string[],
|
||||
{ meta, publicMode }: PageDataForFilter
|
||||
{ meta, publicMode, favorite }: PageDataForFilter
|
||||
) => {
|
||||
if (allowList?.includes(meta.id)) {
|
||||
return true;
|
||||
}
|
||||
return filterByFilterList(rules, {
|
||||
'Is Favourited': !!meta.favorite,
|
||||
'Is Favourited': !!favorite,
|
||||
'Is Public': !!publicMode,
|
||||
Created: meta.createDate,
|
||||
Updated: meta.updatedDate ?? meta.createDate,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { FavoriteItemsAdapter } from '@affine/core/modules/workspace';
|
||||
import type { Collection, Filter } from '@affine/env/filter';
|
||||
import type { DocMeta } from '@blocksuite/store';
|
||||
import type { Workspace } from '@toeverything/infra';
|
||||
import { useLiveData, useService, type Workspace } from '@toeverything/infra';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { usePublicPages } from '../../hooks/affine/use-is-shared-page';
|
||||
@@ -16,6 +17,8 @@ export const useFilteredPageMetas = (
|
||||
} = {}
|
||||
) => {
|
||||
const { getPublicMode } = usePublicPages(workspace);
|
||||
const favAdapter = useService(FavoriteItemsAdapter);
|
||||
const favoriteItems = useLiveData(favAdapter.favorites$);
|
||||
|
||||
const filteredPageMetas = useMemo(
|
||||
() =>
|
||||
@@ -29,6 +32,7 @@ export const useFilteredPageMetas = (
|
||||
}
|
||||
const pageData = {
|
||||
meta: pageMeta,
|
||||
favorite: favoriteItems.some(fav => fav.id === pageMeta.id),
|
||||
publicMode: getPublicMode(pageMeta.id),
|
||||
};
|
||||
if (
|
||||
@@ -49,6 +53,7 @@ export const useFilteredPageMetas = (
|
||||
options.trash,
|
||||
options.filters,
|
||||
options.collection,
|
||||
favoriteItems,
|
||||
getPublicMode,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { Button, FlexWrapper, Menu } from '@affine/component';
|
||||
import type {
|
||||
Collection,
|
||||
DeleteCollectionInfo,
|
||||
Filter,
|
||||
PropertiesMeta,
|
||||
} from '@affine/env/filter';
|
||||
import type { Collection, Filter, PropertiesMeta } from '@affine/env/filter';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { FilterIcon } from '@blocksuite/icons';
|
||||
|
||||
@@ -16,20 +11,14 @@ import type { AllPageListConfig } from './edit-collection/edit-collection';
|
||||
export const CollectionPageListOperationsMenu = ({
|
||||
collection,
|
||||
allPageListConfig,
|
||||
userInfo,
|
||||
}: {
|
||||
collection: Collection;
|
||||
allPageListConfig: AllPageListConfig;
|
||||
userInfo: DeleteCollectionInfo;
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
return (
|
||||
<FlexWrapper alignItems="center">
|
||||
<CollectionOperations
|
||||
info={userInfo}
|
||||
collection={collection}
|
||||
config={allPageListConfig}
|
||||
>
|
||||
<CollectionOperations collection={collection} config={allPageListConfig}>
|
||||
<Button
|
||||
className={styles.filterMenuTrigger}
|
||||
type="default"
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import type { MenuItemProps } from '@affine/component';
|
||||
import { Menu, MenuIcon, MenuItem } from '@affine/component';
|
||||
import { useAppSettingHelper } from '@affine/core/hooks/affine/use-app-setting-helper';
|
||||
import { useDeleteCollectionInfo } from '@affine/core/hooks/affine/use-delete-collection-info';
|
||||
import { Workbench } from '@affine/core/modules/workbench';
|
||||
import type { Collection, DeleteCollectionInfo } from '@affine/env/filter';
|
||||
import { FavoriteItemsAdapter } from '@affine/core/modules/workspace';
|
||||
import type { Collection } from '@affine/env/filter';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import {
|
||||
DeleteIcon,
|
||||
EditIcon,
|
||||
FavoritedIcon,
|
||||
FavoriteIcon,
|
||||
FilterIcon,
|
||||
SplitViewIcon,
|
||||
} from '@blocksuite/icons';
|
||||
import { useService } from '@toeverything/infra';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import type { PropsWithChildren, ReactElement } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
@@ -25,15 +29,14 @@ import {
|
||||
export const CollectionOperations = ({
|
||||
collection,
|
||||
config,
|
||||
info,
|
||||
openRenameModal,
|
||||
children,
|
||||
}: PropsWithChildren<{
|
||||
info: DeleteCollectionInfo;
|
||||
collection: Collection;
|
||||
config: AllPageListConfig;
|
||||
openRenameModal?: () => void;
|
||||
}>) => {
|
||||
const deleteInfo = useDeleteCollectionInfo();
|
||||
const { appSettings } = useAppSettingHelper();
|
||||
const service = useService(CollectionService);
|
||||
const workbench = useService(Workbench);
|
||||
@@ -76,6 +79,19 @@ export const CollectionOperations = ({
|
||||
workbench.openCollection(collection.id, { at: 'tail' });
|
||||
}, [collection.id, workbench]);
|
||||
|
||||
const favAdapter = useService(FavoriteItemsAdapter);
|
||||
|
||||
const onToggleFavoritePage = useCallback(() => {
|
||||
favAdapter.toggle(collection.id, 'collection');
|
||||
}, [favAdapter, collection.id]);
|
||||
|
||||
const favorite = useLiveData(
|
||||
useMemo(
|
||||
() => favAdapter.isFavorite$(collection.id, 'collection'),
|
||||
[collection.id, favAdapter]
|
||||
)
|
||||
);
|
||||
|
||||
const actions = useMemo<
|
||||
Array<
|
||||
| {
|
||||
@@ -109,6 +125,21 @@ export const CollectionOperations = ({
|
||||
name: t['com.affine.collection.menu.edit'](),
|
||||
click: showEdit,
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<MenuIcon>
|
||||
{favorite ? (
|
||||
<FavoritedIcon style={{ color: 'var(--affine-primary-color)' }} />
|
||||
) : (
|
||||
<FavoriteIcon />
|
||||
)}
|
||||
</MenuIcon>
|
||||
),
|
||||
name: favorite
|
||||
? t['com.affine.favoritePageOperation.remove']()
|
||||
: t['com.affine.favoritePageOperation.add'](),
|
||||
click: onToggleFavoritePage,
|
||||
},
|
||||
...(appSettings.enableMultiView
|
||||
? [
|
||||
{
|
||||
@@ -133,7 +164,7 @@ export const CollectionOperations = ({
|
||||
),
|
||||
name: t['Delete'](),
|
||||
click: () => {
|
||||
service.deleteCollection(info, collection.id);
|
||||
service.deleteCollection(deleteInfo, collection.id);
|
||||
},
|
||||
type: 'danger',
|
||||
},
|
||||
@@ -142,10 +173,12 @@ export const CollectionOperations = ({
|
||||
t,
|
||||
showEditName,
|
||||
showEdit,
|
||||
favorite,
|
||||
onToggleFavoritePage,
|
||||
appSettings.enableMultiView,
|
||||
openCollectionSplitView,
|
||||
service,
|
||||
info,
|
||||
deleteInfo,
|
||||
collection.id,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Menu } from '@affine/component';
|
||||
import { FavoriteItemsAdapter } from '@affine/core/modules/workspace';
|
||||
import type { Collection } from '@affine/env/filter';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { FilterIcon } from '@blocksuite/icons';
|
||||
import type { DocMeta } from '@blocksuite/store';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import clsx from 'clsx';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
@@ -34,6 +36,8 @@ export const PagesMode = ({
|
||||
allPageListConfig: AllPageListConfig;
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const favAdapter = useService(FavoriteItemsAdapter);
|
||||
const favorites = useLiveData(favAdapter.favorites$);
|
||||
const {
|
||||
showFilter,
|
||||
filters,
|
||||
@@ -45,6 +49,7 @@ export const PagesMode = ({
|
||||
allPageListConfig.allPages.map(meta => ({
|
||||
meta,
|
||||
publicMode: allPageListConfig.getPublicMode(meta.id),
|
||||
favorite: favorites.some(f => f.id === meta.id),
|
||||
}))
|
||||
);
|
||||
const pageHeaderColsDef = usePageHeaderColsDef();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FavoriteItemsAdapter } from '@affine/core/modules/workspace';
|
||||
import type { Collection } from '@affine/env/filter';
|
||||
import { Trans } from '@affine/i18n';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
ToggleCollapseIcon,
|
||||
} from '@blocksuite/icons';
|
||||
import type { DocMeta } from '@blocksuite/store';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import clsx from 'clsx';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
@@ -42,6 +44,8 @@ export const RulesMode = ({
|
||||
const allowListPages: DocMeta[] = [];
|
||||
const rulesPages: DocMeta[] = [];
|
||||
const [showTips, setShowTips] = useState(false);
|
||||
const favAdapter = useService(FavoriteItemsAdapter);
|
||||
const favorites = useLiveData(favAdapter.favorites$);
|
||||
useEffect(() => {
|
||||
setShowTips(!localStorage.getItem('hide-rules-mode-include-page-tips'));
|
||||
}, []);
|
||||
@@ -56,6 +60,7 @@ export const RulesMode = ({
|
||||
const pageData = {
|
||||
meta,
|
||||
publicMode: allPageListConfig.getPublicMode(meta.id),
|
||||
favorite: favorites.some(f => f.id === meta.id),
|
||||
};
|
||||
if (
|
||||
collection.filterList.length &&
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Button, Menu } from '@affine/component';
|
||||
import { FavoriteItemsAdapter } from '@affine/core/modules/workspace';
|
||||
import { Trans } from '@affine/i18n';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { FilterIcon } from '@blocksuite/icons';
|
||||
import type { DocMeta } from '@blocksuite/store';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import clsx from 'clsx';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
@@ -35,6 +37,8 @@ export const SelectPage = ({
|
||||
const clearSelected = useCallback(() => {
|
||||
onChange([]);
|
||||
}, []);
|
||||
const favAdapter = useService(FavoriteItemsAdapter);
|
||||
const favourites = useLiveData(favAdapter.favorites$);
|
||||
const {
|
||||
clickFilter,
|
||||
createFilter,
|
||||
@@ -46,6 +50,7 @@ export const SelectPage = ({
|
||||
allPageListConfig.allPages.map(meta => ({
|
||||
meta,
|
||||
publicMode: allPageListConfig.getPublicMode(meta.id),
|
||||
favorite: favourites.some(fav => fav.id === meta.id),
|
||||
}))
|
||||
);
|
||||
const { searchText, updateSearchText, searchedList } =
|
||||
|
||||
@@ -6,70 +6,101 @@ import {
|
||||
filterPage,
|
||||
stopPropagation,
|
||||
} from '@affine/core/components/page-list';
|
||||
import {
|
||||
type DNDIdentifier,
|
||||
getDNDId,
|
||||
parseDNDId,
|
||||
resolveDragEndIntent,
|
||||
} from '@affine/core/hooks/affine/use-global-dnd-helper';
|
||||
import { CollectionService } from '@affine/core/modules/collection';
|
||||
import type { Collection, DeleteCollectionInfo } from '@affine/env/filter';
|
||||
import { FavoriteItemsAdapter } from '@affine/core/modules/workspace';
|
||||
import type { Collection } from '@affine/env/filter';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { MoreHorizontalIcon, ViewLayersIcon } from '@blocksuite/icons';
|
||||
import type { DocCollection, DocMeta } from '@blocksuite/store';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import type { DocCollection } from '@blocksuite/store';
|
||||
import { type AnimateLayoutChanges, useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import * as Collapsible from '@radix-ui/react-collapsible';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { useAllPageListConfig } from '../../../../hooks/affine/use-all-page-list-config';
|
||||
import { getDropItemId } from '../../../../hooks/affine/use-sidebar-drag';
|
||||
import { useBlockSuiteDocMeta } from '../../../../hooks/use-block-suite-page-meta';
|
||||
import { Workbench } from '../../../../modules/workbench';
|
||||
import { WorkbenchLink } from '../../../../modules/workbench/view/workbench-link';
|
||||
import { MenuLinkItem as SidebarMenuLinkItem } from '../../../app-sidebar';
|
||||
import { DragMenuItemOverlay } from '../components/drag-menu-item-overlay';
|
||||
import * as draggableMenuItemStyles from '../components/draggable-menu-item.css';
|
||||
import type { CollectionsListProps } from '../index';
|
||||
import { Page } from './page';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
const CollectionRenderer = ({
|
||||
const animateLayoutChanges: AnimateLayoutChanges = ({
|
||||
isSorting,
|
||||
wasDragging,
|
||||
}) => (isSorting || wasDragging ? false : true);
|
||||
|
||||
export const CollectionSidebarNavItem = ({
|
||||
collection,
|
||||
pages,
|
||||
docCollection,
|
||||
info,
|
||||
className,
|
||||
dndId,
|
||||
}: {
|
||||
collection: Collection;
|
||||
pages: DocMeta[];
|
||||
docCollection: DocCollection;
|
||||
info: DeleteCollectionInfo;
|
||||
dndId: DNDIdentifier;
|
||||
className?: string;
|
||||
}) => {
|
||||
const pages = useBlockSuiteDocMeta(docCollection);
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
const [open, setOpen] = useState(false);
|
||||
const collectionService = useService(CollectionService);
|
||||
const favAdapter = useService(FavoriteItemsAdapter);
|
||||
const t = useAFFiNEI18N();
|
||||
const dragItemId = getDropItemId('collections', collection.id);
|
||||
|
||||
const favourites = useLiveData(favAdapter.favorites$);
|
||||
|
||||
const removeFromAllowList = useCallback(
|
||||
(id: string) => {
|
||||
collectionService.updateCollection(collection.id, () => ({
|
||||
...collection,
|
||||
allowList: collection.allowList?.filter(v => v !== id),
|
||||
}));
|
||||
|
||||
collectionService.deletePageFromCollection(collection.id, id);
|
||||
toast(t['com.affine.collection.removePage.success']());
|
||||
},
|
||||
[collection, collectionService, t]
|
||||
);
|
||||
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id: dragItemId,
|
||||
const overlayPreview = useMemo(() => {
|
||||
return (
|
||||
<DragMenuItemOverlay icon={<ViewLayersIcon />} title={collection.name} />
|
||||
);
|
||||
}, [collection.name]);
|
||||
|
||||
const {
|
||||
setNodeRef,
|
||||
isDragging,
|
||||
attributes,
|
||||
listeners,
|
||||
transform,
|
||||
over,
|
||||
active,
|
||||
transition,
|
||||
} = useSortable({
|
||||
id: dndId,
|
||||
data: {
|
||||
addToCollection: (id: string) => {
|
||||
if (collection.allowList.includes(id)) {
|
||||
toast(t['com.affine.collection.addPage.alreadyExists']());
|
||||
return;
|
||||
} else {
|
||||
toast(t['com.affine.collection.addPage.success']());
|
||||
}
|
||||
collectionService.addPageToCollection(collection.id, id);
|
||||
},
|
||||
preview: overlayPreview,
|
||||
},
|
||||
animateLayoutChanges,
|
||||
});
|
||||
|
||||
const isSorting = parseDNDId(active?.id)?.where === 'sidebar-pin';
|
||||
const dragOverIntent = resolveDragEndIntent(active, over);
|
||||
|
||||
const style = {
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition: isSorting ? transition : undefined,
|
||||
};
|
||||
|
||||
const isOver = over?.id === dndId && dragOverIntent === 'collection:add';
|
||||
|
||||
const config = useAllPageListConfig();
|
||||
const allPagesMeta = useMemo(
|
||||
() => Object.fromEntries(pages.map(v => [v.id, v])),
|
||||
@@ -85,6 +116,7 @@ const CollectionRenderer = ({
|
||||
const pageData = {
|
||||
meta,
|
||||
publicMode: config.getPublicMode(meta.id),
|
||||
favorite: favourites.some(fav => fav.id === meta.id),
|
||||
};
|
||||
return filterPage(collection, pageData);
|
||||
});
|
||||
@@ -107,9 +139,20 @@ const CollectionRenderer = ({
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Collapsible.Root open={!collapsed} ref={setNodeRef}>
|
||||
<Collapsible.Root
|
||||
open={!collapsed}
|
||||
className={className}
|
||||
style={style}
|
||||
ref={setNodeRef}
|
||||
{...attributes}
|
||||
>
|
||||
<SidebarMenuLinkItem
|
||||
{...listeners}
|
||||
data-draggable={true}
|
||||
data-dragging={isDragging}
|
||||
className={draggableMenuItemStyles.draggableMenuItem}
|
||||
data-testid="collection-item"
|
||||
data-collection-id={collection.id}
|
||||
data-type="collection-list-item"
|
||||
onCollapsedChange={setCollapsed}
|
||||
active={isOver || currentPath === path}
|
||||
@@ -122,7 +165,6 @@ const CollectionRenderer = ({
|
||||
style={{ display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
<CollectionOperations
|
||||
info={info}
|
||||
collection={collection}
|
||||
config={config}
|
||||
openRenameModal={handleOpen}
|
||||
@@ -153,6 +195,7 @@ const CollectionRenderer = ({
|
||||
{pagesToRender.map(page => {
|
||||
return (
|
||||
<Page
|
||||
parentId={dndId}
|
||||
inAllowList={allowList.has(page.id)}
|
||||
removeFromAllowList={removeFromAllowList}
|
||||
allPageMeta={allPagesMeta}
|
||||
@@ -169,12 +212,11 @@ const CollectionRenderer = ({
|
||||
};
|
||||
export const CollectionsList = ({
|
||||
docCollection: workspace,
|
||||
info,
|
||||
onCreate,
|
||||
}: CollectionsListProps) => {
|
||||
const metas = useBlockSuiteDocMeta(workspace);
|
||||
const collections = useLiveData(useService(CollectionService).collections$);
|
||||
const t = useAFFiNEI18N();
|
||||
|
||||
if (collections.length === 0) {
|
||||
return (
|
||||
<div className={styles.emptyCollectionWrapper}>
|
||||
@@ -198,13 +240,18 @@ export const CollectionsList = ({
|
||||
return (
|
||||
<div data-testid="collections" className={styles.wrapper}>
|
||||
{collections.map(view => {
|
||||
const dragItemId = getDNDId(
|
||||
'sidebar-collections',
|
||||
'collection',
|
||||
view.id
|
||||
);
|
||||
|
||||
return (
|
||||
<CollectionRenderer
|
||||
info={info}
|
||||
<CollectionSidebarNavItem
|
||||
key={view.id}
|
||||
collection={view}
|
||||
pages={metas}
|
||||
docCollection={workspace}
|
||||
dndId={dragItemId}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -8,7 +8,10 @@ import { PageRecordList, useLiveData, useService } from '@toeverything/infra';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { getDragItemId } from '../../../../hooks/affine/use-sidebar-drag';
|
||||
import {
|
||||
type DNDIdentifier,
|
||||
getDNDId,
|
||||
} from '../../../../hooks/affine/use-global-dnd-helper';
|
||||
import { useNavigateHelper } from '../../../../hooks/use-navigate-helper';
|
||||
import { MenuItem as CollectionItem } from '../../../app-sidebar';
|
||||
import { DragMenuItemOverlay } from '../components/drag-menu-item-overlay';
|
||||
@@ -18,11 +21,13 @@ import * as styles from './styles.css';
|
||||
|
||||
export const Page = ({
|
||||
page,
|
||||
parentId,
|
||||
docCollection,
|
||||
allPageMeta,
|
||||
inAllowList,
|
||||
removeFromAllowList,
|
||||
}: {
|
||||
parentId: DNDIdentifier;
|
||||
page: DocMeta;
|
||||
inAllowList: boolean;
|
||||
removeFromAllowList: (id: string) => void;
|
||||
@@ -38,7 +43,7 @@ export const Page = ({
|
||||
const active = params.pageId === pageId;
|
||||
const pageRecord = useLiveData(useService(PageRecordList).record$(pageId));
|
||||
const pageMode = useLiveData(pageRecord?.mode$);
|
||||
const dragItemId = getDragItemId('collectionPage', pageId);
|
||||
const dragItemId = getDNDId('collection-list', 'doc', pageId, parentId);
|
||||
|
||||
const icon = useMemo(() => {
|
||||
return pageMode === 'edgeless' ? <EdgelessIcon /> : <PageIcon />;
|
||||
@@ -56,15 +61,13 @@ export const Page = ({
|
||||
|
||||
const pageTitle = page.title || t['Untitled']();
|
||||
const pageTitleElement = useMemo(() => {
|
||||
return <DragMenuItemOverlay icon={icon} pageTitle={pageTitle} />;
|
||||
return <DragMenuItemOverlay icon={icon} title={pageTitle} />;
|
||||
}, [icon, pageTitle]);
|
||||
|
||||
const { setNodeRef, attributes, listeners, isDragging } = useDraggable({
|
||||
id: dragItemId,
|
||||
data: {
|
||||
pageId,
|
||||
pageTitle: pageTitleElement,
|
||||
removeFromCollection: () => removeFromAllowList(pageId),
|
||||
preview: pageTitleElement,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -102,6 +102,9 @@ export const collapsibleContent = style({
|
||||
overflow: 'hidden',
|
||||
marginTop: '4px',
|
||||
selectors: {
|
||||
'&[data-hidden="true"]': {
|
||||
display: 'none',
|
||||
},
|
||||
'&[data-state="open"]': {
|
||||
animation: `${slideDown} 0.2s ease-in-out`,
|
||||
},
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import * as styles from '../favorite/styles.css';
|
||||
|
||||
export const DragMenuItemOverlay = ({
|
||||
pageTitle,
|
||||
title,
|
||||
icon,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
pageTitle: React.ReactNode;
|
||||
title: React.ReactNode;
|
||||
}) => {
|
||||
return (
|
||||
<div className={styles.dragPageItemOverlay}>
|
||||
{icon}
|
||||
<span>{pageTitle}</span>
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const draggableMenuItem = style({
|
||||
selectors: {
|
||||
'&[data-draggable=true]:before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
left: 0,
|
||||
width: 4,
|
||||
height: 4,
|
||||
transition: 'height 0.2s, opacity 0.2s',
|
||||
backgroundColor: cssVar('placeholderColor'),
|
||||
borderRadius: '2px',
|
||||
opacity: 0,
|
||||
willChange: 'height, opacity',
|
||||
},
|
||||
'&[data-draggable=true]:hover:before': {
|
||||
height: 12,
|
||||
opacity: 1,
|
||||
},
|
||||
'&[data-draggable=true][data-dragging=true]': {
|
||||
backgroundColor: cssVar('hoverColor'),
|
||||
},
|
||||
'&[data-draggable=true][data-dragging=true]:before': {
|
||||
height: 32,
|
||||
width: 2,
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -2,13 +2,13 @@ import { toast } from '@affine/component';
|
||||
import { IconButton } from '@affine/component/ui/button';
|
||||
import { Menu } from '@affine/component/ui/menu';
|
||||
import { Workbench } from '@affine/core/modules/workbench';
|
||||
import { FavoriteItemsAdapter } from '@affine/core/modules/workspace';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { MoreHorizontalIcon } from '@blocksuite/icons';
|
||||
import type { DocCollection } from '@blocksuite/store';
|
||||
import { useService } from '@toeverything/infra';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { useBlockSuiteMetaHelper } from '../../../../hooks/affine/use-block-suite-meta-helper';
|
||||
import { useTrashModalHelper } from '../../../../hooks/affine/use-trash-modal-helper';
|
||||
import { usePageHelper } from '../../../blocksuite/block-suite-page-list/utils';
|
||||
import { OperationItems } from './operation-item';
|
||||
@@ -38,7 +38,8 @@ export const OperationMenuButton = ({ ...props }: OperationMenuButtonProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const { createLinkedPage } = usePageHelper(docCollection);
|
||||
const { setTrashModal } = useTrashModalHelper(docCollection);
|
||||
const { removeFromFavorite } = useBlockSuiteMetaHelper(docCollection);
|
||||
|
||||
const favAdapter = useService(FavoriteItemsAdapter);
|
||||
const workbench = useService(Workbench);
|
||||
|
||||
const handleRename = useCallback(() => {
|
||||
@@ -51,9 +52,9 @@ export const OperationMenuButton = ({ ...props }: OperationMenuButtonProps) => {
|
||||
}, [createLinkedPage, pageId, t]);
|
||||
|
||||
const handleRemoveFromFavourites = useCallback(() => {
|
||||
removeFromFavorite(pageId);
|
||||
favAdapter.remove(pageId, 'doc');
|
||||
toast(t['com.affine.toastMessage.removedFavorites']());
|
||||
}, [pageId, removeFromFavorite, t]);
|
||||
}, [favAdapter, pageId, t]);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
setTrashModal({
|
||||
|
||||
@@ -14,7 +14,7 @@ export interface ReferencePageProps {
|
||||
docCollection: DocCollection;
|
||||
pageId: string;
|
||||
metaMapping: Record<string, DocMeta>;
|
||||
parentIds: Set<string>;
|
||||
parentIds?: Set<string>;
|
||||
}
|
||||
|
||||
export const ReferencePage = ({
|
||||
@@ -44,7 +44,7 @@ export const ReferencePage = ({
|
||||
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
const collapsible = referencesToShow.length > 0;
|
||||
const nestedItem = parentIds.size > 0;
|
||||
const nestedItem = parentIds && parentIds.size > 0;
|
||||
|
||||
const untitled = !metaMapping[pageId]?.title;
|
||||
const pageTitle = metaMapping[pageId]?.title || t['Untitled']();
|
||||
@@ -86,7 +86,7 @@ export const ReferencePage = ({
|
||||
docCollection={docCollection}
|
||||
pageId={ref}
|
||||
metaMapping={metaMapping}
|
||||
parentIds={new Set([...parentIds, pageId])}
|
||||
parentIds={new Set([...(parentIds ?? []), pageId])}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { IconButton } from '@affine/component/ui/button';
|
||||
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
||||
import { useDocMetaHelper } from '@affine/core/hooks/use-block-suite-page-meta';
|
||||
import { FavoriteItemsAdapter } from '@affine/core/modules/workspace';
|
||||
import { PlusIcon } from '@blocksuite/icons';
|
||||
import type { DocCollection } from '@blocksuite/store';
|
||||
import { useService } from '@toeverything/infra';
|
||||
|
||||
import { usePageHelper } from '../../../blocksuite/block-suite-page-list/utils';
|
||||
|
||||
@@ -16,7 +17,7 @@ export const AddFavouriteButton = ({
|
||||
pageId,
|
||||
}: AddFavouriteButtonProps) => {
|
||||
const { createPage, createLinkedPage } = usePageHelper(docCollection);
|
||||
const { setDocMeta } = useDocMetaHelper(docCollection);
|
||||
const favAdapter = useService(FavoriteItemsAdapter);
|
||||
const handleAddFavorite = useAsyncCallback(
|
||||
async e => {
|
||||
if (pageId) {
|
||||
@@ -26,10 +27,10 @@ export const AddFavouriteButton = ({
|
||||
} else {
|
||||
const page = createPage();
|
||||
page.load();
|
||||
setDocMeta(page.id, { favorite: true });
|
||||
favAdapter.set(page.id, 'doc', true);
|
||||
}
|
||||
},
|
||||
[pageId, createLinkedPage, createPage, setDocMeta]
|
||||
[pageId, createLinkedPage, createPage, favAdapter]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,28 +1,36 @@
|
||||
import { CategoryDivider } from '@affine/core/components/app-sidebar';
|
||||
import {
|
||||
getDNDId,
|
||||
resolveDragEndIntent,
|
||||
} from '@affine/core/hooks/affine/use-global-dnd-helper';
|
||||
import { useBlockSuiteDocMeta } from '@affine/core/hooks/use-block-suite-page-meta';
|
||||
import { CollectionService } from '@affine/core/modules/collection';
|
||||
import { FavoriteItemsAdapter } from '@affine/core/modules/workspace';
|
||||
import type { WorkspaceFavoriteItem } from '@affine/core/modules/workspace/properties/schema';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import type { DocMeta } from '@blocksuite/store';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import { useMemo } from 'react';
|
||||
import { useDndContext, useDroppable } from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { Fragment, useCallback, useMemo } from 'react';
|
||||
|
||||
import { getDropItemId } from '../../../../hooks/affine/use-sidebar-drag';
|
||||
import { CollectionSidebarNavItem } from '../collections';
|
||||
import type { FavoriteListProps } from '../index';
|
||||
import { AddFavouriteButton } from './add-favourite-button';
|
||||
import EmptyItem from './empty-item';
|
||||
import { FavouritePage } from './favourite-page';
|
||||
import { FavouriteDocSidebarNavItem } from './favourite-nav-item';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
const emptyPageIdSet = new Set<string>();
|
||||
|
||||
export const FavoriteList = ({
|
||||
docCollection: workspace,
|
||||
}: FavoriteListProps) => {
|
||||
const FavoriteListInner = ({ docCollection: workspace }: FavoriteListProps) => {
|
||||
const metas = useBlockSuiteDocMeta(workspace);
|
||||
const dropItemId = getDropItemId('favorites');
|
||||
const favAdapter = useService(FavoriteItemsAdapter);
|
||||
const collections = useLiveData(useService(CollectionService).collections$);
|
||||
const dropItemId = getDNDId('sidebar-pin', 'container', workspace.id);
|
||||
|
||||
const favoriteList = useMemo(
|
||||
() => metas.filter(p => p.favorite && !p.trash),
|
||||
[metas]
|
||||
);
|
||||
|
||||
const metaMapping = useMemo(
|
||||
const docMetaMapping = useMemo(
|
||||
() =>
|
||||
metas.reduce(
|
||||
(acc, meta) => {
|
||||
@@ -34,32 +42,94 @@ export const FavoriteList = ({
|
||||
[metas]
|
||||
);
|
||||
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
const favourites = useLiveData(
|
||||
favAdapter.orderedFavorites$.map(favs => {
|
||||
return favs.filter(fav => {
|
||||
if (fav.type === 'doc') {
|
||||
return !!docMetaMapping[fav.id] && !docMetaMapping[fav.id].trash;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// disable drop styles when dragging from the pin list
|
||||
const { active } = useDndContext();
|
||||
|
||||
const { setNodeRef, over } = useDroppable({
|
||||
id: dropItemId,
|
||||
});
|
||||
|
||||
const intent = resolveDragEndIntent(active, over);
|
||||
const shouldRenderDragOver = intent === 'pin:add';
|
||||
|
||||
const renderFavItem = useCallback(
|
||||
(item: WorkspaceFavoriteItem) => {
|
||||
if (item.type === 'collection') {
|
||||
const collection = collections.find(c => c.id === item.id);
|
||||
if (collection) {
|
||||
const dragItemId = getDNDId(
|
||||
'sidebar-pin',
|
||||
'collection',
|
||||
collection.id
|
||||
);
|
||||
return (
|
||||
<CollectionSidebarNavItem
|
||||
dndId={dragItemId}
|
||||
className={styles.favItemWrapper}
|
||||
docCollection={workspace}
|
||||
collection={collection}
|
||||
/>
|
||||
);
|
||||
}
|
||||
} else if (item.type === 'doc' && !docMetaMapping[item.id].trash) {
|
||||
return (
|
||||
<FavouriteDocSidebarNavItem
|
||||
metaMapping={docMetaMapping}
|
||||
pageId={item.id}
|
||||
// memo?
|
||||
docCollection={workspace}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[collections, docMetaMapping, workspace]
|
||||
);
|
||||
|
||||
const t = useAFFiNEI18N();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.favoriteList}
|
||||
data-testid="favourites"
|
||||
ref={setNodeRef}
|
||||
data-over={isOver}
|
||||
data-over={shouldRenderDragOver}
|
||||
>
|
||||
{favoriteList.map((pageMeta, index) => {
|
||||
return (
|
||||
<FavouritePage
|
||||
key={`${pageMeta}-${index}`}
|
||||
metaMapping={metaMapping}
|
||||
pageId={pageMeta.id}
|
||||
// memo?
|
||||
parentIds={emptyPageIdSet}
|
||||
docCollection={workspace}
|
||||
/>
|
||||
);
|
||||
<CategoryDivider label={t['com.affine.rootAppSidebar.favorites']()}>
|
||||
<AddFavouriteButton docCollection={workspace} />
|
||||
</CategoryDivider>
|
||||
{favourites.map(item => {
|
||||
return <Fragment key={item.id}>{renderFavItem(item)}</Fragment>;
|
||||
})}
|
||||
{favoriteList.length === 0 && <EmptyItem />}
|
||||
{favourites.length === 0 && <EmptyItem />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const FavoriteList = ({
|
||||
docCollection: workspace,
|
||||
}: FavoriteListProps) => {
|
||||
const favAdapter = useService(FavoriteItemsAdapter);
|
||||
const favourites = useLiveData(favAdapter.orderedFavorites$);
|
||||
const sortItems = useMemo(() => {
|
||||
return favourites.map(fav => getDNDId('sidebar-pin', fav.type, fav.id));
|
||||
}, [favourites]);
|
||||
return (
|
||||
<SortableContext items={sortItems} strategy={verticalListSortingStrategy}>
|
||||
<FavoriteListInner docCollection={workspace} />
|
||||
</SortableContext>
|
||||
);
|
||||
};
|
||||
|
||||
export default FavoriteList;
|
||||
|
||||
@@ -1,30 +1,40 @@
|
||||
import {
|
||||
getDNDId,
|
||||
parseDNDId,
|
||||
} from '@affine/core/hooks/affine/use-global-dnd-helper';
|
||||
import { useBlockSuitePageReferences } from '@affine/core/hooks/use-block-suite-page-references';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { EdgelessIcon, PageIcon } from '@blocksuite/icons';
|
||||
import { useDraggable } from '@dnd-kit/core';
|
||||
import { type AnimateLayoutChanges, useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import * as Collapsible from '@radix-ui/react-collapsible';
|
||||
import { PageRecordList, useLiveData, useService } from '@toeverything/infra';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { getDragItemId } from '../../../../hooks/affine/use-sidebar-drag';
|
||||
import { MenuLinkItem } from '../../../app-sidebar';
|
||||
import { DragMenuItemOverlay } from '../components/drag-menu-item-overlay';
|
||||
import * as draggableMenuItemStyles from '../components/draggable-menu-item.css';
|
||||
import { PostfixItem } from '../components/postfix-item';
|
||||
import type { ReferencePageProps } from '../components/reference-page';
|
||||
import { ReferencePage } from '../components/reference-page';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
export const FavouritePage = ({
|
||||
const animateLayoutChanges: AnimateLayoutChanges = ({
|
||||
isSorting,
|
||||
wasDragging,
|
||||
}) => (isSorting || wasDragging ? false : true);
|
||||
|
||||
export const FavouriteDocSidebarNavItem = ({
|
||||
docCollection: workspace,
|
||||
pageId,
|
||||
metaMapping,
|
||||
parentIds,
|
||||
}: ReferencePageProps) => {
|
||||
}: ReferencePageProps & {
|
||||
sortable?: boolean;
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const params = useParams();
|
||||
const active = params.pageId === pageId;
|
||||
const dragItemId = getDragItemId('favouritePage', pageId);
|
||||
const linkActive = params.pageId === pageId;
|
||||
const pageRecord = useLiveData(useService(PageRecordList).record$(pageId));
|
||||
const pageMode = useLiveData(pageRecord?.mode$);
|
||||
|
||||
@@ -43,43 +53,57 @@ export const FavouritePage = ({
|
||||
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
const collapsible = referencesToShow.length > 0;
|
||||
const nestedItem = parentIds.size > 0;
|
||||
|
||||
const untitled = !metaMapping[pageId]?.title;
|
||||
const pageTitle = metaMapping[pageId]?.title || t['Untitled']();
|
||||
|
||||
const pageTitleElement = useMemo(() => {
|
||||
return <DragMenuItemOverlay icon={icon} pageTitle={pageTitle} />;
|
||||
const overlayPreview = useMemo(() => {
|
||||
return <DragMenuItemOverlay icon={icon} title={pageTitle} />;
|
||||
}, [icon, pageTitle]);
|
||||
|
||||
const { setNodeRef, attributes, listeners, isDragging } = useDraggable({
|
||||
const dragItemId = getDNDId('sidebar-pin', 'doc', pageId);
|
||||
|
||||
const {
|
||||
setNodeRef,
|
||||
isDragging,
|
||||
attributes,
|
||||
listeners,
|
||||
transform,
|
||||
transition,
|
||||
active,
|
||||
} = useSortable({
|
||||
id: dragItemId,
|
||||
data: {
|
||||
pageId,
|
||||
pageTitle: pageTitleElement,
|
||||
preview: overlayPreview,
|
||||
},
|
||||
animateLayoutChanges,
|
||||
});
|
||||
|
||||
const isSorting = parseDNDId(active?.id)?.where === 'sidebar-pin';
|
||||
const style = {
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition: isSorting ? transition : undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<Collapsible.Root
|
||||
className={styles.favItemWrapper}
|
||||
data-nested={nestedItem}
|
||||
open={!collapsed}
|
||||
data-draggable={true}
|
||||
data-dragging={isDragging}
|
||||
style={style}
|
||||
ref={setNodeRef}
|
||||
{...attributes}
|
||||
>
|
||||
<MenuLinkItem
|
||||
{...listeners}
|
||||
data-testid={`favourite-page-${pageId}`}
|
||||
data-type="favourite-list-item"
|
||||
icon={icon}
|
||||
className={styles.favItem}
|
||||
active={active}
|
||||
data-draggable={true}
|
||||
data-dragging={isDragging}
|
||||
className={draggableMenuItemStyles.draggableMenuItem}
|
||||
active={linkActive}
|
||||
to={`/workspace/${workspace.id}/${pageId}`}
|
||||
collapsed={collapsible ? collapsed : undefined}
|
||||
onCollapsedChange={setCollapsed}
|
||||
ref={setNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
postfix={
|
||||
<PostfixItem
|
||||
docCollection={workspace}
|
||||
@@ -10,6 +10,8 @@ export const label = style({
|
||||
export const favItemWrapper = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flexShrink: 0,
|
||||
userSelect: 'none',
|
||||
selectors: {
|
||||
'&[data-nested="true"]': {
|
||||
marginLeft: '20px',
|
||||
@@ -40,6 +42,9 @@ export const collapsibleContent = style({
|
||||
overflow: 'hidden',
|
||||
marginTop: '4px',
|
||||
selectors: {
|
||||
'&[data-hidden="true"]': {
|
||||
display: 'none',
|
||||
},
|
||||
'&[data-state="open"]': {
|
||||
animation: `${slideDown} 0.2s ease-out`,
|
||||
},
|
||||
@@ -52,33 +57,7 @@ export const collapsibleContentInner = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
});
|
||||
export const favItem = style({});
|
||||
globalStyle(`[data-draggable=true] ${favItem}:before`, {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
left: 0,
|
||||
width: 4,
|
||||
height: 4,
|
||||
transition: 'height 0.2s, opacity 0.2s',
|
||||
backgroundColor: cssVar('placeholderColor'),
|
||||
borderRadius: '2px',
|
||||
opacity: 0,
|
||||
willChange: 'height, opacity',
|
||||
});
|
||||
globalStyle(`[data-draggable=true] ${favItem}:hover:before`, {
|
||||
height: 12,
|
||||
opacity: 1,
|
||||
});
|
||||
globalStyle(`[data-draggable=true][data-dragging=true] ${favItem}`, {
|
||||
opacity: 0.5,
|
||||
});
|
||||
globalStyle(`[data-draggable=true][data-dragging=true] ${favItem}:before`, {
|
||||
height: 32,
|
||||
width: 2,
|
||||
opacity: 1,
|
||||
});
|
||||
|
||||
export const dragPageItemOverlay = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -91,7 +70,6 @@ export const dragPageItemOverlay = style({
|
||||
gap: '8px',
|
||||
padding: '4px',
|
||||
borderRadius: '4px',
|
||||
cursor: 'grabbing',
|
||||
});
|
||||
globalStyle(`${dragPageItemOverlay} svg`, {
|
||||
width: '20px',
|
||||
@@ -104,6 +82,7 @@ globalStyle(`${dragPageItemOverlay} span`, {
|
||||
overflow: 'hidden',
|
||||
});
|
||||
export const favoriteList = style({
|
||||
overflow: 'hidden',
|
||||
selectors: {
|
||||
'&[data-over="true"]': {
|
||||
background: cssVar('hoverColorFilled'),
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { DeleteCollectionInfo } from '@affine/env/filter';
|
||||
import type { DocCollection } from '@blocksuite/store';
|
||||
|
||||
export type FavoriteListProps = {
|
||||
@@ -7,6 +6,5 @@ export type FavoriteListProps = {
|
||||
|
||||
export type CollectionsListProps = {
|
||||
docCollection: DocCollection;
|
||||
info: DeleteCollectionInfo;
|
||||
onCreate?: () => void;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AnimatedDeleteIcon } from '@affine/component';
|
||||
import { getDNDId } from '@affine/core/hooks/affine/use-global-dnd-helper';
|
||||
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
||||
import { CollectionService } from '@affine/core/modules/collection';
|
||||
import { apis, events } from '@affine/electron-api';
|
||||
@@ -14,8 +15,6 @@ import type { HTMLAttributes, ReactElement } from 'react';
|
||||
import { forwardRef, useCallback, useEffect } from 'react';
|
||||
|
||||
import { useAppSettingHelper } from '../../hooks/affine/use-app-setting-helper';
|
||||
import { useDeleteCollectionInfo } from '../../hooks/affine/use-delete-collection-info';
|
||||
import { getDropItemId } from '../../hooks/affine/use-sidebar-drag';
|
||||
import { useTrashModalHelper } from '../../hooks/affine/use-trash-modal-helper';
|
||||
import { useNavigateHelper } from '../../hooks/use-navigate-helper';
|
||||
import { Workbench } from '../../modules/workbench';
|
||||
@@ -38,7 +37,6 @@ import {
|
||||
} from '../page-list';
|
||||
import { CollectionsList } from '../pure/workspace-slider-bar/collections';
|
||||
import { AddCollectionButton } from '../pure/workspace-slider-bar/collections/add-collection-button';
|
||||
import { AddFavouriteButton } from '../pure/workspace-slider-bar/favorite/add-favourite-button';
|
||||
import FavoriteList from '../pure/workspace-slider-bar/favorite/favorite-list';
|
||||
import { WorkspaceSelector } from '../workspace-selector';
|
||||
import ImportPage from './import-page';
|
||||
@@ -142,7 +140,7 @@ export const RootAppSidebar = ({
|
||||
}
|
||||
}, [sidebarOpen]);
|
||||
|
||||
const dropItemId = getDropItemId('trash');
|
||||
const dropItemId = getDNDId('sidebar-trash', 'container', 'trash');
|
||||
const trashDroppable = useDroppable({
|
||||
id: dropItemId,
|
||||
});
|
||||
@@ -163,7 +161,6 @@ export const RootAppSidebar = ({
|
||||
console.error(err);
|
||||
});
|
||||
}, [docCollection.id, collection, navigateHelper, open]);
|
||||
const userInfo = useDeleteCollectionInfo();
|
||||
|
||||
const allPageActive = currentPath === '/all';
|
||||
|
||||
@@ -217,16 +214,12 @@ export const RootAppSidebar = ({
|
||||
</SidebarContainer>
|
||||
|
||||
<SidebarScrollableContainer>
|
||||
<CategoryDivider label={t['com.affine.rootAppSidebar.favorites']()}>
|
||||
<AddFavouriteButton docCollection={docCollection} />
|
||||
</CategoryDivider>
|
||||
<FavoriteList docCollection={docCollection} />
|
||||
<CategoryDivider label={t['com.affine.rootAppSidebar.collections']()}>
|
||||
<AddCollectionButton node={node} onClick={handleCreateCollection} />
|
||||
</CategoryDivider>
|
||||
<CollectionsList
|
||||
docCollection={docCollection}
|
||||
info={userInfo}
|
||||
onCreate={handleCreateCollection}
|
||||
/>
|
||||
<CategoryDivider label={t['com.affine.rootAppSidebar.others']()} />
|
||||
@@ -236,7 +229,7 @@ export const RootAppSidebar = ({
|
||||
<RouteMenuLinkItem
|
||||
ref={trashDroppable.setNodeRef}
|
||||
icon={<AnimatedDeleteIcon closed={trashDroppable.isOver} />}
|
||||
active={trashActive}
|
||||
active={trashActive || trashDroppable.isOver}
|
||||
path={paths.trash(currentWorkspaceId)}
|
||||
>
|
||||
<span data-testid="trash-page">
|
||||
|
||||
@@ -2,13 +2,13 @@ import { toast } from '@affine/component';
|
||||
import type { AllPageListConfig } from '@affine/core/components/page-list';
|
||||
import { FavoriteTag } from '@affine/core/components/page-list';
|
||||
import { useBlockSuiteDocMeta } from '@affine/core/hooks/use-block-suite-page-meta';
|
||||
import { FavoriteItemsAdapter } from '@affine/core/modules/workspace';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import type { DocMeta } from '@blocksuite/store';
|
||||
import { useService, Workspace } from '@toeverything/infra';
|
||||
import { useLiveData, useService, Workspace } from '@toeverything/infra';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { usePageHelper } from '../../components/blocksuite/block-suite-page-list/utils';
|
||||
import { useBlockSuiteMetaHelper } from './use-block-suite-meta-helper';
|
||||
import { usePublicPages } from './use-is-shared-page';
|
||||
|
||||
export const useAllPageListConfig = () => {
|
||||
@@ -21,22 +21,29 @@ export const useAllPageListConfig = () => {
|
||||
() => Object.fromEntries(pageMetas.map(page => [page.id, page])),
|
||||
[pageMetas]
|
||||
);
|
||||
const { toggleFavorite } = useBlockSuiteMetaHelper(
|
||||
currentWorkspace.docCollection
|
||||
);
|
||||
const favAdapter = useService(FavoriteItemsAdapter);
|
||||
const t = useAFFiNEI18N();
|
||||
const favoriteItems = useLiveData(favAdapter.favorites$);
|
||||
|
||||
const isActive = useCallback(
|
||||
(page: DocMeta) => {
|
||||
return favoriteItems.some(fav => fav.id === page.id);
|
||||
},
|
||||
[favoriteItems]
|
||||
);
|
||||
const onToggleFavoritePage = useCallback(
|
||||
(page: DocMeta) => {
|
||||
const status = page.favorite;
|
||||
toggleFavorite(page.id);
|
||||
const status = isActive(page);
|
||||
favAdapter.toggle(page.id, 'doc');
|
||||
toast(
|
||||
status
|
||||
? t['com.affine.toastMessage.removedFavorites']()
|
||||
: t['com.affine.toastMessage.addedFavorites']()
|
||||
);
|
||||
},
|
||||
[t, toggleFavorite]
|
||||
[favAdapter, isActive, t]
|
||||
);
|
||||
|
||||
return useMemo<AllPageListConfig>(() => {
|
||||
return {
|
||||
allPages: pageMetas,
|
||||
@@ -49,7 +56,7 @@ export const useAllPageListConfig = () => {
|
||||
<FavoriteTag
|
||||
style={{ marginRight: 8 }}
|
||||
onClick={() => onToggleFavoritePage(page)}
|
||||
active={!!page.favorite}
|
||||
active={isActive(page)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
@@ -60,6 +67,7 @@ export const useAllPageListConfig = () => {
|
||||
getPublicMode,
|
||||
currentWorkspace.docCollection,
|
||||
pageMap,
|
||||
isActive,
|
||||
onToggleFavoritePage,
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -19,32 +19,6 @@ export function useBlockSuiteMetaHelper(docCollection: DocCollection) {
|
||||
const collectionService = useService(CollectionService);
|
||||
const pageRecordList = useService(PageRecordList);
|
||||
|
||||
const addToFavorite = useCallback(
|
||||
(pageId: string) => {
|
||||
setDocMeta(pageId, {
|
||||
favorite: true,
|
||||
});
|
||||
},
|
||||
[setDocMeta]
|
||||
);
|
||||
const removeFromFavorite = useCallback(
|
||||
(pageId: string) => {
|
||||
setDocMeta(pageId, {
|
||||
favorite: false,
|
||||
});
|
||||
},
|
||||
[setDocMeta]
|
||||
);
|
||||
const toggleFavorite = useCallback(
|
||||
(pageId: string) => {
|
||||
const { favorite } = getDocMeta(pageId) ?? {};
|
||||
setDocMeta(pageId, {
|
||||
favorite: !favorite,
|
||||
});
|
||||
},
|
||||
[getDocMeta, setDocMeta]
|
||||
);
|
||||
|
||||
// TODO-Doma
|
||||
// "Remove" may cause ambiguity here. Consider renaming as "moveToTrash".
|
||||
const removeToTrash = useCallback(
|
||||
@@ -126,7 +100,6 @@ export function useBlockSuiteMetaHelper(docCollection: DocCollection) {
|
||||
|
||||
setDocMeta(newPage.id, {
|
||||
tags: currentPageMeta.tags,
|
||||
favorite: currentPageMeta.favorite,
|
||||
});
|
||||
|
||||
const lastDigitRegex = /\((\d+)\)$/;
|
||||
@@ -157,10 +130,6 @@ export function useBlockSuiteMetaHelper(docCollection: DocCollection) {
|
||||
publicPage,
|
||||
cancelPublicPage,
|
||||
|
||||
addToFavorite,
|
||||
removeFromFavorite,
|
||||
toggleFavorite,
|
||||
|
||||
removeToTrash,
|
||||
restoreFromTrash,
|
||||
permanentlyDeletePage,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DeleteCollectionInfo } from '@affine/env/filter';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useSession } from './use-current-user';
|
||||
@@ -5,7 +6,7 @@ import { useSession } from './use-current-user';
|
||||
export const useDeleteCollectionInfo = () => {
|
||||
const { user } = useSession();
|
||||
|
||||
return useMemo(
|
||||
return useMemo<DeleteCollectionInfo | null>(
|
||||
() => (user ? { userName: user.name, userId: user.id } : null),
|
||||
[user]
|
||||
);
|
||||
|
||||
281
packages/frontend/core/src/hooks/affine/use-global-dnd-helper.ts
Normal file
281
packages/frontend/core/src/hooks/affine/use-global-dnd-helper.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import { toast } from '@affine/component';
|
||||
import { useDocMetaHelper } from '@affine/core/hooks/use-block-suite-page-meta';
|
||||
import { CollectionService } from '@affine/core/modules/collection';
|
||||
import { FavoriteItemsAdapter } from '@affine/core/modules/workspace';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import type {
|
||||
Active,
|
||||
DragEndEvent,
|
||||
Over,
|
||||
UniqueIdentifier,
|
||||
} from '@dnd-kit/core';
|
||||
import { useLiveData, useService, Workspace } from '@toeverything/infra';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useDeleteCollectionInfo } from './use-delete-collection-info';
|
||||
import { useTrashModalHelper } from './use-trash-modal-helper';
|
||||
|
||||
export type DndWhere =
|
||||
| 'sidebar-pin'
|
||||
| 'sidebar-collections'
|
||||
| 'sidebar-trash'
|
||||
| 'doc-list'
|
||||
| 'collection-list'
|
||||
| 'tag-list';
|
||||
|
||||
export type DNDItemKind = 'container' | 'collection' | 'doc' | 'tag';
|
||||
|
||||
// where:kind:id
|
||||
// we want to make the id something that can be used to identify the item
|
||||
//
|
||||
// Note, not all combinations are valid
|
||||
type DNDItemIdentifier = `${DndWhere}:${DNDItemKind}:${string}`;
|
||||
export type DNDIdentifier =
|
||||
| `${DNDItemIdentifier}/${DNDItemIdentifier}`
|
||||
| DNDItemIdentifier;
|
||||
|
||||
export type DndItem = {
|
||||
where: DndWhere;
|
||||
kind: DNDItemKind;
|
||||
itemId: string;
|
||||
parent?: DndItem; // for now we only support one level of nesting
|
||||
};
|
||||
|
||||
export function getDNDId(
|
||||
where: DndWhere,
|
||||
kind: DNDItemKind,
|
||||
id: string,
|
||||
parentId?: DNDIdentifier
|
||||
): DNDIdentifier {
|
||||
const itemId = `${where}:${kind}:${id}` as DNDItemIdentifier;
|
||||
return parentId ? `${parentId}/${itemId}` : itemId;
|
||||
}
|
||||
|
||||
export function parseDNDId(
|
||||
id: UniqueIdentifier | null | undefined
|
||||
): DndItem | undefined {
|
||||
if (typeof id !== 'string') return undefined;
|
||||
const parts = id.split('/');
|
||||
if (parts.length === 1) {
|
||||
const [where, kind, itemId] = id.split(':') as [
|
||||
DndWhere,
|
||||
DNDItemKind,
|
||||
string,
|
||||
];
|
||||
return where && kind && itemId
|
||||
? {
|
||||
where,
|
||||
kind,
|
||||
itemId,
|
||||
}
|
||||
: undefined;
|
||||
} else if (parts.length === 2) {
|
||||
const item = parseDNDId(parts[1]);
|
||||
const parent = parseDNDId(parts[0]);
|
||||
if (!item || !parent) return undefined;
|
||||
return {
|
||||
...item,
|
||||
parent,
|
||||
};
|
||||
} else {
|
||||
throw new Error('Invalid DND ID');
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveDragEndIntent(
|
||||
active?: Active | null,
|
||||
over?: Over | null
|
||||
) {
|
||||
const dragItem = parseDNDId(active?.id);
|
||||
const dropItem = parseDNDId(over?.id);
|
||||
|
||||
if (!dragItem) return null;
|
||||
|
||||
// any doc item to trash
|
||||
if (
|
||||
dropItem?.where === 'sidebar-trash' &&
|
||||
(dragItem.kind === 'doc' || dragItem.kind === 'collection')
|
||||
) {
|
||||
return 'trash:move-to';
|
||||
}
|
||||
|
||||
// add page to collection
|
||||
if (
|
||||
dragItem.kind === 'doc' &&
|
||||
dragItem.where !== dropItem?.where &&
|
||||
dropItem?.kind === 'collection'
|
||||
) {
|
||||
return 'collection:add';
|
||||
}
|
||||
|
||||
// move a doc from one collection to another
|
||||
if (
|
||||
dragItem.kind === 'doc' &&
|
||||
dragItem?.where === 'collection-list' &&
|
||||
dragItem.parent?.kind === 'collection' &&
|
||||
dropItem?.kind !== 'collection'
|
||||
) {
|
||||
return 'collection:remove';
|
||||
}
|
||||
|
||||
// move any doc/collection to sidebar pin
|
||||
if (
|
||||
dragItem.where !== 'sidebar-pin' &&
|
||||
dropItem?.where === 'sidebar-pin' &&
|
||||
(dragItem.kind === 'doc' || dragItem.kind === 'collection')
|
||||
) {
|
||||
return 'pin:add';
|
||||
}
|
||||
|
||||
// from sidebar pin to sidebar pin (reorder)
|
||||
if (
|
||||
dragItem.where === 'sidebar-pin' &&
|
||||
dropItem?.where === 'sidebar-pin' &&
|
||||
(dragItem.kind === 'doc' || dragItem.kind === 'collection') &&
|
||||
(dropItem.kind === 'doc' || dropItem.kind === 'collection')
|
||||
) {
|
||||
return 'pin:reorder';
|
||||
}
|
||||
|
||||
// from sidebar pin to outside (remove from favourites)
|
||||
if (
|
||||
dragItem.where === 'sidebar-pin' &&
|
||||
dropItem?.where !== 'sidebar-pin' &&
|
||||
(dragItem.kind === 'doc' || dragItem.kind === 'collection')
|
||||
) {
|
||||
return 'pin:remove';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export type GlobalDragEndIntent = ReturnType<typeof resolveDragEndIntent>;
|
||||
|
||||
export const useGlobalDNDHelper = () => {
|
||||
const t = useAFFiNEI18N();
|
||||
const currentWorkspace = useService(Workspace);
|
||||
const favAdapter = useService(FavoriteItemsAdapter);
|
||||
const workspace = currentWorkspace.docCollection;
|
||||
const { setTrashModal } = useTrashModalHelper(workspace);
|
||||
const { getDocMeta } = useDocMetaHelper(workspace);
|
||||
const collectionService = useService(CollectionService);
|
||||
const collections = useLiveData(collectionService.collections$);
|
||||
const deleteInfo = useDeleteCollectionInfo();
|
||||
|
||||
return useMemo(() => {
|
||||
return {
|
||||
handleDragEnd: (e: DragEndEvent) => {
|
||||
const intent = resolveDragEndIntent(e.active, e.over);
|
||||
|
||||
const dragItem = parseDNDId(e.active.id);
|
||||
const dropItem = parseDNDId(e.over?.id);
|
||||
|
||||
switch (intent) {
|
||||
case 'pin:remove':
|
||||
if (
|
||||
dragItem &&
|
||||
favAdapter.isFavorite(
|
||||
dragItem.itemId,
|
||||
dragItem.kind as 'doc' | 'collection'
|
||||
)
|
||||
) {
|
||||
favAdapter.remove(
|
||||
dragItem.itemId,
|
||||
dragItem.kind as 'doc' | 'collection'
|
||||
);
|
||||
toast(
|
||||
t['com.affine.cmdk.affine.editor.remove-from-favourites']()
|
||||
);
|
||||
}
|
||||
return;
|
||||
|
||||
case 'pin:reorder':
|
||||
if (dragItem && dropItem) {
|
||||
const fromId = FavoriteItemsAdapter.getFavItemKey(
|
||||
dragItem.itemId,
|
||||
dragItem.kind as 'doc' | 'collection'
|
||||
);
|
||||
const toId = FavoriteItemsAdapter.getFavItemKey(
|
||||
dropItem.itemId,
|
||||
dropItem.kind as 'doc' | 'collection'
|
||||
);
|
||||
favAdapter.sorter.move(fromId, toId);
|
||||
}
|
||||
|
||||
return;
|
||||
|
||||
case 'pin:add':
|
||||
if (
|
||||
dragItem &&
|
||||
!favAdapter.isFavorite(
|
||||
dragItem.itemId,
|
||||
dragItem.kind as 'doc' | 'collection'
|
||||
)
|
||||
) {
|
||||
favAdapter.set(
|
||||
dragItem.itemId,
|
||||
dragItem.kind as 'collection' | 'doc',
|
||||
true
|
||||
);
|
||||
toast(t['com.affine.cmdk.affine.editor.add-to-favourites']());
|
||||
}
|
||||
return;
|
||||
|
||||
case 'collection:add':
|
||||
if (dragItem && dropItem) {
|
||||
const pageId = dragItem.itemId;
|
||||
const collectionId = dropItem.itemId;
|
||||
const collection = collections.find(c => {
|
||||
return c.id === collectionId;
|
||||
});
|
||||
|
||||
if (collection?.allowList.includes(pageId)) {
|
||||
toast(t['com.affine.collection.addPage.alreadyExists']());
|
||||
} else {
|
||||
collectionService.addPageToCollection(collectionId, pageId);
|
||||
toast(t['com.affine.collection.addPage.success']());
|
||||
}
|
||||
}
|
||||
return;
|
||||
|
||||
case 'collection:remove':
|
||||
if (dragItem) {
|
||||
const pageId = dragItem.itemId;
|
||||
const collId = dragItem.parent?.itemId;
|
||||
if (collId) {
|
||||
collectionService.deletePageFromCollection(collId, pageId);
|
||||
toast(t['com.affine.collection.removePage.success']());
|
||||
}
|
||||
}
|
||||
return;
|
||||
|
||||
case 'trash:move-to':
|
||||
if (dragItem) {
|
||||
const pageId = dragItem.itemId;
|
||||
if (dragItem.kind === 'doc') {
|
||||
const pageTitle = getDocMeta(pageId)?.title ?? t['Untitled']();
|
||||
setTrashModal({
|
||||
open: true,
|
||||
pageIds: [pageId],
|
||||
pageTitles: [pageTitle],
|
||||
});
|
||||
} else {
|
||||
collectionService.deleteCollection(deleteInfo, dragItem.itemId);
|
||||
}
|
||||
}
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
},
|
||||
};
|
||||
}, [
|
||||
collectionService,
|
||||
collections,
|
||||
deleteInfo,
|
||||
favAdapter,
|
||||
getDocMeta,
|
||||
setTrashModal,
|
||||
t,
|
||||
]);
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import { toast } from '@affine/component';
|
||||
import { useDocMetaHelper } from '@affine/core/hooks/use-block-suite-page-meta';
|
||||
import { FavoriteItemsAdapter } from '@affine/core/modules/workspace';
|
||||
import { WorkspaceFlavour } from '@affine/env/workspace';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
@@ -32,7 +33,9 @@ export function useRegisterBlocksuiteEditorCommands() {
|
||||
assertExists(currentPage);
|
||||
const pageMeta = getDocMeta(pageId);
|
||||
assertExists(pageMeta);
|
||||
const favorite = pageMeta.favorite ?? false;
|
||||
|
||||
const favAdapter = useService(FavoriteItemsAdapter);
|
||||
const favorite = useLiveData(favAdapter.isFavorite$(pageId, 'doc'));
|
||||
const trash = pageMeta.trash ?? false;
|
||||
|
||||
const setPageHistoryModalState = useSetAtom(pageHistoryModalAtom);
|
||||
@@ -44,7 +47,7 @@ export function useRegisterBlocksuiteEditorCommands() {
|
||||
}));
|
||||
}, [pageId, setPageHistoryModalState]);
|
||||
|
||||
const { toggleFavorite, restoreFromTrash, duplicate } =
|
||||
const { restoreFromTrash, duplicate } =
|
||||
useBlockSuiteMetaHelper(docCollection);
|
||||
const exportHandler = useExportPage(currentPage);
|
||||
const { setTrashModal } = useTrashModalHelper(docCollection);
|
||||
@@ -94,7 +97,7 @@ export function useRegisterBlocksuiteEditorCommands() {
|
||||
? t['com.affine.favoritePageOperation.remove']()
|
||||
: t['com.affine.favoritePageOperation.add'](),
|
||||
run() {
|
||||
toggleFavorite(pageId);
|
||||
favAdapter.toggle(pageId, 'doc');
|
||||
toast(
|
||||
favorite
|
||||
? t['com.affine.cmdk.affine.editor.remove-from-favourites']()
|
||||
@@ -246,11 +249,11 @@ export function useRegisterBlocksuiteEditorCommands() {
|
||||
pageId,
|
||||
restoreFromTrash,
|
||||
t,
|
||||
toggleFavorite,
|
||||
trash,
|
||||
isCloudWorkspace,
|
||||
openHistoryModal,
|
||||
duplicate,
|
||||
page,
|
||||
favAdapter,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
import { toast } from '@affine/component';
|
||||
import type { DraggableTitleCellData } from '@affine/core/components/page-list';
|
||||
import { useDocMetaHelper } from '@affine/core/hooks/use-block-suite-page-meta';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import type { DragEndEvent, UniqueIdentifier } from '@dnd-kit/core';
|
||||
import { useService, Workspace } from '@toeverything/infra';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { useBlockSuiteMetaHelper } from './use-block-suite-meta-helper';
|
||||
import { useTrashModalHelper } from './use-trash-modal-helper';
|
||||
|
||||
// Unique droppable IDs
|
||||
export const DropPrefix = {
|
||||
SidebarCollections: 'sidebar-collections-',
|
||||
SidebarTrash: 'sidebar-trash',
|
||||
SidebarFavorites: 'sidebar-favorites',
|
||||
};
|
||||
|
||||
export const DragPrefix = {
|
||||
PageListItem: 'page-list-item-title-',
|
||||
FavouriteListItem: 'favourite-list-item-',
|
||||
CollectionListItem: 'collection-list-item-',
|
||||
CollectionListPageItem: 'collection-list-page-item-',
|
||||
};
|
||||
|
||||
export function getDropItemId(
|
||||
type: 'collections' | 'trash' | 'favorites',
|
||||
id?: string
|
||||
): string {
|
||||
let prefix = '';
|
||||
switch (type) {
|
||||
case 'collections':
|
||||
prefix = DropPrefix.SidebarCollections;
|
||||
break;
|
||||
case 'trash':
|
||||
prefix = DropPrefix.SidebarTrash;
|
||||
break;
|
||||
case 'favorites':
|
||||
prefix = DropPrefix.SidebarFavorites;
|
||||
break;
|
||||
}
|
||||
|
||||
return `${prefix}${id}`;
|
||||
}
|
||||
|
||||
export function getDragItemId(
|
||||
type: 'collection' | 'page' | 'collectionPage' | 'favouritePage',
|
||||
id: string
|
||||
): string {
|
||||
let prefix = '';
|
||||
switch (type) {
|
||||
case 'collection':
|
||||
prefix = DragPrefix.CollectionListItem;
|
||||
break;
|
||||
case 'page':
|
||||
prefix = DragPrefix.PageListItem;
|
||||
break;
|
||||
case 'collectionPage':
|
||||
prefix = DragPrefix.CollectionListPageItem;
|
||||
break;
|
||||
case 'favouritePage':
|
||||
prefix = DragPrefix.FavouriteListItem;
|
||||
break;
|
||||
}
|
||||
|
||||
return `${prefix}${id}`;
|
||||
}
|
||||
|
||||
export const useSidebarDrag = () => {
|
||||
const t = useAFFiNEI18N();
|
||||
const currentWorkspace = useService(Workspace);
|
||||
const workspace = currentWorkspace.docCollection;
|
||||
const { setTrashModal } = useTrashModalHelper(workspace);
|
||||
const { addToFavorite, removeFromFavorite } =
|
||||
useBlockSuiteMetaHelper(workspace);
|
||||
const { getDocMeta } = useDocMetaHelper(workspace);
|
||||
|
||||
const isDropArea = useCallback(
|
||||
(id: UniqueIdentifier | undefined, prefix: string) => {
|
||||
return typeof id === 'string' && id.startsWith(prefix);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const processDrag = useCallback(
|
||||
(e: DragEndEvent, dropPrefix: string, action: (pageId: string) => void) => {
|
||||
const validPrefixes = Object.values(DragPrefix);
|
||||
const isActiveIdValid = validPrefixes.some(pref =>
|
||||
String(e.active.id).startsWith(pref)
|
||||
);
|
||||
if (isDropArea(e.over?.id, dropPrefix) && isActiveIdValid) {
|
||||
const { pageId } = e.active.data.current as DraggableTitleCellData;
|
||||
action(pageId);
|
||||
}
|
||||
return;
|
||||
},
|
||||
[isDropArea]
|
||||
);
|
||||
|
||||
const processCollectionsDrag = useCallback(
|
||||
(e: DragEndEvent) =>
|
||||
processDrag(e, DropPrefix.SidebarCollections, pageId => {
|
||||
e.over?.data.current?.addToCollection?.(pageId);
|
||||
}),
|
||||
[processDrag]
|
||||
);
|
||||
|
||||
const processMoveToTrashDrag = useCallback(
|
||||
(e: DragEndEvent) => {
|
||||
const { pageId } = e.active.data.current as DraggableTitleCellData;
|
||||
const pageTitle = getDocMeta(pageId)?.title ?? t['Untitled']();
|
||||
processDrag(e, DropPrefix.SidebarTrash, pageId => {
|
||||
setTrashModal({
|
||||
open: true,
|
||||
pageIds: [pageId],
|
||||
pageTitles: [pageTitle],
|
||||
});
|
||||
});
|
||||
},
|
||||
[getDocMeta, processDrag, setTrashModal, t]
|
||||
);
|
||||
|
||||
const processFavouritesDrag = useCallback(
|
||||
(e: DragEndEvent) => {
|
||||
const { pageId } = e.active.data.current as DraggableTitleCellData;
|
||||
const isFavourited = getDocMeta(pageId)?.favorite;
|
||||
const isFavouriteDrag = String(e.over?.id).startsWith(
|
||||
DropPrefix.SidebarFavorites
|
||||
);
|
||||
if (isFavourited && isFavouriteDrag) {
|
||||
return toast(t['com.affine.collection.addPage.alreadyExists']());
|
||||
}
|
||||
processDrag(e, DropPrefix.SidebarFavorites, pageId => {
|
||||
addToFavorite(pageId);
|
||||
toast(t['com.affine.cmdk.affine.editor.add-to-favourites']());
|
||||
});
|
||||
},
|
||||
[getDocMeta, processDrag, addToFavorite, t]
|
||||
);
|
||||
|
||||
const processRemoveDrag = useCallback(
|
||||
(e: DragEndEvent) => {
|
||||
if (e.over) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (String(e.active.id).startsWith(DragPrefix.FavouriteListItem)) {
|
||||
const pageId = e.active.data.current?.pageId;
|
||||
removeFromFavorite(pageId);
|
||||
toast(t['com.affine.cmdk.affine.editor.remove-from-favourites']());
|
||||
return;
|
||||
}
|
||||
if (String(e.active.id).startsWith(DragPrefix.CollectionListPageItem)) {
|
||||
return e.active.data.current?.removeFromCollection?.();
|
||||
}
|
||||
},
|
||||
|
||||
[removeFromFavorite, t]
|
||||
);
|
||||
|
||||
return useCallback(
|
||||
(e: DragEndEvent) => {
|
||||
processCollectionsDrag(e);
|
||||
processFavouritesDrag(e);
|
||||
processMoveToTrashDrag(e);
|
||||
processRemoveDrag(e);
|
||||
},
|
||||
[
|
||||
processCollectionsDrag,
|
||||
processFavouritesDrag,
|
||||
processMoveToTrashDrag,
|
||||
processRemoveDrag,
|
||||
]
|
||||
);
|
||||
};
|
||||
19
packages/frontend/core/src/layouts/styles.css.ts
Normal file
19
packages/frontend/core/src/layouts/styles.css.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const dragOverlay = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
zIndex: 1001,
|
||||
cursor: 'grabbing',
|
||||
maxWidth: '360px',
|
||||
transition: 'transform 0.2s, opacity 0.2s',
|
||||
willChange: 'transform opacity',
|
||||
selectors: {
|
||||
'&[data-over-drop=true]': {
|
||||
transform: 'scale(0.8)',
|
||||
},
|
||||
'&[data-sorting=true]': {
|
||||
opacity: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
MouseSensor,
|
||||
pointerWithin,
|
||||
useDndContext,
|
||||
useSensor,
|
||||
useSensors,
|
||||
@@ -18,6 +17,7 @@ import {
|
||||
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
||||
import type { PropsWithChildren, ReactNode } from 'react';
|
||||
import { lazy, Suspense, useCallback, useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { matchPath } from 'react-router-dom';
|
||||
import { Map as YMap } from 'yjs';
|
||||
|
||||
@@ -30,12 +30,14 @@ import {
|
||||
} from '../components/app-sidebar';
|
||||
import { usePageHelper } from '../components/blocksuite/block-suite-page-list/utils';
|
||||
import type { DraggableTitleCellData } from '../components/page-list';
|
||||
import { PageListDragOverlay } from '../components/page-list';
|
||||
import { RootAppSidebar } from '../components/root-app-sidebar';
|
||||
import { MainContainer, WorkspaceFallback } from '../components/workspace';
|
||||
import { WorkspaceUpgrade } from '../components/workspace-upgrade';
|
||||
import { useAppSettingHelper } from '../hooks/affine/use-app-setting-helper';
|
||||
import { useSidebarDrag } from '../hooks/affine/use-sidebar-drag';
|
||||
import {
|
||||
resolveDragEndIntent,
|
||||
useGlobalDNDHelper,
|
||||
} from '../hooks/affine/use-global-dnd-helper';
|
||||
import { useNavigateHelper } from '../hooks/use-navigate-helper';
|
||||
import { useRegisterWorkspaceCommands } from '../hooks/use-register-workspace-commands';
|
||||
import { Workbench } from '../modules/workbench';
|
||||
@@ -45,6 +47,7 @@ import {
|
||||
} from '../providers/modal-provider';
|
||||
import { SWRConfigProvider } from '../providers/swr-config-provider';
|
||||
import { pathGenerator } from '../shared';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
const CMDKQuickSearchModal = lazy(() =>
|
||||
import('../components/pure/cmdk').then(module => ({
|
||||
@@ -149,20 +152,14 @@ export const WorkspaceLayoutInner = ({ children }: PropsWithChildren) => {
|
||||
})
|
||||
);
|
||||
|
||||
const handleDragEnd = useSidebarDrag();
|
||||
|
||||
const { handleDragEnd } = useGlobalDNDHelper();
|
||||
const { appSettings } = useAppSettingHelper();
|
||||
|
||||
const upgradeStatus = useWorkspaceStatus(currentWorkspace, s => s.upgrade);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* This DndContext is used for drag page from all-pages list into a folder in sidebar */}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={pointerWithin}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
|
||||
<AppContainer resizing={resizing}>
|
||||
<Suspense fallback={<AppSidebarFallback />}>
|
||||
<RootAppSidebar
|
||||
@@ -191,7 +188,7 @@ export const WorkspaceLayoutInner = ({ children }: PropsWithChildren) => {
|
||||
</Suspense>
|
||||
</MainContainer>
|
||||
</AppContainer>
|
||||
<PageListTitleCellDragOverlay />
|
||||
<GlobalDragOverlay />
|
||||
</DndContext>
|
||||
<QuickSearch />
|
||||
<SyncAwareness />
|
||||
@@ -199,26 +196,48 @@ export const WorkspaceLayoutInner = ({ children }: PropsWithChildren) => {
|
||||
);
|
||||
};
|
||||
|
||||
function PageListTitleCellDragOverlay() {
|
||||
function GlobalDragOverlay() {
|
||||
const { active, over } = useDndContext();
|
||||
const [content, setContent] = useState<ReactNode>();
|
||||
const [preview, setPreview] = useState<ReactNode>();
|
||||
|
||||
useEffect(() => {
|
||||
if (active) {
|
||||
const data = active.data.current as DraggableTitleCellData;
|
||||
setContent(data.pageTitle);
|
||||
setPreview(data.preview);
|
||||
}
|
||||
// do not update content since it may disappear because of virtual rendering
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [active?.id]);
|
||||
|
||||
const renderChildren = useCallback(() => {
|
||||
return <PageListDragOverlay over={!!over}>{content}</PageListDragOverlay>;
|
||||
}, [content, over]);
|
||||
const intent = resolveDragEndIntent(active, over);
|
||||
|
||||
return (
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{active ? renderChildren() : null}
|
||||
</DragOverlay>
|
||||
const overDropZone =
|
||||
intent === 'pin:add' ||
|
||||
intent === 'collection:add' ||
|
||||
intent === 'trash:move-to';
|
||||
|
||||
const accent =
|
||||
intent === 'pin:remove'
|
||||
? 'warning'
|
||||
: intent === 'trash:move-to'
|
||||
? 'error'
|
||||
: 'normal';
|
||||
|
||||
const sorting = intent === 'pin:reorder';
|
||||
|
||||
return createPortal(
|
||||
<DragOverlay adjustScale={false} dropAnimation={null}>
|
||||
{preview ? (
|
||||
<div
|
||||
data-over-drop={overDropZone}
|
||||
data-sorting={sorting}
|
||||
data-accent={accent}
|
||||
className={styles.dragOverlay}
|
||||
>
|
||||
{preview}
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
@@ -90,6 +90,15 @@ export class CollectionService {
|
||||
});
|
||||
}
|
||||
|
||||
deletePageFromCollection(collectionId: string, pageId: string) {
|
||||
this.updateCollection(collectionId, old => {
|
||||
return {
|
||||
...old,
|
||||
allowList: old.allowList?.filter(id => id !== pageId),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
deleteCollection(info: DeleteCollectionInfo, ...ids: string[]) {
|
||||
const collectionsYArray = this.collectionsYArray;
|
||||
if (!collectionsYArray) {
|
||||
|
||||
@@ -18,6 +18,7 @@ import { TagService } from './tag';
|
||||
import { Workbench } from './workbench';
|
||||
import {
|
||||
CurrentWorkspaceService,
|
||||
FavoriteItemsAdapter,
|
||||
WorkspaceLegacyProperties,
|
||||
WorkspacePropertiesAdapter,
|
||||
} from './workspace';
|
||||
@@ -30,6 +31,7 @@ export function configureBusinessServices(services: ServiceCollection) {
|
||||
.add(Navigator, [Workbench])
|
||||
.add(RightSidebar, [GlobalState])
|
||||
.add(WorkspacePropertiesAdapter, [Workspace])
|
||||
.add(FavoriteItemsAdapter, [WorkspacePropertiesAdapter])
|
||||
.add(CollectionService, [Workspace])
|
||||
.add(WorkspaceLegacyProperties, [Workspace])
|
||||
.add(TagService, [WorkspaceLegacyProperties, PageRecordList]);
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
// the adapter is to bridge the workspace rootdoc & native js bindings
|
||||
|
||||
import type { Y } from '@blocksuite/store';
|
||||
import { createYProxy } from '@blocksuite/store';
|
||||
import type { Workspace } from '@toeverything/infra';
|
||||
import { createFractionalIndexingSortableHelper } from '@affine/core/utils';
|
||||
import { createYProxy, type Y } from '@blocksuite/store';
|
||||
import { LiveData, type Workspace } from '@toeverything/infra';
|
||||
import { defaultsDeep } from 'lodash-es';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import type {
|
||||
WorkspaceAffineProperties,
|
||||
WorkspaceFavoriteItem,
|
||||
import {
|
||||
PagePropertyType,
|
||||
PageSystemPropertyId,
|
||||
type WorkspaceAffineProperties,
|
||||
type WorkspaceFavoriteItem,
|
||||
} from './schema';
|
||||
import { PagePropertyType, PageSystemPropertyId } from './schema';
|
||||
|
||||
const AFFINE_PROPERTIES_ID = 'affine:workspace-properties';
|
||||
|
||||
@@ -27,6 +28,7 @@ export class WorkspacePropertiesAdapter {
|
||||
// provides a easy-to-use interface for workspace properties
|
||||
public readonly proxy: WorkspaceAffineProperties;
|
||||
public readonly properties: Y.Map<any>;
|
||||
public readonly properties$: LiveData<WorkspaceAffineProperties>;
|
||||
|
||||
private ensuredRoot = false;
|
||||
private ensuredPages = {} as Record<string, boolean>;
|
||||
@@ -36,9 +38,25 @@ export class WorkspacePropertiesAdapter {
|
||||
const rootDoc = workspace.docCollection.doc;
|
||||
this.properties = rootDoc.getMap(AFFINE_PROPERTIES_ID);
|
||||
this.proxy = createYProxy(this.properties);
|
||||
|
||||
this.properties$ = LiveData.from(
|
||||
new Observable(observer => {
|
||||
const update = () => {
|
||||
requestAnimationFrame(() => {
|
||||
observer.next(new Proxy(this.proxy, {}));
|
||||
});
|
||||
};
|
||||
update();
|
||||
this.properties.observeDeep(update);
|
||||
return () => {
|
||||
this.properties.unobserveDeep(update);
|
||||
};
|
||||
}),
|
||||
this.proxy
|
||||
);
|
||||
}
|
||||
|
||||
private ensureRootProperties() {
|
||||
public ensureRootProperties() {
|
||||
if (this.ensuredRoot) {
|
||||
return;
|
||||
}
|
||||
@@ -120,10 +138,6 @@ export class WorkspacePropertiesAdapter {
|
||||
return this.pageProperties?.[pageId] ?? null;
|
||||
}
|
||||
|
||||
isFavorite(id: string, type: WorkspaceFavoriteItem['type']) {
|
||||
return this.favorites?.[id]?.type === type;
|
||||
}
|
||||
|
||||
getJournalPageDateString(id: string) {
|
||||
return this.pageProperties?.[id]?.system[PageSystemPropertyId.Journal]
|
||||
?.value;
|
||||
@@ -135,3 +149,133 @@ export class WorkspacePropertiesAdapter {
|
||||
pageProperties!.system[PageSystemPropertyId.Journal].value = date;
|
||||
}
|
||||
}
|
||||
|
||||
export class FavoriteItemsAdapter {
|
||||
constructor(private readonly adapter: WorkspacePropertiesAdapter) {
|
||||
this.migrateFavorites();
|
||||
}
|
||||
|
||||
readonly sorter = createFractionalIndexingSortableHelper<
|
||||
WorkspaceFavoriteItem,
|
||||
string
|
||||
>(this);
|
||||
|
||||
static getFavItemKey(id: string, type: WorkspaceFavoriteItem['type']) {
|
||||
return `${type}:${id}`;
|
||||
}
|
||||
|
||||
favorites$ = this.adapter.properties$.map(() =>
|
||||
this.getItems().filter(i => i.value)
|
||||
);
|
||||
|
||||
orderedFavorites$ = this.adapter.properties$.map(() => {
|
||||
const seen = new Set<string>();
|
||||
return this.sorter.getOrderedItems().filter(item => {
|
||||
const key = FavoriteItemsAdapter.getFavItemKey(item.id, item.type);
|
||||
if (seen.has(key) || !item.value) {
|
||||
return null;
|
||||
}
|
||||
seen.add(key);
|
||||
return item;
|
||||
});
|
||||
});
|
||||
|
||||
getItems() {
|
||||
return Object.entries(this.adapter.favorites ?? {})
|
||||
.filter(([k]) => k.includes(':'))
|
||||
.map(([, v]) => v);
|
||||
}
|
||||
|
||||
get favorites() {
|
||||
return this.adapter.favorites;
|
||||
}
|
||||
|
||||
get workspace() {
|
||||
return this.adapter.workspace;
|
||||
}
|
||||
|
||||
getItemId(item: WorkspaceFavoriteItem) {
|
||||
return FavoriteItemsAdapter.getFavItemKey(item.id, item.type);
|
||||
}
|
||||
|
||||
getItemOrder(item: WorkspaceFavoriteItem) {
|
||||
return item.order;
|
||||
}
|
||||
|
||||
setItemOrder(item: WorkspaceFavoriteItem, order: string) {
|
||||
item.order = order;
|
||||
}
|
||||
|
||||
// read from the workspace meta and migrate to the properties
|
||||
private migrateFavorites() {
|
||||
// only migrate if favorites is empty
|
||||
if (Object.keys(this.favorites ?? {}).length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// old favorited pages
|
||||
const oldFavorites = this.workspace.docCollection.meta.docMetas
|
||||
.filter(meta => meta.favorite)
|
||||
.map(meta => meta.id);
|
||||
|
||||
this.adapter.transact(() => {
|
||||
for (const id of oldFavorites) {
|
||||
this.set(id, 'doc', true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
isFavorite(id: string, type: WorkspaceFavoriteItem['type']) {
|
||||
const existing = this.getFavoriteItem(id, type);
|
||||
return existing?.value ?? false;
|
||||
}
|
||||
|
||||
isFavorite$(id: string, type: WorkspaceFavoriteItem['type']) {
|
||||
return this.favorites$.map(() => {
|
||||
return this.isFavorite(id, type);
|
||||
});
|
||||
}
|
||||
|
||||
private getFavoriteItem(id: string, type: WorkspaceFavoriteItem['type']) {
|
||||
return this.favorites?.[FavoriteItemsAdapter.getFavItemKey(id, type)];
|
||||
}
|
||||
|
||||
// add or set a new fav item to the list. note the id added with prefix
|
||||
set(
|
||||
id: string,
|
||||
type: WorkspaceFavoriteItem['type'],
|
||||
value: boolean,
|
||||
order?: string
|
||||
) {
|
||||
this.adapter.ensureRootProperties();
|
||||
if (!this.favorites) {
|
||||
throw new Error('Favorites is not initialized');
|
||||
}
|
||||
const existing = this.getFavoriteItem(id, type);
|
||||
if (!existing) {
|
||||
this.favorites[FavoriteItemsAdapter.getFavItemKey(id, type)] = {
|
||||
id,
|
||||
type,
|
||||
value: true,
|
||||
order: order ?? this.sorter.getNewItemOrder(),
|
||||
};
|
||||
} else {
|
||||
Object.assign(existing, {
|
||||
value,
|
||||
order: order ?? existing.order,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
toggle(id: string, type: WorkspaceFavoriteItem['type']) {
|
||||
this.set(id, type, !this.isFavorite(id, type));
|
||||
}
|
||||
|
||||
remove(id: string, type: WorkspaceFavoriteItem['type']) {
|
||||
this.adapter.ensureRootProperties();
|
||||
const existing = this.getFavoriteItem(id, type);
|
||||
if (existing) {
|
||||
existing.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,8 +64,9 @@ export type PageInfoTagsItem = z.infer<typeof PageInfoTagsItemSchema>;
|
||||
// ====== workspace properties schema ======
|
||||
export const WorkspaceFavoriteItemSchema = z.object({
|
||||
id: z.string(),
|
||||
order: z.number(),
|
||||
type: z.enum(['page', 'collection']),
|
||||
order: z.string(),
|
||||
type: z.enum(['doc', 'collection']),
|
||||
value: z.boolean(),
|
||||
});
|
||||
|
||||
export type WorkspaceFavoriteItem = z.infer<typeof WorkspaceFavoriteItemSchema>;
|
||||
|
||||
@@ -14,6 +14,12 @@ export const footerContainer = style({
|
||||
paddingLeft: cssVar('editorSidePadding'),
|
||||
paddingRight: cssVar('editorSidePadding'),
|
||||
marginBottom: '200px',
|
||||
'@media': {
|
||||
'screen and (max-width: 800px)': {
|
||||
paddingLeft: '24px',
|
||||
paddingRight: '24px',
|
||||
},
|
||||
},
|
||||
});
|
||||
export const footer = style({
|
||||
display: 'flex',
|
||||
|
||||
113
packages/frontend/core/src/utils/fractional-indexing.ts
Normal file
113
packages/frontend/core/src/utils/fractional-indexing.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { generateKeyBetween } from 'fractional-indexing';
|
||||
|
||||
export interface SortableProvider<T, K extends string | number> {
|
||||
getItems(): T[];
|
||||
getItemId(item: T): K;
|
||||
getItemOrder(item: T): string;
|
||||
setItemOrder(item: T, order: string): void;
|
||||
}
|
||||
|
||||
// Using fractional-indexing managing orders of items in a list
|
||||
export function createFractionalIndexingSortableHelper<
|
||||
T,
|
||||
K extends string | number,
|
||||
>(provider: SortableProvider<T, K>) {
|
||||
function getOrderedItems() {
|
||||
return provider.getItems().sort((a, b) => {
|
||||
const oa = provider.getItemOrder(a);
|
||||
const ob = provider.getItemOrder(b);
|
||||
return oa > ob ? 1 : oa < ob ? -1 : 0;
|
||||
});
|
||||
}
|
||||
|
||||
function getLargestOrder() {
|
||||
const lastItem = getOrderedItems().at(-1);
|
||||
return lastItem ? provider.getItemOrder(lastItem) : null;
|
||||
}
|
||||
|
||||
function getSmallestOrder() {
|
||||
const firstItem = getOrderedItems().at(0);
|
||||
return firstItem ? provider.getItemOrder(firstItem) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a new order at the end of the list
|
||||
*/
|
||||
function getNewItemOrder() {
|
||||
return generateKeyBetween(getLargestOrder(), null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move item from one position to another
|
||||
*
|
||||
* in the most common sorting case, moving over will visually place the dragging item to the target position
|
||||
* the original item in the target position will either move up or down, depending on the direction of the drag
|
||||
*
|
||||
* @param fromId
|
||||
* @param toId
|
||||
*/
|
||||
function move(fromId: K, toId: K) {
|
||||
const items = getOrderedItems();
|
||||
const from = items.findIndex(i => provider.getItemId(i) === fromId);
|
||||
const to = items.findIndex(i => provider.getItemId(i) === toId);
|
||||
const fromItem = items[from];
|
||||
const toItem = items[to];
|
||||
const toNextItem = items[from < to ? to + 1 : to - 1];
|
||||
const toOrder = toItem ? provider.getItemOrder(toItem) : null;
|
||||
const toNextOrder = toNextItem ? provider.getItemOrder(toNextItem) : null;
|
||||
const args: [string | null, string | null] =
|
||||
from < to ? [toOrder, toNextOrder] : [toNextOrder, toOrder];
|
||||
provider.setItemOrder(fromItem, generateKeyBetween(...args));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cases example:
|
||||
* Imagine we have the following items, | a | b | c |
|
||||
* 1. insertBefore('b', undefined). before is not provided, which means insert b after c
|
||||
* | a | c |
|
||||
* ▴
|
||||
* b
|
||||
* result: | a | c | b |
|
||||
*
|
||||
* 2. insertBefore('b', 'a'). insert b before a
|
||||
* | a | c |
|
||||
* ▴
|
||||
* b
|
||||
*
|
||||
* result: | b | a | c |
|
||||
*/
|
||||
function insertBefore(
|
||||
id: string | number,
|
||||
beforeId: string | number | undefined
|
||||
) {
|
||||
const items = getOrderedItems();
|
||||
// assert id is in the list
|
||||
const item = items.find(i => provider.getItemId(i) === id);
|
||||
if (!item) return;
|
||||
|
||||
const beforeItemIndex = items.findIndex(
|
||||
i => provider.getItemId(i) === beforeId
|
||||
);
|
||||
const beforeItem = beforeItemIndex !== -1 ? items[beforeItemIndex] : null;
|
||||
const beforeItemPrev = beforeItem ? items[beforeItemIndex - 1] : null;
|
||||
|
||||
const beforeOrder = beforeItem ? provider.getItemOrder(beforeItem) : null;
|
||||
const beforePrevOrder = beforeItemPrev
|
||||
? provider.getItemOrder(beforeItemPrev)
|
||||
: null;
|
||||
|
||||
provider.setItemOrder(
|
||||
item,
|
||||
generateKeyBetween(beforePrevOrder, beforeOrder)
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
getOrderedItems,
|
||||
getLargestOrder,
|
||||
getSmallestOrder,
|
||||
getNewItemOrder,
|
||||
move,
|
||||
insertBefore,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './create-emotion-cache';
|
||||
export * from './fractional-indexing';
|
||||
export * from './intl-formatter';
|
||||
export * from './mixpanel';
|
||||
export * from './string2color';
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"url": "git+https://github.com/toeverything/AFFiNE.git"
|
||||
},
|
||||
"dependencies": {
|
||||
"@magic-works/i18n-codegen": "^0.5.0",
|
||||
"i18next": "^23.10.0",
|
||||
"react-i18next": "^14.0.5",
|
||||
"undici": "^6.6.2"
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"./build-edgeless": "./build-edgeless.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"glob": "^10.3.12",
|
||||
"jszip": "^3.10.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable unicorn/prefer-dom-node-dataset */
|
||||
import { test } from '@affine-test/kit/playwright';
|
||||
import { openHomePage } from '@affine-test/kit/utils/load-page';
|
||||
import {
|
||||
@@ -13,37 +14,58 @@ import { expect } from '@playwright/test';
|
||||
const dragToFavourites = async (
|
||||
page: Page,
|
||||
dragItem: Locator,
|
||||
pageId: string
|
||||
id: string,
|
||||
type: 'page' | 'collection' = 'page'
|
||||
) => {
|
||||
const favourites = page.getByTestId('favourites');
|
||||
await dragTo(page, dragItem, favourites);
|
||||
const favouritePage = page.getByTestId(`favourite-page-${pageId}`);
|
||||
expect(favouritePage).not.toBeUndefined();
|
||||
return favouritePage;
|
||||
if (type === 'collection') {
|
||||
const collection = page
|
||||
.getByTestId(`favourites`)
|
||||
.locator(`[data-collection-id="${id}"]`);
|
||||
await expect(collection).toBeVisible();
|
||||
return collection;
|
||||
} else {
|
||||
const favouritePage = page.getByTestId(`favourite-page-${id}`);
|
||||
await expect(favouritePage).toBeVisible();
|
||||
return favouritePage;
|
||||
}
|
||||
};
|
||||
|
||||
const dragToCollection = async (page: Page, dragItem: Locator) => {
|
||||
const createCollection = async (page: Page, name: string) => {
|
||||
await page.getByTestId('slider-bar-add-collection-button').click();
|
||||
const input = page.getByTestId('input-collection-title');
|
||||
await expect(input).toBeVisible();
|
||||
await input.fill('test collection');
|
||||
await input.fill(name);
|
||||
await page.getByTestId('save-collection').click();
|
||||
const collection = page.getByTestId('collection-item');
|
||||
expect(collection).not.toBeUndefined();
|
||||
const collection = page.locator(
|
||||
`[data-testid=collection-item]:has-text("${name}")`
|
||||
);
|
||||
await expect(collection).toBeVisible();
|
||||
return collection;
|
||||
};
|
||||
|
||||
const createPage = async (page: Page, title: string) => {
|
||||
await clickNewPageButton(page);
|
||||
await getBlockSuiteEditorTitle(page).fill(title);
|
||||
};
|
||||
|
||||
const dragToCollection = async (page: Page, dragItem: Locator) => {
|
||||
const collection = await createCollection(page, 'test collection');
|
||||
await clickSideBarAllPageButton(page);
|
||||
await dragTo(page, dragItem, collection);
|
||||
await page.waitForTimeout(500);
|
||||
await collection.getByTestId('fav-collapsed-button').click();
|
||||
const collectionPage = page.getByTestId('collection-page');
|
||||
expect(collectionPage).not.toBeUndefined();
|
||||
await expect(collectionPage).toBeVisible();
|
||||
return collectionPage;
|
||||
};
|
||||
|
||||
const dragToTrash = async (page: Page, title: string, dragItem: Locator) => {
|
||||
// drag to trash
|
||||
await dragTo(page, dragItem, page.getByTestId('trash-page'));
|
||||
const confirmTip = page.getByText('Delete page?');
|
||||
expect(confirmTip).not.toBeUndefined();
|
||||
const confirmTip = page.getByText('Delete doc?');
|
||||
await expect(confirmTip).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
@@ -60,16 +82,17 @@ const dragToTrash = async (page: Page, title: string, dragItem: Locator) => {
|
||||
).toHaveCount(1);
|
||||
};
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await openHomePage(page);
|
||||
await waitForEditorLoad(page);
|
||||
});
|
||||
|
||||
test('drag a page from "All pages" list to favourites, then drag to trash', async ({
|
||||
page,
|
||||
}) => {
|
||||
const title = 'this is a new page to drag';
|
||||
{
|
||||
await openHomePage(page);
|
||||
await waitForEditorLoad(page);
|
||||
await clickNewPageButton(page);
|
||||
await getBlockSuiteEditorTitle(page).fill(title);
|
||||
}
|
||||
await waitForEditorLoad(page);
|
||||
await createPage(page, title);
|
||||
const pageId = page.url().split('/').reverse()[0];
|
||||
await clickSideBarAllPageButton(page);
|
||||
await page.waitForTimeout(500);
|
||||
@@ -87,12 +110,8 @@ test('drag a page from "All pages" list to collections, then drag to trash', asy
|
||||
page,
|
||||
}) => {
|
||||
const title = 'this is a new page to drag';
|
||||
{
|
||||
await openHomePage(page);
|
||||
await waitForEditorLoad(page);
|
||||
await clickNewPageButton(page);
|
||||
await getBlockSuiteEditorTitle(page).fill(title);
|
||||
}
|
||||
await waitForEditorLoad(page);
|
||||
await createPage(page, title);
|
||||
await clickSideBarAllPageButton(page);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
@@ -106,12 +125,8 @@ test('drag a page from "All pages" list to collections, then drag to trash', asy
|
||||
|
||||
test('drag a page from "All pages" list to trash', async ({ page }) => {
|
||||
const title = 'this is a new page to drag';
|
||||
{
|
||||
await openHomePage(page);
|
||||
await waitForEditorLoad(page);
|
||||
await clickNewPageButton(page);
|
||||
await getBlockSuiteEditorTitle(page).fill(title);
|
||||
}
|
||||
await createPage(page, title);
|
||||
|
||||
await clickSideBarAllPageButton(page);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
@@ -124,12 +139,8 @@ test('drag a page from "All pages" list to trash', async ({ page }) => {
|
||||
|
||||
test('drag a page from favourites to collection', async ({ page }) => {
|
||||
const title = 'this is a new page to drag';
|
||||
{
|
||||
await openHomePage(page);
|
||||
await waitForEditorLoad(page);
|
||||
await clickNewPageButton(page);
|
||||
await getBlockSuiteEditorTitle(page).fill(title);
|
||||
}
|
||||
await createPage(page, title);
|
||||
|
||||
const pageId = page.url().split('/').reverse()[0];
|
||||
await clickSideBarAllPageButton(page);
|
||||
await page.waitForTimeout(500);
|
||||
@@ -144,3 +155,68 @@ test('drag a page from favourites to collection', async ({ page }) => {
|
||||
// drag to collections
|
||||
await dragToCollection(page, favouritePage);
|
||||
});
|
||||
|
||||
test('drag a collection to favourites', async ({ page }) => {
|
||||
await clickSideBarAllPageButton(page);
|
||||
await page.waitForTimeout(500);
|
||||
const collection = await createCollection(page, 'test collection');
|
||||
const collectionId = (await collection.getAttribute(
|
||||
'data-collection-id'
|
||||
)) as string;
|
||||
await dragToFavourites(page, collection, collectionId, 'collection');
|
||||
});
|
||||
|
||||
test('items in favourites can be reordered by dragging', async ({ page }) => {
|
||||
const title0 = 'this is a new page to drag';
|
||||
await createPage(page, title0);
|
||||
await page.getByTestId('pin-button').click();
|
||||
|
||||
const title1 = 'this is another new page to drag';
|
||||
await createPage(page, title1);
|
||||
await page.getByTestId('pin-button').click();
|
||||
|
||||
{
|
||||
const collection = await createCollection(page, 'test collection');
|
||||
const collectionId = (await collection.getAttribute(
|
||||
'data-collection-id'
|
||||
)) as string;
|
||||
await dragToFavourites(page, collection, collectionId, 'collection');
|
||||
}
|
||||
|
||||
// assert the order of the items in favourites
|
||||
await expect(
|
||||
page.getByTestId('favourites').locator('[data-draggable]')
|
||||
).toHaveCount(3);
|
||||
|
||||
await expect(
|
||||
page.getByTestId('favourites').locator('[data-draggable]').first()
|
||||
).toHaveText(title0);
|
||||
|
||||
await expect(
|
||||
page.getByTestId('favourites').locator('[data-draggable]').last()
|
||||
).toHaveText('test collection');
|
||||
|
||||
// drag the first item to the last
|
||||
const firstItem = page
|
||||
.getByTestId('favourites')
|
||||
.locator('[data-draggable]')
|
||||
.first();
|
||||
const lastItem = page
|
||||
.getByTestId('favourites')
|
||||
.locator('[data-draggable]')
|
||||
.last();
|
||||
await dragTo(page, firstItem, lastItem);
|
||||
|
||||
// now check the order again
|
||||
await expect(
|
||||
page.getByTestId('favourites').locator('[data-draggable]')
|
||||
).toHaveCount(3);
|
||||
|
||||
await expect(
|
||||
page.getByTestId('favourites').locator('[data-draggable]').first()
|
||||
).toHaveText(title1);
|
||||
|
||||
await expect(
|
||||
page.getByTestId('favourites').locator('[data-draggable]').last()
|
||||
).toHaveText(title0);
|
||||
});
|
||||
|
||||
@@ -144,8 +144,8 @@ test('Add new favorite page via sidebar', async ({ page }) => {
|
||||
// enter random page title
|
||||
await getBlockSuiteEditorTitle(page).fill('this is a new fav page');
|
||||
// check if the page title is shown in the favorite list
|
||||
const favItem = page.locator(
|
||||
'[data-type=favourite-list-item] >> text=this is a new fav page'
|
||||
);
|
||||
const favItem = page
|
||||
.getByTestId('favourites')
|
||||
.locator('[data-draggable] >> text=this is a new fav page');
|
||||
await expect(favItem).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -43,11 +43,12 @@ export const AffineOperationCell: StoryFn<PageOperationCellProps> = ({
|
||||
}) => <PageOperationCell {...props} />;
|
||||
|
||||
AffineOperationCell.args = {
|
||||
favorite: false,
|
||||
isPublic: true,
|
||||
onToggleFavoritePage: () => toast('Toggle favorite page'),
|
||||
onDisablePublicSharing: () => toast('Disable public sharing'),
|
||||
onRemoveToTrash: () => toast('Remove to trash'),
|
||||
page: {
|
||||
id: '123',
|
||||
title: 'Test Page Title',
|
||||
tags: ['tag1', 'tag2'],
|
||||
createDate: new Date('2021-01-01').getTime(),
|
||||
},
|
||||
};
|
||||
AffineOperationCell.parameters = {
|
||||
reactRouter: reactRouterParameters({
|
||||
|
||||
3
tools/@types/env/__all.d.ts
vendored
3
tools/@types/env/__all.d.ts
vendored
@@ -20,6 +20,9 @@ declare global {
|
||||
|
||||
declare module '@blocksuite/store' {
|
||||
interface DocMeta {
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
favorite?: boolean;
|
||||
// If a page remove to trash, and it is a subpage, it will remove from its parent `subpageIds`, 'trashRelate' is use for save it parent
|
||||
trashRelate?: string;
|
||||
|
||||
39
yarn.lock
39
yarn.lock
@@ -337,6 +337,7 @@ __metadata:
|
||||
"@dnd-kit/core": "npm:^6.1.0"
|
||||
"@dnd-kit/modifiers": "npm:^7.0.0"
|
||||
"@dnd-kit/sortable": "npm:^8.0.0"
|
||||
"@dnd-kit/utilities": "npm:^3.2.2"
|
||||
"@emotion/cache": "npm:^11.11.0"
|
||||
"@emotion/react": "npm:^11.11.3"
|
||||
"@emotion/server": "npm:^11.11.0"
|
||||
@@ -548,6 +549,7 @@ __metadata:
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@affine/i18n@workspace:packages/frontend/i18n"
|
||||
dependencies:
|
||||
"@magic-works/i18n-codegen": "npm:^0.5.0"
|
||||
"@types/prettier": "npm:^3.0.0"
|
||||
i18next: "npm:^23.10.0"
|
||||
prettier: "npm:^3.2.5"
|
||||
@@ -816,6 +818,7 @@ __metadata:
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@affine/templates@workspace:packages/frontend/templates"
|
||||
dependencies:
|
||||
glob: "npm:^10.3.12"
|
||||
jszip: "npm:^3.10.1"
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
@@ -22626,18 +22629,18 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"glob@npm:^10.0.0, glob@npm:^10.2.2, glob@npm:^10.3.10, glob@npm:^10.3.7":
|
||||
version: 10.3.10
|
||||
resolution: "glob@npm:10.3.10"
|
||||
"glob@npm:^10.0.0, glob@npm:^10.2.2, glob@npm:^10.3.10, glob@npm:^10.3.12, glob@npm:^10.3.7":
|
||||
version: 10.3.12
|
||||
resolution: "glob@npm:10.3.12"
|
||||
dependencies:
|
||||
foreground-child: "npm:^3.1.0"
|
||||
jackspeak: "npm:^2.3.5"
|
||||
jackspeak: "npm:^2.3.6"
|
||||
minimatch: "npm:^9.0.1"
|
||||
minipass: "npm:^5.0.0 || ^6.0.2 || ^7.0.0"
|
||||
path-scurry: "npm:^1.10.1"
|
||||
minipass: "npm:^7.0.4"
|
||||
path-scurry: "npm:^1.10.2"
|
||||
bin:
|
||||
glob: dist/esm/bin.mjs
|
||||
checksum: 10/38bdb2c9ce75eb5ed168f309d4ed05b0798f640b637034800a6bf306f39d35409bf278b0eaaffaec07591085d3acb7184a201eae791468f0f617771c2486a6a8
|
||||
checksum: 10/9e8186abc22dc824b5dd86cefd8e6b5621a72d1be7f68bacc0fd681e8c162ec5546660a6ec0553d6a74757a585e655956c7f8f1a6d24570e8d865c307323d178
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -24728,7 +24731,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"jackspeak@npm:^2.3.5":
|
||||
"jackspeak@npm:^2.3.6":
|
||||
version: 2.3.6
|
||||
resolution: "jackspeak@npm:2.3.6"
|
||||
dependencies:
|
||||
@@ -26421,10 +26424,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lru-cache@npm:^10.0.1, lru-cache@npm:^9.1.1 || ^10.0.0":
|
||||
version: 10.1.0
|
||||
resolution: "lru-cache@npm:10.1.0"
|
||||
checksum: 10/207278d6fa711fb1f94a0835d4d4737441d2475302482a14785b10515e4c906a57ebf9f35bf060740c9560e91c7c1ad5a04fd7ed030972a9ba18bce2a228e95b
|
||||
"lru-cache@npm:^10.0.1, lru-cache@npm:^10.2.0":
|
||||
version: 10.2.0
|
||||
resolution: "lru-cache@npm:10.2.0"
|
||||
checksum: 10/502ec42c3309c0eae1ce41afca471f831c278566d45a5273a0c51102dee31e0e250a62fa9029c3370988df33a14188a38e682c16143b794de78668de3643e302
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -28025,7 +28028,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"minipass@npm:^5.0.0 || ^6.0.2 || ^7.0.0, minipass@npm:^7.0.2, minipass@npm:^7.0.3":
|
||||
"minipass@npm:^5.0.0 || ^6.0.2 || ^7.0.0, minipass@npm:^7.0.2, minipass@npm:^7.0.3, minipass@npm:^7.0.4":
|
||||
version: 7.0.4
|
||||
resolution: "minipass@npm:7.0.4"
|
||||
checksum: 10/e864bd02ceb5e0707696d58f7ce3a0b89233f0d686ef0d447a66db705c0846a8dc6f34865cd85256c1472ff623665f616b90b8ff58058b2ad996c5de747d2d18
|
||||
@@ -29606,13 +29609,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"path-scurry@npm:^1.10.1, path-scurry@npm:^1.6.1":
|
||||
version: 1.10.1
|
||||
resolution: "path-scurry@npm:1.10.1"
|
||||
"path-scurry@npm:^1.10.2, path-scurry@npm:^1.6.1":
|
||||
version: 1.10.2
|
||||
resolution: "path-scurry@npm:1.10.2"
|
||||
dependencies:
|
||||
lru-cache: "npm:^9.1.1 || ^10.0.0"
|
||||
lru-cache: "npm:^10.2.0"
|
||||
minipass: "npm:^5.0.0 || ^6.0.2 || ^7.0.0"
|
||||
checksum: 10/eebfb8304fef1d4f7e1486df987e4fd77413de4fce16508dea69fcf8eb318c09a6b15a7a2f4c22877cec1cb7ecbd3071d18ca9de79eeece0df874a00f1f0bdc8
|
||||
checksum: 10/a2bbbe8dc284c49dd9be78ca25f3a8b89300e0acc24a77e6c74824d353ef50efbf163e64a69f4330b301afca42d0e2229be0560d6d616ac4e99d48b4062016b1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user