refactor(editor): reorg blocksuite ai code (#10372)

### TL;DR

Relocated AI-related code from `presets` directory to a dedicated `ai` directory for better organization and maintainability.

### What changed?

- Moved AI-related code from `blocksuite/presets/ai` to `blocksuite/ai`
- Relocated AI chat block code from `blocksuite/blocks` to `blocksuite/ai/blocks`
- Updated imports across files to reflect new directory structure
- Renamed `registerBlocksuitePresetsCustomComponents` to `registerAIEffects`
- Fixed path references in GitHub workflow file

### How to test?

1. Build and run the application
2. Verify AI functionality works as expected:
   - Test AI chat blocks
   - Check AI panel functionality
   - Verify AI copilot features
   - Ensure AI-related UI components render correctly

### Why make this change?

This restructuring improves code organization by:
- Giving AI features a dedicated directory that better reflects their importance
- Making the codebase more maintainable by grouping related AI functionality
- Reducing confusion by removing AI code from the more general `presets` directory
- Creating clearer boundaries between AI and non-AI related code
This commit is contained in:
Saul-Mirone
2025-02-23 09:26:00 +00:00
parent 542f759ffb
commit eef2f004b8
233 changed files with 254 additions and 286 deletions
@@ -1,4 +1,4 @@
import { NoPageRootError } from '@affine/core/components/blocksuite/block-suite-editor/no-page-error';
import { NoPageRootError } from '@affine/core/blocksuite/block-suite-editor/no-page-error';
import { useI18n } from '@affine/i18n';
import { ContactUS, ErrorDetail } from '../error-basic/error-detail';
@@ -6,7 +6,7 @@ import { AllDocsIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { type MouseEvent, useCallback } from 'react';
import { usePageHelper } from '../../blocksuite/block-suite-page-list/utils';
import { usePageHelper } from '../../../blocksuite/block-suite-page-list/utils';
import { ActionButton } from './action-button';
import docsIllustrationDark from './assets/docs.dark.png';
import docsIllustrationLight from './assets/docs.light.png';
@@ -32,9 +32,9 @@ import {
} from 'react';
import { encodeStateAsUpdate } from 'yjs';
import { BlockSuiteEditor } from '../../../blocksuite/block-suite-editor';
import { PureEditorModeSwitch } from '../../../blocksuite/block-suite-mode-switch';
import { pageHistoryModalAtom } from '../../atoms/page-history';
import { BlockSuiteEditor } from '../../blocksuite/block-suite-editor';
import { PureEditorModeSwitch } from '../../blocksuite/block-suite-mode-switch';
import { AffineErrorBoundary } from '../affine-error-boundary';
import {
historyListGroupByDay,
@@ -1,349 +0,0 @@
import {
GeneralNetworkError,
PaymentRequiredError,
UnauthorizedError,
} from '@affine/core/blocksuite/presets/ai/_common/components/ai-item/types';
import { showAILoginRequiredAtom } from '@affine/core/components/affine/auth/ai-login-required';
import {
addContextDocMutation,
cleanupCopilotSessionMutation,
createCopilotContextMutation,
createCopilotMessageMutation,
createCopilotSessionMutation,
forkCopilotSessionMutation,
getCopilotHistoriesQuery,
getCopilotHistoryIdsQuery,
getCopilotSessionsQuery,
GraphQLError,
type GraphQLQuery,
listContextDocsAndFilesQuery,
listContextQuery,
type QueryOptions,
type QueryResponse,
removeContextDocMutation,
type RequestOptions,
updateCopilotSessionMutation,
UserFriendlyError,
} from '@affine/graphql';
import { getCurrentStore } from '@toeverything/infra';
type OptionsField<T extends GraphQLQuery> =
RequestOptions<T>['variables'] extends { options: infer U } ? U : never;
function codeToError(error: UserFriendlyError) {
switch (error.status) {
case 401:
return new UnauthorizedError();
case 402:
return new PaymentRequiredError();
default:
return new GeneralNetworkError(
error.code
? `${error.code}: ${error.message}\nIdentify: ${error.name}`
: undefined
);
}
}
export function resolveError(err: any) {
const standardError =
err instanceof GraphQLError
? new UserFriendlyError(err.extensions)
: UserFriendlyError.fromAnyError(err);
return codeToError(standardError);
}
export function handleError(src: any) {
const err = resolveError(src);
if (err instanceof UnauthorizedError) {
getCurrentStore().set(showAILoginRequiredAtom, true);
}
return err;
}
export class CopilotClient {
constructor(
readonly gql: <Query extends GraphQLQuery>(
options: QueryOptions<Query>
) => Promise<QueryResponse<Query>>,
readonly fetcher: (input: string, init?: RequestInit) => Promise<Response>,
readonly eventSource: (
url: string,
eventSourceInitDict?: EventSourceInit
) => EventSource
) {}
async createSession(
options: OptionsField<typeof createCopilotSessionMutation>
) {
try {
const res = await this.gql({
query: createCopilotSessionMutation,
variables: {
options,
},
});
return res.createCopilotSession;
} catch (err) {
throw resolveError(err);
}
}
async updateSession(
options: OptionsField<typeof updateCopilotSessionMutation>
) {
try {
const res = await this.gql({
query: updateCopilotSessionMutation,
variables: {
options,
},
});
return res.updateCopilotSession;
} catch (err) {
throw resolveError(err);
}
}
async forkSession(options: OptionsField<typeof forkCopilotSessionMutation>) {
try {
const res = await this.gql({
query: forkCopilotSessionMutation,
variables: {
options,
},
});
return res.forkCopilotSession;
} catch (err) {
throw resolveError(err);
}
}
async createMessage(
options: OptionsField<typeof createCopilotMessageMutation>
) {
try {
const res = await this.gql({
query: createCopilotMessageMutation,
variables: {
options,
},
});
return res.createCopilotMessage;
} catch (err) {
throw resolveError(err);
}
}
async getSessionIds(
workspaceId: string,
docId?: string,
options?: RequestOptions<
typeof getCopilotSessionsQuery
>['variables']['options']
) {
try {
const res = await this.gql({
query: getCopilotSessionsQuery,
variables: {
workspaceId,
docId,
options,
},
});
return res.currentUser?.copilot?.sessionIds;
} catch (err) {
throw resolveError(err);
}
}
async getHistories(
workspaceId: string,
docId?: string,
options?: RequestOptions<
typeof getCopilotHistoriesQuery
>['variables']['options']
) {
try {
const res = await this.gql({
query: getCopilotHistoriesQuery,
variables: {
workspaceId,
docId,
options,
},
});
return res.currentUser?.copilot?.histories;
} catch (err) {
throw resolveError(err);
}
}
async getHistoryIds(
workspaceId: string,
docId?: string,
options?: RequestOptions<
typeof getCopilotHistoriesQuery
>['variables']['options']
) {
try {
const res = await this.gql({
query: getCopilotHistoryIdsQuery,
variables: {
workspaceId,
docId,
options,
},
});
return res.currentUser?.copilot?.histories;
} catch (err) {
throw resolveError(err);
}
}
async cleanupSessions(input: {
workspaceId: string;
docId: string;
sessionIds: string[];
}) {
try {
const res = await this.gql({
query: cleanupCopilotSessionMutation,
variables: {
input,
},
});
return res.cleanupCopilotSession;
} catch (err) {
throw resolveError(err);
}
}
async createContext(workspaceId: string, sessionId: string) {
const res = await this.gql({
query: createCopilotContextMutation,
variables: {
workspaceId,
sessionId,
},
});
return res.createCopilotContext;
}
async getContextId(workspaceId: string, sessionId: string) {
const res = await this.gql({
query: listContextQuery,
variables: {
workspaceId,
sessionId,
},
});
return res.currentUser?.copilot?.contexts?.[0]?.id;
}
async addContextDoc(options: OptionsField<typeof addContextDocMutation>) {
const res = await this.gql({
query: addContextDocMutation,
variables: {
options,
},
});
return res.addContextDoc;
}
async removeContextDoc(
options: OptionsField<typeof removeContextDocMutation>
) {
const res = await this.gql({
query: removeContextDocMutation,
variables: {
options,
},
});
return res.removeContextDoc;
}
async addContextFile() {
return;
}
async removeContextFile() {
return;
}
async getContextDocsAndFiles(
workspaceId: string,
sessionId: string,
contextId: string
) {
const res = await this.gql({
query: listContextDocsAndFilesQuery,
variables: {
workspaceId,
sessionId,
contextId,
},
});
return res.currentUser?.copilot?.contexts?.[0];
}
async chatText({
sessionId,
messageId,
signal,
}: {
sessionId: string;
messageId?: string;
signal?: AbortSignal;
}) {
let url = `/api/copilot/chat/${sessionId}`;
if (messageId) {
url += `?messageId=${encodeURIComponent(messageId)}`;
}
const response = await this.fetcher(url.toString(), { signal });
return response.text();
}
// Text or image to text
chatTextStream(
{
sessionId,
messageId,
}: {
sessionId: string;
messageId?: string;
},
endpoint = 'stream'
) {
let url = `/api/copilot/chat/${sessionId}/${endpoint}`;
if (messageId) {
url += `?messageId=${encodeURIComponent(messageId)}`;
}
return this.eventSource(url);
}
// Text or image to images
imagesStream(
sessionId: string,
messageId?: string,
seed?: string,
endpoint = 'images'
) {
let url = `/api/copilot/chat/${sessionId}/${endpoint}`;
if (messageId || seed) {
url += '?';
url += new URLSearchParams(
Object.fromEntries(
Object.entries({ messageId, seed }).filter(
([_, v]) => v !== undefined
)
) as Record<string, string>
).toString();
}
return this.eventSource(url);
}
}
@@ -1,105 +0,0 @@
import { handleError } from './copilot-client';
export function delay(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
export type AffineTextEvent = {
type: 'attachment' | 'message';
data: string;
};
type AffineTextStream = AsyncIterable<AffineTextEvent>;
type toTextStreamOptions = {
timeout?: number;
signal?: AbortSignal;
};
// todo(@Peng): may need to extend the error type
const safeParseError = (data: string): { status: number } => {
try {
return JSON.parse(data);
} catch {
return {
status: 500,
};
}
};
export function toTextStream(
eventSource: EventSource,
{ timeout, signal }: toTextStreamOptions = {}
): AffineTextStream {
return {
[Symbol.asyncIterator]: async function* () {
const messageQueue: AffineTextEvent[] = [];
let resolveMessagePromise: () => void;
let rejectMessagePromise: (err: Error) => void;
function resetMessagePromise() {
if (resolveMessagePromise) {
resolveMessagePromise();
}
return new Promise<void>((resolve, reject) => {
resolveMessagePromise = resolve;
rejectMessagePromise = reject;
});
}
let messagePromise = resetMessagePromise();
function messageListener(event: MessageEvent) {
messageQueue.push({
type: event.type as 'attachment' | 'message',
data: event.data as string,
});
messagePromise = resetMessagePromise();
}
eventSource.addEventListener('message', messageListener);
eventSource.addEventListener('attachment', messageListener);
eventSource.addEventListener('error', event => {
const errorMessage = (event as unknown as { data: string }).data;
// if there is data in Error event, it means the server sent an error message
// otherwise, the stream is finished successfully
if (event.type === 'error' && errorMessage) {
// try to parse the error message as a JSON object
const error = safeParseError(errorMessage);
rejectMessagePromise(handleError(error));
} else {
resolveMessagePromise();
}
eventSource.close();
});
try {
while (
eventSource.readyState !== EventSource.CLOSED &&
!signal?.aborted
) {
if (messageQueue.length === 0) {
// Wait for the next message or timeout
await (timeout
? Promise.race([
messagePromise,
delay(timeout).then(() => {
if (!signal?.aborted) {
throw new Error('Timeout');
}
}),
])
: messagePromise);
} else if (messageQueue.length > 0) {
const top = messageQueue.shift();
if (top) {
yield top;
}
}
}
} finally {
eventSource.close();
}
},
};
}
@@ -1,46 +0,0 @@
// manually synced with packages/backend/server/src/data/migrations/utils/prompts.ts
// TODO(@Peng): automate this
export const promptKeys = [
'debug:chat:gpt4',
'debug:action:dalle3',
'debug:action:fal-sd15',
'debug:action:fal-upscaler',
'debug:action:fal-remove-bg',
'debug:action:fal-face-to-sticker',
'Chat With AFFiNE AI',
'Search With AFFiNE AI',
'Summary',
'Generate a caption',
'Summary the webpage',
'Explain this',
'Explain this image',
'Explain this code',
'Translate to',
'Write an article about this',
'Write a twitter about this',
'Write a poem about this',
'Write a blog post about this',
'Write outline',
'Change tone to',
'Brainstorm ideas about this',
'Expand mind map',
'Improve writing for it',
'Improve grammar for it',
'Fix spelling for it',
'Find action items from it',
'Check code error',
'Create headings',
'Make it real',
'Make it real with text',
'Make it longer',
'Make it shorter',
'Continue writing',
'workflow:presentation',
'workflow:brainstorm',
'workflow:image-sketch',
'workflow:image-clay',
'workflow:image-anime',
'workflow:image-pixel',
] as const;
export type PromptKey = (typeof promptKeys)[number];
@@ -1,313 +0,0 @@
import { AIProvider } from '@affine/core/blocksuite/presets';
import { assertExists } from '@blocksuite/affine/global/utils';
import { partition } from 'lodash-es';
import type { CopilotClient } from './copilot-client';
import { delay, toTextStream } from './event-source';
import type { PromptKey } from './prompt';
const TIMEOUT = 50000;
export type TextToTextOptions = {
client: CopilotClient;
docId: string;
workspaceId: string;
promptName?: PromptKey;
sessionId?: string | Promise<string>;
content?: string;
attachments?: (string | Blob | File)[];
params?: Record<string, any>;
timeout?: number;
stream?: boolean;
signal?: AbortSignal;
retry?: boolean;
workflow?: boolean;
isRootSession?: boolean;
postfix?: (text: string) => string;
};
export type ToImageOptions = TextToTextOptions & {
seed?: string;
};
async function resizeImage(blob: Blob | File): Promise<Blob | null> {
let src = '';
try {
src = URL.createObjectURL(blob);
const img = new Image();
img.src = src;
await new Promise(resolve => {
img.onload = resolve;
});
const canvas = document.createElement('canvas');
// keep aspect ratio
const scale = Math.min(1024 / img.width, 1024 / img.height);
canvas.width = Math.floor(img.width * scale);
canvas.height = Math.floor(img.height * scale);
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
return await new Promise(resolve =>
canvas.toBlob(blob => resolve(blob), 'image/jpeg', 0.8)
);
}
} catch (e) {
console.error(e);
} finally {
if (src) URL.revokeObjectURL(src);
}
return null;
}
async function createSessionMessage({
client,
docId,
workspaceId,
promptName = 'Chat With AFFiNE AI',
content,
sessionId: providedSessionId,
attachments,
params,
retry = false,
}: TextToTextOptions) {
if (!promptName && !providedSessionId) {
throw new Error('promptName or sessionId is required');
}
const hasAttachments = attachments && attachments.length > 0;
const sessionId = await (providedSessionId ??
client.createSession({
workspaceId,
docId,
promptName,
}));
const options: Parameters<CopilotClient['createMessage']>[0] = {
sessionId,
content,
params,
};
if (hasAttachments) {
const [stringAttachments, blobs] = partition(
attachments,
attachment => typeof attachment === 'string'
) as [string[], (Blob | File)[]];
options.attachments = stringAttachments;
options.blobs = (
await Promise.all(
blobs.map(resizeImage).map(async blob => {
const file = await blob;
if (!file) return null;
return new File([file], sessionId, {
type: file.type,
});
})
)
).filter(Boolean) as File[];
}
if (retry)
return {
sessionId,
};
const messageId = await client.createMessage(options);
return {
messageId,
sessionId,
};
}
export function textToText({
client,
docId,
workspaceId,
promptName,
content,
attachments,
params,
sessionId,
stream,
signal,
timeout = TIMEOUT,
retry = false,
workflow = false,
isRootSession = false,
postfix,
}: TextToTextOptions) {
let _sessionId: string;
let _messageId: string | undefined;
if (stream) {
return {
[Symbol.asyncIterator]: async function* () {
if (retry) {
const retrySessionId =
(await sessionId) ?? AIProvider.LAST_ACTION_SESSIONID;
assertExists(retrySessionId, 'retry sessionId is required');
_sessionId = retrySessionId;
_messageId = undefined;
} else {
const message = await createSessionMessage({
client,
docId,
workspaceId,
promptName,
content,
attachments,
params,
sessionId,
retry,
});
_sessionId = message.sessionId;
_messageId = message.messageId;
}
const eventSource = client.chatTextStream(
{
sessionId: _sessionId,
messageId: _messageId,
},
workflow ? 'workflow' : undefined
);
AIProvider.LAST_ACTION_SESSIONID = _sessionId;
if (isRootSession) {
AIProvider.LAST_ROOT_SESSION_ID = _sessionId;
}
if (signal) {
if (signal.aborted) {
eventSource.close();
return;
}
signal.onabort = () => {
eventSource.close();
};
}
if (postfix) {
const messages: string[] = [];
for await (const event of toTextStream(eventSource, {
timeout,
signal,
})) {
if (event.type === 'message') {
messages.push(event.data);
}
}
yield postfix(messages.join(''));
} else {
for await (const event of toTextStream(eventSource, {
timeout,
signal,
})) {
if (event.type === 'message') {
yield event.data;
}
}
}
},
};
} else {
return Promise.race([
timeout
? delay(timeout).then(() => {
throw new Error('Timeout');
})
: null,
(async function () {
if (retry) {
const retrySessionId =
(await sessionId) ?? AIProvider.LAST_ACTION_SESSIONID;
assertExists(retrySessionId, 'retry sessionId is required');
_sessionId = retrySessionId;
_messageId = undefined;
} else {
const message = await createSessionMessage({
client,
docId,
workspaceId,
promptName,
content,
attachments,
params,
sessionId,
});
_sessionId = message.sessionId;
_messageId = message.messageId;
}
AIProvider.LAST_ACTION_SESSIONID = _sessionId;
if (isRootSession) {
AIProvider.LAST_ROOT_SESSION_ID = _sessionId;
}
return client.chatText({
sessionId: _sessionId,
messageId: _messageId,
});
})(),
]);
}
}
// Only one image is currently being processed
export function toImage({
docId,
workspaceId,
promptName,
content,
attachments,
params,
seed,
sessionId,
signal,
timeout = TIMEOUT,
retry = false,
workflow = false,
client,
}: ToImageOptions) {
let _sessionId: string;
let _messageId: string | undefined;
return {
[Symbol.asyncIterator]: async function* () {
if (retry) {
const retrySessionId =
(await sessionId) ?? AIProvider.LAST_ACTION_SESSIONID;
assertExists(retrySessionId, 'retry sessionId is required');
_sessionId = retrySessionId;
_messageId = undefined;
} else {
const { messageId, sessionId } = await createSessionMessage({
docId,
workspaceId,
promptName,
content,
attachments,
params,
client,
});
_sessionId = sessionId;
_messageId = messageId;
}
const eventSource = client.imagesStream(
_sessionId,
_messageId,
seed,
workflow ? 'workflow' : undefined
);
AIProvider.LAST_ACTION_SESSIONID = _sessionId;
for await (const event of toTextStream(eventSource, {
timeout,
signal,
})) {
if (event.type === 'attachment') {
yield event.data;
}
}
},
};
}
@@ -1,538 +0,0 @@
import { AIProvider } from '@affine/core/blocksuite/presets';
import { toggleGeneralAIOnboarding } from '@affine/core/components/affine/ai-onboarding/apis';
import type { GlobalDialogService } from '@affine/core/modules/dialogs';
import {
type ChatHistoryOrder,
type getCopilotHistoriesQuery,
type RequestOptions,
} from '@affine/graphql';
import { assertExists } from '@blocksuite/affine/global/utils';
import { z } from 'zod';
import type { CopilotClient } from './copilot-client';
import type { PromptKey } from './prompt';
import { textToText, toImage } from './request';
import { setupTracker } from './tracker';
const filterStyleToPromptName = new Map(
Object.entries({
'Clay style': 'workflow:image-clay',
'Pixel style': 'workflow:image-pixel',
'Sketch style': 'workflow:image-sketch',
'Anime style': 'workflow:image-anime',
})
);
const processTypeToPromptName = new Map(
Object.entries({
Clearer: 'debug:action:fal-upscaler',
'Remove background': 'debug:action:fal-remove-bg',
'Convert to sticker': 'debug:action:fal-face-to-sticker',
})
);
export function setupAIProvider(
client: CopilotClient,
globalDialogService: GlobalDialogService
) {
//#region actions
AIProvider.provide('chat', options => {
const { input, docs, ...rest } = options;
const params = docs?.length
? {
docs: docs.map((doc, i) => ({
docId: doc.docId,
markdown: doc.markdown,
index: i + 1,
})),
}
: undefined;
return textToText({
...rest,
client,
content: input,
params,
});
});
AIProvider.provide('summary', options => {
return textToText({
...options,
client,
content: options.input,
promptName: 'Summary',
});
});
AIProvider.provide('translate', options => {
return textToText({
...options,
client,
promptName: 'Translate to',
content: options.input,
params: {
language: options.lang,
},
});
});
AIProvider.provide('changeTone', options => {
return textToText({
...options,
client,
params: {
tone: options.tone.toLowerCase(),
},
content: options.input,
promptName: 'Change tone to',
});
});
AIProvider.provide('improveWriting', options => {
return textToText({
...options,
client,
content: options.input,
promptName: 'Improve writing for it',
});
});
AIProvider.provide('improveGrammar', options => {
return textToText({
...options,
client,
content: options.input,
promptName: 'Improve grammar for it',
});
});
AIProvider.provide('fixSpelling', options => {
return textToText({
...options,
client,
content: options.input,
promptName: 'Fix spelling for it',
});
});
AIProvider.provide('createHeadings', options => {
return textToText({
...options,
client,
content: options.input,
promptName: 'Create headings',
});
});
AIProvider.provide('makeLonger', options => {
return textToText({
...options,
client,
content: options.input,
promptName: 'Make it longer',
});
});
AIProvider.provide('makeShorter', options => {
return textToText({
...options,
client,
content: options.input,
promptName: 'Make it shorter',
});
});
AIProvider.provide('checkCodeErrors', options => {
return textToText({
...options,
client,
content: options.input,
promptName: 'Check code error',
});
});
AIProvider.provide('explainCode', options => {
return textToText({
...options,
client,
content: options.input,
promptName: 'Explain this code',
});
});
AIProvider.provide('writeArticle', options => {
return textToText({
...options,
client,
content: options.input,
promptName: 'Write an article about this',
});
});
AIProvider.provide('writeTwitterPost', options => {
return textToText({
...options,
client,
content: options.input,
promptName: 'Write a twitter about this',
});
});
AIProvider.provide('writePoem', options => {
return textToText({
...options,
client,
content: options.input,
promptName: 'Write a poem about this',
});
});
AIProvider.provide('writeOutline', options => {
return textToText({
...options,
client,
content: options.input,
promptName: 'Write outline',
});
});
AIProvider.provide('writeBlogPost', options => {
return textToText({
...options,
client,
content: options.input,
promptName: 'Write a blog post about this',
});
});
AIProvider.provide('brainstorm', options => {
return textToText({
...options,
client,
content: options.input,
promptName: 'Brainstorm ideas about this',
});
});
AIProvider.provide('findActions', options => {
return textToText({
...options,
client,
content: options.input,
promptName: 'Find action items from it',
});
});
AIProvider.provide('brainstormMindmap', options => {
return textToText({
...options,
client,
content: options.input,
promptName: 'workflow:brainstorm',
workflow: true,
});
});
AIProvider.provide('expandMindmap', options => {
assertExists(options.input, 'expandMindmap action requires input');
return textToText({
...options,
client,
params: {
mindmap: options.mindmap,
node: options.input,
},
content: options.input,
promptName: 'Expand mind map',
});
});
AIProvider.provide('explain', options => {
return textToText({
...options,
client,
content: options.input,
promptName: 'Explain this',
});
});
AIProvider.provide('explainImage', options => {
return textToText({
...options,
client,
content: options.input,
promptName: 'Explain this image',
});
});
AIProvider.provide('makeItReal', options => {
let promptName: PromptKey = 'Make it real';
let content = options.input || '';
// wireframes
if (options.attachments?.length) {
content = `Here are the latest wireframes. Could you make a new website based on these wireframes and notes and send back just the html file?
Here are our design notes:\n ${content}.`;
} else {
// notes
promptName = 'Make it real with text';
content = `Here are the latest notes: \n ${content}.
Could you make a new website based on these notes and send back just the html file?`;
}
return textToText({
...options,
client,
content,
promptName,
});
});
AIProvider.provide('createSlides', options => {
const SlideSchema = z.object({
page: z.number(),
type: z.enum(['name', 'title', 'content']),
content: z.string(),
});
type Slide = z.infer<typeof SlideSchema>;
const parseJson = (json: string) => {
try {
return SlideSchema.parse(JSON.parse(json));
} catch {
return null;
}
};
// TODO(@darkskygit): move this to backend's workflow after workflow support custom code action
const postfix = (text: string): string => {
const slides = text
.split('\n')
.map(parseJson)
.filter((v): v is Slide => !!v);
return slides
.map(slide => {
if (slide.type === 'name') {
return `- ${slide.content}`;
} else if (slide.type === 'title') {
return ` - ${slide.content}`;
} else if (slide.content.includes('\n')) {
return slide.content
.split('\n')
.map(c => ` - ${c}`)
.join('\n');
} else {
return ` - ${slide.content}`;
}
})
.join('\n');
};
return textToText({
...options,
client,
content: options.input,
promptName: 'workflow:presentation',
workflow: true,
postfix,
});
});
AIProvider.provide('createImage', options => {
// test to image
let promptName: PromptKey = 'debug:action:dalle3';
// image to image
if (options.attachments?.length) {
promptName = 'debug:action:fal-sd15';
}
return toImage({
...options,
client,
content: options.input,
promptName,
});
});
AIProvider.provide('filterImage', options => {
// test to image
const promptName = filterStyleToPromptName.get(options.style as string);
return toImage({
...options,
client,
content: options.input,
timeout: 120000,
promptName: promptName as PromptKey,
workflow: !!promptName?.startsWith('workflow:'),
});
});
AIProvider.provide('processImage', options => {
// test to image
const promptName = processTypeToPromptName.get(
options.type as string
) as PromptKey;
return toImage({
...options,
client,
content: options.input,
timeout: 120000,
promptName,
});
});
AIProvider.provide('generateCaption', options => {
return textToText({
...options,
client,
content: options.input,
promptName: 'Generate a caption',
});
});
AIProvider.provide('continueWriting', options => {
return textToText({
...options,
client,
content: options.input,
promptName: 'Continue writing',
});
});
//#endregion
AIProvider.provide('session', {
createSession: async (
workspaceId: string,
docId: string,
promptName = 'Chat With AFFiNE AI'
) => {
return client.createSession({
workspaceId,
docId,
promptName,
});
},
getSessionIds: async (
workspaceId: string,
docId?: string,
options?: { action?: boolean }
) => {
return client.getSessionIds(workspaceId, docId, options);
},
updateSession: async (sessionId: string, promptName: string) => {
return client.updateSession({
sessionId,
promptName,
});
},
});
AIProvider.provide('context', {
createContext: async (workspaceId: string, sessionId: string) => {
return client.createContext(workspaceId, sessionId);
},
getContextId: async (workspaceId: string, sessionId: string) => {
return client.getContextId(workspaceId, sessionId);
},
addContextDoc: async (options: { contextId: string; docId: string }) => {
return client.addContextDoc(options);
},
removeContextDoc: async (options: { contextId: string; docId: string }) => {
return client.removeContextDoc(options);
},
addContextFile: async () => {
return client.addContextFile();
},
removeContextFile: async () => {
return client.removeContextFile();
},
getContextDocsAndFiles: async (
workspaceId: string,
sessionId: string,
contextId: string
) => {
return client.getContextDocsAndFiles(workspaceId, sessionId, contextId);
},
});
AIProvider.provide('histories', {
actions: async (
workspaceId: string,
docId?: string
): Promise<BlockSuitePresets.AIHistory[]> => {
// @ts-expect-error - 'action' is missing in server impl
return (
(await client.getHistories(workspaceId, docId, {
action: true,
withPrompt: true,
})) ?? []
);
},
chats: async (
workspaceId: string,
docId?: string,
options?: {
sessionId?: string;
messageOrder?: ChatHistoryOrder;
}
): Promise<BlockSuitePresets.AIHistory[]> => {
// @ts-expect-error - 'action' is missing in server impl
return (await client.getHistories(workspaceId, docId, options)) ?? [];
},
cleanup: async (
workspaceId: string,
docId: string,
sessionIds: string[]
) => {
await client.cleanupSessions({ workspaceId, docId, sessionIds });
},
ids: async (
workspaceId: string,
docId?: string,
options?: RequestOptions<
typeof getCopilotHistoriesQuery
>['variables']['options']
): Promise<BlockSuitePresets.AIHistoryIds[]> => {
// @ts-expect-error - 'role' is missing type in server impl
return await client.getHistoryIds(workspaceId, docId, options);
},
});
AIProvider.provide('photoEngine', {
async searchImages(options): Promise<string[]> {
let url = '/api/copilot/unsplash/photos';
if (options.query) {
url += `?query=${encodeURIComponent(options.query)}`;
}
const result: {
results?: {
urls: {
regular: string;
};
}[];
} = await client.fetcher(url.toString()).then(res => res.json());
if (!result.results) return [];
return result.results.map(r => {
const url = new URL(r.urls.regular);
url.searchParams.set('fit', 'crop');
url.searchParams.set('crop', 'edges');
url.searchParams.set('dpr', (window.devicePixelRatio ?? 2).toString());
url.searchParams.set('w', `${options.width}`);
url.searchParams.set('h', `${options.height}`);
return url.toString();
});
},
});
AIProvider.provide('onboarding', toggleGeneralAIOnboarding);
AIProvider.provide('forkChat', options => {
return client.forkSession(options);
});
const disposeRequestLoginHandler = AIProvider.slots.requestLogin.on(() => {
globalDialogService.open('sign-in', {});
});
setupTracker();
return () => {
disposeRequestLoginHandler.dispose();
};
}
@@ -1,268 +0,0 @@
import { AIProvider } from '@affine/core/blocksuite/presets';
import { mixpanel, track } from '@affine/track';
import type { EditorHost } from '@blocksuite/affine/block-std';
import type { GfxPrimitiveElementModel } from '@blocksuite/affine/block-std/gfx';
import type { BlockModel } from '@blocksuite/affine/store';
import { lowerCase, omit } from 'lodash-es';
type ElementModel = GfxPrimitiveElementModel;
type AIActionEventName =
| 'AI action invoked'
| 'AI action aborted'
| 'AI result discarded'
| 'AI result accepted';
type AIActionEventProperties = {
page: 'doc' | 'edgeless';
segment:
| 'AI action panel'
| 'right side bar'
| 'inline chat panel'
| 'AI result panel'
| 'AI chat block';
module:
| 'exit confirmation'
| 'AI action panel'
| 'AI chat panel'
| 'inline chat panel'
| 'AI result panel'
| 'AI chat block';
control:
| 'stop button'
| 'format toolbar'
| 'AI chat send button'
| 'Block action bar'
| 'paywall'
| 'policy wall'
| 'server error'
| 'login required'
| 'insert'
| 'replace'
| 'use as caption'
| 'discard'
| 'retry'
| 'add note'
| 'add page'
| 'continue in chat';
type:
| 'doc' // synced doc
| 'note' // note shape
| 'text'
| 'image'
| 'draw object'
| 'chatbox text'
| 'other';
category: string;
other: Record<string, unknown>;
docId: string;
workspaceId: string;
};
type BlocksuiteActionEvent = Parameters<
Parameters<typeof AIProvider.slots.actions.on>[0]
>[0];
const trackAction = ({
eventName,
properties,
}: {
eventName: AIActionEventName;
properties: AIActionEventProperties;
}) => {
mixpanel.track(eventName, properties);
};
const inferPageMode = (host: EditorHost) => {
return host.querySelector('affine-page-root') ? 'doc' : 'edgeless';
};
const defaultActionOptions = [
'stream',
'input',
'content',
'stream',
'attachments',
'signal',
'docId',
'workspaceId',
'host',
'models',
'control',
'where',
'seed',
];
function isElementModel(
model: BlockModel | ElementModel
): model is ElementModel {
return !isBlockModel(model);
}
function isBlockModel(model: BlockModel | ElementModel): model is BlockModel {
return 'flavour' in model;
}
function inferObjectType(event: BlocksuiteActionEvent) {
const models: (BlockModel | ElementModel)[] | undefined =
event.options.models;
if (!models) {
if (event.action === 'chat') {
return 'chatbox text';
} else if (event.options.attachments?.length) {
return 'image';
} else {
return 'text';
}
} else if (models.every(isElementModel)) {
return 'draw object';
} else if (models.every(isBlockModel)) {
const flavour = models[0].flavour;
if (flavour === 'affine:note') {
return 'note';
} else if (
['affine:paragraph', 'affine:list', 'affine:code'].includes(flavour)
) {
return 'text';
} else if (flavour === 'affine:image') {
return 'image';
}
}
return 'other';
}
function inferSegment(
event: BlocksuiteActionEvent
): AIActionEventProperties['segment'] {
if (event.options.where === 'inline-chat-panel') {
return 'inline chat panel';
} else if (event.event.startsWith('result:')) {
return 'AI result panel';
} else if (event.options.where === 'chat-panel') {
return 'right side bar';
} else if (event.options.where === 'ai-chat-block') {
return 'AI chat block';
} else {
return 'AI action panel';
}
}
function inferModule(
event: BlocksuiteActionEvent
): AIActionEventProperties['module'] {
if (event.options.where === 'chat-panel') {
return 'AI chat panel';
} else if (event.event === 'result:discard') {
return 'exit confirmation';
} else if (event.event.startsWith('result:')) {
return 'AI result panel';
} else if (event.options.where === 'inline-chat-panel') {
return 'inline chat panel';
} else if (event.options.where === 'ai-chat-block') {
return 'AI chat block';
} else {
return 'AI action panel';
}
}
function inferEventName(
event: BlocksuiteActionEvent
): AIActionEventName | null {
if (['result:discard', 'result:retry'].includes(event.event)) {
return 'AI result discarded';
} else if (event.event.startsWith('result:')) {
return 'AI result accepted';
} else if (event.event.startsWith('aborted:')) {
return 'AI action aborted';
} else if (event.event === 'started') {
return 'AI action invoked';
}
return null;
}
function inferControl(
event: BlocksuiteActionEvent
): AIActionEventProperties['control'] {
if (event.event === 'aborted:stop') {
return 'stop button';
} else if (event.event === 'aborted:paywall') {
return 'paywall';
} else if (event.event === 'aborted:server-error') {
return 'server error';
} else if (event.event === 'aborted:login-required') {
return 'login required';
} else if (event.options.control === 'chat-send') {
return 'AI chat send button';
} else if (event.options.control === 'block-action-bar') {
return 'Block action bar';
} else if (event.event === 'result:add-note') {
return 'add note';
} else if (event.event === 'result:add-page') {
return 'add page';
} else if (event.event === 'result:continue-in-chat') {
return 'continue in chat';
} else if (event.event === 'result:insert') {
return 'insert';
} else if (event.event === 'result:replace') {
return 'replace';
} else if (event.event === 'result:use-as-caption') {
return 'use as caption';
} else if (event.event === 'result:discard') {
return 'discard';
} else if (event.event === 'result:retry') {
return 'retry';
} else {
return 'format toolbar';
}
}
const toTrackedOptions = (
event: BlocksuiteActionEvent
): {
eventName: AIActionEventName;
properties: AIActionEventProperties;
} | null => {
const eventName = inferEventName(event);
if (!eventName) return null;
const pageMode = inferPageMode(event.options.host);
const otherProperties = omit(event.options, defaultActionOptions);
const type = inferObjectType(event);
const segment = inferSegment(event);
const module = inferModule(event);
const control = inferControl(event);
const category = lowerCase(event.action);
return {
eventName,
properties: {
page: pageMode,
segment,
category,
module,
control,
type,
other: otherProperties,
docId: event.options.docId,
workspaceId: event.options.workspaceId,
},
};
};
export function setupTracker() {
AIProvider.slots.requestUpgradePlan.on(() => {
track.$.paywall.aiAction.viewPlans();
});
AIProvider.slots.requestLogin.on(() => {
track.doc.editor.aiActions.requestSignIn();
});
AIProvider.slots.actions.on(event => {
const properties = toTrackedOptions(event);
if (properties) {
trackAction(properties);
}
});
}
@@ -1,127 +0,0 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { globalStyle, style } from '@vanilla-extract/css';
export const container = style({
width: '100%',
maxWidth: cssVar('--affine-editor-width'),
marginLeft: 'auto',
marginRight: 'auto',
paddingLeft: cssVar('--affine-editor-side-padding', '24'),
paddingRight: cssVar('--affine-editor-side-padding', '24'),
fontSize: cssVar('--affine-font-base'),
'@container': {
[`viewport (width <= 640px)`]: {
padding: '0 24px',
},
},
'@media': {
print: {
display: 'none',
},
},
});
export const titleLine = style({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
});
export const title = style({
fontWeight: 500,
fontSize: '15px',
lineHeight: '24px',
color: cssVar('--affine-text-primary-color'),
});
export const showButton = style({
height: '28px',
borderRadius: '8px',
border: '1px solid ' + cssVar('--affine-border-color'),
backgroundColor: cssVar('--affine-white'),
textAlign: 'center',
fontSize: '12px',
lineHeight: '28px',
fontWeight: '500',
color: cssVar('--affine-text-primary-color'),
cursor: 'pointer',
});
export const linksContainer = style({
marginBottom: '16px',
});
export const linksTitles = style({
color: cssVar('--affine-text-secondary-color'),
height: '32px',
lineHeight: '32px',
});
export const link = style({
width: '100%',
height: '30px',
display: 'flex',
alignItems: 'center',
gap: '4px',
whiteSpace: 'nowrap',
borderRadius: '4px',
':hover': {
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
},
});
globalStyle(`${link} .affine-reference-title`, {
borderBottom: 'none',
});
globalStyle(`${link} svg`, {
color: cssVarV2('icon/secondary'),
});
globalStyle(`${link}:hover svg`, {
color: cssVarV2('icon/primary'),
});
export const linkPreviewContainer = style({
display: 'flex',
flexDirection: 'column',
gap: '12px',
marginTop: '4px',
marginBottom: '16px',
});
export const linkPreview = style({
cursor: 'default',
border: `0.5px solid ${cssVarV2('backlinks/blockBorder')}`,
borderRadius: '8px',
padding: '8px',
color: cssVarV2('text/primary'),
vars: {
[cssVar('fontFamily')]: cssVar('fontSansFamily'),
},
backgroundColor: cssVarV2('backlinks/blockBackgroundColor'),
':hover': {
backgroundColor: cssVarV2('backlinks/blockHover'),
},
});
globalStyle(`${linkPreview} *`, {
cursor: 'default',
});
export const linkPreviewRenderer = style({
cursor: 'pointer',
});
export const collapsedIcon = style({
transition: 'all 0.2s ease-in-out',
color: cssVarV2('icon/primary'),
fontSize: 20,
selectors: {
'&[data-collapsed="true"]': {
transform: 'rotate(90deg)',
color: cssVarV2('icon/secondary'),
},
},
});
@@ -1,382 +0,0 @@
import {
Button,
createReactComponentFromLit,
Divider,
useLitPortalFactory,
} from '@affine/component';
import { TextRenderer } from '@affine/core/blocksuite/presets';
import { DocService } from '@affine/core/modules/doc';
import {
type Backlink,
DocLinksService,
type Link,
} from '@affine/core/modules/doc-link';
import { toURLSearchParams } from '@affine/core/modules/navigation';
import { GlobalSessionStateService } from '@affine/core/modules/storage';
import { WorkbenchLink } from '@affine/core/modules/workbench';
import {
getAFFiNEWorkspaceSchema,
WorkspaceService,
} from '@affine/core/modules/workspace';
import { useI18n } from '@affine/i18n';
import track from '@affine/track';
import type { TransformerMiddleware } from '@blocksuite/affine/store';
import { ToggleDownIcon } from '@blocksuite/icons/rc';
import * as Collapsible from '@radix-ui/react-collapsible';
import {
LiveData,
useFramework,
useLiveData,
useServices,
} from '@toeverything/infra';
import React, {
Fragment,
type ReactNode,
useCallback,
useMemo,
useState,
} from 'react';
import {
AffinePageReference,
AffineSharedPageReference,
} from '../../affine/reference-link';
import * as styles from './bi-directional-link-panel.css';
import {
patchReferenceRenderer,
type ReferenceReactRenderer,
} from './specs/custom/spec-patchers';
import { createPageModeSpecs } from './specs/page';
const BlocksuiteTextRenderer = createReactComponentFromLit({
react: React,
elementClass: TextRenderer,
});
const PREFIX = 'bi-directional-link-panel-collapse:';
const useBiDirectionalLinkPanelCollapseState = (
docId: string,
linkDocId?: string
) => {
const { globalSessionStateService } = useServices({
GlobalSessionStateService,
});
const path = linkDocId ? docId + ':' + linkDocId : docId;
const [open, setOpen] = useState(
globalSessionStateService.globalSessionState.get(PREFIX + path) ?? false
);
const wrappedSetOpen = useCallback(
(open: boolean) => {
setOpen(open);
globalSessionStateService.globalSessionState.set(PREFIX + path, open);
},
[path, globalSessionStateService]
);
return [open, wrappedSetOpen] as const;
};
const CollapsibleSection = ({
title,
children,
length,
docId,
linkDocId,
}: {
title: ReactNode;
children: ReactNode;
length?: number;
docId: string;
linkDocId?: string;
}) => {
const [open, setOpen] = useBiDirectionalLinkPanelCollapseState(
docId,
linkDocId
);
const handleToggle = useCallback(() => {
setOpen(!open);
track.doc.biDirectionalLinksPanel.$.toggle({
type: open ? 'collapse' : 'expand',
});
}, [open, setOpen]);
return (
<Collapsible.Root open={open} onOpenChange={handleToggle}>
<Collapsible.Trigger className={styles.link}>
{title}
{length ? (
<ToggleDownIcon
className={styles.collapsedIcon}
data-collapsed={!open}
/>
) : null}
</Collapsible.Trigger>
<Collapsible.Content>{children}</Collapsible.Content>
</Collapsible.Root>
);
};
const usePreviewExtensions = () => {
const [reactToLit, portals] = useLitPortalFactory();
const framework = useFramework();
const { workspaceService } = useServices({
WorkspaceService,
});
const referenceRenderer: ReferenceReactRenderer = useMemo(() => {
return function customReference(reference) {
const data = reference.delta.attributes?.reference;
if (!data) return <span />;
const pageId = data.pageId;
if (!pageId) return <span />;
const params = toURLSearchParams(data.params);
if (workspaceService.workspace.openOptions.isSharedMode) {
return (
<AffineSharedPageReference
docCollection={workspaceService.workspace.docCollection}
pageId={pageId}
params={params}
/>
);
}
return <AffinePageReference pageId={pageId} params={params} />;
};
}, [workspaceService]);
const extensions = useMemo(() => {
const specs = createPageModeSpecs(framework);
specs.extend([patchReferenceRenderer(reactToLit, referenceRenderer)]);
return specs.value;
}, [reactToLit, referenceRenderer, framework]);
return [extensions, portals] as const;
};
const useBacklinkGroups = () => {
const { docLinksService } = useServices({
DocLinksService,
});
const backlinkGroups = useLiveData(
LiveData.computed(get => {
const links = get(docLinksService.backlinks.backlinks$);
// group by docId
const groupedLinks = links.reduce(
(acc, link) => {
acc[link.docId] = [...(acc[link.docId] || []), link];
return acc;
},
{} as Record<string, Backlink[]>
);
return Object.entries(groupedLinks).map(([docId, links]) => ({
docId,
title: links[0].title, // title should be the same for all blocks
links,
}));
})
);
return backlinkGroups;
};
export const BacklinkGroups = () => {
const [extensions, portals] = usePreviewExtensions();
const { workspaceService, docService } = useServices({
WorkspaceService,
DocService,
});
const backlinkGroups = useBacklinkGroups();
const textRendererOptions = useMemo(() => {
const docLinkBaseURLMiddleware: TransformerMiddleware = ({
adapterConfigs,
}) => {
adapterConfigs.set(
'docLinkBaseUrl',
`/workspace/${workspaceService.workspace.id}`
);
};
return {
customHeading: true,
extensions,
additionalMiddlewares: [docLinkBaseURLMiddleware],
};
}, [extensions, workspaceService.workspace.id]);
return (
<>
{backlinkGroups.map(linkGroup => (
<CollapsibleSection
key={linkGroup.docId}
title={
<AffinePageReference
pageId={linkGroup.docId}
onClick={() => {
track.doc.biDirectionalLinksPanel.backlinkTitle.navigate();
}}
/>
}
length={linkGroup.links.length}
docId={docService.doc.id}
linkDocId={linkGroup.docId}
>
<div className={styles.linkPreviewContainer}>
{linkGroup.links.map(link => {
if (!link.markdownPreview) {
return null;
}
const searchParams = new URLSearchParams();
const displayMode = link.displayMode || 'page';
searchParams.set('mode', displayMode);
let blockId = link.blockId;
if (
link.parentFlavour === 'affine:database' &&
link.parentBlockId
) {
// if parentBlockFlavour is 'affine:database',
// we will fallback to the database block instead
blockId = link.parentBlockId;
} else if (displayMode === 'edgeless' && link.noteBlockId) {
// if note has displayMode === 'edgeless' && has noteBlockId,
// set noteBlockId as blockId
blockId = link.noteBlockId;
}
searchParams.set('blockIds', blockId);
const to = {
pathname: '/' + linkGroup.docId,
search: '?' + searchParams.toString(),
hash: '',
};
// if this backlink has no noteBlock && displayMode is edgeless, we will render
// the link as a page link
const edgelessLink =
displayMode === 'edgeless' && !link.noteBlockId;
return (
<WorkbenchLink
to={to}
key={link.blockId}
className={styles.linkPreview}
onClick={() => {
track.doc.biDirectionalLinksPanel.backlinkPreview.navigate();
}}
>
{edgelessLink ? (
<>
[Edgeless]
<AffinePageReference
key={link.blockId}
pageId={linkGroup.docId}
params={searchParams}
/>
</>
) : (
<BlocksuiteTextRenderer
className={styles.linkPreviewRenderer}
answer={link.markdownPreview}
schema={getAFFiNEWorkspaceSchema()}
options={textRendererOptions}
/>
)}
</WorkbenchLink>
);
})}
</div>
</CollapsibleSection>
))}
<>
{portals.map(p => (
<Fragment key={p.id}>{p.portal}</Fragment>
))}
</>
</>
);
};
export const BiDirectionalLinkPanel = () => {
const { docLinksService, docService } = useServices({
DocLinksService,
DocService,
});
const t = useI18n();
const [show, setShow] = useBiDirectionalLinkPanelCollapseState(
docService.doc.id
);
const links = useLiveData(
show ? docLinksService.links.links$ : new LiveData([] as Link[])
);
const backlinkGroups = useBacklinkGroups();
const backlinkCount = useMemo(() => {
return backlinkGroups.reduce((acc, link) => acc + link.links.length, 0);
}, [backlinkGroups]);
const handleClickShow = useCallback(() => {
setShow(!show);
track.doc.biDirectionalLinksPanel.$.toggle({
type: show ? 'collapse' : 'expand',
});
}, [show, setShow]);
return (
<div className={styles.container}>
{!show && <Divider size="thinner" />}
<div className={styles.titleLine}>
<div className={styles.title}>Bi-Directional Links</div>
<Button className={styles.showButton} onClick={handleClickShow}>
{show
? t['com.affine.editor.bi-directional-link-panel.hide']()
: t['com.affine.editor.bi-directional-link-panel.show']()}
</Button>
</div>
{show && (
<>
<Divider size="thinner" />
<div className={styles.linksContainer}>
<div className={styles.linksTitles}>
{t['com.affine.page-properties.backlinks']()} · {backlinkCount}
</div>
<BacklinkGroups />
</div>
<div className={styles.linksContainer}>
<div className={styles.linksTitles}>
{t['com.affine.page-properties.outgoing-links']()} ·{' '}
{links.length}
</div>
{links.map((link, i) => (
<div
key={`${link.docId}-${link.params?.toString()}-${i}`}
className={styles.link}
>
<AffinePageReference pageId={link.docId} params={link.params} />
</div>
))}
</div>
</>
)}
</div>
);
};
@@ -1,194 +0,0 @@
import type {
EdgelessEditor,
PageEditor,
} from '@affine/core/blocksuite/editors';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import type { BlockStdScope, EditorHost } from '@blocksuite/affine/block-std';
import {
appendParagraphCommand,
type DocMode,
type DocTitle,
focusBlockEnd,
getLastNoteBlock,
type RootBlockModel,
} from '@blocksuite/affine/blocks';
import { type Store } from '@blocksuite/affine/store';
import { useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx';
import type React from 'react';
import {
forwardRef,
useCallback,
useImperativeHandle,
useMemo,
useRef,
} from 'react';
import type { DefaultOpenProperty } from '../../doc-properties';
import { BlocksuiteDocEditor, BlocksuiteEdgelessEditor } from './lit-adaper';
import * as styles from './styles.css';
interface BlocksuiteEditorContainerProps {
page: Store;
mode: DocMode;
shared?: boolean;
readonly?: boolean;
className?: string;
defaultOpenProperty?: DefaultOpenProperty;
style?: React.CSSProperties;
}
export interface AffineEditorContainer extends HTMLElement {
page: Store;
doc: Store;
docTitle: DocTitle;
host?: EditorHost;
model: RootBlockModel | null;
updateComplete: Promise<boolean>;
mode: DocMode;
origin: HTMLDivElement;
std: BlockStdScope;
}
export const BlocksuiteEditorContainer = forwardRef<
AffineEditorContainer,
BlocksuiteEditorContainerProps
>(function AffineEditorContainer(
{ page, mode, className, style, shared, readonly, defaultOpenProperty },
ref
) {
const rootRef = useRef<HTMLDivElement>(null);
const docRef = useRef<PageEditor>(null);
const docTitleRef = useRef<DocTitle>(null);
const edgelessRef = useRef<EdgelessEditor>(null);
const featureFlags = useService(FeatureFlagService).flags;
const enableEditorRTL = useLiveData(featureFlags.enable_editor_rtl.$);
/**
* mimic an AffineEditorContainer using proxy
*/
const affineEditorContainerProxy = useMemo(() => {
const api = {
get page() {
return page;
},
get doc() {
return page;
},
get docTitle() {
return docTitleRef.current;
},
get host() {
return (
(mode === 'page'
? docRef.current?.host
: edgelessRef.current?.host) ?? null
);
},
get model() {
return page.root as any;
},
get updateComplete() {
return mode === 'page'
? docRef.current?.updateComplete
: edgelessRef.current?.updateComplete;
},
get mode() {
return mode;
},
get origin() {
return rootRef.current;
},
get std() {
return mode === 'page' ? docRef.current?.std : edgelessRef.current?.std;
},
};
const proxy = new Proxy(api, {
has(_, prop) {
return (
Reflect.has(api, prop) ||
(rootRef.current ? Reflect.has(rootRef.current, prop) : false)
);
},
get(_, prop) {
if (Reflect.has(api, prop)) {
return api[prop as keyof typeof api];
}
if (rootRef.current && Reflect.has(rootRef.current, prop)) {
const maybeFn = Reflect.get(rootRef.current, prop);
if (typeof maybeFn === 'function') {
return maybeFn.bind(rootRef.current);
} else {
return maybeFn;
}
}
return undefined;
},
}) as AffineEditorContainer;
return proxy;
}, [mode, page]);
useImperativeHandle(ref, () => affineEditorContainerProxy, [
affineEditorContainerProxy,
]);
const handleClickPageModeBlank = useCallback(() => {
if (shared || readonly || page.readonly) return;
const std = affineEditorContainerProxy.host?.std;
if (!std) {
return;
}
const note = getLastNoteBlock(page);
if (note) {
const lastBlock = note.lastChild();
if (
lastBlock &&
lastBlock.flavour === 'affine:paragraph' &&
lastBlock.text?.length === 0
) {
const focusBlock = std.view.getBlock(lastBlock.id) ?? undefined;
std.command.exec(focusBlockEnd, {
focusBlock,
});
return;
}
}
std.command.exec(appendParagraphCommand);
}, [affineEditorContainerProxy.host?.std, page, readonly, shared]);
return (
<div
data-testid={`editor-${page.id}`}
dir={enableEditorRTL ? 'rtl' : 'ltr'}
className={clsx(
`editor-wrapper ${mode}-mode`,
styles.docEditorRoot,
className
)}
data-affine-editor-container
style={style}
ref={rootRef}
>
{mode === 'page' ? (
<BlocksuiteDocEditor
shared={shared}
page={page}
ref={docRef}
readonly={readonly}
titleRef={docTitleRef}
onClickBlank={handleClickPageModeBlank}
defaultOpenProperty={defaultOpenProperty}
/>
) : (
<BlocksuiteEdgelessEditor
shared={shared}
page={page}
ref={edgelessRef}
/>
)}
</div>
);
});
@@ -1,183 +0,0 @@
import { useRefEffect } from '@affine/component';
import { EditorLoading } from '@affine/component/page-detail-skeleton';
import { ServerService } from '@affine/core/modules/cloud';
import {
EditorSettingService,
fontStyleOptions,
} from '@affine/core/modules/editor-setting';
import {
customImageProxyMiddleware,
type DocMode,
ImageProxyService,
LinkPreviewerService,
} from '@blocksuite/affine/blocks';
import { DisposableGroup } from '@blocksuite/affine/global/utils';
import type { Store } from '@blocksuite/affine/store';
import { Slot } from '@radix-ui/react-slot';
import { useLiveData, useService } from '@toeverything/infra';
import { cssVar } from '@toeverything/theme';
import type { CSSProperties } from 'react';
import { useEffect, useMemo, useState } from 'react';
import type { DefaultOpenProperty } from '../../doc-properties';
import {
type AffineEditorContainer,
BlocksuiteEditorContainer,
} from './blocksuite-editor-container';
import { NoPageRootError } from './no-page-error';
export type EditorProps = {
page: Store;
mode: DocMode;
shared?: boolean;
readonly?: boolean;
defaultOpenProperty?: DefaultOpenProperty;
// on Editor ready
onEditorReady?: (editor: AffineEditorContainer) => (() => void) | void;
style?: CSSProperties;
className?: string;
};
const BlockSuiteEditorImpl = ({
mode,
page,
className,
shared,
readonly,
style,
onEditorReady,
defaultOpenProperty,
}: EditorProps) => {
useEffect(() => {
const disposable = page.slots.blockUpdated.once(() => {
page.workspace.meta.setDocMeta(page.id, {
updatedDate: Date.now(),
});
});
return () => {
disposable.dispose();
};
}, [page]);
const server = useService(ServerService).server;
const editorRef = useRefEffect(
(editor: AffineEditorContainer) => {
globalThis.currentEditor = editor;
let canceled = false;
const disposableGroup = new DisposableGroup();
// Invoke onLoad once the editor has been mounted to the DOM.
if (canceled) {
return;
}
// provide image proxy endpoint to blocksuite
const imageProxyUrl = new URL(
BUILD_CONFIG.imageProxyUrl,
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
.then(() => {
if (onEditorReady) {
const dispose = onEditorReady(editor);
if (dispose) {
disposableGroup.add(dispose);
}
}
})
.catch(error => {
console.error('Error updating editor', error);
});
return () => {
canceled = true;
disposableGroup.dispose();
};
},
[onEditorReady, page, server]
);
return (
<BlocksuiteEditorContainer
mode={mode}
page={page}
shared={shared}
readonly={readonly}
defaultOpenProperty={defaultOpenProperty}
ref={editorRef}
className={className}
style={style}
/>
);
};
export const BlockSuiteEditor = (props: EditorProps) => {
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const editorSetting = useService(EditorSettingService).editorSetting;
const settings = useLiveData(
editorSetting.settings$.selector(s => ({
fontFamily: s.fontFamily,
customFontFamily: s.customFontFamily,
fullWidthLayout: s.fullWidthLayout,
}))
);
const fontFamily = useMemo(() => {
const fontStyle = fontStyleOptions.find(
option => option.key === settings.fontFamily
);
if (!fontStyle) {
return cssVar('fontSansFamily');
}
const customFontFamily = settings.customFontFamily;
return customFontFamily && fontStyle.key === 'Custom'
? `${customFontFamily}, ${fontStyle.value}`
: fontStyle.value;
}, [settings.customFontFamily, settings.fontFamily]);
useEffect(() => {
if (props.page.root) {
setIsLoading(false);
return;
}
const timer = setTimeout(() => {
disposable.dispose();
setError(new NoPageRootError(props.page));
}, 20 * 1000);
const disposable = props.page.slots.rootAdded.once(() => {
setIsLoading(false);
clearTimeout(timer);
});
return () => {
disposable.dispose();
clearTimeout(timer);
};
}, [props.page]);
if (error) {
throw error;
}
return (
<Slot style={{ '--affine-font-family': fontFamily } as CSSProperties}>
{isLoading ? (
<EditorLoading />
) : (
<BlockSuiteEditorImpl key={props.page.id} {...props} />
)}
</Slot>
);
};
@@ -1,15 +0,0 @@
import { effects as editorEffects } from '@affine/core/blocksuite/editors';
import { registerBlocksuitePresetsCustomComponents } from '@affine/core/blocksuite/presets/effects';
import { effects as bsEffects } from '@blocksuite/affine/effects';
import { effects as edgelessEffects } from './specs/edgeless';
import { effects as patchEffects } from './specs/preview';
bsEffects();
patchEffects();
editorEffects();
edgelessEffects();
registerBlocksuitePresetsCustomComponents();
export * from './blocksuite-editor';
export * from './blocksuite-editor-container';
@@ -1,34 +0,0 @@
import { JournalService } from '@affine/core/modules/journal';
import { i18nTime, useI18n } from '@affine/i18n';
import type { Store } from '@blocksuite/affine/store';
import { useLiveData, useService } from '@toeverything/infra';
import dayjs from 'dayjs';
import * as styles from './styles.css';
export const BlocksuiteEditorJournalDocTitle = ({ page }: { page: Store }) => {
const journalService = useService(JournalService);
const journalDateStr = useLiveData(journalService.journalDate$(page.id));
const journalDate = journalDateStr ? dayjs(journalDateStr) : null;
const isTodayJournal = useLiveData(journalService.journalToday$(page.id));
const localizedJournalDate = i18nTime(journalDateStr, {
absolute: { accuracy: 'day' },
});
const t = useI18n();
// TODO(catsjuice): i18n
const day = journalDate?.format('dddd') ?? null;
return (
<div className="doc-title-container" data-testid="journal-title">
<span data-testid="date">{localizedJournalDate}</span>
{isTodayJournal ? (
<span className={styles.titleTodayTag} data-testid="date-today-label">
{t['com.affine.today']()}
</span>
) : (
<span className={styles.titleDayTag}>{day}</span>
)}
</div>
);
};
@@ -1,398 +0,0 @@
import {
createReactComponentFromLit,
useConfirmModal,
useLitPortalFactory,
} from '@affine/component';
import { EdgelessEditor, PageEditor } from '@affine/core/blocksuite/editors';
import type { DocCustomPropertyInfo } from '@affine/core/modules/db';
import { DocService, DocsService } from '@affine/core/modules/doc';
import type {
DatabaseRow,
DatabaseValueCell,
} from '@affine/core/modules/doc-info/types';
import { EditorService } from '@affine/core/modules/editor';
import { EditorSettingService } from '@affine/core/modules/editor-setting';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { JournalService } from '@affine/core/modules/journal';
import { toURLSearchParams } from '@affine/core/modules/navigation';
import { PeekViewService } from '@affine/core/modules/peek-view/services/peek-view';
import { WorkspaceService } from '@affine/core/modules/workspace';
import track from '@affine/track';
import {
codeToolbarWidget,
type DocMode,
DocTitle,
embedCardToolbarWidget,
formatBarWidget,
imageToolbarWidget,
slashMenuWidget,
surfaceRefToolbarWidget,
} from '@blocksuite/affine/blocks';
import type { Store } from '@blocksuite/affine/store';
import {
useFramework,
useLiveData,
useService,
useServices,
} from '@toeverything/infra';
import React, {
forwardRef,
Fragment,
useCallback,
useEffect,
useMemo,
useRef,
} from 'react';
import {
AffinePageReference,
AffineSharedPageReference,
} from '../../affine/reference-link';
import {
type DefaultOpenProperty,
DocPropertiesTable,
} from '../../doc-properties';
import { BiDirectionalLinkPanel } from './bi-directional-link-panel';
import { BlocksuiteEditorJournalDocTitle } from './journal-doc-title';
import { extendEdgelessPreviewSpec } from './specs/custom/root-block';
import {
patchDocModeService,
patchEdgelessClipboard,
patchForAttachmentEmbedViews,
patchForClipboardInElectron,
patchForEdgelessNoteConfig,
patchForMobile,
patchGenerateDocUrlExtension,
patchNotificationService,
patchOpenDocExtension,
patchParseDocUrlExtension,
patchPeekViewService,
patchQuickSearchService,
patchReferenceRenderer,
patchSideBarService,
type ReferenceReactRenderer,
} from './specs/custom/spec-patchers';
import { createEdgelessModeSpecs } from './specs/edgeless';
import { createPageModeSpecs } from './specs/page';
import { StarterBar } from './starter-bar';
import * as styles from './styles.css';
const adapted = {
DocEditor: createReactComponentFromLit({
react: React,
elementClass: PageEditor,
}),
DocTitle: createReactComponentFromLit({
react: React,
elementClass: DocTitle,
}),
EdgelessEditor: createReactComponentFromLit({
react: React,
elementClass: EdgelessEditor,
}),
};
interface BlocksuiteEditorProps {
page: Store;
readonly?: boolean;
shared?: boolean;
defaultOpenProperty?: DefaultOpenProperty;
}
const usePatchSpecs = (mode: DocMode) => {
const [reactToLit, portals] = useLitPortalFactory();
const {
peekViewService,
docService,
docsService,
editorService,
workspaceService,
featureFlagService,
} = useServices({
PeekViewService,
DocService,
DocsService,
WorkspaceService,
EditorService,
FeatureFlagService,
});
const framework = useFramework();
const referenceRenderer: ReferenceReactRenderer = useMemo(() => {
return function customReference(reference) {
const data = reference.delta.attributes?.reference;
if (!data) return <span />;
const pageId = data.pageId;
if (!pageId) return <span />;
// title alias
const title = data.title;
const params = toURLSearchParams(data.params);
if (workspaceService.workspace.openOptions.isSharedMode) {
return (
<AffineSharedPageReference
docCollection={workspaceService.workspace.docCollection}
pageId={pageId}
params={params}
title={title}
/>
);
}
return (
<AffinePageReference pageId={pageId} params={params} title={title} />
);
};
}, [workspaceService]);
useMemo(() => {
extendEdgelessPreviewSpec(framework);
}, [framework]);
const confirmModal = useConfirmModal();
const patchedSpecs = useMemo(() => {
const builder =
mode === 'edgeless'
? createEdgelessModeSpecs(framework)
: createPageModeSpecs(framework);
builder.extend(
[
patchReferenceRenderer(reactToLit, referenceRenderer),
patchForEdgelessNoteConfig(framework, reactToLit),
patchNotificationService(confirmModal),
patchPeekViewService(peekViewService),
patchOpenDocExtension(),
patchEdgelessClipboard(),
patchParseDocUrlExtension(framework),
patchGenerateDocUrlExtension(framework),
patchQuickSearchService(framework),
patchSideBarService(framework),
patchDocModeService(docService, docsService, editorService),
].flat()
);
if (featureFlagService.flags.enable_pdf_embed_preview.value) {
builder.extend([patchForAttachmentEmbedViews(reactToLit)]);
}
if (BUILD_CONFIG.isMobileEdition) {
builder.omit(formatBarWidget);
builder.omit(embedCardToolbarWidget);
builder.omit(slashMenuWidget);
builder.omit(codeToolbarWidget);
builder.omit(imageToolbarWidget);
builder.omit(surfaceRefToolbarWidget);
builder.extend([patchForMobile()].flat());
}
if (BUILD_CONFIG.isElectron) {
builder.extend([patchForClipboardInElectron(framework)].flat());
}
return builder.value;
}, [
mode,
confirmModal,
docService,
docsService,
editorService,
framework,
peekViewService,
reactToLit,
referenceRenderer,
featureFlagService,
]);
return [
patchedSpecs,
useMemo(
() => (
<>
{portals.map(p => (
<Fragment key={p.id}>{p.portal}</Fragment>
))}
</>
),
[portals]
),
] as const;
};
export const BlocksuiteDocEditor = forwardRef<
PageEditor,
BlocksuiteEditorProps & {
onClickBlank?: () => void;
titleRef?: React.Ref<DocTitle>;
}
>(function BlocksuiteDocEditor(
{
page,
shared,
onClickBlank,
titleRef: externalTitleRef,
defaultOpenProperty,
readonly,
},
ref
) {
const titleRef = useRef<DocTitle | null>(null);
const docRef = useRef<PageEditor | null>(null);
const journalService = useService(JournalService);
const isJournal = !!useLiveData(journalService.journalDate$(page.id));
const editorSettingService = useService(EditorSettingService);
const onDocRef = useCallback(
(el: PageEditor) => {
docRef.current = el;
if (ref) {
if (typeof ref === 'function') {
ref(el);
} else {
ref.current = el;
}
}
},
[ref]
);
const onTitleRef = useCallback(
(el: DocTitle) => {
titleRef.current = el;
if (externalTitleRef) {
if (typeof externalTitleRef === 'function') {
externalTitleRef(el);
} else {
(externalTitleRef as any).current = el;
}
}
},
[externalTitleRef]
);
const [specs, portals] = usePatchSpecs('page');
const displayBiDirectionalLink = useLiveData(
editorSettingService.editorSetting.settings$.selector(
s => s.displayBiDirectionalLink
)
);
const displayDocInfo = useLiveData(
editorSettingService.editorSetting.settings$.selector(s => s.displayDocInfo)
);
const onPropertyChange = useCallback((property: DocCustomPropertyInfo) => {
track.doc.inlineDocInfo.property.editProperty({
type: property.type,
});
}, []);
const onPropertyAdded = useCallback((property: DocCustomPropertyInfo) => {
track.doc.inlineDocInfo.property.addProperty({
type: property.type,
control: 'at menu',
});
}, []);
const onDatabasePropertyChange = useCallback(
(_row: DatabaseRow, cell: DatabaseValueCell) => {
track.doc.inlineDocInfo.databaseProperty.editProperty({
type: cell.property.type$.value,
});
},
[]
);
const onPropertyInfoChange = useCallback(
(property: DocCustomPropertyInfo, field: string) => {
track.doc.inlineDocInfo.property.editPropertyMeta({
type: property.type,
field,
});
},
[]
);
return (
<>
<div className={styles.affineDocViewport}>
{!isJournal ? (
<adapted.DocTitle doc={page} ref={onTitleRef} />
) : (
<BlocksuiteEditorJournalDocTitle page={page} />
)}
{!shared && displayDocInfo ? (
<div className={styles.docPropertiesTableContainer}>
<DocPropertiesTable
className={styles.docPropertiesTable}
onDatabasePropertyChange={onDatabasePropertyChange}
onPropertyChange={onPropertyChange}
onPropertyAdded={onPropertyAdded}
onPropertyInfoChange={onPropertyInfoChange}
defaultOpenProperty={defaultOpenProperty}
/>
</div>
) : null}
<adapted.DocEditor
className={styles.docContainer}
ref={onDocRef}
doc={page}
specs={specs}
/>
<div
className={styles.docEditorGap}
data-testid="page-editor-blank"
onClick={onClickBlank}
></div>
{!readonly && !BUILD_CONFIG.isMobileEdition && (
<StarterBar doc={page} />
)}
{!shared && displayBiDirectionalLink ? (
<BiDirectionalLinkPanel />
) : null}
</div>
{portals}
</>
);
});
export const BlocksuiteEdgelessEditor = forwardRef<
EdgelessEditor,
BlocksuiteEditorProps
>(function BlocksuiteEdgelessEditor({ page }, ref) {
const [specs, portals] = usePatchSpecs('edgeless');
const editorRef = useRef<EdgelessEditor | null>(null);
const onDocRef = useCallback(
(el: EdgelessEditor) => {
editorRef.current = el;
if (ref) {
if (typeof ref === 'function') {
ref(el);
} else {
ref.current = el;
}
}
},
[ref]
);
useEffect(() => {
if (editorRef.current) {
editorRef.current.updateComplete
.then(() => {
// make sure editor can get keyboard events on showing up
editorRef.current?.querySelector('affine-edgeless-root')?.click();
})
.catch(console.error);
}
}, []);
return (
<div className={styles.affineEdgelessDocViewport}>
<adapted.EdgelessEditor ref={onDocRef} doc={page} specs={specs} />
{portals}
</div>
);
});
@@ -1,30 +0,0 @@
import type { Store } from '@blocksuite/affine/store';
import type { Doc as YDoc, Map as YMap } from 'yjs';
/**
* TODO(@eyhn): Define error to unexpected state together in the future.
*/
export class NoPageRootError extends Error {
constructor(public page: Store) {
super('Page root not found when render editor!');
// Log info to let sentry collect more message
const hasExpectSpace = Array.from(
page.rootDoc.getMap<YDoc>('spaces').values()
).some(doc => page.spaceDoc.guid === doc.guid);
const blocks = page.spaceDoc.getMap('blocks') as YMap<YMap<any>>;
const havePageBlock = Array.from(blocks.values()).some(
block => block.get('sys:flavour') === 'affine:page'
);
console.info(
'NoPageRootError current data: %s',
JSON.stringify({
expectPageId: page.id,
expectGuid: page.spaceDoc.guid,
hasExpectSpace,
blockSize: blocks.size,
havePageBlock,
})
);
}
}
@@ -1,60 +0,0 @@
import { AIChatBlockSpec } from '@affine/core/blocksuite/blocks';
import {
AICodeBlockSpec,
AIImageBlockSpec,
AIParagraphBlockSpec,
} from '@affine/core/blocksuite/presets';
import {
AdapterFactoryExtensions,
AttachmentBlockSpec,
BookmarkBlockSpec,
CodeBlockSpec,
DatabaseBlockSpec,
DataViewBlockSpec,
DefaultOpenDocExtension,
DividerBlockSpec,
EditPropsStore,
EmbedExtensions,
FontLoaderService,
ImageBlockSpec,
LatexBlockSpec,
ListBlockSpec,
ParagraphBlockSpec,
RefNodeSlotsExtension,
RichTextExtensions,
TableBlockSpec,
} from '@blocksuite/affine/blocks';
import type { ExtensionType } from '@blocksuite/affine/store';
const CommonBlockSpecs: ExtensionType[] = [
RefNodeSlotsExtension,
EditPropsStore,
RichTextExtensions,
LatexBlockSpec,
ListBlockSpec,
DatabaseBlockSpec,
TableBlockSpec,
DataViewBlockSpec,
DividerBlockSpec,
EmbedExtensions,
BookmarkBlockSpec,
AttachmentBlockSpec,
AdapterFactoryExtensions,
FontLoaderService,
DefaultOpenDocExtension,
].flat();
export const DefaultBlockSpecs: ExtensionType[] = [
CodeBlockSpec,
ImageBlockSpec,
ParagraphBlockSpec,
...CommonBlockSpecs,
].flat();
export const AIBlockSpecs: ExtensionType[] = [
AICodeBlockSpec,
AIImageBlockSpec,
AIParagraphBlockSpec,
AIChatBlockSpec,
...CommonBlockSpecs,
].flat();
@@ -1,79 +0,0 @@
import { notify } from '@affine/component';
import {
generateUrl,
type UseSharingUrl,
} from '@affine/core/components/hooks/affine/use-share-url';
import { ServerService } from '@affine/core/modules/cloud';
import { EditorService } from '@affine/core/modules/editor';
import { copyLinkToBlockStdScopeClipboard } from '@affine/core/utils/clipboard';
import { I18n } from '@affine/i18n';
import { track } from '@affine/track';
import type {
DatabaseBlockModel,
MenuOptions,
} from '@blocksuite/affine/blocks';
import { menu } from '@blocksuite/affine/blocks';
import { LinkIcon } from '@blocksuite/icons/lit';
import type { FrameworkProvider } from '@toeverything/infra';
export function createDatabaseOptionsConfig(framework: FrameworkProvider) {
return {
configure: (model: DatabaseBlockModel, options: MenuOptions) => {
const items = options.items;
items.splice(2, 0, createCopyLinkToBlockMenuItem(framework, model));
return options;
},
};
}
function createCopyLinkToBlockMenuItem(
framework: FrameworkProvider,
model: DatabaseBlockModel
) {
return menu.action({
name: 'Copy link to block',
prefix: LinkIcon({ width: '20', height: '20' }),
hide: () => {
const { editor } = framework.get(EditorService);
const mode = editor.mode$.value;
return mode === 'edgeless';
},
select: () => {
const serverService = framework.get(ServerService);
const pageId = model.doc.id;
const { editor } = framework.get(EditorService);
const mode = editor.mode$.value;
if (mode === 'edgeless') return;
const workspaceId = editor.doc.workspace.id;
const options: UseSharingUrl = {
workspaceId,
pageId,
mode,
blockIds: [model.id],
};
const str = generateUrl({
...options,
baseUrl: serverService.server.baseUrl,
});
if (!str) return;
const type = model.flavour;
const page = editor.editorContainer$.value;
copyLinkToBlockStdScopeClipboard(str, page?.host?.std.clipboard)
.then(success => {
if (!success) return;
notify.success({ title: I18n['Copied link to clipboard']() });
})
.catch(console.error);
track.doc.editor.toolbar.copyBlockToLink({ type });
},
});
}
@@ -1,276 +0,0 @@
import { AIChatBlockSpec } from '@affine/core/blocksuite/blocks';
import {
AICodeBlockSpec,
AIImageBlockSpec,
AIParagraphBlockSpec,
} from '@affine/core/blocksuite/presets';
import { DocService, DocsService } from '@affine/core/modules/doc';
import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta';
import { EditorSettingService } from '@affine/core/modules/editor-setting';
import { AppThemeService } from '@affine/core/modules/theme';
import { mixpanel } from '@affine/track';
import { LifeCycleWatcher, StdIdentifier } from '@blocksuite/affine/block-std';
import type {
DocDisplayMetaExtension,
DocDisplayMetaParams,
Signal,
SpecBuilder,
TelemetryEventMap,
ThemeExtension,
} from '@blocksuite/affine/blocks';
import {
CodeBlockSpec,
ColorScheme,
createSignalFromObservable,
DatabaseConfigExtension,
DocDisplayMetaProvider,
EditorSettingExtension,
ImageBlockSpec,
ParagraphBlockSpec,
referenceToNode,
RootBlockConfigExtension,
SpecProvider,
TelemetryProvider,
ThemeExtensionIdentifier,
ToolbarMoreMenuConfigExtension,
} from '@blocksuite/affine/blocks';
import type { Container } from '@blocksuite/affine/global/di';
import type { ExtensionType } from '@blocksuite/affine/store';
import { LinkedPageIcon, PageIcon } from '@blocksuite/icons/lit';
import { type FrameworkProvider } from '@toeverything/infra';
import type { TemplateResult } from 'lit';
import type { Observable } from 'rxjs';
import { combineLatest, map } from 'rxjs';
import { getFontConfigExtension } from '../font-extension';
import { createDatabaseOptionsConfig } from './database-block';
import { createLinkedWidgetConfig } from './widgets/linked';
import { createToolbarMoreMenuConfig } from './widgets/toolbar';
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>);
},
}));
},
};
}
function getThemeExtension(framework: FrameworkProvider) {
class AffineThemeExtension
extends LifeCycleWatcher
implements ThemeExtension
{
static override readonly key = 'affine-theme';
private readonly themes: Map<string, Signal<ColorScheme>> = new Map();
protected readonly disposables: (() => void)[] = [];
static override setup(di: Container) {
super.setup(di);
di.override(ThemeExtensionIdentifier, AffineThemeExtension, [
StdIdentifier,
]);
}
getAppTheme() {
const keyName = 'app-theme';
const cache = this.themes.get(keyName);
if (cache) return cache;
const theme$: Observable<ColorScheme> = framework
.get(AppThemeService)
.appTheme.theme$.map(theme => {
return theme === ColorScheme.Dark
? ColorScheme.Dark
: ColorScheme.Light;
});
const { signal: themeSignal, cleanup } =
createSignalFromObservable<ColorScheme>(theme$, ColorScheme.Light);
this.disposables.push(cleanup);
this.themes.set(keyName, themeSignal);
return themeSignal;
}
getEdgelessTheme(docId?: string) {
const doc =
(docId && framework.get(DocsService).list.doc$(docId).getValue()) ||
framework.get(DocService).doc;
const cache = this.themes.get(doc.id);
if (cache) return cache;
const appTheme$ = framework.get(AppThemeService).appTheme.theme$;
const docTheme$ = doc.properties$.map(
props => props.edgelessColorTheme || 'system'
);
const theme$: Observable<ColorScheme> = combineLatest([
appTheme$,
docTheme$,
]).pipe(
map(([appTheme, docTheme]) => {
const theme = docTheme === 'system' ? appTheme : docTheme;
return theme === ColorScheme.Dark
? ColorScheme.Dark
: ColorScheme.Light;
})
);
const { signal: themeSignal, cleanup } =
createSignalFromObservable<ColorScheme>(theme$, ColorScheme.Light);
this.disposables.push(cleanup);
this.themes.set(doc.id, themeSignal);
return themeSignal;
}
override unmounted() {
this.dispose();
}
dispose() {
this.disposables.forEach(dispose => dispose());
}
}
return AffineThemeExtension;
}
export function buildDocDisplayMetaExtension(framework: FrameworkProvider) {
const docDisplayMetaService = framework.get(DocDisplayMetaService);
function iconBuilder(
icon: typeof PageIcon,
size = '1.25em',
style = 'user-select:none;flex-shrink:0;vertical-align:middle;font-size:inherit;margin-bottom:0.1em;'
) {
return icon({
width: size,
height: size,
style,
});
}
class AffineDocDisplayMetaService
extends LifeCycleWatcher
implements DocDisplayMetaExtension
{
static override key = 'doc-display-meta';
readonly disposables: (() => void)[] = [];
static override setup(di: Container) {
super.setup(di);
di.override(DocDisplayMetaProvider, this, [StdIdentifier]);
}
dispose() {
while (this.disposables.length > 0) {
this.disposables.pop()?.();
}
}
icon(
docId: string,
{ params, title, referenced }: DocDisplayMetaParams = {}
): Signal<TemplateResult> {
const icon$ = docDisplayMetaService
.icon$(docId, {
type: 'lit',
title,
reference: referenced,
referenceToNode: referenceToNode({ pageId: docId, params }),
})
.map(iconBuilder);
const { signal: iconSignal, cleanup } = createSignalFromObservable(
icon$,
iconBuilder(referenced ? LinkedPageIcon : PageIcon)
);
this.disposables.push(cleanup);
return iconSignal;
}
title(
docId: string,
{ title, referenced }: DocDisplayMetaParams = {}
): Signal<string> {
const title$ = docDisplayMetaService.title$(docId, {
title,
reference: referenced,
});
const { signal: titleSignal, cleanup } =
createSignalFromObservable<string>(title$, title ?? '');
this.disposables.push(cleanup);
return titleSignal;
}
override unmounted() {
this.dispose();
}
}
return AffineDocDisplayMetaService;
}
function getEditorConfigExtension(
framework: FrameworkProvider
): ExtensionType[] {
const editorSettingService = framework.get(EditorSettingService);
return [
EditorSettingExtension(editorSettingService.editorSetting.settingSignal),
DatabaseConfigExtension(createDatabaseOptionsConfig(framework)),
RootBlockConfigExtension({
linkedWidget: createLinkedWidgetConfig(framework),
}),
ToolbarMoreMenuConfigExtension(createToolbarMoreMenuConfig(framework)),
];
}
export const extendEdgelessPreviewSpec = (function () {
let _extension: ExtensionType;
let _framework: FrameworkProvider;
return function (framework: FrameworkProvider) {
if (framework === _framework && _extension) {
return _extension;
} else {
_extension && SpecProvider._.omitSpec('preview:edgeless', _extension);
_extension = getThemeExtension(framework);
_framework = framework;
SpecProvider._.extendSpec('preview:edgeless', [_extension]);
return _extension;
}
};
})();
export function enableAffineExtension(
framework: FrameworkProvider,
specBuilder: SpecBuilder
): void {
specBuilder.extend(
[
getThemeExtension(framework),
getFontConfigExtension(),
getTelemetryExtension(),
getEditorConfigExtension(framework),
buildDocDisplayMetaExtension(framework),
].flat()
);
}
export function enableAIExtension(specBuilder: SpecBuilder): void {
specBuilder.replace(CodeBlockSpec, AICodeBlockSpec);
specBuilder.replace(ImageBlockSpec, AIImageBlockSpec);
specBuilder.replace(ParagraphBlockSpec, AIParagraphBlockSpec);
specBuilder.extend(AIChatBlockSpec);
}
@@ -1,761 +0,0 @@
import {
type ElementOrFactory,
Input,
notify,
toast,
type ToastOptions,
toReactNode,
type useConfirmModal,
} from '@affine/component';
import { AIChatBlockSchema } from '@affine/core/blocksuite/blocks';
import { WorkspaceServerService } from '@affine/core/modules/cloud';
import { DesktopApiService } from '@affine/core/modules/desktop-api';
import { type DocService, DocsService } from '@affine/core/modules/doc';
import type { EditorService } from '@affine/core/modules/editor';
import { EditorSettingService } from '@affine/core/modules/editor-setting';
import { JournalService } from '@affine/core/modules/journal';
import { resolveLinkToDoc } from '@affine/core/modules/navigation';
import type { PeekViewService } from '@affine/core/modules/peek-view';
import {
CreationQuickSearchSession,
DocsQuickSearchSession,
LinksQuickSearchSession,
QuickSearchService,
RecentDocsQuickSearchSession,
} from '@affine/core/modules/quicksearch';
import { ExternalLinksQuickSearchSession } from '@affine/core/modules/quicksearch/impls/external-links';
import { JournalsQuickSearchSession } from '@affine/core/modules/quicksearch/impls/journals';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { DebugLogger } from '@affine/debug';
import { I18n } from '@affine/i18n';
import { track } from '@affine/track';
import {
BlockServiceWatcher,
type BlockStdScope,
BlockViewIdentifier,
ConfigIdentifier,
LifeCycleWatcher,
type WidgetComponent,
} from '@blocksuite/affine/block-std';
import type {
AffineReference,
CodeBlockConfig,
DocMode,
DocModeProvider,
OpenDocConfig,
OpenDocConfigItem,
PeekOptions,
PeekViewService as BSPeekViewService,
QuickSearchResult,
ReferenceNodeConfig,
RootBlockConfig,
} from '@blocksuite/affine/blocks';
import {
AffineSlashMenuWidget,
AttachmentEmbedConfigIdentifier,
DocModeExtension,
EdgelessRootBlockComponent,
EmbedLinkedDocBlockComponent,
FeatureFlagService,
GenerateDocUrlExtension,
insertLinkByQuickSearchCommand,
NativeClipboardExtension,
NoteConfigExtension,
NotificationExtension,
OpenDocExtension,
ParagraphBlockService,
ParseDocUrlExtension,
PeekViewExtension,
QuickSearchExtension,
ReferenceNodeConfigExtension,
ReferenceNodeConfigIdentifier,
RootBlockConfigExtension,
SidebarExtension,
} from '@blocksuite/affine/blocks';
import type { Container } from '@blocksuite/affine/global/di';
import { Bound } from '@blocksuite/affine/global/utils';
import {
type BlockSnapshot,
type ExtensionType,
Text,
} from '@blocksuite/affine/store';
import type { ReferenceParams } from '@blocksuite/affine-model';
import {
CenterPeekIcon,
ExpandFullIcon,
OpenInNewIcon,
SplitViewIcon,
} from '@blocksuite/icons/lit';
import { type FrameworkProvider } from '@toeverything/infra';
import { html, type TemplateResult } from 'lit';
import { customElement } from 'lit/decorators.js';
import { literal } from 'lit/static-html.js';
import { pick } from 'lodash-es';
import type { DocProps } from '../../../../../blocksuite/initialization';
import { AttachmentEmbedPreview } from '../../../../attachment-viewer/pdf-viewer-embedded';
import { generateUrl } from '../../../../hooks/affine/use-share-url';
import { BlocksuiteEditorJournalDocTitle } from '../../journal-doc-title';
import { EdgelessNoteHeader } from './widgets/edgeless-note-header';
import { createKeyboardToolbarConfig } from './widgets/keyboard-toolbar';
export type ReferenceReactRenderer = (
reference: AffineReference
) => React.ReactElement;
const logger = new DebugLogger('affine::spec-patchers');
function patchSpecService(
flavour: string,
onWidgetConnected?: (component: WidgetComponent) => void
) {
class TempServiceWatcher extends BlockServiceWatcher {
static override readonly flavour = flavour;
override mounted() {
super.mounted();
const disposableGroup = this.blockService.disposables;
if (onWidgetConnected) {
disposableGroup.add(
this.blockService.specSlots.widgetConnected.on(({ component }) => {
onWidgetConnected(component);
})
);
}
}
}
return TempServiceWatcher;
}
/**
* Patch the block specs with custom renderers.
*/
export function patchReferenceRenderer(
reactToLit: (element: ElementOrFactory) => TemplateResult,
reactRenderer: ReferenceReactRenderer
): ExtensionType {
const customContent = (reference: AffineReference) => {
const node = reactRenderer(reference);
return reactToLit(node);
};
return ReferenceNodeConfigExtension({
customContent,
});
}
export function patchNotificationService({
closeConfirmModal,
openConfirmModal,
}: ReturnType<typeof useConfirmModal>) {
return NotificationExtension({
confirm: async ({ title, message, confirmText, cancelText, abort }) => {
return new Promise<boolean>(resolve => {
openConfirmModal({
title: toReactNode(title),
description: toReactNode(message),
confirmText,
confirmButtonOptions: {
variant: 'primary',
},
cancelText,
onConfirm: () => {
resolve(true);
},
onCancel: () => {
resolve(false);
},
});
abort?.addEventListener('abort', () => {
resolve(false);
closeConfirmModal();
});
});
},
prompt: async ({
title,
message,
confirmText,
placeholder,
cancelText,
autofill,
abort,
}) => {
return new Promise<string | null>(resolve => {
let value = autofill || '';
const description = (
<div>
<span style={{ marginBottom: 12 }}>{toReactNode(message)}</span>
<Input
autoSelect={true}
placeholder={placeholder}
defaultValue={value}
onChange={e => (value = e)}
/>
</div>
);
openConfirmModal({
title: toReactNode(title),
description: description,
confirmText: confirmText ?? 'Confirm',
confirmButtonOptions: {
variant: 'primary',
},
cancelText: cancelText ?? 'Cancel',
onConfirm: () => {
resolve(value);
},
onCancel: () => {
resolve(null);
},
autoFocusConfirm: false,
});
abort?.addEventListener('abort', () => {
resolve(null);
closeConfirmModal();
});
});
},
toast: (message: string, options: ToastOptions) => {
return toast(message, options);
},
notify: notification => {
const accentToNotify = {
error: notify.error,
success: notify.success,
warning: notify.warning,
info: notify,
};
const fn = accentToNotify[notification.accent || 'info'];
if (!fn) {
throw new Error('Invalid notification accent');
}
const toastId = fn(
{
title: toReactNode(notification.title),
message: toReactNode(notification.message),
footer: toReactNode(notification.footer),
action: notification.action?.onClick
? {
label: toReactNode(notification.action?.label),
onClick: notification.action.onClick,
}
: undefined,
onDismiss: notification.onClose,
},
{
duration: notification.duration || 0,
onDismiss: notification.onClose,
onAutoClose: notification.onClose,
}
);
notification.abort?.addEventListener('abort', () => {
notify.dismiss(toastId);
});
},
});
}
export function patchOpenDocExtension() {
const openDocConfig: OpenDocConfig = {
items: [
{
type: 'open-in-active-view',
label: I18n['com.affine.peek-view-controls.open-doc'](),
icon: ExpandFullIcon(),
},
BUILD_CONFIG.isElectron
? {
type: 'open-in-new-view',
label:
I18n['com.affine.peek-view-controls.open-doc-in-split-view'](),
icon: SplitViewIcon(),
}
: null,
{
type: 'open-in-new-tab',
label: I18n['com.affine.peek-view-controls.open-doc-in-new-tab'](),
icon: OpenInNewIcon(),
},
{
type: 'open-in-center-peek',
label: I18n['com.affine.peek-view-controls.open-doc-in-center-peek'](),
icon: CenterPeekIcon(),
},
].filter((item): item is OpenDocConfigItem => item !== null),
};
return OpenDocExtension(openDocConfig);
}
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);
}
export function patchDocModeService(
docService: DocService,
docsService: DocsService,
editorService: EditorService
): ExtensionType {
const DEFAULT_MODE = 'page';
class AffineDocModeService implements DocModeProvider {
setEditorMode = (mode: DocMode) => {
editorService.editor.setMode(mode);
};
getEditorMode = () => {
return editorService.editor.mode$.value;
};
setPrimaryMode = (mode: DocMode, id?: string) => {
if (id) {
docsService.list.setPrimaryMode(id, mode);
} else {
docService.doc.setPrimaryMode(mode);
}
};
getPrimaryMode = (id?: string) => {
const mode = id
? docsService.list.getPrimaryMode(id)
: docService.doc.getPrimaryMode();
return (mode || DEFAULT_MODE) as DocMode;
};
togglePrimaryMode = (id?: string) => {
const mode = id
? docsService.list.togglePrimaryMode(id)
: docService.doc.togglePrimaryMode();
return (mode || DEFAULT_MODE) as DocMode;
};
onPrimaryModeChange = (handler: (mode: DocMode) => void, id?: string) => {
const mode$ = id
? docsService.list.primaryMode$(id)
: docService.doc.primaryMode$;
const sub = mode$.subscribe(m => handler((m || DEFAULT_MODE) as DocMode));
return {
dispose: sub.unsubscribe,
};
};
}
const docModeExtension = DocModeExtension(new AffineDocModeService());
return docModeExtension;
}
export function patchQuickSearchService(framework: FrameworkProvider) {
const QuickSearch = QuickSearchExtension({
async openQuickSearch() {
let searchResult: QuickSearchResult = null;
searchResult = await new Promise((resolve, reject) =>
framework.get(QuickSearchService).quickSearch.show(
[
framework.get(RecentDocsQuickSearchSession),
framework.get(CreationQuickSearchSession),
framework.get(DocsQuickSearchSession),
framework.get(LinksQuickSearchSession),
framework.get(ExternalLinksQuickSearchSession),
framework.get(JournalsQuickSearchSession),
],
result => {
if (result === null) {
resolve(null);
return;
}
if (result.source === 'docs') {
resolve({
docId: result.payload.docId,
});
return;
}
if (result.source === 'recent-doc') {
resolve({
docId: result.payload.docId,
});
return;
}
if (result.source === 'link') {
resolve({
docId: result.payload.docId,
params: pick(result.payload, [
'mode',
'blockIds',
'elementIds',
]),
});
return;
}
if (result.source === 'date-picker') {
result.payload
.getDocId()
.then(docId => {
if (docId) {
resolve({ docId });
}
})
.catch(reject);
return;
}
if (result.source === 'external-link') {
const externalUrl = result.payload.url;
resolve({ externalUrl });
return;
}
if (result.source === 'creation') {
const docsService = framework.get(DocsService);
const editorSettingService = framework.get(EditorSettingService);
const mode =
result.id === 'creation:create-edgeless' ? 'edgeless' : 'page';
const docProps: DocProps = {
page: { title: new Text(result.payload.title) },
note: editorSettingService.editorSetting.get('affine:note'),
};
const newDoc = docsService.createDoc({
primaryMode: mode,
docProps,
});
resolve({ docId: newDoc.id });
return;
}
},
{
label: {
i18nKey: 'com.affine.cmdk.insert-links',
},
placeholder: {
i18nKey: 'com.affine.cmdk.docs.placeholder',
},
}
)
);
return searchResult;
},
});
const SlashMenuQuickSearchExtension = patchSpecService(
'affine:page',
(component: WidgetComponent) => {
if (component instanceof AffineSlashMenuWidget) {
component.config.items.forEach(item => {
if (
'action' in item &&
(item.name === 'Linked Doc' || item.name === 'Link')
) {
item.action = async ({ rootComponent }) => {
const [success, { insertedLinkType }] =
rootComponent.std.command.exec(insertLinkByQuickSearchCommand);
if (!success) return;
insertedLinkType
?.then(type => {
const flavour = type?.flavour;
if (!flavour) return;
if (flavour === 'affine:bookmark') {
track.doc.editor.slashMenu.bookmark();
return;
}
if (flavour === 'affine:embed-linked-doc') {
track.doc.editor.slashMenu.linkDoc({
control: 'linkDoc',
});
return;
}
})
.catch(console.error);
};
}
});
}
}
);
return [QuickSearch, SlashMenuQuickSearchExtension];
}
export function patchParseDocUrlExtension(framework: FrameworkProvider) {
const workspaceService = framework.get(WorkspaceService);
const ParseDocUrl = ParseDocUrlExtension({
parseDocUrl(url) {
const info = resolveLinkToDoc(url);
if (!info || info.workspaceId !== workspaceService.workspace.id) return;
delete info.refreshKey;
return info;
},
});
return [ParseDocUrl];
}
export function patchGenerateDocUrlExtension(framework: FrameworkProvider) {
const workspaceService = framework.get(WorkspaceService);
const workspaceServerService = framework.get(WorkspaceServerService);
const GenerateDocUrl = GenerateDocUrlExtension({
generateDocUrl(pageId: string, params?: ReferenceParams) {
return generateUrl({
...params,
pageId,
workspaceId: workspaceService.workspace.id,
baseUrl: workspaceServerService.server?.baseUrl ?? location.origin,
});
},
});
return [GenerateDocUrl];
}
export function patchEdgelessClipboard() {
class EdgelessClipboardWatcher extends BlockServiceWatcher {
static override readonly flavour = 'affine:page';
override mounted() {
super.mounted();
this.blockService.disposables.add(
this.blockService.specSlots.viewConnected.on(view => {
const { component } = view;
if (component instanceof EdgelessRootBlockComponent) {
const AIChatBlockFlavour = AIChatBlockSchema.model.flavour;
const createFunc = (block: BlockSnapshot) => {
const {
xywh,
scale,
messages,
sessionId,
rootDocId,
rootWorkspaceId,
} = block.props;
const blockId = component.service.crud.addBlock(
AIChatBlockFlavour,
{
xywh,
scale,
messages,
sessionId,
rootDocId,
rootWorkspaceId,
},
component.surface.model.id
);
return blockId;
};
component.clipboardController.registerBlock(
AIChatBlockFlavour,
createFunc
);
}
})
);
}
}
return EdgelessClipboardWatcher;
}
@customElement('affine-linked-doc-ref-block')
export class LinkedDocBlockComponent extends EmbedLinkedDocBlockComponent {
override getInitialState() {
return {
loading: false,
isBannerEmpty: true,
};
}
}
export function patchForSharedPage() {
const extension: ExtensionType = {
setup: di => {
di.override(
BlockViewIdentifier('affine:embed-linked-doc'),
() => literal`affine-linked-doc-ref-block`
);
di.override(
BlockViewIdentifier('affine:embed-synced-doc'),
() => literal`affine-linked-doc-ref-block`
);
},
};
return extension;
}
export function patchForMobile() {
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);
}
static override setup(di: Container) {
super.setup(di);
// Hide reference popup on mobile.
{
const prev = di.getFactory(ReferenceNodeConfigIdentifier);
di.override(ReferenceNodeConfigIdentifier, provider => {
return {
...prev?.(provider),
hidePopup: true,
} satisfies ReferenceNodeConfig;
});
}
// Hide number lines for code block on mobile.
{
const codeConfigIdentifier = ConfigIdentifier('affine:code');
const prev = di.getFactory(codeConfigIdentifier);
di.override(codeConfigIdentifier, provider => {
return {
...prev?.(provider),
showLineNumbers: false,
} satisfies CodeBlockConfig;
});
}
}
override mounted() {
// remove slash placeholder for mobile: `type / ...`
{
const paragraphService = this.std.get(ParagraphBlockService);
if (!paragraphService) return;
paragraphService.placeholderGenerator = 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.type];
};
}
}
}
const extensions: ExtensionType[] = [
{
setup: di => {
const prev = di.getFactory(RootBlockConfigExtension.identifier);
di.override(RootBlockConfigExtension.identifier, provider => {
return {
...prev?.(provider),
keyboardToolbar: createKeyboardToolbarConfig(),
} satisfies RootBlockConfig;
});
},
},
MobileSpecsPatches,
];
return extensions;
}
export function patchForAttachmentEmbedViews(
reactToLit: (
element: ElementOrFactory,
rerendering?: boolean
) => TemplateResult
): ExtensionType {
return {
setup: di => {
di.override(AttachmentEmbedConfigIdentifier('pdf'), () => ({
name: 'pdf',
check: (model, maxFileSize) =>
model.type === 'application/pdf' && model.size <= maxFileSize,
action: model => {
const bound = Bound.deserialize(model.xywh);
bound.w = 537 + 24 + 2;
bound.h = 759 + 46 + 24 + 2;
model.doc.updateBlock(model, {
embed: true,
style: 'pdf',
xywh: bound.serialize(),
});
},
template: (model, _blobUrl) =>
reactToLit(<AttachmentEmbedPreview model={model} />, false),
}));
},
};
}
export function patchForClipboardInElectron(framework: FrameworkProvider) {
const desktopApi = framework.get(DesktopApiService);
return NativeClipboardExtension({
copyAsPNG: desktopApi.handler.clipboard.copyAsPNG,
});
}
export function patchForEdgelessNoteConfig(
framework: FrameworkProvider,
reactToLit: (element: ElementOrFactory) => TemplateResult
) {
return NoteConfigExtension({
edgelessNoteHeader: ({ note }) =>
reactToLit(<EdgelessNoteHeader note={note} />),
pageBlockTitle: ({ note }) => {
const journalService = framework.get(JournalService);
const isJournal = !!journalService.journalDate$(note.doc.id).value;
if (isJournal) {
return reactToLit(<BlocksuiteEditorJournalDocTitle page={note.doc} />);
} else {
return html`<doc-title .doc=${note.doc}></doc-title>`;
}
},
});
}
export function patchSideBarService(framework: FrameworkProvider) {
const { workbench } = framework.get(WorkbenchService);
return SidebarExtension({
open: (tabId?: string) => {
workbench.openSidebar();
workbench.activeView$.value.activeSidebarTab(tabId ?? null);
},
close: () => {
workbench.closeSidebar();
},
getTabIds: () => {
return workbench.activeView$.value.sidebarTabs$.value.map(tab => tab.id);
},
});
}
@@ -1,227 +0,0 @@
import { notify } from '@affine/component';
import { isMindmapChild, isMindMapRoot } from '@affine/core/blocksuite/presets';
import { EditorService } from '@affine/core/modules/editor';
import { apis } from '@affine/electron-api';
import { I18n } from '@affine/i18n';
import type { BlockStdScope } from '@blocksuite/affine/block-std';
import {
type GfxBlockElementModel,
GfxControllerIdentifier,
type GfxModel,
GfxPrimitiveElementModel,
isGfxGroupCompatibleModel,
} from '@blocksuite/affine/block-std/gfx';
import type { MenuContext } from '@blocksuite/affine/blocks';
import { Bound, getCommonBound } from '@blocksuite/affine/global/utils';
import { CopyAsImgaeIcon } from '@blocksuite/icons/lit';
import type { FrameworkProvider } from '@toeverything/infra';
const snapshotStyle = `
affine-edgeless-root .widgets-container,
.copy-as-image-transparent {
opacity: 0;
}
.edgeless-background {
background-image: none;
}
`;
function getSelectedRect() {
const selected = document
.querySelector('edgeless-selected-rect')
?.shadowRoot?.querySelector('.affine-edgeless-selected-rect');
if (!selected) {
throw new Error('Missing edgeless selected rect');
}
return selected.getBoundingClientRect();
}
function expandBound(bound: Bound, margin: number) {
const x = bound.x - margin;
const y = bound.y - margin;
const w = bound.w + margin * 2;
const h = bound.h + margin * 2;
return new Bound(x, y, w, h);
}
function isOverlap(target: Bound, source: Bound) {
const { x, y, w, h } = source;
const left = target.x;
const top = target.y;
const right = target.x + target.w;
const bottom = target.y + target.h;
return x < right && y < bottom && x + w > left && y + h > top;
}
function isInside(target: Bound, source: Bound) {
const { x, y, w, h } = source;
const left = target.x;
const top = target.y;
const right = target.x + target.w;
const bottom = target.y + target.h;
return x >= left && y >= top && x + w <= right && y + h <= bottom;
}
function hideEdgelessElements(elements: GfxModel[], std: BlockStdScope) {
elements.forEach(ele => {
if (ele instanceof GfxPrimitiveElementModel) {
(ele as any).lastOpacity = ele.opacity;
ele.opacity = 0;
} else {
const block = std.view.getBlock(ele.id);
if (!block) return;
block.classList.add('copy-as-image-transparent');
}
});
}
function showEdgelessElements(elements: GfxModel[], std: BlockStdScope) {
elements.forEach(ele => {
if (ele instanceof GfxPrimitiveElementModel) {
ele.opacity = (ele as any).lastOpacity;
delete (ele as any).lastOpacity;
} else {
const block = std.view.getBlock(ele.id);
if (!block) return;
block.classList.remove('copy-as-image-transparent');
}
});
}
function withDescendantElements(elements: GfxModel[]) {
const set = new Set<GfxModel>();
elements.forEach(element => {
if (set.has(element)) return;
set.add(element);
if (isGfxGroupCompatibleModel(element)) {
element.descendantElements.forEach(descendant => set.add(descendant));
}
});
return [...set];
}
const MARGIN = 20;
export function createCopyAsPngMenuItem(framework: FrameworkProvider) {
return {
icon: CopyAsImgaeIcon({ width: '20', height: '20' }),
label: 'Copy as Image',
type: 'copy-as-image',
when: (ctx: MenuContext) => {
if (ctx.isEmpty()) return false;
const { editor } = framework.get(EditorService);
const mode = editor.mode$.value;
return mode === 'edgeless';
},
action: async (ctx: MenuContext) => {
if (!apis) {
notify.error({
title: I18n.t('com.affine.copy.asImage.notAvailable.title'),
message: I18n.t('com.affine.copy.asImage.notAvailable.message'),
action: {
label: I18n.t('com.affine.copy.asImage.notAvailable.action'),
onClick: () => {
window.open('https://affine.pro/download');
},
},
});
return;
}
const gfx = ctx.host.std.get(GfxControllerIdentifier);
let selected = gfx.selection.selectedElements;
// select mindmap if root node selected
const maybeMindmap = selected[0];
const mindmapId = maybeMindmap.group?.id;
if (
selected.length === 1 &&
mindmapId &&
(isMindMapRoot(maybeMindmap) || isMindmapChild(maybeMindmap))
) {
gfx.selection.set({ elements: [mindmapId] });
}
// select bound
selected = gfx.selection.selectedElements;
const elements = withDescendantElements(selected);
const bounds = elements.map(element => Bound.deserialize(element.xywh));
const bound = getCommonBound(bounds);
if (!bound) return;
const { zoom } = gfx.viewport;
const exBound = expandBound(bound, MARGIN * zoom);
// fit to screen
if (
!isInside(gfx.viewport.viewportBounds, exBound) ||
gfx.viewport.zoom < 1
) {
gfx.viewport.setViewportByBound(bound, [20, 20, 20, 20], false);
if (gfx.viewport.zoom > 1) {
gfx.viewport.setZoom(1);
}
}
// hide unselected overlap elements
const overlapElements = gfx.gfxElements.filter(ele => {
const eleBound = Bound.deserialize(ele.xywh);
const exEleBound = expandBound(eleBound, MARGIN * zoom);
const isSelected = elements.includes(ele);
return !isSelected && isOverlap(exBound, exEleBound);
});
hideEdgelessElements(overlapElements, ctx.host.std);
// add css style
const styleEle = document.createElement('style');
styleEle.innerHTML = snapshotStyle;
document.head.append(styleEle);
// capture image
setTimeout(() => {
if (!apis) return;
try {
const domRect = getSelectedRect();
const { zoom } = gfx.viewport;
const isFrameSelected =
selected.length === 1 &&
(selected[0] as GfxBlockElementModel).flavour === 'affine:frame';
const margin = isFrameSelected ? -2 : MARGIN * zoom;
gfx.selection.clear();
apis.ui
.captureArea({
x: domRect.left - margin,
y: domRect.top - margin,
width: domRect.width + margin * 2,
height: domRect.height + margin * 2,
})
.then(() => {
notify.success({
title: I18n.t('com.affine.copy.asImage.success'),
});
})
.catch(e => {
notify.error({
title: I18n.t('com.affine.copy.asImage.failed'),
message: String(e),
});
})
.finally(() => {
styleEle.remove();
showEdgelessElements(overlapElements, ctx.host.std);
});
} catch (e) {
styleEle.remove();
showEdgelessElements(overlapElements, ctx.host.std);
notify.error({
title: I18n.t('com.affine.copy.asImage.failed'),
message: String(e),
});
}
}, 100);
},
};
}
@@ -1,34 +0,0 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
const headerPadding = 8;
export const header = style({
position: 'relative',
display: 'flex',
alignItems: 'center',
gap: 4,
padding: headerPadding,
zIndex: 2, // should have higher z-index than the note mask
pointerEvents: 'none',
});
export const title = style({
display: 'flex',
alignItems: 'center',
gap: 4,
flex: 1,
color: cssVarV2('text/primary'),
fontFamily: 'Inter',
fontWeight: 600,
lineHeight: '30px',
});
export const iconSize = 24;
const buttonPadding = 4;
export const button = style({
padding: buttonPadding,
pointerEvents: 'auto',
color: cssVarV2('icon/transparentBlack'),
});
export const headerHeight = 2 * headerPadding + iconSize + 2 * buttonPadding;
@@ -1,194 +0,0 @@
import { IconButton } from '@affine/component';
import { useSharingUrl } from '@affine/core/components/hooks/affine/use-share-url';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { DocService } from '@affine/core/modules/doc';
import { EditorService } from '@affine/core/modules/editor';
import { useInsidePeekView } from '@affine/core/modules/peek-view/view/modal-container';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { extractEmojiIcon } from '@affine/core/utils';
import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';
import { GfxControllerIdentifier } from '@blocksuite/affine/block-std/gfx';
import { type NoteBlockModel } from '@blocksuite/affine/blocks';
import { Bound } from '@blocksuite/affine/global/utils';
import {
InformationIcon,
LinkedPageIcon,
LinkIcon,
ToggleDownIcon,
ToggleRightIcon,
} from '@blocksuite/icons/rc';
import { useLiveData, useService, useServices } from '@toeverything/infra';
import { useCallback, useEffect, useMemo, useState } from 'react';
import * as styles from './edgeless-note-header.css';
const EdgelessNoteToggleButton = ({ note }: { note: NoteBlockModel }) => {
const t = useI18n();
const [collapsed, setCollapsed] = useState(note.edgeless.collapse);
const editor = useService(EditorService).editor;
const editorContainer = useLiveData(editor.editorContainer$);
const gfx = editorContainer?.std.get(GfxControllerIdentifier);
const { doc } = useService(DocService);
const title = useLiveData(doc.title$);
// only render emoji if it exists (mode or journal icon will not be rendered)
const { emoji, rest: titleWithoutEmoji } = useMemo(
() => extractEmojiIcon(title),
[title]
);
useEffect(() => {
return note.edgeless$.subscribe(({ collapse, collapsedHeight }) => {
if (
collapse &&
collapsedHeight &&
Math.abs(collapsedHeight - styles.headerHeight) < 1
) {
setCollapsed(true);
} else {
setCollapsed(false);
}
});
}, [note.edgeless$]);
useEffect(() => {
if (!gfx) return;
const { selection } = gfx;
const dispose = selection.slots.updated.on(() => {
if (selection.has(note.id) && selection.editing) {
note.doc.transact(() => {
note.edgeless.collapse = false;
});
}
});
return () => dispose.dispose();
}, [gfx, note]);
const toggle = useCallback(() => {
note.doc.transact(() => {
if (collapsed) {
note.edgeless.collapse = false;
} else {
const bound = Bound.deserialize(note.xywh);
bound.h = styles.headerHeight * (note.edgeless.scale ?? 1);
note.xywh = bound.serialize();
note.edgeless.collapse = true;
note.edgeless.collapsedHeight = styles.headerHeight;
gfx?.selection.clear();
}
});
}, [collapsed, gfx, note]);
return (
<>
<IconButton
className={styles.button}
size={styles.iconSize}
tooltip={t['com.affine.editor.edgeless-note-header.fold-page-block']()}
data-testid="edgeless-note-toggle-button"
onClick={toggle}
>
{collapsed ? <ToggleRightIcon /> : <ToggleDownIcon />}
</IconButton>
<div className={styles.title} data-testid="edgeless-note-title">
{collapsed && (
<>
{emoji && <span>{emoji}</span>}
<span>{titleWithoutEmoji}</span>
</>
)}
</div>
</>
);
};
const ViewInPageButton = () => {
const t = useI18n();
const editor = useService(EditorService).editor;
const viewInPage = useCallback(() => {
editor.setMode('page');
}, [editor]);
return (
<IconButton
className={styles.button}
size={styles.iconSize}
tooltip={t['com.affine.editor.edgeless-note-header.view-in-page']()}
data-testid="edgeless-note-view-in-page-button"
onClick={viewInPage}
>
<LinkedPageIcon />
</IconButton>
);
};
const InfoButton = ({ note }: { note: NoteBlockModel }) => {
const t = useI18n();
const workspaceDialogService = useService(WorkspaceDialogService);
const onOpenInfoModal = useCallback(() => {
track.doc.editor.pageBlockHeader.openDocInfo();
workspaceDialogService.open('doc-info', { docId: note.doc.id });
}, [note.doc.id, workspaceDialogService]);
return (
<IconButton
className={styles.button}
size={styles.iconSize}
tooltip={t['com.affine.page-properties.page-info.view']()}
data-testid="edgeless-note-info-button"
onClick={onOpenInfoModal}
>
<InformationIcon />
</IconButton>
);
};
const LinkButton = ({ note }: { note: NoteBlockModel }) => {
const t = useI18n();
const { workspaceService, editorService } = useServices({
WorkspaceService,
EditorService,
});
const { onClickCopyLink } = useSharingUrl({
workspaceId: workspaceService.workspace.id,
pageId: editorService.editor.doc.id,
});
const copyLink = useCallback(() => {
onClickCopyLink('edgeless', [note.id]);
}, [note.id, onClickCopyLink]);
return (
<IconButton
className={styles.button}
size={styles.iconSize}
tooltip={t['com.affine.share-menu.copy']()}
data-testid="edgeless-note-link-button"
onClick={copyLink}
>
<LinkIcon />
</IconButton>
);
};
export const EdgelessNoteHeader = ({ note }: { note: NoteBlockModel }) => {
const insidePeekView = useInsidePeekView();
if (!note.isPageBlock()) return null;
return (
<div className={styles.header} data-testid="edgeless-page-block-header">
<EdgelessNoteToggleButton note={note} />
<ViewInPageButton />
{!insidePeekView && <InfoButton note={note} />}
<LinkButton note={note} />
</div>
);
};
@@ -1,9 +0,0 @@
import { type KeyboardToolbarConfig } from '@blocksuite/affine/blocks';
export function createKeyboardToolbarConfig(): Partial<KeyboardToolbarConfig> {
return {
// TODO(@L-Sun): check android following the PR
// https://github.com/toeverything/blocksuite/pull/8645
useScreenHeight: BUILD_CONFIG.isIOS,
};
}
@@ -1,9 +0,0 @@
import { AtMenuConfigService } from '@affine/core/modules/at-menu-config/services';
import type { LinkedWidgetConfig } from '@blocksuite/affine/blocks';
import { type FrameworkProvider } from '@toeverything/infra';
export function createLinkedWidgetConfig(
framework: FrameworkProvider
): Partial<LinkedWidgetConfig> {
return framework.get(AtMenuConfigService).getConfig();
}
@@ -1,138 +0,0 @@
import { notify } from '@affine/component';
import {
generateUrl,
type UseSharingUrl,
} from '@affine/core/components/hooks/affine/use-share-url';
import { WorkspaceServerService } from '@affine/core/modules/cloud';
import { EditorService } from '@affine/core/modules/editor';
import { copyLinkToBlockStdScopeClipboard } from '@affine/core/utils/clipboard';
import { I18n } from '@affine/i18n';
import { track } from '@affine/track';
import type {
GfxBlockElementModel,
GfxPrimitiveElementModel,
} from '@blocksuite/affine/block-std/gfx';
import type { MenuContext, MenuItemGroup } from '@blocksuite/affine/blocks';
import { LinkIcon } from '@blocksuite/icons/lit';
import type { FrameworkProvider } from '@toeverything/infra';
import { createCopyAsPngMenuItem } from './copy-as-image';
export function createToolbarMoreMenuConfig(framework: FrameworkProvider) {
return {
configure: <T extends MenuContext>(groups: MenuItemGroup<T>[]) => {
const clipboardGroup = groups.find(group => group.type === 'clipboard');
if (clipboardGroup) {
let copyIndex = clipboardGroup.items.findIndex(
item => item.type === 'copy'
);
if (copyIndex === -1) {
copyIndex = clipboardGroup.items.findIndex(
item => item.type === 'duplicate'
);
if (copyIndex !== -1) {
copyIndex -= 1;
}
}
// after `copy` or before `duplicate`
clipboardGroup.items.splice(
copyIndex + 1,
0,
createCopyLinkToBlockMenuItem(framework)
);
clipboardGroup.items.splice(
copyIndex + 1,
0,
createCopyAsPngMenuItem(framework)
);
}
return groups;
},
};
}
function createCopyLinkToBlockMenuItem(
framework: FrameworkProvider,
item = {
icon: LinkIcon({ width: '20', height: '20' }),
label: 'Copy link to block',
type: 'copy-link-to-block',
when: (ctx: MenuContext) => {
if (ctx.isEmpty()) return false;
const { editor } = framework.get(EditorService);
const mode = editor.mode$.value;
if (mode === 'edgeless') {
// linking blocks in notes is currently not supported in edgeless mode.
if (ctx.selectedBlockModels.length > 0) {
return false;
}
// linking single block/element in edgeless mode.
if (ctx.isMultiple()) {
return false;
}
}
return true;
},
}
) {
return {
...item,
action: async (ctx: MenuContext) => {
const workspaceServerService = framework.get(WorkspaceServerService);
const { editor } = framework.get(EditorService);
const mode = editor.mode$.value;
const pageId = editor.doc.id;
const workspaceId = editor.doc.workspace.id;
const options: UseSharingUrl = { workspaceId, pageId, mode };
let type = '';
if (mode === 'page') {
// maybe multiple blocks
const blockIds = ctx.selectedBlockModels.map(model => model.id);
options.blockIds = blockIds;
type = ctx.selectedBlockModels[0].flavour;
} else if (mode === 'edgeless' && ctx.firstElement) {
// single block/element
const id = ctx.firstElement.id;
if (ctx.isElement()) {
options.elementIds = [id];
type = (ctx.firstElement as GfxPrimitiveElementModel).type;
} else {
options.blockIds = [id];
type = (ctx.firstElement as GfxBlockElementModel).flavour;
}
}
const str = generateUrl({
...options,
baseUrl: workspaceServerService.server?.baseUrl ?? location.origin,
});
if (!str) {
ctx.close();
return;
}
const success = await copyLinkToBlockStdScopeClipboard(
str,
ctx.std.clipboard
);
if (success) {
notify.success({ title: I18n['Copied link to clipboard']() });
}
track.doc.editor.toolbar.copyBlockToLink({ type });
ctx.close();
},
};
}
@@ -1,40 +0,0 @@
import { createAIEdgelessRootBlockSpec } from '@affine/core/blocksuite/presets';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { builtInTemplates as builtInEdgelessTemplates } from '@affine/templates/edgeless';
import { builtInTemplates as builtInStickersTemplates } from '@affine/templates/stickers';
import type { SpecBuilder, TemplateManager } from '@blocksuite/affine/blocks';
import {
EdgelessRootBlockSpec,
EdgelessTemplatePanel,
SpecProvider,
} from '@blocksuite/affine/blocks';
import { type FrameworkProvider } from '@toeverything/infra';
import { enableAffineExtension, enableAIExtension } from './custom/root-block';
export function createEdgelessModeSpecs(
framework: FrameworkProvider
): SpecBuilder {
const featureFlagService = framework.get(FeatureFlagService);
const enableAI = featureFlagService.flags.enable_ai.value;
const edgelessSpec = SpecProvider._.getSpec('edgeless');
enableAffineExtension(framework, edgelessSpec);
if (enableAI) {
enableAIExtension(edgelessSpec);
edgelessSpec.replace(
EdgelessRootBlockSpec,
createAIEdgelessRootBlockSpec(framework)
);
}
return edgelessSpec;
}
export function effects() {
EdgelessTemplatePanel.templates.extend(
builtInStickersTemplates as TemplateManager
);
EdgelessTemplatePanel.templates.extend(
builtInEdgelessTemplates as TemplateManager
);
}
@@ -1,13 +0,0 @@
import {
AffineCanvasTextFonts,
FontConfigExtension,
} from '@blocksuite/affine/blocks';
export function getFontConfigExtension() {
return FontConfigExtension(
AffineCanvasTextFonts.map(font => ({
...font,
url: environment.publicPath + 'fonts/' + font.url.split('/').pop(),
}))
);
}
@@ -1,23 +0,0 @@
import { createAIPageRootBlockSpec } from '@affine/core/blocksuite/presets';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import {
PageRootBlockSpec,
type SpecBuilder,
SpecProvider,
} from '@blocksuite/affine/blocks';
import { type FrameworkProvider } from '@toeverything/infra';
import { enableAffineExtension, enableAIExtension } from './custom/root-block';
export function createPageModeSpecs(framework: FrameworkProvider): SpecBuilder {
const featureFlagService = framework.get(FeatureFlagService);
const enableAI = featureFlagService.flags.enable_ai.value;
const provider = SpecProvider._;
const pageSpec = provider.getSpec('page');
enableAffineExtension(framework, pageSpec);
if (enableAI) {
enableAIExtension(pageSpec);
pageSpec.replace(PageRootBlockSpec, createAIPageRootBlockSpec(framework));
}
return pageSpec;
}
@@ -1,113 +0,0 @@
import { AIChatBlockSpec } from '@affine/core/blocksuite/blocks';
import { PeekViewService } from '@affine/core/modules/peek-view/services/peek-view';
import { AppThemeService } from '@affine/core/modules/theme';
import {
type BlockStdScope,
LifeCycleWatcher,
StdIdentifier,
} from '@blocksuite/affine/block-std';
import {
ColorScheme,
createSignalFromObservable,
type Signal,
type SpecBuilder,
SpecProvider,
type ThemeExtension,
ThemeExtensionIdentifier,
} from '@blocksuite/affine/blocks';
import type { Container } from '@blocksuite/affine/global/di';
import type { ExtensionType } from '@blocksuite/affine/store';
import type { FrameworkProvider } from '@toeverything/infra';
import type { Observable } from 'rxjs';
import { buildDocDisplayMetaExtension } from './custom/root-block';
import { patchPeekViewService } from './custom/spec-patchers';
import { getFontConfigExtension } from './font-extension';
const CustomSpecs: ExtensionType[] = [
AIChatBlockSpec,
getFontConfigExtension(),
].flat();
function patchPreviewSpec(
id: 'preview:edgeless' | 'preview:page',
specs: ExtensionType[]
) {
const specProvider = SpecProvider._;
specProvider.extendSpec(id, specs);
}
export function effects() {
// Patch edgeless preview spec for blocksuite surface-ref and embed-synced-doc
patchPreviewSpec('preview:edgeless', CustomSpecs);
}
export function getPagePreviewThemeExtension(framework: FrameworkProvider) {
class AffinePagePreviewThemeExtension
extends LifeCycleWatcher
implements ThemeExtension
{
static override readonly key = 'affine-page-preview-theme';
readonly theme: Signal<ColorScheme>;
readonly disposables: (() => void)[] = [];
static override setup(di: Container) {
super.setup(di);
di.override(ThemeExtensionIdentifier, AffinePagePreviewThemeExtension, [
StdIdentifier,
]);
}
constructor(std: BlockStdScope) {
super(std);
const theme$: Observable<ColorScheme> = framework
.get(AppThemeService)
.appTheme.theme$.map(theme => {
return theme === ColorScheme.Dark
? ColorScheme.Dark
: ColorScheme.Light;
});
const { signal, cleanup } = createSignalFromObservable<ColorScheme>(
theme$,
ColorScheme.Light
);
this.theme = signal;
this.disposables.push(cleanup);
}
getAppTheme() {
return this.theme;
}
getEdgelessTheme() {
return this.theme;
}
override unmounted() {
this.dispose();
}
dispose() {
this.disposables.forEach(dispose => dispose());
}
}
return AffinePagePreviewThemeExtension;
}
export function createPageModePreviewSpecs(
framework: FrameworkProvider
): SpecBuilder {
const specProvider = SpecProvider._;
const pagePreviewSpec = specProvider.getSpec('preview:page');
// Enable theme extension, doc display meta extension and peek view service
const peekViewService = framework.get(PeekViewService);
pagePreviewSpec.extend([
getPagePreviewThemeExtension(framework),
buildDocDisplayMetaExtension(framework),
patchPeekViewService(peekViewService),
]);
return pagePreviewSpec;
}
@@ -1,75 +0,0 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
import { container } from './bi-directional-link-panel.css';
export const root = style([
container,
{
paddingBottom: 6,
display: 'flex',
gap: 8,
alignItems: 'center',
fontSize: 12,
fontWeight: 400,
lineHeight: '20px',
color: cssVarV2.text.primary,
},
]);
export const badges = style({
display: 'flex',
gap: 12,
alignItems: 'center',
});
export const badge = style({
display: 'flex',
alignItems: 'center',
gap: 4,
padding: '2px 8px',
borderRadius: 40,
backgroundColor: cssVarV2.layer.background.secondary,
cursor: 'pointer',
userSelect: 'none',
position: 'relative',
':before': {
content: '""',
position: 'absolute',
left: 0,
top: 0,
width: '100%',
height: '100%',
backgroundColor: 'rgba(0,0,0,.04)',
borderRadius: 'inherit',
opacity: 0,
transition: 'opacity 0.2s ease',
},
selectors: {
'&:hover:before': {
opacity: 1,
},
'&[data-active="true"]:before': {
opacity: 1,
},
},
});
export const badgeIcon = style({
fontSize: 20,
lineHeight: 0,
color: cssVarV2.icon.primary,
});
export const aiIcon = style({
color: cssVarV2.icon.activated,
});
export const badgeText = style({
fontSize: 15,
lineHeight: '24px',
whiteSpace: 'nowrap',
});
@@ -1,180 +0,0 @@
import {
handleInlineAskAIAction,
pageAIGroups,
} from '@affine/core/blocksuite/presets';
import { DocsService } from '@affine/core/modules/doc';
import { EditorService } from '@affine/core/modules/editor';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { TemplateDocService } from '@affine/core/modules/template-doc';
import { TemplateListMenu } from '@affine/core/modules/template-doc/view/template-list-menu';
import { useI18n } from '@affine/i18n';
import track from '@affine/track';
import { PageRootBlockComponent } from '@blocksuite/affine/blocks';
import type { Store } from '@blocksuite/affine/store';
import {
AiIcon,
EdgelessIcon,
TemplateColoredIcon,
} from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx';
import {
forwardRef,
type HTMLAttributes,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { useAsyncCallback } from '../../hooks/affine-async-hooks';
import * as styles from './starter-bar.css';
const Badge = forwardRef<
HTMLLIElement,
HTMLAttributes<HTMLLIElement> & {
icon: React.ReactNode;
text: string;
active?: boolean;
}
>(function Badge({ icon, text, className, active, ...attrs }, ref) {
return (
<li
data-active={active}
className={clsx(styles.badge, className)}
ref={ref}
{...attrs}
>
<span className={styles.badgeText}>{text}</span>
<span className={styles.badgeIcon}>{icon}</span>
</li>
);
});
const StarterBarNotEmpty = ({ doc }: { doc: Store }) => {
const t = useI18n();
const templateDocService = useService(TemplateDocService);
const featureFlagService = useService(FeatureFlagService);
const docsService = useService(DocsService);
const editorService = useService(EditorService);
const [templateMenuOpen, setTemplateMenuOpen] = useState(false);
const isTemplate = useLiveData(
useMemo(
() => templateDocService.list.isTemplate$(doc.id),
[doc.id, templateDocService.list]
)
);
const enableAI = useLiveData(featureFlagService.flags.enable_ai.$);
const handleSelectTemplate = useAsyncCallback(
async (templateId: string) => {
await docsService.duplicateFromTemplate(templateId, doc.id);
track.doc.editor.starterBar.quickStart({ with: 'template' });
},
[doc.id, docsService]
);
const startWithEdgeless = useCallback(() => {
const record = docsService.list.doc$(doc.id).value;
record?.setPrimaryMode('edgeless');
editorService.editor.setMode('edgeless');
}, [doc.id, docsService.list, editorService.editor]);
const onTemplateMenuOpenChange = useCallback((open: boolean) => {
if (open) track.doc.editor.starterBar.openTemplateListMenu();
setTemplateMenuOpen(open);
}, []);
const startWithAI = useCallback(() => {
const std = editorService.editor.editorContainer$.value?.std;
if (!std) return;
const rootBlockId = std.host.doc.root?.id;
if (!rootBlockId) return;
const rootComponent = std.view.getBlock(rootBlockId);
if (!(rootComponent instanceof PageRootBlockComponent)) return;
const { id, created } = rootComponent.focusFirstParagraph();
if (created) {
std.view.viewUpdated.once(v => {
if (v.id === id) handleInlineAskAIAction(std.host, pageAIGroups);
});
} else {
handleInlineAskAIAction(std.host, pageAIGroups);
}
}, [editorService.editor]);
const showTemplate = !isTemplate;
if (!enableAI && !showTemplate) {
return null;
}
return (
<div className={styles.root}>
{t['com.affine.page-starter-bar.start']()}
<ul className={styles.badges}>
{enableAI ? (
<Badge
data-testid="start-with-ai-badge"
icon={<AiIcon className={styles.aiIcon} />}
text={t['com.affine.page-starter-bar.ai']()}
onClick={startWithAI}
/>
) : null}
{showTemplate ? (
<TemplateListMenu
onSelect={handleSelectTemplate}
rootOptions={{
open: templateMenuOpen,
onOpenChange: onTemplateMenuOpenChange,
}}
>
<Badge
data-testid="template-docs-badge"
icon={<TemplateColoredIcon />}
text={t['com.affine.page-starter-bar.template']()}
active={templateMenuOpen}
/>
</TemplateListMenu>
) : null}
<Badge
icon={<EdgelessIcon />}
text={t['com.affine.page-starter-bar.edgeless']()}
onClick={startWithEdgeless}
/>
</ul>
</div>
);
};
export const StarterBar = ({ doc }: { doc: Store }) => {
const [isEmpty, setIsEmpty] = useState(doc.isEmpty);
const templateDocService = useService(TemplateDocService);
const isTemplate = useLiveData(
useMemo(
() => templateDocService.list.isTemplate$(doc.id),
[doc.id, templateDocService.list]
)
);
useEffect(() => {
const disposable = doc.slots.blockUpdated.on(() => {
setIsEmpty(doc.isEmpty);
});
return () => {
disposable.dispose();
};
}, [doc]);
if (!isEmpty || isTemplate) return null;
return <StarterBarNotEmpty doc={doc} />;
};
@@ -1,88 +0,0 @@
import { cssVar } from '@toeverything/theme';
import { style, type StyleRule } from '@vanilla-extract/css';
export const docEditorRoot = style({
overflowX: 'clip',
display: 'flex',
flexDirection: 'column',
});
export const affineDocViewport = style({
height: '100%',
flex: 1,
display: 'flex',
flexDirection: 'column',
paddingBottom: '100px',
});
export const affineEdgelessDocViewport = style({
height: '100%',
flex: 1,
});
export const docContainer = style({
display: 'block',
selectors: ['generating', 'finished', 'error'].reduce<
NonNullable<StyleRule['selectors']>
>((rules, state) => {
rules[`&:has(affine-ai-panel-widget[data-state='${state}'])`] = {
paddingBottom: '980px',
};
return rules;
}, {}),
});
export const docEditorGap = style({
display: 'block',
width: '100%',
margin: '0 auto',
paddingTop: 50,
paddingBottom: 50,
cursor: 'text',
flexGrow: 1,
});
const titleTagBasic = style({
fontSize: cssVar('fontH4'),
fontWeight: 600,
padding: '0 4px',
borderRadius: '4px',
marginLeft: '4px',
lineHeight: '0px',
});
export const titleDayTag = style([
titleTagBasic,
{
color: cssVar('textSecondaryColor'),
},
]);
export const titleTodayTag = style([
titleTagBasic,
{
color: cssVar('brandColor'),
},
]);
export const pageReferenceIcon = style({
verticalAlign: 'middle',
fontSize: '1.1em',
transform: 'translate(2px, -1px)',
});
export const docPropertiesTableContainer = style({
display: 'flex',
width: '100%',
justifyContent: 'center',
});
export const docPropertiesTable = style({
display: 'flex',
flexDirection: 'column',
gap: 8,
width: '100%',
maxWidth: cssVar('editorWidth'),
padding: `0 ${cssVar('editorSidePadding', '24px')}`,
'@container': {
[`viewport (width <= 640px)`]: {
padding: '0 16px',
},
},
});
@@ -1,46 +0,0 @@
import { FavoriteTag } from '@affine/core/components/page-list';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
import { toast } from '@affine/core/utils';
import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback } from 'react';
export interface FavoriteButtonProps {
pageId: string;
}
export const useFavorite = (pageId: string) => {
const t = useI18n();
const favAdapter = useService(CompatibleFavoriteItemsAdapter);
const favorite = useLiveData(favAdapter.isFavorite$(pageId, 'doc'));
const toggleFavorite = useCallback(() => {
favAdapter.toggle(pageId, 'doc');
toast(
favorite
? t['com.affine.toastMessage.removedFavorites']()
: t['com.affine.toastMessage.addedFavorites']()
);
}, [favorite, pageId, t, favAdapter]);
return { favorite, toggleFavorite };
};
export const FavoriteButton = ({ pageId }: FavoriteButtonProps) => {
const { favorite, toggleFavorite } = useFavorite(pageId);
const handleFavorite = useCallback(() => {
track.$.header.actions.toggleFavorite();
toggleFavorite();
}, [toggleFavorite]);
return (
<FavoriteTag
data-testid="pin-button"
active={!!favorite}
onClick={handleFavorite}
/>
);
};
@@ -1,28 +0,0 @@
import { IconButton } from '@affine/component';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';
import { InformationIcon } from '@blocksuite/icons/rc';
import { useService } from '@toeverything/infra';
import { useCallback } from 'react';
export const InfoButton = ({ docId }: { docId: string }) => {
const workspaceDialogService = useService(WorkspaceDialogService);
const t = useI18n();
const onOpenInfoModal = useCallback(() => {
track.$.header.actions.openDocInfo();
workspaceDialogService.open('doc-info', { docId });
}, [docId, workspaceDialogService]);
return (
<IconButton
size="20"
tooltip={t['com.affine.page-properties.page-info.view']()}
data-testid="header-info-button"
onClick={onOpenInfoModal}
>
<InformationIcon />
</IconButton>
);
};
@@ -1,40 +0,0 @@
import type { WeekDatePickerHandle } from '@affine/component';
import { WeekDatePicker } from '@affine/component';
import { useJournalRouteHelper } from '@affine/core/components/hooks/use-journal';
import { JournalService } from '@affine/core/modules/journal';
import type { Store } from '@blocksuite/affine/store';
import { useLiveData, useService } from '@toeverything/infra';
import dayjs from 'dayjs';
import { useEffect, useRef, useState } from 'react';
export interface JournalWeekDatePickerProps {
page: Store;
}
const weekStyle = { maxWidth: 800, width: '100%' };
export const JournalWeekDatePicker = ({ page }: JournalWeekDatePickerProps) => {
const handleRef = useRef<WeekDatePickerHandle>(null);
const journalService = useService(JournalService);
const journalDateStr = useLiveData(journalService.journalDate$(page.id));
const journalDate = journalDateStr ? dayjs(journalDateStr) : null;
const { openJournal } = useJournalRouteHelper();
const [date, setDate] = useState(
(journalDate ?? dayjs()).format('YYYY-MM-DD')
);
useEffect(() => {
if (!journalDate) return;
setDate(journalDate.format('YYYY-MM-DD'));
handleRef.current?.setCursor?.(journalDate);
}, [journalDate]);
return (
<WeekDatePicker
data-testid="journal-week-picker"
handleRef={handleRef}
style={weekStyle}
value={date}
onChange={openJournal}
/>
);
};
@@ -1,25 +0,0 @@
import { Button } from '@affine/component';
import { useJournalRouteHelper } from '@affine/core/components/hooks/use-journal';
import { useI18n } from '@affine/i18n';
import { useCallback } from 'react';
export const JournalTodayButton = () => {
const t = useI18n();
const journalHelper = useJournalRouteHelper();
const onToday = useCallback(() => {
journalHelper.openToday({
replaceHistory: true,
});
}, [journalHelper]);
return (
<Button
size="default"
onClick={onToday}
style={{ height: 32, padding: '0px 8px' }}
>
{t['com.affine.today']()}
</Button>
);
};
@@ -1,41 +0,0 @@
import { OverlayModal } from '@affine/component';
import { useEnableCloud } from '@affine/core/components/hooks/affine/use-enable-cloud';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { useI18n } from '@affine/i18n';
import { useService } from '@toeverything/infra';
import { useCallback } from 'react';
import TopSvg from './top-svg';
export const HistoryTipsModal = ({
open,
setOpen,
}: {
open: boolean;
setOpen: (open: boolean) => void;
}) => {
const t = useI18n();
const currentWorkspace = useService(WorkspaceService).workspace;
const confirmEnableCloud = useEnableCloud();
const handleConfirm = useCallback(() => {
setOpen(false);
confirmEnableCloud(currentWorkspace);
}, [confirmEnableCloud, currentWorkspace, setOpen]);
return (
<OverlayModal
open={open}
topImage={<TopSvg />}
title={t['com.affine.history-vision.tips-modal.title']()}
onOpenChange={setOpen}
description={t['com.affine.history-vision.tips-modal.description']()}
cancelText={t['com.affine.history-vision.tips-modal.cancel']()}
confirmButtonOptions={{
variant: 'primary',
}}
onConfirm={handleConfirm}
confirmText={t['com.affine.history-vision.tips-modal.confirm']()}
/>
);
};
File diff suppressed because one or more lines are too long
@@ -1,456 +0,0 @@
import { notify, toast, useConfirmModal } from '@affine/component';
import {
Menu,
MenuItem,
MenuSeparator,
MenuSub,
} from '@affine/component/ui/menu';
import { PageHistoryModal } from '@affine/core/components/affine/page-history-modal';
import { useBlockSuiteMetaHelper } from '@affine/core/components/hooks/affine/use-block-suite-meta-helper';
import { useEnableCloud } from '@affine/core/components/hooks/affine/use-enable-cloud';
import { useExportPage } from '@affine/core/components/hooks/affine/use-export-page';
import { Export, MoveToTrash } from '@affine/core/components/page-list';
import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
import { useDetailPageHeaderResponsive } from '@affine/core/desktop/pages/workspace/detail-page/use-header-responsive';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { EditorService } from '@affine/core/modules/editor';
import { OpenInAppService } from '@affine/core/modules/open-in-app/services';
import { GuardService } from '@affine/core/modules/permissions';
import { ShareMenuContent } from '@affine/core/modules/share-menu';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { ViewService } from '@affine/core/modules/workbench/services/view';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';
import type { Store } from '@blocksuite/affine/store';
import {
DuplicateIcon,
EdgelessIcon,
EditIcon,
FrameIcon,
HistoryIcon,
ImportIcon,
InformationIcon,
LocalWorkspaceIcon,
OpenInNewIcon,
PageIcon,
ShareIcon,
SplitViewIcon,
TocIcon,
} from '@blocksuite/icons/rc';
import {
useLiveData,
useService,
useServiceOptional,
} from '@toeverything/infra';
import { useCallback, useState } from 'react';
import { HeaderDropDownButton } from '../../../pure/header-drop-down-button';
import { useFavorite } from '../favorite';
import { HistoryTipsModal } from './history-tips-modal';
type PageMenuProps = {
rename?: () => void;
page: Store;
isJournal?: boolean;
containerWidth: number;
};
export const PageHeaderMenuButton = ({
rename,
page,
isJournal,
containerWidth,
}: PageMenuProps) => {
const workspace = useService(WorkspaceService).workspace;
const editorService = useService(EditorService);
const isInTrash = useLiveData(
editorService.editor.doc.meta$.map(meta => meta.trash)
);
const [historyModalOpen, setHistoryModalOpen] = useState(false);
const [openHistoryTipsModal, setOpenHistoryTipsModal] = useState(false);
const handleMenuOpenChange = useCallback((open: boolean) => {
if (open) {
track.$.header.docOptions.open();
}
}, []);
const openHistoryModal = useCallback(() => {
track.$.header.history.open();
if (workspace.flavour === 'affine-cloud') {
return setHistoryModalOpen(true);
}
return setOpenHistoryTipsModal(true);
}, [setOpenHistoryTipsModal, workspace.flavour]);
if (isInTrash) {
return null;
}
return (
<>
<Menu
items={
<PageHeaderMenuItem
page={page}
containerWidth={containerWidth}
rename={rename}
isJournal={isJournal}
openHistoryModal={openHistoryModal}
/>
}
contentOptions={{
align: 'center',
}}
rootOptions={{
onOpenChange: handleMenuOpenChange,
}}
>
<HeaderDropDownButton />
</Menu>
{workspace.flavour !== 'local' ? (
<PageHistoryModal
docCollection={workspace.docCollection}
open={historyModalOpen}
pageId={page.id}
onOpenChange={setHistoryModalOpen}
/>
) : null}
<HistoryTipsModal
open={openHistoryTipsModal}
setOpen={setOpenHistoryTipsModal}
/>
</>
);
};
// fixme: refactor this file
const PageHeaderMenuItem = ({
rename,
page,
isJournal,
containerWidth,
openHistoryModal,
}: PageMenuProps & {
openHistoryModal: () => void;
}) => {
const pageId = page?.id;
const t = useI18n();
const { hideShare } = useDetailPageHeaderResponsive(containerWidth);
const confirmEnableCloud = useEnableCloud();
const workspace = useService(WorkspaceService).workspace;
const guardService = useService(GuardService);
const editorService = useService(EditorService);
const currentMode = useLiveData(editorService.editor.mode$);
const primaryMode = useLiveData(editorService.editor.doc.primaryMode$);
const workbench = useService(WorkbenchService).workbench;
const openInAppService = useServiceOptional(OpenInAppService);
const { favorite, toggleFavorite } = useFavorite(pageId);
const { duplicate } = useBlockSuiteMetaHelper();
const view = useService(ViewService).view;
const openSidePanel = useCallback(
(id: string) => {
workbench.openSidebar();
view.activeSidebarTab(id);
},
[workbench, view]
);
const openAllFrames = useCallback(() => {
openSidePanel('frame');
}, [openSidePanel]);
const openOutlinePanel = useCallback(() => {
openSidePanel('outline');
}, [openSidePanel]);
const workspaceDialogService = useService(WorkspaceDialogService);
const openInfoModal = useCallback(() => {
track.$.header.pageInfo.open();
workspaceDialogService.open('doc-info', { docId: pageId });
}, [workspaceDialogService, pageId]);
const handleOpenInNewTab = useCallback(() => {
workbench.openDoc(pageId, {
at: 'new-tab',
});
}, [pageId, workbench]);
const handleOpenInSplitView = useCallback(() => {
workbench.openDoc(pageId, {
at: 'tail',
});
}, [pageId, workbench]);
const { openConfirmModal } = useConfirmModal();
const handleOpenTrashModal = useCallback(() => {
track.$.header.docOptions.deleteDoc();
openConfirmModal({
title: t['com.affine.moveToTrash.confirmModal.title'](),
description: t['com.affine.moveToTrash.confirmModal.description']({
title: editorService.editor.doc.title$.value || t['Untitled'](),
}),
cancelText: t['com.affine.confirmModal.button.cancel'](),
confirmText: t.Delete(),
confirmButtonOptions: {
variant: 'error',
},
onConfirm: async () => {
const canTrash = await guardService.can('Doc_Trash', pageId);
if (!canTrash) {
toast(t['com.affine.no-permission']());
return;
}
editorService.editor.doc.moveToTrash();
},
});
}, [editorService.editor.doc, guardService, openConfirmModal, pageId, t]);
const handleRename = useCallback(() => {
rename?.();
track.$.header.docOptions.renameDoc();
}, [rename]);
const handleSwitchMode = useCallback(() => {
const mode = primaryMode === 'page' ? 'edgeless' : 'page';
editorService.editor.setMode(mode);
editorService.editor.doc.setPrimaryMode(mode);
track.$.header.docOptions.switchPageMode({
mode,
});
notify.success({
title:
primaryMode === 'page'
? t['com.affine.toastMessage.defaultMode.edgeless.title']()
: t['com.affine.toastMessage.defaultMode.page.title'](),
message:
primaryMode === 'page'
? t['com.affine.toastMessage.defaultMode.edgeless.message']()
: t['com.affine.toastMessage.defaultMode.page.message'](),
});
}, [primaryMode, editorService, t]);
const exportHandler = useExportPage();
const handleDuplicate = useCallback(() => {
duplicate(pageId);
track.$.header.docOptions.createDoc({
control: 'duplicate',
});
}, [duplicate, pageId]);
const handleOpenDocs = useCallback(
(result: {
docIds: string[];
entryId?: string;
isWorkspaceFile?: boolean;
}) => {
const { docIds, entryId, isWorkspaceFile } = result;
// If the imported file is a workspace file, open the entry page.
if (isWorkspaceFile && entryId) {
workbench.openDoc(entryId);
} else if (!docIds.length) {
return;
}
// Open all the docs when there are multiple docs imported.
if (docIds.length > 1) {
workbench.openAll();
} else {
// Otherwise, open the only doc.
workbench.openDoc(docIds[0]);
}
},
[workbench]
);
const handleOpenImportModal = useCallback(() => {
track.$.header.importModal.open();
workspaceDialogService.open('import', undefined, payload => {
if (!payload) {
return;
}
handleOpenDocs(payload);
});
}, [workspaceDialogService, handleOpenDocs]);
const handleShareMenuOpenChange = useCallback((open: boolean) => {
if (open) {
track.$.sharePanel.$.open();
}
}, []);
const handleToggleFavorite = useCallback(() => {
track.$.header.docOptions.toggleFavorite();
toggleFavorite();
}, [toggleFavorite]);
const showResponsiveMenu = hideShare;
const ResponsiveMenuItems = (
<>
{hideShare ? (
<MenuSub
subContentOptions={{
sideOffset: 12,
alignOffset: -8,
}}
items={
<div style={{ padding: 4 }}>
<ShareMenuContent
workspaceMetadata={workspace.meta}
currentPage={page}
onEnableAffineCloud={() =>
confirmEnableCloud(workspace, {
openPageId: page.id,
})
}
/>
</div>
}
triggerOptions={{
prefixIcon: <ShareIcon />,
}}
subOptions={{
onOpenChange: handleShareMenuOpenChange,
}}
>
{t['com.affine.share-menu.shareButton']()}
</MenuSub>
) : null}
<MenuSeparator />
</>
);
const onOpenInDesktop = useCallback(() => {
openInAppService?.showOpenInAppPage();
}, [openInAppService]);
const canEdit = useLiveData(guardService.can$('Doc_Update', pageId));
const canMoveToTrash = useLiveData(guardService.can$('Doc_Trash', pageId));
return (
<>
{showResponsiveMenu ? ResponsiveMenuItems : null}
{!isJournal && (
<MenuItem
prefixIcon={<EditIcon />}
data-testid="editor-option-menu-rename"
onSelect={handleRename}
disabled={!canEdit}
>
{t['Rename']()}
</MenuItem>
)}
<MenuItem
prefixIcon={primaryMode === 'page' ? <EdgelessIcon /> : <PageIcon />}
data-testid="editor-option-menu-edgeless"
onSelect={handleSwitchMode}
disabled={!canEdit}
>
{primaryMode === 'page'
? t['com.affine.editorDefaultMode.edgeless']()
: t['com.affine.editorDefaultMode.page']()}
</MenuItem>
<MenuItem
data-testid="editor-option-menu-favorite"
onSelect={handleToggleFavorite}
prefixIcon={<IsFavoriteIcon favorite={favorite} />}
>
{favorite
? t['com.affine.favoritePageOperation.remove']()
: t['com.affine.favoritePageOperation.add']()}
</MenuItem>
<MenuSeparator />
<MenuItem
prefixIcon={<OpenInNewIcon />}
data-testid="editor-option-menu-open-in-new-tab"
onSelect={handleOpenInNewTab}
>
{t['com.affine.workbench.tab.page-menu-open']()}
</MenuItem>
{BUILD_CONFIG.isElectron && (
<MenuItem
prefixIcon={<SplitViewIcon />}
data-testid="editor-option-menu-open-in-split-new"
onSelect={handleOpenInSplitView}
>
{t['com.affine.workbench.split-view.page-menu-open']()}
</MenuItem>
)}
<MenuSeparator />
<MenuItem
prefixIcon={<InformationIcon />}
data-testid="editor-option-menu-info"
onSelect={openInfoModal}
>
{t['com.affine.page-properties.page-info.view']()}
</MenuItem>
{currentMode === 'page' ? (
<MenuItem
prefixIcon={<TocIcon />}
data-testid="editor-option-toc"
onSelect={openOutlinePanel}
>
{t['com.affine.header.option.view-toc']()}
</MenuItem>
) : (
<MenuItem
prefixIcon={<FrameIcon />}
data-testid="editor-option-frame"
onSelect={openAllFrames}
>
{t['com.affine.header.option.view-frame']()}
</MenuItem>
)}
<MenuItem
prefixIcon={<HistoryIcon />}
data-testid="editor-option-menu-history"
onSelect={openHistoryModal}
>
{t['com.affine.history.view-history-version']()}
</MenuItem>
<MenuSeparator />
{!isJournal && (
<MenuItem
prefixIcon={<DuplicateIcon />}
data-testid="editor-option-menu-duplicate"
onSelect={handleDuplicate}
>
{t['com.affine.header.option.duplicate']()}
</MenuItem>
)}
<MenuItem
prefixIcon={<ImportIcon />}
data-testid="editor-option-menu-import"
onSelect={handleOpenImportModal}
>
{t['Import']()}
</MenuItem>
<Export exportHandler={exportHandler} pageMode={currentMode} />
<MenuSeparator />
<MoveToTrash
data-testid="editor-option-menu-delete"
onSelect={handleOpenTrashModal}
disabled={!canMoveToTrash}
/>
{BUILD_CONFIG.isWeb && workspace.flavour === 'affine-cloud' ? (
<MenuItem
prefixIcon={<LocalWorkspaceIcon />}
data-testid="editor-option-menu-link"
onSelect={onOpenInDesktop}
>
{t['com.affine.header.option.open-in-desktop']()}
</MenuItem>
) : null}
</>
);
};
@@ -1,18 +0,0 @@
import { IconButton } from '@affine/component';
import { EditorService } from '@affine/core/modules/editor';
import { PresentationIcon } from '@blocksuite/icons/rc';
import { useService } from '@toeverything/infra';
export const DetailPageHeaderPresentButton = () => {
const editorService = useService(EditorService);
return (
<IconButton
style={{ flexShrink: 0 }}
size="24"
onClick={() => editorService.editor.togglePresentation()}
>
<PresentationIcon />
</IconButton>
);
};
@@ -1,57 +0,0 @@
import type { InlineEditProps } from '@affine/component';
import { InlineEdit } from '@affine/component';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { DocService, DocsService } from '@affine/core/modules/doc';
import { GuardService } from '@affine/core/modules/permissions';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { track } from '@affine/track';
import { useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx';
import type { HTMLAttributes } from 'react';
import * as styles from './style.css';
export interface BlockSuiteHeaderTitleProps {
/** if set, title cannot be edited */
inputHandleRef?: InlineEditProps['handleRef'];
className?: string;
}
const inputAttrs = {
'data-testid': 'title-content',
} as HTMLAttributes<HTMLInputElement>;
export const BlocksuiteHeaderTitle = (props: BlockSuiteHeaderTitleProps) => {
const { inputHandleRef } = props;
const workspaceService = useService(WorkspaceService);
const isSharedMode = workspaceService.workspace.openOptions.isSharedMode;
const docsService = useService(DocsService);
const guardService = useService(GuardService);
const docService = useService(DocService);
const docTitle = useLiveData(docService.doc.record.title$);
const onChange = useAsyncCallback(
async (v: string) => {
await docsService.changeDocTitle(docService.doc.id, v);
track.$.header.actions.renameDoc();
},
[docService.doc.id, docsService]
);
const canEdit = useLiveData(
guardService.can$('Doc_Update', docService.doc.id)
);
return (
<InlineEdit
className={clsx(styles.title, props.className)}
value={docTitle}
onChange={onChange}
editable={!isSharedMode && canEdit}
exitible={true}
placeholder="Untitled"
data-testid="title-edit-button"
handleRef={inputHandleRef}
inputAttrs={inputAttrs}
/>
);
};
@@ -1,11 +0,0 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const title = style({
fontWeight: 500,
color: cssVar('textPrimaryColor'),
selectors: {
'&[data-editing="true"]': {
['WebkitAppRegion' as string]: 'no-drag',
},
},
});
@@ -1,919 +0,0 @@
{
"v": "5.12.1",
"fr": 120,
"ip": 0,
"op": 76,
"w": 240,
"h": 240,
"nm": "Edgeless",
"ddd": 0,
"assets": [],
"layers": [
{
"ddd": 0,
"ind": 1,
"ty": 4,
"nm": "“图层 2”轮廓",
"sr": 1,
"ks": {
"o": {
"a": 0,
"k": 100,
"ix": 11
},
"r": {
"a": 0,
"k": 0,
"ix": 10
},
"p": {
"a": 0,
"k": [97.5, 138, 0],
"ix": 2,
"l": 2
},
"a": {
"a": 0,
"k": [10.35, 13.5, 0],
"ix": 1,
"l": 2
},
"s": {
"a": 1,
"k": [
{
"i": {
"x": [0.772, 0.772, 0.667],
"y": [1, 1, 1.219]
},
"o": {
"x": [0.462, 0.462, 0.333],
"y": [0, 0, 0]
},
"t": 5,
"s": [1100, 1100, 100]
},
{
"i": {
"x": [0.562, 0.562, 0.667],
"y": [1, 1, 1]
},
"o": {
"x": [0.455, 0.455, 0.333],
"y": [0, 0, -0.238]
},
"t": 26.562,
"s": [1070, 1070, 100]
},
{
"t": 50,
"s": [1100, 1100, 100]
}
],
"ix": 6,
"l": 2
}
},
"ao": 0,
"shapes": [
{
"ty": "gr",
"it": [
{
"ind": 0,
"ty": "sh",
"ix": 1,
"ks": {
"a": 0,
"k": {
"i": [
[0, 0],
[1.369, 1.18],
[1.875, 0]
],
"o": [
[0, -1.885],
[-2.094, -1.571],
[0, 0]
],
"v": [
[3.665, 3.299],
[1.57, -1.728],
[-3.665, -3.299]
],
"c": false
},
"ix": 2
},
"nm": "路径 1",
"mn": "ADBE Vector Shape - Group",
"hd": false
},
{
"ty": "st",
"c": {
"a": 0,
"k": [0.466666696586, 0.458823559331, 0.490196108351, 1],
"ix": 3
},
"o": {
"a": 0,
"k": 100,
"ix": 4
},
"w": {
"a": 0,
"k": 1.5,
"ix": 5
},
"lc": 1,
"lj": 1,
"ml": 4,
"bm": 0,
"nm": "描边 1",
"mn": "ADBE Vector Graphic - Stroke",
"hd": false
},
{
"ty": "tr",
"p": {
"a": 0,
"k": [13.626, 10.441],
"ix": 2
},
"a": {
"a": 0,
"k": [0, 0],
"ix": 1
},
"s": {
"a": 0,
"k": [100, 100],
"ix": 3
},
"r": {
"a": 0,
"k": 0,
"ix": 6
},
"o": {
"a": 0,
"k": 100,
"ix": 7
},
"sk": {
"a": 0,
"k": 0,
"ix": 4
},
"sa": {
"a": 0,
"k": 0,
"ix": 5
},
"nm": "变换"
}
],
"nm": "组 1",
"np": 2,
"cix": 2,
"bm": 0,
"ix": 1,
"mn": "ADBE Vector Group",
"hd": false
}
],
"ip": 0,
"op": 240,
"st": 0,
"ct": 1,
"bm": 0
},
{
"ddd": 0,
"ind": 2,
"ty": 4,
"nm": "“图层 3”轮廓",
"sr": 1,
"ks": {
"o": {
"a": 0,
"k": 100,
"ix": 11
},
"r": {
"a": 0,
"k": 0,
"ix": 10
},
"p": {
"a": 0,
"k": [69, 119, 0],
"ix": 2,
"l": 2
},
"a": {
"a": 0,
"k": [6.9, 11.9, 0],
"ix": 1,
"l": 2
},
"s": {
"a": 0,
"k": [1000, 1000, 100],
"ix": 6,
"l": 2
}
},
"ao": 0,
"shapes": [
{
"ty": "gr",
"it": [
{
"ind": 0,
"ty": "sh",
"ix": 1,
"ks": {
"a": 0,
"k": {
"i": [
[0, 0],
[0, 0]
],
"o": [
[0, 0],
[0, 0]
],
"v": [
[6.818, 9.76],
[6.818, 13.949]
],
"c": false
},
"ix": 2
},
"nm": "路径 1",
"mn": "ADBE Vector Shape - Group",
"hd": false
},
{
"ty": "st",
"c": {
"a": 0,
"k": [0.466666696586, 0.458823559331, 0.490196108351, 1],
"ix": 3
},
"o": {
"a": 0,
"k": 100,
"ix": 4
},
"w": {
"a": 0,
"k": 1.5,
"ix": 5
},
"lc": 1,
"lj": 1,
"ml": 4,
"bm": 0,
"nm": "描边 1",
"mn": "ADBE Vector Graphic - Stroke",
"hd": false
},
{
"ty": "tr",
"p": {
"a": 0,
"k": [0, 0],
"ix": 2
},
"a": {
"a": 0,
"k": [0, 0],
"ix": 1
},
"s": {
"a": 0,
"k": [100, 100],
"ix": 3
},
"r": {
"a": 0,
"k": 0,
"ix": 6
},
"o": {
"a": 0,
"k": 100,
"ix": 7
},
"sk": {
"a": 0,
"k": 0,
"ix": 4
},
"sa": {
"a": 0,
"k": 0,
"ix": 5
},
"nm": "变换"
}
],
"nm": "组 1",
"np": 2,
"cix": 2,
"bm": 0,
"ix": 1,
"mn": "ADBE Vector Group",
"hd": false
}
],
"ip": 0,
"op": 240,
"st": 0,
"ct": 1,
"bm": 0
},
{
"ddd": 0,
"ind": 3,
"ty": 4,
"nm": "“图层 4”轮廓",
"sr": 1,
"ks": {
"o": {
"a": 0,
"k": 100,
"ix": 11
},
"r": {
"a": 1,
"k": [
{
"i": {
"x": [0.363],
"y": [1]
},
"o": {
"x": [0.675],
"y": [-0.111]
},
"t": 5,
"s": [0]
},
{
"t": 50,
"s": [90]
}
],
"ix": 10
},
"p": {
"a": 1,
"k": [
{
"i": {
"x": 0.363,
"y": 1
},
"o": {
"x": 0.675,
"y": 0
},
"t": 5,
"s": [173.5, 171, 0],
"to": [0, -1.333, 0],
"ti": [0, 0, 0]
},
{
"i": {
"x": 0.667,
"y": 1
},
"o": {
"x": 0.647,
"y": 0
},
"t": 26.562,
"s": [173.5, 163, 0],
"to": [0, 0, 0],
"ti": [0, -1.333, 0]
},
{
"t": 50,
"s": [173.5, 171, 0]
}
],
"ix": 2,
"l": 2
},
"a": {
"a": 0,
"k": [17.35, 16.9, 0],
"ix": 1,
"l": 2
},
"s": {
"a": 0,
"k": [1000, 1000, 100],
"ix": 6,
"l": 2
}
},
"ao": 0,
"shapes": [
{
"ty": "gr",
"it": [
{
"ind": 0,
"ty": "sh",
"ix": 1,
"ks": {
"a": 0,
"k": {
"i": [
[0, 0],
[0, 0],
[0, 0],
[0, 0]
],
"o": [
[0, 0],
[0, 0],
[0, 0],
[0, 0]
],
"v": [
[-2.357, -2.357],
[2.357, -2.357],
[2.357, 2.357],
[-2.357, 2.357]
],
"c": true
},
"ix": 2
},
"nm": "路径 1",
"mn": "ADBE Vector Shape - Group",
"hd": false
},
{
"ty": "st",
"c": {
"a": 0,
"k": [0.466666696586, 0.458823559331, 0.490196108351, 1],
"ix": 3
},
"o": {
"a": 0,
"k": 100,
"ix": 4
},
"w": {
"a": 0,
"k": 1.5,
"ix": 5
},
"lc": 1,
"lj": 2,
"bm": 0,
"nm": "描边 1",
"mn": "ADBE Vector Graphic - Stroke",
"hd": false
},
{
"ty": "tr",
"p": {
"a": 0,
"k": [17.344, 16.829],
"ix": 2
},
"a": {
"a": 0,
"k": [0, 0],
"ix": 1
},
"s": {
"a": 0,
"k": [100, 100],
"ix": 3
},
"r": {
"a": 0,
"k": 0,
"ix": 6
},
"o": {
"a": 0,
"k": 100,
"ix": 7
},
"sk": {
"a": 0,
"k": 0,
"ix": 4
},
"sa": {
"a": 0,
"k": 0,
"ix": 5
},
"nm": "变换"
}
],
"nm": "组 1",
"np": 2,
"cix": 2,
"bm": 0,
"ix": 1,
"mn": "ADBE Vector Group",
"hd": false
}
],
"ip": 0,
"op": 240,
"st": 0,
"ct": 1,
"bm": 0
},
{
"ddd": 0,
"ind": 4,
"ty": 4,
"nm": "“图层 5”轮廓",
"sr": 1,
"ks": {
"o": {
"a": 0,
"k": 100,
"ix": 11
},
"r": {
"a": 0,
"k": 0,
"ix": 10
},
"p": {
"a": 1,
"k": [
{
"i": {
"x": 0.363,
"y": 1
},
"o": {
"x": 0.675,
"y": 0
},
"t": 5,
"s": [68.5, 170, 0],
"to": [0, -1.333, 0],
"ti": [0, 0, 0]
},
{
"i": {
"x": 0.667,
"y": 1
},
"o": {
"x": 0.647,
"y": 0
},
"t": 26.562,
"s": [68.5, 162, 0],
"to": [0, 0, 0],
"ti": [0, -1.333, 0]
},
{
"t": 50,
"s": [68.5, 170, 0]
}
],
"ix": 2,
"l": 2
},
"a": {
"a": 0,
"k": [6.85, 17.05, 0],
"ix": 1,
"l": 2
},
"s": {
"a": 0,
"k": [1000, 1000, 100],
"ix": 6,
"l": 2
}
},
"ao": 0,
"shapes": [
{
"ty": "gr",
"it": [
{
"ind": 0,
"ty": "sh",
"ix": 1,
"ks": {
"a": 0,
"k": {
"i": [
[-1.446, 0],
[0, -1.446],
[1.446, 0],
[0, 1.446]
],
"o": [
[1.446, 0],
[0, 1.446],
[-1.446, 0],
[0, -1.446]
],
"v": [
[0, -2.618],
[2.618, 0],
[0, 2.618],
[-2.618, 0]
],
"c": true
},
"ix": 2
},
"nm": "路径 1",
"mn": "ADBE Vector Shape - Group",
"hd": false
},
{
"ty": "st",
"c": {
"a": 0,
"k": [0.466666696586, 0.458823559331, 0.490196108351, 1],
"ix": 3
},
"o": {
"a": 0,
"k": 100,
"ix": 4
},
"w": {
"a": 0,
"k": 1.5,
"ix": 5
},
"lc": 1,
"lj": 1,
"ml": 4,
"bm": 0,
"nm": "描边 1",
"mn": "ADBE Vector Graphic - Stroke",
"hd": false
},
{
"ty": "tr",
"p": {
"a": 0,
"k": [6.818, 17.091],
"ix": 2
},
"a": {
"a": 0,
"k": [0, 0],
"ix": 1
},
"s": {
"a": 0,
"k": [100, 100],
"ix": 3
},
"r": {
"a": 0,
"k": 0,
"ix": 6
},
"o": {
"a": 0,
"k": 100,
"ix": 7
},
"sk": {
"a": 0,
"k": 0,
"ix": 4
},
"sa": {
"a": 0,
"k": 0,
"ix": 5
},
"nm": "变换"
}
],
"nm": "组 1",
"np": 2,
"cix": 2,
"bm": 0,
"ix": 1,
"mn": "ADBE Vector Group",
"hd": false
}
],
"ip": 0,
"op": 240,
"st": 0,
"ct": 1,
"bm": 0
},
{
"ddd": 0,
"ind": 5,
"ty": 4,
"nm": "“图层 6”轮廓",
"sr": 1,
"ks": {
"o": {
"a": 0,
"k": 100,
"ix": 11
},
"r": {
"a": 0,
"k": 0,
"ix": 10
},
"p": {
"a": 1,
"k": [
{
"i": {
"x": 0.363,
"y": 1
},
"o": {
"x": 0.675,
"y": 0
},
"t": 5,
"s": [68, 65, 0],
"to": [0, 1.333, 0],
"ti": [0, 0, 0]
},
{
"i": {
"x": 0.667,
"y": 1
},
"o": {
"x": 0.647,
"y": 0
},
"t": 26.562,
"s": [68, 73, 0],
"to": [0, 0, 0],
"ti": [0, 1.333, 0]
},
{
"t": 50,
"s": [68, 65, 0]
}
],
"ix": 2,
"l": 2
},
"a": {
"a": 0,
"k": [6.8, 6.6, 0],
"ix": 1,
"l": 2
},
"s": {
"a": 0,
"k": [1000, 1000, 100],
"ix": 6,
"l": 2
}
},
"ao": 0,
"shapes": [
{
"ty": "gr",
"it": [
{
"ind": 0,
"ty": "sh",
"ix": 1,
"ks": {
"a": 0,
"k": {
"i": [
[-1.446, 0],
[0, -1.446],
[1.446, 0],
[0, 1.446]
],
"o": [
[1.446, 0],
[0, 1.446],
[-1.446, 0],
[0, -1.446]
],
"v": [
[0, -2.618],
[2.618, 0],
[0, 2.618],
[-2.618, 0]
],
"c": true
},
"ix": 2
},
"nm": "路径 1",
"mn": "ADBE Vector Shape - Group",
"hd": false
},
{
"ty": "st",
"c": {
"a": 0,
"k": [0.466666696586, 0.458823559331, 0.490196108351, 1],
"ix": 3
},
"o": {
"a": 0,
"k": 100,
"ix": 4
},
"w": {
"a": 0,
"k": 1.5,
"ix": 5
},
"lc": 1,
"lj": 1,
"ml": 4,
"bm": 0,
"nm": "描边 1",
"mn": "ADBE Vector Graphic - Stroke",
"hd": false
},
{
"ty": "tr",
"p": {
"a": 0,
"k": [6.818, 6.618],
"ix": 2
},
"a": {
"a": 0,
"k": [0, 0],
"ix": 1
},
"s": {
"a": 0,
"k": [100, 100],
"ix": 3
},
"r": {
"a": 0,
"k": 0,
"ix": 6
},
"o": {
"a": 0,
"k": 100,
"ix": 7
},
"sk": {
"a": 0,
"k": 0,
"ix": 4
},
"sa": {
"a": 0,
"k": 0,
"ix": 5
},
"nm": "变换"
}
],
"nm": "组 1",
"np": 2,
"cix": 2,
"bm": 0,
"ix": 1,
"mn": "ADBE Vector Group",
"hd": false
}
],
"ip": 0,
"op": 240,
"st": 0,
"ct": 1,
"bm": 0
}
],
"markers": []
}
@@ -1,136 +0,0 @@
import { RadioGroup, type RadioItem } from '@affine/component';
import { registerAffineCommand } from '@affine/core/commands';
import { EditorService } from '@affine/core/modules/editor';
import { ViewService, WorkbenchService } from '@affine/core/modules/workbench';
import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';
import type { DocMode } from '@blocksuite/affine/blocks';
import { EdgelessIcon, PageIcon } from '@blocksuite/icons/rc';
import {
useLiveData,
useService,
useServiceOptional,
} from '@toeverything/infra';
import { useCallback, useEffect, useMemo } from 'react';
import { switchItem } from './style.css';
import { EdgelessSwitchItem, PageSwitchItem } from './switch-items';
export interface EditorModeSwitchProps {
pageId: string;
isPublic?: boolean;
publicMode?: DocMode;
}
const EdgelessRadioItem: RadioItem = {
value: 'edgeless',
label: <EdgelessSwitchItem />,
testId: 'switch-edgeless-mode-button',
className: switchItem,
};
const PageRadioItem: RadioItem = {
value: 'page',
label: <PageSwitchItem />,
testId: 'switch-page-mode-button',
className: switchItem,
};
export const EditorModeSwitch = () => {
const t = useI18n();
const editor = useService(EditorService).editor;
const trash = useLiveData(editor.doc.trash$);
const isSharedMode = editor.isSharedMode;
const currentMode = useLiveData(editor.mode$);
const view = useServiceOptional(ViewService)?.view;
const workbench = useServiceOptional(WorkbenchService)?.workbench;
const activeView = useLiveData(workbench?.activeView$);
const isActiveView = activeView?.id && activeView?.id === view?.id;
const togglePage = useCallback(() => {
if (currentMode === 'page' || isSharedMode || trash) return;
editor.setMode('page');
editor.setSelector(undefined);
track.$.header.actions.switchPageMode({ mode: 'page' });
}, [currentMode, editor, isSharedMode, trash]);
const toggleEdgeless = useCallback(() => {
if (currentMode === 'edgeless' || isSharedMode || trash) return;
editor.setMode('edgeless');
editor.setSelector(undefined);
track.$.header.actions.switchPageMode({ mode: 'edgeless' });
}, [currentMode, editor, isSharedMode, trash]);
const onModeChange = useCallback(
(mode: DocMode) => {
mode === 'page' ? togglePage() : toggleEdgeless();
},
[toggleEdgeless, togglePage]
);
const shouldHide = useCallback(
(mode: DocMode) => (trash || isSharedMode) && currentMode !== mode,
[currentMode, isSharedMode, trash]
);
useEffect(() => {
if (trash || isSharedMode || currentMode === undefined || !isActiveView)
return;
return registerAffineCommand({
id: 'affine:doc-mode-switch',
category: 'editor:page',
label:
currentMode === 'page'
? t['com.affine.cmdk.switch-to-edgeless']()
: t['com.affine.cmdk.switch-to-page'](),
icon: currentMode === 'page' ? <EdgelessIcon /> : <PageIcon />,
keyBinding: {
binding: 'Alt+KeyS',
capture: true,
},
run: () => onModeChange(currentMode === 'edgeless' ? 'page' : 'edgeless'),
});
}, [currentMode, isActiveView, isSharedMode, onModeChange, t, trash]);
return (
<PureEditorModeSwitch
mode={currentMode}
setMode={onModeChange}
hidePage={shouldHide('page')}
hideEdgeless={shouldHide('edgeless')}
/>
);
};
export interface PureEditorModeSwitchProps {
mode?: DocMode;
setMode?: (mode: DocMode) => void;
hidePage?: boolean;
hideEdgeless?: boolean;
}
export const PureEditorModeSwitch = ({
mode,
setMode,
hidePage,
hideEdgeless,
}: PureEditorModeSwitchProps) => {
const items = useMemo(
() => [
...(hidePage ? [] : [PageRadioItem]),
...(hideEdgeless ? [] : [EdgelessRadioItem]),
],
[hideEdgeless, hidePage]
);
return (
<RadioGroup
iconMode
itemHeight={24}
borderRadius={8}
padding={4}
gap={8}
value={mode}
items={items}
onChange={setMode}
/>
);
};
@@ -1,10 +0,0 @@
import { globalStyle, style } from '@vanilla-extract/css';
export const switchItem = style({
width: 24,
height: 24,
});
// a workaround to override lottie svg stroke with default radio button color schemes
globalStyle(`${switchItem} svg path`, {
stroke: 'currentColor',
});
@@ -1,92 +0,0 @@
import { Tooltip } from '@affine/component';
import {
type CustomLottieProps,
InternalLottie,
} from '@affine/component/internal-lottie';
import { useI18n } from '@affine/i18n';
import type { HTMLAttributes } from 'react';
import type React from 'react';
import { cloneElement, useState } from 'react';
import edgelessHover from './animation-data/edgeless-hover.json';
import pageHover from './animation-data/page-hover.json';
type HoverAnimateControllerProps = {
active?: boolean;
hide?: boolean;
trash?: boolean;
children: React.ReactElement<CustomLottieProps>;
} & HTMLAttributes<HTMLDivElement>;
const HoverAnimateController = ({
children,
...props
}: HoverAnimateControllerProps) => {
const [startAnimate, setStartAnimate] = useState(false);
return (
<div
onMouseEnter={() => setStartAnimate(true)}
onMouseLeave={() => setStartAnimate(false)}
{...props}
>
{cloneElement(children, {
isStopped: !startAnimate,
speed: 1,
width: 20,
height: 20,
})}
</div>
);
};
const pageLottieOptions = {
loop: false,
autoplay: false,
animationData: pageHover,
rendererSettings: {
preserveAspectRatio: 'xMidYMid slice',
},
};
const edgelessLottieOptions = {
loop: false,
autoplay: false,
animationData: edgelessHover,
rendererSettings: {
preserveAspectRatio: 'xMidYMid slice',
},
};
export const PageSwitchItem = (
props: Omit<HoverAnimateControllerProps, 'children'>
) => {
const t = useI18n();
return (
<Tooltip
content={t['com.affine.header.mode-switch.page']()}
shortcut={['$alt', 'S']}
side="bottom"
>
<HoverAnimateController {...props}>
<InternalLottie options={pageLottieOptions} />
</HoverAnimateController>
</Tooltip>
);
};
export const EdgelessSwitchItem = (
props: Omit<HoverAnimateControllerProps, 'children'>
) => {
const t = useI18n();
return (
<Tooltip
content={t['com.affine.header.mode-switch.edgeless']()}
shortcut={['$alt', 'S']}
side="bottom"
>
<HoverAnimateController {...props}>
<InternalLottie options={edgelessLottieOptions} />
</HoverAnimateController>
</Tooltip>
);
};
@@ -1,25 +0,0 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const pageListEmptyStyle = style({
height: 'calc(100% - 52px)',
});
export const emptyDescButton = style({
cursor: 'pointer',
color: cssVar('textSecondaryColor'),
background: cssVar('backgroundCodeBlock'),
border: `1px solid ${cssVar('borderColor')}`,
borderRadius: '4px',
padding: '0 6px',
boxSizing: 'border-box',
selectors: {
'&:hover': {
background: cssVar('hoverColor'),
},
},
});
export const emptyDescKbd = style([
emptyDescButton,
{
cursor: 'text',
},
]);
@@ -1,136 +0,0 @@
import { toast } from '@affine/component';
import type { DocProps } from '@affine/core/blocksuite/initialization';
import { AppSidebarService } from '@affine/core/modules/app-sidebar';
import { DocsService } from '@affine/core/modules/doc';
import { EditorSettingService } from '@affine/core/modules/editor-setting';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { type DocMode } from '@blocksuite/affine/blocks';
import type { Workspace } from '@blocksuite/affine/store';
import { useServices } from '@toeverything/infra';
import { useCallback, useMemo } from 'react';
export const usePageHelper = (docCollection: Workspace) => {
const {
docsService,
workbenchService,
editorSettingService,
appSidebarService,
} = useServices({
DocsService,
WorkbenchService,
EditorSettingService,
AppSidebarService,
});
const workbench = workbenchService.workbench;
const docRecordList = docsService.list;
const appSidebar = appSidebarService.sidebar;
const createPageAndOpen = useCallback(
(
mode?: DocMode,
options: {
at?: 'new-tab' | 'tail' | 'active';
show?: boolean;
} = {
at: 'active',
show: true,
}
) => {
appSidebar.setHovering(false);
const docProps: DocProps = {
note: editorSettingService.editorSetting.get('affine:note'),
};
const page = docsService.createDoc({ docProps });
if (mode) {
docRecordList.doc$(page.id).value?.setPrimaryMode(mode);
}
if (options.show !== false) {
workbench.openDoc(page.id, {
at: options.at,
show: options.show,
});
}
return page;
},
[
appSidebar,
docRecordList,
docsService,
editorSettingService.editorSetting,
workbench,
]
);
const createEdgelessAndOpen = useCallback(
(
options: {
at?: 'new-tab' | 'tail' | 'active';
show?: boolean;
} = {
at: 'active',
show: true,
}
) => {
return createPageAndOpen('edgeless', options);
},
[createPageAndOpen]
);
const importFileAndOpen = useMemo(
() => async () => {
const { showImportModal } = await import('@blocksuite/affine/blocks');
const { promise, resolve, reject } =
Promise.withResolvers<
Parameters<
NonNullable<Parameters<typeof showImportModal>[0]['onSuccess']>
>[1]
>();
const onSuccess = (
pageIds: string[],
options: { isWorkspaceFile: boolean; importedCount: number }
) => {
resolve(options);
toast(
`Successfully imported ${options.importedCount} Page${
options.importedCount > 1 ? 's' : ''
}.`
);
if (options.isWorkspaceFile) {
workbench.openAll();
return;
}
if (pageIds.length === 0) {
return;
}
const pageId = pageIds[0];
workbench.openDoc(pageId);
};
showImportModal({
collection: docCollection,
onSuccess,
onFail: message => {
reject(new Error(message));
},
});
return await promise;
},
[docCollection, workbench]
);
return useMemo(() => {
return {
createPage: (
mode?: DocMode,
options?: {
at?: 'new-tab' | 'tail' | 'active';
show?: boolean;
}
) => createPageAndOpen(mode, options),
createEdgeless: createEdgelessAndOpen,
importFile: importFileAndOpen,
};
}, [createEdgelessAndOpen, createPageAndOpen, importFileAndOpen]);
};
@@ -1,45 +0,0 @@
import type { EditorHost } from '@blocksuite/affine/block-std';
import { OutlineViewer } from '@blocksuite/affine/blocks';
import { useCallback, useRef } from 'react';
import * as styles from './outline-viewer.css';
export const EditorOutlineViewer = ({
editor,
show,
openOutlinePanel,
}: {
editor: EditorHost | null;
show: boolean;
openOutlinePanel?: () => void;
}) => {
const outlineViewerRef = useRef<OutlineViewer | null>(null);
const onRefChange = useCallback((container: HTMLDivElement | null) => {
if (container) {
if (outlineViewerRef.current === null) {
console.error('outline viewer should be initialized');
return;
}
container.append(outlineViewerRef.current);
}
}, []);
if (!editor || !show) return;
if (!outlineViewerRef.current) {
outlineViewerRef.current = new OutlineViewer();
}
if (outlineViewerRef.current.editor !== editor) {
outlineViewerRef.current.editor = editor;
}
if (
outlineViewerRef.current.toggleOutlinePanel !== openOutlinePanel &&
openOutlinePanel
) {
outlineViewerRef.current.toggleOutlinePanel = openOutlinePanel;
}
return <div className={styles.root} ref={onRefChange} />;
};
@@ -1,17 +0,0 @@
import { style } from '@vanilla-extract/css';
const top = 256 - 52; // 52 is the height of the header
const bottom = 76;
export const root = style({
position: 'absolute',
top,
right: 22,
maxHeight: `calc(100% - ${top}px - ${bottom}px)`,
display: 'flex',
'@container': {
'(width <= 640px)': {
display: 'none',
},
},
});
@@ -26,7 +26,7 @@ import { useLiveData, useService } from '@toeverything/infra';
import { useSetAtom } from 'jotai';
import { nanoid } from 'nanoid';
import type { AffineEditorContainer } from '../../blocksuite/block-suite-editor/blocksuite-editor-container';
import type { AffineEditorContainer } from '../../../blocksuite/block-suite-editor/blocksuite-editor-container';
import { useAsyncCallback } from '../affine-async-hooks';
type ExportType = 'pdf' | 'html' | 'png' | 'markdown' | 'snapshot';
@@ -1,7 +1,7 @@
import type { SetStateAction } from 'jotai';
import { atom, useAtom } from 'jotai';
import type { AffineEditorContainer } from '../blocksuite/block-suite-editor';
import type { AffineEditorContainer } from '../../blocksuite/block-suite-editor';
const activeEditorContainerAtom = atom<AffineEditorContainer | null>(null);
@@ -14,6 +14,8 @@ import { useStore } from 'jotai';
import { useTheme } from 'next-themes';
import { useEffect } from 'react';
import type { AffineEditorContainer } from '../../blocksuite/block-suite-editor';
import { usePageHelper } from '../../blocksuite/block-suite-page-list/utils';
import {
PreconditionStrategy,
registerAffineCommand,
@@ -25,10 +27,8 @@ import {
registerAffineSettingsCommands,
registerAffineUpdatesCommands,
} from '../../commands';
import { usePageHelper } from '../../components/blocksuite/block-suite-page-list/utils';
import { EditorSettingService } from '../../modules/editor-setting';
import { CMDKQuickSearchService } from '../../modules/quicksearch/services/cmdk';
import type { AffineEditorContainer } from '../blocksuite/block-suite-editor';
import { useActiveBlocksuiteEditor } from './use-block-suite-editor';
import { useNavigateHelper } from './use-navigate-helper';
@@ -4,11 +4,11 @@ import { useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx';
import { useEffect } from 'react';
import type { AffineEditorContainer } from '../blocksuite/block-suite-editor';
import { BlockSuiteEditor } from '../blocksuite/block-suite-editor';
import { DocService } from '../modules/doc';
import { EditorService } from '../modules/editor';
import { EditorSettingService } from '../modules/editor-setting';
import type { AffineEditorContainer } from './blocksuite/block-suite-editor';
import { BlockSuiteEditor } from './blocksuite/block-suite-editor';
import * as styles from './page-detail-editor.css';
declare global {
@@ -28,8 +28,8 @@ import clsx from 'clsx';
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 { usePageHelper } from '../../blocksuite/block-suite-page-list/utils';
import { createTagFilter } from '../filter/utils';
import { SaveAsCollectionButton } from '../view';
import * as styles from './page-list-header.css';
@@ -39,8 +39,8 @@ 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 { usePageHelper } from '../blocksuite/block-suite-page-list/utils';
import { IsFavoriteIcon } from '../pure/icons';
import { FavoriteTag } from './components/favorite-tag';
import * as styles from './list.css';
@@ -3,7 +3,11 @@ import {
pushGlobalLoadingEventAtom,
resolveGlobalLoadingEventAtom,
} from '@affine/component/global-loading';
import { AIProvider } from '@affine/core/blocksuite/presets';
import {
AIProvider,
CopilotClient,
setupAIProvider,
} from '@affine/core/blocksuite/ai';
import { SyncAwareness } from '@affine/core/components/affine/awareness';
import { useRegisterFindInPageCommands } from '@affine/core/components/hooks/affine/use-register-find-in-page-commands';
import { useRegisterWorkspaceCommands } from '@affine/core/components/hooks/use-register-workspace-commands';
@@ -45,9 +49,6 @@ import {
timeout,
} from 'rxjs';
import { CopilotClient } from '../blocksuite/block-suite-editor/ai/copilot-client';
import { setupAIProvider } from '../blocksuite/block-suite-editor/ai/setup-provider';
/**
* @deprecated just for legacy code, will be removed in the future
*/