mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-04 00:28:33 +00:00
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:
@@ -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:*",
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './icon-picker-service/index.js';
|
||||
@@ -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');
|
||||
@@ -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';
|
||||
|
||||
@@ -175,6 +175,7 @@ const usePreviewExtensions = () => {
|
||||
.ai(enableAI, framework)
|
||||
.theme(framework)
|
||||
.database(framework)
|
||||
.iconPicker(framework)
|
||||
.linkedDoc(framework)
|
||||
.paragraph(enableAI)
|
||||
.linkPreview(framework)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,7 @@ export const useAISpecs = () => {
|
||||
.mobile(framework)
|
||||
.electron(framework)
|
||||
.linkPreview(framework)
|
||||
.iconPicker(framework)
|
||||
.codeBlockPreview(framework).value;
|
||||
|
||||
return manager.get('page');
|
||||
|
||||
9
packages/frontend/core/src/modules/icon-picker/index.ts
Normal file
9
packages/frontend/core/src/modules/icon-picker/index.ts
Normal 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);
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user