diff --git a/blocksuite/affine/block-callout/package.json b/blocksuite/affine/block-callout/package.json new file mode 100644 index 0000000000..33f26854a9 --- /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 0000000000..6f70edd2d0 --- /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 0000000000..8f9cbe9d60 --- /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 0000000000..7cf7bc7737 --- /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 0000000000..c9cf0c33d9 --- /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 0000000000..96b03184db --- /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 0000000000..230fca2705 --- /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 423c37baa2..33762386b9 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 ff136fe522..71d547eb83 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 095e512f20..f5487e1674 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 50a81b51c7..a50998c187 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 0000000000..3ba2f013f1 --- /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 0000000000..16e1117693 --- /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 6e06e5ec32..0bc0e23afc 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 65ca43cca1..08f6cd8f6a 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 40f94522c8..3d88b45792 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 75abd0ddbf..303646a508 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 afd8fa43ea..f93d60b62b 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 0e76fe04a9..dfcb2270ec 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 e0e3344cf1..d437df0567 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 55ba503afe..1a08405ef5 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 a89928570f..ed1c9fa1ea 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 b2ea9607de..e52e48d3b1 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 afe95e2a8c..04fd4931f4 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 c44bcf33b3..91dc3e33b6 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 84c8ee4628..ea50fa270e 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 1ed1e86f11..da64640d82 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 1e3c61a142..aa2469ca31 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 f71b65ee01..f97bbc1562 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 afb227e0d6..7fd4e4d055 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 1e2fc709b2..2e6a16eb2d 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 0000000000..e74bb5399e --- /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 e1177e13cd..2ac4766614 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 50ed943e9f..c15a713ba5 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 1d4f5ccca2..bb81c8fd1b 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"