mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-21 11:13:20 +08:00
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:
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user