Merge branch 'canary' into zzj/feat/database-block/upgrade-prompt

This commit is contained in:
3720
2025-05-14 01:28:25 +08:00
committed by GitHub
352 changed files with 7012 additions and 6659 deletions
@@ -135,6 +135,7 @@ declare global {
isRootSession?: boolean;
webSearch?: boolean;
reasoning?: boolean;
modelId?: string;
contexts?: {
docs: AIDocContextOption[];
files: AIFileContextOption[];
@@ -1,9 +1,11 @@
import { Peekable } from '@blocksuite/affine/components/peek';
import { ViewExtensionManagerIdentifier } from '@blocksuite/affine/ext-loader';
import { BlockComponent } from '@blocksuite/affine/std';
import { computed } from '@preact/signals-core';
import { html } from 'lit';
import { ChatMessagesSchema } from '../../components/ai-chat-messages';
import type { TextRendererOptions } from '../../components/text-renderer';
import { ChatWithAIIcon } from './components/icon';
import { type AIChatBlockModel } from './model';
import { AIChatBlockStyles } from './styles';
@@ -17,6 +19,8 @@ import { AIChatBlockStyles } from './styles';
export class AIChatBlockComponent extends BlockComponent<AIChatBlockModel> {
static override styles = AIChatBlockStyles;
private _textRendererOptions: TextRendererOptions = {};
// Deserialize messages from JSON string and verify the type using zod
private readonly _deserializeChatMessages = computed(() => {
const messages = this.model.props.messages$.value;
@@ -32,18 +36,23 @@ export class AIChatBlockComponent extends BlockComponent<AIChatBlockModel> {
}
});
override connectedCallback() {
super.connectedCallback();
this._textRendererOptions = {
customHeading: true,
extensions: this.previewExtensions,
};
}
override renderBlock() {
const messages = this._deserializeChatMessages.value.slice(-2);
const textRendererOptions = {
customHeading: true,
};
return html`<div class="affine-ai-chat-block-container">
<div class="ai-chat-messages-container">
<ai-chat-messages
.host=${this.host}
.messages=${messages}
.textRendererOptions=${textRendererOptions}
.textRendererOptions=${this._textRendererOptions}
.withMask=${true}
></ai-chat-messages>
</div>
@@ -52,6 +61,10 @@ export class AIChatBlockComponent extends BlockComponent<AIChatBlockModel> {
</div>
</div> `;
}
get previewExtensions() {
return this.std.get(ViewExtensionManagerIdentifier).get('preview-page');
}
}
declare global {
@@ -1,9 +1,11 @@
import { Bound } from '@blocksuite/affine/global/gfx';
import { Bound, clamp } from '@blocksuite/affine/global/gfx';
import { toGfxBlockComponent } from '@blocksuite/affine/std';
import { GfxViewInteractionExtension } from '@blocksuite/std/gfx';
import { html } from 'lit';
import { styleMap } from 'lit/directives/style-map.js';
import { AIChatBlockComponent } from './ai-chat-block';
import { AIChatBlockSchema } from './model';
export class EdgelessAIChatBlockComponent extends toGfxBlockComponent(
AIChatBlockComponent
@@ -31,6 +33,69 @@ export class EdgelessAIChatBlockComponent extends toGfxBlockComponent(
}
}
export const EdgelessAIChatBlockInteraction =
GfxViewInteractionExtension<EdgelessAIChatBlockComponent>(
AIChatBlockSchema.model.flavour,
{
resizeConstraint: {
minWidth: 260,
minHeight: 160,
maxWidth: 320,
maxHeight: 300,
},
handleRotate() {
return {
beforeRotate(context) {
context.set({
rotatable: false,
});
},
};
},
handleResize({ model }) {
const initialScale = model.props.scale$.peek();
return {
onResizeStart(context) {
context.default(context);
model.stash('scale');
},
onResizeMove(context) {
const { newBound, originalBound, lockRatio, constraint } = context;
const { minWidth, maxWidth, minHeight, maxHeight } = constraint;
let scale = initialScale;
const originalRealWidth = originalBound.w / scale;
// update scale if resize is proportional
if (lockRatio) {
scale = newBound.w / originalRealWidth;
}
let newRealWidth = clamp(newBound.w / scale, minWidth, maxWidth);
let newRealHeight = clamp(newBound.h / scale, minHeight, maxHeight);
newBound.w = newRealWidth * scale;
newBound.h = newRealHeight * scale;
model.props.xywh = newBound.serialize();
if (scale !== initialScale) {
model.props.scale = scale;
}
},
onResizeEnd(context) {
context.default(context);
model.pop('scale');
},
};
},
}
);
declare global {
interface HTMLElementTagNameMap {
'affine-edgeless-ai-chat': EdgelessAIChatBlockComponent;
@@ -158,6 +158,9 @@ export class ChatPanelMessages extends WithDisposable(ShadowlessElement) {
@property({ attribute: false })
accessor extensions!: ExtensionType[];
@property({ attribute: false })
accessor affineFeatureFlagService!: FeatureFlagService;
@query('.chat-panel-messages-container')
accessor messagesContainer: HTMLDivElement | null = null;
@@ -271,6 +274,7 @@ export class ChatPanelMessages extends WithDisposable(ShadowlessElement) {
.status=${isLast ? status : 'idle'}
.error=${isLast ? error : null}
.extensions=${this.extensions}
.affineFeatureFlagService=${this.affineFeatureFlagService}
.getSessionId=${this.getSessionId}
.retry=${() => this.retry()}
></chat-message-assistant>`;
@@ -1,3 +1,4 @@
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { WithDisposable } from '@blocksuite/affine/global/lit';
import type { EditorHost } from '@blocksuite/affine/std';
import { ShadowlessElement } from '@blocksuite/affine/std';
@@ -20,11 +21,15 @@ export class ChatContentRichText extends WithDisposable(ShadowlessElement) {
@property({ attribute: false })
accessor extensions!: ExtensionType[];
@property({ attribute: false })
accessor affineFeatureFlagService!: FeatureFlagService;
protected override render() {
const { text, host } = this;
return html`${createTextRenderer(host, {
customHeading: true,
extensions: this.extensions,
affineFeatureFlagService: this.affineFeatureFlagService,
})(text, this.state)}`;
}
}
@@ -1,5 +1,6 @@
import './chat-panel-messages';
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
import type { ContextEmbedStatus } from '@affine/graphql';
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
import type { EditorHost } from '@blocksuite/affine/std';
@@ -205,6 +206,9 @@ export class ChatPanel extends SignalWatcher(
@property({ attribute: false })
accessor extensions!: ExtensionType[];
@property({ attribute: false })
accessor affineFeatureFlagService!: FeatureFlagService;
@state()
accessor isLoading = false;
@@ -399,6 +403,7 @@ export class ChatPanel extends SignalWatcher(
.host=${this.host}
.isLoading=${this.isLoading}
.extensions=${this.extensions}
.affineFeatureFlagService=${this.affineFeatureFlagService}
></chat-panel-messages>
<ai-chat-composer
.host=${this.host}
@@ -1,6 +1,7 @@
import '../content/assistant-avatar';
import '../content/rich-text';
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { WithDisposable } from '@blocksuite/affine/global/lit';
import { isInsidePageEditor } from '@blocksuite/affine/shared/utils';
import type { EditorHost } from '@blocksuite/affine/std';
@@ -48,6 +49,9 @@ export class ChatMessageAssistant extends WithDisposable(ShadowlessElement) {
@property({ attribute: false })
accessor extensions!: ExtensionType[];
@property({ attribute: false })
accessor affineFeatureFlagService!: FeatureFlagService;
@property({ attribute: false })
accessor getSessionId!: () => Promise<string | undefined>;
@@ -93,6 +97,7 @@ export class ChatMessageAssistant extends WithDisposable(ShadowlessElement) {
.text=${item.content}
.state=${state}
.extensions=${this.extensions}
.affineFeatureFlagService=${this.affineFeatureFlagService}
></chat-content-rich-text>
${shouldRenderError ? AIChatErrorRenderer(host, error) : nothing}
${this.renderEditorActions()}
@@ -1,8 +1,6 @@
import { toast } from '@affine/component';
import type {
CollectionMeta,
TagMeta,
} from '@affine/core/components/page-list';
import type { TagMeta } from '@affine/core/components/page-list';
import type { CollectionMeta } from '@affine/core/modules/collection';
import track from '@affine/track';
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
import { scrollbarStyle } from '@blocksuite/affine/shared/styles';
@@ -1,5 +1,4 @@
import type { TagMeta } from '@affine/core/components/page-list';
import type { Collection } from '@affine/env/filter';
import { createLitPortal } from '@blocksuite/affine/components/portal';
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
@@ -128,7 +127,7 @@ export class ChatPanelChips extends SignalWatcher(
private _tags: Signal<TagMeta[]> = signal([]);
private _collections: Signal<Collection[]> = signal([]);
private _collections: Signal<{ id: string; name: string }[]> = signal([]);
private _cleanup: (() => void) | null = null;
@@ -1,4 +1,3 @@
import type { Collection } from '@affine/env/filter';
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
import { ShadowlessElement } from '@blocksuite/affine/std';
import { CollectionsIcon } from '@blocksuite/icons/lit';
@@ -18,7 +17,7 @@ export class ChatPanelCollectionChip extends SignalWatcher(
accessor removeChip!: (chip: CollectionChip) => void;
@property({ attribute: false })
accessor collection!: Collection;
accessor collection!: { id: string; name: string };
override render() {
const { state } = this.chip;
@@ -4,7 +4,6 @@ import type {
SearchDocMenuAction,
SearchTagMenuAction,
} from '@affine/core/modules/search-menu/services';
import type { Collection } from '@affine/env/filter';
import type { DocMeta, Store } from '@blocksuite/affine/store';
import type { LinkedMenuGroup } from '@blocksuite/affine/widgets/linked-doc';
import type { Signal } from '@preact/signals-core';
@@ -71,7 +70,7 @@ export interface DocDisplayConfig {
getTagTitle: (tagId: string) => string;
getTagPageIds: (tagId: string) => string[];
getCollections: () => {
signal: Signal<Collection[]>;
signal: Signal<{ id: string; name: string }[]>;
cleanup: () => void;
};
getCollectionPageIds: (collectionId: string) => string[];
@@ -1,4 +1,5 @@
import { createReactComponentFromLit } from '@affine/component';
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { Container, type ServiceProvider } from '@blocksuite/affine/global/di';
import { WithDisposable } from '@blocksuite/affine/global/lit';
import {
@@ -6,10 +7,7 @@ import {
defaultImageProxyMiddleware,
ImageProxyService,
} from '@blocksuite/affine/shared/adapters';
import {
LinkPreviewerService,
ThemeProvider,
} from '@blocksuite/affine/shared/services';
import { ThemeProvider } from '@blocksuite/affine/shared/services';
import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
import {
BlockStdScope,
@@ -104,6 +102,7 @@ export type TextRendererOptions = {
extensions?: ExtensionType[];
additionalMiddlewares?: TransformerMiddleware[];
testId?: string;
affineFeatureFlagService?: FeatureFlagService;
};
// todo: refactor it for more general purpose usage instead of AI only?
@@ -266,7 +265,14 @@ export class TextRenderer extends WithDisposable(ShadowlessElement) {
codeBlockWrapMiddleware(true),
...(this.options.additionalMiddlewares ?? []),
];
markDownToDoc(provider, schema, latestAnswer, middlewares)
const affineFeatureFlagService = this.options.affineFeatureFlagService;
markDownToDoc(
provider,
schema,
latestAnswer,
middlewares,
affineFeatureFlagService
)
.then(doc => {
this.disposeDoc();
this._doc = doc.doc.getStore({
@@ -278,16 +284,10 @@ export class TextRenderer extends WithDisposable(ShadowlessElement) {
this._doc.readonly = true;
this.requestUpdate();
if (this.state !== 'generating') {
// LinkPreviewerService & ImageProxyService config should read from host settings
const linkPreviewerService =
this.host?.std.store.get(LinkPreviewerService);
this._doc.load();
// LinkPreviewService & ImageProxyService config should read from host settings
const imageProxyService =
this.host?.std.store.get(ImageProxyService);
if (linkPreviewerService) {
this._doc
?.get(LinkPreviewerService)
.setEndpoint(linkPreviewerService.endpoint);
}
if (imageProxyService) {
this._doc
?.get(ImageProxyService)
@@ -1,3 +1,4 @@
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
import type { ContextEmbedStatus } from '@affine/graphql';
import {
CanvasElementType,
@@ -445,7 +446,10 @@ export class AIChatBlockPeekView extends LitElement {
.get(ViewExtensionManagerIdentifier)
.get('preview-page');
this._textRendererOptions = { extensions };
this._textRendererOptions = {
extensions,
affineFeatureFlagService: this.affineFeatureFlagService,
};
this._historyMessages = this._deserializeHistoryChatMessages(
this.historyMessagesString
);
@@ -556,6 +560,9 @@ export class AIChatBlockPeekView extends LitElement {
@property({ attribute: false })
accessor searchMenuConfig!: SearchMenuConfig;
@property({ attribute: false })
accessor affineFeatureFlagService!: FeatureFlagService;
@state()
accessor _historyMessages: ChatMessage[] = [];
@@ -584,7 +591,8 @@ export const AIChatBlockPeekViewTemplate = (
docDisplayConfig: DocDisplayConfig,
searchMenuConfig: SearchMenuConfig,
networkSearchConfig: AINetworkSearchConfig,
reasoningConfig: AIReasoningConfig
reasoningConfig: AIReasoningConfig,
affineFeatureFlagService: FeatureFlagService
) => {
return html`<ai-chat-block-peek-view
.blockModel=${blockModel}
@@ -593,5 +601,6 @@ export const AIChatBlockPeekViewTemplate = (
.docDisplayConfig=${docDisplayConfig}
.searchMenuConfig=${searchMenuConfig}
.reasoningConfig=${reasoningConfig}
.affineFeatureFlagService=${affineFeatureFlagService}
></ai-chat-block-peek-view>`;
};
@@ -352,12 +352,14 @@ export class CopilotClient {
messageId,
reasoning,
webSearch,
modelId,
signal,
}: {
sessionId: string;
messageId?: string;
reasoning?: boolean;
webSearch?: boolean;
modelId?: string;
signal?: AbortSignal;
}) {
let url = `/api/copilot/chat/${sessionId}`;
@@ -365,6 +367,7 @@ export class CopilotClient {
messageId,
reasoning,
webSearch,
modelId,
});
if (queryString) {
url += `?${queryString}`;
@@ -380,11 +383,13 @@ export class CopilotClient {
messageId,
reasoning,
webSearch,
modelId,
}: {
sessionId: string;
messageId?: string;
reasoning?: boolean;
webSearch?: boolean;
modelId?: string;
},
endpoint = 'stream'
) {
@@ -393,6 +398,7 @@ export class CopilotClient {
messageId,
reasoning,
webSearch,
modelId,
});
if (queryString) {
url += `?${queryString}`;
@@ -21,6 +21,7 @@ export type TextToTextOptions = {
postfix?: (text: string) => string;
reasoning?: boolean;
webSearch?: boolean;
modelId?: string;
};
export type ToImageOptions = TextToTextOptions & {
@@ -117,6 +118,7 @@ export function textToText({
postfix,
reasoning,
webSearch,
modelId,
}: TextToTextOptions) {
let messageId: string | undefined;
@@ -138,6 +140,7 @@ export function textToText({
messageId,
reasoning,
webSearch,
modelId,
},
workflow ? 'workflow' : undefined
);
@@ -199,6 +202,7 @@ export function textToText({
messageId,
reasoning,
webSearch,
modelId,
});
})(),
]);
@@ -82,7 +82,7 @@ export function setupAIProvider(
//#region actions
AIProvider.provide('chat', async options => {
const { input, contexts, webSearch } = options;
const { input, contexts, webSearch, reasoning } = options;
const sessionId = await createSession({
promptName: 'Chat With AFFiNE AI',
@@ -90,6 +90,7 @@ export function setupAIProvider(
});
return textToText({
...options,
modelId: options.modelId ?? (reasoning ? 'o4-mini' : undefined),
client,
sessionId,
content: input,
@@ -165,11 +165,13 @@ const usePreviewExtensions = () => {
const extensions = useMemo(() => {
const manager = getViewManager()
.config.init()
.common(framework, enableAI)
.foundation(framework)
.ai(enableAI, framework)
.theme(framework)
.database(framework)
.linkedDoc(framework)
.paragraph(enableAI).value;
.paragraph(enableAI)
.linkPreview(framework).value;
const specs = manager.get('preview-page');
return [...specs, patchReferenceRenderer(reactToLit, referenceRenderer)];
}, [reactToLit, referenceRenderer, framework, enableAI]);
@@ -19,7 +19,6 @@ import {
ImageProxyService,
} from '@blocksuite/affine/shared/adapters';
import { focusBlockEnd } from '@blocksuite/affine/shared/commands';
import { LinkPreviewerService } from '@blocksuite/affine/shared/services';
import { getLastNoteBlock } from '@blocksuite/affine/shared/utils';
import type { BlockStdScope, EditorHost } from '@blocksuite/affine/std';
import type { Store } from '@blocksuite/affine/store';
@@ -212,13 +211,7 @@ const BlockSuiteEditorImpl = ({
server.baseUrl
).toString();
const linkPreviewUrl = new URL(
BUILD_CONFIG.linkPreviewUrl,
server.baseUrl
).toString();
editor.std.clipboard.use(customImageProxyMiddleware(imageProxyUrl));
page.get(LinkPreviewerService).setEndpoint(linkPreviewUrl);
page.get(ImageProxyService).setImageProxyURL(imageProxyUrl);
editor.updateComplete
@@ -80,7 +80,8 @@ const usePatchSpecs = (mode: DocMode) => {
const patchedSpecs = useMemo(() => {
const manager = getViewManager()
.config.init()
.common(framework, enableAI)
.foundation(framework)
.ai(enableAI, framework)
.theme(framework)
.editorConfig(framework)
.editorView({
@@ -98,7 +99,10 @@ const usePatchSpecs = (mode: DocMode) => {
})
.database(framework)
.linkedDoc(framework)
.paragraph(enableAI).value;
.paragraph(enableAI)
.mobile(framework)
.electron(framework)
.linkPreview(framework).value;
if (BUILD_CONFIG.isMobileEdition) {
if (mode === 'page') {
@@ -0,0 +1,147 @@
import { Avatar, uniReactRoot } from '@affine/component';
import {
createGroupByConfig,
type GroupRenderProps,
t,
ungroups,
} from '@blocksuite/affine/blocks/database';
import type { UserService } from '@blocksuite/affine-shared/services';
import { useMemberInfo } from '../hooks/use-member-info';
import {
avatar,
memberName,
memberPreviewContainer,
} from '../properties/member/style.css';
const MemberPreview = ({
memberId,
userService,
}: {
memberId: string;
userService: UserService | null | undefined;
}) => {
const userInfo = useMemberInfo(memberId, userService);
if (!userInfo) {
return null;
}
return (
<div className={memberPreviewContainer}>
<Avatar
name={userInfo.removed ? undefined : (userInfo.name ?? undefined)}
className={avatar}
url={!userInfo.removed ? userInfo.avatar : undefined}
size={20}
/>
<div className={memberName}>
{userInfo.removed ? 'Deleted user' : userInfo.name || 'Unnamed'}
</div>
</div>
);
};
const MemberGroupView = (props: GroupRenderProps<string | null, {}>) => {
const tType = props.group.tType;
if (!t.user.is(tType)) return 'Ungroup';
const memberId = props.group.value;
if (memberId == null) return 'Ungroup';
return (
<MemberPreview
memberId={memberId}
userService={tType.data?.userService}
></MemberPreview>
);
};
const MultiMemberGroupView = (props: GroupRenderProps<string | null, {}>) => {
const tType = props.group.tType;
if (!t.array.is(tType) || !t.user.is(tType.element)) return 'Ungroup';
const memberId = props.group.value;
if (memberId == null) return 'Ungroup';
return (
<MemberPreview
memberId={memberId}
userService={tType.element.data?.userService}
></MemberPreview>
);
};
export const groupByConfigList = [
createGroupByConfig({
name: 'member',
matchType: t.user.instance(),
groupName: (type, value: string | null) => {
if (t.user.is(type) && typeof value === 'string') {
const userService = type.data?.userService;
if (userService) {
const userInfo = userService.userInfo$(value).value;
if (userInfo && !userInfo?.removed) {
return userInfo.name ?? 'Unnamed';
}
}
}
return '';
},
defaultKeys: () => {
return [ungroups];
},
valuesGroup: value => {
if (typeof value !== 'string') {
return [ungroups];
}
return [
{
key: value,
value: value,
},
];
},
view: uniReactRoot.createUniComponent(MemberGroupView),
}),
createGroupByConfig({
name: 'multi-member',
matchType: t.array.instance(t.user.instance()),
groupName: (_type, value: string | null) => {
if (
t.array.is(_type) &&
t.user.is(_type.element) &&
typeof value === 'string'
) {
const userService = _type.element.data?.userService;
if (userService) {
const userInfo = userService.userInfo$(value).value;
if (userInfo && !userInfo?.removed) {
return userInfo.name ?? 'Unnamed';
}
}
}
return '';
},
defaultKeys: _type => {
return [ungroups];
},
valuesGroup: (value, _type) => {
if (!Array.isArray(value)) {
return [ungroups];
}
return value.map(id => ({
key: id,
value: id,
}));
},
addToGroup: (value, old) => {
if (value == null) {
return old;
}
return Array.isArray(old) ? [...old, value] : [value];
},
removeFromGroup: (value, old) => {
if (Array.isArray(old)) {
return old.filter(v => v !== value);
}
return old;
},
view: uniReactRoot.createUniComponent(MultiMemberGroupView),
}),
];
@@ -0,0 +1,14 @@
import type { UserService } from '@blocksuite/affine-shared/services';
import { useEffect } from 'react';
import { useSignalValue } from '../../../modules/doc-info/utils';
export const useMemberInfo = (
id: string,
userService: UserService | null | undefined
) => {
useEffect(() => {
userService?.revalidateUserInfo(id);
}, [id, userService]);
return useSignalValue(userService?.userInfo$(id));
};
@@ -1,4 +1,12 @@
import { propertyType, t } from '@blocksuite/affine/blocks/database';
import {
EditorHostKey,
propertyType,
t,
} from '@blocksuite/affine/blocks/database';
import {
UserListProvider,
UserProvider,
} from '@blocksuite/affine/shared/services';
import zod from 'zod';
export const createdByColumnType = propertyType('created-by');
@@ -26,6 +34,19 @@ export const createdByPropertyModelConfig = createdByColumnType.modelConfig({
jsonValue: {
schema: zod.string().nullable(),
isEmpty: () => false,
type: () => t.string.instance(),
type: ({ dataSource }) => {
const host = dataSource.serviceGet(EditorHostKey);
const userService = host?.std.getOptional(UserProvider);
const userListService = host?.std.getOptional(UserListProvider);
return t.user.instance(
userListService && userService
? {
userService,
userListService,
}
: undefined
);
},
},
});
@@ -3,7 +3,7 @@ import {
type CellRenderProps,
createIcon,
type DataViewCellLifeCycle,
HostContextKey,
EditorHostKey,
} from '@blocksuite/affine/blocks/database';
import {
UserProvider,
@@ -14,11 +14,11 @@ import {
forwardRef,
type ForwardRefRenderFunction,
type ReactNode,
useEffect,
useImperativeHandle,
} from 'react';
import { useSignalValue } from '../../../../modules/doc-info/utils';
import { useMemberInfo } from '../../hooks/use-member-info';
import { createdByPropertyModelConfig } from './define';
const cellContainer = css({
@@ -64,7 +64,7 @@ const CreatedByCellComponent: ForwardRefRenderFunction<
}),
[]
);
const host = props.cell.view.contextGet(HostContextKey);
const host = props.cell.view.serviceGet(EditorHostKey);
const userService = host?.std.getOptional(UserProvider);
const memberId = useSignalValue(props.cell.value$);
if (!memberId) {
@@ -83,16 +83,6 @@ const CreatedByCellComponent: ForwardRefRenderFunction<
);
};
const useMemberInfo = (
id: string,
userService: UserService | null | undefined
) => {
useEffect(() => {
userService?.revalidateUserInfo(id);
}, [id, userService]);
return useSignalValue(userService?.userInfo$(id));
};
const MemberPreview = ({
memberId,
userService,
@@ -10,7 +10,7 @@ import {
type CellRenderProps,
createIcon,
type DataViewCellLifeCycle,
HostContextKey,
EditorHostKey,
} from '@blocksuite/affine/blocks/database';
import { openFileOrFiles } from '@blocksuite/affine/shared/utils';
import type { BlobEngine } from '@blocksuite/affine/sync';
@@ -226,8 +226,8 @@ class FileCellManager {
this.cell = props.cell;
this.selectCurrentCell = props.selectCurrentCell;
this.isEditing = props.isEditing$;
this.blobSync = this.cell?.view?.contextGet
? this.cell.view.contextGet(HostContextKey)?.store.blobSync
this.blobSync = this.cell?.view?.serviceGet
? this.cell.view.serviceGet(EditorHostKey)?.store.blobSync
: undefined;
this.fileUploadManager = this.blobSync
@@ -1,4 +1,12 @@
import { propertyType, t } from '@blocksuite/affine/blocks/database';
import {
EditorHostKey,
propertyType,
t,
} from '@blocksuite/affine/blocks/database';
import {
UserListProvider,
UserProvider,
} from '@blocksuite/affine/shared/services';
import zod from 'zod';
export const memberColumnType = propertyType('member');
@@ -31,7 +39,21 @@ export const memberPropertyModelConfig = memberColumnType.modelConfig({
},
jsonValue: {
schema: MemberCellJsonValueTypeSchema,
type: () => t.array.instance(t.string.instance()),
type: ({ dataSource }) => {
const host = dataSource.serviceGet(EditorHostKey);
const userService = host?.std.getOptional(UserProvider);
const userListService = host?.std.getOptional(UserListProvider);
return t.array.instance(
t.user.instance(
userListService && userService
? {
userService: userService,
userListService: userListService,
}
: undefined
)
);
},
isEmpty: ({ value }) => value.length === 0,
},
});
@@ -16,6 +16,7 @@ import {
import { useSignalValue } from '../../../../../modules/doc-info/utils';
import { Spinner } from '../../../components/loading';
import { useMemberInfo } from '../../../hooks/use-member-info';
import * as styles from './style.css';
type BaseOptions = {
@@ -172,13 +173,6 @@ class MemberManager {
};
}
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;
@@ -225,7 +219,7 @@ export const MemberPreview = ({
memberManager: MemberManager;
onDelete?: () => void;
}) => {
const userInfo = useMemberInfo(memberId, memberManager);
const userInfo = useMemberInfo(memberId, memberManager.userService);
if (!userInfo) {
return null;
}
@@ -1,4 +1,3 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const memberPopoverContainer = style({
@@ -10,45 +9,10 @@ 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',
@@ -70,21 +34,6 @@ 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',
@@ -4,7 +4,7 @@ import {
type CellRenderProps,
createIcon,
type DataViewCellLifeCycle,
HostContextKey,
EditorHostKey,
} from '@blocksuite/affine/blocks/database';
import {
UserListProvider,
@@ -17,12 +17,12 @@ import {
forwardRef,
type ForwardRefRenderFunction,
type ReactNode,
useEffect,
useImperativeHandle,
useMemo,
} from 'react';
import { useSignalValue } from '../../../../modules/doc-info/utils';
import { useMemberInfo } from '../../hooks/use-member-info';
import type {
MemberCellJsonValueType,
MemberCellRawValueType,
@@ -55,7 +55,7 @@ class MemberManager {
this.cell = props.cell;
this.selectCurrentCell = props.selectCurrentCell;
this.isEditing = props.isEditing$;
const host = this.cell.view.contextGet(HostContextKey);
const host = this.cell.view.serviceGet(EditorHostKey);
this.userService = host?.std.getOptional(UserProvider);
this.userListService = host?.std.getOptional(UserListProvider);
}
@@ -140,13 +140,6 @@ const MemberCellComponent: ForwardRefRenderFunction<
);
};
const useMemberInfo = (id: string, memberManager: MemberManager) => {
useEffect(() => {
memberManager.userService?.revalidateUserInfo(id);
}, [id, memberManager.userService]);
return useSignalValue(memberManager.userService?.userInfo$(id));
};
const MemberPreview = ({
memberId,
memberManager,
@@ -154,7 +147,7 @@ const MemberPreview = ({
memberId: string;
memberManager: MemberManager;
}) => {
const userInfo = useMemberInfo(memberId, memberManager);
const userInfo = useMemberInfo(memberId, memberManager.userService);
if (!userInfo) {
return null;
}
@@ -10,12 +10,6 @@ import { AiSlashMenuConfigExtension } from '@affine/core/blocksuite/ai/extension
import { CopilotTool } from '@affine/core/blocksuite/ai/tool/copilot-tool';
import { aiPanelWidget } from '@affine/core/blocksuite/ai/widgets/ai-panel/ai-panel';
import { edgelessCopilotWidget } from '@affine/core/blocksuite/ai/widgets/edgeless-copilot';
import { buildDocDisplayMetaExtension } from '@affine/core/blocksuite/extensions/display-meta';
import { patchFileSizeLimitExtension } from '@affine/core/blocksuite/extensions/file-size-limit';
import { getFontConfigExtension } from '@affine/core/blocksuite/extensions/font-config';
import { patchPeekViewService } from '@affine/core/blocksuite/extensions/peek-view-service';
import { getTelemetryExtension } from '@affine/core/blocksuite/extensions/telemetry';
import { PeekViewService } from '@affine/core/modules/peek-view';
import {
type ViewExtensionContext,
ViewExtensionProvider,
@@ -25,31 +19,37 @@ import { BlockFlavourIdentifier } from '@blocksuite/affine/std';
import { FrameworkProvider } from '@toeverything/infra';
import { z } from 'zod';
import { EdgelessClipboardAIChatConfig } from './edgeless-clipboard';
const optionsSchema = z.object({
enableAI: z.boolean().optional(),
enable: z.boolean().optional(),
framework: z.instanceof(FrameworkProvider).optional(),
});
export class AffineCommonViewExtension extends ViewExtensionProvider<
z.infer<typeof optionsSchema>
> {
override name = 'affine-view-extensions';
type AIViewOptions = z.infer<typeof optionsSchema>;
export class AIViewExtension extends ViewExtensionProvider<AIViewOptions> {
override name = 'affine-ai-view-extension';
override schema = optionsSchema;
private _setupAI(
context: ViewExtensionContext,
framework: FrameworkProvider
) {
context.register(AIChatBlockSpec);
context.register(AITranscriptionBlockSpec);
context.register([
AICodeBlockWatcher,
ToolbarModuleExtension({
id: BlockFlavourIdentifier('custom:affine:image'),
config: imageToolbarAIEntryConfig(),
}),
]);
override setup(context: ViewExtensionContext, options?: AIViewOptions) {
super.setup(context, options);
if (!options?.enable) return;
const framework = options.framework;
if (!framework) return;
context
.register(AIChatBlockSpec)
.register(AITranscriptionBlockSpec)
.register(EdgelessClipboardAIChatConfig)
.register(AICodeBlockWatcher)
.register(
ToolbarModuleExtension({
id: BlockFlavourIdentifier('custom:affine:image'),
config: imageToolbarAIEntryConfig(),
})
);
if (context.scope === 'edgeless' || context.scope === 'page') {
context.register([
aiPanelWidget,
@@ -76,26 +76,4 @@ export class AffineCommonViewExtension extends ViewExtensionProvider<
context.register(getAIPageRootWatcher(framework));
}
}
override setup(
context: ViewExtensionContext,
options?: z.infer<typeof optionsSchema>
) {
super.setup(context, options);
const { framework, enableAI } = options || {};
if (framework) {
context.register(patchPeekViewService(framework.get(PeekViewService)));
context.register([
getFontConfigExtension(),
buildDocDisplayMetaExtension(framework),
]);
context.register(getTelemetryExtension());
if (context.scope === 'edgeless' || context.scope === 'page') {
context.register(patchFileSizeLimitExtension(framework));
}
if (enableAI) {
this._setupAI(context, framework);
}
}
}
}
@@ -6,6 +6,10 @@ import type { ExtensionType } from '@blocksuite/store';
export function patchForAudioEmbedView(reactToLit: ReactToLit): ExtensionType {
return {
setup: di => {
// do not show audio block on mobile
if (BUILD_CONFIG.isMobileEdition) {
return;
}
di.override(AttachmentEmbedConfigIdentifier('audio'), () => ({
name: 'audio',
check: (model, maxFileSize) =>
@@ -1,12 +1,20 @@
import { DatabaseBlockDataSource } from '@blocksuite/affine/blocks/database';
import {
DatabaseBlockDataSource,
ExternalGroupByConfigProvider,
} from '@blocksuite/affine/blocks/database';
import type { ExtensionType } from '@blocksuite/affine/store';
import { groupByConfigList } from '../database-block/group-by';
import { propertiesPresets } from '../database-block/properties';
export function patchDatabaseBlockConfigService(): ExtensionType {
//TODO use service
DatabaseBlockDataSource.externalProperties.value = propertiesPresets;
return {
setup: () => {},
setup: di => {
groupByConfigList.forEach(config => {
di.addValue(ExternalGroupByConfigProvider(config.name), config);
});
},
};
}
@@ -0,0 +1,33 @@
import {
type ViewExtensionContext,
ViewExtensionProvider,
} from '@blocksuite/affine/ext-loader';
import { FrameworkProvider } from '@toeverything/infra';
import { z } from 'zod';
import { patchForClipboardInElectron } from './electron-clipboard';
const optionsSchema = z.object({
framework: z.instanceof(FrameworkProvider).optional(),
});
type ElectronViewExtensionOptions = z.infer<typeof optionsSchema>;
export class ElectronViewExtension extends ViewExtensionProvider<ElectronViewExtensionOptions> {
override name = 'electron-view-extensions';
override schema = optionsSchema;
override setup(
context: ViewExtensionContext,
options?: ElectronViewExtensionOptions
) {
super.setup(context, options);
if (!BUILD_CONFIG.isElectron) return;
const framework = options?.framework;
if (!framework) return;
context.register(patchForClipboardInElectron(framework));
}
}
@@ -1,13 +0,0 @@
import {
AffineCanvasTextFonts,
FontConfigExtension,
} from '@blocksuite/affine/shared/services';
export function getFontConfigExtension() {
return FontConfigExtension(
AffineCanvasTextFonts.map(font => ({
...font,
url: environment.publicPath + 'fonts/' + font.url.split('/').pop(),
}))
);
}
@@ -0,0 +1,32 @@
import {
type ViewExtensionContext,
ViewExtensionProvider,
} from '@blocksuite/affine/ext-loader';
import { FrameworkProvider } from '@toeverything/infra';
import { z } from 'zod';
import { patchLinkPreviewService } from './link-preview-service';
const optionsSchema = z.object({
framework: z.instanceof(FrameworkProvider).optional(),
});
type AffineLinkPreviewViewOptions = z.infer<typeof optionsSchema>;
export class AffineLinkPreviewExtension extends ViewExtensionProvider<AffineLinkPreviewViewOptions> {
override name = 'affine-link-preview-extension';
override schema = optionsSchema;
override setup(
context: ViewExtensionContext,
options?: AffineLinkPreviewViewOptions
) {
super.setup(context, options);
if (!options?.framework) {
return;
}
const { framework } = options;
context.register(patchLinkPreviewService(framework));
}
}
@@ -0,0 +1,61 @@
import { DEFAULT_LINK_PREVIEW_ENDPOINT } from '@blocksuite/affine/shared/consts';
import {
LinkPreviewCacheIdentifier,
type LinkPreviewCacheProvider,
LinkPreviewService,
LinkPreviewServiceIdentifier,
} from '@blocksuite/affine/shared/services';
import { type BlockStdScope, StdIdentifier } from '@blocksuite/affine/std';
import { type ExtensionType } from '@blocksuite/affine/store';
import type { Container } from '@blocksuite/global/di';
import type { FrameworkProvider } from '@toeverything/infra';
import { ServerService } from '../../../modules/cloud/services/server';
class AffineLinkPreviewService extends LinkPreviewService {
constructor(
endpoint: string,
std: BlockStdScope,
cache: LinkPreviewCacheProvider
) {
super(std, cache);
this.setEndpoint(endpoint);
}
}
/**
* Patch the link preview service, set the endpoint and cache
* @param framework
* @returns
*/
export function patchLinkPreviewService(
framework: FrameworkProvider
): ExtensionType {
// get link preview service endpoint from server and BUILD_CONFIG
let linkPreviewUrl: string;
try {
const server = framework.get(ServerService).server;
linkPreviewUrl = new URL(
BUILD_CONFIG.linkPreviewUrl || '/',
server.baseUrl
).toString();
} catch (err) {
console.error(
'Invalid BUILD_CONFIG.linkPreviewUrl, falling back to default',
err
);
linkPreviewUrl = DEFAULT_LINK_PREVIEW_ENDPOINT;
}
return {
setup: (di: Container) => {
di.override(LinkPreviewServiceIdentifier, provider => {
return new AffineLinkPreviewService(
linkPreviewUrl,
provider.get(StdIdentifier),
provider.get(LinkPreviewCacheIdentifier)
);
});
},
};
}
@@ -0,0 +1,33 @@
import { KeyboardToolbarExtension } from '@affine/core/blocksuite/extensions/mobile/keyboard-toolbar-extension';
import { MobileFeatureFlagControl } from '@affine/core/blocksuite/extensions/mobile/mobile-feature-flag-control';
import {
type ViewExtensionContext,
ViewExtensionProvider,
} from '@blocksuite/affine/ext-loader';
import { FrameworkProvider } from '@toeverything/infra';
import { z } from 'zod';
const optionsSchema = z.object({
framework: z.instanceof(FrameworkProvider).optional(),
});
type MobileViewOptions = z.infer<typeof optionsSchema>;
export class MobileViewExtension extends ViewExtensionProvider<MobileViewOptions> {
override name = 'mobile-view-extension';
override schema = optionsSchema;
override setup(context: ViewExtensionContext, options?: MobileViewOptions) {
super.setup(context, options);
const isMobile = BUILD_CONFIG.isMobileEdition;
if (!isMobile) return;
const framework = options?.framework;
if (framework) {
context.register(KeyboardToolbarExtension(framework));
}
context.register(MobileFeatureFlagControl);
}
}
@@ -1,50 +1,15 @@
import { VirtualKeyboardProvider } from '@affine/core/mobile/modules/virtual-keyboard';
import { CodeBlockConfigExtension } from '@blocksuite/affine/blocks/code';
import { ParagraphBlockConfigExtension } from '@blocksuite/affine/blocks/paragraph';
import type { Container } from '@blocksuite/affine/global/di';
import { DisposableGroup } from '@blocksuite/affine/global/disposable';
import {
FeatureFlagService,
VirtualKeyboardProvider as BSVirtualKeyboardProvider,
type VirtualKeyboardProviderWithAction,
} from '@blocksuite/affine/shared/services';
import { type BlockStdScope, LifeCycleWatcher } from '@blocksuite/affine/std';
import { LifeCycleWatcher } from '@blocksuite/affine/std';
import type { ExtensionType } from '@blocksuite/affine/store';
import { batch, signal } from '@preact/signals-core';
import type { FrameworkProvider } from '@toeverything/infra';
export class MobileSpecsPatches extends LifeCycleWatcher {
static override key = 'mobile-patches';
constructor(std: BlockStdScope) {
super(std);
const featureFlagService = std.get(FeatureFlagService);
featureFlagService.setFlag('enable_mobile_keyboard_toolbar', true);
featureFlagService.setFlag('enable_mobile_linked_doc_menu', true);
}
}
export const mobileParagraphConfig = ParagraphBlockConfigExtension({
getPlaceholder: model => {
const placeholders = {
text: '',
h1: 'Heading 1',
h2: 'Heading 2',
h3: 'Heading 3',
h4: 'Heading 4',
h5: 'Heading 5',
h6: 'Heading 6',
quote: '',
};
return placeholders[model.props.type];
},
});
export const mobileCodeConfig = CodeBlockConfigExtension({
showLineNumbers: false,
});
export function KeyboardToolbarExtension(
framework: FrameworkProvider
): ExtensionType {
@@ -89,6 +54,7 @@ export function KeyboardToolbarExtension(
if ('show' in affineVirtualKeyboardProvider) {
const providerWithAction = affineVirtualKeyboardProvider;
class BSVirtualKeyboardServiceWithShowAndHide
extends BSVirtualKeyboardService
implements VirtualKeyboardProviderWithAction
@@ -96,6 +62,7 @@ export function KeyboardToolbarExtension(
show() {
providerWithAction.show();
}
hide() {
providerWithAction.hide();
}
@@ -0,0 +1,14 @@
import { FeatureFlagService } from '@blocksuite/affine/shared/services';
import { type BlockStdScope, LifeCycleWatcher } from '@blocksuite/affine/std';
export class MobileFeatureFlagControl extends LifeCycleWatcher {
static override key = 'mobile-patches';
constructor(std: BlockStdScope) {
super(std);
const featureFlagService = std.get(FeatureFlagService);
featureFlagService.setFlag('enable_mobile_keyboard_toolbar', true);
featureFlagService.setFlag('enable_mobile_linked_doc_menu', true);
}
}
@@ -1,36 +0,0 @@
import type { PeekViewService } from '@affine/core/modules/peek-view';
import { DebugLogger } from '@affine/debug';
import type {
PeekOptions,
PeekViewService as BSPeekViewService,
} from '@blocksuite/affine/components/peek';
import { PeekViewExtension } from '@blocksuite/affine/components/peek';
import type { TemplateResult } from 'lit';
const logger = new DebugLogger('affine::patch-peek-view-service');
export function patchPeekViewService(service: PeekViewService) {
return PeekViewExtension({
peek: (
element: {
target: HTMLElement;
docId: string;
blockIds?: string[];
template?: TemplateResult;
},
options?: PeekOptions
) => {
logger.debug('center peek', element);
const { template, target, ...props } = element;
return service.peekView.open(
{
element: target,
docRef: props,
},
template,
options?.abortSignal
);
},
} satisfies BSPeekViewService);
}
@@ -1,21 +0,0 @@
import { mixpanel } from '@affine/track';
import {
type TelemetryEventMap,
TelemetryProvider,
} from '@blocksuite/affine/shared/services';
import type { ExtensionType } from '@blocksuite/affine/store';
export function getTelemetryExtension(): ExtensionType {
return {
setup: di => {
di.addImpl(TelemetryProvider, () => ({
track: <T extends keyof TelemetryEventMap>(
eventName: T,
props: TelemetryEventMap[T]
) => {
mixpanel.track(eventName as string, props as Record<string, unknown>);
},
}));
},
};
}
@@ -1,10 +1,10 @@
import type { ConfirmModalProps, ElementOrFactory } from '@affine/component';
import { patchForAudioEmbedView } from '@affine/core/blocksuite/extensions/audio/audio-view';
import { patchDatabaseBlockConfigService } from '@affine/core/blocksuite/extensions/database-block-config-service';
import { buildDocDisplayMetaExtension } from '@affine/core/blocksuite/extensions/display-meta';
import { patchDocModeService } from '@affine/core/blocksuite/extensions/doc-mode-service';
import { patchDocUrlExtensions } from '@affine/core/blocksuite/extensions/doc-url';
import { EdgelessClipboardAIChatConfig } from '@affine/core/blocksuite/extensions/edgeless-clipboard';
import { patchForClipboardInElectron } from '@affine/core/blocksuite/extensions/electron-clipboard';
import { patchFileSizeLimitExtension } from '@affine/core/blocksuite/extensions/file-size-limit';
import { patchNotificationService } from '@affine/core/blocksuite/extensions/notification-service';
import { patchOpenDocExtension } from '@affine/core/blocksuite/extensions/open-doc';
import { patchQuickSearchService } from '@affine/core/blocksuite/extensions/quick-search-service';
@@ -29,13 +29,6 @@ import { FrameworkProvider } from '@toeverything/infra';
import type { TemplateResult } from 'lit';
import { z } from 'zod';
import {
KeyboardToolbarExtension,
mobileCodeConfig,
mobileParagraphConfig,
MobileSpecsPatches,
} from '../extensions/mobile-config';
const optionsSchema = z.object({
// services
framework: z.instanceof(FrameworkProvider),
@@ -106,8 +99,6 @@ export class AffineEditorViewExtension extends ViewExtensionProvider<AffineEdito
reactToLit,
confirmModal,
} = options;
const isMobileEdition = BUILD_CONFIG.isMobileEdition;
const isElectron = BUILD_CONFIG.isElectron;
const docService = framework.get(DocService);
const docsService = framework.get(DocsService);
@@ -115,30 +106,21 @@ export class AffineEditorViewExtension extends ViewExtensionProvider<AffineEdito
const referenceRenderer = this._getCustomReferenceRenderer(framework);
context.register([
patchReferenceRenderer(reactToLit, referenceRenderer),
patchNotificationService(confirmModal),
patchOpenDocExtension(),
EdgelessClipboardAIChatConfig,
patchSideBarService(framework),
patchDocModeService(docService, docsService, editorService),
]);
context.register(patchDocUrlExtensions(framework));
context.register(patchQuickSearchService(framework));
context.register([
patchDatabaseBlockConfigService(),
patchForAudioEmbedView(reactToLit),
]);
if (isMobileEdition) {
context.register([
KeyboardToolbarExtension(framework),
MobileSpecsPatches,
mobileParagraphConfig,
mobileCodeConfig,
context
.register([
patchReferenceRenderer(reactToLit, referenceRenderer),
patchNotificationService(confirmModal),
patchOpenDocExtension(),
patchSideBarService(framework),
patchDocModeService(docService, docsService, editorService),
patchFileSizeLimitExtension(framework),
buildDocDisplayMetaExtension(framework),
])
.register(patchDocUrlExtensions(framework))
.register(patchQuickSearchService(framework))
.register([
patchDatabaseBlockConfigService(),
patchForAudioEmbedView(reactToLit),
]);
}
if (isElectron) {
context.register(patchForClipboardInElectron(framework));
}
}
}
@@ -22,7 +22,9 @@ class MigratingAffineStoreExtension extends StoreExtensionProvider {
interface Configure {
init: () => Configure;
featureFlag: (featureFlagService?: FeatureFlagService) => Configure;
value: StoreExtensionManager;
}
@@ -1,4 +1,5 @@
import type { ReactToLit } from '@affine/component';
import { AIViewExtension } from '@affine/core/blocksuite/extensions/ai';
import { CloudViewExtension } from '@affine/core/blocksuite/extensions/cloud';
import {
EdgelessBlockHeaderConfigViewExtension,
@@ -7,23 +8,59 @@ import {
import { AffineEditorConfigViewExtension } from '@affine/core/blocksuite/extensions/editor-config';
import { createDatabaseOptionsConfig } from '@affine/core/blocksuite/extensions/editor-config/database';
import { createLinkedWidgetConfig } from '@affine/core/blocksuite/extensions/editor-config/linked';
import { ElectronViewExtension } from '@affine/core/blocksuite/extensions/electron';
import { AffineLinkPreviewExtension } from '@affine/core/blocksuite/extensions/link-preview-service';
import { MobileViewExtension } from '@affine/core/blocksuite/extensions/mobile';
import { PdfViewExtension } from '@affine/core/blocksuite/extensions/pdf';
import { AffineThemeViewExtension } from '@affine/core/blocksuite/extensions/theme';
import { TurboRendererViewExtension } from '@affine/core/blocksuite/extensions/turbo-renderer';
import { AffineCommonViewExtension } from '@affine/core/blocksuite/manager/common-view';
import {
AffineEditorViewExtension,
type AffineEditorViewOptions,
} from '@affine/core/blocksuite/manager/editor-view';
import { PeekViewService } from '@affine/core/modules/peek-view';
import { DebugLogger } from '@affine/debug';
import { mixpanel } from '@affine/track';
import { DatabaseViewExtension } from '@blocksuite/affine/blocks/database/view';
import { ParagraphViewExtension } from '@blocksuite/affine/blocks/paragraph/view';
import type {
PeekOptions,
PeekViewService as BSPeekViewService,
} from '@blocksuite/affine/components/peek';
import { ViewExtensionManager } from '@blocksuite/affine/ext-loader';
import { getInternalViewExtensions } from '@blocksuite/affine/extensions/view';
import { FoundationViewExtension } from '@blocksuite/affine/foundation/view';
import { AffineCanvasTextFonts } from '@blocksuite/affine/shared/services';
import { LinkedDocViewExtension } from '@blocksuite/affine/widgets/linked-doc/view';
import type { FrameworkProvider } from '@toeverything/infra';
import type { TemplateResult } from 'lit';
import { CodeBlockPreviewViewExtension } from './code-block-preview';
type Configure = {
init: () => Configure;
foundation: (framework?: FrameworkProvider) => Configure;
editorView: (options?: AffineEditorViewOptions) => Configure;
theme: (framework?: FrameworkProvider) => Configure;
editorConfig: (framework?: FrameworkProvider) => Configure;
edgelessBlockHeader: (options?: EdgelessBlockHeaderViewOptions) => Configure;
database: (framework?: FrameworkProvider) => Configure;
linkedDoc: (framework?: FrameworkProvider) => Configure;
paragraph: (enableAI?: boolean) => Configure;
cloud: (framework?: FrameworkProvider, enableCloud?: boolean) => Configure;
turboRenderer: (enableTurboRenderer?: boolean) => Configure;
pdf: (enablePDFEmbedPreview?: boolean, reactToLit?: ReactToLit) => Configure;
mobile: (framework?: FrameworkProvider) => Configure;
ai: (enable?: boolean, framework?: FrameworkProvider) => Configure;
electron: (framework?: FrameworkProvider) => Configure;
linkPreview: (framework?: FrameworkProvider) => Configure;
value: ViewExtensionManager;
};
const peekViewLogger = new DebugLogger('affine::patch-peek-view-service');
class ViewProvider {
static instance: ViewProvider | null = null;
static getInstance() {
@@ -40,7 +77,6 @@ class ViewProvider {
...getInternalViewExtensions(),
AffineThemeViewExtension,
AffineCommonViewExtension,
AffineEditorViewExtension,
AffineEditorConfigViewExtension,
CodeBlockPreviewViewExtension,
@@ -48,6 +84,10 @@ class ViewProvider {
TurboRendererViewExtension,
CloudViewExtension,
PdfViewExtension,
MobileViewExtension,
AIViewExtension,
ElectronViewExtension,
AffineLinkPreviewExtension,
]);
}
@@ -55,10 +95,10 @@ class ViewProvider {
return this._manager;
}
get config() {
get config(): Configure {
return {
init: this._initDefaultConfig,
common: this._configureCommon,
foundation: this._configureFoundation,
editorView: this._configureEditorView,
theme: this._configureTheme,
editorConfig: this._configureEditorConfig,
@@ -69,13 +109,17 @@ class ViewProvider {
cloud: this._configureCloud,
turboRenderer: this._configureTurboRenderer,
pdf: this._configurePdf,
mobile: this._configureMobile,
ai: this._configureAI,
electron: this._configureElectron,
linkPreview: this._configureLinkPreview,
value: this._manager,
};
}
private readonly _initDefaultConfig = () => {
this.config
.common()
.foundation()
.theme()
.editorView()
.editorConfig()
@@ -85,19 +129,55 @@ class ViewProvider {
.paragraph()
.cloud()
.turboRenderer()
.pdf();
.pdf()
.mobile()
.ai()
.electron()
.linkPreview();
return this.config;
};
private readonly _configureCommon = (
framework?: FrameworkProvider,
enableAI?: boolean
) => {
this._manager.configure(AffineCommonViewExtension, {
framework,
enableAI,
private readonly _configureFoundation = (framework?: FrameworkProvider) => {
const peekViewService = framework?.get(PeekViewService);
this._manager.configure(FoundationViewExtension, {
telemetry: {
track: (eventName, props) => {
mixpanel.track(eventName, props);
},
},
fontConfig: AffineCanvasTextFonts.map(font => ({
...font,
url: environment.publicPath + 'fonts/' + font.url.split('/').pop(),
})),
peekView: !peekViewService
? undefined
: ({
peek: (
element: {
target: HTMLElement;
docId: string;
blockIds?: string[];
template?: TemplateResult;
},
options?: PeekOptions
) => {
peekViewLogger.debug('center peek', element);
const { template, target, ...props } = element;
return peekViewService.peekView.open(
{
element: target,
docRef: props,
},
template,
options?.abortSignal
);
},
} satisfies BSPeekViewService),
});
return this.config;
};
@@ -146,7 +226,23 @@ class ViewProvider {
};
private readonly _configureParagraph = (enableAI?: boolean) => {
if (enableAI) {
if (BUILD_CONFIG.isMobileEdition) {
this._manager.configure(ParagraphViewExtension, {
getPlaceholder: model => {
const placeholders = {
text: '',
h1: 'Heading 1',
h2: 'Heading 2',
h3: 'Heading 3',
h4: 'Heading 4',
h5: 'Heading 5',
h6: 'Heading 6',
quote: '',
};
return placeholders[model.props.type] ?? '';
},
});
} else if (enableAI) {
this._manager.configure(ParagraphViewExtension, {
getPlaceholder: model => {
const placeholders = {
@@ -193,6 +289,29 @@ class ViewProvider {
});
return this.config;
};
private readonly _configureMobile = (framework?: FrameworkProvider) => {
this._manager.configure(MobileViewExtension, { framework });
return this.config;
};
private readonly _configureAI = (
enable?: boolean,
framework?: FrameworkProvider
) => {
this._manager.configure(AIViewExtension, { framework, enable });
return this.config;
};
private readonly _configureElectron = (framework?: FrameworkProvider) => {
this._manager.configure(ElectronViewExtension, { framework });
return this.config;
};
private readonly _configureLinkPreview = (framework?: FrameworkProvider) => {
this._manager.configure(AffineLinkPreviewExtension, { framework });
return this.config;
};
}
export function getViewManager() {
@@ -1,3 +1,4 @@
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { WorkspaceImpl } from '@affine/core/modules/workspace/impls/workspace';
import type { ServiceProvider } from '@blocksuite/affine/global/di';
import {
@@ -161,11 +162,13 @@ export async function markDownToDoc(
provider: ServiceProvider,
schema: Schema,
answer: string,
middlewares?: TransformerMiddleware[]
middlewares?: TransformerMiddleware[],
affineFeatureFlagService?: FeatureFlagService
) {
// Should not create a new doc in the original collection
const collection = new WorkspaceImpl({
rootDoc: new YDoc({ guid: 'markdownToDoc' }),
featureFlagService: affineFeatureFlagService,
});
collection.meta.initialize();
const transformer = new Transformer({
@@ -1,5 +1,5 @@
import type { Collection } from '@affine/core/modules/collection';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import type { Collection } from '@affine/env/filter';
import { useI18n } from '@affine/i18n';
import { AllDocsIcon, FilterIcon } from '@blocksuite/icons/rc';
import { useService } from '@toeverything/infra';
@@ -5,10 +5,8 @@ import { WorkspaceService } from '@affine/core/modules/workspace';
import { useI18n } from '@affine/i18n';
import { ViewLayersIcon } from '@blocksuite/icons/rc';
import { useService } from '@toeverything/infra';
import { nanoid } from 'nanoid';
import { useCallback } from 'react';
import { createEmptyCollection } from '../../page-list';
import { ActionButton } from './action-button';
import collectionListDark from './assets/collection-list.dark.png';
import collectionListLight from './assets/collection-list.light.png';
@@ -39,8 +37,7 @@ export const EmptyCollections = (props: UniversalEmptyProps) => {
variant: 'primary',
},
onConfirm(name) {
const id = nanoid();
collectionService.addCollection(createEmptyCollection(id, { name }));
const id = collectionService.createCollection({ name });
navigateHelper.jumpToCollection(currentWorkspace.id, id);
},
});
@@ -2,7 +2,7 @@ import { IconButton, Menu, MenuItem, MenuSeparator } from '@affine/component';
import type { FilterParams } from '@affine/core/modules/collection-rules';
import { WorkspacePropertyService } from '@affine/core/modules/workspace-property';
import { useI18n } from '@affine/i18n';
import { PlusIcon } from '@blocksuite/icons/rc';
import { FavoriteIcon, PlusIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { WorkspacePropertyIcon, WorkspacePropertyName } from '../properties';
@@ -24,6 +24,36 @@ export const AddFilterMenu = ({
{t['com.affine.filter']()}
</div>
<MenuSeparator />
<MenuItem
prefixIcon={<FavoriteIcon className={styles.filterTypeItemIcon} />}
key={'favorite'}
onClick={() => {
onAdd({
type: 'system',
key: 'favorite',
method: 'is',
value: 'true',
});
}}
>
<span className={styles.filterTypeItemName}>{t['Favorited']()}</span>
</MenuItem>
<MenuItem
prefixIcon={<FavoriteIcon className={styles.filterTypeItemIcon} />}
key={'shared'}
onClick={() => {
onAdd({
type: 'system',
key: 'shared',
method: 'is',
value: 'true',
});
}}
>
<span className={styles.filterTypeItemName}>
{t['com.affine.filter.is-public']()}
</span>
</MenuItem>
{workspaceProperties.map(property => {
const type = WorkspacePropertyTypes[property.type];
const defaultFilter = type?.defaultFilter;
@@ -81,13 +81,13 @@ export function useAIChatConfig() {
return tag$.value?.pageIds$.value ?? [];
},
getCollections: () => {
const collections$ = collectionService.collections$;
return createSignalFromObservable(collections$, []);
const collectionMetas$ = collectionService.collectionMetas$;
return createSignalFromObservable(collectionMetas$, []);
},
getCollectionPageIds: (collectionId: string) => {
const collection$ = collectionService.collection$(collectionId);
// TODO: lack of documents that meet the collection rules
return collection$?.value?.allowList ?? [];
return collection$?.value?.info$.value.allowList ?? [];
},
};
@@ -1,15 +0,0 @@
import { AuthService } from '@affine/core/modules/cloud';
import type { DeleteCollectionInfo } from '@affine/env/filter';
import { useLiveData, useService } from '@toeverything/infra';
import { useMemo } from 'react';
export const useDeleteCollectionInfo = () => {
const authService = useService(AuthService);
const user = useLiveData(authService.session.account$);
return useMemo<DeleteCollectionInfo | null>(
() => (user ? { userName: user.label, userId: user.id } : null),
[user]
);
};
@@ -1,201 +0,0 @@
/**
* @vitest-environment happy-dom
*/
import 'fake-indexeddb/auto';
import type {
Filter,
LiteralValue,
PropertiesMeta,
Ref,
VariableMap,
} from '@affine/env/filter';
import { getOrCreateI18n, I18nextProvider } from '@affine/i18n';
import { render } from '@testing-library/react';
import type { ReactElement } from 'react';
import { useState } from 'react';
import { describe, expect, test } from 'vitest';
import { Condition } from '../filter/condition';
import { tBoolean, tDate } from '../filter/logical/custom-type';
import { toLiteral } from '../filter/shared-types';
import type { FilterMatcherDataType } from '../filter/vars';
import { filterMatcher } from '../filter/vars';
import { filterByFilterList } from '../use-collection-manager';
const ref = (name: keyof VariableMap): Ref => {
return {
type: 'ref',
name,
};
};
const mockVariableMap = (vars: Partial<VariableMap>): VariableMap => {
return {
Created: 0,
Updated: 0,
'Is Favourited': false,
'Is Public': false,
Tags: [],
...vars,
};
};
const mockPropertiesMeta = (meta: Partial<PropertiesMeta>): PropertiesMeta => {
return {
tags: {
options: [],
},
...meta,
};
};
const filter = (
matcherData: FilterMatcherDataType,
left: Ref,
args: LiteralValue[]
): Filter => {
return {
type: 'filter',
left,
funcName: matcherData.name,
args: args.map(toLiteral),
};
};
describe('match filter', () => {
test('boolean variable will match `is` filter', () => {
const is = filterMatcher
.allMatchedData(tBoolean.create())
.find(v => v.name === 'is');
expect(is?.name).toBe('is');
});
test('Date variable will match `before` filter', () => {
const before = filterMatcher
.allMatchedData(tDate.create())
.find(v => v.name === 'before');
expect(before?.name).toBe('before');
});
});
describe('eval filter', () => {
test('before', async () => {
const before = filterMatcher.findData(v => v.name === 'before');
if (!before) {
throw new Error('before is not found');
}
const filter1 = filter(before, ref('Created'), [
new Date(2023, 5, 28).getTime(),
]);
const filter2 = filter(before, ref('Created'), [
new Date(2023, 5, 30).getTime(),
]);
const filter3 = filter(before, ref('Created'), [
new Date(2023, 5, 29).getTime(),
]);
const varMap = mockVariableMap({
Created: new Date(2023, 5, 29).getTime(),
});
expect(filterByFilterList([filter1], varMap)).toBe(false);
expect(filterByFilterList([filter2], varMap)).toBe(true);
expect(filterByFilterList([filter3], varMap)).toBe(false);
});
test('after', async () => {
const after = filterMatcher.findData(v => v.name === 'after');
if (!after) {
throw new Error('after is not found');
}
const filter1 = filter(after, ref('Created'), [
new Date(2023, 5, 28).getTime(),
]);
const filter2 = filter(after, ref('Created'), [
new Date(2023, 5, 30).getTime(),
]);
const filter3 = filter(after, ref('Created'), [
new Date(2023, 5, 29).getTime(),
]);
const varMap = mockVariableMap({
Created: new Date(2023, 5, 29).getTime(),
});
expect(filterByFilterList([filter1], varMap)).toBe(true);
expect(filterByFilterList([filter2], varMap)).toBe(false);
expect(filterByFilterList([filter3], varMap)).toBe(false);
});
test('is', async () => {
const is = filterMatcher.findData(v => v.name === 'is');
if (!is) {
throw new Error('is is not found');
}
const filter1 = filter(is, ref('Is Favourited'), [false]);
const filter2 = filter(is, ref('Is Favourited'), [true]);
const varMap = mockVariableMap({
'Is Favourited': true,
});
expect(filterByFilterList([filter1], varMap)).toBe(false);
expect(filterByFilterList([filter2], varMap)).toBe(true);
});
});
describe('render filter', () => {
test('boolean condition value change', async () => {
const is = filterMatcher.match(tBoolean.create());
const i18n = getOrCreateI18n();
if (!is) {
throw new Error('is is not found');
}
const Wrapper = () => {
const [value, onChange] = useState(
filter(is, ref('Is Favourited'), [true])
);
return (
<I18nextProvider i18n={i18n}>
<Condition
propertiesMeta={mockPropertiesMeta({})}
value={value}
onChange={onChange}
/>
</I18nextProvider>
);
};
const result = render(<Wrapper />);
const dom = await result.findByText('true');
dom.click();
await result.findByText('false');
result.unmount();
});
const WrapperCreator = (fn: FilterMatcherDataType) =>
function Wrapper(): ReactElement {
const [value, onChange] = useState(
filter(fn, ref('Created'), [new Date(2023, 5, 29).getTime()])
);
return (
<Condition
propertiesMeta={mockPropertiesMeta({})}
value={value}
onChange={onChange}
/>
);
};
test('date condition function change', async () => {
const dateFunction = filterMatcher.match(tDate.create());
if (!dateFunction) {
throw new Error('dateFunction is not found');
}
const Wrapper = WrapperCreator(dateFunction);
const result = render(<Wrapper />);
const dom = await result.findByTestId('filter-name');
dom.click();
await result.findByTestId('filter-name');
result.unmount();
});
test('date condition variable change', async () => {
const dateFunction = filterMatcher.match(tDate.create());
if (!dateFunction) {
throw new Error('dateFunction is not found');
}
const Wrapper = WrapperCreator(dateFunction);
const result = render(<Wrapper />);
const dom = await result.findByTestId('variable-name');
dom.click();
await result.findByTestId('variable-name');
result.unmount();
});
});
@@ -1,51 +1,25 @@
import { useDeleteCollectionInfo } from '@affine/core/components/hooks/affine/use-delete-collection-info';
import { WorkspaceService } from '@affine/core/modules/workspace';
import type { Collection, DeleteCollectionInfo } from '@affine/env/filter';
import { Trans } from '@affine/i18n';
import { useService } from '@toeverything/infra';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useMemo, useRef, useState } from 'react';
import { CollectionService } from '../../../modules/collection';
import {
type CollectionMeta,
CollectionService,
} from '../../../modules/collection';
import { ListFloatingToolbar } from '../components/list-floating-toolbar';
import { collectionHeaderColsDef } from '../header-col-def';
import { CollectionOperationCell } from '../operation-cell';
import { CollectionListItemRenderer } from '../page-group';
import { ListTableHeader } from '../page-header';
import type { CollectionMeta, ItemListHandle, ListItem } from '../types';
import type { ItemListHandle, ListItem } from '../types';
import { VirtualizedList } from '../virtualized-list';
import { CollectionListHeader } from './collection-list-header';
const useCollectionOperationsRenderer = ({
info,
service,
}: {
info: DeleteCollectionInfo;
service: CollectionService;
}) => {
const collectionOperationsRenderer = useCallback(
(collection: Collection) => {
return (
<CollectionOperationCell
info={info}
collection={collection}
service={service}
/>
);
},
[info, service]
);
return collectionOperationsRenderer;
};
export const VirtualizedCollectionList = ({
collections,
collectionMetas,
setHideHeaderCreateNewCollection,
handleCreateCollection,
}: {
collections: Collection[];
collectionMetas: CollectionMeta[];
handleCreateCollection: () => void;
setHideHeaderCreateNewCollection: (hide: boolean) => void;
}) => {
@@ -55,30 +29,24 @@ export const VirtualizedCollectionList = ({
[]
);
const collectionService = useService(CollectionService);
const collectionMetas = useLiveData(collectionService.collectionMetas$);
const currentWorkspace = useService(WorkspaceService).workspace;
const info = useDeleteCollectionInfo();
const collectionOperations = useCollectionOperationsRenderer({
info,
service: collectionService,
});
const filteredSelectedCollectionIds = useMemo(() => {
const ids = new Set(collections.map(collection => collection.id));
const ids = new Set(collectionMetas.map(collection => collection.id));
return selectedCollectionIds.filter(id => ids.has(id));
}, [collections, selectedCollectionIds]);
}, [collectionMetas, selectedCollectionIds]);
const hideFloatingToolbar = useCallback(() => {
listRef.current?.toggleSelectable();
}, []);
const collectionOperationRenderer = useCallback(
(item: ListItem) => {
const collection = item as CollectionMeta;
return collectionOperations(collection);
},
[collectionOperations]
);
const collectionOperationRenderer = useCallback((item: ListItem) => {
const collection = item;
return (
<CollectionOperationCell collectionMeta={collection as CollectionMeta} />
);
}, []);
const collectionHeaderRenderer = useCallback(() => {
return <ListTableHeader headerCols={collectionHeaderColsDef} />;
@@ -92,9 +60,11 @@ export const VirtualizedCollectionList = ({
if (selectedCollectionIds.length === 0) {
return;
}
collectionService.deleteCollection(info, ...selectedCollectionIds);
for (const collectionId of selectedCollectionIds) {
collectionService.deleteCollection(collectionId);
}
hideFloatingToolbar();
}, [collectionService, hideFloatingToolbar, info, selectedCollectionIds]);
}, [collectionService, hideFloatingToolbar, selectedCollectionIds]);
return (
<>
@@ -14,7 +14,6 @@ import { TagService } from '@affine/core/modules/tag';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { inferOpenMode } from '@affine/core/utils';
import type { Collection } from '@affine/env/filter';
import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';
import type { DocMode } from '@blocksuite/affine/model';
@@ -29,8 +28,10 @@ import { useCallback, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { usePageHelper } from '../../../blocksuite/block-suite-page-list/utils';
import { CollectionService } from '../../../modules/collection';
import { createTagFilter } from '../filter/utils';
import {
type Collection,
CollectionService,
} from '../../../modules/collection';
import { SaveAsCollectionButton } from '../view';
import * as styles from './page-list-header.css';
import { PageListNewPageButton } from './page-list-new-page-button';
@@ -133,11 +134,12 @@ export const CollectionPageListHeader = ({
const workspace = workspaceService.workspace;
const { createEdgeless, createPage } = usePageHelper(workspace.docCollection);
const { openConfirmModal } = useConfirmModal();
const name = useLiveData(collection.name$);
const createAndAddDocument = useCallback(
(createDocumentFn: () => DocRecord) => {
const newDoc = createDocumentFn();
collectionService.addPageToCollection(collection.id, newDoc.id);
collectionService.addDocToCollection(collection.id, newDoc.id);
},
[collection.id, collectionService]
);
@@ -183,7 +185,7 @@ export const CollectionPageListHeader = ({
<div className={styles.titleIcon}>
<ViewLayersIcon />
</div>
<div className={styles.titleCollectionName}>{collection.name}</div>
<div className={styles.titleCollectionName}>{name}</div>
</div>
<div className={styles.rightButtonGroup}>
<Button onClick={handleEdit}>{t['Edit']()}</Button>
@@ -221,12 +223,21 @@ export const TagPageListHeader = ({
}, [jumpToTags, workspaceId]);
const saveToCollection = useCallback(
(collection: Collection) => {
collectionService.addCollection({
...collection,
filterList: [createTagFilter(tag.id)],
(collectionName: string) => {
const id = collectionService.createCollection({
name: collectionName,
rules: {
filters: [
{
type: 'system',
key: 'tags',
method: 'include-all',
value: tag.id,
},
],
},
});
jumpToCollection(workspaceId, collection.id);
jumpToCollection(workspaceId, id);
},
[collectionService, tag.id, jumpToCollection, workspaceId]
);
@@ -1,18 +1,27 @@
import { IconButton, Menu, toast } from '@affine/component';
import { useBlockSuiteDocMeta } from '@affine/core/components/hooks/use-block-suite-page-meta';
import {
CollectionRulesService,
type FilterParams,
} from '@affine/core/modules/collection-rules';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
import { ShareDocsListService } from '@affine/core/modules/share-doc';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { PublicDocMode } from '@affine/graphql';
import { Trans, useI18n } from '@affine/i18n';
import type { DocMeta } from '@blocksuite/affine/store';
import { FilterIcon } from '@blocksuite/icons/rc';
import { useLiveData, useServices } from '@toeverything/infra';
import { type ReactNode, useCallback, useEffect, useState } from 'react';
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { Filters } from '../../filter';
import { AddFilterMenu } from '../../filter/add-filter';
import { AffineShapeIcon, FavoriteTag } from '..';
import { FilterList } from '../filter';
import { VariableSelect } from '../filter/vars';
import { usePageHeaderColsDef } from '../header-col-def';
import { PageListItemRenderer } from '../page-group';
import { ListTableHeader } from '../page-header';
@@ -20,7 +29,6 @@ import { SelectorLayout } from '../selector/selector-layout';
import type { ListItem } from '../types';
import { VirtualizedList } from '../virtualized-list';
import * as styles from './select-page.css';
import { useFilter } from './use-filter';
import { useSearch } from './use-search';
export const SelectPage = ({
@@ -58,12 +66,13 @@ export const SelectPage = ({
workspaceService,
compatibleFavoriteItemsAdapter,
shareDocsListService,
collectionRulesService,
} = useServices({
ShareDocsListService,
WorkspaceService,
CompatibleFavoriteItemsAdapter,
CollectionRulesService,
});
const shareDocs = useLiveData(shareDocsListService.shareDocs?.list$);
const workspace = workspaceService.workspace;
const docCollection = workspace.docCollection;
const pageMetas = useBlockSuiteDocMeta(docCollection);
@@ -73,20 +82,6 @@ export const SelectPage = ({
shareDocsListService.shareDocs?.revalidate();
}, [shareDocsListService.shareDocs]);
const getPublicMode = useCallback(
(id: string) => {
const mode = shareDocs?.find(shareDoc => shareDoc.id === id)?.mode;
if (mode === PublicDocMode.Edgeless) {
return 'edgeless';
} else if (mode === PublicDocMode.Page) {
return 'page';
} else {
return undefined;
}
},
[shareDocs]
);
const isFavorite = useCallback(
(meta: DocMeta) => favourites.some(fav => fav.id === meta.id),
[favourites]
@@ -106,22 +101,41 @@ export const SelectPage = ({
);
const pageHeaderColsDef = usePageHeaderColsDef();
const {
clickFilter,
createFilter,
filters,
showFilter,
updateFilters,
filteredList,
} = useFilter(
pageMetas.map(meta => ({
meta,
publicMode: getPublicMode(meta.id),
favorite: isFavorite(meta),
}))
);
const [filters, setFilters] = useState<FilterParams[]>([]);
const [filteredDocIds, setFilteredDocIds] = useState<string[]>([]);
const filteredPageMetas = useMemo(() => {
const idSet = new Set(filteredDocIds);
return pageMetas.filter(page => idSet.has(page.id));
}, [pageMetas, filteredDocIds]);
const { searchText, updateSearchText, searchedList } =
useSearch(filteredList);
useSearch(filteredPageMetas);
useEffect(() => {
const subscription = collectionRulesService
.watch([
...filters,
{
type: 'system',
key: 'empty-journal',
method: 'is',
value: 'false',
},
{
type: 'system',
key: 'trash',
method: 'is',
value: 'false',
},
])
.subscribe(result => {
setFilteredDocIds(result.groups.flatMap(group => group.items));
});
return () => {
subscription.unsubscribe();
};
}, [collectionRulesService, filters]);
const operationsRenderer = useCallback(
(item: ListItem) => {
@@ -162,29 +176,21 @@ export const SelectPage = ({
{t['com.affine.selectPage.title']()}
</div>
)}
{!showFilter && filters.length === 0 ? (
{filters.length === 0 ? (
<Menu
items={
<VariableSelect
propertiesMeta={docCollection.meta.properties}
selected={filters}
onSelect={createFilter}
<AddFilterMenu
onAdd={params => setFilters([...filters, params])}
/>
}
>
<IconButton icon={<FilterIcon />} onClick={clickFilter} />
<IconButton icon={<FilterIcon />} />
</Menu>
) : (
<IconButton icon={<FilterIcon />} onClick={clickFilter} />
)}
) : null}
</div>
{showFilter ? (
{filters.length !== 0 ? (
<div style={{ padding: '12px 16px 16px' }}>
<FilterList
propertiesMeta={docCollection.meta.properties}
value={filters}
onChange={updateFilters}
/>
<Filters filters={filters} onChange={setFilters} />
</div>
) : null}
{searchedList.length ? (
@@ -1,45 +0,0 @@
import type { Filter } from '@affine/env/filter';
import type { MouseEvent } from 'react';
import { useCallback, useState } from 'react';
import {
filterPageByRules,
type PageDataForFilter,
} from '../use-collection-manager';
export const useFilter = (list: PageDataForFilter[]) => {
const [filters, changeFilters] = useState<Filter[]>([]);
const [showFilter, setShowFilter] = useState(false);
const clickFilter = useCallback(
(e: MouseEvent) => {
if (showFilter || filters.length !== 0) {
e.stopPropagation();
e.preventDefault();
setShowFilter(!showFilter);
}
},
[filters.length, showFilter]
);
const onCreateFilter = useCallback(
(filter: Filter) => {
changeFilters([...filters, filter]);
setShowFilter(true);
},
[filters]
);
return {
showFilter,
filters,
updateFilters: changeFilters,
clickFilter,
createFilter: onCreateFilter,
filteredList: list
.filter(pageData => {
if (pageData.meta.trash) {
return false;
}
return filterPageByRules(filters, [], pageData);
})
.map(pageData => pageData.meta),
};
};
@@ -1,14 +1,13 @@
import { toast, useConfirmModal } from '@affine/component';
import { useBlockSuiteDocMeta } from '@affine/core/components/hooks/use-block-suite-page-meta';
import { CollectionService } from '@affine/core/modules/collection';
import { type Collection } from '@affine/core/modules/collection';
import { DocsService } from '@affine/core/modules/doc';
import type { Tag } from '@affine/core/modules/tag';
import { WorkspaceService } from '@affine/core/modules/workspace';
import type { Collection, Filter } from '@affine/env/filter';
import { Trans, useI18n } from '@affine/i18n';
import type { DocMeta } from '@blocksuite/affine/store';
import { useService } from '@toeverything/infra';
import { memo, useCallback, useMemo, useRef, useState } from 'react';
import { useLiveData, useService } from '@toeverything/infra';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ListFloatingToolbar } from '../components/list-floating-toolbar';
import { usePageItemGroupDefinitions } from '../group-definitions';
@@ -17,7 +16,6 @@ import { PageOperationCell } from '../operation-cell';
import { PageListItemRenderer } from '../page-group';
import { ListTableHeader } from '../page-header';
import type { ItemListHandle, ListItem } from '../types';
import { useFilteredPageMetas } from '../use-filtered-page-metas';
import { VirtualizedList } from '../virtualized-list';
import {
CollectionPageListHeader,
@@ -25,15 +23,14 @@ import {
TagPageListHeader,
} from './page-list-header';
const usePageOperationsRenderer = () => {
const usePageOperationsRenderer = (collection?: Collection) => {
const t = useI18n();
const collectionService = useService(CollectionService);
const removeFromAllowList = useCallback(
(id: string) => {
collectionService.deletePagesFromCollections([id]);
collection?.removeDoc(id);
toast(t['com.affine.collection.removePage.success']());
},
[collectionService, t]
[collection, t]
);
const pageOperationsRenderer = useCallback(
(page: DocMeta, isInAllowList?: boolean) => {
@@ -53,14 +50,12 @@ const usePageOperationsRenderer = () => {
export const VirtualizedPageList = memo(function VirtualizedPageList({
tag,
collection,
filters,
listItem,
setHideHeaderCreateNewPage,
disableMultiDelete,
}: {
tag?: Tag;
collection?: Collection;
filters?: Filter[];
listItem?: DocMeta[];
setHideHeaderCreateNewPage?: (hide: boolean) => void;
disableMultiDelete?: boolean;
@@ -72,19 +67,28 @@ export const VirtualizedPageList = memo(function VirtualizedPageList({
const currentWorkspace = useService(WorkspaceService).workspace;
const docsService = useService(DocsService);
const pageMetas = useBlockSuiteDocMeta(currentWorkspace.docCollection);
const pageOperations = usePageOperationsRenderer();
const pageOperations = usePageOperationsRenderer(collection);
const pageHeaderColsDef = usePageHeaderColsDef();
const filteredPageMetas = useFilteredPageMetas(pageMetas, {
filters,
collection,
});
const [filteredPageIds, setFilteredPageIds] = useState<string[]>([]);
useEffect(() => {
const subscription = collection?.watch().subscribe(docIds => {
setFilteredPageIds(docIds);
});
return () => subscription?.unsubscribe();
}, [collection]);
const allowList = useLiveData(collection?.info$.map(info => info.allowList));
const pageMetasToRender = useMemo(() => {
if (listItem) {
return listItem;
}
return filteredPageMetas;
}, [filteredPageMetas, listItem]);
if (collection) {
return pageMetas.filter(
page => filteredPageIds.includes(page.id) && !page.trash
);
}
return pageMetas.filter(page => !page.trash);
}, [collection, filteredPageIds, listItem, pageMetas]);
const filteredSelectedPageIds = useMemo(() => {
const ids = new Set(pageMetasToRender.map(page => page.id));
@@ -98,10 +102,10 @@ export const VirtualizedPageList = memo(function VirtualizedPageList({
const pageOperationRenderer = useCallback(
(item: ListItem) => {
const page = item as DocMeta;
const isInAllowList = collection?.allowList?.includes(page.id);
const isInAllowList = allowList?.includes(page.id);
return pageOperations(page, isInAllowList);
},
[collection, pageOperations]
[allowList, pageOperations]
);
const pageHeaderRenderer = useCallback(() => {
@@ -1,181 +0,0 @@
import { Menu, MenuItem, Tooltip } from '@affine/component';
import type { Filter, Literal, PropertiesMeta } from '@affine/env/filter';
import clsx from 'clsx';
import type { ReactNode } from 'react';
import { useMemo } from 'react';
import { FilterTag } from './filter-tag-translation';
import * as styles from './index.css';
import { literalMatcher } from './literal-matcher';
import { tBoolean } from './logical/custom-type';
import type { TFunction, TType } from './logical/typesystem';
import { typesystem } from './logical/typesystem';
import { variableDefineMap } from './shared-types';
import { filterMatcher, VariableSelect, vars } from './vars';
export const Condition = ({
value,
onChange,
propertiesMeta,
}: {
value: Filter;
onChange: (filter: Filter) => void;
propertiesMeta: PropertiesMeta;
}) => {
const data = useMemo(() => {
const data = filterMatcher.find(v => v.data.name === value.funcName);
if (!data) {
return;
}
const instance = typesystem.instance(
{},
[variableDefineMap[value.left.name].type(propertiesMeta)],
tBoolean.create(),
data.type
);
return {
render: data.data.render,
type: instance,
};
}, [propertiesMeta, value.funcName, value.left.name]);
if (!data) {
return null;
}
const render =
data.render ??
(({ ast }) => {
const args = renderArgs(value, onChange, data.type);
return (
<div className={styles.filterContainerStyle}>
<Menu
items={
<VariableSelect
propertiesMeta={propertiesMeta}
selected={[]}
onSelect={onChange}
/>
}
>
<div
data-testid="variable-name"
className={clsx(styles.filterTypeStyle, styles.ellipsisTextStyle)}
>
<Tooltip content={ast.left.name}>
<div className={styles.filterTypeIconStyle}>
{variableDefineMap[ast.left.name].icon}
</div>
</Tooltip>
<FilterTag name={ast.left.name} />
</div>
</Menu>
<Menu
items={
<FunctionSelect
propertiesMeta={propertiesMeta}
value={value}
onChange={onChange}
/>
}
>
<div
className={clsx(styles.switchStyle, styles.ellipsisTextStyle)}
data-testid="filter-name"
>
<FilterTag name={ast.funcName} />
</div>
</Menu>
{args}
</div>
);
});
return <>{render({ ast: value })}</>;
};
const FunctionSelect = ({
value,
onChange,
propertiesMeta,
}: {
value: Filter;
onChange: (value: Filter) => void;
propertiesMeta: PropertiesMeta;
}) => {
const list = useMemo(() => {
const type = vars.find(v => v.name === value.left.name)?.type;
if (!type) {
return [];
}
return filterMatcher.allMatchedData(type(propertiesMeta));
}, [propertiesMeta, value.left.name]);
return (
<div data-testid="filter-name-select">
{list.map(v => (
<MenuItem
onClick={() => {
onChange({
...value,
funcName: v.name,
args: v.defaultArgs().map(v => ({ type: 'literal', value: v })),
});
}}
key={v.name}
>
<FilterTag name={v.name} />
</MenuItem>
))}
</div>
);
};
export const Arg = ({
type,
value,
onChange,
}: {
type: TType;
value: Literal;
onChange: (lit: Literal) => void;
}) => {
const data = useMemo(() => literalMatcher.match(type), [type]);
if (!data) {
return null;
}
return (
<div
data-testid="filter-arg"
className={clsx(styles.argStyle, styles.ellipsisTextStyle)}
>
{data.render({
type,
value: value?.value,
onChange: v => onChange({ type: 'literal', value: v }),
})}
</div>
);
};
export const renderArgs = (
filter: Filter,
onChange: (value: Filter) => void,
type: TFunction
): ReactNode => {
const rest = type.args.slice(1);
return rest.map((argType, i) => {
const value = filter.args[i];
return (
<Arg
key={`${argType.type}-${i}`}
type={argType}
value={value}
onChange={value => {
const args = type.args.map((_, index) =>
i === index ? value : filter.args[index]
);
onChange({
...filter,
args,
});
}}
></Arg>
);
});
};
@@ -1,17 +0,0 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const datePickerTriggerInput = style({
fontSize: cssVar('fontXs'),
width: '50px',
fontWeight: '600',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '22px',
textAlign: 'center',
':hover': {
background: cssVar('hoverColor'),
borderRadius: '4px',
},
});
@@ -1,52 +0,0 @@
import type { PopoverProps } from '@affine/component';
import { DatePicker, Popover } from '@affine/component';
import { useI18n } from '@affine/i18n';
import dayjs from 'dayjs';
import { useCallback, useState } from 'react';
import { datePickerTriggerInput } from './date-select.css';
const datePickerPopperContentOptions: PopoverProps['contentOptions'] = {
style: { padding: 20, marginTop: 10 },
};
export const DateSelect = ({
value,
onChange,
}: {
value: number;
onChange: (value: number) => void;
}) => {
const t = useI18n();
const [open, setOpen] = useState(false);
const onDateChange = useCallback(
(e: string) => {
setOpen(false);
onChange(dayjs(e, 'YYYY-MM-DD').valueOf());
},
[onChange]
);
return (
<Popover
open={open}
onOpenChange={setOpen}
contentOptions={datePickerPopperContentOptions}
content={
<DatePicker
weekDays={t['com.affine.calendar-date-picker.week-days']()}
monthNames={t['com.affine.calendar-date-picker.month-names']()}
todayLabel={t['com.affine.calendar-date-picker.today']()}
value={dayjs(value as number).format('YYYY-MM-DD')}
onChange={onDateChange}
/>
}
>
<input
value={dayjs(value as number).format('MMM DD')}
className={datePickerTriggerInput}
/>
</Popover>
);
};
@@ -1,25 +0,0 @@
import type { Filter, Literal, Ref, VariableMap } from '@affine/env/filter';
import { filterMatcher } from './vars';
const evalRef = (ref: Ref, variableMap: VariableMap) => {
return variableMap[ref.name];
};
const evalLiteral = (lit?: Literal) => {
return lit?.value;
};
const evalFilter = (filter: Filter, variableMap: VariableMap): boolean => {
const impl = filterMatcher.findData(v => v.name === filter.funcName)?.impl;
if (!impl) {
throw new Error('No function implementation found');
}
const leftValue = evalRef(filter.left, variableMap);
const args = filter.args.map(evalLiteral);
return impl(leftValue, ...args);
};
export const evalFilterList = (
filterList: Filter[],
variableMap: VariableMap
) => {
return filterList.every(filter => evalFilter(filter, variableMap));
};
@@ -1,74 +0,0 @@
import { Button, IconButton, Menu } from '@affine/component';
import type { Filter, PropertiesMeta } from '@affine/env/filter';
import { useI18n } from '@affine/i18n';
import { CloseIcon, PlusIcon } from '@blocksuite/icons/rc';
import { Condition } from './condition';
import * as styles from './index.css';
import { CreateFilterMenu } from './vars';
export const FilterList = ({
value,
onChange,
propertiesMeta,
}: {
value: Filter[];
onChange: (value: Filter[]) => void;
propertiesMeta: PropertiesMeta;
}) => {
const t = useI18n();
return (
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 10,
alignItems: 'center',
}}
>
{value.map((filter, i) => {
return (
<div className={styles.filterItemStyle} key={i}>
<Condition
propertiesMeta={propertiesMeta}
value={filter}
onChange={filter => {
onChange(
value.map((old, oldIndex) => (oldIndex === i ? filter : old))
);
}}
/>
<div
className={styles.filterItemCloseStyle}
onClick={() => {
onChange(value.filter((_, index) => i !== index));
}}
>
<CloseIcon />
</div>
</div>
);
})}
<Menu
key={value.length} // hack to force menu to rerender (disable unmount animation)
items={
<CreateFilterMenu
value={value}
onChange={onChange}
propertiesMeta={propertiesMeta}
/>
}
>
{value.length === 0 ? (
<Button suffix={<PlusIcon />}>
{t['com.affine.filterList.button.add']()}
</Button>
) : (
<IconButton size="16">
<PlusIcon />
</IconButton>
)}
</Menu>
</div>
);
};
@@ -1,61 +0,0 @@
import { Tooltip } from '@affine/component';
import { useI18n } from '@affine/i18n';
import { ellipsisTextStyle } from './index.css';
type FilterTagProps = {
name: string;
};
const useFilterTag = ({ name }: FilterTagProps) => {
const t = useI18n();
switch (name) {
case 'Created':
return t['Created']();
case 'Updated':
return t['Updated']();
case 'Tags':
return t['Tags']();
case 'Is Favourited':
return t['com.affine.filter.is-favourited']();
case 'Is Public':
return t['com.affine.filter.is-public']();
case 'after':
return t['com.affine.filter.after']();
case 'before':
return t['com.affine.filter.before']();
case 'last':
return t['com.affine.filter.last']();
case 'is':
return t['com.affine.filter.is']();
case 'is not empty':
return t['com.affine.filter.is not empty']();
case 'is empty':
return t['com.affine.filter.is empty']();
case 'contains all':
return t['com.affine.filter.contains all']();
case 'contains one of':
return t['com.affine.filter.contains one of']();
case 'does not contains all':
return t['com.affine.filter.does not contains all']();
case 'does not contains one of':
return t['com.affine.filter.does not contains one of']();
case 'true':
return t['com.affine.filter.true']();
case 'false':
return t['com.affine.filter.false']();
default:
return name;
}
};
export const FilterTag = ({ name }: FilterTagProps) => {
const tag = useFilterTag({ name });
return (
<Tooltip content={tag}>
<span className={ellipsisTextStyle} data-testid={`filler-tag-${tag}`}>
{tag}
</span>
</Tooltip>
);
};
@@ -1,106 +0,0 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const filterContainerStyle = style({
display: 'flex',
userSelect: 'none',
alignItems: 'center',
overflow: 'hidden',
});
export const menuItemStyle = style({
fontSize: cssVar('fontXs'),
});
export const variableSelectTitleStyle = style({
margin: '7px 16px',
fontWeight: 500,
lineHeight: '20px',
fontSize: cssVar('fontXs'),
color: cssVar('textSecondaryColor'),
});
export const variableSelectDividerStyle = style({
marginTop: '2px',
marginBottom: '2px',
marginLeft: '12px',
marginRight: '8px',
height: '1px',
background: cssVar('borderColor'),
});
export const menuItemTextStyle = style({
fontSize: cssVar('fontXs'),
});
export const filterItemStyle = style({
display: 'flex',
border: `1px solid ${cssVar('borderColor')}`,
borderRadius: '8px',
background: cssVar('white'),
padding: '4px 8px',
overflow: 'hidden',
justifyContent: 'space-between',
});
export const filterItemCloseStyle = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
marginLeft: '4px',
});
export const inputStyle = style({
fontSize: cssVar('fontXs'),
padding: '2px 4px',
transition: 'all 0.15s ease-in-out',
':hover': {
cursor: 'pointer',
background: cssVar('hoverColor'),
borderRadius: '4px',
},
});
export const switchStyle = style({
fontSize: cssVar('fontXs'),
color: cssVar('textSecondaryColor'),
padding: '2px 4px',
transition: 'all 0.15s ease-in-out',
display: 'flex',
alignItems: 'center',
flex: '3 1 auto',
minWidth: '28px',
':hover': {
cursor: 'pointer',
background: cssVar('hoverColor'),
borderRadius: '4px',
},
});
export const filterTypeStyle = style({
fontSize: cssVar('fontSm'),
display: 'flex',
alignItems: 'center',
padding: '2px 4px',
transition: 'all 0.15s ease-in-out',
marginRight: '6px',
flex: '1 0 auto',
':hover': {
cursor: 'pointer',
background: cssVar('hoverColor'),
borderRadius: '4px',
},
});
export const filterTypeIconStyle = style({
fontSize: cssVar('fontBase'),
marginRight: '6px',
padding: '1px 0',
display: 'flex',
alignItems: 'center',
color: cssVar('iconColor'),
});
export const argStyle = style({
marginLeft: 4,
fontWeight: 600,
flex: '1 0 auto',
});
export const ellipsisTextStyle = style({
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
});
@@ -1,3 +0,0 @@
export * from './eval';
export * from './filter-list';
export * from './utils';
@@ -1,97 +0,0 @@
import { Input, Menu, MenuItem } from '@affine/component';
import type { LiteralValue } from '@affine/env/filter';
import type { ReactNode } from 'react';
import type { TagMeta } from '../types';
import { DateSelect } from './date-select';
import { FilterTag } from './filter-tag-translation';
import { inputStyle } from './index.css';
import { tBoolean, tDate, tDateRange, tTag } from './logical/custom-type';
import { Matcher } from './logical/matcher';
import type { TType } from './logical/typesystem';
import { tArray, typesystem } from './logical/typesystem';
import { MultiSelect } from './multi-select';
export const literalMatcher = new Matcher<{
render: (props: {
type: TType;
value: LiteralValue;
onChange: (lit: LiteralValue) => void;
}) => ReactNode;
}>((type, target) => {
return typesystem.isSubtype(type, target);
});
literalMatcher.register(tDateRange.create(), {
render: ({ value, onChange }) => (
<Menu
items={
<div>
<Input
type="number"
// Handle the input change and update the value accordingly
onChange={i => (i ? onChange(parseInt(i)) : onChange(0))}
/>
{[1, 2, 3, 7, 14, 30].map(i => (
<MenuItem
key={i}
onClick={() => {
// Handle the menu item click and update the value accordingly
onChange(i);
}}
>
{i} {i > 1 ? 'days' : 'day'}
</MenuItem>
))}
</div>
}
>
<div>
<span>{value.toString()}</span> {(value as number) > 1 ? 'days' : 'day'}
</div>
</Menu>
),
});
literalMatcher.register(tBoolean.create(), {
render: ({ value, onChange }) => (
<div
className={inputStyle}
style={{ cursor: 'pointer' }}
onClick={() => {
onChange(!value);
}}
>
<FilterTag name={value?.toString()} />
</div>
),
});
literalMatcher.register(tDate.create(), {
render: ({ value, onChange }) => (
<DateSelect value={value as number} onChange={onChange} />
),
});
const getTagsOfArrayTag = (type: TType): TagMeta[] => {
if (type.type === 'array') {
if (tTag.is(type.ele)) {
return type.ele.data?.tags ?? [];
}
return [];
} else {
return [];
}
};
literalMatcher.register(tArray(tTag.create()), {
render: ({ type, value, onChange }) => {
return (
<MultiSelect
value={(value ?? []) as string[]}
onChange={value => onChange(value)}
options={getTagsOfArrayTag(type).map((v: any) => ({
label: v.name,
value: v.id,
}))}
></MultiSelect>
);
},
});
@@ -1,24 +0,0 @@
import type { TagMeta } from '../../types';
import { DataHelper, typesystem } from './typesystem';
export const tNumber = typesystem.defineData(
DataHelper.create<{ value: number }>('Number')
);
export const tString = typesystem.defineData(
DataHelper.create<{ value: string }>('String')
);
export const tBoolean = typesystem.defineData(
DataHelper.create<{ value: boolean }>('Boolean')
);
export const tDate = typesystem.defineData(
DataHelper.create<{ value: number }>('Date')
);
export const tTag = typesystem.defineData<{ tags: TagMeta[] }>({
name: 'Tag',
supers: [],
});
export const tDateRange = typesystem.defineData(
DataHelper.create<{ value: number }>('DateRange')
);
@@ -1,55 +0,0 @@
import type { TType } from './typesystem';
import { typesystem } from './typesystem';
type MatcherData<Data, Type extends TType = TType> = { type: Type; data: Data };
export class Matcher<Data, Type extends TType = TType> {
private readonly list: MatcherData<Data, Type>[] = [];
constructor(
private readonly _match?: (type: Type, target: TType) => boolean
) {}
register(type: Type, data: Data) {
this.list.push({ type, data });
}
match(type: TType) {
const match = this._match ?? typesystem.isSubtype.bind(typesystem);
for (const t of this.list) {
if (match(t.type, type)) {
return t.data;
}
}
return;
}
allMatched(type: TType): MatcherData<Data>[] {
const match = this._match ?? typesystem.isSubtype.bind(typesystem);
const result: MatcherData<Data>[] = [];
for (const t of this.list) {
if (match(t.type, type)) {
result.push(t);
}
}
return result;
}
allMatchedData(type: TType): Data[] {
return this.allMatched(type).map(v => v.data);
}
findData(f: (data: Data) => boolean): Data | undefined {
return this.list.find(data => f(data.data))?.data;
}
find(
f: (data: MatcherData<Data, Type>) => boolean
): MatcherData<Data, Type> | undefined {
return this.list.find(f);
}
all(): MatcherData<Data, Type>[] {
return this.list;
}
}
@@ -1,282 +0,0 @@
/**
* This file will be moved to a separate package soon.
*/
export interface TUnion {
type: 'union';
title: 'union';
list: TType[];
}
export const tUnion = (list: TType[]): TUnion => ({
type: 'union',
title: 'union',
list,
});
// TODO treat as data type
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export interface TArray<Ele extends TType = TType> {
type: 'array';
ele: Ele;
title: 'array';
}
export const tArray = <const T extends TType>(ele: T): TArray<T> => {
return {
type: 'array',
title: 'array',
ele,
};
};
export type TTypeVar = {
type: 'typeVar';
title: 'typeVar';
name: string;
bound: TType;
};
export const tTypeVar = (name: string, bound: TType): TTypeVar => {
return {
type: 'typeVar',
title: 'typeVar',
name,
bound,
};
};
export type TTypeRef = {
type: 'typeRef';
title: 'typeRef';
name: string;
};
export const tTypeRef = (name: string): TTypeRef => {
return {
type: 'typeRef',
title: 'typeRef',
name,
};
};
export type TFunction = {
type: 'function';
title: 'function';
typeVars: TTypeVar[];
args: TType[];
rt: TType;
};
export const tFunction = (fn: {
typeVars?: TTypeVar[];
args: TType[];
rt: TType;
}): TFunction => {
return {
type: 'function',
title: 'function',
typeVars: fn.typeVars ?? [],
args: fn.args,
rt: fn.rt,
};
};
export type TType = TDataType | TArray | TUnion | TTypeRef | TFunction;
export type DataTypeShape = Record<string, unknown>;
export type TDataType<Data extends DataTypeShape = Record<string, unknown>> = {
type: 'data';
name: string;
data?: Data;
};
export type ValueOfData<T extends DataDefine> =
T extends DataDefine<infer R> ? R : never;
export class DataDefine<Data extends DataTypeShape = Record<string, unknown>> {
constructor(
private readonly config: DataDefineConfig<Data>,
private readonly dataMap: Map<string, DataDefine>
) {}
create(data?: Data): TDataType<Data> {
return {
type: 'data',
name: this.config.name,
data,
};
}
is(data: TType): data is TDataType<Data> {
if (data.type !== 'data') {
return false;
}
return data.name === this.config.name;
}
private isByName(name: string): boolean {
return name === this.config.name;
}
isSubOf(superType: TDataType): boolean {
if (this.is(superType)) {
return true;
}
return this.config.supers.some(sup => sup.isSubOf(superType));
}
private isSubOfByName(superType: string): boolean {
if (this.isByName(superType)) {
return true;
}
return this.config.supers.some(sup => sup.isSubOfByName(superType));
}
isSuperOf(subType: TDataType): boolean {
const dataDefine = this.dataMap.get(subType.name);
if (!dataDefine) {
throw new Error('bug');
}
return dataDefine.isSubOfByName(this.config.name);
}
}
// type DataTypeVar = {};
// TODO support generic data type
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface DataDefineConfig<T extends DataTypeShape> {
name: string;
supers: DataDefine[];
_phantom?: T;
}
interface DataHelper<T extends DataTypeShape> {
create<V = Record<string, unknown>>(name: string): DataDefineConfig<T & V>;
extends<V extends DataTypeShape>(
dataDefine: DataDefine<V>
): DataHelper<T & V>;
}
const createDataHelper = <T extends DataTypeShape = Record<string, unknown>>(
...supers: DataDefine[]
): DataHelper<T> => {
return {
create(name: string) {
return {
name,
supers,
};
},
extends(dataDefine) {
return createDataHelper(...supers, dataDefine);
},
};
};
export const DataHelper = createDataHelper();
export class Typesystem {
dataMap = new Map<string, DataDefine<any>>();
defineData<T extends DataTypeShape>(
config: DataDefineConfig<T>
): DataDefine<T> {
const result = new DataDefine(config, this.dataMap);
this.dataMap.set(config.name, result);
return result;
}
isDataType(t: TType): t is TDataType {
return t.type === 'data';
}
isSubtype(
superType: TType,
sub: TType,
context?: Record<string, TType>
): boolean {
if (superType.type === 'typeRef') {
// TODO both are ref
if (context && sub.type !== 'typeRef') {
context[superType.name] = sub;
}
// TODO bound
return true;
}
if (sub.type === 'typeRef') {
// TODO both are ref
if (context) {
context[sub.name] = superType;
}
return true;
}
if (tUnknown.is(superType)) {
return true;
}
if (superType.type === 'union') {
return superType.list.some(type => this.isSubtype(type, sub, context));
}
if (sub.type === 'union') {
return sub.list.every(type => this.isSubtype(superType, type, context));
}
if (this.isDataType(sub)) {
const dataDefine = this.dataMap.get(sub.name);
if (!dataDefine) {
throw new Error('bug');
}
if (!this.isDataType(superType)) {
return false;
}
return dataDefine.isSubOf(superType);
}
if (superType.type === 'array' || sub.type === 'array') {
if (superType.type !== 'array' || sub.type !== 'array') {
return false;
}
return this.isSubtype(superType.ele, sub.ele, context);
}
return false;
}
subst(context: Record<string, TType>, template: TFunction): TFunction {
const subst = (type: TType): TType => {
if (this.isDataType(type)) {
return type;
}
switch (type.type) {
case 'typeRef':
return { ...context[type.name] };
case 'union':
return tUnion(type.list.map(type => subst(type)));
case 'array':
return tArray(subst(type.ele));
case 'function':
throw new Error('TODO');
}
};
const result = tFunction({
args: template.args.map(type => subst(type)),
rt: subst(template.rt),
});
return result;
}
instance(
context: Record<string, TType>,
realArgs: TType[],
realRt: TType,
template: TFunction
): TFunction {
const ctx = { ...context };
template.args.forEach((arg, i) => {
const realArg = realArgs[i];
if (realArg) {
this.isSubtype(arg, realArg, ctx);
}
});
this.isSubtype(realRt, template.rt);
return this.subst(ctx, template);
}
}
export const typesystem = new Typesystem();
export const tUnknown = typesystem.defineData(DataHelper.create('Unknown'));
@@ -1,62 +0,0 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const content = style({
fontSize: 12,
color: cssVar('textPrimaryColor'),
borderRadius: 8,
padding: '3px 4px',
cursor: 'pointer',
overflow: 'hidden',
':hover': {
backgroundColor: cssVar('hoverColor'),
},
});
export const text = style({
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: 350,
selectors: {
'&.empty': {
color: 'var(--affine-text-secondary-color)',
},
},
});
export const optionList = style({
display: 'flex',
flexDirection: 'column',
gap: 4,
padding: '0 4px',
maxHeight: '220px',
});
export const scrollbar = style({
vars: {
'--scrollbar-width': '4px',
},
});
export const selectOption = style({
display: 'flex',
alignItems: 'center',
fontSize: 14,
height: 26,
borderRadius: 5,
maxWidth: 240,
minWidth: 100,
padding: '0 12px',
cursor: 'pointer',
':hover': {
backgroundColor: cssVar('hoverColor'),
},
});
export const optionLabel = style({
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
});
export const done = style({
display: 'flex',
alignItems: 'center',
color: cssVar('primaryColor'),
marginLeft: 8,
});
@@ -1,92 +0,0 @@
import { Menu, MenuItem, Scrollable, Tooltip } from '@affine/component';
import { useI18n } from '@affine/i18n';
import clsx from 'clsx';
import type { MouseEvent } from 'react';
import { useMemo } from 'react';
import * as styles from './multi-select.css';
export const MultiSelect = ({
value,
onChange,
options,
}: {
value: string[];
onChange: (value: string[]) => void;
options: {
label: string;
value: string;
}[];
}) => {
const t = useI18n();
const optionMap = useMemo(
() => Object.fromEntries(options.map(v => [v.value, v])),
[options]
);
const content = useMemo(
() => value.map(id => optionMap[id]?.label).join(', '),
[optionMap, value]
);
const items = useMemo(() => {
return (
<Scrollable.Root>
<Scrollable.Viewport
data-testid="multi-select"
className={styles.optionList}
>
{options.length === 0 ? (
<MenuItem checked={true}>
{t['com.affine.filter.empty-tag']()}
</MenuItem>
) : (
options.map(option => {
const selected = value.includes(option.value);
const click = (e: MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
e.preventDefault();
if (selected) {
onChange(value.filter(v => v !== option.value));
} else {
onChange([...value, option.value]);
}
};
return (
<MenuItem
data-testid={`multi-select-${option.label}`}
checked={selected}
onClick={click}
key={option.value}
>
{option.label}
</MenuItem>
);
})
)}
</Scrollable.Viewport>
<Scrollable.Scrollbar className={styles.scrollbar} />
</Scrollable.Root>
);
}, [onChange, options, t, value]);
return (
<Menu items={items}>
<div className={styles.content}>
<Tooltip
content={
content.length ? content : t['com.affine.filter.empty-tag']()
}
>
{value.length ? (
<div className={styles.text}>{content}</div>
) : (
<div className={clsx(styles.text, 'empty')}>
{t['com.affine.filter.empty-tag']()}
</div>
)}
</Tooltip>
</div>
</Menu>
);
};
@@ -1,77 +0,0 @@
import type {
Literal,
LiteralValue,
PropertiesMeta,
VariableMap,
} from '@affine/env/filter';
import {
CloudWorkspaceIcon,
CreatedIcon,
FavoriteIcon,
TagsIcon,
UpdatedIcon,
} from '@blocksuite/icons/rc';
import type { ReactElement } from 'react';
import { tBoolean, tDate, tTag } from './logical/custom-type';
import type { TType } from './logical/typesystem';
import { tArray } from './logical/typesystem';
export const toLiteral = (value: LiteralValue): Literal => ({
type: 'literal',
value,
});
export type FilterVariable = {
name: keyof VariableMap;
type: (propertiesMeta: PropertiesMeta) => TType;
icon: ReactElement;
};
export const variableDefineMap = {
Created: {
type: () => tDate.create(),
icon: <CreatedIcon />,
},
Updated: {
type: () => tDate.create(),
icon: <UpdatedIcon />,
},
'Is Favourited': {
type: () => tBoolean.create(),
icon: <FavoriteIcon />,
},
Tags: {
type: meta =>
tArray(
tTag.create({
tags:
meta.tags?.options.map(t => ({
id: t.id,
name: t.value,
color: t.color,
})) ?? [],
})
),
icon: <TagsIcon />,
},
'Is Public': {
type: () => tBoolean.create(),
icon: <CloudWorkspaceIcon />,
},
// Imported: {
// type: tBoolean.create(),
// },
// 'Daily Note': {
// type: tBoolean.create(),
// },
} satisfies Record<string, Omit<FilterVariable, 'name'>>;
export type InternalVariableMap = {
[K in keyof typeof variableDefineMap]: LiteralValue;
};
declare module '@affine/env/filter' {
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface VariableMap extends InternalVariableMap {}
}
@@ -1,10 +0,0 @@
import type { Filter } from '@affine/env/filter';
export const createTagFilter = (id: string): Filter => {
return {
type: 'filter',
left: { type: 'ref', name: 'Tags' },
funcName: 'contains all',
args: [{ type: 'literal', value: [id] }],
};
};
@@ -1,305 +0,0 @@
import { MenuItem, MenuSeparator } from '@affine/component';
import type {
Filter,
LiteralValue,
PropertiesMeta,
VariableMap,
} from '@affine/env/filter';
import { useI18n } from '@affine/i18n';
import dayjs from 'dayjs';
import type { ReactNode } from 'react';
import { FilterTag } from './filter-tag-translation';
import * as styles from './index.css';
import { tBoolean, tDate, tDateRange, tTag } from './logical/custom-type';
import { Matcher } from './logical/matcher';
import type { TFunction } from './logical/typesystem';
import {
tArray,
tFunction,
tTypeRef,
tTypeVar,
typesystem,
} from './logical/typesystem';
import type { FilterVariable } from './shared-types';
import { variableDefineMap } from './shared-types';
export const vars: FilterVariable[] = Object.entries(variableDefineMap).map(
([key, value]) => ({
name: key as keyof VariableMap,
type: value.type,
icon: value.icon,
})
);
export const createDefaultFilter = (
variable: FilterVariable,
propertiesMeta: PropertiesMeta
): Filter => {
const data = filterMatcher.match(variable.type(propertiesMeta));
if (!data) {
throw new Error('No matching function found');
}
return {
type: 'filter',
left: {
type: 'ref',
name: variable.name,
},
funcName: data.name,
args: data.defaultArgs().map(value => ({
type: 'literal',
value,
})),
};
};
export const CreateFilterMenu = ({
value,
onChange,
propertiesMeta,
}: {
value: Filter[];
onChange: (value: Filter[]) => void;
propertiesMeta: PropertiesMeta;
}) => {
return (
<VariableSelect
propertiesMeta={propertiesMeta}
selected={value}
onSelect={filter => {
onChange([...value, filter]);
}}
/>
);
};
export const VariableSelect = ({
onSelect,
propertiesMeta,
}: {
selected: Filter[];
onSelect: (value: Filter) => void;
propertiesMeta: PropertiesMeta;
}) => {
const t = useI18n();
return (
<div data-testid="variable-select">
<div className={styles.variableSelectTitleStyle}>
{t['com.affine.filter']()}
</div>
<MenuSeparator />
{vars
// .filter(v => !selected.find(filter => filter.left.name === v.name))
.map(v => (
<MenuItem
prefixIcon={variableDefineMap[v.name].icon}
key={v.name}
onClick={() => {
onSelect(createDefaultFilter(v, propertiesMeta));
}}
className={styles.menuItemStyle}
>
<div
data-testid="variable-select-item"
className={styles.menuItemTextStyle}
>
<FilterTag name={v.name} />
</div>
</MenuItem>
))}
</div>
);
};
export type FilterMatcherDataType = {
name: string;
defaultArgs: () => LiteralValue[];
render?: (props: { ast: Filter }) => ReactNode;
impl: (...args: (LiteralValue | undefined)[]) => boolean;
};
export const filterMatcher = new Matcher<FilterMatcherDataType, TFunction>(
(type, target) => {
const staticType = typesystem.subst(
Object.fromEntries(type.typeVars?.map(v => [v.name, v.bound]) ?? []),
type
);
const firstArg = staticType.args[0];
return firstArg && typesystem.isSubtype(firstArg, target);
}
);
filterMatcher.register(
tFunction({
args: [tBoolean.create(), tBoolean.create()],
rt: tBoolean.create(),
}),
{
name: 'is',
defaultArgs: () => [true],
impl: (value, target) => {
return value === target;
},
}
);
filterMatcher.register(
tFunction({
args: [tDate.create(), tDate.create()],
rt: tBoolean.create(),
}),
{
name: 'after',
defaultArgs: () => {
return [dayjs().subtract(1, 'day').endOf('day').valueOf()];
},
impl: (date, target) => {
if (typeof date !== 'number' || typeof target !== 'number') {
throw new Error('argument type error');
}
return dayjs(date).isAfter(dayjs(target).endOf('day'));
},
}
);
filterMatcher.register(
tFunction({
args: [tDate.create(), tDateRange.create()],
rt: tBoolean.create(),
}),
{
name: 'last',
defaultArgs: () => [30], // Default to the last 30 days
impl: (date, n) => {
if (typeof date !== 'number' || typeof n !== 'number') {
throw new Error('Argument type error: date and n must be numbers');
}
const startDate = dayjs().subtract(n, 'day').startOf('day').valueOf();
return date > startDate;
},
}
);
filterMatcher.register(
tFunction({
args: [tDate.create(), tDate.create()],
rt: tBoolean.create(),
}),
{
name: 'before',
defaultArgs: () => [dayjs().endOf('day').valueOf()],
impl: (date, target) => {
if (typeof date !== 'number' || typeof target !== 'number') {
throw new Error('argument type error');
}
return dayjs(date).isBefore(dayjs(target).startOf('day'));
},
}
);
const safeArray = (arr: unknown): LiteralValue[] => {
return Array.isArray(arr) ? arr : [];
};
filterMatcher.register(
tFunction({
args: [tArray(tTag.create())],
rt: tBoolean.create(),
}),
{
name: 'is not empty',
defaultArgs: () => [],
impl: tags => {
const safeTags = safeArray(tags);
return safeTags.length > 0;
},
}
);
filterMatcher.register(
tFunction({
args: [tArray(tTag.create())],
rt: tBoolean.create(),
}),
{
name: 'is empty',
defaultArgs: () => [],
impl: tags => {
const safeTags = safeArray(tags);
return safeTags.length === 0;
},
}
);
filterMatcher.register(
tFunction({
typeVars: [tTypeVar('T', tTag.create())],
args: [tArray(tTypeRef('T')), tArray(tTypeRef('T'))],
rt: tBoolean.create(),
}),
{
name: 'contains all',
defaultArgs: () => [],
impl: (tags, target) => {
if (!Array.isArray(target)) {
return true;
}
const safeTags = safeArray(tags);
return target.every(id => safeTags.includes(id));
},
}
);
filterMatcher.register(
tFunction({
typeVars: [tTypeVar('T', tTag.create())],
args: [tArray(tTypeRef('T')), tArray(tTypeRef('T'))],
rt: tBoolean.create(),
}),
{
name: 'contains one of',
defaultArgs: () => [],
impl: (tags, target) => {
if (!Array.isArray(target)) {
return true;
}
const safeTags = safeArray(tags);
return target.some(id => safeTags.includes(id));
},
}
);
filterMatcher.register(
tFunction({
typeVars: [tTypeVar('T', tTag.create())],
args: [tArray(tTypeRef('T')), tArray(tTypeRef('T'))],
rt: tBoolean.create(),
}),
{
name: 'does not contains all',
defaultArgs: () => [],
impl: (tags, target) => {
if (!Array.isArray(target)) {
return true;
}
const safeTags = safeArray(tags);
return !target.every(id => safeTags.includes(id));
},
}
);
filterMatcher.register(
tFunction({
typeVars: [tTypeVar('T', tTag.create())],
args: [tArray(tTypeRef('T')), tArray(tTypeRef('T'))],
rt: tBoolean.create(),
}),
{
name: 'does not contains one of',
defaultArgs: () => [],
impl: (tags, target) => {
if (!Array.isArray(target)) {
return true;
}
const safeTags = safeArray(tags);
return !target.some(id => safeTags.includes(id));
},
}
);
@@ -6,7 +6,6 @@ export * from './components/page-display-menu';
export * from './docs';
export * from './docs/page-list-item';
export * from './docs/page-tags';
export * from './filter';
export * from './group-definitions';
export * from './header-col-def';
export * from './list';
@@ -17,8 +16,6 @@ export * from './page-header';
export * from './tags';
export * from './types';
export * from './use-all-doc-display-properties';
export * from './use-collection-manager';
export * from './use-filtered-page-metas';
export * from './utils';
export * from './view';
export * from './virtualized-list';
@@ -16,7 +16,6 @@ import {
} from '@affine/core/modules/favorite';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { WorkspaceService } from '@affine/core/modules/workspace';
import type { Collection, DeleteCollectionInfo } from '@affine/env/filter';
import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';
import type { DocMeta } from '@blocksuite/affine/store';
@@ -38,8 +37,10 @@ import { useLiveData, useService, useServices } from '@toeverything/infra';
import type { MouseEvent } from 'react';
import { useCallback, useState } from 'react';
import { usePageHelper } from '../../blocksuite/block-suite-page-list/utils';
import type { CollectionService } from '../../modules/collection';
import {
type CollectionMeta,
CollectionService,
} from '../../modules/collection';
import { useGuard } from '../guard';
import { IsFavoriteIcon } from '../pure/icons';
import { FavoriteTag } from './components/favorite-tag';
@@ -328,31 +329,28 @@ export const TrashOperationCell = ({
};
export interface CollectionOperationCellProps {
collection: Collection;
info: DeleteCollectionInfo;
service: CollectionService;
collectionMeta: CollectionMeta;
}
export const CollectionOperationCell = ({
collection,
service,
info,
collectionMeta,
}: CollectionOperationCellProps) => {
const t = useI18n();
const {
compatibleFavoriteItemsAdapter: favAdapter,
workspaceService,
workspaceDialogService,
collectionService,
docsService,
} = useServices({
CompatibleFavoriteItemsAdapter,
WorkspaceService,
WorkspaceDialogService,
CollectionService,
DocsService,
});
const docCollection = workspaceService.workspace.docCollection;
const { createPage } = usePageHelper(docCollection);
const collectionId = collectionMeta.id;
const { openConfirmModal } = useConfirmModal();
const favourite = useLiveData(
favAdapter.isFavorite$(collection.id, 'collection')
favAdapter.isFavorite$(collectionId, 'collection')
);
const { openPromptModal } = usePromptModal();
@@ -377,44 +375,43 @@ export const CollectionOperationCell = ({
variant: 'primary',
},
onConfirm(name) {
service.updateCollection(collection.id, () => ({
...collection,
collectionService.updateCollection(collectionId, {
name,
}));
});
},
});
},
[collection, handlePropagation, openPromptModal, service, t]
[collectionId, collectionService, handlePropagation, openPromptModal, t]
);
const handleEdit = useCallback(
(event: MouseEvent) => {
handlePropagation(event);
workspaceDialogService.open('collection-editor', {
collectionId: collection.id,
collectionId: collectionId,
});
},
[handlePropagation, workspaceDialogService, collection.id]
[handlePropagation, workspaceDialogService, collectionId]
);
const handleDelete = useCallback(() => {
return service.deleteCollection(info, collection.id);
}, [service, info, collection]);
return collectionService.deleteCollection(collectionId);
}, [collectionId, collectionService]);
const onToggleFavoriteCollection = useCallback(() => {
const status = favAdapter.isFavorite(collection.id, 'collection');
favAdapter.toggle(collection.id, 'collection');
const status = favAdapter.isFavorite(collectionId, 'collection');
favAdapter.toggle(collectionId, 'collection');
toast(
status
? t['com.affine.toastMessage.removedFavorites']()
: t['com.affine.toastMessage.addedFavorites']()
);
}, [favAdapter, collection.id, t]);
}, [favAdapter, collectionId, t]);
const createAndAddDocument = useCallback(() => {
const newDoc = createPage();
service.addPageToCollection(collection.id, newDoc.id);
}, [collection.id, createPage, service]);
const newDoc = docsService.createDoc();
collectionService.addDocToCollection(collectionId, newDoc.id);
}, [docsService, collectionService, collectionId]);
const onConfirmAddDocToCollection = useCallback(() => {
openConfirmModal({
@@ -1,4 +1,5 @@
import { shallowEqual } from '@affine/component';
import type { CollectionMeta } from '@affine/core/modules/collection';
import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta';
import { useI18n } from '@affine/i18n';
import type { DocMeta } from '@blocksuite/affine/store';
@@ -25,7 +26,6 @@ import {
import { TagListItem } from './tags/tag-list-item';
import type {
CollectionListItemProps,
CollectionMeta,
ItemGroupProps,
ListItem,
ListProps,
@@ -1,15 +1,15 @@
import type { Collection } from '@affine/env/filter';
import type { CollectionMeta } from '@affine/core/modules/collection';
import type { DocMeta, Workspace } from '@blocksuite/affine/store';
import type { JSX, PropsWithChildren, ReactNode } from 'react';
import type { To } from 'react-router-dom';
export type ListItem = DocMeta | CollectionMeta | TagMeta;
export interface CollectionMeta extends Collection {
title: string;
createDate?: Date | number;
updatedDate?: Date | number;
}
export type ListItem =
| DocMeta
| (CollectionMeta & {
createDate?: Date | number;
updatedDate?: Date | number;
})
| TagMeta;
export type TagMeta = {
id: string;
@@ -1,49 +0,0 @@
import type { Collection, Filter, VariableMap } from '@affine/env/filter';
import type { DocMeta } from '@blocksuite/affine/store';
import { evalFilterList } from './filter';
export const createEmptyCollection = (
id: string,
data?: Partial<Omit<Collection, 'id'>>
): Collection => {
return {
id,
name: '',
filterList: [],
allowList: [],
...data,
};
};
export const filterByFilterList = (filterList: Filter[], varMap: VariableMap) =>
evalFilterList(filterList, varMap);
export type PageDataForFilter = {
meta: DocMeta;
favorite: boolean;
publicMode: undefined | 'page' | 'edgeless';
};
export const filterPage = (collection: Collection, page: PageDataForFilter) => {
if (collection.filterList.length === 0) {
return collection.allowList.includes(page.meta.id);
}
return filterPageByRules(collection.filterList, collection.allowList, page);
};
export const filterPageByRules = (
rules: Filter[],
allowList: string[],
{ meta, publicMode, favorite }: PageDataForFilter
) => {
if (allowList?.includes(meta.id)) {
return true;
}
return filterByFilterList(rules, {
'Is Favourited': !!favorite,
'Is Public': !!publicMode,
Created: meta.createDate,
Updated: meta.updatedDate ?? meta.createDate,
Tags: meta.tags,
});
};
@@ -1,81 +0,0 @@
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
import { ShareDocsListService } from '@affine/core/modules/share-doc';
import type { Collection, Filter } from '@affine/env/filter';
import { PublicDocMode } from '@affine/graphql';
import type { DocMeta } from '@blocksuite/affine/store';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useEffect, useMemo } from 'react';
import { filterPage, filterPageByRules } from './use-collection-manager';
export const useFilteredPageMetas = (
pageMetas: DocMeta[],
options: {
trash?: boolean;
filters?: Filter[];
collection?: Collection;
} = {}
) => {
const shareDocsListService = useService(ShareDocsListService);
const shareDocs = useLiveData(shareDocsListService.shareDocs?.list$);
const getPublicMode = useCallback(
(id: string) => {
const mode = shareDocs?.find(shareDoc => shareDoc.id === id)?.mode;
return mode
? mode === PublicDocMode.Edgeless
? ('edgeless' as const)
: ('page' as const)
: undefined;
},
[shareDocs]
);
useEffect(() => {
// TODO(@eyhn): loading & error UI
shareDocsListService.shareDocs?.revalidate();
}, [shareDocsListService]);
const favAdapter = useService(CompatibleFavoriteItemsAdapter);
const favoriteItems = useLiveData(favAdapter.favorites$);
const filteredPageMetas = useMemo(
() =>
pageMetas.filter(pageMeta => {
if (options.trash) {
if (!pageMeta.trash) {
return false;
}
} else if (pageMeta.trash) {
return false;
}
const pageData = {
meta: pageMeta,
favorite: favoriteItems.some(fav => fav.id === pageMeta.id),
publicMode: getPublicMode(pageMeta.id),
};
if (
options.filters &&
!filterPageByRules(options.filters, [], pageData)
) {
return false;
}
if (options.collection && !filterPage(options.collection, pageData)) {
return false;
}
return true;
}),
[
pageMetas,
options.trash,
options.filters,
options.collection,
favoriteItems,
getPublicMode,
]
);
return filteredPageMetas;
};
@@ -1,43 +0,0 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const menuTitleStyle = style({
marginLeft: '12px',
marginTop: '10px',
fontSize: cssVar('fontXs'),
color: cssVar('textSecondaryColor'),
});
export const menuDividerStyle = style({
marginTop: '2px',
marginBottom: '2px',
marginLeft: '12px',
marginRight: '8px',
height: '1px',
background: cssVar('borderColor'),
});
export const viewMenu = style({});
export const viewOption = style({
borderRadius: 8,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginLeft: 6,
width: 24,
height: 24,
opacity: 0,
':hover': {
backgroundColor: cssVar('hoverColor'),
},
selectors: {
[`${viewMenu}:hover &`]: {
opacity: 1,
},
},
});
export const filterMenuTrigger = style({
padding: '6px 8px',
selectors: {
[`&[data-is-hidden="true"]`]: {
display: 'none',
},
},
});
@@ -1,41 +0,0 @@
import { Button, FlexWrapper, Menu } from '@affine/component';
import type { Filter, PropertiesMeta } from '@affine/env/filter';
import { useI18n } from '@affine/i18n';
import { FilterIcon } from '@blocksuite/icons/rc';
import { CreateFilterMenu } from '../filter/vars';
import * as styles from './collection-list.css';
export const AllPageListOperationsMenu = ({
propertiesMeta,
filterList,
onChangeFilterList,
}: {
propertiesMeta: PropertiesMeta;
filterList: Filter[];
onChangeFilterList: (filterList: Filter[]) => void;
}) => {
const t = useI18n();
return (
<FlexWrapper alignItems="center">
<Menu
items={
<CreateFilterMenu
propertiesMeta={propertiesMeta}
value={filterList}
onChange={onChangeFilterList}
/>
}
>
<Button
className={styles.filterMenuTrigger}
prefix={<FilterIcon />}
data-testid="create-first-filter"
>
{t['com.affine.filter']()}
</Button>
</Menu>
</FlexWrapper>
);
};
@@ -1,10 +1,8 @@
import type { MenuItemProps } from '@affine/component';
import { Menu, MenuItem, usePromptModal } from '@affine/component';
import { useDeleteCollectionInfo } from '@affine/core/components/hooks/affine/use-delete-collection-info';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
import { WorkbenchService } from '@affine/core/modules/workbench';
import type { Collection } from '@affine/env/filter';
import { useI18n } from '@affine/i18n';
import {
DeleteIcon,
@@ -18,7 +16,10 @@ import { useLiveData, useService, useServices } from '@toeverything/infra';
import type { PropsWithChildren, ReactElement } from 'react';
import { useCallback, useMemo } from 'react';
import { CollectionService } from '../../../modules/collection';
import {
type Collection,
CollectionService,
} from '../../../modules/collection';
import { IsFavoriteIcon } from '../../pure/icons';
import * as styles from './collection-operations.css';
@@ -41,7 +42,6 @@ export const CollectionOperations = ({
WorkbenchService,
WorkspaceDialogService,
});
const deleteInfo = useDeleteCollectionInfo();
const workbench = workbenchService.workbench;
const t = useI18n();
const { openPromptModal } = usePromptModal();
@@ -63,10 +63,9 @@ export const CollectionOperations = ({
variant: 'primary',
},
onConfirm(name) {
service.updateCollection(collection.id, () => ({
...collection,
service.updateCollection(collection.id, {
name,
}));
});
},
});
}, [openRenameModal, openPromptModal, t, service, collection]);
@@ -160,7 +159,7 @@ export const CollectionOperations = ({
icon: <DeleteIcon />,
name: t['Delete'](),
click: () => {
service.deleteCollection(deleteInfo, collection.id);
service.deleteCollection(collection.id);
},
type: 'danger',
},
@@ -175,7 +174,6 @@ export const CollectionOperations = ({
openCollectionNewTab,
openCollectionSplitView,
service,
deleteInfo,
collection.id,
]
);
@@ -1,5 +1,4 @@
export * from './affine-shape';
export * from './collection-list';
export * from './collection-operations';
export * from './create-collection';
export * from './save-as-collection-button';
@@ -1,15 +1,12 @@
import { Button, usePromptModal } from '@affine/component';
import type { Collection } from '@affine/env/filter';
import { useI18n } from '@affine/i18n';
import { SaveIcon } from '@blocksuite/icons/rc';
import { nanoid } from 'nanoid';
import { useCallback } from 'react';
import { createEmptyCollection } from '../use-collection-manager';
import * as styles from './save-as-collection-button.css';
interface SaveAsCollectionButtonProps {
onConfirm: (collection: Collection) => void;
onConfirm: (collectionName: string) => void;
}
export const SaveAsCollectionButton = ({
@@ -35,7 +32,7 @@ export const SaveAsCollectionButton = ({
variant: 'primary',
},
onConfirm(name) {
onConfirm(createEmptyCollection(nanoid(), { name }));
onConfirm(name);
},
});
}, [openPromptModal, t, onConfirm]);
@@ -1,4 +1,4 @@
import type { Collection } from '@affine/env/filter';
import type { Collection } from '@affine/core/modules/collection';
import { useI18n } from '@affine/i18n';
import { DeleteIcon, FilterIcon } from '@blocksuite/icons/rc';
import type { ReactNode } from 'react';
@@ -1,11 +1,12 @@
import { toast, useConfirmModal } from '@affine/component';
import { useBlockSuiteMetaHelper } from '@affine/core/components/hooks/affine/use-block-suite-meta-helper';
import { useBlockSuiteDocMeta } from '@affine/core/components/hooks/use-block-suite-page-meta';
import { DocsService } from '@affine/core/modules/doc';
import { GuardService } from '@affine/core/modules/permissions';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { Trans, useI18n } from '@affine/i18n';
import type { DocMeta } from '@blocksuite/affine/store';
import { useService } from '@toeverything/infra';
import { LiveData, useLiveData, useService } from '@toeverything/infra';
import { useCallback, useMemo, useRef, useState } from 'react';
import { ListFloatingToolbar } from './components/list-floating-toolbar';
@@ -14,7 +15,6 @@ import { TrashOperationCell } from './operation-cell';
import { PageListItemRenderer } from './page-group';
import { ListTableHeader } from './page-header';
import type { ItemListHandle, ListItem } from './types';
import { useFilteredPageMetas } from './use-filtered-page-metas';
import { VirtualizedList } from './virtualized-list';
export const VirtualizedTrashList = ({
@@ -25,13 +25,17 @@ export const VirtualizedTrashList = ({
disableMultiRestore?: boolean;
}) => {
const currentWorkspace = useService(WorkspaceService).workspace;
const docsService = useService(DocsService);
const guardService = useService(GuardService);
const docCollection = currentWorkspace.docCollection;
const { restoreFromTrash, permanentlyDeletePage } = useBlockSuiteMetaHelper();
const allTrashPageIds = useLiveData(
LiveData.from(docsService.allTrashDocIds$(), [])
);
const pageMetas = useBlockSuiteDocMeta(docCollection);
const filteredPageMetas = useFilteredPageMetas(pageMetas, {
trash: true,
});
const filteredPageMetas = useMemo(() => {
return pageMetas.filter(page => allTrashPageIds.includes(page.id));
}, [pageMetas, allTrashPageIds]);
const listRef = useRef<ItemListHandle>(null);
const [showFloatingToolbar, setShowFloatingToolbar] = useState(false);
@@ -83,7 +83,7 @@ const AccountMenu = () => {
const openSignOutModal = useSignOut();
const serverService = useService(ServerService);
const userFeatureService = useService(UserFeatureService);
const isAdmin = useLiveData(userFeatureService.userFeature.isAdmin$);
const isAFFiNEAdmin = useLiveData(userFeatureService.userFeature.isAdmin$);
const onOpenAccountSetting = useCallback(() => {
track.$.navigationPanel.profileAndBadge.openSettings({ to: 'account' });
@@ -111,7 +111,7 @@ const AccountMenu = () => {
>
{t['com.affine.workspace.cloud.account.settings']()}
</MenuItem>
{isAdmin ? (
{isAFFiNEAdmin ? (
<MenuItem
prefixIcon={<AdminIcon />}
data-testid="workspace-modal-account-admin-option"
@@ -0,0 +1,43 @@
import { Menu, MenuItem } from '@affine/component';
import type { FilterParams } from '@affine/core/modules/collection-rules';
export const FavoriteFilterValue = ({
filter,
onChange,
}: {
filter: FilterParams;
onChange: (filter: FilterParams) => void;
}) => {
return (
<Menu
items={
<>
<MenuItem
onClick={() => {
onChange({
...filter,
value: 'true',
});
}}
selected={filter.value === 'true'}
>
{'True'}
</MenuItem>
<MenuItem
onClick={() => {
onChange({
...filter,
value: 'false',
});
}}
selected={filter.value !== 'true'}
>
{'False'}
</MenuItem>
</>
}
>
<span>{filter.value === 'true' ? 'True' : 'False'}</span>
</Menu>
);
};
@@ -1,7 +1,9 @@
import type { FilterParams } from '@affine/core/modules/collection-rules';
import type { I18nString } from '@affine/i18n';
import { TagIcon } from '@blocksuite/icons/rc';
import { FavoriteIcon, ShareIcon, TagIcon } from '@blocksuite/icons/rc';
import { FavoriteFilterValue } from './favorite';
import { SharedFilterValue } from './shared';
import { TagsFilterValue } from './tags';
export const SystemPropertyTypes = {
@@ -15,6 +17,22 @@ export const SystemPropertyTypes = {
},
filterValue: TagsFilterValue,
},
favorite: {
icon: FavoriteIcon,
name: 'Favorited',
filterMethod: {
is: 'com.affine.filter.is',
},
filterValue: FavoriteFilterValue,
},
shared: {
icon: ShareIcon,
name: 'Shared',
filterMethod: {
is: 'com.affine.filter.is',
},
filterValue: SharedFilterValue,
},
} satisfies {
[type: string]: {
icon: React.FC<React.SVGProps<SVGSVGElement>>;
@@ -0,0 +1,43 @@
import { Menu, MenuItem } from '@affine/component';
import type { FilterParams } from '@affine/core/modules/collection-rules';
export const SharedFilterValue = ({
filter,
onChange,
}: {
filter: FilterParams;
onChange: (filter: FilterParams) => void;
}) => {
return (
<Menu
items={
<>
<MenuItem
onClick={() => {
onChange({
...filter,
value: 'true',
});
}}
selected={filter.value === 'true'}
>
{'True'}
</MenuItem>
<MenuItem
onClick={() => {
onChange({
...filter,
value: 'false',
});
}}
selected={filter.value !== 'true'}
>
{'False'}
</MenuItem>
</>
}
>
<span>{filter.value === 'true' ? 'True' : 'False'}</span>
</Menu>
);
};
@@ -102,7 +102,10 @@ export const WorkspacePropertyTypes = {
renameable: false,
description: 'com.affine.page-properties.property.tags.tooltips',
filterMethod: {
include: 'com.affine.filter.contains all',
'include-all': 'com.affine.filter.contains all',
'include-any-of': 'com.affine.filter.contains one of',
'not-include-all': 'com.affine.filter.does not contains all',
'not-include-any-of': 'com.affine.filter.does not contains one of',
'is-not-empty': 'com.affine.filter.is not empty',
'is-empty': 'com.affine.filter.is empty',
},
@@ -5,26 +5,17 @@ import {
MenuItem,
toast,
} from '@affine/component';
import { filterPage } from '@affine/core/components/page-list';
import { CollectionService } from '@affine/core/modules/collection';
import {
type Collection,
CollectionService,
} from '@affine/core/modules/collection';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { DocsService } from '@affine/core/modules/doc';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
import { GlobalContextService } from '@affine/core/modules/global-context';
import { ShareDocsListService } from '@affine/core/modules/share-doc';
import type { AffineDNDData } from '@affine/core/types/dnd';
import type { Collection } from '@affine/env/filter';
import { PublicDocMode } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';
import type { DocMeta } from '@blocksuite/affine/store';
import { FilterMinusIcon } from '@blocksuite/icons/rc';
import {
LiveData,
useLiveData,
useService,
useServices,
} from '@toeverything/infra';
import { useLiveData, useService, useServices } from '@toeverything/infra';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
@@ -71,6 +62,7 @@ export const NavigationPanelCollectionNode = ({
const collectionService = useService(CollectionService);
const collection = useLiveData(collectionService.collection$(collectionId));
const name = useLiveData(collection?.name$);
const dndData = useMemo(() => {
return {
@@ -89,11 +81,10 @@ export const NavigationPanelCollectionNode = ({
const handleRename = useCallback(
(name: string) => {
if (collection && collection.name !== name) {
collectionService.updateCollection(collectionId, () => ({
...collection,
if (collection && collection.name$.value !== name) {
collectionService.updateCollection(collectionId, {
name,
}));
});
track.$.navigationPanel.organize.renameOrganizeItem({
type: 'collection',
@@ -109,10 +100,10 @@ export const NavigationPanelCollectionNode = ({
if (!collection) {
return;
}
if (collection.allowList.includes(docId)) {
if (collection.allowList$.value.includes(docId)) {
toast(t['com.affine.collection.addPage.alreadyExists']());
} else {
collectionService.addPageToCollection(collection.id, docId);
collectionService.addDocToCollection(collection.id, docId);
}
},
[collection, collectionService, t]
@@ -210,7 +201,7 @@ export const NavigationPanelCollectionNode = ({
return (
<NavigationPanelTreeNode
icon={CollectionIcon}
name={collection.name || t['Untitled']()}
name={name || t['Untitled']()}
dndData={dndData}
onDrop={handleDropOnCollection}
renameable
@@ -237,84 +228,51 @@ const NavigationPanelCollectionNodeChildren = ({
collection: Collection;
}) => {
const t = useI18n();
const {
docsService,
compatibleFavoriteItemsAdapter,
shareDocsListService,
collectionService,
} = useServices({
DocsService,
CompatibleFavoriteItemsAdapter,
ShareDocsListService,
const { collectionService } = useServices({
CollectionService,
});
useEffect(() => {
// TODO(@eyhn): loading & error UI
shareDocsListService.shareDocs?.revalidate();
}, [shareDocsListService]);
const docMetas = useLiveData(
useMemo(
() =>
LiveData.computed(get => {
return get(docsService.list.docs$).map(
doc => get(doc.meta$) as DocMeta
);
}),
[docsService]
)
const allowList = useLiveData(
collection.allowList$.map(list => new Set(list))
);
const favourites = useLiveData(compatibleFavoriteItemsAdapter.favorites$);
const allowList = useMemo(
() => new Set(collection.allowList),
[collection.allowList]
);
const shareDocs = useLiveData(shareDocsListService.shareDocs?.list$);
const handleRemoveFromAllowList = useCallback(
(id: string) => {
track.$.navigationPanel.collections.removeOrganizeItem({ type: 'doc' });
collectionService.deletePageFromCollection(collection.id, id);
collectionService.removeDocFromCollection(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 === PublicDocMode.Edgeless
? ('edgeless' as const)
: publicMode === PublicDocMode.Page
? ('page' as const)
: undefined,
favorite: favourites.some(fav => fav.id === meta.id),
};
return filterPage(collection, pageData);
});
const [filteredDocIds, setFilteredDocIds] = useState<string[]>([]);
return filtered.map(doc => (
useEffect(() => {
const subscription = collection.watch().subscribe(docIds => {
setFilteredDocIds(docIds);
});
return () => subscription.unsubscribe();
}, [collection]);
return filteredDocIds.map(docId => (
<NavigationPanelDocNode
key={doc.id}
docId={doc.id}
key={docId}
docId={docId}
reorderable={false}
location={{
at: 'navigation-panel:collection:filtered-docs',
collectionId: collection.id,
}}
operations={
allowList
allowList.has(docId)
? [
{
index: 99,
view: (
<MenuItem
prefixIcon={<FilterMinusIcon />}
onClick={() => handleRemoveFromAllowList(doc.id)}
onClick={() => handleRemoveFromAllowList(docId)}
>
{t['Remove special filter']()}
</MenuItem>
@@ -5,7 +5,6 @@ import {
useConfirmModal,
} from '@affine/component';
import { usePageHelper } from '@affine/core/blocksuite/block-suite-page-list/utils';
import { useDeleteCollectionInfo } from '@affine/core/components/hooks/affine/use-delete-collection-info';
import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
import { CollectionService } from '@affine/core/modules/collection';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
@@ -42,7 +41,6 @@ export const useNavigationPanelCollectionNodeOperations = (
CollectionService,
CompatibleFavoriteItemsAdapter,
});
const deleteInfo = useDeleteCollectionInfo();
const { createPage } = usePageHelper(
workspaceService.workspace.docCollection
@@ -59,7 +57,7 @@ export const useNavigationPanelCollectionNodeOperations = (
const createAndAddDocument = useCallback(() => {
const newDoc = createPage();
collectionService.addPageToCollection(collectionId, newDoc.id);
collectionService.addDocToCollection(collectionId, newDoc.id);
track.$.navigationPanel.collections.createDoc();
track.$.navigationPanel.collections.addDocToCollection({
control: 'button',
@@ -100,11 +98,11 @@ export const useNavigationPanelCollectionNodeOperations = (
}, [collectionId, workbenchService.workbench]);
const handleDeleteCollection = useCallback(() => {
collectionService.deleteCollection(deleteInfo, collectionId);
collectionService.deleteCollection(collectionId);
track.$.navigationPanel.organize.deleteOrganizeItem({
type: 'collection',
});
}, [collectionId, collectionService, deleteInfo]);
}, [collectionId, collectionService]);
const handleShowEdit = useCallback(() => {
onOpenEdit();
@@ -1,5 +1,4 @@
import { IconButton, usePromptModal } from '@affine/component';
import { createEmptyCollection } from '@affine/core/components/page-list/use-collection-manager';
import { CollectionService } from '@affine/core/modules/collection';
import { NavigationPanelService } from '@affine/core/modules/navigation-panel';
import { WorkbenchService } from '@affine/core/modules/workbench';
@@ -7,7 +6,6 @@ import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';
import { AddCollectionIcon } from '@blocksuite/icons/rc';
import { useLiveData, useServices } from '@toeverything/infra';
import { nanoid } from 'nanoid';
import { useCallback } from 'react';
import { CollapsibleSection } from '../../layouts/collapsible-section';
@@ -46,8 +44,7 @@ export const NavigationPanelCollections = () => {
variant: 'primary',
},
onConfirm(name) {
const id = nanoid();
collectionService.addCollection(createEmptyCollection(id, { name }));
const id = collectionService.createCollection({ name });
track.$.navigationPanel.organize.createOrganizeItem({
type: 'collection',
});
@@ -84,7 +81,7 @@ export const NavigationPanelCollections = () => {
<NavigationPanelTreeRoot
placeholder={<RootEmpty onClickCreate={handleCreateCollection} />}
>
{collections.map(collection => (
{Array.from(collections.values()).map(collection => (
<NavigationPanelCollectionNode
key={collection.id}
collectionId={collection.id}
@@ -1,7 +1,7 @@
import { Button, RadioGroup } from '@affine/component';
import { useAllPageListConfig } from '@affine/core/components/hooks/affine/use-all-page-list-config';
import { SelectPage } from '@affine/core/components/page-list/docs/select-page';
import type { Collection } from '@affine/env/filter';
import type { CollectionInfo } from '@affine/core/modules/collection';
import { useI18n } from '@affine/i18n';
import { useCallback, useMemo, useState } from 'react';
@@ -12,10 +12,10 @@ export type EditCollectionMode = 'page' | 'rule';
export interface EditCollectionProps {
onConfirmText?: string;
init: Collection;
init: CollectionInfo;
mode?: EditCollectionMode;
onCancel: () => void;
onConfirm: (collection: Collection) => void;
onConfirm: (collection: CollectionInfo) => void;
}
export const EditCollection = ({
@@ -27,9 +27,9 @@ export const EditCollection = ({
}: EditCollectionProps) => {
const t = useI18n();
const config = useAllPageListConfig();
const [value, onChange] = useState<Collection>(init);
const [value, onChange] = useState<CollectionInfo>(init);
const [mode, setMode] = useState<'page' | 'rule'>(
initMode ?? (init.filterList.length === 0 ? 'page' : 'rule')
initMode ?? (init.rules.filters.length === 0 ? 'page' : 'rule')
);
const isNameEmpty = useMemo(() => value.name.trim().length === 0, [value]);
const onSaveCollection = useCallback(() => {
@@ -40,10 +40,10 @@ export const EditCollection = ({
const reset = useCallback(() => {
onChange({
...value,
filterList: init.filterList,
rules: init.rules,
allowList: init.allowList,
});
}, [init.allowList, init.filterList, value]);
}, [init, value]);
const onIdsChange = useCallback(
(ids: string[]) => {
onChange({ ...value, allowList: ids });
@@ -1,8 +1,10 @@
import { Modal } from '@affine/component';
import { CollectionService } from '@affine/core/modules/collection';
import {
type CollectionInfo,
CollectionService,
} from '@affine/core/modules/collection';
import type { DialogComponentProps } from '@affine/core/modules/dialogs';
import type { WORKSPACE_DIALOG_SCHEMA } from '@affine/core/modules/dialogs/constant';
import type { Collection } from '@affine/env/filter';
import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback } from 'react';
@@ -18,17 +20,18 @@ export const CollectionEditorDialog = ({
const collectionService = useService(CollectionService);
const collection = useLiveData(collectionService.collection$(collectionId));
const onConfirmOnCollection = useCallback(
(collection: Collection) => {
collectionService.updateCollection(collection.id, () => collection);
(collection: CollectionInfo) => {
collectionService.updateCollection(collection.id, collection);
close();
},
[close, collectionService]
);
const info = useLiveData(collection?.info$);
const onCancel = useCallback(() => {
close();
}, [close]);
if (!collection) {
if (!collection || !info) {
return null;
}
@@ -50,7 +53,7 @@ export const CollectionEditorDialog = ({
>
<EditCollection
onConfirmText={t['com.affine.editCollection.save']()}
init={collection}
init={info}
mode={mode}
onCancel={onCancel}
onConfirm={onConfirmOnCollection}
@@ -1,16 +1,15 @@
import { Button, IconButton, Tooltip } from '@affine/component';
import { Filters } from '@affine/core/components/filter';
import type { AllPageListConfig } from '@affine/core/components/hooks/affine/use-all-page-list-config';
import {
AffineShapeIcon,
FilterList,
filterPageByRules,
List,
type ListItem,
ListScrollContainer,
} from '@affine/core/components/page-list';
import type { CollectionInfo } from '@affine/core/modules/collection';
import { CollectionRulesService } from '@affine/core/modules/collection-rules';
import { DocsService } from '@affine/core/modules/doc';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
import type { Collection } from '@affine/env/filter';
import { Trans, useI18n } from '@affine/i18n';
import type { DocMeta } from '@blocksuite/affine/store';
import {
@@ -19,11 +18,11 @@ import {
PageIcon,
ToggleRightIcon,
} from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { useService } from '@toeverything/infra';
import { cssVar } from '@toeverything/theme';
import clsx from 'clsx';
import type { ReactNode } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import * as styles from './edit-collection.css';
@@ -35,8 +34,8 @@ export const RulesMode = ({
switchMode,
allPageListConfig,
}: {
collection: Collection;
updateCollection: (collection: Collection) => void;
collection: CollectionInfo;
updateCollection: (collection: CollectionInfo) => void;
reset: () => void;
buttons: ReactNode;
switchMode: ReactNode;
@@ -44,30 +43,50 @@ export const RulesMode = ({
}) => {
const t = useI18n();
const [showPreview, setShowPreview] = useState(true);
const allowListPages: DocMeta[] = [];
const rulesPages: DocMeta[] = [];
const docsService = useService(DocsService);
const favAdapter = useService(CompatibleFavoriteItemsAdapter);
const favorites = useLiveData(favAdapter.favorites$);
allPageListConfig.allPages.forEach(meta => {
if (meta.trash) {
return;
}
const pageData = {
meta,
publicMode: allPageListConfig.getPublicMode(meta.id),
favorite: favorites.some(f => f.id === meta.id),
const collectionRulesService = useService(CollectionRulesService);
const [rulesPageIds, setRulesPageIds] = useState<string[]>([]);
useEffect(() => {
const subscription = collectionRulesService
.watch(
collection.rules.filters.length > 0
? [
...collection.rules.filters,
{
type: 'system',
key: 'trash',
method: 'is',
value: 'false',
},
]
: [],
undefined,
undefined
)
.subscribe(rules => {
setRulesPageIds(rules.groups.flatMap(group => group.items));
});
return () => {
subscription.unsubscribe();
};
if (
collection.filterList.length &&
filterPageByRules(collection.filterList, [], pageData)
) {
rulesPages.push(meta);
}
if (collection.allowList.includes(meta.id)) {
allowListPages.push(meta);
}
});
}, [collection, collectionRulesService]);
const rulesPages = useMemo(() => {
return allPageListConfig.allPages.filter(meta => {
return rulesPageIds.includes(meta.id);
});
}, [allPageListConfig.allPages, rulesPageIds]);
const allowListPages = useMemo(() => {
return allPageListConfig.allPages.filter(meta => {
return (
collection.allowList.includes(meta.id) &&
!rulesPageIds.includes(meta.id)
);
});
}, [allPageListConfig.allPages, collection.allowList, rulesPageIds]);
const [expandInclude, setExpandInclude] = useState(
collection.allowList.length > 0
);
@@ -113,13 +132,17 @@ export const RulesMode = ({
overflowY: 'auto',
}}
>
<FilterList
propertiesMeta={allPageListConfig.docCollection.meta.properties}
value={collection.filterList}
onChange={useCallback(
filterList => updateCollection({ ...collection, filterList }),
[collection, updateCollection]
)}
<Filters
filters={collection.rules.filters}
onChange={filters => {
updateCollection({
...collection,
rules: {
...collection.rules,
filters,
},
});
}}
/>
<div className={styles.rulesContainerLeftContentInclude}>
{collection.allowList.length > 0 ? (
@@ -215,7 +238,7 @@ export const RulesMode = ({
></List>
) : (
<RulesEmpty
noRules={collection.filterList.length === 0}
noRules={collection.rules.filters.length === 0}
fullHeight={allowListPages.length === 0}
/>
)}
@@ -2,14 +2,16 @@ import { Modal, toast } from '@affine/component';
import {
collectionHeaderColsDef,
CollectionListItemRenderer,
type CollectionMeta,
FavoriteTag,
type ListItem,
ListTableHeader,
VirtualizedList,
} from '@affine/core/components/page-list';
import { SelectorLayout } from '@affine/core/components/page-list/selector/selector-layout';
import { CollectionService } from '@affine/core/modules/collection';
import {
type CollectionMeta,
CollectionService,
} from '@affine/core/modules/collection';
import type { DialogComponentProps } from '@affine/core/modules/dialogs';
import type { WORKSPACE_DIALOG_SCHEMA } from '@affine/core/modules/dialogs/constant';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
@@ -52,22 +54,15 @@ export const CollectionSelectorDialog = ({
const collectionService = useService(CollectionService);
const workspace = useService(WorkspaceService).workspace;
const collections = useLiveData(collectionService.collections$);
const collections = useLiveData(collectionService.collectionMetas$);
const [selection, setSelection] = useState(selectedCollectionIds);
const [keyword, setKeyword] = useState('');
const collectionMetas = useMemo(() => {
const collectionsList: CollectionMeta[] = collections
.map(collection => {
return {
...collection,
title: collection.name,
};
})
.filter(meta => {
const reg = new RegExp(keyword, 'i');
return reg.test(meta.title);
});
const collectionsList: CollectionMeta[] = collections.filter(meta => {
const reg = new RegExp(keyword, 'i');
return reg.test(meta.title);
});
return collectionsList;
}, [collections, keyword]);
@@ -58,7 +58,7 @@ export const EdgelessSnapshot = (props: Props) => {
const extensions = useMemo(() => {
const manager = getViewManager()
.config.init()
.common(framework)
.foundation(framework)
.theme(framework)
.database(framework)
.linkedDoc(framework).value;

Some files were not shown because too many files have changed in this diff Show More