fix(editor): callout delete merge and slash menu (#13597)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- New Features
- Press Enter inside a callout splits the paragraph at the cursor into a
new focused paragraph.
- Clicking an empty callout inserts and focuses a new paragraph; emoji
menu behavior unchanged.
- New command to convert a callout paragraph to callout/selection flow
for Backspace handling.
  - New native API: ShareableContent.isUsingMicrophone(processId).

- Bug Fixes
- Backspace inside callout paragraphs now merges or deletes text
predictably and selects the callout when appropriate.

- Style
- Callout layout refined: top-aligned content and adjusted emoji
spacing.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
3720
2025-09-22 19:29:18 +08:00
committed by GitHub
parent 195864fc88
commit 7fe95f50f4
7 changed files with 265 additions and 40 deletions

View File

@@ -2,6 +2,7 @@ import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption';
import { createLitPortal } from '@blocksuite/affine-components/portal'; import { createLitPortal } from '@blocksuite/affine-components/portal';
import { DefaultInlineManagerExtension } from '@blocksuite/affine-inline-preset'; import { DefaultInlineManagerExtension } from '@blocksuite/affine-inline-preset';
import { type CalloutBlockModel } from '@blocksuite/affine-model'; 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 { EDGELESS_TOP_CONTENTEDITABLE_SELECTOR } from '@blocksuite/affine-shared/consts';
import { import {
DocModeProvider, DocModeProvider,
@@ -22,14 +23,13 @@ export class CalloutBlockComponent extends CaptionedBlockComponent<CalloutBlockM
.affine-callout-block-container { .affine-callout-block-container {
display: flex; display: flex;
align-items: flex-start;
padding: 5px 10px; padding: 5px 10px;
border-radius: 8px; border-radius: 8px;
background-color: ${unsafeCSSVarV2('block/callout/background/grey')}; background-color: ${unsafeCSSVarV2('block/callout/background/grey')};
} }
.affine-callout-emoji-container { .affine-callout-emoji-container {
margin-right: 10px;
margin-top: 14px;
user-select: none; user-select: none;
font-size: 1.2em; font-size: 1.2em;
width: 24px; width: 24px;
@@ -37,6 +37,9 @@ export class CalloutBlockComponent extends CaptionedBlockComponent<CalloutBlockM
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-top: 10px;
margin-bottom: 10px;
flex-shrink: 0;
} }
.affine-callout-emoji:hover { .affine-callout-emoji:hover {
cursor: pointer; cursor: pointer;
@@ -62,7 +65,7 @@ export class CalloutBlockComponent extends CaptionedBlockComponent<CalloutBlockM
createLitPortal({ createLitPortal({
template: html`<affine-emoji-menu template: html`<affine-emoji-menu
.theme=${theme} .theme=${theme}
.onEmojiSelect=${(data: any) => { .onEmojiSelect=${(data: { native: string }) => {
this.model.props.emoji = data.native; this.model.props.emoji = data.native;
}} }}
></affine-emoji-menu>`, ></affine-emoji-menu>`,
@@ -81,6 +84,31 @@ export class CalloutBlockComponent extends CaptionedBlockComponent<CalloutBlockM
}); });
}; };
private readonly _handleBlockClick = (event: MouseEvent) => {
// 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() { get attributeRenderer() {
return this.inlineManager.getRenderer(); return this.inlineManager.getRenderer();
} }
@@ -112,7 +140,10 @@ export class CalloutBlockComponent extends CaptionedBlockComponent<CalloutBlockM
override renderBlock() { override renderBlock() {
const emoji = this.model.props.emoji$.value; const emoji = this.model.props.emoji$.value;
return html` return html`
<div class="affine-callout-block-container"> <div
class="affine-callout-block-container"
@click=${this._handleBlockClick}
>
<div <div
@click=${this._toggleEmojiMenu} @click=${this._toggleEmojiMenu}
contenteditable="false" contenteditable="false"

View File

@@ -1,4 +1,7 @@
import { CalloutBlockModel } from '@blocksuite/affine-model'; import {
CalloutBlockModel,
ParagraphBlockModel,
} from '@blocksuite/affine-model';
import { matchModels } from '@blocksuite/affine-shared/utils'; import { matchModels } from '@blocksuite/affine-shared/utils';
import { import {
BlockSelection, BlockSelection,
@@ -6,13 +9,46 @@ import {
TextSelection, TextSelection,
} from '@blocksuite/std'; } from '@blocksuite/std';
import { calloutToParagraphCommand } from './commands/callout-to-paragraph.js';
import { splitCalloutCommand } from './commands/split-callout.js';
export const CalloutKeymapExtension = KeymapExtension(std => { export const CalloutKeymapExtension = KeymapExtension(std => {
return { 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 => { Backspace: ctx => {
const text = std.selection.find(TextSelection); const text = std.selection.find(TextSelection);
if (text && text.isCollapsed() && text.from.index === 0) { if (text && text.isCollapsed() && text.from.index === 0) {
const event = ctx.get('defaultState').event; const event = ctx.get('defaultState').event;
event.preventDefault();
const block = std.store.getBlock(text.from.blockId); const block = std.store.getBlock(text.from.blockId);
if (!block) return false; if (!block) return false;
@@ -20,6 +56,22 @@ export const CalloutKeymapExtension = KeymapExtension(std => {
if (!parent) return false; if (!parent) return false;
if (!matchModels(parent, [CalloutBlockModel])) 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.setGroup('note', [
std.selection.create(BlockSelection, { std.selection.create(BlockSelection, {
blockId: parent.id, blockId: parent.id,

View File

@@ -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 });
};

View File

@@ -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();
};

View File

@@ -1,24 +1,12 @@
import { CalloutBlockModel } from '@blocksuite/affine-model';
import { focusBlockEnd } from '@blocksuite/affine-shared/commands'; import { focusBlockEnd } from '@blocksuite/affine-shared/commands';
import { FeatureFlagService } from '@blocksuite/affine-shared/services'; import { FeatureFlagService } from '@blocksuite/affine-shared/services';
import { import { isInsideBlockByFlavour } from '@blocksuite/affine-shared/utils';
findAncestorModel,
isInsideBlockByFlavour,
matchModels,
} from '@blocksuite/affine-shared/utils';
import { type SlashMenuConfig } from '@blocksuite/affine-widget-slash-menu'; import { type SlashMenuConfig } from '@blocksuite/affine-widget-slash-menu';
import { FontIcon } from '@blocksuite/icons/lit'; import { FontIcon } from '@blocksuite/icons/lit';
import { calloutTooltip } from './tooltips'; import { calloutTooltip } from './tooltips';
export const calloutSlashMenuConfig: SlashMenuConfig = { export const calloutSlashMenuConfig: SlashMenuConfig = {
disableWhen: ({ model }) => {
return (
findAncestorModel(model, ancestor =>
matchModels(ancestor, [CalloutBlockModel])
) !== null
);
},
items: [ items: [
{ {
name: 'Callout', name: 'Callout',

View File

@@ -19,10 +19,10 @@ export declare class ApplicationStateChangedSubscriber {
} }
export declare class AudioCaptureSession { export declare class AudioCaptureSession {
stop(): void
get sampleRate(): number get sampleRate(): number
get channels(): number get channels(): number
get actualSampleRate(): number get actualSampleRate(): number
stop(): void
} }
export declare class ShareableContent { export declare class ShareableContent {
@@ -31,9 +31,9 @@ export declare class ShareableContent {
constructor() constructor()
static applications(): Array<ApplicationInfo> static applications(): Array<ApplicationInfo>
static applicationWithProcessId(processId: number): ApplicationInfo | null static applicationWithProcessId(processId: number): ApplicationInfo | null
static isUsingMicrophone(processId: number): boolean
static tapAudio(processId: number, audioStreamCallback: ((err: Error | null, arg: Float32Array) => void)): AudioCaptureSession static tapAudio(processId: number, audioStreamCallback: ((err: Error | null, arg: Float32Array) => void)): AudioCaptureSession
static tapGlobalAudio(excludedProcesses: Array<ApplicationInfo> | undefined | null, audioStreamCallback: ((err: Error | null, arg: Float32Array) => void)): AudioCaptureSession static tapGlobalAudio(excludedProcesses: Array<ApplicationInfo> | 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<Float32Array> export declare function decodeAudio(buf: Uint8Array, destSampleRate?: number | undefined | null, filename?: string | undefined | null, signal?: AbortSignal | undefined | null): Promise<Float32Array>

View File

@@ -3,7 +3,6 @@ import {
pressArrowUp, pressArrowUp,
pressBackspace, pressBackspace,
pressEnter, pressEnter,
undoByKeyboard,
} from '@affine-test/kit/utils/keyboard'; } from '@affine-test/kit/utils/keyboard';
import { openHomePage } from '@affine-test/kit/utils/load-page'; import { openHomePage } from '@affine-test/kit/utils/load-page';
import { import {
@@ -30,7 +29,7 @@ test('add callout block using slash menu and change emoji', async ({
await expect(emoji).toContainText('😀'); await expect(emoji).toContainText('😀');
const paragraph = page.locator('affine-callout affine-paragraph'); 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'); const vLine = page.locator('affine-callout v-line');
await expect(vLine).toHaveCount(2); await expect(vLine).toHaveCount(2);
@@ -50,22 +49,6 @@ test('add callout block using slash menu and change emoji', async ({
await expect(emoji).toContainText('😆'); 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 }) => { test('press backspace after callout block', async ({ page }) => {
await pressEnter(page); await pressEnter(page);
await pressArrowUp(page); await pressArrowUp(page);
@@ -96,7 +79,7 @@ test('press backspace in callout block', async ({ page }) => {
expect(await callout.count()).toBe(1); expect(await callout.count()).toBe(1);
await pressBackspace(page); await pressBackspace(page);
await expect(paragraph).toHaveCount(2); await expect(paragraph).toHaveCount(1);
await expect(callout).toHaveCount(1); await expect(callout).toHaveCount(1);
await pressBackspace(page); await pressBackspace(page);