chore: merge blocksuite source code (#9213)

This commit is contained in:
Mirone
2024-12-20 15:38:06 +08:00
committed by GitHub
parent 2c9ef916f4
commit 30200ff86d
2031 changed files with 238888 additions and 229 deletions
@@ -0,0 +1,506 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import type { Command } from '../command/index.js';
import { CommandManager } from '../command/index.js';
type Command1 = Command<
never,
'commandData1',
{
command1Option?: string;
}
>;
type Command2 = Command<'commandData1', 'commandData2'>;
type Command3 = Command<'commandData1' | 'commandData2', 'commandData3'>;
declare global {
namespace BlockSuite {
interface CommandContext {
commandData1?: string;
commandData2?: string;
commandData3?: string;
}
interface Commands {
command1: Command1;
command2: Command2;
command3: Command3;
command4: Command;
}
}
}
describe('CommandManager', () => {
let std: BlockSuite.Std;
let commandManager: CommandManager;
beforeEach(() => {
// @ts-expect-error FIXME: ts error
std = {};
commandManager = new CommandManager(std);
});
test('can add and execute a command', () => {
const command1: Command = vi.fn((_ctx, next) => next());
const command2: Command = vi.fn((_ctx, _next) => {});
commandManager.add('command1', command1);
commandManager.add('command2', command2);
const [success1] = commandManager.chain().command1().run();
const [success2] = commandManager.chain().command2().run();
expect(command1).toHaveBeenCalled();
expect(command2).toHaveBeenCalled();
expect(success1).toBeTruthy();
expect(success2).toBeFalsy();
});
test('can chain multiple commands', () => {
const command1: Command = vi.fn((_ctx, next) => next());
const command2: Command = vi.fn((_ctx, next) => next());
const command3: Command = vi.fn((_ctx, next) => next());
commandManager.add('command1', command1);
commandManager.add('command2', command2);
commandManager.add('command3', command3);
const [success] = commandManager
.chain()
.command1()
.command2()
.command3()
.run();
expect(command1).toHaveBeenCalled();
expect(command2).toHaveBeenCalled();
expect(command3).toHaveBeenCalled();
expect(success).toBeTruthy();
});
test('skip commands if there is a command failed before them (`next` not executed)', () => {
const command1: Command = vi.fn((_ctx, next) => next());
const command2: Command = vi.fn((_ctx, _next) => {});
const command3: Command = vi.fn((_ctx, next) => next());
commandManager.add('command1', command1);
commandManager.add('command2', command2);
commandManager.add('command3', command3);
const [success] = commandManager
.chain()
.command1()
.command2()
.command3()
.run();
expect(command1).toHaveBeenCalled();
expect(command2).toHaveBeenCalled();
expect(command3).not.toHaveBeenCalled();
expect(success).toBeFalsy();
});
test('can handle command failure', () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const command1: Command = vi.fn((_ctx, next) => next());
const command2: Command = vi.fn((_ctx, _next) => {
throw new Error('command2 failed');
});
const command3: Command = vi.fn((_ctx, next) => next());
commandManager.add('command1', command1);
commandManager.add('command2', command2);
commandManager.add('command3', command3);
const [success] = commandManager
.chain()
.command1()
.command2()
.command3()
.run();
expect(command1).toHaveBeenCalled();
expect(command2).toHaveBeenCalled();
expect(command3).not.toHaveBeenCalled();
expect(success).toBeFalsy();
expect(errorSpy).toHaveBeenCalledWith(new Error('command2 failed'));
});
test('can pass data to command when calling a command', () => {
const command1: Command = vi.fn((_ctx, next) => next());
commandManager.add('command1', command1);
const [success] = commandManager
.chain()
.command1({ command1Option: 'test' })
.run();
expect(command1).toHaveBeenCalledWith(
expect.objectContaining({ command1Option: 'test' }),
expect.any(Function)
);
expect(success).toBeTruthy();
});
test('can add data to the command chain with `with` method', () => {
const command1: Command = vi.fn((_ctx, next) => next());
commandManager.add('command1', command1);
const [success, ctx] = commandManager
.chain()
.with({ commandData1: 'test' })
.command1()
.run();
expect(command1).toHaveBeenCalledWith(
expect.objectContaining({ commandData1: 'test' }),
expect.any(Function)
);
expect(success).toBeTruthy();
expect(ctx.commandData1).toBe('test');
});
test('passes and updates context across commands', () => {
const command1: Command<'std', 'commandData1'> = vi.fn((_ctx, next) =>
next({ commandData1: '123' })
);
const command2: Command<'commandData1'> = vi.fn((ctx, next) => {
expect(ctx.commandData1).toBe('123');
next({ commandData1: '456' });
});
commandManager.add('command1', command1);
commandManager.add('command2', command2);
const [success, ctx] = commandManager.chain().command1().command2().run();
expect(command1).toHaveBeenCalled();
expect(command2).toHaveBeenCalled();
expect(success).toBeTruthy();
expect(ctx.commandData1).toBe('456');
});
test('can execute an inline command', () => {
const inlineCommand: Command = vi.fn((_ctx, next) => next());
const success = commandManager.chain().inline(inlineCommand).run();
expect(inlineCommand).toHaveBeenCalled();
expect(success).toBeTruthy();
});
test('can execute a single command with `exec`', () => {
const command1: Command1 = vi.fn((_ctx, next) =>
next({ commandData1: (_ctx.command1Option ?? '') + '123' })
);
const command2: Command2 = vi.fn((_ctx, next) =>
next({ commandData2: 'cmd2' })
);
const command3: Command3 = vi.fn((_ctx, next) => next());
commandManager.add('command1', command1);
commandManager.add('command2', command2);
commandManager.add('command3', command3);
const result1 = commandManager.exec('command1');
const result2 = commandManager.exec('command1', {
command1Option: 'test',
});
const result3 = commandManager.exec('command2');
const result4 = commandManager.exec('command3');
expect(command1).toHaveBeenCalled();
expect(command2).toHaveBeenCalled();
expect(command3).toHaveBeenCalled();
expect(result1).toEqual({ commandData1: '123', success: true });
expect(result2).toEqual({ commandData1: 'test123', success: true });
expect(result3).toEqual({ commandData2: 'cmd2', success: true });
expect(result4).toEqual({ success: true });
});
test('should not continue with the rest of the chain if all commands in `try` fail', () => {
const command1: Command<never, 'commandData1'> = vi.fn((_ctx, _next) => {});
const command2: Command = vi.fn((_ctx, _next) => {});
const command3: Command = vi.fn((_ctx, next) => next());
commandManager.add('command1', command1);
commandManager.add('command2', command2);
commandManager.add('command3', command3);
const [success] = commandManager
.chain()
.try(cmd => [cmd.command1(), cmd.command2()])
.command3()
.run();
expect(command1).toHaveBeenCalled();
expect(command2).toHaveBeenCalled();
expect(command3).not.toHaveBeenCalled();
expect(success).toBeFalsy();
});
test('should not re-execute previous commands in the chain before `try`', () => {
const command1: Command1 = vi.fn((_ctx, next) =>
next({ commandData1: '123' })
);
const command2: Command = vi.fn((_ctx, _next) => {});
const command3: Command = vi.fn((_ctx, next) => next());
commandManager.add('command1', command1);
commandManager.add('command2', command2);
commandManager.add('command3', command3);
const [success, ctx] = commandManager
.chain()
.command1()
.try(cmd => [cmd.command2(), cmd.command3()])
.run();
expect(command1).toHaveBeenCalledTimes(1);
expect(command2).toHaveBeenCalled();
expect(command3).toHaveBeenCalled();
expect(success).toBeTruthy();
expect(ctx.commandData1).toBe('123');
});
test('should continue with the rest of the chain if one command in `try` succeeds', () => {
const command1: Command1 = vi.fn((_ctx, _next) => {});
const command2: Command2 = vi.fn((_ctx, next) =>
next({ commandData2: '123' })
);
const command3: Command3 = vi.fn((_ctx, next) =>
next({ commandData3: '456' })
);
commandManager.add('command1', command1);
commandManager.add('command2', command2);
commandManager.add('command3', command3);
const [success, ctx] = commandManager
.chain()
.try(cmd => [cmd.command1(), cmd.command2()])
.command3()
.run();
expect(command1).toHaveBeenCalled();
expect(command2).toHaveBeenCalled();
expect(command3).toHaveBeenCalled();
expect(success).toBeTruthy();
expect(ctx.commandData1).toBeUndefined();
expect(ctx.commandData2).toBe('123');
expect(ctx.commandData3).toBe('456');
});
test('should not execute any further commands in `try` after one succeeds', () => {
const command1: Command1 = vi.fn((_ctx, next) =>
next({ commandData1: '123' })
);
const command2: Command2 = vi.fn((_ctx, next) =>
next({ commandData2: '456' })
);
commandManager.add('command1', command1);
commandManager.add('command2', command2);
const [success, ctx] = commandManager
.chain()
.try(cmd => [cmd.command1(), cmd.command2()])
.run();
expect(command1).toHaveBeenCalled();
expect(command2).not.toHaveBeenCalled();
expect(success).toBeTruthy();
expect(ctx.commandData1).toBe('123');
expect(ctx.commandData2).toBeUndefined();
});
test('should pass context correctly in `try` when a command succeeds', () => {
const command1: Command = vi.fn((_ctx, next) =>
next({ commandData1: 'fromCommand1', commandData2: 'fromCommand1' })
);
const command2: Command<'commandData1' | 'commandData2'> = vi.fn(
(ctx, next) => {
expect(ctx.commandData1).toBe('fromCommand1');
expect(ctx.commandData2).toBe('fromCommand1');
// override commandData2
next({ commandData2: 'fromCommand2' });
}
);
const command3: Command<'commandData1' | 'commandData2'> = vi.fn(
(ctx, next) => {
expect(ctx.commandData1).toBe('fromCommand1');
expect(ctx.commandData2).toBe('fromCommand2');
next();
}
);
commandManager.add('command1', command1);
commandManager.add('command2', command2);
commandManager.add('command3', command3);
const [success] = commandManager
.chain()
.command1()
.try(cmd => [cmd.command2()])
.command3()
.run();
expect(command1).toHaveBeenCalled();
expect(command2).toHaveBeenCalled();
expect(command3).toHaveBeenCalled();
expect(success).toBeTruthy();
});
test('should continue with the rest of the chain if at least one command in `tryAll` succeeds', () => {
const command1: Command = vi.fn((_ctx, _next) => {});
const command2: Command = vi.fn((_ctx, next) => next());
const command3: Command = vi.fn((_ctx, next) => next());
commandManager.add('command1', command1);
commandManager.add('command2', command2);
commandManager.add('command3', command3);
const [success] = commandManager
.chain()
.tryAll(cmd => [cmd.command1(), cmd.command2()])
.command3()
.run();
expect(command1).toHaveBeenCalled();
expect(command2).toHaveBeenCalled();
expect(command3).toHaveBeenCalled();
expect(success).toBeTruthy();
});
test('should execute all commands in `tryAll` even if one has already succeeded', () => {
const command1: Command1 = vi.fn((_ctx, next) =>
next({ commandData1: '123' })
);
const command2: Command2 = vi.fn((_ctx, next) =>
next({ commandData2: '456' })
);
const command3: Command3 = vi.fn((_ctx, next) =>
next({ commandData3: '789' })
);
commandManager.add('command1', command1);
commandManager.add('command2', command2);
commandManager.add('command3', command3);
const [success, ctx] = commandManager
.chain()
.tryAll(cmd => [cmd.command1(), cmd.command2(), cmd.command3()])
.run();
expect(command1).toHaveBeenCalled();
expect(command2).toHaveBeenCalled();
expect(command3).toHaveBeenCalled();
expect(ctx.commandData1).toBe('123');
expect(ctx.commandData2).toBe('456');
expect(ctx.commandData3).toBe('789');
expect(success).toBeTruthy();
});
test('should not continue with the rest of the chain if all commands in `tryAll` fail', () => {
const command1: Command1 = vi.fn((_ctx, _next) => {});
const command2: Command2 = vi.fn((_ctx, _next) => {});
const command3: Command3 = vi.fn((_ctx, next) =>
next({ commandData3: '123' })
);
commandManager.add('command1', command1);
commandManager.add('command2', command2);
commandManager.add('command3', command3);
const [success, ctx] = commandManager
.chain()
.tryAll(cmd => [cmd.command1(), cmd.command2()])
.command3()
.run();
expect(command1).toHaveBeenCalled();
expect(command2).toHaveBeenCalled();
expect(command3).not.toHaveBeenCalled();
expect(ctx.commandData3).toBeUndefined();
expect(success).toBeFalsy();
});
test('should pass context correctly in `tryAll` when at least one command succeeds', () => {
const command1: Command = vi.fn((_ctx, next) =>
next({ commandData1: 'fromCommand1' })
);
const command2: Command<'commandData1'> = vi.fn((ctx, next) => {
expect(ctx.commandData1).toBe('fromCommand1');
// override commandData1
next({ commandData1: 'fromCommand2', commandData2: 'fromCommand2' });
});
const command3: Command<'commandData1' | 'commandData2'> = vi.fn(
(ctx, next) => {
expect(ctx.commandData1).toBe('fromCommand2');
expect(ctx.commandData2).toBe('fromCommand2');
next({
// override commandData2
commandData2: 'fromCommand3',
commandData3: 'fromCommand3',
});
}
);
const command4: Command<'commandData1' | 'commandData2' | 'commandData3'> =
vi.fn((ctx, next) => {
expect(ctx.commandData1).toBe('fromCommand2');
expect(ctx.commandData2).toBe('fromCommand3');
expect(ctx.commandData3).toBe('fromCommand3');
next();
});
commandManager.add('command1', command1);
commandManager.add('command2', command2);
commandManager.add('command3', command3);
commandManager.add('command4', command4);
const [success, ctx] = commandManager
.chain()
.command1()
.tryAll(cmd => [cmd.command2(), cmd.command3()])
.command4()
.run();
expect(command1).toHaveBeenCalled();
expect(command2).toHaveBeenCalled();
expect(command3).toHaveBeenCalled();
expect(command4).toHaveBeenCalled();
expect(success).toBeTruthy();
expect(ctx.commandData1).toBe('fromCommand2');
expect(ctx.commandData2).toBe('fromCommand3');
expect(ctx.commandData3).toBe('fromCommand3');
});
test('should not re-execute commands before `tryAll` after executing `tryAll`', () => {
const command1: Command = vi.fn((_ctx, next) => next());
const command2: Command = vi.fn((_ctx, next) => next());
const command3: Command = vi.fn((_ctx, _next) => {});
const command4: Command = vi.fn((_ctx, next) => next());
commandManager.add('command1', command1);
commandManager.add('command2', command2);
commandManager.add('command3', command3);
commandManager.add('command4', command4);
const [success] = commandManager
.chain()
.command1()
.tryAll(cmd => [cmd.command2(), cmd.command3()])
.command4()
.run();
expect(command1).toHaveBeenCalledTimes(1);
expect(command2).toHaveBeenCalled();
expect(command3).toHaveBeenCalled();
expect(command4).toHaveBeenCalled();
expect(success).toBeTruthy();
});
});
@@ -0,0 +1,56 @@
import { DocCollection, IdGeneratorType, Schema } from '@blocksuite/store';
import { describe, expect, test } from 'vitest';
import { effects } from '../effects.js';
import { TestEditorContainer } from './test-editor.js';
import {
type HeadingBlockModel,
HeadingBlockSchema,
NoteBlockSchema,
RootBlockSchema,
} from './test-schema.js';
import { testSpecs } from './test-spec.js';
effects();
function createTestOptions() {
const idGenerator = IdGeneratorType.AutoIncrement;
const schema = new Schema();
schema.register([RootBlockSchema, NoteBlockSchema, HeadingBlockSchema]);
return { id: 'test-collection', idGenerator, schema };
}
function wait(time: number) {
return new Promise(resolve => setTimeout(resolve, time));
}
describe('editor host', () => {
test('editor host should rerender model when view changes', async () => {
const collection = new DocCollection(createTestOptions());
collection.meta.initialize();
const doc = collection.createDoc({ id: 'home' });
doc.load();
const rootId = doc.addBlock('test:page');
const noteId = doc.addBlock('test:note', {}, rootId);
const headingId = doc.addBlock('test:heading', { type: 'h1' }, noteId);
const headingBlock = doc.getBlock(headingId)!;
const editorContainer = new TestEditorContainer();
editorContainer.doc = doc;
editorContainer.specs = testSpecs;
document.body.append(editorContainer);
await wait(50);
let headingElm = editorContainer.std.view.getBlock(headingId);
expect(headingElm!.tagName).toBe('TEST-H1-BLOCK');
(headingBlock.model as HeadingBlockModel).type = 'h2';
await wait(50);
headingElm = editorContainer.std.view.getBlock(headingId);
expect(headingElm!.tagName).toBe('TEST-H2-BLOCK');
});
});
@@ -0,0 +1,65 @@
import rehypeParse from 'rehype-parse';
import { unified } from 'unified';
import { describe, expect, test } from 'vitest';
import { onlyContainImgElement } from '../clipboard/index.js';
describe('only contains img elements', () => {
test('normal with head', () => {
const htmlAst = unified().use(rehypeParse).parse(`<html>
<head></head>
<body>
<!--StartFragment--><img src="https://files.slack.com/deadbeef.png" alt="image.png"/><!--EndFragment-->
</body>
</html>`);
const isImgOnly =
htmlAst.children.map(onlyContainImgElement).reduce((a, b) => {
if (a === 'no' || b === 'no') {
return 'no';
}
if (a === 'maybe' && b === 'maybe') {
return 'maybe';
}
return 'yes';
}, 'maybe') === 'yes';
expect(isImgOnly).toBe(true);
});
test('normal without head', () => {
const htmlAst = unified().use(rehypeParse).parse(`<html>
<body>
<!--StartFragment--><img src="https://files.slack.com/deadbeef.png" alt="image.png"/><!--EndFragment-->
</body>
</html>`);
const isImgOnly =
htmlAst.children.map(onlyContainImgElement).reduce((a, b) => {
if (a === 'no' || b === 'no') {
return 'no';
}
if (a === 'maybe' && b === 'maybe') {
return 'maybe';
}
return 'yes';
}, 'maybe') === 'yes';
expect(isImgOnly).toBe(true);
});
test('contain spans', () => {
const htmlAst = unified().use(rehypeParse).parse(`<html>
<body>
<!--StartFragment--><img src="https://files.slack.com/deadbeef.png" alt="image.png"/><span></span><!--EndFragment-->
</body>
</html>`);
const isImgOnly =
htmlAst.children.map(onlyContainImgElement).reduce((a, b) => {
if (a === 'no' || b === 'no') {
return 'no';
}
if (a === 'maybe' && b === 'maybe') {
return 'maybe';
}
return 'yes';
}, 'maybe') === 'yes';
expect(isImgOnly).toBe(false);
});
});
@@ -0,0 +1,41 @@
import { html } from 'lit';
import { customElement } from 'lit/decorators.js';
import { BlockComponent } from '../view/index.js';
import type {
HeadingBlockModel,
NoteBlockModel,
RootBlockModel,
} from './test-schema.js';
@customElement('test-root-block')
export class RootBlockComponent extends BlockComponent<RootBlockModel> {
override renderBlock() {
return html`
<div class="test-root-block">${this.renderChildren(this.model)}</div>
`;
}
}
@customElement('test-note-block')
export class NoteBlockComponent extends BlockComponent<NoteBlockModel> {
override renderBlock() {
return html`
<div class="test-note-block">${this.renderChildren(this.model)}</div>
`;
}
}
@customElement('test-h1-block')
export class HeadingH1BlockComponent extends BlockComponent<HeadingBlockModel> {
override renderBlock() {
return html` <div class="test-heading-block h1">${this.model.text}</div> `;
}
}
@customElement('test-h2-block')
export class HeadingH2BlockComponent extends BlockComponent<HeadingBlockModel> {
override renderBlock() {
return html` <div class="test-heading-block h2">${this.model.text}</div> `;
}
}
@@ -0,0 +1,39 @@
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
import type { Doc } from '@blocksuite/store';
import { html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import type { ExtensionType } from '../extension/index.js';
import { BlockStdScope } from '../scope/index.js';
import { ShadowlessElement } from '../view/index.js';
@customElement('test-editor-container')
export class TestEditorContainer extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
private _std!: BlockStdScope;
get std() {
return this._std;
}
override connectedCallback() {
super.connectedCallback();
this._std = new BlockStdScope({
doc: this.doc,
extensions: this.specs,
});
}
protected override render() {
return html` <div class="test-editor-container">
${this._std.render()}
</div>`;
}
@property({ attribute: false })
accessor doc!: Doc;
@property({ attribute: false })
accessor specs: ExtensionType[] = [];
}
@@ -0,0 +1,56 @@
import { defineBlockSchema, type SchemaToModel } from '@blocksuite/store';
export const RootBlockSchema = defineBlockSchema({
flavour: 'test:page',
props: internal => ({
title: internal.Text(),
count: 0,
style: {} as Record<string, unknown>,
items: [] as unknown[],
}),
metadata: {
version: 2,
role: 'root',
children: ['test:note'],
},
});
export type RootBlockModel = SchemaToModel<typeof RootBlockSchema>;
export const NoteBlockSchema = defineBlockSchema({
flavour: 'test:note',
props: () => ({}),
metadata: {
version: 1,
role: 'hub',
parent: ['test:page'],
children: ['test:heading'],
},
});
export type NoteBlockModel = SchemaToModel<typeof NoteBlockSchema>;
export const HeadingBlockSchema = defineBlockSchema({
flavour: 'test:heading',
props: internal => ({
type: 'h1',
text: internal.Text(),
}),
metadata: {
version: 1,
role: 'content',
parent: ['test:note'],
},
});
export type HeadingBlockModel = SchemaToModel<typeof HeadingBlockSchema>;
declare global {
namespace BlockSuite {
interface BlockModels {
'test:page': RootBlockModel;
'test:note': NoteBlockModel;
'test:heading': HeadingBlockModel;
}
}
}
@@ -0,0 +1,22 @@
import './test-block.js';
import { literal } from 'lit/static-html.js';
import { BlockViewExtension, type ExtensionType } from '../extension/index.js';
import type { HeadingBlockModel } from './test-schema.js';
export const testSpecs: ExtensionType[] = [
BlockViewExtension('test:page', literal`test-root-block`),
BlockViewExtension('test:note', literal`test-note-block`),
BlockViewExtension('test:heading', model => {
const h = (model as HeadingBlockModel).type$.value;
if (h === 'h1') {
return literal`test-h1-block`;
}
return literal`test-h2-block`;
}),
];