feat(core): init organize (#7456)

This commit is contained in:
EYHN
2024-07-26 04:35:31 +00:00
parent b26b0c3a22
commit 54da85ec62
140 changed files with 6257 additions and 2804 deletions
+1
View File
@@ -30,6 +30,7 @@ export const runtimeFlagsSchema = z.object({
enableEnhanceShareMode: z.boolean(),
enableExperimentalFeature: z.boolean(),
enableInfoModal: z.boolean(),
enableOrganize: z.boolean(),
});
export type RuntimeConfig = z.infer<typeof runtimeFlagsSchema>;
+3
View File
@@ -4,6 +4,7 @@ export * from './blocksuite';
export * from './framework';
export * from './initialization';
export * from './livedata';
export * from './modules/db';
export * from './modules/doc';
export * from './modules/global-context';
export * from './modules/lifecycle';
@@ -14,6 +15,7 @@ export * from './sync';
export * from './utils';
import type { Framework } from './framework';
import { configureDBModule } from './modules/db';
import { configureDocModule } from './modules/doc';
import { configureGlobalContextModule } from './modules/global-context';
import { configureLifecycleModule } from './modules/lifecycle';
@@ -29,6 +31,7 @@ import {
export function configureInfraModules(framework: Framework) {
configureWorkspaceModule(framework);
configureDocModule(framework);
configureDBModule(framework);
configureGlobalStorageModule(framework);
configureGlobalContextModule(framework);
configureLifecycleModule(framework);
@@ -449,6 +449,10 @@ export class LiveData<T = unknown>
) as any;
}
static flat<T>(v: T): Flat<LiveData<T>> {
return new LiveData(v).flat();
}
waitFor(predicate: (v: T) => unknown, signal?: AbortSignal): Promise<T> {
return new Promise((resolve, reject) => {
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 type { DocScope } from '../scopes/doc';
import type { DocsStore } from '../stores/docs';
import type { DocMode } from './record';
export class Doc extends Entity {
constructor(public readonly scope: DocScope) {
constructor(
public readonly scope: DocScope,
private readonly store: DocsStore
) {
super();
}
@@ -42,4 +46,12 @@ export class Doc extends Entity {
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(DocRecordList, [DocsStore])
.scope(DocScope)
.entity(Doc, [DocScope])
.entity(Doc, [DocScope, DocsStore])
.service(DocService);
}
@@ -1,4 +1,5 @@
import { Unreachable } from '@affine/env/constant';
import type { RootBlockModel } from '@blocksuite/blocks';
import { Service } from '../../../framework';
import { initEmptyPage } from '../../../initialization';
@@ -59,6 +60,7 @@ export class DocsService extends Service {
) {
const doc = this.store.createBlockSuiteDoc();
initEmptyPage(doc, options.title);
this.store.markDocSyncStateAsReady(doc.id);
const docRecord = this.list.doc$(doc.id).value;
if (!docRecord) {
throw new Unreachable();
@@ -68,4 +70,45 @@ export class DocsService extends Service {
}
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) {
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');
isDoc = this.define<boolean>('isDoc');
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');
isTag = this.define<boolean>('isTag');
tagId = this.define<string>('tagId');
define<T>(key: string) {
this.memento.set(key, null);
const livedata$ = LiveData.from(this.memento.watch<T>(key), null);
+1 -1
View File
@@ -159,7 +159,7 @@ export class Table<T extends TableSchemaBuilder> {
return record || null;
}
get$(key: PrimaryKeyFieldType<T>): Observable<Entity<T>> {
get$(key: PrimaryKeyFieldType<T>): Observable<Entity<T> | null> {
let ob$ = this.subscribedKeys.get(key);
if (!ob$) {
+1
View File
@@ -0,0 +1 @@
export * from './core';
@@ -77,6 +77,10 @@ export class DocEngine {
});
}
markAsReady(docId: string) {
this.localPart.actions.markAsReady(docId);
}
constructor(
storage: DocStorage,
private readonly server?: DocServer | null
@@ -163,6 +163,10 @@ export class DocEngineLocalPart {
this.status.docs.set(doc.guid, doc);
this.statusUpdatedSubject$.next(doc.guid);
},
markAsReady: (docId: string) => {
this.status.readyDocs.add(docId);
this.statusUpdatedSubject$.next(docId);
},
};
readonly jobs = {
@@ -48,6 +48,7 @@ export const RenameModal = ({
items={
<Input
autoFocus
autoSelect
value={value}
onChange={setValue}
onEnter={handleRename}
+2
View File
@@ -5,6 +5,7 @@ export * from './ui/button';
export * from './ui/checkbox';
export * from './ui/date-picker';
export * from './ui/divider';
export * from './ui/dnd';
export * from './ui/editable';
export * from './ui/empty';
export * from './ui/error-message';
@@ -13,6 +14,7 @@ export * from './ui/layout';
export * from './ui/loading';
export * from './ui/lottie/collections-icon';
export * from './ui/lottie/delete-icon';
export * from './ui/lottie/folder-icon';
export * from './ui/menu';
export * from './ui/modal';
export * from './ui/notification';
@@ -174,7 +174,10 @@ NestedDropTarget.args = {
};
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 } =
useDraggable<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 (
<div
style={{
@@ -240,6 +253,30 @@ export const DynamicDragPreview = () => {
>
Small
</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">
<div
style={{
@@ -43,10 +43,16 @@ export interface DraggableOptions<D extends DNDData = DNDData> {
}>;
canDrag?: DraggableGet<boolean>;
disableDragPreview?: boolean;
dragPreviewPosition?: DraggableDragPreviewPosition;
}
export type DraggableDragPreviewPosition =
| 'pointer-outside'
| 'pointer-center'
| 'native';
export type DraggableCustomDragPreviewProps = React.PropsWithChildren<{
position?: 'pointer-outside' | 'pointer-center' | 'native';
position?: DraggableDragPreviewPosition;
}>;
export const useDraggable = <D extends DNDData = DNDData>(
@@ -172,9 +178,11 @@ export const useDraggable = <D extends DNDData = DNDData>(
disableNativeDragPreview({ nativeSetDragImage });
return;
}
let previewPosition: DraggableDragPreviewPosition =
options.dragPreviewPosition ?? 'native';
if (enableCustomDragPreview.current) {
let previewPosition: DraggableCustomDragPreviewProps['position'] =
'native';
setCustomNativeDragPreview({
getOffset: (...args) => {
if (previewPosition === 'pointer-center') {
@@ -199,7 +207,7 @@ export const useDraggable = <D extends DNDData = DNDData>(
children,
position,
}: DraggableCustomDragPreviewProps) => {
previewPosition = position;
previewPosition = position || previewPosition;
return ReactDOM.createPortal(children, container);
}
);
@@ -208,6 +216,28 @@ export const useDraggable = <D extends DNDData = DNDData>(
},
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,
'::before': {
// 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 { Instruction } from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item';
import { cssVar } from '@toeverything/theme';
@@ -20,6 +20,9 @@ type DropTargetGetFeedback<D extends DNDData> = Parameters<
source: {
data: D['draggable'];
};
} & {
treeInstruction: Instruction | null;
closestEdge: Edge | null;
};
type DropTargetGet<T, D extends DNDData> =
@@ -27,17 +30,60 @@ type DropTargetGet<T, D extends DNDData> =
| ((data: DropTargetGetFeedback<D>) => T);
function dropTargetGet<T, D extends DNDData>(
get: T
get: T,
options: DropTargetOptions<D>
): T extends undefined
? undefined
: T extends DropTargetGet<infer I, D>
? (args: DropTargetGetFeedback<D>) => I
? (
args: Omit<DropTargetGetFeedback<D>, 'treeInstruction' | 'closestEdge'>
) => I
: never {
if (get === undefined) {
return undefined as any;
}
return ((args: DropTargetGetFeedback<D>) =>
typeof get === 'function' ? (get as any)(args) : get) as any;
return ((
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<
@@ -52,6 +98,8 @@ export type DropTargetDragEvent<D extends DNDData> = Parameters<
source: { data: D['draggable'] };
};
export type DropTargetTreeInstruction = Instruction;
export interface DropTargetOptions<D extends DNDData = DNDData> {
data?: DropTargetGet<D['dropTarget'], D>;
canDrop?: DropTargetGet<boolean, D>;
@@ -80,8 +128,26 @@ export const useDropTarget = <D extends DNDData = DNDData>(
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 enableDraggedOverDraggable = useRef(false);
const enableDraggedOverPosition = useRef(false);
const enableDropEffect = useRef(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
const options = useMemo(getOptions, deps);
@@ -92,19 +158,36 @@ export const useDropTarget = <D extends DNDData = DNDData>(
}
return dropTargetForElements({
element: dropTargetRef.current,
canDrop: dropTargetGet(options.canDrop),
getDropEffect: dropTargetGet(options.dropEffect),
getIsSticky: dropTargetGet(options.isSticky),
canDrop: dropTargetGet(options.canDrop, options),
getDropEffect: dropTargetGet(options.dropEffect, options),
getIsSticky: dropTargetGet(options.isSticky, options),
onDrop: args => {
if (enableDraggedOver.current) {
setDraggedOver(false);
}
if (enableDraggedOverDraggable.current) {
setDraggedOverDraggable(null);
}
if (enableDraggedOverPosition.current) {
setDraggedOverPosition({
relativeX: 0,
relativeY: 0,
clientX: 0,
clientY: 0,
});
}
if (options.treeInstruction) {
setTreeInstruction(null);
if (dropTargetRef.current) {
delete dropTargetRef.current.dataset['treeInstruction'];
}
}
if (options.closestEdge) {
setClosestEdge(null);
}
if (enableDropEffect.current) {
setDropEffect(null);
}
if (dropTargetRef.current) {
delete dropTargetRef.current.dataset['draggedOver'];
}
@@ -120,7 +203,7 @@ export const useDropTarget = <D extends DNDData = DNDData>(
}
},
getData: args => {
const originData = dropTargetGet(options.data ?? {})(args);
const originData = dropTargetGet(options.data ?? {}, options)(args);
const { input, element } = args;
const withInstruction = options.treeInstruction
? attachInstruction(originData, {
@@ -141,43 +224,116 @@ export const useDropTarget = <D extends DNDData = DNDData>(
: withInstruction;
return withClosestEdge;
},
onDragEnter: () => {
if (enableDraggedOver.current) {
setDraggedOver(true);
}
if (dropTargetRef.current) {
dropTargetRef.current.dataset['draggedOver'] = 'true';
}
},
onDrag: args => {
let instruction = null;
let closestEdge = null;
if (options.treeInstruction) {
instruction = extractInstruction(args.self.data);
setTreeInstruction(instruction);
if (
args.location.current.dropTargets[0]?.element ===
dropTargetRef.current
) {
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: () => {
if (enableDraggedOver.current) {
setDraggedOver(false);
}
if (options.treeInstruction) {
setTreeInstruction(null);
}
if (options.closestEdge) {
setClosestEdge(null);
}
if (dropTargetRef.current) {
delete dropTargetRef.current.dataset['draggedOver'];
onDropTargetChange: args => {
if (
args.location.current.dropTargets[0]?.element ===
dropTargetRef.current
) {
if (enableDraggedOver.current) {
setDraggedOver(true);
}
if (options.treeInstruction) {
const instruction = extractInstruction(args.self.data);
setTreeInstruction(instruction);
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;
return draggedOver;
},
get draggedOverDraggable() {
enableDraggedOverDraggable.current = true;
return draggedOverDraggable;
},
get draggedOverPosition() {
enableDraggedOverPosition.current = true;
return draggedOverPosition;
},
get dropEffect() {
enableDropEffect.current = true;
return dropEffect;
},
treeInstruction,
closestEdge,
};
@@ -9,7 +9,7 @@ import type {
KeyboardEventHandler,
ReactNode,
} from 'react';
import { forwardRef, useCallback } from 'react';
import { forwardRef, useCallback, useLayoutEffect, useRef } from 'react';
import { input, inputWrapper } from './style.css';
@@ -18,6 +18,7 @@ export type InputProps = {
onChange?: (value: string) => void;
onBlur?: FocusEventHandler<HTMLInputElement>;
onKeyDown?: KeyboardEventHandler<HTMLInputElement>;
autoSelect?: boolean;
noBorder?: boolean;
status?: 'error' | 'success' | 'warning' | 'default';
size?: 'default' | 'large' | 'extraLarge';
@@ -43,23 +44,20 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
onEnter,
onKeyDown,
autoFocus,
autoSelect,
...otherProps
}: InputProps,
upstreamRef: ForwardedRef<HTMLInputElement>
) {
const handleAutoFocus = useCallback(
(ref: HTMLInputElement | null) => {
if (ref) {
window.setTimeout(() => ref.focus(), 0);
if (typeof upstreamRef === 'function') {
upstreamRef(ref);
} else if (upstreamRef) {
upstreamRef.current = ref;
}
const inputRef = useRef<HTMLInputElement | null>(null);
useLayoutEffect(() => {
if (inputRef.current && (autoFocus || autoSelect)) {
inputRef.current?.focus();
if (autoSelect) {
inputRef.current?.select();
}
},
[upstreamRef]
);
}
}, [autoFocus, autoSelect, upstreamRef]);
return (
<div
@@ -86,7 +84,16 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
large: size === 'large',
'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}
style={inputStyle}
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>
<Button onClick={onCancel} {...cancelButtonOptions}>
<Button
onClick={onCancel}
data-testid="confirm-modal-cancel"
{...cancelButtonOptions}
>
{cancelText}
</Button>
</DialogTrigger>
<Button onClick={onConfirmClick} {...confirmButtonOptions}>
<Button
onClick={onConfirmClick}
data-testid="confirm-modal-confirm"
{...confirmButtonOptions}
>
{confirmText}
</Button>
</div>
+2 -1
View File
@@ -22,7 +22,7 @@
"@blocksuite/block-std": "0.16.0-canary-202407250137-267cd23",
"@blocksuite/blocks": "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/presets": "0.16.0-canary-202407250137-267cd23",
"@blocksuite/store": "0.16.0-canary-202407250137-267cd23",
@@ -60,6 +60,7 @@
"file-type": "^19.1.0",
"foxact": "^0.2.33",
"fractional-indexing": "^3.2.0",
"fractional-indexing-jittered": "^0.9.1",
"fuse.js": "^7.0.0",
"graphql": "^16.8.1",
"history": "^5.3.0",
@@ -1,26 +1,6 @@
import { cssVar } from '@toeverything/theme';
import { createContext } from 'react';
import type { PagePropertiesManager } from './page-properties-manager';
// @ts-expect-error this should always be set
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
);
@@ -5,12 +5,11 @@ import {
Scrollable,
} from '@affine/component';
import { DocsSearchService } from '@affine/core/modules/docs-search';
import type { Doc } from '@blocksuite/store';
import {
LiveData,
useLiveData,
useService,
type Workspace,
useServices,
WorkspaceService,
} from '@toeverything/infra';
import { Suspense, useCallback, useContext, useMemo, useRef } from 'react';
@@ -30,27 +29,26 @@ import { TimeRow } from './time-row';
export const InfoModal = ({
open,
onOpenChange,
page,
workspace,
docId,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
page: Doc;
workspace: Workspace;
docId: string;
}) => {
const { docsSearchService, workspaceService } = useServices({
DocsSearchService,
WorkspaceService,
});
const titleInputHandleRef = useRef<InlineEditHandle>(null);
const manager = usePagePropertiesManager(page);
const manager = usePagePropertiesManager(docId);
const handleClose = useCallback(() => {
onOpenChange(false);
}, [onOpenChange]);
const docsSearchService = useService(DocsSearchService);
const references = useLiveData(
useMemo(
() => LiveData.from(docsSearchService.watchRefsFrom(page.id), null),
[docsSearchService, page.id]
() => LiveData.from(docsSearchService.watchRefsFrom(docId), null),
[docId, docsSearchService]
)
);
@@ -76,14 +74,14 @@ export const InfoModal = ({
<BlocksuiteHeaderTitle
className={styles.titleStyle}
inputHandleRef={titleInputHandleRef}
pageId={page.id}
docCollection={workspace.docCollection}
pageId={docId}
docCollection={workspaceService.workspace.docCollection}
/>
</div>
<managerContext.Provider value={manager}>
<Suspense>
<InfoTable
docId={page.id}
docId={docId}
onClose={handleClose}
references={references}
readonly={manager.readonly}
@@ -26,7 +26,6 @@ import {
ToggleExpandIcon,
ViewIcon,
} from '@blocksuite/icons/rc';
import type { Doc } from '@blocksuite/store';
import type { DragEndEvent, DraggableAttributes } from '@dnd-kit/core';
import {
DndContext,
@@ -1085,21 +1084,21 @@ const PagePropertiesTableInner = () => {
);
};
export const usePagePropertiesManager = (page: Doc) => {
export const usePagePropertiesManager = (docId: string) => {
// the workspace properties adapter adapter is reactive,
// which means it's reference will change when any of the properties change
// also it will trigger a re-render of the component
const adapter = useCurrentWorkspacePropertiesAdapter();
const manager = useMemo(() => {
return new PagePropertiesManager(adapter, page.id);
}, [adapter, page.id]);
return new PagePropertiesManager(adapter, docId);
}, [adapter, docId]);
return manager;
};
// this is the main component that renders the page properties table at the top of the page below
// the page title
export const PagePropertiesTable = ({ page }: { page: Doc }) => {
const manager = usePagePropertiesManager(page);
export const PagePropertiesTable = ({ docId }: { docId: string }) => {
const manager = usePagePropertiesManager(docId);
// if the given page is not in the current workspace, then we don't render anything
// eg. when it is in history modal
@@ -13,7 +13,6 @@ import type { HTMLAttributes, PropsWithChildren } from 'react';
import { useCallback, useMemo, useReducer, useRef, useState } from 'react';
import { TagItem, TempTagItem } from '../../page-list';
import { tagColors } from './common';
import type { MenuItemOption } from './menu-items';
import { renderMenuItemOptions } from './menu-items';
import * as styles from './tags-inline-editor.css';
@@ -80,7 +79,8 @@ export const EditTagMenu = ({
}>) => {
const t = useI18n();
const legacyProperties = useService(WorkspaceLegacyProperties);
const tagList = useService(TagService).tagList;
const tagService = useService(TagService);
const tagList = tagService.tagList;
const tag = useLiveData(tagList.tagByTagId$(tagId));
const tagColor = useLiveData(tag?.color$);
const tagValue = useLiveData(tag?.value$);
@@ -133,7 +133,7 @@ export const EditTagMenu = ({
options.push('-');
options.push(
tagColors.map(([name, color], i) => {
tagService.tagColors.map(([name, color], i) => {
return {
text: name,
icon: (
@@ -170,6 +170,7 @@ export const EditTagMenu = ({
t,
tag,
tagColor,
tagService.tagColors,
tagValue,
]);
@@ -185,7 +186,8 @@ const isCreateNewTag = (
export const TagsEditor = ({ pageId, readonly }: TagsEditorProps) => {
const t = useI18n();
const tagList = useService(TagService).tagList;
const tagService = useService(TagService);
const tagList = tagService.tagList;
const tags = useLiveData(tagList.tags$);
const tagIds = useLiveData(tagList.tagIdsByPageId$(pageId));
const [inputValue, setInputValue] = useState('');
@@ -265,10 +267,12 @@ export const TagsEditor = ({ pageId, readonly }: TagsEditorProps) => {
const [nextColor, rotateNextColor] = useReducer(
color => {
const idx = tagColors.findIndex(c => c[1] === color);
return tagColors[(idx + 1) % tagColors.length][1];
const idx = tagService.tagColors.findIndex(c => c[1] === color);
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(
@@ -1,17 +1,29 @@
import clsx from 'clsx';
import type { PropsWithChildren } from 'react';
import { type ForwardedRef, forwardRef, type PropsWithChildren } from 'react';
import * as styles from './index.css';
interface CategoryDividerProps extends PropsWithChildren {
label: string;
}
export type CategoryDividerProps = PropsWithChildren<
{
label: string;
className?: string;
} & {
[key: `data-${string}`]: unknown;
}
>;
export function CategoryDivider({ label, children }: CategoryDividerProps) {
return (
<div className={clsx([styles.root])}>
<div className={styles.label}>{label}</div>
{children}
</div>
);
}
export const CategoryDivider = forwardRef(
(
{ label, children, className, ...otherProps }: CategoryDividerProps,
ref: ForwardedRef<HTMLDivElement>
) => {
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} />
)}
<PagePropertiesTable page={page} />
<PagePropertiesTable docId={page.id} />
<adapted.DocEditor
className={styles.docContainer}
ref={onDocRef}
@@ -23,7 +23,7 @@ export const root = style({
});
export const dragPageItemOverlay = style({
height: '54px',
height: '45px',
borderRadius: '10px',
display: 'flex',
alignItems: 'center',
@@ -1,17 +1,12 @@
import { Checkbox } from '@affine/component';
import { getDNDId } from '@affine/core/hooks/affine/use-global-dnd-helper';
import { Checkbox, useDraggable } from '@affine/component';
import { WorkbenchLink } from '@affine/core/modules/workbench';
import type { AffineDNDData } from '@affine/core/types/dnd';
import { useI18n } from '@affine/i18n';
import { useDraggable } from '@dnd-kit/core';
import type { PropsWithChildren } from 'react';
import { useCallback, useMemo } from 'react';
import type { ForwardedRef, PropsWithChildren } from 'react';
import { forwardRef, useCallback, useMemo } from 'react';
import { selectionStateAtom, useAtom } from '../scoped-atoms';
import type {
CollectionListItemProps,
DraggableTitleCellData,
PageListItemProps,
} from '../types';
import type { CollectionListItemProps, PageListItemProps } from '../types';
import { ColWrapper, stopPropagation } from '../utils';
import * as styles from './collection-list-item.css';
@@ -109,56 +104,64 @@ export const CollectionListItem = (props: CollectionListItemProps) => {
props.title,
]);
const { setNodeRef, attributes, listeners, isDragging } = useDraggable({
id: getDNDId('collection-list', 'collection', props.collectionId),
data: {
preview: collectionTitleElement,
} satisfies DraggableTitleCellData,
disabled: !props.draggable,
});
const { dragRef, dragging, CustomDragPreview } = useDraggable<AffineDNDData>(
() => ({
data: {
entity: {
type: 'collection',
id: props.collectionId,
},
from: {
at: 'all-collections:list',
},
},
canDrag: props.draggable,
}),
[props.collectionId, props.draggable]
);
return (
<CollectionListItemWrapper
onClick={props.onClick}
to={props.to}
collectionId={props.collectionId}
draggable={props.draggable}
isDragging={isDragging}
>
<ColWrapper flex={9}>
<ColWrapper
className={styles.dndCell}
flex={8}
ref={setNodeRef}
{...attributes}
{...listeners}
>
<div className={styles.titleIconsWrapper}>
<CollectionSelectionCell
onSelectedChange={props.onSelectedChange}
selectable={props.selectable}
selected={props.selected}
/>
<ListIconCell icon={props.icon} />
</div>
<ListTitleCell title={props.title} />
<>
<CollectionListItemWrapper
onClick={props.onClick}
to={props.to}
collectionId={props.collectionId}
draggable={props.draggable}
isDragging={dragging}
ref={dragRef}
>
<ColWrapper flex={9}>
<ColWrapper className={styles.dndCell} flex={8}>
<div className={styles.titleIconsWrapper}>
<CollectionSelectionCell
onSelectedChange={props.onSelectedChange}
selectable={props.selectable}
selected={props.selected}
/>
<ListIconCell icon={props.icon} />
</div>
<ListTitleCell title={props.title} />
</ColWrapper>
<ColWrapper
flex={4}
alignment="end"
style={{ overflow: 'visible' }}
></ColWrapper>
</ColWrapper>
<ColWrapper
flex={4}
alignment="end"
style={{ overflow: 'visible' }}
></ColWrapper>
</ColWrapper>
{props.operations ? (
<ColWrapper
className={styles.actionsCellWrapper}
flex={3}
alignment="end"
>
<CollectionListOperationsCell operations={props.operations} />
</ColWrapper>
) : null}
</CollectionListItemWrapper>
{props.operations ? (
<ColWrapper
className={styles.actionsCellWrapper}
flex={3}
alignment="end"
>
<CollectionListOperationsCell operations={props.operations} />
</ColWrapper>
) : null}
</CollectionListItemWrapper>
<CustomDragPreview position="pointer-outside">
{collectionTitleElement}
</CustomDragPreview>
</>
);
};
@@ -171,58 +174,69 @@ type collectionListWrapperProps = PropsWithChildren<
}
>;
function CollectionListItemWrapper({
to,
isDragging,
collectionId,
onClick,
children,
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?.();
}
},
[
const CollectionListItemWrapper = forwardRef(
(
{
to,
isDragging,
collectionId,
onClick,
selectionState.selectable,
selectionState.selectionActive,
setSelectionActive,
]
);
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}>
{children}
</WorkbenchLink>
children,
draggable,
}: collectionListWrapperProps,
ref: ForwardedRef<HTMLAnchorElement & HTMLDivElement>
) => {
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,
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({
height: '54px',
borderRadius: '10px',
height: '45px',
borderRadius: '8px',
display: 'flex',
alignItems: 'center',
background: cssVar('hoverColorFilled'),
@@ -1,11 +1,10 @@
import { Checkbox, Tooltip } from '@affine/component';
import { getDNDId } from '@affine/core/hooks/affine/use-global-dnd-helper';
import { Checkbox, Tooltip, useDraggable } from '@affine/component';
import { TagService } from '@affine/core/modules/tag';
import type { AffineDNDData } from '@affine/core/types/dnd';
import { i18nTime } from '@affine/i18n';
import { useDraggable } from '@dnd-kit/core';
import { useLiveData, useService } from '@toeverything/infra';
import type { PropsWithChildren } from 'react';
import { useCallback, useEffect, useMemo } from 'react';
import type { ForwardedRef, PropsWithChildren } from 'react';
import { forwardRef, useCallback, useEffect, useMemo } from 'react';
import { WorkbenchLink } from '../../../modules/workbench/view/workbench-link';
import {
@@ -14,7 +13,7 @@ import {
selectionStateAtom,
useAtom,
} from '../scoped-atoms';
import type { DraggableTitleCellData, PageListItemProps } from '../types';
import type { PageListItemProps } from '../types';
import { useAllDocDisplayProperties } from '../use-all-doc-display-properties';
import { ColWrapper, stopPropagation } from '../utils';
import * as styles from './page-list-item.css';
@@ -167,76 +166,84 @@ export const PageListItem = (props: PageListItemProps) => {
props.title,
]);
const { setNodeRef, attributes, listeners, isDragging } = useDraggable({
id: getDNDId('doc-list', 'doc', props.pageId),
data: {
preview: pageTitleElement,
} satisfies DraggableTitleCellData,
disabled: !props.draggable,
});
const { dragRef, CustomDragPreview, dragging } = useDraggable<AffineDNDData>(
() => ({
canDrag: props.draggable,
data: {
entity: {
type: 'doc',
id: props.pageId,
},
from: {
at: 'all-docs:list',
},
},
}),
[props.draggable, props.pageId]
);
return (
<PageListItemWrapper
onClick={props.onClick}
to={props.to}
pageId={props.pageId}
draggable={props.draggable}
isDragging={isDragging}
pageIds={props.pageIds || []}
>
<ColWrapper flex={9}>
<ColWrapper
className={styles.dndCell}
flex={8}
ref={setNodeRef}
{...attributes}
{...listeners}
>
<div className={styles.titleIconsWrapper}>
<PageSelectionCell
onSelectedChange={props.onSelectedChange}
selectable={props.selectable}
selected={props.selected}
/>
<ListIconCell icon={props.icon} />
</div>
<ListTitleCell title={props.title} preview={props.preview} />
<>
<PageListItemWrapper
onClick={props.onClick}
to={props.to}
pageId={props.pageId}
draggable={props.draggable}
isDragging={dragging}
ref={dragRef}
pageIds={props.pageIds || []}
>
<ColWrapper flex={9}>
<ColWrapper className={styles.dndCell} flex={8}>
<div className={styles.titleIconsWrapper}>
<PageSelectionCell
onSelectedChange={props.onSelectedChange}
selectable={props.selectable}
selected={props.selected}
/>
<ListIconCell icon={props.icon} />
</div>
<ListTitleCell title={props.title} preview={props.preview} />
</ColWrapper>
<ColWrapper
flex={4}
alignment="end"
style={{ overflow: 'visible' }}
hidden={!displayProperties.displayProperties.tags}
>
<PageTagsCell pageId={props.pageId} />
</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}
alignment="end"
hideInSmallContainer
hidden={!displayProperties.displayProperties.createDate}
>
<PageListOperationsCell operations={props.operations} />
<PageCreateDateCell createDate={props.createDate} />
</ColWrapper>
) : null}
</PageListItemWrapper>
<ColWrapper
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({
to,
isDragging,
pageId,
pageIds,
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,
const PageListItemWrapper = forwardRef(
(
{
to,
isDragging,
pageId,
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,
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);
};
if (to) {
return (
<WorkbenchLink ref={ref} {...commonProps} to={to}>
{children}
</WorkbenchLink>
);
} else {
return (
<div ref={ref} {...commonProps}>
{children}
</div>
);
}
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
open={openInfoModal}
onOpenChange={setOpenInfoModal}
page={blocksuiteDoc}
workspace={currentWorkspace}
docId={blocksuiteDoc.id}
/>
) : null}
<DisablePublicSharing.DisablePublicSharingModal
@@ -5,7 +5,6 @@ import { useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { tagColors } from '../../affine/page-properties/common';
import type { TagMeta } from '../types';
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 = ({
open,
onOpenChange,
@@ -32,7 +26,8 @@ export const CreateOrEditTag = ({
onOpenChange: (open: boolean) => void;
tagMeta?: TagMeta;
}) => {
const tagList = useService(TagService).tagList;
const tagService = useService(TagService);
const tagList = tagService.tagList;
const tagOptions = useLiveData(tagList.tagMetas$);
const tag = useLiveData(tagList.tagByTagId$(tagMeta?.id));
const t = useI18n();
@@ -43,14 +38,16 @@ export const CreateOrEditTag = ({
setTagName(value);
}, []);
const [tagIcon, setTagIcon] = useState(tagMeta?.color || randomTagColor());
const [tagIcon, setTagIcon] = useState(
tagMeta?.color || tagService.randomTagColor()
);
const handleChangeIcon = useCallback((value: string) => {
setTagIcon(value);
}, []);
const tags = useMemo(() => {
return tagColors.map(([_, color]) => {
return tagService.tagColors.map(([name, color]) => {
return {
name: name,
color: color,
@@ -60,7 +57,7 @@ export const CreateOrEditTag = ({
},
};
});
}, [handleChangeIcon]);
}, [handleChangeIcon, tagService.tagColors]);
const items = useMemo(() => {
const tagItems = tags.map(item => {
@@ -81,11 +78,11 @@ export const CreateOrEditTag = ({
const onClose = useCallback(() => {
if (!tagMeta) {
handleChangeIcon(randomTagColor());
handleChangeIcon(tagService.randomTagColor());
setTagName('');
}
onOpenChange(false);
}, [handleChangeIcon, onOpenChange, tagMeta]);
}, [handleChangeIcon, onOpenChange, tagMeta, tagService]);
const onConfirm = useCallback(() => {
if (!tagName?.trim()) return;
@@ -129,8 +126,8 @@ export const CreateOrEditTag = ({
useEffect(() => {
setTagName(tagMeta?.title || '');
setTagIcon(tagMeta?.color || randomTagColor());
}, [tagMeta?.color, tagMeta?.title]);
setTagIcon(tagMeta?.color || tagService.randomTagColor());
}, [tagMeta?.color, tagMeta?.title, tagService]);
if (!open) {
return null;
@@ -22,7 +22,7 @@ export const root = style({
},
});
export const dragPageItemOverlay = style({
height: '54px',
height: '45px',
borderRadius: '10px',
display: 'flex',
alignItems: 'center',
@@ -1,13 +1,12 @@
import { Checkbox } from '@affine/component';
import { getDNDId } from '@affine/core/hooks/affine/use-global-dnd-helper';
import { Checkbox, useDraggable } from '@affine/component';
import { WorkbenchLink } from '@affine/core/modules/workbench';
import type { AffineDNDData } from '@affine/core/types/dnd';
import { useI18n } from '@affine/i18n';
import { useDraggable } from '@dnd-kit/core';
import type { PropsWithChildren } from 'react';
import { useCallback, useMemo } from 'react';
import type { ForwardedRef, PropsWithChildren } from 'react';
import { forwardRef, useCallback, useMemo } from 'react';
import { selectionStateAtom, useAtom } from '../scoped-atoms';
import type { DraggableTitleCellData, TagListItemProps } from '../types';
import type { TagListItemProps } from '../types';
import { ColWrapper, stopPropagation } from '../utils';
import * as styles from './tag-list-item.css';
@@ -83,45 +82,62 @@ const TagListOperationsCell = ({
};
export const TagListItem = (props: TagListItemProps) => {
const tagTitleElement = useMemo(() => {
return (
<div className={styles.dragPageItemOverlay}>
<div className={styles.titleIconsWrapper}>
<TagSelectionCell
onSelectedChange={props.onSelectedChange}
selectable={props.selectable}
selected={props.selected}
/>
<ListIconCell color={props.color} />
</div>
</div>
);
}, [props.color, props.onSelectedChange, props.selectable, props.selected]);
const { setNodeRef, attributes, listeners, isDragging } = useDraggable({
id: getDNDId('tag-list', 'tag', props.tagId),
data: {
preview: tagTitleElement,
} satisfies DraggableTitleCellData,
disabled: !props.draggable,
});
const { dragRef, CustomDragPreview, dragging } = useDraggable<AffineDNDData>(
() => ({
canDrag: props.draggable,
data: {
entity: {
type: 'tag',
id: props.tagId,
},
from: {
at: 'all-tags:list',
},
},
}),
[props.draggable, props.tagId]
);
return (
<TagListItemWrapper
onClick={props.onClick}
to={props.to}
tagId={props.tagId}
draggable={props.draggable}
isDragging={isDragging}
>
<ColWrapper flex={9}>
<ColWrapper
className={styles.dndCell}
flex={8}
ref={setNodeRef}
{...attributes}
{...listeners}
>
<>
<TagListItemWrapper
onClick={props.onClick}
to={props.to}
tagId={props.tagId}
draggable={props.draggable}
isDragging={dragging}
ref={dragRef}
>
<ColWrapper flex={9}>
<ColWrapper className={styles.dndCell} flex={8}>
<div className={styles.titleIconsWrapper}>
<TagSelectionCell
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}>
<TagSelectionCell
onSelectedChange={props.onSelectedChange}
@@ -131,23 +147,9 @@ export const TagListItem = (props: TagListItemProps) => {
<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>
</div>
</CustomDragPreview>
</>
);
};
@@ -157,58 +159,68 @@ type TagListWrapperProps = PropsWithChildren<
}
>;
function TagListItemWrapper({
to,
isDragging,
tagId,
onClick,
children,
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?.();
}
},
[
const TagListItemWrapper = forwardRef(
(
{
to,
isDragging,
tagId,
onClick,
selectionState.selectable,
selectionState.selectionActive,
setSelectionActive,
]
);
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}>
{children}
</WorkbenchLink>
children,
draggable,
}: TagListWrapperProps,
ref: ForwardedRef<HTMLAnchorElement & HTMLDivElement>
) => {
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,
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
ref={listRef}
selectable="toggle"
draggable={false}
draggable={true}
atTopThreshold={80}
onSelectionActiveChange={setShowFloatingToolbar}
heading={<TagListHeader onOpen={onOpenCreate} />}
@@ -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}
</>
);
};
@@ -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';
@@ -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',
});
@@ -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>
);
};
@@ -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,
},
},
});
@@ -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>
);
})}
</>
);
};
@@ -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}
</>
);
};
@@ -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>
);
});
@@ -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>
);
};
@@ -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;
@@ -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;
@@ -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';
@@ -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 { 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 { mixpanel } from '@affine/core/utils';
import { apis, events } from '@affine/electron-api';
import { useI18n } from '@affine/i18n';
import { FolderIcon, SettingsIcon } from '@blocksuite/icons/rc';
import type { Doc } from '@blocksuite/store';
import { useDroppable } from '@dnd-kit/core';
import type { Workspace } from '@toeverything/infra';
import { useLiveData, useService } from '@toeverything/infra';
import { useAtomValue } from 'jotai';
import { nanoid } from 'nanoid';
import type { HTMLAttributes, ReactElement } from 'react';
import { forwardRef, memo, useCallback, useEffect } from 'react';
import type { ReactElement } from 'react';
import { memo, useEffect } from 'react';
import { useAppSettingHelper } from '../../hooks/affine/use-app-setting-helper';
import { useNavigateHelper } from '../../hooks/use-navigate-helper';
import { WorkbenchService } from '../../modules/workbench';
import {
AddPageButton,
@@ -31,10 +31,6 @@ import {
SidebarContainer,
SidebarScrollableContainer,
} 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 ImportPage from './import-page';
import {
@@ -44,6 +40,7 @@ import {
workspaceWrapper,
} from './index.css';
import { AppSidebarJournalButton } from './journal-button';
import { TrashButton } from './trash-button';
import { UpdaterButton } from './updater-button';
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 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 trashActive = currentPath === '/trash';
const onClickNewPage = useAsyncCallback(async () => {
const page = createPage();
page.load();
@@ -129,7 +101,6 @@ export const RootAppSidebar = memo(
});
}, [createPage, openPage, telemetry]);
const navigateHelper = useNavigateHelper();
// Listen to the "New Page" action from the menu
useEffect(() => {
if (environment.isDesktop) {
@@ -147,28 +118,6 @@ export const RootAppSidebar = memo(
}
}, [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 (
<AppSidebar
clientBorder={appSettings.clientBorder}
@@ -189,15 +138,15 @@ export const RootAppSidebar = memo(
/>
<AddPageButton onClick={onClickNewPage} />
</div>
<RouteMenuLinkItem
<MenuLinkItem
icon={<FolderIcon />}
active={allPageActive}
path={paths.all(currentWorkspaceId)}
to={paths.all(currentWorkspaceId)}
>
<span data-testid="all-pages">
{t['com.affine.workspaceSubPath.all']()}
</span>
</RouteMenuLinkItem>
</MenuLinkItem>
<AppSidebarJournalButton
docCollection={currentWorkspace.docCollection}
/>
@@ -212,28 +161,15 @@ export const RootAppSidebar = memo(
</MenuItem>
</SidebarContainer>
<SidebarScrollableContainer>
<FavoriteList docCollection={docCollection} />
<CategoryDivider label={t['com.affine.rootAppSidebar.collections']()}>
<AddCollectionButton node={node} onClick={handleCreateCollection} />
</CategoryDivider>
<CollectionsList
docCollection={docCollection}
onCreate={handleCreateCollection}
/>
{runtimeConfig.enableOrganize && <ExplorerOrganize />}
<ExplorerFavorites />
<ExplorerCollections />
<ExplorerTags />
<CategoryDivider label={t['com.affine.rootAppSidebar.others']()} />
{/* fixme: remove the following spacer */}
<div style={{ height: '4px' }} />
<div style={{ padding: '0 8px' }}>
<RouteMenuLinkItem
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>
<TrashButton />
<ImportPage docCollection={docCollection} />
</div>
</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 { ZipTransformer } from '@blocksuite/blocks';
import { assertExists } from '@blocksuite/global/utils';
import {
DndContext,
DragOverlay,
MouseSensor,
useDndContext,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
type DocMode,
DocsService,
@@ -26,9 +18,8 @@ import {
WorkspaceService,
} from '@toeverything/infra';
import { useAtomValue, useSetAtom } from 'jotai';
import type { PropsWithChildren, ReactNode } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import type { PropsWithChildren } from 'react';
import { useCallback, useEffect } from 'react';
import {
catchError,
EMPTY,
@@ -46,16 +37,11 @@ import { AppContainer } from '../components/affine/app-container';
import { SyncAwareness } from '../components/affine/awareness';
import { appSidebarResizingAtom } from '../components/app-sidebar';
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 { RootAppSidebar } from '../components/root-app-sidebar';
import { MainContainer } from '../components/workspace';
import { WorkspaceUpgrade } from '../components/workspace-upgrade';
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 { useSubscriptionNotifyReader } from '../hooks/affine/use-subscription-notify';
import { useNavigateHelper } from '../hooks/use-navigate-helper';
@@ -71,7 +57,6 @@ import {
import { SWRConfigProvider } from '../providers/swr-config-provider';
import { pathGenerator } from '../shared';
import { mixpanel } from '../utils';
import * as styles from './styles.css';
export const WorkspaceLayout = function WorkspaceLayout({
children,
@@ -215,6 +200,7 @@ export const WorkspaceLayoutInner = ({ children }: PropsWithChildren) => {
activeTab: 'appearance',
open: true,
});
mixpanel.track('SettingsViewed', {
// page:
segment: 'navigation panel',
@@ -225,99 +211,33 @@ export const WorkspaceLayoutInner = ({ children }: PropsWithChildren) => {
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();
return (
<>
{/* This DndContext is used for drag page from all-pages list into a folder in sidebar */}
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
<AppContainer data-current-path={currentPath} resizing={resizing}>
<RootAppSidebar
isPublicWorkspace={false}
onOpenQuickSearchModal={handleOpenQuickSearchModal}
onOpenSettingModal={handleOpenSettingModal}
currentWorkspace={currentWorkspace}
openPage={useCallback(
(pageId: string) => {
assertExists(currentWorkspace);
return openPage(currentWorkspace.id, pageId);
},
[currentWorkspace, openPage]
)}
createPage={handleCreatePage}
paths={pathGenerator}
/>
<AppContainer data-current-path={currentPath} resizing={resizing}>
<RootAppSidebar
isPublicWorkspace={false}
onOpenQuickSearchModal={handleOpenQuickSearchModal}
onOpenSettingModal={handleOpenSettingModal}
currentWorkspace={currentWorkspace}
openPage={useCallback(
(pageId: string) => {
assertExists(currentWorkspace);
return openPage(currentWorkspace.id, pageId);
},
[currentWorkspace, openPage]
)}
createPage={handleCreatePage}
paths={pathGenerator}
/>
<MainContainer clientBorder={appSettings.clientBorder}>
{needUpgrade || upgrading ? <WorkspaceUpgrade /> : children}
</MainContainer>
</AppContainer>
<GlobalDragOverlay />
</DndContext>
<MainContainer clientBorder={appSettings.clientBorder}>
{needUpgrade || upgrading ? <WorkspaceUpgrade /> : children}
</MainContainer>
</AppContainer>
<QuickSearchContainer />
<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(
new Observable<DeletedCollection[]>(subscriber => {
subscriber.next(this.collectionsTrashYArray?.toArray() ?? []);
@@ -1,6 +1,7 @@
import { DebugLogger } from '@affine/debug';
import type { Job, JobQueue, WorkspaceService } from '@toeverything/infra';
import {
DBService,
Entity,
IndexedDBIndexStorage,
IndexedDBJobQueue,
@@ -68,6 +69,10 @@ export class DocsIndexer extends Entity {
setupListener() {
this.workspaceEngine.doc.storage.eventBus.on(event => {
if (DBService.isDBDocId(event.docId)) {
// skip db doc
return;
}
if (event.clientId === this.workspaceEngine.doc.clientId) {
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