mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-17 18:16:15 +08:00
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:
@@ -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
@@ -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
|
||||
|
||||
+23
@@ -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 }) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user