refactor(editor): add helper function for undo notification (#11903)

### What Changes
- Refactors the `notify` function call with undo button. It is called `notifyWithUndo`, which can  associate the visibility of the `NotificationCard` with the top undo item in history stack, such as undo by shortcut `Cmd+Z`.
- change icon of the "Insert into page" button. Close [BS-3267](https://linear.app/affine-design/issue/BS-3267/frame和group的insert-into-page图标也更换一下)
This commit is contained in:
L-Sun
2025-04-23 02:43:56 +00:00
committed by L-Sun
parent 27ff9ab9f4
commit 9dbdd4b7ba
14 changed files with 136 additions and 157 deletions
@@ -11,7 +11,7 @@ import {
import { EMBED_CARD_HEIGHT } from '@blocksuite/affine-shared/consts'; import { EMBED_CARD_HEIGHT } from '@blocksuite/affine-shared/consts';
import { NotificationProvider } from '@blocksuite/affine-shared/services'; import { NotificationProvider } from '@blocksuite/affine-shared/services';
import { matchModels } from '@blocksuite/affine-shared/utils'; import { matchModels } from '@blocksuite/affine-shared/utils';
import { BlockStdScope, EditorLifeCycleExtension } from '@blocksuite/std'; import { BlockStdScope } from '@blocksuite/std';
import { import {
type BlockModel, type BlockModel,
type BlockSnapshot, type BlockSnapshot,
@@ -337,45 +337,12 @@ export function promptDocTitle(std: BlockStdScope, autofill?: string) {
}); });
} }
export function notifyDocCreated(std: BlockStdScope, doc: Store) { export function notifyDocCreated(std: BlockStdScope) {
const notification = std.getOptional(NotificationProvider); std.getOptional(NotificationProvider)?.notifyWithUndoAction({
if (!notification) return;
const abortController = new AbortController();
const clear = () => {
doc.history.off('stack-item-added', addHandler);
doc.history.off('stack-item-popped', popHandler);
disposable.unsubscribe();
};
const closeNotify = () => {
abortController.abort();
clear();
};
// edit or undo or switch doc, close notify toast
const addHandler = doc.history.on('stack-item-added', closeNotify);
const popHandler = doc.history.on('stack-item-popped', closeNotify);
const disposable = std
.get(EditorLifeCycleExtension)
.slots.unmounted.subscribe(closeNotify);
notification.notify({
title: 'Linked doc created', title: 'Linked doc created',
message: 'You can click undo to recovery block content', message: 'You can click undo to recovery block content',
accent: 'info', accent: 'info',
duration: 10 * 1000, duration: 10 * 1000,
actions: [
{
key: 'undo',
label: 'Undo',
onClick: () => {
doc.undo();
clear();
},
},
],
abort: abortController.signal,
onClose: clear,
}); });
} }
@@ -230,7 +230,7 @@ export const builtinToolbarConfig = {
draftedModels, draftedModels,
title title
); );
notifyDocCreated(std, store); notifyDocCreated(std);
track('DocCreated', { track('DocCreated', {
segment: 'doc', segment: 'doc',
@@ -16,6 +16,7 @@ import {
SurfaceRefBlockSchema, SurfaceRefBlockSchema,
} from '@blocksuite/affine-model'; } from '@blocksuite/affine-model';
import { import {
NotificationProvider,
type ToolbarContext, type ToolbarContext,
type ToolbarModuleConfig, type ToolbarModuleConfig,
ToolbarModuleExtension, ToolbarModuleExtension,
@@ -26,7 +27,11 @@ import {
} from '@blocksuite/affine-shared/utils'; } from '@blocksuite/affine-shared/utils';
import { mountFrameTitleEditor } from '@blocksuite/affine-widget-frame-title'; import { mountFrameTitleEditor } from '@blocksuite/affine-widget-frame-title';
import { Bound } from '@blocksuite/global/gfx'; import { Bound } from '@blocksuite/global/gfx';
import { EditIcon, PageIcon, UngroupIcon } from '@blocksuite/icons/lit'; import {
EditIcon,
InsertIntoPageIcon,
UngroupIcon,
} from '@blocksuite/icons/lit';
import { type BlockComponent, BlockFlavourIdentifier } from '@blocksuite/std'; import { type BlockComponent, BlockFlavourIdentifier } from '@blocksuite/std';
import { GfxControllerIdentifier } from '@blocksuite/std/gfx'; import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
import type { ExtensionType } from '@blocksuite/store'; import type { ExtensionType } from '@blocksuite/store';
@@ -46,9 +51,8 @@ const builtinSurfaceToolbarConfig = {
{ {
id: 'a.insert-into-page', id: 'a.insert-into-page',
label: 'Insert into Page', label: 'Insert into Page',
showLabel: true,
tooltip: 'Insert into Page', tooltip: 'Insert into Page',
icon: PageIcon(), icon: InsertIntoPageIcon(),
when: ctx => ctx.getSurfaceModelsByType(FrameBlockModel).length === 1, when: ctx => ctx.getSurfaceModelsByType(FrameBlockModel).length === 1,
run(ctx) { run(ctx) {
const model = ctx.getCurrentModelByType(FrameBlockModel); const model = ctx.getCurrentModelByType(FrameBlockModel);
@@ -78,13 +82,23 @@ const builtinSurfaceToolbarConfig = {
); );
} }
ctx.store.captureSync();
ctx.store.addBlock( ctx.store.addBlock(
SurfaceRefBlockSchema.model.flavour, SurfaceRefBlockSchema.model.flavour,
{ reference: frameId, refFlavour: FrameBlockSchema.model.flavour }, { reference: frameId, refFlavour: FrameBlockSchema.model.flavour },
lastNoteId lastNoteId
); );
toast(ctx.host, 'Frame has been inserted into doc'); const notification = ctx.std.getOptional(NotificationProvider);
if (notification) {
notification.notifyWithUndoAction({
title: 'Frame inserted into Page.',
message: 'Frame has been inserted into doc',
accent: 'success',
});
} else {
toast(ctx.host, 'Frame has been inserted into doc');
}
}, },
}, },
{ {
@@ -31,10 +31,7 @@ import {
LinkedPageIcon, LinkedPageIcon,
ScissorsIcon, ScissorsIcon,
} from '@blocksuite/icons/lit'; } from '@blocksuite/icons/lit';
import { import { BlockFlavourIdentifier } from '@blocksuite/std';
BlockFlavourIdentifier,
EditorLifeCycleExtension,
} from '@blocksuite/std';
import type { ExtensionType } from '@blocksuite/store'; import type { ExtensionType } from '@blocksuite/store';
import { computed } from '@preact/signals-core'; import { computed } from '@preact/signals-core';
import { html } from 'lit'; import { html } from 'lit';
@@ -504,34 +501,6 @@ function setDisplayMode(
ctx.selection.clear(); ctx.selection.clear();
} }
const abortController = new AbortController();
const clear = () => {
ctx.history.off('stack-item-added', addHandler);
ctx.history.off('stack-item-popped', popHandler);
disposable.unsubscribe();
};
const closeNotify = () => {
abortController.abort();
clear();
};
const addHandler = ctx.history.on('stack-item-added', closeNotify);
const popHandler = ctx.history.on('stack-item-popped', closeNotify);
const disposable = ctx.std
.get(EditorLifeCycleExtension)
.slots.unmounted.subscribe(closeNotify);
const undo = () => {
ctx.store.undo();
closeNotify();
};
const viewInToc = () => {
const sidebar = ctx.std.getOptional(SidebarExtensionIdentifier);
sidebar?.open('outline');
closeNotify();
};
const data = const data =
newMode === NoteDisplayMode.EdgelessOnly newMode === NoteDisplayMode.EdgelessOnly
? { ? {
@@ -544,27 +513,21 @@ function setDisplayMode(
}; };
const notification = ctx.std.getOptional(NotificationProvider); const notification = ctx.std.getOptional(NotificationProvider);
notification?.notify({ notification?.notifyWithUndoAction({
title: data.title, title: data.title,
message: `${data.message} Find it in the TOC for quick navigation.`, message: `${data.message} Find it in the TOC for quick navigation.`,
accent: 'success', accent: 'success',
duration: 5 * 1000, duration: 5 * 1000,
actions: [ actions: [
{
key: 'undo-display-in-page',
label: 'Undo',
onClick: () => undo(),
},
{ {
key: 'view-in-toc', key: 'view-in-toc',
label: 'View in Toc', label: 'View in Toc',
onClick: () => viewInToc(), onClick: () => {
const sidebar = ctx.std.getOptional(SidebarExtensionIdentifier);
sidebar?.open('outline');
},
}, },
], ],
abort: abortController.signal,
onClose: () => {
clear();
},
}); });
ctx.track('NoteDisplayModeChanged', { ctx.track('NoteDisplayModeChanged', {
@@ -58,7 +58,7 @@ export const quickActionConfig: QuickActionConfig[] = [
draftedModels, draftedModels,
title title
).catch(console.error); ).catch(console.error);
notifyDocCreated(std, doc); notifyDocCreated(std);
}) })
.catch(console.error); .catch(console.error);
}, },
@@ -244,7 +244,7 @@ const turnIntoLinkedDoc = {
draftedModels, draftedModels,
title title
); );
notifyDocCreated(std, store); notifyDocCreated(std);
track('DocCreated', { track('DocCreated', {
segment: 'doc', segment: 'doc',
@@ -347,7 +347,7 @@ export const moreActions = [
other: 'new doc', other: 'new doc',
}); });
notifyDocCreated(ctx.std, ctx.store); notifyDocCreated(ctx.std);
}; };
create().catch(console.error); create().catch(console.error);
@@ -156,7 +156,7 @@ export class PageKeyboardManager {
draftedModels, draftedModels,
title title
).catch(console.error); ).catch(console.error);
notifyDocCreated(rootComponent.std, doc); notifyDocCreated(rootComponent.std);
}) })
.catch(console.error); .catch(console.error);
} }
@@ -1,52 +1,22 @@
import { NotificationProvider } from '@blocksuite/affine-shared/services'; import { NotificationProvider } from '@blocksuite/affine-shared/services';
import { type BlockStdScope, EditorLifeCycleExtension } from '@blocksuite/std'; import { type BlockStdScope } from '@blocksuite/std';
import { toast } from '../toast/toast.js'; import { toast } from '../toast/toast.js';
function notify(std: BlockStdScope, title: string, message: string) { function notify(std: BlockStdScope, title: string, message: string) {
const notification = std.getOptional(NotificationProvider); const notification = std.getOptional(NotificationProvider);
const { store: doc, host } = std; const { host } = std;
if (!notification) { if (!notification) {
toast(host, title); toast(host, title);
return; return;
} }
const abortController = new AbortController(); notification.notifyWithUndoAction({
const clear = () => {
doc.history.off('stack-item-added', addHandler);
doc.history.off('stack-item-popped', popHandler);
disposable.unsubscribe();
};
const closeNotify = () => {
abortController.abort();
clear();
};
// edit or undo or switch doc, close notify toast
const addHandler = doc.history.on('stack-item-added', closeNotify);
const popHandler = doc.history.on('stack-item-popped', closeNotify);
const disposable = host.std
.get(EditorLifeCycleExtension)
.slots.unmounted.subscribe(closeNotify);
notification.notify({
title, title,
message, message,
accent: 'info', accent: 'info',
duration: 10 * 1000, duration: 10 * 1000,
actions: [
{
key: 'undo',
label: 'Undo',
onClick: () => {
doc.undo();
clear();
},
},
],
abort: abortController.signal,
onClose: clear,
}); });
} }
@@ -14,7 +14,11 @@ import {
import { matchModels } from '@blocksuite/affine-shared/utils'; import { matchModels } from '@blocksuite/affine-shared/utils';
import { getRootBlock } from '@blocksuite/affine-widget-edgeless-toolbar'; import { getRootBlock } from '@blocksuite/affine-widget-edgeless-toolbar';
import { Bound } from '@blocksuite/global/gfx'; import { Bound } from '@blocksuite/global/gfx';
import { EditIcon, PageIcon, UngroupIcon } from '@blocksuite/icons/lit'; import {
EditIcon,
InsertIntoPageIcon,
UngroupIcon,
} from '@blocksuite/icons/lit';
import { BlockFlavourIdentifier } from '@blocksuite/std'; import { BlockFlavourIdentifier } from '@blocksuite/std';
import { ungroupCommand } from '../command'; import { ungroupCommand } from '../command';
@@ -25,9 +29,8 @@ export const groupToolbarConfig = {
{ {
id: 'a.insert-into-page', id: 'a.insert-into-page',
label: 'Insert into Page', label: 'Insert into Page',
showLabel: true,
tooltip: 'Insert into Page', tooltip: 'Insert into Page',
icon: PageIcon(), icon: InsertIntoPageIcon(),
when: ctx => ctx.getSurfaceModelsByType(GroupElementModel).length === 1, when: ctx => ctx.getSurfaceModelsByType(GroupElementModel).length === 1,
run(ctx) { run(ctx) {
const model = ctx.getCurrentModelByType(GroupElementModel); const model = ctx.getCurrentModelByType(GroupElementModel);
@@ -1,5 +1,6 @@
import { createIdentifier } from '@blocksuite/global/di'; import { createIdentifier, type ServiceProvider } from '@blocksuite/global/di';
import type { ExtensionType } from '@blocksuite/store'; import { EditorLifeCycleExtension } from '@blocksuite/std';
import { type ExtensionType, StoreIdentifier } from '@blocksuite/store';
import type { TemplateResult } from 'lit'; import type { TemplateResult } from 'lit';
export interface NotificationService { export interface NotificationService {
@@ -37,8 +38,16 @@ export interface NotificationService {
label: string | TemplateResult; label: string | TemplateResult;
onClick: () => void; onClick: () => void;
}[]; }[];
onClose: () => void; onClose?: () => void;
}): void; }): void;
/**
* Notify with undo action, it is a helper function to notify with undo action.
* And the notification card will be closed when undo action is triggered by shortcut key or other ways.
*/
notifyWithUndoAction: (
options: Parameters<NotificationService['notify']>[0]
) => void;
} }
export const NotificationProvider = createIdentifier<NotificationService>( export const NotificationProvider = createIdentifier<NotificationService>(
@@ -46,11 +55,69 @@ export const NotificationProvider = createIdentifier<NotificationService>(
); );
export function NotificationExtension( export function NotificationExtension(
notificationService: NotificationService notificationService: Omit<NotificationService, 'notifyWithUndoAction'>
): ExtensionType { ): ExtensionType {
return { return {
setup: di => { setup: di => {
di.addImpl(NotificationProvider, notificationService); di.addImpl(NotificationProvider, provider => {
return {
...notificationService,
notifyWithUndoAction: options => {
notifyWithUndoActionImpl(
provider,
notificationService.notify,
options
);
},
};
});
}, },
}; };
} }
function notifyWithUndoActionImpl(
provider: ServiceProvider,
notify: NotificationService['notify'],
options: Parameters<NotificationService['notifyWithUndoAction']>[0]
) {
const store = provider.get(StoreIdentifier);
const abortController = new AbortController();
const abort = () => {
abortController.abort();
};
options.abort?.addEventListener('abort', abort);
const clearOnClose = () => {
store.history.off('stack-item-added', addHandler);
store.history.off('stack-item-popped', popHandler);
disposable.unsubscribe();
options.abort?.removeEventListener('abort', abort);
};
const addHandler = store.history.on('stack-item-added', abort);
const popHandler = store.history.on('stack-item-popped', abort);
const disposable = provider
.get(EditorLifeCycleExtension)
.slots.unmounted.subscribe(() => abort());
notify({
...options,
actions: [
{
key: 'notification-card-undo',
label: 'Undo',
onClick: () => {
store.undo();
abortController.abort();
},
},
...(options.actions ?? []),
],
abort: abortController.signal,
onClose: () => {
options.onClose?.();
clearOnClose();
},
});
}
@@ -97,6 +97,10 @@ export function mockNotificationService(editor: TestAffineEditorContainer) {
// todo: implement in playground // todo: implement in playground
console.log(notification); console.log(notification);
}, },
notifyWithUndoAction: notification => {
// todo: implement in playground
console.log(notification);
},
}; };
return notificationService; return notificationService;
} }
@@ -252,7 +252,7 @@ test.describe('edgeless note element toolbar', () => {
await waitForEditorLoad(page); await waitForEditorLoad(page);
await expect(notes).toHaveCount(1); await expect(notes).toHaveCount(1);
const undoButton = page.getByTestId('undo-display-in-page'); const undoButton = page.getByTestId('notification-card-undo');
const viewTocButton = page.getByTestId('view-in-toc'); const viewTocButton = page.getByTestId('view-in-toc');
await clickEdgelessModeButton(page); await clickEdgelessModeButton(page);
@@ -1,6 +1,7 @@
import { expect } from '@playwright/test'; import { expect } from '@playwright/test';
import { import {
clickView,
copyByKeyboard, copyByKeyboard,
createConnectorElement, createConnectorElement,
createNote, createNote,
@@ -10,12 +11,12 @@ import {
edgelessCommonSetup, edgelessCommonSetup,
getAllSortedIds, getAllSortedIds,
getFirstContainerId, getFirstContainerId,
moveView,
pasteByKeyboard, pasteByKeyboard,
pressEscape,
selectAllByKeyboard, selectAllByKeyboard,
Shape, Shape,
toViewCoord,
triggerComponentToolbarAction, triggerComponentToolbarAction,
waitNextFrame,
} from '../../utils/actions/index.js'; } from '../../utils/actions/index.js';
import { import {
assertContainerChildCount, assertContainerChildCount,
@@ -33,10 +34,8 @@ test.describe('clipboard', () => {
const originGroupId = await getFirstContainerId(page); const originGroupId = await getFirstContainerId(page);
await copyByKeyboard(page); await copyByKeyboard(page);
await waitNextFrame(page, 100); await pressEscape(page);
const move = await toViewCoord(page, [100, -50]); await clickView(page, [100, -50]);
await page.mouse.click(move[0], move[1]);
await waitNextFrame(page, 1000);
await pasteByKeyboard(page, false); await pasteByKeyboard(page, false);
const copyedGroupId = await getFirstContainerId(page, [originGroupId]); const copyedGroupId = await getFirstContainerId(page, [originGroupId]);
@@ -59,10 +58,8 @@ test.describe('clipboard', () => {
const originGroupId = await getFirstContainerId(page); const originGroupId = await getFirstContainerId(page);
await copyByKeyboard(page); await copyByKeyboard(page);
await waitNextFrame(page, 100); await pressEscape(page);
const move = await toViewCoord(page, [100, -50]); await clickView(page, [100, -50]);
await page.mouse.click(move[0], move[1]);
await waitNextFrame(page, 1000);
await pasteByKeyboard(page, false); await pasteByKeyboard(page, false);
const copyedGroupId = await getFirstContainerId(page, [originGroupId]); const copyedGroupId = await getFirstContainerId(page, [originGroupId]);
@@ -81,20 +78,17 @@ test.describe('group clipboard', () => {
await commonSetup(page); await commonSetup(page);
await createShapeElement(page, [0, 0], [100, 100], Shape.Square); await createShapeElement(page, [0, 0], [100, 100], Shape.Square);
await createNote(page, [100, -100]); await createNote(page, [100, -100]);
await page.mouse.click(10, 50); await pressEscape(page, 3);
await selectAllByKeyboard(page); await selectAllByKeyboard(page);
await triggerComponentToolbarAction(page, 'addGroup'); await triggerComponentToolbarAction(page, 'addGroup');
const originIds = await getAllSortedIds(page); const originIds = await getAllSortedIds(page);
expect(originIds.length).toBe(3); expect(originIds.length).toBe(3);
await copyByKeyboard(page); await copyByKeyboard(page);
const move = await toViewCoord(page, [250, 250]); await pressEscape(page);
await page.mouse.move(move[0], move[1]); await moveView(page, [250, 250]);
await page.mouse.click(move[0], move[1]);
await pasteByKeyboard(page, true); await pasteByKeyboard(page, true);
await waitNextFrame(page, 500);
const sortedIds = await getAllSortedIds(page); const sortedIds = await getAllSortedIds(page);
expect(sortedIds.length).toBe(6); expect(sortedIds.length).toBe(6);
}); });
@@ -105,9 +99,10 @@ test.describe('group clipboard', () => {
await createShapeElement(page, [200, 0], [300, 100], Shape.Square); await createShapeElement(page, [200, 0], [300, 100], Shape.Square);
await selectAllByKeyboard(page); await selectAllByKeyboard(page);
await triggerComponentToolbarAction(page, 'addGroup'); await triggerComponentToolbarAction(page, 'addGroup');
await pressEscape(page);
await createNote(page, [100, -100]); await createNote(page, [100, -200]);
await page.mouse.click(10, 50); await pressEscape(page, 3);
await selectAllByKeyboard(page); await selectAllByKeyboard(page);
await triggerComponentToolbarAction(page, 'createGroupOnMoreOption'); await triggerComponentToolbarAction(page, 'createGroupOnMoreOption');
@@ -115,11 +110,9 @@ test.describe('group clipboard', () => {
expect(originIds.length).toBe(5); expect(originIds.length).toBe(5);
await copyByKeyboard(page); await copyByKeyboard(page);
const move = await toViewCoord(page, [250, 250]); await pressEscape(page);
await page.mouse.move(move[0], move[1]); await moveView(page, [250, 250]);
await page.mouse.click(move[0], move[1]);
await pasteByKeyboard(page, true); await pasteByKeyboard(page, true);
await waitNextFrame(page, 500);
const sortedIds = await getAllSortedIds(page); const sortedIds = await getAllSortedIds(page);
expect(sortedIds.length).toBe(10); expect(sortedIds.length).toBe(10);
}); });
@@ -128,7 +121,7 @@ test.describe('group clipboard', () => {
await commonSetup(page); await commonSetup(page);
await createShapeElement(page, [0, 0], [100, 100], Shape.Square); await createShapeElement(page, [0, 0], [100, 100], Shape.Square);
await createNote(page, [100, -100]); await createNote(page, [100, -100]);
await page.mouse.click(10, 50); await pressEscape(page, 3);
await selectAllByKeyboard(page); await selectAllByKeyboard(page);
await triggerComponentToolbarAction(page, 'addFrame'); await triggerComponentToolbarAction(page, 'addFrame');
@@ -141,11 +134,9 @@ test.describe('group clipboard', () => {
expect(originIds.length).toBe(5); expect(originIds.length).toBe(5);
await copyByKeyboard(page); await copyByKeyboard(page);
const move = await toViewCoord(page, [250, 250]); await pressEscape(page);
await page.mouse.move(move[0], move[1]); await moveView(page, [250, 250]);
await page.mouse.click(move[0], move[1]);
await pasteByKeyboard(page, true); await pasteByKeyboard(page, true);
await waitNextFrame(page, 500);
const sortedIds = await getAllSortedIds(page); const sortedIds = await getAllSortedIds(page);
expect(sortedIds.length).toBe(10); expect(sortedIds.length).toBe(10);
}); });