mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-10 05:29:08 +08:00
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:
+102
-2
@@ -44,7 +44,10 @@ import {
|
|||||||
EmbedSyncedDocModel,
|
EmbedSyncedDocModel,
|
||||||
SurfaceRefBlockSchema,
|
SurfaceRefBlockSchema,
|
||||||
} from '@blocksuite/affine/model';
|
} 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 { ImageSelection } from '@blocksuite/affine/shared/selection';
|
||||||
import {
|
import {
|
||||||
ActionPlacement,
|
ActionPlacement,
|
||||||
@@ -71,11 +74,12 @@ import {
|
|||||||
GfxBlockElementModel,
|
GfxBlockElementModel,
|
||||||
GfxPrimitiveElementModel,
|
GfxPrimitiveElementModel,
|
||||||
} from '@blocksuite/affine/std/gfx';
|
} from '@blocksuite/affine/std/gfx';
|
||||||
import type { ExtensionType } from '@blocksuite/affine/store';
|
import { type ExtensionType, Slice } from '@blocksuite/affine/store';
|
||||||
import {
|
import {
|
||||||
CopyAsImgaeIcon,
|
CopyAsImgaeIcon,
|
||||||
CopyIcon,
|
CopyIcon,
|
||||||
EditIcon,
|
EditIcon,
|
||||||
|
ExportToMarkdownIcon,
|
||||||
LinkIcon,
|
LinkIcon,
|
||||||
OpenInNewIcon,
|
OpenInNewIcon,
|
||||||
} from '@blocksuite/icons/lit';
|
} from '@blocksuite/icons/lit';
|
||||||
@@ -87,6 +91,7 @@ import { keyed } from 'lit/directives/keyed.js';
|
|||||||
import { repeat } from 'lit/directives/repeat.js';
|
import { repeat } from 'lit/directives/repeat.js';
|
||||||
import { styleMap } from 'lit/directives/style-map.js';
|
import { styleMap } from 'lit/directives/style-map.js';
|
||||||
|
|
||||||
|
import { getContentFromSlice } from '../../../utils/markdown-utils';
|
||||||
import { openDocActions } from '../../editor-view/open-doc';
|
import { openDocActions } from '../../editor-view/open-doc';
|
||||||
import { copyAsImage, createCopyAsPngMenuItem } from './copy-as-image';
|
import { copyAsImage, createCopyAsPngMenuItem } from './copy-as-image';
|
||||||
|
|
||||||
@@ -120,6 +125,12 @@ export function createToolbarMoreMenuConfig(framework: FrameworkProvider) {
|
|||||||
0,
|
0,
|
||||||
createCopyAsPngMenuItem(framework)
|
createCopyAsPngMenuItem(framework)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
clipboardGroup.items.splice(
|
||||||
|
copyIndex + 1,
|
||||||
|
0,
|
||||||
|
createCopyAsMarkdownMenuItem(framework)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return groups;
|
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) {
|
function createToolbarMoreMenuConfigV2(baseUrl?: string) {
|
||||||
return {
|
return {
|
||||||
actions: [
|
actions: [
|
||||||
@@ -228,6 +288,46 @@ function createToolbarMoreMenuConfigV2(baseUrl?: string) {
|
|||||||
copyAsImage(std);
|
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',
|
id: 'copy-link-to-block',
|
||||||
label: 'Copy link to block',
|
label: 'Copy link to block',
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ type ExportType =
|
|||||||
| 'html'
|
| 'html'
|
||||||
| 'png'
|
| 'png'
|
||||||
| 'markdown'
|
| 'markdown'
|
||||||
|
| 'copy-markdown'
|
||||||
| 'snapshot'
|
| 'snapshot'
|
||||||
| 'pdf-export';
|
| 'pdf-export';
|
||||||
|
|
||||||
@@ -63,12 +64,8 @@ interface AdapterConfig {
|
|||||||
indexFileName: string;
|
indexFileName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function exportDoc(
|
function createTransformer(doc: Store) {
|
||||||
doc: Store,
|
return new Transformer({
|
||||||
std: BlockStdScope,
|
|
||||||
config: AdapterConfig
|
|
||||||
) {
|
|
||||||
const transformer = new Transformer({
|
|
||||||
schema: getAFFiNEWorkspaceSchema(),
|
schema: getAFFiNEWorkspaceSchema(),
|
||||||
blobCRUD: doc.workspace.blobSync,
|
blobCRUD: doc.workspace.blobSync,
|
||||||
docCRUD: {
|
docCRUD: {
|
||||||
@@ -82,6 +79,14 @@ async function exportDoc(
|
|||||||
embedSyncedDocMiddleware('content'),
|
embedSyncedDocMiddleware('content'),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportDoc(
|
||||||
|
doc: Store,
|
||||||
|
std: BlockStdScope,
|
||||||
|
config: AdapterConfig
|
||||||
|
) {
|
||||||
|
const transformer = createTransformer(doc);
|
||||||
|
|
||||||
const adapterFactory = std.store.provider.get(config.identifier);
|
const adapterFactory = std.store.provider.get(config.identifier);
|
||||||
const adapter = adapterFactory.get(transformer);
|
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({
|
async function exportHandler({
|
||||||
page,
|
page,
|
||||||
type,
|
type,
|
||||||
editorContainer,
|
editorContainer,
|
||||||
}: ExportHandlerOptions) {
|
}: ExportHandlerOptions): Promise<boolean> {
|
||||||
const editorRoot = document.querySelector('editor-host');
|
const editorRoot = document.querySelector('editor-host');
|
||||||
track.$.sharePanel.$.export({
|
track.$.sharePanel.$.export({
|
||||||
type,
|
type,
|
||||||
@@ -153,29 +183,35 @@ async function exportHandler({
|
|||||||
switch (type) {
|
switch (type) {
|
||||||
case 'html':
|
case 'html':
|
||||||
await exportToHtml(page, editorRoot?.std);
|
await exportToHtml(page, editorRoot?.std);
|
||||||
return;
|
return true;
|
||||||
case 'markdown':
|
case 'markdown':
|
||||||
await exportToMarkdown(page, editorRoot?.std);
|
await exportToMarkdown(page, editorRoot?.std);
|
||||||
return;
|
return true;
|
||||||
|
case 'copy-markdown':
|
||||||
|
return await copyAsMarkdown(page, editorRoot?.std);
|
||||||
case 'snapshot':
|
case 'snapshot':
|
||||||
await ZipTransformer.exportDocs(
|
await ZipTransformer.exportDocs(
|
||||||
page.workspace,
|
page.workspace,
|
||||||
getAFFiNEWorkspaceSchema(),
|
getAFFiNEWorkspaceSchema(),
|
||||||
[page]
|
[page]
|
||||||
);
|
);
|
||||||
return;
|
return true;
|
||||||
case 'pdf':
|
case 'pdf':
|
||||||
await printToPdf(editorContainer);
|
await printToPdf(editorContainer);
|
||||||
return;
|
return true;
|
||||||
case 'png': {
|
case 'png': {
|
||||||
await editorRoot?.std.get(ExportManager).exportPng();
|
const std = editorRoot?.std;
|
||||||
return;
|
if (!std) return false;
|
||||||
|
await std.get(ExportManager).exportPng();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
case 'pdf-export': {
|
case 'pdf-export': {
|
||||||
await PdfTransformer.exportDoc(page);
|
await PdfTransformer.exportDoc(page);
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useExportPage = () => {
|
export const useExportPage = () => {
|
||||||
@@ -199,15 +235,30 @@ export const useExportPage = () => {
|
|||||||
key: globalLoadingID,
|
key: globalLoadingID,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await exportHandler({
|
const success = await exportHandler({
|
||||||
page: blocksuiteDoc,
|
page: blocksuiteDoc,
|
||||||
type,
|
type,
|
||||||
editorContainer: originEditorContainer,
|
editorContainer: originEditorContainer,
|
||||||
});
|
});
|
||||||
notify.success({
|
|
||||||
title: t['com.affine.export.success.title'](),
|
if (!success) {
|
||||||
message: t['com.affine.export.success.message'](),
|
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) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
notify.error({
|
notify.error({
|
||||||
|
|||||||
@@ -26,7 +26,14 @@ interface ExportMenuItemProps<T> {
|
|||||||
|
|
||||||
interface ExportProps {
|
interface ExportProps {
|
||||||
exportHandler: (
|
exportHandler: (
|
||||||
type: 'pdf' | 'html' | 'png' | 'markdown' | 'snapshot' | 'pdf-export'
|
type:
|
||||||
|
| 'pdf'
|
||||||
|
| 'html'
|
||||||
|
| 'png'
|
||||||
|
| 'markdown'
|
||||||
|
| 'copy-markdown'
|
||||||
|
| 'snapshot'
|
||||||
|
| 'pdf-export'
|
||||||
) => void;
|
) => void;
|
||||||
pageMode?: 'page' | 'edgeless';
|
pageMode?: 'page' | 'edgeless';
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -104,6 +111,13 @@ export const ExportMenuItems = ({
|
|||||||
icon={<ExportToMarkdownIcon />}
|
icon={<ExportToMarkdownIcon />}
|
||||||
label={t['Export to Markdown']()}
|
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 && (
|
{pageMode !== 'edgeless' && enable_pdfmake_export && (
|
||||||
<ExportMenuItem
|
<ExportMenuItem
|
||||||
onSelect={() => exportHandler('pdf-export')}
|
onSelect={() => exportHandler('pdf-export')}
|
||||||
|
|||||||
@@ -21,6 +21,6 @@
|
|||||||
"sv-SE": 96,
|
"sv-SE": 96,
|
||||||
"uk": 96,
|
"uk": 96,
|
||||||
"ur": 2,
|
"ur": 2,
|
||||||
"zh-Hans": 98,
|
"zh-Hans": 97,
|
||||||
"zh-Hant": 96
|
"zh-Hant": 96
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1822,6 +1822,14 @@ export function useAFFiNEI18N(): {
|
|||||||
* `Image copy failed`
|
* `Image copy failed`
|
||||||
*/
|
*/
|
||||||
["com.affine.copy.asImage.failed"](): string;
|
["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`
|
* `Cancel`
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -449,6 +449,8 @@
|
|||||||
"com.affine.copy.asImage.notAvailable.action": "Download Client",
|
"com.affine.copy.asImage.notAvailable.action": "Download Client",
|
||||||
"com.affine.copy.asImage.success": "Image copied",
|
"com.affine.copy.asImage.success": "Image copied",
|
||||||
"com.affine.copy.asImage.failed": "Image copy failed",
|
"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.cancel": "Cancel",
|
||||||
"com.affine.confirmModal.button.ok": "Ok",
|
"com.affine.confirmModal.button.ok": "Ok",
|
||||||
"com.affine.currentYear": "Current year",
|
"com.affine.currentYear": "Current year",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import { openHomePage } from '@affine-test/kit/utils/load-page';
|
import { openHomePage } from '@affine-test/kit/utils/load-page';
|
||||||
import {
|
import {
|
||||||
clickNewPageButton,
|
clickNewPageButton,
|
||||||
|
type,
|
||||||
waitForEmptyEditor,
|
waitForEmptyEditor,
|
||||||
} from '@affine-test/kit/utils/page-logic';
|
} from '@affine-test/kit/utils/page-logic';
|
||||||
import { expect } from '@playwright/test';
|
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.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 }) => {
|
test('should duplicate block', async ({ page }) => {
|
||||||
await page.keyboard.press('Enter');
|
await page.keyboard.press('Enter');
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user