mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-09 21:25:45 +08:00
feat(core): init organize (#7456)
This commit is contained in:
Vendored
+1
@@ -30,6 +30,7 @@ export const runtimeFlagsSchema = z.object({
|
|||||||
enableEnhanceShareMode: z.boolean(),
|
enableEnhanceShareMode: z.boolean(),
|
||||||
enableExperimentalFeature: z.boolean(),
|
enableExperimentalFeature: z.boolean(),
|
||||||
enableInfoModal: z.boolean(),
|
enableInfoModal: z.boolean(),
|
||||||
|
enableOrganize: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type RuntimeConfig = z.infer<typeof runtimeFlagsSchema>;
|
export type RuntimeConfig = z.infer<typeof runtimeFlagsSchema>;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export * from './blocksuite';
|
|||||||
export * from './framework';
|
export * from './framework';
|
||||||
export * from './initialization';
|
export * from './initialization';
|
||||||
export * from './livedata';
|
export * from './livedata';
|
||||||
|
export * from './modules/db';
|
||||||
export * from './modules/doc';
|
export * from './modules/doc';
|
||||||
export * from './modules/global-context';
|
export * from './modules/global-context';
|
||||||
export * from './modules/lifecycle';
|
export * from './modules/lifecycle';
|
||||||
@@ -14,6 +15,7 @@ export * from './sync';
|
|||||||
export * from './utils';
|
export * from './utils';
|
||||||
|
|
||||||
import type { Framework } from './framework';
|
import type { Framework } from './framework';
|
||||||
|
import { configureDBModule } from './modules/db';
|
||||||
import { configureDocModule } from './modules/doc';
|
import { configureDocModule } from './modules/doc';
|
||||||
import { configureGlobalContextModule } from './modules/global-context';
|
import { configureGlobalContextModule } from './modules/global-context';
|
||||||
import { configureLifecycleModule } from './modules/lifecycle';
|
import { configureLifecycleModule } from './modules/lifecycle';
|
||||||
@@ -29,6 +31,7 @@ import {
|
|||||||
export function configureInfraModules(framework: Framework) {
|
export function configureInfraModules(framework: Framework) {
|
||||||
configureWorkspaceModule(framework);
|
configureWorkspaceModule(framework);
|
||||||
configureDocModule(framework);
|
configureDocModule(framework);
|
||||||
|
configureDBModule(framework);
|
||||||
configureGlobalStorageModule(framework);
|
configureGlobalStorageModule(framework);
|
||||||
configureGlobalContextModule(framework);
|
configureGlobalContextModule(framework);
|
||||||
configureLifecycleModule(framework);
|
configureLifecycleModule(framework);
|
||||||
|
|||||||
@@ -449,6 +449,10 @@ export class LiveData<T = unknown>
|
|||||||
) as any;
|
) as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static flat<T>(v: T): Flat<LiveData<T>> {
|
||||||
|
return new LiveData(v).flat();
|
||||||
|
}
|
||||||
|
|
||||||
waitFor(predicate: (v: T) => unknown, signal?: AbortSignal): Promise<T> {
|
waitFor(predicate: (v: T) => unknown, signal?: AbortSignal): Promise<T> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const subscription = this.subscribe(v => {
|
const subscription = this.subscribe(v => {
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { Framework } from '../../framework';
|
||||||
|
import { WorkspaceScope, WorkspaceService } from '../workspace';
|
||||||
|
import { DBService } from './services/db';
|
||||||
|
|
||||||
|
export { AFFiNE_DB_SCHEMA } from './schema';
|
||||||
|
export { DBService } from './services/db';
|
||||||
|
|
||||||
|
export function configureDBModule(framework: Framework) {
|
||||||
|
framework.scope(WorkspaceScope).service(DBService, [WorkspaceService]);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { AFFiNE_DB_SCHEMA } from './schema';
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
|
||||||
|
import { type DBSchemaBuilder, f } from '../../../orm';
|
||||||
|
|
||||||
|
export const AFFiNE_DB_SCHEMA = {
|
||||||
|
folders: {
|
||||||
|
id: f.string().primaryKey().optional().default(nanoid),
|
||||||
|
parentId: f.string().optional(),
|
||||||
|
data: f.string(),
|
||||||
|
type: f.string(),
|
||||||
|
index: f.string(),
|
||||||
|
},
|
||||||
|
} as const satisfies DBSchemaBuilder;
|
||||||
|
export type AFFiNE_DB_SCHEMA = typeof AFFiNE_DB_SCHEMA;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Doc as YDoc } from 'yjs';
|
||||||
|
|
||||||
|
import { Service } from '../../../framework';
|
||||||
|
import { createORMClient, type TableMap, YjsDBAdapter } from '../../../orm';
|
||||||
|
import type { WorkspaceService } from '../../workspace';
|
||||||
|
import { AFFiNE_DB_SCHEMA } from '../schema';
|
||||||
|
|
||||||
|
export class DBService extends Service {
|
||||||
|
db: TableMap<AFFiNE_DB_SCHEMA>;
|
||||||
|
|
||||||
|
constructor(private readonly workspaceService: WorkspaceService) {
|
||||||
|
super();
|
||||||
|
const Client = createORMClient(AFFiNE_DB_SCHEMA);
|
||||||
|
this.db = new Client(
|
||||||
|
new YjsDBAdapter(AFFiNE_DB_SCHEMA, {
|
||||||
|
getDoc: guid => {
|
||||||
|
const ydoc = new YDoc({
|
||||||
|
// guid format: db${workspaceId}${guid}
|
||||||
|
guid: `db$${this.workspaceService.workspace.id}$${guid}`,
|
||||||
|
});
|
||||||
|
this.workspaceService.workspace.engine.doc.addDoc(ydoc, false);
|
||||||
|
this.workspaceService.workspace.engine.doc.setPriority(ydoc.guid, 50);
|
||||||
|
return ydoc;
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static isDBDocId(docId: string) {
|
||||||
|
return docId.startsWith('db$');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
import { Entity } from '../../../framework';
|
import { Entity } from '../../../framework';
|
||||||
import type { DocScope } from '../scopes/doc';
|
import type { DocScope } from '../scopes/doc';
|
||||||
|
import type { DocsStore } from '../stores/docs';
|
||||||
import type { DocMode } from './record';
|
import type { DocMode } from './record';
|
||||||
|
|
||||||
export class Doc extends Entity {
|
export class Doc extends Entity {
|
||||||
constructor(public readonly scope: DocScope) {
|
constructor(
|
||||||
|
public readonly scope: DocScope,
|
||||||
|
private readonly store: DocsStore
|
||||||
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,4 +46,12 @@ export class Doc extends Entity {
|
|||||||
restoreFromTrash() {
|
restoreFromTrash() {
|
||||||
return this.record.restoreFromTrash();
|
return this.record.restoreFromTrash();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
waitForSyncReady() {
|
||||||
|
return this.store.waitForDocLoadReady(this.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
setPriorityLoad(priority: number) {
|
||||||
|
return this.store.setPriorityLoad(this.id, priority);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,6 @@ export function configureDocModule(framework: Framework) {
|
|||||||
.entity(DocRecord, [DocsStore])
|
.entity(DocRecord, [DocsStore])
|
||||||
.entity(DocRecordList, [DocsStore])
|
.entity(DocRecordList, [DocsStore])
|
||||||
.scope(DocScope)
|
.scope(DocScope)
|
||||||
.entity(Doc, [DocScope])
|
.entity(Doc, [DocScope, DocsStore])
|
||||||
.service(DocService);
|
.service(DocService);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Unreachable } from '@affine/env/constant';
|
import { Unreachable } from '@affine/env/constant';
|
||||||
|
import type { RootBlockModel } from '@blocksuite/blocks';
|
||||||
|
|
||||||
import { Service } from '../../../framework';
|
import { Service } from '../../../framework';
|
||||||
import { initEmptyPage } from '../../../initialization';
|
import { initEmptyPage } from '../../../initialization';
|
||||||
@@ -59,6 +60,7 @@ export class DocsService extends Service {
|
|||||||
) {
|
) {
|
||||||
const doc = this.store.createBlockSuiteDoc();
|
const doc = this.store.createBlockSuiteDoc();
|
||||||
initEmptyPage(doc, options.title);
|
initEmptyPage(doc, options.title);
|
||||||
|
this.store.markDocSyncStateAsReady(doc.id);
|
||||||
const docRecord = this.list.doc$(doc.id).value;
|
const docRecord = this.list.doc$(doc.id).value;
|
||||||
if (!docRecord) {
|
if (!docRecord) {
|
||||||
throw new Unreachable();
|
throw new Unreachable();
|
||||||
@@ -68,4 +70,45 @@ export class DocsService extends Service {
|
|||||||
}
|
}
|
||||||
return docRecord;
|
return docRecord;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async addLinkedDoc(targetDocId: string, linkedDocId: string) {
|
||||||
|
const { doc, release } = this.open(targetDocId);
|
||||||
|
doc.setPriorityLoad(10);
|
||||||
|
await doc.waitForSyncReady();
|
||||||
|
const text = doc.blockSuiteDoc.Text.fromDelta([
|
||||||
|
{
|
||||||
|
insert: ' ',
|
||||||
|
attributes: {
|
||||||
|
reference: {
|
||||||
|
type: 'LinkedPage',
|
||||||
|
pageId: linkedDocId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const [frame] = doc.blockSuiteDoc.getBlocksByFlavour('affine:note');
|
||||||
|
frame &&
|
||||||
|
doc.blockSuiteDoc.addBlock(
|
||||||
|
'affine:paragraph' as never, // TODO(eyhn): fix type
|
||||||
|
{ text },
|
||||||
|
frame.id
|
||||||
|
);
|
||||||
|
release();
|
||||||
|
}
|
||||||
|
|
||||||
|
async changeDocTitle(docId: string, newTitle: string) {
|
||||||
|
const { doc, release } = this.open(docId);
|
||||||
|
doc.setPriorityLoad(10);
|
||||||
|
await doc.waitForSyncReady();
|
||||||
|
const pageBlock = doc.blockSuiteDoc.getBlocksByFlavour('affine:page').at(0)
|
||||||
|
?.model as RootBlockModel | undefined;
|
||||||
|
if (pageBlock) {
|
||||||
|
doc.blockSuiteDoc.transact(() => {
|
||||||
|
pageBlock.title.delete(0, pageBlock.title.length);
|
||||||
|
pageBlock.title.insert(newTitle, 0);
|
||||||
|
});
|
||||||
|
doc.record.setMeta({ title: newTitle });
|
||||||
|
}
|
||||||
|
release();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,4 +112,16 @@ export class DocsStore extends Store {
|
|||||||
watchDocModeSetting(id: string) {
|
watchDocModeSetting(id: string) {
|
||||||
return this.localState.watch<DocMode>(`page:${id}:mode`);
|
return this.localState.watch<DocMode>(`page:${id}:mode`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
waitForDocLoadReady(id: string) {
|
||||||
|
return this.workspaceService.workspace.engine.doc.waitForReady(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
setPriorityLoad(id: string, priority: number) {
|
||||||
|
return this.workspaceService.workspace.engine.doc.setPriority(id, priority);
|
||||||
|
}
|
||||||
|
|
||||||
|
markDocSyncStateAsReady(id: string) {
|
||||||
|
this.workspaceService.workspace.engine.doc.markAsReady(id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,19 @@ export class GlobalContext extends Entity {
|
|||||||
|
|
||||||
workspaceId = this.define<string>('workspaceId');
|
workspaceId = this.define<string>('workspaceId');
|
||||||
|
|
||||||
|
isDoc = this.define<boolean>('isDoc');
|
||||||
docId = this.define<string>('docId');
|
docId = this.define<string>('docId');
|
||||||
|
|
||||||
|
isCollection = this.define<boolean>('isCollection');
|
||||||
|
collectionId = this.define<string>('collectionId');
|
||||||
|
|
||||||
|
isTrash = this.define<boolean>('isTrash');
|
||||||
|
|
||||||
docMode = this.define<DocMode>('docMode');
|
docMode = this.define<DocMode>('docMode');
|
||||||
|
|
||||||
|
isTag = this.define<boolean>('isTag');
|
||||||
|
tagId = this.define<string>('tagId');
|
||||||
|
|
||||||
define<T>(key: string) {
|
define<T>(key: string) {
|
||||||
this.memento.set(key, null);
|
this.memento.set(key, null);
|
||||||
const livedata$ = LiveData.from(this.memento.watch<T>(key), null);
|
const livedata$ = LiveData.from(this.memento.watch<T>(key), null);
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ export class Table<T extends TableSchemaBuilder> {
|
|||||||
return record || null;
|
return record || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
get$(key: PrimaryKeyFieldType<T>): Observable<Entity<T>> {
|
get$(key: PrimaryKeyFieldType<T>): Observable<Entity<T> | null> {
|
||||||
let ob$ = this.subscribedKeys.get(key);
|
let ob$ = this.subscribedKeys.get(key);
|
||||||
|
|
||||||
if (!ob$) {
|
if (!ob$) {
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './core';
|
||||||
@@ -77,6 +77,10 @@ export class DocEngine {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
markAsReady(docId: string) {
|
||||||
|
this.localPart.actions.markAsReady(docId);
|
||||||
|
}
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
storage: DocStorage,
|
storage: DocStorage,
|
||||||
private readonly server?: DocServer | null
|
private readonly server?: DocServer | null
|
||||||
|
|||||||
@@ -163,6 +163,10 @@ export class DocEngineLocalPart {
|
|||||||
this.status.docs.set(doc.guid, doc);
|
this.status.docs.set(doc.guid, doc);
|
||||||
this.statusUpdatedSubject$.next(doc.guid);
|
this.statusUpdatedSubject$.next(doc.guid);
|
||||||
},
|
},
|
||||||
|
markAsReady: (docId: string) => {
|
||||||
|
this.status.readyDocs.add(docId);
|
||||||
|
this.statusUpdatedSubject$.next(docId);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
readonly jobs = {
|
readonly jobs = {
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export const RenameModal = ({
|
|||||||
items={
|
items={
|
||||||
<Input
|
<Input
|
||||||
autoFocus
|
autoFocus
|
||||||
|
autoSelect
|
||||||
value={value}
|
value={value}
|
||||||
onChange={setValue}
|
onChange={setValue}
|
||||||
onEnter={handleRename}
|
onEnter={handleRename}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export * from './ui/button';
|
|||||||
export * from './ui/checkbox';
|
export * from './ui/checkbox';
|
||||||
export * from './ui/date-picker';
|
export * from './ui/date-picker';
|
||||||
export * from './ui/divider';
|
export * from './ui/divider';
|
||||||
|
export * from './ui/dnd';
|
||||||
export * from './ui/editable';
|
export * from './ui/editable';
|
||||||
export * from './ui/empty';
|
export * from './ui/empty';
|
||||||
export * from './ui/error-message';
|
export * from './ui/error-message';
|
||||||
@@ -13,6 +14,7 @@ export * from './ui/layout';
|
|||||||
export * from './ui/loading';
|
export * from './ui/loading';
|
||||||
export * from './ui/lottie/collections-icon';
|
export * from './ui/lottie/collections-icon';
|
||||||
export * from './ui/lottie/delete-icon';
|
export * from './ui/lottie/delete-icon';
|
||||||
|
export * from './ui/lottie/folder-icon';
|
||||||
export * from './ui/menu';
|
export * from './ui/menu';
|
||||||
export * from './ui/modal';
|
export * from './ui/modal';
|
||||||
export * from './ui/notification';
|
export * from './ui/notification';
|
||||||
|
|||||||
@@ -174,7 +174,10 @@ NestedDropTarget.args = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const DynamicDragPreview = () => {
|
export const DynamicDragPreview = () => {
|
||||||
type DataType = DNDData<Record<string, never>, { type: 'big' | 'small' }>;
|
type DataType = DNDData<
|
||||||
|
Record<string, never>,
|
||||||
|
{ type: 'big' | 'small' | 'tips' }
|
||||||
|
>;
|
||||||
const { dragRef, dragging, draggingPosition, dropTarget, CustomDragPreview } =
|
const { dragRef, dragging, draggingPosition, dropTarget, CustomDragPreview } =
|
||||||
useDraggable<DataType>(() => ({}), []);
|
useDraggable<DataType>(() => ({}), []);
|
||||||
const { dropTargetRef: bigDropTargetRef } = useDropTarget<DataType>(
|
const { dropTargetRef: bigDropTargetRef } = useDropTarget<DataType>(
|
||||||
@@ -189,6 +192,16 @@ export const DynamicDragPreview = () => {
|
|||||||
}),
|
}),
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
const {
|
||||||
|
dropTargetRef: tipsDropTargetRef,
|
||||||
|
draggedOver: tipsDraggedOver,
|
||||||
|
draggedOverPosition: tipsDraggedOverPosition,
|
||||||
|
} = useDropTarget<DataType>(
|
||||||
|
() => ({
|
||||||
|
data: { type: 'tips' },
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -240,6 +253,30 @@ export const DynamicDragPreview = () => {
|
|||||||
>
|
>
|
||||||
Small
|
Small
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
ref={tipsDropTargetRef}
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
width: '100%',
|
||||||
|
border: '1px solid green',
|
||||||
|
height: '100px',
|
||||||
|
fontSize: '50px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Tips
|
||||||
|
{tipsDraggedOver && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
transform: `translate(${tipsDraggedOverPosition.relativeX}px, ${tipsDraggedOverPosition.relativeY}px)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
tips
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<CustomDragPreview position="pointer-outside">
|
<CustomDragPreview position="pointer-outside">
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -43,10 +43,16 @@ export interface DraggableOptions<D extends DNDData = DNDData> {
|
|||||||
}>;
|
}>;
|
||||||
canDrag?: DraggableGet<boolean>;
|
canDrag?: DraggableGet<boolean>;
|
||||||
disableDragPreview?: boolean;
|
disableDragPreview?: boolean;
|
||||||
|
dragPreviewPosition?: DraggableDragPreviewPosition;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DraggableDragPreviewPosition =
|
||||||
|
| 'pointer-outside'
|
||||||
|
| 'pointer-center'
|
||||||
|
| 'native';
|
||||||
|
|
||||||
export type DraggableCustomDragPreviewProps = React.PropsWithChildren<{
|
export type DraggableCustomDragPreviewProps = React.PropsWithChildren<{
|
||||||
position?: 'pointer-outside' | 'pointer-center' | 'native';
|
position?: DraggableDragPreviewPosition;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export const useDraggable = <D extends DNDData = DNDData>(
|
export const useDraggable = <D extends DNDData = DNDData>(
|
||||||
@@ -172,9 +178,11 @@ export const useDraggable = <D extends DNDData = DNDData>(
|
|||||||
disableNativeDragPreview({ nativeSetDragImage });
|
disableNativeDragPreview({ nativeSetDragImage });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let previewPosition: DraggableDragPreviewPosition =
|
||||||
|
options.dragPreviewPosition ?? 'native';
|
||||||
|
|
||||||
if (enableCustomDragPreview.current) {
|
if (enableCustomDragPreview.current) {
|
||||||
let previewPosition: DraggableCustomDragPreviewProps['position'] =
|
|
||||||
'native';
|
|
||||||
setCustomNativeDragPreview({
|
setCustomNativeDragPreview({
|
||||||
getOffset: (...args) => {
|
getOffset: (...args) => {
|
||||||
if (previewPosition === 'pointer-center') {
|
if (previewPosition === 'pointer-center') {
|
||||||
@@ -199,7 +207,7 @@ export const useDraggable = <D extends DNDData = DNDData>(
|
|||||||
children,
|
children,
|
||||||
position,
|
position,
|
||||||
}: DraggableCustomDragPreviewProps) => {
|
}: DraggableCustomDragPreviewProps) => {
|
||||||
previewPosition = position;
|
previewPosition = position || previewPosition;
|
||||||
return ReactDOM.createPortal(children, container);
|
return ReactDOM.createPortal(children, container);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -208,6 +216,28 @@ export const useDraggable = <D extends DNDData = DNDData>(
|
|||||||
},
|
},
|
||||||
nativeSetDragImage,
|
nativeSetDragImage,
|
||||||
});
|
});
|
||||||
|
} else if (previewPosition !== 'native') {
|
||||||
|
setCustomNativeDragPreview({
|
||||||
|
getOffset: (...args) => {
|
||||||
|
if (previewPosition === 'pointer-center') {
|
||||||
|
return centerUnderPointer(...args);
|
||||||
|
} else if (previewPosition === 'pointer-outside') {
|
||||||
|
return pointerOutsideOfPreview({
|
||||||
|
x: '8px',
|
||||||
|
y: '4px',
|
||||||
|
})(...args);
|
||||||
|
} else {
|
||||||
|
return preserveOffsetOnSource({
|
||||||
|
element: source.element,
|
||||||
|
input: location.current.input,
|
||||||
|
})(...args);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
render({ container }) {
|
||||||
|
container.append(source.element.cloneNode(true));
|
||||||
|
},
|
||||||
|
nativeSetDragImage,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export const horizontal = style({
|
|||||||
right: 0,
|
right: 0,
|
||||||
'::before': {
|
'::before': {
|
||||||
// Horizontal indicators have the terminal on the left
|
// Horizontal indicators have the terminal on the left
|
||||||
left: `calc(-${terminalSize})`,
|
left: `calc(-1 * ${terminalSize})`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
/** @jsx jsx */
|
|
||||||
|
|
||||||
import type { Edge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge';
|
import type { Edge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge';
|
||||||
import type { Instruction } from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item';
|
import type { Instruction } from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item';
|
||||||
import { cssVar } from '@toeverything/theme';
|
import { cssVar } from '@toeverything/theme';
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ type DropTargetGetFeedback<D extends DNDData> = Parameters<
|
|||||||
source: {
|
source: {
|
||||||
data: D['draggable'];
|
data: D['draggable'];
|
||||||
};
|
};
|
||||||
|
} & {
|
||||||
|
treeInstruction: Instruction | null;
|
||||||
|
closestEdge: Edge | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DropTargetGet<T, D extends DNDData> =
|
type DropTargetGet<T, D extends DNDData> =
|
||||||
@@ -27,17 +30,60 @@ type DropTargetGet<T, D extends DNDData> =
|
|||||||
| ((data: DropTargetGetFeedback<D>) => T);
|
| ((data: DropTargetGetFeedback<D>) => T);
|
||||||
|
|
||||||
function dropTargetGet<T, D extends DNDData>(
|
function dropTargetGet<T, D extends DNDData>(
|
||||||
get: T
|
get: T,
|
||||||
|
options: DropTargetOptions<D>
|
||||||
): T extends undefined
|
): T extends undefined
|
||||||
? undefined
|
? undefined
|
||||||
: T extends DropTargetGet<infer I, D>
|
: T extends DropTargetGet<infer I, D>
|
||||||
? (args: DropTargetGetFeedback<D>) => I
|
? (
|
||||||
|
args: Omit<DropTargetGetFeedback<D>, 'treeInstruction' | 'closestEdge'>
|
||||||
|
) => I
|
||||||
: never {
|
: never {
|
||||||
if (get === undefined) {
|
if (get === undefined) {
|
||||||
return undefined as any;
|
return undefined as any;
|
||||||
}
|
}
|
||||||
return ((args: DropTargetGetFeedback<D>) =>
|
return ((
|
||||||
typeof get === 'function' ? (get as any)(args) : get) as any;
|
args: Omit<DropTargetGetFeedback<D>, 'treeInstruction' | 'closestEdge'>
|
||||||
|
) => {
|
||||||
|
if (typeof get === 'function') {
|
||||||
|
return (get as any)({
|
||||||
|
...args,
|
||||||
|
get treeInstruction() {
|
||||||
|
return options.treeInstruction
|
||||||
|
? extractInstruction(
|
||||||
|
attachInstruction(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
input: args.input,
|
||||||
|
element: args.element,
|
||||||
|
currentLevel: options.treeInstruction.currentLevel,
|
||||||
|
indentPerLevel: options.treeInstruction.indentPerLevel,
|
||||||
|
mode: options.treeInstruction.mode,
|
||||||
|
block: options.treeInstruction.block,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
},
|
||||||
|
get closestEdge() {
|
||||||
|
return options.closestEdge
|
||||||
|
? extractClosestEdge(
|
||||||
|
attachClosestEdge(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
input: args.input,
|
||||||
|
element: args.element,
|
||||||
|
allowedEdges: options.closestEdge.allowedEdges,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return get;
|
||||||
|
}
|
||||||
|
}) as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DropTargetDropEvent<D extends DNDData> = Parameters<
|
export type DropTargetDropEvent<D extends DNDData> = Parameters<
|
||||||
@@ -52,6 +98,8 @@ export type DropTargetDragEvent<D extends DNDData> = Parameters<
|
|||||||
source: { data: D['draggable'] };
|
source: { data: D['draggable'] };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DropTargetTreeInstruction = Instruction;
|
||||||
|
|
||||||
export interface DropTargetOptions<D extends DNDData = DNDData> {
|
export interface DropTargetOptions<D extends DNDData = DNDData> {
|
||||||
data?: DropTargetGet<D['dropTarget'], D>;
|
data?: DropTargetGet<D['dropTarget'], D>;
|
||||||
canDrop?: DropTargetGet<boolean, D>;
|
canDrop?: DropTargetGet<boolean, D>;
|
||||||
@@ -80,8 +128,26 @@ export const useDropTarget = <D extends DNDData = DNDData>(
|
|||||||
null
|
null
|
||||||
);
|
);
|
||||||
const [closestEdge, setClosestEdge] = useState<Edge | null>(null);
|
const [closestEdge, setClosestEdge] = useState<Edge | null>(null);
|
||||||
|
const [dropEffect, setDropEffect] = useState<'copy' | 'link' | 'move' | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const [draggedOverDraggable, setDraggedOverDraggable] = useState<{
|
||||||
|
data: D['draggable'];
|
||||||
|
} | null>(null);
|
||||||
|
const [draggedOverPosition, setDraggedOverPosition] = useState<{
|
||||||
|
/**
|
||||||
|
* relative position to the drop target element top-left corner
|
||||||
|
*/
|
||||||
|
relativeX: number;
|
||||||
|
relativeY: number;
|
||||||
|
clientX: number;
|
||||||
|
clientY: number;
|
||||||
|
}>({ relativeX: 0, relativeY: 0, clientX: 0, clientY: 0 });
|
||||||
|
|
||||||
const enableDraggedOver = useRef(false);
|
const enableDraggedOver = useRef(false);
|
||||||
|
const enableDraggedOverDraggable = useRef(false);
|
||||||
|
const enableDraggedOverPosition = useRef(false);
|
||||||
|
const enableDropEffect = useRef(false);
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
const options = useMemo(getOptions, deps);
|
const options = useMemo(getOptions, deps);
|
||||||
@@ -92,19 +158,36 @@ export const useDropTarget = <D extends DNDData = DNDData>(
|
|||||||
}
|
}
|
||||||
return dropTargetForElements({
|
return dropTargetForElements({
|
||||||
element: dropTargetRef.current,
|
element: dropTargetRef.current,
|
||||||
canDrop: dropTargetGet(options.canDrop),
|
canDrop: dropTargetGet(options.canDrop, options),
|
||||||
getDropEffect: dropTargetGet(options.dropEffect),
|
getDropEffect: dropTargetGet(options.dropEffect, options),
|
||||||
getIsSticky: dropTargetGet(options.isSticky),
|
getIsSticky: dropTargetGet(options.isSticky, options),
|
||||||
onDrop: args => {
|
onDrop: args => {
|
||||||
if (enableDraggedOver.current) {
|
if (enableDraggedOver.current) {
|
||||||
setDraggedOver(false);
|
setDraggedOver(false);
|
||||||
}
|
}
|
||||||
|
if (enableDraggedOverDraggable.current) {
|
||||||
|
setDraggedOverDraggable(null);
|
||||||
|
}
|
||||||
|
if (enableDraggedOverPosition.current) {
|
||||||
|
setDraggedOverPosition({
|
||||||
|
relativeX: 0,
|
||||||
|
relativeY: 0,
|
||||||
|
clientX: 0,
|
||||||
|
clientY: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
if (options.treeInstruction) {
|
if (options.treeInstruction) {
|
||||||
setTreeInstruction(null);
|
setTreeInstruction(null);
|
||||||
|
if (dropTargetRef.current) {
|
||||||
|
delete dropTargetRef.current.dataset['treeInstruction'];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (options.closestEdge) {
|
if (options.closestEdge) {
|
||||||
setClosestEdge(null);
|
setClosestEdge(null);
|
||||||
}
|
}
|
||||||
|
if (enableDropEffect.current) {
|
||||||
|
setDropEffect(null);
|
||||||
|
}
|
||||||
if (dropTargetRef.current) {
|
if (dropTargetRef.current) {
|
||||||
delete dropTargetRef.current.dataset['draggedOver'];
|
delete dropTargetRef.current.dataset['draggedOver'];
|
||||||
}
|
}
|
||||||
@@ -120,7 +203,7 @@ export const useDropTarget = <D extends DNDData = DNDData>(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
getData: args => {
|
getData: args => {
|
||||||
const originData = dropTargetGet(options.data ?? {})(args);
|
const originData = dropTargetGet(options.data ?? {}, options)(args);
|
||||||
const { input, element } = args;
|
const { input, element } = args;
|
||||||
const withInstruction = options.treeInstruction
|
const withInstruction = options.treeInstruction
|
||||||
? attachInstruction(originData, {
|
? attachInstruction(originData, {
|
||||||
@@ -141,43 +224,116 @@ export const useDropTarget = <D extends DNDData = DNDData>(
|
|||||||
: withInstruction;
|
: withInstruction;
|
||||||
return withClosestEdge;
|
return withClosestEdge;
|
||||||
},
|
},
|
||||||
onDragEnter: () => {
|
|
||||||
if (enableDraggedOver.current) {
|
|
||||||
setDraggedOver(true);
|
|
||||||
}
|
|
||||||
if (dropTargetRef.current) {
|
|
||||||
dropTargetRef.current.dataset['draggedOver'] = 'true';
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onDrag: args => {
|
onDrag: args => {
|
||||||
let instruction = null;
|
if (
|
||||||
let closestEdge = null;
|
args.location.current.dropTargets[0]?.element ===
|
||||||
if (options.treeInstruction) {
|
dropTargetRef.current
|
||||||
instruction = extractInstruction(args.self.data);
|
) {
|
||||||
setTreeInstruction(instruction);
|
if (enableDraggedOverDraggable.current) {
|
||||||
|
setDraggedOverDraggable({ data: args.source.data });
|
||||||
|
}
|
||||||
|
let instruction = null;
|
||||||
|
let closestEdge = null;
|
||||||
|
if (options.treeInstruction) {
|
||||||
|
instruction = extractInstruction(args.self.data);
|
||||||
|
setTreeInstruction(instruction);
|
||||||
|
if (dropTargetRef.current) {
|
||||||
|
dropTargetRef.current.dataset['treeInstruction'] =
|
||||||
|
instruction?.type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (options.closestEdge) {
|
||||||
|
closestEdge = extractClosestEdge(args.self.data);
|
||||||
|
setClosestEdge(closestEdge);
|
||||||
|
}
|
||||||
|
if (enableDropEffect.current) {
|
||||||
|
setDropEffect(args.self.dropEffect);
|
||||||
|
}
|
||||||
|
if (enableDraggedOverPosition.current) {
|
||||||
|
const rect = args.self.element.getBoundingClientRect();
|
||||||
|
const { clientX, clientY } = args.location.current.input;
|
||||||
|
setDraggedOverPosition({
|
||||||
|
relativeX: clientX - rect.x,
|
||||||
|
relativeY: clientY - rect.y,
|
||||||
|
clientX: clientX,
|
||||||
|
clientY: clientY,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
options.onDrag?.({
|
||||||
|
...args,
|
||||||
|
treeInstruction: instruction,
|
||||||
|
closestEdge,
|
||||||
|
} as DropTargetDropEvent<D>);
|
||||||
}
|
}
|
||||||
if (options.closestEdge) {
|
|
||||||
closestEdge = extractClosestEdge(args.self.data);
|
|
||||||
setClosestEdge(closestEdge);
|
|
||||||
}
|
|
||||||
options.onDrag?.({
|
|
||||||
...args,
|
|
||||||
treeInstruction: instruction,
|
|
||||||
closestEdge,
|
|
||||||
} as DropTargetDropEvent<D>);
|
|
||||||
},
|
},
|
||||||
onDragLeave: () => {
|
onDropTargetChange: args => {
|
||||||
if (enableDraggedOver.current) {
|
if (
|
||||||
setDraggedOver(false);
|
args.location.current.dropTargets[0]?.element ===
|
||||||
}
|
dropTargetRef.current
|
||||||
if (options.treeInstruction) {
|
) {
|
||||||
setTreeInstruction(null);
|
if (enableDraggedOver.current) {
|
||||||
}
|
setDraggedOver(true);
|
||||||
if (options.closestEdge) {
|
}
|
||||||
setClosestEdge(null);
|
if (options.treeInstruction) {
|
||||||
}
|
const instruction = extractInstruction(args.self.data);
|
||||||
if (dropTargetRef.current) {
|
setTreeInstruction(instruction);
|
||||||
delete dropTargetRef.current.dataset['draggedOver'];
|
if (dropTargetRef.current) {
|
||||||
|
dropTargetRef.current.dataset['treeInstruction'] =
|
||||||
|
instruction?.type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (options.closestEdge) {
|
||||||
|
const closestEdge = extractClosestEdge(args.self.data);
|
||||||
|
setClosestEdge(closestEdge);
|
||||||
|
}
|
||||||
|
if (enableDropEffect.current) {
|
||||||
|
setDropEffect(args.self.dropEffect);
|
||||||
|
}
|
||||||
|
if (enableDraggedOverDraggable.current) {
|
||||||
|
setDraggedOverDraggable({ data: args.source.data });
|
||||||
|
}
|
||||||
|
if (enableDraggedOverPosition.current) {
|
||||||
|
const rect = args.self.element.getBoundingClientRect();
|
||||||
|
setDraggedOverPosition({
|
||||||
|
relativeX: args.location.current.input.clientX - rect.x,
|
||||||
|
relativeY: args.location.current.input.clientY - rect.y,
|
||||||
|
clientX: args.location.current.input.clientX,
|
||||||
|
clientY: args.location.current.input.clientY,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (dropTargetRef.current) {
|
||||||
|
dropTargetRef.current.dataset['draggedOver'] = 'true';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (enableDraggedOver.current) {
|
||||||
|
setDraggedOver(false);
|
||||||
|
}
|
||||||
|
if (enableDraggedOverDraggable.current) {
|
||||||
|
setDraggedOverDraggable(null);
|
||||||
|
}
|
||||||
|
if (options.treeInstruction) {
|
||||||
|
setTreeInstruction(null);
|
||||||
|
if (dropTargetRef.current) {
|
||||||
|
delete dropTargetRef.current.dataset['treeInstruction'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (enableDropEffect.current) {
|
||||||
|
setDropEffect(args.self.dropEffect);
|
||||||
|
}
|
||||||
|
if (enableDraggedOverPosition.current) {
|
||||||
|
setDraggedOverPosition({
|
||||||
|
relativeX: 0,
|
||||||
|
relativeY: 0,
|
||||||
|
clientX: 0,
|
||||||
|
clientY: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (options.closestEdge) {
|
||||||
|
setClosestEdge(null);
|
||||||
|
}
|
||||||
|
if (dropTargetRef.current) {
|
||||||
|
delete dropTargetRef.current.dataset['draggedOver'];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -189,6 +345,18 @@ export const useDropTarget = <D extends DNDData = DNDData>(
|
|||||||
enableDraggedOver.current = true;
|
enableDraggedOver.current = true;
|
||||||
return draggedOver;
|
return draggedOver;
|
||||||
},
|
},
|
||||||
|
get draggedOverDraggable() {
|
||||||
|
enableDraggedOverDraggable.current = true;
|
||||||
|
return draggedOverDraggable;
|
||||||
|
},
|
||||||
|
get draggedOverPosition() {
|
||||||
|
enableDraggedOverPosition.current = true;
|
||||||
|
return draggedOverPosition;
|
||||||
|
},
|
||||||
|
get dropEffect() {
|
||||||
|
enableDropEffect.current = true;
|
||||||
|
return dropEffect;
|
||||||
|
},
|
||||||
treeInstruction,
|
treeInstruction,
|
||||||
closestEdge,
|
closestEdge,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import type {
|
|||||||
KeyboardEventHandler,
|
KeyboardEventHandler,
|
||||||
ReactNode,
|
ReactNode,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { forwardRef, useCallback } from 'react';
|
import { forwardRef, useCallback, useLayoutEffect, useRef } from 'react';
|
||||||
|
|
||||||
import { input, inputWrapper } from './style.css';
|
import { input, inputWrapper } from './style.css';
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@ export type InputProps = {
|
|||||||
onChange?: (value: string) => void;
|
onChange?: (value: string) => void;
|
||||||
onBlur?: FocusEventHandler<HTMLInputElement>;
|
onBlur?: FocusEventHandler<HTMLInputElement>;
|
||||||
onKeyDown?: KeyboardEventHandler<HTMLInputElement>;
|
onKeyDown?: KeyboardEventHandler<HTMLInputElement>;
|
||||||
|
autoSelect?: boolean;
|
||||||
noBorder?: boolean;
|
noBorder?: boolean;
|
||||||
status?: 'error' | 'success' | 'warning' | 'default';
|
status?: 'error' | 'success' | 'warning' | 'default';
|
||||||
size?: 'default' | 'large' | 'extraLarge';
|
size?: 'default' | 'large' | 'extraLarge';
|
||||||
@@ -43,23 +44,20 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
|||||||
onEnter,
|
onEnter,
|
||||||
onKeyDown,
|
onKeyDown,
|
||||||
autoFocus,
|
autoFocus,
|
||||||
|
autoSelect,
|
||||||
...otherProps
|
...otherProps
|
||||||
}: InputProps,
|
}: InputProps,
|
||||||
upstreamRef: ForwardedRef<HTMLInputElement>
|
upstreamRef: ForwardedRef<HTMLInputElement>
|
||||||
) {
|
) {
|
||||||
const handleAutoFocus = useCallback(
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
(ref: HTMLInputElement | null) => {
|
useLayoutEffect(() => {
|
||||||
if (ref) {
|
if (inputRef.current && (autoFocus || autoSelect)) {
|
||||||
window.setTimeout(() => ref.focus(), 0);
|
inputRef.current?.focus();
|
||||||
if (typeof upstreamRef === 'function') {
|
if (autoSelect) {
|
||||||
upstreamRef(ref);
|
inputRef.current?.select();
|
||||||
} else if (upstreamRef) {
|
|
||||||
upstreamRef.current = ref;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
[upstreamRef]
|
}, [autoFocus, autoSelect, upstreamRef]);
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -86,7 +84,16 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
|||||||
large: size === 'large',
|
large: size === 'large',
|
||||||
'extra-large': size === 'extraLarge',
|
'extra-large': size === 'extraLarge',
|
||||||
})}
|
})}
|
||||||
ref={autoFocus ? handleAutoFocus : upstreamRef}
|
ref={ref => {
|
||||||
|
inputRef.current = ref;
|
||||||
|
if (upstreamRef) {
|
||||||
|
if (typeof upstreamRef === 'function') {
|
||||||
|
upstreamRef(ref);
|
||||||
|
} else {
|
||||||
|
upstreamRef.current = ref;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
style={inputStyle}
|
style={inputStyle}
|
||||||
onChange={useCallback(
|
onChange={useCallback(
|
||||||
|
|||||||
@@ -0,0 +1,421 @@
|
|||||||
|
{
|
||||||
|
"v": "5.12.1",
|
||||||
|
"fr": 60,
|
||||||
|
"ip": 0,
|
||||||
|
"op": 89,
|
||||||
|
"w": 240,
|
||||||
|
"h": 240,
|
||||||
|
"nm": "folder",
|
||||||
|
"ddd": 0,
|
||||||
|
"assets": [],
|
||||||
|
"layers": [
|
||||||
|
{
|
||||||
|
"ddd": 0,
|
||||||
|
"ind": 1,
|
||||||
|
"ty": 4,
|
||||||
|
"nm": "“图层 2”轮廓 3",
|
||||||
|
"sr": 1,
|
||||||
|
"ks": {
|
||||||
|
"o": { "a": 0, "k": 100, "ix": 11 },
|
||||||
|
"r": { "a": 0, "k": 0, "ix": 10 },
|
||||||
|
"p": { "a": 0, "k": [120, 120, 0], "ix": 2, "l": 2 },
|
||||||
|
"a": { "a": 0, "k": [12, 12, 0], "ix": 1, "l": 2 },
|
||||||
|
"s": { "a": 0, "k": [1000, 1000, 100], "ix": 6, "l": 2 }
|
||||||
|
},
|
||||||
|
"ao": 0,
|
||||||
|
"shapes": [
|
||||||
|
{
|
||||||
|
"ty": "gr",
|
||||||
|
"it": [
|
||||||
|
{
|
||||||
|
"ind": 0,
|
||||||
|
"ty": "sh",
|
||||||
|
"ix": 1,
|
||||||
|
"ks": {
|
||||||
|
"a": 1,
|
||||||
|
"k": [
|
||||||
|
{
|
||||||
|
"i": { "x": 0.833, "y": 0.833 },
|
||||||
|
"o": { "x": 0.167, "y": 0.167 },
|
||||||
|
"t": 0,
|
||||||
|
"s": [
|
||||||
|
{
|
||||||
|
"i": [
|
||||||
|
[0, 0],
|
||||||
|
[-1.105, 0],
|
||||||
|
[0, 0],
|
||||||
|
[0, 0],
|
||||||
|
[0, 0],
|
||||||
|
[-0.366, -0.46],
|
||||||
|
[0, 0],
|
||||||
|
[1.105, 0],
|
||||||
|
[0, 0],
|
||||||
|
[0, 1.105]
|
||||||
|
],
|
||||||
|
"o": [
|
||||||
|
[0, -1.105],
|
||||||
|
[0, 0],
|
||||||
|
[0.53, 0],
|
||||||
|
[0, 0],
|
||||||
|
[0.634, 0],
|
||||||
|
[0.272, 0.342],
|
||||||
|
[0, 1.105],
|
||||||
|
[0, 0],
|
||||||
|
[-1.105, 0],
|
||||||
|
[0, 0]
|
||||||
|
],
|
||||||
|
"v": [
|
||||||
|
[-9, -5.25],
|
||||||
|
[-7, -7.25],
|
||||||
|
[-2.328, -7.25],
|
||||||
|
[0.5, -5.25],
|
||||||
|
[7, -5.25],
|
||||||
|
[8.565, -4.495],
|
||||||
|
[9, 5.25],
|
||||||
|
[7, 7.25],
|
||||||
|
[-7, 7.25],
|
||||||
|
[-9, 5.25]
|
||||||
|
],
|
||||||
|
"c": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"t": 5,
|
||||||
|
"s": [
|
||||||
|
{
|
||||||
|
"i": [
|
||||||
|
[0, 0],
|
||||||
|
[-1.105, 0],
|
||||||
|
[0, 0],
|
||||||
|
[0, 0],
|
||||||
|
[0, 0],
|
||||||
|
[-0.002, -0.756],
|
||||||
|
[0, 0],
|
||||||
|
[1.105, 0],
|
||||||
|
[0, 0],
|
||||||
|
[0, 1.105]
|
||||||
|
],
|
||||||
|
"o": [
|
||||||
|
[0.225, -0.675],
|
||||||
|
[0, 0],
|
||||||
|
[0.53, 0],
|
||||||
|
[0, 0],
|
||||||
|
[2.525, -0.05],
|
||||||
|
[-0.002, 0.982],
|
||||||
|
[-0.375, 1.05],
|
||||||
|
[0, 0],
|
||||||
|
[-1.105, 0],
|
||||||
|
[0, 0]
|
||||||
|
],
|
||||||
|
"v": [
|
||||||
|
[-7.5, 0.35],
|
||||||
|
[-5.5, -1.65],
|
||||||
|
[-0.828, -1.65],
|
||||||
|
[2, -1.637],
|
||||||
|
[7.825, -1.612],
|
||||||
|
[10.577, 0.344],
|
||||||
|
[9.075, 5.25],
|
||||||
|
[7.075, 7.25],
|
||||||
|
[-7, 7.25],
|
||||||
|
[-9, 5.25]
|
||||||
|
],
|
||||||
|
"c": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ix": 2
|
||||||
|
},
|
||||||
|
"nm": "路径 1",
|
||||||
|
"mn": "ADBE Vector Shape - Group",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "st",
|
||||||
|
"c": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [0.466666666667, 0.458823529412, 0.490196078431, 1],
|
||||||
|
"ix": 3
|
||||||
|
},
|
||||||
|
"o": { "a": 0, "k": 100, "ix": 4 },
|
||||||
|
"w": { "a": 0, "k": 1.5, "ix": 5 },
|
||||||
|
"lc": 1,
|
||||||
|
"lj": 2,
|
||||||
|
"bm": 0,
|
||||||
|
"nm": "描边 1",
|
||||||
|
"mn": "ADBE Vector Graphic - Stroke",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "tr",
|
||||||
|
"p": { "a": 0, "k": [12, 11.75], "ix": 2 },
|
||||||
|
"a": { "a": 0, "k": [0, 0], "ix": 1 },
|
||||||
|
"s": { "a": 0, "k": [100, 100], "ix": 3 },
|
||||||
|
"r": { "a": 0, "k": 0, "ix": 6 },
|
||||||
|
"o": { "a": 0, "k": 100, "ix": 7 },
|
||||||
|
"sk": { "a": 0, "k": 0, "ix": 4 },
|
||||||
|
"sa": { "a": 0, "k": 0, "ix": 5 },
|
||||||
|
"nm": "变换"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"nm": "组 1",
|
||||||
|
"np": 2,
|
||||||
|
"cix": 2,
|
||||||
|
"bm": 0,
|
||||||
|
"ix": 1,
|
||||||
|
"mn": "ADBE Vector Group",
|
||||||
|
"hd": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ip": 1,
|
||||||
|
"op": 18060,
|
||||||
|
"st": 0,
|
||||||
|
"ct": 1,
|
||||||
|
"bm": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ddd": 0,
|
||||||
|
"ind": 2,
|
||||||
|
"ty": 4,
|
||||||
|
"nm": "形状图层 1",
|
||||||
|
"td": 1,
|
||||||
|
"sr": 1,
|
||||||
|
"ks": {
|
||||||
|
"o": { "a": 0, "k": 100, "ix": 11 },
|
||||||
|
"r": { "a": 0, "k": 0, "ix": 10 },
|
||||||
|
"p": { "a": 0, "k": [120, 120, 0], "ix": 2, "l": 2 },
|
||||||
|
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
|
||||||
|
"s": { "a": 0, "k": [100, 100, 100], "ix": 6, "l": 2 }
|
||||||
|
},
|
||||||
|
"ao": 0,
|
||||||
|
"shapes": [
|
||||||
|
{
|
||||||
|
"ty": "gr",
|
||||||
|
"it": [
|
||||||
|
{
|
||||||
|
"ind": 0,
|
||||||
|
"ty": "sh",
|
||||||
|
"ix": 1,
|
||||||
|
"ks": {
|
||||||
|
"a": 1,
|
||||||
|
"k": [
|
||||||
|
{
|
||||||
|
"i": { "x": 0.833, "y": 0.833 },
|
||||||
|
"o": { "x": 0.167, "y": 0.167 },
|
||||||
|
"t": 0,
|
||||||
|
"s": [
|
||||||
|
{
|
||||||
|
"i": [
|
||||||
|
[0, 0],
|
||||||
|
[0, 0],
|
||||||
|
[0, 0],
|
||||||
|
[0, 0]
|
||||||
|
],
|
||||||
|
"o": [
|
||||||
|
[0, 0],
|
||||||
|
[0, 0],
|
||||||
|
[0, 0],
|
||||||
|
[0, 0]
|
||||||
|
],
|
||||||
|
"v": [
|
||||||
|
[77.675, -57.65],
|
||||||
|
[77.675, 39.75],
|
||||||
|
[-74.425, 39.801],
|
||||||
|
[-74.425, -57.6]
|
||||||
|
],
|
||||||
|
"c": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"t": 5,
|
||||||
|
"s": [
|
||||||
|
{
|
||||||
|
"i": [
|
||||||
|
[0, 0],
|
||||||
|
[0, 0],
|
||||||
|
[0, 0],
|
||||||
|
[0, 0]
|
||||||
|
],
|
||||||
|
"o": [
|
||||||
|
[0, 0],
|
||||||
|
[0, 0],
|
||||||
|
[0, 0],
|
||||||
|
[0, 0]
|
||||||
|
],
|
||||||
|
"v": [
|
||||||
|
[107.675, -32.825],
|
||||||
|
[84.175, 45.15],
|
||||||
|
[-80.425, 45.075],
|
||||||
|
[-80.425, -34.825]
|
||||||
|
],
|
||||||
|
"c": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ix": 2
|
||||||
|
},
|
||||||
|
"nm": "路径 1",
|
||||||
|
"mn": "ADBE Vector Shape - Group",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "fl",
|
||||||
|
"c": { "a": 0, "k": [1, 1, 1, 1], "ix": 4 },
|
||||||
|
"o": { "a": 0, "k": 100, "ix": 5 },
|
||||||
|
"r": 1,
|
||||||
|
"bm": 0,
|
||||||
|
"nm": "填充 1",
|
||||||
|
"mn": "ADBE Vector Graphic - Fill",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "tr",
|
||||||
|
"p": { "a": 0, "k": [-1.575, 18.575], "ix": 2 },
|
||||||
|
"a": { "a": 0, "k": [0, 0], "ix": 1 },
|
||||||
|
"s": { "a": 0, "k": [100, 100], "ix": 3 },
|
||||||
|
"r": { "a": 0, "k": 0, "ix": 6 },
|
||||||
|
"o": { "a": 0, "k": 100, "ix": 7 },
|
||||||
|
"sk": { "a": 0, "k": 0, "ix": 4 },
|
||||||
|
"sa": { "a": 0, "k": 0, "ix": 5 },
|
||||||
|
"nm": "变换"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"nm": "矩形 1",
|
||||||
|
"np": 3,
|
||||||
|
"cix": 2,
|
||||||
|
"bm": 0,
|
||||||
|
"ix": 1,
|
||||||
|
"mn": "ADBE Vector Group",
|
||||||
|
"hd": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ip": 0,
|
||||||
|
"op": 18060,
|
||||||
|
"st": 0,
|
||||||
|
"ct": 1,
|
||||||
|
"bm": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ddd": 0,
|
||||||
|
"ind": 3,
|
||||||
|
"ty": 4,
|
||||||
|
"nm": "“图层 2”轮廓",
|
||||||
|
"tt": 2,
|
||||||
|
"tp": 2,
|
||||||
|
"sr": 1,
|
||||||
|
"ks": {
|
||||||
|
"o": { "a": 0, "k": 100, "ix": 11 },
|
||||||
|
"r": { "a": 0, "k": 0, "ix": 10 },
|
||||||
|
"p": { "a": 0, "k": [120, 120, 0], "ix": 2, "l": 2 },
|
||||||
|
"a": { "a": 0, "k": [12, 12, 0], "ix": 1, "l": 2 },
|
||||||
|
"s": { "a": 0, "k": [1000, 1000, 100], "ix": 6, "l": 2 }
|
||||||
|
},
|
||||||
|
"ao": 0,
|
||||||
|
"shapes": [
|
||||||
|
{
|
||||||
|
"ty": "gr",
|
||||||
|
"it": [
|
||||||
|
{
|
||||||
|
"ind": 0,
|
||||||
|
"ty": "sh",
|
||||||
|
"ix": 1,
|
||||||
|
"ks": {
|
||||||
|
"a": 0,
|
||||||
|
"k": {
|
||||||
|
"i": [
|
||||||
|
[0, 0],
|
||||||
|
[-1.105, 0],
|
||||||
|
[0, 0],
|
||||||
|
[-0.375, -0.375],
|
||||||
|
[0, 0],
|
||||||
|
[0, 0],
|
||||||
|
[0, -1.105],
|
||||||
|
[0, 0],
|
||||||
|
[1.105, 0],
|
||||||
|
[0, 0],
|
||||||
|
[0, 1.105]
|
||||||
|
],
|
||||||
|
"o": [
|
||||||
|
[0, -1.105],
|
||||||
|
[0, 0],
|
||||||
|
[0.53, 0],
|
||||||
|
[0, 0],
|
||||||
|
[0, 0],
|
||||||
|
[1.105, 0],
|
||||||
|
[0, 0],
|
||||||
|
[0, 1.105],
|
||||||
|
[0, 0],
|
||||||
|
[-1.105, 0],
|
||||||
|
[0, 0]
|
||||||
|
],
|
||||||
|
"v": [
|
||||||
|
[-9, -5.25],
|
||||||
|
[-7, -7.25],
|
||||||
|
[-2.328, -7.25],
|
||||||
|
[-0.914, -6.664],
|
||||||
|
[0.5, -5.25],
|
||||||
|
[7, -5.25],
|
||||||
|
[9, -3.25],
|
||||||
|
[9, 5.25],
|
||||||
|
[7, 7.25],
|
||||||
|
[-7, 7.25],
|
||||||
|
[-9, 5.25]
|
||||||
|
],
|
||||||
|
"c": true
|
||||||
|
},
|
||||||
|
"ix": 2
|
||||||
|
},
|
||||||
|
"nm": "路径 1",
|
||||||
|
"mn": "ADBE Vector Shape - Group",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "st",
|
||||||
|
"c": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [0.466666666667, 0.458823529412, 0.490196078431, 1],
|
||||||
|
"ix": 3
|
||||||
|
},
|
||||||
|
"o": { "a": 0, "k": 100, "ix": 4 },
|
||||||
|
"w": { "a": 0, "k": 1.5, "ix": 5 },
|
||||||
|
"lc": 1,
|
||||||
|
"lj": 2,
|
||||||
|
"bm": 0,
|
||||||
|
"nm": "描边 1",
|
||||||
|
"mn": "ADBE Vector Graphic - Stroke",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "tr",
|
||||||
|
"p": { "a": 0, "k": [12, 11.75], "ix": 2 },
|
||||||
|
"a": { "a": 0, "k": [0, 0], "ix": 1 },
|
||||||
|
"s": { "a": 0, "k": [100, 100], "ix": 3 },
|
||||||
|
"r": { "a": 0, "k": 0, "ix": 6 },
|
||||||
|
"o": { "a": 0, "k": 100, "ix": 7 },
|
||||||
|
"sk": { "a": 0, "k": 0, "ix": 4 },
|
||||||
|
"sa": { "a": 0, "k": 0, "ix": 5 },
|
||||||
|
"nm": "变换"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"nm": "组 1",
|
||||||
|
"np": 2,
|
||||||
|
"cix": 2,
|
||||||
|
"bm": 0,
|
||||||
|
"ix": 1,
|
||||||
|
"mn": "ADBE Vector Group",
|
||||||
|
"hd": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ip": 0,
|
||||||
|
"op": 18060,
|
||||||
|
"st": 0,
|
||||||
|
"ct": 1,
|
||||||
|
"bm": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"markers": [],
|
||||||
|
"props": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
|
import type { LottieRef } from 'lottie-react';
|
||||||
|
import Lottie from 'lottie-react';
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
import animationData from './folder-icon.json';
|
||||||
|
import * as styles from './styles.css';
|
||||||
|
|
||||||
|
export interface FolderIconProps {
|
||||||
|
closed: boolean; // eg, when folder icon is a "dragged over" state
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// animated folder icon that has two states: closed and opened
|
||||||
|
export const AnimatedFolderIcon = ({ closed, className }: FolderIconProps) => {
|
||||||
|
const lottieRef: LottieRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (lottieRef.current) {
|
||||||
|
const lottie = lottieRef.current;
|
||||||
|
if (closed) {
|
||||||
|
lottie.setDirection(1);
|
||||||
|
} else {
|
||||||
|
lottie.setDirection(-1);
|
||||||
|
}
|
||||||
|
lottie.play();
|
||||||
|
}
|
||||||
|
}, [closed]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Lottie
|
||||||
|
className={clsx(styles.root, className)}
|
||||||
|
autoPlay={false}
|
||||||
|
loop={false}
|
||||||
|
lottieRef={lottieRef}
|
||||||
|
animationData={animationData}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -62,11 +62,19 @@ export const ConfirmModal = ({
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button onClick={onCancel} {...cancelButtonOptions}>
|
<Button
|
||||||
|
onClick={onCancel}
|
||||||
|
data-testid="confirm-modal-cancel"
|
||||||
|
{...cancelButtonOptions}
|
||||||
|
>
|
||||||
{cancelText}
|
{cancelText}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<Button onClick={onConfirmClick} {...confirmButtonOptions}>
|
<Button
|
||||||
|
onClick={onConfirmClick}
|
||||||
|
data-testid="confirm-modal-confirm"
|
||||||
|
{...confirmButtonOptions}
|
||||||
|
>
|
||||||
{confirmText}
|
{confirmText}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
"@blocksuite/block-std": "0.16.0-canary-202407250137-267cd23",
|
"@blocksuite/block-std": "0.16.0-canary-202407250137-267cd23",
|
||||||
"@blocksuite/blocks": "0.16.0-canary-202407250137-267cd23",
|
"@blocksuite/blocks": "0.16.0-canary-202407250137-267cd23",
|
||||||
"@blocksuite/global": "0.16.0-canary-202407250137-267cd23",
|
"@blocksuite/global": "0.16.0-canary-202407250137-267cd23",
|
||||||
"@blocksuite/icons": "2.1.59",
|
"@blocksuite/icons": "2.1.60",
|
||||||
"@blocksuite/inline": "0.16.0-canary-202407250137-267cd23",
|
"@blocksuite/inline": "0.16.0-canary-202407250137-267cd23",
|
||||||
"@blocksuite/presets": "0.16.0-canary-202407250137-267cd23",
|
"@blocksuite/presets": "0.16.0-canary-202407250137-267cd23",
|
||||||
"@blocksuite/store": "0.16.0-canary-202407250137-267cd23",
|
"@blocksuite/store": "0.16.0-canary-202407250137-267cd23",
|
||||||
@@ -60,6 +60,7 @@
|
|||||||
"file-type": "^19.1.0",
|
"file-type": "^19.1.0",
|
||||||
"foxact": "^0.2.33",
|
"foxact": "^0.2.33",
|
||||||
"fractional-indexing": "^3.2.0",
|
"fractional-indexing": "^3.2.0",
|
||||||
|
"fractional-indexing-jittered": "^0.9.1",
|
||||||
"fuse.js": "^7.0.0",
|
"fuse.js": "^7.0.0",
|
||||||
"graphql": "^16.8.1",
|
"graphql": "^16.8.1",
|
||||||
"history": "^5.3.0",
|
"history": "^5.3.0",
|
||||||
|
|||||||
@@ -1,26 +1,6 @@
|
|||||||
import { cssVar } from '@toeverything/theme';
|
|
||||||
import { createContext } from 'react';
|
import { createContext } from 'react';
|
||||||
|
|
||||||
import type { PagePropertiesManager } from './page-properties-manager';
|
import type { PagePropertiesManager } from './page-properties-manager';
|
||||||
|
|
||||||
// @ts-expect-error this should always be set
|
// @ts-expect-error this should always be set
|
||||||
export const managerContext = createContext<PagePropertiesManager>();
|
export const managerContext = createContext<PagePropertiesManager>();
|
||||||
|
|
||||||
type TagColorHelper<T> = T extends `paletteLine${infer Color}` ? Color : never;
|
|
||||||
export type TagColorName = TagColorHelper<Parameters<typeof cssVar>[0]>;
|
|
||||||
|
|
||||||
const tagColorIds: TagColorName[] = [
|
|
||||||
'Red',
|
|
||||||
'Magenta',
|
|
||||||
'Orange',
|
|
||||||
'Yellow',
|
|
||||||
'Green',
|
|
||||||
'Teal',
|
|
||||||
'Blue',
|
|
||||||
'Purple',
|
|
||||||
'Grey',
|
|
||||||
];
|
|
||||||
|
|
||||||
export const tagColors = tagColorIds.map(
|
|
||||||
color => [color, cssVar(`paletteLine${color}`)] as const
|
|
||||||
);
|
|
||||||
|
|||||||
+14
-16
@@ -5,12 +5,11 @@ import {
|
|||||||
Scrollable,
|
Scrollable,
|
||||||
} from '@affine/component';
|
} from '@affine/component';
|
||||||
import { DocsSearchService } from '@affine/core/modules/docs-search';
|
import { DocsSearchService } from '@affine/core/modules/docs-search';
|
||||||
import type { Doc } from '@blocksuite/store';
|
|
||||||
import {
|
import {
|
||||||
LiveData,
|
LiveData,
|
||||||
useLiveData,
|
useLiveData,
|
||||||
useService,
|
useServices,
|
||||||
type Workspace,
|
WorkspaceService,
|
||||||
} from '@toeverything/infra';
|
} from '@toeverything/infra';
|
||||||
import { Suspense, useCallback, useContext, useMemo, useRef } from 'react';
|
import { Suspense, useCallback, useContext, useMemo, useRef } from 'react';
|
||||||
|
|
||||||
@@ -30,27 +29,26 @@ import { TimeRow } from './time-row';
|
|||||||
export const InfoModal = ({
|
export const InfoModal = ({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
page,
|
docId,
|
||||||
workspace,
|
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
page: Doc;
|
docId: string;
|
||||||
workspace: Workspace;
|
|
||||||
}) => {
|
}) => {
|
||||||
|
const { docsSearchService, workspaceService } = useServices({
|
||||||
|
DocsSearchService,
|
||||||
|
WorkspaceService,
|
||||||
|
});
|
||||||
const titleInputHandleRef = useRef<InlineEditHandle>(null);
|
const titleInputHandleRef = useRef<InlineEditHandle>(null);
|
||||||
|
const manager = usePagePropertiesManager(docId);
|
||||||
const manager = usePagePropertiesManager(page);
|
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
}, [onOpenChange]);
|
}, [onOpenChange]);
|
||||||
|
|
||||||
const docsSearchService = useService(DocsSearchService);
|
|
||||||
const references = useLiveData(
|
const references = useLiveData(
|
||||||
useMemo(
|
useMemo(
|
||||||
() => LiveData.from(docsSearchService.watchRefsFrom(page.id), null),
|
() => LiveData.from(docsSearchService.watchRefsFrom(docId), null),
|
||||||
[docsSearchService, page.id]
|
[docId, docsSearchService]
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -76,14 +74,14 @@ export const InfoModal = ({
|
|||||||
<BlocksuiteHeaderTitle
|
<BlocksuiteHeaderTitle
|
||||||
className={styles.titleStyle}
|
className={styles.titleStyle}
|
||||||
inputHandleRef={titleInputHandleRef}
|
inputHandleRef={titleInputHandleRef}
|
||||||
pageId={page.id}
|
pageId={docId}
|
||||||
docCollection={workspace.docCollection}
|
docCollection={workspaceService.workspace.docCollection}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<managerContext.Provider value={manager}>
|
<managerContext.Provider value={manager}>
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<InfoTable
|
<InfoTable
|
||||||
docId={page.id}
|
docId={docId}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
references={references}
|
references={references}
|
||||||
readonly={manager.readonly}
|
readonly={manager.readonly}
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import {
|
|||||||
ToggleExpandIcon,
|
ToggleExpandIcon,
|
||||||
ViewIcon,
|
ViewIcon,
|
||||||
} from '@blocksuite/icons/rc';
|
} from '@blocksuite/icons/rc';
|
||||||
import type { Doc } from '@blocksuite/store';
|
|
||||||
import type { DragEndEvent, DraggableAttributes } from '@dnd-kit/core';
|
import type { DragEndEvent, DraggableAttributes } from '@dnd-kit/core';
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
@@ -1085,21 +1084,21 @@ const PagePropertiesTableInner = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const usePagePropertiesManager = (page: Doc) => {
|
export const usePagePropertiesManager = (docId: string) => {
|
||||||
// the workspace properties adapter adapter is reactive,
|
// the workspace properties adapter adapter is reactive,
|
||||||
// which means it's reference will change when any of the properties change
|
// which means it's reference will change when any of the properties change
|
||||||
// also it will trigger a re-render of the component
|
// also it will trigger a re-render of the component
|
||||||
const adapter = useCurrentWorkspacePropertiesAdapter();
|
const adapter = useCurrentWorkspacePropertiesAdapter();
|
||||||
const manager = useMemo(() => {
|
const manager = useMemo(() => {
|
||||||
return new PagePropertiesManager(adapter, page.id);
|
return new PagePropertiesManager(adapter, docId);
|
||||||
}, [adapter, page.id]);
|
}, [adapter, docId]);
|
||||||
return manager;
|
return manager;
|
||||||
};
|
};
|
||||||
|
|
||||||
// this is the main component that renders the page properties table at the top of the page below
|
// this is the main component that renders the page properties table at the top of the page below
|
||||||
// the page title
|
// the page title
|
||||||
export const PagePropertiesTable = ({ page }: { page: Doc }) => {
|
export const PagePropertiesTable = ({ docId }: { docId: string }) => {
|
||||||
const manager = usePagePropertiesManager(page);
|
const manager = usePagePropertiesManager(docId);
|
||||||
|
|
||||||
// if the given page is not in the current workspace, then we don't render anything
|
// if the given page is not in the current workspace, then we don't render anything
|
||||||
// eg. when it is in history modal
|
// eg. when it is in history modal
|
||||||
|
|||||||
+11
-7
@@ -13,7 +13,6 @@ import type { HTMLAttributes, PropsWithChildren } from 'react';
|
|||||||
import { useCallback, useMemo, useReducer, useRef, useState } from 'react';
|
import { useCallback, useMemo, useReducer, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { TagItem, TempTagItem } from '../../page-list';
|
import { TagItem, TempTagItem } from '../../page-list';
|
||||||
import { tagColors } from './common';
|
|
||||||
import type { MenuItemOption } from './menu-items';
|
import type { MenuItemOption } from './menu-items';
|
||||||
import { renderMenuItemOptions } from './menu-items';
|
import { renderMenuItemOptions } from './menu-items';
|
||||||
import * as styles from './tags-inline-editor.css';
|
import * as styles from './tags-inline-editor.css';
|
||||||
@@ -80,7 +79,8 @@ export const EditTagMenu = ({
|
|||||||
}>) => {
|
}>) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const legacyProperties = useService(WorkspaceLegacyProperties);
|
const legacyProperties = useService(WorkspaceLegacyProperties);
|
||||||
const tagList = useService(TagService).tagList;
|
const tagService = useService(TagService);
|
||||||
|
const tagList = tagService.tagList;
|
||||||
const tag = useLiveData(tagList.tagByTagId$(tagId));
|
const tag = useLiveData(tagList.tagByTagId$(tagId));
|
||||||
const tagColor = useLiveData(tag?.color$);
|
const tagColor = useLiveData(tag?.color$);
|
||||||
const tagValue = useLiveData(tag?.value$);
|
const tagValue = useLiveData(tag?.value$);
|
||||||
@@ -133,7 +133,7 @@ export const EditTagMenu = ({
|
|||||||
options.push('-');
|
options.push('-');
|
||||||
|
|
||||||
options.push(
|
options.push(
|
||||||
tagColors.map(([name, color], i) => {
|
tagService.tagColors.map(([name, color], i) => {
|
||||||
return {
|
return {
|
||||||
text: name,
|
text: name,
|
||||||
icon: (
|
icon: (
|
||||||
@@ -170,6 +170,7 @@ export const EditTagMenu = ({
|
|||||||
t,
|
t,
|
||||||
tag,
|
tag,
|
||||||
tagColor,
|
tagColor,
|
||||||
|
tagService.tagColors,
|
||||||
tagValue,
|
tagValue,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -185,7 +186,8 @@ const isCreateNewTag = (
|
|||||||
|
|
||||||
export const TagsEditor = ({ pageId, readonly }: TagsEditorProps) => {
|
export const TagsEditor = ({ pageId, readonly }: TagsEditorProps) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const tagList = useService(TagService).tagList;
|
const tagService = useService(TagService);
|
||||||
|
const tagList = tagService.tagList;
|
||||||
const tags = useLiveData(tagList.tags$);
|
const tags = useLiveData(tagList.tags$);
|
||||||
const tagIds = useLiveData(tagList.tagIdsByPageId$(pageId));
|
const tagIds = useLiveData(tagList.tagIdsByPageId$(pageId));
|
||||||
const [inputValue, setInputValue] = useState('');
|
const [inputValue, setInputValue] = useState('');
|
||||||
@@ -265,10 +267,12 @@ export const TagsEditor = ({ pageId, readonly }: TagsEditorProps) => {
|
|||||||
|
|
||||||
const [nextColor, rotateNextColor] = useReducer(
|
const [nextColor, rotateNextColor] = useReducer(
|
||||||
color => {
|
color => {
|
||||||
const idx = tagColors.findIndex(c => c[1] === color);
|
const idx = tagService.tagColors.findIndex(c => c[1] === color);
|
||||||
return tagColors[(idx + 1) % tagColors.length][1];
|
return tagService.tagColors[(idx + 1) % tagService.tagColors.length][1];
|
||||||
},
|
},
|
||||||
tagColors[Math.floor(Math.random() * tagColors.length)][1]
|
tagService.tagColors[
|
||||||
|
Math.floor(Math.random() * tagService.tagColors.length)
|
||||||
|
][1]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onCreateTag = useCallback(
|
const onCreateTag = useCallback(
|
||||||
|
|||||||
@@ -1,17 +1,29 @@
|
|||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import type { PropsWithChildren } from 'react';
|
import { type ForwardedRef, forwardRef, type PropsWithChildren } from 'react';
|
||||||
|
|
||||||
import * as styles from './index.css';
|
import * as styles from './index.css';
|
||||||
|
|
||||||
interface CategoryDividerProps extends PropsWithChildren {
|
export type CategoryDividerProps = PropsWithChildren<
|
||||||
label: string;
|
{
|
||||||
}
|
label: string;
|
||||||
|
className?: string;
|
||||||
|
} & {
|
||||||
|
[key: `data-${string}`]: unknown;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
export function CategoryDivider({ label, children }: CategoryDividerProps) {
|
export const CategoryDivider = forwardRef(
|
||||||
return (
|
(
|
||||||
<div className={clsx([styles.root])}>
|
{ label, children, className, ...otherProps }: CategoryDividerProps,
|
||||||
<div className={styles.label}>{label}</div>
|
ref: ForwardedRef<HTMLDivElement>
|
||||||
{children}
|
) => {
|
||||||
</div>
|
return (
|
||||||
);
|
<div className={clsx([styles.root, className])} ref={ref} {...otherProps}>
|
||||||
}
|
<div className={styles.label}>{label}</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
CategoryDivider.displayName = 'CategoryDivider';
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ export const BlocksuiteDocEditor = forwardRef<
|
|||||||
) : (
|
) : (
|
||||||
<BlocksuiteEditorJournalDocTitle page={page} />
|
<BlocksuiteEditorJournalDocTitle page={page} />
|
||||||
)}
|
)}
|
||||||
<PagePropertiesTable page={page} />
|
<PagePropertiesTable docId={page.id} />
|
||||||
<adapted.DocEditor
|
<adapted.DocEditor
|
||||||
className={styles.docContainer}
|
className={styles.docContainer}
|
||||||
ref={onDocRef}
|
ref={onDocRef}
|
||||||
|
|||||||
+1
-1
@@ -23,7 +23,7 @@ export const root = style({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const dragPageItemOverlay = style({
|
export const dragPageItemOverlay = style({
|
||||||
height: '54px',
|
height: '45px',
|
||||||
borderRadius: '10px',
|
borderRadius: '10px',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
+123
-109
@@ -1,17 +1,12 @@
|
|||||||
import { Checkbox } from '@affine/component';
|
import { Checkbox, useDraggable } from '@affine/component';
|
||||||
import { getDNDId } from '@affine/core/hooks/affine/use-global-dnd-helper';
|
|
||||||
import { WorkbenchLink } from '@affine/core/modules/workbench';
|
import { WorkbenchLink } from '@affine/core/modules/workbench';
|
||||||
|
import type { AffineDNDData } from '@affine/core/types/dnd';
|
||||||
import { useI18n } from '@affine/i18n';
|
import { useI18n } from '@affine/i18n';
|
||||||
import { useDraggable } from '@dnd-kit/core';
|
import type { ForwardedRef, PropsWithChildren } from 'react';
|
||||||
import type { PropsWithChildren } from 'react';
|
import { forwardRef, useCallback, useMemo } from 'react';
|
||||||
import { useCallback, useMemo } from 'react';
|
|
||||||
|
|
||||||
import { selectionStateAtom, useAtom } from '../scoped-atoms';
|
import { selectionStateAtom, useAtom } from '../scoped-atoms';
|
||||||
import type {
|
import type { CollectionListItemProps, PageListItemProps } from '../types';
|
||||||
CollectionListItemProps,
|
|
||||||
DraggableTitleCellData,
|
|
||||||
PageListItemProps,
|
|
||||||
} from '../types';
|
|
||||||
import { ColWrapper, stopPropagation } from '../utils';
|
import { ColWrapper, stopPropagation } from '../utils';
|
||||||
import * as styles from './collection-list-item.css';
|
import * as styles from './collection-list-item.css';
|
||||||
|
|
||||||
@@ -109,56 +104,64 @@ export const CollectionListItem = (props: CollectionListItemProps) => {
|
|||||||
props.title,
|
props.title,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const { setNodeRef, attributes, listeners, isDragging } = useDraggable({
|
const { dragRef, dragging, CustomDragPreview } = useDraggable<AffineDNDData>(
|
||||||
id: getDNDId('collection-list', 'collection', props.collectionId),
|
() => ({
|
||||||
data: {
|
data: {
|
||||||
preview: collectionTitleElement,
|
entity: {
|
||||||
} satisfies DraggableTitleCellData,
|
type: 'collection',
|
||||||
disabled: !props.draggable,
|
id: props.collectionId,
|
||||||
});
|
},
|
||||||
|
from: {
|
||||||
|
at: 'all-collections:list',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
canDrag: props.draggable,
|
||||||
|
}),
|
||||||
|
[props.collectionId, props.draggable]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CollectionListItemWrapper
|
<>
|
||||||
onClick={props.onClick}
|
<CollectionListItemWrapper
|
||||||
to={props.to}
|
onClick={props.onClick}
|
||||||
collectionId={props.collectionId}
|
to={props.to}
|
||||||
draggable={props.draggable}
|
collectionId={props.collectionId}
|
||||||
isDragging={isDragging}
|
draggable={props.draggable}
|
||||||
>
|
isDragging={dragging}
|
||||||
<ColWrapper flex={9}>
|
ref={dragRef}
|
||||||
<ColWrapper
|
>
|
||||||
className={styles.dndCell}
|
<ColWrapper flex={9}>
|
||||||
flex={8}
|
<ColWrapper className={styles.dndCell} flex={8}>
|
||||||
ref={setNodeRef}
|
<div className={styles.titleIconsWrapper}>
|
||||||
{...attributes}
|
<CollectionSelectionCell
|
||||||
{...listeners}
|
onSelectedChange={props.onSelectedChange}
|
||||||
>
|
selectable={props.selectable}
|
||||||
<div className={styles.titleIconsWrapper}>
|
selected={props.selected}
|
||||||
<CollectionSelectionCell
|
/>
|
||||||
onSelectedChange={props.onSelectedChange}
|
<ListIconCell icon={props.icon} />
|
||||||
selectable={props.selectable}
|
</div>
|
||||||
selected={props.selected}
|
<ListTitleCell title={props.title} />
|
||||||
/>
|
</ColWrapper>
|
||||||
<ListIconCell icon={props.icon} />
|
<ColWrapper
|
||||||
</div>
|
flex={4}
|
||||||
<ListTitleCell title={props.title} />
|
alignment="end"
|
||||||
|
style={{ overflow: 'visible' }}
|
||||||
|
></ColWrapper>
|
||||||
</ColWrapper>
|
</ColWrapper>
|
||||||
<ColWrapper
|
{props.operations ? (
|
||||||
flex={4}
|
<ColWrapper
|
||||||
alignment="end"
|
className={styles.actionsCellWrapper}
|
||||||
style={{ overflow: 'visible' }}
|
flex={3}
|
||||||
></ColWrapper>
|
alignment="end"
|
||||||
</ColWrapper>
|
>
|
||||||
{props.operations ? (
|
<CollectionListOperationsCell operations={props.operations} />
|
||||||
<ColWrapper
|
</ColWrapper>
|
||||||
className={styles.actionsCellWrapper}
|
) : null}
|
||||||
flex={3}
|
</CollectionListItemWrapper>
|
||||||
alignment="end"
|
<CustomDragPreview position="pointer-outside">
|
||||||
>
|
{collectionTitleElement}
|
||||||
<CollectionListOperationsCell operations={props.operations} />
|
</CustomDragPreview>
|
||||||
</ColWrapper>
|
</>
|
||||||
) : null}
|
|
||||||
</CollectionListItemWrapper>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -171,58 +174,69 @@ type collectionListWrapperProps = PropsWithChildren<
|
|||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
|
|
||||||
function CollectionListItemWrapper({
|
const CollectionListItemWrapper = forwardRef(
|
||||||
to,
|
(
|
||||||
isDragging,
|
{
|
||||||
collectionId,
|
to,
|
||||||
onClick,
|
isDragging,
|
||||||
children,
|
collectionId,
|
||||||
draggable,
|
|
||||||
}: collectionListWrapperProps) {
|
|
||||||
const [selectionState, setSelectionActive] = useAtom(selectionStateAtom);
|
|
||||||
const handleClick = useCallback(
|
|
||||||
(e: React.MouseEvent) => {
|
|
||||||
if (!selectionState.selectable) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.shiftKey) {
|
|
||||||
stopPropagation(e);
|
|
||||||
setSelectionActive(true);
|
|
||||||
onClick?.();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (selectionState.selectionActive) {
|
|
||||||
return onClick?.();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[
|
|
||||||
onClick,
|
onClick,
|
||||||
selectionState.selectable,
|
children,
|
||||||
selectionState.selectionActive,
|
draggable,
|
||||||
setSelectionActive,
|
}: collectionListWrapperProps,
|
||||||
]
|
ref: ForwardedRef<HTMLAnchorElement & HTMLDivElement>
|
||||||
);
|
) => {
|
||||||
|
const [selectionState, setSelectionActive] = useAtom(selectionStateAtom);
|
||||||
const commonProps = useMemo(
|
const handleClick = useCallback(
|
||||||
() => ({
|
(e: React.MouseEvent) => {
|
||||||
'data-testid': 'collection-list-item',
|
if (!selectionState.selectable) {
|
||||||
'data-collection-id': collectionId,
|
return;
|
||||||
'data-draggable': draggable,
|
}
|
||||||
className: styles.root,
|
if (e.shiftKey) {
|
||||||
'data-clickable': !!onClick || !!to,
|
stopPropagation(e);
|
||||||
'data-dragging': isDragging,
|
setSelectionActive(true);
|
||||||
onClick: handleClick,
|
onClick?.();
|
||||||
}),
|
return;
|
||||||
[collectionId, draggable, isDragging, onClick, to, handleClick]
|
}
|
||||||
);
|
if (selectionState.selectionActive) {
|
||||||
|
return onClick?.();
|
||||||
if (to) {
|
}
|
||||||
return (
|
},
|
||||||
<WorkbenchLink {...commonProps} to={to}>
|
[
|
||||||
{children}
|
onClick,
|
||||||
</WorkbenchLink>
|
selectionState.selectable,
|
||||||
|
selectionState.selectionActive,
|
||||||
|
setSelectionActive,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
return <div {...commonProps}>{children}</div>;
|
const commonProps = useMemo(
|
||||||
|
() => ({
|
||||||
|
'data-testid': 'collection-list-item',
|
||||||
|
'data-collection-id': collectionId,
|
||||||
|
'data-draggable': draggable,
|
||||||
|
className: styles.root,
|
||||||
|
'data-clickable': !!onClick || !!to,
|
||||||
|
'data-dragging': isDragging,
|
||||||
|
onClick: handleClick,
|
||||||
|
}),
|
||||||
|
[collectionId, draggable, isDragging, onClick, to, handleClick]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (to) {
|
||||||
|
return (
|
||||||
|
<WorkbenchLink {...commonProps} to={to} ref={ref}>
|
||||||
|
{children}
|
||||||
|
</WorkbenchLink>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<div {...commonProps} ref={ref}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
);
|
||||||
|
|
||||||
|
CollectionListItemWrapper.displayName = 'CollectionListItemWrapper';
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ export const root = style({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const dragPageItemOverlay = style({
|
export const dragPageItemOverlay = style({
|
||||||
height: '54px',
|
height: '45px',
|
||||||
borderRadius: '10px',
|
borderRadius: '8px',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
background: cssVar('hoverColorFilled'),
|
background: cssVar('hoverColorFilled'),
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { Checkbox, Tooltip } from '@affine/component';
|
import { Checkbox, Tooltip, useDraggable } from '@affine/component';
|
||||||
import { getDNDId } from '@affine/core/hooks/affine/use-global-dnd-helper';
|
|
||||||
import { TagService } from '@affine/core/modules/tag';
|
import { TagService } from '@affine/core/modules/tag';
|
||||||
|
import type { AffineDNDData } from '@affine/core/types/dnd';
|
||||||
import { i18nTime } from '@affine/i18n';
|
import { i18nTime } from '@affine/i18n';
|
||||||
import { useDraggable } from '@dnd-kit/core';
|
|
||||||
import { useLiveData, useService } from '@toeverything/infra';
|
import { useLiveData, useService } from '@toeverything/infra';
|
||||||
import type { PropsWithChildren } from 'react';
|
import type { ForwardedRef, PropsWithChildren } from 'react';
|
||||||
import { useCallback, useEffect, useMemo } from 'react';
|
import { forwardRef, useCallback, useEffect, useMemo } from 'react';
|
||||||
|
|
||||||
import { WorkbenchLink } from '../../../modules/workbench/view/workbench-link';
|
import { WorkbenchLink } from '../../../modules/workbench/view/workbench-link';
|
||||||
import {
|
import {
|
||||||
@@ -14,7 +13,7 @@ import {
|
|||||||
selectionStateAtom,
|
selectionStateAtom,
|
||||||
useAtom,
|
useAtom,
|
||||||
} from '../scoped-atoms';
|
} from '../scoped-atoms';
|
||||||
import type { DraggableTitleCellData, PageListItemProps } from '../types';
|
import type { PageListItemProps } from '../types';
|
||||||
import { useAllDocDisplayProperties } from '../use-all-doc-display-properties';
|
import { useAllDocDisplayProperties } from '../use-all-doc-display-properties';
|
||||||
import { ColWrapper, stopPropagation } from '../utils';
|
import { ColWrapper, stopPropagation } from '../utils';
|
||||||
import * as styles from './page-list-item.css';
|
import * as styles from './page-list-item.css';
|
||||||
@@ -167,76 +166,84 @@ export const PageListItem = (props: PageListItemProps) => {
|
|||||||
props.title,
|
props.title,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const { setNodeRef, attributes, listeners, isDragging } = useDraggable({
|
const { dragRef, CustomDragPreview, dragging } = useDraggable<AffineDNDData>(
|
||||||
id: getDNDId('doc-list', 'doc', props.pageId),
|
() => ({
|
||||||
data: {
|
canDrag: props.draggable,
|
||||||
preview: pageTitleElement,
|
data: {
|
||||||
} satisfies DraggableTitleCellData,
|
entity: {
|
||||||
disabled: !props.draggable,
|
type: 'doc',
|
||||||
});
|
id: props.pageId,
|
||||||
|
},
|
||||||
|
from: {
|
||||||
|
at: 'all-docs:list',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[props.draggable, props.pageId]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageListItemWrapper
|
<>
|
||||||
onClick={props.onClick}
|
<PageListItemWrapper
|
||||||
to={props.to}
|
onClick={props.onClick}
|
||||||
pageId={props.pageId}
|
to={props.to}
|
||||||
draggable={props.draggable}
|
pageId={props.pageId}
|
||||||
isDragging={isDragging}
|
draggable={props.draggable}
|
||||||
pageIds={props.pageIds || []}
|
isDragging={dragging}
|
||||||
>
|
ref={dragRef}
|
||||||
<ColWrapper flex={9}>
|
pageIds={props.pageIds || []}
|
||||||
<ColWrapper
|
>
|
||||||
className={styles.dndCell}
|
<ColWrapper flex={9}>
|
||||||
flex={8}
|
<ColWrapper className={styles.dndCell} flex={8}>
|
||||||
ref={setNodeRef}
|
<div className={styles.titleIconsWrapper}>
|
||||||
{...attributes}
|
<PageSelectionCell
|
||||||
{...listeners}
|
onSelectedChange={props.onSelectedChange}
|
||||||
>
|
selectable={props.selectable}
|
||||||
<div className={styles.titleIconsWrapper}>
|
selected={props.selected}
|
||||||
<PageSelectionCell
|
/>
|
||||||
onSelectedChange={props.onSelectedChange}
|
<ListIconCell icon={props.icon} />
|
||||||
selectable={props.selectable}
|
</div>
|
||||||
selected={props.selected}
|
<ListTitleCell title={props.title} preview={props.preview} />
|
||||||
/>
|
</ColWrapper>
|
||||||
<ListIconCell icon={props.icon} />
|
<ColWrapper
|
||||||
</div>
|
flex={4}
|
||||||
<ListTitleCell title={props.title} preview={props.preview} />
|
alignment="end"
|
||||||
|
style={{ overflow: 'visible' }}
|
||||||
|
hidden={!displayProperties.displayProperties.tags}
|
||||||
|
>
|
||||||
|
<PageTagsCell pageId={props.pageId} />
|
||||||
|
</ColWrapper>
|
||||||
</ColWrapper>
|
</ColWrapper>
|
||||||
<ColWrapper
|
<ColWrapper
|
||||||
flex={4}
|
|
||||||
alignment="end"
|
|
||||||
style={{ overflow: 'visible' }}
|
|
||||||
hidden={!displayProperties.displayProperties.tags}
|
|
||||||
>
|
|
||||||
<PageTagsCell pageId={props.pageId} />
|
|
||||||
</ColWrapper>
|
|
||||||
</ColWrapper>
|
|
||||||
<ColWrapper
|
|
||||||
flex={1}
|
|
||||||
alignment="end"
|
|
||||||
hideInSmallContainer
|
|
||||||
hidden={!displayProperties.displayProperties.createDate}
|
|
||||||
>
|
|
||||||
<PageCreateDateCell createDate={props.createDate} />
|
|
||||||
</ColWrapper>
|
|
||||||
<ColWrapper
|
|
||||||
flex={1}
|
|
||||||
alignment="end"
|
|
||||||
hideInSmallContainer
|
|
||||||
hidden={!displayProperties.displayProperties.updatedDate}
|
|
||||||
>
|
|
||||||
<PageUpdatedDateCell updatedDate={props.updatedDate} />
|
|
||||||
</ColWrapper>
|
|
||||||
{props.operations ? (
|
|
||||||
<ColWrapper
|
|
||||||
className={styles.actionsCellWrapper}
|
|
||||||
flex={1}
|
flex={1}
|
||||||
alignment="end"
|
alignment="end"
|
||||||
|
hideInSmallContainer
|
||||||
|
hidden={!displayProperties.displayProperties.createDate}
|
||||||
>
|
>
|
||||||
<PageListOperationsCell operations={props.operations} />
|
<PageCreateDateCell createDate={props.createDate} />
|
||||||
</ColWrapper>
|
</ColWrapper>
|
||||||
) : null}
|
<ColWrapper
|
||||||
</PageListItemWrapper>
|
flex={1}
|
||||||
|
alignment="end"
|
||||||
|
hideInSmallContainer
|
||||||
|
hidden={!displayProperties.displayProperties.updatedDate}
|
||||||
|
>
|
||||||
|
<PageUpdatedDateCell updatedDate={props.updatedDate} />
|
||||||
|
</ColWrapper>
|
||||||
|
{props.operations ? (
|
||||||
|
<ColWrapper
|
||||||
|
className={styles.actionsCellWrapper}
|
||||||
|
flex={1}
|
||||||
|
alignment="end"
|
||||||
|
>
|
||||||
|
<PageListOperationsCell operations={props.operations} />
|
||||||
|
</ColWrapper>
|
||||||
|
) : null}
|
||||||
|
</PageListItemWrapper>
|
||||||
|
<CustomDragPreview position="pointer-outside">
|
||||||
|
{pageTitleElement}
|
||||||
|
</CustomDragPreview>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -247,132 +254,142 @@ type PageListWrapperProps = PropsWithChildren<
|
|||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
|
|
||||||
function PageListItemWrapper({
|
const PageListItemWrapper = forwardRef(
|
||||||
to,
|
(
|
||||||
isDragging,
|
{
|
||||||
pageId,
|
to,
|
||||||
pageIds,
|
isDragging,
|
||||||
onClick,
|
|
||||||
children,
|
|
||||||
draggable,
|
|
||||||
}: PageListWrapperProps) {
|
|
||||||
const [selectionState, setSelectionActive] = useAtom(selectionStateAtom);
|
|
||||||
const [anchorIndex, setAnchorIndex] = useAtom(anchorIndexAtom);
|
|
||||||
const [rangeIds, setRangeIds] = useAtom(rangeIdsAtom);
|
|
||||||
|
|
||||||
const handleShiftClick = useCallback(
|
|
||||||
(currentIndex: number) => {
|
|
||||||
if (anchorIndex === undefined) {
|
|
||||||
setAnchorIndex(currentIndex);
|
|
||||||
onClick?.();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lowerIndex = Math.min(anchorIndex, currentIndex);
|
|
||||||
const upperIndex = Math.max(anchorIndex, currentIndex);
|
|
||||||
const newRangeIds = pageIds.slice(lowerIndex, upperIndex + 1);
|
|
||||||
|
|
||||||
const currentSelected = selectionState.selectedIds || [];
|
|
||||||
|
|
||||||
// Set operations
|
|
||||||
const setRange = new Set(rangeIds);
|
|
||||||
const newSelected = new Set(
|
|
||||||
currentSelected.filter(id => !setRange.has(id)).concat(newRangeIds)
|
|
||||||
);
|
|
||||||
|
|
||||||
selectionState.onSelectedIdsChange?.([...newSelected]);
|
|
||||||
setRangeIds(newRangeIds);
|
|
||||||
},
|
|
||||||
[
|
|
||||||
anchorIndex,
|
|
||||||
onClick,
|
|
||||||
pageIds,
|
|
||||||
selectionState,
|
|
||||||
setAnchorIndex,
|
|
||||||
rangeIds,
|
|
||||||
setRangeIds,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleClick = useCallback(
|
|
||||||
(e: React.MouseEvent) => {
|
|
||||||
if (!selectionState.selectable) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
stopPropagation(e);
|
|
||||||
const currentIndex = pageIds.indexOf(pageId);
|
|
||||||
|
|
||||||
if (e.shiftKey) {
|
|
||||||
if (!selectionState.selectionActive) {
|
|
||||||
setSelectionActive(true);
|
|
||||||
setAnchorIndex(currentIndex);
|
|
||||||
onClick?.();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
handleShiftClick(currentIndex);
|
|
||||||
return false;
|
|
||||||
} else {
|
|
||||||
setAnchorIndex(undefined);
|
|
||||||
setRangeIds([]);
|
|
||||||
onClick?.();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[
|
|
||||||
handleShiftClick,
|
|
||||||
onClick,
|
|
||||||
pageId,
|
pageId,
|
||||||
pageIds,
|
pageIds,
|
||||||
selectionState.selectable,
|
onClick,
|
||||||
|
children,
|
||||||
|
draggable,
|
||||||
|
}: PageListWrapperProps,
|
||||||
|
ref: ForwardedRef<HTMLAnchorElement & HTMLDivElement>
|
||||||
|
) => {
|
||||||
|
const [selectionState, setSelectionActive] = useAtom(selectionStateAtom);
|
||||||
|
const [anchorIndex, setAnchorIndex] = useAtom(anchorIndexAtom);
|
||||||
|
const [rangeIds, setRangeIds] = useAtom(rangeIdsAtom);
|
||||||
|
|
||||||
|
const handleShiftClick = useCallback(
|
||||||
|
(currentIndex: number) => {
|
||||||
|
if (anchorIndex === undefined) {
|
||||||
|
setAnchorIndex(currentIndex);
|
||||||
|
onClick?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowerIndex = Math.min(anchorIndex, currentIndex);
|
||||||
|
const upperIndex = Math.max(anchorIndex, currentIndex);
|
||||||
|
const newRangeIds = pageIds.slice(lowerIndex, upperIndex + 1);
|
||||||
|
|
||||||
|
const currentSelected = selectionState.selectedIds || [];
|
||||||
|
|
||||||
|
// Set operations
|
||||||
|
const setRange = new Set(rangeIds);
|
||||||
|
const newSelected = new Set(
|
||||||
|
currentSelected.filter(id => !setRange.has(id)).concat(newRangeIds)
|
||||||
|
);
|
||||||
|
|
||||||
|
selectionState.onSelectedIdsChange?.([...newSelected]);
|
||||||
|
setRangeIds(newRangeIds);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
anchorIndex,
|
||||||
|
onClick,
|
||||||
|
pageIds,
|
||||||
|
selectionState,
|
||||||
|
setAnchorIndex,
|
||||||
|
rangeIds,
|
||||||
|
setRangeIds,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClick = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
if (!selectionState.selectable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stopPropagation(e);
|
||||||
|
const currentIndex = pageIds.indexOf(pageId);
|
||||||
|
|
||||||
|
if (e.shiftKey) {
|
||||||
|
if (!selectionState.selectionActive) {
|
||||||
|
setSelectionActive(true);
|
||||||
|
setAnchorIndex(currentIndex);
|
||||||
|
onClick?.();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
handleShiftClick(currentIndex);
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
setAnchorIndex(undefined);
|
||||||
|
setRangeIds([]);
|
||||||
|
onClick?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
handleShiftClick,
|
||||||
|
onClick,
|
||||||
|
pageId,
|
||||||
|
pageIds,
|
||||||
|
selectionState.selectable,
|
||||||
|
selectionState.selectionActive,
|
||||||
|
setAnchorIndex,
|
||||||
|
setRangeIds,
|
||||||
|
setSelectionActive,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const commonProps = useMemo(
|
||||||
|
() => ({
|
||||||
|
'data-testid': 'page-list-item',
|
||||||
|
'data-page-id': pageId,
|
||||||
|
'data-draggable': draggable,
|
||||||
|
className: styles.root,
|
||||||
|
'data-clickable': !!onClick || !!to,
|
||||||
|
'data-dragging': isDragging,
|
||||||
|
onClick: onClick ? handleClick : undefined,
|
||||||
|
}),
|
||||||
|
[pageId, draggable, onClick, to, isDragging, handleClick]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectionState.selectionActive) {
|
||||||
|
// listen for shift key up
|
||||||
|
const handleKeyUp = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Shift') {
|
||||||
|
setAnchorIndex(undefined);
|
||||||
|
setRangeIds([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keyup', handleKeyUp);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keyup', handleKeyUp);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}, [
|
||||||
selectionState.selectionActive,
|
selectionState.selectionActive,
|
||||||
setAnchorIndex,
|
setAnchorIndex,
|
||||||
setRangeIds,
|
setRangeIds,
|
||||||
setSelectionActive,
|
setSelectionActive,
|
||||||
]
|
]);
|
||||||
);
|
|
||||||
|
|
||||||
const commonProps = useMemo(
|
if (to) {
|
||||||
() => ({
|
return (
|
||||||
'data-testid': 'page-list-item',
|
<WorkbenchLink ref={ref} {...commonProps} to={to}>
|
||||||
'data-page-id': pageId,
|
{children}
|
||||||
'data-draggable': draggable,
|
</WorkbenchLink>
|
||||||
className: styles.root,
|
);
|
||||||
'data-clickable': !!onClick || !!to,
|
} else {
|
||||||
'data-dragging': isDragging,
|
return (
|
||||||
onClick: onClick ? handleClick : undefined,
|
<div ref={ref} {...commonProps}>
|
||||||
}),
|
{children}
|
||||||
[pageId, draggable, onClick, to, isDragging, handleClick]
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectionState.selectionActive) {
|
|
||||||
// listen for shift key up
|
|
||||||
const handleKeyUp = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === 'Shift') {
|
|
||||||
setAnchorIndex(undefined);
|
|
||||||
setRangeIds([]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener('keyup', handleKeyUp);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('keyup', handleKeyUp);
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}, [
|
|
||||||
selectionState.selectionActive,
|
|
||||||
setAnchorIndex,
|
|
||||||
setRangeIds,
|
|
||||||
setSelectionActive,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (to) {
|
|
||||||
return (
|
|
||||||
<WorkbenchLink {...commonProps} to={to}>
|
|
||||||
{children}
|
|
||||||
</WorkbenchLink>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return <div {...commonProps}>{children}</div>;
|
|
||||||
}
|
}
|
||||||
}
|
);
|
||||||
|
PageListItemWrapper.displayName = 'PageListItemWrapper';
|
||||||
|
|||||||
@@ -239,8 +239,7 @@ export const PageOperationCell = ({
|
|||||||
<InfoModal
|
<InfoModal
|
||||||
open={openInfoModal}
|
open={openInfoModal}
|
||||||
onOpenChange={setOpenInfoModal}
|
onOpenChange={setOpenInfoModal}
|
||||||
page={blocksuiteDoc}
|
docId={blocksuiteDoc.id}
|
||||||
workspace={currentWorkspace}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<DisablePublicSharing.DisablePublicSharingModal
|
<DisablePublicSharing.DisablePublicSharingModal
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { useLiveData, useService } from '@toeverything/infra';
|
|||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { tagColors } from '../../affine/page-properties/common';
|
|
||||||
import type { TagMeta } from '../types';
|
import type { TagMeta } from '../types';
|
||||||
import * as styles from './create-tag.css';
|
import * as styles from './create-tag.css';
|
||||||
|
|
||||||
@@ -18,11 +17,6 @@ const TagIcon = ({ color, large }: { color: string; large?: boolean }) => (
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const randomTagColor = () => {
|
|
||||||
const randomIndex = Math.floor(Math.random() * tagColors.length);
|
|
||||||
return tagColors[randomIndex][1];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const CreateOrEditTag = ({
|
export const CreateOrEditTag = ({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
@@ -32,7 +26,8 @@ export const CreateOrEditTag = ({
|
|||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
tagMeta?: TagMeta;
|
tagMeta?: TagMeta;
|
||||||
}) => {
|
}) => {
|
||||||
const tagList = useService(TagService).tagList;
|
const tagService = useService(TagService);
|
||||||
|
const tagList = tagService.tagList;
|
||||||
const tagOptions = useLiveData(tagList.tagMetas$);
|
const tagOptions = useLiveData(tagList.tagMetas$);
|
||||||
const tag = useLiveData(tagList.tagByTagId$(tagMeta?.id));
|
const tag = useLiveData(tagList.tagByTagId$(tagMeta?.id));
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
@@ -43,14 +38,16 @@ export const CreateOrEditTag = ({
|
|||||||
setTagName(value);
|
setTagName(value);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const [tagIcon, setTagIcon] = useState(tagMeta?.color || randomTagColor());
|
const [tagIcon, setTagIcon] = useState(
|
||||||
|
tagMeta?.color || tagService.randomTagColor()
|
||||||
|
);
|
||||||
|
|
||||||
const handleChangeIcon = useCallback((value: string) => {
|
const handleChangeIcon = useCallback((value: string) => {
|
||||||
setTagIcon(value);
|
setTagIcon(value);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const tags = useMemo(() => {
|
const tags = useMemo(() => {
|
||||||
return tagColors.map(([_, color]) => {
|
return tagService.tagColors.map(([name, color]) => {
|
||||||
return {
|
return {
|
||||||
name: name,
|
name: name,
|
||||||
color: color,
|
color: color,
|
||||||
@@ -60,7 +57,7 @@ export const CreateOrEditTag = ({
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [handleChangeIcon]);
|
}, [handleChangeIcon, tagService.tagColors]);
|
||||||
|
|
||||||
const items = useMemo(() => {
|
const items = useMemo(() => {
|
||||||
const tagItems = tags.map(item => {
|
const tagItems = tags.map(item => {
|
||||||
@@ -81,11 +78,11 @@ export const CreateOrEditTag = ({
|
|||||||
|
|
||||||
const onClose = useCallback(() => {
|
const onClose = useCallback(() => {
|
||||||
if (!tagMeta) {
|
if (!tagMeta) {
|
||||||
handleChangeIcon(randomTagColor());
|
handleChangeIcon(tagService.randomTagColor());
|
||||||
setTagName('');
|
setTagName('');
|
||||||
}
|
}
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
}, [handleChangeIcon, onOpenChange, tagMeta]);
|
}, [handleChangeIcon, onOpenChange, tagMeta, tagService]);
|
||||||
|
|
||||||
const onConfirm = useCallback(() => {
|
const onConfirm = useCallback(() => {
|
||||||
if (!tagName?.trim()) return;
|
if (!tagName?.trim()) return;
|
||||||
@@ -129,8 +126,8 @@ export const CreateOrEditTag = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTagName(tagMeta?.title || '');
|
setTagName(tagMeta?.title || '');
|
||||||
setTagIcon(tagMeta?.color || randomTagColor());
|
setTagIcon(tagMeta?.color || tagService.randomTagColor());
|
||||||
}, [tagMeta?.color, tagMeta?.title]);
|
}, [tagMeta?.color, tagMeta?.title, tagService]);
|
||||||
|
|
||||||
if (!open) {
|
if (!open) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export const root = style({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
export const dragPageItemOverlay = style({
|
export const dragPageItemOverlay = style({
|
||||||
height: '54px',
|
height: '45px',
|
||||||
borderRadius: '10px',
|
borderRadius: '10px',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { Checkbox } from '@affine/component';
|
import { Checkbox, useDraggable } from '@affine/component';
|
||||||
import { getDNDId } from '@affine/core/hooks/affine/use-global-dnd-helper';
|
|
||||||
import { WorkbenchLink } from '@affine/core/modules/workbench';
|
import { WorkbenchLink } from '@affine/core/modules/workbench';
|
||||||
|
import type { AffineDNDData } from '@affine/core/types/dnd';
|
||||||
import { useI18n } from '@affine/i18n';
|
import { useI18n } from '@affine/i18n';
|
||||||
import { useDraggable } from '@dnd-kit/core';
|
import type { ForwardedRef, PropsWithChildren } from 'react';
|
||||||
import type { PropsWithChildren } from 'react';
|
import { forwardRef, useCallback, useMemo } from 'react';
|
||||||
import { useCallback, useMemo } from 'react';
|
|
||||||
|
|
||||||
import { selectionStateAtom, useAtom } from '../scoped-atoms';
|
import { selectionStateAtom, useAtom } from '../scoped-atoms';
|
||||||
import type { DraggableTitleCellData, TagListItemProps } from '../types';
|
import type { TagListItemProps } from '../types';
|
||||||
import { ColWrapper, stopPropagation } from '../utils';
|
import { ColWrapper, stopPropagation } from '../utils';
|
||||||
import * as styles from './tag-list-item.css';
|
import * as styles from './tag-list-item.css';
|
||||||
|
|
||||||
@@ -83,45 +82,62 @@ const TagListOperationsCell = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const TagListItem = (props: TagListItemProps) => {
|
export const TagListItem = (props: TagListItemProps) => {
|
||||||
const tagTitleElement = useMemo(() => {
|
const { dragRef, CustomDragPreview, dragging } = useDraggable<AffineDNDData>(
|
||||||
return (
|
() => ({
|
||||||
<div className={styles.dragPageItemOverlay}>
|
canDrag: props.draggable,
|
||||||
<div className={styles.titleIconsWrapper}>
|
data: {
|
||||||
<TagSelectionCell
|
entity: {
|
||||||
onSelectedChange={props.onSelectedChange}
|
type: 'tag',
|
||||||
selectable={props.selectable}
|
id: props.tagId,
|
||||||
selected={props.selected}
|
},
|
||||||
/>
|
from: {
|
||||||
<ListIconCell color={props.color} />
|
at: 'all-tags:list',
|
||||||
</div>
|
},
|
||||||
</div>
|
},
|
||||||
);
|
}),
|
||||||
}, [props.color, props.onSelectedChange, props.selectable, props.selected]);
|
[props.draggable, props.tagId]
|
||||||
|
);
|
||||||
const { setNodeRef, attributes, listeners, isDragging } = useDraggable({
|
|
||||||
id: getDNDId('tag-list', 'tag', props.tagId),
|
|
||||||
data: {
|
|
||||||
preview: tagTitleElement,
|
|
||||||
} satisfies DraggableTitleCellData,
|
|
||||||
disabled: !props.draggable,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TagListItemWrapper
|
<>
|
||||||
onClick={props.onClick}
|
<TagListItemWrapper
|
||||||
to={props.to}
|
onClick={props.onClick}
|
||||||
tagId={props.tagId}
|
to={props.to}
|
||||||
draggable={props.draggable}
|
tagId={props.tagId}
|
||||||
isDragging={isDragging}
|
draggable={props.draggable}
|
||||||
>
|
isDragging={dragging}
|
||||||
<ColWrapper flex={9}>
|
ref={dragRef}
|
||||||
<ColWrapper
|
>
|
||||||
className={styles.dndCell}
|
<ColWrapper flex={9}>
|
||||||
flex={8}
|
<ColWrapper className={styles.dndCell} flex={8}>
|
||||||
ref={setNodeRef}
|
<div className={styles.titleIconsWrapper}>
|
||||||
{...attributes}
|
<TagSelectionCell
|
||||||
{...listeners}
|
onSelectedChange={props.onSelectedChange}
|
||||||
>
|
selectable={props.selectable}
|
||||||
|
selected={props.selected}
|
||||||
|
/>
|
||||||
|
<ListIconCell color={props.color} />
|
||||||
|
</div>
|
||||||
|
<TagListTitleCell title={props.title} pageCount={props.pageCount} />
|
||||||
|
</ColWrapper>
|
||||||
|
<ColWrapper
|
||||||
|
flex={4}
|
||||||
|
alignment="end"
|
||||||
|
style={{ overflow: 'visible' }}
|
||||||
|
></ColWrapper>
|
||||||
|
</ColWrapper>
|
||||||
|
{props.operations ? (
|
||||||
|
<ColWrapper
|
||||||
|
className={styles.actionsCellWrapper}
|
||||||
|
flex={2}
|
||||||
|
alignment="end"
|
||||||
|
>
|
||||||
|
<TagListOperationsCell operations={props.operations} />
|
||||||
|
</ColWrapper>
|
||||||
|
) : null}
|
||||||
|
</TagListItemWrapper>
|
||||||
|
<CustomDragPreview position="pointer-outside">
|
||||||
|
<div className={styles.dragPageItemOverlay}>
|
||||||
<div className={styles.titleIconsWrapper}>
|
<div className={styles.titleIconsWrapper}>
|
||||||
<TagSelectionCell
|
<TagSelectionCell
|
||||||
onSelectedChange={props.onSelectedChange}
|
onSelectedChange={props.onSelectedChange}
|
||||||
@@ -131,23 +147,9 @@ export const TagListItem = (props: TagListItemProps) => {
|
|||||||
<ListIconCell color={props.color} />
|
<ListIconCell color={props.color} />
|
||||||
</div>
|
</div>
|
||||||
<TagListTitleCell title={props.title} pageCount={props.pageCount} />
|
<TagListTitleCell title={props.title} pageCount={props.pageCount} />
|
||||||
</ColWrapper>
|
</div>
|
||||||
<ColWrapper
|
</CustomDragPreview>
|
||||||
flex={4}
|
</>
|
||||||
alignment="end"
|
|
||||||
style={{ overflow: 'visible' }}
|
|
||||||
></ColWrapper>
|
|
||||||
</ColWrapper>
|
|
||||||
{props.operations ? (
|
|
||||||
<ColWrapper
|
|
||||||
className={styles.actionsCellWrapper}
|
|
||||||
flex={2}
|
|
||||||
alignment="end"
|
|
||||||
>
|
|
||||||
<TagListOperationsCell operations={props.operations} />
|
|
||||||
</ColWrapper>
|
|
||||||
) : null}
|
|
||||||
</TagListItemWrapper>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -157,58 +159,68 @@ type TagListWrapperProps = PropsWithChildren<
|
|||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
|
|
||||||
function TagListItemWrapper({
|
const TagListItemWrapper = forwardRef(
|
||||||
to,
|
(
|
||||||
isDragging,
|
{
|
||||||
tagId,
|
to,
|
||||||
onClick,
|
isDragging,
|
||||||
children,
|
tagId,
|
||||||
draggable,
|
|
||||||
}: TagListWrapperProps) {
|
|
||||||
const [selectionState, setSelectionActive] = useAtom(selectionStateAtom);
|
|
||||||
const handleClick = useCallback(
|
|
||||||
(e: React.MouseEvent) => {
|
|
||||||
if (!selectionState.selectable) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.shiftKey) {
|
|
||||||
stopPropagation(e);
|
|
||||||
setSelectionActive(true);
|
|
||||||
onClick?.();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (selectionState.selectionActive) {
|
|
||||||
return onClick?.();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[
|
|
||||||
onClick,
|
onClick,
|
||||||
selectionState.selectable,
|
children,
|
||||||
selectionState.selectionActive,
|
draggable,
|
||||||
setSelectionActive,
|
}: TagListWrapperProps,
|
||||||
]
|
ref: ForwardedRef<HTMLAnchorElement & HTMLDivElement>
|
||||||
);
|
) => {
|
||||||
|
const [selectionState, setSelectionActive] = useAtom(selectionStateAtom);
|
||||||
const commonProps = useMemo(
|
const handleClick = useCallback(
|
||||||
() => ({
|
(e: React.MouseEvent) => {
|
||||||
'data-testid': 'tag-list-item',
|
if (!selectionState.selectable) {
|
||||||
'data-tag-id': tagId,
|
return;
|
||||||
'data-draggable': draggable,
|
}
|
||||||
className: styles.root,
|
if (e.shiftKey) {
|
||||||
'data-clickable': !!onClick || !!to,
|
stopPropagation(e);
|
||||||
'data-dragging': isDragging,
|
setSelectionActive(true);
|
||||||
onClick: handleClick,
|
onClick?.();
|
||||||
}),
|
return;
|
||||||
[tagId, draggable, isDragging, onClick, to, handleClick]
|
}
|
||||||
);
|
if (selectionState.selectionActive) {
|
||||||
|
return onClick?.();
|
||||||
if (to) {
|
}
|
||||||
return (
|
},
|
||||||
<WorkbenchLink {...commonProps} to={to}>
|
[
|
||||||
{children}
|
onClick,
|
||||||
</WorkbenchLink>
|
selectionState.selectable,
|
||||||
|
selectionState.selectionActive,
|
||||||
|
setSelectionActive,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
return <div {...commonProps}>{children}</div>;
|
const commonProps = useMemo(
|
||||||
|
() => ({
|
||||||
|
'data-testid': 'tag-list-item',
|
||||||
|
'data-tag-id': tagId,
|
||||||
|
'data-draggable': draggable,
|
||||||
|
className: styles.root,
|
||||||
|
'data-clickable': !!onClick || !!to,
|
||||||
|
'data-dragging': isDragging,
|
||||||
|
onClick: handleClick,
|
||||||
|
}),
|
||||||
|
[tagId, draggable, isDragging, onClick, to, handleClick]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (to) {
|
||||||
|
return (
|
||||||
|
<WorkbenchLink {...commonProps} to={to} ref={ref}>
|
||||||
|
{children}
|
||||||
|
</WorkbenchLink>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<div {...commonProps} ref={ref}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
);
|
||||||
|
TagListItemWrapper.displayName = 'TagListItemWrapper';
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export const VirtualizedTagList = ({
|
|||||||
<VirtualizedList
|
<VirtualizedList
|
||||||
ref={listRef}
|
ref={listRef}
|
||||||
selectable="toggle"
|
selectable="toggle"
|
||||||
draggable={false}
|
draggable={true}
|
||||||
atTopThreshold={80}
|
atTopThreshold={80}
|
||||||
onSelectionActiveChange={setShowFloatingToolbar}
|
onSelectionActiveChange={setShowFloatingToolbar}
|
||||||
heading={<TagListHeader onOpen={onOpenCreate} />}
|
heading={<TagListHeader onOpen={onOpenCreate} />}
|
||||||
|
|||||||
-24
@@ -1,24 +0,0 @@
|
|||||||
import { IconButton } from '@affine/component/ui/button';
|
|
||||||
import { PlusIcon } from '@blocksuite/icons/rc';
|
|
||||||
import type { ReactElement } from 'react';
|
|
||||||
|
|
||||||
export const AddCollectionButton = ({
|
|
||||||
node,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
node: ReactElement | null;
|
|
||||||
onClick: () => void;
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<IconButton
|
|
||||||
data-testid="slider-bar-add-collection-button"
|
|
||||||
onClick={onClick}
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
<PlusIcon />
|
|
||||||
</IconButton>
|
|
||||||
{node}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
-327
@@ -1,327 +0,0 @@
|
|||||||
import {
|
|
||||||
AnimatedCollectionsIcon,
|
|
||||||
toast,
|
|
||||||
useConfirmModal,
|
|
||||||
} from '@affine/component';
|
|
||||||
import { RenameModal } from '@affine/component/rename-modal';
|
|
||||||
import { Button, IconButton } from '@affine/component/ui/button';
|
|
||||||
import { usePageHelper } from '@affine/core/components/blocksuite/block-suite-page-list/utils';
|
|
||||||
import {
|
|
||||||
CollectionOperations,
|
|
||||||
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 { FavoriteItemsAdapter } from '@affine/core/modules/properties';
|
|
||||||
import type { Collection } from '@affine/env/filter';
|
|
||||||
import { useI18n } from '@affine/i18n';
|
|
||||||
import {
|
|
||||||
MoreHorizontalIcon,
|
|
||||||
PlusIcon,
|
|
||||||
ViewLayersIcon,
|
|
||||||
} from '@blocksuite/icons/rc';
|
|
||||||
import type { DocCollection } from '@blocksuite/store';
|
|
||||||
import { type AnimateLayoutChanges, useSortable } from '@dnd-kit/sortable';
|
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
|
||||||
import { useLiveData, useService } from '@toeverything/infra';
|
|
||||||
import { useCallback, useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
import { useAllPageListConfig } from '../../../../hooks/affine/use-all-page-list-config';
|
|
||||||
import { useBlockSuiteDocMeta } from '../../../../hooks/use-block-suite-page-meta';
|
|
||||||
import { WorkbenchService } from '../../../../modules/workbench';
|
|
||||||
import { WorkbenchLink } from '../../../../modules/workbench/view/workbench-link';
|
|
||||||
import { DragMenuItemOverlay } from '../components/drag-menu-item-overlay';
|
|
||||||
import * as draggableMenuItemStyles from '../components/draggable-menu-item.css';
|
|
||||||
import { SidebarDocItem } from '../doc-tree/doc';
|
|
||||||
import { SidebarDocTreeNode } from '../doc-tree/node';
|
|
||||||
import type { CollectionsListProps } from '../index';
|
|
||||||
import * as styles from './styles.css';
|
|
||||||
|
|
||||||
const animateLayoutChanges: AnimateLayoutChanges = ({
|
|
||||||
isSorting,
|
|
||||||
wasDragging,
|
|
||||||
}) => (isSorting || wasDragging ? false : true);
|
|
||||||
|
|
||||||
export const CollectionSidebarNavItem = ({
|
|
||||||
collection,
|
|
||||||
docCollection,
|
|
||||||
className,
|
|
||||||
dndId,
|
|
||||||
}: {
|
|
||||||
collection: Collection;
|
|
||||||
docCollection: DocCollection;
|
|
||||||
dndId: DNDIdentifier;
|
|
||||||
className?: string;
|
|
||||||
}) => {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const collectionService = useService(CollectionService);
|
|
||||||
const { createPage } = usePageHelper(docCollection);
|
|
||||||
const { openConfirmModal } = useConfirmModal();
|
|
||||||
const t = useI18n();
|
|
||||||
|
|
||||||
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: {
|
|
||||||
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 currentPath = useLiveData(
|
|
||||||
useService(WorkbenchService).workbench.location$.map(
|
|
||||||
location => location.pathname
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const path = `/collection/${collection.id}`;
|
|
||||||
|
|
||||||
const onRename = useCallback(
|
|
||||||
(name: string) => {
|
|
||||||
collectionService.updateCollection(collection.id, () => ({
|
|
||||||
...collection,
|
|
||||||
name,
|
|
||||||
}));
|
|
||||||
toast(t['com.affine.toastMessage.rename']());
|
|
||||||
},
|
|
||||||
[collection, collectionService, t]
|
|
||||||
);
|
|
||||||
const handleOpen = useCallback(() => {
|
|
||||||
setOpen(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const createAndAddDocument = useCallback(() => {
|
|
||||||
const newDoc = createPage();
|
|
||||||
collectionService.addPageToCollection(collection.id, newDoc.id);
|
|
||||||
}, [collection.id, collectionService, createPage]);
|
|
||||||
|
|
||||||
const onConfirmAddDocToCollection = useCallback(() => {
|
|
||||||
openConfirmModal({
|
|
||||||
title: t['com.affine.collection.add-doc.confirm.title'](),
|
|
||||||
description: t['com.affine.collection.add-doc.confirm.description'](),
|
|
||||||
cancelText: t['Cancel'](),
|
|
||||||
confirmText: t['Confirm'](),
|
|
||||||
confirmButtonOptions: {
|
|
||||||
type: 'primary',
|
|
||||||
},
|
|
||||||
onConfirm: createAndAddDocument,
|
|
||||||
});
|
|
||||||
}, [createAndAddDocument, openConfirmModal, t]);
|
|
||||||
|
|
||||||
const postfix = (
|
|
||||||
<div
|
|
||||||
onClick={stopPropagation}
|
|
||||||
onMouseDown={e => {
|
|
||||||
// prevent drag
|
|
||||||
e.stopPropagation();
|
|
||||||
}}
|
|
||||||
style={{ display: 'flex', alignItems: 'center' }}
|
|
||||||
>
|
|
||||||
<IconButton onClick={onConfirmAddDocToCollection} size="small">
|
|
||||||
<PlusIcon />
|
|
||||||
</IconButton>
|
|
||||||
<CollectionOperations
|
|
||||||
collection={collection}
|
|
||||||
openRenameModal={handleOpen}
|
|
||||||
onAddDocToCollection={onConfirmAddDocToCollection}
|
|
||||||
>
|
|
||||||
<IconButton
|
|
||||||
data-testid="collection-options"
|
|
||||||
type="plain"
|
|
||||||
size="small"
|
|
||||||
style={{ marginLeft: 4 }}
|
|
||||||
>
|
|
||||||
<MoreHorizontalIcon />
|
|
||||||
</IconButton>
|
|
||||||
</CollectionOperations>
|
|
||||||
<RenameModal
|
|
||||||
open={open}
|
|
||||||
onOpenChange={setOpen}
|
|
||||||
onRename={onRename}
|
|
||||||
currentName={collection.name}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SidebarDocTreeNode
|
|
||||||
ref={setNodeRef}
|
|
||||||
node={{ type: 'collection', data: collection }}
|
|
||||||
to={path}
|
|
||||||
linkComponent={WorkbenchLink}
|
|
||||||
subTree={
|
|
||||||
<CollectionSidebarNavItemContent
|
|
||||||
collection={collection}
|
|
||||||
docCollection={docCollection}
|
|
||||||
dndId={dndId}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
rootProps={{
|
|
||||||
className,
|
|
||||||
style,
|
|
||||||
...attributes,
|
|
||||||
}}
|
|
||||||
menuItemProps={{
|
|
||||||
...listeners,
|
|
||||||
'data-draggable': true,
|
|
||||||
'data-dragging': isDragging,
|
|
||||||
'data-testid': 'collection-item',
|
|
||||||
'data-collection-id': collection.id,
|
|
||||||
'data-type': 'collection-list-item',
|
|
||||||
className: draggableMenuItemStyles.draggableMenuItem,
|
|
||||||
active: isOver || currentPath === path,
|
|
||||||
icon: <AnimatedCollectionsIcon closed={isOver} />,
|
|
||||||
postfix,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span>{collection.name}</span>
|
|
||||||
</SidebarDocTreeNode>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const CollectionSidebarNavItemContent = ({
|
|
||||||
collection,
|
|
||||||
docCollection,
|
|
||||||
dndId,
|
|
||||||
}: {
|
|
||||||
collection: Collection;
|
|
||||||
docCollection: DocCollection;
|
|
||||||
dndId: DNDIdentifier;
|
|
||||||
}) => {
|
|
||||||
const t = useI18n();
|
|
||||||
const pages = useBlockSuiteDocMeta(docCollection);
|
|
||||||
const favAdapter = useService(FavoriteItemsAdapter);
|
|
||||||
const collectionService = useService(CollectionService);
|
|
||||||
|
|
||||||
const config = useAllPageListConfig();
|
|
||||||
const favourites = useLiveData(favAdapter.favorites$);
|
|
||||||
const allowList = useMemo(
|
|
||||||
() => new Set(collection.allowList),
|
|
||||||
[collection.allowList]
|
|
||||||
);
|
|
||||||
const removeFromAllowList = useCallback(
|
|
||||||
(id: string) => {
|
|
||||||
collectionService.deletePageFromCollection(collection.id, id);
|
|
||||||
toast(t['com.affine.collection.removePage.success']());
|
|
||||||
},
|
|
||||||
[collection, collectionService, t]
|
|
||||||
);
|
|
||||||
|
|
||||||
const filtered = pages.filter(meta => {
|
|
||||||
if (meta.trash) return false;
|
|
||||||
const pageData = {
|
|
||||||
meta,
|
|
||||||
publicMode: config.getPublicMode(meta.id),
|
|
||||||
favorite: favourites.some(fav => fav.id === meta.id),
|
|
||||||
};
|
|
||||||
return filterPage(collection, pageData);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.docsListContainer}>
|
|
||||||
{filtered.length > 0 ? (
|
|
||||||
filtered.map(page => {
|
|
||||||
return (
|
|
||||||
<SidebarDocItem
|
|
||||||
key={page.id}
|
|
||||||
docId={page.id}
|
|
||||||
postfixConfig={{
|
|
||||||
inAllowList: allowList.has(page.id),
|
|
||||||
removeFromAllowList: removeFromAllowList,
|
|
||||||
}}
|
|
||||||
dragConfig={{
|
|
||||||
parentId: dndId,
|
|
||||||
where: 'collection-list',
|
|
||||||
}}
|
|
||||||
menuItemProps={{
|
|
||||||
'data-testid': 'collection-page',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<div className={styles.noReferences}>
|
|
||||||
{t['com.affine.collection.emptyCollection']()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const CollectionsList = ({
|
|
||||||
docCollection: workspace,
|
|
||||||
onCreate,
|
|
||||||
}: CollectionsListProps) => {
|
|
||||||
const collections = useLiveData(useService(CollectionService).collections$);
|
|
||||||
const t = useI18n();
|
|
||||||
|
|
||||||
if (collections.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className={styles.emptyCollectionWrapper}>
|
|
||||||
<div className={styles.emptyCollectionContent}>
|
|
||||||
<div className={styles.emptyCollectionIconWrapper}>
|
|
||||||
<ViewLayersIcon className={styles.emptyCollectionIcon} />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
data-testid="slider-bar-collection-null-description"
|
|
||||||
className={styles.emptyCollectionMessage}
|
|
||||||
>
|
|
||||||
{t['com.affine.collections.empty.message']()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button className={styles.emptyCollectionNewButton} onClick={onCreate}>
|
|
||||||
{t['com.affine.collections.empty.new-collection-button']()}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div data-testid="collections" className={styles.wrapper}>
|
|
||||||
{collections.map(view => {
|
|
||||||
const dragItemId = getDNDId(
|
|
||||||
'sidebar-collections',
|
|
||||||
'collection',
|
|
||||||
view.id
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CollectionSidebarNavItem
|
|
||||||
key={view.id}
|
|
||||||
collection={view}
|
|
||||||
docCollection={workspace}
|
|
||||||
dndId={dragItemId}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from './collections-list';
|
|
||||||
-111
@@ -1,111 +0,0 @@
|
|||||||
import { cssVar } from '@toeverything/theme';
|
|
||||||
import { globalStyle, style } from '@vanilla-extract/css';
|
|
||||||
export const wrapper = style({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: 2,
|
|
||||||
userSelect: 'none',
|
|
||||||
// marginLeft:8,
|
|
||||||
});
|
|
||||||
export const collapsedIcon = style({
|
|
||||||
transition: 'transform 0.2s ease-in-out',
|
|
||||||
selectors: {
|
|
||||||
'&[data-collapsed="true"]': {
|
|
||||||
transform: 'rotate(-90deg)',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
export const view = style({
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
});
|
|
||||||
export const viewTitle = style({
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
});
|
|
||||||
export const more = style({
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
borderRadius: 2,
|
|
||||||
fontSize: 16,
|
|
||||||
color: cssVar('iconColor'),
|
|
||||||
':hover': {
|
|
||||||
backgroundColor: cssVar('hoverColor'),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
export const deleteFolder = style({
|
|
||||||
':hover': {
|
|
||||||
color: cssVar('errorColor'),
|
|
||||||
backgroundColor: cssVar('backgroundErrorColor'),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
globalStyle(`${deleteFolder}:hover svg`, {
|
|
||||||
color: cssVar('errorColor'),
|
|
||||||
});
|
|
||||||
export const menuDividerStyle = style({
|
|
||||||
marginTop: '2px',
|
|
||||||
marginBottom: '2px',
|
|
||||||
marginLeft: '12px',
|
|
||||||
marginRight: '8px',
|
|
||||||
height: '1px',
|
|
||||||
background: cssVar('borderColor'),
|
|
||||||
});
|
|
||||||
export const collapsibleContent = style({
|
|
||||||
overflow: 'hidden',
|
|
||||||
marginTop: '4px',
|
|
||||||
selectors: {
|
|
||||||
'&[data-hidden="true"]': {
|
|
||||||
display: 'none',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
export const emptyCollectionWrapper = style({
|
|
||||||
padding: '9px 0',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 8,
|
|
||||||
});
|
|
||||||
export const emptyCollectionContent = style({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 6,
|
|
||||||
});
|
|
||||||
export const emptyCollectionIconWrapper = style({
|
|
||||||
width: 36,
|
|
||||||
height: 36,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
borderRadius: '50%',
|
|
||||||
backgroundColor: cssVar('hoverColor'),
|
|
||||||
});
|
|
||||||
export const emptyCollectionIcon = style({
|
|
||||||
fontSize: 20,
|
|
||||||
color: cssVar('iconSecondary'),
|
|
||||||
});
|
|
||||||
export const emptyCollectionMessage = style({
|
|
||||||
fontSize: cssVar('fontSm'),
|
|
||||||
textAlign: 'center',
|
|
||||||
color: cssVar('black30'),
|
|
||||||
userSelect: 'none',
|
|
||||||
});
|
|
||||||
export const emptyCollectionNewButton = style({
|
|
||||||
padding: '0 8px',
|
|
||||||
height: '28px',
|
|
||||||
fontSize: cssVar('fontXs'),
|
|
||||||
});
|
|
||||||
export const docsListContainer = style({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: 2,
|
|
||||||
});
|
|
||||||
export const noReferences = style({
|
|
||||||
fontSize: cssVar('fontSm'),
|
|
||||||
textAlign: 'left',
|
|
||||||
paddingLeft: '32px',
|
|
||||||
color: cssVar('black30'),
|
|
||||||
userSelect: 'none',
|
|
||||||
});
|
|
||||||
-16
@@ -1,16 +0,0 @@
|
|||||||
import * as styles from '../favorite/styles.css';
|
|
||||||
|
|
||||||
export const DragMenuItemOverlay = ({
|
|
||||||
title,
|
|
||||||
icon,
|
|
||||||
}: {
|
|
||||||
icon: React.ReactNode;
|
|
||||||
title: React.ReactNode;
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<div className={styles.dragPageItemOverlay}>
|
|
||||||
{icon}
|
|
||||||
<span>{title}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
-33
@@ -1,33 +0,0 @@
|
|||||||
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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
-182
@@ -1,182 +0,0 @@
|
|||||||
import type { MenuItemProps } from '@affine/component';
|
|
||||||
import { MenuIcon, MenuItem, MenuSeparator } from '@affine/component';
|
|
||||||
import { useAppSettingHelper } from '@affine/core/hooks/affine/use-app-setting-helper';
|
|
||||||
import { useI18n } from '@affine/i18n';
|
|
||||||
import {
|
|
||||||
DeleteIcon,
|
|
||||||
EditIcon,
|
|
||||||
FavoriteIcon,
|
|
||||||
FilterMinusIcon,
|
|
||||||
InformationIcon,
|
|
||||||
LinkedPageIcon,
|
|
||||||
SplitViewIcon,
|
|
||||||
} from '@blocksuite/icons/rc';
|
|
||||||
import type { ReactElement } from 'react';
|
|
||||||
import { useMemo } from 'react';
|
|
||||||
|
|
||||||
type OperationItemsProps = {
|
|
||||||
inFavorites?: boolean;
|
|
||||||
isReferencePage?: boolean;
|
|
||||||
inAllowList?: boolean;
|
|
||||||
onRemoveFromAllowList?: () => void;
|
|
||||||
setRenameModalOpen?: () => void;
|
|
||||||
onRename: () => void;
|
|
||||||
onAddLinkedPage: () => void;
|
|
||||||
onRemoveFromFavourites?: () => void;
|
|
||||||
onDelete: () => void;
|
|
||||||
onOpenInSplitView: () => void;
|
|
||||||
onOpenInfoModal: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const OperationItems = ({
|
|
||||||
inFavorites,
|
|
||||||
isReferencePage,
|
|
||||||
inAllowList,
|
|
||||||
onRemoveFromAllowList,
|
|
||||||
onRename,
|
|
||||||
onAddLinkedPage,
|
|
||||||
onRemoveFromFavourites,
|
|
||||||
onDelete,
|
|
||||||
onOpenInSplitView,
|
|
||||||
onOpenInfoModal,
|
|
||||||
}: OperationItemsProps) => {
|
|
||||||
const { appSettings } = useAppSettingHelper();
|
|
||||||
const t = useI18n();
|
|
||||||
const actions = useMemo<
|
|
||||||
Array<
|
|
||||||
| {
|
|
||||||
icon: ReactElement;
|
|
||||||
name: string;
|
|
||||||
click: () => void;
|
|
||||||
type?: MenuItemProps['type'];
|
|
||||||
element?: undefined;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
element: ReactElement;
|
|
||||||
}
|
|
||||||
>
|
|
||||||
>(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
icon: (
|
|
||||||
<MenuIcon>
|
|
||||||
<EditIcon />
|
|
||||||
</MenuIcon>
|
|
||||||
),
|
|
||||||
name: t['Rename'](),
|
|
||||||
click: onRename,
|
|
||||||
},
|
|
||||||
...(runtimeConfig.enableInfoModal
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
icon: (
|
|
||||||
<MenuIcon>
|
|
||||||
<InformationIcon />
|
|
||||||
</MenuIcon>
|
|
||||||
),
|
|
||||||
name: t['com.affine.page-properties.page-info.view'](),
|
|
||||||
click: onOpenInfoModal,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
{
|
|
||||||
icon: (
|
|
||||||
<MenuIcon>
|
|
||||||
<LinkedPageIcon />
|
|
||||||
</MenuIcon>
|
|
||||||
),
|
|
||||||
name: t['com.affine.page-operation.add-linked-page'](),
|
|
||||||
click: onAddLinkedPage,
|
|
||||||
},
|
|
||||||
...(inFavorites && onRemoveFromFavourites && !isReferencePage
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
icon: (
|
|
||||||
<MenuIcon>
|
|
||||||
<FavoriteIcon />
|
|
||||||
</MenuIcon>
|
|
||||||
),
|
|
||||||
name: t['Remove from favorites'](),
|
|
||||||
click: onRemoveFromFavourites,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
...(inAllowList && onRemoveFromAllowList
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
icon: (
|
|
||||||
<MenuIcon>
|
|
||||||
<FilterMinusIcon />
|
|
||||||
</MenuIcon>
|
|
||||||
),
|
|
||||||
name: t['Remove special filter'](),
|
|
||||||
click: onRemoveFromAllowList,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
|
|
||||||
...(appSettings.enableMultiView
|
|
||||||
? [
|
|
||||||
// open split view
|
|
||||||
{
|
|
||||||
icon: (
|
|
||||||
<MenuIcon>
|
|
||||||
<SplitViewIcon />
|
|
||||||
</MenuIcon>
|
|
||||||
),
|
|
||||||
name: t['com.affine.workbench.split-view.page-menu-open'](),
|
|
||||||
click: onOpenInSplitView,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
|
|
||||||
{
|
|
||||||
element: <MenuSeparator key="menu-separator" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: (
|
|
||||||
<MenuIcon>
|
|
||||||
<DeleteIcon />
|
|
||||||
</MenuIcon>
|
|
||||||
),
|
|
||||||
name: t['com.affine.moveToTrash.title'](),
|
|
||||||
click: onDelete,
|
|
||||||
type: 'danger',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
|
||||||
t,
|
|
||||||
onRename,
|
|
||||||
onAddLinkedPage,
|
|
||||||
inFavorites,
|
|
||||||
onRemoveFromFavourites,
|
|
||||||
isReferencePage,
|
|
||||||
inAllowList,
|
|
||||||
onRemoveFromAllowList,
|
|
||||||
appSettings.enableMultiView,
|
|
||||||
onOpenInSplitView,
|
|
||||||
onOpenInfoModal,
|
|
||||||
onDelete,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{actions.map(action => {
|
|
||||||
if (action.element) {
|
|
||||||
return action.element;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<MenuItem
|
|
||||||
data-testid="sidebar-page-option-item"
|
|
||||||
key={action.name}
|
|
||||||
type={action.type}
|
|
||||||
preFix={action.icon}
|
|
||||||
onClick={action.click}
|
|
||||||
>
|
|
||||||
{action.name}
|
|
||||||
</MenuItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
-124
@@ -1,124 +0,0 @@
|
|||||||
import { toast } from '@affine/component';
|
|
||||||
import { IconButton } from '@affine/component/ui/button';
|
|
||||||
import { Menu } from '@affine/component/ui/menu';
|
|
||||||
import { InfoModal } from '@affine/core/components/affine/page-properties';
|
|
||||||
import { FavoriteItemsAdapter } from '@affine/core/modules/properties';
|
|
||||||
import { WorkbenchService } from '@affine/core/modules/workbench';
|
|
||||||
import { useI18n } from '@affine/i18n';
|
|
||||||
import { MoreHorizontalIcon } from '@blocksuite/icons/rc';
|
|
||||||
import { useService, useServices, WorkspaceService } from '@toeverything/infra';
|
|
||||||
import { useCallback, useState } from 'react';
|
|
||||||
|
|
||||||
import { useTrashModalHelper } from '../../../../hooks/affine/use-trash-modal-helper';
|
|
||||||
import { usePageHelper } from '../../../blocksuite/block-suite-page-list/utils';
|
|
||||||
import { OperationItems } from './operation-item';
|
|
||||||
|
|
||||||
export type OperationMenuButtonProps = {
|
|
||||||
pageId: string;
|
|
||||||
pageTitle: string;
|
|
||||||
setRenameModalOpen: () => void;
|
|
||||||
inFavorites?: boolean;
|
|
||||||
isReferencePage?: boolean;
|
|
||||||
inAllowList?: boolean;
|
|
||||||
removeFromAllowList?: (id: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const OperationMenuButton = ({ ...props }: OperationMenuButtonProps) => {
|
|
||||||
const {
|
|
||||||
pageId,
|
|
||||||
pageTitle,
|
|
||||||
setRenameModalOpen,
|
|
||||||
removeFromAllowList,
|
|
||||||
inAllowList,
|
|
||||||
inFavorites,
|
|
||||||
isReferencePage,
|
|
||||||
} = props;
|
|
||||||
const t = useI18n();
|
|
||||||
const [openInfoModal, setOpenInfoModal] = useState(false);
|
|
||||||
|
|
||||||
const { workspaceService } = useServices({
|
|
||||||
WorkspaceService,
|
|
||||||
});
|
|
||||||
const page = workspaceService.workspace.docCollection.getDoc(pageId);
|
|
||||||
const { createLinkedPage } = usePageHelper(
|
|
||||||
workspaceService.workspace.docCollection
|
|
||||||
);
|
|
||||||
const { setTrashModal } = useTrashModalHelper(
|
|
||||||
workspaceService.workspace.docCollection
|
|
||||||
);
|
|
||||||
|
|
||||||
const favAdapter = useService(FavoriteItemsAdapter);
|
|
||||||
const workbench = useService(WorkbenchService).workbench;
|
|
||||||
|
|
||||||
const handleRename = useCallback(() => {
|
|
||||||
setRenameModalOpen?.();
|
|
||||||
}, [setRenameModalOpen]);
|
|
||||||
|
|
||||||
const handleAddLinkedPage = useCallback(() => {
|
|
||||||
createLinkedPage(pageId);
|
|
||||||
toast(t['com.affine.toastMessage.addLinkedPage']());
|
|
||||||
}, [createLinkedPage, pageId, t]);
|
|
||||||
|
|
||||||
const handleRemoveFromFavourites = useCallback(() => {
|
|
||||||
favAdapter.remove(pageId, 'doc');
|
|
||||||
toast(t['com.affine.toastMessage.removedFavorites']());
|
|
||||||
}, [favAdapter, pageId, t]);
|
|
||||||
|
|
||||||
const handleDelete = useCallback(() => {
|
|
||||||
setTrashModal({
|
|
||||||
open: true,
|
|
||||||
pageIds: [pageId],
|
|
||||||
pageTitles: [pageTitle],
|
|
||||||
});
|
|
||||||
}, [pageId, pageTitle, setTrashModal]);
|
|
||||||
|
|
||||||
const handleRemoveFromAllowList = useCallback(() => {
|
|
||||||
removeFromAllowList?.(pageId);
|
|
||||||
}, [pageId, removeFromAllowList]);
|
|
||||||
|
|
||||||
const handleOpenInSplitView = useCallback(() => {
|
|
||||||
workbench.openDoc(pageId, { at: 'tail' });
|
|
||||||
}, [pageId, workbench]);
|
|
||||||
|
|
||||||
const handleOpenInfoModal = useCallback(() => {
|
|
||||||
setOpenInfoModal(true);
|
|
||||||
}, [setOpenInfoModal]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Menu
|
|
||||||
items={
|
|
||||||
<OperationItems
|
|
||||||
onAddLinkedPage={handleAddLinkedPage}
|
|
||||||
onDelete={handleDelete}
|
|
||||||
onRemoveFromAllowList={handleRemoveFromAllowList}
|
|
||||||
onRemoveFromFavourites={handleRemoveFromFavourites}
|
|
||||||
onRename={handleRename}
|
|
||||||
onOpenInSplitView={handleOpenInSplitView}
|
|
||||||
onOpenInfoModal={handleOpenInfoModal}
|
|
||||||
inAllowList={inAllowList}
|
|
||||||
inFavorites={inFavorites}
|
|
||||||
isReferencePage={isReferencePage}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
type="plain"
|
|
||||||
data-testid="left-sidebar-page-operation-button"
|
|
||||||
style={{ marginLeft: 4 }}
|
|
||||||
>
|
|
||||||
<MoreHorizontalIcon />
|
|
||||||
</IconButton>
|
|
||||||
</Menu>
|
|
||||||
{page ? (
|
|
||||||
<InfoModal
|
|
||||||
open={openInfoModal}
|
|
||||||
onOpenChange={setOpenInfoModal}
|
|
||||||
page={page}
|
|
||||||
workspace={workspaceService.workspace}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
-69
@@ -1,69 +0,0 @@
|
|||||||
import { toast } from '@affine/component';
|
|
||||||
import { RenameModal } from '@affine/component/rename-modal';
|
|
||||||
import { useDocMetaHelper } from '@affine/core/hooks/use-block-suite-page-meta';
|
|
||||||
import { useI18n } from '@affine/i18n';
|
|
||||||
import { useServices, WorkspaceService } from '@toeverything/infra';
|
|
||||||
import { useCallback, useState } from 'react';
|
|
||||||
|
|
||||||
import { AddFavouriteButton } from '../favorite/add-favourite-button';
|
|
||||||
import * as styles from '../favorite/styles.css';
|
|
||||||
import { OperationMenuButton } from './operation-menu-button';
|
|
||||||
|
|
||||||
export type PostfixItemProps = {
|
|
||||||
pageId: string;
|
|
||||||
pageTitle: string;
|
|
||||||
inFavorites?: boolean;
|
|
||||||
isReferencePage?: boolean;
|
|
||||||
inAllowList?: boolean;
|
|
||||||
removeFromAllowList?: (id: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PostfixItem = ({ ...props }: PostfixItemProps) => {
|
|
||||||
const { pageId, pageTitle } = props;
|
|
||||||
const t = useI18n();
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const { workspaceService } = useServices({
|
|
||||||
WorkspaceService,
|
|
||||||
});
|
|
||||||
const { setDocTitle } = useDocMetaHelper(
|
|
||||||
workspaceService.workspace.docCollection
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleRename = useCallback(
|
|
||||||
(newName: string) => {
|
|
||||||
setDocTitle(pageId, newName);
|
|
||||||
setOpen(false);
|
|
||||||
toast(t['com.affine.toastMessage.rename']());
|
|
||||||
},
|
|
||||||
[pageId, setDocTitle, t]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={styles.favoritePostfixItem}
|
|
||||||
onMouseDown={e => {
|
|
||||||
// prevent drag
|
|
||||||
e.stopPropagation();
|
|
||||||
}}
|
|
||||||
onClick={e => {
|
|
||||||
// prevent jump to page
|
|
||||||
e.stopPropagation();
|
|
||||||
e.preventDefault();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AddFavouriteButton {...props} />
|
|
||||||
<OperationMenuButton
|
|
||||||
setRenameModalOpen={() => {
|
|
||||||
setOpen(true);
|
|
||||||
}}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
<RenameModal
|
|
||||||
open={open}
|
|
||||||
onOpenChange={setOpen}
|
|
||||||
onRename={handleRename}
|
|
||||||
currentName={pageTitle}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import { cssVar } from '@toeverything/theme';
|
|
||||||
import { globalStyle, style } from '@vanilla-extract/css';
|
|
||||||
|
|
||||||
export const title = style({
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
});
|
|
||||||
|
|
||||||
globalStyle(`[data-draggable=true] ${title}: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] ${title}:hover:before`, {
|
|
||||||
height: 12,
|
|
||||||
opacity: 1,
|
|
||||||
});
|
|
||||||
globalStyle(`[data-draggable=true][data-dragging=true] ${title}`, {
|
|
||||||
opacity: 0.5,
|
|
||||||
});
|
|
||||||
globalStyle(`[data-draggable=true][data-dragging=true] ${title}:before`, {
|
|
||||||
height: 32,
|
|
||||||
width: 2,
|
|
||||||
opacity: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const label = style({
|
|
||||||
selectors: {
|
|
||||||
'&[data-untitled="true"]': {
|
|
||||||
opacity: 0.6,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
export const labelContainer = style({
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
});
|
|
||||||
export const labelTooltipContainer = style({
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const noReferences = style({
|
|
||||||
fontSize: cssVar('fontSm'),
|
|
||||||
textAlign: 'left',
|
|
||||||
paddingLeft: '32px',
|
|
||||||
color: cssVar('black30'),
|
|
||||||
userSelect: 'none',
|
|
||||||
});
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
import { Loading, Tooltip } from '@affine/component';
|
|
||||||
import type { MenuItemProps } from '@affine/core/components/app-sidebar';
|
|
||||||
import {
|
|
||||||
type DNDIdentifier,
|
|
||||||
type DndWhere,
|
|
||||||
getDNDId,
|
|
||||||
} from '@affine/core/hooks/affine/use-global-dnd-helper';
|
|
||||||
import { DocsSearchService } from '@affine/core/modules/docs-search';
|
|
||||||
import {
|
|
||||||
WorkbenchLink,
|
|
||||||
WorkbenchService,
|
|
||||||
} from '@affine/core/modules/workbench';
|
|
||||||
import { useI18n } from '@affine/i18n';
|
|
||||||
import { EdgelessIcon, PageIcon } from '@blocksuite/icons/rc';
|
|
||||||
import { useDraggable } from '@dnd-kit/core';
|
|
||||||
import {
|
|
||||||
DocsService,
|
|
||||||
LiveData,
|
|
||||||
useLiveData,
|
|
||||||
useServices,
|
|
||||||
} from '@toeverything/infra';
|
|
||||||
import { nanoid } from 'nanoid';
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
import { DragMenuItemOverlay } from '../components/drag-menu-item-overlay';
|
|
||||||
import { PostfixItem, type PostfixItemProps } from '../components/postfix-item';
|
|
||||||
import * as styles from './doc.css';
|
|
||||||
import { SidebarDocTreeNode } from './node';
|
|
||||||
|
|
||||||
export type SidebarDocItemProps = {
|
|
||||||
docId: string;
|
|
||||||
postfixConfig?: Omit<
|
|
||||||
PostfixItemProps,
|
|
||||||
'pageId' | 'pageTitle' | 'isReferencePage'
|
|
||||||
>;
|
|
||||||
isReference?: boolean;
|
|
||||||
dragConfig?: {
|
|
||||||
parentId?: DNDIdentifier;
|
|
||||||
where: DndWhere;
|
|
||||||
};
|
|
||||||
menuItemProps?: Partial<MenuItemProps> & Record<`data-${string}`, string>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SidebarDocItem = function SidebarDocItem({
|
|
||||||
docId,
|
|
||||||
postfixConfig,
|
|
||||||
isReference,
|
|
||||||
dragConfig,
|
|
||||||
menuItemProps,
|
|
||||||
}: SidebarDocItemProps) {
|
|
||||||
const { docsSearchService, workbenchService, docsService } = useServices({
|
|
||||||
DocsSearchService,
|
|
||||||
WorkbenchService,
|
|
||||||
DocsService,
|
|
||||||
});
|
|
||||||
const t = useI18n();
|
|
||||||
const location = useLiveData(workbenchService.workbench.location$);
|
|
||||||
const active = location.pathname === '/' + docId;
|
|
||||||
|
|
||||||
const docRecord = useLiveData(docsService.list.doc$(docId));
|
|
||||||
const docMode = useLiveData(docRecord?.mode$);
|
|
||||||
const docTitle = useLiveData(docRecord?.title$);
|
|
||||||
const icon = useMemo(() => {
|
|
||||||
return docMode === 'edgeless' ? <EdgelessIcon /> : <PageIcon />;
|
|
||||||
}, [docMode]);
|
|
||||||
|
|
||||||
const references = useLiveData(
|
|
||||||
useMemo(
|
|
||||||
() => LiveData.from(docsSearchService.watchRefsFrom(docId), null),
|
|
||||||
[docsSearchService, docId]
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const indexerLoading = useLiveData(
|
|
||||||
docsSearchService.indexer.status$.map(
|
|
||||||
v => v.remaining === undefined || v.remaining > 0
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const [referencesLoading, setReferencesLoading] = useState(true);
|
|
||||||
useEffect(() => {
|
|
||||||
setReferencesLoading(
|
|
||||||
prev =>
|
|
||||||
prev &&
|
|
||||||
indexerLoading /* after loading becomes false, it never becomes true */
|
|
||||||
);
|
|
||||||
}, [indexerLoading]);
|
|
||||||
const untitled = !docTitle;
|
|
||||||
const title = docTitle || t['Untitled']();
|
|
||||||
|
|
||||||
// drag (not available for sub-docs)
|
|
||||||
const dragItemId = dragConfig
|
|
||||||
? getDNDId(dragConfig.where, 'doc', docId, dragConfig.parentId)
|
|
||||||
: nanoid();
|
|
||||||
const docTitleElement = useMemo(() => {
|
|
||||||
return <DragMenuItemOverlay icon={icon} title={docTitle} />;
|
|
||||||
}, [icon, docTitle]);
|
|
||||||
const { setNodeRef, attributes, listeners, isDragging } = useDraggable({
|
|
||||||
id: dragItemId,
|
|
||||||
data: { preview: docTitleElement },
|
|
||||||
disabled: !dragConfig || isReference,
|
|
||||||
});
|
|
||||||
|
|
||||||
const dragAttrs: Partial<MenuItemProps> = isReference
|
|
||||||
? {
|
|
||||||
// prevent dragging parent node
|
|
||||||
onMouseDown: e => e.stopPropagation(),
|
|
||||||
}
|
|
||||||
: { ...attributes, ...listeners };
|
|
||||||
|
|
||||||
// workaround to avoid invisible in playwright caused by nested drag
|
|
||||||
delete dragAttrs['aria-disabled'];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SidebarDocTreeNode
|
|
||||||
ref={setNodeRef}
|
|
||||||
rootProps={{ 'data-dragging': isDragging }}
|
|
||||||
node={{ type: 'doc', data: docId }}
|
|
||||||
to={`/${docId}`}
|
|
||||||
linkComponent={WorkbenchLink}
|
|
||||||
menuItemProps={{
|
|
||||||
'data-type': isReference ? 'reference-page' : undefined,
|
|
||||||
icon,
|
|
||||||
active,
|
|
||||||
className: styles.title,
|
|
||||||
postfix: (
|
|
||||||
<PostfixItem
|
|
||||||
pageId={docId}
|
|
||||||
pageTitle={title}
|
|
||||||
isReferencePage={isReference}
|
|
||||||
{...postfixConfig}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
...dragAttrs,
|
|
||||||
...menuItemProps,
|
|
||||||
}}
|
|
||||||
subTree={
|
|
||||||
references ? (
|
|
||||||
references.length > 0 ? (
|
|
||||||
references.map(({ docId: childDocId }) => {
|
|
||||||
return (
|
|
||||||
<SidebarDocItem
|
|
||||||
key={childDocId}
|
|
||||||
docId={childDocId}
|
|
||||||
isReference={true}
|
|
||||||
menuItemProps={{
|
|
||||||
'data-testid': `reference-page-${childDocId}`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<div className={styles.noReferences}>
|
|
||||||
{t['com.affine.rootAppSidebar.docs.no-subdoc']()}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className={styles.labelContainer}>
|
|
||||||
<span className={styles.label} data-untitled={untitled}>
|
|
||||||
{title || t['Untitled']()}
|
|
||||||
</span>
|
|
||||||
{referencesLoading && (
|
|
||||||
<Tooltip
|
|
||||||
content={t['com.affine.rootAppSidebar.docs.references-loading']()}
|
|
||||||
>
|
|
||||||
<div className={styles.labelTooltipContainer}>
|
|
||||||
<Loading />
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</SidebarDocTreeNode>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { style } from '@vanilla-extract/css';
|
|
||||||
|
|
||||||
export const collapseContent = style({
|
|
||||||
paddingTop: 2,
|
|
||||||
paddingLeft: 20,
|
|
||||||
});
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
import {
|
|
||||||
MenuItem,
|
|
||||||
type MenuItemProps,
|
|
||||||
MenuLinkItem,
|
|
||||||
} from '@affine/core/components/app-sidebar';
|
|
||||||
import type { Collection } from '@affine/env/filter';
|
|
||||||
import * as Collapsible from '@radix-ui/react-collapsible';
|
|
||||||
import {
|
|
||||||
createContext,
|
|
||||||
forwardRef,
|
|
||||||
type PropsWithChildren,
|
|
||||||
type ReactNode,
|
|
||||||
useContext,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
import { Link, type To } from 'react-router-dom';
|
|
||||||
|
|
||||||
import * as styles from './node.css';
|
|
||||||
|
|
||||||
type SidebarDocTreeNode =
|
|
||||||
| {
|
|
||||||
type: 'collection';
|
|
||||||
data: Collection;
|
|
||||||
}
|
|
||||||
// | { type: 'tag' }
|
|
||||||
// | { type: 'folder' }
|
|
||||||
| {
|
|
||||||
type: 'doc';
|
|
||||||
data: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SidebarDocTreeNodeProps = PropsWithChildren<{
|
|
||||||
node: SidebarDocTreeNode;
|
|
||||||
subTree?: ReactNode;
|
|
||||||
to?: To;
|
|
||||||
linkComponent?: React.ComponentType<{ to: To; className?: string }>;
|
|
||||||
|
|
||||||
menuItemProps?: MenuItemProps & Record<`data-${string}`, unknown>;
|
|
||||||
rootProps?: Collapsible.CollapsibleProps & Record<`data-${string}`, unknown>;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
type SidebarDocTreeNodeContext = {
|
|
||||||
ancestors: SidebarDocTreeNode[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const sidebarDocTreeContext =
|
|
||||||
createContext<SidebarDocTreeNodeContext | null>(null);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tree node for the sidebar doc/folder/tag/collection tree.
|
|
||||||
* This component is used to manage:
|
|
||||||
* - Collapsing state
|
|
||||||
* - Ancestors context
|
|
||||||
* - Link/Menu item rendering
|
|
||||||
* - Subtree indentation (left/top)
|
|
||||||
*/
|
|
||||||
export const SidebarDocTreeNode = forwardRef(function SidebarDocTreeNode(
|
|
||||||
{
|
|
||||||
node,
|
|
||||||
children,
|
|
||||||
subTree,
|
|
||||||
to,
|
|
||||||
linkComponent: LinkComponent = Link,
|
|
||||||
menuItemProps,
|
|
||||||
rootProps,
|
|
||||||
}: SidebarDocTreeNodeProps,
|
|
||||||
ref: React.Ref<HTMLDivElement>
|
|
||||||
) {
|
|
||||||
const [collapsed, setCollapsed] = useState(true);
|
|
||||||
const { ancestors } = useContext(sidebarDocTreeContext) ?? { ancestors: [] };
|
|
||||||
|
|
||||||
const finalMenuItemProps: SidebarDocTreeNodeProps['menuItemProps'] = {
|
|
||||||
...menuItemProps,
|
|
||||||
collapsed,
|
|
||||||
onCollapsedChange: setCollapsed,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<sidebarDocTreeContext.Provider value={{ ancestors: [...ancestors, node] }}>
|
|
||||||
<Collapsible.Root
|
|
||||||
{...rootProps}
|
|
||||||
ref={ref}
|
|
||||||
open={!collapsed}
|
|
||||||
onOpenChange={setCollapsed}
|
|
||||||
>
|
|
||||||
{to ? (
|
|
||||||
<MenuLinkItem
|
|
||||||
to={to}
|
|
||||||
linkComponent={LinkComponent}
|
|
||||||
{...finalMenuItemProps}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</MenuLinkItem>
|
|
||||||
) : (
|
|
||||||
<MenuItem {...finalMenuItemProps}>{children}</MenuItem>
|
|
||||||
)}
|
|
||||||
<Collapsible.Content className={styles.collapseContent}>
|
|
||||||
{collapsed ? null : subTree}
|
|
||||||
</Collapsible.Content>
|
|
||||||
</Collapsible.Root>
|
|
||||||
</sidebarDocTreeContext.Provider>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
-68
@@ -1,68 +0,0 @@
|
|||||||
import { IconButton } from '@affine/component/ui/button';
|
|
||||||
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
|
||||||
import { FavoriteItemsAdapter } from '@affine/core/modules/properties';
|
|
||||||
import { TelemetryWorkspaceContextService } from '@affine/core/modules/telemetry/services/telemetry';
|
|
||||||
import { mixpanel } from '@affine/core/utils';
|
|
||||||
import { PlusIcon } from '@blocksuite/icons/rc';
|
|
||||||
import { useService, useServices, WorkspaceService } from '@toeverything/infra';
|
|
||||||
|
|
||||||
import { usePageHelper } from '../../../blocksuite/block-suite-page-list/utils';
|
|
||||||
|
|
||||||
type AddFavouriteButtonProps = {
|
|
||||||
pageId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AddFavouriteButton = ({ pageId }: AddFavouriteButtonProps) => {
|
|
||||||
const { workspaceService } = useServices({
|
|
||||||
WorkspaceService,
|
|
||||||
});
|
|
||||||
const { createPage, createLinkedPage } = usePageHelper(
|
|
||||||
workspaceService.workspace.docCollection
|
|
||||||
);
|
|
||||||
const favAdapter = useService(FavoriteItemsAdapter);
|
|
||||||
const telemetry = useService(TelemetryWorkspaceContextService);
|
|
||||||
const handleAddFavorite = useAsyncCallback(
|
|
||||||
async e => {
|
|
||||||
const page = telemetry.getPageContext();
|
|
||||||
|
|
||||||
if (pageId) {
|
|
||||||
e.stopPropagation();
|
|
||||||
e.preventDefault();
|
|
||||||
createLinkedPage(pageId);
|
|
||||||
mixpanel.track('DocCreated', {
|
|
||||||
// page:
|
|
||||||
segment: 'all doc',
|
|
||||||
module: 'favorite',
|
|
||||||
control: 'new fav sub doc',
|
|
||||||
type: 'doc',
|
|
||||||
category: 'page',
|
|
||||||
page: page,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const page = createPage();
|
|
||||||
page.load();
|
|
||||||
favAdapter.set(page.id, 'doc', true);
|
|
||||||
mixpanel.track('DocCreated', {
|
|
||||||
// page:
|
|
||||||
segment: 'all doc',
|
|
||||||
module: 'favorite',
|
|
||||||
control: 'new fav doc',
|
|
||||||
type: 'doc',
|
|
||||||
category: 'page',
|
|
||||||
page: page,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[telemetry, pageId, createLinkedPage, createPage, favAdapter]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<IconButton
|
|
||||||
data-testid="slider-bar-add-favorite-button"
|
|
||||||
onClick={handleAddFavorite}
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
<PlusIcon />
|
|
||||||
</IconButton>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
-22
@@ -1,22 +0,0 @@
|
|||||||
import { useI18n } from '@affine/i18n';
|
|
||||||
import { FavoriteIcon } from '@blocksuite/icons/rc';
|
|
||||||
|
|
||||||
import * as styles from './styles.css';
|
|
||||||
export const EmptyItem = () => {
|
|
||||||
const t = useI18n();
|
|
||||||
return (
|
|
||||||
<div className={styles.emptyFavouritesContent}>
|
|
||||||
<div className={styles.emptyFavouritesIconWrapper}>
|
|
||||||
<FavoriteIcon className={styles.emptyFavouritesIcon} />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
data-testid="slider-bar-favourites-empty-message"
|
|
||||||
className={styles.emptyFavouritesMessage}
|
|
||||||
>
|
|
||||||
{t['com.affine.rootAppSidebar.favorites.empty']()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default EmptyItem;
|
|
||||||
-132
@@ -1,132 +0,0 @@
|
|||||||
import { CategoryDivider } from '@affine/core/components/app-sidebar';
|
|
||||||
import {
|
|
||||||
getDNDId,
|
|
||||||
resolveDragEndIntent,
|
|
||||||
} from '@affine/core/hooks/affine/use-global-dnd-helper';
|
|
||||||
import { CollectionService } from '@affine/core/modules/collection';
|
|
||||||
import { FavoriteItemsAdapter } from '@affine/core/modules/properties';
|
|
||||||
import type { WorkspaceFavoriteItem } from '@affine/core/modules/properties/services/schema';
|
|
||||||
import { useI18n } from '@affine/i18n';
|
|
||||||
import { useDndContext, useDroppable } from '@dnd-kit/core';
|
|
||||||
import {
|
|
||||||
SortableContext,
|
|
||||||
verticalListSortingStrategy,
|
|
||||||
} from '@dnd-kit/sortable';
|
|
||||||
import {
|
|
||||||
DocsService,
|
|
||||||
useLiveData,
|
|
||||||
useService,
|
|
||||||
useServices,
|
|
||||||
} from '@toeverything/infra';
|
|
||||||
import { Fragment, useCallback, useMemo } from 'react';
|
|
||||||
|
|
||||||
import { CollectionSidebarNavItem } from '../collections';
|
|
||||||
import type { FavoriteListProps } from '../index';
|
|
||||||
import { AddFavouriteButton } from './add-favourite-button';
|
|
||||||
import EmptyItem from './empty-item';
|
|
||||||
import { FavouriteDocSidebarNavItem } from './favourite-nav-item';
|
|
||||||
import * as styles from './styles.css';
|
|
||||||
|
|
||||||
const FavoriteListInner = ({ docCollection: workspace }: FavoriteListProps) => {
|
|
||||||
const { favoriteItemsAdapter, docsService, collectionService } = useServices({
|
|
||||||
FavoriteItemsAdapter,
|
|
||||||
DocsService,
|
|
||||||
CollectionService,
|
|
||||||
});
|
|
||||||
const collections = useLiveData(collectionService.collections$);
|
|
||||||
const docs = useLiveData(docsService.list.docs$);
|
|
||||||
const trashDocs = useLiveData(docsService.list.trashDocs$);
|
|
||||||
const dropItemId = getDNDId('sidebar-pin', 'container', workspace.id);
|
|
||||||
|
|
||||||
const favourites = useLiveData(
|
|
||||||
favoriteItemsAdapter.orderedFavorites$.map(favs => {
|
|
||||||
return favs.filter(fav => {
|
|
||||||
if (fav.type === 'doc') {
|
|
||||||
return (
|
|
||||||
docs.some(doc => doc.id === fav.id) &&
|
|
||||||
!trashDocs.some(doc => doc.id === fav.id)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
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') {
|
|
||||||
return (
|
|
||||||
<FavouriteDocSidebarNavItem
|
|
||||||
docId={item.id}
|
|
||||||
// memo?
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
[collections, workspace]
|
|
||||||
);
|
|
||||||
|
|
||||||
const t = useI18n();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={styles.favoriteList}
|
|
||||||
data-testid="favourites"
|
|
||||||
ref={setNodeRef}
|
|
||||||
data-over={shouldRenderDragOver}
|
|
||||||
>
|
|
||||||
<CategoryDivider label={t['com.affine.rootAppSidebar.favorites']()}>
|
|
||||||
<AddFavouriteButton />
|
|
||||||
</CategoryDivider>
|
|
||||||
{favourites.map(item => {
|
|
||||||
return <Fragment key={item.id}>{renderFavItem(item)}</Fragment>;
|
|
||||||
})}
|
|
||||||
{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;
|
|
||||||
-81
@@ -1,81 +0,0 @@
|
|||||||
import {
|
|
||||||
getDNDId,
|
|
||||||
parseDNDId,
|
|
||||||
} from '@affine/core/hooks/affine/use-global-dnd-helper';
|
|
||||||
import { useI18n } from '@affine/i18n';
|
|
||||||
import { EdgelessIcon, PageIcon } from '@blocksuite/icons/rc';
|
|
||||||
import { type AnimateLayoutChanges, useSortable } from '@dnd-kit/sortable';
|
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
|
||||||
import { DocsService, useLiveData, useService } from '@toeverything/infra';
|
|
||||||
import { useMemo } from 'react';
|
|
||||||
|
|
||||||
import { DragMenuItemOverlay } from '../components/drag-menu-item-overlay';
|
|
||||||
import { SidebarDocItem } from '../doc-tree/doc';
|
|
||||||
import * as styles from './styles.css';
|
|
||||||
|
|
||||||
const animateLayoutChanges: AnimateLayoutChanges = ({
|
|
||||||
isSorting,
|
|
||||||
wasDragging,
|
|
||||||
}) => (isSorting || wasDragging ? false : true);
|
|
||||||
|
|
||||||
export const FavouriteDocSidebarNavItem = ({ docId }: { docId: string }) => {
|
|
||||||
const t = useI18n();
|
|
||||||
const docsService = useService(DocsService);
|
|
||||||
const docRecord = useLiveData(docsService.list.doc$(docId));
|
|
||||||
const docMode = useLiveData(docRecord?.mode$);
|
|
||||||
const docTitle = useLiveData(docRecord?.title$);
|
|
||||||
const pageTitle = docTitle || t['Untitled']();
|
|
||||||
|
|
||||||
const icon = useMemo(() => {
|
|
||||||
return docMode === 'edgeless' ? <EdgelessIcon /> : <PageIcon />;
|
|
||||||
}, [docMode]);
|
|
||||||
|
|
||||||
const overlayPreview = useMemo(() => {
|
|
||||||
return <DragMenuItemOverlay icon={icon} title={pageTitle} />;
|
|
||||||
}, [icon, pageTitle]);
|
|
||||||
|
|
||||||
const dragItemId = getDNDId('sidebar-pin', 'doc', docId);
|
|
||||||
|
|
||||||
const {
|
|
||||||
setNodeRef,
|
|
||||||
isDragging,
|
|
||||||
attributes,
|
|
||||||
listeners,
|
|
||||||
transform,
|
|
||||||
transition,
|
|
||||||
active,
|
|
||||||
} = useSortable({
|
|
||||||
id: dragItemId,
|
|
||||||
data: {
|
|
||||||
preview: overlayPreview,
|
|
||||||
},
|
|
||||||
animateLayoutChanges,
|
|
||||||
});
|
|
||||||
|
|
||||||
const isSorting = parseDNDId(active?.id)?.where === 'sidebar-pin';
|
|
||||||
const style = {
|
|
||||||
transform: CSS.Translate.toString(transform),
|
|
||||||
transition: isSorting ? transition : undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={styles.favItemWrapper}
|
|
||||||
style={style}
|
|
||||||
ref={setNodeRef}
|
|
||||||
data-draggable={true}
|
|
||||||
data-dragging={isDragging}
|
|
||||||
data-testid={`favourite-page-${docId}`}
|
|
||||||
data-favourite-page-item
|
|
||||||
{...attributes}
|
|
||||||
{...listeners}
|
|
||||||
>
|
|
||||||
<SidebarDocItem
|
|
||||||
docId={docId}
|
|
||||||
postfixConfig={{
|
|
||||||
inFavorites: true,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from './favorite-list';
|
|
||||||
-123
@@ -1,123 +0,0 @@
|
|||||||
import { cssVar } from '@toeverything/theme';
|
|
||||||
import { globalStyle, style } from '@vanilla-extract/css';
|
|
||||||
export const label = style({
|
|
||||||
selectors: {
|
|
||||||
'&[data-untitled="true"]': {
|
|
||||||
opacity: 0.6,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
export const labelContainer = style({
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
});
|
|
||||||
export const labelTooltipContainer = style({
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
});
|
|
||||||
export const favItemWrapper = style({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
flexShrink: 0,
|
|
||||||
userSelect: 'none',
|
|
||||||
});
|
|
||||||
export const collapsibleContent = style({
|
|
||||||
overflow: 'hidden',
|
|
||||||
marginTop: '4px',
|
|
||||||
selectors: {
|
|
||||||
'&[data-hidden="true"]': {
|
|
||||||
display: 'none',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
export const collapsibleContentInner = style({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const dragPageItemOverlay = style({
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
background: cssVar('hoverColorFilled'),
|
|
||||||
boxShadow: cssVar('menuShadow'),
|
|
||||||
minHeight: '30px',
|
|
||||||
maxWidth: '360px',
|
|
||||||
width: '100%',
|
|
||||||
fontSize: cssVar('fontSm'),
|
|
||||||
gap: '8px',
|
|
||||||
padding: '4px',
|
|
||||||
borderRadius: '4px',
|
|
||||||
});
|
|
||||||
globalStyle(`${dragPageItemOverlay} svg`, {
|
|
||||||
width: '20px',
|
|
||||||
height: '20px',
|
|
||||||
color: cssVar('iconColor'),
|
|
||||||
});
|
|
||||||
globalStyle(`${dragPageItemOverlay} span`, {
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
overflow: 'hidden',
|
|
||||||
});
|
|
||||||
export const favoriteList = style({
|
|
||||||
overflow: 'hidden',
|
|
||||||
borderRadius: '4px',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: 2,
|
|
||||||
selectors: {
|
|
||||||
'&[data-over="true"]': {
|
|
||||||
background: cssVar('hoverColorFilled'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
export const favoritePostfixItem = style({
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
});
|
|
||||||
export const menuItem = style({
|
|
||||||
gap: '8px',
|
|
||||||
});
|
|
||||||
globalStyle(`${menuItem} svg`, {
|
|
||||||
width: '20px',
|
|
||||||
height: '20px',
|
|
||||||
color: cssVar('iconColor'),
|
|
||||||
});
|
|
||||||
globalStyle(`${menuItem}.danger:hover svg`, {
|
|
||||||
color: cssVar('errorColor'),
|
|
||||||
});
|
|
||||||
export const emptyFavouritesContent = style({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 6,
|
|
||||||
padding: '9px 20px 25px 21px',
|
|
||||||
});
|
|
||||||
export const emptyFavouritesIconWrapper = style({
|
|
||||||
width: 36,
|
|
||||||
height: 36,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
borderRadius: '50%',
|
|
||||||
backgroundColor: cssVar('hoverColor'),
|
|
||||||
});
|
|
||||||
export const emptyFavouritesIcon = style({
|
|
||||||
fontSize: 20,
|
|
||||||
color: cssVar('iconSecondary'),
|
|
||||||
});
|
|
||||||
export const emptyFavouritesMessage = style({
|
|
||||||
fontSize: cssVar('fontSm'),
|
|
||||||
textAlign: 'center',
|
|
||||||
color: cssVar('black30'),
|
|
||||||
userSelect: 'none',
|
|
||||||
});
|
|
||||||
export const noReferences = style({
|
|
||||||
fontSize: cssVar('fontSm'),
|
|
||||||
textAlign: 'left',
|
|
||||||
paddingLeft: '32px',
|
|
||||||
color: cssVar('black30'),
|
|
||||||
lineHeight: '30px',
|
|
||||||
userSelect: 'none',
|
|
||||||
});
|
|
||||||
@@ -1,23 +1,23 @@
|
|||||||
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 { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
||||||
import { CollectionService } from '@affine/core/modules/collection';
|
import {
|
||||||
|
ExplorerCollections,
|
||||||
|
ExplorerFavorites,
|
||||||
|
ExplorerOrganize,
|
||||||
|
} from '@affine/core/modules/explorer';
|
||||||
|
import { ExplorerTags } from '@affine/core/modules/explorer/views/sections/tags';
|
||||||
import { TelemetryWorkspaceContextService } from '@affine/core/modules/telemetry/services/telemetry';
|
import { TelemetryWorkspaceContextService } from '@affine/core/modules/telemetry/services/telemetry';
|
||||||
import { mixpanel } from '@affine/core/utils';
|
import { mixpanel } from '@affine/core/utils';
|
||||||
import { apis, events } from '@affine/electron-api';
|
import { apis, events } from '@affine/electron-api';
|
||||||
import { useI18n } from '@affine/i18n';
|
import { useI18n } from '@affine/i18n';
|
||||||
import { FolderIcon, SettingsIcon } from '@blocksuite/icons/rc';
|
import { FolderIcon, SettingsIcon } from '@blocksuite/icons/rc';
|
||||||
import type { Doc } from '@blocksuite/store';
|
import type { Doc } from '@blocksuite/store';
|
||||||
import { useDroppable } from '@dnd-kit/core';
|
|
||||||
import type { Workspace } from '@toeverything/infra';
|
import type { Workspace } from '@toeverything/infra';
|
||||||
import { useLiveData, useService } from '@toeverything/infra';
|
import { useLiveData, useService } from '@toeverything/infra';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
import { nanoid } from 'nanoid';
|
import type { ReactElement } from 'react';
|
||||||
import type { HTMLAttributes, ReactElement } from 'react';
|
import { memo, useEffect } from 'react';
|
||||||
import { forwardRef, memo, useCallback, useEffect } from 'react';
|
|
||||||
|
|
||||||
import { useAppSettingHelper } from '../../hooks/affine/use-app-setting-helper';
|
import { useAppSettingHelper } from '../../hooks/affine/use-app-setting-helper';
|
||||||
import { useNavigateHelper } from '../../hooks/use-navigate-helper';
|
|
||||||
import { WorkbenchService } from '../../modules/workbench';
|
import { WorkbenchService } from '../../modules/workbench';
|
||||||
import {
|
import {
|
||||||
AddPageButton,
|
AddPageButton,
|
||||||
@@ -31,10 +31,6 @@ import {
|
|||||||
SidebarContainer,
|
SidebarContainer,
|
||||||
SidebarScrollableContainer,
|
SidebarScrollableContainer,
|
||||||
} from '../app-sidebar';
|
} from '../app-sidebar';
|
||||||
import { createEmptyCollection, useEditCollectionName } from '../page-list';
|
|
||||||
import { CollectionsList } from '../pure/workspace-slider-bar/collections';
|
|
||||||
import { AddCollectionButton } from '../pure/workspace-slider-bar/collections/add-collection-button';
|
|
||||||
import FavoriteList from '../pure/workspace-slider-bar/favorite/favorite-list';
|
|
||||||
import { WorkspaceSelector } from '../workspace-selector';
|
import { WorkspaceSelector } from '../workspace-selector';
|
||||||
import ImportPage from './import-page';
|
import ImportPage from './import-page';
|
||||||
import {
|
import {
|
||||||
@@ -44,6 +40,7 @@ import {
|
|||||||
workspaceWrapper,
|
workspaceWrapper,
|
||||||
} from './index.css';
|
} from './index.css';
|
||||||
import { AppSidebarJournalButton } from './journal-button';
|
import { AppSidebarJournalButton } from './journal-button';
|
||||||
|
import { TrashButton } from './trash-button';
|
||||||
import { UpdaterButton } from './updater-button';
|
import { UpdaterButton } from './updater-button';
|
||||||
import { UserInfo } from './user-info';
|
import { UserInfo } from './user-info';
|
||||||
|
|
||||||
@@ -61,29 +58,6 @@ export type RootAppSidebarProps = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const RouteMenuLinkItem = forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
{
|
|
||||||
path: string;
|
|
||||||
icon: ReactElement;
|
|
||||||
active?: boolean;
|
|
||||||
children?: ReactElement;
|
|
||||||
} & HTMLAttributes<HTMLDivElement>
|
|
||||||
>(({ path, icon, active, children, ...props }, ref) => {
|
|
||||||
return (
|
|
||||||
<MenuLinkItem
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
active={active}
|
|
||||||
to={path ?? ''}
|
|
||||||
icon={icon}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</MenuLinkItem>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
RouteMenuLinkItem.displayName = 'RouteMenuLinkItem';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This is for the whole affine app sidebar.
|
* This is for the whole affine app sidebar.
|
||||||
* This component wraps the app sidebar in `@affine/component` with logic and data.
|
* This component wraps the app sidebar in `@affine/component` with logic and data.
|
||||||
@@ -113,8 +87,6 @@ export const RootAppSidebar = memo(
|
|||||||
|
|
||||||
const allPageActive = currentPath === '/all';
|
const allPageActive = currentPath === '/all';
|
||||||
|
|
||||||
const trashActive = currentPath === '/trash';
|
|
||||||
|
|
||||||
const onClickNewPage = useAsyncCallback(async () => {
|
const onClickNewPage = useAsyncCallback(async () => {
|
||||||
const page = createPage();
|
const page = createPage();
|
||||||
page.load();
|
page.load();
|
||||||
@@ -129,7 +101,6 @@ export const RootAppSidebar = memo(
|
|||||||
});
|
});
|
||||||
}, [createPage, openPage, telemetry]);
|
}, [createPage, openPage, telemetry]);
|
||||||
|
|
||||||
const navigateHelper = useNavigateHelper();
|
|
||||||
// Listen to the "New Page" action from the menu
|
// Listen to the "New Page" action from the menu
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (environment.isDesktop) {
|
if (environment.isDesktop) {
|
||||||
@@ -147,28 +118,6 @@ export const RootAppSidebar = memo(
|
|||||||
}
|
}
|
||||||
}, [sidebarOpen]);
|
}, [sidebarOpen]);
|
||||||
|
|
||||||
const dropItemId = getDNDId('sidebar-trash', 'container', 'trash');
|
|
||||||
const trashDroppable = useDroppable({
|
|
||||||
id: dropItemId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const collection = useService(CollectionService);
|
|
||||||
const { node, open } = useEditCollectionName({
|
|
||||||
title: t['com.affine.editCollection.createCollection'](),
|
|
||||||
showTips: true,
|
|
||||||
});
|
|
||||||
const handleCreateCollection = useCallback(() => {
|
|
||||||
open('')
|
|
||||||
.then(name => {
|
|
||||||
const id = nanoid();
|
|
||||||
collection.addCollection(createEmptyCollection(id, { name }));
|
|
||||||
navigateHelper.jumpToCollection(docCollection.id, id);
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.error(err);
|
|
||||||
});
|
|
||||||
}, [docCollection.id, collection, navigateHelper, open]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppSidebar
|
<AppSidebar
|
||||||
clientBorder={appSettings.clientBorder}
|
clientBorder={appSettings.clientBorder}
|
||||||
@@ -189,15 +138,15 @@ export const RootAppSidebar = memo(
|
|||||||
/>
|
/>
|
||||||
<AddPageButton onClick={onClickNewPage} />
|
<AddPageButton onClick={onClickNewPage} />
|
||||||
</div>
|
</div>
|
||||||
<RouteMenuLinkItem
|
<MenuLinkItem
|
||||||
icon={<FolderIcon />}
|
icon={<FolderIcon />}
|
||||||
active={allPageActive}
|
active={allPageActive}
|
||||||
path={paths.all(currentWorkspaceId)}
|
to={paths.all(currentWorkspaceId)}
|
||||||
>
|
>
|
||||||
<span data-testid="all-pages">
|
<span data-testid="all-pages">
|
||||||
{t['com.affine.workspaceSubPath.all']()}
|
{t['com.affine.workspaceSubPath.all']()}
|
||||||
</span>
|
</span>
|
||||||
</RouteMenuLinkItem>
|
</MenuLinkItem>
|
||||||
<AppSidebarJournalButton
|
<AppSidebarJournalButton
|
||||||
docCollection={currentWorkspace.docCollection}
|
docCollection={currentWorkspace.docCollection}
|
||||||
/>
|
/>
|
||||||
@@ -212,28 +161,15 @@ export const RootAppSidebar = memo(
|
|||||||
</MenuItem>
|
</MenuItem>
|
||||||
</SidebarContainer>
|
</SidebarContainer>
|
||||||
<SidebarScrollableContainer>
|
<SidebarScrollableContainer>
|
||||||
<FavoriteList docCollection={docCollection} />
|
{runtimeConfig.enableOrganize && <ExplorerOrganize />}
|
||||||
<CategoryDivider label={t['com.affine.rootAppSidebar.collections']()}>
|
<ExplorerFavorites />
|
||||||
<AddCollectionButton node={node} onClick={handleCreateCollection} />
|
<ExplorerCollections />
|
||||||
</CategoryDivider>
|
<ExplorerTags />
|
||||||
<CollectionsList
|
|
||||||
docCollection={docCollection}
|
|
||||||
onCreate={handleCreateCollection}
|
|
||||||
/>
|
|
||||||
<CategoryDivider label={t['com.affine.rootAppSidebar.others']()} />
|
<CategoryDivider label={t['com.affine.rootAppSidebar.others']()} />
|
||||||
{/* fixme: remove the following spacer */}
|
{/* fixme: remove the following spacer */}
|
||||||
<div style={{ height: '4px' }} />
|
<div style={{ height: '4px' }} />
|
||||||
<div style={{ padding: '0 8px' }}>
|
<div style={{ padding: '0 8px' }}>
|
||||||
<RouteMenuLinkItem
|
<TrashButton />
|
||||||
ref={trashDroppable.setNodeRef}
|
|
||||||
icon={<AnimatedDeleteIcon closed={trashDroppable.isOver} />}
|
|
||||||
active={trashActive || trashDroppable.isOver}
|
|
||||||
path={paths.trash(currentWorkspaceId)}
|
|
||||||
>
|
|
||||||
<span data-testid="trash-page">
|
|
||||||
{t['com.affine.workspaceSubPath.trash']()}
|
|
||||||
</span>
|
|
||||||
</RouteMenuLinkItem>
|
|
||||||
<ImportPage docCollection={docCollection} />
|
<ImportPage docCollection={docCollection} />
|
||||||
</div>
|
</div>
|
||||||
</SidebarScrollableContainer>
|
</SidebarScrollableContainer>
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import {
|
||||||
|
AnimatedDeleteIcon,
|
||||||
|
useConfirmModal,
|
||||||
|
useDropTarget,
|
||||||
|
} from '@affine/component';
|
||||||
|
import { WorkbenchLink } from '@affine/core/modules/workbench';
|
||||||
|
import type { AffineDNDData } from '@affine/core/types/dnd';
|
||||||
|
import { useI18n } from '@affine/i18n';
|
||||||
|
import {
|
||||||
|
DocsService,
|
||||||
|
GlobalContextService,
|
||||||
|
useLiveData,
|
||||||
|
useService,
|
||||||
|
} from '@toeverything/infra';
|
||||||
|
|
||||||
|
import { MenuLinkItem } from '../app-sidebar';
|
||||||
|
|
||||||
|
export const TrashButton = () => {
|
||||||
|
const t = useI18n();
|
||||||
|
const docsService = useService(DocsService);
|
||||||
|
const { openConfirmModal } = useConfirmModal();
|
||||||
|
const globalContextService = useService(GlobalContextService);
|
||||||
|
const trashActive = useLiveData(globalContextService.globalContext.isTrash.$);
|
||||||
|
|
||||||
|
const { dropTargetRef, draggedOver } = useDropTarget<AffineDNDData>(
|
||||||
|
() => ({
|
||||||
|
data: {
|
||||||
|
at: 'app-sidebar:trash',
|
||||||
|
},
|
||||||
|
canDrop(data) {
|
||||||
|
return data.source.data.entity?.type === 'doc';
|
||||||
|
},
|
||||||
|
onDrop(data) {
|
||||||
|
if (data.source.data.entity?.type === 'doc') {
|
||||||
|
const docId = data.source.data.entity.id;
|
||||||
|
const docRecord = docsService.list.doc$(docId).value;
|
||||||
|
if (docRecord) {
|
||||||
|
openConfirmModal({
|
||||||
|
title: t['com.affine.moveToTrash.confirmModal.title'](),
|
||||||
|
description: t['com.affine.moveToTrash.confirmModal.description'](
|
||||||
|
{
|
||||||
|
title: docRecord.title$.value || t['Untitled'](),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
confirmText: t.Delete(),
|
||||||
|
confirmButtonOptions: {
|
||||||
|
type: 'error',
|
||||||
|
},
|
||||||
|
onConfirm() {
|
||||||
|
docRecord.moveToTrash();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[docsService.list, openConfirmModal, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MenuLinkItem
|
||||||
|
ref={dropTargetRef}
|
||||||
|
icon={<AnimatedDeleteIcon closed={draggedOver} />}
|
||||||
|
active={trashActive || draggedOver}
|
||||||
|
linkComponent={WorkbenchLink}
|
||||||
|
to={'/trash'}
|
||||||
|
>
|
||||||
|
<span data-testid="trash-page">
|
||||||
|
{t['com.affine.workspaceSubPath.trash']()}
|
||||||
|
</span>
|
||||||
|
</MenuLinkItem>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,281 +0,0 @@
|
|||||||
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/properties';
|
|
||||||
import { useI18n } from '@affine/i18n';
|
|
||||||
import type {
|
|
||||||
Active,
|
|
||||||
DragEndEvent,
|
|
||||||
Over,
|
|
||||||
UniqueIdentifier,
|
|
||||||
} from '@dnd-kit/core';
|
|
||||||
import { useLiveData, useService, WorkspaceService } 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 = useI18n();
|
|
||||||
const currentWorkspace = useService(WorkspaceService).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,19 +0,0 @@
|
|||||||
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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -6,14 +6,6 @@ import {
|
|||||||
import { useI18n } from '@affine/i18n';
|
import { useI18n } from '@affine/i18n';
|
||||||
import { ZipTransformer } from '@blocksuite/blocks';
|
import { ZipTransformer } from '@blocksuite/blocks';
|
||||||
import { assertExists } from '@blocksuite/global/utils';
|
import { assertExists } from '@blocksuite/global/utils';
|
||||||
import {
|
|
||||||
DndContext,
|
|
||||||
DragOverlay,
|
|
||||||
MouseSensor,
|
|
||||||
useDndContext,
|
|
||||||
useSensor,
|
|
||||||
useSensors,
|
|
||||||
} from '@dnd-kit/core';
|
|
||||||
import {
|
import {
|
||||||
type DocMode,
|
type DocMode,
|
||||||
DocsService,
|
DocsService,
|
||||||
@@ -26,9 +18,8 @@ import {
|
|||||||
WorkspaceService,
|
WorkspaceService,
|
||||||
} from '@toeverything/infra';
|
} from '@toeverything/infra';
|
||||||
import { useAtomValue, useSetAtom } from 'jotai';
|
import { useAtomValue, useSetAtom } from 'jotai';
|
||||||
import type { PropsWithChildren, ReactNode } from 'react';
|
import type { PropsWithChildren } from 'react';
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
|
||||||
import {
|
import {
|
||||||
catchError,
|
catchError,
|
||||||
EMPTY,
|
EMPTY,
|
||||||
@@ -46,16 +37,11 @@ import { AppContainer } from '../components/affine/app-container';
|
|||||||
import { SyncAwareness } from '../components/affine/awareness';
|
import { SyncAwareness } from '../components/affine/awareness';
|
||||||
import { appSidebarResizingAtom } from '../components/app-sidebar';
|
import { appSidebarResizingAtom } from '../components/app-sidebar';
|
||||||
import { usePageHelper } from '../components/blocksuite/block-suite-page-list/utils';
|
import { usePageHelper } from '../components/blocksuite/block-suite-page-list/utils';
|
||||||
import type { DraggableTitleCellData } from '../components/page-list';
|
|
||||||
import { AIIsland } from '../components/pure/ai-island';
|
import { AIIsland } from '../components/pure/ai-island';
|
||||||
import { RootAppSidebar } from '../components/root-app-sidebar';
|
import { RootAppSidebar } from '../components/root-app-sidebar';
|
||||||
import { MainContainer } from '../components/workspace';
|
import { MainContainer } from '../components/workspace';
|
||||||
import { WorkspaceUpgrade } from '../components/workspace-upgrade';
|
import { WorkspaceUpgrade } from '../components/workspace-upgrade';
|
||||||
import { useAppSettingHelper } from '../hooks/affine/use-app-setting-helper';
|
import { useAppSettingHelper } from '../hooks/affine/use-app-setting-helper';
|
||||||
import {
|
|
||||||
resolveDragEndIntent,
|
|
||||||
useGlobalDNDHelper,
|
|
||||||
} from '../hooks/affine/use-global-dnd-helper';
|
|
||||||
import { useRegisterFindInPageCommands } from '../hooks/affine/use-register-find-in-page-commands';
|
import { useRegisterFindInPageCommands } from '../hooks/affine/use-register-find-in-page-commands';
|
||||||
import { useSubscriptionNotifyReader } from '../hooks/affine/use-subscription-notify';
|
import { useSubscriptionNotifyReader } from '../hooks/affine/use-subscription-notify';
|
||||||
import { useNavigateHelper } from '../hooks/use-navigate-helper';
|
import { useNavigateHelper } from '../hooks/use-navigate-helper';
|
||||||
@@ -71,7 +57,6 @@ import {
|
|||||||
import { SWRConfigProvider } from '../providers/swr-config-provider';
|
import { SWRConfigProvider } from '../providers/swr-config-provider';
|
||||||
import { pathGenerator } from '../shared';
|
import { pathGenerator } from '../shared';
|
||||||
import { mixpanel } from '../utils';
|
import { mixpanel } from '../utils';
|
||||||
import * as styles from './styles.css';
|
|
||||||
|
|
||||||
export const WorkspaceLayout = function WorkspaceLayout({
|
export const WorkspaceLayout = function WorkspaceLayout({
|
||||||
children,
|
children,
|
||||||
@@ -215,6 +200,7 @@ export const WorkspaceLayoutInner = ({ children }: PropsWithChildren) => {
|
|||||||
activeTab: 'appearance',
|
activeTab: 'appearance',
|
||||||
open: true,
|
open: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
mixpanel.track('SettingsViewed', {
|
mixpanel.track('SettingsViewed', {
|
||||||
// page:
|
// page:
|
||||||
segment: 'navigation panel',
|
segment: 'navigation panel',
|
||||||
@@ -225,99 +211,33 @@ export const WorkspaceLayoutInner = ({ children }: PropsWithChildren) => {
|
|||||||
|
|
||||||
const resizing = useAtomValue(appSidebarResizingAtom);
|
const resizing = useAtomValue(appSidebarResizingAtom);
|
||||||
|
|
||||||
const sensors = useSensors(
|
|
||||||
useSensor(
|
|
||||||
MouseSensor,
|
|
||||||
useMemo(
|
|
||||||
/* useMemo is necessary to avoid re-render */
|
|
||||||
() => ({
|
|
||||||
activationConstraint: {
|
|
||||||
distance: 10,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
[]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const { handleDragEnd } = useGlobalDNDHelper();
|
|
||||||
const { appSettings } = useAppSettingHelper();
|
const { appSettings } = useAppSettingHelper();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* This DndContext is used for drag page from all-pages list into a folder in sidebar */}
|
<AppContainer data-current-path={currentPath} resizing={resizing}>
|
||||||
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
|
<RootAppSidebar
|
||||||
<AppContainer data-current-path={currentPath} resizing={resizing}>
|
isPublicWorkspace={false}
|
||||||
<RootAppSidebar
|
onOpenQuickSearchModal={handleOpenQuickSearchModal}
|
||||||
isPublicWorkspace={false}
|
onOpenSettingModal={handleOpenSettingModal}
|
||||||
onOpenQuickSearchModal={handleOpenQuickSearchModal}
|
currentWorkspace={currentWorkspace}
|
||||||
onOpenSettingModal={handleOpenSettingModal}
|
openPage={useCallback(
|
||||||
currentWorkspace={currentWorkspace}
|
(pageId: string) => {
|
||||||
openPage={useCallback(
|
assertExists(currentWorkspace);
|
||||||
(pageId: string) => {
|
return openPage(currentWorkspace.id, pageId);
|
||||||
assertExists(currentWorkspace);
|
},
|
||||||
return openPage(currentWorkspace.id, pageId);
|
[currentWorkspace, openPage]
|
||||||
},
|
)}
|
||||||
[currentWorkspace, openPage]
|
createPage={handleCreatePage}
|
||||||
)}
|
paths={pathGenerator}
|
||||||
createPage={handleCreatePage}
|
/>
|
||||||
paths={pathGenerator}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MainContainer clientBorder={appSettings.clientBorder}>
|
<MainContainer clientBorder={appSettings.clientBorder}>
|
||||||
{needUpgrade || upgrading ? <WorkspaceUpgrade /> : children}
|
{needUpgrade || upgrading ? <WorkspaceUpgrade /> : children}
|
||||||
</MainContainer>
|
</MainContainer>
|
||||||
</AppContainer>
|
</AppContainer>
|
||||||
<GlobalDragOverlay />
|
|
||||||
</DndContext>
|
|
||||||
<QuickSearchContainer />
|
<QuickSearchContainer />
|
||||||
<SyncAwareness />
|
<SyncAwareness />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
function GlobalDragOverlay() {
|
|
||||||
const { active, over } = useDndContext();
|
|
||||||
const [preview, setPreview] = useState<ReactNode>();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (active) {
|
|
||||||
const data = active.data.current as DraggableTitleCellData;
|
|
||||||
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 intent = resolveDragEndIntent(active, over);
|
|
||||||
|
|
||||||
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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ export class CollectionService extends Service {
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
collection$(id: string) {
|
||||||
|
return this.collections$.map(collections => {
|
||||||
|
return collections.find(v => v.id === id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
readonly collectionsTrash$ = LiveData.from(
|
readonly collectionsTrash$ = LiveData.from(
|
||||||
new Observable<DeletedCollection[]>(subscriber => {
|
new Observable<DeletedCollection[]>(subscriber => {
|
||||||
subscriber.next(this.collectionsTrashYArray?.toArray() ?? []);
|
subscriber.next(this.collectionsTrashYArray?.toArray() ?? []);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { DebugLogger } from '@affine/debug';
|
import { DebugLogger } from '@affine/debug';
|
||||||
import type { Job, JobQueue, WorkspaceService } from '@toeverything/infra';
|
import type { Job, JobQueue, WorkspaceService } from '@toeverything/infra';
|
||||||
import {
|
import {
|
||||||
|
DBService,
|
||||||
Entity,
|
Entity,
|
||||||
IndexedDBIndexStorage,
|
IndexedDBIndexStorage,
|
||||||
IndexedDBJobQueue,
|
IndexedDBJobQueue,
|
||||||
@@ -68,6 +69,10 @@ export class DocsIndexer extends Entity {
|
|||||||
|
|
||||||
setupListener() {
|
setupListener() {
|
||||||
this.workspaceEngine.doc.storage.eventBus.on(event => {
|
this.workspaceEngine.doc.storage.eventBus.on(event => {
|
||||||
|
if (DBService.isDBDocId(event.docId)) {
|
||||||
|
// skip db doc
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (event.clientId === this.workspaceEngine.doc.clientId) {
|
if (event.clientId === this.workspaceEngine.doc.clientId) {
|
||||||
const docId = normalizeDocId(event.docId);
|
const docId = normalizeDocId(event.docId);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# explorer
|
||||||
|
|
||||||
|
file manager in app left sidebar
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { ExplorerCollections } from './views/sections/collections';
|
||||||
|
export { ExplorerFavorites } from './views/sections/favorites';
|
||||||
|
export { ExplorerOrganize } from './views/sections/organize';
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { cssVar } from '@toeverything/theme';
|
||||||
|
import { fallbackVar, style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
import { levelIndent } from '../../tree/node.css';
|
||||||
|
|
||||||
|
export const noReferences = style({
|
||||||
|
fontSize: cssVar('fontSm'),
|
||||||
|
textAlign: 'left',
|
||||||
|
padding: '4px 0 4px 32px',
|
||||||
|
color: cssVar('black30'),
|
||||||
|
userSelect: 'none',
|
||||||
|
paddingLeft: `calc(${fallbackVar(levelIndent, '20px')} + 32px)`,
|
||||||
|
selectors: {
|
||||||
|
'&[data-dragged-over="true"]': {
|
||||||
|
background: cssVar('--affine-hover-color'),
|
||||||
|
borderRadius: '4px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { type DropTargetDropEvent, useDropTarget } from '@affine/component';
|
||||||
|
import type { AffineDNDData } from '@affine/core/types/dnd';
|
||||||
|
import { useI18n } from '@affine/i18n';
|
||||||
|
|
||||||
|
import * as styles from './empty.css';
|
||||||
|
|
||||||
|
export const Empty = ({
|
||||||
|
onDrop,
|
||||||
|
}: {
|
||||||
|
onDrop: (data: DropTargetDropEvent<AffineDNDData>) => void;
|
||||||
|
}) => {
|
||||||
|
const { dropTargetRef } = useDropTarget(
|
||||||
|
() => ({
|
||||||
|
onDrop,
|
||||||
|
}),
|
||||||
|
[onDrop]
|
||||||
|
);
|
||||||
|
const t = useI18n();
|
||||||
|
return (
|
||||||
|
<div className={styles.noReferences} ref={dropTargetRef}>
|
||||||
|
{t['com.affine.collection.emptyCollection']()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
import {
|
||||||
|
AnimatedCollectionsIcon,
|
||||||
|
type DropTargetDropEvent,
|
||||||
|
type DropTargetOptions,
|
||||||
|
MenuIcon,
|
||||||
|
MenuItem,
|
||||||
|
toast,
|
||||||
|
} from '@affine/component';
|
||||||
|
import {
|
||||||
|
filterPage,
|
||||||
|
useEditCollection,
|
||||||
|
} from '@affine/core/components/page-list';
|
||||||
|
import { CollectionService } from '@affine/core/modules/collection';
|
||||||
|
import { FavoriteItemsAdapter } from '@affine/core/modules/properties';
|
||||||
|
import { ShareDocsService } from '@affine/core/modules/share-doc';
|
||||||
|
import type { AffineDNDData } from '@affine/core/types/dnd';
|
||||||
|
import type { Collection } from '@affine/env/filter';
|
||||||
|
import { PublicPageMode } from '@affine/graphql';
|
||||||
|
import { useI18n } from '@affine/i18n';
|
||||||
|
import { FilterMinusIcon } from '@blocksuite/icons/rc';
|
||||||
|
import type { DocMeta } from '@blocksuite/store';
|
||||||
|
import {
|
||||||
|
DocsService,
|
||||||
|
GlobalContextService,
|
||||||
|
LiveData,
|
||||||
|
useLiveData,
|
||||||
|
useService,
|
||||||
|
useServices,
|
||||||
|
} from '@toeverything/infra';
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { ExplorerTreeNode, type ExplorerTreeNodeDropEffect } from '../../tree';
|
||||||
|
import { ExplorerDocNode } from '../doc';
|
||||||
|
import type { GenericExplorerNode } from '../types';
|
||||||
|
import { Empty } from './empty';
|
||||||
|
import { useExplorerCollectionNodeOperations } from './operations';
|
||||||
|
|
||||||
|
export const ExplorerCollectionNode = ({
|
||||||
|
collectionId,
|
||||||
|
onDrop,
|
||||||
|
location,
|
||||||
|
reorderable,
|
||||||
|
operations: additionalOperations,
|
||||||
|
canDrop,
|
||||||
|
dropEffect,
|
||||||
|
}: {
|
||||||
|
collectionId: string;
|
||||||
|
} & GenericExplorerNode) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const { globalContextService } = useServices({
|
||||||
|
GlobalContextService,
|
||||||
|
});
|
||||||
|
const { open: openEditCollectionModal, node: editModal } =
|
||||||
|
useEditCollection();
|
||||||
|
const active =
|
||||||
|
useLiveData(globalContextService.globalContext.collectionId.$) ===
|
||||||
|
collectionId;
|
||||||
|
const [collapsed, setCollapsed] = useState(true);
|
||||||
|
|
||||||
|
const collectionService = useService(CollectionService);
|
||||||
|
const collection = useLiveData(collectionService.collection$(collectionId));
|
||||||
|
|
||||||
|
const dndData = useMemo(() => {
|
||||||
|
return {
|
||||||
|
draggable: {
|
||||||
|
entity: {
|
||||||
|
type: 'collection',
|
||||||
|
id: collectionId,
|
||||||
|
},
|
||||||
|
from: location,
|
||||||
|
},
|
||||||
|
dropTarget: {
|
||||||
|
at: 'explorer:doc',
|
||||||
|
},
|
||||||
|
} satisfies AffineDNDData;
|
||||||
|
}, [collectionId, location]);
|
||||||
|
|
||||||
|
const handleRename = useCallback(
|
||||||
|
(name: string) => {
|
||||||
|
if (collection) {
|
||||||
|
collectionService.updateCollection(collectionId, () => ({
|
||||||
|
...collection,
|
||||||
|
name,
|
||||||
|
}));
|
||||||
|
toast(t['com.affine.toastMessage.rename']());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[collection, collectionId, collectionService, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAddDocToCollection = useCallback(
|
||||||
|
(docId: string) => {
|
||||||
|
if (!collection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (collection.allowList.includes(docId)) {
|
||||||
|
toast(t['com.affine.collection.addPage.alreadyExists']());
|
||||||
|
} else {
|
||||||
|
collectionService.addPageToCollection(collection.id, docId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[collection, collectionService, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDropOnCollection = useCallback(
|
||||||
|
(data: DropTargetDropEvent<AffineDNDData>) => {
|
||||||
|
if (collection && data.treeInstruction?.type === 'make-child') {
|
||||||
|
if (data.source.data.entity?.type === 'doc') {
|
||||||
|
handleAddDocToCollection(data.source.data.entity.id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onDrop?.(data);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[collection, onDrop, handleAddDocToCollection]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDropEffectOnCollection = useCallback<ExplorerTreeNodeDropEffect>(
|
||||||
|
data => {
|
||||||
|
if (collection && data.treeInstruction?.type === 'make-child') {
|
||||||
|
if (data.source.data.entity?.type === 'doc') {
|
||||||
|
return 'link';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return dropEffect?.(data);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
[collection, dropEffect]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDropOnPlaceholder = useCallback(
|
||||||
|
(data: DropTargetDropEvent<AffineDNDData>) => {
|
||||||
|
if (collection && data.source.data.entity?.type === 'doc') {
|
||||||
|
handleAddDocToCollection(data.source.data.entity.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[collection, handleAddDocToCollection]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOpenCollapsed = useCallback(() => {
|
||||||
|
setCollapsed(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleEditCollection = useCallback(() => {
|
||||||
|
if (!collection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openEditCollectionModal(collection)
|
||||||
|
.then(collection => {
|
||||||
|
return collectionService.updateCollection(
|
||||||
|
collection.id,
|
||||||
|
() => collection
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
});
|
||||||
|
}, [collection, collectionService, openEditCollectionModal]);
|
||||||
|
|
||||||
|
const collectionOperations = useExplorerCollectionNodeOperations(
|
||||||
|
collectionId,
|
||||||
|
handleOpenCollapsed,
|
||||||
|
handleEditCollection
|
||||||
|
);
|
||||||
|
|
||||||
|
const finalOperations = useMemo(() => {
|
||||||
|
if (additionalOperations) {
|
||||||
|
return [...additionalOperations, ...collectionOperations];
|
||||||
|
}
|
||||||
|
return collectionOperations;
|
||||||
|
}, [collectionOperations, additionalOperations]);
|
||||||
|
|
||||||
|
const handleCanDrop = useMemo<DropTargetOptions<AffineDNDData>['canDrop']>(
|
||||||
|
() => args => {
|
||||||
|
const entityType = args.source.data.entity?.type;
|
||||||
|
return args.treeInstruction?.type !== 'make-child'
|
||||||
|
? ((typeof canDrop === 'function' ? canDrop(args) : canDrop) ?? true)
|
||||||
|
: entityType === 'doc';
|
||||||
|
},
|
||||||
|
[canDrop]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!collection) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ExplorerTreeNode
|
||||||
|
icon={({ draggedOver, className, treeInstruction }) => (
|
||||||
|
<AnimatedCollectionsIcon
|
||||||
|
className={className}
|
||||||
|
closed={!!draggedOver && treeInstruction?.type === 'make-child'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
name={collection.name || t['Untitled']()}
|
||||||
|
dndData={dndData}
|
||||||
|
onDrop={handleDropOnCollection}
|
||||||
|
renameable
|
||||||
|
collapsed={collapsed}
|
||||||
|
setCollapsed={setCollapsed}
|
||||||
|
to={`/collection/${collection.id}`}
|
||||||
|
active={active}
|
||||||
|
canDrop={handleCanDrop}
|
||||||
|
reorderable={reorderable}
|
||||||
|
onRename={handleRename}
|
||||||
|
childrenPlaceholder={<Empty onDrop={handleDropOnPlaceholder} />}
|
||||||
|
operations={finalOperations}
|
||||||
|
dropEffect={handleDropEffectOnCollection}
|
||||||
|
data-testid={`explorer-collection-${collectionId}`}
|
||||||
|
>
|
||||||
|
<ExplorerCollectionNodeChildren collection={collection} />
|
||||||
|
</ExplorerTreeNode>
|
||||||
|
{editModal}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ExplorerCollectionNodeChildren = ({
|
||||||
|
collection,
|
||||||
|
}: {
|
||||||
|
collection: Collection;
|
||||||
|
}) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const {
|
||||||
|
docsService,
|
||||||
|
favoriteItemsAdapter,
|
||||||
|
shareDocsService,
|
||||||
|
collectionService,
|
||||||
|
} = useServices({
|
||||||
|
DocsService,
|
||||||
|
FavoriteItemsAdapter,
|
||||||
|
ShareDocsService,
|
||||||
|
CollectionService,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// TODO(@eyhn): loading & error UI
|
||||||
|
shareDocsService.shareDocs?.revalidate();
|
||||||
|
}, [shareDocsService]);
|
||||||
|
|
||||||
|
const docMetas = useLiveData(
|
||||||
|
useMemo(
|
||||||
|
() =>
|
||||||
|
LiveData.computed(get => {
|
||||||
|
return get(docsService.list.docs$).map(
|
||||||
|
doc => get(doc.meta$) as DocMeta
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
[docsService]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const favourites = useLiveData(favoriteItemsAdapter.favorites$);
|
||||||
|
const allowList = useMemo(
|
||||||
|
() => new Set(collection.allowList),
|
||||||
|
[collection.allowList]
|
||||||
|
);
|
||||||
|
const shareDocs = useLiveData(shareDocsService.shareDocs?.list$);
|
||||||
|
|
||||||
|
const handleRemoveFromAllowList = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
collectionService.deletePageFromCollection(collection.id, id);
|
||||||
|
toast(t['com.affine.collection.removePage.success']());
|
||||||
|
},
|
||||||
|
[collection.id, collectionService, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const filtered = docMetas.filter(meta => {
|
||||||
|
if (meta.trash) return false;
|
||||||
|
const publicMode = shareDocs?.find(d => d.id === meta.id)?.mode;
|
||||||
|
const pageData = {
|
||||||
|
meta: meta as DocMeta,
|
||||||
|
publicMode:
|
||||||
|
publicMode === PublicPageMode.Edgeless
|
||||||
|
? ('edgeless' as const)
|
||||||
|
: publicMode === PublicPageMode.Page
|
||||||
|
? ('page' as const)
|
||||||
|
: undefined,
|
||||||
|
favorite: favourites.some(fav => fav.id === meta.id),
|
||||||
|
};
|
||||||
|
return filterPage(collection, pageData);
|
||||||
|
});
|
||||||
|
|
||||||
|
return filtered.map(doc => (
|
||||||
|
<ExplorerDocNode
|
||||||
|
key={doc.id}
|
||||||
|
docId={doc.id}
|
||||||
|
reorderable={false}
|
||||||
|
location={{
|
||||||
|
at: 'explorer:collection:filtered-docs',
|
||||||
|
collectionId: collection.id,
|
||||||
|
}}
|
||||||
|
operations={
|
||||||
|
allowList
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
index: 99,
|
||||||
|
view: (
|
||||||
|
<MenuItem
|
||||||
|
preFix={
|
||||||
|
<MenuIcon>
|
||||||
|
<FilterMinusIcon />
|
||||||
|
</MenuIcon>
|
||||||
|
}
|
||||||
|
onClick={() => handleRemoveFromAllowList(doc.id)}
|
||||||
|
>
|
||||||
|
{t['Remove special filter']()}
|
||||||
|
</MenuItem>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
};
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import {
|
||||||
|
IconButton,
|
||||||
|
MenuIcon,
|
||||||
|
MenuItem,
|
||||||
|
MenuSeparator,
|
||||||
|
useConfirmModal,
|
||||||
|
} 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 { CollectionService } from '@affine/core/modules/collection';
|
||||||
|
import { FavoriteItemsAdapter } from '@affine/core/modules/properties';
|
||||||
|
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||||
|
import { useI18n } from '@affine/i18n';
|
||||||
|
import {
|
||||||
|
DeleteIcon,
|
||||||
|
FavoritedIcon,
|
||||||
|
FavoriteIcon,
|
||||||
|
FilterIcon,
|
||||||
|
PlusIcon,
|
||||||
|
SplitViewIcon,
|
||||||
|
} from '@blocksuite/icons/rc';
|
||||||
|
import { DocsService, useLiveData, useServices } from '@toeverything/infra';
|
||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
|
import type { NodeOperation } from '../../tree/types';
|
||||||
|
|
||||||
|
export const useExplorerCollectionNodeOperations = (
|
||||||
|
collectionId: string,
|
||||||
|
onOpenCollapsed: () => void,
|
||||||
|
onOpenEdit: () => void
|
||||||
|
): NodeOperation[] => {
|
||||||
|
const t = useI18n();
|
||||||
|
const { appSettings } = useAppSettingHelper();
|
||||||
|
const {
|
||||||
|
workbenchService,
|
||||||
|
docsService,
|
||||||
|
collectionService,
|
||||||
|
favoriteItemsAdapter,
|
||||||
|
} = useServices({
|
||||||
|
DocsService,
|
||||||
|
WorkbenchService,
|
||||||
|
CollectionService,
|
||||||
|
FavoriteItemsAdapter,
|
||||||
|
});
|
||||||
|
const deleteInfo = useDeleteCollectionInfo();
|
||||||
|
|
||||||
|
const favorite = useLiveData(
|
||||||
|
useMemo(
|
||||||
|
() => favoriteItemsAdapter.isFavorite$(collectionId, 'collection'),
|
||||||
|
[collectionId, favoriteItemsAdapter]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const { openConfirmModal } = useConfirmModal();
|
||||||
|
|
||||||
|
const createAndAddDocument = useCallback(() => {
|
||||||
|
const newDoc = docsService.createDoc();
|
||||||
|
collectionService.addPageToCollection(collectionId, newDoc.id);
|
||||||
|
workbenchService.workbench.openDoc(newDoc.id);
|
||||||
|
onOpenCollapsed();
|
||||||
|
}, [
|
||||||
|
collectionId,
|
||||||
|
collectionService,
|
||||||
|
docsService,
|
||||||
|
onOpenCollapsed,
|
||||||
|
workbenchService.workbench,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleToggleFavoritePage = useCallback(() => {
|
||||||
|
favoriteItemsAdapter.toggle(collectionId, 'collection');
|
||||||
|
}, [favoriteItemsAdapter, collectionId]);
|
||||||
|
|
||||||
|
const handleAddDocToCollection = useCallback(() => {
|
||||||
|
openConfirmModal({
|
||||||
|
title: t['com.affine.collection.add-doc.confirm.title'](),
|
||||||
|
description: t['com.affine.collection.add-doc.confirm.description'](),
|
||||||
|
cancelText: t['Cancel'](),
|
||||||
|
confirmText: t['Confirm'](),
|
||||||
|
confirmButtonOptions: {
|
||||||
|
type: 'primary',
|
||||||
|
},
|
||||||
|
onConfirm: createAndAddDocument,
|
||||||
|
});
|
||||||
|
}, [createAndAddDocument, openConfirmModal, t]);
|
||||||
|
|
||||||
|
const handleOpenInSplitView = useCallback(() => {
|
||||||
|
workbenchService.workbench.openCollection(collectionId, { at: 'beside' });
|
||||||
|
}, [collectionId, workbenchService.workbench]);
|
||||||
|
|
||||||
|
const handleDeleteCollection = useCallback(() => {
|
||||||
|
collectionService.deleteCollection(deleteInfo, collectionId);
|
||||||
|
}, [collectionId, collectionService, deleteInfo]);
|
||||||
|
|
||||||
|
const handleShowEdit = useCallback(() => {
|
||||||
|
onOpenEdit();
|
||||||
|
}, [onOpenEdit]);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
inline: true,
|
||||||
|
view: (
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
type="plain"
|
||||||
|
onClick={handleAddDocToCollection}
|
||||||
|
>
|
||||||
|
<PlusIcon />
|
||||||
|
</IconButton>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 99,
|
||||||
|
view: (
|
||||||
|
<MenuItem
|
||||||
|
preFix={
|
||||||
|
<MenuIcon>
|
||||||
|
<FilterIcon />
|
||||||
|
</MenuIcon>
|
||||||
|
}
|
||||||
|
onClick={handleShowEdit}
|
||||||
|
>
|
||||||
|
{t['com.affine.collection.menu.edit']()}
|
||||||
|
</MenuItem>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 99,
|
||||||
|
view: (
|
||||||
|
<MenuItem
|
||||||
|
preFix={
|
||||||
|
<MenuIcon>
|
||||||
|
<PlusIcon />
|
||||||
|
</MenuIcon>
|
||||||
|
}
|
||||||
|
onClick={handleAddDocToCollection}
|
||||||
|
>
|
||||||
|
{t['New Page']()}
|
||||||
|
</MenuItem>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 99,
|
||||||
|
view: (
|
||||||
|
<MenuItem
|
||||||
|
preFix={
|
||||||
|
<MenuIcon>
|
||||||
|
{favorite ? (
|
||||||
|
<FavoritedIcon
|
||||||
|
style={{ color: 'var(--affine-primary-color)' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FavoriteIcon />
|
||||||
|
)}
|
||||||
|
</MenuIcon>
|
||||||
|
}
|
||||||
|
onClick={handleToggleFavoritePage}
|
||||||
|
>
|
||||||
|
{favorite
|
||||||
|
? t['com.affine.favoritePageOperation.remove']()
|
||||||
|
: t['com.affine.favoritePageOperation.add']()}
|
||||||
|
</MenuItem>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
...(appSettings.enableMultiView
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
index: 99,
|
||||||
|
view: (
|
||||||
|
<MenuItem
|
||||||
|
preFix={
|
||||||
|
<MenuIcon>
|
||||||
|
<SplitViewIcon />
|
||||||
|
</MenuIcon>
|
||||||
|
}
|
||||||
|
onClick={handleOpenInSplitView}
|
||||||
|
>
|
||||||
|
{t['com.affine.workbench.split-view.page-menu-open']()}
|
||||||
|
</MenuItem>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
index: 9999,
|
||||||
|
view: <MenuSeparator key="menu-separator" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 10000,
|
||||||
|
view: (
|
||||||
|
<MenuItem
|
||||||
|
type={'danger'}
|
||||||
|
preFix={
|
||||||
|
<MenuIcon>
|
||||||
|
<DeleteIcon />
|
||||||
|
</MenuIcon>
|
||||||
|
}
|
||||||
|
onClick={handleDeleteCollection}
|
||||||
|
>
|
||||||
|
{t['Delete']()}
|
||||||
|
</MenuItem>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
appSettings.enableMultiView,
|
||||||
|
favorite,
|
||||||
|
handleAddDocToCollection,
|
||||||
|
handleDeleteCollection,
|
||||||
|
handleOpenInSplitView,
|
||||||
|
handleShowEdit,
|
||||||
|
handleToggleFavoritePage,
|
||||||
|
t,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { cssVar } from '@toeverything/theme';
|
||||||
|
import { fallbackVar, style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
import { levelIndent } from '../../tree/node.css';
|
||||||
|
|
||||||
|
export const noReferences = style({
|
||||||
|
fontSize: cssVar('fontSm'),
|
||||||
|
textAlign: 'left',
|
||||||
|
padding: '4px 0 4px 32px',
|
||||||
|
color: cssVar('black30'),
|
||||||
|
userSelect: 'none',
|
||||||
|
paddingLeft: `calc(${fallbackVar(levelIndent, '20px')} + 32px)`,
|
||||||
|
selectors: {
|
||||||
|
'&[data-dragged-over="true"]': {
|
||||||
|
background: cssVar('--affine-hover-color'),
|
||||||
|
borderRadius: '4px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { type DropTargetDropEvent, useDropTarget } from '@affine/component';
|
||||||
|
import type { AffineDNDData } from '@affine/core/types/dnd';
|
||||||
|
import { useI18n } from '@affine/i18n';
|
||||||
|
|
||||||
|
import * as styles from './empty.css';
|
||||||
|
|
||||||
|
export const Empty = ({
|
||||||
|
onDrop,
|
||||||
|
}: {
|
||||||
|
onDrop: (data: DropTargetDropEvent<AffineDNDData>) => void;
|
||||||
|
}) => {
|
||||||
|
const { dropTargetRef } = useDropTarget(
|
||||||
|
() => ({
|
||||||
|
onDrop,
|
||||||
|
}),
|
||||||
|
[onDrop]
|
||||||
|
);
|
||||||
|
const t = useI18n();
|
||||||
|
return (
|
||||||
|
<div className={styles.noReferences} ref={dropTargetRef}>
|
||||||
|
{t['com.affine.rootAppSidebar.docs.no-subdoc']()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import {
|
||||||
|
type DropTargetDropEvent,
|
||||||
|
type DropTargetOptions,
|
||||||
|
Loading,
|
||||||
|
toast,
|
||||||
|
Tooltip,
|
||||||
|
} from '@affine/component';
|
||||||
|
import { InfoModal } from '@affine/core/components/affine/page-properties';
|
||||||
|
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
||||||
|
import { DocsSearchService } from '@affine/core/modules/docs-search';
|
||||||
|
import type { AffineDNDData } from '@affine/core/types/dnd';
|
||||||
|
import { useI18n } from '@affine/i18n';
|
||||||
|
import {
|
||||||
|
EdgelessIcon,
|
||||||
|
LinkedEdgelessIcon,
|
||||||
|
LinkedPageIcon,
|
||||||
|
PageIcon,
|
||||||
|
} from '@blocksuite/icons/rc';
|
||||||
|
import {
|
||||||
|
DocsService,
|
||||||
|
GlobalContextService,
|
||||||
|
LiveData,
|
||||||
|
useLiveData,
|
||||||
|
useServices,
|
||||||
|
} from '@toeverything/infra';
|
||||||
|
import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { ExplorerTreeNode, type ExplorerTreeNodeDropEffect } from '../../tree';
|
||||||
|
import type { GenericExplorerNode } from '../types';
|
||||||
|
import { Empty } from './empty';
|
||||||
|
import { useExplorerDocNodeOperations } from './operations';
|
||||||
|
import * as styles from './styles.css';
|
||||||
|
|
||||||
|
export const ExplorerDocNode = ({
|
||||||
|
docId,
|
||||||
|
onDrop,
|
||||||
|
location,
|
||||||
|
reorderable,
|
||||||
|
isLinked,
|
||||||
|
canDrop,
|
||||||
|
operations: additionalOperations,
|
||||||
|
dropEffect,
|
||||||
|
}: {
|
||||||
|
docId: string;
|
||||||
|
isLinked?: boolean;
|
||||||
|
} & GenericExplorerNode) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const { docsSearchService, docsService, globalContextService } = useServices({
|
||||||
|
DocsSearchService,
|
||||||
|
DocsService,
|
||||||
|
GlobalContextService,
|
||||||
|
});
|
||||||
|
const active =
|
||||||
|
useLiveData(globalContextService.globalContext.docId.$) === docId;
|
||||||
|
const [collapsed, setCollapsed] = useState(true);
|
||||||
|
|
||||||
|
const docRecord = useLiveData(docsService.list.doc$(docId));
|
||||||
|
const docMode = useLiveData(docRecord?.mode$);
|
||||||
|
const docTitle = useLiveData(docRecord?.title$);
|
||||||
|
const isInTrash = useLiveData(docRecord?.trash$);
|
||||||
|
|
||||||
|
const Icon = useCallback(
|
||||||
|
({ className }: { className?: string }) => {
|
||||||
|
return isLinked ? (
|
||||||
|
docMode === 'edgeless' ? (
|
||||||
|
<LinkedEdgelessIcon className={className} />
|
||||||
|
) : (
|
||||||
|
<LinkedPageIcon className={className} />
|
||||||
|
)
|
||||||
|
) : docMode === 'edgeless' ? (
|
||||||
|
<EdgelessIcon className={className} />
|
||||||
|
) : (
|
||||||
|
<PageIcon className={className} />
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[docMode, isLinked]
|
||||||
|
);
|
||||||
|
|
||||||
|
const children = useLiveData(
|
||||||
|
useMemo(
|
||||||
|
() => LiveData.from(docsSearchService.watchRefsFrom(docId), null),
|
||||||
|
[docsSearchService, docId]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const indexerLoading = useLiveData(
|
||||||
|
docsSearchService.indexer.status$.map(
|
||||||
|
v => v.remaining === undefined || v.remaining > 0
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const [referencesLoading, setReferencesLoading] = useState(true);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
setReferencesLoading(
|
||||||
|
prev =>
|
||||||
|
prev &&
|
||||||
|
indexerLoading /* after loading becomes false, it never becomes true */
|
||||||
|
);
|
||||||
|
}, [indexerLoading]);
|
||||||
|
|
||||||
|
const dndData = useMemo(() => {
|
||||||
|
return {
|
||||||
|
draggable: {
|
||||||
|
entity: {
|
||||||
|
type: 'doc',
|
||||||
|
id: docId,
|
||||||
|
},
|
||||||
|
from: location,
|
||||||
|
},
|
||||||
|
dropTarget: {
|
||||||
|
at: 'explorer:doc',
|
||||||
|
},
|
||||||
|
} satisfies AffineDNDData;
|
||||||
|
}, [docId, location]);
|
||||||
|
|
||||||
|
const handleRename = useAsyncCallback(
|
||||||
|
async (newName: string) => {
|
||||||
|
await docsService.changeDocTitle(docId, newName);
|
||||||
|
},
|
||||||
|
[docId, docsService]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDropOnDoc = useAsyncCallback(
|
||||||
|
async (data: DropTargetDropEvent<AffineDNDData>) => {
|
||||||
|
if (data.treeInstruction?.type === 'make-child') {
|
||||||
|
if (data.source.data.entity?.type === 'doc') {
|
||||||
|
await docsService.addLinkedDoc(docId, data.source.data.entity.id);
|
||||||
|
} else {
|
||||||
|
toast(t['com.affine.rootAppSidebar.doc.link-doc-only']());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onDrop?.(data);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[docId, docsService, onDrop, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDropEffectOnDoc = useCallback<ExplorerTreeNodeDropEffect>(
|
||||||
|
data => {
|
||||||
|
if (data.treeInstruction?.type === 'make-child') {
|
||||||
|
if (data.source.data.entity?.type === 'doc') {
|
||||||
|
return 'link';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return dropEffect?.(data);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
[dropEffect]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDropOnPlaceholder = useAsyncCallback(
|
||||||
|
async (data: DropTargetDropEvent<AffineDNDData>) => {
|
||||||
|
if (data.source.data.entity?.type === 'doc') {
|
||||||
|
// TODO(eyhn): timeout&error handling
|
||||||
|
await docsService.addLinkedDoc(docId, data.source.data.entity.id);
|
||||||
|
} else {
|
||||||
|
toast(t['com.affine.rootAppSidebar.doc.link-doc-only']());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[docId, docsService, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCanDrop = useMemo<DropTargetOptions<AffineDNDData>['canDrop']>(
|
||||||
|
() => args => {
|
||||||
|
const entityType = args.source.data.entity?.type;
|
||||||
|
return args.treeInstruction?.type !== 'make-child'
|
||||||
|
? ((typeof canDrop === 'function' ? canDrop(args) : canDrop) ?? true)
|
||||||
|
: entityType === 'doc';
|
||||||
|
},
|
||||||
|
[canDrop]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [enableInfoModal, setEnableInfoModal] = useState(false);
|
||||||
|
const operations = useExplorerDocNodeOperations(
|
||||||
|
docId,
|
||||||
|
useMemo(
|
||||||
|
() => ({
|
||||||
|
openInfoModal: () => setEnableInfoModal(true),
|
||||||
|
openNodeCollapsed: () => setCollapsed(false),
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const finalOperations = useMemo(() => {
|
||||||
|
if (additionalOperations) {
|
||||||
|
return [...operations, ...additionalOperations];
|
||||||
|
}
|
||||||
|
return operations;
|
||||||
|
}, [additionalOperations, operations]);
|
||||||
|
|
||||||
|
if (isInTrash || !docRecord) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ExplorerTreeNode
|
||||||
|
icon={Icon}
|
||||||
|
name={docTitle || t['Untitled']()}
|
||||||
|
dndData={dndData}
|
||||||
|
onDrop={handleDropOnDoc}
|
||||||
|
renameable
|
||||||
|
collapsed={collapsed}
|
||||||
|
setCollapsed={setCollapsed}
|
||||||
|
canDrop={handleCanDrop}
|
||||||
|
to={`/${docId}`}
|
||||||
|
active={active}
|
||||||
|
postfix={
|
||||||
|
referencesLoading &&
|
||||||
|
!collapsed && (
|
||||||
|
<Tooltip
|
||||||
|
content={t['com.affine.rootAppSidebar.docs.references-loading']()}
|
||||||
|
>
|
||||||
|
<div className={styles.loadingIcon}>
|
||||||
|
<Loading />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
reorderable={reorderable}
|
||||||
|
onRename={handleRename}
|
||||||
|
childrenPlaceholder={<Empty onDrop={handleDropOnPlaceholder} />}
|
||||||
|
operations={finalOperations}
|
||||||
|
dropEffect={handleDropEffectOnDoc}
|
||||||
|
data-testid={`explorer-doc-${docId}`}
|
||||||
|
>
|
||||||
|
{children?.map(child => (
|
||||||
|
<ExplorerDocNode
|
||||||
|
key={child.docId}
|
||||||
|
docId={child.docId}
|
||||||
|
reorderable={false}
|
||||||
|
location={{
|
||||||
|
at: 'explorer:doc:linked-docs',
|
||||||
|
docId,
|
||||||
|
}}
|
||||||
|
isLinked
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ExplorerTreeNode>
|
||||||
|
{enableInfoModal && (
|
||||||
|
<InfoModal
|
||||||
|
open={enableInfoModal}
|
||||||
|
onOpenChange={setEnableInfoModal}
|
||||||
|
docId={docId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import {
|
||||||
|
MenuIcon,
|
||||||
|
MenuItem,
|
||||||
|
MenuSeparator,
|
||||||
|
toast,
|
||||||
|
useConfirmModal,
|
||||||
|
} from '@affine/component';
|
||||||
|
import { useAppSettingHelper } from '@affine/core/hooks/affine/use-app-setting-helper';
|
||||||
|
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
||||||
|
import { FavoriteItemsAdapter } from '@affine/core/modules/properties';
|
||||||
|
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||||
|
import { useI18n } from '@affine/i18n';
|
||||||
|
import {
|
||||||
|
DeleteIcon,
|
||||||
|
FavoritedIcon,
|
||||||
|
FavoriteIcon,
|
||||||
|
InformationIcon,
|
||||||
|
LinkedPageIcon,
|
||||||
|
SplitViewIcon,
|
||||||
|
} from '@blocksuite/icons/rc';
|
||||||
|
import { DocsService, useLiveData, useServices } from '@toeverything/infra';
|
||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
|
import type { NodeOperation } from '../../tree/types';
|
||||||
|
|
||||||
|
export const useExplorerDocNodeOperations = (
|
||||||
|
docId: string,
|
||||||
|
options: {
|
||||||
|
openInfoModal: () => void;
|
||||||
|
openNodeCollapsed: () => void;
|
||||||
|
}
|
||||||
|
): NodeOperation[] => {
|
||||||
|
const t = useI18n();
|
||||||
|
const { appSettings } = useAppSettingHelper();
|
||||||
|
const { workbenchService, docsService, favoriteItemsAdapter } = useServices({
|
||||||
|
DocsService,
|
||||||
|
WorkbenchService,
|
||||||
|
FavoriteItemsAdapter,
|
||||||
|
});
|
||||||
|
const { openConfirmModal } = useConfirmModal();
|
||||||
|
|
||||||
|
const docRecord = useLiveData(docsService.list.doc$(docId));
|
||||||
|
|
||||||
|
const favorite = useLiveData(
|
||||||
|
useMemo(
|
||||||
|
() => favoriteItemsAdapter.isFavorite$(docId, 'doc'),
|
||||||
|
[docId, favoriteItemsAdapter]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMoveToTrash = useCallback(() => {
|
||||||
|
if (!docRecord) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openConfirmModal({
|
||||||
|
title: t['com.affine.moveToTrash.title'](),
|
||||||
|
description: t['com.affine.moveToTrash.confirmModal.description']({
|
||||||
|
title: docRecord.title$.value,
|
||||||
|
}),
|
||||||
|
confirmText: t['com.affine.moveToTrash.confirmModal.confirm'](),
|
||||||
|
cancelText: t['com.affine.moveToTrash.confirmModal.cancel'](),
|
||||||
|
confirmButtonOptions: {
|
||||||
|
type: 'error',
|
||||||
|
},
|
||||||
|
onConfirm() {
|
||||||
|
docRecord.moveToTrash();
|
||||||
|
toast(t['com.affine.toastMessage.movedTrash']());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [docRecord, openConfirmModal, t]);
|
||||||
|
|
||||||
|
const handleOpenInSplitView = useCallback(() => {
|
||||||
|
workbenchService.workbench.openDoc(docId, {
|
||||||
|
at: 'beside',
|
||||||
|
});
|
||||||
|
}, [docId, workbenchService]);
|
||||||
|
|
||||||
|
const handleAddLinkedPage = useAsyncCallback(async () => {
|
||||||
|
const newDoc = docsService.createDoc();
|
||||||
|
// TODO: handle timeout & error
|
||||||
|
await docsService.addLinkedDoc(docId, newDoc.id);
|
||||||
|
workbenchService.workbench.openDoc(newDoc.id);
|
||||||
|
options.openNodeCollapsed();
|
||||||
|
}, [docId, options, docsService, workbenchService.workbench]);
|
||||||
|
|
||||||
|
const handleToggleFavoriteDoc = useCallback(() => {
|
||||||
|
favoriteItemsAdapter.toggle(docId, 'doc');
|
||||||
|
}, [favoriteItemsAdapter, docId]);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => [
|
||||||
|
...(runtimeConfig.enableInfoModal
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
index: 50,
|
||||||
|
view: (
|
||||||
|
<MenuItem
|
||||||
|
preFix={
|
||||||
|
<MenuIcon>
|
||||||
|
<InformationIcon />
|
||||||
|
</MenuIcon>
|
||||||
|
}
|
||||||
|
onClick={options.openInfoModal}
|
||||||
|
>
|
||||||
|
{t['com.affine.page-properties.page-info.view']()}
|
||||||
|
</MenuItem>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
index: 99,
|
||||||
|
view: (
|
||||||
|
<MenuItem
|
||||||
|
preFix={
|
||||||
|
<MenuIcon>
|
||||||
|
<LinkedPageIcon />
|
||||||
|
</MenuIcon>
|
||||||
|
}
|
||||||
|
onClick={handleAddLinkedPage}
|
||||||
|
>
|
||||||
|
{t['com.affine.page-operation.add-linked-page']()}
|
||||||
|
</MenuItem>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
...(appSettings.enableMultiView
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
index: 100,
|
||||||
|
view: (
|
||||||
|
<MenuItem
|
||||||
|
preFix={
|
||||||
|
<MenuIcon>
|
||||||
|
<SplitViewIcon />
|
||||||
|
</MenuIcon>
|
||||||
|
}
|
||||||
|
onClick={handleOpenInSplitView}
|
||||||
|
>
|
||||||
|
{t['com.affine.workbench.split-view.page-menu-open']()}
|
||||||
|
</MenuItem>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
index: 199,
|
||||||
|
view: (
|
||||||
|
<MenuItem
|
||||||
|
preFix={
|
||||||
|
<MenuIcon>
|
||||||
|
{favorite ? (
|
||||||
|
<FavoritedIcon
|
||||||
|
style={{ color: 'var(--affine-primary-color)' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FavoriteIcon />
|
||||||
|
)}
|
||||||
|
</MenuIcon>
|
||||||
|
}
|
||||||
|
onClick={handleToggleFavoriteDoc}
|
||||||
|
>
|
||||||
|
{favorite
|
||||||
|
? t['com.affine.favoritePageOperation.remove']()
|
||||||
|
: t['com.affine.favoritePageOperation.add']()}
|
||||||
|
</MenuItem>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 9999,
|
||||||
|
view: <MenuSeparator key="menu-separator" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 10000,
|
||||||
|
view: (
|
||||||
|
<MenuItem
|
||||||
|
type={'danger'}
|
||||||
|
preFix={
|
||||||
|
<MenuIcon>
|
||||||
|
<DeleteIcon />
|
||||||
|
</MenuIcon>
|
||||||
|
}
|
||||||
|
onClick={handleMoveToTrash}
|
||||||
|
>
|
||||||
|
{t['com.affine.moveToTrash.title']()}
|
||||||
|
</MenuItem>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
appSettings.enableMultiView,
|
||||||
|
favorite,
|
||||||
|
handleAddLinkedPage,
|
||||||
|
handleMoveToTrash,
|
||||||
|
handleOpenInSplitView,
|
||||||
|
handleToggleFavoriteDoc,
|
||||||
|
options.openInfoModal,
|
||||||
|
t,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
export const loadingIcon = style({
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { cssVar } from '@toeverything/theme';
|
||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
export const content = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
padding: '9px 20px 25px 21px',
|
||||||
|
});
|
||||||
|
export const iconWrapper = style({
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: cssVar('hoverColor'),
|
||||||
|
});
|
||||||
|
export const icon = style({
|
||||||
|
fontSize: 20,
|
||||||
|
color: cssVar('iconSecondary'),
|
||||||
|
});
|
||||||
|
export const message = style({
|
||||||
|
fontSize: cssVar('fontSm'),
|
||||||
|
textAlign: 'center',
|
||||||
|
color: cssVar('black30'),
|
||||||
|
userSelect: 'none',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const newButton = style({
|
||||||
|
padding: '0 8px',
|
||||||
|
height: '28px',
|
||||||
|
fontSize: cssVar('fontXs'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const draggedOverHighlight = style({
|
||||||
|
selectors: {
|
||||||
|
'&[data-dragged-over="true"]': {
|
||||||
|
background: cssVar('--affine-hover-color'),
|
||||||
|
borderRadius: '4px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
type DropTargetDropEvent,
|
||||||
|
type DropTargetOptions,
|
||||||
|
useDropTarget,
|
||||||
|
} from '@affine/component';
|
||||||
|
import type { AffineDNDData } from '@affine/core/types/dnd';
|
||||||
|
import { useI18n } from '@affine/i18n';
|
||||||
|
import { FolderIcon } from '@blocksuite/icons/rc';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
import * as styles from './empty.css';
|
||||||
|
|
||||||
|
export const FolderEmpty = ({
|
||||||
|
onClickCreate,
|
||||||
|
className,
|
||||||
|
canDrop,
|
||||||
|
onDrop,
|
||||||
|
}: {
|
||||||
|
onClickCreate?: () => void;
|
||||||
|
onDrop?: (data: DropTargetDropEvent<AffineDNDData>) => void;
|
||||||
|
canDrop?: DropTargetOptions<AffineDNDData>['canDrop'];
|
||||||
|
className?: string;
|
||||||
|
}) => {
|
||||||
|
const { dropTargetRef } = useDropTarget(
|
||||||
|
() => ({
|
||||||
|
onDrop,
|
||||||
|
canDrop,
|
||||||
|
}),
|
||||||
|
[onDrop, canDrop]
|
||||||
|
);
|
||||||
|
|
||||||
|
const t = useI18n();
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(styles.content, styles.draggedOverHighlight, className)}
|
||||||
|
ref={dropTargetRef}
|
||||||
|
>
|
||||||
|
<div className={styles.iconWrapper}>
|
||||||
|
<FolderIcon className={styles.icon} />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
data-testid="slider-bar-organize-empty-message"
|
||||||
|
className={styles.message}
|
||||||
|
>
|
||||||
|
{t['com.affine.rootAppSidebar.organize.empty-folder']()}
|
||||||
|
</div>
|
||||||
|
<Button className={styles.newButton} onClick={onClickCreate}>
|
||||||
|
{t['com.affine.rootAppSidebar.organize.empty-folder.add-pages']()}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,612 @@
|
|||||||
|
import {
|
||||||
|
AnimatedFolderIcon,
|
||||||
|
type DropTargetDropEvent,
|
||||||
|
type DropTargetOptions,
|
||||||
|
IconButton,
|
||||||
|
MenuIcon,
|
||||||
|
MenuItem,
|
||||||
|
MenuSeparator,
|
||||||
|
} from '@affine/component';
|
||||||
|
import {
|
||||||
|
type FolderNode,
|
||||||
|
OrganizeService,
|
||||||
|
} from '@affine/core/modules/organize';
|
||||||
|
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||||
|
import type { AffineDNDData } from '@affine/core/types/dnd';
|
||||||
|
import { Unreachable } from '@affine/env/constant';
|
||||||
|
import { useI18n } from '@affine/i18n';
|
||||||
|
import {
|
||||||
|
DeleteIcon,
|
||||||
|
FolderIcon,
|
||||||
|
PlusIcon,
|
||||||
|
RemoveFolderIcon,
|
||||||
|
} from '@blocksuite/icons/rc';
|
||||||
|
import { DocsService, useLiveData, useServices } from '@toeverything/infra';
|
||||||
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { ExplorerTreeNode, type ExplorerTreeNodeDropEffect } from '../../tree';
|
||||||
|
import type { NodeOperation } from '../../tree/types';
|
||||||
|
import { ExplorerCollectionNode } from '../collection';
|
||||||
|
import { ExplorerDocNode } from '../doc';
|
||||||
|
import { ExplorerTagNode } from '../tag';
|
||||||
|
import type { GenericExplorerNode } from '../types';
|
||||||
|
import { FolderEmpty } from './empty';
|
||||||
|
|
||||||
|
export const ExplorerFolderNode = ({
|
||||||
|
nodeId,
|
||||||
|
onDrop,
|
||||||
|
defaultRenaming,
|
||||||
|
operations,
|
||||||
|
location,
|
||||||
|
dropEffect,
|
||||||
|
canDrop,
|
||||||
|
reorderable,
|
||||||
|
}: {
|
||||||
|
defaultRenaming?: boolean;
|
||||||
|
nodeId: string;
|
||||||
|
onDrop?: (data: DropTargetDropEvent<AffineDNDData>, node: FolderNode) => void;
|
||||||
|
operations?:
|
||||||
|
| NodeOperation[]
|
||||||
|
| ((type: string, node: FolderNode) => NodeOperation[]);
|
||||||
|
} & Omit<GenericExplorerNode, 'operations'>) => {
|
||||||
|
const { organizeService } = useServices({ OrganizeService });
|
||||||
|
const node = useLiveData(organizeService.folderTree.folderNode$(nodeId));
|
||||||
|
const type = useLiveData(node?.type$);
|
||||||
|
const data = useLiveData(node?.data$);
|
||||||
|
const handleDrop = useCallback(
|
||||||
|
(data: DropTargetDropEvent<AffineDNDData>) => {
|
||||||
|
if (!node) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onDrop?.(data, node);
|
||||||
|
},
|
||||||
|
[node, onDrop]
|
||||||
|
);
|
||||||
|
const additionalOperations = useMemo(() => {
|
||||||
|
if (!type || !node) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof operations === 'function') {
|
||||||
|
return operations(type, node);
|
||||||
|
}
|
||||||
|
return operations;
|
||||||
|
}, [node, operations, type]);
|
||||||
|
|
||||||
|
if (!node) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'folder') {
|
||||||
|
return (
|
||||||
|
<ExplorerFolderNodeFolder
|
||||||
|
node={node}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
defaultRenaming={defaultRenaming}
|
||||||
|
operations={additionalOperations}
|
||||||
|
dropEffect={dropEffect}
|
||||||
|
reorderable={reorderable}
|
||||||
|
canDrop={canDrop}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (type === 'doc') {
|
||||||
|
return (
|
||||||
|
data && (
|
||||||
|
<ExplorerDocNode
|
||||||
|
docId={data}
|
||||||
|
location={location}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
reorderable={reorderable}
|
||||||
|
canDrop={canDrop}
|
||||||
|
dropEffect={dropEffect}
|
||||||
|
operations={additionalOperations}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else if (type === 'collection') {
|
||||||
|
return (
|
||||||
|
data && (
|
||||||
|
<ExplorerCollectionNode
|
||||||
|
collectionId={data}
|
||||||
|
location={location}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
canDrop={canDrop}
|
||||||
|
reorderable={reorderable}
|
||||||
|
dropEffect={dropEffect}
|
||||||
|
operations={additionalOperations}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else if (type === 'tag') {
|
||||||
|
return (
|
||||||
|
data && (
|
||||||
|
<ExplorerTagNode
|
||||||
|
tagId={data}
|
||||||
|
location={location}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
canDrop={canDrop}
|
||||||
|
reorderable
|
||||||
|
dropEffect={dropEffect}
|
||||||
|
operations={additionalOperations}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ExplorerFolderNodeFolder = ({
|
||||||
|
node,
|
||||||
|
onDrop,
|
||||||
|
defaultRenaming,
|
||||||
|
location,
|
||||||
|
operations: additionalOperations,
|
||||||
|
canDrop,
|
||||||
|
dropEffect,
|
||||||
|
reorderable,
|
||||||
|
}: {
|
||||||
|
defaultRenaming?: boolean;
|
||||||
|
node: FolderNode;
|
||||||
|
} & GenericExplorerNode) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const { docsService, workbenchService } = useServices({
|
||||||
|
DocsService,
|
||||||
|
WorkbenchService,
|
||||||
|
});
|
||||||
|
const name = useLiveData(node.name$);
|
||||||
|
const [collapsed, setCollapsed] = useState(true);
|
||||||
|
const [newFolderId, setNewFolderId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(() => {
|
||||||
|
node.delete();
|
||||||
|
}, [node]);
|
||||||
|
|
||||||
|
const children = useLiveData(node.sortedChildren$);
|
||||||
|
|
||||||
|
const dndData = useMemo(() => {
|
||||||
|
if (!node.id) {
|
||||||
|
throw new Unreachable();
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
draggable: {
|
||||||
|
entity: {
|
||||||
|
type: 'folder',
|
||||||
|
id: node.id,
|
||||||
|
},
|
||||||
|
from: location,
|
||||||
|
},
|
||||||
|
dropTarget: {
|
||||||
|
at: 'explorer:organize:folder',
|
||||||
|
},
|
||||||
|
} satisfies AffineDNDData;
|
||||||
|
}, [location, node.id]);
|
||||||
|
|
||||||
|
const handleRename = useCallback(
|
||||||
|
(newName: string) => {
|
||||||
|
node.rename(newName);
|
||||||
|
},
|
||||||
|
[node]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDropOnFolder = useCallback(
|
||||||
|
(data: DropTargetDropEvent<AffineDNDData>) => {
|
||||||
|
if (data.treeInstruction?.type === 'make-child') {
|
||||||
|
if (data.source.data.entity?.type === 'folder') {
|
||||||
|
if (
|
||||||
|
node.id === data.source.data.entity.id ||
|
||||||
|
node.beChildOf(data.source.data.entity.id)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
node.moveHere(data.source.data.entity.id, node.indexAt('before'));
|
||||||
|
} else if (
|
||||||
|
data.source.data.from?.at === 'explorer:organize:folder-node'
|
||||||
|
) {
|
||||||
|
node.moveHere(data.source.data.from.nodeId, node.indexAt('before'));
|
||||||
|
} else if (
|
||||||
|
data.source.data.entity?.type === 'collection' ||
|
||||||
|
data.source.data.entity?.type === 'doc' ||
|
||||||
|
data.source.data.entity?.type === 'tag'
|
||||||
|
) {
|
||||||
|
node.createLink(
|
||||||
|
data.source.data.entity?.type,
|
||||||
|
data.source.data.entity.id,
|
||||||
|
node.indexAt('before')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onDrop?.(data);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[node, onDrop]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDropEffect = useCallback<ExplorerTreeNodeDropEffect>(
|
||||||
|
data => {
|
||||||
|
if (data.treeInstruction?.type === 'make-child') {
|
||||||
|
if (data.source.data.entity?.type === 'folder') {
|
||||||
|
if (
|
||||||
|
node.id === data.source.data.entity.id ||
|
||||||
|
node.beChildOf(data.source.data.entity.id)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return 'move';
|
||||||
|
} else if (
|
||||||
|
data.source.data.from?.at === 'explorer:organize:folder-node'
|
||||||
|
) {
|
||||||
|
return 'move';
|
||||||
|
} else if (
|
||||||
|
data.source.data.entity?.type === 'collection' ||
|
||||||
|
data.source.data.entity?.type === 'doc' ||
|
||||||
|
data.source.data.entity?.type === 'tag'
|
||||||
|
) {
|
||||||
|
return 'link';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return dropEffect?.(data);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
[dropEffect, node]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDropOnPlaceholder = useCallback(
|
||||||
|
(data: DropTargetDropEvent<AffineDNDData>) => {
|
||||||
|
if (data.source.data.entity?.type === 'folder') {
|
||||||
|
if (
|
||||||
|
node.id === data.source.data.entity.id ||
|
||||||
|
node.beChildOf(data.source.data.entity.id)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
node.moveHere(data.source.data.entity.id, node.indexAt('before'));
|
||||||
|
} else if (
|
||||||
|
data.source.data.from?.at === 'explorer:organize:folder-node'
|
||||||
|
) {
|
||||||
|
node.moveHere(data.source.data.from.nodeId, node.indexAt('before'));
|
||||||
|
} else if (
|
||||||
|
data.source.data.entity?.type === 'collection' ||
|
||||||
|
data.source.data.entity?.type === 'doc' ||
|
||||||
|
data.source.data.entity?.type === 'tag'
|
||||||
|
) {
|
||||||
|
node.createLink(
|
||||||
|
data.source.data.entity?.type,
|
||||||
|
data.source.data.entity.id,
|
||||||
|
node.indexAt('before')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[node]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDropOnChildren = useCallback(
|
||||||
|
(data: DropTargetDropEvent<AffineDNDData>, dropAtNode?: FolderNode) => {
|
||||||
|
if (!dropAtNode || !dropAtNode.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
data.treeInstruction?.type === 'reorder-above' ||
|
||||||
|
data.treeInstruction?.type === 'reorder-below'
|
||||||
|
) {
|
||||||
|
const at =
|
||||||
|
data.treeInstruction?.type === 'reorder-below' ? 'after' : 'before';
|
||||||
|
if (data.source.data.entity?.type === 'folder') {
|
||||||
|
if (
|
||||||
|
node.id === data.source.data.entity.id ||
|
||||||
|
node.beChildOf(data.source.data.entity.id)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
node.moveHere(
|
||||||
|
data.source.data.entity.id,
|
||||||
|
node.indexAt(at, dropAtNode.id)
|
||||||
|
);
|
||||||
|
} else if (
|
||||||
|
data.source.data.from?.at === 'explorer:organize:folder-node'
|
||||||
|
) {
|
||||||
|
node.moveHere(
|
||||||
|
data.source.data.from.nodeId,
|
||||||
|
node.indexAt(at, dropAtNode.id)
|
||||||
|
);
|
||||||
|
} else if (
|
||||||
|
data.source.data.entity?.type === 'collection' ||
|
||||||
|
data.source.data.entity?.type === 'doc' ||
|
||||||
|
data.source.data.entity?.type === 'tag'
|
||||||
|
) {
|
||||||
|
node.createLink(
|
||||||
|
data.source.data.entity?.type,
|
||||||
|
data.source.data.entity.id,
|
||||||
|
node.indexAt(at, dropAtNode.id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (data.treeInstruction?.type === 'reparent') {
|
||||||
|
const currentLevel = data.treeInstruction.currentLevel;
|
||||||
|
const desiredLevel = data.treeInstruction.desiredLevel;
|
||||||
|
if (currentLevel === desiredLevel + 1) {
|
||||||
|
onDrop?.({
|
||||||
|
...data,
|
||||||
|
treeInstruction: {
|
||||||
|
type: 'reorder-below',
|
||||||
|
currentLevel,
|
||||||
|
indentPerLevel: data.treeInstruction.indentPerLevel,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
onDrop?.({
|
||||||
|
...data,
|
||||||
|
treeInstruction: {
|
||||||
|
...data.treeInstruction,
|
||||||
|
currentLevel: currentLevel - 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[node, onDrop]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDropEffectOnChildren = useCallback<ExplorerTreeNodeDropEffect>(
|
||||||
|
data => {
|
||||||
|
if (
|
||||||
|
data.treeInstruction?.type === 'reorder-above' ||
|
||||||
|
data.treeInstruction?.type === 'reorder-below'
|
||||||
|
) {
|
||||||
|
if (data.source.data.entity?.type === 'folder') {
|
||||||
|
if (
|
||||||
|
node.id === data.source.data.entity.id ||
|
||||||
|
node.beChildOf(data.source.data.entity.id)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return 'move';
|
||||||
|
} else if (
|
||||||
|
data.source.data.from?.at === 'explorer:organize:folder-node'
|
||||||
|
) {
|
||||||
|
return 'move';
|
||||||
|
} else if (
|
||||||
|
data.source.data.entity?.type === 'collection' ||
|
||||||
|
data.source.data.entity?.type === 'doc' ||
|
||||||
|
data.source.data.entity?.type === 'tag'
|
||||||
|
) {
|
||||||
|
return 'link';
|
||||||
|
}
|
||||||
|
} else if (data.treeInstruction?.type === 'reparent') {
|
||||||
|
const currentLevel = data.treeInstruction.currentLevel;
|
||||||
|
const desiredLevel = data.treeInstruction.desiredLevel;
|
||||||
|
if (currentLevel === desiredLevel + 1) {
|
||||||
|
dropEffect?.({
|
||||||
|
...data,
|
||||||
|
treeInstruction: {
|
||||||
|
type: 'reorder-below',
|
||||||
|
currentLevel,
|
||||||
|
indentPerLevel: data.treeInstruction.indentPerLevel,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
dropEffect?.({
|
||||||
|
...data,
|
||||||
|
treeInstruction: {
|
||||||
|
...data.treeInstruction,
|
||||||
|
currentLevel: currentLevel - 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
[dropEffect, node]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCanDrop = useMemo<DropTargetOptions<AffineDNDData>['canDrop']>(
|
||||||
|
() => args => {
|
||||||
|
const entityType = args.source.data.entity?.type;
|
||||||
|
if (args.treeInstruction && args.treeInstruction?.type !== 'make-child') {
|
||||||
|
return (
|
||||||
|
(typeof canDrop === 'function' ? canDrop(args) : canDrop) ?? true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.source.data.entity?.type === 'folder') {
|
||||||
|
if (
|
||||||
|
node.id === args.source.data.entity.id ||
|
||||||
|
node.beChildOf(args.source.data.entity.id)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} else if (
|
||||||
|
args.source.data.from?.at === 'explorer:organize:folder-node'
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
} else if (
|
||||||
|
entityType === 'collection' ||
|
||||||
|
entityType === 'doc' ||
|
||||||
|
entityType === 'tag'
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
[canDrop, node]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChildrenCanDrop = useMemo<
|
||||||
|
DropTargetOptions<AffineDNDData>['canDrop']
|
||||||
|
>(
|
||||||
|
() => args => {
|
||||||
|
const entityType = args.source.data.entity?.type;
|
||||||
|
|
||||||
|
if (args.source.data.entity?.type === 'folder') {
|
||||||
|
if (
|
||||||
|
node.id === args.source.data.entity.id ||
|
||||||
|
node.beChildOf(args.source.data.entity.id)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} else if (
|
||||||
|
args.source.data.from?.at === 'explorer:organize:folder-node'
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
} else if (
|
||||||
|
entityType === 'collection' ||
|
||||||
|
entityType === 'doc' ||
|
||||||
|
entityType === 'tag'
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
[node]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleNewDoc = useCallback(() => {
|
||||||
|
const newDoc = docsService.createDoc();
|
||||||
|
node.createLink('doc', newDoc.id, node.indexAt('before'));
|
||||||
|
workbenchService.workbench.openDoc(newDoc.id);
|
||||||
|
setCollapsed(false);
|
||||||
|
}, [docsService, node, workbenchService.workbench]);
|
||||||
|
|
||||||
|
const handleCreateSubfolder = useCallback(() => {
|
||||||
|
const newFolderId = node.createFolder(
|
||||||
|
t['com.affine.rootAppSidebar.organize.new-folders'](),
|
||||||
|
node.indexAt('before')
|
||||||
|
);
|
||||||
|
setCollapsed(false);
|
||||||
|
setNewFolderId(newFolderId);
|
||||||
|
}, [node, t]);
|
||||||
|
|
||||||
|
const folderOperations = useMemo(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
inline: true,
|
||||||
|
view: (
|
||||||
|
<IconButton size="small" type="plain" onClick={handleNewDoc}>
|
||||||
|
<PlusIcon />
|
||||||
|
</IconButton>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 100,
|
||||||
|
view: (
|
||||||
|
<MenuItem
|
||||||
|
preFix={
|
||||||
|
<MenuIcon>
|
||||||
|
<FolderIcon />
|
||||||
|
</MenuIcon>
|
||||||
|
}
|
||||||
|
onClick={handleCreateSubfolder}
|
||||||
|
>
|
||||||
|
{t['com.affine.rootAppSidebar.organize.folder.create-subfolder']()}
|
||||||
|
</MenuItem>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 9999,
|
||||||
|
view: <MenuSeparator key="menu-separator" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 10000,
|
||||||
|
view: (
|
||||||
|
<MenuItem
|
||||||
|
type={'danger'}
|
||||||
|
preFix={
|
||||||
|
<MenuIcon>
|
||||||
|
<DeleteIcon />
|
||||||
|
</MenuIcon>
|
||||||
|
}
|
||||||
|
onClick={handleDelete}
|
||||||
|
>
|
||||||
|
{t['com.affine.rootAppSidebar.organize.delete']()}
|
||||||
|
</MenuItem>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [handleCreateSubfolder, handleDelete, handleNewDoc, t]);
|
||||||
|
|
||||||
|
const finalOperations = useMemo(() => {
|
||||||
|
if (additionalOperations) {
|
||||||
|
return [...additionalOperations, ...folderOperations];
|
||||||
|
}
|
||||||
|
return folderOperations;
|
||||||
|
}, [additionalOperations, folderOperations]);
|
||||||
|
|
||||||
|
const handleDeleteChildren = useCallback((node: FolderNode) => {
|
||||||
|
node.delete();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const childrenOperations = useCallback(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||||
|
(type: string, node: FolderNode) => {
|
||||||
|
if (type === 'doc' || type === 'collection' || type === 'tag') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
index: 999,
|
||||||
|
view: (
|
||||||
|
<MenuItem
|
||||||
|
type={'danger'}
|
||||||
|
preFix={
|
||||||
|
<MenuIcon>
|
||||||
|
<RemoveFolderIcon />
|
||||||
|
</MenuIcon>
|
||||||
|
}
|
||||||
|
onClick={() => handleDeleteChildren(node)}
|
||||||
|
>
|
||||||
|
{t['com.affine.rootAppSidebar.organize.delete-from-folder']()}
|
||||||
|
</MenuItem>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
] satisfies NodeOperation[];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
[handleDeleteChildren, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ExplorerTreeNode
|
||||||
|
icon={({ draggedOver, className, treeInstruction }) => (
|
||||||
|
<AnimatedFolderIcon
|
||||||
|
className={className}
|
||||||
|
closed={!!draggedOver && treeInstruction?.type === 'make-child'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
name={name}
|
||||||
|
dndData={dndData}
|
||||||
|
onDrop={handleDropOnFolder}
|
||||||
|
defaultRenaming={defaultRenaming}
|
||||||
|
renameable
|
||||||
|
reorderable={reorderable}
|
||||||
|
collapsed={collapsed}
|
||||||
|
setCollapsed={setCollapsed}
|
||||||
|
onRename={handleRename}
|
||||||
|
operations={finalOperations}
|
||||||
|
canDrop={handleCanDrop}
|
||||||
|
childrenPlaceholder={
|
||||||
|
<FolderEmpty canDrop={handleCanDrop} onDrop={handleDropOnPlaceholder} />
|
||||||
|
}
|
||||||
|
dropEffect={handleDropEffect}
|
||||||
|
data-testid={`explorer-folder-${node.id}`}
|
||||||
|
>
|
||||||
|
{children.map(child => (
|
||||||
|
<ExplorerFolderNode
|
||||||
|
key={child.id}
|
||||||
|
nodeId={child.id as string}
|
||||||
|
defaultRenaming={child.id === newFolderId}
|
||||||
|
onDrop={handleDropOnChildren}
|
||||||
|
operations={childrenOperations}
|
||||||
|
dropEffect={handleDropEffectOnChildren}
|
||||||
|
canDrop={handleChildrenCanDrop}
|
||||||
|
location={{
|
||||||
|
at: 'explorer:organize:folder-node',
|
||||||
|
nodeId: child.id as string,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ExplorerTreeNode>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { cssVar } from '@toeverything/theme';
|
||||||
|
import { fallbackVar, style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
import { levelIndent } from '../../tree/node.css';
|
||||||
|
|
||||||
|
export const noReferences = style({
|
||||||
|
fontSize: cssVar('fontSm'),
|
||||||
|
textAlign: 'left',
|
||||||
|
padding: '4px 0 4px 32px',
|
||||||
|
color: cssVar('black30'),
|
||||||
|
userSelect: 'none',
|
||||||
|
paddingLeft: `calc(${fallbackVar(levelIndent, '20px')} + 32px)`,
|
||||||
|
selectors: {
|
||||||
|
'&[data-dragged-over="true"]': {
|
||||||
|
background: cssVar('--affine-hover-color'),
|
||||||
|
borderRadius: '4px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { type DropTargetDropEvent, useDropTarget } from '@affine/component';
|
||||||
|
import type { AffineDNDData } from '@affine/core/types/dnd';
|
||||||
|
import { useI18n } from '@affine/i18n';
|
||||||
|
|
||||||
|
import * as styles from './empty.css';
|
||||||
|
|
||||||
|
export const Empty = ({
|
||||||
|
onDrop,
|
||||||
|
}: {
|
||||||
|
onDrop: (data: DropTargetDropEvent<AffineDNDData>) => void;
|
||||||
|
}) => {
|
||||||
|
const { dropTargetRef } = useDropTarget(
|
||||||
|
() => ({
|
||||||
|
onDrop,
|
||||||
|
}),
|
||||||
|
[onDrop]
|
||||||
|
);
|
||||||
|
const t = useI18n();
|
||||||
|
return (
|
||||||
|
<div className={styles.noReferences} ref={dropTargetRef}>
|
||||||
|
{t['com.affine.rootAppSidebar.tags.no-doc']()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import {
|
||||||
|
type DropTargetDropEvent,
|
||||||
|
type DropTargetOptions,
|
||||||
|
toast,
|
||||||
|
} from '@affine/component';
|
||||||
|
import { TagService } from '@affine/core/modules/tag';
|
||||||
|
import type { AffineDNDData } from '@affine/core/types/dnd';
|
||||||
|
import { useI18n } from '@affine/i18n';
|
||||||
|
import {
|
||||||
|
GlobalContextService,
|
||||||
|
useLiveData,
|
||||||
|
useServices,
|
||||||
|
} from '@toeverything/infra';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { ExplorerTreeNode, type ExplorerTreeNodeDropEffect } from '../../tree';
|
||||||
|
import { ExplorerDocNode } from '../doc';
|
||||||
|
import type { GenericExplorerNode } from '../types';
|
||||||
|
import { Empty } from './empty';
|
||||||
|
import { useExplorerTagNodeOperations } from './operations';
|
||||||
|
import * as styles from './styles.css';
|
||||||
|
|
||||||
|
export const ExplorerTagNode = ({
|
||||||
|
tagId,
|
||||||
|
onDrop,
|
||||||
|
location,
|
||||||
|
reorderable,
|
||||||
|
operations: additionalOperations,
|
||||||
|
dropEffect,
|
||||||
|
canDrop,
|
||||||
|
defaultRenaming,
|
||||||
|
}: {
|
||||||
|
tagId: string;
|
||||||
|
defaultRenaming?: boolean;
|
||||||
|
} & GenericExplorerNode) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const { tagService, globalContextService } = useServices({
|
||||||
|
TagService,
|
||||||
|
GlobalContextService,
|
||||||
|
});
|
||||||
|
const active =
|
||||||
|
useLiveData(globalContextService.globalContext.tagId.$) === tagId;
|
||||||
|
const [collapsed, setCollapsed] = useState(true);
|
||||||
|
|
||||||
|
const tagRecord = useLiveData(tagService.tagList.tagByTagId$(tagId));
|
||||||
|
const tagColor = useLiveData(tagRecord?.color$);
|
||||||
|
const tagName = useLiveData(tagRecord?.value$);
|
||||||
|
const tagDocIds = useLiveData(tagRecord?.pageIds$);
|
||||||
|
|
||||||
|
const Icon = useCallback(
|
||||||
|
({ className }: { className?: string }) => {
|
||||||
|
return (
|
||||||
|
<div className={clsx(styles.tagIconContainer, className)}>
|
||||||
|
<div
|
||||||
|
className={styles.tagIcon}
|
||||||
|
style={{
|
||||||
|
backgroundColor: tagColor,
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[tagColor]
|
||||||
|
);
|
||||||
|
|
||||||
|
const dndData = useMemo(() => {
|
||||||
|
return {
|
||||||
|
draggable: {
|
||||||
|
entity: {
|
||||||
|
type: 'tag',
|
||||||
|
id: tagId,
|
||||||
|
},
|
||||||
|
from: location,
|
||||||
|
},
|
||||||
|
dropTarget: {
|
||||||
|
at: 'explorer:tag',
|
||||||
|
},
|
||||||
|
} satisfies AffineDNDData;
|
||||||
|
}, [location, tagId]);
|
||||||
|
|
||||||
|
const handleRename = useCallback(
|
||||||
|
(newName: string) => {
|
||||||
|
if (tagRecord) {
|
||||||
|
tagRecord.rename(newName);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[tagRecord]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDropOnTag = useCallback(
|
||||||
|
(data: DropTargetDropEvent<AffineDNDData>) => {
|
||||||
|
if (data.treeInstruction?.type === 'make-child' && tagRecord) {
|
||||||
|
if (data.source.data.entity?.type === 'doc') {
|
||||||
|
tagRecord.tag(data.source.data.entity.id);
|
||||||
|
} else {
|
||||||
|
toast(t['com.affine.rootAppSidebar.tag.doc-only']());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onDrop?.(data);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onDrop, t, tagRecord]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDropEffectOnTag = useCallback<ExplorerTreeNodeDropEffect>(
|
||||||
|
data => {
|
||||||
|
if (data.treeInstruction?.type === 'make-child') {
|
||||||
|
if (data.source.data.entity?.type === 'doc') {
|
||||||
|
return 'link';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return dropEffect?.(data);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
[dropEffect]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDropOnPlaceholder = useCallback(
|
||||||
|
(data: DropTargetDropEvent<AffineDNDData>) => {
|
||||||
|
if (tagRecord) {
|
||||||
|
if (data.source.data.entity?.type === 'doc') {
|
||||||
|
tagRecord.tag(data.source.data.entity.id);
|
||||||
|
} else {
|
||||||
|
toast(t['com.affine.rootAppSidebar.tag.doc-only']());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[t, tagRecord]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCanDrop = useMemo<DropTargetOptions<AffineDNDData>['canDrop']>(
|
||||||
|
() => args => {
|
||||||
|
const entityType = args.source.data.entity?.type;
|
||||||
|
return args.treeInstruction?.type !== 'make-child'
|
||||||
|
? ((typeof canDrop === 'function' ? canDrop(args) : canDrop) ?? true)
|
||||||
|
: entityType === 'doc';
|
||||||
|
},
|
||||||
|
[canDrop]
|
||||||
|
);
|
||||||
|
|
||||||
|
const operations = useExplorerTagNodeOperations(
|
||||||
|
tagId,
|
||||||
|
useMemo(
|
||||||
|
() => ({
|
||||||
|
openNodeCollapsed: () => setCollapsed(false),
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const finalOperations = useMemo(() => {
|
||||||
|
if (additionalOperations) {
|
||||||
|
return [...operations, ...additionalOperations];
|
||||||
|
}
|
||||||
|
return operations;
|
||||||
|
}, [additionalOperations, operations]);
|
||||||
|
|
||||||
|
if (!tagRecord) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ExplorerTreeNode
|
||||||
|
icon={Icon}
|
||||||
|
name={tagName || t['Untitled']()}
|
||||||
|
dndData={dndData}
|
||||||
|
onDrop={handleDropOnTag}
|
||||||
|
renameable
|
||||||
|
collapsed={collapsed}
|
||||||
|
setCollapsed={setCollapsed}
|
||||||
|
to={`/tag/${tagId}`}
|
||||||
|
active={active}
|
||||||
|
defaultRenaming={defaultRenaming}
|
||||||
|
reorderable={reorderable}
|
||||||
|
onRename={handleRename}
|
||||||
|
canDrop={handleCanDrop}
|
||||||
|
childrenPlaceholder={<Empty onDrop={handleDropOnPlaceholder} />}
|
||||||
|
operations={finalOperations}
|
||||||
|
dropEffect={handleDropEffectOnTag}
|
||||||
|
data-testid={`explorer-tag-${tagId}`}
|
||||||
|
>
|
||||||
|
{tagDocIds?.map(docId => (
|
||||||
|
<ExplorerDocNode
|
||||||
|
key={docId}
|
||||||
|
docId={docId}
|
||||||
|
reorderable={false}
|
||||||
|
location={{
|
||||||
|
at: 'explorer:tags:docs',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ExplorerTreeNode>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import {
|
||||||
|
IconButton,
|
||||||
|
MenuIcon,
|
||||||
|
MenuItem,
|
||||||
|
MenuSeparator,
|
||||||
|
toast,
|
||||||
|
} from '@affine/component';
|
||||||
|
import { useAppSettingHelper } from '@affine/core/hooks/affine/use-app-setting-helper';
|
||||||
|
import { TagService } from '@affine/core/modules/tag';
|
||||||
|
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||||
|
import { useI18n } from '@affine/i18n';
|
||||||
|
import { DeleteIcon, PlusIcon, SplitViewIcon } from '@blocksuite/icons/rc';
|
||||||
|
import { DocsService, useLiveData, useServices } from '@toeverything/infra';
|
||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
|
import type { NodeOperation } from '../../tree/types';
|
||||||
|
|
||||||
|
export const useExplorerTagNodeOperations = (
|
||||||
|
tagId: string,
|
||||||
|
{
|
||||||
|
openNodeCollapsed,
|
||||||
|
}: {
|
||||||
|
openNodeCollapsed: () => void;
|
||||||
|
}
|
||||||
|
): NodeOperation[] => {
|
||||||
|
const t = useI18n();
|
||||||
|
const { appSettings } = useAppSettingHelper();
|
||||||
|
const { docsService, workbenchService, tagService } = useServices({
|
||||||
|
WorkbenchService,
|
||||||
|
TagService,
|
||||||
|
DocsService,
|
||||||
|
});
|
||||||
|
|
||||||
|
const tagRecord = useLiveData(tagService.tagList.tagByTagId$(tagId));
|
||||||
|
|
||||||
|
const handleNewDoc = useCallback(() => {
|
||||||
|
if (tagRecord) {
|
||||||
|
const newDoc = docsService.createDoc();
|
||||||
|
tagRecord?.tag(newDoc.id);
|
||||||
|
workbenchService.workbench.openDoc(newDoc.id);
|
||||||
|
openNodeCollapsed();
|
||||||
|
}
|
||||||
|
}, [docsService, openNodeCollapsed, tagRecord, workbenchService.workbench]);
|
||||||
|
|
||||||
|
const handleMoveToTrash = useCallback(() => {
|
||||||
|
tagService.tagList.deleteTag(tagId);
|
||||||
|
toast(t['com.affine.tags.delete-tags.toast']());
|
||||||
|
}, [t, tagId, tagService.tagList]);
|
||||||
|
|
||||||
|
const handleOpenInSplitView = useCallback(() => {
|
||||||
|
workbenchService.workbench.openTag(tagId, {
|
||||||
|
at: 'beside',
|
||||||
|
});
|
||||||
|
}, [tagId, workbenchService]);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
inline: true,
|
||||||
|
view: (
|
||||||
|
<IconButton size="small" type="plain" onClick={handleNewDoc}>
|
||||||
|
<PlusIcon />
|
||||||
|
</IconButton>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
...(appSettings.enableMultiView
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
index: 100,
|
||||||
|
view: (
|
||||||
|
<MenuItem
|
||||||
|
preFix={
|
||||||
|
<MenuIcon>
|
||||||
|
<SplitViewIcon />
|
||||||
|
</MenuIcon>
|
||||||
|
}
|
||||||
|
onClick={handleOpenInSplitView}
|
||||||
|
>
|
||||||
|
{t['com.affine.workbench.split-view.page-menu-open']()}
|
||||||
|
</MenuItem>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
index: 9999,
|
||||||
|
view: <MenuSeparator key="menu-separator" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 10000,
|
||||||
|
view: (
|
||||||
|
<MenuItem
|
||||||
|
type={'danger'}
|
||||||
|
preFix={
|
||||||
|
<MenuIcon>
|
||||||
|
<DeleteIcon />
|
||||||
|
</MenuIcon>
|
||||||
|
}
|
||||||
|
onClick={handleMoveToTrash}
|
||||||
|
>
|
||||||
|
{t['Delete']()}
|
||||||
|
</MenuItem>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
appSettings.enableMultiView,
|
||||||
|
handleMoveToTrash,
|
||||||
|
handleNewDoc,
|
||||||
|
handleOpenInSplitView,
|
||||||
|
t,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
export const tagIcon = style({
|
||||||
|
borderRadius: '50%',
|
||||||
|
height: '8px',
|
||||||
|
width: '8px',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const tagIconContainer = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '1em',
|
||||||
|
height: '1em',
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { DropTargetDropEvent, DropTargetOptions } from '@affine/component';
|
||||||
|
import type { AffineDNDData } from '@affine/core/types/dnd';
|
||||||
|
|
||||||
|
import type { ExplorerTreeNodeDropEffect } from '../tree';
|
||||||
|
import type { NodeOperation } from '../tree/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The interface for a generic explorer node.
|
||||||
|
*
|
||||||
|
* # Drop controlled area
|
||||||
|
*
|
||||||
|
* When an element is dragged over the node, there are two controlled areas depending on the mouse position.
|
||||||
|
*
|
||||||
|
* **Make Child Area**:
|
||||||
|
* When the mouse is in the center area of the node, it is in `Make Child Area`,
|
||||||
|
* `canDrop`, `onDrop`, and `dropEffect` are handled by the node itself.
|
||||||
|
*
|
||||||
|
* **Edge Area**:
|
||||||
|
* When the mouse is at the upper edge, lower edge, or front of a node, it is located in the `Edge Area`,
|
||||||
|
* and all drop events are handled by the node's parent, which callbacks in this interface.
|
||||||
|
*
|
||||||
|
* The controlled area can be distinguished by `data.treeInstruction.type` in the callback parameter.
|
||||||
|
*/
|
||||||
|
export interface GenericExplorerNode {
|
||||||
|
/**
|
||||||
|
* Tell the node and dropTarget where the node is located in the tree
|
||||||
|
*/
|
||||||
|
location?: AffineDNDData['draggable']['from'];
|
||||||
|
/**
|
||||||
|
* Whether the node is allowed to reorder with its sibling nodes
|
||||||
|
*/
|
||||||
|
reorderable?: boolean;
|
||||||
|
/**
|
||||||
|
* Additional operations to be displayed in the node
|
||||||
|
*/
|
||||||
|
operations?: NodeOperation[];
|
||||||
|
/**
|
||||||
|
* Control whether drop is allowed, the callback will be called when dragging.
|
||||||
|
*/
|
||||||
|
canDrop?: DropTargetOptions<AffineDNDData>['canDrop'];
|
||||||
|
/**
|
||||||
|
* Called when an element is dropped over the node.
|
||||||
|
*/
|
||||||
|
onDrop?: (data: DropTargetDropEvent<AffineDNDData>) => void;
|
||||||
|
/**
|
||||||
|
* The drop effect to be used when an element is dropped over the node.
|
||||||
|
*/
|
||||||
|
dropEffect?: ExplorerTreeNodeDropEffect;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { cssVar } from '@toeverything/theme';
|
||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
export const content = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
padding: '9px 20px 25px 21px',
|
||||||
|
});
|
||||||
|
export const iconWrapper = style({
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: cssVar('hoverColor'),
|
||||||
|
});
|
||||||
|
export const icon = style({
|
||||||
|
fontSize: 20,
|
||||||
|
color: cssVar('iconSecondary'),
|
||||||
|
});
|
||||||
|
export const message = style({
|
||||||
|
fontSize: cssVar('fontSm'),
|
||||||
|
textAlign: 'center',
|
||||||
|
color: cssVar('black30'),
|
||||||
|
userSelect: 'none',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const newButton = style({
|
||||||
|
padding: '0 8px',
|
||||||
|
height: '28px',
|
||||||
|
fontSize: cssVar('fontXs'),
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Button } from '@affine/component';
|
||||||
|
import { useI18n } from '@affine/i18n';
|
||||||
|
import { ViewLayersIcon } from '@blocksuite/icons/rc';
|
||||||
|
|
||||||
|
import * as styles from './empty.css';
|
||||||
|
|
||||||
|
export const RootEmpty = ({
|
||||||
|
onClickCreate,
|
||||||
|
}: {
|
||||||
|
onClickCreate?: () => void;
|
||||||
|
}) => {
|
||||||
|
const t = useI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.content}>
|
||||||
|
<div className={styles.iconWrapper}>
|
||||||
|
<ViewLayersIcon className={styles.icon} />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
data-testid="slider-bar-collection-empty-message"
|
||||||
|
className={styles.message}
|
||||||
|
>
|
||||||
|
{t['com.affine.collections.empty.message']()}
|
||||||
|
</div>
|
||||||
|
<Button className={styles.newButton} onClick={onClickCreate}>
|
||||||
|
{t['com.affine.collections.empty.new-collection-button']()}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { IconButton } from '@affine/component';
|
||||||
|
import { CategoryDivider } from '@affine/core/components/app-sidebar';
|
||||||
|
import { useEditCollectionName } from '@affine/core/components/page-list';
|
||||||
|
import { createEmptyCollection } from '@affine/core/components/page-list/use-collection-manager';
|
||||||
|
import { CollectionService } from '@affine/core/modules/collection';
|
||||||
|
import { ExplorerTreeRoot } from '@affine/core/modules/explorer/views/tree';
|
||||||
|
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||||
|
import { useI18n } from '@affine/i18n';
|
||||||
|
import { PlusIcon } from '@blocksuite/icons/rc';
|
||||||
|
import { useLiveData, useServices } from '@toeverything/infra';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
|
import { ExplorerCollectionNode } from '../../nodes/collection';
|
||||||
|
import { RootEmpty } from './empty';
|
||||||
|
import * as styles from './styles.css';
|
||||||
|
|
||||||
|
export const ExplorerCollections = () => {
|
||||||
|
const t = useI18n();
|
||||||
|
const { collectionService, workbenchService } = useServices({
|
||||||
|
CollectionService,
|
||||||
|
WorkbenchService,
|
||||||
|
});
|
||||||
|
const collections = useLiveData(collectionService.collections$);
|
||||||
|
const { node, open: openCreateCollectionModel } = useEditCollectionName({
|
||||||
|
title: t['com.affine.editCollection.createCollection'](),
|
||||||
|
showTips: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCreateCollection = useCallback(() => {
|
||||||
|
openCreateCollectionModel('')
|
||||||
|
.then(name => {
|
||||||
|
const id = nanoid();
|
||||||
|
collectionService.addCollection(createEmptyCollection(id, { name }));
|
||||||
|
workbenchService.workbench.openCollection(id);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
});
|
||||||
|
}, [collectionService, openCreateCollectionModel, workbenchService]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles.container} data-testid="explorer-collections">
|
||||||
|
<CategoryDivider label={t['com.affine.rootAppSidebar.collections']()}>
|
||||||
|
<IconButton
|
||||||
|
data-testid="explorer-bar-add-collection-button"
|
||||||
|
onClick={handleCreateCollection}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<PlusIcon />
|
||||||
|
</IconButton>
|
||||||
|
</CategoryDivider>
|
||||||
|
<ExplorerTreeRoot
|
||||||
|
placeholder={<RootEmpty onClickCreate={handleCreateCollection} />}
|
||||||
|
>
|
||||||
|
{collections.map(collection => (
|
||||||
|
<ExplorerCollectionNode
|
||||||
|
key={collection.id}
|
||||||
|
collectionId={collection.id}
|
||||||
|
reorderable={false}
|
||||||
|
location={{
|
||||||
|
at: 'explorer:collection:list',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ExplorerTreeRoot>
|
||||||
|
</div>
|
||||||
|
{node}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
export const container = style({
|
||||||
|
marginTop: '16px',
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { cssVar } from '@toeverything/theme';
|
||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
export const content = style({
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
padding: '9px 20px 25px 21px',
|
||||||
|
});
|
||||||
|
export const iconWrapper = style({
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: cssVar('hoverColor'),
|
||||||
|
});
|
||||||
|
export const icon = style({
|
||||||
|
fontSize: 20,
|
||||||
|
color: cssVar('iconSecondary'),
|
||||||
|
});
|
||||||
|
export const message = style({
|
||||||
|
fontSize: cssVar('fontSm'),
|
||||||
|
textAlign: 'center',
|
||||||
|
color: cssVar('black30'),
|
||||||
|
userSelect: 'none',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const newButton = style({
|
||||||
|
padding: '0 8px',
|
||||||
|
height: '28px',
|
||||||
|
fontSize: cssVar('fontXs'),
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import {
|
||||||
|
type DropTargetDropEvent,
|
||||||
|
type DropTargetOptions,
|
||||||
|
useDropTarget,
|
||||||
|
} from '@affine/component';
|
||||||
|
import type { AffineDNDData } from '@affine/core/types/dnd';
|
||||||
|
import { useI18n } from '@affine/i18n';
|
||||||
|
import { FolderIcon } from '@blocksuite/icons/rc';
|
||||||
|
|
||||||
|
import { DropEffect, type ExplorerTreeNodeDropEffect } from '../../tree';
|
||||||
|
import * as styles from './empty.css';
|
||||||
|
|
||||||
|
export const RootEmpty = ({
|
||||||
|
onDrop,
|
||||||
|
canDrop,
|
||||||
|
dropEffect,
|
||||||
|
}: {
|
||||||
|
onDrop?: (data: DropTargetDropEvent<AffineDNDData>) => void;
|
||||||
|
canDrop?: DropTargetOptions<AffineDNDData>['canDrop'];
|
||||||
|
dropEffect?: ExplorerTreeNodeDropEffect;
|
||||||
|
}) => {
|
||||||
|
const t = useI18n();
|
||||||
|
|
||||||
|
const { dropTargetRef, draggedOverDraggable, draggedOverPosition } =
|
||||||
|
useDropTarget<AffineDNDData>(
|
||||||
|
() => ({
|
||||||
|
data: {
|
||||||
|
at: 'explorer:favorite:root',
|
||||||
|
},
|
||||||
|
onDrop: onDrop,
|
||||||
|
canDrop: canDrop,
|
||||||
|
}),
|
||||||
|
[onDrop, canDrop]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.content} ref={dropTargetRef}>
|
||||||
|
<div className={styles.iconWrapper}>
|
||||||
|
<FolderIcon className={styles.icon} />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
data-testid="slider-bar-organize-empty-message"
|
||||||
|
className={styles.message}
|
||||||
|
>
|
||||||
|
{t['com.affine.rootAppSidebar.organize.empty']()}
|
||||||
|
</div>
|
||||||
|
{dropEffect && draggedOverDraggable && (
|
||||||
|
<DropEffect
|
||||||
|
position={{
|
||||||
|
x: draggedOverPosition.relativeX,
|
||||||
|
y: draggedOverPosition.relativeY,
|
||||||
|
}}
|
||||||
|
dropEffect={dropEffect({
|
||||||
|
source: draggedOverDraggable,
|
||||||
|
treeInstruction: null,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
import {
|
||||||
|
type DropTargetDropEvent,
|
||||||
|
type DropTargetOptions,
|
||||||
|
IconButton,
|
||||||
|
useDropTarget,
|
||||||
|
} from '@affine/component';
|
||||||
|
import { CategoryDivider } from '@affine/core/components/app-sidebar';
|
||||||
|
import {
|
||||||
|
DropEffect,
|
||||||
|
type ExplorerTreeNodeDropEffect,
|
||||||
|
ExplorerTreeRoot,
|
||||||
|
} from '@affine/core/modules/explorer/views/tree';
|
||||||
|
import { FavoriteItemsAdapter } from '@affine/core/modules/properties';
|
||||||
|
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||||
|
import type { AffineDNDData } from '@affine/core/types/dnd';
|
||||||
|
import { useI18n } from '@affine/i18n';
|
||||||
|
import { PlusIcon } from '@blocksuite/icons/rc';
|
||||||
|
import { DocsService, useLiveData, useServices } from '@toeverything/infra';
|
||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
|
import { ExplorerCollectionNode } from '../../nodes/collection';
|
||||||
|
import { ExplorerDocNode } from '../../nodes/doc';
|
||||||
|
import { RootEmpty } from './empty';
|
||||||
|
import * as styles from './styles.css';
|
||||||
|
|
||||||
|
export const ExplorerFavorites = () => {
|
||||||
|
const { favoriteItemsAdapter, docsService, workbenchService } = useServices({
|
||||||
|
FavoriteItemsAdapter,
|
||||||
|
DocsService,
|
||||||
|
WorkbenchService,
|
||||||
|
});
|
||||||
|
|
||||||
|
const docs = useLiveData(docsService.list.docs$);
|
||||||
|
const trashDocs = useLiveData(docsService.list.trashDocs$);
|
||||||
|
|
||||||
|
const favorites = useLiveData(
|
||||||
|
favoriteItemsAdapter.orderedFavorites$.map(favs => {
|
||||||
|
return favs.filter(fav => {
|
||||||
|
if (fav.type === 'doc') {
|
||||||
|
return (
|
||||||
|
docs.some(doc => doc.id === fav.id) &&
|
||||||
|
!trashDocs.some(doc => doc.id === fav.id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const t = useI18n();
|
||||||
|
|
||||||
|
const handleDrop = useCallback(
|
||||||
|
(data: DropTargetDropEvent<AffineDNDData>) => {
|
||||||
|
if (
|
||||||
|
data.source.data.entity?.type === 'doc' ||
|
||||||
|
data.source.data.entity?.type === 'collection'
|
||||||
|
) {
|
||||||
|
favoriteItemsAdapter.set(
|
||||||
|
data.source.data.entity.id,
|
||||||
|
data.source.data.entity?.type,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[favoriteItemsAdapter]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDropEffect = useCallback<ExplorerTreeNodeDropEffect>(data => {
|
||||||
|
if (
|
||||||
|
data.source.data.entity?.type === 'doc' ||
|
||||||
|
data.source.data.entity?.type === 'collection'
|
||||||
|
) {
|
||||||
|
return 'link';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCanDrop = useMemo<DropTargetOptions<AffineDNDData>['canDrop']>(
|
||||||
|
() => data => {
|
||||||
|
return (
|
||||||
|
data.source.data.entity?.type === 'doc' ||
|
||||||
|
data.source.data.entity?.type === 'collection'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreateNewFavoriteDoc = useCallback(() => {
|
||||||
|
const newDoc = docsService.createDoc();
|
||||||
|
favoriteItemsAdapter.set(newDoc.id, 'doc', true);
|
||||||
|
workbenchService.workbench.openDoc(newDoc.id);
|
||||||
|
}, [docsService, favoriteItemsAdapter, workbenchService]);
|
||||||
|
|
||||||
|
const handleOnChildrenDrop = useCallback(
|
||||||
|
(
|
||||||
|
favorite: { id: string; type: 'doc' | 'collection' },
|
||||||
|
data: DropTargetDropEvent<AffineDNDData>
|
||||||
|
) => {
|
||||||
|
if (
|
||||||
|
data.treeInstruction?.type === 'reorder-above' ||
|
||||||
|
data.treeInstruction?.type === 'reorder-below'
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
data.source.data.from?.at === 'explorer:favorite:items' &&
|
||||||
|
(data.source.data.entity?.type === 'doc' ||
|
||||||
|
data.source.data.entity?.type === 'collection')
|
||||||
|
) {
|
||||||
|
// is reordering
|
||||||
|
favoriteItemsAdapter.sorter.moveTo(
|
||||||
|
FavoriteItemsAdapter.getFavItemKey(
|
||||||
|
data.source.data.entity.id,
|
||||||
|
data.source.data.entity.type
|
||||||
|
),
|
||||||
|
FavoriteItemsAdapter.getFavItemKey(favorite.id, favorite.type),
|
||||||
|
data.treeInstruction?.type === 'reorder-above' ? 'before' : 'after'
|
||||||
|
);
|
||||||
|
} else if (
|
||||||
|
data.source.data.entity?.type === 'doc' ||
|
||||||
|
data.source.data.entity?.type === 'collection'
|
||||||
|
) {
|
||||||
|
favoriteItemsAdapter.set(
|
||||||
|
data.source.data.entity.id,
|
||||||
|
data.source.data.entity?.type,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
favoriteItemsAdapter.sorter.moveTo(
|
||||||
|
FavoriteItemsAdapter.getFavItemKey(
|
||||||
|
data.source.data.entity.id,
|
||||||
|
data.source.data.entity.type
|
||||||
|
),
|
||||||
|
FavoriteItemsAdapter.getFavItemKey(favorite.id, favorite.type),
|
||||||
|
data.treeInstruction?.type === 'reorder-above' ? 'before' : 'after'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return; // not supported
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[favoriteItemsAdapter]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChildrenDropEffect = useCallback<ExplorerTreeNodeDropEffect>(
|
||||||
|
data => {
|
||||||
|
if (
|
||||||
|
data.treeInstruction?.type === 'reorder-above' ||
|
||||||
|
data.treeInstruction?.type === 'reorder-below'
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
data.source.data.from?.at === 'explorer:favorite:items' &&
|
||||||
|
(data.source.data.entity?.type === 'doc' ||
|
||||||
|
data.source.data.entity?.type === 'collection')
|
||||||
|
) {
|
||||||
|
return 'move';
|
||||||
|
} else if (
|
||||||
|
data.source.data.entity?.type === 'doc' ||
|
||||||
|
data.source.data.entity?.type === 'collection'
|
||||||
|
) {
|
||||||
|
return 'link';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return; // not supported
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChildrenCanDrop = useMemo<
|
||||||
|
DropTargetOptions<AffineDNDData>['canDrop']
|
||||||
|
>(
|
||||||
|
() => args =>
|
||||||
|
args.source.data.entity?.type === 'doc' ||
|
||||||
|
args.source.data.entity?.type === 'collection',
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { dropTargetRef, draggedOverDraggable, draggedOverPosition } =
|
||||||
|
useDropTarget<AffineDNDData>(
|
||||||
|
() => ({
|
||||||
|
data: {
|
||||||
|
at: 'explorer:favorite:root',
|
||||||
|
},
|
||||||
|
onDrop: handleDrop,
|
||||||
|
canDrop: handleCanDrop,
|
||||||
|
}),
|
||||||
|
[handleCanDrop, handleDrop]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container} data-testid="explorer-favorites">
|
||||||
|
<CategoryDivider
|
||||||
|
className={styles.draggedOverHighlight}
|
||||||
|
label={t['com.affine.rootAppSidebar.favorites']()}
|
||||||
|
ref={dropTargetRef}
|
||||||
|
data-testid="explorer-favorite-category-divider"
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
data-testid="explorer-bar-add-favorite-button"
|
||||||
|
onClick={handleCreateNewFavoriteDoc}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<PlusIcon />
|
||||||
|
</IconButton>
|
||||||
|
{draggedOverDraggable && (
|
||||||
|
<DropEffect
|
||||||
|
position={{
|
||||||
|
x: draggedOverPosition.relativeX,
|
||||||
|
y: draggedOverPosition.relativeY,
|
||||||
|
}}
|
||||||
|
dropEffect={handleDropEffect({
|
||||||
|
source: draggedOverDraggable,
|
||||||
|
treeInstruction: null,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CategoryDivider>
|
||||||
|
<ExplorerTreeRoot
|
||||||
|
placeholder={
|
||||||
|
<RootEmpty
|
||||||
|
onDrop={handleDrop}
|
||||||
|
canDrop={handleCanDrop}
|
||||||
|
dropEffect={handleDropEffect}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{favorites.map(favorite => (
|
||||||
|
<ExplorerFavoriteNode
|
||||||
|
key={favorite.id}
|
||||||
|
favorite={favorite}
|
||||||
|
onDrop={handleOnChildrenDrop}
|
||||||
|
dropEffect={handleChildrenDropEffect}
|
||||||
|
canDrop={handleChildrenCanDrop}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ExplorerTreeRoot>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const childLocation = {
|
||||||
|
at: 'explorer:favorite:items' as const,
|
||||||
|
};
|
||||||
|
const ExplorerFavoriteNode = ({
|
||||||
|
favorite,
|
||||||
|
onDrop,
|
||||||
|
canDrop,
|
||||||
|
dropEffect,
|
||||||
|
}: {
|
||||||
|
favorite: {
|
||||||
|
id: string;
|
||||||
|
type: 'collection' | 'doc';
|
||||||
|
};
|
||||||
|
canDrop?: DropTargetOptions<AffineDNDData>['canDrop'];
|
||||||
|
onDrop: (
|
||||||
|
favorite: {
|
||||||
|
id: string;
|
||||||
|
type: 'collection' | 'doc';
|
||||||
|
},
|
||||||
|
data: DropTargetDropEvent<AffineDNDData>
|
||||||
|
) => void;
|
||||||
|
dropEffect: ExplorerTreeNodeDropEffect;
|
||||||
|
}) => {
|
||||||
|
const handleOnChildrenDrop = useCallback(
|
||||||
|
(data: DropTargetDropEvent<AffineDNDData>) => {
|
||||||
|
onDrop(favorite, data);
|
||||||
|
},
|
||||||
|
[favorite, onDrop]
|
||||||
|
);
|
||||||
|
return favorite.type === 'doc' ? (
|
||||||
|
<ExplorerDocNode
|
||||||
|
key={favorite.id}
|
||||||
|
docId={favorite.id}
|
||||||
|
location={childLocation}
|
||||||
|
onDrop={handleOnChildrenDrop}
|
||||||
|
dropEffect={dropEffect}
|
||||||
|
canDrop={canDrop}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ExplorerCollectionNode
|
||||||
|
key={favorite.id}
|
||||||
|
collectionId={favorite.id}
|
||||||
|
location={childLocation}
|
||||||
|
onDrop={handleOnChildrenDrop}
|
||||||
|
dropEffect={dropEffect}
|
||||||
|
canDrop={canDrop}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { cssVar } from '@toeverything/theme';
|
||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
export const container = style({
|
||||||
|
marginTop: '16px',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const draggedOverHighlight = style({
|
||||||
|
position: 'relative',
|
||||||
|
selectors: {
|
||||||
|
'&[data-dragged-over="true"]': {
|
||||||
|
background: cssVar('--affine-hover-color'),
|
||||||
|
borderRadius: '4px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { cssVar } from '@toeverything/theme';
|
||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
export const content = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
padding: '9px 20px 25px 21px',
|
||||||
|
position: 'relative',
|
||||||
|
});
|
||||||
|
export const iconWrapper = style({
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: cssVar('hoverColor'),
|
||||||
|
});
|
||||||
|
export const icon = style({
|
||||||
|
fontSize: 20,
|
||||||
|
color: cssVar('iconSecondary'),
|
||||||
|
});
|
||||||
|
export const message = style({
|
||||||
|
fontSize: cssVar('fontSm'),
|
||||||
|
textAlign: 'center',
|
||||||
|
color: cssVar('black30'),
|
||||||
|
userSelect: 'none',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const newButton = style({
|
||||||
|
padding: '0 8px',
|
||||||
|
height: '28px',
|
||||||
|
fontSize: cssVar('fontXs'),
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Button } from '@affine/component';
|
||||||
|
import { useI18n } from '@affine/i18n';
|
||||||
|
import { FolderIcon } from '@blocksuite/icons/rc';
|
||||||
|
|
||||||
|
import * as styles from './empty.css';
|
||||||
|
|
||||||
|
export const RootEmpty = ({
|
||||||
|
onClickCreate,
|
||||||
|
}: {
|
||||||
|
onClickCreate?: () => void;
|
||||||
|
}) => {
|
||||||
|
const t = useI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.content}>
|
||||||
|
<div className={styles.iconWrapper}>
|
||||||
|
<FolderIcon className={styles.icon} />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
data-testid="slider-bar-organize-empty-message"
|
||||||
|
className={styles.message}
|
||||||
|
>
|
||||||
|
{t['com.affine.rootAppSidebar.organize.empty']()}
|
||||||
|
</div>
|
||||||
|
<Button className={styles.newButton} onClick={onClickCreate}>
|
||||||
|
{t['com.affine.rootAppSidebar.organize.empty.new-folders-button']()}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import {
|
||||||
|
type DropTargetDropEvent,
|
||||||
|
type DropTargetOptions,
|
||||||
|
IconButton,
|
||||||
|
toast,
|
||||||
|
} from '@affine/component';
|
||||||
|
import { CategoryDivider } from '@affine/core/components/app-sidebar';
|
||||||
|
import {
|
||||||
|
type ExplorerTreeNodeDropEffect,
|
||||||
|
ExplorerTreeRoot,
|
||||||
|
} from '@affine/core/modules/explorer/views/tree';
|
||||||
|
import {
|
||||||
|
type FolderNode,
|
||||||
|
OrganizeService,
|
||||||
|
} from '@affine/core/modules/organize';
|
||||||
|
import type { AffineDNDData } from '@affine/core/types/dnd';
|
||||||
|
import { useI18n } from '@affine/i18n';
|
||||||
|
import { PlusIcon } from '@blocksuite/icons/rc';
|
||||||
|
import { useLiveData, useServices } from '@toeverything/infra';
|
||||||
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { ExplorerFolderNode } from '../../nodes/folder';
|
||||||
|
import { RootEmpty } from './empty';
|
||||||
|
import * as styles from './styles.css';
|
||||||
|
|
||||||
|
export const ExplorerOrganize = () => {
|
||||||
|
const { organizeService } = useServices({ OrganizeService });
|
||||||
|
const [newFolderId, setNewFolderId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const t = useI18n();
|
||||||
|
|
||||||
|
const rootFolder = organizeService.folderTree.rootFolder;
|
||||||
|
|
||||||
|
const folders = useLiveData(rootFolder.sortedChildren$);
|
||||||
|
|
||||||
|
const handleCreateFolder = useCallback(() => {
|
||||||
|
const newFolderId = rootFolder.createFolder(
|
||||||
|
'New Folder',
|
||||||
|
rootFolder.indexAt('before')
|
||||||
|
);
|
||||||
|
setNewFolderId(newFolderId);
|
||||||
|
}, [rootFolder]);
|
||||||
|
|
||||||
|
const handleOnChildrenDrop = useCallback(
|
||||||
|
(data: DropTargetDropEvent<AffineDNDData>, node?: FolderNode) => {
|
||||||
|
if (!node || !node.id) {
|
||||||
|
return; // never happens
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
data.treeInstruction?.type === 'reorder-above' ||
|
||||||
|
data.treeInstruction?.type === 'reorder-below'
|
||||||
|
) {
|
||||||
|
const at =
|
||||||
|
data.treeInstruction?.type === 'reorder-below' ? 'after' : 'before';
|
||||||
|
if (data.source.data.entity?.type === 'folder') {
|
||||||
|
rootFolder.moveHere(
|
||||||
|
data.source.data.entity.id,
|
||||||
|
rootFolder.indexAt(at, node.id)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast(t['com.affine.rootAppSidebar.organize.root-folder-only']());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return; // not supported
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[rootFolder, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChildrenDropEffect = useCallback<ExplorerTreeNodeDropEffect>(
|
||||||
|
data => {
|
||||||
|
if (
|
||||||
|
data.treeInstruction?.type === 'reorder-above' ||
|
||||||
|
data.treeInstruction?.type === 'reorder-below'
|
||||||
|
) {
|
||||||
|
if (data.source.data.entity?.type === 'folder') {
|
||||||
|
return 'move';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return; // not supported
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChildrenCanDrop = useMemo<
|
||||||
|
DropTargetOptions<AffineDNDData>['canDrop']
|
||||||
|
>(() => args => args.source.data.entity?.type === 'folder', []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<CategoryDivider
|
||||||
|
className={styles.draggedOverHighlight}
|
||||||
|
label={t['com.affine.rootAppSidebar.organize']()}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
data-testid="explorer-bar-add-organize-button"
|
||||||
|
onClick={handleCreateFolder}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<PlusIcon />
|
||||||
|
</IconButton>
|
||||||
|
</CategoryDivider>
|
||||||
|
<ExplorerTreeRoot
|
||||||
|
placeholder={<RootEmpty onClickCreate={handleCreateFolder} />}
|
||||||
|
>
|
||||||
|
{folders.map(child => (
|
||||||
|
<ExplorerFolderNode
|
||||||
|
key={child.id}
|
||||||
|
nodeId={child.id as string}
|
||||||
|
defaultRenaming={child.id === newFolderId}
|
||||||
|
onDrop={handleOnChildrenDrop}
|
||||||
|
dropEffect={handleChildrenDropEffect}
|
||||||
|
canDrop={handleChildrenCanDrop}
|
||||||
|
location={{
|
||||||
|
at: 'explorer:organize:folder-node',
|
||||||
|
nodeId: child.id as string,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ExplorerTreeRoot>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user