diff --git a/blocksuite/affine/blocks/callout/package.json b/blocksuite/affine/blocks/callout/package.json index b396853fa5..4d7b525b98 100644 --- a/blocksuite/affine/blocks/callout/package.json +++ b/blocksuite/affine/blocks/callout/package.json @@ -10,6 +10,7 @@ "author": "toeverything", "license": "MIT", "dependencies": { + "@affine/component": "workspace:*", "@blocksuite/affine-components": "workspace:*", "@blocksuite/affine-ext-loader": "workspace:*", "@blocksuite/affine-inline-preset": "workspace:*", diff --git a/blocksuite/affine/blocks/callout/src/callout-block.ts b/blocksuite/affine/blocks/callout/src/callout-block.ts index 3e806ef827..13b18aeed6 100644 --- a/blocksuite/affine/blocks/callout/src/callout-block.ts +++ b/blocksuite/affine/blocks/callout/src/callout-block.ts @@ -1,18 +1,54 @@ import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption'; -import { createLitPortal } from '@blocksuite/affine-components/portal'; import { DefaultInlineManagerExtension } from '@blocksuite/affine-inline-preset'; import { type CalloutBlockModel, DefaultTheme } from '@blocksuite/affine-model'; import { focusTextModel } from '@blocksuite/affine-rich-text'; import { EDGELESS_TOP_CONTENTEDITABLE_SELECTOR } from '@blocksuite/affine-shared/consts'; import { DocModeProvider, + type IconData, + IconPickerServiceIdentifier, + IconType, ThemeProvider, } from '@blocksuite/affine-shared/services'; +import type { UniComponent } from '@blocksuite/affine-shared/types'; +import * as icons from '@blocksuite/icons/lit'; import type { BlockComponent } from '@blocksuite/std'; -import { flip, offset } from '@floating-ui/dom'; +import { type Signal, signal } from '@preact/signals-core'; +import type { TemplateResult } from 'lit'; import { css, html } from 'lit'; -import { query } from 'lit/decorators.js'; -import { styleMap } from 'lit/directives/style-map.js'; +import { type StyleInfo, styleMap } from 'lit/directives/style-map.js'; +// Copy of renderUniLit and UniLit from affine-data-view +export const renderUniLit = >( + uni: UniComponent | undefined, + props?: Props, + options?: { + ref?: Signal; + style?: Readonly; + class?: string; + } +): TemplateResult => { + return html` `; +}; +const getIcon = (icon?: IconData) => { + console.log(icon); + if (!icon) { + return '💡'; + } + if (icon.type === IconType.Emoji) { + return icon.unicode; + } + if (icon.type === IconType.AffineIcon) { + return ( + icons as Record TemplateResult> + )[`${icon.name}Icon`]?.({ style: `color:${icon.color}` }); + } + return '💡'; +}; export class CalloutBlockComponent extends CaptionedBlockComponent { static override styles = css` :host { @@ -38,6 +74,12 @@ export class CalloutBlockComponent extends CaptionedBlockComponent { - if (this._emojiMenuAbortController) { - this._emojiMenuAbortController.abort(); + private readonly showIconPicker$ = signal(false); + + private _closeEmojiMenu() { + this.showIconPicker$.value = false; + } + + private _toggleIconPicker() { + this.showIconPicker$.value = !this.showIconPicker$.value; + } + + private _renderIconPicker() { + if (!this.showIconPicker$.value) { + return html``; } - this._emojiMenuAbortController = new AbortController(); - const theme = this.std.get(ThemeProvider).theme$.value; + // Get IconPickerService from the framework + const iconPickerService = this.std.getOptional(IconPickerServiceIdentifier); + if (!iconPickerService) { + console.warn('IconPickerService not found'); + return html``; + } - createLitPortal({ - template: html` { - this.model.props.emoji = data.native; + // Get the uni-component from the service + const iconPickerComponent = iconPickerService.iconPickerComponent; + + // Create props for the icon picker + const props = { + onSelect: (iconData?: IconData) => { + this.model.props.icon$.value = iconData; + this._closeEmojiMenu(); // Close the picker after selection + }, + onClose: () => { + this._closeEmojiMenu(); + }, + }; + + return html` +
{ + e.stopPropagation(); }} - >`, - portalStyles: { - zIndex: 'var(--affine-z-index-popover)', - }, - container: this.host, - computePosition: { - referenceElement: this._emojiButton, - placement: 'bottom-start', - middleware: [flip(), offset(4)], - autoUpdate: { animationFrame: true }, - }, - abortController: this._emojiMenuAbortController, - closeOnClickAway: true, - }); - }; + class="icon-picker-container" + > + ${renderUniLit(iconPickerComponent, props)} +
+ `; + } private readonly _handleBlockClick = (event: MouseEvent) => { // Check if the click target is emoji related element @@ -123,9 +195,6 @@ export class CalloutBlockComponent extends CaptionedBlockComponent( @@ -136,7 +205,7 @@ export class CalloutBlockComponent extends CaptionedBlockComponent
- ${emoji} + ${getIcon(icon)} + ${this._renderIconPicker()}
${this.renderChildren(this.model)} diff --git a/blocksuite/affine/blocks/callout/src/effects.ts b/blocksuite/affine/blocks/callout/src/effects.ts index 7cf7bc7737..dadde7bab5 100644 --- a/blocksuite/affine/blocks/callout/src/effects.ts +++ b/blocksuite/affine/blocks/callout/src/effects.ts @@ -1,14 +1,11 @@ import { CalloutBlockComponent } from './callout-block'; -import { EmojiMenu } from './emoji-menu'; export function effects() { customElements.define('affine-callout', CalloutBlockComponent); - customElements.define('affine-emoji-menu', EmojiMenu); } declare global { interface HTMLElementTagNameMap { 'affine-callout': CalloutBlockComponent; - 'affine-emoji-menu': EmojiMenu; } } diff --git a/blocksuite/affine/blocks/callout/src/emoji-menu.ts b/blocksuite/affine/blocks/callout/src/emoji-menu.ts deleted file mode 100644 index 3e06187c49..0000000000 --- a/blocksuite/affine/blocks/callout/src/emoji-menu.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { WithDisposable } from '@blocksuite/global/lit'; -import data from '@emoji-mart/data'; -import { Picker } from 'emoji-mart'; -import { html, LitElement, type PropertyValues } from 'lit'; -import { property, query } from 'lit/decorators.js'; - -export class EmojiMenu extends WithDisposable(LitElement) { - override firstUpdated(props: PropertyValues) { - const result = super.firstUpdated(props); - - const picker = new Picker({ - data, - onEmojiSelect: this.onEmojiSelect, - autoFocus: true, - theme: this.theme, - }); - this.emojiMenu.append(picker as unknown as Node); - - return result; - } - - @property({ attribute: false }) - accessor onEmojiSelect: (data: any) => void = () => {}; - - @property({ attribute: false }) - accessor theme: 'light' | 'dark' = 'light'; - - @query('.affine-emoji-menu') - accessor emojiMenu!: HTMLElement; - - override render() { - return html`
`; - } -} diff --git a/blocksuite/affine/model/src/blocks/callout/callout-model.ts b/blocksuite/affine/model/src/blocks/callout/callout-model.ts index 661655b95a..bd511bea24 100644 --- a/blocksuite/affine/model/src/blocks/callout/callout-model.ts +++ b/blocksuite/affine/model/src/blocks/callout/callout-model.ts @@ -1,3 +1,4 @@ +import type { IconData } from '@blocksuite/affine-shared/services'; import { BlockModel, BlockSchemaExtension, @@ -10,7 +11,7 @@ import { DefaultTheme } from '../../themes/index.js'; import type { BlockMeta } from '../../utils/types'; export type CalloutProps = { - emoji: string; + icon?: IconData; text: Text; background: Color; } & BlockMeta; @@ -18,7 +19,7 @@ export type CalloutProps = { export const CalloutBlockSchema = defineBlockSchema({ flavour: 'affine:callout', props: (internal): CalloutProps => ({ - emoji: '😀', + icon: undefined, text: internal.Text(), background: DefaultTheme.NoteBackgroundColorMap.White, 'meta:createdAt': undefined, diff --git a/blocksuite/affine/shared/src/services/icon-picker-service.ts b/blocksuite/affine/shared/src/services/icon-picker-service.ts new file mode 100644 index 0000000000..7aae6ce533 --- /dev/null +++ b/blocksuite/affine/shared/src/services/icon-picker-service.ts @@ -0,0 +1 @@ +export * from './icon-picker-service/index.js'; diff --git a/blocksuite/affine/shared/src/services/icon-picker-service/index.ts b/blocksuite/affine/shared/src/services/icon-picker-service/index.ts new file mode 100644 index 0000000000..6c188ff112 --- /dev/null +++ b/blocksuite/affine/shared/src/services/icon-picker-service/index.ts @@ -0,0 +1,37 @@ +import type { UniComponent } from '@blocksuite/affine-shared/types'; +import { createIdentifier } from '@blocksuite/global/di'; +import type { TemplateResult } from 'lit'; +export enum IconType { + Emoji = 'emoji', + AffineIcon = 'affine-icon', + Blob = 'blob', +} + +export type IconData = + | { + type: IconType.Emoji; + unicode: string; + } + | { + type: IconType.AffineIcon; + name: string; + color: string; + } + | { + type: IconType.Blob; + blob: Blob; + }; + +export interface IconPickerOptions { + onSelect?: (icon: IconData) => void; + onClose?: () => void; + currentIcon?: IconData; +} + +export interface IconPickerService { + iconPickerComponent: UniComponent<{ onSelect?: (data?: IconData) => void }>; + renderIconPicker(options: IconPickerOptions): TemplateResult; +} + +export const IconPickerServiceIdentifier = + createIdentifier('IconPickerService'); diff --git a/blocksuite/affine/shared/src/services/index.ts b/blocksuite/affine/shared/src/services/index.ts index 8768374a54..c8f7f6ab0c 100644 --- a/blocksuite/affine/shared/src/services/index.ts +++ b/blocksuite/affine/shared/src/services/index.ts @@ -13,6 +13,7 @@ export * from './feature-flag-service'; export * from './file-size-limit-service'; export * from './font-loader'; export * from './generate-url-service'; +export * from './icon-picker-service'; export * from './link-preview-service'; export * from './native-clipboard-service'; export * from './notification-service'; 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 5b43794d50..8b5871a361 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 @@ -175,6 +175,7 @@ const usePreviewExtensions = () => { .ai(enableAI, framework) .theme(framework) .database(framework) + .iconPicker(framework) .linkedDoc(framework) .paragraph(enableAI) .linkPreview(framework) diff --git a/packages/frontend/core/src/blocksuite/block-suite-editor/lit-adaper.tsx b/packages/frontend/core/src/blocksuite/block-suite-editor/lit-adaper.tsx index 23cb69b181..73a4b2e71e 100644 --- a/packages/frontend/core/src/blocksuite/block-suite-editor/lit-adaper.tsx +++ b/packages/frontend/core/src/blocksuite/block-suite-editor/lit-adaper.tsx @@ -117,6 +117,7 @@ const usePatchSpecs = (mode: DocMode, shared?: boolean) => { .electron(framework) .linkPreview(framework) .codeBlockPreview(framework) + .iconPicker(framework) .comment(enableComment, framework).value; if (BUILD_CONFIG.isMobileEdition) { diff --git a/packages/frontend/core/src/blocksuite/manager/view.ts b/packages/frontend/core/src/blocksuite/manager/view.ts index b89eb7d73f..d75580534b 100644 --- a/packages/frontend/core/src/blocksuite/manager/view.ts +++ b/packages/frontend/core/src/blocksuite/manager/view.ts @@ -16,6 +16,7 @@ import { type AffineEditorViewOptions, } from '@affine/core/blocksuite/view-extensions/editor-view/editor-view'; import { ElectronViewExtension } from '@affine/core/blocksuite/view-extensions/electron'; +import { AffineIconPickerExtension } from '@affine/core/blocksuite/view-extensions/icon-picker'; import { AffineLinkPreviewExtension } from '@affine/core/blocksuite/view-extensions/link-preview-service'; import { MobileViewExtension } from '@affine/core/blocksuite/view-extensions/mobile'; import { PdfViewExtension } from '@affine/core/blocksuite/view-extensions/pdf'; @@ -58,6 +59,7 @@ type Configure = { electron: (framework?: FrameworkProvider) => Configure; linkPreview: (framework?: FrameworkProvider) => Configure; codeBlockPreview: (framework?: FrameworkProvider) => Configure; + iconPicker: (framework?: FrameworkProvider) => Configure; comment: ( enableComment?: boolean, framework?: FrameworkProvider @@ -86,6 +88,7 @@ class ViewProvider { AffineThemeViewExtension, AffineEditorViewExtension, AffineEditorConfigViewExtension, + AffineIconPickerExtension, CodeBlockPreviewViewExtension, EdgelessBlockHeaderConfigViewExtension, TurboRendererViewExtension, @@ -123,6 +126,7 @@ class ViewProvider { electron: this._configureElectron, linkPreview: this._configureLinkPreview, codeBlockPreview: this._configureCodeBlockHtmlPreview, + iconPicker: this._configureIconPicker, comment: this._configureComment, value: this._manager, }; @@ -146,6 +150,7 @@ class ViewProvider { .electron() .linkPreview() .codeBlockPreview() + .iconPicker() .comment(); return this.config; @@ -333,6 +338,11 @@ class ViewProvider { return this.config; }; + private readonly _configureIconPicker = (framework?: FrameworkProvider) => { + this._manager.configure(AffineIconPickerExtension, { framework }); + return this.config; + }; + private readonly _configureComment = ( enableComment?: boolean, framework?: FrameworkProvider diff --git a/packages/frontend/core/src/blocksuite/view-extensions/icon-picker/icon-picker-service.ts b/packages/frontend/core/src/blocksuite/view-extensions/icon-picker/icon-picker-service.ts new file mode 100644 index 0000000000..aeef7288d9 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/view-extensions/icon-picker/icon-picker-service.ts @@ -0,0 +1,23 @@ +import { IconPickerServiceIdentifier } from '@blocksuite/affine/shared/services'; +import { type ExtensionType } from '@blocksuite/affine/store'; +import type { Container } from '@blocksuite/global/di'; +import type { FrameworkProvider } from '@toeverything/infra'; + +import { IconPickerService } from '../../../modules/icon-picker/services/icon-picker'; + +/** + * Patch the icon picker service to make it available in BlockSuite + * @param framework + * @returns + */ +export function patchIconPickerService( + framework: FrameworkProvider +): ExtensionType { + return { + setup: (di: Container) => { + di.override(IconPickerServiceIdentifier, () => { + return framework.get(IconPickerService); + }); + }, + }; +} diff --git a/packages/frontend/core/src/blocksuite/view-extensions/icon-picker/index.ts b/packages/frontend/core/src/blocksuite/view-extensions/icon-picker/index.ts new file mode 100644 index 0000000000..a61879645b --- /dev/null +++ b/packages/frontend/core/src/blocksuite/view-extensions/icon-picker/index.ts @@ -0,0 +1,32 @@ +import { + type ViewExtensionContext, + ViewExtensionProvider, +} from '@blocksuite/affine/ext-loader'; +import { FrameworkProvider } from '@toeverything/infra'; +import { z } from 'zod'; + +import { patchIconPickerService } from './icon-picker-service'; + +const optionsSchema = z.object({ + framework: z.instanceof(FrameworkProvider).optional(), +}); + +type AffineIconPickerViewOptions = z.infer; + +export class AffineIconPickerExtension extends ViewExtensionProvider { + override name = 'affine-icon-picker-extension'; + + override schema = optionsSchema; + + override setup( + context: ViewExtensionContext, + options?: AffineIconPickerViewOptions + ) { + super.setup(context, options); + if (!options?.framework) { + return; + } + const { framework } = options; + context.register(patchIconPickerService(framework)); + } +} diff --git a/packages/frontend/core/src/components/hooks/affine/use-ai-specs.ts b/packages/frontend/core/src/components/hooks/affine/use-ai-specs.ts index 231cd69682..e402145b27 100644 --- a/packages/frontend/core/src/components/hooks/affine/use-ai-specs.ts +++ b/packages/frontend/core/src/components/hooks/affine/use-ai-specs.ts @@ -44,6 +44,7 @@ export const useAISpecs = () => { .mobile(framework) .electron(framework) .linkPreview(framework) + .iconPicker(framework) .codeBlockPreview(framework).value; return manager.get('page'); diff --git a/packages/frontend/core/src/modules/icon-picker/index.ts b/packages/frontend/core/src/modules/icon-picker/index.ts new file mode 100644 index 0000000000..4c1aaa6f4b --- /dev/null +++ b/packages/frontend/core/src/modules/icon-picker/index.ts @@ -0,0 +1,9 @@ +import { type Framework } from '@toeverything/infra'; + +import { IconPickerService } from './services/icon-picker'; + +export { IconPickerService } from './services/icon-picker'; + +export function configureIconPickerModule(framework: Framework) { + framework.service(IconPickerService); +} diff --git a/packages/frontend/core/src/modules/icon-picker/services/icon-picker.ts b/packages/frontend/core/src/modules/icon-picker/services/icon-picker.ts new file mode 100644 index 0000000000..44dd1d056a --- /dev/null +++ b/packages/frontend/core/src/modules/icon-picker/services/icon-picker.ts @@ -0,0 +1,69 @@ +import { + type IconData as ComponentIconData, + IconPicker, + IconType, + uniReactRoot, +} from '@affine/component'; +// Import the identifier for internal use +import { + type IconData, + type IconPickerOptions, + type IconPickerService as IIconPickerService, +} from '@blocksuite/affine-shared/services'; +import { Service } from '@toeverything/infra'; +import { html, type TemplateResult } from 'lit'; + +// Re-export types from BlockSuite shared services +export type { + IconData, + IconPickerOptions, + IconPickerService as IIconPickerService, +} from '@blocksuite/affine-shared/services'; +export { IconPickerServiceIdentifier } from '@blocksuite/affine-shared/services'; + +// Convert between BlockSuite IconData and Component IconData +function convertToBlockSuiteIconData( + componentIconData: ComponentIconData +): IconData { + if (componentIconData.type === IconType.Emoji) { + return { + type: 'emoji', + value: componentIconData.unicode, + }; + } else if (componentIconData.type === IconType.AffineIcon) { + return { + type: 'icon', + value: componentIconData.name, + }; + } + // For other types, default to icon type + return { + type: 'icon', + value: 'default', + }; +} + +export class IconPickerService extends Service implements IIconPickerService { + public readonly iconPickerComponent = + uniReactRoot.createUniComponent(IconPicker); + + renderIconPicker(options: IconPickerOptions): TemplateResult { + const element = document.createElement('div'); + + // Adapt the options to match IconPicker component's expected interface + const adaptedOptions = { + onSelect: options.onSelect + ? (data?: ComponentIconData) => { + if (data && options.onSelect) { + const blockSuiteIconData = convertToBlockSuiteIconData(data); + options.onSelect(blockSuiteIconData); + } + } + : undefined, + onClose: options.onClose, + }; + + this.iconPickerComponent(element, adaptedOptions, () => {}); + return html`${element}`; + } +} diff --git a/packages/frontend/core/src/modules/index.ts b/packages/frontend/core/src/modules/index.ts index c09aa994ee..890adefe58 100644 --- a/packages/frontend/core/src/modules/index.ts +++ b/packages/frontend/core/src/modules/index.ts @@ -33,6 +33,7 @@ import { configureFavoriteModule } from './favorite'; import { configureFeatureFlagModule } from './feature-flag'; import { configureGlobalContextModule } from './global-context'; import { configureI18nModule } from './i18n'; +import { configureIconPickerModule } from './icon-picker'; import { configureImportClipperModule } from './import-clipper'; import { configureImportTemplateModule } from './import-template'; import { configureIntegrationModule } from './integration'; @@ -132,4 +133,5 @@ export function configureCommonModules(framework: Framework) { configureCommentModule(framework); configureDocSummaryModule(framework); configurePaywallModule(framework); + configureIconPickerModule(framework); }