feat(editor): support file column and member column for database block (#10932)

close: BS-2630, BS-2631, BS-2629, BS-2632, BS-2635
This commit is contained in:
zzj3720
2025-03-18 14:51:45 +00:00
parent 321e3449ec
commit 3939cc1c52
34 changed files with 1796 additions and 77 deletions

View File

@@ -51,6 +51,7 @@ import {
DocPropertiesTable,
} from '../../components/doc-properties';
import { patchForAttachmentEmbedViews } from '../extensions/attachment-embed-view';
import { patchDatabaseBlockConfigService } from '../extensions/database-block-config-service';
import { patchDocModeService } from '../extensions/doc-mode-service';
import { patchDocUrlExtensions } from '../extensions/doc-url';
import { EdgelessClipboardWatcher } from '../extensions/edgeless-clipboard';
@@ -168,6 +169,7 @@ const usePatchSpecs = (mode: DocMode) => {
patchUserExtensions(publicUserService),
]
: [],
patchDatabaseBlockConfigService(),
mode === 'edgeless' && enableTurboRenderer
? patchTurboRendererExtension()
: [],

View File

@@ -0,0 +1,45 @@
import { propertyType, t } from '@blocksuite/affine/blocks/database';
import zod from 'zod';
export const fileColumnType = propertyType('file');
export const FileItemSchema = zod.object({
id: zod.string(),
name: zod.string(),
mime: zod.string().optional(),
order: zod.string(),
});
export type FileItemType = zod.TypeOf<typeof FileItemSchema>;
const FileCellRawValueTypeSchema = zod.record(zod.string(), FileItemSchema);
export const FileCellJsonValueTypeSchema = zod.array(zod.string());
export type FileCellRawValueType = zod.TypeOf<
typeof FileCellRawValueTypeSchema
>;
export type FileCellJsonValueType = zod.TypeOf<
typeof FileCellJsonValueTypeSchema
>;
export const filePropertyModelConfig = fileColumnType.modelConfig({
name: 'File',
propertyData: {
schema: zod.object({}),
default: () => ({}),
},
rawValue: {
schema: FileCellRawValueTypeSchema,
default: () => ({}) as FileCellRawValueType,
fromString: () => ({
value: {},
}),
toString: ({ value }) =>
Object.values(value ?? {})
?.map(v => v.name)
.join(',') ?? '',
toJson: ({ value }) => Object.values(value ?? {}).map(v => v.name),
},
jsonValue: {
schema: FileCellJsonValueTypeSchema,
type: () => t.array.instance(t.string.instance()),
isEmpty: ({ value }) => value.length === 0,
},
});

View File

@@ -0,0 +1,174 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const filePopoverContainer = style({
padding: '8px 0 0 0',
width: '415px',
});
export const filePopoverContent = style({
padding: '0',
});
export const uploadButton = style({
width: '100%',
fontSize: '16px',
});
export const fileInfoContainer = style({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
fontSize: '14px',
gap: '8px',
overflow: 'hidden',
});
export const fileSizeInfo = style({
color: cssVarV2('text/secondary'),
});
export const upgradeLink = style({
color: '#1E96F0',
textDecoration: 'none',
fontWeight: 500,
whiteSpace: 'nowrap',
});
export const fileListContainer = style({
display: 'flex',
flexDirection: 'column',
});
export const fileItem = style({
display: 'flex',
justifyContent: 'space-between',
padding: '4px 8px',
gap: '8px',
overflow: 'hidden',
});
export const fileItemContent = style({
display: 'flex',
alignItems: 'center',
gap: '4px',
overflow: 'hidden',
});
export const fileName = style({
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontSize: '14px',
});
export const menuButton = style({
display: 'flex',
height: '20px',
width: '20px',
flexShrink: 0,
justifyContent: 'center',
alignItems: 'center',
borderRadius: '2px',
cursor: 'pointer',
color: cssVarV2.icon.primary,
':hover': {
backgroundColor: cssVarV2.layer.background.hoverOverlay,
},
});
export const cellContainer = style({
width: '100%',
position: 'relative',
gap: '6px',
display: 'flex',
flexWrap: 'wrap',
overflow: 'hidden',
});
export const fileItemCell = style({
display: 'flex',
alignItems: 'center',
overflow: 'hidden',
height: '24px',
});
export const fileItemImagePreview = style({
height: '136px',
borderRadius: '2px',
});
export const progressIconContainer = style({
position: 'relative',
width: '24px',
height: '24px',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
});
export const progressSvg = style({
transform: 'rotate(-90deg)',
});
export const progressCircle = style({
transition: 'stroke-dasharray 0.3s ease-in-out',
});
export const imagePreviewIcon = style({
borderRadius: '2px',
height: '100%',
});
export const filePreviewContainer = style({
width: '100%',
height: '100%',
backgroundColor: cssVarV2.database.attachment.fileSolidBackground,
padding: '1px 4px',
fontSize: '14px',
lineHeight: '22px',
borderRadius: '2px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
});
export const imagePreviewContainer = style({
height: '24px',
borderRadius: '2px',
border: 'none',
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
});
export const uploadContainer = style({
display: 'flex',
alignItems: 'center',
padding: '8px 16px',
borderTop: '1px solid',
borderColor: cssVarV2.layer.insideBorder.border,
});
export const uploadButtonStyle = style({
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '4px',
cursor: 'pointer',
});
export const uploadPopoverContainer = style({
padding: '12px',
width: '415px',
display: 'flex',
flexDirection: 'column',
gap: '10px',
});
export const fileNameStyle = style({
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
});

View File

@@ -0,0 +1,633 @@
import { Popover, uniReactRoot } from '@affine/component';
import { Button } from '@affine/component/ui/button';
import { Menu, MenuItem } from '@affine/component/ui/menu';
import {
type Cell,
type CellRenderProps,
createIcon,
type DataViewCellLifeCycle,
HostContextKey,
} from '@blocksuite/affine/blocks/database';
import { openFileOrFiles } from '@blocksuite/affine/shared/utils';
import type { BlobEngine } from '@blocksuite/affine/sync';
import {
DeleteIcon,
DownloadIcon,
FileIcon,
MoreHorizontalIcon,
PlusIcon,
} from '@blocksuite/icons/rc';
import {
computed,
type ReadonlySignal,
type Signal,
signal,
} from '@preact/signals-core';
import { generateFractionalIndexingKeyBetween } from '@toeverything/infra';
import { cssVarV2 } from '@toeverything/theme/v2';
import { fileTypeFromBuffer, type FileTypeResult } from 'file-type';
import { nanoid } from 'nanoid';
import type { ForwardRefRenderFunction, MouseEvent, ReactNode } from 'react';
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
} from 'react';
import { useSignalValue } from '../../../../modules/doc-info/utils';
import type {
FileCellJsonValueType,
FileCellRawValueType,
FileItemType,
} from './define';
import { filePropertyModelConfig } from './define';
import * as styles from './style.css';
interface FileUploadProgress {
name: string;
progress: number;
}
interface FileLoadData {
blob: Blob;
url: string;
fileType?: FileTypeResult;
}
class FileUploadManager {
private readonly uploadProgressMap: Map<string, Signal<FileUploadProgress>> =
new Map();
private readonly fileLoadMap: Map<string, Signal<FileLoadData | undefined>> =
new Map();
constructor(private readonly blobSync: BlobEngine) {}
uploadFile(file: File, onComplete: (blobId?: string) => void): string {
const tempId = nanoid();
const progress = signal<FileUploadProgress>({
progress: 0,
name: file.name,
});
this.uploadProgressMap.set(tempId, progress);
this.startUpload(file, tempId)
.then(blobId => {
this.uploadProgressMap.delete(tempId);
onComplete?.(blobId);
})
.catch(() => {
this.uploadProgressMap.delete(tempId);
onComplete?.();
});
return tempId;
}
async startUpload(file: File, fileId: string): Promise<string | undefined> {
let progress = this.uploadProgressMap.get(fileId);
if (!progress) {
return;
}
progress.value = {
...progress.value,
progress: 10,
};
const arrayBuffer = await file.arrayBuffer();
progress = this.uploadProgressMap.get(fileId);
if (!progress) {
return;
}
progress.value = {
...progress.value,
progress: 30,
};
const blob = new Blob([arrayBuffer], {
type: file.type,
});
this.simulateUploadProgress(fileId);
const uploadedId = await this.blobSync.set(blob);
progress = this.uploadProgressMap.get(fileId);
if (!progress) {
return;
}
progress.value = {
...progress.value,
progress: 100,
};
return uploadedId;
}
getUploadProgress(
fileId: string
): ReadonlySignal<FileUploadProgress> | undefined {
return this.uploadProgressMap.get(fileId);
}
async getFileBlob(blobId: string): Promise<Blob | null> {
return this.blobSync?.get(blobId);
}
getFileInfo(blobId: string): ReadonlySignal<FileLoadData | undefined> {
let fileLoadData = this.fileLoadMap.get(blobId);
if (fileLoadData) {
return fileLoadData;
}
const blobPromise = this.getFileBlob(blobId);
fileLoadData = signal<FileLoadData | undefined>(undefined);
this.fileLoadMap.set(blobId, fileLoadData);
blobPromise
.then(async blob => {
if (!blob) {
return;
}
const fileType = await fileTypeFromBuffer(await blob.arrayBuffer());
fileLoadData.value = {
blob,
url: URL.createObjectURL(blob),
fileType,
};
})
.catch(() => {});
return fileLoadData;
}
private simulateUploadProgress(fileId: string): void {
setTimeout(() => {
const progress = this.uploadProgressMap.get(fileId);
if (!progress || progress.value.progress >= 100) return;
const next =
(100 - progress.value.progress) / 10 + progress.value.progress;
progress.value = {
...progress.value,
progress: Math.min(next, 100),
};
this.simulateUploadProgress(fileId);
}, 10);
}
dispose(): void {
this.fileLoadMap.forEach(fileLoadData => {
const url = fileLoadData.value?.url;
if (url) {
URL.revokeObjectURL(url);
}
});
this.uploadProgressMap.clear();
this.fileLoadMap.clear();
}
}
type FileItemDoneType = FileItemType & {
type: 'done';
};
type FileItemUploadingType = {
id: string;
type: 'uploading';
name: string;
order: string;
};
type FileItemRenderType = FileItemDoneType | FileItemUploadingType;
const CircularProgress = ({ progress }: { progress: number }) => {
const circumference = 2 * Math.PI * 10;
return (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
className={styles.progressSvg}
>
<circle
cx="12"
cy="12"
r="10"
fill="none"
stroke={cssVarV2.loading.background}
strokeWidth="4"
/>
<circle
cx="12"
cy="12"
r="10"
fill="none"
stroke={cssVarV2.loading.foreground}
strokeWidth="4"
strokeDasharray={`${(progress / 100) * circumference} ${circumference}`}
strokeLinecap="round"
className={styles.progressCircle}
/>
</svg>
);
};
class FileCellManager {
private readonly cell: Cell<FileCellRawValueType, FileCellJsonValueType, {}>;
readonly selectCurrentCell: (editing: boolean) => void;
private readonly blobSync?: BlobEngine;
private readonly uploadingFiles = signal<
Record<string, FileItemUploadingType>
>({});
readonly isEditing: ReadonlySignal<boolean>;
readonly fileUploadManager: FileUploadManager | undefined;
doneFiles = computed(() => this.cell.value$.value ?? {});
get readonly() {
return this.cell.property.readonly$;
}
constructor(
props: CellRenderProps<{}, FileCellRawValueType, FileCellJsonValueType>
) {
this.cell = props.cell;
this.selectCurrentCell = props.selectCurrentCell;
this.isEditing = props.isEditing$;
this.blobSync = this.cell?.view?.contextGet
? this.cell.view.contextGet(HostContextKey)?.doc.blobSync
: undefined;
this.fileUploadManager = this.blobSync
? new FileUploadManager(this.blobSync)
: undefined;
}
dispose(): void {
this.fileUploadManager?.dispose();
}
removeFile = (file: FileItemRenderType, e?: MouseEvent): void => {
e?.stopPropagation();
if (file.type === 'uploading') {
const newTemp = { ...this.uploadingFiles.value };
delete newTemp[file.id];
this.uploadingFiles.value = newTemp;
return;
}
const value = { ...this.cell.value$.value };
delete value[file.id];
this.cell.valueSet(value);
};
uploadFile = (file: File): void => {
if (!this.fileUploadManager) {
return;
}
const lastFile = this.fileList.value[this.fileList.value.length - 1];
const order = generateFractionalIndexingKeyBetween(
lastFile?.order || null,
null
);
const fileId = this.fileUploadManager.uploadFile(file, blobId => {
if (blobId) {
this.cell.valueSet({
...this.cell.value$.value,
[blobId]: {
name: file.name,
id: blobId,
order,
mime: this.fileUploadManager?.getFileInfo(blobId).value?.fileType
?.mime,
},
});
}
this.removeFile(tempFile);
});
const tempFile: FileItemUploadingType = {
id: fileId,
type: 'uploading',
name: file.name,
order,
};
this.uploadingFiles.value = {
...this.uploadingFiles.value,
[fileId]: tempFile,
};
};
fileList = computed(() => {
const uploadingList = Object.values(this.uploadingFiles.value);
const doneList = Object.values(this.doneFiles.value).map<FileItemDoneType>(
file => ({
...file,
type: 'done',
})
);
return [...doneList, ...uploadingList].sort((a, b) =>
a.order > b.order ? 1 : -1
);
});
}
const FileCellComponent: ForwardRefRenderFunction<
DataViewCellLifeCycle,
CellRenderProps<{}, FileCellRawValueType, FileCellJsonValueType>
> = (props, ref): ReactNode => {
// eslint-disable-next-line react-hooks/exhaustive-deps
const manager = useMemo(() => new FileCellManager(props), []);
useEffect(() => {
return () => {
manager.dispose();
};
}, [manager]);
useImperativeHandle(
ref,
() => ({
beforeEnterEditMode: () => {
return true;
},
beforeExitEditingMode: () => {},
afterEnterEditingMode: () => {},
focusCell: () => true,
blurCell: () => true,
forceUpdate: () => {},
}),
[]
);
const fileList = useSignalValue(manager.fileList);
const isEditing = useSignalValue(manager.isEditing);
const renderPopoverContent = () => {
if (fileList.length === 0) {
return (
<div className={styles.uploadPopoverContainer}>
<Button
onClick={() => {
openFileOrFiles({ multiple: true })
.then(files => {
files?.forEach(file => {
manager.uploadFile(file);
});
})
.catch(e => {
console.error(e);
});
}}
variant="primary"
className={styles.uploadButton}
>
Choose a file
</Button>
<div className={styles.fileInfoContainer}>
<div className={styles.fileSizeInfo}>
The maximum size per file is 100MB
</div>
<a
href="#"
className={styles.upgradeLink}
onClick={e => e.stopPropagation()}
>
Upgrade to Pro
</a>
</div>
</div>
);
}
return (
<div className={styles.filePopoverContainer}>
<div className={styles.fileListContainer}>
{fileList.map(file => (
<FileListItem
key={file.id}
file={file}
handleRemoveFile={manager.removeFile}
fileUploadManager={manager.fileUploadManager}
/>
))}
</div>
<div className={styles.uploadContainer}>
<div
onClick={() => {
openFileOrFiles({ multiple: true })
.then(files => {
files?.forEach(file => {
manager.uploadFile(file);
});
})
.catch(e => {
console.error(e);
});
}}
className={styles.uploadButtonStyle}
>
<PlusIcon width={20} height={20} />
<span>Add a file or image</span>
</div>
</div>
</div>
);
};
return (
<div style={{ overflow: 'hidden' }}>
<Popover
open={isEditing}
onOpenChange={open => {
manager.selectCurrentCell(open);
}}
contentOptions={{
className: styles.filePopoverContent,
}}
content={renderPopoverContent()}
>
<div></div>
</Popover>
<div className={styles.cellContainer}>
{fileList.map(file => (
<div key={file.id} className={styles.fileItemCell}>
<FilePreview
file={file}
fileUploadManager={manager.fileUploadManager}
/>
</div>
))}
</div>
</div>
);
};
const useFilePreview = (
file: FileItemRenderType,
fileUploadManager?: FileUploadManager
): {
preview: ReactNode;
fileType: 'uploading' | 'loading' | 'image' | 'file';
} => {
const uploadProgress = useSignalValue(
file.type === 'uploading'
? fileUploadManager?.getUploadProgress(file.id)
: undefined
);
const loadFileData = useSignalValue(
file.type === 'done' ? fileUploadManager?.getFileInfo(file.id) : undefined
);
if (uploadProgress != null) {
return {
preview: (
<div className={styles.progressIconContainer}>
<CircularProgress progress={uploadProgress.progress} />
</div>
),
fileType: 'uploading',
};
}
const mime =
loadFileData?.fileType?.mime ??
(file.type === 'done' ? file.mime : undefined);
if (mime?.startsWith('image/')) {
if (loadFileData == null) {
return {
preview: null,
fileType: 'loading',
};
}
return {
preview: (
<img
className={styles.imagePreviewIcon}
src={loadFileData.url}
alt={file.name}
/>
),
fileType: 'image',
};
}
return {
preview: <FileIcon width={18} height={18} />,
fileType: 'file',
};
};
export const FileListItem = (props: {
file: FileItemRenderType;
handleRemoveFile: (file: FileItemRenderType, e?: MouseEvent) => void;
fileUploadManager?: FileUploadManager;
}) => {
const { file, handleRemoveFile, fileUploadManager } = props;
const { preview, fileType } = useFilePreview(file, fileUploadManager);
const handleDownloadFile = useCallback(
async (fileId: string, e?: MouseEvent) => {
e?.stopPropagation();
try {
const blob = await fileUploadManager?.getFileBlob(fileId);
if (!blob) {
console.error('Failed to download: blob not found');
return;
}
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = file.name;
document.body.append(a);
a.click();
setTimeout(() => {
a.remove();
URL.revokeObjectURL(url);
}, 100);
} catch (error) {
console.error('Download failed', error);
}
},
[fileUploadManager, file.name]
);
const menuItems = (
<>
{/* {fileType === 'image' && (
<MenuItem
onClick={() => {
console.log('Preview image:', file.id);
}}
prefixIcon={<FileIcon width={20} height={20} />}
>
Preview
</MenuItem>
)} */}
{(fileType === 'file' || fileType === 'image') && (
<MenuItem
onClick={e => {
void handleDownloadFile(file.id, e).catch(error => {
console.error('Download failed:', error);
});
}}
prefixIcon={<DownloadIcon width={20} height={20} />}
>
Download
</MenuItem>
)}
<MenuItem
onClick={e => {
handleRemoveFile(file, e);
}}
prefixIcon={<DeleteIcon width={20} height={20} />}
>
Delete
</MenuItem>
</>
);
return (
<div className={styles.fileItem}>
<div className={styles.fileItemContent}>
{fileType === 'image' ? (
<div className={styles.fileItemImagePreview}>{preview}</div>
) : (
<>
{preview}
<div className={styles.fileNameStyle}>{file.name}</div>
</>
)}
</div>
<Menu items={menuItems} rootOptions={{ modal: false }}>
<div
className={styles.menuButton}
onClick={(e: MouseEvent) => {
e.stopPropagation();
e.preventDefault();
}}
>
<MoreHorizontalIcon width={16} height={16} />
</div>
</Menu>
</div>
);
};
const FilePreview = (props: {
file: FileItemRenderType;
fileUploadManager?: FileUploadManager;
}) => {
const { file, fileUploadManager } = props;
const { preview, fileType } = useFilePreview(file, fileUploadManager);
if (fileType === 'file') {
return <div className={styles.filePreviewContainer}>{file.name}</div>;
}
if (fileType === 'image') {
return <div className={styles.imagePreviewContainer}>{preview}</div>;
}
return preview;
};
const FileCell = forwardRef(FileCellComponent);
export const filePropertyConfig = filePropertyModelConfig.createPropertyMeta({
icon: createIcon('FileIcon'),
cellRenderer: {
view: uniReactRoot.createUniComponent(FileCell),
},
});

View File

@@ -0,0 +1,9 @@
import type { PropertyMetaConfig } from '@blocksuite/affine/blocks/database';
import { filePropertyConfig } from './file/view';
import { memberPropertyConfig } from './member/view';
export const propertiesPresets: PropertyMetaConfig<string, any, any, any>[] = [
filePropertyConfig,
memberPropertyConfig,
];

View File

@@ -0,0 +1,37 @@
import { propertyType, t } from '@blocksuite/affine/blocks/database';
import zod from 'zod';
export const memberColumnType = propertyType('member');
export const MemberItemSchema = zod.string();
export type MemberItemType = zod.TypeOf<typeof MemberItemSchema>;
const MemberCellRawValueTypeSchema = zod.array(MemberItemSchema);
export const MemberCellJsonValueTypeSchema = zod.array(zod.string());
export type MemberCellRawValueType = zod.TypeOf<
typeof MemberCellRawValueTypeSchema
>;
export type MemberCellJsonValueType = zod.TypeOf<
typeof MemberCellJsonValueTypeSchema
>;
export const memberPropertyModelConfig = memberColumnType.modelConfig({
name: 'Member',
propertyData: {
schema: zod.object({}),
default: () => ({}),
},
rawValue: {
schema: MemberCellRawValueTypeSchema,
default: () => [] as MemberCellRawValueType,
fromString: () => ({
value: [],
}),
toString: ({ value }) => value.join(',') ?? '',
toJson: ({ value }) => value,
},
jsonValue: {
schema: MemberCellJsonValueTypeSchema,
type: () => t.array.instance(t.string.instance()),
isEmpty: ({ value }) => value.length === 0,
},
});

View File

@@ -0,0 +1,341 @@
import { Avatar, notify } from '@affine/component';
import {
type ExistedUserInfo,
type UserListService,
type UserService,
} from '@blocksuite/affine/shared/services';
import { computed, type ReadonlySignal, signal } from '@preact/signals-core';
import clsx from 'clsx';
import {
type ChangeEvent,
type MouseEvent,
useEffect,
useMemo,
useRef,
} from 'react';
import { useSignalValue } from '../../../../../modules/doc-info/utils';
import * as styles from './style.css';
type BaseOptions = {
userService: UserService;
userListService: UserListService;
onComplete: () => void;
};
export type MemberManagerOptions =
| ({
multiple: true;
value: ReadonlySignal<string[]>;
onChange: (value: string[]) => void;
} & BaseOptions)
| ({
multiple: false;
value: ReadonlySignal<string>;
onChange: (value?: string) => void;
} & BaseOptions);
class MemberManager {
selectedMembers = computed(() => {
if (this.ops.multiple) {
return this.ops.value.value;
}
return this.ops.value.value ? [this.ops.value.value] : [];
});
selectedMemberId = signal<string | null>(null);
filteredMembers = computed(() => {
return this.ops.userListService.users$.value.filter(
member =>
!member.removed && !this.selectedMembers.value.includes(member.id)
);
});
constructor(private readonly ops: MemberManagerOptions) {}
get userService() {
return this.ops.userService;
}
get userListService() {
return this.ops.userListService;
}
search = (searchText: string): void => {
this.userListService.search(searchText);
};
selectMember = (memberId: string): void => {
if (this.ops.multiple) {
if (this.selectedMembers.value.includes(memberId)) {
notify.error({
title: 'Member already exists',
message: 'The member has already been selected',
});
return;
}
this.ops.onChange([...this.selectedMembers.value, memberId]);
this.moveSelectionAfterSelect(memberId);
} else {
this.ops.onChange(memberId);
}
};
moveSelectionAfterSelect = (selectedId: string): void => {
const members = this.filteredMembers.value;
const currentIndex = members.findIndex(member => member.id === selectedId);
if (currentIndex === -1) {
return;
}
const updatedMembers = this.filteredMembers.value;
const nextMember = updatedMembers[currentIndex + 1];
if (nextMember) {
this.selectedMemberId.value = nextMember.id;
return;
}
const prevMember = updatedMembers[currentIndex - 1];
if (prevMember) {
this.selectedMemberId.value = prevMember.id;
return;
}
this.selectedMemberId.value = null;
};
removeMember = (memberId: string, e?: MouseEvent): void => {
e?.stopPropagation();
if (this.ops.multiple) {
this.ops.onChange(this.ops.value.value.filter(id => id !== memberId));
} else {
this.ops.onChange(undefined);
}
};
complete = (): void => {
this.ops.onComplete();
};
getSelectedIndex = (): number => {
if (!this.selectedMemberId.value) return -1;
const members = this.filteredMembers.value;
return members.findIndex(
member => member.id === this.selectedMemberId.value
);
};
moveSelectionUp = (): void => {
const members = this.filteredMembers.value;
if (members.length === 0) return;
const currentIndex = this.getSelectedIndex();
let newIndex = currentIndex > 0 ? currentIndex - 1 : members.length - 1;
this.selectedMemberId.value = members[newIndex].id;
};
moveSelectionDown = (): void => {
const members = this.filteredMembers.value;
if (members.length === 0) return;
const currentIndex = this.getSelectedIndex();
let newIndex = currentIndex < members.length - 1 ? currentIndex + 1 : 0;
this.selectedMemberId.value = members[newIndex].id;
};
scrollSelectedIntoView = (
memberListRef: React.RefObject<HTMLDivElement | null>
): void => {
if (!memberListRef.current) return;
const selectedElement = memberListRef.current.querySelector(
`[data-selected="true"]`
);
if (selectedElement) {
selectedElement.scrollIntoView({
block: 'nearest',
behavior: 'smooth',
});
}
};
confirmSelection = (): void => {
if (
this.selectedMemberId.value &&
this.filteredMembers.value.some(v => v.id === this.selectedMemberId.value)
) {
this.selectMember(this.selectedMemberId.value);
}
};
}
export const useMemberInfo = (id: string, memberManager: MemberManager) => {
useEffect(() => {
memberManager.userService?.revalidateUserInfo(id);
}, [id, memberManager.userService]);
return useSignalValue(memberManager.userService?.userInfo$(id));
};
export const MemberListItem = (props: {
member: ExistedUserInfo;
memberManager: MemberManager;
isSelected?: boolean;
}) => {
const { member, memberManager, isSelected } = props;
const handleClick = () => {
memberManager.selectMember(member.id);
};
const handleMouseEnter = () => {
memberManager.selectedMemberId.value = member.id;
};
return (
<div
className={clsx(
styles.memberItem,
isSelected && styles.memberSelectedItem
)}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
data-selected={isSelected ? 'true' : 'false'}
>
<div className={styles.avatar}>
<Avatar url={member.avatar} size={24} />
</div>
<div className={styles.memberName}>{member.name}</div>
</div>
);
};
export const MemberPreview = ({
memberId,
memberManager,
onDelete,
}: {
memberId: string;
memberManager: MemberManager;
onDelete?: () => void;
}) => {
const userInfo = useMemberInfo(memberId, memberManager);
if (!userInfo) {
return null;
}
return (
<div className={styles.memberPreviewContainer}>
<Avatar
className={styles.avatar}
url={!userInfo.removed ? userInfo.avatar : undefined}
size={16}
/>
<div className={styles.memberName}>
{userInfo.removed ? 'Deleted user' : userInfo.name || 'Unnamed'}
</div>
{onDelete && (
<div className={styles.memberDeleteIcon} onClick={onDelete}>
</div>
)}
</div>
);
};
export const MultiMemberSelect: React.FC<MemberManagerOptions> = props => {
const inputRef = useRef<HTMLInputElement>(null);
const memberListRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line react-hooks/exhaustive-deps
const memberManager = useMemo(() => new MemberManager(props), []);
const isLoading = useSignalValue(memberManager.userListService.isLoading$);
const selectedMembers = useSignalValue(memberManager.selectedMembers);
const filteredMemberList = useSignalValue(memberManager.filteredMembers);
const selectedMemberId = useSignalValue(memberManager.selectedMemberId);
useEffect(() => {
memberManager.search('');
const input = inputRef.current;
if (input) {
input.focus();
const handleKeyDown = (e: KeyboardEvent) => {
e.stopPropagation();
if (e.key === 'ArrowDown') {
e.preventDefault();
memberManager.moveSelectionDown();
memberManager.scrollSelectedIntoView(memberListRef);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
memberManager.moveSelectionUp();
memberManager.scrollSelectedIntoView(memberListRef);
} else if (e.key === 'Enter') {
e.preventDefault();
memberManager.confirmSelection();
} else if (
e.key === 'Backspace' &&
memberManager.userListService.searchText$.value === ''
) {
const selectedMembers = memberManager.selectedMembers.value;
const lastId = selectedMembers[selectedMembers.length - 1];
if (lastId) {
memberManager.removeMember(lastId);
}
} else if (e.key === 'Escape') {
memberManager.complete();
}
};
input.addEventListener('keydown', handleKeyDown);
return () => {
input.removeEventListener('keydown', handleKeyDown);
};
}
return;
}, [memberManager]);
const handleInputChange = (event: ChangeEvent<HTMLInputElement>) => {
memberManager.search(event.target.value);
};
return (
<div
className={styles.multiMemberSelectContainer}
onClick={() => inputRef.current?.focus()}
>
<div className={styles.memberInputContainer}>
{selectedMembers.map(memberId => (
<MemberPreview
key={memberId}
memberId={memberId}
memberManager={memberManager}
onDelete={() => memberManager.removeMember(memberId)}
/>
))}
<input
ref={inputRef}
className={styles.memberSearchInput}
placeholder="Search members..."
value={memberManager.userListService.searchText$.value}
onChange={handleInputChange}
/>
</div>
<div className={styles.memberListContainer} ref={memberListRef}>
{isLoading ? (
<div className={styles.loadingContainer}>Loading...</div>
) : filteredMemberList.length === 0 ? (
<div className={styles.noResultContainer}>No results</div>
) : (
filteredMemberList.map(member => (
<MemberListItem
key={member.id}
member={member as ExistedUserInfo}
memberManager={memberManager}
isSelected={member.id === selectedMemberId}
/>
))
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,133 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const multiMemberSelectContainer = style({
display: 'flex',
flexDirection: 'column',
gap: '8px',
position: 'relative',
padding: '8px',
width: '214px',
});
export const memberInputContainer = style({
display: 'flex',
flexWrap: 'wrap',
gap: '4px',
padding: '4px',
borderRadius: '4px',
alignItems: 'center',
border: `1px solid transparent`,
borderColor: cssVarV2.layer.insideBorder.blackBorder,
':focus-within': {
borderColor: cssVarV2.layer.insideBorder.primaryBorder,
boxShadow: cssVar('activeShadow'),
},
});
export const memberSearchInput = style({
flex: '1',
minWidth: '100px',
border: 'none',
outline: 'none',
backgroundColor: 'transparent',
fontSize: '14px',
lineHeight: '22px',
padding: '0',
color: cssVarV2.text.primary,
});
export const memberListContainer = style({
display: 'flex',
flexDirection: 'column',
maxHeight: '300px',
overflow: 'auto',
});
export const memberSelectedItem = style({
backgroundColor: cssVarV2.layer.background.hoverOverlay,
});
export const memberDeleteIcon = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '16px',
height: '16px',
color: cssVarV2.icon.primary,
fontSize: '16px',
cursor: 'pointer',
borderRadius: '2px',
':hover': {
backgroundColor: cssVarV2.layer.background.tertiary,
},
});
export const memberPreviewContainer = style({
display: 'flex',
alignItems: 'center',
overflow: 'hidden',
padding: '2px',
borderRadius: '2px',
border: `1px solid transparent`,
borderColor: cssVarV2.layer.insideBorder.blackBorder,
backgroundColor: cssVarV2.button.secondary,
});
export const memberPreview = style({
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontSize: '14px',
lineHeight: '22px',
});
export const memberItem = style({
display: 'flex',
alignItems: 'center',
gap: '8px',
overflow: 'hidden',
padding: '4px',
borderRadius: '4px',
});
export const memberItemContent = style({
display: 'flex',
alignItems: 'center',
gap: '8px',
overflow: 'hidden',
});
export const memberName = style({
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontSize: '12px',
lineHeight: '20px',
padding: '0 4px',
flex: 1,
});
export const avatar = style({
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
});
export const loadingContainer = style({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
padding: '16px',
});
export const noResultContainer = style({
display: 'flex',
alignItems: 'center',
padding: '4px',
color: cssVarV2.text.primary,
fontSize: '14px',
lineHeight: '22px',
});

View File

@@ -0,0 +1,93 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const memberPopoverContainer = style({
padding: '8px 0 0 0',
width: '415px',
});
export const memberPopoverContent = style({
padding: '0',
});
export const searchContainer = style({
padding: '12px 12px 8px 12px',
});
export const searchInput = style({
width: '100%',
});
export const memberListContainer = style({
display: 'flex',
flexDirection: 'column',
maxHeight: '300px',
overflow: 'auto',
});
export const memberItem = style({
display: 'flex',
justifyContent: 'space-between',
padding: '8px 12px',
gap: '8px',
overflow: 'hidden',
cursor: 'pointer',
borderRadius: '4px',
transition: 'background-color 0.2s ease',
':hover': {
backgroundColor: cssVarV2.layer.background.hoverOverlay,
},
':active': {
backgroundColor: cssVarV2.layer.background.secondary,
},
});
export const memberItemContent = style({
display: 'flex',
alignItems: 'center',
gap: '8px',
overflow: 'hidden',
});
export const memberName = style({
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontSize: '14px',
lineHeight: '22px',
});
export const cellContainer = style({
width: '100%',
position: 'relative',
gap: '6px',
display: 'flex',
flexWrap: 'wrap',
overflow: 'hidden',
});
export const avatar = style({
flexShrink: 0,
});
export const loadingContainer = style({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
padding: '16px',
});
export const noResultContainer = style({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
padding: '16px',
color: cssVarV2.text.secondary,
});
export const memberPreviewContainer = style({
display: 'flex',
alignItems: 'center',
gap: '4px',
overflow: 'hidden',
});

View File

@@ -0,0 +1,183 @@
import { Avatar, Popover, uniReactRoot } from '@affine/component';
import {
type Cell,
type CellRenderProps,
createIcon,
type DataViewCellLifeCycle,
HostContextKey,
} from '@blocksuite/affine/blocks/database';
import {
UserListProvider,
type UserListService,
UserProvider,
type UserService,
} from '@blocksuite/affine/shared/services';
import { computed, type ReadonlySignal } from '@preact/signals-core';
import {
forwardRef,
type ForwardRefRenderFunction,
type ReactNode,
useEffect,
useImperativeHandle,
useMemo,
} from 'react';
import { useSignalValue } from '../../../../modules/doc-info/utils';
import type {
MemberCellJsonValueType,
MemberCellRawValueType,
MemberItemType,
} from './define';
import { memberPropertyModelConfig } from './define';
import { MultiMemberSelect } from './multi-member-select';
import * as styles from './style.css';
class MemberManager {
private readonly cell: Cell<
MemberCellRawValueType,
MemberCellJsonValueType,
{}
>;
readonly selectCurrentCell: (editing: boolean) => void;
readonly isEditing: ReadonlySignal<boolean>;
public readonly userService?: UserService | null;
public readonly userListService?: UserListService | null;
memberList = computed(() => this.cell.value$.value ?? []);
get readonly() {
return this.cell.property.readonly$;
}
constructor(
props: CellRenderProps<{}, MemberCellRawValueType, MemberCellJsonValueType>
) {
this.cell = props.cell;
this.selectCurrentCell = props.selectCurrentCell;
this.isEditing = props.isEditing$;
const host = this.cell.view.contextGet(HostContextKey);
this.userService = host?.std.getOptional(UserProvider);
this.userListService = host?.std.getOptional(UserListProvider);
}
setMemberList = (memberList: MemberItemType[]): void => {
this.cell.valueSet(memberList);
};
}
const MemberCellComponent: ForwardRefRenderFunction<
DataViewCellLifeCycle,
CellRenderProps<{}, MemberCellRawValueType, MemberCellJsonValueType>
> = (props, ref): ReactNode => {
// eslint-disable-next-line react-hooks/exhaustive-deps
const manager = useMemo(() => new MemberManager(props), []);
useImperativeHandle(
ref,
() => ({
beforeEnterEditMode: () => {
return true;
},
beforeExitEditingMode: () => {},
afterEnterEditingMode: () => {},
focusCell: () => true,
blurCell: () => true,
forceUpdate: () => {},
}),
[]
);
const memberList = useSignalValue(manager.memberList);
const isEditing = useSignalValue(manager.isEditing);
const renderPopoverContent = () => {
if (!manager.userService || !manager.userListService) {
return (
<div className={styles.memberPopoverContainer}>
member list only works in cloud
</div>
);
}
return (
<MultiMemberSelect
multiple
value={manager.memberList}
onChange={newIds => {
manager.setMemberList(newIds);
}}
userService={manager.userService}
userListService={manager.userListService}
onComplete={() => {
// manager.selectCurrentCell(false);
}}
/>
);
};
return (
<div style={{ overflow: 'hidden' }}>
<Popover
open={isEditing}
onOpenChange={open => {
manager.selectCurrentCell(open);
}}
contentOptions={{
className: styles.memberPopoverContent,
}}
content={renderPopoverContent()}
>
<div></div>
</Popover>
<div className={styles.cellContainer}>
{memberList.map(memberId => (
<MemberPreview
key={memberId}
memberId={memberId}
memberManager={manager}
/>
))}
</div>
</div>
);
};
const useMemberInfo = (id: string, memberManager: MemberManager) => {
useEffect(() => {
memberManager.userService?.revalidateUserInfo(id);
}, [id, memberManager.userService]);
return useSignalValue(memberManager.userService?.userInfo$(id));
};
const MemberPreview = ({
memberId,
memberManager,
}: {
memberId: string;
memberManager: MemberManager;
}) => {
const userInfo = useMemberInfo(memberId, memberManager);
if (!userInfo) {
return null;
}
return (
<div className={styles.memberPreviewContainer}>
<Avatar
className={styles.avatar}
url={!userInfo.removed ? userInfo.avatar : undefined}
size={24}
/>
<div className={styles.memberName}>
{userInfo.removed ? 'Deleted user' : userInfo.name || 'Unnamed'}
</div>
</div>
);
};
const MemberCell = forwardRef(MemberCellComponent);
export const memberPropertyConfig =
memberPropertyModelConfig.createPropertyMeta({
icon: createIcon('MultiPeopleIcon'),
cellRenderer: {
view: uniReactRoot.createUniComponent(MemberCell),
},
});

View File

@@ -0,0 +1,12 @@
import { DatabaseBlockDataSource } from '@blocksuite/affine/blocks/database';
import type { ExtensionType } from '@blocksuite/affine/store';
import { propertiesPresets } from '../database-block/properties';
export function patchDatabaseBlockConfigService(): ExtensionType {
//TODO use service
DatabaseBlockDataSource.externalProperties.value = propertiesPresets;
return {
setup: () => {},
};
}

View File

@@ -8,6 +8,10 @@ export function patchUserListExtensions(memberSearch: MemberSearchService) {
loadMore() {
memberSearch.loadMore();
},
// eslint-disable-next-line rxjs/finnish
isLoading$: memberSearch.isLoading$.signal,
// eslint-disable-next-line rxjs/finnish
searchText$: memberSearch.searchText$.signal,
search(keyword) {
memberSearch.search(keyword);
},

View File

@@ -2,12 +2,13 @@ import { DebugLogger } from '@affine/debug';
import { BlockStdScope } from '@blocksuite/affine/block-std';
import { PageEditorBlockSpecs } from '@blocksuite/affine/extensions';
import type { Store } from '@blocksuite/affine/store';
import { useMemo } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Observable } from 'rxjs';
const logger = new DebugLogger('doc-info');
interface ReadonlySignal<T> {
value: T;
subscribe: (fn: (value: T) => void) => () => void;
}
@@ -23,6 +24,20 @@ export function signalToObservable<T>(
};
});
}
export function useSignalValue<T>(signal: ReadonlySignal<T>): T;
export function useSignalValue<T>(signal?: ReadonlySignal<T>): T | undefined;
export function useSignalValue<T>(signal?: ReadonlySignal<T>): T | undefined {
const [value, setValue] = useState<T | undefined>(signal?.value);
useEffect(() => {
if (signal == null) {
return;
}
return signal.subscribe(value => {
setValue(value);
});
}, [signal]);
return value;
}
// todo(pengx17): use rc pool?
export function createBlockStdScope(doc: Store) {

View File

@@ -68,9 +68,6 @@ export class MemberSearchService extends Service {
}
search(searchText?: string) {
if (this.searchText$.value === searchText) {
return;
}
this.reset();
this.searchText$.setValue(searchText ?? '');
this.loadMore();