From 60b994f38b6978bda6a714c0edfbc144906375c4 Mon Sep 17 00:00:00 2001 From: Saul-Mirone Date: Mon, 24 Feb 2025 08:30:00 +0000 Subject: [PATCH] refactor(editor): modular custom specs (#10398) Key Changes: 1. **Removal of Scroll Anchoring Widget** - Removed the scroll anchoring widget import and its related implementation from `blocksuite/affine/block-root/src/common-specs/widgets.ts` 2. **Enhanced React-Lit Integration** - Added `ReactWebComponent` type export in `packages/frontend/component/src/lit-react/index.ts` - Refactored text renderer component to use React integration: - Added React import and created `LitTextRenderer` component using `createReactComponentFromLit` - Moved the component declaration to a more appropriate location 3. **AI Feature Flag Integration** - Added feature flag check for AI functionality in `enableAIExtension` - Only enables AI extensions if the `enable_ai` flag is true 4. **Component Restructuring** - Moved several components and utilities to dedicated extension files - Consolidated Lit adapter implementations - Removed direct widget imports in favor of extension-based approach - Reorganized editor component structure for better maintainability 5. **File Reorganization** - Removed `specs/custom/spec-patchers.ts` and distributed its functionality across multiple extension files - Created new extension files for various features like: - Attachment embed views - Doc mode service - Doc URL handling - Edgeless clipboard - Mobile support - Note configuration - Various service patches (notification, peek view, quick search, etc.) 6. **Mobile Support Improvements** - Refactored mobile extension enablement to be more modular - Moved mobile-specific widget omissions into a dedicated extension 7. **Type System Improvements** - Added more specific type imports for editors and components - Enhanced type safety across the codebase This PR appears to be a significant refactoring effort focused on: 1. Improving code organization through better separation of concerns 2. Enhancing the integration between React and Lit components 3. Adding feature flag support for AI capabilities 4. Making the codebase more maintainable and modular 5. Improving mobile support 6. Strengthening type safety The changes suggest a move towards a more extension-based architecture, where functionality is more clearly separated into distinct modules rather than being centralized in larger files. --- .../block-root/src/common-specs/widgets.ts | 6 - .../frontend/component/src/lit-react/index.ts | 5 +- .../blocksuite/ai/components/text-renderer.ts | 19 +- .../src/blocksuite/ai/extensions/enable-ai.ts | 7 + .../bi-directional-link-panel.tsx | 22 +- .../block-suite-editor/lit-adaper.tsx | 95 +-- .../specs/custom/root-block.ts | 260 ------ .../specs/custom/spec-patchers.tsx | 761 ------------------ .../block-suite-editor/specs/edgeless.ts | 12 +- .../block-suite-editor/specs/page.ts | 15 +- .../block-suite-editor/specs/preview.ts | 23 +- .../core/src/blocksuite/editors/index.ts | 19 + .../extensions/attachment-embed-view.tsx | 35 + .../src/blocksuite/extensions/display-meta.ts | 98 +++ .../blocksuite/extensions/doc-mode-service.ts | 53 ++ .../core/src/blocksuite/extensions/doc-url.ts | 50 ++ .../extensions/edgeless-clipboard.ts | 47 ++ .../editor-config/database.ts} | 0 .../extensions/editor-config/index.ts | 27 + .../editor-config}/linked.ts | 0 .../editor-config/toolbar}/copy-as-image.ts | 0 .../editor-config/toolbar/index.ts} | 0 .../extensions/electron-clipboard.ts | 10 + .../extensions/entry/enable-affine.ts | 23 + .../extensions/entry/enable-mobile.ts | 113 +++ .../font-config.ts} | 0 .../core/src/blocksuite/extensions/index.ts | 2 + .../keyboard-toolbar-config.ts} | 0 .../note-config}/edgeless-note-header.css.ts | 0 .../note-config}/edgeless-note-header.tsx | 0 .../extensions/note-config/index.tsx | 27 + .../extensions/notification-service.tsx | 124 +++ .../src/blocksuite/extensions/open-doc.ts | 43 + .../extensions/peek-view-service.ts | 36 + .../extensions/quick-search-service.ts | 187 +++++ .../extensions/reference-renderer.ts | 23 + .../blocksuite/extensions/side-bar-service.ts | 20 + .../src/blocksuite/extensions/telemetry.ts | 21 + .../core/src/blocksuite/extensions/theme.ts | 94 +++ 39 files changed, 1142 insertions(+), 1135 deletions(-) delete mode 100644 packages/frontend/core/src/blocksuite/block-suite-editor/specs/custom/root-block.ts delete mode 100644 packages/frontend/core/src/blocksuite/block-suite-editor/specs/custom/spec-patchers.tsx create mode 100644 packages/frontend/core/src/blocksuite/extensions/attachment-embed-view.tsx create mode 100644 packages/frontend/core/src/blocksuite/extensions/display-meta.ts create mode 100644 packages/frontend/core/src/blocksuite/extensions/doc-mode-service.ts create mode 100644 packages/frontend/core/src/blocksuite/extensions/doc-url.ts create mode 100644 packages/frontend/core/src/blocksuite/extensions/edgeless-clipboard.ts rename packages/frontend/core/src/blocksuite/{block-suite-editor/specs/custom/database-block.ts => extensions/editor-config/database.ts} (100%) create mode 100644 packages/frontend/core/src/blocksuite/extensions/editor-config/index.ts rename packages/frontend/core/src/blocksuite/{block-suite-editor/specs/custom/widgets => extensions/editor-config}/linked.ts (100%) rename packages/frontend/core/src/blocksuite/{block-suite-editor/specs/custom/widgets => extensions/editor-config/toolbar}/copy-as-image.ts (100%) rename packages/frontend/core/src/blocksuite/{block-suite-editor/specs/custom/widgets/toolbar.ts => extensions/editor-config/toolbar/index.ts} (100%) create mode 100644 packages/frontend/core/src/blocksuite/extensions/electron-clipboard.ts create mode 100644 packages/frontend/core/src/blocksuite/extensions/entry/enable-affine.ts create mode 100644 packages/frontend/core/src/blocksuite/extensions/entry/enable-mobile.ts rename packages/frontend/core/src/blocksuite/{block-suite-editor/specs/font-extension.ts => extensions/font-config.ts} (100%) create mode 100644 packages/frontend/core/src/blocksuite/extensions/index.ts rename packages/frontend/core/src/blocksuite/{block-suite-editor/specs/custom/widgets/keyboard-toolbar.ts => extensions/keyboard-toolbar-config.ts} (100%) rename packages/frontend/core/src/blocksuite/{block-suite-editor/specs/custom/widgets => extensions/note-config}/edgeless-note-header.css.ts (100%) rename packages/frontend/core/src/blocksuite/{block-suite-editor/specs/custom/widgets => extensions/note-config}/edgeless-note-header.tsx (100%) create mode 100644 packages/frontend/core/src/blocksuite/extensions/note-config/index.tsx create mode 100644 packages/frontend/core/src/blocksuite/extensions/notification-service.tsx create mode 100644 packages/frontend/core/src/blocksuite/extensions/open-doc.ts create mode 100644 packages/frontend/core/src/blocksuite/extensions/peek-view-service.ts create mode 100644 packages/frontend/core/src/blocksuite/extensions/quick-search-service.ts create mode 100644 packages/frontend/core/src/blocksuite/extensions/reference-renderer.ts create mode 100644 packages/frontend/core/src/blocksuite/extensions/side-bar-service.ts create mode 100644 packages/frontend/core/src/blocksuite/extensions/telemetry.ts create mode 100644 packages/frontend/core/src/blocksuite/extensions/theme.ts diff --git a/blocksuite/affine/block-root/src/common-specs/widgets.ts b/blocksuite/affine/block-root/src/common-specs/widgets.ts index ecb620742..a247391d6 100644 --- a/blocksuite/affine/block-root/src/common-specs/widgets.ts +++ b/blocksuite/affine/block-root/src/common-specs/widgets.ts @@ -1,4 +1,3 @@ -import { AFFINE_SCROLL_ANCHORING_WIDGET } from '@blocksuite/affine-widget-scroll-anchoring'; import { WidgetViewExtension } from '@blocksuite/block-std'; import { literal, unsafeStatic } from 'lit/static-html.js'; @@ -45,8 +44,3 @@ export const viewportOverlayWidget = WidgetViewExtension( AFFINE_VIEWPORT_OVERLAY_WIDGET, literal`${unsafeStatic(AFFINE_VIEWPORT_OVERLAY_WIDGET)}` ); -export const scrollAnchoringWidget = WidgetViewExtension( - 'affine:page', - AFFINE_SCROLL_ANCHORING_WIDGET, - literal`${unsafeStatic(AFFINE_SCROLL_ANCHORING_WIDGET)}` -); diff --git a/packages/frontend/component/src/lit-react/index.ts b/packages/frontend/component/src/lit-react/index.ts index fb388ef51..f4335f5bd 100644 --- a/packages/frontend/component/src/lit-react/index.ts +++ b/packages/frontend/component/src/lit-react/index.ts @@ -1,4 +1,7 @@ -export { createComponent as createReactComponentFromLit } from './create-component'; +export { + createComponent as createReactComponentFromLit, + type ReactWebComponent, +} from './create-component'; export * from './lit-portal'; export { toReactNode } from './to-react-node'; export { templateToString } from './utils'; diff --git a/packages/frontend/core/src/blocksuite/ai/components/text-renderer.ts b/packages/frontend/core/src/blocksuite/ai/components/text-renderer.ts index f5039e55e..11b8ad25d 100644 --- a/packages/frontend/core/src/blocksuite/ai/components/text-renderer.ts +++ b/packages/frontend/core/src/blocksuite/ai/components/text-renderer.ts @@ -1,3 +1,4 @@ +import { createReactComponentFromLit } from '@affine/component'; import { BlockStdScope, BlockViewIdentifier, @@ -30,6 +31,7 @@ import { property, query } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { keyed } from 'lit/directives/keyed.js'; import { literal } from 'lit/static-html.js'; +import React from 'react'; import { markDownToDoc } from '../../utils'; import type { @@ -349,12 +351,6 @@ export class TextRenderer extends WithDisposable(ShadowlessElement) { accessor state: AffineAIPanelState | undefined = undefined; } -declare global { - interface HTMLElementTagNameMap { - 'text-renderer': TextRenderer; - } -} - export const createTextRenderer: ( host: EditorHost, options: TextRendererOptions @@ -368,3 +364,14 @@ export const createTextRenderer: ( >`; }; }; + +export const LitTextRenderer = createReactComponentFromLit({ + react: React, + elementClass: TextRenderer, +}); + +declare global { + interface HTMLElementTagNameMap { + 'text-renderer': TextRenderer; + } +} diff --git a/packages/frontend/core/src/blocksuite/ai/extensions/enable-ai.ts b/packages/frontend/core/src/blocksuite/ai/extensions/enable-ai.ts index 5db82b967..2d07fa809 100644 --- a/packages/frontend/core/src/blocksuite/ai/extensions/enable-ai.ts +++ b/packages/frontend/core/src/blocksuite/ai/extensions/enable-ai.ts @@ -1,3 +1,4 @@ +import { FeatureFlagService } from '@affine/core/modules/feature-flag'; import { CodeBlockSpec, EdgelessRootBlockSpec, @@ -19,6 +20,12 @@ export function enableAIExtension( specBuilder: SpecBuilder, framework: FrameworkProvider ) { + const featureFlagService = framework.get(FeatureFlagService); + const enableAI = featureFlagService.flags.enable_ai.value; + if (!enableAI) { + return; + } + specBuilder.replace(CodeBlockSpec, AICodeBlockSpec); specBuilder.replace(ImageBlockSpec, AIImageBlockSpec); specBuilder.replace(ParagraphBlockSpec, AIParagraphBlockSpec); diff --git a/packages/frontend/core/src/blocksuite/block-suite-editor/bi-directional-link-panel.tsx b/packages/frontend/core/src/blocksuite/block-suite-editor/bi-directional-link-panel.tsx index 0752f7574..34d7f3fa7 100644 --- a/packages/frontend/core/src/blocksuite/block-suite-editor/bi-directional-link-panel.tsx +++ b/packages/frontend/core/src/blocksuite/block-suite-editor/bi-directional-link-panel.tsx @@ -1,9 +1,4 @@ -import { - Button, - createReactComponentFromLit, - Divider, - useLitPortalFactory, -} from '@affine/component'; +import { Button, Divider, useLitPortalFactory } from '@affine/component'; import { DocService } from '@affine/core/modules/doc'; import { type Backlink, @@ -28,7 +23,7 @@ import { useLiveData, useServices, } from '@toeverything/infra'; -import React, { +import { Fragment, type ReactNode, useCallback, @@ -40,19 +35,14 @@ import { AffinePageReference, AffineSharedPageReference, } from '../../components/affine/reference-link'; -import { TextRenderer } from '../ai/components/text-renderer'; -import * as styles from './bi-directional-link-panel.css'; +import { LitTextRenderer } from '../ai/components/text-renderer'; import { patchReferenceRenderer, type ReferenceReactRenderer, -} from './specs/custom/spec-patchers'; +} from '../extensions/reference-renderer'; +import * as styles from './bi-directional-link-panel.css'; import { createPageModeSpecs } from './specs/page'; -const BlocksuiteTextRenderer = createReactComponentFromLit({ - react: React, - elementClass: TextRenderer, -}); - const PREFIX = 'bi-directional-link-panel-collapse:'; const useBiDirectionalLinkPanelCollapseState = ( @@ -288,7 +278,7 @@ export const BacklinkGroups = () => { /> ) : ( - { patchNotificationService(confirmModal), patchPeekViewService(peekViewService), patchOpenDocExtension(), - patchEdgelessClipboard(), - patchParseDocUrlExtension(framework), - patchGenerateDocUrlExtension(framework), + EdgelessClipboardWatcher, + patchDocUrlExtensions(framework), patchQuickSearchService(framework), patchSideBarService(framework), patchDocModeService(docService, docsService, editorService), @@ -178,13 +155,7 @@ const usePatchSpecs = (mode: DocMode) => { 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()); + enableMobileExtension(builder); } if (BUILD_CONFIG.isElectron) { builder.extend([patchForClipboardInElectron(framework)].flat()); @@ -319,7 +290,7 @@ export const BlocksuiteDocEditor = forwardRef< <>
{!isJournal ? ( - + ) : ( )} @@ -335,7 +306,7 @@ export const BlocksuiteDocEditor = forwardRef< />
) : null} - - + {portals} ); diff --git a/packages/frontend/core/src/blocksuite/block-suite-editor/specs/custom/root-block.ts b/packages/frontend/core/src/blocksuite/block-suite-editor/specs/custom/root-block.ts deleted file mode 100644 index 97f31275a..000000000 --- a/packages/frontend/core/src/blocksuite/block-suite-editor/specs/custom/root-block.ts +++ /dev/null @@ -1,260 +0,0 @@ -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 { - ColorScheme, - createSignalFromObservable, - DatabaseConfigExtension, - DocDisplayMetaProvider, - EditorSettingExtension, - 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: ( - eventName: T, - props: TelemetryEventMap[T] - ) => { - mixpanel.track(eventName as string, props as Record); - }, - })); - }, - }; -} - -function getThemeExtension(framework: FrameworkProvider) { - class AffineThemeExtension - extends LifeCycleWatcher - implements ThemeExtension - { - static override readonly key = 'affine-theme'; - - private readonly themes: Map> = 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 = framework - .get(AppThemeService) - .appTheme.theme$.map(theme => { - return theme === ColorScheme.Dark - ? ColorScheme.Dark - : ColorScheme.Light; - }); - const { signal: themeSignal, cleanup } = - createSignalFromObservable(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 = 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(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 { - 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 { - const title$ = docDisplayMetaService.title$(docId, { - title, - reference: referenced, - }); - - const { signal: titleSignal, cleanup } = - createSignalFromObservable(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() - ); -} diff --git a/packages/frontend/core/src/blocksuite/block-suite-editor/specs/custom/spec-patchers.tsx b/packages/frontend/core/src/blocksuite/block-suite-editor/specs/custom/spec-patchers.tsx deleted file mode 100644 index 3481d93e4..000000000 --- a/packages/frontend/core/src/blocksuite/block-suite-editor/specs/custom/spec-patchers.tsx +++ /dev/null @@ -1,761 +0,0 @@ -import { - type ElementOrFactory, - Input, - notify, - toast, - type ToastOptions, - toReactNode, - type useConfirmModal, -} from '@affine/component'; -import { AIChatBlockSchema } from '@affine/core/blocksuite/ai/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 { AttachmentEmbedPreview } from '../../../../components/attachment-viewer/pdf-viewer-embedded'; -import { generateUrl } from '../../../../components/hooks/affine/use-share-url'; -import type { DocProps } from '../../../initialization'; -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) { - return NotificationExtension({ - confirm: async ({ title, message, confirmText, cancelText, abort }) => { - return new Promise(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(resolve => { - let value = autofill || ''; - const description = ( -
- {toReactNode(message)} - (value = e)} - /> -
- ); - 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(, 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(), - pageBlockTitle: ({ note }) => { - const journalService = framework.get(JournalService); - const isJournal = !!journalService.journalDate$(note.doc.id).value; - if (isJournal) { - return reactToLit(); - } else { - return html``; - } - }, - }); -} - -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); - }, - }); -} diff --git a/packages/frontend/core/src/blocksuite/block-suite-editor/specs/edgeless.ts b/packages/frontend/core/src/blocksuite/block-suite-editor/specs/edgeless.ts index 50f2ade69..a26f14f09 100644 --- a/packages/frontend/core/src/blocksuite/block-suite-editor/specs/edgeless.ts +++ b/packages/frontend/core/src/blocksuite/block-suite-editor/specs/edgeless.ts @@ -1,23 +1,17 @@ import { enableAIExtension } from '@affine/core/blocksuite/ai'; -import { FeatureFlagService } from '@affine/core/modules/feature-flag'; +import { enableAffineExtension } from '@affine/core/blocksuite/extensions'; 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 { EdgelessTemplatePanel, SpecProvider } from '@blocksuite/affine/blocks'; import { type FrameworkProvider } from '@toeverything/infra'; -import { enableAffineExtension } 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, framework); - } + enableAffineExtension(edgelessSpec, framework); + enableAIExtension(edgelessSpec, framework); return edgelessSpec; } diff --git a/packages/frontend/core/src/blocksuite/block-suite-editor/specs/page.ts b/packages/frontend/core/src/blocksuite/block-suite-editor/specs/page.ts index ea5ee6fec..f074e016f 100644 --- a/packages/frontend/core/src/blocksuite/block-suite-editor/specs/page.ts +++ b/packages/frontend/core/src/blocksuite/block-suite-editor/specs/page.ts @@ -1,18 +1,11 @@ import { enableAIExtension } from '@affine/core/blocksuite/ai'; -import { FeatureFlagService } from '@affine/core/modules/feature-flag'; +import { enableAffineExtension } from '@affine/core/blocksuite/extensions'; import { type SpecBuilder, SpecProvider } from '@blocksuite/affine/blocks'; import { type FrameworkProvider } from '@toeverything/infra'; -import { enableAffineExtension } 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, framework); - } + const pageSpec = SpecProvider._.getSpec('page'); + enableAffineExtension(pageSpec, framework); + enableAIExtension(pageSpec, framework); return pageSpec; } diff --git a/packages/frontend/core/src/blocksuite/block-suite-editor/specs/preview.ts b/packages/frontend/core/src/blocksuite/block-suite-editor/specs/preview.ts index ef9a5dbcc..9c728ec6c 100644 --- a/packages/frontend/core/src/blocksuite/block-suite-editor/specs/preview.ts +++ b/packages/frontend/core/src/blocksuite/block-suite-editor/specs/preview.ts @@ -20,9 +20,10 @@ 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'; +import { buildDocDisplayMetaExtension } from '../../extensions/display-meta'; +import { getFontConfigExtension } from '../../extensions/font-config'; +import { patchPeekViewService } from '../../extensions/peek-view-service'; +import { getThemeExtension } from '../../extensions/theme'; const CustomSpecs: ExtensionType[] = [ AIChatBlockSpec, @@ -111,3 +112,19 @@ export function createPageModePreviewSpecs( ]); return pagePreviewSpec; } + +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; + } + }; +})(); diff --git a/packages/frontend/core/src/blocksuite/editors/index.ts b/packages/frontend/core/src/blocksuite/editors/index.ts index 4ba445ba8..4716eef1f 100644 --- a/packages/frontend/core/src/blocksuite/editors/index.ts +++ b/packages/frontend/core/src/blocksuite/editors/index.ts @@ -1,9 +1,28 @@ +import { createReactComponentFromLit } from '@affine/component'; +import { DocTitle } from '@blocksuite/affine/blocks'; +import React from 'react'; + import { EdgelessEditor } from './edgeless-editor'; import { PageEditor } from './page-editor'; export * from './edgeless-editor'; export * from './page-editor'; +export const LitDocEditor = createReactComponentFromLit({ + react: React, + elementClass: PageEditor, +}); + +export const LitDocTitle = createReactComponentFromLit({ + react: React, + elementClass: DocTitle, +}); + +export const LitEdgelessEditor = createReactComponentFromLit({ + react: React, + elementClass: EdgelessEditor, +}); + export function effects() { customElements.define('page-editor', PageEditor); customElements.define('edgeless-editor', EdgelessEditor); diff --git a/packages/frontend/core/src/blocksuite/extensions/attachment-embed-view.tsx b/packages/frontend/core/src/blocksuite/extensions/attachment-embed-view.tsx new file mode 100644 index 000000000..baff8bf8b --- /dev/null +++ b/packages/frontend/core/src/blocksuite/extensions/attachment-embed-view.tsx @@ -0,0 +1,35 @@ +import type { ElementOrFactory } from '@affine/component'; +import { AttachmentEmbedPreview } from '@affine/core/components/attachment-viewer/pdf-viewer-embedded'; +import { AttachmentEmbedConfigIdentifier } from '@blocksuite/affine/blocks'; +import { Bound } from '@blocksuite/affine/global/utils'; +import type { ExtensionType } from '@blocksuite/affine/store'; +import type { TemplateResult } from 'lit'; + +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(, false), + })); + }, + }; +} diff --git a/packages/frontend/core/src/blocksuite/extensions/display-meta.ts b/packages/frontend/core/src/blocksuite/extensions/display-meta.ts new file mode 100644 index 000000000..6fe888bd5 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/extensions/display-meta.ts @@ -0,0 +1,98 @@ +import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta'; +import { LifeCycleWatcher, StdIdentifier } from '@blocksuite/affine/block-std'; +import type { + DocDisplayMetaExtension, + DocDisplayMetaParams, + Signal, +} from '@blocksuite/affine/blocks'; +import { + createSignalFromObservable, + DocDisplayMetaProvider, + referenceToNode, +} from '@blocksuite/affine/blocks'; +import type { Container } from '@blocksuite/affine/global/di'; +import { LinkedPageIcon, PageIcon } from '@blocksuite/icons/lit'; +import { type FrameworkProvider } from '@toeverything/infra'; +import type { TemplateResult } from 'lit'; + +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 { + 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 { + const title$ = docDisplayMetaService.title$(docId, { + title, + reference: referenced, + }); + + const { signal: titleSignal, cleanup } = + createSignalFromObservable(title$, title ?? ''); + + this.disposables.push(cleanup); + + return titleSignal; + } + + override unmounted() { + this.dispose(); + } + } + + return AffineDocDisplayMetaService; +} diff --git a/packages/frontend/core/src/blocksuite/extensions/doc-mode-service.ts b/packages/frontend/core/src/blocksuite/extensions/doc-mode-service.ts new file mode 100644 index 000000000..0fbafaaa1 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/extensions/doc-mode-service.ts @@ -0,0 +1,53 @@ +import type { DocService, DocsService } from '@affine/core/modules/doc'; +import type { EditorService } from '@affine/core/modules/editor'; +import type { DocMode, DocModeProvider } from '@blocksuite/affine/blocks'; +import { DocModeExtension } from '@blocksuite/affine/blocks'; +import type { ExtensionType } from '@blocksuite/affine/store'; + +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; +} diff --git a/packages/frontend/core/src/blocksuite/extensions/doc-url.ts b/packages/frontend/core/src/blocksuite/extensions/doc-url.ts new file mode 100644 index 000000000..5fd780866 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/extensions/doc-url.ts @@ -0,0 +1,50 @@ +import { generateUrl } from '@affine/core/components/hooks/affine/use-share-url'; +import { WorkspaceServerService } from '@affine/core/modules/cloud'; +import { resolveLinkToDoc } from '@affine/core/modules/navigation/utils'; +import { WorkspaceService } from '@affine/core/modules/workspace'; +import { + GenerateDocUrlExtension, + ParseDocUrlExtension, + type ReferenceParams, +} from '@blocksuite/affine/blocks'; +import type { FrameworkProvider } from '@toeverything/infra'; + +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; +} + +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 patchDocUrlExtensions(framework: FrameworkProvider) { + return [ + patchParseDocUrlExtension(framework), + patchGenerateDocUrlExtension(framework), + ]; +} diff --git a/packages/frontend/core/src/blocksuite/extensions/edgeless-clipboard.ts b/packages/frontend/core/src/blocksuite/extensions/edgeless-clipboard.ts new file mode 100644 index 000000000..ee6b042e5 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/extensions/edgeless-clipboard.ts @@ -0,0 +1,47 @@ +import { AIChatBlockSchema } from '@affine/core/blocksuite/ai/blocks'; +import { BlockServiceWatcher } from '@blocksuite/affine/block-std'; +import { EdgelessRootBlockComponent } from '@blocksuite/affine/blocks'; +import type { BlockSnapshot } from '@blocksuite/affine/store'; + +export 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 + ); + } + }) + ); + } +} diff --git a/packages/frontend/core/src/blocksuite/block-suite-editor/specs/custom/database-block.ts b/packages/frontend/core/src/blocksuite/extensions/editor-config/database.ts similarity index 100% rename from packages/frontend/core/src/blocksuite/block-suite-editor/specs/custom/database-block.ts rename to packages/frontend/core/src/blocksuite/extensions/editor-config/database.ts diff --git a/packages/frontend/core/src/blocksuite/extensions/editor-config/index.ts b/packages/frontend/core/src/blocksuite/extensions/editor-config/index.ts new file mode 100644 index 000000000..d35a3ba34 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/extensions/editor-config/index.ts @@ -0,0 +1,27 @@ +import { EditorSettingService } from '@affine/core/modules/editor-setting'; +import { + DatabaseConfigExtension, + EditorSettingExtension, + RootBlockConfigExtension, + ToolbarMoreMenuConfigExtension, +} from '@blocksuite/affine/blocks'; +import type { ExtensionType } from '@blocksuite/affine/store'; +import type { FrameworkProvider } from '@toeverything/infra'; + +import { createDatabaseOptionsConfig } from './database'; +import { createLinkedWidgetConfig } from './linked'; +import { createToolbarMoreMenuConfig } from './toolbar'; + +export 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)), + ]; +} diff --git a/packages/frontend/core/src/blocksuite/block-suite-editor/specs/custom/widgets/linked.ts b/packages/frontend/core/src/blocksuite/extensions/editor-config/linked.ts similarity index 100% rename from packages/frontend/core/src/blocksuite/block-suite-editor/specs/custom/widgets/linked.ts rename to packages/frontend/core/src/blocksuite/extensions/editor-config/linked.ts diff --git a/packages/frontend/core/src/blocksuite/block-suite-editor/specs/custom/widgets/copy-as-image.ts b/packages/frontend/core/src/blocksuite/extensions/editor-config/toolbar/copy-as-image.ts similarity index 100% rename from packages/frontend/core/src/blocksuite/block-suite-editor/specs/custom/widgets/copy-as-image.ts rename to packages/frontend/core/src/blocksuite/extensions/editor-config/toolbar/copy-as-image.ts diff --git a/packages/frontend/core/src/blocksuite/block-suite-editor/specs/custom/widgets/toolbar.ts b/packages/frontend/core/src/blocksuite/extensions/editor-config/toolbar/index.ts similarity index 100% rename from packages/frontend/core/src/blocksuite/block-suite-editor/specs/custom/widgets/toolbar.ts rename to packages/frontend/core/src/blocksuite/extensions/editor-config/toolbar/index.ts diff --git a/packages/frontend/core/src/blocksuite/extensions/electron-clipboard.ts b/packages/frontend/core/src/blocksuite/extensions/electron-clipboard.ts new file mode 100644 index 000000000..bebd8c4d6 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/extensions/electron-clipboard.ts @@ -0,0 +1,10 @@ +import { DesktopApiService } from '@affine/core/modules/desktop-api'; +import { NativeClipboardExtension } from '@blocksuite/affine/blocks'; +import type { FrameworkProvider } from '@toeverything/infra'; + +export function patchForClipboardInElectron(framework: FrameworkProvider) { + const desktopApi = framework.get(DesktopApiService); + return NativeClipboardExtension({ + copyAsPNG: desktopApi.handler.clipboard.copyAsPNG, + }); +} diff --git a/packages/frontend/core/src/blocksuite/extensions/entry/enable-affine.ts b/packages/frontend/core/src/blocksuite/extensions/entry/enable-affine.ts new file mode 100644 index 000000000..d4b8bfca5 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/extensions/entry/enable-affine.ts @@ -0,0 +1,23 @@ +import type { SpecBuilder } from '@blocksuite/affine/blocks'; +import { type FrameworkProvider } from '@toeverything/infra'; + +import { buildDocDisplayMetaExtension } from '../display-meta'; +import { getEditorConfigExtension } from '../editor-config'; +import { getFontConfigExtension } from '../font-config'; +import { getTelemetryExtension } from '../telemetry'; +import { getThemeExtension } from '../theme'; + +export function enableAffineExtension( + specBuilder: SpecBuilder, + framework: FrameworkProvider +): void { + specBuilder.extend( + [ + getThemeExtension(framework), + getFontConfigExtension(), + getTelemetryExtension(), + getEditorConfigExtension(framework), + buildDocDisplayMetaExtension(framework), + ].flat() + ); +} diff --git a/packages/frontend/core/src/blocksuite/extensions/entry/enable-mobile.ts b/packages/frontend/core/src/blocksuite/extensions/entry/enable-mobile.ts new file mode 100644 index 000000000..76ffedaf4 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/extensions/entry/enable-mobile.ts @@ -0,0 +1,113 @@ +import { createKeyboardToolbarConfig } from '@affine/core/blocksuite/extensions/keyboard-toolbar-config'; +import { + type BlockStdScope, + ConfigIdentifier, + LifeCycleWatcher, +} from '@blocksuite/affine/block-std'; +import type { + CodeBlockConfig, + ReferenceNodeConfig, + RootBlockConfig, + SpecBuilder, +} from '@blocksuite/affine/blocks'; +import { + codeToolbarWidget, + embedCardToolbarWidget, + FeatureFlagService, + formatBarWidget, + imageToolbarWidget, + ParagraphBlockService, + ReferenceNodeConfigIdentifier, + RootBlockConfigExtension, + slashMenuWidget, + surfaceRefToolbarWidget, +} from '@blocksuite/affine/blocks'; +import type { Container } from '@blocksuite/affine/global/di'; +import type { ExtensionType } from '@blocksuite/affine/store'; + +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 mobileExtensions: ExtensionType[] = [ + { + setup: di => { + const prev = di.getFactory(RootBlockConfigExtension.identifier); + + di.override(RootBlockConfigExtension.identifier, provider => { + return { + ...prev?.(provider), + keyboardToolbar: createKeyboardToolbarConfig(), + } satisfies RootBlockConfig; + }); + }, + }, + MobileSpecsPatches, +]; + +export function enableMobileExtension(specBuilder: SpecBuilder): void { + specBuilder.omit(formatBarWidget); + specBuilder.omit(embedCardToolbarWidget); + specBuilder.omit(slashMenuWidget); + specBuilder.omit(codeToolbarWidget); + specBuilder.omit(imageToolbarWidget); + specBuilder.omit(surfaceRefToolbarWidget); + specBuilder.extend(mobileExtensions); +} diff --git a/packages/frontend/core/src/blocksuite/block-suite-editor/specs/font-extension.ts b/packages/frontend/core/src/blocksuite/extensions/font-config.ts similarity index 100% rename from packages/frontend/core/src/blocksuite/block-suite-editor/specs/font-extension.ts rename to packages/frontend/core/src/blocksuite/extensions/font-config.ts diff --git a/packages/frontend/core/src/blocksuite/extensions/index.ts b/packages/frontend/core/src/blocksuite/extensions/index.ts new file mode 100644 index 000000000..bbc9b34f6 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/extensions/index.ts @@ -0,0 +1,2 @@ +export * from './entry/enable-affine'; +export * from './entry/enable-mobile'; diff --git a/packages/frontend/core/src/blocksuite/block-suite-editor/specs/custom/widgets/keyboard-toolbar.ts b/packages/frontend/core/src/blocksuite/extensions/keyboard-toolbar-config.ts similarity index 100% rename from packages/frontend/core/src/blocksuite/block-suite-editor/specs/custom/widgets/keyboard-toolbar.ts rename to packages/frontend/core/src/blocksuite/extensions/keyboard-toolbar-config.ts diff --git a/packages/frontend/core/src/blocksuite/block-suite-editor/specs/custom/widgets/edgeless-note-header.css.ts b/packages/frontend/core/src/blocksuite/extensions/note-config/edgeless-note-header.css.ts similarity index 100% rename from packages/frontend/core/src/blocksuite/block-suite-editor/specs/custom/widgets/edgeless-note-header.css.ts rename to packages/frontend/core/src/blocksuite/extensions/note-config/edgeless-note-header.css.ts diff --git a/packages/frontend/core/src/blocksuite/block-suite-editor/specs/custom/widgets/edgeless-note-header.tsx b/packages/frontend/core/src/blocksuite/extensions/note-config/edgeless-note-header.tsx similarity index 100% rename from packages/frontend/core/src/blocksuite/block-suite-editor/specs/custom/widgets/edgeless-note-header.tsx rename to packages/frontend/core/src/blocksuite/extensions/note-config/edgeless-note-header.tsx diff --git a/packages/frontend/core/src/blocksuite/extensions/note-config/index.tsx b/packages/frontend/core/src/blocksuite/extensions/note-config/index.tsx new file mode 100644 index 000000000..e2c267ccc --- /dev/null +++ b/packages/frontend/core/src/blocksuite/extensions/note-config/index.tsx @@ -0,0 +1,27 @@ +import type { ElementOrFactory } from '@affine/component'; +import { JournalService } from '@affine/core/modules/journal'; +import { NoteConfigExtension } from '@blocksuite/affine/blocks'; +import type { FrameworkProvider } from '@toeverything/infra'; +import { html, type TemplateResult } from 'lit'; + +import { BlocksuiteEditorJournalDocTitle } from '../../block-suite-editor/journal-doc-title'; +import { EdgelessNoteHeader } from './edgeless-note-header'; + +export function patchForEdgelessNoteConfig( + framework: FrameworkProvider, + reactToLit: (element: ElementOrFactory) => TemplateResult +) { + return NoteConfigExtension({ + edgelessNoteHeader: ({ note }) => + reactToLit(), + pageBlockTitle: ({ note }) => { + const journalService = framework.get(JournalService); + const isJournal = !!journalService.journalDate$(note.doc.id).value; + if (isJournal) { + return reactToLit(); + } else { + return html``; + } + }, + }); +} diff --git a/packages/frontend/core/src/blocksuite/extensions/notification-service.tsx b/packages/frontend/core/src/blocksuite/extensions/notification-service.tsx new file mode 100644 index 000000000..a2cc2dd8c --- /dev/null +++ b/packages/frontend/core/src/blocksuite/extensions/notification-service.tsx @@ -0,0 +1,124 @@ +import { + Input, + notify, + toast, + type ToastOptions, + toReactNode, + type useConfirmModal, +} from '@affine/component'; +import { NotificationExtension } from '@blocksuite/affine/blocks'; + +export function patchNotificationService({ + closeConfirmModal, + openConfirmModal, +}: ReturnType) { + return NotificationExtension({ + confirm: async ({ title, message, confirmText, cancelText, abort }) => { + return new Promise(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(resolve => { + let value = autofill || ''; + const description = ( +
+ {toReactNode(message)} + (value = e)} + /> +
+ ); + 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); + }); + }, + }); +} diff --git a/packages/frontend/core/src/blocksuite/extensions/open-doc.ts b/packages/frontend/core/src/blocksuite/extensions/open-doc.ts new file mode 100644 index 000000000..4b5444deb --- /dev/null +++ b/packages/frontend/core/src/blocksuite/extensions/open-doc.ts @@ -0,0 +1,43 @@ +import { I18n } from '@affine/i18n'; +import type { + OpenDocConfig, + OpenDocConfigItem, +} from '@blocksuite/affine/blocks'; +import { OpenDocExtension } from '@blocksuite/affine/blocks'; +import { + CenterPeekIcon, + ExpandFullIcon, + OpenInNewIcon, + SplitViewIcon, +} from '@blocksuite/icons/lit'; + +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); +} diff --git a/packages/frontend/core/src/blocksuite/extensions/peek-view-service.ts b/packages/frontend/core/src/blocksuite/extensions/peek-view-service.ts new file mode 100644 index 000000000..8d1e2ec8c --- /dev/null +++ b/packages/frontend/core/src/blocksuite/extensions/peek-view-service.ts @@ -0,0 +1,36 @@ +import type { PeekViewService } from '@affine/core/modules/peek-view'; +import { DebugLogger } from '@affine/debug'; +import type { + PeekOptions, + PeekViewService as BSPeekViewService, +} from '@blocksuite/affine/blocks'; +import { PeekViewExtension } from '@blocksuite/affine/blocks'; +import type { TemplateResult } from 'lit'; + +const logger = new DebugLogger('affine::patch-peek-view-service'); + +export function patchPeekViewService(service: PeekViewService) { + return PeekViewExtension({ + peek: ( + element: { + target: HTMLElement; + docId: string; + blockIds?: string[]; + template?: TemplateResult; + }, + options?: PeekOptions + ) => { + logger.debug('center peek', element); + const { template, target, ...props } = element; + + return service.peekView.open( + { + element: target, + docRef: props, + }, + template, + options?.abortSignal + ); + }, + } satisfies BSPeekViewService); +} diff --git a/packages/frontend/core/src/blocksuite/extensions/quick-search-service.ts b/packages/frontend/core/src/blocksuite/extensions/quick-search-service.ts new file mode 100644 index 000000000..5fbb5612f --- /dev/null +++ b/packages/frontend/core/src/blocksuite/extensions/quick-search-service.ts @@ -0,0 +1,187 @@ +import { DocsService } from '@affine/core/modules/doc'; +import { EditorSettingService } from '@affine/core/modules/editor-setting'; +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 { track } from '@affine/track'; +import { + BlockServiceWatcher, + type WidgetComponent, +} from '@blocksuite/affine/block-std'; +import type { QuickSearchResult } from '@blocksuite/affine/blocks'; +import { + AffineSlashMenuWidget, + insertLinkByQuickSearchCommand, + QuickSearchExtension, +} from '@blocksuite/affine/blocks'; +import { Text } from '@blocksuite/affine/store'; +import type { FrameworkProvider } from '@toeverything/infra'; +import { pick } from 'lodash-es'; + +import type { DocProps } from '../initialization'; + +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]; +} + +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; +} diff --git a/packages/frontend/core/src/blocksuite/extensions/reference-renderer.ts b/packages/frontend/core/src/blocksuite/extensions/reference-renderer.ts new file mode 100644 index 000000000..24f091b56 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/extensions/reference-renderer.ts @@ -0,0 +1,23 @@ +import type { ElementOrFactory } from '@affine/component'; +import type { AffineReference } from '@blocksuite/affine/blocks'; +import { ReferenceNodeConfigExtension } from '@blocksuite/affine/blocks'; +import { type ExtensionType } from '@blocksuite/affine/store'; +import type { TemplateResult } from 'lit'; + +export type ReferenceReactRenderer = ( + reference: AffineReference +) => React.ReactElement; + +export function patchReferenceRenderer( + reactToLit: (element: ElementOrFactory) => TemplateResult, + reactRenderer: ReferenceReactRenderer +): ExtensionType { + const customContent = (reference: AffineReference) => { + const node = reactRenderer(reference); + return reactToLit(node); + }; + + return ReferenceNodeConfigExtension({ + customContent, + }); +} diff --git a/packages/frontend/core/src/blocksuite/extensions/side-bar-service.ts b/packages/frontend/core/src/blocksuite/extensions/side-bar-service.ts new file mode 100644 index 000000000..c72de0768 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/extensions/side-bar-service.ts @@ -0,0 +1,20 @@ +import { WorkbenchService } from '@affine/core/modules/workbench'; +import { SidebarExtension } from '@blocksuite/affine/blocks'; +import type { FrameworkProvider } from '@toeverything/infra'; + +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); + }, + }); +} diff --git a/packages/frontend/core/src/blocksuite/extensions/telemetry.ts b/packages/frontend/core/src/blocksuite/extensions/telemetry.ts new file mode 100644 index 000000000..ccda0a539 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/extensions/telemetry.ts @@ -0,0 +1,21 @@ +import { mixpanel } from '@affine/track'; +import { + type TelemetryEventMap, + TelemetryProvider, +} from '@blocksuite/affine/blocks'; +import type { ExtensionType } from '@blocksuite/affine/store'; + +export function getTelemetryExtension(): ExtensionType { + return { + setup: di => { + di.addImpl(TelemetryProvider, () => ({ + track: ( + eventName: T, + props: TelemetryEventMap[T] + ) => { + mixpanel.track(eventName as string, props as Record); + }, + })); + }, + }; +} diff --git a/packages/frontend/core/src/blocksuite/extensions/theme.ts b/packages/frontend/core/src/blocksuite/extensions/theme.ts new file mode 100644 index 000000000..cf75c90dd --- /dev/null +++ b/packages/frontend/core/src/blocksuite/extensions/theme.ts @@ -0,0 +1,94 @@ +import { DocService, DocsService } from '@affine/core/modules/doc'; +import { AppThemeService } from '@affine/core/modules/theme'; +import { LifeCycleWatcher, StdIdentifier } from '@blocksuite/affine/block-std'; +import type { Signal, ThemeExtension } from '@blocksuite/affine/blocks'; +import { + ColorScheme, + createSignalFromObservable, + ThemeExtensionIdentifier, +} from '@blocksuite/affine/blocks'; +import type { Container } from '@blocksuite/affine/global/di'; +import { type FrameworkProvider } from '@toeverything/infra'; +import type { Observable } from 'rxjs'; +import { combineLatest, map } from 'rxjs'; + +export function getThemeExtension( + framework: FrameworkProvider +): typeof LifeCycleWatcher { + class AffineThemeExtension + extends LifeCycleWatcher + implements ThemeExtension + { + static override readonly key = 'affine-theme'; + + private readonly themes: Map> = 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 = framework + .get(AppThemeService) + .appTheme.theme$.map(theme => { + return theme === ColorScheme.Dark + ? ColorScheme.Dark + : ColorScheme.Light; + }); + const { signal: themeSignal, cleanup } = + createSignalFromObservable(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 = 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(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; +}