diff --git a/blocksuite/affine/blocks/embed/src/embed-synced-doc-block/configs/toolbar.ts b/blocksuite/affine/blocks/embed/src/embed-synced-doc-block/configs/toolbar.ts index bd0f0d346e..c271cb5624 100644 --- a/blocksuite/affine/blocks/embed/src/embed-synced-doc-block/configs/toolbar.ts +++ b/blocksuite/affine/blocks/embed/src/embed-synced-doc-block/configs/toolbar.ts @@ -1,6 +1,17 @@ import { toast } from '@blocksuite/affine-components/toast'; import { EditorChevronDown } from '@blocksuite/affine-components/toolbar'; -import { EmbedSyncedDocModel } from '@blocksuite/affine-model'; +import { + DEFAULT_NOTE_HEIGHT, + DEFAULT_NOTE_WIDTH, + EmbedSyncedDocModel, + NoteBlockModel, + NoteDisplayMode, + type NoteProps, +} from '@blocksuite/affine-model'; +import { + draftSelectedModelsCommand, + duplicateSelectedModelsCommand, +} from '@blocksuite/affine-shared/commands'; import { ActionPlacement, EditorSettingProvider, @@ -13,7 +24,7 @@ import { type ToolbarModuleConfig, ToolbarModuleExtension, } from '@blocksuite/affine-shared/services'; -import { getBlockProps } from '@blocksuite/affine-shared/utils'; +import { getBlockProps, matchModels } from '@blocksuite/affine-shared/utils'; import { Bound } from '@blocksuite/global/gfx'; import { CaptionIcon, @@ -21,10 +32,11 @@ import { DeleteIcon, DuplicateIcon, ExpandFullIcon, + InsertIntoPageIcon, OpenInNewIcon, } from '@blocksuite/icons/lit'; import { BlockFlavourIdentifier, isGfxBlockComponent } from '@blocksuite/std'; -import { type ExtensionType, Slice } from '@blocksuite/store'; +import { type BlockModel, type ExtensionType, Slice } from '@blocksuite/store'; import { computed, signal } from '@preact/signals-core'; import { html } from 'lit'; import { ifDefined } from 'lit/directives/if-defined.js'; @@ -192,7 +204,7 @@ const conversionsActionGroup = { } as const satisfies ToolbarActionGroup; const captionAction = { - id: 'c.caption', + id: 'd.caption', tooltip: 'Caption', icon: CaptionIcon(), run(ctx) { @@ -269,11 +281,116 @@ const builtinToolbarConfig = { const builtinSurfaceToolbarConfig = { actions: [ + // TODO(@L-Sun): remove this after impl header toolbar for embed-edgeless-synced-doc openDocActionGroup, conversionsActionGroup, + { + id: 'b.insert-to-page', + label: 'Insert to page', + tooltip: 'Insert to page', + icon: InsertIntoPageIcon(), + when: ({ std }) => + std.get(FeatureFlagService).getFlag('enable_embed_doc_with_alias'), + run: ctx => { + const model = ctx.getCurrentModelByType(EmbedSyncedDocModel); + if (!model) return; + + const lastVisibleNote = ctx.store + .getModelsByFlavour('affine:note') + .findLast( + (note): note is NoteBlockModel => + matchModels(note, [NoteBlockModel]) && + note.props.displayMode !== NoteDisplayMode.EdgelessOnly + ); + + ctx.doc.captureSync(); + ctx.chain + .pipe(duplicateSelectedModelsCommand, { + selectedModels: [model], + parentModel: lastVisibleNote, + }) + .run(); + }, + }, + { + id: 'c.duplicate-as-note', + label: 'Duplicate as note', + tooltip: + 'Duplicate as note to create an editable copy, the original remains unchanged.', + icon: DuplicateIcon(), + when: ({ std }) => + std.get(FeatureFlagService).getFlag('enable_embed_doc_with_alias'), + run: ctx => { + const { gfx } = ctx; + + const syncedDocModel = ctx.getCurrentModelByType(EmbedSyncedDocModel); + if (!syncedDocModel) return; + + let contentModels: BlockModel[] = []; + { + const doc = ctx.store.workspace.getDoc(syncedDocModel.props.pageId); + // TODO(@L-Sun): clear query cache + const store = doc?.getStore({ readonly: true }); + if (!store) return; + contentModels = store + .getModelsByFlavour('affine:note') + .filter( + (note): note is NoteBlockModel => + matchModels(note, [NoteBlockModel]) && + note.props.displayMode !== NoteDisplayMode.EdgelessOnly + ) + .flatMap(note => note.children); + } + if (contentModels.length === 0) return; + + ctx.doc.captureSync(); + ctx.chain + .pipe(draftSelectedModelsCommand, { + selectedModels: contentModels, + }) + .pipe(({ std, draftedModels }, next) => { + (async () => { + const PADDING = 20; + const x = + syncedDocModel.elementBound.x + + syncedDocModel.elementBound.w + + PADDING; + const y = syncedDocModel.elementBound.y; + + const children = await draftedModels; + const noteId = std.store.addBlock( + 'affine:note', + { + xywh: new Bound( + x, + y, + DEFAULT_NOTE_WIDTH, + DEFAULT_NOTE_HEIGHT + ).serialize(), + displayMode: NoteDisplayMode.EdgelessOnly, + } satisfies Partial, + ctx.store.root + ); + + await std.clipboard.duplicateSlice( + Slice.fromModels(std.store, children), + std.store, + noteId + ); + + gfx.selection.set({ + elements: [noteId], + editing: false, + }); + })().catch(console.error); + return next(); + }) + .run(); + }, + }, captionAction, { - id: 'd.scale', + id: 'e.scale', content(ctx) { const model = ctx.getCurrentBlockByType( EmbedSyncedDocBlockComponent diff --git a/blocksuite/affine/blocks/embed/src/embed-synced-doc-block/index.ts b/blocksuite/affine/blocks/embed/src/embed-synced-doc-block/index.ts index 37c3e96e40..8f353ae555 100644 --- a/blocksuite/affine/blocks/embed/src/embed-synced-doc-block/index.ts +++ b/blocksuite/affine/blocks/embed/src/embed-synced-doc-block/index.ts @@ -2,4 +2,4 @@ export * from './adapters/index.js'; export * from './edgeless-clipboard-config'; export * from './embed-synced-doc-block.js'; export * from './embed-synced-doc-spec.js'; -export { SYNCED_MIN_HEIGHT, SYNCED_MIN_WIDTH } from './styles.js'; +export { SYNCED_MIN_HEIGHT, SYNCED_MIN_WIDTH } from '@blocksuite/affine-model'; diff --git a/blocksuite/affine/blocks/embed/src/embed-synced-doc-block/styles.ts b/blocksuite/affine/blocks/embed/src/embed-synced-doc-block/styles.ts index 12842767b9..0189eadf7b 100644 --- a/blocksuite/affine/blocks/embed/src/embed-synced-doc-block/styles.ts +++ b/blocksuite/affine/blocks/embed/src/embed-synced-doc-block/styles.ts @@ -7,9 +7,6 @@ import { css, html, unsafeCSS } from 'lit'; import { embedNoteContentStyles } from '../common/embed-note-content-styles.js'; -export const SYNCED_MIN_WIDTH = 370; -export const SYNCED_MIN_HEIGHT = 64; - export const blockStyles = css` affine-embed-synced-doc-block { --embed-padding: 24px; diff --git a/blocksuite/affine/model/src/blocks/embed/synced-doc/synced-doc-model.ts b/blocksuite/affine/model/src/blocks/embed/synced-doc/synced-doc-model.ts index 2b1e6930ce..2614c09b84 100644 --- a/blocksuite/affine/model/src/blocks/embed/synced-doc/synced-doc-model.ts +++ b/blocksuite/affine/model/src/blocks/embed/synced-doc/synced-doc-model.ts @@ -1,3 +1,4 @@ +import type { GfxCompatibleProps } from '@blocksuite/std/gfx'; import { BlockModel } from '@blocksuite/store'; import type { ReferenceInfo } from '../../../consts/doc.js'; @@ -10,7 +11,8 @@ export type EmbedSyncedDocBlockProps = { style: EmbedCardStyle; caption?: string | null; scale?: number; -} & ReferenceInfo; +} & ReferenceInfo & + GfxCompatibleProps; export class EmbedSyncedDocModel extends defineEmbedModel( BlockModel diff --git a/blocksuite/affine/model/src/blocks/embed/synced-doc/synced-doc-schema.ts b/blocksuite/affine/model/src/blocks/embed/synced-doc/synced-doc-schema.ts index d3235845a9..ab4dba1694 100644 --- a/blocksuite/affine/model/src/blocks/embed/synced-doc/synced-doc-schema.ts +++ b/blocksuite/affine/model/src/blocks/embed/synced-doc/synced-doc-schema.ts @@ -7,6 +7,9 @@ import { EmbedSyncedDocStyles, } from './synced-doc-model.js'; +export const SYNCED_MIN_WIDTH = 370; +export const SYNCED_MIN_HEIGHT = 64; + export const defaultEmbedSyncedDocBlockProps: EmbedSyncedDocBlockProps = { pageId: '', style: EmbedSyncedDocStyles[0], @@ -15,6 +18,9 @@ export const defaultEmbedSyncedDocBlockProps: EmbedSyncedDocBlockProps = { // title & description aliases title: undefined, description: undefined, + index: 'a0', + xywh: `[0,0,${SYNCED_MIN_WIDTH},100]`, + lockedBySelf: undefined, }; export const EmbedSyncedDocBlockSchema = createEmbedBlockSchema({ diff --git a/packages/frontend/core/src/blocksuite/extensions/editor-config/toolbar/index.ts b/packages/frontend/core/src/blocksuite/extensions/editor-config/toolbar/index.ts index 6bcae02e80..0e12c5e669 100644 --- a/packages/frontend/core/src/blocksuite/extensions/editor-config/toolbar/index.ts +++ b/packages/frontend/core/src/blocksuite/extensions/editor-config/toolbar/index.ts @@ -1099,19 +1099,6 @@ export const createCustomToolbarExtension = ( }, }), - ToolbarModuleExtension({ - id: BlockFlavourIdentifier('custom:affine:surface:embed-synced-doc'), - config: { - actions: [ - embedSyncedDocToolbarConfig.actions, - createOpenDocActionGroup(EmbedSyncedDocBlockComponent, settings), - createEdgelessOpenDocActionGroup(EmbedSyncedDocBlockComponent), - ].flat(), - - when: ctx => ctx.getSurfaceModels().length === 1, - }, - }), - ToolbarModuleExtension({ id: BlockFlavourIdentifier('custom:affine:reference'), config: { diff --git a/tests/blocksuite/e2e/embed-synced-doc/edgeless.spec.ts b/tests/blocksuite/e2e/embed-synced-doc/edgeless.spec.ts index d00196fa52..fc294806f8 100644 --- a/tests/blocksuite/e2e/embed-synced-doc/edgeless.spec.ts +++ b/tests/blocksuite/e2e/embed-synced-doc/edgeless.spec.ts @@ -1,11 +1,20 @@ -import { expect } from '@playwright/test'; +import { expect, type Page } from '@playwright/test'; -import { switchEditorMode } from '../utils/actions/edgeless.js'; -import { enterPlaygroundRoom, waitNextFrame } from '../utils/actions/misc.js'; -import { test } from '../utils/playwright.js'; -import { initEmbedSyncedDocState } from './utils.js'; +import { clickView } from '../utils/actions/click.js'; +import { + createNote, + getIds, + getSelectedBound, + getSelectedIds, + isIntersected, + switchEditorMode, +} from '../utils/actions/edgeless'; +import { pressEscape } from '../utils/actions/keyboard.js'; +import { enterPlaygroundRoom, waitNextFrame } from '../utils/actions/misc'; +import { test } from '../utils/playwright'; +import { initEmbedSyncedDocState } from './utils'; -test.describe('Embed synced doc', () => { +test.describe('Embed synced doc in edgeless mode', () => { test.beforeEach(async ({ page }) => { await enterPlaygroundRoom(page); }); @@ -18,7 +27,6 @@ test.describe('Embed synced doc', () => { { title: 'Page 1', content: 'hello page 1' }, ]); - // Switch to edgeless mode await switchEditorMode(page); // Double click on note to enter edit status @@ -63,4 +71,120 @@ test.describe('Embed synced doc', () => { ); } ); + + test.describe('edgeless element toolbar', () => { + test.beforeEach(async ({ page }) => { + await initEmbedSyncedDocState(page, [ + { title: 'Root Doc', content: 'hello root doc' }, + { title: 'Page 1', content: 'hello page 1', inEdgeless: true }, + ]); + + // TODO(@L-Sun): remove this after this feature is released + await page.evaluate(() => { + const { FeatureFlagService } = window.$blocksuite.services; + window.editor.std + .get(FeatureFlagService) + .setFlag('enable_embed_doc_with_alias', true); + }); + + await switchEditorMode(page); + + const edgelessEmbedSyncedBlock = page.locator( + 'affine-embed-edgeless-synced-doc-block' + ); + await edgelessEmbedSyncedBlock.click(); + }); + + const locateToolbar = (page: Page) => { + return page.locator( + // TODO(@L-Sun): simplify this selector after that toolbar widget are disabled in preview rendering is ready + 'affine-edgeless-root > .widgets-container affine-toolbar-widget editor-toolbar' + ); + }; + + test('should insert embed-synced-doc into page when click "Insert into page" button', async ({ + page, + }) => { + const embedSyncedBlock = page.locator('affine-embed-synced-doc-block'); + const edgelessEmbedSyncedBlock = page.locator( + 'affine-embed-edgeless-synced-doc-block' + ); + + const toolbar = locateToolbar(page); + const insertButton = toolbar.getByLabel('Insert to page'); + await insertButton.click(); + + await expect( + edgelessEmbedSyncedBlock, + 'the edgeless embed synced doc should be remained after click insert button' + ).toBeVisible(); + + await switchEditorMode(page); + await expect(embedSyncedBlock).toBeVisible(); + }); + + test('should using all content of embed-synced-doc to duplicate as a note', async ({ + page, + }) => { + // switch doc + const switchDoc = async () => + page.evaluate(() => { + for (const [id, doc] of window.collection.docs.entries()) { + if (id !== window.doc.id) { + window.editor.doc = doc.getStore(); + window.doc = window.editor.doc; + break; + } + } + }); + await switchDoc(); + + const toolbar = locateToolbar(page); + + await createNote(page, [0, 100, 100, 800], 'hello note'); + await pressEscape(page, 3); + await clickView(page, [400, 150]); + await toolbar.getByLabel('Display in Page').click(); + + await switchDoc(); + + const edgelessEmbedSyncedBlock = page.locator( + 'affine-embed-edgeless-synced-doc-block' + ); + await edgelessEmbedSyncedBlock.click(); + await toolbar.getByLabel('Duplicate as note').click(); + + const edgelessNotes = page.locator('affine-edgeless-note'); + await expect(edgelessNotes).toHaveCount(2); + await expect(edgelessNotes.last()).toBeVisible(); + + const paragraphs = edgelessNotes + .last() + .locator('affine-paragraph [data-v-root="true"]'); + + await expect(paragraphs).toHaveCount(2); + await expect(paragraphs.first()).toHaveText('hello page 1'); + await expect(paragraphs.last()).toHaveText('hello note'); + }); + + test('should be selected and not overlay with the embed-synced-doc after duplicating as note', async ({ + page, + }) => { + const prevIds = await getIds(page); + + const embedDocBound = await getSelectedBound(page); + + const toolbar = locateToolbar(page); + await toolbar.getByLabel('Duplicate as note').click(); + + const edgelessNotes = page.locator('affine-edgeless-note'); + await expect(edgelessNotes).toHaveCount(2); + expect(await getSelectedIds(page)).toHaveLength(1); + expect(await getSelectedIds(page)).not.toContain(prevIds); + expect(edgelessNotes.last()).toBeVisible(); + + const noteBound = await getSelectedBound(page); + expect(isIntersected(embedDocBound, noteBound)).toBe(false); + }); + }); }); diff --git a/tests/blocksuite/e2e/embed-synced-doc/utils.ts b/tests/blocksuite/e2e/embed-synced-doc/utils.ts index 6e3dde5884..823429b89c 100644 --- a/tests/blocksuite/e2e/embed-synced-doc/utils.ts +++ b/tests/blocksuite/e2e/embed-synced-doc/utils.ts @@ -9,13 +9,15 @@ import type { Page } from '@playwright/test'; /** * using page.evaluate to init the embed synced doc state * @param page - playwright page - * @param data - the data to init the embed synced doc state + * @param data.title - the title of the doc + * @param data.content - the content of the doc + * @param data.inEdgeless - whether this doc is in parent doc's canvas, default is in page * @param option.chain - doc1 -> doc2 -> doc3 -> ..., if chain is false, doc1 will be the parent of remaining docs * @returns the ids of created docs */ export async function initEmbedSyncedDocState( page: Page, - data: { title: string; content: string }[], + data: { title: string; content: string; inEdgeless?: boolean }[], option?: { chain?: boolean; } @@ -92,12 +94,18 @@ export async function initEmbedSyncedDocState( throw new Error(`Note not found in ${docId}`); } + const surface = store.getModelsByFlavour('affine:surface')[0]; + if (!surface) { + throw new Error(`Surface not found in ${docId}`); + } + store.addBlock( 'affine:embed-synced-doc', { pageId: docId, + xywh: '[0, 100, 370, 100]', } satisfies Partial, - note + data[index].inEdgeless ? surface.id : note.id ); prevId = docId; diff --git a/tests/blocksuite/e2e/utils/actions/edgeless.ts b/tests/blocksuite/e2e/utils/actions/edgeless.ts index cd7ea96846..95f9f1d0dc 100644 --- a/tests/blocksuite/e2e/utils/actions/edgeless.ts +++ b/tests/blocksuite/e2e/utils/actions/edgeless.ts @@ -1942,3 +1942,12 @@ export async function waitFontsLoaded(page: Page) { return edgelessBlock.fontLoader?.ready; }); } + +export function isIntersected( + bound1: [number, number, number, number], + bound2: [number, number, number, number] +) { + const [x1, y1, w1, h1] = bound1; + const [x2, y2, w2, h2] = bound2; + return x1 < x2 + w2 && x1 + w1 > x2 && y1 < y2 + h2 && y1 + h1 > y2; +}