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

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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".
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Whitewater <me@waterwater.moe>
Co-authored-by: lawvs <18554747+lawvs@users.noreply.github.com>
This commit is contained in:
Saurabh Pardeshi
2026-04-18 18:09:20 +05:30
committed by GitHub
parent f7d0f1d5ae
commit 0009f91d2a
7 changed files with 254 additions and 23 deletions

View File

@@ -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',

View File

@@ -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<boolean> {
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({

View File

@@ -26,7 +26,14 @@ interface ExportMenuItemProps<T> {
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={<ExportToMarkdownIcon />}
label={t['Export to Markdown']()}
/>
<ExportMenuItem
onSelect={() => exportHandler('copy-markdown')}
className={className}
type="copy-markdown"
icon={<ExportToMarkdownIcon />}
label={t['com.affine.export.copy-markdown']()}
/>
{pageMode !== 'edgeless' && enable_pdfmake_export && (
<ExportMenuItem
onSelect={() => exportHandler('pdf-export')}

View File

@@ -21,6 +21,6 @@
"sv-SE": 96,
"uk": 96,
"ur": 2,
"zh-Hans": 98,
"zh-Hans": 97,
"zh-Hant": 96
}

View File

@@ -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`
*/

View File

@@ -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",

View File

@@ -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');