diff --git a/blocksuite/affine/blocks/callout/src/callout-block.ts b/blocksuite/affine/blocks/callout/src/callout-block.ts index 9deeb73ed..f79ee03bc 100644 --- a/blocksuite/affine/blocks/callout/src/callout-block.ts +++ b/blocksuite/affine/blocks/callout/src/callout-block.ts @@ -2,6 +2,7 @@ import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption'; import { createLitPortal } from '@blocksuite/affine-components/portal'; import { DefaultInlineManagerExtension } from '@blocksuite/affine-inline-preset'; import { type CalloutBlockModel } from '@blocksuite/affine-model'; +import { focusTextModel } from '@blocksuite/affine-rich-text'; import { EDGELESS_TOP_CONTENTEDITABLE_SELECTOR } from '@blocksuite/affine-shared/consts'; import { DocModeProvider, @@ -22,14 +23,13 @@ export class CalloutBlockComponent extends CaptionedBlockComponent { + .onEmojiSelect=${(data: { native: string }) => { this.model.props.emoji = data.native; }} >`, @@ -81,6 +84,31 @@ export class CalloutBlockComponent extends CaptionedBlockComponent { + // Check if the click target is emoji related element + const target = event.target as HTMLElement; + if ( + target.closest('.affine-callout-emoji-container') || + target.classList.contains('affine-callout-emoji') + ) { + return; + } + + // Only handle clicks when there are no children + if (this.model.children.length > 0) { + return; + } + + // Prevent event bubbling + event.stopPropagation(); + + // Create a new paragraph block + const paragraphId = this.store.addBlock('affine:paragraph', {}, this.model); + + // Focus the new paragraph + focusTextModel(this.std, paragraphId); + }; + get attributeRenderer() { return this.inlineManager.getRenderer(); } @@ -112,7 +140,10 @@ export class CalloutBlockComponent extends CaptionedBlockComponent +
{ return { + Enter: ctx => { + const text = std.selection.find(TextSelection); + if (!text) return false; + + const currentBlock = std.store.getBlock(text.from.blockId); + if (!currentBlock) return false; + + // Check if current block is a callout block + let calloutBlock = currentBlock; + if (!matchModels(currentBlock.model, [CalloutBlockModel])) { + // If not, check if the parent is a callout block + const parent = std.store.getParent(currentBlock.model); + if (!parent || !matchModels(parent, [CalloutBlockModel])) { + return false; + } + const parentBlock = std.store.getBlock(parent.id); + if (!parentBlock) return false; + calloutBlock = parentBlock; + } + + ctx.get('keyboardState').raw.preventDefault(); + std.command + .chain() + .pipe(splitCalloutCommand, { + blockId: calloutBlock.model.id, + inlineIndex: text.from.index, + currentBlockId: text.from.blockId, + }) + .run(); + return true; + }, Backspace: ctx => { const text = std.selection.find(TextSelection); if (text && text.isCollapsed() && text.from.index === 0) { const event = ctx.get('defaultState').event; - event.preventDefault(); const block = std.store.getBlock(text.from.blockId); if (!block) return false; @@ -20,6 +56,22 @@ export const CalloutKeymapExtension = KeymapExtension(std => { if (!parent) return false; if (!matchModels(parent, [CalloutBlockModel])) return false; + // Check if current block is a paragraph inside callout + if (matchModels(block.model, [ParagraphBlockModel])) { + event.preventDefault(); + + std.command + .chain() + .pipe(calloutToParagraphCommand, { + id: block.model.id, + }) + .run(); + + return true; + } + + // Fallback to selecting the callout block + event.preventDefault(); std.selection.setGroup('note', [ std.selection.create(BlockSelection, { blockId: parent.id, diff --git a/blocksuite/affine/blocks/callout/src/commands/callout-to-paragraph.ts b/blocksuite/affine/blocks/callout/src/commands/callout-to-paragraph.ts new file mode 100644 index 000000000..2c5bde996 --- /dev/null +++ b/blocksuite/affine/blocks/callout/src/commands/callout-to-paragraph.ts @@ -0,0 +1,86 @@ +import { + CalloutBlockModel, + ParagraphBlockModel, +} from '@blocksuite/affine-model'; +import { focusTextModel } from '@blocksuite/affine-rich-text'; +import { matchModels } from '@blocksuite/affine-shared/utils'; +import type { Command } from '@blocksuite/std'; +import { BlockSelection } from '@blocksuite/std'; +import { Text } from '@blocksuite/store'; + +export const calloutToParagraphCommand: Command< + { + id: string; + stopCapturing?: boolean; + }, + { + success: boolean; + } +> = (ctx, next) => { + const { id, stopCapturing = true } = ctx; + const std = ctx.std; + const doc = std.store; + const model = doc.getBlock(id)?.model; + + if (!model || !matchModels(model, [ParagraphBlockModel])) return false; + + const parent = doc.getParent(model); + if (!parent || !matchModels(parent, [CalloutBlockModel])) return false; + + if (stopCapturing) std.store.captureSync(); + + // Get current block index in callout + const currentIndex = parent.children.indexOf(model); + const hasText = model.text && model.text.length > 0; + + // Find previous paragraph block in callout + let previousBlock = null; + for (let i = currentIndex - 1; i >= 0; i--) { + const sibling = parent.children[i]; + if (matchModels(sibling, [ParagraphBlockModel])) { + previousBlock = sibling; + break; + } + } + + if (previousBlock && hasText) { + // Clone current text content before any operations to prevent data loss + const currentText = model.text || new Text(); + + // Get previous block text and merge index + const previousText = previousBlock.text || new Text(); + const mergeIndex = previousText.length; + + // Apply each delta from cloned current text to previous block to preserve formatting + previousText.join(currentText); + + // Remove current block after text has been merged + doc.deleteBlock(model, { + deleteChildren: false, + }); + + // Focus at merge point in previous block + focusTextModel(std, previousBlock.id, mergeIndex); + } else if (previousBlock && !hasText) { + // Move cursor to end of previous block + doc.deleteBlock(model, { + deleteChildren: false, + }); + + const previousText = previousBlock.text || new Text(); + focusTextModel(std, previousBlock.id, previousText.length); + } else { + // No previous block, select the entire callout + doc.deleteBlock(model, { + deleteChildren: false, + }); + + std.selection.setGroup('note', [ + std.selection.create(BlockSelection, { + blockId: parent.id, + }), + ]); + } + + return next({ success: true }); +}; diff --git a/blocksuite/affine/blocks/callout/src/commands/split-callout.ts b/blocksuite/affine/blocks/callout/src/commands/split-callout.ts new file mode 100644 index 000000000..f60d32f03 --- /dev/null +++ b/blocksuite/affine/blocks/callout/src/commands/split-callout.ts @@ -0,0 +1,85 @@ +import { + CalloutBlockModel, + ParagraphBlockModel, +} from '@blocksuite/affine-model'; +import { focusTextModel } from '@blocksuite/affine-rich-text'; +import { matchModels } from '@blocksuite/affine-shared/utils'; +import type { Command, EditorHost } from '@blocksuite/std'; + +export const splitCalloutCommand: Command<{ + blockId: string; + inlineIndex: number; + currentBlockId: string; +}> = (ctx, next) => { + const { blockId, inlineIndex, currentBlockId, std } = ctx; + const host = std.host as EditorHost; + const doc = host.store; + + const calloutModel = doc.getBlock(blockId)?.model; + if (!calloutModel || !matchModels(calloutModel, [CalloutBlockModel])) { + console.error(`block ${blockId} is not a callout block`); + return; + } + + const currentModel = doc.getBlock(currentBlockId)?.model; + if (!currentModel) { + console.error(`current block ${currentBlockId} not found`); + return; + } + + doc.captureSync(); + + if (matchModels(currentModel, [ParagraphBlockModel])) { + // User is in a paragraph within the callout's children + const afterText = currentModel.props.text.split(inlineIndex); + + // Update the current paragraph's text to keep only the part before cursor + doc.transact(() => { + currentModel.props.text.delete( + inlineIndex, + currentModel.props.text.length - inlineIndex + ); + }); + + // Create a new paragraph block after the current one + const parent = doc.getParent(currentModel); + if (parent) { + const currentIndex = parent.children.indexOf(currentModel); + const newParagraphId = doc.addBlock( + 'affine:paragraph', + { + text: afterText, + }, + parent, + currentIndex + 1 + ); + + if (newParagraphId) { + host.updateComplete + .then(() => { + focusTextModel(std, newParagraphId); + }) + .catch(console.error); + } + } + } else { + // If current block is not a paragraph, create a new paragraph in callout + const newParagraphId = doc.addBlock( + 'affine:paragraph', + { + text: new Text(), + }, + calloutModel + ); + + if (newParagraphId) { + host.updateComplete + .then(() => { + focusTextModel(std, newParagraphId); + }) + .catch(console.error); + } + } + + next(); +}; diff --git a/blocksuite/affine/blocks/callout/src/configs/slash-menu.ts b/blocksuite/affine/blocks/callout/src/configs/slash-menu.ts index 9de6047c0..65eb0d0e0 100644 --- a/blocksuite/affine/blocks/callout/src/configs/slash-menu.ts +++ b/blocksuite/affine/blocks/callout/src/configs/slash-menu.ts @@ -1,24 +1,12 @@ -import { CalloutBlockModel } from '@blocksuite/affine-model'; import { focusBlockEnd } from '@blocksuite/affine-shared/commands'; import { FeatureFlagService } from '@blocksuite/affine-shared/services'; -import { - findAncestorModel, - isInsideBlockByFlavour, - matchModels, -} from '@blocksuite/affine-shared/utils'; +import { isInsideBlockByFlavour } from '@blocksuite/affine-shared/utils'; import { type SlashMenuConfig } from '@blocksuite/affine-widget-slash-menu'; import { FontIcon } from '@blocksuite/icons/lit'; import { calloutTooltip } from './tooltips'; export const calloutSlashMenuConfig: SlashMenuConfig = { - disableWhen: ({ model }) => { - return ( - findAncestorModel(model, ancestor => - matchModels(ancestor, [CalloutBlockModel]) - ) !== null - ); - }, items: [ { name: 'Callout', diff --git a/packages/frontend/native/index.d.ts b/packages/frontend/native/index.d.ts index ae3c03e98..7d36a2780 100644 --- a/packages/frontend/native/index.d.ts +++ b/packages/frontend/native/index.d.ts @@ -19,10 +19,10 @@ export declare class ApplicationStateChangedSubscriber { } export declare class AudioCaptureSession { + stop(): void get sampleRate(): number get channels(): number get actualSampleRate(): number - stop(): void } export declare class ShareableContent { @@ -31,9 +31,9 @@ export declare class ShareableContent { constructor() static applications(): Array static applicationWithProcessId(processId: number): ApplicationInfo | null + static isUsingMicrophone(processId: number): boolean static tapAudio(processId: number, audioStreamCallback: ((err: Error | null, arg: Float32Array) => void)): AudioCaptureSession static tapGlobalAudio(excludedProcesses: Array | undefined | null, audioStreamCallback: ((err: Error | null, arg: Float32Array) => void)): AudioCaptureSession - static isUsingMicrophone(processId: number): boolean } export declare function decodeAudio(buf: Uint8Array, destSampleRate?: number | undefined | null, filename?: string | undefined | null, signal?: AbortSignal | undefined | null): Promise diff --git a/tests/affine-local/e2e/blocksuite/callout/callout.spec.ts b/tests/affine-local/e2e/blocksuite/callout/callout.spec.ts index 87400b862..0b7cbd23d 100644 --- a/tests/affine-local/e2e/blocksuite/callout/callout.spec.ts +++ b/tests/affine-local/e2e/blocksuite/callout/callout.spec.ts @@ -3,7 +3,6 @@ import { pressArrowUp, pressBackspace, pressEnter, - undoByKeyboard, } from '@affine-test/kit/utils/keyboard'; import { openHomePage } from '@affine-test/kit/utils/load-page'; import { @@ -30,7 +29,7 @@ test('add callout block using slash menu and change emoji', async ({ await expect(emoji).toContainText('😀'); const paragraph = page.locator('affine-callout affine-paragraph'); - await expect(paragraph).toHaveCount(1); + await expect(paragraph).toHaveCount(2); const vLine = page.locator('affine-callout v-line'); await expect(vLine).toHaveCount(2); @@ -50,22 +49,6 @@ test('add callout block using slash menu and change emoji', async ({ await expect(emoji).toContainText('😆'); }); -test('disable slash menu in callout block', async ({ page }) => { - await type(page, '/callout\n'); - const callout = page.locator('affine-callout'); - const emoji = page.locator('affine-callout .affine-callout-emoji'); - await expect(callout).toBeVisible(); - await expect(emoji).toContainText('😀'); - - await type(page, '/'); - const slashMenu = page.locator('.slash-menu'); - await expect(slashMenu).not.toBeVisible(); - await undoByKeyboard(page); - await undoByKeyboard(page); - await type(page, '/'); - await expect(slashMenu).toBeVisible(); -}); - test('press backspace after callout block', async ({ page }) => { await pressEnter(page); await pressArrowUp(page); @@ -96,7 +79,7 @@ test('press backspace in callout block', async ({ page }) => { expect(await callout.count()).toBe(1); await pressBackspace(page); - await expect(paragraph).toHaveCount(2); + await expect(paragraph).toHaveCount(1); await expect(callout).toHaveCount(1); await pressBackspace(page);