fix(editor): update card style after dragging it to note (#12660)

Close [BS-3148](https://linear.app/affine-design/issue/BS-3148/拖拽到note后,更新card样式)

### What Changes
- fix the style of card not updated after draggin it from canvas to note
- narrow type of specific card style by using `as const satisfies EmbedCardStyle[]`
- add type hint to the `props`, the second parameter  of `store.updateBlock`

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

- **New Features**
  - Added middleware to automatically update card styles when dragging blocks into notes.
- **Bug Fixes**
  - Ensured that dragging a bookmark card into a note preserves its style.
- **Tests**
  - Introduced an end-to-end test to verify bookmark card style is retained after drag-and-drop.
- **Refactor**
  - Enhanced type safety and clarity for card style configurations and block properties.
- **Chores**
  - Refined type annotations and assertions across multiple block style constants and toolbar configurations.
  - Improved generic typing for block update methods to increase type precision.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
L-Sun
2025-05-30 10:55:28 +00:00
parent 887a496f8b
commit 10da3ad28e
21 changed files with 213 additions and 98 deletions

View File

@@ -407,7 +407,7 @@ const builtinSurfaceToolbarConfig = {
if (options?.viewType !== 'embed') return; if (options?.viewType !== 'embed') return;
const { flavour, styles } = options; const { flavour, styles } = options;
let { style } = model.props; let style: EmbedCardStyle = model.props.style;
if (!styles.includes(style)) { if (!styles.includes(style)) {
style = styles[0]; style = styles[0];
@@ -482,24 +482,26 @@ const builtinSurfaceToolbarConfig = {
} satisfies ToolbarActionGroup<ToolbarAction>, } satisfies ToolbarActionGroup<ToolbarAction>,
{ {
id: 'b.style', id: 'b.style',
actions: [ actions: (
{ [
id: 'horizontal', {
label: 'Large horizontal style', id: 'horizontal',
}, label: 'Large horizontal style',
{ },
id: 'list', {
label: 'Small horizontal style', id: 'list',
}, label: 'Small horizontal style',
{ },
id: 'vertical', {
label: 'Large vertical style', id: 'vertical',
}, label: 'Large vertical style',
{ },
id: 'cube', {
label: 'Small vertical style', id: 'cube',
}, label: 'Small vertical style',
].filter(action => BookmarkStyles.includes(action.id as EmbedCardStyle)), },
] as const
).filter(action => BookmarkStyles.includes(action.id)),
content(ctx) { content(ctx) {
const model = ctx.getCurrentModelByType(BookmarkBlockModel); const model = ctx.getCurrentModelByType(BookmarkBlockModel);
if (!model) return null; if (!model) return null;

View File

@@ -259,18 +259,18 @@ const builtinToolbarConfig = {
conversionsActionGroup, conversionsActionGroup,
{ {
id: 'c.style', id: 'c.style',
actions: [ actions: (
{ [
id: 'horizontal', {
label: 'Large horizontal style', id: 'horizontal',
}, label: 'Large horizontal style',
{ },
id: 'list', {
label: 'Small horizontal style', id: 'list',
}, label: 'Small horizontal style',
].filter(action => },
EmbedLinkedDocStyles.includes(action.id as EmbedCardStyle) ] as const
), ).filter(action => EmbedLinkedDocStyles.includes(action.id)),
content(ctx) { content(ctx) {
const model = ctx.getCurrentModelByType(EmbedLinkedDocModel); const model = ctx.getCurrentModelByType(EmbedLinkedDocModel);
if (!model) return null; if (!model) return null;
@@ -368,26 +368,26 @@ const builtinSurfaceToolbarConfig = {
conversionsActionGroup, conversionsActionGroup,
{ {
id: 'c.style', id: 'c.style',
actions: [ actions: (
{ [
id: 'horizontal', {
label: 'Large horizontal style', id: 'horizontal',
}, label: 'Large horizontal style',
{ },
id: 'list', {
label: 'Small horizontal style', id: 'list',
}, label: 'Small horizontal style',
{ },
id: 'vertical', {
label: 'Large vertical style', id: 'vertical',
}, label: 'Large vertical style',
{ },
id: 'cube', {
label: 'Small vertical style', id: 'cube',
}, label: 'Small vertical style',
].filter(action => },
EmbedLinkedDocStyles.includes(action.id as EmbedCardStyle) ] as const
), ).filter(action => EmbedLinkedDocStyles.includes(action.id)),
content(ctx) { content(ctx) {
const model = ctx.getCurrentModelByType(EmbedLinkedDocModel); const model = ctx.getCurrentModelByType(EmbedLinkedDocModel);
if (!model) return null; if (!model) return null;

View File

@@ -153,7 +153,7 @@ function createBuiltinToolbarConfigForExternal(
.get(EmbedOptionProvider) .get(EmbedOptionProvider)
.getEmbedBlockOptions(url); .getEmbedBlockOptions(url);
let { style } = model.props; let style: EmbedCardStyle = model.props.style;
let flavour = 'affine:bookmark'; let flavour = 'affine:bookmark';
if (options?.viewType === 'card') { if (options?.viewType === 'card') {
@@ -227,7 +227,7 @@ function createBuiltinToolbarConfigForExternal(
if (options?.viewType !== 'embed') return; if (options?.viewType !== 'embed') return;
const { flavour, styles } = options; const { flavour, styles } = options;
let { style } = model.props; let style: EmbedCardStyle = model.props.style;
if (!styles.includes(style)) { if (!styles.includes(style)) {
style = style =
@@ -441,7 +441,11 @@ const createBuiltinSurfaceToolbarConfigForExternal = (
let { style } = model.props; let { style } = model.props;
let flavour = 'affine:bookmark'; let flavour = 'affine:bookmark';
if (!BookmarkStyles.includes(style)) { if (
!BookmarkStyles.includes(
style as (typeof BookmarkStyles)[number]
)
) {
style = BookmarkStyles[0]; style = BookmarkStyles[0];
} }
@@ -517,26 +521,26 @@ const createBuiltinSurfaceToolbarConfigForExternal = (
} satisfies ToolbarActionGroup<ToolbarAction>, } satisfies ToolbarActionGroup<ToolbarAction>,
{ {
id: 'c.style', id: 'c.style',
actions: [ actions: (
{ [
id: 'horizontal', {
label: 'Large horizontal style', id: 'horizontal',
}, label: 'Large horizontal style',
{ },
id: 'list', {
label: 'Small horizontal style', id: 'list',
}, label: 'Small horizontal style',
{ },
id: 'vertical', {
label: 'Large vertical style', id: 'vertical',
}, label: 'Large vertical style',
{ },
id: 'cube', {
label: 'Small vertical style', id: 'cube',
}, label: 'Small vertical style',
].filter(action => },
EmbedGithubStyles.includes(action.id as EmbedCardStyle) ] as const
), ).filter(action => EmbedGithubStyles.includes(action.id)),
when(ctx) { when(ctx) {
return Boolean(ctx.getCurrentModelByType(EmbedGithubModel)); return Boolean(ctx.getCurrentModelByType(EmbedGithubModel));
}, },

View File

@@ -9,7 +9,10 @@ import {
getSurfaceComponent, getSurfaceComponent,
} from '@blocksuite/affine-block-surface'; } from '@blocksuite/affine-block-surface';
import { splitIntoLines } from '@blocksuite/affine-gfx-text'; import { splitIntoLines } from '@blocksuite/affine-gfx-text';
import type { ShapeElementModel } from '@blocksuite/affine-model'; import type {
EmbedCardStyle,
ShapeElementModel,
} from '@blocksuite/affine-model';
import { import {
BookmarkStyles, BookmarkStyles,
DEFAULT_NOTE_HEIGHT, DEFAULT_NOTE_HEIGHT,
@@ -236,7 +239,7 @@ export class EdgelessClipboardController extends PageClipboard {
const options: Record<string, unknown> = {}; const options: Record<string, unknown> = {};
let flavour = 'affine:bookmark'; let flavour = 'affine:bookmark';
let style = BookmarkStyles[0]; let style: EmbedCardStyle = BookmarkStyles[0];
let isInternalLink = false; let isInternalLink = false;
let isLinkedBlock = false; let isLinkedBlock = false;

View File

@@ -30,11 +30,12 @@ import { AttachmentBlockTransformer } from './attachment-transformer.js';
*/ */
type BackwardCompatibleUndefined = undefined; type BackwardCompatibleUndefined = undefined;
export const AttachmentBlockStyles: EmbedCardStyle[] = [ export const AttachmentBlockStyles = [
'cubeThick', 'cubeThick',
'horizontalThin', 'horizontalThin',
'pdf', 'pdf',
] as const; 'citation',
] as const satisfies EmbedCardStyle[];
export type AttachmentBlockProps = { export type AttachmentBlockProps = {
name: string; name: string;

View File

@@ -15,13 +15,13 @@ import type {
LinkPreviewData, LinkPreviewData,
} from '../../utils/index.js'; } from '../../utils/index.js';
export const BookmarkStyles: EmbedCardStyle[] = [ export const BookmarkStyles = [
'vertical', 'vertical',
'horizontal', 'horizontal',
'list', 'list',
'cube', 'cube',
'citation', 'citation',
] as const; ] as const satisfies EmbedCardStyle[];
export type BookmarkBlockProps = { export type BookmarkBlockProps = {
style: (typeof BookmarkStyles)[number]; style: (typeof BookmarkStyles)[number];

View File

@@ -8,7 +8,7 @@ export type EmbedFigmaBlockUrlData = {
description: string | null; description: string | null;
}; };
export const EmbedFigmaStyles: EmbedCardStyle[] = ['figma'] as const; export const EmbedFigmaStyles = ['figma'] as const satisfies EmbedCardStyle[];
export type EmbedFigmaBlockProps = { export type EmbedFigmaBlockProps = {
style: (typeof EmbedFigmaStyles)[number]; style: (typeof EmbedFigmaStyles)[number];

View File

@@ -13,12 +13,12 @@ export type EmbedGithubBlockUrlData = {
assignees: string[] | null; assignees: string[] | null;
}; };
export const EmbedGithubStyles: EmbedCardStyle[] = [ export const EmbedGithubStyles = [
'vertical', 'vertical',
'horizontal', 'horizontal',
'list', 'list',
'cube', 'cube',
] as const; ] as const satisfies EmbedCardStyle[];
export type EmbedGithubBlockProps = { export type EmbedGithubBlockProps = {
style: (typeof EmbedGithubStyles)[number]; style: (typeof EmbedGithubStyles)[number];

View File

@@ -3,7 +3,7 @@ import { BlockModel } from '@blocksuite/store';
import type { EmbedCardStyle } from '../../../utils/index.js'; import type { EmbedCardStyle } from '../../../utils/index.js';
import { defineEmbedModel } from '../../../utils/index.js'; import { defineEmbedModel } from '../../../utils/index.js';
export const EmbedHtmlStyles: EmbedCardStyle[] = ['html'] as const; export const EmbedHtmlStyles = ['html'] as const satisfies EmbedCardStyle[];
export type EmbedHtmlBlockProps = { export type EmbedHtmlBlockProps = {
style: (typeof EmbedHtmlStyles)[number]; style: (typeof EmbedHtmlStyles)[number];

View File

@@ -7,7 +7,7 @@ import { BlockModel } from '@blocksuite/store';
import { type EmbedCardStyle } from '../../../utils/index.js'; import { type EmbedCardStyle } from '../../../utils/index.js';
export const EmbedIframeStyles: EmbedCardStyle[] = ['figma'] as const; export const EmbedIframeStyles = ['figma'] as const satisfies EmbedCardStyle[];
export type EmbedIframeBlockProps = { export type EmbedIframeBlockProps = {
url: string; // the original url that user input url: string; // the original url that user input

View File

@@ -4,17 +4,17 @@ import type { ReferenceInfo } from '../../../consts/doc.js';
import type { EmbedCardStyle } from '../../../utils/index.js'; import type { EmbedCardStyle } from '../../../utils/index.js';
import { defineEmbedModel } from '../../../utils/index.js'; import { defineEmbedModel } from '../../../utils/index.js';
export const EmbedLinkedDocStyles: EmbedCardStyle[] = [ export const EmbedLinkedDocStyles = [
'vertical', 'vertical',
'horizontal', 'horizontal',
'list', 'list',
'cube', 'cube',
'horizontalThin', 'horizontalThin',
'citation', 'citation',
]; ] as const satisfies EmbedCardStyle[];
export type EmbedLinkedDocBlockProps = { export type EmbedLinkedDocBlockProps = {
style: EmbedCardStyle; style: (typeof EmbedLinkedDocStyles)[number];
caption: string | null; caption: string | null;
footnoteIdentifier: string | null; footnoteIdentifier: string | null;
} & ReferenceInfo; } & ReferenceInfo;

View File

@@ -10,7 +10,7 @@ export type EmbedLoomBlockUrlData = {
description: string | null; description: string | null;
}; };
export const EmbedLoomStyles: EmbedCardStyle[] = ['video'] as const; export const EmbedLoomStyles = ['video'] as const satisfies EmbedCardStyle[];
export type EmbedLoomBlockProps = { export type EmbedLoomBlockProps = {
style: (typeof EmbedLoomStyles)[number]; style: (typeof EmbedLoomStyles)[number];

View File

@@ -5,7 +5,9 @@ import type { ReferenceInfo } from '../../../consts/doc.js';
import type { EmbedCardStyle } from '../../../utils/index.js'; import type { EmbedCardStyle } from '../../../utils/index.js';
import { defineEmbedModel } from '../../../utils/index.js'; import { defineEmbedModel } from '../../../utils/index.js';
export const EmbedSyncedDocStyles: EmbedCardStyle[] = ['syncedDoc']; export const EmbedSyncedDocStyles = [
'syncedDoc',
] as const satisfies EmbedCardStyle[];
export type EmbedSyncedDocBlockProps = { export type EmbedSyncedDocBlockProps = {
style: EmbedCardStyle; style: EmbedCardStyle;

View File

@@ -1,6 +1,7 @@
import type { GfxModel } from '@blocksuite/std/gfx'; import type { GfxModel } from '@blocksuite/std/gfx';
import type { BlockModel } from '@blocksuite/store'; import type { BlockModel } from '@blocksuite/store';
import type { BookmarkBlockModel } from '../bookmark';
import { EmbedFigmaModel } from './figma'; import { EmbedFigmaModel } from './figma';
import { EmbedGithubModel } from './github'; import { EmbedGithubModel } from './github';
import type { EmbedHtmlModel } from './html'; import type { EmbedHtmlModel } from './html';
@@ -30,7 +31,10 @@ export type EmbedCardModel = InstanceType<
ExternalEmbedModel | InternalEmbedModel ExternalEmbedModel | InternalEmbedModel
>; >;
export type LinkableEmbedModel = EmbedCardModel | EmbedIframeBlockModel; export type LinkableEmbedModel =
| EmbedCardModel
| EmbedIframeBlockModel
| BookmarkBlockModel;
export type BuiltInEmbedModel = EmbedCardModel | EmbedHtmlModel; export type BuiltInEmbedModel = EmbedCardModel | EmbedHtmlModel;

View File

@@ -13,7 +13,7 @@ export type EmbedYoutubeBlockUrlData = {
creatorImage: string | null; creatorImage: string | null;
}; };
export const EmbedYoutubeStyles: EmbedCardStyle[] = ['video'] as const; export const EmbedYoutubeStyles = ['video'] as const satisfies EmbedCardStyle[];
export type EmbedYoutubeBlockProps = { export type EmbedYoutubeBlockProps = {
style: (typeof EmbedYoutubeStyles)[number]; style: (typeof EmbedYoutubeStyles)[number];

View File

@@ -0,0 +1,56 @@
import {
AttachmentBlockModel,
BookmarkBlockModel,
EmbedGithubModel,
EmbedLinkedDocModel,
NoteBlockModel,
} from '@blocksuite/affine-model';
import { matchModels } from '@blocksuite/affine-shared/utils';
import type { BlockStdScope } from '@blocksuite/std';
import type { TransformerMiddleware } from '@blocksuite/store';
export const cardStyleUpdater =
(std: BlockStdScope): TransformerMiddleware =>
({ slots }) => {
slots.beforeImport.subscribe(payload => {
if (payload.type !== 'block' || !payload.parent) return;
const parentModel = std.store.getModelById(payload.parent);
if (!matchModels(parentModel, [NoteBlockModel])) return;
// TODO(@L-Sun): Refactor this after refactor `store.moveBlocks`
// Currently, drag a block will use store.moveBlocks to update the tree of blocks
// but the instance of it is not changed.
// So change the style of snapshot.props in the middleware is not working.
// Instead, we can change the style of the model instance in the middleware,
const model = std.store.getModelById(payload.snapshot.id);
if (!model) return;
if (model instanceof AttachmentBlockModel) {
std.store.updateBlock(model, {
style: 'horizontalThin',
});
return;
}
if (model instanceof BookmarkBlockModel) {
std.store.updateBlock(model, {
style: 'horizontal',
});
return;
}
if (model instanceof EmbedGithubModel) {
std.store.updateBlock(model, {
style: 'horizontal',
});
return;
}
if (model instanceof EmbedLinkedDocModel) {
std.store.updateBlock(model, {
style: 'horizontal',
});
return;
}
});
};

View File

@@ -76,6 +76,7 @@ import last from 'lodash-es/last';
import type { AffineDragHandleWidget } from '../drag-handle.js'; import type { AffineDragHandleWidget } from '../drag-handle.js';
import { PreviewHelper } from '../helpers/preview-helper.js'; import { PreviewHelper } from '../helpers/preview-helper.js';
import { gfxBlocksFilter } from '../middleware/blocks-filter.js'; import { gfxBlocksFilter } from '../middleware/blocks-filter.js';
import { cardStyleUpdater } from '../middleware/card-style-updater.js';
import { newIdCrossDoc } from '../middleware/new-id-cross-doc.js'; import { newIdCrossDoc } from '../middleware/new-id-cross-doc.js';
import { reorderList } from '../middleware/reorder-list'; import { reorderList } from '../middleware/reorder-list';
import { import {
@@ -1433,6 +1434,7 @@ export class DragEventWatcher {
newIdCrossDoc(std), newIdCrossDoc(std),
reorderList(std), reorderList(std),
surfaceRefToEmbed(std), surfaceRefToEmbed(std),
cardStyleUpdater(std),
]; ];
if (selectedIds) { if (selectedIds) {

View File

@@ -566,23 +566,29 @@ Optional flag to insert before sibling
### updateBlock() ### updateBlock()
> **updateBlock**(`modelOrId`, `callBackOrProps`): `void` > **updateBlock**\<`T`\>(`modelOrId`, `callBackOrProps`): `void`
Updates a block's properties or executes a callback in a transaction Updates a block's properties or executes a callback in a transaction
#### Type Parameters
##### T
`T` *extends* `BlockModel`\<`object`\> = `BlockModel`\<`object`\>
#### Parameters #### Parameters
##### modelOrId ##### modelOrId
The block model or block ID to update The block model or block ID to update
`string` | `BlockModel`\<`object`\> `string` | `T`
##### callBackOrProps ##### callBackOrProps
Either a callback function to execute or properties to update Either a callback function to execute or properties to update
`Partial`\<`BlockProps`\> | () => `void` () => `void` | `Partial`\<`BlockProps` \| `PropsOfModel`\<`T`\> & `BlockSysProps`\>
#### Returns #### Returns

View File

@@ -19,3 +19,5 @@ export type BlockSysProps = {
children?: BlockModel[]; children?: BlockModel[];
}; };
export type BlockProps = BlockSysProps & Record<string, unknown>; export type BlockProps = BlockSysProps & Record<string, unknown>;
export type PropsOfModel<T> = T extends BlockModel<infer P> ? P : never;

View File

@@ -22,6 +22,8 @@ import {
type BlockModel, type BlockModel,
type BlockOptions, type BlockOptions,
type BlockProps, type BlockProps,
type BlockSysProps,
type PropsOfModel,
type YBlock, type YBlock,
} from '../block/index.js'; } from '../block/index.js';
import { DocCRUD } from './crud.js'; import { DocCRUD } from './crud.js';
@@ -852,9 +854,12 @@ export class Store {
* *
* @category Block CRUD * @category Block CRUD
*/ */
updateBlock(
modelOrId: BlockModel | string, updateBlock<T extends BlockModel = BlockModel>(
callBackOrProps: (() => void) | Partial<BlockProps> modelOrId: T | string,
callBackOrProps:
| (() => void)
| Partial<(PropsOfModel<T> & BlockSysProps) | BlockProps>
) { ) {
if (this.readonly) { if (this.readonly) {
console.error('cannot modify data in readonly mode'); console.error('cannot modify data in readonly mode');

View File

@@ -1,5 +1,6 @@
import './utils/declare-test-window.js'; import './utils/declare-test-window.js';
import type { BookmarkBlockComponent } from '@blocksuite/affine/blocks/bookmark';
import type { BlockSnapshot } from '@blocksuite/store'; import type { BlockSnapshot } from '@blocksuite/store';
import type { Page } from '@playwright/test'; import type { Page } from '@playwright/test';
import { expect } from '@playwright/test'; import { expect } from '@playwright/test';
@@ -8,7 +9,9 @@ import {
activeNoteInEdgeless, activeNoteInEdgeless,
clickView, clickView,
copyByKeyboard, copyByKeyboard,
createNote,
dragBlockToPoint, dragBlockToPoint,
edgelessCommonSetup,
enterPlaygroundRoom, enterPlaygroundRoom,
expectConsoleMessage, expectConsoleMessage,
focusRichText, focusRichText,
@@ -500,3 +503,28 @@ test.describe('embed github card', () => {
await expect(banner).toBeVisible(); await expect(banner).toBeVisible();
}); });
}); });
test('drag a card from canvas to note should not change the style of the card', async ({
page,
}) => {
const url = 'https://github.com/toeverything/AFFiNE/pull/12660';
await edgelessCommonSetup(page);
await createNote(page, [-100, -300]);
await page.locator('edgeless-link-tool-button').click();
await page.locator('.embed-card-modal-input').fill(url);
await pressEnter(page);
const edgelessBookmark = page.locator('affine-edgeless-bookmark');
await edgelessBookmark.click();
await waitNextFrame(page);
const dragHandle = page.locator('.affine-drag-handle-container');
await dragHandle.dragTo(page.locator('affine-edgeless-note'));
const noteBookmark = page.locator('affine-bookmark');
await expect(noteBookmark).toBeVisible();
const style = await noteBookmark.evaluate(
(el: BookmarkBlockComponent) => el.model.props.style
);
expect(style).toBe('horizontal');
});