mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-23 04:04:27 +08:00
fix(editor): add code block clipboard extension (#11731)
Close [BS-3109](https://linear.app/affine-design/issue/BS-3109/code-block-不支援-markdown-語法)
This commit is contained in:
@@ -0,0 +1,173 @@
|
|||||||
|
import { deleteTextCommand } from '@blocksuite/affine-inline-preset';
|
||||||
|
import {
|
||||||
|
HtmlAdapter,
|
||||||
|
pasteMiddleware,
|
||||||
|
PlainTextAdapter,
|
||||||
|
} from '@blocksuite/affine-shared/adapters';
|
||||||
|
import {
|
||||||
|
getBlockIndexCommand,
|
||||||
|
getBlockSelectionsCommand,
|
||||||
|
getTextSelectionCommand,
|
||||||
|
} from '@blocksuite/affine-shared/commands';
|
||||||
|
import { type Container, createIdentifier } from '@blocksuite/global/di';
|
||||||
|
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||||
|
import {
|
||||||
|
type BlockStdScope,
|
||||||
|
Clipboard,
|
||||||
|
type ClipboardAdapterConfig,
|
||||||
|
LifeCycleWatcher,
|
||||||
|
LifeCycleWatcherIdentifier,
|
||||||
|
StdIdentifier,
|
||||||
|
type UIEventHandler,
|
||||||
|
} from '@blocksuite/std';
|
||||||
|
import type { ExtensionType } from '@blocksuite/store';
|
||||||
|
|
||||||
|
export const CodeClipboardAdapterConfigIdentifier =
|
||||||
|
createIdentifier<ClipboardAdapterConfig>('code-clipboard-adapter-config');
|
||||||
|
|
||||||
|
export function CodeClipboardAdapterConfigExtension(
|
||||||
|
config: ClipboardAdapterConfig
|
||||||
|
): ExtensionType {
|
||||||
|
return {
|
||||||
|
setup: di => {
|
||||||
|
di.addImpl(
|
||||||
|
CodeClipboardAdapterConfigIdentifier(config.mimeType),
|
||||||
|
() => config
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlainTextClipboardConfig = CodeClipboardAdapterConfigExtension({
|
||||||
|
mimeType: 'text/plain',
|
||||||
|
adapter: PlainTextAdapter,
|
||||||
|
priority: 90,
|
||||||
|
});
|
||||||
|
|
||||||
|
const HtmlClipboardConfig = CodeClipboardAdapterConfigExtension({
|
||||||
|
mimeType: 'text/html',
|
||||||
|
adapter: HtmlAdapter,
|
||||||
|
priority: 80,
|
||||||
|
});
|
||||||
|
|
||||||
|
export class CodeBlockClipboard extends Clipboard {
|
||||||
|
static override readonly key = 'code-block-clipboard';
|
||||||
|
|
||||||
|
override get _adapters() {
|
||||||
|
const adapterConfigs = this.std.provider.getAll(
|
||||||
|
CodeClipboardAdapterConfigIdentifier
|
||||||
|
);
|
||||||
|
return Array.from(adapterConfigs.values());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CodeBlockClipboardController extends LifeCycleWatcher {
|
||||||
|
static override key = 'code-block-clipboard-controller';
|
||||||
|
|
||||||
|
private readonly _disposables = new DisposableGroup();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
std: BlockStdScope,
|
||||||
|
readonly clipboard: CodeBlockClipboard
|
||||||
|
) {
|
||||||
|
super(std);
|
||||||
|
}
|
||||||
|
|
||||||
|
static override setup(di: Container) {
|
||||||
|
di.add(
|
||||||
|
this as unknown as {
|
||||||
|
new (
|
||||||
|
std: BlockStdScope,
|
||||||
|
clipboard: CodeBlockClipboard
|
||||||
|
): CodeBlockClipboardController;
|
||||||
|
},
|
||||||
|
[StdIdentifier, CodeBlockClipboard]
|
||||||
|
);
|
||||||
|
di.addImpl(LifeCycleWatcherIdentifier(this.key), provider =>
|
||||||
|
provider.get(this)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _init = () => {
|
||||||
|
const paste = pasteMiddleware(this.std);
|
||||||
|
this.clipboard.use(paste);
|
||||||
|
|
||||||
|
this._disposables.add({
|
||||||
|
dispose: () => {
|
||||||
|
this.clipboard.unuse(paste);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
onPaste: UIEventHandler = ctx => {
|
||||||
|
const e = ctx.get('clipboardState').raw;
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
this.std.store.captureSync();
|
||||||
|
this.std.command
|
||||||
|
.chain()
|
||||||
|
.try(cmd => [
|
||||||
|
cmd.pipe(getTextSelectionCommand).pipe((ctx, next) => {
|
||||||
|
const textSelection = ctx.currentTextSelection;
|
||||||
|
if (!textSelection) return;
|
||||||
|
const end = textSelection.to ?? textSelection.from;
|
||||||
|
next({ currentSelectionPath: end.blockId });
|
||||||
|
}),
|
||||||
|
cmd.pipe(getBlockSelectionsCommand).pipe((ctx, next) => {
|
||||||
|
const currentBlockSelections = ctx.currentBlockSelections;
|
||||||
|
if (!currentBlockSelections) return;
|
||||||
|
const blockSelection = currentBlockSelections.at(-1);
|
||||||
|
if (!blockSelection) return;
|
||||||
|
next({ currentSelectionPath: blockSelection.blockId });
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
.pipe(getBlockIndexCommand)
|
||||||
|
.try(cmd => [cmd.pipe(getTextSelectionCommand).pipe(deleteTextCommand)])
|
||||||
|
.pipe((ctx, next) => {
|
||||||
|
if (!ctx.parentBlock) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.clipboard
|
||||||
|
.paste(
|
||||||
|
e,
|
||||||
|
this.std.store,
|
||||||
|
ctx.parentBlock.model.id,
|
||||||
|
ctx.blockIndex ? ctx.blockIndex + 1 : 1
|
||||||
|
)
|
||||||
|
.catch(console.error);
|
||||||
|
|
||||||
|
return next();
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
override mounted() {
|
||||||
|
this._init();
|
||||||
|
|
||||||
|
// add paste event listener for code block
|
||||||
|
const subscription = this.std.view.viewUpdated.subscribe(
|
||||||
|
({ type, method, view }) => {
|
||||||
|
if (type !== 'block' || view.model.flavour !== 'affine:code') return;
|
||||||
|
|
||||||
|
if (method === 'add') {
|
||||||
|
view.handleEvent('paste', this.onPaste);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
this._disposables.add(subscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
override unmounted() {
|
||||||
|
this._disposables.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCodeClipboardExtensions(): ExtensionType[] {
|
||||||
|
return [
|
||||||
|
PlainTextClipboardConfig,
|
||||||
|
HtmlClipboardConfig,
|
||||||
|
CodeBlockClipboard,
|
||||||
|
CodeBlockClipboardController,
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import type { ExtensionType } from '@blocksuite/store';
|
|||||||
import { literal, unsafeStatic } from 'lit/static-html.js';
|
import { literal, unsafeStatic } from 'lit/static-html.js';
|
||||||
|
|
||||||
import { CodeBlockAdapterExtensions } from './adapters/extension.js';
|
import { CodeBlockAdapterExtensions } from './adapters/extension.js';
|
||||||
|
import { getCodeClipboardExtensions } from './clipboard/index.js';
|
||||||
import {
|
import {
|
||||||
CodeBlockInlineManagerExtension,
|
CodeBlockInlineManagerExtension,
|
||||||
CodeBlockUnitSpecExtension,
|
CodeBlockUnitSpecExtension,
|
||||||
@@ -33,4 +34,5 @@ export const CodeBlockSpec: ExtensionType[] = [
|
|||||||
CodeBlockAdapterExtensions,
|
CodeBlockAdapterExtensions,
|
||||||
SlashMenuConfigExtension('affine:code', codeSlashMenuConfig),
|
SlashMenuConfigExtension('affine:code', codeSlashMenuConfig),
|
||||||
CodeKeymapExtension,
|
CodeKeymapExtension,
|
||||||
|
...getCodeClipboardExtensions(),
|
||||||
].flat();
|
].flat();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from './adapters';
|
export * from './adapters';
|
||||||
|
export * from './clipboard';
|
||||||
export * from './code-block';
|
export * from './code-block';
|
||||||
export * from './code-block-config';
|
export * from './code-block-config';
|
||||||
export * from './code-block-spec';
|
export * from './code-block-spec';
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ const NotionClipboardConfig = ClipboardAdapterConfigExtension({
|
|||||||
priority: 95,
|
priority: 95,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const HtmlClipboardConfig = ClipboardAdapterConfigExtension({
|
||||||
|
mimeType: 'text/html',
|
||||||
|
adapter: HtmlAdapter,
|
||||||
|
priority: 90,
|
||||||
|
});
|
||||||
|
|
||||||
const imageClipboardConfigs = [
|
const imageClipboardConfigs = [
|
||||||
'image/apng',
|
'image/apng',
|
||||||
'image/avif',
|
'image/avif',
|
||||||
@@ -46,20 +52,14 @@ const imageClipboardConfigs = [
|
|||||||
return ClipboardAdapterConfigExtension({
|
return ClipboardAdapterConfigExtension({
|
||||||
mimeType,
|
mimeType,
|
||||||
adapter: ImageAdapter,
|
adapter: ImageAdapter,
|
||||||
priority: 85,
|
priority: 80,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const PlainTextClipboardConfig = ClipboardAdapterConfigExtension({
|
const PlainTextClipboardConfig = ClipboardAdapterConfigExtension({
|
||||||
mimeType: 'text/plain',
|
mimeType: 'text/plain',
|
||||||
adapter: MixTextAdapter,
|
adapter: MixTextAdapter,
|
||||||
priority: 80,
|
priority: 70,
|
||||||
});
|
|
||||||
|
|
||||||
const HtmlClipboardConfig = ClipboardAdapterConfigExtension({
|
|
||||||
mimeType: 'text/html',
|
|
||||||
adapter: HtmlAdapter,
|
|
||||||
priority: 75,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const AttachmentClipboardConfig = ClipboardAdapterConfigExtension({
|
const AttachmentClipboardConfig = ClipboardAdapterConfigExtension({
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ import { ClipboardAdapterConfigIdentifier } from './clipboard-adapter.js';
|
|||||||
import { onlyContainImgElement } from './utils.js';
|
import { onlyContainImgElement } from './utils.js';
|
||||||
|
|
||||||
export class Clipboard extends LifeCycleWatcher {
|
export class Clipboard extends LifeCycleWatcher {
|
||||||
static override readonly key = 'clipboard';
|
static override key = 'clipboard';
|
||||||
|
|
||||||
private get _adapters() {
|
protected get _adapters() {
|
||||||
const adapterConfigs = this.std.provider.getAll(
|
const adapterConfigs = this.std.provider.getAll(
|
||||||
ClipboardAdapterConfigIdentifier
|
ClipboardAdapterConfigIdentifier
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -397,4 +397,40 @@ test.describe('paste to code block', () => {
|
|||||||
// Verify the pasted code maintains indentation
|
// Verify the pasted code maintains indentation
|
||||||
await verifyCodeBlockContent(page, 0, plainTextCode);
|
await verifyCodeBlockContent(page, 0, plainTextCode);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should paste markdown text as plain text', async ({ page }) => {
|
||||||
|
await pressEnter(page);
|
||||||
|
await addCodeBlock(page);
|
||||||
|
|
||||||
|
const markdownText = [
|
||||||
|
'# Heading 1',
|
||||||
|
'',
|
||||||
|
'## Heading 2 with **bold** and *italic*',
|
||||||
|
'',
|
||||||
|
'### Lists:',
|
||||||
|
'- Item 1',
|
||||||
|
' - Nested item with `inline code`',
|
||||||
|
' - Another nested item',
|
||||||
|
'- Item 2 with [link](https://example.com)',
|
||||||
|
'',
|
||||||
|
'```typescript',
|
||||||
|
'const code = "block";',
|
||||||
|
'console.log(code);',
|
||||||
|
'```',
|
||||||
|
'',
|
||||||
|
'> This is a blockquote with **bold** text',
|
||||||
|
'> Multiple lines in blockquote',
|
||||||
|
'',
|
||||||
|
'| Table | Header |',
|
||||||
|
'|-------|--------|',
|
||||||
|
'| Cell 1 | Cell 2 |',
|
||||||
|
'$This is a inline latex$',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
await pasteContent(page, { 'text/plain': markdownText });
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
|
||||||
|
// Verify the pasted code maintains indentation
|
||||||
|
await verifyCodeBlockContent(page, 0, markdownText);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user