mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 21:27:20 +00:00
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:
@@ -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()
|
||||
: [],
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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),
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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),
|
||||
},
|
||||
});
|
||||
@@ -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: () => {},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user