From bd62634a76d37850886ccbe028dfa6e871cfb412 Mon Sep 17 00:00:00 2001 From: Flrande <1978616327@qq.com> Date: Wed, 5 Mar 2025 09:28:51 +0000 Subject: [PATCH] feat(editor): add callout block (#10563) - Add `CalloutBlockModel ` - Implement `CalloutBlockComponent ` - Integrate with slash menu (/) --- blocksuite/affine/block-callout/package.json | 45 +++++++ .../affine/block-callout/src/callout-block.ts | 121 ++++++++++++++++++ .../affine/block-callout/src/callout-spec.ts | 8 ++ .../affine/block-callout/src/effects.ts | 14 ++ .../affine/block-callout/src/emoji-menu.ts | 34 +++++ blocksuite/affine/block-callout/src/index.ts | 3 + blocksuite/affine/block-callout/tsconfig.json | 18 +++ .../block-paragraph/src/paragraph-keymap.ts | 32 +++-- .../src/utils/forward-delete.ts | 8 +- .../src/widgets/slash-menu/config.ts | 33 +++++ .../src/rich-text/markdown/markdown-input.ts | 14 +- .../model/src/blocks/callout/callout-model.ts | 39 ++++++ .../affine/model/src/blocks/callout/index.ts | 1 + blocksuite/affine/model/src/blocks/index.ts | 1 + .../model/src/blocks/note/note-model.ts | 1 + .../src/blocks/paragraph/paragraph-model.ts | 1 + .../src/services/feature-flag-service.ts | 2 + .../affine/widget-drag-handle/package.json | 1 + .../affine/widget-drag-handle/src/utils.ts | 11 +- .../affine/widget-drag-handle/tsconfig.json | 1 + blocksuite/blocks/package.json | 1 + blocksuite/blocks/src/effects.ts | 2 + blocksuite/blocks/src/extensions/common.ts | 2 + blocksuite/blocks/src/extensions/store.ts | 2 + blocksuite/blocks/src/schemas.ts | 2 + blocksuite/blocks/tsconfig.json | 1 + .../use-block-suite-page-preview.spec.ts | 5 + .../core/src/modules/feature-flag/constant.ts | 10 ++ .../journal/__tests__/suggest-date.spec.ts | 8 +- packages/frontend/i18n/src/i18n.gen.ts | 8 ++ packages/frontend/i18n/src/resources/en.json | 2 + .../e2e/blocksuite/callout/callout.spec.ts | 43 +++++++ tools/utils/src/workspace.gen.ts | 16 +++ tsconfig.json | 1 + yarn.lock | 42 +++++- 35 files changed, 519 insertions(+), 14 deletions(-) create mode 100644 blocksuite/affine/block-callout/package.json create mode 100644 blocksuite/affine/block-callout/src/callout-block.ts create mode 100644 blocksuite/affine/block-callout/src/callout-spec.ts create mode 100644 blocksuite/affine/block-callout/src/effects.ts create mode 100644 blocksuite/affine/block-callout/src/emoji-menu.ts create mode 100644 blocksuite/affine/block-callout/src/index.ts create mode 100644 blocksuite/affine/block-callout/tsconfig.json create mode 100644 blocksuite/affine/model/src/blocks/callout/callout-model.ts create mode 100644 blocksuite/affine/model/src/blocks/callout/index.ts create mode 100644 tests/affine-local/e2e/blocksuite/callout/callout.spec.ts diff --git a/blocksuite/affine/block-callout/package.json b/blocksuite/affine/block-callout/package.json new file mode 100644 index 000000000..33f26854a --- /dev/null +++ b/blocksuite/affine/block-callout/package.json @@ -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" +} diff --git a/blocksuite/affine/block-callout/src/callout-block.ts b/blocksuite/affine/block-callout/src/callout-block.ts new file mode 100644 index 000000000..6f70edd2d --- /dev/null +++ b/blocksuite/affine/block-callout/src/callout-block.ts @@ -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 { + 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` { + this.model.emoji = data.native; + console.log(data); + }} + >`, + 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(NOTE_SELECTOR); + } + return this.rootComponent; + } + + override renderBlock() { + return html` +
+
+ ${this.model.emoji} +
+
+ ${this.renderChildren(this.model)} +
+
+ `; + } +} diff --git a/blocksuite/affine/block-callout/src/callout-spec.ts b/blocksuite/affine/block-callout/src/callout-spec.ts new file mode 100644 index 000000000..8f9cbe9d6 --- /dev/null +++ b/blocksuite/affine/block-callout/src/callout-spec.ts @@ -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`), +]; diff --git a/blocksuite/affine/block-callout/src/effects.ts b/blocksuite/affine/block-callout/src/effects.ts new file mode 100644 index 000000000..7cf7bc773 --- /dev/null +++ b/blocksuite/affine/block-callout/src/effects.ts @@ -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; + } +} diff --git a/blocksuite/affine/block-callout/src/emoji-menu.ts b/blocksuite/affine/block-callout/src/emoji-menu.ts new file mode 100644 index 000000000..c9cf0c33d --- /dev/null +++ b/blocksuite/affine/block-callout/src/emoji-menu.ts @@ -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`
`; + } +} diff --git a/blocksuite/affine/block-callout/src/index.ts b/blocksuite/affine/block-callout/src/index.ts new file mode 100644 index 000000000..96b03184d --- /dev/null +++ b/blocksuite/affine/block-callout/src/index.ts @@ -0,0 +1,3 @@ +export * from './callout-block.js'; +export * from './callout-spec.js'; +export * from './effects.js'; diff --git a/blocksuite/affine/block-callout/tsconfig.json b/blocksuite/affine/block-callout/tsconfig.json new file mode 100644 index 000000000..230fca270 --- /dev/null +++ b/blocksuite/affine/block-callout/tsconfig.json @@ -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" } + ] +} diff --git a/blocksuite/affine/block-paragraph/src/paragraph-keymap.ts b/blocksuite/affine/block-paragraph/src/paragraph-keymap.ts index 423c37baa..33762386b 100644 --- a/blocksuite/affine/block-paragraph/src/paragraph-keymap.ts +++ b/blocksuite/affine/block-paragraph/src/paragraph-keymap.ts @@ -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 }, diff --git a/blocksuite/affine/block-paragraph/src/utils/forward-delete.ts b/blocksuite/affine/block-paragraph/src/utils/forward-delete.ts index ff136fe52..71d547eb8 100644 --- a/blocksuite/affine/block-paragraph/src/utils/forward-delete.ts +++ b/blocksuite/affine/block-paragraph/src/utils/forward-delete.ts @@ -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); diff --git a/blocksuite/affine/block-root/src/widgets/slash-menu/config.ts b/blocksuite/affine/block-root/src/widgets/slash-menu/config.ts index 095e512f2..f5487e167 100644 --- a/blocksuite/affine/block-root/src/widgets/slash-menu/config.ts +++ b/blocksuite/affine/block-root/src/widgets/slash-menu/config.ts @@ -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.', diff --git a/blocksuite/affine/components/src/rich-text/markdown/markdown-input.ts b/blocksuite/affine/components/src/rich-text/markdown/markdown-input.ts index 50a81b51c..a50998c18 100644 --- a/blocksuite/affine/components/src/rich-text/markdown/markdown-input.ts +++ b/blocksuite/affine/components/src/rich-text/markdown/markdown-input.ts @@ -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; diff --git a/blocksuite/affine/model/src/blocks/callout/callout-model.ts b/blocksuite/affine/model/src/blocks/callout/callout-model.ts new file mode 100644 index 000000000..3ba2f013f --- /dev/null +++ b/blocksuite/affine/model/src/blocks/callout/callout-model.ts @@ -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 { + override text!: Text; +} + +export const CalloutBlockSchemaExtension = + BlockSchemaExtension(CalloutBlockSchema); diff --git a/blocksuite/affine/model/src/blocks/callout/index.ts b/blocksuite/affine/model/src/blocks/callout/index.ts new file mode 100644 index 000000000..16e111769 --- /dev/null +++ b/blocksuite/affine/model/src/blocks/callout/index.ts @@ -0,0 +1 @@ +export * from './callout-model.js'; diff --git a/blocksuite/affine/model/src/blocks/index.ts b/blocksuite/affine/model/src/blocks/index.ts index 6e06e5ec3..0bc0e23af 100644 --- a/blocksuite/affine/model/src/blocks/index.ts +++ b/blocksuite/affine/model/src/blocks/index.ts @@ -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'; diff --git a/blocksuite/affine/model/src/blocks/note/note-model.ts b/blocksuite/affine/model/src/blocks/note/note-model.ts index 65ca43cca..08f6cd8f6 100644 --- a/blocksuite/affine/model/src/blocks/note/note-model.ts +++ b/blocksuite/affine/model/src/blocks/note/note-model.ts @@ -88,6 +88,7 @@ export const NoteBlockSchema = defineBlockSchema({ 'affine:surface-ref', 'affine:embed-*', 'affine:latex', + 'affine:callout', TableModelFlavour, ], }, diff --git a/blocksuite/affine/model/src/blocks/paragraph/paragraph-model.ts b/blocksuite/affine/model/src/blocks/paragraph/paragraph-model.ts index 40f94522c..3d88b4579 100644 --- a/blocksuite/affine/model/src/blocks/paragraph/paragraph-model.ts +++ b/blocksuite/affine/model/src/blocks/paragraph/paragraph-model.ts @@ -37,6 +37,7 @@ export const ParagraphBlockSchema = defineBlockSchema({ 'affine:paragraph', 'affine:list', 'affine:edgeless-text', + 'affine:callout', ], }, toModel: () => new ParagraphBlockModel(), diff --git a/blocksuite/affine/shared/src/services/feature-flag-service.ts b/blocksuite/affine/shared/src/services/feature-flag-service.ts index 75abd0ddb..303646a50 100644 --- a/blocksuite/affine/shared/src/services/feature-flag-service.ts +++ b/blocksuite/affine/shared/src/services/feature-flag-service.ts @@ -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) { diff --git a/blocksuite/affine/widget-drag-handle/package.json b/blocksuite/affine/widget-drag-handle/package.json index afd8fa43e..f93d60b62 100644 --- a/blocksuite/affine/widget-drag-handle/package.json +++ b/blocksuite/affine/widget-drag-handle/package.json @@ -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:*", diff --git a/blocksuite/affine/widget-drag-handle/src/utils.ts b/blocksuite/affine/widget-drag-handle/src/utils.ts index 0e76fe04a..dfcb2270e 100644 --- a/blocksuite/affine/widget-drag-handle/src/utils.ts +++ b/blocksuite/affine/widget-drag-handle/src/utils.ts @@ -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('affine-callout'); + if (callout) { + return callout; + } + } + return closestBlock; }; diff --git a/blocksuite/affine/widget-drag-handle/tsconfig.json b/blocksuite/affine/widget-drag-handle/tsconfig.json index e0e3344cf..d437df056 100644 --- a/blocksuite/affine/widget-drag-handle/tsconfig.json +++ b/blocksuite/affine/widget-drag-handle/tsconfig.json @@ -7,6 +7,7 @@ }, "include": ["./src"], "references": [ + { "path": "../block-callout" }, { "path": "../block-list" }, { "path": "../block-note" }, { "path": "../block-paragraph" }, diff --git a/blocksuite/blocks/package.json b/blocksuite/blocks/package.json index 55ba503af..1a08405ef 100644 --- a/blocksuite/blocks/package.json +++ b/blocksuite/blocks/package.json @@ -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:*", diff --git a/blocksuite/blocks/src/effects.ts b/blocksuite/blocks/src/effects.ts index a89928570..ed1c9fa1e 100644 --- a/blocksuite/blocks/src/effects.ts +++ b/blocksuite/blocks/src/effects.ts @@ -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(); diff --git a/blocksuite/blocks/src/extensions/common.ts b/blocksuite/blocks/src/extensions/common.ts index b2ea9607d..e52e48d3b 100644 --- a/blocksuite/blocks/src/extensions/common.ts +++ b/blocksuite/blocks/src/extensions/common.ts @@ -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[] = [ diff --git a/blocksuite/blocks/src/extensions/store.ts b/blocksuite/blocks/src/extensions/store.ts index afe95e2a8..04fd4931f 100644 --- a/blocksuite/blocks/src/extensions/store.ts +++ b/blocksuite/blocks/src/extensions/store.ts @@ -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, diff --git a/blocksuite/blocks/src/schemas.ts b/blocksuite/blocks/src/schemas.ts index c44bcf33b..91dc3e33b 100644 --- a/blocksuite/blocks/src/schemas.ts +++ b/blocksuite/blocks/src/schemas.ts @@ -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[] = [ EdgelessTextBlockSchema, LatexBlockSchema, TableBlockSchema, + CalloutBlockSchema, ]; diff --git a/blocksuite/blocks/tsconfig.json b/blocksuite/blocks/tsconfig.json index 84c8ee462..ea50fa270 100644 --- a/blocksuite/blocks/tsconfig.json +++ b/blocksuite/blocks/tsconfig.json @@ -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" }, diff --git a/packages/frontend/core/src/components/page-list/__tests__/use-block-suite-page-preview.spec.ts b/packages/frontend/core/src/components/page-list/__tests__/use-block-suite-page-preview.spec.ts index 1ed1e86f1..da64640d8 100644 --- a/packages/frontend/core/src/components/page-list/__tests__/use-block-suite-page-preview.spec.ts +++ b/packages/frontend/core/src/components/page-list/__tests__/use-block-suite-page-preview.spec.ts @@ -17,6 +17,11 @@ const extensions = StoreExtensions; beforeEach(async () => { vi.useFakeTimers({ toFake: ['requestIdleCallback'] }); + vi.mock('emoji-mart', () => { + return { + Picker: vi.fn(), + }; + }); docCollection = new TestWorkspace({ id: 'test' }); docCollection.meta.initialize(); const initPage = async (page: Store) => { diff --git a/packages/frontend/core/src/modules/feature-flag/constant.ts b/packages/frontend/core/src/modules/feature-flag/constant.ts index 1e3c61a14..aa2469ca3 100644 --- a/packages/frontend/core/src/modules/feature-flag/constant.ts +++ b/packages/frontend/core/src/modules/feature-flag/constant.ts @@ -113,6 +113,16 @@ export const AFFINE_FLAGS = { configurable: isCanaryBuild, defaultState: false, }, + enable_callout: { + category: 'blocksuite', + bsFlag: 'enable_callout', + displayName: + 'com.affine.settings.workspace.experimental-features.enable-callout.name', + description: + 'com.affine.settings.workspace.experimental-features.enable-callout.description', + configurable: isCanaryBuild, + defaultState: false, + }, enable_emoji_folder_icon: { category: 'affine', displayName: diff --git a/packages/frontend/core/src/modules/journal/__tests__/suggest-date.spec.ts b/packages/frontend/core/src/modules/journal/__tests__/suggest-date.spec.ts index f71b65ee0..f97bbc156 100644 --- a/packages/frontend/core/src/modules/journal/__tests__/suggest-date.spec.ts +++ b/packages/frontend/core/src/modules/journal/__tests__/suggest-date.spec.ts @@ -5,7 +5,13 @@ import { JOURNAL_DATE_FORMAT } from '@affine/core/modules/journal'; import { I18n } from '@affine/i18n'; import dayjs from 'dayjs'; -import { describe, expect, test } from 'vitest'; +import { describe, expect, test, vi } from 'vitest'; + +vi.mock('emoji-mart', () => { + return { + Picker: vi.fn(), + }; +}); import { suggestJournalDate } from '../suggest-journal-date'; diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts index afb227e0d..7fd4e4d05 100644 --- a/packages/frontend/i18n/src/i18n.gen.ts +++ b/packages/frontend/i18n/src/i18n.gen.ts @@ -5391,6 +5391,14 @@ export function useAFFiNEI18N(): { * `Once enabled, all blocks will have created time, updated time, created by and updated by.` */ ["com.affine.settings.workspace.experimental-features.enable-block-meta.description"](): string; + /** + * `Callout` + */ + ["com.affine.settings.workspace.experimental-features.enable-callout.name"](): string; + /** + * `Let your words stand out.` + */ + ["com.affine.settings.workspace.experimental-features.enable-callout.description"](): string; /** * `Emoji Folder Icon` */ diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 1e2fc709b..2e6a16eb2 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -1345,6 +1345,8 @@ "com.affine.settings.workspace.experimental-features.enable-mind-map-import.description": "Enables mind map import.", "com.affine.settings.workspace.experimental-features.enable-block-meta.name": "Block Meta", "com.affine.settings.workspace.experimental-features.enable-block-meta.description": "Once enabled, all blocks will have created time, updated time, created by and updated by.", + "com.affine.settings.workspace.experimental-features.enable-callout.name": "Callout", + "com.affine.settings.workspace.experimental-features.enable-callout.description": "Let your words stand out.", "com.affine.settings.workspace.experimental-features.enable-emoji-folder-icon.name": "Emoji Folder Icon", "com.affine.settings.workspace.experimental-features.enable-emoji-folder-icon.description": "Once enabled, you can use an emoji as the folder icon. When the first character of the folder name is an emoji, it will be extracted and used as its icon.", "com.affine.settings.workspace.experimental-features.enable-emoji-doc-icon.name": "Emoji Doc Icon", diff --git a/tests/affine-local/e2e/blocksuite/callout/callout.spec.ts b/tests/affine-local/e2e/blocksuite/callout/callout.spec.ts new file mode 100644 index 000000000..e74bb5399 --- /dev/null +++ b/tests/affine-local/e2e/blocksuite/callout/callout.spec.ts @@ -0,0 +1,43 @@ +import { openHomePage } from '@affine-test/kit/utils/load-page'; +import { type } from '@affine-test/kit/utils/page-logic'; +import { expect, test } from '@playwright/test'; + +test('add callout block using slash menu and change emoji', async ({ + page, +}) => { + await openHomePage(page); + await page.getByTestId('settings-modal-trigger').click(); + await page.getByText('Experimental features').click(); + await page.getByText('I am aware of the risks, and').click(); + await page.getByTestId('experimental-confirm-button').click(); + await page.getByTestId('enable_callout').locator('span').click(); + await page.getByTestId('modal-close-button').click(); + await page.getByTestId('sidebar-new-page-button').click(); + await page.locator('affine-paragraph v-line div').click(); + + await type(page, '/callout\naaaa\nbbbb'); + const callout = page.locator('affine-callout'); + const emoji = page.locator('affine-callout .affine-callout-emoji'); + await expect(callout).toBeVisible(); + await expect(emoji).toContainText('😀'); + + const paragraph = page.locator('affine-callout affine-paragraph'); + await expect(paragraph).toHaveCount(1); + + const vLine = page.locator('affine-callout v-line'); + 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('😆'); +}); diff --git a/tools/utils/src/workspace.gen.ts b/tools/utils/src/workspace.gen.ts index e1177e13c..2ac476661 100644 --- a/tools/utils/src/workspace.gen.ts +++ b/tools/utils/src/workspace.gen.ts @@ -42,6 +42,19 @@ export const PackageList = [ 'blocksuite/framework/store', ], }, + { + location: 'blocksuite/affine/block-callout', + name: '@blocksuite/affine-block-callout', + workspaceDependencies: [ + 'blocksuite/affine/components', + 'blocksuite/affine/model', + 'blocksuite/affine/shared', + 'blocksuite/framework/block-std', + 'blocksuite/framework/global', + 'blocksuite/framework/inline', + 'blocksuite/framework/store', + ], + }, { location: 'blocksuite/affine/block-code', name: '@blocksuite/affine-block-code', @@ -361,6 +374,7 @@ export const PackageList = [ location: 'blocksuite/affine/widget-drag-handle', name: '@blocksuite/affine-widget-drag-handle', workspaceDependencies: [ + 'blocksuite/affine/block-callout', 'blocksuite/affine/block-list', 'blocksuite/affine/block-note', 'blocksuite/affine/block-paragraph', @@ -426,6 +440,7 @@ export const PackageList = [ workspaceDependencies: [ 'blocksuite/affine/block-attachment', 'blocksuite/affine/block-bookmark', + 'blocksuite/affine/block-callout', 'blocksuite/affine/block-code', 'blocksuite/affine/block-data-view', 'blocksuite/affine/block-database', @@ -816,6 +831,7 @@ export type PackageName = | '@blocksuite/affine' | '@blocksuite/affine-block-attachment' | '@blocksuite/affine-block-bookmark' + | '@blocksuite/affine-block-callout' | '@blocksuite/affine-block-code' | '@blocksuite/affine-block-data-view' | '@blocksuite/affine-block-database' diff --git a/tsconfig.json b/tsconfig.json index 50ed943e9..c15a713ba 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -53,6 +53,7 @@ { "path": "./blocksuite/affine/all" }, { "path": "./blocksuite/affine/block-attachment" }, { "path": "./blocksuite/affine/block-bookmark" }, + { "path": "./blocksuite/affine/block-callout" }, { "path": "./blocksuite/affine/block-code" }, { "path": "./blocksuite/affine/block-data-view" }, { "path": "./blocksuite/affine/block-database" }, diff --git a/yarn.lock b/yarn.lock index 1d4f5ccca..bb81c8fd1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2244,6 +2244,30 @@ __metadata: languageName: unknown linkType: soft +"@blocksuite/affine-block-callout@workspace:*, @blocksuite/affine-block-callout@workspace:blocksuite/affine/block-callout": + version: 0.0.0-use.local + resolution: "@blocksuite/affine-block-callout@workspace:blocksuite/affine/block-callout" + 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": "npm:^1.2.1" + "@floating-ui/dom": "npm:^1.6.10" + "@lit/context": "npm:^1.1.2" + "@preact/signals-core": "npm:^1.8.0" + "@toeverything/theme": "npm:^1.1.12" + "@types/mdast": "npm:^4.0.4" + emoji-mart: "npm:^5.6.0" + lit: "npm:^3.2.0" + minimatch: "npm:^10.0.1" + zod: "npm:^3.23.8" + languageName: unknown + linkType: soft + "@blocksuite/affine-block-code@workspace:*, @blocksuite/affine-block-code@workspace:blocksuite/affine/block-code": version: 0.0.0-use.local resolution: "@blocksuite/affine-block-code@workspace:blocksuite/affine/block-code" @@ -2811,6 +2835,7 @@ __metadata: version: 0.0.0-use.local resolution: "@blocksuite/affine-widget-drag-handle@workspace:blocksuite/affine/widget-drag-handle" dependencies: + "@blocksuite/affine-block-callout": "workspace:*" "@blocksuite/affine-block-list": "workspace:*" "@blocksuite/affine-block-note": "workspace:*" "@blocksuite/affine-block-paragraph": "workspace:*" @@ -2944,6 +2969,7 @@ __metadata: 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:*" @@ -4223,6 +4249,13 @@ __metadata: languageName: node linkType: hard +"@emoji-mart/data@npm:^1.2.1": + version: 1.2.1 + resolution: "@emoji-mart/data@npm:1.2.1" + checksum: 10/a9f50edaf354aadfede604fb26d80055a085e9160db2c924fd5e6afc27033cd5beb0006a9ee48240ce9c543e58e1bf1cf9ed83baba5db83a395154984b30bd91 + languageName: node + linkType: hard + "@emotion/babel-plugin@npm:^11.13.5": version: 11.13.5 resolution: "@emotion/babel-plugin@npm:11.13.5" @@ -4864,7 +4897,7 @@ __metadata: languageName: node linkType: hard -"@floating-ui/dom@npm:^1.0.0, @floating-ui/dom@npm:^1.6.12, @floating-ui/dom@npm:^1.6.13": +"@floating-ui/dom@npm:^1.0.0, @floating-ui/dom@npm:^1.6.10, @floating-ui/dom@npm:^1.6.12, @floating-ui/dom@npm:^1.6.13": version: 1.6.13 resolution: "@floating-ui/dom@npm:1.6.13" dependencies: @@ -19225,6 +19258,13 @@ __metadata: languageName: node linkType: hard +"emoji-mart@npm:^5.6.0": + version: 5.6.0 + resolution: "emoji-mart@npm:5.6.0" + checksum: 10/fbbd6ce6fe6bc30020a7de4bfd6375f3c00da9880a147fdbee37303d985856e5463a48a61177166ad056a355e0bd589df9c0866bd54f9c065d60a9efa80639dd + languageName: node + linkType: hard + "emoji-regex-xs@npm:^1.0.0": version: 1.0.0 resolution: "emoji-regex-xs@npm:1.0.0"