From 9dbdd4b7ba6023196ffcaa624b0165f934336535 Mon Sep 17 00:00:00 2001 From: L-Sun Date: Wed, 23 Apr 2025 02:43:56 +0000 Subject: [PATCH] refactor(editor): add helper function for undo notification (#11903) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### 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图标也更换一下) --- .../embed/src/common/render-linked-doc.ts | 39 +--------- .../src/embed-iframe-block/configs/toolbar.ts | 2 +- .../affine/blocks/frame/src/frame-toolbar.ts | 22 +++++- .../affine/blocks/note/src/configs/toolbar.ts | 49 ++---------- .../affine/blocks/note/src/quick-action.ts | 2 +- .../affine/blocks/root/src/configs/toolbar.ts | 2 +- .../root/src/edgeless/configs/toolbar/more.ts | 2 +- .../root/src/keyboard/keyboard-manager.ts | 2 +- .../components/src/notification/linked-doc.ts | 36 +-------- .../affine/gfx/group/src/toolbar/config.ts | 9 ++- .../src/services/notification-service.ts | 77 +++++++++++++++++-- .../playground/apps/_common/mock-services.ts | 4 + .../e2e/blocksuite/edgeless/note.spec.ts | 2 +- .../e2e/edgeless/group/clipboard.spec.ts | 45 +++++------ 14 files changed, 136 insertions(+), 157 deletions(-) diff --git a/blocksuite/affine/blocks/embed/src/common/render-linked-doc.ts b/blocksuite/affine/blocks/embed/src/common/render-linked-doc.ts index 6a2acdd8a..a41208936 100644 --- a/blocksuite/affine/blocks/embed/src/common/render-linked-doc.ts +++ b/blocksuite/affine/blocks/embed/src/common/render-linked-doc.ts @@ -11,7 +11,7 @@ import { import { EMBED_CARD_HEIGHT } from '@blocksuite/affine-shared/consts'; import { NotificationProvider } from '@blocksuite/affine-shared/services'; import { matchModels } from '@blocksuite/affine-shared/utils'; -import { BlockStdScope, EditorLifeCycleExtension } from '@blocksuite/std'; +import { BlockStdScope } from '@blocksuite/std'; import { type BlockModel, type BlockSnapshot, @@ -337,45 +337,12 @@ export function promptDocTitle(std: BlockStdScope, autofill?: string) { }); } -export function notifyDocCreated(std: BlockStdScope, doc: Store) { - const notification = std.getOptional(NotificationProvider); - 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({ +export function notifyDocCreated(std: BlockStdScope) { + std.getOptional(NotificationProvider)?.notifyWithUndoAction({ title: 'Linked doc created', message: 'You can click undo to recovery block content', accent: 'info', duration: 10 * 1000, - actions: [ - { - key: 'undo', - label: 'Undo', - onClick: () => { - doc.undo(); - clear(); - }, - }, - ], - abort: abortController.signal, - onClose: clear, }); } diff --git a/blocksuite/affine/blocks/embed/src/embed-iframe-block/configs/toolbar.ts b/blocksuite/affine/blocks/embed/src/embed-iframe-block/configs/toolbar.ts index 3c473b586..1820c1442 100644 --- a/blocksuite/affine/blocks/embed/src/embed-iframe-block/configs/toolbar.ts +++ b/blocksuite/affine/blocks/embed/src/embed-iframe-block/configs/toolbar.ts @@ -230,7 +230,7 @@ export const builtinToolbarConfig = { draftedModels, title ); - notifyDocCreated(std, store); + notifyDocCreated(std); track('DocCreated', { segment: 'doc', diff --git a/blocksuite/affine/blocks/frame/src/frame-toolbar.ts b/blocksuite/affine/blocks/frame/src/frame-toolbar.ts index 1aeb60ec3..084e1a4dd 100644 --- a/blocksuite/affine/blocks/frame/src/frame-toolbar.ts +++ b/blocksuite/affine/blocks/frame/src/frame-toolbar.ts @@ -16,6 +16,7 @@ import { SurfaceRefBlockSchema, } from '@blocksuite/affine-model'; import { + NotificationProvider, type ToolbarContext, type ToolbarModuleConfig, ToolbarModuleExtension, @@ -26,7 +27,11 @@ import { } from '@blocksuite/affine-shared/utils'; import { mountFrameTitleEditor } from '@blocksuite/affine-widget-frame-title'; 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 { GfxControllerIdentifier } from '@blocksuite/std/gfx'; import type { ExtensionType } from '@blocksuite/store'; @@ -46,9 +51,8 @@ const builtinSurfaceToolbarConfig = { { id: 'a.insert-into-page', label: 'Insert into Page', - showLabel: true, tooltip: 'Insert into Page', - icon: PageIcon(), + icon: InsertIntoPageIcon(), when: ctx => ctx.getSurfaceModelsByType(FrameBlockModel).length === 1, run(ctx) { const model = ctx.getCurrentModelByType(FrameBlockModel); @@ -78,13 +82,23 @@ const builtinSurfaceToolbarConfig = { ); } + ctx.store.captureSync(); ctx.store.addBlock( SurfaceRefBlockSchema.model.flavour, { reference: frameId, refFlavour: FrameBlockSchema.model.flavour }, 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'); + } }, }, { diff --git a/blocksuite/affine/blocks/note/src/configs/toolbar.ts b/blocksuite/affine/blocks/note/src/configs/toolbar.ts index d8a0efbcf..c67e7a7ad 100644 --- a/blocksuite/affine/blocks/note/src/configs/toolbar.ts +++ b/blocksuite/affine/blocks/note/src/configs/toolbar.ts @@ -31,10 +31,7 @@ import { LinkedPageIcon, ScissorsIcon, } from '@blocksuite/icons/lit'; -import { - BlockFlavourIdentifier, - EditorLifeCycleExtension, -} from '@blocksuite/std'; +import { BlockFlavourIdentifier } from '@blocksuite/std'; import type { ExtensionType } from '@blocksuite/store'; import { computed } from '@preact/signals-core'; import { html } from 'lit'; @@ -504,34 +501,6 @@ function setDisplayMode( 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 = newMode === NoteDisplayMode.EdgelessOnly ? { @@ -544,27 +513,21 @@ function setDisplayMode( }; const notification = ctx.std.getOptional(NotificationProvider); - notification?.notify({ + notification?.notifyWithUndoAction({ title: data.title, message: `${data.message} Find it in the TOC for quick navigation.`, accent: 'success', duration: 5 * 1000, actions: [ - { - key: 'undo-display-in-page', - label: 'Undo', - onClick: () => undo(), - }, { key: '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', { diff --git a/blocksuite/affine/blocks/note/src/quick-action.ts b/blocksuite/affine/blocks/note/src/quick-action.ts index 361636cf4..c8e97ad60 100644 --- a/blocksuite/affine/blocks/note/src/quick-action.ts +++ b/blocksuite/affine/blocks/note/src/quick-action.ts @@ -58,7 +58,7 @@ export const quickActionConfig: QuickActionConfig[] = [ draftedModels, title ).catch(console.error); - notifyDocCreated(std, doc); + notifyDocCreated(std); }) .catch(console.error); }, diff --git a/blocksuite/affine/blocks/root/src/configs/toolbar.ts b/blocksuite/affine/blocks/root/src/configs/toolbar.ts index 8a74dc0a5..6945c1f16 100644 --- a/blocksuite/affine/blocks/root/src/configs/toolbar.ts +++ b/blocksuite/affine/blocks/root/src/configs/toolbar.ts @@ -244,7 +244,7 @@ const turnIntoLinkedDoc = { draftedModels, title ); - notifyDocCreated(std, store); + notifyDocCreated(std); track('DocCreated', { segment: 'doc', diff --git a/blocksuite/affine/blocks/root/src/edgeless/configs/toolbar/more.ts b/blocksuite/affine/blocks/root/src/edgeless/configs/toolbar/more.ts index be108b32e..388625dac 100644 --- a/blocksuite/affine/blocks/root/src/edgeless/configs/toolbar/more.ts +++ b/blocksuite/affine/blocks/root/src/edgeless/configs/toolbar/more.ts @@ -347,7 +347,7 @@ export const moreActions = [ other: 'new doc', }); - notifyDocCreated(ctx.std, ctx.store); + notifyDocCreated(ctx.std); }; create().catch(console.error); diff --git a/blocksuite/affine/blocks/root/src/keyboard/keyboard-manager.ts b/blocksuite/affine/blocks/root/src/keyboard/keyboard-manager.ts index 47ef3cb05..8334a0774 100644 --- a/blocksuite/affine/blocks/root/src/keyboard/keyboard-manager.ts +++ b/blocksuite/affine/blocks/root/src/keyboard/keyboard-manager.ts @@ -156,7 +156,7 @@ export class PageKeyboardManager { draftedModels, title ).catch(console.error); - notifyDocCreated(rootComponent.std, doc); + notifyDocCreated(rootComponent.std); }) .catch(console.error); } diff --git a/blocksuite/affine/components/src/notification/linked-doc.ts b/blocksuite/affine/components/src/notification/linked-doc.ts index fc74b9a18..df3be7ff9 100644 --- a/blocksuite/affine/components/src/notification/linked-doc.ts +++ b/blocksuite/affine/components/src/notification/linked-doc.ts @@ -1,52 +1,22 @@ 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'; function notify(std: BlockStdScope, title: string, message: string) { const notification = std.getOptional(NotificationProvider); - const { store: doc, host } = std; + const { host } = std; if (!notification) { toast(host, title); 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 = host.std - .get(EditorLifeCycleExtension) - .slots.unmounted.subscribe(closeNotify); - - notification.notify({ + notification.notifyWithUndoAction({ title, message, accent: 'info', duration: 10 * 1000, - actions: [ - { - key: 'undo', - label: 'Undo', - onClick: () => { - doc.undo(); - clear(); - }, - }, - ], - abort: abortController.signal, - onClose: clear, }); } diff --git a/blocksuite/affine/gfx/group/src/toolbar/config.ts b/blocksuite/affine/gfx/group/src/toolbar/config.ts index 104f10879..ec59ae50e 100644 --- a/blocksuite/affine/gfx/group/src/toolbar/config.ts +++ b/blocksuite/affine/gfx/group/src/toolbar/config.ts @@ -14,7 +14,11 @@ import { import { matchModels } from '@blocksuite/affine-shared/utils'; import { getRootBlock } from '@blocksuite/affine-widget-edgeless-toolbar'; 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 { ungroupCommand } from '../command'; @@ -25,9 +29,8 @@ export const groupToolbarConfig = { { id: 'a.insert-into-page', label: 'Insert into Page', - showLabel: true, tooltip: 'Insert into Page', - icon: PageIcon(), + icon: InsertIntoPageIcon(), when: ctx => ctx.getSurfaceModelsByType(GroupElementModel).length === 1, run(ctx) { const model = ctx.getCurrentModelByType(GroupElementModel); diff --git a/blocksuite/affine/shared/src/services/notification-service.ts b/blocksuite/affine/shared/src/services/notification-service.ts index 9b8dc2fab..7f7dd4b20 100644 --- a/blocksuite/affine/shared/src/services/notification-service.ts +++ b/blocksuite/affine/shared/src/services/notification-service.ts @@ -1,5 +1,6 @@ -import { createIdentifier } from '@blocksuite/global/di'; -import type { ExtensionType } from '@blocksuite/store'; +import { createIdentifier, type ServiceProvider } from '@blocksuite/global/di'; +import { EditorLifeCycleExtension } from '@blocksuite/std'; +import { type ExtensionType, StoreIdentifier } from '@blocksuite/store'; import type { TemplateResult } from 'lit'; export interface NotificationService { @@ -37,8 +38,16 @@ export interface NotificationService { label: string | TemplateResult; onClick: () => void; }[]; - onClose: () => void; + onClose?: () => 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[0] + ) => void; } export const NotificationProvider = createIdentifier( @@ -46,11 +55,69 @@ export const NotificationProvider = createIdentifier( ); export function NotificationExtension( - notificationService: NotificationService + notificationService: Omit ): ExtensionType { return { 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[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(); + }, + }); +} diff --git a/blocksuite/playground/apps/_common/mock-services.ts b/blocksuite/playground/apps/_common/mock-services.ts index f194380b4..261fc91e3 100644 --- a/blocksuite/playground/apps/_common/mock-services.ts +++ b/blocksuite/playground/apps/_common/mock-services.ts @@ -97,6 +97,10 @@ export function mockNotificationService(editor: TestAffineEditorContainer) { // todo: implement in playground console.log(notification); }, + notifyWithUndoAction: notification => { + // todo: implement in playground + console.log(notification); + }, }; return notificationService; } diff --git a/tests/affine-local/e2e/blocksuite/edgeless/note.spec.ts b/tests/affine-local/e2e/blocksuite/edgeless/note.spec.ts index 6391ac273..03a4bec30 100644 --- a/tests/affine-local/e2e/blocksuite/edgeless/note.spec.ts +++ b/tests/affine-local/e2e/blocksuite/edgeless/note.spec.ts @@ -252,7 +252,7 @@ test.describe('edgeless note element toolbar', () => { await waitForEditorLoad(page); 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'); await clickEdgelessModeButton(page); diff --git a/tests/blocksuite/e2e/edgeless/group/clipboard.spec.ts b/tests/blocksuite/e2e/edgeless/group/clipboard.spec.ts index 0f8a4c1f1..6f7182944 100644 --- a/tests/blocksuite/e2e/edgeless/group/clipboard.spec.ts +++ b/tests/blocksuite/e2e/edgeless/group/clipboard.spec.ts @@ -1,6 +1,7 @@ import { expect } from '@playwright/test'; import { + clickView, copyByKeyboard, createConnectorElement, createNote, @@ -10,12 +11,12 @@ import { edgelessCommonSetup, getAllSortedIds, getFirstContainerId, + moveView, pasteByKeyboard, + pressEscape, selectAllByKeyboard, Shape, - toViewCoord, triggerComponentToolbarAction, - waitNextFrame, } from '../../utils/actions/index.js'; import { assertContainerChildCount, @@ -33,10 +34,8 @@ test.describe('clipboard', () => { const originGroupId = await getFirstContainerId(page); await copyByKeyboard(page); - await waitNextFrame(page, 100); - const move = await toViewCoord(page, [100, -50]); - await page.mouse.click(move[0], move[1]); - await waitNextFrame(page, 1000); + await pressEscape(page); + await clickView(page, [100, -50]); await pasteByKeyboard(page, false); const copyedGroupId = await getFirstContainerId(page, [originGroupId]); @@ -59,10 +58,8 @@ test.describe('clipboard', () => { const originGroupId = await getFirstContainerId(page); await copyByKeyboard(page); - await waitNextFrame(page, 100); - const move = await toViewCoord(page, [100, -50]); - await page.mouse.click(move[0], move[1]); - await waitNextFrame(page, 1000); + await pressEscape(page); + await clickView(page, [100, -50]); await pasteByKeyboard(page, false); const copyedGroupId = await getFirstContainerId(page, [originGroupId]); @@ -81,20 +78,17 @@ test.describe('group clipboard', () => { await commonSetup(page); await createShapeElement(page, [0, 0], [100, 100], Shape.Square); await createNote(page, [100, -100]); - await page.mouse.click(10, 50); + await pressEscape(page, 3); await selectAllByKeyboard(page); await triggerComponentToolbarAction(page, 'addGroup'); - const originIds = await getAllSortedIds(page); expect(originIds.length).toBe(3); await copyByKeyboard(page); - const move = await toViewCoord(page, [250, 250]); - await page.mouse.move(move[0], move[1]); - await page.mouse.click(move[0], move[1]); + await pressEscape(page); + await moveView(page, [250, 250]); await pasteByKeyboard(page, true); - await waitNextFrame(page, 500); const sortedIds = await getAllSortedIds(page); expect(sortedIds.length).toBe(6); }); @@ -105,9 +99,10 @@ test.describe('group clipboard', () => { await createShapeElement(page, [200, 0], [300, 100], Shape.Square); await selectAllByKeyboard(page); await triggerComponentToolbarAction(page, 'addGroup'); + await pressEscape(page); - await createNote(page, [100, -100]); - await page.mouse.click(10, 50); + await createNote(page, [100, -200]); + await pressEscape(page, 3); await selectAllByKeyboard(page); await triggerComponentToolbarAction(page, 'createGroupOnMoreOption'); @@ -115,11 +110,9 @@ test.describe('group clipboard', () => { expect(originIds.length).toBe(5); await copyByKeyboard(page); - const move = await toViewCoord(page, [250, 250]); - await page.mouse.move(move[0], move[1]); - await page.mouse.click(move[0], move[1]); + await pressEscape(page); + await moveView(page, [250, 250]); await pasteByKeyboard(page, true); - await waitNextFrame(page, 500); const sortedIds = await getAllSortedIds(page); expect(sortedIds.length).toBe(10); }); @@ -128,7 +121,7 @@ test.describe('group clipboard', () => { await commonSetup(page); await createShapeElement(page, [0, 0], [100, 100], Shape.Square); await createNote(page, [100, -100]); - await page.mouse.click(10, 50); + await pressEscape(page, 3); await selectAllByKeyboard(page); await triggerComponentToolbarAction(page, 'addFrame'); @@ -141,11 +134,9 @@ test.describe('group clipboard', () => { expect(originIds.length).toBe(5); await copyByKeyboard(page); - const move = await toViewCoord(page, [250, 250]); - await page.mouse.move(move[0], move[1]); - await page.mouse.click(move[0], move[1]); + await pressEscape(page); + await moveView(page, [250, 250]); await pasteByKeyboard(page, true); - await waitNextFrame(page, 500); const sortedIds = await getAllSortedIds(page); expect(sortedIds.length).toBe(10); });