From 0009f91d2a68a5f55e5ecba100af23bfcf2ae0e8 Mon Sep 17 00:00:00 2001 From: Saurabh Pardeshi <111561015+asaurabhprsas@users.noreply.github.com> Date: Sat, 18 Apr 2026 18:09:20 +0530 Subject: [PATCH] feat(editor): add "Copy as Markdown" option in context & export menus (#14705) - Allow users to select text and copy it as Markdown via the context menu - Add "Copy as Markdown" under Export menu to copy entire document to clipboard Fixes #12983 ## Summary by CodeRabbit * **New Features** * Added "Copy as Markdown" to the toolbar clipboard More menu for selected content. * Added "Copy as Markdown" to the page export menu to copy entire pages as Markdown. * **Behavior** * Export flow now returns success/failure so the UI shows a dedicated success or error notification for clipboard exports. * **Localization** * Added strings for "Copy as Markdown" and "Copied as Markdown". --------- Co-authored-by: Whitewater Co-authored-by: lawvs <18554747+lawvs@users.noreply.github.com> --- .../editor-config/toolbar/index.ts | 104 +++++++++++++++++- .../hooks/affine/use-export-page.ts | 89 +++++++++++---- .../page-list/operation-menu-items/export.tsx | 16 ++- .../i18n/src/i18n-completenesses.json | 2 +- packages/frontend/i18n/src/i18n.gen.ts | 8 ++ packages/frontend/i18n/src/resources/en.json | 2 + .../e2e/blocksuite/toolbar.spec.ts | 56 ++++++++++ 7 files changed, 254 insertions(+), 23 deletions(-) diff --git a/packages/frontend/core/src/blocksuite/view-extensions/editor-config/toolbar/index.ts b/packages/frontend/core/src/blocksuite/view-extensions/editor-config/toolbar/index.ts index 4884b88ad..1351cc524 100644 --- a/packages/frontend/core/src/blocksuite/view-extensions/editor-config/toolbar/index.ts +++ b/packages/frontend/core/src/blocksuite/view-extensions/editor-config/toolbar/index.ts @@ -44,7 +44,10 @@ import { EmbedSyncedDocModel, SurfaceRefBlockSchema, } from '@blocksuite/affine/model'; -import { getSelectedModelsCommand } from '@blocksuite/affine/shared/commands'; +import { + draftSelectedModelsCommand, + getSelectedModelsCommand, +} from '@blocksuite/affine/shared/commands'; import { ImageSelection } from '@blocksuite/affine/shared/selection'; import { ActionPlacement, @@ -71,11 +74,12 @@ import { GfxBlockElementModel, GfxPrimitiveElementModel, } from '@blocksuite/affine/std/gfx'; -import type { ExtensionType } from '@blocksuite/affine/store'; +import { type ExtensionType, Slice } from '@blocksuite/affine/store'; import { CopyAsImgaeIcon, CopyIcon, EditIcon, + ExportToMarkdownIcon, LinkIcon, OpenInNewIcon, } from '@blocksuite/icons/lit'; @@ -87,6 +91,7 @@ import { keyed } from 'lit/directives/keyed.js'; import { repeat } from 'lit/directives/repeat.js'; import { styleMap } from 'lit/directives/style-map.js'; +import { getContentFromSlice } from '../../../utils/markdown-utils'; import { openDocActions } from '../../editor-view/open-doc'; import { copyAsImage, createCopyAsPngMenuItem } from './copy-as-image'; @@ -120,6 +125,12 @@ export function createToolbarMoreMenuConfig(framework: FrameworkProvider) { 0, createCopyAsPngMenuItem(framework) ); + + clipboardGroup.items.splice( + copyIndex + 1, + 0, + createCopyAsMarkdownMenuItem(framework) + ); } return groups; @@ -209,6 +220,55 @@ function createCopyLinkToBlockMenuItem( }; } +function createCopyAsMarkdownMenuItem( + _framework: FrameworkProvider, + item = { + icon: ExportToMarkdownIcon({ width: '20', height: '20' }), + label: I18n['com.affine.export.copy-markdown'](), + type: 'copy-as-markdown', + when: (ctx: MenuContext) => { + if (ctx.isEmpty()) return false; + return true; + }, + } +) { + return { + ...item, + action: (ctx: MenuContext) => { + void (async () => { + const { std } = ctx; + const [ok, commandCtx] = std.command + .chain() + .pipe(getSelectedModelsCommand) + .pipe(draftSelectedModelsCommand) + .run(); + const draftedModels = commandCtx.draftedModels; + if (!ok || !draftedModels) { + return; + } + + const models = await draftedModels; + if (models.length > 0) { + const slice = Slice.fromModels(std.store, models); + const markdown = await getContentFromSlice( + std.host, + slice, + 'markdown' + ); + if (markdown) { + await navigator.clipboard.writeText(markdown); + toast(std.host, I18n['com.affine.export.copied-as-markdown']()); + } + } + })() + .catch(console.error) + .finally(() => { + ctx.close(); + }); + }, + }; +} + function createToolbarMoreMenuConfigV2(baseUrl?: string) { return { actions: [ @@ -228,6 +288,46 @@ function createToolbarMoreMenuConfigV2(baseUrl?: string) { copyAsImage(std); }, }, + { + id: 'copy-as-markdown', + label: I18n['com.affine.export.copy-markdown'](), + icon: ExportToMarkdownIcon(), + when: ({ gfx, flags, isPageMode }) => { + if (flags.isHovering()) return false; + if (isPageMode) return true; + return gfx.selection.selectedElements.length > 0; + }, + run: ({ std }) => { + void (async () => { + const [ok, ctx] = std.command + .chain() + .pipe(getSelectedModelsCommand) + .pipe(draftSelectedModelsCommand) + .run(); + const draftedModels = ctx.draftedModels; + if (!ok || !draftedModels) { + return; + } + + const models = await draftedModels; + if (models.length > 0) { + const slice = Slice.fromModels(std.store, models); + const markdown = await getContentFromSlice( + std.host, + slice, + 'markdown' + ); + if (markdown) { + await navigator.clipboard.writeText(markdown); + toast( + std.host, + I18n['com.affine.export.copied-as-markdown']() + ); + } + } + })().catch(console.error); + }, + }, { id: 'copy-link-to-block', label: 'Copy link to block', diff --git a/packages/frontend/core/src/components/hooks/affine/use-export-page.ts b/packages/frontend/core/src/components/hooks/affine/use-export-page.ts index 4baaf3ad3..1e10015bf 100644 --- a/packages/frontend/core/src/components/hooks/affine/use-export-page.ts +++ b/packages/frontend/core/src/components/hooks/affine/use-export-page.ts @@ -38,6 +38,7 @@ type ExportType = | 'html' | 'png' | 'markdown' + | 'copy-markdown' | 'snapshot' | 'pdf-export'; @@ -63,12 +64,8 @@ interface AdapterConfig { indexFileName: string; } -async function exportDoc( - doc: Store, - std: BlockStdScope, - config: AdapterConfig -) { - const transformer = new Transformer({ +function createTransformer(doc: Store) { + return new Transformer({ schema: getAFFiNEWorkspaceSchema(), blobCRUD: doc.workspace.blobSync, docCRUD: { @@ -82,6 +79,14 @@ async function exportDoc( embedSyncedDocMiddleware('content'), ], }); +} + +async function exportDoc( + doc: Store, + std: BlockStdScope, + config: AdapterConfig +) { + const transformer = createTransformer(doc); const adapterFactory = std.store.provider.get(config.identifier); const adapter = adapterFactory.get(transformer); @@ -141,11 +146,36 @@ async function exportToMarkdown(doc: Store, std?: BlockStdScope) { } } +async function copyAsMarkdown(doc: Store, std?: BlockStdScope) { + if (!std) { + return false; + } + + try { + const transformer = createTransformer(doc); + const adapterFactory = std.store.provider.get( + MarkdownAdapterFactoryIdentifier + ); + const adapter = adapterFactory.get(transformer); + const result = (await adapter.fromDoc(doc)) as AdapterResult; + + if (!result || result.file === undefined) { + return false; + } + + await navigator.clipboard.writeText(result.file); + return true; + } catch (err) { + console.error(err); + return false; + } +} + async function exportHandler({ page, type, editorContainer, -}: ExportHandlerOptions) { +}: ExportHandlerOptions): Promise { const editorRoot = document.querySelector('editor-host'); track.$.sharePanel.$.export({ type, @@ -153,29 +183,35 @@ async function exportHandler({ switch (type) { case 'html': await exportToHtml(page, editorRoot?.std); - return; + return true; case 'markdown': await exportToMarkdown(page, editorRoot?.std); - return; + return true; + case 'copy-markdown': + return await copyAsMarkdown(page, editorRoot?.std); case 'snapshot': await ZipTransformer.exportDocs( page.workspace, getAFFiNEWorkspaceSchema(), [page] ); - return; + return true; case 'pdf': await printToPdf(editorContainer); - return; + return true; case 'png': { - await editorRoot?.std.get(ExportManager).exportPng(); - return; + const std = editorRoot?.std; + if (!std) return false; + await std.get(ExportManager).exportPng(); + return true; } case 'pdf-export': { await PdfTransformer.exportDoc(page); - return; + return true; } } + + return false; } export const useExportPage = () => { @@ -199,15 +235,30 @@ export const useExportPage = () => { key: globalLoadingID, }); try { - await exportHandler({ + const success = await exportHandler({ page: blocksuiteDoc, type, editorContainer: originEditorContainer, }); - notify.success({ - title: t['com.affine.export.success.title'](), - message: t['com.affine.export.success.message'](), - }); + + if (!success) { + notify.error({ + title: t['com.affine.export.error.title'](), + message: t['com.affine.export.error.message'](), + }); + return; + } + + if (type === 'copy-markdown') { + notify.success({ + title: t['com.affine.export.copied-as-markdown'](), + }); + } else { + notify.success({ + title: t['com.affine.export.success.title'](), + message: t['com.affine.export.success.message'](), + }); + } } catch (err) { console.error(err); notify.error({ diff --git a/packages/frontend/core/src/components/page-list/operation-menu-items/export.tsx b/packages/frontend/core/src/components/page-list/operation-menu-items/export.tsx index f81f4d79f..7d1142b4b 100644 --- a/packages/frontend/core/src/components/page-list/operation-menu-items/export.tsx +++ b/packages/frontend/core/src/components/page-list/operation-menu-items/export.tsx @@ -26,7 +26,14 @@ interface ExportMenuItemProps { interface ExportProps { exportHandler: ( - type: 'pdf' | 'html' | 'png' | 'markdown' | 'snapshot' | 'pdf-export' + type: + | 'pdf' + | 'html' + | 'png' + | 'markdown' + | 'copy-markdown' + | 'snapshot' + | 'pdf-export' ) => void; pageMode?: 'page' | 'edgeless'; className?: string; @@ -104,6 +111,13 @@ export const ExportMenuItems = ({ icon={} label={t['Export to Markdown']()} /> + exportHandler('copy-markdown')} + className={className} + type="copy-markdown" + icon={} + label={t['com.affine.export.copy-markdown']()} + /> {pageMode !== 'edgeless' && enable_pdfmake_export && ( exportHandler('pdf-export')} diff --git a/packages/frontend/i18n/src/i18n-completenesses.json b/packages/frontend/i18n/src/i18n-completenesses.json index f50a7a475..d884b6b24 100644 --- a/packages/frontend/i18n/src/i18n-completenesses.json +++ b/packages/frontend/i18n/src/i18n-completenesses.json @@ -21,6 +21,6 @@ "sv-SE": 96, "uk": 96, "ur": 2, - "zh-Hans": 98, + "zh-Hans": 97, "zh-Hant": 96 } diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts index 3ef4afe65..0c432c065 100644 --- a/packages/frontend/i18n/src/i18n.gen.ts +++ b/packages/frontend/i18n/src/i18n.gen.ts @@ -1822,6 +1822,14 @@ export function useAFFiNEI18N(): { * `Image copy failed` */ ["com.affine.copy.asImage.failed"](): string; + /** + * `Copy as Markdown` + */ + ["com.affine.export.copy-markdown"](): string; + /** + * `Copied as Markdown` + */ + ["com.affine.export.copied-as-markdown"](): string; /** * `Cancel` */ diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index b18a39ef7..9ad895564 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -449,6 +449,8 @@ "com.affine.copy.asImage.notAvailable.action": "Download Client", "com.affine.copy.asImage.success": "Image copied", "com.affine.copy.asImage.failed": "Image copy failed", + "com.affine.export.copy-markdown": "Copy as Markdown", + "com.affine.export.copied-as-markdown": "Copied as Markdown", "com.affine.confirmModal.button.cancel": "Cancel", "com.affine.confirmModal.button.ok": "Ok", "com.affine.currentYear": "Current year", diff --git a/tests/affine-local/e2e/blocksuite/toolbar.spec.ts b/tests/affine-local/e2e/blocksuite/toolbar.spec.ts index 6f1794048..ecbf26162 100644 --- a/tests/affine-local/e2e/blocksuite/toolbar.spec.ts +++ b/tests/affine-local/e2e/blocksuite/toolbar.spec.ts @@ -13,6 +13,7 @@ import { import { openHomePage } from '@affine-test/kit/utils/load-page'; import { clickNewPageButton, + type, waitForEmptyEditor, } from '@affine-test/kit/utils/page-logic'; import { expect } from '@playwright/test'; @@ -370,6 +371,61 @@ test('should clear selection when switching doc mode', async ({ page }) => { }); test.describe('Toolbar More Actions', () => { + test('should copy selected text as markdown', async ({ page }) => { + await page.keyboard.press('Enter'); + await page.keyboard.type('toolbar-copy-as-markdown'); + await selectAllByKeyboard(page); + + const toolbar = page.locator('affine-toolbar-widget editor-toolbar'); + await expect(toolbar).toBeVisible(); + + await toolbar.getByLabel('More menu').click(); + await toolbar.getByLabel('Copy as Markdown').click(); + + const clipboardText = await ( + await page.evaluateHandle(() => navigator.clipboard.readText()) + ).jsonValue(); + expect(clipboardText).toContain('toolbar-copy-as-markdown'); + }); + + test('should preserve heading syntax when copying as markdown from toolbar', async ({ + page, + }) => { + await page.keyboard.press('Enter'); + await type(page, '# toolbar-heading'); + await selectAllByKeyboard(page); + + const toolbar = page.locator('affine-toolbar-widget editor-toolbar'); + await expect(toolbar).toBeVisible(); + + await toolbar.getByLabel('More menu').click(); + await toolbar.getByLabel('Copy as Markdown').click(); + + const clipboardText = await ( + await page.evaluateHandle(() => navigator.clipboard.readText()) + ).jsonValue(); + expect(clipboardText).toContain('# toolbar-heading'); + }); + + test('should preserve bulleted list syntax when copying as markdown from toolbar', async ({ + page, + }) => { + await page.keyboard.press('Enter'); + await type(page, '- toolbar-list-item'); + await selectAllByKeyboard(page); + + const toolbar = page.locator('affine-toolbar-widget editor-toolbar'); + await expect(toolbar).toBeVisible(); + + await toolbar.getByLabel('More menu').click(); + await toolbar.getByLabel('Copy as Markdown').click(); + + const clipboardText = await ( + await page.evaluateHandle(() => navigator.clipboard.readText()) + ).jsonValue(); + expect(clipboardText).toMatch(/^\* toolbar-list-item/m); + }); + test('should duplicate block', async ({ page }) => { await page.keyboard.press('Enter');