feat(editor): add callout block (#10563)

- Add `CalloutBlockModel `
- Implement `CalloutBlockComponent `
- Integrate with slash menu (/)
This commit is contained in:
Flrande
2025-03-05 09:28:51 +00:00
parent 1c2a6eac85
commit bd62634a76
35 changed files with 519 additions and 14 deletions

View File

@@ -0,0 +1,45 @@
{
"name": "@blocksuite/affine-block-callout",
"description": "Callout block for BlockSuite.",
"type": "module",
"scripts": {
"build": "tsc",
"test:unit": "nx vite:test --run --passWithNoTests",
"test:unit:coverage": "nx vite:test --run --coverage",
"test:e2e": "playwright test"
},
"sideEffects": false,
"keywords": [],
"author": "toeverything",
"license": "MIT",
"dependencies": {
"@blocksuite/affine-components": "workspace:*",
"@blocksuite/affine-model": "workspace:*",
"@blocksuite/affine-shared": "workspace:*",
"@blocksuite/block-std": "workspace:*",
"@blocksuite/global": "workspace:*",
"@blocksuite/inline": "workspace:*",
"@blocksuite/store": "workspace:*",
"@emoji-mart/data": "^1.2.1",
"@floating-ui/dom": "^1.6.10",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.12",
"@types/mdast": "^4.0.4",
"emoji-mart": "^5.6.0",
"lit": "^3.2.0",
"minimatch": "^10.0.1",
"zod": "^3.23.8"
},
"exports": {
".": "./src/index.ts",
"./effects": "./src/effects.ts"
},
"files": [
"src",
"dist",
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.20.0"
}

View File

@@ -0,0 +1,121 @@
import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption';
import { createLitPortal } from '@blocksuite/affine-components/portal';
import { DefaultInlineManagerExtension } from '@blocksuite/affine-components/rich-text';
import { type CalloutBlockModel } from '@blocksuite/affine-model';
import { NOTE_SELECTOR } from '@blocksuite/affine-shared/consts';
import {
DocModeProvider,
ThemeProvider,
} from '@blocksuite/affine-shared/services';
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import type { BlockComponent } from '@blocksuite/block-std';
import { flip, offset } from '@floating-ui/dom';
import { css, html } from 'lit';
import { query } from 'lit/decorators.js';
export class CalloutBlockComponent extends CaptionedBlockComponent<CalloutBlockModel> {
static override styles = css`
:host {
display: block;
margin: 8px 0;
}
.affine-callout-block-container {
display: flex;
padding: 12px 16px;
border-radius: 8px;
background-color: ${unsafeCSSVarV2('block/callout/background/grey')};
}
.affine-callout-emoji-container {
margin-right: 12px;
margin-top: 10px;
user-select: none;
font-size: 1.2em;
}
.affine-callout-emoji:hover {
cursor: pointer;
opacity: 0.7;
}
.affine-callout-children {
flex: 1;
min-width: 0;
}
`;
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: any) => {
this.model.emoji = data.native;
console.log(data);
}}
></affine-emoji-menu>`,
portalStyles: {
zIndex: 'var(--affine-z-index-popover)',
},
container: this.host,
computePosition: {
referenceElement: this._emojiButton,
placement: 'bottom-start',
middleware: [flip(), offset(4)],
autoUpdate: { animationFrame: true },
},
abortController: this._emojiMenuAbortController,
closeOnClickAway: true,
});
};
get attributeRenderer() {
return this.inlineManager.getRenderer();
}
get attributesSchema() {
return this.inlineManager.getSchema();
}
get embedChecker() {
return this.inlineManager.embedChecker;
}
get inlineManager() {
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>(NOTE_SELECTOR);
}
return this.rootComponent;
}
override renderBlock() {
return html`
<div class="affine-callout-block-container">
<div
@click=${this._toggleEmojiMenu}
contenteditable="false"
class="affine-callout-emoji-container"
>
<span class="affine-callout-emoji">${this.model.emoji}</span>
</div>
<div class="affine-callout-children">
${this.renderChildren(this.model)}
</div>
</div>
`;
}
}

View File

@@ -0,0 +1,8 @@
import { BlockViewExtension, FlavourExtension } from '@blocksuite/block-std';
import type { ExtensionType } from '@blocksuite/store';
import { literal } from 'lit/static-html.js';
export const CalloutBlockSpec: ExtensionType[] = [
FlavourExtension('affine:callout'),
BlockViewExtension('affine:callout', literal`affine-callout`),
];

View File

@@ -0,0 +1,14 @@
import { CalloutBlockComponent } from './callout-block';
import { EmojiMenu } from './emoji-menu';
export function effects() {
customElements.define('affine-callout', CalloutBlockComponent);
customElements.define('affine-emoji-menu', EmojiMenu);
}
declare global {
interface HTMLElementTagNameMap {
'affine-callout': CalloutBlockComponent;
'affine-emoji-menu': EmojiMenu;
}
}

View File

@@ -0,0 +1,34 @@
import { WithDisposable } from '@blocksuite/global/utils';
import data from '@emoji-mart/data';
import { Picker } from 'emoji-mart';
import { html, LitElement, type PropertyValues } from 'lit';
import { property, query } from 'lit/decorators.js';
export class EmojiMenu extends WithDisposable(LitElement) {
override firstUpdated(props: PropertyValues) {
const result = super.firstUpdated(props);
const picker = new Picker({
data,
onEmojiSelect: this.onEmojiSelect,
autoFocus: true,
theme: this.theme,
});
this.emojiMenu.append(picker as unknown as Node);
return result;
}
@property({ attribute: false })
accessor onEmojiSelect: (data: any) => void = () => {};
@property({ attribute: false })
accessor theme: 'light' | 'dark' = 'light';
@query('.affine-emoji-menu')
accessor emojiMenu!: HTMLElement;
override render() {
return html`<div class="affine-emoji-menu"></div>`;
}
}

View File

@@ -0,0 +1,3 @@
export * from './callout-block.js';
export * from './callout-spec.js';
export * from './effects.js';

View File

@@ -0,0 +1,18 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": ["./src"],
"references": [
{ "path": "../components" },
{ "path": "../model" },
{ "path": "../shared" },
{ "path": "../../framework/block-std" },
{ "path": "../../framework/global" },
{ "path": "../../framework/inline" },
{ "path": "../../framework/store" }
]
}

View File

@@ -5,6 +5,7 @@ import {
textKeymap,
} from '@blocksuite/affine-components/rich-text';
import {
CalloutBlockModel,
ParagraphBlockModel,
ParagraphBlockSchema,
} from '@blocksuite/affine-model';
@@ -40,7 +41,12 @@ export const ParagraphKeymapExtension = KeymapExtension(
const { store } = std;
const model = store.getBlock(text.from.blockId)?.model;
if (!model || !matchModels(model, [ParagraphBlockModel])) return;
if (
!model ||
!matchModels(model, [ParagraphBlockModel]) ||
matchModels(model.parent, [CalloutBlockModel])
)
return;
const event = ctx.get('defaultState').event;
event.preventDefault();
@@ -71,7 +77,12 @@ export const ParagraphKeymapExtension = KeymapExtension(
const text = std.selection.find(TextSelection);
if (!text) return;
const model = store.getBlock(text.from.blockId)?.model;
if (!model || !matchModels(model, [ParagraphBlockModel])) return;
if (
!model ||
!matchModels(model, [ParagraphBlockModel]) ||
matchModels(model.parent, [CalloutBlockModel])
)
return;
const inlineEditor = getInlineEditorByModel(
std.host,
text.from.blockId
@@ -98,16 +109,21 @@ export const ParagraphKeymapExtension = KeymapExtension(
const text = std.selection.find(TextSelection);
if (!text) return;
const model = store.getBlock(text.from.blockId)?.model;
if (!model || !matchModels(model, [ParagraphBlockModel])) return;
if (
!model ||
!matchModels(model, [ParagraphBlockModel]) ||
matchModels(model.parent, [CalloutBlockModel])
)
return;
const inlineEditor = getInlineEditorByModel(
std.host,
text.from.blockId
);
const range = inlineEditor?.getInlineRange();
if (!range || !inlineEditor) return;
const inlineRange = inlineEditor?.getInlineRange();
if (!inlineRange || !inlineEditor) return;
const raw = ctx.get('keyboardState').raw;
const isEnd = model.text.length === range.index;
const isEnd = model.text.length === inlineRange.index;
if (model.type === 'quote') {
const textStr = model.text.toString();
@@ -129,7 +145,7 @@ export const ParagraphKeymapExtension = KeymapExtension(
if (isEnd && endWithTwoBlankLines) {
raw.preventDefault();
store.captureSync();
model.text.delete(range.index - 1, 1);
model.text.delete(inlineRange.index - 1, 1);
std.command.chain().pipe(addParagraphCommand).run();
return true;
}
@@ -149,7 +165,7 @@ export const ParagraphKeymapExtension = KeymapExtension(
if (index === -1) return true;
const collapsedSiblings = calculateCollapsedSiblings(model);
const rightText = model.text.split(range.index);
const rightText = model.text.split(inlineRange.index);
const newId = store.addBlock(
model.flavour,
{ type: model.type, text: rightText },

View File

@@ -1,6 +1,7 @@
import {
AttachmentBlockModel,
BookmarkBlockModel,
CalloutBlockModel,
CodeBlockModel,
DatabaseBlockModel,
DividerBlockModel,
@@ -25,7 +26,12 @@ export function forwardDelete(std: BlockStdScope) {
if (!text) return;
const isCollapsed = text.isCollapsed();
const model = store.getBlock(text.from.blockId)?.model;
if (!model || !matchModels(model, [ParagraphBlockModel])) return;
if (
!model ||
!matchModels(model, [ParagraphBlockModel]) ||
matchModels(model.parent, [CalloutBlockModel])
)
return;
const isEnd = isCollapsed && text.from.index === model.text.length;
if (!isEnd) return;
const parent = store.getParent(model);

View File

@@ -9,6 +9,7 @@ import {
} from '@blocksuite/affine-block-embed';
import { insertImagesCommand } from '@blocksuite/affine-block-image';
import { insertLatexBlockCommand } from '@blocksuite/affine-block-latex';
import { focusBlockEnd } from '@blocksuite/affine-block-note';
import { getSurfaceBlock } from '@blocksuite/affine-block-surface';
import { insertSurfaceRefBlockCommand } from '@blocksuite/affine-block-surface-ref';
import { insertTableBlockCommand } from '@blocksuite/affine-block-table';
@@ -61,6 +62,7 @@ import { assertType } from '@blocksuite/global/utils';
import {
DualLinkIcon,
ExportToPdfIcon,
FontIcon,
FrameIcon,
GroupingIcon,
ImageIcon,
@@ -171,6 +173,37 @@ export const defaultSlashMenuConfig: SlashMenuConfig = {
!insideEdgelessText(model),
})),
{
name: 'Callout',
description: 'Let your words stand out.',
icon: FontIcon(),
alias: ['callout'],
showWhen: ({ model }) => {
return model.doc.get(FeatureFlagService).getFlag('enable_callout');
},
action: ({ model, rootComponent }) => {
const { doc } = model;
const parent = doc.getParent(model);
if (!parent) return;
const index = parent.children.indexOf(model);
if (index === -1) return;
const calloutId = doc.addBlock('affine:callout', {}, parent, index + 1);
if (!calloutId) return;
const paragraphId = doc.addBlock('affine:paragraph', {}, calloutId);
if (!paragraphId) return;
rootComponent.updateComplete
.then(() => {
const paragraph = rootComponent.std.view.getBlock(paragraphId);
if (!paragraph) return;
rootComponent.std.command.exec(focusBlockEnd, {
focusBlock: paragraph,
});
})
.catch(console.error);
},
},
{
name: 'Inline equation',
description: 'Create a equation block.',

View File

@@ -1,4 +1,8 @@
import { CodeBlockModel, ParagraphBlockModel } from '@blocksuite/affine-model';
import {
CalloutBlockModel,
CodeBlockModel,
ParagraphBlockModel,
} from '@blocksuite/affine-model';
import {
isHorizontalRuleMarkdown,
isMarkdownPrefix,
@@ -37,7 +41,13 @@ export function markdownInput(
const isHeading = isParagraph && model.type.startsWith('h');
const isParagraphQuoteBlock = isParagraph && model.type === 'quote';
const isCodeBlock = matchModels(model, [CodeBlockModel]);
if (isHeading || isParagraphQuoteBlock || isCodeBlock) return;
if (
isHeading ||
isParagraphQuoteBlock ||
isCodeBlock ||
matchModels(model.parent, [CalloutBlockModel])
)
return;
const lineInfo = inline.getLine(range.index);
if (!lineInfo) return;

View File

@@ -0,0 +1,39 @@
import {
BlockModel,
BlockSchemaExtension,
defineBlockSchema,
type Text,
} from '@blocksuite/store';
export const CalloutBlockSchema = defineBlockSchema({
flavour: 'affine:callout',
props: internal => ({
emoji: '😀',
text: internal.Text(),
}),
metadata: {
version: 1,
role: 'hub',
parent: [
'affine:note',
'affine:database',
'affine:paragraph',
'affine:list',
'affine:edgeless-text',
],
children: ['affine:paragraph'],
},
toModel: () => new CalloutBlockModel(),
});
export type CalloutProps = {
emoji: string;
text: Text;
};
export class CalloutBlockModel extends BlockModel<CalloutProps> {
override text!: Text;
}
export const CalloutBlockSchemaExtension =
BlockSchemaExtension(CalloutBlockSchema);

View File

@@ -0,0 +1 @@
export * from './callout-model.js';

View File

@@ -1,5 +1,6 @@
export * from './attachment/index.js';
export * from './bookmark/index.js';
export * from './callout/index.js';
export * from './code/index.js';
export * from './database/index.js';
export * from './divider/index.js';

View File

@@ -88,6 +88,7 @@ export const NoteBlockSchema = defineBlockSchema({
'affine:surface-ref',
'affine:embed-*',
'affine:latex',
'affine:callout',
TableModelFlavour,
],
},

View File

@@ -37,6 +37,7 @@ export const ParagraphBlockSchema = defineBlockSchema({
'affine:paragraph',
'affine:list',
'affine:edgeless-text',
'affine:callout',
],
},
toModel: () => new ParagraphBlockModel(),

View File

@@ -17,6 +17,7 @@ export interface BlockSuiteFlags {
enable_mobile_keyboard_toolbar: boolean;
enable_mobile_linked_doc_menu: boolean;
enable_block_meta: boolean;
enable_callout: boolean;
}
export class FeatureFlagService extends StoreExtension {
@@ -38,6 +39,7 @@ export class FeatureFlagService extends StoreExtension {
enable_mobile_keyboard_toolbar: false,
enable_mobile_linked_doc_menu: false,
enable_block_meta: false,
enable_callout: false,
});
setFlag(key: keyof BlockSuiteFlags, value: boolean) {

View File

@@ -13,6 +13,7 @@
"author": "toeverything",
"license": "MIT",
"dependencies": {
"@blocksuite/affine-block-callout": "workspace:*",
"@blocksuite/affine-block-list": "workspace:*",
"@blocksuite/affine-block-note": "workspace:*",
"@blocksuite/affine-block-paragraph": "workspace:*",

View File

@@ -1,3 +1,4 @@
import { type CalloutBlockComponent } from '@blocksuite/affine-block-callout';
import {
AFFINE_EDGELESS_NOTE,
type EdgelessNoteBlockComponent,
@@ -6,7 +7,7 @@ import { ParagraphBlockComponent } from '@blocksuite/affine-block-paragraph';
import {
DatabaseBlockModel,
ListBlockModel,
type ParagraphBlockModel,
ParagraphBlockModel,
} from '@blocksuite/affine-model';
import { DocModeProvider } from '@blocksuite/affine-shared/services';
import {
@@ -209,6 +210,14 @@ export const getClosestBlockByPoint = (
return null;
}
if (matchModels(closestBlock.model, [ParagraphBlockModel])) {
const callout =
closestBlock.closest<CalloutBlockComponent>('affine-callout');
if (callout) {
return callout;
}
}
return closestBlock;
};

View File

@@ -7,6 +7,7 @@
},
"include": ["./src"],
"references": [
{ "path": "../block-callout" },
{ "path": "../block-list" },
{ "path": "../block-note" },
{ "path": "../block-paragraph" },

View File

@@ -16,6 +16,7 @@
"dependencies": {
"@blocksuite/affine-block-attachment": "workspace:*",
"@blocksuite/affine-block-bookmark": "workspace:*",
"@blocksuite/affine-block-callout": "workspace:*",
"@blocksuite/affine-block-code": "workspace:*",
"@blocksuite/affine-block-data-view": "workspace:*",
"@blocksuite/affine-block-database": "workspace:*",

View File

@@ -1,5 +1,6 @@
import { effects as blockAttachmentEffects } from '@blocksuite/affine-block-attachment/effects';
import { effects as blockBookmarkEffects } from '@blocksuite/affine-block-bookmark/effects';
import { effects as blockCalloutEffects } from '@blocksuite/affine-block-callout/effects';
import { effects as blockCodeEffects } from '@blocksuite/affine-block-code/effects';
import { effects as blockDataViewEffects } from '@blocksuite/affine-block-data-view/effects';
import { effects as blockDatabaseEffects } from '@blocksuite/affine-block-database/effects';
@@ -72,6 +73,7 @@ export function effects() {
blockCodeEffects();
blockTableEffects();
blockRootEffects();
blockCalloutEffects();
componentCaptionEffects();
componentContextMenuEffects();

View File

@@ -1,5 +1,6 @@
import { AttachmentBlockSpec } from '@blocksuite/affine-block-attachment';
import { BookmarkBlockSpec } from '@blocksuite/affine-block-bookmark';
import { CalloutBlockSpec } from '@blocksuite/affine-block-callout';
import { CodeBlockSpec } from '@blocksuite/affine-block-code';
import { DataViewBlockSpec } from '@blocksuite/affine-block-data-view';
import { DatabaseBlockSpec } from '@blocksuite/affine-block-database';
@@ -55,6 +56,7 @@ export const CommonBlockSpecs: ExtensionType[] = [
ParagraphBlockSpec,
DefaultOpenDocExtension,
FontLoaderService,
CalloutBlockSpec,
].flat();
export const PageFirstPartyBlockSpecs: ExtensionType[] = [

View File

@@ -6,6 +6,7 @@ import { TableSelectionExtension } from '@blocksuite/affine-block-table';
import {
AttachmentBlockSchemaExtension,
BookmarkBlockSchemaExtension,
CalloutBlockSchemaExtension,
CodeBlockSchemaExtension,
DatabaseBlockSchemaExtension,
DividerBlockSchemaExtension,
@@ -78,6 +79,7 @@ export const StoreExtensions: ExtensionType[] = [
EdgelessTextBlockSchemaExtension,
LatexBlockSchemaExtension,
TableBlockSchemaExtension,
CalloutBlockSchemaExtension,
BlockSelectionExtension,
TextSelectionExtension,

View File

@@ -4,6 +4,7 @@ import { SurfaceBlockSchema } from '@blocksuite/affine-block-surface';
import {
AttachmentBlockSchema,
BookmarkBlockSchema,
CalloutBlockSchema,
CodeBlockSchema,
DatabaseBlockSchema,
DividerBlockSchema,
@@ -54,4 +55,5 @@ export const AffineSchemas: z.infer<typeof BlockSchema>[] = [
EdgelessTextBlockSchema,
LatexBlockSchema,
TableBlockSchema,
CalloutBlockSchema,
];

View File

@@ -9,6 +9,7 @@
"references": [
{ "path": "../affine/block-attachment" },
{ "path": "../affine/block-bookmark" },
{ "path": "../affine/block-callout" },
{ "path": "../affine/block-code" },
{ "path": "../affine/block-data-view" },
{ "path": "../affine/block-database" },