refactor(editor): new icon picker (#13658)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: L-Sun <zover.v@gmail.com>
This commit is contained in:
3720
2025-09-29 19:06:14 +08:00
committed by GitHub
parent 8df7353722
commit 8006812bc0
24 changed files with 630 additions and 174 deletions
@@ -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",
@@ -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',
});
@@ -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 = <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) => {
if (!icon) {
return null;
}
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 null;
};
export class CalloutBlockComponent extends CaptionedBlockComponent<CalloutBlockModel> {
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`<affine-emoji-menu
.theme=${theme}
.onEmojiSelect=${(data: { native: string }) => {
this.model.props.emoji = data.native;
}}
></affine-emoji-menu>`,
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<CalloutBlockM
return;
}
// If there's no icon, open icon picker on click
const icon = this.model.props.icon$.value;
if (!icon) {
this._toggleIconPicker(event);
return;
}
// Only handle clicks when there are no children
if (this.model.children.length > 0) {
return;
@@ -125,9 +172,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>(
@@ -138,23 +182,36 @@ export class CalloutBlockComponent extends CaptionedBlockComponent<CalloutBlockM
}
override renderBlock() {
const emoji = this.model.props.emoji$.value;
const icon = this.model.props.icon$.value;
const backgroundColorName = this.model.props.backgroundColorName$.value;
const backgroundColor = (
cssVarV2.block.callout.background as Record<string, string>
)[backgroundColorName ?? ''];
const iconContent = getIcon(icon);
return html`
<div
class="affine-callout-block-container"
class="${calloutBlockContainerStyles}"
@click=${this._handleBlockClick}
style=${styleMap({
backgroundColor: backgroundColor ?? 'transparent',
})}
>
<div
@click=${this._toggleEmojiMenu}
contenteditable="false"
class="affine-callout-emoji-container"
style=${styleMap({
display: emoji.length === 0 ? 'none' : undefined,
})}
>
<span class="affine-callout-emoji">${emoji}</span>
</div>
<div class="affine-callout-children">
${iconContent
? html`
<div
@click=${this._toggleIconPicker}
contenteditable="false"
class="${calloutEmojiContainerStyles}"
>
<span class="${calloutEmojiStyles}" data-testid="callout-emoji"
>${iconContent}</span
>
</div>
`
: ''}
<div class="${calloutChildrenStyles}">
${this.renderChildren(this.model)}
</div>
</div>
@@ -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`
<editor-menu-button
.contentPadding=${'8px'}
.button=${html`
<editor-icon-button
aria-label="background"
.tooltip=${'Background Color'}
>
${PaletteIcon()} ${EditorChevronDown}
</editor-icon-button>
`}
>
<div data-size="large" data-orientation="vertical">
<div class="highlight-heading">Background</div>
${repeat(colors, color => {
const isDefault = color === 'default';
const value = isDefault
? null
: `var(--affine-text-highlight-${color})`;
const displayName = `${color} Background`;
return html`
<editor-menu-action
data-testid="background-${color}"
@click=${() => updateBackground(color)}
>
<affine-text-duotone-icon
style=${styleMap({
'--color': 'var(--affine-text-primary-color)',
'--background': value ?? 'transparent',
})}
></affine-text-duotone-icon>
<span class="label capitalize">${displayName}</span>
</editor-menu-action>
`;
})}
</div>
</editor-menu-button>
`;
},
} 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`
<editor-icon-button
aria-label="icon-picker"
.tooltip=${'Change Icon'}
@click=${handleIconPickerClick}
>
${SmileIcon()} ${EditorChevronDown}
</editor-icon-button>
`;
},
} satisfies ToolbarAction;
const builtinToolbarConfig = {
actions: [
{
id: 'style',
actions: [backgroundColorAction],
} satisfies ToolbarActionGroup<ToolbarAction>,
{
id: 'icon',
actions: [iconPickerAction],
} satisfies ToolbarActionGroup<ToolbarAction>,
{
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,
}),
];
};
@@ -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;
}
}
@@ -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>`;
}
}
@@ -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 = <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>`;
};
export interface IconPickerWrapperProps {
onSelect?: (iconData?: IconData) => void;
onClose?: () => void;
}
export class IconPickerWrapper extends ShadowlessElement {
iconPickerComponent?: UniComponent<IconPickerWrapperProps, any>;
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;
}
}
@@ -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'),
]);
}
}
@@ -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,
@@ -0,0 +1 @@
export * from './icon-picker-service/index.js';
@@ -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>('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';
+1 -1
View File
@@ -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",
@@ -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');
@@ -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,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);
}
@@ -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);
}
@@ -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 }) => {
+38 -37
View File
@@ -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