feat(editor): add callout to "Turn into" menu (#15508)

## Summary

Closes #13954

The "Turn into" menu was missing the Callout option. Users had no way to
convert an existing paragraph/list/code block into a Callout directly
from the slash menu or context toolbar.

## Root Cause

`textConversionConfigs` in `rich-text/src/conversion.ts` had no entry
for `affine:callout`, so the option never appeared in the menu.

Additionally, Callout is a **hub block** — its text lives in a child
paragraph, not in its own `text` prop. This means the generic
`transformModel` path would silently fail, requiring a dedicated
conversion handler.

## Changes

- `blocksuite/affine/rich-text/src/conversion.ts` — add callout entry to
`textConversionConfigs`
- `blocksuite/affine/blocks/note/src/commands/block-type.ts` — add
`transformToCallout` command that:
  1. Creates a new `affine:callout` block at the original position
2. Moves the original text into a child `affine:paragraph` inside the
callout
  3. Deletes the original block

## Before / After

| Before | After |
|--------|-------|
| "Turn into" menu had no Callout option | Callout appears in "Turn
into" menu |
| Selecting any block → Turn into showed: Heading, Text, Quote, Divider…
| Now also shows: **Callout** |


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

* **New Features**
* Convert selected paragraphs, lists, and code blocks into callouts
while preserving text and nested content.
  * Added callout recognition and icon display in rich-text conversion.
  * Conversion results now distinguish between selected blocks and text.
* **Bug Fixes**
  * Prevented invalid, nested, or incompatible callout conversions.
  * The conversion menu now hides Callout when unavailable.
  * Preserved original blocks when conversion fails.
  * Improved undo and redo behavior for callout conversions.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: DarkSky <darksky2048@gmail.com>
This commit is contained in:
cyw
2026-09-20 00:54:30 +08:00
committed by GitHub
co-authored by DarkSky
parent 3747879413
commit 81c8622b95
5 changed files with 247 additions and 12 deletions
@@ -14,6 +14,7 @@ import {
getTextSelectionCommand,
} from '@blocksuite/affine-shared/commands';
import {
isInsideBlockByFlavour,
matchModels,
mergeToCodeModel,
transformModel,
@@ -31,6 +32,11 @@ type UpdateBlockConfig = {
props?: Record<string, unknown>;
};
type UpdateBlockResult = {
updatedBlocks: BlockModel[];
textTargetBlocks?: BlockModel[];
};
export const updateBlockType: Command<
UpdateBlockConfig & {
selectedBlocks?: BlockComponent[];
@@ -121,6 +127,90 @@ export const updateBlockType: Command<
}
return next({ updatedBlocks: [newModel] });
};
const transformToCallout: Command<{}, UpdateBlockResult> = (_, next) => {
if (flavour !== 'affine:callout') return;
const selectedIds = new Set(blockModels.map(model => model.id));
const sourceModels = blockModels.filter(model => {
let parent = doc.getParent(model);
while (parent) {
if (selectedIds.has(parent.id)) return false;
parent = doc.getParent(parent);
}
return true;
});
const plans = sourceModels.flatMap(model => {
if (
!matchModels(model, [
ParagraphBlockModel,
ListBlockModel,
CodeBlockModel,
]) ||
isInsideBlockByFlavour(doc, model, 'affine:callout')
) {
return [];
}
const parent = doc.getParent(model);
if (
!parent ||
parent.children.indexOf(model) === -1 ||
!doc.schema.isValid('affine:callout', parent.flavour) ||
!model.children.every(child =>
doc.schema.isValid(child.flavour, 'affine:paragraph')
)
) {
return [];
}
return [{ model, parent, text: model.text?.clone() }];
});
if (plans.length === 0 || plans.length !== sourceModels.length) {
return next({ updatedBlocks: [] });
}
const conversions: Array<{
source: BlockModel;
callout: BlockModel;
paragraph: BlockModel;
}> = [];
for (const { model, parent, text } of plans) {
const index = parent.children.indexOf(model);
const calloutId = doc.addBlock('affine:callout', {}, parent, index);
const callout = doc.getModelById(calloutId);
if (!callout) {
conversions.forEach(({ callout }) => doc.deleteBlock(callout));
return next({ updatedBlocks: [] });
}
const paragraphId = doc.addBlock('affine:paragraph', { text }, callout);
const paragraph = doc.getModelById(paragraphId);
if (!paragraph) {
doc.deleteBlock(callout);
conversions.forEach(({ callout }) => doc.deleteBlock(callout));
return next({ updatedBlocks: [] });
}
conversions.push({ source: model, callout, paragraph });
}
conversions.forEach(({ source, paragraph }) => {
doc.deleteBlock(
source,
source.children.length > 0
? { bringChildrenTo: paragraph }
: { deleteChildren: false }
);
});
return next({
updatedBlocks: conversions.map(({ callout }) => callout),
textTargetBlocks: conversions.map(({ paragraph }) => paragraph),
});
};
const transformToLatex: Command<{}, { updatedBlocks: BlockModel[] }> = (
_,
next
@@ -154,16 +244,16 @@ export const updateBlockType: Command<
return next({ updatedBlocks: newModels });
};
const focusText: Command<{ updatedBlocks: BlockModel[] }> = (ctx, next) => {
const { updatedBlocks } = ctx;
if (!updatedBlocks || updatedBlocks.length === 0) {
const focusText: Command<UpdateBlockResult> = (ctx, next) => {
const targetBlocks = ctx.textTargetBlocks ?? ctx.updatedBlocks;
if (!targetBlocks || targetBlocks.length === 0) {
return false;
}
const firstNewModel = updatedBlocks[0];
const lastNewModel = updatedBlocks[updatedBlocks.length - 1];
const firstNewModel = targetBlocks[0];
const lastNewModel = targetBlocks[targetBlocks.length - 1];
const allTextUpdated = updatedBlocks.map(model =>
const allTextUpdated = targetBlocks.map(model =>
onModelTextUpdated(std, model)
);
const selectionManager = host.selection;
@@ -194,7 +284,7 @@ export const updateBlockType: Command<
return next();
};
const focusBlock: Command<{ updatedBlocks: BlockModel[] }> = (ctx, next) => {
const focusBlock: Command<UpdateBlockResult> = (ctx, next) => {
const { updatedBlocks } = ctx;
if (!updatedBlocks || updatedBlocks.length === 0) {
return false;
@@ -206,6 +296,7 @@ export const updateBlockType: Command<
if (blockSelections.length === 0) {
return false;
}
requestAnimationFrame(() => {
const selections = updatedBlocks.map(model => {
return selectionManager.create(BlockSelection, {
@@ -246,9 +337,10 @@ export const updateBlockType: Command<
return next();
})
// update block type
.try<{ updatedBlocks: BlockModel[] }>(chain => [
.try<UpdateBlockResult>(chain => [
chain.pipe(mergeToCode),
chain.pipe(appendDivider),
chain.pipe(transformToCallout),
chain.pipe(transformToLatex),
chain.pipe((_, next) => {
const newModels: BlockModel[] = [];
@@ -54,7 +54,10 @@ import {
ActionPlacement,
blockCommentToolbarButton,
} from '@blocksuite/affine-shared/services';
import { getMostCommonValue } from '@blocksuite/affine-shared/utils';
import {
getMostCommonValue,
isInsideBlockByFlavour,
} from '@blocksuite/affine-shared/utils';
import { tableViewMeta } from '@blocksuite/data-view/view-presets';
import {
CopyIcon,
@@ -106,6 +109,10 @@ const conversionsActionGroup = {
.run();
};
const hasModelInsideCallout = selectedModels.some(model =>
isInsideBlockByFlavour(model.store, model, 'affine:callout')
);
return {
content: html`
<editor-menu-button
@@ -121,7 +128,11 @@ const conversionsActionGroup = {
>
<div data-size="large" data-orientation="vertical">
${repeat(
textConversionConfigs.filter(c => c.flavour !== 'affine:divider'),
textConversionConfigs.filter(
c =>
c.flavour !== 'affine:divider' &&
!(hasModelInsideCallout && c.flavour === 'affine:callout')
),
item => item.name,
({ flavour, type, name, icon }) => html`
<editor-menu-action
@@ -13,7 +13,7 @@ import {
QuoteIcon,
TextIcon,
} from '@blocksuite/affine-components/icons';
import { TeXIcon } from '@blocksuite/icons/lit';
import { FontIcon, TeXIcon } from '@blocksuite/icons/lit';
import type { TemplateResult } from 'lit';
/**
@@ -137,6 +137,13 @@ export const textConversionConfigs: TextConversionConfig[] = [
hotkey: null,
icon: QuoteIcon,
},
{
flavour: 'affine:callout',
type: undefined,
name: 'Callout',
hotkey: null,
icon: FontIcon(),
},
{
flavour: 'affine:divider',
type: 'divider',
@@ -24,6 +24,9 @@ export function transformModel(
// Sometimes the new block can not be added due to some reason, e.g. invalid schema check.
// So we need to try to add the new block first, and if it fails, we will not delete the old block.
const id = doc.addBlock(flavour, blockProps, parent, index);
if (!doc.getModelById(id)) {
return null;
}
doc.deleteBlock(model, {
deleteChildren: false,
});
@@ -1,8 +1,14 @@
import { locateToolbar } from '@affine-test/kit/utils/editor';
import {
pressArrowDown,
pressArrowUp,
pressBackspace,
pressEnter,
pressEscape,
pressTab,
selectAllByKeyboard,
undoByKeyboard,
withCtrlOrMeta,
} from '@affine-test/kit/utils/keyboard';
import { openHomePage } from '@affine-test/kit/utils/load-page';
import {
@@ -10,7 +16,19 @@ import {
type,
waitForEmptyEditor,
} from '@affine-test/kit/utils/page-logic';
import { expect, test } from '@playwright/test';
import { expect, type Page, test } from '@playwright/test';
async function openTurnIntoMenu(page: Page) {
await selectAllByKeyboard(page);
const toolbar = locateToolbar(page);
await toolbar.getByLabel('Conversions').click();
return toolbar;
}
async function convertToCallout(page: Page) {
const toolbar = await openTurnIntoMenu(page);
await toolbar.getByLabel('Callout').click();
}
test.beforeEach(async ({ page }) => {
await openHomePage(page);
@@ -74,3 +92,107 @@ test('press backspace in callout block', async ({ page }) => {
await expect(paragraph).toHaveCount(1);
await expect(callout).toHaveCount(0);
});
test('turn into callout preserves text formatting', async ({ page }) => {
await type(page, 'plain ');
await withCtrlOrMeta(page, () => page.keyboard.press('b'));
await type(page, 'bold');
await withCtrlOrMeta(page, () => page.keyboard.press('b'));
await convertToCallout(page);
const callout = page.locator('affine-callout');
await expect(callout).toHaveCount(1);
const innerParagraph = page.locator('affine-callout affine-paragraph');
await expect(innerParagraph).toHaveCount(1);
await expect(innerParagraph.locator('v-line')).toHaveText('plain bold');
await expect(
innerParagraph
.locator('v-element', { hasText: 'bold' })
.locator('span')
.last()
).toHaveCSS('font-weight', '700');
});
test('turn into callout: nested list items remain reachable after conversion', async ({
page,
}) => {
await type(page, '- parent item');
await pressEnter(page);
await pressTab(page);
await type(page, 'child item');
await pressArrowUp(page);
await convertToCallout(page);
const callout = page.locator('affine-callout');
await expect(callout).toHaveCount(1);
await expect(callout).toContainText('parent item');
await expect(callout).toContainText('child item');
await expect(
callout.locator('affine-paragraph affine-list', {
hasText: 'child item',
})
).toHaveCount(1);
});
test('turn into callout is unavailable for descendants of a callout', async ({
page,
}) => {
await type(page, '/callout\n- parent item');
await pressEnter(page);
await pressTab(page);
await type(page, 'child item');
const callout = page.locator('affine-callout');
await expect(callout).toHaveCount(1);
const toolbar = await openTurnIntoMenu(page);
await expect(toolbar.getByLabel('Callout')).toHaveCount(0);
await pressEscape(page);
await expect(callout).toContainText('parent item');
await expect(callout).toContainText('child item');
await expect(callout).toHaveCount(1);
});
test('turn into callout: delete after conversion removes the whole callout', async ({
page,
}) => {
await type(page, 'delete me');
await pressEscape(page);
const toolbar = locateToolbar(page);
await toolbar.getByLabel('Conversions').click();
await toolbar.getByLabel('Callout').click();
const callout = page.locator('affine-callout');
await expect(callout).toHaveCount(1);
await page.keyboard.press('Backspace');
await expect(callout).toHaveCount(0);
});
test('turn into callout preserves the block tree across undo and redo', async ({
page,
}) => {
await type(page, 'undo me');
await convertToCallout(page);
const callout = page.locator('affine-callout');
await expect(callout).toHaveCount(1);
await undoByKeyboard(page);
await expect(callout).toHaveCount(0);
const paragraph = page.locator('affine-note affine-paragraph');
await expect(paragraph).toContainText('undo me');
if (process.platform === 'darwin') {
await withCtrlOrMeta(page, () => page.keyboard.press('Shift+z'));
} else {
await page.keyboard.press('Control+y');
}
await expect(callout).toHaveCount(1);
await expect(callout).toContainText('undo me');
});