From 8006812bc0b09b6e388c2b455b50b00acf626fc1 Mon Sep 17 00:00:00 2001 From: 3720 Date: Mon, 29 Sep 2025 19:06:14 +0800 Subject: [PATCH] refactor(editor): new icon picker (#13658) ## Summary by CodeRabbit * **New Features** * In-tree icon picker for Callout blocks (emoji, app icons, images) with popup UI and editor-wide extension/service. * Callout toolbar adds background color presets, an icon-picker action, and a destructive Delete action. * **Refactor** * Replaced legacy emoji workflow with icon-based rendering, updated state, styling, and lifecycle for callouts. * **Tests** * Updated callout E2E to reflect new default icon and picker behavior. --------- Co-authored-by: L-Sun --- blocksuite/affine/blocks/callout/package.json | 1 + .../callout/src/callout-block-styles.ts | 56 +++++ .../blocks/callout/src/callout-block.ts | 223 +++++++++++------- .../blocks/callout/src/configs/toolbar.ts | 204 ++++++++++++++++ .../affine/blocks/callout/src/effects.ts | 6 +- .../affine/blocks/callout/src/emoji-menu.ts | 34 --- .../blocks/callout/src/icon-picker-wrapper.ts | 52 ++++ blocksuite/affine/blocks/callout/src/view.ts | 2 + .../model/src/blocks/callout/callout-model.ts | 7 +- .../src/services/icon-picker-service.ts | 1 + .../src/services/icon-picker-service/index.ts | 29 +++ .../affine/shared/src/services/index.ts | 1 + package.json | 2 +- .../bi-directional-link-panel.tsx | 1 + .../block-suite-editor/lit-adaper.tsx | 1 + .../core/src/blocksuite/manager/view.ts | 10 + .../icon-picker/icon-picker-service.ts | 23 ++ .../view-extensions/icon-picker/index.ts | 32 +++ .../components/hooks/affine/use-ai-specs.ts | 1 + .../core/src/modules/icon-picker/index.ts | 9 + .../icon-picker/services/icon-picker.ts | 16 ++ packages/frontend/core/src/modules/index.ts | 2 + .../e2e/blocksuite/callout/callout.spec.ts | 16 +- yarn.lock | 75 +++--- 24 files changed, 630 insertions(+), 174 deletions(-) create mode 100644 blocksuite/affine/blocks/callout/src/callout-block-styles.ts create mode 100644 blocksuite/affine/blocks/callout/src/configs/toolbar.ts delete mode 100644 blocksuite/affine/blocks/callout/src/emoji-menu.ts create mode 100644 blocksuite/affine/blocks/callout/src/icon-picker-wrapper.ts create mode 100644 blocksuite/affine/shared/src/services/icon-picker-service.ts create mode 100644 blocksuite/affine/shared/src/services/icon-picker-service/index.ts create mode 100644 packages/frontend/core/src/blocksuite/view-extensions/icon-picker/icon-picker-service.ts create mode 100644 packages/frontend/core/src/blocksuite/view-extensions/icon-picker/index.ts create mode 100644 packages/frontend/core/src/modules/icon-picker/index.ts create mode 100644 packages/frontend/core/src/modules/icon-picker/services/icon-picker.ts diff --git a/blocksuite/affine/blocks/callout/package.json b/blocksuite/affine/blocks/callout/package.json index b396853fa5..7a74017f01 100644 --- a/blocksuite/affine/blocks/callout/package.json +++ b/blocksuite/affine/blocks/callout/package.json @@ -22,6 +22,7 @@ "@blocksuite/std": "workspace:*", "@blocksuite/store": "workspace:*", "@emoji-mart/data": "^1.2.1", + "@emotion/css": "^11.13.5", "@floating-ui/dom": "^1.6.10", "@lit/context": "^1.1.2", "@preact/signals-core": "^1.8.0", diff --git a/blocksuite/affine/blocks/callout/src/callout-block-styles.ts b/blocksuite/affine/blocks/callout/src/callout-block-styles.ts new file mode 100644 index 0000000000..631ce0e7bb --- /dev/null +++ b/blocksuite/affine/blocks/callout/src/callout-block-styles.ts @@ -0,0 +1,56 @@ +import { css } from '@emotion/css'; + +export const calloutHostStyles = css({ + display: 'block', + margin: '8px 0', +}); + +export const calloutBlockContainerStyles = css({ + display: 'flex', + alignItems: 'flex-start', + padding: '5px 10px', + borderRadius: '8px', +}); + +export const calloutEmojiContainerStyles = css({ + userSelect: 'none', + fontSize: '1.2em', + width: '24px', + height: '24px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + marginTop: '10px', + marginBottom: '10px', + flexShrink: 0, + position: 'relative', +}); + +export const calloutEmojiStyles = css({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + ':hover': { + cursor: 'pointer', + opacity: 0.7, + }, +}); + +export const calloutChildrenStyles = css({ + flex: 1, + minWidth: 0, + paddingLeft: '10px', +}); + +export const iconPickerContainerStyles = css({ + position: 'absolute', + top: '100%', + left: 0, + zIndex: 1000, + background: 'white', + border: '1px solid #ccc', + borderRadius: '8px', + boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)', + width: '390px', + height: '400px', +}); diff --git a/blocksuite/affine/blocks/callout/src/callout-block.ts b/blocksuite/affine/blocks/callout/src/callout-block.ts index f79ee03bc5..7819bbe775 100644 --- a/blocksuite/affine/blocks/callout/src/callout-block.ts +++ b/blocksuite/affine/blocks/callout/src/callout-block.ts @@ -1,88 +1,128 @@ import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption'; -import { createLitPortal } from '@blocksuite/affine-components/portal'; +import { + createPopup, + popupTargetFromElement, +} from '@blocksuite/affine-components/context-menu'; import { DefaultInlineManagerExtension } from '@blocksuite/affine-inline-preset'; import { type CalloutBlockModel } from '@blocksuite/affine-model'; import { focusTextModel } from '@blocksuite/affine-rich-text'; import { EDGELESS_TOP_CONTENTEDITABLE_SELECTOR } from '@blocksuite/affine-shared/consts'; import { DocModeProvider, - ThemeProvider, + type IconData, + IconPickerServiceIdentifier, + IconType, } from '@blocksuite/affine-shared/services'; -import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; +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 { css, html } from 'lit'; -import { query } from 'lit/decorators.js'; -import { styleMap } from 'lit/directives/style-map.js'; +import { type Signal } from '@preact/signals-core'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import type { TemplateResult } from 'lit'; +import { html } from 'lit'; +import { type StyleInfo, styleMap } from 'lit/directives/style-map.js'; + +import { + calloutBlockContainerStyles, + calloutChildrenStyles, + calloutEmojiContainerStyles, + calloutEmojiStyles, + calloutHostStyles, +} from './callout-block-styles.js'; +import { IconPickerWrapper } from './icon-picker-wrapper.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) => { + if (!icon) { + return null; + } + 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 null; +}; export class CalloutBlockComponent extends CaptionedBlockComponent { - static override styles = css` - :host { - display: block; - margin: 8px 0; + private _popupCloseHandler: (() => void) | null = null; + + override connectedCallback() { + super.connectedCallback(); + this.classList.add(calloutHostStyles); + } + + private _closeIconPicker() { + if (this._popupCloseHandler) { + this._popupCloseHandler(); + this._popupCloseHandler = null; + } + } + + private _toggleIconPicker(event: MouseEvent) { + // If popup is already open, close it + if (this._popupCloseHandler) { + this._closeIconPicker(); + return; } - .affine-callout-block-container { - display: flex; - align-items: flex-start; - padding: 5px 10px; - border-radius: 8px; - background-color: ${unsafeCSSVarV2('block/callout/background/grey')}; + // Get IconPickerService from the framework + const iconPickerService = this.std.getOptional(IconPickerServiceIdentifier); + if (!iconPickerService) { + console.warn('IconPickerService not found'); + return; } - .affine-callout-emoji-container { - user-select: none; - font-size: 1.2em; - width: 24px; - height: 24px; - display: flex; - align-items: center; - justify-content: center; - margin-top: 10px; - margin-bottom: 10px; - flex-shrink: 0; - } - .affine-callout-emoji:hover { - cursor: pointer; - opacity: 0.7; - } + // Get the uni-component from the service + const iconPickerComponent = iconPickerService.iconPickerComponent; - .affine-callout-children { - flex: 1; - min-width: 0; - padding-left: 10px; - } - `; - - private _emojiMenuAbortController: AbortController | null = null; - private readonly _toggleEmojiMenu = () => { - if (this._emojiMenuAbortController) { - this._emojiMenuAbortController.abort(); - } - this._emojiMenuAbortController = new AbortController(); - - const theme = this.std.get(ThemeProvider).theme$.value; - - createLitPortal({ - template: html` { - this.model.props.emoji = data.native; - }} - >`, - portalStyles: { - zIndex: 'var(--affine-z-index-popover)', + // Create props for the icon picker + const props = { + onSelect: (iconData?: IconData) => { + this.model.props.icon$.value = iconData; + this._closeIconPicker(); // Close the picker after selection }, - container: this.host, - computePosition: { - referenceElement: this._emojiButton, - placement: 'bottom-start', - middleware: [flip(), offset(4)], - autoUpdate: { animationFrame: true }, + onClose: () => { + this._closeIconPicker(); + }, + }; + + // Create IconPickerWrapper instance + const wrapper = new IconPickerWrapper(); + wrapper.iconPickerComponent = iconPickerComponent; + wrapper.props = props; + wrapper.style.position = 'absolute'; + wrapper.style.backgroundColor = cssVarV2.layer.background.overlayPanel; + wrapper.style.boxShadow = 'var(--affine-menu-shadow)'; + wrapper.style.borderRadius = '8px'; + + // Create popup target from the clicked element + const target = popupTargetFromElement(event.currentTarget as HTMLElement); + + // Create popup + this._popupCloseHandler = createPopup(target, wrapper, { + onClose: () => { + this._popupCloseHandler = null; }, - abortController: this._emojiMenuAbortController, - closeOnClickAway: true, }); - }; + } private readonly _handleBlockClick = (event: MouseEvent) => { // Check if the click target is emoji related element @@ -94,6 +134,13 @@ export class CalloutBlockComponent extends CaptionedBlockComponent 0) { return; @@ -125,9 +172,6 @@ export class CalloutBlockComponent extends CaptionedBlockComponent( @@ -138,23 +182,36 @@ export class CalloutBlockComponent extends CaptionedBlockComponent + )[backgroundColorName ?? '']; + + const iconContent = getIcon(icon); + return html`
-
- ${emoji} -
-
+ ${iconContent + ? html` +
+ ${iconContent} +
+ ` + : ''} +
${this.renderChildren(this.model)}
diff --git a/blocksuite/affine/blocks/callout/src/configs/toolbar.ts b/blocksuite/affine/blocks/callout/src/configs/toolbar.ts new file mode 100644 index 0000000000..59c7236b91 --- /dev/null +++ b/blocksuite/affine/blocks/callout/src/configs/toolbar.ts @@ -0,0 +1,204 @@ +import { + createPopup, + popupTargetFromElement, +} from '@blocksuite/affine-components/context-menu'; +import { EditorChevronDown } from '@blocksuite/affine-components/toolbar'; +import { CalloutBlockModel } from '@blocksuite/affine-model'; +import { + ActionPlacement, + type IconData, + IconPickerServiceIdentifier, + type ToolbarAction, + type ToolbarActionGroup, + type ToolbarModuleConfig, + ToolbarModuleExtension, +} from '@blocksuite/affine-shared/services'; +import { DeleteIcon, PaletteIcon, SmileIcon } from '@blocksuite/icons/lit'; +import { BlockFlavourIdentifier } from '@blocksuite/std'; +import type { ExtensionType } from '@blocksuite/store'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import { html } from 'lit'; +import { repeat } from 'lit/directives/repeat.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { IconPickerWrapper } from '../icon-picker-wrapper.js'; + +const colors = [ + 'default', + 'red', + 'orange', + 'yellow', + 'green', + 'teal', + 'blue', + 'purple', + 'grey', +] as const; + +const backgroundColorAction = { + id: 'background-color', + label: 'Background Color', + tooltip: 'Change background color', + icon: PaletteIcon(), + run() { + // This will be handled by the content function + }, + content(ctx) { + const model = ctx.getCurrentModelByType(CalloutBlockModel); + if (!model) return null; + + const updateBackground = (color: string) => { + ctx.store.updateBlock(model, { backgroundColorName: color }); + }; + + return html` + + ${PaletteIcon()} ${EditorChevronDown} + + `} + > +
+
Background
+ ${repeat(colors, color => { + const isDefault = color === 'default'; + const value = isDefault + ? null + : `var(--affine-text-highlight-${color})`; + const displayName = `${color} Background`; + + return html` + updateBackground(color)} + > + + ${displayName} + + `; + })} +
+
+ `; + }, +} satisfies ToolbarAction; + +const iconPickerAction = { + id: 'icon-picker', + label: 'Icon Picker', + tooltip: 'Change icon', + icon: SmileIcon(), + run() { + // This will be handled by the content function + }, + content(ctx) { + const model = ctx.getCurrentModelByType(CalloutBlockModel); + if (!model) return null; + + const handleIconPickerClick = (event: MouseEvent) => { + // Get IconPickerService from the framework + const iconPickerService = ctx.std.getOptional( + IconPickerServiceIdentifier + ); + if (!iconPickerService) { + console.warn('IconPickerService not found'); + return; + } + + // Get the uni-component from the service + const iconPickerComponent = iconPickerService.iconPickerComponent; + + // Create props for the icon picker + const props = { + onSelect: (iconData?: IconData) => { + // When iconData is undefined (delete icon), set icon to undefined + ctx.store.updateBlock(model, { icon: iconData }); + closeHandler(); // Close the picker after selection + }, + onClose: () => { + closeHandler(); + }, + }; + + // Create IconPickerWrapper instance + const wrapper = new IconPickerWrapper(); + wrapper.iconPickerComponent = iconPickerComponent; + wrapper.props = props; + wrapper.style.position = 'absolute'; + wrapper.style.backgroundColor = cssVarV2.layer.background.overlayPanel; + wrapper.style.boxShadow = 'var(--affine-menu-shadow)'; + wrapper.style.borderRadius = '8px'; + + // Create popup target from the clicked element + const target = popupTargetFromElement(event.currentTarget as HTMLElement); + + // Create popup + const closeHandler = createPopup(target, wrapper, { + onClose: () => { + // Cleanup if needed + }, + }); + }; + + return html` + + ${SmileIcon()} ${EditorChevronDown} + + `; + }, +} satisfies ToolbarAction; + +const builtinToolbarConfig = { + actions: [ + { + id: 'style', + actions: [backgroundColorAction], + } satisfies ToolbarActionGroup, + { + id: 'icon', + actions: [iconPickerAction], + } satisfies ToolbarActionGroup, + { + placement: ActionPlacement.More, + id: 'c.delete', + label: 'Delete', + icon: DeleteIcon(), + variant: 'destructive', + run(ctx) { + const model = ctx.getCurrentModelByType(CalloutBlockModel); + if (!model) return; + + ctx.store.deleteBlock(model); + + // Clears + ctx.select('note'); + ctx.reset(); + }, + } satisfies ToolbarAction, + ], +} as const satisfies ToolbarModuleConfig; + +export const createBuiltinToolbarConfigExtension = ( + flavour: string +): ExtensionType[] => { + return [ + ToolbarModuleExtension({ + id: BlockFlavourIdentifier(flavour), + config: builtinToolbarConfig, + }), + ]; +}; diff --git a/blocksuite/affine/blocks/callout/src/effects.ts b/blocksuite/affine/blocks/callout/src/effects.ts index 7cf7bc7737..923619145c 100644 --- a/blocksuite/affine/blocks/callout/src/effects.ts +++ b/blocksuite/affine/blocks/callout/src/effects.ts @@ -1,14 +1,14 @@ import { CalloutBlockComponent } from './callout-block'; -import { EmojiMenu } from './emoji-menu'; +import { IconPickerWrapper } from './icon-picker-wrapper'; export function effects() { customElements.define('affine-callout', CalloutBlockComponent); - customElements.define('affine-emoji-menu', EmojiMenu); + customElements.define('icon-picker-wrapper', IconPickerWrapper); } declare global { interface HTMLElementTagNameMap { 'affine-callout': CalloutBlockComponent; - 'affine-emoji-menu': EmojiMenu; + 'icon-picker-wrapper': IconPickerWrapper; } } 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/blocks/callout/src/icon-picker-wrapper.ts b/blocksuite/affine/blocks/callout/src/icon-picker-wrapper.ts new file mode 100644 index 0000000000..8132fb12d7 --- /dev/null +++ b/blocksuite/affine/blocks/callout/src/icon-picker-wrapper.ts @@ -0,0 +1,52 @@ +import type { IconData } from '@blocksuite/affine-shared/services'; +import type { UniComponent } from '@blocksuite/affine-shared/types'; +import { ShadowlessElement } from '@blocksuite/std'; +import { type Signal } from '@preact/signals-core'; +import { html, type TemplateResult } from 'lit'; +import { type StyleInfo, styleMap } from 'lit/directives/style-map.js'; + +// Copy of renderUniLit from callout-block.ts +const renderUniLit = >( + uni: UniComponent | undefined, + props?: Props, + options?: { + ref?: Signal; + style?: Readonly; + class?: string; + } +): TemplateResult => { + return html` `; +}; + +export interface IconPickerWrapperProps { + onSelect?: (iconData?: IconData) => void; + onClose?: () => void; +} + +export class IconPickerWrapper extends ShadowlessElement { + iconPickerComponent?: UniComponent; + props?: IconPickerWrapperProps; + + constructor() { + super(); + } + + override render() { + if (!this.iconPickerComponent) { + return html``; + } + + return renderUniLit(this.iconPickerComponent, this.props); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'icon-picker-wrapper': IconPickerWrapper; + } +} diff --git a/blocksuite/affine/blocks/callout/src/view.ts b/blocksuite/affine/blocks/callout/src/view.ts index 981b2235bb..07e47553cf 100644 --- a/blocksuite/affine/blocks/callout/src/view.ts +++ b/blocksuite/affine/blocks/callout/src/view.ts @@ -8,6 +8,7 @@ import { literal } from 'lit/static-html.js'; import { CalloutKeymapExtension } from './callout-keymap'; import { calloutSlashMenuConfig } from './configs/slash-menu'; +import { createBuiltinToolbarConfigExtension } from './configs/toolbar'; import { effects } from './effects'; export class CalloutViewExtension extends ViewExtensionProvider { @@ -25,6 +26,7 @@ export class CalloutViewExtension extends ViewExtensionProvider { BlockViewExtension('affine:callout', literal`affine-callout`), CalloutKeymapExtension, SlashMenuConfigExtension('affine:callout', calloutSlashMenuConfig), + ...createBuiltinToolbarConfigExtension('affine:callout'), ]); } } diff --git a/blocksuite/affine/model/src/blocks/callout/callout-model.ts b/blocksuite/affine/model/src/blocks/callout/callout-model.ts index aa7591e4f0..9ff1e3c401 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, @@ -8,15 +9,17 @@ import { import type { BlockMeta } from '../../utils/types'; export type CalloutProps = { - emoji: string; + icon?: IconData; text: Text; + backgroundColorName?: string; } & BlockMeta; export const CalloutBlockSchema = defineBlockSchema({ flavour: 'affine:callout', props: (internal): CalloutProps => ({ - emoji: '😀', + icon: { type: 'emoji', unicode: '💡' } as IconData, text: internal.Text(), + backgroundColorName: 'grey', 'meta:createdAt': undefined, 'meta:updatedAt': undefined, 'meta:createdBy': 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..ad0c466124 --- /dev/null +++ b/blocksuite/affine/shared/src/services/icon-picker-service/index.ts @@ -0,0 +1,29 @@ +import type { UniComponent } from '@blocksuite/affine-shared/types'; +import { createIdentifier } from '@blocksuite/global/di'; +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 IconPickerService { + iconPickerComponent: UniComponent<{ onSelect?: (data?: IconData) => void }>; +} + +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/package.json b/package.json index 7e7c2601ef..4ac60a1df2 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,7 @@ "husky": "^9.1.7", "lint-staged": "^16.0.0", "msw": "^2.6.8", - "oxlint": "^1.15.0", + "oxlint": "~1.18.0", "prettier": "^3.4.2", "semver": "^7.6.3", "serve": "^14.2.4", 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..e9b3808cb6 --- /dev/null +++ b/packages/frontend/core/src/modules/icon-picker/services/icon-picker.ts @@ -0,0 +1,16 @@ +import { IconPicker, uniReactRoot } from '@affine/component'; +// Import the identifier for internal use +import { type IconPickerService as IIconPickerService } from '@blocksuite/affine-shared/services'; +import { Service } from '@toeverything/infra'; + +// Re-export types from BlockSuite shared services +export type { + IconData, + IconPickerService as IIconPickerService, +} from '@blocksuite/affine-shared/services'; +export { IconPickerServiceIdentifier } from '@blocksuite/affine-shared/services'; + +export class IconPickerService extends Service implements IIconPickerService { + public readonly iconPickerComponent = + uniReactRoot.createUniComponent(IconPicker); +} 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); } diff --git a/tests/affine-local/e2e/blocksuite/callout/callout.spec.ts b/tests/affine-local/e2e/blocksuite/callout/callout.spec.ts index 0b7cbd23d4..6c147cf79f 100644 --- a/tests/affine-local/e2e/blocksuite/callout/callout.spec.ts +++ b/tests/affine-local/e2e/blocksuite/callout/callout.spec.ts @@ -24,9 +24,9 @@ test('add callout block using slash menu and change emoji', async ({ }) => { await type(page, '/callout\naaaa\nbbbb'); const callout = page.locator('affine-callout'); - const emoji = page.locator('affine-callout .affine-callout-emoji'); + const emoji = page.locator('affine-callout').getByTestId('callout-emoji'); await expect(callout).toBeVisible(); - await expect(emoji).toContainText('😀'); + await expect(emoji).toContainText('💡'); const paragraph = page.locator('affine-callout affine-paragraph'); await expect(paragraph).toHaveCount(2); @@ -35,18 +35,6 @@ test('add callout block using slash menu and change emoji', async ({ await expect(vLine).toHaveCount(2); expect(await vLine.nth(0).innerText()).toBe('aaaa'); expect(await vLine.nth(1).innerText()).toBe('bbbb'); - - await emoji.click(); - const emojiMenu = page.locator('affine-emoji-menu'); - await expect(emojiMenu).toBeVisible(); - await page - .locator('div') - .filter({ hasText: /^😀😃😄😁😆😅🤣😂🙂$/ }) - .getByLabel('😆') - .click(); - await page.getByTestId('page-editor-blank').click(); - await expect(emojiMenu).not.toBeVisible(); - await expect(emoji).toContainText('😆'); }); test('press backspace after callout block', async ({ page }) => { diff --git a/yarn.lock b/yarn.lock index 54741484df..d6d9f69f09 100644 --- a/yarn.lock +++ b/yarn.lock @@ -802,7 +802,7 @@ __metadata: husky: "npm:^9.1.7" lint-staged: "npm:^16.0.0" msw: "npm:^2.6.8" - oxlint: "npm:^1.15.0" + oxlint: "npm:~1.18.0" prettier: "npm:^3.4.2" semver: "npm:^7.6.3" serve: "npm:^14.2.4" @@ -2509,6 +2509,7 @@ __metadata: "@blocksuite/std": "workspace:*" "@blocksuite/store": "workspace:*" "@emoji-mart/data": "npm:^1.2.1" + "@emotion/css": "npm:^11.13.5" "@floating-ui/dom": "npm:^1.6.10" "@lit/context": "npm:^1.1.2" "@preact/signals-core": "npm:^1.8.0" @@ -10814,58 +10815,58 @@ __metadata: languageName: node linkType: hard -"@oxlint/darwin-arm64@npm:1.15.0": - version: 1.15.0 - resolution: "@oxlint/darwin-arm64@npm:1.15.0" +"@oxlint/darwin-arm64@npm:1.18.0": + version: 1.18.0 + resolution: "@oxlint/darwin-arm64@npm:1.18.0" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@oxlint/darwin-x64@npm:1.15.0": - version: 1.15.0 - resolution: "@oxlint/darwin-x64@npm:1.15.0" +"@oxlint/darwin-x64@npm:1.18.0": + version: 1.18.0 + resolution: "@oxlint/darwin-x64@npm:1.18.0" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@oxlint/linux-arm64-gnu@npm:1.15.0": - version: 1.15.0 - resolution: "@oxlint/linux-arm64-gnu@npm:1.15.0" +"@oxlint/linux-arm64-gnu@npm:1.18.0": + version: 1.18.0 + resolution: "@oxlint/linux-arm64-gnu@npm:1.18.0" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@oxlint/linux-arm64-musl@npm:1.15.0": - version: 1.15.0 - resolution: "@oxlint/linux-arm64-musl@npm:1.15.0" +"@oxlint/linux-arm64-musl@npm:1.18.0": + version: 1.18.0 + resolution: "@oxlint/linux-arm64-musl@npm:1.18.0" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@oxlint/linux-x64-gnu@npm:1.15.0": - version: 1.15.0 - resolution: "@oxlint/linux-x64-gnu@npm:1.15.0" +"@oxlint/linux-x64-gnu@npm:1.18.0": + version: 1.18.0 + resolution: "@oxlint/linux-x64-gnu@npm:1.18.0" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@oxlint/linux-x64-musl@npm:1.15.0": - version: 1.15.0 - resolution: "@oxlint/linux-x64-musl@npm:1.15.0" +"@oxlint/linux-x64-musl@npm:1.18.0": + version: 1.18.0 + resolution: "@oxlint/linux-x64-musl@npm:1.18.0" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@oxlint/win32-arm64@npm:1.15.0": - version: 1.15.0 - resolution: "@oxlint/win32-arm64@npm:1.15.0" +"@oxlint/win32-arm64@npm:1.18.0": + version: 1.18.0 + resolution: "@oxlint/win32-arm64@npm:1.18.0" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@oxlint/win32-x64@npm:1.15.0": - version: 1.15.0 - resolution: "@oxlint/win32-x64@npm:1.15.0" +"@oxlint/win32-x64@npm:1.18.0": + version: 1.18.0 + resolution: "@oxlint/win32-x64@npm:1.18.0" conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -28835,18 +28836,18 @@ __metadata: languageName: node linkType: hard -"oxlint@npm:^1.15.0": - version: 1.15.0 - resolution: "oxlint@npm:1.15.0" +"oxlint@npm:~1.18.0": + version: 1.18.0 + resolution: "oxlint@npm:1.18.0" dependencies: - "@oxlint/darwin-arm64": "npm:1.15.0" - "@oxlint/darwin-x64": "npm:1.15.0" - "@oxlint/linux-arm64-gnu": "npm:1.15.0" - "@oxlint/linux-arm64-musl": "npm:1.15.0" - "@oxlint/linux-x64-gnu": "npm:1.15.0" - "@oxlint/linux-x64-musl": "npm:1.15.0" - "@oxlint/win32-arm64": "npm:1.15.0" - "@oxlint/win32-x64": "npm:1.15.0" + "@oxlint/darwin-arm64": "npm:1.18.0" + "@oxlint/darwin-x64": "npm:1.18.0" + "@oxlint/linux-arm64-gnu": "npm:1.18.0" + "@oxlint/linux-arm64-musl": "npm:1.18.0" + "@oxlint/linux-x64-gnu": "npm:1.18.0" + "@oxlint/linux-x64-musl": "npm:1.18.0" + "@oxlint/win32-arm64": "npm:1.18.0" + "@oxlint/win32-x64": "npm:1.18.0" peerDependencies: oxlint-tsgolint: ">=0.2.0" dependenciesMeta: @@ -28872,7 +28873,7 @@ __metadata: bin: oxc_language_server: bin/oxc_language_server oxlint: bin/oxlint - checksum: 10/1ee632ad359b3e63a3a5fccadfcab23ac4b0881b06f2e6c29431db56377858571592005459f247b2eef822d1da4c9d68afdf23965afe6ec6a5fe092f60239fa8 + checksum: 10/79118edfc90df62011b467a273819f00977208e6aed3a98784e15707fb87612ddf609573fd8f438a75986eaf56768bc0de3f1f2b863c2572c191a0944aa6d619 languageName: node linkType: hard