feat: add icon picker functionality to callout block

- Add IconData type import to callout-model.ts
- Implement icon picker component in callout-block.ts
- Copy renderUniLit function to avoid external dependencies
- Integrate icon picker directly in renderBlock for testing
- Remove unused IconPickerServiceIdentifier import
This commit is contained in:
zzj3720
2025-09-26 23:59:30 +08:00
parent 5147e2c62d
commit 06b84330a9
17 changed files with 297 additions and 78 deletions

View File

@@ -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:*",

View File

@@ -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 = <Props, Expose extends NonNullable<unknown>>(
uni: UniComponent<Props, Expose> | undefined,
props?: Props,
options?: {
ref?: Signal<Expose | undefined>;
style?: Readonly<StyleInfo>;
class?: string;
}
): TemplateResult => {
return html` <uni-lit
.uni="${uni}"
.props="${props}"
.ref="${options?.ref}"
style=${options?.style ? styleMap(options?.style) : ''}
></uni-lit>`;
};
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<string, (props: { style: string }) => TemplateResult>
)[`${icon.name}Icon`]?.({ style: `color:${icon.color}` });
}
return '💡';
};
export class CalloutBlockComponent extends CaptionedBlockComponent<CalloutBlockModel> {
static override styles = css`
:host {
@@ -38,6 +74,12 @@ export class CalloutBlockComponent extends CaptionedBlockComponent<CalloutBlockM
margin-top: 10px;
margin-bottom: 10px;
flex-shrink: 0;
position: relative;
}
.affine-callout-emoji {
display: flex;
align-items: center;
justify-content: center;
}
.affine-callout-emoji:hover {
cursor: pointer;
@@ -49,38 +91,68 @@ export class CalloutBlockComponent extends CaptionedBlockComponent<CalloutBlockM
min-width: 0;
padding-left: 10px;
}
.icon-picker-container {
position: absolute;
top: 100%;
left: 0;
z-index: 1000;
background: white;
border: 1px solid #ccc;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
width: 300px;
height: 400px;
}
`;
private _emojiMenuAbortController: AbortController | null = null;
private readonly _toggleEmojiMenu = () => {
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`<affine-emoji-menu
.theme=${theme}
.onEmojiSelect=${(data: { native: string }) => {
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`
<div
@click=${(e: MouseEvent) => {
e.stopPropagation();
}}
></affine-emoji-menu>`,
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)}
</div>
`;
}
private readonly _handleBlockClick = (event: MouseEvent) => {
// Check if the click target is emoji related element
@@ -123,9 +195,6 @@ export class CalloutBlockComponent extends CaptionedBlockComponent<CalloutBlockM
return this.std.get(DefaultInlineManagerExtension.identifier);
}
@query('.affine-callout-emoji')
private accessor _emojiButton!: HTMLElement;
override get topContenteditableElement() {
if (this.std.get(DocModeProvider).getEditorMode() === 'edgeless') {
return this.closest<BlockComponent>(
@@ -136,7 +205,7 @@ export class CalloutBlockComponent extends CaptionedBlockComponent<CalloutBlockM
}
override renderBlock() {
const emoji = this.model.props.emoji$.value;
const icon = this.model.props.icon$.value;
const background = this.model.props.background$.value;
const themeProvider = this.std.get(ThemeProvider);
@@ -156,14 +225,12 @@ export class CalloutBlockComponent extends CaptionedBlockComponent<CalloutBlockM
})}
>
<div
@click=${this._toggleEmojiMenu}
@click=${this._toggleIconPicker}
contenteditable="false"
class="affine-callout-emoji-container"
style=${styleMap({
display: emoji.length === 0 ? 'none' : undefined,
})}
>
<span class="affine-callout-emoji">${emoji}</span>
<span class="affine-callout-emoji">${getIcon(icon)}</span>
${this._renderIconPicker()}
</div>
<div class="affine-callout-children">
${this.renderChildren(this.model)}

View File

@@ -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;
}
}

View File

@@ -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`<div class="affine-emoji-menu"></div>`;
}
}

View File

@@ -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,

View File

@@ -0,0 +1 @@
export * from './icon-picker-service/index.js';

View File

@@ -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>('IconPickerService');

View File

@@ -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';

View File

@@ -175,6 +175,7 @@ const usePreviewExtensions = () => {
.ai(enableAI, framework)
.theme(framework)
.database(framework)
.iconPicker(framework)
.linkedDoc(framework)
.paragraph(enableAI)
.linkPreview(framework)

View File

@@ -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) {

View File

@@ -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

View File

@@ -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);
});
},
};
}

View File

@@ -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<typeof optionsSchema>;
export class AffineIconPickerExtension extends ViewExtensionProvider<AffineIconPickerViewOptions> {
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));
}
}

View File

@@ -44,6 +44,7 @@ export const useAISpecs = () => {
.mobile(framework)
.electron(framework)
.linkPreview(framework)
.iconPicker(framework)
.codeBlockPreview(framework).value;
return manager.get('page');

View File

@@ -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);
}

View File

@@ -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}`;
}
}

View File

@@ -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);
}